ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RT-Thread Studio实战:STM32F411外挂W25Q128实现YModem OTA升级全解析

RT-Thread Studio实战:STM32F411外挂W25Q128实现YModem OTA升级全解析 做过带片外Flash的OTA升级的朋友应该都有体会Bootloader、App、下载区、备份区再多一个W25Q128这类SPI NOR Flash整个分区和升级流程一下就复杂起来。RT-Thread Studio自带的YModem例程默认用的是片内Flash一旦固件超过片内容量或者你想把下载区放到外部Flash来节省内部空间就必须自己动手改造。这篇笔记就是记录我在STM32F411平台上基于RT-Thread Studio用W25Q128做下载分区、走YModem协议完成OTA升级的完整过程和踩坑记录。先说结论这套方案做出来的效果非常稳定。Bootloader放在片内Flash开头App放在片内Flash后续区域两个App备份区和一个下载区放在W25Q128里通过YModem把固件先传到外部Flash再搬运到App分区执行。整个思路不复杂但细节很多尤其是分区表规划、FAL抽象层的配置、以及YModem传输完成后的校验和搬运逻辑每一步都容易出问题。这篇文章适合正在用RT-Thread做产品、需要OTA能力或者被片内Flash容量逼到必须外挂Flash的开发者参考。1. 整体方案设计与分区规划1.1 为什么要把下载区放在片外FlashSTM32F411的片内Flash通常是512KB或1MB如果固件本身就有两三百KB片内空间其实很紧张。OTA升级至少要两个区一个跑当前固件的App区一个临时存放新固件的下载区。如果这两个区都放在片内512KB的芯片基本就没法用了。把下载区放到外部W25Q128是个非常合理的折中方案W25Q128有16MB空间存固件绰绰有余而片内Flash只需要留给Bootloader和App本身。还有一层考虑是安全性和稳定性。YModem走串口传输波特率通常115200一个300KB的固件大概要传30秒左右这期间如果断电最多损坏下载区跑在片内Flash里的正式固件完全不受影响。等到整个固件包接收完整、校验通过之后才一次性搬运到App区。这个“先缓存、后搬运”的设计能最大程度避免升级失败变砖的风险。1.2 方案选型为什么选YModem而不是XModem或自定义协议YModem在嵌入式领域之所以经久不衰核心原因是它解决了XModem最大的痛点文件名和文件大小传输。XModem只传裸数据接收端根本不知道这次收到的固件是谁、多大、版本多少。YModem在起始阶段会先传一个包含文件名和大小信息的数据包接收端可以在校验前就把这些元数据解析出来做版本判断避免传了一半才发现版本不对的尴尬。而且YModem的“逐个文件批量传输”能力也很实用。虽说OTA一般一次只传一个固件但有时候你需要在同一次升级里同时更新Bootloader和App这时候YModem可以顺序传两个文件接收端按顺序写入不同分区就行。这套机制成熟、实现简单、工具链齐全——PC端随便一个SecureCRT或者ExtraPuTTY都原生支持YModem不需要额外开发上位机这也是我选它的一个重要原因。1.3 分区规划片内和片外各管哪一块分区规划是整个OTA方案的基石我强烈建议在一张纸上先画清楚再写代码。我的布局是这样的区域存储介质起始地址大小用途Bootloader片内Flash0x0800000064KB引导、YModem接收、固件搬运App区片内Flash0x08010000448KB当前运行的应用程序下载区W25Q1280x000000001MB暂存YModem上传的新固件备份区AW25Q1280x001000001MB预留用于App备份或双备份升级备份区BW25Q1280x002000001MB预留用于App备份或双备份升级这里有几个关键点要解释一下。Bootloader给64KB是保守的RT-Thread最小配置跑YModem大概在40-50KB左右留64KB是给自己加日志和调试功能留余地。App区从0x08010000开始意味着App的链接脚本里ROM起始地址必须是这个值不能错。片外Flash的1MB下载区看起来很大其实是为了支持将来更大固件预留的W25Q128一共16MB用1MB完全没压力。提示片内Flash的扇区大小因芯片而异STM32F411是128KB一个扇区。Bootloader占64KB意味着它只占半个物理扇区所以App区起始地址必须是128KB对齐否则扇区擦除会把Bootloader尾部擦掉。这一点务必检查芯片参考手册。2. RT-Thread Studio工程配置与W25Q128驱动接入2.1 创建工程与使能必要组件在RT-Thread Studio里新建一个基于STM32F411的裸机工程然后在RT-Thread Settings里勾选需要的组件。我这边的清单是内核必选、FALFlash抽象层、SFUD串行Flash通用驱动、YModem在FinSH组件里、OTART-Thread的OTA组件。有个容易漏掉的地方是YModem组件依赖POSIX层的文件操作接口所以还要在组件配置里打开libc和posix相关的选项否则编译会报一堆open、read、close未定义的错。FAL和SFUD这两个组件是配合使用的。SFUD负责把W25Q128初始化为一个块设备FAL负责把这个块设备按起始地址和大小切成逻辑分区。在RT-Thread Studio的配置界面里FAL可以配置分区表我上面那张表就是填在这里的。配置完成后fal probe命令能直接看到所有分区。2.2 W25Q128接线与驱动配置W25Q128是标准的SPI NOR Flash四线制CS、CLK、MOSI、MISO。我在F411开发板上用的是SPI1引脚分配是PA5接CLKPA6接MISOPA7接MOSIPA4接CS。这个接法比较常规如果你的板子引脚不同记得在Board Configuration里改。SFUD对W25Q128的支持非常完善型号直接在它内置的Flash设备表里。所以驱动这块基本不用写代码关键是SPI设备的初始化要在应用代码里手动调用。我之前犯过一个低级错误只勾选了SPI驱动和SFUD但没在代码里rt_hw_spi_device_attach把CS引脚和SPI总线绑定结果SFUD一直枚举不到Flash设备。#include rtthread.h #include spi.h #include sfud.h #include drv_spi.h static int spi_flash_init(void) { /* 将SPI1总线上挂载的W25Q128设备片选为SPI1_CS_1 */ rt_hw_spi_device_attach(spi1, spi10, GPIOA, GPIO_PIN_4); /* 使用SFUD探测并注册 */ if (rt_sfud_flash_probe(w25q128, spi10) RT_NULL) { rt_kprintf(W25Q128 probe failed.\n); return -RT_ERROR; } return RT_EOK; } INIT_APP_EXPORT(spi_flash_init);rt_hw_spi_device_attach的最后一个参数是CS引脚我用的GPIOA的Pin4。rt_sfud_flash_probe执行完之后FAL就能看到这个Flash块设备了。运行sfud probe spi10命令如果能正确打印出W25Q128的型号和容量说明驱动这一层已经通了。2.3 FAL分区表配置的坑FAL分区表在RT-Thread Studio里有两种配置方式一种是图形化界面里直接填另一种是在fal_cfg.h里手写。我建议用fal_cfg.h手写理由很简单图形界面生成的代码有时候分区起始地址会意外被修改而且版本管理的时候手写文件更容易diff。#ifndef _FAL_CFG_H_ #define _FAL_CFG_H_ #include rtconfig.h #include board.h #define NOR_FLASH_DEV_NAME w25q128 /* Flash设备表 */ extern struct fal_flash_dev nor_flash0; extern struct fal_flash_dev stm32f4_onchip_flash; /* Flash设备表 */ #define FAL_FLASH_DEV_TABLE \ { \ stm32f4_onchip_flash, \ nor_flash0, \ } /* 分区表 */ #define FAL_PART_TABLE \ { \ {FAL_PART_MAGIC_WORD, bootloader, stm32f4_onchip_flash, 0, 64*1024, 0}, \ {FAL_PART_MAGIC_WORD, app, stm32f4_onchip_flash, 64*1024, 448*1024, 0}, \ {FAL_PART_MAGIC_WORD, download, nor_flash0, 0, 1024*1024, 0}, \ {FAL_PART_MAGIC_WORD, backup_a, nor_flash0, 1024*1024, 1024*1024, 0}, \ {FAL_PART_MAGIC_WORD, backup_b, nor_flash0, 2048*1024, 1024*1024, 0}, \ } #endif /* _FAL_CFG_H_ */这里有个特别容易踩的坑stm32f4_onchip_flash这个设备在FAL里默认的起始地址是0x08000000大小是芯片的全部Flash。如果分区表里bootloader的起始地址填0FAL会自动映射到0x08000000这是对的。但App分区的地址必须自己算好64KB偏移对应的是0x08010000这在代码里隐含在了“64*1024”这个偏移量里所以App链接脚本里的ROM地址也必须和这个偏移严格一致。3. YModem协议与OTA组件的工作机制3.1 YModem协议关键细节回顾YModem协议看着简单实际写代码的时候坑不少。它的基本流程是发送方先发一个包含文件名和文件大小的数据包133字节128字节数据加5字节头接收方确认后发送方再按128字节或1024字节的分块发送文件数据。全部发完后发送方发一个结束包接收方回应ACK整个传输结束。在嵌入式端最容易出问题的是这几个点第一块号溢出。YModem的块号是8位最大255超过255会回绕到0。如果固件超过32KB128字节块的情况下块号就会回绕。我的处理方式是直接忽略块号校验只校验数据CRC因为块号在文件传输场景下本来就不太有意义。第二取消机制。YModem协议里接收方连续发送两个CAN0x18表示取消传输。这个机制一定要留好接口否则串口调试助手误触发了什么字符传输状态就卡死了。我一般会在接收循环里判断如果连续两次收到0x18就强制退出并擦除下载区。第三超时与重传。YModem规定了3秒超时重传机制发送方发一个包后如果3秒内没收到应答就会重发。嵌入式接收端必须在这个时间内响应否则体验很不好。我实测下来SecureCRT的YModem发送一个1024字节块后的等待时间是3秒这要求接收端的擦除Flash、写入Flash操作不能超过这个时间阈值否则就必须把擦写操作拆分或者用双缓冲。提示片外SPI Flash页写入通常是256字节所以在接收循环里每收到一个1024字节块要拆成4次页写入。每次页写入前还要确保目标地址是空闲状态。我建议在写之前先判断当前块是否需要擦除用SFUD的sfud_erase_write接口一次搞定省得自己处理擦写边界。3.2 RT-Thread OTA组件的分层设计RT-Thread的OTA升级组件其实是做了分层设计的理解这个分层对后续调试很有帮助。最底层是FAL提供的分区读写接口中间层是OTA组件自己的管理逻辑包括固件头解析、版本校验、拷贝执行等最上层才是YModem这样的传输协议。用YModem做OTA时固件数据流的完整路径是串口 - YModem接收循环 - 下载区分区写入 - 固件头校验 - 拷贝到App分区 - 设置Boot标志 - 复位重启。这个流程里最容易忽略的是固件头。如果不做固件头Bootloader拿到一堆裸二进制数据根本不知道这是不是个合法固件版本号对不对长度是否超出App分区。所以我在生成升级包的时候会在二进制数据最前面拼一个自定义头部结构体。3.3 固件包格式与版本校验设计固件包格式这块我踩过不少坑。起初直接裸传bin文件Bootloader收到后直接搬运后来发现如果传了个损坏文件App区就被覆盖成砖了。所以后来引入了自定义包头typedef struct { uint32_t magic; /* 魔数固定为0x544F4131即TOA1 */ uint32_t firmware_size; /* 固件实际大小不含包头 */ uint32_t firmware_crc; /* 固件数据的CRC32 */ uint32_t version; /* 版本号单调递增 */ uint32_t reserved[3]; /* 保留 */ } firmware_header_t;这个头部一共32字节放在固件文件的最前面。生成升级包时先算原始bin的CRC32和大小填好头然后把头部原始bin拼接成一个新的升级文件。Bootloader收到文件后第一步检查魔数第二步检查CRC第三步检查版本号是否大于当前运行固件版本。全部通过才允许搬运否则直接丢弃并报错。版本号用单调递增的uint32避免用字符串版本号在比较时出幺蛾子。我第一次用v1.2.3这种字符串版本号比较逻辑写起来麻烦不说还容易把v1.10.0和v1.9.0比较错。改成数字版本号之后清爽很多。4. 实操过程Bootloader与App的完整实现4.1 Bootloader端代码结构Bootloader的代码结构不算复杂核心就是一个主循环加状态机。我把它分成四个模块平台初始化模块、YModem接收模块、固件搬运模块、启动跳转模块。主程序的逻辑用状态机来表达清晰且不容易乱typedef enum { STATE_CHECK_APP_VALID, /* 检查App分区是否有合法固件 */ STATE_WAIT_FIRMWARE, /* 等待YModem上传新固件 */ STATE_RECEIVING, /* 正在接收固件数据 */ STATE_VERIFY_FIRMWARE, /* 校验固件头和CRC */ STATE_COPY_FIRMWARE, /* 搬运固件到App分区 */ STATE_BOOT_APP, /* 跳转到App执行 */ STATE_ERROR /* 错误状态打印日志并停止 */ } boot_state_t;这里有个细节值得展开启动时检查App区是否有合法固件是OTA系统里最容易被忽略的一环。如果App区是空的或者已经被擦除了Bootloader不能直接跳转否则就是死机。我的检查方式是读App区起始地址存放的两个值一个是栈指针必须是有效的RAM地址在0x20000000到0x20020000范围内另一个是复位向量必须是有效的Flash地址。两个都满足才跳转。4.2 Bootloader跳转App的实现跳转逻辑是Bootloader里最敏感的代码。网上流传的跳转代码很多但很多都有个小问题。标准写法是typedef void (*app_entry_t)(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); app_entry_t app_entry (app_entry_t)app_pc; /* 关闭全局中断 */ __disable_irq(); /* 设置主栈指针 */ __set_MSP(app_sp); /* 清空中断表防止旧中断响应 */ for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } /* 跳转 */ app_entry(); }这里有两个关键点。第一__disable_irq()必须在跳转前执行否则App启动过程中如果来了中断而中断向量表还没切换好就会进HardFault。第二__set_MSP不只是设置一个寄存器那么简单它还涉及流水线状态所以跳转前最好用__DSB()和__ISB()做一下指令屏障。严谨一点的写法是这样__disable_irq(); __DSB(); __ISB(); __set_MSP(app_sp); __DSB(); __ISB(); app_entry();不要小看这两个屏障我在调试时遇到过一种诡异现象跳转后App偶尔能跑偶尔跑飞时好时坏。查了很久最后发现就是少了ISB指令屏障导致处理器流水线里还有老的指令没冲刷干净跳过去后执行了旧的指令序列。4.3 App端的接收配合与分区写入App端的逻辑就相对简单了它只需要把YModem收到的数据写入下载分区即可。这里我用RT-Thread的YModem组件它已经把协议细节封装好了我们只需要注册一个接收回调static rt_err_t ymodem_recv_callback(ymodem_ctx_t *ctx, uint8_t *buffer, rt_size_t size) { struct fal_part *download_part fal_part_find(download); if (download_part RT_NULL) { return -RT_ERROR; } /* 从下载区当前偏移位置写入数据 */ rt_size_t written fal_part_write(download_part, ctx-file_offset, buffer, size); if (written ! size) { return -RT_ERROR; } return RT_EOK; }整个接收流程调用ymodem_ota_start(ymodem_recv_callback)就行。YModem组件会在每次收到完整数据块后回调一次我们把数据写入download分区。因为YModem最终传完会把偏移量停在文件末尾所以可以在回调里记录一下最终文件大小方便后续校验。固件头校验和版本检查放在接收完成后执行。注意顺序先校验再搬运任何一个环节出错都要停止不要尝试“继续传半个包”。我遇到过有人图省事在接收过程中边收边校验最后发现前面的数据块在传输过程中出错但后面的块已经覆盖了。所以正确的姿势一定是“整包收完 - 统一校验 - 成功才搬运”。4.4 生成YModem升级包并执行升级生成升级包这块我用的是Python脚本在编译完成后自动处理。脚本做的事很简单读入编译出的bin文件计算CRC32和大小填充头部再拼接输出一个新的bin。之后在SecureCRT里选择YModem发送这个新bin即可。import struct import zlib import sys import os def make_ota_package(input_bin, output_bin, version): with open(input_bin, rb) as f: firmware f.read() firmware_size len(firmware) firmware_crc zlib.crc32(firmware) 0xFFFFFFFF magic 0x544F4131 header struct.pack(IIIII, magic, firmware_size, firmware_crc, version, 0) header b\x00 * 12 # reserved with open(output_bin, wb) as f: f.write(header) f.write(firmware) print(fOTA package generated: {output_bin}) print(fFirmware size: {firmware_size} bytes, CRC32: 0x{firmware_crc:08X}) if __name__ __main__: make_ota_package(sys.argv[1], sys.argv[2], int(sys.argv[3]))执行升级时SecureCRT的连接协议要选YModem。这里有一个实用小技巧SecureCRT的逐文件传输剧本功能可以自动完成“发送YModem包-等待完成”的流程不需要手动点。把升级流程脚本化之后产线升级效率能提升很多也不用教工人点那些复杂的界面。4.5 从Bootloader到App的完整启动流程最后把所有环节串起来看一次完整的启动流程是什么样的芯片上电Bootloader开始执行。Bootloader检查App区头部合法性和CRC。如果App合法等待2秒或通过按键进入升级模式超时后跳转App。如果进入升级模式初始化W25Q128清除下载区开始YModem接收。接收完成校验固件头、校验CRC、比较版本号。校验通过后擦除App分区将下载区固件搬运到App分区。设置升级标志复位重启。Bootloader启动检查App合法跳转执行新固件。这个流程里第5步的版本号比较是防止重复升级的第2步的合法性检查是防止App区损坏时变砖的第6步的搬运是存在FW搬迁失败可能性的所以搬运完之后Bootloader要再次校验App区的CRC。我在这个位置犯过一个错误搬运完成后没有重新校验结果有次Flash擦写不稳定App区拷了一半就跳转了直接变砖最后还是靠JTAG救回来的。从那之后我在拷贝完成后再做一次CRC校验稳了很多。5. 常见问题与排查技巧实录5.1 典型问题速查表下面这个表是我在这套方案上踩过的坑里最有代表性的几个整理出来供排查时参考问题现象可能原因解决方案YModem传输一直超时重传无法开始SPI Flash未初始化或设备名不对先执行sfud probe spi10确认Flash设备存在传输完成但校验失败固件头里的CRC计算方式与接收端不一致确认双方都用CRC32多项式相同初始值与最终异或值一致传输完成后跳转App启动即HardFaultApp链接脚本的ROM起始地址与分区表不一致检查App工程的链接脚本确保ROM起始地址是0x08010000跳转后PC跑飞时好时坏缺少指令屏障或中断表没有清理干净在跳转前增加__DSB()和__ISB()清空NVIC挂起中断YModem传输很慢只有每秒几十KB波特率设置过低或Flash页写入效率低把波特率从115200提升到460800或921600用SFUD的整块写入接口Bootloader能收到数据但下载区没数据回调函数写入的偏移量未按实际接收长度增加检查回调里是否使用了ctx-file_offset作为写入偏移固件包能传但版本号比较逻辑混乱版本号用字符串表示统一改为uint32递增数字版本号5.2 两个容易忽略的深坑上面表格里列的方案都能解决但有两个深坑我想单独拿出来详细说。第一个是关于SPI Flash擦除的耗时问题。W25Q128一个扇区4KB擦除时间典型是45ms如果你的固件有300KB下载区1MB分成256个扇区全片擦除一次大概要12秒。这个时间必须在YModem开始传输之前完成不能在接收过程中边擦边写。我一开始没注意这个问题把擦除放在第一次写入回调里执行结果收到第一个块之后一直在擦片擦区YModem超时重传了好几轮整个传输过程非常不稳定。后来我把擦除操作移到YModem接收之前一次性擦完整个下载区问题彻底消失。第二个是关于边界对齐的写操作。W25Q128的最小可编程单位是1字节但实际写入最小单位是页256字节。当你用SFUD的sfud_write写数据时如果起始地址或结束地址没有按页边界对齐SFUD内部会做处理但处理逻辑是先读回整页数据再改写部分字节再整页写回。这个逻辑本身没问题问题在于它会对未擦除的区域做读改写而如果你之前没有先擦除读到的是0xFF以外的数据那你写入的数据就会被旧数据污染。所以正确的操作顺序一定是先擦除整个目标区域再分区按块写入。5.3 调试技巧如何快速定位问题调试OTA这种功能最痛苦的是“现象在Bootloader和App之间切换分不清哪边的问题”。我习惯在Bootloader里加完整的日志输出所有关键节点都打一行。特别是YModem接收过程中每收到一个块就打印一个进度条这样能直观看到传输是否卡住。另一个技巧是给Bootloader和App分别配置独立的串口日志前缀。Bootloader的日志统一以[BOOT]开头App的日志以[APP]开头。跳转后如果还能看到[BOOT]的日志说明跳转失败了如果只能看到[APP]的日志说明App已经正常运行。别小看这个简单的约定在调试时它帮我省了大量时间。还有一个小技巧是专门针对“下载区搬运到App区失败”这种问题的。如果搬运完后CRC校验失败但下载区数据本身是好的大概率是搬运过程中的Flash写入问题。我会在Bootloader里留一个“从下载区重新搬运”的命令行入口这样不需要重新通过YModem传文件直接在命令行触发一次搬运就行调试效率高很多。6. 这套方案的局限与后续扩展说句实在话YModem 串口这套OTA方案虽然稳定可靠但也有它的天花板。最明显的一点是必须靠人工介入没法远程触发。如果产品部署在客户现场想要远程升级固件那YModem这套就不合适了必须换HTTP或者MQTT这类网络传输方式。我现在这个方案里其实已经预留了升级接口后续如果要做网络OTA基本上只需要做两件事一是把YModem接收模块替换成HTTP下载模块App在运行时可以通过网络下载固件包到下载区二是下载完之后的校验、搬运、跳转逻辑完全不用动因为它们本来就和传输协议解耦了。还有一点是关于双备份升级。我计划在W25Q128的backup_a和backup_b分区做双备份。思路是新固件先写到backup_a然后从backup_a搬运到App区这样即使App区写入失败还可以从backup_a重新搬运甚至Bootloader可以支持从上一次备份的backup_b引导。这种设计能进一步降低变砖概率但现阶段对大多数场景来说下载区加单备份已经够用了双备份等有需求再加。根据我的测试这套方案在115200波特率下传一个300KB的固件包大约需要35秒加上校验和搬运的时间总体升级时间在40秒左右。如果波特率提高到460800时间能压到10秒以内。不过要提高波特率前提是你的串口芯片和线材质量足够好不然误码率上来了反而更慢。这篇文章写到的所有关键点都是我在这块板子上实实在在跑通了的。从分区规划到YModem接收从Bootloader跳转到固件校验每一步都踩过坑、填过坑。如果你正在做类似的事情希望这些记录能帮你少走一些弯路。尤其是分区表、链接脚本、跳转屏障这几个位置一次配好后面就非常省心了。
返回列表