Linux内核源代码漫游
Alessandro Rubini著, rubini@pop.systemy.it
赵炯 译,gohigh@sh163net, ()
本章试图以顺序的方式来解释Linux源代码,以帮助读者对源代码的体系结构以及很多相关的unix特性的实现有一个很好的理解。目标是帮助对Linux不甚了解的有经验的C程序员对整个Linux的设计有所了解。这也就是为什么内核漫游的入点选择为内核本身的启始点:系统引导(启动)。
这份材料需要对C语言以及对Unix的概念和PC机的结构有很好的了解,然而本章中并没有出现任何的C代码,而是直接参考(指向)实际的代码的。有关内核设计的最佳篇幅是在本手册的其它章节中,而本章仍趋向于是一个非正式的概述。
本章中所参阅的任何文件的路径名都是指主源代码目录树,通常是/usr/src/linux。
这里所给出的大多数信息都是取之于Linux发行版1.0的源代码。虽然如此,有时也会提供对后期版本的参考。这篇漫游中开头有 图标的任何小节都是强调1.0版本后对内核的新的改动。如果没有这样的小节存在,则表示直到版本1.0.9-1.1.76,没有作过改动。
有时候本章中会有象这样的小节,这是指向正确的代码以对刚讨论过的主题取得更多信息的指示符。当然,这里是指源代码。
引导(启动)系统
当PC的电源打开后,80x86结构的CPU将自动进入实模式,并从地址0xFFFF0开始自动执行程序代码,这个地址通常是ROM-BIOS中的地址。PC机的BIOS将执行某些系统的检测,在物理地址0处开始初始化中断向量。此后,它将可启动设备的第一个扇区读入内存地址0x7C00处,并跳转到这个地方。启动设备通常是软驱或是硬盘。这里的叙述是非常简单的,但这已经足够理解内核初始化的工作过程了。
Linux的最最前面部分是用8086汇编语言编写的(boot/bootsect.S),它将由BIOS读入到内存0x7C00处,当它被执行时就会把自己移到绝对地址0x90000处,并将启动设备(boot/setup.S)
的下2kB字节的代码读入内存0x90200处,而内核的其它部分则被读入到地址0x10000处。在系统加载期间将显示信息""。然后控制权将传递给boot/Setup.S中的代码,这是另一个实模式汇编语言程序。
启动部分识别主机的某些特性以及vga卡的类型。如果需要,它会要求用户为控制台选择显示模式。然后将整个系统从地址0x10000移至0x1000处,进入保护模式并跳转至系统的余下部分(在0x1000处)。
下一步是内核的解压缩。0x1000处的代码来自于zBoot/head.S,它初始化寄存器并调用decompress_kernel(),它们依次是由zBoot/inflate.c、zBoot/unzip.c和zBoot/misc.c组成。被解压的数据存放到了地址0x10000处(1兆),这也是为什么Linux不能运行于少于2兆内存的主要原因。[在1兆内存中解压内核的工作已经完成,见 Memory Savers--ED]
将内核封装在一个gzip文件中的工作是由zBoot目录中的Makefile以及工具完成的。它们是值得一看的有趣的文件。
内核发行版1.1.75将boot和zBoot目录下移到了arch/i386/boot中了,这个改动意味着对不同的体系结构允许真正的内核建造,不过我将仍然只讲解有关i386的信息。
解压过的代码是从地址0x10100处开始执行的[这里我可能忘记了具体的物理地址了,因为我对相应的代码不是很熟],在那里,所有32比特的设置启动被完成: IDT、GDT以及LDT被加载,处理器和协处理器也已确认,分页工作也设置好了;最终调用start_kernel子程序。上述操作的源代码是在boot/head.S中的,这可能是整个内核中最有诀窍的代码了。
注意如果在前述任何一步中出了错,计算机就会死锁。在操作系统还没有完全运转之前是处理不了出错的。
mmap格式怎么打开start_kernel()是位于init/main.c中的,并且没有任何返回结果。从现在起的任何代码都是用C语言编制的,除了中断管理和系统调用的入/出代码(当然,还有大多数的宏都嵌入了汇编代码)。
让轮子转动起来
在处理了所有错综复杂的问题之后,start_kernel()初始化了内核的所有部分,尤其是:
∙ 设置内存边界和调用paging_init();
∙ 初始化中断、IRQ通道和调度;
∙ 分析(解析)命令行;
∙ 如果需要,就分配一个数据缓冲区(profiling buffer)以及其它一些小部分;
∙ 校正延迟循环(计算“BogoMips”数);
∙ 检查中断16是否能与协处理器工作。
最后,为了生成初始进程,内核准备好了移至move_to_user_mode(),它的代码也是在同一个源代码文件中的。然后,所谓的空闲任务,进程号0就进入无限的空闲循环中运行。
接着初始进程(init process)尝试着运行/etc/init、/bin/init或者/sbin/init。
如果它们没有一个运行成功的,就会去执行代码“/bin/sh /etc/rc”并且在第一个终端上生成一个根命令解释程序(root shell)。这段代码回溯至Linux 0.01,当时操作系统只有一个内核,并且没有登录进程。
在从一个标准的地方(让我们假定我们有)用exec()执行了init初始化程序之后,内核就对程序的执行没有了直接的控制。从现在起它的规则是提供对系统调用的处理,以及为异步事件
服务(比如硬件中断等)。多任务的环境已经建立,从现在起是init程序通过fork()派生出的系统进程和登录进程来管理多用户的访问了。
由于内核是负责提供服务的,这个漫游文章将通过观察这些服务(“系统调用”)以及通过提供基本数据结构的原理和代码的组织结构继续讨论下去。
内核是如何看见一个进程的
从内核的观点来看,一个进程只是进程表中的一个条目而已。
而进程表以及各个内存管理表和缓冲存储器则是系统中最为重要的数据结构。进程表中的各个单项是task_struct结构,是定义在include/linux/sched.h中的非常大的数据结构。在task_struct中保留着从低层到高层的信息,范围从某些硬件寄存器的拷贝到进程工作目录的inode信息。
进程表既是一个数组和双链表,也是一个树结构。它的物理实现是一个静态的指针数组,它的长度是定义在include/linux/tasks.h中的常量NR_TASKS,并且每个结构都位于一个保留内存页中。这个列表结构是通过指针next_task和pre_task构成的,而树结构则是非常复杂的并
且我们在此将不加以讨论。你可能希望改动NR_TASKS的默认值128,但你要保证所有源文件中相关的适当文件都要被重新编译过。
在启动引导过程结束后,内核将总是代表某个进程而工作,并且全局变量current --- 一个指向某个task_struct条目的指针 --- 被用于记录正在运行的进程。current仅能通过在kernel/sched.c中的调度程序来改变。然而,由于所有的进程都必须访问它,所以使用了宏for_each_task。当系统负荷很轻时,它要比数组的顺序扫描快得多。
进程总是运行于“用户模式”或“内核模式”。用户程序的主体是运行于用户模式而其中的系统调用则运行于内核模式中。在这两种执行模式中进程所用的堆栈是不一样的 -- 常规的堆栈段用于用户模式,而一个固定大小的堆栈(一页,由该进程所有)则用于内核模式。内核堆栈页是从不交换出去的,因为每当一个系统调用进入时它就必须存在着。
内核中的系统调用(system calls)是作为C语言函数存在的,它们的‘正规’名称是以‘sys_’开头的。例如一个名为burnout的系统调用将调用内核函数sys_burnout()。
系统调用机制在本手册的第三章中进行了讨论。观看在include/linux/sched.h中的for_each_task和SET_LINKS能够帮助理解进程表中的列表和树结构。
创建和结束进程
unix系统是通过fork()系统调用创建一个进程的,而进程的终止是通过exit()或收到一个信号来完成的。它们的Linux实现位于kernel/fork.c和kernel/exit.c中。 派生出一个进程是很容易的,所以fork.c程序很短并易于理解。它的主要任务是为新的进程填写数据结构。除了填写各个字段以外,相关的步骤有:
● 取得一个空闲内存页面来保存task_struct
● 到一个空闲的进程槽(find_empty_process())
● 为内存堆栈页kernel_stack_page取得另一个空闲的内存页面
● 将父辈的LDT拷贝到子进程
● 复制父进程的mmap信息
sys_fork() 同样也管理文件描述符和inode。
1.0的内核也对线程提供某些不够完善的支持,所以fork()系统调用对此也给出了某些示意。内核的线程是主流内核以外的过程产品。
从一个进程中退出是比较有窍门的,因为父进程必须被通告有关任何子进程的退出。而且,一个进程可以由另外一个进程使用kill()而退出(这些是Unix的特性),所以除了sys_exit()之外,sys_kill()以及sys_wait()的各种特性也存在于exit.c之中了。
这里不对exit.c的代码加以讨论---因为它一点也不令人感兴趣。为了以一致的状态退出系统,它涉及到许多细节。而POSIX标准对于信号则是要求相当严格的,所以这里必须对其加以叙述。
执行程序
在调用了fork()之后,就有同一个程序的两个拷贝在运行了,通常一个程序使用exec()执行另一个程序。exec()系统调用必须定位该执行文件的二进制映像,加载并执行它。词语‘加载’并不一定意味着“将二进制映像拷贝进内存”,因为Linux支持按需加载。 exec()的Linux实现支持不同的二进制格式。这是通过linux_binfmt结构来达到的,其中内嵌了两个指向函数的指针-
-一个是用于加载可执行文件的,另一个用于加载库函数,每种二进制格式都实现有这两个函数。共享库的加载是在exec()同一个源程序中实现的,但我们只讨论exec()本身。 Unix系统提供了六种exec()函数。除了一个以外,所有都是以库函数的形式实现的,并且,Linux内核是单独实现sys_execve()调用的。它执行一个非常简单的任务:加载可执行文件的头部,并试着去执行它。如果头两个字节是“#!”,那么就会解析该可执行文件的第一行并调用一个解释器来执行它,否则的话,就会顺序地试用各个注册过的二进制格式。 Linux本身的格式是由fs/exec.c直接支持的,并且相关的函数是load_aout_binary和load_aout_library。对于二进制,函数将加载一个“a.out”可执行文件并以使用mmap()加载磁盘文件或调用read_exec()而结束。前一种方法使用了Linux的按需加载机理,在程序被访问时使用出错加载方式(fault-in)加载程序页面,而后一种方式是在主机文件系统不支持内存映像时(例如“msdos”文件系统)使用的。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论