分享

内核代码学习==>深入介绍Linux内核(九)

 快乐学习 2006-12-10

深入介绍Linux内核(九)

第五章


5.5 Linux的系统呼叫

5.5.1系统呼叫介面

系统呼叫(通常称为syscalls)是Linux內核与上层应用程式进行交互通信的唯一介面,参见图5-4所示。从对中断机制的說明可知,用戶程式透过直接或间接(透过程式库函数)呼叫中断int 0x80,並在eax寄存器中指定系统呼叫功能号,即可使用內核资源,包括系统硬件资源。 不过通常应用程式都是使用具有标批介面定义的 C 函数库中的函数间接地使用內核的系统呼叫,见图5-19所示。






通常系琉呼叫使用函数形式进行呼叫,因此可带有一个或多个参数。对於系统呼叫执行的结果,它会在返回值中表示出来。通常负值表示错误,而0则表示成功,在出错的情況下,错误的类型码被存放在全域变数errno中。透过呼叫程式库函数perror( ),我们可以列印出该错误码对应应的出错字串资讯。

在linux內核中,每个系统呼叫都具有唯一的一个系统呼叫功能号。这些功能号定义在当include/unistd.h中第62行开始处。例如,write系统呼叫的功能号是4,定义为符号--NR_write这些系统。这些系统呼叫功能号实际上对应於include/linux/sys.h中定义的系统呼叫处理程式指标阵列表sys_call_table[ ]中项的索引值。因此,write( )系统呼叫的处理程式指标就位于该阵列的项4处。

当我们想在自己的程式中使用这些系统呼叫符,需要像下面所示在包括进档“”之前定义符号“__LIBRARY__”。

#define__LIBRARY__
#include

另外,我们从sys_call_table[ ]中可以看出,內核中所有系统呼叫处理函数的名称基本上都是以符号‘sys_’开始的。例如系统呼叫read()在內核原始码中的实现函数就是sys_read( )。


5.5.2系统呼叫处理过程

当应用程式经过程式库函数向內核发出一个中断呼叫int 0x80时,就开始执行一个系统呼叫。其中寄存器eax中存放著系统呼叫号,而攜带的参数可依次存放在寄存器ebx、ecx和edx中。因此Linux 0.12内核中用戶程式能夠向內核最多直接传递三个参数,当然也可以不带参数。处理系统呼叫中断int 0x80的过程是程式kernel/system_call.s中的system_call。


为了方便执行系统呼叫,内核原始码在include/unistd.h档(150-200行)中定义了巨集函数_syscalln( ) ,其中n代表攜带的参数个数,可以分別0至3。因此最多可以直接传递3个参数。若需睪传递大块资料给內核,则可以传递这块资料的指标值。例如对於read()系统呼叫,其定义是:

int read(int fd,char *buf, int n );

若我们在用戶程式中直接执行对应的系统呼叫,那麼该系统呼叫的巨集的形式为:

#define__LIBRARY__
#include
_syscall3(int, read, int, fd, char *, buf, int, n)

因此我们可以在用戶程式直接使用上面的_syscall3( )来执行一个系统呼叫read( ),而不用透过C函数库作仲介。实际上C函数库中函数最终呼叫系统呼叫的形式和这裡给出的完全一样。

对于include/unistd.h中给出的每个系统呼叫巨集,都有2+2*n个参数。其中第1个参数对应系统呼叫返回值的类型;第2个参数是系统呼叫的名称;随后是系统呼叫所攜带参数的类型 名称。这个巨集会被扩展成包含內嵌组合语句的C函数,见如下所示。

int read(int fd,char *buf, int n)
{
long__res;
__asm__volatile (
“int$0x80”
:“=a” ( __res)
: “”(__NR_read),“b”((long) (fd)),“c”((1ong) (buf)),“d”((1ong) (n)));
if ( __res>=0)
return int __res;
errno=- __res;
return -1;
}

可以看出,这个巨集经过展开就是一个读取作业系统呼叫的具体实现。其中使用了嵌入组合语句以功能号_ _NR_read (3)执行了Linux的系统中断呼叫0x80。该中断呼叫在eax(_ _res )寄存器中返回了实际读取的位元组数。若返回的值小于0,则表示此次读取操作出错,于是将出错号反转后存入全域变数errno中,并向呼叫程式返回-1值。

如果有某个系统呼叫需要多於3个参数,那么內核通常採用的方法是直接把这些参数作为一个参数缓冲区块,並把这个缓冲区块的指标作为一个参数传递给內核。因此对於多於3个参数的系统呼叫,我们只需要使用带一个参数的巨集_syscalll( ),把第一个参数的指标传递给內核即可。例如,select( )函数系统呼叫具有5个参数,但我们只需传递其第l个参熟的指标,参见对fs/select.c程式的說明。

当进入內核中的系统呼叫处理程式kernel/sys_call.s后,system_call的代码会写先检查eax中的系统呼叫功能号是否在有效系统呼叫号范围內,然后根据sys_call_table[ ]函数指标表呼叫执行相应的系统呼叫处理程式。

call_sys_call_table(, %eax, 4) //kernel/sys_call.s第99行。

这句组合语句运算元的含义是间接呼叫地址在_sys_call_table + %eax * 4处的函数。由於sys_call_table[ ]指标每项4 立元组,因此这里需要给系统呼叫功能号乘上4。然后用所得到的值从表中获取被呼叫处理函数的位址。

5.5.3Linux系统呼叫的参数传递方式

关于Linux用戶行程向系统中断呼叫过程传递参数方面,Linux系统使用了通用寄存器传递方法,例如寄存器ebx、ecx和edx。这种使用寄存器传递参数方法的一个明显优点就是:当进入系统中断服务程式而保存寄存器值时,这些传递参数的寄存器也被自动地放在了內核态堆栈上,因此用不著再专门对传递参数的寄存器进行特殊处理。这种方法是Linus 当时所知的最简单最快速的参数传递方法。另外还有一种使用Intel CPU提供的系统呼叫门(System Call gate)的参数专递方法,它在行程用戶态堆栈和內核态堆栈自动复制传递的参数。但这种
方法吏用起来步骤比较复杂。

另外,在每个系统呼叫处理函数中应该传递的参数进行验证,以保证所有参数都合法有效。尤其是用戶提供的指标,应该进行严格地审查。以保证指标所指的记忆体区域范围有效,並且具有相应的读写许可权。

5.6系统时间和定时

5.6.1系统时间

为了让作业系统能自动地準确提供当前时间和日期资讯,PC/AT微机系统中提供了用电池供电的真实时钟RT(Real Time)电路支援。通常这部分电路与保存系统资讯的少量CMOS RAM集成在一个晶片上,因此这部分电路被称为RT/CMOS RAM电路。PC/AT微机或其相容机中使用了Motorola公司的MC146818晶片。

有初始化时,Linux 0.12內核透过init/main.c程式中的time_init( )函数读取这块晶片中保存的当前时间和日期资讯,并透过kernel/mktime.c程式中的kernel mktime( )函数转換成从1970年1月1日午夜0时开始计起到当前的以秒为单位的时间,我们称之为UNIX 日历时间。该时间确定了系统开始执行的日历时间,被保存在全域变数startup_time中供内核所有代码使用。用戶程式可以使用系统呼叫stime( )来读取startup_time的值,而超级用戶则可以透过系统呼叫stime()来修改这个系统时间值。


另外,再透过下面介绍的从系统啟动开始计数的系统滴答值jiffies,程式就可以唯一地确定执行时刻的当前时间值。由于每个滴答定时值是10毫秒,因此內核代码中定义了一个巨集来方便代码对当前时间的存取。这个巨集定义在include/linux/sched.h档第192行上,其形主 下:

# define CURRENT_TIME(startup_time + jiffiles/HZ)

其中,HZ = 100,是內核系统时钟频率。当前时间巨集CURRENT_TIME被定义为系统开机时间startup_time加上开机系统执行的时间jiffies/100 。在修改一个档被存取时间或其i节点被修改时间均使用了这个巨集。


5.6.2 系统定时

在Linux 0.12內核的初始化过程中,PC 机的可程式化定时晶片Intel 8253(8254)的计数器通道0被设置成执行在方式3下(方波发生器方式),並且初始计数值LATCH被设置成每隔10毫秒在通道0输出端OUT发出一个方波上升沿。由于8254晶片的时钟输入频率为1.193180MHz,因此初始计数值LATCH=1193180/100,約为11931。由於OUT接腳被连接到可程式化控制晶片的0级上,因此系统每隔10毫秒就会发出一个时钟中断请求(IRQ0)信号。这个时间节拍就是作业系统执行的脈搏,我们称之为l个系统滴答或一个系统时钟週期。因此每经过1个滴答时问,系统就会呼叫一次时钟中断处理程式(timer_interrupt)。

