4. 链接脚本深度解析:分散加载文件(.sct)结构、ROM/RAM分区定义、中断向量表重定位

做 Bootloader 开发,绕不开链接脚本。说白了,它就是告诉编译器:你的代码该放哪儿,数据该放哪儿,中断向量表又该在哪儿安家。很多新手觉得这玩意儿枯燥,直接拿厂家例程改改就用了。嗯,我早年也这么干过,结果有一次 Bootloader 跳转后死活跑不起来,查了三天,最后发现是向量表没对齐——从那以后,我再也不敢轻视 .sct 文件了。

4.1 分散加载文件的结构

ARM 编译器用的 .sct 文件,其实就是一个分区地图。它把芯片的存储空间切成若干块,然后告诉链接器:哪个段(Section)该放进哪个块。

一个典型的 .sct 文件长这样:

LR_IROM1 0x08000000 0x00010000  {    ; 加载区,起始0x08000000,大小64KB
  ER_IROM1 0x08000000 0x00010000  { ; 执行区,与加载区重叠
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
  }
  RW_IRAM1 0x20000000 0x00008000  { ; RAM区,起始0x20000000,大小32KB
   .ANY (+RW +ZI)
  }
}

结构其实不复杂,就三层:

  • 加载区(Load Region):程序烧录时在 Flash 里的位置。名字可以随便起,比如 LR_IROM1。
  • 执行区(Execution Region):程序运行时在内存里的位置。可以跟加载区重叠(比如代码直接在 Flash 里跑),也可以不同(比如 RW 数据要搬到 RAM)。
  • 输入段(Input Section):具体哪些 .o 文件、哪些段名放进来。比如 *.o (RESET, +First) 就是把所有目标文件的 RESET 段放在最前面。

关键点:加载区的大小必须跟芯片 Flash 容量匹配。我见过有人把 LR_IROM1 的大小写成了 0x20000(128KB),结果芯片只有 64KB Flash,链接器也不报错,烧录时直接写飞了。

4.2 ROM/RAM 分区定义

Bootloader 和应用程序通常共用一颗芯片。怎么分家?靠的就是 ROM/RAM 分区。

我个人习惯把 Flash 分成三块:

分区 起始地址 大小 用途
Bootloader 区 0x08000000 32KB 存放 Bootloader 代码、固定参数
参数区 0x08008000 4KB 升级标志、版本号、校验值
应用程序区 0x08009000 剩余空间 用户应用程序

对应的 .sct 文件就要写两个加载区:

; Bootloader 区
LR_BOOT 0x08000000 0x00008000 {
  ER_BOOT 0x08000000 0x00008000 {
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
  }
  RW_BOOT_RAM 0x20000000 0x00004000 {
   .ANY (+RW +ZI)
  }
}

; 参数区
LR_PARA 0x08008000 0x00001000 {
  ER_PARA 0x08008000 0x00001000 {
   *(.param_section)
  }
}

; 应用程序区(由应用程序自己的 .sct 定义)

经验之谈:参数区我建议单独划出来,不要跟代码混在一起。这样升级应用程序时,Bootloader 只需要擦除应用程序区,参数区里的版本号和校验值还能保留。我曾经踩过坑——参数跟代码放一起,一次擦除全没了,升级到一半断电,板子直接变砖。

RAM 分区也要注意。Bootloader 和应用程序如果共用 RAM,跳转前必须把 Bootloader 用过的全局变量、堆栈都清理干净。否则应用程序一启动,发现 RAM 里全是垃圾数据,跑着跑着就 HardFault 了。

我常用的做法是:Bootloader 只用低 16KB RAM,应用程序从 0x20004000 开始用。这样跳转前只需要把 0x20000000~0x20003FFF 清零就行,简单粗暴。

4.3 中断向量表重定位

这是 Bootloader 跳转的灵魂操作。芯片上电后,CPU 从 0x00000000 取栈顶指针,从 0x00000004 取复位向量。但 CMS32 这类 Cortex-M 内核的芯片,实际上会把 0x08000000 映射到 0x00000000(通过硬件配置)。

