本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

Bootloader 跳转与 OTA:
分区规划、向量表偏移与容易踩的坑

OTA 这件事听起来很轻——把固件传进去、写进 Flash、跳过去,不就完了吗。 真正做完一遍才发现,难的部分全在“跳过去之后”:SysTick 不走了、延时函数不返回了、 上一个程序关掉的中断被带了过来。这篇笔记记录我在 STM32G030C8T6 上做这套东西时, 几个反复卡住我的地方,以及我最后固定下来的检查顺序。

Bootloader OTA STM32G030 Cortex-M0+ Flash 参数 已发布

一、先画分区表:地址一旦定下来就不要再改

我做这套东西的顺序一开始是反的:先把 APP 写出来,能跑起来了,才回头想“Bootloader 放在哪里”。 这个顺序的代价很快就来了——所有和地址有关的东西都得跟着改一遍: 链接脚本、中断向量表偏移、上位机下发的目标地址,还有文档里已经写下来的那些记录。 改完之后还要重新验证,因为漏掉任何一处,现象都是同一句话:升级完设备不启动。

所以现在我给自己定了一条规矩:动手写第一行代码之前,先把分区表画出来。 下面这张表是我在这颗芯片(STM32G030C8T6,Cortex-M0+,64 KB Flash、8 KB RAM)上最后定下来的划分:

区域地址范围大小说明
Bootloader0x08000000 ~ 0x08003FFF16 KB上电先跑这一段,负责升级与跳转,自己一般不再改动
应用程序区(APP)0x08004000 ~ 0x0800F7FF约 46 KB真正跑业务的固件,每次升级替换的就是这一段
参数段0x0800F800 起约 2 KB系统参数,采用双区 Flash 存储

表里的数字我自己按地址算过一遍,也顺便记在这里,免得以后连自己都记不清: Bootloader 从 0x08000000 到 0x08003FFF,是 0x4000 即 16384 字节 = 16 KB; APP 从 0x08004000 到 0x0800F7FF,是 0xB800 即 47104 字节,约 46 KB; 参数段从 0x0800F800 到 Flash 末尾 0x08010000,正好 0x800 即 2048 字节 = 2 KB。 三段加起来 16 + 46 + 2 = 64 KB,刚好把整片 Flash 用完,中间没有留下空着的扇区。

16 KB 给 Bootloader,在这个项目里其实用不完——它要做的事情只有四件:串口收发、Flash 编程、 CRC 校验、跳转。我还是留了 16 KB,原因是Bootloader 自己不参与升级: 它一旦烧进去,后面所有的升级都建立在“它能正常工作”这个前提上。 留足余量的意思是,以后我想加一个“升级前的二次确认”或者“写完之后读回校验”, 不用重新分区、不用把已经发出去的设备再叫回来。

分区定下来之后,它同时约束至少四个地方:.ld 链接脚本(我用的是 VS Code 加 STM32 的 VS Code 扩展, 构建产物直接按 STM32G030C8Tx_FLASH.ld 链接,不是 Keil 那一套工程配置); system_stm32g0xx.c 里的中断向量表偏移;上位机升级工具里配置的下发地址; 以及测试用例和文档里写下来的地址。这四处必须同时改,改完还要能互相印证。

参数段我用的是双区 Flash 存储,它的偏移量在 system_param.c 里由 STM32_FLASH_PARAM_OFFSET 定义。“参数在哪”这件事在代码里只有一个来源, 这带来一个好处和一个坑:好处是改地址只改一处;坑是这一处一旦和分区表不一致, 编译器不会报任何错,运行起来才发现读到的是空白区,然后被 CRC 校验挡下来。

这套分区最后怎么落到协议和流程上,我写在个人实验页 《RS485 总线上的广播式 Bootloader 与 OTA》里。 如果你更关心“地址表怎么排”这一类规划问题,可以先看我另一篇 《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》—— 分区表和寄存器表在我看来是同一类工作:先把地址定了,再写代码。

二、为什么现成的 Xmodem / Ymodem 不够用

我最初的想法是直接用现成的 Xmodem 或 Ymodem。理由很充分:这两个协议到处都有现成实现, 逻辑成熟,遇到问题也容易查到资料。看完需求之后我放弃了,原因有两个。

第一,它们解决的是点对点传输。一次会话里只有一个发送方和一个接收方, 而我的需求是“一个主站同时更新多台从站”:总线上挂着多台设备, 主站希望把同一份固件一次发给它们。Xmodem / Ymodem 的帧结构里没有地方放“这一包是给谁的”, 也没有广播这个概念。如果硬要一台一台来,现场要重复 N 遍完全相同的流程,这个时间成本我不能接受。

