2.7 Linux模块机制

1、模块插入

模块的格式。驱动模块在2.6内核下编译出来是.ko文件,.ko文件也是elf格式的。elf格式的文件有3种类型:可重定位文件obj、可执行文件、共享库文件。需要注意的是,用户态编译出来的应用程序是可执行类型的elf,而.ko文件是可重定位类型的elf。

1

仅从文件格式上就可以看到,模块文件的加载和用户态可执行文件的动态加载机制是不一样的。

1.1、外部模块插入工具Modprobe

加载外部模块的方法有两种。第一种使用insmod命令手工把它插入到内核。另一个更智能的方法是用modeprobe命令进行加载。
modprobe 也可以处理模块之间的依赖关系;如果所需加载的模块需要其他模块,modprobe 会将它们一并加载。前提是模块被安装之后已经运行了 depmod -a 命令,生成了/lib/modules/uname -r目录下的modules.dep文件中的模块列表,这个文件中有的模块modprobe会正确加载,否则就会出错。
Modprobe另外一个好处是,可以根据抽象的名字,比如功能名而不是模块名来加载模块。另外modprobe在插入和安装模块时,可以执行用户额外的自定义动作。这些都是在modprobe的配置文件/etc/modprobe.conf中定义的。
模块相关的工具是由modutils软件包提供的,modutils-2.4.27提供的工具有:insmod、rmmod、lsmod、modprobe、depmod、modinfo、kallsyms、kerneld。

1.1.1、depmod

常用的命令是“depmod –a”,该命令扫描/lib/modules/uname -r目录下所有的模块文件,生成modules.dep文件以供modprobe命令使用。
该命令还生成modules.alias、modules.pcimap等等一系列modules.*文件,以供udev机制自动插入模块使用。

2

1.1.2、/etc/modprobe.conf

/etc/modprobe.conf是modprobe命令的配置文件,常常还包括/etc/modprobe.d和/etc/modprobe.conf.local文件。其定义了模块的别名、选项、和自定义动作,基本定义格式为:

  • alias wildcard modulename:给模块名指定一个别名。
  • options modulename option…:指定模块插入时,可以带入的参数项。
  • install modulename command…:定义模块插入时的自定义动作。
  • remove modulename command… :定义模块卸载时的自定义动作。

详细的modprobe.conf的使用说明,请参考“man modprobe.conf”。具体的例子请参考/etc/modprobe.conf文件。

1.2、启动脚本脚本插入外部模块

当编译内核时,使用“make modules”命令编译外部模块,然后将其拷贝到文件系统的“/lib/modules/uname-r/”目录,但是这些ko文件是在什么时间被insmod或者modprobe插入的呢?答案是:一部分是由文件系统启动脚本插入的,一部分是由udev根据设备的存在与否自动插入的。

3

1.2.1、Linux启动顺序

研究linux启动时外部模块的插入时机,因为大部分模块的插入是由文件系统的启动脚本来完成的,所以需要研究linux启动脚本的架构。
Linux在内核态启动完成后,调用用户态的“init”程序开始布置整个用户态的应用环境,init在随后根据配置文件调用文件系统中的初始化脚本。在这里,唯一可以肯定的是任何linux发行版本第一个应用程序都是会去调用init程序,且init程序解析配置文件的方法都是一致的。而关于启动脚本的组织形式和风格,在多个发行版本之间是各不相同、多种多样的。
所以如果需要修改启动脚本以用来加入一些自定义的模块,需要先理解该linux文件系统的脚本架构。
Linux脚本的启动顺序大概如下图所示:

4

1.2.2、/sbin/init

