3. 编译与输出文件:从源码到固件的“变形记”

说实话,很多初学者拿到一个工程,点一下“Build”按钮,看到底下输出了一堆 .hex、.bin、.elf 文件,就以为完事了。但你真的知道这些文件是怎么来的吗?它们之间有什么区别?哪个该用来烧录?哪个只是中间产物?

我记得刚入行那会儿,有一次把 .elf 文件直接拖进烧录工具,结果工具报错“文件格式不支持”。当时我还纳闷:明明编译成功了呀?后来才搞明白,这三个文件各司其职,用错了地方,烧录器根本不认。

这一章,我就带你彻底搞懂编译的四个阶段,以及三种输出文件的“身世之谜”。

3.1 编译的四个阶段:代码的“流水线”

你写的那一堆 .c 和 .h 文件,编译器是怎么把它们变成机器码的?其实它走了一条四步流水线:预处理 → 编译 → 汇编 → 链接。每一步都有明确的分工。

核心理解:编译不是一步到位的魔法,而是四个独立工序的接力赛。每个阶段都会产生中间文件,但默认情况下编译器会帮你“擦屁股”,只保留最终产物。

3.1.1 预处理(Preprocessing)

这一步干的是“体力活”。编译器把源文件中所有以 # 开头的指令处理掉:

  • 头文件展开:#include <stdio.h> 会被替换成 stdio.h 的完整内容。你想想看,一个简单的 #include 可能引入几千行代码。
  • 宏替换:#define LED_PIN 5 会把代码中所有 LED_PIN 替换成 5。
  • 条件编译:#ifdef DEBUG 决定哪些代码块保留,哪些被丢弃。
  • 删除注释:所有 // 和 /* */ 都被清理掉。

预处理后的文件通常以 .i 为后缀。你可以用 gcc -E main.c -o main.i 看看它长什么样——相信我,打开后你会吓一跳,几千行代码铺面而来。

我的小技巧:当宏展开结果和你预期不符时,用 -E 生成 .i 文件,直接看预处理后的代码,比对着头文件猜要快得多。我曾经被一个嵌套三层 #define 的宏坑过,最后就是用这招找到问题的。

3.1.2 编译(Compilation)

这一步才是真正的“编译”。编译器把预处理后的 .i 文件,翻译成汇编语言。注意,不是机器码,而是人能读懂的汇编指令。

比如你写的 a = b + c;,在 ARM Cortex-M 上可能被翻译成:

