核心判断
DMA 真正难的,从来不是“分到一块 buffer”或“把地址交给硬件”,而是设计一套 ownership、交接协议、生命周期 都能自洽的系统。三者缺一,系统迟早会在偶发错帧、重复提交、早回收、异常路径失控里翻车。
这一章到底在解决什么
前面几章已经把边界和地基铺好了,这一章开始进入 DMA 设计的中轴。
如果只记一个结论,请记这个:
DMA buffer 不是“谁都能碰的共享内存”,而是一根在 CPU、设备、用户态之间不断交接的接力棒。
而一根接力棒要想不掉地上,必须同时回答三件事:
- Ownership:现在到底归谁控制
- 协议:怎么合法交接,谁来确认
- 生命周期:从申请、提交、完成到回收,整条链如何闭环
一句话翻译
你不是在“共享一块内存”,你是在设计一套 状态机 + 交接动作 + 异常回收 的协作系统。
为什么这三件事必须一起设计
只有 ownership,没有协议
你知道“当前归谁”,但不知道怎么安全交出去。
典型结果:
- 状态写在文档里
- 接口没有对应交接动作
- 用户态、驱动、设备之间只能靠猜测协作
只有协议,没有 ownership
你设计了一堆
SUBMIT、WAIT、CONSUME,但没有明确每个状态下谁能读、谁能写。结果就是:
- 接口存在
- 边界模糊
- 非法迁移没人拦
- 同一块 buffer 同时被两方“自认为有权操作”
只有生命周期,没有异常路径
你把 happy path 跑通了,却没定义:
- timeout 之后归谁接管
- reset 之后旧 buffer 怎么判废
- 用户态退出时谁来清场
结果很经典:平时看起来能跑,出事时全系统集体失忆。
先立住一套最小状态机
最推荐先建立这四个状态:
状态 | 主控制方 | 允许动作 | 禁止动作 |
CPU_OWNED | CPU / 用户态 | 填充数据、修改元数据、准备提交 | 设备直接读写 |
READY_FOR_DEVICE | 交接中的中间态 | 做同步、写 descriptor、挂队列、敲 doorbell | 再次修改 payload |
DEVICE_OWNED | 设备 | 执行 DMA、推进硬件状态 | CPU / 用户改写同一 buffer |
READY_FOR_CPU | 交还 CPU 前的中间态 | sync_for_cpu、读取结果、通知上层 | 设备继续写同一 buffer |
这四个状态不是为了“图画得好看”,而是为了给后面所有接口、同步和异常处理提供锚点。
一条典型生命周期
这里最关键的观察不是“有几个箭头”,而是:
- 生命周期不止于“分配出内存”
- 每一次 ownership 切换都必须绑定 动作、条件、回收路径
- 异常路径不是附录,而是主路径的一部分
Ownership 迁移必须落到具体动作上
迁移 | 典型动作 | 设计目的 |
CPU_OWNED → READY_FOR_DEVICE | dma_sync_single_for_device() / dma_wmb() | 让设备看见 CPU 刚写好的新版本 |
READY_FOR_DEVICE → DEVICE_OWNED | 写 descriptor / doorbell | 正式把 buffer 交给设备 |
DEVICE_OWNED → READY_FOR_CPU | completion / 中断 / 状态更新 | 通知软件“硬件已经处理完” |
READY_FOR_CPU → CPU_OWNED | dma_sync_single_for_cpu() / reclaim | 把结果安全交回 CPU / 用户态 |
重点不是 API 名字,而是这两个铁律:
- 状态迁移必须绑定具体动作
- 同步不是装饰,而是 ownership 切换的物理落点
协议的作用:把协作边界写死
协议不是为了把内核细节裸奔给用户态,而是为了把 谁在什么时候可以做什么 说清楚。
以
mmap 零拷贝场景为例,用户态至少要知道四件事,而且这四件事不能靠猜:1. 什么时候可写
不是“映射到了就能写”,而是同时满足:
- 驱动明确把某个 slot 分配给了用户
- 该 slot 当前处于
CPU_OWNED
- 它还没有被提交给设备
也就是说,
mmap 只是让用户看得见 buffer,不代表用户随时有权改 buffer。2. 写完后怎么提交
用户写完后,必须有一个明确动作告诉驱动:
- 这块 slot 已经写完
- 可以进入提交流程
- 驱动接下来会执行
sync_for_device、写 descriptor、敲 doorbell
如果没有这个提交动作,驱动根本分不清:用户是还在写,还是已经写完。
3. 什么时候可读结果
用户不能靠“估计硬件差不多写完了”来读结果。
必须有明确机制告诉用户:
- 哪个 slot 完成了
- 它已经从
DEVICE_OWNED进入READY_FOR_CPU
- 驱动已经完成必要的
sync_for_cpu或等价处理
这一步通常通过以下机制实现:
poll
WAIT_DONE
PEEK_DONE
4. 读完后怎么回收
这是最容易被忽略、但也最容易埋雷的一步。
用户读完结果后,必须显式告诉驱动:
- 这块 slot 我已经用完
- 可以回收
- 可以重新进入空闲池或下一轮
CPU_OWNED
如果没有
CONSUME / ACK,驱动就永远不知道:- 结果是不是还在被上层引用
- 这块 buffer 能不能安全复用
很多 ring 卡死、slot 泄漏、偶发错帧,最后都能追溯到一句话:消费完成没有被建模。
把这四个问题翻译成最小协议
用户真正关心的问题 | 协议动作 | 核心语义 | 对应迁移 |
什么时候可写 | GET / ALLOC | 拿到一个当前明确归自己操作的 slot | 空闲池 → CPU_OWNED |
写完后怎么提交 | SUBMIT | 声明“我写完了,可以交给设备” | CPU_OWNED → READY_FOR_DEVICE → DEVICE_OWNED |
什么时候可读结果 | WAIT / PEEK | 等待或查看已完成 slot | DEVICE_OWNED → READY_FOR_CPU |
读完后怎么回收 | CONSUME / ACK | 声明“我读完了,可以复用” | READY_FOR_CPU → CPU_OWNED / 空闲池 |
用户态最小心智模型
对用户态来说,最理想的理解方式不是死记 ioctl 名字,而是记住这条交接链:
把它展开成伪代码,大致就是:
这段流程真正要表达的是四句话:
- 写之前先拿所有权
- 写完后显式交接
- 完成后显式取回
- 读完后显式回收
如果其中任意一步是模糊的,
mmap 零拷贝系统就会迅速退化成“共享内存 + 口头约定”。而这玩意儿的稳定性,通常和玄学差不多。单 buffer 和 ring buffer 的本质差别
单 buffer:适合先建模
优点很简单:
- 状态清楚
- 时序容易画
- ownership 切换一眼能看明白
它适合教学,也适合先验证最小闭环。
ring buffer:真实系统的主战场
真实流式设备一上来,单 buffer 往往根本不够。你面对的是:
- 多个 slot 同时存在
- 有的 slot 正在被设备使用
- 有的 slot 正在等用户消费
- 有的 slot 刚被回收,准备再次分配
所以 ring buffer 不是“多个 buffer 排成一圈”这么朴素,它的本质是:
多个生命周期在并发推进。
这也是为什么 ring 场景必须明确:
- producer / consumer 谁推进哪个指针
- head / tail 在什么条件下移动
- completion 是按 slot 到达,还是按 batch 到达
- reset 之后哪些 slot 直接作废
合法迁移和非法迁移,必须强约束
合法迁移示例
CPU_OWNED -> READY_FOR_DEVICE
READY_FOR_DEVICE -> DEVICE_OWNED
DEVICE_OWNED -> READY_FOR_CPU
READY_FOR_CPU -> CPU_OWNED
非法迁移示例
DEVICE_OWNED -> READY_FOR_DEVICE:设备还没完成,却被二次提交
CPU_OWNED -> READY_FOR_CPU:没经过设备处理,却被当成已完成
READY_FOR_CPU -> DEVICE_OWNED:用户还没消费完,设备又重新写入
这些非法迁移本质上都在制造同一个事故:
同一时刻,多方都以为自己对同一块 buffer 有控制权。
timeout、reset、进程退出:为什么必须写进主设计
timeout
设备长时间不完成时,你必须定义:
- 当前 in-flight buffer 是继续等、重试还是作废
- 是否允许上层取消
- 是否触发硬件 reset
reset
reset 最危险的不是“重启一下设备”,而是旧世界可能阴魂不散:
- 旧 completion 可能晚到
- 旧 descriptor 状态可能残留
- 软件 ring 和硬件 ring 可能彻底失步
所以 reset 的本质不是“重新开始”,而是先把旧世界判死刑,再建立新秩序。
进程退出
如果用户态持有
mmap 区、或还有未 consume 的 slot 就退出,驱动必须决定:- 是否强制回收
- 是否延迟释放直到最后引用消失
- 是否阻止硬件继续写这片区域
工程上可直接复用的检查表
- 是否定义了明确状态,而不是散落在若干 bool、flag 和注释里?
- 是否定义了每个状态下谁能读、谁能写、谁能提交?
- 是否给每个迁移绑定了具体动作:
sync、barrier、descriptor、doorbell、completion?
- 是否明确了用户态的
submit / wait / consume协议闭环?
- 是否设计了 timeout、reset、close、remove 的清理策略?
这五条里只要有两条是糊的,后面大概率不是“调试几天就补齐”,而是给自己攒一份事故合集。
本章结论
- DMA buffer 的本质是接力棒,不是共享内存。
- ownership、协议、生命周期必须同时设计,缺一不可。
- 单 buffer 适合建模,ring buffer 才是现实世界。
- timeout、reset、进程退出不是附录,而是生命周期的一部分。
下一章要落到什么层面
下一章开始把这些抽象规则落到代码骨架上:
probe
- resource init
- DMA 能力声明
- 中断与完成路径
- 设备节点与接口占位
因为从下一步开始,问题就不再只是“模型是否正确”,而是“代码能不能把这个模型守住”。






