☰
STM32F407 IAP 在线升级实验:Ymodem + ECC/AES 加密 + 版本防回滚
2026/10/24 4:02:40 网站建设 项目流程

摘要:本文记录我在 STM32F407ZGT6 上实现 IAP 固件在线升级的完整过程,涵盖 Ymodem 协议接收、ECC 签名验签、AES-256 解密、版本防回滚,以及首次烧录的 hex 合并方案。本实验笔记仅供参考。

目录

一、前言

二、实验环境

三、总体设计

3.1 系统架构

3.2 Bootloader 程序流程图

3.3 App 程序软复位流程图

四、模块详细设计

4.1 Ymodem 协议模块

4.2 解密模块

4.3 中断处理

五、测试与操作说明

5.1 两种方式烧录步骤

六、踩坑记录

坑 1:Bootloader 没关定时器,跳转到 App 后出现异常

坑 2:计算哈希时把签名也算进去了,导致验签失败

坑 3:空片或首次烧录,bootloader 和 app 无法同时写入

七、版本防回滚设计

八、工程目录结构

九、总结


一、前言

在线升级(IAP/OTA)几乎是每个嵌入式产品必备的功能。我之前虽然看过一些关于 IAP的代码,但一直没自己动手写通,对 bootloader 的理解也停留在“能跳转就行”的层面。这次借普中 STM32F407 麒麟开发板,完整做了一遍 IAP 实验,并在基础升级之上加了ECC 签名 + AES 加密 + 版本防回滚,顺手把首次烧录的问题也解决了。

实验过程中踩了不少坑,比如 Bootloader 没关定时器导致跳转后串口发送数据乱码、哈希计算范围搞错导致验签失败等。这篇文章把整体设计、关键代码和踩坑记录整理出来,主要是为了给自己做个复盘记录。由于个人水平有限,文中难免有疏漏或不足之处,如果能给同样在做 IAP 的朋友提供一点点思路,那就再好不过了,也欢迎大家在评论区批评指正。

实验目标:

  • 用 ExtraPuTTY 通过 Ymodem 协议在线升级加密后的 App

  • 升级过程保证安全性:先签名后加密

  • App 版本号防回滚

  • 空片或首次烧录用 ST-LINK 一次性写入 bootloader + key + app + flag

二、实验环境

MCU型号与主频:STM32F407ZGT6,主频168Mhz IDE与编译器: KEIL5 编码工具: Visual Studio Code 调试器: 普中DAP仿真器 烧录工具: ST_LINK、STM32CubeProgrammer 加密工具编译器: Python 3.13

三、总体设计

3.1 系统架构

整体采用单分区 App方案,升级失败可重新请求升级。Bootloader 基于裸机前后台系统:前台负责串口收数据和定时器计时,后台负责状态处理。

Flash 分区表:

起始地址大小用途备注
0x0800 000064KBootloader
0x0801 000064BECC Public Key
0x0801 004032BAES Key
0x0802 0000768KApp起始地址偏移 0x230 处存放版本号,占 13 字节
0x080E 0000—App Upgrade Flag
0x080E 0040—App Size
0x080E 0080—App Sign

擦除flash的最小单位通常是扇区,由于升级标志区需要频繁擦写,因此把升级标准区用独立的扇区存储。但是STM32F407ZGT6的扇区颗粒度比较大,参考STM32F4中文手册如下图。因此把最后一个扇区分配给标志区,占用128KB。其中密钥属于不需要频繁擦写的数据,所以ECC公钥和AES密钥也使用了64KB的独立扇区存储。我的实验在flash分配上并不合理,浪费太多存储空间,仅实验用。在批量情况下,可把密钥写到OTP区(一次性可编程区域,数据块和锁定块均无法擦除)。

Flash 分区示意图:

0x0800 0000 ┌─────────────────┐ │ │ │ Bootloader │ ← 64K │ │ 0x0801 0000 ├─────────────────┤ │ Keys │ ← ECC 公钥 + AES 密钥 0x0802 0000 ├─────────────────┤ │ │ │ App │ ← 768K │ │ 0x080E 0000 ├─────────────────┤ │ Flag │ ← 升级标志 / App 大小 / App 签名 └─────────────────┘

系统框图:

3.2 Bootloader 程序流程图

流程说明:

  • 上电先读升级标志,决定进升级模式还是直接校验已有 App

  • 已有程序时,先读 App 参数 → 计算哈希 → 验签,通过才跳转;不通过则回到 Ymodem 等待升级

  • 升级模式下发'C'与上位机握手,接收完数据后依次做 AES 解密 → 哈希验签 → 版本校验

  • 任何一步失败都回到 Ymodem 等待重新接收,避免半成品 App 被跳转执行

