4. 缓存一致性核心问题

DMA传输的核心,说白了就是「谁来告诉DMA控制器,数据在哪、要搬到哪里去」。这个「谁」就是描述符。我刚开始接触DMA时,总觉得描述符就是个结构体,随便填填就行。直到有一次,我在调试一个高速数据采集卡时,发现数据总是错位,查了三天才发现是描述符的next指针没对齐——嗯,从那以后我再也不敢小看描述符了。

这一节,我们重点聊三种东西:描述符结构体怎么定义、循环链表模式怎么玩、散聚模式又是什么鬼。你想想看,搞懂了这些,DMA传输才算真正入了门。

4.1 描述符结构体:DMA的「任务卡片」

每个DMA控制器都有一组寄存器,但寄存器数量有限。要传输大量数据,就得靠描述符。描述符就是一张任务卡片,告诉DMA:「从A地址读N个字节,写到B地址,完成后通知我。」

我个人习惯把描述符分成三个区域:控制区、地址区、状态区。下面是一个典型的描述符结构体定义:

struct dma_descriptor {
    /* 控制字段 */
    uint32_t    control;        /* 传输方向、宽度、突发长度等 */
    uint32_t    next_desc;      /* 下一个描述符的物理地址 */

    /* 地址字段 */
    uint32_t    src_addr;       /* 源地址(物理地址) */
    uint32_t    dst_addr;       /* 目的地址(物理地址) */
    uint32_t    transfer_size;  /* 本次传输的字节数 */

    /* 状态字段 */
    uint32_t    status;         /* 完成标志、错误标志等 */
    uint32_t    reserved;       /* 保留,通常用于对齐 */
} __attribute__((packed, aligned(32)));

关键点:

  • 对齐要求:描述符必须按DMA控制器要求的边界对齐。我见过最狠的是要求128字节对齐,否则DMA直接罢工。
  • 物理地址:描述符里存的地址必须是物理地址,不是虚拟地址。很多新手在这里翻车。
  • next指针:这是链表的关键。如果next为NULL,DMA传输完当前描述符就停了。

我的习惯:在描述符末尾加一个reserved字段,用来凑对齐。别小看这个字段,它救过我两次——一次是换芯片后对齐要求变了,一次是调试时用来临时存调试信息。

4.2 循环链表模式:永不停歇的数据搬运

循环链表模式,说白了就是描述符串成一个环。DMA传输完最后一个,自动跳回第一个。这种模式最适合什么场景?连续的数据流,比如音频播放、视频采集、网络数据收发。

我记得有一次做音频编解码器驱动,要求不间断地往DAC送数据。如果用单次传输,每次中断都要重新配置DMA,中间必然出现断流。换成循环链表后,问题迎刃而解。

循环链表的初始化流程大致如下:

  1. 分配N个描述符,全部清零。
  2. 设置每个描述符的src_addr、dst_addr、transfer_size。
  3. 将第i个描述符的next_desc指向第(i+1)个描述符。
  4. 将最后一个描述符的next_desc指向第一个描述符。
  5. 设置DMA控制器的当前描述符地址为首地址。
  6. 启动DMA。
void dma_cyclic_init(struct dma_descriptor *desc, int num,
                     uint32_t src, uint32_t dst, uint32_t size)
{
    int i;
    for (i = 0; i < num; i++) {
        desc[i].src_addr = src + i * size;
        desc[i].dst_addr = dst + i * size;
        desc[i].transfer_size = size;
        desc[i].control = DMA_CTRL_INTERRUPT;  /* 每个描述符完成后产生中断 */
        desc[i].status = 0;

        /* 链接到下一个 */
        desc[i].next_desc = (uint32_t)&desc[(i + 1) % num];
    }
    /* 最后一个指向第一个,形成环 */
    desc[num - 1].next_desc = (uint32_t)&desc[0];
}

避坑指南:我曾经在循环链表里犯过一个低级错误——描述符的next_desc存的是虚拟地址。DMA控制器可不管什么MMU,它只认物理地址。结果就是DMA跳到了一个随机地址,系统直接挂掉。从那以后,我每次设置next_desc都会用virt_to_phys()转一下。

4.3 散聚(Scatter-Gather)模式:零散的缓冲区也不怕

现实中的嵌入式系统,内存往往是碎片化的。你申请一个连续的1MB缓冲区,很可能失败。散聚模式就是解决这个问题的——它允许你把多个不连续的物理内存块,通过描述符链表串起来,让DMA一次传输完成。

