3. 链接脚本深度解析:.lds 文件、内存布局与段映射
链接脚本,说白了就是告诉链接器:你的代码该往哪里放。很多刚入行的朋友觉得这玩意儿就是抄个模板,改改地址就行。我刚开始做 Bootloader 时也这么想,结果有一次芯片死活跑不起来,折腾了两天,最后发现是 .bss 段没清零——链接脚本里少写了一行。
嗯,从那以后我再也不敢小看 .lds 了。今天咱们就把这东西彻底讲透。
3.1 链接脚本的结构骨架
一个典型的 .lds 文件,其实就三大部分:
- 内存布局定义(MEMORY 命令)
- 段映射规则(SECTIONS 命令)
- 符号导出(PROVIDE、ASSERT 等辅助命令)
我个人习惯把链接脚本比作一张「房产证」——MEMORY 告诉你有几块地(RAM、Flash),SECTIONS 告诉你每间房(代码段、数据段)分给谁。
核心要点:链接脚本的最终产物是一个 ELF 文件,里面包含了每个段的起始地址、长度和内容。Bootloader 的启动代码就是靠这些地址来初始化内存的。
3.2 MEMORY 命令:定义你的「地皮」
先看一个实际项目中我常用的内存定义:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 16K
}
这里每个字段的含义:
| 字段 | 含义 | 我踩过的坑 |
|---|---|---|
| ORIGIN | 起始地址 | 一定要和芯片手册的 memory map 对齐,差一个字都不行 |
| LENGTH | 可用大小 | 别写满,留点余量给堆栈 |
| (rx)/(rwx) | 访问属性 | Flash 别写 w,否则链接器会报错 |
我曾经犯过的错:把 FLASH 的 LENGTH 写成了 128K,但芯片实际只有 64K。链接时没报错,下载后程序跑飞。后来加了 ASSERT 才抓住这个问题。
3.3 SECTIONS 命令:段映射的核心
这是链接脚本里最复杂的部分。咱们拆开来看。
3.4 .text 段:代码的归宿
SECTIONS
{
.text :
{
. = ALIGN(4);
_stext = .; /* 代码段起始符号 */
*(.isr_vector) /* 中断向量表,必须放在最前面 */
*(.text*) /* 所有 .text 子段 */
*(.rodata*) /* 只读数据,比如字符串常量 */
. = ALIGN(4);
_etext = .; /* 代码段结束符号 */
} > FLASH
}
你想想看,为什么中断向量表要放最前面?因为芯片复位后,CPU 会从 0x08000000(或你设置的起始地址)取前 4 字节作为栈指针,再取 4 字节作为复位向量。顺序错了,系统直接死机。
个人经验:我习惯在 .text 段开头和结尾都放一个符号(_stext / _etext),这样启动代码里可以计算代码段大小,方便做校验和。
3.5 .data 段:初始化的全局变量
.data : AT (ADDR(.text) + SIZEOF(.text))
{
. = ALIGN(4);
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT > FLASH
这里有个关键点:AT 指令。它表示 .data 段在运行时位于 RAM,但它的初始值存储在 Flash 里(紧跟在 .text 后面)。启动代码必须把这些初始值从 Flash 拷贝到 RAM。
我记得第一次看到这个语法时,完全没理解「> RAM AT > FLASH」是什么意思。说白了就是:运行地址在 RAM,加载地址在 Flash。
3.6 .bss 段:未初始化的全局变量
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
.bss 段不需要从 Flash 拷贝初始值,因为它的值就是 0。但启动代码必须把它清零。我见过太多人忘了这一步,结果全局变量一上电就是随机值,程序行为完全不可控。
避坑指南:如果你的芯片有多个 RAM 区域(比如 CCMRAM),记得给每个区域单独定义 .bss 段,否则链接器会把所有 .bss 塞到默认 RAM 里,导致溢出。
3.7 完整的段映射关系图
下面这张图是我做培训时必画的,它把整个内存布局讲得清清楚楚:
3.8 链接脚本中的符号导出
除了段定义,链接脚本还经常用 PROVIDE 导出符号,供启动代码使用:
PROVIDE(_estack = ORIGIN(RAM) + LENGTH(RAM));
PROVIDE(_heap_start = _ebss);
PROVIDE(_heap_end = ORIGIN(RAM) + LENGTH(RAM) - 4K);
这些符号在 C 代码里用 extern 声明就能直接引用:
extern uint32_t _estack;
extern uint32_t _sdata, _edata;
extern uint32_t _sbss, _ebss;
void startup(void)
{
uint32_t *src, *dst;
// 拷贝 .data
src = &_sdata; // 实际是 Flash 地址?不对!
// 这里需要 __etext 符号指向 Flash 中的初始值
}
注意:上面代码有个陷阱。_sdata 是 RAM 地址,不是 Flash 地址。要获取 .data 在 Flash 中的位置,需要额外定义一个符号,比如 __etext,放在 .text 段末尾。我习惯这样写:
.text :
{
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .; /* 这就是 .data 初始值在 Flash 中的起始地址 */
} > FLASH
3.9 多区域内存布局的实战技巧
有些芯片有多个 RAM 区域,比如 STM32 的 CCMRAM。这时候需要为每个区域单独定义段:
.ccm_data :
{
*(.ccm_data*)
} > CCMRAM
.ccm_bss (NOLOAD) :
{
*(.ccm_bss*)
} > CCMRAM
然后在 C 代码里用属性指定:
__attribute__((section(".ccm_data"))) uint32_t fast_buffer[1024];
__attribute__((section(".ccm_bss"))) uint32_t temp_flag;
我个人习惯把中断处理函数用的变量放到 CCMRAM,因为它的访问延迟更低。但要注意,CCMRAM 不支持 DMA 访问,别把 DMA buffer 放进去。
3.10 链接脚本的调试与验证
写完链接脚本后,怎么确认它是对的?我一般用这几个方法:
- 查看 .map 文件:链接器生成的 map 文件会列出每个段的地址和大小,仔细检查起始地址对不对
- 用 readelf 或 objdump:
arm-none-eabi-objdump -h firmware.elf可以看段信息 - 加 ASSERT:在链接脚本里加断言,比如
ASSERT(_ebss <= ORIGIN(RAM) + LENGTH(RAM), "BSS overflow!")
我曾经踩过的坑:有一次 .data 段和 .bss 段之间有 4 字节的间隙,导致拷贝后 .bss 的起始地址不对。后来我在每个段之间都加了 . = ALIGN(4);,再也没出过问题。
链接脚本这东西,看着简单,但细节特别多。你只要把 MEMORY、SECTIONS、符号导出这三块搞明白,再配合 map 文件反复验证,基本就能驾驭了。下次遇到芯片跑不起来,先别急着查代码,看看链接脚本——说不定问题就出在这里。