3.3 App 程序软复位流程图

关键点:

  • App 运行中后台系统等待串口数据,收到RST命令才触发升级流程

  • 先写升级标志位,保证软复位后 Bootloader 能识别到升级请求

  • 关所有中断后再软复位,避免跳转过程中残留中断误触发

  • 软复位用NVIC_SystemReset()或直接跳 Bootloader 复位向量

四、模块详细设计

4.1 Ymodem 协议模块

模块关键接口说明

该模块延用别人的开源代码,稍作修改。核心接口:

/** * @brief 接收数据并处理数据 * @return 返回接收到的文件数量,最高位为1表示接收异常 */ y_uint16_t ymodem_receive(void);

内部用状态机方式接收和解析帧数据。关键状态定义:

typedef enum { Y_OK = 0x00u, /* 传输成功 */ Y_ERROR_CRC = 0x01u, /* CRC 校验错误 */ Y_ERROR_NUMBER = 0x02u, /* 包数量不匹配 */ Y_ERROR_UART = 0x04u, /* 传输错误 */ Y_ERROR_FLASH = 0x06u, /* Flash 错误 */ Y_EOY = 0x07u, /* 文件传输结束 */ Y_ERROR = 0xFFu /* 其他错误 */ } ymodem_status;

协议关键字节:

#define Y_SOH ((y_uint8_t)0x01u) /* 包头 128 bytes */ #define Y_STX ((y_uint8_t)0x02u) /* 包头 1024 bytes */ #define Y_EOT ((y_uint8_t)0x04u) /* 传输结束 */ #define Y_ACK ((y_uint8_t)0x06u) /* 应答 */ #define Y_NAK ((y_uint8_t)0x15u) /* 非应答 */ #define Y_CAN ((y_uint8_t)0x18u) /* 取消 */ #define Y_C ((y_uint8_t)0x43u) /* ASCII 'C',通知上位机用 CRC16 */

Ymodem协议数据收发流程:

4.2 解密模块

加密方案选型:

项目具体内容
哈希算法SHA-256
非对称加密ECDSA (P256)
对称加密AES-256-CBC
  • ECC 验签:用 TinyCrypt 开源库,计算哈希并验签

  • AES 解密:TinyCrypt 不支持 AES256 加解密,改用轻量的 micro-aes 库

SHA-256 哈希计算(注意:只对 App 数据计算,不含签名):

tc_sha256_init(&sha_state); tc_sha256_update(&sha_state, PlainTextBuff, App_ActualLen); // App_ActualLen 不含签名 tc_sha256_final(digest, &sha_state);

ECC 验签:

