第3章:固件编译与链接:Makefile/CMake基础、链接脚本解析、固件镜像生成与优化

各位同学,大家好。这一章我们聊聊固件编译和链接。说实话,很多刚入行的朋友觉得写代码才是技术活,编译链接嘛,点个按钮就完事了。嗯,我以前也这么想,直到有一次在产线上,固件死活跑不起来,查了两天才发现是链接脚本里一个地址配错了。从那以后,我再也不敢小看这一步了。

3.1 为什么需要构建系统?

你想想看,一个点钞机固件,少说也有几十个源文件。传感器驱动、电机控制、图像处理、通信协议……每次改一行代码,难道要手动敲一遍gcc命令?

我个人习惯,项目一开始就搭好构建系统。哪怕只有三个文件,也用Makefile管起来。为什么?因为后面一定会膨胀。我见过一个项目,前期图省事,后面文件多了,编译一次要敲20多个参数,还经常漏掉依赖关系。那叫一个痛苦。

构建系统说白了就干三件事:

  • 自动化编译:一条命令搞定所有
  • 增量编译:只编译改过的文件
  • 依赖管理:头文件变了,自动重新编译相关源文件

3.2 Makefile 基础

Makefile 是嵌入式开发的老朋友了。虽然现在CMake更流行,但很多芯片厂商的SDK还是用Makefile。你得看得懂,也得会改。

一个最简单的Makefile长这样:

# 最简单的Makefile
all: firmware.elf

firmware.elf: main.o uart.o gpio.o
	arm-none-eabi-gcc -o firmware.elf main.o uart.o gpio.o

main.o: main.c main.h
	arm-none-eabi-gcc -c main.c

uart.o: uart.c uart.h
	arm-none-eabi-gcc -c uart.c

gpio.o: gpio.c gpio.h
	arm-none-eabi-gcc -c gpio.c

clean:
	rm -f *.o firmware.elf

这里有个坑,我必须要说。很多新手写Makefile,依赖关系写不全。比如头文件改了,但Makefile里没写依赖,结果编译出来的还是旧代码。我曾经在调试一个USB协议栈时,改了头文件里的一个宏定义,Makefile没更新依赖,白白浪费了半天。

我的小技巧:用gcc的-MM选项自动生成依赖关系。Makefile里加一行:
%.d: %.c
	arm-none-eabi-gcc -MM $< > $@
这样头文件变了,自动重新编译。

3.3 CMake 进阶

CMake 比 Makefile 更高级。它不直接编译,而是生成Makefile或其他构建系统的文件。说白了,它是构建系统的构建系统。

为什么我推荐CMake?因为跨平台。同一个CMakeLists.txt,在Windows上生成VS工程,在Linux上生成Makefile,在Mac上生成Xcode工程。对于点钞机这种需要多人协作的项目,太方便了。

一个典型的CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(PointCounterFirmware C ASM)

# 设置目标芯片
set(MCU STM32F407)

# 添加源文件
file(GLOB_RECURSE SOURCES
    src/*.c
    src/*.s
)

# 添加头文件路径
include_directories(
    inc
    inc/hal
)

# 生成可执行文件
add_executable(${PROJECT_NAME}.elf ${SOURCES})

# 设置编译选项
target_compile_options(${PROJECT_NAME}.elf PRIVATE
    -mcpu=cortex-m4
    -mthumb
    -mfloat-abi=hard
    -mfpu=fpv4-sp-d16
    -O2
    -Wall
)

# 设置链接选项
target_link_options(${PROJECT_NAME}.elf PRIVATE
    -mcpu=cortex-m4
    -mthumb
    -mfloat-abi=hard
    -mfpu=fpv4-sp-d16
    -Tlinker.ld
    -Wl,--gc-sections
)

这里要注意,file(GLOB_RECURSE) 虽然方便,但有个问题:新增文件后,CMake不会自动重新配置。我建议还是手动列出源文件,或者用aux_source_directory

3.4 链接脚本解析

链接脚本,很多人觉得难,其实没那么复杂。它就是告诉链接器:你的代码放哪里,数据放哪里,堆栈放哪里。

点钞机的MCU通常是ARM Cortex-M系列。它的内存布局一般是:

区域 起始地址 大小 内容
Flash 0x08000000 1MB 代码、只读数据
SRAM 0x20000000 128KB 变量、堆栈
备份SRAM 0x40024000 4KB 掉电保持数据

一个典型的链接脚本片段:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1M
    SRAM  (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

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

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

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

这里有个关键点:.data 段在Flash里存储,但运行时在SRAM里。启动代码要把数据从Flash拷贝到SRAM。我见过有人忘了实现这个拷贝,结果全局变量全是乱的。

曾经踩过的坑:链接脚本里Flash和SRAM的地址写反了。编译通过,下载也成功,但芯片一跑就死。查了三天,最后发现是链接脚本里把Flash地址写成了0x20000000。嗯,这种低级错误,犯过一次就再也不会犯了。

3.5 固件镜像生成与优化

编译链接完,生成的是ELF文件。但MCU不认识ELF,它要的是二进制镜像。常用的格式有:

  • .bin:纯二进制,从指定地址开始
  • .hex:Intel HEX格式,带地址信息
  • .srec:Motorola S-record,类似HEX

生成命令:

# 生成bin文件
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

# 生成hex文件
arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex

# 生成srec文件
arm-none-eabi-objcopy -O srec firmware.elf firmware.srec

优化方面,我一般关注三点:

  1. 代码大小:点钞机的Flash通常不大,能省则省。用-Os优化,去掉没用的段。
  2. 启动时间:减少.data段的拷贝量。能放Flash的常量就别放RAM。
  3. 校验完整性:生成镜像后,加个CRC校验。我曾经遇到过下载过程中断,固件只写了一半,机器点钞时乱报数。

我的优化习惯

  • 链接时加 -Wl,--gc-sections,去掉没用的函数
  • __attribute__((section(".text.fast"))) 把关键函数放到SRAM里跑
  • 镜像末尾加4字节CRC,启动时校验

3.6 实战:点钞机固件构建脚本

最后,给一个我实际项目中用的构建脚本框架。它支持三种构建类型:debug、release、production。

# 构建类型
BUILD_TYPE ?= debug

# 根据类型设置优化等级
ifeq ($(BUILD_TYPE), debug)
    OPT = -O0 -g
else ifeq ($(BUILD_TYPE), release)
    OPT = -O2
else ifeq ($(BUILD_TYPE), production)
    OPT = -Os -DNDEBUG
endif

# 编译
firmware.elf: $(OBJS)
	$(CC) $(LDFLAGS) -o $@ $^ -T$(LINKER_SCRIPT)
	$(SZ) $@

# 生成镜像
firmware.bin: firmware.elf
	$(OBJCOPY) -O binary $< $@
	# 添加CRC
	python3 tools/add_crc.py $@

# 烧录
flash: firmware.bin
	openocd -f board/stm32f4discovery.cfg \
		-c "program $< 0x08000000 verify reset exit"

嗯,这一章的内容就到这里。编译链接看起来是基础,但基础不牢,地动山摇。下一章我们讲固件调试,到时候会用到今天生成的镜像文件。

记住一句话:好的构建系统,能让你把精力放在真正的算法和逻辑上,而不是在命令行里敲参数