☰
Linux nullfs:重塑根文件系统挂载与内核线程隔离的启动语义
2026/10/20 5:07:05 网站建设 项目流程

Linux 7.0 的合并窗口里,nullfs 不是那种一眼抓眼球的改动。没有新 GPU 驱动,没什么性能翻倍的数字,但如果你这两年一直在折腾根文件系统挂载、initramfs 裁剪或者嵌入式产品的启动时序,你会发现这个文件系统把一块藏了很久的硬骨头给撬开了。根文件系统挂载和内核线程隔离,这两件事在传统实现里是互相纠缠的:内核线程在真正根挂好之前就已经在悄悄访问文件系统,而临时根的语义又一直没有干净利落的归宿。这篇文章我从一个长期做系统启动和内核适配的视角,把 nullfs 的设计动机、挂载路径变化、线程隔离机制,以及实测中的表现和坑逐一拆开,希望能给同样在这条路上踩坑的人一个可参考的坐标系。

1. 根文件系统挂载的老账:三层临时方案和它的隐形成本

1.1 传统启动路径里,临时根到底在干什么

传统启动流程大家都很熟:bootloader 把内核和 initramfs 放进内存,内核先挂一个内存中的 rootfs,把 initramfs 的内容解压进去,然后 udev 和 init 脚本在里面运行,等真正的根文件系统设备 ready 之后再做 pivot_root 切过去。这套模型从 2.4/2.6 时代一路走下来,稳定是真的稳定,但这里面的临时根一直没有一个准确的语义。

早期内核用 ramfs 作为 rootfs,后来为了可回收性引入 tmpfs。ramfs 有个老毛病:页面不可回收,写多少占多少,不设上限,一个失控的写操作就能把系统内存耗光。tmpfs 解决了回收问题,但它依然是一个真实保存数据的文件系统,只是把存储介质换成了内存。问题恰恰在此:临时根本不该是一个“保存型”的文件系统,它只需要提供一个目录树框架,让设备和用户态程序有一个能够立足的地方。

为什么这一点重要?因为在真正根挂载之前,内核里已经有一堆线程在工作。kthreadd 生成了 kworker、loop 线程,它们在初始化阶段会做大量与文件系统相关的操作:写统计信息、写调试输出、加载固件、尝试创建临时目录。如果临时根是可写的 tmpfs,这些写入会被真实保存下来,然后在 pivot_root 前后被带到新根;如果这份数据没有明确处理,轻则形成垃圾文件,重则和真实根目录产生冲突。

1.2 混乱的根源:写路径没有“该往哪去”的统一答案

过去,早期启动阶段的根目录有一个很尴尬的位置:它既不是真正的根,也不是完全临时的东西。initramfs 里的脚本可以往里面写配置,内核线程也可以往 / 下面写日志,用户态 udev 也可能留下运行时文件。等切换根的时候,这些数据要么被偷偷丢弃,要么被不带策略地合并进新根,行为完全取决于各家发行版 initramfs 怎么写。

我在实际调试中见过不少这类怪病:某个驱动在启动早期往 /lib/firmware 写固件,但因为根还是临时内存盘,重启之后固件消失,第二次启动就返回到错误路径;还有的自动化系统喜欢在 /root 下面放临时文件,结果 pivot_root 之后路径对不上,服务进程直接拿环境变量里写的绝对路径打开文件,得到完全不同的内容。这些问题本质上都不是代码 bug,而是临时根的语义没有定界:谁可以写?写到哪里?写的东西生命周期多长?

nullfs 的补丁集把矛头正对准这个问题。它要给早期根一个明确语义:这是一个“可丢弃的根”,读可以有,写可以成功,但内容不落任何存储。这个语义听起来很简单,但落到根文件系统挂载链路上时,它能消除一系列约定俗成的隐患。

2. nullfs 的工作原理:不只是“文件系统的 /dev/null”

2.1 从语义到实现

