3、DMA描述符链:链式传输机制、描述符结构设计、环形缓冲区与链表的实现
DMA传输,说白了就是让数据自己跑,不占CPU。但单次DMA传输能做的事太有限了。你想想看,一个数据流可能持续几毫秒甚至几秒,难道每次传输完都要CPU去重新配置一次DMA?那还不如用中断呢。
所以就有了描述符链。它的核心思想是:把多次DMA传输的配置信息,提前排好队,让DMA自己按顺序执行。CPU只需要在开始时点个火,然后就可以去干别的事了。
描述符链的本质:一个由DMA硬件自动遍历的“任务列表”。每个描述符就是一条任务指令,告诉DMA从哪里读、写到哪里、传多少字节、传完通知谁。
3.1 链式传输机制:DMA如何“自动”干活
我记得第一次接触链式DMA是在一个视频采集项目上。摄像头每秒30帧,每帧数据量不小。如果用单次DMA,每帧都要CPU介入,帧率根本跑不满。后来换成描述符链,CPU只在初始化时配置一次,后面DMA自己循环跑,帧率直接拉满。
链式传输的工作流程大致是这样的:
- 初始化阶段:CPU在内存中创建一组描述符,每个描述符指向一块数据缓冲区。
- 启动传输:CPU将第一个描述符的地址写入DMA控制器的启动寄存器。
- 自动遍历:DMA完成当前描述符后,自动读取下一个描述符的地址(从当前描述符的Next指针字段获取),继续传输。
- 完成通知:每个描述符可以配置是否在传输完成后产生中断。通常只在最后一个描述符或出错时产生中断。
- 循环或终止:如果最后一个描述符的Next指针指向第一个描述符,就形成循环链;如果指向NULL,则传输停止。
我的习惯:在关键数据流(比如音频、视频)中,我通常只在链的尾部开一个中断。这样CPU被打扰的次数最少,吞吐量最高。但调试阶段我会在每个描述符都开中断,方便定位问题。
3.2 描述符结构设计:每个字段都有它的脾气
描述符的结构,不同芯片厂商的命名和布局略有差异,但核心字段大同小异。我以最常见的通用设计为例:
| 字段 | 位宽 | 说明 |
|---|---|---|
| SrcAddr | 32/64位 | 源地址。外设地址或内存地址。 |
| DstAddr | 32/64位 | 目的地址。内存地址或外设地址。 |
| TransferSize | 16/32位 | 本次传输的字节数。注意对齐要求。 |
| NextDescPtr | 32/64位 | 下一个描述符的指针。链式传输的关键。 |
| Control | 16/32位 | 控制位:中断使能、传输方向、数据宽度、突发长度等。 |
| Status | 16/32位 | 状态位:传输完成、错误标志、当前描述符是否被DMA占用等。 |
这里有个坑,我踩过不止一次:描述符本身必须放在DMA可访问的内存区域。有些DMA控制器只能访问特定的内存地址范围(比如片内SRAM),如果你把描述符放在DDR里,DMA根本读不到。嗯,这个在芯片选型阶段就要确认清楚。
我曾经在一个项目里把描述符放在外部SDRAM中,结果DMA死活不工作。查了两天,最后发现是DMA的地址总线根本连不到SDRAM控制器。后来把所有描述符挪到片内SRAM,问题解决。所以,描述符的存放位置,必须在DMA的地址可达范围内。
3.3 环形缓冲区与链表的实现:两种思路,各有千秋
描述符链的组织方式,主要有两种:环形缓冲区和链表。它们各有适用的场景。
3.3.1 环形缓冲区实现
环形缓冲区,说白了就是一个固定大小的数组,最后一个描述符的Next指针指向第一个。这样DMA就会一圈一圈地循环传输。
它的优点是:
- 内存固定:描述符数组在初始化时一次性分配,运行时不需要动态分配内存。
- 访问速度快:数组是连续内存,CPU和DMA访问时缓存友好。
- 实现简单:只需要维护头尾指针,不需要处理链表插入删除。
缺点是:
- 大小固定:如果数据流突然变大,描述符不够用,就得重新设计。
- 灵活性差:所有描述符的缓冲区大小通常相同,不适合变长数据包。
我一般在周期性数据流(音频采样、视频帧、ADC连续采集)中使用环形缓冲区。比如一个音频项目,采样率固定,缓冲区大小固定,环形缓冲区完美匹配。
3.3.2 链表实现
链表实现就是每个描述符的Next指针指向下一个动态分配的描述符。最后一个描述符的Next指针可以指向NULL(单次链)或指向第一个(循环链)。
它的优点是:
- 灵活:描述符可以动态添加和删除,适合变长数据包或不确定的数据流。
- 内存利用率高:每个描述符的缓冲区大小可以不同,按需分配。
缺点是:
- 内存碎片:频繁分配释放描述符,容易产生碎片。
- 访问速度慢:描述符在内存中不连续,DMA预取效率低。
- 实现复杂:需要处理链表插入、删除、异常恢复等逻辑。
链表适合网络数据包处理或不确定长度的数据采集。比如一个以太网项目,每个数据包大小不同,用链表可以灵活分配缓冲区。
我的建议:如果数据流是周期性的、大小固定的,优先用环形缓冲区。如果数据流是突发性的、大小不固定的,用链表。但不管哪种方式,描述符的更新一定要保证原子性,否则DMA读到一半的描述符,后果很严重。
3.4 核心逻辑关系图
下面这张图展示了描述符链的核心逻辑。你可以看到CPU、DMA控制器、描述符链和数据缓冲区之间的关系。
3.5 代码示例:环形缓冲区描述符链初始化
下面是一个简化的环形缓冲区描述符链初始化代码。我用的是类C伪代码,实际使用时需要根据具体芯片的寄存器定义调整。
/* 描述符结构体定义 */
typedef struct {
uint32_t src_addr; /* 源地址 */
uint32_t dst_addr; /* 目的地址 */
uint16_t transfer_size; /* 传输大小 */
uint16_t control; /* 控制位 */
uint32_t next_desc; /* 下一个描述符指针 */
uint32_t status; /* 状态位 */
} dma_desc_t;
/* 描述符数组(环形缓冲区) */
#define NUM_DESCRIPTORS 4
dma_desc_t desc_ring[NUM_DESCRIPTORS] __attribute__((aligned(32)));
/* 数据缓冲区 */
uint8_t buffer[NUM_DESCRIPTORS][1024] __attribute__((aligned(32)));
void dma_ring_init(void)
{
int i;
for (i = 0; i < NUM_DESCRIPTORS; i++) {
/* 配置每个描述符 */
desc_ring[i].src_addr = (uint32_t)&buffer[i][0]; /* 外设地址需根据实际情况修改 */
desc_ring[i].dst_addr = (uint32_t)&buffer[i][0]; /* 这里假设内存到内存传输 */
desc_ring[i].transfer_size = 1024;
desc_ring[i].control = DMA_INT_EN; /* 最后一个描述符才开中断 */
desc_ring[i].status = 0;
/* 环形链接:最后一个指向第一个 */
if (i == NUM_DESCRIPTORS - 1) {
desc_ring[i].next_desc = (uint32_t)&desc_ring[0];
} else {
desc_ring[i].next_desc = (uint32_t)&desc_ring[i + 1];
}
}
/* 将第一个描述符地址写入DMA控制器的启动寄存器 */
DMA->START_ADDR = (uint32_t)&desc_ring[0];
DMA->CONTROL = DMA_CHAIN_EN | DMA_START;
}
注意:描述符数组的对齐很重要。很多DMA控制器要求描述符按32字节或64字节对齐。如果不对齐,DMA可能读错描述符字段。我习惯在定义时加上__attribute__((aligned(32))),省心。
3.6 避坑指南:描述符链的常见问题
- 描述符被DMA占用时不要修改:DMA正在读取的描述符,CPU不要去改。否则DMA读到一半的数据,行为不可预测。我通常用状态位来标记描述符是否可用。
- 中断处理要快:如果每个描述符都开中断,中断频率会很高。中断处理函数里只做必要的事(比如更新指针、清标志),不要做耗时操作。
- 缓存一致性问题:如果CPU和DMA共享内存,注意缓存一致性。CPU修改描述符后,要flush缓存;DMA写入数据后,CPU要invalidate缓存。这个在ARM Cortex-A系列上尤其要注意。
- 描述符链的异常恢复:如果DMA传输过程中出错(比如总线错误),描述符链可能卡住。我习惯在DMA错误中断里重置整个链,而不是尝试恢复当前描述符。
我曾经在一个项目中,DMA描述符链跑着跑着突然停了。查了半天,发现是CPU在中断里修改了当前正在被DMA读取的描述符。DMA读到一半的Next指针被改成了无效地址,直接挂掉。从那以后,我强制要求:CPU只修改状态位标记为“已完成”的描述符。
好了,描述符链的核心内容就这些。环形缓冲区适合固定周期数据流,链表适合变长数据包。选择哪种,取决于你的实际场景。但不管哪种,描述符的原子性更新和缓存一致性是绕不开的两个坎。跨过它们,DMA传输才能真正做到“让数据自己跑”。