深入理解linux内核--系统启动

史前时代:BIOS

计算机在加电的那一刻几乎是毫无用处的,因为RAM芯片中包含的是随机数据,此时还没有操作系统在运行。在开始启动时,有一个特殊的硬件电路在CPU的一个引脚上产生一个RESET逻辑值。在RESET产生以后,就把处理器的一些寄存器(包括cs和eip)设置成固定的值,并执行在物理地址0xfffffffO处找到的代码。硬件把这个地址映射到某个只读、持久的存储芯片中,该芯片通常称为ROM(Read-Only Memory,只读内存)。ROM中所存放的程序集在80x86体系中通常叫作基本输入/输出系统(Basic Input/Output System,BIOS),因为它包括几个中断驱动的低级过程。所有操作系统在启动时,都要通过这些过程对计算机硬件设备初始化。一些操作系统,如微软的MS-DOS,依赖于BIOS实现大部分系统调用。

Linux一旦进入保护模式(参见第二章“硬件中的分段”一节),就不再使用BIOS,而是为计算机上的每个硬件设备提供各自的设备驱动程序。实际上,因为BIOS过程必须在实模式下运行,所以即使有益,两者之间也不能共享函数。BIOS使用实模式的地址,因为在计算机加电启动时只有这些可以使用。一个实模式的地址由一个seg段和一个off偏移量组成。相应的物理地址可以这样计算:seg *16+off。
所以CPU寻址电路根本就不需要全局描述符表、局部描述符表或者页表把逻辑地址转换成物理地址。显然,对GDT、LDT和页表进行初始化的代码必须在实模式下运行。Linux在启动阶段必须使用BIOS,此时Linux必须要从磁盘或者其他外部设备中获取内核映像。BIOS启动过程实际上执行以下4个操作:

  1. 对计算机硬件执行一系列的测试,用来检测现在都有什么设备以及这些设备是否正常工作。
    这个阶段通常称为POST(Power-OnSelf-Test,上电自检)。在这个阶段中,会显示一些信息,例如BIOS版本号。如今的80x86、AMD64和Itanium计算机使用高级配置与开机界面(AdvancedConfiguration and Power Interface,ACPI)标准。在ACPI兼容的BIOS中,启动代码会建立几个表来描述当前系统中的硬件设备。这些表的格式独立于设备生产商,而且可由操作系统读取以获得如何调用这些设备的信息。
  2. 初始化硬件设备。这个阶段在现代基于PCI的体系结构中相当重要,因为它可以保证所有的硬件设备操作不会引起IRQ线与I/O端口的冲突。在本阶段的最后,会显示系统中所安装的所有PCI设备的一个列表。
  3. 搜索一个操作系统来启动。实际上,根据BIOS的设置,这个过程可能要试图访问(按照用户预定义的次序)系统中软盘、硬盘和CD-ROM的第一个扇区(引导扇区)。
  4. 只要找到一个有效的设备,就把第一个扇区的内容拷贝到RAM中从物理地址0x00007c00开始的位置,然后跳转到这个地址处,开始执行刚才装载进来的代码。本附录其余的部分会带你体验从最原始的开始状态到运行Linux系统的整个历程。

远古时代:引导装入程序

引导装入程序(boot loader)是由BIOS用来把操作系统的内核映像装载到RAM中所调用的一个程序。让我们简要地描绘一下引导装入程序在IBM的PC体系结构中是如何工作的。为了从软盘上启动,必须把第一个扇区中所存放的指令装载到RAM中并执行;这些指令再把包含内核映像的其他所有扇区都拷贝到RAM中。

从硬盘启动的实现有点不同。硬盘的第一个扇区称为主引导记录(Master Boot Record,MBR),该扇区中包括分区表(注1)和一个小程序,这个小程序用来装载被启动的操作系统所在分区的第一个扇区。

诸如Microsoft Windows 98之类的操作系统使用分区表中所包含的一个活动(active)标志来标识这个分区(注2)。按照这种方法,只有那些内核映像存放在活动分区中的操作系统才可以被启动。正如我们将在后面看到的一样,Linux的处理方式更加灵活,因为Linux使用一个巧妙的引导装入程序取代这个MBR中不完善的程序,它允许用户来选择要启动的操作系统。

