☰
STM32 IAP升级实战:Flash分区、向量表重映射与Ymodem状态机避坑指南
2026/11/5 4:16:32 网站建设 项目流程

简介:面向STM32G4系列开发者与嵌入式初学者的IAP在线升级实践资料,围绕STM32G431单片机展开,基于Ymode协议实现Bootloader与应用程序的固件升级流程,适合需要为产品加入串口远程升级能力、或正在学习Bootloader跳转与Flash分区管理的工程师参考。压缩包共200个文件、约10.62MB,以26个C源文件与69个头文件构成HAL库驱动与升级逻辑主体,辅以Keil工程文件、编译中间产物、map与lst映射清单、hex烧录文件及ioc配置,完整保留了从工程配置到编译输出的全链路痕迹。已有207人学习下载,可直接对照工程结构理解协议帧解析、Flash擦写与跳转实现,并借助映射文件排查空间占用与链接问题,省去从零搭建验证环境的时间。

1. 为什么一个 32KB 的 bootloader 值得单独拆开看

很多人第一次接触 IAP 升级,脑子里想的都是「上位机发文件、单片机收文件、写完跳过去」这三步,觉得 bootloader 不过是一段几百行的搬运代码。真到板子上跑起来才发现,事情远没有这么简单:跳转过去程序跑飞、升级到一半断电变砖、新固件校验通过但一运行就 HardFault、Ymodem 传大文件时莫名丢包。这些问题的根子,几乎都不在应用层,而在 bootloader 对 Flash 分区、中断向量、传输协议状态机的处理方式上。STM32G431 这类 Cortex-M4 芯片,Flash 只有 128KB 左右,RAM 32KB,bootloader 和应用要挤在同一片存储里,任何一处地址算错都是血泪经验。搞懂这套机制,你才能明白为什么升级流程要这么设计、为什么有些「看起来多余」的校验和跳转前准备一步都不能省。

2. Flash 分区与向量表重映射:bootloader 和应用怎么在同一片 Flash 里共存

2.1 为什么必须先划死分区边界

IAP 的本质是「程序自己改自己」,而 Cortex-M 的取指是从固定地址开始的。上电复位后,内核从 0x08000000 取初始 MSP,从 0x08000004 取 Reset_Handler 地址,这是硬件写死的,改不了。所以 bootloader 必须占据 Flash 起始位置,应用只能往后挪。STM32G431 的 Flash 页大小是 2KB(G4 系列部分型号支持双 bank,但单 bank 模式下按 2KB 页擦除),这就决定了分区边界最好落在页边界上,否则擦除应用区时会连带擦掉 bootloader 的尾部。

常见的实现思路是把 128KB 切成两段:前 32KB 给 bootloader,后 96KB 给应用。这个比例不是随便定的——bootloader 要放 Ymodem 解析、Flash 驱动、串口收发、CRC 校验,32KB 对 G431 来说比较宽裕;应用区留 96KB 也够大多数控制类固件用。分区一旦定下,就要在链接脚本里同步体现,否则编译器会把应用代码放到错误地址。

/* 分区宏定义,bootloader 和 app 共用同一份头文件 */ #define FLASH_BASE_ADDR 0x08000000U #define BOOTLOADER_SIZE (32U * 1024U) /* 32KB 给 bootloader */ #define APP_START_ADDR (FLASH_BASE_ADDR + BOOTLOADER_SIZE) /* 0x08008000 */ #define APP_MAX_SIZE (96U * 1024U) /* 应用区上限 96KB */ #define FLASH_PAGE_SIZE 2048U /* G431 单 bank 页大小 */

这段宏是整个升级系统的地基。APP_START_ADDR既用于 bootloader 跳转,也要写进应用的链接脚本(FLASH ORIGIN = 0x08008000),两边必须严格一致。我见过最常见的翻车就是 bootloader 里改了地址、应用链接脚本忘了改,结果应用编译出来还从 0x08000000 开始,烧进去直接把 bootloader 覆盖了,板子当场变砖。所以这类地址宏一定要放在共享头文件里,两边#include同一份,别各写各的。

2.2 向量表偏移:跳转后中断为什么全乱了

应用从 0x08008000 开始运行后,中断向量表默认还指向 0x08000000,也就是 bootloader 的向量表。这时候应用里任何一个中断触发,CPU 都会跳到 bootloader 的中断服务函数去,轻则功能异常,重则直接跑飞。解决办法是在应用启动的最早期(SystemInit之后、main之前)设置SCB->VTOR,把向量表基址改到应用自己的起始地址。

/* 应用侧 main 之前必须执行:重定位向量表 */ int main(void) { /* VTOR 要求 128 字节对齐,0x08008000 天然满足 */ SCB->VTOR = APP_START_ADDR; __DSB(); /* 确保写入完成后再取指 */ __ISB(); HAL_Init(); SystemClock_Config(); /* ... 应用逻辑 ... */ }