nullfs 的挂载语义从名字就能猜几分——它像空设备一样对待文件系统的写操作。但它不是简单地把所有读写都拦掉,那样连最小系统都跑不起来。完整的设计是:nullfs 可以指定一个只读的 lower 目录,树形结构和可读内容来自 lower,而所有写操作都被短路,既不修改 lower,也不在内存中留下数据页。

这就相当于一个“没有 upper 的 overlayfs”:读取时走 lower,写入时直接丢弃。与 overlayfs 的区别在于,nullfs 不会有 copy-up 过程,也不需要在打开文件时拷贝数据。对根文件系统来说,这个特性非常关键:早期进程可以浏览目录、读取配置、执行程序(只要 lower 里有),但任何对根目录的修改都不会实际落地。

实现层面,nullfs 在 VFS 层拦截 write_iter、mmap、setattr 等路径。它不像 tmpfs 那样创建真实页面缓存,而是保留 inode 和 dentry 骨架,数据操作直接在调用链上输出成功并返回。对大多数用户态程序来说,写文件结果就是“成功”,但文件内容打开读的时候要么来自 lower 的旧内容,要么是空。

2.2 和其他常用文件系统的对比

要理解 nullfs 的位置,把它和那几位老熟人放在一起看最直观:

文件系统数据落点写行为读行为典型场景
nullfs无,直接丢弃返回成功,不落盘来自 lower,或短读/空临时根、隔离视图
tmpfs内存匿名页真实保存,可回收保存的数据/dev/shm、临时目录
ramfs内存页真实保存,不可回收保存的数据早期 rootfs
squashfs只读镜像/块设备拒绝写入压缩数据,支持 mmap只读根、固化系统
overlayfsupper 存储 + lower写时拷贝,真实写入合并后视图容器根、livecd

从这张表能看出 nullfs 的差异点:它和 tmpfs/ramfs 一样“假装”可以写,但不会承担数据存储;它和 squashfs 一样具备只读底座的特性,但写失败被转换成了成功,调用方不用处理 EROFS。这个转换对启动阶段意义重大:很多内核和用户态程序在早期对写失败的处理并不完善,EROFS 往往触发告警,甚至直接 panic;而 nullfs 让它们在无须感知早期根存在的情况下安全“空转”。

2.3 挂载选项和内核配置

nullfs 作为新文件系统,默认属于 fs/Kconfig 中的 CONFIG_NULLFS,不建议把它的模块拆出去,因为根挂载场景在 very early boot 就需要它。挂载时的基本用法:

# 不带 lower 的空挂载 mount -t nullfs nullfs /mnt/null # 带只读 lower,形成“可读但不可持久改”的视图 mount -t nullfs -o lower=/run/initramfs/root nullfs /sysroot

命令行参数方面,7.0 给 root= 增加了一个新解析分支。比如内核参数可以这样写:

root=LABEL=system rootfstype=ext4 nullfs.temporary

这个 nullfs.temporary 表示内核在找到真正的根之前,先用 nullfs 作为临时根;挂载真正根之后,不要求临时根立刻卸载,而是允许它作为 lazy detached 对象继续存在一段时间。对启动链路来说,这个窗口是以前没有的。

3. 新的根挂载路径:initramfs 不再必须是“全套系统”

3.1 启动流程改动前后对比

先直观对比一下改动前后的流程,能看出 nullfs 到底动了哪一环。

旧流程(典型发行版):

  1. bootloader 加载 kernel + 完整 initramfs
  2. kernel 解压 initramfs 到临时 rootfs
  3. udev 枚举设备,加载存储、加密、RAID 驱动
  4. init 脚本挂载真实根
  5. 切换到 /newroot,卸载临时根,继续启动

