☰
STM32F103C8T6串口IAP实战:Flash分区、Bootloader与App跳转全解析
2026/9/28 16:26:45 网站建设 项目流程

1. 为什么我最终选了串口IAP而不是烧录器

手里攥着一块STM32F103C8T6最小系统板,板子焊好、外壳装好、螺丝拧紧,结果发现固件有个小bug要改。这时候你面对两个选择:要么把设备拆开、插上ST-Link重新烧录,要么在板子上留一个串口,通过IAP把新固件"推"进去。前者每次升级都要动硬件,后者只需要一根USB转TTL线。但凡产品有一点点量产或者现场部署的需求,IAP就是绕不过去的坎。

STM32F103C8T6这颗芯片在圈子里太常见了,64KB Flash、20KB RAM,价格便宜、资料多、国产替代方案也成熟,最小系统板几乎人手一块。但很多人拿到板子之后,跑完点灯、串口打印、按键扫描这些基础实验,就停在"能跑就行"的阶段。真正把IAP跑通、把Bootloader和App的分区规划清楚、把跳转和中断向量表处理干净的人,其实没那么多。我见过不少项目,Bootloader写了一半,App跳过去就HardFault,或者升级过程中断电直接变砖,最后又老老实实回去用烧录器。

这篇内容就是把我自己在STM32F103C8T6上做串口IAP的完整过程拆开讲。从Flash分区怎么划、Bootloader怎么收数据、App怎么改链接脚本、跳转前要关哪些东西,到实际调试中遇到的坑,全部落到可复现的代码和参数上。适合已经会点灯、会串口收发、想往产品化方向走一步的嵌入式开发者。不需要你有多深的RTOS经验,但至少要能看懂启动文件和链接脚本的基本结构。

提示:IAP的本质是"程序自己改自己"。Bootloader负责把新固件写到App区,然后跳过去执行。这个过程中最怕的就是写到一半断电,所以分区规划和校验机制必须提前想清楚。

2. STM32F103C8T6的Flash地图与分区决策

2.1 64KB Flash到底怎么切才合理

STM32F103C8T6的Flash是64KB,地址从0x08000000到0x0800FFFF。IAP方案里,这段空间要被切成至少两块:Bootloader区和App区。Bootloader负责升级逻辑,App是实际业务代码。如果还要存升级标志、固件备份或者参数,可能还得再切一块。

我自己的习惯是这样分的:

区域起始地址大小用途
Bootloader0x0800000012KB升级逻辑、串口协议、跳转
App0x0800300048KB业务代码
参数/标志区0x0800F0004KB升级标志、版本号、校验值

12KB给Bootloader是偏保守的。如果你Bootloader里不放复杂协议、不做双备份,8KB也够。但考虑到以后可能要加CRC校验、YModem协议、甚至简单的菜单交互,留12KB比较稳。App区48KB,对于大部分中小型项目够用,如果代码超了,可以把Bootloader压到8KB,App给52KB。

参数区放在最后4KB,主要是存一个升级标志。比如Bootloader启动时先读这个标志,如果是"需要升级"状态,就进升级流程;否则直接跳App。这个标志在升级完成后要清掉,防止每次上电都进Bootloader。

2.2 为什么App的起始地址必须偏移

很多人第一次做IAP,直接把App的下载地址设成0x08000000,结果Bootloader和App打架,谁也别想跑。App的起始地址必须从Bootloader结束的地方开始,也就是0x08003000。这个偏移量要同时改三个地方:Keil的IROM1设置、链接脚本里的Flash起始地址、以及中断向量表的偏移寄存器。

Keil里在Options for Target -> Target -> IROM1,Start改成0x08003000,Size改成0xC000(48KB)。链接脚本如果是用Keil自带的.sct文件,也要对应改。中断向量表偏移用SCB->VTOR = 0x08003000;,这行代码必须放在App的main函数最开头,越早越好,最好在SystemInit之后立刻设置。

