1.2 技术准备与环境
在真正动手敲代码之前,我们需要确保环境就绪,并且对即将面对的这个“怪兽”——Linux 内核驱动模型——有一个心理准备。
本章假设你已经浏览了前言部分,并在 Ubuntu 18.04 LTS(或更新版本)的虚拟机中安装了所有必要的包。如果还没做,强烈建议先完成这一步。
为了获得最佳体验,请务必克隆本书的 GitHub 仓库(Linux-Kernel-Programming-Part-2),并准备随时动手。
想象你站在内核的大门口。手里虽然有钥匙(源码),但如果没有地图(文档)和正确的鞋子(开发环境),走两步就会迷路或者崴脚。我们这一节的任务就是把装备整理好,然后试着推开门缝往里看一眼。
1.3 开始编写一个简单的 misc 字符设备驱动
这一节将是你的第一次实战。我们会先铺垫一些背景知识——关于设备文件、设备号和内核模型。然后,通过编写一个名为 misc 的字符驱动骨架,你将亲眼看到内核是如何将用户的请求“魔术般”地转化为驱动里的函数调用的。
理解设备的基础
在 Unix/Linux 的哲学里,一切皆文件。这句话你听得耳朵都起茧子了,但在驱动开发里,它意味着什么?
设备驱动 是连接 OS 和硬件的桥梁。它可以编译进内核镜像,也可以作为一个内核模块(LKM)动态加载。不管哪种形式,它都运行在内核空间,拥有最高权限(Ring 0)。
用户空间的程序要和硬件说话,必须经过这道门。为了不打破“一切皆文件”的设计,内核把设备也抽象成了一种特殊的文件——设备文件 或 设备节点。它们通常住在 /dev 目录下。
为了区分成千上万的设备,内核给每个设备发了两张“身份证”:
- 类型:是字符设备还是块设备。
- 设备号:一个 32 位的数字,分为主设备号 和 次设备号。
你可以把这棵树想象成一张巨大的地图:
- 树根是设备类型。
- 树枝是主设备号(代表了设备类别,比如 SCSI 硬盘、键盘、显卡)。
- 树叶是次设备号(代表了具体实例,比如第二块硬盘的第三个分区)。
字符设备 vs 块设备
这二者的区别经常让人混淆,其实核心只有一个:能不能被挂载。
- 块设备:支持随机访问,能被挂载到文件系统。通常是存储设备(硬盘、U 盘)。
- 字符设备:不能被挂载,数据像流一样按顺序进出。除了存储和网络设备,你见到的大多是字符设备。
类比:水管 vs 纸箱 你可以把字符设备想象成一根水管。水(数据)只能从一头流进去,从另一头流出来,你不能直接跳到中间去舀一勺,也不需要把水管“挂载”到墙上才能用。 而块设备就像一排储物箱。你可以随意打开第 5 个箱子拿东西,也可以把这排箱子挂到墙上(挂载文件系统)统一管理。
回到那张地图。从 Linux 2.6 开始,设备号被压缩进了一个 32 位的 dev_t 类型里:
- 高 12 位:主设备号(最多 4096 个)。
- 低 20 位:次设备号(每个主设备号下最多 100 万个)。
这意味着:理论上,字符设备树和块设备树,各自都能容纳 4096 个大类,每大类下有 100 万个具体设备。这在很长一段时间内是够用的。
那 misc 类又是怎么回事?
有个问题一直困扰着内核开发者:主设备号资源快用光了。
为了解决这个问题,内核决定把一堆“杂牌军”——鼠标、传感器、触摸板——收编成一个特殊的杂项类,这就是 misc 类。
- 类型:字符设备。
- 主设备号:固定为 10。
- 次设备号:在这个类里,次设备号变成了“二级主设备号”,用来区分具体的 misc 设备。
这就是为什么我们选择从 misc 驱动开始:不需要申请专门的主设备号,内核会自动分配,省去了很多行政流程。
Linux 设备模型(LDM)速览
在深入代码之前,我们还得稍微抬头看一眼宏观架构。现代 Linux 内核(2.6+)有一个统一的 Linux Device Model (LDM)。
这是一个非常“天才”的设计。LDM 在内核里维护了一棵巨大的树,把系统中的总线、设备、驱动全部串联起来。这棵树通过 sysfs(挂载在 /sys)展示给用户空间。
类比:树状的家族企业 你可以把 LDM 看作一个家族企业的组织架构图(
/sys)。
- 总线 是各个部门。
- 设备 是部门里的员工。
- 驱动 是员工的具体岗位说明书。 当一个新员工(设备)加入公司时,部门主管(总线驱动)会根据招聘启事(驱动)找到匹配的岗位,然后把员工安排进去。这个过程叫 Probe(探测)。
核心原则:每一个设备都必须挂在某条总线上。
- USB 设备挂在 USB 总线上。
- PCI 设备挂在 PCI 总线上。
- 那些集成在 SoC 里、没有物理总线的外设怎么办?内核发明了一条虚拟总线——Platform Bus(平台总线)。
当一个驱动注册到总线时,如果发现有匹配的设备,内核会调用驱动的 probe() 方法(初始化资源);反之,设备移除或模块卸载时,调用 remove() 方法(清理资源)。
回到我们的 misc 驱动:
为了保持简单,我们编写的 misc 驱动不需要显式地注册到任何总线,也不需要实现 probe/remove。它直接注册到 misc 框架,就像是一个个体户,不需要挂靠在某个大部门下。
编写 misc 驱动代码 —— 第一部分:骨架
好了,地图看完了,该开始铺路了。我们要写一个最简单的骨架驱动。
在驱动的初始化代码中,我们需要向内核注册我们的设备。对于 misc 设备,使用的 API 是 misc_register()。它只需要一个参数:一个指向 miscdevice 结构体的指针,在这个结构体里,我们描述了设备的各种属性。
// ch1/miscdrv/miscdrv.c
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
#include <linux/miscdevice.h>
#include <linux/fs.h> /* fops, file 数据结构 */
static struct miscdevice llkd_miscdev = {
.minor = MISC_DYNAMIC_MINOR, /* 让内核动态分配一个空闲的次设备号 */
.name = "llkd_miscdrv", /* 设备名,注册后内核会自动创建 /dev/llkd_miscdrv */
.mode = 0666, /* 设备节点权限:所有用户可读写 */
.fops = &llkd_misc_fops, /* 指向驱动功能实现的钩子 */
};
static int __init miscdrv_init(void)
{
int ret;
struct device *dev;
ret = misc_register(&llkd_miscdev);
if (ret != 0) {
pr_notice("misc device registration failed, aborting\n");
return ret;
}
/* 获取设备指针,用于日志输出 */
dev = llkd_miscdev.this_device;
pr_info("LLKD misc driver (major # 10) registered, minor# = %d, "
"dev node is /dev/%s\n", llkd_miscdev.minor, llkd_miscdev.name);
dev_info(dev, "sample dev_info(): minor# = %d\n", llkd_miscdev.minor);
return 0; /* success */
}
代码拆解
MISC_DYNAMIC_MINOR:这是一个宏。我们告诉内核:“帮我挑一个没人用的次设备号”。注册成功后,内核会把分配好的号码填回llkd_miscdev.minor。.name:名字很重要。misc 框架会利用这个名字,自动在/dev下创建一个同名的设备节点。这省得我们自己去敲mknod命令了。.mode:0666意味着所有人都可以读写这个设备。这在生产环境是大忌,但在调试阶段能省去很多权限问题的麻烦。.fops:这是最关键的一环。它把设备节点和具体的 C 函数连接起来。我们下节细讲。
编译并插入这个模块后,你应该能在 /dev 下看到 llkd_miscdrv,并且内核日志会显示它被分配到的次设备号(比如 56)。
理解进程、驱动与内核之间的连接
现在驱动的“身体”(结构体)已经注册好了,但它的“灵魂”在哪里?
在 Unix/Linux 系统中,当用户空间进程对一个文件(包括设备文件)发起系统调用(比如 read, write)时,内核的 VFS(虚拟文件系统) 层会拦截这个调用。
VFS 是怎么知道该调用哪个驱动的函数呢?
答案就在那个 .fops 指向的 file_operations 结构体里。
你可以把 file_operations 想象成一张函数指针表(或者说 C++ 里的纯虚函数接口)。表里每一项都对应一个系统调用:open, read, write, llseek, mmap 等等。
当我们执行:
int fd = open("/dev/llkd_miscdrv", O_RDWR);
read(fd, buf, 100);
内核内部发生了这样的流程(伪代码):
/* 内核 VFS 层逻辑 */
struct file *filp = ...; // 代表打开的文件对象
if (filp->f_op->read)
filp->f_op->read(...); // 调用驱动注册的 read 函数
这就是连接的本质:注册时,我们把驱动里的函数地址填入 VFS 的槽位;调用时,VFS 直接跳转执行。
处理不支持的方法
如果一个驱动不支持某个操作(比如 lseek),我们可以不实现它,对应的指针为 NULL。此时 VFS 会返回一个默认的错误(通常是 EINVAL)。
⚠️ 注意:有一个坑
对于 llseek,如果你不设置它,它可能返回一个随机值,导致用户空间误以为成功了。
正确的做法是:
- 将
.llseek显式赋值为no_llseek。 - 在你的
open方法里调用nonseekable_open()。
这样用户空间调用 lseek 时会得到明确的 -ESPIPE(Illegal seek)错误。
编写 misc 驱动代码 —— 第二部分:功能实现
有了上面的理解,现在我们可以看看具体的函数实现了。
首先,定义 file_operations 结构体实例:
static const struct file_operations llkd_misc_fops = {
.open = open_miscdrv,
.read = read_miscdrv,
.write = write_miscdrv,
.release = close_miscdrv,
.llseek = no_llseek, /* 明确声明不支持 seek */
};
然后,我们实现 open 方法。在这个方法里,我们可以做权限检查、资源初始化,或者像现在这样,只是打印一些调试信息。
static int open_miscdrv(struct inode *inode, struct file *filp)
{
char *buf = kzalloc(PATH_MAX, GFP_KERNEL);
if (unlikely(!buf))
return -ENOMEM;
PRINT_CTX(); // 打印当前进程上下文信息
pr_info(" opening \"%s\" now; wrt open file: f_flags = 0x%x\n",
file_path(filp, buf, PATH_MAX), filp->f_flags);
kfree(buf);
return nonseekable_open(inode, filp);
}
这里用到了 file_path() API 来获取设备节点的路径名。注意,它需要一个内核缓冲区(buf),用完记得 kfree。
接下来是 read 方法。这是最简单的实现:
static ssize_t read_miscdrv(struct file *filp, char __user *ubuf, size_t count, loff_t *off)
{
pr_info("to read %zd bytes\n", count);
return count; /* 假装读取了 count 字节 */
}
此时,如果你用 dd if=/dev/llkd_miscdrv of=readtest bs=4k count=1 去测试,dd 会成功,并且得到一个全是 0 的文件。为什么?因为我们的驱动并没有真的把数据拷贝到 ubuf,只是返回了成功(count)。用户空间 dd 的缓冲区原本就是 0,所以它读到了 0。
真正的数据搬运:用户空间与内核空间
上一个驱动是“假”的。一个真正的驱动,需要把硬件数据(或内核数据)搬运给用户。
这就涉及到了一个核心问题:内核空间和用户空间的内存是隔离的。 你不能简单地用 memcpy() 在两者之间拷贝数据,这不仅不安全,而且在某些架构上直接就挂了。
内核提供了两个专用的一级 API:
copy_to_user(void __user *to, const void *from, unsigned long n):从内核拷贝到用户。copy_from_user(void *to, const void __user *from, unsigned long n):从用户拷贝到内核。
这两个函数会检查用户空间的地址是否合法、是否可写。它们可能会触发缺页异常,导致进程休眠,所以绝对不能在中断上下文或持有自旋锁时使用它们。
使用 copy_to_user
static ssize_t read_method(struct file *filp, char __user *ubuf, size_t count, loff_t *off)
{
char *kbuf = kzalloc(...); // 内核缓冲区
if (!kbuf) return -ENOMEM;
/* 假设这里已经从硬件读到了 kbuf 里 */
/* 拷贝到用户空间 */
if (copy_to_user(ubuf, kbuf, count)) {
dev_warn(dev, "copy_to_user() failed\n");
kfree(kbuf);
return -EFAULT;
}
kfree(kbuf);
return count; /* 返回成功读取的字节数 */
}
如果 copy_to_user 返回非 0,说明没有完全拷贝成功(通常是用户空间地址非法),此时返回 -EFAULT 是标准做法。
进阶实战:带“秘密”的 misc 驱动
现在,我们要写一个更完整的驱动:ch1/miscdrv_rdwr。
这个驱动会在内核里保存一个“秘密字符串”。用户可以 read 它来获取秘密,也可以 write 它来更新秘密。
为了做到这一点,我们需要定义一个驱动上下文结构体(Private Data),用来存储全局状态。
/* 驱动上下文结构体 */
struct drv_ctx {
struct device *dev;
int tx, rx, err;
char oursecret[128]; /* 秘密存储地 */
};
static struct drv_ctx *ctx;
初始化
在 init 函数中,我们分配内存并初始化:
ctx = devm_kzalloc(dev, sizeof(struct drv_ctx), GFP_KERNEL);
if (unlikely(!ctx))
return -ENOMEM;
ctx->dev = dev;
strscpy(ctx->oursecret, "initmsg", 8); /* 初始化秘密 */
注意这里使用了 devm_kzalloc。这是资源管理 版本的 kzalloc。当驱动卸载时,内核会自动释放这块内存,不需要我们手动 kfree。这大大减少了内存泄漏的风险。
Read 方法实现
static ssize_t read_miscdrv_rdwr(struct file *filp, char __user *ubuf, size_t count, loff_t *off)
{
int secret_len = strlen(ctx->oursecret);
struct device *dev = ctx->dev;
if (count < secret_len) return -EINVAL; /* 缓冲区太小 */
/* 核心操作:把秘密发给用户 */
if (copy_to_user(ubuf, ctx->oursecret, secret_len)) {
dev_warn(dev, "copy_to_user() failed\n");
return -EFAULT;
}
/* 更新统计信息 */
ctx->tx += secret_len;
dev_info(dev, " %d bytes read, returning... (stats: tx=%d rx=%d)\n",
secret_len, ctx->tx, ctx->rx);
return secret_len;
}
Write 方法实现
static ssize_t write_miscdrv_rdwr(struct file *filp, const char __user *ubuf, size_t count, loff_t *off)
{
void *kbuf = NULL;
struct device *dev = ctx->dev;
if (unlikely(count > MAXBYTES)) return -EFBIG; /* 数据太大 */
/* 分配临时内核缓冲区 */
kbuf = kvmalloc(count, GFP_KERNEL);
if (unlikely(!kbuf)) return -ENOMEM;
/* 从用户拿数据 */
if (copy_from_user(kbuf, ubuf, count)) {
dev_warn(dev, "copy_from_user() failed\n");
kvfree(kbuf);
return -EFAULT;
}
/* 更新秘密 */
strscpy(ctx->oursecret, kbuf, count);
/* 更新统计 */
ctx->rx += count;
dev_info(dev, " %zd bytes written, returning... (stats: tx=%d rx=%d)\n",
count, ctx->tx, ctx->rx);
kvfree(kbuf);
return count;
}
现在,你可以编译这个驱动,写一个用户态程序去 read 和 write,你会看到“秘密”在内核和用户之间传递。
安全问题:当驱动成为噩梦
这里有一个非常严肃的转折点。你可能觉得上面的代码逻辑很简单,但哪怕是一行代码写错,内核的安全大门就会洞开。
还记得引子里提到的“为什么不能随意写内核”吗?因为你在 Ring 0。
我们来看看如果故意写错一个指针,会发生什么。我们编写一个“坏”驱动 bad_miscdrv。
场景一:Read 的坑
假设我们在 read 中,不小心把目标地址写错了,或者用户传了一个恶意地址。
/* 错误示范 */
new_dest = ubuf + (512*1024); /* 指向非法位置 */
copy_to_user(new_dest, ctx->oursecret, secret_len);
现代内核有 KASAN (Kernel Address Sanitizer) 和 access_ok() 检查。这种非法访问通常会导致 copy_to_user 失败,返回 -EFAULT,用户空间会收到 "Bad address" 错误。这虽然会导致程序崩溃,但至少不会立刻导致系统提权。
场景二:Write 的坑 —— 提权攻击
这才是恐怖的地方。copy_from_user 的目标地址是内核缓冲区。如果我们能控制这个目标地址,我们就能向内核内存的任意位置写入数据。
Linux 进程的权限信息保存在 task_struct 里的 struct cred 结构中。其中 uid 如果为 0,就是 Root。
想象一下,如果驱动的 write 方法里有这样的漏洞:
/* 假设驱动里有一个逻辑漏洞,导致 new_dest 被我们控制 */
new_dest = ¤t->cred->uid; /* 极其危险的写法! */
count = 4; /* uid 是 32 位整数 */
copy_from_user(new_dest, ubuf, count);
如果用户空间发送 4 个字节的 0,这段代码就会把当前进程的 UID 覆盖为 0。
后果:
write系统调用返回。- 用户进程检查自己的 UID —— 变成了 0(Root)。
- 进程弹出一个 Root Shell。
这就是内核提权 最经典的原理。虽然这只是一个演示代码,但历史上无数 CVE 都是由于类似的指针错误、边界检查缺失导致的。
还记得开头那个问题吗 —— 为什么驱动开发不能像写 Python 那样随意? 现在你应该能回答了:因为任何一次随意的内存访问,都可能变成通向 Root 的一扇后门。我们写的每一行
copy_from_user和边界检查,其实都是在加固这扇门。
本章回响
本章真正在做的事情,是建立 “用户态与内核态交互” 这个认知的底层形式。表面上我们在配置一个 misc 设备,实际上我们在理解为什么 copy_to_user 必须存在,以及为什么 struct cred(进程凭证)是内核安全的最后一道防线。
我们不仅写了代码,还拆解了代码背后的数据流:从用户空间的 open() 调用,穿透 VFS 层,最终落入驱动模块的 C 函数指针上。这就像是看到汽车的引擎盖下,火花塞是如何点火的。
下一章我们会把这个机制推进到一个新的场景里——届时你会发现,今天建立的直觉会以一种意想不到的方式派上用场:当真正的硬件中断发生时,这个简单的读写模型会变得更加复杂,也更加迷人。
练习题
练习 1:understanding
题目:在 Linux 设备模型中,如果查看 /dev 目录下的设备节点 miscdrv,其属性显示为 crw-rw-rw- 1 root root 10, 56, ...。请根据本章内容解释这三个数字分别代表什么含义?它们是如何在系统启动或驱动加载时确立的?
答案与解析
答案:10 代表主设备号,56 代表次设备号,1 代表硬链接计数(这里显示的不是设备号,而是节点属性)。
解析:在 Linux 设备驱动中,10 是该设备的主设备号,标识设备类型(此处为 misc 杂项字符设备)。56 是次设备号,由内核动态分配或驱动指定,用于区分同一主设备号下的不同设备实例。1 是文件系统层面的硬链接计数,不是设备标识。
本章提到,misc 类设备共享主设备号 10。当驱动调用 misc_register() 并传入 MISC_DYNAMIC_MINOR 时,内核会自动分配一个空闲的次设备号(此处为 56)。主设备号是内核静态分配给 misc 类的,用于在 VFS 层索引到正确的驱动处理函数。
练习 2:application
题目:假设你正在编写一个字符设备驱动,并且需要在 read 方法中将内核缓冲区 kbuf 中的 100 字节数据安全地传输给用户空间。为了保证内核安全性并正确处理可能的页面错误,你应该使用哪个内核 API?如果直接使用 memcpy 会导致什么后果?
答案与解析
答案:应使用 copy_to_user。直接使用 memcpy 会导致内核安全性崩溃或系统不稳定(因为用户空间指针可能无效或触发页面错误,内核无法直接处理)。
解析:根据章节内容,内核空间和用户空间的内存访问受到严格限制。copy_to_user 专门用于将数据从内核空间复制到用户空间,它不仅执行复制,还会检查用户空间地址的有效性。如果指针无效,它会返回未复制的字节数而不是导致系统崩溃。
直接使用 memcpy 会绕过这些安全检查,如果用户空间提供的地址是无效的(例如映射了只读内存或未映射地址),内核会触发异常,通常导致 Oops(内核崩溃)。
练习 3:application
题目:在设计 misc 驱动的 file_operations 时,如果我们不打算支持 llseek 功能,仅仅将 .llseek 成员设为 NULL 是不够的。为什么?正确的做法是什么?
答案与解析
答案:因为 VFS 层对于 llseek 默认处理机制可能返回随机的正数(模拟成功),而不是返回错误。正确做法是将 .llseek 赋值为 no_llseek,并在 open 方法中调用 nonseekable_open。
解析:根据章节 'Handling unsupported methods' 的描述,如果简单地将 llseek 设为 NULL,默认的 VFS 处理可能会修改文件位置指针并返回一个看似成功的随机正值,这会误导用户空间程序。
为了明确告知用户空间该设备不支持 seek,必须将 .llseek 显式设置为内核提供的 no_llseek 函数。此外,最佳实践是在驱动的 open 回调中调用 nonseekable_open(inode, filp) 来彻底标记该文件不可寻址。
练习 4:thinking
题目:本章介绍了 devm_kzalloc(资源管理分配)用于驱动内存分配。请思考并分析:在驱动的 probe 方法(或 init 函数)中分配内存时,使用传统的 kmalloc + kfree 手动管理方式与使用 devm_kzalloc 相比,在处理“设备初始化失败”或“驱动卸载”这两种错误路径时,哪一个更容易出现资源泄漏?为什么?
答案与解析
答案:传统手动管理(kmalloc/kfree)更容易出现资源泄漏。
解析:在驱动开发中,初始化流程往往包含多个步骤(分配内存、注册设备、申请 IRQ 等)。如果在中间某一步失败,传统方式必须小心地回滚并释放之前分配的所有资源,这容易遗漏某些 kfree 调用,导致内存泄漏。
而 devm_kzalloc 将内存生命周期绑定到设备本身。当设备从内核移除或驱动卸载时,内核会自动释放所有通过 devm API 分配的资源。这消除了编写繁琐的错误处理回滚代码的需求,大大降低了在复杂初始化逻辑中发生资源泄漏的风险。这体现了现代 Linux 内核设计中利用对象生命周期管理来简化驱动开发的思路。
要点提炼
用户空间与内核空间存在严格的界限,内核驱动运行在最高特权级,任何微小的内存访问错误都可能导致系统崩溃或严重的安全漏洞,因此不能像编写用户态程序那样随意。驱动开发本质上是在建立一条受控的通道,让普通程序能够安全地通过内核与硬件交互,这种从“被保护”到“裸奔”的环境转换是开发者首先需要建立的心理认知。
Linux 通过“一切皆文件”的抽象将设备接入系统,VFS(虚拟文件系统)层充当了用户请求与驱动代码之间的调度员。当用户程序调用 read 或 write 时,请求并非直达硬件,而是被 VFS 拦截并查找对应的 file_operations 结构体,该结构体存储了驱动开发者注册的函数指针(如 .open, .read),VFS 通过这些指针跳转执行具体的驱动逻辑。
为了避免手动管理主设备号的繁琐与冲突,现代驱动开发通常采用 misc(杂项)类机制,这是一种将所有非标准设备统一挂载在主设备号 10 下的方案。内核根据次设备号来区分具体的 misc 设备,开发者只需调用 misc_register() 并填充设备名称和操作接口,内核便会自动在 /dev 下创建对应的设备节点。
由于内核空间与用户空间的内存页表是隔离的,驱动代码绝对不能使用 memcpy 直接交换数据,必须使用 copy_to_user 和 copy_from_user 这两个专用 API。这两个函数不仅负责数据搬运,还会严格检查用户空间指针的合法性,防止内核因访问非法地址而 panic,同时确保在指针无效时安全返回错误而非崩溃。
安全是驱动开发的生命线,不严谨的边界检查往往是系统提权的源头。如果在 write 操作中缺乏对数据长度的验证,攻击者可能通过缓冲区溢出覆盖内核内存中的关键数据结构(如 struct cred 中的 UID),从而将普通进程权限提升为 Root。正确使用 devm_kzalloc 等托管接口以及严格的参数校验,是加固这扇内核大门的关键手段。