从零实现QSPI Flash外部加载器:SFIx变种镜像解析与烧写全解析

📅 发布时间:2026/8/30 12:45:29
从零实现QSPI Flash外部加载器:SFIx变种镜像解析与烧写全解析 刚把SFIx变种的external loader调通趁热把代码思路和踩坑记录整理出来。这是一个给IDE和量产烧录器用的外部Flash下载算法目标是把SFIx这种带分段表和安全校验信息的固件镜像通过统一的外部加载器接口写进QSPI NOR Flash。如果你现在正卡在“标准FLM模板能跑但换成自定义镜像格式就不知道怎么解析”这个阶段这篇内容应该能帮你少走不少弯路。网上关于外部加载器的资料大部分停留在通用FLM模板的层面比如怎么配置分散加载文件、怎么实现Init和ProgramPage。可一旦镜像格式变成了SFIx变种这种相对小众的东西能参考的直接代码就非常少。我这边的项目背景是某颗MCU内部Flash只有128KB应用代码和文件系统必须放在外部QSPI Flash里通过XIP方式执行于是就需要一个能解析SFIx镜像并完整烧写的external loader。这篇文章不聊理论直接讲清楚我用的数据结构、示例代码主流程、Flash编程适配以及几个让我排查了一整晚的坑。1. 先搞清楚external loader和SFIx变种之间的分工1.1 external loader在整个工具链里的位置external loader本质上是一个动态库Keil MDK里叫FLMSTM32CubeProgrammer里叫STLDRIAR那边则有board support文件。IDE或者量产工具在烧写外部Flash时并不直接操作SPI/QSPI外设而是把这个loader加载进来然后按固定接口调用。对外暴露的接口就那么几个Init、UnInit、EraseChip、EraseSector、ProgramPage、Verify。IDE做的事很简单读取你要下载的镜像文件按照地址把数据切成块然后调用ProgramPage写入。对于MCU内部Flash这个loader通常是芯片厂商写好的对于外部QSPI Flash就得自己来。实际项目中我们经常碰到MCU内部Flash不够装整个应用的情况。把代码放到外部QSPI Flash通过memory-mapped模式XIP执行是STM32H7这类芯片的常见玩法但这样做有一个前置条件出厂时得有人把镜像写进那颗外部Flash而这个“有人”就是external loader。SFIx变种的作用是让这一过程标准化一个镜像文件里可以同时包含Bootloader、App、配置文件和数据区loader按段描述符依次写入即可。1.2 SFIx变种和普通bin、hex、ELF的区别很多人一开始会问直接用bin文件烧不行吗当然可以裸烧bin是所有方案里最简单的但bin缺少地址信息和校验信息如果文件损坏或者烧错地址很难及时发现。hex文件带地址记录但本质上还是线性地址流对“需要分段、每段带独立校验”这种需求的支持比较弱。ELF文件信息最全但那主要用于调试生产线上很少直接用。SFIxSecure Firmware Image eXtended是在安全固件镜像基础上做的一个变种它比普通bin多了一层“可被外部loader解析的分段描述表”。和标准SFI的区别在于SFIx变种没有把安全启动仓库作为唯一目标而是把“给loader烧写用的分段信息”和“给安全校验用的签名数据”同时放进一个文件里这样离线量产工具也能用同一个镜像文件。我整理了一个简单的对照关系格式地址信息分段支持校验能力生产环境适用性bin无无无一般容易烧错hex有弱弱仅行校验可用但校验弱ELF有有无适合调试SFIx变种有有CRC可选签名强适合量产1.3 为什么示例代码值得单独整理标准FLM模板里只需要实现几个下载函数就行根本不需要关心文件格式因为IDE已经把文件解析完了。但SFIx变种的loader通常有两种工作模式一种是完整解析模式loader直接接收镜像文件自己解析头部和段表然后烧写另一种是标准接口模式loader只负责按地址写数据。前者更适合量产工具因为它把解析逻辑放在端侧便于统一格式。我这次主要实现的是完整解析模式这个模式下loader需要自己完成文件解析、CRC校验、擦除规划、编程回读一套流程下来代码量其实不小。单独的模板代码根本覆盖不了这些细节所以才值得单独写一篇把核心结构和流程拆开来讲。2. SFIx镜像头部与段描述符解析完整结构体定义2.1 镜像头部结构和CRC校验SFIx变种的头部设计得很紧凑总共只占32字节。按照我的实现头部字段定义如下typedef struct { uint32_t magic; // S,F,I,X uint16_t version_major; // 主版本号当前0x01 uint16_t version_minor; // 次版本号当前0x01 uint32_t header_size; // 头部段描述符表总大小 uint32_t image_size; // 整个镜像文件大小 uint32_t segment_count; // 段描述符数量 uint32_t flags; // 功能标志位 uint32_t entry_point; // 应用入口地址 uint32_t header_crc; // 头部CRC32 } sfx_header_t;magic字段是四字节的S,F,I,X用于快速判断文件是不是SFIx变种。header_size非常关键因为它指明了段描述符表从哪里开始在解析时直接以此作为段表的偏移基址。image_size用来做文件完整性边界检查防止loader读到越界数据。CRC校验这里有一个容易踩的坑SFIx变种的header_crc覆盖范围是从文件头到header_crc字段之前而不是整个头部。这样设计的原因是为了让CRC字段本身不被包含在计算范围内否则校验时就要先把CRC字段清零再做计算多一步操作。代码里用offsetof宏可以直接定位到CRC字段的偏移干净利落uint32_t calc_crc crc32(img_ptr, offsetof(sfx_header_t, header_crc)); if (calc_crc ! hdr-header_crc) { return ERR_HEADER_CRC; }main开发调试的时候这个CRC校验其实是个麻烦事。因为镜像文件只要有任何一个字节不对解析就会失败排查起来容易让人误以为是loader的问题。所以调试阶段建议把第二步做扎实先用十六进制编辑器直接看文件头手工算一遍CRC再决定是改文件生成工具还是改loader。2.2 段描述符地址映射的关键段描述符是SFIx变种的核心每个段都是16字节的固定长度从header_size处开始连续排列。typedef struct { uint32_t src_offset; // 段数据在文件中的偏移 uint32_t dest_addr; // 目标地址Flash映射地址 uint32_t data_size; // 段数据长度 uint32_t crc32; // 本段独立CRC uint32_t flags; // 段属性 } sfx_segment_t;src_offset为什么不用“段数据直接跟在描述符后面”这种简单设计因为SFIx变种的文件尾部还挂着签名块如果段数据紧挨着段表排列签名块的插入位置会变得很难处理。用偏移量来做索引生成镜像时可以把数据区任意折叠loader只按src_offset去取即可。dest_addr是整个loader里最需要小心的字段。外部Flash的映射地址通常由MCU决定比如QSPI Flash挂在0x90000000内部Flash则在0x08000000。同一个镜像文件里可能有段要烧到外部Flash也可能有段要烧到MCU内部Flash甚至可能有段只是一些校准参数需要写到Flash末尾的固定位置。如果loader不检查dest_addr是否落在合法范围内很容易把数据写到奇怪的地方去。段属性flags我定义了以下几个bit位0x01编程前需要擦除0x02该段参与XIP执行写入时需要确保16字节对齐0x04只写不回读比如一些熔丝位或一次性配置区域0x08段数据可能未按Flash页对齐允许loader做读-改-写这些标志位非常有用。比如0x04这个只写段如果loader在量产时对每个段都做回读验证某些一次性可编程区域读写特性不同回读就会失败反而拖慢产线。有了标志位loader就能针对不同段的特性做差异化处理。2.3 尾部签名块与安全策略SFIx变种和普通bin最大的不同就是文件尾部可以挂一个签名块。这个签名块是可选的通过头部flags的bit0控制。开发调试阶段这个bit通常为0loader直接跳过签名验证只做CRC校验量产阶段上位机用私钥给文件签名loader端用内置公钥验证。签名块放在文件尾部对齐到64字节边界。签名算法我用的ECDSA P-256因为它在MCU上的验证速度比RSA快代码量也小。签名内容覆盖的范围是整个文件去掉签名块本身也就是说从文件头到签名块开始之前的所有字节。验证失败时loader应该返回一个独立的错误码ERR_SIGNATURE这样上位机才能区分“文件损坏”和“文件被篡改”。我贴一段验证流程的判断逻辑if (hdr-flags SFX_FLAG_SIGNED) { int ret verify_file_signature(img_ptr, img_size - SIGNATURE_SIZE); if (ret ! 0) { return ERR_SIGNATURE; } }这里要注意一个实际工程问题ECDSA验证在几十MHz的MCU上可能耗时几百毫秒如果产线每台设备都要验证会明显拖慢节拍。所以我的建议是量产工具里加一个开关默认开启签名验证但内部测试和返修流程可以关掉避免没必要的性能损耗。3. 从解析到烧写SFIx加载器示例代码逐段拆解3.1 主流程从打开镜像到完成烧写SFIx loader的完整解析主流程我拆成了几步读取镜像文件、校验头部CRC、遍历段描述符、逐个段擦除并编程、按需回读校验、最后做签名验证。这个顺序是有讲究的先做轻量级校验头部CRC再做重量级操作擦除和编程最后做密码学验证。如果顺序反过来遇到内容被篡改的镜像loader会先把Flash擦掉再报告签名错误产线上设备就被搞坏了。主流程的核心代码大概长这样int sfx_load_image(const uint8_t *img, uint32_t img_size) { const sfx_header_t *hdr; int ret; if (img NULL || img_size sizeof(sfx_header_t)) { return ERR_INVALID_PARAM; } hdr (const sfx_header_t *)img; if (hdr-magic ! SFX_MAGIC) { return ERR_MAGIC; } if (img_size ! hdr-image_size) { return ERR_SIZE_MISMATCH; } ret verify_header_crc(img, hdr); if (ret ! 0) { return ret; } // 遍历段表 const sfx_segment_t *seg (const sfx_segment_t *)(img hdr-header_size); for (uint32_t i 0; i hdr-segment_count; i) { ret program_one_segment(img, img_size, seg[i]); if (ret ! 0) { return ret; } } // 签名验证放到最后 if (hdr-flags SFX_FLAG_SIGNED) { ret verify_file_signature(img, img_size - SIGNATURE_SIZE); if (ret ! 0) { return ERR_SIGNATURE; } } return 0; }program_one_segment需要单独封装因为单个段的处理逻辑里既有擦除又有编程还有对齐处理不拆开的话主函数会变得很长。这种做法也方便移植换一颗Flash时只需要改编程层不需要动解析层。3.2 Flash编程层的封装思路外部Flash的编程基本遵循“先擦后写”的原则而且擦除和编程都有对齐要求。QSPI NOR Flash的页编程通常是256字节对齐扇区擦除则是4KB、32KB或64KB。如果loader拿到的目标地址或者数据长度不是页对齐的就不能直接调用页编程需要先读回旧数据修改后再写回。我封装了一层flash_erase和flash_program接口目标是把“对齐处理”和“底层驱动”隔离开来。代码大概这样static int program_aligned(uint32_t addr, const uint8_t *data, uint32_t size) { uint32_t page_size flash_get_page_size(); uint32_t page_mask page_size - 1; uint32_t offset 0; while (size 0) { uint32_t chunk page_size - (addr page_mask); if (chunk size) chunk size; // 非页对齐的头部先读-改-写 if ((addr page_mask) ! 0 || chunk page_size) { uint8_t tmp[256]; memcpy(tmp, flash_read(addr ~page_mask), page_size); memcpy(tmp (addr page_mask), data offset, chunk); flash_erase_page(addr ~page_mask); flash_program_page(addr ~page_mask, tmp, page_size); } else { flash_program_page(addr, data offset, chunk); } addr chunk; offset chunk; size - chunk; } return 0; }这段代码里最需要注意的就是读-改-写分支的处理。直接擦除会把这页数据全部清掉必须先读回旧数据再修改对应区域最后整体重写。实际生产中如果镜像生成工具足够规范将每个段都做了页对齐这个分支基本不会走到但loader里必须保留它否则碰到不对齐的段就直接把相邻数据破坏了。3.3 用Python辅助验证边界和大数计算调试SFIx loader时有一个很实际的需求验证地址对齐、计算Flash容量边界、生成测试掩码。这些数字经常是2的幂次比如64KB扇区擦除大小是2的16次方512Mbit Flash的字节数是2的26次方。在C里面随手算这些大数容易溢出我用Python比较多因为它不需要关心类型宽度。我这里顺手写了一个验证脚本同时演示了while循环计算大数和logging输出日志的用法import logging logging.basicConfig( filenameapp_85.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s ) logging.info(start align check) # 用while循环计算2的59次方和2的104次方用于边界测试 n 0 value 1 while n 104: if n 58: print(2^59 , value) logging.info(2^59 %d, value) value * 2 n 1 print(2^104 , value) logging.info(2^104 %d, value)为什么强调用while而不是直接写2 ** 104因为在实际调试中有时候需要模拟逐步逼近某个边界的场景while循环可以方便加入打印和断点。比如验证段描述符的src_offset是否越界时我就用这个脚本来生成一组间距不等的偏移量再喂给C代码解析。这段脚本跑完之后会把结果写到app_85.log文件里。我在调试loader时会同时跑编译器和行为日志方便确认某个边界值到底是用32位还是64位来存储避免在C代码里因为整数溢出产生隐蔽bug。3.4 与IDE调试器/量产工具的集成接口如果loader要接到Keil MDK的Flash下载流程里就必须按FLM标准实现几个接口。即便SFIx解析逻辑是放在上位机端loader本身仍然需要暴露标准函数给IDE调用。FLM接口的标准签名有几个关键函数我贴一下最常用的两个int Init(uint32_t adr, uint32_t clk, uint32_t fnc) { // adr外部Flash在MCU总线上的映射基地址 // clk外部存储器接口时钟频率 // fnc功能码用于区分是编程模式还是校验模式 flash_controller_init(); flash_reset(); return flash_check_id() 0 ? 0 : 1; } int ProgramPage(uint32_t adr, uint32_t sz, uint32_t *buf) { return program_aligned(adr, (const uint8_t *)buf, sz); }注意ProgramPage里的buf参数是uint32_t指针这是FLM接口规范规定的但Flash编程层需要的是字节流所以这里做了一次指针转换。实际工程中buf指向的内存不一定是32位对齐的如果底层驱动对对齐有要求就需要在program_aligned内部再加一次字节拷贝确保起始地址和长度都满足要求后再调用Flash驱动。如果你的loader只做标准接口模式不负责解析SFIx镜像那上面这些接口就足够了。但如果你要走完整解析模式还需要在Init之后、ProgramPage之前增加一个LoadImage接口由上位机先把整个镜像文件传给loaderloader解析完后再通过ProgramPage按段烧写。这个接口没有统一标准通常是自己定义的量产工具和loader需要协商好调用时序。4. 移植到目标板之后的调试经验与性能优化4.1 最小可运行环境的搭建顺序第一次把SFIx loader跑在目标板上我建议遵循从简到繁的顺序不要一上来就加载完整镜像。良好的调试顺序能把问题隔离在最小范围否则文件解析、Flash驱动、时钟配置、校验逻辑混在一起出了问题很难定位。我自己的做法分四步用裸驱动直接往外部Flash固定地址写一个pattern然后读回对比确认SPI/QSPI时钟和引脚配置正确。封装标准的Init和ProgramPage接口用IDE的下载功能烧一个裸机bin文件确认IDE和loader的通信链路正常。接入SFIx解析逻辑先用只带一个段的镜像测试确认解析和编程链路。最后再加签名验证确认公钥和签名逻辑无误。这套顺序的核心优势是每一层都有明确的验证标准。第一步失败说明驱动有问题第二步失败说明接口适配有问题第三步失败说明解析逻辑有问题第四步失败说明安全逻辑有问题。这样排查范围会小很多。4.2 踩过最深的三个坑第一个坑是QSPI时钟配置。外部Flash的datasheet里会写明最高时钟频率和dummy cycle数如果loader里配置的外部存储器时钟高于Flash支持范围读回的数据就会不定时出现错误。我刚开始调试时回读的数据有时全对有时错几个字节排查了很久才怀疑是时钟问题。后来我把时钟降了一档错误立刻消失。如果你的板子回读数据不稳定先看一眼时钟配置这比怀疑缓存问题更经济。第二个坑是D-Cache导致回读不一致。MCU开启D-Cache后通过CPU读取外部Flash映射地址时如果映射区域被配置成cacheable读到的可能是缓存里的旧数据。编程完成后做回读校验读到的是缓存旧值而不是Flash里的新值于是校验一直失败。解决方法是编程前调用CleanDCache_by_Addr或干脆把外部Flash映射区配置成Device或者Strongly-Ordered属性绕过缓存。第三个坑是擦除操作跨越了物理扇区边界。QSPI NOR Flash的扇区擦除最小单位是4KB但有些型号支持更大的块擦除。如果loader用64KB块擦除来加速而段描述符只覆盖了其中一部分那么同处一个块的其他段就会被白白擦掉造成数据丢失。解决思路是遍历所有段合并那些落在同一物理块内的区域统一擦除再逐段编程。4.3 性能优化从单段到多段的烧写时间量产工具的烧写速度直接关系到产线节拍。外部Flash擦除一个64KB的块可能需要上百毫秒如果一个镜像是几MB的量级光擦除就是好几秒这是不可接受的。所以我做了一处优化编程前先扫描目标区域是否已经被擦除即内容全为0xFF如果是就跳过擦除直接编程。这个扫描成本很低但能省下大量擦除时间。另外对于有多个段的镜像如果几个段的地址落在同一个擦除块内可以先把这些段的数据合并到一个临时缓冲区统一擦除后再一次编程。这个优化能把多次擦除降到一次效果立竿见影。如果镜像里包含只写不回读的配置段比如0x04标志位的段loader可以跳过Verify流程直接进入下一段也能节省一部分时间。但请注意这里有一个权衡跳过回读意味着无法在编程阶段发现坏块我的建议是仅在产线异常率极低的情况下开启否则还是老老实实做回读校验。5. 常见问题排查与速查表5.1 典型失败现象速查调试loader过程中失败现象其实非常集中。我把最常见的几类整理成一个表格方便排查时对照现象可能原因排查步骤一上来magic error文件偏移错误、字节序不对用十六进制编辑器看文件前16字节header_crc校验失败上位机发送不完整、文件损坏重新生成镜像确认发送链路Init返回失败QSPI引脚配置错误、Flash ID不匹配先只跑JEDEC ID读取ProgramPage写入失败Flash未初始化、地址越界检查Init是否已调用、目标地址是否在映射区Verify回读不一致D-Cache未清理、时序参数有问题先关Cache再降时钟频率擦除后相邻数据丢失使用了过大的擦除块、未做块边界检查打印所有段的地址区间检查物理块覆盖关系排查的时候我习惯从外到内先确认工具链链路再查loader逻辑最后查Flash驱动。顺序反过来很容易在乱七八糟的症状里迷失方向。5.2 一个完整的排查案例实录有一次我在量产工具上跑SFIx镜像ProgramPage返回成功但Verify失败。最开始我怀疑是Flash驱动时序问题把dummy cycle改宽、时钟改慢结果错误依然存在。后来我加了一个打印函数把回读的前16字节打出来发现前8字节是对的后8字节完全不对。这个现象和内容有关和地址无关说明不是时序错位。我又检查了缓冲区发现ProgramPage里传进来的buf是局部变量只做了4字节对齐而我在底层驱动里用了DDR模式读取要求32字节对齐。问题就在这DDR模式下读取QSPI控制器会把读缓冲区地址强制对齐到某个边界导致我的数据错位。最后的修复很朴素给缓冲区加上__ALIGNED(32)修饰或者在驱动内部做一个字节拷贝确保DMA目标地址严格对齐。这个问题在MCU主频不高、缓存严格一致的单片机上可能不会出现但只要用了DMA内存对齐就是一个永远躲不开的话题。5.3 一些能帮你省时间的调试建议最后再补几招都是我这段时间攒下来的实操习惯。首先给loader代码加一个自检命令也就是说当上位机没有连接时loader自己可以往Flash写一段预设数据再读回来用于验证硬件通路。这个功能在硬件调试阶段非常好用能省去反复配置IDE的时间。其次日志输出一定要分级。我习惯把日志分成init、parse、erase、program、verify几个阶段每个阶段固定打一行带时间戳的日志。量产阶段通过对比日志里每个阶段出现的顺序能快速判断是解析挂了、驱动挂了还是校验挂了。如果日志一直停留在erase阶段那肯定是指定的段地址与物理Flash不匹配。还有一个很多人忽略的点SFIx镜像生成工具的字节序必须和loader保持一致。如果上位机在x86机器上生成镜像默认是小端序而MCU通常也是小端这没问题但如果目标MCU是大端内核镜像就必须在生成时做字节序转换。这类错误很难一眼发现因为它表现为某些字段正常、某些字段异常非常容易误导排查方向。我个人在实际操作中的一个体会是SFIx这种带分段表和安全信息的镜像格式在量产和OTA场景里会越来越常见写loader时把解析、校验、烧写三块代码彻底分开后续无论是换Flash型号还是换MCU平台代码复用率都会非常高前期那点抽象成本绝对值得。