3. 链接脚本深度解析:.lds 的结构、内存布局与段映射
链接脚本,说白了就是告诉链接器:你的代码该往哪里放。很多做嵌入式开发的朋友,写了几年 C 代码,却从来没碰过 .lds 文件。嗯,这其实挺危险的。我记得刚入行那会儿,自己写了个 BootLoader,烧进去就是不跑,折腾了两天才发现——是链接脚本里 RAM 地址写错了。
今天我们就把它彻底讲透。你想想看,搞懂了链接脚本,你就能精确控制每一段代码、每一个全局变量在 Flash 和 RAM 里的位置。这对 BootLoader 的 OTA 升级来说,是基本功。
3.1 链接脚本的基本结构
一个标准的 .lds 文件,通常由三大部分组成:内存布局定义、段映射规则、符号导出。我习惯把它看作一张「搬家地图」——告诉链接器什么东西搬到哪个房间。
核心三要素:
- MEMORY:描述芯片有哪些物理存储区域(Flash、RAM、ROM 等)
- SECTIONS:定义如何把输入段(.text、.data、.bss)放到 MEMORY 中
- 符号赋值:提供链接时地址常量,供代码运行时使用
来看一个最简的示例,我把它叫做「Hello World 版链接脚本」:
/* 最简单的链接脚本示例 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
*(.text*)
*(.rodata*)
} > FLASH
.data :
{
*(.data*)
} > RAM AT> FLASH
.bss :
{
*(.bss*)
} > RAM
}
这段脚本虽然短,但包含了所有关键概念。我们逐行拆解。
3.2 MEMORY 命令:定义物理存储
MEMORY 块用来描述目标芯片的物理存储资源。每个区域需要指定:名字、属性、起始地址、长度。
| 属性 | 含义 | 典型用途 |
|---|---|---|
| r | 可读 | 所有存储区域都应有 |
| w | 可写 | RAM 必须包含 |
| x | 可执行 | Flash 存放代码时需要 |
| ! | 反转属性 | 很少用,但某些场景下有用 |
我的经验: 属性写错是新手常犯的坑。比如把 Flash 写成 rw,链接器不会报错,但运行时写 Flash 会触发 HardFault。我曾经排查过一整天,最后发现是属性少了个 x。
ORIGIN 和 LENGTH 的值取决于具体芯片。以 STM32F103 为例,Flash 从 0x08000000 开始,大小 512KB;RAM 从 0x20000000 开始,大小 128KB。不同型号要查数据手册,千万别想当然。
3.3 SECTIONS 命令:段映射的核心
SECTIONS 是链接脚本的灵魂。它定义了每个输出段从哪里来、到哪里去。我习惯把它分成三类:只读段、可读写段、零初始化段。
3.3.1 .text 段:代码与只读数据
.text 段包含你的程序指令和常量数据。它通常放在 Flash 里,因为 Flash 是只读可执行的。
.text :
{
/* 中断向量表必须放在最前面 */
. = ALIGN(4);
_stext = .;
KEEP(*(.isr_vector))
/* 用户代码 */
*(.text*)
*(.text.*)
/* 只读数据 */
*(.rodata*)
*(.rodata.*)
. = ALIGN(4);
_etext = .;
} > FLASH
这里有几个细节要注意:
- KEEP:告诉链接器不要优化掉这个段。中断向量表必须保留,否则芯片上电后找不到入口。
- ALIGN(4):4 字节对齐。ARM Cortex-M 要求代码对齐,否则可能触发异常。
- _stext / _etext:我习惯定义这些符号,方便在代码里获取段的起始和结束地址。
3.3.2 .data 段:已初始化的全局变量
.data 段存放已初始化的全局变量和静态变量。这些变量在编译时就有初始值,但初始值必须存在 Flash 里,运行时再拷贝到 RAM。
.data : ALIGN(4)
{
_sdata = .;
*(.data*)
*(.data.*)
. = ALIGN(4);
_edata = .;
} > RAM AT> FLASH
注意 > RAM AT> FLASH 这个语法:
> RAM:运行时,.data 段在 RAM 中AT> FLASH:但它的初始值存储在 Flash 中
这就是为什么 BootLoader 启动代码里要有一段「搬运」逻辑——把 .data 从 Flash 拷贝到 RAM。我见过有人忘记实现这个拷贝,结果全局变量全是乱的,程序跑飞都不知道为什么。
避坑指南: 我曾经接手过一个项目,.data 段搬运代码写错了拷贝长度,导致部分全局变量初始值不对。排查了三天,最后用 map 文件对比才发现 _sdata 和 _edata 的差值算错了。所以,一定要在 map 文件里确认段的大小。
3.3.3 .bss 段:未初始化的全局变量
.bss 段存放未初始化或初始化为 0 的全局变量。它只占 RAM 空间,不占 Flash 空间。启动时由代码清零。
.bss (NOLOAD) : ALIGN(4)
{
_sbss = .;
*(.bss*)
*(.bss.*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
(NOLOAD) 告诉链接器:这个段不需要加载初始值。链接器不会在生成的二进制文件中为 .bss 分配空间。启动代码只需要把从 _sbss 到 _ebss 的区域清零即可。
3.4 内存布局的完整视图
为了让你更直观地理解,我画了一张内存布局图。它展示了 BootLoader 在 Flash 和 RAM 中的典型分布。
3.5 链接脚本中的符号与启动流程
链接脚本定义的符号,在启动代码中会被直接引用。以 ARM Cortex-M 为例,典型的启动流程是这样的:
- 从 Flash 0x00000000 取栈顶指针(由链接脚本提供)
- 从 Flash 0x00000004 取复位向量,跳转执行
- 在 Reset_Handler 中:
- 拷贝 .data 段:从 Flash 的 LMA 地址拷贝到 RAM 的 VMA 地址
- 清零 .bss 段:从 _sbss 到 _ebss 全部写 0
- 跳转到 main()
对应的启动代码片段:
/* 链接脚本导出的符号 */
extern uint32_t _sdata, _edata; /* .data 在 RAM 中的起止 */
extern uint32_t _sidata; /* .data 在 Flash 中的起始 */
extern uint32_t _sbss, _ebss; /* .bss 段的起止 */
void Reset_Handler(void)
{
uint32_t *src, *dst;
/* 1. 拷贝 .data 段 */
src = &_sidata;
dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
/* 2. 清零 .bss 段 */
dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
/* 3. 跳转到主函数 */
main();
}
我的习惯: 我会在链接脚本里额外定义一个 _sstack 和 _estack,用来在启动时检查栈是否溢出。这在调试 OTA 升级时的栈溢出问题特别有用。你可以在 main 函数开头加一段栈填充和校验逻辑。
3.6 常见陷阱与调试技巧
搞链接脚本,说白了就是和地址打交道。我踩过的坑,列出来给你参考:
| 问题 | 现象 | 原因 | 解决方法 |
|---|---|---|---|
| 程序跑飞 | 上电后无反应 | 中断向量表未放在开头 | 用 KEEP 保留 .isr_vector |
| 全局变量值不对 | 变量初始值混乱 | .data 拷贝未执行或拷贝长度错误 | 检查 _sdata/_edata 符号 |
| HardFault | 访问地址异常 | 段未对齐或越界 | 检查 ALIGN 和 LENGTH |
| OTA 升级后不启动 | 跳转到新固件失败 | 新固件的链接脚本地址冲突 | 确认 Flash 分区地址不重叠 |
避坑指南: 我曾经在 OTA 项目中,把 BootLoader 和 App 的链接脚本写成了完全相同的 Flash 起始地址。结果升级后,BootLoader 把自己覆盖了——板子直接变砖。从那以后,我强制要求每个固件版本都单独维护一份链接脚本,并在编译时打印出内存布局。
调试链接脚本最有效的工具是 map 文件。编译时加上 -Wl,-Map=output.map,就能生成详细的映射信息。我每次修改 .lds 后,第一件事就是打开 map 文件,确认每个段的地址和大小是否符合预期。
嗯,链接脚本的内容就讲到这里。你可能会觉得它有点枯燥,但相信我——当你亲手调试过一个因为链接脚本错误导致的启动失败后,你就会明白,这 30 分钟的投入,值。