LDR r0, [sp, #4]   ; 加载 b 到 r0
LDR r1, [sp, #8]   ; 加载 c 到 r1
ADD r0, r0, r1     ; r0 = r0 + r1
STR r0, [sp, #0]   ; 存储结果到 a

输出文件是 .s 汇编文件。用 gcc -S main.c -o main.s 就能看到。

3.1.3 汇编(Assembly)

汇编器把 .s 文件里的助记符,转换成机器码——也就是 CPU 真正能执行的二进制指令。这一步输出的是目标文件(Object File),在 Linux/Windows 下通常是 .o 文件,在嵌入式工具链里可能是 .obj。

目标文件里已经包含了机器码,但它还不能直接运行。为什么?因为函数调用、全局变量这些“外部引用”还没解决。比如你的 main.c 里调用了 uart_send(),但这个函数定义在 uart.c 里。汇编器只知道“这里要调用一个外部函数”,但不知道它的具体地址。

注意:目标文件(.o)里的地址是可重定位的(relocatable),不是最终地址。如果你用十六进制编辑器打开 .o 文件,看到的地址都是相对于模块起始位置的偏移量,不是芯片 Flash 里的真实地址。

3.1.4 链接(Linking)

链接器是最后一道关。它把所有 .o 文件、以及用到的库文件(比如 libc.a)合并在一起,解决外部符号引用,分配最终的内存地址,生成可执行文件。

链接器主要干三件事:

  1. 符号解析:找到每个符号(函数名、全局变量名)的定义位置。
  2. 重定位:把 .o 文件里的相对地址,修改成芯片 Flash/RAM 里的绝对地址。
  3. 段合并:把所有 .o 文件的 .text(代码段)、.data(已初始化数据段)、.bss(未初始化数据段)合并到一起。

链接器的输入是 .o 文件和链接脚本(Linker Script,通常以 .ld 或 .icf 结尾),输出就是我们要讲的三种文件格式之一——.elf。

下面这张图总结了整个流程:

编译四阶段流程图 预处理 .c → .i 编译 .i → .s 汇编 .s → .o 链接 .o → .elf 输入:.c / .h 文件 输出:.elf 文件 链接脚本 (.ld) 关键点 • 前三个阶段(预处理、编译、汇编)是每个 .c 文件独立进行的 • 链接阶段才把所有 .o 文件合并,并解决跨文件的符号引用 • 链接脚本决定了代码和数据在芯片内存中的最终布局

3.2 三种输出文件格式:.elf、.hex、.bin

链接器生成的 .elf 文件是“母版”,包含了所有调试信息和符号表。但烧录时我们通常不用它,而是用 .hex 或 .bin。为什么?因为 .elf 太“胖”了,而且烧录器不一定认识。

特性 .elf .hex .bin
全称 Executable and Linkable Format Intel HEX Binary
内容 机器码 + 调试信息 + 符号表 + 段信息 机器码 + 地址信息 + 校验和 纯机器码,无任何附加信息
可读性 二进制,需专用工具解析 ASCII 文本,可用记事本打开 二进制,不可直接阅读
是否包含地址 是(在段头中) 是(每行记录都有地址) 否(从烧录起始地址连续存放)
文件大小 最大(含调试信息) 中等(ASCII 编码膨胀约 2 倍) 最小(与 Flash 大小一致)
主要用途 调试、分析、反汇编 烧录(支持分段烧录) 烧录(量产、OTA 升级)

3.2.1 .elf 文件:调试的“瑞士军刀”

.elf 是链接器直接生成的原始产物。它包含了所有调试信息——函数名、变量名、行号、甚至你写的注释(如果编译时开了 -g 选项)。调试器(比如 J-Link、ST-Link)就是靠 .elf 里的信息,才能在你打断点时显示源代码和变量值。

但 .elf 有个问题:它不是为烧录设计的。它包含了很多烧录器不需要的东西,比如符号表、段头表、字符串表。而且不同工具链生成的 .elf 格式可能略有差异,兼容性不如 .hex 和 .bin。

我的习惯:开发调试阶段,我一直用 .elf 配合调试器。但到了要生成烧录文件时,我一定用工具把 .elf 转成 .hex 或 .bin。这样做的好处是——万一烧录工具不认 .elf,或者版本不兼容,你不会在产线上抓瞎。

3.2.2 .hex 文件:带地址的“快递单”

.hex 文件是 Intel 公司定义的一种文本格式。它把机器码和地址信息打包成一行一行的 ASCII 文本,每行都包含:

  • 起始地址:这行数据要烧录到 Flash 的哪个位置。
  • 数据长度:这行包含多少个字节。
  • 数据内容:实际的机器码。
  • 校验和:用于验证这行数据有没有传错。

举个例子,一行典型的 .hex 记录长这样:

:1000000008000020E1000008F100000800000000F0

拆开来看:

  • : 起始标记
  • 10 数据长度(16 字节)
  • 0000 起始地址(0x0000)
  • 00 记录类型(00 表示数据记录)
  • 08000020E1000008F100000800000000 16 字节数据
  • F0 校验和

.hex 的好处是:支持非连续地址。如果你的固件在 Flash 里有空洞(比如 0x0000-0x1000 有代码,0x2000-0x3000 有数据,中间是空的),.hex 可以只记录有数据的区域,烧录器会自动跳过空洞。这在某些需要保留 Bootloader 的应用场景下特别有用。

3.2.3 .bin 文件:最纯粹的“二进制流”

.bin 文件最简单——它就是 Flash 内容的逐字节镜像。没有地址信息,没有校验和,没有任何元数据。烧录时,烧录器从你指定的起始地址开始,把 .bin 文件的内容一个字节一个字节地写进 Flash。

举个例子,如果你的固件编译出来是 32KB,那么 .bin 文件就是 32KB 大小,不多不少。烧录到 Flash 的 0x08000000 地址,它就占据 0x08000000 ~ 0x08007FFF。

注意:.bin 文件不包含地址信息,所以烧录时必须手动指定起始地址。我曾经见过有人把 .bin 烧到了错误地址,结果芯片上电后跑飞了。更麻烦的是,如果固件在 Flash 里有空洞(比如 Bootloader 和 App 之间有预留区域),.bin 文件会把这些空洞也填上(通常是 0xFF),导致文件体积变大。

3.3 文件格式转换:从 .elf 到 .hex / .bin

实际工作中,我们通常用 .elf 作为“母版”,然后根据需要转换成 .hex 或 .bin。最常用的工具是 objcopy(来自 GNU Binutils 工具链)。

3.3.1 基本转换命令

假设你编译生成了 firmware.elf,想生成烧录文件:

# 生成 .hex 文件(带地址信息)
arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex

# 生成 .bin 文件(纯二进制)
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

就这么简单。但实际项目中,我经常需要加一些额外参数:

# 只提取特定段(比如只提取 .text 和 .data 段)
arm-none-eabi-objcopy -O binary -j .text -j .data firmware.elf firmware.bin

# 指定输出文件的起始地址偏移(用于某些 Bootloader 场景)
arm-none-eabi-objcopy -O binary --change-addresses=0x8000000 firmware.elf firmware.bin

3.3.2 其他转换工具

除了 objcopy,还有一些工具也能做转换:

工具 来源 特点
objcopy GNU Binutils 最通用,支持几乎所有格式
srec_cat SRecord 工具集 支持多种格式互转,可做数据合并、裁剪
hex2bin 第三方工具 轻量级,只做 .hex → .bin 转换
IDE 内置工具 Keil / IAR / STM32CubeIDE 在编译选项中直接配置输出格式

我的经验:量产烧录时,我强烈推荐用 .hex 文件。原因有二:第一,.hex 自带校验和,烧录器可以验证每行数据是否正确写入;第二,.hex 支持分段烧录,如果 Flash 里有 Bootloader 保护区,.hex 可以只烧录应用区,不会误擦 Bootloader。而 .bin 文件一旦指定错了地址,后果很严重。

3.4 避坑指南:我踩过的那些坑

最后分享几个我亲身经历过的“血泪史”,希望能帮你少走弯路。

坑一:.hex 文件行数太多导致烧录慢

有一次我用 .hex 文件烧录一个 1MB 的固件,烧了整整 5 分钟。后来发现,编译器生成的 .hex 文件每行只包含 16 字节数据,行数太多,烧录器每处理一行都要解析地址和校验和。解决办法:用 objcopy --gap-fill=0xFF --pad-to=0x100000 生成连续地址的 .hex,或者干脆用 .bin 文件烧录,速度能快 10 倍。

坑二:.bin 文件烧录后芯片跑飞

我有个同事,把 .bin 文件烧到 STM32 的 0x08000000 地址,结果芯片上电后毫无反应。查了半天,发现他用的烧录工具默认起始地址是 0x00000000,而 STM32 的 Flash 起始地址是 0x08000000。.bin 文件没有地址信息,烧录器把它从 0x00000000 开始写,自然就写到了错误的位置。从那以后,我每次烧 .bin 都会再三确认起始地址。

坑三:.elf 文件太大,Git 提交时爆仓

团队协作时,有人把编译生成的 .elf 文件也提交到了 Git 仓库。一个 .elf 文件动辄几十 MB,几次提交后仓库就膨胀到几个 GB。后来我们在 .gitignore 里加上了 *.elf、*.hex、*.bin,只提交源码和链接脚本。需要固件文件时,现场编译生成。

好了,这一章的内容就到这里。编译的四个阶段、三种输出文件的区别、以及如何转换格式,这些知识在你后续的固件开发中会反复用到。记住一句话:.elf 用来调试,.hex 用来烧录,.bin 用来量产。搞清楚了这些,你离“固件打包”这门手艺又近了一步。