mmap 能映射什么:一张全景图

引言

mmap 本质上是一种**"把任意字节序列对象映射进进程虚拟地址空间"的通用机制**——它的作用域取决于 fd 指向什么。

fd 可以是普通文件、tmpfs 文件、字符设备、GPU 节点、VFIO 组,甚至完全不要 fd(MAP_ANONYMOUS)。内核为每次 mmap 创建一个 vm_area_struct(VMA),并绑定对应的文件操作表 f_op->mmap。

所以真正决定"能不能映射、映射出来是什么"的,从来不是 mmap 这个系统调用本身,而是 fd 背后的 file_operations 实现。

1
2
3
4
5
6
7
8
9
mmap(addr, len, prot, flags, fd, offset)
│
├─ 无 fd ──────────────► 匿名内存(零页 + COW)
└─ 有 fd
│
├─ 通用路径 do_mmap() → 新建 VMA
└─ f_op->mmap(file, vma) ← 语义在这里被定义
│
└─ 缺页时 vm_ops->fault() 真正取数据

一句话记住这套心智模型:mmap 负责"登记一段地址区间",驱动/文件系统负责"这段区间背后是什么、缺页时去哪儿取"。


一、按映射对象分类:mmap 的完整作用域

1.1 匿名内存(无 fd,fd = -1)

最基础也最常用的形式:进程向内核申请一段零初始化的虚拟内存,不关联任何文件。

1
2
void *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  • MAP_PRIVATE | MAP_ANONYMOUS:私有匿名页,malloc/glibc 堆、线程栈都是这么来的;
  • MAP_SHARED | MAP_ANONYMOUS:共享匿名页,fork 后父子进程可共享(Linux 2.4+),是 memfd 出现之前唯一的"匿名共享"手段;
  • 典型用途:动态内存分配、大块 scratch buffer、COW fork 的栈空间。

注意匿名映射背后并不是"凭空一块内存"——内核挂的是 anon_vma,首次写入触发缺页时分配零页并建立 COW 关系。

1.2 普通文件(regular file)

把文件内容当作内存访问,绕开 read/write 的系统调用开销和缓冲区拷贝:

  • Page cache 共享:MAP_SHARED 时多个进程映射同一文件,通过 page cache 天然共享;
  • COW 优化:MAP_PRIVATE 是进程加载可执行文件的标配——同一段代码的 .text 在物理内存只存在一份;
  • 随机访问大文件:数据库、搜索引擎、NumPy 的 np.memmap、PyTorch 加载超大权重;
  • 启动加速:配合 MADV_WILLNEED、MAP_POPULATE 做预读,或者反过来用 userfaultfd 做 lazy-load(Nydus 的镜像按需加载就是这一思想)。

文件映射的缺页路径走的是通用 filemap_fault():从 page cache 找页,找不到就发起 IO 并睡眠等待——这也是 mmap 文件时"卡住"的地方,延迟从 syscall 转移到了缺页。

1.3 tmpfs / shmem / memfd:内存里的"文件"

这类对象看起来是文件,但数据只在 RAM/swap 里,是 Linux IPC 的主力:

对象 创建方式 典型用途
/dev/shm 下的文件 shm_open / 直接 open POSIX 共享内存,进程间 IPC
System V shm shmget + shmat 老式 IPC,依然广泛使用
memfd memfd_create 匿名但可通过 fd 传递的共享内存,Android 从 ashmem 迁移到 memfd 的目标形态
ashmem (Android) /dev/ashmem ioctl Android 特有,支持"按 name 共享 + 内存压力下丢弃"
overlay 文件 open + O_TMPFILE 构建临时文件
1
2
3
4
int fd = memfd_create("shm", MFD_CLOEXEC);
ftruncate(fd, size);
void *p = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// fd 可以经 SCM_RIGHTS 通过 Unix socket 送给另一个进程

这类映射的核心特征:有 fd、能被 SCM_RIGHTS 通过 Unix socket 传给另一个进程——这是 binder、Wayland、CUDA IPC 等高级机制的基础。

1.4 字符设备:用户态驱动的基石

