简介:这是一套面向STM32嵌入式开发者的USB OTA固件升级完整方案,针对传统串口或调试器烧录效率低、现场维护不便的痛点,提供自制Bootloader与App双工程源码,帮助读者掌握通过USB接口完成固件传输与在线升级的完整实现思路,适合具备一定STM32与USB协议基础的进阶开发者参考。资源包共504个文件,约7.57MB,以C源码与头文件为核心,涵盖USB寄存器、串口、Flash、GPIO等底层驱动实现,并附带编译中间文件、工程配置、映射表及bin、hex固件等,便于直接编译验证与二次移植。目前已有6647人学习下载,热度较高。读者可从中获得Bootloader跳转逻辑、USB通信收发、Flash擦写与App分区等关键模块的完整代码,结合工程结构快速搭建自己的OTA升级框架,并参考其中的排错与调试配置,缩短开发周期。
1. STM32 USB OTA升级:为什么它是量产设备最省心的固件更新路径
做过带 USB 接口的 STM32 产品,大概率都遇到过同一个尴尬:设备装在壳子里、贴在墙上、埋在现场,固件有 bug 或者要加功能,只能把设备拆回来插上 ST-Link 重新烧。一次两次还行,出货几百上千台就是灾难。STM32 USB OTA升级解决的正是这件事——让设备通过 USB 口自己接收新固件、自己校验、自己切换、自己重启,全程不用拆机、不用仿真器。
它的核心思路并不玄学:把 Flash 划成 Bootloader 区、App 运行区、下载暂存区三块,Bootloader 常驻不动,负责枚举 USB、接收固件、校验完整性、搬运到运行区、跳转执行。上位机侧只需要一个能发文件的工具,把 bin 按约定协议推下去即可。适合谁?适合已经用 STM32 做 USB 通信、有量产维护诉求、又不想引入额外无线模块的团队。下面按「原理选型 → 分区与跳转 → USB 接收实现 → 上位机协议 → 避坑 → 进阶验证」的顺序,把能复现的细节讲透。
2. 分区规划与 Bootloader 跳转:先把地址算清楚再写一行代码
OTA 翻车十有八九不是 USB 写错,而是分区没算明白。Flash 地址、向量表偏移、栈顶指针这三样只要有一个对不上,现象就是「下载成功但一跑就 HardFault」,而且没有任何报错,纯黑匣子。
2.1 三区划分与地址对齐原则
以常见的 STM32F103C8(64KB Flash)为例,一个稳妥的划分是:
| 区域 | 起始地址 | 大小 | 作用 |
|---|---|---|---|
| Bootloader | 0x08000000 | 16KB | 常驻,负责 USB 接收与跳转 |
| App 运行区 | 0x08004000 | 40KB | 实际业务固件 |
| 下载暂存区 | 0x0800E000 | 8KB | 接收新固件,校验后再搬运 |
划分要遵守三条硬规则。第一,每个区的起始地址必须落在 Flash 页边界上,STM32F1 的页大小是 1KB,F4 是 16KB/64KB/128KB 不等,跨页擦除会连带擦掉邻居。第二,Bootloader 区要留足余量,USB 协议栈加校验逻辑很容易吃掉 12KB 以上,别卡着 8KB 做。第三,暂存区至少要能放下「最大 App 体积」,如果 App 有 40KB,暂存区就得 40KB,否则只能边收边写运行区,一旦断电就是砖。
提示:暂存区方案比「边收边写运行区」安全得多。前者断电最多是升级失败,旧固件还在;后者断电可能把运行区擦了一半,设备直接变砖。
2.2 跳转前的三件事:关中断、设向量表、取栈顶
从 Bootloader 跳到 App,标准动作是这几步,缺一不可:
// 跳转到 App 运行区,app_addr = 0x08004000 typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t stack_top = *(volatile uint32_t *)app_addr; // 取 App 栈顶 uint32_t reset_handler = *(volatile uint32_t *)(app_addr + 4); // 取复位向量 // 合法性检查:栈顶必须落在 SRAM 范围,复位向量必须落在 Flash 范围 if ((stack_top & 0x2FFE0000) != 0x20000000) return; // SRAM 起始 0x20000000 if (reset_handler < 0x08000000 || reset_handler > 0x08010000) return; __disable_irq(); // 关全局中断 HAL_RCC_DeInit(); // 复位时钟,避免 App 沿用 Boot 配置 SysTick->CTRL = 0; // 关 SysTick SysTick->LOAD = 0; SysTick->VAL = 0; SCB->VTOR = app_addr; // 重定向向量表到 App __set_MSP(stack_top); // 设置主栈指针 pFunction app_entry = (pFunction)reset_handler; app_entry(); // 跳转,不再返回 }逻辑说明:前两行从 App 区头部读出栈顶和复位向量,这是 Cortex-M 上电时的硬件约定。合法性检查是后悔药——如果 App 区是空的(全 0xFF),栈顶会读到 0xFFFFFFFF,直接跳过去必崩,检查一下就能优雅退回 Bootloader。HAL_RCC_DeInit容易被忽略,但 Bootloader 里如果开了 USB 时钟、改了 PLL,App 再初始化时钟就会冲突,表现为「跳过去后串口波特率全乱」。
参数说明:app_addr必须和链接脚本里 App 的起始地址完全一致。App 工程里还要改两处——链接脚本的 ROM 起始改成 0x08004000,以及system_stm32f1xx.c里的VECT_TAB_OFFSET改成 0x4000。这两处不改,App 自己跑没事,一被跳转就进不了中断。
2.3 App 侧必须配合改的两个地方
很多人只改 Bootloader,忘了 App 也要动。第一处是中断向量表偏移,除了上面说的VECT_TAB_OFFSET,更稳的做法是在 App 的main开头手动再设一次:
int main(void) { SCB->VTOR = 0x08004000; // 双保险,防止启动文件没生效 HAL_Init(); SystemClock_Config(); // ... 业务代码 }第二处是链接脚本。以 GCC 的.ld为例,把FLASH ORIGIN从0x08000000改成0x08004000,长度相应减去 Bootloader 占用。Keil 用户则在 Target 选项的 IROM1 里改起始和大小。改完编译,用fromelf或objcopy生成 bin,确认文件头 8 字节是合理的栈顶和复位向量,再拿去升级。
3. USB 接收固件的实现:用 CDC 还是 DFU,怎么选怎么落地
USB OTA 的传输层有两条主流路线:一是自定义 CDC 虚拟串口,自己定协议传 bin;二是用芯片自带的 USB DFU 类,配合官方工具。选哪条,取决于你对上位机控制力的要求。
3.1 CDC 自定义协议 vs 内置 DFU 的选型对比
| 维度 | CDC 自定义 | 内置 DFU |
|---|---|---|
| 上位机 | 自己写,可集成到现有工具 | 官方工具或通用 DFU 软件 |
| 协议灵活度 | 高,可加版本号、CRC、断点续传 | 低,受 DFU 规范约束 |
| Bootloader 体积 | 较大,需带 USB CDC 栈 | 较小,DFU 类由库提供 |
| 量产友好度 | 高,可做一键升级 | 一般,操作步骤偏多 |
| 调试难度 | 中,协议自己定自己查 | 低,工具成熟 |
我的经验是:如果产品已经有上位机软件、要批量升级、要显示进度和版本,选 CDC 自定义;如果是单台维护、不想写上位机,用内置 DFU 更省事。下面重点讲 CDC 路线的落地,因为它覆盖的场景更广。
3.2 CDC 接收状态机与 Flash 写入
接收端不能收到一包就写一包,要先在 RAM 里攒够一个 Flash 页再擦写,否则频繁擦写会拖慢速度、增加出错概率。核心状态机如下:
#define PAGE_SIZE 1024 #define STAGING_ADDR 0x0800E000 #define APP_MAX_SIZE (40 * 1024) static uint8_t page_buf[PAGE_SIZE]; static uint32_t page_offset = 0; // 当前页内偏移 static uint32_t total_recv = 0; // 已接收总字节 static uint32_t staging_wr = STAGING_ADDR; // 每收到一段 CDC 数据就调用 void ota_feed(uint8_t *data, uint32_t len) { for (uint32_t i = 0; i < len; i++) { page_buf[page_offset++] = data[i]; total_recv++; if (total_recv > APP_MAX_SIZE) { // 越界保护 ota_abort(); return; } if (page_offset == PAGE_SIZE) { // 攒满一页,落盘 flash_write_page(staging_wr, page_buf); staging_wr += PAGE_SIZE; page_offset = 0; } } } // 收完后把不足一页的尾巴补 0xFF 写进去 void ota_finish(void) { if (page_offset > 0) { memset(&page_buf[page_offset], 0xFF, PAGE_SIZE - page_offset); flash_write_page(staging_wr, page_buf); page_offset = 0; } }逻辑说明:page_buf是 RAM 缓冲,攒满 1KB 才调用一次flash_write_page,把擦写次数降到最低。total_recv做越界保护,防止上位机发超长数据把暂存区写穿。ota_finish处理最后一页不满的情况,补 0xFF 是因为 Flash 擦除后就是 0xFF,补上保证写入内容一致。
参数说明:PAGE_SIZE必须和芯片实际页大小一致,F1 是 1KB,F4 常见 16KB 或 128KB,写错会导致擦除范围错误。STAGING_ADDR要和分区表对齐。flash_write_page内部要先擦后写,擦除单位是页,写入单位可以是半字或字,注意对齐。
3.3 校验与搬运:CRC32 是最后一道防线
接收完必须校验,否则传输中一位翻转就可能让新固件跑飞。常用 CRC32,上位机算好放在固件尾部或单独发一条命令。
// 校验暂存区数据,expected 由上位机下发 uint8_t ota_verify(uint32_t fw_len, uint32_t expected_crc) { uint32_t crc = 0xFFFFFFFF; uint8_t *p = (uint8_t *)STAGING_ADDR; for (uint32_t i = 0; i < fw_len; i++) { crc ^= p[i]; for (int b = 0; b < 8; b++) crc = (crc >> 1) ^ (0xEDB88320 & -(crc & 1)); } crc ^= 0xFFFFFFFF; return (crc == expected_crc) ? 1 : 0; }校验通过后,把暂存区数据逐页搬到 App 运行区。搬运前先擦除 App 区,搬运中如果断电,下次上电 Bootloader 检测到 App 区 CRC 不对,就停在 Bootloader 等待重新升级,不会跳转到半成品。这一步是「后悔药」的关键——永远不要在校验通过前擦除运行区。
4. 上位机协议设计与传输提速:把 40KB 固件压进 10 秒
协议设计得好不好,直接决定升级体验。一个能用的协议至少要有:握手、下发固件长度和 CRC、分包传输、结束确认、失败重传。
4.1 帧格式与命令字定义
推荐一个简洁的帧结构,固定 8 字节头加变长数据:
| 字段 | 长度 | 说明 |
|---|---|---|
| SOF | 1 | 固定 0x5A |
| CMD | 1 | 命令字,如 0x01 握手、0x02 数据、0x03 结束 |
| SEQ | 2 | 包序号,用于重传判断 |
| LEN | 2 | 数据长度,小端 |
| DATA | N | 负载 |
| CRC16 | 2 | 从 CMD 到 DATA 的校验 |
命令字建议只保留四条:握手(返回 Bootloader 版本和芯片 ID)、开始(带固件总长和 CRC32)、数据(带序号和负载)、结束(触发校验和搬运)。命令越少,状态机越简单,越不容易出玄学问题。
4.2 分包大小与超时重传参数
CDC 的端点缓冲区常见 64 字节,但实际每包可以发几百字节,由 USB 栈自动分包。经验值:每包负载 512 字节,兼顾效率和重传成本。超时设 500ms,连续 3 次超时就报错退出。
# 上位机发送逻辑(伪代码,基于 pyserial) import serial, struct, time def send_firmware(port, fw_path): ser = serial.Serial(port, 115200, timeout=0.5) fw = open(fw_path, 'rb').read() crc = crc32(fw) # 1. 握手 ser.write(b'\x5A\x01\x00\x00\x00\x00\x00\x00') if not wait_ack(ser): raise Exception("握手失败") # 2. 开始,下发长度和 CRC pkt = struct.pack('<BBHH', 0x5A, 0x02, 0, 8) + struct.pack('<II', len(fw), crc) ser.write(pkt + crc16(pkt[1:])) # 3. 分包发送 seq = 0 for off in range(0, len(fw), 512): chunk = fw[off:off+512] pkt = struct.pack('<BBHH', 0x5A, 0x03, seq, len(chunk)) + chunk for retry in range(3): ser.write(pkt + crc16(pkt[1:])) if wait_ack(ser): break else: raise Exception(f"第 {seq} 包重传失败") seq += 1 # 4. 结束 ser.write(b'\x5A\x04\x00\x00\x00\x00\x00\x00') return wait_ack(ser)逻辑说明:握手确认设备在 Bootloader 里;开始命令把总长和 CRC 告诉设备,设备据此判断暂存区够不够;数据包带序号,设备收到后回 ACK,上位机没收到就重传;结束命令触发设备校验和搬运。整个流程是「一问一答」,虽然比单向流慢一点,但可靠性高得多。
参数说明:波特率对 CDC 无意义(CDC 是 USB 虚拟串口,速率由 USB 决定),但设一个值避免某些驱动报错。分包 512 字节是经验值,太小则包数多、握手开销大,太大则单包重传代价高。超时 500ms 要大于设备擦写一页 Flash 的时间,F1 擦一页约 20~40ms,留足余量。
4.3 传输耗时估算与提速手段
40KB 固件、512 字节一包,共 80 包。每包往返按 5ms 算,约 0.4 秒,加上擦写和搬运,整体 3~5 秒能完成。如果慢到几十秒,通常是三个原因:分包太小(改成 1KB 或 2KB)、每包都等 ACK 且超时设太长、Flash 擦写没按页对齐导致重复擦除。提速优先调分包大小,其次检查擦写逻辑。
5. OTA 避坑排查:五个让设备变砖的真实场景
这一章全是血泪经验,每条都按「现象 → 原因 → 解决」写,照着排查能省下大量返工。
5.1 现象:下载成功但跳转后立刻 HardFault
原因:App 的向量表偏移没设,或者链接脚本起始地址没改,导致中断向量还指向 Bootloader 区。解决:确认 App 工程VECT_TAB_OFFSET和链接脚本 ROM 起始都是 0x08004000,并在main开头手动设一次SCB->VTOR。
5.2 现象:升级到一半断电,设备再也起不来
原因:采用了「边收边写运行区」方案,运行区被擦了一半。解决:改用暂存区方案,运行区只在校验通过后才擦写。已经变砖的用仿真器重新烧 Bootloader 和 App 恢复。
5.3 现象:USB 枚举成功但收数据丢包
原因:CDC 接收回调里做了 Flash 擦写,阻塞太久导致 USB 缓冲区溢出。解决:接收回调只往 RAM 环形缓冲区塞数据,擦写放到主循环里做,两者解耦。
5.4 现象:CRC 校验总是失败,但数据看着没错
原因:上位机算 CRC 的范围和设备不一致,比如上位机算了整个文件,设备只算了有效长度,或者字节序反了。解决:明确约定 CRC 覆盖范围(建议是纯固件数据,不含协议头),统一用小端,双方用同一组测试向量验证。
5.5 现象:升级后 App 能跑但串口/定时器全乱
原因:Bootloader 里初始化了时钟和外设,跳转前没复位,App 再初始化时冲突。解决:跳转前调用HAL_RCC_DeInit,并关闭所有已开中断和 SysTick,让 App 从干净状态启动。
6. 进阶验证:用双区交替升级把「变砖」概率压到零
前面讲的暂存区方案已经能应对大多数场景,但如果产品对可用性要求极高——比如医疗或工业现场设备,停机就是损失——可以再进一步做双区交替升级(A/B 分区)。思路是:Flash 里划出两个等大的 App 区,Bootloader 记录当前运行的是哪个区,新固件永远写到另一个区,校验通过后只改一个「启动标志」,下次上电就跳到新分区。这样即使新固件有问题,改回标志就能回滚到旧版本,全程不擦除正在运行的固件。
实现上,Bootloader 里维护一个小的配置区(单独一页 Flash),存当前活动分区号和两个分区的固件 CRC。跳转逻辑变成:
typedef struct { uint32_t magic; // 0x5A5A5A5A 表示配置有效 uint32_t active_slot; // 0 或 1 uint32_t crc[2]; // 两个分区的固件 CRC } ota_config_t; void boot_select(void) { ota_config_t *cfg = (ota_config_t *)CONFIG_ADDR; if (cfg->magic != 0x5A5A5A5A) { jump_to_app(SLOT0_ADDR); // 配置无效,默认跑 0 区 return; } uint32_t addr = cfg->active_slot ? SLOT1_ADDR : SLOT0_ADDR; if (verify_crc(addr, cfg->crc[cfg->active_slot])) { jump_to_app(addr); } else { // 当前分区坏了,尝试另一个 uint32_t other = cfg->active_slot ? SLOT0_ADDR : SLOT1_ADDR; if (verify_crc(other, cfg->crc[!cfg->active_slot])) { cfg->active_slot = !cfg->active_slot; flash_write_config(cfg); jump_to_app(other); } // 两个都坏,停在 Bootloader 等待升级 } }逻辑说明:magic用于判断配置区是否被初始化过,首次上电或配置损坏时默认跑 0 区。active_slot是当前活动分区,升级时写到另一个分区,写完更新crc和active_slot。启动时先校验当前分区,坏了自动切到另一个,两个都坏才停在 Bootloader。这套逻辑把「变砖」概率压到接近零,代价是 Flash 要够大,两个 App 区加 Bootloader 加配置区,64KB 的芯片基本不够用,建议 128KB 起步。
验证方法上,我一般做三组测试:第一组正常升级,确认新固件能跑;第二组升级后故意改坏新分区的一个字节,确认能自动回滚;第三组升级中途拔 USB,确认旧分区不受影响。三组都过,这套 OTA 才算能上量产。
最后说个习惯:每次改完分区或跳转逻辑,我都会先用一个「点灯 App」验证——App 里只做一件事,翻转一个 GPIO,用示波器看波形。点灯能稳定跑起来,再往里加业务代码。这个习惯帮我省下过无数次「到底是 OTA 问题还是业务问题」的扯皮。希望帮到你。
本文还有配套的精品资源,点击获取