Ret = uECC_verify((u8*)public_key, digest, TC_SHA256_DIGEST_SIZE, // 32 signature, uECC_secp256r1());

AES-CBC 解密:

char AES_CBC_decrypt(const uint8_t* key, const block_t iVec, const void* crtxt, const size_t crtxtLen, void* pntxt);

调用以上接口来实现解密的代码如下:

//获取CBC模式的IV memcpy(iv, USART1_RX_BUF, 16); //获取AES密钥 STM32_FLASH_Read(AES_SECRETKEY_ADDR, (u32*)key,8); // 调用 AES256-CBC 解密 DecryptResult = AES_CBC_decrypt((u8*)key, iv, &USART1_RX_BUF[16], USART1_RX_CNT-16, PlainTextBuff); if(DecryptResult == 0) { LCD_ShowString(30,170,tftlcd_data.width,tftlcd_data.height,16,"Decryption successful!"); // 获取解密后明文的大小(APP+签名块) APP_DecryptedLen = pkcs7_unpad(PlainTextBuff, (USART1_RX_CNT-16)); if(APP_DecryptedLen > 0) { App_ActualLen = APP_DecryptedLen-SIG_BLOCK_LEN; Sign_ReadOffset = APP_DecryptedLen-SIG_LEN; //偏移签名块头7个字节 } else { LCD_ShowString(30,190,tftlcd_data.width,tftlcd_data.height,16,"PKCS7 unpad abnarmal"); } //计算 SHA-256 哈希 tc_sha256_init(&sha_state); tc_sha256_update(&sha_state, PlainTextBuff, App_ActualLen); tc_sha256_final(digest, &sha_state); STM32_FLASH_Read(ECC_PUBLICKEY_ADDR, (u32*)public_key,16); //获取上位机签名 memcpy(signature,&PlainTextBuff[Sign_ReadOffset],SIG_LEN); //验证ECC签名 Ret = uECC_verify((u8*)public_key, digest, TC_SHA256_DIGEST_SIZE, // 32 signature, uECC_secp256r1()); if(Ret == SUCCESS) { LCD_ShowString(30,190,tftlcd_data.width,tftlcd_data.height,16,"ECC vrify successful!"); } else { // 验签失败(签名无效或参数错误) LCD_ShowString(30,190,tftlcd_data.width,tftlcd_data.height,16,"ECC vrify fail"); } } else { LCD_ShowString(30,170,tftlcd_data.width,tftlcd_data.height,16,"Decryption fail"); }

解密数据长度还需结合脚本加密时对文件的处理来看,以下是我的加密文件长度格式:

(1)签名文件比源文件大71个字节,多了签名块头7个字节+签名数据64字节。

(2)AES加密后文件格式是:头16个字节是初始化向量,中间和末尾分别是密文和PKCS#7填充数据。

4.3 中断处理

中断函数作用
定时器 4TIM4_IRQHandler1ms 触发,给 Ymodem 提供计数,检测通信是否超时
串口 1USART1_IRQHandler调用Ymodem协议的接收数据函数,传入串口接收的数据
/******************************************************************************* * 函 数 名 : TIM4_IRQHandler * 函数功能 : TIM4中断函数 * 输 入 : 无 * 输 出 : 无 *******************************************************************************/ void TIM4_IRQHandler(void) { static u16 i=0; if(TIM_GetITStatus(TIM4,TIM_IT_Update)) { uwTick++; if(uwTick > 65535) { uwTick = 0; } } TIM_ClearITPendingBit(TIM4,TIM_IT_Update); }
/******************************************************************************* * 函 数 名 : USART1_IRQHandler * 函数功能 : USART1中断函数 * 输 入 : 无 * 输 出 : 无 *******************************************************************************/ void USART1_IRQHandler(void) { u8 r; if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { r =USART_ReceiveData(USART1); ymodem_data_recv(&r, 1); //给Ymodem协议传入数据 } }

五、测试与操作说明

5.1 两种方式烧录步骤

在线烧录:

  1. App 编译后复制firmware.bin到签名脚本路径

  2. 双击ecc_cryp.bat对 App 签名

  3. 双击aes_cryp.bat对签名后的 App 加密

  4. 打开 ExtraPuTTY,波特率9600

  5. 发送RST\r\n命令进入 Ymodem 模式

  6. 选择firmware_signed_encrypted.bin发送升级

烧录器烧录:

  1. 复制签名后的firmware_signed.bin到 Merge 文件夹

  2. 双击file_merge.bat合并数据

  3. 生成的factory.hex用 ST-LINK 工具和 STM32CubeProgrammer 软件烧录

操作视频链接:

【STM32在线升级测试-Ymodem协议】 https://www.bilibili.com/video/BV1TGnm6AEYx/?share_source=copy_web&vd_source=5eac46cd66a2fcc72c9eaac16dea7123

六、踩坑记录

坑 1:Bootloader 没关定时器,跳转到 App 后出现异常

现象:App 跳转成功、程序能跑,但电脑上串口助手接收到的数据是乱码。用调试器看,发现是TIM4_IRQHandler被意外触发。

原因:Bootloader 跳转前只关了全局中断__disable_irq(),但没有关闭 TIM4 的外设时钟和中断使能位。跳转到 App 后,App 一旦调用__enable_irq()开全局中断,TIM4 的更新中断立刻被挂起并触发。而此时 App 的向量表里TIM4_IRQHandler可能是空的或指向错误地址,导致跑飞。

解决:跳转前把 Bootloader 用过的外设全部复位:

//跳转到应用程序段 //appxaddr:用户代码起始地址. void iap_load_app(u32 appxaddr) { // 1. 合法性检查:检查 APP 首地址存放的栈顶指针是否合法 // 栈顶指针必须在 SRAM 范围内(STM32F4 通常是 0x20000000 起始) if (((*(vu32*)appxaddr) & 0x2FFE0000 ) == 0x20000000) { // 1. 关闭 SysTick 定时器并清空计数器 // SysTick 属于内核外设,__disable_irq 无法关闭其计数行为 SysTick->CTRL = 0; // 关闭 SysTick SysTick->LOAD = 0; // 清零重装载值 SysTick->VAL = 0; // 清零当前值 // 2. 关掉定时器 TIM_ClearITPendingBit(TIM4, TIM_IT_Update); // 清除残留中断标志 TIM_Cmd(TIM4, DISABLE); // 关闭定时器 TIM_DeInit(TIM4); // 复位定时器寄存器 // 3. 关闭所有中断 // 防止跳转瞬间触发未处理的中断,导致硬件错误 __disable_irq(); // 4. 复位串口外设 // 将外设寄存器恢复到复位默认值,避免状态残留 USART_DeInit(USART1); // ========== 执行跳转 ========== jump2app=(iapfun)*(vu32*)(appxaddr+4); //用户代码区第二个字为程序开始地址(复位地址) MSR_MSP(*(vu32*)appxaddr); //初始化APP堆栈指针(用户代码区的第一个字用于存放栈顶地址) jump2app(); } }

坑 2:计算哈希时把签名也算进去了,导致验签失败

现象:App 接收完成、AES 解密成功,但 ECC 验签一直失败,uECC_verify返回 0。反复检查公钥、哈希和签名数据都没问题。

原因:计算 SHA-256 哈希时,把签名数据也一起算进去了。而签名脚本在签名时,只对 App 原始数据计算哈希,签名是附在文件末尾的。MCU 端如果对“App 数据 + 签名”整体算哈希,得到的摘要和签名时的摘要不一致,自然验签失败。

解决:计算哈希时只对 App 数据部分,签名要排除在外:

tc_sha256_update(&sha_state, PlainTextBuff, App_ActualLen); // App_ActualLen 不含签名

经验总结:

  • 签名和哈希的范围必须严格一致:签名时对什么算,验签时就对什么算

  • 文件布局要明确:[App 数据][签名 64B][长度信息 7B],哈希只覆盖第一段

  • 调试时可以先在 PC 端用 Python 算一遍哈希,和 MCU 端对比,快速定位是范围问题还是算法问题

坑 3:空片或首次烧录,bootloader 和 app 无法同时写入

现象:App 单独烧录后无法运行,因为 Flash 里没有 bootloader 和校验参数。

尝试方案一:把 bootloader 打包成数组放进 App,固定到启动首地址。问题是加密场景下需要动态签名数据,这种方式不可行。

最终方案:用Python脚本把 bootloader、ECC 公钥、AES 密钥、App、升级标志、App 大小、App 签名合并成一个 hex,一次性烧录。合并内容如下:

步骤区域数据来源写入地址
1BootloaderBootloader.bin0x0800 0000
2ECC 公钥ec_public.pem0x0801 0000
3AES 密钥aes256-cbc-key.txt0x0801 0040
4Appfirmware_signed.bin,取 [开始, 末尾-71)0x0802 0000
5升级标志固定0x5A5A5A5A0x080E 0000
6App 大小firmware_signed.bin大小 - 710x080E 0040
7App 签名firmware_signed.bin末尾 64 字节0x080E 0080

七、版本防回滚设计

App 版本号格式:主版本号 + 修订号,如01.01。

判断逻辑:

  • 主版本号前进时,修订号重置(如01.05→02.00允许)

  • 主版本号不变时,修订号必须前进(如01.05→01.06允许,01.05→01.04拒绝)

  • 主版本号不能倒退(如02.00→01.09拒绝)

版本号通过分散加载方式写到 App 区偏移0x230处。

判断逻辑伪代码:

/* new_ver / old_ver 均为 (主版本号, 修订号) 结构 */ if (new_ver.major > old_ver.major) { /* 主版本进版,修订号重置,允许 */ return ALLOW; } else if (new_ver.major == old_ver.major) { if (new_ver.revision > old_ver.revision) { return ALLOW; /* 修订号前进,允许 */ } else { return REJECT; /* 修订号不变或倒退,拒绝 */ } } else { return REJECT; /* 主版本号倒退,拒绝 */ }

八、工程目录结构

文件夹说明
APP外设初始化和驱动代码,如串口、定时器、LCD
Encryption存放AES 和 ECC 加密库
IAP升级跳转程序
Ymodem_ProtocolYmodem 协议解析程序
LibrariesCMSIS和STM32F4标准库
Obj存放编译结果文件
Usermain.c和stm32f4xx_it.c文件

九、总结

整个 IAP 实验走下来,核心步骤可以归纳为:

  1. 分区:bootloader / key / app / flag 各就各位

  2. 接收:Ymodem 状态机 + 定时器超时检测

  3. 安全:SHA-256 哈希 → ECDSA 签名 → AES-256-CBC 加密

  4. 跳转:重映射 VTOR、设 MSP、跳复位向量,跳转前清干净外设

  5. 防回滚:版本号分散加载 + 升级时读取并校验,主版本进版时修订号重置

  6. 首次烧录:脚本合并生成 hex,用于空片或第一次烧录时一次写入

最大的收获:IAP 不只是“能跳转”,安全性和容错才是 bootloader 的关键。单分区方案虽然简单,但升级失败会变砖,后续有时间考虑加双 Bank 或备份区做回滚。

万事开头难,这是我的 CSDN 第一篇技术文章。技术更新迭代很快,我也还在不断学习中。文中如有疏漏或不当之处,恳请各位前辈和同行不吝赐教。感谢阅读!

标签:#STM32 #IAP #Bootloader #Ymodem #AES #ECC #OTA #嵌入式

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询