Linux下为什么会要有个init?用过windows 9.x的人应该知道有个批处理文件autoexec.bat,用过windows NT/2000系统的人应该在控制面板中见过system service工具,它们的目的是相同的。只是比较起来windows下的这些东西功能太弱(当然用法也更简单)。
init是Linux启动的最后一步,它帮助用户完成每次启动系统都必须完成的一些重复性任务,如加载文件系统、各类网络服务等等程序;它还有一个重要用途,让用户自定义系统运行环境,只启动需要的进程,关闭不用的进程,释放内存和处理器资源,让系统运行得更快更稳。
常见的init用户程序有两种:一种完整版的init程序sysvinit,sysvinit软件包提供了一系列开关机的命令,常见的有:hutdown、reboot、halt、poweroff、telinit、init。它们都可以达到关机或重启的目的,但是每个命令的工作流程并不一样。
另一种是busybox提供的精简版init :

5

init会按任务表执行我们下的命令,这个任务表就是/etc/inittab文件。下面查看一个inittab文件的例子:

6

“/etc/inittab”文件每一行定义一个指令,其基本格式为:“id:runlevels:action:command”。各字段的详解如下:

  • id:是任意一个名称(具体是什么并不重要);
  • runlevels:是一个数字串(代表运行等级);

一条命令可以是一个等级下执行,也可以是多个等级下执行,例如:“1:2345:respawn:/sbin/getty 38400 tty1 “,该命令在等级2345下都会被执行。
根据Linux的定义,Init 可以启动到8个不同的运行级别上:0-6 和 S 或 s。运行级别可以由超级用户通过 telinit 命令来转换,此命令可以将转换信号传递给 init,告诉它切换到哪个运行级别。运行级别0,1,和 6为系统保留的专用运行级别。运行级别 0 用来关机,运行级别 6 用来重启,运行级别 1 用来使计算机进入单用户模式。运行级别 S 不是给我们直接使用的,更多是为进入运行级别 1 时运行某些可执行脚本时被调用。下面是几个运行级的简单介绍:

# 0 - 关机(千万不要把initdefault 设置为0 )
# 1 - 单用户模式
# 2 - 多用户,但是没有 NFS
# 3 - 完全多用户模式
# 4 - 没有用到
# 5 - X11
# 6 - 重启(千万不要把initdefault 设置为6 )
  • action:描述何时执行命令;

action,告诉init执行的动作,即如何处理process字段指定的进程。action字段允许的值及对应的动作分别为:

1)respawn:如果process字段指定的进程没有运行,则启动该进程,init不等待处理结束,而是继续扫描inittab文件中的后续进程,当这样的进程终止时,init会重新启动它,如果这样的进程已经运行,则什么也不做。
2wait:启动process字段指定的进程,并等到处理结束才去处理inittab中的下一记录项。
3)once:启动process字段指定的进程,不等待处理结束就去处理下一记录项。当这样的进程终止时,也不再重新启动它,在进入新的运行级别时,如果这样的进程仍在运行,init也不重新启动它。
4)boot:只有在系统启动时,init才处理这样的记录项,启动相应进程,并不等待处理结束就去处理下一个记录项。当这样的进程终止时,系统也不重启它。
5)bootwait:系统启动后,当第一次从单用户模式进入多用户模式时处理这样的记录项,init启动这样的进程,并且等待它的处理结束,然后再进行下一个记录项的处理,当这样的进程终止时,系统也不重启它。
6)powerfail:当init接到断电的信号(SIGPWR)时,处理指定的进程。
7)powerwait:当init接到断电的信号(SIGPWR)时,处理指定的进程,并且等到处理结束才去检查其他的记录项。
8)off:如果指定的进程正在运行,init就给它发SIGTERM警告信号,在向它发出信号SIGKILL强制其结束之前等待5秒,如果这样的进程不存在,则忽略这一项。
9)ondemand:功能通respawn,不同的是,与具体的运行级别无关,只用于runlevel字段是a、b、c的那些记录项。
10)sysinit:指定的进程在访问控制台之前执行,这样的记录项仅用于对某些设备的初始化,目的是为了使init在这样的设备上向用户提问有关运行级别的问题,init需要等待进程运行结束后才继续。
11)initdefault:指定一个默认的运行级别,只有当init一开始被调用时才扫描这一项,如果rstate字段指定了多个运行级别,其中最大的数字是默认的运行级别,如果runlevel字段是空的,init认为字段是0123456,于是进入级别6,这样便陷入了一个循环,如果inittab文件中没有包含initdefault的记录项,则在系统启动时请求用户为它指定一个初始运行级别。
  • command:指定执行的实际命令。该字段中进程可以是任意的守候进程、可执行脚本或程序,后面可以带参数。

