资讯详情

资讯详情

STM32F103C8T6串口IAP升级:Bootloader分区与跳转避坑指南

简介面向STM32F103C8T6开发者的串口IAP升级固件源码包完整解决单片机通过串口实现在线固件升级与跳转执行的需求适合需要掌握Bootloader原理或正在调试在线升级方案的嵌入式工程师也适合为仪器仪表、物联网节点等设备增加远程维护能力的项目参考。资源共430个文件、2.23MB以86个C源文件与86个头文件构成主要逻辑另有汇编启动文件、链接脚本以及uvproj、ewp等工程配置覆盖从源码修改、编译链接到生成hex/bin固件的完整开发链条汇编入口与链接脚本便于分析启动过程和内存布局说明文档则可帮助快速理解代码结构。包内基础工程直接搭建了IAP升级环境按典型流程展示Flash分区规划、串口通信协议与固件跳转处理且保留较多可配置参数方便根据实际硬件或协议需求进行裁剪移植省去从零构建引导程序的繁琐环节。该资源已有5148人学习下载对初学STM32在线升级或需要快速复现Bootloader方案的中高级开发者是一份实用价值较高的参考源码包。1. 串口IAP升级STM32F103C8T6最反直觉的一次开发在 STM32F103C8T6 的项目里做串口 IAP 固件升级最反直觉的一点是决定升级过程能不能可靠完成的不是那几行“接收串口数据、写 Flash”的代码而是 Bootloader 里两个看起来不起眼的瞬间——上电后等多久再跳 App以及“升级请求”标志被放在哪个地址。这两处没想清楚设备在产线上烧好第一版时一切正常发到现场要通过串口升级时十次里有三次变砖。这个源码包做的就是把 C8T6 的 64KB Flash 拆成 16KB Bootloader 和 48KB App用串口协议把新固件分包写入 App 区校验通过后跳转执行。适合刚接触 IAP 的人照着完整流程手搓一遍也适合正在做量产设备、被现场升级稳定性反复折磨的开发者对照排查。下面按我从拿到源码到跑通、再到改出自己一版的顺序来讲。2. 整体设计与 Flash 分区Bootloader 和 App 怎么在 64KB Flash 里共存拿到 C8T6 的第一件事是把“Flash 有 64KB”这个数字忘掉。实际应用可用的只有 48KB因为前面 16KB 要划给 Bootloader。很多人的 App 加了协议栈、日志和状态机之后塞不进 48KB十有八九是在选型时没做这个心理预期。IAP 不是把两个程序随便丢进 Flash而是先画好地盘再写代码。2.1 双区架构与内存规划16KB Bootloader 48KB App源码包里的分区方案很直接一张表就能说清区域起始地址大小存放内容Bootloader 区0x0800000016KBIAP 主程序、串口驱动、Flash 擦写驱动App 区0x0800400048KB业务固件、App 中断向量表参数页0x08003C001KB升级请求标志、版本号记录为什么要留 16KB 给 Bootloader而不是省到 8KB我用标准外设库编译过这个工程串口加 Flash 驱动加上协议解析编译出来大约 6~9KB留 16KB 是给自己留后路。后面要加 CRC 校验之外的加密、签名代码量会明显膨胀如果把 Bootloader 压缩到 4KB纯寄存器操作确实能塞下但可读性和可维护性都会变差。这里有个绕不开的硬件事实Cortex-M3 上电后从 Flash 起始地址取两个字——第一个字是初始主堆栈指针 MSP第二个字是复位向量。Bootloader 的向量表必须在 0x08000000因为这是硬件固定的。而 App 的向量表本来也应在 0x08000000可那个位置被 Bootloader 占着App 只能把向量表挪到 0x08004000。麻烦在于 CPU 并不知道这个偏移必须通过 SCB-VTOR 告诉它。STM32F103 的 VTOR 在 0xE000ED08F1 全系列都有这个寄存器这点比某些 Cortex-M0 单片机要省事。2.2 启动流程与跳转条件升级标志位、串口窗口与跳转判定整个启动流程如果用文字描述是这样的链路上电复位 → Bootloader 初始化时钟和串口 → 读取参数页的升级请求标志 → 若标志有效清标志并直接进入升级等待模式 → 若标志无效等待约 300ms 的串口命令窗口 → 窗口内收到握手帧进入升级模式 → 窗口结束仍未收到命令跳转到 App。为什么是“等约 300ms”而不是无限等现场升级要求上电盲操作设备一上电 Bootloader 先开一个短暂的升级窗口上位机只要能在这个窗口里发出握手帧就能抢占。窗口太短现场人员来不及窗口太长设备每次开机都要慢几秒。300ms 是我在速度和成功率之间试出来的经验值源码包里也留了宏定义可以自己调。升级请求标志放在参数页而不是放在 App 区尾部原因很实际App 运行时不能擦写自己正在执行的 Flash 页。假如把标志写在 App 区同一页程序一边从 Flash 取指令一边擦写那一页轻则写失败重则 HardFault。所以 App 请求升级时是把标志写给 Bootloader 区里独立的一页。核心代码是这样的#define PARA_FLAG_ADDR 0x08003C00 /* Bootloader区最后一页1KB */ #define FLAG_MAGIC 0x5A5AA5A5 void app_request_upgrade(void) { uint32_t flags FLAG_MAGIC; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); FLASH_ErasePage(PARA_FLAG_ADDR); /* 先整页擦除 */ FLASH_ProgramWord(PARA_FLAG_ADDR, flags); /* 写入魔数 */ FLASH_Lock(); NVIC_SystemReset(); /* 软复位进Bootloader */ }这段代码关键在两点擦除后写入是标准流程不能省掉 ClearFlag写完魔数后立刻软复位让 Bootloader 来决定接下来是升级还是正常跳转App 自己不做升级流程的分发。现场人员只要触发 App 里的这个函数设备重启后就会自动停在升级等待状态。Bootloader 启动时的判断同样简单if (*(volatile uint32_t *)PARA_FLAG_ADDR FLAG_MAGIC) { FLASH_ErasePage(PARA_FLAG_ADDR); /* 清掉请求标志防止下次重复误入升级 */ iap_state IAP_STATE_UPGRADE; /* 进入升级等待模式 */ }清标志必须在跳转升级逻辑之前完成否则升级过程中途断电再上电会又一次进入升级模式反而把正常 App 挡住。这个顺序我一开始写反过结果现场出现“设备永远停在升级等待App 起不来”的翻车原因就是标志没被清掉。3. 串口升级协议与 Bootloader 核心代码拆解一帧一帧把固件搬进 FlashBootloader 的本质是一个串口透传写入器上位机发一帧Bootloader 解析一帧、校验一帧、写一次 Flash。协议设计得不好升级过程就会变成一场对时钟和运气的赌博。这章我把协议帧、状态机、Flash 操作和跳转逻辑逐一拆开讲。3.1 串口协议帧设计帧头、命令字、长度与 CRC16 校验源码包里的协议帧格式很简单但足够防错字节序字段长度说明0帧头2 字节固定 0xAA 0x552命令字1 字节0x01 升级开始0x02 数据块0x03 完成校验0x04 跳转 App3数据长度2 字节小端序payload 字节数不含帧头和 CRC5Payload变长数据块命令里是 offset(4 字节) 固件数据(最多 256 字节)末尾CRC162 字节CRC16-MODBUS覆盖数据长度和 Payload命令字是升级流程的骨架。0x01 升级开始命令里上位机告诉 Bootloader 固件总长度Bootloader 收到后擦除整个 App 区并回复 ACK0x02 数据块命令承担真正的搬砖工作每帧最多带 256 字节数据0x03 完成校验命令用于回读 Flash 算 CRC0x04 才是真正触发跳转的那一下。串口接收我建议用状态机而不是直接等一个“完整帧中断”因为 STM32F103 的串口一次中断只停在一个字节上数据是碎片化到达的。状态机在串口中断里逐字节喂最稳妥typedef enum { ST_WAIT_HEAD1 0, ST_WAIT_HEAD2, ST_WAIT_CMD, ST_WAIT_LEN, ST_WAIT_PAYLOAD, ST_WAIT_CRC } RX_STATE; static RX_STATE rx_state ST_WAIT_HEAD1; static uint16_t payload_len 0; static uint16_t payload_idx 0; static uint16_t crc_idx 0; void IAP_RxHandler(uint8_t byte) { switch (rx_state) { case ST_WAIT_HEAD1: if (byte 0xAA) rx_state ST_WAIT_HEAD2; break; case ST_WAIT_HEAD2: rx_state (byte 0x55) ? ST_WAIT_CMD : ST_WAIT_HEAD1; break; case ST_WAIT_CMD: rx_frame.cmd byte; payload_len 0; payload_idx 0; crc_idx 0; rx_state ST_WAIT_LEN; break; case ST_WAIT_LEN: if (payload_idx 0) { payload_len byte; /* 长度低字节先到 */ payload_idx 1; } else { payload_len | (uint16_t)byte 8; /* 高字节拼出完整长度 */ payload_idx 0; rx_state ST_WAIT_PAYLOAD; } break; case ST_WAIT_PAYLOAD: rx_frame.payload[payload_idx] byte; if (payload_idx payload_len) { crc_idx 0; rx_state ST_WAIT_CRC; } break; case ST_WAIT_CRC: if (crc_idx 0) { rx_frame.crc byte; /* CRC 低字节 */ crc_idx 1; } else { rx_frame.crc | (uint16_t)byte 8; IAP_OnFrameComplete(rx_frame); /* 一帧收齐 */ rx_state ST_WAIT_HEAD1; /* 回到帧头状态 */ } break; default: rx_state ST_WAIT_HEAD1; break; } }状态机的几个参数值得说明payload_len 是两字节小端所以要先收低字节再收高字节payload_idx 在这里实际是长度收齐后的数据计数实现时也可以单独用一个计数变量逻辑更清楚任何一帧中间字节出错状态机都会回到 ST_WAIT_HEAD1下一帧能自动恢复不用重启设备。帧收齐后进入分发性处理先把 CRC 校验做掉再按命令字分发void IAP_OnFrameComplete(IAP_Frame *frame) { uint16_t calc_crc CRC16_Modbus((uint8_t *)frame-cmd, 3 frame-len); if (calc_crc ! frame-crc) { IAP_SendAck(AK_CRC_ERR); return; } switch (frame-cmd) { case CMD_UPGRADE_START: IAP_OnStart(frame); break; case CMD_UPGRADE_DATA: IAP_OnData(frame); break; case CMD_UPGRADE_FINISH: IAP_OnFinish(frame); break; case CMD_UPGRADE_JUMP: IAP_JumpToApp(); break; default: IAP_SendAck(AK_CMD_ERR); break; } }CRC 的覆盖范围是命令字 长度 Payload帧头不参与计算。这是有意为之因为帧头本身有 AA 55 做同步而 CRC 要保护的是真正影响数据的字段。源码里用的是 CRC16-MODBUS初值 0xFFFF 多项式 0xA001实现只有几行uint16_t CRC16_Modbus(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ buf[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }上位机计算 CRC 时必须用同样的多项式否则联调时会出现“每一帧都回 CRC_ERR”的现象。这种问题排查起来特别像玄学其实是两端 CRC 不一致。3.2 Flash 擦写与存储逻辑先整片擦除、再半字写入、最后回读校验STM32F103 的 Flash 有一个特性写入以半字16 位为单位擦除以页为单位每页 1KB。写入前目标页必须处于已擦除状态否则写入结果不确定。这直接决定了升级策略收到第一帧命令时不能边收边写而是先把整个 App 区擦干净。#define APP_BASE_ADDR 0x08004000 #define FLASH_PAGE_SIZE 1024 #define APP_TOTAL_SIZE 0xC000 /* 48KB */ static uint32_t write_offset 0; static uint8_t flash_started 0; void IAP_OnStart(IAP_Frame *frame) { uint32_t total_len *(uint32_t *)frame-payload[0]; if (total_len APP_TOTAL_SIZE) { IAP_SendAck(AK_LEN_ERR); return; } FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); /* 整片擦除App区防止旧数据和残留页干扰 */ for (uint32_t addr APP_BASE_ADDR; addr APP_BASE_ADDR APP_TOTAL_SIZE; addr FLASH_PAGE_SIZE) { FLASH_ErasePage(addr); } write_offset 0; flash_started 1; IAP_SendAck(AK_OK); }整片擦除 48KB 共 48 页按 HSI 8MHz 或 PLL 倍频后的 CPU 时钟算大约要几百毫秒。所以上位机发完升级开始命令后不能立刻发数据必须等 Bootloader 回 ACK。这个等待关系在协议设计里要写清楚否则上位机发得快数据帧会因为 Flash 还没擦完而被忽略。数据块写入是每帧动作核心是把 payload 里的偏移量和数据取出来逐个半字写入void IAP_OnData(IAP_Frame *frame) { uint32_t offset; uint8_t *data; uint16_t data_len; uint32_t target_addr; if (!flash_started) { IAP_SendAck(AK_NOT_STARTED); /* 没收到升级开始命令直接拒绝 */ return; } offset *(uint32_t *)frame-payload[0]; /* 前4字节是目标偏移 */ data frame-payload[4]; data_len frame-len - 4; target_addr APP_BASE_ADDR offset; /* 越界检查防住上位机发疯 */ if ((target_addr data_len) (APP_BASE_ADDR APP_TOTAL_SIZE)) { IAP_SendAck(AK_ADDR_ERR); return; } /* 半字写入data_len 确保是偶数 */ for (uint16_t i 0; i data_len; i 2) { uint16_t half_word data[i] | (data[i 1] 8); FLASH_ProgramHalfWord(target_addr i, half_word); } write_offset offset data_len; IAP_SendAck(AK_OK); }这里有两个必须守住的约定offset 必须按 2 字节对齐data_len 必须是偶数。上位机在发送数据帧之前要把 bin 文件按 2 字节补齐最后不足 2 字节补 0xFF。如果上位机发来奇数长度的数据Bootloader 应该直接回错误而不是硬写否则最后一次写入会踩到未知地址。写完后回 ACK 表示这一帧落盘上位机再发下一帧。升级完成的校验比“写入成功”更可靠的是“整片回读再算 CRC”。FLASH_ProgramHalfWord 返回状态位在某些操作异常时不代表数据真写对了回读是最后防线void IAP_OnFinish(IAP_Frame *frame) { uint32_t expect_crc *(uint32_t *)frame-payload[0]; uint32_t real_crc CRC32_Flash(APP_BASE_ADDR, app_total_len); if (real_crc expect_crc) { IAP_SendAck(AK_CRC_OK); } else { IAP_SendAck(AK_CRC_ERR); /* 不跳转保留现场等上位机重传 */ } }CRC32_Flash 是源码包里提供的函数遍历 App 区已写入的字节计算 CRC32。比较失败时不擦除、不跳转等上位机重新发起升级。这比直接跳进一个损坏的 App 要安全得多。3.3 跳转到 App 的代码实现栈顶检查、VTOR 切换与中断屏蔽最后一步跳转是 IAP 里最容易写翻车的地方。跳转的本质不是“跳”而是“把 CPU 的执行环境完整换掉”换中断向量表、换栈指针、换 PC 指针。typedef void (*IAP_JumpFunc)(void); void IAP_JumpToApp(void) { uint32_t app_sp; uint32_t app_pc; IAP_JumpFunc jump_func; app_sp *(volatile uint32_t *)APP_BASE_ADDR; app_pc *(volatile uint32_t *)(APP_BASE_ADDR 4); /* 栈顶值必须在RAM地址范围内否则App区是空白或损坏 */ if ((app_sp 0x2FFE0000) ! 0x20000000) { return; } if ((app_pc 0xFFFFF000) 0) { return; } jump_func (IAP_JumpFunc)app_pc; /* 跳转前彻底关中断防止VTOR切换瞬间中断乱入 */ __set_PRIMASK(1); SCB-VTOR APP_BASE_ADDR; /* 让App找到自己的向量表 */ __set_MSP(app_sp); /* 换成App的初始栈指针 */ jump_func(); /* 跳到App复位向量 */ /* 如果走到这里说明App没起来 */ __set_PRIMASK(0); }这个函数里有几处细节决定生死。app_sp 是 App 向量表第一个字也就是 App 初始主堆栈指针跳转前不换栈指针Bootloader 的栈调用链会继续压栈App 的 main 函数跑起来后栈空间被污染。__set_PRIMASK(1)在切换 VTOR 前关闭全部可屏蔽中断避免串口中断在向量表切换的瞬间来一下CPU 拿着新向量表去找旧中断入口直接进 HardFault。跳转前的两次检查能拦掉“App 区是空白 Flash”的情况空白 Flash 读出来是 0xFFFFFFFF通不过 RAM 地址范围判断。4. 编译配置与烧录避坑让 Bootloader 和 App 各就各位代码写完只是第一步真正让这个工程跑起来的是两个 Keil 工程的配置。这章讲清楚怎么建两个工程、怎么生成 bin、怎么用串口助手手搓一次升级最后给出我踩过的三个坑。4.1 两套 Keil 工程的 IROM 配置与 bin 文件生成源码包分了两个 Keil 工程一个 Bootloader一个 App。各自的关键配置用一张表说清配置项Bootloader 工程App 工程IROM1 起始地址0x080000000x08004000IROM1 大小0x40000xC000是否设置 VTOR不需要设置偏移 0x4000烧录方式ST-Link 写入通过 Bootloader 串口升级App 工程里设置向量表偏移有两种方式一种是在 main 函数里手动执行SCB-VTOR 0x08004000;另一种是在 system_stm32f10x.c 里宏定义VECT_TAB_OFFSET 0x4000。我推荐用后一种因为 SystemInit 在 startup 文件里比 main 更早执行向量表在 C 环境初始化之前就切好了更稳。如果是手动方式注意别放在 main 的后面否则启动早期来的中断照样会跑飞。生成 bin 文件是升级的前提Keil 的 After Build 命令行里加一句fromelf --bin -o $LL.bin #L$LL会自动展开成不带扩展名的工程输出路径#L是链接后的映像文件。生成后把这颗 bin 交给上位机程序就能用于串口升级。注意工程路径不要带空格否则 fromelf 会把路径拆成多个参数这是我实际遇到过的低级问题。4.2 用 Python 脚本模拟上位机手搓一遍完整升级流程在没有现成上位机程序时用 Python 串口脚本就能联调起来。核心是构造协议帧import serial import struct def crc16_modbus(data: bytes) - int: crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return crc def make_frame(cmd: int, payload: bytes) - bytes: body bytes([cmd]) struct.pack(H, len(payload)) payload crc crc16_modbus(body) return b\xaa\x55 body struct.pack(H, crc)联调时 BOOT0 保持低电平先用 ST-Link 烧 Bootloader然后只接 USB 转串口 TTL打开串口后按这个顺序跑发送升级开始帧payload 里是固件总长度等待 Bootloader 回 ACK分段读取 bin 文件每段 256 字节前三字节是因为对齐要处理发数据帧并逐帧确认 ACK发完成校验帧收到 CRC_OK 后发跳转帧。第一次联调建议用 9600 波特率虽然慢但容错高。波特率越高现场线材和干扰对帧错误率的影响越大。源码包里默认配置是 115200如果发现 CRC 错误率高先把波特率降下来再排查。4.3 避坑记录变砖、Flash 写入 HardFault、App 跑飞的三类排查经验这一节记录的是我实际踩过的三个坑每条都按现象、原因、解决展开。第一个坑是“升级中途断电再上电设备变砖”。现象是升级到一半现场工人误拔电源再上电后设备没有任何反应App 起不来Bootloader 也没响应。原因是写 Flash 只擦除了一部分页App 区里既有新数据又有残留的旧数据程序还尝试直接跳转。解决方法是跳转前做严格的栈顶检查和 App 有效魔数检查魔数在 App 工程编译时固定在向量表后第 4 个字节位置Bootloader 跳转前读这个位置不是约定的值就绝不跳转并且等待上位机重新升级。第二个坑是“FLASH_ProgramHalfWord 写入时进 HardFault”。现象是写入执行到一半卡死调试器定位到 HardFault_Handler。原因有两个目标页没擦除干净或者写地址不是半字对齐。我第一次写 IAP 时边收边写收到哪页写哪页没先整片擦除结果在旧数据页上写入直接触发错误。解决方法是升级开始命令里整片擦除并在 IAP_OnData 里校验 offset 是否为 2 的倍数、data_len 是否为偶数两端约定数据补齐策略。第三个坑是“跳转后 App 跑飞”。现象是升级流程全部返回成功设备串口就是不工作调试器显示停在了 HardFault_Handler。原因是 App 向量表偏移没设。跳转成功后App 的 main 还没跑起来一个 SysTick 或串口中断到达CPU 从 0x08000000 读中断向量读到的是 Bootloader 的中断处理函数地址入口对不上直接 HardFault。解决方式就是在 App 工程里设置 VECT_TAB_OFFSET 为 0x4000并在跳转函数里先__set_PRIMASK(1)再切 VTOR。这类问题表现很像玄学但根因基本都是向量表偏移。5. 二次开发思路给串口 IAP 加上固件加密、版本回滚和生产验证跑通基础升级之后要考虑的是怎么让这套东西更接近产品级。Flash 只有 64KB很多雕花功能做不了但有几件务实的事值得做。5.1 固件加密与签名从裸数据帧到加密帧裸 bin 文件被上位机直接抓走就能逆向出固件所以量产产品通常会做一层加密。我在二次开发时采用的思路是上位机把 bin 用 AES-128-CBC 加密密钥硬编码在 Bootloader 里升级时先传加密数据Bootloader 收到一帧后先解密、再写入 Flash。解密用软件实现STM32F103 的主频跑 AES 会慢一点但好在数据量不大48KB 的固件多花几百毫秒可以接受。要注意的是密钥放 Flash 里只能防外行不能防专业抄板。真要防篡改得上签名校验上位机对固件做 SHA256 摘要再用私钥签个名Bootloader 里放公钥验签。验签通过才擦写。这个项目里我把签名设计成可选的Bootloader 编译宏打开才启用节省了不必要的时间开销。5.2 版本回滚、断点续传与 BKP 记录Flash 小的务实方案C8T6 的 64KB 放不下 A/B 双系统的大回滚方案但可以实现小成本回滚升级前把当前运行中的 App 关键版本号记录在备份寄存器里。STM32F103 的 BKP 寄存器在复位后不会丢失App 启动时读 BKP 里的版本号和当前固件版本比对如果发现版本回退或者写失败就主动请求重写。断点续传比回滚更实用。因为串口升级最怕断断了要从头传。Bootloader 里记录已经写入的偏移量到 BKP再次上电时上位机可以直接从断点续传省掉整包重传的时间。这个功能实现不复杂但要改协议在升级开始命令里带上“上次写入偏移”的查询帧。5.3 出厂验证的习惯比功能本身更值钱做完这些扩展有个动作是每次都必须做的把整个验证脚本固化成出厂测试用例。上电不跳 App 的空白 Flash 测试、升级到一半断电再上电的断电测试、下载完成后断电重启的验收测试这三条我每做一个 IAP 工程都会强制走一遍。源码包能帮你跑通流程但这三次测试能帮你确定它在现场不会翻车。从那以后我每次做升级功能都坚持把这三步写进测试清单一次都没漏过希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →