4. 分区读写性能优化:DMA传输、Cache策略、双缓冲机制、预取与流水线

分区读写,说白了就是Bootloader从Flash里把数据搬来搬去。很多人觉得这有什么难的?直接memcpy不就完了?嗯,我刚开始做Bootloader时也这么想,直到有一次升级固件,用户等了整整两分钟……从那以后,我就再也不敢小看这块了。

这一章,我把压箱底的四板斧掏出来:DMA传输、Cache策略、双缓冲机制、预取与流水线。每个都是实战中踩过坑才真正理解的。

核心观点:分区读写的瓶颈不在Flash速度,而在CPU等待。优化思路就是让CPU别闲着。
分区读写性能优化四板斧 分区读写性能 DMA传输 Cache策略 双缓冲机制 预取与流水线 关键思路: • DMA传输:让硬件搬运数据,CPU去干别的 • Cache策略:该缓存的数据缓存,不该缓存的别碰 • 双缓冲机制:一个缓冲区在读,另一个在写,交替进行 • 预取与流水线:提前把下一块数据准备好

4.1 DMA传输:让硬件替你搬砖

我见过太多Bootloader代码,读Flash用轮询,写Flash也用轮询。CPU全程傻等,啥也不干。这太浪费了。

DMA(直接存储器访问)就是专门干这个的。你告诉DMA控制器:从地址A搬N个字节到地址B,然后它自己搞定,搬完了给你发个中断。CPU呢?该算校验和算校验和,该解压缩解压缩,两不耽误。

我的习惯:只要单次传输超过64字节,我就上DMA。小于64字节时,DMA初始化开销反而比直接拷贝大,不划算。

配置DMA时,有几个坑要注意:

  • 源地址和目的地址的对齐——很多DMA控制器要求32位对齐,不对齐就降速甚至报错
  • 传输宽度匹配——Flash读通常是32位,RAM写也是32位,别配成8位模式,速度差4倍
  • 中断优先级——DMA完成中断别设太低,否则数据准备好了CPU还在忙别的
// 一个典型的DMA配置示例(伪代码)
void dma_flash_to_ram(uint32_t flash_addr, uint32_t ram_addr, uint32_t size) {
    // 1. 关闭DMA通道,防止配置冲突
    DMA_Cmd(DMA_CHANNEL, DISABLE);
    
    // 2. 配置源:Flash地址,32位宽度,地址递增
    DMA_SetSource(DMA_CHANNEL, flash_addr, DMA_WIDTH_32, DMA_INC_ENABLE);
    
    // 3. 配置目的:RAM地址,32位宽度,地址递增
    DMA_SetDest(DMA_CHANNEL, ram_addr, DMA_WIDTH_32, DMA_INC_ENABLE);
    
    // 4. 设置传输大小(字节数)
    DMA_SetSize(DMA_CHANNEL, size);
    
    // 5. 开启传输完成中断
    DMA_ITConfig(DMA_CHANNEL, DMA_IT_TC, ENABLE);
    
    // 6. 启动DMA
    DMA_Cmd(DMA_CHANNEL, ENABLE);
    
    // CPU可以去做别的事了
    // 等中断来了再处理数据
}
我曾经踩过的坑:有一次DMA传输Flash数据,没注意Flash的等待周期。DMA读得太快,Flash跟不上,读出来的全是0xFF。后来加了Flash等待周期配置,问题解决。记住:DMA快不代表Flash快,时序要匹配。

4.2 Cache策略:该缓存的缓存,不该缓存的别碰

Cache这东西,用好了是神器,用不好是灾难。尤其是在Bootloader里,Flash和RAM的访问模式完全不同。

我个人习惯把Cache策略分成三种场景:

场景 Cache策略 原因
读固件镜像(顺序访问) 开启Cache,预取使能 顺序读命中率高,Cache能大幅提速
写分区数据(随机写入) 关闭Cache或写透模式 Cache写回会导致数据不一致,万一掉电就完了
校验计算(SHA/CRC) 开启Cache,但注意DMA一致性 计算密集,Cache加速明显,但要处理好DMA和Cache的同步

你想想看,如果写Flash时用了写回(Write-Back)Cache,数据先留在Cache里,还没写到Flash就掉电了——嗯,固件就废了。所以写操作我坚决用写透(Write-Through)或者干脆关掉Cache。

避坑指南:DMA和Cache一起用时,一定要做Cache一致性维护。DMA直接访问内存,不经过Cache。如果Cache里有脏数据,DMA读到的就是旧数据。我一般会在DMA传输前做Cache Clean,传输后做Cache Invalidate。
// DMA传输前后的Cache维护
void dma_with_cache_sync(uint32_t flash_addr, uint32_t ram_addr, uint32_t size) {
    // 传输前:将RAM区域的Cache写回(Clean)
    // 防止Cache里的脏数据没写回RAM
    SCB_CleanDCache_by_Addr((uint32_t*)ram_addr, size);
    
    // 启动DMA传输
    start_dma(flash_addr, ram_addr, size);
    
    // 等待DMA完成
    while(!dma_done);
    
    // 传输后:将RAM区域的Cache失效(Invalidate)
    // 确保CPU读的是DMA写进来的新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)ram_addr, size);
    
    // 现在可以安全地访问ram_addr了
}