fd 是字符设备时,mmap 就进入"设备访问"领域。内核会调用该驱动注册的 f_op->mmap 回调,由驱动决定把什么映射给用户空间:

设备节点 映射出来的东西 典型用途
/dev/zero 零页(COW) 匿名内存的老派写法,MAP_ANON 的兼容路径
/dev/mem 物理地址空间(需要 root + STRICT_DEVMEM 限制) 嵌入式 bare-metal、SoC 寄存器访问
/dev/kmem 内核虚拟地址空间 已基本废弃
/dev/dax(device-dax) 字节可寻址持久内存 PMDK / LMCache 等 DAX 应用
/dev/pmem* 持久内存命名空间 fsdax/devdax 访问 Optane
/dev/nvidia* GPU 显存、寄存器 CUDA 用户态驱动
/dev/nvidia-uvm GPU 统一虚拟内存 CUDA UVM,CPU/GPU 共享 VA 空间
/dev/vfio/N VFIO 组下所有设备的 MMIO BAR VFIO 直通、DPDK 用户态驱动
/dev/fb0 framebuffer 显存 简单图形绘制
/dev/dri/renderD* GPU buffer object (GEM) DRM 图形栈,dumb buffer + mmap
/dev/video* V4L2 缓冲区 摄像头零拷贝捕获
/dev/uio* 简单设备的寄存器/内存 用户态驱动原型
/dev/binder binder 共享缓冲区 Android IPC,一次拷贝

字符设备 mmap 的关键在于驱动自己实现了 mmap 回调——它可以通过 remap_pfn_range() 把物理地址(如 PCI BAR、设备 MMIO)映射进用户空间,也可以通过 vm_insert_page() 把内核已分配的 page 塞进用户 VMA。

这两条路径的差别值得单独记一句:

  • remap_pfn_range():绕过 page cache,直接建 PTE 指向物理页。设备寄存器、显存、BAR 都走这条路。代价是这些页不参与内核的页回收与 COW,fork 之后父子进程共享的是同一块物理内存。
  • vm_insert_page():把内核已经分配好的 struct page 插进 VMA,仍然受内核内存管理约束,适合"内核分配、用户读写"的缓冲区(如 V4L2 的 VIDIOC_REQBUFS + mmap 组合)。

1.5 特殊机制:内核/用户共享的"环形结构"

这类场景里 mmap 的价值是零拷贝 + 零系统调用的数据通道:

  • io_uring:SQ/CQ 两个环形缓冲区都通过 mmap 暴露给用户态,SQE/CQE 由用户和内核同时读写,用内存屏障保证一致性;
  • AF_XDP / UMEM:网卡 DMA 到一块用户态和内核共享的 UMEM 内存,用户态直接读写包,绕开整个内核网络栈,性能接近 DPDK;
  • eBPF ring buffer:用户态 mmap 读取 BPF 程序产生的事件;
  • vDSO:内核把一小段代码 + 数据(如 clock_gettime 使用的 vvar 页)映射到每个进程的地址空间,让高频 syscall 变成纯用户态函数调用;
  • futex:底层依赖共享内存做用户态 fast path;
  • 通用内核环形缓冲区(LWN 2024 提案):把"fd + mmap"的环形缓冲区范式泛化成内核接口。

io_uring 是最能说明问题的例子——用户态初始化时对同一个 fd 做三次 mmap,用 offset 区分映射对象:

1
2
3
4
5
6
7
// 三个魔法 offset,都是 mmap 的 offset 参数
mmap(NULL, sq_ring_sz, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_POPULATE,
ring_fd, IORING_OFF_SQ_RING); // 提交队列环
mmap(NULL, cq_ring_sz, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_POPULATE,
ring_fd, IORING_OFF_CQ_RING); // 完成队列环
mmap(NULL, sqes_sz, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_POPULATE,
ring_fd, IORING_OFF_SQES); // SQE 数组本体

同一个 fd、不同的 offset、映射出三块语义完全不同的内存——这正是"mmap 的语义由 f_op->mmap 定义"最直白的证据。

1.6 一些"语义边界"之外的用法

  • mmap 一个 socket/pipe:不支持(ENODEV);
  • mmap 一个目录:不支持(EACCES,fd 是非普通文件);
  • mmap 块设备:可以,但受块大小限制,不常用于业务代码;
  • mmap /proc/kcore:可作为 ELF core dump 读取内核虚拟地址空间(需要 root,用于调试/取证);
  • mmap overlay 文件系统上的文件:受 overlayfs 特性影响,某些场景下会有私有副本而非共享页(典型的 CVE-2023-0386 相关)。

二、按 flags 分类:映射行为的多维组合

mmap 的 flags 由"必选一"的三种共享语义 + 可选修饰位组成。

2.1 共享语义(必选一)

flag 语义 修改是否回写文件 是否对其他映射者可见
MAP_SHARED 共享映射 msync/munmap 时回写 ✅
MAP_PRIVATE COW 私有 ❌ ❌
MAP_SHARED_VALIDATE (4.15+) 共享 + 严格校验 同 SHARED ✅,MAP_SYNC 只能配合它使用

MAP_SHARED_VALIDATE 的存在感很低但含义很关键:普通的 MAP_SHARED 会静默忽略内核不认识的 flag 位,而 MAP_SHARED_VALIDATE 会对未知位直接返回 EOPNOTSUPP。MAP_SYNC 之所以必须配它,就是为了防止"在老内核上以为开了持久化、其实被静默忽略"。

2.2 对象来源(可选,二选一)

  • MAP_ANONYMOUS:无 fd、零初始化;
  • 无此 flag:fd 必须有效,offset 需页对齐。

2.3 修饰位

flag 作用
MAP_FIXED 强制使用 addr,覆盖已有映射
MAP_FIXED_NOREPLACE (4.17+) 强制使用 addr,冲突时报 EEXIST(比 FIXED 安全)
MAP_HUGETLB / MAP_HUGE_2MB / MAP_HUGE_1GB 用大页分配(TLB 命中率、内存密集负载优化)
MAP_LOCKED 映射页立即锁定(类似 mlock,但失败不报错)
MAP_POPULATE 预先填满页表,避免后续 page fault
MAP_NONBLOCK 配合 POPULATE,只填已在内存的页
MAP_NORESERVE 不预留 swap 空间,允许 overcommit
MAP_GROWSDOWN 栈式向下增长(配合 guard page)
MAP_STACK 提示用于线程栈(Linux 上是 no-op,glibc 仍用)
MAP_SYNC (4.15+) 仅 DAX 文件支持:写入在断电/崩溃后仍持久可见
MAP_UNINITIALIZED 匿名页不清零(嵌入式专用,默认关闭)
MAP_32BIT x86-64 上把映射放在低 2GB

MAP_FIXED 是其中最危险的一位:它不会报错,而是直接 unmap 掉覆盖区域里已有的任何映射。历史上无数安全漏洞与诡异崩溃都源于此——MAP_FIXED_NOREPLACE 就是为了给它上保险。

2.4 prot 参数

  • PROT_NONE:完全不可访问(用作 guard page);
  • PROT_READ / PROT_WRITE / PROT_EXEC:组合位;
  • 架构差异:某些架构上 PROT_WRITE 隐含 PROT_READ,PROT_READ 是否隐含 PROT_EXEC 取决于架构(RISC-V / x86 的某些配置要求显式声明 EXEC)。

三、换个维度看:什么不能映射

理解 mmap 的边界同样重要:

  • 无法映射内核动态分配的任意对象(如某个 task_struct)——除非驱动通过某种 fd(如 debugfs)暴露出来;
  • 无法映射另一个进程的 VA 空间——process_vm_readv 是另一套接口;
  • 无法映射非页对齐的地址——offset 必须是 PAGE_SIZE 的倍数;
  • 无法映射没有实现 f_op->mmap 的文件——比如大多数 procfs/sysfs 文件会返回 ENODEV 或 ENOSYS;
  • 无法映射超过 RLIMIT_DATA / overcommit 限制的长度;
  • 不能在映射后动态改大小——改大小要用 mremap(),这不是 mmap 的职责。

