嵌入式 IAP 在线升级-整体方案

flowchart LR
%%{init: { "flowchart": { "curve": "basis" } } }%%
    full_update(全量差分包)
    diff_update(增量差分包)
    app(大程序)
    iap(小程序)
    dnload(数据暂存区)
    backup(数据暂存区)
    runapp(程序运行区)
    decode(数据解码区)

    full_update-->app
    full_update-->iap
    diff_update-->app
    diff_update-->iap

    app-->dnload
    iap-->dnload

    dnload--跳转至小程序-->backup

    backup-.㈣<br>回滚旧程序.->runapp
    backup--㈠<br>解码升级包-->decode
    decode--㈢<br>写入新程序-->runapp
    runapp--㈡<br>备份旧程序-->backup

当前方案

  • 支持差分升级(hdiffpatchlite)
  • 支持命令交互
  • 支持 Y-Modem
  • 支持异常回滚
  • 支持快速启动
  • 空间占用情况(40KB + 1个扇区)
    • 目前程序大小为 31.5KB,建议预留 40KB 的空间。
    • 另外还需要一个扇区作为引导程序与应用程序的共享空间,唤作「升级控制区」。
    • 如果开启快速启动,升级控制区必须放在内部 FLASH 中。
    • 如果关闭快速启动,升级控制区可以放在内部 FLASH 中,也可以放在外部 FLASH 中。

快速启动:上电后首先检查应用程序的有效性,有效则立即跳转,无效则留在引导程序中。优点是启动速度快,但不支持按回车键留在引导程序。

程序分工

众所周知,人不能踩着自己的脚上天,应用程序升级亦是如此,所以需要借助 bootloader 来完成自身的替换操作。

分工条目 引导程序 应用程序
程序解码
备份回滚
程序替换
命令交互
数据存取
数据传输 基础的通信协议栈 完备的通信协议栈

应用程序(大程序)集成完备的通信协议栈,只负责接收、存储和校验升级包。

引导程序(小程序)集成基础的通信协议栈,没有应用程序或应用程序异常时也能进行升级。

存储空间

#1

内部flash存储空间划分 内部flash存储空间细分
引导程序区 中断向量表
引导程序
升级控制区 升级状态
应用程序区 重定向的中断向量表
运行中的旧程序
外部flash存储空间划分 外部flash存储空间细分
数据暂存区 接收到的升级包
备份来的旧程序
数据解码区 解码后的新程序

通用方案

#2

内部flash存储空间划分 内部flash存储空间细分
引导程序区 中断向量表
引导程序
升级控制区 升级状态
应用程序区 重定向的中断向量表
运行中的旧程序
数据暂存区 接收到的升级包
备份来的旧程序
数据解码区 解码后的新程序

低成本产品(没有外部存储芯片)的方案

#3

内部flash存储空间划分 内部flash存储空间细分
引导程序区 中断向量表
引导程序
升级控制区 升级状态
应用程序区
(A)
重定向的中断向量表
上一版的旧程序
应用程序区
(B)
重定向的中断向量表
运行中的旧程序
外部flash存储空间划分 外部flash存储空间细分
数据暂存区 接收到的升级包

不推荐的方案:代码量不能超过 FLASH 的一半,并且在编译阶段要将应用程序A和应用程序B链接到不同的地址空间,后期难以维护。

增量升级

全量升级由于要传输新版程序的完整镜像,因此升级时间通常较长,升级失败的概率也更大。那么能不能只传送差异数据呢?答案是可以。这种技术被称作增量升级/差量升级/差分升级。我个人更喜欢增量升级这个叫法,因为增量一定是差分,但是差分不一定是增量。

增量升级确实降低了传输过程中的数据量,但也带来了版本管理复杂的问题,所以说不能因为有了增量升级,全量升级就不用了。

全量升级 完整全量升级 (✘)
压缩全量升级 (✘)
差分全量升级 (✔)
增量升级 差分增量升级 (✔)

控制分区

为了方便实现应用程序与引导程序之间的相互协作,需要单独拿出一个扇区的空间作为升级控制区。

  • 如果开启快速启动,升级控制区必须放在内部 FLASH 中。
  • 如果关闭快速启动,升级控制区可以放在内部 FLASH 中,也可以放在外部 FLASH 中。

