2.5.3进程初始化
boot/目录中引导程序
l把内核从磁盘上加载到内存中
l让系统进入保护模式下运行
系统初始化程序init/main.c
l确定如何分配使用系统物理内存
l调用内核各部分的初始化函数分别对内存管理、中断处理、块设备和字符设备、进程管理以及硬盘和软盘硬件进行初始化处理
l(此时系统各部分已经处于可运行状态)
l程序把自己“手工”移动到任务0(进程0)中运行,并使用fork()调用首次创建出进程1
l进程1中程序将继续进行应用环境的初始化并执行shell登录程序
l原进程0在系统空闲时被调度执行,此时任务0仅执行pause()系统调用,并又会调用调度函数
宏move_to_user_mode(include/asm/system.h)
//把main.c程序执行流从内核态移动到了用户态的任务0中运行
sched_init() //对调度程序的初始化过程,对任务0的运行环境进行了设置
l设置好任务0数据结构各字段的值(include/linux/sched.h)
l在全局描述符表中添入任务0的任务状态段(TSS)描述符和局部描述符表(LDT)的段描述符,并把它们分别加载到任务寄存器tr和局部描述符表寄存器ldtr中
这里需要强调的是,内核初始化是一个特殊过程,内核初始化代码也即是任务0的代码。从任务0数据结构中设置的初始数据可知,任务0的代码段和数据段的基址是0、段限长是640KB。而内核代码段和数据段的基址是0、段限长是16MB,因此任务0的代码段和数据段分别包含在内核代码段和数据段中。内核初始化程序main.c也即是任务0中的代码,只是在移动到任务0之前系统正以内核态特权级0运行着main.c程序。宏move_to_user_mode的功能就是把运行特权级从内核态的0级变换到用户态的3级,但是仍然继续执行原来的代码指令流。???zzz
在移动到任务0的过程中,宏move_to_user_mode使用了中断返回指令造成特权级改变的方法。该方法的主要思想是在堆栈中构筑中断返回指令需要的内容,把返回地址的段选择符设置成任务0代码段选择符,其特权级为3。此后执行中断返回指令iret时将导致系统CPU从特权级0跳转到外层的特权级3上运行。参见图2-7所示的特权级发生变化时中断返回堆栈结构示意图。
图2-7特权级发生变化时中断返回堆栈结构示意图
宏move_to_user_mode首先往内核堆栈中压入任务0数据段选择符和内核堆栈指针。然后压入标志寄存器内容。最后压入任务0代码段选择符和执行中断返回后需要执行的下一条指令的偏移位置。该偏移位置是iret后的一条指令处。
当执行iret指令时,CPU把返回地址送入CS:EIP中,同时弹出堆栈中标志寄存器内容。由于CPU判断出目的代码段的特权级是3,与当前内核态的0级不同。于是CPU会把堆栈中的堆栈段选择符和堆栈指针弹出到SS:ESP中。由于特权级发上了变化,段寄存器DS、ES、FS和GS的值变得无效,此时CPU会把这些段寄存器清零。因此在执行了iret指令后需要重新加载这些段寄存器。此后,系统就开始以特权级3运行在任务0的代码上。所使用的用户态堆栈还是原来在移动之前使用的堆栈。而其内核态堆栈则被指定为其任务数据结构所在页面的顶端开始(PAGE_SIZE + (long)&init_task)。由于以后在创建新进程时,需要复制任务0的任务数据结构,包括其用户堆栈指针,因此要求任务0的用户态堆栈在创建任务1(进程1)之前保持“干净”状态。
2.5.4创建新进程
Linux系统中创建新进程使用fork()系统调用。所有进程都是通过复制进程0而得到的,都是进程0的子进程。在创建新进程的过程中,系统首先在任务数组中找出一个还没有被任何进程使用的空项(空槽)。如果系统已经有64个进程在运行,则fork()系统调用会因为任务数组表中没有可用空项而出错返回。然后系统为新建进程在主内存区中申请一页内存来存放其任务数据结构信息,并复制当前进程任务数据结构中的所有内容作为新进程任务数据结构的模板。为了防止这个还未处理完成的新建进程被调度函数执行,此时应该立刻将新进程状态置为不可中断的等待状态(TASK_UNINTERRUPTIBLE)。
随后对复制的任务数据结构进行修改。把当前进程设置为新进程的父进程,清除信号位图并复位新进程各统计值,并设置初始运行时间片值为15个系统滴答数(150毫秒)。接着根据当前进程设置任务状态段(TSS)中各寄存器的值。由于创建进程时新进程返回值应为0,所以需要设置tss.eax = 0。新建进程内核态堆栈指针tss.esp0被设置成新进程任务数据结构所在内存页面的顶端,而堆栈段tss.ss0被设置成内核数据段选择符。tss.ldt被设置为局部表描述符在GDT中的索引值。如果当前进程使用了协处理器,把还需要把协处理器的完整状态保存到新进程的tss.i387结构中。
此后系统设置新任务的代码和数据段基址、限长并复制当前进程内存分页管理的页表。如果父进程中有文件是打开的,则应将对应文件的打开次数增1。接着在GDT中设置新任务的TSS和LDT描述符项,其中基地址信息指向新进程任务结构中的tss和ldt。最后再将新任务设置成可运行状态并返回新进程号。
2.5.5进程调度
由前面描述可知,Linux进程是抢占式的。被抢占的进程仍然处于TASK_RUNNING状态,只是暂时没有被CPU运行。进程的抢占发生在进程处于用户态执行阶段,在内核态执行时是不能被抢占的。为了能让进程有效地使用系统资源,又能使进程有较快的响应时间,就需要对进程的切换调度采用一定的调度策略。在Linux 0.11中采用了基于优先级排队的调度策略。
调度程序
schedule()函数首先扫描任务数组。通过比较每个就绪态(TASK_RUNNING)任务的运行时间递减滴答计数counter的值来确定当前哪个进程运行的时间最少。哪一个的值大,就表示运行时间还不长,于是就选中该进程,并使用任务切换宏函数切换到该进程运行。
如果此时所有处于TASK_RUNNING状态进程的时间片都已经用完,系统就会根据每个进程的优先权值priority,对系统中所有进程(包括正在睡眠的进程)重新计算每个任务需要运行的时间片值counter。
计算的公式是:
然后schdeule()函数重新扫描任务数组中所有处于TASK_RUNNING状态,重复上述过程,直到选择出一个进程为止。最后调用switch_to()执行实际的进程切换操作。
如果此时没有其它进程可运行,系统就会选择进程0运行。对于Linux 0.11来说,进程0会调用pause()把自己置为可中断的睡眠状态并再次调用schedule()。不过在调度进程运行时,schedule()并不在意进程0处于什么状态。只要系统空闲就调度进程0运行。
进程切换
执行实际进程切换的任务由switch_to()宏定义的一段汇编代码完成。在进行切换之前,switch_to()首先检查要切换到的进程是否就是当前进程,如果是则什么也不做,直接退出。否则就首先把内核全局变量current置为新任务的指针,然后长跳转到新任务的任务状态段TSS组成的地址处,造成CPU执行任务切换操作(???zzz)。此时CPU会把其所有寄存器的状态保存到当前任务寄存器TR中TSS段选择符所指向的当前进程任务数据结构的tss结构中,然后把新任务状态段选择符所指向的新任务数据结构中tss结构中的寄存器信息恢复到CPU中,系统就正式开始运行新切换的任务了。这个过程可参见图2-8所示。
图2-8任务切换操作示意图
2.5.6终止进程
当一个进程结束了运行或在半途中终止了运行,那么内核就需要释放该进程所占用的系统资源。这包括进程运行时打开的文件、申请的内存等。
当一个用户程序调用exit()系统调用时,就会执行内核函数do_exit()。该函数会首先释放进程代码段和数据段占用的内存页面,关闭进程打开着的所有文件,对进程使用的当前工作目录、根目录和运行程序的i节点进行同步操作。如果进程有子进程,则让init进程作为其所有子进程的父进程。如果进程是一个会话头进程并且有控制终端,则释放控制终端,并向属于该会话的所有进程发送挂断信号SIGHUP,这通常会终止该会话中的所有进程。然后把进程状态置为僵死状态TASK_ZOMBIE。并向其原父进程发送SIGCHLD信号,通知其某个子进程已经终止。最后do_exit()调用调度函数去执行其它进程。由此可见在进程被终止时,它的任务数据结构仍然保留着。因为其父进程还需要使用其中的信息。
在子进程在执行期间,父进程通常使用wait()或waitpid()函数等待其某个子进程终止。当等待的子进程被终止并处于僵死状态时,父进程就会把子进程运行所使用的时间累加到自己进程中。最终释放已终止子进程任务数据结构所占用的内存页面,并置空子进程在任务数组中占用的指针项。
2.6Linux内核对内存的使用方法
在Linux 0.11内核中,为了有效地使用机器中的物理内存,内存被划分成几个功能区域,见下图2-9所示。
图2-9物理内存使用的功能分布图
其中,Linux内核程序占据在物理内存的开始部分,接下来是用于供硬盘或软盘等块设备使用的高速缓冲区部分。当一个进程需要读取块设备中的数据时,系统会首先将数据读到高速缓冲区中;当有数据需要写到块设备上去时,系统也是先将数据放到高速缓冲区中,然后由块设备驱动程序写到设备上。最后部分是供所有程序可以随时申请使用的主内存区部分。内核程序在使用主内存区时,也同样要首先向内核的内存管理模块提出申请,在申请成功后方能使用。对于含有RAM虚拟盘的系统,主内存区头部还要划去一部分,供虚拟盘存放数据。
由于计算机系统中所含的实际物理内存容量是有限的,因此CPU中通常都提供了内存管理机制对系统中的内存进行有效的管理。在Intel CPU中,提供了两种内存管理(变换)系统:内存分段系统(Segmentation System)和分页系统(Paging System)。而分页管理系统是可选择的,由系统程序员通过编程来确定是否采用。为了能有效地使用这些物理内存,Linux系统同时采用了Intel CPU的内存分段和分页管理机制。
在Linux 0.11内核中,在进行地址映射时,我们需要首先分清3种地址以及它们之间的变换概念:
a.程序(进程)的逻辑地址;b. CPU的线性地址;c.实际物理内存地址。
逻辑地址(Logical Address)是指有程序产生的与段相关的偏移地址部分。在Intel保护模式下即是指程序执行代码段限长内的偏移地址(假定代码段、数据段完全一样)。应用程序员仅需与逻辑地址打交道,而分段和分页机制对他来说是完全透明的,仅由系统编程人员涉及。
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G。
物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
虚拟内存(VirtualMemory)是指计算机呈现出要比实际拥有的内存大得多的内存量。因此它允许程序员编制并运行比实际系统拥有的内存大得多的程序。这使得许多大型项目也能够在具有有限内存资源的系统上实现。一个很恰当的比喻是:你不需要很长的轨道就可以让一列火车从上海开到北京。你只需要足够长的铁轨(比如说3公里)就可以完成这个任务。采取的方法是把后面的铁轨立刻铺到火车的前面,只要你的操作足够快并能满足要求,列车就能象在一条完整的轨道上运行。这也就是虚拟内存管理需要完成的任务。在Linux 0.11内核中,给每个程序(进程)都划分了总容量为64MB的虚拟内存空间。因此程序的逻辑地址范围是0x0000000到0x。
有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的。
在内存分段系统中,一个程序的逻辑地址是通过分段机制自动地映射(变换)到中间层的线性地址上。每次对内存的引用都是对内存段中内存的引用。当一个程序引用一个内存地址时,通过把相应的段基址加到程序员看得见的逻辑地址上就形成了一个对应的线性地址。此时若没有启用分页机制,则该线性地址就被送到CPU的外部地址总线上,用于直接寻址对应的物理内存。
若采用了分页机制,则此时线性地址只是一个中间结果,还需要使用分页机制进行变换,再最终映射到实际物理内存地址上。与分段机制类似,分页机制允许我们重新定向(变换)每次内存引用,以适应我们的特殊要求。使用分页机制最普遍的场合是当系统内存实际上被分成很多凌乱的块时,它可以建立一个大而连续的内存空间的映象,好让程序不用操心和管理这些分散的内存块。分页机制增强了分段机制的性能。页地址变换是建立在段变换基础之上的。任何分页机制的保护措施并不会取代段变换的保护措施而只是进行更进一步的检查操作。
因此,CPU进行地址变换(映射)的主要目的是为了解决虚拟内存空间到物理内存空间的映射问题。虚拟内存空间的含义是指一种利用二级或外部存储空间,使程序能不受实际物理内存量限制而使用内存的一种方法。通常虚拟内存空间要比实际物理内存量大得多。
那么虚拟内存空间管理是怎样实现的呢?原理与上述列车运行的比喻类似。首先,当一个程序需要使用一块不存在的内存时(也即在内存页表项中已标出相应内存页面不在内存中),CPU就需要一种方法来得知这个情况。这是通过80386的页错误异常中断来实现的。当一个进程引用一个不存在页面中的内存地址时,就会触发CPU产生页出错异常中断,并把引起中断的线性地址放到CR2控制寄存器中。因此处理该中断的过程就可以知道发生页异常的确切地址,从而可以把进程要求的页面从二级存储空间(比如硬盘上)加载到物理内存中。如果此时物理内存已经被全部占用,那么可以借助二级存储空间的一部分作为交换缓冲区(Swapper)把内存中暂时不使用的页面交换到二级缓冲区中,然后把要求的页面调入内存中。这也就是内存管理的缺页加载机制,在Linux 0.11内核中是在程序mm/memory.c中实现。
IntelCPU使用段(Segment)的概念来对程序进行寻址。每个段定义了内存中的某个区域以及访问的优先级等信息。而每个程序都可有若干个内存段组成。程序的逻辑地址(或称为虚拟地址)即是用于寻址这些段和段中具体地址位置。在Linux 0.11中,程序逻辑地址到线性地址的变换过程使用了CPU的全局段描述符表GDT和局部段描述符表LDT。由GDT映射的地址空间称为全局地址空间,由LDT映射的地址空间则称为局部地址空间,而这两者构成了虚拟地址的空间。具体的使用方式见图2-10所示。
图2-10Linux系统中虚拟地址空间分配图
图中画出了具有两个任务时的情况。对于中断描述符表idt,它是保存在内核代码段中的。由于在Linux 0.11内核中,内核和各任务的代码段和数据段都分别被映射到线性地址空间中相同基址处,且段限长也一样,因此内核和任务的代码段和数据段都分别是重叠的。另外,Linux0.11内核中没有使用系统段描述符。
内存分页管理的基本原理是将整个主内存区域划分成4096字节为一页的内存页面。程序申请使用内存时,就以内存页为单位进行分配。
在使用这种内存分页管理方法时,每个执行中的进程(任务)可以使用比实际内存容量大得多的连续地址空间。对于Intel 80386系统,其CPU可以提供多达4G的线性地址空间。对于Linux 0.11内核,系统设置全局描述符表GDT中的段描述符项数最大为256,其中2项空闲、2项系统使用,每个进程使用两项。因此,此时系统可以最多容纳(256-4)/2 + 1=127个任务,并且虚拟地址范围是((256-4)/2)* 64MB约等于8G。但0.11内核中人工定义最大任务数NR_TASKS = 64个,每个进程虚拟地址范围是64M,并且各个进程的虚拟地址起始位置是(任务号-1)*64MB。因此所使用的虚拟地址空间范围是64MB*64 =4G,见图2-11所示。4G正好与CPU的线性地址空间范围或物理地址空间范围相同,因此在0.11内核中比较容易混淆三种地址概念。
图2-11Linux0.11线性地址空间的使用示意图
进程的虚拟地址需要首先通过其局部段描述符变换为CPU整个线性地址空间中的地址,然后再使用页目录表PDT(一级页表)和页表PT(二级页表)映射到实际物理地址页上。因此两种变换不能混淆。为了使用实际物理内存,每个进程的线性地址通过二级内存页表动态地映射到主内存区域的不同内存页上。因此每个进程最大可用的虚拟内存空间是64MB。每个进程的逻辑地址通过加上任务号*64M,即可转换为线性地址。不过在注释中,我们通常将进程中的地址简单地称为线性地址。有关内存分页管理的详细信息,请参见第10章开始部分的有关说明,或参见附录。
从Linux内核0.99版以后,对内存空间的使用方式发生了变化。每个进程可以单独享用整个4G的地址空间范围。由于篇幅所限,这里对此不再说明。
2.7Linux系统中堆栈的使用方法
本节内容概要描述了Linux内核从开机引导到系统正常运行过程中对堆栈的使用方式。这部分内容的说明与内核代码关系比较密切,可以先跳过。在开始阅读相应代码时再回来仔细研究。
Linux0.11系统中共使用了四种堆栈。一种是系统初始化时临时使用的堆栈;一种是供内核程序自己使用的堆栈(内核堆栈),只有一个,位于系统地址空间固定的位置,也是后来任务0的用户态堆栈;另一种是每个任务通过系统调用,执行内核程序时使用的堆栈,我们称之为任务的内核态堆栈,每个任务都有自己独立的内核态堆栈;最后一种是任务在用户态执行的堆栈,位于任务(进程)地址空间的末端。下面分别对它们进行说明。
2.7.1初始化阶段
开机初始化时(bootsect.s,setup.s)
当bootsect代码被ROMBIOS引导加载到物理内存0x7c00处时,并没有设置堆栈段,当然程序也没有使用堆栈。直到bootsect被移动到0x9000:0处时,才把堆栈段寄存器SS设置为0x9000,堆栈指针esp寄存器设置为0xff00,也即堆栈顶端在0x9000:0xff00处,参见boot/bootsect.s第61、62行。setup.s程序中也沿用了bootsect中设置的堆栈段。这就是系统初始化时临时使用的堆栈。
进入保护模式时(head.s)
从head.s程序起,系统开始正式在保护模式下运行。此时堆栈段被设置为内核数据段(0x10),堆栈指针esp设置成指向user_stack数组的顶端(参见head.s,第31行),保留了1页内存(4K)作为堆栈使用。user_stack数组定义在sched.c的67--72行,共含有1024个长字。它在物理内存中的位置可参见下图2-12所示。此时该堆栈是内核程序自己使用的堆栈。
图2-12刚进入保护模式时内核使用的堆栈示意图
初始化时(main.c)
在main.c中,在执行move_to_user_mode()代码之前,系统一直使用上述堆栈。而在执行过move_to_user_mode()之后,main.c的代码被“切换”成任务0中执行。通过执行fork()系统调用,main.c中的init()将在任务1中执行,并使用任务1的堆栈。而main()本身则在被“切换”成为任务0后,仍然继续使用上述内核程序自己的堆栈作为任务0的用户态堆栈。关于任务0所使用堆栈的详细描述见后面说明。
2.7.2任务的堆栈
每个任务都有两个堆栈,分别用于用户态和内核态程序的执行,并且分别称为用户态堆栈和内核态堆栈。这两个堆栈之间的主要区别在于任务的内核态堆栈很小,所保存的数据量最多不能超过(4096 –任务数据结构)个字节,大约为3K字节。而任务的用户态堆栈却可以在用户的64MB空间内延伸。
在用户态运行时
每个任务(除了任务0)有自己的64MB地址空间。当一个任务(进程)刚被创建时,它的用户态堆栈指针被设置在其地址空间的末端(64MB顶端),而其内核态堆栈则被设置成位于其任务数据结构所在页面的末端。应用程序在用户态下运行时就一直使用这个堆栈。堆栈实际使用的物理内存则由CPU分页机制确定。由于Linux实现了写时复制功能(Copy on Write),因此在进程被创建后,若该进程及其父进程没有使用堆栈,则两者共享同一堆栈对应的物理内存页面。
在内核态运行时
每个任务有其自己的内核态堆栈,与每个任务的任务数据结构(task_struct)放在同一页面内。这是在建立新任务时,fork()程序在任务tss段的内核级堆栈字段(tss.esp0和tss.ss0)中设置的,参见kernel/fork.c,93行:
p->tss.esp0=PAGE_SIZE+(long)p;
p->tss.ss0=0x10;
其中p是新任务的任务数据结构指针,tss是任务状态段结构。内核为新任务申请内存用作保存其task_struct结构数据,而tss结构(段)是task_struct中的一个字段。该任务的内核堆栈段值tss.ss0也被设置成为0x10(即内核数据段),而tss.esp0则指向保存task_struct结构页面的末端。见图2-13所示。
???zzz疯了~~~~~明白了,囧囧囧囧
图2-13进程的内核态堆栈示意图
为什么通过内存管理程序从主内存区分配得来的用于保存任务数据结构的一页内存也能被设置成内核数据段中的数据呢,也即tss.ss0为什么能被设置成0x10呢?这要从内核代码段的长度范围来说明。
在head.s程序的末端,分别设置了内核代码段和数据段的描述符。其中段的长度被设置成了16MB。这个长度值是Linux 0.11内核所能支持的最大物理内存长度(参见head.s,110行开始的注释)。因此,内核代码可以寻址到整个物理内存范围中的任何位置,当然也包括主内存区。到Linux 0.98版后内核段的限长被修改成了1GB。
每当任务执行内核程序而需要使用其内核栈时,CPU就会利用TSS结构把它的内核态堆栈设置成由这两个值构成。在任务切换时,老任务的内核栈指针(esp0)不会被保存。对CPU来讲,这两个值是只读的。因此每当一个任务进入内核态执行时,其内核态堆栈总是空的。
任务0的堆栈
任务0的堆栈比较特殊,需要特别予以说明。
任务0的代码段和数据段相同,段基地址都是从0开始,限长也都是640KB。这个地址范围也就是内核代码和基本数据所在的地方。在执行了move_to_user_mode()之后,它的内核态堆栈位于其任务数据结构所在页面的末端,而它的用户态堆栈就是前面进入保护模式后所使用的堆栈,也即sched.c的user_stack数组的位置。任务0的内核态堆栈是在其人工设置的初始化任务数据结构中指定的,而它的用户态堆栈是在执行movie_to_user_mode()时,在模拟iret返回之前的堆栈中设置的。在该堆栈中,esp仍然是user_stack中原来的位置,而ss被设置成0x17,也即用户态局部表中的数据段,也即从内存地址0开始并且限长为640KB的段。参见图2-7所示。zzz???
2.7.3任务内核态堆栈与用户态堆栈之间的切换
任务调用系统调用时就会进入内核,执行内核代码。此时内核代码就会使用该任务的内核态堆栈进行操作。当进入内核程序时,由于优先级别发生了改变(从用户态转到内核态),用户态堆栈的堆栈段和堆栈指针以及eflags会被保存在任务的内核态堆栈中。而在执行iret退出内核程序返回到用户程序时,将恢复用户态的堆栈和eflags。这个过程见图2-14所示。
图2-14内核态和用户态堆栈的切换
2.8Linux内核源代码的目录结构
由于Linux内核是一种单内核模式的系统,因此,内核中所有的程序几乎都有紧密的联系,它们之间的依赖和调用关系非常密切。所以在阅读一个源代码文件时往往需要参阅其它相关的文件。因此有必要在开始阅读内核源代码之前,先熟悉一下源代码文件的目录结构和安排。
这里我们首先列出Linux内核完整的源代码目录,包括其中的子目录。然后逐一介绍各个目录中所包含程序的主要功能,使得整个内核源代码的安排形式能在我们的头脑中建立起一个大概的框架,以便于下一章开始的源代码阅读工作。
当我们使用tar命令将linux-0.11.tar.gz解开时,内核源代码文件被放到了linux/目录中。其中的目录结构见图2-15所示:
图2-15Linux内核源代码目录结构
该内核版本的源代码目录中含有14个子目录,总共包括102个代码文件。下面逐个对这些子目录中的内容进行描述。
2.8.1内核主目录linux
linux目录是源代码的主目录,在该主目录中除了包括所有的14个子目录以外,还含有唯一的一个Makefile文件。该文件是编译辅助工具软件make的参数配置文件。make工具软件的主要用途是通过识别哪些文件已被修改过,从而自动地决定在一个含有多个源程序文件的程序系统中哪些文件需要被重新编译。因此,make工具软件是程序项目的管理软件。
linux目录下的这个Makefile文件还嵌套地调用了所有子目录中包含的Makefile文件,这样,当linux目录(包括子目录)下的任何文件被修改过时,make都会对其进行重新编译。因此为了编译整个内核所有的源代码文件,只要在linux目录下运行一次make软件即可。
2.8.2引导启动程序目录boot
boot目录中含有3个汇编语言文件,是内核源代码文件中最先被编译的程序。这3个程序完成的主要功能是当计算机加电时引导内核启动,将内核代码加载到内存中,并做一些进入32位保护运行方式前的系统初始化工作。其中bootsect.s和setup.s程序需要使用as86软件来编译,使用的是as86的汇编语言格式(与微软的类似),而head.s需要用GNU as来编译,使用的是AT&T格式的汇编语言。这两种汇编语言在下一章的代码注释里以及代码列表后面的说明中会有简单的介绍。
bootsect.s程序是磁盘引导块程序,编译后会驻留在磁盘的第一个扇区中(引导扇区,0磁道(柱面),0磁头,第1个扇区)。在PC机加电ROM BIOS自检后,将被BIOS加载到内存0x7C00处进行执行。setup.s程序主要用于读取机器的硬件配置参数,并把内核模块system移动到适当的内存位置处。head.s程序会被编译连接在system模块的最前部分,主要进行硬件设备的探测设置和内存管理页面的初始设置工作。
2.8.3文件系统目录fs
Linux 0.11内核的文件系统采用了1.0版的MINIX文件系统,这是由于Linux是在MINIX系统上开发的,采用MINIX文件系统便于进行交叉编译(???zzz什么是交叉编译),并且可以从MINIX中加载Linux分区。虽然使用的是MINIX文件系统,但Linux对其处理方式与MINIX系统不同。主要的区别在于MINIX对文件系统采用单线程处理方式,而Linux则采用了多线程方式。由于采用了多线程处理方式,Linux程序就必须处理多线程带来的竞争条件、死锁等问题,因此Linux文件系统代码要比MINIX系统的复杂得多。为了避免竞争条件的发生,Linux系统对资源分配进行了严格地检查,并且在内核模式下运行时,如果任务没有主动睡眠(调用sleep()),就不让内核切换任务。
fs/目录是文件系统实现程序的目录,共包含17个C语言程序。这些程序之间的主要引用关系见图2-16所示图中每个方框代表一个文件,从上到下按基本按引用关系放置。其中各文件名均略去了后缀.c,虚框中是的程序文件不属于文件系统,带箭头的线条表示引用关系,粗线条表示有相互引用关系。
图2-16fs目录中各程序中函数之间的引用关系。
由图可以看出,该目录中的程序可以划分成四个部分:高速缓冲区管理、低层文件操作、文件数据访问和文件高层函数,在对本目录中文件进行注释说明时,我们也将分成这四个部分来描述。
对于文件系统,我们可以将它看成是内存高速缓冲区的扩展部分。所有对文件系统中数据的访问,都需要首先读取到高速缓冲区中。本目录中的程序主要用来管理高速缓冲区中缓冲块的使用分配和块设备上的文件系统。管理高速缓冲区的程序是buffer.c,而其它程序则主要都是用于文件系统管理。
在file_table.c文件中,目前仅定义了一个文件句柄(描述符)结构数组。ioctl.c文件将引用kernel/chr_drv/tty.c中的函数,实现字符设备的io控制功能。exec.c程序主要包含一个执行程序函数do_execve(),它是所有exec()函数簇中的主要函数。fcntl.c程序用于实现文件i/o控制的系统调用函数。read_write.c程序用于实现文件读/写和定位三个系统调用函数。stat.c程序中实现了两个获取文件状态的系统调用函数。open.c程序主要包含实现修改文件属性和创建与关闭文件的系统调用函数。
char_dev.c主要包含字符设备读写函数rw_char()。pipe.c程序中包含管道读写函数和创建管道的系统调用。file_dev.c程序中包含基于i节点和描述符结构的文件读写函数。namei.c程序主要包括文件系统中目录名和文件名的操作函数和系统调用函数。block_dev.c程序包含块数据读和写函数。inode.c程序中包含针对文件系统i节点操作的函数。truncate.c程序用于在删除文件时释放文件所占用的设备数据空间。bitmap.c程序用于处理文件系统中i节点和逻辑数据块的位图。super.c程序中包含对文件系统超级块的处理函数。buffer.c程序主要用于对内存高速缓冲区进行处理。虚框中的ll_rw_block是块设备的底层读函数,它并不在fs目录中,而是kernel/blk_drv/ll_rw_block.c中的块设备读写驱动函数。放在这里只是让我们清楚的看到,文件系统对于块设备中数据的读写,都需要通过高速缓冲区与块设备的驱动程序(ll_rw_block())来操作来进行,文件系统程序集本身并不直接与块设备的驱动程序打交道。在对程序进行注释过程中,我们将另外给出这些文件中各个主要函数之间的调用层次关系。
2.8.4头文件主目录include
头文件目录中总共有32个.h头文件。其中主目录下有13个,asm子目录中有4个,linux子目录中有10个,sys子目录中有5个。这些头文件各自的功能见如下简述,具体的作用和所包含的信息请参见对头文件的注释一章。
a.out头文件,定义了a.out执行文件格式和一些宏。
常数符号头文件,目前仅定义了i节点中i_mode字段的各标志位。
字符类型头文件。定义了一些有关字符类型判断和转换的宏。
错误号头文件。包含系统中各种出错号。(Linus从minix中引进的)。
文件控制头文件。用于文件及其描述符的操作控制常数符号的定义。
信号头文件。定义信号符号常量,信号结构以及信号操作函数原型。
标准参数头文件。以宏的形式定义变量参数列表。主要说明了-个类型(va_list)和三个宏(va_start, va_arg和va_end),用于vsprintf、vprintf、vfprintf函数。
标准定义头文件。定义了NULL, offsetof(TYPE, MEMBER)。
字符串头文件。主要定义了一些有关字符串操作的嵌入函数。
终端输入输出函数头文件。主要定义控制异步通信口的终端接口。
时间类型头文件。其中最主要定义了tm结构和一些有关时间的函数原形。
Linux标准头文件。定义了各种符号常数和类型,并申明了各种函数。如定义了__LIBRARY__,则还包括系统调用号和内嵌汇编_syscall0()等。
用户时间头文件。定义了访问和修改时间结构以及utime()原型。
体系结构相关头文件子目录include/asm
这些头文件主要定义了一些与CPU体系结构密切相关的数据结构、宏函数和变量。共4个文件。
io头文件。以宏的嵌入汇编程序形式定义对io端口操作的函数。
内存拷贝头文件。含有memcpy()嵌入式汇编宏函数。
段操作头文件。定义了有关段寄存器操作的嵌入式汇编函数。
系统头文件。定义了设置或修改描述符/中断门等的嵌入式汇编宏。
Linux内核专用头文件子目录include/linux
内核配置头文件。定义键盘语言和硬盘类型(HD_TYPE)可选项。
软驱头文件。含有软盘控制器参数的一些定义。
文件系统头文件。定义文件表结构(file,buffer_head,m_inode等)。
硬盘参数头文件。定义访问硬盘寄存器端口,状态码,分区表等信息。
head头文件,定义了段描述符的简单结构,和几个选择符常量。
内核头文件。含有一些内核常用函数的原形定义。
内存管理头文件。含有页面大小定义和一些页面释放函数原型。
调度程序头文件,定义了任务结构task_struct、初始任务0的数据,还有一些有关描述符参数设置和获取的嵌入式汇编函数宏语句。
系统调用头文件。含有72个系统调用C函数处理程序,以'sys_'开头。
tty头文件,定义了有关tty_io,串行通信方面的参数、常数。
系统专用数据结构子目录include/sys
文件状态头文件。含有文件或文件系统状态结构stat{}和常量。
定义了进程中运行时间结构tms以及times()函数原型。
类型头文件。定义了基本的系统数据类型。
系统名称结构头文件。
等待调用头文件。定义系统调用wait()和waitpid()及相关常数符号。
2.8.5内核初始化程序目录init
该目录中仅包含一个文件main.c。用于执行内核所有的初始化工作,然后移到用户模式创建新进程,并在控制台设备上运行shell程序。
程序首先根据机器内存的多少对缓冲区内存容量进行分配,如果还设置了要使用虚拟盘,则在缓冲区内存后面也为它留下空间。之后就进行所有硬件的初始化工作,包括人工创建第一个任务(task0),并设置了中断允许标志。在执行从核心态移到用户态之后,系统第一次调用创建进程函数fork(),创建出一个用于运行init()的进程,在该子进程中,系统将进行控制台环境设置,并且在生成一个子进程用来运行shell程序。
2.8.6内核程序主目录kernel
linux/kernel目录中共包含12个代码文件和一个Makefile文件,另外还有3个子目录。所有处理任务的程序都保存在kernel/目录中,其中包括象fork、exit、调度程序以及一些系统调用程序等。还包括处理中断异常和陷阱的处理过程。子目录中包括了低层的设备驱动程序,如get_hd_block和tty_write等。由于这些文件中代码之间调用关系复杂,因此这里就不详细列出各文件之间的引用关系图,但仍然可以进行大概分类,见图2-17所示。
图2-17各文件的调用层次关系
asm.s程序是用于处理系统硬件异常所引起的中断,对各硬件异常的实际处理程序则是在traps.c文件中,在各个中断处理过程中,将分别调用traps.c中相应的C语言处理函数。
exit.c程序主要包括用于处理进程终止的系统调用。包含进程释放、会话(进程组)终止和程序退出处理函数以及杀死进程、终止进程、挂起进程等系统调用函数。
fork.c程序给出了sys_fork()系统调用中使用了两个C语言函数:find_empty_process()和copy_process()。
mktime.c程序包含一个内核使用的时间函数mktime(),用于计算从1970年1月1日0时起到开机当日的秒数,作为开机秒时间。仅在init/main.c中被调用一次。
panic.程序包含一个显示内核出错信息并停机的函数panic()。
printk.c程序包含一个内核专用信息显示函数printk()。
sched.c程序中包括有关调度的基本函数(sleep_on、wakeup、schedule等)以及一些简单的系统调用函数。另外还有几个与定时相关的软盘操作函数。
signal.c程序中包括了有关信号处理的4个系统调用以及一个在对应的中断处理程序中处理信号的函数do_signal()。
sys.c程序包括很多系统调用函数,其中有些还没有实现。
system_call.s程序实现了Linux系统调用(int0x80)的接口处理过程,实际的处理过程则包含在各系统调用相应的C语言处理函数中,这些处理函数分布在整个Linux内核代码中。
vsprintf.c程序实现了现在已经归入标准库函数中的字符串格式化函数。
块设备驱动程序子目录kernel/blk_drv
通常情况下,用户是通过文件系统来访问设备的,因此设备驱动程序为文件系统实现了调用接口。
在使用块设备时,由于其数据吞吐量大,为了能够高效率地使用块设备上的数据,在用户进程与块设备之间使用了高速缓冲机制。在访问块设备上的数据时,系统首先以数据块的形式把块设备上的数据读入到高速缓冲区中,然后再提供给用户。blk_drv子目录共包含4个c文件和1个头文件。头文件blk.h由于是块设备程序专用的,所以与C文件放在一起。这几个文件之间的大致关系,见图2-18所示。
blk.h中定义了3个C程序中共用的块设备结构和数据块请求结构。hd.c程序主要实现对硬盘数据块进行读/写的底层驱动函数,主要是do_hd__request()函数;floppy.c程序中主要实现了对软盘数据块的读/写驱动函数,主要是do_fd_request()函数。ll_rw_blk.c中程序实现了低层块设备数据读/写函数ll_rw_block(),内核中所有其它程序都是通过该函数对块设备进行数据读写操作。你将看到该函数在许多访问块设备数据的地方被调用,尤其是在高速缓冲区处理文件fs/buffer.c中。
字符设备驱动程序子目录kernel/chr_drv
字符设备程序子目录共含有4个C语言程序和2个汇编程序文件。这些文件实现了对串行端口rs-232、串行终端、键盘和控制台终端设备的驱动。图2-19是这些文件之间的大致调用层次关系。
图2-19字符设备程序之间的关系示意图
tty_io.c程序中包含tty字符设备读函数tty_read()和写函数tty_write(),为文件系统提供了上层访问接口。另外还包括在串行中断处理过程中调用的C函数do_tty_interrupt(),该函数将会在中断类型为读字符的处理中被调用。
console.c文件主要包含控制台初始化程序和控制台写函数con_write(),用于被tty设备调用。还包含对显示器和键盘中断的初始化设置程序con_init()。
rs_io.s汇编程序用于实现两个串行接口的中断处理程序。该中断处理程序会根据从中断标识寄存器(端口0x3fa或0x2fa)中取得的4种中断类型分别进行处理,并在处理中断类型为读字符的代码中调用do_tty_interrupt()。
serial.c用于对异步串行通信芯片UART进行初始化操作,并设置两个通信端口的中断向量。另外还包括tty用于往串口输出的rs_write()函数。
tty_ioctl.c程序实现了tty的io控制接口函数tty_ioctl()以及对termio(s)终端io结构的读写函数,并会在实现系统调用sys_ioctl()的fs/ioctl.c程序中被调用。
keyboard.S程序主要实现了键盘中断处理过程keyboard_interrupt。
协处理器仿真和操作程序子目录kernel/math
该子目录中目前仅有一个C程序math_emulate.c。其中的math_emulate()函数是中断int7的中断处理程序调用的C函数。当机器中没有数学协处理器,而CPU却又执行了协处理器的指令时,就会引发该中断。因此,使用该中断就可以用软件来仿真协处理器的功能。本书所讨论的内核版本还没有包含有关协处理器的仿真代码。本程序中只是打印一条出错信息,并向用户程序发送一个协处理器错误信号SIGFPE。
2.8.7内核库函数目录lib
内核库函数用于为内核初始化程序init/main.c运行在用户态的进程(进程0、1)提供调用支持。它与普通静态库的实现方法完全一样。读者可从中了解一般libc函数库的基本组成原理。在lib/目录中共有12个C语言文件,除了一个由tytso编制的malloc.c程序较长以外,其它的程序很短,有的只有一二行代码,实现了一些系统调用的接口函数。
这些文件中主要包括有退出函数_exit()、关闭文件函数close(fd)、复制文件描述符函数dup()、文件打开函数open()、写文件函数write()、执行程序函数execve()、内存分配函数malloc()、等待子进程状态函数wait()、创建会话系统调用setsid()以及在include/string.h中实现的所有字符串操作函数。
2.8.8内存管理程序目录mm
该目录包括2个代码文件。主要用于管理程序对主内存区的使用,实现了进程逻辑地址到线性地址以及线性地址到主内存区中物理内存地址的映射,通过内存的分页管理机制,在进程的虚拟内存页与主内存区的物理内存页之间建立了对应关系。
Linux内核对内存的处理使用了分页和分段两种方式。首先是将386的4G虚拟地址空间分割成64个段,每个段64MB。所有内核程序占用其中第一个段,并且物理地址与该段线性地址相同。然后每个任务分配一个段使用。分页机制用于把指定的物理内存页面映射到段内,检测fork创建的任何重复的拷贝,并执行写时复制机制(???zzz)。
page.s文件包括内存页面异常中断(int 14)处理程序,主要用于处理程序由于缺页而引起的页异常中断和访问非法地址而引起的页保护。
memory.c程序包括对内存进行初始化的函数mem_init(),由page.s的内存处理中断过程调用的do_no_page()和do_wp_page()函数。在创建新进程而执行复制进程操作时,即使用该文件中的内存处理函数来分配管理内存空间。
2.8.9编译内核工具程序目录tools
该目录下的build.c程序用于将Linux各个目录中被分别编译生成的目标代码连接合并成一个可运行的内核映象文件image。其具体的功能可参见下一章内容。
2.9内核系统与用户程序的关系
在Linux系统中,内核为应用程序提供了两方面的接口。其一是系统调用接口(在第5章中说明),也即中断调用int 0x80;另一方面是通过内核库函数(在第12章中说明)与内核进行信息交流。内核库函数是基本C函数库libc的组成部分。许多系统调用是作为基本C语言函数库的一部分实现的。
系统调用主要是提供给系统软件直接使用或用于库函数的实现。而一般用户开发的程序则是通过调用象libc等库中的函数来访问内核资源。通过调用这些库中的程序,应用程序代码能够完成各种常用工作,例如,打开和关闭对文件或设备的访问、进行科学计算、出错处理以及访问组和用户标识号ID等系统信息。
系统调用是内核与外界接口的最高层。在内核中,每个系统调用都有一个序列号(在include/linux/unistd.h头文件中定义),并常以宏的形式实现。应用程序不应该直接使用系统调用,因为这样的话,程序的移植性就不好了。因此目前Linux标准库LSB(Linux Standard Base)和许多其它标准都不允许应用程序直接访问系统调用宏。系统调用的有关文档可参见Linux操作系统的在线手册的第2部分。
库函数一般包括C语言没有提供的执行高级功能的用户级函数,例如输入/输出和字符串处理函数。某些库函数只是系统调用的增强功能版。例如,标准I/O库函数fopen和fclose提供了与系统调用open和close类似的功能,但却是在更高的层次上。在这种情况下,系统调用通常能提供比库函数略微好一些的性能,但是库函数却能提供更多的功能,而且更具检错能力。系统提供的库函数有关文档可参见操作系统的在线手册第3部分。
2.10 linux/Makefile文件
从本节起,我们开始对内核源代码文件进行注释。首先注释linux目录下遇到的第一个文件Makefile。后续章节将按照这里类似的描述结构进行注释。
2.10.1功能描述
Makefile文件相当于程序编译过程中的批处理文件。是工具程序make运行时的输入数据文件。只要在含有Makefile的当前目录中键入make命令,它就会依据Makefile文件中的设置对源程序或目标代码文件进行编译、连接或进行安装等活动。
make工具程序能自动地确定一个大程序系统中那些程序文件需要被重新编译,并发出命令对这些程序文件进行编译。在使用make之前,需要编写Makefile信息文件,该文件描述了整个程序包中各程序之间的关系,并针对每个需要更新的文件给出具体的控制命令。通常,执行程序是根据其目标文件进行更新的,而这些目标文件则是由编译程序创建的。一旦编写好一个合适的Makefile文件,那么在你每次修改过程序系统中的某些源代码文件后,执行make命令就能进行所有必要的重新编译工作。make程序是使用Makefile数据文件和代码文件的最后修改时间(last-modificationtime)来确定那些文件需要进行更新,对于每一个需要更新的文件它会根据Makefile中的信息发出相应的命令。在Makefile文件中,开头为'#'的行是注释行。文件开头部分的'='赋值语句定义了一些参数或命令的缩写。
这个Makefile文件的主要作用是指示make程序最终使用独立编译连接成的tools/目录中的build执行程序将所有内核编译代码连接和合并成一个可运行的内核映像文件image。具体是对boot/中的bootsect.s、setup.s使用8086汇编器进行编译,分别生成各自的执行模块。再对源代码中的其它所有程序使用GNU的编译器gcc/gas进行编译,并连接成模块system。再用build工具将这三块组合成一个内核映象文件image.基本编译连接/组合结构如图2-20所示。
2.10.2代码注释
程序2-1 linux/Makefile文件
2.10.3其它信息
Makefile简介
makefile文件是make工具程序的配置文件。Make工具程序的主要用途是能自动地决定一个含有很多源程序文件的大型程序中哪个文件需要被重新编译。makefile的使用比较复杂,这里只是根据上面的makefile文件作些简单的介绍。详细说明请参考GNU make使用手册。
为了使用make程序,你就需要makefile文件来告诉make要做些什么工作。通常,makefile文件会告诉make如何编译和连接一个文件。当明确指出时,makefile还可以告诉make运行各种命令(例如,作为清理操作而删除某些文件)。
make的执行过程分为两个不同的阶段。在第一个阶段,它读取所有的makefile文件以及包含的makefile文件等,记录所有的变量及其值、隐式的或显式的规则,并构造出所有目标对象及其先决条件的一幅全景图。在第二阶段期间,make就使用这些内部结构来确定哪个目标对象需要被重建,并且使用相应的规则来操作。
当make重新编译程序时,每个修改过的C代码文件必须被重新编译。如果一个头文件被修改过了,那么为了确保正确,每一个包含该头文件的C代码程序都将被重新编译。每次编译操作都产生一个与源程序对应的目标文件(object file)。最终,如果任何源代码文件被编译过了,那么所有的目标文件不管是刚编译完的还是以前就编译好的必须连接在一起以生成新的可执行文件。
简单的makefile文件含有一些规则,这些规则具有如下的形式:
目标(target)... :先决条件(prerequisites)...
命令(command)
...
其中'目标'对象通常是程序生成的一个文件的名称;例如是一个可执行文件或目标文件。目标也可以是所要采取活动的名字,比如'清除'('clean')。'先决条件'是一个或多个文件名,是用作产生目标的输入条件。通常一个目标依赖几个文件。而'命令'是make需要执行的操作。一个规则可以有多个命令,每一个命令自成一行。请注意,你需要在每个命令行之前键入一个制表符!这是粗心者常常忽略的地方。
如果一个先决条件通过目录搜寻而在另外一个目录中被找到,这并不会改变规则的命令;它们将被如期执行。因此,你必须小心地设置命令,使得命令能够在make发现先决条件的目录中找到需要的先决条件(???zzz)。这就需要通过使用自动变量来做到。自动变量是一种在命令行上根据具体情况能被自动替换的变量。自动变量的值是基于目标对象及其先决条件而在命令执行前设置的。例如,’$^’的值表示规则的所有先决条件,包括它们所处目录的名称;’$
有时,先决条件还常包含头文件,而这些头文件并不愿在命令中说明。此时自动变量’$
foo.o : foo.c defs.h hack.h
cc -c $(CFLAGS) $< -o $@
其中的’$
为了让make能使用习惯用法来更新一个目标对象,你可以不指定命令,写一个不带命令的规则或者不写规则。此时make程序将会根据源程序文件的类型(程序的后缀)来判断要使用哪个隐式规则。后缀规则是为make程序定义隐式规则的老式方法。(现在这种规则已经不用了,取而代之的是使用更通用更清晰的模式匹配规则)。下面例子就是一种双后缀规则。双后缀规则是用一对后缀定义的:源后缀和目标后缀。相应的隐式先决条件是通过使用文件名中的源后缀替换目标后缀后得到。因此,此时下面的’$

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容,请联系我们,一经查实,本站将立刻删除。
如需转载请保留出处:https://51itzy.com/kjqy/14242.html