Inittab是所有启动脚本的总入口,各种版本linux的启动脚本组织风格虽然不同,从这里都能找到它的入口。

1.2.3、/etc/rc.d

虽然不同linux版本启动脚本的组织风格是不一样,你也可以自定义出一套风格来,但是普遍来说/etc/rc.d/rcN.d是一种最常见的风格。
如在/etc/inittab文件中,N运行级别调用/etc/rc.d/rc N的命令:

7

以运行级别5为例,init将执行配置文件inittab中的以下这行:l5:5:wait:/etc/rc.d/rc 5
这一行表示以5为参数运行/etc/rc.d/rc,/etc/rc.d/rc是一个Shell脚本,它接受5作为参数,去执行/etc/rc.d /rc5.d/目录下的所有的rc启动脚本,/etc/rc.d/rc5.d/目录中的这些启动脚本实际上都是一些链接文件,而不是真正的rc启动脚本,真正的rc启动脚本实际上都是放在/etc/rc.d/init.d/目录下。而这些rc启动脚本有着类似的用法,它们一般能接受start、stop、 restart、status等参数。
/etc/rc.d/rc5.d/中的rc启动脚本通常是K或S开头的链接文件,对于以以S开头的启动脚本,将以start参数来运行。而如果发现存在相应的脚本也存在K打头的链接,而且已经处于运行态了(以/var/lock/subsys/下的文件作为标志),则将首先以stop为参数停止这些已经启动了的守护进程,然后再重新运行。这样做是为了保证是当init改变运行级别时,所有相关的守护进程都将重启。

1.2.4、启动脚本举例

  • 例子系统1:(32bit编译服务器 32.27.155.2):

    8

  • 例子系统2:(R5 x86系统 32.1.12.119 :172.16.127.8):

    9

  • 例子系统3:(R5 mips系统 32.1.12.119 :172.16.127.80):

    10

1.3、内核请求插入外部模块

任何内核空间的代码在需要时都可以通过调用 kmod 程序来请求加载模块。Kmod机制提供了request_module函数来实现外部模块的加载,其实现在 kernel/kmod.c文件中定义。
request_module 程序运行了一个用户态程序(作为单独的进程,以非特权模式在用户空间内运行)来帮助它完成任务,再在用户态程序中再使用modprobe来帮助其加载外部模块。内核态运行用户态程序的方法是使用call_usermodehelper函数。
具体的实现可以参考kernel/kmod.c中的request_module函数代码。

11

1.4、udev插入外部模块

前面说过“depmod –a”命令会在/lib/modules/uname -r目录下生成modules.alias、modules.pcimap等等一系列modules.文件,以供udev机制自动插入模块使用。所以udev的基本原理也就是根据sysfs一些事件信息和modules.文件,自动调用modprobe来插入所需的模块。
这里不对udev的详细原理进行展开,下次在udev的文章里再详细的写。这里udev的自动加载模块可以参考这个:
和udev类似的,还有一个kerneld用户态程序,也可以实现模块的自动加载。kerneld需要和内核配合使用,即内核需要开启对该特性的支持才能使用。Kerneld由modutils软件包提供。

1.5、内部模块的初始化

在编译内核时,选择和内核编译成一起的模块称之为内部模块。内核模块的加载在init/main.c文件的do_initcalls函数中执行。
内部模块中使用module_init声明的模块初始化函数的地址,会统一链接到section “.initcall .init”中。do_initcalls函数就是循环读取section “.initcall .init”中的初始化函数指针,一条一条的执行内部模块初始化函数。

