土法炼钢 · 系统与基础设施

【SPDK 用户态存储】DMA 与 Mempool:可 DMA 内存、对齐与零拷贝约束

文章导航

分类入口
storagelinux
标签入口
#spdk#dma#mempool#hugepage#iommu#zero-copy#prp#v26.01

目录

上一篇把 I/O 提交说成「把调用方缓冲交给设备 DMA」。本篇钉这句话的前提:什么样的指针合法、对齐与 PRP 如何约束、mempool 解决什么、何时不得不拷贝。

常见误区:posix_memalign + mlock 就以为可以扔给 spdk_nvme_ns_cmd_write;或把「零拷贝」理解成「从网卡包头到盘片全程从不 memcpy」。官方 DMA From User Space 把第一点判死刑;第二点在工程上往往是分层目标,而不是单函数保证。

本文是「SPDK / 用户态存储栈」系列第 5 篇(共 16 篇)。→ 系列目录

篇目 核心内容
第 4 篇 · NVMe 驱动 提交/完成与零拷贝声明
第 5 篇 · DMA / mempool 分配、对齐、池化、拷贝边界
第 6 篇 · bdev 核心 块层如何携带缓冲
第 3 篇 · 环境与绑盘 大页与 VFIO 原料

版本锚定:SPDK v26.01 Direct Memory Access (DMA) From User Space;env/DPDK 内存与 spdk_dma_malloc 族 / mempool API @ tag v26.01。不粘贴伪造分配失败栈或 fio 零拷贝对比。


一、问题:设备看见的地址不是 void *

用户态指针是虚拟地址。NVMe 设备(无 IOMMU 时)在 PCI 上发起的 DMA 使用物理地址;有 IOMMU 时使用经翻译的总线地址 / IOVA。无论哪条路,运行时都必须能在提交命令时填入设备能懂的地址,并保证传输期间映射不变。

操作系统不保证普通匿名页的虚拟→物理映射永恒不变:换页、compaction、透明压缩、热加内存重平衡都可能移动页框。内核驱动用内核 DMA API 处理钉页与映射;用户态驱动必须走另一套契约。

POSIX mlock 只保证「有物理页backing、不被换出」,保证页框号不变,也不是可移植的 pin API。因此「先 malloc 再 mlock」不是 SPDK 认可的数据面分配路径。


二、官方结论:用 spdk_dma_malloc()

DMA From User Space 的收束句:

所有传给 SPDK 的数据缓冲,都必须用 spdk_dma_malloc() 或其兄弟分配;SPDK 依赖 DPDK 已验证的底座做内存管理。(与 DPDK 合用时,DPDK mbuf 也是安全的。)

含义分解:

要求 原因
经 SPDK/DPDK 分配器 登记到可翻译的内存映射表;已知物理/IOVA
钉住 / 大页 降低映射被内核挪走的风险
可与 IOMMU 配合 VFIO 下用进程映射做设备侧翻译(长期正确路径)

setup.sh 预留的 hugepage 是原料;业务缓冲仍要显式从 DMA 分配器取出。把大页耗尽与「偶发 I/O 失败」联系起来时,先查池与分配,而不是先怪盘。

flowchart LR
  HP["Hugepages / VFIO IOVA"]
  DMA["spdk_dma_malloc family"]
  POOL["spdk_mempool"]
  IO["NVMe PRP/SGL + DMA"]
  HP --> DMA
  DMA --> POOL
  DMA --> IO
  POOL --> IO
  BAD["malloc / new"] -.->|"not valid"| IO

三、PRP:规范如何约束物理布局

在 IOMMU 普及前(以及设备仍报 PRP 时),NVMe 1.0 要求内存能被 PRP list 描述。官方文档概括的性质包括:

NVMe 1.1 起有更灵活的 SGL,但是可选;文档写明写作时多数设备仍弱于「任意 scatter」。工程后果:

  1. 随意拼起来的多段缓冲可能根本无法生成合法 PRP。
  2. 对齐失败的故障可能出现在提交期(命令构造失败)或表现为设备级错误——取决于路径,但根因常在缓冲布局。
  3. 即使用 IOMMU,「乱切的用户态 iovec」仍可能在驱动/传输层被拒绝或被迫拷贝——不要假设 IOMMU 赦免所有布局罪

数学上,一次传输触及的物理页集合必须可被 PRP/SGL 规则枚举;分配器通过大页与对齐的返回块,把「偶然合法」变成「默认合法」。


四、IOMMU:长期地基,而不是可选美化

同一文档把 IOMMU 写成未来可证明正确的方向:设备看到的是总线地址,经 IOMMU 落到进程映射,内核可移动底层物理页而不拆掉进行中的 DMA(在映射仍有效的前提下)。Linux vfio-pci 是用户态配置入口。

与第 3 篇对齐:

排障口诀:分配器报错先看大页与 EAL;能分配但 I/O 怪错再看对齐/IOMMU 组/设备是否仍在 VFIO。


五、Mempool:复用、局部性与生命周期

热路径上每次 spdk_dma_malloc / free 都能工作,但对小块高 IOPS 不友好:分配器锁/跨核、缓存冷、失败路径复杂。SPDK(经 DPDK)提供 mempool:预分配一堆同尺寸 DMA 安全对象,运行时 get/put

典型用法(语义级,符号以 v26.01 头文件为准):

/* Illustrative shape only — verify prototypes against include/ @ v26.01 */
void *buf = spdk_dma_malloc(4096, 0x1000, NULL);
if (buf == NULL) {
    /* handle allocation failure — do not fall back to malloc for DMA I/O */
}
/* ... submit NVMe I/O using buf ... */
/* ... after completion callback ... */
spdk_dma_free(buf);

