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”交互流程。我们在项目里也是这么用的:
- CPU 分配描述符内存:通常在系统初始化时,从内存中分配一块连续的区域,或者预先定义好描述符数组。
- CPU 初始化描述符:填写每个描述符的源地址、目标地址、传输长度、控制标志。特别是
next_desc_ptr,必须指向下一个描述符的物理地址。 - CPU 把第一个描述符地址写入DMA控制器的起始地址寄存器:这一步相当于告诉DMA“你的任务列表从这儿开始”。
- CPU 启动DMA:设置DMA控制器的启动位。
- DMA 控制器自动运行:
- 从当前描述符读取源地址、目标地址、传输长度。
- 执行数据传输。
- 传输完成后,写回状态到描述符的
status字段。 - 如果设置了中断标志,向CPU发出中断。
- 读取
next_desc_ptr,指向下一个描述符。 - 重复上述步骤,直到链尾(
next_desc_ptr为NULL或者特殊值)。
- CPU 处理中断/轮询状态:CPU可以在中断服务程序或者轮询循环中检查描述符的
status字段,确认传输是否完成。
嗯,这里要注意。如果描述符链走到头了,大部分DMA控制器会停止工作。这时候需要CPU重新把链头地址写进去,再启动一次。这个“重新启动”的动作,就是我们下一节要讲循环的基础。
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控制器都支持描述符链。描述符表则多用于那些对实时性要求极高、传输模式固定的场景。我们的课程以描述符链为主,因为它最灵活,也最能体现我们的循环优化思路。