时钟中断处理程式timer_interrupt主要用来透过jiffies变数来累计自系统啟动以来经过的时钟滴答数。每当发生一次时钟中断jiflies值就增加1。然后呼叫C语言函数do_timer( )作进一步的处理。呼叫时所带的参数CPL是从被中断程式的段选择符(保存在堆栈中的CS段寄存器值)中取得当前代码特权级CPL。

do_timer( )函数则根据特权级对当前行程执行时间作累计。如果CPL=0,则表示行程执行在內核态时被中断,因此內核就会把行程的內核态执行时间统计值stime增1,否则把行程用戶态执行时间统计值增1。如果软碟处理程式floppy.c在操作过程中添加过计时器,则对计时器链表进行处理。若某个计时器时间到(递減后等於0),则呼叫该计时器的处理函数。然后对当前行程执行时间进行处理,把当前行程执行时间片減1。时间片是一个行程在被切換掉之前所能持续执行的CPU时间,其单位是上面定义的滴答数。如果行程时间片值递減后还大於0,表示其时间片还沒有用完,于是就退出do_timer( )继续执行当前行程。如果此时行程时间片已经递減为0,表示该行程已经用完了此次使用CPU的时间片,於是程式就会根据被中断程式的级別来确定进一步处理的方法。若被中断的当前行程是工作在用户态的(特权级別大於0),则do_timer()会呼叫调度程式schedule( )切換到其饱行程去执行。如果被中断的当前行程工作在內核态,也即在內核程式中执行时被中断,则do_timer( )会立刻退出。因此这樣的处理方式決定了Linux系统的行程在內核态执行时不会被调度程式切換。即行程在內核态程式中执行时是不可抢占的(nonpreemptive) ¹,但当处於用户程式中执行时则是可以被抢佔的(preemptive)。

¹从Linux2.4内核起,Robert Love开发出了可抢占式的内核升级套件。这使得在内核空间低优先顺序的行程也能被高优先顺序行程抢占,从而能使系统回应效能最大提高200%。参见Robert Love编著的《Linux内核开发》一书。

注意 上述计时器专门用於软碟马达开啟和关闭定时操作。这种计时器类似现代Linux系统中的动态计时器(Dynamic Timer),仅供內核使用。这种计时器可以在非要时动态地建立,而在定时到期时动态地撤销。在Linux 0.12內核中计时器同时最多可以有64个。计时器的处理代码在sched.c程式283- -368行。


5.7 Linux行程控制

程式是一个可执行的档案,而行程(process)是一个执行中的程式实例。利用分时技术,在Linux作业系统上同时可以执行多个行程。分时技术的基本原理是把CPU的执行时间划分成一个个规定长度的时间片(time slice),让每个行程在一个时间片內执行。当行程的时间片用完时系统就利用调度程式切換到另一个行程去执行。因此实际上对於具有单个CPU的机器来說某一时刻只能执行一个行程。但由於每个行程执行的时间片很短(例如15个系统滴答=150毫秒) ,所以表面看来好象所有行程在同时执行著。

对於Linux 0.12內核来讲,系统最多可有64个行程同时存在,除了第一个行程用“手工”建立以外,其余的都是现有行程使用系统呼叫fork建立的新行程,被建立的行程称为子行程(child process),建立者,则称为父行程(parent process)。內核程式使用行程标识号(process ID,pid)来标识每个行程。行程由可执行的指令代码、资料和堆栈区。行程中的代码和资料部分分別对应一个执行档中的代码段、资料段。每个行程只能执行自己的代码和存取自己的资料及堆栈区。行程之间的通信需要透过系统呼叫来进行。对於只有一个CPU的系统,在某一时刻只能有一个行程正在执行。內核透过调度程式分时调度各个行程执行。

我们已经知道,Linux系统中一个行程可以在內核态(kernel mode)或用户态(user mode)下执行,並且分別使用各自独立的內核态堆栈和用戶态堆栈。用戶堆疊用於行程在用戶态下临时保存 呼叫函数的参数、区域变数等资料;內核堆栈则含有內核程式执行函数呼叫时的信息。

另外在Linux內核中,行程通常被称作任务(task) ,而把执行在用戶空间的程式称作行程。本文将在尽量遵守这个预设规则的同时混用这两个术语。

5.7.1 任务资料结构

內核程式透过行程表对行程进行程管理,每个行程在行程表中佔有一项。在Linux系统中,行程表项是一个task_struct任务结构指标。任务资料结构定义在标头档include/linux/sched.h中。有写书上称其为行程控制块PCB(Process Control Block)或行程描述符PD (Processor Descriptor) 。其中保存著用于控制和管理行程的所有信息。主要包括当前执行的状态信息、信号、行程号、父行程号、执行时间累计值、正在使用的档案和本任务的区域描述符以及任务状态段信息。该结构每个栏位的具体含义如下所示。


};


■ long state栏位含有行程的当前状态代号。如果行程正在等待使用CPU或者行程正被执行,那麼state的值是TASK_RUNNING。如果行程正在等待某一事件的发生因而处於空閒状态,那麼state的值就是TASK_INTERRUPTIBLE或者TASK_UNINTERRUPTIBLE。这两个值含义区別在于处于TASK_INTERRUPTIBLE状态的行程能夠被信号喚醒並啟动,而处於TASK_UNINTERRUPTIBLE状态的行程则通常是在直接或间接地等待硬件条件的满足因而不会接受任何信号。TASK_STOPPED状态用於說明一个行程正处於停止状态。例如行程在收到一个相关信号时(例如SIGSTOP、SIGTTIN或SIGTTOU等)或者当行程被另一个行程使用ptrace系统呼叫监控並且控制权在监控行程中时。TASK_ZOMBIE状态用於描述一个行程已经被终止,但其任务资料结构项仍然存在於任务结构表中。一个行程在这些状态之间的转換过程见下面說明。

■ long counter栏位保存著行程在被暂时停止本次执行之前还能执行的时间滴答数,即在正常情況下还需要经过几个系统时钟周期才切換到另一个行程。调度程式会使用行程的counter值来选择下一个要执行的行程,因此counter可以看作是一个行程的动态特性。在一个行程刚被建立时counter的初值等於priority。

■ long priority用於给counter代入初始值。在Linux0.12中这个初值为15个系统时钟週期时间(15个滴答)。当需要时调度程式会使用priority的值为counter代入一个初值,参见sched.c程式和fork.c程式。当然,priority的单位也是时间滴答数。

■ long signal栏位是行程当前所收到信号的点阵图,共32个Bit位,每个Bit位元代表一种信号,信号值二位元偏移值 +l。因此Linux內核最多有32个信号。在每个系统呼叫处理过程的最后,系统会使用该信号点阵图对信号进行预处理。

■ struct sigaction sigaction [32]结构阵列用来保存处理各信号所使用的操作和属性。阵列的每一项对应一个信号。

■ long blocked栏位是行程当前不想处理的信号阻塞点阵图。与signal栏位类似,其每一Bit位代表一种被阻塞的信号。

■ int exit栏位是用来保存程式终止时的退出码。在子行程结束后父行程可以查询它的这个退出码。

■ unsigned long start_code栏位是行程代码在线性空间中的开始位址。

■ unsigned long end_code栏位保存著行程代码的位元组长度值。

■ unsigned long end_data栏位保存著行程的代码长度 + 资料长度的总位元组长度值。

■ unsigned long brk栏位也是行程代码和资料的总位元组长度值(指标值) ,但是还包括未初始化的的资料区bss,参见图13-6。这是brk在一个行程开始执行时內初值。透过修改这个指标,內核可以为行程添加和释放动态分配的记忆体。这通常是透过呼叫malloc( )函数並透过brk系统呼叫由內核进行操作。

■ unsigned long start_stack栏位值指向行程逻位址空间中堆栈的起始处。同樣请参寻图13-6中的堆栈指标位置。

■ long pid是行程标识号,即行程号。它被用来唯一地标识行程。

■ long pgrp是指行程所属行程群组号。

■ long session是行程的会话号,即所属会话的行程好。

■ long leader是会话首行程号。有关行程群组和会话的概念请参见第7章程序列表后的說明。

■ int groups[NGROUPS]是行程所属各个组的群组号阵列。一个行程可属於多个组。

■ task_struct *p_pptr是指向父行程任务结构的指标。

■ task_struct *p_cptr是指向最新子行程任务结构 旨标o

■ task_struct *p_ysptr是指向比自己后建立的相邻行程的指标。