你想想看,如果没有散聚模式,你得先把数据拷贝到一个连续的大缓冲区里,再启动DMA。这不仅浪费CPU时间,还浪费内存。散聚模式直接让DMA从多个源地址读,写到多个目的地址,中间不需要CPU插手。

散聚模式的核心思想:一个描述符对应一个物理上连续的块,多个描述符串起来覆盖整个不连续的缓冲区。

特性 循环链表模式 散聚模式
链表结构 环形,最后一个指向第一个 线性,最后一个指向NULL
典型用途 连续数据流(音频、视频) 大块数据传输(文件、网络)
内存要求 每个描述符对应固定大小的块 每个描述符可以不同大小
中断频率 每个描述符或每圈中断 通常最后一个描述符中断
CPU干预 较少,只需更新已完成的描述符 较少,一次配置多次传输

散聚模式的描述符链表构建代码示例:

int dma_sg_build(struct dma_descriptor *desc,
                 struct scatterlist *sg, int sg_count,
                 uint32_t dst_base)
{
    int i;
    uint32_t dst_addr = dst_base;

    for (i = 0; i < sg_count; i++) {
        desc[i].src_addr = sg_phys(&sg[i]);  /* 获取物理地址 */
        desc[i].dst_addr = dst_addr;
        desc[i].transfer_size = sg[i].length;
        desc[i].control = DMA_CTRL_DEFAULT;

        if (i == sg_count - 1) {
            desc[i].next_desc = 0;  /* 最后一个,结束 */
            desc[i].control |= DMA_CTRL_INTERRUPT;  /* 完成时中断 */
        } else {
            desc[i].next_desc = (uint32_t)&desc[i + 1];
            dst_addr += sg[i].length;
        }
    }
    return 0;
}

我的经验:散聚模式里,每个描述符的transfer_size最好对齐到DMA控制器的突发长度。不对齐的话,有些DMA控制器会报错,有些虽然不报错但性能会下降。我一般会检查sg列表,如果某个块太小(比如小于16字节),就把它合并到前一个描述符里。

4.4 两种模式的对比与选择

什么时候用循环链表?什么时候用散聚?我个人的判断标准很简单:

  • 数据是流式的、周期性的 → 循环链表。比如音频I2S、摄像头CSI、以太网环形缓冲区。
  • 数据是块式的、一次性的 → 散聚模式。比如文件读写、SPI Flash操作、USB批量传输。
  • 两者混合 → 有些场景需要循环链表里的每个节点又是散聚的。这种高级玩法,我们后面章节再聊。

核心原则:描述符链表的设计,本质上是「用空间换时间」。你多花一点内存来存描述符,换来的是CPU从繁重的数据搬运中解放出来。这个交换,在嵌入式系统里几乎总是值得的。

4.5 描述符链表的SVG结构图

下面这张图展示了循环链表模式和散聚模式的核心区别。左边是环,右边是链,一目了然。

DMA描述符链表:循环模式 vs 散聚模式 DMA描述符链表模式对比 循环链表模式 Desc 0 Desc 1 Desc 2 Desc N 最后一个描述符的next指针 指向第一个,形成环形 散聚模式 SG 0 SG 1 SG 2 SG N NULL 最后一个描述符的next指针 为NULL,传输结束

这张图里,左侧的循环链表用红色虚线表示回环,右侧的散聚模式用红色虚线指向NULL。两种模式的核心差异就在这——一个永远循环,一个一次到底。

4.6 描述符管理:别忘了缓存一致性

最后提一个容易忽略的点:描述符本身也存在缓存一致性问题。CPU写描述符时,数据可能还在Cache里,没写回内存。DMA控制器读描述符时,读到的可能是旧数据。

我曾经在一个ARM Cortex-A7平台上调试DMA,描述符链表总是跑飞。查了两天,最后发现是CPU写完描述符后没有flush Cache。加上一句dma_cache_wback_inv()后,问题立刻消失。

避坑指南:每次CPU修改描述符后,必须做两件事:

  • 如果描述符在Cacheable内存中,调用Cache刷新函数。
  • 确保描述符的物理地址正确,且DMA控制器能看到最新的值。

如果你用的是DMA专用的Coherent内存(比如通过dma_alloc_coherent申请),那就不需要手动刷新。但如果你用的是普通内存,千万别忘了这一步。

好了,描述符和链表模式就聊到这里。循环链表和散聚模式是DMA传输的两大基石,搞懂了它们,后面的高级话题——比如多通道DMA、链式传输、硬件自动回填——就水到渠成了。