Lazy loaded image
02|一次 DMA 传输的全链路图:设备、内存、缓存与用户态
Words 3149Read Time≈ 8 min
2026-7-9
🗺️
核心判断
写 DMA 驱动之前,最应该先看清的不是 API,而是数据路径和控制路径到底怎么分层。很多实现之所以写着写着就乱,不是因为函数不会调,而是因为脑子里根本没有一张全链路图。

这一章解决什么问题

你后面会碰到一堆名词:
  • buffer
  • descriptor
  • doorbell
  • completion
  • ring
  • mmap
  • ioctl
  • 中断
  • poll
 
如果没有全景图,这些词就会像一堆散装零件;有了全景图,它们才会变成一台机器。
 
本章的任务很简单:
先把一次 DMA 传输从设备到用户态的完整链路画出来。

一张总图先压住全场

这张图里,最核心的不是“设备连内存”,而是下面三件事:
  1. 数据面:真正承载 payload 的 buffer
  1. 控制面:提交、完成、回收、错误处理
  1. 同步边界:CPU、设备、用户态之间何时能看见同一份最新数据

先分清数据面和控制面

数据面:真正承载数据的地方

数据面通常包括:
  • 设备读的发送 buffer
  • 设备写的接收 buffer
  • ring buffer 中每个 slot 的 payload
  • 用户态 mmap 看到的那块共享区
 
数据面的目标通常是:
  • 高吞吐
  • 尽量少拷贝
  • 可持续流转

控制面:保证这条通路不翻车

控制面通常包括:
  • descriptor
  • head / tail
  • valid / done / owner 状态位
  • ioctl / poll / 中断
  • timeout / reset / reclaim
 
控制面的目标不是快,而是:
  • 明确谁能提交
  • 明确谁已完成
  • 明确谁可以回收
  • 明确异常怎么收口
 
一句话:
数据面负责搬东西,控制面负责防止大家同时动同一块东西。

一次 DMA 传输,从设备视角看是什么

场景 A:设备从内存读数据(TX)

比如 CPU/用户态准备好一块发送数据,让设备拿去发。
在这个路径里,设备的角色像一个消费者:
  • CPU/用户先生产数据
  • 驱动完成提交
  • 设备消费 buffer

场景 B:设备把数据写入内存(RX)

比如音频采样、网络收包、ADC 连续采集。
在这个路径里,设备的角色像一个生产者:
  • 驱动先准备空 buffer
  • 设备往里写
  • CPU/用户再来消费
 
这类 RX 场景,也是本专题的主线。因为它天然会引出 ring buffer、ownership、completion、零拷贝这些核心问题。

descriptor、doorbell、completion 到底各管什么

组件
作用
本质问题
descriptor
描述某个 buffer 的地址、长度、控制位
“设备该处理哪块内存?”
doorbell
通知设备有新任务可处理
“现在可以开始干活了吗?”
completion
表示一次处理已完成
“干完了吗?结果现在归谁?”

descriptor 不是数据本身,而是任务说明书

descriptor 常常包含:
  • DMA 地址
  • 长度
  • 控制标志
  • ownership / valid / done 之类的状态位
别把 descriptor 理解成“附属结构”。很多 DMA 驱动真正的状态机,其实就藏在 descriptor 和 ring 推进语义里。

doorbell 是交接动作,不是业务逻辑

doorbell 可能是:
  • 写某个寄存器
  • 推进 producer index
  • 设置某个位
它的语义通常是:
“驱动这边准备好了,你可以开始看了。”

completion 是所有收尾逻辑的起点

completion 可能来自:
  • 硬件中断
  • 状态寄存器轮询
  • descriptor done bit
  • DMAEngine 回调
completion 不是“整个系统结束”,而只是:
设备阶段结束,下一阶段可以开始交接。

从驱动视角看,一次传输究竟做了什么

驱动做的第一件事:组织内存

驱动需要先准备:
  • DMA buffer
  • descriptor/ring
  • 同步原语
同步原语含义
同步原语(synchronization primitives)指一类“用来在并发参与者之间建立顺序与可见性”的基础机制。并发参与者可以是:CPU 上不同线程/CPU core、CPU 与设备(DMA)、甚至用户态与内核态。
把话说得更硬一点:同步原语解决两件事——谁先谁后(ordering)和对方能不能看到我刚写的(visibility)。

1) 它在 DMA 链路里具体“同步”什么?

DMA 场景的坑不是“设备能不能搬”,而是:
  • CPU 写了 descriptor/buffer 之后,设备什么时候能可靠读到?
  • 设备 DMA 写了 buffer/状态位之后,CPU/用户态什么时候能可靠看到?
  • 中断/doorbell 这些事件到来时,数据是不是已经准备好?
如果没有同步原语,你会遇到典型鬼故事:
  • doorbell 已经敲了,但设备读到的 descriptor 还是旧的(CPU 写还在 cache 或乱序窗口里)
  • CPU 收到完成中断,但读到的 buffer 仍是旧数据(设备写已到内存,但 CPU 这边 cache 还没失效/没同步)
  • 生产者/消费者同时推进 ring 指针,偶发丢包或重复回收

2) 常见同步原语有哪些(按用途分)

A. 互斥类:保证“同一时刻只有一个人改”

  • mutex / spinlock
  • 适用:保护 ring 的 head/tail、空闲队列、descriptor 回收链表等共享元数据
  • 本质:解决竞争条件(race)

