内核基础概念:内核空间与用户空间、系统调用、模块机制、设备文件、主次设备号、udev规则

Linux 内核驱动开发的第一道门槛,并非具体某个 API 的用法,而是对内核运行模型与设备管理体系的整体认知。驱动本质上是内核空间与硬件之间的翻译官,而用户程序通过系统调用与设备文件间接访问硬件。本章将围绕六个核心概念展开:内核空间与用户空间的隔离机制、系统调用的完整路径、内核模块的加载与卸载、设备文件的本质、主次设备号的编码规则,以及 udev 如何动态管理设备节点。理解这些概念之间的协作关系,是后续编写字符设备驱动、平台驱动和中断处理程序的基础。

用户空间 (User Space) 应用程序 open/read/write C 库 (glibc) 封装系统调用 系统调用接口 (Syscall Interface) int 0x80 / syscall 指令 → 陷入内核 内核空间 (Kernel Space) VFS / 文件系统 按主次设备号查找 设备驱动 file_operations 硬件抽象层 ioremap / regmap 物理硬件 寄存器 / 中断 / DMA 设备管理链路 内核模块机制 insmod / rmmod / modprobe module_init() / module_exit() 设备文件 + 主次设备号 /dev/xxx → c major minor 主设备号: 驱动类型 (12 bit) 次设备号: 实例编号 (20 bit) udev 动态规则 内核 uevent → udev 守护进程 /etc/udev/rules.d/*.rules 自动创建 /dev 节点与符号链接 陷入内核 访问 注册 生成节点

上图展示了本章六个概念的整体协作关系:用户程序通过系统调用陷入内核,VFS 根据设备文件中的主次设备号找到对应的驱动,驱动通过模块机制动态加载,而 udev 则在设备注册时自动创建设备节点。下面逐一深入拆解。

2.1 内核空间与用户空间:隔离与交互

核心原理

Linux 使用虚拟内存机制将系统分为两个特权级区域:用户空间(Ring 3)和内核空间(Ring 0)。用户空间运行应用程序,只能访问受限内存地址;内核空间运行操作系统核心代码,可访问全部内存和硬件资源。这种隔离保证了用户程序的崩溃不会导致整个系统宕机。驱动代码运行在内核空间,因此拥有最高权限,但也意味着任何内存越界、空指针解引用都会直接导致内核 panic。

判断方法

在驱动开发中,需要时刻区分当前代码运行在哪个上下文:

  • 进程上下文:由系统调用进入内核,可以睡眠、可以调度,使用 current 宏获取当前进程。
  • 中断上下文:由硬件中断触发,不能睡眠、不能使用可能导致阻塞的 API(如 kmalloc(GFP_KERNEL)),应使用 GFP_ATOMIC。
  • 原子上下文:持有自旋锁时,同样禁止睡眠。
风险提示:新手驱动开发者最常见的错误之一,就是在中断处理函数中调用 printk 时使用 %p 等需要页表操作的格式符,或者在持有锁时调用 copy_to_user。这些操作在原子上下文中会导致内核死锁或崩溃。

案例拆解:一个简单的内存隔离验证

假设我们编写一个驱动,尝试在用户空间直接访问内核地址 0xffffffff81000000。用户程序会收到 Segmentation Fault,因为该地址属于内核空间,用户态页表未映射。而驱动中通过 ioremap 映射物理地址后,内核空间可以正常访问。这验证了隔离机制的有效性。

总结复盘

理解用户/内核空间隔离,是驱动开发的第一原则。所有数据交换必须通过 copy_to_user / copy_from_user 或 mmap 完成,绝不能直接解引用用户空间指针。

2.2 系统调用:用户与内核的桥梁

核心原理

系统调用是用户空间请求内核服务的唯一合法入口。当应用程序调用 open()、read() 等 C 库函数时,glibc 会执行 syscall 指令,将系统调用号放入寄存器,触发 CPU 陷入内核。内核根据系统调用号查找 sys_call_table,执行对应的内核函数。对于设备驱动而言,系统调用最终会通过 VFS 层分发到驱动的 file_operations 结构体中的相应函数。

操作流程

  1. 用户程序调用 read(fd, buf, count)。
  2. glibc 封装,将参数放入寄存器,执行 syscall 指令。
  3. CPU 切换到内核态,保存用户态寄存器上下文。
  4. 内核执行 sys_read,根据 fd 找到对应的 struct file。
  5. 调用 file->f_op->read,即驱动实现的 read 函数。
  6. 驱动将数据通过 copy_to_user 拷贝到用户缓冲区。
  7. 返回用户态,恢复寄存器,继续执行。

实战场景:自定义 ioctl 命令

驱动通常通过 ioctl 系统调用接收控制命令。定义命令时使用 _IO、_IOR、_IOW 宏,编码包含幻数、序号、方向和数据大小。例如:

#define MY_IOCTL_SET_REG _IOW('M', 1, struct reg_data)
#define MY_IOCTL_GET_REG _IOR('M', 2, struct reg_data)

用户程序通过 ioctl(fd, MY_IOCTL_SET_REG, &data) 调用,驱动在 unlocked_ioctl 中解析命令并执行寄存器读写。

经验提醒:系统调用路径上的每个环节都可能成为性能瓶颈。对于高频读写场景,建议使用 mmap 映射内核缓冲区,避免每次 read/write 都经历完整的系统调用开销。

2.3 内核模块机制:动态扩展内核

核心原理

内核模块(LKM)允许在运行时向内核添加代码,无需重新编译整个内核。模块使用 module_init() 和 module_exit() 宏声明入口和出口函数。加载时,内核会解析模块的依赖关系,分配内存,并调用初始化函数;卸载时则调用清理函数,释放资源。

操作流程

命令作用对应内核操作
insmod xxx.ko加载模块调用 module_init
rmmod xxx卸载模块调用 module_exit
modprobe xxx加载模块及其依赖自动处理依赖
lsmod查看已加载模块读取 /proc/modules
modinfo xxx.ko查看模块信息解析 .modinfo 段

案例拆解:一个最小字符设备模块

以下是一个完整的最小模块,注册一个字符设备并创建设备节点:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>

static dev_t dev_num;
static struct cdev demo_cdev;
static struct class *demo_class;

static int demo_open(struct inode *inode, struct file *filp) {
    printk(KERN_INFO "demo: open called\n");
    return 0;
}

static struct file_operations demo_fops = {
    .owner = THIS_MODULE,
    .open = demo_open,
};

static int __init demo_init(void) {
    // 1. 动态分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, "demo_dev");
    // 2. 初始化 cdev 并添加到内核
    cdev_init(&demo_cdev, &demo_fops);
    cdev_add(&demo_cdev, dev_num, 1);
    // 3. 创建设备类并在 /dev 下生成节点
    demo_class = class_create("demo_class");
    device_create(demo_class, NULL, dev_num, NULL, "demo_dev");
    printk(KERN_INFO "demo: initialized, major=%d minor=%d\n",
           MAJOR(dev_num), MINOR(dev_num));
    return 0;
}

static void __exit demo_exit(void) {
    device_destroy(demo_class, dev_num);
    class_destroy(demo_class);
    cdev_del(&demo_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "demo: exited\n");
}

module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");

编译后执行 insmod demo.ko,内核日志会显示分配的主次设备号,/dev/demo_dev 节点自动出现。执行 rmmod demo 后节点消失。

风险提示:模块卸载时必须确保没有进程正在使用该设备,否则会导致 use-after-free。应在 module_exit 中检查引用计数,或使用 try_module_get 保护关键路径。

2.4 设备文件与主次设备号

核心原理

设备文件是用户空间访问驱动的入口,位于 /dev 目录下。使用 ls -l /dev/demo_dev 可以看到:

crw------- 1 root root 240, 0 Jan 1 00:00 /dev/demo_dev

开头的 c 表示字符设备,240 是主设备号,0 是次设备号。主设备号标识设备对应的驱动程序,次设备号标识同一驱动管理的不同实例。Linux 内核使用 dev_t 类型(32 位)存储设备号,其中高 12 位为主设备号,低 20 位为次设备号。

判断方法

  • 静态分配:使用 register_chrdev_region 指定固定的主设备号,适用于知名设备(如 1 代表内存设备,4 代表 tty)。
  • 动态分配:使用 alloc_chrdev_region 让内核自动分配未使用的主设备号,推荐用于大多数驱动。

案例拆解:设备号冲突排查

假设系统中有两个驱动都尝试使用主设备号 240。第一个驱动加载成功后,第二个驱动执行 register_chrdev_region 会返回 -EBUSY。此时应改用动态分配,或通过 cat /proc/devices 查看已占用的设备号。

# cat /proc/devices
Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
  5 /dev/tty
  5 /dev/console
  10 misc
  240 demo_dev
经验提醒:主设备号是稀缺资源,内核社区建议新驱动一律使用动态分配。次设备号可以自由规划,例如用次设备号区分同一芯片上的多个 SPI 从设备。

2.5 udev 规则:动态设备管理

核心原理

udev 是 Linux 用户空间的设备管理器,它监听内核发出的 uevent(设备添加/移除事件),根据 /etc/udev/rules.d/ 下的规则文件自动创建设备节点、设置权限、创建符号链接。相比静态 mknod,udev 实现了设备节点的即插即用。

操作流程

  1. 驱动调用 device_create 时,内核发送 uevent 到 netlink 套接字。
  2. udev 守护进程接收事件,解析设备属性(如 MAJOR、MINOR、DEVNAME)。
  3. udev 按规则文件顺序匹配,执行匹配规则中的 SYMLINK、MODE、OWNER 等指令。
  4. 在 /dev 下创建设备节点或符号链接。

实战场景:自定义 udev 规则

假设我们希望 /dev/demo_dev 对普通用户可读写,并创建一个稳定的符号链接 /dev/mydevice。创建规则文件 /etc/udev/rules.d/99-demo.rules:

KERNEL=="demo_dev", MODE="0666", SYMLINK+="mydevice"

重新加载规则并触发事件:

udevadm control --reload-rules
udevadm trigger

此时 ls -l /dev/mydevice 会显示指向 demo_dev 的符号链接,且权限为 666。

常见误区

  • 误区一:认为 device_create 会自动创建设备节点。实际上它只发送 uevent,节点创建完全依赖 udev。
  • 误区二:在规则中使用 NAME 重命名设备节点,可能导致与内核默认命名冲突。推荐使用 SYMLINK 创建别名。
  • 误区三:忽略 SUBSYSTEM 匹配条件,导致规则误匹配其他设备。应尽量使用 KERNEL、SUBSYSTEM、ATTR 组合精确定位。
本章核心要点:内核空间与用户空间的隔离是安全基础;系统调用是唯一的合法入口;模块机制提供了动态扩展能力;设备文件通过主次设备号关联驱动;udev 将内核事件转化为用户空间可见的设备节点。掌握这五个概念的联动关系,后续编写任何类型的驱动都将事半功倍。