注意:VTOR寄存器在STM32F103里是有的,属于Cortex-M3内核的系统控制块。如果不设置VTOR,中断发生时CPU还是会去0x08000000找向量表,而那里现在是Bootloader的向量表,中断服务函数就会跑飞。

2.3 升级标志区的读写策略

参数区我一般放在0x0800F000,占4KB,但实际只用一个32位字。写入之前要先擦除整个页,STM32F103的Flash页大小是1KB,所以擦除地址要对齐到0x0800F000。写标志的时候用FLASH_ProgramWord(),读的时候直接指针取值。

标志值我定义了两个:0x5A5A5A5A表示"请求升级",0xFFFFFFFF表示"无需升级"。Bootloader启动后先读这个地址,如果是0x5A5A5A5A,就进串口升级流程;升级成功后把标志擦成0xFFFFFFFF,然后跳App。App里如果收到特定串口命令,也可以主动把标志写成0x5A5A5A5A,然后软复位,让Bootloader接管。

这里有个细节:擦除和写入Flash的时候,CPU会暂停执行。STM32F103的Flash编程时间大概是几十微秒,擦除一页是20ms左右。这个过程中如果看门狗没处理好,可能会复位。所以Bootloader里要么先关看门狗,要么在擦写前后喂狗。

3. Bootloader的串口协议设计与接收逻辑

3.1 为什么不用YModem而自己定协议

YModem协议在IAP里很常见,优点是成熟、有校验、支持文件名和大小。但它有个问题:协议本身比较重,Bootloader里要实现完整的YModem状态机,代码量不小,而且调试的时候如果上位机工具不配合,排查起来很麻烦。对于STM32F103C8T6这种资源有限的芯片,我倾向于自己定一个轻量协议。

我的协议格式很简单:帧头0xAA 0x55,然后跟命令字、数据长度、数据内容、CRC16校验。命令字有三种:0x01表示开始升级(带固件大小和CRC),0x02表示数据包,0x03表示升级结束。每包数据最大256字节,因为串口缓冲区一般设256,再大就要分片。

上位机发数据的时候,每包之间要有短暂延时,给Bootloader留出写Flash的时间。我实测下来,波特率115200的情况下,每包之间延时5ms比较稳。如果波特率更高,比如460800,延时可以缩短到2ms,但Flash写入时间是不变的,所以不能无限缩短。

3.2 串口接收用中断还是DMA

Bootloader里的串口接收,我建议用中断+环形缓冲区。DMA虽然效率高,但配置复杂,而且一旦DMA和Flash写入冲突,排查起来很痛苦。中断方式下,每收到一个字节就存进环形缓冲区,主循环里再解析帧。这样即使主循环正在擦Flash,串口数据也不会丢,因为中断优先级高于Flash操作。

环形缓冲区大小设512字节足够。解析的时候先找帧头0xAA 0x55,然后读命令字和长度,再算CRC。如果CRC不对,直接丢弃这一帧,等下一帧。这里要注意:串口中断里不要做复杂运算,只负责存数据,解析放在主循环。

// 串口中断接收,存环形缓冲区 void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); ring_buf_write(&rx_buf, data); USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }

3.3 Flash写入的时序与对齐问题

STM32F103的Flash写入必须按半字(16位)对齐。也就是说,你写一个字节,实际上要凑成两个字节一起写。如果数据长度是奇数,最后一个字节要补0xFF。擦除必须按页擦除,页大小1KB,所以App区的起始地址和结束地址都要对齐到1KB边界。

写Flash的流程是:解锁、擦除目标页、写入数据、上锁。擦除一页大概20ms,写入一个字大概50us。如果固件是48KB,那就是48页,擦除时间加起来接近1秒。这1秒内如果断电,App区就是空的,设备变砖。所以我的做法是:先擦一页、写一页、校验一页,再擦下一页。这样即使断电,最多损失一页数据,而且升级标志还在,重新上电后Bootloader会重新进入升级流程。

// 写一页数据 void flash_write_page(uint32_t addr, uint8_t *data, uint16_t len) { FLASH_Unlock(); FLASH_ErasePage(addr); for(uint16_t i = 0; i < len; i += 2) { uint16_t half_word = data[i] | (data[i+1] << 8); FLASH_ProgramHalfWord(addr + i, half_word); } FLASH_Lock(); }

