nhmk/文档/00-准备工作.md

44 KiB

准备开始

什么是内核模块?

参与Linux内核模块开发需要扎实的C编程语言基础和创建用于进程执行的传统程序的丰富经验。这一追求涉及到一个领域, 如果处理不当, 一个未受限制的指针可能会导致整个文件系统的崩溃, 导致系统必须重启。

Linux 内核模块的确切定义是: 能够根据需要在内核中动态加载和卸载的代码段。 这些模块增强了内核功能, 而无需重新启动系统。 一个值得注意的例子是设备驱动程序模块, 它促进了内核与链接到系统的硬件组件的交互。 在没有模块的情况下, 主流的方法倾向于使用单内核( Monolithic Kernel 也可以称之为宏内核 Macrokernel ), 需要将新功能直接集成到内核镜像中。这种方法会导致内核变得更大, 并在需要新增功能时, 必须重新构建内核并进行系统重启。

译者注: 单内核( Monolithic Kernel) 有时候也被称之为宏内核(Macrokernel), 与其对应的另一种内核形式是微内核, Linux内核是微内核和内核的混合产物

内核模块包

Linux发行版在一个包中提供了命令 modprobe, insmoddepmod

在安同上:

sudo oma install gcc kmod

在 Debian 与其衍生发行版(乌班图ubuntu, 深度deebin, kali)上:

sudo apt-get install build-essential kmod

在 Arch Linux系上:

sudo pacman -S gcc kmod

我的内核中有哪些模块?

要发现当前内核中已经加载了哪些模块可以使用命令 sudo lsmod

模块存储在文件 /proc/modules中, 可以使用sudo cat /proc/modules命令查看它们

这可能是一个很长的列表, 可以使用grep命令搜索某类模块, 比如使用sudo lsmod | grep net命令搜索网络模块

是否需要下载和编译内核?

本指南并没有对此有强制要求。但建议本指南的示例使用虚拟机运行, 从而减少可能对系统造成的任何风险

开始前

在深入研究代码之前, 需要注意某些事项。每个人的主机都有所不同, 所以在不同的内核与发行版之间都可能存在一些差异, 成功编译和加载首个 "hello world" 程序有时可能会是一个挑战。 克服最初的障碍是一个令人欣慰的过程, 它会为你后续的努力铺平道路。

  1. Modversioning: 如果引导不同的内核, 则为一个内核编译的模块将不会加载, 除非在内核中启用了 CONFIG_MODVERSIONS 本指南稍后将讨论模块版本化问题。在讨论模块版本控制之前, 如果在启用了 modversioning 的情况下运行内核, 则本指南中的示例可能无法正常工作。然而, 大多数Linux发行版内核都启用了 modversioning 。如果由于版本控制错误而在加载模块时出现问题, 请考虑在关闭 modversioning 的情况下编译内核。

  2. 使用 X Window System: 强烈建议使用控制台(终端)中获取、编译、运行本指南中讨论的所有示例, 不建议使用可视化窗口运行, 模块不能像 printf() 那样直接打印到屏幕上, 但它们可以记录最终显示在屏幕上的信息和警告, 特别是在控制台中。如果从 xTER 加载模块, 则信息和警告将被记录在systemd日志中。只有查询 journalctl 才能看到这些日志。 有关详细信息, 请看 Hello World 。为了即时访问此信息, 建议从控制台执行所有任务。

  3. SecureBoot: 许多现代计算机都预先配置了UEFI SecureBoot, 这是一个基本的安全标准, 确保仅通过原始设备制造商认可的可信软件引导。某些Linux发行版甚至附带配置为支持SecureBoot的默认Linux内核。在这些情况下, 内核模块需要一个签名的安全密钥。如果在启动"hello world"模块时出现错误信息: ERROR: could not insert module (错误:无法插入模块)。如果此消息 Lockdown: insmod: unsigned module loading is restricted; see man kernel lockdown.7(未签名的模块的装载限制, 请参阅 man kernel lockdown.7 ) 出现在 dmesg 输出, 最简单的方法是从主机的引导菜单中禁用UEFI SecureBoot。当然, 另一种方法涉及复杂的过程, 如生成密钥、系统密钥安装和模块签名, 以实现功能。然而, 这种复杂的过程不太适合初学者。如果有兴趣, 可以探索和遵循SecureBoot的更详细步骤。

译者注: 编译时需要调用内核中的objtool模块, 但并不是所有发行版的内核都包含此模块, 可以使用file /lib/modules/$(uname -r)/build/tools/objtool/objtool查看当前发行版内核中是否存在此模块, 如果为找到此模块可以尝试替换其他内核或者使用其他发行版

准备头文件

在构建任何东西之前, 必须安装内核的头文件。对于几乎所有的发行版都可以使用 ls /lib/modules/$(uname -r)/build 命令来确定是否已有内核头文件, 返回不为空则代表存在相关内核头文件, 其他情况需要从包管理器中手动下载

内核头文件的包名在Debian系、Arch系中名称一般是linux-headers linux-headers-内核版本号-备注(如: linux-headers-5.4.0-80-generic), 而在红帽系中一般名称是 kernel-devel kernel-headers

# Ubuntu/Debian
sudo apt install linux-headers-$(uname -r)
# Arch Linux:
sudo pacman -S linux-headers
# Fedora:
sudo dnf install kernel-devel kernel-headers

示例

本文档中的所有示例都位于 示例(examples) 子目录中。