12

__initcall_start和__initcall_end是section “.initcall .init”起始和结束变量。在内核链接脚本arch/i386/kernel/vmlinux.lds.s中定义:

13

do_initcalls函数的调用关系:start_kernel() rest_init() init()  do_basic_setup()  do_initcalls()  do_initcalls()

2、模块的版本检查机制

模块机制的主要问题之一是版本依赖性。在我们运行若干定制模块时,如果针对每一个要使用的内核版本,都要重新编译每个模块,将是件非常痛苦的事情。如果运行的是以二进制形式发布的商业模块时,甚至连编译也是不可能的。幸运的是,内核开发者们找到的一个灵活的办法来处理版本问题。其思想是,只有内核提供的软件接口发生改变时,才会出现与新内核版本不兼容的问题。软件接口可以由函数原型以及函数调用所涉及的所有数据结构的确切定义来表示。最后,可以使用一个 CRC 算法*把所有关于软件接口的信息映射到一个单一的 32 位数值上去。
在插入模块的时候,会校验模块编译时所使用的内核接口函数名及其CRC值是否和现有系统内核的函数名及CRC相符,接口完全一致则可以插入,接口有变动则不予插入。
可见linux模块版本检验机制,本来是只要系统函数接口不变就可以在不同的内核版本下使用模块。但是现在的内核检查,除了函数接口,还需要校验内核版本和gcc版本:

14

2.1、版本检查符号表ksymtab

前面说,为了版本校验,不但要保存接口函数变量的符号,还需要保存其CRC值。其在内核代码中的实现是这样的:把需要做模块版本校验的符号,对内核来说就是需要提供给外部模块使用的函数或变量,使用EXPORT_SYMBOL(sym)宏声明,对于使用EXPORT_SYMBOL(sym) 宏声明的符号,链接器会把其集中到__ksymtab section中。
下面查看EXPORT_SYMBOL(sym) 声明的定义,在include/linux/module.h中,研究其实现原理:

15

EXPORT_SYMBOL(sym)的这一段代码,其实就是针对模块版本校验机制,生成了某个符号相应的3个段的值。段“_ksymtab”保存了符号的地址和名字,段“__ksymtab_strings”是段名字的实际存储位置,段“__kcrctab”保存了符号crc值的地址。
可见EXPORT_SYMBOL(sym)宏声明以后,符号的地址名字和CRC值都有了,已经符合版本校验的需要了。可还有一个疑问,符号实际的crc值是怎么生成的呢?宏中引用了一个外部变量“extern void *__crc_##sym attribute((weak));”,_crc##sym是怎么来的?
_crc##sym是在编译.o时候做的,查看内核的makefile,makefile.build文件定义了.o的生成规则:

16

而当CONFIG_MODVERSIONS=y 时, cmd_cc_o_c 会将file.c 编译成.tmp_file.o而不是file.o 。cmd_modversions 会检查.tmp_file.o 是否包含__ksymtab ,也就是说file.c 是否包含EXPORT_SYMBOL(xxx); 如果没有__ksymtab , cmd_modversions 会将.tmp_file.o 直接更名为file.o 。如果确实包含__ksymtab , cmd_modversions 会通过genksyms 产生xxxx ( export symbol )的符号签名( checksum ),然后调用linker 重新把这些符号以及这些符号的checksum链接进file.o。
可见符号的CRC值_crc##sym,是在编译.o时,由genksyms脚本生成的。

通过readelf命令可以,查看内核中关于版本检查的三个段“_ksymtab”、“__ksymtab_strings”、“__kcrctab”:

17

2.2、全局符号表kallsyms