Linux早期版本(一直到2.4系列)的内核映像,在第一个512字节有一个最小的引导装入程序,因此在第一扇区拷贝一个内核映像就可以使软盘可启动。但是在Linux 2.6中就不再有这样的引导装入程序,所以要从软盘启动,就必须在第一个磁盘扇区存放一个合适的引导装入程序。而现在从软盘启动与从硬盘或CD-ROM启动是十分相似的。

从磁盘启动Linux

从磁盘启动Linux内核需要一个两步的引导装入程序。在80x86体系中,众所周知的Linx 引导装入程序叫作LInux LOader(LILO)。确实还有一些80x86体系的引导装入程序,如广泛使用的GRand Unified Bootloader(GRUB)。GRUB比LILO更为先进,因为它可识别多个基于磁盘的文件系统,而且可以从文件中读入部分引导程序。当然,对于Linux支持的所有体系结构都有各自专门的引导装入程序。

LILO或许被装在MBR上(代替那个装载活动引导扇区的小程序),或许被装在每个磁盘分区的引导扇区上。在这两种情况下,最终的结果是相同的:装入程序在启动过程中被执行时,用户都可以选择装入哪个操作系统。实际上,LILO引导装入程序被分为两部分,因为不划分的话,它就太大而无法装进单个扇区。MBR或者分区引导扇区包括一个小的引导装入程序,由BIOS把这个小程序装入从地址0×00007c00开始的RAM中。这个小程序又把自己移到地址0x00096a00,建立实模式栈(0x00098000~0x000969ff),并把LILO的第二部分装入到从地址0x00096c00开始的RAM中。

第二部分又依次从磁盘读取可用操作系统的映射表,并提供给用户一个提示符,因此用户就可以从中选择一个操作系统。最后,用户选择了被装入的内核后(或经过一个延迟时间以使LILO选择一个缺省值)。引导装入程序就可以把相应分区的引导扇区拷贝到RAM中并执行它,或直接把内核映像拷贝到RAM中。假定Linux内核映像必须被导入,LILO引导装入程序依赖于BIOS例程,主要执行如下步骤:

  1. 调用一个BIOS过程显示“Loading”信息。
  2. 调用一个BIOS过程从磁盘装入内核映像的初始部分,即将内核映像的第一个512字节从地址0x00090000开始存入RAM中,而将setup()函数的代码(参见下面)从地址0x00090200开始存入RAM中。
  3. 调用一个BIOS过程从磁盘中装载其余的内核映像,并把内核映像放入从低地址0×00010000(适用于使用make zImage编译的小内核映像)或者从高地址0x00100000(适用于使用make bzImage编译的大内核映像)开始的RAM中。在以下的讨论中,我们将分别称内核映像是“低装载”到RAM中或者“高装载”到RAM中。大内核映像的支持虽然本质上与其他启动模式相同,但是它却把数据放在不同的物理内存地址,以避免在第二章“物理内存布局”一节所介绍的ISA黑洞问题。
  4. 跳转到setup()代码。

中世纪:setup()函数

setup()汇编语言函数的代码由链接程序放在内核映像文件的偏移量0x200处。引导装入程序因此就可以很容易地确定setup()代码的位置,并把它拷贝到从物理地址0x00090200 开始的RAM中。setup()函数必须初始化计算机中的硬件设备,并为内核程序的执行建立环境。

