4、Flash映射与缓存:内存映射Flash、XIP(原地执行)原理、缓存一致性

好,咱们接着聊Flash。前面几章我们把文件系统、磨损均衡、掉电保护都过了一遍。但有个问题一直没细说——Flash到底怎么跟CPU打交道?

你想想看,CPU跑代码,最舒服的方式是直接从内存里取指令。但Flash呢?它是个块设备,读写都得按页来,速度还慢。这就尴尬了。我早年做第一个Zephyr项目时,就踩过这个坑——代码放在外部Flash上,启动慢得像蜗牛爬。后来才搞明白,原来得用内存映射XIP这两招。

4.1 内存映射Flash:把Flash“伪装”成内存

说白了,内存映射就是把Flash的地址空间,直接映射到CPU的寻址空间里。CPU读某个内存地址,实际上是在读Flash里的某个位置。对CPU来说,它根本不知道背后是Flash还是RAM——反正都是读地址嘛。

在Zephyr里,这通常由SPI控制器QSPI控制器的硬件模块来完成。比如STM32的QuadSPI,它内部有个查找表(LUT),你配置好命令序列,它就能自动把内存读请求转换成Flash的读命令。

关键点:内存映射模式下,CPU读Flash就像读RAM一样——直接load指令就行。但写操作呢?嗯,这里要注意,大多数Flash不支持直接写映射地址。你得通过SPI命令去写,或者用XIP模式下的特殊写操作。

我在项目中遇到过一个问题:某款Flash芯片,内存映射读没问题,但一写就死机。查了半天,发现是芯片的写保护引脚没拉高。所以啊,硬件设计时一定要把Flash的控制引脚留出来,别图省事全接地。

4.2 XIP(原地执行):代码直接在Flash上跑

XIP,全称eXecute In Place。意思就是代码不搬到RAM里,直接在Flash上执行

你可能会问:Flash那么慢,怎么跑代码?嗯,这里有两个前提:

  • Flash支持高速读:比如QSPI Flash,四线模式下时钟能跑到100MHz以上,读速度跟慢速RAM差不多。
  • CPU有指令缓存:代码执行有局部性,缓存命中率高的话,实际性能损失很小。

Zephyr对XIP的支持很成熟。你只要在设备树里配好Flash的reg属性,把代码段链接到Flash地址上就行。举个例子:

// 设备树片段
&qspi {
    pinctrl-0 = <&qspi_pins>;
    status = "okay";

    mx25r6435f: flash@0 {
        compatible = "jedec,spi-nor";
        reg = <0>;
        spi-max-frequency = <104000000>;
        size = <0x800000>;  // 8MB
        // 关键:映射到CPU地址空间
        memory-map = <0x90000000 0x800000>;
    };
};

然后链接脚本里,把text段放到0x90000000开始的区域:

SECTIONS {
    .text : {
        *(.text*)
    } > FLASH_MMAP  // 这个FLASH_MMAP就是0x90000000
}

这样编译出来的固件,启动时CPU直接从Flash取指令执行。我习惯在调试阶段先用RAM运行,稳定后再切到XIP——毕竟XIP下断点调试不太方便。

小技巧:XIP模式下,如果代码里频繁访问全局变量,性能会下降。因为全局变量在RAM里,每次访问都要跨总线。我一般把频繁用的变量放到紧耦合内存(TCM)里,或者用__attribute__((section(".ramfunc")))把关键函数搬到RAM里执行。

4.3 缓存一致性:别让CPU和Flash“打架”

好,现在代码在Flash上跑了,CPU也开了缓存。问题来了——缓存里的数据跟Flash里的数据不一致怎么办?

举个例子:你通过SPI命令往Flash里写了一段新代码,然后CPU从映射地址去读。如果缓存里还存着旧数据,CPU读到的就是错的。这就是缓存一致性问题。

在Zephyr里,处理方式分几种情况:

场景 处理方法 我踩过的坑
写Flash后读映射地址 手动无效化缓存行 忘了做,读回旧数据,程序跑飞
DMA写Flash后CPU读 DMA完成回调里清缓存 回调里没做,数据错乱
多核共享Flash映射 用内存屏障+缓存维护指令 两个核同时读,一个读到旧的

具体到代码,ARM Cortex-M系列有专门的指令:

// 无效化某个地址的缓存行
void invalidate_dcache_by_addr(uint32_t addr, uint32_t size) {
    uint32_t line_size = 32;  // 通常32字节
    uint32_t start = addr & ~(line_size - 1);
    uint32_t end = (addr + size + line_size - 1) & ~(line_size - 1);
    
    for (uint32_t a = start; a < end; a += line_size) {
        __asm volatile("DCIMVAC %0" : : "r"(a));  // 数据缓存无效化
    }
    __DSB();  // 数据同步屏障
    __ISB();  // 指令同步屏障
}

我曾经在OTA升级时吃过这个亏。升级完成后,新固件写到Flash里,然后复位重启。结果CPU从映射地址读到的还是旧代码——因为指令缓存没清。后来在复位前加了段汇编,把整个指令缓存刷一遍,问题才解决。

警告:千万不要在中断服务函数里做缓存维护操作!ISR里调用DCIMVAC这类指令,可能会触发异常。我建议在任务上下文里统一处理,或者用硬件自带的缓存一致性协议(比如某些SoC的ACE总线)。

4.4 实战建议:怎么选?

最后给点个人经验。做项目时,到底用不用XIP,得看场景:

  • 代码量小(< 256KB):直接全放RAM里跑,省心。Zephyr的ROM/RAM分区很灵活。
  • 代码量大(> 1MB):必须用XIP,不然RAM不够用。这时候要选高速QSPI Flash,最好带DTR(双倍数据率)模式。
  • 实时性要求高:把中断处理函数、RTOS调度器这些关键代码放到RAM里。我习惯用链接脚本的.ramfunc段来管理。

嗯,Flash映射和缓存这块,说白了就是用硬件特性来掩盖Flash的慢。你只要把映射配好、缓存管好,Zephyr就能跑得又快又稳。下一章我们聊聊更高级的话题——多分区管理和安全启动,到时候这些知识还会用上。