HC32L130/L136串口IAP方案:YModem协议Bootloader与C#上位机实战
2026/9/2 6:47:19 网站建设 项目流程

简介:面向华大半导体HC32L130与HC32L136系列低功耗微控制器,这套基于YModem传输协议的IAP引导加载程序,连同电脑端上位机源码,为嵌入式开发者提供了一套可直接落地的固件远程升级参考实现。资源共三百一十七个文件,压缩包大小三点三八兆字节,其中源码以C语言与头文件为主,并包含多种工程配置文件(如Keil与IAR的工程)、链接脚本、编译中间产物、可执行固件、烧录批处理脚本及PDF说明文档,覆盖从编译、烧录到二次开发的完整链路。方案完整实现了YModem协议解析、引导加载流程、中断向量表重映射、闪存分区与擦写管理、固件校验及跳转逻辑,电脑端程序具备文件选择、分块传输、进度显示与错误重传等机制,并预先适配了多个芯片工程,方便迁移到华大系列其他型号。目前已有一千零五十七人学习下载,适合正在设计引导加载方案、需要实现远程升级功能,或想深入理解单片机在线编程机制的开发者借鉴参考。 产品都已经批量出货了,客户现场突然说要改一个通信协议参数,固件不升级就得返厂拆壳。干嵌入式的应该都懂这种痛。我这次就是因为这个,给基于华大HC32L130/L136单片机的产品补了一套完整的IAP方案:传输层用YModem协议,Bootloader常驻Flash引导区,上位机用C#写了一套串口升级工具。整套链路从协议选型、Flash分区、Bootloader实现到上位机调试全部跑通,这篇文章把关键设计思路、协议细节、代码结构和踩过的坑一起整理出来,给正在做华大平台或者想从STM32迁移到HC32的工程师做个参考。

1. 为什么最终选了YModem:给HC32做升级通道的选型思考

1.1 现场升级需求与串口IAP的应用场景

很多产品量产之后才意识到需要固件升级能力。家电控制板、电表模块、传感器节点、工业控制板,这类设备通常没有网络模块,也没有蓝牙,唯一能稳定暴露出来的就是串口或者烧录引脚。串口IAP是成本最低、最容易落地的一种方案,不需要增加任何硬件成本,只要预留一个UART引脚,Bootloader和App做好分区,就能通过串口工具完成固件更新。

HC32L130和HC32L136这两个型号现在归在小华半导体名下,但很多老工程师还是习惯叫"华大"。它们是Cortex-M0+内核的低功耗MCU,同系列还有不同Flash容量的版本,选型的时候注意区分。它们的IAP方案思路和STM32基本一致,但Flash驱动、时钟配置、低功耗处理这些外设细节有差别,不能直接照搬。

1.2 XModem、YModem、自研协议,我为什么选了YModem

做串口升级,传输协议是第一件事。我当时列了几个候选方案,各自优缺点如下:

方案优点缺点适用场景
XModem实现简单,协议开销小不支持文件名和大小,128字节定长效率低对协议栈体积极度敏感的小资源MCU
YModem带文件名和文件大小,支持128/1024字节可变包长,协议公开成熟实现比XModem复杂一点,CRC计算和状态机需要仔细设计串口IAP首选,兼容性最好
ZModem支持断点续传、多文件批量传输,功能完善协议复杂,Bootloader代码体积大,超时策略难调需要断点续传的PC端大文件传输
自研分包协议完全按业务需求裁剪,灵活性高需要自行设计校验、重传、时序,出问题没有公开资料可查有充分联调条件、协议栈想完全可控的项目

对MCU端Bootloader来说,最关键的一个限制是资源。HC32L130的Bootloader分区一般只有16KB左右,要在这么小的空间里塞下串口驱动、Flash驱动、通信协议、跳转逻辑,协议实现必须足够精简。YModem最典型的实现方式就是基于一个128字节缓冲区做状态机流转,不需要动态内存分配,这对单片机环境非常友好。

另外,YModem是公开标准协议,市面上几乎所有串口终端工具(包括SecureCRT、Tera Term等)都自带YModem发送功能。这意味着即使自研上位机还没写好,也能先用现成工具完成升级测试,这对项目调试阶段帮助很大。

1.3 YModem解决的核心问题

