简介:这份STM32 BootLoader固件串口UART升级工程源码,面向嵌入式开发工程师与STM32学习者,解决设备端通过串口接收上位机固件并自动完成程序升级与更新的问题。工程基于STM32单片机,完整实现UART数据收发与命令解析、FLASH读写、CRC校验以及Bootloader固件加载与跳转,复位后重新设置新APP向量地址,形成闭环升级流程。压缩包共109个文件,以72个h头文件与30个c源文件为主体,另含ioc配置、ld链接脚本、makefile、s启动文件及jflash烧录配置等,整体约833KB,工程结构完整、便于直接编译与二次移植。内容覆盖HAL库使用、UART初始化、FLASH操作与CRC校验等关键环节,适合作为IAP升级方案的学习模板与项目参考。目前已有1952人学习下载,可帮助读者快速理解串口升级的完整链路与排错思路。
1. 串口升级这件事,比想象中更值得自己搭一遍
去年帮一个做工业控制的朋友救急,现场设备装在配电柜里,固件有个小 bug 要修,结果整机拆下来接仿真器重烧,来回折腾了一整天。那次之后我就下定决心,把 STM32 的 BootLoader 串口升级这套东西彻底吃透。这份工程源码干的就是这件事:让 MCU 通过 UART 接收新固件,自己擦写 Flash、跳转执行,不用拆机、不用仿真器,一根串口线就能完成升级。它适合正在做量产设备、需要远程维护固件的嵌入式开发者,也适合想搞懂 BootLoader 跳转机制和 Flash 分区管理的同学。下面我按自己拆代码的顺序,把这份工程从架构到跑通再到改造,完整讲一遍。
2. 工程架构拆解:BootLoader 和 App 是怎么分工的
2.1 整体分区思路
这套工程的核心思路是把 STM32 的内部 Flash 切成两块:一块给 BootLoader,一块给用户 App。BootLoader 永远不动,负责上电判断、接收新固件、擦写 App 区;App 才是真正跑业务逻辑的代码。上电后先跑 BootLoader,它检查有没有升级请求,有就进升级流程,没有就直接跳到 App 区执行。
常见的分区方案是这样的(以 128KB Flash 的型号为例):
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| BootLoader | 0x08000000 | 16KB | 引导程序,不参与升级 |
| App | 0x08004000 | 100KB | 用户应用固件 |
| 参数区 | 0x0801D000 | 4KB | 存放升级标志、固件长度等 |
| 备份区 | 0x0801E000 | 8KB | 可选,用于双备份升级 |
这个划分不是死的,具体要看芯片型号和 App 体积。我一般会先算清楚 App 编译出来多大,再倒推 BootLoader 留多少空间。留太少以后加功能会后悔,留太多又浪费。
2.2 BootLoader 主流程
BootLoader 的 main 函数逻辑其实很清晰,我把它简化成下面这段:
int main(void) { /* 初始化时钟、串口、Flash 等外设 */ HAL_Init(); SystemClock_Config(); UART_Init(115200); /* 读取参数区,判断是否有升级请求 */ uint32_t upgrade_flag = Read_Upgrade_Flag(); if (upgrade_flag == UPGRADE_REQUEST) { /* 有升级请求,进入接收固件流程 */ if (Receive_Firmware() == SUCCESS) { /* 接收完成,清除标志,跳转到 App */ Clear_Upgrade_Flag(); Jump_To_App(APP_START_ADDR); } else { /* 接收失败,留在 BootLoader 等待重试 */ while (1) { UART_SendString("Upgrade failed, retry...\r\n"); HAL_Delay(1000); } } } else { /* 没有升级请求,直接跳转 App */ Jump_To_App(APP_START_ADDR); } }这里有几个关键点。Read_Upgrade_Flag是从参数区读一个特定值,这个值由 App 在收到升级命令时写入。Receive_Firmware是核心接收函数,后面单独讲。Jump_To_App负责关中断、设栈指针、跳转,是最容易翻车的地方。
2.3 App 端的配合逻辑
App 不是被动等着被升级,它也要主动配合。App 里需要做两件事:一是收到上位机的升级命令时,往参数区写升级标志,然后触发软复位;二是修改链接脚本,把起始地址挪到 App 区。
/* App 中收到升级命令的处理 */ void Handle_Upgrade_Command(void) { /* 往参数区写入升级标志 */ HAL_FLASH_Unlock(); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, UPGRADE_FLAG_ADDR, UPGRADE_REQUEST); HAL_FLASH_Lock(); /* 软复位,让 BootLoader 重新跑 */ NVIC_SystemReset(); }链接脚本的修改在 Keil 或 IAR 里对应的是 IROM1 起始地址,改成0x08004000,大小改成0x19000。这一步如果忘了改,App 编译出来还是从0x08000000开始,跳转过去直接跑飞。
注意:App 的向量表偏移也要改,在
system_stm32f1xx.c里把VECT_TAB_OFFSET设成0x4000,或者在 main 开头调用NVIC_SetVectorTable。这个坑我踩过,现象是中断全部失效,串口收不到数据。
3. 核心模块实战:固件接收、Flash 擦写与跳转
3.1 串口接收固件的协议设计
固件接收不能裸收,得有协议。这份工程用的是一种简单的分包协议:上位机把 bin 文件按固定长度分包,每包带序号和校验,BootLoader 收到后校验、写入、回 ACK。
/* 接收一包固件的函数 */ typedef struct { uint8_t head; /* 帧头 0xAA */ uint16_t seq; /* 包序号 */ uint16_t len; /* 数据长度 */ uint8_t data[256]; /* 数据 */ uint16_t crc; /* CRC16 校验 */ } FirmwarePacket; int Receive_One_Packet(FirmwarePacket *pkt) { uint8_t buf[sizeof(FirmwarePacket)]; /* 等待帧头 */ if (UART_ReceiveBytes(buf, sizeof(FirmwarePacket), TIMEOUT) != SUCCESS) return ERROR; /* 校验帧头 */ if (buf[0] != 0xAA) return ERROR; /* 拷贝并校验 CRC */ memcpy(pkt, buf, sizeof(FirmwarePacket)); if (CRC16(pkt->data, pkt->len) != pkt->crc) return ERROR; return SUCCESS; }seq用来检测丢包,len限制单包最大 256 字节,crc保证数据完整性。实际用的时候,上位机每发一包等一个 ACK,超时就重发。这个协议简单但够用,工业现场跑 115200 波特率很稳。
3.2 Flash 擦写的关键细节
STM32 的 Flash 擦除是按页来的,写入是按半字或字来的。擦之前要解锁,擦完要上锁。下面这段是写入一包数据的核心:
int Write_Firmware_To_Flash(uint32_t addr, uint8_t *data, uint16_t len) { HAL_FLASH_Unlock(); /* 按页擦除,注意 STM32F1 的页大小是 1KB 或 2KB */ 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 ERROR; } /* 按半字写入 */ for (uint16_t i = 0; i < len; i += 2) { uint16_t halfword = data[i] | (data[i + 1] << 8); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr + i, halfword) != HAL_OK) { HAL_FLASH_Lock(); return ERROR; } } HAL_FLASH_Lock(); return SUCCESS; }PageAddress必须是页对齐的,NbPages是擦几页。写入的时候地址要递增,不能跨页写。如果 App 区跨了多页,接收的时候要按页边界分包,或者接收完整后再统一擦写。
提示:擦除操作会阻塞 CPU,大页擦除可能几十毫秒,期间串口如果还在收数据会丢包。常见做法是接收和擦写分开,先收到 RAM 缓冲区,收满一页再擦写。
3.3 跳转到 App 的正确姿势
跳转是整套工程里最玄学的一步,写错了就是 HardFault。正确的跳转函数长这样:
typedef void (*pFunction)(void); void Jump_To_App(uint32_t app_addr) { pFunction jump_to_app; uint32_t stack_ptr; /* 检查 App 区是否有有效栈顶 */ stack_ptr = *(volatile uint32_t *)app_addr; if ((stack_ptr & 0x2FFE0000) != 0x20000000) { /* 栈顶不在 RAM 范围,说明 App 无效 */ return; } /* 关所有中断 */ __disable_irq(); /* 重设向量表 */ SCB->VTOR = app_addr; /* 设栈指针 */ __set_MSP(stack_ptr); /* 取复位向量,跳转 */ jump_to_app = (pFunction)(*(volatile uint32_t *)(app_addr + 4)); jump_to_app(); }app_addr就是 App 区的起始地址,比如0x08004000。stack_ptr是 App 向量表的第一个字,指向 RAM 顶部。app_addr + 4是复位向量。跳转前必须关中断,否则 BootLoader 的中断会干扰 App。SCB->VTOR重设向量表偏移,这一步在 Cortex-M3/M4 上是必须的。
4. 跑通全流程:从编译到串口升级的完整步骤
4.1 编译 BootLoader 和 App
先编译 BootLoader,Keil 里 IROM1 起始地址保持0x08000000,大小设成0x4000。编译出来一个 hex 或 bin,用仿真器烧进去。然后编译 App,IROM1 起始地址改成0x08004000,大小0x19000,同时改VECT_TAB_OFFSET。编译出来的 bin 就是待升级的固件。
# 用 fromelf 或 objcopy 生成 bin fromelf --bin --output app.bin ./Objects/app.axf # 或者用 arm-none-eabi arm-none-eabi-objcopy -O binary app.elf app.bin生成的app.bin就是上位机要发的文件。注意 bin 文件不带地址信息,所以上位机必须知道从0x08004000开始写。
4.2 上位机发送固件
上位机可以用 Python 写,逻辑就是读 bin、分包、发一包等 ACK、超时重发。核心代码:
import serial, struct, time def send_firmware(port, baud, bin_path): ser = serial.Serial(port, baud, timeout=1) with open(bin_path, 'rb') as f: data = f.read() seq = 0 for i in range(0, len(data), 256): chunk = data[i:i+256] # 构造包:帧头 + 序号 + 长度 + 数据 + CRC pkt = struct.pack('<BHH', 0xAA, seq, len(chunk)) + chunk pkt += struct.pack('<H', crc16(chunk)) ser.write(pkt) # 等 ACK ack = ser.read(1) if ack != b'\x06': print(f"包 {seq} 失败,重发") ser.write(pkt) ser.read(1) seq += 1 print("发送完成")crc16要和 BootLoader 端一致,多项式、初值、异或值都得对上。ser.read(1)等 ACK,超时 1 秒。实际用的时候可以加重传次数上限,避免死循环。
4.3 验证升级结果
升级完成后,BootLoader 会跳转到 App。验证方法很简单:App 里点个灯或者周期性发串口数据,看现象对不对。如果没反应,先检查跳转地址、向量表偏移、栈顶值这三个地方。我一般会在 BootLoader 里加一句串口打印,跳转前把stack_ptr和复位向量打出来,方便排查。
5. 避坑与常见问题排查
5.1 跳转后跑飞或 HardFault
现象:BootLoader 跳转后 App 不运行,或者直接进 HardFault。原因通常是向量表没重设、栈指针不对、或者 App 编译地址没改。解决:确认SCB->VTOR设成了 App 起始地址,确认 App 的 IROM1 和VECT_TAB_OFFSET都改了,确认跳转前关了中断。
5.2 串口接收丢包
现象:升级到一半卡住,或者 CRC 校验失败。原因可能是波特率太高、擦写阻塞导致丢包、或者上位机发太快。解决:降低波特率到 115200 或 57600,接收和擦写分开,先收 RAM 再写 Flash,上位机每包等 ACK。
5.3 擦除后 App 区数据不对
现象:升级完成但 App 跑不起来,读 Flash 发现数据错位。原因通常是擦除页没对齐,或者写入地址算错。解决:确认PageAddress是页大小整数倍,写入地址从 App 起始地址开始递增,不要跨页写。
5.4 升级标志误触发
现象:每次上电都进升级模式,不跳 App。原因可能是参数区没初始化,读出来是随机值,恰好等于升级标志。解决:参数区加魔数校验,只有魔数和标志都匹配才进升级,否则清标志跳 App。
5.5 App 中断失效
现象:App 跑起来了但串口、定时器中断不响应。原因就是向量表偏移没设。解决:在 App 的system_stm32f1xx.c里改VECT_TAB_OFFSET,或者在 main 开头调用NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x4000)。
6. 二次开发:把串口升级改成双备份和断点续传
这套工程跑通之后,我建议往两个方向改。第一个是双备份升级:把 App 区分成 App_A 和 App_B,新固件先写到备份区,校验通过后再切换运行区。这样升级失败还能回滚,不会变砖。实现上就是在参数区加一个active_region字段,BootLoader 根据它决定跳哪个区。
第二个是断点续传:升级过程中断电,重新上电后能从上次的包序号继续,不用从头传。做法是每收若干包就把当前seq写到参数区,BootLoader 启动时读出来,告诉上位机从哪个包开始。
/* 断点续传:记录已接收的包序号 */ void Save_Progress(uint16_t seq) { HAL_FLASH_Unlock(); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, PROGRESS_ADDR, seq); HAL_FLASH_Lock(); }PROGRESS_ADDR放在参数区,每次写之前不用擦,因为 STM32 的 Flash 可以多次写 1 变 0,但要注意写之前该地址得是擦除状态。如果频繁写,建议单独开一页做日志式记录。
还有一个实用技巧是加一个超时自动跳转:BootLoader 启动后等 3 秒,如果没收到升级命令就跳 App。这样正常上电不会卡在 BootLoader,需要升级时上位机在这 3 秒内发命令就行。
/* 等待升级命令,超时跳 App */ uint32_t start = HAL_GetTick(); while (HAL_GetTick() - start < 3000) { if (UART_Check_Upgrade_Cmd()) { Receive_Firmware(); break; } } Jump_To_App(APP_START_ADDR);从那以后我每次改 BootLoader,都强制走一遍「擦除-写入-跳转-中断验证」的完整流程,不敢跳步。这套源码把最核心的机制都摆出来了,剩下的就是按自己项目调分区和协议。希望帮到你。
本文还有配套的精品资源,点击获取