1. 为什么EC800的OTA升级值得单独拿出来讲
移远EC800这颗4G Cat.1模块,这两年出货量非常大,价格便宜、功耗控制得不错、AT指令集也相对规整,很多做远程抄表、共享设备、环境监测的团队都在用。但真正把OTA升级跑通、跑稳的人并不多。我见过太多项目,前期功能都调通了,一到远程升级环节就翻车——要么升级包传了一半断了,要么CRC校验对不上导致设备变砖,要么升级完新固件起不来又没有回滚机制。
STM32这边的OTA升级,本质上要解决三个问题:固件从哪里来、怎么安全地写进去、写坏了怎么办。EC800负责第一个问题,它通过4G网络从服务器拉取固件包;STM32负责后两个问题,它要把固件写到内部Flash或外部存储,校验完整性,然后跳转执行。CRC校验是保证"写进去的东西和服务器上的一模一样"的关键手段,错误处理则是保证"万一不一样,设备还能活着"的兜底逻辑。
这篇文章面向的是已经能用STM32驱动EC800做基本通信、现在想把OTA升级做扎实的开发者。我会从Flash分区规划开始讲,到EC800的HTTP/HTTPS取包流程、CRC32校验的实现、双Bank切换与回滚机制,最后给出几个我在实际项目中踩过的坑和对应的处理方案。代码基于STM32 HAL库,EC800走AT指令,不依赖特定云平台,你可以直接移植到自己的项目里。
注意:OTA升级涉及Flash擦写和程序跳转,调试阶段务必保留SWD接口可用,否则一旦跳转失败就只能拆机接串口了。
2. Flash分区规划:升级方案的地基
2.1 为什么不能把新固件直接覆盖到运行区
很多人第一反应是:收到固件包,直接擦掉原来的APP区,写进去,重启。这个做法在实验室里能跑通,但在现场设备上极其危险。原因很简单——擦写过程中一旦断电、断网或模块复位,设备就彻底变砖了,因为原来的程序已经被擦掉,新的还没写完。
正确的做法是在Flash里划出至少两个区域:一个放当前运行的固件(Active区),一个放待升级的新固件(Download区或Backup区)。新固件先完整写入Download区并校验通过,再通过一个"升级标志"告诉Bootloader:下次启动时把Download区的内容搬到Active区,或者直接把执行入口切到Download区。这样即使升级过程中出问题,原来的Active区始终是完整的。
2.2 以STM32F103C8T6为例的分区表
STM32F103C8T6有64KB Flash,分区要精打细算。下面是我在一个实际项目中用的方案:
| 区域名称 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 12KB | 启动判断、固件搬运、跳转 |
| 参数区 | 0x08003000 | 2KB | 升级标志、固件版本、CRC值 |
| Active APP | 0x08003800 | 24KB | 当前运行固件 |
| Download APP | 0x08009800 | 24KB | 待升级固件暂存 |
| 保留 | 0x0800F800 | 2KB | 预留扩展 |
Bootloader放在最前面是因为STM32上电后固定从0x08000000取栈顶指针和复位向量,这个位置改不了。参数区单独划出来是为了避免每次升级都擦写Bootloader所在的页,STM32F1的Flash页大小是1KB,擦写次数有限,分开管理更安全。
Active区和Download区大小必须一致,且要能装下你的APP。24KB对于功能不太复杂的应用够用,如果你的固件超过这个尺寸,要么换更大Flash的型号(比如C8T6换成RCT6,256KB),要么外挂SPI Flash做存储。外挂Flash的好处是容量大、成本低,坏处是读取速度慢,跳转执行前需要搬运到内部RAM或内部Flash,多了一步。
2.3 参数区的数据结构设计
参数区虽然只有2KB,但要存的东西不少。我通常定义一个结构体,放在固定地址:
typedef struct { uint32_t magic; // 固定值0x5A5A5A5A,用于判断参数区是否已初始化 uint32_t upgrade_flag; // 0=无升级 1=待升级 2=升级成功待确认 uint32_t firmware_size; // 新固件字节数 uint32_t firmware_crc; // 新固件CRC32值 uint32_t firmware_version;// 版本号,如0x00010002表示V1.2 uint8_t reserved[236]; // 预留 } OTA_Param_t;magic字段很关键。第一次烧录时参数区是0xFF,读出来magic不对,Bootloader就知道要初始化参数区。upgrade_flag用三个状态而不是简单的0/1,是为了支持"升级成功待确认"机制——新固件启动后要主动把flag改成0,表示"我跑起来了",否则Bootloader下次启动会认为升级失败并回滚。这个机制后面会详细讲。
3. EC800取包:从AT指令到固件落盘
3.1 EC800的HTTP AT指令流程
EC800支持HTTP和HTTPS,走AT指令操作。整个取包流程分几步:配置HTTP上下文、设置URL、发起GET、读取响应头、分块读取响应体、关闭连接。下面是我实际用的指令序列:
AT+QHTTPCFG="contextid",1 # 使用PDP上下文1 AT+QHTTPCFG="responseheader",1 # 开启响应头输出 AT+QHTTPURL=64,10 # 设置URL长度64,超时10秒 # 模块返回CONNECT后,发送URL字符串 AT+QHTTPGET=60 # 发起GET,超时60秒 # 等待+QHTTPGET: 0,200,<body_len> AT+QHTTPREAD=60 # 读取响应体,超时60秒 # 模块返回CONNECT,开始输出数据这里有几个细节值得说。AT+QHTTPURL设置完长度后,模块会返回CONNECT,此时要在规定时间内把URL字符串发过去,不能带回车换行。AT+QHTTPGET的响应里会带上HTTP状态码和响应体长度,这个长度就是固件包的字节数,要存到参数区里用于后续校验。
AT+QHTTPREAD读出来的数据是原始二进制,不是文本。如果你用串口助手调试,会看到一堆乱码,这是正常的。关键是要在STM32端做好接收缓冲,EC800输出数据的速度取决于串口波特率,我一般用115200,读24KB大概需要2-3秒。
3.2 分块接收与Flash写入的配合
EC800的AT+QHTTPREAD是一次性把整个响应体吐出来的,但STM32的串口接收缓冲不可能开24KB那么大(RAM不够)。我的做法是:开一个2KB的环形缓冲,串口中断往里填,主循环从环形缓冲取数据,攒够1KB就写一次Flash。
写Flash要注意:STM32F1的Flash编程单位是半字(16位),也就是每次必须写2字节。所以攒的数据长度最好是偶数。另外Flash写入前必须先擦除,而擦除的最小单位是页(1KB)。这意味着你不能"边收边擦边写"——擦除会把整页清掉,如果这一页已经写了数据,再擦就丢了。
正确的顺序是:先擦除整个Download区,再从头开始顺序写入。擦除24KB需要擦24页,每页擦除时间大概20-40ms,总共不到1秒。擦完之后再开始接收数据,收到1KB就写1KB,写完一页再写下一页,不能跳着写。
// Flash写入函数示例(HAL库) HAL_StatusTypeDef flash_write(uint32_t addr, uint8_t *data, uint32_t len) { HAL_FLASH_Unlock(); for (uint32_t i = 0; i < len; i += 2) { uint16_t halfword = data[i] | (data[i+1] << 8); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr + i, halfword) != HAL_OK) { HAL_FLASH_Lock(); return HAL_ERROR; } } HAL_FLASH_Lock(); return HAL_OK; }3.3 断点续传的取舍
理论上可以做断点续传:记录已经下载了多少字节,下次从断点继续。但实际做下来,我觉得对于24KB这个量级的固件,断点续传的复杂度不值得。原因有三:一是EC800的HTTP GET不支持Range头(至少我用的固件版本不支持),没法从中间开始取;二是断点信息本身要存Flash,增加了擦写次数;三是24KB在4G网络下也就几秒钟的事,重头再来成本很低。
所以我的策略是:要么一次下完,要么全部重来。下载过程中如果超时或CRC不对,直接清空Download区,重新发起GET。这样逻辑简单,可靠性反而更高。如果你的固件到了几百KB甚至MB级别,那断点续传就有必要了,可以考虑用EC800的FTP模式或者自己实现分片协议。
4. CRC32校验:不只是算个值那么简单
4.1 为什么选CRC32而不是简单求和
校验固件完整性,最朴素的做法是把所有字节加起来取低16位。但这个做法有个致命问题:它检测不出字节顺序错误。比如固件里有两个字节0x12和0x34,不管它们是"0x12 0x34"还是"0x34 0x12",求和结果都是0x46。如果传输过程中字节顺序乱了,简单求和发现不了。
CRC32(循环冗余校验)则对字节顺序敏感,而且能检测出绝大多数随机错误和突发错误。它的原理是把数据看成一个巨大的二进制多项式,除以一个固定的生成多项式,余数就是CRC值。STM32有硬件CRC外设,但只支持CRC32的特定多项式(0x04C11DB7),和标准CRC32一致,可以直接用。
不过要注意:STM32硬件CRC外设的输入是32位字,不是字节。如果你按字节喂数据,需要做移位处理。我一般直接用软件查表法,速度快、移植方便,不依赖特定硬件。
4.2 软件CRC32查表实现
static const uint32_t crc32_table[256] = { 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, /* ... 省略中间项 ... */ }; uint32_t crc32_calc(uint8_t *data, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i++) { crc = (crc >> 8) ^ crc32_table[(crc ^ data[i]) & 0xFF]; } return crc ^ 0xFFFFFFFF; }这个表有256个32位值,占1KB Flash。如果你Flash紧张,可以用半字节查表(16项),速度慢一点但省空间。实测在STM32F103上,72MHz主频,算24KB数据的CRC32大概需要3-5ms,完全可以接受。
4.3 校验时机与失败处理
CRC校验要在两个地方做:下载完成后校验一次,确认Download区的内容和服务器一致;Bootloader搬运前再校验一次,确认存放期间没有出问题(虽然概率极低,但Flash位翻转不是不可能)。
校验失败的处理逻辑要分场景。如果是下载后校验失败,说明传输过程出了问题,直接清空Download区、清除升级标志、重新下载。如果是Bootloader搬运前校验失败,说明Download区的数据不可信,此时绝对不能搬运,应该清除升级标志,继续运行原来的Active区,同时记录一个错误码,等下次联网时上报给服务器。
提示:CRC值本身也要存到参数区,而且参数区的写入要在CRC校验通过之后。顺序是:下载完成 → 算CRC → 比对 → 通过则写参数区 → 置升级标志。
5. Bootloader的搬运逻辑与回滚机制
5.1 Bootloader的启动判断流程
Bootloader上电后要做的事,按优先级排:先检查参数区magic是否有效,无效则初始化;然后检查upgrade_flag,如果是"待升级",校验Download区CRC,通过则搬运,不通过则清除标志;如果是"升级成功待确认",说明上次升级后新固件没起来,需要回滚;最后跳转到Active区执行。
这个流程里,"升级成功待确认"状态是最容易被忽略的。它的逻辑是:Bootloader搬运完新固件后,把flag置为"待确认",然后跳转到新固件。新固件启动后,在初始化完成的某个时间点(比如连上服务器、或者跑完自检),主动把flag改为0。如果新固件有bug起不来,flag就一直是"待确认",下次重启时Bootloader就知道"上次升级失败了",于是把Active区恢复成备份的旧固件。
5.2 搬运过程中的断电保护
搬运24KB数据,按半字编程,大概需要24KB/2=12288次编程操作,每次几十微秒,总共不到1秒。但这1秒内如果断电,Active区就被写了一半,原来的固件也毁了。所以搬运前必须先把旧固件备份到另一个区域。
我的做法是:Download区其实承担了双重角色——升级时它是新固件的暂存区,搬运时它是旧固件的备份区。具体流程是:先把Active区的旧固件复制到Download区(此时Download区的新固件已经被校验过,可以丢弃),复制完成后再把新固件从Download区搬到Active区。这样任何时刻至少有一个完整的固件存在。
等等,这里有个矛盾:Download区只有一个,既存新固件又存旧固件,放不下。所以实际方案要么是三个区(Active、New、Backup),要么是搬运时先把新固件从Download区读到RAM,再擦Active区,再从RAM写Active区。24KB的RAM对STM32F103C8T6(20KB RAM)来说放不下,所以只能用三区方案,或者外挂Flash。
对于Flash只有64KB的型号,三区方案确实紧张。我的建议是:如果固件超过16KB,直接换256KB Flash的型号,比如STM32F103RCT6,分区就宽裕多了。省下来的调试时间和变砖风险,远比芯片差价值钱。
5.3 跳转到APP的代码细节
跳转前要做的准备:关闭所有中断、关闭外设时钟、设置主栈指针为APP区的第一个字、设置PC为APP区的第二个字。代码如下:
typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t stack_top = *(volatile uint32_t*)app_addr; uint32_t reset_handler = *(volatile uint32_t*)(app_addr + 4); __disable_irq(); HAL_DeInit(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; SCB->VTOR = app_addr; // 重定向中断向量表 __set_MSP(stack_top); pFunction app_entry = (pFunction)reset_handler; app_entry(); }SCB->VTOR这行很关键。APP区的固件编译时链接地址是0x08003800,它的中断向量表也在那个位置。如果不重定向VTOR,中断发生时CPU还是去0x08000000找向量表,就会跳到Bootloader的中断处理函数,导致APP的中断全部失效。这个问题我在早期项目中遇到过,现象是APP能跑但串口接收中断不触发,查了半天才发现是VTOR没设。
6. 错误处理:那些让你半夜爬起来的情况
6.1 EC800取包失败的分类处理
EC800取包可能失败在多个环节,每种失败的处理方式不同:
| 失败环节 | 典型现象 | 处理策略 |
|---|---|---|
| PDP激活失败 | AT+QIACT返回ERROR | 检查SIM卡、天线、APN配置,重试3次后上报 |
| HTTP连接失败 | AT+QHTTPGET返回错误码 | 检查URL、服务器状态,退避重试 |
| 响应码非200 | +QHTTPGET: 0,404,0 | 固件不存在或URL错误,清除升级标志 |
| 读取超时 | AT+QHTTPREAD无响应 | 复位HTTP上下文,重新发起 |
| 数据长度不符 | 实际接收字节数≠响应头声明 | 清空Download区,重新下载 |
退避重试的策略很重要。不要失败后立刻重试,那样如果服务器挂了,设备会疯狂发请求。我的做法是:第一次失败等30秒,第二次等2分钟,第三次等10分钟,三次都失败就放弃本次升级,等下一个升级周期(比如24小时后)再试。
6.2 Flash写入失败的兜底
Flash写入失败在正常情况下很少见,但一旦发生就很麻烦。可能的原因:擦除不彻底、地址越界、供电不稳导致编程失败。HAL库的HAL_FLASH_Program返回错误时,我的处理是:立即停止写入、清除升级标志、记录错误码、重启设备。不要试图"再写一次",因为Flash编程失败往往意味着硬件状态异常,继续写可能把参数区也搞坏。
还有一个隐蔽的坑:Flash擦写期间如果EC800正在接收数据,串口中断会打断Flash操作。STM32F1的Flash编程期间,CPU从Flash取指会被暂停,如果中断服务函数也在Flash里,就会导致中断响应延迟甚至丢失。我的做法是:写Flash前先关闭串口接收中断,写完再开。或者把中断服务函数放到RAM里执行,但这需要修改链接脚本,比较麻烦。
6.3 升级后外设不工作的排查思路
新固件跑起来了,但串口不通、屏幕不亮、传感器读不到——这种情况我遇到过好几次。根本原因通常是新固件的初始化代码和Bootloader有冲突。比如Bootloader开了某个外设时钟没关,APP又初始化一次,导致外设状态异常。
排查方法:在APP的初始化代码最前面,先执行一次HAL_DeInit()或者手动复位所有外设,再重新初始化。另外,APP里要重新配置SysTick,因为Bootloader跳转前把SysTick关了。还有NVIC的中断优先级分组也要重设,Bootloader和APP如果分组不同,中断行为会不一致。
注意:APP的链接地址必须和分区表里的Active区起始地址一致,否则跳转后取指会跑飞。Keil里在Target选项的IROM1里设置,IAR里在Linker的Vector Table里设置。
7. 实测数据与几个反直觉的结论
7.1 升级耗时拆解
我在一个实际项目里测过完整升级流程的耗时(EC800走4G,信号良好,STM32F103C8T6,72MHz):
| 阶段 | 耗时 | 占比 |
|---|---|---|
| PDP激活+HTTP连接 | 3-5秒 | 约20% |
| 固件下载(24KB) | 8-12秒 | 约50% |
| CRC校验 | 3-5毫秒 | 忽略不计 |
| Flash擦除+写入 | 1-2秒 | 约10% |
| Bootloader搬运+跳转 | 1-2秒 | 约10% |
| 新固件启动自检 | 1-3秒 | 约10% |
总耗时大概15-25秒。其中下载占了大部分时间,这是4G网络带宽和EC800处理速度共同决定的。想缩短时间,要么压缩固件体积(开-Os优化、去掉不用的库),要么提高串口波特率(EC800支持到921600,但STM32F1的串口在高波特率下容易出错,我一般用115200或230400)。
7.2 CRC32的碰撞概率到底有多低
有人担心CRC32会碰撞——两个不同的固件算出相同的CRC值。理论上确实可能,CRC32有2^32种取值,对于24KB的固件,随机碰撞概率大约是2^-32,也就是约23亿分之一。这个概率比你设备被雷劈中还低。如果实在不放心,可以在CRC之外再加一个MD5或SHA1,但那样计算量大很多,对STM32F1来说不划算。
我的观点是:CRC32用于固件完整性校验足够了。它防的是传输错误和存储错误,不是防恶意篡改。如果要做安全升级,那应该用数字签名,那是另一个层面的问题。
7.3 为什么我不推荐在APP里做升级逻辑
有些方案把升级逻辑放在APP里:APP收到升级指令,自己下载固件,自己写Flash,自己跳转。这个做法的问题是:APP在运行时要擦写自己所在的Flash区域,风险极高。虽然技术上可以通过把擦写代码放到RAM里执行来规避,但复杂度陡增,而且一旦跳转失败,连个兜底的Bootloader都没有。
我的建议是:APP只负责通信和触发,真正的下载、校验、搬运全部由Bootloader完成。APP收到升级指令后,置一个标志、重启,剩下的交给Bootloader。这样职责清晰,APP的代码量也小,Flash占用少,分区压力小。
8. 写在最后:几个让我印象深刻的现场问题
第一个是EC800在弱信号下的HTTP超时。现场设备在地下室,信号只有2格,AT+QHTTPGET经常超时。后来我把超时从60秒加到120秒,并且在GET之前先发AT+CSQ检查信号质量,低于10就跳过本次升级。这个改动之后,升级成功率从70%提到了95%以上。
第二个是Flash擦除时的看门狗复位。Bootloader里开了独立看门狗,擦除24页Flash需要1秒左右,如果看门狗超时设得太短(比如500ms),擦到一半就被复位了。解决办法是在擦除循环里定期喂狗,或者把看门狗超时设到3秒以上。
第三个是参数区的写保护。有一次调试时手滑,用ST-Link Utility把整个Flash都擦了,参数区也没了。虽然Bootloader能重新初始化参数区,但升级标志丢了,设备就停在旧固件上不动了。后来我在参数区前后各加了一个magic值,只有两个magic都对才认为参数有效,这样即使部分被擦也能检测出来。
OTA升级这件事,说难不难,说简单也不简单。核心就是把"下载-校验-搬运-回滚"这条链路做扎实,每个环节都有失败预案。代码写完之后,一定要做断电测试——在下载中、擦除中、搬运中分别拔电,看设备能不能恢复。这个测试过了,现场基本就不会出大问题了。