链接脚本与内存布局:给芯片画一张精确的地图

做 Bootloader 开发,第一道坎就是搞懂链接脚本。说白了,链接脚本就是告诉编译器:「你的代码该放哪儿,数据该放哪儿,入口在哪。」没有它,你编译出来的二进制文件就是一盘散沙。

我记得刚入行那会儿,接手一个老项目,链接脚本写得一塌糊涂。Flash 和 RAM 区域重叠了都没人发现,结果芯片一上电就跑飞。从那以后,我养成了一个习惯——每次新项目,第一件事就是亲手写链接脚本。

链接脚本到底在干什么?

你想想看,我们写 C 代码的时候,函数、全局变量、静态变量,这些东西最终都要落到具体的物理地址上。链接脚本就是那个「分配师」。它负责:

  • 定义存储区域:告诉链接器 Flash 从哪开始、RAM 从哪开始
  • 指定段布局:代码段(.text)、数据段(.data)、BSS 段(.bss)各放哪
  • 设置入口点:芯片复位后第一条指令从哪取
  • 定位中断向量表:中断来了,CPU 该去哪查表

核心认知:链接脚本不是「可有可无」的配置,它是固件能否正确运行的基石。一个错误的链接脚本,足以让你的 Bootloader 在产线上全军覆没。

Flash 和 RAM 区域怎么定义?

以 STM32F103 为例,它的 Flash 从 0x08000000 开始,大小 512KB;RAM 从 0x20000000 开始,大小 64KB。在链接脚本里,我们这样定义:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

这里有几个关键点:

  • ORIGIN:起始地址,必须和芯片手册一致
  • LENGTH:可用大小,别写多了,芯片没那么多空间
  • (rx)/(rwx):权限属性,Flash 只读可执行,RAM 可读写可执行

我的习惯:我会在 MEMORY 块里多定义一个「BOOTLOADER」区域,专门给 Bootloader 用。这样应用代码和 Bootloader 的地址空间彻底隔离,OTA 升级时不容易出问题。

入口地址与中断向量表

芯片上电后,CPU 会去一个固定的地址取第一条指令。对于 Cortex-M 系列,这个地址是 0x00000000,但实际映射到 Flash 的起始位置。所以,入口地址通常就是 Flash 的 ORIGIN。

中断向量表必须放在入口地址处。为什么?因为 CPU 复位后,会从向量表的第一个字(栈顶指针)和第二个字(复位向量)开始执行。我见过有人把向量表放偏了,结果中断一进来就死机。

SECTIONS
{
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))  /* 中断向量表 */
        . = ALIGN(4);
    } > FLASH

    .text :
    {
        . = ALIGN(4);
        *(.text)              /* 代码段 */
        *(.text*)
        . = ALIGN(4);
    } > FLASH

    .data :
    {
        . = ALIGN(4);
        _sdata = .;           /* 数据段起始 */
        *(.data)
        *(.data*)
        . = ALIGN(4);
        _edata = .;           /* 数据段结束 */
    } > RAM AT> FLASH

    .bss :
    {
        . = ALIGN(4);
        _sbss = .;            /* BSS 段起始 */
        *(.bss)
        *(.bss*)
        . = ALIGN(4);
        _ebss = .;            /* BSS 段结束 */
    } > RAM
}

注意看 .data 段后面的 > RAM AT> FLASH。这表示:运行时数据在 RAM 里,但初始值存储在 Flash 中。启动代码需要把这段数据从 Flash 拷贝到 RAM。这个细节,很多人会忽略。

我曾经踩过的坑:有一次做 OTA 升级,新固件下载完后,校验通过,但重启后就是跑不起来。查了两天,最后发现是链接脚本里 .data 段的 LMA(加载地址)和 VMA(运行地址)没对齐。拷贝的时候少拷贝了几个字节,全局变量全乱了。从那以后,我每次都会检查 _sdata 和 _edata 的差值是否和预期一致。

Bootloader 特有的内存布局

Bootloader 和应用代码共享同一颗芯片,但它们的地址空间必须严格隔离。我常用的布局是这样的:

区域 起始地址 大小 用途
Bootloader Flash 0x08000000 32KB Bootloader 代码 + 向量表
参数存储区 0x08008000 4KB 升级标志、版本号等
应用代码区 0x08009000 448KB 应用固件
备份区 0x08080000 64KB OTA 下载暂存
RAM 0x20000000 64KB 运行时数据

这样设计的好处是:Bootloader 永远不会被应用代码覆盖。即使 OTA 升级失败,Bootloader 依然健在,可以重新下载固件。

SVG:链接脚本与内存布局全景图

链接脚本与内存布局全景图 链接脚本 (.ld) MEMORY 块 定义 Flash / RAM 区域 SECTIONS 块 分配各段位置 ENTRY 指令 指定入口地址 PROVIDE 符号 导出段起始/结束地址 AT 指令 指定加载地址 (LMA) 生成 芯片内存布局 Flash 0x08000000 - 0x0807FFFF (512KB) Bootloader 32KB 参数区 4KB 应用代码区 448KB OTA 备份区 0x08080000 - 0x0808FFFF (64KB) RAM 0x20000000 - 0x2000FFFF (64KB) .data 段(运行时) 初始值从 Flash 拷贝 .bss 段 启动时清零 堆 + 栈 + 运行时临时数据 关键:向量表必须放在 Flash 起始位置 0x08000000

实际项目中的避坑指南

链接脚本看起来简单,但实际项目中坑不少。我总结了几条:

  • 向量表对齐:Cortex-M 要求向量表 256 字节对齐。如果没对齐,中断可能跳到错误的地方。
  • LMA 和 VMA 要分清:.data 段的加载地址(LMA)在 Flash,运行地址(VMA)在 RAM。启动代码必须做拷贝。
  • BSS 段清零:未初始化的全局变量默认是 0,但硬件不会自动清零。启动代码要手动清 .bss 段。
  • 栈大小别设太小:我见过有人栈设了 256 字节,函数调用深一点就栈溢出。Bootloader 里至少留 1KB。
  • OTA 升级时的地址偏移:应用代码的链接脚本里,Flash 起始地址要偏移 Bootloader 占用的空间。否则两个固件会打架。

我的调试技巧:编译完成后,用 objdump -h 查看各段的 VMA 和 LMA,用 objdump -t 查看符号地址。如果发现某个全局变量的地址不在 RAM 范围内,那肯定是链接脚本写错了。

总结一下

链接脚本是 Bootloader 开发的「宪法」。它定义了代码和数据在芯片里的「居住权」。搞懂了它,你就能精确控制每一字节的去向。搞不懂它,你的固件就像没有地图的探险队——迟早会迷路。

嗯,这一章的内容就到这里。记住:写链接脚本的时候,多花十分钟检查,能省下后面十小时的调试时间。