3. 固件编译流程:预处理、编译、汇编、链接四阶段详解,Makefile与CMake基础

说到固件编译,很多人第一反应就是「点一下编译按钮,等它跑完」。但说实话,如果你不清楚中间那四个阶段到底干了什么,出了问题你根本不知道从哪下手。我刚开始做嵌入式那会儿,就吃过这个亏——明明代码语法没问题,烧进去就是跑不起来,后来才发现是链接脚本写错了。

今天咱们就把这四步掰开揉碎了讲清楚,顺便聊聊 Makefile 和 CMake 这两个构建工具怎么用。嗯,都是实战里天天要碰的东西。

3.1 预处理(Preprocessing)

预处理是编译的第一站。说白了,就是帮你把代码里的「宏」和「头文件」展开成真正的 C 代码。

你写 #include <stdio.h>,预处理器就把 stdio.h 的内容原封不动复制进来。你写 #define LED_ON 1,它就把所有 LED_ON 替换成 1。你写条件编译 #ifdef DEBUG,它就只保留符合条件的代码块。

预处理后的文件:通常以 .i 或 .ii 结尾。你可以用 gcc -E main.c -o main.i 单独生成看看,那文件会膨胀好几倍。

我记得有一次,项目里某个头文件被反复 include 了十几次,预处理后的代码直接飙到几万行。嗯,这就是为什么要有 #pragma once 或者头文件保护宏。

3.2 编译(Compilation)

编译阶段,编译器把预处理后的 C 代码转成汇编代码。这一步是真正的「翻译」——从高级语言到低级语言。

编译器会做词法分析、语法分析、语义分析,然后生成中间表示,最后优化并输出汇编。你想想看,你写的 a = b + c * 2,编译器会先分析出运算符优先级,再生成对应的汇编指令。

小技巧:用 gcc -S main.c -o main.s 可以查看生成的汇编代码。调试优化问题时,我经常这么干。

编译阶段还会做各种优化:常量折叠、死代码消除、循环展开等等。但要注意,优化等级开太高(比如 -O3)有时候会引入奇怪的 bug。我曾经在 STM32 上遇到过 -O3 把延时循环直接优化没了的情况,烧进去程序跑得飞快,但外设根本来不及响应。

3.3 汇编(Assembly)

汇编器把汇编代码转成机器码,生成目标文件(.o 或 .obj)。这一步相对简单,基本上是「一对一」的翻译——一条汇编指令对应一条机器指令。

目标文件里除了机器码,还有符号表、重定位信息、段信息等。你可以用 objdump -d main.o 反汇编看看里面的内容。

注意:目标文件里的地址还不是最终地址,都是「相对地址」或者「未解析的符号」。链接器才是真正干地址分配活的。

3.4 链接(Linking)

链接是最后一步,也是最容易出问题的一步。链接器把多个目标文件和库文件合并成一个可执行文件(或者固件镜像)。

链接器主要干三件事:

  • 符号解析:把每个 extern 变量和函数调用,找到它真正的定义位置。
  • 重定位:把相对地址改成绝对地址,确定每个段在内存里的最终位置。
  • 段合并:把所有 .text 段合并到一起,所有 .data 段合并到一起。

我遇到过最坑的一次,是链接时提示 undefined reference to 'HAL_UART_Transmit'。查了半天,发现是库文件链接顺序不对——GCC 链接器对顺序敏感,被依赖的库要放在后面。

链接脚本(.ld 文件):在固件开发里,链接脚本特别重要。它告诉链接器代码段放哪、数据段放哪、堆栈地址是多少。写错了,程序直接跑飞。

3.5 四阶段流程图

下面这张图把整个流程串起来了,你可以对照着看:

固件编译四阶段流程图 固件编译四阶段流程 预处理 .c → .i 编译 .i → .s 汇编 .s → .o 链接 .o → .elf 输入:main.c, uart.c, timer.c ... 输出:firmware.elf → 再转成 .bin / .hex 烧录 各阶段关键产物 .i 预处理文件 .s 汇编文件 .o 目标文件 .elf 可执行 .bin / .hex 每个阶段都可以单独调试,用 -E / -S / -c 参数分别生成中间产物

3.6 Makefile 基础

手动敲 gcc 命令编译固件?项目小还行,文件一多你就疯了。Makefile 就是帮你自动化这件事的。

一个最简单的 Makefile 长这样:

CC = arm-none-eabi-gcc
CFLAGS = -mcpu=cortex-m4 -mthumb -O2 -Wall

main.o: main.c
    $(CC) $(CFLAGS) -c main.c -o main.o

uart.o: uart.c
    $(CC) $(CFLAGS) -c uart.c -o uart.o

firmware.elf: main.o uart.o
    $(CC) $(CFLAGS) main.o uart.o -o firmware.elf -T link.ld

clean:
    rm -f *.o firmware.elf

Makefile 的核心就是「目标: 依赖」和「命令」。你执行 make firmware.elf,它会自动检查依赖文件有没有更新,只重新编译有改动的部分。

个人习惯:我一般会在 Makefile 里加一个 flash 目标,直接调用烧录工具。这样改完代码,一行 make flash 就搞定。

3.7 CMake 基础

Makefile 虽然好用,但跨平台就麻烦了——Windows 和 Linux 的路径分隔符、编译器路径都不一样。CMake 就是来解决这个问题的。

CMake 不直接编译,它先生成 Makefile(或者 Ninja 构建文件),然后再调用 make 编译。你写一个 CMakeLists.txt,描述项目结构,CMake 帮你适配平台。

一个简单的 CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(FirmwareProject C)

set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_C_FLAGS "-mcpu=cortex-m4 -mthumb -O2 -Wall")

add_executable(firmware.elf main.c uart.c timer.c)

target_link_options(firmware.elf PRIVATE -T link.ld)

使用方式:

mkdir build && cd build
cmake ..
make

CMake 的优势:

  • 自动检测编译器、工具链
  • 支持交叉编译(嵌入式必备)
  • 可以生成 IDE 项目文件(如 Keil、IAR)
  • 模块化管理,大型项目更清晰

3.8 Makefile vs CMake 对比

特性 Makefile CMake
学习曲线 中等,语法简单但容易踩坑 较陡,但功能更强大
跨平台 需要手动适配 自动适配
依赖管理 手动写规则 自动处理
大型项目 容易混乱 模块化清晰
嵌入式支持 直接,灵活 需要工具链文件

避坑指南:我曾经在 CMake 里忘记设置 CMAKE_SYSTEM_NAME 为 Generic,结果 CMake 自动检测成 Linux,链接时加了一堆系统库,固件体积直接爆了。嵌入式项目一定要显式指定交叉编译工具链。

好了,编译流程和构建工具就聊到这儿。记住,预处理、编译、汇编、链接这四步,每一步都可能出问题。出错了别慌,用 -E、-S、-c 参数逐步排查,总能找到根因。至于 Makefile 和 CMake,选哪个看项目规模——小项目 Makefile 够用,大项目还是上 CMake 省心。