第一百三十三章:块设备
合道期涉及内核源码:
一
离开字符设备的领地后,林小源沿着石阶继续攀登。山路渐渐变得宽阔,脚下的碎石也变了模样——不再是零散的字节,而是一块块方方正正的石砖,每块石砖上都刻着编号。
"4096、8192、12288……"林小源蹲下来看了看,"每块石砖的大小都是 4K?"
"那是基本块大小。"守山老者说,"你已经进入了块设备的领地。"
林小源抬头望去,只见前方矗立着一座巨大的山峰,山体被切割成无数整齐的方块,像是一个巨型的魔方。山脚下排着一条长长的队列,无数请求在队列中等待,有的被合并在一起,有的被重新排序。
"那是什么?"林小源指着队列。
"请求队列。"一个低沉的声音从山体内部传来。
林小源吓了一跳。只见山峰表面的岩石缓缓裂开,露出一只巨大的眼睛。那眼睛瞳孔是银灰色的,像是磁盘的盘片。
"我是块设备的守卫。"山峰开口说话,声音像是磁头在寻道,低沉而有节奏,"你可以叫我 gendisk。所有块设备的注册,都要通过我。"
gendisk 向林小源解释了块设备和字符设备的根本区别。字符设备像溪流,数据一个字节一个字节地流;块设备像矿场,数据以块为单位,整块整块地挖出来。块设备的复杂之处在于,它有一套完整的 I/O 调度系统——请求不是来了就立刻执行,而是先进入队列,由调度器进行合并和排序。
"为什么要这么麻烦?"林小源问。
"因为磁盘寻道很慢。"gendisk 说,"如果请求乱序执行,磁头来回跳动,性能会很差。把相邻的请求合并、按顺序排列,磁头只需扫一遍,效率高得多。"
/*
* 块设备驱动的关键结构:
*
* 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");#include <stdio.h>
/*
* 块设备驱动的关键结构:
*
* struct block_device_operations:
* open — 打开设备
* release — 关闭设备
* ioctl — 控制命令
* submit_bio — 提交 I/O 请求
*
* struct gendisk:
* 通用磁盘结构
* 包含设备号、名称、容量等
*
* struct request_queue:
* 请求队列
* I/O 调度器在这里工作
*
* 块设备 vs 字符设备:
* 字符设备: 字节流,直接读写
* 块设备: 块为单位,有请求队列
*/
int main() {
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");
return 0;
}二
"你说的 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 调度器、驱动,最后才到硬件——这条路比字符设备长了不少,但换来的是更高的吞吐量。调度器会把相邻的请求合并、按顺序排列,让磁头少跑冤枉路。所以说,块设备的复杂不是为了炫技,而是为了让磁盘这种慢设备尽量跑得快一点。
块设备之试
块设备最终仍要被文件系统路径访问,本章中承接上层读写请求的统一抽象层是什么?