STM32F4 USB CDC IAP 在线升级实战
STM32F4 USB CDC IAP 在线升级实战
一、写在前面
IAP(In-Application Programming),简而言之就是让单片机自己给自己烧程序。产品都组装好了、外壳封好了,要是发现 bug 还得拆开拿 ST-Link 去下载,那也太不现实了。有了 IAP,我们就可以像软件更新一样进行固件升级,插上 USB 线,跑个脚本,完事。
这次项目用的是 STM32F405,ARM Cortex-M4 内核,主频 168MHz。升级方式我选择了 USB CDC(Communication Device Class)虚拟串口,不外加串口芯片,一根 USB 线搞定供电和数据传输,Windows/Linux 都免驱,插上就认,对用户非常友好。
本文只介绍IAP的核心逻辑编写思路,关于项目中用到的 HAL 库 API 以及 STM32Cube 的工程配置,可以参考:正点原子STM32F407 U盘升级程序(IAP)
二、整体架构与程序框架
在深入代码之前,先看一下整个项目的顶层设计,搞清楚数据是怎么流动的、MCU 在什么状态做什么事。下面这张框架图涵盖了 Bootloader 主流程和 APP 端流程:

整个项目分两层来看:
Bootloader 层——上电即运行,负责判断"是直接跑 APP 还是先升级"。核心逻辑很简单:查标志 → 有升级需求就搬运固件 → 跳转 APP → 没升级需求直接跳 APP。
APP 层——正常运行用户程序的同时,具备接收升级指令的能力。当收到升级命令时,通过 USB CDC 接收新固件到 RAM Buffer,CRC32 校验通过后写入 FLASH,设标志位,然后复位进入 Bootloader 完成搬运。
三、FLASH 读写接口
做 IAP 绕不开 FLASH 操作。先来熟悉一下 STM32F4 的 FLASH 结构,它是按 Sector(扇区)划分的,每个扇区大小不一样:
| Sector | 起始地址 | 大小 |
|---|---|---|
| 0 | 0x08000000 | 16KB |
| 1 | 0x08004000 | 16KB |
| 2 | 0x08008000 | 16KB |
| 3 | 0x0800C000 | 16KB |
| 4 | 0x08010000 | 64KB |
| 5 | 0x08020000 | 128KB |
| 6-11 | 0x08040000 起 | 128KB/个 |
前 4 个 Sector 各 16KB,Sector 4 是 64KB,Sector 5 开始都是 128KB。擦除时只能按整扇区擦,所以分区的时候要考虑好这个约束。
头文件里先把要用到的地址定义好:
#define ADDR_FLASH_SECTOR_0 ((uint32_t)0x08000000) /* 16 Kbytes */
#define ADDR_FLASH_SECTOR_1 ((uint32_t)0x08004000) /* 16 Kbytes */
#define ADDR_FLASH_SECTOR_2 ((uint32_t)0x08008000) /* 16 Kbytes */
#define ADDR_FLASH_SECTOR_3 ((uint32_t)0x0800C000) /* 16 Kbytes */
#define ADDR_FLASH_SECTOR_4 ((uint32_t)0x08010000) /* 64 Kbytes */
还需要一个根据地址获取 Sector 编号的工具函数,擦除时要用到:
static uint32_t GetSector(uint32_t Address)
{
if(Address < ADDR_FLASH_SECTOR_1) return FLASH_SECTOR_0;
if(Address < ADDR_FLASH_SECTOR_2) return FLASH_SECTOR_1;
if(Address < ADDR_FLASH_SECTOR_3) return FLASH_SECTOR_2;
if(Address < ADDR_FLASH_SECTOR_4) return FLASH_SECTOR_3;
if(Address < ADDR_FLASH_SECTOR_5) return FLASH_SECTOR_4;
return FLASH_SECTOR_5;
}
然后就是核心的烧写函数 FLASH_Erase_Write()。这个函数做的事情总结起来就是:解锁 -> 擦除 -> 处理 Cache -> 写入 -> 回读校验 -> 上锁。
uint32_t FLASH_Erase_Write(void)
{
uint32_t i = 0;
HAL_FLASH_Unlock();
// 计算需要擦除的扇区范围
FirstSector = GetSector(FLASH_USER_START_ADDR);
NbOfSectors = GetSector(FLASH_USER_END_ADDR) - FirstSector + 1;
EraseInitStruct.TypeErase = FLASH_TYPEERASE_SECTORS;
EraseInitStruct.VoltageRange = FLASH_VOLTAGE_RANGE_3; // 2.7V-3.6V
EraseInitStruct.Sector = FirstSector;
EraseInitStruct.NbSectors = NbOfSectors;
// 擦除扇区
if(HAL_FLASHEx_Erase(&EraseInitStruct, &SectorError) != HAL_OK)
{
HAL_FLASH_Lock();
return HAL_ERROR;
}
// Cache 复位(一定要处理!)
__HAL_FLASH_DATA_CACHE_DISABLE();
__HAL_FLASH_INSTRUCTION_CACHE_RESET();
__HAL_FLASH_DATA_CACHE_RESET();
__HAL_FLASH_INSTRUCTION_CACHE_ENABLE();
__HAL_FLASH_DATA_CACHE_ENABLE();
// 按 Word(4 字节)编程
Address = FLASH_USER_START_ADDR;
while ((Address < FLASH_USER_START_ADDR + APP_Size) && (i < APP_Size))
{
p = (uint32_t *)&RAM_Buffer[i];
if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, Address, *p) == HAL_OK)
{
Address += 4;
i += 4;
}
else
{
HAL_FLASH_Lock();
return HAL_ERROR;
}
}
HAL_FLASH_Lock();
// 写入后回读校验
Address = FLASH_USER_START_ADDR;
i = 0;
while ((Address < FLASH_USER_START_ADDR + APP_Size) && (i < APP_Size))
{
data32 = *(__IO uint32_t*)Address;
if (data32 != *(uint32_t*)&RAM_Buffer[i])
MemoryProgramStatus++;
Address += 4;
i += 4;
}
return (MemoryProgramStatus == 0) ? HAL_OK : HAL_ERROR;
}
这里有几个关键点要展开说说。
Cache 处理。F4 有 Data Cache 和 Instruction Cache,可以理解为 CPU 和 FLASH 之间的一个缓存层。擦写 FLASH 之后,Cache 里可能还存着旧的 FLASH 数据,这时候回读校验读到的其实是 Cache 里的旧内容,不是新写入的数据。必须把 Cache 禁用、复位、再启用。顺序是:先禁用 Data Cache,然后复位 Instruction Cache 和 Data Cache,最后重新启用两者。为什么 Instruction Cache 不用禁直接复位?HAL 库的参考代码就是这么写的,我照着写的。
编程方式。用的是 Word(4 字节)编程,HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, Address, *p)。这个比 Half Word(2 字节)效率高一倍。
回读校验。虽然 HAL_FLASH_Program() 返回了 HAL_OK,但我还是倾向于自己再读一次逐个对比,MemoryProgramStatus 只要不是 0 就说明有数据写错了,返回错误让上层处理。
四、分区规划
FLASH 搞清楚了,接下来就是分区。我的方案很简洁:
| 分区 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| Bootloader | 0x08000000 | 64KB(Sector 0-3) | 引导程序 |
| APP | 0x08010000 | 64KB(Sector 4) | 应用程序 |
| RAM Buffer | 片上 SRAM | 30KB | 固件接收缓存 |
Bootloader 占了 64KB,代码量完全够用:USB 驱动、FLASH 驱动、状态机、CRC 校验,加上 HAL 库,绰绰有余。APP 也暂定 64KB,如果后期需要做大可以扩展到 Sector 5(128KB)。
RAM Buffer 用了 30KB,相当于一个中转站固件从 USB 收下来先放 RAM,校验通过再烧写到 FLASH。为什么不边收边写 FLASH?因为如果中途断线或者数据错了,FLASH 已经擦掉了,设备就变砖了。RAM 里先备着,校验过了再写,安全第一。(备注:当然这个RAM Buffer的大小会限制固件传输的大小,毕竟要固件先存储到RAM Buffer中去,RAM Buffer最大为128KB,但不建议全调用用于缓存,保证安全的一般100KB差不多了,后续固件超过这个范围,就建议换用流式烧写)。
五、通信协议设计
数据传输得有规矩,不能乱来。我设计了一个简单的 4 状态接收机,配合固定的应答帧,实现可靠传输。
帧格式
所有数据包都以 0xAA 0x55 作为帧头,后面跟两个字节来表示命令或状态:
| 数据(十六进制) | 方向 | 含义 |
|---|---|---|
| AA 55 00 00 | PC -> MCU | 启动升级(CRC32 校验模式) |
| AA 55 00 00 | MCU -> PC | ACK,已就绪 |
| AA 55 02 00 | MCU -> PC | 固件烧写成功 |
| AA 55 03 00 | MCU -> PC | 错误(大小非法/CRC错误/Flash写入失败) |
状态机
接收端有 4 个状态:
State 0: 等待启动命令
收到 AA 55 00 00 -> CRC32 模式,进入 State 1(等 4 字节大小头)
State 1: 接收 4 字节大小头(仅 CRC32 模式)
拼出 APP_Size,剩余数据填入 RAM Buffer
收完所有数据后 -> State 3
State 2: 接收固件数据(简单模式)
持续填入 RAM Buffer
填满后提取尾部 CRC32 -> State 3
State 3: 固件接收完成,等待主循环处理
State 0 为初始状态,等着接收启动命令。收到 AA 55 00 00 就进入 CRC32 模式,发个 ACK 回去告诉 PC 端"我准备好了,发数据吧"。State 1 先收 4 字节的大小头,知道固件有多长,然后继续收数据。State 2 只管往 RAM Buffer 里填数据,填满了就转到 State 3。State 3 是个"我在主循环里处理,别打扰我"的状态。
对应到代码里大致就是这样:
void USB_Process_Received_Data(uint8_t* data, uint32_t len)
{
// State 0: 等待启动命令
if(SystemUpdateFlag == 0)
{
if(len >= 4)
{
uint16_t start_flag = (data[1] << 8) | data[0];
if(start_flag == UPGRADE_START_FLAG)
{
current_buffer_index = 0;
APP_Size = 0;
upgrade_timeout = 0;
if(data[2] == 0 && data[3] == 0)
{
upgrade_use_crc = 1;
SystemUpdateFlag = 1; // 进 State 1,等大小头
}
// 回 ACK
uint8_t ack[] = {0xAA, 0x55, 0x00, 0x00};
CDC_Transmit_FS(ack, 4);
}
}
return;
}
// State 1: 接收 4 字节大小头
if(SystemUpdateFlag == 1)
{
// 拼出 APP_Size,剩余数据填 RAM Buffer
// 收完转 State 2
}
// State 2: 接收固件数据
if(SystemUpdateFlag == 2)
{
// 持续写入 RAM Buffer
// 收满后转 State 3
}
}
握手时序
把整个过程串起来看:
PC (upgrade.py) MCU (BootLoader)
| |
|--- [AA 55 00 00] 启动命令 ------------>| State 0 -> State 1
|<-- [AA 55 00 00] ACK 确认 ------------|
| |
|--- [4字节大小 | 固件数据 | 4字节CRC] ->| State 1 -> 2 -> 3
| |
| MCU 内部:CRC 校验 -> 擦除 FLASH |
| -> 逐字写入 -> 回读验证 |
| |
|<-- [AA 55 02 00] 成功 -----------------|
| |
| [USB 断连 — MCU 已跳转 APP] | jumpToApp()
六、CRC32 校验
校验是 IAP 的良心。没有校验,固件传输过程中出了错你也不知道,烧进去跑不起来只能干瞪眼。
CRC32 我用的是 IEEE 802.3 标准,多项式 0xEDB88320,反射模式(LSB-first)。MCU 端的实现:
#define UPGRADE_CRC32_POLY 0xEDB88320
uint32_t CRC32_Calculate(const uint8_t *buf, uint32_t len)
{
uint32_t crc = 0xFFFFFFFF;
for (uint32_t i = 0; i < len; i++)
{
crc ^= buf[i];
for (int j = 0; j < 8; j++)
{
if(crc & 1)
crc = (crc >> 1) ^ UPGRADE_CRC32_POLY;
else
crc >>= 1;
}
}
return ~crc;
}
PC 端直接用 Python 标准库,省事:
import binascii
crc = binascii.crc32(firmware_data) & 0xFFFFFFFF
PC 端发送的是 [4字节固件大小(LE)] + [固件数据] + [4字节CRC32(LE)]。MCU 收到后先算 CRC,和尾部的 CRC 值比对,一致才烧写,不一致直接返回 NAK。这样就能确保传输过程中没有丢包或数据错误。
七、跳转到 APP
这是 IAP 的灵魂,写不对前面干再多都是零。
void jumpToApp(void)
{
uint32_t appStackTop = *(__IO uint32_t *)FLASH_USER_START_ADDR;
// 检查栈顶地址是否合法
if ((appStackTop & 0x2FFE0000) != 0x20000000)
return;
HAL_RCC_DeInit(); // 复位外设时钟
HAL_DeInit(); // 复位 HAL 库
__disable_irq(); // 关全局中断
SCB->VTOR = FLASH_USER_START_ADDR; // 中断向量表偏移
__set_MSP(appStackTop); // 设置主栈指针
JumpAddress = *(__IO uint32_t *)(FLASH_USER_START_ADDR + 4);
Jump_To_Application = (pFunction)JumpAddress;
Jump_To_Application();
}
一步一步解释:
检查栈顶地址。APP 的起始 4 字节应该是栈顶指针,一定指向 SRAM 区域(0x20000000 范围)。如果不在这个范围,说明 FLASH 里根本没有合法的 APP,直接 return,不会跳转。这就是 Bootloader 在"没有 APP"时的容错处理。
HAL_RCC_DeInit() + HAL_DeInit()。把外设时钟和 HAL 库恢复到复位状态。如果不做这一步,APP 初始化时可能会发现某个外设状态"不对"而卡住。
__disable_irq()。跳转过程中关掉全局中断,防止跳转到一半来个中断把事情搞乱。APP 启动后由 APP 自己开中断。
SCB->VTOR。这个是关键。因为 APP 的起始地址是 0x08010000,不是默认的 0x08000000,必须告诉 CPU 中断向量表的新位置。同时,APP 工程的 system_stm32f4xx.c 里也要把 VECT_TAB_OFFSET 改成 0x10000,两边一致才能正常工作。
__set_MSP() + 函数指针跳转。设置 SP 到 APP 的栈顶,然后取出复位向量地址(起始 + 4 的位置),用函数指针跳转过去。这一步执行完,CPU 就跑 APP 的代码了。
八、主循环与超时机制
主循环的逻辑比较简单,就是"等升级,或者跳 APP":
while (1)
{
if(SystemUpdateFlag == 3)
{
// 固件收完了,开始校验和烧写
if(APP_Size == 0 || APP_Size > RAM_BUFFER_SIZE)
{
// 大小非法 -> NAK
ack_data = {0xAA, 0x55, 0x03, 0x00};
}
else if(!upgrade_use_crc || CRC32_Calculate(RAM_Buffer, APP_Size) == APP_CRC32)
{
// CRC 校验通过
if(FLASH_Erase_Write() == HAL_OK)
{
ack_data = {0xAA, 0x55, 0x02, 0x00};
CDC_Transmit_FS(ack_data, 4);
// 等待 USB ACK 发送完毕再跳转
while (hcdc->TxState != 0 && wait < 500) {
HAL_Delay(1); wait++;
}
jumpToApp();
}
else
{
ack_data = {0xAA, 0x55, 0x03, 0x00};
}
}
else
{
ack_data = {0xAA, 0x55, 0x03, 0x00};
}
SystemUpdateFlag = 0;
}
// 超时自动跳转 APP
if(SystemUpdateFlag == 0)
{
static uint8_t app_checked = 0;
if (!app_checked) {
upgrade_timeout++;
if(upgrade_timeout > UPGRADE_TIMEOUT_MS) {
app_checked = 1;
jumpToApp();
}
}
}
HAL_Delay(1);
}
这里说两个容易被忽略的点。
超时自动跳转。设备开机不能永远停在 Bootloader 等人发固件,那不成砖了吗?我设了一个 5 秒的超时,从开机算起,如果 5 秒内没有收到任何升级命令,就自动跳转到 APP。APP 如果是完好的,就正常运行;如果 APP 不存在,jumpToApp() 会检查栈顶地址不合法然后直接 return,继续在循环里等着。
ACK 后等发送完成。CDC_Transmit_FS() 把数据放到 USB 发送缓冲区就返回了,真正发送是在 USB 中断里做的。如果发了 ACK 立刻跳转,ACK 可能还没发出去 MCU 就跑了。我加了一个小循环等 TxState 清零,最多等 500ms,确保 PC 端收到确认再跳转。
九、PC 端升级脚本
上面说了这么多 MCU 端的代码,还得有个 PC 端配合才行。我用 Python 写了一个升级脚本,用法是:
python upgrade.py COM13 my_app.bin
脚本的完整代码:
import serial, struct, binascii, sys
COM = sys.argv[1]
BIN = sys.argv[2]
fw = open(BIN, 'rb').read()
# 安全检查:确认是原始 APP.bin
first_word = struct.unpack('<I', fw[:4])[0]
if first_word < 0x10000000:
print('ERROR: 可能传了已打包过的升级文件,请使用原始 APP.bin')
sys.exit(1)
crc = binascii.crc32(fw) & 0xFFFFFFFF
s = serial.Serial(COM, timeout=10)
s.reset_input_buffer()
# Step 1: 发送启动命令
s.write(bytes([0xAA, 0x55, 0, 0]))
ack = s.read(4)
print(f'Start ACK: {ack.hex()}')
# Step 2: 发送 [4字节大小 + 固件 + 4字节CRC32]
s.write(struct.pack('<I', len(fw)) + fw + struct.pack('<I', crc))
print(f'Firmware: {len(fw)} bytes, CRC32={crc:08X}')
# Step 3: 等待结果
try:
r = s.read(4)
if r == bytes([0xAA, 0x55, 0x02, 0x00]):
print('Result: SUCCESS')
elif r == bytes([0xAA, 0x55, 0x03, 0x00]):
print('Result: FAILED')
except:
# USB 断连 = MCU 已跳转 APP,也算成功
print('Result: USB disconnected -> SUCCESS')
s.close()
脚本做了三件事:发启动命令、发固件数据(带 CRC32 尾巴)、等结果。注意那个 try-except 包起来的 serial.read(),如果 MCU 成功跳转 APP,APP 重新初始化 USB 会导致 USB 断开重连,PC 这边的读操作会抛异常,这其实是"成功"的信号,不是错误。
另外加了安全检查:如果 .bin 文件前 4 字节的值小于 0x10000000,说明可能传错了文件——比如传了一个已经打过包的升级文件。这个是我自己踩过的坑,有一次调糊涂了把文件搞混了。
十、踩坑记录
整个开发过程中遇到了一些问题,分享出来,大家遇到类似的可以少走弯路。
坑 1:FLASH Cache 问题
这个在前面提到了,F4 的 Cache 在擦写 FLASH 后会缓存旧数据,导致回读校验失败。解决就是擦除后做一遍 Cache 的 disable -> reset -> enable。当时调这个的时候卡了挺久,回读校验死活过不去,后来查到是 Cache 的问题。
坑 2:USB 断开异常
升级成功后 MCU 跳转 APP,APP 初始化 USB 会导致 USB 断开重连。PC 端的 serial.read() 会抛出异常。一开始我还以为是升级失败了,后来才发现不是。在脚本里加个 try-except 捕获异常就好了。
坑 3:ACK 发送不完整
CDC_Transmit_FS() 是异步的,数据放到缓冲区就返回,真正的 USB 发送在中断里完成。如果发了 ACK 立刻跳转,ACK 可能还没发出去。解决就是在跳转前等 TxState 清零,代码里那个 while (hcdc->TxState != 0 ...) 循环就是干这个的。
坑 4:APP 中断向量偏移
这是一个容易忽略的点。APP 工程的 system_stm32f4xx.c 里必须设置 VECT_TAB_OFFSET = 0x10000,同时 Bootloader 的 jumpToApp() 里也要设 SCB->VTOR = FLASH_USER_START_ADDR。两边对不上,中断就进不去,程序秒死。
坑 5:电压范围选择
擦除 FLASH 时要指定电压范围,FLASH_VOLTAGE_RANGE_3 对应 2.7V-3.6V,选错了擦除会失败。这个一般不会错,但万一你复制了别的工程的擦除代码,记得检查一下。
十一、后续迭代方向
当前这套方案跑通了基本闭环,但距离真正"工程化"还有一段路。这里简要梳理几个值得继续迭代的方向,给后续开发留个路标。
1. 流式烧写,突破 RAM Buffer 容量上限
目前的方案是把固件先完整收到 RAM Buffer(30KB),校验通过再一次性写入 FLASH。固件越大需要的 RAM 越多,超过了就不行。改进方向是流式烧写:上层协议带包序号(比如每包 2KB,PC 端分包下发),MCU 每收满一包就写入 FLASH,边收边烧,不再受 RAM 大小限制。但这会引入新问题——如果写到一半失败了,FLASH 里已经是"半新半旧"的状态,需要有回滚机制(另一个思路是保留双 APP 分区做 A/B 面切换)。
2. 固件加密与签名校验
目前只有 CRC32 做数据完整性校验,但没有防篡改能力。如果升级文件在 PC 端被恶意替换,MCU 无法识别。后续可以加入 AES 加密 + ECC/RSA 签名,MCU 端先验签再解密再烧写,防止固件被篡改或逆向。
3. 断点续传
USB CDC 虽然比串口可靠,但实际使用中用户可能不小心拔线或者 PC 端脚本崩溃。目前的方案碰到这种情况只能重来。后续协议可以加入断点续传:PC 端先查询 MCU 当前写到哪个包了,然后从断点接着发。
4. 双 APP 分区(A/B 面升级)
现在的方案只有一个 APP 分区,如果升级过程中断电或者固件本身有问题,设备就变砖了。参考 Android 的 A/B 分区思路,预留两个 APP 分区(Sector 4 和 Sector 5,各 64KB),Bootloader 维护一个"当前运行面"标志。升级时写到另一个面,写完之后切换标志、重启。如果新固件启动失败,Bootloader 检测到后自动回退到旧面。就相当于备份升级。
5. 上位机图形化
现在的 PC 端是命令行脚本,虽然能用,但给产线工人用显然不友好。后续可以写一个带 GUI 的上位机(PyQt 或 C# WinForms),支持拖拽 .bin 文件、显示进度条、记录升级日志,甚至批量升级多个设备。
以上这些方向不是都要做,根据项目阶段按需取舍。Demo 验证阶段把流程跑通就行,小批量阶段把安全和断点续传加上,到量产阶段再做双分区和上位机。好的架构是一步步演进出来的,不是一上来就设计出来的。
十二、总结
好了,整个 USB CDC IAP 方案就讲完了。核心流程总结为:
上电 -> Bootloader 等待 5 秒 -> 无人升级则跳 APP / 收到升级则接收固件到 RAM -> CRC32 校验 -> 擦除 FLASH -> 写入 -> 回读校验 -> 跳转 APP
整个方案的核心要点:
- FLASH 操作:扇区擦除、Cache 处理、Word 编程、回读校验,每一步都不能马虎
- 通信协议:简单但可靠的状态机 + 应答机制,确保 PC 和 MCU 步调一致
- 数据校验:CRC32 保证固件完整性,杜绝"烧进去跑不起来"的尴尬
- 跳转前收尾:关中断、复位外设、设置向量表偏移、等 USB 发完,一个都不能少
有什么问题欢迎交流,写到不好的地方也请多指教。
浙公网安备 33010602011771号