YModem协议在实际项目中解决了三个关键问题:

  • 文件大小信息在起始帧就传过来了,Bootloader可以提前判断剩余Flash空间是否足够,避免写一半才发现空间不够导致分区损坏。
  • 每一帧都有CRC16校验和序号确认机制,传输错误可以及时重传而不是盲目写Flash。
  • 数据帧支持128字节和1024字节两种长度,可以根据链路质量动态选择,Fast模式下传输效率能提升接近8倍。

2. Flash布局与启动流程:不想中途变砖,分区先要想清楚

2.1 芯片资源与地址空间

HC32L130/L136基于Cortex-M0+内核,主频最高可以跑几十MHz(具体以数据手册为准),Flash容量根据具体型号从64KB到128KB不等。我用的这颗是128KB Flash版本,规划起来比较宽裕。

M0+内核的启动流程很标准:芯片上电后从地址0x00000000取出栈顶指针(MSP),从0x00000004取出复位向量,然后跳转到复位处理函数执行。Bootloader和App本质上都是独立的裸机程序,它们共享同一块Flash,只是被分到了不同地址区间。

2.2 128KB容量下的分区示例

我用的分区方案如下:

地址范围大小用途
0x00000000 - 0x00003FFF16KBBootloader区
0x00004000 - 0x0001FFFF112KBApp应用区
0x00020000 之后的末尾扇区几KB升级标志与参数存储区

这个分区的核心思路:Bootloader放在起始地址,上电默认先跑Bootloader,通过标志位判断是直接跳App还是进入IAP升级流程。App区独立放在后面,即使App跑飞或者升级失败,也不影响Bootloader区,只要Bootloader还在,出厂后随时都有一次通过串口救砖的机会。

2.3 App工程的链接与中断向量表处理

App工程需要做两件事: 一是修改链接脚本,把代码起始地址从0x00000000改到0x00004000。Keil的IROM1起始地址和大小要同步调整,IAR的链接配置文件里也要把ICF的rom起始地址改掉。 二是中断向量表重定向。Cortex-M0+内核有VTOR寄存器(地址0xE000ED08),可以把它指向App区开头的向量表。必须在App启动早期、任何中断使能前完成重定向,否则中断一进来,CPU去Bootloader区域的向量表取中断处理函数,整个程序就跑飞了。

2.4 启动流程与升级标志位

整个启动流程是这样一个逻辑:

上电复位 -> Bootloader初始化时钟和串口 -> 读取升级标志位 -> 如果标志位表示"需要升级",进入IAP接收流程 -> 如果标志位表示"正常启动",直接跳转到App

升级标志位是关键,我把它放在Flash末尾的一个独立扇区。Bootloader在准备进入IAP之前先擦掉该扇区写入升级标记,App跑起来之后如果确认自己运行正常,再主动清除这个标记。某些场景下还需要配合外部按键:开机时按住按键强制进入IAP模式,这样设备在现场即使没有上位机,也能通过按键组合进入升级状态。

3. YModem协议细节拆解:帧格式、CRC16与时序状态机

3.1 三种帧的Byte级结构

YModem协议里一共就三种帧,理解了这三种帧,整个协议就通了。

起始帧(Block 0):SOH + 0x00 + 0xFF + 128字节数据 + CRC16。128字节数据区的格式是:文件名以ASCII码放在开头,结尾补0x00,紧接着是文件大小的ASCII字符串(如"131072"),再补0x00,剩余部分统一填充0x00。起始帧用来告诉单片机"我要传一个多大的文件、文件名是什么"。

数据帧(Block N):传输主体有两种帧头。SOH(0x01)表示后跟128字节数据,STX(0x02)表示后跟1024字节数据。帧格式是:帧头 + 序号 + 序号反码 + 数据区 + CRC16。序号从0x00开始,每发一帧加1,到达0xFF后回绕到0x00,序号反码就是按位取反,接收端通过检查frame_seq == ~frame_seq_inv来判断帧序号是否被破坏。

结束帧(EOT后):格式和起始帧一样,SOH + 0x00 + 0xFF + 128字节数据 + CRC16,但数据区全部填充0x00。表示"所有文件发送完毕"。

3.2 CRC16-CCITT的具体实现

YModem用的是CRC16-CCITT,多项式0x1021,初值0x0000,传输时高字节在前。我在Bootloader里维护了一个查表法实现的CRC,代码如下:

