很多做嵌入式开发的朋友一听到AB OTA第一反应是那不是Linux/Android大系统才玩得起的方案吗STM32F103这种72MHz、几十KB Flash的小单片机折腾这个有意义吗实际上恰恰相反正因为资源紧张AB双分区OTA在STM32F103上反而更能逼你把底层机制吃透。这篇教程就是标题里写的“从零复现”——不依赖任何现成OTA库Bootloader自己写、传输协议自己定、上位机脚本自己敲把整个升级链路完整跑通。这期内容特别适合三类人一是产品里已经在用STM32F103想把“现场升级固件”这个功能落地的工程师二是学完标准库、想进阶理解Flash分区、中断向量表、固件引导这些底层知识的同学三是对OTA原理半懂不懂想找个最小可复现工程来拆解的嵌入式爱好者。我会按实际踩坑的顺序来讲不绕弯子尽量让每个步骤都能照着做。1. AB OTA是什么为什么值得在STM32F103上复现1.1 AB双分区OTA的基本原理AB OTA的全称是A/B Slot OTA核心思路是把片内Flash划分成两个大小相同的应用区分别叫Slot A和Slot B。当前正在运行的固件放在其中一个槽位另一个槽位作为升级目标。升级时新固件被完整写入非活动槽位校验通过后Bootloader通过标志位或版本信息决定下次启动跳到哪个槽位。这里最关键的一点是升级过程完全不碰当前正在运行的固件。哪怕升级写了一半掉电、传输中断、校验失败最坏情况就是新槽位数据不完整Bootloader检测到CRC不过依旧启动旧槽位。这就是AB方案比传统“单一应用区Bootloader覆盖升级”安全得多的地方。传统方案一旦写坏MCU直接变砖只能拉串口重新烧录。做过现场维护的人都知道设备变砖再返修的成本有多高。在STM32F103这种片内Flash只有64KB/128KB的芯片上做AB分区最大的挑战不是逻辑复杂而是空间紧张。Bootloader、两个应用槽位、标志区都要塞进64KB每一KB都得精打细算。但也正因为紧凑你会发现所有代码都必须写得很克制这种“被空间逼着优化”的过程比在大容量芯片上抄代码有价值得多。1.2 一套适合从零复现的简化设计完整版的AB OTA在不同平台上有各种变体比如Android里的A/B无缝更新、基于USB CDC的DFU、基于Ymodem的传输协议等。如果一上来就照搬大系统的方案很容易被复杂的启动状态机、回滚策略、日志记录淹没。我做这套复现时刻意把设计收敛成了三个核心环节Bootloader负责两件事扫描A/B两个槽位校验固件头与CRC选择有效且版本新的槽位启动通过串口接收上位机发来的固件包写入非活动槽位。应用固件只需要做一件事启动早期重定位中断向量表然后正常运行自己的业务逻辑。上位机脚本负责把编译出来的bin文件分包发送通过串口与Bootloader完成握手、传输、校验、重启这一串动作。这个设计把AB OTA的精髓压缩到了“串口升级引导”这一个场景。等你把这套跑通再想移植到网口、CAN、SPI Flash、4G模组其实只是换个传输层的事核心的Flash管理逻辑完全可以复用。2. Flash空间规划与固件升级协议设计2.1 以STM32F103C8T6为例的分区表STM32F103C8T6是最常见的“蓝板”主控片内Flash 64KB每页1KB。在做AB分区前必须先明确一个原则所有分区起始地址必须按页对齐。原因很简单STM32F103的Flash擦除最小单位是一页1KB如果应用区起始地址不在页边界上擦除时就会误伤相邻区域的数据。我实测下来比较适合教学的分区方案是Bootloader占16KB0x08000000~0x08003FFFApp A占20KB0x08004000~0x08008FFFApp B占20KB0x08009000~0x0800DFFF最后8KB0x0800E000~0x0800FFFF作为标志与备份区。地址计算很直观Bootloader起始0x080000000x08004000就是偏移16KBSlot B在0x08009000就是偏移36KB标志区0x0800E000偏移56KB。这里要提醒一下20KB的App空间对STM32F103来说并不宽裕。如果你的业务代码加协议栈超过了20KB要么换用RCT6256KB Flash或ZET6512KB Flash这类更大容量的芯片要么压缩分区重新分配。判断固件大小的方法很简单编译后打开Keil生成的map文件看最后几行的RO Data、RW Data、ZI Data总和或者直接看bin文件体积。map文件里Program Size各项的含义网上讲烂了核心就是总占用不能超过你给App分配的空间。2.2 固件头与CRC校验设计固件不是裸的二进制直接写进Flash就完事Bootloader必须知道这个固件“是什么版本、多长、校验对不对”。所以我在每个槽位起始位置放了一个固定长度的固件头结构如下typedef struct { uint32_t magic; // 魔数固定为0xA5A5A5A5 uint32_t version; // 版本号高版本优先启动 uint32_t length; // 固件数据长度不含头部 uint32_t crc32; // 固件数据CRC32校验值 uint32_t reserved; // 保留字段 } firmware_header_t;magic是用来快速判断这个槽位有没有写过固件。如果读到的magic不是0xA5A5A5A5说明这一槽是空的或者数据损坏Bootloader直接跳过。version用来在A/B两个槽都有效时决定启动谁。CRC32覆盖的是固件头之后的所有数据校验失败就认为固件不完整不允许启动。这里有个细节值得展开CRC32我建议用软件查表法实现而不是用STM32F103自带的硬件CRC外设。虽然F103参考手册里确实有CRC计算单元但它的数据寄存器是32位的处理流式数据需要频繁搬运和拼接反而不如软件查表方便。另外软件实现的可移植性更好后面你把Bootloader挪到别的MCU上这段代码可以直接带走。CRC32多项式用标准的多项式0x04C11DB7初始值0xFFFFFFFF输出异或0xFFFFFFFF。2.3 自定义串口升级协议帧格式很多现成方案会用Ymodem或者Xmodem协议传固件但我还是建议自己定义一个精简协议。一是Ymodem的ACK/NAK时序逻辑对新手理解不太友好出了问题不好排查二是自定义协议可以按需设计帧格式足够简单几百行状态机代码就能搞定非常适合“从零复现”的调性。我定义的串口帧格式是帧头2字节0xAA 0x55命令字1字节0x01握手0x02开始升级0x03传输数据0x04结束升级0x05复位跳转数据长度2字节小端模式帧序号2字节从0递增数据域N字节CRC162字节对帧头之后所有字段做CRC16校验每次上位机发一帧Bootloader校验通过后回一个ACK0x06校验失败回NAK0x15。如果上位机2秒内没收到ACK就重发当前帧连续3次NAK则中断升级。这个机制虽然简单但已经能保证串口传输的可靠性。为什么数据域用CRC16而不是CRC32因为传输校验和固件完整性校验是两个层面的东西。传输层用CRC16做实时校验速度快、计算量小固件完整性用CRC32做最终确认防止传输正确但固件本身有问题的情况。分层校验的思路在做工程时很重要不要把所有校验都堆在一个环节里。3. Bootloader 与应用固件的完整实现3.1 Bootloader 启动流程与跳转代码Bootloader的main函数逻辑比想象中简单核心就两步。第一步调用firmware_check_all_slots()扫描A/B槽位的固件头找出当前应该启动哪个槽位。第二步根据结果要么跳转到App要么进入串口升级模式。我建议让Bootloader上电后先等待一小段时间比如200ms判断上位机有没有发送握手命令。如果有就进入升级模式如果没有直接启动有效的App槽位。这样做的好处是升级不需要额外的物理按键上位机随时可以抢占总线进入升级流程。跳转函数是整个Bootloader的灵魂我贴一段实际能跑的代码typedef void (*pFunction)(void); void jump_to_application(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); // 栈顶指针必须在RAM范围否则说明固件头非法 if ((app_sp 0xFFF00000) ! 0x20000000) { return; } __disable_irq(); // 恢复时钟到默认状态关闭SysTick RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 清空中断使能与挂起标志 for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } // 设置MSP重定位向量表跳转 __set_MSP(app_sp); SCB-VTOR app_addr; ((pFunction)app_pc)(); }这段代码里的每个操作都有对应的问题。跳转前关全局中断、清空NVIC是为了防止Bootloader时期残留的串口中断或定时器中断在App启动瞬间触发导致程序跑飞。恢复时钟到默认状态是为了避免Bootloader配好的72MHz时钟和外设状态影响App自己的SystemInit初始化。设置MSP为App的栈顶指针这一行尤其关键因为Cortex-M3上电后使用的是主栈指针如果不覆盖成App的栈顶App的局部变量、函数调用栈会直接错乱。我当时第一次写跳转函数就是忘了关NVIC里的中断结果App刚进入main被一个残留的串口中断打飞卡在HardFault里查了半天。这种问题只要你理解了“Cortex-M3中断是向量表驱动”的原理其实完全可以避免。3.2 应用工程改造向量表重定位与链接配置App工程如果不做任何配置编译出来是默认链接到0x08000000的。一旦Bootloader从0x08004000跳过来第一个取到的栈顶指针就是错的程序根本跑不起来。所以App工程必须做两处修改一是编译器链接地址改到0x08004000二是在启动早期重定位中断向量表。在Keil MDK里链接地址修改很简单Options for Target - Target页签 - IROM1起始地址改成0x08004000大小改成0x5000十进制20480即20KB。这个配置的本质是告诉链接器“我的代码要放在这个地址段”编译出来的bin文件自带地址信息跳转过去才能找到正确的向量表。系统烧录Bootloader时一定不要忘记把App的bin文件烧录到0x08004000烧录到0x08000000就会覆盖Bootloader这是新手最容易搞混的地方。更准确地说应该使用J-Flash或STM32CubeProgrammer把App bin文件的起始地址写到0x08004000。而Bootloader编译出来默认地址就是0x08000000烧录不需要特殊设置。向量表重定位则是代码层面的事在App进入main的第一行执行int main(void) { SCB-VTOR APP_ADDR; // APP_ADDR 为 0x08004000 // ... 正常业务代码 }有的教程会建议把SCB-VTOR这行放到SystemInit函数里理由是“越早设置越好”。但实测下来只要在第一次外部中断使能之前设置好就够了放在main开头完全可行。需要注意的坑是stm32f10x.h标准库里SCB-VTOR这个寄存器定义在较新的CMSIS版本才有如果你用的是老版本标准库可能需要手动加上这个寄存器定义。3.3 Flash擦写与掉电保护的常见做法固件接收下来要写入FlashSTM32F103的标准库提供了FLASH_ProgramHalfWord和FLASH_ErasePage这两个函数但直接调用会遇到两个问题一是Flash操作前必须先解锁二是每次写操作都应该是16位半字对齐。我封装了一个按页写入的函数void flash_write_page(uint32_t page_addr, uint8_t *data, uint32_t len) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); FLASH_ErasePage(page_addr); uint32_t i; for (i 0; i len; i 2) { uint16_t half_word data[i] | ((uint16_t)data[i 1] 8); FLASH_ProgramHalfWord(page_addr i, half_word); } FLASH_Lock(); }这个函数是按页擦除后整页写入前提是len必须为偶数且page_addr和传入的数据长度必须是页对齐的。实际项目里我一般会让上位机按1KB分片发送正好一包对应一页这样Bootloader接收到一包后直接擦写一页不需要在RAM里缓存整块固件。要知道STM32F103C8T6的RAM只有20KB如果固件有几十KB一口气收完再写Flash直接就把内存挤爆了。掉电保护的设计也值得展开。我在写固件头的时候做了个小技巧先在目标槽位起始处写入一个特殊的“升级中”标记全部数据写完并校验通过后再把完整的firmware_header_t写入。这样就算升级过程中掉电Bootloader检测到魔数不是0xA5A5A5A5会判定该槽位无效自动回滚到另一个槽位绝不会出现“固件头是好的但数据不完整”这种危险状态。3.4 上位机升级脚本编写思路上位机建议用Python加pyserial库代码量不到100行非常适合复现。核心逻辑分四步打开串口、发送握手命令等待ACK、读取bin文件分包发送、发送结束命令等待设备重启。import serial import struct import zlib port serial.Serial(COM5, 115200, timeout1) def send_frame(cmd, seq, payload): crc 0xFFFF for b in bytes([cmd]) struct.pack(HH, seq, len(payload)) payload: crc ^ b for _ in range(8): crc (crc 1) ^ 0xA001 if crc 1 else crc 1 frame b\xAA\x55 bytes([cmd]) struct.pack(HH, seq, len(payload)) payload struct.pack(H, crc) return frame # 握手 port.write(send_frame(0x01, 0, b)) ack port.read(1) if ack ! b\x06: print(handshake failed) # 读取bin firmware open(app.bin, rb).read() header struct.pack(IIII, 0xA5A5A5A5, 0x00010001, len(firmware), zlib.crc32(firmware)) # 先发头部信息再分包发送数据这里要注意zlib.crc32返回的是Python整数直接struct.pack时需要保证无符号可以用zlib.crc32(firmware) 0xFFFFFFFF处理。还有一点bin文件的大小必须是偶数如果不是读出来之后要手动填充一字节0x00否则flash_write_page函数里的半字循环会漏写最后一位。等到整个升级流程走通你再看上位机这几十行代码会发现真正有含金量的不是脚本本身而是上位机和Bootloader之间那套约定的协议。协议对上了脚本多简单都行。4. 从零复现中的常见问题与排查技巧4.1 现象跳转后App完全没反应这个是复现AB OTA时出现频率最高的问题没有之一。我排查过的案例里面80%都是以下三种原因一是Keil的IROM1地址没改App编译出来仍然链接到0x08000000跳过去执行的代码根本不是App二是跳转函数里没有设置SCB-VTORApp外部中断一来就进HardFault三是App的栈顶指针校验没写对把非法的SP当成合法地址执行。排查方法也很简单先用ST-Link连接芯片在Debug模式下把PC指针拉到跳转地址看看是不是落在0x08004000这个地址段。如果是再单步看第一条指令能不能正常执行。如果PC根本没跳过去八成是固件头读取的地址算错了。4.2 现象串口握手不通握手不通首先要区分是电平问题还是逻辑问题。STM32F103的串口是TTL电平如果你的USB转串口模块是RS232电平中间必须加MAX232转换芯片。我用过不少免驱的CH340模块这类模块输出的是TTL电平可以直接和STM32F103连接但要注意共地否则串口数据会乱码。排除掉硬件问题后检查Bootloader的串口初始化。标准库v3.5里的USART初始化配置有一个很隐蔽的坑GPIO_Mode要设置成GPIO_Mode_AF_PP复用推挽输出GPIO_Speed建议设置成GPIO_Speed_50MHz。有些同学习惯照抄别的工程把RX引脚配成了GPIO_Mode_IN_FLOATING这种浮空输入配置在波特率低的时候偶尔能用115200下很容易误码。4.3 现象CRC校验反复失败CRC校验失败第一反应不是查协议而是先确认你到底是“哪一段数据”的CRC算错了。我见过最典型的错误是上位机把整个bin文件的CRC算出来但Bootloader收到数据时是分包处理的每包都有自己的帧序号和长度域Bootloader校验的是帧内字段的CRC16而不是整个固件的CRC32。这两者一旦混用必然校验失败。所以一定要区分清楚传输层的CRC16校验的是每一帧的数据固件层的CRC32校验的是槽位里固件头之后的所有数据。如果你用的是软件查表CRC32还要注意多字节数据的字节序。STM32F103是小端模式上位机如果按大端发送校验结果会差得很远。4.4 常见问题速查表整理了一张我在复现和调试过程中反复用到的问题速查表遇到问题先来这里查一圈绝大多数都能解决。现象优先排查点解决思路跳转后死机IROM1地址、SCB-VTOR、MSP设置重新检查Keil配置和跳转函数顺序中断一触发就跑飞向量表没有重定位或设置过晚在main开头加SCB-VTOR确认是否第一次中断前串口收到乱码波特率、电平、共地用逻辑分析仪抓波形检查上位机COM口参数Flash写不进去未解锁、未清标志、地址错位执行FLASH_Unlock和FLASH_ClearFlag检查地址是否为半字对齐握手总是超时串口复用配置错误、Bootloader等待时间太短检查GPIO_Mode_AF_PP等待时间调整到200ms以上升级后仍然启动旧槽固件头写入时序错误、CRC算法不一致确认魔数和CRC覆盖范围检查是否写了“升级中”标记这张表并不保证覆盖所有情况但顺着这几条排查80%的问题都能定位到具体环节。4.5 复现清单与扩展方向最后给一套我从零复现的完整操作清单跟着顺序做基本不会乱准备硬件STM32F103C8T6最小系统板、ST-Link下载器、CH340串口模块、杜邦线若干。准备软件Keil MDK5、标准库STM32F10x_StdPeriph_Lib_V3.5.0、Python3并安装pyserial。第一步写Bootloader工程实现串口初始化、Flash读写、固件头解析、跳转函数编译烧录到0x08000000。第二步写一个最简单的App比如LED闪烁加串口打印版本号修改IROM1地址到0x08004000main开头设置SCB-VTOR编译生成bin。用STM32CubeProgrammer把App bin烧录到0x08004000上电确认从Slot A正常启动。改一行App版本号重新编译生成bin用Python脚本通过串口升级观察是否能写到Slot B并跳转启动。把Slot A的Flash内容手动擦掉一部分模拟损坏确认Bootloader能自动回滚到Slot B。如果按这个清单完整跑一遍你不仅掌握了AB OTA的复现方法还会对Cortex-M3的启动流程、Flash操作、串口状态机这些嵌入式基本功有一次系统性的梳理。我个人做完这套之后的体会是AB OTA并不神秘它其实就是“Bootloader引导多固件”这一底层思路在“可靠升级”这个需求下的一次升级。把起点放低到STM32F103的最小系统上复现一遍比在大容量芯片上直接调现成库要扎实得多。后续如果你想把升级通道从串口换成GPRS模组或者以太网核心逻辑基本不用动只需要替换传输层实现这大概就是“从零复现”给你留下的最大回报。