第二,它们通常要求接收端具备一定的缓冲和流控配合。 这类协议靠“一块数据 + 一个应答”往前推进,接收端要在校验完当前块之前不丢掉下一块, 本身就隐含着缓冲需求。而我这颗芯片只有 8 KB RAM:光一个 2 KB 的数据包缓冲区就要吃掉四分之一, 更不用说“把整个固件缓冲下来再统一校验”这种做法——46 KB 的 APP 区根本放不进 8 KB 的 RAM, 连想都不用想。这也是我后来在整个方案里坚持“收到一包、校验一包、写一包”的原因。

结论很直接:必须自己设计一个主从式、带站号、可分包的协议。 我不觉得这是在重复造轮子——现成的轮子是为点对点链路设计的, 形状和“一条总线上挂多台设备”这个场景不匹配。硬套上去的结果是协议层要打一堆补丁, 最后比自己写还难维护,而且每一层补丁都是一个将来会出问题的地方。

还有一个约束是硬件给的:RS485 半双工,收发共用一对差分线,收发方向的切换在硬件上就已经固化了。 这意味着总线上任何时刻只能有一个设备在说话,所以这套协议只能做主从—— 从站不能主动开口,只能应答。这一条不是风格选择,是电气特性决定的。

三、跳转这件事:三行代码背后的一堆前提

从 Bootloader 跳到 APP,核心真的只有三行:取 APP 的栈顶、取 APP 的复位向量、跳过去。 但要让这三行真的能用,前面要满足一堆前提。

/* 最小跳转片段:仅用来说明顺序,省略了错误处理 */
typedef void (*app_entry_t)(void);

void jump_to_app(uint32_t app_addr)
{
    uint32_t stack_top = *(volatile uint32_t *)(app_addr);       /* 向量表第 0 项:栈顶 */
    uint32_t reset_vec = *(volatile uint32_t *)(app_addr + 4);   /* 向量表第 1 项:复位向量 */

    __disable_irq();                  /* 1. 先关中断,别让残留中断在切换过程中进来 */
    HAL_DeInit();                     /* 2. 把 Bootloader 用过的外设反初始化干净 */
    SCB->VTOR = app_addr;             /* 3. 向量表指向 APP */
    __set_MSP(stack_top);             /* 4. 重设主栈指针 */
    ((app_entry_t)reset_vec)();       /* 5. 跳过去,正常情况下不再返回 */
}

代码里的注释标了顺序,这个顺序是我踩过坑之后固定下来的: 关中断 → 反初始化外设 → 设置向量表偏移 → 重设 MSP → 取复位向量跳转。 我遇到过“跳转前死机”这个现象,复盘的时候把常见成因归成三类: 一是跳转前没有关中断,残留的中断请求在向量表已经被改掉的情况下取不到正确的入口; 二是没有清理外设,Bootloader 里开着的定时器、DMA、串口带着自己的状态进了 APP; 三是栈指针的设置顺序不对,比如先跳转再设栈,或者干脆没设。 这三类的现象不一样,但都能表现成“跳过去就没动静了”,所以我干脆把顺序写死在注释里, 每次改动这块代码,就照着逐项核对一遍。

有一点要特别说明:这里设置 SCB->VTOR,和后面在 APP 的 SystemInit() 里设置向量表偏移,看起来是重复动作,但两处都不能省。 Bootloader 里设置,是为了“跳过去的那一瞬间”向量表就是对的; APP 的 SystemInit() 里设置,是为了 APP 被单独烧录、直接上电运行时也是对的。 少了后面那一处,单独烧录 APP 调试的时候就会再踩一次同样的坑。

还有一件事必须说清楚:跳转不是复位。 复位会把中断使能、外设寄存器、栈指针都恢复到一个已知的初始状态,跳转不会。 下面两节,就是我在这一点上踩的两个坑,也是我认为这套东西里最值得写下来的部分。

五个方框串联:关闭中断 → 反初始化外设 → 设置栈指针 → 取复位向量 → 跳转,每个方框旁标注“漏掉这一步会怎样”
图 1 · 跳转流程框图

四、中断向量表偏移:SysTick 为什么不动了

我第一次跳转成功的时候非常高兴:LED 在闪、串口能打印,看起来一切正常。 但很快就发现不对——所有依赖 SysTick 的东西全部失灵: HAL_Delay() 调用之后不返回,HAL_GetTick() 永远返回同一个值, 任何带超时判断的 HAL 调用都会卡在那里。LED 之所以还在闪,是因为那一段是我用空循环写的, 它恰好不依赖系统时基,所以把问题盖住了一阵子。

