Skip to content

2.2 技术准备与通信路径全景 ​

在那之前,我们需要确保工具在手。

技术准备 ​

假设你已经跟过了前言中的「准备工作」部分,手上应该已经有一台装好了 Ubuntu 18.04 LTS(或更新稳定版)的虚拟机,并且该装的包都装好了。如果你还没弄,强烈建议先去搞定这个——磨刀不误砍柴工。

为了榨干这本书的价值,我还强烈建议你把工作环境先搭好,先把这本书的 GitHub 仓库(https://github.com/PacktPublishing/Linux-Kernel-Programming-Part-2)clone 下来。我们要动手,不动眼。

用户-内核通信路径全景 ​

我们在引言里提到过,本章的核心任务是搞定内核空间组件(通常是设备驱动,但也可能是任何内核模块)与用户空间进程(或线程)之间的高效信息传输。要开始干活,先得盘点一下我们手里到底有哪些牌。

你可以把用户空间的组件想象成一个 C 程序、一个 Shell 脚本(我们在书里常展示这两种),甚至是 Python/Perl 脚本之类的玩意儿。

在上一章,我们已经摸到了一点边角:系统调用 API。这是用户空间应用和内核(包括其中的设备驱动)打交道的根本大道。上一章里,你学会了写一个简单的字符设备驱动,也学会了用户空间程序怎么通过 read(2) 和 write(2) 系统调用来传递数据。这会导致 VFS 调用你的驱动里的 read/write 方法,然后你用 copy_{from|to}_user() 这套 API 完成了数据搬运。

这时候你可能会问:这事儿不已经完了吗?还有什么好学的?

啊,那可太多了。

现实是,除了标准的 read/write,还有一堆其他的接口技术。它们当然都离不开系统调用——毕竟没有别的路能从用户空间同步地进到内核里。但路不同,风景就不同。本章的目的就是把这些路都展示给你看。当然,没有哪种方法是万能银弹,具体选哪个,得看项目里你是想切菜还是砍柴。

来看看本章我们要覆盖的这几种通信手段:

  • 通过传统的 procfs 接口
  • 通过 sysfs
  • 通过 debugfs
  • 通过 netlink 套接字
  • 通过 ioctl(2) 系统调用

我们会通过代码示例,把这些技术的里里外外都讲透。除此之外,我们还会顺带聊一聊它们在「调试」这个场景下到底好不好用。


2.3 通过 procfs 接口 ​

这一节我们要聊的是 proc 文件系统(procfs),以及怎么用它架起用户空间和内核空间的桥梁。这是一个强大且编程简单的接口,以前常用来汇报状态和调试核心内核子系统。

但在此之前,我得先泼一盆冷水:从 Linux 2.6 版本开始,如果你想向内核主线贡献代码,那么这个接口对驱动作者来说是禁用的——它严格仅限于内核内部使用。虽然如此,为了知识体系的完整性,我们还是得讲讲它。

理解 proc 文件系统 ​

Linux 有个叫 proc 的虚拟文件系统,默认挂载点在 /proc。

理解 procfs 的第一件事,就是接受一个反直觉的事实:它里面的内容不在磁盘上。它们在 RAM 里,是易失的。你在 /proc 下看到的那些文件和目录,都是内核代码为了 procfs 机制而设立的伪文件。为了暗示这一点,内核(几乎)总是把这些文件的大小显示为 0:

bash
$ mount | grep -w proc
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
$ ls -l /proc/
total 0
dr-xr-xr-x  8 root  root          0 Jan 27 11:13 1/
dr-xr-xr-x  8 root  root          0 Jan 29 08:22 10/
-r--r--r--  1 root  root          0 Jan 29 08:22 consoles
-r--r--r--  1 root  root          0 Jan 29 08:19 cpuinfo
-r--r--r--  1 root  root          0 Jan 29 08:20 devices
[...]
-r--r--r--  1 root  root          0 Jan 29 08:22 vmstat
-r--r--r--  1 root  root          0 Jan 29 08:22 zoneinfo
$

我们可以总结几个关于 procfs 的关键点:

  • /proc 下的对象(文件、目录、软链接等)都是伪对象,它们活在 RAM 里!

/proc 下的目录 ​

那些名字是整数值的目录,代表当前系统中活着的进程。这个目录名就是进程的 PID(准确说是 TGID,我们在《Linux 内核编程》配套指南里讲过这俩的区别)。

这个 /proc/PID/ 文件夹包含了该进程的所有详细信息。比如,对于 init 或 systemd 进程(永远是 PID 1),你可以在 /proc/1/ 下查到它的所有细节(属性、打开的文件、内存布局、子进程等)。

举个例子,我们在 x86_64 虚拟机上弄个 root shell 看看 /proc/1/ 里面有什么:

(此处展示查看 /proc/1 目录内容的截图)

关于 /proc/<PID>/... 下这些伪文件和文件夹的完整细节,可以去查 proc(5) 的 man 手册(man 5 proc),强烈建议去翻翻。

注意:/proc 下的具体内容取决于内核版本和(CPU)架构。通常 x86_64 下的内容是最丰富的。

procfs 存在的目的 ​

procfs 的目的主要有两个:

  1. 它提供了一个简单的接口,让开发者、系统管理员(或者任何人)能深入窥探内核内部,从而获取进程、内核甚至硬件的内部信息。使用这个接口只需要你懂点基础 shell 命令,比如 cd、cat、echo、ls 就行了。
  2. 作为 root 用户(有时甚至是所有者),你可以向 /proc/sys 下的某些伪文件写入数据,从而动态调整内核参数。这个功能叫 sysctl。比如,你可以在 /proc/sys/net/ipv4/ 下调整各种 IPv4 网络参数。

修改一个基于 proc 的可调参数非常简单。举个例子,我们来改一下系统允许的最大线程数。以 root 身份运行以下命令:

bash
# cat /proc/sys/kernel/threads-max
15741
# echo 10000 > /proc/sys/kernel/threads-max
# cat /proc/sys/kernel/threads-max
10000
#

这就完事了。不过显然,上面的操作是易失的——改动仅限当前会话,断电重启肯定打回原形。那怎么才能永久生效呢?简单答案是:用 sysctl(8) 工具。具体细节请参考它的 man 手册。

现在准备好写 procfs 接口代码了吗?别急——下一节告诉你为什么这可能不是个好主意。

procfs 对驱动作者来说是禁区 ​

虽然我们可以用 procfs 来跟用户空间应用打交道,但这里有个关键点需要注意!

你必须意识到,procfs 和内核里许多类似的设施一样,属于 ABI(应用二进制接口)。内核社区不承诺它会保持稳定,或者永远维持现在的样子,就像对待内核内部 API 和数据结构那样。事实上,从 2.6 内核开始,内核大佬们已经把话说得很死——设备驱动作者(及类似角色)不应该为了自己的目的使用 procfs,无论是调试还是其他。

说实话,这个规矩挺反直觉的——毕竟它挺好用,API 也简单,但内核社区就是不让用了。

在早期的 2.6 Linux 中,用 proc 干这事儿(按照内核社区的说法,这是「滥用」,因为 proc 是专为内核内部设计的!)还挺常见的。既然 procfs 被视为禁区,那驱动作者该用什么设施跟用户空间进程通信呢?驱动作者应该使用 sysfs 设施来导出接口。实际上,不仅仅是 sysfs,你还有好几个选择:sysfs、debugfs、netlink 套接字以及 ioctl 系统调用。我们稍后会在本章详细讲这些。

不过,先等一下。现实情况是,关于「驱动作者不该用 procfs」的这条规则,主要是针对社区的。这意味着,如果你打算把驱动或内核模块上游到主线内核,也就是在 GPLv2 许可证下贡献代码,那么所有社区规则绝对适用。如果你不打算上游,那其实取决于你自己。当然,遵循内核社区的指南和规则总归是好事,我们强烈建议你这么做。

使用 procfs 接口用户空间 ​

作为内核模块或设备驱动开发者,我们其实可以在 /proc 下创建自己的条目,把它作为一个通往用户空间的简单接口。怎么做到的?内核提供了一堆 API 让我们在 procfs 下创建目录和文件。我们在这一节来学学怎么用。

基础 procfs API ​

这里我们不打算深究 procfs API 集的血腥细节,只讲够让你理解和使用的那部分。想看细节,就去查终极资源:内核代码库。我们要讲的这些例程都已经导出了,所以对你这样的驱动作者来说是可用的。另外,正如前面提到的,所有 procfs 文件对象实际上都是伪对象,意思是它们只存在于 RAM 中。

这里假设你已经懂怎么设计并实现一个简单的 LKM 了;更多细节请参考本书的配套指南《Linux 内核编程》的第四、五章。

我们来探索几个简单的 procfs API,它们能帮你干几件关键的事——在 procfs 下创建目录、在里面创建(伪)文件,以及删除它们。做这些事之前,确保 include 了相关的头文件:#include <linux/proc_fs.h>。

第一步:创建文件夹

首先,我们得有个「房间」来放我们的东西。在 /proc 下创建一个名为 name 的目录:

c
struct proc_dir_entry *proc_mkdir(const char *name,
                         struct proc_dir_entry *parent);

第一个参数是目录名,第二个参数是要创建在其下的父目录指针。传 NULL 就表示在根目录下创建,也就是 /proc 下。保存返回值,因为后续 API 通常会用到它。

proc_mkdir_data() 这个例程还允许你顺便传一个数据项(void *);注意它是通过 EXPORT_SYMBOL_GPL 导出的。

第二步:创建文件

房间建好了,现在往里面放档案。创建一个名为 /proc/parent/name 的 procfs(伪)文件:

c
struct proc_dir_entry *proc_create(const char *name, umode_t mode,
                         struct proc_dir_entry *parent,
                         const struct file_operations *proc_fops);

这里最关键的参数是 struct file_operations,我们在上一章介绍过。你需要往里面填「方法」实现(稍后细讲)。想想看,这玩意儿威力巨大:通过 fops 结构,你可以在驱动(或内核模块)里设置「回调」函数,内核的 procfs 层会买账:当用户空间进程读你的 proc 文件时,它(VFS)会调用驱动的 .read 方法或回调函数。如果用户空间应用写,它就调驱动的 .write 回调!

第三步:清理战场

最后,如果你不想留着这些东西了,可以用 remove_proc_entry():

c
void remove_proc_entry(const char *name, struct proc_dir_entry *parent)

这个 API 会移除指定的 /proc/name 条目并释放它(如果没在使用);同样(通常更方便),可以用 remove_proc_subtree() API 来一次性删除 /proc 下的整个子树(通常在清理或出错时用)。

我们将创建的四个 procfs 文件 ​

为了清晰演示如何把 procfs 当作接口技术来用,我们的内核模块将在 /proc 下创建一个目录。在这个目录下,它将创建四个 procfs(伪)文件。注意,默认情况下,所有 procfs 文件的 owner:group 属性都是 root:root。

我们创建一个叫 /proc/proc_simple_intf 的目录,在它下面创建四个(伪)文件。下表列出了这四个文件的名称和属性:

procfs '文件'名称R: 用户空间 read 触发的回调动作W: 用户空间 write 触发的回调动作文件权限
llkdproc_dbg_level获取(向用户空间返回)全局变量 debug_level 的当前值更新 debug_level 全局变量的值为用户空间写入的值0644
llkdproc_show_pgoff获取(向用户空间返回)内核的 PAGE_OFFSET 值– 无写回调 –0444
llkdproc_show_drvctx获取(向用户空间返回)驱动「上下文」结构体 drv_ctx 的当前值– 无写回调 –0440
llkdproc_config1 (也被视为 dbg_level)获取(向用户空间返回)上下文变量 drvctx->config1 的当前值更新驱动上下文成员 drvctx->config1 的值为用户空间写入的值0644

稍后我们会看创建 /proc 下的 proc_simple_intf 目录及其中上述文件的 API 和实际代码。(由于篇幅限制,我们不会展示所有代码,只展示关于「debug level」获取和设置的部分;这没问题,剩下的代码概念上非常相似)。

尝试动态控制 debug_level 的 procfs ​

首先,来看看我们要用到的「驱动上下文」数据结构,本章我们会一直用它(其实在上一章我们也用过):

c
// ch2/procfs_simple_intf/procfs_simple_intf.c
[ ... ]
/* Borrowed from ch1; the 'driver context' data structure;
 * all relevant 'state info' reg the driver and (fictional) 'device'
 * is maintained here.
 */
struct drv_ctx {
    int tx, rx, err, myword, power;
    u32 config1; /* treated as equivalent to 'debug level' of our driver */
    u32 config2;
    u64 config3;
#define MAXBYTES   128
    char oursecret[MAXBYTES];
};
static struct drv_ctx *gdrvctx;
static int debug_level;    /* 'off' (0) by default ... */

这里我们还能看到一个名为 debug_level 的全局整型变量;这将提供对项目调试详细程度的动态控制。debug level 被赋值范围 [0-2],含义如下:

  • 0 表示无调试信息(默认)。
  • 1 表示中等调试详细度。
  • 2 表示高调试详细度。

整个设计的妙处——也是这节真正的要点——在于我们将能够通过创建的 procfs 接口从用户空间查询并设置这个 debug_level 变量!这将允许终端用户(出于安全原因,通常需要 root 权限)在运行时动态改变调试级别(这在很多产品中是个相当常见的功能)。

在深入代码细节之前,先试运行一下,好让你心里有个谱:

  1. 这里我们要用自己写的 lkm 便捷包装脚本来编译并 insmod(8) 内核模块(本节源码树的 ch2/proc_simple_intf):
bash
$ cd <booksrc>/ch2/proc_simple_intf
$ ../../lkm procfs_simple_intf          <-- builds the kernel module
Version info:
[...]
[24826.234323] procfs_simple_intf:procfs_simple_intf_init():321: 
proc dir (/proc/procfs_simple_intf) created
[24826.240592] procfs_simple_intf:procfs_simple_intf_init():333: 
proc file 1 (/proc/procfs_simple_intf/llkdproc_debug_level) created
[24826.245072] procfs_simple_intf:procfs_simple_intf_init():348: 
proc file 2 (/proc/procfs_simple_intf/llkdproc_show_pgoff) created
[24826.248628] procfs_simple_intf:alloc_init_drvctx():218: 
allocated and init the driver context structure
[24826.251784] procfs_simple_intf:procfs_simple_intf_init():368: 
proc file 3 (/proc/procfs_simple_intf/llkdproc_show_drvctx) created
[24826.255145] procfs_simple_intf:procfs_simple_intf_init():378: 
proc file 4 (/proc/procfs_simple_intf/llkdproc_config1) created
[24826.259203] procfs_simple_intf initialized
$

这里我们编译并插入了内核模块;dmesg(1) 显示了内核 printk,表明我们创建的 procfs 文件之一是关于动态调试功能的(这里加粗显示;因为这些都是伪文件,文件大小会显示为 0 字节)。

  1. 现在,我们来测试一下,查询当前 debug_level 的值:
bash
$ cat /proc/procfs_simple_intf/llkdproc_debug_level
debug_level:0
$

很好,是 0——默认值,符合预期。现在,我们把它改成 2:

  1. bash
    $ sudo sh -c "echo 2 > /proc/procfs_simple_intf/llkdproc_debug_level"
    $ cat /proc/procfs_simple_intf/llkdproc_debug_level
    debug_level:2
    $

注意看我们这里必须以 root 身份执行 echo。显然,debug level 确实变了(变成了 2)!如果试图写入超出范围的值,也会被捕获(并且 debug_level 变量的值会重置回上一个有效值),如下所示:

bash
$ sudo sh -c "echo 5 > /proc/procfs_simple_intf/llkdproc_debug_level"
sh: echo: I/O error
$ dmesg
[...]
[ 6756.415727] procfs_simple_intf: trying to set invalid value for
debug_level [allowed range: 0-2]; resetting to previous (2)

没错,效果符合预期。然而,问题来了:这在代码层面是怎么做到的?请往下读!

通过 procfs 动态控制 debug_level ​

我们来回答上述问题——代码里是怎么实现的?其实挺简单的:

  1. 首先,在内核模块的 init 代码里,我们必须创建我们的 procfs 目录,名字就用内核模块的名字:
c
static struct proc_dir_entry *gprocdir;
[...]
gprocdir = proc_mkdir(OURMODNAME, NULL);
  1. 同样在 init 代码里,创建控制项目「debug level」的 procfs 文件:
c
// ch2/procfs_simple_intf/procfs_simple_intf.c
[...]
#define PROC_FILE1           "llkdproc_debug_level"
#define PROC_FILE1_PERMS     0644
[...]
static int __init procfs_simple_intf_init(void)
{
    int stat = 0;
    [...]
    /* 1. Create the PROC_FILE1 proc entry under the parent dir OURMODNAME;
     * this will serve as the 'dynamically view/modify debug_level' 
     * (pseudo) file */
    if (!proc_create(PROC_FILE1, PROC_FILE1_PERMS, gprocdir,
                     &fops_rdwr_dbg_level)) {
    [...]
    pr_debug("proc file 1 (/proc/%s/%s) created\n", OURMODNAME, PROC_FILE1);
    [...]

这里我们用了 proc_create() API 来创建 procfs 文件,并把它与提供的 file_operations 结构体「钩连」起来了。

  1. fops 结构体(技术上讲是 struct file_operations)是这里的关键数据结构。正如我们在第 1 章《编写简单的 misc 字符设备驱动》中学到的,这是我们把功能分配给设备上各种文件操作的地方,或者像这个例子,是 procfs 文件。下面是我们初始化 fops 的代码:
c
static const struct file_operations fops_rdwr_dbg_level = {
    .owner = THIS_MODULE,
    .open = myproc_open_dbg_level,
    .read = seq_read,
    .write = myproc_write_debug_level,
    .llseek = seq_lseek,
    .release = single_release,
};
  1. fops 的 .open 方法指向一个我们必须定义的函数:
c
static int myproc_open_dbg_level(struct inode *inode, struct file *file)
{
    return single_open(file, proc_show_debug_level, NULL);
}

使用内核的 single_open() API,我们注册了这样一个事实:每当这个文件被读——最终是通过用户空间的 read(2) 系统调用进行的——procfs 就会「回调」我们的 proc_show_debug_level() 例程(传给 single_open() 的第二个参数)。

我们这里不打算深究 single_open() API 的内部实现;如果你好奇,可以自己去查:fs/seq_file.c:single_open()。

这里有点历史背景;不用太深究,只要知道旧版 procfs 的工作方式有问题。特别是,你不能传输超过一页的数据(通过 read 或 write),除非手动迭代内容。2.6.12 引入的序列迭代器功能修复了这些问题。现在,使用 single_open() 及其同类(seq_read、seq_lseek 和 seq_release 内置内核函数)是使用 procfs 更简单、更正确的方法。

  1. 那么,当用户空间向 proc 文件写入(通过 write(2) 系统调用)时呢?简单:在上面的代码中,你可以看到我们把 fops_rdwr_dbg_level.write 方法注册成了 myproc_write_debug_level() 函数,意味着只要这个(伪)文件被写入,这个函数就会被回调(稍后在第 6 步解释 read 回调)。

我们通过 single_open 注册的 read 回调函数代码如下:

c
/* Our proc file 1: displays the current value of debug_level */
static int proc_show_debug_level(struct seq_file *seq, void *v)
{
    if (mutex_lock_interruptible(&mtx))
        return -ERESTARTSYS;
    seq_printf(seq, "debug_level:%d\n", debug_level);
    mutex_unlock(&mtx);
    return 0;
}

seq_printf() 概念上类似于熟悉的 sprintf() API。它正确地把供给它的数据「打印」——也就是写入——到 seq_file 对象里。当我们这里说「打印」时,真正的意思是它有效地把数据缓冲区传递给了发起让我们跑到这儿来的 read 系统调用的用户空间进程或线程,实际上也就是把数据传到了用户空间。

哦对了,那 mutex_{un}lock*() 这几个 API 是干啥的?它们是为了非常关键的事情——加锁。我们会在第 6 章《内核同步——第 1 部分》和第 7 章《内核同步——第 2 部分》详细讨论锁;现在,你只要理解这些是必需的同步原语就行。

  1. 我们通过 fops_rdwr_dbg_level.write 注册的 write 回调函数如下:
c
#define DEBUG_LEVEL_MIN     0
#define DEBUG_LEVEL_MAX     2
[...]
/* proc file 1 : modify the driver's debug_level global variable as
   per what user space writes */
static ssize_t myproc_write_debug_level(struct file *filp,
                const char __user *ubuf, size_t count, loff_t *off)
{
   char buf[12];
   int ret = count, prev_dbglevel;
   [...]
   prev_dbglevel = debug_level;
   // < ... validity checks (not shown here) ... >
   /* Get the user mode buffer content into the kernel (into 'buf') */
   if (copy_from_user(buf, ubuf, count)) {
        ret = -EFAULT;
        goto out;
   }
   [...]
   ret = kstrtoint(buf, 0, &debug_level); /* update it! */
   if (ret)
        goto out;
   if (debug_level < DEBUG_LEVEL_MIN || debug_level > DEBUG_LEVEL_MAX) {
            [...]
            debug_level = prev_dbglevel;
            ret = -EFAULT; goto out;
   }
   /* just for fun, let's say that our drv ctx 'config1'
      represents the debug level */
   gdrvctx->config1 = debug_level;
   ret = count;
out:
   mutex_unlock(&mtx);
   return ret;
}

在我们的 write 方法实现中(注意看它的结构和字符设备驱动的 write 方法何其相似),我们做了一些有效性检查,然后通过通常的 copy_from_user() 函数把用户空间进程写给我们的数据复制进来(回想一下我们是怎么用 echo 命令写 procfs 文件的)。然后我们用了内核内置的 kstrtoint() API(还有好几个类似的)把字符串缓冲区转换成整数,把结果存到我们的全局变量里——也就是 debug_level!再次检查,如果一切正常,我们顺便(举个例子)把驱动上下文的 config1 成员设成同样的值,然后返回成功消息。

  1. 内核模块剩下的代码非常类似——我们要为剩下的三个 procfs 文件设置功能。这部分留给你去详细浏览代码并试试看。

  2. 再来个快速演示:我们把 debug_level 设为 1,然后转储驱动上下文结构体(通过我们创建的第三个 procfs 文件):

bash
$ cat /proc/procfs_simple_intf/llkdproc_debug_level
debug_level:0
$ sudo sh -c "echo 1 > /proc/procfs_simple_intf/llkdproc_debug_level"

好了,现在 debug_level 变量的值应该是 1 了。现在转储驱动上下文结构体:

  1. bash
    $ cat /proc/procfs_simple_intf/llkdproc_show_drvctx
    cat: /proc/procfs_simple_intf/llkdproc_show_drvctx: Permission denied
    $ sudo cat /proc/procfs_simple_intf/llkdproc_show_drvctx
    prodname:procfs_simple_intf
    tx:0,rx:0,err:0,myword:0,power:1
    config1:0x1,config2:0x48554a5f,config3:0x424c0a52
    oursecret:AhA xxx
    $

我们需要 root 权限来做这个。一旦搞定,我们可以清楚地看到 drv_ctx 数据结构的所有成员。不仅如此,我们还验证了 config1 成员(上面加粗显示)现在的值是 1,从而反映了按设计要求的「debug level」。

还要注意,这里的输出特意生成了高度可解析的格式传给用户空间,几乎是 JSON 风格的。当然,作为个小练习,你可以试着把它搞成标准的 JSON。

大量 recent 的物联网产品使用 RESTful API 来通信;解析的格式通常是 JSON。养成设计和实现内核到用户(反之亦然)通信时使用易于解析格式(比如 JSON)的习惯,只有好处。

这样,你就学会了怎么创建一个 procfs 目录、里面的文件,以及最重要的,怎么创建和使用 read/write 回调函数,以便当用户模式进程读写你的 proc 文件时,你能从内核深处做出适当响应。正如前面提到的,由于篇幅限制,我们不描述创建和使用其余三个 procfs 文件的代码。这在概念上跟我们刚才讲的非常类似。我们期望你通读代码并亲自试试!

其他杂项 procfs API ​

让我们用几个剩余的杂项 procfs API 来结束这一节。你可以用 proc_symlink() 函数在 /proc 下创建一个符号或软链接。

接下来,proc_create_single_data() API 可能会非常有用;它是个「快捷方式」,当你只需要给 procfs 文件挂一个「read」方法时可以用:

c
struct proc_dir_entry *proc_create_single_data(const char *name, umode_t mode,
        struct proc_dir_entry *parent, 
        int (*show)(struct seq_file *, void *),
        void *data);

使用这个 API 就不需要单独的 fops 数据结构了。我们可以用这个函数来创建并处理我们的第二个 procfs 文件——llkdproc_show_pgoff 文件:

c
... proc_create_single_data(PROC_FILE2, PROC_FILE2_PERMS, gprocdir,
proc_show_pgoff, 0) ...

当从用户空间读取时,内核的 VFS 和 proc 层代码路径会调用我们模块中注册的方法——也就是 proc_show_pgoff() 函数——在它里面我们简单调用一下 seq_printf() 把 PAGE_OFFSET 的值发给用户空间:

c
seq_printf(seq, "%s:PAGE_OFFSET:0x%px\n", OURMODNAME, PAGE_OFFSET);

注意:关于 proc_create_single_data API:

  • 你可以利用第五个参数传任意数据项给 read 回调(在那里可以通过 seq_file 成员 private 取回,非常像我们在上一章用 filp->private_data 那样)。
  • 内核主线里的几个(通常是较老的)驱动确实在使用这个函数来创建它们的 procfs 接口。其中就包括 RTC 驱动(它在 /proc/driver/rtc 设置了一个条目)。SCSI megaraid 驱动(drivers/scsi/megaraid)在设置它的 proc 接口时用了这个例程不下 10 次(当某个配置选项开启时;默认是开启的)。

小心! 我发现在运行发行版(默认)内核的 Ubuntu 18.04 LTS 系统上,这个 API——proc_create_single_data()——甚至不可用,所以构建会失败。在我们自定义的「原汁原味」5.4 LTS 内核上,它工作得很好。

此外,关于我们这里提到的 procfs API,确实有一些文档,虽然这些文档往往是给内部用的,而不是给模块用的:https://www.kernel.org/doc/html/latest/filesystems/api-summary.html#the-proc-filesystem。

所以,正如我们之前提到的,对于 procfs API 来说,情况就是「效果因人而异」(YMMV)!发布前仔细测试你的代码。最好的做法大概是遵循内核社区指南,直接对 procfs 作为驱动接口技术说不。别担心——本章后面我们会讲更好的!

这就完成了关于使用 procfs 作为有用通信接口的介绍。现在,让我们学习使用更适合驱动的那个——sysfs 接口。


2.4 通过 sysfs 接口 ​

2.6 Linux 内核发布的一个关键特性就是所谓的「现代设备模型」的问世。本质上,一系列复杂的树状层次数据结构对系统上的所有设备进行了建模。实际上,它远不止于此;sysfs 树包含了以下内容(除其他外):

  • 系统上的每一条总线(可以是虚拟或伪总线)
  • 每一条总线上的每一个设备
  • 绑定到总线上设备的每一个设备驱动

因此,不仅仅是外围设备,底层的系统总线、总线上的设备以及绑定(或将要绑定)到设备的设备驱动,都是在运行时创建并由设备模型维护的。作为典型的驱动作者,这些内部机制对你来说是不可见的;你其实不用操心它。系统启动时,以及每当新设备变得可见时,驱动核心(内置内核机器的一部分)会在 sysfs 树下生成所需的伪文件。(反之,当设备被移除或拔出时,它的条目就会从树中消失)。

回顾一下「通过 procfs 接口」那一节,使用 procfs 作为设备驱动接口目的其实并不是正确的路子,至少对于想进主线的代码来说。那么,正确的路子是什么?啊,创建 sysfs(伪)文件被认为是设备驱动与用户空间接口的「正确之道」。

我们现在看清了!sysfs 是一个通常挂载在 /sys 目录下的虚拟文件系统。实际上,sysfs 跟 procfs 非常像,是一个向用户空间导出的信息(设备及其他)树。

你可以把 sysfs 想象成设备的「一本实时体检报告」——就像医生给你开的那种。

但这个比喻有一个地方是错的:真正的体检报告打印出来就不会变了,而 sysfs 是动态的,内核会随时更新里面的数据。而且,这份报告是按条目收费的——每一个文件只能展示一项指标。这不仅是习惯,是硬性规定。

下面的截图展示了 /sys 内容,清楚地表明了这一点:

(此处展示 /sys 目录内容的截图)

在代码中创建 sysfs(伪)文件 ​

在 sysfs 下创建伪(或虚拟)文件的一种方法是通过 device_create_file() API。其签名如下:

c
drivers/base/core.c:int device_create_file(struct device *dev,
                         const struct device_attribute *attr);

让我们逐个看一下它的两个参数;首先,有一个指向 struct device 的指针。第二个参数是指向设备属性结构的指针;我们稍后(在「设置设备属性并创建 sysfs 文件」一节)会解释和操作它。现在,让我们只关注第一个参数——设备结构体。

这看起来很直观——一个设备由一个叫 device 的元数据结构体表示(它是驱动核心的一部分;你可以在 include/linux/device.h 头文件里查到它的完整定义)。

回到那个体检报告的类比:为了在你的报告里加一行,你得先确认你是挂在哪个「科室」下面的。

注意,当你编写(或处理)一个「真正的」设备驱动时,极大概率一个通用的 device 结构体已经存在或即将形成。这通常发生在注册设备时;一个底层的 device 结构体通常作为该设备专用结构体的一个成员存在。例如,所有像 platform_device、pci_device、net_device、usb_device、i2c_client、serial_port 这样的结构体,里面都嵌入了一个 struct device 成员。因此,你可以使用那个设备结构体指针作为 API 的参数,以便在 sysfs 下创建文件。请放心,你很快就会在代码中看到这样做了!那么,让我们通过创建一个简单的「platform 设备」来弄到一个 device 结构体。你将在下一节学会怎么做!

创建一个简单的 platform 设备 ​

显然,为了在 sysfs 下创建一个(伪)文件,我们需要某种方式搞到一个指向 struct device 的指针,作为 device_create_file() 的第一个参数。然而,对于我们现在这里的 demo sysfs 驱动,我们实际上没有任何真实设备,因此也没有 struct device 可用!

那么,我们不能创建一个人工的或伪设备并用它吗?可以,但是怎么做,更重要的是,为什么我们非得这么做?理解这一点至关重要:现代 Linux 设备模型(LDM)建立在三个关键组件之上:必须存在一个底层总线,设备存在于总线上,并且设备「绑定到」由设备驱动驱动。(我们已经在第 1 章《编写简单的 misc 字符设备驱动》的《关于 Linux 设备模型的简短说明》一节提到过这一点)。

所有这些都必须注册到驱动核心。现在,别担心总线和驱动总线的总线驱动;它们会由内核的驱动核心子系统内部注册和处理。然而,当没有真实设备时,我们将不得不创建一个伪设备,以便在这个模型里工作。同样,有几种方法可以做这种事,但我们将创建一个 platform 设备。这个设备将「活」在一个伪总线(也就是只存在于软件中的总线)上,被称为 platform 总线。

Platform 设备 ​

简单但重要的补充说明:platform 设备常用于代表嵌入式板上 SoC(片上系统)里的各种设备。SoC 通常是一个非常复杂的芯片,把各种组件集成到了硅片里。除了处理单元(CPU/GPU),它可能还容纳了几个外设,包括以太网 MAC、USB、多媒体、串口 UART、时钟、I2C、SPI、flash 芯片控制器等。我们需要把这些组件枚举为 platform 设备的原因是 SoC 内部没有物理总线;因此,用了 platform 总线。

以前,用来实例化这些 SoC platform 设备的代码保存在内核源码里的「板」文件(或几个文件)中(arch/<arch>/...)。因为变得太臃肿,它被移出了纯内核源码,进入了一个有用的硬件描述格式,叫设备树(在设备树源文件里,即 DTS 文件,它们随内核源码树一起)。

在我们的 Ubuntu 18.04 LTS 客户虚拟机上,让我们看看 sysfs 下的 platform 设备:

bash
$ ls /sys/devices/platform/
alarmtimer  'Fixed MDIO bus.0'   intel_pmc_core.0   platform-framebuffer.0
reg-dummy
serial8250 eisa.0  i8042  pcspkr power rtc_cmos uevent
$

Bootlin 网站(以前叫 Free Electrons)提供了关于嵌入式 Linux、驱动等 superb 的材料。他们网站上的这个链接提供了关于 LDM 的优秀材料:https://bootlin.com/pub/conferences/2019/elce/opdenacker-kernel-programming-device-model/。

回到驱动:我们通过 platform_device_register_simple() API 把我们的(人工)platform 设备注册到(已经存在的)platform 总线驱动,从而让它存在。一旦我们这么做,驱动核心就会生成所需的 sysfs 目录和一些样板式的 sysfs 条目(或文件)。在这里,我们 sysfs demo 驱动的 init 代码里,我们将通过把它注册到驱动核心来设置一个(尽可能简单的)platform 设备:

c
// ch2/sysfs_simple_intf/sysfs_simple_intf.c
include <linux/platform_device.h>
static struct platform_device *sysfs_demo_platdev;
[...]
#define PLAT_NAME    "llkd_sysfs_simple_intf_device"
sysfs_demo_platdev =
     platform_device_register_simple(PLAT_NAME, -1, NULL, 0);
[...]

platform_device_register_simple() API 返回一个指向 struct platform_device 的指针。这个结构体的成员之一是 struct device dev。我们现在搞到了我们要找的东西:一个 device 结构体。还要注意,当这个注册 API 运行时,效果在 sysfs 内是可见的。你可以很容易地看到新的 platform 设备,加上驱动核心创建的一些样板 sysfs 对象,在这里(通过 sysfs 对我们可见)变得可见;让我们编译并 insmod 我们的内核模块来看看:

bash
$ cd <...>/ch2/sysfs_simple_intf
$ make && sudo insmod ./sysfs_simple_intf.ko
[...]
$ ls -l /sys/devices/platform/llkd_sysfs_simple_intf_device/
total 0
-rw-r--r-- 1 root root 4.0K Feb 15 20:22 driver_override
-rw-r--r-- 1 root root 4.0K Feb 15 20:22 llkdsysfs_debug_level
-r--r--r-- 1 root root 4.0K Feb 15 20:22 llkdsysfs_pgoff
-r--r--r-- 1 root root 4.0K Feb 15 20:22 llkdsysfs_pressure
-r--r--r-- 1 root root 4.0K Feb 15 20:22 modalias
drwxr-xr-x 2 root root 0 Feb 15 20:22 power/
lrwxrwxrwx 1 root root 0 Feb 15 20:22 subsystem -> ../../../bus/platform/
-rw-r--r-- 1 root root 4.0K Feb 15 20:21 uevent
$

我们可以用不同方式创建 struct device;通用的方式是设置并发出 device_create() API。创建 sysfs 文件的另一种替代方法,同时绕过对 device 结构体的需求,是创建一个「对象」并调用 sysfs_create_file() API。(使用这两种方法的教程链接可以在「进一步阅读」部分找到)。这里,我们倾向于使用「platform 设备」,因为它更接近编写 驱动的方法。

还有另一种有效方法。正如我们在第 1 章《编写简单的 misc 字符设备驱动》中看到的,我们构建了一个符合内核 misc 框架的简单字符驱动。在那里,我们实例化了一个 struct miscdevice;一旦注册(通过 misc_register() API),这个结构体将包含一个叫 struct device *this_device; 的成员,从而允许我们使用它作为有效的设备指针!因此,我们本来可以简单地扩展我们之前的 misc 设备驱动并在这里使用它。然而,为了学一点关于 platform 驱动的知识,我们选择了那种方法。(我们把扩展之前的 misc 设备驱动使其可以使用 sysfs API 并创建/使用 sysfs 文件的工作留给你作为练习)。

回到我们的驱动,与 init 代码相比,在 cleanup 代码里,我们必须注销我们的 platform 设备:

c
platform_device_unregister(sysfs_demo_platdev);

现在,让我们把所有这些知识结合起来,看看实际生成 sysfs 文件的代码,以及它们的 read 和 write 回调函数!

一气呵成——设置设备属性并创建 sysfs 文件 ​

正如我们在本节开头提到的,device_create_file() API 是我们要用来创建 sysfs 文件的那个:

c
int device_create_file(struct device *dev, const struct device_attribute *attr);

在上一节,你学到了怎么获取 device 结构体(我们 API 的第一个参数)。现在,让我们弄清楚怎么初始化和使用第二个参数;也就是 device_attribute 结构体。该结构体定义如下:

c
// include/linux/device.h
struct device_attribute {
    struct attribute attr;
    ssize_t (*show)(struct device *dev, struct device_attribute *attr, 
                    char *buf);
    ssize_t (*store)(struct device *dev, struct device_attribute *attr, 
                     const char *buf, size_t count);
};

第一个成员 attr 本质上由 sysfs 文件的名称及其模式(权限位掩码)组成。另外两个成员是函数指针(「虚函数」,类似于 file operations 或 fops 结构体里的那些):

  • show:代表 read 回调函数
  • store:代表 write 回调函数

我们的任务是初始化这个 device_attribute 结构体,从而设置好 sysfs 文件。虽然你总是可以手动初始化它,但有更简单的路子:内核提供了(好几个)宏来初始化 struct device_attribute;其中之一是 DEVICE_ATTR() 宏:

c
// include/linux/device.h
#define DEVICE_ATTR(_name, _mode, _show, _store) \
   struct device_attribute dev_attr_##_name = __ATTR(_name, _mode, _show, _store)

注意由 dev_attr_##_name 执行的「字符串化」操作,确保结构体的名字后缀是传给 DEVICE_ATTR 的名字。此外,实际干活儿的宏,叫 __ATTR(),实际上在预处理时代码中实例化了一个 device_attribute 结构体,通过(字符串化)使得结构体的名字变成了 dev_attr_<name>:

c
// include/linux/sysfs.h
#define __ATTR(_name, _mode, _show, _store) { \
    .attr = {.name = __stringify(_name), \
    .mode = VERIFY_OCTAL_PERMISSIONS(_mode) }, \
    .show = _show, \
    .store = _store, \
}

此外,内核在这些宏之上定义了额外的简单包装宏,用来指定模式(sysfs 文件的权限),从而让你(驱动作者)更简单。其中包括 DEVICE_ATTR_RW(_name)、DEVICE_ATTR_RO(_name) 和 DEVICE_ATTR_WO(_name):

c
#define DEVICE_ATTR_RW(_name) \
     struct device_attribute dev_attr_##_name = __ATTR_RW(_name)
#define __ATTR_RW(_name) __ATTR(_name, 0644, _name##_show, _name##_store)

有了这个代码,我们可以创建一个读-写(RW)、只读(RO)或只写(WO)的 sysfs 文件。现在,我们想设置一个 sysfs 文件,既能读也能写。在内部,这是一个用来查询或设置 debug_level 全局变量的「钩子」或回调,就像我们之前在 procfs 示例内核模块里做的那样!

既然我们有足够的背景知识了,让我们深入代码吧!

实现 sysfs 文件及其回调的代码 ​

让我们来看看我们简单的 sysfs 接口驱动的相关代码部分,并一步步试运行:

  1. 设置设备属性结构体(通过 DEVICE_ATTR_RW 宏;更多信息见上一节)并创建我们的第一个 sysfs(伪)文件:
c
// ch2/sysfs_simple_intf/sysfs_simple_intf.c

#define SYSFS_FILE1 llkdsysfs_debug_level
// [... <we show the actual read/write callback functions just a bit further down> ...]
static DEVICE_ATTR_RW(SYSFS_FILE1);

int __init sysfs_simple_intf_init(void)
{
 [...]
/* << 0. The platform device is created via the 
    platform_device_register_simple() API; code already shown above ...
>> */

// 1. Create our first sysfile file : llkdsysfs_debug_level
 /* The device_create_file() API creates a sysfs attribute file for
  * given device (1st parameter); the second parameter is the pointer
  * to it's struct device_attribute structure dev_attr_<name> which was
  * instantiated by our DEV_ATTR{_RW|RO} macros above ... */
  stat = device_create_file(&sysfs_demo_platdev->dev, &dev_attr_SYSFS_FILE1);
[...]

从上面展示的宏定义,我们可以推断出 static DEVICE_ATTR_RW(SYSFS_FILE1); 实例化了一个初始化好的 device_attribute 结构体,名字是 llkdsysfs_debug_level(因为 SYSFS_FILE1 宏展开就是这个)并且模式是 0644;read 回调名会是 llkdsysfs_debug_level_show(),write 回调名会是 llkdsysfs_debug_level_store()!

  1. 这里是 read 和 write 回调的相关代码(同样,我们不会展示所有代码)。首先,看 read 回调:
c
/* debug_level: sysfs entry point for the 'show' (read) callback */
static ssize_t llkdsysfs_debug_level_show(struct device *dev,
                                          struct device_attribute *attr,
                                          char *buf)
{
        int n;
        if (mutex_lock_interruptible(&mtx))
                return -ERESTARTSYS;
        pr_debug("In the 'show' method: name: %s, debug_level=%d\n",
                 dev->kobj.name, debug_level);
        n = snprintf(buf, 25, "%d\n", debug_level);
        mutex_unlock(&mtx);
        return n;
}

这怎么工作的?当读我们的 sysfs 文件时,上面的回调函数会被调用。在它里面,简单地把数据写到用户空间提供的缓冲区指针 buf(它的第三个参数;我们用了内核的 snprintf() API 来干这事),效果就是把值(这里是 debug_level)传递给用户空间!

  1. 让我们编译并 insmod(8) 内核模块(为了方便,我们将使用我们的 lkm 包装脚本来做这件事):
bash
$ ../../lkm sysfs_simple_intf          // <-- build and insmod it
[...]
[83907.192247] sysfs_simple_intf:sysfs_simple_intf_init():237: 
sysfs file [1] (/sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level) created
[83907.197279] sysfs_simple_intf:sysfs_simple_intf_init():250: 
sysfs file [2] (/sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_pgoff) created
[83907.201959] sysfs_simple_intf:sysfs_simple_intf_init():264: 
sysfs file [3] (/sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_pressure) created
[83907.205888] sysfs_simple_intf initialized
$
  1. 现在,让我们列出并读取关于 debug-level 的 sysfs 文件:
bash
$ ls -l /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level
-rw-r--r-- 1 root root 4096 Feb   4 17:41 /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level
$ cat /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level
0

这反映了 debug-level 当前是 0。

  1. 现在,让我们瞄一下 debug-level sysfs 文件的 write 回调代码:
c
#define DEBUG_LEVEL_MIN 0
#define DEBUG_LEVEL_MAX 2

static ssize_t llkdsysfs_debug_level_store(struct device *dev,
                                           struct device_attribute *attr,
                                           const char *buf, size_t count)
{
        int ret = (int)count, prev_dbglevel;
        if (mutex_lock_interruptible(&mtx))
                return -ERESTARTSYS;

        prev_dbglevel = debug_level;
        pr_debug("In the 'store' method:\ncount=%zu, buf=0x%px count=%zu\n"
        "Buffer contents: \"%.*s\"\n", count, buf, count, (int)count, buf);
        if (count == 0 || count > 12) {
                ret = -EINVAL;
                goto out;
        }

        ret = kstrtoint(buf, 0, &debug_level); /* update it! */
        // < ... validity checks ... >
        ret = count;
 out:
        mutex_unlock(&mtx);
        return ret;
}

同样,应该清楚这里用了 kstrtoint() 内核 API 把用户空间 buf 字符串转换成整数值,然后我们验证它。而且,kstrtoint 的第三个参数是要写入的整数,从而更新了它!

  1. 现在,让我们试着从它的 sysfs 文件更新 debug_level 的值:
bash
$ sudo sh -c "echo 2 > /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level"
$ cat /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_debug_level
2
$

Voila——成功!

  1. 正如我们使用 procfs 接口时做的那样,我们在 sysfs 代码示例里提供了更多代码。这里,我们有另一个(只读)sysfs 接口来显示 PAGE_OFFSET 的值,外加一个新的。想象一下这个驱动的工作是检索一个「压力」值(也许是通过 I2C 驱动的压力传感器芯片)。假设我们已经做到了,并把压力值存在了一个名为 gpressure 的整型全局变量里。为了向用户空间「展示」当前压力值,我们必须使用一个 sysfs 文件。就是它:

在内部,为了演示目的,我们随机把 gpressure 全局变量设成了 25。

bash
$ cat /sys/devices/platform/llkd_sysfs_simple_intf_device/llkdsysfs_pressure
25$

仔细看输出;为什么提示符紧跟在 25 后面?因为我们只是按原样打印了值——没有换行符,什么都没有;这才是预期的。显示「压力」值的代码确实很简单:

c
/* show 'pressure' value: sysfs entry point for the 'show' (read) callback */
static ssize_t llkdsysfs_pressure_show(struct device *dev,
                       struct device_attribute *attr, char *buf)
{
        int n;

        if (mutex_lock_interruptible(&mtx))
                return -ERESTARTSYS;
        pr_debug("In the 'show' method: pressure=%u\n", gpressure);
        n = snprintf(buf, 25, "%u", gpressure);
        mutex_unlock(&mtx);
        return n;
}
/* The DEVICE_ATTR{_RW|RO|WO}() macro instantiates a struct device_attribute dev_attr_<name> here...   */
static DEVICE_ATTR_RO(llkdsysfs_pressure);

这样,你就学会了怎么通过 sysfs 与用户空间接口了!像往常一样,我强烈建议你实际写写代码并亲自试试这些技能;看一看本章末尾的「问题」部分,试着做做相关的作业。现在,让我们继续聊 sysfs,理解关于它 ABI 的一条重要规则。

「一个 sysfs 文件一个值」的规则 ​

到目前为止,你已经理解了怎么创建和使用 sysfs 来进行用户内核接口目的,但有一个我们一直忽略的关键点。关于使用 sysfs 文件有一条「规则」,规定你必须只读取或写入一个值!

让我们回到那份体检报告:你会发现,sysfs 就像是只能列出一项指标的报告单。你想看血压?这是一张纸。你想看心率?那是另一张纸。你不能像在 procfs 里那样,把整个人的病史都塞进一个文件里打印出来。

这就是为什么 sysfs 不适合用来转储复杂的结构体——它就像医生让你拿着「血压单」去问「我有啥病」,这俩不是一码事。

关于使用 sysfs 的内核文档和「规则」可以在这里找到:https://www.kernel.org/doc/html/latest/admin-guide/sysfs-rules.html#rules-on-how-to-access-information-in-sysfs。此外,这里也有关于 sysfs API 的文档:https://www.kernel.org/doc/html/latest/filesystems/api-summary.html#the-filesystem-for-exporting-kernel-objects。

内核通常提供好几种不同的方法来创建 sysfs 对象;例如,使用 sysfs_create_files() API,你可以一次性创建多个 sysfs 文件:int __must_check sysfs_create_files(struct kobject *kobj, const struct attribute * const *attr);。这里,你需要提供一个指向 kobject 的指针和一个指向属性结构体列表的指针。

这就结束了我们关于 sysfs 作为接口技术的讨论;总之,sysfs 确实被认为是驱动作者向用户空间显示和/或设置特定驱动值的正确方式。但由于「一个文件一个值」的死板规矩,它没办法像 procfs 那样随心所欲地打印调试信息。

这时候我们就得找替代方案了。


2.5 通过 debugfs 接口 ​

procfs 被内核大佬封杀后,sysfs 又因为「一个文件一个值」的规矩把路给堵死了。如果我们真的需要调试,该去哪里找路?

答案是 debugfs。

想象一下,你作为 Linux 驱动开发者面临的困境:你想实现一种简单而优雅的方式,从驱动向用户空间提供调试「钩子」。比如,用户只要简单地对一个(伪)文件执行 cat(1),就会导致驱动的「调试回调」函数被调用。然后它会转储一些状态信息(也许是「驱动上下文」结构体)给用户模式进程,后者会忠实地把它转储到 stdout。

好吧,没问题:在 2.6 版本之前的日子,我们可以(正如你在「通过 proc 文件系统接口」一节学到的)愉快地使用 procfs 层来把驱动和用户空间接口。然后,从 2.6 Linux 开始,内核社区否决了这种方法。我们被告知严格停止使用 procfs,改用 sysfs 层作为驱动与用户空间接口的手段。然而,正如我们在「通过 sys 文件系统」一节看到的,它有一个严格的单值文件规则。这对于向驱动报告或发送单个值(通常是环境传感器值之类的)确实很好,但很快就把几乎所有非平凡的调试接口都排除了。我们可以用 ioctl 方法(我们稍后会看到)来设置调试接口,但这要难办得多。

那你怎么办?幸运的是,从大约 2.6.12 Linux 开始,有一个优雅的解决方案叫 debugfs。「调试文件系统」非常易于使用,并且非常明确地传达了这样一点:驱动作者(实际上是任何人)可以为了他们选择的任何目的使用它!没有单值文件规则——忘了吧,这里没有规则。

当然,就像我们处理过的其他基于文件系统的方法一样——procfs、sysfs,以及现在的 debugfs——内核社区清楚地声称所有这些接口都是 ABI,因此,它们的稳定性和寿命是不保证的。虽然那是正式采用的立场,但现实是这些接口在现实世界中已经成为了事实上的标准;哪天不打招呼就把它们删了,谁也落不着好。

下面的截图展示了我们在 x86-64 Ubuntu 18.04.3 LTS 客户机(运行我们在配套书《Linux 内核编程》第 3 章《从源码构建 5.0 Linux 内核第 2 部分》里构建的「定制」5.4.0 内核)上 debugfs 的内容:

(此处展示 debugfs 内容的截图)

与 procfs 和 sysfs 一样,由于 debugfs 是一个内核特性(它毕竟是个虚拟文件系统!),它里面的具体内容高度依赖于内核版本和 CPU 架构。正如前面提到的,看这张截图应该很明显了,debugfs 有不少「真实世界」的用户。

检查 debugfs 的存在 ​

首先,为了使用强大的 debugfs 接口,必须在内核配置里启用它。相关的 Kconfig 宏是 CONFIG_DEBUG_FS。让我们检查一下在我们的 5.4 定制内核上是否启用了它:

这里我们假设你设置了 CONFIG_IKCONFIG 和 CONFIG_IKCONFIG_PROC 选项为 y,从而允许我们使用 /proc/config.gz 伪文件来访问当前内核的配置。

bash
$ zcat /proc/config.gz | grep -w CONFIG_DEBUG_FS
CONFIG_DEBUG_FS=y

确实是;通常发行版里默认是启用的。

接下来,debugfs 的默认挂载点是 /sys/kernel/debug。因此,我们可以看出它内部依赖于 sysfs 内核特性存在并被挂载,这通常是默认的。让我们检查一下 debugfs 挂载在我们的 Ubuntu 18.04 x86_64 虚拟机的哪里:

bash
$ mount | grep -w debugfs
debugfs on /sys/kernel/debug type debugfs (rw,relatime)

它是可用的,并且挂载在了预期的位置;也就是 /sys/kernel/debug。

当然,始终最好的一点是不要假设它永远挂载在那个位置;在你的脚本或用户模式 C 程序里,费点事检查并验证一下。事实上,允许我换种说法:永远不要假设任何事;做假设是 bug 的真正来源。

顺便说一句,Linux 有个有趣特性是文件系统可以挂载在不同甚至多个位置;另外,有些人喜欢创建一个到 /sys/kernel/debug 的符号链接叫 /debug;这其实取决于你。

跟往常一样,我们的目的是在 debugfs 的保护伞下创建我们的(伪)文件,然后注册并使用它们的 read/write 回调,以便将我们的驱动与用户空间接口。为此,我们需要理解 debugfs API 的基本用法。我们会在下一节指出相关文档。

查阅 debugfs API 文档 ​

内核提供了关于使用 debugfs API 的简洁而 superb 的文档(感谢 Jonathan Corbet,LWN):https://www.kernel.org/doc/Documentation/filesystems/debugfs.txt(当然,你也可以直接在内核代码库里查)。

我强烈建议你参考这个文档来学习怎么使用 debugfs API,因为它易读且易懂;这样,你可以避免不必要地在这里重复同样的信息。除了上述文档,现代内核文档系统(基于「Sphinx」的那个)也提供了相当详细的 debugfs API 页面:https://www.kernel.org/doc/html/latest/filesystems/api-summary.html?highlight=debugfs#the-debugfs-filesystem。

注意所有 debugfs API 都只作为 GPL 导出给内核模块(因此要求模块以「GPL」许可证发布(可以是双许可证,但其中之一必须是「GPL」))。

一个 debugfs 接口示例 ​

debugfs 是故意带着「没什么特定规则」的心态设计的,这使它成为调试目的的理想接口。为什么?它允许你构建任意字节流并发送给用户空间,包括二进制大块头(blob),用的就是 debugfs_create_blob() API。

我们之前带有 procfs 和 sysfs 的示例内核模块构建并使用了三到四个(伪)文件。为了快速演示 debugfs,我们将只坚持用两个「文件」:

  • llkd_dbgfs_show_drvctx:毫无疑问你猜到了,当读取它时,会导致当前「驱动上下文」数据结构的内容被转储到控制台;我们要确保伪文件的模式是只读的(仅限 root)。
  • llkd_dbgfs_debug_level:这个文件的模式将是读-写(仅限 root);读取时会显示 debug_level 的当前值;当写入一个整数时,我们将更新内核模块里 debug_level 的值为传入的值。

这里,在我们内核模块的 init 代码里,我们首先会在 debugfs 下创建一个目录:

c
// ch2/debugfs_simple_intf/debugfs_simple_intf.c

static struct dentry *gparent;
[...]
static int debugfs_simple_intf_init(void)
{
    int stat = 0;
    struct dentry *file1, *file2;
    [...]
    gparent = debugfs_create_dir(OURMODNAME, NULL);

既然我们有了起点——一个目录——让我们继续在它下面创建 debugfs(伪)文件。

创建并使用第一个 debugfs 文件 ​

为了可读性和节省篇幅,我们这里不展示错误处理代码段。

(此处省略部分代码讲解)

显然,「read」回调是我们的 dbgfs_show_drvctx() 函数。提醒一下,每当 debugfs 文件(llkd_dbgfs_show_drvctx)被读时,这个函数就会被自动 debugfs 层调用;这是我们的 debugfs read 回调函数代码:

c
static ssize_t dbgfs_show_drvctx(struct file *filp, char __user * ubuf,
                                 size_t count, loff_t * fpos)
{
    struct drv_ctx *data = (struct drv_ctx *)filp->f_inode->i_private;
                       // retrieve the "data" from the inode
#define MAXUPASS 256   // careful- the kernel stack is small!
    char locbuf[MAXUPASS];

    if (mutex_lock_interruptible(&mtx))
        return -ERESTARTSYS;

    /* As an experiment, we set our 'config3' member of the drv ctx stucture
     * to the current 'jiffies' value (# of timer interrupts since boot); 
     * so, every time we 'cat' this file, the 'config3' value should change!
     */
    data->config3 = jiffies;
    snprintf(locbuf, MAXUPASS - 1,
            "prodname:%s\n"
            "tx:%d,rx:%d,err:%d,myword:%d,power:%d\n"
            "config1:0x%x,config2:0x%x,config3:0x%llx (%llu)\n"
            "oursecret:%s\n",
            OURMODNAME,
            data->tx, data->rx, data->err, data->myword, data->power,
            data->config1, data->config2, data->config3, data->config3,
            data->oursecret);

    mutex_unlock(&mtx);
    return simple_read_from_buffer(ubuf, MAXUPASS, fpos, locbuf,
                                   strlen(locbuf));
}

注意我们是怎么通过解引用 debugfs 文件的 inode 成员(叫 i_private)来取回「数据」指针(我们的驱动上下文结构体)的。

正如我们在第 1 章《编写简单的 misc 字符设备驱动》中提到的,使用数据指针从文件的 inode 解引用驱动上下文结构体,是驱动作者为了避免使用全局变量而采用的几种类似常见技术之一。这里,gdrvctx 是个全局变量,所以其实无所谓;我们只是用它来演示典型的用例。

使用 snprintf() API,我们可以用当前驱动「上下文」结构体的内容填充一个本地缓冲区,然后通过 simple_read_from_buffer() API,把它传给发起 read 的用户空间应用,这通常会导致它被显示在终端/控制台窗口上。这个 simple_read_from_buffer() API 其实是 copy_to_user() 的一个包装。

让我们试运行一下:

bash
$ ../../lkm debugfs_simple_intf
[...]
[200221.725752] dbgfs_simple_intf: allocated and init the driver context structure
[200221.728158] dbgfs_simple_intf: debugfs file 1 <debugfs_mountpt>/dbgfs_simple_intf/llkd_dbgfs_show_drvctx created
[200221.732167] dbgfs_simple_intf: debugfs file 2 <debugfs_mountpt>/dbgfs_simple_intf/llkd_dbgfs_debug_level created
[200221.735723] dbgfs_simple_intf initialized

我们可以看到,两个 debugfs 文件按预期创建了;让我们验证一下(小心,你只能以 root 身份查看 debugfs):

bash
$ ls -l /sys/kernel/debug/dbgfs_simple_intf
ls: cannot access '/sys/kernel/debug/dbgfs_simple_intf': Permission denied
$ sudo ls -l /sys/kernel/debug/dbgfs_simple_intf
total 0
-rw-r--r-- 1 root root 0 Feb  7 15:58 llkd_dbgfs_debug_level
-r--r----- 1 root root 0 Feb  7 15:58 llkd_dbgfs_show_drvctx
$

伪文件已创建,权限也正确。现在,让我们(作为 root 用户)从 llkd_dbgfs_show_drvctx 文件读取:

bash
$ sudo cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_show_drvctx
prodname:dbgfs_simple_intf
tx:0,rx:0,err:0,myword:0,power:1
config1:0x0,config2:0x48554a5f,config3:0x102fbcbc2 (4345023426)
oursecret:AhA yyy
$

成功了;几秒后再执行一次读取。注意 config3 的值变了。为什么?回想一下我们把它设成了 jiffies 值——自系统启动以来发生的定时器「滴答」/中断数:

bash
$ sudo cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_show_drvctx | grep config3
config1:0x0,config2:0x48554a5f,config3:0x102fbe828 (4345030696)
$

既然创建并使用了我们的第一个 debugfs 文件,让我们理解第二个 debugfs 文件。

创建并使用第二个 debugfs 文件 ​

让我们继续第二个 debugfs 文件。我们将使用一个有趣的快捷 helper debugfs API,名叫 debugfs_create_u32()。这个 API 自动设置内部回调,允许你读/写驱动中指定的无符号 32 位全局变量。这个「helper」例程的主要好处是,你不需要显式提供 file_operations 结构体,甚至不需要任何回调例程。debugfs 层「理解」并在内部把事情摆平,使得读取或写入数字(全局)变量总是能行!看看 init 代码路径里的以下代码,它创建并设置我们的第二个 debugfs 文件:

c
static int debug_level;    /* 'off' (0) by default ... */
[...]
/* 3. Create the debugfs file for the debug_level global; we use the
    * helper routine to make it simple! There is a downside: we have no
    * chance to perform a validity check on the value being written.. */
#define DBGFS_FILE2     "llkd_dbgfs_debug_level"
file2 = debugfs_create_u32(DBGFS_FILE2, 0644, gparent, &debug_level);
[...]
pr_debug("%s: debugfs file 2 <debugfs_mountpt>/%s/%s created\n",
         OURMODNAME, OURMODNAME, DBGFS_FILE2);

就这么简单!现在,读取这个文件将产生 debug_level 的当前值;写入它将把值设为写入的值。让我们操作一下:

bash
$ sudo cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_debug_level
0
$ sudo sh -c "echo 5 > /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_debug_level"
$ sudo cat /sys/kernel/debug/dbgfs_simple_intf/llkd_dbgfs_debug_level
5
$

这能行,但这个「快捷」方式有个缺点:因为这都是内部搞定的,我们没法验证写入的值。因此,这里我们写入了值 5 给 debug_level;它成功了,但这其实是个无效值(至少让我们假设是这样!)。那这怎么修正?简单:别用这个 helper 方法;而是通过通用的 debugfs_create_file() API 用「常规」方式(就像我们对第一个 debugfs 文件做的那样)。好处在于,当我们在 fops 结构体里指定显式回调例程来读和写时,我们就能控制写入的值(我留给你作为练习)。就像生活一样,这是种权衡;有得必有失。

用于处理数字全局变量的 helper debugfs API ​

你刚刚学会了怎么用 debugfs_create_u32() helper API 来设置 debugfs 文件来读/写一个无符号 32 位整数全局。事实上,debugfs 层提供了一堆类似的「helper」API 来隐式读/写模块内数字(整数)全局变量。

用于创建可读/写不同位宽无符号整数(8、16、32 和 64 位)全局的 debugfs 条目的 helper 例程如下。最后一个参数是关键——内核/模块里全局整数的地址:

c
// include/linux/debugfs.h
struct dentry *debugfs_create_u8(const char *name, umode_t mode,
                 struct dentry *parent, u8 *value);
struct dentry *debugfs_create_u16(const char *name, umode_t mode,
                 struct dentry *parent, u16 *value);
struct dentry *debugfs_create_u32(const char *name, umode_t mode,
                 struct dentry *parent, u32 *value);
struct dentry *debugfs_create_u64(const char *name, umode_t mode,
                 struct dentry *parent, u64 *value);

上面的 API 使用十进制进制;为了让使用十六进制进制变简单,我们有以下 helper:

c
struct dentry *debugfs_create_x8(const char *name, umode_t mode,
                 struct dentry *parent, u8 *value);
struct dentry *debugfs_create_x16(const char *name, umode_t mode,
                 struct dentry *parent, u16 *value);
struct dentry *debugfs_create_x32(const char *name, umode_t mode,
                 struct dentry *parent, u32 *value);
struct dentry *debugfs_create_x64(const char *name, umode_t mode,
                 struct dentry *parent, u64 *value);

顺便说一句,内核还提供了一个 helper API 给那些变量大小不固定的情况;因此,使用 debugfs_create_size_t() helper 会创建一个适合 size_t 大小变量的 debugfs 文件。

对于只需要查看数字全局或更新它而不担心无效值的驱动来说,这些 debugfs helper API 非常有用,并且确实被主线内核里的几个驱动使用(我们稍后会看 MMC 驱动里的例子)。为了逃避「有效性检查」问题,通常我们可以安排用户空间应用(或脚本)来执行有效性检查;事实上,这通常是「正确」的做法。

UNIX 范式有句名言:提供机制,而非策略。

当处理布尔类型的全局变量时,debugfs 提供了以下 helper API:

c
struct dentry *debugfs_create_bool(const char *name, umode_t mode,
                  struct dentry *parent, bool *value);

读取「文件」将导致只返回 Y 或 N(后跟换行符);显然,如果第四个值参数的当前值非零,则为 Y,否则为 N。写入时,你可以写 Y 或 N 或 1 或 0;其他值将被拒绝。

想想看:你可以通过向一个名为 power 的布尔变量写入 1 来控制你的「机器人」设备,从而通过你的机器人设备驱动打开它,写入 0 来关闭它!可能性无限。

内核关于 debugfs 的文档还提供了几个杂项 API;我留给你去查。既然我们已经覆盖了怎么创建和使用我们的 demo debugfs 伪文件,让我们学习怎么移除它们。

移除 debugfs 伪文件 ​

当模块被移除(比如通过 rmmod(8))时,我们必须删除我们的 debugfs 文件。旧的方法是通过 debugfs_remove() API,每个 debugfs 文件都得单独用它移除(说得委婉点,很痛苦)。现代方法把这变得极其简单:

c
void debugfs_remove_recursive(struct dentry *dentry);

传指向整个「父」目录(我们第一个创建的那个)的指针,整个分支就被递归移除了;完美。

如果不在这里删除你的 debug

基于 VitePress 构建