虽然BIOS已经初始化了大部分硬件设备,但是Linux并不依赖于BIOS,而是以自己的方式重新初始化设备以增强可移植性和健壮性。setup()本质上执行以下操作

  1. 在ACPI兼容的系统中,它调用一个BIOS例程,以在RAM中建立系统物理内存布局表(通过检索“IOS-e820”标签,就可在引导内核信息中看到该表)。在早期系统中,它调用BIOS例程,返回系统可用内存。
  2. 设置键盘重复延时和速率(当用户一直按下一个键超过一定的时间,键盘设备就反复地向CPU发送相应的键盘码)。
  3. 初始化视频卡。
  4. 重新初始化磁盘控制器并检测硬盘参数。
  5. 检查IBM微通道总线(MCA)。
  6. 检查PS/2指针设备(总线鼠标)。
  7. 检查对高级电源管理(APM)BIOS的支持。
  8. 如果BIOS支持增强磁盘驱动服务(Enhanced Disk Drive Service,EDD),它就调用相应的BIOS过程在RAM中建立系统可用硬盘表(表中的信息可以通过sysfs特殊文件系统的firmwareledd目录查看)。
  9. 如果内核映像被低装载到RAM中(在物理地址0x00010000处),就把它移动到物理地址0x00001000处。
    反之,如果内核映像被高装载到RAM中,就不用移动。这个步骤是必需的,因为为了能在软盘上存储内核映像并节省启动的时间,存放在磁盘上的内核映像都是压缩的,解压程序需要一些空闲空间作为临时缓冲区(在RAM中紧挨内核映像的地方)。
  10. 置位8042键盘控制器的A20引脚。A20引脚是在80286系统中引入的,为的是与古老的8088微处理器物理地址兼容。不幸的是,在切换到保护模式之前必须将A20引脚正确置位,否则,每个物理地址的第21位都会被CPU看作0。置位A20引脚是件讨厌的事情。
  11. 建立一个临时中断描述符表(IDT)和一个临时全局描述符表(GDT)。
  12. 如果需要,重置浮点单元(FPU)。
  13. 重新编写可编程中断控制器(Programmable Interrupt Controller,PIC),以屏蔽所有中断,但保留IRQ2,它是两个PIC之间的级联中断。
  14. 通过设置cr0状态寄存器中的PE位,把CPU从实模式切换到保护模式。cr0状态寄存器中的PG位被清0,因此分页还没有启用。
  15. 跳转到startup_32()汇编语言函数。

文艺复兴时期:startup_32()函数

有两个不同的startup_32()函数,我们此处所指的是在arch/i386/boot/compressed/head.S文件中实现的那一个。在setup()结束之后,startup_32()就已经被移动到物理地址0x00100000处或者0x00001000处,这取决于内核映像是被高装载到RAM中还是低装载到RAM中。该函数执行以下操作:

  1. 初始化段寄存器和一个临时堆栈。
  2. 清零eflags寄存器的所有位。
  3. 用0填充由_edata和_end符号标识的内核未初始化数据区(参见第二章的“物理内存布局”一节)。
  4. 调用decompress_kernel()函数来解压内核映像。首先显示“UncompressingLinux…”信息。完成内核映像的解压之后,显示“OK,booting the kernel.”信息。如果内核映像是低装载的,那么解压后的内核就被放在物理地址0x00100000处。否则,如果内核映像是高装载的,那么解压后的内核就被放在位于这个压缩映像之后的临时缓冲区中。然后,解压后的映像就被移动到从物理地址0x00100000开始的最终位置。
  5. 跳转到物理地址0x00100000处。
    解压的内核映像以包含在arch/i386/kernel/head.S中的另一个startup_32()函数开始。这两个函数使用相同的名字不会产生任何问题(除了使读者容易混淆外),因为这两个函数会跳转到自己的起始物理地址去执行。

第二个startup_32()函数为第一个Linux进程(进程0)建立执行环境。该函数执行以下操作:

  1. 把段寄存器初始化为最终值
  2. 把内核的bss段填充为0(参见第二十章“程序段和进程的内存区域”一节)。
  3. 初始化包含在swapper_pg_dir的临时内核页表,并初始化pg0,以使线性地址一致地映射同一物理地址,这在第二章“内核页表”一节已经作了说明。
  4. 把页全局目录的地址存放在cr3寄存器中,并通过设置cr0寄存器的PG位启用分页。
  5. 为进程0建立内核态堆栈(参见第三章的“内核线程”一节)。
  6. 该函数再一次清零eflags寄存器的所有位。
  7. 调用setup_idt()用空的中断处理程序填充IDT(参见第四章的“IDT的初步初始化”一节)。
  8. 把从BIOS中获得的系统参数和传递给操作系统的参数放入第一个页框中(参见第二章的“物理内存布局”一节)。
  9. 识别处理器的型号。
  10. 用GDT和IDT表的地址来填充gdt r和idt r寄存器。
  11. 跳转到start_kernel()函数。

现代:start_kernel()函数

