3. 链接脚本与内存布局:如何定义 Bootloader 与应用程序的地址空间

说实话,我刚入行那会儿,觉得链接脚本就是个「黑盒子」。编译器帮我搞定了,我干嘛要管它?直到有一次,我写的 Bootloader 跳转到应用程序后,板子直接死机。查了三天,最后发现是链接脚本里把中断向量表的位置写错了。

从那以后,我养成了一个习惯:写固件之前,先把内存布局画清楚。你想想看,芯片就那么点 Flash 和 RAM,Bootloader 占多少、应用程序占多少、中间要不要留升级缓冲区,这些不规划好,后面全是坑。

3.1 为什么内存布局如此重要?

嵌入式系统的内存资源是固定的。Flash 用来存代码和只读数据,RAM 用来存变量和堆栈。Bootloader 和应用程序是两个独立的固件镜像,它们必须「和平共处」在同一块芯片上。

如果地址空间重叠了,会发生什么?

  • Bootloader 把自己覆盖了——死机
  • 应用程序写入了 Bootloader 的区域——变砖
  • 升级过程中断电,两边都坏了——彻底完蛋

核心原则:Bootloader 和应用程序的地址空间必须严格隔离,互不侵犯。这是固件安全的第一道防线。

3.2 典型的内存分区方案

我习惯把 Flash 分成四个区域。这个方案我在好几个量产项目里都用过,稳定可靠。

区域 起始地址 大小 内容
Bootloader 区 0x08000000 64 KB Bootloader 代码、中断向量表
参数存储区 0x08010000 16 KB 升级标志、版本号、校验值
应用程序区 0x08014000 384 KB 应用程序代码、中断向量表
备份/下载区 0x08074000 112 KB 升级固件临时存储

嗯,这里要注意:中断向量表必须放在每个区域的起始位置。这是芯片硬件决定的——CPU 上电后,会从固定地址读取栈指针和复位向量。Bootloader 的向量表在 0x08000000,应用程序的向量表在 0x08014000。

3.3 链接脚本怎么写?

以 STM32 为例,我们来看看 Bootloader 的链接脚本。我个人习惯用 GCC 工具链,它的链接脚本语法清晰,可读性强。

/* bootloader.ld - Bootloader 链接脚本 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

SECTIONS
{
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    .text :
    {
        . = ALIGN(4);
        *(.text*)
        *(.rodata*)
        . = ALIGN(4);
    } > FLASH

    .data :
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH

    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM
}

这段脚本做了几件事:

  • 定义了 Flash 从 0x08000000 开始,大小 64 KB
  • 定义了 RAM 从 0x20000000 开始,大小 20 KB
  • 中断向量表放在 Flash 开头
  • 代码和只读数据紧随其后
  • 可读写数据在 RAM 中,但初始值存储在 Flash 里(AT> FLASH)

小技巧:我习惯在链接脚本里定义一些符号,比如 _sdata、_edata,这样启动代码里可以直接用这些地址来搬运数据。省得手动算偏移量。

3.4 应用程序的链接脚本

应用程序的脚本和 Bootloader 类似,但有两个关键区别:

  • Flash 起始地址变了,不再是 0x08000000
  • 中断向量表偏移量需要重新设置
/* application.ld - 应用程序链接脚本 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08014000, LENGTH = 384K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

SECTIONS
{
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    /* 其余段与 Bootloader 类似,略 */
}

看到区别了吗?Flash 的 ORIGIN 改成了 0x08014000。这意味着应用程序的代码会从 0x08014000 开始存放。

但光改链接脚本还不够。你还需要在应用程序的启动代码里设置中断向量表偏移量寄存器。以 Cortex-M 为例:

/* 应用程序启动代码中设置向量表偏移 */
#define APP_VECTOR_TABLE_ADDR  0x08014000

void SystemInit(void)
{
    /* 设置中断向量表偏移 */
    SCB->VTOR = APP_VECTOR_TABLE_ADDR;
}

我曾经犯过一个错:只改了链接脚本,忘了改 VTOR。结果应用程序一跑,中断全跑到了 Bootloader 的向量表里。按键按下,本该触发 GPIO 中断,结果执行了定时器中断的服务函数。那场面,简直乱成一锅粥。

警告:链接脚本和 VTOR 必须同步修改。一个改了另一个没改,系统必然崩溃。这是 Bootloader 开发中最常见的错误之一。

3.5 RAM 的分配策略

Flash 的问题解决了,RAM 呢?Bootloader 和应用程序通常共用同一片 RAM。因为 Bootloader 运行完后,它的使命就结束了,RAM 里的数据可以全部释放给应用程序。

但有一个例外:升级过程中需要保存上下文。比如,你在升级前要把一些关键参数存下来,升级完成后还要恢复。这时候,我建议在 RAM 的末尾预留一小块区域,专门用来做数据交换。

/* 在链接脚本中预留共享内存区域 */
.share_buffer (NOLOAD) :
{
    . = ALIGN(4);
    _sshare = .;
    *(.share_buffer*)
    _eshare = .;
} > RAM

这个区域不会被 Bootloader 的启动代码清零,也不会被应用程序的启动代码覆盖。两边都能读写,但必须约定好数据格式。

3.6 内存布局的 SVG 示意图

光说可能不够直观。我画了一张内存布局图,你看看就明白了。

典型 Bootloader + 应用程序内存布局 Flash 存储器 Bootloader 64 KB 0x08000000 参数 16 KB 0x08010000 应用程序 384 KB 0x08014000 备份 112 KB 0x08074000 RAM 存储器 Bootloader 运行时 栈、堆、全局变量 0x20000000 共享 缓冲区 4 KB 应用程序运行时 栈、堆、全局变量 0x20001000 说明:Bootloader 运行结束后,其 RAM 区域可被应用程序复用。共享缓冲区用于升级过程中的数据交换。

3.7 避坑指南

做 Bootloader 内存布局,有几个坑我踩过,你千万别再踩了。

我曾经犯过的错:

  • Flash 大小没对齐:有些芯片的 Flash 有擦除扇区大小限制。比如 STM32F103 的扇区是 1 KB 或 2 KB。你分配 Bootloader 为 63 KB,结果最后一个扇区放不下,编译能过,下载就报错。我建议所有分区大小都按扇区对齐。
  • 忘了保护 Bootloader 区:很多芯片支持 Flash 写保护。你可以在链接脚本里把 Bootloader 区设为只读,防止应用程序意外写入。我有个项目没开保护,结果一次野指针操作把 Bootloader 区给擦了,板子直接变砖。
  • RAM 地址重叠:Bootloader 和应用程序的链接脚本里,RAM 起始地址和大小必须一致。我曾经在两个脚本里用了不同的 RAM 大小,结果 Bootloader 跑完,应用程序启动时栈指针指向了未初始化的区域。

3.8 小结

链接脚本和内存布局,说白了就是给固件画「地皮」。Bootloader 占哪块、应用程序占哪块、中间留多少缓冲区,这些必须在项目一开始就定好。

我个人习惯是先画一张内存布局图,再写链接脚本。图画清楚了,脚本就是填空。你想想看,如果连图都没画,直接上手写脚本,后面改起来得多痛苦?

嗯,这一章的内容就到这儿。记住:内存布局是 Bootloader 的基石,基石不稳,上层建筑全是危房。