把这些"不能"反过来读,就是 mmap 的能力边界:它映射的永远是"页对齐的、有明确 fault 处理者的地址区间",而不是"任意一块内存"。


四、放在之前讨论的坐标系里

mmap 这个话题串起了前面几轮讨论的很多底层细节:

  • Firecracker 快照恢复:microVM 的 guest 物理内存是以一个大文件(或 memfd)的形式被 mmap 进 VMM 的,恢复时零拷贝;
  • EROFS / FSCache 按需加载:RAFS 镜像本质是"mmap 一个特殊文件,缺页时通知用户态 daemon 拉取";
  • VFIO 直通:QEMU 对 GPU BAR 的访问就是 mmap VFIO 设备 fd 的特定偏移(BAR 对应的 region);
  • CUDA UVM:CPU 进程 mmap /dev/nvidia-uvm,让 GPU 和 CPU 共享虚拟地址空间;
  • Agent sandbox 的进程间通信:gVisor / Firecracker 内部大量使用 memfd + mmap 共享内存做 IPC;
  • vDSO 加速 syscall:Firecracker 快照恢复的毫秒级性能背后,guest 的 clock_gettime 也通过 vDSO 免去了真实 syscall 开销。

可以看到,这些系统虽然分属快照、镜像、直通、GPU、沙盒五个完全不同的领域,但它们共享同一个原语:“一个 fd + 一次 mmap + 缺页时由谁负责填充”。理解了这一点,再看这些系统的实现会顺畅很多。


五、往下走一层:mmap 的页究竟从哪儿来

前面把 mmap "能映射什么"讲完了,但还有一半问题没回答:那些页是谁填进去的? 这一节顺着 page fault 往下走,走到块设备驱动为止。

5.1 page cache 和 ublk 的层级关系

拿 overlaybd 的 /dev/ublk 做例子——它和 mmap page cache 常被放在一起讨论,但两者处在 I/O 栈的两个不同层级,不是竞争关系,而是上下游关系:page cache 在文件系统之上,/dev/ublk 在文件系统之下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
┌─────────────────────────────────────────────┐
│ 应用:mmap(file) MAP_SHARED │
└──────────────────┬──────────────────────────┘
↓ page fault
┌─────────────────────────────────────────────┐
│ Page Cache(per-inode 的 address_space) │ ← mmap 的页住在这里
└──────────────────┬──────────────────────────┘
↓ filemap_fault / readahead
┌─────────────────────────────────────────────┐
│ 文件系统(ext4 / xfs / overlayfs) │
└──────────────────┬──────────────────────────┘
↓ bio(block I/O 请求)
┌─────────────────────────────────────────────┐
│ Block Layer │
└──────────────────┬──────────────────────────┘
│
┌─────────────┴─────────────┐
↓ ↓
┌─────────────┐ ┌──────────────────────┐
│ NVMe/SSD │ │ /dev/ublkb0 (ublk) │ ← overlaybd 挂在这里
│ (本地盘) │ │ io_uring cmd │
└─────────────┘ └──────────┬───────────┘
↓ SQE/CQE
┌──────────────────────┐
│ overlaybd-turbo-io │ ← 用户态 daemon
│ · LBA B+ tree 索引 │
│ · Zfile 在线解压 │
│ · 本地 cache │
└──────────┬───────────┘
↓ HTTPS
┌──────────────────────┐
│ Registry / OSS / P2P │
└──────────────────────┘
  • Page cache:在文件系统之上,per-inode,mmap 的 MAP_SHARED 页就住在这里,dirty 页通过 writepage 回写;
  • ublk:在文件系统之下,块层之后,内核把 bio 转成 io_uring command 发给用户态 daemon,daemon 决定数据从哪来(本地 cache、Zfile 解压、远端拉取)。

关键事实:page cache 不知道下面是 NVMe 还是 ublk,它只跟"文件系统给我 bio"这件事打交道;反过来 ublk daemon 只看到 bio(LBA + 长度 + buffer),看不到上面的 page cache。两者解耦,靠文件系统做中介。