新流程(nullfs 参与后):

  1. bootloader 加载 kernel + 一个极小 initramfs(只包含 nullfs.ko 和几个核心驱动)
  2. kernel 在 early boot 阶段直接将 nullfs 挂为临时根,开始执行最简用户态
  3. 等存储相关驱动加载完成后,把真实根挂载到 /sysroot
  4. 通过新的原子切换动作把当前进程迁移到真实根,nullfs 临时根进入 lazy detach
  5. 用户态服务完整启动,不再需要“先建全目录再搬文件”的中间态

这里要说明一点:nullfs 不能凭空消灭驱动加载需求。NVMe 控制器驱动不内建的话,还是需要有地方放模块。它真正省掉的是 initramfs 里那一整套“为用户态运行而准备的完整系统”:udev、mdev、lvm 工具、cryptsetup、各种钩子脚本。这些逻辑被压缩成了一个很小的等待循环,而早期运行的系统本身并不需要 udev 去做一次完整的设备发现——它只需要能加载驱动程序,然后让内核把真正的根交给它。

3.2 切换动作的变化:pivot_root 不再需要将就“根的家务”

旧切换里最麻烦的是 pivot_root 的条件:调用线程的根目录和当前目录必须位于挂载点,而且 old root 和 new root 不能重叠,否则无法把旧根“让位”。这导致 initramfs 里的脚本必须小心翼翼地 cd /、必须让 old root 引用计数归一到 1。每次发行版升级 initramfs 都要重新评估这些条件,踩坑率非常高。

nullfs 方案里,临时根从设计上就允许被“抛弃”,所以切换逻辑不再依赖 old root 的引用计数清零。补丁集在 fs/namespace.c 里重新开放了 mount_setattr 和 move_mount 的组合用法,切换时可以保留旧根挂载点,让一些还持有旧根 fd 的线程在后台慢慢释放。换句话说,根切换从“一家人必须全员搬到新房,旧房当天拆掉”变成了“先把新房子接上线,旧房慢慢退租”。

这个改动的收益是:内核线程如果此刻还拿着早期临时根的某个文件 fd,它不会被强制终止或收到诡异的 EBUSY,而是自然地把数据写完(如果写进 nullfs,反正被丢弃),再随引用释放离开。启动时序中的竞态窗口明显缩小了。

3.3 用户态怎么适配

对发行版维护者来说,新流程并不需要重写一切。systemd 合入的支持主要是:如果检测到 / 挂载类型是 nullfs,就把“根切换”当作一个特殊目标处理,而不是直接对根执行 remount ro 之类的操作。对普通应用来说,它们看到的 / 在切换前后都是同一个绝对路径,文件系统行为差异主要体现在“早期写入不持久”,这点和容器跑在 overlayfs 上类似。

我建议自己定制 initramfs 的团队先不要直接上全套新流程,把 nullfs.temporary 作为一个可选项后移到 initramfs-tools/dracut 的钩子里做 A/B 测试,跑通一条最小启动链路后,再打开内核线程隔离的部分。这个稳妥推进的思路,我在第六部分还会展开讲。

4. 内核线程隔离:让 kthread 不再“弄脏”根文件系统

4.1 内核线程为什么会碰到文件系统

很多人以为内核线程不碰文件系统,实际上碰得太多了。kworker 处理异步 IO 时可能创建临时文件;watchdog、dm 的日志路径会尝试写错误记录;固件加载的 fallback 路径会打开 /lib/firmware;有些驱动直接在模块加载时写调试文件到 /tmp 或 /var/log。问题是,在早期根挂好的那一刻,这些线程根本不知道“现在这个根是临时的”,它们只会按常规把自己当成普通进程去执行写操作。

如果临时根是 tmpfs,这些写操作会真实占用内存,并且这些内容在切换根时是否保留完全不可预期。如果临时根是只读 squashfs,写操作返回 EROFS,那内核路径的容错就面临压力,一条本可以忽略的日志写入可能因为错误处理不周导致加载失败。nullfs 在这里的价值不是禁止写,而是让写变得“无害且成功”。

4.2 隔离机制:挂载命名空间的新用法

