10 · 特性专题:FSDAX(文件系统直接访问持久内存)
对应配置:
CONFIG_FS_DAX(内核通用)+ EROFS 的dax=always挂载选项
相关源码:data.c(I/O 与 mmap)、inode.c(S_DAX标志设置)本文回答:为什么只读文件系统也需要 DAX → EROFS 的 DAX 与常规路径差在哪 → 用到哪些结构 → 关键函数做什么 → 一次 DAX 读/映射的来龙去脉。
本专题目标(读完你应该能做到什么)
- 说清 DAX 想解决什么问题,以及为什么 EROFS 这种只读文件系统特别适合 DAX
- 解释
S_DAX标志在 EROFS 里什么时候会被设置(条件很具体) - 说清 DAX 路径与常规路径在
erofs_iomap_begin()里的分叉点 - 解释为什么 DAX 优先于 DIO(读路径里的判断顺序)
- 解释为什么 EROFS 的 DAX 不支持可写共享映射
- 说清
dax_part_off是什么、为什么要额外加一次
一、特性缘由:为什么需要 DAX
1.1 常规 I/O 的”多余”路径
常规读一个文件:
进程 read()
→ 系统调用
→ 文件系统算出设备位置
→ 块设备层发 I/O
→ 数据 DMA 到内存
→ 拷进 page cache
→ 再从 page cache 拷到用户缓冲区
注意两次拷贝,而且数据要在内存里存两份(page cache 一份 + 用户一份)。
1.2 持久内存(PMEM)改变了前提
持久内存(如 Intel Optane PMEM)可以像内存一样按字节寻址, 直接挂在内存总线上。
既然如此,”把数据读到内存”这一步就没必要了——数据本来就在能按字节访问的地址空间里。
⇒ DAX(Direct Access):绕过 page cache 与块设备层, 把持久内存直接映射到用户进程的地址空间。
进程 mmap 后访问
→ 缺页异常
→ 文件系统算出 PMEM 上的物理地址
→ 直接建立页表映射
→ 之后访问就是纯内存访问,无 I/O、无拷贝
1.3 为什么 EROFS 特别适合 DAX
EROFS 是只读的,这恰好避开了 DAX 最麻烦的问题:
| DAX 的难点 | EROFS 为什么不受影响 |
|---|---|
| 写时元数据/journal 一致性 | 没有写,不需要 |
| 块分配、空间管理 | 只读,不分配 |
| DAX 与 page cache 的一致性 | 只读数据不会变,天然一致 |
而且 EROFS 的典型场景(容器镜像、只读 rootfs)数据量巨大、读多写少, 省掉一次拷贝与整条块设备路径收益明显。
⇒ 可以说:DAX 在可写文件系统上是难题,在 EROFS 上几乎是”白捡”的优化。
1.4 术语
| 术语 | 含义 |
|---|---|
| DAX | Direct Access,直接访问持久内存 |
| FSDAX | 文件系统层面的 DAX(区别于 device DAX) |
S_DAX |
inode 上的一个标志位,表示”这个 inode 走 DAX” |
| PMEM | 持久内存硬件 |
二、设计理念
理念 1:复用 iomap,只换”最终地址”
EROFS 的读路径已经统一到 iomap 框架。DAX 的做法是:
- 地址计算完全复用:
erofs_map_blocks()→erofs_map_dev()照走 - 只在最后填
iomap时换一个字段:- 常规:
iomap->bdev = ...(块设备) - DAX:
iomap->dax_dev = ...(DAX 设备)
- 常规:
⇒ 上层(dax_iomap_rw / dax_iomap_fault)拿到 iomap 后自己知道该怎么做。
理念 2:DAX 优先于 DIO
在 erofs_file_read_iter()(data.c)里,判断顺序是:
if (IS_ENABLED(CONFIG_FS_DAX) && IS_DAX(inode))
return dax_iomap_rw(iocb, to, &erofs_iomap_ops); /* ① DAX 最先 */
if ((iocb->ki_flags & IOCB_DIRECT) && inode->i_sb->s_bdev) {
... /* ② 其次 DIO */
}
return filemap_read(iocb, to, 0); /* ③ 最后缓冲读 */
为什么 DAX 要在最前面?
因为 DAX 比 DIO 更”直接”:DIO 还要走块设备层发命令,
DAX 连块设备都不用——直接算地址建映射。
既然 inode 已经是 DAX 的,就没必要再考虑 DIO 了。
理念 3:mmap 才是 DAX 的主战场
DAX 最大的价值在 mmap:映射建立后,后续访问完全没有内核参与。
所以 EROFS 为 DAX 专门准备了一套 vm_operations_struct
(而不是 address_space_operations)——这解释了前面的澄清:
DAX 走的是 vm_ops,不是 aops。
理念 4:只读 → 拒绝可写共享映射
erofs_file_mmap_prepare() 里:
if (vma_desc_test_all(desc, VMA_SHARED_BIT, VMA_MAYWRITE_BIT))
return -EINVAL;
共享 + 可写的映射直接拒绝。原因很直接:
EROFS 只读,允许可写共享映射会让用户以为”能写通”,实际写到哪都不对。
三、实现架构
3.1 两条路径对照
erofs_file_read_iter()
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
IS_DAX(inode)? IOCB_DIRECT? 其余
│ │ │
dax_iomap_rw() iomap_dio_rw() filemap_read()
│ │ │
└────────┬────────┘ │
▼ ▼
erofs_iomap_begin() 走 page cache
│
┌────────┴────────┐
▼ ▼
flags & IOMAP_DAX? 否则
│ │
iomap->dax_dev iomap->bdev
+ dax_part_off
3.2 mmap 路径
进程 mmap()
→ erofs_file_mmap_prepare()
├ 非 DAX → generic_file_readonly_mmap_prepare()
└ DAX → 检查可写共享(有则 -EINVAL)
desc->vm_ops = &erofs_dax_vm_ops
设置 VMA_HUGEPAGE_BIT(支持大页)
→ 首次访问 → 缺页异常
→ erofs_dax_fault() / erofs_dax_huge_fault()
└ dax_iomap_fault(vmf, order, NULL, NULL, &erofs_iomap_ops)
└ 内部调 erofs_iomap_begin() 拿 dax_dev + addr
└ 直接建立页表映射
3.3 S_DAX 什么时候被设置
在 erofs_read_inode()(inode.c)末尾:
inode->i_flags &= ~S_DAX;
if (test_opt(&sbi->opt, DAX_ALWAYS) && S_ISREG(inode->i_mode) &&
(vi->datalayout == EROFS_INODE_FLAT_PLAIN ||
vi->datalayout == EROFS_INODE_CHUNK_BASED))
inode->i_flags |= S_DAX;
三个条件必须同时满足(第三个是”二选一”):
| # | 条件 | 含义 |
|---|---|---|
| ① | test_opt(..., DAX_ALWAYS) |
挂载时指定 dax=always(不是默认的 dax=never) |
| ② | S_ISREG(inode->i_mode) |
只能是常规文件(目录、符号链接等不行) |
| ③ | datalayout == FLAT_PLAIN 或 datalayout == CHUNK_BASED |
非压缩平面布局 或 分块布局(二者满足其一即可) |
⚠️ 注意断句:③ 里的两个 datalayout 是 OR 关系,不是”两个都要满足”。
代码里它整体是 && 链上的一个子表达式:
test_opt(DAX_ALWAYS) && S_ISREG(...) && (FLAT_PLAIN || CHUNK_BASED)
^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
条件① 条件② 条件③(内部是 OR)
⇒ 所以是 3 个 AND 条件,不是 4 个。
⇒ 压缩文件不能走 DAX。原因很直接:
数据在磁盘上是压缩的,必须经过解压才能得到内容,而 DAX 是”直接把设备地址映射到用户空间”——中间没有解压的机会。
四、关键结构体
4.1 struct iomap(内核通用,不是 EROFS 的)
DAX 与非 DAX 的分叉就体现在它的字段上:
| 字段 | 常规路径 | DAX 路径 |
|---|---|---|
iomap->bdev |
设置为设备的 block_device |
不设置 |
iomap->dax_dev |
不设置 | 设置为 m_dif->dax_dev |
iomap->addr |
fsoff + m_pa |
fsoff + m_pa + dax_part_off |
iomap->type |
IOMAP_MAPPED / IOMAP_INLINE / IOMAP_HOLE 等 |
同左 |
关键就一句:DAX 时把 dax_dev 填上、地址再额外加一次 dax_part_off。
4.2 struct dax_device
内核对”支持 DAX 的设备”的抽象。EROFS 不自己创建它,
而是从 erofs_device_info 里拿:
iomap->dax_dev = mdev.m_dif->dax_dev;
对应 struct erofs_device_info(见 08 专题)里的 dax_dev 字段。
4.3 struct erofs_map_dev(internal.h)
映射后的具体设备信息,DAX 用到其中两项:
| 字段 | 用途 |
|---|---|
m_dif->dax_dev |
DAX 设备(若该设备支持) |
m_dif->dax_part_off |
分区内偏移——见下 |
m_dif->fsoff |
文件系统在本设备内的起始偏移 |
m_bdev |
常规块设备(DAX 时不用) |
4.4 erofs_dax_vm_ops(data.c)
static const struct vm_operations_struct erofs_dax_vm_ops = {
.fault = erofs_dax_fault,
.huge_fault = erofs_dax_huge_fault,
};
| 成员 | 作用 |
|---|---|
.fault |
普通缺页 → dax_iomap_fault(vmf, 0, ...) |
.huge_fault |
大页缺页 → dax_iomap_fault(vmf, order, ...) |
有 huge_fault 意味着 DAX 映射支持大页(PMD 级)——
这是 DAX 的重要优势:一次映射 2MB,页表项更少、TLB 压力更小。
五、主要函数
5.1 erofs_iomap_begin()(data.c)—— 分叉点
if (flags & IOMAP_DAX)
iomap->dax_dev = mdev.m_dif->dax_dev;
else
iomap->bdev = mdev.m_bdev;
iomap->addr = mdev.m_dif->fsoff + mdev.m_pa;
if (flags & IOMAP_DAX)
iomap->addr += mdev.m_dif->dax_part_off;
这是整个 DAX 支持的核心几行。
为什么要额外加 dax_part_off?
fsoff 是”文件系统在本设备内的起始偏移”。
但 DAX 设备可能有分区概念——dax_part_off 是该分区在 DAX 设备内的偏移。
整个 DAX 设备
├── 分区 A(偏移 dax_part_off)
│ └── 文件系统起点 fsoff
│ └── 文件数据 m_pa
所以要得到”数据在 DAX 设备内的绝对位置”,得把两者都加上。
5.2 erofs_file_read_iter()(data.c)—— 入口分派
if (!iov_iter_count(to))
return 0;
if (IS_ENABLED(CONFIG_FS_DAX) && IS_DAX(inode))
return dax_iomap_rw(iocb, to, &erofs_iomap_ops);
...
IS_ENABLED(CONFIG_FS_DAX) 是编译期判断(没编 DAX 就整段消失),
IS_DAX(inode) 是运行期判断(看这个 inode 是否带 S_DAX)。
5.3 erofs_file_mmap_prepare()(data.c)—— mmap 准备
if (!IS_DAX(file_inode(desc->file)))
return generic_file_readonly_mmap_prepare(desc);
if (vma_desc_test_all(desc, VMA_SHARED_BIT, VMA_MAYWRITE_BIT))
return -EINVAL;
desc->vm_ops = &erofs_dax_vm_ops;
vma_desc_set_flags(desc, VMA_HUGEPAGE_BIT);
三步:
- 非 DAX → 走常规只读 mmap
- DAX 但可写共享 → 拒绝(
-EINVAL) - 否则 → 挂上
erofs_dax_vm_ops并开启大页
5.4 erofs_dax_fault() / erofs_dax_huge_fault()(data.c)
static vm_fault_t erofs_dax_huge_fault(struct vm_fault *vmf, unsigned int order)
{
return dax_iomap_fault(vmf, order, NULL, NULL, &erofs_iomap_ops);
}
static vm_fault_t erofs_dax_fault(struct vm_fault *vmf)
{
return erofs_dax_huge_fault(vmf, 0); /* order = 0,即单页 */
}
两者都转交给内核通用的 dax_iomap_fault(),
后者内部再回调 erofs_iomap_begin() 拿地址。
⇒ EROFS 在这里只提供”地址怎么算”,建页表等脏活由内核 DAX 层做。
5.5 erofs_read_inode() 中的 S_DAX 设置(inode.c)
见 3.3 节。四个条件同时满足才设置 S_DAX。
六、来龙去脉:一次 DAX 访问的完整过程
6.1 read() 路径
① 挂载时指定 dax=always
│
② erofs_read_inode() 检查四条件 → 设置 S_DAX
│
③ 进程 read()
│
④ erofs_file_read_iter()
├ IS_DAX(inode) 为真
└ dax_iomap_rw(iocb, to, &erofs_iomap_ops)
│
⑤ 内核 DAX 层回调 erofs_iomap_begin()
├ erofs_map_blocks() → m_pa
├ erofs_map_dev() → m_dif(含 dax_dev / dax_part_off)
├ 因 flags 带 IOMAP_DAX:
│ iomap->dax_dev = m_dif->dax_dev
│ iomap->addr = fsoff + m_pa + dax_part_off
└ 返回
│
⑥ 内核 DAX 层按 dax_dev + addr 直接从持久内存拷贝/访问
│
⑦ 数据直达用户缓冲区 —— 无 page cache、无块设备 I/O
6.2 mmap() 路径
① mmap() → erofs_file_mmap_prepare()
├ 检查可写共享 → 若有则 -EINVAL
├ desc->vm_ops = &erofs_dax_vm_ops
└ 开启 VMA_HUGEPAGE_BIT
│
② 首次访问这段地址 → 缺页异常
│
③ erofs_dax_fault()(或 huge_fault)
└ dax_iomap_fault(...)
└ 回调 erofs_iomap_begin() 拿 dax_dev + addr
└ 建立页表映射(可能一次映射 2MB 大页)
│
④ 之后每次访问 = 纯内存访问,内核完全不参与
第 ④ 步是 DAX 的全部意义所在:映射建立后, 访问持久内存就像访问普通内存一样快,且不再有系统调用开销。
七、动手验证
验证 1:确认内核是否编译了 FS_DAX
grep CONFIG_FS_DAX /sdd/linux/linux-stable/.config
若为 =n,则所有 DAX 代码在编译期就被裁掉了
(IS_ENABLED(CONFIG_FS_DAX) 为假)。
验证 2:看挂载选项怎么解析
cd /sdd/linux/linux-stable/fs/erofs
grep -rn "DAX_ALWAYS\|dax" super.c | head
找到 dax=always 被解析成 EROFS_MOUNT_DAX_ALWAYS 的地方,
确认挂载选项名与枚举的对应。
验证 3:确认压缩文件被排除
对照 inode.c 里 S_DAX 的设置条件,确认
datalayout 只能是 FLAT_PLAIN 或 CHUNK_BASED。
用 dump.erofs 看一个压缩文件的 Layout,验证它不会被设 S_DAX:
/opt/erofs-utils/bin/dump.erofs --path=/rep.txt /host/comp2.erofs
# Layout 为压缩 → 即便 dax=always 也不走 DAX
验证 4:VM 内实际挂载(需支持 DAX 的设备)
mount -t erofs -o dax=always /dev/pmem0 /mnt # 需要真实 PMEM 设备
cat /proc/mounts | grep mnt
⚠️ QEMU 里没有真实的 PMEM 设备,此步通常无法实测。 可以用
ndctl/memmap模拟,但超出本材料范围。
八、常见误解(重要)
误解 1:DAX 就是 DIO(直接 I/O)
不是。两者都绕过 page cache,但:
| DIO | DAX | |
|---|---|---|
| 是否走块设备层 | 是(要发 I/O 命令) | 否 |
| 数据落点 | 用户缓冲区(需 DMA) | 直接映射持久内存 |
| mmap 后是否还需内核参与 | 需要 | 不需要 |
代码里也能看出:DAX 的判断在 DIO 之前,是不同层次的东西。
误解 2:设了 dax=always 所有文件都走 DAX
不对。还有三个限制:常规文件、非压缩(FLAT_PLAIN / CHUNK_BASED)。 目录、符号链接、压缩文件都不会走 DAX。
误解 3:EROFS 有 erofs_fsdax_aops
没有。EROFS 只有 4 套 aops(erofs_aops、erofs_fileio_aops、
z_erofs_aops、z_erofs_cache_aops),
DAX 不走 aops,走的是 vm_operations_struct(erofs_dax_vm_ops)。
(笔者第一次写文档时凭记忆写了个不存在的 erofs_fsdax_aops,
核对源码后已纠正——这也说明”凭记忆写内核文档”有多危险。)
误解 4:DAX 映射可以写
不行,至少共享可写映射会被直接拒绝(-EINVAL)。
EROFS 是只读文件系统,DAX 映射自然也是只读的。
误解 5:dax_part_off 多余
不多余。它补上了”分区在 DAX 设备内的偏移”,
与 fsoff(文件系统在分区内的偏移)是两个不同层次的偏移,
要相加才能得到 DAX 设备内的绝对位置。
九、与其他特性的关系
| 特性 | 关系 |
|---|---|
| 文件后端 fileio(09 专题) | 都是”绕开常规块设备路径”,但方向不同:fileio 是”读文件”,DAX 是”直接映射内存” |
| ishare / page cache sharing(11 专题) | DAX 不经过 page cache,所以与 ishare 的”共享页缓存”思路正交 |
| 压缩 | 互斥——压缩文件无法走 DAX(S_DAX 设置条件明确排除) |
| 多设备 | DAX 也走 erofs_map_dev(),所以多设备逻辑照常,只是取的是 dax_dev 而非 bdev |
| 48-bit 地址 | 大容量 PMEM 可能触及地址位宽问题,两者有交集 |
自测检查点
- DAX 想解决什么问题?为什么 EROFS 特别适合它?
S_DAX在 EROFS 里需要哪四个条件才设置?- 为什么压缩文件不能走 DAX?
erofs_iomap_begin()里,DAX 与非 DAX 填的字段有什么不同?dax_part_off是什么?为什么要额外加一次?- 读路径里 DAX、DIO、缓冲读的判断顺序是什么?为什么 DAX 在最前?
- EROFS 有
erofs_fsdax_aops吗?DAX 走的是什么结构? erofs_dax_vm_ops有哪两个成员?huge_fault意味着什么?- 为什么可写共享的 DAX 映射会被拒绝?
- DAX 与 DIO 的本质区别是什么?
自测答案
点击展开
1. 解决"访问持久内存时,数据仍要经块设备层 + 两次拷贝"的浪费。 EROFS 适合因为它是**只读**的:没有写一致性、块分配、DAX 与 page cache 同步这些难题,几乎是"白捡"优化。 2. 四个:**①** 挂载选项 `dax=always`(`DAX_ALWAYS`); **②** `S_ISREG()`,只能是常规文件; **③/④** `datalayout` 为 `EROFS_INODE_FLAT_PLAIN` 或 `EROFS_INODE_CHUNK_BASED`。 3. 因为压缩数据在磁盘上是压缩的,必须解压才有内容; 而 DAX 是"把设备地址直接映射到用户空间",中间**没有解压的机会**。 4. 非 DAX 填 `iomap->bdev = mdev.m_bdev`; DAX 填 `iomap->dax_dev = mdev.m_dif->dax_dev`, 且地址还要额外 `+= m_dif->dax_part_off`。 5. `dax_part_off` 是**分区在 DAX 设备内的偏移**。 `fsoff` 是文件系统在分区内的偏移——两者是不同层次, 相加才是数据在 DAX 设备内的绝对位置。 6. 顺序:**① DAX**(`IS_DAX` → `dax_iomap_rw`)→ **② DIO**(`IOCB_DIRECT`) → **③ 缓冲读**(`filemap_read`)。 DAX 在最前因为它比 DIO 更直接(连块设备都不用), inode 既然是 DAX 的就没必要再考虑 DIO。 7. **没有**。EROFS 的 DAX 走 `vm_operations_struct`(`erofs_dax_vm_ops`), 不走 `address_space_operations`。 8. `.fault`(普通缺页)和 `.huge_fault`(大页缺页)。 有 `huge_fault` 说明 DAX 映射**支持大页**(如 PMD 级 2MB), 页表项更少、TLB 压力更小。 9. 因为 EROFS 只读。允许可写共享映射会让用户以为能写通, 实际写到哪都不对——所以 `erofs_file_mmap_prepare()` 直接返回 `-EINVAL`。 10. 两者都绕过 page cache,但 **DIO 仍要走块设备层发 I/O 命令**, **DAX 完全不走块设备**,直接把持久内存映射到用户地址空间。 mmap 之后,DAX 的后续访问内核完全不参与。参考
linux-stable
EROFS 官方文档 Release 0.1
OSS NA 2024《EROFS: Past, Present, and Future》
OSS China 2023《EROFS Everywhere》