问题来了:Bootloader 在 0x08000000,应用程序在 0x08009000。应用程序的中断向量表在 0x08009000,但 CPU 默认还是去 0x08000000 找向量表。怎么办?

答案就是修改 VTOR(向量表偏移寄存器)。

跳转前,Bootloader 必须做这件事:

// 假设应用程序起始地址为 APP_ADDR (0x08009000)
uint32_t app_stack = *(volatile uint32_t*)APP_ADDR;
uint32_t app_reset  = *(volatile uint32_t*)(APP_ADDR + 4);

// 1. 检查栈顶指针是否合法(通常在 RAM 范围内)
if (app_stack < 0x20000000 || app_stack >= 0x20010000) {
    // 非法,不跳转
    return;
}

// 2. 关闭全局中断
__disable_irq();

// 3. 重定位向量表
SCB->VTOR = APP_ADDR;

// 4. 设置主栈指针
__set_MSP(app_stack);

// 5. 跳转到应用程序复位向量
((void (*)(void))app_reset)();

注意:跳转前一定要关闭所有外设中断!我遇到过最诡异的问题:跳转后应用程序刚初始化串口,就触发了 Bootloader 时期未清除的 DMA 中断,直接卡死在中断服务函数里。所以我的习惯是跳转前把所有外设时钟关了,中断标志清了,再关全局中断。

另外,应用程序的 .sct 文件也要配合修改。它的加载区起始地址必须跟 Bootloader 约好的 APP_ADDR 一致:

LR_APP 0x08009000 0x00070000 {
  ER_APP 0x08009000 0x00070000 {
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
  }
  RW_APP_RAM 0x20004000 0x0000C000 {
   .ANY (+RW +ZI)
  }
}

你看,应用程序的 RESET 段在 0x08009000,所以它的向量表也在那里。Bootloader 把 VTOR 改成 0x08009000 后,CPU 就能找到应用程序的中断入口了。

4.4 整体流程梳理

为了让你更直观地理解,我画了一张流程图:

Bootloader 链接脚本与跳转流程 Flash 分区 Bootloader 区 0x08000000 ~ 0x08007FFF 参数区 0x08008000 ~ 0x08008FFF 应用程序区 0x08009000 ~ 结束 跳转流程 1. 关闭全局中断 __disable_irq() 2. 重定位 VTOR SCB->VTOR = APP_ADDR 3. 设置 MSP __set_MSP(app_stack) 4. 跳转复位向量 ((void(*)())app_reset)() 关键检查点 ✅ 栈顶指针合法性 ✅ 外设中断全部关闭 ✅ DMA 传输已停止 ✅ RAM 数据已清理 ✅ 应用程序 .sct 对齐 ✅ 向量表 256 字节对齐

这张图把 Flash 分区、跳转步骤和检查点串在了一起。你写代码时,对着这张图一步步来,基本不会漏。

4.5 避坑指南

最后,我把自己这些年踩过的坑列出来,你写 .sct 和跳转代码时对照着检查:

  • 向量表对齐:Cortex-M 要求 VTOR 的值必须是 256 的倍数(低 9 位为 0)。应用程序的起始地址如果没对齐,跳转后所有中断都进不去。
  • 栈顶指针检查:跳转前一定要读应用程序的栈顶指针,确认它在 RAM 范围内。如果 Flash 是空的或者数据被破坏了,读出来可能是 0xFFFFFFFF,直接设置 MSP 会立刻 HardFault。
  • 外设状态清理:我曾经在跳转前忘了关定时器,结果应用程序初始化时定时器溢出触发了中断,而应用程序的中断向量表还没配好,直接跑飞。
  • 链接脚本命名冲突:Bootloader 和应用程序的 .sct 文件里,加载区名字不要一样。比如 Bootloader 用 LR_BOOT,应用程序用 LR_APP,否则链接器可能搞混。

我的习惯:每次改完 .sct 文件,我都会用 fromelf --text -a -c output.axf 反汇编看看,确认 RESET 段确实在预期的地址上。花 5 分钟看一眼,能省下半天调试时间。