STM32 BootLoader串口UART升级:量产固件迭代的保底方案 简介这份STM32 BootLoader固件串口UART升级工程源码面向嵌入式开发工程师与STM32学习者用于解决设备运行程序的自动升级与更新问题。工程基于STM32单片机通过串口接收上位机下发的bootloader固件在芯片内完成UART数据接收与命令解析再将固件写入FLASH随后复位系统并重设新APP向量地址实现固件更新。内容涵盖HAL库使用、UART初始化与收发解析、FLASH读写、CRC校验以及Bootloader固件加载与更新等关键环节适合具备一定嵌入式基础、希望掌握IAP升级流程的开发者参考。资源包共109个文件以72个h头文件与30个c源文件为主体另含makefile、ld链接脚本、ioc配置、s汇编及jflash烧录配置等压缩包约833KB工程结构完整。目前已有1952人学习下载可帮助读者理解串口升级的完整链路与校验、跳转等排错思路。1. STM32 BootLoader 串口 UART 升级为什么它是量产固件迭代的保底方案做嵌入式量产维护的同行大概都经历过这种场面设备已经装到现场机柜里突然发现应用固件有个逻辑漏洞返厂拆机烧录的成本高得离谱。这时候如果板子上跑着一个能通过串口 UART 接收新固件并写入 Flash 的 BootLoader一根 USB 转 TTL 线就能把问题解决掉。STM32 BootLoader 固件串口 UART 升级工程源码说的就是这套东西——芯片上电后先跑一段不参与业务的小程序它判断是否需要升级需要就从串口收数据、擦写 Flash、跳转到应用区。这套方案适合三类人一是做工业控制、仪器仪表这类设备分散、拆机困难的团队二是产品还在小批量试产、需要频繁改固件验证的开发者三是想搞懂 STM32 启动流程和 Flash 分区但不想一上来就啃复杂协议栈的学习者。它不依赖网络、不依赖操作系统只要一根串口线可靠性在电磁环境复杂的现场反而比无线方案更稳。下面从分区设计讲到协议实现再到实际调试中那些让人抓狂的坑一步步把可复现的工程搭出来。2. 先把 Flash 分区和跳转逻辑定死BootLoader 的地基2.1 为什么分区表必须在写第一行代码前确定很多新手拿到源码直接编译烧录跑不通就改跳转地址改来改去把自己绕晕。根子在于没有先定分区。STM32 的 Flash 是按页Page或扇区Sector擦除的不同型号粒度不同F1 系列通常是 1KB 或 2KB 一页F4 系列是 16KB 到 128KB 一个扇区。BootLoader 自己占用的区域必须和应用程序区严格分开否则升级时擦除操作会把正在运行的 BootLoader 代码也抹掉直接变砖。我一般按这个原则划BootLoader 占最前面一段大小取 Flash 擦除粒度的整数倍并且留出至少 20% 余量给后续加功能。应用程序区从 BootLoader 结束地址开始末尾再留一小块存升级标志和固件校验信息。以某款 128KB Flash、2KB 页大小的 STM32 为例分区可以这样定区域起始地址大小用途BootLoader0x0800000016KB启动判断、串口收发、Flash 编程升级标志区0x080040002KB存升级请求标志、固件长度、CRC应用程序区0x08004800108KB用户业务固件参数备份区0x0801F8002KB可选存设备参数这个表要写进链接脚本和 BootLoader 代码里两边地址必须一致。应用程序的链接脚本里 ROM 起始地址要改成 0x08004800否则编译出来的中断向量表偏移不对跳转过去直接 HardFault。2.2 跳转到应用程序的三个必要条件跳转不是一句函数指针调用就完事。Cortex-M 内核在跳转前要求第一关闭所有已使能的中断尤其是 SysTick 和串口中断否则应用区还没初始化中断向量一个挂起的中断就会打到 BootLoader 的向量表上第二设置主栈指针 MSP 为应用区向量表的第一个字第三设置向量表偏移寄存器 SCB-VTOR 为应用区起始地址。这三步缺一个都可能出现“跳过去跑几秒就死”的玄学现象。/* 跳转到应用程序区app_addr 为应用区起始地址 */ typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t jump_addr; pFunction app_entry; /* 检查应用区栈顶是否合法必须在 SRAM 范围内 */ if (((*(uint32_t *)app_addr) 0x2FFE0000) ! 0x20000000) { return; /* 栈顶不合法说明应用区没有有效固件 */ } /* 关闭全局中断防止跳转过程中被中断打断 */ __disable_irq(); /* 关闭 SysTick否则它的中断会持续触发 */ SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; /* 清除所有挂起的中断标志 */ for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } /* 设置主栈指针为应用区向量表首字 */ __set_MSP(*(uint32_t *)app_addr); /* 取应用区复位向量地址向量表第二字 */ jump_addr *(uint32_t *)(app_addr 4); app_entry (pFunction)jump_addr; /* 设置向量表偏移让应用区中断能正确响应 */ SCB-VTOR app_addr; /* 开全局中断并跳转 */ __enable_irq(); app_entry(); }这段代码里app_addr就是 0x08004800。栈顶检查那一步很关键如果应用区是空的全 0xFF栈顶值就是 0xFFFFFFFF直接跳过去必死。加上这个判断BootLoader 在没收到固件时就能安心停在等待状态。SCB-VTOR的设置容易被忽略不设的话应用区里所有中断都会跑到 BootLoader 的向量表去现象就是串口收发正常但定时器中断进不去。2.3 升级标志的存放与掉电保护升级标志不能只放 RAM 里因为设备可能升级到一半断电重启后 BootLoader 必须知道上次升级没完成需要重新进入升级模式。常见做法是在 Flash 里划一页专门存标志写入前先擦除写入后校验。但 Flash 擦写次数有限一般 1 万次左右如果每次上电都写标志寿命很快耗尽。我的做法是只在收到升级命令时写一次标志升级成功后擦除标志上电时读标志只有标志为特定值才进升级模式否则直接跳应用区。标志区还要考虑写入原子性。如果写到一半断电标志可能处于半写状态。解决办法是存两个互补的值比如地址 A 存 0x5A5A5A5A地址 B 存 0xA5A5A5A5只有两个都匹配才认为标志有效。这样即使写入过程中断电两个值不会同时正确BootLoader 就当作无升级请求处理保证设备还能正常启动。3. 串口 UART 升级协议怎么设计才不丢包3.1 帧格式与握手流程串口升级最怕的是数据丢包导致固件写坏。没有校验的裸传数据在长线缆或电机干扰环境下几乎必然出错。我一般用一套简单的帧协议帧头2 字节 命令1 字节 长度2 字节 数据N 字节 CRC162 字节。帧头用 0xAA 0x55命令区分握手、擦除、写数据、校验、跳转等操作。接收端用状态机逐字节解析遇到帧头才开始收长度字段限制单帧最大 1024 字节防止缓冲区溢出。握手流程是这样的上位机先发握手命令BootLoader 回复芯片型号和 Flash 容量上位机确认后发擦除命令BootLoader 擦除应用区并回复就绪然后上位机分包发送固件数据每包 BootLoader 写入后回复 ACK全部发完后上位机发校验命令BootLoader 计算整包 CRC 并比对校验通过则写升级成功标志并跳转失败则回复错误码等待重传。/* 帧解析状态机逐字节喂入 */ typedef enum { STATE_HEAD1, STATE_HEAD2, STATE_CMD, STATE_LEN_H, STATE_LEN_L, STATE_DATA, STATE_CRC_H, STATE_CRC_L } parse_state_t; static parse_state_t state STATE_HEAD1; static uint8_t rx_buf[1024]; static uint16_t rx_len 0; static uint16_t rx_index 0; static uint8_t rx_cmd 0; void uart_byte_input(uint8_t byte) { switch (state) { case STATE_HEAD1: if (byte 0xAA) state STATE_HEAD2; break; case STATE_HEAD2: state (byte 0x55) ? STATE_CMD : STATE_HEAD1; break; case STATE_CMD: rx_cmd byte; state STATE_LEN_H; break; case STATE_LEN_H: rx_len (uint16_t)byte 8; state STATE_LEN_L; break; case STATE_LEN_L: rx_len | byte; if (rx_len sizeof(rx_buf)) { /* 长度超限丢弃重来 */ state STATE_HEAD1; } else { rx_index 0; state (rx_len 0) ? STATE_DATA : STATE_CRC_H; } break; case STATE_DATA: rx_buf[rx_index] byte; if (rx_index rx_len) state STATE_CRC_H; break; case STATE_CRC_H: /* 高字节暂存实际项目用变量保存 */ state STATE_CRC_L; break; case STATE_CRC_L: /* 两字节 CRC 收齐后校验校验通过则处理命令 */ protocol_handle_frame(rx_cmd, rx_buf, rx_len); state STATE_HEAD1; break; default: state STATE_HEAD1; break; } }状态机的好处是不会因为某一帧错误就卡死收到非法字节自动回到帧头等待。rx_len超过缓冲区大小时直接丢弃避免溢出。CRC 校验放在最后只有校验通过才交给命令处理函数这样上层逻辑不用关心数据完整性。3.2 分包大小与超时重传参数分包大小直接影响升级速度和可靠性。包太小握手和 ACK 开销占比高升级慢包太大一包出错重传代价高而且 BootLoader 的 RAM 缓冲区要够大。我实测下来1024 字节一包在 115200 波特率下比较均衡一包传输约 90ms加上 ACK 和上位机处理整体升级 100KB 固件大约 15 到 20 秒。如果波特率提到 460800可以缩短到 5 秒左右但前提是线缆质量好、干扰小。超时重传参数要配合上位机设置。BootLoader 收到一包后必须在规定时间内回复 ACK否则上位机认为丢包并重传。这个超时我一般设 500ms因为 Flash 写入一页 2KB 数据在 STM32 上大约 2 到 5ms加上串口传输时间500ms 足够宽裕。重传次数设 3 次连续 3 次失败就终止升级并报错避免上位机无限等待。注意Flash 写入前必须先擦除且擦除操作是按页进行的。如果一包数据跨越了两个页边界需要先擦除下一页再写否则写入会失败。上位机分包时最好按页大小对齐比如 2KB 页就每包 2KB这样 BootLoader 处理逻辑最简单。3.3 写入地址校验与防误擦BootLoader 收到写数据命令时必须校验目标地址是否落在应用程序区内。如果上位机因为 bug 发了一个指向 BootLoader 区的地址而 BootLoader 不加判断直接擦写设备当场变砖。校验逻辑很简单地址大于等于应用区起始地址且地址加数据长度小于等于应用区结束地址。不满足就回复错误码拒绝写入。/* 写数据命令处理addr 为目标地址data 为数据指针len 为长度 */ uint8_t handle_write_data(uint32_t addr, uint8_t *data, uint16_t len) { /* 地址范围校验必须在应用区内 */ if (addr APP_START_ADDR || (addr len) APP_END_ADDR) { return ERR_ADDR_OUT_OF_RANGE; } /* 解锁 Flash */ HAL_FLASH_Unlock(); /* 按页擦除如果 addr 是页起始地址先擦除该页 */ if ((addr % FLASH_PAGE_SIZE) 0) { FLASH_EraseInitTypeDef erase; uint32_t page_error 0; erase.TypeErase FLASH_TYPEERASE_PAGES; erase.PageAddress addr; erase.NbPages 1; if (HAL_FLASHEx_Erase(erase, page_error) ! HAL_OK) { HAL_FLASH_Lock(); return ERR_ERASE_FAIL; } } /* 逐字写入STM32 要求按字32位或半字编程 */ for (uint16_t i 0; i len; i 4) { uint32_t word *(uint32_t *)(data i); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr i, word) ! HAL_OK) { HAL_FLASH_Lock(); return ERR_WRITE_FAIL; } } HAL_FLASH_Lock(); return ERR_NONE; }这段代码里APP_START_ADDR和APP_END_ADDR就是分区表里的应用程序区范围。擦除判断用addr % FLASH_PAGE_SIZE 0意味着上位机每包都从页边界开始发这样每包只擦一页逻辑清晰。写入用字编程因为 STM32 的 Flash 编程粒度通常是 32 位按字节写会失败。写完必须 Lock防止误操作。4. 避坑与排查那些让 BootLoader 翻车的细节4.1 跳转后应用区中断不响应现象BootLoader 跳转到应用区后应用区的主循环能跑串口能收发但定时器中断、DMA 中断死活不进。原因通常是SCB-VTOR没有设置或者设置的值不是应用区起始地址。Cortex-M 默认从 0x00000000 取向量表STM32 映射到 0x08000000也就是 BootLoader 的向量表。应用区的中断向量在 0x08004800不告诉内核它就一直用 BootLoader 的。解决就是在跳转前加SCB-VTOR app_addr;并且确保应用区链接脚本里向量表偏移也设对了。4.2 升级到一半断电后设备无法启动现象升级过程中拔掉电源重新上电后设备没有任何反应串口也不回复。原因是升级标志已经写入但应用区固件不完整BootLoader 上电后进入升级模式等待数据而上位机没有重新发起升级设备就卡在等待状态。解决方法是给升级模式加超时进入升级模式后启动一个定时器比如 10 秒内没有收到任何有效帧就清除升级标志并跳转到应用区。即使应用区固件不完整至少设备还能跑起来用户有机会重新升级。4.3 串口接收丢字节导致 CRC 校验失败现象上位机发送固件BootLoader 偶尔回复 CRC 错误重传几次后成功但升级时间明显变长。原因通常是串口接收中断优先级太低被其他中断打断后没有及时取走数据硬件溢出。解决方法是把串口中断优先级设到最高或者在中断里只做数据入队主循环里再解析。如果用的是 HAL 库的HAL_UART_Receive_IT注意每次接收完要重新调用否则只收一次就停了。更稳的做法是用 DMA 加空闲中断DMA 自动搬数据空闲中断触发时一次性处理一帧。4.4 Flash 擦除时间过长导致看门狗复位现象BootLoader 在擦除应用区时如果开了独立看门狗且没有及时喂狗擦除大页比如 F4 的 128KB 扇区耗时可能超过看门狗超时导致复位。解决方法是擦除前先关闭看门狗或者把看门狗超时设长一些再或者在擦除循环里插入喂狗操作。但注意擦除过程中 CPU 会阻塞在 Flash 操作上喂狗代码可能执行不到所以最稳妥的还是擦除前关狗擦除完再开。4.5 应用程序编译出的 bin 文件包含多余数据现象升级完成后应用区能跑但 Flash 里多写了很多 0xFF 或者随机数据浪费空间。原因是 Keil 或 IAR 默认生成的 bin 文件可能包含未使用的 Flash 区域填充。解决是在 IDE 的链接选项里勾选“仅生成已使用区域”或者用fromelf工具手动裁剪。另外bin 文件大小要跟 BootLoader 协议里的固件长度字段一致上位机发送时按实际大小发不要发整个 Flash 大小的数据。5. 进阶技巧让升级更稳、更快、更省心5.1 双区备份与回滚单区升级最大的风险是升级失败后设备变砖。如果 Flash 空间够可以划两个应用区A 区和 B 区。当前运行 A 区升级时写 B 区写完后把启动标志指向 B 区并复位。如果 B 区启动失败比如看门狗复位次数超限BootLoader 自动切回 A 区。这样即使新固件有问题设备也能回滚到旧版本。代价是 Flash 要够大而且 BootLoader 逻辑复杂一些但量产设备上这点成本值得花。5.2 用 CRC32 替代 CRC16 做整包校验CRC16 能发现大部分传输错误但对抗随机错误的能力有限。如果固件包很大比如几百 KB建议用 CRC32。STM32 的硬件 CRC 外设支持 CRC32计算速度快不占 CPU。上位机也计算 CRC32两边比对。注意硬件 CRC 的初始值和多项式要跟上位机一致否则算出来不一样。我一般用标准 CRC32 多项式 0x04C11DB7初始值 0xFFFFFFFF结果取反。5.3 升级速度优化提高波特率与减少 ACK 等待115200 波特率下升级 100KB 大约 15 秒如果产线批量升级这个时间累积起来很可观。把波特率提到 460800 或 921600升级时间能缩短到 3 到 5 秒。前提是 BootLoader 的串口时钟配置正确上位机 USB 转串口芯片支持高波特率。另外可以把“每包 ACK”改成“每 N 包 ACK”减少握手开销。但这样丢包重传的粒度变大一包出错要重传 N 包需要权衡。我的经验是每 4 包 ACK 一次兼顾速度和重传代价。5.4 用脚本自动化升级流程手动点上位机工具升级容易出错尤其是产线上几十台设备。我一般写一个 Python 脚本调用串口库自动完成握手、擦除、发送、校验、跳转全过程。脚本里加日志和重试失败自动重来。这样产线工人只需要插上线、按一下回车剩下的交给脚本。import serial import time import struct def crc16(data): crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 return crc def send_frame(ser, cmd, payloadb): frame b\xAA\x55 bytes([cmd]) frame struct.pack(H, len(payload)) payload frame struct.pack(H, crc16(frame[2:])) ser.write(frame) # 等待 ACK超时 500ms ser.timeout 0.5 resp ser.read(6) if len(resp) 6 or resp[0] ! 0xAA or resp[1] ! 0x55: return None return resp[2] # 返回命令码作为状态 # 打开串口波特率 460800 ser serial.Serial(COM3, 460800, timeout1) # 握手 if send_frame(ser, 0x01) 0x81: print(握手成功) # 擦除 if send_frame(ser, 0x02) 0x82: print(擦除完成) # 发送固件每包 1024 字节 with open(app.bin, rb) as f: addr 0x08004800 while True: chunk f.read(1024) if not chunk: break payload struct.pack(I, addr) chunk if send_frame(ser, 0x03, payload) ! 0x83: print(写入失败地址, hex(addr)) break addr len(chunk) # 校验并跳转 if send_frame(ser, 0x04) 0x84: print(升级成功) ser.close()脚本里crc16函数跟 BootLoader 里的 CRC 算法必须一致否则校验永远不过。send_frame发送后等 500ms超时返回 None上层可以重试。实际使用时把COM3换成实际串口号app.bin换成编译出的固件文件。这个脚本我用了好几年产线升级从没出过批量事故。5.5 一个让我长记性的教训早期做这套方案时我图省事把升级标志和应用程序放在同一个 Flash 页里。结果升级时擦除应用区把标志也擦掉了设备重启后 BootLoader 读不到标志直接跳转到空的应用区当场死机。后来把标志单独放一页并且加了双备份校验再也没出过这个问题。Flash 分区这件事宁可多留一页也不要让不同用途的数据共享擦除单元。希望帮到你。本文还有配套的精品资源点击获取