1. 项目概述:为什么我们需要一个BootLoader?
如果你玩过STM32,肯定遇到过这样的场景:产品已经焊在板子上、装在壳子里了,突然发现程序有个bug需要修复,或者要增加一个新功能。这时候难道要把产品拆开,重新接上ST-Link或者J-Link下载器来烧录程序吗?对于量产产品或者部署在偏远地区的设备,这几乎是不可能的。BootLoader,或者说IAP(In-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校验、更多通信协议),我们可以分配16KB(0x4000字节)。
- 对齐要求:STM32的Flash擦除操作是以“页”(Page)或“扇区”(Sector)为单位的。对于F103,每页是1KB。因此,我们的分区地址最好从页的起始地址开始,方便管理。
- 中断向量表偏移:这是最关键的一点!APP程序的中断向量表默认在0x0800 0000。但现在这个地址放的是BootLoader,所以APP的中断向量表必须进行偏移。
基于以上,一个合理的划分方案如下:
- BootLoader 起始地址:0x0800 0000 (单片机启动地址)
- BootLoader 结束地址:0x0800 3FFF (共16KB)
- APP 起始地址: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改为0x08004000,Size改为你分配的APP空间大小(如0xC000)。 - 确保
Use MicroLIB如果之前勾选了,继续保持勾选,否则可能因标准库的初始化代码导致问题。
生成正确的Bin文件: 在Options for Target->User选项卡,在After Build/Rebuild部分,勾选Run #1,并填入生成bin文件的命令,例如:fromelf --bin --output=@L.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, timeout=2) 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:i+block_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_ADDRESS+4地址所指向的值(即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代码错误地禁用了SWD(Serial 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文件效率低下。可以设计差分算法,上位机生成差分包(Patch),BootLoader负责在原有固件上应用Patch,这能极大减少传输数据量,特别适合GPRS/NB-IoT等流量敏感的场景。
- 完善上位机:为你的Python脚本加上图形界面(用Tkinter或PyQt),增加多端口选择、历史记录、日志查看等功能,让它成为一个真正的生产工具。
最后,我把这个项目的核心源码整理了出来。它包含了STM32F1系列的BootLoader工程(Keil MDK)、适配好的APP工程模板,以及上面提到的Python上位机脚本。代码中有详尽的注释,你可以以此为骨架,快速移植到你的项目中。记住,理解原理比复制代码更重要,希望这篇长文和这份源码能帮你彻底拿下STM32的BootLoader开发。