第 1 章 跨越界限:从用户空间到内核的第一步
1.1 编写一个简单的 misc 字符设备驱动
有一类问题,表面上看是编程问题,实际上是世界观问题。
我们在这一章要处理的,正是这样一个问题。在此之前,你写的代码都运行在用户空间——有 OS 帮你兜底,有虚拟内存护着你,程序崩溃了也只是进程自己退出,不会搞挂机器。但驱动程序不一样,它是运行在内核空间的。在这里,没有所谓的“保护伞”,你写下的每一个指针偏移,都直接映射到物理内存;你犯下的每一个错误,都可能让整个系统瞬间蒸发。
听起来很吓人?是的。但这种恐惧感通常会让我们忽略另一个事实:内核其实并没有那么神秘。
说白了,内核就是一个运行在特权级、拥有全部硬件访问权的巨大程序。而驱动程序,只是这个巨大程序里的一块“插件”。它的核心任务非常简单:建立一个通道——让用户空间那帮不敢乱碰硬件的普通程序,能够安全地通过内核,把数据发给硬件,或者从硬件拿回数据。
旧方案为什么不行?过去(或者说在非常老的教科书里),人们编写驱动时往往需要手动注册字符设备、处理硬编码的主设备号,甚至要直接去修改 /dev 目录下的节点。这种方式不仅繁琐,而且容易冲突。如果每个驱动作者都随便挑一个主设备号,那系统迟早会因为“撞号”而乱套。现在我们有了更好的机制——misc 类,它解决了这个问题。
这一章,我们将从零开始,写一个最简单的“加密”驱动。它不控制任何真实硬件,但这不重要——重要的是它将完整展示 VFS(虚拟文件系统)、内核空间和设备驱动是如何协作的。
我们要做的第一件事,就是让一段内核代码“活”过来。
理解基础:设备是如何被内核认知的
在 Linux 的眼里,世界上的设备被粗暴地分成了两类:
- 字符设备:像是键盘、鼠标、传感器、显卡。它们的特点是“流式”的——你只能按顺序读写,不能随机跳转(除非设备本身支持)。通常它们无法被挂载为文件系统。
- 块设备:像是硬盘、Flash、SD 卡。它们是“块”状的,数据按块存储,支持随机访问。因为长得像磁盘,所以它们可以被挂载到文件树上。
我们要写的 misc 驱动,属于字符设备的一种。
但是,这里有个麻烦事:内核怎么知道你是哪个设备?
在传统的字符设备里,内核靠一个 32 位的整数来识别设备。这个整数被掰成两半:
- 主设备号(高 12 位):这是“家族姓氏”。它告诉内核,“我是由哪个驱动程序管的”。
- 次设备号(低 20 位):这是“名字”。它告诉具体的驱动程序,“我是这个家族里的第几号设备”。
想象一下 ls -l /dev/sda1 的输出,你会看到类似 8, 1 的数字,这就是主次设备号。
但手动管理这个号太烦了——你得去申请一个没被用的主号,还得自己在 /dev 下创建节点。于是,Linux 内核提供了一个偷懒的神器:misc 类。
⚠️ 这里的类比要回收三次
你可以把 misc 类 理解为内核里的“公共租车位”。所有的普通车(设备)都停在同一个停车场(主设备号 10)里,保安(内核)不靠车牌号(主设备号)来认车,而是靠你手里持有的停车卡(次设备号)来区分哪辆车是你的。
但“停车场”这个比喻有一个地方是错的:真正的停车场车停满了就不让进了,而 misc 设备是用 次设备号(也就是 0~255 之间的数字)来做唯一标识的。只要你的次设备号不冲突,理论上你可以挂无数个设备——这更像是一个拥有无限分机的电话总机,大家拨打同一个总机号码(10),然后靠分机号(次设备号)找到具体的房间。
……
(当我们后续讲到代码注册时)
现在回过头看那个“电话总机”:当你调用
misc_register()时,你实际上是在告诉总机接线员“我要接通 210 这个分机”。只要 210 没人占,你的驱动电话就通了。如果有人已经占了这个分机,misc_register会返给你一个-EBUSY,就像打电话听到占线音一样。
现代视角下的 Linux 设备模型 (LDM)
在旧版本的 Linux 里,驱动和设备是松散的。但在现代内核里,一切皆对象,这就是 Linux 设备模型 (LDM)。
LDM 在内核里维护了一棵巨大的树(就像 Windows 的设备管理器里的树状图)。为了让用户空间能看到这棵树,内核把结构挂载到了 /sys 目录下——这就是 sysfs。
在这个模型里,万物皆总线。哪怕是像 SoC 内部集成的、不插在物理插槽上的外设,也被虚拟地挂在一个叫 platform 总线 的虚拟总线上。
- 总线驱动:就是负责在总线上扫货的搬运工。它负责发现设备(枚举),然后把设备和对应的驱动程序凑一对。
- probe 方法:当搬运工说“嘿,我发现了一个设备,你的驱动能管吗?”,内核调用的就是这个回调函数。这里才是真正的初始化现场。
- remove 方法:拔设备或者卸载驱动时触动的“临别赠言”,用来擦屁股、释放内存。
我们的 misc 驱动虽然简单,但它也会通过 LDM 这套机制,把自己的脸露给 /sys 和 /dev。
建立连接:进程、驱动与内核的三角关系
在动手写代码之前,我们必须先解决一个认知问题:当一个用户程序 write() 时,到底发生了什么?
你可能会觉得:“不就是写数据吗?”
其实这里有一个微妙的转折——这中间隔着一条“护城河”。
- 用户空间发起:你的程序调用
write(fd, "hello", 5)。这是在用户空间,也就是“河对岸”。 - VFS 拦截:内核的 VFS(虚拟文件系统) 层首先拦截了这个调用。VFS 并不知道你在写什么具体的硬件,它只知道“有人要往这个文件描述符里写点东西”。
- 查找注册表:VFS 通过文件描述符,找到了对应的
inode,然后拿着这个inode去查表。这个表就是file_operations结构体。 - 落入内核:
file_operations结构体里存着一堆函数指针。如果write这个指针被你的驱动赋值了,VFS 就会跳过去执行你的代码。
⚠️ 关键结构体:file_operations
这个结构体就是驱动程序的“功能菜单”。你告诉内核:“如果你要读我,就调用 A 函数;如果要写我,就调用 B 函数”。如果不赋值,菜单上就是空的,用户程序操作时就会收到
-EINVAL或者-ENOSYS。
让我们开始写代码:一个带秘密的驱动
这里先验证一下:如果这一步成功了,你预期会看到什么?
先在脑子里过一遍,再上板。我们期待在内核日志里看到“Hello world”,在 /dev 下看到我们的设备文件。
1. 基础骨架:头文件与初始化宏
所有的内核模块都有两个固定的入口点:init(加载时)和 exit(卸载时)。
我们需要 module_init 和 module_exit 这两个宏来告诉内核:“往这跳”。
2. 实现数据的“摆渡”:copy_to_user 与 copy_from_user
这是新手最容易翻船的地方。
你可能会想:“不就是拷贝内存吗?直接用 memcpy 不就完了?”
绝对不行。
还记得吗?内核空间和用户空间的页表是不一样的。用户空间传给你的指针,在内核里可能是“未映射”或者“无效”的。直接用 memcpy 会导致内核尝试访问非法地址,直接 panic。或者更糟,它可能恰好是合法的,但这属于安全漏洞。
你必须使用内核提供的“摆渡船” API:
- copy_to_user():把数据从内核空间 安全地 搬到用户空间。
- copy_from_user():把数据从用户空间 安全地 搬到内核空间。
这两个函数会检查用户空间的指针是否可写。如果指针有问题,它们不会让内核崩溃,而是会返回“拷贝失败”的字节数(未拷贝完的部分)。
3. 定义我们的“秘密”:驱动私有数据
假设我们要做一个简单的“加密”驱动。它有一个秘密字符串,只有知道口令的人才能读到。这就需要驱动程序能“记住”东西。
⚠️ 注意:千万别用全局变量! 如果你在驱动里定义一个全局变量来存秘密,当你有两个设备实例时,它们就会打架。正确的做法是——为每个设备分配一份私有数据。
我们定义一个结构体,里面存我们的秘密:
/* drivers/misc/secret_example.c (部分) */
#define MY_SECRET_MAX 64
struct secret_device {
char secret[MY_SECRET_MAX]; // 存放秘密字符串的缓冲区
// ...
};
4. 上号!编写 init 代码
现在我们来实现模块的初始化函数。这里有几个步骤不能乱。
目标说明:注册一个 misc 设备,并分配我们的私有数据。
→ 为什么这样做:利用内核的“资源管理”机制,确保如果注册失败,资源能自动回滚;如果注册成功,我们的结构体能和设备绑定。
→ 操作位置:static int __init secret_init(void)
代码 / 命令块:
static struct miscdevice secret_misc_device;
static int __init secret_init(void)
{
struct secret_device *my_dev;
int ret;
/* 1. 分配我们的私有数据结构体 */
/* 使用 devm_kzalloc:这是“托管”的内存分配。
* 当设备卸载时,内核会自动帮我们释放这块内存,
* 省去了手动调用 kfree 的麻烦,防止内存泄漏。
*/
my_dev = devm_kzalloc(&secret_misc_device.parent, sizeof(struct secret_device), GFP_KERNEL);
if (!my_dev)
return -ENOMEM; // 内存不足,直接退出
/* 设备初始化:把我们的秘密硬编码进去 */
scnprintf(my_dev->secret, MY_SECRET_MAX, "Linux is the best OS ever!");
/* 2. 填充 miscdevice 结构体 */
secret_misc_device.minor = MISC_DYNAMIC_MINOR; // 动态分配一个次设备号
secret_misc_device.name = "secret"; // 这决定了它在 /dev 下的名字是 /dev/secret
secret_misc_device.fops = &secret_fops; // 把我们的操作函数表挂上去
/* 将私有数据保存到 miscdevice 的父结构中,方便后续回调使用
* (注:这是简化的写法,通常miscdevice本身会被嵌入到更大的结构中)
*/
/* 3. 注册设备 */
ret = misc_register(&secret_misc_device);
if (ret) {
pr_err("Failed to register misc device\n");
return ret;
}
pr_info("Secret driver loaded with major 10, dynamic minor %d\n", secret_misc_device.minor);
return 0;
}
→ 预期输出:
如果你把这段代码编译进模块并 insmod,你会在 dmesg 里看到注册成功的日志,并且在 /dev 目录下多出一个 secret 文件。
→ 验证成功的方式:
ls -l /dev/secret
# 应该能看到类似 crw-rw---- 1 root root 10, 58 ... 的输出
# 10 是主号,58 是动态分配的次号
也就是这里,如果不小心,你就会提权
现在我们已经建立好了通道。但有时候,通道是会漏水的。
想象一下,如果我们在实现 write 函数时,没有检查用户传来的数据长度,直接往内核缓冲区里拷,会发生什么?
→ 为什么这样做(演示错误的代码):为了展示这是如何变成一个安全漏洞的。
/* 这是一个有 BUG 的 write 实现 */
static ssize_t secret_write(struct file *file, const char __user *buf,
size_t len, loff_t *ppos)
{
struct secret_device *dev = PDE_DATA(file_inode(file));
/* 灾难在这里:我们没有检查 len 是否超过了 MY_SECRET_MAX */
if (copy_from_user(dev->secret, buf, len)) {
return -EFAULT;
}
return len;
}
如果你传过去 1000 个字节的数据,而 dev->secret 只有 64 字节。copy_from_user 会无情地覆盖掉 secret 数组后面的内存。
后面是什么?可能是内核的其他关键数据结构,或者是函数返回地址。
这就是传说中的“缓冲区溢出”。
如果在某些特定的内核版本或架构上,覆盖掉内核栈上的返回地址,把它指向一段你精心构造的 Shellcode,你就完成了 权限提升——从一个普通用户变成了 Root。
如果你觉得这个设计有点可怕,那你的直觉是对的。所以在真正的驱动开发里,边界检查是生死攸关的。
问题更深层一点:除了溢出,还有“读它”
如果我们有一个有 bug 的 read 函数,会怎么样?
内核里有一个叫 KASAN (Kernel Address SANitizer) 的工具,就是专门抓这种坏事的。
假设我们在 read 里不小心把“未初始化的内核内存”泄露给了用户:
static ssize_t secret_read(struct file *file, char __user *buf,
size_t len, loff_t *ppos)
{
struct secret_device *dev = PDE_DATA(file_inode(file));
/* 假设这里有个逻辑错误,我们把 dev 结构体后面的内存也读出去了 */
/* 或者我们根本没有清空分配给 dev 的内存 */
if (copy_to_user(buf, dev->secret, len))
return -EFAULT;
return len;
}
如果你开启了 KASAN 内核选项,系统会直接 panic 并打印大段的红色错误日志,告诉你“你访问了越界的内存”。
不要觉得这是小题大做。泄露内核指针(KASLR 泄露)是攻击者绕过内核防护的第一步。哪怕只是多读了 1 个字节,都可能成为攻破整个系统的裂缝。
走到这里,机制应该已经清楚了——或者你以为清楚了。我们构建的不仅仅是一个能读写的文件,而是在内核那片危险的荒原上,划定了一块安全区。
还记得开头那个问题吗——为什么驱动开发不能像写 Python 那样随意? 现在你应该能回答了:因为任何一次随意的内存访问,都可能变成通向 Root 的一扇后门。我们写的每一行
copy_from_user和边界检查,其实都是在加固这扇门。
本章真正在做的事情,是建立 “用户态与内核态交互” 这个认知的底层形式。表面上我们在配置一个 misc 设备,实际上我们在理解为什么 copy_to_user 必须存在,以及为什么 struct cred(进程凭证)是内核安全的最后一道防线。
下一章我们会把这个机制推进到一个新的场景里——届时你会发现,今天建立的直觉会以一种意想不到的方式派上用场:当真正的硬件中断发生时,这个简单的读写模型会变得更加复杂,也更加迷人。