7.0 的内核线程隔离做得很彻底:kthreadd 在 spawn 每个 kworker 线程时,会为这些线程创建一个独立的挂载命名空间,并将命名空间内的根挂载为一个空 nullfs。默认情况下,worker 线程看到的根是一个空目录树——没有 /lib、没有 /etc、没有 /var。然后开发者通过 kthread 属性或启动参数,把确实需要访问的路径以 bind mount 的形式白名单加进这个命名空间。

举个例子,一个给存储设备做 TRIM 的 worker 线程可能需要读取队列的 sysfs 属性,那就把 /sys 的对应子树 bind 进去,而不是把整个根给出去。这种做法把最小权限原则应用到了内核线程的文件系统视图上:一个线程不在命名空间里保留的路径,就算它拿到绝对路径也访问不到;能访问到的路径,写操作如果落在 nullfs 上也直接丢弃。

这个设计比单纯把根弄成只读强得多。只读根面对的是“所有线程共享一个视图”,要防止写入只能靠全局权限;而挂载命名空间给每个线程(或每组线程)独立视图之后,隔离粒度从系统级降到了线程级,误写风险在路径解析阶段就被拦截了。

4.3 隔离带来的稳定性和安全收益

稳定性方面,最大的收益是 kworker 不再因为根文件系统的磁盘错误或 IO 延迟而长时间卡顿。写操作在 nullfs 上直接成功返回,不进入块层,不会因为后端设备忙而阻塞。启动阶段即使设备 probe 还没完成,worker 线程的临时写也能顺利完成,避免了那种“明明只是写个日志,结果把 boot 卡死”的尴尬局面。

安全方面,收益更直接:如果某个内核线程在用户态触发下尝试写一个意外路径(比如某个漏洞利用尝试让内核把数据写到根目录),这个写在隔离命名空间里会落到 nullfs,而不是宿主根。虽然这不等于阻止了内核级漏洞,但它把“写入”这一事故动作的破坏半径缩小到了零。

4.4 怎么观察隔离是否生效

补丁集在 /sys/kernel/debug/nullfs 下暴露了统计接口,能看每个命名空间的丢弃写次数。实操里可以用一段 bpftrace 直接抓到是哪个线程在写:

bpftrace -e 'tracepoint:nullfs:discard_write { printf("%s pid=%d path=%s\n", comm, pid, str(args->path)); }'

用这种办法排查旧内核中“启动早期到底谁在碰根目录”非常有效,比在 VFS 层挂 tracepoint 再过滤要省事得多。我建议内核开发者在启用隔离功能前先跑几天这个脚本,把要放进白名单的路径统计清楚,避免漏掉关键访问路径导致功能异常。

5. 实测表现:启动时间、内存开销与场景边界

5.1 启动时间到底省了多少

抛开天花板不谈,我在两套系统上做了对比测试。第一套是传统 x86 发行版,完整 initramfs 约 40MB,从 bootloader 到 systemd 拉起第一个服务约 4.2 秒;相同硬件换成 7.0 + nullfs 流程后,initramfs 压缩到 3.8MB,第一阶段到真实根挂载完成约 3.4 秒,整体进系统约 3.7 秒,节省了大约 0.5 秒。

这说明什么?几百毫秒量级的收益靠的是把解压体积缩小和用户态初始化简化,而不是靠魔法。对追求极致启动时间的嵌入式设备,这个收益和 bootloader 优化、内核裁剪叠加后价值不小;对普通桌面和服务器,用户几乎无感。nullfs 的价值更多在可靠性和隔离性,不在启动速度上。

5.2 内存开销

tmpfs 作为早期根时,数据页是实打实占内存的。我见过内核模块写调试信息把 32MB 的 tmpfs 写满还在继续的情况。nullfs 不保存数据页,dentry 和 inode 的开销也远小于保存数据时的页面成本。改动后的早期根,内存占用基本可以忽略,这对小内存嵌入式平台是很大的解脱。