■ task_struct *p_osptr是指向比自己早建立的相邻行程的指标。以上4个指标的关系参见图5-20所示。在Linux 0.11內核的任务数据结构中专门有一个父行程号栏位彻father,但是0.12內核中已经不用。此时我们可以使用行程的pptr->pid来取得父行程的行程号。






■ unsigned short uid是拥有该行程的用戶标识号(用戶id)。

■ unsigned short euid是有效用戶标识号,用于指明存取档的权力。

■ unsigned short suid是保存的用戶标识号。当执行档的设置用戶ID标志。
(set-user-ID)置位元时,suid中保存著执行档的uido。否则suid等於行程的euid。

■ unsigned short gid是用戶所属组标识号(组id)。指明了拥有该行程的用戶群组。

■ unsigned short egid是有效群组标识号,用于指明该群组用戶存取档的许可权。

■ unsigned short sgid是保存的用戶组标识号。当执行档的设置组ID旗标(set-group-ID)置位元时,sgid中保存着执行档的gid。否则sgid等於行程的egid。有关这些用戶号和群组号的描述请参第5章sys.c程式前的概述。

■ long timeout內核定时超时值。

■ long alarm是行程的报警定时值(滴答数) 主系统定时中断中会递減该值。当使用系统呼叫alarm( ) (sched.c第338行) 设置了该值后(参数是以秒为单位,但在保存到alarm栏位中之前內核会把它转换为系统滴答数),那麼在经过了指定的秒数后,该值递減为0,此时系统就会向该行程发送一个SIGALRM信号,预设时该信号会终止程式的执行。当然也可以使用信号捕捉函数(signal( )或signal ())来捕捉该信号进行指定的操作。

■ long utime是累计行程在用戶态执行的时间(滴答数)。

■ long stime是累计行程在系统态(內核态) 执行的时间 (滴答数)。

■ long cutime是累计行程的子行程在用戶态执行的时间 (滴答数)。

■ long cstime是累计行程的子行程內核态执行的时间 (滴答数)。

■ struct start_time是行程生成並开始执行的时刻。

■ struct rlimit rlim[RLIM NLIMITS] 行程资源使用统计阵列。

■ unsigned int flags各行程的标志,0.12內核还未使用。

■ unsigned short used_math是一个标志,指明本行程是否使用了辅助运算器。

■ int tty是行程使用tty终端的子装置号。-1 表示沒有使用。

■ unsigned short umask是行程建立新档时所用的属性遮罩位元,即新建档所设置的存取属性。

■ struct m_inode * pwd是行程的当前工作目录 i节点结构。每个行程都有一个当前工作目錄,用於解析相对路径名,並且可以使用系统呼叫chdir来改变之。

■ struct m_inode * root是行程自己的根目錄 i点节结构。每个行程都可有自己指定的根目錄,用於解析絕对路径名。只有超级用户能透过系统呼叫chroot来修改这个根目錄。

■ struct m_inode * executable是行程执行的执行档在记忆体中i节点结构指标。系统可根据该栏位来判断系统中是否还有另一个行程在执行同一个执行档。如果有的话那麼这个记忆体中i节点参照计数值executable ->i_count会大於1在行程被建立时该栏位被赋予和父行程同一栏位相同的值,即表示正在与父行程执行同一个程式。当在行程中呼叫cxec( )类函数而去执行一个指定的执行档时,该栏位值就会被替換成exec( ) 函数所执行程式的记忆体i节点指标。当行程呼叫exit( )函数而执行退出处理时该栏位所指记忆体i节点的参照引用计数会被減l,並且该栏位将被置空。该栏位的主要作用体现存memory.c程式的share_page()函数中。该函数代码根据行程的executable所指节点的引用计数可判断系统中当前执行的程式是否有多个拷贝存在(起码2个)。若是的话则在他们之间尝试页面共用操作。

■ 在系统初始化时,在第1次呼叫执行execve()牧之前,系统建立的所有任务的executable都是0。这些任务包括任务0、任务1以及任务1直接建立的沒有执行过execve( )的所有任务,即代码直接包含在内核码中的所有任务的executable都是0。因为任务0的代码包含在內核代码中,它不是由系统从档案系统上载入执行的执行档,因此內核代码中固定设置它的executable值为0。另外,建立新行程时,fork( )会复制父行程的任务资料结构,因此任务1的executable也是0。但在执行了exccve( )之后,executable就被赋予了被执行档的记忆体i节点的指标,此后所有任务的该值就均不会为0 了。

■ unsigned long close_on_exec是一个行程档案描述符(档案控制码)点阵图标志。每个Bit位代表一个档案描述符,用於确定在系统呼叫execvc( )时需要关闭的档案描述符(参见include/fcntl.h)。当一个程式使用fork( )函数建立了一个子行程时,通常会在该子行程中呼叫execve( )函数戴入执行另一个新程式。此时子行程将完全被新程式替換掉,並在子行程中开始执行新程式,若一个档案描述符在close_on_exec中的对应Bit位元是置位元状态,那麼在子行程执行execve( )呼叫时对应打开着的档案描述符将被关闭,即在新行程中该档案描述符被关闭。否则该档案描述符将始终处於打开状态。

■ struct file * filp[NR_OPEN]是行程使用的所有打开档的档案结构指标表,最多32项。档案描述符的值即是该结构中的索引值。其中每一项用於档案描述符定位档指标和存取档。

■ struct desc_struct ldt[3]是该行程区域描述符表结构。定义了该任务在虛拟位址空间中的代码段和资料段。其中阵列项0是空项,项l是代码段描述符,项2是资料段(包含数据和堆栈)描述符。

■ struct tss_struct tss是行程的任务状态段TSS(Task State Segment)资讯结构。在任务从执行中被切換出时tss_struct结构保存了当前处理器的所有寄存器值。当任务又被CPU重新执行时,CPU就会利用这些值恢复到任务被切換出时的状态,並开始执行。

当一个行程在执行时,CPU的所有寄存器中的值、行程的状态以及堆栈中的內容被称为该行程的上下文。当內核需要切換( switch)至另一个行程时,它就需要保存当前行程的所有状态,也即保存当前行程的上下文,以便在再次执行该行程时,能夠恢复到切換时的状态执行下去。在Linux中,当前行程上下文均保存在行程的任务资料结构中。在发生中断时,內核就在被中断行程的上下文中,在內核态下执行中断服务常式。但同时会保留所有要用到的资源,以便中断服务结束时能够恢复被中断行程的执行。


5.7.2 行程执行状态

一个程在其生存期內,可处於一组不同的状态下,称为行程状态。见图5-21所示。行程状态保存在行程任务结构的state栏位中。当行程正在等待系统中的资源而处于等待状态时,则称其处於睡眠等待状态,在Linux系统中,睡眠等待状态被分为可中断的和不可中断的等待状态。






执行状态 (TASK_RUNNING)
当行程正在被CPU执行,或已经準备就绪随时可由调度程式执行,则称该行程为处于执行状态(running)。若此时行程沒有被CPU执行,则称其处於就绪执行状态。见图5-21中三个标号为0的状态,行程可以在內核态执行,也可以在用戶态执行。当一个行程在內核代码中执行时,我们称其处於內核执行态,或简称为内核态;当一个行程正在执行用戶自己的代码时,我们称其为处於用戶执行态(用戶态)。当系统资源已经可用时,行程就被喚醒而进入準备执行状态,该状态称为就绪态。这些状态(图中中间一列)在内核中表示方法相同,都被成
为处於TASK_RUNNING状态。当一个新行程刚被建立出后就处於本状态中(最下一个0处)。

可中断睡眠状态 (TASK_INTERRUPTIBLE)

当行程处於可中断等待(睡眠)状态时,系统不会调度该行程执行。当系统產生一个中断或者释放了行程正在等待的资源,或者行程收到一个信号,都可以喚醒行程转換到就绪状态(执行状态)。

不可中断睡眠状态 (TASK_UNINTERRUPTIBLE)

除了不会因为收到信号而被喚醒,该状态与可中断睡眠状态类似。但处於该状态的行程只有被使用wake_up( )函数明确喚醒时才能转換到可执行的就绪状态,该状态通常在行程需要不受干扰地等待或者所等待事件会很快发生时使用。

暂停状态 (TASK_STOPPED)
当行程收到信号SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU时就会进入暂停状态。可向其发送SIGCONT信号让行程转换到可执行状态。行程在除错期间接收到任何信号均会进入该状态。在Linux 0.12中,还未实现对该状态的转換处理。处於该状态的行程将被作为行程终止来处理。