start_kernel()函数完成Linux内核的初始化工作。几乎每天内核部件都是由这个函数进行初始化的,我们只提及其中的少部分:

  1. 调用sched_init()函数来初始化调度程序(参见第七章)。
  2. 调用build_all_zonelists()函数来初始化内存管理区(参见第八章“内存管理区”一节)。
  3. 调用page_alloc_init()函数来初始化伙伴系统分配程序(参见第八章“伙伴系统算法”一节)。
  4. 调用trap_init()函数(参见第四章“异常处理”一节)和init_IRQ()函数(参见第四章“IRQ数据结构”一节)以完成IDT初始化。
  5. 调用softirq_init()函数初始化TASKLET_SOFTIRQ和HI_SOFTIRQ(参见第四章“软中断”一节)。
  6. 调用time_init()函数来初始化系统日期和时间(参见第六章“Linux计时体系结构”一节)。
  7. 调用kmem_cache_init()函数来初始化slab分配器(参见第八章的“普通和专用高速缓存”一节)。
  8. 调用calibrate_delay()函数以确定CPU时钟的速度(参见第六章“延迟函数”一节)。
  9. 调用kernel_thread()函数为进程1创建内核线程。正如我们在第三章的“内核线程”一节中已经描述的一样,这个内核线程又会创建其他的内核线程并执行/sbin/init程序。在start_kernel()开始执行之后会显示“Linux version2.6.11…”信息,除此之外,在init程序和内核线程执行的最后阶段还会显示很多其他信息。最后,就会在控制台上出现熟悉的登录提示符(如果在启动时所启动的是XWindow系统,那么登录提示符就会出现在一个图形窗口中),通知用户Linux内核已经启动,现在正在运行。

模块

是否使用模块?

当系统程序员希望给Linux内核增加新功能时,就面临一个进退两难的问题:他们应该编写新代码从而将其作为一个模块进行编译,还是应该将这些代码静态地链接到内核中?通常,系统程序员都倾向于把新代码作为一个模块来实现。因为模块可以根据需要进行链接,这样内核就不会因为装载那些数以百计的很少使用的程序而变得非常庞大,这一点我们后面就会看到。几乎Linux内核的每个高层组件——文件系统、设备驱动程序、可执行格式、网络层等等——都可以作为模块进行编译。

Linux的发布版,充分使用模块方式全面地支持多种硬件设备。例如,发布版中会将几十种声卡驱动程序模块放在某个目录下,但是在某个计算机上只会有效加载其中一个声卡驱动程序。然而,有些Linux代码必须被静态链接,也就是说相应组件或者被包含在内核中,或者根本不被编译。典型情况下,这发生在组件需要对内核中静态链接的某个数据结构或函数进行修改时。例如,假设某个组件必须在进程描述符中引入新字段。链接一个模块并不能修改诸如task_struct之类已经定义的数据结构,因为即使这个模块使用其数据结构的修改版,所有静态链接的代码看到的仍是原来的版本,这样就很容易发生数据崩溃。对此问题的一种局部解决方法就是“静态地”把新字段加到进程描述符,从而让这个内核组件可以使用这些字段,而不用考虑组件究竟是如何被链接的。然而,如果该内核组件从未被使用,那么,在每个进程描述符中都复制这些额外的字段就是对内存的浪费。如果新内核组件对进程描述符的大小有很大的增加,那么,只有新内核组件被静态地链接到内核,才可能通过在这个数据结构中增加需要的字段获得较好的系统性能。

再例如,考虑一个内核组件,它要替换静态链接的代码。显然,这样的组件不能作为一个模块来编译,因为在链接模块时内核不能修改已经在RAM中的机器码。 例如,系统不可能链接一个改变页框分配方法的模块,因为伙伴系统函数总是被静态地链接到内核(注1)。内核有两个主要的任务来进行模块的管理。第一个任务是确保内核的其他部分可以访问该模块的全局符号,例如指向模块主函数的入口。模块还必须知道这些符号在内核及其他模块中的地址。因此,在链接模块时,一定要解决模块间的引用关系。

第二个任务是记录模块的使用情况,以便在其他模块或者内核的其他部分正在使用这个模块时,不能卸载这个模块。系统使用了一个简单的引用计数器来记录每个模块的引用次数。

模块许可证

Linux内核许可证(GPL,版本2)不限制用户与企业使用其源代码,但是它严格禁止在非GPL许可证下发行相关的源代码,而这些代码起源于或大部分起源于Linux代码。也就是说,内核开发者要确保他们的代码及其衍生代码可由所有用户自由使用。但是,模块对这一机制造成了威胁。可能有人只发行一个用于Linux内核的二进制格式模块;

