第 3 章. 预备知识

模块 vs 程序

模块如何开始和结束

程序通常以一个main()函数开始,执行一系列指令,并在这些指令完成后终止。内核模块的工作方式略有不同。一个模块总是以init_module或您使用module_init调用指定的函数开始。这是模块的入口函数;它告诉内核模块提供的功能,并设置内核在需要时运行模块的函数。一旦完成这些,入口函数返回,模块就什么也不做,直到内核想要用模块提供的代码做一些事情。

所有模块都通过调用cleanup_module或您使用module_exit调用指定的函数结束。这是模块的出口函数;它撤销入口函数所做的任何操作。它取消注册入口函数注册的功能。

每个模块都必须有一个入口函数和一个出口函数。由于指定入口函数和出口函数的方式不止一种,我会尽力使用术语“入口函数”和“出口函数”,但如果我偶尔不小心简单地将它们称为init_modulecleanup_module,我想您会明白我的意思。

模块可用的函数

程序员经常使用他们没有定义的函数。一个典型的例子是printf()。您使用这些库函数,它们由标准C库libc提供。这些函数的定义实际上在链接阶段才进入您的程序,这确保了代码(例如printf()例如)是可用的,并修复调用指令以指向该代码。

内核模块在这里也不同。在hello world示例中,您可能已经注意到我们使用了一个函数,printk()但没有包含标准I/O库。这是因为模块是目标文件,其符号在insmod时被解析。符号的定义来自内核本身;您唯一可以使用的外部函数是内核提供的函数。如果您对内核导出了哪些符号感到好奇,请查看/proc/ksyms.

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

您想看看printf()使用了哪些系统调用吗?很简单!编译以下程序

    #include <stdio.h>
    int main(void)
    { printf("hello"); return 0; }
				

使用 gcc -Wall -o hello hello.c 进行编译。使用 strace hello 运行可执行文件。您感到惊讶吗?您看到的每一行都对应一个系统调用。strace[1] 是一个方便的程序,它为您提供有关程序正在进行的系统调用的详细信息,包括进行了哪些调用,其参数是什么以及返回值是什么。它是查找程序尝试访问哪些文件等问题的宝贵工具。在结尾处,您会看到一行类似于write(1, "hello", 5hello)。就在这里。printf()面具背后的真相。您可能不熟悉write,因为大多数人使用库函数进行文件I/O(如fopen,fputs,fclose)。如果是这种情况,请尝试查看 man 2 write。第2节手册专门介绍系统调用(如kill()read())。第3节手册专门介绍库调用,您可能更熟悉(如cosh()random()).

您甚至可以编写模块来替换内核的系统调用,我们稍后会这样做。黑客经常利用这类东西进行后门或木马,但您可以编写自己的模块来做更良性的事情,例如让内核在有人尝试删除系统上的文件时写入 嘻嘻,真痒!

用户空间 vs 内核空间

内核完全是关于资源访问的,无论所讨论的资源是显卡、硬盘驱动器甚至是内存。程序经常竞争相同的资源。当我刚刚保存此文档时,updatedb 开始更新 locate 数据库。我的 vim 会话和 updatedb 都在并发使用硬盘驱动器。内核需要保持事物有序,而不是在用户想访问资源时就给予他们访问权限。为此,CPU 可以在不同的模式下运行。每种模式都给予您在系统上执行所需操作的不同程度的自由。Intel 80386 架构有 4 种这些模式,称为环。Unix 仅使用两个环;最高的环(环 0,也称为“超级用户模式”,允许发生任何事情)和最低的环,称为“用户模式”。

回想一下关于库函数与系统调用的讨论。通常,您在用户模式下使用库函数。库函数调用一个或多个系统调用,这些系统调用代表库函数执行,但在超级用户模式下执行,因为它们是内核本身的一部分。一旦系统调用完成其任务,它就会返回,执行权转移回用户模式。

命名空间

当您编写一个小型 C 程序时,您使用的变量对读者来说既方便又有意义。另一方面,如果您正在编写将成为更大问题一部分的例程,那么您拥有的任何全局变量都是其他人全局变量社区的一部分;某些变量名称可能会冲突。当一个程序有很多不够有意义以至于无法区分的全局变量时,您会得到 命名空间污染。在大型项目中,必须努力记住保留名称,并找到开发命名唯一变量名称和符号的方案的方法。

在编写内核代码时,即使是最小的模块也将与整个内核链接,因此这绝对是一个问题。处理此问题的最佳方法是将所有变量声明为 static,并为您的符号使用明确定义的前缀。按照惯例,所有内核前缀都是小写的。如果您不想将所有内容声明为 static,另一种选择是声明一个符号表并将其注册到内核。我们稍后会讲到这一点。

文件/proc/ksyms保存内核知道的所有符号,因此您的模块可以访问这些符号,因为它们共享内核的代码空间。

代码空间

内存管理是一个非常复杂的主题---O'Reilly 的《Understanding The Linux Kernel》的大部分内容都是关于内存管理的!我们不是要成为内存管理专家,但我们确实需要了解一些事实,才能开始担心编写真正的模块。

如果您没有考虑过段错误真正意味着什么,您可能会惊讶地听到指针实际上并不指向内存位置。至少不是真正的内存位置。当创建一个进程时,内核会留出一部分真实的物理内存,并将其交给进程用于执行代码、变量、堆栈、堆以及计算机科学家会知道的其他东西[2]。此内存从 $0$ 开始,并扩展到所需的任何大小。由于任何两个进程的内存空间都不重叠,因此每个可以访问内存地址的进程,比如0xbffff978,都将访问真实物理内存中的不同位置!这些进程将访问一个名为0xbffff978的索引,该索引指向为该特定进程预留的内存区域中的某种偏移量。在大多数情况下,像我们的 Hello, World 程序这样的进程无法访问另一个进程的空间,尽管有一些方法我们稍后会讨论。

