



内核模块是一种软件组件,旨在通过添加新功能扩展Linux内核。内核模块可以是一个设备驱动程序,在这种情况下,它将控制和管理特定的硬件设备,因此称为设备驱动程序。内核模块还可以添加框架(例如IIO框架)支持,扩展现有框架,甚至可以是新文件系统或其扩展。需要记住的是,内核模块并不总是设备驱动程序,但设备驱动程序一定是内核模块。
注意
在Linux中,框架指的是一组API和库的集合。
除了内核模块,还有简单模块或用户空间模块,它们在用户空间中运行,权限较低。而本书只讨论内核模块,尤其是Linux内核模块。
本章将讨论以下主题:
●模块概念的介绍;
●构建Linux内核模块;
●处理模块参数;
●处理符号导出和模块依赖;
●学习Linux内核编程技巧。
在构建Linux内核时,配置文件中所有启用的功能将生成相应的目标文件,所有目标文件链接起来,将生成最终的单一映像文件。一旦内核启动,即使文件系统尚未准备好或不存在,内核映像文件中包含的功能也可以立即使用。这些功能是内置的,相应的模块称为静态模块。内核映像文件中的静态模块是随时可用的,因此无法卸载,这会增加最终的内核映像文件的大小。静态模块也称为内置模块,由于它是最终的内核映像文件输出的组成部分,因此对代码的任何修改都需要重新构建整个内核。
然而,某些功能(如设备驱动程序、文件系统和框架)可以编译为可加载模块。这些模块与最终的内核映像文件各自独立,按需加载。内核模块可在运行时增设或删除内核的功能,因此被视为可动态加载和卸载的插件。由于每个模块都作为单独的文件存放在文件系统中,因此使用可加载模块需要获取文件系统的访问权限。
总而言之,模块对于Linux内核就像插件对于用户软件(如Firefox)一样。当模块静态链接到最终的内核映像文件时,称为内置模块。当模块作为单独的文件(可以加载/卸载)被构建时,模块是可加载的。模块机制甚至可以在不需要重启系统的情况下,动态地扩展内核的功能。
要支持模块的加载,内核的构建就必须启用以下选项:
CONFIG_MODULES=y
卸载模块是内核的一个功能,可根据CONFIG_MODULE_UNLOAD内核配置选项来启用或禁用。如果没有这个选项,将无法卸载任何模块。因此,为了能够卸载模块,必须启用以下选项:
CONFIG_MODULE_UNLOAD=y
也就是说,内核足够智能可以防止卸载模块对系统造成损害。例如,即使明确地要求卸载正在使用的模块,内核也不会这么做。这是因为内核具有对模块的引用计数,引用计数记录了模块当前是否仍在使用,所以如果内核认为卸载模块是不安全的,它就不会卸载。不过,可以通过以下配置修改这种特性:
MODULE_FORCE_UNLOAD=y
上述选项允许强制卸载模块。至此,我们已经介绍完模块所涉及的主要概念,现在可以开始动手实践了。作为本章的基础,下面介绍一个模块骨架。
案例学习:模块骨架
考虑以下hello-world模块。这将是贯穿本章实践的基础案例。首先把这个可编译的文件命名为helloworld.c,其内容如下:
#include <linux/module.h>
#include <linux/init.h>
static int __init helloworld_init(void){
pr_info("Hello world initialization!\n");
return 0;
}
static void __exit helloworld_exit(void) {
pr_info("Hello world exit\n");
}
module_init(helloworld_init);
module_exit(helloworld_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("John Madieu <john.madieu@gmail.com>");
MODULE_DESCRIPTION("Linux kernel module skeleton");
在上述模块骨架中,头文件只属于Linux内核源代码,因此引用时使用linux/xxx.h。所有的内核模块都必须引用module.h头文件,而引用__init和__exit宏也必须使用init.h头文件。要构建这个模块骨架,首先需要编写一个专门的Makefile,本章稍后将介绍这部分内容。
定义一个初始化函数是创建内核模块的最低要求,也是必要条件;而如果要构建可加载模块,则必须提供它的退出函数。前者是入口点,对应于加载模块(modprobe或insmod)时调用的函数;后者是清理和退出点,对应于卸载模块(rmmod或modprobe -r)时执行的函数。
你只需要告知内核哪些函数在入口点执行,哪些函数在退出点执行即可。你可以自行修改上述例子中的helloworld_init和helloworld_exit的函数名。唯一的强制要求就是将它们标识为相应的初始化函数和退出函数,并作为参数传递给module_init()宏和module_exit()宏。
总而言之,当构建可加载的内核模块时,若使用insmod或modprobe命令,则会调用module_init()宏;当模块已经编译进内核,且在内核运行到相应的运行级别时,也会调用module_init()宏。模块的功能由初始化函数的内容决定。可加载的内核模块只有在使用rmmod命令卸载时,才需要调用module_exit()宏。
当内核模块是设备驱动程序时,无论该模块处理的设备数量是多少,init或exit函数都只需要调用一次。
通常,作为平台(或类似)设备驱动程序的模块会在其初始化函数中注册平台驱动程序和相应的probe/remove回调函数。每当在系统中添加或移除该模块处理的设备时,都会调用这些回调函数。这样,模块只需要调用exit函数注销平台驱动程序即可。
__init和__exit是内核宏,定义在include/linux/init.h中,如下所示:
#define __init __section(.init.text) #define __exit __section(.exit.text)
__init关键字告诉链接器,以__init为前缀的变量或函数生成的内核目标文件要放到内核的专用区。该专用区被预先告知给内核,内核会在模块加载和初始化函数执行完后释放它。这个过程仅适用于内置模块,而不适用于可加载模块。在系统依序启动的过程中,内核将首次运行驱动程序的初始化函数。由于驱动程序无法卸载,它的初始化函数直到下次重启前都不会被调用,因此也不再需要保留该函数的相关信息。__exit关键字与退出函数也是如此。当模块被静态编译到内核中,或内核不支持模块的卸载时,__exit关键字与退出函数对应的代码被忽略。因为在这两种情况下,退出函数都不会被调用。__exit关键字对可加载模块没有任何影响。
综上所述,__init和__exit是Linux指令(宏),它们封装了GNU C编译器属性。GNU C编译器属性在编译时用于指定符号的存储位置。即使内核可以访问目标文件的不同区段,__init和__exit也会指示编译器将那些以它们作为前缀的代码分别放置在.init.text和.exit.text区段。
即便没有阅读代码,也应该能够获取给定模块的信息(例如模块作者、模块参数描述和许可证)。这些信息存放在模块的.modinfo区段,可通过给MODULE_*宏传递参数值来更新.modinfo区段的内容,这些宏包括MODULE_DESCRIPTION()、MODULE_AUTHOR()和MODULE_LICENSE()。不过,内核提供的更底层的宏是MODULE_INFO(tag, info),用于向模块的.modinfo区段添加条目。该宏可以用tag ="info"的形式添加通用信息。这意味着驱动程序的作者可以添加他想要的任何自定义信息,例如以下信息:
MODULE_INFO(my_field_name, "What easy value");
除了自定义信息,还需要提供一些标准信息。这些标准信息需要由内核提供相应的宏来支持。
许可证定义了源代码是否与其他开发人员共享,以及如何共享。MODULE_LICENSE()会告诉内核一个模块使用了什么许可证,这会对该模块的行为产生影响。因为不兼容GPL(General Public License,通用公共许可证)的许可证将导致模块无法查看或使用内核通过EXPORT_SYMBOL_GPL()宏导出的符号。该宏仅对兼容的GPL模块显示符号,这与EXPORT_SYMBOL()宏相反,后者可为具有任何许可证的模块导出函数。加载不兼容GPL的模块会导致内核被污染,这意味着非开源或不受信任的代码被加载,并且很可能使你得不到来自社区的支持。请记住,没有MODULE_LICENSE()的模块不会被视为开源,并且会污染内核。可用的许可证可以在include/linux/module.h中找到,该头文件描述了内核支持的许可证。
MODULE_AUTHOR()用来声明模块作者,比如MODULE_AUTHOR("John Madieu")。一个模块可能有多个作者,在这种情况下,每个作者都必须使用MODULE_AUTHOR()来声明:
MODULE_AUTHOR("John Madieu ");
MODULE_AUTHOR("Lorem Ipsum ");
MODULE_DESCRIPTION()用来简要描述模块的功能:
MODULE_DESCRIPTION("Hello, world! Module");
你可以使用objdump -d -j .modinfo命令来转储给定内核模块中.modinfo区段的内容。对于需要进行交叉编译的模块,应使用$(CORSS_COMPILE)objdump命令。至此,我们已经满足Linux内核模块编写的最后两个要求——提供模块信息和元数据,接下来让我们学习如何构建这些模块。
目前,构建Linux内核模块有以下两种解决方案。
●第一种解决方案是树外构建,用于构建位于内核源代码树之外的模块。模块的源代码位于与内核不同的目录中。以这种方式构建的模块不允许被集成到内核配置/编译过程中,只能被单独构建。需要注意的是,若使用此解决方案,模块无法静态链接到最终的内核镜像中。也就是说,模块不能被内置。树外编译只生成可加载的内核模块。
●第二种解决方案是树内构建,这种方式允许将代码提交到上游分支(upstream),因为这样可以很好地将代码集成到内核配置/编译过程中。此解决方案允许生成静态模块(也称为内置模块)或可加载的内核模块。
至此,我们已经列举并说明了构建Linux内核模块的两种解决方案的特点。在研究每种解决方案之前,让我们先深入学习Linux内核构建的过程,这将有助于我们了解不同解决方案的编译先决条件。
Linux内核有一套自己的构建系统,叫作kbuild(注意,k是小写)。它允许你配置Linux内核,并根据已有的配置进行编译。这套构建系统主要依赖3个文件来实现,其中Kconfig用于功能选择,主要在内核树构建时使用;而Kbuild(注意,K是大写)或Makefile用于制定编译规则。
在这套构建系统中,makefile被称为Makefile或Kbuild。如果这两个文件都存在,则只使用Kbuild。也就是说,makefile是用于执行一组操作的特殊文件,其中最常见的操作是编译程序。有一个专门解析makefile的工具,叫作make。使用这个工具构建内核模块的命令模式如下:
make -C $KERNEL_SRC M=$(shell pwd) [target]
在上述命令模式中,$KERNEL_SRC指的是预构建内核目录的路径。-C $KERNEL_SRC指示make在执行时进入指定的目录,并在完成后返回到原目录。M=$(shell pwd)指示内核构建系统返回此目录以查找正在构建的模块。M所给出的值是模块源代码(或相关的Kbuild)所在目录的绝对路径。[target]对应构建外部模块时可用的make目标文件子集,如下所示。
●modules:这是外部模块的默认目标。无论是否指定目标,作用都是相同的。
●modules_install:用来安装外部模块。默认路径是/lib/modules/<kernel_release>/ extra/。该路径可以被覆盖。
●clean:用来删除模块目录中产生的所有文件。
然而,我们尚未告知内核构建系统要构建或链接哪些目标文件。我们必须指定所要构建模块的名称,以及所需源文件的列表。这可以用以下简单的一行命令来完成:
obj-<X> := <module_name>.o
在上述例子中,内核构建系统将由<module_name>.c或<module_name>.S文件构建出<module_name>.o文件。链接后将生成<module_name>.ko内核可加载模块,或将该模块作为单个内核映像文件的一部分。<X>可以是y、m或为空。
至于如何构建或链接mymodule.o文件,则取决于<X>的值。
●如果<X>设置为m,则使用obj-m变量,mymodule.o将被构建为可加载的内核模块。
●如果<X>设置为y,则使用obj-y变量,mymodule.o将被构建为内核的一部分。此时可以认为“foo是一个内置的内核模块”。
●如果<X>未设置,则使用obj-变量,此时mymodule.o根本不会被构建。
然而,obj-$(CONFIG_XXX)模式经常被使用。其中,CONFIG_XXX是内核配置中一个可设置的配置选项。以下是一个示例:
obj-$(CONFIG_MYMODULE) += mymodule.o
$(CONFIG_MYMODULE)根据其在内核配置过程中的值(使用menuconfig显示)会被计算为y、m或空。如果CONFIG_MYMODULE不是y或m,则文件不会被编译或链接。y表示内置模块(在内核配置过程中代表“yes”),而m表示可加载模块。$(CONFIG_MYMODULE)会从常规配置过程中提取正确的答案。
到目前为止,我们一直假设模块是由单个.c源文件构建的。当模块从多个源文件构建时,需要添加一行命令来列出这些源文件,如下所示:
<module_name>-y := <file1>.o <file2>.o
上述命令行表明<module_name>.ko将由file1.c和file2.c两个源文件构建。但是,如果想要构建两个模块,如foo.ko和bar.ko,则Makefile中的代码应该如下所示:
obj-m := foo.o bar.o
如果foo.o和bar.o由除了foo.c和bar.c以外的其他源文件构成,则可以指定每个目标文件所对应的源文件,如下所示:
obj-m := foo.o bar.o foo-y := foo1.o foo2.o … bar-y := bar1.o bar2.o bar3.o …
以下是另一个示例,用于列出给定模块所需的源文件:
obj-m := 8123.o 8123-y := 8123_if.o 8123_pci.o 8123_bin.o
上述示例说明,通过编译和链接,8123_if.c、8123_pci.c和8123_bin.c将构建成8123这个可加载的内核模块。
Makefile不仅可以包含需要构建的文件,也可以包含编译器和链接器的标志(flag),如下所示:
ccflags-y := -I$(src)/include ccflags-y += -I$(src)/src/hal/include ldflags-y := -T$(src)foo_sections.lds
这里需要特别强调的是,可以自定义这些标志的值,而无须使用例子中设定的值。
obj-<X>的另一种用法如下:
obj-<X> += somedir/
这意味着内核构建系统会进入名为somedir的目录,查找是否有Makefile或Kbuild,然后进行处理,以确定应该构建哪些对象。
上述内容可以通过以下Makefile来总结:
# kbuild part of makefile
obj-m := helloworld.o
#the following is just an example
#ldflags-y := -T foo_sections.lds
# normal makefile
KERNEL_SRC ?= /lib/modules/$(shell uname -r)/build
all default: modules
install: modules_install
modules modules_install help clean:
$(MAKE) -C $(KERNEL_SRC) M=$(shell pwd) $@
以下是对Makefile极简框架的描述。
●obj-m := helloworld.o:obj-m列出了所要构建的模块。对于每个<filename>.o,内核构建系统将查找<filename>.c或<filename>.S文件进行构建。obj-m用于构建可加载的内核模块,而obj-y用于构建内置的内核模块。
●KERNEL_SRC ?= /lib/modules/$(shell uname -r)/build:KERNEL_SRC指示了预构建内核的源代码所在的位置。如前所述,构建任何模块都需要有一个预构建内核。如果已经从源代码构建了内核,则应该将这个变量设置为内核源代码目录的绝对路径。-C会指示make工具跳转到该指定路径并读取Makefile。
●M=$(shell pwd):这与内核构建系统有关。内核中的Makefile使用此变量来确定要构建的外部模块所在的目录。.c文件应放在该目录下。
●all default: modules:此行指示make工具将模块目标作为all或default目标的依赖关系而执行。换句话说,make default、make all或简单的make命令将在执行任何后续命令之前执行make modules命令。
●modules modules_install help clean:此行表示Makefile中有效的目标列表。
●$(MAKE) -C $(KERNEL_SRC) M=$(shell pwd) $@:这是针对之前列举的每个目标都要执行的规则。$@将被替换为传递给make的参数,其中包括目标。这种“魔法词”使得我们可以不必为每一个目标都写一行规则。换句话说,如果执行make modules命令,$@将被替换为modules,规则将变为$(MAKE) -C $(KERNEL_SRC) M=$(shell pwd) modules。
至此,我们已经了解了内核构建系统的需求,下面让我们来看看到底如何真正地构建模块。
在构建树外模块之前,需要有完整的预编译内核源代码树。预构建的内核版本必须与你将要加载和使用的模块的内核版本相同。以下两种方法可以获得预构建的内核版本。
●正如之前所提到的,可以自己构建:适用于本地编译以及交叉编译,例如使用Yocto或Buildroot等构建系统。
●从发行版软件包源代码中安装linux-headers-*软件包:这仅适用于x86本地编译,除非你的嵌入式目标是运行维护包源的Linux分发版(如Raspbian)。
必须注意的是,树外构建不能构建内置的内核模块,因为树外构建Linux内核模块需要一个预构建或准备好的内核。
对于本地内核模块的构建,最简单的方法是安装预构建的内核头文件并将其目录路径作为Makefile中的内核目录。在开始之前,可以使用以下命令安装内核头文件:
sudo apt update sudo apt install linux-headers-$(uname -r)
这将在/usr/src/linux-headers-$(uname -r)中安装预配置和预构建的内核头文件(并非整个源代码树)。另外,将有一个符号链接/lib/modules/$(uname -r)/build指向先前安装的内核头文件。这个路径应该在Makefile中被指定为内核目录。请记住,$(uname -r)对应的是正在使用的内核版本。
至此,当你完成Makefile后,请继续停留在模块源目录中,执行make命令或make modules命令:
$ make make -C /lib/modules/ 5.11.0-37-generic/build \ M=/home/john/driver/helloworld modules make[1]: Entering directory '/usr/src/linux-headers-5.11.0-37-generic' CC [M] /media/jma/DATA/work/tutos/sources/helloworld/helloworld.o Building modules, stage 2. MODPOST 1 modules CC /media/jma/DATA/work/tutos/sources/helloworld/helloworld.mod.o LD [M] /media/jma/DATA/work/tutos/sources/helloworld/helloworld.ko make[1]: Leaving directory '/usr/src/linux-headers- 5.11.0-37-generic'
等到构建结束时,你将看到以下文件:
$ ls helloworld.c helloworld.ko helloworld.mod.c helloworld.mod.o helloworld.o Makefile modules.order Module.symvers
要进行测试,可以执行以下命令:
$ sudo insmod helloworld.ko $ sudo rmmod helloworld $ dmesg [...] [308342.285157] Hello world initialization! [308372.084288] Hello world exit
前面的示例仅涉及本地构建,即在运行标准Linux发行版的机器上进行编译,我们可以利用其软件包存储库来安装预构建的内核头文件。
在交叉编译树外模块时,内核make命令需要注意两个变量:ARCH和CROSS_COMPILE,它们分别代表目标架构和交叉编译器。此外,必须在Makefile中指定目标体系架构的预构建内核的位置,在内核架构中称为KERNEL_SRC。
在使用诸如Yocto等构建系统时,Linux内核首先会作为依赖项进行交叉编译,然后才开始交叉编译模块。也就是说,我们故意使用变量KERNEL_SRC来表示预构建内核目录,因为这个变量是由Yocto为内核模块配方自动导出的。在module.bbclass类中,它被设置为STAGING_KERNEL_DIR的值,由所有内核模块配方继承。
也就是说,树外模块的本地编译和交叉编译的区别在于最终的make命令。下面是一个用于32位ARM架构的make命令示例:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf
而对于64位ARM架构,make命令如下:
make ARCH=aarch64 CROSS_COMPILE=aarch64-linux-gnu
上述命令假定交叉编译的内核源代码路径已经在Makefile中被指定。
树内构建需要处理一个额外的文件Kconfig,该文件允许在配置菜单中公开模块功能。也就是说,在内核树中构建模块之前,应该首先确定源文件应存放在哪个目录中。对于给定的文件名mychardev.c,由于其中包含专门的字符驱动程序源代码,应该将其移到内核源代码的drivers/char目录中(驱动程序中的每个子目录都有Makefile和Kconfig)。
请将以下内容添加到该目录的Kconfig中:
config PACKT_MYCDEV
tristate "Our packtpub special Character driver"
default m
help
Say Y here to support /dev/mycdev char device.
The /dev/mycdev is used to access packtpub.
在同一目录的Makefile中,添加如下一行命令:
obj-$(CONFIG_PACKT_MYCDEV) += mychardev.o
更新Makefile时要注意——.o后缀的文件名必须与.c后缀的文件名保持一致。如果源文件名是foobar.c,则必须在Makefile中使用foobar.o。要将模块构建为可加载的内核模块,请在arch/arm/configs目录下的defconfig板块添加如下一行:
CONFIG_PACKT_MYCDEV=m
你还可以从UI界面中选择执行menuconfig,然后执行make来构建内核,最后执行make modules来构建模块(包括你自己的模块)。要将驱动程序设为内置程序,只需要将m替换为y即可:
CONFIG_PACKT_MYCDEV=y
此处描述的所有内容都是嵌入式开发板制造商采取的方案,目的是为嵌入式开发板提供板级支持包(Board Support Package,BSP),其内核已包含嵌入式开发板制造商自定义的驱动程序,如图2.1所示。
图2.1 内核源代码树中的Packt_dev模块
配置完成后,就可以使用make命令来构建内核,并使用make modules命令来构建模块。
内核源代码树中包含的模块被安装在/lib/modules/$(unale-r)/kernel/中,在Linux系统中则被安装在/lib/modules/$(uname -r)/kernel/中。
既然已经熟悉了内核模块的树外构建和树内构建,下面让我们看看如何通过向模块传递参数来处理模块行为的自适应问题。
与用户程序类似,内核模块可以接受来自命令行的参数,这使得我们能够通过给定参数来动态地更改模块的行为,这可以帮助开发人员避免在测试/调试期间不停地更改/编译模块。为了对模块进行设置,首先需要声明一个变量来保存命令行的参数值,并对每个变量使用module_param()宏。该宏在include/linux/moduleparam.h头文件中的定义如下(代码中还应该包含语句#include <linux/moduleparam.h>):
module_param(name, type, perm);
该宏包含以下元素。
●name:参数的变量名称。
●type:参数的类型(bool、charp、byte、short、ushort、int、uint、long和ulong),其中charp代表字符指针。
●perm:表示/sys/module/<module>/parameters/<param>文件权限,其中包括S_ IWUSR、S_IRUSR、S_IXUSR、S_IRGRP、S_WGRP和S_IRUGO,解释如下。
❏S_I只是一个前缀。
❏R=读取,W=写入,X=执行。
❏USR=用户,GRP=组,UGO=用户、组和其他。
可以使用|(OR操作)设置多个权限。如果perm为0,则不会在sysfs的文件参数中创建该参数。强烈建议只使用S_IRUGO只读参数;通过将OR运算符与其他属性相结合,可以获得细粒度的属性。
在使用模块参数时,可以针对不同的参数使用MODULE_PARM_DESC宏进行描述。该宏将填充每个参数描述的模块信息部分。以下是本书代码库随附的helloworldparams.c源文件中的示例:
#include <linux/moduleparam.h>
[...]
static char *mystr = "hello";
static int myint = 1;
static int myarr[3] = {0, 1, 2};
module_param(myint, int, S_IRUGO);
module_param(mystr, charp, S_IRUGO);
module_param_array(myarr, int,NULL, S_IWUSR|S_IRUSR);
MODULE_PARM_DESC(myint,"this is my int variable");
MODULE_PARM_DESC(mystr,"this is my char pointer variable");
MODULE_PARM_DESC(myarr,"this is my array of int");
static int foo()
{
pr_info("mystring is a string: %s\n",
mystr);
pr_info("Array elements: %d\t%d\t%d",
myarr[0],myarr[1], myarr[2]);
return myint;
}
要加载模块并为其提供参数,可以执行以下操作:
$ insmod hellomodule-params.ko mystring="packtpub" myint=15 myArray=1,2,3
也就是说,在加载模块之前,可以使用modinfo来显示模块支持的参数描述:
$ modinfo ./helloworld-params.ko filename: /home/jma/work/tutos/sources/helloworld/./ helloworld-params.ko license: GPL author: John Madieu <john.madieu@gmail.com> srcversion: BBF43E098EAB5D2E2DD78C0 depends: vermagic: 4.4.0-93-generic SMP mod_unload modversions parm: myint:this is my int variable (int) parm: mystr:this is my char pointer variable (charp) parm: myarr:this is my array of int (array of int)
也可以在sysfs的sys/module/<name>/parameters目录中查找和编辑已加载模块当前的参数值。在该目录中,每个参数对应一个文件,其中包含该参数的值。如果对应文件有写入的权限(取决于模块代码),则可以更改这些参数的值。
以下是一个示例:
$ echo 0 > /sys/module/usbcore/parameters/authorized_default
不仅可加载的内核模块可以接收参数,只要一个模块已经在内核中构建,就可以从Linux内核命令行(由引导加载程序传递或由CONFIG_CMDLINE配置选项提供的命令行)为该模块指定参数。形式如下:
[initial command line ...] my_module.param=value
在上面的例子中,my_module对应模块名称,value则是分配给该参数的值。
现在我们已经能够处理模块参数了,下面让我们更深入地探讨一些较为复杂的情境,学习Linux内核及其构建系统如何处理模块的依赖关系。
内核模块只能调用数量有限的内核函数。要使函数和变量对内核模块可见,就必须由内核显式地导出。因此,Linux内核提供了以下两个宏,用于导出函数和变量。
●EXPORT_SYMBOL(symbolname):该宏将函数或变量导出到所有模块中。
●EXPORT_SYMBOL_GPL(symbolname):该宏仅将函数或变量导出到GPL模块中。
EXPORT_SYMBOL()宏及其对应的GPL版本是Linux内核宏,它们可使符号用于可加载的内核模块或者动态加载模块(前提是这些模块添加了extern声明,也就是包含了与导出符号编译单元对应的头文件)。EXPORT_SYMBOL()宏指示Kbuild机制将作为参数传递的符号包含在内核符号的全局列表中。因此,内核模块可以访问它们。当然,内置于内核本身的代码(与可加载的内核模块相反)可以通过extern声明访问任何非静态符号,就像传统的C语言代码一样。
这两个宏还允许我们从可加载的内核模块中导出符号,这些符号也可以被其他可加载的内核模块访问。有趣的是,一个模块导出的符号可以被依赖于这个模块的其他模块访问!正常的驱动程序不应该需要任何非导出函数。
当模块B使用模块A导出的一个或多个符号时,就意味着模块B是模块A的依赖性模块。接下来介绍如何在Linux内核基础设施中处理此类依赖关系。
depmod实用程序是在内核构建过程中运行的工具,用于生成模块依赖文件。它通过读取/lib/modules/<kernel_release>/中的每个模块来确定应该导出哪些符号以及需要哪些符号。该过程的结果被写入modules.dep文件及其二进制版本modules.dep.bin中。modules.dep是一种模块索引。
要使模块可操作,需要将其加载到Linux内核中。开发过程中的首选方法是使用insmod命令,并将模块路径作为参数传递。或者使用modprobe命令进行加载,这是一个简便的命令,更适合在实际生产环境中使用。
手动加载需要用户干预,但用户应具有root访问权限。实现该操作的两个经典命令是modprobe和insmod。
在开发过程中,通常使用insmod命令来加载模块。将模块路径传递给insmod命令,如下所示:
insmod /path/to/mydrv.ko
这是模块加载的低级形式,也是其他模块加载方法的基础,本书将使用这种模块加载方法。modprobe命令通常由系统管理员执行或在生产类系统中使用。modprobe命令很好用,它会解析我们之前讨论过的modules.dep文件,以便在加载给定模块前优先加载依赖项。它能够自动处理模块的依赖关系,就像软件包管理器一样。modprode命令的用法如下:
modprobe mydrv
是否可以使用modprobe命令加载模块,取决于depmod实用程序是否知道模块的安装路径和依赖关系。
depmod实用程序不仅可以构建文件modules.dep和modules.dep.bin,而且还有其他更多的功能。当内核开发人员编写驱动程序时,他们确切地知道驱动程序将支持哪些硬件,他们需要负责为所有受驱动程序支持的设备提供产品ID和供应商ID。depmod实用程序还能够处理模块文件以提取和收集这些信息,并生成一个modules.alias文件,位于/lib/modules/<kernel_ release>/中,用于将设备映射到相应的驱动程序。
以下是modules.alias文件中的一段内容:
alias usb:v0403pFF1Cd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio alias usb:v0403pFF18d*dc*dsc*dp*ic*isc*ip*in* ftdi_sio alias usb:v0403pDAFFd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio alias usb:v0403pDAFEd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio alias usb:v0403pDAFDd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio alias usb:v0403pDAFCd*dc*dsc*dp*ic*isc*ip*in* ftdi_sio [...]
在这一步,你将需要一个用户空间的hotplug代理(或设备管理器),通常是udev(或mdev),将它注册到内核中以便在新设备出现时获取通知。
通知由内核来完成,内核将设备的描述信息(产品ID、供应商ID、类、设备类、设备子类、接口和任何其他可以识别设备的信息)发送给hotplug守护进程,hotplug守护进程则用这些信息调用modprobe命令。然后,modprobe命令解析modules.alias文件,以匹配与设备相关联的驱动程序。在加载模块之前,modprobe命令将在module.dep中查找模块的依赖项。如果找到了,则先加载这些依赖项;否则,该模块将直接被加载。
另一种在系统启动时自动加载模块的方法,是在/etc/modules-load.d/ <filename>.conf中实现的。如果希望在系统启动时加载某些模块,只需要创建一个/etc/modules-load.d/<filename>.conf文件,并添加所要加载模块的名称即可,每行一个。你可以自定义文件名<filename>,不过我们通常使用的模块配置文件名是/etc/modules-load.d/mymodules.conf。你也可以根据需要创建多个.conf文件。
/etc/modules-load.d/mymodules.conf示例如下:
uio iwlwifi
如果你的机器使用systemd作为初始化管理器,这些配置文件将通过systemd-modules-load.service进行处理。在SysVinit系统中,这些配置文件由/etc/init.d/kmod脚本进行处理。
卸载模块的常用命令是rmmod,它可以卸载由insmod命令加载的模块。rmmod命令要求传递想要卸载的模块名称作为参数:
rmmod -f mymodule
而更智能的模块卸载方式是使用高级命令modeprobe -r,它会自动卸载未使用的依赖模块:
modeprobe -r mymodule
你可能已经猜到,这对开发人员来说更加便捷。最后,可以使用如下lsmod命令来检查一个模块是否已加载:
$ lsmod Module Size Used by btrfs 1327104 0 blake2b_generic 20480 0 xor 24576 1 btrfs raid6_pq 114688 1 btrfs ufs 81920 0 [...]
以上输出包括模块的名称、模块使用的内存量、使用该模块的其他模块的数量,以及这些模块的名称。lsmod命令会输出一个格式非常工整的模块加载列表,你可以在/proc/ modules目录下看到如下内容:
$ cat /proc/modules btrfs 1327104 0 - Live 0x0000000000000000 blake2b_generic 20480 0 - Live 0x0000000000000000 xor 24576 1 btrfs, Live 0x0000000000000000 raid6_pq 114688 1 btrfs, Live 0x0000000000000000 ufs 81920 0 - Live 0x0000000000000000 qnx4 16384 0 - Live 0x0000000000000000
上面的输出较为原始,格式混乱。因此,最好使用lsmod命令。
现在我们已经熟悉了内核模块的管理,接下来通过学习一些内核开发人员经常采用的编程技巧来扩展内核开发技能。
Linux内核开发是在前人经验的基础上学习,而不是重复“造轮子”。进行Linux内核开发需要遵循一套规则,笔者从中挑选了自认为最相关的两个规则——错误处理和消息打印,它们在编写用户空间程序时可能会发生变化。
在用户空间中,用main()方法退出就足以恢复可能发生的所有错误。但在内核中,由于直接涉及硬件,情况大不相同。消息打印的情况也有所不同,你将在本节中看到这一点。
返回与产生的错误不符的报错码,可能导致内核或用户空间程序解释错误,并做出错误决策,从而产生不必要的行为。为了保持清晰,内核树中预定义的错误几乎可以涵盖你可能遇到的所有情况。其中一些错误(及其含义)定义在include/uapi/ asm-generic/errno-base.h中,错误列表的剩余部分可以在include/uapi/asm-generic/ errno.h中找到。以下摘录的错误列表,来自include/uapi/asm-generic/errno-base.h:
#define EPERM 1 /* Operation not permitted */ #define ENOENT 2 /* No such file or directory */ #define ESRCH 3 /* No such process */ #define EINTR 4 /* Interrupted system call */ #define EIO 5 /* I/O error */ #define ENXIO 6 /* No such device or address */ #define E2BIG 7 /* Argument list too long */ #define ENOEXEC 8 /* Exec format error */ #define EBADF 9 /* Bad file number */ #define ECHILD 10 /* No child processes */ #define EAGAIN 11 /* Try again */ #define ENOMEM 12 /* Out of memory */ #define EACCES 13 /* Permission denied */ #define EFAULT 14 /* Bad address */ #define ENOTBLK 15 /* Block device required */ #define EBUSY 16 /* Device or resource busy */ #define EEXIST 17 /* File exists */ #define EXDEV 18 /* Cross-device link */ #define ENODEV 19 /* No such device */ [...]
大多数情况下,返回错误的标准形式是return -ERROR,特别是在响应系统调用时。例如,对于I/O错误,报错码是EIO,此时应该使用return -EIO,如下所示:
dev=init(&ptr);
if(!dev)
return –EIO
错误有时会跨越内核空间,并传播到用户空间。如果返回的错误是对系统调用(open、read、ioctl或mmap)的响应,那么错误值将被自动分配给用户空间的errno全局变量。在该变量上使用strerror(errno),可将错误转换为一个可读的字符串:
#include <errno.h> /* to access errno global variable */
#include <string.h>
[...]
if(wite(fd, buf, 1) < 0) {
printf("something gone wrong! %s\n", strerror(errno));
}
[...]
当遇到错误时,必须撤销所有在发生错误之前设置的内容。常用的做法是使用goto语句:
ret = 0;
ptr = kmalloc(sizeof(device_t));
if(!ptr) {
ret = -ENOMEM
goto err_alloc;
}
dev = init(&ptr);
if(!dev) {
ret = -EIO
goto err_init;
}
return 0;
err_init:
free(ptr);
err_alloc:
return ret;
使用goto语句的原因很简单。在处理错误时,假设在第5步需要清除之前的操作,与其进行如下多次的嵌套检查[可读性差、容易出错且令人困惑(可读性还取决于缩进)]:
if (ops1() != ERR) {
if (ops2() != ERR) {
if (ops3() != ERR) {
if (ops4() != ERR) {
不如通过使用goto语句,进行直接的流程控制:
if (ops1() == ERR)
goto error1;
if (ops2() == ERR)
goto error2;
if (ops3() == ERR)
goto error3;
if (ops4() == ERR)
goto error4;
error5:
[...]
error4:
[...]
error3:
[...]
error2:
[...]
error1:
[...]
因此,你应该只使用goto语句在函数中向前移动,而不是向后移动,也不要像在汇编程序中那样进行循环操作。
通常在应用中,当应该返回指针的函数发生错误时,该函数经常返回的是空指针NULL。这是一种有效却毫无意义的方式,因为我们实际并不知道返回的这个空指针意味着什么。为此,内核提供了3个宏——ERR_PTR、IS_ERR和PTR_ERR,定义如下:
void *ERR_PTR(long error); long IS_ERR(const void *ptr); long PTR_ERR(const void *ptr);
上面的第一个宏将错误值作为指针返回,它可以看作指针宏的错误值。假设有一个函数在内存分配失败后返回-ENOMEM,接下来需要做的就是执行诸如return ERR_ PTR(-ENOMEM)的操作。第二个宏用if(IS_ERR(foo))来检查返回值是否为指针错误。最后一个宏返回实际的错误码,也就是返回PTR_ERR(foo),它可以看作一个指针,指向错误值的宏。
以下是使用ERR_PTR、IS_ERR和PTR_ERR的示例:
static struct iio_dev *indiodev_setup(){
[...]
struct iio_dev *indio_dev;
indio_dev = devm_iio_device_alloc(&data->client->dev, sizeof(data));
if (!indio_dev)
return ERR_PTR(-ENOMEM);
[...]
return indio_dev;
}
static int foo_probe([...]){
[...]
struct iio_dev *my_indio_dev = indiodev_setup();
if (IS_ERR(my_indio_dev))
return PTR_ERR(data->acc_indio_dev);
[...]
}
这是错误处理的一个额外要点,也是内核编码风格的一部分。该风格规定,如果函数的名称是一个动作或命令,那么该函数应返回整型错误码。然而,如果函数的名称是一个谓词,那么该函数应返回一个布尔值,以表示操作的成功状态。
例如,add work是一条命令,add_work()函数在成功时返回0,失败时返回-EBUSY。而PCI device present是一个谓词,那么如前所述,pci_dev_present()函数在成功找到匹配的设备时返回1,否则返回0。
除了告知用户正在发生什么,打印消息是首要的调试技术。printk()之于内核就像printf()之于用户空间。长期以来,printk()以分级的方式控制内核消息的打印。编写的消息可以使用dmesg命令显示。根据要打印消息的重要性,printk()允许你在include/linux/kern_levels.h中定义的8个日志级别消息之间进行选择,并理解其含义。
如今,虽然printk()仍然是低级别的消息打印API,但printk()和日志等级的结对已经被编码为有明确命名的辅助函数,推荐在新的驱动程序中使用,它们如下所示。
●pr_<level>(…):此函数用于常规模块,而非设备驱动程序。
●dev_<level>(struct device *dev, …):此函数用于非网络设备的设备驱动程序(也称为netdev驱动程序)。
●netdev_<level>(struct net_device *dev, …):此函数仅用于netdev驱动程序。
在所有这些辅助函数中,<level>对应每个日志级别的编码,分别以相应的含义命名,如表2.1所示。
表2.1 Linux内核打印API
日志级别的工作方式是,每打印一条消息,内核就会将消息的日志级别与当前控制台的日志级别进行比较;如果前者更高(对应较低的值),就将消息立即打印到控制台。你可以使用以下方法检查日志级别参数:
cat /proc/sys/kernel/printk 4 4 1 7
在上述输出中,第一个值是当前的日志级别(4)。根据这个值,任何具有更高重要性(较低的日志级别)的消息都将一并显示在控制台中。根据CONFIG_ DEFAULT_MESSAGE_LOGLEVEL选项可知,第二个值是默认的日志级别。其他值与本章内容无关,因此可以暂时忽略。
当前日志级别可以通过以下方式更改:
echo <level> > /proc/sys/kernel/printk
此外,可以通过定义pr_fmt宏,在模块输出消息前添加自定义字符串前缀。可以使用模块名称定义此消息前缀,如下所示:
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
为了得到更简洁的日志输出,有些覆盖会使用当前函数名作为前缀,如下所示:
#define pr_fmt(fmt) "%s: " fmt, __func__
查看内核源代码树中的net/bluetooth/lib.c文件,可在第一行发现以下内容:
#define pr_fmt(fmt) "Bluetooth: " fmt
使用该行代码,任何pr_<level>(在常规模块中,而非设备驱动程序中)日志调用都会生成一个以Bluetooth:为前缀的日志,类似于以下内容:
$ dmesg | grep Bluetooth [ 3.294445] Bluetooth: Core ver 2.22 [ 3.294458] Bluetooth: HCI device and connection manager initialized [ 3.294460] Bluetooth: HCI socket layer initialized [ 3.294462] Bluetooth: L2CAP socket layer initialized [ 3.294465] Bluetooth: SCO socket layer initialized [...]
以上就是关于消息打印的全部内容,其中涵盖了如何根据情况选择和使用适当的打印API。
内核模块入门系列的介绍已经结束。现在你应该能够下载、配置和(交叉)编译Linux内核,以及编写和构建针对Linux内核的内核模块了。
注意
printk()(或其编码的辅助函数)从不阻塞,并且即使在原子上下文中被调用也足够安全。它会尝试锁定控制台并打印消息。如果锁定失败,则输出被写入缓冲区,函数将返回且不会阻塞。当前的控制台持有者将收到有关新消息的通知,并在释放控制台之前打印这些消息。
本章介绍了驱动程序开发的基础知识,解释了内置模块和可加载的内核模块的概念,以及它们的加载和卸载方法。即使无法与用户空间交互,你也可以编写工作模块、打印格式化的消息并了解init/exit的概念。
第3章将介绍处理内核的核心辅助函数,第3章与本章一起组成了Linux内核开发的“瑞士军刀”。在第3章中,你将以更强的功能为目标,对系统进行更多花式操作,并与Linux内核的核心进行交互。