提示:擦除之前一定要确认目标地址在App区范围内,不要误擦Bootloader区。我见过有人地址算错,把Bootloader自己擦了,结果只能拆机重新烧录。

4. App端的链接脚本修改与中断向量表重定位

4.1 Keil工程里必须改的三个地方

App工程和普通工程的区别,核心就在地址偏移。第一个地方是Options for Target -> Target -> IROM1,Start改成0x08003000,Size改成0xC000。第二个地方是链接脚本,如果用的是Keil默认的分散加载文件,要确认ROM起始地址和大小跟IROM1一致。第三个地方是中断向量表偏移,在main函数开头加SCB->VTOR = 0x08003000;。

这三个地方缺一不可。只改IROM1不改VTOR,中断会跑飞;只改VTOR不改IROM1,编译出来的bin文件地址不对,Bootloader跳过去也跑不起来。我一般会在App的main函数里加一个打印,把VTOR的值和当前PC地址打出来,确认跳转成功。

4.2 生成bin文件而不是hex

Bootloader通过串口接收的是bin文件,不是hex。hex文件带地址信息,Bootloader解析起来麻烦。Keil里生成bin文件需要加一个User Command:fromelf --bin --output=app.bin .\Objects\app.axf。这个命令在Options for Target -> User -> After Build/Rebuild里配置。

生成bin之后,用上位机工具打开,先算CRC16,然后通过串口发给Bootloader。上位机工具可以用Python写,也可以用现成的串口助手加脚本。我自己用Python写了一个简单的,读bin文件、分帧、发串口、等应答,代码不到100行。

4.3 跳转前的清理工作

从Bootloader跳转到App之前,要做几件事:关总中断、关外设时钟、设置主栈指针、设置VTOR、然后跳转。关中断用__disable_irq(),关外设时钟用RCC_APBxPeriphClockCmd()把用到的外设都关掉。主栈指针从App的向量表第一个字取,也就是*(uint32_t*)0x08003000。VTOR设成0x08003000。最后用函数指针跳转。

void jump_to_app(uint32_t app_addr) { __disable_irq(); RCC_DeInit(); SCB->VTOR = app_addr; uint32_t stack_ptr = *(uint32_t*)app_addr; uint32_t reset_handler = *(uint32_t*)(app_addr + 4); __set_MSP(stack_ptr); void (*app_entry)(void) = (void (*)(void))reset_handler; app_entry(); }

这里有个坑:跳转之前一定要把串口中断关掉,否则App里如果没重新配置串口,中断还会往Bootloader的处理函数跑。另外,跳转之后Bootloader的栈和堆都不再有效,所以App必须有自己的完整初始化。

5. 实际调试中遇到的五个坑与排查过程

5.1 跳转后HardFault:VTOR没设对

第一次跑IAP的时候,Bootloader跳过去直接HardFault。用调试器看,PC停在0x08000000附近,说明中断向量表没重定位。检查代码发现SCB->VTOR确实写了,但写在了SystemInit()之前,被后面的时钟初始化覆盖了。把VTOR设置挪到SystemInit()之后、外设初始化之前,问题解决。

这个坑的教训是:VTOR的设置时机很重要。SystemInit里会配置时钟,但不会动VTOR。真正会动VTOR的是你自己写的代码。所以只要保证在使能任何中断之前设置VTOR就行。我现在的习惯是在main函数第一行就设VTOR,然后再做其他初始化。

5.2 串口收到数据但CRC一直错

上位机发数据,Bootloader能收到,但CRC校验总是不对。排查发现是上位机在发帧头之前多发了一个0x00,导致Bootloader把0x00当成了帧头的一部分。后来在协议里加了超时机制:如果收到0xAA之后500ms内没收到0x55,就丢弃这个0xAA,重新找帧头。

