3. 链接脚本与内存布局
好,咱们今天聊聊链接脚本和内存布局。说实话,这玩意儿在固件开发里属于「平时没人提,出问题急死人」的那种。我见过不少工程师,代码写得飞起,一碰到链接脚本就懵了。嗯,今天咱们把它彻底讲透。
3.1 链接脚本(.lds)到底在干什么?
说白了,链接脚本就是告诉编译器:「你的代码放哪儿,数据放哪儿,堆栈放哪儿。」它是一张内存地图。没有它,你的固件就是一盘散沙。
我个人习惯,拿到一个新芯片,第一件事就是看它的内存映射手册。然后自己手写一个 .lds 文件。为什么?因为芯片厂商给的默认脚本,很多时候并不适合你的实际需求。
来看一个典型的 ARM Cortex-M 链接脚本片段:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
. = ALIGN(4);
*(.isr_vector) /* 中断向量表 */
*(.text) /* 代码段 */
*(.text*)
*(.rodata) /* 只读数据 */
. = ALIGN(4);
_etext = .;
} > FLASH
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data) /* 初始化数据 */
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT> FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss) /* 未初始化数据 */
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
你看,这个脚本把 FLASH 和 RAM 的地址范围都定义好了。代码段(.text)放在 FLASH 里,数据段(.data)和 BSS 段放在 RAM 里。这里有个关键点:.data > RAM AT> FLASH 表示数据段运行时在 RAM,但初始值存在 FLASH 里。启动代码需要把这些数据从 FLASH 拷贝到 RAM。
核心要点:链接脚本定义了三个关键区域——代码段(.text)、数据段(.data)、BSS 段(.bss)。它们各自有明确的存放位置和用途。
3.2 代码段、数据段、BSS 段的分配策略
咱们一个一个说。
3.2.1 代码段(.text)
代码段放的是你的程序指令和只读常量。它通常放在非易失性存储器里,比如 FLASH 或 ROM。为什么?因为掉电不能丢啊。我在项目中遇到过,有人把大数组放在代码段里当常量用,结果 FLASH 空间爆了。嗯,这里要注意:只读数据也是占 FLASH 空间的。
3.2.2 数据段(.data)
数据段放的是已初始化的全局变量和静态变量。这些变量在编译时就有初始值,但运行时需要放在 RAM 里才能读写。所以启动时要从 FLASH 拷贝到 RAM。这个拷贝过程,我建议你写启动代码时一定要验证一下。我曾经踩过一个坑:拷贝地址算错了,结果全局变量全是乱的,程序跑飞了。
3.2.3 BSS 段(.bss)
BSS 段放的是未初始化的全局变量和静态变量。启动时要把这段内存清零。你想想看,如果不清零,这些变量初始值就是随机的,程序行为不可预测。所以启动代码里一定要有 memset(_sbss, 0, _ebss - _sbss) 这一步。
| 段名称 | 存放内容 | 存储位置 | 初始化方式 |
|---|---|---|---|
| .text | 程序指令、只读常量 | FLASH | 无需初始化 |
| .data | 已初始化全局/静态变量 | 运行时在 RAM,初始值在 FLASH | 启动时从 FLASH 拷贝到 RAM |
| .bss | 未初始化全局/静态变量 | RAM | 启动时清零 |
3.3 堆栈设置
堆栈设置是另一个容易出问题的地方。堆(Heap)用于动态内存分配,栈(Stack)用于函数调用和局部变量。在嵌入式系统里,我建议你尽量少用堆。为什么?因为堆容易产生碎片,而且不好调试。
来看一个典型的堆栈设置:
/* 在链接脚本中定义堆栈大小 */
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶在 RAM 末尾 */
_Min_Heap_Size = 0x200; /* 512 字节堆 */
_Min_Stack_Size = 0x400; /* 1K 字节栈 */
/* 在启动代码中设置栈指针 */
ldr r0, =_estack
msr msp, r0
我个人习惯,栈大小至少给 1KB,复杂系统给 4KB 以上。堆的话,除非你非用 malloc 不可,否则直接设成 0。我在项目中遇到过,有人把栈设得太小,函数嵌套调用一多就栈溢出,程序莫名其妙复位。查了三天才找到原因。
小技巧:在栈底放一个特殊标记(比如 0xDEADBEEF),运行时定期检查这个标记有没有被覆盖。如果被覆盖了,说明栈溢出了。这个方法我用了很多年,简单有效。
3.4 内存保护单元(MPU)
MPU 是 ARM Cortex-M3/M4/M7 等内核提供的一个硬件保护机制。它可以把内存分成多个区域,每个区域设置不同的访问权限。说白了,就是给内存上锁。
为什么要用 MPU?我举个例子。你有一个 RTOS,任务 A 和任务 B 共享一块内存。如果任务 A 的指针写错了,把任务 B 的数据覆盖了,系统就崩了。有了 MPU,你可以把任务 A 的内存区域设为只读,任务 A 想写也写不了,硬件直接触发异常。
来看一个 MPU 配置示例:
/* 配置 MPU 区域 0:整个 FLASH 为只读 */
MPU->RNR = 0; /* 区域编号 0 */
MPU->RBAR = 0x08000000; /* 基地址 */
MPU->RASR = (0x1A << 1) | /* 4KB 大小 */
(0x1 << 16) | /* 使能 */
(0x0 << 24) | /* 只读 */
(0x1 << 28); /* 可执行 */
/* 配置 MPU 区域 1:RAM 区域为可读写 */
MPU->RNR = 1;
MPU->RBAR = 0x20000000;
MPU->RASR = (0x1B << 1) | /* 8KB 大小 */
(0x1 << 16) |
(0x1 << 24) | /* 可读写 */
(0x0 << 28); /* 不可执行 */
这段代码把 FLASH 设为只读可执行,RAM 设为可读写不可执行。这样,即使代码里有缓冲区溢出漏洞,攻击者也没法在 RAM 里执行恶意代码。
注意:MPU 配置必须在特权模式下进行。而且,一旦使能 MPU,默认情况下所有未配置的区域都是不可访问的。所以你要确保把所有用到的内存区域都配置好,否则程序一跑就进 HardFault。
我曾经在一个项目里,MPU 配置漏掉了一块外设寄存器区域,结果程序一访问外设就死机。查了两天,最后发现是 MPU 把外设区域给挡住了。嗯,从那以后我养成了一个习惯:配置 MPU 时,把整个内存映射图画出来,一个区域一个区域核对。
3.5 链接脚本与 MPU 的配合
链接脚本和 MPU 是相辅相成的。链接脚本定义了「数据应该放哪儿」,MPU 定义了「谁可以访问这些数据」。我建议你在链接脚本里把内存区域划分好,然后在 MPU 里给每个区域设置合适的权限。
举个例子,你可以把代码段设为只读可执行,数据段设为可读写不可执行,BSS 段设为可读写不可执行,堆栈段设为可读写不可执行。这样,即使代码有漏洞,攻击者也没法在数据区执行代码。
最后,送你一个检查清单:
- 链接脚本里,代码段、数据段、BSS 段的地址是否正确?
- 启动代码里,数据段拷贝和 BSS 段清零是否实现?
- 堆栈大小是否足够?有没有溢出检测机制?
- MPU 配置是否覆盖了所有用到的内存区域?
- MPU 的权限设置是否符合安全要求?
好,这一章就到这里。下一章咱们聊聊启动代码和中断向量表,那是固件运行的起点。