如果发生编译错误, 可能是由于正在使用较新的内核版本, 或者可能需要安装相应的内核头文件。

Hello World

最简单的模块

大多数人开始他们的编程旅程通常从 hello world 示例的某种变体开始。偏离这一传统的人目前尚未可知会产生什么结果, 但坚持这一传统似乎是明智的。 学习过程将从一系列 hello world 程序开始, 这些程序说明了编写内核模块的各个基本方面。

接下来介绍的可能是最简单的模块。

创建测试目录:

mkdir -p /develop/kernel/hello-1 
cd /develop/kernel/hello-1

将下述代码粘贴到你喜爱的编辑器中, 并将其命名为 hello-1.c:

/*
 * hello-1.c - 最简单的内核模块
 */
#include <linux/module.h> /* 内核模块必要的头 */
#include <linux/printk.h> /* 引入 pr_info() */

int init_module(void)
{
    pr_info("Hello world 1.\n");

    /* 非0返回意味着init_module失败; 无法加载模块 */
    return 0;
}

void cleanup_module(void)
{
    pr_info("Goodbye world 1.\n");
}

MODULE_LICENSE("GPL");

现在你需要一个 Makefile 。如果复制并粘贴此内容, 需要注意的是复制后下述代码中的制表符(tabs)可能会变成空格, 这里我们需要使用制表符。

obj-m += hello-1.o

PWD := $(CURDIR)

all:
    	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
    	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Makefile 中, $(CURDIR) 可以设置为当前工作目录的绝对路径名(在处理所有 -C 选项后, 如果有)。请参阅 GNU makemanual 中有关 CURDIR 的更多信息。

在完成上述操作后, 之后直接运行make即可

如果在Makefile中没有PWD := $(CURDIR)这样的语句, 那么使用sudo make可能无法正确编译。这是因为一些环境变量受到安全策略的限制, 不能被继承。默认的安全策略是sudoers。在sudoers安全策略中, 默认启用了env_reset, 这会限制环境变量的使用。具体来说, 路径变量不会从用户环境中保留, 而是被设置为默认值(更多信息请参考sudoers 手册)。你可以通过sudo -ssudo -V 方式查看环境变量的设置

下面以一个简单的 Makefile 为例, 演示上述问题。

all:
    echo $(PWD)

我们可以使用-p标志从Makefile中打印出环境变量值。

$ make -p | grep PWD
PWD = /home/ubuntu/temp
OLDPWD = /home/ubuntu
	echo $(PWD)

PWDsudo不会被一起继承。

$ sudo make -p | grep PWD
echo $(PWD)

有三种方法可以解决这个问题。

  1. 可以使用-E标志临时保留它们。
    $ sudo -E make -p | grep PWD
    PWD = /home/ubuntu/temp
    OLDPWD = /home/ubuntu
    echo $(PWD)
    
  2. 可以通过执行visudo命令, 编辑/etc/sudoers来禁用env_reset(环境重置)。
    1. vim /etc/sudoers
    2. 在其中找到 Defaults env_reset
    3. env_reset更改为!env_reset, 其他环境变量不做更改 分别执行env和sudo env
    # 禁用 env_reset
    echo "user:" > non-env_reset.log; env >> non-env_reset.log
    echo "root:" >> non-env_reset.log; sudo env >> non-env_reset.log
    # 启用 env_reset
    echo "user:" > env_reset.log; env >> env_reset.log
    echo "root:" >> env_reset.log; sudo env >> env_reset.log
    

    可以查看日志(cat non-env_reset.logcat env_reset.log)发现env_reset!env_reset的差异

  3. 可以通过将环境变量附加到/etc/sudoers中的env_keep来保留环境变量

    Defaults env_keep += "PWD" 应用上述更改后, 可以通过sudo -ssudo -V的方式检查环境变量设置:

如果一切顺利, 你应该会发现你有一个已编译的hello-1.ko模块。你可以使用以下命令查找有关它的信息:

sudo modinfo hello-1.ko

使用 sudo lsmod | grep hello 命令应该不返回任何内容。使用下述的命令来尝试加载新模块:

sudo insmod hello-1.ko

载入模块时中划线(hello-1)将会被替换成下划线(hello_1), 再次尝试时sudo lsmod | grep hello

就可以看到已加载的模块。可以使用以下命令再次移除它(请注意, 中划线已被下划线替换):

sudo rmmod hello_1

要查看日志中刚刚发生的情况, 请执行以下操作:

sudo journalctl --since "1 hour ago" | grep kernel

现在你已经了解了创建、编译、安装和删除模块的基础知识。下面将会更多地描述该模块的工作原理。

内核模块必须至少有两个函数: 一个名为init_module()"init"(初始化) 函数, 当模块被插入内核时调用;另一个名为主模块的 "cleanup"(清理) 函数, 在将其从内核中删除之前调用。实际上, 从内核2.3.13开始, 情况发生了变化。现在, 你可以为模块的开始和结束函数使用任何名称, 你将在第4.2节中学习如何做到这一点。事实上, 新方法是首选方法。然而, 许多人仍然使用init_module()cleanup_module()作为他们的开始和结束函数。

通常,init_module()要么向内核注册某个对象的处理程序, 要么用自己的代码替换其中一个内核函数(通常是执行某些操作的代码, 然后调用原始函数)。cleanup_module()函数的作用是撤消init_module()所做的任何操作,因此可以安全地卸载该模块。

