一、问题背景:为什么 SDRAM 数据会“神秘”出错?

STM32F4 系列(如 STM32F429/439)内置了 D-Cache(数据缓存),用于加速对内部 SRAM 和外部存储器的访问。当外部 SDRAM 被映射到内存空间(如 0xD0000000)时,CPU 读写 SDRAM 会先经过 D-Cache。

核心矛盾:D-Cache 以 32 字节(或 64 字节,取决于实现)为行(line)缓存数据。当 CPU 写入 SDRAM 时,数据可能只停留在 Cache 中,尚未写回 SDRAM;而 DMA 或其他总线主设备(如 LCD 控制器)直接访问 SDRAM 时,读取到的却是旧数据。反之,DMA 写入 SDRAM 后,Cache 中可能还保留着旧副本,CPU 再次读取时拿到的是过期数据。

这种不一致性在涉及 DMA、以太网、USB 或摄像头接口时尤为致命,表现为:图像花屏、网络丢包、文件系统数据损坏等。

二、原理剖析:D-Cache 的工作模式与一致性策略

2.1 D-Cache 的写策略

STM32F4 的 D-Cache 支持两种写策略(通过 SCB->CACR 寄存器配置):

  • Write-back(回写):CPU 写操作只更新 Cache,标记为 dirty,直到缓存行被替换或显式 Clean 操作时才写回 SDRAM。这是默认模式,性能高但一致性风险大。
  • Write-through(直写):CPU 写操作同时更新 Cache 和 SDRAM,保证一致性,但性能下降。

2.2 一致性操作:Clean 与 Invalidate

  • Clean(清理):将 dirty 缓存行写回 SDRAM,确保外部存储器数据最新。
  • Invalidate(失效):丢弃缓存行,下次 CPU 访问时重新从 SDRAM 读取,确保 CPU 看到外部更新。

2.3 volatile 与内存屏障的局限性

  • volatile 告诉编译器不要优化对该变量的访问,但它只保证编译器生成的代码每次都访问内存地址,不能控制硬件 Cache 的行为。也就是说,volatile 无法阻止 D-Cache 将数据留在缓存中。
  • 内存屏障指令(如 __DSB()__DMB())用于保证内存访问顺序,但同样不负责 Cache 与 SDRAM 的数据同步。它们只能确保在屏障点之前的所有内存访问完成,但若数据在 Cache 中,屏障不会强制写回。

因此,正确的做法是:在关键操作前后显式调用 CMSIS 提供的 Cache 维护函数

三、实战修正:基于 CMSIS 的 Cache 操作

3.1 启用 D-Cache 的初始化

#include "stm32f4xx.h"

void Cache_Init(void) {
    // 启用 D-Cache(I-Cache 可选)
    SCB_EnableDCache();
    // 可选:配置为 Write-through 模式(降低一致性风险)
    SCB->CACR |= (1 << 0);  // FORCEWT 位:强制所有区域写透
}

注意:SCB_EnableDCache() 在启动文件或 SystemInit() 中调用更早。

3.2 数据发送前:Clean 缓存

假设我们要通过 DMA 将内存缓冲区 buf 发送到外设(如 UART、SPI),必须确保 DMA 能看到最新数据:

void DMA_SendBuffer(uint32_t *buf, uint32_t len) {
    // 1. 确保 CPU 写入的数据已写回 SDRAM
    SCB_CleanDCache_by_Addr((uint32_t*)buf, (int32_t)len);
    
    // 2. 启动 DMA 传输
    DMA_Start(buf, len);
    
    // 3. 等待传输完成...
}

3.3 数据接收后:Invalidate 缓存

当 DMA 从外设接收数据到 SDRAM 缓冲区后,CPU 读取前必须使缓存失效:

void DMA_ReceiveBuffer(uint32_t *buf, uint32_t len) {
    // 1. 等待 DMA 传输完成
    DMA_Wait();
    
    // 2. 使缓存失效,强制 CPU 从 SDRAM 重新读取
    SCB_InvalidateDCache_by_Addr((uint32_t*)buf, (int32_t)len);
    
    // 3. 现在可以安全访问 buf
    ProcessData(buf, len);
}

3.4 同时需要 Clean 和 Invalidate 的场景

如果缓冲区既被 CPU 写又被 DMA 写(如双缓冲),可使用 SCB_CleanInvalidateDCache_by_Addr()

3.5 注意事项:地址对齐与长度

  • 函数要求地址按 32 字节对齐(Cache line 大小)。若不对齐,需手动处理首尾部分。
  • 长度参数为字节数,但实际操作会向上取整到整行。建议缓冲区设计时对齐。
// 安全封装:处理非对齐情况
void Cache_CleanBuffer(uint8_t *addr, uint32_t len) {
    uint32_t start = (uint32_t)addr;
    uint32_t end = start + len;
    uint32_t aligned_start = start & ~0x1F;
    uint32_t aligned_end = (end + 0x1F) & ~0x1F;
    SCB_CleanDCache_by_Addr((uint32_t*)aligned_start, aligned_end - aligned_start);
}

四、完整示例:SDRAM 上的 DMA 传输

以下代码演示了在 SDRAM 中分配缓冲区,并通过 DMA 进行收发(以 UART 为例):

// 假设 SDRAM 基地址为 0xD0000000,缓冲区位于此区域
#define SDRAM_BUF_ADDR  0xD0000000
#define BUF_SIZE        1024

uint8_t *tx_buf = (uint8_t*)SDRAM_BUF_ADDR;
uint8_t *rx_buf = (uint8_t*)(SDRAM_BUF_ADDR + 0x1000);

void UART_DMA_Test(void) {
    // 初始化 UART DMA...
    
    // 准备发送数据
    for (int i = 0; i < BUF_SIZE; i++) {
        tx_buf[i] = i;
    }
    
    // 发送前 Clean
    SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, BUF_SIZE);
    
    // 启动 DMA 发送
    HAL_UART_Transmit_DMA(&huart1, tx_buf, BUF_SIZE);
    
    // 等待发送完成...
    
    // 启动 DMA 接收
    HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
    
    // 等待接收完成(中断或轮询)
    
    // 接收后 Invalidate
    SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, BUF_SIZE);
    
    // 现在可以安全读取 rx_buf
}

五、常见陷阱与注意事项

  • 不要滥用 volatile:它无法解决 Cache 问题,反而可能降低性能。仅在访问内存映射寄存器时使用。
  • 内存屏障不是万能药__DSB() 只能保证指令执行顺序,不能替代 Clean/Invalidate。
  • 缓冲区对齐:建议使用 __attribute__((aligned(32)))memalign 分配。
  • 性能权衡:Write-through 模式简化一致性,但会降低写入性能。若对性能要求高,可保持 Write-back,但务必在 DMA 操作前后调用维护函数。
  • 多核/多总线:如果使用 LTDC 等外设直接读取 SDRAM,也需要在 CPU 修改后 Clean。

六、总结

D-Cache 与 SDRAM 的一致性是 STM32F4 高性能应用的必修课。理解 Cache 的写回机制,掌握 Clean/Invalidate 操作,并正确区分 volatile 与内存屏障的适用场景,是避免随机性 bug 的关键。建议在项目初期就设计好缓存管理策略,并在所有 DMA 或外设访问共享缓冲区的地方强制执行。

通过本文的实战代码,你可以快速将一致性保护集成到自己的驱动中,让系统稳定运行。