
引言
每次在 Keil 或 VS Code 里点下"Build"按钮,几秒钟后就能把程序烧到芯片里运行。但这个过程中到底发生了什么?你写的 if、for、printf 是怎么变成 ARM Cortex-M 能执行的 0 和 1 的?
对于桌面开发来说,编译后的程序直接在本机 CPU 上运行,操作系统负责加载和调度。但嵌入式开发有一个本质区别:你在 x86 电脑上编译代码,生成的却是 ARM(或 RISC-V 等)架构的机器码——这就是所谓的交叉编译(Cross Compilation)。
理解编译流程不只是"知识补充"。在实际开发中,链接脚本配置错误会导致程序无法启动,优化等级选择不当会引入诡异 Bug,看不懂 map 文件就无法定位内存溢出。编译流程是连接"写代码"和"芯片跑起来"之间的桥梁。
本文以 ARM Cortex-M 平台和 GCC 工具链为例,完整拆解从 .c 源文件到芯片 Flash 中机器码的每一个步骤。
编译全景图
整个编译过程分为四个阶段,每个阶段的输入和输出都不同:
源文件 (.c/.h)
↓ 预处理 (Preprocessing)
预处理文件 (.i)
↓ 编译 (Compilation)
汇编文件 (.s)
↓ 汇编 (Assembly)
目标文件 (.o)
↓ 链接 (Linking)
可执行文件 (.elf)
↓ 格式转换
烧录文件 (.bin/.hex)
↓ 烧录
芯片 Flash
对应的 GCC 工具链命令:
| 阶段 | 工具 | 输入 | 输出 | 选项 |
|---|---|---|---|---|
| 预处理 | arm-none-eabi-gcc -E | .c | .i | -E |
| 编译 | arm-none-eabi-gcc -S | .i | .s | -S |
| 汇编 | arm-none-eabi-as | .s | .o | -c |
| 链接 | arm-none-eabi-ld | .o + .ld | .elf | -T |
| 转换 | arm-none-eabi-objcopy | .elf | .bin/.hex | -O binary |
实际开发中,通常一条 gcc 命令就完成了前三个阶段,但理解每个阶段各自做了什么,对排查编译问题非常有帮助。
预处理(Preprocessing)
预处理是编译的第一步,它处理所有以 # 开头的指令,不涉及任何语法分析,本质上就是文本替换。
主要工作
头文件展开:#include "stm32f4xx.h" 会把头文件的全部内容原封不动地插入到当前位置。一个看似只有 20 行的源文件,展开后可能有几万行。
宏替换:#define LED_PIN 13 会把代码中所有 LED_PIN 替换为 13。函数式宏 #define MAX(a,b) ((a)>(b)?(a):(b)) 同理展开。
条件编译:
#if (PMIC_DEVIECE_TYPE == 2)
#include "sgm41511.h"
#endif
预处理器根据宏的值决定保留哪些代码、丢弃哪些。这在嵌入式中极为常见——同一套代码通过宏适配不同的硬件型号。
删除注释:所有 // 和 /* */ 注释在这一步被清除。
查看预处理结果
arm-none-eabi-gcc -E main.c -o main.i
打开 main.i,你会看到一个庞大的纯 C 文件:所有头文件已展开、宏已替换、条件编译已解析。这对排查"宏展开结果是否符合预期"非常有用。
常见问题
- 头文件循环包含:A.h include B.h,B.h include A.h,导致展开无限递归。解决方案是使用
#ifndef头文件保护或#pragma once - 宏展开不符合预期:函数式宏的参数没有加括号,导致运算优先级错误。例如
#define SQUARE(x) x*x,调用SQUARE(1+2)展开为1+2*1+2 = 5而不是9
编译(Compilation)
编译阶段将预处理后的 C 代码翻译成汇编语言。这是整个流程中最复杂的一步,包含词法分析、语法分析、语义分析和代码优化。
编译器做了什么
- 词法分析:将代码拆分为 token(关键字、标识符、运算符、常量等)
- 语法分析:根据 C 语法规则构建抽象语法树(AST)
- 语义分析:检查类型匹配、变量是否声明、函数参数是否正确
- 中间代码生成:转换为编译器内部的中间表示(IR)
- 优化:根据优化级别对中间代码进行变换
- 目标代码生成:将优化后的 IR 翻译为目标平台的汇编指令
查看汇编输出
arm-none-eabi-gcc -S -mcpu=cortex-m4 -O0 main.c -o main.s
一个简单的 C 函数:
int add(int a, int b) {
return a + b;
}
生成的 ARM Thumb-2 汇编(-O0 无优化):
add:
push {r7} @ 保存帧指针
sub sp, sp, #12 @ 分配栈空间
add r7, sp, #0 @ 设置帧指针
str r0, [r7, #4] @ 参数 a 存入栈
str r1, [r7, #0] @ 参数 b 存入栈
ldr r2, [r7, #4] @ 从栈加载 a
ldr r3, [r7, #0] @ 从栈加载 b
add r3, r3, r2 @ r3 = a + b
mov r0, r3 @ 返回值放入 r0
adds r7, r7, #12 @ 恢复栈指针
mov sp, r7
pop {r7} @ 恢复帧指针
bx lr @ 返回
同一个函数在 -O2 优化下:
add:
add r0, r0, r1 @ r0 = a + b,直接返回
bx lr
从 13 条指令优化到 2 条——编译器发现参数已经在寄存器中,不需要压栈再弹栈。
优化级别
| 级别 | 含义 | 嵌入式场景 |
|---|---|---|
-O0 | 不优化,便于调试 | 开发调试阶段 |
-O1 | 基本优化,不增加编译时间 | 平衡选择 |
-O2 | 较强优化,可能改变代码结构 | 性能敏感场景 |
-Os | 优化代码体积(嵌入式常用) | Flash 空间紧张时 |
-O3 | 激进优化,可能增大代码体积 | 嵌入式中较少使用 |
嵌入式项目中最常用的是 -Os(优化体积)和 -O0(调试模式)。很多诡异的 Bug 只在开启优化后出现,关闭优化就消失——这通常是代码本身存在未定义行为(如 volatile 遗漏、竞态条件等)。
汇编(Assembly)
汇编阶段将汇编语言翻译成机器码,生成目标文件(.o,也叫 object file)。
汇编器做了什么
汇编器的工作相对简单——把汇编指令一对一翻译成机器码:
arm-none-eabi-gcc -c main.c -o main.o
或者直接汇编 .s 文件:
arm-none-eabi-as main.s -o main.o
目标文件的结构
.o 文件是 ELF 格式(Executable and Linkable Format),包含:
.text段:机器码(代码).data段:已初始化的全局变量.bss段:未初始化的全局变量(不占文件空间,只记录大小).rodata段:只读数据(常量字符串等)- 符号表:记录函数名、全局变量名及其地址(此时地址是相对的)
- 重定位表:记录哪些地方引用了外部符号,需要链接器填入最终地址
查看目标文件
# 查看段信息
arm-none-eabi-objdump -h main.o
# 反汇编 .text 段
arm-none-eabi-objdump -d main.o
# 查看符号表
arm-none-eabi-nm main.o
objdump -d 的输出示例:
00000000 <add>:
0: b580 push {r7, lr}
2: 4403 add r3, r0, r1
4: 4618 mov r0, r3
6: bd80 pop {r7, pc}
注意左边的地址是从 0x00000000 开始的——因为还没有经过链接,不知道这个函数最终会被放在 Flash 的哪个位置。
关键概念:符号(Symbol)
每个函数名和全局变量名都是一个符号。在 .o 文件中:
- 定义的符号:在本文件中实现了的函数或变量(如
add函数) - 未定义的符号:在本文件中引用了但没有实现的(如调用了
printf,但printf的实现在其他.o或库中)
链接器的任务之一就是把所有"未定义符号"和"定义符号"匹配起来。
链接(Linking)
链接是编译流程中最关键也最容易出问题的一步。它把多个 .o 文件和库文件合并成一个完整的可执行文件(.elf)。
链接器做了什么
- 符号解析:扫描所有
.o文件,将每个"未定义符号"匹配到某个.o中"已定义的符号"。如果找不到,就是undefined reference错误;如果同一个符号被多个.o定义,就是multiple definition错误 - 段合并:把所有
.o的.text段合并为一个大的.text段,.data段合并为一个大的.data段,以此类推 - 地址重定位:根据链接脚本分配最终地址,把所有函数调用、全局变量引用的相对地址替换为绝对地址
链接脚本(.ld)
这是嵌入式编译区别于桌面编译的核心。桌面程序由操作系统负责内存布局,而嵌入式没有操作系统,需要通过链接脚本手动指定代码和数据放在哪里。
一个简化的 nRF52832 链接脚本:
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
SECTIONS
{
.text :
{
KEEP(*(.isr_vector)) /* 中断向量表,必须放在 Flash 开头 */
*(.text*) /* 所有代码 */
*(.rodata*) /* 只读数据 */
} > FLASH
.data :
{
_sdata = .;
*(.data*) /* 已初始化全局变量 */
_edata = .;
} > RAM AT> FLASH /* 运行在 RAM,加载在 Flash */
.bss :
{
_sbss = .;
*(.bss*) /* 未初始化全局变量 */
*(COMMON)
_ebss = .;
} > RAM
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶地址 */
}
关键点:
- FLASH 区域(
0x00000000):存放代码和常量,掉电不丢失 - RAM 区域(
0x20000000):存放变量和栈,掉电丢失 .data段的双重归属:> RAM AT> FLASH表示变量运行时在 RAM 中,但初始值存储在 Flash 中。启动时由 startup 代码从 Flash 拷贝到 RAM_sdata/_edata/_sbss/_ebss:这些符号供 startup 代码使用,用于初始化.data段和清零.bss段_estack:栈顶地址,芯片复位后第一个读取的值
startup.s:真正的入口
嵌入式程序的入口不是 main(),而是中断向量表和 Reset_Handler。startup.s(或 startup.c)做了以下工作:
- 定义中断向量表(放在 Flash 开头,
.isr_vector段) Reset_Handler:芯片上电/复位后第一个执行的函数- 将
.data段从 Flash 拷贝到 RAM(已初始化全局变量) - 将
.bss段清零(未初始化全局变量按 C 标准为 0) - 调用
SystemInit()(配置时钟等) - 跳转到
main()
Reset_Handler:
ldr r0, =_sdata @ RAM 中 .data 起始地址
ldr r1, =_edata @ RAM 中 .data 结束地址
ldr r2, =_sidata @ Flash 中 .data 的加载地址
copy_data:
cmp r0, r1
bge zero_bss
ldr r3, [r2], #4
str r3, [r0], #4
b copy_data
zero_bss:
ldr r0, =_sbss
ldr r1, =_ebss
movs r2, #0
clear_bss:
cmp r0, r1
bge call_main
str r2, [r0], #4
b clear_bss
call_main:
bl SystemInit
bl main
b . @ main 返回后死循环(不应发生)
这就是为什么全局变量 int g_count = 100; 在 main() 执行时已经有了初始值——Reset_Handler 在进入 main 之前就把 100 从 Flash 拷贝到了 RAM。
嵌入式特有环节
ELF → BIN/HEX 转换
链接器输出的 .elf 文件包含调试信息、符号表等元数据,芯片无法直接执行。需要转换为纯二进制格式:
# 转为 BIN(纯二进制,按地址顺序排列)
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
# 转为 HEX(Intel HEX 格式,包含地址信息)
arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex
| 格式 | 特点 | 使用场景 |
|---|---|---|
.elf | 包含代码、数据、调试信息、符号表 | 调试器使用(GDB、J-Link) |
.bin | 纯二进制,无地址信息 | DFU 升级、部分烧录工具 |
.hex | ASCII 文本,包含地址信息 | Keil 下载、J-Flash |
烧录到 Flash
通过 J-Link、ST-Link 等调试器,将 .bin 或 .hex 写入芯片的 Flash 存储器。烧录工具根据芯片型号,控制 Flash 的擦除和编程时序。
启动流程
芯片上电后的完整执行路径:
上电 / 复位
↓
从 Flash 地址 0x00000000 读取栈顶指针 → 写入 SP 寄存器
↓
从 Flash 地址 0x00000004 读取 Reset_Handler 地址 → 跳转执行
↓
Reset_Handler: 拷贝 .data、清零 .bss、调用 SystemInit
↓
跳转到 main()
↓
应用代码开始运行
这就是为什么链接脚本中 .isr_vector 必须放在 Flash 开头——芯片复位后第一件事就是从 0x00000000 读取栈顶和入口地址。
实战:用 GCC 手动走一遍
以一个最简单的 LED 闪烁程序为例,手动执行编译的每个阶段。
源代码
// main.c
#include <stdint.h>
#define LED_PIN 13
#define GPIO_BASE 0x50000000
#define GPIO_OUTSET (*(volatile uint32_t *)(GPIO_BASE + 0x508))
#define GPIO_OUTCLR (*(volatile uint32_t *)(GPIO_BASE + 0x50C))
#define GPIO_DIRSET (*(volatile uint32_t *)(GPIO_BASE + 0x518))
void delay(volatile uint32_t count)
{
while (count--) {}
}
int main(void)
{
GPIO_DIRSET = (1 << LED_PIN);
while (1)
{
GPIO_OUTSET = (1 << LED_PIN);
delay(500000);
GPIO_OUTCLR = (1 << LED_PIN);
delay(500000);
}
}
逐步执行
# 1. 预处理:展开头文件和宏
arm-none-eabi-gcc -E -mcpu=cortex-m4 main.c -o main.i
# 查看 main.i,LED_PIN 已被替换为 13,GPIO_BASE 已展开为地址
# 2. 编译:C → 汇编
arm-none-eabi-gcc -S -mcpu=cortex-m4 -mthumb -Os main.c -o main.s
# 查看 main.s,可以看到 ARM Thumb-2 汇编指令
# 3. 汇编:汇编 → 目标文件
arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -Os main.c -o main.o
# 查看符号:arm-none-eabi-nm main.o
# 4. 链接:目标文件 → ELF(需要链接脚本和 startup)
arm-none-eabi-gcc main.o startup.o -T linker.ld -nostdlib -o firmware.elf
# -nostdlib:不链接标准库(裸机环境)
# -T linker.ld:指定链接脚本
# 5. 转换:ELF → BIN
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
# 6. 查看最终结果
arm-none-eabi-objdump -d firmware.elf # 反汇编
arm-none-eabi-size firmware.elf # 查看各段大小
arm-none-eabi-nm firmware.elf # 查看符号地址
size 输出解读
text data bss dec hex filename
1024 12 4 1040 410 firmware.elf
- text(1024 字节):代码 + 只读数据,占用 Flash
- data(12 字节):已初始化全局变量,占用 Flash(存储初始值)+ RAM(运行时)
- bss(4 字节):未初始化全局变量,只占用 RAM
- Flash 占用 = text + data = 1036 字节
- RAM 占用 = data + bss = 16 字节(不含栈和堆)
常见编译错误与排查
undefined reference
main.o: undefined reference to `sgm41511_init'
原因:链接器在所有 .o 文件和库中都找不到 sgm41511_init 的定义。
排查:
- 忘了把
sgm41511.c加入编译 - 函数名拼写不一致(C++ 有 name mangling,需要
extern "C") - 函数声明了但没有实现
multiple definition
sgm41511.o: multiple definition of `g_charge_status'
pmic.o: first defined here
原因:同一个全局变量在多个 .c 文件中被定义。
排查:
- 全局变量应在
.c中定义,头文件中只用extern声明 - 头文件中不要写
int g_charge_status = 0;
section overflow
region `FLASH' overflowed by 2048 bytes
原因:代码或数据超出了链接脚本中定义的 FLASH 或 RAM 大小。
排查:
- 用
arm-none-eabi-size查看各段大小 - 用
-Os优化代码体积 - 检查是否有未使用的大数组或大型
const表 - 检查第三方库是否引入了过多代码
Hard Fault 与编译的关系
Hard Fault 是 ARM Cortex-M 最常见的异常。虽然通常是运行时错误,但很多根因和编译有关:
- 栈溢出:链接脚本中栈空间分配不足,局部变量过大
- 访问非法地址:指针未初始化,
.bss段没有正确清零 - 函数指针为空:中断向量表中某个 Handler 没有实现,跳转到 0 地址
- 对齐错误:某些 ARM 指令要求地址对齐,编译器通常会处理,但手动操作内存时需注意
编译知识在代码调试中的应用
理解编译流程不只是理论知识——它在日常调试中有很多实际用途。
用 map 文件定位内存问题
编译时加上 -Wl,-Map=firmware.map 生成 map 文件。它记录了每个函数和变量的最终地址和大小:
.text 0x00000000 0x1a4
*(.isr_vector)
.isr_vector 0x00000000 0xc0 startup.o
*(.text*)
.text.main 0x000000c0 0x48 main.o
.text.delay 0x00000108 0x14 main.o
.text.sgm41511_init
0x0000011c 0x58 sgm41511.o
.bss 0x20000000 0x120
.bss.rx_buffer 0x20000000 0x100 uart.o
.bss.g_status 0x20000100 0x04 main.o
当你遇到 region 'RAM' overflowed 时,打开 map 文件找到占用最大的变量——往往一眼就能看到有人定义了一个 uint8_t buffer[4096] 之类的大数组。
用 objdump 反汇编分析 Hard Fault
当程序触发 Hard Fault 时,调试器通常会给出出错的 PC(程序计数器)地址。通过反汇编定位出错的 C 代码行:
# 反汇编整个 ELF
arm-none-eabi-objdump -d -S firmware.elf > firmware.asm
# 如果 Hard Fault 时 PC = 0x00001234,在 firmware.asm 中搜索该地址
# 加了 -S 选项时,反汇编会穿插 C 源码行,直接看到对应的 C 代码
如果编译时加了 -g(调试信息),还可以用 addr2line 直接查到源文件和行号:
arm-none-eabi-addr2line -e firmware.elf 0x00001234
# 输出: /src/sgm41511.c:47
优化等级导致的调试异常
这是嵌入式开发中最常见的"灵异事件"之一:
现象 1:断点打不住
int result = compute_value();
// 在这里打断点,调试器说 "breakpoint at optimized-out location"
return result;
编译器在 -O2 下可能将 result 变量优化掉,直接用寄存器传递返回值。解决方案:调试时切换到 -O0,或者将关键变量声明为 volatile。
现象 2:变量值"错乱"
volatile uint32_t *status_reg = (volatile uint32_t *)0x40001000;
uint32_t status = *status_reg; // 调试器显示 status 值和预期不符
如果忘记 volatile,编译器可能缓存读取结果,甚至完全移除"看似无意义"的读操作。这在硬件寄存器访问中是致命的。
现象 3:代码被优化消失
void delay_us(uint32_t us)
{
for (uint32_t i = 0; i < us * 10; i++) {
// 空循环
}
}
在 -O2 下,编译器认为这个循环没有副作用(不改变任何可观察状态),直接把整个函数体删除了。解决方案:循环变量加 volatile,或使用 __NOP() 等内联汇编。
利用符号表辅助 Crash 分析
在量产设备中无法连接调试器时,通常会在 Hard Fault Handler 中打印 PC、LR、SP 等寄存器值。拿到这些地址后:
# 查看该地址附近的符号
arm-none-eabi-nm -n firmware.elf | grep -B1 "00001234"
# 或者查看该地址属于哪个函数
arm-none-eabi-addr2line -f -e firmware.elf 0x00001234
# 输出:
# sgm41511_write_reg
# /src/sgm41511.c:47
结合 map 文件和符号表,即使没有调试器也能大致定位 Crash 原因——这在量产运维中非常实用。
用 readelf 检查 ELF 头信息
当出现"程序能编译但烧录后不运行"的情况时,检查 ELF 头可以排除一些低级错误:
arm-none-eabi-readelf -h firmware.elf
Class: ELF32
Machine: ARM
Entry point address: 0x00000c1
Flags: 0x5000400, ...
检查 Entry point 是否指向 Reset_Handler,Machine 是否为 ARM,这些信息和芯片是否匹配。
总结
从 C 源码到芯片运行,完整的编译流程可以归纳为:
.c 源文件
→ 预处理(宏展开、头文件包含、条件编译)
→ 编译(C → 汇编,类型检查、代码优化)
→ 汇编(汇编 → 机器码,生成 .o 目标文件)
→ 链接(合并 .o、符号解析、地址重定位,生成 .elf)
→ 格式转换(.elf → .bin/.hex)
→ 烧录到 Flash
→ 芯片上电,从 Reset_Handler 开始执行
关键要点
- 预处理是纯文本操作,用
-E可以排查宏展开问题 - 编译阶段做优化,优化级别直接影响代码大小、执行速度和可调试性
- 链接脚本是嵌入式编译的核心配置,决定了代码和数据在 Flash/RAM 中的布局
- startup 代码在
main()之前执行,负责初始化 C 运行环境(拷贝 .data、清零 .bss) - map 文件、objdump、addr2line 是排查内存溢出和 Crash 的利器
- 编译相关的知识不是"选修课"——
volatile遗漏、栈溢出、链接脚本错误等问题,只有理解编译流程才能从根本上解决