mmap 能映射什么:一张全景图
mmap 能映射什么:一张全景图
引言
mmap 本质上是一种**"把任意字节序列对象映射进进程虚拟地址空间"的通用机制**——它的作用域取决于 fd 指向什么。
fd 可以是普通文件、tmpfs 文件、字符设备、GPU 节点、VFIO 组,甚至完全不要 fd(MAP_ANONYMOUS)。内核为每次 mmap 创建一个 vm_area_struct(VMA),并绑定对应的文件操作表 f_op->mmap。
所以真正决定"能不能映射、映射出来是什么"的,从来不是 mmap 这个系统调用本身,而是 fd 背后的 file_operations 实现。
1 | mmap(addr, len, prot, flags, fd, offset) |
一句话记住这套心智模型:mmap 负责"登记一段地址区间",驱动/文件系统负责"这段区间背后是什么、缺页时去哪儿取"。
一、按映射对象分类:mmap 的完整作用域
1.1 匿名内存(无 fd,fd = -1)
最基础也最常用的形式:进程向内核申请一段零初始化的虚拟内存,不关联任何文件。
1 | void *p = mmap(NULL, len, PROT_READ | PROT_WRITE, |
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 | int fd = memfd_create("shm", MFD_CLOEXEC); |
这类映射的核心特征:有 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 | // 三个魔法 offset,都是 mmap 的 offset 参数 |
同一个 fd、不同的 offset、映射出三块语义完全不同的内存——这正是"mmap 的语义由 f_op->mmap 定义"最直白的证据。
1.6 一些"语义边界"之外的用法
mmap一个 socket/pipe:不支持(ENODEV);mmap一个目录:不支持(EACCES,fd 是非普通文件);mmap块设备:可以,但受块大小限制,不常用于业务代码;mmap/proc/kcore:可作为 ELF core dump 读取内核虚拟地址空间(需要 root,用于调试/取证);mmapoverlay 文件系统上的文件:受 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 | ┌─────────────────────────────────────────────┐ |
- 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 文件系统里的一个文件:
- 应用调
mmap(fd, MAP_SHARED),内核建立 file-backed VMA; - 首次访问触发 page fault →
filemap_fault()查 page cache; - miss → 文件系统发 readahead/read bio → block layer → ublk driver;
- ublk driver 把 bio 封装成 io_uring command(
IORING_OP_URING_CMD)通过共享 cmd buffer 通知用户态 daemon; - overlaybd-turbo-io 收到请求,按 LBA 查 B+ tree 索引,找到数据在哪个 layer / 哪个 blob / 是否需要 Zfile 解压 / 是否要远端拉取;
- 数据填回 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:
- 块设备 inode 自己的 page cache(bdev inode 的
address_space)——当你dd if=/dev/ublkb0或直接mmap /dev/ublkb0时用的是这个; - 挂载其上的文件系统里文件的 page cache——当你 mmap 一个 ext4 文件时用的是这个。
业务代码 mmap 文件用的是第 2 种,它和 ublk 的关系是"隔着文件系统 + bio"的间接关系。第 1 种主要用于裸设备访问(备份、格式化工具),通常不在业务路径上。
5.5 完整的缓存层级
在 overlaybd 场景下,一个 mmap 文件的实际缓存层级是五层:
1 | ① 应用 CPU cache |
每层的角色不同:
- ② 让 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 | /* include/linux/blk_types.h */ |
为什么用 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 | mmap page fault |
一个重要细节: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 | mmap page fault |
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,上层挂任意文件系统 |


