摘要:本文记录我在 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 0000 | 64K | Bootloader | |
| 0x0801 0000 | 64B | ECC Public Key | |
| 0x0801 0040 | 32B | AES Key | |
| 0x0802 0000 | 768K | App | 起始地址偏移 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 中断处理
| 中断 | 函数 | 作用 |
|---|---|---|
| 定时器 4 | TIM4_IRQHandler | 1ms 触发,给 Ymodem 提供计数,检测通信是否超时 |
| 串口 1 | USART1_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 两种方式烧录步骤
在线烧录:
App 编译后复制
firmware.bin到签名脚本路径双击
ecc_cryp.bat对 App 签名双击
aes_cryp.bat对签名后的 App 加密打开 ExtraPuTTY,波特率9600
发送
RST\r\n命令进入 Ymodem 模式选择
firmware_signed_encrypted.bin发送升级
烧录器烧录:
复制签名后的
firmware_signed.bin到 Merge 文件夹双击
file_merge.bat合并数据生成的
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,一次性烧录。合并内容如下:
| 步骤 | 区域 | 数据来源 | 写入地址 |
|---|---|---|---|
| 1 | Bootloader | Bootloader.bin | 0x0800 0000 |
| 2 | ECC 公钥 | ec_public.pem | 0x0801 0000 |
| 3 | AES 密钥 | aes256-cbc-key.txt | 0x0801 0040 |
| 4 | App | firmware_signed.bin,取 [开始, 末尾-71) | 0x0802 0000 |
| 5 | 升级标志 | 固定0x5A5A5A5A | 0x080E 0000 |
| 6 | App 大小 | firmware_signed.bin大小 - 71 | 0x080E 0040 |
| 7 | App 签名 | 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_Protocol | Ymodem 协议解析程序 |
| Libraries | CMSIS和STM32F4标准库 |
| Obj | 存放编译结果文件 |
| User | main.c和stm32f4xx_it.c文件 |
九、总结
整个 IAP 实验走下来,核心步骤可以归纳为:
分区:bootloader / key / app / flag 各就各位
接收:Ymodem 状态机 + 定时器超时检测
安全:SHA-256 哈希 → ECDSA 签名 → AES-256-CBC 加密
跳转:重映射 VTOR、设 MSP、跳复位向量,跳转前清干净外设
防回滚:版本号分散加载 + 升级时读取并校验,主版本进版时修订号重置
首次烧录:脚本合并生成 hex,用于空片或第一次烧录时一次写入
最大的收获:IAP 不只是“能跳转”,安全性和容错才是 bootloader 的关键。单分区方案虽然简单,但升级失败会变砖,后续有时间考虑加双 Bank 或备份区做回滚。
万事开头难,这是我的 CSDN 第一篇技术文章。技术更新迭代很快,我也还在不断学习中。文中如有疏漏或不当之处,恳请各位前辈和同行不吝赐教。感谢阅读!
标签:#STM32 #IAP #Bootloader #Ymodem #AES #ECC #OTA #嵌入式