大家好!我是大聪明-PLUS!
Linux 内核可以说是世界上最广泛使用(或许也最被低估)的软件之一。它是所有 Linux 发行版的基础(这毋庸置疑),但它的作用远不止于此。内核还为几乎所有嵌入式设备提供动力。你家有微波炉吗?很有可能,它运行的就是 Linux 内核。你的洗碗机呢?也一样。如果你有足够的钱买一辆特斯拉,你甚至可以找到一些 bug,修复它们,然后把补丁提交到 GitHub 上的 Model S 或 Model X 代码库。那么,那些防止国际空间站脱离轨道并坠入地球的电路呢?当然,它们也运行着 Linux 内核。内核非常轻量级,这意味着即使在零重力条件下也能完美运行。
Linux 内核的迭代周期可谓疯狂。例如,5.10 内核的发布统计数据显示,它新增了 252 位贡献者(顺便一提,这是自 5.6 版本以来新增贡献者数量最少的一次),而且新版本每九周就会发布一次。最终,内核是计算机行业诸多领域的坚实基础,但它绝非陈旧过时。这一切听起来都很棒,但如果你想了解它的工作原理,甚至编写自己的代码呢?这似乎是一项艰巨的任务,因为编程领域很少有学校或课程涉及这方面的内容。此外,与几乎每个月都会出现的最新热门 JavaScript 框架不同,你无法简单地在 Stack Overflow 上找到数百万个帖子来解决你遇到的每一个问题。
所以,你有兴趣为这个最稳定、运行时间最长的开源项目创建一个"Hello, World"程序吗?想深入了解操作系统理论吗?你喜欢用一种诞生于 70 年代的语言编程,并且当你的程序至少做对了某些事情时,你会获得巨大的成就感吗?太好了,因为我想不出比这更好的方式来消磨时间了。
注意:本文假设您已了解如何设置 Ubuntu 虚拟机。网上有很多相关资料,您可以选择自己喜欢的虚拟机管理程序进行设置。本文还假设您熟悉 C 语言,Linux 内核就是用 C 语言编写的。由于我们只是编写一个简单的"Hello, World"模块,所以不会涉及复杂的编程,但我不会解释 C 语言的基本概念。总之,代码应该相当简单明了。那么,让我们开始吧。
编写一个基础模块
首先,我们来定义一下什么是内核模块。普通的模块也称为驱动程序,它就像硬件和软件之间的API。大多数操作系统都有两个计算空间:内核空间和用户空间。Linux和Windows都是如此。用户空间负责用户交互,例如在Spotify上听音乐。内核空间则负责操作系统底层内部进程的一切。如果你在Spotify上听音乐,首先必须与他们的服务器建立连接,然后你电脑上的某个程序必须监控网络数据包,从中检索数据,并将其传输到你的扬声器或耳机,这样你才能听到声音。所有这些都在内核空间中完成。这里涉及的驱动程序之一,就是将通过网络端口传入的数据包转换为音乐的软件。该驱动程序本身具有类似API的接口,允许用户应用程序(甚至内核空间中的其他应用程序)调用其函数并接收数据包。
幸运的是,我们的模块不会太复杂,所以不用担心。它甚至不会与硬件交互。许多模块都是完全基于软件的。一个很好的例子是内核进程调度器,它决定处理器上的哪些核心处理哪些正在运行的进程。纯软件模块是学习内核模块开发的最佳起点。启动你的虚拟机,按 Ctrl+Alt+T 打开终端,然后运行命令......
sudo` apt update && `sudo` apt upgrade
`
......以确保您的软件是最新版本。接下来,让我们安装任务所需的新软件包。运行以下命令:
sudo` apt install `gcc` `make` build-essential libncurses-dev exuberant-ctags
`
现在我们终于可以开始编写代码了。让我们从简单的开始:将以下代码放在一个源文件中。我把我的文件放在了"文档**"文件** 夹中,并将其命名为dvt-driver.c。
`#include <linux/init.h>
#include <linux/module.h>
#include <linux/uaccess.h>
#include <linux/fs.h>
#include <linux/proc_fs.h>
// Module metadata
MODULE_DESCRIPTION("Hello world driver");
MODULE_LICENSE("GPL");
// Custom init and exit methods
static int __init custom_init(void) {
printk(KERN_INFO "Hello world driver loaded.");
return 0;
}
static void __exit custom_exit(void) {
printk(KERN_INFO "Goodbye my friend, I shall miss you dearly...");
}
module_init(custom_init);
module_exit(custom_exit);
`
请注意,我们现在不需要所有头文件(包含文件),但稍后会用到它们。现在我们需要编译代码。在源代码旁边创建一个名为Makefile 的新文件,并将以下内容添加到其中:
`obj-m += dvt-driver.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
`
在包含这两个文件的目录中打开终端,并运行make 命令。此时,您应该会在控制台中看到模块编译过程的输出,最终生成一个名为dvt-driver.ko的文件。这就是您编译完成的内核模块。让我们把这个革命性的知识产权加载到内核中;它只是放在那里可没什么用。在代码所在的目录中,运行以下命令:
sudo` insmod dvt-driver.ko
`
......你的驱动程序应该已经加载到内核中。你可以运行**`lsmod`** 命令来验证这一点,该命令会列出当前加载到内核中的所有模块。你应该能在其中看到**`dvt_driver`**。请注意,内核在加载模块时会将模块名称中的连字符替换为下划线。如果要移除该模块,请运行以下命令:
sudo` rmmod dvt_driver
`
为了确保驱动程序正确加载,我们还在源代码中添加了日志记录功能,请在终端中运行 `dmesg` 命令。该命令会将内核日志打印到屏幕上,方便阅读。`dmesg` 输出的最后几行应该 包含 来自驱动程序的消息,例如确认"hello world"模块已加载等。请注意,驱动程序的初始化和退出消息可能会有延迟,但如果您加载和卸载模块两次,所有这些消息都应该会被记录。如果您想实时监控日志,可以打开第二个终端并运行**`dmesg --follow`**命令。然后,当您在第一个终端中加载和卸载驱动程序时,您将看到消息实时显示。