SCB->VTOR是 Cortex-M 的系统控制块寄存器,写它之前要保证地址对齐(低 7 位为 0),0x08008000 正好满足。__DSB和__ISB是内存屏障,防止编译器或流水线把后续取指提前到 VTOR 生效之前——这一步在优化等级高的时候尤其容易出玄学问题,不加屏障偶尔能跑、偶尔跑飞,排查起来非常痛苦。另一种做法是在 bootloader 跳转前就替应用设好 VTOR,但那样应用就依赖 bootloader 的「善意」,一旦应用被单独烧录调试就会失效,所以更稳妥的是应用自己负责重定位。

2.3 跳转前的现场清理:MSP、外设、中断一个都不能留

bootloader 跳转到应用之前,必须把 CPU 恢复到一个「干净」的状态,否则应用拿到的是一个被污染的环境。核心动作有三件:关全局中断、把 MSP 设成应用向量表里的初始值、跳转到应用的 Reset_Handler。

typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_msp = *(volatile uint32_t *)app_addr; /* 向量表第 0 项:初始 MSP */ uint32_t app_reset = *(volatile uint32_t *)(app_addr + 4);/* 向量表第 1 项:Reset_Handler */ /* 地址合法性检查:MSP 必须落在 SRAM 范围内 */ if ((app_msp & 0x2FFE0000U) != 0x20000000U) { return; /* 栈顶不合法,拒绝跳转,防止跑飞 */ } __disable_irq(); /* 关全局中断 */ SysTick->CTRL = 0; /* 停掉 SysTick,否则跳过去还在计数 */ SysTick->LOAD = 0; SysTick->VAL = 0; /* 关闭 bootloader 用到的外设,避免残留状态干扰应用 */ HAL_UART_DeInit(&huart1); HAL_DeInit(); __set_MSP(app_msp); /* 切换主栈指针 */ pFunction jump = (pFunction)app_reset; jump(); /* 跳转,不再返回 */ }

这里每个动作都有讲究。app_msp的合法性检查用掩码判断高 14 位是否为 0x2000,是因为 G431 的 SRAM 起始于 0x20000000,栈顶必须落在这片区域,否则说明应用区根本没烧进有效固件,跳过去必崩。SysTick必须手动停,因为HAL_DeInit不一定清它,残留的 SysTick 中断会在应用里继续触发,而那时向量表已经换了,中断入口对不上。__set_MSP要在关中断之后、跳转之前执行,顺序反了会有一小段窗口期用着 bootloader 的栈。这些细节单看都不起眼,凑在一起就是「跳转成功但应用跑不起来」的经典黑匣子。

3. Ymodem 协议状态机:为什么大文件传输不能靠「发完就完」

3.1 帧结构与 CRC16:128 字节和 1024 字节两种包怎么选

Ymodem 是 Xmodem 的增强版,核心改进是支持批量文件传输和 1KB 数据包。它的帧结构很规整:起始字节(SOH 表示 128 字节包,STX 表示 1024 字节包)、包序号、包序号反码、数据区、16 位 CRC。接收方每收一包都要校验序号连续性和 CRC,任何一项不对就回 NAK 要求重发。

/* Ymodem 包解析核心:返回 0 表示成功,负值表示错误类型 */ int ymodem_parse_packet(const uint8_t *buf, uint32_t len, ymodem_pkt_t *out) { if (len < 3) return -1; uint8_t start = buf[0]; if (start == SOH) { out->data_len = 128; } else if (start == STX) { out->data_len = 1024; } else if (start == EOT) { return YMODEM_EOT; /* 文件结束标志 */ } else { return -2; /* 非法起始字节 */ } out->seq = buf[1]; out->seq_inv = buf[2]; if ((uint8_t)(out->seq + out->seq_inv) != 0xFF) { return -3; /* 序号与反码不互补,帧损坏 */ } /* CRC 覆盖数据区,不含头部 3 字节 */ uint16_t crc_recv = (buf[3 + out->data_len] << 8) | buf[4 + out->data_len]; uint16_t crc_calc = crc16_xmodem(&buf[3], out->data_len); if (crc_recv != crc_calc) { return -4; /* CRC 校验失败 */ } out->payload = &buf[3]; return 0; }

选 128 还是 1024,取决于串口速率和 RAM 余量。G431 的 RAM 只有 32KB,bootloader 里开一个 1KB 的接收缓冲加一个 1KB 的发送缓冲,再算上协议栈和 HAL 的开销,压力不大;但如果波特率只有 115200,1KB 包传一包要将近 90ms,重传代价高,反而不如 128 字节包灵活。常见的做法是接收端两种都支持,发送端(上位机)根据链路质量自适应。CRC16 用的是 XMODEM 多项式 0x1021,初值 0,这个必须和上位机严格一致,否则每一包都校验失败,表现为「一直重传、永远传不完」。