所以答案是:mmap 一个由 overlaybd 块设备支撑的文件时,page cache 依然照常工作,只是底层"填 page cache 的那一步 bio"被换成了走 ublk 到用户态 daemon 的路径。

5.2 mmap 一个 overlaybd 支撑的文件,实际发生什么

假设容器里有个文件 /data/weights.bin,它是 overlaybd 块设备上 ext4 文件系统里的一个文件:

  1. 应用调 mmap(fd, MAP_SHARED),内核建立 file-backed VMA;
  2. 首次访问触发 page fault → filemap_fault() 查 page cache;
  3. miss → 文件系统发 readahead/read bio → block layer → ublk driver;
  4. ublk driver 把 bio 封装成 io_uring command(IORING_OP_URING_CMD)通过共享 cmd buffer 通知用户态 daemon;
  5. overlaybd-turbo-io 收到请求,按 LBA 查 B+ tree 索引,找到数据在哪个 layer / 哪个 blob / 是否需要 Zfile 解压 / 是否要远端拉取;
  6. 数据填回 bio → page cache 填上这页 → mmap 的用户 VA 就能读到了。

整个过程 mmap 完全无感知——它以为自己在 mmap 一个"普通的 ext4 文件",只是 page fault 的响应时间比本地盘长(冷访问要走网络),热访问会命中 overlaybd 的本地 cache。

5.3 overlaybd 为什么选块级而不是文件级

这是理解这个问题的关键对比。同为"镜像按需加载",Nydus RAFS 走的是文件级(自己实现一个文件系统),overlaybd 走的是块级(把镜像变成一个块设备):

维度 RAFS / EROFS(文件级) overlaybd / ublk(块级)
实现层 自己的文件系统 虚拟块设备 + 上层任意文件系统
Page cache 的角色 由 RAFS/EROFS 自己填 page cache(filemap_ops 实现) 完全复用上层文件系统的 page cache
上层兼容性 必须用 RAFS-aware 工具 任何文件系统都能挂在它上面(ext4/xfs/overlayfs)
POSIX 兼容 需要自己保证 天然兼容(就是块设备上的普通文件系统)
VM 场景 不适用(VM 需要块设备) VM 可以直接当磁盘用
按需加载粒度 chunk 级(更细) LBA 块级(更粗但通用)

overlaybd 的 README 直接写明这个定位:“applicable to virtual machine” 和 “all forms of containers, virtual machines, and sandboxes”。块级方案换来的通用性代价,是把按需加载逻辑从"实现一个 FS"变成"实现一个用户态块设备"——ublk 的出现让这件事的成本大幅降低(TCMU 时代要绕 iSCSI/TCMU,ublk 时代直接 io_uring,性能和延迟都更好)。

5.4 一个容易混淆的点:块设备文件自己也有 page cache

严格说 Linux 里其实存在两种和块设备相关的 page cache:

  1. 块设备 inode 自己的 page cache(bdev inode 的 address_space)——当你 dd if=/dev/ublkb0 或直接 mmap /dev/ublkb0 时用的是这个;
  2. 挂载其上的文件系统里文件的 page cache——当你 mmap 一个 ext4 文件时用的是这个。

业务代码 mmap 文件用的是第 2 种,它和 ublk 的关系是"隔着文件系统 + bio"的间接关系。第 1 种主要用于裸设备访问(备份、格式化工具),通常不在业务路径上。

5.5 完整的缓存层级

在 overlaybd 场景下,一个 mmap 文件的实际缓存层级是五层:

1
2
3
4
5
6
7
8
9
① 应用 CPU cache
↓
② Page cache(内核,per-file inode) ← mmap 的页在这里
↓
③ 文件系统元数据缓存(也在 page cache 里)
↓
④ overlaybd 本地 cache(用户态,本地盘) ← ublk daemon 管理
↓
⑤ 远端 Registry / OSS / P2P ← 最慢但最大

每层的角色不同:

  • ② 让 mmap 的重复访问接近内存速度;
  • ④ 让重复启动同一镜像的容器不用再走网络;
  • ⑤ 是冷启动时的数据源。