那么,让我们来分析一下。在源代码中,我们首先要添加模块的元数据。你可以不包含作者和其他信息,但最好还是包含你的名字。如果你不包含许可证,编译器也会发出警告,而我这种病态的寻求认可的倾向,迫使我总是包含许可证代码。如果你不关心这些心理上的细微差别,那么仍然值得一提的是,内核维护者非常重视代码的开源性,并且特别关注许可证。过去,大型公司被禁止向内核源代码添加专有模块。不要重蹈覆辙。做一个好人。支持开源。使用开放许可证。
接下来,我们创建init 和exit 函数。每次模块加载到内核时,都会调用其init 函数;卸载模块时,则会调用exit 函数。我们的函数很简单------它们只是将文本写入内核日志。printk () 函数类似于经典的C语言print函数。显然,内核没有终端或屏幕来输出随机信息,因此 printk() 会将消息输出到内核日志。为此,我们使用KERN_INFO 宏来记录常规信息。您还可以使用KERN_ERROR等宏来写入错误消息,这会改变 dmesg 的输出格式。无论如何,init 和exit这两个函数都注册在源代码的最后两行。这是必要的,否则驱动程序将无法确定要执行哪些函数。您可以随意命名这些函数,关键是要保持与示例中相同的签名(参数和返回类型)。
最后,我们来看一下Makefile 。许多开源项目都使用GNU Make 工具来编译库。它通常用于 C/C++ 编写的库,并能自动完成编译过程。这里展示的Makefile 是编译模块的标准方法。第一行将你的**.o** 文件添加到obj-m 变量中以进行编译。内核的编译方式类似,在构建之前将多个**.o** 文件添加到该变量中。在下一行,我们使用了一个小技巧。内核模块的构建规则和命令已经在内核自带的Makefile 中定义好了。我们不需要编写自己的规则;我们可以直接使用内核的规则,而我们也正是这样做的。在**-C** 参数中,我们指定内核源文件的根目录路径,然后告诉Make 编译我们项目工作目录中的模块。瞧!GNU Make是一个功能极其强大的编译工具,它可以用来自动构建任何项目,而不仅仅是 C/C++ 项目。
进入 /proc
现在我们进入本文的重点。内核日志记录固然重要,但这并非优秀模块的必要条件。我在文章前面提到过,内核模块通常作为用户空间程序的 API。而我们的驱动程序目前并不具备这种功能。Linux 提供了一种非常便捷的方式来处理这个问题:它采用"一切皆文件"的抽象概念。
为了演示,打开另一个终端并运行**`cd /proc`** 。运行**`ls` 命令** 会列出文件。现在运行**`cat modules`** ,屏幕上会显示一些文本。这看起来眼熟吗?应该很眼熟;你之前运行的**`lsmod`** 命令显示的所有模块都在这里。现在试试**`cat meminfo`** 。这将显示虚拟机内存使用情况的信息。很棒!另一个要运行的命令是**`ls -sh`**。这将在每个文件名称旁边打印出文件大小,然后......等等,这是什么鬼?