3.2 状态机设计:从握手到 EOT 的完整时序

Ymodem 的传输不是简单的「收包-写 Flash」循环,而是一个有明确阶段的状态机。接收方先发字符C(0x43)请求发送方用 CRC 模式起传,然后进入「收文件头包」阶段——第一个包的数据区是文件名和文件大小,接收方据此判断空间够不够、要不要擦除。文件头确认后进入「收数据包」阶段,逐包写 Flash。收到 EOT 后回 ACK,再收一个空包确认,传输结束。

typedef enum { YM_WAIT_HEADER, /* 等待文件信息包 */ YM_RECV_DATA, /* 接收数据包 */ YM_WAIT_EOT, /* 等待结束标志 */ YM_DONE, /* 传输完成 */ YM_ERROR /* 出错 */ } ym_state_t; ym_state_t ym_fsm(uint8_t *rx_buf, uint32_t rx_len, uint32_t *write_addr) { static uint8_t expect_seq = 0; ymodem_pkt_t pkt; int ret = ymodem_parse_packet(rx_buf, rx_len, &pkt); switch (current_state) { case YM_WAIT_HEADER: if (ret == 0 && pkt.seq == 0) { /* 解析文件名和大小,检查是否超出 APP_MAX_SIZE */ if (parse_file_info(pkt.payload) < 0) return YM_ERROR; flash_erase_app_area(); /* 擦除应用区 */ *write_addr = APP_START_ADDR; expect_seq = 1; uart_send_byte(ACK); uart_send_byte('C'); /* 请求下一包 */ current_state = YM_RECV_DATA; } break; case YM_RECV_DATA: if (ret == YMODEM_EOT) { uart_send_byte(ACK); current_state = YM_WAIT_EOT; } else if (ret == 0 && pkt.seq == expect_seq) { flash_write(*write_addr, pkt.payload, pkt.data_len); *write_addr += pkt.data_len; expect_seq++; uart_send_byte(ACK); } else { uart_send_byte(NAK); /* 序号或 CRC 错,请求重发 */ } break; case YM_WAIT_EOT: /* 收空包,确认传输真正结束 */ if (ret == 0 && pkt.seq == 0) { uart_send_byte(ACK); current_state = YM_DONE; } break; } return current_state; }

这个状态机里有两个容易踩坑的点。第一,expect_seq是 8 位无符号数,传到 255 之后自然回绕到 0,这是 Ymodem 规范允许的,但如果你用int存序号又忘了取低 8 位,回绕时就会判错。第二,EOT 之后那个空包不能省——有些上位机实现会在 EOT 后补发一个 seq=0 的空包作为「最终确认」,接收方如果不处理,就会卡在YM_WAIT_EOT一直等,表现为「文件传完了但 bootloader 不跳转」。这类时序问题没有日志几乎无法定位,所以 bootloader 里最好留一个串口调试口,把状态迁移打出来。

3.3 边收边写 Flash:擦除时机与写对齐

G431 的 Flash 写入必须以双字(64 位)为单位,且写入前目标区域必须是已擦除状态(0xFF)。Ymodem 的 128 字节包天然是 8 的倍数,对齐没问题;但 1024 字节包如果最后一包不满,就要补 0xFF 到对齐边界再写。擦除则必须在收第一个数据包之前完成——文件头包里带了文件大小,接收方据此算出需要擦多少页,一次性擦完,避免边写边擦导致时序抖动。

/* 按页擦除应用区,擦除范围由文件大小决定 */ int flash_erase_app_area(uint32_t file_size) { uint32_t pages = (file_size + FLASH_PAGE_SIZE - 1) / FLASH_PAGE_SIZE; uint32_t addr = APP_START_ADDR; HAL_FLASH_Unlock(); for (uint32_t i = 0; i < pages; i++) { FLASH_EraseInitTypeDef erase = { .TypeErase = FLASH_TYPEERASE_PAGES, .Page = (addr - FLASH_BASE_ADDR) / FLASH_PAGE_SIZE, .NbPages = 1 }; uint32_t err; if (HAL_FLASHEx_Erase(&erase, &err) != HAL_OK) { HAL_FLASH_Lock(); return -1; } addr += FLASH_PAGE_SIZE; } HAL_FLASH_Lock(); return 0; }

