4. 描述符(Descriptor)的概念与作用

好的,我们正式进入DMA描述符的世界。

说实话,我第一次接触描述符的时候,被一堆结构体成员搞得头大。那时候我还在做某款网络加速卡,需要DMA在内存和网卡之间搬运数据包。一开始我总觉得这东西就是个“包装盒”,没啥技术含量。直到有一次调试数据错乱,我盯着DMA控制器的寄存器看了三天……最后才发现是描述符的一个标志位没设置对。

从那以后,我再也不敢小看这玩意儿了。

一句话总结:描述符就是DMA传输的“任务单”。CPU把任务写进描述符,DMA控制器照着单子干活。一个描述符定义一次传输,多个描述符串起来就是你今天要学的描述符链。

4.1 描述符长什么样?

不同硬件厂商的描述符格式不一样。但核心成员就那么几个。我画了一个通用的描述符结构供你参考。

struct dma_descriptor {
    uint32_t    src_addr;       // 源地址
    uint32_t    dst_addr;       // 目标地址
    uint32_t    transfer_size;  // 传输长度(字节)
    uint32_t    ctrl_flags;     // 控制标志位
    uint32_t    next_desc_ptr;  // 下一个描述符地址
    uint32_t    status;         // 状态信息(DMA完成后更新)
};

你看,结构并不复杂。但每一个字段都关系着传输的正确性和效率。我习惯把这些字段分成三组来理解:

分组 字段 干什么用的?
地址信息 src_addr, dst_addr 告诉DMA从哪里读,写到哪里去。可以是内存地址,也可以是外设地址。
传输控制 transfer_size, ctrl_flags 传多少数据?是否产生中断?是否循环传输?都在这里设置。
链管理 next_desc_ptr, status 描述符链的“指针”和“回执”。CPU通过这些字段把描述符串起来,DMA做完后写回状态。

4.2 为什么需要描述符链?

你想想看,如果没有描述符链,CPU每次只能往DMA控制器里塞一个传输任务。等这个任务做完了,CPU再塞下一个。

这有什么问题?

问题大了。CPU在塞任务的时候,DMA控制器只能干等着。数据流就断了。对于大数据量、高吞吐的场景——比如音频流、视频采集或者高速网络——这种“串行”方式显然扛不住。

描述符链就是把多个描述符用链表的方式串起来。DMA控制器自动从链头开始,读完一个描述符,就执行一次传输,然后顺着next_desc_ptr找到下一个描述符,继续干活。CPU只需要把链子搭好,剩下的就交给DMA自己跑。

核心价值:CPU和DMA真正做到了并行工作。CPU在准备链尾的新任务,DMA已经在处理链头的旧任务。重叠起来,吞吐量自然就上去了。

4.3 描述符的经典工作流程

我整理了一个标准的“CPU-DMA”交互流程。我们在项目里也是这么用的:

  1. CPU 分配描述符内存:通常在系统初始化时,从内存中分配一块连续的区域,或者预先定义好描述符数组。
  2. CPU 初始化描述符:填写每个描述符的源地址、目标地址、传输长度、控制标志。特别是next_desc_ptr,必须指向下一个描述符的物理地址。
  3. CPU 把第一个描述符地址写入DMA控制器的起始地址寄存器:这一步相当于告诉DMA“你的任务列表从这儿开始”。
  4. CPU 启动DMA:设置DMA控制器的启动位。
  5. DMA 控制器自动运行:
    • 从当前描述符读取源地址、目标地址、传输长度。
    • 执行数据传输。
    • 传输完成后,写回状态到描述符的status字段。
    • 如果设置了中断标志,向CPU发出中断。
    • 读取next_desc_ptr,指向下一个描述符。
    • 重复上述步骤,直到链尾(next_desc_ptr为NULL或者特殊值)。
  6. CPU 处理中断/轮询状态:CPU可以在中断服务程序或者轮询循环中检查描述符的status字段,确认传输是否完成。

嗯,这里要注意。如果描述符链走到头了,大部分DMA控制器会停止工作。这时候需要CPU重新把链头地址写进去,再启动一次。这个“重新启动”的动作,就是我们下一节要讲循环的基础。

描述符链工作流程 ① CPU 分配描述符内存 ② CPU 初始化描述符内容 ③ 写入起始地址 ④ CPU 启动 DMA ⑤ DMA 自动读取描述符并传输 (循环执行直到链尾) ⑥ 写回状态 ⑦ 中断 / 轮询 Desc 0 Desc 1 … CPU 只需要初始化描述符链并启动一次,后续传输由 DMA 自动完成。 链尾描述符的 next_desc_ptr 应指向 NULL 或特定结束符。

4.4 描述符的关键控制标志位

标志位是描述符的“灵魂”。我在项目中踩过很多坑,基本都是标志位没搞对。

常见的控制位包括:

  • 中断使能(IE):这个描述符传输完成后是否产生中断。如果你希望每个描述符都中断CPU,你就都设上。但中断太频繁会拖累性能,我习惯只在链尾设中断,或者批量完成时才中断。
  • 链结束(EOL):表示当前描述符是链的最后一个。DMA控制器读到这个位后,不会再去找下一个描述符。有些硬件用这个位代替检查next_desc_ptr是否为NULL。
  • 循环模式(Loop):结合EOL使用。如果整个描述符链是循环的,最后一个描述符的next_desc_ptr会指向第一个描述符。但这是一个特殊情况,我们下一章专门讲循环。
  • 传输方向:内存到内存(M2M),内存到外设(M2P),外设到内存(P2M)。这个标志位告诉DMA源地址和目标地址谁是“外设”。

我个人遇到的坑:有一次在做高清摄像头数据采集时,我忘了设置P2M方向。DMA一直从内存读数据到另一个内存地址,图像数据全是内存里的垃圾。调试了整整一天才发现是方向位没填对。所以建议你在初始化描述符时,用一个调试工具或者串口把每个描述符的内容打印出来看一眼。

4.5 描述符对齐与内存约束

DMA控制器对描述符存放的内存地址有对齐要求。多数芯片要求描述符起始地址4字节对齐,也有要求8字节甚至128字节对齐的。

为什么?因为DMA控制器内部是用突发传输(Burst)来读取描述符的。不对齐的地址可能导致总线错误,或者读出来的数据是错的。

你可以这样处理:

  • 使用__attribute__((aligned(32)))或者memalign()函数分配内存。
  • 让描述符数组的首地址对齐到缓存行大小(通常是32字节或64字节)。
  • 千万不要把描述符放在栈上,普通栈的变量地址很难保证对齐。

建议:描述符本身占用的内存通常不大,一个描述符16到32字节就够用了。在系统初始化时预先分配一个大的DMA描述符池(比如几百个描述符),以后就不再动态分配了。这样做的好处是:内存连续、地址固定、对齐有保证、不会产生内存碎片。我在做工业相机项目时一直用这种预分配池的方式,稳定得很。

4.6 描述符链 vs 描述符表

你可能听说过这两种方式。简单说一下区别:

特性 描述符链 描述符表
组织方式 链表结构,通过指针链接 数组结构,顺序连续排列
内存连续性 可以不连续,分散在内存各处 必须连续,一次性分配
灵活性 支持动态增删描述符 大小固定,不易扩展
DMA遍历方式 通过next_desc_ptr跳转 根据当前索引+1移动
硬件支持度 大多数DMA控制器支持 部分专用DMA支持

大部分通用DMA控制器都支持描述符链。描述符表则多用于那些对实时性要求极高、传输模式固定的场景。我们的课程以描述符链为主,因为它最灵活,也最能体现我们的循环优化思路。