static const uint16_t crc16_table[256] = { 0x0000, 0x1021, 0x2042, 0x3063, // ... 共256个表项 }; uint16_t ymodem_crc16(uint16_t crc, const uint8_t *buf, uint16_t len) { while (len--) { crc = (crc << 8) ^ crc16_table[((crc >> 8) ^ *buf++) & 0xFF]; } return crc; }

查表法在M0+上算得很快,一个1024字节帧的CRC计算量可以忽略不计。Bootloader里所有帧的校验都走这一个函数,调用前先丢弃前两个字节(帧头和序号部分),只对数据和CRC字段计算。

3.3 一次完整传输的握手时序

一次完整的YModem传输时序如下:

Bootloader(接收端) 上位机(发送端) |----- 发送 'C' (请求开始) -------->| |<---- 发送起始帧 Block 0 ----------| |----- 发送 ACK 'C' --------------->| |<---- 发送数据帧 Block 1 ----------| |----- 发送 ACK ------------------->| |<---- 发送数据帧 Block 2 ----------| |----- 发送 ACK ------------------->| | ... | |<---- 发送 EOT --------------------| |----- 发送 'C' (请求结束帧) ------->| |<---- 发送结束帧 Block 0 ----------| |----- 发送 ACK ------------------->| |<---- 发送结束帧(可能再次出现)----| |----- 发送 ACK ------------------->| |<---- EOT -------------------------| |----- 发送 ACK ------------------->|

这个流程里最容易出问题的地方在EOT之后。标准YModem规定:接收端收到EOT后,要回复'C'请求结束帧,发送端发一个空文件名结束帧表示"后面没有其他文件了",接收端再回复ACK,整个传输才算完成。如果Bootloader在EOT后直接跳转,自写上位机可能没问题,但用SecureCRT这类标准工具发送时,工具会认为传输还没完成而报错。

3.4 超时与重传约定的坑

协议里没有规定统一的超时时间,这是双方必须事先协商好的关键参数。我的约定是:接收端1秒内没收到任何数据就重新发送'C';发送端发送一帧后1秒内没收到ACK就重发当前帧;连续重发超过10次自动取消传输。这个参数不能设得太短,尤其当波特率较低、1024字节帧正在传输过程中时,链路间的微小延迟都可能触发超时误判。

4. Bootloader端实现:Flash擦写、接收状态机与跳转

4.1 工程结构初始化

Bootloader工程结构很简洁:系统时钟初始化、串口初始化、GPIO初始化,然后进入主循环判断标志位。串口初始化时需要注意,Bootloader的串口参数必须和上位机完全一致,建议固定为115200-8-N-1,实测这个波特率在HC32L130上稳定性很好,如果想要更快可以用460800,但硬件走线质量不好的话容易偶发错帧。

4.2 Flash擦写封装与注意事项

HC32L130的Flash操作依赖寄存器级驱动,华大官方库函数提供了基础封装。我在此基础上又做了一层面向IAP的封装,只暴露擦除和写入两个接口:

static void flash_erase_app_region(uint32_t addr, uint32_t size) { Flash_Unlock(); while (size > 0) { Flash_SectorErase(addr); addr += FLASH_SECTOR_SIZE; size -= FLASH_SECTOR_SIZE; } Flash_Lock(); } static void flash_write_app(uint32_t addr, uint8_t *buf, uint32_t len) { Flash_Unlock(); for (uint32_t i = 0; i < len; i += 2) { uint16_t half_word = buf[i] | (buf[i + 1] << 8); Flash_ProgramHalfWord(addr, half_word); addr += 2; } Flash_Lock(); }

注意事项有两处:

  • Flash擦写期间必须关闭中断,否则串口接收中断一旦打断写Flash操作,写入数据和地址都会错乱。我用__disable_irq()在擦写前关中断,完成后恢复。
  • Flash擦除是按扇区来的,App的起始地址必须是扇区边界的整数倍,否则擦除时会把Bootloader区域一起擦掉。我在分区时已经把App起始地址对齐到0x4000,也就是64KB地址对齐,彻底避开这个问题。

4.3 接收状态机的核心循环

Bootloader的串口接收逻辑我实现成有限状态机,只用一个128字节的缓冲区就完成了整个协议流转。核心循环结构如下:

typedef enum { WAIT_START, // 等待起始帧 WAIT_FILENAME, // 解析起始帧数据区 WAIT_DATA_HEADER, // 等待数据帧头 WAIT_DATA_BODY, // 等待数据体 WAIT_DATA_CRC, // 等待并校验CRC WAIT_EOT // 等待传输结束 } ymodem_state_t; // 主循环(简化代码) while (1) { if (uart_receive_byte(&ch) == 0) continue; switch (state) { case WAIT_START: if (ch == SOH) { // 接收128字节起始帧数据区 state = WAIT_FILENAME; } break; case WAIT_FILENAME: // 解析文件名和大小 // 擦除App区 // 发送 ACK 'C' state = WAIT_DATA_HEADER; break; case WAIT_DATA_HEADER: // 收到SOH则开始128字节数据帧 // 收到STX则开始1024字节数据帧 // 收到EOT则进入结束流程 break; case WAIT_DATA_BODY: // 逐字节接收数据到缓冲区 break; case WAIT_DATA_CRC: // 校验CRC,通过则写Flash并回ACK break; } }

状态机的好处是逻辑清晰、不会死等一帧数据。每个状态都有超时看门狗,如果长时间卡住(比如上位机掉线),看门狗直接复位整个Bootloader,恢复初始状态等下一次传输。

4.4 跳转App前的收尾动作

接收完最后一帧之后,跳转之前一定要做这几件事:

void jump_to_app(void) { uint32_t app_stack = *(volatile uint32_t *)APP_START_ADDR; uint32_t app_reset = *(volatile uint32_t *)(APP_START_ADDR + 4); // 1. 关闭SysTick和外设中断 SysTick->CTRL = 0; __disable_irq(); // 2. 重定向中断向量表 SCB->VTOR = APP_START_ADDR; // 3. 设置主栈指针并跳转 __set_MSP(app_stack); ((void (*)(void))app_reset)(); }

这里面有个很容易忽略的点:跳转之前必须先清除升级标志位。否则设备下一次复位还会进入Bootloader升级流程,永远无法正常开机。我是在接收完成、跳转之前就擦掉标志位,这样即使跳转失败,下次复位也能根据标志位状态决定是再升级还是进App。另外跳转前可以适当延时几百毫秒,让串口把最后一个ACK发完,避免上位机因为没收到ACK而反复重发。

4.5 低功耗设备的IAP注意事项

HC32L130/L136主打低功耗,很多项目会跑深度休眠。但IAP期间绝对不能进入低功耗:深度休眠下串口接收会停止,外设时钟也被关闭,等再次唤醒时接收到的数据早丢了。我在Bootloader初始化时把电源管理模块的所有低功耗唤醒事件屏蔽掉,直接让系统在升级期间保持全速运行状态。如果是通过深度休眠唤醒事件来触发按键判断进入IAP,唤醒后必须重新做一遍串口和时钟初始化,不要在残留状态下直接开收。

5. C#上位机实现:YModem发送端的核心逻辑

5.1 串口参数选择与界面结构

上位机用C#的SerialPort类实现串口通信,UI层只负责选择端口、选择固件文件和显示进度。关键参数:

serialPort.BaudRate = 115200; serialPort.DataBits = 8; serialPort.StopBits = StopBits.One; serialPort.Parity = Parity.None; serialPort.ReadTimeout = 1000; serialPort.WriteTimeout = 1000;

打开串口前要取消勾选DTR和RTS控制。很多USB转串口模块在串口打开瞬间会把DTR/RTS拉低,如果硬件设计里把DTR接到了单片机复位脚,就会出现"一点升级按钮单片机就重启"的诡异问题。

5.2 帧组装与CRC计算

C#端发送逻辑封装了几个核心函数。数据帧组装如下:

private byte[] BuildDataFrame(byte seq, byte[] data) { var frame = new List<byte>(); frame.Add((byte)(data.Length == 1024 ? 0x02 : 0x01)); // STX 或 SOH frame.Add(seq); frame.Add((byte)(~seq)); frame.AddRange(data); ushort crc = Crc16(data); frame.Add((byte)(crc >> 8)); frame.Add((byte)(crc & 0xFF)); return frame.ToArray(); }

CRC计算和Bootloader端使用同样的CRC16-CCITT查表法,保证两端一致。起始帧的组装类似,只是数据区需要把文件名和文件大小按ASCII格式拼接。

5.3 基于状态的发送流程

上位机端也是一个状态机。核心流程:

  1. 等待串口收到接收方的'C'握手信号。
  2. 收到后发送起始帧,等待ACK。
  3. 收到ACK后再等待一个'C'(表示接收端已就绪),开始发数据帧。
  4. 每发一帧等待ACK,收到后继续下一帧,超时重试。
  5. 全部发完后发送EOT,等待ACK。
  6. 回复接收方的'C'请求,发送结束帧,等待ACK。

这里要注意:很多上位机实现会漏掉第3步的两个握手信号,直接发数据帧。如果你的Bootloader是自己写的、两边协商好,问题不大。但为了兼容标准YModem工具,建议把完整流程做足。

5.4 超时重试与进度回调

发送和接收逻辑放在后台Task里跑,避免阻塞UI线程。每帧发送后等待ACK,如果超时则重发当前帧,重发10次仍失败就报错终止并提示用户检查串口连接。进度回调通过C#的IProgress<int>接口同步到进度条上,每次收到ACK后计算已发送字节数占总字节数的百分比。

6. 实测记录与踩坑复盘

6.1 整包升级耗时与成功率

我用一个128KB的固件做了多轮实测,数据如下:

场景波特率固件大小耗时成功率
空白Flash冷启动升级115200128KB约13秒连续20次全通过
覆盖已有App升级115200128KB约14秒(含擦除时间)连续20次全通过
全程开串口调试干扰115200128KB偶发超时重传15次中2次重传后成功

实测下来115200波特率1秒能传约11KB有效数据,128KB固件加协议开销需要13秒左右,这个速度对于现场维护场景完全够用。

6.2 坑1:深度休眠模式下串口首字节丢失

第一次联调时遇到一个很隐蔽的坑:MCU之前跑的是低功耗固件,进入升级流程后虽然MCU还在工作,但串口接收的第一个字节经常丢掉,导致上位机一直等不到ACK。排查后发现是唤醒事件没有彻底清干净,系统虽然从休眠醒来了,但串口外设的时钟使能状态残留异常。解决办法是在Bootloader强制重新初始化串口外设时钟并复位串口模块,问题是频率很高的话容易触发栈溢出或数据错乱;解决办法是加一层简单的NAK流控,MCU端在每帧写入Flash后发送ACK之前,确保串口发送FIFO已清空。上位机端不要盲目地把所有帧一股脑塞给串口缓冲区,而是严格等ACK再发下一帧,实测这个调整能显著降低偶发丢帧率。

6.3 坑2:上位机发太快导致缓冲溢出

把波特率从115200提到460800后,出现了一个新问题:上位机发送速度超过MCU的Flash擦写速度,串口接收FIFO溢出丢数据。YModem协议本身靠ACK做流控,但有些上位机实现会在收到ACK之前就预发下一帧,导致FIFO溢出。解决办法是:上位机严格等ACK后再发下一帧,MCU端在进入Flash擦写期间关闭串口接收中断,仅在擦写完成后恢复。这样整个链路的背压机制就完整了。

6.4 坑3:升级中断电的恢复策略

一个永远躲不开的风险是升级到一半断电,Flash里同时存在半截App和半截Bootloader。Bootloader区域完好,所以系统不会完全变砖,但App区已经损坏,标志位还停留在"升级中"状态。我针对这个场景做了两步处理:一是App在跳转前先校验自己的CRC,如果不通过就重新进入Bootloader等待升级;二是Bootloader里加了超时退出逻辑,升级流程卡住超过30秒自动复位,复位的Bootloader再根据标志位决定是否继续尝试升级。这就保证设备只要还能上电,永远有一条救回来的路。

我在实际做这个项目的过程中,最大的体会是:IAP方案里协议本身并不难,难的是把两端的时序、错误处理、边界条件都考虑周全,并且留好恢复手段。上面这套设计已经经过多轮实测验证,如果你也在用HC32L130/L136做产品,按这个思路搭一套Bootloader加自研上位机的方案,比自己从头摸索省时间得多。还有一个小建议,一开始就在App里做一个软件版本号上报接口,后期做远程升级统计和现场故障定位会非常方便。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询