擦除是按页算的,文件大小向上取整到页边界,多擦的尾部填 0xFF 不影响运行。这里有个血泪经验:擦除操作会阻塞 CPU,G431 擦一页大约 20~40ms,96KB 全擦要将近 2 秒,这期间如果串口还在收数据,接收缓冲就会溢出。所以擦除必须在发C请求数据之前做完,让上位机等着,而不是边擦边收。另外HAL_FLASH_Unlock和Lock要成对出现,中途 return 忘了 Lock,后续任何 Flash 操作都会失败,而且错误码不明显,很容易误判成「芯片坏了」。

4. 升级流程的避坑与排查:那些让板子变砖的瞬间

4.1 现象:升级到一半断电,重新上电后既不进 bootloader 也不进应用

原因通常是 bootloader 里没有「应用有效性标志」。如果应用区只写了一半,向量表里的 MSP 和 Reset_Handler 可能是 0xFFFFFFFF,跳转检查虽然能拦住,但 bootloader 自己没有超时机制,会一直等串口数据,看起来像死机。解决办法是在 Flash 末尾或独立的信息页里放一个升级标志和固件 CRC,上电时先校验:标志有效且 CRC 匹配才跳应用,否则停在 bootloader 等待升级。这个标志要在应用完整接收并校验通过后才置位,跳转前再读一次确认。

4.2 现象:Ymodem 传输到 60% 左右必丢包

多半是接收缓冲太小或串口中断优先级配置不当。G431 的 UART 在 115200 波特率下每字节约 87μs 到达,如果 bootloader 在写 Flash 时关了中断,或者 DMA 缓冲只有几十字节,就会溢出。解决思路是接收用 DMA + 空闲中断,把整包数据攒齐再交给状态机处理,写 Flash 期间让 DMA 继续往另一个缓冲填,双缓冲切换。中断优先级上,UART 的抢占优先级要高于其他外设,但低于 SysTick 之外的系统异常。

4.3 现象:应用单独烧录能跑,经 bootloader 跳转后 HardFault

典型原因是向量表没重定位,或者应用链接脚本的起始地址和APP_START_ADDR不一致。排查方法是在应用的HardFault_Handler里读SCB->VTOR和出错时的 PC 值,如果 PC 落在 0x08000000 附近,基本可以确定是向量表问题。另一个隐蔽原因是 bootloader 跳转前没关外设时钟,应用初始化时重复配置导致总线错误,这种情况要看CFSR寄存器的具体位。

4.4 现象:CRC 校验通过但应用运行异常

CRC 只保证「数据没传错」,不保证「数据是有效固件」。如果上位机发错了文件(比如发了 bootloader 自己的 bin),CRC 照样通过,跳过去就覆盖运行环境。稳妥做法是在固件头部加一个魔数和版本号,bootloader 校验魔数匹配才认为这是合法应用。魔数放在向量表之后的固定偏移,应用编译时由链接脚本预留,升级时一并写入。

4.5 现象:连续升级两次,第二次必失败

Flash 擦除有寿命限制,但更常见的原因是第一次升级后没有正确复位外设状态。比如 UART 的 DMA 通道还挂着、Flash 的 PG 位没清,第二次进 bootloader 时这些残留状态导致初始化失败。解决方式是在 bootloader 入口处做一次完整的外设复位(__HAL_RCC_APB1_FORCE_RESET之类),而不是依赖HAL_Init的默认行为。

5. 设计取舍:为什么 bootloader 要「笨」一点

写到这里,回头看这套 bootloader 的设计,会发现它处处在「功能」和「稳健」之间做减法。比如它不做双 bank 无缝切换——G431 的 Flash 容量和双 bank 支持因型号而异,做 A/B 分区意味着应用区直接砍半,对 96KB 的预算来说太奢侈;它也不做加密签名,因为校验链越长,出错时的排查成本越高。这些取舍背后的判断标准其实只有一条:升级失败时,板子必须还能被救回来。所以 bootloader 本身要尽可能小、尽可能简单,所有复杂逻辑(文件解析、CRC、状态机)都放在「即使跑飞也不影响重新进入 bootloader」的位置。

验证这套机制是否可靠,我一般会强制走一遍「破坏性测试」:传输中途拔电、发错误 CRC 的包、发超大文件、发空文件、连续升级十次。每次测试后检查三件事——bootloader 是否还能正常响应C字符、应用区擦除是否干净、跳转后SCB->VTOR是否指向正确地址。这三项过了,基本可以认为升级链路是通的。至于 Ymodem 的细节参数,比如重传次数上限、超时时间,建议在代码里做成宏,不同上位机兼容性不一样,留个后悔药比事后改代码强。

从那以后我每次做 IAP,都会先把分区宏、向量表重定位、跳转前清理这三件事写成检查清单,跳转函数里每一行都对着清单核一遍,再上板子。这套流程看着笨,但省下来的调试时间远比写清单的时间多。希望帮到你。

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

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

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

立即咨询