每个内核模块都需要包含<linux/module.h>。但只有在需要输出日志时(pr_alert())包含<linux/printk.h>宏扩展即可, 你将在 载入与卸载 中了解这一点。

关于编码风格

对任何开始内核编程的人来说都不容易被注意到的事情是, 代码中的缩进应该使用\textbf{制表符(tabs)}, 而不是空格。它是内核的编码约定之一。你可能不喜欢它, 但如果你想要向上游提交补丁, 则需要习惯它。

译者注: 内核中的注释无论是单行还是多行都习惯使用 /**/ 的方式而非 //

打印(print)宏

早期打印日志通常使用的是printk函数,printk函数需要写优先级, 如KERN_INFO KERN_DEBUG。在较新的内核也可以使用一组打印宏, 如pr_info pr_debug 以缩写形式表示。省去了一些无意义的输入, 看起来更加整洁。有关printk的头文件可以在include/linux/printk.h中找到。务必花时间阅读一下这些宏(参考说明中文版 printk 手册/英文版 printk 手册

译者注: 根据测试在具有中文环境的系统中打印宏是可以正常输出中文的

关于编译

内核模块的编译需要与常规用户空间应用程序稍有不同。以前的内核版本要求我们非常关心这些设置, 这些设置通常存储在Makefiles中。尽管分层组织, 但许多冗余设置积累在子级Makefiles中, 使它们变得很大, 很难维护。幸运的是, 有一种新的方法来完成这些事情, 称为kbuild, 并且外部可加载模块的构建过程现在完全集成到标准内核构建机制中。要了解有关如何编译不属于官方内核的模块(如本指南中的所有示例)的更多信息, 请参阅文件Documentation/kbuild/modules.rst

有关内核模块的Makefiles的其他详细信息, 请参阅Documentation/kbuild/makefiles.rst。在开始修改Makefile之前, 请务必阅读此文件和相关文件。它可能会为你节省大量工作。

练习:

看到init_module()return语句上方的注释了吗?将返回值更改为负值, 重新编译并再次加载模块。看看会发生什么!

译者注: 如果返回值是非零的正整数则会在日志里看见报错

8月 13 17:42:50 xunmi-pc kernel: do_init_module: 'hello_1'->init suspiciously returned 1, it should follow 0/-E convention
8月 13 17:42:50 xunmi-pc kernel: CPU: 2 PID: 6800 Comm: insmod Tainted: G           O      4.19.0-19-loongson-3 #1
8月 13 17:42:50 xunmi-pc kernel: Hardware name: Loongson Loongson-3A5000-HV-7A1000-1w-A2101/Loongson-LS3A5000-7A1000-1w-A2101, BIOS vUDK2018-LoongArch-V4.0.05132-beta10 12/13/
8月 13 17:42:50 xunmi-pc kernel: Stack : 9000000000ffb918 9000000000ca60bc 90000003e0e14000 90000003e0e17bb0
8月 13 17:42:50 xunmi-pc kernel:         0000000000000007 0000000000071c1c 00000000000b0000 9000000001156950
8月 13 17:42:50 xunmi-pc kernel:         ...
8月 13 17:42:50 xunmi-pc kernel: Call Trace:
8月 13 17:42:50 xunmi-pc kernel: [<9000000000209d54>] show_stack+0x34/0x140
8月 13 17:42:50 xunmi-pc kernel: [<9000000000ca60b8>] dump_stack+0xa4/0xdc
8月 13 17:42:50 xunmi-pc kernel: [<90000000002de294>] do_init_module+0x224/0x230
8月 13 17:42:50 xunmi-pc kernel: [<90000000002e038c>] load_module+0x201c/0x24d0
8月 13 17:42:50 xunmi-pc kernel: [<90000000002e0b1c>] sys_finit_module+0xcc/0x120
8月 13 17:42:50 xunmi-pc kernel: [<9000000000211c74>] syscall_common+0x20/0x34

但模块会部分加载, 使用sudo lsmod | grep hello能看到模块

但如果出现负整数则会在加载模块的时候出现如下报错:

insmod: ERROR: could not insert module hello-1.ko: Operation not permitted

模块不会正常加载, 使用sudo lsmod | grep hello无法看到模块

载入与卸载

在早期的内核版本中, 你必须使用init_modulecleanup_module函数, 就像在第一个 hello world 示例中一样, 但现在你可以通过使用module_initmodule_exit宏来命名这些函数。这些宏在include/linux/module.h 中定义。唯一的要求是必须在调用这些宏之前定义init和cleanup函数, 否则将出现编译错误。下面是该技术的一个示例:

/*
 * hello-2.c - 演示module_init()和module_exit()宏
 * 这比使用init_module()和cleanup_module()更受欢迎
 */
#include <linux/init.h> /* 初始化需要的宏 */
#include <linux/module.h> /* 内核模块必要的头 */
#include <linux/printk.h> /* 引入 pr_info() */

static int __init hello_2_init(void)
{
    pr_info("Hello, world 2\n");
    return 0;
}

static void __exit hello_2_exit(void)
{
    pr_info("Goodbye, world 2\n");
}

module_init(hello_2_init);
module_exit(hello_2_exit);

MODULE_LICENSE("GPL");

现在我们有两个内核模块。新增一个模块非常简单:

obj-m += hello-1.o
obj-m += hello-2.o

PWD := $(CURDIR)

all:
        make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
        make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

现在来看一个真实示例drivers/char/Makefile。从上述文件可以看到有些东西已经被硬编码到内核中(obj-y), 但另一些obj-m都到哪里去了? 熟悉shell脚本的人将很容易发现它们。对于那些不熟悉的人来说, 你在各处看到的obj-$(CONFIG_FOO)条目会根据CONFIG_FOO变量是设置为y 还是m, 展开为obj-yobj-m。顺便提一下, 这些正是你在Linux内核源代码树的顶级目录下使用make menuconfig或类似命令时, 在.config文件中设置的那种变量。

译者注: 在构建内核或模块时, obj-yobj-m 是用于 Makefile 的变量。y 代表“yes”, 表示这个文件或模块会被编译成内核的一部分, 而 m 代表“module”, 表示这个文件或模块会被编译成一个可加载的模块。

__init__exit

__init宏: 在初始化函数为内置驱动程序(不是可加载模块)完成后丢弃初始化函数并释放其内存。如果你思考一下初始化函数何时被调用就会发现这是非常合理的操作, 因为内置模块不需要再次加载, 而可加载的模块则需要。还有一个__initdata, 其工作方式类似于__init, 但用于初始化变量而不是函数。

__exit宏: 在将模块内置到内核时也会被省略, 与__init 类似, 对可加载模块没有影响。考虑到退出函数的作用这种做法也是完全合理的。这些宏在include/linux/init.h中定义, 用于释放内核内存。当引导内核并看到类似"释放未使用的内核内存: 236k已释放(Freeing unused kernel memory: 236k freed)"的内容时, 这正是内核正在释放的内容。

/*
 * hello-3.c - __init, __initdata 和 __exit 宏的使用说明
 */
#include <linux/init.h> /* 初始化需要的宏 */
#include <linux/module.h> /* 内核模块必要的头 */
#include <linux/printk.h> /* 引入 pr_info() */

static int hello3_data __initdata = 3;

static int __init hello_3_init(void)
{
    pr_info("Hello, world %d\n", hello3_data);
    return 0;
}

static void __exit hello_3_exit(void)
{
    pr_info("Goodbye, world 3\n");
}

module_init(hello_3_init);
module_exit(hello_3_exit);

MODULE_LICENSE("GPL");

许可(Licensing)和模块文档

老实说, 谁加载甚至关心专有模块?如果你这样做那么你可能已经看到了这样的事情:

$ sudo insmod xxxxxx.ko
loading out-of-tree module taints kernel.
module license 'unspecified' taints kernel.

你可以使用几个宏来指示模块的许可证。常见示例有"GPL"、"GPL v2"、"GPL和附加权限(GPL and additional rights)"、"Dual BSD/GPL", "Dual MIT/GPL""DualMPL/GPL"和"Proprietary"。它们在include/linux/module.h中定义。

要引用你正在使用的许可证, 可以使用名为MODULE_LICENSE的宏。下面的示例中说明了该宏和几个描述该模块的其他宏。

/*
 * hello-4.c - 演示模块文档
 */
#include <linux/init.h> /* 初始化需要的宏 */
#include <linux/module.h> /* 内核模块必要的头 */
#include <linux/printk.h> /* 引入 pr_info() */

MODULE_LICENSE("GPL");
MODULE_AUTHOR("寻觅");
MODULE_DESCRIPTION("一个练习驱动");

static int __init init_hello_4(void)
{
    pr_info("Hello, world 4\n");
    return 0;
}

static void __exit cleanup_hello_4(void)
{
    pr_info("Goodbye, world 4\n");
}

module_init(init_hello_4);
module_exit(cleanup_hello_4);

将命令行参数传递到模块

模块可以采用命令行参数, 但不能使用你可能习惯的argc/argv。

要允许将参数传递给模块, 请声明将采用命令行参数值的变量作为全局变量,然后使用module_param()宏(在include/linux/moduleparam.h中定义)来设置机制。在运行时, insmod将使用给定的任何命令行参数填充变量, 如insmod mymodule.ko myvariable=5。为了清楚起见, 变量声明和宏应该放在模块的开头。这块解释的很糟糕, 还是无法理解可以试着查看实例代码。

module_param()宏接受3个参数: 变量的名称、类型和sysfs中相应文件的权限。整数类型可以像平常一样有符号, 也可以无符号。如果要使用整数数组或字符串数组, 请使用module_param_array()module_param_string()

int myint = 3;
module_param(myint, int, 0);

数组也得到了支持, 但与过去相比, 现在的处理方式有所不同。为了跟踪参数数量, 你需要将一个指向计数变量的指针作为第三个参数传递。你也可以选择忽略计数并传递NULL。下面我们展示了两种可能性:

int myintarray[2];
module_param_array(myintarray, int, NULL, 0); /* 不需求计数器 */
short myshortarray[4];
int count;
module_param_array(myshortarray, short, &count, 0); /* 将计数器的值传给count */

这种用法的一个好处是可以设置模块变量的默认值, 如端口或IO 地址。如果变量包含默认值, 则执行自动检测(在其他地方解释)。否则, 保留当前值。这将在后面详细说明。

最后, 有一个宏函数MODULE_PARM_DESC(), 用于记录模块可以接受的参数。它接受两个参数: 变量名和描述该变量的自由格式字符串。

/*
 * hello-5.c - 演示传递给模块的命令行参数
 */
#include <linux/init.h>
#include <linux/kernel.h> /* 引入 ARRAY_SIZE() */
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/printk.h>
#include <linux/stat.h>

MODULE_LICENSE("GPL");

static short int myshort = 1;
static int myint = 420;
static long int mylong = 9999;
static char *mystring = "寻觅";
static int myintarray[2] = { 420, 420 };
static int arr_argc = 0;

/* module_param(foo, int, 0000)
 * foo: 传入参数的名称
 * int: 参数的数据类型
 * 0000: 权限位, 用于在稍后阶段公开sysfs中的参数(如果非零)
 */
module_param(myshort, short, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
MODULE_PARM_DESC(myshort, "一个短整数");
module_param(myint, int, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
MODULE_PARM_DESC(myint, "一个整数");
module_param(mylong, long, S_IRUSR);
MODULE_PARM_DESC(mylong, "一个长整数");
module_param(mystring, charp, 0000);
MODULE_PARM_DESC(mystring, "一个字符");

/* module_param_array(name, type, num, perm);
 * name: 数组的名称
 * type: 数组元素的类型
 * num: 指向变量的指针, 该变量将在模块加载时存储用户初始化的数组元素数量
 * perm: 权限位
 */
module_param_array(myintarray, int, &arr_argc, 0000);
MODULE_PARM_DESC(myintarray, "一组整数数组");

static int __init hello_5_init(void)
{
    int i;

    pr_info("你好 5\n=============\n");
    pr_info("myshort是一个短整数: %hd\n", myshort);
    pr_info("myint是一个整数: %d\n", myint);
    pr_info("mylong是一个长整数: %ld\n", mylong);
    pr_info("mystring是一个字符: %s\n", mystring);

    for (i = 0; i < ARRAY_SIZE(myintarray); i++)
        pr_info("myintarray[%d] = %d\n", i, myintarray[i]);

    pr_info("从 myintarray 中获得 %d 个参数\n", arr_argc);
    return 0;
}

static void __exit hello_5_exit(void)
{
    pr_info("再见 5\n");
}

module_init(hello_5_init);
module_exit(hello_5_exit);

推荐使用下述方法测试参数传递代码:

$ sudo insmod hello-5.ko mystring="bebop" myintarray=-1
$ sudo dmesg -t | tail -7
myshort is a short integer: 1
myint is an integer: 420
mylong is a long integer: 9999
mystring is a string: bebop
myintarray[0] = -1
myintarray[1] = 420
got 1 arguments for myintarray.

$ sudo rmmod hello-5
$ sudo dmesg -t | tail -1
Goodbye, world 5

$ sudo insmod hello-5.ko mystring="supercalifragilisticexpialidocious" myintarray=-1,-1
$ sudo dmesg -t | tail -7
myshort is a short integer: 1
myint is an integer: 420
mylong is a long integer: 9999
mystring is a string: supercalifragilisticexpialidocious
myintarray[0] = -1
myintarray[1] = -1
got 2 arguments for myintarray.

$ sudo rmmod hello-5
$ sudo dmesg -t | tail -1
Goodbye, world 5

$ sudo insmod hello-5.ko mylong=hello
insmod: ERROR: could not insert module hello-5.ko: Invalid parameters

跨多文件的模块

有时将内核模块划分为几个源文件是有意义的。会分别创建一个start.cstop.c来共同组合成一个模块。

跨文件模块的初始化部分, start.c:

/*
 * start.c - 跨文件模块-初始化
 */

#include <linux/kernel.h> /* 进行内核工作时引入的头 */
#include <linux/module.h> /* 内核模块必要的头 */

int init_module(void)
{
    pr_info("你好, 内核初始化模块启动\n");
    return 0;
}

MODULE_LICENSE("GPL");

跨文件模块的结束部分, stop.c

/*
 * stop.c - 跨文件模块-结束
 */

#include <linux/kernel.h> /* 进行内核工作时引入的头 */
#include <linux/module.h> /* 内核模块必要的头 */

void cleanup_module(void)
{
    pr_info("一个很短的内核结束模块\n");
}

MODULE_LICENSE("GPL");

这里makefile和之前有所不同, 跨多文件的模块加上我们上述所有hello模块的makefile文件如下:

obj-m += hello-1.o
obj-m += hello-2.o
obj-m += hello-3.o
obj-m += hello-4.o
obj-m += hello-5.o
obj-m += startstop.o
startstop-objs := start.o stop.o

PWD := $(CURDIR)

all:
        make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
        make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

前五行没什么特别的, 但对于最后一个示例, 在其中使用了两行。首先, 我们为我们的组合模块发明一个对象名称(startstop.o), 其次我们告诉make哪些对象文件是该模块的一部分。

为预编译内核构建模块

我们强烈建议你重新编译内核, 以便你可以启用许多有用的调试功能, 例如强制模块卸载(MODULE_FORCE_UNLOAD): 当启用此选项时, 你可以通过sudo rmmod -f module命令强制内核卸载模块, 即使它认为模块不安全。在模块开发过程中, 此选项可以节省大量时间和多次重新启动。如果不想重新编译内核, 则应该考虑在虚拟机上的测试发行版中运行这些示例。如果你搞坏了什么, 可以快速重新启动或恢复虚拟机(VM)。

在许多情况下, 你可能希望将模块加载到预编译的运行内核中, 例如常见Linux发行版附带的内核, 或者你过去编译过的内核。在某些情况下, 你可能需要编译模块并将其插入到不允许重新编译的正在运行的内核中, 或者在不希望重新启动的机器上。如果你想不出一种情况会迫使你为预编译内核使用模块,那么你可能想跳过本节剩下的内容, 并将本节的其余部分视为一个大注脚。

如果你只是安装内核源代码(kernel source tree), 请使用它来编译内核模块, 然后尝试将模块插入内核, 在大多数情况下, 你将获得如下错误:

  • insmod: ERROR: could not insert module poet.ko: Invalid module format
  • 报错翻译: insmod: 错误:无法插入poet.ko模块: 无效的模块格式

一些隐秘信息被记录到systemd日志中:

  • kernel: poet: disagrees about version of symbol module_layout
  • 报错翻译: kernel: poet: 对 module_layout 的版本符号存在分歧

换句话说, 内核拒绝接受模块, 因为版本信息不匹配(版本信息更准确的翻译应该是版本魔术(version magic), 在这里版本信息更有助于理解, 后续所有version magic的翻译都会翻译成版本信息, 请参阅include/linux/vermagic.h)。顺便说一句,版本信息以静态字符串的形式存储在模块对象中, 从vermagic开始。当版本信息链接到kernel/module.o文件时, 它会插入到模块中。要检查存储在给定模块中的版本信息和其他字符串, 可以使用命令modinfo module.ko:

$ sudo modinfo hello-4.ko
filename:       /home/xunmi/object/drive/hello-4.ko
description:    一个练习驱动
author:         寻觅
license:        GPL
depends:        
name:           hello_4
vermagic:       4.19.0-19-loongson-3 SMP mod_unload modversions LOONGARCH 64BIT 

为了克服这个问题, 我们可以使用--force-vermagic选项, 但这种解决方案可能是不安全的, 并且在生产环境中是不可接受的。因此, 我们希望在与构建预编译内核的环境相同的环境中编译模块。如何做到这一点, 是本章其余部分的主要议题。

首先, 确保有一个内核源代码树可用, 其版本与当前内核完全相同。然后, 找到用于编译预编译内核的配置文件。这通常在/boot目录下, 文件名类似于config-5.14.x。你可能只需将其复制到内核源代码中: 执行命令cp /boot/config-$(uname -r) .config

观察之前的版本信息, 即使两个配置文件完全相同, 版本信息还是可能会出现一些差异阻止模块插入到内核中。这种差异是由于对某些发行版修改了Makefile中定义的版本信息所致。查看你的内核源码根目录下的Makefile, 确保指定的版本信息与当前内核使用的完全匹配。发行版的Makefile是类似如下格式:

VERSION = 5
PATCHLEVEL = 14
SUBLEVEL = 0
EXTRAVERSION = -rc2

假设出现上述情况时, 我们需要将Makefile中的EXTRAVERSION的值配置为-rc2。可以使用如下命令保留编译内核的makefile的备份。

cp /lib/modules/$(uname -r)/build/Makefile linux-$(uname -r)

linux-$(uname -r)是你试图构建的linux内核源代码, 可以在命令行中执行uname -r查看返回值。最后, 运行make来更新配置、头文件:

$ make
SYNC    include/config/auto.conf.cmd
HOSTCC  scripts/basic/fixdep
HOSTCC  scripts/kconfig/conf.o
HOSTCC  scripts/kconfig/confdata.o
HOSTCC  scripts/kconfig/expr.o
LEX     scripts/kconfig/lexer.lex.c
YACC    scripts/kconfig/parser.tab.[ch]
HOSTCC  scripts/kconfig/preprocess.o
HOSTCC  scripts/kconfig/symbol.o
HOSTCC  scripts/kconfig/util.o
HOSTCC  scripts/kconfig/lexer.lex.o
HOSTCC  scripts/kconfig/parser.tab.o
HOSTLD  scripts/kconfig/conf

如果你不想编译完整的内核, 在SPLIT行之后中断构建(CTRL-C) 即可, 后续返回到模块的目录并编译模块即可: 它将完全根据你当前的内核设置构建, 并且它将在没有任何错误的情况下加载。

准备工作

模块开始和结束方式

典型的程序以 main() 函数开始, 执行一系列指令, 并在完成这些指令后终止。然而内核模块遵循模式有所不同。模块总是以init_module函数或module_init调用指定的函数开头。该函数是模块的入口, 会通知内核模块的功能, 并在必要时让内核做好使用模块功能的准备。完成这些任务后, 入口函数返回, 模块将保持不活动状态, 直到内核使用其代码。

所有模块都通过cleanup_module函数 或module_exit调用指定的函数来结束。用作模块的退出功能, 通过注销以前注册的功能来逆转入口函数操作。

每个模块都必须具有进入和退出功能。虽然有多种方法来定义这些函数, 但通常使用术语"进入函数(entry function)"和"退出函数(exit function)"。它们有时可能被称为init_modulecleanup_module这两个模块的含义是相同的。

模块可用的功能

程序员经常使用不是自己定义的函数。这方面的一个常见的例子是printf()。在使用标准C库libc提供的这些库函数实际上直到链接阶段才会进入你的程序, 这确保代码(例如printf()的代码)可用, 并将调用指令指向那段代码。

内核模块在这里也有所不同。在"Hello World"示例中, 你可能注意到我们使用了函数pr_info(), 但没有包括标准I/O库。这是因为模块是对象文件, 其符号在运行insmodmodprobe时得到解析。符号的定义来自内核本身; 你只能使用内核提供的外部函数。如果你对内核已导出的符号感兴趣, 可以查看/proc/kallsyms

需要记住的一点是库函数和系统调用之间的区别。库函数是更上层的, 完 全在用户空间中运行, 并为程序员提供了一个更方便的接口, 以实现真正的工 作系统调用。系统调用代表用户在内核模式下运行, 并由内核本身提供。库函 数printf()可能看起来像一个非常通用的打印函数, 但它真正做的只是将数据 格式化为字符串, 并使用底层系统调用write()写入字符串数据, 然后将数据发 送到标准输出。

可以使用strace看看printf()都进行了那些系统调用。编译以下程序:

#include <stdio.h> 

int main(void) 
{ 
    printf("你好"); 
    return 0; 
}

使用gcc -Wall -o hello hello.c编译此文件后可以调用strace ./hello运行可执行文件, 可以看到的每一行都对应于一个系统调用。strace是一个非常实用的程序, 它可以告诉你一个程序正在进行的系统调用的详细信息, 包括调用的是哪个系统函数、其参数是什么以及它返回了什么。这是一个极其宝贵的工具, 用来了解诸如程序试图访问哪些文件之类的问题。在输出的最后, 你会看到像write(1, "\344\275\240\345\245\275", 6你好) 这样的一行。这就是printf()函数背后的真实系统调用。你可能不熟悉write, 因为大多数人会使用库函数来进行文件I/O(文件读写操作)(如fopen fputs fclose)。如果想要了解更详细的信息可以查看man 2 write。第二部分的手册专门讨论系统调用(如kill() read()), 而第三部分的手册则专门讨论库调用, 你可能会更熟悉这部分(如cosh() random())。

你甚至可以编写模块来替换内核的系统调用, 我们很快就会这样做。这种操作常被骇客用于制作后门或木马程序, 但你可以编写自己的模块来做无害的事情, 比如每当有人试图删除你系统上的文件时, 让内核输出"哎呀!你干~嘛?"

用户空间vs内核空间

内核主要管理对资源的访问, 无论是显卡、硬盘还是内存。程序经常争夺相同的资源。例如, 在保存文档时, updatedb 可能会开始更新 locate 数据库。vim等编辑器中的会话和 updatedb 等进程可以同时利用硬盘。内核的作用是维护秩序, 确保用户不会随意访问资源。

为了管理这些资源, CPU 在不同模式下运行, 每个模式都提供不同级别的系统控制。例如, 英特尔的80386架构就具有四种这样的模式, 称为环。然而, Unix仅使用其中两个环: 最高环(环0, 也称为"内核模式", 其中允许所有操作)和最低环, 称为"用户模式"。

回顾关于库函数与系统调用的讨论。通常会在用户模式下使用库函数。库函数调用一个或多个系统调用, 并且这些系统调用以库函数的名义执行, 但在内核模式下执行, 因为它们是内核本身的一部分。一旦系统调用完成其任务, 它将返回, 并将执行权转交回用户模式。

命名空间

当你在编写一个小规模项目时, 使用通俗易懂的变量名通常是为了增加程序的可读性。但如果参与一个多人维护的庞大项目, 你的全局变量会和其他人的全局变量共存, 可能会出现全局变量名冲突的情况。当一个程序拥有大量全局变量且这些变量名不足以准确描述其用途时, 就会出现命名空间污染。在大型团队项目中, 在确保能记住保留的名称的同时, 需要找到一种制定方案, 以便为变量名和符号命名保持唯一性。

在编写内核代码时, 即使是最小的模块也会链接到整个内核, 因此需要避免命名空间污染这个问题。处理此问题的最佳方法是将所有变量声明为静态, 并使用一个精准不会重复的前缀。按照惯例, 所有内核前缀都是小写的。如果你不想将所有内容都声明为静态, 另一个方法是声明符号表并将其注册到内核。我们稍后将讨论这个问题。

文件 /proc/kallsyms 包含了内核所知道的所有符号, 因此这些符号对你的模块来说是可访问的, 因为它们共享了内核的代码空间。

代码空间

内存管理是一个非常复杂的主题, O'Reilly 出版的 Understanding The Linux Kernel(理解 Linux 内核)一书中大部分内容都专注于内存管理!我们的目标不是成为内存管理方面的专家, 但我们确实需要掌握一些基本知识, 才能开始着手编写实际的模块。

如果你没有深入思考过段错误(segfault)的真正含义, 你可能会惊讶地发现指针并不真正指向内存位置——至少不是真实的内存位置。当一个进程被创建时,内核会划分一部分真实的物理内存并分配给该进程, 用于其执行代码、变量、栈、堆以及计算机科学家所熟知的其他内容。这部分内存从0x00000000开始,延伸到所需的任何地址。由于任何两个进程的内存空间不会重叠, 任何可以访问某个内存地址(比如0xbffff978)的进程, 实际上访问的是物理内存中的不同位置!这些进程实际上访问的是名为0xbffff978的索引, 该索引指向为该特定进程预留的内存区域中的某个偏移量。大多数情况下, 像我们的"Hello, World"程序这样的进程不能访问其他进程的空间, 我们稍后会讨论一些可以做到这一点的方法。

内核也有自己的内存空间。由于模块是可以在内核中动态插入和删除的代码(与半独立对象相反), 因此它共享内核的代码空间, 而不是拥有自己的代码空间。因此, 如果模块段出错, 则内核段出错。如果你因为一个"边界错误(off-by-one)"开始覆写数据, 那么你就是在破坏内核数据(或代码)。这种情况比听起来的还要危险, 所以尽量小心处理。

译者注: "半独立"强调的是既有独立操作的能力, 同时又不完全脱离外部控制或支持的状态, 内核模块直接运行在内核空间, 与内核共享相同的内存和执行环境, 而半独立的对象通常在用户空间运行, 拥有自己独立的内存和资源管理, 这使得它们在出现错误时不会直接影响到内核的稳定性。

应当注意, 上述讨论适用于任何使用单块式内核(monolithic kernel)的操作系统。这个概念与"将所有模块构建到内核中"略有不同, 尽管底层原理类似。相反, 在微内核模块会分配自己的代码空间。微内核的著名的例子有GNU Hurd和谷歌Fuchsia的锆石内核(Zircon kernel)

设备驱动

有一类模块是设备驱动程序, 它为硬件(如串行端口)提供功能。在Unix上, 每个硬件都通过位于/dev目录下的设备文件来表示, 这些文件提供了与硬件通信的手段。设备驱动程序代表用户程序提供通信。因此, es1370.ko 声卡设备驱动程序可以将/dev/sound设备文件连接到 Ensoniq ES1370 声卡。像 mp3blaster 这样的用户空间程序可以使用/dev/sound, 而无需知道安装了何种声卡。

我们可以看一些设备文件。以下是表示串行端口(tty-teletypewriter)上的前三个端口的设备文件:

$ ls -l /dev/tty[1-3]
crw--w---- 1 root tty 4, 1  7月28日 15:44 /dev/tty1
crw--w---- 1 root tty 4, 2  7月28日 15:44 /dev/tty2
crw--w---- 1 root tty 4, 3  7月28日 15:44 /dev/tty3

请注意日期前方由逗号分隔的数字列(4, 1 ; 4, 2 ; 4, 3)。第一个数字称为设备的主编号。第二个数字是次要数字。主编号告诉你使用哪个驱动程序访问硬件。每个驱动程序都有唯一的一个主编号;主编号相同的设备文件都由同一个驱动程序控制。上述所有主编号都是 4 , 因为它们都由同一个驱动程序控制。

次要编号由驱动程序用于区分其控制的各种硬件。回到上面的例子, 尽管所有三个设备都由相同的驱动程序处理, 但它们具有唯一的次要编号, 因为驱动程序将它们视为不同的硬件。

设备分为两种类型: 字符设备和块设备。区别在于块设备具有请求的缓冲区, 因此它们可以选择响应请求的最佳顺序。这一点对存储设备尤为重要, 因为读写相邻的扇区比读写距离较远的扇区更快。另一个区别是, 块设备只能按块接收输入和返回输出(块的大小可以根据设备而变), 而字符设备则可以按需要使用任意数量的字节。世界上大多数设备都是字符型的, 它们不需要这种缓冲不以固定的块大小运行。你可以通过查看ls -l输出中的第一个字符来判断设备文件是用于块设备还是字符设备。如果它是"b", 则它是块设备, 如果它是c, 则它为字符设备。上面看到的设备是字符设备。以下是一些块设备(存储):

brw-rw----  1 root disk      7,   0  7月28日 15:44 loop0
brw-rw----  1 root disk      7,   1  7月28日 15:44 loop1
brw-rw----  1 root disk      7,   2  7月28日 15:44 loop2
brw-rw----  1 root disk      7,   3  7月28日 15:44 loop3
brw-rw----  1 root disk      7,   4  7月28日 15:44 loop4
brw-rw----  1 root disk      7,   5  7月28日 15:44 loop5
brw-rw----  1 root disk      7,   6  7月28日 15:44 loop6
brw-rw----  1 root disk      7,   7  7月28日 15:44 loop7
brw-rw----  1 root disk    259,   0  7月28日 15:44 nvme0n1
brw-rw----  1 root disk    259,   1  7月28日 15:44 nvme0n1p1
brw-rw----  1 root disk      8,   0  7月28日 15:44 sda
brw-rw----  1 root disk      8,   1  7月28日 15:44 sda1
brw-rw----  1 root disk      8,   2  7月28日 15:44 sda2

如果要查看已分配的主编号, 可以查看Documentation/admin-guide/devices.txt.

安装系统时, 所有设备文件都是通过mknod命令创建的。要创建一个名为coffee的新字符设备, 主为12和次为2, 只需执行mknod /dev/coffee c 12 2。你不必将设备文件放入/dev中, 但这是按照约定完成的。Linus将他的设备文件放在/dev中, 你也应该这样。然而, 当为测试目的创建设备文件时, 将其放置在你编译内核模块的工作目录中通常是可以的。但完成设备驱动程序编写后, 确保将其放在正确的位置。

最后需要明确的是当访问设备文件时, 内核使用该文件的主编号来识别处理访问对应的驱动程序。这意味着内核不必依赖或了解次设备号。处理次设备号的是驱动程序本身, 它用次设备号来区分不同的硬件设备。

需要注意的是, 当提到 "硬件(hardware)" 时, 该术语的使用比较抽象, 他指的不仅仅是PCI设备。看看以下两个设备文件:

ls -l /dev/nvme0n1* /dev/sda
brw-rw---- 1 root disk 259, 0  728 15:44 /dev/nvme0n1
brw-rw---- 1 root disk 259, 1  728 15:44 /dev/nvme0n1p1

从这两个设备文件的信息你应该能看出它们是块设备, 并且他们是由相同的驱动程序处理(块, 主259)。但是上述具有相同主编号但不同次号的两个设备文件实际上是表示相同的物理硬件。所以请注意, 我们讨论中的 "硬件(hardware)" 一词可能意味着一些抽象的东西。