尧图精选

CW32L012串行Flash下载方案移植:Bootloader与量产烧录实战

🕒 发布时间:2026/9/12 7:10:16 📁 来源:尧图网络
玩单片机这些年我一直有个执念能用串口搞定的事绝不动调试器。尤其是到了产线烧录或者现场升级这种场景SWD调试器又贵又挑环境一根USB转串口线反倒是最靠谱的伙伴。所以当我拿到CW32L012这颗芯片第一反应不是看它的外设资源有多豪华而是琢磨怎么把串行flash下载这套方案给它安排上。折腾了几天踩了不少坑终于把整个流程跑通了今天就把它完整记录下来。这篇教程适合两类人一类是刚接触CW32系列想用低成本串口方案解决程序烧录问题的开发者另一类是手上正好有量产项目想提前评估串口下载能不能替代传统调试器烧录的工程师。我会从项目背景、硬件准备、协议设计、代码移植、主机端工具到常见坑位完整走一遍保证你看完能直接照着做。1. 项目背景与方案选型1.1 为什么CW32L012需要串行flash下载方案CW32L012是武汉芯源半导体推出的一款Cortex-M0内核MCU主频最高能跑到48MHzflash容量从32KB到64KB可选主打低功耗和高性价比。这颗芯片本身支持SWD调试接口但开发阶段用SWD没问题真到了量产阶段问题就来了SWD接口需要专门的调试器量产工人操作起来不如串口线直观而且有些结构紧凑的产品外壳装好后根本留不出SWD接口的位置。我最早量产一款智能传感器的时候就是被这个问题卡住的。产品内部空间极小PCBA上只预留了一排测试点SWD的SWCLK和SWDIO分别分布在两个角落产线上工人拿飞针探针去触碰测试点误触率很高时不时就烧录失败。后来改用串口TXD/RXD两条线直接并到产品调试串口上稳定性一下子提升了不少。CW32L012集成的是串行flash控制器支持标准的片上flash编程接口所以移植串口升级方案完全是可行的。1.2 方案的三种常见形态在正式动手之前我先把市面上常见的串行flash下载方案捋了一下发现主要有三种形态第一种是芯片原厂出厂Bootloader方案。CW32系列芯片出厂时自带一段ISP引导程序可以通过串口直接烧录。这个方案的好处是零成本不需要自己写bootloader坏处是灵活性差原厂ISP协议不公开底层细节而且通常会占用一部分flash空间升级流程固定死板。第二种是自研Bootloader加APP的双区方案。上电先跑bootloader检测串口是否有升级指令有就接收固件写入flash没有就跳转到APP区。这个方案最常用可控性最强我今天讲的移植也是基于这个方案。第三种是通过SPI接口接外部flash把固件先缓存在外部flash再写入内部。这个方案适合固件体积特别大、内部flash不够缓存的情况但对CW32L012这种内部flash本来就不大的芯片来说属于过度设计用得很少。我最终选择的是第二种方案理由很简单代码量可控协议自己定想加校验加校验想加密解密都行最关键的是可以完全避开原厂ISP可能存在的限制比如波特率固定、帧格式固定这些问题。1.3 移植前后的资源对比移植前我特意统计了一下资源占用情况列个表方便大家对照资源项Bootloader占用APP区可用备注Flash起始地址0x00000000-0x00001FFF0x00002000以后Bootloader预留8KBRAM占用约400字节不受影响主要给串口缓冲区用定时器无独占全部可用超时检测用SysTick串口UART1与APP共用打印串口波特率默认115200GPIO1个普通IO判断下载模式可复用按住按键上电进入下载模式Bootloader占用8KB flash是我踩过坑之后定下来的。一开始我预留了4KB结果代码一编译眼看就要超了临时改链接脚本又容易出错。后来干脆一步到位预留8KB反正CW32L012的flash最小也有32KBAPP区还剩24KB日常项目完全够用。2. 硬件准备与引脚规划2.1 最小系统连接方式串行flash下载方案看着简单但硬件的几个关键点不处理到位后面全是麻烦。先说供电CW32L012的工作电压范围是1.65V到3.6V串口模块的逻辑电平必须和MCU的VDD匹配。我习惯用3.3V供电所以USB转串口模块也选3.3V电平的千万不要直接拿5V电平的模块怼上去轻则通信乱码重则烧芯片。再说引脚分配CW32L012有两组串口可以用我选的是UART1的PA2和PA3作为下载串口。选PA2/PA3的原因很简单PA2是USART1_TXPA3是USART1_RX这两个引脚在绝大多数CW32L012封装上都引出来了而且默认功能就是串口不需要额外配置remap。PC13我用来作为下载模式检测引脚低电平有效——上电时如果按住按键将PC13拉低就进入下载模式否则直接跳转APP。连接关系非常简单PA2 (USART1_TX) 接 USB转串口模块的 RXPA3 (USART1_RX) 接 USB转串口模块的 TXPC13 接一个10K上拉电阻到VDD再串一个按键到GND共地务必共地共地这个问题我吃过大亏。之前调试一块板子USB转串口模块是电脑USB口供电目标板是独立电源两边没共地结果串口发送一切正常接收全是乱码。折腾了一下午最后发现就是地电位不一致导致的把两个GND用一根杜邦线连上问题秒解决。所以大家做硬件连接的时候一定把“共地”两个字刻在脑子里。2.2 复位电路与下载模式进入逻辑复位电路看起来简单但和下载模式检测逻辑是联动的这里我多说几句。CW32L012的NRST引脚是低电平复位我用的方案是10K上拉电阻加一个0.1uF电容到地这是最标准的阻容复位电路。但要注意如果产品上有指示灯、电池管理之类的负载复位引脚的上拉电阻不能选太小否则复位释放瞬间的电流冲击会影响电源稳定性。下载模式的进入逻辑我是这样设计的MCU上电复位后先延时50ms等待电源稳定然后读PC13引脚的电平。如果PC13是低电平说明用户按住了下载按键进入Bootloader如果是高电平正常跳转APP。这里有个细节如果按键是机械按键上电瞬间可能因为抖动导致误判。所以我在软件里加了20ms的去抖延时确认电平稳定后再做判断。实际使用中还有个更稳妥的做法——协议握手超时机制。也就是不管PC13是高是低MCU上电后都先进Bootloader然后等主机端发送升级指令如果500ms内没有任何指令再跳转APP。这样即使按键接触不良只要主机端软件及时发送握手指令依然能进入升级流程。我把这两种方式结合起来了先判断按键电平按键没按下就等待握手指令超时后再跳转APP。这样双保险产线上基本不会出现因为硬件原因进不了下载模式的情况。3. 移植前的软件工程准备3.1 开发环境与SDK版本选择CW32L012的开发环境我推荐用Keil MDK版本至少5.30以上。如果你手上还有IAR或者GCC工具链也都能用但考虑到CW32系列的资料和例程在Keil下最全出了问题上网查资料也方便所以对新手来说Keil是首选。SDK方面我使用的是武汉芯源半导体官方的CW32L031板级支持包因为CW32L012与CW32L031同系列外设寄存器高度兼容直接以L031的SDK为基底移植可以省掉很多自己造轮子的时间。下载SDK后主要用到这几个文件cw32l031.h寄存器定义头文件system_cw32l031.c系统时钟初始化startup_cw32l031.s启动文件cw32l031_uart.c / cw32l031_uart.h串口驱动cw32l031_flash.c / cw32l031_flash.h内部flash读写驱动当然直接用官方例程里的标准外设库也可以并不强制要求板级支持包。关键是要确保flash驱动函数的地址、命名和你的编译器平台匹配因为下面我们要基于这些驱动做二次封装和移植。3.2 链接脚本与启动文件修改这是整个移植过程中最容易翻车的地方我第一次移植就栽在这里。Bootloader和APP是两个独立的工程它们的链接脚本分散加载文件或.sct文件必须分别指定不同的flash起始地址。Bootloader工程保持默认即可即代码从0x00000000开始。APP工程的起始地址就要改了对应之前预留的8KB偏移即0x00002000。在Keil里操作很简单打开Options for Target对话框在Target标签页的IROM1起始地址填0x2000大小填0xA000这是按48KB flash算的如果你的芯片是32KB flash大小要改成0x6000。仅仅改链接脚本还不够还有一个关键的坑等着你——中断向量表重映射。APP工程的启动文件里默认会把向量表放在0x00000000但由于APP实际运行地址在0x00002000如果不修改APP一旦发生中断CPU还是会去0x00000000取向量表取到的却是Bootloader的向量表中断自然全部错乱。CW32系列不像某些芯片有专门的VTOR寄存器可以直接改向量表偏移所以更通用的做法是在APP工程启动代码的最早期把中断向量表从flash拷贝到SRAM然后把SRAM映射到0x00000000地址。具体分两步第一步在startup_cw32l031.s的Reset_Handler里增加向量表拷贝逻辑Reset_Handler ; 复制中断向量表到SRAM LDR R0, __Vectors ; 源地址flash中的向量表 LDR R1, __Vectors_RAM ; 目的地址SRAM中的向量表 MOVS R2, #128 ; 向量表大小按128字节算32个中断向量 copy_loop LDR R3, [R0], #4 STR R3, [R1], #4 SUBS R2, R2, #4 BNE copy_loop ; 重映射中断向量表到SRAM LDR R0, 0xE000ED08 LDR R1, __Vectors_RAM STR R1, [R0] ; 跳转main BL main第二步在分散加载文件中给APP工程增加一个只读数据段存放__Vectors_RAM的地址。这部分逻辑看着绕其实核心思想就是把向量表放到一个“既能在flash中存储又能在SRAM中运行”的位置。如果你用的是GCC工具链对应的链接脚本写法是MEMORY { FLASH (rx) : ORIGIN 0x00002000, LENGTH 0xA000 RAM (rwx) : ORIGIN 0x20000000, LENGTH 0x2000 } SECTIONS { .vectors : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .vectors_ram : { . ALIGN(4); __Vectors_RAM .; . 128; . ALIGN(4); } RAM /* 其他段保持不变 */ }关于向量表拷贝的大小我这边是固定预留128字节也就是32个中断向量。CW32L012的中断源如果不确定到底有多少个建议直接把__Vectors到__Vectors_End之间的长度全部拷贝过去最稳妥。这里给一段可复用的方式extern uint32_t __Vectors[]; extern uint32_t __Vectors_End[]; extern uint32_t __Vectors_RAM[]; void vector_table_reload(void) { uint32_t i, size (uint32_t)__Vectors_End - (uint32_t)__Vectors; for (i 0; i size / 4; i) { __Vectors_RAM[i] __Vectors[i]; } SCB-VTOR (uint32_t)__Vectors_RAM; }3.3 工程文件的目录组织移植前把工程目录规划好后面能省很多事。我的项目结构是这样组织的project/ ├── bootloader/ │ ├── core/ │ │ ├── startup_cw32l031.s │ │ ├── system_cw32l031.c │ │ ├── cw32l031.h │ │ ├── cw32l031_flash.c │ │ └── cw32l031_uart.c │ ├── app/ │ │ └── boot_main.c │ ├── protocol/ │ │ └── xmodem_protocol.c │ └── MDK-ARM/ │ └── bootloader.uvprojx ├── app_project/ │ ├── core/ (与bootloader共用SDK但独立编译) │ ├── user/ │ │ └── app_main.c │ └── MDK-ARM/ │ └── app.uvprojx └── host_tool/ ├── cw32_downloader.py └── README.md强调一点bootloader和APP是两个独立的Keil工程不要试图用同一个工程管理。因为它们的链接地址不同、编译选项不同、甚至优化等级都可能不同强行放在一个工程里只会让脚本越改越乱。分开之后各自编译各自下载逻辑非常清晰。3.4 编译优化等级的选择Bootloader的编译优化等级我建议选-O0或者-O1不建议上-O2以上的优化。原因很直接Bootloader代码里有很多对时序敏感的操作比如flash擦写时的循环等待、串口接收的超时判断如果编译器过度优化可能会把一些你认为“多余的”空操作直接优化掉导致时序错乱。别问我怎么知道的有一次我把Bootloader的优化级别从-O1调到了-O2结果flash擦除后总是校验失败后来用调试器单步跟踪才发现是优化器把一个关键的寄存器等待循环干掉了。APP工程则相反该开-O2开-O2该开-O3开-O3毕竟APP是真正跑业务逻辑的地方性能优化收益明显。这与Bootloader追求稳定可靠的目标不冲突。4. 核心移植步骤串口驱动的寄存器级实现4.1 引脚复用配置串口驱动的移植是整个项目的重头戏很多第一次接触CW32系列的朋友在这里卡住核心原因是CW32L012的引脚复用配置不像STM32那样写在GPIO_Init结构体里而是在专门的“引脚复用表”中配置。这个设计初看绕用熟了反而觉得顺手——所有引脚功能集中管理排查问题的时候一目了然。首先配置PA2为USART1_TXPA3为USART1_RXvoid uart_pin_init(void) { // 使能GPIOA时钟 __RCC_GPIOA_CLK_ENABLE(); // 使能UART1时钟 __RCC_UART1_CLK_ENABLE(); // PA2配置为USART1_TX复用功能带上拉 GPIO_InitTypeDef gpio_init; gpio_init.Pins GPIO_PIN_2; gpio_init.Mode GPIO_MODE_MUX; gpio_init.Speed GPIO_SPEED_HIGH; gpio_init.Pull GPIO_PULL_UP; GPIO_Init(GPIOA, gpio_init); GPIO_SetFunc(GPIOA, GPIO_PIN_2, GPIO_FUNC_USART1_TX); // PA3配置为USART1_RX复用功能带上拉 gpio_init.Pins GPIO_PIN_3; gpio_init.Mode GPIO_MODE_MUX; gpio_init.Speed GPIO_SPEED_HIGH; gpio_init.Pull GPIO_PULL_UP; GPIO_Init(GPIOA, gpio_init); GPIO_SetFunc(GPIOA, GPIO_PIN_3, GPIO_FUNC_USART1_RX); }GPIO_SetFunc这个函数就是CW32系列独有的引脚复用配置函数把引脚和复用功能解耦想改引脚绑定只需要改这一行参数即可。这个设计我后来用得很舒服调试时想把串口从UART1换到UART2只需要把GPIO_SetFunc的参数换一下再改一下UART外设基地址就够了。需要注意的是引脚复用功能的枚举值不是随便填的要看芯片手册的“引脚功能复用表”。CW32L012的手册最后几页会有这么个表PA2对应USART1_TXPA3对应USART1_RX这个对应关系务必查手册确认不要凭感觉填。我见过不少朋友拿STM32的思维去套CW32结果烧录后串口完全没反应最后发现就是复用功能枚举值填错了。4.2 串口时钟与波特率计算CW32L012的UART1挂载在APB总线上时钟源可以选择PCLK或者SYSCLK。为了波特率计算方便我直接选择PCLK作为UART1的时钟源。系统时钟配置为48MHzAPB分频系数为1也就是说PCLK 48MHz。波特率115200的计算过程目标波特率115200时钟源频率48,000,000 Hz分频系数 48,000,000 / 115200 416.67取整417实际波特率 48,000,000 / 417 ≈ 115108误差 (115200 - 115108) / 115200 ≈ 0.08%0.08%的误差完全在串口通信可接受范围内所以115200这个波特率可以用。如果系统时钟不是整数分频的计算出来的误差会变大超过2%就容易出现偶发乱码了。再补充一个经验值UART1的接收和发送都建议启用FIFO。CW32L012的UART支持发送FIFO和接收FIFO深度各为8字节。开启FIFO后接收多个字节时的CPU中断频率会显著降低对Bootloader接收大固件来说帮助明显。初始化代码里使能FIFO的寄存器位为UART_CR2的FIFOEN位。void uart1_init(uint32_t baudrate) { // 复位UART1 UART1-CR0 UART_CR0_RST; UART1-CR0 0; // 使能发送和接收 UART1-CR0 | UART_CR0_TXEN | UART_CR0_RXEN; // 配置8数据位1停止位无校验 UART1-CR1 | UART_CR1_MODE_8N1; // 使能FIFO UART1-CR2 | UART_CR2_FIFOEN; // 设置波特率 UART1-BRR (uint32_t)(48000000 / baudrate); }4.3 串口发送与接收的实现细节串口发送函数的移植相对简单核心逻辑就一句话把数据写入发送数据寄存器等待发送完成标志。但这里有个优化点如果开启了发送FIFO可以先检查FIFO是否已满满了再等待否则直接往FIFO里塞数据效率能提升不少。void uart1_write_byte(uint8_t data) { while (!(UART1-ISR UART_ISR_TXE)); // 等待发送数据寄存器空 UART1-TDR data; } void uart1_write_buffer(uint8_t *buf, uint16_t len) { for (uint16_t i 0; i len; i) { uart1_write_byte(buf[i]); } }接收函数的实现就要小心了因为Bootloader的接收逻辑是“不定长、按帧处理、超时判定”。如果用查询方式接收主循环需要不停轮询接收标志位浪费CPU用中断方式的话帧边界怎么判定又是个问题。我这边用了一个简单可靠的做法接收超时中断处理。思路是这样的串口每收到一个字节就触发一次接收中断在中断服务函数里把数据存入缓冲区同时重置一个超时计数器。主循环里不断检查这个超时计数器如果连续多长周期没有新数据进来就认为一帧数据接收完毕开始处理帧。这个“帧间超时判定”的机制比固定帧长要灵活得多尤其适合Xmodem这类变长协议。#define RX_TIMEOUT_MS 10 // 10ms没有新数据视为帧结束 volatile uint8_t uart1_rx_buf[1024]; volatile uint16_t uart1_rx_cnt 0; volatile uint8_t uart1_rx_idle 0; void UART1_IRQHandler(void) { if (UART1-ISR UART_ISR_RXF) { uint8_t data (uint8_t)UART1-RDR; if (uart1_rx_cnt sizeof(uart1_rx_buf)) { uart1_rx_buf[uart1_rx_cnt] data; } uart1_rx_idle 0; // 重载超时定时器 } }配合一个1ms的SysTick中断主循环里做超时判断void idle_handler(void) { static uint16_t idle_timeout 0; if (uart1_rx_cnt 0) { idle_timeout; if (idle_timeout RX_TIMEOUT_MS) { uart1_rx_idle 1; idle_timeout 0; } } }4.4 串口乱码排查心得串口乱码是嵌入式开发最常见的问题在Bootloader场景下尤其致命——因为Bootloader正常情况下不会输出任何数据一旦乱码你甚至没法判断芯片是否还活着。我整理几个排查重点第一检查电平匹配。USB转串口模块的输出电平是不是3.3V如果是5V模块必须加电平转换芯片比如TXS0108E或者简单点用两个三极管搭个电平转换电路。第二检查波特率计算。不要只看理论值用逻辑分析仪或者示波器实测一下TX引脚的波形看一位持续的时间是不是8.68us。CW32L012的BRR寄存器写入流程需要在CR0里先关断UART改完再打开否则写入无效。第三检查引脚复用。这是CW32系列特有的坑PA2配置成USART1_TX后还要确认GPIO_SetFunc是否被正确调用。很多朋友只配置了GPIO模式为复用忘了调用GPIO_SetFunc结果引脚仍然输出普通GPIO电平。第四检查共地。前面说了这是最容易被忽略的问题尤其是用笔记本供电时USB模块和板上电源来自不同供电回路地电位不一致乱码率极高。5. 核心移植步骤内部flash驱动移植5.1 CW32L012内部flash结构在写flash驱动之前先要搞清楚CW32L012的内部flash结构。CW32L012的flash按扇区组织每个扇区大小是512字节整片flash由若干个扇区组成。擦除操作的最小单位是扇区也就是说你哪怕只想改一个字节也必须先把整个扇区擦掉再重新写入全部数据。这和EEPROM差别很大很多从EEPROM转过来的朋友一开始不适应改一个配置项要“读出-擦除-写入”三步走。CW32L012的flash操作需要特别注意一个特性flash操作期间如果CPU需要从同一块flash取指会发生总线阻塞。所以擦写flash的函数代码必须放到RAM中运行或者关闭全局中断以避免取指冲突。CW32系列的官方库通常提供了一个宏来标记RAM运行的函数#if defined (__CC_ARM) #define RAM_FUNC __attribute__((section(RamFunc))) #elif defined (__GNUC__) #define RAM_FUNC __attribute__((section(.ramfunc))) #endif所有需要在flash操作期间执行的函数加上RAM_FUNC修饰然后链接脚本里要把RamFunc段放到SRAM区域。5.2 解锁、擦除、写入的完整流程CW32L012的flash控制器和STM32类似有解锁机制防止误操作。标准流程是#define FLASH_KEY1 0x5A5A #define FLASH_KEY2 0xA5A5 void flash_unlock(void) { // 先解锁主flash编程 if (FLASH-CR FLASH_CR_LOCK) { FLASH-KEY FLASH_KEY1; FLASH-KEY FLASH_KEY2; } } void flash_lock(void) { FLASH-CR | FLASH_CR_LOCK; }擦除扇区的实现RAM_FUNC void flash_erase_sector(uint32_t sector_addr) { // 等待flash空闲 while (FLASH-SR FLASH_SR_BUSY); // 解锁 flash_unlock(); // 设置扇区地址并触发擦除 FLASH-ADDR sector_addr; FLASH-CR | FLASH_CR_ERASE; FLASH-CR | FLASH_CR_START; // 等待擦除完成 while (FLASH-SR FLASH_SR_BUSY); // 重新加锁 flash_lock(); }写入一个字的实现RAM_FUNC void flash_program_word(uint32_t addr, uint32_t data) { while (FLASH-SR FLASH_SR_BUSY); flash_unlock(); // 先写入数据寄存器 FLASH-DR data; // 设置目标地址 FLASH-ADDR addr; // 触发编程 FLASH-CR (FLASH-CR ~FLASH_CR_ERASE) | FLASH_CR_START; // 等待编程完成 while (FLASH-SR FLASH_SR_BUSY); flash_lock(); // 校验 if (*(volatile uint32_t *)addr ! data) { // 写入失败进入错误处理 } }5.3 写flash时CPU取指冲突的处理这部分是整个移植过程中最核心、也最容易出问题的点。前面说过flash擦写期间CPU如果还要从同一块flash取指就会出问题。解决思路有几个层次最粗犷的做法是关闭全局中断在擦写前调用__disable_irq()擦写完成后调用__enable_irq()重新打开中断。这个做法的缺点是如果擦写函数本身放在flash里关闭中断后CPU仍然在从flash取指系统仍然可能卡死。所以关闭中断必须配合中断服务函数和擦写函数都放入RAM的手段才能保证彻底隔离。推荐的方案是把擦写代码全部放到RAM中执行。上面的RAM_FUNC宏就是干这个的。在Keil的分散加载文件里需要新增一个RamFunc的执行区LR_IROM1 0x00002000 0x0000A000 { ER_IROM1 0x00002000 0x0000A000 { *(.isr_vector) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00002000 { .ANY (RW ZI) } RW_RAMFUNC 0x20000400 UNINIT { *(RamFunc) } }如果不方便修改分散加载文件还有另一个取巧的办法擦写走flash地址别名。CW32系列有些型号的flash支持“编程时通过别名地址访问”也就是擦写期间CPU通过特殊地址窗口去访问flash让正常的取指不受影响。但这个功能具体是否支持需要查阅对应型号的数据手册不是所有型号都有。我在CW32L012上的实测结论是这样的只要把flash擦写函数放RAM同时关闭全局中断就没有问题。两个条件缺一个都不行。单独关闭全局中断但代码还在flash会在擦除期间卡死单独放RAM但没关中断如果串口中断恰好打断了擦写流程flash状态机可能被破坏导致写入数据错误。6. 串行下载通信协议设计6.1 Xmodem协议的选型思考通信协议是整个串行下载方案的灵魂最初我想自己定义一套简单的私有协议比如固定帧头加命令字加数据长度加CRC16后来想了想还是决定用成熟的Xmodem协议。原因有三点第一Xmodem是几十年前就定型的经典协议成熟稳定主机端工具随便用什么语言都能轻易实现Python、C#、C都有现成的库开发门槛低。第二Xmodem天然支持分包传输、应答确认和CRC校验这正好满足bootloader烧录对可靠性的要求。Xmodem-1K模式每包可以传1024字节对动辄几十KB的APP固件来说传输效率也还可以。第三Xmodem以字节为最小操作单位的设计让后续扩展加密、压缩等处理变得简单。你可以在发送前对固件做AES加密bootloader收到后解密再写入flash流程完全不用改协议本身。当然Xmodem也有它的缺点最明显的就是协议开销大。每个1024字节的数据包要额外携带3字节的头部和2字节的CRC16有效载荷率约99.5%等等重新算一下3102421029载荷率1024/1029约99.5%其实很高了。整体来说利大于弊最终就定它了。6.2 Xmodem-1K的帧格式Xmodem-1K的帧格式如下SOH (0x01)表示128字节数据块模式STX (0x02)表示1024字节数据块模式块序号从0x01开始循环递增0x00和0xFF特殊值不使用块序号的反码用于校验块序号数据块128字节或1024字节校验CRC16高字节 CRC16低字节使用CRC-CCITT多项式控制字符定义字符值含义SOH0x01128字节包开始STX0x021024字节包开始EOT0x04发送结束ACK0x06确认接收NAK0x15请求重传或开始传输CAN0x18取消传输C0x43请求CRC模式握手流程接收方bootloader发送字符C表示等待接收发送方收到C后开始发送第一个数据包接收方校验通过后回ACK否则回NAK发送方收到NAK重新发送当前包。所有数据包发送完毕后发送方发送EOT接收方回ACK整个传输完成。6.3 主机端Python工具的实现主机端工具我用Python实现核心依赖是pyserial。上位机工具的逻辑分为三层串口通信层处理底层字节收发和超时重试Xmodem协议层处理分包、校验和确认应用层负责读取固件文件、启动传输、显示进度。先看串口通信层import serial import time class SerialPort: def __init__(self, port, baudrate115200, timeout0.1): self.ser serial.Serial(port, baudrate, timeouttimeout) def read_byte(self): data self.ser.read(1) return data[0] if data else None def write_byte(self, data): self.ser.write(bytes([data])) def write_bytes(self, data): self.ser.write(data) def flush(self): self.ser.flush()Xmodem-1K的发送端核心逻辑import crcmod.predefined crc16 crcmod.predefined.mkCrcFun(xmodem) class XmodemSender: def __init__(self, serial_port): self.port serial_port self.seq 1 def send_packet(self, data): # 数据包格式STX SEQ SEQ_INV DATA CRC seq_inv (~self.seq) 0xFF crc crc16(data) packet bytes([0x02, self.seq, seq_inv]) data bytes([(crc 8) 0xFF, crc 0xFF]) self.port.write_bytes(packet) self.seq (self.seq 1) 0xFF def start_transfer(self, file_path, packet_size1024): # 等待接收方的C字符 while True: c self.port.read_byte() if c 0x43: break # 分包发送 with open(file_path, rb) as f: while True: data f.read(packet_size) if not data: break # 补齐不足packet_size的包 if len(data) packet_size: data b\x1A * (packet_size - len(data)) # 发送并等待ACK最多重试10次 for retry in range(10): self.send_packet(data) response self.port.read_byte() if response 0x06: # ACK break elif response 0x15: # NAK continue else: raise Exception(fUnexpected response: {response}) else: # 最后发EOT for retry in range(10): self.port.write_byte(0x04) response self.port.read_byte() if response 0x06: break6.4 Bootloader接收端的状态机实现Bootloader接收端的核心是一个状态机我用枚举定义了几个状态每个状态下处理特定的字符输入。这样写的好处是逻辑清晰、便于测试和排错。typedef enum { XM_IDLE, // 空闲等待起始字符 XM_SEQ, // 收到起始字符等待块序号 XM_SEQ_INV, // 收到块序号等待反码 XM_DATA, // 正在接收数据 XM_CRC_H, // 收到数据等待CRC高字节 XM_CRC_L, // 等待CRC低字节 XM_ERROR // 出错状态 } XmodemState;状态机的主循环可以这样实现XmodemState xm_state XM_IDLE; uint8_t xm_seq_expected 0x01; uint8_t xm_data_buf[1024]; uint16_t xm_data_len 0; void xmodem_process_byte(uint8_t byte) { switch (xm_state) { case XM_IDLE: if (byte STX) { // 1024字节包 xm_data_len 1024; xm_data_cnt 0; xm_state XM_SEQ; } else if (byte SOH) { // 128字节包 xm_data_len 128; xm_data_cnt 0; xm_state XM_SEQ; } else if (byte EOT) { uart1_write_byte(ACK); // 收到结束符回ACK xm_state XM_IDLE; // 执行跳转APP jump_to_app(); } else if (byte CAN) { xm_state XM_IDLE; } break; case XM_SEQ: if (byte xm_seq_expected) { xm_state XM_SEQ_INV; } else { uart1_write_byte(NAK); xm_state XM_IDLE; } break; case XM_SEQ_INV: if (byte (uint8_t)(~xm_seq_expected)) { xm_state XM_DATA; } else { uart1_write_byte(NAK); xm_state XM_IDLE; } break; case XM_DATA: if (xm_data_cnt xm_data_len) { xm_data_buf[xm_data_cnt] byte; } if (xm_data_cnt xm_data_len) { xm_state XM_CRC_H; } break; case XM_CRC_H: xm_received_crc (uint16_t)byte 8; xm_state XM_CRC_L; break; case XM_CRC_L: xm_received_crc | byte; // 计算实际CRC并比较 uint16_t calc_crc crc16_calc(xm_data_buf, xm_data_len); if (calc_crc xm_received_crc) { flash_write_packet(xm_seq_expected, xm_data_buf, xm_data_len); uart1_write_byte(ACK); xm_seq_expected; } else { uart1_write_byte(NAK); } xm_state XM_IDLE; break; } }这个状态机的核心优势是每个字节的处理逻辑是确定性的出问题可以通过串口打印状态值快速定位。我调试时加了串口打印状态转换信息的功能一旦传输中断看log能直接看出是卡在等待块序号还是等待数据阶段排查效率提升明显。7. 下载模式跳转与升级流程实现7.1 Bootloader中的APP启动条件判断跳转逻辑是整个方案的收尾环节虽然代码不长但细节很多。我在Bootloader的main函数里的主循环逻辑是这样设计的初始化时钟为48MHz配置串口和GPIO初始化flash控制器读取PC13引脚电平加20ms去抖如果PC13为低电平下载按键按下进入下载流程等待主机端发送Xmodem传输数据如果PC13为高电平进入超时等待状态500ms内如果收到字符“C”请求握手则进入下载流程否则超时后跳转APP这里有一个关键分支——当需要跳转APP时跳转前必须做几件收尾动作关闭全局中断防止跳转瞬间有中断触发导致取指到未初始化的APP中断向量关闭SysTick定时器把SysTick的计数值清零避免APP启动时误判系统时钟复位UART1外设到默认状态如果带着Bootloader的串口配置跳到APPAPP要重新初始化串口中间可能有残留数据设置主栈指针为APP向量表的第一个字跳转代码实现typedef void (*app_entry_t)(void); void jump_to_app(void) { uint32_t app_addr APP_START_ADDR; // 0x2000 uint32_t app_stack *(volatile uint32_t *)app_addr; uint32_t app_entry *(volatile uint32_t *)(app_addr 4); // 检查向量表是否有效 if ((app_stack 0x2FFE0000) ! 0x20000000) { // 校验失败栈顶指针不是合法SRAM地址 return; } // 关闭全局中断 __disable_irq(); // 复位SysTick SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 复位UART1 UART1-CR0 | UART_CR0_RST; // 设置跳转函数 app_entry_t app (app_entry_t)app_entry; // 设置主栈指针 __set_MSP(app_stack); // 跳转 app(); // 永远不应该执行到这里 while (1); }这段代码最值得说的就是栈顶指针合法性检查。CW32L012的SRAM地址从0x20000000开始大小16KB所以合法的栈顶指针应该在0x20000000到0x20004000之间。如果这个地址被错误地写成了0xFFFFFFFFflash空数据说明APP区是空的这时候跳转过去只会死机所以先检查再跳转是保命符。7.2 APP工程中的SystemInit处理APP工程跳转后有另一个容易忽略的坑APP工程编译时编译器自动生成的SystemInit函数会把系统时钟重新配置一遍。如果你的APP工程里SystemInit代码沿用默认配置系统时钟可能从Bootloader的48MHz被重置为默认的内部RC时钟导致APP外设工作异常。解决方式是在SystemInit函数中明确配置系统时钟为想要的频率void SystemInit(void) { // 使能HSI // 配置PLL倍频系数使系统时钟达到48MHz // 设置flash等待周期 // 更新全局SystemCoreClock变量 }这里有个小经验如果你在Bootloader里已经初始化好时钟了APP的SystemInit里可以什么都不做只更新SystemCoreClock变量。这样跳转后时钟不会被打断实现无缝衔接。但考虑到独立调试APP时也需要正确的时钟我建议还是在SystemInit里把时钟配置逻辑写完整保持APP工程的独立性。7.3 Bootloader中配置存储和flash规划除了存放APP代码的flashBootloader还需要考虑配置参数的存储。比如你在量产时要给不同产品写不同的序列号、校准参数这些数据放在flash的哪个区域必须有明确的规划。我的flash分区规划如下地址范围大小用途0x00000000-0x000000FF256BBootloader中断向量表0x00000100-0x00001FFF约8KBBootloader代码区0x00002000-0x00003FFF8KB参数配置区0x00004000-0x0000FFFF48KBAPP代码区0x00010000-0x0001FFFF64KB预留区个人建议配置参数区单独划一块不要和APP区混在一起。因为参数区更新频率可能很高每次都擦除APP区会带来风险。设置参数区时写操作要遵循“读-改-擦-写”的标准流程typedef struct { uint32_t magic; // 魔数用于确认参数有效 uint32_t version; // 参数版本号 uint8_t sn[16]; // 序列号 int16_t offset; // 校准偏移 } app_params_t; void params_save(app_params_t *params) { uint32_t addr PARAM_BASE_ADDR; // 1. 如果已有有效参数先备份到RAM app_params_t old_params; if (params_check_valid()) { old_params params_load(); } // 2. 擦除参数区扇区 flash_erase_sector(addr); // 3. 写入新参数 flash_program_word(addr, *(uint32_t *)params); flash_program_word(addr 4, *(uint32_t *)((uint8_t *)params 4)); // ... 根据结构体大小逐个字写入 }写完参数读回来校验校验失败尝试再擦再写连续失败三次报错误提示用户重新设置。这套逻辑虽然朴素但在量产环境中非常可靠我用到至今没出过因为参数写入失败导致的设备异常。8. 常见问题与排查技巧实录8.1 下载卡在等待C字符现象主机端工具启动后一直显示“等待接收方发送C字符”就是进入不了传输阶段。排查方向先用串口助手直接发一个“C”字符0x43看Bootloader是否回NAK0x15。如果回NAK说明串口通路和Bootloader接收逻辑都正常问题出在主机端工具没有正确检测C字符。如果什么都不回说明Bootloader根本没收到数据。检查接线方向。USB转串口模块的RXD要接MCU的TXDPA2TXD要接MCU的RXDPA3。很多朋友把两根线接反了结果“监听”到了自己发的数据却收不到对方的回复。检查波特率是否匹配。主机端工具和Bootloader的波特率必须一致。我调试时习惯用115200但如果你的USB转串口模块晶振不准实际波特率会有偏差。可以试着降低波特率到9600看通信是否恢复正常。检查Bootloader是否真的进入了下载模式。如果PC13按键没按对或者超时时间设得太短Bootloader可能已经跳转到APP了自然收不到C字符。此时无论怎么发握手信号都没用。8.2 传输中途CRC校验失败现象传输进行到一半Bootloader频繁回NAK主机端重试几次后放弃升级失败。排查方向检查供电稳定性。这是最常见的原因。USB转串口模块连着电脑电压波动会直接影响MCU供电。我遇到过一个案例板子上还有一个电机驱动启动瞬间电流达到700mA导致MCU供电电压跌到3.0V以下flash写入时电压不稳导致数据损坏。解决方式是加一个大容量电解电容或者用独立稳压电源供电。检查波特率误差。当波特率超过460800时CW32L012的BRR分频误差可能超过1%在长包传输时累积错误。建议用115200甚至更低的57600稳定优先。检查flash擦除是否超时。如果擦除大扇区时等待循环没有设置超时上限万一flash状态机卡住Bootloader就会永久卡在擦除状态表现为“收到一个NAK后不再有任何响应”。这种情况必须重启才行。所以我的代码里给所有flash等待循环都加了超时上限超过一定时间直接报错退出。排查问题最快的工具还是逻辑分析仪把RXD和TXD的波形拉出来看能直接看出是发送端的问题还是接收端的问题。曾经有个CRC校验失败的问题我用逻辑分析仪看波形发现主机端发送的数据包中间有一段电平异常——原来是数据线接触不良某个字节的位信号被拉长了。这种问题靠看代码根本找不到原因。8.3 跳转APP后串口打印乱码现象Bootloader下载成功但跳转APP后APP的串口打印一片乱码。排查方向检查波特率设置是否一致。APP工程里如果用了和Bootloader不同的波特率比如Bootloader是115200APP里初始化成了9600那打印必然乱码。检查时钟配置。APP工程的SystemInit如果重新配置了系统时钟且倍频系数写错了比如配置成了56MHz而不是48MHzUART波特率计算基准就变了实际波特率差一截乱码自然出现。检查跳转前是否正确复位了UART1。如果在Bootloader持有UART1外设控制权时直接跳转APPAPP重新初始化UART1时可能残留波特率配置、FIFO状态等异常信息。跳转前手动复位UART1外设是个好习惯。如果APP中断向量表重映射做得不彻底串口中断服务函数可能没有正确注册导致中断数据丢失。这种情况乱码不规律带有丢字符特征。8.4 问题排查速查表现象可能原因检查项解决方式无法进入下载模式引脚复用配置错误用示波器看PC13电平检查GPIO_SetFunc和按键电路串口完全无响应接线错误检查TXD/RXD是否交叉连接重新接线传输开始后立刻失败波特率不匹配用逻辑分析仪看波形验证波特率统一波特率传输中途CRC失败供电波动用万用表测VDD电压加电容独立供电跳转后程序跑飞栈顶指针校验失败检查APP起始地址向量表内容确认APP工程链接地址跳转后中断异常中断向量表未重映射检查VTOR或向量表拷贝代码补充向量表重映射逻辑APP串口打印乱码时钟配置不一致打印SystemCoreClock统一时钟配置8.5 我的几个独家避坑技巧第一个技巧下载校验结束再加一道保险。Xmodem协议本身自带CRC校验但我在整个固件接收完成后还会对所有写入flash的数据做一次累加和校验。具体做法是把固件按照4字节对齐分成多个字在主机端算出一个总的累加和放在固件末尾一并发送Bootloader接收完再算一遍累加和两者对比一致才算升级成功。这样就算中间的某个包被CRC漏过极小概率最终校验也能发现。第二个技巧善用串口打印调试信息但要分级。Bootloader的串口打印在正式量产时必须关闭否则下载过程会干扰Xmodem协议通信。我的做法是用一个编译宏控制验证阶段打开printf量产编译时关掉非常干净。第三个技巧升级失败后的自动回退机制。我在flash里写了一个“升级计数”字段每次开始升级时先加1升级成功后又减1。如果Bootloader检测到计数大于3说明连续三次升级失败自动恢复到上一次保存的备份固件。这个机制防住了不少产线意外情况。第四个技巧硬件设计时给串口下载留一个测试点。即使你的产品量产时用SWD也建议在设计阶段把串口下载的TX/RX各引两个测试点出来。有一次我在客户现场做固件升级他们的SWD接口被结构件挡住了幸好预留了串口测试点直接用飞线就完成了升级客户当场惊呆。9. 后续扩展方向串行flash下载方案跑通后我还在陆续做一些扩展这里简单聊聊给大家一个参考方向。一个是固件加密传输。Xmodem协议本身是明文传输只要有人监听串口线就能抓取完整的固件数据逆向出APP逻辑。我现在在实验的方案是主机端先用AES-128对固件做加密把密钥烧录在Bootloader固定位置升级时Bootloader解密后再写入flash。这样即使固件被截获没有密钥也无法还原对很多有防抄板需求的场景非常实用。另一个是OTA差分升级。现在很多产品只有一根串口线连着外部模块没有网络OTA根本无从谈起。但如果外部模块本身有联网能力可以通过串口把升级包传给BootloaderBootloader支持只接收差分数据、在内部做差分合并再写入APP区可以把升级包体积缩小80%以上。这个方案的难点在于差分算法的选择和flash缓存的规划后续有时间我再单独写一篇。还有一个相对简单的方向把bootloader同时烧录出厂固件和参数配置。产线上一次串口操作同时完成“烧固件”和“写序列号”两个步骤节省一道工序这事谁用谁知道省下来的时间都是产线良率。串行flash下载方案的移植其实不复杂真正的复杂度在于底层细节的把控——中断向量表的重映射、flash擦写的时序、协议状态机的边界条件、跳转时机的正确性判断。任何一个环节出问题整个方案就跑不通。但一旦跑通了这套方案就能在产线烧录、现场升级、固件恢复等多个场景中发挥价值。我在自己的几个项目里用了这套方案无论是量产稳定度还是后期维护效率都提升非常明显。如果你也在用CW32L012或者其他同系列芯片强烈建议试一试。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →