尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32 BootLoader实战:从内存规划到空中升级的完整实现

STM32 BootLoader实战:从内存规划到空中升级的完整实现 1. 项目概述为什么我们需要一个BootLoader如果你玩过STM32肯定遇到过这样的场景产品已经焊在板子上、装在壳子里了突然发现程序有个bug需要修复或者要增加一个新功能。这时候难道要把产品拆开重新接上ST-Link或者J-Link下载器来烧录程序吗对于量产产品或者部署在偏远地区的设备这几乎是不可能的。BootLoader或者说IAPIn-Application Programming功能就是为了解决这个痛点而生的。它本质上是一段预先烧录在单片机Flash存储器开头的小程序它的核心任务不是实现业务功能而是管理应用程序的更新。当我们需要升级设备时可以通过串口、CAN、USB、甚至网络等通信接口将新的应用程序二进制文件发送给BootLoader由它负责擦除旧的程序区域并将新的程序写入到指定的Flash地址。完成后BootLoader再跳转到新程序的入口地址设备就完成了“空中升级”。网上关于STM32 BootLoader的教程很多但很多要么讲得太浅只给个代码框架要么过于复杂引入了Ymodem、Xmodem等协议让初学者望而却步。这次我带来的这份源码和解析目标是做一个“实用主义”的BootLoader。它基于最常用的串口通信协议简单到极致但包含了所有关键环节内存规划、跳转逻辑、Flash编程、以及最重要的——失败恢复机制。我会把每一步的原理、代码实现、以及我踩过的坑都掰开揉碎了讲清楚让你不仅能跑通更能理解背后的“为什么”最终能把它修改、适配到你自己的项目中去。2. 整体设计与内存规划思路在动手写代码之前最重要的一步是规划好单片机的Flash内存空间。STM32的Flash是线性地址空间我们的BootLoader和应用程序APP都要存放在这里必须井水不犯河水否则一上电就会跑飞。2.1 Flash地址空间划分以最常见的STM32F103C8T6为例它拥有64KB的Flash地址从0x0800 0000开始。我们需要把它分成两部分BootLoader区存放BootLoader程序本身。它需要一直存在通常不会被更新。应用程序APP区存放我们真正的功能程序。这部分是需要通过BootLoader来更新的。如何划分这里没有绝对标准但有几个原则BootLoader大小先估算你的BootLoader代码编译后有多大。一个基础的、带串口通信和Flash擦写的BootLoader在优化等级-O2下通常不会超过10KB。为了预留扩展空间比如未来增加CRC校验、更多通信协议我们可以分配16KB0x4000字节。对齐要求STM32的Flash擦除操作是以“页”Page或“扇区”Sector为单位的。对于F103每页是1KB。因此我们的分区地址最好从页的起始地址开始方便管理。中断向量表偏移这是最关键的一点APP程序的中断向量表默认在0x0800 0000。但现在这个地址放的是BootLoader所以APP的中断向量表必须进行偏移。基于以上一个合理的划分方案如下BootLoader 起始地址0x0800 0000 单片机启动地址BootLoader 结束地址0x0800 3FFF 共16KBAPP 起始地址0x0800 4000 紧接着BootLoader并且是4KB的整数倍方便某些系列芯片的扇区对齐APP 结束地址0x0800 FFFF 芯片Flash的末尾这个划分需要在两个地方体现在BootLoader工程的链接脚本.ld文件或Keil的Target Options中设置程序的ROM起始地址为0x08000000大小设为0x4000。在APP工程的链接脚本和配置中设置程序的ROM起始地址为0x08004000大小设为0xC000即剩下的48KB。同时必须在代码中通常是system_stm32f1xx.c或类似的系统初始化文件里设置中断向量表偏移量SCB-VTOR FLASH_BASE | 0x4000;。注意不同的STM32系列F1 F4 H7等和不同容量的芯片其Flash扇区大小和分布可能完全不同。例如F407的扇区可能高达128KB。在规划前务必查阅你所用芯片的《参考手册》中的Flash章节。2.2 通信协议与流程设计为了简单实用我们设计一个极简的串口协议不依赖复杂的Ymodem而是采用“命令数据”的帧格式。通信流程如下上电/复位MCU首先运行BootLoader。等待命令BootLoader启动后初始化串口然后等待一段时间比如3秒。如果在等待期间收到特定的“升级命令”例如字符‘U’则进入固件接收模式。如果超时未收到命令则直接跳转到APP。进入升级模式BootLoader发送“准备就绪”信号。接收固件信息PC端上位机首先发送固件大小4字节和CRC校验码4字节。BootLoader收到后检查目标APP区域是否足够并回复确认。接收固件数据PC端开始将APP的bin文件分块发送。每收到一包数据例如256字节BootLoader将其写入Flash并计算本包的CRC。同时可以回复ACK进行流控制。校验与跳转全部数据接收并写入完毕后BootLoader计算整个APP区域的CRC与步骤4收到的校验码对比。如果一致则向PC发送“成功”信号然后执行软复位或直接跳转到APP起始地址。如果不一致则发送“失败”信号并等待重新接收。为什么选择CRC校验而不是简单的累加和Flash在编程过程中可能受到电源噪声干扰导致个别位错误。CRC循环冗余校验的检错能力远强于累加和能有效确保写入数据的完整性避免因一个比特错误导致整个设备“变砖”。3. BootLoader核心代码实现与解析接下来我们深入到代码层面看看各个核心模块如何实现。3.1 跳转函数从BootLoader到APP这是BootLoader的“临门一脚”也是最容易出错的地方。跳转的本质是修改PC程序计数器指针到APP的起始地址并重置堆栈指针。// 定义APP的起始地址根据你的内存规划修改 #define APP_ADDRESS 0x08004000 typedef void (*pFunction)(void); pFunction Jump_To_Application; void JumpToApp(void) { uint32_t jumpAddress; pFunction jumpToApp; // 1. 检查APP地址是否有效检查栈顶指针 // APP起始地址的第一个字存放的是栈顶指针MSP初始值 if (((*(__IO uint32_t*)APP_ADDRESS) 0x2FFE0000) 0x20000000) { // 2. 关闭所有开启的中断防止跳转过程中中断触发导致异常 __disable_irq(); // 3. 将SysTick定时器复位避免其中断在APP中意外触发 SysTick-CTRL 0; SysTick-VAL 0; SysTick-LOAD 0; // 4. 重新设置中断向量表偏移可选APP中会再次设置但这里清理现场更安全 SCB-VTOR APP_ADDRESS; // 5. 设置主堆栈指针MSP为APP区的栈顶值 __set_MSP(*(__IO uint32_t*)APP_ADDRESS); // 6. 获取APP的复位中断服务程序地址并跳转 // APP起始地址的第二个字存放的是复位中断向量Reset_Handler的地址 jumpAddress *(__IO uint32_t*)(APP_ADDRESS 4); jumpToApp (pFunction)jumpAddress; // 7. 跳转前初始化APP的堆栈指针 __set_MSP(*(__IO uint32_t*)APP_ADDRESS); // 跳转到APP的Reset_Handler jumpToApp(); } else { // APP地址无效可能是没有烧录程序可以在此处点亮错误灯或循环 Error_Handler(); } }关键点解析__disable_irq()至关重要如果BootLoader开启了全局中断或某些外设定时器中断跳转时必须关闭。否则跳转后APP的中断向量表还未正确设置此时发生中断CPU会跑到错误的中断服务程序地址导致硬件错误HardFault。SysTick复位SysTick是Cortex-M内核的定时器BootLoader可能用它做延时。如果不复位它可能在APP初始化前就产生中断同样引发HardFault。检查栈顶值一个简单的有效性检查。STM32的RAM地址通常从0x2000 0000开始所以APP栈顶指针的高位应该是0x2000。这不是绝对可靠的但能过滤掉明显错误如Flash全为0xFF或0x00。3.2 Flash编程驱动BootLoader需要擦除旧的APP并写入新的APP数据。这需要直接操作Flash存储器的寄存器。不同系列的STM32其Flash操作库函数或寄存器略有不同但流程一致解锁 - 擦除 - 编程 - 上锁。以下是基于STM32标准外设库StdLib的示例#include “stm32f1xx_flash.h” // 根据你的芯片系列包含对应头文件 // 假设APP区域从 APP_START_ADDR 开始大小为 APP_SIZE #define APP_START_ADDR 0x08004000 #define FLASH_PAGE_SIZE 1024 // F103的页大小是1KB uint8_t Flash_EraseAppArea(void) { FLASH_Status status FLASH_COMPLETE; uint32_t pageError 0; uint32_t startPage 0; uint32_t endPage 0; // 1. 计算需要擦除的起始页和结束页 startPage (APP_START_ADDR - FLASH_BASE) / FLASH_PAGE_SIZE; endPage startPage (APP_SIZE FLASH_PAGE_SIZE - 1) / FLASH_PAGE_SIZE - 1; // 2. 解锁Flash FLASH_Unlock(); // 3. 清除所有挂起的标志位可选但建议做 FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); // 4. 按页擦除 for(uint32_t page startPage; page endPage; page) { status FLASH_ErasePage(FLASH_BASE (page * FLASH_PAGE_SIZE)); if(status ! FLASH_COMPLETE) { // 擦除失败 FLASH_Lock(); return 1; // 返回错误 } } // 5. 重新上锁Flash FLASH_Lock(); return 0; // 成功 } uint8_t Flash_WriteData(uint32_t address, uint8_t *data, uint16_t length) { FLASH_Status status FLASH_COMPLETE; uint16_t *pData (uint16_t*)data; // Flash编程以半字16位为单位 uint32_t writeAddr address; // 检查地址是否对齐到半字2字节 if(address 0x01) return 1; // 检查长度是否为偶数 if(length 0x01) return 2; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for(uint16_t i 0; i length; i 2) { status FLASH_ProgramHalfWord(writeAddr, *pData); if(status ! FLASH_COMPLETE) { FLASH_Lock(); return 3; // 编程失败 } writeAddr 2; pData; } FLASH_Lock(); return 0; // 成功 }实操心得在循环擦除或编程时务必在每次操作后检查状态而不是全部做完再检查。因为一旦某次操作失败后续的继续操作很可能没有意义甚至破坏Flash内容。早期版本的我就是吃了这个亏擦除失败后还继续写数据导致芯片彻底无法启动只能通过下载器全片擦除才能恢复。3.3 串口通信与协议解析BootLoader通过串口与上位机通信。我们需要实现一个简单的命令解析器和数据接收状态机。typedef enum { CMD_IDLE, // 空闲状态等待‘U’命令 CMD_RECEIVING_SIZE, // 接收固件大小 CMD_RECEIVING_CRC, // 接收CRC校验值 CMD_RECEIVING_DATA // 接收固件数据 } Bootloader_State; Bootloader_State bl_state CMD_IDLE; uint32_t expectedFirmwareSize 0; uint32_t expectedCRC 0; uint32_t receivedDataLength 0; uint32_t currentWriteAddress APP_START_ADDR; // 假设串口接收中断服务函数 void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t rxData USART_ReceiveData(USART1); switch(bl_state) { case CMD_IDLE: if(rxData ‘U’) // 收到升级命令 { // 发送确认进入接收大小状态 USART_SendString(“READY\n”); bl_state CMD_RECEIVING_SIZE; sizeRecvIndex 0; } break; case CMD_RECEIVING_SIZE: // 假设上位机先发低字节。将4个字节组合成32位整数 *(((uint8_t*)expectedFirmwareSize) sizeRecvIndex) rxData; sizeRecvIndex; if(sizeRecvIndex 4) { // 收到完整大小可以检查Flash空间是否足够 if(expectedFirmwareSize MAX_APP_SIZE) { USART_SendString(“SIZE_ERR\n”); bl_state CMD_IDLE; } else { USART_SendString(“SIZE_OK\n”); bl_state CMD_RECEIVING_CRC; crcRecvIndex 0; } } break; case CMD_RECEIVING_CRC: // 接收4字节CRC *(((uint8_t*)expectedCRC) crcRecvIndex) rxData; crcRecvIndex; if(crcRecvIndex 4) { USART_SendString(“CRC_OK\n”); // 擦除APP区域 if(Flash_EraseAppArea() 0) { USART_SendString(“ERASE_OK\n”); bl_state CMD_RECEIVING_DATA; receivedDataLength 0; currentWriteAddress APP_START_ADDR; } else { USART_SendString(“ERASE_ERR\n”); bl_state CMD_IDLE; } } break; case CMD_RECEIVING_DATA: // 将数据存入缓存集满一个编程块如256字节再写入Flash提高效率 dataBuffer[dataBufferIndex] rxData; receivedDataLength; if(dataBufferIndex DATA_BLOCK_SIZE) { // 写入一个块到Flash if(Flash_WriteData(currentWriteAddress, dataBuffer, DATA_BLOCK_SIZE) ! 0) { USART_SendString(“WRITE_ERR\n”); bl_state CMD_IDLE; return; } currentWriteAddress DATA_BLOCK_SIZE; dataBufferIndex 0; // 每写成功一个块回复一个ACK用于流控 USART_SendByte(0x06); // ACK } // 判断是否接收完毕 if(receivedDataLength expectedFirmwareSize) { // 将缓存中剩余的数据写入Flash if(dataBufferIndex 0) { Flash_WriteData(currentWriteAddress, dataBuffer, dataBufferIndex); } // 计算整个APP区域的CRC uint32_t calcCRC Calculate_CRC(APP_START_ADDR, expectedFirmwareSize); if(calcCRC expectedCRC) { USART_SendString(“SUCCESS\n”); // 延时一小会儿确保串口数据发送完毕 Delay_ms(100); // 跳转到APP JumpToApp(); } else { USART_SendString(“CRC_MISMATCH\n”); bl_state CMD_IDLE; } } break; } } }这个状态机清晰地定义了BootLoader与上位机对话的每一步逻辑严密易于调试和扩展。4. 应用程序APP的适配与配置BootLoader写好了如果你的APP不做任何修改跳转过去大概率会失败。APP需要配合BootLoader进行两项关键配置。4.1 修改中断向量表偏移VTOR这是让APP中断能正确响应的核心。必须在APP启动后、初始化任何可能产生中断的外设特别是SysTick之前重新设置向量表偏移寄存器VTOR。对于使用标准库的工程通常在system_stm32f1xx.c文件的SystemInit()函数末尾添加// 在SystemInit()函数内部调用完 SetSysClock(); 之后 #ifdef VECT_TAB_SRAM SCB-VTOR SRAM_BASE | VECT_TAB_OFFSET; #else // 将中断向量表重定位到 0x08004000 SCB-VTOR FLASH_BASE | 0x4000; #endif对于HAL库工程可以在main.c的main()函数开头HAL_Init()之后添加int main(void) { HAL_Init(); // 设置中断向量表偏移 SCB-VTOR FLASH_BASE | 0x4000; SystemClock_Config(); // ... 其他初始化 }4.2 修改工程链接地址以Keil MDK为例点击Options for Target-Target选项卡。将IROM1的Start改为0x08004000Size改为你分配的APP空间大小如0xC000。确保Use MicroLIB如果之前勾选了继续保持勾选否则可能因标准库的初始化代码导致问题。生成正确的Bin文件 在Options for Target-User选项卡在After Build/Rebuild部分勾选Run #1并填入生成bin文件的命令例如fromelf --bin --outputL.bin !L这样编译后会在工程目录下生成一个.bin文件这个文件就是我们要通过串口发送给BootLoader的固件。5. 上位机软件与文件传输BootLoader需要一个“搭档”——上位机程序负责读取bin文件按照协议发送给单片机。你可以用任何语言编写如C#、Python、Qt。这里给出一个Python的简单示例展示核心逻辑import serial import time import struct import zlib # 用于计算CRC32 def send_firmware(port, baudrate, bin_file_path): try: ser serial.Serial(port, baudrate, timeout2) except: print(f“无法打开串口 {port}”) return False with open(bin_file_path, ‘rb’) as f: firmware_data f.read() firmware_size len(firmware_data) # 计算整个固件数据的CRC32 firmware_crc zlib.crc32(firmware_data) 0xFFFFFFFF print(f“固件大小: {firmware_size} 字节, CRC32: 0x{firmware_crc:08X}”) # 1. 发送升级命令 ‘U’ ser.write(b‘U’) response ser.readline().decode(‘ascii’).strip() if response ! “READY”: print(“BootLoader未就绪”) ser.close() return False # 2. 发送固件大小 (4字节小端格式) ser.write(struct.pack(‘I’, firmware_size)) # ‘I’ 表示小端无符号32位整数 response ser.readline().decode(‘ascii’).strip() if response ! “SIZE_OK”: print(“BootLoader报告固件大小错误”) ser.close() return False # 3. 发送CRC32值 (4字节小端格式) ser.write(struct.pack(‘I’, firmware_crc)) response ser.readline().decode(‘ascii’).strip() if response ! “CRC_OK”: print(“BootLoader报告CRC错误”) ser.close() return False # 4. 等待擦除完成 response ser.readline().decode(‘ascii’).strip() if response ! “ERASE_OK”: print(“Flash擦除失败”) ser.close() return False # 5. 分块发送固件数据 block_size 256 total_sent 0 for i in range(0, firmware_size, block_size): block firmware_data[i:iblock_size] ser.write(block) total_sent len(block) # 等待BootLoader的ACK (0x06) ack ser.read(1) if ack ! b‘\x06’: print(f“在字节 {total_sent} 处接收ACK失败”) ser.close() return False # 打印进度 progress (total_sent / firmware_size) * 100 print(f“\r进度: {progress:.1f}%”, end“”) print(“\n发送完成等待最终校验...”) # 6. 等待最终结果 response ser.readline().decode(‘ascii’).strip() ser.close() if response “SUCCESS”: print(“固件升级成功”) return True else: print(f“升级失败: {response}”) return False # 使用示例 if __name__ “__main__”: send_firmware(“COM3”, 115200, “application.bin”)这个Python脚本完整实现了之前定义的协议包含了进度显示和错误处理你可以直接修改使用。6. 常见问题、调试技巧与避坑指南即使代码逻辑正确在实际操作中你依然会遇到各种奇怪的问题。下面是我总结的“血泪教训”。6.1 跳转后程序跑飞或进入HardFault这是最常见的问题原因多种多样中断未关闭如前所述跳转前必须__disable_irq()并复位SysTick。堆栈指针SP未正确设置跳转前必须将MSP设置为APP区的栈顶值即APP起始地址的第一个字。__set_MSP(*(__IO uint32_t*)APP_ADDRESS);这句代码的位置很关键必须在跳转指令执行前执行。APP中断向量表偏移VTOR未设置这是最高频的原因。务必确认在APP的启动代码中在初始化任何中断源包括SysTick_Config之前已经正确设置了SCB-VTOR。APP的时钟配置与BootLoader不一致比如BootLoader使用HSI内部8MHz时钟而APP配置为HSE外部晶振。如果跳转后APP重新配置系统时钟但PLL未稳定就发生了中断也可能导致异常。确保时钟切换代码稳定或考虑在BootLoader中初始化好系统时钟APP不再重新配置。APP使用了BootLoader初始化过的外设例如BootLoader使用了USART1跳转后APP又对USART1进行了初始化可能导致冲突。安全的做法是在跳转前将BootLoader用过的外设反初始化DeInit或者APP在初始化时完全重新配置。调试技巧当跳转失败时可以在跳转函数JumpToApp()之前手动设置一个断点。单步执行观察PC指针是否成功跳转到APP_ADDRESS4地址所指向的值即Reset_Handler。如果跳过去了但立刻进入HardFault则问题大概率出在APP的初始化阶段如VTOR、时钟、外设。6.2 串口升级过程中数据错误或丢失波特率误差确保BootLoader和上位机使用相同的波特率并且单片机的主频经过分频后能产生标准的波特率误差在可接受范围内。高波特率如115200对时钟精度要求更高。流控缺失我们的简单协议采用了“发送一包等待一个ACK”的流控。如果去掉ACK上位机连续发送而BootLoader的Flash写入速度ms级别远慢于串口接收速度us级别必然导致数据溢出丢失。务必实现流控。串口接收中断处理时间过长在串口中断服务函数中直接进行Flash编程是危险的因为Flash写入耗时较长可能阻塞中断导致后续数据丢失。更好的做法是中断只负责将数据存入环形缓冲区主循环检查缓冲区数据量达到一个块如256字节后再一次性写入Flash。这需要将协议状态机从中断移到主循环。6.3 Flash编程失败 (Error: Flash Download Failed)这个错误通常发生在你用下载器ST-Link烧录BootLoader时而不是BootLoader运行时。选项字节Option Bytes配置错误特别是读写保护RDP, WRP。如果你的芯片之前被设置了读保护或写保护下载器将无法擦写Flash。你需要通过ST-Link Utility等工具先解除保护Full Chip Erase。BootLoader程序本身破坏了下载器接口比如你的BootLoader代码错误地禁用了SWDSerial Wire Debug调试接口通过复用对应的GPIO。在BootLoader的初始化代码中避免在初始化早期重新配置用于SWD的PA13、PA14引脚。或者在初始化GPIO时先读取一下调试端口的状态寄存器确保不会影响调试。电源不稳定Flash编程对电源电压要求较高。如果使用不稳定的USB供电或存在较大纹波可能在编程瞬间导致失败。确保电源质量。6.4 如何实现“双备份”或“失败回滚”一个健壮的BootLoader应该考虑升级失败后的恢复能力。一个经典的策略是“双备份”将APP区域划分为两个相等的槽Slot A和Slot B。设备当前运行在Slot A。升级时将新固件下载到空闲的Slot B。下载并校验成功后在Flash的某个固定位置如BootLoader区域末尾写入一个“标志位”指示下次启动时应跳转到Slot B。重启后BootLoader读取标志位跳转到对应的Slot。如果新固件Slot B运行失败可通过看门狗或应用层心跳机制检测则设备可以自动重启BootLoader发现Slot B启动失败后清除标志位并回滚到Slot A。这增加了复杂度但极大地提升了可靠性适合对稳定性要求高的产品。6.5 优化建议与扩展方向增加AES加密如果你担心传输的固件被截获和反编译可以在上位机端加密bin文件在BootLoader端解密后再写入Flash。但要注意加解密算法在单片机上运行的性能和空间开销。支持更多通信接口将串口协议抽象出来可以轻松扩展为CAN、USB CDC、甚至SPI Flash、SD卡等本地存储升级。差分升级对于只是修复少量bug的升级传输整个bin文件效率低下。可以设计差分算法上位机生成差分包PatchBootLoader负责在原有固件上应用Patch这能极大减少传输数据量特别适合GPRS/NB-IoT等流量敏感的场景。完善上位机为你的Python脚本加上图形界面用Tkinter或PyQt增加多端口选择、历史记录、日志查看等功能让它成为一个真正的生产工具。最后我把这个项目的核心源码整理了出来。它包含了STM32F1系列的BootLoader工程Keil MDK、适配好的APP工程模板以及上面提到的Python上位机脚本。代码中有详尽的注释你可以以此为骨架快速移植到你的项目中。记住理解原理比复制代码更重要希望这篇长文和这份源码能帮你彻底拿下STM32的BootLoader开发。
返回列表