僵死状态(TASK ZOMBIE)
当行程已停止执行,但其父行程还沒有呼叫wait ( )询问其状态时,则称该行程处於僵死状态。为了了让父行程能夠获取其停止其执行的资讯,此时子行程的任务资料结构资讯还需要保留着。一旦父行程呼叫wait ( )取得了子行程的资讯,则处於该状态行程的任务资料结构就会被释放掉。

当一个行程的执行时间片用完,系统就会使用调度程式強制切換到其他的行程去执行。另外,如果行程在內核态执行时需要等待系统的某个资源,此时该行程就会呼叫sleep_on( )或interruptible_sleep_on 自愿地放棄CPU的使用权,而让调度程式去执行其他行程。行程则进入睡眠状态(TASK_UNINTERRUPTIBLE或TASK_INTERRUPTIBLE)。

只有当行程从“內核执行态”转移到“睡眠状态”时,內核才会进行行程切換操作。在內核态下执行的行程不能被其他行程抢占,而且一个行程不能改变另一个行程门状态。为了避免行程切換时造成內核数据错误,內核在执行临界区代码时会禁止一切中断。


5.7.3行程初始化

在boot/目錄中,开机程式把內核从磁碟上载入到记忆体中,並让系统进入保护模式下执行后,就开始执行系统初始化程式init/main.c。该程式首先确定如何分配使用系统实体记忆体,然后呼叫內核各部分的初始化函数分別对记忆体管理、中断处理、区块装置和字元装置、行程管理以及硬盘和软碟硬体进行初始化处理。在完成了这些操作之后,系统各部分已经处于可执行状态。此后程式把自己“手工”移动到任务0(行程0)中执行,並使用fork( )呼叫首次建立出行程l。在行程1中程式将继续进行应用环境的初始化並执行shell登錄程式。而原行程0则会在系统空閒时被调度执行,此时任务0仅执行pause( )系统呼叫,並又会呼叫调度函数。

“移动到任务0中执行”这个过程由巨集move_to_user_mode (include/asm/system.h)完成。它把main.c程式执行流从內核态(特权级0)移动到了用戶态(特权级3)的任务0中继续执行。在移动之前,系统在对调度程式的初始化过程(sched_init ( ))中,首先对任务0的执行环境进行了设置。这包括人工预先设置好任务0资料结构各栏位的值(include/linux/sched.h) 、在全域描述符表中添入任务0的任务状态段(TSS) 描述符和区域描述符表(LDT)的段描述符,並把它们分別载入到任务寄存器tr和区域描述符表寄存器ldtr中。

这裡需要強调的是,內核初始化是一个特殊过程,內核初始化代码也即是任务0的代码。从任务0资料结构中设置的初始数据可知,任务0的代码段和资料段的基址是0、段限长是640KB。而內核代码段和资料段的基址是0、段限长是16MB,因此任务0的代码段和资料段分別包含在内核代码段和资料段中。內核初始化程式main.c也即是任务0中的代码,只是在移动到任务0之前系统正以內核态特权级。执行著main.c程式。巨集move_ to_user_mode的功能就是把执行特权级从內核态的0级变換到用戶态的3级,但是仍然继续执行原来的代码指今流。

在移动到任务0的过程中,巨集move_to_user_mode使用了中断返回指令造成特权级改变的方法。使用这种方法进行控制权转移是由CPU保护机制造成的。CPU允许低级別(如特权级3)代码透过呼叫门或中断、陷阱门来呼叫或转移到高级別代码中执行,但反之则不行。因此内核採用了这种模拟IRET返回低级別代码的方法。该方法的主要思想是在堆栈中构筑中断返回指令需要的內容,把返回位址的段选择符设置成任务0代码段选择符,其特权级为3。此后执行中断返回指令iret时将导致系统CPU从特权级0跳转到外层的特权级3上执行。
参见图5-22所示的特权级发生变化时中断返回堆栈结构示意图。






巨集move_to_user_mode首先往內核堆栈中压入任务。资料段选择符和內核堆栈指标。然后压入标志寄存器內容。最后压入任务0代码段选择符和执行中断返回后需要执行的下一条指令的偏移位置。该偏移多位置是iret后的一条指令处。

当执行iret指令时,CPU把返回位址送入CS: EIP中,同时弹出堆栈中标志寄存器内容。由於CPU判断出目的代码段的特权级是3,与当前內核态的0级不同。于是CPU会把堆栈中的堆栈段选择符和指标弹出到SS : ESP中。由於特权级发生了变化,段寄存器DS、ES、FS和GS的值变得无效,此时CPU会把这些段寄存器清零。因此在执行了iret指令后需要重新载入这些段寄存器。此后,系统就开始以特权级3执行在任务0的代码上。所使用的用戶态堆栈还是原来在移动之前使用的堆栈。而其內核态堆栈则被指定为其任务资料结构所在页面
的顶端开始(PAGE_SIZE + (1ong) &init_task)由於以后在建立新行程时,需要复制任务0的任务资料结构,包括其用戶幻 ”指标,因此要求任务。的用戶
态堆栈在建立任务l (行程1)之前保持“干淨”状态。


5.7.4 建立新行程

Linux系统中建立新行程使用fork( )系统呼叫。所有行程都是透过复制行程0而得至的,都是行程0的子行程。

在建立新行程的过程中,系统首先在任务阵列中找出一个还沒有被任何行程使用的空项(空槽) 。如果系统已经有64个行程在执行,则fork ( )系统呼叫会因为任务阵列表中沒有可用空项而出错返回。然后系统为新建行程在主记忆体区中申请一页记忆体来存放其任务资料结构资讯,并复制当前行程任务资料结构中的所有内容作为新行程任务资料结构的范本。为了防止这个还未处理完成的新建行程被调度函数执行,此时应该立刻将新行程状态置为不可断的等待状态 (TASK_UNINTERRUPTIBLE)。

随后对复制的任务资料结构进行修改。把当前行程设置为新行程的父行程,清除信号点阵图並重定新行程各统计值,並设置初始执行时间片值为15个系统滴答数(150毫秒) 。接著根据当前行程设置任务状态段(TSS)中各寄存器的值。由于建立行程时新行程返回值应为0,所.以需要设置tss.eax = 0。新建行程內核态堆栈指标tss.esp0被设置成新行程任务资料结构所在记忆体页面的顶端,而堆栈段 tss.ss0被设置成內核资料段选择符。tss.1dt被设置为区域表描述符在GDT中的索引值。如果当前行程使用了辅助运算器,则还需要把辅助运算器的完整状态保存到新行程的tss.i387结构中。


此后系统设置新任务的代码和资料段基址、限长,並复制当前行程记忆体分页管理的页表。注意,此时系统並不为新的行程分配实际的实体记忆体页面,而是让它共用其父行程的记忆体页面。只有当父行程或新行程中任意一个有写记忆体操作时,系统才会为执行写操作的行程分配相关的独自使用的记忆体页面。这种处理方式称为写时复制(Copy On Write)技术。

随后,如果父行程中有档案是打的,则应将对应档案的打开次数增加1。接著在GDT中设置新任务的TSS和LDT描述符项,其中基底位址资讯指向新行程任务结构中的tss和ldt。最后再将新任务设置成可执行状态並返回新行程号。

另外请注意,建立一个新的子行程和载入执行一个执行程式档是两个不同的概念。当建立子行程时,它完全复制了父行程代码和资料区,並会在其中执行子行程部分的代码。而执行区块装置上的一个程式时,一般是在子行程中执行exec( )系统呼叫来操作的。在进入exec( )后,子行程原来的代码和资料区就会被清掉(释放) 。待该子行程开始执行新程式时,由於此时內核还沒有从区块装置上载入该程式的代码,CPU就会立刻產生內码表面不存在的異常(Fault) ,此时记忆体管理程式就会从区块装置上载入相应的內码表面,然后CPU又重新执
行引起異常的指令,到此时新程式的代码才真正开始室执行。


5.7.5行程调度

內核中的调度程式用於选择系统中下一个要执行的行程。这种选择执行机制是多工作业系统的基础。调度程式可以看作为在所有处於执行状态的行程之间分配CPU执行时间的管理代码。由前面描述可知,Linux行程是抢佔式的,但被抢佔的行程仍然处於TASK_RUNNING状态,只是暂时沒有被CPU执行。行程的抢佔发生在行程处於用戶态执行阶段,在內核态执行时是不能被抢佔的。

为了能让行程有效地使用系统资源,又能使行程有较快的回应时问,就需要对行程的切换调度採用一定的调度策略。在Linux 0.12中採用了基於优先顺序排队的调度策略。