例如,厂商可能只以二进制格式模块发行一个硬件驱动程序。现在,这种情况较为少见。理论上说,Linux内核的特性和功能可被只有二进制格式的模块极大地改变,从而把基于Linux的内核转变成商业产品。因此,Linux内核开发者社团不太接受只有二进制格式的模块。Linux模块的实现就反映出这一点。一般地,使用MODULE_LICENSE宏,每个模块开发者应当在模块源代码中标出许可证类型。

如果许可证是非GPL兼容(或根本没有标出),模块就不能使用内核的许多核心函数和数据结构。而且,使用非GPL许可证的模块会“玷污”内核,也就是说内核开发者不再考虑内核中可能的缺陷。

模块的实现

模块是作为ELF对象文件存放在文件系统中的,并通过执行insmod程序链接到内核中(参见后面的“模块的链接和取消”一节)。对于每个模块,系统都分配一个包含以下数据的内存区:

  1. 一个module对象
  2. 表示模块名的一个以nul1结束的字符串(所有的模块都必须有唯一的名字)
  3. 实现模块功能的代码

module对象描述一个模块,其字段如表B-1所示。一个双向循环列表存放所有module 对象。链表头部存放在modules变量中,而指向相邻单元的指针存放在每个module对象的list字段中。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
state字段记录模块内部状态,它可以是:MODULE_STATE_LIVE(模块为活动的)、MODULE_STATE_COMING(模块正在初始化)和MODULE_STATE_GOING(模块正在卸载)。正如我们在第十章的“动态地址检查:修正代码”一节中已经介绍的那样,每个模块都有自己的异常表。该表包括(如果有)模块的修正代码的地址。在链接模块时,该表被拷贝到RAM中,其开始地址保存在module对象的extable字段中。

模块使用计数器

每个模块都有一组使用计数器,每个CPU一个,存放在相应module对象的ref字段中。在模块功能所涉及的操作开始执行时递增这个计数器,在操作结束时递减这个计数器。只有所有使用计数器的和为0时,模块才可以被取消链接。

例如,假设MS-DOS文件系统层作为模块被编译,而且这个模块已经在运行时被链接。最开始时,该模块的引用计数器是0。如果用户装载一张MS-DOS软盘,那么模块引用计数器其中的一个就被递增1。反之,当用户卸载这张软盘时,计数器其中之一就被减1 (甚至不是刚才递增的那一个)。模块的总的引用计数器就是所有CPU计数器的总和。

导出符号

当链接一个模块时,必须用合适的地址替换在模块对象代码中引用的所有全局内核符号(变量和函数)。这个操作与在用户态编译程序时链接程序所执行的操作非常类似(参见第二十章的“库”一节),这是委托给insmod外部程序完成的(将在后面的“模块的链接和取消”一节进行介绍)。内核使用一些专门的内核符号表(kernel symbol table),用于保存模块访问的符号和相应的地址。
它们在内核代码段中分三个节:

  1. __kstrtab节(保存符号名)
  2. __ksymtab节(所有模块可使用的符号地址)
  3. __ksymtab_gpl节(GPL兼容许可证下发布的模块可以使用的符号地址)。

当用于静态链接内核代码内时,EXPORT_SYMBOL与EXPORT_SYMBOL_GPL 宏让C编译器分别往__ksymtab和__ksymtab_gpl部分相应地加入一个专用符号。只有某一现有的模块实际使用的内核符号才会保存在这个表中。如果系统程序员在某些模块中需要访问一个尚未导出的内核符号,那么他只要在Linux源代码中增加相应的EXPORT_SYMBOL_GPL宏就可以了。

当然,如果许可证不是GPL兼容的,他就不能为模块合法导出一个新符号。已链接的模块也可以导出自己的符号,这样其他模块就可以访问这些符号。模块符号部分表(module symbol table)保存在模块代码段的__ksymtab、__ksymtab_gpl和__kstrtab部分中。要从模块中导出符号的一个子集,程序员可以使用上面描述的EXPORT_SYMBOL和EXPORT_SYMBOL_GPL宏。当模块链接时,模块的导出符号被拷贝到两个内存数组中,而其地址保存在module对象的syms和gpl_syms字段中。

模块依赖

一个模块(B)可以引用由另一个模块(A)所导出的符号;在这种情况下,我们就说B 装载在A的上面,或者说A被B使用。为了链接模块B,必须首先链接模块A;否则,对于模块A所导出的那些符号的引用就不能适当地链接到B中。简而言之,在这两个模块之间存在着依赖(dependency)。