升级控制区的数据结构如下:

typedef enum update_step_t
{
update_step_verify = 0x7FFFFFFF,
update_step_decode = 0x0000FFFF,
update_step_backup = 0x00000FFF,
update_step_docopy = 0x000000FF,
update_step_revert = 0x0000000F,
update_step_finish = 0x00000000,
} update_step_t;

typedef struct update_ctrl_t
{
uint32_t update_step; // 升级阶段:指示升级流程执行到了哪个阶段
uint32_t stayin_flag; // 停留标识:0/-1表示在引导程序中不作停留

uint32_t backup_size; // 引导程序内部备份和回滚用的变量(禁止在应用程序中修改)
uint32_t backup_hash; // 引导程序内部备份和回滚用的变量(禁止在应用程序中修改)
} update_ctrl_t;

升级包头

为了保证在线升级能够顺利进行,除了升级数据以外,我们还要向设备发送一些附加信息,这些附加信息通常被添加至升级文件的头部。

包头长度 04B 从「包头长度」开始计算
包头校验 04B 从「数据长度」开始计算
数据长度 04B 从「剩余数据」开始计算
数据校验 04B 从「剩余数据」开始计算
 
产品代码 08B 避免给错误的设备升级
产品代码 08B 避免给错误的设备升级
 
旧程序 LEN 值 04B 0xFFFFFFFF:全量镜像包
0x00000000:全量差分包
0xXXXXXXXX:增量差分包
新程序 LEN 值 04B
旧程序 CRC 值 04B
新程序 CRC 值 04B
旧程序 MD5 值 16B
新程序 MD5 值 16B
......
可以按需增加
typedef struct update_pack_t
{
// 包头 ################################################
uint32_t header_size; // 包头长度
uint32_t header_hash; // 包头校验
uint32_t remain_size; // 包体长度
uint32_t remain_hash; // 包体校验

unsigned char device_code[8]; // 产品代码
unsigned char device_data[8]; // 产品代码

uint32_t oldapp_size; // 旧程序大小(LEN)(0xFFFFFFFF-全量镜像包, 0x00000000-全量差分包, 0xXXXXXXXX-增量差分包)
uint32_t newapp_size; // 新程序大小(LEN)
uint32_t oldapp_hash; // 旧程序校验(CRC)
uint32_t newapp_hash; // 新程序校验(CRC)
#if 0
unsigned char oldapp_hash_md5[16]; // 旧程序校验(MD5)
unsigned char newapp_hash_md5[16]; // 新程序校验(MD5)
#endif

// 包体 ################################################
unsigned char remain_data[]; // 全量镜像包、全量差分包、增量差分包。

} update_pack_t;

变长包头

建议将升级包头设计成长度可变的,以便在扩展后还能兼容现场的旧设备。

升级文件

如何移植引导程序?

创建一个分支

当前模板工程用的是 vscode 和 eide 插件,请自行搜索 eide 插件的使用方式或参见:官方文档

基于模板工程创建小程序分支是比较推荐的方式,当然你也可以使用 baseline 中的代码自行创建一个不使用 eide 的工程,比如:Keil、IAR。