调度程式
schedule ( )函数首先扫描任务阵列。透过比较每个就绪态(TASK_RUNNING)任务的执行时间递減滴答计数counter的值来确定当前哪个行程执行的时间最少。哪一个的值大,就表示执行时间还不长,于是就选中该行程,並使用任务切換巨集函数切換到该行程执行。

如果此时所有处于TASK_RUNNING状态行程的时间片都已经用完,系统就会根据每个行程的优先权值priority,对系统中所有行程(包括正在睡眠的行程)重新计算每个任务需要执行的时间片值counter。计算的公式是:



这样对于正在睡眠的行程当它们被唤醒时就具有较高的时间片counter值。然后schedule ( )函数重新扫描任务阵列中所有处于TASK_RUNNING状态,重复上述过程,直到选择出一个行程为止。最后呼叫switch_to( )执行实际的行程切換操作。

如果此时沒有其他行程可执行,系统就会选择行程0执行,对於Linux 0.12来說,行程0会呼叫pause( )把自己置为可中断的睡眠状态並再次呼叫schedule( )。不过在调度行程执行时,schedule( )並>不在意行程0处於什麼状态。只要系统空闲就调度行程0执行。


行程切换
每当选择出一个新的可执行行程时,schedule( )函数就会呼叫定义在include/asm/system.h中的switch_to ( )巨集执行实际行程切換操作。该巨集会把CPU的当前行程状态(上下文)替換成新行程的状态。在进行切換之前,switch_to ( )首先检查要切換到的行程是否就是当前行程,如果是则什麼也不做,直接退出。否则就首先把內核全域变数current置为新任务的指标,然后长跳转到新任务的任务状态段TSS组成的位址处,造成CPU执行任务切換操作。此时CPU会把其所有寄存器的状态保存到当前任务寄存器TR中TSS段选择符所指向的当前行
程任务资料结构的tss结构中,然后把新任务状态段选择符所指向的新任务资料结构中tss结构中的寄存器资讯恢复到CPU中,统就正式开始执行新切換的任务了。这个过程可参见图5-23所示。






5.7.6终止行程

当一个行程结束了执行或在半途中终止了执行,那么內核就需要释放该行程所佔用的系统资源。这包括行程执行时打开的档案、申请的记忆体等。

当一个用户程式呼叫exit ( )系统呼叫时,就会执行內核函数do_exit ( )。该函数会首先释放行程代码段和资料段佔用的记忆体页面,关闭行程打开着的所有档,对行程使用的当前工作目錄、根目錄和执行程式的i节点进行同步操作。如果行程有子行程,则让init行程作为其所有子行程的父行程。如果行程是一个会话头行程并且有控制终端,则释放控制终端,並向属于该会话的所有行程发送掛断信号SIGHUP,这通常会终止该会话中的所有行程。然后把行程状态置为僵死状态TASK_ZOMBIE。並向其原父行程发送SIGCHLD信号,通知其某个子行程已经终止,最后do_exit ( )呼叫调度函数去执行其它行程。由此可见在行程被终止时,它的任务资料结构仍然保留著。因为其父行程还需要使用其中的资讯。

在子行程在执行期间,父行程通常使用wait ( )或waitpid ( )函数等待其某个子行程终止。当等待的子行程被终止並处於僵死状态时,父行程就会把子行程执行所使用的时间累加到自己行程中。最终释放已终止子行程任务资料结构所佔用的记忆体页面,並置空子行程在任务阵列中佔用的指标项。

5.8 Linux系统中堆栈的使用方法

本节內容概要描述了Linux內核从开机引导到系统正常执行过程中对堆栈的使用方式。这部分內容的說明与內核代码关系比较密切,可以先跳过。在开始閱读相应代码时再回来仔细研究。

Linux 0.12系统中共使用了四种堆栈。一种是系统开机初始化时临时使用的堆栈;一种己进入保护模式之后提供內核程式初始化使用的堆栈,位於內核代码位址空间固定位置处。该堆栈也是后来任务0使用的用戶态堆栈;另一种是每个任务透过系统呼叫,执行內核程式时使用的堆栈;我们称之为任务的內核态堆栈。每个任务都有自己独立的內核态堆栈;最后一种是任务在用戶态执行的堆栈,位於任务(行程)逻辑位址空间近末端处。

使用多个堆栈或在不同情況下使用不同堆栈的主要原因有两个。首先是由於从真实模式进入保护模式,使得CPU对记忆体定址存取方式发生了变化,因此需要重新调整设置堆栈区域。另外,为了解決不同CPU特权级共用使用堆栈带来的保护问题,执行0级的內核代码和执行3级的用戶代码需要使用不同的堆栈。当一个任务进入內核态执行时,就会使用其TSS段中给出的特权级0的堆栈指标tss.ss ( )、tss.esp( ),即内核堆栈。原用户堆栈指标会被保存在内核堆栈中。而当初从内核态返回用户态时,就会恢复使用用户态的堆栈。下面分别对它们进行说明。


5.8.1初始化陪段

开机初始化时(bootsect.s,setup.s)

当bootsect代码被ROM BIOS开机载入到实体记忆体0x7c00虞睛,并没有设置堆栈,当然程式也没有使用堆栈。直到bootsect被移勤到Ox9000:O处时,才把堆栈段暂存器SS设置为Ox9000,堆栈指标esp暂存器设置为Oxff00,也即堆栈顶端在Ox9000:0xff00处,参见boot/bootsect.s第61、62行。Setup.s程式中也沿用了bootsect设置的堆栈段。这就是系统初始化时临时使用的堆栈。


进入保护模式时fhead.s

从head.s程式起,系统开始正式在保护模式下执行。此时堆栈段被设置为内核资料段(0x10),堆栈指标esp设置成指向user_stack阵列的顶端(参兄head.s,第3l行),保留了1页记忆体(4K)作为堆栈使用。user_stack阵列定义在sched.c的67- -72行,共含有1024个字。它在实体记忆体中的位置示意图可参见下图5-24所示。此时堆栈是内核程式自己使用的堆栈。其中的给出位址是大约值,它们与编译时的实际设置参数有关。这些位址位置是从编译内核时生成的system.map楷案中查到的。






初始化阶段(main.c)

在init/main.c程式中,在执行move_to_user_mode( )代碣把控制权移交给任务0之前,系统一直使用上述堆栈。而在执行过move_to_user_mode( )之後,main.c的代码被“切换”成任务0中孰行。透过执行fork( )系梳呼叫,main.c中的init( )将在任务1中执行,并使用任务1的堆栈。而main( )本身则在被“切换”成为任务0後,仍然继续使用上述内核程式自己的堆栈作为任务0的用户态堆栈。关于任务0所使用堆栈的详细描述後请见后面说明。

5.8.2任务的堆栈

每个任务都有两个堆栈,分别用于用户态和内核态程式的执行,并且分别称为用户态堆栈和内核态堆栈。除了处于不同CPU特权极中,这两个堆栈之间的主要区别在於任移的内核太堆栈很小,所保存的资料量最多不能超过(4096 – 任务资料结构区块)个位元组,大约为3K位元组。而任务的 用户态堆栈却可以在用户的64MB空同内延伸。


在用戶态执行时

每个任务(除了任务0任务1)有自己的64MB位址空间。当一个任务(行程)刚被建立时,它的用户态堆栈指标被设置在其位址空间的靠近末端(64MB顶端)部分。实际上末端部分还要包括执行程式的参数和环境变数,然后才是用戶堆栈空间,见图5-25所示 。应用程式在用戶态下执行时就一直使用这个堆栈。堆栈实际使用的实体记忆则由CPU分页机制确定。由於Linux实现了写时复制功能(Copy on Write),因此在行程被建立后,若该行程及其父行程都沒有使用堆栈,则两者共用同一堆栈对应的实体记忆体页面。只有当其中一个行程执行堆栈写操作(例如push操作)时內核记忆体管理程式才会为写操作行程分配新的记忆体页面。而行程 0和行程1的用戶堆栈比较特殊,见后面說明。







在内核态执行时

每个任务有其自己的内核态堆栈,用於任务在內核代码中执行期间。其所在线性位址中的位置由该任务TSS段中ss0和esp0两个栏位指定。ss0是任务內核态堆栈的段选择符,esp0是堆栈栈底指标。因此每当任务从用戶代码转移进入內核代码中执行时,任务的內核态堆栈总是空的。任务內核态堆栈被设置在位於其任务资料结构所在页面的末端,即与任务的任务资料结构(task_struct)放在同一页面內。这是在建立新任务时,fork( )程式在任务tss段的內核级堆栈栏位(tss.esp0和tss.ss0)中设置的,参见kernel/fork.c,92行:

p->tss.espO = PAGE_SIZE + (1ong)p ;
p->tss.ssO = 0x10 ;

