第四章:固件工程结构

好,咱们今天聊聊固件工程的结构。说白了,就是你的代码该怎么放、怎么组织、怎么跑起来。我见过不少新手,代码写得挺溜,但一打开工程目录就懵了——文件散落一地,链接脚本看不懂,Makefile 更是天书。嗯,这章咱们就把这些硬骨头啃下来。

4.1 工程目录组织

先说说目录怎么安排。我个人习惯,一个固件工程至少要有这么几个目录:

project_root/
├── app/          # 应用层代码
├── bsp/          # 板级支持包
├── driver/       # 外设驱动
├── lib/          # 第三方库
├── startup/      # 启动文件
├── linker/       # 链接脚本
├── build/        # 编译输出
└── doc/          # 文档

为什么要这么分?你想想看,如果所有 .c 文件都堆在根目录下,找起来多费劲。我在项目中遇到过,有个同事把 200 多个源文件全放一个文件夹里,结果每次编译都要等半天——因为 Makefile 要扫描所有文件。后来我帮他重新组织,编译时间直接砍了一半。

我的建议: 每个模块建一个子目录,文件名要能反映功能。比如 bsp_uart.c 就比 uart.c 好,因为你知道它是板级相关的。

4.2 启动文件分析

启动文件,这是芯片上电后第一个执行的代码。很多人觉得它神秘,其实说白了就是干三件事:

  1. 设置堆栈指针
  2. 初始化中断向量表
  3. 跳转到 main 函数

以 ARM Cortex-M 为例,启动文件通常长这样:

; 启动文件 startup_stm32f4xx.s
; 定义堆栈大小
Stack_Size      EQU     0x00000400

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

; 中断向量表
                AREA    RESET, DATA, READONLY
                EXPORT  __Vectors
                EXPORT  __Vectors_End
                EXPORT  __Vectors_Size

__Vectors       DCD     __initial_sp               ; 栈顶指针
                DCD     Reset_Handler              ; 复位中断
                DCD     NMI_Handler                ; NMI
                DCD     HardFault_Handler          ; 硬错误
                ; ... 其他中断向量

; 复位中断处理函数
                AREA    |.text|, CODE, READONLY
Reset_Handler   PROC
                EXPORT  Reset_Handler
                ; 初始化 .bss 段
                LDR     R0, =__bss_start
                LDR     R1, =__bss_end
                MOV     R2, #0
bss_loop        CMP     R0, R1
                IT      LT
                STR     R2, [R0], #4
                BLT     bss_loop
                
                ; 跳转到 main
                BL      main
                ENDP
注意: 我曾经踩过一个坑——启动文件里忘了初始化 .bss 段。结果全局变量默认值不是 0,程序跑起来各种诡异。从那以后,我每次写启动文件都会反复检查这段代码。

4.3 链接脚本(.ld)编写

链接脚本,嗯,这玩意儿决定了你的代码和数据怎么放到芯片的存储空间里。说白了,就是告诉链接器:哪段代码放 Flash,哪段数据放 RAM。

一个典型的链接脚本:

/* 链接脚本 example.ld */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text :
    {
        *(.isr_vector)    /* 中断向量表 */
        *(.text)          /* 代码段 */
        *(.rodata)        /* 只读数据 */
        _etext = .;
    } > FLASH

    .data : AT(_etext)
    {
        _sdata = .;
        *(.data)          /* 初始化数据 */
        _edata = .;
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss)           /* 未初始化数据 */
        _ebss = .;
    } > RAM
}

这里有个关键点:.data 段在 Flash 里存储初始值,但运行时在 RAM 里。启动文件里那段复制代码,就是干这个活的。我记得有次调试,发现全局变量值不对,查了半天才发现是链接脚本里 AT 地址写错了。

核心要点:
  • MEMORY 定义芯片的物理存储区域
  • SECTIONS 定义如何分配代码和数据
  • AT 关键字指定加载地址和运行地址

4.4 Makefile 基础

最后说说 Makefile。这东西是固件开发的标配,虽然现在有 IDE 帮你生成,但我觉得还是得懂原理。你想想看,如果哪天 IDE 抽风了,你还能用手搓 Makefile 来编译。

一个基础的 Makefile:

# 编译器设置
CC = arm-none-eabi-gcc
LD = arm-none-eabi-ld
OBJCOPY = arm-none-eabi-objcopy

# 编译选项
CFLAGS = -mcpu=cortex-m4 -mthumb -O2 -Wall
LDFLAGS = -T linker.ld -nostartfiles

# 源文件
SRCS = main.c uart.c gpio.c
OBJS = $(SRCS:.c=.o)

# 目标文件
TARGET = firmware.elf

# 编译规则
all: $(TARGET).bin

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

$(TARGET): $(OBJS)
	$(LD) $(LDFLAGS) $^ -o $@

$(TARGET).bin: $(TARGET)
	$(OBJCOPY) -O binary $< $@

clean:
	rm -f *.o *.elf *.bin

.PHONY: all clean

Makefile 的核心就是依赖关系。比如 main.o 依赖 main.c,如果 main.c 改了,就重新编译 main.o。我在项目中遇到过,有人把依赖写反了,结果改了头文件却不重新编译,调试了一整天才发现。

实用技巧:-MM 选项自动生成头文件依赖,这样就不用手动维护了。命令大概是:gcc -MM main.c,它会输出 main.o: main.c header1.h header2.h

嗯,这一章的内容就这些。工程目录、启动文件、链接脚本、Makefile,这四个东西是固件开发的基石。你把这些搞明白了,后面写代码就顺了。下一章咱们聊聊中断系统和实时性设计,那可是继电保护装置的核心。

本章小结:

  • 目录组织要模块化,每个模块一个子目录
  • 启动文件负责初始化硬件环境,跳转到 main
  • 链接脚本定义代码和数据的存储布局
  • Makefile 管理编译流程,自动化构建

公众号:蓝海资料掘金营,微信deep3321