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里存着代码和只读数据,还有.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命令定义布局,启动代码负责搬运和清零。