STM32F103,一颗卖了十几年的Cortex-M3单片机,到现在依然是无数产品的中枢。我接触过的不少项目里,它还在稳定跑着产线逻辑、通信网关和各类变流器控制。但这两年有个问题越来越绕不开——产品要联网、要迭代、要能远程修复bug。尤其当你需要给一批已经铺到现场的F103设备升级固件时,拿着J-Link挨个刷的办法根本就行不通。
所以我决定把这套基于STM32F103的AB双分区OTA方案完整复现出来,从零开始,不跳步。这套方案不依赖昂贵芯片,不用外挂Flash(当然要挂也能挂),只用STM32F103自带的Flash和串口就能跑通。AB分区的核心价值就是安全:升级失败还能回滚,永远有一个能跑的固件在兜底。本文适合正在做产品化、需要给设备加空中升级能力的嵌入式工程师,也适合刚接触Bootloader、想搞清楚AB分区和OTA完整链路的学生。我会从方案怎么选、Flash怎么分,一直到Bootloader怎么写、App怎么改、上位机怎么传,一条线拉通讲完,最后再分享我复现过程中踩过的坑。
1. 整体方案设计:为什么F103也能做AB分区OTA
1.1 先搞清楚AB分区到底解决什么问题
很多开发者第一次接触OTA,第一反应是“我只要一个Bootloader,把新固件下载到某个地址,然后跳过去不就行了”。这种做法在小型产品里确实常见,但它有一个致命的隐患:如果在下载过程中断电、传输中断、或者新固件本身就有bug,设备就变砖了。而AB分区也叫A/B Slot,核心思想是把Flash划出两个独立的固件区——A区和B区。当前运行的是A区,升级时就把新固件完整写入B区,写完校验通过后,下一次启动由Bootloader切换到B区。如果B区跑不起来,Bootloader自动回退到A区。
这个机制在汽车电子和Android系统里已经很成熟,但很多嵌入式开发者一听到AB分区,第一反应是“Flash不够用”。这种顾虑有道理,但对STM32F103来说,其实取决于你选哪个型号。比如STM32F103C8T6是64KB Flash,做AB分区确实紧张,但STM32F103RCT6有256KB,STM32F103ZET6有512KB,做AB分区绰绰有余。我的复现项目选用的是STM32F103RCT6,256KB Flash,把整个方案跑通,同时也会给出小容量芯片怎么选的建议。
1.2 为什么选串口/Ymodem作为传输方式
F103没有以太网控制器,也没有USB High-Speed,最常用的通信外设是USART、CAN、SPI和I2C。我在这个项目里选择USART加Ymodem协议作为OTA传输通道,原因有三个。第一,几乎每个F103产品都会引出至少一路串口,不少产品本身就带RS232或RS485接口,复用一路作为升级通道零成本。第二,Ymodem协议有CRC校验、有批传输、有文件结束标志,实现复杂度适中,和F103的64KB到512KB Flash搭配非常合适。第三,调试方便——用电脑上的SecureCRT、XShell、或者自己写的PyQt上位机,都能直接发文件,不需要额外硬件。
当然,如果未来产品升级,同一套Bootloader思路也能平滑迁移到CAN FD、以太网,甚至无线模块(比如LoRa、4G模组)上。我在设计通信层时做了抽象,上层只管收数据、写Flash,底层是串口还是其他外设,只影响这一层的实现而已。
1.3 双Bank、自适应擦写与断电保护
STM32F103的Flash有扇区(Sector)概念,但不同型号扇区大小不一样。F103RCT6的256KB Flash,从0x08000000开始,前4页每页1KB,然后是1页4KB,之后是1页16KB,再往后每页64KB。这个结构对AB分区划分影响很大,因为擦除操作必须整页擦,你不可能只擦一个扇区里的某几个字节。设计分区表时,必须先把Flash的物理页边界摸清楚,再决定Bootloader、A区、B区的起始地址和大小,否则擦写逻辑会很别扭。
断电保护也是这个方案的重点。如果新固件写到一半掉电,B区残留一个不完整固件,下一次启动时Bootloader的CRC校验会失败,直接回退到A区。A区没动过,设备还能正常运行,等下次有机会再重新升级。这种机制在实践中非常有用——用过单分区方案的朋友应该深有体会:升级过程中一旦断电,设备就彻底没反应了,只能开壳、接J-Link、救砖。
关于是否需要均衡磨损,也就是AB区轮流写入的磨损控制,我在这个项目里没有做。原因是F103的Flash擦写寿命标称是1万次,对于大多数设备来说,一年升级几次到几十次,足够用很多年。但如果你的产品升级非常频繁,比如每周一次,那建议考虑动态切换起始分区、写坏块标记,这是后话,本文不做展开。
2. Flash分区设计与Bootloader核心实现
2.1 我的分区表:256KB Flash这么分最省心
我做嵌入式有一个习惯:设计任何系统前,先把资源地图画出来。F103RCT6的256KB Flash,地址从0x08000000到0x0803FFFF。经过反复权衡,我最终使用的分区方案如下:
| 分区 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 启动、校验、升级逻辑 |
| App_A | 0x08008000 | 96KB | 当前稳定运行固件 |
| App_B | 0x08020000 | 96KB | 备用固件/新固件接收区 |
| 参数区 | 0x08038000 | 8KB | 启动标志、升级计数器、版本信息 |
| 预留 | 0x0803A000 | 24KB | 用户日志、出厂校准数据等 |
提示:分区时必须保证每个分区起始地址对齐到Flash的页边界。F103RCT6的Flash从0x08000000开始,前16KB是4个1KB页加1个4KB页加1个16KB页,再往后每页64KB。所以0x08008000正好落在64KB页的起始处,这是合法的页对齐地址。对齐的意义在于:擦除时不会误伤其他分区的数据。
App区选择96KB,是基于我的实际项目需求——一个带FreeRTOS、FatFS、Modbus协议栈、LCD驱动的固件,编译出来大约70KB,留出约25%余量。如果你的固件更大,可以适当缩小Bootloader到24KB,或者压缩参数区。如果是C8T6的64KB Flash,最多只能给A区20KB、B区20KB、Bootloader16KB,这要求固件必须精简,有的项目甚至得去掉RTOS才能塞下。
2.2 Bootloader要干的三件事:校验、跳转、升级
Bootloader的功能可以拆成三个核心环节:启动检查、固件跳转、升级接收。启动检查的逻辑是:上电后先读参数区的启动标志,如果标志位表示“新固件已就绪”,则对新固件区执行CRC32校验;校验通过,更新标志位,跳转;校验失败,清除标志位,跳转到另一个分区。如果标志位是“无升级请求”,直接跳转到默认运行分区。为了可靠,启动标志我使用了双字冗余——同一个标志写两次在参数区,读取时两次一致才有效,防止Flash写入过程中的bit翻转。
固件跳转的核心代码不长,但有一个关键点非常容易踩坑。跳转前必须把全局中断关掉,把SysTick停在当前状态,然后把MSP主堆栈指针设置为App分区首地址的前4字节内容,再把PC指针设置为App分区首地址的后4字节内容。F103的App固件中断向量表默认是在0x08000000,但你App烧在0x08008000,必须把SCB->VTOR设置为0x08008000,否则中断向量表对不上,一进中断就HardFault。跳转代码参考如下:
typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4); pFunction app_entry; __disable_irq(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; SCB->VTOR = app_addr; app_entry = (pFunction)app_pc; __set_MSP(app_sp); app_entry(); }提示:跳转前不仅要去初始化外设。我在实际调试中发现,如果Bootloader里初始化过DMA、串口或定时器,跳转前必须将这些外设复位,否则App启动时会遇到外设状态异常。最省事的做法是跳转前调用RCC->AHBRSTR、RCC->APB1RSTR、RCC->APB2RSTR把涉及到的外设全部复位一遍。
2.3 固件校验:CRC32一定要算对
固件校验我选择了CRC32。STM32F103内部自带一个CRC外设,硬件算起来很快,但它固定使用多项式0x04C11DB7,初值为0xFFFFFFFF,输出不取反。如果你上位机用标准CRC32(比如zlib的crc32),算出来的结果和硬件CRC外设不一致,直接校验失败。这个问题我调试了一下午才找到原因,后来干脆不用硬件CRC,改成在Bootloader里软件实现标准CRC32,和上位机保持一致。这样也方便以后换芯片平台,代码直接复制。
软件CRC32实现网上很多,我使用的是查表法,256项表,速度很快。对一个96KB的固件区做全量CRC校验,在72MHz主频下大约耗时200ms到300ms,完全可接受。校验时机放在启动时做一次全量校验,而不是只校验几个标志字,这能确保固件区数据的完整性——有时候Flash内容会因为写Flash时的电压不稳发生个别字节错误,只有全量校验才能发现。
升级流程放在Bootloader里做,接收一包数据、写入Flash、继续接收,直到收完整个文件,做一次最终CRC校验。注意F103擦写Flash期间CPU会暂停,所以串口接收需要用中断或DMA把数据先缓存到RAM。我在实现时开了双缓冲,一个缓冲在写Flash,另一个缓冲在接收串口数据,这样整个升级过程可以做到无缝衔接。缓冲区大小设定为2KB,和Ymodem的128字节包或者1KB包都能对齐。
3. App端改造与固件生成:链接脚本是第一道关
3.1 修改Linker Script让App跑在0x08008000
很多第一次做OTA的朋友,写好了Bootloader,跳转也正常,但App一跑就HardFault,问题多半出在链接脚本没有改。你在Keil里新建工程时,默认的Linker脚本把Flash起始地址设为0x08000000,大小设为256KB。如果App烧在0x08008000却仍然按0x08000000链接,中断向量表和代码里的绝对地址引用全部错位,当然跑不起来。
以Keil MDK为例,修改方法有两种。我推荐直接改分散加载文件(.sct),在Options for Target -> Linker -> Edit里,把加载域和运行域的起始地址改成0x08008000,大小改成0x00018000(96KB)。改完之后重新编译,用生成的.axf或.hex文件转成.bin,就能看到bin文件大小能反映App实际占用空间。如果你用的是IAR或者GCC,思路一样——改链接脚本的FLASH起始地址和长度。
改完链接脚本后,App里的中断向量表也要搬。STM32F103在上电时会从0x08000000读取初始SP和PC,但如果你是Bootloader跳转到App,Bootloader已经运行过了,中断向量表还在0x08000000,不重映射的话,App里的任何中断(串口、定时器、GPIO外部中断)都会跑飞。解决方法是App启动早期调用:
SCB->VTOR = 0x08008000;注意:SCB->VTOR是Cortex-M3内核的标准寄存器,F103上可以直接用。但有些从STM32F1移植到F0或L0系列的朋友会发现没有这个寄存器,那是Cortex-M0的差异,需要特殊处理。本文基于F103,直接用VTOR即可。
3.2 App要通知Bootloader“我活着”
AB分区比单分区多了一个关键机制:运行确认。Bootloader跳转到App后,App需要在一定时间内(比如10秒)往参数区写入一个“运行正常”标志。如果App因为自身bug启动失败、卡死在初始化阶段,Bootloader在下一次启动时会发现这个分区没有被确认过,就会自动回退到另一个分区。
这个机制的实现要点是:参数区专门分出一个字节作为运行确认位。Bootloader跳转前把确认位清零;App启动后完成关键初始化,再置位确认位;Bootloader下次上电时检查确认位,如果当前运行分区没有确认,就切换启动另一个分区。为了保险,我把确认位也做了双写冗余,防止单字节翻转导致误判。
有一个细节需要注意:App启动到确认位置位之间的时间不能太长,否则Bootloader会以为App没跑起来。一般放在系统时钟初始化完成、Flash读写校验正常、关键外设初始化完成之后,就可以置位了。如果RTOS或其他慢速外设初始化放在后面,不影响这个确认的时效性。
3.3 生成bin文件并管理版本号
Keil MDK生成.hex文件很方便,但Bootloader里做OTA最好用.bin文件,因为它没有地址信息,纯粹是数据流,便于按偏移写入。Keil里在After Build的User页签添加一条命令:
fromelf --bin --output .\Output\app.bin .\Output\app.axf构建后自动生成app.bin。上位机读取这个bin,加上一个自定义的文件头(魔数、版本号、固件大小、CRC32),再按Ymodem协议发送。文件头设计如下:
| 字段 | 大小 | 说明 |
|---|---|---|
| 魔数 | 4字节 | 0xAA55A55A,用于识别合法固件包 |
| 版本号 | 2字节 | 主版本/次版本,Bootloader可据此判断是否需要升级 |
| 固件大小 | 4字节 | 有效固件字节数 |
| CRC32 | 4字节 | 全固件数据的CRC32校验值 |
| 固件数据 | N字节 | 实际固件内容 |
App固件里维护一个全局版本号变量,或者是编译时自动生成的git commit号。Bootloader在升级前读取包头版本号,和当前运行分区记录里的版本号比较,同版本不发。这样能避免用户反复升级同一个固件,浪费Flash擦写寿命。
4. 上位机与Ymodem通信:从0到1写一个能用的下载工具
4.1 Ymodem协议要点
Ymodem协议是从Xmodem扩展来的,支持文件名传输和批处理。它的基本传输流程是:发送方先发一个128字节的块,块0包含文件名和文件大小;接收方收到后回复ACK;然后发送方按128字节或1024字节的块依次发送数据;文件结束后发送EOT,接收方回复ACK后再发送一个全0块表示结束。
我选择在Bootloader里实现Ymodem接收端,因为Ymodem的CRC校验足够强大,而且有成熟的协议流程。实现过程不算复杂,但几个细节必须处理对:块号是1到255循环的,块号0用于文件头;每个块用CRC16校验,多项式0x1021,初值0x0000,这在Ymodem协议里叫CRC16-CCITT;接收方收到NACK表示要求重发,收到ACK表示正确接收。
4.2 一个基于Python的上位机实现
我在项目里用Python写了一个简易版OTA上位机,核心功能就是通过串口发送bin文件到设备。PyQt版本的可视化界面留到以后分享,这里先给一个命令行版本,方便大家跑通流程。依赖只有pyserial和tqdm,安装没什么坑。
import serial import time import struct import zlib from tqdm import tqdm def crc16_ccitt(data): crc = 0x0000 for byte in data: crc ^= byte << 8 for _ in range(8): crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF return crc def build_frame(seq, payload): frame = b'\x01' + bytes([seq & 0xFF, (256 - seq) & 0xFF]) + payload frame += struct.pack('>H', crc16_ccitt(payload)) return frame def send_ota(port, baudrate, bin_path): with open(bin_path, 'rb') as f: fw_data = f.read() header = b'\xAA\x55\xA5\x5A' + struct.pack('>H', 0x0100) \ + struct.pack('>I', len(fw_data)) + struct.pack('>I', zlib.crc32(fw_data)) total_data = header + fw_data ser = serial.Serial(port, baudrate, timeout=1) time.sleep(0.2) # 发送C握手字符,等待Bootloader应答 ser.write(b'C') time.sleep(0.2) resp = ser.read(1) if resp != b'C': print('bootloader not ready') return # 发送块0:文件名和大小 filename = bin_path.split('/')[-1].encode() zero_payload = filename + b'\x00' + str(len(total_data)).encode() zero_payload += b'\x00' * (128 - len(zero_payload)) ser.write(build_frame(0, zero_payload)) resp = ser.read(2) if resp[:1] != b'\x06': print('header ack failed') return ser.write(b'\x06') # 再ACK time.sleep(0.1) # 按1024字节发送数据块 seq = 1 for offset in tqdm(range(0, len(total_data), 1024), desc='OTA'): chunk = total_data[offset:offset + 1024] if len(chunk) < 1024: chunk += b'\x00' * (1024 - len(chunk)) ser.write(build_frame(seq, chunk)) resp = ser.read(1) if resp == b'\x15': ser.write(build_frame(seq, chunk)) # 重发 resp = ser.read(1) seq += 1 # 发送EOT结束 ser.write(b'\x04') time.sleep(0.2) ser.write(b'\x04') time.sleep(0.2) ser.close() print('OTA done')这个脚本对Windows和Linux都适用,唯一要注意的是串口号写法,Windows是COM3,Linux是/dev/ttyUSB0。实际传一个70KB的固件,在115200波特率下,大约耗时15秒到20秒,比J-Link烧录慢不少,但胜在不需要打开外壳。
4.3 Bootloader端Ymodem接收的注意点
Bootloader端实现Ymodem接收时,我遇到过最麻烦的一个问题是握手。Ymodem协议规定接收方要先发一个字符‘C’(0x43),表示具备CRC校验能力。但有些上位机工具(比如SecureCRT)发送Ymodem前会先等接收方发‘C’。我调试时抓串口数据才发现,双方都在等对方先说话,造成假死。
解决方法是Bootloader端上电后不管有没有升级请求,先主动延时100ms再发一个‘C’;如果没有收到数据,再发一个‘C’;连续发三次后进入超时跳转逻辑。这样上位机就不会蒙圈。另外,串口中断接收时,每收到一个字节后要判断帧边界,Ymodem帧头是SOH(0x01)表示128字节块,STX(0x02)表示1024字节块,根据帧头类型决定后续接收长度,不能固定按1024字节接收,否则会把SOH后的控制字节当作数据。
5. 三套方案对比:AB分区、回滚指针、双Bank
5.1 为什么最终选了AB而不是回滚指针
在嵌入式OTA设计里,除了AB分区,还有一种常见方案:回滚指针。这个方案的核心是只保留一个App区,升级时把旧固件备份到一个独立备份区,升级失败时用备份区恢复。听起来也不错,但它有两个痛点:一是备份区的Flash开销和AB分区几乎一样,并没有省Flash;二是恢复到备份区的动作需要较长时间,设备在恢复期间无法对外服务,如果这时候断电,系统可能处于中间状态,变砖风险更高。AB分区切换只需要Bootloader修改几个标志字节,几乎瞬时完成,安全性更好。
做产品不能只看“能不能升级”,还要看“升级失败怎么办”。AB分区在Flash利用率上略低,但换来了“永远有一个可启动系统”的确定性。这个确定性对产线、对用户、对售后都很重要——不用开壳,不用返厂,远程就能自救。
5.2 STM32F103有没有硬件双Bank
有些新系列MCU(比如STM32F7、H7)支持硬件双Bank,可以一边跑程序一边擦写另一个Bank。但STM32F103不支持,它只有单一Flash阵列。所以在F103上做AB分区,必须要软件分区,并且在升级App_B时,CPU需要从App_A执行,因为Flash在写期间无法同时取指。
如果你把Bootloader也放到App_A前面,那么升级App_B时Bootloader可以先执行升级流程,App_A只是待命。但如果Bootloader的空间已经很小,升级逻辑比较复杂,也可以把升级逻辑放到App_A的一部分区域,这叫“A升级B、B升级A”的交互模式。我目前的方案是升级逻辑全部放在Bootloader里,App只管运行和运行确认,结构更清爽,也方便长期维护。
5.3 小容量F103怎么办:裁剪策略
如果你的项目用的是STM32F103C8T6(64KB Flash),做AB分区确实只能算“硬扛”,但也不是完全不行。最极端的分配方式是Bootloader 16KB、A区20KB、B区20KB、参数区4KB、剩4KB备用。这样你的App固件必须控制在20KB以内,意味着不能跑太重的RTOS,驱动也得精简,LCD字库要放到外部存储器。我在一个简单的温控器项目上验证过这种裁剪方案,固件压缩到18KB左右,功能上能接受。
如果项目复杂,我的建议是优先换芯片,比如F103RCT6或者F103RET6,成本差异很小,但开发体验完全不是一个量级。特别是如果固件用到浮点运算、文件系统、加密算法,96KB分区刚刚好,64KB会一直处于“删了加、加了删”的尴尬状态。
6. 常见问题与排查技巧实录
6.1 跳转后立刻HardFault,这个坑我栽了三次
跳转到App后立刻进入HardFault,原因至少有三种:栈指针不对、向量表偏移没设置、App在编译时链接地址没改。我栽得最深的一次是前两个都对,但App里用了微库(MicroLIB),初始化时访问了位于0x08000000的默认向量表,结果跳飞。排查思路是用调试器在HardFault_Handler里打点,查看PC寄存器的值和LR寄存器的值,如果PC值是0xFFFFFFxx或0x08000000开头的低地址,基本就是向量表或栈指针问题。
我在Bootloader的跳转函数里加了一行防御性代码:跳转前检查app_sp是否落在0x20000000到0x20010000范围内(F103RCT6的RAM范围),如果超出,直接判定App固件无效,不跳转。这个检查成本极低,但能挡住80%的错误固件包。
6.2 OTA传输到一半失败,如何断点续传
有一次我在测试时发现,升级到50%左右,串口通信忽然卡住,超时后Bootloader重启,设备还是老固件在运行。问题出在Bootloader接收缓冲区溢出。我原来只准备了1KB的接收缓冲,但Ymodem一次最大发1024字节,加上帧头和CRC校验,正好超过1KB,导致最后几个字节丢包。后来把缓冲区改成2KB,并采用双缓冲切换,写Flash的同时继续收下一包,问题就解决了。
关于断点续传:如果传输中途失败,Bootloader应该允许上位机从失败点重新发起整个升级流程,而不是要求设备必须回到出厂状态。因为AB分区里有另一个分区在正常运行,重复升级失败不会影响设备功能。我实现的方式是Bootloader在参数区记录已接收字节数,上位机如果检测到中断,重新握手后Bootloader读到断点,继续从断点接收。这个功能在弱网、易干扰的工业现场非常实用。
6.3 启动时CRC校验为什么莫名其妙失败
CRC校验失败的典型案例是:固件升级完成,CRC刚才明明校验通过了,但设备重启后Bootloader再校验一遍却失败。排查下来发现是升级完成后、设备重启前,App往参数区写了运行确认标志,但写Flash时擦除页的操作覆盖了一小段固件区数据。原因就是分区表起始地址和Flash页边界没有对齐,擦除范围越界了。
所以分区表设计时必须画好页边界图,App_B结束地址到参数区起始地址之间要留出至少一个页的空白。不能为了省空间把分区紧密贴合,否则擦除参数区时会连带擦掉固件区尾部的数据,这样的bug在实验室环境里很难复现,但到现场用户手里几乎必炸。
6.4 快速排查速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 跳转后PC值异常 | App链接地址未改 | 检查.sct分散加载文件 |
| 进不了串口中断 | SCB->VTOR没设置或设置值错误 | App启动早期设置VTOR |
| Ymodem握手失败 | 双方都在等待对方发C | Bootloader主动延时后发C,连续发3次 |
| 升级到一半卡死 | 缓冲区太小或单缓冲冲突 | 改用双缓冲,Buffer加大到2KB |
| 重启后CRC失败 | 分区表未对齐页边界 | 重新检查Flash分区对齐 |
| 升级后不能回滚 | 运行确认位未实现 | 加运行确认标志与回退逻辑 |
| 擦写Flash时中断丢失 | 串口中断优先级配置不当 | 提中断优先级,擦除前缓存数据 |
7. 扩展方向:从串口OTA到无线OTA
串口OTA跑通之后,这套架构的通信层是可以替换的。我在另一个项目中,把串口驱动换成了一颗SX1268 LoRa模块的SPI驱动,AIr接口上跑的还是Ymodem帧结构,只改了物理层和数据链路层的适配。Bootloader的Flash写入、分区切换、校验逻辑一行没动。这就是分层设计的好处。
如果是ESP32之类的Wi-Fi模组,OTA的速度和体验会好很多。但STM32F103在很多工业设备里依然占有重要地位——它的稳定性、成熟度、外设丰富程度,依然是很多产品经理的首选。给F103加上AB分区OTA能力,相当于给这些老设备续上了远程维护的生命线。
我个人实际操作中的最深体会是:做OTA不只是写代码,更重要的是把“升级失败也能活”这套状态机想清楚。AB分区真正的价值不在平时升级成功那一秒,而在升级失败、断电、串线、手滑这些意外发生的时候——你还能保住设备。这套方案我从设计到跑通,前后大概花了一个周末加三个晚上,其中一半时间都在和Flash扇区边界以及CRC变体过不去。如果你照着这份教程做,应该会顺利很多。