本节摘要:GNU 工具链把"生成程序"拆成编译器、汇编器、链接器三道独立工序:编译器产出汇编文本,汇编器把文本编成目标文件,链接器按链接脚本把目标文件拼成镜像。本节拆开三道工序的接口——目标文件的段与符号表,手写一个裸机链接脚本,并解决"未定义符号"这一最高频报错。
前六章我们的命令都是 gcc 一条龙到底,本节把一条龙拆回三匹马。拆开的价值在于接口本身:汇编器与链接器之间的"段与符号表"协议,是理解目标文件、静态库、链接报错、固件布局的全部基础——第 6 章启动代码引用的那些界碑符号,就诞生在这个协议里。
先分清职责。编译器(cc1,由 gcc 驱动)吃 C 产出汇编文本——第 1 章的 -S 开关就是让它在此时停手。汇编器(as)吃汇编文本产出目标文件:把助记符翻成机器码、记录段与符号。链接器(ld)吃一批目标文件加链接脚本,产出最终镜像:合并同类段、解析符号引用、按脚本摆放地址。gcc 驱动程序只是替你按顺序调度三匹马——-v 开关能看到它实际执行的每条子命令。手跑一遍三阶段,看清中间产物:
aarch64-linux-gnu-gcc -c sum.c -o sum.o # 编译加汇编两步并作一步 aarch64-linux-gnu-as -march=armv8-a startup.s -o startup.o # 纯汇编文件走汇编器 aarch64-linux-gnu-ld -T baremetal.ld startup.o sum.o -o image.elf
目标文件里有什么?objdump -h sum.o 列出段清单:.text 代码、.data 已初始化数据、.bss 未初始化数据、.rodata 常量,还有重定位信息——ldr x0, =some_symbol 这类引用在汇编时地址未知,汇编器只能先留坑并登记"此处需重定位",链接时由链接器填坑。符号表(nm sum.o 可见)是另一份合同:每个符号标注定义还是引用、属于哪段。链接器的全部工作可以概括成一句话:把所有人引用的符号都找到定义者,把所有段按脚本摆到位,把所有重定位坑填上。
用户态程序用默认脚本就够,裸机必须手写。链接脚本语法不多,核心是 MEMORY(有哪些内存)与 SECTIONS(段怎么摆)。给 6.2 节的启动代码配一份完整地图:
/* baremetal.ld :最小裸机链接脚本 */ ENTRY(_start) /* 入口符号:谁第一句执行 */ MEMORY { FLASH (rx) : ORIGIN = 0x40000000, LENGTH = 4M /* 代码与常量:可读可执行 */ RAM (rwx) : ORIGIN = 0x42000000, LENGTH = 2M /* 数据与栈:可读可写可执行关掉更安全 */ } SECTIONS { .text : { KEEP(*(.text.boot)) *(.text .rodata) } > FLASH .data : { _data_start = .; /* 界碑:运行地址起 */ *(.data) _data_end = .; } > RAM AT> FLASH /* 双地址:运行在 RAM,初值躺在 FLASH */ _data_load = LOADADDR(.data); /* 界碑:装载地址,启动代码搬运的源头 */ .bss (NOLOAD) : { __bss_start = .; *(.bss *(COMMON)) __bss_end = .; } > RAM . = ALIGN(16); /* 栈顶对齐到 16:AAPCS 的老规矩 */ _stack_top = .; }
四个关键语法对应第 6 章的四部曲:ENTRY(_start) 告诉调试器与仿真器入口在哪;.text 里 KEEP(*(.text.boot)) 防止链接器把没人引用的启动节裁掉;.data 的 > RAM AT> FLASH 就是"装载域与运行域分离"的语法本体,LOADADDR 取出装载地址供搬运;NOLOAD 让 BSS 不占镜像体积(上电内存本来就随机,清零是启动代码的事)。写完用 objdump -h image.elf 核对各段落位,再拿 nm image.elf 验证界碑符号都有值——这两条命令是链接脚本调试的眼睛。

链接报错是汇编工程师的老朋友,三种病因覆盖绝大多数案例。病因一,符号真没定义:报 undefined reference to 'sum',用 nm 查目标文件——小写 U 表示只有引用没有定义,多半是忘了链接对应的目标文件或库。病因二,架构不匹配:报 file format not recognized 或"skipping incompatible",混进了别的架构的目标文件(比如把宿主机编的 .o 混进交叉链),用 file sum.o 看架构声明即可确诊。病因三,C++ 名字修饰:C++ 编译的 sum 在符号表里叫一长串修饰名,汇编里的 sum 匹配不上——给 C++ 函数加 extern "C" 声明关闭修饰。三种病因的诊断流程图一张收拢:先 file 查架构,再 nm 查符号,最后查修饰名——顺序按成本从低到高。
链接顺序报错是新手第二个高峰:把库写在目标文件之前,报"未定义符号"。机制一句话:链接器从左往右扫,遇到未解析引用时记下名字,只有后续输入里的定义才能配对——库在左边,引用还没出现,配对就错过。规则:被依赖者放右边。静态库(ar 工具把一批目标文件打包成一份归档)内部还有一层按需取用:链接器只从库里捞"能配对当前未解析引用"的成员,其余成员一律不进镜像——所以库里塞一堆用不上的函数不增体积,这是静态库比直接堆目标文件省的地方。
裸机方向的裁剪开关与链接脚本是一对搭档:--gc-sections 让链接器丢弃没被引用的节,但启动入口这类"没人引用却必须保留"的节要用 KEEP 括住——7.1 正文的脚本里那句 KEEP(*(.text.boot)) 就是干这个的。裁剪前后用 size 命令对比镜像体积,再抽查入口节还在不在,是裸机发布的固定动作。变式:把 --gc-sections 与函数级节开关(-ffunction-sections,每个函数独立成节)配合,裁剪粒度从文件级细化到函数级——同一份代码,镜像尺寸往往再降一截。
> RAM AT> FLASH 加 LOADADDR 是裸机搬运的官方配合。工具链配齐了。下一节进观察台:调试器单步与性能分析,把运行中的程序看个透。