程序通常以main()函数开始,执行一系列指令并在这些指令完成后终止。内核模块的工作方式略有不同。模块总是以init_module或您使用module_init调用的函数开始。 这是模块的入口函数;它告诉内核模块提供的功能,并设置内核以在需要时运行模块的函数。 完成此操作后,入口函数返回,并且模块不执行任何操作,直到内核想要使用模块提供的代码执行某些操作为止。
所有模块都通过调用cleanup_module或您使用module_exit调用的函数结束。 这是模块的退出函数;它撤消入口函数所做的任何事情。 它取消注册入口函数注册的功能。
每个模块都必须有一个入口函数和一个退出函数。 由于有多种指定入口和退出函数的方法,因此我将尽力使用术语“入口函数”和“退出函数”,但是如果我不小心简单地将它们称为init_module和cleanup_module,我想您会明白我的意思。
程序员经常使用他们没有定义的函数。 一个主要的例子是printf()。 您使用这些由标准 C 库 libc 提供的库函数。 这些函数的定义实际上直到链接阶段才会进入您的程序,这可以确保代码(例如,printf())可用,并修复调用指令以指向该代码。
内核模块在这里也有所不同。 在 hello world 示例中,您可能已经注意到我们使用了一个函数,printk(),但没有包含标准 I/O 库。 这是因为模块是对象文件,其符号在 insmod 时被解析。 符号的定义来自内核本身; 您可以使用的唯一外部函数是内核提供的函数。 如果您对内核导出的符号感到好奇,请查看/proc/kallsyms.
需要记住的一点是库函数和系统调用之间的区别。 库函数是更高级别的,完全在用户空间中运行,并为程序员提供更方便的接口来执行实际工作的函数---系统调用。 系统调用代表用户在内核模式下运行,并由内核本身提供。 库函数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,真痒!。
内核是关于访问资源的,无论所讨论的资源是视频卡,硬盘驱动器甚至是内存。 程序经常争夺相同的资源。 正当我保存此文档时,updatedb 开始更新 locate 数据库。 我的 vim 会话和 updatedb 都在同时使用硬盘驱动器。 内核需要保持井井有条,而不是在用户想要的时候才让他们访问资源。 为此,CPU 可以在不同的模式下运行。 每种模式都给您在系统上执行操作的不同程度的自由。 Intel 80386 架构具有 4 种这样的模式,称为环。 Unix 仅使用两个环; 最高环(环 0,也称为“超级用户模式”,允许发生所有事情)和最低环,称为“用户模式”。
回想一下关于库函数与系统调用的讨论。 通常,您在用户模式下使用库函数。 库函数调用一个或多个系统调用,并且这些系统调用代表库函数执行,但由于它们是内核本身的一部分,因此在超级用户模式下执行。 系统调用完成其任务后,它将返回,并且执行将转移回用户模式。
当您编写一个小型 C 程序时,您会使用方便且对读者有意义的变量。 另一方面,如果您编写的例程将成为更大问题的一部分,那么您的任何全局变量都是其他人的全局变量社区的一部分; 一些变量名称可能会冲突。 当程序有很多不够有意义的全局变量无法区分时,您会遇到命名空间污染。 在大型项目中,必须努力记住保留的名称,并找到开发用于命名唯一变量名和符号的方案的方法。
在编写内核代码时,即使是最小的模块也将与整个内核链接,因此这绝对是一个问题。 解决此问题的最佳方法是将所有变量声明为 static 并为您的符号使用明确定义的前缀。 按照惯例,所有内核前缀均为小写。 如果您不想将所有内容声明为 static,则另一种选择是声明一个符号表并将其注册到内核。 我们稍后会介绍这一点。
文件/proc/kallsyms包含内核知道的所有符号,因此您的模块可以访问这些符号,因为它们共享内核的代码空间。
内存管理是一个非常复杂的主题---O'Reilly 的《了解 Linux 内核》的大部分内容都只是关于内存管理! 我们并不是要成为内存管理方面的专家,但是我们确实需要知道一些事实,才能开始担心编写真实的模块。
如果您还没有考虑过段错误真正意味着什么,您可能会惊讶地听到指针实际上并没有指向内存位置。 无论如何,都不是真正的。 创建进程时,内核会预留一部分真实的物理内存并将其交给进程,以用于其执行代码,变量,堆栈,堆以及计算机科学家会知道的其他东西[2]。 此内存从 0x00000000 开始,并扩展到所需的任何位置。 由于任何两个进程的内存空间都不重叠,因此每个可以访问内存地址的进程,例如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] | 这与“将所有模块构建到内核中”并不完全相同,尽管想法是一样的。 |