简介:面向STM32F4嵌入式开发者,提供一套基于串口/485总线的OTA在线升级方案,包含自制Bootloader与应用程序两个完整工程,适用于设备远程固件更新、现场维护及批量产线烧录等场景。包内共277个文件,以C源码与头文件为主,包含88个.c源文件和90个.h头文件,另有Keil工程配置文件、链接脚本、存储器映射文件、编译生成的axf/bin/hex固件,以及一键清理中间文件的批处理脚本,整体仅2.86MB,结构清晰易查。Bootloader工程细致实现串口/485通信协议、数据帧校验、固件接收与Flash写入逻辑,App工程则展示升级标识设置、版本判断、跳转Bootloader和恢复运行等流程,两份工程相互配合,并带有可直接烧录的固件,能帮助开发者快速理解OTA机制。目前已有2534人学习下载,适合正在设计Bootloader或准备为产品增加串口升级功能的嵌入式工程师参考。 做嵌入式开发的朋友都清楚,产品一旦量产,固件升级就是绕不开的一关。这两年我给STM32F4系列写过好几套OTA方案,今天聊的是其中最基础也最常用的一套:通过串口或RS-485,配合一个自研的BootLoader完成固件升级。这种方案不依赖官方那套复杂协议,代码可控、逻辑直观,特别适合中小团队和批量化产线。文章会从整体方案设计、Flash分区、BootLoader跳转、上位机实现到常见坑位逐一讲透,你可以当成一份可以直接抄作业的参考。
1. 项目背景与方案选型:为什么我最终选了这个方案
1.1 产品对OTA的真实需求
很多项目一开始根本没有升级能力,程序烧录全靠产线上的J-Link或者ST-Link。等到设备发到现场,客户反馈说逻辑要改、协议要调、甚至显示界面要换,这时候才发现麻烦了——要么派工程师出差拆壳子,要么把几十台设备寄回来重新烧,时间成本和经济成本都很高。
OTA升级解决的就是这个痛点。不过ST官方其实给STM32F4预置了ROM BootLoader,串口、USB、CAN等方式都能用,为什么还要自己写?因为官方的串口ISP协议是固定格式的,使用起来有几个别扭的地方:首先,它的握手时序和命令交互比较繁琐,产线工人操作容易出错;其次,官方协议不支持工程化定制,比如加版本号识别、做加密校验、断点续传,统统得自己再包一层;最后,当走485总线升级时,官方BootLoader基本不配合,因为485是半双工,方向引脚必须自己控制,而ROM里的程序根本不知道你的方向控制引脚接在哪个GPIO上。
所以我这里定义的“方式1”,就是完全自研BootLoader + 上位机通过串口或485发送bin文件升级。它做的事情简单直接:上电后BootLoader先判断是否需要升级,如果需要就接收固件并写入App区,不需要就直接跳转App运行。这种方式代码量不大,逻辑清晰,出问题也容易定位,非常适合作为团队的第一套OTA方案。
1.2 串口和485:怎么选、怎么共存
串口和485本质上是同一种物理层传输技术,只是电平标准不同。串口的TX/RX是单端电平,适合板级通信和短距离调试,距离一般也就一两米;485则是差分信号,A/B两根线,抗共模干扰能力强,传输距离能到1200米左右,还能组网挂多台设备。
实战中,我通常会在硬件设计上同时预留两路接口:一路TTL串口给产线调试用,一路485给现场远端升级用。这样做的好处很多——产线调试时不用额外转接,现场升级时也不需要拆设备,直接用手持终端或者电脑通过USB转485接上总线就能操作。如果设备本身有多个串口,首选方案是BootLoader放在USART1,App也使用USART1,这样物理链路完全复用,逻辑最简洁。当然,也可以把BootLoader和App用不同的串口,升级时上位机始终连BootLoader那个口,但这样对现场接线要求更高,一般不建议第一次就搞这么复杂。
1.3 “方式1”的定义与整体架构
整体架构可以拆成三个部分:设备端的BootLoader、设备端的App、上位机升级工具。
BootLoader负责最底层的工作——初始化时钟和串口,接收升级数据,擦写Flash,最后跳转App。App是实际业务程序,只需要在启动时做一个中断向量表重定向,其余逻辑完全不用关心升级这件事。上位机工具负责把编译好的bin文件读进来,按协议分包发送、校验、确认,最后通知设备重启。三部分各司其职,只要协议约定好,任何一端出问题都能通过日志定位到具体环节。
## 2. BootLoader与App分区:整个方案的地基 ### 2.1 Flash分区设计与地址计算 STM32F4的Flash从0x08000000开始,不同型号容量不同,比如F407VG是1MB,分成12个扇区;F401RE是512KB,扇区大小不均匀。设计分区的第一原则是:BootLoader放在最开头,App放在其后的某个扇区起始位置。 以STM32F407VE为例,Flash共512KB,第一个扇区是16KB,第二到第四个扇区是16KB,第五个及以后是128KB。一个稳妥的做法是:BootLoader占用前两个扇区,即32KB空间,App起始地址设为0x08008000。这样BootLoader有32KB空间存放升级逻辑和串口协议代码,完全够用。App的大小取决于业务代码量,但要注意,App最大只能用到Flash末尾,中间不能跨过保留区。BootLoader和App之间还可以留一个扇区存放升级标志、版本号等参数,这个稍后讲。 分区设计的另一个重要考量是“断电安全”。如果擦除App扇区后掉电,设备会变成半砖。所以我习惯在App区之前再划分一个32KB的备份区,升级时先把新固件写入备份区,全部接收校验完成后,再把备份区内容整体复制到App区。这样即使复制前断电,App区的旧固件还在,设备依然能跑旧版本。代价是Flash空间占用更大,但对实际产品来说,这个代价换来的是可靠性的提升,非常值得。 ### 2.2 BootLoader核心逻辑:接收、校验、跳转 BootLoader的启动流程大概是这样的: 上电后先初始化系统时钟、串口、485方向控制引脚,然后读取Flash里存储的升级标志位。如果标志位表示“需要升级”,就进入升级模式,等待上位机连接;如果标志位表示“正常运行”,直接跳转App。还有一种情况:如果App区是空的(比如第一次烧录),BootLoader也要自动进入升级模式等待下载。 升级模式下的接收逻辑要考虑很多细节。我这里给出一个简化但实用的状态机:上位机发“握手命令”,BootLoader回复“就绪”;上位机发“擦除命令”,BootLoader擦除备份区并回复“完成”;上位机分包发“固件数据”,每包包含本包序号和CRC32校验值,BootLoader校验通过后写Flash并回复“成功”,校验失败则回复“重发”;全部发完后上位机发“升级完成命令”,BootLoader把备份区复制到App区,然后置升级标志为“完成”、复位跳转App。 跳转操作是BootLoader里最核心的一段代码,直接操作函数指针: ```c typedef void (*pFunction)(void); #define APP_START_ADDR 0x08008000 void JumpToApp(void) { uint32_t app_sp = *(volatile uint32_t *)APP_START_ADDR; pFunction app_entry = (pFunction)(*(volatile uint32_t *)(APP_START_ADDR + 4)); __disable_irq(); __set_MSP(app_sp); app_entry(); }APP_START_ADDR处存放的是App的初始栈指针,APP_START_ADDR+4处存放的是App的Reset_Handler入口地址。跳转前必须先关中断,跳转后设置主栈指针,再调用入口函数。这个顺序不能乱,否则会导致程序跑飞。
2.3 App端必须做的三件事
App并没有完全置身事外,它在启动时要做三件事,缺一不可。
第一件事是重定向中断向量表。之前说过,F4支持通过SCB->VTOR寄存器设置向量表偏移。在App的main函数最开始加上这一句:
SCB->VTOR = APP_START_ADDR;如果不做这一步,任何中断来了都会跳到BootLoader的向量表去取入口地址,而BootLoader里根本没有对应中断处理函数,结果就是中断跑飞、设备死机。
第二件事是重新实现软件复位跳转到BootLoader的机制。App运行过程中如果需要进入升级模式,不能直接复位,因为复位后BootLoader会照常检查升级标志位,如果标志位没置位就直接跳回App了。所以App在复位前要先写一个“需要升级”的标志到备份区某地址,然后再调用NVIC_SystemReset()复位。这样BootLoader启动时读到标志位为“需要升级”,才会停下来进入下载模式。
第三件事是预留与上位机的通信能力。如果在升级过程中,上位机想查询设备当前固件版本号,BootLoader不会解析业务协议,所以这个查询命令直接转发给App处理更合理。不过这是进阶功能,第一版可以不做,我最开始就没加,后来现场排查问题时才补上的。
3. 上位机与协议设计:传输这件事要做得稳
3.1 协议帧格式与关键参数计算
上位机和BootLoader之间的通信协议,我建议遵循一个原则:帧头+命令字+长度+数据+校验,每个字段都要有明确意义,长度和数据部分都要加上校验保护。
一个比较实用的帧格式:
- 帧头:2字节,固定为0xAA 0x55
- 命令字:1字节,比如0x01握手、0x02擦除、0x03数据、0x04完成
- 数据长度:2字节,小端模式
- 数据:N字节
- 校验:4字节CRC32或者2字节CRC16
关键参数是波特率和分包大小。波特率我建议115200,因为这个速率在普通串口线和485总线上都非常稳定,再高比如460800,在长线485传输时容易受干扰。分包大小要综合考虑Flash页大小和RAM空间,通常一包256字节比较合适。假设一包256字节,包含帧头和校验字段,实际一帧约270字节,在115200波特率下,传输一帧大约23.4ms,写Flash(擦除除外)一页时间在1-2ms内,所以传输瓶颈在上位机的发送间隔和稳定性,而不是Flash写入速度。
有一个经常被忽略的细节是Flash擦除时间。STM32F4擦除一个扇区需要几百毫秒到一秒不等。擦除期间MCU不能响应串口数据,所以上位机发送擦除命令后,必须等待BootLoader的完成回复再发下一帧,不能连发。解决方法是上位机设一个超时重发机制,BootLoader在擦除前先通过485发送一个“忙”状态帧,上位机收到后进入等待;如果上位机没收到任何回复,超时后重发擦除命令。
3.2 485半双工收发切换的时序细节
485通信和普通串口最大的区别是半双工,同一时刻只能发送或接收。典型的485电路会有一个方向控制引脚(通常用DE/RE引脚),高电平切换到发送模式,低电平切换到接收模式。如果这个切换时序没处理好,极易出现丢包或自发自收问题。
实际调试中,最常见的错误是发送完最后一个字节后立刻切换方向引脚。因为UART外设有发送移位寄存器和发送数据寄存器,软件往数据寄存器写入最后一个字节后,移位寄存器可能还在发送。这时如果马上拉低方向引脚,最后一个字节会被截断。正确做法是等待发送完成标志位(TC位)置位后再切换方向。
void UART_SendBytes485(uint8_t *buf, uint16_t len) { // 切换到发送模式 DE_GPIO_HIGH(); for (uint16_t i = 0; i < len; i++) { while (!(USART1->SR & USART_SR_TXE)); USART1->DR = buf[i]; } // 关键:等待移位寄存器完全发送完成 while (!(USART1->SR & USART_SR_TC)); // 再切回接收模式 DE_GPIO_LOW(); }TC标志位表示的是数据完全移位输出完成,而不是仅仅写入发送寄存器。另外要注意,发送完成后要留一点空闲时间再切换方向,因为485总线上从发送切到接收需要一定的稳定时间,一般加50us到200us即可,具体看总线负载。为了提高效率,可以在BootLoader和上位机中约定:上位机每次发送完数据后等一小段时间再等回复,BootLoader回复帧之间留出间隔,让总线电平稳定。
3.3 上位机实现思路与工具链
上位机可以用多种方式实现,我推荐优先使用Python配合pyserial库做原型验证,因为代码量小、调试方便。正式产线工具再考虑用C#或Qt做成GUI。这里给出一个关键的帧发送核心逻辑:
import serial, struct, binascii def calc_crc32(data): return binascii.crc32(data) & 0xFFFFFFFF def build_frame(cmd, payload): length = len(payload) header = b'\xAA\x55' + bytes([cmd]) + struct.pack('<H', length) crc = calc_crc32(header[2:] + payload) return header + payload + struct.pack('<I', crc) def send_frame(ser, cmd, payload): frame = build_frame(cmd, payload) ser.write(frame) # 等待应答 resp = ser.read(8) if len(resp) != 8: return False # 校验应答CRC return True上位机最重要的一点是重发逻辑。串口和485本质上都是不可靠传输,总线上可能有干扰导致帧损坏,也可能因为BootLoader正在擦Flash没来得及回复。所以上位机必须实现“发送-等待-超时重发”的循环。我一般设置超时时间为1秒,同一帧重发3次,超过3次就报告升级失败。断点续传在第一版可以不做,但如果你的产品总线上挂了很多设备,每一次重发都会拖慢升级速度,后续还是值得研究一下续传方案的。
4. 实操全流程记录:从编译到升级成功
4.1 Keil工程配置与bin文件生成
BootLoader和App要建立两个独立的Keil工程。BootLoader工程不需要特殊配置,只要代码里没有跳转逻辑之外的特殊处理。App工程则必须修改IROM1起始地址和大小。以STM32F407VE为例,App的IROM1起始地址填0x08008000,大小填(512K-32K)=0x78000。这里要特别注意,Size的单位是0x,直接填十进制数会出错。
生成bin文件时,Keil需要加上fromelf命令。在Options for Target -> User页面,After Build页签下的User Command里填:
fromelf --bin --output=.\Output\App.bin .\Output\App.axf这样编译完成后就会自动生成App.bin。注意bin文件不包含起始地址信息,上位机烧录时不需要关心地址,BootLoader会固定把数据写入备份区或App区。生成bin之前还要在C/C++页面的Define里加上APP_START_ADDR=0x08008000,这样App代码里可以用宏控制向量表重定向。
4.2 第一次联调的完整过程
我第一次做这套方案时,在实验室里用两块开发板联调花了一个晚上才跑通,总结了几个关键动作,照着做能少走弯路。
第一步,先把BootLoader烧进去,串口接上调试助手,发一个握手命令,看BootLoader有没有回复。如果没回复,优先查串口引脚复用是否配置正确、时钟是否使能、波特率是否一致。第二步,把App烧进去,上电后看App是否正常启动。这里有个技巧:如果App已经烧过,BootLoader会自动跳转,不需要手工干预。第三步,检查升级流程。上位机发升级命令后,观察BootLoader是否正确擦除、接收、校验、跳转。这时候最好把BootLoader的调试日志通过另一个串口输出到电脑,同步观察内部状态。
我用过一个组合调试方式:BootLoader的调试串口接CH340模块,485链路接USB转485模块,上位机通过USB转485发指令。两路日志同时打开,一旦升级失败,直接对比两组日志就能定位是哪端出了问题。CH340和FTDI这两类USB转串口模块我都在用,稳定性都可以,关键是驱动要先装好,特别是FTDI模块在Windows 10以上系统偶尔需要手动更新驱动。
4.3 升级失败的回退机制
回退机制是升级方案里最容易忽略、也最致命的一块。我第一次做时没有备份区,直接在App区擦写。有一次升级过程中误拔了电源,再上电时BootLoader发现App区是乱的,想跳转又不敢跳,结果设备直接变砖,只能重新用烧录器恢复。后来我加上了备份区方案,流程变成:
- 上位机把新固件全部写入备份区,并且每包都做CRC校验;
- 全部写完后,BootLoader读取备份区的固件头信息,重新计算整个bin文件的CRC,与上位机发送的最终CRC比对;
- 比对通过,才把备份区复制到App区;
- App区复制完成后,BootLoader再读App固件头,校验一次合法性,确认无误才跳转。
整个过程中任何一步失败,BootLoader都能保证App区里还是旧固件,从而可以继续运行。旧固件运行时如果发现通信异常,上位机还能再次发起升级。这套机制在实际项目里救过我很多次。
5. 常见问题与排障实录
5.1 升级过程中串口卡死、无响应
这类问题在串口升级场景中非常高频。我遇到过的情况大概有三种:第一种是上位机发送速度过快,BootLoader的接收中断被淹没或者DMA缓冲区溢出,解决方法是增大接收缓冲区,同时在协议层做流控,每发一包必须等确认;第二种是BootLoader在擦Flash时,中断长时间被屏蔽,上位机发来的数据全丢了,解决方法是擦除前先清空接收缓冲,擦除完成后再恢复接收;第三种是485方向切换时序不对,导致BootLoader根本没收到完整帧,这个问题上面说过,切方向时务必等TC标志位置位。
这里还要特别提一下中断优先级。如果BootLoader的串口接收中断优先级比Flash擦除相关中断低,那擦除期间即使有数据也进不了中断。最好把串口接收中断的优先级设为最高,同时把串口接收做成DMA+空闲中断的形式,这样可以降低MCU在接收大量数据时的CPU占用率。
5.2 跳转后死机、App跑不起来
升级成功后跳转App,却发现程序不运行,这是另一个高频问题。最常见的原因是中断向量表没重定向。很多人改了IROM地址,但忘了改SCB->VTOR,导致App里任何中断都进不去。还有一个隐蔽原因:App使用的寄存器状态在跳转前没被清理干净。跳转前建议把所有用到的外设都Deinit一遍,关闭中断,把SysTick停在零状态,再执行跳转函数。
另外,如果App工程里的启动文件还是默认的startup_stm32f40xx.s,也没关系,它仍然会执行SystemInit和main,但SystemInit里如果重新配置了Flash等待周期等,可能影响BootLoader跳转后第一次取指。碰到这种问题,可以在跳转前先关闭Cache和预取缓冲,跳转后再重新配置。调试这种问题时,用示波器或逻辑分析仪抓App的第一个GPIO翻转波形,是排查最快的方法。
还有一个容易被忽视的坑是App用的堆栈大小。如果App项目里启动了RTOS,任务栈很大,但链接脚本里的Stack_Size设得不够,App运行起来没问题,但一旦任务切换频繁就会HardFault。这个问题和OTA本身无直接关系,但往往在升级后才暴露。
5.3 485通信偶发错包
485虽然抗干扰强,但现场布线如果走线过长、又没有接地,外部强电干扰仍然可能窜进A/B线。我的经验是硬件上做好隔离和防护,软件上做好CRC校验和重发机制。485总线的终端电阻也很有讲究,如果线上只挂了一台设备,终端电阻不接问题不大;如果多台设备组网,必须在最远两端各接一个120欧姆电阻,否则波形反射会导致数据错乱。
还有一点是关于地线的:很多工程师以为485是差分传输,不需要共地。其实485的共模电压是有范围的,如果两个设备的GND电位差太大,哪怕有隔离电路,通信一样不稳定。我在现场遇到过一次,设备装在电机柜旁边,电机一启动,485就时不时丢包。后来把设备外壳处理好接地,通信马上就稳了。
软件层面,我强烈建议BootLoader和上行方做“帧序号确认+超时重传”,而不是简单地发完不管。别嫌它慢,实际传一个512KB的bin文件,在115200波特率下大约需要46秒,如果中间每包都重传几次,那才会真的慢到怀疑人生。
最后再说两句实际体会
做OTA升级这件事,方案本身的代码量其实不大,真正花时间的是各种边界情况——断电、干扰、误操作、存储器擦写异常。这些情况在实验室里未必出现,但一到现场就全都冒出来了。我个人觉得,第一版OTA方案的精髓不是功能多炫、速度多快,而是失败后还能安全地回到旧版本继续运行。这一条稳住了,其余的事情都好说。还有一个小心得:正式发布前,一定找一台设备做一次“升级过程中拔电”的破坏性测试,反复拔上十次,如果每次都能恢复或者保持旧版本运行,这套方案才算是真正过了关。
本文还有配套的精品资源,点击获取