修改板级配置(bsp_cfg.h)

  • 配置 FLASH 的 SPIx 号

    #define BSP_USING_SPI1
  • 配置 RS485 的 URTx 号

    #define BSP_USING_UART1
  • 配置 FLASH 的通信引脚(SPI)

    #define FLASH_SPI_SCK_PRT               GPIO_PORT_X         // SCLK
    #define FLASH_SPI_SCK_PIN GPIO_PIN_XX
    #define FLASH_SPI_SCK_FNC GPIO_FUNC_X
    #define FLASH_SPI_TXD_PRT GPIO_PORT_X // MOSI
    #define FLASH_SPI_TXD_PIN GPIO_PIN_XX
    #define FLASH_SPI_TXD_FNC GPIO_FUNC_X
    #define FLASH_SPI_RXD_PRT GPIO_PORT_X // MISO
    #define FLASH_SPI_RXD_PIN GPIO_PIN_XX
    #define FLASH_SPI_RXD_FNC GPIO_FUNC_X
  • 配置 FLASH 的片选引脚

    #define FLASH_CSB_PIN                   GET_PIN(X, XX)
    #define FLASH_CSB_LVL PIN_LOW
  • 配置 FLASH 的供电引脚(如果有的话)

    #define FLASH_PWR_PIN
    #define FLASH_PWR_LVL
  • 配置 RS485 的通信引脚(URT)

    #define RS485_URT_TXD_PRT               GPIO_PORT_X
    #define RS485_URT_TXD_PIN GPIO_PIN_XX
    #define RS485_URT_TXD_FNC GPIO_FUNC_X
    #define RS485_URT_RXD_PRT GPIO_PORT_X
    #define RS485_URT_RXD_PIN GPIO_PIN_XX
    #define RS485_URT_RXD_FNC GPIO_FUNC_X
  • 配置 RS485 的收发引脚

    #define RS485_RTS_PIN                   GET_PIN(X, XX)
    #define RS485_RTS_LVL RT_SERIAL_HALF_DUPLEX_TX_HIGH // 高电平发送|低电平发送
    #define RS485_RTS_DLY 50 // 切换为发送模式后要延时的时间(微秒)
  • 配置 LED 的控制引脚(如果有的话)

    #define LED_PIN                         GET_PIN(X, XX)
  • 配置 WDT 的喂狗引脚(如果有的话)

    #define WDT_PIN                         GET_PIN(X, XX)

修改存储配置(fal_cfg.h)

  • 配置快速启动

    #define BOOTLOADER_USING_FAST_MODE
  • 配置存储空间

    #define __booter_zone_size              ( 40 * 1024)        // 引导区(小程序)
    #define __update_zone_size ( 8 * 1024) // 控制区
    #define __paramt_zone_size ( 16 * 1024) // 参数区
    #define __runapp_zone_size (448 * 1024) // 运行区(大程序)
    #define __backup_zone_size (448 * 1024) // 暂存区
    #define __decode_zone_size (448 * 1024) // 解码区

    #define __update_zone_addr (__booter_zone_size)
    #define __runapp_zone_addr (__booter_zone_size + __update_zone_size + __paramt_zone_size)

    /* partition table */
    #define FAL_PART_TABLE \
    { \
    {FAL_PART_MAGIC_WORD, "bootloader", "chipflash", 0, __booter_zone_size, 0}, /* 引导区 */ \
    {FAL_PART_MAGIC_WORD, "update", "chipflash", __update_zone_addr, __update_zone_size, 0}, /* 控制区 */ \
    {FAL_PART_MAGIC_WORD, "runapp", "chipflash", __runapp_zone_addr, __runapp_zone_size, 0}, /* 运行区 */ /* 原先的旧程序 */ \
    \
    {FAL_PART_MAGIC_WORD, "backup", "norflash0", 0, __backup_zone_size, 0}, /* 暂存区 */ /* 备份的旧程序 */ /* 下载的升级包 */ \
    {FAL_PART_MAGIC_WORD, "decode", "norflash0", __backup_zone_size, __decode_zone_size, 0}, /* 解码区 */ /* 解码的新程序 */ \
    }

如何制作升级文件?

制作现场用的全量升级包

在 build 目录下新建一个批处理脚本:

@echo off
echo ================================================================================
echo script.bat
echo ================================================================================

set exepath=.\build
set binpath=.\build\gcc

set product_code=XXXX0000
set product_data=00000000
set ver=%binpath%\main.old.specified.version.bin
set old=%binpath%\main.old.bin
set new=%binpath%\main.bin

set ooo=%binpath%\empty.bin
set patch_full=%binpath%\patch_full.bin
set patch_diff=%binpath%\patch_diff.bin
set patch_diff_specified_version=%binpath%\patch_diff.specified.version.bin

set UpdateFullZIP=%binpath%\update.pkg.full.zip.bin
set UpdateDiffZIP=%binpath%\update.pkg.diff.zip.bin
set UpdateDiffZIP_SPECIFIED_VERSION=%binpath%\update.pkg.diff.zip.specified.version.bin

cd. > %binpath%\empty.bin

echo ================================================================================
echo backup
echo ================================================================================
copy %new% %old%

