提到Linux文件系统,用不了多久你就会被superblock这个词拦住去路。我第一次在源码里遇到它时,想当然地以为这只是磁盘上那块存着“文件系统总信息”的元数据区域,直到后来排查一个挂载异常问题,才发现它远比想象的复杂:同样的名字,既指磁盘上格式化的静态数据,又指内存里每次挂载动态生成的结构体;既承担VFS通用层的入口职责,又通过一组回调函数把几十种文件系统全都串在一起。这篇文章是我系统性啃完superblock源码后整理的学习笔记,适合正在读VFS层代码、或者想搞明白挂载/卸载/sync到底发生了什么的人。核心关键词就一个:Linux内核的superblock(超级块)。我会先把它到底是什么讲清楚,再顺着数据结构、生命周期、落盘一致性、调试手段这条线,把我踩过的坑一并写出来。
1. 我为什么盯上 superblock:文件系统生态的核心枢纽
1.1 从一个调用栈倒查,superblock 总会出现
如果你从任意一个文件操作出发,比如最普通的open("/mnt/test/foo", O_CREAT),顺着内核调用栈往下追,用不了多久就会撞见super_block。以路径查找为例:
open() -> do_sys_open() -> do_dentry_open() -> path_openat() -> walk_component() -> lookup_fast() -> lookup_slow() -> inode = iget_locked(sb, ino); -> inode->i_op->lookup(inode, dentry, ...)这里的sb就是struct super_block *。每个 inode 都必须隶属于一个 superblock,文件系统类型、设备号、最大文件长度、挂载选项、根目录 dentry 全部挂在它身上。换句话说,只要你操作文件,就离不开它。这种“无处不在”的感觉,让我决定不再见到一个字段查一个字段,而是系统性地从头读一遍。
我后来发现,几乎所有疑难问题都能归结到 superblock 的某个环节:为什么某块设备被重复挂载时报device busy?为什么umount后内核崩溃?为什么sync看起来执行了但数据还是丢了?答案都藏在这个结构体以及它对应的操作函数里。
1.2 先分清楚磁盘超级块和 VFS 超级块,这是第一道坎
我见过不少同学把这两个概念混为一谈,其实它们是两层东西。磁盘超级块是在执行mkfs.ext4时写进块设备固定位置的元数据,描述的是“这个文件系统怎么布局”;而 VFS 超级块是每次挂载时在内存里分配的通用结构,描述的是“当前这个挂载实例运行得怎么样”。一份磁盘数据可以被多次挂载,也就对应多个不同的 VFS superblock 实例,但共用同一份磁盘超级块信息。
用我的生活类比:磁盘超级块像房产证上写明的房屋结构、面积、产权归属,VFS 超级块则像你住进去后自己贴的便利贴——水电抄表数、门禁卡状态、昨天买了什么菜。前者是登记数据,后者是运行时状态。
| 对比项 | 磁盘超级块 | VFS 超级块(struct super_block) |
|---|---|---|
| 存储位置 | 块设备的固定偏移,如 ext4 在偏移 1024 字节处 | RAM 中,每次挂载动态分配 |
| 作用 | 描述文件系统静态布局和特性 | 描述挂载实例状态,并为 VFS 提供统一入口 |
| 生命周期 | 格式化时写入,与文件系统共存 | 挂载时创建,卸载后销毁 |
| 格式 | 每种文件系统各自定义 | VFS 统一结构,通过 s_fs_info 指向各文件系统私有数据 |
搞懂这一层后,再读源码就不会被“superblock”这一个词绕晕了。本文后面提到的superblock/sb,默认指 VFS 层的struct super_block,需要对比时我会明确写出“磁盘超级块”。
2. struct super_block:读懂这个结构体,才算摸到门
struct super_block是内核里出了名的大结构体,字段多的时候接近上百个。我的经验是不要从头到尾死记,而是按功能分成四组:身份标识、挂载关系、锁与状态、缓存关联。下面按这条线逐一拆开。
2.1 身份标识:它代表“哪个设备上的哪种文件系统”
第一组字段回答的是“我是谁”。日常调试最常用的有这几个:
s_id:一个可读字符串,通常是设备名,比如"/dev/sda1"。内核日志、trace 输出里频繁出现,排查问题时第一眼就靠它。s_dev:与设备关联的设备号。注意有些内存文件系统没有块设备,内核会为其分配一个虚拟伪设备号。s_magic:文件系统魔数,比如 ext4 是0xEF53,XFS 是0x58465342。区分文件系统类型时靠这个字段。s_type:指向struct file_system_type,表示这是 ext4、xfs 还是别的什么。s_bdev:指向struct block_device,只有基于块设备的文件系统才有。s_fs_info:一个void *,指向当前文件系统的私有超级块结构。比如 ext4 里它通常指向struct ext4_sb_info,里面保存了从磁盘读出的原始超级块副本以及大量运行时数据。s_maxbytes:这个文件系统支持的最大文件大小。写文件时,VFS 的generic_write_checks会在这一层做边界校验。
有一点值得专门记一下:s_fs_info是把 VFS 和具体文件系统解耦的关键。VFS 不需要知道 ext4 内部怎么组织,只要s_fs_info能拿到私有数据、s_op能回调到具体实现就够了。
2.2 挂载关系:s_root、s_mounts 与全局链表
第二组字段描述的是“我挂在哪儿、我怎么被找到”。s_root是文件系统根目录对应的 dentry,s_mounts是该超级块上的所有挂载点列表。一个文件系统可以被挂载到多个位置,每个位置是一个struct mount,它们通过s_mounts串起来。
struct super_block自身还会挂到全局的super_blocks链表中,由s_list连接。内核遍历所有文件系统时,比如执行sync()时,就是从super_blocks链表逐个遍历,再通过s_mounts处理每个挂载点。我写调试脚本时经常利用这个全局链表:在 crash 工具里list super_blocks就能看到当前系统全部挂载实例。
这也是理解“同一块设备重复挂载”的关键:内核在分配新超级块之前,会先通过sget系列函数在已有的超级块里找“同类型、同设备”的实例,找到了就直接复用,找不到才新建。所以同一个分区不需要真的在内存里存在多份 VFS superblock,除非挂载时使用了某些特殊选项或经过网络文件系统的特殊处理。
2.3 锁与状态标志:为什么结构体里全是锁
第三组字段是我最开始读源码时最不适应的地方:一个 superblock 居然要维护一把读信号量、若干原子计数和一堆标志位。原因很简单:superblock 是全局共享资源,几十个 CPU 可能同时在读写同一个文件系统,不加锁就全乱了。
几个重要的:
s_umount:读写信号量,保护超级块的挂载状态。读文件时持有读锁,umount/remount需要写锁。这也是为什么在挂载目录里执行cd /mnt/test后立刻umount会说不忙——因为那个进程还在持有s_umount读锁。s_active:一个原子计数,表示“这个超级块当前是否被使用”。当它从 1 降到 0,说明没有挂载实例引用了,符合销毁条件。s_count:超级块本身的基础引用计数,脱钩回收时用。s_flags、s_iflags:一堆标志位,比如SB_RDONLY表示只读挂载,内部还有一些表示“正在销毁”“已有活跃挂载”等状态的标志。
这里有个常见误区:s_umount名字叫 umount,但普通文件操作也依赖它。我之前有一次在调试回调函数里试图直接拿写锁,结果在一个已经持有读锁的路径上死锁。记住处理优先级:普通文件路径先拿s_umount读锁,再调具体文件系统回调和;umount则要拿写锁,然后等所有读者退出。
2.4 与 inode/file 的关联:整个缓存体系的根
第四组字段是缓存关联。s_inodes是当前文件系统所有 inode 的链表,s_files是所有打开文件(struct file)的链表,s_dentry_lru和s_inode_lru是 dentry 和 inode 的 LRU 回收队列。
这套结构决定了什么?比如你执行echo 2 > /proc/sys/vm/drop_caches,回收机制要从每个 superblock 的 LRU 链表开始,逐个把这些缓存对象无效化;又比如排查“某个文件删了磁盘空间还不释放”时,本质上就是在 superblock 的s_inodes链表里找那个 still open 的 inode。可以这样理解:superblock 是文件系统一切内存对象的“起点”,每个对象都要通过它反向找到文件系统类型和私有数据。
我阅读这一章的技巧是:先只抓每个字段一句话的作用,知道它属于哪一类;等后面读具体调用路径时,再回头记忆细节。这样比从头一行行啃效率高得多。
3. super_operations 回调集:VFS 怎么调用具体文件系统的实现
3.1 一组明确分工的回调函数
struct super_block里最关键的成员其实是s_op,它指向struct super_operations。这是 VFS 与具体文件系统之间的“接口约定”,文件系统通过实现这些回调把自己的能力暴露给 VFS,VFS 则尽量不感知具体实现。
我按调用时机整理了一份简表,方便对照源码看:
| 回调函数 | 调用时机 | 典型作用 |
|---|---|---|
s_alloc_inode | 分配 inode 时 | 分配包含文件系统私有数据的 inode 对象 |
s_dirty_inode | inode 被标记为脏时 | 让文件系统知道某个 inode 需要回写 |
s_write_inode | 写 inode 元数据时 | 将内存 inode 的元数据落盘 |
s_evict_inode | inode 从内存删除时 | 回收 inode 相关资源,截断文件等 |
s_sync_fs | sync 时 | 同步文件系统私有状态到磁盘 |
s_statfs | statfs 系统调用时 | 返回文件系统统计信息,df 的结果就在这里产生 |
s_put_super | 超级块销毁时 | 释放文件系统私有数据 |
s_remount_fs | remount 时 | 处理挂载选项变化 |
s_shutdown | 文件系统进入停机状态时 | 如检测到损坏后将文件系统标记为只读 |
理解这套回调有个大方向:文件操作每走一步,VFS 都在调用这些函数。比如ext4_file_open、xfs_fs_sync_fs,本质上都是s_op某个具体实现。
3.2 不同文件系统实现风格差异极大
一个让我印象很深的点是:不同文件系统的super_operations差别巨大。ext4 这类需要日志、配额、错误恢复的本地文件系统,s_op密密麻麻实现了一堆;而像proc、sysfs这类内存文件系统,很多回调是NULL或者极简实现。VFS 在调用前会做if (sb->s_op->xxx)判空,所以少实现几个也没关系。
也就是说,s_op本质上是一份“能力声明”。你实现得越完整,VFS 就能替你做得越多;你实现得越少,VFS 只能用最通用的逻辑兜底。我曾经为了给一个教学用的模拟文件系统写s_op,只实现了s_alloc_inode、s_evict_inode和s_statfs,其他全靠 VFS 的通用路径兜底,照样能跑通基本功能。这也反向印证了:读文件系统驱动代码时,第一件事就是去它的super_operations表里看它到底承诺了哪些能力。
4. 超级块生命周期:挂载到卸载全链路
4.1 分配前的 sget 与缓存复用
挂载一个文件系统远不止“读取磁盘超级块数据”那么简单。以常见的块设备文件系统为例,挂载路径大致是:
mount() -> path_mount() -> do_new_mount() -> fs_context_for_mount() -> vfs_get_tree() -> fc->ops->get_tree(fc) -> get_tree_bdev(fc) -> sget_fc(fc, set_bdev_super, compare_bdev_super)get_tree_bdev做的就是:先从现有超级块里找“同类型、同设备”的实例,找到就直接复用;找不到才调用sget_fc分配一个新的。之所以要先找,是为了避免同一块设备反复挂载导致内存里有多个互相打架的 superblock。你可以在/proc/mounts里把同一个/dev/sda1挂到两个目录,但内核里通常只有一个 VFS superblock,只是有两个mount实例挂在s_mounts上。
实际调试时,sget的比较函数是区分不同文件系统行为的重要一环。有的文件系统(如基于网络的)可能不按块设备比较,而是按服务器地址、导出路径比较;这时候同一个远程目录挂两次,就可能出现两个 superblock。记住这一点,排查“为什么这里有两个 superblock”时非常有用。
4.2 fill_super 与超级块状态转换
分配完空的 superblock 后,真正干活的函数是fill_super。以 ext4 为例,ext4_fill_super会从块设备偏移 1024 字节处读出磁盘超级块,做大量校验,然后把解析结果填进s_fs_info指向的私有结构,最后设置sb->s_op、sb->s_maxbytes、sb->s_root等字段。
这个过程如果出错,文件系统会返回一个负数错误码,VFS 需要在错误路径里做清理。我在这里踩过一个大坑:自己写学习用的模拟文件系统时,fill_super中途失败,我直接返回错误码,结果发现之前分配的s_fs_info泄漏了。正确做法是:任何在fill_super之前的分配,都要在错误路径里显式释放,或者依赖s_op->s_put_super在kill_sb时兜底。内核日志里常见的EXT4-fs (loop0): ...报错,就是这一阶段校验失败的输出。
fill_super成功之后,内核才把 superblock 状态从“正在初始化”切到“已激活”,然后挂载点才真正可用。所以你在dmesg里看到的文件系统错误,大半都发生在这个窗口内。
4.3 卸载、冻结与销毁:最容易泄漏的地方
卸载路径的关键函数是deactivate_super和kill_super。当最后一个挂载点被移除,s_active计数变成 0,内核会先调用文件系统的回写逻辑确保脏数据落盘,再调用s_op->put_super释放私有数据,最后在destroy_super里释放结构体本身。
这个流程里最典型的坑有两个。
第一个是异步任务没有取消。比如文件系统内部起了一个定期清理脏 inode 的 workqueue,如果put_super里没有cancel_work_sync,等 superblock 已经释放,那个任务再访问s_fs_info就会触发 use-after-free,表现是卸载后随机崩溃,且栈回溯很难看。
第二个是引用计数不平衡。任何人在自己的代码里调用了sb_get、get_super等增加s_count/s_active的操作,就必须在对应路径里调用deactivate_super或put_super。漏了会让 superblock 永远无法销毁,表现是umount后设备仍然 busy,/proc/mounts里看不到挂载,但内核上报device busy。遇到这种情况,我一般先去 grep 谁多拿了引用。
5. 脏超级块和落盘一致性:一次 sync 的完整旅程
5.1 “超级块脏”到底脏在哪
这是很多人最迷惑的一点。读了很多文档,总觉得 superblock 应该有个“脏标志”,定期回写磁盘。但现代内核里,VFS 超级块本身已经不太强调“单块超级块脏”的概念,早期内核里的s_dirt字段已经被删除。现在真正管落盘的是 writeback 子系统和具体文件系统自己的事务机制。
所以我的理解是:VFS 超级块更多是一个“调度入口”,具体哪些元数据要落盘、以什么顺序落盘,都由文件系统自己把握。ext4 有日志(jbd2),XFS 有日志,btrfs 有自己的事务,它们并不会因为 VFS 层说“同步一下”就把磁盘超级块整个重写一遍。大多数情况下,磁盘超级块只在挂载、卸载、或关键特性变更时才更新,其余时间保持只读。
这解释了为什么你会看到类似EXT4-fs: superblock last mount time is in the future这样的告警——它读到了磁盘超级块上的字段,发现时间戳不合理。磁盘超级块确实存在,但它不是每次文件操作都在变的“热数据”。
5.2 sync 系统调用触发路径
看sync(2)的完整旅程,最能理解 superblock 在一致性中扮演的角色。简化后的内核路径大致是:
ksys_sync() -> iterate_supers(sync_inodes_one_sb, NULL) // 第一遍:回写所有 inode -> iterate_supers(sync_fs_one_sb, NULL) // 第二遍:回写文件系统私有状态 -> sync_filesystem(sb) -> writeback_inodes_sb(sb, ...) -> if (sb->s_op->sync_fs) sb->s_op->sync_fs(sb, 0) -> if (sb->s_bdev) blkdev_issue_flush(sb->s_bdev)注意这里有两遍遍历:第一遍先回写 inode 数据,第二遍才调用文件系统的s_sync_fs。为什么要两遍?因为文件系统私有的元数据(比如块位图、inode 位图)可能依赖第一遍刚回写的 inode 状态,必须先确保 inode 落盘,再更新文件级元数据。
另外,blkdev_issue_flush很重要:它向块设备下发 flush 命令,确保先前的数据已经被物理设备真正写入非易失介质,而不仅仅是到达设备缓存。很多人跑完sync后立刻拔电还是丢数据,就是因为依赖了设备缓存,没有下刷。
5.3 实测观察:用 ftrace 看 sync 过程
我在测试环境里验证过完整的 sync 流程。步骤很简单:
mkdir -p /mnt/test mount -t ext4 /dev/loop0 /mnt/test # 打开 writeback 相关 tracepoint echo 0 > /sys/kernel/debug/tracing/tracing_on echo 1 > /sys/kernel/debug/tracing/events/writeback/writeback_start/enable echo 1 > /sys/kernel/debug/tracing/events/writeback/writeback_sb_inodes/enable # 制造脏数据 dd if=/dev/zero of=/mnt/test/foo bs=1M count=64 conv=fsync echo 0 > /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace | grep /mnt/test | head我看到的 trace 输出大致是:
writeback_start: bdi=loop0:0 sb_dev=0:0 inodes=... writeback_sb_inodes: sb_dev=0:0 ino=...注意sb_dev字段是一个(dev_t)的组合值,不是我们平时看到的8:1这种成熟格式,初看时容易懵。从这里能直观看到:一次fsync调用,最终会汇聚到超级块的回写逻辑,再由超级块决定回写哪些 inode。
这个实验也适合做教学:改挂载参数、调整 dirty 比例、模拟断电前只执行sync不执行设备 flush,观察数据丢失概率。当然实验要在测试环境做,别拿生产设备折腾。
5.4 崩溃一致性视角:为什么可以不完全依赖超级块脏回写
现代文件系统大量使用日志或写时复制(CoW)机制,目的就是不让磁盘超级块成为单点瓶颈。ext4 在格式化时会写入多个超级块副本,主超级块损坏时可以利用备份恢复;XFS 也有主超级块和备用超级块的冗余设计。
这些设计意味着,即使断电发生在超级块更新过程中,文件系统重启时也能通过备份、日志重放或校验和检测,把文件系统恢复到一致状态。这也是为什么“VFS 超级块不需要每次变动都写回磁盘”在工程上完全可行。理解到这一层,再去看sync的路径就不会觉得“它怎么只调了几个函数就完事”,因为真正的一致性保障是文件系统和块设备层共同承担的。
6. 排查问题时的三板斧:观察状态、复现崩溃、查找泄漏
6.1 用户态能看到的“影子”
用户态看不到 superblock 的完整结构,但能看到它的若干投影。/proc/mounts、/proc/self/mountinfo里能看到当前挂载类型和选项;df -T会调用statfs,实际上触发s_op->s_statfs;mount -o remount,ro会改变内存超级块的状态标志,这就是为什么 remount 后/proc/mounts里多了ro。
我遇到的一个实用技巧是:通过/proc/self/mountinfo的 mount ID 可以反查一个挂载点对应的超级块编号关系(内核提供/proc/self/mountstats之类的信息),虽然它不直接显示 sb 地址,但配合 ftrace 能定位到具体实例。如果你有 root 权限,crash工具在读内核内存时可以super_blocks链表遍历所有实例,再打印s_id、s_fs_info、s_count、s_active等字段,这是排查泄漏的利器。
6.2 内核态观察工具箱
首选 ftrace 的几个 tracepoint。在挂载和卸载文件系统时,events/fs下有关联的跟踪点,可以这样观察:
echo 1 > /sys/kernel/debug/tracing/events/fs/sb_alloc/enable echo 1 > /sys/kernel/debug/tracing/events/fs/sb_free/enable mount -t ext4 /dev/loop0 /mnt/test umount /mnt/test cat /sys/kernel/debug/tracing/trace如果内核编译了CONFIG_TRACEPOINTS,这一套就能用。配合trace-cmd record -e 'fs:*' -e 'writeback:*'可以更舒服地记录全过程。
另一条路是用 bpftrace 挂在alloc_super、kill_super这类函数上。比如:
bpftrace -e 'kprobe:alloc_super { printf("alloc sb: %p\n", arg0); }'注意这要求内核带调试符号,不同内核版本字段布局可能不同,打印字段前先pahole看一眼再写。
6.3 一个延迟任务导致超级块释放后崩溃的复盘
最后分享一个我在内部测试里遇到的典型案例,文件系统用一个虚拟代号“某网络文件系统”代替。
现象是:卸载挂载点后过了几十秒,系统日志里出现一次内核崩溃,栈回溯落在某网络文件系统_writeback_worker这个函数里,指向一个非法地址。从栈看,worker 在访问sb->s_fs_info里的一个链表,但那个地址已经被释放。
排查过程分三步。第一步,我确认umount时kill_super确实执行了,并且没有报错,说明s_op->put_super正常释放了私有数据;第二步,我通过 ftrace 确认sb_free发生的时间点;第三步,我在s_op->put_super和 worker 函数入口加上自定义打印,发现 worker 在sb_free之后仍然被调度起来。
根因很清晰:文件系统内部在挂载时启动了一个延迟回写任务,但put_super里只做了flush_work,没有处理delayed_work,任务在 countdown 结束后又跑了一遍。修复也很简单:在put_super和kill_sb里显式调用cancel_delayed_work_sync,并且在 worker 入口检查 superblock 状态标志,如果已进入销毁流程就直接返回。
这个案例提醒我一件事:凡是文件系统内部起了独立线程或延迟任务,必须把生命周期终点严格对齐到 superblock 释放之前。这个坑和具体文件系统无关,任何基于 superblock 的模块都可能碰到。
我在实际排查中还有一个小习惯:遇到 superblock 相关问题,先不看代码,先在 ftrace 里把sb_alloc、sb_free、writeback事件抓一遍,把时间线画出来。大多数问题在这个阶段就能看出端倪。读完整个机制之后你会发现,superblock 并没有那么神秘,它就是一个把所有文件系统对象统一挂起来的“总目录”。顺着生命周期、回调和状态转换三条主线走一遍,比单纯背字段有效得多。希望这篇笔记能帮你少走一点弯路。