3、编译器与链接脚本:GCC编译流程、链接脚本(.ld)语法、段(.text/.data/.bss)布局、符号表与重定位
好,咱们今天聊点硬核的。编译器与链接脚本,这玩意儿是嵌入式开发的「幕后黑手」。很多朋友写代码跑得飞起,但一问到代码怎么从 .c 变成 .bin,就有点含糊了。我当年刚入行时也这样,直到有一次调试一个诡异的全局变量被篡改问题,折腾了三天,最后发现是链接脚本里 .bss 段地址没对齐。嗯,从那以后,我再也不敢小看这几百行的 .ld 文件了。
3.1 GCC编译流程:从源码到二进制
说白了,GCC 编译一个 C 文件,不是一步到位的。它分四步走:预处理、编译、汇编、链接。每一步都有明确的产出物。
核心流程:
- 预处理(.c → .i):处理 #include、#define、条件编译。说白了就是把头文件内容复制进来,宏展开。
- 编译(.i → .s):将预处理后的代码翻译成汇编语言。这是最核心的一步,语法检查、优化都在这里。
- 汇编(.s → .o):把汇编代码转成机器码,生成目标文件(.o)。注意,此时地址还没定死。
- 链接(.o → .elf/.bin):把多个目标文件和库合并,分配地址,生成最终的可执行文件。
我习惯用 -v 选项看 GCC 到底干了什么。你试试看:
arm-none-eabi-gcc -v -c main.c -o main.o
它会打印出每一步调用的子程序。我第一次看到时挺震撼的——原来背后有这么多工具链在协同工作。
3.2 链接脚本(.ld)语法:嵌入式工程师的「地图」
链接脚本,就是告诉链接器:「你的代码该放哪儿,数据该放哪儿」。没有它,MCU 上电后根本不知道从哪里开始跑。
一个典型的 .ld 文件长这样:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
*(.isr_vector) /* 中断向量表 */
*(.text) /* 代码段 */
*(.rodata) /* 只读数据 */
_etext = .; /* 代码结束地址 */
} > FLASH
.data : AT(_etext)
{
_sdata = .;
*(.data) /* 初始化数据 */
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss) /* 未初始化数据 */
_ebss = .;
} > RAM
}
这里有几个关键点,我重点说一下:
- MEMORY 命令:定义芯片的物理存储区域。FLASH 是只读可执行,RAM 是可读写。地址和大小必须和芯片手册一致,写错了程序直接跑飞。
- SECTIONS 命令:定义各段怎么放。注意
.data段有个AT(_etext),意思是它运行时在 RAM,但初始值存在 FLASH 里。启动代码需要手动把它从 FLASH 拷贝到 RAM。 - 符号赋值:像
_sdata、_ebss这些,是给启动代码用的。我见过有人忘了定义_sbss,结果 .bss 段没清零,全局变量初始值全是乱的。
我曾经踩过的坑:有一次我改了 FLASH 的 ORIGIN 地址,想给 bootloader 腾空间。结果忘了同步修改中断向量表的偏移量,程序一上电就跑到了错误的中断入口。嗯,从那以后我改链接脚本都会再三核对地址。
3.3 段(.text/.data/.bss)布局:代码的「三兄弟」
一个嵌入式程序,内存布局就靠这三个段撑着。咱们逐个看:
| 段名 | 存储位置 | 内容 | 特点 |
|---|---|---|---|
| .text | FLASH(只读) | 代码、常量、中断向量表 | 掉电不丢失,直接执行 |
| .data | RAM(可读写) | 已初始化的全局/静态变量 | 初始值在 FLASH,启动时拷贝到 RAM |
| .bss | RAM(可读写) | 未初始化的全局/静态变量 | 启动时清零,不占 FLASH 空间 |
你想想看,为什么 .data 段要搞这么麻烦?直接放 RAM 里不行吗?
其实原因很简单:RAM 掉电就丢数据。所以初始值必须存在 FLASH 里,上电后再由启动代码搬过去。这个搬运过程,就是 startup_xxx.s 文件里那段循环拷贝代码干的活。
我的个人习惯:在链接脚本里加一个 _sidata = LOADADDR(.data); 符号,这样启动代码可以直接用这个地址找到 FLASH 里的初始值。比硬编码地址灵活多了。
3.4 符号表与重定位:链接器的「拼图游戏」
目标文件(.o)里的地址都是相对的,或者说是「未定的」。链接器的工作,就是把这些散落的拼图块,按照链接脚本的指示,拼到正确的位置上。
咱们用 readelf 看看目标文件的符号表:
arm-none-eabi-readelf -s main.o
你会看到类似这样的输出:
Num: Value Size Type Bind Vis Ndx Name
5: 00000000 24 FUNC GLOBAL DEFAULT 1 main
6: 00000018 4 OBJECT GLOBAL DEFAULT 3 global_var
注意看 Value 列,此时 main 函数的地址是 0x00000000,global_var 的地址是 0x00000018。这些都是相对于段起始位置的偏移量,不是最终地址。
重定位,就是链接器把这些偏移量改成实际地址。比如 main 函数最终被放到了 0x08000100,那链接器就会把所有调用 main 的指令里的地址,都加上这个基址。
重定位的类型:
- 绝对重定位:直接填入目标地址。比如函数指针赋值。
- 相对重定位:计算当前指令与目标地址的差值。比如跳转指令(B、BL),它们用的是 PC 相对寻址。
我遇到过一个问题:在 bootloader 里跳转到应用程序时,直接用函数指针跳转,结果程序跑飞了。后来发现是 bootloader 和 app 用了不同的链接脚本,导致地址空间重叠。嗯,重定位不是万能的,你得确保链接脚本把各模块的地址空间划分清楚。
3.5 实战建议:如何写出靠谱的链接脚本
说了这么多,最后给几条实在的建议:
- 从芯片厂商的示例改起:别自己从头写。ST、NXP、GD 这些厂商都提供了现成的 .ld 文件,你只需要改 MEMORY 的地址和大小。
- 加足够的符号:除了
_sdata、_ebss,我习惯再加_heap_start、_stack_end,方便管理堆栈。 - 检查对齐:ARM 要求代码段 4 字节对齐,数据段 8 字节对齐。不对齐的话,访问会触发异常。
- 用 map 文件验证:编译时加
-Wl,-Map=output.map,生成 map 文件。里面详细记录了每个符号的最终地址,是调试的利器。
一个小技巧:在链接脚本里用 PROVIDE() 定义符号,这样如果代码里已经定义了同名符号,链接器就不会报重复定义错误。我经常用这个来给启动代码提供默认的堆栈大小。
好了,关于编译器与链接脚本,咱们就聊到这儿。说白了,这东西就是嵌入式开发的「地基」。地基没打好,上面盖的楼再漂亮也白搭。下一章咱们聊聊内存分配与堆栈管理,那又是另一番天地了。