echo ================================================================================
echo hdiffi.exe
echo ================================================================================
%exepath%\hdiffi.exe -c-tuz-1024 -f %ooo% %new% %patch_full%
%exepath%\hdiffi.exe -c-tuz-1024 -f %old% %new% %patch_diff%
%exepath%\hdiffi.exe -c-tuz-1024 -f %ver% %new% %patch_diff_specified_version%

echo ================================================================================
echo header.exe
echo ================================================================================
%exepath%\update_header_v2.exe %product_code% %product_data% %UpdateFullZIP% %patch_full% %new%
%exepath%\update_header_v2.exe %product_code% %product_data% %UpdateDiffZIP% %patch_diff% %new% %old%
%exepath%\update_header_v2.exe %product_code% %product_data% %UpdateDiffZIP_SPECIFIED_VERSION% %patch_diff_specified_version% %new% %ver%

把 hdiffi.exe 和 update_header_v2.exe 拷贝到 ./build 目录下

修改以下变量:

  • exepath = hdiffi.exe 和 update_header_v2.exe 所在的路径
  • binpath = main.bin 所在的路径
  • old = 「上次编译的程序」所在的路径
  • new = 「这次编译的程序」所在的路径
  • product_code = 实际项目的产品代码(这个产品代码是为了避免在升级时升错产品而设置的)

运行该批处理文件,生成以下文件:

  • update.pkg.full.zip.bin 现场用的全量升级包
  • update.pkg.diff.zip.bin 测试用的增量升级包

如果想要程序编译完之后自动调用该脚本,达到自动生成全量升级包的目的,请参照下图修改 EIDE 编译选项。

注意!这里生成的增量升级包是本次编译与上次编译之间的差异,仅用于测试,现场实际怎么用请继续往下看。

制作现场用的增量升级包

用批处理脚本制作

把旧版程序拷贝到脚本中%binpath%对应的目录,并重命名为%ver%对应的名称。

运行上面的脚本,生成以下文件:

  • update.pkg.diff.zip.specified.version.bin 现场用的增量升级包

用图形化工具制作

  • 选择旧版程序
  • 选择新版程序
  • 点击生成按钮

批处理脚本有利于实现自动化,但是用起来有点难度。图形化工具用起来比较简单,但是不太好实现自动化。建议两种方式一起使用,各取所长。

如何实现在线升级?

在引导程序中升级

  • 使用 USB-485 数据线连接电脑和设备,然后使用串口调试工具打开对应的 COM 口。
  • 配置串口和传输协议:115200, 8, 1, N, Y-Modem=1024。
  • 敲击键盘上的 TAB 键,引导程序会打印出所有支持的命令。
  • 执行 download 命令,引导程序进入 ymodem 接收模式,等待上位机下发升级文件。
  • 在 windterm 中单击右键,选择:传输二进制 -> 发送 YModem。
  • 选择制作好的升级文件并下发。
  • 等待文件传输完毕。
  • 收到升级文件后,引导程序开始执行升级流程。
  • 待升级流程执行完毕,自动跳转至应用程序。

参考视频:

在应用程序中升级

{FAL, "bootloader", "chipflash", addr, size, 0}, /* 引导区 */
{FAL, "update", "chipflash", addr, size, 0}, /* 控制区 */
{FAL, "runapp", "chipflash", addr, size, 0}, /* 运行区 */ /* 原先的旧程序 */

{FAL, "backup", "norflash0", addr, size, 0}, /* 暂存区 */ /* 备份的旧程序 */ /* 下载的升级包 */
{FAL, "decode", "norflash0", addr, size, 0}, /* 解码区 */ /* 解码的新程序 */
  • 在应用程序中接收升级文件并存储到 “backup” 分区。
  • 校验升级文件,如果是差分升级则还要校验旧程序。
  • 如果校验失败,则将 “backup” 分区擦除,结束。
  • 如果校验成功,则将 “update” 分区中的升级标识设置为 update_step_verify
  • 重启进入引导程序,开始执行升级流程。
  • 待升级流程执行完毕,自动跳转至应用程序。

为了提高后期的扩展能力,在解析升级包头时要以「变长」的思想来处理。