跳到主要内容

1.2 技术准备与环境

在真正动手敲代码之前,我们需要确保环境就绪,并且对即将面对的这个“怪兽”——Linux 内核驱动模型——有一个心理准备。

本章假设你已经浏览了前言部分,并在 Ubuntu 18.04 LTS(或更新版本)的虚拟机中安装了所有必要的包。如果还没做,强烈建议先完成这一步。

为了获得最佳体验,请务必克隆本书的 GitHub 仓库(Linux-Kernel-Programming-Part-2),并准备随时动手。

想象你站在内核的大门口。手里虽然有钥匙(源码),但如果没有地图(文档)和正确的鞋子(开发环境),走两步就会迷路或者崴脚。我们这一节的任务就是把装备整理好,然后试着推开门缝往里看一眼。


1.3 开始编写一个简单的 misc 字符设备驱动

这一节将是你的第一次实战。我们会先铺垫一些背景知识——关于设备文件、设备号和内核模型。然后,通过编写一个名为 misc 的字符驱动骨架,你将亲眼看到内核是如何将用户的请求“魔术般”地转化为驱动里的函数调用的。

理解设备的基础

在 Unix/Linux 的哲学里,一切皆文件。这句话你听得耳朵都起茧子了,但在驱动开发里,它意味着什么?

设备驱动 是连接 OS 和硬件的桥梁。它可以编译进内核镜像,也可以作为一个内核模块(LKM)动态加载。不管哪种形式,它都运行在内核空间,拥有最高权限(Ring 0)。

用户空间的程序要和硬件说话,必须经过这道门。为了不打破“一切皆文件”的设计,内核把设备也抽象成了一种特殊的文件——设备文件设备节点。它们通常住在 /dev 目录下。

为了区分成千上万的设备,内核给每个设备发了两张“身份证”:

  1. 类型:是字符设备还是块设备。
  2. 设备号:一个 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 */
}

代码拆解

  1. MISC_DYNAMIC_MINOR:这是一个宏。我们告诉内核:“帮我挑一个没人用的次设备号”。注册成功后,内核会把分配好的号码填回 llkd_miscdev.minor
  2. .name:名字很重要。misc 框架会利用这个名字,自动在 /dev 下创建一个同名的设备节点。这省得我们自己去敲 mknod 命令了。
  3. .mode0666 意味着所有人都可以读写这个设备。这在生产环境是大忌,但在调试阶段能省去很多权限问题的麻烦。
  4. .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,如果你不设置它,它可能返回一个随机值,导致用户空间误以为成功了。 正确的做法是:

  1. .llseek 显式赋值为 no_llseek
  2. 在你的 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:

  1. copy_to_user(void __user *to, const void *from, unsigned long n):从内核拷贝到用户。
  2. 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;
}

现在,你可以编译这个驱动,写一个用户态程序去 readwrite,你会看到“秘密”在内核和用户之间传递。


安全问题:当驱动成为噩梦

这里有一个非常严肃的转折点。你可能觉得上面的代码逻辑很简单,但哪怕是一行代码写错,内核的安全大门就会洞开。

还记得引子里提到的“为什么不能随意写内核”吗?因为你在 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 = &current->cred->uid; /* 极其危险的写法! */
count = 4; /* uid 是 32 位整数 */
copy_from_user(new_dest, ubuf, count);

如果用户空间发送 4 个字节的 0,这段代码就会把当前进程的 UID 覆盖为 0。

后果

  1. write 系统调用返回。
  2. 用户进程检查自己的 UID —— 变成了 0(Root)。
  3. 进程弹出一个 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(虚拟文件系统)层充当了用户请求与驱动代码之间的调度员。当用户程序调用 readwrite 时,请求并非直达硬件,而是被 VFS 拦截并查找对应的 file_operations 结构体,该结构体存储了驱动开发者注册的函数指针(如 .open, .read),VFS 通过这些指针跳转执行具体的驱动逻辑。

为了避免手动管理主设备号的繁琐与冲突,现代驱动开发通常采用 misc(杂项)类机制,这是一种将所有非标准设备统一挂载在主设备号 10 下的方案。内核根据次设备号来区分具体的 misc 设备,开发者只需调用 misc_register() 并填充设备名称和操作接口,内核便会自动在 /dev 下创建对应的设备节点。

由于内核空间与用户空间的内存页表是隔离的,驱动代码绝对不能使用 memcpy 直接交换数据,必须使用 copy_to_usercopy_from_user 这两个专用 API。这两个函数不仅负责数据搬运,还会严格检查用户空间指针的合法性,防止内核因访问非法地址而 panic,同时确保在指针无效时安全返回错误而非崩溃。

安全是驱动开发的生命线,不严谨的边界检查往往是系统提权的源头。如果在 write 操作中缺乏对数据长度的验证,攻击者可能通过缓冲区溢出覆盖内核内存中的关键数据结构(如 struct cred 中的 UID),从而将普通进程权限提升为 Root。正确使用 devm_kzalloc 等托管接口以及严格的参数校验,是加固这扇内核大门的关键手段。