其中p是新任务的任务资料结构指标,tss是任务状态段结构。內核为新任务申请记忆体用作保存其task_struct结构资料,而tss结构(段)是task_struct中的一个栏位。该任务的 内核堆栈段值tss.ss0也被设置成为0x10(即內核资料段选择符),而tss.esp0 则指向保存task_struct结构页面的末端。见图5-26所示。实际上tss.espO被设置指向该页面(外)上一位元组处(图中堆栈底处)。这是因为Intel CPU执行堆栈操作时是先递減堆栈指标esp值,然后在esp指标处保存入堆栈內容。






为什麼从主记忆体区申请得来的用於保存任务资料结构的一页记忆体也能被设置成內核资料段中的资料呢,也即tss.ss0为什麼能被设置成0x10呢? 这是因为用戶內核态堆栈仍然属于内核资料空间。我们可以从內核代码段的长度范围来說明。在head.s程式的末端,分別设置了內核代码段和资料段的描述符,段长度都被设置成16MB。这个长度值是Linux 0.12內核所能支持的最大实体记忆体长度(参见head.s,110行开始的注释)。因此,內核代码可以定址到整个实体记忆体范围中的任何位置,当然也包括主记忆体区。每当任务执行內核程式而需要使用其內核堆栈时,CPU就会利用TSS结构把它的內核态堆栈设置成由tss.ss0和tss.espO这两个值构成。在任务切換时,老任务的內核堆栈指标esp0不会被保存。对CPU来讲,这两个值是唯读的。因此每当一个任务进入內核态执行时,其內核态堆栈总是空。


任务0和任务1的堆栈

任务0(空閒行程idle)和任务1(初始化行程init)的堆栈比较特殊,需要特別予以說明。任务0和任务1的代码段和资料段相同,限长也都是640KB,但它们被映射到不同的线性位址范围中。任务0的段基底位址从线性位址。开始,而任务1的段基底位址从64MB开始。但是它们全都映射到实体位址O- -640KB范围中。这个位址范围也就是內核代码和基本资料所存放的地方,在执行了move_to_user_mode( ),任务O和任务1的內核态堆栈分別位於各自
任务资料结构所在页面的末端,而任务0的用戶态堆栈就是前面进入保护模式后所使用的堆栈,即sched.c的user_stack[]阵列的位置。由于任务1在建立时复制了任务0的用戶堆栈,因此刚开始时任务0和任务l共用使用同一个用戶堆栈空间。但是当任务1开始执行,由于任务1映射到user_stack[]处的页表项被设置成唯读,使得任务l在执行堆栈操作时将会引起写页面異常,从而內核会使用写时复制机制²为任务1另行分配主记忆体区页面作为堆栈空间使用。只有到此时,任务1才开始使用自己独立的用戶堆栈记忆体页面。因此任务0的堆栈需要在任务1实际开始使用之前保持“干淨”,即任务0此时不能使用堆栈,以确保复制的堆栈页面中不含有任务0的资料。

任务0的內核态堆栈是在其人工设置的初始化任务资料结构中指定的,而它的用戶态堆栈是在执行move_to_user_mode( )时,在类此iret返回之前的堆栈中设置的,参见图5-22所示。我们知道,当进行特权级会发生变化的控制权转移时,目的代码会使用新特权级的堆栈,而原特权级代码堆栈指标将保留在新堆栈中。因此这里先把任务0用户堆栈指标压入当前处於特权级O的堆栈中,同时把代码指标也压入堆栈,然后执行IRET指令即可实现把控制权从特权级0的代码转移到特权级3的任务O中。在这个人工设置內容的堆栈中,原esp值被设
置成仍然是user_stack中原来的位置值,而原ss段选择符被设置成0x17,即设置成用戶态区域表LDT中的资料段选择符。然后把任务0代码段选择符0xlf压入堆栈作为堆栈中原CS 段的选择符,把下一条指令的指标作为原EIP压入堆栈。这樣,透过执行IRET指令即可“返回”到任务0的代码中继续执行了。


5.8.3 任务內核态堆栈与用戶态堆栈之间的切換

在Linux 0.12系统中,所有中断服务程式部属於內核代码。如果一个中断產生时任务正在用戶代码中执行,那麼该中断就会引起CPU特权级从3级到O级的变化,此时CPU就会进行用戶态堆栈到內核态堆栈的切換操作。CPU会从当前任务的任务状态段TSS中取得新堆栈的段选择符和偏移值。因为中断服务程式在內核中,属於0级特权级代码,所以48Bit的內核态堆栈指标会从TSS的ss0和espO栏位中获得。在定位了新堆栈(內核态堆栈)之后,CPU就会首先把原用戶态堆栈指标ss和esp压入內核态堆栈,随后把标志寄存器eflags的內容和返回位置cs、eip压入核态堆栈。

內核的系统呼叫是一个软件中断,因此任务呼叫系统呼叫时就会进入內核並执行內核中的中断服务代码。此时內核代码就会使用该任务的內核态堆栈进行操作。同樣,当进入內核程式时,由於特权级別发生了改变(从用戶态转到內核态),用戶态堆栈的堆栈段和堆栈指标以及eflags会被保存在任务的內核态堆栈中。而在执行iret退出內核程式返回到用戶程式时,将恢复用戶态的堆栈和eflags。这个过程见图5-27所示。







如果一个任务正在內核态中执行,那么若CPU回应中断就不再需要进行堆栈切換操作,因为此时该任务执行的內核代码已经在使用內核态堆栈,並且不涉及优先级別的变化,所以CPU 直把eflags和中断返回指标cs、eip压入当前內核态堆栈,然后执行中断服务过程。

5.9 Linux 0.12用的档案系统

內核代码若要正常执行就需要档案系统的支援。用於向內核提供最基本资讯和支援的是根档案系统,即Linux系统引导啟动时,预设使用的档案系统是根档案系统。其中包括作业系统最起码的一些配置档和命令执行程式。对於Linux系统中使用的UNIX类档案系釉 其中主要包括一些规定的目錄、配置档、装置驱动程式、开发程式以及所有其他用戶资料或文字档案等。其中一般都包括以下一些子目錄和档案:

etc/ 目錄主要含有一些系统配置档;
dev/ 含有装置特殊档,用于使用档操作语句操作装置;
bin/ 存放系统执行程式。列如sh、mkfs、fdisk等;
usr/ 存放程式库函数、手册和其他一些文件;
usr/bin 存放用戶常用的普通命令;
var/ 用於存放系统执行时可变的资料或者是日誌等资讯。


存放档案系统的装置就是档案系统装置。比如,对于一般使用的Windows 2000作业系统,硬碟C就是档案系统装置,而硬碟上按一定规则存放的档案就组成档案系统,Windows 2000有NTFS或FAT32等档案系统。而Linux 0.12內核所支援的档案系统是MINIX 1.0档案系统。目前Linux系统上使用最广泛的则是ext2或ext3档案系统。

对於第l章中介绍对于在软碟上执行的Linux 0.12系统,它由简单的2张软碟组成:bootimage磁碟和rootimage磁碟。bootimage是开机啟动Image档,其中主要包括磁片开机磁区代码、作业系统载入程式和內核执行代码。rootimage就是用於向內核提供最基本支援的根档案系统。这两个磁碟合起来就相当於一张可啟动的DOS作业系统碟。

当linux啟动磁碟载入根档案系统时,会根据啟动磁碟上开机磁区第509、51O位元组处一个字(ROOT_DEV)中的根档案系统装置号从指定的装置中载入根档案系统。如果这个装置号是0的话,则表示需要从开机碟所在当前驱动器中载入根档案系统。若该装置号是一个硬碟分区装置号的话,就会从该指定硬碟分区中载入根档案系统。


5.10內核原始码的目錄结构

由於Linux內核是种內核模式的系统,因此,內核中所有的程式几乎都有紧密的关联,它们之间的依赖和呼叫关系非常密切。所以在閱读一个原始码档时往往需要参閱其他相关的档案。因此有必要在开始閱读內核原始码之前,先熟悉一下原始码档的目錄结构和安排。

这裡我们首先列出Linux內核完整的原始码目錄,包括其中的子目錄。然后逐一介绍各个目錄中所包含程式的主要功能,使得整个內核原始码的安排形式能在我们的头脑中建立起一 大概的框架,以便於下一章开始的原始码閱读工作。

当我们使用tar命令将linux-0.12.tar.gz解开时,內核原始码档被放到了linux/目錄中。其中的目录结构见图5-28所示:






该內核版本的原始码目錄中含有14个子目錄,总共包括102个代码档。下面逐个对这些子目錄中的內容进行描述。