内核也有自己的内存空间。由于模块是可以动态插入和删除到内核中的代码(而不是半自主对象),因此它共享内核的代码空间,而不是拥有自己的代码空间。因此,如果您的模块发生段错误,内核也会发生段错误。如果您由于差一错误而开始覆盖数据,那么您就是在践踏内核代码。这比听起来更糟糕,所以请尽量小心。

顺便说一句,我想指出的是,以上讨论适用于任何使用单内核[3]的操作系统。有些东西叫做微内核,它们的模块有自己的代码空间。GNU Hurd 和 QNX Neutrino 是微内核的两个例子。

设备驱动程序

模块的一个类别是设备驱动程序,它为硬件(如电视卡或串口)提供功能。在 unix 上,每件硬件都由位于/dev的文件中表示,该文件名为设备文件,它提供了与硬件通信的手段。设备驱动程序代表用户程序提供通信。所以es1370.o声卡设备驱动程序可能会连接/dev/sound设备文件到 Ensoniq IS1370 声卡。像 mp3blaster 这样的用户空间程序可以使用/dev/sound,而无需知道安装了哪种声卡。

主设备号和次设备号

让我们看一些设备文件。以下是代表主 IDE 硬盘驱动器上的前三个分区的设备文件

    # ls -l /dev/hda[1-3]
    brw-rw----  1 root  disk  3, 1 Jul  5  2000 /dev/hda1
    brw-rw----  1 root  disk  3, 2 Jul  5  2000 /dev/hda2
    brw-rw----  1 root  disk  3, 3 Jul  5  2000 /dev/hda3
					

注意用逗号分隔的数字列吗?第一个数字称为设备的主设备号。第二个数字是次设备号。主设备号告诉您哪个驱动程序用于访问硬件。每个驱动程序都分配有一个唯一的主设备号;所有具有相同主设备号的设备文件都由同一个驱动程序控制。以上所有主设备号都是 3,因为它们都由同一个驱动程序控制。

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

设备分为两种类型:字符设备和块设备。区别在于块设备有一个请求缓冲区,因此它们可以选择响应请求的最佳顺序。这在存储设备的情况下很重要,在存储设备中,读取或写入彼此靠近的扇区比读取或写入相距较远的扇区更快。另一个区别是块设备只能以块为单位接受输入和返回输出(块的大小可能因设备而异),而字符设备可以根据需要使用任意数量的字节。世界上大多数设备都是字符设备,因为它们不需要这种类型的缓冲,并且它们不以固定的块大小运行。您可以通过查看 ls -l 输出的第一个字符来判断设备文件是块设备还是字符设备。如果它是“b”,则它是块设备,如果它是“c”,则它是字符设备。您在上面看到的设备是块设备。以下是一些字符设备(串行端口)

    crw-rw----  1 root  dial 4, 64 Feb 18 23:34 /dev/ttyS0
    crw-r-----  1 root  dial 4, 65 Nov 17 10:26 /dev/ttyS1
    crw-rw----  1 root  dial 4, 66 Jul  5  2000 /dev/ttyS2
    crw-rw----  1 root  dial 4, 67 Jul  5  2000 /dev/ttyS3
					

如果您想查看已分配了哪些主设备号,您可以查看/usr/src/linux/Documentation/devices.txt.

当系统安装时,所有这些设备文件都是由 mknod 命令创建的。要创建一个名为“coffee”的新字符设备,其主/次设备号为122,只需执行 mknod /dev/coffee c 12 2。您不 必须 将您的设备文件放在/dev,但这是一种约定俗成的做法。Linus 将他的设备文件放在/dev,您也应该这样做。但是,当为测试目的创建设备文件时,将其放在您编译内核模块的工作目录中可能是可以的。只需确保在完成编写设备驱动程序后将其放在正确的位置。

我想提出最后几点,这些点从上面的讨论中已经隐含,但我希望明确地说明一下,以防万一。当访问设备文件时,内核使用文件的主设备号来确定应使用哪个驱动程序来处理访问。这意味着内核实际上不需要使用甚至不需要知道次设备号。驱动程序本身是唯一关心次设备号的东西。它使用次设备号来区分不同的硬件。

顺便说一句,当我说“硬件”时,我的意思是指比您可以握在手中的 PCI 卡更抽象的东西。看看这两个设备文件

    % ls -l /dev/fd0 /dev/fd0u1680
    brwxrwxrwx   1 root  floppy   2,  0 Jul  5  2000 /dev/fd0
    brw-rw----   1 root  floppy   2, 44 Jul  5  2000 /dev/fd0u1680
					

现在您可以查看这两个设备文件,并立即知道它们是块设备,并且由同一个驱动程序处理(块主设备号2)。您甚至可能意识到它们都代表您的软盘驱动器,即使您只有一个软盘驱动器。为什么是两个文件?一个代表具有1.44 MB 存储空间的软盘驱动器。另一个是具有1.68 MB 存储空间的相同 软盘驱动器,并且对应于某些人所说的“超格式化”磁盘。一种比标准格式化软盘保存更多数据的磁盘。所以这是一个两个具有不同次设备号的设备文件实际上代表同一物理硬件的例子。所以请注意,我们讨论中的“硬件”一词可能意味着非常抽象的东西。

注释

[1]

它是查找程序尝试访问哪些文件等问题的宝贵工具。有没有遇到过程序因为找不到文件而静默退出的情况?真是太痛苦了!

[2]

我是物理学家,不是计算机科学家,吉姆!

[3]

这与“将所有模块构建到内核中”并不完全相同,尽管想法是相同的。