简介:这份完整版原创STM32F103串口IAP升级程序面向嵌入式开发工程师与STM32学习者,解决设备运行中通过串口在线更新固件、无需拆机或专用编程器的问题。资源包含Bootloader、串口通信、固件接收校验擦除写入、失败回滚恢复、固件合法性校验等关键环节,并附带测试app与完整IAP工程,便于对照验证升级流程的稳定性与可靠性。压缩包共290个文件,以72个h头文件、68个c源文件为核心,辅以o、d、crf等编译中间文件及hex、bin、axf、map等烧录与调试产物,整体约5.68MB,工程结构完整。目前已有2786人学习下载。读者可借此理解STM32F103内部Flash操作、HAL/LL库配置与串口收发实现,掌握Bootloader编写原理、IAP流程细节及错误恢复机制,并参考测试app模拟不同升级场景,为自研项目快速落地串口升级功能提供可复用的代码框架与排错思路。
1. 串口IAP升级到底解决了什么:从“拆机烧录”到“发个bin就完事”
做嵌入式这行的,大概都经历过这种场景:设备已经装在现场机柜里,螺丝拧了十几颗,外壳封得严严实实,结果发现固件有个小bug要改。传统做法是抱着仿真器跑现场,拆机、接线、烧录、再装回去,一趟下来半天没了。STM32F103的串口IAP升级程序,就是专门治这个病的——它让MCU通过串口接收新的固件数据,自己把Flash里的程序擦掉重写,全程不用拆机、不用仿真器,发个bin文件过去就完事。
这套完整版原创STM32F103串口IAP升级程序,包含了两大部分:一份是运行在MCU里的IAP引导程序(Bootloader),负责接收串口数据并写入Flash;另一份是测试用的APP程序,用来验证升级流程是否跑通。代码基于STM32标准外设库,小部分基础代码有借鉴,但整体框架和升级逻辑是原创的。适合谁用?如果你手上有F103的板子,想搞明白IAP到底怎么落地,或者项目里正好需要串口升级功能,这份资源可以直接拿来改。它不是什么高深的东西,但能把“从零搭一套能用的IAP”这件事省掉。
2. IAP升级的底层逻辑:Flash分区、中断向量表和跳转机制
2.1 为什么必须做Flash分区
STM32F103的Flash起始地址是0x08000000,CPU上电后从这里取第一条指令。IAP的核心思路,就是把Flash切成两块:前面一块放Bootloader,后面一块放APP。Bootloader先跑,判断是否需要升级——需要就收数据写APP区,不需要就直接跳过去执行APP。
分区方案没有绝对标准,但有个基本原则:Bootloader区要够放你的引导代码,APP区要够放你的业务代码。F103C8T6只有64KB Flash,分区就得抠着算。常见做法是Bootloader占16KB(0x08000000~0x08003FFF),APP从0x08004000开始,剩下48KB给APP用。如果是RCT6这种256KB的,可以给Bootloader分32KB甚至64KB,APP区更宽裕。
/* flash_partition.h - Flash分区定义 */ #define BOOTLOADER_START_ADDR 0x08000000 /* Bootloader起始地址 */ #define BOOTLOADER_SIZE 0x4000 /* 16KB,Bootloader占用 */ #define APP_START_ADDR 0x08004000 /* APP起始地址,紧跟Bootloader */ #define APP_MAX_SIZE 0xC000 /* 48KB,APP最大可用空间 */ #define FLASH_PAGE_SIZE 0x400 /* F103中容量产品每页1KB */这里的关键参数是APP_START_ADDR,它必须和Bootloader里跳转的目标地址、APP程序的链接地址三者一致。任何一处对不上,跳转后直接HardFault。FLASH_PAGE_SIZE是擦除的最小单位,F103小容量和中容量产品是1KB一页,大容量是2KB,写代码前先翻一下参考手册确认。
2.2 中断向量表的偏移处理
APP程序从0x08004000开始放,但STM32的中断向量表默认在0x08000000。如果不做处理,APP里任何一个中断触发,CPU都会跑到Bootloader的向量表里去取地址,结果就是跑飞。解决办法是在APP的启动代码里重设向量表偏移。
/* app_main.c - APP程序入口处重设向量表 */ #include "stm32f10x.h" int main(void) { /* 关键操作:将向量表偏移到APP起始地址 */ NVIC_SetVectorTable(NVIC_VectTab_FLASH, APP_START_ADDR); /* 后续正常初始化外设 */ SystemInit(); USART1_Init(115200); while(1) { /* 业务逻辑 */ } }NVIC_SetVectorTable的第二个参数是偏移量,注意它传的是相对于0x08000000的偏移,不是绝对地址。有些库函数版本要求传绝对地址,用之前确认一下库的实现。这一步漏掉的话,APP单独烧录能跑,IAP跳转过去就死,是新手最容易翻车的地方。
2.3 跳转前的准备工作
从Bootloader跳转到APP,不是一句函数指针调用就完事。跳转前必须把Bootloader用过的外设全部关掉,中断全部禁用,时钟恢复到默认状态。否则APP跑起来后,某个中断还挂在Bootloader的处理函数上,或者某个外设状态不对,都会导致玄学问题。
/* bootloader.c - 跳转到APP的完整流程 */ typedef void (*pFunction)(void); void JumpToApp(uint32_t appAddr) { pFunction jumpToApp; uint32_t jumpAddr; /* 检查APP区是否有有效程序(栈顶地址合法性判断) */ if (((*(__IO uint32_t*)appAddr) & 0x2FFE0000) == 0x20000000) { /* 关闭所有中断 */ __disable_irq(); /* 关闭用过的外设:串口、定时器等 */ USART_DeInit(USART1); TIM_DeInit(TIM2); /* 重设SysTick */ SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; /* 设置主栈指针 */ __set_MSP(*(__IO uint32_t*)appAddr); /* 取APP的复位向量地址 */ jumpAddr = *(__IO uint32_t*)(appAddr + 4); jumpToApp = (pFunction)jumpAddr; /* 开中断并跳转 */ __enable_irq(); jumpToApp(); } }这段代码里有个细节值得说:(*(__IO uint32_t*)appAddr) & 0x2FFE0000) == 0x20000000这行是在检查APP区的第一个字(也就是初始栈顶值)是否落在SRAM范围内。F103的SRAM起始是0x20000000,栈顶一般设在0x20000000加上SRAM大小附近。如果APP区是空的(0xFFFFFFFF),这个判断就不成立,直接跳过跳转,避免跑飞。这个检查不是万能的,但能挡住大部分“APP区没数据还硬跳”的情况。
3. 串口接收与Flash写入:YModem协议、数据校验和页对齐
3.1 为什么选YModem而不是自定义协议
串口传固件,最怕的是数据丢包或出错。自定义一个“帧头+长度+数据+校验”的协议当然可以,但得自己处理重传、超时、流控,工作量不小。YModem是现成的方案,支持1024字节数据块、CRC校验、文件名和大小传输,SecureCRT、Xshell这些终端工具都自带支持,Bootloader这边只需要实现接收逻辑就行。
YModem的交互流程大致是:接收方先发一个字符'C',发送方收到后开始传第一帧(包含文件名和大小),接收方应答后继续传数据帧,每帧传完等接收方ACK再传下一帧,全部传完发送方发EOT,接收方再ACK。整个过程有明确的握手和应答,丢包会自动重传。
3.2 串口接收的环形缓冲区设计
Bootloader在接收数据时,不能收一个字节就写一个字节Flash——Flash写入需要按页对齐,而且频繁擦写会缩短寿命。常见做法是用一个环形缓冲区暂存串口数据,攒够一帧再处理。
/* uart_buffer.c - 串口环形缓冲区 */ #define UART_BUF_SIZE 2048 typedef struct { uint8_t buffer[UART_BUF_SIZE]; volatile uint32_t head; volatile uint32_t tail; } RingBuffer; static RingBuffer uartRxBuf; /* 串口中断服务函数中调用 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); uint32_t next = (uartRxBuf.head + 1) % UART_BUF_SIZE; if (next != uartRxBuf.tail) { /* 缓冲区未满 */ uartRxBuf.buffer[uartRxBuf.head] = data; uartRxBuf.head = next; } /* 满了就丢弃,靠YModem重传机制恢复 */ } } /* 从缓冲区取一个字节,返回0表示无数据 */ uint8_t UART_GetByte(uint8_t *pData) { if (uartRxBuf.head == uartRxBuf.tail) { return 0; } *pData = uartRxBuf.buffer[uartRxBuf.tail]; uartRxBuf.tail = (uartRxBuf.tail + 1) % UART_BUF_SIZE; return 1; }缓冲区大小2048字节是个经验值,够放两帧YModem数据(1024字节一帧),给处理留出余量。head和tail用volatile修饰,因为它们在中断和主循环里都会被访问。缓冲区满的时候直接丢弃新数据,靠YModem的重传机制来恢复,比在中断里做复杂处理要安全。
3.3 Flash写入的页对齐与擦除策略
F103的Flash写入有个硬性要求:写之前必须先擦除,擦除的最小单位是页(1KB或2KB)。而且写入地址必须是半字(2字节)对齐,不能随便写。YModem传过来的数据是连续流,但Flash操作得按页来。
/* flash_ops.c - Flash擦写操作 */ #include "stm32f10x_flash.h" /* 擦除APP区,从appAddr开始擦除numPages页 */ uint8_t Flash_EraseApp(uint32_t appAddr, uint32_t numPages) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (uint32_t i = 0; i < numPages; i++) { if (FLASH_ErasePage(appAddr + i * FLASH_PAGE_SIZE) != FLASH_COMPLETE) { FLASH_Lock(); return 1; /* 擦除失败 */ } } FLASH_Lock(); return 0; } /* 向APP区写入数据,dataLen必须是2的倍数 */ uint8_t Flash_WriteApp(uint32_t addr, uint8_t *data, uint32_t dataLen) { FLASH_Unlock(); for (uint32_t i = 0; i < dataLen; i += 2) { uint16_t halfWord = data[i] | (data[i + 1] << 8); if (FLASH_ProgramHalfWord(addr + i, halfWord) != FLASH_COMPLETE) { FLASH_Lock(); return 1; /* 写入失败 */ } } FLASH_Lock(); return 0; }擦除策略上,我一般会在接收第一帧数据之前,先把整个APP区擦干净。这样后面写入就不用边写边擦,逻辑简单,速度也快。代价是如果升级中途断电,APP区就是空的,Bootloader下次上电发现APP无效,会继续等升级——这反而是安全的。如果边写边擦,断电后APP区可能半新半旧,跳转过去行为不可预测。
3.4 YModem接收状态机
YModem接收是一个状态机,不能写成顺序流程。因为串口数据是异步到达的,你得在循环里不断查询缓冲区,根据当前状态决定下一步动作。
/* ymodem.c - YModem接收状态机核心逻辑 */ typedef enum { YM_IDLE, /* 空闲,等待开始 */ YM_RECEIVING, /* 正在接收数据帧 */ YM_FINISHED, /* 接收完成 */ YM_ERROR /* 出错 */ } YModemState; YModemState YModem_Receive(uint32_t appAddr) { YModemState state = YM_IDLE; uint8_t packet[1024]; uint32_t writeAddr = appAddr; /* 发送'C',请求发送方开始 */ UART_SendByte('C'); while (state != YM_FINISHED && state != YM_ERROR) { uint8_t byte; if (!UART_GetByte(&byte)) { continue; /* 无数据,继续等 */ } switch (state) { case YM_IDLE: if (byte == SOH) { /* 收到帧起始 */ state = YM_RECEIVING; } break; case YM_RECEIVING: /* 接收完整帧:序号+反码+数据+CRC */ if (ReceivePacket(packet) == 0) { /* 写入Flash */ if (Flash_WriteApp(writeAddr, packet, 1024) != 0) { state = YM_ERROR; break; } writeAddr += 1024; UART_SendByte(ACK); } else { UART_SendByte(NAK); /* 校验失败,请求重传 */ } break; } } return state; }状态机的关键是超时处理。如果发送方中途断开,接收方不能死等。常见做法是在UART_GetByte返回0时累加一个计数器,超过阈值就判定超时,回到IDLE状态重新发'C'。这个阈值根据波特率算,115200下大概等1秒左右比较合适。
4. 避坑与排查:跳转失败、数据错位、中断冲突的常见问题
4.1 跳转后直接HardFault
现象:Bootloader里调用JumpToApp后,程序没有跑进APP的main,而是进了HardFault_Handler。
原因:最常见的是APP的链接地址没改。Keil里默认链接到0x08000000,你把它烧到0x08004000,向量表里的复位向量还是指向0x08000000附近的地址,跳过去当然跑飞。其次是跳转前没关中断,SysTick中断还挂着,跳转后触发就死。
解决:在Keil的Target选项里,把IROM1的Start改成0x08004000,Size改成0xC000。同时在APP的main函数开头加上NVIC_SetVectorTable。跳转前用__disable_irq()关中断,跳转后再开。
4.2 YModem传输中途卡死
现象:SecureCRT发送bin文件,进度条走到一半不动了,Bootloader这边也没反应。
原因:串口接收缓冲区溢出。如果主循环处理Flash写入的时间太长,串口中断还在往里塞数据,缓冲区满了之后新数据被丢弃,YModem等不到ACK就超时重传,重传又丢,陷入死循环。
解决:把缓冲区加大到4096字节,或者优化Flash写入速度——F103的Flash编程大概几十微秒一个半字,1024字节一帧要写512个半字,算下来十几毫秒,115200波特率下1024字节传输约90毫秒,时间上是够的。如果还卡,检查是不是在中断里做了耗时操作。
4.3 APP单独烧录能跑,IAP升级后不跑
现象:用仿真器直接把APP烧到0x08004000,运行正常。但通过IAP升级同样的bin文件,跳转后没反应。
原因:IAP升级时写入的起始地址和APP链接地址不一致。比如APP链接在0x08004000,但Bootloader里APP_START_ADDR定义成了0x08008000,写进去的数据整体偏移了16KB,向量表对不上。
解决:把Bootloader里的APP_START_ADDR、APP的IROM1 Start、NVIC_SetVectorTable的偏移量三处对齐。建议在Bootloader里加一个打印,把实际写入的起始地址和收到的第一个字(栈顶值)打出来,和APP的map文件对照。
4.4 升级完成后跳转,APP里串口收不到数据
现象:IAP升级成功,跳转到APP后,APP的串口初始化了但收不到数据。
原因:Bootloader里初始化了串口,跳转前没有DeInit。APP里再次初始化时,串口的状态寄存器还残留着Bootloader的设置,导致波特率不对或者接收使能没打开。
解决:跳转前把所有用过的外设都DeInit掉,包括USART、TIM、DMA。如果APP里用的是同一个串口,DeInit之后重新Init一遍。另外注意RCC_APB2PeriphResetCmd也可以用来复位外设。
4.5 擦除时间过长导致看门狗复位
现象:Bootloader里开了独立看门狗,升级过程中看门狗超时,MCU复位,升级中断。
原因:擦除整个APP区(48KB,48页)需要的时间不短,F103擦一页大概20~40毫秒,48页就是1~2秒。如果看门狗超时设的是1秒,中途就复位了。
解决:在擦除循环里喂狗。或者升级期间先关掉看门狗,升级完成后再开。我一般会在Flash_EraseApp的循环里加IWDG_ReloadCounter(),每擦一页喂一次。
5. 进阶技巧:双区备份、升级标志位和CRC校验的工程化落地
5.1 用备份区实现“后悔药”
单区IAP有个致命问题:升级过程中断电,APP区被擦了一半,设备变砖。工程上常见的做法是划一个备份区,新固件先写到备份区,校验通过后再擦APP区、从备份区拷贝过去。这样即使拷贝过程中断电,备份区还在,下次上电可以重新拷贝。
F103C8T6只有64KB Flash,划备份区比较紧张。如果是RCT6这种256KB的,可以这么分:Bootloader 32KB,APP区 96KB,备份区 96KB,剩下32KB放参数。备份区的起始地址和大小在flash_partition.h里定义好,升级流程改成“先写备份区→校验→擦APP区→拷贝”。
5.2 升级标志位与参数存储
Bootloader怎么知道这次上电是要升级还是直接跳APP?靠一个标志位。这个标志位不能放在RAM里(掉电丢失),得放在Flash的某个固定位置。常见做法是在Bootloader区末尾划一页专门存参数。
/* upgrade_flag.c - 升级标志位管理 */ #define UPGRADE_FLAG_ADDR 0x08003C00 /* Bootloader区最后一页 */ #define UPGRADE_FLAG_MAGIC 0x5A5A5A5A /* 魔数,防止误判 */ typedef struct { uint32_t magic; /* 魔数,标识参数区有效 */ uint32_t upgradeFlag; /* 1=需要升级,0=正常启动 */ uint32_t appSize; /* APP固件大小 */ uint32_t appCRC; /* APP固件CRC32 */ } UpgradeParams; uint8_t CheckUpgradeFlag(void) { UpgradeParams *params = (UpgradeParams*)UPGRADE_FLAG_ADDR; if (params->magic != UPGRADE_FLAG_MAGIC) { return 0; /* 参数区无效,默认不升级 */ } return (params->upgradeFlag == 1) ? 1 : 0; }写入标志位之前要先擦除那一页,写入之后要校验。注意F103的Flash写之前必须擦,不能覆盖写。magic字段的作用是判断参数区是否被初始化过,如果第一次上电,Flash里是0xFF,magic对不上,就走默认逻辑。
5.3 CRC32校验:确保固件完整
YModem自带的CRC16只能校验单帧数据,整包固件的完整性得靠CRC32。发送方在传文件之前,先算好整个bin的CRC32,通过YModem的文件大小字段或者额外一帧传给Bootloader。Bootloader收完所有数据后,对APP区做一次CRC32计算,和收到的值比对,一致才置升级成功标志。
/* crc32.c - CRC32计算,用于固件完整性校验 */ uint32_t CRC32_Calculate(uint8_t *data, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 1) { crc = (crc >> 1) ^ 0xEDB88320; } else { crc >>= 1; } } } return crc ^ 0xFFFFFFFF; }这个CRC32用的是标准多项式0xEDB88320,和PC端工具算出来的一致。校验的时候注意,APP区实际固件大小可能小于分区大小,计算CRC时要按实际大小算,不能把整个分区都算进去——分区末尾可能是0xFF,算进去CRC对不上。
5.4 升级流程的完整状态流转
把上面这些串起来,一个工程化的升级流程是这样的:
| 步骤 | 操作 | 失败处理 |
|---|---|---|
| 1 | 上电,Bootloader检查升级标志位 | 标志无效则直接跳APP |
| 2 | 标志有效,擦除备份区 | 擦除失败则报错,跳APP |
| 3 | 通过YModem接收固件到备份区 | 超时或校验失败则重试,重试3次后跳APP |
| 4 | 接收完成,计算备份区固件CRC32 | CRC不匹配则擦备份区,跳APP |
| 5 | CRC通过,擦除APP区 | 擦除失败则报错,跳APP |
| 6 | 从备份区拷贝到APP区 | 拷贝失败则报错,跳APP |
| 7 | 校验APP区CRC32 | 不匹配则报错,跳APP |
| 8 | 清除升级标志位,跳转APP | — |
这个流程里,第3步的重试机制很重要。串口传输受干扰的概率不低,一次失败就放弃太脆弱。我一般设3次重试,每次重试前重新发'C',让发送方从头开始传。
5.5 一个容易忽略的细节:栈顶地址的合法性判断
前面跳转代码里有一句((*(__IO uint32_t*)appAddr) & 0x2FFE0000) == 0x20000000,这个判断不是随便写的。F103的SRAM范围是0x20000000~0x20004FFF(C8T6是20KB),栈顶一般设在SRAM末尾附近,所以高几位应该是0x2000xxxx。0x2FFE0000这个掩码就是用来提取高位的,如果APP区是空的(0xFFFFFFFF),与上掩码后不等于0x20000000,判断不通过,就不会跳转。
但这个判断有个边界:如果APP的栈顶设在了SRAM中间,比如0x20002000,与上掩码后还是0x20000000,能通过。如果栈顶设到了0x20005000(超出SRAM范围),与上掩码后是0x20000000,也能通过,但跳过去就HardFault。所以这个判断只能挡住“APP区为空”的情况,不能完全保证栈顶合法。更严格的做法是判断栈顶是否在0x20000000 + SRAM_SIZE范围内,SRAM_SIZE根据具体型号定。
从那以后我每次做IAP,都会在跳转前把APP区的头两个字打出来看一眼——第一个字是栈顶,第二个字是复位向量。栈顶落在SRAM范围内、复位向量落在APP区范围内,这两个条件同时满足才跳。这个习惯帮我省掉了不少“明明升级成功了但就是跑不起来”的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取