4.3 双缓冲机制:流水线式读写

单缓冲的问题很明显:你读一块数据,处理完,再读下一块。中间有大量空闲时间。双缓冲就是解决这个的。

原理很简单:两个缓冲区,一个给DMA读数据,一个给CPU处理数据。DMA填满缓冲区A,发中断;CPU处理A的同时,DMA已经在填缓冲区B了。交替进行,谁也别等谁。

我在项目中用过的一个典型配置:

  • 缓冲区大小:4KB(刚好一个Flash扇区大小)
  • 缓冲区A:0x20000000
  • 缓冲区B:0x20001000
  • DMA传输完成中断里,切换当前缓冲区
// 双缓冲机制核心代码
#define BUF_SIZE  4096
uint8_t buf_a[BUF_SIZE] __attribute__((aligned(32)));
uint8_t buf_b[BUF_SIZE] __attribute__((aligned(32)));
volatile uint32_t current_buf = 0;

void dma_double_buffer_isr(void) {
    if (current_buf == 0) {
        // 缓冲区A已填满,处理A
        process_data(buf_a, BUF_SIZE);
        // 同时DMA已经在填B(由硬件自动切换)
        current_buf = 1;
    } else {
        // 缓冲区B已填满,处理B
        process_data(buf_b, BUF_SIZE);
        current_buf = 0;
    }
}

void start_double_buffer_read(uint32_t flash_addr, uint32_t total_size) {
    // 配置DMA为循环模式,双缓冲
    DMA_DoubleBufferConfig(DMA_CHANNEL, 
                           (uint32_t)buf_a,  // 内存地址1
                           (uint32_t)buf_b,  // 内存地址2
                           BUF_SIZE);        // 每次传输大小
    
    // 启动DMA,硬件会自动交替使用两个缓冲区
    DMA_Cmd(DMA_CHANNEL, ENABLE);
}
效果有多明显?我测过一次,单缓冲读1MB固件需要约800ms,双缓冲降到450ms。几乎翻倍。而且CPU利用率从100%降到60%,还能腾出手做别的事。

4.4 预取与流水线:让数据等CPU,别让CPU等数据

预取说白了就是:猜你下一步要读什么,提前帮你准备好。Flash读是有延迟的,尤其是NOR Flash,读第一个字节可能要等几十个周期,但后续字节可以流水线读出。

我一般会做两件事:

  1. 开启Flash预取缓冲——大多数MCU的Flash控制器都有预取功能,默认可能没开。翻翻手册,找到FLASH_ACR寄存器,把PRFTEN位置1。
  2. 指令预取对齐——代码和数据尽量放在连续的地址空间,让预取逻辑能命中。别东一块西一块。

流水线呢?就是把一个大的读操作拆成多个阶段:发送地址→等待延迟→读取数据。每个阶段独立进行,就像工厂流水线一样。

举个例子:读4个32位字,如果单次发送地址再等延迟,需要4×(发送+等待)的时间。如果用流水线,发送地址1→发送地址2(同时等待1完成)→发送地址3(同时等待2完成)→……总时间变成发送+4×等待,节省了3次发送时间。

具体到代码层面,很多MCU的Flash控制器会自动做流水线。你需要做的只是:

  • 配置正确的等待周期(根据主频和Flash速度算)
  • 开启预取使能
  • 如果支持,开启指令Cache和数据Cache
// STM32系列Flash预取配置示例
void flash_prefetch_config(void) {
    // 假设HCLK=168MHz,Flash等待周期设为5
    FLASH->ACR = FLASH_ACR_LATENCY_5WS |   // 等待周期
                 FLASH_ACR_PRFTEN |         // 预取使能
                 FLASH_ACR_ICEN |           // 指令Cache使能
                 FLASH_ACR_DCEN;            // 数据Cache使能
    // 就这么几行,性能能差30%以上
}
注意:预取不是万能的。如果你的代码有大量跳转(比如函数指针、状态机跳转),预取命中率会很低。这时候不如把热点代码放到RAM里执行。我在一个OTA项目中就这么干过——把校验函数拷贝到RAM,速度从2MB/s飙到5MB/s。

4.5 四板斧组合实战

单独用哪个都有效果,但组合起来才是王炸。我整理了一个典型的优化流程:

步骤 操作 预期提升
1 开启Flash预取和Cache 基础读性能提升30%
2 DMA替代CPU轮询 CPU利用率降低40%
3 双缓冲机制 吞吐量提升50%
4 流水线读优化 延迟降低20%

加起来,整体分区读写性能可以提升2-3倍。这不是理论值,是我在好几个项目里实测出来的。

最后说一句:优化前一定要先测量。用GPIO翻转或者定时器抓一下实际耗时,别凭感觉。我见过有人花了一周优化,结果发现瓶颈根本不在读写上——嗯,先测再优化,这是铁律。