做嵌入式开发,存储空间不够用是迟早要面对的事。我的一个量产项目里,APP固件到了300多KB,而内部Flash总共只有512KB,除了代码还得放字库、参数和OTA临时缓存,实在塞不下了。最后只能把APP主体放到外部SPI Flash上。可放过去以后,另一个麻烦立刻冒出来了:上电后启动明显变慢,从按下电源到界面能点,差不多要等一两秒。这个场景其实非常典型——MCUBoot协议负责安全启动和固件升级没问题,但外部SPI Flash的加载速度成了整个启动链路里绕不过去的新瓶颈。为了解决这个瓶颈,我做了turbo-spiboot,一个基于MCUBoot协议的二级SPI加载APP提速方案,核心目标只有一个:让挂在SPI Flash上的APP,启动速度快到接近从内部Flash直接启动的效果。
这个方案不挑具体MCU品牌,只要你的平台满足两个条件就能用:一是内部Flash里放得下一个精简的二级引导程序;二是有SPI或QSPI控制器可以接外部Flash。ARM Cortex-M系列、部分RISC-V芯片都能跑。适合谁参考?如果你是做量产产品、刚好被内部Flash容量卡住,或者想把APP代码扔到外部Flash顺便做OTA升级,这篇文章应该能帮你省掉不少弯路。下面我把设计思路、提速手段、代码实现和踩坑记录一次讲清楚。
1. 为什么需要二级SPI加载:当内部Flash装不下APP时
1.1 从一张“存储地图”看懂整机启动链路
先别急着上代码,你得先画一张存储地图,把整个启动链路在脑子里过一遍。典型的设计是这样的:MCU内部Flash被切成了两块,一块放一级引导程序(也就是MCUBoot本体),另一块放二级引导程序;外部SPI Flash则放着真正的APP镜像,以及OTA升级所需的备份区和状态区。
上电后的完整链路是:芯片BootROM → 一级引导(内嵌MCUBoot) → 二级引导(turbo-spiboot) → APP。
这里有个问题:既然MCUBoot已经在内部Flash里了,为什么还需要一个二级引导?原因很直接——MCUBoot本身的功能重心是固件验证和升级管理,它验证完镜像的签名和哈希之后,下一步总得有人把外部SPI Flash里的APP搬到可执行的地方去。这个“搬运”动作看似简单,其实牵扯到SPI Flash初始化、驱动配置、DMA搬运、镜像解析、跳转地址计算等一堆细节。如果都堆在MCUBoot里,引导程序体积会膨胀,而且MCUBoot的版本升级也会牵连这部分代码。拆成独立的二级引导,职责单一,出了问题也好定位。
再说一个很多人忽略的点:二级引导跑完后,内部Flash里这块区域如果不再被用到,它的空间完全可以留给后续OTA回调使用,也可以继续保留作为故障回滚的保险。分区规划得当,后面维护会非常舒服。
1.2 MCUBoot在方案里的角色
MCUBoot是开源生态里很主流的一套引导方案,它的设计目标很明确:为嵌入式设备提供一套安全可靠的启动和固件更新框架。放到我们这个方案里,它主要负责两件事:第一,校验APP镜像的完整性,防止升级过程断电或者传输损坏导致固件不可用;第二,管理固件切换,就是A/B分区、回滚标志这些机制。
这里的核心关系是:MCUBoot不关心APP存在哪里,它只负责“能不能用”的判断。真正决定“怎么把APP取出来执行”的,是二级引导程序。所以turbo-spiboot其实是在MCUBoot的生态协议下,补齐了“SPI Flash加载”这一环。
有个概念容易混淆:MCUBoot的镜像头部(Image Header)和TLV区域(包含哈希、签名)是固定格式,二级引导在搬运之前,至少要确认这个头部能解析、镜像长度符合预期。至于签名校验,如果一级引导已经做过了,二级引导可以只做一个轻量级CRC或者直接跳过校验,把时间省下来。这个取舍后面会细讲。
1.3 “慢”到底慢在哪:一次典型加载的耗时解剖
在动手优化之前,先把一次“朴素”的加载过程拆开看看。假设直接用通用SPI接口,以最常见的W25Q128 Flash为例,默认读命令是0x03,每读一页数据,协议上是这样的:先发1个字节的命令0x03,接着发3个字节的地址,然后Flash才开始逐字节输出数据。如果你用轮询方式逐字节读,每读一个字节还要等SPI外设把数据接收完,再手动存到内存数组。
这个过程的耗时可以拆成三块:SPI时钟本身的时间、协议开销(命令+地址+dummy周期)、CPU参与搬运的时间。
以50MHz SPI时钟来算,纯串行传输的理论吞吐是50Mbps,也就是6.25MB/s。但0x03命令每4个字节里只有1个字节是有效数据(命令+3字节地址),实际吞吐要往下打折,大概只有5MB/s上下。如果代码里再一字节一字节地读,中间不断查询SPI状态寄存器,吞吐可能掉到1-2MB/s。一个300KB的APP,光是加载就要几百毫秒,加上校验、跳转,启动时间直接就爆了。
所以,提速的本质不是单一手段,而是从“时钟频率”、“协议模式”、“搬运方式”三个维度同时下手。下面一节挨个说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 提速的本质:把SPI加载从“搬运工”变成“输送带”
2.1 选对读命令:普通Read和Fast Read的差距
很多人知道SPI Flash有Fast Read命令,但不知道什么时候该用、能省多少时间。这里我把常用读命令拉一个对比,你一眼就能看出差距。
| 命令 | 命令字节 | 地址字节 | dummy周期 | 数据线宽 | 典型场景 |
|---|---|---|---|---|---|
| 0x03 Read | 1 | 3 | 0 | 1-bit | 低速、兼容性最好 |
| 0x0B Fast Read | 1 | 3 | 8 | 1-bit | 20-50MHz常用,读吞吐提升 |
| 0x3B Dual Output | 1 | 3 | 8 | 2-bit | 需要双线支持 |
| 0x6B Quad Output | 1 | 3 | 8 | 4-bit | QSPI控制器常用 |
| 0xEB Quad I/O | 1 | 3(部分4字节模式) | 6或4 | 4-bit | 高吞吐、XIP场景首选 |
关键在于:0x03虽然协议简单没有dummy周期,但Flash芯片在这种模式下最高支持频率往往被限制在33MHz左右(具体看芯片手册)。而0x0B Fast Read命令允许把时钟拉到50MHz、80MHz甚至更高,因为8个dummy周期给Flash芯片留出了准备数据的时间。也就是说,你能跑多快频率,很大程度取决于你用的命令。
实际项目里,如果MCU只有普通SPI控制器,我建议直接把读命令从0x03切到0x0B,同时把SPI时钟提到Flash规格允许的上限。这一步几乎不需要改硬件,纯软件就能拿到明显收益。如果你的MCU有QSPI控制器(比如STM32的QUADSPI、NXP的FlexSPI),那就直接上0xEB命令,四线模式下时钟可以跑到100MHz以上,吞吐能到20-30MB/s级别。
2.2 驱动层面的三条提速路:时钟、DMA、缓存
选对了命令,接下来是驱动层面的优化。我总结为三条路,按性价比排序。
第一条,拉高SPI时钟。这个看着简单,但坑不少。开启Fast Read之后,还要确认Flash芯片支持的最高时钟频率。比如W25Q128在Fast Read模式下最高可以跑到133MHz(部分型号),但普通STM32F4的SPI外设最高只能到42MHz左右,这时瓶颈在MCU外设,不在Flash。换用有QSPI控制器的芯片,或者支持更高时钟的型号,才能吃满Flash性能。
第二条,上DMA,让CPU从搬运苦力中解放出来。DMA负责把SPI接收到的数据直接搬到内存缓冲区,CPU只需要在DMA传输完成中断里处理业务逻辑。这里有个很容易被忽视的细节:DMA搬运之前,要确保目标内存缓冲区是32位对齐的。很多MCU的DMA控制器要求源地址、目的地址和数据长度都满足对齐条件,否则会报错或者搬运数据错位。初始化时用__attribute__((aligned(32)))这类关键字声明缓冲数组,能避免三分之二的“灵异问题”。
第三条,连续读取,减少Flash内部的页切换开销。SPI Flash虽然支持任意地址读取,但它的内部缓存机制是分页的(常见页大小256字节)。如果你一块一块地随机读,每次都要重新发命令和地址;如果按扇区或更长的块连续读,Flash内部可以有效预取,DMA也能保持持续的传输流水。实操时我习惯按4KB一块来搬,这样既不会把内存缓冲区撑爆,又能把连续读取的优势发挥出来。
2.3 校验与搬运并行:别让CRC成为串行瓶颈
很多人在加载流程里是串行做的:先等DMA把整个镜像搬到RAM,再跑一遍CRC校验,再跳转。这个顺序没错,但效率很低。尤其在大镜像场景下,校验阶段可能占掉总耗时的一半。
优化思路是“边搬边校验”,或者说“流水线式校验”。把镜像分成多个块,DMA搬第一块时,CPU不闲着,直接算第一块的CRC;等DMA搬完第二块,第一块的CRC也早就算完了。这样整体耗时大约等于“搬运总时长 + 最后一块的校验时间”,而不是“搬运总时长 + 校验总时长”。
具体实现上需要一个乒乓缓冲(双缓冲)机制:两个缓冲区轮流使用,一个给DMA写,一个给CPU读。DMA搬运完成中断里切换缓冲区,同时通知主循环去校验另一块。一开始写这个逻辑容易踩同步问题,我的建议是先画状态机:缓冲区状态分为“空闲”、“DMA写入中”、“等待CPU校验”,每个缓冲区只允许在“空闲”状态下被DMA触发,CPU校验完必须把状态归回“空闲”,这个闭环稳定了再优化。
3. turbo-spiboot整体设计思路
3.1 方案架构与代码分层
turbo-spiboot虽然是个引导程序,但它的代码要按可维护的标准来组织,不能因为是“临时启动代码”就随便写。我的分层习惯是三层:底层驱动层、传输协议层、加载应用层。
底层驱动层负责SPI控制器初始化和Flash读命令封装。具体一点,就是初始化GPIO复用为SPI功能、配置SPI时钟、片选(硬件片选或软件片选都可以,看板子布线)、封装spi_flash_read函数。需要特别留意的点:有些板子走的是软件片选,如果SPI总线上还挂了别的从设备,片选控制不当会导致总线冲突。加载期间建议把SPI总线占用权明确锁给Flash。
传输协议层负责MCUBoot镜像格式的解析。MCUBoot的镜像头部字段比较固定,核心要拿到的信息是镜像总长度、加载地址(image_load_addr)和头部长度。这里我建议把镜像头部结构体定义为__packed,避免不同编译器下结构体对齐导致字段解析错位。曾见过一个案例,因为结构体默认对齐,读image_load_addr时多拿了两字节,跳转直接HardFault。
加载应用层则是整个方案的灵魂,负责把镜像从SPI Flash搬到目标内存、做必要的CRC校验、最后准备跳转环境。这个层面对的是具体MCU,需要处理缓存、中断、向量表这些细节。建议把这一层写得尽量薄,方便以后换平台时只重写这一层。
3.2 镜像分区与存储布局
分区规划直接影响后续升级策略和加载效率。这里给出一个经过量产验证的存储布局示例:
| 区域 | 存放内容 | 容量建议 | 说明 |
|---|---|---|---|
| 内部Flash 0x0000000 | 一级引导(MCUBoot) | 64KB | BootROM启动后进入 |
| 内部Flash 0x1000000 | 二级引导(turbo-spiboot) | 32KB | 从外部Flash搬运APP |
| 外部SPI Flash 0x000000 | 配置参数区 | 4KB | 存放启动参数、跳转地址 |
| 外部SPI Flash 0x001000 | APP镜像A区 | 按APP大小对齐到扇区 | 当前运行镜像 |
| 外部SPI Flash 0x100000 | APP镜像B区 | 同上 | OTA备份/回滚镜像 |
| 外部SPI Flash 0x200000 | 升级状态区 | 4KB | 升级标志、失败计数 |
注意一点:内部Flash的容量余量不要卡太死。二级引导要在启动时完成Flash初始化和搬运,代码体积加上栈和缓冲区,至少需要16-32KB宽松空间。如果内部Flash总容量只有64KB,一级引导加二级引导可能就占掉大半,这时就要考虑把二级引导的缓冲区分块缩小,比如用2KB的单缓冲而不是4KB的双缓冲,牺牲一点并发度换空间。
3.3 对接MCUBoot的关键点
对接MCUBoot时,有几个容易踩的协议细节。第一是镜像头部的字节序。不同MCU架构下,头部字段可能是大端也可能是小端,建议统一按小端解析,因为大多数ARM MCU都是小端模式。解析时还是那句话,用无符号整数类型读,不要用有符号类型,否则长度超过0x7FFFFFFF的镜像会解析成负数。
第二是加载地址的处理。MCUBoot头部里的image_load_addr并不一定等于APP实际的链接地址,它可能是0,也可能是内存映射后的地址。二级引导的跳转地址应该以APP链接脚本里的向量表起始地址为准,头部里的加载地址只能作为搬运目标地址的参考。实操中我遇到过头部里加载地址带偏移的情况,跳转前一定要再核对一次向量表的前两行(初始栈指针和复位向量)。
第三是启动标志的交互。MCUBoot会在升级状态区写一些标志位,比如“当前镜像有效”、“固件已提交”等。二级引导在搬完APP之后,建议根据标志位决定是否走正常的“搬运→跳转”流程,还是“搬运→回滚→跳转备份区”。这块逻辑不复杂,但写错会导致升级成功后第二天启动变成旧版本。
4. 实操:从零实现一个turbo-spiboot
4.1 环境准备
硬件上,我以一块带SPI Flash的板子为例:MCU选择常见的Cortex-M4内核,内部Flash 256KB起步,SPI控制器支持DMA;外部Flash用W25Q128,SPI时钟跑40MHz(保守选择,兼容性更好)。调试工具用J-Link,串口打印用来输出启动日志。
软件上,IDE可以用MCUXpresso或Keil,根据你最熟悉的来。重点是把以下底层驱动准备好:
- GPIO初始化:把SPI引脚(SCK、MOSI、MISO、CS)复用为SPI功能;
- SPI外设初始化:配置为主模式、8位数据宽度、CPOL/CPHA为模式0(Flash常见为mode 0或mode 3,以手册为准);
- DMA初始化:配置接收通道,源地址为SPI外设数据寄存器,目标地址为内存缓冲区;
- 一个能打印的串口,方便输出各阶段的耗时。
4.2 核心代码流程
下面这段伪代码是turbo-spiboot的主体流程,你直接照着搭框架就行:
c复制/* 伪代码:turbo-spiboot 主流程 */
void turbo_spiboot_main(void)
{
spi_flash_init(); // 初始化SPI Flash
spi_flash_fast_read_enable(); // 切换Fast Read / Quad模式
// 1. 解析MCUBoot镜像头,拿到总长度和加载地址
spi_flash_read(APP_IMAGE_HEADER_OFFSET, (uint8_t *)&img_hdr, sizeof(img_hdr));
if (img_hdr.magic != MCUBOOT_MAGIC) {
error_handler(); // 镜像不合法,停在二级引导
}
uint32_t image_size = img_hdr.image_size;
uint32_t load_addr = img_hdr.load_addr;
// 2. 分块搬运 + 边搬边CRC
uint8_t buf[2][4096] __attribute__((aligned(32)));
uint8_t active_buf = 0;
uint32_t offset = APP_IMAGE_HEADER_OFFSET + img_hdr.header_size;
uint32_t remain = image_size;
uint32_t crc_acc = 0;
uint32_t dst = load_addr;
while (remain > 0) {
uint32_t chunk = MIN(remain, sizeof(buf[0]));
// 等待上一个DMA块校验完成
wait_buffer_free(active_buf);
spi_flash_dma_read(offset, buf[active_buf], chunk);
offset += chunk;
// 立即校验“上一次”完成的块,搬运和校验重叠
crc_acc = crc32_continue(buf[1 - active_buf], last_chunk, crc_acc);
load_and_jump_prepare(dst, buf[active_buf], chunk); // 可选的缓存预取
dst += chunk;
remain -= chunk;
active_buf = 1 - active_buf;
}
// 3. 等待最后一个块校验完成,再整体CRC核对
wait_dma_idle();
crc_acc = crc32_finalize(crc_acc, last_block_len);
if (crc_acc != expected_crc) {
error_handler();
}
// 4. 准备跳转环境:关中断、关缓存、设置VTOR、跳转
jump_to_application(load_addr);
}
这段代码有几个关键点要展开说明。
第一个是wait_buffer_free。它实际上是在等缓冲区状态机从“等待CPU校验”回到“空闲”。我在实现时用的是事件标志位,而不是简单的delay。DMA传输完成中断里会置位一个事件标志,主循环里用事件等待的方式同步,这样不会浪费CPU空转。
第二个是spi_flash_dma_read。这个函数是有进步的:它用DMA从Flash读入一整块到指定缓冲区。注意第一个块的CRC可能没有对应“上一块”可算,所以循环里我留了一个last_chunk变量记录上一次实际搬运的字节数,保证第一轮CRC计算的正确性。上面伪代码为了简洁没有把第一轮单独处理,实际编码时建议把第一次读取提到循环外,先触发一次DMA,再进循环,这样逻辑更清晰。
第三个是jump_to_application。跳转前要做四件事:关闭全局中断;如果开了数据缓存,先把缓存里的脏数据写回(clean)并禁用缓存;把向量表基地址寄存器(VTOR)指向APP的新向量表地址;取向量表第二个字作为栈指针、第一个字作为复位向量,然后函数指针跳转。漏掉任何一步都可能在APP侧产生诡异问题。
4.3 启动时间的实测结果
在40MHz SPI时钟、Fast Read命令、双缓冲+DMA的条件下,我实测了一个312KB的APP镜像,完整加载加CRC校验加跳转,耗时约180ms。对比优化前的轮询逐字节读取方式,耗时约1.4s,提速接近8倍。
如果把SPI时钟提到80MHz并启用Quad模式,理论上可以压到60-80ms。再激进一点,如果使用支持XIP的QSPI Flash(比如i.MX RT系列的FlexSPI直接内存映射执行),甚至可以不搬运代码,直接从外部Flash执行,启动时间能到几十毫秒以内。但XIP执行速度比SRAM慢,而且对Flash性能要求更高,属于另一种取舍。turbo-spiboot的价值就在于:不需要换更贵的XIP Flash,仅靠现有SPI Flash就能获得接近XIP的启动体验。
5. 常见问题与排查技巧实录
5.1 SPI读出的数据错位怎么办
这是最容易遇到的问题:DMA搬回的数据,前几个字节是对的,后面整体错位。排查步骤按这个顺序来:
- 确认SPI时钟频率没超Flash规格。我曾经为了追求速度把时钟拉到Flash标称极限以上,结果高速时读取不稳定,降到规格内就好了。
- 确认Fast Read命令的dummy周期配置正确。0x0B需要8个时钟的dummy,如果配置成0或者4,数据时序直接不对。
- 用逻辑分析仪抓波形,看MISO上的数据位和SCK沿是不是对齐。SPI有四种模式,Flash常用mode 0(CPOL=0, CPHA=0)或mode 3(CPOL=1, CPHA=1),不匹配会导致采集到的数据整体错位一个周期。
5.2 DMA搬运越界或目标地址不对齐
DMA配置里最容易犯的错是缓冲区地址不对齐。很多MCU的DMA要求外设到内存传输时,目标地址按数据宽度对齐,比如32位宽度要求地址能被4整除。解决办法是在缓冲区定义时加上对齐属性。另外,DMA计数器配置的是字节数还是字(半字)数,不同MCU差别很大,读参考手册时一定要看清楚。我的经验是统一用字节数配置,让DMA内部去处理宽度转换,不容易绕晕。
5.3 校验失败
先分清是哪个环节的校验失败。如果是MCUBoot的签名校验失败,大概率是镜像本身没被正确签名,或者密钥不匹配;如果是二级引导的CRC校验失败,优先怀疑DMA搬运过程中数据被改动,比如缓冲区被中断服务程序踩了、缓冲区大小不够导致越界写。
还有一种情况:你校验的是整个镜像文件,但镜像头部里的image_size字段可能不包含TLV区域,或者恰好包含了,要按MCUBoot协议里的定义严格对应。我建议先在校验前把镜像长度的计算方式打出来,和实际文件大小核对一遍,很多时候问题就出在这一两字节上。
5.4 跳转后进HardFault
这个坑最让人头大。跳转后HardFault,最常见的三个原因:
- 跳转地址没指向APP向量表,而是指向了向量表偏移后的某个位置。向量表的起始位置应该是
load_addr,要确保load_addr和APP链接脚本里的FLASH_ORIGIN一致。 - 没关闭旧的SPI外设中断或DMA中断。APP启动时如果还有未处理的中断请求挂起,会把处理器引导到一个无效的中断服务函数上。
- 在跳转前打开了D-Cache又没clean。缓存里的脏数据没写回内存,APP启动后访问到的是过期数据,典型表现是全局变量初始值不对。解决办法是在跳转前执行
SCB_CleanDCache()并禁D-Cache。
5.5 启动提速的边界在哪里
很多朋友问:既然这个方案这么好,是不是所有场景都能上?我的回答是,要看两个条件。第一,你的APP是否对实时启动有极高要求,比如车机、医疗设备这类上电几百毫秒内必须出画面的场景。如果是,再优化一下,60ms以内基本是这套方案的极限,再快就得换XIP Flash或者直接内存映射方案。第二,你的SPI Flash总线上是否还挂了其他设备。如果SPI总线共享,二级引导跑的时候必须考虑总线占用,其他设备处于什么状态,否则会有死锁风险。
在实测环境里,我还发现一个容易被忽略的隐性开销:编译器的代码优化等级。Debug版(-O0)的CRC计算函数比Release版(-O2)慢好几倍,我一开始没注意,导致实测数据忽高忽低。后来统一用-O2编译,数据才稳定下来。别觉得这是小事,启动时间评估阶段,连一个编译选项都可能影响最终结果。
最后分享一个我自己用下来的心得:做这类引导加载优化,不要一上来就追求极致参数,先把基础链路跑通、打印出每一阶段的耗时占比,再针对占比最大的环节优化。有时候你以为瓶颈在SPI时钟,实际瓶颈可能在CRC校验函数写得不够高效;有时候你以为需要上四线模式,实际只要把DMA配置对,普通模式就够用了。turbo-spiboot这个项目也是一步步迭代出来的,从最初的轮询版到现在的双缓冲流水线版,每次只改一个变量,用数据说话,这样出来的方案才能经得起量产项目的考验。
