程序通常以main()函数开始,执行一系列指令,并在这些指令完成后终止。内核模块的工作方式略有不同。模块总是以init_module或者您使用module_init调用的函数开始。这是模块的入口函数;它告诉内核模块提供的功能,并设置内核在需要时运行模块的函数。完成此操作后,入口函数返回,模块不执行任何操作,直到内核想要使用模块提供的代码执行某些操作为止。
所有模块都通过调用cleanup_module或者您使用module_exit调用的函数结束。这是模块的退出函数;它撤消入口函数所做的一切。它注销入口函数注册的功能。
每个模块都必须有一个入口函数和一个退出函数。由于指定入口和退出函数的方式不止一种,我会尽力使用术语“入口函数”和“退出函数”,但如果我不小心只是将它们称为init_module和cleanup_module, 我想您会明白我的意思。
程序员经常使用他们没有定义的函数。一个主要的例子是printf()。您使用由标准 C 库 libc 提供的这些库函数。这些函数的定义实际上不会进入您的程序,直到链接阶段,这确保了代码(例如printf())可用,并修复了指向该代码的调用指令。
内核模块在这方面也有所不同。在 hello world 示例中,您可能已经注意到我们使用了函数printk(),但没有包含标准 I/O 库。这是因为模块是对象文件,其符号在 insmod 时得到解析。符号的定义来自内核本身;您唯一可以使用的外部函数是内核提供的函数。如果您对内核导出了哪些符号感到好奇,请查看/proc/ksyms.
需要记住的一点是库函数和系统调用之间的区别。库函数是更高级的函数,完全在用户空间中运行,并为程序员提供更方便的接口来执行实际工作的函数---系统调用。系统调用以用户身份在内核模式下运行,由内核本身提供。库函数printf()可能看起来像一个非常通用的打印函数,但它实际上所做的只是将数据格式化为字符串,并使用底层系统调用write()写入字符串数据,然后将数据发送到标准输出。
您想看看printf()发出了哪些系统调用吗?很简单!使用 gcc -Wall -o hello hello.c 编译以下程序
#include <stdio.h> int main(void) { printf("hello"); return 0; } |
。使用 strace hello 运行可执行文件。您感到惊讶吗?您看到的每一行都对应于一个系统调用。 strace[1] 是一个方便的程序,可以为您提供有关程序正在进行的系统调用的详细信息,包括进行了哪个调用、它的参数是什么以及它返回什么。它是一个非常宝贵的工具,可以用来确定程序尝试访问哪些文件。在末尾附近,您会看到一行看起来像write(1, "hello", 5hello)。就在那里。在printf()面具背后的脸。您可能不熟悉 write,因为大多数人使用库函数进行文件 I/O(例如 fopen、fputs、fclose)。如果是这种情况,请尝试查看 man 2 write。第 2 个 man 节专门介绍系统调用(例如kill()和read())。第 3 个 man 节专门介绍库调用,您可能更熟悉这些调用(例如cosh()和random()).
您甚至可以编写模块来替换内核的系统调用,我们稍后会这样做。黑客经常利用这种东西来设置后门或木马,但您可以编写自己的模块来做更良性的事情,例如让内核在有人尝试删除您系统上的文件时写入 Tee hee, that tickles! 。
内核是关于访问资源的,无论所讨论的资源是显卡、硬盘驱动器还是内存。程序经常争夺相同的资源。正如我刚刚保存此文档一样,updatedb 开始更新 locate 数据库。我的 vim 会话和 updatedb 同时使用硬盘驱动器。内核需要保持有序,并且不允许用户随时访问资源。为此,CPU 可以在不同的模式下运行。每种模式都提供不同级别的自由度,让您在系统上做您想做的事情。 Intel 80386 架构有 4 种这样的模式,称为环。 Unix 仅使用两个环;最高的环(环 0,也称为“超级用户模式”,其中允许发生一切)和最低的环,称为“用户模式”。
回想一下关于库函数与系统调用的讨论。通常,您在用户模式下使用库函数。库函数调用一个或多个系统调用,这些系统调用代表库函数执行,但由于它们是内核本身的一部分,因此在超级用户模式下执行。一旦系统调用完成其任务,它就会返回,并且执行会转移回用户模式。
当您编写一个小的 C 程序时,您使用方便且对读者有意义的变量。另一方面,如果您正在编写将成为更大问题一部分的例程,那么您拥有的任何全局变量都是其他人全局变量社区的一部分;某些变量名可能会冲突。当程序有大量全局变量,但这些变量的含义不足以区分时,就会发生命名空间污染。在大型项目中,必须努力记住保留名称,并找到开发一种命名唯一变量名和符号的方案的方法。
在编写内核代码时,即使是最小的模块也会与整个内核链接,因此这绝对是一个问题。处理此问题的最佳方法是将所有变量声明为 static,并为您的符号使用定义良好的前缀。按照惯例,所有内核前缀都是小写的。如果您不想将所有内容声明为 static,则另一个选项是声明一个符号表并将其注册到内核。我们稍后会讲到这一点。
文件/proc/ksyms包含内核知道的所有符号,因此您的模块可以访问这些符号,因为它们共享内核的代码空间。
内存管理是一个非常复杂的主题---O'Reilly 的《理解 Linux 内核》的大部分内容都只是关于内存管理!我们并不是要成为内存管理方面的专家,但我们确实需要了解一些事实,才能开始担心编写真正的模块。
如果您还没有想过段错误真正意味着什么,您可能会惊讶地听到指针实际上并不指向内存位置。无论如何,不是真正的。创建进程时,内核会预留一部分实际物理内存,并将其交给进程以用于其执行代码、变量、堆栈、堆以及计算机科学家会知道的其他内容[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',主/次设备号为12和2,只需执行 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] | 这与“将所有模块构建到内核中”并不完全相同,尽管想法是相同的。 |