4、链接脚本深入:链接脚本(.ld)的作用、内存布局定义、段(.text, .data, .bss)的分配。
好,咱们继续往下走。上一章我们把启动代码的汇编部分捋了一遍,你可能会想:代码写好了,怎么把它放到芯片里正确的地址上?这就得靠链接脚本了。
说实话,我刚开始做嵌入式那会儿,觉得链接脚本就是个黑盒子。编译器生成的 .o 文件扔进去,出来一个 .bin,完事。直到有一次,我调试一个诡异的启动失败问题——程序在 RAM 里跑得挺好,一烧到 Flash 就死机。折腾了两天,最后发现是 .data 段没被正确拷贝到 RAM 里。嗯,从那以后,我再也不敢小看这个 .ld 文件了。
链接脚本到底在干什么?
说白了,链接脚本就是一张「地图」。它告诉链接器:
- 你的芯片有哪些内存区域(Flash、RAM、外设寄存器空间)
- 每个区域从哪个地址开始、有多大
- 你的代码和数据分别该放到哪个区域
你想想看,如果没有这张地图,链接器就只能把所有的东西胡乱堆在一起。那肯定不行——Flash 和 RAM 的地址空间是分开的,代码必须放在 Flash 里才能掉电不丢失,变量必须放在 RAM 里才能读写。
核心概念:链接脚本定义了「输入段」到「输出段」的映射关系。输入段是 .o 文件里的 .text、.data、.bss,输出段是最终可执行文件里的内存布局。
内存布局:你得先知道芯片长什么样
写链接脚本之前,必须先搞清楚目标芯片的内存映射。以最常见的 STM32F103 为例:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Flash | 0x08000000 | 64KB ~ 1MB | 存放代码和只读数据 |
| SRAM | 0x20000000 | 20KB ~ 192KB | 存放变量和堆栈 |
| 系统存储器 | 0x1FFFF000 | 2KB | Bootloader 出厂固件 |
| 外设寄存器 | 0x40000000 | 可变 | GPIO、UART 等外设 |
这些地址在芯片的数据手册里都能找到。我个人习惯是把它们定义成链接脚本开头的常量,方便后面引用。
三个核心段:.text、.data、.bss
咱们写 C 代码的时候,变量和函数会被编译器自动归类到不同的段里。理解这三个段,是看懂链接脚本的关键。
.text 段:你的代码和常量
.text 段存放的是程序指令和只读常量。它必须放在 Flash 里,因为 Flash 是只读的,掉电不丢失。启动后 CPU 直接从 Flash 取指令执行。
.data 段:初始化的全局变量
.data 段存放的是有初始值的全局变量和静态变量。比如 int a = 42;,这个 42 的初始值必须保存在 Flash 里,但变量 a 本身必须在 RAM 里才能被修改。所以启动代码需要把 .data 段从 Flash 拷贝到 RAM。
.bss 段:未初始化的全局变量
.bss 段存放的是没有初始值或初始值为 0 的全局变量。它只占 RAM 空间,不占 Flash 空间。启动代码需要把这部分 RAM 区域清零。
我的经验:很多新手会忽略 .bss 段的清零。如果你发现上电后全局变量不是 0,或者程序行为随机,十有八九是 .bss 没清。我曾经在一个电机控制项目里踩过这个坑,电机上电后随机抖动,查了两天才发现是 .bss 段没初始化。
一个完整的链接脚本长什么样?
咱们直接看代码。这是一个针对 STM32F103 的最小链接脚本:
/* 内存区域定义 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}
/* 输出段定义 */
SECTIONS
{
/* .text 段:放在 Flash 里 */
.text :
{
. = ALIGN(4);
_stext = .; /* 代码起始地址 */
*(.text*) /* 所有 .text 子段 */
*(.rodata*) /* 只读数据 */
. = ALIGN(4);
_etext = .; /* 代码结束地址 */
} > FLASH
/* .data 段:初始值在 Flash,运行时在 RAM */
.data : AT (ADDR(.text) + SIZEOF(.text))
{
. = ALIGN(4);
_sdata = .; /* RAM 中数据起始地址 */
*(.data*) /* 所有 .data 子段 */
. = ALIGN(4);
_edata = .; /* RAM 中数据结束地址 */
} > RAM AT > FLASH
/* .bss 段:只占 RAM,不占 Flash */
.bss :
{
. = ALIGN(4);
_sbss = .; /* BSS 起始地址 */
*(.bss*) /* 所有 .bss 子段 */
*(COMMON) /* 通用符号 */
. = ALIGN(4);
_ebss = .; /* BSS 结束地址 */
} > RAM
/* 堆栈区域 */
. = ALIGN(4);
_end = .; /* 堆起始地址 */
PROVIDE (_stack_top = ORIGIN(RAM) + LENGTH(RAM));
}
这段脚本里,有几个关键点我得跟你说明白:
- MEMORY 块:定义了两个内存区域,FLASH 和 RAM,分别指定了起始地址和大小。
- SECTIONS 块:定义了输出段的布局。每个段都可以指定加载地址(LMA)和运行地址(VMA)。
- AT 关键字:.data 段用了
AT (ADDR(.text) + SIZEOF(.text)),意思是 .data 的初始值紧跟在 .text 后面存放在 Flash 里,但运行时会被拷贝到 RAM 中。 - 符号导出:
_stext、_etext、_sdata这些符号,启动代码里会用到它们来执行拷贝和清零操作。
注意:.data 段的 LMA 和 VMA 不同,这是 Bootloader 启动代码必须处理的核心逻辑。如果你忘了在启动代码里做拷贝,.data 段里的全局变量初始值全是错的。我见过有人把这个问题误判成编译器 bug,其实只是链接脚本没写对。
段分配的可视化:一张图看懂
光说可能不够直观,我画了一张图,把 Flash 和 RAM 里的布局展示出来:
这张图把 Flash 和 RAM 的对应关系画得很清楚。你看,.data 段在 Flash 里存了一份初始值,启动时拷贝到 RAM 里。.bss 段只占 RAM 空间,启动时清零。堆栈从 RAM 末尾往下生长。
链接脚本里的符号:启动代码的「信使」
链接脚本里定义的符号,比如 _stext、_sdata、_ebss,在 C 代码里可以用 extern 声明来引用。上一章我们写的启动代码里,就用到了这些符号:
extern uint32_t _stext, _etext;
extern uint32_t _sdata, _edata;
extern uint32_t _sbss, _ebss;
void reset_handler(void)
{
uint32_t *src, *dst;
/* 拷贝 .data 段:从 Flash 到 RAM */
src = &_etext; /* .data 初始值在 .text 之后 */
dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
/* 清零 .bss 段 */
dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
/* 跳转到 main() */
main();
}
这里有个细节:&_etext 指向的是 .text 段的结束地址,也就是 .data 初始值在 Flash 里的起始地址。因为我们在链接脚本里用 AT (ADDR(.text) + SIZEOF(.text)) 把 .data 的 LMA 放在了 .text 后面。
避坑指南:链接脚本里的符号,在 C 代码里取地址时一定要加 &。因为这些符号本质上是地址常量,不是变量。不加 & 的话,你拿到的是地址的值,而不是地址本身。我曾经在这个问题上浪费了半天,打印出来的地址全是 0。
链接脚本的进阶技巧
等你把基础版本跑通了,可以试试这些进阶用法:
- 段对齐:用
. = ALIGN(4);保证段起始地址是 4 字节对齐的。ARM 指令要求对齐,不对齐会触发异常。 - KEEP 关键字:
KEEP(*(.isr_vector))告诉链接器,即使这个段没有被引用,也要保留。中断向量表必须放在最开头,不能被优化掉。 - PROVIDE 关键字:
PROVIDE(_stack_top = ...)定义了一个弱符号。如果其他地方没有定义_stack_top,就用这个默认值。 - 多个输入段合并:
*(.text*)匹配所有 .o 文件里的 .text 子段,把它们合并成一个输出段。
调试链接脚本的小技巧
链接脚本写好了,怎么验证对不对?我一般用这几个方法:
- 查看 map 文件:编译时加
-Wl,-Map=output.map,链接器会生成一个 map 文件,里面详细列出了每个段的地址和大小。 - 用 objdump 反汇编:
arm-none-eabi-objdump -h output.elf可以查看各段的 VMA、LMA 和大小。 - 检查符号表:
arm-none-eabi-nm output.elf | grep _sdata可以确认链接脚本里定义的符号是否正确。
我个人习惯在启动代码里加一段调试输出,把 _sdata、_edata、_sbss、_ebss 的地址通过串口打印出来。如果地址和预期不符,说明链接脚本有问题,赶紧回头检查。
重要提醒:链接脚本的语法很严格,少一个分号、多一个空格都可能导致链接失败。我建议你从官方的示例脚本开始改,不要自己从头写。ST 官方提供了 STM32 系列的链接脚本模板,直接拿来用,改一下 Flash 和 RAM 的大小就行。