另一个原因是CRC计算的范围不对。我的协议里CRC是从命令字开始算,不包括帧头。上位机如果从帧头开始算,两边就对不上。这种问题最好在协议文档里写清楚,或者干脆把帧头也纳入CRC范围,减少歧义。

5.3 升级到一半断电,设备变砖

这个问题前面提过,解决办法是分页擦写+升级标志。但实际测试的时候发现,如果断电发生在擦除页的过程中,那一页的数据会变成全0xFF,但升级标志还是0x5A5A5A5A。重新上电后Bootloader会重新进入升级流程,从头开始写。所以只要升级标志没被清掉,设备就能恢复。

但如果断电发生在写升级标志的过程中呢?比如标志刚擦成0xFFFFFFFF,还没写0x5A5A5A5A就断电了。这时候重新上电,Bootloader读到0xFFFFFFFF,以为不需要升级,直接跳App。而App区可能只写了一半,跳过去就HardFault。所以我的做法是:升级标志的擦除和写入要放在升级流程的最后,等所有数据都写完、校验通过之后,再清标志。这样即使标志区操作失败,App区也是完整的。

5.4 App里串口中断不响应

App跳转成功,主循环能跑,但串口中断不响应。检查发现App里没有重新配置NVIC,中断优先级和使能都是Bootloader留下的状态。解决办法是在App的串口初始化里,重新调用NVIC_Init(),把串口中断的优先级和使能重新设一遍。另外,App的启动文件里如果开了__disable_irq(),要在初始化完成后__enable_irq()。

5.5 国产替代芯片的Flash差异

有些国产替代的STM32F103C8T6,Flash页大小可能不是1KB,而是2KB。如果按1KB擦除,会擦掉相邻页的数据。排查方法是查芯片手册,确认页大小。如果手册没写,可以写个测试程序,往不同地址写数据,然后擦除一个页,看哪些地址被影响了。我遇到过一款替代芯片,页大小是2KB,但手册上写的是1KB,实际测试才发现。

注意:国产替代芯片的Flash编程时间可能比原厂长,擦除一页可能要30ms甚至更久。Bootloader里的超时时间要留够余量,否则会误判为升级失败。

6. 上位机工具的极简实现与升级流程串联

6.1 用Python写一个够用的发送端

上位机不需要太复杂,能读bin文件、算CRC、分帧、发串口、等应答就行。我用Python的serial库和struct库,核心代码不到100行。流程是:打开串口、读bin文件、发开始帧(带文件大小和CRC)、等Bootloader应答、然后循环发数据帧、每帧等应答、最后发结束帧。

import serial, struct, 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 ser = serial.Serial('COM3', 115200, timeout=1) with open('app.bin', 'rb') as f: firmware = f.read() # 发开始帧 start_frame = struct.pack('<BBHI', 0xAA, 0x55, 0x01, len(firmware)) start_frame += struct.pack('<H', crc16(start_frame[2:])) ser.write(start_frame) time.sleep(0.1) # 发数据帧 for i in range(0, len(firmware), 256): chunk = firmware[i:i+256] frame = struct.pack('<BBH', 0xAA, 0x55, 0x02) + struct.pack('<H', len(chunk)) + chunk frame += struct.pack('<H', crc16(frame[2:])) ser.write(frame) time.sleep(0.005)

6.2 Bootloader的状态机设计

Bootloader的主循环是一个状态机:空闲态、接收开始帧、接收数据帧、接收结束帧、校验、跳转。每个状态处理对应的命令,处理完回到空闲态等下一帧。如果超时没收到数据,就回到空闲态。状态机的好处是逻辑清晰,不会因为某一帧出错就卡死。

开始帧里带固件总大小和总CRC,Bootloader收到后先擦除App区,然后进入数据接收状态。每收到一包数据,写一页Flash,然后回一个应答。如果某一包CRC错,回NAK,上位机重发。所有数据收完后,Bootloader算一遍总CRC,跟开始帧里的对比,一致就清升级标志、跳App,不一致就回错误,等上位机重新开始。

6.3 升级流程的完整时序