原因我后来是这么理解的:STM32 的系统初始化阶段,SystemInit() (一般在 main 之前运行)需要导入中断向量表。 我改好了 .ld 文件,让代码偏移到了 APP 区, 但 SystemInit() 里的中断向量表偏移地址仍然是 0—— 也就是说,APP 的向量表实际上还是指向 Bootloader 的向量表。 SysTick 中断来了之后,内核按 VTOR 找到的入口是 Bootloader 的那份处理函数, 而那段代码早就在跳转时被绕过去了,于是时间基准再也不自增。

解决办法是:手动在 SystemInit() 里给中断向量表加偏移量, 偏移量的大小取决于 APP 在 Flash 中的偏移量,两者保持一致即可。 具体做法是在 system_stm32g0xx.c 中启用 USER_VECT_TAB_ADDRESS, 并把 VECT_TAB_OFFSET 设为 0x00004000U (与 APP 起始地址 0x08004000 对应):

/* system_stm32g0xx.c 中手动添加用户中断向量表地址 */
#define USER_VECT_TAB_ADDRESS

#if defined(USER_VECT_TAB_ADDRESS)
#if defined(VECT_TAB_SRAM)
#define VECT_TAB_BASE_ADDRESS   SRAM_BASE
#define VECT_TAB_OFFSET         APP_OFFSET    /* 手动改成与 APP 在 FLASH 中的偏移一致 */
#else
#define VECT_TAB_BASE_ADDRESS   FLASH_BASE
#define VECT_TAB_OFFSET         APP_OFFSET    /* 同上:必须与 APP 的偏移一致 */
#endif
#endif

这个 0x00004000U 不是随便写的,它必须和分区表里 APP 的起始地址 0x08004000 对得上。也就是说,分区表里改一个数字, 这里就要跟着改一个数字,而且编译器不会替你检查这两个数字是否一致。 这也是我在第一节里强调“地址一旦定下来就不要再改”的原因: 它牵连的地方比想象中多,而且大多是“改错了不会报错”的那种地方。

上下两组对比,错误组:代码段已偏移到应用区,但向量表仍指向引导区,中断箭头指向错误位置,正确组:向量表偏移与应用区起始一
图 2 · 向量表偏移的两种情形对比图

五、中断开关的“跨程序遗留问题”

Bootloader 跳转到 APP 之前,一般需要关闭所有中断,也就是调用 __disable_irq(), 否则残留的中断请求可能在向量表切换的空档里被响应。 但这里有一个很容易忽略的点:设备完成跳转之后并没有断电, 中断关闭这个状态不会被复位,会一直带到 APP 里面去。

也就是说,如果 APP 里什么都不做,它是在“全局中断关闭”的状态下开始运行的。 这时候外设配置全对、代码逻辑全对,但串口收不到数据、定时器不进中断、按键没有反应—— 又是一个“哪里都不对”。而且它和上一节的 SysTick 问题现象很像, 如果不清楚这条规则,很容易把两个问题混在一起查。

所以必须在 APP 的 SystemInit() 中最先调用 __enable_irq()。 顺序很重要:要放在最前面,因为后面任何一步(时钟配置、外设初始化, 甚至某个 HAL 函数内部的等待)都可能依赖中断。

__disable_irq();   /* 在 bootloader 跳转到 APP 之前调用 */
__enable_irq();    /* 在 APP 的 SystemInit 中最先调用 */

中断使能位属于内核状态,只有复位才会让它回到默认值,而跳转并不是复位—— 这句话我在两个不同的地方各写过一次,因为它实在是太容易被忘掉了。 后来我把它变成一个检查习惯:只要代码里出现 __disable_irq(), 就必须能说出它在什么地方被打开。说不出来,就是漏了。

六、Flash 寿命:一个必须提前算的账

STM32G030C8T6 的数据手册给出的 Flash 擦除次数最低为 1000 次 (也就是保证能正常使用的次数)。这个数字对“保存参数”来说够用,对“记录日志”来说完全不够。

我一开始的想法很朴素:把错误事件和状态信息记录到 Flash 里,出问题的时候读出来看。 后来一算账就停住了——假设一台设备每天因为某个偶发事件写 100 次,每次都要擦一页, 1000 次擦除也就是十天左右,这颗 Flash 的保证寿命就到期了。 而参数存储完全不是这个量级:参数只在人工修改的时候才会写一次。 同一个“写 Flash”的动作,在两种用途下的结论完全相反,区别只在频度。

所以“把错误事件和状态信息记录到 Flash”这个需求,必须先评估数据量和数据更新频度 再决定要不要做、怎么做,否则用不了多久就会把这一页写坏。 如果真的需要频繁记录,我了解到三条路:

