引言:DMA 与 D-Cache 的“隐形战争”

在嵌入式开发中,DMA 负责高效搬运数据,而 CPU 依赖 D-Cache 加速访问。然而,当两者操作同一内存时,D-Cache 的“懒惰”写回策略会引发数据不一致:CPU 修改的数据可能仍留在 Cache 中,DMA 却从主存读取旧数据;反之,DMA 写入主存后,CPU 可能读到 Cache 中的过期数据。STM32F4 系列(Cortex-M4)默认不启用 D-Cache,但若使用外部 SDRAM 或开启 D-Cache 加速,问题便浮现。本文以 STM32F429 为例,通过 MPU 配置解决这一经典难题。

原理剖析:Cache 与 DMA 的冲突根源

1. D-Cache 的工作机制

  • 写回(Write-back):CPU 写数据时仅更新 Cache,标记为脏(Dirty),延迟写回主存。
  • 写分配(Write-allocate):读未命中时,将主存数据加载到 Cache。

2. DMA 的“旁路”特性

DMA 直接访问主存(SRAM 或 SDRAM),不经过 Cache。因此,当 CPU 与 DMA 共享缓冲区时,Cache 中的脏数据或过期数据会导致不一致。

3. 解决方案思路

  • 禁用 Cache:简单粗暴,但牺牲性能。
  • MPU 配置区域为非缓存(Device 或 Strongly-ordered):强制 CPU 直接访问主存,适用于 DMA 缓冲区。
  • 软件维护 Cache:使用 SCB_CleanDCacheSCB_InvalidateDCache,但需谨慎时机。

实战配置:MPU 设置非缓存区域

1. 硬件环境

  • 芯片:STM32F429ZIT6(Cortex-M4,带 D-Cache)
  • 外部 SDRAM:IS42S16400J(1M×16bit,位于 Bank1)
  • 开发环境:Keil MDK 5.27 + STM32CubeF4 固件库

2. 配置步骤

步骤 1:启用 D-Cache 和 MPU

在系统初始化时,先使能 MPU,再使能 D-Cache(顺序不可颠倒)。

void MPU_Config(void)
{
    MPU_Region_InitTypeDef MPU_InitStruct;

    // 禁用 MPU 进行配置
    HAL_MPU_Disable();

    // 配置 SDRAM 区域(地址 0xC0000000,大小 4MB)为非缓存
    MPU_InitStruct.Enable = MPU_REGION_ENABLE;
    MPU_InitStruct.BaseAddress = 0xC0000000;
    MPU_InitStruct.Size = MPU_REGION_SIZE_4MB;
    MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE;
    MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE;  // 关键:非缓存
    MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE;
    MPU_InitStruct.Number = MPU_REGION_NUMBER0;
    MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0;
    MPU_InitStruct.SubRegionDisable = 0x00;
    MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_ENABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);

    // 使能 MPU
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

void Cache_Init(void)
{
    // 使能 D-Cache(需在 MPU 之后)
    SCB_EnableDCache();
}

步骤 2:定义 DMA 缓冲区并放置到非缓存区域

将 DMA 缓冲区定义在 SDRAM 中(或通过链接脚本指定),确保其地址落在 MPU 配置的非缓存区域。

// 使用 __attribute__ 指定段,或直接定义在 SDRAM 地址
uint8_t dma_rx_buffer[1024] __attribute__((section(".sdram")));
// 在链接脚本中,将 .sdram 段定位到 0xC0000000 之后

步骤 3:DMA 与 CPU 交互示例

以 UART DMA 接收为例,演示数据一致性保证。

// 启动 DMA 接收
HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, sizeof(dma_rx_buffer));

// 在 DMA 传输完成回调中处理数据
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        // 由于缓冲区是非缓存的,CPU 直接读取主存,无需 Cache 操作
        ProcessData(dma_rx_buffer);
        // 重新启动 DMA 接收
        HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, sizeof(dma_rx_buffer));
    }
}

步骤 4:软件维护 Cache(备用方案)

若无法使用 MPU,可在 DMA 操作前后手动维护 Cache。

// 在 CPU 写数据后,启动 DMA 前,清 Cache
SCB_CleanDCache();
// 在 DMA 完成,CPU 读数据前,无效化 Cache
SCB_InvalidateDCache();

完整代码示例:MPU + DMA 环形缓冲区

以下是一个更完整的示例,包含 MPU 配置、DMA 环形缓冲区实现。

#include "stm32f4xx_hal.h"

// 定义环形缓冲区结构(位于非缓存区域)
typedef struct {
    uint8_t data[256];
    uint16_t head;
    uint16_t tail;
} RingBuffer;

// 将缓冲区放置到 SDRAM 非缓存区域
RingBuffer g_rb __attribute__((section(".sdram")));

// MPU 配置函数(如前所述)
void MPU_Config(void);

// 初始化 DMA 接收
void DMA_Init(void)
{
    // 配置 UART DMA 等,省略具体细节
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MPU_Config();
    SCB_EnableDCache();
    DMA_Init();

    // 启动 DMA 接收,数据直接写入 g_rb.data
    HAL_UART_Receive_DMA(&huart1, g_rb.data, sizeof(g_rb.data));

    while (1)
    {
        // 主循环处理数据,无需 Cache 操作
        if (g_rb.tail != g_rb.head)
        {
            ProcessByte(g_rb.data[g_rb.tail++]);
            g_rb.tail %= sizeof(g_rb.data);
        }
    }
}

注意事项与调试技巧

  • MPU 区域大小对齐:MPU 区域大小必须是 2 的幂次,且基地址对齐。例如 4MB 区域要求基地址 0xC0000000 对齐到 4MB。
  • 链接脚本:确保 .sdram 段正确放置在 SDRAM 起始地址,否则 MPU 不生效。
  • Cache 操作时机:若使用软件维护,务必在 DMA 启动前 Clean,完成后 Invalidate,顺序错误会导致数据错乱。
  • 调试技巧:使用逻辑分析仪或断点观察内存值,确认 Cache 行为。也可临时禁用 D-Cache 对比测试。
  • 性能权衡:非缓存区域访问速度较慢,仅对 DMA 缓冲区使用,其他高频数据仍可缓存。

结语

通过 MPU 将 DMA 缓冲区配置为非缓存,从根源上避免了 Cache 与 DMA 的数据一致性冲突,既保证了正确性,又保留了对其他区域的缓存加速。掌握这一技巧,将使你在处理高速数据采集、网络通信等场景时更加游刃有余。希望本文能成为你嵌入式开发路上的有力工具。