ublk 的价值就在于把 ④⑤ 两层挂到了标准块设备接口之下,让 ②③ 完全不用改。

5.6 性能上的实际影响

这个设计带来的几个实际观察:

  • 冷访问延迟:首次 page fault 要经过 page cache miss → bio → ublk → 用户态 → 网络 → 回填,可能几百 ms;MAP_POPULATE 在这种场景下会串行触发大量 page fault,不适合 overlaybd 冷启动场景;
  • Readahead 有帮助:filemap_fault 会预读相邻页,产生更大的 bio,ublk daemon 可以一次拉更大块的远端数据,摊薄网络 RTT;
  • 热访问几乎无损耗:第二次访问同一页时,mmap 命中 page cache,完全感知不到下面是 ublk——这就是块级方案的优势,“第一次慢,之后和本地盘没区别”;
  • io_uring 通信成本可控:ublk 用 per-queue 共享 cmd buffer + SQPOLL,用户态通信的开销远小于 TCMU 时代的 iSCSI loopback。

这也解释了为什么 RAFS 和 overlaybd 是互补的生态位置:前者做文件级极致优化,后者做块级通用性。


六、bio:块层的"最小 I/O 请求描述符"

上一节反复提到 bio,它是 page cache 和块设备之间真正流动的对象,值得单独讲清楚。

bio(Block I/O)是 Linux 块层(block layer)的基本 I/O 描述符——文件系统向块设备提交 I/O 的最小单位,一个 bio 描述"对某个块设备的某段连续扇区,做某种操作(读/写/flush/discard),数据来自/去往内存中某些散列的页"。它是把上层(page cache / 文件系统)和下层(设备驱动 / NVMe / ublk)连接起来的通用语言。

LWN 的经典描述是:bio is a wish——“Read these 4 KiB, from this spot on the disk, into this page of memory”。它是文件系统递给块层的"愿望单",块层和驱动负责把它变成硬件命令并执行。

6.1 一个 bio 回答四个问题

每个 bio 是对一段连续磁盘区域的一次操作的完整描述:

问题 字段 举例
哪个设备? bi_bdev(bi_bdev->bd_disk 得到磁盘)+ 分区偏移 /dev/nvme0n1 上的第 2 分区
什么操作? bi_opf(op + flags) REQ_OP_READ、REQ_OP_WRITE、REQ_OP_FLUSH、REQ_OP_DISCARD
磁盘哪里? bi_iter.bi_sector 从扇区 67489624 开始(1 扇区 = 512B)
内存哪里? bi_io_vec 散列列表 3 个不在物理内存连续位置的 page

再加上两个关键元数据:

  • bi_end_io:完成回调——I/O 完成后块层调它,这是发起者的"返回地址";
  • bi_status:完成时填入成功/失败状态。

关键特性:磁盘侧连续,内存侧可以散列。 一个 bio 对应磁盘上一段连续的扇区,但对应内存里多个不连续的 page——这就是 scatter-gather I/O,设备通过 DMA 直接在多个 page 之间搬数据,不需要 CPU 拷贝。

6.2 struct bio 与 bio_vec:数据结构的实际样子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* include/linux/blk_types.h */
struct bio {
struct block_device *bi_bdev; /* 目标块设备 */
blk_opf_t bi_opf; /* 操作 + flags */
blk_status_t bi_status; /* 完成状态 */
struct bio_vec *bi_io_vec; /* 散列列表数组 */
struct bvec_iter bi_iter; /* 当前迭代位置(用于 split / 部分完成) */
bio_end_io_t *bi_end_io; /* 完成回调 */
void *bi_private; /* 发起者的私有数据 */
/* ... */
};

/* 一个 scatter-gather 段:一个 page 里的一段连续区域 */
struct bio_vec {
struct page *bv_page; /* 哪个物理页 */
unsigned int bv_len; /* 多少字节 */
unsigned int bv_offset; /* 页内偏移 */
};

