坚持高质量原创,拒绝内容堆砌,喜欢的话点击上方星标,更新第一时间收到提醒,谢谢关注!
大家好,今天我们继续来讲内核系列专栏,文件系统这一章节。
前面文件系统 5.2 到 5.4 章,我们已经深度解析了 VFS 的几个核心结构体,包括:super_block 、inode 、dentry 、file 。
5.4章中还通过 GPIO 驱动完整的梳理了 open/read/write 从用户态到 VFS 再到设备节点的流程。
但那条链路默认有一个前提:系统里已经有一棵可用的目录树。/dev/gpio 能被解析,说明 / 已经存在,/dev 也已经在这棵树上。
但是我们还没有讲过/根目录在内核中是如何生成的。
这节我们再从内核角度,深度分析一下内核如何解析 uboot 传参以及文件系统的挂载流程是什么样的。
1. rootfs 是怎么挂载成系统根目录/ 的?
在《从零开始的 BSP 学习之路》里,我们已经完整演示了 eMMC 启动流程。我们这节挂载流程也以eMMC ext4 作为示例来讲解实现原理。
启动介质里通常会有几个关键分区:

实际启动时,U-Boot 从 boot 分区加载内核镜像和设备树,然后通过 bootargs 把根文件系统参数传给内核:
console=ttyS2,1500000n8 root=/dev/mmcblk0p2 rootwait rw
这条参数配置正确,rootfs 分区里的 ext4 镜像也没问题时,内核启动日志里会出现类似输出:
VFS: Mounted root (ext4 filesystem) on device ...
这行日志很容易被忽略,但它其实是启动过程里的关键,它表示内核已经把 eMMC 上的 rootfs 分区接到了系统根目录 / 上。
所以后面内核才能按路径去找 /sbin/init,Buildroot 生成的 bin/、sbin/、etc/ 这些目录才真正成为 Linux 运行时看到的目录树。
站在使用者角度,我们只是在 uboot 中给内核传了 root=/dev/mmcblk0p2 rootwait rw。
但内核不能直接把这串字符当成根目录,它要先找到 /dev/mmcblk0p2 对应的块设备,等 eMMC 分区准备好,再让 ext4 读取这个分区里的文件系统元数据,最后把它接入 VFS。
前面 内核系列 5.2 章讲过,VFS 用 super_block 描述一个已经挂载的文件系统实例。
放到我们这里看,根 super_block 描述的就是这次被挂成 / 的 eMMC ext4 rootfs。
这一节我们就沿着这条之前已经跑通的启动流程来梳理内核的实现流程:root= 参数使用,/dev/mmcblk0p2 怎么进入挂载流程,ext4 又如何创建出根文件系统实例。
经过这节的讲解,前面讲过的 VFS 对象,就能和我们真实启动日志对上了,也能帮助大家对于 VFS 各个结构体有一个具象化的理解。
可以参考详细的流程图来帮助理解:

