很多人把STM32玩得越来越顺手,但一提到“程序从外部Flash里跑”,第一反应往往是“把BOOT引脚拨一下不就行了”。实际上STM32上电后能选的启动源就那几个:主Flash、系统存储器、SRAM,外部SPI Flash根本不在BootROM的选择列表里。那大家口里的“从外部Flash启动”到底是怎么实现的?这中间其实藏着两条完全不同的技术路线,我见过太多人在这个地方绕圈子,几天下来毫无进展。
写这篇文章的直接原因是最近好几个做产品的朋友都在问类似问题:内置Flash不够用,想接一颗W25Q64放固件;OTA升级时想用外部Flash当双备份区;还有的干脆就想把程序放外部Flash执行,省掉内置Flash有限的擦写寿命。这三类需求听起来差不多,实际工程做法完全不一样,选错方向后面全是坑。这篇文章就拿最常见的W25Q64展开,把两种主流方案的原理、硬件连接、代码、下载方式和排查经验全部摊开讲。适合正在做OTA、想给产品固件扩容的工程师,也适合把标准外设刚学完、准备进阶的在校同学。
1. 为什么放着内置Flash不用,偏要折腾外部Flash?
1.1 内置Flash的三个硬伤
先说最直接的痛点:容量。STM32F103主流的C8T6只有64KB Flash,ZET6也就512KB,F407顶到1MB。放到今天的产品里,跑个RTOS、挂个图形界面、存几套字库、再放两版OTA固件,1MB根本不够看。更要命的是擦写寿命,内置Flash标称一般是一万次左右,做数据记录、频繁配置更新、OTA反复升级的场景,很快就会有擦写接近上限的风险。另外内置Flash容量越大,芯片价格爬得越快,而一颗8MB的外部SPI Flash只要几块钱,这个成本差距确实让人没法忽视。
1.2 “从外部Flash启动”到底有几种玩法?
这是全文最关键的认知,不搞清楚后面代码全白写。“从外部Flash启动”在实际项目里至少有三层含义。
第一层是“搬运执行”,Bootloader上电后把外部Flash里存的程序读出来,放到内部Flash的应用程序区或SRAM里再跳转。这是最通用、最稳妥的做法,STM32F1到H7都能干,代价是启动前有一小段拷贝时间。
第二层是“加载回写”,严格说不算从外部启动,但很多产品都这么做:外部Flash只是当仓库,固件平时存在里面,上电后Bootloader先把固件搬回内部Flash再执行。
第三层是“XIP内存映射”,直接让CPU从外部Flash映射出来的地址取指执行,比如STM32F7/H7的QSPI外设可以把外部NOR Flash映射到0x90000000这个地址段,PC指针直接指过去就能跑。这才是字面意义的“从外部Flash启动”,但只有特定芯片支持,而且启动顺序上有坑。
很多人把这三件事混在一起聊,结果方案选型从一开始就错了。下面我把它们的核心差异整理成一张表,看得更清楚。
| 方案形态 | 程序最终在哪执行 | 是否需要内部Flash配合 | 适用芯片 | 复杂度 | 运行速度 |
|---|---|---|---|---|---|
| 搬运到SRAM执行 | SRAM | 需要Bootloader | 主流Cortex-M系列 | 中 | 快,但SRAM容量限制大 |
| Bootloader加载回写 | 内部Flash | 必须 | 所有STM32 | 低 | 与内置Flash一致 |
| QSPI内存映射XIP | 外部Flash | 需要极简二级引导 | F7/H7等带QSPI | 高 | 依赖Cache配置,略慢 |
本文重点讲第二种和第三种,因为第一种受SRAM容量限制太死,实用价值有限。
1.3 为什么拿W25Q64当教材
W25Q64是Winbond出的64Mbit(8MB)SPI NOR Flash,采用标准SPI和Quad SPI接口,读写速度快、驱动资料多、价格便宜,几乎是开发板上的标配。网上关于它的驱动代码铺天盖地,但它不止能存字库、存图片、存日志,完全可以跑程序。因为它支持按扇区擦除、按页编程,时序简单,而且有成熟的Quad指令,做XIP的时候可以直接用0x6B或0xEB这种Fast Read指令提升取指速度,不像普通EEPROM那样只能按字节读。另外它的擦写寿命标称10万次,做OTA升级仓库比内置Flash轻松太多。
2. 动手前的硬件准备:W25Q64与STM32怎么接线
2.1 W25Q64引脚速览与SPI接口连接
W25Q64核心引脚就6个:CS(片选)、CLK(时钟)、DI(MOSI)、DO(MISO)、WP(写保护)、HOLD(保持)。其中WP和HOLD如果不做特殊处理,一般直接拉高,否则编程时可能被意外触发保护或暂停。
以最常见的STM32F407为例,用SPI1接口接W25Q64,最常用的连线方式是这样:
- W25Q64 CS → PA4(软件管理片选,非常关键)
- W25Q64 CLK → PA5
- W25Q64 DI → PA7
- W25Q64 DO → PA6
- WP和HOLD直接接3.3V
这里有个很多新手踩过的坑:CS不要接到SPI硬件的NSS引脚上,除非你想体验一把自动片选带来的酸爽。实际项目里我始终奉行“CS引脚一定用普通GPIO软件控制”,因为SPI硬件NSS在多设备总线、通信被打断、以及后续做DMA、做Bootloader跳转时都有各种隐性时序问题。GPIO控制CS,简单直接,必要时还可以手动拉低强制复位Flash状态机。
2.2 如果要用QSPI内存映射,连接又不一样了
如果走XIP路线,就必须用带QSPI外设的芯片,比如STM32F767、H743。W25Q64本身支持Quad SPI,但要用四线模式,就不能只接四根线了,得把IO0到IO3全部接上。
我以STM32F767的QUADSPI为例,常用复用关系如下:
- QSPI_CLK → PB2
- QSPI_BK1_IO0 → PB6
- QSPI_BK1_IO1 → PB5
- QSPI_BK1_IO2 → PB7
- QSPI_BK1_IO3 → PB4
- QSPI_BK1_CS → PB10
不同开发板可能走了不同的复用引脚,所以最稳的方法还是在CubeMX里打开QUADSPI后,查看芯片引脚复用表确认一遍,别盲抄网上的接线图。硬件上还有几个经验:供电必须干净,W25Q64的VCC旁边放一颗100nF去耦电容;WP、HOLD必须接上拉,特别是HOLD,悬空时只要线上毛刺一抖,Flash就会进入保持状态,表现为“读数据偶尔卡死”;SPI信号线不要飞线超过10cm,CLK和IO线尽量等长,否则频率拉高后容易误码。
2.3 CubeMX初始化SPI/QSPI的几个关键配置
用SPI接口时,CubeMX里把SPI1设为全双工主机,速率先压低,比如2分频,配置为8位数据宽度,CPOL和CPHA设成模式0(W25Q64支持模式0和模式3,模式0更常用)。这里多说一句,很多例程清一色写模式0,如果你的板子上Flash和主控之间串了很长的排线或者转接板,可以试试模式3,抗干扰能力会好一些。
如果是QSPI方案,CubeMX里选择QUADSPI后,重点看几个参数:
- Flash Size:这里比较容易犯迷糊。QSPI外设内部是按地址线数量记录Flash大小的,公式是FlashSize = 2^(FLASH_SIZE + 1),所以8MB的W25Q64对应的是2^23字节,寄存器位的值应该是22。CubeMX图形界面上有些版本直接让你选“FLASH_SIZE_CS = 22”,有些版本会写成以字节为单位的指数,不管界面长什么样,记住最终寄存器值要能表示2^23字节就不会错。
- Clock Prescaler:先给个大的分频值,比如4分频甚至8分频,调通之后再根据实测波形往上提频率。
- Dummy Cycles和Read Instruction:这两个参数直接影响后续内存映射能否正确读到数据,后面第4节专门讲。
- FIFO Threshold默认就行,后面用DMA或内存映射时可能要重新调,跑起来再说。
CubeMX配置完成生成代码之后,SPI驱动基本不用动,但QSPI的初始化代码建议自己把寄存器再看一遍,因为CubeMX生成的代码只保证“能通信”,不保证“内存映射配置最优”,这一点我们后面展开。
3. 方案一:内部Flash放Bootloader,从外部Flash把程序“请”回来
3.1 整体架构和分区规划
这个方案的思路很简单:内部Flash最前面放一个Bootloader,它负责上电后的总管工作。正常情况下去外部Flash读应用,通过某种方式把应用搬到指定的可执行区域,然后跳过去。
我在实际产品中用的分区方式是这样的:
- 内部Flash 0x08000000 ~ 0x0800FFFF,64KB,放Bootloader
- 内部Flash 0x08010000 ~ 0x0807FFFF,放应用程序APP
- 外部Flash 0x000000 ~ 0x003FFFFF,4MB,放生产固件备份
- 外部Flash 0x004000 ~ 0x007FFFFF,放运行日志或参数记录
为什么外部Flash不从0地址开始放App?因为我在最前面预留了4KB存放固件信息头,比如固件版本、CRC校验值、编译时间、固件长度。Bootloader先读这个头做校验,确认固件完整再搬运,比直接一股脑读0地址靠谱得多。这个习惯帮我挡住了很多次升级过程中掉电导致成砖的惨案。
3.2 W25Q64驱动:读、写、擦除三件套
这个方案依赖的外部Flash驱动其实只有三个核心操作:读数据、扇区擦除、页编程。读ID只是拿来开局验证用的,不参与业务逻辑。
读数据函数长这样:
void W25Q64_Read(uint32_t addr, uint8_t *buf, uint32_t len) { W25Q64_CS_LOW(); SPI1_WriteByte(0x03); // Read Data 指令 SPI1_WriteByte((addr >> 16) & 0xFF); SPI1_WriteByte((addr >> 8) & 0xFF); SPI1_WriteByte(addr & 0xFF); while (len--) { *buf++ = SPI1_ReadWriteByte(0xFF); } W25Q64_CS_HIGH(); }这块代码本身不难,难的是写操作,因为写之前必须先擦除。W25Q64擦除的最小单位是4KB扇区,擦除后扇区所有字节变成0xFF。所以擦除函数必须先发写使能指令0x06,再发0x20扇区擦除指令,最后轮询状态寄存器直到BUSY位清零:
void W25Q64_EraseSector(uint32_t sector_addr) { W25Q64_CS_LOW(); SPI1_WriteByte(0x06); // Write Enable W25Q64_CS_HIGH(); W25Q64_CS_LOW(); SPI1_WriteByte(0x20); // Sector Erase 4KB SPI1_WriteByte((sector_addr >> 16) & 0xFF); SPI1_WriteByte((sector_addr >> 8) & 0xFF); SPI1_WriteByte(sector_addr & 0xFF); W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }页编程稍微特殊,它最多一次写256字节,而且这256字节必须在同一页内,跨页就得拆分。为了避免这类边界问题,我在项目里封装了一个“按任意地址、任意长度写数据”的函数,内部自动处理页边界拆分,Bootloader升级时只需要调这一个接口,不用关心底层分页细节。核心逻辑就是循环执行“计算当前页剩余空间→写入不超过剩余空间的长度→地址和剩余长度相应递减”。
驱动层写完后,一定要先做一轮裸测:写一段数据到外部Flash,全片读回来比对,再断电重新上电读一遍。这一步能排除绝大多数硬件连接问题,免得后面查启动问题时分不清是Flash坏了还是Bootloader逻辑问题。
3.3 Bootloader升级与跳转逻辑
Bootloader的主流程并不复杂:
- 初始化系统时钟、串口、SPI驱动
- 读取外部Flash固件信息头,判断是否有有效固件
- 如果检测到串口收到升级命令,则进入升级模式,把上位机发来的新固件写入外部Flash
- 如果固件有效,就把它从外部Flash搬到内部Flash的APP区
- 校验通过后,跳到APP
升级协议我用过Ymodem,也用过自定义协议。个人经验是,Bootloader里的传输协议越简单越好,Ymodem虽然通用但移植起来要处理一堆状态机细节。自定义协议用不上几十行逻辑就能跑:
帧头0xAA 0x55 | 固件包长度2字节 | 包序号2字节 | 数据区最多1024字节 | CRC32校验4字节每收完一包就写一包到外部Flash缓冲区,不直接写最终地址,等全部收完并整体校验通过后,再一次性把缓冲区固件搬到正式区域。这样做的好处是传输中途断线或者协议出错,不会把Flash里原有的固件弄坏。产品量产阶段这招极其重要。
跳到APP的代码是方案一的关键操作,我封装成了一个函数:
typedef void (*AppFunc)(void); void Boot_JumpToApp(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4); AppFunc app_main = (AppFunc)app_pc; // 跳转前关中断,防止残留外设中断把APP打死 __disable_irq(); // 如果有使用RTOS,这里还要把SysTick停掉,恢复默认 SysTick->CTRL = 0; __set_MSP(app_sp); app_main(); while (1); }这里有几个细节值得单独拿出来说。第一步一定是读APP地址处的前两个字,第一个字是栈顶指针MSP,第二个字才是复位向量。跳转之前必须关全局中断,否则你在Bootloader里开得正欢的串口中断、定时器中断,会在APP还没来得及重定向中断向量表的时候突然触发,大概率直接进HardFault。如果Bootloader里用了RTOS,还要把SysTick关掉,不然APP第一次跑调度器时可能会接到一个来历不明的时基中断。
3.4 APP工程改造:偏移地址与中断向量表重定位
跳转只是Bootloader这半边,APP工程不改,照样跑不起来。APP工程要动的核心点就两个:编译器链接地址和中断向量表。
用Keil MDK时,打开Options for Target → Target标签页,把IROM1的Start地址从0x08000000改成0x08010000,Size按实际需要填。如果用的分散加载文件,则修改类似这样的配置:
LR_IROM1 0x08010000 0x00070000 { ER_IROM1 0x08010000 0x00070000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00020000 { .ANY (+RW +ZI) } }链接脚本改完,APP编译出来已经有偏移了,但中断向量表默认还是从0x08000000找。Cortex-M系列芯片的中断向量表地址由VTOR寄存器控制,APP启动后必须显式设置:
int main(void) { SCB->VTOR = 0x08010000; // 后面再执行其他外设初始化 }这块是很多初学者栽跟头的地方。很多人把链接地址改了,编译烧进去,发现一进中断就死机,就是因为VTOR还指向0x08000000,中断一来CPU去了Bootloader的向量表,找了一圈压根没有对应ISR,直接HardFault。
3.5 生成bin和烧录流程
Bootloader编译成hex直接烧内部Flash。APP编译出来要转成bin格式,因为需要通过Bootloader的串口协议写到外部Flash。Keil里可以加一条After Build命令:
fromelf --bin --output=@L.bin @L.axf这样每次编译完自动生成和工程同名的bin文件。
把APP固件写进外部Flash通常有两条路。第一条是Bootloader自身的串口升级通道,上位机发bin文件,Bootloader边收边存。第二条是用STM32CubeProgrammer这类的工具,配一个支持外部Flash的加载算法,直接把bin写到外部Flash指定偏移。我日常调试更爱用第二条,因为能随便改地址、能直观看到Flash内容分布,量产时才用第一条走串口。
这里有一个建议:Bootloader和APP尽量用同一个分散加载思路管理,Bootloader永远不升级,APP版号越升越高,两者之间接口越少越好。一旦Bootloader和APP之间有共享变量,就通过固定地址交换,别图省事直接extern,否则将来某一方编译器版本一变,结构体对齐规则变了,莫名奇妙跑飞就够你查半天。
4. 方案二:F7/H7的QSPI内存映射,程序直接在外地执行
4.1 内存映射是怎么回事
前面方案一的本质是“搬运”,方案二才是真的“原地执行”。STM32F7和H7系列带有QUADSPI/OSPI外设,可以把外接的NOR Flash映射到芯片的地址空间中。F7系列是0x90000000,CPU可以直接从这个地址取指令、读数据,Flash对CPU来说就像挂在总线上的只读存储器。
要理解这个方案,可以打个比方:你把游戏光盘插进光驱,但电脑默认不支持光盘引导启动。你只能在硬盘上放一个极小的引导程序,这个引导程序的任务是先加载光驱驱动,然后把控制权交给光盘里的系统。对应到STM32,内部Flash那几百字节的引导程序负责初始化QSPI,让外部Flash“活”过来,再把程序计数器指到0x90000000去执行。
这是一个很多教程没讲透的点:即使芯片支持QSPI内存映射,也没有把QSPI Flash列在复位后的启动源里。上电之后CPU的第一条指令还是得从内部Flash取,所以无论如何内部Flash都需要一段启动代码,区别只是这段代码可以做得很短。
4.2 极简启动器的编写思路与代码
这个启动器代码量非常少,但顺序一个都不能错。完整逻辑如下:
- 配置系统时钟,至少让QSPI外设的时钟源稳定
- 初始化QUADSPI外设,配置W25Q64的工作模式、Flash大小、时序参数
- 切换QSPI到内存映射模式
- 读取0x90000000处的栈顶指针和0x90000004处的复位向量
- 设置VTOR指向0x90000000
- 跳转
这里最大的难点在第2步和第3步。因为启动器本身占据内部Flash,它可以先安全地完成所有QSPI初始化,再跳过去执行外部Flash里的APP。而APP那边的Reset_Handler执行时,QSPI外设已经是工作状态了,所以APP代码在启动早期不能再把QSPI关掉重来。
QSPI初始化和内存映射的关键寄存器操作,用寄存器写会比较透明:
void QSPI_Init_ForMemoryMapped(void) { // 打开时钟 __HAL_RCC_QSPI_CLK_ENABLE(); // 复位并配置CR寄存器,设置时钟分频、Flash大小 QUADSPI->CR = 0; QUADSPI->CR |= (3 << 24); // 4分频,根据实际总线和Flash手册调整 QUADSPI->CR |= (22 << 16); // FLASH_SIZE,8MB对应2^23字节,寄存器位22 QUADSPI->CR |= QSPI_CR_SSHIFT; // 采样偏移,提高高频稳定性 // 配置通信配置寄存器,使用0xEB Quad Fast Read QUADSPI->CCR = 0; QUADSPI->CCR |= (1 << 0); // 指令模式:1字节 QUADSPI->CCR |= QSPI_CCR_DMODE_4; // 数据线宽:4线 QUADSPI->CCR |= (0xEB << 8); // 指令:0xEB QUADSPI->CCR |= QSPI_CCR_DMODE_4; // 注意地址模式也要4线 QUADSPI->CCR |= (QSPI_CCR_ADSIZE_3 << 16); QUADSPI->CCR |= (6 << 24); // Dummy Cycle 根据Flash手册调 // 打开内存映射模式 QUADSPI->CR |= QSPI_CR_DMM; QUADSPI->CR |= QSPI_CR_MMEN; }注意,上面这段我用的是寄存器操作示意,生产代码建议基于HAL库再包一层,但寄存器注释你必须看懂,因为CubeMX生成的内存映射模式配置不一定完美匹配你的Flash型号。最典型的问题是Dummy Cycles:0x6B指令和0xEB指令需要的dummy周期不一样,配置少了读出来的数据错位,配置多了又浪费时间。W25Q64手册上写得清清楚楚,0x6B配8个dummy周期,0xEB配6个dummy周期,别想当然。
跳转部分和方案一类似,但目标是0x90000000:
#define APP_XIP_ADDR 0x90000000 #define APP_VTOR_ALIGN 0x400 // 中断向量表对齐值,M7要求0x400倍数 void Boot_JumpToXipApp(void) { __disable_irq(); SysTick->CTRL = 0; // 设置中断向量表指向外部Flash SCB->VTOR = APP_XIP_ADDR & ~(APP_VTOR_ALIGN - 1); // 取复位向量 uint32_t app_sp = *(volatile uint32_t *)APP_XIP_ADDR; uint32_t app_pc = *(volatile uint32_t *)(APP_XIP_ADDR + 4); __set_MSP(app_sp); ((void (*)(void))app_pc)(); }4.3 应用程序工程与SystemInit的坑
在XIP方案里,APP工程的散列文件要把Flash地址改成0x90000000:
LR_IROM1 0x90000000 0x00800000 { ER_IROM1 0x90000000 0x00800000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00020000 { .ANY (+RW +ZI) } }这里要特别注意一个坑:使用HAL库时,SystemInit函数会重新配置系统时钟,CubeMX生成的代码可能会去操作RCC里和QSPI相关的时钟。正常情况下这不影响已经初始化好的QSPI,因为QSPI时钟来自QSPICLK,系统时钟从Flash到PLL切换时QSPI不会掉。但是,如果APP代码在启动早期调用SystemInit之后,又走了HAL_QSPI_Init并重新配置了寄存器,那就完蛋了。因为QSPI重新初始化时,CCR和CR寄存器会被重置,Flash立刻从映射地址消失,而CPU此时正在0x90000000取指令,结果就是取指总线错误,程序死在现场。
我的解决办法是:APP工程的启动文件里,Reset_Handler调用SystemInit之后,不再让应用代码重复初始化QSPI,直接沿用启动器已配置好的状态。如果确实需要通过HAL库再封装一层控制逻辑,那就把HAL_QSPI_Init封装成“检测到QSPI已处于内存映射模式就直接跳过配置”的样子,从根上避免二次初始化。
4.4 下载到外部Flash的两种途径与性能优化
把APP bin下载到外部Flash,常见做法有两种。一种是用STM32CubeProgrammer配合外部Flash加载器,直接把bin写到0x90000000地址,调试方便,适合开发阶段。另一种是通过上一节方案的Bootloader串口通道写外部Flash,适合脱离ST-LINK的量产现场。
在真正跑起来之前,还有一件大事:性能。直接通过QSPI内存映射取指,如果不开缓存,速度会明显比内置Flash慢。Cortex-M7内核自带的I-Cache和D-Cache在这里是救命稻草。启动器里要在跳转前把ICache打开:
SCB_EnableICache(); SCB_EnableDCache();两个Cache打开以后,实际测下来执行纯逻辑代码的性能损失已经不太明显。如果还要压榨速度,就从以下几方面下手:提高QSPI时钟频率,优先保证PLL给QSPI的时钟足够稳;把Flash的Read指令从0x6B换成0xEB,四线数据模式;配置样点偏移(SSHIFT)减少高频误读;把热路径中断服务函数放到内部RAM运行,通过__attribute__((section(".data")))把关键函数挪到SRAM里去。
5. 实操中踩过的坑:常见问题与排查速查
5.1 读ID不对,一切都白搭
SPI驱动写完后,第一件事应该是读JEDEC ID。W25Q64的ID是0xEF4017,读出来不对就说明硬件链路有问题。最常见的症状是读出来全是0xFF,九成原因是Flash没有进入工作状态。按顺序排查:供电接了没有,WP、HOLD有没有拉高,CS是不是被硬件NSS自动拉高导致片选无效,SPI的CPOL/CPHA是否匹配,分频系数是否过高导致时序采样失败。
我自己遇到最隐蔽的一次问题是CS串了一颗10k电阻,低速通信时没问题,速率一拉高,片选沿被拉圆了,Flash误判命令。后来直接用0欧短接,问题消失。高频SPI下,CS和CLK信号路径上的任何多余电阻电容都可能成为压死骆驼的最后一根稻草。
5.2 跳转失败、进HardFault怎么办
方案一搬运跳转后立刻进HardFault,优先级最高的排查项是:APP地址处的MSP和PC到底读对没有。用调试器在跳转前打断点,看到的内存内容是不是预期值。如果是一堆0xFFFFFFFF,说明外部Flash里根本没写东西,或者读地址错了。
如果MSP和PC看起来都对,仍然进HardFault,就查两件事。第一,APP的VTOR是否已经设置到新地址,没设置的话中断一来就死。第二,APP的链接地址和Bootloader跳转地址是否一致,比如APP链接到0x08010000,但你跳转时用0x08020000,PC取出来是0x08010001的复位向量,相当于APP的向量表所在位置根本没代码,必然炸。这个错误看起来很低级,但工程里把外部Flash偏移和内部Flash偏移搞混的案例我见过太多次了。
5.3 XIP模式跑不起来的几个隐蔽原因
QSPI内存映射模式下程序跑飞,最常见的原因就是Dummy Cycles配置不匹配。0xEB指令在W25Q64上需要6个dummy周期,少配一个周期,后面读出来的数据整体错位,PC自然跳到野地址。
另一个隐蔽问题是内存映射模式和写操作切换。QSPI处于内存映射模式下,CPU会持续从外部Flash读取指令,此时绝对不允许往QSPI发写Flash命令。一旦应用代码里有写外部Flash的逻辑,必须先退出内存映射模式,写完再重新进入。常见症状就是在XIP下跑到一半突然死机,看PC指针落在0x90000000附近,查半天才发现是某个写日志的库函数顺手调了外部Flash驱动,把QSPI状态搅乱了。
5.4 外部Flash编程的可靠性问题
外部Flash写入最怕掉电。如果在擦写过程中掉电,轻则固件损坏,重则整颗Flash文件系统结构被破坏。解决方案就是把升级过程设计成事务性的:先写临时区域,全部写完校验通过后,再写一个“固件有效”标志位。下次上电时Bootloader先查标志,有效才跳转,无效就进入串口升级等待状态。这样哪怕升级过程中掉电,最坏情况只是原地等待重新刷机,不会出现设备彻底变砖的尴尬。
5.5 排查速查表
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 读ID返回0xFF | 供电/WP/HOLD/CS/时序异常 | 先量电压,再查拉高电阻,最后看示波器波形 |
| 读ID返回0x00 | 引脚复用或PCB错线 | 用万用表量CLK/MOSI/MISO是否连对 |
| 扇区擦除超时 | 写使能没发、BUSY轮询条件不对 | 在擦除前打印状态寄存器确认WEL位 |
| 跳转后进HardFault | MSP/PC/链接地址/VTOR任一不对 | 跳转前断点读内存,确认APP向量表 |
| XIP下取指卡死 | QSPI还没初始化就访问外部Flash | 确保启动器完成QSPI内存映射再跳转 |
| XIP下偶发死机 | Dummy周期不对/时钟太快/未退出映射就写Flash | 降频验证,核对Flash手册dummy配置 |
| 升级中断后无法启动 | 升级过程没有事务标志 | 增加临时区写入+有效标志位机制 |
最后再分享一个我个人在实际项目里的体会:如果产品对启动速度要求没那么苛刻,方案一确实比方案二好维护。它不挑芯片型号,不依赖复杂的QSPI时序,调试工具也顺手。方案二真正的战场在“内部Flash真的放不下双份固件”或者“希望热重启直接从外部Flash跑”的场景,这时候你再搬出XIP,收益才明显。先把方案一的Bootloader、分散加载、VTOR这一套动作练熟了,再挑战XIP,你会觉得一切顺理成章,而不是玄学调参。