STM32 BootLoader串口UART固件升级工程源码实战

发布时间:2026/10/19 8:16:49
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 串口升级到底解决了什么去年帮一个做工业控制的朋友处理一批返修板子故障现象很统一设备装在现场机柜里固件有个浮点数溢出的小 bug但板子已经封在整机里引出的只有一根 RS485 和电源。拆机接 SWD 重新烧录人工成本比板子本身还贵。当时我就想如果这批板子出厂时带一个能通过串口自升级的 BootLoader一个脚本就能把几十台设备刷完根本不用拆机。这就是 STM32 BootLoader 固件串口 UART 升级工程源码要解决的核心问题——让 MCU 在没有仿真器、没有调试口的情况下靠一根串口线完成固件更新。这份工程源码面向的是做 STM32 产品固件开发、需要现场升级能力的嵌入式工程师。它不是一个跑马灯 Demo而是一套完整的 IAPIn-Application Programming实现BootLoader 负责接收上位机发来的固件数据、校验、写入 Flash 指定区域然后跳转到用户 AppApp 则通过一个触发条件比如上电检测某个标志位、或者收到特定命令决定是否回滚到 BootLoader 等待升级。整套逻辑跑在 UART 上协议简单、可移植适合资源受限的 Cortex-M 系列。如果你手上有 STM32 的板子正在纠结“怎么让设备自己升级固件”或者已经写过一版 BootLoader 但跳转就 HardFault那这份源码值得拆开看一遍。下面我按实际复现的顺序把分区规划、协议设计、跳转实现和踩坑点逐个讲清楚。2. Flash 分区与中断向量重映射BootLoader 的地基怎么打2.1 为什么必须先把 Flash 切成两块STM32 的 Flash 是按页Page或扇区Sector擦除的不同型号粒度不一样。F103 系列中容量产品页大小 1KBF4 系列扇区从 16KB 到 128KB 不等。BootLoader 和 App 如果混在同一片区域升级时擦除 App 会连 BootLoader 一起擦掉设备直接变砖。所以第一件事是划边界。常见做法是把 Flash 起始的若干页留给 BootLoader紧接着的区域给 App最后再留一小块存升级标志和固件校验信息。以 STM32F103C8T664KB Flash为例一个稳妥的划分是区域起始地址大小用途BootLoader0x0800000016KB存放 Boot 程序永不被擦App0x0800400044KB用户固件参数区0x0800F0004KB升级标志、固件长度、CRC这个划分不是拍脑袋定的。BootLoader 要包含 UART 驱动、Flash 擦写、CRC 校验和跳转逻辑16KB 对 F103 来说够用但不算宽裕如果加了 YMODEM 协议或者双备份得往上加。App 的起始地址必须和 BootLoader 里定义的APP_ADDR宏一致否则跳过去取向量表就是一片乱码。提示分区地址一旦定下App 工程的链接脚本.ld 或 Keil 的 IROM1 设置必须同步改否则编译出来的固件起始地址还是 0x08000000烧进去必挂。2.2 中断向量表偏移跳转后中断跑飞的根因App 不在 0x08000000 起始中断向量表就不在默认位置。Cortex-M 内核响应中断时从 VTORVector Table Offset Register指向的地址取向量。如果 App 里不重映射 VTOR发生中断时内核还是去 0x08000000 取取到的是 BootLoader 的向量表跳转后要么进错中断服务函数要么直接 HardFault。在 App 的main()最开始SystemInit 之后、任何外设初始化之前加这一句// App 工程 main.c 开头必须在使能任何中断之前执行 #define APP_BASE_ADDR 0x08004000U #define VECT_TAB_OFFSET (APP_BASE_ADDR - 0x08000000U) int main(void) { // 重映射中断向量表到 App 起始地址 SCB-VTOR APP_BASE_ADDR; // 或者用库函数NVIC_SetVectorTable(NVIC_VectTab_FLASH, VECT_TAB_OFFSET); HAL_Init(); SystemClock_Config(); // ... 后续外设初始化 }SCB-VTOR的低位有对齐要求偏移必须是 0x200 的整数倍Cortex-M3/M40x4000 满足条件。有些开发者用NVIC_SetVectorTable库函数效果一样但要注意标准库和 HAL 库的参数含义不同别混用。BootLoader 跳转前还有一步容易漏关闭所有已使能的中断关闭 SysTick。BootLoader 里如果开了串口接收中断、定时器中断跳转前不关App 跑起来后这些中断还挂着 BootLoader 的服务函数地址一触发就跳回 BootLoader 的代码区行为完全不可预期。跳转函数里加__disable_irq()跳转后再由 App 自己__enable_irq()。2.3 跳转函数的实现与栈指针设置跳转的本质是把 App 起始地址的前两个字分别当作初始 MSP 和复位向量。第一个字是栈顶地址第二个字是 Reset_Handler 入口。代码不长但每一句都有讲究// BootLoader 中执行跳转的函数 typedef void (*pFunction)(void); void JumpToApp(uint32_t appAddr) { uint32_t stackTop *(volatile uint32_t *)appAddr; uint32_t resetVec *(volatile uint32_t *)(appAddr 4); pFunction jump (pFunction)resetVec; // 检查栈顶地址是否在合法 RAM 范围0x20000000 起 if ((stackTop 0x2FFE0000) ! 0x20000000) { // 地址非法说明 App 区没有有效固件留在 BootLoader return; } __disable_irq(); // 关全局中断 SysTick-CTRL 0; // 关 SysTick SysTick-LOAD 0; SysTick-VAL 0; for (int i 0; i 8; i) { // 关外设中断按需 NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } __set_MSP(stackTop); // 设置主栈指针 SCB-VTOR appAddr; // 重映射向量表 __enable_irq(); // 开中断交给 App jump(); // 跳转不再返回 }stackTop 0x2FFE0000这个判断是检查栈顶是否落在 SRAM 区域0x20000000~0x2001FFFF 附近如果 App 区是空的全 0xFF栈顶读出来是 0xFFFFFFFF判断不通过就留在 BootLoader避免跳到空区域跑飞。这个检查是血泪经验早期版本没加空片跳转直接锁死只能上仿真器擦片。__set_MSP必须在SCB-VTOR之前还是之后顺序上先设 MSP 再设 VTOR 更稳因为设 MSP 本身不依赖向量表而 VTOR 设完后如果立刻来中断取向量已经指向 App 了。但此时 MSP 如果还是 BootLoader 的栈中断压栈会破坏 BootLoader 的栈空间。所以先 MSP 后 VTOR最后开中断这个顺序别调。3. UART 升级协议设计从帧格式到 Flash 写入的完整链路3.1 帧格式定义与上位机握手串口升级的可靠性八成取决于协议设计。最简陋的做法是直接发原始 bin 数据BootLoader 收到就写但这样没法处理丢包、没法校验、没法知道什么时候结束。常见做法是定义一个带帧头、长度、命令、数据和校验的帧结构。这份源码里用的是一种轻量协议帧格式如下字段长度说明帧头2B0xAA 0x55命令1B0x01 握手 / 0x02 数据 / 0x03 结束 / 0x04 跳转长度2B数据段长度小端数据N B固件数据或参数CRC162B从命令到数据的校验握手阶段上位机先发0xAA 0x55 0x01 0x00 0x00 CRCBootLoader 收到后回复当前状态比如返回芯片 ID 和 BootLoader 版本。握手成功后进入数据阶段每包数据带一个包序号BootLoader 每收一包回一个 ACK超时未收到 ACK 则上位机重发。这个机制比“一发到底”可靠得多尤其在工业现场串口线有干扰的情况下。// BootLoader 中解析一帧数据的核心逻辑简化版 typedef struct { uint8_t header[2]; uint8_t cmd; uint16_t len; uint8_t data[256]; uint16_t crc; } UART_Frame; int ParseFrame(uint8_t *buf, uint32_t bufLen, UART_Frame *frame) { if (bufLen 7) return -1; // 最短帧长度 if (buf[0] ! 0xAA || buf[1] ! 0x55) return -2; // 帧头不对 frame-cmd buf[2]; frame-len buf[3] | (buf[4] 8); // 小端拼接 if (frame-len sizeof(frame-data)) return -3; // 长度越界 if (bufLen 7 frame-len) return -4; // 数据不完整 memcpy(frame-data, buf[5], frame-len); frame-crc buf[5 frame-len] | (buf[6 frame-len] 8); uint16_t calc CRC16_Calc(buf[2], 3 frame-len); if (calc ! frame-crc) return -5; // 校验失败 return 0; }frame-len用两个字节小端拼接支持最大 65535 字节但实际单包不会超过 256 字节因为 BootLoader 的接收缓冲区有限而且串口波特率下包太大容易丢。CRC16 覆盖从命令到数据的全部字节帧头不参与校验因为帧头是固定值校验它没意义。3.2 Flash 擦除与写入的时序约束STM32 的 Flash 写入不是想写就写。写入前必须擦除擦除的最小单位是页或扇区。F103 页大小 1KB擦一页大概 20~40ms取决于型号和电压这期间 CPU 取指会停顿。如果 BootLoader 在擦写时还在响应串口中断中断服务函数如果放在 Flash 里取指就会冲突导致中断响应延迟甚至丢失。常见做法是擦写期间关中断或者把擦写函数和中断服务函数都放到 RAM 里执行。这份源码用的是关中断方案简单可靠。每收到一包数据先判断目标地址是否跨页如果跨页就先擦下一页再写。// Flash 写入一包数据的流程 int WriteToFlash(uint32_t addr, uint8_t *data, uint16_t len) { HAL_FLASH_Unlock(); __disable_irq(); // 擦写期间关中断 for (uint16_t i 0; i len; i 2) { uint16_t halfWord data[i] | (data[i1] 8); // 检查是否需要擦除地址是页起始或首次写入 if ((addr i) % FLASH_PAGE_SIZE 0) { FLASH_EraseInitTypeDef erase; erase.TypeErase FLASH_TYPEERASE_PAGES; erase.PageAddress addr i; erase.NbPages 1; uint32_t pageError 0; HAL_FLASHEx_Erase(erase, pageError); } HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr i, halfWord); } __enable_irq(); HAL_FLASH_Lock(); return 0; }FLASH_PAGE_SIZE根据芯片型号定义F103 中容量是 0x4001KB大容量是 0x8002KB。HAL_FLASH_Program的第二个参数是半字16 位所以循环步长是 2。如果数据长度是奇数最后一个字节要补 0xFF 凑成半字否则写入会出错。注意擦除前一定要确认目标地址在 App 区范围内别把 BootLoader 自己擦了。源码里在WriteToFlash入口加了一个地址范围检查addr APP_ADDR addr APP_ADDR APP_SIZE不满足直接返回错误。3.3 升级标志与固件校验让设备知道“我该不该升级”BootLoader 上电后怎么判断是正常启动 App 还是等待升级靠一个存在参数区的标志位。上位机在发送固件之前先发一条命令把标志位置为“升级中”BootLoader 收到后擦除 App 区、开始接收数据。数据接收完毕、CRC 校验通过后把标志位改为“固件有效”然后跳转 App。如果升级中途断电标志位还是“升级中”下次上电 BootLoader 就知道上次没升完继续等待上位机重发。固件校验用 CRC32 对整个 App 区做一次计算和上位机发来的 CRC32 比对。CRC16 用于单包校验CRC32 用于整包校验两者配合能覆盖绝大多数传输错误。源码里 CRC32 用的是查表法表放在 BootLoader 的常量区不占 RAM。// 升级结束时的整包校验 uint32_t VerifyApp(uint32_t appAddr, uint32_t fwLen, uint32_t expectedCrc) { uint32_t crc 0xFFFFFFFF; for (uint32_t i 0; i fwLen; i) { crc CRC32_Table[(crc ^ *(uint8_t *)(appAddr i)) 0xFF] ^ (crc 8); } crc ^ 0xFFFFFFFF; return (crc expectedCrc) ? 0 : -1; }fwLen是上位机在握手阶段告诉 BootLoader 的固件总长度存在参数区。校验通过后写“固件有效”标志校验失败则保持“升级中”等上位机重发。这个设计的好处是即使升级过程中断电、拔线、干扰导致数据错乱设备也不会变砖最坏情况就是停在 BootLoader 等下一次升级。4. 避坑与排查跳转失败、擦写超时、串口丢包的现场记录4.1 跳转后 HardFault现象、原因、解决现象BootLoader 打印“跳转中”然后设备无响应调试器连上去发现停在 HardFault_Handler。原因九成是 App 的 VTOR 没设对或者 App 编译时起始地址还是 0x08000000。中断发生时内核去 0x08000000 取向量取到的是 BootLoader 的向量表但 BootLoader 的栈已经切走了压栈地址非法触发 HardFault。解决检查 App 工程的链接脚本或 Keil 的 IROM1 起始地址是否改成 0x08004000检查SCB-VTOR是否在main()最开始就设置。用调试器读SCB-VTOR的值确认。另外确认 App 的SystemInit里没有把 VTOR 又改回 0x08000000。4.2 擦除超时导致串口丢包现象、原因、解决现象升级到一半上位机报“等待 ACK 超时”重发几次后 BootLoader 无响应。原因Flash 擦除一页 20~40ms这期间如果关中断串口接收中断被挂起上位机发来的下一包数据可能因为 BootLoader 的 UART 硬件缓冲区溢出而丢失。如果没关中断擦除时 CPU 取指停顿中断响应延迟同样可能丢包。解决上位机在发送数据包之间加延时等 BootLoader 回 ACK 后再发下一包不要连续猛发。BootLoader 端在擦除前先回一个“忙”状态擦完再回 ACK。如果芯片支持把擦写函数放到 RAM 执行中断向量也放 RAM这样擦写期间中断能正常响应。4.3 串口波特率过高导致误码现象、原因、解决现象低速波特率9600、19200升级正常调到 115200 后偶发 CRC 校验失败。原因串口线太长、没有屏蔽、或者 BootLoader 的 UART 初始化时钟配置有偏差。STM32 的 UART 波特率寄存器有小数部分如果时钟源精度不够比如用 HSI 而不是 HSE高波特率下误差累积采样点偏移导致误码。解决用 HSE 作为 UART 时钟源计算波特率时把误差控制在 2% 以内。串口线用双绞屏蔽线长度控制在 1 米以内。如果现场环境恶劣降到 57600 或 38400牺牲速度换可靠性。源码里默认用 115200但在uart_init里留了波特率宏改一个数字就能降速。4.4 App 区首地址不是页对齐现象、原因、解决现象跳转后 App 能跑但一写 Flash 就出错或者擦除时把 BootLoader 擦了。原因App 起始地址没有按 Flash 页大小对齐。比如 F103 页大小 1KBApp 起始地址设成 0x08004200擦除时按页擦0x08004000 那一页被擦掉而那一页可能存着 BootLoader 的尾部代码。解决App 起始地址必须是页大小的整数倍。F103 中容量用 0x0800400016KB 偏移大容量用 0x08008000 或 0x08010000具体看 BootLoader 占了几页。在 BootLoader 里加一个编译期断言_Static_assert(APP_ADDR % FLASH_PAGE_SIZE 0, App address not page aligned);编译不过比运行时翻车好。4.5 升级标志位被意外擦除现象、原因、解决现象升级完成后设备正常跑 App但下次断电重启又进了 BootLoader。原因参数区和 App 区在同一个 Flash 页里App 升级时擦除了整页把标志位也擦了。标志位被擦后是全 0xFFBootLoader 读出来不是“固件有效”就停在 BootLoader。解决参数区必须单独占一页和 App 区之间留出至少一页的间隔。或者把标志位存在备份寄存器BKP里但 BKP 需要纽扣电池供电掉电会丢。更稳妥的做法是参数区放在 Flash 最后一页App 区不覆盖它。源码里参数区地址是0x0800F000App 区最大到0x0800EFFF中间没有重叠。5. 进阶技巧用 Python 上位机脚本把升级流程自动化5.1 上位机脚本的核心逻辑BootLoader 写好了每次升级还手动开串口助手发文件就太原始了。我一般会配一个 Python 脚本用pyserial库把握手、分包、校验、重传全自动跑完。脚本不长但能把升级时间从“手动操作五分钟”压到“插上线十秒钟”。import serial import struct import time import binascii APP_ADDR 0x08004000 CHUNK_SIZE 128 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 build_frame(cmd, payloadb): frame struct.pack(BHH, cmd, len(payload), 0) # 占位 frame frame[:1] struct.pack(H, len(payload)) payload crc crc16(frame) return b\xAA\x55 frame struct.pack(H, crc) def upgrade(port, baud, firmware_path): ser serial.Serial(port, baud, timeout2) with open(firmware_path, rb) as f: fw f.read() # 握手 ser.write(build_frame(0x01)) resp ser.read(7) if len(resp) 7 or resp[2] ! 0x01: print(握手失败) return # 发送固件长度和 CRC32 fw_crc binascii.crc32(fw) 0xFFFFFFFF meta struct.pack(II, len(fw), fw_crc) ser.write(build_frame(0x02, meta)) time.sleep(0.1) # 分包发送 for i in range(0, len(fw), CHUNK_SIZE): chunk fw[i:iCHUNK_SIZE] seq struct.pack(H, i // CHUNK_SIZE) ser.write(build_frame(0x03, seq chunk)) ack ser.read(7) if len(ack) 7 or ack[2] ! 0x03: print(f第 {i//CHUNK_SIZE} 包重传) ser.write(build_frame(0x03, seq chunk)) ser.read(7) # 结束并跳转 ser.write(build_frame(0x04)) print(升级完成) ser.close() if __name__ __main__: upgrade(COM3, 115200, app.bin)build_frame里先拼命令和长度再算 CRC16最后加上帧头。upgrade函数按握手、元数据、分包、结束四步走每包等 ACK超时就重传一次。CHUNK_SIZE设 128 是折中值太小则包数多、握手开销大太大则单包传输时间长、出错重传成本高。5.2 用 bin 文件而不是 hex地址信息由脚本控制Keil 或 CubeIDE 编译出来默认是.hex或.elf但 BootLoader 升级用的是纯二进制.bin。.hex里带地址信息BootLoader 不需要解析直接用fromelf --bin或arm-none-eabi-objcopy -O binary转成.bin。转换时注意起始地址objcopy默认从 0x08000000 开始截取如果 App 起始地址是 0x08004000要加--pad-to或者用fromelf --bin --outputapp.bin --base0x08004000指定基地址否则 bin 文件头部会多出一段填充。我一般会在编译后加一个 post-build 步骤自动调fromelf生成 bin然后用 Python 脚本算 CRC32 并打印出来和 BootLoader 端校验值比对。这一步能提前发现“编译出来的固件和实际烧进去的不一致”这种玄学问题。5.3 验证升级是否成功的三个检查点升级跑完不代表成功我习惯做三个检查。第一读参数区的标志位确认是“固件有效”而不是“升级中”。第二用调试器读 App 起始地址的前两个字确认栈顶在 RAM 范围、复位向量在 App 区范围内。第三让 App 跑起来后翻转一个 GPIO 或者打印一条串口信息确认中断向量表重映射生效、外设正常工作。这三个检查点走完基本能排除 95% 的升级后异常。从那以后我每次改完 BootLoader 或者 App 的链接地址都强制走一遍“编译→转 bin→算 CRC→升级→读标志→读向量→跑 App”的完整流程不再凭感觉判断。希望帮到你。本文还有配套的精品资源点击获取