P03 教学文档:编译与链接------从纸带到固件
配套视频 :
[p03_compile_link_1080p30.mp4(1920×1080@30,7 分 20 秒,10 章)](https://b23.tv/lLczZUb)【MCU拆解室--论一行C是如何变成镜像的-哔哩哔哩】 https://b23.tv/lLczZUb
【俺系一坨硅的作品】【芯片拆解室】一行C代码的蜕变之旅上一篇:P02《你的变量住在哪里》;下一篇:P04《上电之后,main 之前》
本文不是视频文字稿。 视频用 7 分钟讲完七十年的抽象史;本文把每一道工序、每一条重定位、每一个坑,落到真实工程的字节和地址上。
阅读指南
这篇文档写给谁
- 天天点"编译",但说不清
.i/.s/.o/.elf/.bin各是什么的人; - 见过
undefined reference、region overflowed,只会复制粘贴解决的人; - 想搞懂"为什么我的 C 不能在电脑上跑、也不能直接烧进 MCU"的人;
- 需要维护 Makefile/CMake/工具链,想弄明白每个 flag 在干什么的人。
本文的数字从哪来
全部来自配套工程 projects/key_led_f103/(STM32F103C8T6,GCC 15.2.1 arm-none-eabi)。视频里的数字和它一致:
| 项 | 值 |
|---|---|
源码 main.c |
823 B / 24 行 |
预处理 main.i |
109,512 B / 3,726 行 |
汇编 main.s |
15,364 B / 927 行 |
目标文件 main.o |
5,320 B |
链接 firmware.elf |
68,856 B(35 个 .o) |
烧录 firmware.bin |
3,624 B |
HEX firmware.hex |
10,276 B |
size |
text 3,520 / data 104 / bss 1,992 |
823 → 3,624,中间差 4.4 倍;但 .elf 又有 68,856 B。三个数字分别代表三种"大小",混在一起就会得出"编译器把代码放大了 80 倍"这种错误结论(第 6 章拆)。
怎么读
| 读法 | 路径 |
|---|---|
| 第一次 | 第 0 章 → 第 10 章顺序读 |
| 只想搞懂交叉编译 | 第 4 章(4.2--4.8) |
| 只想搞懂链接错误 | 第 8 章 + 第 6.4 节 |
| 面试突击 | 第 6 章(五道工序)+ 第 8 章(重定位/符号)+ 第 11 章 |
视频章节 ↔ 本文
| 视频层 | 时间码 | 讲什么 | 本文 |
|---|---|---|---|
| L1 开场 | 00:00--00:20 | 823 B → 3,624 B;五道工序 | 第 1 章 |
| L2 纸带 | 00:20--01:21 | ENIAC、纸带、位域 | 第 2 章 |
| L3 汇编/ISA | 01:21--02:19 | 汇编器、ISA、换机重写 | 第 3 章 |
| L4 C | 02:19--03:00 | 抽象、三段编译、一行变四条 | 第 4 章 |
| L5 C++ | 03:00--03:42 | 零开销、名字改编 | 第 5 章 |
| L6 五道工序 | 03:42--04:23 | 真实尺寸、-Os 澄清 |
第 6 章 |
| L7 反汇编 | 04:23--05:02 | 查表/点灯/puts/循环 | 第 7 章 |
| L8 链接 | 05:02--06:14 | 符号、重定位、段、脚本 | 第 8 章 |
| L9 工具链 | 06:14--06:49 | 八件套、构建、size | 第 9 章 |
| L10 总结 | 06:49--07:20 | 七十年与一条链 | 第 10 章 |
第 0 章 五个问题
- 823 字节的
main.c,为什么烧进芯片是 3,624 字节?多出来的 2,801 字节是什么? - 预处理后为什么变成 109,512 字节?多出来的 108,689 字节是谁?
.o里的函数地址为什么是 0?谁把它填上的?-Os的O是什么?-O0/-O2/-Os到底改了什么?- 同一份 C,为什么不能在 Windows 上跑、也不能直接跑在 MCU 上?
一句话立意:编译是"翻译 + 分层",链接是"拼装 + 定址";你写的每个字节,最后都要落进某个段的某个地址。
第 1 章 823 字节 → 3,624 字节:中间发生了什么

图 1-1 源码与固件的尺寸差(视频 00:05)
1.1 五道工序
| 工序 | 命令 | 输入 → 输出 | 本例尺寸 | 本质 |
|---|---|---|---|---|
| 预处理 | gcc -E |
.c → .i |
823 → 109,512 | 展开头文件/宏/条件编译 |
| 编译 | gcc -S |
.i → .s |
→ 15,364 | 语法/语义 → 汇编 |
| 汇编 | as |
.s → .o |
→ 5,320 | 汇编 → 机器码 + 重定位表 |
| 链接 | ld |
35 个 .o + 库 → .elf |
→ 68,856 | 合并段、定地址、填重定位 |
| 转格式 | objcopy |
.elf → .bin |
→ 3,624 | 只留加载内容 |
"3,624 = 823 × 4.4"不是编译膨胀,而是:
3,624 = 你的代码(约 300 B)
+ 启动代码/向量表(236 B)
+ 标准库(puts/printf 相关,约 1.5 KB)
+ 外设库/系统初始化(SystemInit 240 B + 其他)
+ .data 初值(104 B)
第 6 章会把这 2,801 字节逐个拆开。
1.2 三个"大小"不要混
| 文件 | 大小 | 里面是什么 | 用途 |
|---|---|---|---|
main.i |
109,512 B | 展开后的 C(含整个头文件) | 排查宏/条件编译 |
main.o |
5,320 B | 机器码 + 符号 + 重定位(地址不全) | 链接的输入 |
firmware.elf |
68,856 B | 段 + 符号 + 调试信息(DWARF) | 调试、反汇编、符号化 |
firmware.bin |
3,624 B | 只有"要烧进 Flash 的字节" | 烧录 |
firmware.hex |
10,276 B | 带地址/校验的文本格式 | 烧录/传输 |
.elf 比 .bin 大 19 倍,差的是元数据(符号表、调试信息、段头),不是代码(第 6.5 节)。
落到项目
- 打开你的构建目录,找齐这五个文件,用
ls -l对一遍尺寸; - 如果
main.i特别大(几十 MB),检查头文件包含顺序/是否包含了不该包含的巨型头; - 养成"看
.o大小变化"的习惯:某个.c的.o突然翻倍,通常是加了大表或模板。
第 2 章 从纸带到开关:机器码到底是什么

图 2-1 纸带上的孔就是指令位(视频 00:35)
2.1 视频里的历史,一句话就够
ENIAC 用插线板编程;后来用纸带,一排孔 = 一条指令;程序员翻手册查操作码、手算地址、一个孔一个孔地打。那时候人就是编译器,一个孔打错整卷重来。
这段历史的价值不在"故事",而在一个事实:机器码就是按位段排布的数字。指令寄存器里的每一位都有含义,不是"黑箱"。
2.2 真实 Thumb 编码解剖
视频举的例子是 movs r0, #1 → 机器码 0x2001。为什么?
Thumb-1 的"MOVS 立即数"编码(16 位):
位: 15 14 13 12 11 | 10 9 8 | 7 6 5 4 3 2 1 0
0 0 1 0 0 | Rd | imm8
└── 操作码 ──┘ └ 目标 ┘ └ 立即数 ┘
- 操作码
00100= MOVS 立即数; Rd = 000= r0;imm8 = 0000_0001= 1。
拼起来:0010 0000 0000 0001 = 0x2001。每个位都能对上,没有魔法。
再看一条 bl(带链接跳转,32 位 Thumb-2):
f000 f8c4 bl 0x08000ae8
高 16 位 f000 的低 11 位 + 低 16 位 f8c4 的低 11 位,拼成 24 位有符号偏移(再左移 1)。链接器的工作就是把"偏移"算出来填进这两个 11 位字段(第 8.2 节)。
2.3 CPU 是"聪明的笨蛋"
- 它不懂变量、循环、函数;
- 它只会:取指 → 译码 → 执行(读寄存器/读内存/运算/写回/跳转);
- 所谓程序,就是"按硬件规定的格式排好的 0 和 1"。
理解了这一点,后面所有"抽象"(汇编、C、C++)都只是让人少写 0 和 1 的工具。
2.4 为什么手写机器码不可维护
| 问题 | 后果 |
|---|---|
| 每条指令都要查表 | 慢、易错 |
| 地址要手算 | 加一行代码,后面全变 |
| 没有符号/名字 | 看不懂、改不动 |
| 换芯片要重打 | 不可移植 |
于是有了汇编器。
落到项目
- 用
objdump -d随便找一条指令,对照 ARM 手册的编码表,确认你能拆出操作码/寄存器/立即数; - 记住
bl的 32 位编码形态(f000 f8xx),调试时看到它就知道是"函数调用"。
第 3 章 汇编器与 ISA:换一台机器就要重写

图 3-1 ISA 定死寄存器/编码/寻址(视频 01:50)
3.1 汇编器做两件事
- 翻译助记符 :
movs r0,#1→0x2001; - 算地址:标号(label)在第一遍记下,第二遍回填。
两遍扫描是必须的:向前跳转时,目标地址还没出现。伪代码:
pass 1: 扫描每一行,记录 label → 当前地址;计算每条指令的长度
pass 2: 再扫一遍,把 label 替换成地址,生成机器码 + 重定位表
3.2 ISA 到底包含什么
| 组成 | 例子(Cortex-M3) |
|---|---|
| 寄存器 | r0--r15、xPSR、特殊寄存器 |
| 指令编码 | Thumb/Thumb-2,16/32 位混合 |
| 寻址方式 | 立即数、寄存器偏移、PC 相对 |
| 内存模型 | 统一编址、对齐要求、大小端 |
| 异常/特权 | 异常入口、MSP/PSP、特权级 |
汇编只是 ISA 的 1:1 翻译,所以"会 ARM 汇编"不等于"会写可移植代码"。
3.3 同一串 0x2001,不同机器不同含义
视频举的例子很到位:同一串位,A 厂读成"把 1 放进 R0",B 厂读成"两个寄存器相加",C 厂读成"跳到地址 1"。这就是ISA 不兼容:二进制层面没有"通用机器码"。
ARM 内部也有类似问题:ARM 状态 vs Thumb 状态 。同一个地址,bit0=0 按 ARM(32 位指令)解释,bit0=1 按 Thumb 解释。Cortex-M 只支持 Thumb ,所以函数指针的最低位永远是 1(第 6.1 节的 0x0800_086D)。
3.4 "换机重写"的死结
| 年代 | 做法 | 代价 |
|---|---|---|
| 纸带 | 手打机器码 | 换机 = 全部重打 |
| 汇编 | 助记符 + 标号 | 换机 = 重写汇编(指令/寄存器都不同) |
| 高级语言 | 编译器生成目标码 | 换机 = 重新编译(源码不动) |
可移植性不是"代码能在任何机器跑",而是"同一份源码能编给任何机器"------这句话是第 4 章(尤其 4.5/4.6)的引子。
落到项目
- 用
objdump -d看你的.o,找一条bl,确认它是 32 位编码; - 如果你手写过启动文件/汇编,检查有没有"假设 ARM 状态"的代码(Cortex-M 上会 fault);
- 换芯片时,先确认 ISA 是否兼容(M0/M3/M4/M7 的 Thumb 子集不同)。
第 4 章 C:把机器细节藏进编译器

图 4-1 1972 年 PDP-11 上的 C(视频 02:40)
4.1 同一段 C,编给三种 CPU
c
a = b + c;
| 阶段 | 做什么 |
|---|---|
| 前端 | 读懂 C(词法/语法/语义),生成中间表示(IR) |
| 中端 | 优化 IR(常量折叠、循环优化...) |
| 后端 | 按目标 ISA 生成汇编(选寄存器、选指令、排指令) |
抽象和真实不是一一对应:一行 C 摊成四条指令:
asm
ldr r0, [r1] ; 读 b
ldr r2, [r3] ; 读 c
adds r0, r0, r2 ; 加
str r0, [r4] ; 写 a
常见误区:"一行 C = 一条指令"。错。优化等级、寄存器压力、寻址方式都会改变指令条数。
但这只是"指令层"的差异。真正决定"能不能跑"的,还有下面三层。
4.2 编译器不是"翻译器":它同时决定 ISA、ABI 和运行环境
"把 C 翻译成机器码"这句话漏掉了两件同样重要的事。编译器后端真正要产出的是三样东西:
| 层 | 决定什么 | 例子 |
|---|---|---|
| ISA(指令集) | 有哪些指令、多少位编码 | Cortex-M0 没有 mla/sdiv;M3 有 |
| ABI(应用二进制接口) | 参数放哪、谁保存寄存器、栈怎么对齐、浮点用哪个寄存器 | soft-float 用 r0/r1 传 float;hard-float 用 s0/s1 |
| 运行环境(OS/库) | 程序怎么被加载、printf 最终写到哪、启动时谁调 main |
Linux 用 _start + syscall;MCU 用 Reset_Handler + 你写的 _write |
所以"同一段 C 编给三种 CPU",不只是换指令,而是同时换 ABI 和运行库 。这就是为什么换一个 -mcpu,目标文件的大小、依赖的库函数、甚至链接能不能过都会变------4.4 节有实测。
4.3 交叉编译器:名字里的每个词都有意义
你在 MCU 工程里用的 arm-none-eabi-gcc 不是"ARM 版的 gcc"这么简单,它的名字是一个目标三元组(target triple):
arm - none - eabi
│ │ │
架构 厂商 操作系统/ABI
| 三元组 | 架构 | OS/ABI | 产物能跑在哪 |
|---|---|---|---|
arm-none-eabi |
ARM 32 位 | 无 OS,EABI | 裸机 MCU(Cortex-M) |
arm-linux-gnueabihf |
ARM 32 位 | Linux,hard-float | ARM Linux 板(树莓派等) |
aarch64-none-elf |
ARM 64 位 | 无 OS | 裸机 AArch64 |
x86_64-linux-gnu |
x86-64 | Linux | PC 上的 Linux |
x86_64-w64-mingw32 |
x86-64 | Windows | PC 上的 Windows |
关键点:
none不是"没有厂商",而是"没有操作系统 "。裸机程序没有 loader、没有 syscall,printf不能直接工作;eabi= Embedded ABI,裸机场景的调用约定(和 Linux 的gnueabi不同);- 交叉编译器 = 编译机(host)≠ 运行机(target) 。你在 Windows 上敲
arm-none-eabi-gcc,它生成的是 ARM 机器码,Windows 自己根本执行不了------但这不是 bug,正是它的用途。
为什么不能用电脑上的
gcc编 MCU 程序? 因为本机gcc的目标是x86_64-w64-mingw32(或x86_64-linux-gnu):它生成 x86 指令、Windows/Linux 的 ABI、依赖KERNEL32.dll/libc.so。这些在 Cortex-M 上一样都不存在。编译器默认服务"自己所在的平台",交叉编译器才服务"目标板"。
4.4 实测:同一份 C,四个目标,四种结果
用同一份源码(projects/p03_cross/x.c),只改 -mcpu/-mfpu,看产物怎么变:
c
volatile int g_sink;
int sum_to(int n){ int s=0; for(int i=1;i<=n;i++) s += i*(i&7); return s; }
void scale(float *p, int n){ for(int i=0;i<n;i++) p[i] = p[i]*0.5f + 1.0f; }
| 目标 | 命令关键参数 | .text |
未定义符号 | 关键指令 |
|---|---|---|---|---|
| Cortex-M0 | -mcpu=cortex-m0 -mthumb |
72 B | __aeabi_fmul、__aeabi_fadd |
muls + adds(无 MLA) |
| Cortex-M3 | -mcpu=cortex-m3 -mthumb |
80 B | __aeabi_fmul、__aeabi_fadd |
mla(一条顶两条) |
| Cortex-M7 | -mcpu=cortex-m7 -mthumb |
80 B | 同上 | mla |
| Cortex-M4F | -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard |
68 B | 无(全硬件) | vfma.f32 |
三个反直觉的结论:
- M0 的代码反而最小(72 B < 80 B) 。因为 M0 只有 Thumb-1(16 位编码),M3/M7 的
mla、宽立即数要用 32 位 Thumb-2 编码。"CPU 越强代码越小"是错的------指令更强往往编码更长。 - M3/M7 不算"有浮点" 。没写
-mfpu/-mfloat-abi=hard时,float运算仍然调用__aeabi_fmul/__aeabi_fadd(软件浮点),每次调用还要压栈。"芯片有 FPU" ≠ "编译器会用 FPU"。 - M4F 的 ABI 变了。看 ELF 属性:
bash
arm-none-eabi-readelf -A m4f.o | grep -E "CPU_arch|FP_arch|VFP_args"
# Tag_CPU_arch: v7E-M
# Tag_FP_arch: VFPv4-D16
# Tag_ABI_VFP_args: VFP registers ← 参数用 s0/s1 传,不是 r0/r1
ABI 不匹配会直接链接失败(这是真实报错,不是理论):
ld: error: mix.o uses VFP register arguments, hf_soft.o does not
所以工程里有一条铁律:整个工程(包括预编译库、RTOS、DSP 库)必须用同一套 -mfloat-abi/-mfpu。混用 soft-float 和 hard-float 的库,链接器会拦你;如果链接器没拦(比如通过函数指针),运行时会以最诡异的方式崩。
4.5 为什么 Linux 编的程序不能在 Windows 上跑
这是"交叉编译"最容易被跳过、但最能说明问题的一问。即使两台机器都是 x86-64,Linux 编译的 ELF 也不能在 Windows 上双击运行,原因有三层,缺一不可:
第一层:可执行文件格式不同(谁认这个文件)
| Linux | Windows | |
|---|---|---|
| 格式 | ELF (0x7F 'E' 'L' 'F') |
PE/COFF (MZ 开头) |
| 加载器 | 内核读 ELF 头 + PT_INTERP(动态链接器路径) |
ntdll/kernel32 读 PE 头 + 导入表 |
| 动态库 | .so(libc.so.6) |
.dll(KERNEL32.dll、ucrtbase.dll) |
Windows 的加载器看到 ELF 文件,第一反应是"这不是 PE",直接拒绝。这不是"缺个运行库",是文件格式就不认。
第二层:系统调用/运行时不同(程序怎么跟世界打交道)
printf("hi") 在 Linux 上最终是 write(1, ...)(syscall 号 1,syscall 指令);在 Windows 上是 WriteFile(GetStdHandle(...))(走 KERNEL32.dll → ntdll.dll → 内核)。同一行 C,底层走的 API 完全不同 ,glibc 和 msvcrt 是两套实现。
第三层:ABI 不同(函数之间怎么传参)
即使都是 x86-64,Linux(SysV ABI)和 Windows(Microsoft x64 ABI)的约定也不同:
| 项 | Linux SysV AMD64 | Windows x64 |
|---|---|---|
| 前 4 个整型参数 | rdi, rsi, rdx, rcx |
rcx, rdx, r8, r9 |
| 浮点参数 | xmm0--xmm7 |
xmm0--xmm3 |
| 调用者栈上的"影子空间" | 无 | 32 B shadow space |
| 栈对齐 | 调用前 16 B | 调用前 16 B(且要留 shadow) |
所以同一份机器码在两个系统上也互不理解 ------参数放错寄存器,main 的序言都对不上。
结论 :C 的"可移植"是源码级可移植 ,不是二进制可移植。要让同一份源码跑在另一个平台,必须用目标平台的编译器重新编译、链接目标平台的库。这就是"交叉编译"存在的全部理由。
怎么自己验证(不需要装双系统):
bash
# Linux 上
file ./a.out # ELF 64-bit LSB executable, x86-64
readelf -h ./a.out # Machine: Advanced Micro Devices X86-64
readelf -l ./a.out | grep INTERP # 有 /lib64/ld-linux-x86-64.so.2
readelf -d ./a.out | grep NEEDED # libc.so.6
# Windows 上(VS 开发者命令行)
dumpbin /headers app.exe # machine (x64), subsystem (Windows CUI)
dumpbin /imports app.exe # KERNEL32.dll / ucrtbase.dll
4.6 为什么更不能直接跑在 MCU 上:三层全缺
从 Linux 到 Windows,至少还有"操作系统"这一层;从 PC 到 MCU,连操作系统都没有:
| 层 | PC(Linux/Windows) | 裸机 MCU |
|---|---|---|
| 加载器 | 内核/PE loader 把程序装进内存 | 没有 ;复位后硬件从向量表取 MSP + Reset_Handler |
| 入口 | _start → __libc_start_main → main(argc, argv) |
Reset_Handler → 启动代码 → main(void),没有 argc/argv |
| 系统调用 | write/ReadFile |
没有 ;printf 最终调 _write,要你自己实现(或 nosys 返回 -1) |
| 内存布局 | 虚拟内存、按需分页 | 物理地址写死:Flash 0x0800_0000、SRAM 0x2000_0000,由链接脚本决定 |
| 动态链接 | .so/.dll |
一般静态链接;没有动态加载器 |
用我们 P07 的固件(projects/p07_stack/out_case1/firmware.elf)验证:
bash
arm-none-eabi-readelf -h firmware.elf
# Class: ELF32
# Type: EXEC
# Machine: ARM
# Entry point address: 0x8000179 ← Reset_Handler,不是 _start
# Flags: 0x5000200, Version5 EABI, soft-float ABI
arm-none-eabi-readelf -l firmware.elf | grep -E "INTERP|LOAD"
# (没有 INTERP)
# LOAD 0x001000 0x08000000 ... ← Flash
# LOAD 0x002000 0x20000000 ... ← SRAM(.data 的 VMA)
arm-none-eabi-readelf -d firmware.elf
# There is no dynamic section in this file. ← 没有 .so,没有动态链接
没有 INTERP、没有动态段、入口是 Reset_Handler ------这三条就是"裸机"的 ELF 特征。要让 printf 能输出,你需要:
- 链接
newlib-nano(-specs=nano.specs); - 提供
_write(通常是"往 UART DR 写一个字节"),或者用-specs=nosys.specs让它返回 -1(此时printf什么也不输出,但能链接过); - 提供启动文件(向量表 +
Reset_Handler+.data拷贝 +.bss清零); - 用链接脚本把段放到正确地址。
这四件事,就是"交叉编译 + 裸机工程"比"写个 C 文件"多出来的全部工作量。
4.7 工具链参数速查(以及四个真实坑)
| 参数 | 作用 | 典型值 |
|---|---|---|
-mcpu= |
目标 CPU | cortex-m0/m3/m4/m7 |
-mthumb |
用 Thumb 指令集 | Cortex-M 必须 |
-mfloat-abi= |
浮点 ABI | soft(默认)/softfp/hard |
-mfpu= |
浮点单元 | fpv4-sp-d16(M4F)、fpv5-d16(M7) |
-mabi=aapcs |
调用约定 | 一般用默认 |
-specs=nano.specs |
用 newlib-nano(小) | 省 Flash |
-specs=nosys.specs |
提供空 syscall 桩 | 先链接过,再补 _write |
-nostartfiles |
不用标准启动 | 自己写 Reset_Handler 时 |
-Wl,--gc-sections |
丢弃未引用段 | 配合 -ffunction-sections |
坑 1:用错编译器。 CMake 工程忘了设 CMAKE_C_COMPILER,结果用了本机 gcc,链接时报"不认识的 -mcpu"或生成 x86 目标文件。对策:用 toolchain file(见 4.8)。
坑 2:浮点 ABI 混用。 自己代码 hard、第三方库 soft → 链接报 uses VFP register arguments。对策:全工程统一,并在 CI 里检查 readelf -A。
坑 3:-nostdlib 后缺 libgcc。 除法/64 位乘法/软浮点会引用 __aeabi_*,报 undefined reference。对策:-nostdlib 时手动加 -lgcc。
坑 4:newlib-nano 的 printf 不支持浮点。 默认裁剪了浮点格式化,printf("%f") 输出空/乱码。对策:加 -u _printf_float(Flash 变大),或改用整数/定点打印。
4.8 工程落地:一个最小 CMake toolchain file
cmake
# toolchain-arm-none-eabi.cmake
set(CMAKE_SYSTEM_NAME Generic) # 无 OS
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)
set(CMAKE_OBJCOPY arm-none-eabi-objcopy)
set(CMAKE_C_FLAGS_INIT
"-mcpu=cortex-m3 -mthumb -mfloat-abi=soft -O2 -g3 \
-ffunction-sections -fdata-sections")
set(CMAKE_EXE_LINKER_FLAGS_INIT
"-Wl,--gc-sections -specs=nano.specs -specs=nosys.specs -T${CMAKE_SOURCE_DIR}/STM32F103C8Tx_FLASH.ld")
# 交叉编译时"编译测试程序"无法运行,改成只编译静态库
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
配置时:
bash
cmake -B build -DCMAKE_TOOLCHAIN_FILE=toolchain-arm-none-eabi.cmake
看懂这三行,你就跨过了"点灯"和"能维护工程"之间的门槛 :
Generic告诉 CMake"没有 OS,别找unistd.h";TRY_COMPILE_TARGET_TYPE避免"编译出的程序跑不起来"导致配置失败;-specs=nosys.specs让printf至少能链接过。
随堂 3 问
- 为什么 Cortex-M0 的目标文件可能比 Cortex-M3 的更小?
- 同一台 x86-64 电脑上,Linux 的 ELF 为什么不能在 Windows 上直接运行?至少说出两层原因。
- 你的工程用了
-mfloat-abi=hard,但第三方 DSP 库是soft,会发生什么?怎么发现?
答案:1. M0 只有 Thumb-1(16 位编码),M3 的
mla/宽立即数需要 32 位 Thumb-2 编码,指令更强但编码更长;2. 文件格式(ELF vs PE,加载器不认)+ 系统调用/运行库(glibc vs msvcrt)+ ABI(参数寄存器/影子空间)三者都不同;3. 链接器报uses VFP register arguments, ... does not;用readelf -A检查Tag_ABI_VFP_args,或全工程统一 flags。
第 5 章 C++:更厚的抽象,零开销
5.1 名字改编:链接器只认"乱码"
cpp
template<typename T> T max(T a, T b) { return a > b ? a : b; }
max<int>(1, 2);
符号表里不是 max,而是:
_Z3maxIiET_S0_S0_
这是 Itanium C++ ABI 的名字改编(mangling):_Z + 长度 + 名字 + 模板参数 + 参数类型。链接器不认识 C++,它只按字符串匹配;两个同名但参数不同的重载,靠改编后的名字区分。
工程含义 :C 和 C++ 混合编译时,C 的函数必须用
extern "C"包住,否则 C++ 编译器会改编名字,C 那边找不到。
5.2 "零开销"到底零在哪
| 特性 | 不用的代价 | 用了的代价 |
|---|---|---|
| 类/成员函数 | 0 | 和 C 函数一样(非虚) |
| 模板 | 0 | 每个实例化一份代码(可能膨胀) |
| 虚函数 | 0 | 每个对象一个 vptr(4/8 B)+ 虚表(Flash) |
| 异常 | 0(关掉时) | 展开表 + 运行时(Flash/RAM 都涨) |
| RTTI | 0(关掉时) | 类型信息表(Flash) |
new/delete |
0 | 堆 + 异常/失败处理 |
"零开销"的前提是"你不用"。嵌入式里常见的配置:
bash
-fno-exceptions -fno-rtti -fno-threadsafe-statics
关掉异常/RTTI 后,C++ 的代码尺寸和 C 很接近;但一旦用了异常,链接会拉进整个 unwinder,Flash 可能涨几 KB。这就是"嵌入式慎用异常"的真实原因,不是教条。
5.3 嵌入式用 C++ 的检查清单
- 是否关了异常/RTTI(或明确接受成本)?
- 是否禁了
new/delete(或用了自定义池)? - 静态局部对象是否有非平凡构造(
__cxa_guard)? - 虚函数是否只在"真的需要多态"的地方用?
- 模板实例化是否导致代码膨胀(
nm --size-sort看)? - 是否有全局对象的构造顺序问题(静态初始化顺序 fiasco)?
落到项目
- 用
nm -C firmware.elf | grep -c "_Z"数一下 C++ 符号,确认没有意外的模板实例化; - 检查编译选项里是否有
-fno-exceptions -fno-rtti; - 如果用了 C++,给
.init_array留够空间(全局构造函数的调用表)。
第 6 章 五道工序:每一步到底改了什么

