2、链接脚本深度解析:.text, .data, .bss段的布局与定位

好,咱们今天聊点硬核的。链接脚本,这玩意儿很多C语言开发者写了好几年代码都没碰过。但说实话,如果你做嵌入式开发,不理解链接脚本,你就像开车不看仪表盘——能跑,但心里没底。

我个人习惯把链接脚本叫做「程序的内存地图」。它告诉编译器:你的代码放哪儿,全局变量放哪儿,未初始化的数据又该去哪儿。没有这张地图,单片机根本不知道从哪里开始执行。

2.1 三个核心段:.text, .data, .bss

先搞清楚这三个家伙分别是什么。我当年刚入行时,总把.data和.bss搞混,后来踩了个坑才彻底明白。

段名 存储内容 生命周期 是否占用Flash 是否占用RAM
.text 代码指令、只读常量 程序运行期间 否(但需在Flash中执行)
.data 已初始化的全局/静态变量 程序运行期间 是(存储初始值) 是(运行时拷贝到RAM)
.bss 未初始化的全局/静态变量 程序运行期间 是(启动时清零)

你看这个表,有个关键点:.data段既占Flash又占RAM。为什么?因为初始值必须保存在Flash里,上电后再拷贝到RAM中。而.bss段只占RAM,启动时清零就行。

核心理解: .text是只读的,放在Flash里跑。.data和.bss在RAM里运行,但.data的「种子」在Flash里存着。

2.2 链接脚本到底长什么样?

拿一个典型的ARM Cortex-M链接脚本举例。我简化了一下,去掉了一些杂项,保留核心结构。

ENTRY(Reset_Handler)

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text :
    {
        _stext = .;
        *(.isr_vector)    /* 中断向量表 */
        *(.text*)         /* 代码段 */
        *(.rodata*)       /* 只读数据 */
        _etext = .;
    } > FLASH

    .data : AT(_etext)
    {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss*)
        *(COMMON)
        _ebss = .;
    } > RAM
}

嗯,这里要注意几个符号:_stext_etext_sdata_edata_sbss_ebss。这些是链接器生成的符号,启动代码会用到它们。

2.3 启动代码如何搬运.data和清零.bss?

你想想看,单片机一上电,RAM里的数据是随机的。谁把Flash里的初始值搬到RAM里?谁把.bss段清零?就是启动代码干的活。

我曾经在一个项目里,因为链接脚本里.data段的加载地址(LMA)和运行地址(VMA)没配对,导致全局变量全是乱的。排查了两天才找到原因——启动代码从错误的地址拷贝了数据。

下面是典型的启动代码片段(简化版):

; 搬运.data段
ldr r0, =_sdata      ; RAM中的目标地址
ldr r1, =_edata      ; RAM中的结束地址
ldr r2, =_etext      ; Flash中的源地址(紧跟在.text后面)
copy_loop:
    cmp r0, r1
    ittt lt
    ldrlt r3, [r2], #4
    strlt r3, [r0], #4
    blt copy_loop

; 清零.bss段
ldr r0, =_sbss
ldr r1, =_ebss
mov r2, #0
zero_loop:
    cmp r0, r1
    itt lt
    strlt r2, [r0], #4
    blt zero_loop

说白了,这就是个内存拷贝和内存清零的操作。但如果没有链接脚本提供的那些符号,启动代码根本不知道从哪里搬、搬多少。

小技巧: 调试时可以在链接脚本里给每个段加一个填充字节,比如 FILL(0xDEADBEEF)。这样如果看到内存里有这个模式,就知道段边界没对齐。

2.4 段的布局图:一张SVG说清楚

光说不够直观,我画了一张图。这张图展示了程序在Flash和RAM中的实际布局。

Flash (0x08000000) .isr_vector (中断向量表) .text (代码指令) _stext ~ _etext .rodata (只读常量) .data 初始值 (LMA) _etext ~ _etext + data_size 启动时拷贝 RAM (0x20000000) .data (运行时拷贝) _sdata ~ _edata .bss (清零区域) _sbss ~ _ebss 堆 (Heap) _heap_start ~ _heap_end 栈 (Stack) (上电时内容随机)

这张图你看懂了吗?Flash里存着代码和只读数据,还有.data段的初始值。RAM里放着运行时.data的拷贝、清零后的.bss段,以及堆和栈。箭头表示启动时的那次拷贝动作。

2.5 常见坑:段对齐与符号引用

我在项目中遇到过一个问题:某个全局变量初始值总是错的。查了半天,发现是链接脚本里.data段的起始地址没有4字节对齐。ARM架构要求32位访问必须对齐,否则硬件异常。

警告: 链接脚本中段的起始地址必须满足对齐要求。通常.text按4字节对齐,.data按4或8字节对齐。如果对齐不对,轻则性能下降,重则硬件fault。

另一个坑是符号引用。链接脚本里定义的符号(如_sdata)在C代码中怎么用?记住:这些符号是地址,不是变量。正确的用法是:

extern uint32_t _sdata;  // 声明为外部符号
uint32_t *p = &_sdata;   // 取地址,得到.data段的起始地址

千万别写成 uint32_t p = _sdata;,那样你拿到的是地址里的内容,而不是地址本身。我见过有人这么写,结果程序跑飞了。

2.6 自定义段:把特定变量放到指定位置

有时候你需要把某些变量放到特定的内存区域。比如,电池供电的设备里,有些关键数据要放在备份SRAM中,掉电不丢失。

链接脚本里可以这样定义:

MEMORY
{
    BACKUP_SRAM (rw) : ORIGIN = 0x40024000, LENGTH = 4K
}

SECTIONS
{
    .backup_data :
    {
        *(.backup_data*)
    } > BACKUP_SRAM
}

然后在C代码中用 __attribute__((section(".backup_data"))) 来标记变量:

__attribute__((section(".backup_data")))
uint32_t system_config[64];  // 这个变量会放在备份SRAM中

嗯,这个技巧在低功耗设备里特别有用。我做过一个智能门锁项目,就用这种方式保存了开锁记录,即使主电池没电了,数据也不会丢。

2.7 调试链接脚本的小工具

最后分享一个实用技巧。用 objdump 或者 readelf 可以查看最终生成的段信息:

arm-none-eabi-objdump -h firmware.elf

# 输出示例:
# Idx Name          Size      VMA       LMA       File off  Algn
#   0 .text         0000a000  08000000  08000000  00010000  2**4
#   1 .data         00000400  20000000  0800a000  00020000  2**2
#   2 .bss          00000200  20000400  20000400  00020400  2**2

看到VMA和LMA的区别了吗?.data段的VMA是0x20000000(RAM),LMA是0x0800a000(Flash)。这就是我说的「种子在Flash,果实长在RAM」。

好了,关于链接脚本的段布局,咱们就聊到这儿。记住一句话:链接脚本是程序的内存蓝图,启动代码是施工队。蓝图画错了,施工队再努力也白搭。

总结: .text放代码,.data放已初始化全局变量(初始值在Flash),.bss放未初始化全局变量(启动时清零)。链接脚本用MEMORY和SECTIONS命令定义布局,启动代码负责搬运和清零。

公众号:蓝海资料掘金营,微信deep3321