第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没更新依赖,白白浪费了半天。
%.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。我见过有人忘了实现这个拷贝,结果全局变量全是乱的。
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
优化方面,我一般关注三点:
- 代码大小:点钞机的Flash通常不大,能省则省。用
-Os优化,去掉没用的段。 - 启动时间:减少.data段的拷贝量。能放Flash的常量就别放RAM。
- 校验完整性:生成镜像后,加个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"
嗯,这一章的内容就到这里。编译链接看起来是基础,但基础不牢,地动山摇。下一章我们讲固件调试,到时候会用到今天生成的镜像文件。
记住一句话:好的构建系统,能让你把精力放在真正的算法和逻辑上,而不是在命令行里敲参数。