4. 链接脚本与内存布局:Bootloader与App的分区艺术

说实话,很多做嵌入式开发的朋友,写了几年C代码,却从来没碰过链接脚本。这其实挺可惜的。链接脚本就像是给编译器的一张“地图”,告诉它你的代码该放哪儿,数据该搁哪儿。尤其在Bootloader和App共存的场景下,这张地图画错了,程序跑起来就是灾难。

我个人习惯,拿到一个新MCU平台,第一件事不是写代码,而是先把链接脚本和内存布局图画清楚。磨刀不误砍柴工嘛。

4.1 链接脚本(.ld文件)到底在干什么?

简单说,链接脚本就是告诉链接器:“你的输入段(section)要放到输出文件的哪个位置”。你写的每个函数、每个全局变量,最终都会被归类到不同的段里。链接脚本决定了这些段在Flash和RAM里的具体地址。

一个典型的.ld文件,核心结构就这几块:

/* 入口点 */
ENTRY(Reset_Handler)

/* 内存区域定义 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

/* 段布局 */
SECTIONS
{
    .text :
    {
        *(.isr_vector)    /* 中断向量表 */
        *(.text)          /* 代码 */
        *(.rodata)        /* 只读数据 */
        _etext = .;       /* 代码段结束地址 */
    } > FLASH

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

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

这里有个关键点:.data段用了> RAM AT> FLASH。什么意思?就是运行时它在RAM里,但初始值存在Flash里。启动代码需要把这段数据从Flash拷贝到RAM。我曾经见过一个同事忘了处理这个拷贝,结果全局变量全是乱的,查了两天bug……嗯,从那以后我每次都会检查启动文件里的__main或_start流程。

4.2 代码段、数据段、BSS段——它们到底怎么分布的?

你想想看,一个嵌入式程序编译出来,最终在芯片里是怎么放的?我画了张图,帮你理清这个关系。

Flash中的布局(烧录后):

  • .isr_vector:中断向量表,必须放在起始地址
  • .text:你的代码指令
  • .rodata:字符串常量、const修饰的变量
  • .data (初始值):全局/静态变量的初始值备份

RAM中的布局(运行时):

  • .data:可读写的全局/静态变量(从Flash拷贝过来)
  • .bss:未初始化或初始化为0的全局/静态变量(启动时清零)
  • 堆(Heap):动态内存分配区域
  • 栈(Stack):函数调用和局部变量区域

为什么要把.bss单独拎出来?因为它在Flash里不占空间。启动时只需要在RAM里把对应区域清零就行。省Flash空间,这是嵌入式开发的常识。

我建议你在链接脚本里把_sdata、_edata、_sbss、_ebss这些符号都定义清楚。启动代码要靠它们来完成数据拷贝和BSS清零。少了任何一个,程序都可能跑飞。

4.3 Bootloader与App的内存分区设计——这才是重头戏

做固件升级,核心就是Bootloader和App要物理隔离。它们不能互相踩踏。怎么隔离?靠链接脚本把各自的地址空间划清楚。

我一般这样划分:

区域 起始地址 大小 内容
Bootloader Flash 0x08000000 32KB Bootloader代码、向量表
App Flash 0x08008000 448KB App代码、向量表
参数存储区 0x0807E000 8KB 升级标志、版本号等
Bootloader RAM 0x20000000 16KB Bootloader运行时数据
App RAM 0x20004000 112KB App运行时数据

注意看,Bootloader和App的Flash地址是连续的,但互不重叠。App的起始地址是0x08008000,也就是Bootloader结束的地方。RAM也是同理。

这里有个坑:中断向量表的重映射。App的向量表必须放在它自己的起始地址。在STM32上,你需要调用SCB->VTOR = APP_VECTOR_ADDR;来告诉内核:“嘿,中断向量表搬家了,别找错地方。”我刚开始做Bootloader时忘了这步,一进中断就死机,查了整整一个下午……

4.4 实战:为Bootloader和App分别编写链接脚本

Bootloader的链接脚本,内存区域要限制在它自己的地盘:

/* bootloader.ld */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 32K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 16K
}

App的链接脚本,起始地址要偏移:

/* app.ld */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08008000, LENGTH = 448K
    RAM   (rwx) : ORIGIN = 0x20004000, LENGTH = 112K
}

你可能会问:“App的RAM为什么也要偏移?”因为Bootloader和App是独立编译的,它们各自管理自己的RAM空间。如果App用了Bootloader的RAM区域,Bootloader在跳转前可能正在用那块内存存数据,一覆盖就乱套了。

我的小技巧:在App的链接脚本里,把_estack(栈顶地址)设置为App RAM区域的末尾。这样栈就不会往Bootloader的RAM里长了。安全第一。

4.5 一张图看懂整体布局

说了这么多,不如一张SVG图来得直观。下面这张图展示了Bootloader和App在Flash和RAM中的完整分区关系:

Bootloader与App内存分区结构图 Flash (512KB) Bootloader 0x08000000 - 0x08007FFF 32KB App 0x08008000 - 0x0807DFFF 448KB 参数区 8KB RAM (128KB) Bootloader RAM 0x20000000 - 0x20003FFF 16KB App RAM 0x20004000 - 0x2001BFFF 112KB ⚠️ 注意:App的向量表必须放在0x08008000,并在启动时通过SCB->VTOR重映射。 Bootloader和App的RAM区域不能重叠,否则跳转后数据会互相污染。

我曾经踩过的坑:有一次做OTA升级,Bootloader把新固件写到了Flash里,然后跳转到App。结果App死活跑不起来。查了半天,发现是Bootloader的链接脚本里RAM定义太大了,占用了App的RAM起始地址。Bootloader在跳转前用过的变量,正好落在App的.data段位置。App一启动,数据全乱了。从那以后,我强制要求Bootloader和App的RAM区域必须物理隔离,中间留个4KB的“缓冲区”作为安全间隔。

4.6 链接脚本中的符号导出——让启动代码找到关键地址

链接脚本里定义的_sdata、_edata这些符号,在C代码里怎么用?用extern声明就行:

/* 在启动文件或C代码中 */
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
extern uint32_t _estack;

void Reset_Handler(void)
{
    /* 拷贝.data段 */
    uint32_t *src = &_sidata;  /* Flash中的初始值地址 */
    uint32_t *dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }
    
    /* 清零.bss段 */
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0;
    }
    
    /* 设置栈指针 */
    __set_MSP((uint32_t)&_estack);
    
    /* 跳转到main */
    main();
}

这里有个细节:_sidata是.data段在Flash里的初始值地址。它通常由链接脚本自动生成,指向LOADADDR(.data)。如果你在链接脚本里没定义这个符号,启动代码就不知道从哪儿拷贝数据。嗯,这个坑我也踩过。

4.7 总结一下关键点

  • 链接脚本是内存布局的“宪法”——所有段的位置都由它决定
  • Bootloader和App必须物理隔离——Flash和RAM都要划清界限
  • 中断向量表重映射不能忘——App的VTOR要指向自己的起始地址
  • 符号导出要完整——启动代码需要的_sdata、_sbss、_estack一个都不能少
  • RAM分区要留安全间隔——防止Bootloader的残留数据污染App

链接脚本这东西,看着枯燥,但它是整个固件系统的地基。地基没打好,上面盖的楼再漂亮也白搭。我每次做新项目,都会花至少半天时间反复确认内存布局,确保每个段都待在它该待的地方。这个习惯,帮我省了无数调试时间。