
简介面向嵌入式开发者的STM32固件升级方案实现通过SD卡完成IAP在线升级。方案基于Bootloader与FATFS文件系统上电后自动读取SD卡内的bin文件并写入Flash适合远程维护和批量更新场景。资源共532个文件13.28MB核心是138个C源文件与120个头文件另含bin/hex固件、编译中间文件及图片文档便于对照工程学习已有275人学习。内容覆盖SD卡初始化、bin校验解析、Flash分区写入、Bootloader跳转等环节SD_BOOT与FATFS工程可直接用Keil打开。开发者可复用其中的FATFS移植代码和引导流程快速搭建可靠的固件升级机制。1. SD卡升级stm32固件把固件升级从“拆机烧录”变成“插卡重启”现场设备要升级固件传统做法是拆壳、接线、打开烧录工具、断电重启一台十分钟一个项目几十台上百台整晚耗进去。SD卡升级就是把固件文件放进一张MicroSD卡插回设备上电自动完成擦写和重启全程不用电脑不用工具。这套机制的核心是IAPIn-Application Programming芯片里先放一段Bootloader固件以文件形式放在SD卡按约定格式写入应用区。它解决的是量产烧录、现场运维、无串口设备的固件更新问题成本只需要一个卡槽和一张几块钱的卡。适合正要给设备加升级能力的开发者也适合正在用串口升级、想换成离线卡升级的团队。照着读下来你能得到一份能编译的Bootloader搭建思路、App改造清单以及我踩过的一些坑这些坑能让你少熬几个通宵。2. 先弄懂这套机制的三个关键点IAP、Flash分区、升级流程2.1 IAP机制为什么Bootloader要先跑IAP不是新奇技术本质是芯片里放两段程序一段叫Bootloader放在Flash最低地址上电先执行另一段叫App是真正的业务固件放在偏移之后的区域。Bootloader的任务很单纯——判断有没有升级请求有就读取新固件写入App区没有就直接跳转。不写Bootloader也能升级芯片出厂自带的ROM引导程序支持串口或USB下载但那种方式依赖主机工具现场没人愿意拿笔记本电脑折腾。SD卡方案的优势在于升级介质是卡固件是文件现场人员只要把卡插进去再上电Bootloader自己完成剩下的事。我看过几个团队从串口升级切到SD卡之后运维时间从每台十几分钟降到半分钟这就是实打实的效率差。升级方式是否需要主机现场操作典型成本串口/UART需要电脑和工具接线、打开软件、下载低但耗时USB DFU需要电脑按键加工具中网络OTA不需要后台下发需要联网链路SD卡不需要插卡上电卡槽加卡为什么不直接用芯片ROM里的系统Bootloader因为它按厂家协议走升级流程、校验逻辑、触发条件都没法定制也不方便在升级前打印日志。自己做Bootloader才能把“插卡自动升级”这个动作做得干净。2.2 Flash分区与链接脚本地址规划错了后面全是坑升级机制正常运行的前提是Flash分区清晰。以常见的512KB Flash芯片为例我一般把分区规划成这样区域地址范围容量用途Bootloader区0x08000000 - 0x0800FFFF64KBIAP引导程序App区0x08010000 - 0x0807FFFF448KB业务固件备份区可选0x08060000 - 0x0807FFFF128KB双备份与版本回退见第6章Bootloader建议独占至少64KB。我见过有人为省容量把Bootloader压到32KB结果FATFS开了长文件名支持、加了几条打印日志编译直接溢出最后还得回退方案。App工程的链接脚本要改两个数值下面是App工程链接脚本中需要调整的部分/* 改造前Bootloader与App共用时的原始定义 */ FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 64K /* 改造后App起始地址和容量都让出来 */ FLASH (rx) : ORIGIN 0x08010000, LENGTH 448K RAM (rwx) : ORIGIN 0x20000000, LENGTH 64K链接脚本的ORIGIN决定了编译产物里所有代码地址的起点。App不改成0x08010000编译出的向量表还在0x08000000跳转过去必出问题。RAM不用动App和Bootloader共用同一块SRAM只要错开执行时间就行。分区边界一旦定下来就要当产品约束冻结别在项目中期改。改一次意味着所有在网设备都要重新刷Bootloader等于放弃了SD卡升级的便利性。我经历过一次因为App必须从512KB压到448KB而砍功能的项目事后复盘根因就是最初没给Bootloader留够余量。2.3 启动判定与升级包协议上电后Bootloader到底做什么Bootloader的上电流程拆成四步初始化外设、判断升级请求、执行升级、跳转App。初始化阶段只做与升级相关的最小集合——时钟、SD卡、串口调试打印用不要初始化App的外设否则跳转后外设状态混乱排查起来非常头疼。升级请求的判定常见做法有两种一是检测SD卡上是否存在固定文件名的升级包比如FW.BIN存在就升级二是通过标志位或按键输入决定是否进入升级模式。我倾向第一种因为文件名即触发条件现场零培训成本运维人员只需知道“把包改名FW.BIN放卡根目录”。判断顺序如下上电系统时钟初始化初始化SD卡挂载文件系统尝试打开0:/FW.BIN成功则进入升级流程升级成功后跳转App文件不存在或校验失败直接跳转现有App升级包本身需要自描述结构不能只把bin文件丢进卡里。我常用这样的包头格式偏移字段长度说明0魔数2字节固定0xAA55防止误读非固件文件2版本号2字节高字节主版本低字节次版本4数据长度4字节固件数据字节数8CRC324字节对固件数据的校验值12固件数据N字节待写入App区的二进制Bootloader先读头部魔数不对直接放弃升级CRC不对也放弃并把错误状态通过串口打出来。这个协议字段可按芯片Flash扇区对齐特性微调但前四条缺一不可缺了后面排查升级问题就像在黑匣子里猜。3. 把Bootloader写出来从SD卡读取到Flash写入的全套动作3.1 SD卡硬件连接SPI还是SDIOBootloader里用SPI模式操作SD卡是我做过几个项目后最稳的选型。SPI模式每个卡槽只占4根信号线CS、SCK、MISO、MOSI任意SPI外设都能驱动代码量小出问题好定位。SDIO模式速度快但对线路质量、拉线长度敏感Bootloader不是数据吞吐场景几百KB固件用SPI几秒钟就完成了没必要上SDIO。项目SPI模式SDIO模式速度400kHz - 12MHz25MHz/50MHz引脚占用4根6根以上驱动复杂度低高适合场景Bootloader、日志存储大容量持续读写初始化SD卡有个经典时序一步都不能省上电后先延时至少几十毫秒期间SPI时钟保持在400kHz以下持续发送至少74个时钟周期的空数据让卡完成内部上电之后再发CMD0进入SPI模式。我见过有人把时钟直接设在1MHz结果几张卡能认、几张卡卡死最后统一降到低速才稳定。3.2 FATFS移植与挂载文件系统是SD卡升级的地基FATFS是开源的FAT文件系统组件Bootloader只需要用到挂载、打开、读取、关闭几个接口。我一般把ff.c、ff.h、ffconf.h、diskio.c、diskio.h这五个文件加进工程在diskio.c里实现与SD卡驱动对接。下面是读扇区函数的一个实现骨架以SPI模式驱动为例DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! 0) { return RES_PARERR; /* 只支持一个物理盘 */ } while (count--) { if (SD_ReadSector(sector, buff) ! SD_OK) { return RES_ERROR; /* 底层读卡失败直接上报 */ } buff 512; /* 每个扇区固定512字节 */ } return RES_OK; }代码逻辑FATFS按扇区为单位调用disk_readsector是逻辑扇区号buff是目标缓冲区count是要读的扇区数。底层SD_ReadSector负责完成SPI读写。注意buff必须能容纳512乘以count字节且建议4字节对齐因为SPI DMA传输对对齐有硬性要求不对齐可能触发DMA错误中断。挂载文件系统再打开升级包核心代码是这样FATFS fs; /* 文件系统对象需要保持存活 */ FIL file; /* 文件对象读操作都在它上面进行 */ FRESULT res; res f_mount(fs, 0:, 1); /* 挂载SD卡卷1表示立即挂载 */ if (res ! FR_OK) { /* 挂载失败打印错误码后跳转App */ return res; } res f_open(file, 0:/FW.BIN, FA_READ); /* 打开根目录下的固件文件 */ if (res ! FR_OK) { /* 文件不存在说明没有升级请求直接跳App */ }f_mount的第三个参数1表示立即挂载0表示延迟到首次访问时挂载。路径0:是卷标FATFS用盘符区分多个存储设备Bootloader里只有一个SD卡固定写0:即可。f_open打开后后续f_read、f_lseek都通过FIL对象定位文件内容。3.3 固件搬运与Flash编程擦、写、校验三件套升级包读出来之后要把数据搬进App区。最容易错的点是Flash必须先擦后写、按半字编程、地址必须落在扇区边界。下面是我常用的升级主流程#define APP_BASE_ADDR 0x08010000U #define FLASH_SECTOR 2048U /* 扇区大小按芯片手册配置 */ static int do_firmware_update(void) { uint8_t header[12]; uint8_t buf[512]; UINT br; uint32_t app_addr APP_BASE_ADDR; uint32_t fw_len, remaining, crc_calc, crc_file; FIL file; if (f_open(file, 0:/FW.BIN, FA_READ) ! FR_OK) { return UPDATE_NO_FILE; } /* 读12字节包头 */ f_read(file, header, 12, br); if (header[0] ! 0xAA || header[1] ! 0x55) { f_close(file); return UPDATE_BAD_MAGIC; } /* 包头字段按小端拼接 */ fw_len (uint32_t)header[4] | ((uint32_t)header[5] 8) | ((uint32_t)header[6] 16) | ((uint32_t)header[7] 24); crc_file (uint32_t)header[8] | ((uint32_t)header[9] 8) | ((uint32_t)header[10] 16) | ((uint32_t)header[11] 24); /* 第一遍读数据边读边算CRC */ crc_calc 0; remaining fw_len; while (remaining 0) { UINT to_read (remaining sizeof(buf)) ? sizeof(buf) : remaining; f_read(file, buf, to_read, br); crc_calc CRC32_Update(crc_calc, buf, br); remaining - br; } if (crc_calc ! crc_file) { f_close(file); return UPDATE_BAD_CRC; /* 校验不过绝不碰Flash */ } /* 第二遍回到数据起始位置边读边写Flash */ f_lseek(file, 12); Flash_Unlock(); /* 先整片擦除App区按扇区循环 */ for (uint32_t off 0; off 448U * 1024U; off FLASH_SECTOR) { FLASH_ErasePage(APP_BASE_ADDR off); } remaining fw_len; while (remaining 0) { UINT to_read (remaining sizeof(buf)) ? sizeof(buf) : remaining; f_read(file, buf, to_read, br); flash_write_buffer(app_addr, buf, br); app_addr br; remaining - br; } Flash_Lock(); f_close(file); return UPDATE_OK; }参数说明APP_BASE_ADDR是第2章定好的App起始地址FLASH_SECTOR必须与芯片实际扇区大小一致——有些系列是1KB、2KB有些大容量器件扇区是数十KB查参考手册再填。CRC32_Update是逐字节喂数据的查表法CRC32与打包脚本里的算法要保持同一个变体。flash_write_buffer内部完成Flash编程注意它必须处理半字对齐。下面的辅助函数处理Flash写入时的半字对齐static void flash_write_buffer(uint32_t addr, uint8_t *buf, uint32_t len) { uint16_t tmp; for (uint32_t i 0; i 1 len; i 2) { tmp (uint16_t)(buf[i] | ((uint16_t)buf[i 1] 8)); FLASH_ProgramHalfWord(addr i, tmp); } /* 若固件长度不是偶数最后一个字节补0xFF凑半字 */ if (len % 2 ! 0) { tmp (uint16_t)(buf[len - 1] | 0xFF00); FLASH_ProgramHalfWord(addr len - 1, tmp); } }逻辑说明Flash编程以半字为单位所以每次从缓冲区取2字节组成一个16位值写入。固件bin一般是偶数长度但升级包协议不改小端结构和长度字段写函数里要对奇数长度兜底否则最后一个字节写不进去CRC也白算了。3.4 跳转App把CPU控制权干净地交出去升级完成或不需要升级时Bootloader都要跳转。跳转不是普通函数调用要重新设置主栈指针并执行App复位向量。下面是跳转函数typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t msp_value *(volatile uint32_t *)app_addr; pFunction app_entry (pFunction)(*(volatile uint32_t *)(app_addr 4)); /* 栈指针必须落在片内RAM区域防止空白Flash导致的非法跳转 */ if ((msp_value 0x20000000U) || (msp_value (0x20000000U 64U * 1024U))) { return; } __disable_irq(); /* 关闭全局中断 */ __set_MSP(msp_value); /* 设置主栈指针 */ app_entry(); /* 跳转到App复位向量 */ }跳转前关闭全局中断是血泪经验Bootloader里如果开了定时器或串口中断跳到App后中断仍可能触发但App还没完成外设初始化一进中断服务函数就是HardFault。栈指针有效性检查也很有必要如果App区是空白Flash读出来全是0xFF直接设置MSP会立刻跑飞。提示跳转后App不会把控制权还给Bootloader所以app_entry()之后不要放任何需要执行的代码。4. 改造App工程向量偏移、bin生成与升级触发4.1 中断向量表偏移不设这一步跳过去就是HardFaultApp工程改完链接脚本还不够还要改中断向量表寄存器。原因是芯片上电时从0x08000000读向量表Bootloader在低地址App在高地址跳转后CPU仍然只会从0x08000000取中断向量。如果不把向量表基址改到App区任何中断一来CPU查到的是Bootloader的向量函数指针全乱直接进HardFault。在App的main函数第一行加上int main(void) { /* 中断向量表偏移到App区必须在任何外设中断使能之前执行 */ SCB-VTOR 0x08010000U; /* 后接时钟初始化、外设初始化等其他代码 */ }不同系列的CPU写法略有差异但SCB-VTOR这个内核寄存器的含义是一致的。赋值语句必须放在任何外设中断使能之前否则中断可能在被使能的瞬间就因旧向量表触发异常。我在一个项目里就是没注意执行顺序把VTOR设置放到了串口初始化之后结果串口一使能就卡死在中断里。4.2 生成bin文件与升级包制作App工程编译出来默认是hex格式SD卡升级需要的是bin因为bin不包含地址信息、体积小、写入偏移容易控制。生成bin的命令我放在构建后步骤里不让手工记fromelf --bin --outputapp.bin app.axffromelf是ARM工具链自带的格式转换工具--bin表示输出纯二进制--output指定输出文件名最后一个参数是链接器生成的中间文件。每次编译后自动执行这条命令产出的app.bin就是可以直接打包的固件。打包逻辑用脚本处理比手工靠谱。我习惯用Python脚本读取app.bin计算CRC32再按协议拼出FW.BINimport struct, zlib fw open(app.bin, rb).read() header struct.pack(HHII, 0xAA55, 0x0101, len(fw), zlib.crc32(fw) 0xFFFFFFFF) open(FW.BIN, wb).write(header fw)脚本里struct.pack的格式HHII表示小端模式依次写入2字节魔数、2字节版本号0x0101即1.1、4字节长度、4字节CRC。zlib.crc32算出的校验值Bootloader端必须用同一个CRC32变体去验证否则两端对不上升级永远失败。版本号每次发布都要递增后面做版本回退和升级日志都靠它。提示如果Bootloader端用了硬件CRC外设注意硬件CRC的初值和多项式与zlib.crc32不一定一致最好统一用软件查表法省得两头调。4.3 触发升级的几种方式与标志位管理App运行中的升级触发常见就两种。第一种是软复位后Bootloader检测SD卡文件这种方式简单可靠App收到升级命令后把关键参数存到备份寄存器然后调用NVIC_SystemReset()复位Bootloader启动后先查SD卡。第二种是App运行状态下直接通过串口等通道接收固件数据并写入Flash写完后软复位跳转。第二种复杂且容易踩时序坑不是SD卡场景的首选我一般不推荐。标志位管理要守住“升级失败别把设备变砖”的原则。我常用的做法App收到升级指令后只置一个“请求升级”标志真正决定动作的始终是Bootloader。如果升级包不存在或校验失败Bootloader不会擦除App区直接跳转现有App设备还能正常工作。这样用户最多发现“没升级成”而不是“设备成砖了”。标志位触发时机Bootloader行为REQ_UPDATEApp收到远程或按键指令检测SD卡是否存在升级包UPD_DONEBootloader升级完成跳转App前清除UPD_FAILBootloader校验失败保留标志并打印错误码标志位一般放在备份寄存器或者Flash的专用扇区里不要放在普通RAM里因为复位后RAM内容不保证保留。如果芯片没有备份寄存器就划一小块Flash专门存标志注意Flash寿命有限别高频写。5. 避坑指南SD卡升级最常翻车的五个现场5.1 跳转App就死机栈指针有效却还是HardFault现象Bootloader串口打印“跳转中”然后设备没有任何反应进调试发现停在HardFault_Handler。原因App中断向量表偏移没设置或者设置了但执行顺序不对。跳转本身是成功的但App第一个中断触发时向量表还在0x08000000CPU按Bootloader的向量表找中断服务函数地址全错。解决确认App的main函数第一行就是SCB-VTOR赋值软复位后看寄存器确认向量表基址是0x08010000。另外跳转前把Bootloader用过的外设复位一遍比如串口、SPI否则这些外设还保持着Bootloader的配置状态App初始化时没做DeInit又会复用旧配置。我现在写代码模板把“向量偏移、关闭全局中断、外设复位”三件事固定写在跳转函数附近每次新建工程直接抄。5.2 SD卡初始化卡死同一张卡在别的板子上能跑现象插卡后程序停在SD卡初始化循环串口没有任何打印把卡换到另一块板子却正常。原因SPI时钟太快或者卡上电后没留够稳定时间。不同批次卡的内部电容差异导致上电时间有波动高速时钟下CMD0响应丢失初始化永远等不到回应。解决初始化阶段把SPI时钟压到400kHz以下命令后加足够延时上电后先延时100毫秒再发CMD0。稳定初始化后再把SPI时钟切到1MHz以上用于后续扇区读取。代码里给CMD0加10次重试每次间隔至少8个时钟周期。这个时序问题很像玄学但本质就是“卡没准备好你就问它”把准备时间拉长就稳了。5.3 Flash擦写超时擦除一个扇区要等很久现象升级进行到一半Flash擦除一直忙程序超时退出更新。原因擦除地址没有按扇区对齐。有的擦除库函数对地址有对齐要求传入非对齐地址行为不可预期另外擦除时Flash总线被占用如果程序还在从App区的Flash取指令执行也会出现总线冲突。解决把擦除地址按扇区大小向下取整确保传入地址是扇区边界的整数倍。常见扇区尺寸是1KB/2KB/64KB具体查对应芯片的参考手册。擦除前调用Flash解锁擦除后立即校验第一个字是否正确为0xFFFFFFFF。如果擦除期间代码需要执行就把擦除循环放到SRAM里跑或者至少保证代码段不落在App区内。5.4 f_read读到一半报错SPI模式下文件读着读着就断了现象升级到大约一半时f_read返回FR_INT_ERR升级中止但单独读扇区又是好的。原因FATFS读取跨多个扇区的连续数据时底层SPI中断抢占导致传输时序破坏。常见原因是SPI的DMA中断优先级设置不当或读卡过程中有高优先级中断长时间打断传输。解决把SPI相关中断优先级设为足够高或读扇区期间关闭可被抢占的中断源读取缓冲区按4字节对齐。也可以一次性把整个升级包读入外部RAM再写Flash代价是RAM不够时没法用。这个问题的排查思路是先看错误码出现的位置——是f_open失败还是f_read失败再决定往文件系统配置还是底层驱动方向查。5.5 升级断电变砖擦除后掉电等于半成品现象升级中途断电重新上电后设备完全无法启动必须接调试器恢复。原因Bootloader的升级流程是“先擦后写”擦除完成后、写入完成前断电App区就变成空白或残片跳转条件检查也过不了因为栈指针位置是0xFFFFFFFF。解决引入备份区或至少保证升级包CRC在Bootloader里先完整校验再开始擦写——校验失败直接不碰Flash。更强的做法是第6章的双备份区方案。如果实在没有备份区那就在官网升级流程里明确警告“升级过程中不要断电”但这不是技术保障只能算运维约束。我经手过的量产项目里凡是没有断电保护的后续全都补了备份区方案。6. 进阶掉电保护、版本回退与量产验证6.1 双备份区给升级上一道保险双备份是解决断电变砖最直接的办法。在Flash里划一块备份区大小和App区相同升级流程改成先擦备份区把新固件写入备份区校验备份区完整校验通过后置一个“备份区有效”标志再擦App区并复制复制完成后清除标志。断电发生在任何阶段下次上电Bootloader检查标志备份区有效就重新复制App区有效就直接运行。6.2 版本回退版本回退依赖两个前提Bootloader保存“上一版本有效”的记录App运行时定期上报健康状态。如果App启动后一定时间内没有上报Bootloader认为新版本异常自动回退到备份区里的旧版本。这个机制对现场运维很关键它把“升级失败变砖”变成了“升级失败自动回到可用状态”。6.3 验证清单验证项方法通过标准SD卡识别上电串口打印卡信息容量与类型正确升级包校验改动CRC后再升级Bootloader拒绝掉电恢复升级中任意时刻断电上电后能恢复或回退中断响应跳转App后触发按键中断中断正常执行连续升级同一张卡连续升3个版本每次版本号递增成功我现在做升级方案默认加齐三样东西包头CRC、备份区、回退标志没有这三样不敢交付量产。SD卡升级不是黑匣子把流程拆成“初始化、判定、校验、搬运、跳转”五步每一步用串口打一个状态字符出问题看打印就能定位。这套流程我在几个项目里反复用坑基本踩平了希望帮到你。本文还有配套的精品资源点击获取