Lazy loaded image
Words 0Read Time 1 min
Invalid Date
🔄
核心判断
DMA 真正难的,从来不是“分到一块 buffer”或“把地址交给硬件”,而是设计一套 ownership、交接协议、生命周期 都能自洽的系统。三者缺一,系统迟早会在偶发错帧、重复提交、早回收、异常路径失控里翻车。

这一章到底在解决什么

前面几章已经把边界和地基铺好了,这一章开始进入 DMA 设计的中轴。
 
如果只记一个结论,请记这个:
DMA buffer 不是“谁都能碰的共享内存”,而是一根在 CPU、设备、用户态之间不断交接的接力棒。
 
而一根接力棒要想不掉地上,必须同时回答三件事:
  1. Ownership:现在到底归谁控制
  1. 协议:怎么合法交接,谁来确认
  1. 生命周期:从申请、提交、完成到回收,整条链如何闭环
🧭
一句话翻译
你不是在“共享一块内存”,你是在设计一套 状态机 + 交接动作 + 异常回收 的协作系统。

为什么这三件事必须一起设计

只有 ownership,没有协议

你知道“当前归谁”,但不知道怎么安全交出去。
 
典型结果:
  • 状态写在文档里
  • 接口没有对应交接动作
  • 用户态、驱动、设备之间只能靠猜测协作

只有协议,没有 ownership

你设计了一堆 SUBMITWAITCONSUME,但没有明确每个状态下谁能读、谁能写。
 
结果就是:
  • 接口存在
  • 边界模糊
  • 非法迁移没人拦
  • 同一块 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_OWNEDREADY_FOR_DEVICEDEVICE_OWNED
什么时候可读结果
WAIT / PEEK
等待或查看已完成 slot
DEVICE_OWNEDREADY_FOR_CPU
读完后怎么回收
CONSUME / ACK
声明“我读完了,可以复用”
READY_FOR_CPUCPU_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 的清理策略?
 
这五条里只要有两条是糊的,后面大概率不是“调试几天就补齐”,而是给自己攒一份事故合集。

本章结论

  1. DMA buffer 的本质是接力棒,不是共享内存。
  1. ownership、协议、生命周期必须同时设计,缺一不可。
  1. 单 buffer 适合建模,ring buffer 才是现实世界。
  1. timeout、reset、进程退出不是附录,而是生命周期的一部分。

下一章要落到什么层面

下一章开始把这些抽象规则落到代码骨架上:
  • probe
  • resource init
  • DMA 能力声明
  • 中断与完成路径
  • 设备节点与接口占位
 
因为从下一步开始,问题就不再只是“模型是否正确”,而是“代码能不能把这个模型守住”。
上一篇
Data Structure and Algorithm
下一篇
用面试拷问嵌入式技术栈

Comments
Loading...