简介:面向基于 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 Flash,CBT6是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烧进0x08000000,App烧进0x08002000或更靠后的位置。设备每次复位都先进入Bootloader,它检查有没有升级请求、Flash里有没有有效App,再决定跳转还是擦写。
F103C8T6和F103CBT6的差异主要在Flash容量:C8T6是64 KB、CBT6是128 KB,SRAM都是20 KB。分区代码里不要写死容量,用宏区分。F103中容量芯片按1 KB一个Page擦除,下面这套分区表是仪表板项目里比较常见的布置。
| 区域 | C8T6起始地址 | C8T6大小 | CBT6起始地址 | CBT6大小 | 用途 |
|---|---|---|---|---|---|
| Bootloader区 | 0x08000000 | 8 KB | 0x08000000 | 8 KB | 引导、传输、擦写与校验 |
| App区 | 0x08002000 | 44 KB | 0x08002000 | 96 KB | 仪表界面、业务逻辑 |
| 参数/校准区 | 0x0800D000 | 4 KB | 0x0801A000 | 8 KB | 仪表系数、密码、升级标志 |
| 备份区 | 0x0800E000 | 8 KB | 0x0801C000 | 16 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改成0x08002000,Size改成0xB000(C8T6)或0x18000(CBT6)。
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 KB,IAR和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_BASE=0x08002000 */ HAL_Init(); SystemClock_Config(); ... }如果App使用HAL库,HAL_Init里会初始化SysTick和中断分组,这要求向量表已经正确。所以SCB->VTOR这行必须放在任何外设中断使能之前。有人习惯直接在launch文件或startup文件里改VECT_TAB_OFFSET宏,这个宏本质上是给SystemInit用的,改它也可以,但不如main开头一行直观,排查问题更方便。
3.3 别把BOOT引脚启动和IAP混为一谈
很多资料说STM32的BOOT0拉高进系统Bootloader,BOOT1再选择系统存储器,然后通过串口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-CCITT |
|---|---|---|---|---|
| 0xAA 0x55 | 0x00-0xFF | 1-128 | 不足128补0xFF | 16位覆盖帧头到负载 |
负载长度是实际有效数据长度,补的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) == b"OK" 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编程中如果程序跑到一半被看门狗复位,下次上电会检测到status=2,擦掉半成品重新接收。
4.4 关键参数与失败处理
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 包负载长度 | 128字节 | 4包正好1 KB,对应一个Flash Page |
| CRC算法 | CRC16-CCITT,初值0xFFFF | 比CRC8可靠,适合128字节包 |
| ACK响应 | 0x4F 0x4B("OK") | 简单,串口调试时也能肉眼识别 |
| 设备超时 | 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区写保护已使能”作为产测项写进测试工装,能避免售后面对大量救砖返修。
本文还有配套的精品资源,点击获取