简介:这是一套面向华大单片机开发者的远程在线IAP升级参考方案,聚焦HC32L136平台,同时兼容HC32F030、HC32L130、HC32L(F)17X、HC32L(F)19X等型号。资源内含BOOT引导程序、APP应用程序与上位机程序三部分,可帮助嵌入式工程师理解并搭建远程固件升级链路,适合具备一定单片机与通信基础的开发者参考移植。压缩包共282个文件,约3.29MB,以h头文件、c源文件、xcl链接脚本为主,另含bat批处理、jlink调试配置、flash烧录算法、svd寄存器描述、bin固件及Keil、IAR工程文件,覆盖编译、下载与调试全流程。目前已有3526人学习下载。通过该DEMO,读者可掌握BOOT与APP分区跳转、固件接收与校验、上位机下发升级包等关键环节,并借助多型号批处理脚本快速切换芯片工程,为自研远程升级功能提供可复用的代码框架与排错思路。
1. 远程在线IAP升级:为什么你的HC32L136板子不用再拆壳插烧录器
去年帮一个做工业采集终端的朋友处理售后,现场设备装在配电柜里,固件有个小bug要修,结果派了两个工程师带着烧录器跑现场,拆壳、接线、重新烧录,折腾一整天。回来之后我就琢磨,HC32L136、HC32F030、HC32L130这几颗华大单片机都带Flash自编程能力,为什么不做一套远程在线IAP升级?后来花了两周把bootloader和上位机协议搭起来,现在设备只要联网,后台推个固件包就能自动升级,再也不用拆壳了。这套方案适合做远程部署的嵌入式产品,尤其是那些装好之后不方便物理接触的设备,比如环境监测节点、智能电表、工业网关。核心思路就是让MCU自己擦写自己的Flash,通过串口或者无线模块接收新固件,跳转到bootloader完成升级。下面我把整个项目的架构、代码和踩过的坑拆开讲一遍。
2. 整体架构拆解:Bootloader与App如何分区共存
2.1 Flash空间划分策略
HC32L136这颗片子Flash是64KB,HC32F030是32KB,HC32L130也是64KB。做IAP升级第一步就是把Flash切成两块:Bootloader区和App区。我一般会把Bootloader放在0x00000000起始的位置,大小控制在8KB以内,App从0x00002000开始。为什么是8KB?因为Bootloader只需要实现Flash擦写、串口收发、跳转逻辑,8KB绰绰有余,留太多反而浪费App空间。
具体分区如下表:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x00000000 | 8KB | 升级逻辑、通信协议 |
| App | 0x00002000 | 48KB | 用户应用程序 |
| 参数区 | 0x0000F000 | 4KB | 升级标志、版本号、校验值 |
参数区放在Flash最后一页,用来存升级标志位和固件校验信息。每次上电Bootloader先读参数区,如果升级标志有效就进入升级流程,否则直接跳转到App。
2.2 启动流程与跳转机制
上电之后先跑Bootloader,它做三件事:检查升级标志、检查App有效性、跳转。跳转的本质就是修改栈顶指针和PC指针,HC32系列用的是Cortex-M0+内核,跳转代码很标准:
// 跳转到App的代码,addr是App起始地址 typedef void (*pFunction)(void); void jump_to_app(uint32_t addr) { uint32_t stack_top = *(volatile uint32_t*)addr; // 取App栈顶 uint32_t reset_handler = *(volatile uint32_t*)(addr + 4); // 取复位向量 pFunction app_entry = (pFunction)reset_handler; __disable_irq(); // 关中断,防止跳转过程被打断 SCB->VTOR = addr; // 重定向中断向量表 __set_MSP(stack_top); // 设置主栈指针 app_entry(); // 跳转执行 }这里有个关键点:SCB->VTOR必须重定向,否则App里的中断会跑到Bootloader的向量表去,直接HardFault。HC32L136的VTOR寄存器是可写的,但要注意跳转前把外设中断都关掉,尤其是SysTick,不然跳过去之后中断还在跑Bootloader的代码,必翻车。
2.3 通信协议设计
远程升级的通信层我用的是自定义的帧协议,走串口或者透传模块。帧格式很简单:帧头(2字节) + 命令(1字节) + 长度(2字节) + 数据(N字节) + CRC16(2字节)。命令码包括:握手、擦除、写数据、校验、跳转。每包数据最大256字节,因为HC32L136的RAM只有8KB,缓冲区不能开太大。
// 帧结构定义 typedef struct { uint16_t head; // 0xAA55 uint8_t cmd; // 命令码 uint16_t len; // 数据长度 uint8_t data[256]; // 数据负载 uint16_t crc; // CRC16校验 } iap_frame_t; // 命令码枚举 #define CMD_HANDSHAKE 0x01 // 握手,返回Bootloader版本 #define CMD_ERASE_APP 0x02 // 擦除App区 #define CMD_WRITE_DATA 0x03 // 写入一包数据 #define CMD_CHECK_CRC 0x04 // 校验整个App区 #define CMD_JUMP_APP 0x05 // 跳转到App上位机每次发一包,Bootloader收到后先校验CRC,对了再写Flash,写完回一个ACK。如果CRC错了回NAK,上位机重发。这个重传机制在无线环境下特别重要,丢包是常态。
3. Bootloader核心实现:Flash擦写与固件接收
3.1 Flash擦写驱动
HC32L136的Flash操作跟STM32不太一样,它需要先解锁,再配置时钟,然后才能擦写。华大官方库提供了Flash_Init和Flash_Write函数,但实际用的时候有几个坑。擦除必须按页擦,一页512字节,写的时候必须按字写,不能按字节写。
// Flash擦除一页 uint8_t flash_erase_page(uint32_t addr) { uint8_t ret; Flash_Init(1, 1); // 初始化Flash控制器 Flash_Unlock(); // 解锁Flash ret = Flash_PageErase(addr); // 擦除指定页 Flash_Lock(); // 重新上锁 return ret; } // Flash写入数据,必须4字节对齐 uint8_t flash_write_data(uint32_t addr, uint8_t *buf, uint16_t len) { uint32_t i; uint32_t *p = (uint32_t*)buf; Flash_Unlock(); for(i = 0; i < len / 4; i++) { Flash_WriteWord(addr + i*4, p[i]); // 按字写入 } Flash_Lock(); return 0; }注意Flash_WriteWord这个函数,华大库里面叫Flash_WriteWord或者Flash_ProgramWord,不同版本的库名字可能不一样,但功能都是写一个32位字。写之前一定要确保目标地址已经擦除过,Flash只能把1写成0,不能把0写成1,没擦就写必然出错。
3.2 固件接收状态机
Bootloader的主循环是一个状态机,根据上位机发来的命令切换状态。我把它设计成阻塞式接收,每收到一帧就处理一帧,处理完回响应。这样逻辑简单,不容易出bug。
void bootloader_main(void) { iap_frame_t frame; uint32_t app_addr = APP_START_ADDR; uint8_t upgrade_flag = 0; // 检查参数区是否有升级标志 upgrade_flag = check_upgrade_flag(); if(upgrade_flag == 0) { // 无升级标志,直接跳App if(check_app_valid() == 0) { jump_to_app(APP_START_ADDR); } } // 进入升级模式,等待上位机命令 while(1) { if(receive_frame(&frame) == 0) { // 收到完整帧 switch(frame.cmd) { case CMD_HANDSHAKE: send_handshake_response(); break; case CMD_ERASE_APP: erase_app_area(); send_ack(); break; case CMD_WRITE_DATA: write_firmware_packet(&frame); send_ack(); break; case CMD_CHECK_CRC: if(verify_app_crc() == 0) { set_upgrade_flag(0); // 清除升级标志 send_ack(); jump_to_app(APP_START_ADDR); } else { send_nak(); } break; default: send_nak(); break; } } } }这个状态机里,receive_frame负责拼包,它要处理粘包和断包。我的做法是用一个环形缓冲区,每收到一个字节就塞进去,然后检查缓冲区里有没有完整的帧。如果有就取出来,没有就继续等。
3.3 参数区读写与升级标志管理
参数区我放在Flash的最后一页,地址0x0000F000。里面存三个东西:升级标志、固件大小、固件CRC。升级标志用0x5A5A5A5A表示需要升级,0x00000000表示不需要。
#define PARAM_ADDR 0x0000F000 #define UPGRADE_MAGIC 0x5A5A5A5A typedef struct { uint32_t upgrade_flag; // 升级标志 uint32_t firmware_size; // 固件大小 uint32_t firmware_crc; // 固件CRC32 } iap_param_t; // 读取参数区 void read_params(iap_param_t *param) { memcpy(param, (void*)PARAM_ADDR, sizeof(iap_param_t)); } // 写入参数区 void write_params(iap_param_t *param) { flash_erase_page(PARAM_ADDR); flash_write_data(PARAM_ADDR, (uint8_t*)param, sizeof(iap_param_t)); }写参数区之前必须先擦除,因为Flash写之前必须是0xFF。这个操作在升级流程里只做一次,不会频繁擦写,所以寿命不用担心。
4. 上位机与App端配合:升级流程完整跑通
4.1 上位机升级脚本
上位机我用Python写了一个简单的脚本,通过串口跟板子通信。脚本负责读固件bin文件、分包发送、等待ACK、超时重传。核心逻辑就是按帧格式打包,然后一包一包发。
import serial import struct import time def send_frame(ser, cmd, data=b''): """发送一帧数据,返回True表示收到ACK""" head = 0xAA55 length = len(data) # 打包:帧头 + 命令 + 长度 + 数据 frame = struct.pack('<HHB', head, cmd, length) + data # 计算CRC16 crc = calc_crc16(frame) frame += struct.pack('<H', crc) ser.write(frame) # 等待响应 resp = ser.read(4) if len(resp) == 4 and resp[0] == 0xAA and resp[1] == 0x55: return resp[2] == 0x00 # 0x00表示ACK return False def upgrade_firmware(port, baudrate, firmware_path): """完整的升级流程""" ser = serial.Serial(port, baudrate, timeout=2) with open(firmware_path, 'rb') as f: firmware = f.read() # 1. 握手 if not send_frame(ser, CMD_HANDSHAKE): print("握手失败") return False # 2. 擦除App区 if not send_frame(ser, CMD_ERASE_APP): print("擦除失败") return False # 3. 分包写入 packet_size = 256 for i in range(0, len(firmware), packet_size): packet = firmware[i:i+packet_size] retry = 0 while not send_frame(ser, CMD_WRITE_DATA, packet): retry += 1 if retry > 3: print(f"写入失败,偏移:{i}") return False time.sleep(0.1) # 4. 校验 if not send_frame(ser, CMD_CHECK_CRC): print("校验失败") return False print("升级成功") return True这个脚本里send_frame的超时重传逻辑很关键,无线模块丢包率有时候能到5%,没有重传根本升不上去。重传次数我设的3次,超过就报错退出。
4.2 App端跳转与中断向量重定向
App端的代码需要做两件事:一是链接脚本里把起始地址改成0x00002000,二是启动文件里重定向中断向量表。以Keil为例,在Target选项卡里把IROM1的Start改成0x2000,Size改成0xC000。然后在main函数开头加上:
// App端main函数开头,重定向中断向量表 int main(void) { SCB->VTOR = APP_START_ADDR; // 0x00002000 __enable_irq(); // 开中断 // 初始化外设... while(1) { // 用户代码 } }如果不加重定向,App里的中断会跑到Bootloader的向量表去,因为Cortex-M0+默认从0x00000000取向量。这个坑我踩过,现象是App跑起来之后一进中断就死机,查了半天才发现是VTOR没设。
4.3 升级流程时序与超时处理
整个升级流程的时序是这样的:上位机发握手命令,Bootloader回版本号;上位机发擦除命令,Bootloader擦除App区并回ACK;上位机分包发数据,每包等ACK;发完之后发校验命令,Bootloader算CRC并比对;校验通过就跳转App。
超时处理在每个环节都要做。Bootloader端如果等命令超过30秒没收到,就自动跳回App,防止设备卡在Bootloader里。上位机端每个命令等2秒,超时就重发。这个双向超时机制保证了升级失败也能恢复。
5. 避坑与常见问题:那些让我熬夜的Flash和跳转问题
5.1 跳转后HardFault
现象:Bootloader跳转到App之后,App跑几行就进HardFault。原因:中断向量表没重定向,或者跳转前没关中断。解决:跳转前__disable_irq(),App开头SCB->VTOR = APP_START_ADDR,然后再__enable_irq()。另外检查App的链接脚本,起始地址必须是0x2000。
5.2 Flash写入失败
现象:Flash_WriteWord返回错误,或者写进去的数据读出来不对。原因:目标地址没擦除,或者Flash没解锁。解决:写之前先擦页,擦除和写入之间不要断电。华大的Flash控制器在擦写期间不能执行Flash取指,所以擦写函数必须放在RAM里跑,或者确保擦写期间不取Flash指令。我一般把擦写函数用__attribute__((section(".ramfunc")))放到RAM。
5.3 串口丢包导致升级卡死
现象:升级到一半卡住,上位机一直等ACK。原因:无线模块丢包,或者Bootloader处理太慢没及时回ACK。解决:上位机加重传,Bootloader收到重复包要能识别并重新回ACK。我在Bootloader里加了一个包序号字段,重复包直接回ACK不重复写。
5.4 升级标志误触发
现象:设备上电后不进App,一直停在Bootloader。原因:参数区的升级标志被意外写成0x5A5A5A5A。解决:参数区写入之前先校验,只有收到完整的升级命令才写标志。另外参数区加一个魔数校验,不是合法参数就恢复默认值。
5.5 CRC校验不通过
现象:固件发完了,校验命令返回NAK。原因:Flash写入过程中有数据出错,或者CRC算法不一致。解决:上位机和Bootloader用同一个CRC16算法,多项式0x8005,初值0xFFFF。写入的时候每包都校验,不要等最后才查。
6. 二次开发改造:把这套IAP移植到你的项目里
这套IAP框架的移植性其实很好,核心就是三个东西:Flash驱动、通信协议、跳转逻辑。换一颗MCU,比如从HC32L136换到HC32F030,Flash驱动要改,因为页大小和寄存器不一样。HC32F030的Flash页是512字节,跟L136一样,但寄存器地址不同,需要改Flash_PageErase和Flash_WriteWord的底层实现。通信协议和跳转逻辑基本不用动。
如果你想改成无线升级,比如走LoRa或者4G模块,只需要把串口收发换成模块的AT指令收发。协议层不变,还是那个帧格式。我一般会在Bootloader里留一个通信接口抽象层,把receive_byte和send_byte做成函数指针,换通信方式的时候只改这两个函数。
// 通信接口抽象 typedef struct { void (*init)(void); uint8_t (*recv_byte)(uint8_t *byte, uint32_t timeout); void (*send_data)(uint8_t *data, uint16_t len); } comm_if_t; // 串口实现 comm_if_t uart_comm = { .init = uart_init, .recv_byte = uart_recv_byte, .send_data = uart_send_data }; // 无线模块实现 comm_if_t lora_comm = { .init = lora_init, .recv_byte = lora_recv_byte, .send_data = lora_send_data };这样换通信方式的时候,Bootloader主逻辑一行不用改。另外,如果你需要双备份升级,也就是升级失败还能回滚,可以在Flash里划两个App区,Bootloader根据标志位决定跳哪个。这个方案我在这块板子上没做,因为Flash只有64KB,两个App区不够分。如果你的片子Flash够大,比如128KB以上,强烈建议做双备份,升级失败也不怕变砖。
验证升级是否成功,我一般用三个手段:一是读App区的CRC,跟固件包的CRC比对;二是跳转后让App闪一个LED,肉眼确认跑起来了;三是通过串口打印版本号,确认是新版本。这三个手段叠加,基本不会误判。
从那以后我每次做IAP升级,都会先在RAM里跑一遍擦写测试,确认Flash驱动没问题再烧Bootloader。这个习惯帮我省了很多返工的时间。希望帮到你。
本文还有配套的精品资源,点击获取