4、静态配置与链接脚本:链接脚本(.ld)编写实战、段(section)布局策略、符号表与地址绑定、启动代码配置
说实话,很多嵌入式工程师写了三五年代码,对链接脚本还是一知半解。我见过不少人把 .ld 文件当成「祖传代码」,从来不敢动,也不敢删。其实这东西没那么玄乎。说白了,链接脚本就是告诉链接器:你的代码放哪,数据放哪,堆栈放哪。
今天咱们就把这层窗户纸捅破。我会结合我实际项目中踩过的坑,带你手写一个能用的链接脚本。
4.1 链接脚本到底在干什么?
先想一个问题:你写了个 int a = 0x55;,编译器把它变成了机器码。但机器码不知道这个变量该放在地址 0x20000000 还是 0x20001000。谁来决定?链接器。链接器依据什么?链接脚本。
链接脚本的核心职责就三条:
- 定义内存布局——告诉链接器芯片有哪些 RAM、哪些 Flash,地址范围是多少。
- 分配段(Section)——把 .text、.data、.bss 放到对应的内存区域。
- 绑定符号地址——让全局变量、函数入口有确定的地址。
一句话总结:没有链接脚本,你的程序就是一盘散沙,不知道往哪放。
4.2 手写一个 .ld 文件——从零开始
我习惯用 STM32F103 举例,因为它够典型。Flash 从 0x08000000 开始,RAM 从 0x20000000 开始。咱们一步步来。
4.2.1 入口与内存定义
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这里 ENTRY 指定了程序入口——复位后第一条指令从 Reset_Handler 开始。MEMORY 块定义了两块物理内存。注意属性:Flash 是只读可执行(rx),RAM 是读写可执行(rwx)。
小经验:我刚开始写链接脚本时,把 Flash 写成了 rwx,链接器没报错,但下载后程序跑飞了。因为 Cortex-M3 从 Flash 取指令时,如果属性不对,总线 fault。所以属性别乱写。
4.2.2 段布局——.text、.data、.bss
接下来是重头戏。咱们把各个段安排到对应的内存区域。
SECTIONS
{
/* 中断向量表 + 代码段,放在 Flash */
.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
/* 初始化数据段,运行时在 RAM,初始值在 Flash */
.data : AT (ADDR(.text) + SIZEOF(.text))
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
/* BSS 段,零初始化 */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
这里有几个关键点:
.isr_vector必须放在最前面,且用KEEP防止链接器优化掉。.data的加载地址(LMA)在 Flash,运行地址(VMA)在 RAM。启动代码需要把数据从 Flash 拷贝到 RAM。_sdata、_edata、_sbss、_ebss这些符号,启动代码会用到。
注意:很多人忘记给 .data 段指定加载地址。如果不写 AT (...),链接器默认加载地址等于运行地址,那你的初始化数据就没了来源。我见过一个项目,产品偶尔启动异常,查了两周才发现是 .data 段没拷贝完整。
4.3 符号表与地址绑定——链接器在背后做了什么?
你写了个全局变量 uint32_t sys_tick = 1000;,链接器怎么知道它放在哪?
过程是这样的:
- 编译器生成目标文件(.o),里面记录了符号名和它在段内的偏移。
- 链接器读取所有 .o 文件,合并同名的段。
- 根据链接脚本,给每个段分配基地址。
- 符号的最终地址 = 段基地址 + 段内偏移。
你可以用 objdump -t 或 nm 查看符号表。比如:
$ arm-none-eabi-nm firmware.elf
08000100 T Reset_Handler
20000000 D sys_tick
20000004 B heap_start
看到没?sys_tick 被分配到了 RAM 起始地址 0x20000000。这就是链接器根据链接脚本算出来的。
实用技巧:我习惯在链接脚本里定义一些辅助符号,比如 _stack_end = ORIGIN(RAM) + LENGTH(RAM);,然后在启动代码里直接用。这样改内存大小只需要改一处。
4.4 启动代码配置——从复位到 main()
链接脚本和启动代码是「搭档」。启动代码负责执行链接脚本定义的布局。典型的启动流程:
- 从
.isr_vector取出栈指针和 Reset_Handler 地址。 - 跳转到 Reset_Handler。
- 拷贝 .data 段从 Flash 到 RAM。
- 清零 .bss 段。
- 调用 SystemInit(时钟配置等)。
- 跳转到 main()。
下面是一段精简的启动代码(汇编):
Reset_Handler:
/* 拷贝 .data 段 */
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_sidata
movs r3, #0
b LoopCopyDataInit
CopyDataInit:
ldr r4, [r2, r3]
str r4, [r0, r3]
adds r3, r3, #4
LoopCopyDataInit:
adds r4, r0, r3
cmp r4, r1
bcc CopyDataInit
/* 清零 .bss 段 */
ldr r0, =_sbss
ldr r1, =_ebss
movs r2, #0
b LoopFillZerobss
FillZerobss:
str r2, [r0]
adds r0, r0, #4
LoopFillZerobss:
cmp r0, r1
bcc FillZerobss
/* 跳转到 main */
bl main
b .
注意 _sidata 这个符号——它指向 .data 段在 Flash 中的加载地址。这个符号需要在链接脚本里定义:
_sidata = LOADADDR(.data);
我的习惯:我会在启动代码里加一个「看门狗喂狗」的占位,防止调试时看门狗复位。另外,我建议在跳转到 main 之前把全局中断关掉,等 main 里初始化完再开。不然中断来了,外设还没配好,容易出问题。
4.5 段布局策略——什么时候需要自定义段?
默认的 .text、.data、.bss 够用吗?大部分场景够。但有些情况你必须自定义段:
| 场景 | 做法 | 原因 |
|---|---|---|
| 关键函数放 RAM 执行 | 定义 .ramfunc 段,放在 RAM |
Flash 读取慢,或需要实时擦写 Flash |
| 掉电保存参数 | 定义 .noinit 段,不初始化 |
复位后保留上次运行的数据 |
| Bootloader + App | 定义 .app_vector 段,偏移地址 |
两个程序共存,中断向量表不冲突 |
| DMA 缓冲区对齐 | 定义 .dma_buffer 段,指定对齐 |
某些 DMA 要求缓冲区地址按 1KB 对齐 |
举个例子,把某个函数放到 RAM:
/* 链接脚本中 */
.ramfunc : AT (ADDR(.text) + SIZEOF(.text))
{
. = ALIGN(4);
_sramfunc = .;
*(.ramfunc)
*(.ramfunc*)
. = ALIGN(4);
_eramfunc = .;
} > RAM
/* C 代码中 */
__attribute__((section(".ramfunc")))
void flash_erase(void)
{
/* 擦除 Flash 的操作 */
}
这样 flash_erase 函数就被放到了 RAM 里执行。为什么?因为你在擦除 Flash 时,不能从 Flash 取指令——指令总线会冲突。放到 RAM 执行就避免了这个问题。
我曾经踩过的坑:有一次我把 .ramfunc 段的加载地址写错了,导致启动时拷贝的数据不对。函数执行到一半就 HardFault。后来我在启动代码里加了段校验——拷贝完后比较 CRC,不一致就报错。这个习惯我一直保留到现在。
4.6 知识结构总览
下面这张图把本章的核心逻辑串起来了。你可以把它当作一个「链接脚本设计检查清单」。
从这张图可以看得很清楚:链接脚本定义了「放什么、放哪」,启动代码负责「搬过去、清干净」。两者配合,程序才能正确运行。
4.7 避坑指南——我替你试过的错
最后分享几个我实际项目中遇到的坑,你遇到了可以少走弯路:
- 段对齐问题:ARM 要求中断向量表 4 字节对齐,某些 DMA 缓冲区要求 1KB 对齐。忘了写
ALIGN,程序可能莫名其妙跑飞。 - 符号重复定义:两个 .c 文件定义了同名的全局变量,链接器会报错。但如果你用了
WEAK属性,链接器会默默覆盖。我建议在链接脚本里用PROVIDE定义弱符号,避免意外覆盖。 - 栈溢出检测:在 .bss 段后面留一个「哨兵区域」,初始化为固定模式(比如 0xDEADBEEF)。启动后检查哨兵是否被改写,就能判断栈是否溢出。
- 链接顺序:链接器按输入文件的顺序处理段。如果你有多个库,顺序错了可能导致符号未定义。我习惯把标准库放在最后。
一个小技巧:每次修改链接脚本后,用 objdump -h firmware.elf 查看段布局,确认每个段的起始地址和大小符合预期。这个习惯帮我发现过好几次地址重叠的问题。
好了,链接脚本这块咱们就聊到这。你只要动手写一次,把启动代码和 .ld 文件配合跑通,后面再遇到类似问题就不会慌了。记住:链接脚本不是「祖传代码」,它是你程序的地基。地基稳了,上面盖多高都不怕。