所有这些文件的大小都是 0 字节。什么都没有。即使这些文件没有占用任何空间,我们却读取了它们的内容......?嗯,实际上,是的。你看,/proc 是一个进程目录,它是一个中心位置,用户空间程序从中获取内核模块的信息(有时也用于管理内核模块)。在 Ubuntu 系统中,任务管理器叫做"系统监视器"(System Monitor ),你可以按下键盘上的操作系统徽标键并输入"system"来启动它------此时应该会出现一个指向"系统监视器 "的链接。"系统监视器"会显示诸如正在运行的进程、CPU 使用率、内存使用率等指标。而它获取所有这些信息的方式就是读取 /proc 目录下的特殊文件,例如meminfo 文件。
让我们为驱动程序添加功能,使其在**/proc 目录**中拥有自己的条目。我们将实现当用户空间应用程序读取该条目时,打印消息"hello world"。请将模块元数据下方的所有代码替换为以下代码:
`static struct proc_dir_entry* proc_entry;
static ssize_t custom_read(struct file* file, char __user* user_buffer, size_t count, loff_t* offset)
{
printk(KERN_INFO `"calling our very own custom read method."`);
char greeting[] `=` `"Hello world!\\n"`;
int greeting_length `=` strlen(greeting);
`if` (*offset > `0`)
return `0`;
copy_to_user(user_buffer, greeting, greeting_length);
*offset `=` greeting_length;
return greeting_length;
}
static struct file_operations fops `=`
{
.owner `=` THIS_MODULE,
.read `=` custom_read
};
// Custom init and `exit` methods
static int __init custom_init(void) {
proc_entry `=` proc_create(`"helloworlddriver"`, `0666`, NULL, &fops);
printk(KERN_INFO `"Hello world driver loaded."`);
return `0`;
}
static void __exit custom_exit(void) {
proc_remove(proc_entry);
printk(KERN_INFO `"Goodbye my friend, I shall miss you dearly..."`);
}
module_init(custom_init);
module_exit(custom_exit);
`
现在从内核中移除驱动程序,重新编译它,并将新的**.ko** 模块加载到内核中。运行**`cat /proc/helloworlddriver`** ,你应该会看到我们的驱动程序在终端输出"hello world"。我觉得这很酷。但是,` **cat`**命令过于简单,无法真正展示我们正在做的事情。所以,让我们编写一个用户空间应用程序来与这个驱动程序交互。将以下 Python 代码放在任意目录下的脚本中(我将其命名为hello.py):
`kernel_module `=` open(`'/proc/helloworlddriver'`)
greeting `=` kernel_module.readline();
print(greeting)
kernel_module.close()
`
这段代码应该很容易理解,正如你所看到的,它与任何编程语言中执行文件 I/O 的方式完全相同。/proc/helloworlddriver文件 是我们调用刚刚创建的内核模块的 API。如果你运行命令python3 hello.py,你应该会看到我们的脚本在终端打印一条问候语。很棒吧?

