☰
STM32F103C8T6串口IAP实战:Bootloader设计与Flash分区详解
2026/9/28 16:28:06 网站建设 项目流程

1. 为什么要在 STM32F103C8T6 上折腾串口 IAP

STM32F103C8T6 这颗芯片,搞嵌入式的基本都摸过。72MHz 主频、64KB Flash、20KB SRAM,蓝色最小系统板十块钱出头就能买到,性价比高到离谱。但很多人拿它做项目,固件烧进去之后就固定了,每次改代码都得把板子从设备里拆出来,插上 DAPLink 或者 ST-Link 重新下载。产品一旦装到现场,这种操作根本不现实。

串口 IAP(In-Application Programming)解决的就是这个问题。简单说,就是让单片机自己通过串口接收新固件,然后写进自己的 Flash 里,写完跳过去执行。整个过程不需要拆机,不需要仿真器,一根 USB 转 TTL 线就能搞定。对于 STM32F103C8T6 这种资源紧张的芯片来说,IAP 方案的设计尤其需要精打细算——64KB Flash 要分给 Bootloader 和用户程序,20KB SRAM 要支撑接收缓冲和 Flash 写入,每一步都得算清楚。

这篇文章面向的是已经能跑通 STM32F103C8T6 基础工程、会用 Keil 或 GCC 编译、对 Flash 和中断有基本概念的嵌入式开发者。如果你刚点亮 LED,建议先把 GPIO、串口收发、Flash 读写这几个基础实验做一遍再来看。我会从分区规划开始,一步步拆解 Bootloader 的设计逻辑、用户程序的地址偏移处理、固件传输协议的设计,最后给出完整的代码框架和踩坑记录。整套方案在 STM32F103C8T6 最小系统板上实测通过,代码可以直接移植到同系列的其他型号。

2. 整体方案设计与 Flash 分区规划

2.1 Bootloader 与用户程序的职责划分

IAP 的核心思路是把 Flash 分成两块:一块放 Bootloader,负责接收新固件并写入;另一块放用户程序,也就是实际干活的业务代码。上电后先跑 Bootloader,它检查有没有升级请求——有就接收固件写进去,没有就直接跳转到用户程序。

Bootloader 的职责很明确:初始化串口、等待升级命令、接收固件数据、擦写 Flash、校验完整性、跳转执行。它不需要跑业务逻辑,所以代码量可以控制得很小。用户程序则完全不知道 Bootloader 的存在,它只管自己从某个偏移地址开始运行,中断向量表也要相应偏移。

这里有个关键决策:Bootloader 要不要支持“回滚”?也就是说,如果新固件写坏了,能不能退回旧版本。STM32F103C8T6 只有 64KB Flash,做双备份不现实。我的做法是在 Flash 末尾留一个标志区,记录当前固件是否有效。如果用户程序启动后在一定时间内没有“确认自己活着”,Bootloader 就认为升级失败,停在等待升级的状态。这个机制后面会详细讲。

2.2 64KB Flash 的精确分配

STM32F103C8T6 的 Flash 起始地址是0x08000000,总共 64KB,结束地址是0x0800FFFF。页大小是 1KB,也就是说擦除的最小单位是 1KB。这个页大小很重要,后面擦写的时候要按页对齐。

我的分区方案是这样的:

区域起始地址结束地址大小用途
Bootloader0x080000000x08002FFF12KB引导程序
用户程序0x080030000x0800EFFF48KB业务固件
标志区0x0800F0000x0800F3FF1KB升级标志与固件信息
保留0x0800F4000x0800FFFF3KB预留扩展

Bootloader 给 12KB 是经过实测的。一个带串口收发、Flash 擦写、CRC 校验、跳转逻辑的 Bootloader,用 Keil 开 -O2 优化,代码大概 6-8KB。留 12KB 有足够余量,万一以后要加加密校验或者双串口支持也够用。用户程序 48KB 对于大多数中小型项目绰绰有余,如果你代码实在大,可以压缩 Bootloader 到 8KB,把用户区扩到 52KB。

标志区放在0x0800F000,单独占一页。这一页专门用来存升级状态、固件长度、CRC 值这些元信息。为什么不放在 Bootloader 区域里?因为 Bootloader 区域在跳转后一般不会被擦写,但标志区需要在每次升级时更新,独立出来更清晰,也避免误擦 Bootloader。

