1. 为什么要把OTA放在片外Flash上
做嵌入式开发的朋友应该都遇到过这种尴尬:产品已经量产了,结果现场反馈某个功能有Bug,或者需要加一个新协议,这时候只能派工程师带着ST-Link/J-Link出差去现场,挨个设备拆壳、接线、烧录。说句实话,有些设备安装在偏远站点,光来回的路费和时间就够喝一壶的。OT**A升级就是解决这个痛点的标准方案,它让设备能够通过网络、串口、SD卡等介质远程更新固件,不用再开壳子动硬件。
但OTA这事说起来简单,真正做起来有几个绕不开的问题。第一个就是存储介质的选择。很多人习惯把固件放在MCU内部Flash里,比如STM32F411CEU6有512KB Flash,如果Bootloader占32KB,App占256KB,再留两个分区做备份,你会发现内部Flash根本不够用。尤其是升级过程中,如果你想把当前运行的固件先备份一份,再把新固件写入,写坏了还能回滚,那分区数量翻倍,内部Flash直接爆掉。而且很多MCU的内部Flash在写的时候会影响中断响应,如果在擦写过程中来了一个关键中断,处理不好就会出现系统卡死的情况。
所以把OTA的存储区域放到片外Flash(比如W25Q128)上,就成了一个非常务实的方案。W25Q128是华邦(Winbond)推出的一款SPI NOR Flash,容量128Mbit,也就是16MB,价格便宜、货源稳定、读写寿命通常在10万次擦写以上,在工控、消费电子、通信模块里用得极其广泛。16MB是什么概念?STM32F411的内部Flash才512KB,16MB是它的32倍,放Bootloader、App、备份分区、参数区、日志区,随便怎么折腾都够用。片外Flash属于外设,读写操作走SPI总线,不占用MCU内部程序存储空间,也不影响中断响应,升级过程更安全。
这次的项目使用的是RT-Thread操作系统配合RT-Thread Studio开发环境,MCU是STM32F411系列,通过YModem协议接收固件,将固件存放到片外Flash W25Q128中,再完成校验和跳转。文章会从头到尾把整个方案的技术选型、分区计算、协议流程、代码实现、烧录验证、坑点排查全部讲一遍,适合用什么场景呢?如果你的产品也有远程升级需求,手头正好有片外Flash资源,想搭建一套稳定可靠的Bootloader+App架构,那这篇笔记可以直接拿来当参考。
还要说一下,这篇文章是系列笔记的第四篇,前三篇分别记录了RT-Thread Studio的基础工程创建、串口外设配置、W25Q128驱动的移植过程。如果读者是第一次接触RT-Thread Studio,建议先熟悉一下基本操作,因为后面的内容会默认你已经具备了RT-Thread基础工程和板级外设配置的能力。但就算你是新手,只要跟着这篇文章一步一步走,也能搭出一套能用的OTA链路,只是中间可能要多花一点时间排查环境问题。
2. 系统总体架构与分区规划
2.1 Bootloader、App与下载区的三角关系
OTA升级和普通的程序烧录最大的区别在于,系统里必须有至少两块独立的程序区域,才能实现自更新。最经典的架构就是Bootloader(引导程序)+App(应用程序)双区方案。设备上电后先运行Bootloader,Bootloader负责检查是否有新固件需要升级,如果有就执行升级流程,如果没有就直接跳转到App运行。
那W25Q128放在这个架构里扮演什么角色呢?有两种典型用法。第一种是把W25Q128作为下载缓冲区和备份区,Bootloader通过YModem协议接收到新的固件后,先写入W25Q128的下载区,全部接收完成后做一次CRC校验,校验通过再搬运到内部Flash的App分区,然后跳转执行。这种方案的优点是App运行在内部Flash上,执行速度快,W25Q128只是作为中转站;缺点是如果固件很大,拷贝过程需要时间,而且在搬运过程中掉电,内部Flash里可能会出现半个固件的情况。
第二种方案是把App直接放在W25Q128上执行,也就是XIP(Execute In Place)方案,但这需要MCU支持从SPI Flash映射执行代码,很多MCU不具备这个能力,而且执行速度远不如内部Flash。所以对STM32F411这类不支持XIP的MCU来说,采用第一种方案更可靠。
再来说分区规划。W25Q128有16MB空间,浪费是可耻的,而且合理的分区能避免很多后续麻烦。我给这次项目做的分区表如下:
| 分区名称 | 起始地址 | 容量 | 作用 |
|---|---|---|---|
| 固件备份区 | 0x000000 | 1MB | 保存当前正常运行中的App固件 |
| 下载缓冲区 | 0x100000 | 1MB | 暂存通过YModem接收的新固件 |
| 参数存储区 | 0x200000 | 128KB | 保存升级标志、版本号、校验结果 |
| 日志存储区 | 0x220000 | 1MB | 记录升级日志和运行日志 |
| 保留区域 | 0x320000 | 剩余空间 | 后续扩展使用,如字体库、音频资源 |
分区规划的核心思路是:备份区和下载区至少能存下App最大固件体积,而且这两个区之间还要留足余量。为什么备份区要1MB?因为如果你的App因为优化不足膨胀到600KB,256KB的分区就直接废掉了。我见过不少项目在前期规划分区时拍脑袋,结果后期固件增长超出分区容量,被迫重新设计整个存储方案,周期和成本都上去了。所以在芯片容量允许的前提下,分区宁大勿小。
2.2 片内Flash的Bootloader与App地址分配
Bootloader和App在内部Flash中的地址分配也需要仔细计算。STM32F411的Flash起始地址是0x08000000,但Bootloader和App不能共用同一个起始地址,否则跳转后就乱套了。我这次使用的分配如下:
Bootloader区域:0x08000000,分配64KB。为什么不是32KB?因为Bootloader里除了必要的跳转逻辑,还要包含YModem协议解析、W25Q128驱动、CRC校验、Flash擦写驱动,这些代码量加起来肯定超过32KB了,万一Bootloader本身写爆了Flash,升级链路就彻底瘫痪了。所以Bootloader的空间一定要留足,64KB是一个比较稳妥的起步值。
App区域:0x08010000,也就是从Bootloader结束位置开始,大约448KB可用空间。对STM32F411CEU6来说,这是内部Flash的剩余全部空间。这里有一个非常关键的配置点:App工程的链接脚本起始地址必须改成0x08010000,否则编译出来的固件会默认从0x08000000开始放置向量表,跳转后MCU一上电取向量表地址直接拿错。
向量表偏移也是一个不能漏的设置。Cortex-M4内核的MCU在启动时,CPU会从向量表偏移地址读取初始栈指针和复位向量。默认情况下,向量表放在Flash起始位置0x08000000,如果你的App跑在0x08010000,就必须设置VTOR寄存器指向0x08010000。在STM32的HAL库里,这个操作通常通过SCB->VTOR = APP_ADDRESS;语句实现,要在App的main函数里尽早执行,越早越好,因为在设置VTOR之前,任何中断响应使用的都是Bootloader的向量表,如果此时来了中断,中断处理函数就会跑错位置,轻则功能异常,重则死机。
3. YModem协议与升级流程拆解
3.1 YModem协议基础:不会过时的串口升级老兵
说到串口升级协议,很多人第一反应是用XModem,但XModem一次只能传一个文件,而且没有文件信息头,对于现代固件升级来说功能太弱了。YModem是在XModem基础之上扩展出来的协议,它增加了文件名的传输能力,支持一次会话传输多个文件,每个文件都带独立的长度校验,128字节或1024字节的扇区长度自适应,错误重传机制也保留了。虽然现在各种网络OTA、USB DFU很流行,但YModem在工业设备、通信模块、Bootloader引导场景里依然有一席之地,原因就三个字:简单可靠。
具体来看YModem的帧格式。一次YModem会话可以分成几个阶段:
接收方(设备端Bootloader)发送字符
C,表示等待发送方(PC端的SecureCRT或者YModem发送工具)开始传输。发送方回应一个起始帧,帧头是
SOH(0x01),表示后面是128字节的数据块。这个起始帧的数据块前两个字节是文件名(通常是固件文件名),后面跟着文件大小(ASCII字符串形式),再后面填充0x00补齐128字节。CRC校验占据最后两个字节。然后发送方逐块发送数据帧。数据块序号从0x01开始,每块可能是
STX(0x02)开头的1024字节大块,也可能是SOH开头的128字节小块。接收方每收到一个块就回一个ACK(0x06),如果CRC校验失败就回NAK(0x15),发送方收到NAK就重发当前块。这个过程就是YModem最基础的停止等待ARQ(自动重传请求)。因为没有滑动窗口,所以传输效率不是极致,但是在115200波特率下,传一个200KB固件大约需要40秒左右,作为本地升级手段完全够用。文件数据全部发送完毕后,发送方发一个结束帧(EOT,0x04)。接收方回
ACK,然后发送方再发一个空的YModem结束帧(帧头SOH,数据块全0x00),接收方回ACK,传输结束。
用生活化的类比来解释YModem的工作方式,它就像一个非常谨慎的快递签收员:快递员每送一箱货物,都要等收件人清点完数量、确认包装没破,然后签收一张回执单,快递员才会送下一箱。如果收件人发现这一箱有问题,就不签收,快递员只能原路回去再送一箱一模一样的过来。每一箱货物都有一个唯一的编号,确保不会收重。
3.2 完整的OTA升级状态机
Bootloader里的OTA逻辑不能是一坨简单的“接收固件-写入Flash-跳转”的线性代码,而是需要一个清晰的状态机来管理,否则任何一步出问题都可能导致设备变砖。我设计的升级状态机如下:
IDLE -> 检查是否有升级标志 CHECK_FLAG:读取参数存储区的升级标志位 |-- 无升级标志 -> JUMP_APP(跳到App运行) |-- 有升级标志 -> WAIT_DOWNLOAD(进入待接收状态) WAIT_DOWNLOAD:等待并接收YModem数据 |-- 接收超时 -> JUMP_APP(超时后不能卡死,直接跑App) |-- 接收完成 -> VERIFY_FW(进入固件校验状态) VERIFY_FW:CRC校验下载缓冲区的固件完整性 |-- 校验失败 -> JUMP_OLD_APP(备份区固件还原) |-- 校验成功 -> BUFFER_TO_APP(写入内部Flash) BUFFER_TO_APP:擦除内部App区并写入新固件 |-- 写入失败 -> RESTORE_OLD_APP(回滚备份区固件) |-- 写入成功 -> CLEAR_FLAG(清升级标志)-> JUMP_APP这里面有几个细节值得单独拿出来讲。
第一个细节是升级标志的存储位置。为什么不用一个简单的GPIO电平来判断是否进入升级模式?因为GPIO可能在设备重启后被初始化成其他功能,而且GPIO状态无法记录“升级到一半失败了”的中间状态。所以我选择把升级标志放在W25Q128的参数区,用一个结构体来管理,包含魔数(Magic Number)、升级标志位、目标固件版本号、CRC校验值、升级时间等字段。魔数的目的是防止参数区数据被意外破坏后系统误判。比如说,如果参数区因为某些原因被写入了随机数据,而恰好这个数据的第一个字节等于升级标志的值,系统就会错误地进入升级流程。加上魔数校验后,只有当整个结构体满足“魔数匹配+标志位置位+CRC匹配”三个条件时,才认为是合法升级请求。
第二个细节是接收超时处理。我用了一组时间变量,在等待YModem数据帧时记录最近一次收到数据的时间,如果超过10秒没有收到任何有效数据帧,就放弃升级流程,直接跳转到App。这个设计的理由是:不能因为一次失败的升级尝试就让设备永久停留在Bootloader界面,这在无人值守的场景下是致命的。
第三个细节是App固件搬运。YModem把新的固件完整接收到了W25Q128下载缓冲区,但App最终要放在内部Flash的0x08010000处,所以需要把固件从SPI Flash读出来,写入内部Flash。这个过程不是简单的memcpy,因为内部Flash写入前必须擦除,且擦除的最小单位是扇区(STM32F411的Flash扇区大小不均匀,前4个16KB扇区,接着1个64KB扇区,最后3个128KB扇区)。擦除操作不能影响正在执行的代码所在扇区,尤其注意,如果你的擦写驱动代码恰好放在被擦除的扇区里,代码执行直接崩溃。我习惯把Flash驱动和临时缓冲数据都放在Bootloader的固定区域,这样不管擦除哪个App扇区,驱动代码自身都不会消失。
3.3 CRC32校验:固件完整性的最后一道防线
固件在传输过程中可能因为波特率偏差、电磁干扰、线缆质量等问题产生比特错误。YModem协议自身带CRC16校验,这个校验只保证单个数据块在传输链路上的正确性,但固件写入内部Flash的过程同样可能出问题。所以完整的校验机制应该是分层级的:
- YModem协议自带CRC16,保证串口链路上的数据正确性。
- 固件整个文件在打包时计算一个CRC32值,和固件头一起存放。Bootloader在接收完整个固件后,重新对下载缓冲区的内容计算CRC32,和固件头中记录的CRC32比对,只有一致才允许搬运到内部Flash。
CRC32的计算不难,代码实现可以直接用查表法,网上随便搜都能找到完整的查表代码。关键在于计算范围要统一:打包时对哪一段数据计算CRC,Bootloader也必须对相同范围计算CRC。如果打包工具把CRC放在固件文件末尾,然后Bootloader对包含CRC的文件整体再算一次CRC,那校验永远不过。我这次采用的是自定义固件头的方式,结构体定义如下:
typedef struct { uint32_t magic; /* 固定为0xAA55AA55,用于识别合法固件头 */ uint32_t fw_version; /* 固件版本号,高16位主版本,低16位次版本 */ uint32_t fw_size; /* 固件有效数据长度 */ uint32_t fw_crc32; /* 固件有效数据的CRC32校验值 */ } firmware_header_t;固件头放在固件文件的最前面,固定16字节。Bootloader先读取前16字节判断magic是否为0xAA55AA55,如果不是就立刻判定固件无效。这样在整个固件传输完成之前,Bootloader就能提前判断这次的固件是否合法,省去无谓的等待。
4. RT-Thread Studio工程配置与代码实现
4.1 创建Bootloader工程并配置组件
Bootloader工程和App工程在RT-Thread Studio里的创建方式基本一致,但在配置上有一些差别。Bootloader工程更注重精简,不需要太多系统组件,一个内核加上必要的驱动就够了。
在RT-Thread Studio中,新建RT-Thread项目,芯片型号选择STM32F411系列,基于开发板创建或者基于芯片创建都可以。工程创建完成后,进入RT-Thread Settings页面,建议做以下裁剪:
- 关闭内核里的
RT_USING_CONSOLE是可以的,但在开发调试阶段建议保留,Bootloader阶段通过控制台打印日志能极大方便调试。 - 保留
RT_USING_HEAP,Bootloader里用动态内存来管理接收缓冲会方便很多。 - 添加SPI驱动框架,因为W25Q128挂在SPI总线上。
- 不需要打开DFS文件系统,Bootloader阶段没必要挂文件系统,直接操作Flash驱动更直接。
需要特别提醒的是,Bootloader工程的链接脚本和启动文件地址不需要修改,因为Bootloader本身就是从0x08000000开始运行的。你只需要在App工程里修改起始地址。
串口的配置在这里就显得很关键了。YModem走的是串口和PC端进行通信,我使用的是USART1,波特率115200,8位数据位,无校验位,1位停止位。RT-Thread的串口驱动框架里,需要把串口注册成设备,并设置接收回调。YModem是接收方主动驱动的协议,设备端必须持续监听串口数据。
在RT-Thread Studio里,串口设备的初始化通常是在board.h中配置引脚,然后在应用代码中使用rt_device_find查找设备,用rt_device_open打开设备并设置回调函数。有一个细节:在打开串口设备时,需要配置DMA接收模式还是中断接收模式。YModem接收需要保证每个字节都不丢,中断接收模式在高速传输时可能因为频繁进入中断而增加CPU负担,但115200波特率下问题不大;DMA接收模式则可以大幅减少CPU占用,但接收不定长数据时,你要自己处理空闲中断或者定时器超时来判断一帧数据是否结束。这里我建议直接用中断接收模式,实现简单,代码可读性高,115200波特率下CPU占用完全可以接受。
4.2 W25Q128驱动对接与SPI Flash抽象
W25Q128驱动在系列笔记第三篇中已经详细移植过了,这里只强调在OTA工程中对接时需要特别注意的地方。W25Q128的标准SPI接口是四线制(CS/CLK/MOSI/MISO),最大支持104MHz时钟频率,但实际工程中受布线、MCU SPI外设限制,通常跑在10MHz~50MHz之间。
在RT-Thread环境下,W25Q128驱动需要对接的是RT-Thread的SPI设备框架。大体流程是:
- 通过
rt_hw_spi_device_attach把W25Q128挂载到SPI总线上,相当于给SPI总线上的某个片选引脚绑定一个从设备。 - 配置SPI的波特率、数据位、时钟极性(CPOL)和相位(CPHA)。W25Q128支持SPI Mode 0和Mode 3,即CPOL=0/CPHA=0或者CPOL=1/CPHA=1。我习惯用Mode 0,也就是空闲时时钟为低电平,数据在上升沿采样。
- 调用SPI设备接口
rt_spi_send_then_recv来发送命令和接收数据。W25Q128的读操作不用先擦除,直接发0x03读命令加上24位地址就能读;写操作需要先发0x06写使能命令,再发0x02页编程命令,每页最多256字节,写超过256字节就得按页处理。擦除操作分为4KB扇区擦除(0x20)、32KB块擦除(0x52)、64KB块擦除(0xD8)以及整片擦除(0xC7/0x60)。对OTA来说,4KB扇区擦除是最常用的,灵活度高,擦除时间一般在45ms~400ms之间。
还有一个非常实用的接口是0x9F读ID命令,可以用来在启动时验证W25Q128是否正常连接。如果读出的Manufacturer ID不等于0xEF(Winbond),或者Device ID不等于0x4018(对应128Mbit),就应该打印错误并停止升级流程,避免把固件写到一块不存在的Flash上。这个小检查在量产阶段帮过我大忙——有批板子焊接时W25Q128的引脚虚焊,导致SPI通信不稳定,如果不去检查设备ID,升级过程中会出现各种诡异的写失败错误,排查半天还找不到原因。
4.3 YModem接收模块的代码实现
YModem接收模块是整个Bootloader的核心。我把它封装成独立的C文件,对外提供三个接口:ymodem_init(初始化接收状态机)、ymodem_poll(在串口收到数据时调用,驱动状态机)、ymodem_get_result(获取最终接收结果)。状态机的核心数据结构如下:
typedef enum { YMODEM_STATE_WAIT_START, /* 等待起始帧 */ YMODEM_STATE_RECEIVE_DATA, /* 接收数据帧 */ YMODEM_STATE_WAIT_EOT, /* 等待结束帧 */ YMODEM_STATE_FINISH /* 传输完成 */ } ymodem_state_t; typedef struct { ymodem_state_t state; volatile uint32_t recv_count; uint32_t file_size; uint32_t recv_size; uint16_t seq; /* 当前包序号 */ uint8_t ack_flag; uint8_t buffer[1024 + 64]; /* 接收缓冲,预留帧头 */ } ymodem_ctx_t;核心流程是这样的:初始化时,状态机先处于WAIT_START状态,此时Bootloader通过串口发送字符C,然后把接收到的第一个数据块当作起始帧解析。起始帧里包含文件名和文件大小,这里我直接跳过了文件名,只解析文件大小,用来判断固件是否超出分区容量。接下来进入RECEIVE_DATA状态,逐块接收数据。每收完一个数据块,先做本地CRC校验,校验通过就返回ACK并写入W25Q128下载缓冲区,校验失败返回NAK请求重传。
写入W25Q128时有一个性能优化点:YModem一个数据块最大1024字节,而W25Q128的页编程最大256字节,如果把1024字节逐个按页写,每次都要等写状态寄存器(读状态寄存器0x05直到bit0为0),串口中断可能会在等待期间堆积数据。更好的做法是启用RT-Thread的串口DMA接收模式,或者在中断回调里直接把数据拷到一个大缓冲,然后主循环里批量写入Flash。我在实际工程里采用的方式是,在串口中断回调函数中只做内存拷贝,然后设置一个标志位告诉主循环“有新数据帧了”,主循环拿到标志后再执行YModem协议解析和Flash写入,这样把耗时操作移出了中断上下文。
在接收完最后一个数据帧后,发送方会发EOT,设备端回ACK后再收一个空帧,最终结束传输。此时整个固件已经在W25Q128的下载缓冲区里,可以执行CRC32整体校验了。这里要注意一个细节,YModem接收到的数据长度可能和固件头声明的fw_size不一致,如果收到的字节数不等于fw_size,同样需要判定固件无效。防止极少数情况下发送方提前断开会话导致文件不完整。
4.4 App工程需要改动的3个关键位置
App工程的改动相对简单,主要是三个地方:
第一个是链接脚本。打开App工程里的.lds文件(RT-Thread Studio的链接脚本,通常是link.lds或者stm32f411xe_flash.lds),将FLASH的起始地址改为0x08010000,长度改为0x70000(448KB)。如果你用的是Keil MDK工具链,对应修改分散加载文件的LR_IROM1和ER_IROM1起始地址;如果用的是IAR,对应修改.icf文件里的ROM地址。链接脚本是App能在正确地址运行的基础,这里错了后面全部白搭。
第二个是向量表偏移。在App工程的main函数最开始处增加如下代码:
/* 将向量表重定位到App起始地址 */ SCB->VTOR = 0x08010000;代码位置越靠前越好,最好在进入RT-Thread系统初始化之前就设置完成。
第三个是App内部如果也要用到W25Q128的存储功能(比如存参数、存日志),要注意App里对W25Q128的分区规划必须和Bootloader保持一致。如果Bootloader认为0x200000是参数区,App却把它当成日志区去写,那两边数据就会互相踩踏。我建议把分区表定义在一个公共头文件里,Bootloader和App工程都引用同一个头文件,从源头避免不一致。
4.5 升级标志的写入与App端实现
升级标志一般由App写入。比如App提供一个菜单、一个按键处理函数,或者收到远端服务器指令后,在重启前调用ota_enter_update()函数,该函数先备份当前固件(其实就是把内部Flash的App区内容读出来写入W25Q128的备份区),然后在参数区写入升级标志,最后调用系统软复位(NVIC_SystemReset),使MCU重启进入Bootloader。
这一步里的固件备份要重点说。很多初做OTA的人会问:为什么升级前还要把旧固件备份一份?直接覆盖不行吗?答案是不行。升级过程中任何一个环节出问题(断电、通信中断、Flash写失败),都可能导致新固件无法启动。如果没有旧固件备份,设备就只能返厂烧录,这在工业场景中是不可接受的。有了备份区,Bootloader在发现新固件校验失败后,试着把Flash里残存的App区擦掉,再把备份区固件原样写回,设备就能恢复到升级前的可用状态。这就是OTA设计中常说的“回滚机制”。
App与Bootloader之间的通信也可以通过固件版本号来配合。Bootloader在升级完成后可以把新版本号记录到参数区,App启动后读取参数区版本号,和自身编译时写死的版本号比对,如果不一致,说明上次升级可能被异常中断了,App可以主动触发一次回到Bootloader的操作,重新尝试升级。这种App与Bootloader联动的机制,虽然增加了部分代码量,但显著提升了远程升级的可靠性。
5. 实操:从PC端发送固件完成一次完整升级
5.1 准备升级文件的3个关键步骤
升级文件不是直接把App编译出的bin文件扔给YModem发出去,中间需要做一步打包处理。我这里的打包工具体积很小,整体流程分三步:
第一步,编译App工程,在RT-Thread Studio的构建配置里启用生成bin文件,最终得到rtthread.bin。
第二步,用打包工具给bin文件加上固件头。工具会读入rtthread.bin,计算CRC32,填充firmware_header_t结构体字段,然后在bin文件前面插入16字节固件头,输出app_v1.2.bin这样的固件文件。这里需要重点提醒:固件头的CRC32是对原bin文件的数据部分计算的,不包括固件头自身,Bootloader在校验时要保持同样的计算区间。
第三步,检查固件文件大小是否小于分区预留的1MB空间。1MB=1048576字节,如果你的bin本身很大,比如超过了1MB,说明App工程需要做代码裁剪,或者评估分区容量是否足够。在开发阶段这个检查可能显得多余,但真正量产时,一旦固件体积逼近分区上限,每次升级都如同在悬崖边上走钢丝,风险极大。
5.2 SecureCRT与YModem交互全过程演示
PC端发送YModem文件有很多工具,我这次使用的是SecureCRT,配置方法如下:
- 打开SecureCRT,新建串口连接,选择正确的COM口号,波特率115200,数据位8,停止位1,无校验。
- 连接成功后,按住MCU复位按键,让设备进入Bootloader模式(前提是升级标志位有效,或者Bootloader在启动时检测到外部触发条件,比如某个GPIO保持低电平)。
- Bootloader首先在串口打印一行固件信息和“Waiting for YModem...”的提示,然后持续发送字符
C。 - 在SecureCRT菜单栏选择“Transfer Manager”或直接按快捷键,选择“YModem”,在弹出的对话框中选择刚才打包好的
app_v1.2.bin文件,点击发送。 - 这时SecureCRT就开始通过YModem协议逐块发送数据,设备端会实时打印接收进度,比如
Recv block: 1024, total: 51200 bytes这种日志。
整个交互过程中最容易出现问题的点:Bootloader启动后必须等待数秒,让PC端的SecureCRT有机会发送起始帧。如果两边一个急着一个等着,会出现起始帧还没收到,Bootloader已经超时跳去了App的情况。解决方法是Bootloader在进入YModem等待状态后,给足接收窗口,比如等60秒超时,或者在打印提示后延时10秒再开始主动发送C,给PC端操作留出时间。
传输过程结束后,Bootloader会打印校验结果和写入内部Flash的进度。日志样例大概长这样:
YModem: receive start... YModem: file size = 163872 YModem: block received, total = 71680 YModem: block received, total = 126976 YModem: block received, total = 163840 YModem: transfer complete, verify crc32... YModem: crc32 verify ok Flash: erase app area... Flash: write app area... Flash: write done, jump to app...最后一行打印完成后,设备会自动跳转到App,看到App的启动日志就说明整套OTA链路已经打通。
5.3 升级流程中的版本管理与状态验证
成功的OTA不仅仅是“能升级”,还要能确认“这次升级是真的成功”。我在参数区的结构体里设计了升级状态的四个阶段:UPGRADE_READY(准备升级)、UPGRADE_DOWNLOADING(接收固件中)、UPGRADE_VERIFYING(校验中)、UPGRADE_DONE(升级完成)。每次进入Bootloader,先读取参数区状态,根据当前所处阶段决定下一步动作。举个例子,如果在接收固件到一半时设备断电,重启后Bootloader发现状态还是UPGRADE_DOWNLOADING,且下载缓冲区里只有部分数据、CRC校验不过,就会直接判定为一次失败的升级,自动从备份区恢复旧固件,然后清除升级状态标志。
App启动后,也要主动告诉用户或运维系统当前运行的固件版本。可以把版本号做成设备信息的一部分,通过AT指令或者MQTT上报到服务器。这样即使设备远在千里之外,运维人员也能远程得知设备当前跑的是哪个版本,有没有升级成功。
6. 常见问题、排查技巧与工程经验
6.1 YModem常见故障速查表
调试OTA的过程中,我整理了一份踩坑频率最高的故障清单,按发生概率和影响程度排了个序:
| 故障现象 | 根本原因 | 排查方法 |
|---|---|---|
| Bootloader发出C后PC无响应 | COM口号选错、波特率不对 | 检查设备管理器中的COM口,SecureCRT连接属性里确认波特率是115200 |
| 发送后一直重传NAK | SPI Flash写入失败或CRC算法不一致 | 用逻辑分析仪查看MOSI/MISO上是否有数据回传,用调试串口打印CRC计算区间 |
| 起始帧被跳过,直接进入数据帧解析 | YModem状态机处理顺序错误 | 在WAIT_START状态下打印收到的第一个字节的值,确认0x01(SOH)是否到来 |
| 升级完成后App无法跳转 | VTOR偏移未设置或链接脚本地址未改 | 检查App工程的.lds文件起始地址,确认SCB->VTOR赋值在main函数最前面 |
| 升级中途设备卡死重启 | 串口中断里做了耗时操作(Flash写入) | 把Flash写入移出中断回调,采用标志位+主循环轮询的方式 |
| 固件校验经常失败 | 固件打包工具和Bootloader的CRC计算范围不一致 | 明确CRC计算范围:固件头后的数据部分;两端代码保持一致 |
| 频繁升级后Flash挂掉 | 忽略了扇区擦写寿命 | W25Q128号称10万次擦写,但如果整片反复擦写,寿命骤减;升级逻辑不要做无意义的整片擦除 |
6.2 中断上下文与Flash写操作的正面对抗
前面提到要把Flash写入移出中断回调,这一步值得再深入展开,因为这个坑我在早期项目里踩了不止一次。一开始我在编码时图方便,直接在USART的中断回调函数里调用了W25Q128的写函数,结果发现传输过程中每隔一段时间板子就会死机。为什么?因为W25Q128的页编程操作需要等待内部状态寄存器翻转,这个等待过程消耗的时间虽然只有几毫秒,但足够串口把后续的几十个字节数据挤到RDR寄存器里。如果RDR溢出,数据直接丢弃,YModem这一块的数据帧不完整,接收方就会回NAK,本来115200波特率下能顺畅传输的链路忽然变得支离破碎。
正确做法是设计双缓冲机制。串口中断回调函数只负责把接收的数据拷贝到内存缓冲区,然后针对于YModem的数据帧结构,当检测到当前帧已经收满(可以根据帧头长度字段判断),就把整帧数据移交到待处理队列里。主循环定期检查待处理队列,一旦发现待处理帧,就把它从队列里取出来,完成Flash写入和ACK响应。这样做之后,串口中断的处理时间被压缩到微秒级,就算Flash写入再慢,也不会影响串口数据接收。
还要注意,缓冲区队列的容量至少要能装下一整块YModem数据帧。YModem最大数据块1024字节,加上帧头帧尾,缓冲建议设置在1.2KB以上,如果用两个缓冲交替,总内存占用不到3KB,对STM32F411来说完全不用担心内存不够。
6.3 从源头改进:打包工具自动生成版本与CRC
很多人做OTA时把打包工具做成一个独立的小程序,写完固件还要手动调命令、填参数,步骤繁琐且容易出错。我后来写了一个命令行打包工具,集成在构建脚本里,每次编译完自动调用。
工具输入是App编译出的bin文件,输出是带固件头的升级文件。它自动处理以下几件事:
- 自动从Git分支或构建环境变量读取版本号,比如v1.2.3,生成fw_version字段。
- 自动计算CRC32。
- 自动检查文件长度,超过分区上限直接报错,阻止错误固件被生产出来。
这样从源头杜绝了“版本号忘改”、“CRC算错范围”这类人为失误。工具代码量很小,用Python写几十行就能搞定,推荐大家也都这样做,别每次都手动敲命令行算CRC,太容易出错了。
升级包的安全问题简单提一句。如果产品部署在公共场所或者竞争对手容易接触到的环境,固件可能被恶意提取和重打包。常见的做法是对固件做加密或签名,Bootloader在验签通过后才执行更新。这不属于本文的核心范围,但如果做商用量产产品,建议在一开始设计的时候就把签名验签模块预留出来,Cortex-M4软解RSA1024或者ECDSA P-256大概几百毫秒到一两秒,配合OTA完全可接受。
6.4 量产阶段容易忽视的几个隐患
到这里基本实现了完整可用的Bootloader+App+W25Q128的YModem OTA链路,但量产和开发是两回事,有几个隐患建议在项目交付前逐一排查:
第一个隐患是Bootloader固件本身的更新问题。Bootloader必须保证足够稳定,因为它一旦坏了,设备就永远无法升级了,只能返厂。所以Bootloader代码要尽量少改动,每次发布前做尽量长时间的稳定性测试,尤其要验证串口连续传输大文件、断电恢复、擦写循环这些极端场景。
第二个隐患是串口引脚被复用的问题。很多MCU的串口引脚同时还映射了其他外设功能,如果App初始化时把这些引脚配置成了GPIO输出,而用户在Bootloader阶段依赖串口升级,那升级时引脚功能可能冲突。所以要确保App和Bootloader对串口引脚的初始化方式一致,避免升级模式下的通信被引脚复用破坏。
第三个隐患是Flash擦写次数监控。W25Q128的寿命虽然很长,但日志反复写入同一地址还是会加速老化。更科学的做法是日志区采用环形写入策略,把写负载均衡到整个日志分区,而不是每次只盯着一块写。
第四个隐患是固件包版本管理的规范化。OTA升级的核心价值在于它可以持续迭代,但如果版本管理混乱,今天升级到v1.2,明天又回退到v1.1,后台只会看到一片混乱的版本号。建议建立严格的发布流程,每个固件包都带唯一版本号和构建时间,设备的升级记录也要能回溯。
7. 最后的一点个人体会
整套方案做下来,我的感受是OTA链路本身的技术难度并不高,真正考验人的是那些容易被忽略的细节:分区的规划是否留了余量、超时机制是否完备、固件校验是否可靠、异常中断后能否恢复。如果你正在规划自己的OTA方案,建议不要一上来就直接写代码,先把分区表和状态机画出来,哪怕只是几张草稿纸,也能帮你避免后面改架构的大返工。我在几次项目的历练中养成的习惯是:任何涉及Flash分区的改动,优先检查Bootloader和App两边的一致性;任何涉及跳转的逻辑,优先检查向量表和链接脚本。排查环境问题比写代码更花时间,把这几个关键点做好了,OTA这条路会顺利很多。