5.10.1 内核目录linux
linux目錄是原始码的主目录,在该主目錄中除了包括所有的14个子目錄以外,还含有唯一的一个Makefile档。该档是编译辅助工具软体make的参数配置档。make工具软体的主要用途是透过识別哪些档案已被修改过,从而自动地決定在一个含有多个根源程式档的程式系统中哪些档案需要被重新编译。因此,make工具软体是程式专案的管理软件。

linux目錄下的这个Makefile档还巢状呼叫了所有子目錄中包含的Makefile档,这樣,当linux目錄(包括子目錄)下的任何档被修改过时,make都会对其进行重新编译。因此为了编译整个內核所有的原始码档,只要在linux目錄下执行一次make软体即可。


5.10.2 开机啟动程式目录boot
boot目錄中含有3个组合语言档,是內核原始码档中最先被编译的程式。这3个程式完成的主要功能是当计算机加电时开机內核啟动,将內核代码载入到记忆体中,並做一些进入32位元保护执行方式前的系统初始化工作。其中bootsect.s和setup.s程式需要使用as86 软件来编译,使用的是as86的组合语言格式(与微软的类似),而head.s需要用GNU as来编译,使用的是AT&T格式的组合语言。这两种组合语言在下一章的代码注释裡以及代码列表后面的說明中会有简单的介绍。

Bootsect.s程式是磁碟开机区块程式,编译后会驻留在磁碟的第一个磁区中(开机磁区,O磁轨(柱面),0磁头,第l个磁区)。在PC机加电ROM BIOS自检后,将被BIOS载入到记忆体0x7C00处开始执行。
Setup.s程式主要用于读取机器的硬件配置参数,並把內核模组system移动到适当的记忆体位置处。

Head.s程式会被编译连接在system模组的最前部分,主要进行硬体装置的探测设置和记忆体管理页面的初始设置工作。


5.10.3 档案系统目录S

Linux 0.12內核的档案系统採用了1.0版的MINIX档案系统,这是由於Linux是在MINIX系统上开发的,採用MINIX档案系统便於进行交叉编译,並且可以从MINIX中载入Linux分区。虽然使用的是MINIX档案系统,但Linux对其处理方式与MINIX系统不同。主要的区別在於MINIX对档案系统採用单执行绪处理方式,而Linux则採用了多执行绪方式。由於採用了多执行绪处理方式,Linux程式就必须处理多执行绪带来的竞爭条件、锁死等问题,因此Linux档案系统代码要比MINIX系统的复杂得多。为了避免竞爭条件的发生,Linux系统对资源分配进行了严格地检查,並且在內核模式下执行时,如果任务沒有主动睡眠(呼叫sleep( )),就不让內核切換任务。

Fs/目錄是档案系统实现程式的目錄,共包含18个C语言程式。这些程式之间的主要参照引用关系见 5-29所示图中每个方框代表一个档案,从上到下按基本按引用关系放置。其中各档案名均略去了尾码.c,虛框中是的程式档不属於档案系统,带箭头的线条表示引用关系,粗線条表示有相互引用关系。








由图可以看出,该目錄中程式可以划分成四个部分:高速缓冲区管理、低层档操作、档资料存取和档高层函数,在对本目錄中档案进行注释說明时,我们也将分成这四个部分来描述。

对於档案系统,我们可以将它看成是记忆体高速缓冲区的扩展部分。所有对档案系统中资料的存取,都需要首先读取到高速缓冲区中。本目錄中的程式主要用来管理高速缓冲区中缓冲区块的使用分配和区块装置上的档案系统。管理高速缓冲区的程式是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())来操作来进行,档案系统程式集本身並不直接与区块装置的驱动程式打交道。

在对程式进行注释过程中,我们将另外给出这些档案中各个主要函数之间的呼叫层次关系。


5.10.4 标头档主目錄include

标头档目錄中总共有32个.h标头档。其中主目錄下有13个,asm子目錄中有4个,linux子目錄中有lO个,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标準标头档。定义了各种符号常数和类型,並声明了各种函数。如定义了_IBRARY__,则还包括系统呼叫号和內嵌组合_syscallO( )等。
用戶时间标头档。定义了存取和修改时问结构以及utime( )原型。


体系结构相关标头档子目录include/asm

这些标头档主要定义了一些CPU体系结构密切相关的资料结构、巨集函数和变数。共4个档案。


io标头档。以巨集的嵌入组合语言程式形式定义对io端口操作的函数。
记忆体拷贝标头档。含有memcpy( )嵌入式组合巨集函数。
段操作标头档。定义了有关段寄存器操作的嵌入式组合函数。
系统标头档。定义了设置或修改描述符/中断门等的嵌入式组合巨集。


Linux内核专用标头档子目录include/linux

內核配置标头档。定义键盘语言和硬碟类型(HD_TYPE)可选项。
软盘机标头档。含有软碟控制卡参数的一些定义。
档案系统标头档。定义档案表结构(file,buffer_head,m_inode等)。
硬碟参数标头档。定义存取硬碟寄存器端口,状态码,分区表等信息。
<]inux/head.h> head标头档,定义了段描述符的简单结构,和几个选择符常数。
內核标头档。含有一些內核常用函数的原形定义。
记忆体管理标头档。含有页面大小定义和一些页面释放函数原型。
程式标头档,定义了任务结构task_struct、初始任务0的资料。
还有一些有关描述符参数设置和获取的嵌入式组合函数巨集式。
系统呼叫标头档。含有72个系统呼叫C函数处理程式,以‘sys_’开头。
tty标头档,定义了有关tty_io,串列通信方面的参数、常数。


系统专用资料结构子目录include/sys

档案状态标头档。含有档或档案系统状态结构stat{ }和常数。
定义了行程中执行时间结构tms以及times( )函数原型。
类型标头档。定义了基本的系统资料类型。
系统名称结构标头档o
等待呼叫标头档。定义系统呼叫wait( )核waitpid( )及相关常数符号。


5.10.5 內核初始化程式目錄init

该目錄中仅包含一个档main.c。用于执行內核所有的初始化工作,然后移到用戶模式建立新行程,并在控台装置上执行shell程式。

程式首先根据机器记忆体的多少对缓冲区记忆体容量进行分配,如果还设置了要使用虛拟碟,则在缓冲区记忆体后面也为它留下空间。之后就进行所有硬件的初始化工作,包括人工建立第一个任务(task 0) ,並设置了中断允许标志。在执行从核心态移到用戶态之后,系统第一次呼叫建立行程函数fork( ),建立出一个用於执行init( )的行程,在该子行程中,系统将进行主控台环境设置,並且在生成一个子行程用来执行shell程式。


5.10.6 內核程式主目黪kernel

Linux/kernel目錄中共包含12个代码档和一个Makefile档,另外还有3个子目錄。所有处理任务的程式都保存在kernel/目錄中,其中包括象fork、exit、调度程式以及一些系统呼叫程式等。还包括处理中断異常和陷阱的处理过程。子目錄中包括了低层的装置驱动程式,如get_hd_block和try_write等。由于这些档中代码之间呼叫关系复杂,因此这裡就不详细列出各档之间的引用关系图,但仍然可以进行大概分类,见图5-30所示。







asm.s程式是用於处理系统硬件异常所引起的中断,对各硬件異常的实际处理程式则是在traps.c档中,在各个中断处理过程中,将分別呼叫traps.c中相应的C语言处理函数。

exit.c程式主要包括用於处理行程终止的系统呼叫。包含行程释放、会话(行程组)终止和程式退出处理函数以及杀死行程、终止行程、掛起行程等系统呼叫函数。

fork.c程式给出了sys_fk( )系统呼叫中使用了两个C语言函数:
find_empty_process( )和copy ocess( )。

mktime.c套件程式含一个内核使用的时间函数mktime( ),用於计算从1970年1月l日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系统呼叫(int Ox80)的介面处理过程,实际的处理过程则包含在各系统统呼叫相应的C语言处理函数中,这些处理函数分佈在整个Linux內核代码中。

vsprintf.c程式实现了现在已经归入标準程式库函数中的字串格式化函数。

区块装置驱动程式子目录kernel/blk_drv

通常情況下,用戶是透过档案系统来存取装置的,因此装置驱动程式为档案系统实现了呼叫介面。在使用区块装置时,由於其资料吞吐量大,为了能夠高效率地使用区块装置上的资料,在用戶行程与区块装置之间使用了高速缓冲机制。在存取区块装置上的资料时,系统首先以资料块的形式把区块装置上的资料读入到高速缓冲区中,然后在提供给用戶。blk_drv子目錄共包含4个c档和标头档。标头档blk.h由于是区块装置程式专用的,所以与C档放在一起。这几个档之间的大致关系,见图5-3l所示。







