Post

Linux设备管理实验解析

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 filef_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_flagsidVendoridProduct 字段。

数组末尾的空 { }哨兵模式(Sentinel)——内核遍历 id_table 时用全零条目作为结束标志,避免传入数组长度(类似 C 字符串的 '\0')。

MODULE_DEVICE_TABLE(usb, usbdetect_table) 做两件事:

  1. modprobe / udev 能从 .ko 文件中提取支持的设备列表
  2. 支持热插拔时自动加载正确模块(/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
错误处理逐一 returngoto 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(较差)
计时 APIktime_get_ns()(现代,推荐)do_gettimeofday()已废弃,5.x 内核删除)
错误处理(打开文件)return PTR_ERR(fp_write)return -1(丢失具体错误码)
错误处理(读写循环)检查 ret < 0 和 short write完全不检查 kernel_read 返回值
除法安全性div_u64(...)直接 / read_timeint 除法,可能溢出)
全局变量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                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

三个实验共同揭示的核心设计原则

  1. 分层抽象:每层只对上一层负责(VFS → 文件系统 → 块设备 → 驱动 → 硬件)
  2. 回调驱动:通过函数指针表(file_operationsusb_driver)实现接口与实现分离
  3. 资源所有权:谁分配谁释放,kref 管理跨所有者生命周期
  4. 安全边界:用户空间指针必须通过 copy_from/to_user 访问,不能直接解引用

附录A:常见误区对照表

误区正确认知
printkprintf 一样用printk 输出到内核 ring buffer,需 dmesg 查看;格式符支持不完全相同
内核模块可以直接用 printfmalloc内核没有 libc,用 printkkmallockzalloc
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-mKbuild 变量,指定编译为外核模块(.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_tableUSB 驱动声明支持的 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)
This post is licensed under CC BY 4.0 by the author.