核心判断
mmap 只负责把数据面映射出去,它从来不负责协作秩序。真正决定零拷贝系统是否稳定的,不是“映射成功没”,而是用户态与驱动之间有没有一套清晰、可验证、能处理异常的协议。这一章解决什么问题
前一章我们先在内核态把 DMA 生命周期跑顺。现在把第三位参与者拉进来:用户态。
一旦用户态通过
mmap 直接碰到 DMA buffer,系统会立刻变复杂,因为现在你要同时治理三方:- 设备
- 驱动 / CPU
- 用户态
这意味着你不能再靠“默认大家会自觉”维持秩序,而必须显式定义:
- 哪块内存给用户态看
- 哪些动作必须走
ioctl
- 什么时候
poll应该返回
- 用户态在什么时刻绝对不能再碰某个 slot
mmap 到底解决了什么
它解决的是:数据面的共享
用户态通过
mmap 能直接看到一片共享区,比如:- ring buffer 的 payload 区
- 一组预分配好的 DMA slot
- 一个只读或读写的数据窗口
这带来的好处很直接:
- 少一次或多次复制
- 大块数据传输开销更低
- 用户态可直接消费结果
它没解决的是:控制面的协作
mmap 不会替你定义:- 什么时候一个 slot 可写
- 写完后如何提交
- 什么时候完成
- 什么时候可回收
- 进程退出时如何善后
所以一句话:
mmap暴露了内存,但没有暴露秩序。秩序必须由协议提供。
为什么“只有 mmap 没有协议”是裸奔
设想一个没有协议的系统:
- 用户态拿到映射后直接写 buffer
- 驱动也在推进 ring
- 设备正在 DMA 读写
这时最容易发生三类事故:
- 用户态改了设备正在使用的 buffer
- 设备已经写完,但用户态还在读旧状态
- 驱动已经回收 slot,用户态还以为这块数据归自己
这不是零拷贝,这是把混乱直接搬进用户空间。
正确做法:数据面和控制面分离
数据面应该承载什么
- payload 数据本体
- 只读或按 slot 划分的缓冲区
控制面应该承载什么
GET_WRITE_SLOT
SUBMIT
WAIT_DONE
CONSUME
RESET/CANCEL/QUERY
分开之后,系统会更清晰:
- 数据由
mmap走
- 语义由协议走
一套最小协议长什么样
1. GET_WRITE_SLOT
语义:给用户态一个当前可以操作的 slot。
驱动保证:
- 这个 slot 当前不归设备
- 状态合法
- 用户态拿到后知道自己现在可写
2. SUBMIT
语义:用户态声明“我写完了,可以交给设备”。
驱动负责:
- 校验 slot 当前状态
- 做必要 sync
- 写 descriptor
- doorbell 设备
- 状态转为
DEVICE_OWNED
3. WAIT_DONE / PEEK_DONE
语义:等待设备完成,或查询哪些 slot 已完成。
驱动负责:
- 在完成到来后把 slot 放入可消费队列
- 支持阻塞等待、超时、非阻塞查询等模式
4. CONSUME / ACK
语义:用户态声明“我读完了,可以回收”。
驱动负责:
- 校验 slot 当前在
READY_FOR_CPU
- 标记消费完成
- 放回空闲池
一个推荐的接口节奏
ioctl、poll、共享状态页该怎么分工
ioctl
适合承载:
- 显式命令
- 状态迁移动作
- 带参数的控制请求
比如:
GET_WRITE_SLOT
SUBMIT
CONSUME
RESET
poll
适合承载:
- “有没有完成结果”
- “现在是否可读 / 可消费”
- 事件驱动唤醒
poll 最适合做通知,不适合承载复杂语义。共享状态页
只适合承载:
- 少量只读状态
- ring 只读索引快照
- 统计信息
不建议把完整控制协议塞进共享状态页。性能是上去了,正确性门槛也会一起上去。没必要为了秀肌肉,把驱动写成宗教仪式。
把协议和生命周期连起来看
前面讲的是接口动作;下面补一层更底的运行秩序:slot 在一次 Streaming DMA 往返里到底经历什么状态变化。
真正容易翻车的,不是 API 名字背错了,而是你在错误的时刻做了错误的事:
- 设备还没接手,你就以为已经提交完成
- completion 已经到了,但你还没
sync_for_cpu()就开始读
- 用户刚读完结果,还没 ack,就想复用同一块 buffer
一张最小生命周期图
两次关键同步
同步动作 | 典型触发点 | 解决的问题 |
dma_sync_single_for_device() | submit 前 | 防止设备读到 CPU cache 里的旧内容 |
dma_sync_single_for_cpu() | 完成后、读取前 | 防止 CPU / 用户读到设备写入前的旧 cache |
一句话记忆
别把“写数据”和“宣布数据有效”混成一个模糊动作;也别把“设备完成”误解成“现在谁都能随便复用 buffer”。
用户态状态机也要和驱动对齐
用户态不能只有“我拿到了个指针,所以我现在可以随便动”。
建议用户态也显式维护一套轻量状态:
用户态视角状态 | 含义 |
WRITABLE | 当前 slot 可由用户写或准备 |
SUBMITTED | 已交给驱动/设备,用户不得再碰 |
DONE | 设备完成,用户可读 |
CONSUMED | 用户已确认用完,可回收 |
这样驱动和用户态才能围绕同一套语义协作,而不是各自脑补。
异常路径一定要先写
进程退出
必须定义:
- 用户态持有的 slot 如何回收
mmap区是否在最后一个引用释放后统一清理
- 设备是否继续运行,还是要强制停机
超时
必须定义:
WAIT_DONE超时后用户态能否继续等
- 这次提交是否作废
- 是否允许 reset 后重新同步
reset
必须定义:
- reset 后所有旧 slot 的状态是否全部失效
- 用户态是否需要重新获取 slot
- 旧 completion 是否全部丢弃
如果这些不提前写,后面用户态一遇到异常就会和驱动进入“你以为 / 我以为”的双向误解模式。
一个不建议的设计
错误范式:把控制语义偷偷塞进数据区
比如:
- 用户态写某个 magic 值表示 submit
- 驱动读 buffer 头几个字节判断状态
- done 位、错误码、owner 位都混在 payload 头部
这类设计早期看起来省事,后期调试时像在垃圾堆里找地雷。
正确姿势应该是:
- payload 是 payload
- 协议是协议
- 状态机是状态机
一组最实用的接口设计原则
- 数据面只传数据,不偷渡协议语义
- 控制面只表达动作和状态,不承载大块 payload
- 所有状态迁移都要可验证
poll负责通知,ioctl负责语义
- 用户态一旦
SUBMIT,就视为放弃对该 slot 的修改权
本章结论
mmap解决共享数据面,不解决协作秩序。
- 零拷贝系统的核心不是映射,而是协议。
- 推荐把
GET_WRITE_SLOT / SUBMIT / WAIT_DONE / CONSUME作为最小闭环。
- 用户态也应该有与驱动对齐的状态机,而不是只拿一个裸指针乱跑。
什么时候优先看这一章
如果你正在遇到下面这些问题,这章要比继续背 API 更值得先看:
- 你已经把 buffer 映射给用户态,但系统还是一团乱。
- 你不知道
ioctl、poll和共享状态页该怎么分工。
- 你发现问题根本不是“能不能映射”,而是“映射后谁什么时候能碰哪块内存”。
- 你想做零拷贝,但又不想把驱动写成权限事故制造机。
配套阅读
- 06|内核态 DMA 收发实战:先跑通一条闭环:先把两方闭环跑顺,再拉用户态入场。
- 08|Coherent、Streaming、SG、Cyclic:方案怎么选:协议立住后,再决定底层数据面该选哪种形态。
- 09|常见坑、异常路径与调试验证:别把 DMA 写成玄学:一旦三方协作出问题,优先去那里定位边界失守点。
- 12|API、模板与快查附录:把最小协议和检查表拿来直接落地。
下一章要干什么
下一章开始讨论方案选择:
- coherent vs streaming
- SG
- cyclic
- DMAEngine vs 自己直控
因为到这里,你已经知道系统怎么跑;接下来要解决的是:该选哪种跑法。