为什么用 struct page * 而不是虚拟地址?因为:

  • 数据 buffer 可以在内核空间、用户空间(比如 mmap 的 page)、甚至高端内存;
  • 驱动不用关心 buffer 的"最终归属",只看 page 指针就能 DMA;
  • mmap 一个文件时 page cache 里那页就是 struct page,bio 直接引用它,零拷贝。

bio 可以 split:如果 bio 超过设备单次 I/O 上限(bio_max_segs、队列的 max_sectors),块层用 bio_split() + bio_chain() 把它拆成多个子 bio,每个继承原 bi_end_io,全部完成后回调一次。

6.3 bio 和 request 的区别:容易混淆但本质不同

这是理解块层最容易踩的坑。bio 和 request 是两个不同的对象,处在不同的层:

维度 bio request
是什么 上层递下来的"愿望单" 驱动看到的"待执行命令"
谁创建 文件系统 / page cache / upper layer 块层(从预分配的 tag 池里取)
包含关系 一个 bio = 一次操作 一个 request = 多个 bio 合并(相邻扇区)
有无 tag 无 有(slot index,就是 queue depth 的基本单位)
经过调度器? 不经过 经过 I/O scheduler(elevator)
典型场景 提交给 dm/md/LVM 等虚拟设备 提交给 NVMe/SCSI 等物理设备

关键设计:虚拟设备绕过 request 层,直接消费 bio。 LVM、software RAID (md)、dm-crypt、overlaybd 的 ublk 驱动都是 make_request_fn 消费 bio、然后生成新的 bio 发给下一层设备——不参与 request 合并和调度。

物理设备(NVMe/SATA)才走 request 层,才能享受 I/O scheduler 的合并与排序。这也是为什么 NVMe 有 poll queues、SATA 只有一条 queue——请求池大小 = queue depth,tag 就是 in-flight I/O 的计数器和完成 ID。

6.4 一个 bio 的完整生命周期

从 page fault 到数据落内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mmap page fault
↓
filemap_fault() 查 page cache
↓ miss
文件系统分配 page → submit_bio(bio)
↓
submit_bio_noacct()(旧名 generic_make_request())
↓ (递归保护:current->bio_list,防止 LVM/RAID 叠栈撑爆内核栈)
make_request_fn()
├─ 虚拟设备路径:直接改 bio,生成新 bio 给下一层
└─ 物理设备路径:blk-mq 分配 request,合并相邻 bio,进 I/O scheduler
↓
驱动(NVMe/ublk)从 hctx 取 request,转成硬件命令
↓
DMA 搬运数据(绕开 CPU)
↓
设备中断完成 / ublk daemon 回写结果
↓
blk_mq_end_request() → bio_endio(bio)
↓
bi_end_io() 被调用 → 文件系统把 page 填入 page cache
↓
mmap 的用户 VA 就能读到数据了

一个重要细节:submit_bio() 通常不等待完成——它只是把 bio 排进队列就返回,真正等待要靠 bi_end_io 回调或 submit_bio_wait()(同步版本)。这是 Linux 异步 I/O 的基石。

6.5 bio 不止是读写:块层的"控制操作"

bio 的 bi_opf 除了 READ/WRITE,还能携带多种控制请求:

  • REQ_OP_FLUSH:刷设备内部 cache(FUA、barrier 的底层实现);
  • REQ_OP_DISCARD:告知设备某段 LBA 不再使用(SSD TRIM 的基础);
  • REQ_OP_WRITE_ZEROES:硬件直接填零(比写零页更快);
  • REQ_OP_ZONE_*:ZNS zoned device 的 zone 管理;
  • REQ_OP_SECURE_ERASE:安全擦除;
  • REQ_OP_ZONE_APPEND:zoned device 的原子追加写。

这让 bio 成为块层统一的命令通道,不只是数据搬运。

6.6 递归保护:bio 层一个精妙的设计

虚拟块设备可以叠栈(LVM on md on dm-crypt on NVMe),一个 bio 沿栈传递理论上会无限递归、撑爆内核栈。Linux 2.6.22 后的解法是:

submit_bio_noacct() 检测到递归调用时不继续下传,而是把 bio 挂到 current->bio_list,等当前 bio 处理完再从队列取出继续——把递归变成迭代。

