拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

STM32+EC800 4G模块OTA升级实战:TCP透传、CRC校验与Flash分区

STM32+EC800 4G模块OTA升级实战:TCP透传、CRC校验与Flash分区 1. 为什么要在STM32上折腾EC800的OTA升级做过嵌入式项目的兄弟都清楚设备一旦出货到现场最怕的就是发现固件有bug或者要加新功能。你不可能把设备一个个拆回来重新烧录尤其是那些装在配电柜里、挂在电线杆上、埋在管道井里的设备。OTA远程升级就是解决这个痛点的唯一出路。我这次做的项目是一个基于STM32F103的工业数据采集终端通过移远EC800 4G模块把现场数据传到云端。EC800是Cat.1模块支持LTE FDD/TDD内置TCP/IP协议栈通过AT指令就能跟服务器通信性价比很高。项目做到一半客户提了个需求固件要能远程升级。这就意味着我得在STM32上实现一套完整的OTA方案包括固件分包下载、CRC校验、Flash写入、错误重试、断点续传最后还要能安全跳转到新固件。为什么选EC800来做OTA因为它支持TCP透传模式STM32只需要通过串口发AT指令建立TCP连接然后直接收发数据就行不用在STM32上跑复杂的HTTP或者MQTT协议栈。EC800本身有足够的缓冲区AT指令响应也快实测下来TCP下行速率能稳定在1Mbps左右对于几百KB的固件来说几分钟就能下完。这套方案适合谁适合那些用STM324G模块做物联网终端需要远程升级固件的开发者。不管你是用F103、F407还是H7系列核心思路是一样的。你需要对STM32的Flash操作、串口通信、CRC校验有基本了解如果用过FreeRTOS或者裸机跑过状态机那就更好了。注意OTA升级涉及Bootloader和APP两部分Bootloader负责接收新固件并写入FlashAPP负责业务逻辑。两者需要配合好否则升级失败可能导致设备变砖。2. 整体方案设计与核心思路拆解2.1 Bootloader与APP的分区规划STM32F103C8T6有64KB Flash听起来不多但跑一个Bootloader加一个APP足够了。我的分区方案是这样的区域起始地址大小用途Bootloader0x0800000016KB负责OTA升级、跳转APPAPP0x0800400044KB业务逻辑参数区0x0800F0002KB存储升级标志、固件信息备份区0x0800F8002KB存储旧固件版本信息Bootloader放在最前面因为STM32上电后从0x08000000开始执行。APP偏移0x4000也就是16KB处。参数区用来存升级标志位比如0xAA表示需要升级0x55表示升级完成。备份区存旧固件的版本号和CRC方便回滚。为什么这么分Bootloader要尽量小16KB足够放串口驱动、Flash驱动、CRC校验和状态机。APP有44KB对于大多数应用够了。如果APP超过44KB就得换更大Flash的芯片或者压缩Bootloader。提示分区地址必须是Flash页大小的整数倍。STM32F103的Flash页大小是1KB所以0x4000、0xF000都是1KB的整数倍没问题。2.2 为什么选TCP透传而不是HTTPEC800支持HTTP、MQTT、TCP等多种协议。我选TCP透传的原因很简单STM32端实现最简单。如果用HTTPSTM32要构造HTTP请求头、解析响应头、处理分块传输代码量大且容易出错。MQTT虽然轻量但需要维护心跳、订阅主题对于只需要下载固件的场景来说太重了。TCP透传模式下STM32只需要发AT指令建立连接然后服务器直接推固件数据流过来。我在服务器端写了一个简单的TCP服务收到STM32的连接请求后先发一个固件信息包包含固件大小、CRC32、版本号然后按1KB一包发固件数据。STM32每收到一包就校验一次写入Flash发ACK服务器收到ACK再发下一包。这种一问一答的模式虽然效率不是最高但胜在可靠。如果某一包丢了或者CRC错了STM32不发ACK服务器超时重发。实测在4G网络下1KB一包的传输成功率在99.5%以上偶尔丢包重传一次也就几百毫秒的事。2.3 CRC校验在整个流程中的位置CRC校验在OTA里太重要了。4G网络虽然可靠但串口通信、Flash写入都可能出错。我在三个地方用了CRC第一每包数据校验。服务器发的每包数据末尾带2字节CRC16STM32收到后先算CRC16跟包尾的比对对了才写入Flash。这能挡住传输过程中的位翻转。第二整包固件校验。所有包收完后STM32从Flash里把整个固件读出来算CRC32跟服务器一开始发的固件信息包里的CRC32比对。这能挡住Flash写入错误。第三APP自校验。APP启动后先算自己的CRC32跟参数区里存的比对对了才继续跑。这能挡住Flash位翻转导致的程序跑飞。CRC16我用的是Modbus多项式0xA001CRC32用的是标准多项式0xEDB88320。这两个在嵌入式里很常见查表法实现起来很快1KB数据算CRC16不到1ms。3. 核心细节解析与实操要点3.1 EC800的AT指令配置与TCP连接建立EC800上电后先要配置好串口和AT指令。我用的串口波特率是1152008位数据位1位停止位无校验。EC800默认波特率是115200如果之前被改过需要发ATIPR115200恢复。建立TCP连接的AT指令序列如下AT # 测试模块是否响应 ATCPIN? # 查询SIM卡状态 ATCSQ # 查询信号质量 ATQICSGP1,1,CMNET # 配置APN根据运营商改 ATQIACT1 # 激活PDP上下文 ATQIOPEN1,0,TCP,your.server.com,8080,0,1 # 建立TCP连接 ATQISEND0,1024 # 发送数据长度1024ATQIOPEN的参数含义第一个1是连接ID第二个0是TCP模式后面是服务器地址和端口倒数第二个0是本地端口0表示自动分配最后一个1表示直接进入透传模式。透传模式下STM32发什么EC800就原样发到服务器。服务器发什么EC800就原样通过串口吐给STM32。这时候STM32不能再发AT指令了要退出透传得发“”等EC800回“OK”后再发AT指令。注意EC800进入透传模式后如果串口收到“”会退出透传。所以固件数据里如果恰好有“”这三个字节会误触发退出。解决办法是在服务器端对固件数据做转义或者不用透传模式用ATQISEND手动发数据。我后来改用了非透传模式因为固件数据里出现“”的概率虽然低但一旦出现就是灾难性的。非透传模式下STM32发ATQISEND0,1024等EC800回“”再发1024字节数据发完后EC800回“SEND OK”。虽然多了一步交互但安全。3.2 Flash写入的页对齐与双缓冲STM32F103的Flash写入必须按页擦除按半字16位写入。擦除一页是1KB写入一次最少2字节。如果直接往Flash里写固件数据每写一包都要擦一页效率极低。我的做法是双缓冲在RAM里开两个1KB的缓冲区一个用来接收当前包一个用来攒够一页的数据。当攒够1KB时擦除Flash的一页把缓冲区里的1KB写进去。这样每1KB才擦一次效率高很多。#define PAGE_SIZE 1024 uint8_t buf_a[PAGE_SIZE]; uint8_t buf_b[PAGE_SIZE]; uint8_t *recv_buf buf_a; uint8_t *write_buf buf_b; uint32_t write_addr APP_START_ADDR; void flash_write_page(uint32_t addr, uint8_t *data) { FLASH_Unlock(); FLASH_ErasePage(addr); for (int i 0; i PAGE_SIZE; i 2) { uint16_t half_word data[i] | (data[i1] 8); FLASH_ProgramHalfWord(addr i, half_word); } FLASH_Lock(); }为什么用半字写入因为STM32F103的Flash编程接口就是半字为单位。如果你一次写4字节得调用两次FLASH_ProgramHalfWord。实测下来写1KB数据大概需要5ms擦除1KB大概需要20ms。整个44KB的APP擦写时间加起来不到1.5秒可以接受。提示Flash擦写期间CPU会暂停如果这时候串口来了数据会丢。所以我在擦写前先发ATQISEND告诉服务器暂停发送擦写完再发ATQISEND让服务器继续。或者用DMA接收串口数据擦写期间DMA照样往缓冲区里存。3.3 CRC16和CRC32的查表法实现CRC校验如果用逐位计算1KB数据要算几千次循环太慢。查表法把256种字节的CRC值预先算好存到数组里计算时直接查表速度快10倍以上。CRC16 Modbus的查表法实现static const uint16_t crc16_table[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, /* ... 省略中间 ... */ 0x8201, 0x42C0 }; uint16_t crc16_modbus(uint8_t *data, uint32_t len) { uint16_t crc 0xFFFF; while (len--) { crc (crc 8) ^ crc16_table[(crc ^ *data) 0xFF]; } return crc; }CRC32的查表法类似只是表更大有256个32位值。我用的多项式是0xEDB88320初始值0xFFFFFFFF最后结果取反。static const uint32_t crc32_table[256] { 0x00000000, 0x77073096, 0xEE0E612C, /* ... 省略中间 ... */ 0x2D02EF8D }; uint32_t crc32_calc(uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; while (len--) { crc (crc 8) ^ crc32_table[(crc ^ *data) 0xFF]; } return crc ^ 0xFFFFFFFF; }这两个表加起来占1KB多的Flash空间对于16KB的Bootloader来说可以接受。如果Flash紧张可以把CRC32的表去掉用逐位计算但那样算44KB固件要好几秒用户体验差。注意CRC16和CRC32的初始值、多项式、结果异或值必须跟服务器端一致否则校验永远不过。我一开始服务器端用了CRC16/CCITTSTM32端用了CRC16/Modbus结果每包都校验失败查了半天才发现是多项式不一样。4. 实操过程与核心环节实现4.1 服务器端固件分包与发送逻辑服务器端我用Python写了一个简单的TCP服务监听8080端口。STM32连上来后先发一个请求包包含当前固件版本号。服务器比对版本号如果服务器上的固件更新就发固件信息包然后开始发数据。固件信息包的格式字段长度说明包头2字节0xAA55固件大小4字节小端模式固件CRC324字节小端模式版本号4字节小端模式包尾2字节0x55AA数据包的格式字段长度说明包头2字节0xAA55包序号2字节从0开始数据长度2字节最大1024数据N字节固件数据CRC162字节前面所有字节的CRC16包尾2字节0x55AA服务器发送逻辑import socket import struct import zlib def send_firmware(conn, firmware_path): with open(firmware_path, rb) as f: firmware f.read() total_size len(firmware) crc32 zlib.crc32(firmware) 0xFFFFFFFF version 0x00010002 # 版本号1.0.2 # 发固件信息包 info_pkt struct.pack(HHIIIIH, 0xAA55, 0, total_size, crc32, version, 0, 0x55AA) conn.send(info_pkt) # 等ACK ack conn.recv(16) if ack[0] ! 0xAA: return False # 分包发送 seq 0 offset 0 while offset total_size: chunk firmware[offset:offset1024] data_len len(chunk) crc16 calc_crc16(chunk) pkt struct.pack(HHH, 0xAA55, seq, data_len) chunk struct.pack(H, crc16) struct.pack(H, 0x55AA) conn.send(pkt) # 等ACK超时重发 conn.settimeout(2.0) try: ack conn.recv(16) if ack[0] 0xAA and ack[1] 0x01: seq 1 offset data_len else: continue # 重发 except socket.timeout: continue # 重发 return True为什么用一问一答因为4G网络下TCP虽然可靠但应用层还是可能丢包。一问一答能确保每包都写入了Flash才发下一包。如果服务器一口气把44KB全发出去STM32的串口缓冲区只有几KB肯定溢出。提示服务器端要记录每个设备的升级进度如果STM32中途断线下次连上来可以从断点继续。我在固件信息包里加了偏移量字段STM32告诉服务器已经收到多少字节服务器从那个位置开始发。4.2 STM32端的状态机设计与实现STM32端的OTA流程我用状态机实现状态定义如下typedef enum { OTA_IDLE, // 空闲 OTA_CONNECTING, // 连接服务器 OTA_RECV_INFO, // 接收固件信息 OTA_RECV_DATA, // 接收固件数据 OTA_VERIFY, // 校验固件 OTA_DONE, // 升级完成 OTA_ERROR // 错误 } ota_state_t;状态机主循环void ota_task(void) { switch (ota_state) { case OTA_IDLE: if (need_upgrade) { ota_state OTA_CONNECTING; } break; case OTA_CONNECTING: if (ec800_tcp_connect() 0) { ota_state OTA_RECV_INFO; ota_send_version(); } else { retry_count; if (retry_count 3) { ota_state OTA_ERROR; } } break; case OTA_RECV_INFO: if (recv_info_pkt(fw_info) 0) { if (fw_info.version current_version) { ota_state OTA_RECV_DATA; ota_send_ack(0xAA, 0x01); } else { ota_state OTA_DONE; } } break; case OTA_RECV_DATA: if (recv_data_pkt(data_pkt) 0) { if (crc16_check(data_pkt) 0) { flash_write(data_pkt.seq, data_pkt.data, data_pkt.len); ota_send_ack(0xAA, 0x01); if (data_pkt.seq last_seq) { ota_state OTA_VERIFY; } } else { ota_send_ack(0xAA, 0x00); // NACK请求重发 } } break; case OTA_VERIFY: if (crc32_check() 0) { save_upgrade_flag(0x55); ota_state OTA_DONE; } else { ota_state OTA_ERROR; } break; case OTA_DONE: NVIC_SystemReset(); // 重启进入新固件 break; case OTA_ERROR: // 记录错误重试或回滚 break; } }这个状态机每10ms跑一次放在主循环里或者定时器中断里。为什么用状态机而不是阻塞式因为OTA过程中还要处理其他任务比如看门狗喂狗、LED指示、按键响应。状态机不阻塞能保证系统实时性。注意NVIC_SystemReset()会直接重启重启后Bootloader会检查升级标志。如果标志是0x55就跳转到APP如果是0xAA就继续OTA。这个标志存在参数区掉电不丢。4.3 跳转APP前的准备工作Bootloader在跳转到APP之前必须做几件事第一关闭所有中断。包括SysTick、串口中断、定时器中断。如果不关跳转后中断向量表还是Bootloader的APP里的中断会跑到Bootloader的中断服务函数里直接跑飞。__disable_irq(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0;第二设置栈指针。APP的栈顶地址存在APP起始地址的前4个字节。跳转前要把MSP设成这个值。uint32_t app_stack *(uint32_t*)APP_START_ADDR; __set_MSP(app_stack);第三设置中断向量表偏移。STM32的中断向量表默认在0x08000000APP在0x08004000所以要把向量表偏移设成0x4000。SCB-VTOR APP_START_ADDR;第四跳转到APP的复位处理函数。APP起始地址的后4个字节是复位处理函数的地址。uint32_t app_entry *(uint32_t*)(APP_START_ADDR 4); void (*app_reset)(void) (void (*)(void))app_entry; app_reset();这四步缺一不可。我一开始只做了第一步和第四步结果APP跑起来后串口中断没反应查了半天才发现是向量表偏移没设。提示跳转前最好把串口、定时器等外设都DeInit恢复到复位状态。否则APP初始化时可能会因为外设状态不对而出错。5. 常见问题与排查技巧实录5.1 固件下载到一半卡死怎么办这是最常见的问题。现象是STM32发ACK后服务器不发下一包或者服务器发了但STM32收不到。排查思路如下先看EC800的串口灯。如果灯不闪了说明EC800死机了。EC800在长时间大数据量传输后可能会死机解决办法是加看门狗或者每传100包主动发一次ATQISEND查询连接状态。如果EC800灯正常闪说明数据在串口上跑但STM32没收到。这时候用示波器或者逻辑分析仪抓串口波形看STM32的RX引脚有没有数据。如果没有说明EC800的TX引脚没输出可能是EC800的串口缓冲区满了。EC800的串口缓冲区默认是4KB如果STM32处理太慢缓冲区满了EC800就不收了。解决办法是降低服务器发送速率或者加大STM32的串口接收缓冲区。如果STM32的RX引脚有数据但程序没反应说明串口中断没触发。检查串口中断优先级如果OTA任务优先级太高串口中断被屏蔽了数据就丢了。我把串口中断优先级设成最高OTA任务设成最低问题解决。注意STM32F103的串口接收中断在收到数据后会一直触发直到你读走数据。如果中断服务函数里没读数据会一直卡在中断里。我用的DMA接收中断里只处理空闲中断效率高很多。5.2 CRC校验总是不通过CRC校验不通过的原因很多我遇到过以下几种第一种服务器和STM32的CRC参数不一致。CRC16有Modbus、CCITT、XMODEM等多种变体初始值、多项式、结果异或值都可能不同。解决办法是两边用同一个库或者手动对齐参数。我最后在服务器端和STM32端都用Modbus CRC16初始值0xFFFF多项式0xA001结果不异或。第二种数据包里有转义字符。如果用了透传模式固件数据里的“”会触发退出透传。解决办法是改用非透传模式或者对数据做转义。第三种Flash写入错误。STM32的Flash在擦写时如果电压不稳可能写入错误。解决办法是擦写前关中断擦写后读回来比对。我加了写入后校验如果不对就重写。第四种CRC计算范围不对。CRC16应该算包头到数据末尾不包括CRC16本身和包尾。我一开始把包尾也算进去了结果永远不对。5.3 升级后APP跑不起来升级完成重启APP没跑起来。可能的原因第一跳转地址不对。APP的起始地址必须是0x08004000如果编译时设置的ROM起始地址是0x08000000APP就跑到Bootloader的区域了。解决办法是在Keil的Target选项中把IROM1的Start改成0x08004000Size改成0xB000。第二中断向量表没偏移。APP里的中断向量表默认在0x08000000但APP在0x08004000中断触发后跑到Bootloader的向量表里了。解决办法是在APP的main函数开头加SCB-VTOR 0x08004000;。第三栈指针没设置。跳转前必须把MSP设成APP的栈顶地址否则APP用的还是Bootloader的栈可能溢出。第四APP的CRC自校验失败。如果APP启动后算自己的CRC32跟参数区里的不一致会认为固件损坏不跑业务逻辑。解决办法是确保服务器发的CRC32跟APP算的一致。提示调试跳转问题时可以在Bootloader里加个LED闪烁跳转前闪3次跳转后如果APP跑起来再闪3次。这样一眼就能看出是跳转失败还是APP本身的问题。5.4 常见问题速查表现象可能原因排查方法解决办法连接服务器失败APN配置错误发ATQICSGP查询根据运营商改APN下载中途卡死EC800死机看串口灯是否闪加看门狗定期查连接CRC校验失败参数不一致对比两边CRC参数统一用Modbus CRC16Flash写入失败电压不稳读回来比对擦写前关中断写入后校验跳转后跑飞向量表没偏移检查SCB-VTOR设成APP起始地址APP不跑栈指针没设检查MSP值跳转前设MSP升级后版本没变升级标志没存读参数区确保存0x55断点续传失败偏移量没记录查服务器日志服务器记录每个设备进度6. 实操心得与避坑经验6.1 串口DMA加空闲中断是标配STM32的串口接收如果用中断每收一个字节进一次中断115200波特率下每秒11520次中断CPU什么都干不了。用DMA加空闲中断DMA把数据搬到缓冲区空闲中断触发时一次性处理一整包数据CPU占用率从50%降到5%以下。配置方法串口初始化时开DMA接收USART_IT_IDLE使能。空闲中断服务函数里先读USART_SR和USART_DR清标志然后算DMA剩余计数得出收到多少字节处理完再重启DMA。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { USART_ReceiveData(USART1); // 清标志 uint32_t len BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); ota_recv_callback(recv_buf, len); DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }注意空闲中断的触发条件是串口线空闲一个字节的时间。如果服务器发数据太快两个包之间没有空闲空闲中断不会触发。解决办法是服务器每发一包等ACK包之间自然有空闲。6.2 看门狗和升级标志的配合OTA过程中如果卡死看门狗会复位。复位后Bootloader重新跑如果升级标志还是0xAA会继续OTA。但如果服务器已经关了连接STM32会一直重试永远升不上去。我的做法是升级标志存两个一个是“需要升级”一个是“升级中”。Bootloader启动后如果看到“升级中”说明上次升级没完成先尝试重连服务器续传。如果重连3次失败把标志改成“需要升级”等下次开机再试。如果看到“需要升级”直接进OTA流程。看门狗的超时时间设成10秒。OTA过程中每收到一包喂一次狗如果10秒没收到数据看门狗复位重新开始。6.3 固件版本号的编码方式版本号我用4字节表示高16位是主版本低16位是次版本。比如0x00010002表示1.0.2。服务器和STM32比对版本号时直接比大小服务器的大就升级。为什么不用字符串因为字符串比对麻烦还要考虑长度。4字节整数比对简单而且省空间。如果版本号要显示可以转成字符串但存储和比对用整数。提示版本号最好在编译时自动生成比如用__DATE__和__TIME__宏或者用Git的commit ID。手动改版本号容易忘导致升级后版本没变。6.4 服务器端的断点续传实现断点续传的关键是服务器要记录每个设备的升级进度。我用设备的IMEI作为唯一标识服务器维护一个字典key是IMEIvalue是已发送的字节数。STM32连上来后先发IMEI和当前固件版本。服务器查字典如果这个IMEI有记录从记录的偏移量开始发如果没有从0开始。每发一包更新偏移量。如果STM32断线偏移量保留下次连上来继续。progress {} # key: imei, value: offset def handle_client(conn, imei, version): if imei in progress: offset progress[imei] else: offset 0 # 从offset开始发 while offset total_size: # 发送数据 offset data_len progress[imei] offset # 发完了删除记录 del progress[imei]这个方案实测下来很稳即使4G网络不稳定断线重连后也能继续不用从头下。6.5 Flash擦写期间的电源管理STM32的Flash擦写需要稳定的电压如果电压低于2.7V擦写可能失败。工业现场电源波动大我加了电压检测如果电压低于3.0V暂停OTA等电压恢复再继续。另外擦写期间电流会增大如果用的是电池供电要考虑电池能不能撑住。我实测擦除1KB大概20ms电流从30mA跳到50mA对于2000mAh的电池来说影响不大。注意Flash擦写期间如果复位那一页数据就丢了。所以擦写前要先发ACK告诉服务器暂停擦写完再发ACK让服务器继续。如果擦写期间复位服务器超时重发STM32重新擦写。7. 后续可以扩展的方向这套OTA方案目前只支持TCP后续可以加MQTT支持这样服务器可以主动推送升级通知不用STM32轮询。还可以加HTTPS防止固件被篡改。另外如果固件太大可以加压缩服务器发压缩后的固件STM32解压后再写入Flash能省一半下载时间。我目前在做一个多设备批量升级的功能服务器同时给几百个设备发固件每个设备独立记录进度互不影响。这个功能对于大规模部署的场景很有用不用一个个设备手动触发升级。最后分享一个小技巧调试OTA时先在局域网内用TCP调试助手模拟服务器把固件分包发出去STM32收到后写入Flash用ST-Link读出来跟原文件比对。局域网稳定容易排查问题。等局域网跑通了再切到4G问题就少很多。
返回列表