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 的权限设置是否符合安全要求?

好,这一章就到这里。下一章咱们聊聊启动代码和中断向量表,那是固件运行的起点。