B. 原子操作:保证“一个变量更新要么全发生要么不发生”

  • atomic inc/dec, cmpxchg (CAS)
  • 适用:无锁 ring、引用计数、状态位切换(owner/done/valid)
  • 本质:解决更新的原子性

C. 等待-唤醒类:保证“我等到条件成立再继续”

  • completion / wait queue / semaphore
  • 用户态:futex、condition variable
  • 适用:poll/read 阻塞等待“有新 buffer ready”;驱动等待某硬件事件结束
  • 本质:解决阻塞同步(不是忙等)

D. 内存序/屏障:保证“写入顺序 + 可见性”(DMA 里最关键)

  • memory barrier / fence(如 wmb/rmb、smp_wmb/smp_rmb)
  • DMA 同步 API(Linux 里典型是 dma_sync_* / dma_map_* 的语义)
  • 适用:
    • doorbell 前:保证 descriptor/buffer 的写入先对设备可见
    • 读完成后:保证 CPU 读取数据前,看到的是设备刚写的那份
  • 本质:解决乱序 + cache 可见性
你在这章里写“同步原语”,如果是 DMA 驱动语境,十有八九重点指的就是这类:屏障/缓存同步/可见性协议,而不只是锁。

3) 一个最小但正确的心智模型(强烈建议记这个)

以“CPU 把 descriptor 交给设备”为例:
  1. CPU 写 descriptor(地址/长度/控制位)
  1. 同步原语:写屏障/flush ——把这批写入“推出去”,确保设备随后能看到
  1. CPU 写 doorbell(告诉设备:可以来读 descriptor 了)
反过来“设备写完 buffer,CPU 来读”:
  1. 设备 DMA 写 buffer / done bit
  1. CPU 收到中断/轮询到 done
  1. 同步原语:读屏障/invalidate ——确保 CPU 读 buffer 时,不会拿到旧 cache
  1. CPU/用户态开始消费数据

4) 一句话定义(你可以直接放进文里)

同步原语:用于在 CPU/线程/设备之间建立“先后顺序”和“内存可见性”的机制,确保控制面事件发生时,数据面状态已经一致。

如果你接下来要把这段写进当前页面,我建议你决定“同步原语”在本章的指代范围(更窄更清晰):
  1. 仅指 内存屏障 + cache/DMA 同步(DMA 语境最核心)
  1. 还是包含 锁/原子/等待队列(控制面并发也会用到)
  • 中断和完成通知路径
说白了,设备擅长搬,驱动擅长组织。

驱动做的第二件事:完成 ownership 交接

这一步包括:
  • 什么时候 buffer 从 CPU 交给设备
  • 什么时候设备交回给 CPU
  • 什么时候 CPU 再交给用户态
这也是为什么后面的重头戏不是“分配内存”,而是“定义生命周期”。

驱动做的第三件事:把异常路径写完整

真实系统里,驱动必须准备面对:
  • completion 丢失
  • timeout
  • reset
  • 用户态不消费
  • 进程提前退出
如果没有这些设计,你的 happy path 越漂亮,翻车时越壮观。

从用户态视角看,全链路意味着什么

当用户态介入时,系统会多出一层协作关系:
  • 用户态可能通过 mmap 直接看到数据面
  • 用户态通过 ioctl / poll 与驱动走控制面
  • 用户态不应直接绕过协议修改正在被设备使用的 buffer
所以用户态不是“旁观者”,而是全链路里的第三个参与方。
 
从这一刻起,系统里至少有三方:
  • 设备
  • 驱动 / CPU
  • 用户态
三方都能看到某些资源,但不是三方都能在任意时刻改它。这个限制,就是后面 ownership 状态机存在的理由。

一条典型的流式采集全链路

这条链路里,最值钱的观察不是“发生了几步”,而是:
  • 数据面只在必要时被访问
  • 控制面负责推进状态
  • 每次推进都在做 ownership 切换

写驱动时最容易丢掉的全局视角

只盯着 DMA API,不看系统边界

结果就是:
  • buffer 有了
  • sync 也调了
  • 但 nobody knows 下一步该谁动
这类代码最容易出现“偶发旧数据”、“重复提交”、“早回收” 这类半鬼不鬼的问题。

只盯着设备,不看用户态接口

一旦需要零拷贝,用户态是否知道:
  • 什么时候能写
  • 什么时候能读
  • 什么时候必须停止碰 buffer
如果答案含糊,那问题已经埋好了。

只盯着 happy path,不看回收路径

全链路图里最不该省略的,就是:
  • completion 后谁接手
  • 超时后谁清场
  • reset 后旧 buffer 如何作废
这些看起来“像细节”,其实决定系统有没有资格上线。

本章结论

  1. 一次 DMA 传输不是一个 API 调用,而是一条跨设备、内存、驱动、用户态的完整链路。
  1. 这条链路里必须分清数据面与控制面。
  1. descriptor、doorbell、completion 分别解决“处理谁”“何时开始”“何时结束”的问题。
  1. 一旦用户态进入链路,DMA 问题就升级成多方协作协议问题。

下一章要干什么

下一章开始打地基:
  • 地址空间
  • cache 一致性
  • sync / barrier
  • IOMMU
  • DMA mask
 
因为如果你连“设备看到的地址”和“CPU 看到的地址”都没分清,后面所有生命周期设计都会建立在幻觉上。
上一篇
C++ String 常用操作
下一篇
嵌入式工程师习题集

Comments
Loading...