这个设计带来的副作用是:如果虚拟设备在 make_request_fn 里等待一个还在 bio_list 里排队的 bio,会死锁。经典死锁场景是 mempool 分配 bio 时等待前一个 bio 释放——内核为此引入了 bioset 进程(ps 里能看到),给每个 mempool 配一个 rescuer 线程解这种依赖。

6.7 回到 mmap:bio 在 ublk 链路里的位置

把 bio 放回 5.1 节的栈图里,它的位置就更清晰了:

1
2
3
4
5
6
7
8
9
10
11
12
13
mmap page fault
↓
page cache miss → 文件系统发 bio
↓
ext4 (on /dev/ublkb0)
↓
ublk 驱动(消费 bio,不走 request 层)
↓ bio 转成 io_uring command (IORING_OP_URING_CMD)
overlaybd-turbo-io 用户态 daemon
↓ LBA → B+ tree → 本地 cache / Zfile 解压 / 远端拉取
数据回填 bio 的 page
↓
bio_endio → page cache 填好 → mmap 可读

ublk 驱动本质上就是一个"bio 到用户态的桥"——它作为一个虚拟块设备驱动,注册了 make_request_fn 消费 bio,把每个 bio 的扇区/长度/内存 page 翻译成 io_uring command 通过共享 buffer 递给用户态 daemon,daemon 决定数据从哪来。这个设计绕过了 request 层、绕过了 I/O scheduler,让"用用户态代码实现一个块设备"的成本降到最低——这正是 overlaybd 选它而不是 TCMU/NBD 的原因。


结语

mmap 能映射的是"一切实现了 f_op->mmap 的 fd 对象 + 匿名内存"——从普通文件、tmpfs、memfd、各种字符设备(/dev/zero、/dev/mem、/dev/dax、GPU/VFIO/DRM/V4L2 节点)到 io_uring/AF_XDP/vDSO 这类内核环形结构,几乎覆盖了 Linux 中所有"可以按字节访问的数据源";flags 的组合决定映射是共享还是私有、是否用大页、是否要预填充、是否要 DAX 持久化。

它是 Linux 用户态和内核/设备/其他进程之间最重要的零拷贝通道——而这条通道的宽度、语义和延迟特性,全部由那个不起眼的 f_op->mmap 决定。

把视角再往下拉一层,这条通道的另一半在块层:mmap 在文件系统之上承载页,bio 在文件系统之下描述"这些页的数据从哪来",ublk 则把块设备本身搬到了用户态。三者串起来就是:

mmap(登记地址区间)→ page cache(缓存页)→ bio(描述一次块 I/O)→ ublk(把 bio 转成 io_uring command 交给用户态)→ overlaybd daemon(决定数据从本地 cache、Zfile 还是远端来)

理解了这个链条,mmap、page cache、块层、NVMe、VFIO 直通、ublk 这些话题就在同一个心智模型下串起来了。

附:快速自查表

你想做的事 该用的 fd / flags
分配一块私有内存 MAP_PRIVATE | MAP_ANONYMOUS, fd = -1
父子进程共享一块匿名内存 MAP_SHARED | MAP_ANONYMOUS,或 memfd
跨进程传递共享内存 memfd + SCM_RIGHTS
把大文件当数组随机访问 MAP_SHARED,普通文件 fd
加载可执行文件 / 只读共享代码 MAP_PRIVATE
访问设备寄存器 / PCI BAR MAP_SHARED,对应字符设备 fd
持久内存、掉电不丢 MAP_SHARED_VALIDATE | MAP_SYNC,DAX 文件
想要大页降低 TLB miss MAP_HUGETLB / MAP_HUGE_2MB / MAP_HUGE_1GB
避免运行时缺页抖动 MAP_POPULATE(+ MADV_WILLNEED 预读)
需要延迟加载 / 按需拉取 不用 POPULATE,配合 userfaultfd
改映射大小 mremap(),不是 mmap
镜像按需加载(文件级) RAFS / EROFS + FSCache
镜像按需加载(块级,兼容 VM) overlaybd + ublk,上层挂任意文件系统