整个升级流程的时序是这样的:设备上电,Bootloader读升级标志,如果是0x5A5A5A5A,进升级模式,通过串口打印"等待升级"。上位机打开串口,发开始帧。Bootloader收到后擦除App区,回"准备就绪"。上位机开始发数据帧,每帧256字节,Bootloader写一页回一个ACK。发完后上位机发结束帧,Bootloader校验总CRC,通过后清标志、跳App。App启动后,通过串口打印"App运行中",表示升级成功。

这个流程我实测过几十次,115200波特率下,48KB固件大概需要15秒左右。如果波特率提到460800,可以缩短到5秒以内。但Flash擦写时间是不变的,所以提升有限。

7. 几个让IAP更稳的工程习惯

7.1 版本号和固件信息的存储

App区里我习惯在固定偏移放一个固件信息结构体,包含版本号、编译日期、CRC。Bootloader跳转前可以读这个结构体,通过串口打印出来,方便确认当前运行的是哪个版本。版本号在编译时用宏定义,比如#define FW_VERSION "1.0.3",放在一个单独的.c文件里,每次发版改一下。

固件信息结构体的地址要避开中断向量表,一般放在App区起始地址+0x200的位置。结构体本身也要算CRC,防止被篡改。Bootloader在跳转前校验这个CRC,如果不通过就停在Bootloader里,不跳App。

7.2 看门狗的处理

Bootloader里如果开了独立看门狗,擦Flash的时候要记得喂狗。STM32F103的独立看门狗超时时间最短是几十毫秒,擦一页20ms,如果连续擦几页不喂狗,就会复位。我的做法是在擦除循环里,每擦一页喂一次狗。或者干脆在Bootloader里不开看门狗,等跳转到App之后再开。

App里的看门狗也要注意。如果App启动时间比较长,比如要初始化很多外设,看门狗可能会超时。解决办法是在App启动初期先喂几次狗,等初始化完成后再正常喂。

7.3 串口波特率的自适应

有些场景下,上位机的波特率可能跟Bootloader不一致。可以在Bootloader里做一个简单的波特率探测:上电后先用115200收数据,如果收到0xAA但后续数据乱码,就切换到9600再试。或者更简单:Bootloader固定用115200,上位机也固定用115200,不做自适应。对于大多数项目,固定波特率就够了,自适应反而增加复杂度。

7.4 升级失败的重试机制

如果升级过程中CRC校验失败,Bootloader不要直接跳App,而是回错误帧,等上位机重新发开始帧。上位机收到错误后,重新读bin文件、重新发。重试次数可以设3次,3次都失败就停在Bootloader里,通过串口打印"升级失败",等人工干预。

这个机制在实际部署中很有用。现场升级的时候,如果一次不成功,操作人员只需要重新点一下升级按钮,不需要拆机。我见过一个项目,因为没做重试,升级失败后设备变砖,最后只能返厂。

7.5 调试信息的输出

Bootloader和App里都要留串口打印,方便调试。但正式发布的时候,这些打印要能关掉,否则会影响性能,也可能泄露信息。我的做法是用一个宏DEBUG_ENABLE控制,调试时打开,发布时关掉。打印的内容包括:当前状态、收到的命令、CRC结果、跳转地址等。

跳转前打印"Jumping to app at 0x08003000",跳转后App打印"App started, version 1.0.3"。这样一眼就能看出跳转是否成功。如果跳转后没打印,说明App没跑起来,可能是VTOR没设对,或者栈指针不对。

8. 从IAP延伸到产品化的几个思考

8.1 双备份升级的可行性

STM32F103C8T6只有64KB Flash,做双备份比较紧张。如果Bootloader占8KB,App占28KB,备份区占28KB,刚好64KB。但28KB的App对于稍微复杂一点的项目就不够用了。所以双备份在C8T6上不太现实,更适合Flash更大的型号,比如CBT6或者RET6。

如果非要在C8T6上做双备份,可以把Bootloader压到4KB,App和备份各30KB。但4KB的Bootloader要实现完整的串口协议和Flash操作,代码要写得非常紧凑。我试过,用寄存器操作代替库函数,4KB勉强够,但可读性很差,后期维护困难。