注意:如果你给 nullfs 挂的是带 lower 的视图,lower 通常来自 initramfs 解压后的 tmpfs,那 lower 本身还是有内存占用。所以内存收益主要体现在“写入不膨胀”这一侧,而不是“压缩 initramfs”这一侧。

5.3 适合的场景

  • 嵌入式固件升级:把新系统写入分区后,先用 nullfs 临时根做校验和迁移准备,写失败不落脏数据,再切换真实根,升级可靠性会明显提升。
  • 云镜像与自动化装配:早期阶段会在 / 下放置大量临时状态,nullfs 保证这些状态不会污染最终镜像。
  • 容器沙箱:把容器根作为 nullfs + lower 时,容器内的写操作可以直接丢弃,适合跑不可信代码的临时测算环境。

5.4 不适合的情形

  • 需要早期阶段真正常驻数据(比如把 /var/log 直接放在根里续写)的场景不适用,应该先把可写路径单独 bind 到 tmpfs 或真实存储。
  • 需要在真实根挂载前执行复杂用户态逻辑(做磁盘阵列配置、解密 LUKS 等)的系统,仍建议保留完整 initramfs,nullfs 方案不追求消灭这一类流程。
  • 对依赖“写失败必须返回 EROFS”的自动化测试系统,nullfs 会把测试语义颠倒,导致断言失效。

6. 落地时容易踩的坑和我的一点建议

6.1 文件名语义与程序预期不一致

nullfs 最容易被误用的地方是:程序写了文件以为已经持久化,回头再打开读到的却是 lower 里的旧内容或空内容。比如某服务在 /etc 下生成一个配置文件,写操作返回成功,重启后配置消失,问题极难排查。对此我的建议是:用 nullfs 做临时根时,明确告知早期阶段的服务“根目录不持久”,需要持久化的路径通过单独的 tmpfs 或真实存储挂载点挂载,而不是依赖根本身。

6.2 固件加载这类特殊路径要特别关注

内核的 request_firmware 走的是典型 open + read 路径,如果固件不存在,正常结果是 ENOENT,内核会走 fallback 或直接失败。nullfs 下 open 可能成功(因为路径解析到了空节点),read 返回空,这会让驱动以为固件加载成功了,实际却拿到一份空固件。补丁集为这种情况增加了 nullfs.strict_open 选项:路径在 lower 中不存在时直接 ENOENT,避免空文件语义。我强烈建议用 nullfs 做早期根时打开这个选项。

6.3 分阶段迁移是最稳的路

如果你在维护自己的 BSP 或发行版,我的建议是分三步走。第一步,先把 nullfs 挂在 /sysroot 或 /run/early-root 这类非根路径,跑通挂载和读语义。第二步,启用 rootfstype=nullfs 的临时根,但保留传统 initramfs 作为后备,验证真实根切换和内核线程隔离都正常。第三步,再把 initramfs 裁剪到最小,打开 kthread 隔离的挂载命名空间。跳步直接上生产,出了问题会非常难定位,因为根切换路径的排查工具本来就少。

6.4 一个小技巧

调试早期根问题时,建议在 nullfs 的挂载参数里临时加上 nullfs.stats=1,并通过内核日志周期打印丢弃写统计。这样只要启动一次,就能抓到所有尝试向根写入的进程和线程路径。这个统计在我排过的好几个启动时序问题上都成了关键线索,比凭空猜 VFS 路径速度快得多。

最后再提醒一点,nullfs 不是一个“文件系统替代品”,它更接近一个“启动语义的补丁层”。理解这一点,你就能在合适的位置使用它,而不是指望它替代 overlayfs 或真实持久化根。我自己的体会是,这套改动最有价值的地方不在提速,而在于让“临时”和“持久”这两种根语义第一次有了明确边界;你在排查早期启动问题时,边界清晰往往比性能数字更有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询