内核基础概念:内核空间与用户空间、系统调用、模块机制、设备文件、主次设备号、udev规则
Linux 内核驱动开发的第一道门槛,并非具体某个 API 的用法,而是对内核运行模型与设备管理体系的整体认知。驱动本质上是内核空间与硬件之间的翻译官,而用户程序通过系统调用与设备文件间接访问硬件。本章将围绕六个核心概念展开:内核空间与用户空间的隔离机制、系统调用的完整路径、内核模块的加载与卸载、设备文件的本质、主次设备号的编码规则,以及 udev 如何动态管理设备节点。理解这些概念之间的协作关系,是后续编写字符设备驱动、平台驱动和中断处理程序的基础。
上图展示了本章六个概念的整体协作关系:用户程序通过系统调用陷入内核,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 结构体中的相应函数。
操作流程
- 用户程序调用
read(fd, buf, count)。 - glibc 封装,将参数放入寄存器,执行
syscall指令。 - CPU 切换到内核态,保存用户态寄存器上下文。
- 内核执行
sys_read,根据 fd 找到对应的struct file。 - 调用
file->f_op->read,即驱动实现的read函数。 - 驱动将数据通过
copy_to_user拷贝到用户缓冲区。 - 返回用户态,恢复寄存器,继续执行。
实战场景:自定义 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 后节点消失。
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
2.5 udev 规则:动态设备管理
核心原理
udev 是 Linux 用户空间的设备管理器,它监听内核发出的 uevent(设备添加/移除事件),根据 /etc/udev/rules.d/ 下的规则文件自动创建设备节点、设置权限、创建符号链接。相比静态 mknod,udev 实现了设备节点的即插即用。
操作流程
- 驱动调用
device_create时,内核发送 uevent 到 netlink 套接字。 - udev 守护进程接收事件,解析设备属性(如
MAJOR、MINOR、DEVNAME)。 - udev 按规则文件顺序匹配,执行匹配规则中的
SYMLINK、MODE、OWNER等指令。 - 在
/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组合精确定位。