字元装置驱动程式子目錄kernel/chr_drv

字元装置程式子目錄共含有4个C语言程式和2个组合语言程式档。这些档案实现了对序列端口rs-232、串列终端、键盘和主控台终端装置的驱动。图5-32是这些档案之间的大致呼叫层关系。







tty_iov.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。


5.10.7内核程式库函数目录lib

与普通用戶程式不同,內核代码不能使用标準C函数库及其他一些函数库。主要原因是由於完整的C函数库很大。因此在內核原始码中有专门一个lib/目錄提供內核需要用到的一些函数。內核函数库用於为內核初始化程式init/main.c执行在用戶态的行程(行程0、1)提供呼叫支援。它与普通靜态库的实现方法完全一樣。读者可从中了解一般libc函数库的基本组成原理。在lib/目錄中共有12个C语言档,除了一个由tytso编制的malloc.c程式较长以外,其他的程式很短,有的只有一二行代码,实现了一些系统呼叫的介面函数。

这些档中主要包括有退出函数_exi.t( ),关闭档案函数close(fd)、复制档案描述符函数dup( )、档案开启函数open( )、写入档案函数write( )、执行程式函数execve( )、记忆体分配由malloc( )、等待子行程状态函数wait( )、建立会话系统呼叫setsid( )以及在include/string.h中实现的所有字串操作函数。

5.10.8 记忆体管主等程式目錄mm

该目錄包括3个代码档。主要用於管理程式对主记忆体区的使用,实现了行程逻辑位址到線性位址以及線性位址到实体记忆体位址的映射操作,並透过记忆体分页管理机制,在行程的虛拟记忆体页与主记忆体区的实体记忆体页之间建立了对应关系,同时还真正实现了虛拟储存技术。

Linux內核对记忆体的处理使用了分页和分段两种方式。首先是将386的4G虛拟位址空间分割成64个段,每个段64MB。所有內核程式佔用其中第一个段,並且实体位址与该段线位址相同。然后每个任务分配一个段使用。分页机制用於把指定的实体记忆体页面映射到段內,检测fork建立的任何重复的拷贝,並执行写时复制机制。

Page.s档包括记忆体页面異常中断(int 14)处理程式,主要用於处理程式由於缺页而引起的页面中断和存取非法位址而引起的页保护。

Memory.c程式包括记忆体进行初始化的函数mem_init( ),由page.s的记忆体处理中断程序呼叫的do_no_page( )和do_wp_page( )函数。在建立新行程而执行复制行程操作时,即使用该档中的记忆体处理函数来分配管理记忆体空间。

swap.c程式用於管理主记忆体中实体页面和高速二级储存(硬碟)空间之间的页面交換。当主记忆体空间不够用时就可以先把暂时不用的记忆体页面保存到硬碟中。当发生缺页異常时就首先在硬碟中查看要求的页面是否在硬碟交換空间中,若存在则把页面从交換空间直接读入记忆体中。


5.10.9 编译內核具私式目錄

该目錄下的build.c程式用于将Linux各个目錄中被分別编译生成的目标代码连接合併成一个可执行的內核映射档image。其具体的功能可参见下一章內容。


5.11 內核系统与应用程式的关系

在Linux系统中,內核为用户程式提供了两方面的支援。其一是系统呼叫介面(在第5章中說明),也即中断呼叫int 0x80;另一方面是透过开发环境程式库函数或內核程式库函数与內核进行资讯交流。不过內核程式库函数仅供內核建立的任务0和任务l使用,它们最终还是去呼叫系统呼叫。因此內核对所有用戶程式或行程实际上只提供系统呼叫这一种统一的介面。lib/目錄下內核程式库函数代码的实现方法与基本C函数库libc中类似函数的实现方法基本相同,为了使用内核资源,最终都是透过內嵌组合代码呼叫了內核系统呼叫功能,参见图5-4所示。

系统呼叫主要提供给系统软件程式设计或者用於程式库函数的实现。而一般用戶开发的程式则是透过呼叫象libc等库中函数来存取內核资源。这些程式库中的函数或资源通常被称为应用程式编成介面(API) 。其中定义了应用程式使用的一组标準编成介面。透过呼叫这些库中的程式,应用程式码能夠完成各种常用工作,例如,打开和关闭对档案或装置的存取、进行科学计算、出错处理以及存取群组和用戶标识号ID等系统信息。


在UNIX类作业系统中,最为普遍使用的是基於POSIX标準的API介面。Linux当然也不例外。API与系统呼叫的区別在於:为了实现某一应用程式介面标準,例如POSIX,其中的API可以与一个系统呼叫对应,也可能由几个系统呼叫的功能共同实现。当然某些API函数可能根本就不需要使用系统呼叫,即不使用內核功能,因此函数库可看作是实现像POSIX标準的主体介面,应用程式不用管它与系统呼叫之间到底在什麼关系。不管一个作业系统提供的系统呼叫是多麼的不同或有区別,只要它尊循同一个API标準,那麼应用程式就在这些作业系统之问具有可攜性。


系统呼叫是內核与外界介面的最高层。在內核中,每个系统呼叫都有一个序列号(在include/unistd 标头档中定义),並且常以巨集的形式实现,应用程式不应该直接使用系统呼叫,因为这樣的话,程式的移植性就不好了。因此目前Linux标準库LSB(Lir Standard Base)和许多其他标準都不允许应用程式直接存取系统呼叫巨集。系统呼叫的有关文档可参见Linux作业系统的線上手冊的第2部分。


程式库函数一般包括C语言沒有提供的执行进阶功能的用戶级函数,例如输入/输出和字串处理函数。某些程式库函数只是系统呼叫的增強功能版。例如,标準I/O程式库函数fopen fclose提供了与系统呼叫open和close类似的功能,但卻是在更高的层次上。这种情況下,系统呼叫通常能提供比程式库函数略微好一些的性能,但是程式函数卻能提供更多的功能,而且更具检错能力。系统提供的程式库函数有关文档可参见作业系统的線上手冊第3部分。


5.12 linux/Makefile 档

从本节起,我们开始对內核原始码档进行注释。首先注释linux目錄下遇到的第一个档Makefile。后续章节将按照这裡类似的描述结构进行注释。


5.12.1功能描述

Makefile档相当于程式便宜过程中的批次档案。是工具程式make执行时的输入资料档案。只要在含有Makefile的当前目录中键入make命令,它就会依据Makefile档中的设置对根源程式或目标代码档进行编译、连接或进行安装等活动。

make工具程式能[ 地确定一个大程式系统中那些程式档需要被重新编译,並发出命令对这些程式档进行编译。在使用make之前,需要编写Makefile资讯档,该档描述了整个套件程式中各程式之间的关系,並针对每个需要更新的档案给与具体的控制命令。通常,执行程式是根据其目标档进行更新的,而这些目标档则是由编译程序尽力的。一旦编写好一个合适的Makefile档,那麼在你每次修改过程式系统中的某些原始码档后,执行make命令就能进行所有必要的重新编译工作。make程式是使用Makefile资料档案和代码档的最后修改时间
(1ast-modification time)来确定那些档案需要进行更新,对於每一个需要更新的档它会根据Makefile中的讯发出相应的命令。在Makefile档中,开头为‘#’的行是注释行。档案开头部分的‘=’代入语句定义了一些参数或命令的缩写。

这个Makefile档的主要作用是指示make程式最终使用独立编译连接成的tools/目錄中的build执行程式所有內核编译代码连接和合併成一个可执行的內核映射档image。具体是对tools/中的bootsect.s,setup.s使用8086组合器进行编译,分別生成各自的执行模组。再对原始码中的其他所有程式使用GNU的编译器gcc/gas进行编译,並链接模组system。最后再用build工具将这三块组合成一个內核映射档image。Build是由tools/build.c根源程式编译而成的一个独立的执行程式,它本身並沒有被编译链结到內核代码中。基本编译连接/组合结构如图5-33所示。







5.12.2代码注释









本章小结

本章概述了Linux早期作业系统的内核模式和体系结构。首先带出了Linux 0.12內核使用和管理记忆体的方法、內核堆栈态和用戶态堆栈的设置和使用方法、中断机制、系统时钟定时以及行程建立、调度和终止方法。然后根据原始码的目录结构形式,详细地介绍了各个子目錄中代码档的基本功能和层次关系。同时說明了Linux 0.12所使用的目标档格式。最后从Linux內核主目錄下的makefile档着手,开始对內核原始码进行注释。

本章內容可以看作是对Linux 0.12内核重要资讯的归纳說明,因此可作为閱读后续章节的参考內容。


To be continued......

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多