Skip to content

第一百三十三章:块设备

合道期

涉及内核源码:

离开字符设备的领地后,林小源沿着石阶继续攀登。山路渐渐变得宽阔,脚下的碎石也变了模样——不再是零散的字节,而是一块块方方正正的石砖,每块石砖上都刻着编号。

"4096、8192、12288……"林小源蹲下来看了看,"每块石砖的大小都是 4K?"

"那是基本块大小。"守山老者说,"你已经进入了块设备的领地。"

林小源抬头望去,只见前方矗立着一座巨大的山峰,山体被切割成无数整齐的方块,像是一个巨型的魔方。山脚下排着一条长长的队列,无数请求在队列中等待,有的被合并在一起,有的被重新排序。

"那是什么?"林小源指着队列。

"请求队列。"一个低沉的声音从山体内部传来。

林小源吓了一跳。只见山峰表面的岩石缓缓裂开,露出一只巨大的眼睛。那眼睛瞳孔是银灰色的,像是磁盘的盘片。

"我是块设备的守卫。"山峰开口说话,声音像是磁头在寻道,低沉而有节奏,"你可以叫我 gendisk。所有块设备的注册,都要通过我。"

gendisk 向林小源解释了块设备和字符设备的根本区别。字符设备像溪流,数据一个字节一个字节地流;块设备像矿场,数据以块为单位,整块整块地挖出来。块设备的复杂之处在于,它有一套完整的 I/O 调度系统——请求不是来了就立刻执行,而是先进入队列,由调度器进行合并和排序。

"为什么要这么麻烦?"林小源问。

"因为磁盘寻道很慢。"gendisk 说,"如果请求乱序执行,磁头来回跳动,性能会很差。把相邻的请求合并、按顺序排列,磁头只需扫一遍,效率高得多。"

c
/*
 * 块设备驱动的关键结构:
 *
 * struct block_device_operations:
 *   open — 打开设备
 *   release — 关闭设备
 *   ioctl — 控制命令
 *   submit_bio — 提交 I/O 请求
 *
 * struct gendisk:
 *   通用磁盘结构
 *   包含设备号、名称、容量等
 *
 * struct request_queue:
 *   请求队列
 *   I/O 调度器在这里工作
 *
 * 块设备 vs 字符设备:
 *   字符设备: 字节流,直接读写
 *   块设备: 块为单位,有请求队列
 */

printf("=== 块设备 — 块为单位的访问 ===\n\n");

printf("块设备驱动:\n\n");

printf("--- 关键结构 ---\n");
printf("block_device_operations:\n");
printf("  open, release, ioctl\n");
printf("  submit_bio — 提交 I/O 请求\n\n");
printf("gendisk:\n");
printf("  通用磁盘结构\n");
printf("  设备号、名称、容量\n\n");
printf("request_queue:\n");
printf("  请求队列\n");
printf("  I/O 调度器工作的地方\n\n");

printf("--- 注册步骤 ---\n");
printf("1. 分配 gendisk:\n");
printf("   disk = alloc_disk(minors)\n\n");
printf("2. 设置 gendisk:\n");
printf("   disk->major = MAJOR;\n");
printf("   disk->fops = &my_fops;\n");
printf("   set_capacity(disk, size);\n\n");
printf("3. 设置请求队列:\n");
printf("   queue = blk_mq_init_queue(...);\n\n");
printf("4. 添加磁盘:\n");
printf("   add_disk(disk)\n\n");

printf("--- I/O 路径 ---\n");
printf("用户空间:\n");
printf("  read(fd, buf, 4096)\n");
printf("    ↓\n");
printf("VFS 层:\n");
printf("  提交 bio\n");
printf("    ↓\n");
printf("I/O 调度器:\n");
printf("  合并、排序请求\n");
printf("    ↓\n");
printf("驱动层:\n");
printf("  submit_bio()\n");
printf("    ↓\n");
printf("硬件:\n");
printf("  执行 I/O\n\n");

printf("--- 块设备 vs 字符设备 ---\n");
printf("字符设备:\n");
printf("  字节流\n");
printf("  直接读写\n\n");
printf("块设备:\n");
printf("  块为单位\n");
printf("  有请求队列\n");
printf("  I/O 调度\n");

"你说的 I/O 调度器,具体怎么工作的?"林小源蹲在请求队列旁,看着那些排队等待的请求。

队列中的请求形态各异——有的请求读取连续的块,有的请求跳跃很远。一个戴着调度器袖章的精灵在队列中穿梭,不断把相邻的请求合并在一起,把乱序的请求重新排列。

"你看,"gendisk 说,"队列里有三个请求:读块 100、读块 101、读块 50。如果不调度,磁头先去 100,再去 101,再跳回 50——来回折腾。调度器把 100 和 101 合并成一个请求,然后先执行 50,再执行 100-101——磁头只需扫一遍。"

"不同的调度器有不同的策略吧?"

"当然。"gendisk 说,"有的调度器追求公平——每个进程的请求都要被服务到。有的追求吞吐量——尽可能合并更多请求。有的追求延迟——让关键请求尽快执行。没有万能的调度器,要看你的场景。"

林小源看着调度器精灵忙碌的身影,心中暗叹——硬件和驱动之间,竟然还藏着这么一层精巧的优化机制。

"那这些请求的最小单位是什么?"林小源问。

"bio。"gendisk 说,"bio 是块 I/O 的基本原子。"

gendisk 从山体上取下一块半透明的水晶递给林小源。水晶内部浮现出一个数据结构——一个 bio,描述了要读写的起始块地址、块数量,以及数据缓冲区的位置。

"一个 bio 就是一次'要读哪里、写到哪里'的描述。"gendisk 说,"多个 bio 可以合并成一个请求。比如你要读连续的 10 个块,VFS 层可能会先生成 10 个 bio,然后调度器把它们合并成 1 个请求,一次性提交给驱动。"

林小源把 bio 水晶举到眼前,仔细观察。水晶中的数据结构清晰而精简——没有多余的字段,每一个都承载着必要的信息。

"bio 的设计很讲究。"gendisk 补充道,"它用 scatter-gather 列表来描述不连续的内存页——因为数据缓冲区可能不连续。这样即使内核内存碎片化,I/O 也能正常进行。"

林小源放下水晶,回头望了一眼山脚下那条长长的请求队列。从用户空间的一个 read() 调用,到 VFS 层的 bio,到调度器的请求队列,再到驱动的 ,最后到硬件执行——这条 I/O 路径上的每一环都不可或缺。


道藏笔记

内核启示

块设备跟字符设备最大的不同,就是它不直接把数据丢给你,而是先攒一批,排好队再统一处理。核心结构有三样:gendisk 描述磁盘本身,request_queue 管理排队的请求,block_device_operations 定义操作方式。数据从用户空间出发,经过 VFS、I/O 调度器、驱动,最后才到硬件——这条路比字符设备长了不少,但换来的是更高的吞吐量。调度器会把相邻的请求合并、按顺序排列,让磁头少跑冤枉路。所以说,块设备的复杂不是为了炫技,而是为了让磁盘这种慢设备尽量跑得快一点。


破关试炼

块设备之试

块设备最终仍要被文件系统路径访问,本章中承接上层读写请求的统一抽象层是什么?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道