图 6-1 main.c / main.i / main.s / .o / elf / bin(视频 04:10)
6.1 预处理:823 → 109,512 字节
bash
arm-none-eabi-gcc -E -Os -mcpu=cortex-m3 -mthumb main.c -o main.i
多出来的 108,689 字节,99% 是头文件 :#include "stm32f10x.h" 会展开 core_cm3.h、system_stm32f10x.h、各外设寄存器定义......最终 3,726 行。
预处理做四件事:
| 动作 | 例子 | 注意 |
|---|---|---|
#include |
把头文件内容原样插入 | 递归包含靠 include guard |
#define 展开 |
#define LED PIN(5) |
宏没有类型检查、有副作用 |
| 条件编译 | #ifdef DEBUG |
不同配置编出不同代码 |
| 删除注释/续行 | //、\ |
不影响语义 |
宏的三个经典坑:
c
#define SQUARE(x) x*x
SQUARE(a+1) /* 展开成 a+1*a+1,错 */
#define SQUARE(x) ((x)*(x)) /* 加括号 */
#define LOG(fmt, ...) do { printf(fmt, ##__VA_ARGS__); } while (0)
/* do{}while(0) 让宏在 if/else 里安全 */
调试技巧 :gcc -E -dM 列出所有宏定义;-H 打印头文件包含树,能发现"谁偷偷包含了巨型头"。
6.2 编译:.i → .s(927 行)
bash
arm-none-eabi-gcc -S -Os -mcpu=cortex-m3 -mthumb main.i -o main.s
编译做的是"语义 → 汇编":选指令、分配寄存器、排流水线。这一阶段最容易被忽视的是优化等级改变的不只是速度:
| 选项 | 典型效果 | 风险 |
|---|---|---|
-O0 |
每条语句独立、变量都在栈上 | 代码大、慢,但好调试 |
-O2 |
内联、常量传播、寄存器分配 | volatile 缺失会暴露、断点错位 |
-Os |
优先体积(本例用它) | 可能把 printf 换成 puts、合并字符串 |
-Ofast |
放宽浮点/别名规则 | 数值行为可能变,嵌入式慎用 |
未定义行为(UB)在优化下会"变脸":
c
int f(int x) { return x + 1 > x; } /* 有符号溢出是 UB */
-O0 可能返回 1,-O2 可能直接优化成常量 1 或 0------因为编译器假设"不会溢出"。嵌入式里常见 UB:有符号溢出、越界访问、未初始化读取、违反严格别名、volatile 缺失。
真实例子 :本例 printf("blink start\n") 在 -Os 下被换成 puts(没有格式符,puts 更小)。这不是"编译器乱改",是标准允许的等价变换------库函数也是优化对象。
6.3 汇编:.s → .o(5,320 B)
bash
arm-none-eabi-as -mcpu=cortex-m3 -mthumb main.s -o main.o
汇编器输出三样东西:
- 机器码 (放在
.text.*段); - 符号表 (
T/t/B/D/R/U); - 重定位表(哪些位置"地址还没填")。
真实符号(nm main.o):
00000000 T main
00000000 T SysTick_Handler
00000000 B g_err_cnt
00000000 D g_ms
00000000 b s_ps.0
00000000 b s_seq_idx
00000000 R SEQ
U puts
所有地址都是 0 :.o 是"半成品",段内偏移有了,最终地址没有。U puts 表示"我用了,但没定义"------等链接器去库里找。
6.4 链接:35 个 .o → 68,856 B
bash
arm-none-eabi-gcc -Os -mcpu=cortex-m3 -mthumb \
-T STM32F103C8Tx_FLASH.ld -specs=nano.specs -specs=nosys.specs \
-Wl,--gc-sections -Wl,-Map=firmware.map \
objs/*.o -o firmware.elf
链接器做四件事:
| 步骤 | 做什么 | 本例 |
|---|---|---|
| 符号解析 | 把 U 和 T 配对;找不到就报错 |
puts 在 newlib 里找到 |
| 拉取库成员 | 从 .a 里只取"被引用的 .o" |
为了 puts 拉进 puts.o/_puts_r 等 |
| 合并段 | 所有 .text.* → .text |
35 个 .o → 6 个段 |
| 定址+重定位 | 按链接脚本分配地址,填重定位 | .text @ 0x0800_00EC |
undefined reference 只可能发生在这里 :编译单个 .c 时只看到声明(U puts),链接器才去找定义。所以"编译过了、链接不过"永远不是语法问题。
--gc-sections 的真相 :从入口(Reset_Handler)出发,标记所有可达的段,丢弃其余。本例 g_err_cnt 因为没人引用,被丢掉了(nm firmware.elf | grep g_err 为空)。省 Flash 最有效的一招,但要注意 KEEP(第 8.3 节)。
6.5 objcopy:68,856 → 3,624 B
bash
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex
.elf 里有什么、.bin 里没有什么:
| ELF 有、bin 没有 | 作用 | 本例大小 |
|---|---|---|
| ELF 头 + 程序头 + 段头 | 告诉加载器/调试器"段在哪" | 几百 B |
.symtab + .strtab |
符号名 | 约 5.6 KB |
.debug_*(DWARF) |
行号、变量类型、调用帧 | 约 51 KB |
.rel*(链接后一般已处理) |
重定位 | 少量 |
.comment / .ARM.attributes |
编译器版本、ABI | 110 B |
所以 .elf 68,856 B、.bin 3,624 B,差的 65,232 B 几乎全是调试信息 。烧录用 bin/hex,调试用 elf,两者不能互换。
.hex 又比 .bin 大(10,276 B),因为它是文本格式 :每行 :LLAAAATT[DD..]CC,带地址、记录类型、校验和。第一条记录:
:020000040800F2
02= 2 字节数据;0000= 偏移 0;04= 扩展线性地址记录;0800= 高 16 位地址(所以后续数据在0x0800_xxxx);F2= 校验和(0x100 - (02+00+00+04+08+00) & 0xFF)。
6.6 三个"大小"的用途
| 文件 | 什么时候看它 |
|---|---|
.i |
宏展开不对、条件编译没生效 |
.s |
想看编译器生成了什么指令(比反汇编可读) |
.o |
想看"这个文件贡献了多少代码/符号" |
.elf |
调试、addr2line、反汇编、符号化 |
.bin |
烧录、算 Flash 占用 |
.hex |
烧录(带地址,适合 bootloader/远程升级) |
落到项目
- 构建脚本保留
.i/.s/.o/.map,出问题时不用重新编译就能查; - 用
size和nm --size-sort定期看"谁在变大"; - 每次改优化等级,重新跑一遍
-fstack-usage和关键循环的反汇编(P07 第 5 章)。
第 7 章 反汇编:C ↔ Thumb 逐条对照

图 7-1 查表/点灯/puts/循环(视频 05:20)
objdump -d 把机器码翻译回汇编,并尽量插入源码行。本例 main 的真实输出(-Os):
asm
08000958 <main>:
8000958: b510 push {r4, lr}
800095a: 4809 ldr r0, [pc, #36] ; "blink start\n"
800095c: f000 f8c4 bl 8000ae8 <puts> ; printf 被优化成 puts
8000960: 4b08 ldr r3, [pc, #32] ; r3 = &s_seq_idx
8000962: 4909 ldr r1, [pc, #36] ; r1 = &SEQ
8000964: 781b ldrb r3, [r3, #0] ; 读 s_seq_idx
8000966: 4809 ldr r0, [pc, #36] ; r0 = GPIOA
8000968: 4c09 ldr r4, [pc, #36] ; r4 = &g_ms
800096a: f003 0203 and.w r2, r3, #3 ; idx & 3
800096e: 5c8a ldrb r2, [r1, r2] ; seq_copy = SEQ[idx]
8000970: 3301 adds r3, #1 ; s_seq_idx++
8000972: 0152 lsls r2, r2, #5 ; 1<<5 / 2<<5 / 4<<5 / 8<<5
8000974: f8c0 2810 str.w r2, [r0, #2064] ; 写 GPIOA->BSRR(+0x810)
8000978: 6822 ldr r2, [r4, #0] ; 读 g_ms(每圈都读)
800097a: 2a00 cmp r2, #0
800097c: d1fc bne.n 8000978 ; while(g_ms)
800097e: e7f4 b.n 800096a ; 回到循环开头
逐段对照:
| C | 汇编 | 说明 |
|---|---|---|
SEQ[s_seq_idx&3] |
and.w + ldrb |
先按 3 取模,再从 Flash 读字节 |
*bsrr = seq_copy<<5 |
lsls + str.w [r0,#0x810] |
左移 5 位,写到 GPIOA+0x810(BSRR) |
printf("blink start\n") |
ldr + bl puts |
无格式符,-Os 换成更小的 puts |
while(g_ms){} |
ldr + cmp + bne |
因为 volatile,每圈都重新读 |
注意 .o 版本(未链接):
asm
4: f7ff fffe bl 0 <puts>
bl 0 + R_ARM_THM_CALL puts:偏移字段是 0,等链接器填。链接后变成 f000 f8c4 bl 8000ae8 <puts>------机器码变了,因为偏移被算出来写进去了(第 8.2 节)。
落到项目
- 对你最热的那个循环做
objdump -d,确认关键变量每圈都在读内存(ldr); - 看到
bl 0就知道"还没链接";看到bl 0x...但符号名是puts,说明库函数被拉进来了; -Os下printf变puts、memset变内联,都是正常的,不要惊讶。
第 8 章 链接:把洞补上

图 8-1 T/U 清单、地址飞入指令、LMA/VMA(视频 06:05)
8.1 符号表:链接器的"通讯录"
nm main.o 的真实输出:
00000000 T main
00000000 T SysTick_Handler
00000000 B g_err_cnt
00000000 D g_ms
00000000 b s_ps.0
00000000 b s_seq_idx
00000000 R SEQ
U puts
符号类型(nm 第一列字母):
| 字母 | 含义 | 例子 |
|---|---|---|
T / t |
代码段,全局 / 局部 | main / static 函数 |
D / d |
已初始化数据,全局 / 局部 | g_ms |
B / b |
未初始化数据(.bss),全局 / 局部 |
g_err_cnt / s_seq_idx |
R / r |
只读数据 | SEQ |
A |
绝对地址(链接脚本符号) | _sidata |
W / w |
弱符号(可被覆盖) | Reset_Handler |
U |
未定义(本文件用、别处定义) | puts |
链接器先建全局符号表:所有 T/D/B/R 进"定义表",所有 U 进"待查表";然后逐个配对。配不上就是 undefined reference ,配重了(两个强定义)就是 multiple definition。
8.2 重定位:不是"贴地址",是"改立即数"
readelf -r main.stage.o 的真实重定位表:
Relocation section '.rel.text.SysTick_Handler':
Offset Type Sym.Name
00000024 R_ARM_ABS32 .bss.s_ps.0
00000028 R_ARM_ABS32 g_ms
Relocation section '.rel.text.startup.main':
00000004 R_ARM_THM_CALL puts
00000028 R_ARM_ABS32 .rodata.main.str1.1
0000002c R_ARM_ABS32 .bss.s_seq_idx
00000030 R_ARM_ABS32 SEQ
00000038 R_ARM_ABS32 g_ms
两种类型,两种改法:
| 类型 | 用在哪 | 怎么填 |
|---|---|---|
R_ARM_ABS32 |
数据里的指针(&g_ms、字面量池) |
把 32 位绝对地址整字写进去 |
R_ARM_THM_CALL |
bl func |
算"目标 − 当前指令"的偏移,编码进指令的 11+11 位 |
bl puts 的前后对比:
链接前: 800095c: f7ff fffe bl 0 <puts> ; 偏移字段 = 0,等填
链接后: 800095c: f000 f8c4 bl 8000ae8 <puts> ; 偏移算出来,编码进去
机器码变了 (f7ff fffe → f000 f8c4),因为偏移不是"附加数据",而是指令的一部分。这就是为什么"改一行代码,前面所有地址都变"------所有 bl 的偏移都要重算。
8.3 段合并与 --gc-sections
每个 .o 都有一堆小段(-ffunction-sections -fdata-sections 的产物)。链接器按名字归类,真实结果:
.text 0x080000EC 3,268 B (所有 .text.* 合并)
.rodata 0x08000DB0 16 B (字符串 12 + SEQ 4)
.data 0x20000000 96 B LMA 0x08000DC8
.bss 0x20000060 452 B
._user_heap_stack 0x20000224 1,540 B (堆+栈预留)
--gc-sections 从入口出发做可达性分析,丢弃没人引用的段。两个必须配合的细节:
- 向量表要
KEEP:.isr_vector里的函数指针不是"代码引用",GC 看不见。链接脚本写KEEP(*(.isr_vector)),否则没在 C 里调用的中断处理函数会被丢掉,一进中断就跳飞; - "只被汇编/调试器用"的符号 用
__attribute__((used))或KEEP保护(bootloader 跳转表、OTA 元数据、g_pfnVectors)。
8.4 链接脚本:地址从哪来
STM32F103C8Tx_FLASH.ld 的真实片段:
ld
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}
SECTIONS
{
.isr_vector : { KEEP(*(.isr_vector)) } >FLASH
.text : { *(.text .text.*) } >FLASH
.rodata : { *(.rodata .rodata.*) } >FLASH
.data : { *(.data .data.*) } >RAM AT> FLASH
.bss : { *(.bss .bss.*) *(COMMON) } >RAM
._user_heap_stack (NOLOAD) :
{
. = ALIGN(8);
PROVIDE(end = .);
. = . + _Min_Heap_Size; /* 0x200 */
. = . + _Min_Stack_Size; /* 0x400 */
. = ALIGN(8);
} >RAM
}
三行关键:
>RAM AT> FLASH:VMA 在 RAM、LMA 在 Flash(.data两份的来源);PROVIDE(end = .):给malloc一个"堆起点"符号;NOLOAD:这段只占地址、不写文件(所以.bin里没有它)。
8.5 LMA/VMA:96 字节的搬家
.map 的真实行:
.data 0x20000000 0x60 load address 0x08000dc8
- VMA
0x2000_0000:运行时地址(SRAM); - LMA
0x0800_0DC8:初值在 Flash 的地址; _sidata = 0x0800_0DC8、_sdata = 0x2000_0000、_edata = 0x2000_0060。
启动代码把 [_sidata, _sidata+0x60) 拷到 [_sdata, _edata)(P02 第 6 章有逐条反汇编)。你写的 g_ms=500,初值就在这 96 字节里。
8.6 归档成员:printf 拉进来一整个"家"
newlib 是静态库(.a),链接器只拉"被引用的成员"。本例因为 printf,拉进了:
| 符号 | 大小 | 为什么被拉进来 |
|---|---|---|
_puts_r |
170 B | puts 的实现 |
__sf |
312 B (.bss) |
stdio 的 FILE 结构数组 |
_malloc_r |
248 B | printf 内部可能用堆 |
__sflush_r |
248 B | 刷新缓冲 |
"我只加了一句 printf,RAM 怎么多了 300 多 B" ------答案就在 __sf。用 nm --size-sort 一看便知。
归档拉取规则(工程上很重要):
- 链接器只从
.a里拉"能解决当前未定义符号"的.o; - 如果两个库有同名符号,先出现的赢(顺序敏感);
- 弱符号(
W)不会触发拉取,但可以被强符号覆盖。
8.7 弱符号:启动文件的"默认实现"
nm firmware.elf | grep Reset_Handler 显示 W(weak):启动文件里用 .weak 定义了默认的中断处理函数(死循环),你在 C 里写同名函数就会覆盖它。这是"中断向量表不用改、只写函数"的机制。
asm
.weak Default_Handler
.thumb_set ADC1_2_IRQHandler, Default_Handler
你在 stm32f10x_it.c 里定义 void ADC1_2_IRQHandler(void),链接器就用你的强符号替换弱符号。
8.8 .map 怎么读(工程三问)
| 问题 | 看哪里 |
|---|---|
| Flash/RAM 还剩多少? | 段表末尾的 _estack、_ebss,或 size |
| 某个库占了多大? | map 的 "Archive member included" + 段内的 .o 列表 |
| 为什么这个符号在? | 搜符号名,看它被哪个段/哪个 .o 引用 |
落到项目
- 每次构建保存
.map,和上一版做 diff(新增/变大的段一目了然); - 检查链接脚本的
KEEP(*(.isr_vector))和_Min_Stack_Size; - 用
nm --size-sort抓"意外的大符号"(__sf、缓冲、查找表)。
第 9 章 工具链与构建:八件套 + 一条命令

