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

史前时代:BIOS

在开始启动时,有一个特殊的硬件电路在CPU的一个引脚上产生一个RESET逻辑值。
RESET产生以后,就把处理器的一些寄存器(包括cs和eip)设置成固定的值,并执行在物理地址0xfffffff0处找到的代码。
硬件把这个地址映射到某个只读、持久的存储芯片中,该芯片通常称为ROM(Read-Only Memory,只读内存)

ROM中所存放的程序集在80x86体系中通常叫作基本输入/输出系统(Basic Input/Output System,BIOS),因为它包括几个中断驱动的低级过程。所有操作系统在启动时,都要通过这些过程对计算机硬件设备初始化。一些操作系统,如微软的MS-DOS,依赖于BIOS实现大部分系统调用。

Linux一旦进入保护模式,就不再使用BIOS,而是为计算机上的每个硬件设备提供各自的设备驱动程序。
BIOS使用实模式的地址,一个实模式的地址由一个seg段和一个off偏移量组成。相应的物理地址可以这样计算:seg * 16+off

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

远古时代:引导装入程序

引导装入程序(boot loader)是由BIOS用来把操作系统的内核映像装载到RAM中所调用的一个程序。
让我们简要地描绘一下引导装入程序在IBMPC体系结构中是如何工作的。

(1).从软盘启动
为了从软盘上启动,必须把第一个扇区中所存放的指令装载到RAM中并执行;
这些指令再把包含内核映像的其他所有扇区都拷贝到RAM中。
(2).从硬盘启动
硬盘的第一个扇区称为主引导记录(Master Boot RecordMBR),该扇区中包括分区表和一个小程序,这个小程序用来装载被启动的操作系统所在分区的第一个扇区。

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

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

现在从软盘启动与从硬盘或CD-ROM启动是十分相似的。

从磁盘启动Linux

