STM32F103 IAP Bootloader实战:从Flash分区到串口升级的完整实现

📅 发布时间:2026/9/16 2:50:39
STM32F103 IAP Bootloader实战:从Flash分区到串口升级的完整实现
简介面向基于 STM32F103C8T6/CBT6 的仪表板开发场景这套 C/C 源码包给出完整的 IAP 引导加载程序实现使设备在应用运行时通过 USB 自定义 HID 等通道更新固件便于现场升级与远程维护。资源共 96 个文件以 C 源码和头文件为主58 个 h、31 个 c搭配 MDK 工程文件、启动文件和 README整体约 600KB目录结构清晰可直接打开工程对照学习。内容涉及 Flash 擦写底层函数、通信协议解析、升级流程控制及基本安全校验机制源码基于 ST 官方 HAL 库并结合 CubeMX 生成的 USB 设备栈适合需要为仪表板类产品集成升级功能的嵌入式开发者。已有 394 人学习下载从 bootloader 到 App 的跳转、固件写入、验证等关键环节均有实现是理解 STM32 IAP 原理、快速落地固件更新功能的实用参考。1. 仪表板固件用STM32F103C8T6/CBT6做IAP先解决升级通道再谈功能迭代仪表板产品出厂后最现实的麻烦不是功能写不出来而是壳子封死之后固件怎么改。J-Link只能插在研发台上生产线下线几十台后如果发现界面字库有个错字拆壳重烧的成本远大于重发一版固件。STM32F103C8T6只有64 KB FlashCBT6是128 KB空间本来就不宽裕还要在固件里塞进Bootloader、App和仪表校准参数分区分不好很容易写一次重启就变砖。IAP引导加载程序的价值就是让设备自己具备“从串口或CAN收一段bin文件原地把App换掉”的能力。这篇文章把仪表板IAP落地必须想清的Flash布局、跳转细节、传输协议和写坏兜底串起来讲例程用C语言组织在Keil与IAR环境里都能直接套用。适合正在给现有仪表加升级能力、以及第一次接触Bootloader想少踩坑的人。2. 从“上电先跑谁”说起F103C8T6的Flash分区、启动流程与IAP前提2.1 复位后CPU到底在执行谁的代码STM32F103上电后CPU从0x08000000读取栈顶指针(MSP)再从0x08000004取复位向量然后跳到复位处理函数。常规烧录器把整个App放在0x08000000所以一上电就是应用逻辑在跑IAP方案是把这块起始位置让给Bootloader让“引导”这段逻辑先于“业务”执行。Bootloader本身也是一段普通固件只是它多了接收固件、擦写Flash、校验和跳转的代码。烧录器把Bootloader烧进0x08000000App烧进0x08002000或更靠后的位置。设备每次复位都先进入Bootloader它检查有没有升级请求、Flash里有没有有效App再决定跳转还是擦写。F103C8T6和F103CBT6的差异主要在Flash容量C8T6是64 KB、CBT6是128 KBSRAM都是20 KB。分区代码里不要写死容量用宏区分。F103中容量芯片按1 KB一个Page擦除下面这套分区表是仪表板项目里比较常见的布置。区域C8T6起始地址C8T6大小CBT6起始地址CBT6大小用途Bootloader区0x080000008 KB0x080000008 KB引导、传输、擦写与校验App区0x0800200044 KB0x0800200096 KB仪表界面、业务逻辑参数/校准区0x0800D0004 KB0x0801A0008 KB仪表系数、密码、升级标志备份区0x0800E0008 KB0x0801C00016 KB新固件暂存或上一版备份C8T6的44 KB App区对仪表类程序基本够用如果还不够优先压缩Bootloader到6 KB而不是去动参数区。仪表表底、字库这类大块数据建议做成外部Flash或单独烧录段不要塞进一个bin文件里否则每次升级都要拖着几十KB不变数据重传。2.2 跳转不是函数指针一调就完事从Bootloader跳App很多人第一反应是定义一个函数指针指向0x08002004并调用。这样做偶尔能跑但中断一来就死机原因主要有两个MSP还是Bootloader的栈中断向量表也还指向0x08000000。Cortex-M3复位后从0x08000000取栈顶和复位向量App编译时向量表在0x08002000所以跳转前必须把App区首字当作新MSP写回同时把SCB-VTOR重指向App区的向量表。F103的向量表可以直接放在Flash里VTOR设置成App的Flash起始地址即可。/* bootloader_jump.c */ typedef void (*app_reset_handler_t)(void); void bootloader_jump_to_app(uint32_t app_base_addr) { uint32_t msp_value; app_reset_handler_t app_handler; __disable_irq(); /* 1. 关全局中断 */ SysTick-CTRL 0; /* 2. 停掉SysTick */ msp_value *(volatile uint32_t *)app_base_addr; app_handler (app_reset_handler_t)(*(volatile uint32_t *)(app_base_addr 4)); /* 3. 空Flash读出来是全0xFF不能跳 */ if ((msp_value 0xFFFFFFFF) || (app_handler 0xFFFFFFFF)) { return; /* App区无效留在Bootloader等升级 */ } SCB-VTOR app_base_addr; /* 4. 向量表提前指向App */ __set_MSP(msp_value); /* 5. 换栈 */ app_handler(); /* 6. 跳复位向量 */ while (1) { ; } }这段代码里__disable_irq()是CMSIS核心函数跳转前把所有可屏蔽中断关掉避免跳转过程中外设中断反跳回Bootloader。SysTick也要停否则App初始化时会发现SysTick状态和自己预期不一样。第3步的合法性判断很重要升级写一半断电时App区是0xFF不判断直接跳会进HardFault。VTOR在跳转前设置和App里设置并不冲突两边都做更保险。2.3 Flash擦写期间为什么一定要关中断F103写Flash以16 bit半字为最小单位擦除以Page为单位擦一个Page大约需要20到40毫秒。Flash控制器忙的时候CPU如果从Flash里取指总线会被阻塞住所有中断服务都要排队等。把“关中断”放在擦除前就够吗不够。擦写Flash之前还要解锁Flash控制器写完后要再上锁。标准外设库的典型顺序是这样__disable_irq(); FLASH_Unlock(); FLASH_ErasePage(page_addr); /* 擦除1KB页 */ FLASH_ProgramHalfWord(dst_addr, 0x1234); /* 写一个半字 */ FLASH_Lock(); __enable_irq();FLASH_ErasePage与FLASH_ProgramHalfWord是F1标准库的接口如果工程用HAL库对应的是HAL_FLASHEx_Erase和HAL_FLASH_Program时序逻辑相同。擦写函数执行期间绝不能有串口接收中断插进来否则中断服务函数在Flash里取指Flash接口正忙整个系统就停在等待状态看起来像死机。有人把擦写函数放到SRAM里执行确实能绕开取指阻塞但对F103而言只要升级过程不是长到离谱关中断擦写更简单。另一个常被忽视的边界Bootloader不能允许App的数据地址覆盖到Bootloader自己的Flash区。收到升级帧后第一件事就是检查目标地址低于0x08002000直接回NACK。否则一次越界写就可能把Bootloader擦掉设备只能靠BOOT0引脚进系统Bootloader救砖。3. 手写最小IAP Bootloader跳转、向量表重映射与APP端配合3.1 App端的链接地址要改两处Bootloader工程不用动链接脚本App工程必须把ROM起始地址从0x08000000改到分区表里的App区地址。Keil用户直接在Options for Target - Target页里把IROM1的Start改成0x08002000Size改成0xB000C8T6或0x18000CBT6。IAR用户改.icf文件里的三个符号define symbol __ICFEDIT_intvec_start__ 0x08002000; define symbol __ICFEDIT_region_ROM_start__ 0x08002000; define symbol __ICFEDIT_region_ROM_end__ 0x08019FFF;__ICFEDIT_intvec_start__决定中断向量表放在哪region_ROM_start决定只读代码和常量的起点。只改ROM起点、不改向量表起点编译出来的固件复位向量还在0x08000000跳过去第一行就跑飞。写错链接地址最典型的现象是Bootloader跳转后App完全没反应调试器挂上去看到PC停在0xFFFFFFFE附近。App编译产出的bin文件大小要留意。C8T6的App区只有44 KBIAR和Keil的链接器不会主动报“程序超过分区”而是把数据溢出到参数区甚至备份区。最稳妥的检查方法编译后打开.map文件看最后一条代码地址和ZI段的结束地址确认都在App区范围内。3.2 App启动后的第一行重设向量表从Bootloader跳过来时SCB-VTOR已经被Bootloader写成0x08002000但App的系统初始化函数可能把它重新冲掉。Keil的system_stm32f10x.c里SystemInit执行到最后会根据VECT_TAB_OFFSET宏设置VTOR而这个宏默认是0也就是0x08000000。所以App的main函数里不要只写一次VTOR要在SystemInit之后再设置/* app_main.c */ extern void SystemInit(void); int main(void) { SystemInit(); /* 可能把VTOR恢复到0x08000000 */ SCB-VTOR APP_FLASH_BASE; /* 再指回App区APP_FLASH_BASE0x08002000 */ HAL_Init(); SystemClock_Config(); ... }如果App使用HAL库HAL_Init里会初始化SysTick和中断分组这要求向量表已经正确。所以SCB-VTOR这行必须放在任何外设中断使能之前。有人习惯直接在launch文件或startup文件里改VECT_TAB_OFFSET宏这个宏本质上是给SystemInit用的改它也可以但不如main开头一行直观排查问题更方便。3.3 别把BOOT引脚启动和IAP混为一谈很多资料说STM32的BOOT0拉高进系统BootloaderBOOT1再选择系统存储器然后通过串口ISP烧录。这是出厂ROM里的一段Bootloader能烧整片Flash但有几个限制它不认我们的分区表会把App烧到0x08000000它不能被业务代码调用它需要外接工具配合不能由仪表自己触发。IAP里的Bootloader是用户自己写的存放在0x08000000上电后先跑再判断跳转。和ISP的区别可以这样理解ISP是烧录器做的事情IAP是产品固件自己做的事情。BOOT引脚在IAP方案里只作为“最后一道恢复手段”平时Bootloader不依赖它。我们自己写的Bootloader最大的优势是知道参数区在哪、校验逻辑是什么还能在升级界面显示进度这是ISP做不到的。3.4 跳转前用结构体代替全量CRC每次上电都把整个App区读一遍算CRC会拖慢启动时间。实际工程里App编译后预留一段固定地址的空间写入App长度、CRC和状态标志Bootloader只查这段信息就能判断App是否有效。/* boot_info.h */ typedef struct { uint32_t magic; /* 0xA5A55A5A用于确认结构有效 */ uint32_t app_size; /* App实际代码长度单位字节 */ uint32_t app_crc; /* App区CRC32 */ uint32_t boot_count; /* 连续启动计数 */ uint32_t status; /* 0无App, 1有效, 2升级中, 3需回滚 */ } app_boot_info_t;这个结构体放在参数区固定地址Bootloader在跳转前读它判断status和app_crc。App的链接脚本把bin文件末尾空出一块放这个结构体或者由升级工具在发送前把结构体追加到bin尾部。后一种做法更灵活因为App源码不用专门保留位置。算出App区CRC后只与结构体里的CRC比较全片扫描只在首次烧录或回滚时才做。4. 用串口/XModem框架把固件送进Flash帧格式、CRC16与写Flash状态机4.1 自定义一个够用的串口升级协议标准YModem协议本身很完整带文件名、包序号、取消机制但在64 KB量级的仪表固件上完整YModem实现有点重。实际项目里更常用的是“XModem变体”一包128字节固定帧头包号循环末尾带CRC16。只用串口就能do调试也直观。帧头2字节包序号负载长度负载数据CRC16-CCITT0xAA 0x550x00-0xFF1-128不足128补0xFF16位覆盖帧头到负载负载长度是实际有效数据长度补的0xFF不算数。设备端写Flash时只写长度字段指定的字节多出来的填充字节留在缓冲区里不落盘。这样最后一包不足128字节不会把脏数据写进App区。4.2 上位机最小实现Python脚本发bin文件调试Bootloader时最缺一个能反复发包的上位机。拿Python加pyserial写一个最小发送器速度比串口助手随手点好用得多import serial, struct, time def crc16_ccitt(data: bytes) - int: crc 0xFFFF for b in data: crc ^ b 8 for _ in range(8): crc ((crc 1) ^ 0x1021) 0xFFFF if crc 0x8000 else (crc 1) 0xFFFF return crc def send_packet(ser, seq, payload): payload payload.ljust(128, b\xff) # 补位到128字节 frame b\xaa\x55 bytes([seq 0xFF, len(payload)]) payload crc crc16_ccitt(frame) frame struct.pack(H, crc) ser.write(frame) return ser.read(2) bOK def send_firmware(ser, fw_path, expected_size): fw open(fw_path, rb).read() if len(fw) expected_size: raise RuntimeError(firmware too large) seq 0 for offset in range(0, len(fw), 128): chunk fw[offset:offset 128] for retry in range(3): if send_packet(ser, seq, chunk): break else: raise RuntimeError(packet %d failed % seq) seq 1 ser.write(b\xaa\x55\x00\x00) # 结束帧长度0串口按115200、8N1配置设备端每收到一包就回“OK”两个字节。这版脚本故意没有在发送前先发擦除命令实际使用时建议加一条“擦除命令地址长度”设备收到后先擦对应Page再回ACK避免边收边擦导致一包超时。struct.pack(H, crc)是高字节在前和设备端解析保持一致两边大小端不一致是CRC校验失败最常见的低级原因。4.3 设备端接收状态机与写Flash逻辑Bootloader里如果只用阻塞式HAL_UART_Receive等一整包升级过程中看门狗稍微喂不及时就复位。我一般把接收写成状态机每次轮询只收一个字节帧解析和处理都在主循环里推进。/* iap_core.c */ typedef enum { IAP_WAIT_HDR1, IAP_WAIT_HDR2, IAP_WAIT_SEQ, IAP_WAIT_LEN, IAP_WAIT_DATA, IAP_WAIT_CRC } iap_rx_state_t; static iap_rx_state_t rx_state; static uint8_t rx_seq, rx_len, rx_idx; static uint8_t rx_buf[130]; void iap_poll_byte(uint8_t b) { switch (rx_state) { case IAP_WAIT_HDR1: if (b 0xAA) rx_state IAP_WAIT_HDR2; break; case IAP_WAIT_HDR2: if (b 0x55) rx_state IAP_WAIT_SEQ; else rx_state IAP_WAIT_HDR1; break; case IAP_WAIT_SEQ: rx_seq b; rx_state IAP_WAIT_LEN; break; case IAP_WAIT_LEN: rx_len b; if (rx_len 0) { /* 结束帧 */ rx_state IAP_WAIT_HDR1; iap_finish_transfer(); } else if (rx_len 128) { rx_state IAP_WAIT_HDR1; /* 非法长度重新找帧头 */ } else { rx_idx 0; rx_state IAP_WAIT_DATA; } break; case IAP_WAIT_DATA: rx_buf[rx_idx] b; if (rx_idx rx_len 2) { /* 数据2字节CRC */ iap_process_packet(rx_seq, rx_buf, rx_len); rx_state IAP_WAIT_HDR1; } break; default: rx_state IAP_WAIT_HDR1; break; } }iap_process_packet里先算CRC再检查包序号然后调用Flash写入。由于App区起始地址和每次偏移都是2字节对齐的数据缓冲可以直接强转成uint16_t*写半字。F103擦写要求地址小于等于0x0800FFFF时不能超过Flash地址范围写之前对iap_write_addr rx_len做边界检查越界直接回错误帧。static void iap_process_packet(uint8_t seq, uint8_t *buf, uint8_t len) { uint16_t recv_crc, calc_crc; recv_crc (buf[len] 8) | buf[len 1]; calc_crc crc16_ccitt(buf, len); if (recv_crc ! calc_crc) { uart_send(NC, 2); /* 校验失败请求重发 */ return; } if (seq ! (iap_write_seq 0xFF)) { uart_send(NC, 2); return; } FLASH_Unlock(); for (uint32_t i 0; i (len 1) / 2; i) { FLASH_ProgramHalfWord(iap_write_addr i * 2, ((uint16_t *)buf)[i]); } FLASH_Lock(); iap_write_addr len; iap_write_seq; uart_send(OK, 2); }注意len是有效长度buf里最后两字节是CRC。按半字写入时如果len是奇数最后会多写一个字节多出来的字节来自缓冲区里的填充数据不一定安全。稳妥做法是先把整包数据搬到128字节对齐的静态数组末尾清0再写入。Flash编程中如果程序跑到一半被看门狗复位下次上电会检测到status2擦掉半成品重新接收。4.4 关键参数与失败处理参数推荐值说明包负载长度128字节4包正好1 KB对应一个Flash PageCRC算法CRC16-CCITT初值0xFFFF比CRC8可靠适合128字节包ACK响应0x4F 0x4BOK简单串口调试时也能肉眼识别设备超时1秒超时后重置帧状态不阻塞其他功能主机重试3次超过次数停止发送等待用户操作波特率115200仪表板常用升级44 KB约10秒内完成设备端超时不要用HAL_Delay阻塞否则升级时仪表显示会卡住。在Bootloader里保留一个Timer中断做1秒超时计数超时只做状态复位不清空已经写入的Flash这样网络抖动后能继续从断点发不用从头再来。5. 写坏之后的兜底仪表板IAP的回滚策略与防呆操作5.1 双区备份与启动计数升级最怕写一半断电。F103没有硬件双Bank但软件上可以做到“旧版本不丢”。CBT6空间充足用两个App区轮换新固件先写备份区CRC算完后再改启动标志复位后从备份区启动App运行前3秒把boot_count清零如果连续3次都没有清零Bootloader认定新固件有问题自动把备份区改成待回滚版本下一次跳转回旧区。这样升级过程出现任何一次Flash写入失败旧版本都还在原地。C8T6只有64 KB放不下双App区常见的折中是升级前先把当前App整体拷到备份区。备份区大小只有8 KB如果App超过8 KB就拷不进去此时只能靠“App有效标志完整CRC”保证绝不跳半个固件。没有备份空间时Bootloader至少要在升级前把App区的CRC存到参数区升级失败后能明确告诉用户“需要重新烧录”而不是黑屏。5.2 用CAN升级时的分帧设计仪表板很多走CAN总线串口不一定引出到面板。CAN单帧最多8字节128字节负载要拆成多帧协议设计上和串口不同。常用每帧带帧序号和总帧数接收端收满16帧后拼成一包128字节再做CRC校验。CAN扩展帧的数据场建议这样分配第0字节为帧类型1握手2固件数据3结束确认第1字节为包内片序号第2-7字节为固件数据。最后一帧不足6字节时用0xFF填充。CAN升级的波特率和终端电阻、总线占用都要在Bootloader初期自检仪表板整车上电瞬间好几个节点同时发报文Bootloader的CAN过滤器只放行升级用的CAN ID能少很多干扰。5.3 最后一道防呆把Bootloader区加Flash写保护仪表App如果跑飞无法保证它不会执行到擦写Flash的代码。给Bootloader区加写保护是防止把引导代码刷没的最直接手段。F103的选项字节支持按页写保护在Bootloader首次运行时执行一次FLASH_Unlock(); FLASH_OB_Unlock(); FLASH_OB_WRPConfig(OB_WRP_Pages0to7, ENABLE); /* 保护0x08000000-0x08001FFF */ FLASH_OB_Launch(); /* 加载选项字节芯片会复位 */ FLASH_OB_Lock(); FLASH_Lock();执行这段代码时App区和参数区的写功能不受影响后续升级依旧可以覆写App区。注意不要在升级过程中动态切换写保护选项字节的擦写有自己的时序一旦中途断电选项字节可能进入不确定状态。写保护加在Bootloader区后即使App升级包被恶意构造也没办法把Bootloader擦掉最多损坏App区Bootloader还能继续接收下一次升级。给产品做最后量产前检查时把“Bootloader区写保护已使能”作为产测项写进测试工装能避免售后面对大量救砖返修。本文还有配套的精品资源点击获取