十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SFIx变体:外部Flash Loader实现量产多段镜像烧录与校验

SFIx变体:外部Flash Loader实现量产多段镜像烧录与校验 作为一个常年跟嵌入式固件和量产烧录打交道的工程师我对“external loader”这个词再熟悉不过了。调试器JLink、OpenOCD、Keil、IAR默认只认识芯片内部的Flash一旦你需要在外部SPI NOR Flash、SD卡甚至并口NOR上跑程序就必须自己写一个外部加载器External Flash Loader把擦除、编程、校验这些操作封装成调试器能调用的标准接口。而我这次要聊的是我们在某个项目里实际用到的一种特殊变体SFIx。它不是什么官方标准而是基于通用外部Loader框架扩展出来的镜像格式变体。核心思路是把原本直接往Flash里塞裸数据的流程改造成“先打包成SFIx镜像再通过外部Loader解析写入”这样就能在量产烧录时一次性写入带校验、带块索引、甚至带压缩标记的数据。这篇文章我会把SFIx变体的设计思路、接口实现、配套的Python生成脚本和调试技巧全部拆开讲一遍代码都给可直接抄的版本适合正在做MCU外部存储烧录、量产工具、BootLoader配套开发的工程师参考。1. 项目背景与需求拆解1.1 SFIx到底是什么和普通外部Loader有什么不同先说明一下SFIx这个名字。在我们项目里它的全称是Serial Flash Image eXchange本质上是把标准的外部Loader从“裸数据搬运工”升级成一个“能识别带格式镜像的解析器”。普通的外部Loader只管三件事初始化Flash、擦除扇区、写页数据。调试器从hex/bin里读到的数据是什么Loader就原样往Flash里写什么不做二次处理。这在大多数场景下没什么问题但一旦你遇到下面两种情况就很头疼镜像里有多个子镜像Boot、App、校准数据、文件系统需要各自烧到不同偏移量产时希望用同一份固件文件通过Loader自动决定写到哪里、要不要加CRC、要不要跳过空白区域。SFIx变体解决的就是这类问题。它在Loader内部增加了一个镜像解析层主机侧生成的固件文件不再是裸bin而是带SFIx头Magic、版本、块数量、CRC、每个子块的偏移/长度/属性的镜像包。Loader在Program阶段识别这个头然后根据块表依次写入对应地址。这样主机工具比如JLink Commander、OpenOCD flash write不需要做任何额外逻辑只要把整个SFIx文件扔给Loader就能自动完成多段、多属性的烧录。1.2 这个项目要解决的三个实际问题做这个变体的直接驱动来自量产线的三个痛点烧录分段太多。产品上电后需要同时存在Bootloader和AppBootloader在0x000000App在0x080000中间还夹着一段校准参数区用普通Loader得写三次命令容易出错。烧错地址风险高。流水线上操作员手动输入地址偶尔会把App烧到Boot区域板子直接变砖返修成本高。校验逻辑不统一。之前用JLink脚本在主机侧做CRC校验但烧录工具一换就得重写一遍很烦。SFIx方案把这些问题全部压到Loader内部地址写在镜像头里操作员只需要选择文件块表里带CRC字段Loader写完后自动回读校验属性的含义必须写、可跳过、可覆盖由Loader统一解释跨工具保持一致。1.3 适合谁参考需要具备什么基础这篇内容的适用对象很明确正在做STM32/AT32/GD32之类MCU外部Flash烧录的工程师或者在做量产工装、BootLoader配套工具链的人。你需要对CMSIS-Pack里的Flash算法结构有基本认识至少知道Init、UnInit、EraseSector、ProgramPage这几个函数是干嘛的。如果你还没写过完整的外部Loader建议先把官方模板比如Keil下的FlashOS驱动跑通再来看SFIx的改造就顺了。配套的Python镜像生成脚本也附在后面这一部分门槛很低会Python就行主要用来给现有烧录流程补上一个“打包SFIx”的环节。2. 整体设计思路与格式规划2.1 架构选择为什么在Loader层解析而不是在主机侧解析一开始我们讨论过两个方案方案A在主机侧比如JLink脚本、Python脚本解析SFIx解析完再逐段调用普通Loader写入方案B在Loader内部解析SFIx主机侧每次直接把整个镜像文件丢给Loader。方案A看起来改动更小但它有个致命问题主机侧的烧录工具五花八门JLink脚本是一种写法OpenOCD是另一种将来可能还会接到自研的量产上位机上每换一个工具就得重新写一遍解析。而方案B把解析逻辑固化在Loader这个动态库里所有工具只要会调用ProgramPage就能享受同样的行为。代价是Loader本身要做一些“越权”的事情。标准ProgramPage接口相当于是“你给我一段数据我写进Flash”而SFIx需要Loader在第一次ProgramPage调用时缓存数据、识别镜像头、解析块表后续写入时根据块表决定落点。这里必须在Init函数里先把整个SFIx块表解析到位ProgramPage才能快速定位。所以我们把SFIx头设计得足够紧凑一个扇区的读取就能拿到全部块目录。2.2 SFIx镜像格式定义SFIx v1.0的格式分两个部分文件头File Header和块表Block Table。文件头固定12字节包含字段长度含义Magic4字节固定为0x53464958ASCII SFIX用于Loader识别Version2字节主版本号1次版本号0当前值为0x0100BlockCount2字节块表中块的数量当前最多支持64块HeaderFlags4字节标记位bit0表示“镜像里包含全局CRC”bit1表示“允许跳过空白扇区”块表每条16字节长度为BlockCount条紧跟在文件头后面字段长度含义DestAddr4字节该块写入的目标Flash地址DataLen4字节该块有效数据的长度Flags2字节bit01表示该块必须写bit00且全FF时可跳过Crc162字节该块数据区的CRC16Modbus多项式镜像体就是各块数据连续拼接。生成脚本会把Boot放在第一块、App放在第二块、校准参数区放在第三块三个块的DestAddr写死产线不用再手动传地址。2.3 为什么用CRC16而不是CRC32可能有人会问都2025年了做个镜像校验还用CRC16是不是太抠了这里其实是用脚投票的结果。外部Loader跑在MCU里CPU频率和高核Flash时钟都不算高CRC32在软件实现上要查两次表耗时会翻倍。而CRC16查表法算4KB数据大约在几十微秒级别量产烧录时这个开销可以忽略。另外SFIx镜像主要用于烧录正确性校验不是用于传输安全碰撞概率在产线场景可以接受。如果你非要换成CRC32只需要把生成脚本和Loader里的查表逻辑同步改掉注意头格式里Crc16字段长度要扩到4字节整个块表会变成18字节一条别搞乱偏移。3. 核心代码实现C侧Loader解析与Python镜像生成3.1 C侧Loader的SFIx初始化解析Loader侧最核心的改动集中在Init函数里。正常流程是先初始化Flash控制器然后对外部Flash做一次复位和ID读取。SFIx变体则会在这一步额外读取外部Flash的起始扇区从中解析SFIx文件头。这里有个前提需要说明我们的量产流程规定Flash上空白的起始扇区通常是第一个64KB扇区写入的就是SFIx镜像。所以Init时Loader直接把Flash地址0x000000开始的第一个扇区读到RAM缓存然后检查Magic。#include stdint.h #include string.h #define SFIX_MAGIC 0x53464958UL #define SFIX_HEADER_LEN 12 #define SFIX_BLOCK_LEN 16 #define SFIX_MAX_BLOCKS 64 #define SFIX_FLAG_GLOBAL_CRC 0x00000001UL #define SFIX_FLAG_SKIP_FF 0x00000002UL typedef struct { uint32_t magic; uint16_t version; uint16_t block_count; uint32_t flags; } sfix_file_header_t; typedef struct { uint32_t dest_addr; uint32_t data_len; uint16_t flags; uint16_t crc16; } sfix_block_entry_t; static sfix_file_header_t s_sfix_header; static sfix_block_entry_t s_sfix_blocks[SFIX_MAX_BLOCKS]; static uint8_t s_sfix_block_ram[64 * 1024]; static uint32_t s_sfix_initialized 0; static uint16_t sfix_crc16(const uint8_t *data, uint32_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *data; for (int i 0; i 8; i) { crc (crc 1) ? (crc 1) ^ 0xA001 : (crc 1); } } return crc; } int32_t Init(uint32_t adr, uint32_t clk, uint32_t fnc) { (void)adr; (void)clk; (void)fnc; // 1. Flash控制器的初始化 flash_ctrl_init(); flash_reset(); uint32_t jedec_id flash_read_jedec_id(); if (jedec_id 0xFFFFFFFF) { return 1; } // 2. 读取起始扇区里的SFIx头 flash_read(0x00000000, s_sfix_block_ram, sizeof(s_sfix_block_ram)); memcpy(s_sfix_header, s_sfix_block_ram, SFIX_HEADER_LEN); if (s_sfix_header.magic ! SFIX_MAGIC) { return 1; } // 3. 解析块表 if (s_sfix_header.block_count SFIX_MAX_BLOCKS) { return 1; } memcpy(s_sfix_blocks, s_sfix_block_ram[SFIX_HEADER_LEN], s_sfix_header.block_count * SFIX_BLOCK_LEN); s_sfix_initialized 1; return 0; }这段代码里有一个很容易忽略的细节flash_read一次性读64KB。在外部SPI NOR Flash上连续读64KB可能要好几十毫秒而JLink等调试器对Init的等待时间是有上限的。如果超时会直接报“Cannot communicate with target”这类问题我在后面的调试章节会专门讲。如果你不想在Init里读整个扇区也可以只读文件头和块表部分也就是12 block_count * 16字节通常不到1KB速度快很多。我们当时全量读是为了给后续镜像校验做缓存给ProgramPage省一次Flash回读。3.2 ProgramPage如何按块表落址标准外部Loader的ProgramPage长这样接收一个目标地址、一段数据、一个长度然后把数据写进去。但SFIx变体下主机侧烧录时往往不知道内部子镜像的实际地址它会从0x000000开始把整个SFIx文件依次当作ProgramPage的输入。所以ProgramPage内部需要做一次“地址重定向”——根据当前写入的偏移量查找它在块表里属于哪一块再把数据写到那一块的DestAddr 偏移位置。int32_t ProgramPage(uint32_t adr, uint32_t sz, uint8_t *buf) { if (!s_sfix_initialized) { return 1; } uint32_t base 0; uint32_t written 0; // 把镜像文件内的线性地址映射到块表的DestAddr if (adr SFIX_HEADER_LEN) { // 头区域直接忽略写入 return 0; } uint32_t file_off adr - SFIX_HEADER_LEN; for (uint32_t i 0; i s_sfix_header.block_count; i) { sfix_block_entry_t *blk s_sfix_blocks[i]; if (file_off base file_off base blk-data_len) { uint32_t blk_off file_off - base; uint32_t len blk-data_len - blk_off; if (len sz) { len sz; } uint32_t target blk-dest_addr blk_off; // 如果块标记为“允许跳过”且当前数据全为0xFF则跳过写入 if ((blk-flags 0x0001) 0 is_all_ff(buf, len)) { return 0; } if (flash_write(target, buf, len) ! 0) { return 1; } written len; // 写完每个块后如果启用了CRC校验则回读对比 if (s_sfix_header.flags SFIX_FLAG_GLOBAL_CRC) { uint8_t rd[64]; flash_read(target, rd, len); uint16_t crc_calc sfix_crc16(rd, len); uint16_t crc_expect blk-crc16; if (crc_calc ! crc_expect) { return 2; } } return written; } base blk-data_len; } return 0; }注意代码里对文件偏移的换算逻辑SFIx文件的前12字节是文件头之后紧跟块表然后才是各块数据所以主机侧烧录时镜像内的线性偏移和Flash物理地址是两套体系。ProgramPage收到的是“镜像文件内偏移”必须减去文件头和块表的长度才能映射到第一个数据块。当初我们第一版把文件头长度当成0来计算结果App烧进去后整个启动向量错乱调试器一跑就进HardFault。后来加了一条log才看清楚前面12字节偏移没处理。3.3 Python侧生成SFIx镜像用while循环做容量换算镜像怎么生成我们在产线用的是Python脚本输入三个bin文件boot.bin、app.bin、calib.bin输出一个sfix_image.bin。脚本里有个小细节就是如何计算每个块的容量对齐。外部Flash的扇区擦除粒度是4KB如果块的长度不是4KB整数倍擦除时会把相邻块的数据也抹掉。所以我们用while循环把每个块的长度向上对齐到4KB同时顺便算出整个镜像需要预留多少填充空间。这里需要计算一些边界值比如当块偏移位很大时直接用位运算容易看不懂我就用while循环来确认2^n的数值这也是调试时很好的辅助手段。比如在生成脚本里我们会打印2^59到底是多少用来估算一个“理论上限值”会不会越界def pow2_by_loop(n): result 1 count 0 while count n: result * 2 count 1 return result # 验证64位地址空间中非对齐边界的换算 big_boundary pow2_by_loop(59) print(f2^59 {big_boundary})这个例子在实际脚本里的作用是当我们设计SFIx块表时需要确认某些极端情况下地址偏移会不会超出32位范围。虽然目前所有子镜像都放在32位地址空间内但调试脚本加一个2^59的边界验证可以防止把data_len声明成32位时出现高位截断的隐患。当然如果你用的MCU支持64位寻址这块另说。3.4 Python侧生成SFIx镜像核心实现下面这段是生成SFIx镜像的核心代码其中用到了logging模块把日志输出到文件app_85.log方便产线追溯。import os import struct import logging logging.basicConfig( filenameapp_85.log, levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s ) SFIX_MAGIC 0x53464958 SFIX_VERSION 0x0100 SFIX_HEADER_LEN 12 SFIX_BLOCK_LEN 16 FLASH_SECTOR_ALIGN 4 * 1024 # 4KB对齐 def align_up(value, align): return (value align - 1) // align * align def crc16_modbus(data): crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): crc (crc 1) ^ 0xA001 if (crc 1) else crc 1 return crc def build_sfix(blocks, out_path): blocks: list of (dest_addr, data_bytes, flags) header struct.pack(IHHI, SFIX_MAGIC, SFIX_VERSION, len(blocks), 0x00000003) block_table b payload b for dest_addr, data, flags in blocks: data_len len(data) # 数据长度对齐工厂里方便按扇区擦写 aligned_len align_up(data_len, FLASH_SECTOR_ALIGN) extended data b\xFF * (aligned_len - data_len) blk_crc crc16_modbus(extended) block_table struct.pack(IIHH, dest_addr, aligned_len, flags, blk_crc) payload extended image header block_table payload with open(out_path, wb) as f: f.write(image) logging.info(generate %s, %d blocks, total %d bytes, out_path, len(blocks), len(image)) return len(image) if __name__ __main__: boot_data open(boot.bin, rb).read() app_data open(app.bin, rb).read() calib_data bytearray(2048) # 模拟校准区默认数据 for i in range(len(calib_data)): calib_data[i] i 0xFF total build_sfix([ (0x000000, boot_data, 1), # 必须写 (0x080000, app_data, 1), # 必须写 (0x100000, calib_data, 0), # 允许跳过 ], sfix_image.bin) print(total bytes:, total)几个关键点说明flags字段传1表示必须写块传0表示可跳过。Loader会在ProgramPage里判断如果目标区域全是0xFF就直接跳过加速烧录数据长度按4KB对齐后再做CRC这样Loader回读校验时能够按扇区边界校验避免跨扇区校验时多读一次logging输出到app_85.log这台机器上每次生成镜像都会留痕产线出问题了可以翻日志看是哪批数据生成的不对。4. 编译配置和调试环境的那些坑4.1 外部Loader的工程配置注意点SFIx变体的代码最终是要编译成外部Loader动态库Windows下是DLLOpenOCD环境下可能是so或者直接编译成特定格式的FLM。编译配置里有几个坑值得单独拎出来说。一个是PrgFunc、EraseFunc这些导出函数名必须和调试器预期一致。Keil下Flash算法使用固定函数导出名比如Init、UnInit、EraseSector、ProgramPage大小写都不能错。函数名错了调试器加载DLL时直接提示找不到接口烧录根本跑不起来。另一个是代码应该运行在RAM里而不是Flash里。外部Loader本身是调试器临时加载到MCU RAM里执行的“小固件”所以在分散加载文件sct文件里要把执行区放在RAM。如果你把代码放到了内部的Flash区域烧录时可能覆盖到正在运行的固件直接崩溃。我在第一版就吃过这个亏忘了改ROM地址结果每次Init都跳飞。链接脚本里还要预留足够的堆栈空间。SFIx解析需要在RAM里缓存整个块表和一个扇区数据我们当时把ProgramPage里最大单次写入长度设为64字节但Init里的扇区缓存就占了64KB。对STM32F103这种20KB RAM的型号肯定不够后来换成了F407才跑得动。如果你的MCU RAM偏小就得把Init里的读扇区改成只读文件头块表最大约1KB牺牲一点缓存换RAM空间。4.2 烧录时序为什么Init会超时外部Loader挂在调试器上调试器对每个接口的调用时间是有隐式限制的。JLink环境下Init如果超过一定时间通常不会太长会报“Cannot connect to target”。我上一节提到Init里读64KB外部Flash如果在低速SPI比如1MHz下要几百毫秒就有风险。我们实测把SPI时钟拉到10MHz读64KB大约6毫秒没问题但如果硬件上SPI走线长、负载大10MHz跑不稳只能降到5MHz这时读64KB也会到十几毫秒仍然安全。更麻烦的是EraseSector。SPI NOR Flash擦除一个4KB扇区典型耗时30~80毫秒JLink能容忍。但如果你用SFIx块表一次性擦除大片区域在EraseSector里写了循环擦除几十个扇区总时长可能超过1秒部分调试器会误判为假死。解决方法是EraseSector被调用时只擦除传入的那个扇区涉及多个扇区的擦除由主机侧比如JLink脚本或OpenOCD命令多次调用EraseSector每次擦一个扇区。这样单次耗时可控不会触发超时。4.3 JLink和OpenOCD下烧录SFIx镜像的参考命令调试环境搭建好后烧录命令并不复杂。JLink旁一般用JLink.exe命令行或者JFlash。JFlash里选择对应的外部Loader文件然后加载生成的sfix_image.bin起始地址填0x000000。因为Loader内部会做地址重定向这里起始地址填多少其实影响不大。关键是文件类型要选Raw Binary不要选Intel Hex否则JLink会按Hex里的地址去写那就绕过了SFIx的重定向逻辑。OpenOCD下可以这样写openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \ -c init \ -c halt \ -c flash bank external stm32f4x.flash 0x000000 0 0 0 $TARGET \ -c flash write_image erase sfx_image.bin 0x000000 bin \ -c reset注意flash write_image后面如果带bin参数也会把镜像按线性地址直接写入不会经过块表重定向。所以更稳妥的用法是让OpenOCD只把它当成一个普通二进制把整包丢给Loader靠Loader内部解析。如果要在OpenOCD里完全走外部Loader的路径可以查一下目标板对应的flash驱动配置一般需要指定driver是eflash_loader或者自定义的CFI驱动变体。这块各家的OpenOCD版本差异很大建议确认版本再折腾。5. 常见问题与排查技巧实录5.1 现象烧录完成但App起动不了卡在HardFault这是最典型的SFIx首版问题基本可以锁定为地址映射错位。当时我们烧完后用调试器读外部Flash内容发现Boot区的确写对了但App区开头多了12个FF字节。分析下来就是因为ProgramPage里没有减去文件头的12字节导致第一个块的数据从DestAddr 12开始写App的向量表整体偏移了。改动方法就是我在代码里写的file_off adr - SFIX_HEADER_LEN。如果你自己改过文件头长度或者块表结构这块偏移要同步更新否则所有子镜像都会整体飘移。排查时建议先在ProgramPage里加临时log输出adr、file_off、target这三个值跟Python脚本里预设的DestAddr对比一眼就能看出问题。5.2 现象Init可以过但ProgramPage每次都返回错误码2错误码2是我在代码里写的“CRC校验失败”。没改代码的情况下先不要在Loader里查CRC直接用逻辑分析仪或者调试器读Flash看数据是否真的写入成功。如果写入的数据和预期一致但CRC还不匹配大概率是CRC的计算范围有问题。我在Python脚本里是对“对齐后带填充FF”的数据做CRC而Loader里的flash_read回读长度是len如果len没有包含填充部分算出来的CRC必然不一致。简化处理方式是让Loader回读校验时也按对齐后的长度读。就是把ProgramPage里的len blk-data_len - blk_off改成读取aligned_len - blk_off这样CRC计算范围才能对得上。5.3 现象烧录速度极慢一个2MB镜像要4分钟排除SPI时钟频率太低的情况后最可能的原因是“跳过空白块”功能没有生效。我们量产时校准区大部分数据是0xFF如果Loader没有跳过就会老老实实擦除、写入这些空白区域白白浪费时间。检查ProgramPage里的跳过条件块标志位flags 0x0001为0表示允许跳过当前写入的数据全部为0xFF。两个条件同时满足才跳过。如果块表里flags传的是1那么即使数据全是0xFF也会强制写入速度自然快不了。另一个提速点是EraseSector。如果主机侧每写一个页就触发一次扇区擦除会产生大量重复擦除。可以在Init里记录当前擦除状态EraseSector若传入的是同一个已擦扇区就直接返回成功减少无效擦除。5.4 常见问题速查表现象可能原因排查方向Init返回失败JLink报无法连接Flash ID读取失败或SPI时序不对单独用逻辑分析仪测SPI信号排查硬件连接烧录成功但程序不跑地址重定向偏移错误检查ProgramPage是否处理文件头长度CRC一直失败校验范围没对齐确认Loader回读长度与Python脚本CRC范围一致烧录特别慢跳过空白块没生效检查Flags和全0xFF判断条件擦除时把相邻块抹掉块长度没有按扇区对齐把Python脚本里块长度align到4KB产线无法复现偶尔失败供电不稳或Flash坏块加延长供电延时量SPI电压纹波5.5 一点独家经验把SFIx头和块表放在烧录日志里这个不算技术但挺实用。我们在生成SFIx镜像时会顺带把文件头、块表信息打印成一行摘要存到和app_85.log同级的sfix_index.txt里。产线后来有次出了“烧了一半的板子换工位继续烧”的情况到第二条产线重新烧录时工程师不知道上一工位已经把Boot写好了又往外面写了一整包2MB镜像结果把原来Boot区覆盖成新版本。有了块表日志我们一查就发现两边的镜像版本不一致最终定位是产线脚本同步问题。所以只要是在做量产相关的东西日志和现场信息越详细越好这比在代码里多写几个功能点重要得多。6. 写在最后这个变体后续还能怎么扩展SFIx变体跑通之后整个烧录流程从“多个文件多次操作”变成了“一个文件一次搞定”产线效率提升非常明显。目前这个方案我们还在迭代后续有几个方向是我想继续做的在块表里加一个encrypt_flag字段对接AES-128硬件解密这样SFIx文件放到产线电脑上也不怕泄密在Loader的UnInit里加一个整体镜像指纹计算调用方可以在烧录完成后拿到一个固定字符串用于MES系统做追溯把Python生成脚本扩展成命令行工具接受JSON配置这样换项目时不用改代码改个JSON就能生成对应布局。如果你正在被外部Flash多段烧录、产线地址填错、校验逻辑分散这几个问题困扰照着这个SFIx思路做一套底层的原理是完全通用的。遇到具体实现上的问题也可以按我文里给的排查方向去定位基本都能搞定。
返回列表