注意:STM32F103C8T6 的 Flash 擦除是按页进行的,写入是按半字(16位)进行的。擦除一页需要约 20-40ms,这个时间必须等,不能省略。写入前必须确保目标区域已经擦除,否则写入会失败。

2.3 中断向量表的偏移处理

这是 IAP 最容易翻车的地方。Cortex-M3 的中断向量表默认从0x08000000开始,但用户程序实际烧在0x08003000。如果不做处理,用户程序里任何一个中断触发,CPU 都会去0x08000000找中断服务函数,结果跑到 Bootloader 的向量表里去了,程序直接跑飞。

解决办法是在用户程序的main函数开头,尽早设置SCB->VTOR寄存器,把向量表偏移到用户程序的起始地址。具体代码:

// 用户程序 main 函数开头 SCB->VTOR = 0x08003000; __DSB(); __ISB();

这三行必须在任何中断使能之前执行。我一般放在SystemInit()之后、外设初始化之前。__DSB()和__ISB()是数据同步和指令同步屏障,确保 VTOR 写入立即生效。

另外,Keil 工程里还需要设置 IROM1 的起始地址为0x08003000,大小0xC000(48KB)。在 Options for Target -> Target 里改。GCC 的话改链接脚本里的FLASH ORIGIN。这一步不做,编译出来的固件还是按0x08000000链接的,烧进去必挂。

2.4 串口通信协议的设计取舍

固件传输协议我设计得很简单,因为 STM32F103C8T6 的 SRAM 只有 20KB,搞太复杂的协议栈不现实。核心思路是:上位机按固定格式发数据包,Bootloader 收到后解析、写入、回复 ACK。

数据包格式:

字段长度说明
帧头2字节固定 0xAA 0x55
命令1字节0x01 升级请求,0x02 数据包,0x03 结束
长度2字节数据段长度,小端
数据N字节实际固件数据,最大 1024
CRC162字节从命令到数据的校验

为什么选 CRC16 而不是校验和?校验和对突发错误的检测能力太弱,固件传输过程中一个字节出错就可能导致整个程序跑飞。CRC16 计算量小,在 72MHz 的 M3 上跑 1KB 数据也就几十微秒,完全可接受。

数据包最大 1024 字节,是因为 Flash 一页正好 1KB。收到一包就擦一页、写一页,逻辑最顺。SRAM 里开一个 1024 字节的接收缓冲,加上其他变量,20KB 完全够用。

实操心得:串口波特率不要一上来就飙到 115200 以上。STM32F103C8T6 的 USART 在 72MHz 下,115200 的误差率约 0.16%,很稳。但如果你用的是内部 RC 振荡器(HSI),误差会大很多,建议先用 57600 测试,稳定后再往上提。我实测 115200 配合外部 8MHz 晶振,连续传 48KB 固件零丢包。

3. Bootloader 核心代码实现与关键细节

3.1 Flash 擦写驱动的编写要点

STM32F103C8T6 的 Flash 操作有一套固定的流程:解锁、擦除、写入、锁定。标准库和 HAL 库都有封装,但我建议直接操作寄存器,代码更可控,也省空间。

解锁 Flash:

FLASH->KEYR = 0x45670123; FLASH->KEYR = 0xCDEF89AB;

这两个密钥必须连续写入,中间不能有中断打断。所以解锁前最好关一下全局中断,解锁完再开。

擦除一页:

FLASH->CR |= FLASH_CR_PER; // 页擦除模式 FLASH->AR = page_address; // 目标页地址 FLASH->CR |= FLASH_CR_STRT; // 启动擦除 while (FLASH->SR & FLASH_SR_BSY); // 等待完成 FLASH->CR &= ~FLASH_CR_PER; // 退出页擦除模式

写入半字:

FLASH->CR |= FLASH_CR_PG; // 编程模式 *(volatile uint16_t*)addr = data; // 写入半字 while (FLASH->SR & FLASH_SR_BSY); // 等待完成 FLASH->CR &= ~FLASH_CR_PG; // 退出编程模式

这里有个坑:写入地址必须是半字对齐的,也就是偶数地址。如果你传进来的地址是奇数,写入会失败,而且 Flash 的 SR 寄存器会置位错误标志。我的做法是在写入前强制addr & 0xFFFFFFFE,确保对齐。

还有一个细节:每次写入前要检查FLASH->SR的PGERR和WRPRTERR位,如果置位了说明写入出错,要清掉标志并返回错误。不清的话下次操作会直接失败。

3.2 串口接收的状态机设计

