尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32F103C8T6串口IAP实战:Flash分区、VTOR重映射与协议设计

STM32F103C8T6串口IAP实战:Flash分区、VTOR重映射与协议设计 1. 为什么要在STM32F103C8T6上折腾串口IAPSTM32F103C8T6这颗芯片玩嵌入式的朋友基本都绕不开。72MHz主频、64KB Flash、20KB SRAM蓝色药丸最小系统板十块钱出头就能拿下性价比高到离谱。但很多人用它做项目时会遇到一个很现实的问题板子装在设备内部、外壳封死、调试口没引出来固件有bug要改怎么办总不能每次都拆壳、接SWD、重新烧录吧。串口IAP就是解决这个痛点的。IAP全称In-Application Programming翻译过来叫“在应用编程”核心思路是让单片机自己具备“给自己刷固件”的能力。你把新固件通过串口发给它它自己擦写Flash、跳转执行全程不需要仿真器。这个方案在工业现场、远程设备维护、量产烧录等场景里非常实用。我拿STM32F103C8T6做串口IAP前后折腾了好几版踩过的坑包括但不限于中断向量表没重映射导致跳转后跑飞、Flash擦除地址算错把Bootloader自己擦了、串口接收丢包导致固件校验失败、APP里开了中断但Bootloader没关干净导致HardFault。这些问题在网上的教程里往往一笔带过但实际调试时能耗掉你一整天。这篇文章面向的是有STM32基础、会用Keil或STM32CubeIDE、能看懂基本寄存器操作的嵌入式开发者。我会从Flash分区规划开始一步步拆解Bootloader的设计逻辑、APP的改造要点、串口传输协议的设计、完整的代码实现以及那些只有实际动手才会遇到的坑。代码基于标准外设库但思路对HAL库同样适用。2. Flash分区规划与Bootloader设计思路2.1 STM32F103C8T6的Flash结构你得先摸清楚STM32F103C8T6的64KB Flash在物理上是一整块但操作时按页Page管理每页1KB总共64页。这一点很关键因为擦除操作的最小单位就是页你没法只擦512字节。写操作倒是可以按半字16位或字32位来但前提是目标区域已经被擦除过内容为0xFF。地址范围从0x08000000到0x0800FFFF。系统存储器System Memory在0x1FFFF000附近里面固化了ST官方的Bootloader支持串口下载但那个是出厂自带的我们做IAP用的是用户Flash区域。分区方案我推荐这样切区域起始地址大小用途Bootloader0x0800000012KB存放IAP引导程序APP0x0800300048KB存放用户应用程序参数区0x0800F0004KB存放升级标志、固件信息等为什么Bootloader给12KB而不是更小因为你要在里面放串口驱动、Flash擦写函数、通信协议解析、可能还有校验算法比如CRC32。我试过压缩到8KB功能一多就捉襟见肘。12KB留了余量编译出来大概7-8KB剩下的空间以后加功能也不慌。APP从0x08003000开始48KB对于F103C8T6来说够用了。参数区放在最后4KB用来存升级请求标志、固件长度、CRC校验值这些元数据。这样Bootloader启动时先读参数区判断是正常启动还是进入升级模式。注意分区边界一定要按1KB对齐因为擦除是按页来的。0x08003000是12KB的整数倍0x0800F000是60KB的整数倍都满足对齐要求。2.2 Bootloader到底该干什么Bootloader的本质是一个“永远不被覆盖的小程序”它的职责链条是这样的系统上电或复位先跑Bootloader检查参数区是否有升级请求标志如果有升级请求进入串口接收模式等待上位机发送固件接收完成后校验固件完整性长度、CRC校验通过则写入APP区清除升级标志跳转到APP如果没有升级请求直接跳转到APP这里有个关键设计决策升级请求标志怎么设置常见做法有两种。一种是在APP里收到升级命令后主动写参数区标志然后软复位另一种是Bootloader启动时检测某个GPIO按键按住就进升级模式。我两种都实现了实际用下来按键方式更适合现场调试APP触发方式更适合远程升级。跳转到APP的核心操作就三件事关闭所有中断、设置主栈指针MSP、跳转到复位向量。代码大概长这样typedef void (*pFunction)(void); pFunction Jump_To_Application; uint32_t JumpAddress; /* 检查APP区栈顶地址是否合法 */ if (((*(__IO uint32_t*)APP_ADDR) 0x2FFE0000) 0x20000000) { JumpAddress *(__IO uint32_t*)(APP_ADDR 4); Jump_To_Application (pFunction)JumpAddress; __set_MSP(*(__IO uint32_t*)APP_ADDR); Jump_To_Application(); }那个栈顶地址检查很关键。APP区的第一个字是栈顶地址F103C8T6的SRAM从0x20000000开始大小20KB所以栈顶地址一定在0x20000000到0x20005000之间。用0x2FFE0000做掩码就是判断高两位是不是0x2000开头。如果APP区是空的0xFFFFFFFF这个检查就会失败避免跳转到无效地址导致HardFault。2.3 中断向量表重映射最容易翻车的地方APP从0x08003000开始但STM32默认从0x08000000取中断向量表。如果不做重映射APP里任何一个中断触发CPU都会跑到Bootloader的向量表里去结果就是跑飞或者HardFault。解决方法是在APP的main函数开头尽早设置向量表偏移寄存器SCB-VTORSCB-VTOR APP_ADDR;这行代码必须在任何中断使能之前执行。我一般放在SystemInit()之后、main()的第一行。用标准库的话也可以在system_stm32f10x.c里改VECT_TAB_OFFSET但那样不灵活还是直接在APP里设VTOR更直观。实操心得如果你在APP里用了FreeRTOSVTOR设置必须在osKernelStart()之前。我见过有人在任务里才设VTOR结果SVC中断和PendSV中断全跑错了地方系统直接卡死。3. 串口传输协议设计与上位机配合3.1 为什么不能直接裸发bin文件最简单的IAP就是串口收到什么就往Flash写什么但实际项目中这么干风险很大。串口传输可能丢字节、可能被干扰、上位机可能发错文件。没有协议保护的话写进去的固件是坏的设备直接变砖。我设计的协议包结构是这样的字段长度说明帧头2字节0xAA 0x55命令1字节0x01升级请求 0x02数据包 0x03结束长度2字节数据段长度数据N字节实际固件数据CRC162字节从命令到数据的校验每包数据最大256字节因为F103C8T6的SRAM只有20KBBootloader里还要留缓冲区太大容易爆栈。256字节一包48KB固件大概192包115200波特率下传输时间大概十几秒可以接受。CRC16用查表法实现比逐位计算快很多。多项式用0x1021CCITT初始值0xFFFF。这个校验能挡住绝大多数传输错误。3.2 上位机端怎么做上位机我用Python写了一个简单的脚本用pyserial库。核心流程是打开串口、发送升级请求、等待Bootloader应答、按包发送固件数据、每包等ACK、发完后发结束命令、等待校验结果。import serial import struct import time def crc16(data): crc 0xFFFF for byte in data: crc ^ byte 8 for _ in range(8): if crc 0x8000: crc (crc 1) ^ 0x1021 else: crc 1 crc 0xFFFF return crc def send_packet(ser, cmd, payloadb): header b\xAA\x55 length len(payload) frame header struct.pack(BH, cmd, length) payload crc crc16(frame[2:]) frame struct.pack(H, crc) ser.write(frame) # 等待ACK ack ser.read(1) return ack b\x06上位机每发一包等一个ACK超时就重发重发三次还失败就报错退出。这个机制在实测中很稳偶尔丢一包也能自动补上。注意Python的struct.pack(BH)是小端模式STM32也是小端所以直接对应。如果你用大端的上位机工具字节序要反过来。3.3 Bootloader端的接收状态机Bootloader的串口接收我用的是中断环形缓冲区的方式。每收到一个字节进中断存到缓冲区主循环里解析。状态机有这几个状态等待帧头、等待命令、等待长度、接收数据、校验CRC。为什么不直接在中断里解析因为Flash擦写操作耗时较长页擦除大概20-40ms在中断里做会阻塞其他中断串口容易丢数据。所以中断只负责收主循环负责处理。环形缓冲区大小我设的512字节够存两包数据。如果主循环处理不过来导致缓冲区满就丢弃最旧的数据并置错误标志上位机超时后会重发。4. APP工程的改造与固件生成4.1 Keil工程需要改哪些地方APP工程不能按默认配置编译否则生成的固件地址从0x08000000开始和Bootloader冲突。在Keil里要改两个地方第一Target选项卡里的IROM1起始地址改成0x08003000大小改成0xC00048KB。这样链接器就知道代码从0x08003000开始放。第二在main函数最前面加VTOR设置。标准库的话SystemInit()里默认会把VTOR设成0x08000000你可以在SystemInit()之后覆盖它。int main(void) { SCB-VTOR 0x08003000; // 其他初始化... }如果用STM32CubeMX生成的HAL库工程VTOR设置可以放在HAL_Init()之后、SystemClock_Config()之前。HAL库的HAL_Init()里会调SystemInit()所以要在它之后改。4.2 生成bin文件而不是hexIAP传输的是纯二进制bin文件不是hex。hex文件带地址信息Bootloader解析起来麻烦。Keil里生成bin文件需要加一个User Commandfromelf --bin --output.\Objects\app.bin .\Objects\app.axf在Options for Target - User - After Build/Rebuild里填这行命令。编译完自动生成bin文件大小就是实际代码占用的Flash空间。实操心得bin文件大小最好在发送前检查一下如果超过APP区大小48KBBootloader应该拒绝接收并报错。我见过有人APP编译出来52KB还硬发结果写到一半地址越界把参数区擦了设备直接变砖。4.3 固件版本管理和回滚思路实际项目中升级失败要能回滚。我的做法是在参数区存两个APP区主APP区和备份APP区。但F103C8T6的Flash只有64KB分不出两个48KB。所以退而求其次升级前先把当前APP的版本号和CRC存到参数区升级后如果新固件启动失败比如看门狗复位次数超限Bootloader可以尝试恢复。更简单的方案是升级时先擦除备份区如果空间够把当前APP复制过去再写新APP。但64KB实在紧张这个方案只适合Flash更大的型号。F103C8T6上我一般就做个简单的“升级失败则停留在Bootloader等待重传”的逻辑不搞复杂回滚。5. 完整代码实现与关键函数解析5.1 Flash擦写函数标准库的Flash操作函数在stm32f10x_flash.c里但直接调用要注意解锁和锁定的配对。我封装了两个函数#define APP_ADDR 0x08003000 #define APP_END 0x0800F000 #define PAGE_SIZE 1024 uint8_t Flash_Erase(uint32_t start_addr, uint32_t end_addr) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); uint32_t addr start_addr; while (addr end_addr) { if (FLASH_ErasePage(addr) ! FLASH_COMPLETE) { FLASH_Lock(); return 1; // 失败 } addr PAGE_SIZE; } FLASH_Lock(); return 0; } uint8_t Flash_Write(uint32_t addr, uint8_t *data, uint32_t len) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (uint32_t i 0; i len; i 2) { uint16_t halfword data[i] | (data[i1] 8); if (FLASH_ProgramHalfWord(addr i, halfword) ! FLASH_COMPLETE) { FLASH_Lock(); return 1; } } FLASH_Lock(); return 0; }擦除前一定要先解锁擦完立刻锁定防止误操作。FLASH_ClearFlag那行是清错误标志如果之前有写保护错误没清后续操作会一直失败。注意FLASH_ProgramHalfWord要求目标地址是2字节对齐数据长度也得是偶数。如果上位机发来的最后一包是奇数长度要在后面补一个0xFF凑成偶数再写。5.2 串口接收中断与环形缓冲区#define RX_BUF_SIZE 512 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_head 0; volatile uint16_t rx_tail 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); uint16_t next (rx_head 1) % RX_BUF_SIZE; if (next ! rx_tail) { rx_buf[rx_head] data; rx_head next; } // 缓冲区满则丢弃 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } uint16_t RingBuf_Available(void) { return (rx_head - rx_tail RX_BUF_SIZE) % RX_BUF_SIZE; } uint8_t RingBuf_Read(void) { if (rx_head rx_tail) return 0; uint8_t data rx_buf[rx_tail]; rx_tail (rx_tail 1) % RX_BUF_SIZE; return data; }这个环形缓冲区实现很经典head和tail都是无符号16位用取模运算处理回绕。判断空的条件是head tail判断满的条件是(next tail)。中断里只写head主循环只读tail不需要关中断保护。5.3 协议解析状态机typedef enum { STATE_IDLE, STATE_HEADER1, STATE_HEADER2, STATE_CMD, STATE_LEN_L, STATE_LEN_H, STATE_DATA, STATE_CRC_L, STATE_CRC_H } ParseState; void Parse_Byte(uint8_t byte) { static ParseState state STATE_IDLE; static uint8_t cmd, data[256]; static uint16_t len, idx, crc_recv; switch (state) { case STATE_IDLE: if (byte 0xAA) state STATE_HEADER1; break; case STATE_HEADER1: state (byte 0x55) ? STATE_CMD : STATE_IDLE; break; case STATE_CMD: cmd byte; state STATE_LEN_L; break; case STATE_LEN_L: len byte; state STATE_LEN_H; break; case STATE_LEN_H: len | (byte 8); idx 0; state (len 0) ? STATE_DATA : STATE_CRC_L; break; case STATE_DATA: data[idx] byte; if (idx len) state STATE_CRC_L; break; case STATE_CRC_L: crc_recv byte; state STATE_CRC_H; break; case STATE_CRC_H: crc_recv | (byte 8); // 校验并处理 Process_Packet(cmd, data, len, crc_recv); state STATE_IDLE; break; } }状态机每收到一个字节推进一步不阻塞主循环。Process_Packet里根据命令类型做不同处理升级请求就擦除APP区并回ACK数据包就写入Flash并回ACK结束命令就校验整个固件并跳转。6. 常见问题排查与避坑经验6.1 跳转后跑飞或HardFault这是最高频的问题。原因通常有三个VTOR没设对、MSP没设对、中断没关干净。排查顺序是先用调试器看APP入口的汇编确认第一条指令地址是不是0x080030004对应的复位向量。然后在Bootloader跳转前加一句__disable_irq()跳转后在APP的main里再__enable_irq()。还有一个隐蔽的坑Bootloader里开了串口中断跳转前只关了总中断但没清NVIC的使能位。APP里如果用了同一个中断号可能会触发一次意外的中断。稳妥做法是跳转前把NVIC的ICER寄存器全写0彻底关掉所有中断使能。6.2 串口接收丢数据115200波特率下如果Bootloader主循环里有耗时操作比如Flash擦除串口中断可能来不及响应。F103的USART只有1字节接收缓冲中断响应慢了就溢出。解决办法是提高中断优先级把USART1_IRQn设成最高抢占优先级0Flash操作期间中断照样能进。如果还是丢就降低波特率到57600或38400。实测57600下基本不丢包传输时间翻倍但稳定性好很多。6.3 固件校验失败CRC校验失败通常是传输错误或Flash写入错误。先检查上位机发的bin文件CRC和Bootloader算的是否一致。如果上位机算的对但Bootloader算的不对可能是接收过程中丢了字节。可以在每包数据里加个包序号Bootloader检查序号连续性断了就要求重传。Flash写入错误的话检查目标地址是否已经擦除。STM32的Flash只能把1写成0不能把0写成1。如果没擦就写写入的数据会是原值和新值的按位与结果肯定不对。6.4 常见问题速查表现象可能原因排查方法跳转后无反应VTOR未设置检查APP的SCB-VTOR值HardFaultMSP未设置检查APP区第一个字是否在0x20000000-0x20005000串口丢包中断优先级低提高USART中断抢占优先级CRC校验失败传输错误加包序号和重传机制Flash写入失败未擦除或写保护检查FLASH_CR寄存器的PER和PG位APP运行异常中断向量错位确认APP编译地址和VTOR一致实操心得调试IAP时先在RAM里跑通跳转逻辑再放到Flash里。RAM调试可以随便改不用反复擦Flash效率高很多。具体做法是在Keil的Target里把IROM1去掉只留IRAM1然后设置调试器下载到RAM。跳转目标也改成RAM地址。等逻辑通了再切回Flash。7. 进阶优化与量产考虑7.1 加入固件加密量产产品不希望固件被随便读出来。F103C8T6支持读保护RDP设置RDP Level 1后通过调试口读Flash会返回全0。但开了读保护后IAP自己写Flash也会受影响需要在Bootloader里先解除保护、写完后重新使能。这个操作有风险搞不好会把芯片锁死建议先在废板上试。更轻量的方案是固件本身加密Bootloader收到后解密再写。AES128在F103上跑得动但会增加Bootloader体积。如果只是防君子不防小人做个简单的异或加密就够了。7.2 双串口备份升级工业现场串口线可能松动单串口升级失败率不低。有条件的话用两个串口主串口失败自动切备用串口。或者用CAN总线升级抗干扰能力比串口强很多。F103C8T6自带CAN控制器加个收发器就能用。7.3 升级超时与看门狗Bootloader等待升级请求要有超时不能无限等。我设的是上电后3秒内没收到升级请求就跳APP。等待期间喂独立看门狗IWDG防止Bootloader自身死机。跳转前记得关看门狗否则APP里没及时喂狗会不断复位。APP里也要处理升级标志的清除。如果APP启动后确认运行正常应该主动清除参数区的升级请求标志防止下次上电又进Bootloader。这个清除操作放在APP的main函数里延时几秒后执行确保系统稳定了再清。7.4 批量生产时的烧录策略产线上先用SWD烧Bootloader然后通过串口批量烧APP。Bootloader里可以加个命令收到后返回芯片唯一ID和当前固件版本上位机记录到数据库。这样每台设备的固件版本可追溯售后维护时能快速判断是否需要升级。Bootloader本身也要留升级通道。我的做法是Bootloader里包含一个“自升级”命令收到后把新Bootloader写到参数区旁边的保留页然后跳转到保留页执行由保留页把新Bootloader写到0x08000000。这个过程比较复杂量不大的话直接返厂用SWD重烧更省事。整体调下来STM32F103C8T6的串口IAP并不复杂核心就是Flash分区、VTOR重映射、串口协议这三块。代码量不大Bootloader大概500行左右APP改动就两行。但细节坑很多尤其是中断和跳转部分建议先在最小系统板上把流程跑通再往实际项目里移植。我前后做了三版第一版跳转就HardFault第二版串口丢包严重第三版才稳定下来。把这些问题都踩一遍之后后面再做其他型号的IAP就轻车熟路了。
返回列表