本节摘要:你按下的"编译"按钮背后是一条四段流水线:预处理、编译、汇编、链接。本节沿一条两文件工程走完全程,讲清目标文件、链接脚本、启动文件各自的角色,解释"变量没初始化却被清零""函数调用报未定义"这类构建期现象的原理。读完你能看懂工程构建日志、读懂链接脚本,并在链接报错时知道往哪查。
最早的程序员把机器码手工敲进内存,没有"构建"可言。程序变大后,人们想把常用的函数(比如乘法例程)存起来复用,于是出现了两个仓库:自己写的代码、别人写好的例程库。问题随之而来——两段代码各自编译时都不知道对方的存在,谁该放在内存哪个位置、跨仓库的函数调用地址怎么填?链接器就是为回答这两个问题而生:收集所有目标文件,统一分配地址,把跨文件的引用一一填平。理解了这段历史,就理解了链接器的全部职责:拼图(合并段)与填空(重定位)。
以两个源文件的工程为例:main.c 调用 led.c 里的 led_on 函数。
/* main.c */ extern void led_on(void); /* 声明:函数在别处实现 */ int counter = 100; /* 有初值:住 .data 段 */ int scratch; /* 无初值:住 .bss 段 */ int main(void) { scratch = 0; led_on(); while (1) { } } /* led.c */ void led_on(void) { /* 操作 GPIO 寄存器 */ }
第一步预处理:展开宏、搬进头文件内容、处理条件编译。产物仍是 C 源码,头文件被物理合并进来。这一步的常见故障是头文件循环包含导致的宏错乱。
第二步编译:把预处理后的 C 翻译成汇编。编译器在这里做语法检查与优化(常量折叠、循环展开、死代码删除)。你在 C 层面设置的优化等级,作用点就在这里。
第三步汇编:把汇编翻译成目标文件(main.o、led.o)。目标文件是"半成品":自己的代码与数据已按段分好(代码段、已初始化数据段、零初始化数据段),但 led_on 的真实地址还不知道——main.o 里留着一个"待填空"。
第四步链接:链接器收齐所有目标文件与库,按链接脚本的规划给各段分配最终地址,然后重定位——把 main.o 里那个空填上 led_on 的真实地址。产物是 ELF 文件,再经格式转换烧进 Flash 的就是固件镜像。

链接器把所有目标文件的同类内容归并成段,这是理解内存布局的关键。代码段存指令;已初始化数据段存有初值的全局变量(如 counter 的 100);零初始化数据段为无初值全局变量(如 scratch)预留位置;常量段存 const 数据。一个重要的细节:.data 的初值烧在 Flash 里,但变量运行时要住在 RAM 里——所以上电时有一段"搬运工代码"把初值从 Flash 复制到 RAM,同时把 .bss 整片清零。这段活由启动文件在 main 之前完成。这就解释了一个初学者疑问:"我没写清零代码,为什么全局变量默认是零?"——是启动代码替你清的。
固件镜像(Flash) 运行时(RAM) ┌──────────────────┐ ┌──────────────────┐ │ 向量表 │ 上电取PC │ .data 复制来的初值 │ │ .text 代码 │──搬运──► │ .bss 清零区 │ │ .data 初值副本 │ 清零 │ 栈 ↓ │ │ .rodata 常量 │ │ 堆 ↑ │ └──────────────────┘ └──────────────────┘
链接脚本是内存排座表:声明芯片有多少 Flash、多少 RAM,规定各段的起址与排列顺序,还能定义符号(如栈顶地址、段的起止标记)供代码引用。改内存布局(比如把一个函数固定放在特定地址、划分出一块保留参数区)都靠它。读懂链接脚本三要素——MEMORY 块(有哪些存储)、SECTIONS 块(段怎么排)、符号定义(给代码引用的地址常量)——就能应付绝大多数定制需求。
启动文件是 main 之前那段不为人知的开荒代码,通常由汇编写成:先从向量表第一项取栈顶地址装入 SP、第二项取复位入口跳过去;再搬运 .data、清零 .bss;最后调用系统初始化,接力到你的 main。第 5 章讲中断时还会遇到它——向量表就住在启动文件里。启动文件是"编译产物"与"硬件期望"之间的转接头,它存在的原因纯粹是硬件约定:芯片复位后只认向量表,不认 main。
链接器收的不只你自己编译的目标文件,还有库文件——编译器自带的 C 标准库、芯片厂商的设备库、第三方的算法库。静态链接的机制是"按需抽取":库文件是多个目标文件的打包集合,链接器只把你的代码实际引用到的那些目标文件抽出来并入镜像,没被引用的不占 Flash。这个机制解释了两个常见现象:其一,printf 一调用就"吃掉"几 KB——它拖进了整型的格式化实现等一串依赖,资源紧张的工程会用精简版 printf 替代;其二,数学函数 sin、sqrt 只要用到,对应的库模块整块并入——用查表替代浮点函数常常一举省下几 KB。看懂"谁被拖进了镜像",是 Flash 预算超支时最该做的第一件事:链接器支持生成映射清单(map 文件),列出每个目标文件与库模块的占用量,逐行读一遍,优化目标自然浮现。
报错"undefined reference to led_on":链接器没找到实现——检查 led.c 是否加入工程、函数签名是否一致、C++ 工程是否忘了 extern C 包裹。报错"section overflow":某段放不下——代码超 Flash 或数据超 RAM,看链接报告里哪个段爆了,回头精简或换大容量型号。变量初值"莫名丢失":检查是否误把有初值变量放在了会被启动代码覆盖的区域,或用了非常量表达式初始化(嵌入式下常量表达式限定是惯例)。函数"莫名被优化掉":中断服务函数若没被向量表引用,高优化等级下会被当死代码删除——用保留属性标记。
链接的知识还能解决一个现场管理问题:仓库里十块板子跑着不同时期的固件,出了问题说不清谁是谁。惯例是在镜像里嵌入构建信息——版本号、构建时间、Git 提交摘要,开机通过串口打一行"身份信息"。实现上正用得上本节的段知识:把构建时生成的信息字符串放进一个自定义段,链接脚本把它排到固定地址,上位机工具从固件文件的对应偏移就能直接读出版本,无需拆解任何代码。这个"出生证明"习惯在量产阶段是排查的第一道工序——现场故障单永远先问固件版本,而答案应当来自芯片自己报告,而非人工记忆。