在我们的代码中,我们创建了一个自定义的读取函数。正如您可能已经猜到的,如果您的模块需要用户空间输入,您也可以重写写入函数。例如,如果您有一个控制计算机风扇转速的驱动程序,您可以创建一个写入函数,该函数接受一个百分比值(0 到 100),并根据该值调整风扇转速。如果您想了解此函数重写是如何工作的,请阅读下一节。否则,您可以直接跳到文章末尾。
附加章节------它是如何运作的?
在本节中,我想你们中的一些人可能会好奇,究竟是如何重新定义读写函数来写入**/proc**目录的。为此,我们需要深入了解一些操作系统理论,我将用汇编语言来做个类比。
在汇编语言中,程序使用"栈"来跟踪执行过程中创建的变量。这与计算机科学中的经典栈略有不同,因为你不仅可以压入和弹出数据,还可以访问和修改/读取栈中的任意元素,而不仅仅是栈顶元素。假设你在汇编语言中定义了一个带有两个参数的函数。调用该函数时,你不能直接传递这些变量------绝对不行。用括号传递变量是那些从在线教程中复制 Python 聊天机器人代码的人才会做的事。汇编程序员当时正忙着把阿波罗 11 号送上月球呢。这是无法避免的。在调用带有两个参数的函数之前,你需要先将参数压入栈中。然后你调用该函数,它通常会按相反的顺序从栈中读取参数并根据需要使用它们。由于很容易以错误的顺序传递参数,导致函数将它们读取为无意义的数据,因此很容易出现许多潜在错误。
我之所以提到这一点,是因为你的操作系统也有类似的代码执行方法。它有自己的栈来跟踪变量,当内核调用操作系统函数时,它会在栈顶查找参数,然后执行代码。如果你想从磁盘读取一个文件,会调用带有多个参数的读取函数,这些参数会被压入内核栈,然后再次调用读取函数,从磁盘检索文件(或其部分内容)。内核将所有函数的信息存储在一个巨大的表中,该表包含函数名及其所在的内存地址。这就是我们自定义函数的用武之地。尽管我们通过文件与模块交互,但并没有硬性规定说读取文件时必须调用标准的读取函数。读取函数实际上只是存储在表中的一个内存地址。我们可以重写用户空间程序读取我们模块的**/proc** 条目时调用的内存函数,而这正是我们所做的!在file_operations 结构体中,我们将**.read属性分配给自定义的** `custom_read` 函数,然后向该函数注册一个**`/proc`** 条目。当用户编写的 Python 应用程序调用该读取函数时,它看起来像是从磁盘读取文件,并且所有参数都正确传递到了内核栈。然而,在最后一刻,内核会调用我们自定义的**`custom_read`** 函数,并使用我们事先告知内核的内存地址。之所以能够正常工作,是因为我们的**`custom_read`**函数接受的参数与从磁盘读取文件的函数完全相同,因此内核栈会按正确的顺序读取正确的参数。
需要注意的是,用户空间应用程序会将我们的**`/proc`**条目视为常规磁盘文件并进行相应的操作。确保这种交互正常工作是我们的责任。即使我们的模块并非常规磁盘文件,它也应该像常规磁盘文件一样运行。大多数编程语言以块为单位读取文件。假设每个块大小为 1024 字节。您将文件的前 1024 字节读取到缓冲区中,缓冲区将包含字节 0 到 1023。读取函数将返回 1024,表示已成功读取 1024 字节。然后读取接下来的 1024 字节,缓冲区将包含字节 1024 到 2047。最终,我们到达文件末尾。也许最后一个数据块需要 1024 字节,但只剩下 800 字节。在这种情况下,读取函数将返回 800,并将这最后的 800 字节写入缓冲区。最后,读取函数会尝试读取另一块数据,但文件内容已被完全读取,此时函数将返回 0。当这种情况发生时,编程语言就知道已到达文件末尾,并停止尝试读取文件。
如果你查看`custom_read` 函数的参数,你大概就能明白这是怎么回事了。文件 结构代表程序正在读取的文件(虽然这个结构只能在内核空间访问,但这对于本文来说并不重要)。最后几个参数是缓冲区、字节数和偏移量。缓冲区是一个用户空间缓冲区,它实际上包含了我们要写入字节的数组的地址。字节数 是数据块的大小,偏移量 是我们开始读取数据块的文件位置。让我们来看看从模块读取数据时会发生什么。我们将字符串"Hello world!"返回到用户空间。包括换行符在内,这共有 13 个字符,可以轻松放入任何大小的块中。当我们尝试读取 ` /proc` 目录下的一个条目时,它看起来会是这样:我们读取第一个数据块,将问候语写入缓冲区,并将 13(问候语字符串的长度)返回给应用程序,因为读取了 13 个字节。下一块数据将从偏移量 13 处开始读取,这是文件的"末尾"(我们没有更多数据要传输),因此返回 0。我们的custom_read函数中的逻辑也反映了这一点。如果传入的偏移量大于 0,则表示我们已经发送了问候语,我们直接返回 0。否则,我们将问候语字符串复制到缓冲区并更新偏移量。
其他类型的函数,例如重写写入函数,也应遵循相同的原则。只要你的函数对于任何读取或写入它的应用程序来说都像一个文件一样,那么它就可以执行任何操作。
结论
感谢您阅读本文。希望您觉得这篇文章足够有趣,并有兴趣亲自探索内核。虽然本文使用了虚拟机,但如果您将来需要为嵌入式系统(例如物联网设备)编写代码,那么了解如何编写内核模块至关重要。