3、链接脚本与内存布局:链接脚本(.ld)结构解析、ROM与RAM分区、代码段、数据段、BSS段的定位
做嵌入式开发,特别是裸机或Bootloader,第一道坎就是链接脚本。很多人觉得这玩意儿枯燥,不就是告诉编译器把代码放哪儿吗?
其实没那么简单。我见过不少工程师,代码写得飞起,一调试就崩,最后发现是链接脚本里某个段放错了位置。说白了,链接脚本就是你和硬件之间的「地契」——它决定了你的程序在芯片上到底怎么安家。
3.1 链接脚本到底在干什么?
先问个问题:你写好的 .c 文件,经过编译、汇编,变成了一堆 .o 目标文件。这些文件里,代码、数据、常量都混在一起。链接脚本的任务,就是把这些碎片按规则拼起来,然后分配到具体的地址上。
我个人习惯把链接脚本看作一张「装修图纸」:
- ROM(Flash) —— 相当于房子的外墙和承重墙,不能动,用来放固定不变的东西(代码、常量)。
- RAM(SRAM) —— 相当于室内的家具和摆设,可以随时搬动,用来放变量、堆栈。
一个典型的 Cortex-M 芯片,启动后从 Flash 地址 0x08000000 开始执行。你的链接脚本必须告诉链接器:
- 中断向量表放哪儿?
- 代码段放哪儿?
- 初始化的全局变量怎么从 Flash 拷贝到 RAM?
- 未初始化的全局变量(BSS)怎么清零?
核心原则: 链接脚本定义的布局,必须和芯片的启动流程严格对应。错一个字节,程序就可能跑飞。
3.2 解剖一个典型的 .ld 文件
我们拿 STM32F103 为例,看看一个标准的链接脚本长什么样。我会逐段拆解,告诉你每行代码的实际意义。
/* 入口点 */
ENTRY(Reset_Handler)
/* 内存区域定义 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}
/* 段定义 */
SECTIONS
{
/* 中断向量表 */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
/* 代码段 */
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
*(.glue_7)
*(.glue_7t)
*(.rodata)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
/* 初始化数据段(在Flash中的拷贝源) */
_sidata = .;
/* 数据段(在RAM中运行) */
.data : AT(_sidata)
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
/* BSS段 */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
嗯,看着有点复杂?别急,我一个个说。
3.2.1 MEMORY 区域
这里定义了芯片的物理存储资源:
| 区域名 | 属性 | 起始地址 | 大小 | 说明 |
|---|---|---|---|---|
| FLASH | rx(读+执行) | 0x08000000 | 512K | 存放代码和只读数据 |
| RAM | xrw(读+写+执行) | 0x20000000 | 128K | 存放变量和堆栈 |
注意属性里的 r、w、x。Flash 不能写(运行时),所以没有 w;RAM 可以读写执行。这个属性是给链接器做检查用的,如果你试图把可写数据放到 Flash 里,链接器会报错。
3.2.2 SECTIONS 段布局
这是链接脚本的核心。每个段都定义了一类内容的放置规则。
.isr_vector —— 中断向量表
这是芯片启动时第一个读取的地方。Cortex-M 的复位流程是:从 0x00000000 取栈顶指针,从 0x00000004 取复位入口。但实际芯片会把 Flash 映射到这两个地址。所以向量表必须放在 Flash 开头,且 4 字节对齐。
KEEP() 是关键——告诉链接器,即使这个段没有被显式引用,也不能被优化掉。我曾经遇到过因为忘记加 KEEP,链接器把向量表整个扔了,芯片上电直接跑飞。
.text —— 代码段
这里放的是你的程序指令和只读常量(const 变量、字符串字面量)。注意我用了 *(.text) 和 *(.text*),前者匹配所有 .text 段,后者匹配 .text.funcname 这样的子段。这是为了把不同编译单元生成的代码都收进来。
_etext 是一个符号,标记代码段的结束地址。这个地址在启动代码中会被用到。
.data —— 数据段
这里有个关键点:.data 段在 Flash 里有一份拷贝(_sidata),但运行时必须搬到 RAM 里。为什么?因为全局变量需要可读写,而 Flash 不能写。
AT(_sidata) 就是告诉链接器:这个段的加载地址(LMA)在 Flash 里,但运行地址(VMA)在 RAM 里。启动代码会负责把数据从 _sidata 拷贝到 _sdata。
.bss —— BSS段
未初始化的全局变量和静态变量。它们在 RAM 中占据空间,但不需要从 Flash 拷贝初始值。启动代码只需要把 _sbss 到 _ebss 之间的内存清零即可。
我的经验: 在调试时,如果发现全局变量初始值不对,先检查 .data 段的拷贝是否正常。如果发现全局变量值随机,先检查 BSS 段是否被清零。这两个问题我各踩过不下五次坑。
3.3 内存布局的完整视图
把上面的段拼起来,整个芯片的内存布局是这样的:
3.4 启动代码如何配合链接脚本
链接脚本只定义了布局,真正干活的是启动代码(startup_xxx.s)。它必须知道链接脚本里定义的符号:
; 引用链接脚本中定义的符号
EXTERN _sidata, _sdata, _edata, _sbss, _ebss
; 复位入口
Reset_Handler PROC
; 1. 拷贝 .data 段从 Flash 到 RAM
LDR r0, =_sidata
LDR r1, =_sdata
LDR r2, =_edata
CMP r1, r2
BEQ .L_loop_data_done
.L_loop_data:
LDR r3, [r0], #4
STR r3, [r1], #4
CMP r1, r2
BNE .L_loop_data
.L_loop_data_done:
; 2. 清零 .bss 段
LDR r1, =_sbss
LDR r2, =_ebss
CMP r1, r2
BEQ .L_loop_bss_done
MOVS r3, #0
.L_loop_bss:
STR r3, [r1], #4
CMP r1, r2
BNE .L_loop_bss
.L_loop_bss_done:
; 3. 跳转到 main
BL main
ENDP
这段汇编代码,说白了就是三件事:拷贝、清零、跳转。顺序不能乱——先拷贝 .data,再清零 .bss,最后才调用 main。为什么?因为 main 里可能立即访问全局变量,如果 .data 还没拷贝完,变量值就是错的。
注意: 如果你使用半主机模式(semihosting)或者 C 库初始化,启动代码会更复杂。但 Bootloader 场景下,我建议尽量精简启动流程,减少依赖。我自己做 Bootloader 时,启动代码通常不超过 50 行汇编。
3.5 常见坑与避坑指南
做 Bootloader 时,链接脚本有几个特别容易出问题的地方:
- 向量表重定位 —— Bootloader 升级完固件后,需要跳转到新固件。新固件的向量表不在 0x08000000,而是在某个偏移地址。你需要在链接脚本里给新固件单独定义 FLASH 区域,并把 .isr_vector 放在偏移后的地址。
- 对齐问题 —— ARM Cortex-M 要求某些段必须 4 字节或 8 字节对齐。我遇到过 .bss 段没对齐,导致某些外设 DMA 访问时出错。加 . = ALIGN(4) 能解决大部分问题。
- 符号冲突 —— 如果你在链接脚本里定义了 _sdata,但 C 代码里也有同名变量,链接器会报错。建议用下划线前缀区分链接脚本符号和普通变量。
我的习惯: 每次修改链接脚本后,先用 arm-none-eabi-objdump -h 查看生成的 .elf 文件,确认每个段的地址和大小是否符合预期。这比直接下载到芯片调试快得多。
3.6 小结
链接脚本是 Bootloader 的骨架。你不需要背下所有语法,但必须理解:
- MEMORY 定义物理资源
- SECTIONS 定义逻辑布局
- 启动代码负责执行段拷贝和清零
- Bootloader 场景下要特别注意向量表重定位