Lazy loaded image
Words 0Read Time 1 min
Invalid Date
🚀
核心判断
mmap 只负责把数据面映射出去,它从来不负责协作秩序。真正决定零拷贝系统是否稳定的,不是“映射成功没”,而是用户态与驱动之间有没有一套清晰、可验证、能处理异常的协议。

这一章解决什么问题

前一章我们先在内核态把 DMA 生命周期跑顺。现在把第三位参与者拉进来:用户态。
 
一旦用户态通过 mmap 直接碰到 DMA buffer,系统会立刻变复杂,因为现在你要同时治理三方:
  • 设备
  • 驱动 / CPU
  • 用户态
 
这意味着你不能再靠“默认大家会自觉”维持秩序,而必须显式定义:
  • 哪块内存给用户态看
  • 哪些动作必须走 ioctl
  • 什么时候 poll 应该返回
  • 用户态在什么时刻绝对不能再碰某个 slot

mmap 到底解决了什么

它解决的是:数据面的共享

用户态通过 mmap 能直接看到一片共享区,比如:
  • ring buffer 的 payload 区
  • 一组预分配好的 DMA slot
  • 一个只读或读写的数据窗口
 
这带来的好处很直接:
  • 少一次或多次复制
  • 大块数据传输开销更低
  • 用户态可直接消费结果

它没解决的是:控制面的协作

mmap 不会替你定义:
  • 什么时候一个 slot 可写
  • 写完后如何提交
  • 什么时候完成
  • 什么时候可回收
  • 进程退出时如何善后
 
所以一句话:
mmap 暴露了内存,但没有暴露秩序。秩序必须由协议提供。

为什么“只有 mmap 没有协议”是裸奔

设想一个没有协议的系统:
  • 用户态拿到映射后直接写 buffer
  • 驱动也在推进 ring
  • 设备正在 DMA 读写
 
这时最容易发生三类事故:
  1. 用户态改了设备正在使用的 buffer
  1. 设备已经写完,但用户态还在读旧状态
  1. 驱动已经回收 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
  • 标记消费完成
  • 放回空闲池

一个推荐的接口节奏

ioctlpoll、共享状态页该怎么分工

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 的修改权

本章结论

  1. mmap 解决共享数据面,不解决协作秩序。
  1. 零拷贝系统的核心不是映射,而是协议。
  1. 推荐把 GET_WRITE_SLOT / SUBMIT / WAIT_DONE / CONSUME 作为最小闭环。
  1. 用户态也应该有与驱动对齐的状态机,而不是只拿一个裸指针乱跑。

什么时候优先看这一章

如果你正在遇到下面这些问题,这章要比继续背 API 更值得先看:
  • 你已经把 buffer 映射给用户态,但系统还是一团乱。
  • 你不知道 ioctlpoll 和共享状态页该怎么分工。
  • 你发现问题根本不是“能不能映射”,而是“映射后谁什么时候能碰哪块内存”。
  • 你想做零拷贝,但又不想把驱动写成权限事故制造机。

配套阅读

下一章要干什么

下一章开始讨论方案选择:
  • coherent vs streaming
  • SG
  • cyclic
  • DMAEngine vs 自己直控
 
因为到这里,你已经知道系统怎么跑;接下来要解决的是:该选哪种跑法。
上一篇
Data Structure and Algorithm
下一篇
用面试拷问嵌入式技术栈

Comments
Loading...