一、为什么要做 A/B 分区
在 Bootloader 继续往下设计时,绕不开一个关键能力:分区启动。
所谓分区,就是在 Flash 中划分两个 APP 执行区,例如 A 区和 B 区。这样做的好处是:如果某一个分区中的固件无法正常执行,Bootloader 还可以回退并跳转到另一个分区执行。
它需要解决几个核心问题:
Flash 空间如何划分:Bootloader、A 区、B 区、模拟 EEPROM 分别放在哪里。
如何判断当前要升级哪个分区。
重新上电后如何知道应该从哪个分区启动。
固件启动失败后如何回退到另一个可用分区。
升级、校验、回退等流程如何用状态机统一管理。
整体结构可以先看下面这张图:
二、Flash 分区设计
本项目的 Flash 分区如下:
如果仍然使用单分区升级方式,例如只运行 A 区固件,那么应用工程的链接 地址需要修改为:
0x08018000
如果固件要烧录到 B 区,则链接地址需要修改为:
0x0802B800
也就是说,APP 工程的链接地址必须和目标运行分区的起始地址保持一致,否则 Bootloader 跳转后程序无法按照预期运行。
三、为什么需要 Flash 模拟 EEPROM
A/B 分区启动需要保存一些掉电不丢失的数据,例如:
真正的 EEPROM 支持按字节修改,掉电后数据不丢失。而 STM32 内部 Flash 虽然也可以掉电保存,但它有几个限制:
擦除最小单位通常是 Page,例如 2K。
写入只能从 1 变成 0,不能直接从 0 变成 1。
如果要重新写某个已经写过的位置,必须先擦除整页。
为了修改一个变量就擦除整页,会严重影响 Flash 寿命。
所以这里采用 Flash 模拟 EEPROM 的方式:不在原地址反复覆盖,而是把每次写入都追加到 Flash 后面。读取时从后往前找,找到的第一条就是该变量的最新值。
四、EEPROM 条目格式设计
每条 EEPROM 数据以 8 字节为一个条目:
也可以用表格表示:
虚拟地址可以理解成变量 ID。比如:
#define EE_ADDR_STATE 0x0001
#define EE_ADDR_ACTIVE_APP 0x0002
#define EE_ADDR_TARGET_APP 0x0003
#define EE_ADDR_ROLLBACK_REASON 0x0004
Flash 中不会直接保存变量名,而是保存虚拟地址和变量值:
[虚拟地址][保留字段][变量数据]
五、EEPROM 读写逻辑
1. 写操作
写操作采用追加写入:
从页起始地址开始扫描。
每次按 8 字节解析一个条目。
找到第一个虚拟地址为 0xFFFF 的条目。
认为该条目为空闲位置。
将新的变量值写入这个位置。
地址计算方式如下:
write_addr = page_start_addr + entry_index * 8;
由于每个条目刚好是 8 字节,可以直接调用 Flash 的双字写入接口,例如:
HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, write_addr, data);
2. 读操作
读操作从页末尾往前查找:
因为写操作是不断往后追加的。
同一个虚拟地址可能出现多次。
越靠后的条目越新。
从后往前找到的第一条,就是该变量的最新值。
这样就实现了类 似 EEPROM 的按变量读写能力。
六、写满之后怎么办:引入双页 GC
单页追加写入会遇到一个问题:页写满后怎么办?
一种简单做法是:
把所有变量读到 RAM。
擦除当前 Flash 页。
再把变量重新写回 Flash。
但这种方式有明显风险:如果擦除之后、重新写回之前发生掉电,EEPROM 数据会直接丢失。
因此更稳妥的方案是引入第二页,使用类似 GC 的页转移机制:
页转移的核心目标是:
从旧活跃页中提取每个虚拟地址的最新值,写入新的接收页,然后擦除旧页。
七、如何搬运最新数据
当活跃页写满后,需要把所有变量的最新值搬运到接收页。
处理方式如下:
从旧活跃页的末尾开始往前扫描。
每取出一个条目,就检查接收页中是否已经存在该虚拟地址。
如果接收页已经有该虚拟地址,说明它已经被搬运过,跳过。
如果接收页没有该虚拟地址,说明这是该变量的最新值,将其复制到接收页。
这个过程保证每个虚拟地址只搬运一次,并且搬运的一定是最新值。
八、页状态设计
为了让系统上电后知道哪一页是有效数据,需要给每个页设置状态字。
状态字使用双字写入,对应 Flash 写操作中的:
FLASH_TYPEPROGRAM_DOUBLEWORD
设计了三种页状态:
两个页组合起来可以形成多种状态,但这里只使用以下几种:
注意:组合状态需要区分页顺序,例如 (接收态, 活跃态) 和 (活跃态, 接收态) 对应的接收页不同。
九、页转移的掉电安全设计
页转移是 Flash 模拟 EEPROM 中最需要关注掉电安全的地方。
正常页转移流程如下:
关键点有两个:
进入页转移时,不修改旧活跃页状态。
这样即使掉电,旧活跃页仍然可识别,重新上电后可以重新执行页转移。
先擦除旧活跃页,再把接收页设置为活跃态。
如果擦除旧页之后、设置新活跃页之前掉电,上电后组合状态会变成 (擦除态, 接收态),程序可以明确判断这是页转移最后阶段掉电,直接把接收页补成活跃态即可。
掉电恢复逻辑可以总结如下:
这样 Flash 模拟 EEPROM 的整体思路就清晰了:通过双页、状态字和页转移机制,实现掉电不丢数据的变量存储。
十、Bootloader 状态机设计
EEPROM 解决了掉电数据保存问题,接下来就需要设计 Bootloader 的运行状态机。
这里采用 表驱动有限状态机。它包含 4 个核心元素:
另外还有一个状态调度器,用来解释状态转移表。
本项目设计了 4 个状态:
整体状态关系如下:
十一、表驱动状态机的实现思路
状态转移表本质上可以设计成一个结构体数组:
typedef enum
{
BL_STATE_BOOT = 0,
BL_STATE_UPGRADE,
BL_STATE_VERIFY,
BL_STATE_ROLLBACK,
} bl_state_t;
typedef bool (*guard_func_t)(void *ctx);
typedef bl_state_t (*handle_func_t)(void *ctx);
typedef struct
{
bl_state_t state;
guard_func_t guard;
handle_func_t handle;
} bl_state_node_t;
示意表如下:
static const bl_state_node_t g_state_table[] =
{
{ BL_STATE_BOOT, boot_guard, boot_handle },
{ BL_STATE_UPGRADE, upgrade_guard, upgrade_handle },
{ BL_STATE_VERIFY, verify_guard, verify_handle },
{ BL_STATE_ROLLBACK, rollback_guard, rollback_handle },
};
调度器放在 while 循环中不断执行:
while (1)
{
const bl_state_node_t *node = find_state_node(current_state);
if (node == NULL)
{
current_state = BL_STATE_BOOT;
continue;
}
if (node->guard != NULL && node->guard(&ctx) == false)
{
continue;
}
next_state = node->handle(&ctx);
if (is_valid_state(next_state))
{
eeprom_write_state(next_state);
current_state = next_state;
}
}
调度器本身不携带业务状态,只负责:
找到当前状态对应的表项。
调用 Guard 判断是否允许进入。
调用 Handle 执行业务逻辑。
根据 Handle 返回值切换到下一个状态。
将新状态写入 EEPROM,防止掉电丢失。
十二、状态机上下文初始化
状态机启动时,需要从 EEPROM 中读取几个关键变量:
初始化逻辑如下:
这里有一个很重要的细节:
只有当前状态是 VERIFY 时,才直接使用 EEPROM 中保存的目标升级区;其他状态下,可以根据当前活跃区推导出另一个分区作为目标升级区。
原因是 VERIFY 状态中可能已经把当前活跃区更新为目标升级区。如果此时发生掉电,上电后仍处于 VERIFY,继续读取 EEPROM 中的目标升级区,重新校验同一个分区即可。
如果在 VERIFY 中通过校验后,又立即把目标升级区修改为另一个分区,那么一旦中途掉电,上电后状态仍然是 VERIFY,但校验目标可能已经变了,这会导致逻辑错误。
因此,目标升级区的更新应该放在真正触发升级的流程中,而不是在校验通过后提前修改。
十三、BOOT 状态处理
BOOT 状态负责启动当前活跃分区。
处理流程如下:
总结一下:
当前活跃区有效:等待 3 秒升级指令,超时后跳转 APP。
当前活跃区无效,另一个分区有效:进入启动回退。
两个分区都无效:进入回退流程,最终停留在升级状态等待固件。
十四、UPGRADE 状态处理
UPGRADE 状态负责接收新固件。
流程如下:
根据上下文中的目标分区,计算升级写入地址。
擦除目标分区。
通过升级协议接收固件,例如 Xmodem。
接收完成后进入 VERIFY 状态。
如果没有收到固件,或者接收失败,则继续停留在 UPGRADE 状态。
十五、VERIFY 状态处理
VERIFY 状态负责校验目标分区固件是否合法。
如果校验通过:
将当前活跃区更新为目标升级区。
将状态机状态设置为 BOOT。
跳转或重新启动,让 Bootloader 从新的活跃区启动。
如果校验失败:
设置回退原因为升级回退。
进入 ROLLBACK 状态。
十六、ROLLBACK 状态处理
ROLLBACK 状态根据 EEPROM 中保存的回退原因进行分支处理。
这里有一个需要注意的点:
如果启动回退时,已经把新的活跃分区写入 EEPROM,但还没来得及切换状态机状态就掉电,那么重新上电后状态仍然是 ROLLBACK,可能再次执行一次分区切换,导致又切回原来的无效分区。
可以在切换前增加一次判断:
先校验当前活跃分区是否有效。
如果当前活跃分区已经有效,说明上一次已经切换成功,直接回到 BOOT。
如果当前活跃分区仍无效,再执行分区切换。
这样可以避免重复切换。
十七、整体运行框架
到这里,一个 Bootloader 的基本框架就搭起来了:
整个流程可以概括为:
Bootloader 上电后先恢复 EEPROM 状态。
状态机读取当前状态、活跃分区和目标分区。
BOOT 状态优先启动当前活跃分区。
如果收到升级指令,进入 UPGRADE。
新固件写入目标分区后进入 VERIFY。
校验通过后切换活跃分区。
校验失败或启动失败时进入 ROLLBACK。
两个分区都无效时,停留在升级状态等待上位机下发固件。
十八、本文小结
本文主要梳理了 A/B 分区 Bootloader 的核心框架:
使用 Flash 分区实现 Bootloader、APP A、APP B 和模拟 EEPROM 的空间隔离。
使用 Flash 模拟 EEPROM 保存 Bootloader 运行状态。
使用双页 GC 和页状态字保证 EEPROM 页转移过程的掉电安全。
使用表驱动有限状态机统一管理启动、升级、校验和回退流程。
通过 A/B 分区和回退机制提高升级可靠性。
后续还需要继续补充一个关键点:如何判断固件包是否合法。这部分通常会涉及固件头设计、长度校验、CRC 校验、栈顶地址校验、复位向量校验,以及最终跳转前的中断和向量表处理。
————————————————
版权声明:本文为CSDN博主「日希642」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/2402_84483699/article/details/162269591
|
-
|