2. 根设备参数解析
2.1 bootargs 传递
bootargs 是 U-Boot 交给内核的启动参数。U-Boot 会在跳转内核前把它写入设备树 /chosen 节点,内核启动后再解析这串参数。我们使用的是:
root=/dev/mmcblk0p2 rootwait rw
对根文件系统来说,我们先关注三个参数:
| |
|---|
root=/dev/mmcblk0p2 | |
rootwait | |
rw | |
对于 eMMC rootfs,rootwait 很常用。MMC 控制器 probe、eMMC 识别、分区扫描都需要时间。
没有这个参数时,内核可能在分区设备出现前就尝试挂根,最后报出 VFS: Unable to mount root fs。
rootwait这个实现原理我们后面也会讲到。
2.2 init/do_mounts.c 参数入口
根文件系统相关参数主要在 init/do_mounts.c 里接收:
__setup("root=", root_dev_setup);__setup("rootwait", rootwait_setup);__setup("rootfstype=", fs_names_setup);__setup("rootflags=", root_data_setup);__setup("rw", readwrite);
这些启动参数会先被保存到全局状态里,后面的挂根流程再读取它们:
root_dev_setup() 保存根设备字符串,例如 /dev/mmcblk0p2
rootwait_setup() 记录后面需要等待根设备
fs_names_setup() 保存 rootfstype= 指定的文件系统类型
root_data_setup() 保存 rootflags= 传入的挂载参数
readwrite() 清掉只读标志,让根文件系统按读写方式挂载
很多启动问题可以从这里倒推。比如 root= 写错,后面解析根设备时会失败;rootfstype=ext4 指定了 ext4,但内核没有内置 ext4 支持,挂载阶段会找不到可用文件系统。
3. 根设备发现与等待
3.1 prepare_namespace 入口
挂载根文件系统发生在用户态启动之前。普通的 mount 命令来自用户态工具,/sbin/init 也位于 rootfs 里面。
在 rootfs 还没有挂好之前,内核没有机会依赖它们,所以挂载操作必须由内核启动代码自己完成。
入口在 init/main.c 的 kernel_init()。
这个函数是 1 号内核线程进入用户态之前的最后一段内核流程,我们今天要讲的挂载调用也在这个流程中,prepare_namespace() 就是在这个阶段被调用的:
kernel_init() -> kernel_init_freeable() -> do_basic_setup() -> wait_for_initramfs() -> prepare_namespace() -> free_initmem() -> run_init_process("/sbin/init")
prepare_namespace() 定义在 init/do_mounts.c。它负责处理根设备、加载 initrd、等待设备、挂载根文件系统,最后把根目录切到真正的 /。
这一步完成后,kernel_init() 才具备按路径查找 /sbin/init 的条件,所以可以说是根文件系统挂载流程的起点。
简化后代码的流程如下所示,接下来我们一一展开讲解:
prepare_namespace() -> parse_root_device() -> wait_for_root() -> mount_root() -> devtmpfs_mount() -> init_mount(".", "/", MS_MOVE) -> init_chroot(".")
3.2 parse_root_device
root= 前面已经被保存到了 saved_root_name。
进入 prepare_namespace() 后,内核会调用 parse_root_device(),并把返回的设备号保存到全局变量 ROOT_DEV:
if (saved_root_name[0]) ROOT_DEV = parse_root_device(saved_root_name);
parse_root_device() 的核心逻辑在 init/do_mounts.c:
staticdev_t __init parse_root_device(char *root_device_name){int error;dev_t dev;if (!strncmp(root_device_name, "mtd", 3) || !strncmp(root_device_name, "ubi", 3))return Root_Generic;if (strcmp(root_device_name, "/dev/nfs") == 0)return Root_NFS;if (strcmp(root_device_name, "/dev/cifs") == 0)return Root_CIFS;if (strcmp(root_device_name, "/dev/ram") == 0)return Root_RAM0; error = early_lookup_bdev(root_device_name, &dev);if (error) {if (error == -EINVAL && root_wait) { pr_err("Disabling rootwait; root= is invalid.\n"); root_wait = 0; }return0; }return dev;}
前面几个分支处理的是特殊根文件系统形态。
本文的 root=/dev/mmcblk0p2 会落到最后的块设备路径,也就是调用 early_lookup_bdev(root_device_name, &dev)。
early_lookup_bdev() 定义在 block/early-lookup.c。对 /dev/mmcblk0p2 来说,关键路径是这一段:
int __init early_lookup_bdev(constchar *name, dev_t *devt){ ...if (strncmp(name, "/dev/", 5) == 0)return devt_from_devname(name + 5, devt); ...}
/dev/mmcblk0p2 会被去掉 /dev/ 前缀,变成 mmcblk0p2,再交给块设备层查找对应的 dev_t。
查找成功后,parse_root_device() 返回这个设备号,prepare_namespace() 再把它写入 ROOT_DEV。
如果此时 /dev/mmcblk0p2 还没有出现,解析会暂时失败。
这里可能是参数写错,也可能是 eMMC 驱动还在 probe,或者分区扫描还没完成。这就是 rootwait 存在的意义。
3.3 wait_for_root
如果启动参数里带了 rootwait,内核会进入 wait_for_root():
staticvoid __init wait_for_root(char *root_device_name){if (ROOT_DEV != 0)return; pr_info("Waiting for root device %s...\n", root_device_name);while (!driver_probe_done() || early_lookup_bdev(root_device_name, &ROOT_DEV) < 0) { msleep(5); ... } async_synchronize_full();}
这段代码直接说明了 rootwait 的等待对象:根设备本身。
它关心 eMMC probe 和分区扫描是否完成,还没有进入 ext4 目录内容读取阶段。循环条件里有两个判断:
driver_probe_done():确认异步 probe 基本结束。
early_lookup_bdev():继续尝试把 /dev/mmcblk0p2 查成 ROOT_DEV。
所以看到类似日志时:
Waiting for root device /dev/mmcblk0p2...
排查方向通常在 eMMC 控制器驱动、设备树、分区生成是否有问题、root= 是否写对。
因为这时候根设备还没有找到,后面的 VFS 挂载流程还没开始。
4. 根文件系统挂载流程
4.1 mount_root 分流
根设备准备好后,prepare_namespace() 调用 mount_root():
void __init mount_root(char *root_device_name){switch (ROOT_DEV) {case Root_NFS: mount_nfs_root();break;case Root_CIFS: mount_cifs_root();break;case Root_Generic: mount_root_generic(root_device_name, root_device_name, root_mountflags);break;case0:if (root_device_name && root_fs_names && mount_nodev_root(root_device_name) == 0)break; fallthrough;default: mount_block_root(root_device_name);break; }}
这段代码的核心是按设备类型来做判断。前面 parse_root_device() 和 wait_for_root() 已经尽量把 root=/dev/mmcblk0p2 解析成具体的设备号。
进入 mount_root() 时,内核根据这个结果判断根文件系统属于哪一类。
Root_NFS、Root_CIFS 这些分支对应网络根文件系统;
Root_Generic 用于 mtd、ubi 这类通用根设备;
ROOT_DEV == 0 时还会尝试 nodev 类型的根文件系统。
本文的 eMMC ext4 rootfs 属于普通块设备,最终进入默认分支:
mount_root() -> mount_block_root()
所以 mount_root() 停留在分流层。它完成根文件系统形态的选择,把 eMMC 分区交给后面的块设备挂载路径。
4.2 mount_block_root
mount_block_root() 做了两件关键事情:
create_dev("/dev/root", ROOT_DEV);mount_root_generic("/dev/root", root_device_name, root_mountflags);
第一步创建 /dev/root。它是一个临时设备节点,指向前面解析出来的 ROOT_DEV,也就是 /dev/mmcblk0p2 对应的设备号。
第二步调用 mount_root_generic(),准备把 /dev/root 按某种文件系统类型挂载起来。
4.3 do_mount_root
mount_root_generic() 会决定尝试哪些文件系统类型。
用户传了 rootfstype=ext4 时,就按这个类型尝试;没有指定时,内核会遍历已经注册的块设备文件系统。
真正发起挂载的是 do_mount_root():
staticint __init do_mount_root(constchar *name, constchar *fs,constint flags, constvoid *data){structsuper_block *s;int ret; ret = init_mount(name, "/root", fs, flags, data_page);if (ret)goto out; init_chdir("/root"); s = current->fs->pwd.dentry->d_sb; ROOT_DEV = s->s_dev; printk(KERN_INFO"VFS: Mounted root (%s filesystem)%s on device %u:%u.\n", s->s_type->name, sb_rdonly(s) ? " readonly" : "", MAJOR(ROOT_DEV), MINOR(ROOT_DEV));out: ...}
这里有三个动作要连起来看。
第一,init_mount(name, "/root", fs, flags, data_page) 把 /dev/root 按 ext4 挂到启动期临时目录 /root。
第二,init_chdir("/root") 进入刚挂好的根目录,然后通过 current->fs->pwd.dentry->d_sb 拿到这个文件系统实例的 super_block。
第三,日志里的文件系统类型来自 s->s_type->name,设备号来自 s->s_dev。所以我们平时看到的这行日志:
VFS: Mounted root (ext4 filesystem) on device ...
这行日志表示VFS 已经拿到了根 dentry,也能通过 dentry 找到对应的 super_block。
到这里还没有进入用户态,内核只是完成了第一次根文件系统挂载。
5. VFS 与 ext4 实例创建