8.2 无线升级的改造思路

串口IAP跑通之后,改成无线升级其实不难。把串口换成无线模块的串口,协议不变,Bootloader里的接收逻辑也不用大改。关键是无线模块的波特率和缓冲区要匹配。比如用蓝牙模块,波特率一般115200,缓冲区可能只有128字节,那数据帧就要缩小到128字节以内。

无线升级的另一个问题是稳定性。无线链路可能丢包,所以协议里要有重传机制。我的做法是每帧数据都等ACK,超时没收到就重发,重发3次还不成功就报错。这样虽然速度慢一点,但可靠性高。

8.3 量产时的烧录策略

量产的时候,Bootloader和App要一起烧进去。可以用ST-Link先烧Bootloader,然后通过串口IAP烧App。或者用离线烧录器,把Bootloader和App合并成一个hex文件,一次性烧录。合并的方法是:Bootloader的hex从0x08000000开始,App的hex从0x08003000开始,用工具合并成一个文件。

合并之后要注意,App的VTOR设置和链接脚本还是要按偏移来。不能因为合并烧录就把App的地址改回0x08000000,否则Bootloader跳转的时候地址对不上。

8.4 固件加密的简单实现

如果不想让固件被轻易读出来,可以在App的bin文件上做一个简单的异或加密,Bootloader收到数据后先解密再写Flash。异或的密钥可以固定,也可以根据芯片ID动态生成。STM32F103有一个96位的唯一ID,读出来之后取几个字节作为密钥,这样每个芯片的固件密文都不一样,即使被读出来也没法直接运行。

当然,这种加密强度不高,只能防君子不防小人。真要高安全性,得用硬件加密芯片或者STM32的读保护功能。读保护一开,Flash就没法通过调试器读出来了,但同时也意味着没法再通过调试器烧录,只能通过IAP升级。这个取舍要看具体项目需求。

8.5 升级时间的优化空间

48KB固件、115200波特率、15秒升级时间,对于大多数场景够用了。如果嫌慢,可以从几个方面优化:提高波特率到460800,时间能降到5秒左右;减小数据帧之间的延时,从5ms降到2ms;用DMA接收串口数据,减少中断开销。但Flash擦写时间是大头,48页每页20ms就是960ms,这部分没法压缩。

如果实在要更快,可以考虑只升级变化的部分,也就是差分升级。但差分升级需要上位机生成差分包,Bootloader里要做差分还原,复杂度高很多。对于C8T6这种资源,不太建议。

9. 我个人在实际操作中的几点体会

IAP这个东西,原理不复杂,但细节特别多。我前后做过五六个项目,每个项目都会遇到新的坑。最开始的时候,我觉得只要把跳转代码写对就行,后来发现Flash分区、中断向量表、看门狗、串口协议,每一个环节都可能出问题。

最大的体会是:升级标志和分页擦写是保命的东西。没有这两个,设备一旦升级失败就是砖。有了这两个,最坏情况就是重新升级一次。我在一个现场项目里,因为没做分页擦写,升级过程中断电,设备直接变砖,最后只能派人去现场拆机。从那以后,我所有的IAP方案都强制要求分页擦写和升级标志。

另一个体会是:调试信息要留够。Bootloader里每一步都打印状态,跳转前打印地址,跳转后App打印版本号。这样出问题的时候,一眼就能看出卡在哪一步。我见过有人Bootloader里什么打印都没有,跳转失败后完全不知道是擦除失败、写入失败还是跳转失败,只能一步步加打印重新烧录,效率极低。

最后一点:国产替代芯片要实测。STM32F103C8T6的国产替代很多,大部分是兼容的,但Flash页大小、编程时间、甚至VTOR的行为可能有差异。我遇到过一款替代芯片,VTOR设置之后不生效,必须用NVIC_SetVectorTable()才行。所以拿到新芯片,先跑一遍IAP测试,确认所有环节都正常,再批量使用。

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

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

立即咨询