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 端流程:
IAP整体架构

整个项目分两层来看:

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

整个方案的核心要点:

  1. FLASH 操作:扇区擦除、Cache 处理、Word 编程、回读校验,每一步都不能马虎
  2. 通信协议:简单但可靠的状态机 + 应答机制,确保 PC 和 MCU 步调一致
  3. 数据校验:CRC32 保证固件完整性,杜绝"烧进去跑不起来"的尴尬
  4. 跳转前收尾:关中断、复位外设、设置向量表偏移、等 USB 发完,一个都不能少

有什么问题欢迎交流,写到不好的地方也请多指教。


posted @ 2026-07-24 17:25  dMing`  阅读(2)  评论(0)    收藏  举报