Linux设备管理实验解析
阅读指南:本文档按”知识依赖链”顺序组织。建议按章节顺序阅读:先理解内核模块生命周期(实验1),再理解总线设备模型(实验2),最后理解 I/O 路径与性能(实验3)。每个代码片段都配有”这段代码扩展你哪个编程边界”的分析。
1. 实验总体架构与知识依赖图
本实验围绕 “Linux 如何管理设备” 这一核心问题,从三个维度切入:
1
2
3
4
5
6
7
8
9
10
11
实验1:字符设备驱动
└─ 问题:用户程序如何和内核硬件交互?
└─ 回答:通过 VFS → file_operations → 驱动函数 的分发机制
实验2:USB 热插拔驱动
└─ 问题:设备插入时,内核如何自动找到匹配的驱动?
└─ 回答:通过 总线-驱动-设备 三元组 + ID 表匹配机制
实验3:存储 I/O 性能测试
└─ 问题:内核态和用户态读写磁盘,速率有何不同?为什么?
└─ 回答:系统调用切换、VFS 层开销、Page Cache 差异
三个实验的层次关系:
1
2
3
4
5
6
7
8
9
10
11
12
用户空间 (Ring 3)
├─ test_app.c ── 通过 open/read/write 系统调用访问 /dev/mydev
├─ iozone ── 用户态 I/O 基准测试工具
└─ 普通程序
────────────── 系统调用边界 (syscall) ──────────────
内核空间 (Ring 0)
├─ VFS 层 ── 统一的文件接口抽象
├─ 字符设备框架 ── 实验1:my_char_dev.ko
├─ USB 子系统 ── 实验2:usb_detect.ko
└─ 块设备 I/O 路径 ── 实验3:filp_open/kernel_write
2. 实验1:Hello World 内核模块
2.1 内核模块的生命周期机制
问题动机:Linux 内核是一个运行中的程序,不能停下来重新编译。但硬件是动态变化的(插块网卡、接个USB),怎么办?
朴素方案及其局限:把所有驱动都编译进内核 → 内核镜像巨大,且增加硬件时必须重新编译内核,不可接受。
核心思想:将驱动编译为独立的 .ko(Kernel Object)文件,在运行时动态加载/卸载,像插件一样。
生命周期状态机:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[未加载]
│
│ sudo insmod xxx.ko
▼
[加载中] ──── 调用 module_init 指定的函数
│ │
│ 成功返回 0 失败返回负数
▼ ▼
[运行中] [未加载] (加载失败)
│
│ sudo rmmod xxx
▼
[卸载中] ──── 调用 module_exit 指定的函数
│
▼
[未加载]
关键约束:
module_init函数必须返回 0 表示成功,返回负数(如-ENOMEM)表示失败module_exit函数没有返回值,卸载是不可失败的- 模块运行在 Ring 0,任何崩溃(NULL 解引用、除零)都是 Kernel Panic
2.2 代码精读:hello_module.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <linux/init.h> // 包含模块初始化和清理宏
#include <linux/module.h> // 包含加载模块所需的函数和符号定义
#include <linux/kernel.h> // 包含内核常用函数,如 printk
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Student");
MODULE_DESCRIPTION("A Simple Hello World Module");
static int __init hello_init(void) {
printk(KERN_INFO "Hello Module: 模块已加载!\n");
return 0;
}
static void __exit hello_exit(void) {
printk(KERN_INFO "Hello Module: 模块已卸载,再见!\n");
}
module_init(hello_init);
module_exit(hello_exit);
逐行深度解析:
__init 和 __exit 宏
1
2
static int __init hello_init(void) { ... }
static void __exit hello_exit(void) { ... }
__init是一个 section 属性宏,展开为__attribute__((__section__(".init.text")))- 它告诉链接器:把这个函数放进 ELF 文件的
.init.text节 - 工程意义:模块加载完成后,
.init.text节的内存会被内核释放回收,因为 init 函数只运行一次 __exit同理,如果模块被编译进内核(非.ko形式),__exit函数会被完全丢弃
关键洞察:
__init/__exit是 Linux 的内存优化 trick。这是”一次性代码”的工程模式——用完即弃,节省内核内存。
printk vs printf
1
printk(KERN_INFO "Hello Module: 模块已加载!\n");
| 特性 | printf(用户态) | printk(内核态) |
|---|---|---|
| 输出目标 | stdout(终端/文件) | 内核环形缓冲区 (ring buffer) |
| 查看方式 | 直接看终端 | dmesg 命令读取 |
| 日志级别 | 无 | KERN_EMERG/ALERT/ERR/WARNING/INFO/DEBUG |
| 是否阻塞 | 可能阻塞 | 通常非阻塞(lockless ring buffer) |
| 格式字符串 | 完整支持 | 不支持所有格式符(%f 需谨慎) |
KERN_INFO 是一个字符串宏,值为 "\001" "6",紧贴在格式字符串前面(不是逗号分隔),这是 C 字符串字面量拼接。
MODULE_LICENSE("GPL")
- 不仅是声明,更是功能开关
- 只有声明 GPL 兼容许可证,模块才能使用内核导出的 GPL-only 符号
- 否则加载时出现 “kernel tainted” 警告,某些 API 无法调用
2.3 代码精读 Makefile(内核构建系统)
1
2
3
4
5
6
7
8
9
10
obj-m += hello_module.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
这个 Makefile 的精妙之处:
1
2
3
4
5
6
7
8
9
10
11
12
13
make
│
├─ 第一次调用:make -C $(KDIR) M=$(PWD) modules
│ │
│ └─ 切换到内核源码目录(KDIR)
│ │
│ └─ 内核 Kbuild 系统被激活,KERNELRELEASE 变量被设置
│ │
│ └─ Kbuild 看到 M=$(PWD),回到我们的目录
│ │
│ └─ 读取我们的 Makefile,看到 obj-m += hello_module.o
│ │
│ └─ 编译 hello_module.c → hello_module.ko
为什么要这么迂回? 因为 .ko 文件必须用和当前内核完全一致的头文件和配置来编译。内核 Kbuild 系统负责提供这些上下文(GCC 参数、CONFIG_* 宏等)。你不能简单地 gcc hello_module.c -o hello_module.ko。
obj-m 的含义:
obj-m= “object, module”,告诉 Kbuild 编译成外部模块(.ko)- 对比:
obj-y= 编译进内核镜像本身
2.4 编程边界扩展分析
| 边界 | 之前的认知 | 实验1 拓展到 |
|---|---|---|
| 程序生命周期 | main 函数入口/return 出口 | 模块有独立的 init/exit 钩子,可以在运行时装卸 |
| 打印调试 | printf 到终端 | printk 到内核 ring buffer,需用 dmesg 查看 |
| 编译系统 | gcc file.c -o file | 内核 Kbuild 系统,必须借用内核编译上下文 |
| 链接节(section) | 不关心 ELF 节 | __init/__exit 控制代码所在节,影响内存回收 |
| 权限模型 | 用户态程序 Ring 3 | 内核模块 Ring 0,无保护边界,任何错误即崩溃 |
3. 实验1:字符设备驱动框架
3.1 问题动机:为什么需要”设备文件”抽象?
历史问题:硬件种类繁多,每种硬件的操作方式完全不同。如果每个应用程序都要直接操作硬件寄存器,代码将极度混乱且不可移植。
Unix 的天才解法:一切皆文件(Everything is a file)
1
2
3
4
5
6
7
应用程序只需要知道:
open("/dev/mydev", O_RDWR)
write(fd, data, len)
read(fd, buf, len)
close(fd)
内核负责将这些调用路由到正确的驱动函数。
这就是 VFS(Virtual File System,虚拟文件系统) 的核心价值:提供统一接口,屏蔽差异。
3.2 核心机制:VFS → file_operations → 驱动函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
用户程序
│ write(fd, "hello", 5)
▼
系统调用层 (sys_write)
│ 通过 fd 找到对应的 struct file
▼
VFS 层
│ 调用 file->f_op->write(...)
▼
file_operations 结构体(驱动注册的函数指针表)
│ .write = device_write
▼
驱动函数 device_write()
│ copy_from_user(kernel_buf, user_buf, len)
▼
硬件 / 内核缓冲区
file_operations 是整个字符设备驱动的核心数据结构,本质是一张函数指针表(vtable),这是面向对象的 C 语言实现——接口与实现分离。
3.3 代码精读:my_char_dev.c(驱动侧)
全局状态设计
1
2
3
4
5
6
#define DEVICE_NAME "mydev"
#define BUF_LEN 1024
static int major_num;
static char kernel_buffer[BUF_LEN];
static int msg_size;
注意:这是一个极简驱动,用静态全局变量存数据。
工程警告:真实驱动不应该用全局静态缓冲区。如果同时有多个设备实例(如两块同型号网卡),全局状态会造成数据竞争。真实驱动会用
kmalloc分配每设备(per-device)状态结构体。
打开与关闭设备
1
2
3
4
5
6
7
8
9
static int device_open(struct inode *inode, struct file *file) {
printk(KERN_INFO "MyDev: 设备已打开\n");
return 0;
}
static int device_release(struct inode *inode, struct file *file) {
printk(KERN_INFO "MyDev: 设备已关闭\n");
return 0;
}
inode vs file 的区别(重要!):
1
2
3
4
5
6
7
8
9
inode(索引节点)
├─ 表示文件系统中的一个"文件实体"
├─ 包含:设备号、权限、大小等元数据
└─ 一个 inode 对应磁盘上一个文件,永久存在
struct file(文件描述符在内核的表示)
├─ 表示一次"打开"操作的状态
├─ 包含:当前偏移量 f_pos、标志位 f_flags
└─ 每次 open() 创建一个新的 struct file,close() 销毁它
如果两个进程打开同一个设备,它们共享同一个 inode,但各有自己的 struct file(不同的偏移量)。
read 函数——内核到用户的数据流
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static ssize_t device_read(struct file *filp, char __user *buffer,
size_t length, loff_t *offset) {
int bytes_read = 0;
// 偏移量检查:如果已读完,返回 0 表示 EOF
if (*offset >= msg_size) {
return 0;
}
// 防止读取超出已有数据的范围
if (length > msg_size - *offset) {
length = msg_size - *offset;
}
// ★ 核心:内核空间 → 用户空间的安全拷贝
if (copy_to_user(buffer, kernel_buffer + *offset, length) != 0) {
return -EFAULT;
}
*offset += length;
bytes_read = length;
printk(KERN_INFO "MyDev: 读取了 %d 字节\n", bytes_read);
return bytes_read;
}
loff_t \*offset 的作用:
这是 VFS 维护的”文件当前位置”。每次 read() 后更新,下次 read() 从新位置继续。这就是为什么多次 read() 能逐段读取文件的原因。
1
2
3
第一次 read(fd, buf, 10) → offset: 0 → 10,读取字节 0-9
第二次 read(fd, buf, 10) → offset: 10 → 20,读取字节 10-19
第三次 read(fd, buf, 10) → offset >= msg_size → 返回 0 (EOF)
为什么返回 0 而不是错误? POSIX 规定:read() 返回 0 表示文件结束(EOF),负数才是错误。应用程序通过判断返回值是否为 0 来知道文件读完了。
write 函数——用户到内核的数据流
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static ssize_t device_write(struct file *filp, const char __user *buffer,
size_t length, loff_t *offset) {
// 防止缓冲区溢出(工程安全实践)
if (length > BUF_LEN - 1) {
length = BUF_LEN - 1;
}
// ★ 核心:用户空间 → 内核空间的安全拷贝
if (copy_from_user(kernel_buffer, buffer, length) != 0) {
return -EFAULT;
}
kernel_buffer[length] = '\0';
msg_size = length;
printk(KERN_INFO "MyDev: 写入了 %zu 字节: %s\n", length, kernel_buffer);
return length;
}
BUF_LEN - 1 而非 BUF_LEN:为 '\0' 结尾留出一个字节,防止后面 printk 时字符串越界。
%zu 格式符:size_t 是无符号整数,在 32 位系统上是 uint32_t,64 位上是 uint64_t。用 %zu 才是类型安全的,用 %d 在 64 位系统上会截断。
file_operations 结构体——函数指针表
1
2
3
4
5
6
7
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = device_open,
.release = device_release,
.read = device_read,
.write = device_write,
};
这是 C99 指定初始化器(Designated Initializer)语法,按字段名赋值,顺序无关。
THIS_MODULE 是一个宏,展开为当前模块的 struct module * 指针。设置 .owner 是为了让 VFS 在文件被打开时对模块执行引用计数(try_module_get),防止模块在文件还打开着的情况下被卸载。
设备注册与注销
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static int __init mydev_init(void) {
// 0 表示让内核自动分配主设备号
major_num = register_chrdev(0, DEVICE_NAME, &fops);
if (major_num < 0) {
printk(KERN_ALERT "MyDev: 注册失败,错误码 %d\n", major_num);
return major_num;
}
printk(KERN_INFO "MyDev: 注册成功,主设备号是 %d\n", major_num);
printk(KERN_INFO "MyDev: 请运行命令创建节点: sudo mknod /dev/%s c %d 0\n",
DEVICE_NAME, major_num);
memset(kernel_buffer, 0, BUF_LEN);
return 0;
}
static void __exit mydev_exit(void) {
unregister_chrdev(major_num, DEVICE_NAME);
printk(KERN_INFO "MyDev: 模块已卸载\n");
}
设备号的含义:
1
2
3
4
5
6
设备号 = 主设备号(Major) : 次设备号(Minor)
│ │
│ 标识"用哪个驱动" │ 标识"哪个具体设备"
│ │
│ 例:硬盘驱动 = 8 │ sda=0, sdb=16, sdc=32...
│ 例:字符终端驱动 = 4 │ tty0=0, tty1=1, tty2=2...
为什么需要手动 mknod?
register_chrdev 只是在内核的设备号表里注册了一个条目,并没有在文件系统中创建 /dev/mydev 文件。mknod 才是在 VFS 中创建这个”入口点”(设备节点)。现代内核使用 udev 守护进程自动创建设备节点,但我们的简单驱动没有集成 udev 通知机制。
3.4 代码精读:test_app.c(用户态侧)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/ioctl.h>
#define DEVICE_PATH "/dev/mydev"
int main() {
int fd;
char write_buf[] = "Hello Kernel, this is User Space!";
char read_buf[1024];
// 1. 打开设备——和打开普通文件完全一样
fd = open(DEVICE_PATH, O_RDWR);
if (fd < 0) {
perror("[APP] 打开设备失败");
return -1;
}
// 2. 写入数据
int ret = write(fd, write_buf, strlen(write_buf));
if (ret < 0) {
perror("[APP] 写入失败");
close(fd);
return -1;
}
// 3. 关闭再重新打开(重置 offset)
close(fd);
fd = open(DEVICE_PATH, O_RDWR);
// 4. 读取数据
ret = read(fd, read_buf, sizeof(read_buf));
if (ret < 0) {
perror("[APP] 读取失败");
close(fd);
return -1;
}
printf("[APP] 收到内核数据: \"%s\"\n", read_buf);
close(fd);
return 0;
}
为什么 write 之后要 close 再 open?
1
2
3
4
5
6
7
8
9
10
write(fd, data, len)
└─ 驱动 device_write 被调用
└─ kernel_buffer 里存了数据
└─ *offset 被更新为 len(但我们的简单驱动 write 没有更新 offset)
read(fd, buf, len)
└─ 驱动 device_read 被调用
└─ if (*offset >= msg_size) return 0; // ← 问题!
└─ offset 是 0(write 没更新),msg_size 是写入长度
└─ 0 < msg_size,可以读
实际上在这个驱动实现里,device_write 没有更新 *offset,所以直接读也行。但注释里解释了最安全的做法是关闭重新打开——因为 close 销毁了 struct file,下次 open 创建新的 struct file,f_pos(offset)从 0 开始。
perror 的工作原理:
perror 读取全局变量 errno(系统调用失败后内核设置的错误码),将其翻译为人类可读的错误字符串并打印。这是 POSIX 错误处理的标准模式。
3.5 用户空间 vs 内核空间:最重要的边界
1
2
3
4
5
// 内核 → 用户
copy_to_user(void __user *to, const void *from, unsigned long n);
// 用户 → 内核
copy_from_user(void *to, const void __user *from, unsigned long n);
为什么不能直接用 memcpy?
1
2
3
4
5
6
7
8
9
10
用户空间指针的问题:
1. 可能是 NULL 或无效地址(用户程序 bug 或恶意攻击)
2. 可能指向用户空间不可读/不可写的页面
3. 可能在 copy 过程中被另一个线程改变(TOCTOU 攻击)
4. 用户虚拟地址与内核虚拟地址空间不同
copy_from_user 额外做了:
├─ 地址有效性检查(access_ok)
├─ 缺页处理(触发 page fault 也能安全返回)
└─ 返回"未能复制的字节数"(非 0 表示失败)
__user 是一个编译器注释宏(对 GCC 展开为空,但对 sparse 静态分析工具有意义),提示这是用户空间指针,不能直接解引用。
3.6 编程边界扩展分析
| 边界 | 之前的认知 | 实验1(字符设备)拓展到 |
|---|---|---|
| API 设计模式 | 函数直接调用 | 函数指针表(vtable)实现接口与实现分离 |
| 内存安全 | 用户程序里随意 memcpy | 跨特权级内存拷贝必须用专用安全函数 |
| 文件抽象 | 文件 = 磁盘上的数据 | 文件 = 任何可以 open/read/write 的东西(含硬件) |
| 错误处理 | 返回 -1 | 内核错误码体系(-EFAULT, -ENOMEM, -ENODEV…) |
| 偏移量管理 | 不关心 | loff_t *offset 是 VFS 维护的读写位置,需要驱动更新 |
4. 实验2:USB 热插拔驱动
4.1 Linux 设备模型:总线-驱动-设备三元组
问题动机:当你插入一个 USB 设备时,系统怎么知道该加载哪个驱动?
核心机制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
USB 总线(Bus)
│
├─ 设备侧(Device):插入的 USB 设备,携带 Vendor ID + Product ID
│
└─ 驱动侧(Driver):注册时声明"我支持哪些 VID:PID"(id_table)
匹配过程(由 USB Core 完成):
┌─────────────────────────────────────────┐
│ for each (driver in driver_list): │
│ for each (id in driver.id_table): │
│ if (device.vid == id.vid && │
│ device.pid == id.pid): │
│ call driver.probe(device) │
└─────────────────────────────────────────┘
这是观察者模式(Observer Pattern)在操作系统中的实现:总线是事件源,驱动是订阅者。
4.2 代码精读:usb_detect.c
设备 ID 表
1
2
3
4
5
6
7
8
#define USB_DETECT_VENDOR_ID 0x0951
#define USB_DETECT_PRODUCT_ID 0x160b
static const struct usb_device_id usbdetect_table[] = {
{ USB_DEVICE(USB_DETECT_VENDOR_ID, USB_DETECT_PRODUCT_ID) },
{ } /* Terminating entry - 哨兵,标记数组结束 */
};
MODULE_DEVICE_TABLE(usb, usbdetect_table);
USB_DEVICE(vid, pid) 宏展开为一个 struct usb_device_id 的初始化器,设置 match_flags、idVendor、idProduct 字段。
数组末尾的空 { } 是哨兵模式(Sentinel)——内核遍历 id_table 时用全零条目作为结束标志,避免传入数组长度(类似 C 字符串的 '\0')。
MODULE_DEVICE_TABLE(usb, usbdetect_table) 做两件事:
- 让
modprobe/udev能从.ko文件中提取支持的设备列表 - 支持热插拔时自动加载正确模块(
/lib/modules/.../modules.alias数据库)
probe 函数(设备插入回调)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
static int usbdetect_probe(struct usb_interface *interface,
const struct usb_device_id *id)
{
struct usb_detect *dev;
struct usb_endpoint_descriptor *bulk_in, *bulk_out;
int retval;
// ① 分配设备私有状态结构体
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev)
return -ENOMEM;
// ② 初始化同步原语
kref_init(&dev->kref);
sema_init(&dev->limit_sem, WRITES_IN_FLIGHT);
mutex_init(&dev->io_mutex);
spin_lock_init(&dev->err_lock);
init_usb_anchor(&dev->submitted);
init_waitqueue_head(&dev->bulk_in_wait);
// ③ 获取设备引用
dev->udev = usb_get_dev(interface_to_usbdev(interface));
dev->interface = usb_get_intf(interface);
// ④ 查找端点
retval = usb_find_common_endpoints(interface->cur_altsetting,
&bulk_in, &bulk_out, NULL, NULL);
if (retval) {
dev_err(&interface->dev,
"Could not find both bulk-in and bulk-out endpoints\n");
goto error;
}
// ⑤ 配置 bulk-in 端点
dev->bulk_in_size = usb_endpoint_maxp(bulk_in);
dev->bulk_in_endpointAddr = bulk_in->bEndpointAddress;
dev->bulk_in_buffer = kmalloc(dev->bulk_in_size, GFP_KERNEL);
if (!dev->bulk_in_buffer) {
retval = -ENOMEM;
goto error;
}
dev->bulk_in_urb = usb_alloc_urb(0, GFP_KERNEL);
if (!dev->bulk_in_urb) {
retval = -ENOMEM;
goto error;
}
dev->bulk_out_endpointAddr = bulk_out->bEndpointAddress;
// ⑥ 将私有数据绑定到接口
usb_set_intfdata(interface, dev);
// ⑦ 注册字符设备(获取 minor number)
retval = usb_register_dev(interface, &usbdetect_class);
if (retval) {
dev_err(&interface->dev, "Not able to get a minor for this device.\n");
usb_set_intfdata(interface, NULL);
goto error;
}
dev_info(&interface->dev,
"USB detect device now attached to USBdetect-%d",
interface->minor);
return 0;
error:
// ⑧ 错误统一处理:通过 kref 触发资源释放
kref_put(&dev->kref, usbdetect_delete);
return retval;
}
kzalloc vs kmalloc:
1
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
kmalloc:分配内存,内容随机(可能含有之前的数据残留)kzalloc:分配内存 + 清零(等价于kmalloc+memset(0))- 工程选择:对于包含指针、标志位的结构体,始终用
kzalloc,防止未初始化字段导致的野指针/错误逻辑
GFP_KERNEL:内存分配标志(Get Free Page flags)
1
2
3
GFP_KERNEL = 可以睡眠等待内存(用于进程上下文,最常见)
GFP_ATOMIC = 不能睡眠(用于中断处理、自旋锁持有期间)
GFP_DMA = 分配在 DMA 可访问区域
goto error 模式:
这是内核代码中处理多步初始化失败的标准模式:
1
2
3
4
5
6
7
8
9
step1... if (fail) goto error;
step2... if (fail) goto error;
step3... if (fail) goto error;
return 0;
error:
// 统一清理
kref_put(&dev->kref, usbdetect_delete);
return retval;
优于”嵌套 if-else”(避免厄运金字塔),也比”每个错误点重复清理代码”更安全。
disconnect 函数(设备拔出回调)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static void usbdetect_disconnect(struct usb_interface *interface)
{
struct usb_detect *dev;
int minor = interface->minor;
// ① 取回私有数据,解除绑定
dev = usb_get_intfdata(interface);
usb_set_intfdata(interface, NULL);
// ② 归还 minor number
usb_deregister_dev(interface, &usbdetect_class);
// ③ 阻止新的 I/O
mutex_lock(&dev->io_mutex);
dev->disconnected = 1;
mutex_unlock(&dev->io_mutex);
// ④ 取消所有待处理的 URB
usb_kill_anchored_urbs(&dev->submitted);
// ⑤ 减少引用计数(可能触发 delete)
kref_put(&dev->kref, usbdetect_delete);
dev_info(&interface->dev, "USB detect #%d now disconnected", minor);
}
disconnected = 1 的作用:在 disconnect 之后,如果用户程序还有打开的文件描述符正在做 I/O,驱动函数需要检查这个标志,返回 -ENODEV,优雅地告知应用程序设备已消失。
4.3 关键数据结构:struct usb_detect
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct usb_detect {
struct usb_device *udev; // USB 设备对象
struct usb_interface *interface; // USB 接口对象
struct semaphore limit_sem; // 信号量:限制并发写次数
struct usb_anchor submitted; // 已提交的 URB 锚点
struct urb *bulk_in_urb; // 批量输入 URB
unsigned char *bulk_in_buffer; // 输入缓冲区
size_t bulk_in_size;
size_t bulk_in_filled;
size_t bulk_in_copied;
__u8 bulk_in_endpointAddr;
__u8 bulk_out_endpointAddr;
int errors;
bool ongoing_read;
spinlock_t err_lock; // 自旋锁:保护 errors 字段
struct kref kref; // 引用计数
struct mutex io_mutex; // 互斥锁:同步 I/O 和 disconnect
unsigned long disconnected:1; // 位域:断连标志
wait_queue_head_t bulk_in_wait; // 等待队列
};
同步原语的选择策略:
| 原语 | 用途 | 是否可睡眠 | 保护对象 |
|---|---|---|---|
spinlock_t | 保护简单的 int/bool 字段 | 不可睡眠 | errors |
struct mutex | 保护复杂操作(I/O + disconnect) | 可睡眠 | I/O 流程 |
struct semaphore | 限制并发数量 | 可睡眠 | 写并发计数 |
wait_queue_head_t | 等待某事件(异步读完成) | 可睡眠 | bulk_in 读取 |
位域 disconnected:1:
1
unsigned long disconnected:1;
这告诉编译器:disconnected 只占 unsigned long 中的 1 个 bit,不是整个字段。节省内存,且语义清晰(只有 0 或 1)。
4.4 内核内存管理模式:kref 引用计数
1
2
3
4
5
6
7
8
// 初始化(引用计数 = 1)
kref_init(&dev->kref);
// 增加引用
kref_get(&dev->kref);
// 减少引用(如果变为 0,调用 release 函数)
kref_put(&dev->kref, usbdetect_delete);
为什么需要 kref?
1
2
3
4
时间线:
T1: 用户程序打开设备 (open) → 引用计数 2
T2: 用户拔出 U 盘 (disconnect) → 引用计数 1(不能立即 free!)
T3: 用户程序关闭设备 (release) → 引用计数 0 → 触发 kfree
如果 T2 时直接 kfree(dev),T3 时用户程序还在访问 dev,就是 Use-After-Free 漏洞。kref 确保只有在最后一个引用者放弃引用时才释放内存。
这和 C++ 的 shared_ptr、Rust 的 Arc<T> 本质相同,只是 C 的手动版本。
4.5 编程边界扩展分析
| 边界 | 之前的认知 | 实验2 拓展到 |
|---|---|---|
| 事件驱动编程 | 轮询或回调 | probe/disconnect:设备事件回调,由内核总线框架触发 |
| 内存生命周期 | malloc/free 成对 | kref 引用计数管理复杂生命周期 |
| 并发控制 | 粗粒度锁 | 根据场景选择 spinlock/mutex/semaphore/waitqueue |
| 错误处理 | 逐一 return | goto error 统一资源清理模式 |
| 结构体布局 | 字段按需填充 | per-device 状态结构体封装设备所有上下文 |
| 数组遍历终止 | 传入长度 | 哨兵模式(Sentinel)标记数组结束 |
5. 实验3:存储 I/O 性能测试
5.1 问题动机:内核为什么能直接读写文件?
用户程序通过 open/read/write 系统调用访问文件。那内核模块能不能自己打开文件?
答案是可以但不推荐。内核提供了 filp_open/kernel_read/kernel_write/filp_close 这套接口,供内核代码在需要时操作文件系统。
为什么不推荐在生产驱动中使用?
- 与文件系统紧耦合,不符合驱动设计原则
- 文件系统挂载时机问题(驱动可能比文件系统更早加载)
- 有更好的替代方案(如 debugfs、procfs、sysfs)
在本实验中,这是测量磁盘性能的合理工具。
5.2 代码精读:write_to_disk.c(写速率测试)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/timekeeping.h> // ktime_get_ns()
#include <linux/math64.h> // div_u64()
#include <linux/errno.h>
#define BUF_SIZE 1024 // 每次写入块大小:1 KB
#define WRITE_TIMES 524288 // 写入次数:524288 次
MODULE_LICENSE("GPL");
static int __init write_disk_init(void)
{
struct file *fp_write;
char buf[BUF_SIZE];
int i;
loff_t pos = 0;
u64 start_ns, end_ns, elapsed_us;
u64 total_bytes = (u64)BUF_SIZE * WRITE_TIMES; // 总写入量:512 MB
ssize_t ret;
pr_info("Start write_to_disk module...\n");
// ① 填充测试数据(重复 '0'-'9')
for (i = 0; i < BUF_SIZE; i++)
buf[i] = (char)('0' + (i % 10));
// ② 打开文件(创建/截断)
fp_write = filp_open("/home/tmp_file", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (IS_ERR(fp_write)) {
pr_err("Failed to open file\n");
return PTR_ERR(fp_write); // 将错误指针转为错误码
}
// ③ 记录开始时间(纳秒精度)
start_ns = ktime_get_ns();
// ④ 循环写入
for (i = 0; i < WRITE_TIMES; i++) {
ret = kernel_write(fp_write, buf, BUF_SIZE, &pos);
if (ret < 0) {
pr_err("kernel_write failed at loop %d, ret=%zd\n", i, ret);
filp_close(fp_write, NULL);
return (int)ret;
}
if (ret != BUF_SIZE) {
pr_err("short write at loop %d, ret=%zd\n", i, ret);
filp_close(fp_write, NULL);
return -EIO;
}
}
// ⑤ 记录结束时间,计算速率
end_ns = ktime_get_ns();
filp_close(fp_write, NULL);
elapsed_us = div_u64(end_ns - start_ns, 1000);
pr_info("Writing to file costs %llu us\n", elapsed_us);
if (elapsed_us)
pr_info("Writing speed is %llu MB/s (decimal)\n",
div_u64(total_bytes, elapsed_us));
else
pr_info("Elapsed time too small to calculate speed\n");
return 0;
}
关键知识点解析
总写入量计算:
1
2
3
4
#define BUF_SIZE 1024 // 1 KB
#define WRITE_TIMES 524288 // = 512 * 1024
total_bytes = 1024 * 524288 = 536,870,912 bytes = 512 MB
IS_ERR / PTR_ERR 模式:
Linux 内核中,指针函数的错误返回方式与用户态不同:
1
2
3
4
fp_write = filp_open(...);
if (IS_ERR(fp_write)) {
return PTR_ERR(fp_write);
}
内核约定:如果指针值在 [-4096, 0) 范围内(最后 4KB 的地址),则该指针实际上是一个错误码的伪装。
1
2
3
IS_ERR(ptr) → 检查 ptr 是否在错误范围内
PTR_ERR(ptr) → 提取嵌入在指针里的错误码(负数)
ERR_PTR(err) → 将错误码打包成"错误指针"
这避免了传递额外的 int *err 参数,是内核的一个工程约定。
高精度计时:ktime_get_ns():
1
2
3
4
u64 start_ns = ktime_get_ns();
// ... 操作 ...
u64 end_ns = ktime_get_ns();
u64 elapsed_us = div_u64(end_ns - start_ns, 1000);
ktime_get_ns() 返回单调时钟(Monotonic Clock)的纳秒值:
- 单调时钟:不受系统时间调整影响,只会向前走,适合测量时间间隔
- 对比:
gettimeofday()是墙钟时间,NTP 调整时可能跳变
为什么用 div_u64 而不是 /?
1
elapsed_us = div_u64(end_ns - start_ns, 1000);
在 32 位内核中,64 位整数除法不是原生支持的,需要调用软件除法函数。div_u64 是内核提供的安全 64 位无符号除法宏,在所有架构上都正确工作。直接用 / 在某些架构上会链接失败(undefined __udivdi3)。
速率计算的单位分析:
1
2
3
4
5
6
7
8
total_bytes = 512 MB = 512 * 1024 * 1024 bytes
elapsed_us = 时间,单位微秒
speed = total_bytes / elapsed_us
= bytes / microseconds
= bytes / (10^-6 seconds)
= 10^6 bytes/second
≈ MB/s (因为 10^6 ≈ 1 MB)
注意这里是十进制 MB(1 MB = 10^6 bytes),不是二进制 MiB(1 MiB = 2^20 = 1,048,576 bytes),所以注释里写了 (decimal)。这个细节影响约 4.8% 的数值差异。
Short write 检测:
1
2
3
4
if (ret != BUF_SIZE) {
pr_err("short write at loop %d, ret=%zd\n", i, ret);
return -EIO;
}
kernel_write 可能返回比请求值更小的字节数(Short Write),这在磁盘满、信号中断等情况下会发生。正确的实现必须检测并处理,否则会静默地少写数据。
5.3 代码精读:read_from_disk.c(读速率测试)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/rtc.h>
#define buf_size 1024
#define read_times 524288
MODULE_LICENSE("GPL");
struct timeval tv; // ← 警告:这个 API 已废弃!
static int __init read_disk_init(void)
{
struct file *fp_read;
char buf[buf_size];
int i;
int read_start_time;
int read_start_time_u;
int read_end_time;
int read_end_time_u;
int read_time;
loff_t pos;
printk("Start read_from_disk module...\n");
fp_read = filp_open("/home/tmp_file", O_RDONLY, 0);
if (IS_ERR(fp_read)) {
printk("Failed to open file...\n");
return -1; // ← 工程问题:应该 return PTR_ERR(fp_read)
}
do_gettimeofday(&tv); // ← 废弃 API
read_start_time = (int)tv.tv_sec;
read_start_time_u = (int)tv.tv_usec;
pos = 0;
for(i = 0; i < read_times; i++) {
kernel_read(fp_read, buf, buf_size, &pos);
// ← 工程问题:没有检查返回值!
}
do_gettimeofday(&tv); // ← 废弃 API
read_end_time = (int)tv.tv_sec;
read_end_time_u = (int)tv.tv_usec;
filp_close(fp_read, NULL);
read_time = (read_end_time - read_start_time) * 1000000
+ (read_end_time_u - read_start_time_u);
printk(KERN_ALERT "Read file costs %d us\n", read_time);
printk("Reading speed is %d M/s\n", buf_size * read_times / read_time);
return 0;
}
5.4 两份代码的工程质量对比
这两份代码形成了一个绝佳的工程质量对比,请仔细看:
| 方面 | write_to_disk.c(较好) | read_from_disk.c(较差) |
|---|---|---|
| 计时 API | ktime_get_ns()(现代,推荐) | do_gettimeofday()(已废弃,5.x 内核删除) |
| 错误处理(打开文件) | return PTR_ERR(fp_write) | return -1(丢失具体错误码) |
| 错误处理(读写循环) | 检查 ret < 0 和 short write | 完全不检查 kernel_read 返回值 |
| 除法安全性 | div_u64(...) | 直接 / read_time(int 除法,可能溢出) |
| 全局变量 | 无 | struct timeval tv 是全局变量(模块级并发不安全) |
| 命名规范 | 大写宏名 BUF_SIZE | 小写宏名 buf_size(违反 C 惯例) |
关键洞察:
do_gettimeofday在 Linux 5.x 内核中已被移除(Y2038 问题修复的一部分)。read_from_disk.c 如果在新内核上编译会直接失败。write_to_disk.c使用的ktime_get_ns()是正确的现代替代。
do_gettimeofday 被废弃的原因(Y2038 问题):
1
2
3
4
struct timeval {
long tv_sec; // 32位系统上是 32-bit signed
long tv_usec;
};
32 位 long 的最大值是 2,147,483,647。Unix 时间戳从 1970 年开始计数,在 2038 年 1 月 19 日 会溢出,这就是 Y2038 问题。新内核用 64 位时间类型(ktime_t)替代。
5.5 iozone 工具:用户态基准测试
1
./iozone -Raz -n 512m -g 8g -y 1k -i 0 -i 1 -b /home/developer/iozone.xls
参数解析:
| 参数 | 含义 |
|---|---|
-R | 生成 Excel 兼容格式输出 |
-a | 全自动模式(所有文件大小和块大小组合) |
-z | 配合 -a,测试所有记录块大小(包括小块) |
-n 512m | 最小测试文件大小 512 MB |
-g 8g | 最大测试文件大小 8 GB(必须大于内存 2 倍) |
-y 1k | 最小记录块 1 KB |
-i 0 -i 1 | 测试写(0)和读(1) |
-b file | 输出到指定文件 |
为什么测试文件必须大于内存 2 倍?
1
2
3
4
5
6
7
8
9
10
11
12
13
Page Cache(页缓存)的影响:
├─ 第一次写文件 → 数据先写入 Page Cache → 异步刷回磁盘(writeback)
├─ 第一次读文件 → 从磁盘读入 Page Cache
└─ 第二次读文件 → 直接从 Page Cache 返回(未命中磁盘!)
如果测试文件 < 内存大小:
→ 整个文件都能放进 Page Cache
→ 所有读操作都命中缓存
→ 测试的是内存速度(~10 GB/s),不是磁盘速度(~100-500 MB/s)
→ 数值完全失真
解决方案:
文件 > 内存 × 2 → 强制 Cache 反复驱逐 → 真实磁盘 I/O
5.6 内核态 vs 用户态速率差异的深层原因
实验结果:
- 内核模块写速率:~152 MB/s,读速率:~450 MB/s
- iozone 用户态写速率:~54 MB/s,读速率:~281 MB/s
为什么内核态更快?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
用户态 I/O 路径(iozone):
用户程序
→ write() 系统调用(Ring 3 → Ring 0 切换,保存/恢复寄存器)
→ VFS 层(路径查找、权限检查)
→ 文件系统层(ext4/xfs 等:日志、元数据更新)
→ Page Cache(数据先写入 cache)
→ 块设备层(I/O 调度器:合并、排序)
→ 设备驱动
→ 磁盘
内核态 I/O 路径(kernel_write):
kernel_write()
→ VFS 层
→ 文件系统层
→ Page Cache
→ 块设备层
→ 设备驱动
→ 磁盘
关键差异:
✗ 用户态:每次 write() 都经历系统调用切换(~100-1000 ns)
✓ 内核态:直接调用 kernel_write,无切换开销
✗ 用户态:还有 glibc 缓冲区层(fwrite 等)的额外拷贝
✓ 内核态:数据拷贝更少
更深层的原因——测量的其实不完全是磁盘速度:
内核模块的”高速率”部分原因是 Page Cache 的写回机制:
1
2
3
4
5
6
7
kernel_write 的实际行为:
写入数据 → Page Cache(内存操作,极快)→ 标记为 dirty
模块 exit 前 filp_close → 不一定等待 writeback 完成
测量的时间:
≈ 写入 Page Cache 的时间(内存速度)
≠ 实际写入磁盘的时间
关键洞察:这个实验的”内核态更快”结论是不完整的。严格的磁盘 I/O 测试需要在写完后调用
vfs_fsync()强制将 dirty pages 刷回磁盘,否则测量的是内存 + writeback 的混合速度。
5.7 编程边界扩展分析
| 边界 | 之前的认知 | 实验3 拓展到 |
|---|---|---|
| 计时精度 | time() 秒级 | ktime_get_ns() 纳秒级,单调时钟 |
| I/O 路径理解 | 写文件 = 写磁盘 | 写文件 = 写 Page Cache,异步刷盘 |
| 错误指针模式 | 返回 NULL 表示失败 | IS_ERR/PTR_ERR/ERR_PTR 将错误码嵌入指针 |
| 基准测试设计 | 随意测 | 需排除缓存影响,测试文件 > 内存 2 倍 |
| 64 位除法 | 直接用 / | div_u64() 跨架构安全 |
| API 演进意识 | 用什么就用什么 | 关注废弃 API(do_gettimeofday, Y2038) |
6. 横向综合:三个实验打通的知识体系
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌─────────────────────────────────────────────────────────────┐
│ Linux 设备管理全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户程序 │
│ open("/dev/mydev") write() read() iozone │
│ │ │ │ │ │
│ ───────┼────────────────┼───────┼────────┼──── 系统调用边界 │
│ │ │ │ │ │
│ VFS 层 │ │ │ │ │
│ 文件描述符 → struct file → f_op → file_operations │
│ │
│ ┌──────────────┬─────────────────┬──────────────────┐ │
│ │ 字符设备框架 │ USB 子系统 │ 块设备 / 文件系统 │ │
│ │ (实验1) │ (实验2) │ (实验3) │ │
│ │ │ │ │ │
│ │ register_ │ usb_register │ filp_open │ │
│ │ chrdev │ probe/disconnect│ kernel_read/write │ │
│ │ file_ops │ URB / 端点 │ Page Cache │ │
│ └──────────────┴─────────────────┴──────────────────┘ │
│ │ │
│ 内核内存管理 / 同步原语 │
│ kmalloc/kfree, kref, mutex, spinlock │
│ │
└─────────────────────────────────────────────────────────────┘
三个实验共同揭示的核心设计原则:
- 分层抽象:每层只对上一层负责(VFS → 文件系统 → 块设备 → 驱动 → 硬件)
- 回调驱动:通过函数指针表(
file_operations、usb_driver)实现接口与实现分离 - 资源所有权:谁分配谁释放,kref 管理跨所有者生命周期
- 安全边界:用户空间指针必须通过
copy_from/to_user访问,不能直接解引用
附录A:常见误区对照表
| 误区 | 正确认知 |
|---|---|
printk 和 printf 一样用 | printk 输出到内核 ring buffer,需 dmesg 查看;格式符支持不完全相同 |
内核模块可以直接用 printf、malloc | 内核没有 libc,用 printk、kmalloc、kzalloc |
copy_to_user 只是个安全版 memcpy | 它还处理缺页异常、做地址有效性检查,返回未复制字节数 |
驱动注册后 /dev/xxx 自动出现 | 简单驱动需要手动 mknod;现代驱动配合 udev 自动创建 |
| 测试文件在内存范围内测出的是磁盘速度 | 测的是 Page Cache 速度(内存),需文件大小 > 内存 2 倍才测到磁盘 |
| 内核态 I/O 快是因为没有系统调用 | 主要是省了 Ring 切换开销 + Page Cache 写回的时机不同(可能测的是内存速度) |
do_gettimeofday 可以继续用 | 5.x 内核已删除,改用 ktime_get_ns() |
| 驱动 disconnect 后立即 free | 需要 kref 确保引用为 0 时才 free,防止 Use-After-Free |
附录B:关键概念速查表
| 概念 | 一句话定义 | 所在实验 |
|---|---|---|
__init / __exit | 将函数放入特殊 ELF section,加载完成后内存可回收 | 实验1 |
printk(KERN_INFO ...) | 内核日志函数,输出到 ring buffer,dmesg 查看 | 实验1 |
obj-m | Kbuild 变量,指定编译为外核模块(.ko) | 实验1 |
file_operations | 函数指针表,将 VFS 系统调用路由到驱动实现 | 实验1 |
copy_from_user / copy_to_user | 跨特权级安全内存拷贝 | 实验1 |
major_num / minor_num | 主设备号标识驱动,次设备号标识具体设备 | 实验1 |
mknod | 手动创建设备节点文件(/dev 下的入口) | 实验1 |
usb_device_id / id_table | USB 驱动声明支持的 VID:PID 列表 | 实验2 |
probe / disconnect | 设备插入/拔出时的回调函数 | 实验2 |
kzalloc / GFP_KERNEL | 内核内存分配(清零版),GFP 标志控制分配行为 | 实验2 |
kref | 内核引用计数,管理跨所有者的对象生命周期 | 实验2 |
goto error 模式 | 多步初始化失败时统一清理的惯用法 | 实验2 |
IS_ERR / PTR_ERR | 检测/提取嵌入在指针里的错误码 | 实验3 |
ktime_get_ns() | 单调时钟纳秒计时(替代废弃的 do_gettimeofday) | 实验3 |
div_u64 | 跨架构安全的 64 位无符号整数除法 | 实验3 |
filp_open / kernel_write | 内核态文件操作接口 | 实验3 |
| Page Cache | 内核文件数据缓存层,读写通常经过此层 | 实验3 |
附录C:知识依赖图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Linux 内核模块基础
├─ 编译系统(Kbuild, obj-m, KDIR)
├─ 生命周期(__init, __exit, module_init, module_exit)
└─ 调试(printk, dmesg, insmod, rmmod, lsmod)
│
▼
字符设备驱动
├─ VFS 抽象(一切皆文件)
├─ 设备号(major, minor)
├─ file_operations(函数指针表 / vtable)
├─ inode vs struct file(实体 vs 打开状态)
└─ 用户-内核空间边界(copy_from/to_user)
│
├──────────────────────┐
▼ ▼
USB 总线设备模型 存储 I/O 路径
├─ Bus-Driver-Device ├─ filp_open / kernel_write
├─ probe / disconnect ├─ Page Cache
├─ id_table / VID:PID ├─ 计时(ktime_get_ns)
├─ kref 引用计数 ├─ 性能测量方法论
└─ 内核同步原语 └─ 用户态工具(iozone)