简介:嵌入式设备维护中,通过SD卡升级STM32固件是常见的远程更新方式。该场景下的开发资料包面向需要实现应用内编程(IAP)在线升级的嵌入式工程师,覆盖引导加载程序、文件系统与固件写入等关键环节,适合正在设计引导程序或有单片机开发基础的人群参考。资源共五百三十二个文件,压缩后约十三兆字节,其中一百三十八个C源码与一百二十个H头文件构成完整代码工程,配合工程配置文件可直接打开编译;三个bin与三个hex固件文件分别对应引导程序和应用程序;其他编译中间文件、图片及说明文档可辅助分析工程结构与操作配置。已有二百七十人学习下载。通过这份资料,可以掌握引导程序在上电后如何识别SD卡、读取并校验固件,以及将数据写入内部存储器的完整流程,同时可参考设计中关于外设兼容、分块处理和异常回退等策略,便于在自身项目中快速实现稳定可靠的单片机固件升级功能。 干嵌入式这行,难免会遇到一个场景:板子已经焊好、外壳已经扣上,甚至设备已经发到客户现场,突然发现固件有bug要更新,或者想增加个功能。这时候你总不能跟客户说“把板子拆开,我用J-Link重新烧一下”。在产线上也一样,几十上百块板子挨个接调试器刷程序,效率低不说,还容易出错。我自己在多个项目里用SD卡给STM32做固件升级(IAP),算是一条非常实用的路。用法很简单:把新固件拷进一张SD卡,插到设备上,上电或者按个按键,芯片自己就能完成擦除、写入、跳转。这篇就把这套方案从原理到代码、再到避坑,完整记录下来。
这套方案特别适合三类人:刚接触IAP的嵌入式开发新手,正在做产品量产方案的老手,以及需要做现场远程升级支持的项目维护者。SD卡的介入让整个升级流程完全不依赖调试器,也不需要联网,一张读卡器加一张TF卡就能完成,成本和门槛都低。
1. 方案整体思路与架构拆解
1.1 为什么选SD卡做IAP:搞定产线和售后的升级痛点
IAP的全称是In-Application Programming,本质就是“程序里自带升级功能”。STM32内部Flash可以一边跑程序一边擦写,只要把接收固件、写入Flash的代码提前固化在芯片里,后续就能在运行状态下更新自己。
常见的升级介质有好几种,串口、CAN、以太网、USB、SD卡,我都试过。串口和CAN需要上位机配合,适合开发阶段,不适合终端用户操作,客户不会用串口助手。以太网和USB DFU升级体验不错,但对没有网络模块、没有USB接口的低成本板子,根本不适用。SD卡方案胜在三点:一是存储量大,一个固件几百KB,连大文件都能放;二是操作门槛低,把文件拷进去插上就行,终端用户也能教;三是产线很方便,做一张母卡就能批量复制,比一个个接调试器快得多。
所以如果板子上本来就有SD卡槽,或者设备本身就需要存储日志/数据,那用SD卡升级几乎是零额外成本。就算板子上没有卡槽,加一个TF卡座也就是几毛钱的事,比加网络模块或者USB座划算得多。
1.2 Bootloader + APP 的双区架构
SD卡升级的核心架构,是芯片内部Flash要分成两个区:Bootloader区和APP区。
- Bootloader区:固化在Flash起始地址,上电最先运行,主要负责初始化、检测升级标志、读SD卡、擦写Flash、跳转到APP。
- APP区:真正的业务程序,用户功能都在这里。APP需要预留一个固定偏移量,编译的时候指定起始地址。
上电后的流程是这样的:Bootloader检查有没有升级需求,有就执行升级,升级完跳进APP;没有升级需求就直接跳进APP。这样哪怕APP写坏了、中途断电了,最多就是回到Bootloader状态,设备不会完全变砖,重新插卡再来一次就行。
这种双区结构还有个隐性好处:Bootloader本身非常精简,只在必要时刷新一次,一旦跑起来基本就不动了。稳定压倒一切,越简单的代码越不容易出错。
1.3 Flash空间分配:以STM32F103RCT6为例
不同的芯片Flash大小不一样,分区逻辑是通用的,就是“Bootloader够用就行,APP越大越好”。拿我常用的STM32F103RCT6举例,Flash一共256KB,起始地址0x08000000,我这样分:
| 区域 | 起始地址 | 大小 | 作用 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 启动代码、升级逻辑 |
| APP | 0x08008000 | 224KB | 业务固件 |
| 参数区 | Flash末尾2KB | 2KB | 升级标志、状态记录 |
Bootloader给32KB,用STM32标准库或者HAL库写点读卡、擦写、跳转的逻辑,编译出来大约15KB,空间非常宽裕。APP起始地址必须与Flash的擦除扇区对齐,STM32F103的Flash扇区是1KB一页,所以APP放在0x08008000(32KB对齐处)完全没问题。你在Bootloader里跳转到APP时,地址也就是这个0x08008000。
提示:分区的时候别把Bootloader给得太抠。虽然实际代码可能只有12KB,但后面你要加加密、加校验、加日志,空间一下就紧张了。宁可多留4KB,不要后面改分区改到崩溃。
2. 核心细节解析:SD卡驱动、文件系统与固件包设计
2.1 SD卡工作模式怎么选:SPI模式还是SDIO模式
STM32读SD卡有两种硬件接口:SPI模式和SDIO模式。这个选择直接影响接线和工程复杂度。
SDIO模式速度快,可到几十MB/s,四线并行传输,但引脚是固定的,比如STM32F1的SDIO只能挂在PC8~PC12上,板子布线自由度小。而且SDIO驱动代码复杂度高,处理状态机、超时、CRC错误,新手摸半天不一定跑顺。
SPI模式速度慢一些,实际测试读一个256KB的固件,SPI跑18MHz时大概一两秒,但我个人强烈推荐SPI模式。理由是接线太自由了,SCK、MISO、MOSI、CS四根线可以映射到任意SPI引脚,板子布局非常灵活,尤其适合F103这种不支持SDIO的性价比芯片。代码也简单,一个SPI驱动加一个卡初始化函数就能跑。还有一点,SPI模式的兼容性在低速下更好,很多老卡、杂牌卡,SDIO下不稳定,SPI模式反而能读。
有个容易踩坑的细节:标准SD卡SPI模式需要四根线加电源地,全程用3.3V电平。有些人看到芯片GPIO是5V容忍就以为能直连5V逻辑,千万别,SD卡不支持5V,一接就烧。MISO这条线上最好加上拉电阻,10k左右,否则长走线时容易读到乱码。网上有人用“三线SPI”去接SD卡,就是省了CS线,我试过,遇到卡内部分区或者状态机切换时非常容易出错。CS必须单独用GPIO控制,该拉低拉低,该拉高拉高,别省。
2.2 文件系统:引入FATFS把文件操作变成“复制粘贴”
如果直接把裸扇区数据写在SD卡上,升级时会很痛苦。万一你升级脚本里算错一个扇区偏移,写进去的固件就是坏的。所以我项目的标准做法是引入FATFS(FatFs文件系统库),把SD卡格式化成FAT32,固件就是个普通文件,Bootloader打开“update.bin”然后读内容、写Flash。
FATFS是一个著名的开源文件系统,代码量不大,在嵌入式领域几乎人手一份。接入它不需要自己处理FAT表、目录项等底层逻辑,只需要对接几个底层函数:disk_initialize(初始化SD卡)、disk_read(读扇区)、disk_write(写扇区)、disk_ioctl(获取卡容量等)。我用的比较多的是FATFS R0.14以上版本,接口更清晰,对长文件名支持也更好。写磁盘操作时要注意:FATFS写操作是按扇区写的,你最好在写之前先确认目标扇区已被擦除。但这不是大问题,因为最终我们还是把固件文件读到内存缓冲区,再直接写STM32内部Flash,SD卡本身只做读源。
有一个容易被忽略的点:SD卡的逻辑扇区大小是512字节,而STM32内部Flash的编程最小单位是“字”(32位,4字节),擦除最小单位是“页”(F103是1KB/页)。两者不对齐没关系,只要你读文件时按块读,比如每次读512字节缓冲,再按4字节为一行写入Flash,就没问题。但擦除必须按页对齐:你要擦除整个APP区,起始地址若是0x08008000,则Flash擦除页地址必须从0x08008000开始,这是页首地址,满足要求。
2.3 固件包格式:不是简单放个bin就行
最开始我直接把编译出来的bin文件拷进SD卡,Bootloader找到就写,结果有两次陷入坑:一次是烧了旧版本的固件,程序跑起来行为怪怪的,排查半天才知道SD卡里那个bin是几周前的;还有一次是SD卡上有其他文件,Bootloader误抓了一个不完整的副本,写进去直接启动失败。
后来我养成了一个习惯:固件文件头部加一个自定义报头,打包成自己的格式,比如.fw文件,结构下面这样:
- 魔数:固定值0xA5A5A5A5,用来标识这是合法升级文件
- 固件版本:例如0x0102,表示v1.2
- 固件长度:后面紧跟着的bin数据长度
- 固件CRC32:对整个bin数据算一次CRC32,用于完整性校验
- 目标芯片ID:例如0x0410,防止把别的板子的固件刷进来
Bootloader读到这个文件头后,先校验魔数和芯片ID,再按长度读取整个bin内容并一边算CRC,跟文件头里存的CRC比对,全部通过才开始擦写。这样能保证写进去的固件数据是完整的、版本是正确的。
同时我引入了升级标志位机制:在Flash参数区存一个4字节值,比如0xCAFEBABE表示“需要升级”。用户把SD卡插好,程序检测到卡里存在合法的升级文件,就置上标志位,然后软复位重启。重启后Bootloader看到标志位,执行升级流程,完成后清除标志位再跳转APP。这种“先置标志再复位”的设计,其实是借鉴了OTA领域的标准做法——升级过程必须是可断点恢复的。
3. 实操过程:从Bootloader到APP,一步步跑通
3.1 初始化工程:用STM32CubeMX搭底子
用STM32CubeMX生成工程,先把用到的外设配置好。需要勾选SPI(用于SD卡通信)、一个GPIO输出(控制SD卡CS片选)、一个GPIO输入(可选,读卡检测引脚)、UART(调试日志输出),有需要可以再开一个按键GPIO输入,用来手动触发升级。
SPI初期配成低速模式,因为SD卡刚上电时支持的最高SPI时钟只有400kHz。初始化完成后,可以切换到高速模式,比如分频到18MHz,整个读取速度会快很多。CubeMX生成的SPI初始化代码已经配置好了,要改的只是速率分频系数。我习惯的做法是:卡初始化阶段用一个慢速SPI句柄,等CMD0、ACMD41、CMD58这些初始化命令全部返回成功后,再改SPI分频系数切高速,但同一个SPI总线上代码实现要清晰。
3.2 Bootloader核心代码:读卡、校验、擦写、跳转
Bootloader的逻辑可以用下面的伪代码概括,我在实际项目中就是按这个骨架填的:
int main(void) { /* 初始化时钟、串口、SPI、GPIO */ init_all(); /* 检查参数区的升级标志位 */ if (check_update_flag() == NEED_UPDATE) { if (sd_card_init() != OK) { log("SD card init failed"); clear_update_flag(); jump_to_app(); } f_mount(&sdfs, "", 1); if (f_open(&fp, "0:/update.fw", FA_READ) == FR_OK) { /* 1. 读取并解析文件头,校验魔数、芯片ID、CRC */ read_header(&header); if (verify_file(&header) != OK) { f_close(&fp); clear_update_flag(); jump_to_app(); } /* 2. 擦除APP区 */ flash_erase_pages(APP_ADDR, app_pages); /* 3. 按扇区读取固件数据,写入内部Flash */ uint8_t buffer[512]; while (f_read(&fp, buffer, sizeof(buffer), &br) == FR_OK && br > 0) { flash_write_words(write_addr, (uint32_t *)buffer, br / 4); write_addr += br; } if (write_addr == header.firmware_len + APP_ADDR) log("update success"); f_close(&fp); } /* 升级完成或异常都清除标志位 */ clear_update_flag(); } jmp_to_app(); }写Flash时要特别注意,F103的Flash必须先擦后写,而且擦除是按页(1KB)进行的,写是按字(4字节)进行的。如果你要写的数据不是4字节对齐的数据长度,就要做缓冲区补全处理:最后不足4字节的部分补齐0xFF再写入。我为了省事,编译固件时让链接器对齐到4字节,这样处理起来简单很多。
跳转函数是整个流程的关键,写不好直接进HardFault。标准写法如下:
static void jump_to_app(void) { uint32_t app_entry = APP_ADDR; uint32_t app_sp = *(volatile uint32_t *)app_entry; /* 检查栈顶是否在RAM范围内,防止跳到一个随机地址 */ if ((app_sp & 0xFFF00000) != 0x20000000) { log("invalid app stack addr, stay in bootloader"); while (1); } void (*app_reset)(void) = (void (*)(void))(*(volatile uint32_t *)(app_entry + 4)); /* 关闭全局中断,避免中断在跳转过程中响应 */ __disable_irq(); __set_MSP(app_sp); app_reset(); while (1); }这个校验逻辑我后来一直保留着,它就像一个保险丝,Booterloader只认“栈顶地址在RAM范围”这个合法的APP入口特征,不满足就不跳转,待在Bootloader里等重新升级,防止程序飞去外太空。
3.3 APP侧的三处改动,缺一不可
APP端工程不能直接用默认配置,需要改三处:
第一,在main函数最前面重新定位中断向量表。ARM Cortex-M3上电后默认从0x08000000读取向量表,而你的APP在0x08008000,如果你不做任何处理,中断一来就会跳到Bootloader的向量表,APP里配置的中断处理函数根本不会执行。解决方式是在main最开头执行一行代码:
SCB->VTOR = 0x08008000;HAL库的SystemInit函数在启动文件里已经被调用了,有时重新定位需要在SystemInit里做,但更稳妥的是在main函数第一条语句执行。如果芯片支持,也可以用HAL库的HAL_SYSTICK_Config和设置VTOR的封装函数。
第二,修改链接地址。MDK里选择Options for Target,在Target页把IROM1的起始地址从0x08000000改成0x08008000,size改成0x00038000(224KB),这样编译出来的程序才能跑到APP区地址。
第三,生成bin文件。MDK编译默认生成的是hex文件,hex自带地址信息,但Bootloader里解析hex比较麻烦,所以我统一用bin格式。在MDK的User页里加After Build命令:
fromelf --bin -o ./app.bin ./build/app.axf编译完之后,你会得到一个裸的bin文件,大小为实际代码大小,通常就几十KB到一百多KB。把这个bin文件用一个小工具加上文件头,生成update.fw,拷进SD卡,升级就指日可待了。
4. 踩坑记录与排查技巧
4.1 跳转APP后死机或HardFault:先查三件事
这是SD卡升级里最常见的坑,我在项目里踩了绝对不止一次。跳转完黑屏、串口没输出、板子重启循环,通常原因就三个。
第一个原因是中断向量表没有偏移。APP的main里没有执行SCB->VTOR=APP_ADDR,或者执行时机太晚,在系统初始化之后才设,导致SysTick异常、串口中断一进来就乱了。第二个原因是跳转前没有关全局中断。Bootloader如果在跳转瞬间还有定时器中断在响应,跳到APP后中断状态混乱,很容易卡死。所以在跳转前务必__disable_irq(),跳转函数最后再恢复,但不用自己恢复,因为APP启动代码会重新初始化中断。第三个原因是栈顶指针非法。跳转前检查一下*(volatile uint32_t *)APP_ADDR是否为RAM地址范围,不是就说明APP区根本没有有效程序,不要盲目跳。
如果你的BOOT和APP都用了RTOS,跳转前还要额外注意关掉RTOS的调度器和相关外设时钟,这个更麻烦,建议第一版先裸机跑通再说。
4.2 SD卡初始化失败:串口打印卡在CMD0/ACMD41
初始化SD卡时,最常见的现象就是卡返回错误,日志停留在“send CMD0”然后超时。逐个排查这几个地方:
- SPI速率。卡刚上电的时SPI时钟必须≤400kHz。如果你用一个1.8MHz甚至更快的速率去初始化,很多卡直接不上电应答。
- 片选信号。CS必须在发送命令前拉低,命令发送后拉高。顺序错了,卡不理会你。
- MISO的上拉。有的板子没接上拉,MISO在空闲时电平不定,读回的数据全是乱码。在MISO上挂一个10k电阻到3.3V能解决大部分杂牌卡兼容问题。
- 电源稳定。SD卡在初始化时会拉一下电流,特别是TF卡通过转接板插的时候,如果供电回路设计不太好,电压会被拉低到3.0V以下导致卡复位。在卡电源引脚附近加一个10uF~100uF的电容,效果非常明显。
- 卡的类型。市面上有的卡对ACMD41的响应很慢,有的卡需要发CMD1(MMC模式)才能初始化成功。我写代码时做了一个循环:先发ACMD41,如果超时再试CMD1,两种模式都支持,兼容性明显提升。
4.3 升级中途断电或者写坏,怎么保证不死砖
设备在升级过程中突然断电,是最丑但也最真实的场景,谁也拦不住。Bootloader的设计必须保证哪怕写了一半断电,下次上电还能重新升级。
具体做法是,在Flash参数区记录升级状态,比如0xA5A50001表示“准备升级”,0xA5A50002表示“升级中”,0xA5A50003表示“升级完成”。Bootloader上电后看到状态是“升级中”,就知道上一次升级没有完成,不启动APP,继续等待重新升级。这样就算写到一半断电,最多是APP区数据不完整,但Bootloader完好,还能重新升级。
另外,写Flash之前先擦除参数区里“升级完成”的标记,代表旧固件已经不再可靠。升级成功后再写“升级完成”,再跳转。逻辑顺序搞反了,会造成一种可怕的场景:APP写得一塌糊涂,标志却显示“升级完成”,Bootloader直接跳进去然后卡死。
如果你希望升级更保险,可以做双APP备份,就是ABC三区结构,这适合产品已经量产、误升级会造成严重损失的场景。不过那样代码量会翻倍,第一个版本还是把基本流程跑通为主。
4.4 文件系统挂载失败、读文件报错
挂载FATFS时如果返回FR_NO_FILESYSTEM,通常不是因为代码问题,而是SD卡没格式化或者格式不对。SD卡尽量用FAT32格式,FAT16也行,不要用exFAT,很多FATFS版本默认没开启exFAT支持。格式化时不要选快速格式化,格式化彻底一点,文件系统表干净,读文件时错误少。
如果卡里有多个分区或者有隐藏分区,FATFS默认只挂载第一个分区,你固件放第二个分区,读了半天都是空的。最简单的做法是:一张卡只分一个区。
有读者问过我是否要开FATFS的长文件名支持,我的建议是:升级文件最好用短文件名,比如UPDATE.FW,一来避免长文件名编码和Unicode相关的坑,二来访问更快。如果一定要支持中文文件名,需要在配置里开启LFN,并处理编码转换,纯属给自己找事。
5. 进阶建议:固件校验与防篡改
最后聊聊固件安全。SD卡升级有个天然劣势——SD卡是物理可访问的,别人把卡拿走,就能拿到你的固件文件,甚至改一改再放回去,烧出恶意版本。如果你的产品有固件保护需求,不要只依赖CRC校验,CRC只是完整性校验,不是安全校验。
我现在的方案是把固件包的校验升级成SHA256 + 签名认证。对bin内容算SHA256,然后用私钥对这个哈希做签名,把签名放到文件头里。Bootloader里固化了公钥,升级时先用公钥验签名,签名通过才允许写入。这样哪怕别人改了固件内容,没有私钥也伪造不了合法签名。代价是Bootloader体积会增加一些,启动时间稍长,但对于有抄板、篡改风险的产品,这笔投资很值。
另外还可以做加密存储,把SD卡里的固件文件用AES加密,Bootloader解密后再写入Flash,这样即使卡里的文件被拷出去,别人也得不到明文固件。密钥可以存在芯片内部Flash的选项字节区域或者其他受保护区域。这套方案我落地过,逻辑不复杂,Bootloader里多一个解密步骤,但能有效防止固件被直接读取。
我个人实际操作中体会最深的一点是:SD卡升级不是难在哪个单一环节,而是整个链条上的细节太多,任何一环出问题都会导向“升级失败”这个糟糕的结果。建议你拿到文章后先把最简单的流程跑通,不加密、不校验版本,就先跳转成功一次,再逐步加安全校验,这样遇到问题也能准确定位。做完了你会发现,这套升级方案以后在项目里可以一直复用,性价比极高。
本文还有配套的精品资源,点击获取