
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压到8KBApp给52KB。参数区放在最后4KB主要是存一个升级标志。比如Bootloader启动时先读这个标志如果是需要升级状态就进升级流程否则直接跳App。这个标志在升级完成后要清掉防止每次上电都进Bootloader。2.2 为什么App的起始地址必须偏移很多人第一次做IAP直接把App的下载地址设成0x08000000结果Bootloader和App打架谁也别想跑。App的起始地址必须从Bootloader结束的地方开始也就是0x08003000。这个偏移量要同时改三个地方Keil的IROM1设置、链接脚本里的Flash起始地址、以及中断向量表的偏移寄存器。Keil里在Options for Target - Target - IROM1Start改成0x08003000Size改成0xC00048KB。链接脚本如果是用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表示开始升级带固件大小和CRC0x02表示数据包0x03表示升级结束。每包数据最大256字节因为串口缓冲区一般设256再大就要分片。上位机发数据的时候每包之间要有短暂延时给Bootloader留出写Flash的时间。我实测下来波特率115200的情况下每包之间延时5ms比较稳。如果波特率更高比如460800延时可以缩短到2ms但Flash写入时间是不变的所以不能无限缩短。3.2 串口接收用中断还是DMABootloader里的串口接收我建议用中断环形缓冲区。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[i1] 8); FLASH_ProgramHalfWord(addr i, half_word); } FLASH_Lock(); }提示擦除之前一定要确认目标地址在App区范围内不要误擦Bootloader区。我见过有人地址算错把Bootloader自己擦了结果只能拆机重新烧录。4. App端的链接脚本修改与中断向量表重定位4.1 Keil工程里必须改的三个地方App工程和普通工程的区别核心就在地址偏移。第一个地方是Options for Target - Target - IROM1Start改成0x08003000Size改成0xC000。第二个地方是链接脚本如果用的是Keil默认的分散加载文件要确认ROM起始地址和大小跟IROM1一致。第三个地方是中断向量表偏移在main函数开头加SCB-VTOR 0x08003000;。这三个地方缺一不可。只改IROM1不改VTOR中断会跑飞只改VTOR不改IROM1编译出来的bin文件地址不对Bootloader跳过去也跑不起来。我一般会在App的main函数里加一个打印把VTOR的值和当前PC地址打出来确认跳转成功。4.2 生成bin文件而不是hexBootloader通过串口接收的是bin文件不是hex。hex文件带地址信息Bootloader解析起来麻烦。Keil里生成bin文件需要加一个User Commandfromelf --bin --outputapp.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 跳转后HardFaultVTOR没设对第一次跑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差异有些国产替代的STM32F103C8T6Flash页大小可能不是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, timeout1) 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:i256] 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的主循环是一个状态机空闲态、接收开始帧、接收数据帧、接收结束帧、校验、跳转。每个状态处理对应的命令处理完回到空闲态等下一帧。如果超时没收到数据就回到空闲态。状态机的好处是逻辑清晰不会因为某一帧出错就卡死。开始帧里带固件总大小和总CRCBootloader收到后先擦除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占8KBApp占28KB备份区占28KB刚好64KB。但28KB的App对于稍微复杂一点的项目就不够用了。所以双备份在C8T6上不太现实更适合Flash更大的型号比如CBT6或者RET6。如果非要在C8T6上做双备份可以把Bootloader压到4KBApp和备份各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测试确认所有环节都正常再批量使用。