3. 链接脚本与内存布局:.ld 文件深度解析
做 MCU 底层开发,链接脚本(.ld 文件)是绕不开的一道坎。我见过不少工程师,写应用代码溜得很,一碰到链接脚本就头大,直接拿现成的改两下完事。结果呢?芯片跑起来莫名其妙死机,或者 RAM 不够用,查半天发现是内存布局出了问题。
说白了,链接脚本就是告诉编译器:你的代码放哪,数据放哪,栈和堆又该划多大。今天咱们就把这层窗户纸捅破。
3.1 链接脚本结构解析
一个标准的 .ld 文件,核心就三部分:内存区域定义、段定义、符号导出。我习惯把它看作一张「房产证」—— 芯片的 Flash 和 RAM 就是地皮,你得告诉编译器哪块地盖什么楼。
3.1.1 MEMORY 命令:划地盘
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这里定义了 Flash 和 RAM 的起始地址和大小。注意那个 (rx) 和 (rwx),分别表示读-执行、读-写-执行。我在 STM32F4 项目上就吃过亏 —— 把 RAM 属性写成了 (rw),结果代码里想从 RAM 执行函数,直接 HardFault。
arm-none-eabi-size 一查,代码段已经超出 Flash 范围,但链接器没报错。所以,一定要在编译脚本里加 --check-sections 选项。
3.1.2 SECTIONS 命令:分房间
这是链接脚本的核心。每个段(section)就像房间,你得告诉编译器哪些内容搬进哪个房间。
SECTIONS
{
.text :
{
*(.isr_vector) /* 中断向量表 */
*(.text) /* 代码 */
*(.rodata) /* 只读数据 */
_etext = .; /* 代码段结束地址 */
} > FLASH
.data : AT(_etext)
{
_sdata = .;
*(.data)
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss)
_ebss = .;
} > RAM
}
这里有个关键点:.data 段用了 AT(_etext)。什么意思?数据段在运行时放在 RAM,但初始值存在 Flash 里。启动代码需要把这段数据从 Flash 拷贝到 RAM。嗯,这个拷贝动作,就是 Bootloader 或 CRT 启动文件干的活。
3.2 代码段 / 数据段 / BSS 段布局
咱们把这三个段拆开细说。你想想看,一个程序编译完,最终在芯片里长什么样?
| 段名 | 存储位置 | 内容 | 特点 |
|---|---|---|---|
.text |
Flash | 指令、常量、中断向量表 | 只读,掉电不丢失 |
.data |
RAM(初始值在 Flash) | 已初始化的全局/静态变量 | 可读写,启动时从 Flash 拷贝 |
.bss |
RAM | 未初始化或初始化为 0 的变量 | 可读写,启动时清零 |
我记得有一次做低功耗项目,发现休眠电流比预期大了 200μA。查了半天,原来是 .bss 段里有个大数组没清零,里面残留了随机值,导致外设引脚状态不对。从那以后,我每次都会检查启动文件里的 __bss_start 和 __bss_end__ 符号是否正确。
_sflash = ORIGIN(FLASH);
_eflash = ORIGIN(FLASH) + LENGTH(FLASH);
_sram = ORIGIN(RAM);
_eram = ORIGIN(RAM) + LENGTH(RAM);
这样在 C 代码里可以直接用 extern uint32_t _sflash; 来获取地址,方便做内存校验和 CRC 计算。
3.3 栈与堆的配置策略
栈和堆的配置,是 Bootloader 开发中最容易翻车的地方。我见过有人把栈设成 1KB,结果函数嵌套深一点就栈溢出;也有人把堆设得巨大,结果 .bss 段没空间放变量了。
3.3.1 栈(Stack)配置
栈用于局部变量、函数调用、中断现场保存。在 Cortex-M 系列中,栈指针由 MSP(主栈指针)或 PSP(进程栈指针)控制。Bootloader 通常只用 MSP。
/* 在汇编启动文件中定义栈大小 */
.equ Stack_Size, 0x400 /* 1KB */
.section .stack
.align 3
_stack_start:
.space Stack_Size
_stack_end:
我个人建议:Bootloader 的栈不要小于 512 字节。为什么?因为 Bootloader 要处理 Flash 擦写、校验、通信协议,这些操作都有一定的函数调用深度。我曾经在某个项目里把栈设成 256 字节,结果在擦除 Flash 时调用了回调函数,栈直接爆了,程序卡死在擦除循环里。
栈大小 ≈ 最大中断嵌套深度 × 每个中断的栈帧 + 最大函数调用链的栈使用 + 安全余量(20%)
对于 Bootloader,我通常取 1KB ~ 2KB,够用又不浪费。
3.3.2 堆(Heap)配置
堆用于动态内存分配(malloc/free)。但在 MCU Bootloader 里,我强烈建议:能不用堆就不用堆。
为什么?
- 堆管理有额外开销(碎片、链表维护)
- Bootloader 环境简单,用静态数组完全可以替代
- 一旦堆溢出,定位问题非常困难
如果实在要用,比如需要动态接收变长固件包,那堆大小可以这样设:
/* 堆大小 = 最大固件包大小 × 2 + 协议缓冲区 */
.equ Heap_Size, 0x800 /* 2KB */
我记得有个项目,应用层用了 FreeRTOS,堆由操作系统管理。但 Bootloader 里我直接关了堆,所有缓冲区都静态分配。这样既省了 RAM,又避免了堆碎片问题。
3.4 完整内存布局图
下面这张图,是我做 Bootloader 时经常参考的内存布局。你一看就明白:
3.5 实战配置建议
说了这么多,咱们总结几条实战经验:
- Bootloader 的 .text 段尽量放在 Flash 开头,因为中断向量表必须从 0x08000000 开始(除非你用了 VTOR 重映射)。
- .data 段的 LMA(加载地址)和 VMA(运行地址)要分开,这是 Bootloader 启动拷贝的基础。
- 栈大小设为 1KB 起步,如果用到文件系统或加密库,建议 2KB~4KB。
- 堆能不用就不用,非要用的请把堆放在栈前面,防止栈向下生长时踩到堆的数据。
- 在链接脚本末尾加
PROVIDE符号,方便启动文件和 C 代码引用:
PROVIDE(_stack_top = ORIGIN(RAM) + LENGTH(RAM));
PROVIDE(_heap_start = _ebss);
PROVIDE(_heap_end = _stack_top - Stack_Size);
arm-none-eabi-objdump -h 查看各段的地址和大小,确认是否符合预期。再用 arm-none-eabi-nm 查看关键符号地址,比如 _sdata、_ebss 等。这一步能提前发现 90% 的内存布局问题。