5.1 init_mount 到 path_mount
do_mount_root() 里调用的是 init_mount()。它定义在 fs/init.c:
init_mount() -> path_mount()
init_mount() 是启动期内部接口,用来模拟 mount 系统调用需要做的事情。
它不经过用户态文件描述符,也不依赖 libc。进入 path_mount() 之后,流程就回到了 VFS 的通用挂载路径。
继续往下可以简化成:
path_mount() -> do_new_mount() -> vfs_get_tree()
do_new_mount() 根据文件系统类型创建 fs_context,解析挂载参数,然后调用 vfs_get_tree() 让具体文件系统拿出一个可挂载的根。
vfs_get_tree() 的关键点在这里:
intvfs_get_tree(struct fs_context *fc){ error = fc->ops->get_tree(fc);if (error < 0)return error; sb = fc->root->d_sb; ...}
走到 vfs_get_tree() 时,VFS 已经完成了通用部分:挂载源是 /dev/root,文件系统类型是 ext4,挂载参数也已经放进 fs_context。
接下来要解决的是一个更具体的问题:这个块设备里的 ext4 元数据,怎么变成 VFS 可以接入命名空间的一棵树?
fc->ops->get_tree(fc) 就是这次交接。ext4 注册文件系统类型时,会把自己的挂载入口挂到 fs_context 上。
VFS 调用这个入口后,ext4 会沿着 ext4_get_tree()、get_tree_bdev()、ext4_fill_super() 往下走:打开 /dev/root 背后的块设备,读取 ext4 superblock,建立根 inode 和根 dentry。
所以 fc->root 在这里承载了 ext4 的挂载结果。它指向这次 ext4 挂载生成的根 dentry,fc->root->d_sb 指向对应的 super_block。VFS 拿到这个 dentry 后,后续才能把这棵 ext4 文件系统树接到挂载点上。
5.2 文件系统类型匹配
ext4 在初始化阶段会注册自己的 file_system_type。源码在 fs/ext4/super.c,核心对象是 ext4_fs_type:
staticstructfile_system_typeext4_fs_type = { .owner = THIS_MODULE, .name = "ext4", .init_fs_context = ext4_init_fs_context, .parameters = ext4_param_specs, .kill_sb = ext4_kill_sb, .fs_flags = FS_REQUIRES_DEV | FS_ALLOW_IDMAP | FS_MGTIME,};staticint __init ext4_init_fs(void){ ... err = register_filesystem(&ext4_fs_type);if (err)goto out;return0;}
这里最关键的是两个字段。name = "ext4" 决定 VFS 后面按什么名字找到它,init_fs_context = ext4_init_fs_context 决定创建 fs_context 时进入 ext4 的初始化逻辑。
回到 VFS 通用挂载路径,do_new_mount() 会拿挂载时传入的文件系统类型做匹配。
对本文这条链路来说,这个名字就是 "ext4":
staticintdo_new_mount(const struct path *path, constchar *fstype,int sb_flags, int mnt_flags,constchar *name, void *data){structfile_system_type *type;structfs_context *fc; type = get_fs_type(fstype);if (!type)return -ENODEV; fc = fs_context_for_mount(type, sb_flags); ...}
get_fs_type("ext4") 会在已经注册的文件系统链表里查找名字为 "ext4" 的 file_system_type。找到以后,fs_context_for_mount() 才能基于这个类型创建挂载上下文,后面 fc->ops->get_tree() 才会走到 ext4 自己的挂载路径。
所以这一步解决的是“VFS 如何识别 ext4”。
对于早期根文件系统,ext4 通常要编进内核。如果 ext4 只编成模块,而模块又放在尚未挂载的 rootfs 里,内核在挂根阶段自然加载不到它。
5.3 ext4_fill_super
VFS 找到 ext4 后,ext4 自己接手创建文件系统实例。当前工程里路径是:
staticintext4_get_tree(struct fs_context *fc){return get_tree_bdev(fc, ext4_fill_super);}
这段代码很短,但信息量很大,get_tree_bdev() 说明 ext4 是基于块设备挂载的文件系统,它会打开 /dev/root 背后的块设备,并在通用块设备挂载框架里调用 ext4_fill_super()。
ext4_fill_super() 做的事情很多,这就涉及 ext4 具体的实现了,我们主要还是梳理流程以及VFS相关的内容,所以这里简单过一下就不展开了:
校验 ext4 magic、block size、feature flags
初始化 ext4 私有数据,挂到 sb->s_fs_info
设置 sb->s_op,也就是 ext4 的 super operations
所以 super_block 有明确来源,VFS 提供通用对象和挂载框架,ext4 负责把磁盘上的 ext4 元数据读出来,填进这个对象里。
挂载成功后,super_block 里已经有了这些关键信息:
| |
|---|
s_type | |
s_bdev | |
s_op | |
s_root | |
s_fs_info | |
这一步完成后,内核才真正拥有了一个可以作为 / 的 ext4 文件系统实例。
6. 根目录切换与用户态启动
6.1 Mounted root 日志
我们回到启动日志:
VFS: Mounted root (ext4 filesystem) on device 179:2.
这行日志说明几件事:
ext4 的 super_block 已经创建并填充完成
如果日志停在这之前,重点看根设备、分区和 ext4 挂载。
如果这行日志已经出现,后面又报找不到 init,排查方向就要转到 rootfs 内容本身,比如 /sbin/init、动态库、/dev/console。
6.2 根目录切换
前面 do_mount_root() 先把真实 rootfs 挂在启动期临时目录 /root。挂载成功后,prepare_namespace() 末尾会做切换:
init_mount(".", "/", NULL, MS_MOVE, NULL);init_chroot(".");
可以把它理解成:把刚挂好的 rootfs 移动成真正的 /,然后把当前进程的根目录切过去。
从这一步之后,内核再访问 /sbin/init、/etc/inittab、/bin/sh,都已经是在 eMMC ext4 rootfs 里面查找了。
6.3 init 进程启动
根目录可用后,内核开始尝试执行第一个用户态进程,常见路径包括:
/sbin/init/etc/init/bin/init/bin/sh
BusyBox rootfs、Buildroot rootfs、产品 rootfs 在这里开始出现差异。比如 BusyBox 系统会进入 /sbin/init,再读取 /etc/inittab,执行 /etc/init.d/rcS。后续 /proc、/sys、/dev 的挂载,就由 init 脚本或内核配置继续完成。
作为BSP 工程师,我们经常需要接触/proc、/sys、/dev、debugfs,这几类文件系统,我们会在下一节中详细展开讲解。
7. 总结
这一节我们以 eMMC ext4 rootfs 为例,把根文件系统挂载流程从 bootargs 追到了 VFS 和 ext4。
整条函数调用流程可以总结成如下:
root=/dev/mmcblk0p2 rootwait rw -> root_dev_setup() -> prepare_namespace() -> parse_root_device() -> wait_for_root() -> mount_block_root() -> do_mount_root() -> init_mount() -> path_mount() -> vfs_get_tree() -> ext4_fill_super() -> VFS: Mounted root -> /sbin/init
到这里,5.2 里讲过的 super_block 就不再只是一个结构体了。
它来自一次真实的挂载,背后有块设备、有 ext4 元数据、有根 inode 和根 dentry。
下一节我们继续沿着这棵目录树往下看:/proc、/sys、/dev、debugfs 这些路径背后,分别接到了内核里的哪些对象。