图 9-1 gcc/as/ld/objcopy + size/objdump/nm/map(视频 06:30)
9.1 八件套
| 工具 | 作用 | 常用参数 |
|---|---|---|
gcc |
驱动:按后缀调预处理器/编译器/汇编器/链接器 | -E/-S/-c、-o |
as |
汇编器 | -mcpu、-mthumb |
ld |
链接器 | -T、-Map、--gc-sections |
objcopy |
elf → bin/hex | -O binary、-O ihex |
size |
看 text/data/bss | -A(按段) |
objdump |
反汇编/段表 | -d、-h、-s |
nm |
符号表 | -n(按地址)、-S(带大小)、--size-sort |
readelf |
ELF 全信息 | -h/-S/-l/-r/-A |
9.2 真实构建命令,逐个参数解释
bash
arm-none-eabi-gcc -Os -mcpu=cortex-m3 -mthumb -g3 \
-ffunction-sections -fdata-sections \
-Wall -Wextra -Werror \
-T STM32F103C8Tx_FLASH.ld \
-specs=nano.specs -specs=nosys.specs \
-Wl,--gc-sections -Wl,-Map=firmware.map \
objs/*.o -o firmware.elf
| 参数 | 作用 | 不加会怎样 |
|---|---|---|
-Os |
优化体积 | 代码变大,64 KB 可能装不下 |
-mcpu=cortex-m3 |
目标 CPU | 默认可能用错指令集 |
-mthumb |
Thumb 指令集 | Cortex-M 必须,否则链接/运行错 |
-g3 |
最大调试信息 | 不能源码级调试(但不影响 bin) |
-ffunction-sections |
每个函数一段 | --gc-sections 效果差 |
-fdata-sections |
每个变量一段 | 同上 |
-T xxx.ld |
链接脚本 | 地址全错,烧进去不启动 |
-specs=nano.specs |
newlib-nano | 库变大(printf 可能多几 KB) |
-specs=nosys.specs |
空 syscall 桩 | 链接报 _write 等未定义 |
--gc-sections |
丢弃未引用段 | Flash 浪费 |
-Wl,-Map |
生成 map | 出问题没有账本 |
9.3 size 与 5.5%
text 3,520 / data 104 / bss 1,992
- Flash 占用 =
text + data = 3,624→ 占 64 KB 的 5.5%; - RAM 占用 =
data + bss = 2,096→ 占 20 KB 的 10.2%。
"5.5%"不是"代码很少",而是"库很小" :本例没开 printf 浮点、没拉大库。加一个文件系统/协议栈,这个数会立刻上去。
9.4 可复现构建:让别人编出同一个 bin
| 措施 | 做法 |
|---|---|
| 固定工具链版本 | 记录 arm-none-eabi-gcc --version,或用容器/固定安装包 |
| 固定源码版本 | git commit + 构建 ID 写进固件(__DATE__ 不可靠) |
| 固定路径 | 用相对路径/-fdebug-prefix-map 消除绝对路径 |
| 记录 map | 每次发布归档 firmware.map + size.txt |
| 可复现标志 | SOURCE_DATE_EPOCH、-frandom-seed(GCC 某些场景) |
9.5 CI 里值得加的三条检查
bash
# 1) 尺寸阈值
size firmware.elf | awk 'NR==2 { if ($1+$2 > 52000) exit 1 }'
# 2) 不允许的未定义符号(比如误用浮点 printf)
nm -u firmware.elf | grep -E "_printf_float|_sbrk" && exit 1
# 3) map 与基线 diff(段/大符号突变)
diff <(grep -E "^\.(text|data|bss)" baseline.map) \
<(grep -E "^\.(text|data|bss)" firmware.map)
落到项目
- 把上面的构建命令写进
build.sh/CMake,别在 IDE 里点; - 每次发布归档
elf/bin/hex/map/size.txt五件套; - CI 跑尺寸检查 +
nm --size-sortTop10 对比。
第 10 章 总结:七十年,一条链
纸带(手打 0/1)
↓ 汇编器:助记符 → 机器码,标号两遍扫描
汇编(ISA 绑定)
↓ 编译器:ISA + ABI + 运行环境
C(源码可移植)
↓ 类/模板/虚函数
C++(更厚的抽象)
↓ 五道工序
.c → .i → .s → .o → .elf → .bin
五句话带走:
- 编译是"翻译 + 分层",链接是"拼装 + 定址"。
- 段在编译期定,地址在链接期定;
.data有 VMA/LMA 两份。 undefined reference只发生在链接;.o里的地址是 0。.elf大是因为调试信息,.bin才是烧录字节。- 换平台必须重新编译(交叉编译),二进制不可移植。
落到项目
- 让你的构建产物"五件套齐全"(elf/bin/hex/map/size);
- 给团队写一页"工具链速查"(本文第 9.1/9.2 节可直接用);
- 下次遇到链接错误,先看
nm的U列表,再看库顺序。
第 11 章 自测:20 题
概念
- 预处理、编译、汇编、链接、转格式,各产出什么文件?
- 为什么
main.i比main.c大一百多倍? -Os的O和s分别是什么意思?.o里的函数地址为什么是 0?undefined reference发生在哪个阶段?为什么?R_ARM_ABS32和R_ARM_THM_CALL填的东西有什么不同?.elf68,856 B、.bin3,624 B,差的 65 KB 是什么?- 为什么
.data有 VMA 和 LMA 两个地址? --gc-sections靠什么判断"该丢"?向量表为什么要KEEP?printf为什么会拉进__sf(312 B)?
计算
text=3520, data=104, bss=1992,Flash 占多少?RAM 占多少?.isr_vector=236, .text=3268, .rodata=16,text列是多少?- 64 KB Flash,当前 3,624 B,还剩多少个百分比?
- 35 个
.o里,main.o5,320 B,为什么最终.elf是 68,856 B? main.i3,726 行、main.s927 行,为什么行数差这么多?
诊断
- 链接报
multiple definition of 'g_cnt',最可能的原因? - 链接报
undefined reference to '_write',怎么解决? - 烧进去不启动,PC 停在 0x0000_0000,先查什么?
-O2下程序行为变了,但-O0正常,优先怀疑什么?- 换了工具链版本,bin 大小从 3,624 变成 3,700,正常吗?怎么确认没出错?
答案
.i(预处理)、.s(汇编)、.o(目标)、.elf(可执行)、.bin/.hex(烧录)。- 头文件展开:
stm32f10x.h会带进core_cm3.h、寄存器定义等,几行#include变成几千行。 O= 优化等级(Optimization),s= size(优先压体积),不是"操作系统"。.o还没分配最终地址,只有段内偏移 + 重定位表;地址由链接器填。- 链接阶段;编译单个
.c只看到声明(U),链接器才去找定义。 ABS32写 32 位绝对地址;THM_CALL算相对偏移并编码进bl的立即数位。- 符号表、调试信息(DWARF)、段头、
.comment、.ARM.attributes等元数据。 - VMA 是运行地址(SRAM),LMA 是初值存放地址(Flash);启动时从 LMA 拷到 VMA。
- 从入口做可达性分析;向量表里的函数指针不是"代码引用",GC 看不见,必须
KEEP。 printf需要 stdio 的 FILE 结构;newlib 的__sf是静态 FILE 数组,被整个拉进来。- Flash = 3,520 + 104 = 3,624 B ;RAM = 104 + 1,992 = 2,096 B。
- 236 + 3,268 + 16 = 3,520 B。
- 3,624 / 65,536 ≈ 5.5%,还剩 94.5%。
.elf含 35 个.o的段 + 库 + 符号表 + 调试信息;.o只是其中一个。- 预处理把宏/头文件全展开(行数暴涨);编译把 C 语义压成汇编(大量行被优化/合并)。
- 同一个变量在两个
.c里都有定义(不是extern声明);GCC 10+ 默认-fno-common会报错。 - 用
-specs=nosys.specs提供空桩,或自己实现_write(通常是 UART 输出)。 - 查向量表:
_estack是否是合法 RAM 地址、复位向量是否指向Reset_Handler、.isr_vector是否在0x0800_0000。 - 未定义行为(有符号溢出、越界、未初始化、缺
volatile、严格别名)被优化放大。 - 正常(不同版本代码生成不同);用
size/nm对比段和符号,确认差异来自库/优化,而不是自己的代码。
附录 A 复现
bash
cd projects/key_led_f103
python build_gcc.py # 生成 out/ 下的 i/s/o/elf/bin/hex/map
python collect_facts.py # 生成 facts.json(本文的尺寸/重定位来源)
交叉编译实验(第 4.4 节):
bash
cd projects/p03_cross
# 见同目录 README.md:四个目标 + ABI 混用实验
关键命令:
bash
arm-none-eabi-gcc -E -o main.i main.c
arm-none-eabi-gcc -S -o main.s main.i
arm-none-eabi-as -o main.o main.s
arm-none-eabi-objdump -d main.o
arm-none-eabi-readelf -r main.o
arm-none-eabi-size firmware.elf
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
附录 B 关键数字与符号
| 项 | 值 |
|---|---|
main.c |
823 B / 24 行 |
main.i |
109,512 B / 3,726 行 |
main.s |
15,364 B / 927 行 |
main.o |
5,320 B |
| 目标文件数 | 35 |
firmware.elf |
68,856 B |
firmware.bin |
3,624 B |
firmware.hex |
10,276 B |
text/data/bss |
3,520 / 104 / 1,992 |
.isr_vector |
0x0800_0000,236 B |
.text |
0x0800_00EC,3,268 B |
.rodata |
0x0800_0DB0,16 B |
.data VMA/LMA |
0x2000_0000 / 0x0800_0DC8,96 B |
.bss |
0x2000_0060,452 B |
main |
0x0800_0958,60 B |
Reset_Handler |
0x0800_086C,52 B(weak) |
SystemInit |
0x0800_077C,240 B |
puts 地址 |
0x0800_0AE8 |
| 未定义符号(main.o) | puts |
| 重定位(main.o) | 7 条(6×ABS32 + 1×THM_CALL) |
附录 C 视频时间码
| 时间 | 内容 | 本文 |
|---|---|---|
| 00:00--00:20 | 823 → 3,624;五道工序 | 第 1 章 |
| 00:20--01:21 | ENIAC、纸带、位域 | 第 2 章 |
| 01:21--02:19 | 汇编器、ISA、0x2001 | 第 3 章 |
| 02:19--03:00 | C、三段编译、一行四条 | 第 4 章 |
| 03:00--03:42 | C++、零开销、名字改编 | 第 5 章 |
| 03:42--04:23 | 五道工序真实尺寸 | 第 6 章 |
| 04:23--05:02 | 反汇编四条对照 | 第 7 章 |
| 05:02--06:14 | 符号、重定位、段、脚本 | 第 8 章 |
| 06:14--06:49 | 工具链、构建、size | 第 9 章 |
| 06:49--07:20 | 七十年与一条链 | 第 10 章 |
附录 D 术语与下一集
| 术语 | 一句话 |
|---|---|
| 预处理 | 展开头文件/宏/条件编译,产出 .i |
| 编译 | C → 汇编,做优化与指令选择 |
| 汇编 | 汇编 → 机器码 + 符号 + 重定位表 |
| 链接 | 合并段、解析符号、分配地址、填重定位 |
| VMA / LMA | 运行地址 / 装载地址 |
| 重定位 | 把符号地址填进指令/数据的过程 |
| 符号表 | T/D/B/R/U/W 的清单 |
归档(.a) |
多个 .o 的库,链接器按需拉取 |
--gc-sections |
丢弃不可达段(需 KEEP 保护向量表) |
| 交叉编译 | host ≠ target,为目标板生成代码 |
| target triple | arm-none-eabi 这类"架构-厂商-OS/ABI"三元组 |
| newlib-nano | 精简 C 库(-specs=nano.specs) |
下一集 :P04《上电之后,main 之前》------把 Reset_Handler、向量表、.data 拷贝、.bss 清零逐行拆开;以及链接脚本每一行到底在说什么。
60 B |
| Reset_Handler | 0x0800_086C,52 B(weak) |
| SystemInit | 0x0800_077C,240 B |
| puts 地址 | 0x0800_0AE8 |
| 未定义符号(main.o) | puts |
| 重定位(main.o) | 7 条(6×ABS32 + 1×THM_CALL) |
附录 C 视频时间码
| 时间 | 内容 | 本文 |
|---|---|---|
| 00:00--00:20 | 823 → 3,624;五道工序 | 第 1 章 |
| 00:20--01:21 | ENIAC、纸带、位域 | 第 2 章 |
| 01:21--02:19 | 汇编器、ISA、0x2001 | 第 3 章 |
| 02:19--03:00 | C、三段编译、一行四条 | 第 4 章 |
| 03:00--03:42 | C++、零开销、名字改编 | 第 5 章 |
| 03:42--04:23 | 五道工序真实尺寸 | 第 6 章 |
| 04:23--05:02 | 反汇编四条对照 | 第 7 章 |
| 05:02--06:14 | 符号、重定位、段、脚本 | 第 8 章 |
| 06:14--06:49 | 工具链、构建、size | 第 9 章 |
| 06:49--07:20 | 七十年与一条链 | 第 10 章 |
附录 D 术语与下一集
| 术语 | 一句话 |
|---|---|
| 预处理 | 展开头文件/宏/条件编译,产出 .i |
| 编译 | C → 汇编,做优化与指令选择 |
| 汇编 | 汇编 → 机器码 + 符号 + 重定位表 |
| 链接 | 合并段、解析符号、分配地址、填重定位 |
| VMA / LMA | 运行地址 / 装载地址 |
| 重定位 | 把符号地址填进指令/数据的过程 |
| 符号表 | T/D/B/R/U/W 的清单 |
归档(.a) |
多个 .o 的库,链接器按需拉取 |
--gc-sections |
丢弃不可达段(需 KEEP 保护向量表) |
| 交叉编译 | host ≠ target,为目标板生成代码 |
| target triple | arm-none-eabi 这类"架构-厂商-OS/ABI"三元组 |
| newlib-nano | 精简 C 库(-specs=nano.specs) |
下一集 :P04《上电之后,main 之前》------把 Reset_Handler、向量表、.data 拷贝、.bss 清零逐行拆开;以及链接脚本每一行到底在说什么。