简介这是一套面向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 三区划分与地址对齐原则以常见的 STM32F103C864KB Flash为例一个稳妥的划分是区域起始地址大小作用Bootloader0x0800000016KB常驻负责 USB 接收与跳转App 运行区0x0800400040KB实际业务固件下载暂存区0x0800E0008KB接收新固件校验后再搬运划分要遵守三条硬规则。第一每个区的起始地址必须落在 Flash 页边界上STM32F1 的页大小是 1KBF4 是 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 时钟、改了 PLLApp 再初始化时钟就会冲突表现为「跳过去后串口波特率全乱」。参数说明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 是 1KBF4 常见 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 字节头加变长数据字段长度说明SOF1固定 0x5ACMD1命令字如 0x01 握手、0x02 数据、0x03 结束SEQ2包序号用于重传判断LEN2数据长度小端DATAN负载CRC162从 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, timeout0.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:off512] 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 问题还是业务问题」的扯皮。希望帮到你。本文还有配套的精品资源点击获取