从磁盘启动Linux内核需要一个两步的引导装入程序。
80x86体系中,众所周知的Linux 引导装入程序叫作Linux Loader(LILO)
确实还有一些80x86体系的引导装入程序,如广泛使用的GRand Unified Bootloader(GRUB)
对于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引脚。
(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
(8). 把从BIOS中获得的系统参数和传递给操作系统的参数放入第一个页框中。
(9). 识别处理器的型号。
(10). 用GDTIDT表的地址来填充gdt ridt 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()函数以完成IDT初始化。
(5). 调用softirq_init()函数初始化TASKLET_SOFTIRQHI_SOFTIRQ
(6). 调用time_init()函数来初始化系统日期和时间。
(7). 调用kmem_cache_init()函数来初始化slab分配器。
(8). 调用calibrate_delay()函数以确定CPU时钟的速度。
(9). 调用kernel_thread()函数为进程1创建内核线程。这个内核线程又会创建其他的内核线程并执行/sbin/init程序。

模块

1.是否使用模块

当系统程序员希望给Linux内核增加新功能时,就面临一个进退两难的问题:他们应该编写新代码从而将其作为一个模块进行编译,还是应该将这些代码静态地链接到内核中?

通常,系统程序员都倾向于把新代码作为一个模块来实现。因为模块可以根据需要进行链接,这样内核就不会因为装载那些数以百计的很少使用的程序而变得非常庞大,这一点我们后面就会看到。几乎Linux内核的每个高层组件——文件系统、设备驱动程序、可执行格式、网络层等等——都可以作为模块进行编译。

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

再例如,考虑一个内核组件,它要替换静态链接的代码。显然,这样的组件不能作为一个模块来编译,因为在链接模块时内核不能修改已经在RAM中的机器码。 例如,系统不可能链接一个改变页框分配方法的模块,因为伙伴系统函数总是被静态地链接到内核。

内核有两个主要的任务来进行模块的管理。
第一个任务是确保内核的其他部分可以访问该模块的全局符号,例如指向模块主函数的入口。模块还必须知道这些符号在内核及其他模块中的地址。因此,在链接模块时,一定要解决模块间的引用关系。
第二个任务是记录模块的使用情况,以便在其他模块或者内核的其他部分正在使用这个模块时,不能卸载这个模块。系统使用了一个简单的引用计数器来记录每个模块的引用次数。

模块的实现

模块是作为ELF对象文件存放在文件系统中的,并通过执行insmod程序链接到内核中。
对于每个模块,系统都分配一个包含以下数据的内存区:
(1). 一个module对象
(2). 表示模块名的一个以null结束的字符串(所有的模块都必须有唯一的名字)
(3). 实现模块功能的代码

module对象描述一个模块,其字段如表B-1所示。
一个双向循环列表存放所有module 对象。链表头部存放在modules变量中,而指向相邻单元的指针存放在每个module对象的list字段中。

在这里插入图片描述
在这里插入图片描述

state字段记录模块内部状态,它可以是:
(1). MODULE_STATE_LIVE(模块为活动的)、
(2). MODULE_STATE_COMING(模块正在初始化)
(3). MODULE_STATE_GOING(模块正在卸载)。

每个模块都有自己的异常表。该表包括(如果有)模块的修正代码的地址。
在链接模块时,该表被拷贝到RAM中,其开始地址保存在module对象的extable字段中。

模块使用计数器

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

导出符号

当链接一个模块时,必须用合适的地址替换在模块对象代码中引用的所有全局内核符号(变量和函数)。这个操作与在用户态编译程序时链接程序所执行的操作非常类似,这是委托给insmod外部程序完成的。内核使用一些专门的内核符号表(kernel symbol table),用于保存模块访问的符号和相应的地址。
它们在内核代码段中分三个节:
(1). __kstrtab节(保存符号名)
(2). __ksymtab节(所有模块可使用的符号地址)
(3). __ksymtab_gpl节(GPL兼容许可证下发布的模块可以使用的符号地址)。

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

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

模块依赖

一个模块(B)可以引用由另一个模块(A)所导出的符号;在这种情况下,我们就说B 装载在A的上面,或者说AB使用。为了链接模块B,必须首先链接模块A

模块的链接和取消

用户可以通过执行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). 将第67步中分配的内存区地址存入模块对象的module_codemodule_init字段。
(10). 初始化模块对象的modules_which_use_me链表。当前执行CPU的计数器设为1,而其余所有的模块引用计数器设为0
(11). 根据模块对象许可证类型设定模块对象的license_gplok标志。
(12). 使用内核符号表与模块符号表,重置模块目标码。这意味着用相应的逻辑地址偏移量替换所有外部与全局符号的实例值。
(13). 初始化模块对象的symsgpl_syms字段,使其指向模块导出的内存中符号表。
(14). 模块异常表保存在模块ELF文件的__ex_table一节,因此它在第6步中已拷入内存区,将其地址存入模块对象的extable字段。
(15). 解析insmod程序的参数,并相应地设定模块变量的值。
(16). 注册模块对象mkobj字段中的kobject对象,这样在sysfs特殊文件系统的module目录中就有一个新的子目录。
(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(成功)。

根据需要链接模块

模块可以在系统需要其所提供的功能时自动进行链接,之后也可以自动删除。

modprobe程序

为了自动链接模块,内核要创建一个内核线程来执行modprobe外部程序,该程序要考虑由于模块依赖所引起的所有可能因素。
模块依赖在前面已介绍过:一个模块可能需要一个或者多个其他模块,这些模块又可能需要其他模块。
对模块依赖进行解析以及对模块进行查找的操作最好都在用户态中实现,因为这需要查找和访问文件系统中的模块对象文件。
modprobe外部程序和insmod类似,因为它链接在命令行中指定的一个模块。然而,modprobe还可以递归地链接命令行中模块所使用的所有模块。

实际上,modprobe只是检查模块依赖关系,每个模块的实际的链接工作是通过创建一个进程并执行insmod命令来实现的。
modprobe又是如何知道模块间的依赖关系的呢?
另外一个称为depmod的外部命令在系统启动时被执行。该程序查找为正在运行的内核而编译的所有模块,这些模块通常存放在/lib/modules目录下。然后它就把所有的模块间依赖关系写入一个名为modules.dep 的文件。

这样,modprobe就可以对该文件中存放的信息和/proc/modules文件产生的链接模块链表进行比较。

request_module()函数

在某些情况下,内核可以调用request_module()函数来试图自动链接一个模块。

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

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

raindayinrain

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值