池化时把 malloc/free 换成 get/put,不变量不变:飞行中的 I/O 不得回收缓冲。过早 put 是经典 use-after-free/DMA 踩踏;过晚 put 则表现为池耗尽、提交失败——表面像「盘队列满」。

与 bdev 的衔接(第 6 篇预告):块层请求携带的 iov 必须已经是 DMA 安全,或由模块在边界上显式拷到安全缓冲。拷贝发生在哪一层,决定延迟账记在谁头上。


六、零拷贝 vs 显式拷贝:一张边界表

场景 更可能零拷贝 更可能要拷贝
应用缓冲本身来自 spdk_dma_* / mempool,并满足 PRP
缓冲来自 malloc、语言运行时堆、部分 mmap 是(先搬到 DMA 缓冲)
前端是 socket 读入的普通缓冲,后端是 SPDK NVMe 仅当缓冲分配策略从一开始就按 DMA 设计,或走特殊跨栈机制 常见路径:拷一次
Admin 命令某些 API 视 API 文档已提示可能有拷贝
为了对齐/合并碎片 iov 驱动或上层可能 bounce

所以:驱动文档的 zero-copy = 「驱动不藏载荷 bounce」;系统是否端到端零拷贝,取决于你有没有在边界上引入拷贝。站内 Zero Copy 的肮脏真相 讨论的是 socket/文件路径的类似账本——本系列把同一怀疑态度用在块/NVMe 上。

不写未实测的「拷贝掉百分之多少延迟」;只要求把拷贝标成显式层,以便第 13–14 篇归因。


七、失败模式与观测直觉

症状(语义) 优先怀疑
初始化或首次 I/O 分配失败 大页不足、HUGEMEM、cgroup hugetlb、池尺寸
提交返回错误 / 无法构造命令 对齐、长度跨页非法、非法指针来源
随机数据损坏 / 机器 check 飞行中回收缓冲、错误 IOVA、设备被错误解绑(灾难级)
池「慢慢漏光」 完成路径未 put、错误路径泄漏、回调未注册
换核后变慢 跨 NUMA 取缓冲;池 socket 与 reactor 不一致

无本机直通 NVMe 时,不拿伪造日志证明上述条目;它们来自机制推演与官方约束,供实机时对照。


八、设计谱系与开放问题

谱系:内核 DMA API → 用户态 hugepage 实用主义 → IOMMU/VFIO 正规化。DPDK 先在网卡侧把这条路走通;SPDK 把同一内存故事接到 NVMe PRP/SGL。

争论与开放问题:

  1. 大页依赖 vs IOMMU 纯净模型:今日部署常两者叠用;内核演进若改变大页可迁移性,IOMMU 路径的权重会上升(官方已预警)。
  2. 池尺寸与内存税:预留过多大页损害共置密度;过少则在突发时分配失败——没有无负载的通用公式。
  3. 跨组件零拷贝:NVMe-oF、vhost、压缩/加密叠层时,每一跳都可能逼出拷贝;「全链路 ZC」是产品叙事还是可检验不变量,要在第 8–11 篇逐跳核对。
  4. O_DIRECT 缓冲:内核 Direct I/O 也有对齐约束,但检查与 bounce 策略在内核;SPDK 把检查与失败更多暴露给应用——灵活与锋利并存。

九、把约束写成「分配策略」而不是「每次祈祷」

可维护的用户态存储进程通常把内存策略收成三条厂规:

  1. 数据面只许一种分配入口spdk_dma_* / 指定 mempool);禁止业务模块私自 malloc 后「碰巧对齐」。
  2. 按 NUMA / reactor 建池:哪个核提交,就优先用哪个 socket 的池,避免跨节点拉缓冲。
  3. 错误路径也要还池:提交失败、超时回调、热拔错误完成——每条路径审查是否 put/free,用计数器盯泄漏。

与编程语言运行时交互时(Go/Java/Rust 自管堆),边界更刺眼:要么在 FFI 侧预先准备 DMA 池并由原生层持有,要么接受显式拷贝层。试图把托管堆对象「固定后直接 DMA」需要极强的运行时支持与审核,默认应视为不安全幻想,除非有专门机制与测试。

相对内核 O_DIRECT:应用同样要对齐,但内核可以在部分失败路径上 bounce 或返回清晰 errno;SPDK 把更多布局责任前移——这是性能来源之一,也是新人脚枪来源之一。教学与 code review 应把「缓冲出身」当成一等注释,而不是假设读者记得第 5 篇。

若必须对接「外来缓冲」(例如从内核 aio bdev、socket、或托管运行时划入的内存),把 显式拷贝模块画进架构图:一端 memcpy/spdk_dma 填充,一端只认池内指针。隐藏在中间某层的静默 bounce 会让延迟账对不上,也会让「我们已经零拷贝」的表述失去可检验性。能证明的零拷贝,才配写进设计文档。


十、参考资料

规范 / 官方文档(A)

源码(A)

站内对照

实验台账


十一、小结

  1. DMA 安全分配是硬前提malloc/mlock 不能替代 spdk_dma_malloc 族。
  2. PRP/SGL 约束物理布局;对齐与分段错误是一类独立失败模式。
  3. IOMMU+VFIO 是地址翻译的长期正确故事;大页是今日部署的重要支柱。
  4. Mempool 服务热路径复用,但生命周期必须覆盖 DMA 飞行区间。
  5. 零拷贝是「驱动不藏 bounce」;端到端是否拷贝要按边界逐层记账。
  6. 厂规化分配入口与还池路径,比在出故障后争论「是不是零拷贝」更有用。

上一篇:用户态 NVMe 驱动 · 系列目录 · 下一篇:bdev 核心

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。


By .