A模块对象的modules_which_use_me字段是一个依赖链表的头部,该链表保存A使用的所有模块。链表中的每个元素是一个小型module_use描述符,该描述符保存指向链表中相邻元素的指针及一个指向相应模块对象的指针。在本例中,指向B模块对象的module_use描述符将出现在A的modules_which_use_me链表中。只要有模块装载在A上,modules_which_use_me链表就必须动态更新。如果A的依赖链表非空,模块A 就不能卸载。当然,除了A和B之外,还会有其他模块©装载到B上,依此类推。模块的堆叠是对内核源代码进行模块化的一种有效方法,目的是为了加速内核的开发。

模块的链接和取消

用户可以通过执行insmod外部程序把一个模块链接到正在运行的内核中。该程序执行以下操作:

  1. 从命令行中读取要链接的模块名。
  2. 确定模块对象代码所在的文件在系统目录树中的位置。对应的文件通常都是在/lib/modules的某个子目录中。
  3. 从磁盘读入存有模块目标代码的文件。
  4. 调用init_module()系统调用,传入参数:存有模块目标代码的用户态缓冲区地址、目标代码长度和存有insmod程序所需参数的用户态内存区。
  5. 结束。

sys_init_module()服务例程是实际执行者,主要操作步骤如下:

  1. 检查是否允许用户链接模块(当前进程必须具有CAP_SYS_MODULE权能)。只要给内核增加功能,而它可以访问系统中的所有数据和进程,安全就是至关重要的。
  2. 为模块目标代码分配一个临时内存区,然后拷入作为系统调用第一个参数的用户态缓冲区数据。
  3. 验证内存区中的数据是否有效表示模块的ELF对象,如果不能,则返回错误码。
  4. 为传给insmod程序的参数分配一个内存区,并存入用户态缓冲区的数据,该缓冲区地址是系统调用传入的第三个参数。
  5. 查找modules链表,以验证模块未被链接。通过比较模块名(module对象的name字段)进行这一检查。
  6. 为模块核心可执行代码分配一个内存区,并存入模块相应节的内容。
  7. 为模块初始化代码分配一个内存区,并存入模块相应节的内容。
  8. 为新模块确定模块对象地址,对象映像保存在模块ELF文件的正文段gnu.linkonce.this_module一节,而模块对象保存在第6步中的内存区。
  9. 将第6和7步中分配的内存区地址存入模块对象的module_code和module_init字段。
  10. 初始化模块对象的modules_which_use_me链表。当前执行CPU的计数器设为1,而其余所有的模块引用计数器设为0。
  11. 根据模块对象许可证类型设定模块对象的license_gplok标志。
  12. 使用内核符号表与模块符号表,重置模块目标码。这意味着用相应的逻辑地址偏移量替换所有外部与全局符号的实例值。
  13. 初始化模块对象的syms和gpl_syms字段,使其指向模块导出的内存中符号表。
  14. 模块异常表(参见第十章“异常表”一节)保存在模块ELF文件的__ex_table一节,因此它在第6步中已拷入内存区,将其地址存入模块对象的extable字段。
  15. 解析insmod程序的参数,并相应地设定模块变量的值。
  16. 注册模块对象mkobj字段中的kobject对象,这样在sysfs特殊文件系统的module目录中就有一个新的子目录(参见第十三章“kobject”一节)。
  17. 释放第2步中分配的临时内存区。
  18. 将模块对象追加到modules链表。
  19. 将模块状态设为MODULE_STATE_COMING。
  20. 如果模块对象的init方法已定义,则执行它。
  21. 将模块状态设为MODULE_STATE_LIVE。
  22. 结束并返回0(成功)。

为了取消模块的链接,用户需要调用rmmod外部程序,该程序执行以下操作:

  1. 从命令行中读取要取消的模块的名字。
  2. 打开/proc/modules文件,其中列出了所有链接到内核的模块,检查待取消模块是否有效链接。
  3. 调用delete_module()系统调用,向其传递要卸载的模块名。
  4. 结束。