内核还有一个名气更大的符号表kallsyms,不过这个符号表和模块版本检查机制无关。Kallsyms是内核所有符号的符号表,其包含“nm vmlinux”列出的所有内核符号,符号表规模比ksymtab要大的多,但是其不包含符号的crc值。Kallsyms的作用是内核oops时打印出的跟踪消息内,可以解析出符号。
新内核已经将kallsyms编译进内核,使用命令“cat /proc/kallsyms”可以查看。老的内核kallsyms存放在/boot/System.map文件当中,供需要时使用。
生成kallsyms的方法是在内核源文件根目录的makefile中定义的:

18

scripts/kallsyms脚本根据“nm –n file”的结果,生成tmp_kallsyms%.S文件。tmp_kallsyms%.S中定义的就是kallsyms的相关数据:

这里写图片描述

这里就不详细说明这几个数据结构怎么组成的kallsyms符号表。符号表的基本元素就是符号名和符号地址,不过kallsyms的规模比较庞大,为了加速和压缩的一些操作,所以才弄出这6个结构来表述kallsyms符号表。最终kallsyms会被链接到vmlinux的.rodata section 中。

2.3、外部模块的符号

外部模块.ko是一个可重定位文件,模块在插入的时候是必须要和内核重定位的。该操作分为两部分:

  • 1、当前内核需要检查模块编译时,所链接版本接口函数的带crc的版本检查符号是否一致。所以ko文件中,必须提供模块链接内核的接口函数版本检查表。
  • 2、插入模块中使用EXPORT_SYMBOL(sym)声明的符号,还可以被别的模块调用。所以需要将模块本身中所带的ksymtab导入到内核全局ksymtab符号表中。

首先来说第一部分的原理,ko文件中提供的模块链接内核的接口函数版本检查表是怎么生成的。查看一个外部模块的编译过程:

20

可以看到,在模块编译的“Building modules, stage 2.”阶段,scripts/mod/modpost脚本根据模块引用的系统函数和内核vmlinux,生成一个包含本模块使用的所有系统函数的版本检查符号表。即在vmlinux的全局ksymtab中,抽出本模块的使用的部分,单独生成一个modname.mod.c文件,并将其连接进ko文件的section(“__versions”)。

21

第二部分,关于模块本身需要导出的ksymtab符号表,以供其他模块使用。模块的kallsyms符号表也是由“_ksymtab”、“__ksymtab_strings”、“__kcrctab”三个段联合提供。

22

内核在插入模块时,会给每个模块分配一个数据结构,保存模块的其他信息和模块本身需要导出的符号。使用“cat /proc/kallsyms”可以看到,模块的ksymtab和kallsyms都被导入到其中,而只能看到内核的kallsyms,这应该是内核符号和模块符号的差异。
另外外部模块非EXPORT_SYMBOL(sym)声明的符号是可以重名的,是不是这些符号无人引用所以可以重名?其会造成什么影响有待考证。

23

2.4、模块相关宏的实现

  • 1、MODULE_ALIAS、MODULE_LICENSE、MODULE_AUTHOR、MODULE_DESCRIPTION、MODULE_PARM_DESC。这些宏定义在include/linux/module.h,简单的在section(“.modinfo”)生成描述的字符串信息,使用modinfo命令,可以查看这些信息。

    24

  • 2、module_param、MODULE_PARM。宏定义在include/linux/moduleparam.h,基本意思就是在section (“__param”)生成一个声明的变量,并在section(“.modinfo”)生成描述信息。

    这里写图片描述

  • 3、MODULE_DEVICE_TABLE。定义在include/linux/module.h。声明一个外部变量如:类型为pci_device_id类型的__mod_pci_device_table别名为name;定义宏THIS_MODULE值为 (&__this_module)。具体作用不明。

26

  • 4、EXPORT_SYMBOL。定义在include/linux/module.h。其在前几节中有详细的讲解。

  • 5、module_init、module_exit。定义在include/linux/init.h。宏定义的含义如下图:

    27

3、参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值