ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

嵌入式OTA升级从零实现:Bootloader分区与回滚机制

嵌入式OTA升级从零实现:Bootloader分区与回滚机制 简介这是一套基于C语言实现的OTA升级源码工程面向嵌入式与物联网设备开发者解决无线固件更新的完整流程设计。压缩包共52个文件以.c/.h源码、Makefile及编译中间文件为主整体仅216KB轻量易移植。工程内包含downloadManager、checksumManager、otaManager、cJSON、usbManager、linklist等模块覆盖文件下载、校验和验证、JSON信息解析、升级流程管理和USB升级通道等关键环节。已有4428人学习/下载可作为学习OTA原理、搭建Bootloader升级链路或开发量产固件更新功能的基础代码。通过阅读源码可掌握HTTP拉取固件、数据完整性校验、失败恢复及多模块协作等实用技巧。1. 为什么嵌入式工程师迟早要自己写一版OTA做嵌入式开发的人迟早都会撞上“远程升级”这个需求。我最早的项目是一台部署在野外的数据采集设备第一版固件烧进去之后发现采集频率算错了要改。车开到现场折腾了四个小时拆机、接线、重新烧录、再装回去。回公司的路上我就决定了下一版必须带OTA。OTA在嵌入式里的本质说白了就是“运行中的程序自己把自己换掉”。这句话听起来很玄实际展开就是Bootloader加App的双分区结构靠一个状态标志位决定启动谁。C语言做这件事有天然优势——指针可以跳转、对Flash操作直接、编译器可控性好而且整个逻辑从底层到上层都能掌握住。这篇文章就把我当时从零写ota升级代码的全过程拆开讲一遍。适合刚接手固件开发、想在产品里加入OTA能力的人参考也适合已经在用现成方案但想搞懂底层原理的工程师。我不会只贴代码还会讲清楚为什么要这样分区、校验放在哪一步、回滚怎么触发以及我实际踩过的几个坑。2. 做OTA前必须想清楚的4个问题2.1 分区划分不是随意切两块Flash就行OTA的前提是芯片里至少要有两个能放固件的区域。一个叫Boot区也叫Bootloader一个叫App区也叫用户程序区。Boot区负责启动检查App区放真正的业务代码。如果你用的是内部Flash以STM32F103为例Flash从0x08000000开始总共512KB。我当时的划分是这样的区域地址范围大小用途Bootloader0x08000000-0x08003FFF16KB启动检查、跳转、升级入口App0x08004000-0x0803FFFF240KB业务固件升级缓存区0x08040000-0x0807FFFF256KB接收新固件暂存标志位区最后1个扇区2KB存储升级标志这里有一个关键点App区的起始地址不是随便定的它必须和链接脚本.icf或.ld文件里的FLASH起始地址保持一致。比如App区起始地址是0x08004000那么链接脚本里就要把FLASH的起始地址改成这个值否则编译出来的固件跳转过去就跑飞。提示分区大小要按扇区边界来切尤其是内部Flash擦除是按扇区操作的切错了会导致擦除操作跨越边界影响相邻区域的数据。2.2 升级状态标志比固件本身更能决定成败整个OTA能不能可靠运行最核心的就是那一个标志位。我在项目里定义了一个结构体单独存放在一个固定扇区typedef struct { uint32_t magic; // 魔数固定为0xA5A5A5A5 uint32_t app_crc; // 新固件的CRC校验值 uint32_t app_size; // 新固件的实际大小 uint8_t state; // 升级状态机 uint8_t reserve[7]; // 保留对齐 } ota_flag_t;状态机的取值就四个#define OTA_STATE_IDLE 0x00 // 空闲正常工作 #define OTA_STATE_READY 0x01 // 已收到完整固件等待重启升级 #define OTA_STATE_UPDATING 0x02 // 正在执行擦写 #define OTA_STATE_ROLLBACK 0x03 // 新固件启动失败需要回滚为什么要单独用魔数来校验这个结构体因为我遇到过一次重启之后标志位区域读出来全是0xFF的情况——Flash擦除到一半断电整个扇区数据全部丢失。如果只靠state一个字节判断读到的0xFF会被误判成某种状态就会走错流程。加上magic之后只要读取结果不等于0xA5A5A5A5就说明标志位数据无效直接走正常启动流程。2.3 完整升级包还是差分包先想清楚再动手OTA的固件传输方式有两种整包升级和差分升级。我第一版用的是整包因为实现简单、逻辑清晰、不容易出错。整包升级就是直接把编译好的整个bin文件传到设备端覆盖写入App区。这种方式的优点是理论简单接收完整个固件包校验通过写入搞定。对网络要求不高传输中断了可以重新传。稳定性好主控端只要做好接收和校验就行。差分包则是把新旧固件的差异提取出来只传输差异部分体积小很多。但代价是在设备端要做“旧固件 差分补丁 新固件”的合并运算。对于主频低、内存小的MCU来说这个运算是比较重的负担。我当时用的是STM32F373主频只有72MHzRAM也就32KB做合并操作非常吃力如果再考虑到补丁格式不兼容导致合并失败的情况排查成本会成倍增加。所以要选哪种方案核心看你的MCU性能和传输带宽。如果芯片主频不到100MHz、Flash在512KB以内老老实实用整包就够了。2.4 回滚策略上线前不设计就是给自己挖坑OTA最怕什么旧版本用着没问题升级完新固件直接把设备变砖了。如果Bootloader不够聪明完全依赖App自我修复那新固件一崩溃可能连Bootloader的入口都回不去。我的方案是“升级前备份、启动时检查、失败自动回滚”。整个升级流程是这样的App运行过程中通过串口/网络收到完整的新固件包校验CRC通过后将其写入升级缓存区。写入标志位扇区把state置为OTA_STATE_READY同时记录新固件的CRC和大小。软复位Bootloader启动。Bootloader检测到state OTA_STATE_READY从升级缓存区读取新固件擦除App区写入新固件。写完后把state置为OTA_STATE_UPDATING然后跳转到App。App启动后运行自检比如外设初始化、通信握手如果自检通过把state置为OTA_STATE_IDLE。如果App在规定时间内没有自检通过看门狗复位Bootloader发现state ! OTA_STATE_IDLE说明新固件没起来就从备份区恢复旧固件。这里备份区直接复用升级缓存区——反正新固件已经写进App区了旧固件在整个擦写之前应该先备份到升级缓存区。3. 关键机制的代码实现校验、写入、跳转3.1 固件包结构头部设计得好能省很多事新固件不能把裸bin直接扔过来我建议在固件包前加一个头部用来做初步的合法性判断。我的固件包格式是这样的typedef struct { uint32_t head_magic; // 固定值0x55AA55AA uint32_t bin_len; // 固件长度 uint32_t bin_crc; // 固件数据CRC32 uint32_t reserve; // 保留 uint8_t version[8]; // 版本号比如1.2.3 uint8_t bin_data[]; // 固件内容 } fw_packet_t;这个头部有32字节。设备端接收时的流程是这样的先收头部检查head_magic对不对再检查bin_len有没有超过设备Flash剩余空间然后开始收数据同时实时计算CRC。全部收完后再比对包里的bin_crc。这样设计的好处是在写入Flash之前就能拦截大部分错误包不需要一遍一遍擦Flash去试。我曾经在调试的时候因为没有在接收阶段就校验CRC导致好几次写入带着损坏数据的固件到Flash里最后启动全部失败排查起来特别费劲。3.2 Flash写入擦除、编程、掉电安全STM32内部Flash的写入有三个边界条件写之前必须擦除、擦除按扇区、写入按16位或32位为单位。直接调用库函数其实不复杂但要注意几个细节。#define APP_BASE_ADDR 0x08004000UL #define CACHE_BASE_ADDR 0x08040000UL #define FLAG_SECTOR_ADDR 0x0807F800UL void flash_write_app(uint8_t *data, uint32_t len) { uint32_t i; uint32_t addr APP_BASE_ADDR; // 1. 先备份旧固件到升级缓存区回滚要用 // 注意擦除缓存区前必须先把旧App完整读出否则没法回滚 // 2. 擦除App区 FLASH_Unlock(); for (i 0; i APP_SECTOR_COUNT; i) { FLASH_ErasePage(APP_BASE_ADDR i * 2048); } // 3. 写入新固件 for (i 0; i len; i 4) { FLASH_ProgramWord(addr, *(uint32_t *)(data i)); addr 4; } FLASH_Lock(); }这里要注意一个坑data指针要确保是4字节对齐的。我最早用了一个uint8_t类型的数组当接收缓冲区取地址强制转成uint32_t*之后在某些编译器优化下会出问题要么是HardFault要么写入的数据错位。后来我把接收缓冲区直接定义成uint32_t数组所有操作都统一用32位来对接这个坑就消失了。掉电安全是另一个容易被忽视的问题。写入App区的过程中如果突然断电新固件是不完整的。所以Bootloader写入完成后不要马上跳转先读回App区的前几个字节对比一下固件头部是否正确再跳转。这个“检查完再跳转”的步骤能大大减少变砖概率。3.3 Bootloader如何跳转到App一个函数指针的事Bootloader跳转App的C语言实现其实非常简单void jump_to_app(void) { uint32_t app_stack_addr *(volatile uint32_t *)APP_BASE_ADDR; void (*app_entry)(void) (void (*)(void))(*(volatile uint32_t *)(APP_BASE_ADDR 4)); // 检查栈顶地址是否在RAM范围内 if ((app_stack_addr 0x2FFE0000) ! 0x20000000) { return; } __set_MSP(app_stack_addr); app_entry(); }原理是STM32Cortex-M内核上电后CPU会自动从起始地址读取栈顶指针从起始地址4的位置读取复位中断向量。跳到App区就是手动做这两件事——把MSP改成App的栈顶然后跳转到App的复位向量。有一个隐藏问题跳转前必须关闭全局中断否则某些外设中断比如定时器在跳转瞬间触发会执行旧的中断服务函数而旧函数地址可能已经不在内存里了。我实际遇到过跳转后莫名其妙进入HardFault的情况查了很久才发现是串口接收中断在跳转瞬间触发导致的。__disable_irq(); /* 关闭所有外设、DMA恢复系统时钟默认状态 */ jump_to_app();另外注意跳转前把SysTick也关掉最好把用到的所有外设都Deinit一下让App端有一个干净的环境起来。3.4 App端接收固件并写入的完整流程App端的逻辑相对简单核心就两件事接收数据、入库。我用的串口Ymodem协议做演示实际产品里你可以换成蓝牙、WiFi、4G逻辑完全一样。// 串口收到一帧数据后的处理函数 int ota_data_handle(uint8_t *buf, uint16_t len) { static uint32_t received_len 0; static uint32_t calc_crc 0; if (ota_state ! OTA_STATE_RECEIVING) { return -1; } // 写入升级缓存区注意地址转换 write_to_flash(CACHE_BASE_ADDR received_len, buf, len); calc_crc crc32_update(calc_crc, buf, len); received_len len; // 如果接收完成 if (received_len fw_header.bin_len) { if (calc_crc ! fw_header.bin_crc) { // CRC不匹配请求重传 ota_state OTA_STATE_IDLE; return -2; } // 标志位置为READY准备重启升级 write_ota_flag(OTA_STATE_READY, fw_header.bin_crc, fw_header.bin_len); NVIC_SystemReset(); } return 0; }这段代码的关键是received_len必须是全局静态变量千万别定义成局部变量。我调试的时候犯过这个错一帧数据进来就清零一次累计固件永远收不完。另外写Flash的地址计算要注意是相对CACHE_BASE_ADDR的偏移不是从头开始写。4. 移植到其他平台要注意的差异点4.1 STM32的完整流程可以直接参考STM32上做OTA的参考代码很多但每个系列的Flash规格不一样。F1系列扇区大小是1KB前4个扇区和2KB后面的扇区F4系列是16KB起步。如果你用的芯片是F4上面代码里的FLASH_ErasePage循环次数就得按扇区表来算不能直接用总大小除以固定扇区大小。我当时从F103移植到F373的时候就被这个坑卡过F373的Flash是64KB一个扇区App区如果从0x08004000开那根本没法擦——那个地址不在扇区边界上。后来调整了分区把App区起点改到扇区对齐的位置问题才解决。4.2 ESP32的OTA和MCU思路完全不一样ESP32的OTA是由ESP-IDF框架直接管理的。它在Flash里预定义了ota_0、ota_1两个分区每个约1.5MB还有factory分区。ESP-IDF的esp_ota_ops.h提供了完整的APIextern const esp_app_desc_t esp_app_desc; esp_ota_handle_t ota_handle; esp_ota_begin(esp_ota_get_next_update_partition(NULL), OTA_SIZE_UNKNOWN, ota_handle); esp_ota_write(ota_handle, data_buf, len); esp_ota_end(ota_handle); esp_ota_set_boot_partition(update_partition); esp_restart();思路和单片机上一模一样写到另一个分区设置下次启动分区重启。只是框架替你完成了分区管理、校验、回滚策略。所以如果你用的是ESP32不建议自己从零写直接用官方API是最稳妥的。那C语言在这里发挥的空间在哪里固件的上报逻辑、下载逻辑、Flash的剩余空间管理、服务器的通信协议解析这些还是得你自己写。尤其是esp_ota_write的数据源如果来自自定义协议协议的解析和分包逻辑是绕不开的。4.3 外部SPI Flash方案如果你的MCU内部Flash不够大固件放不下那就得用外部SPI Flash。这种情况下OTA的写入目标就不是内部Flash了而是通过SPI接口操作外部存储。跳转前需要特别注意Bootloader本身运行在内部FlashApp运行在内部Flash数据暂存在SPI Flash。跳转逻辑不变但校验逻辑要多加一道——App跳转前要把SPI Flash里的固件搬运到内部Flash搬运完成后做最终校验。这个方案里有一个我踩过的坑SPI Flash的页大小通常是256字节写之前要按4KB的扇区擦除读的时候可以跨页连续读但写的时候跨页必须拆成两次。很多人的代码在跨页边界上出问题数据中间少了几十个字节整个固件就废了。5. 上线前一定要做的测试和踩坑记录5.1 升级过程中掉电能不能自动恢复这是OTA可靠性的核心指标。我当时做了一台专门的掉电测试设备在升级流程的不同阶段随机断电反复跑了200多次。结果发现最脆弱的环节有两个一是擦除App区过程中断电此时新旧固件都没了二是写入Bootloader标志位的过程中断电可能导致标志位数据混乱。第一个问题靠备份旧固件解决。我在App区擦除前先完整地把旧固件读出来写到SPI Flash的备份区。擦到一半断电后下次上电Bootloader发现App区的代码不完整头部校验失败就去SPI Flash里把旧固件捞回来重新写入。第二个问题靠双备份标志位解决。在标志位扇区里用了两个副本写入时先写副本A、再写副本B读取时两个副本一致才认为有效。虽然麻烦一点但对可靠性要求高的场景值得。5.2 一个典型的升级失败排查过程有一次设备反馈升级成功但重启后还是旧版本。查了半天最后定位到问题出在App自检通过后没有及时把state置回IDLE。因为我在App启动后只是简单地把某个GPIO置高表示“运行正常”但这个GPIO在旧版本固件里已经初始化过新版本还没初始化到位所以看起来是“运行正常”实际上新App根本没进入主循环。后来把自检逻辑改得更严格App正常启动后会主动向服务器上报一次心跳服务器收到心跳才认为“新版本没问题”这时候服务器发指令让设备把state置为IDLE。如果服务器没收到心跳设备就保持UPDATING状态Bootloader下次启动时自动回滚。5.3 看门狗与升级长任务相爱相杀MCU的独立看门狗IWDG是个好东西但在OTA过程中会变成噩梦。如果升级过程中每写一个扇区要几百毫秒而看门狗超时时间只有1秒升级过程中不喂狗就直接复位了。我在升级过程中干脆把IWDG在进入升级模式时就关闭升级完成重启后再重新开启。如果确实不允许关闭看门狗那就用窗口看门狗在升级代码段里加喂狗逻辑但喂狗必须在窗口时间内完成不然还是复位。5.4 调试OTA的3个实用工具串口日志分级Bootloader的日志和App的日志共用一个UART但用不同前缀区分[BL]和[APP]排查问题时一眼就能看出来当前跑的是哪个阶段的代码。版本号输出Bootloader启动时用GPIO或者串口输出当前App的版本号不用每次都得连调试器才能确认升级结果。Scratchpad测试法在升级缓存区里放一个循环闪烁LED的小程序用它验证“Bootloader跳转App”这条路是否完全通畅。这个小程序只有几百字节写入失败也不心疼比直接拿正式固件测试效率高得多。最后分享一个小经验OTA不是写完就完了一定要做“降级测试”——先升级到新版本再升级回旧版本。很多OTA方案能升不能降升上去就回不来这在产品维护里是致命的。我在做第二版OTA的时候专门把“版本回退”作为一个核心用例设计进去后面出问题的时候这一刀砍下去救了我太多次。本文还有配套的精品资源点击获取
返回列表