4. Flash与存储优化:XIP、缓存、压缩与布局

好,咱们聊聊存储这块。说实话,Bootloader的启动时间,很大一部分都耗在Flash读取上。你想想看,CPU跑得飞快,但Flash读取速度就那么点,这中间的不匹配就是性能瓶颈所在。我这些年调过的板子,十有八九都在这里吃过亏。

4.1 XIP(就地执行)技术

XIP,全称是Execute In Place。说白了,就是代码直接在Flash里跑,不用先拷到RAM里。这听起来很美好,对吧?省掉了加载时间,也省了RAM空间。

但事情没那么简单。我遇到过不少新手,一听说XIP好,就把所有代码都扔Flash里了。结果呢?启动是快了,但跑起来慢得像蜗牛。为什么?因为Flash的随机读取延迟太高了。

核心要点:XIP适合存放那些执行频率高、但代码量小的关键路径。比如中断向量表、复位处理函数、以及一些频繁调用的底层驱动。

具体怎么用?以ARM Cortex-M为例,你需要在链接脚本里把代码段放到Flash地址空间:

/* 链接脚本示例 - XIP区域 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text : {
        *(.isr_vector)    /* 中断向量表 - 必须XIP */
        *(.text.boot)     /* 启动代码 - 必须XIP */
        *(.text.xip)      /* 标记为XIP的函数 */
    } > FLASH

    .data : {
        *(.text.ram)      /* 需要拷到RAM执行的函数 */
        *(.data)
    } > RAM AT> FLASH
}

这里有个坑:不是所有函数都适合XIP。那些被频繁调用的、对延迟敏感的函数,我建议还是拷到RAM里跑。我曾经在一个项目里,把中断处理函数也放Flash里XIP,结果中断响应时间直接翻了三倍。嗯,从那以后我就学乖了。

4.2 Flash缓存与预取

现代MCU基本都带Flash缓存和预取单元。这东西的原理很简单:猜你下一步要读哪,提前把数据准备好。

但猜也有猜不准的时候。我见过一个案例,代码里到处都是跳转和分支,预取命中率低得可怜。最后怎么解决的?把关键路径上的分支改成了查表,预取命中率从40%提到了85%。

个人经验:开启预取后,记得把代码中的循环展开。循环体越小,预取越容易命中。我一般把循环体控制在16字节以内,这样预取一次就能覆盖整个循环。

配置示例(以STM32为例):

/* 开启Flash预取和缓存 */
void flash_optimize(void)
{
    /* 开启指令缓存 */
    FLASH->ACR |= FLASH_ACR_ICEN;
    
    /* 开启数据缓存 */
    FLASH->ACR |= FLASH_ACR_DCEN;
    
    /* 设置预取缓冲区 */
    FLASH->ACR |= FLASH_ACR_PRFTEN;
    
    /* 根据主频设置等待周期 */
    if (SystemCoreClock > 48000000) {
        FLASH->ACR = (FLASH->ACR & ~FLASH_ACR_LATENCY_Msk) | 
                     FLASH_ACR_LATENCY_1WS;
    }
}

注意:开启缓存后,如果代码有自修改功能(比如OTA升级),一定要先清缓存再写Flash。否则读到的可能是旧数据。我曾经因为这个bug排查了整整两天。

4.3 压缩与解压缩策略

压缩,听起来是省空间用的。但在Bootloader里,压缩其实也能省时间。为什么?因为Flash读取速度慢,但解压缩速度快。如果压缩比够高,读压缩数据的时间 + 解压时间,可能比直接读原始数据还短。

我常用的压缩算法有几种:

算法 压缩比 解压速度 适用场景
LZSS 2:1 ~ 3:1 极快 代码段压缩
LZ4 2:1 ~ 2.5:1 非常快 数据段压缩
RLE 1.5:1 ~ 2:1 极快 初始化数据

我个人偏爱LZSS。它解压速度快,代码量也小,很适合Bootloader这种资源受限的环境。下面是一个轻量级的解压实现:

/* LZSS解压 - 轻量级实现 */
void lzss_decompress(uint8_t *src, uint8_t *dst, uint32_t size)
{
    uint32_t flags = 0;
    uint32_t mask = 0;
    
    while (size > 0) {
        /* 每8个字节读取一次标志位 */
        if (mask == 0) {
            flags = *src++;
            mask = 0x80;
        }
        
        if (flags & mask) {
            /* 未压缩字节 - 直接拷贝 */
            *dst++ = *src++;
            size--;
        } else {
            /* 压缩块 - 从历史缓冲区拷贝 */
            uint8_t lo = *src++;
            uint8_t hi = *src++;
            uint16_t offset = ((hi & 0xF0) << 4) | lo;
            uint16_t length = (hi & 0x0F) + 3;
            
            uint8_t *ref = dst - offset - 1;
            while (length-- && size) {
                *dst++ = *ref++;
                size--;
            }
        }
        mask >>= 1;
    }
}

避坑指南:我曾经把整个Bootloader都压缩了,结果发现解压代码本身也需要解压才能运行。这是个死循环。正确的做法是:把解压代码放在不压缩的区域,或者用汇编手写一个极简的解压器。

4.4 存储布局优化

存储布局,说白了就是怎么安排Flash里的内容。布局合理,启动就快;布局不合理,启动就慢。我见过最夸张的一个项目,Bootloader大小只有32KB,但启动花了3秒。为什么?因为关键代码分散在Flash的各个角落,每次读取都要跨页。

我的布局原则是这样的:

  • 热代码集中存放:把启动路径上的所有函数放在一个连续的Flash区域。这样预取和缓存都能发挥最大作用。
  • 冷热分离:不常用的功能(比如调试接口、错误处理)放到Flash末尾。别让它们污染了热区。
  • 对齐到Flash页边界:每个功能模块都从Flash页的起始地址开始。这样读取时不会跨页,减少等待周期。

一个典型的布局示例:

Flash布局 (512KB Flash)
+------------------+ 0x08000000
| 中断向量表       | 256字节 - 必须XIP
+------------------+ 0x08000100
| 启动代码         | 4KB - 热区,XIP
+------------------+ 0x08001100
| 核心驱动         | 8KB - 热区,XIP
+------------------+ 0x08003100
| 解压代码         | 2KB - 必须XIP
+------------------+ 0x08003900
| 压缩的应用数据   | 剩余空间
+------------------+ 0x08080000

重要提醒:布局确定后,一定要用objdump或者readelf工具检查实际布局。我遇到过链接脚本写对了,但编译器优化把函数挪到了奇怪的位置。嗯,这种事情发生过不止一次。

最后说一句:存储优化没有银弹。每个项目都有自己的特点,关键是要理解你的瓶颈在哪。是Flash读取慢?还是解压耗时长?还是缓存命中率低?找到瓶颈,对症下药,才能把启动时间真正压下来。