相应的sys_delete_module()服务例程执行以下操作:

  1. 检查是否允许用户取消模块链接(当前进程必须具有CAP_SYS_MODULE权能)。
  2. 将模块名存入内核缓冲区。
  3. 从modules链表查找模块的module对象。
  4. 检查模块的modules_which_use_me依赖链表,如果非空就返回一个错误码。
  5. 检查模块状态,如果不是MODULE_STATE_LIVE,就返回错误码。
  6. 如果模块有自定义init方法,函数就要检查是否有自定义exit方法。如果没有自定义exit方法,模块就不能卸载,那么返回一个退出码。
  7. 为了避免竞争条件,除了运行sys_delete_module()服务例程的CPU外,暂停系统中所有CPU的运行。
  8. 把模块状态设为MODULE_STATE_GOING。
  9. 如果所有模块引用计数器的累加值大于0,就返回错误码。
  10. 如果已定义模块的exit方法,则执行它。
  11. 从modules链表删除模块对象,并且从sysfs特殊文件系统注销该模块。
  12. 从刚才使用的模块依赖链表中删除模块对象。
  13. 释放相应内存区,其中存有模块可执行代码、module对象及有关符号和异常表。
  14. 返回0(成功)。

根据需要链接模块

模块可以在系统需要其所提供的功能时自动进行链接,之后也可以自动删除。例如,假设MS-DOS文件系统既没有被静态链接,也没有被动态链接。如果用户试图装载MS-DOS文件系统,那么mount()系统调用通常就会失败,返回一个错误码,因为MS-DOS没有被包含在已注册文件系统的file_systems链表中。然而,如果内核已配置为支持模块的动态链接,那么Linux就试图链接MS-DOS模块,然后再扫描已经注册过的文件系统的列表。如果该模块成功地被链接,那么mount()系统调用就可以继续执行,就好像MS-DOS文件系统从一开始就存在一样。

modprobe程序

为了自动链接模块,内核要创建一个内核线程来执行modprobe外部程序(注2),该程序要考虑由于模块依赖所引起的所有可能因素。模块依赖在前面已介绍过:一个模块可能需要一个或者多个其他模块,这些模块又可能需要其他模块。例如,MS-DOS模块需要另外一个名为fat的模块,该模块包含基于文件分配表(File Allocation Table,FAT)的所有文件系统所通用的一些代码。

因此,如果fat模块还不在系统中,那么在系统请求MS-DOS模块时,fat模块也必须被动态链接到运行的内核中。对模块依赖进行解析以及对模块进行查找的操作最好都在用户态中实现,因为这需要查找和访问文件系统中的模块对象文件。modprobe外部程序和insmod类似,因为它链接在命令行中指定的一个模块。然而,modprobe还可以递归地链接命令行中模块所使用的所有模块。

例如,如果用户调用modprobe来链接MS-DOS模块,那么在需要的时候,modprobe就会在MS-DOS模块之后链接fat模块。实际上,modprobe只是检查模块依赖关系,每个模块的实际的链接工作是通过创建一个进程并执行insmod命令来实现的。modprobe又是如何知道模块间的依赖关系的呢?另外一个称为depmod的外部命令在系统启动时被执行。该程序查找为正在运行的内核而编译的所有模块,这些模块通常存放在/lib/modules目录下。然后它就把所有的模块间依赖关系写入一个名为modules.dep 的文件。这样,modprobe就可以对该文件中存放的信息和/proc/modules文件产生的链接模块链表进行比较。

request_module()函数

在某些情况下,内核可以调用request_module()函数来试图自动链接一个模块。再次考虑用户试图装载MS-DOS文件系统的情况。如果get_fs_type()函数发现这个文件系统还没有注册,就调用request_module()函数,希望MS-DOS已经被编译为一个模块。如果request_module()函数成功地链接所请求的模块,get_fs_type()就可以继续执行,仿佛这个模块一直都存在一样。当然,并非所有的情况都是如此;在我们的例子中,MS-DOS模块可能根本就没有被编译。在这种情况下,get_fs_type()返回一个错误码。

request_module()函数接收要链接的模块名作为参数。该函数调用kernel_thread()来创建一个新的内核线程并等待,直到这个内核线程结束为止。而此内核线程又接收待链接的模块名作为参数,并调用execve()系统调用以执行modprobe外部程序(注3),向其传递模块名。然后,modeprobe程序真正地链接所请求的模块以及这个模块所依赖的任何模块。由exec_modprobe()执行的程序名和路径名可以通过向/proc/sys/kernel/modprobe文件写入而自定义。

猜你喜欢

转载自blog.csdn.net/x13262608581/article/details/132507228