很多人学 Linux 内核的时候最容易卡住的不是进程调度也不是内存管理反而是输入输出系统这块。原因很简单I/O 涉及的东西太杂了串行设备、块设备、网络设备各有各的脾气而且用户态的命令——mknod、mount、open——看起来那么简单背后却牵着一长串内核路径。尤其是块设备很多同学搞不清楚一个问题我执行完 mknod 之后这个“设备文件”到底是怎么和真正的硬盘扯上关系的为什么有的设备我用 open 能打开有的设备却连 open 都过不去mount 的时候内核又是怎么知道这块盘上是什么文件系统的这篇文章把块设备这条线的三个关键环节——mknod、打开流程、mount 流程——从头到尾梳理一遍。适合正在啃内核源码的初学者也适合那些把 /dev 当纯文件系统看待、想深入理解“设备即文件”的开发者。读完你会清楚一个块设备从“被创建出节点”到“被用户打开”再到“被挂载成目录”每一步分别触发了哪些机制失败时又会卡在哪个环节。1. mknod 那双看不见的手设备节点背后的两个数字先从一个最基础的问题开始mknod 到底做了什么很多资料会说“创建一个设备文件”这个说法没错但容易让人误解成“创建了一个设备”。实际上mknod 和普通文件的创建比如 touch 出来的空文件一样都是在某个目录里创建一个目录项并关联一个 inode只不过这个inode 的类型是设备文件并且 inode 里记录的不是文件数据而是主设备号和次设备号。1.1 主次设备号是怎么来的设备号就是设备在内核里的“门牌号”。主设备号用来区分设备的种类和对应的驱动程序次设备号用来区分同一种类下的不同实例。比如你的机器里有两块同型号的 NVMe 固态硬盘它们的主设备号一样次设备号不同。设备号的分配有两种方式静态分配驱动开发者手动指定一个主设备号然后在模块加载时用register_blkdev(major, name)注册。动态分配调用register_blkdev(0, name)让内核帮你挑一个没被占用的主设备号。那这个设备号是用什么数据结构存下来的内核里定义了一个dev_t类型本质上是一个 32 位无符号整数。高 12 位是主设备号低 20 位是次设备号。也就是说主设备号最大能到 4095次设备号最大能到 1048575。你用mknod /dev/foo b 8 0这样的命令时命令行里那两个数字最终会被包装成一个dev_t。如果用一句话概括 mknod 的语义mknod 是在 VFS 层创建了一个特殊类型的 inode这个 inode 的 i_mode 标记了它是块设备还是字符设备i_rdev 存了设备号仅此而已。它根本不关心这个设备号背后有没有真实的硬件。你完全可以 mknod 一个设备号根本没人认领的节点类型照样是块设备但真正 open 的时候就会失败。1.2 驱动和设备节点是怎么建立联系的这是理解整个流程的关键。很多文章一上来就讲 open却忽略了最基础的联系机制。驱动加载成功之后会调用register_blkdev把它对应的设备号纳入到一个全局表中。这个表的核心是bdev_map说白一点就是一套从 dev_t 到具体 gendisk 和分区信息的索引机制。真正把“设备节点”和“驱动”绑到一起的是 open 设备文件的时候通过设备号去查找对应的block_device结构。注意顺序节点在哪里不重要设备号才是关键。我自己在学这一块的时候有一个很深的体会你可以把一个块设备节点创建在任何目录下甚至叫任何名字只要设备号对驱动就能找到它。反过来如果你的设备节点创建在/dev下但设备号写错了哪怕名字和真实设备一字不差打开也会失败。所以很多 initramfs 脚本或者 docker 容器里经常出现“节点存在但设备不存在”的诡异场景根子就是设备号对不上。1.3 现代系统里 mknod 被谁替代了现在的主流发行版上你很少会手动执行mknod /dev/sda b 8 0这样的命令因为devtmpfs和udev把这件事接管了。内核在初始化块设备和检测到新设备时会自动在 devtmpfs 挂载点上创建设备节点用户态的 udev 再根据规则做权限设置、符号链接创建和事件通知。但这不意味着 mknod 没用。你去看内核源码里的工具链、容器场景、嵌入式系统开发mknod 依然是直接与设备节点交互的兜底手段。我自己的经验是理解 mknod 的本质比背命令参数更重要因为后续的 open 和 mount 流程全是建立在这个基础之上的。2. open(/dev/xxx) 在内核里走的一条完整路径设备节点创建好不等于你能直接读数据。从用户态执行open(/dev/sdb1, O_RDONLY)开始内核要经过一整套 VFS 路径查找和打开逻辑。这条路比较长但我们可以把它拆成三块来理解。2.1 从路径到 dentry 再到 inodeopen系统调用进入内核后会调用do_sys_open然后进入路径查找阶段也就是path_openat。这一段的逻辑对所有文件系统都是一样的从根目录或当前目录开始逐级解析路径分量找到目标文件的 dentry 并获取 inode。关键在于当最后解析到sdb1这个分量时VFS 发现这是一个块设备文件inode 的i_mode里有S_IFBLK的标志位。这里有个很微妙的地方块设备文件的 inode 并不是从磁盘上读出来的而是由 devtmpfs或者你在其他文件系统上手动 mknod在内存里构造出来的。它没有文件数据也不占磁盘空间它的“内容”本身就是设备号。路径查找完成后complete_walk和open_last_lookups会把这个 inode 准备好下一步是打开它。2.2 do_dentry_open 如何决定用哪套 fops这是块设备打开流程里最核心的一步。do_dentry_open会根据 inode 的类型选择不同的文件操作集合赋值给最终返回给用户态的struct file结构体中的f_op字段。对于块设备文件这个 f_op 是def_blk_fops。你可能想问那到底是这个 f_op 里的哪个函数真正去触达硬件其实打开一个块设备文件和打开一个普通文件最大区别在于它需要拿到这个设备对应的block_device描述符并且做一次“绑定”动作。这一步的详细路径可以这样看blkdev_open-blkdev_get_by_dev-bd_acquire-bd_prepare_to_claim- 最终在执行block/open.c里的逻辑时会找到这个 dev_t 对应的gendisk和里面的分区表信息。这里面的一个关键概念是claim 机制。块设备是共享资源内核必须防止出现多个互斥的使用者同时操作设备导致数据混乱。你打开一个块设备的时候会尝试“声明”对这个设备的使用权。如果有人已经以互斥方式占用了这个设备比如它已经被挂载bd_prepare_to_claim会拒绝你的请求导致 open 失败返回 EBUSY。2.3 打开成功和失败的常见临界点基于上面的流程open 失败的几种典型情况就很清楚了设备节点存在但没有对应驱动注册blkdev_get_by_dev找不到对应的 gendisk返回 ENXIO。设备号写错同样找不到 gendisk。设备已经被独占最典型的是被文件系统挂载或者被某个 dm 设备映射持有返回 EBUSY。权限不足块设备节点在 devtmpfs 里的默认权限是 660组是 disk如果你不在那个组里open 直接 EACCES。我在某个项目里遇到过一种非常隐蔽的情况设备节点存在、驱动也加载了但 open 仍然报 ENXIO。排查了很久才发现是驱动在probe阶段创建分区时分区设备的 dev_t 和节点里的不一致用户态拿到的是旧节点。后来把节点和自动探测到的设备号核对一遍才定位到问题。2.4 为什么块设备可以用 read/write 读写可能有同学会好奇既然块设备最终读写的是扇区那 open 之后我能用 read/write 直接读一个扇区吗答案是可以逻辑上def_blk_fops里有read_iter和write_iter的实现VFS 层会通过块设备的submit_bio把读写的范围转换成对应的 bio 请求提交给块设备层。不过直接对一个块设备做读写有诸多限制比如需要对齐、要考虑分区起始偏移这些话题展开又是一大篇这里点到为止。3. mount 流程从块设备到可访问目录树的跨越open 一个块设备是为了“操作”它而mount一个块设备是为了把它上面的文件系统“接入”到当前系统的目录树中来。mount 流程在逻辑上可以描述成读取块设备上的文件系统元数据创建一个超级块对象然后把这个超级块挂到某个目录的 dentry 上。这一节我们围绕mount /dev/sdb1 /mnt/data这个经典场景来拆解。3.1 mount 系统调用的入口与参数解析当你执行mount -t ext4 /dev/sdb1 /mnt/data时用户态的 mount 工具会先做一些自己的检查比如 /mnt/data 是否存在然后调用mount(2)系统调用。内核入口do_mount会解析各个参数包括设备路径、挂载点路径、文件系统类型、挂载标志、以及可选的数据字符串。接下来是path_mount它分为几大步先解析挂载点目录的路径再根据文件系统类型找到对应的文件系统驱动最后调用这个驱动去识别和读取设备上的文件系统。在那之后do_new_mount会被调用。如果文件系统类型是像 ext4 这样的传统块文件系统挂载过程会走get_tree_bdev这条路。这条路的核心逻辑听起来很简单但实际有丰富的细节在里面打开块设备、读取超级块、检查标志位。3.2 get_tree_bdev打开、占用、读超级块get_tree_bdev的关键步骤是通过设备路径找到block_device这一步本质上是调用我们之前讲过的blkdev_get_by_path。也就是说mount 的第一步和 open 设备文件走的几乎是同一条路。对这个block_device做独占声明。内核用bd_may_claim检查这个块设备是否已经被挂载或者已经被其他方式占用。如果设备已经被挂载到 A 目录你再去把它 mount 到 B 目录这里就会失败。除非你用了--bind之类的特殊机制否则一个块设备同一时刻只能被一个文件系统挂载。拿到设备后内核会读取设备开头的数据解析成struct buffer_head交给对应的文件系统驱动进行识别比如ext4_fill_super会读取 ext4 的超级块并做一系列一致性检查。超级块被成功初始化后sget会把新的超级块加入全局超级块链表这样后续所有文件系统操作open、read、write都能通过超级块定位到具体设备。这里要特别提醒一个概念mount 的“打开”和前面用户态 open 是完全不同的两个动作。用户态 open 一个块设备并不代表你有权把文件系统挂到目录树上反过来mount 会打开设备但不会把这个“打开”的状态传递给用户态的某个文件描述符。它们的共同点是都走到了blkdev_get_by_*这一层区别在于后续的处理。3.3 文件系统类型的识别瞎猜还是精确匹配一个很实际的问题是mount /dev/sdb1 /mnt/data时如果不指定-t ext4或者指定错了内核如何知道设备上是哪种文件系统大多数文件系统驱动实现了mount回调里面会调用get_tree_bdev然后尝试对自己的超级块魔数进行匹配。如果你指定的类型和设备上的真实类型不匹配典型的报错是“unknown blocksize”或者“wrong fs type”。如果你不指定类型mount 工具会调用fsck之类的辅助程序或者直接逐个尝试内核里注册的内核驱动直到某个驱动能成功读取超级块为止。这类“探测式”匹配在设计上不可避免地有副作用。比如一个设备上恰好有一段数据它的特定偏移量在数值上碰巧和某种文件系统的魔数一致那么这种探测就可能导致误判。好在这种情况在现代系统已经非常少见了因为大多数设备和分区都会有更复杂的布告栏信息可参考。3.4 挂载完成后 VFS 的视图如何变化挂载完成的瞬间VFS 会在全局的 mount 树中加入一条新的vfsmount记录并把它挂到挂载点的 dentry 下。这时候你ls /mnt/data看到的就是块设备上的文件系统内容而不是原本 /mnt/data 目录里已有的文件了。这个“原有文件被遮挡”的效应排障的时候最容易踩到——你去查一个明明挂载成功的目录却发现看不到数据先想想是不是目录里原本就有内容被遮挡了。超级块的引用计数、设备的引用计数在 mount 和 umount 时都会同步增减。你执行umount时内核会调用kill_sb对块设备执行释放操作这一对一出的闭合逻辑是整个 I/O 子系统稳定性的基石。4. 三套流程串起来时最容易踩的坑前几节分别讲了 mknod、open、mount但实际项目中它们总是组合出现的。单独看每一步逻辑都很顺畅一旦连起来问题就变得隐蔽了。我收集了几个真实的踩坑案例顺着这个链路一起温习一遍。4.1 坑位一节点在设备却不在最经典的情况是你手动执行了mknod /dev/mydisk b 8 20但驱动还没加载或者驱动注册的设备和 8:20 对不上。这时候open会返回 ENXIOmount也会直接提示找不到设备。这类问题的排查思路是去/proc/partitions或者sysfs中确认真实设备号然后重建节点。从 mknod 的角度看这个坑提醒我们一件事mknod 只保证节点存在不保证设备可用。节点就是一个“门牌”门牌挂了不代表房子里有人。4.2 坑位二mount 失败提示 device busymount 失败有时候很奇怪特别是你明明没有手动打开设备也没挂载过它却收到device is busy。原因可能有几种某个进程确实打开过该设备节点并且没有关闭 —lsof查一下就知道了。设备被lvm2、multipath之类的设备映射层接管底层块设备的占有权已经不在常规文件系统手里。文件系统被异常卸载后某些内核线程仍然持有引用。这个坑在容器环境里尤其常见宿主机挂载了一个目录容器里又基于同一设备跑了一个绑定挂载umount 时因为存在另一层引用而失败。4.3 坑位三权限和组策略的刁难devtmpfs 创建的块设备节点默认属于disk组权限是660。如果是普通用户去 open 它十有八九会撞上EACCES。这时候不是节点问题不是设备问题而是节点的 ACL 权限问题。解决方法要么把用户加进 disk 组要么给设备节点配置一条 udev 规则设置更宽松的权限。这个坑值得展开一点。很多人只在普通文件上熟悉rwx的概念但设备节点的权限同样受标准的 inode 权限位约束。读权限对应的是O_RDONLY能打开写权限对应O_WRONLY能打开。也就是说一个小权限的变化就可能让 open 失败。我在调试某个嵌入式板子的 SD 卡设备时就遇到过因为 devtmpfs 没有正确挂载导致设备节点权限全部是 600结果所有用户态工具读不到磁盘内容的怪事。4.4 坑位四设备号的动态分配与节点固定写法不兼容如果你驱动里用的是register_blkdev(0, name)动态分配主设备号那你写死在脚本里的mknod /dev/mydisk b 240 0就非常脆弱主设备号每次加载模块可能都不一样。动态分配的价值是避免和现有设备冲突代价就是节点和设备号不能预先硬编码。常规项目里使用动态分配时通常配合/sys/class/.../dev这个 sysfs 属性读出实际设备号再用mknod去动态生成节点。这一步因为太琐碎而特别容易被忽略直到某一次重启后模块加载顺序变了系统直接起不来才会意识到手动 mknod 的行为有多么脆弱。5. 顺着 devtmpfs 和 udev 看自动化设备的注册全流程传统的 mknod 是手工过程而现代系统里大部分情况是 udev 在后台帮我们做了这些事。从内核的角度回溯整个流程能看到三条路径是如何被自动化的内核在探测到新块设备时先把设备对象注册到 sysfs 并在 devtmpfs 上自动生成节点内核同时会向用户态发送 ueventudev 收到事件后读取 sysfs 里的设备信息应用规则决定权限和符号链接。理解这个自动化过程之后你会发现之前手动学的那套 mknod 和 mount其实是大系统里的一个组成部件。设备管理已经从“程序员手动介入”演化成了“内核发现事件 - 用户态规则处理”的模式。这个模式虽然好用但也引入了新的坑如果 udev 规则写错节点就会消失或指向错误的设备。我自己碰到过最离谱的情况是自定义 udev 规则里用到了ATTR{size}...结果同一型号的磁盘因为容量批次不同始终匹配不上节点一直没被重命名。那次折腾了很久才发现不是 mknod 的问题而是 udev 的匹配逻辑和硬件参数强相关。6. 把 IO 子系统的连接点变成自己的知识地图如果你把 mknod、open、mount 这三个话题连起来会发现它们共享了大量底层基础设施设备号只是数字但所有访问都建立在它之上打开设备要过 VFS 和 claim 机制挂载文件系统要先打开设备再做超块读取和挂载树安装。这恰恰是块设备 IO 子系统的整体逻辑骨架。实践上有几个可以顺手用的命令组合查看设备号ls -l /dev/sda和cat /sys/class/block/sda/dev查看谁占用了设备lsof /dev/sda和fuser -v /dev/sda查看内核是否识别了设备cat /proc/partitions精确找到 sysfs 对应的设备文件udevadm info -a -n /dev/sda排查顺序我一般是这样先确认设备在内核中的编号再确认节点是否对应再确认占用情况最后才去看文件系统本身。这个顺序可以把大量没头没尾的错误快速定位到某一个具体环节。块设备的学习就是这样看起来就三个点路由却很长。但你在自己机器上多敲几遍 strace 和内核 tracepoint把每一步输出和这里讲的对应起来一次下来这套链路基本就长在你脑子里了。
阅读完成 · 觉得有帮助?