思路做法代价
外挂 EEPROM / FRAM 把频繁变动的数据放到片外器件里,片内 Flash 只留参数 多一个器件、多一套读写与地址管理代码,可擦写次数远高于片内 Flash
只在掉电瞬间写一次 运行期数据留在 RAM 和备份寄存器,检测到掉电再抢时间写一次 Flash 需要掉电检测电路和一点储能,可用的时间窗非常短
写均衡 同一份数据轮流写到不同的页或槽位,把擦除次数摊开 代码复杂度上升,还要额外解决“哪一份才是最新的”这个问题

还有一个和擦除有关的基本事实值得一起记:Flash 擦除的最小单位是页, 不是字节;写入只能把 1 变成 0,所以擦除后的 0xFF 才是“空白”的语义。 这两条决定了写参数时那些看起来奇怪的做法——按页擦除、写之前先补齐对齐位、 不足的地方补 0xFF——其实都是被硬件特性逼出来的,不是谁的习惯问题。

七、Bootloader 的两种回跳方案与侵入性取舍

需求里存在“设备需要从 APP 模式跳转回 Bootloader 模式”的场景,比如现场要重新升级一次。 这里有一个绕不过去的事实:Bootloader 和 APP 是两套完全独立、互不影响的程序, 所以这个功能必然对 APP 有侵入性——能选的只是把侵入放在哪里。

两种方案的前半段是一样的:Bootloader 启动前等待串口信息,超时或者收到跳转命令就跳 APP。 区别在“从 APP 回到 Bootloader”这一步怎么做:

方案回 Bootloader 的方式优点缺点
方案一 APP 内实现“设备重启”功能,靠重启回到 Bootloader APP 侧改动最小,只是重启而已 回 Bootloader 必须重启,升级期间业务会中断
方案二 APP 增加“不重启直接跳转到 Bootloader”的程序 切换快,不用等重启 APP 里要复制一份跳转逻辑(关中断、设置栈指针、取复位向量),侵入性更大;还要把 APP 自己的外设全部反初始化干净

我最后选的是方案一。理由不是省事,而是“跳转”这段逻辑本身的前提太多(见第三节): 把它复制到 APP 里,意味着这些前提要在另一套完全不同的外设状态、另一套中断配置下重新成立一遍。 每一次复制,都是一个将来会忘记同步的地方。而重启的代价,只是升级期间业务中断一次, 对“现场重新升级”这个场景来说是可以接受的。

反过来说,如果业务对中断时间敏感——比如设备正在执行一个不能被打断的动作—— 那么方案二那种“不重启直接切过去”的快速切换,才值得它带来的复杂度。 这个取舍我建议在写第一行代码之前就想清楚,因为方案二要求 APP 从设计之初就考虑“自己可以被安全地停掉”, 等做完了再回头补,代价会大得多。

八、几条我反复用到的经验

  1. 先画分区表,再写第一行代码。地址是所有东西的共同来源,它牵连的每一处都是“改错了不报错”的地方。
  2. 跳转不是复位。中断使能、外设状态、栈指针都不会自动回到初始值。凡是“复位会做、跳转不会做”的事,都要自己补上。
  3. 向量表偏移要改两处:Bootloader 跳转前一处,APP 的 SystemInit() 里一处,而且两处都要和 .ld 里的地址对得上。
  4. 跳转之后行为异常,先看两个值:SCB->VTOR 对不对、SysTick 计数值有没有在涨。这比盲猜时钟配置快得多。
  5. 把 __disable_irq() 和 __enable_irq() 当成一对来检查。出现前者,就必须能指出后者在哪里。
  6. Flash 的账要提前算。1000 次擦除是数据手册给出的保证下限,它不是一个可以拿来写日志的数字。
  7. 回跳方案先想清楚侵入性放在哪一边。放在 APP 里还是放在重启里,决定了 APP 从第一行代码开始要遵守什么约束。

这套东西落到具体协议、报文和测试用例上的样子,我整理在个人实验页 《RS485 总线上的广播式 Bootloader 与 OTA》里; 里面“频繁拔插导致通信卡死”那一节的排查思路,和这篇里的几个坑其实是同一类问题: 状态没有回到已知值。

参考资料与说明

  • STM32G030 系列参考手册与数据手册中关于 Flash 擦写寿命、系统初始化与中断向量表的章节,属公开文档。
  • ARM Cortex-M0+ 技术参考手册(Technical Reference Manual)中关于向量表、向量表偏移寄存器与中断屏蔽寄存器的说明,属公开文档。
  • Modbus 应用协议规范(Modbus Application Protocol Specification),用于对照主从式协议的通用做法,属公开标准。
  • 文中芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
  • 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码。