EROFS FSDAX

EROFS 文件系统直接访问持久内存

发布于 2026年9月15日

10 · 特性专题:FSDAX(文件系统直接访问持久内存)

对应配置:CONFIG_FS_DAX(内核通用)+ EROFS 的 dax=always 挂载选项
相关源码:data.c(I/O 与 mmap)、inode.cS_DAX 标志设置)

本文回答:为什么只读文件系统也需要 DAXEROFS 的 DAX 与常规路径差在哪用到哪些结构关键函数做什么一次 DAX 读/映射的来龙去脉

本专题目标(读完你应该能做到什么)

  1. 说清 DAX 想解决什么问题,以及为什么 EROFS 这种只读文件系统特别适合 DAX
  2. 解释 S_DAX 标志在 EROFS 里什么时候会被设置(条件很具体)
  3. 说清 DAX 路径与常规路径在 erofs_iomap_begin() 里的分叉点
  4. 解释为什么 DAX 优先于 DIO(读路径里的判断顺序)
  5. 解释为什么 EROFS 的 DAX 不支持可写共享映射
  6. 说清 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_devinternal.h

映射后的具体设备信息,DAX 用到其中两项:

字段 用途
m_dif->dax_dev DAX 设备(若该设备支持)
m_dif->dax_part_off 分区内偏移——见下
m_dif->fsoff 文件系统在本设备内的起始偏移
m_bdev 常规块设备(DAX 时不用)

4.4 erofs_dax_vm_opsdata.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);

三步:

  1. 非 DAX → 走常规只读 mmap
  2. DAX 但可写共享 → 拒绝(-EINVAL
  3. 否则 → 挂上 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.cS_DAX 的设置条件,确认 datalayout 只能是 FLAT_PLAINCHUNK_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 套 aopserofs_aopserofs_fileio_aopsz_erofs_aopsz_erofs_cache_aops),
DAX 不走 aops,走的是 vm_operations_structerofs_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 可能触及地址位宽问题,两者有交集

自测检查点

  1. DAX 想解决什么问题?为什么 EROFS 特别适合它?
  2. S_DAX 在 EROFS 里需要哪四个条件才设置?
  3. 为什么压缩文件不能走 DAX?
  4. erofs_iomap_begin() 里,DAX 与非 DAX 填的字段有什么不同?
  5. dax_part_off 是什么?为什么要额外加一次?
  6. 读路径里 DAX、DIO、缓冲读的判断顺序是什么?为什么 DAX 在最前?
  7. EROFS 有 erofs_fsdax_aops 吗?DAX 走的是什么结构?
  8. erofs_dax_vm_ops 有哪两个成员?huge_fault 意味着什么?
  9. 为什么可写共享的 DAX 映射会被拒绝?
  10. 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》