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,中间必然出现断流。换成循环链表后,问题迎刃而解。
循环链表的初始化流程大致如下:
- 分配N个描述符,全部清零。
- 设置每个描述符的src_addr、dst_addr、transfer_size。
- 将第i个描述符的next_desc指向第(i+1)个描述符。
- 将最后一个描述符的next_desc指向第一个描述符。
- 设置DMA控制器的当前描述符地址为首地址。
- 启动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结构图
下面这张图展示了循环链表模式和散聚模式的核心区别。左边是环,右边是链,一目了然。
这张图里,左侧的循环链表用红色虚线表示回环,右侧的散聚模式用红色虚线指向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、链式传输、硬件自动回填——就水到渠成了。