3、编译器与链接脚本:GCC编译流程、链接脚本(.ld)语法、段(.text/.data/.bss)布局、符号表与重定位

好,咱们今天聊点硬核的。编译器与链接脚本,这玩意儿是嵌入式开发的「幕后黑手」。很多朋友写代码跑得飞起,但一问到代码怎么从 .c 变成 .bin,就有点含糊了。我当年刚入行时也这样,直到有一次调试一个诡异的全局变量被篡改问题,折腾了三天,最后发现是链接脚本里 .bss 段地址没对齐。嗯,从那以后,我再也不敢小看这几百行的 .ld 文件了。

3.1 GCC编译流程:从源码到二进制

说白了,GCC 编译一个 C 文件,不是一步到位的。它分四步走:预处理、编译、汇编、链接。每一步都有明确的产出物。

核心流程:

  1. 预处理(.c → .i):处理 #include、#define、条件编译。说白了就是把头文件内容复制进来,宏展开。
  2. 编译(.i → .s):将预处理后的代码翻译成汇编语言。这是最核心的一步,语法检查、优化都在这里。
  3. 汇编(.s → .o):把汇编代码转成机器码,生成目标文件(.o)。注意,此时地址还没定死。
  4. 链接(.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 实战建议:如何写出靠谱的链接脚本

说了这么多,最后给几条实在的建议:

  1. 从芯片厂商的示例改起:别自己从头写。ST、NXP、GD 这些厂商都提供了现成的 .ld 文件,你只需要改 MEMORY 的地址和大小。
  2. 加足够的符号:除了 _sdata_ebss,我习惯再加 _heap_start_stack_end,方便管理堆栈。
  3. 检查对齐:ARM 要求代码段 4 字节对齐,数据段 8 字节对齐。不对齐的话,访问会触发异常。
  4. 用 map 文件验证:编译时加 -Wl,-Map=output.map,生成 map 文件。里面详细记录了每个符号的最终地址,是调试的利器。

一个小技巧:在链接脚本里用 PROVIDE() 定义符号,这样如果代码里已经定义了同名符号,链接器就不会报重复定义错误。我经常用这个来给启动代码提供默认的堆栈大小。

好了,关于编译器与链接脚本,咱们就聊到这儿。说白了,这东西就是嵌入式开发的「地基」。地基没打好,上面盖的楼再漂亮也白搭。下一章咱们聊聊内存分配与堆栈管理,那又是另一番天地了。