嵌入式进阶——编译与链接

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 章 五个问题

  1. 823 字节的 main.c,为什么烧进芯片是 3,624 字节?多出来的 2,801 字节是什么?
  2. 预处理后为什么变成 109,512 字节?多出来的 108,689 字节是谁?
  3. .o 里的函数地址为什么是 0?谁把它填上的?
  4. -Os 的 O 是什么?-O0/-O2/-Os 到底改了什么?
  5. 同一份 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 汇编器做两件事

  1. 翻译助记符 :movs r0,#1 → 0x2001;
  2. 算地址:标号(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

三个反直觉的结论:

  1. M0 的代码反而最小(72 B < 80 B) 。因为 M0 只有 Thumb-1(16 位编码),M3/M7 的 mla、宽立即数要用 32 位 Thumb-2 编码。"CPU 越强代码越小"是错的------指令更强往往编码更长。
  2. M3/M7 不算"有浮点" 。没写 -mfpu/-mfloat-abi=hard 时,float 运算仍然调用 __aeabi_fmul/__aeabi_fadd(软件浮点),每次调用还要压栈。"芯片有 FPU" ≠ "编译器会用 FPU"。
  3. 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 能输出,你需要:

  1. 链接 newlib-nano(-specs=nano.specs);
  2. 提供 _write(通常是"往 UART DR 写一个字节"),或者用 -specs=nosys.specs 让它返回 -1(此时 printf 什么也不输出,但能链接过);
  3. 提供启动文件(向量表 + Reset_Handler + .data 拷贝 + .bss 清零);
  4. 用链接脚本把段放到正确地址。

这四件事,就是"交叉编译 + 裸机工程"比"写个 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 问

  1. 为什么 Cortex-M0 的目标文件可能比 Cortex-M3 的更小?
  2. 同一台 x86-64 电脑上,Linux 的 ELF 为什么不能在 Windows 上直接运行?至少说出两层原因。
  3. 你的工程用了 -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

汇编器输出三样东西:

  1. 机器码 (放在 .text.* 段);
  2. 符号表 (T/t/B/D/R/U);
  3. 重定位表(哪些位置"地址还没填")。

真实符号(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 从入口出发做可达性分析,丢弃没人引用的段。两个必须配合的细节:

  1. 向量表要 KEEP :.isr_vector 里的函数指针不是"代码引用",GC 看不见。链接脚本写 KEEP(*(.isr_vector)),否则没在 C 里调用的中断处理函数会被丢掉,一进中断就跳飞;
  2. "只被汇编/调试器用"的符号 用 __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-sort Top10 对比。

第 10 章 总结:七十年,一条链

复制代码
纸带(手打 0/1)
   ↓ 汇编器:助记符 → 机器码,标号两遍扫描
汇编(ISA 绑定)
   ↓ 编译器:ISA + ABI + 运行环境
C(源码可移植)
   ↓ 类/模板/虚函数
C++(更厚的抽象)
   ↓ 五道工序
.c → .i → .s → .o → .elf → .bin

五句话带走:

  1. 编译是"翻译 + 分层",链接是"拼装 + 定址"。
  2. 段在编译期定,地址在链接期定;.data 有 VMA/LMA 两份。
  3. undefined reference 只发生在链接;.o 里的地址是 0。
  4. .elf 大是因为调试信息,.bin 才是烧录字节。
  5. 换平台必须重新编译(交叉编译),二进制不可移植。

落到项目

  • 让你的构建产物"五件套齐全"(elf/bin/hex/map/size);
  • 给团队写一页"工具链速查"(本文第 9.1/9.2 节可直接用);
  • 下次遇到链接错误,先看 nm 的 U 列表,再看库顺序。

第 11 章 自测:20 题

概念

  1. 预处理、编译、汇编、链接、转格式,各产出什么文件?
  2. 为什么 main.i 比 main.c 大一百多倍?
  3. -Os 的 O 和 s 分别是什么意思?
  4. .o 里的函数地址为什么是 0?
  5. undefined reference 发生在哪个阶段?为什么?
  6. R_ARM_ABS32 和 R_ARM_THM_CALL 填的东西有什么不同?
  7. .elf 68,856 B、.bin 3,624 B,差的 65 KB 是什么?
  8. 为什么 .data 有 VMA 和 LMA 两个地址?
  9. --gc-sections 靠什么判断"该丢"?向量表为什么要 KEEP?
  10. printf 为什么会拉进 __sf(312 B)?

计算

  1. text=3520, data=104, bss=1992,Flash 占多少?RAM 占多少?
  2. .isr_vector=236, .text=3268, .rodata=16,text 列是多少?
  3. 64 KB Flash,当前 3,624 B,还剩多少个百分比?
  4. 35 个 .o 里,main.o 5,320 B,为什么最终 .elf 是 68,856 B?
  5. main.i 3,726 行、main.s 927 行,为什么行数差这么多?

诊断

  1. 链接报 multiple definition of 'g_cnt',最可能的原因?
  2. 链接报 undefined reference to '_write',怎么解决?
  3. 烧进去不启动,PC 停在 0x0000_0000,先查什么?
  4. -O2 下程序行为变了,但 -O0 正常,优先怀疑什么?
  5. 换了工具链版本,bin 大小从 3,624 变成 3,700,正常吗?怎么确认没出错?

答案

  1. .i(预处理)、.s(汇编)、.o(目标)、.elf(可执行)、.bin/.hex(烧录)。
  2. 头文件展开:stm32f10x.h 会带进 core_cm3.h、寄存器定义等,几行 #include 变成几千行。
  3. O = 优化等级(Optimization),s = size(优先压体积),不是"操作系统"。
  4. .o 还没分配最终地址,只有段内偏移 + 重定位表;地址由链接器填。
  5. 链接阶段;编译单个 .c 只看到声明(U),链接器才去找定义。
  6. ABS32 写 32 位绝对地址;THM_CALL 算相对偏移并编码进 bl 的立即数位。
  7. 符号表、调试信息(DWARF)、段头、.comment、.ARM.attributes 等元数据。
  8. VMA 是运行地址(SRAM),LMA 是初值存放地址(Flash);启动时从 LMA 拷到 VMA。
  9. 从入口做可达性分析;向量表里的函数指针不是"代码引用",GC 看不见,必须 KEEP。
  10. printf 需要 stdio 的 FILE 结构;newlib 的 __sf 是静态 FILE 数组,被整个拉进来。
  11. Flash = 3,520 + 104 = 3,624 B ;RAM = 104 + 1,992 = 2,096 B。
  12. 236 + 3,268 + 16 = 3,520 B。
  13. 3,624 / 65,536 ≈ 5.5%,还剩 94.5%。
  14. .elf 含 35 个 .o 的段 + 库 + 符号表 + 调试信息;.o 只是其中一个。
  15. 预处理把宏/头文件全展开(行数暴涨);编译把 C 语义压成汇编(大量行被优化/合并)。
  16. 同一个变量在两个 .c 里都有定义(不是 extern 声明);GCC 10+ 默认 -fno-common 会报错。
  17. 用 -specs=nosys.specs 提供空桩,或自己实现 _write(通常是 UART 输出)。
  18. 查向量表:_estack 是否是合法 RAM 地址、复位向量是否指向 Reset_Handler、.isr_vector 是否在 0x0800_0000。
  19. 未定义行为(有符号溢出、越界、未初始化、缺 volatile、严格别名)被优化放大。
  20. 正常(不同版本代码生成不同);用 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 清零逐行拆开;以及链接脚本每一行到底在说什么。

相关推荐
学网安的肆伍1 小时前
认证机制攻防学习|PortSwigger Authentication 靶场复现总结(二):用户名枚举进阶
学习·web安全·网络安全·burpsuite·portswigger·认证安全·靶场复现
海绵宝宝转agent2 小时前
Leetcode二叉树算法个人理解
笔记·学习·算法·leetcode·深度优先
小oo呆2 小时前
【学习心得】MCP工具开发学习笔记
笔记·学习
【 STM32开发 】2 小时前
【CubeMX+STM32】SD卡 文件系统读写 FatFs+SDIO+DMA (F103版本)
stm32·单片机·嵌入式硬件·1024程序员节
kyrie_sakura2 小时前
大模型学习19 大模型学习 - LangChain2_Retrieval(RAG-文档处理-向量处理检索-Milvus)
学习·langchain·milvus
行者-全栈开发2 小时前
【鸿蒙心迹】三方库移植(一)——NAPI 把 C/C++ 库桥接到 ArkTS 的完整链路(HarmonyOS 7.x)
harmonyos·arkts·交叉编译·c/c++·napi·三方库移植
动词ing2 小时前
【51单片机】独立按键控制LED灯亮灭
单片机·嵌入式硬件·51单片机
二十七日秋2 小时前
算法刷题笔记「Day 1|四道 OJ 入门题里的深坑」
c语言·汇编·数据结构·c++·笔记·python·算法
女神下凡3 小时前
芯参谋(79):嵌入式学习进阶_(硬件与软件)深入实践指南
arm开发·单片机·嵌入式硬件·学习·fpga开发·设计规范