Bootloader 的串口接收不能用“收完一帧再处理”的阻塞方式,因为固件包可能很大,阻塞接收会丢数据。我用的是环形缓冲加状态机的方式。

环形缓冲开 2048 字节,串口中断里只管往缓冲里塞数据,主循环里从缓冲里取数据解析。这样中断处理时间极短,不会丢包。

状态机解析数据包的逻辑:

typedef enum { STATE_HEADER1, // 等待 0xAA STATE_HEADER2, // 等待 0x55 STATE_CMD, // 命令字节 STATE_LEN_L, // 长度低字节 STATE_LEN_H, // 长度高字节 STATE_DATA, // 数据段 STATE_CRC_L, // CRC 低字节 STATE_CRC_H // CRC 高字节 } parse_state_t;

每收到一个字节,根据当前状态决定下一步。收到完整包后,计算 CRC 并比对,通过就处理,不通过就丢弃并请求重发。

注意:串口中断里不要做任何耗时操作,包括 CRC 计算。CRC 放到主循环里做。中断里只负责把数据塞进环形缓冲,然后更新写指针。读指针在主循环里更新。这样即使主循环正在擦 Flash(耗时几十毫秒),串口数据也不会丢,因为环形缓冲够大。

3.3 跳转到用户程序的正确姿势

跳转前要做几件事:关掉所有中断、关闭外设时钟、设置主栈指针、设置向量表偏移、跳转。

void jump_to_app(uint32_t app_addr) { uint32_t sp = *(volatile uint32_t*)app_addr; uint32_t pc = *(volatile uint32_t*)(app_addr + 4); __disable_irq(); // 关闭 SysTick SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 关闭所有外设中断 for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } // 设置主栈指针 __set_MSP(sp); // 设置向量表偏移 SCB->VTOR = app_addr; // 跳转 void (*app_entry)(void) = (void (*)(void))pc; app_entry(); }

这段代码有几个关键点。第一,app_addr处存放的是用户程序的栈顶地址,app_addr + 4处是复位向量。第二,关中断必须在设置 MSP 之前,否则中断触发会用到旧的栈。第三,SCB->VTOR在跳转前设置一次,用户程序里还会再设置一次,双保险。

我踩过的坑:有一次忘了关 SysTick,跳转后 SysTick 还在跑,中断触发后跑到 Bootloader 的 SysTick_Handler 里去了,因为向量表还没来得及切换。所以 SysTick 一定要在跳转前关掉。

3.4 升级标志与固件有效性检查

标志区我定义了一个结构体:

typedef struct { uint32_t magic; // 0x5A5A5A5A 表示标志有效 uint32_t fw_len; // 固件长度 uint32_t fw_crc; // 固件 CRC32 uint8_t fw_valid; // 1 表示固件有效 uint8_t reserved[3]; } fw_info_t;

Bootloader 上电后先读这个结构体。如果magic不对,说明标志区没初始化过,直接进升级模式。如果fw_valid为 1,说明上次升级成功,跳转到用户程序。如果fw_valid为 0,说明上次升级中途断电了,固件不完整,停在升级模式等待重新传输。

用户程序启动后,第一件事是把fw_valid置 1,表示“我活着”。这个操作放在main函数开头,越早越好。这样即使后续初始化失败,至少标志已经置上了,下次上电不会误判。

实操心得:标志区的写入要注意,Flash 写入只能把 1 变成 0,不能把 0 变成 1。所以更新标志时,要么先擦除整页再写,要么设计成只写 0 不写 1 的逻辑。我选择的是每次升级前擦除标志页,升级完成后写入新标志。擦除一页 1KB 耗时约 20ms,可以接受。

4. 用户程序的适配与固件生成

4.1 Keil 工程地址偏移配置

用户程序的 Keil 工程需要改两个地方。第一是 Target 里的 IROM1 起始地址和大小:

IROM1: Start = 0x08003000, Size = 0xC000

第二是system_stm32f10x.c里的VECT_TAB_OFFSET:

#define VECT_TAB_OFFSET 0x3000

这个宏决定了SystemInit()里设置的向量表偏移。改完这两处,编译出来的固件就是按0x08003000链接的。

如果你用的是 GCC,链接脚本里改:

MEMORY { FLASH (rx) : ORIGIN = 0x08003000, LENGTH = 48K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K }

4.2 生成可传输的固件文件

Keil 编译出来的是.axf文件,不能直接用于 IAP 传输。需要转成.bin文件。Keil 自带fromelf工具:

fromelf --bin --output=firmware.bin Objects\project.axf

在 Keil 的 User 选项卡里可以配置编译后自动执行这条命令。GCC 的话用objcopy:

arm-none-eabi-objcopy -O binary project.elf firmware.bin

生成的.bin文件就是从0x08003000开始的纯二进制数据,大小就是实际代码量。上位机直接把这个文件按包发送即可。

4.3 上位机工具的选择与编写

上位机我推荐用 Python 写,跨平台,改起来方便。核心逻辑就是读 bin 文件、分包、加协议头、发串口、等 ACK。

import serial import struct import time 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 send_packet(ser, cmd, data): length = len(data) payload = struct.pack('<BH', cmd, length) + data crc = crc16(payload) packet = b'\xAA\x55' + payload + struct.pack('<H', crc) ser.write(packet) # 等待 ACK ack = ser.read(1) return ack == b'\x06'

发送流程:先发升级请求命令,Bootloader 回复 ACK 并擦除用户区;然后按 1KB 分包发送数据,每包等 ACK;最后发结束命令,Bootloader 校验整个固件的 CRC,通过后置fw_valid为 1,回复成功。

注意:每包之间的超时时间要设够。Bootloader 擦一页 Flash 需要 20-40ms,加上写入和 CRC 校验,一包的处理时间可能到 50ms。上位机的超时建议设 500ms 以上,重试次数 3 次。我一开始设了 100ms,结果大量超时重传,传 48KB 花了快两分钟。改成 500ms 后,稳定在 15 秒左右。

4.4 固件 CRC 校验的完整流程

CRC 校验分两级。第一级是每包的 CRC16,确保单包数据没出错。第二级是整个固件的 CRC32,在结束命令时由 Bootloader 计算并比对。

Bootloader 在接收数据的同时,把每个字节喂给一个 CRC32 计算器。收到结束命令后,把计算结果和上位机发来的 CRC32 比对。一致就认为固件完整,不一致就要求重传。

CRC32 我用的是标准多项式0xEDB88320,查表法实现,速度很快。48KB 数据算下来不到 1ms。

uint32_t crc32_update(uint32_t crc, uint8_t data) { crc ^= data; for (int i = 0; i < 8; i++) { if (crc & 1) crc = (crc >> 1) ^ 0xEDB88320; else crc >>= 1; } return crc; }

上位机端用 Python 的zlib.crc32()计算,两边结果一致。注意 Python 的zlib.crc32()返回的是有符号整数,要& 0xFFFFFFFF转成无符号再发送。

5. 常见问题排查与实战避坑记录

5.1 跳转后程序不运行的排查思路

这是 IAP 最常遇到的问题。跳转过去了,但用户程序没跑起来。排查顺序如下:

第一,检查用户程序的链接地址。用fromelf --text -v或者arm-none-eabi-objdump -h看固件的入口地址是不是0x08003000。如果还是0x08000000,说明工程配置没改对。

第二,检查向量表偏移。在用户程序的main开头打断点,看SCB->VTOR的值是不是0x08003000。如果不是,检查VECT_TAB_OFFSET宏和SystemInit()的调用。

第三,检查栈顶地址。用调试器看0x08003000处的值,应该是0x20005000左右(20KB SRAM 的栈顶)。如果是0xFFFFFFFF,说明 Flash 里没数据,固件没写进去。

第四,检查中断。如果程序跑起来了但一进中断就挂,多半是向量表没设对。确认SCB->VTOR在中断使能前就设置好了。

我遇到过一次,跳转后程序能跑,但串口中断不响应。查了半天发现是 Bootloader 里开了串口中断,跳转前没关,用户程序里又没重新配置 NVIC,导致中断优先级混乱。后来在跳转函数里加了NVIC->ICER全清,问题解决。

5.2 Flash 写入失败的典型原因

Flash 写入失败一般有这几个原因:

  • 目标页没擦除。STM32 的 Flash 只能把 1 写成 0,如果目标地址已经是 0,再写就会失败。每次写入前必须确保页已擦除。
  • 地址没对齐。写入地址必须是偶数。奇数地址写入会触发PGERR。
  • 没解锁。FLASH->KEYR没写或者写错,所有操作都会被忽略。
  • 写保护。如果芯片的写保护选项字节被设置了,Flash 写入会被拒绝。用 ST-Link Utility 检查一下选项字节。

排查的时候,每次操作后读FLASH->SR,看有没有错误标志。有就清掉,然后根据标志位判断原因。

5.3 串口丢包与超时的处理

串口丢包在 IAP 里很致命,一个字节丢了整个固件就废了。丢包的原因通常有三个:

第一,波特率误差太大。用外部晶振,115200 没问题。用内部 HSI,建议降到 57600 或 38400。

第二,接收缓冲太小。Bootloader 擦 Flash 的时候会关中断或者长时间不处理串口,如果缓冲不够大,数据就丢了。我开 2048 字节缓冲,擦一页 1KB 的时间足够缓冲后续数据。

第三,上位机发送太快。每包之间要等 ACK,不能连续发。Python 的ser.write()是异步的,写完不等 ACK 就发下一包,Bootloader 处理不过来。必须ser.read(1)等 ACK 再发下一包。

实操心得:调试 IAP 的时候,建议先用小固件测试,比如就闪个 LED 的 bin 文件,几 KB 大小。跑通了再传大固件。另外,Bootloader 里加个 LED 指示很有用:等待升级时慢闪,接收数据时快闪,升级成功时常亮。不用串口打印也能知道状态。

5.4 常见问题速查表

现象可能原因解决方法
跳转后无反应用户程序链接地址不对检查 IROM1 起始地址
跳转后进中断跑飞向量表偏移未设置设置 SCB->VTOR 并加屏障
Flash 写入失败页未擦除或地址未对齐擦除后写入,地址强制偶数对齐
串口丢包波特率误差或缓冲不足降波特率,增大环形缓冲
升级后程序不跑固件 CRC 校验失败检查上位机 CRC 计算和字节序
反复进入升级模式标志区 fw_valid 未置位用户程序开头置位 fw_valid
擦除时间过长正常现象每页 20-40ms,超时设 500ms

6. 进阶优化与扩展思路

6.1 加入固件加密与校验

如果产品需要防抄板或者防篡改,可以在固件传输前做 AES 加密,Bootloader 收到后解密再写入。STM32F103C8T6 没有硬件 AES 加速,软件 AES 解密 48KB 数据大概需要几百毫秒,可以接受。密钥可以存在 Flash 的保留区,或者用芯片唯一 ID 派生。

另一个思路是固件签名。上位机用私钥对固件签名,Bootloader 用公钥验签。这样即使固件被截获,没有私钥也无法伪造合法固件。不过 RSA 验签在 M3 上跑比较慢,ECC 会快很多,但实现复杂度高。对于大多数项目,CRC32 加简单的异或加密就够了。

6.2 双串口备份升级通道

STM32F103C8T6 有 3 个 USART,可以同时支持两个升级通道。比如 USART1 接调试口,USART2 接设备外部接口。Bootloader 同时监听两个串口,哪个先收到升级请求就用哪个。这样现场升级更灵活,不用拆机找特定的接口。

实现上,两个串口各开一个环形缓冲,主循环轮询两个缓冲。哪个有数据就解析哪个。注意两个串口的波特率要一致,否则协议解析会乱。

6.3 从串口 IAP 扩展到 CAN IAP

如果设备用的是 CAN 总线,把串口换成 CAN 即可,协议层不用大改。CAN 的帧格式和串口不同,但分包、CRC、ACK 的逻辑是一样的。STM32F103C8T6 自带 CAN 控制器,加个收发器就能用。CAN 的抗干扰能力比串口强得多,适合工业现场。

6.4 固件版本管理与回滚机制

在标志区里加一个版本号字段,Bootloader 记录当前运行的固件版本。升级时如果新版本号比当前低,可以拒绝升级或者提示确认。回滚的话,因为 Flash 空间不够存两份固件,只能靠上位机重新传旧版本。但可以在标志区里存一个“上一版本有效”的标志,如果新固件启动失败,Bootloader 自动进入升级模式,等待上位机传回旧版本。

这个机制的关键是“启动失败”的判定。我的做法是用户程序启动后,在 5 秒内必须往标志区写一个“心跳”值。Bootloader 在跳转前启动一个定时器,如果 5 秒内没收到心跳,就认为启动失败,复位并停在升级模式。心跳可以用 RTC 备份寄存器实现,不用擦 Flash。

最后分享一个小技巧:Bootloader 的串口最好支持自动波特率检测。上位机先发一个 0x7F 字节,Bootloader 测量脉宽算出波特率,然后回复确认。这样不管上位机用什么波特率都能连上,现场调试很方便。STM32F103C8T6 的 USART 支持自动波特率检测,配置一下USART_CR3的ABREN位就行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询