- Unix/Linux
操作系统的体系结构及系统调用 介绍
- 什么是操作系统和系统调用
操作系统是从硬件抽象出来的虚拟机,在该虚拟机上用户可以运行应用程序。它负责直接与硬件交互,向用户程序提供公共服务,并使它们同硬件特性隔离。因为程序不应该依赖于下层的硬件,只有这样应用程序才能很方便的在各种不同的Unix系统之间移动。系统调用是Unix/Linux操作系统向用户程序提供支持的接口,通过这些接口应用程序向操作系统请求服务,控制转向操作系统,而操作系统在完成服务后,将控制和结果返回给用户程序。
- Unix/Linux系统体系结构
一个Unix/Linux
系统分为三个层次:用户、 核心 以及硬件。
其中系统调用是用户程序与核心间的边界,通过系统调用进程可由用户模式转入核心模式,在核心模式下完成一定的服务请求后在返回用户模式。系统调用接口看起来和C程序中的普通函数调用很相似,它们通常是通过库把这些函数调用映射成进入操作系统所需要的原语。
这些操作原语只是提供一个基本功能集,而通过库对这些操作的引用和封装,可以形成丰富而且强大的系统调用库。这里体现了机制与策略相分离的编程思想——系统调用只是提供访问核心的基本机制,而策略是通过系统调用库来体现。
例:execv, execl, execlv, opendir , readdir…
- Unix/Linux运行模式,地址空间和上下文
运行模式(运行态):
一种计算机硬件要运行Unix/Linux系统,至少需要提供两种运行模式:高优先级的核心模式和低优先级的用户模式。
实际上许多计算机都有两种以上的 执行 模式。如:intel 80×86体系结构就有四层执行特权,内层特权最高。Unix只需要两层即可以了:核心运行在高优先级,称之为核心态;其它外围软件包括shell,编辑程序,Xwindow等等都是在低优先级运行,称之为用户态。之所以采取不同的执行模式主要原因时为了 保护 ,由于用户进程在较低的特权级上运行,它们将不能意外或故意的破坏其它进程或内核。程序造成的破坏会被局部化而不影响系统中其它活动或者进程。当用户进程需要完成特权模式下才能完成的某些功能时,必须严格按照系统调用提供接口才能进入特权模式,然后执行调用所提供的有限功能。每种运行态都应该有自己的堆栈。在Linux
中,分为用户栈和核心栈。用户栈包括在用户态执行时函数调用的 参数 、局部变量和其它数据结构。有些系统中专门为全局中断处理提供了中断栈,但是x86中并没有中断栈,中断在当前进程的核心栈中处理。地址空间:
采用特权模式进行保护的根本目的是对地址空间的保护,用户进程不应该能够访问所有的地址空间:只有通过系统调用这种受严格限制的接口,进程才能进入核心态并访问到受保护的那一部分地址空间的数据,这一部分通常是留给操作系统使用。另外,进程与进程之间的地址空间也不应该随便互访。这样,就需要提供一种机制来在一片物理内存上实现同一进程不同地址空间上的保护,以及不同进程之间地址空间的保护。
Unix/Linux中通过虚存管理机制很好的实现了这种保护,在虚存系统中,进程所使用的地址不直接对应物理的存储单元。每个进程都有自己的虚存空间,每个进程有自己的虚拟地址空间,对虚拟地址的引用通过地址转换机制转换成为物理地址的引用。正因为所有进程共享物理内存资源,所以必须通过一定的方法来保护这种共享资源,通过虚存系统很好的实现了这种保护:每个进程的地址空间通过地址转换机制映射到不同的物理存储页面上,这样就保证了进程只能访问自己的地址空间所对应的页面而不能访问或修改其它进程的地址空间对应的页面。
虚拟地址空间分为两个部分:用户空间和系统空间。在用户模式下只能访问用户空间而在核心模式下可以访问系统空间和用户空间。系统空间在每个进程的虚拟地址空间中都是固定的,而且由于系统中只有一个内核实例在运行,因此所有进程都映射到单一内核地址空间。内核中维护全局数据结构和每个进程的一些对象 信息 ,后者包括的信息使得内核可以访问任何进程的地址空间。通过地址转换机制进程可以直接访问当前进程的地址空间(通过MMU),而通过一些 特殊 的方法也可以访问到其它进程的地址空间。尽管所有进程都共享内核,但是系统空间是受保护的,进程在用户态无法访问。进程如果需要访问内核,则必须通过系统调用接口。进程调用一个系统调用时,通过执行一组特殊的指令(这个指令是与平台相关的,每种系统都提供了专门的trap命令,基于x86的Linux中是使用int 指令)使系统进入内核态,并将控制权交给内核,由内核替代进程完成操作。当系统调用完成后,内核执行另一组特征指令将系统返回到用户态,控制权返回给进程。
上下文:
一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。
用户级上下文:正文、数据、用户栈以及共享存储区;
寄存器上下文:程序寄存器(IP),即CPU将执行的下条指令地址,处理机状态寄存器(EFLAGS),栈指针,通用寄存器;
系统级上下文:进程表项(proc结构)和U区,在Linux中这两个部分被合成task_struct,区表及页表(mm_struct , vm_area_struct, pgd, pmd, pte等),核心栈等。
全部的上下文信息组成了一个进程的运行环境。当发生进程调度时,必须对全部上下文信息进行切换,新调度的进程才能运行。进程就是上下文的集合的一个抽象概念。
- 系统调用的功能和分类
系统调用可以看作是一个所有Unix/Linux进程共享的子程序库,但是它是在特权方式下运行,可以存取核心数据结构和它所支持的用户级数据。系统调用的主要功能是使用户可以使用操作系统提供的有关设备管理、文件系统、进程控制进程通讯以及存储管理方面的功能,而不必要了解操作系统的内部结构和有关硬件的细节问题,从而减轻用户负担和保护系统以及提高资源利用率。
系统调用分为两个部分:与文件子系统交互的和进程子系统交互的两个部分。其中和文件子系统交互的部分进一步由可以包括与设备文件的交互和与普通文件的交互的系统调用(open, close, ioctl, create, unlink, . . . );与进程相关的系统调用又包括进程控制系统调用(fork, exit, getpid, . . . ),进程间通讯,存储管理,进程调度等方面的系统调用。
(以i386为例说明)
A.在Linux中系统调用是怎样陷入核心的?
在每种平台上,都有特定的指令可以使进程的执行由用户态转换为核心态,这种指令称作操作系统陷入(operating system trap)。进程通过执行陷入指令后,便可以在核心态运行系统调用代码。
在Linux中是通过软中断来实现这种陷入的,在x86平台上,这条指令是int 0x80。也就是说在Linux中,系统调用的接口是一个中断处理函数的特例。具体怎样通过中断处理函数来实现系统调用的入口将在后面详细介绍。
这样,就需要在系统启动时,对INT 0x80进行一定的初始化,下面将描述其过程:
1.使用汇编子程序setup_idt(linux/arch/i386/kernel/head.S)初始化idt表(中断描述符表),这时所有的入口函数偏移地址都被设为ignore_int
- ( setup_idt:
lea ignore_int,%edx
movl $(__KERNEL_CS << 16),%eax
movw %dx,%ax /* selector = 0x0010 = cs */
movw $0x8E00,%dx /* interrupt gate – dpl=0, present */
lea SYMBOL_NAME(idt_table),%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
ret
selector = __KERNEL_CS, DPL = 0, TYPE = E, P = 1);
2.Start_kernel()(linux/init/main.c)调用trap_init()(linux/arch/i386/kernel/trap.c)函数设置中断描述符表。在该函数里,实际上是通过调用函数set_system_gate(SYSCALL_VECTOR,&system_call)来完成该项的设置的。其中的SYSCALL_VECTOR就是0x80,而system_call则是一个汇编子函数,它即是中断0x80
set_system_gate()是在linux/arch/i386/kernel/trap.S中定义的,在该文件中还定义了几个类似的函数set_intr_gate(), set_trap_gate, set_call_gate()。这些函数都调用了同一个汇编子函数__set_gate(),该函数的作用是设置门描述符。IDT中的每一项都是一个门描述符。
#define _set_gate(gate_addr,type,dpl,addr)
set_gate(idt_table+n,15,3,addr);
门描述符的作用是用于控制转移,其中会包括选择子,这里总是为__KERNEL_CS(指向GDT中的一项段描述符)、入口函数偏移地址、门访问特权级(DPL)以及类型标识(TYPE)。Set_system_gate的DPL为3,表示从特权级3(最低特权级)也可以访问该门,type为15,表示为386中断门。)
- B.与系统调用相关的数据结构
1.系统调用处理函数的函数名的约定
函数名都以“sys_”开头,后面跟该系统调用的名字。例如,系统调用fork()的处理函数名是sys_fork()。
asmlinkage int sys_fork(struct pt_regs regs);
(补充关于asmlinkage的说明)
2.系统调用号(System Call Number)
核心中为每个系统调用定义了一个唯一的编号,这个编号的定义在linux/include/asm/unistd.h中,编号的定义方式如下所示:
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
. . . . . .
用户在调用一个系统调用时,系统调用号号作为参数传递给中断0x80,而该标号实际上是后面将要提到的系统调用表(sys_call_table)的下标,通过该值可以找到相映系统调用的处理函数地址。
3.系统调用表
ENTRY(sys_call_table)
.long SYMBOL_NAME(sys_exit)
.long SYMBOL_NAME(sys_fork)
.long SYMBOL_NAME(sys_read)
.long SYMBOL_NAME(sys_write)
. . . . . .
C.系统调用函数接口是如何转化为陷入命令
- 如前面提到的,系统调用是通过一条陷入指令进入核心态,然后根据传给核心的系统调用号为索引在系统调用表中找到相映的处理函数入口地址。这里将详细介绍这一过程。
由于陷入指令是一条特殊指令,而且依赖与操作系统实现的平台,如在x86中,这条指令是int 0x80,这显然不是用户在编程时应该使用的语句,因为这将使得用户程序难于移植。所以在操作系统的上层需要实现一个对应的系统调用库,每个系统调用都在该库中包含了一个入口点(如我们看到的fork, open, close等等),这些函数对程序员是可见的,而这些库函数的工作是以对应系统调用号作为参数,执行陷入指令int 0x80,以陷入核心执行真正的系统调用处理函数。当一个进程调用一个特定的系统调用库的入口点,正如同它调用任何函数一样,对于库函数也要创建一个栈帧。而当进程执行陷入指令时,它将处理机状态转换到核心态,并且在核心栈执行核心代码。
这里给出一个示例(linux/include/asm/unistd.h):
#define _syscallN(type, name, type1, arg1, type2, arg2, . . . ) \
type name(type1 arg1,type2 arg2) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2))); \
. . . . . .
__syscall_return(type,__res); \
}
在执行一个系统调用库中定义的系统调用入口函数时,实际执行的是类似如上的一段代码。这里牵涉到一些gcc的嵌入式汇编语言,不做详细的介绍,只简单说明其意义:
其中__NR_##name是系统调用号,如name == ioctl,则为__NR_ioctl,它将被放在寄存器eax中作为参数传递给中断0x80的处理函数。而系统调用的其它参数arg1, arg2, …则依次被放入ebx, ecx, . . .等通用寄存器中,并作为系统调用处理函数的参数,这些参数是怎样传入核心的将会在后面介绍。
下面将示例说明:
int func1()
{
int fd, retval;
fd = open(filename, ……);
……
ioctl(fd, cmd, arg);
. . .
}
func2()
{
int fd, retval;
fd = open(filename, ……);
……
__asm__ __volatile__(\
"int $0x80\n\t"\
:"=a"(retval)\
:"0"(__NR_ioctl),\
"b"(fd),\
"c"(cmd),\
"d"(arg));
}
这两个函数在Linux/x86上运行的结果应该是一样的。
若干个库函数可以映射到同一个系统调用入口点。系统调用入口点对每个系统调用定义其真正的语法和语义,但库函数通常提供一个更方便的接口。如系统调用exec有集中不同的调用方式:execl, execle,等,它们实际上只是同一系统调用的不同接口而已。对于这些调用,它们的库函数对它们各自的参数加以处理,来实现各自的特点,但是最终都被映射到同一个核心入口点。
D.系统调用陷入内核后作何初始化处理
在这一部分,我们将介绍INT 0x80的处理函数system_call。
思考一下就会发现,在调用前和调用后执行态完全不相同:前者是在用户栈上执行用户态程序,后者在核心栈上执行核心态代码。那么,为了保证在核心内部执行完系统调用后能够返回调用点继续执行用户代码,必须在进入核心态时保存时往核心中压入一个上下文层;在从核心返回时会弹出一个上下文层,这样用户进程就可以继续运行。
那么,这些上下文信息是怎样被保存的,被保存的又是那些上下文信息呢?这里仍以x86为例说明。
在执行INT指令时,实际完成了以下几条操作:
1.由于INT指令发生了不同优先级之间的控制转移,所以首先从TSS(任务状态段)中获取高优先级的核心堆栈信息(SS和ESP);2.把低优先级堆栈信息(SS和ESP)保留到高优先级堆栈(即核心栈)中;
3.把EFLAGS,外层CS,EIP推入高优先级堆栈(核心栈)中。
4.通过IDT加载CS,EIP(控制转移至中断处理函数)
#define SAVE_ALL \
pushl %es; \
pushl %ds; \
pushl %eax; \
pushl %ebp; \
pushl %edi; \
pushl %esi; \
pushl %edx; \
pushl %ecx; \
pushl %ebx; \
movl $(__KERNEL_DS),%edx; \
movl %edx,%ds; \
movl %edx,%es;
ENTRY(system_call)
SAVE_ALL
GET_CURRENT(%ebx)
cmpl $(NR_syscalls),%eax
jae badsys
testb $0x20,flags(%ebx) # PF_TRACESYS
jne tracesys
call *SYMBOL_NAME(sys_call_table)(,%eax,4)
在这里所做的所有工作是:
1.保存EAX寄存器,因为在SAVE_ALL中保存的EAX寄存器会被调用的返回值所覆盖;
2.调用SAVE_ALL保存寄存器上下文;
3.判断当前调用是否是合法系统调用(EAX是系统调用号,它应该小于NR_syscalls);
4.如果设置了PF_TRACESYS标志,则跳转到syscall_trace,在那里将会把当前进程挂起并向其父进程发送SIGTRAP
5.如果没有设置PF_TRACESYS标志,则跳转到该系统调用的处理函数入口。这里是以EAX(即前面提到的系统调用号)作为偏移,在系 统调用表sys_call_table中查找处理函数入口地址,并跳转到该入口地址。
1.GET_CURRENT宏
- #define GET_CURRENT(reg)
movl %esp, reg;
andl $-8192, reg;
其作用是取得当前进程的task_struct结构的指针返回到reg中,因为在Linux中核心栈的位置是task_struct之后的两个页面处(8192bytes),所以此处把栈指针与-8192则得到的是task_struct结构指针,而task_struct中偏移为4的位置是成员flags,在这里指令testb $0x20,flags(%ebx)
2.堆栈中的参数
正如前面提到的,SAVE_ALL是系统调用参数的传入过程,当执行完SAVE_ALL并且再由CALL指令调用其处理函数时,堆栈的结构应该如上图所示。这时的堆栈结构看起来和执行一个普通带参数的函数调用是一样的,参数在堆栈中对应的顺序是(arg1, ebx),(arg2, ecx),(arg3, edx). . . . . .,这正是SAVE_ALL压栈的反顺序,这些参数正是用户在使用系统调用时试图传送给核心的参数。下面是在核心的调用处理函数中使用参数的两种典型方法:
asmlinkage int sys_fork(struct pt_regs regs);
asmlinkage int sys_open(const char * filename, int flags, int mode);
在sys_fork中,把整个堆栈中的内容视为一个struct pt_regs类型的参数,该参数的结构和堆栈的结构是一致的,所以可以使用堆栈中的全部信息。而在sys_open中参数filename, flags, mode正好对应与堆栈中的ebx, ecx, edx的位置,而这些寄存器正是用户在通过C库调用系统调用时给这些参数指定的寄存器。
__asm__ __volatile__(\
"int $0x80\n\t"\
:"=a"(retval)\
:"0"(__NR_open),\
"b"(filename),\
"c"(flags),\
"d"(mode));
3.核心如何使用用户空间的参数
ENTRY(gdt_table)
.quad 0x0000000000000000/* not used */
.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */
.quad 0x0000000000000000 /* not used */
.quad 0xc0c39a000000ffff /* 0x10 kernel 1GB code at 0xC0000000 */
.quad 0xc0c392000000ffff /* 0x18 kernel 1GB data at 0xC0000000 */
.quad 0x00cbfa000000ffff /* 0x23 user 3GB code at 0x00000000 */
.quad 0x00cbf2000000ffff /* 0x2b user 3GB data at 0x00000000 *
在2.0版的内核中SAVE_ALL宏定义还有这样几条语句:
"movl $" STR(KERNEL_DS) ",%edx\n\t"
"mov %dx,%ds\n\t"
"mov %dx,%es\n\t"
"movl $" STR(USER_DS) ",%edx\n\t"
"mov %dx,%fs\n\t"
"movl $0,%edx\n\t"
调用返回的过程要做的工作比其响应过程要多一些,这些工作几乎是每次从核心态返回用户态都需要做的,这里将简要的说明:
1.判断有没有软中断,如果有则跳转到软中断处理;
2.判断当前进程是否需要重新调度,如果需要则跳转到调度处理;
3.如果当前进程有挂起的信号还没有处理,则跳转到信号处理;
4.使用用RESTORE_ALL来弹出所有被SAVE_ALL压入核心栈的内容并且使用iret返回用户态。
F.实例介绍
这里实现的系统调用hello仅仅是在控制台上打印一条语句,没有任何功能。
1.修改linux/include/i386/unistd.h,在里面增加一条语句:
- #define __NR_hello ???(这个数字可能因为核心版本不同而不同)
2.在某个合适的目录中(如:linux/kernel)增加一个hello.c,修改该目录下的Makefile(把相映的.o文件列入Makefile中就可以了)。
3.编写hello.c
. . . . . .
asmlinkage int sys_hello(char * str)
{
printk(“My syscall: hello, I know what you say to me: %s ! \n”, str);
return 0;
}
4.修改linux/arch/i386/kernel/entry.S,在里面增加一条语句:
ENTRY(sys_call_table)
. . . . . .
.long SYMBOL_NAME(sys_hello)
并且修改:
.rept NR_syscalls-??? /* ??? = ??? +1 */
.long SYMBOL_NAME(sys_ni_syscall)
5.在linux/include/i386/中增加hello.h,里面至少应包括这样几条语句:
#ifdef __KERNEL
#else
inline _syscall1(int, hello, char *, str);
#endif
这样就可以使用系统调用hello了
- 1. 进程相关的系统调用
Fork & vfork & clone
进程是一个指令执行流及其执行环境,其执行环境是一个系统资源的集合,这些资源在Linux中被抽象成各种数据对象:进程控制块、虚存空间、文件系统,文件I/O、信号处理函数。所以创建一个进程的过程就是这些数据对象的创建过程。
在调用系统调用fork创建一个进程时,子进程只是完全复制父进程的资源,这样得到的子进程独立于父进程,具有良好的并发性,但是二者之间的通讯需要通过专门的通讯机制,如:pipe,fifo,System V IPC机制等,另外通过fork创建子进程系统开销很大,需要将上面描述的每种资源都复制一个副本。这样看来,fork是一个开销十分大的系统调用,这些开销并不是所有的情况下都是必须的,比如某进程fork出一个子进程后,其子进程仅仅是为了调用exec执行另一个执行文件,那么在fork过程中对于虚存空间的复制将是一个多余的过程(由于Linux中是采取了copy-on-write技术,所以这一步骤的所做的工作只是虚存管理部分的复制以及页表的创建,而并没有包括物理也面的拷贝);另外,有时一个进程中具有几个独立的计算单元,可以在相同的地址空间上基本无冲突进行运算,但是为了把这些计算单元分配到不同的处理器上,需要创建几个子进程,然后各个子进程分别计算最后通过一定的进程间通讯和同步机制把计算结果汇总,这样做往往有许多格外的开销,而且这种开销有时足以抵消并行计算带来的好处。
这说明了把计算单元抽象到进程上是不充分的,这也就是许多系统中都引入了线程的概念的原因。在讲述线程前首先介绍以下vfork系统调用,vfork系统调用不同于fork,用vfork创建的子进程共享地址空间,也就是说子进程完全运行在父进程的地址空间上,子进程对虚拟地址空间任何数据的修改同样为父进程所见。但是用vfork创建子进程后,父进程会被阻塞直到子进程调用exec或exit。这样的好处是在子进程被创建后仅仅是为了调用exec执行另一个程序时,因为它就不会对父进程的地址空间有任何引用,所以对地址空间的复制是多余的,通过vfork可以减少不必要的开销。
在Linux中, fork和vfork都是调用同一个核心函数
do_fork(unsigned long clone_flag, unsigned long usp, struct pt_regs)
其中clone_flag包括CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND, CLONE_PID,CLONE_VFORK等等标志位,任何一位被置1了则表明创建的子进程和父进程共享该位对应的资源。所以在vfork的实现中,cloneflags = CLONE_VFORK | CLONE_VM | SIGCHLD,这表示子进程和父进程共享地址空间,同时do_fork会检查CLONE_VFORK,如果该位被置1了,子进程会把父进程的地址空间锁住,直到子进程退出或执行exec时才释放该锁。
在讲述clone系统调用前先简单介绍线程的一些概念。
线程是在进程的基础上进一步的抽象,也就是说一个进程分为两个部分:线程集合和资源集合。线程是进程中的一个动态对象,它应该是一组独立的指令流,进程中的所有线程将共享进程里的资源。但是线程应该有自己的私有对象:比如程序计数器、堆栈和寄存器上下文。
线程分为三种类型:
内核线程、轻量级进程和用户线程。
内核线程:
它的创建和撤消是由内核的内部需求来决定的,用来负责执行一个指定的函数,一个内核线程不需要和一个用户进程联系起来。它共享内核的正文段核全局数据,具有自己的内核堆栈。它能够单独的被调度并且使用标准的内核同步机制,可以被单独的分配到一个处理器上运行。内核线程的调度由于不需要经过态的转换并进行地址空间的重新映射,因此在内核线程间做上下文切换比在进程间做上下文切换快得多。
轻量级进程:
轻量级进程是核心支持的用户线程,它在一个单独的进程中提供多线程控制。这些轻量级进程被单独的调度,可以在多个处理器上运行,每一个轻量级进程都被绑定在一个内核线程上,而且在它的生命周期这种绑定都是有效的。轻量级进程被独立调度并且共享地址空间和进程中的其它资源,但是每个LWP都应该有自己的程序计数器、寄存器集合、核心栈和用户栈。
用户线程:
用户线程是通过线程库实现的。它们可以在没有内核参与下创建、释放和管理。线程库提供了同步和调度的方法。这样进程可以使用大量的线程而不消耗内核资源,而且省去大量的系统开销。用户线程的实现是可能的,因为用户线程的上下文可以在没有内核干预的情况下保存和恢复。每个用户线程都可以有自己的用户堆栈,一块用来保存用户级寄存器上下文以及如信号屏蔽等状态信息的内存区。库通过保存当前线程的堆栈和寄存器内容载入新调度线程的那些内容来实现用户线程之间的调度和上下文切换。
内核仍然负责进程的切换,因为只有内核具有修改内存管理寄存器的权力。用户线程不是真正的调度实体,内核对它们一无所知,而只是调度用户线程下的进程或者轻量级进程,这些进程再通过线程库函数来调度它们的线程。当一个进程被抢占时,它的所有用户线程都被抢占,当一个用户线程被阻塞时,它会阻塞下面的轻量级进程,如果进程只有一个轻量级进程,则它的所有用户线程都会被阻塞。
明确了这些概念后,来讲述Linux的线程和clone系统调用。
在许多实现了MT的操作系统中(如:Solaris,Digital Unix等), 线程和进程通过两种数据结构来抽象表示: 进程表项和线程表项,一个进程表项可以指向若干个线程表项, 调度器在进程的时间片内再调度线程。 但是在Linux中没有做这种区分, 而是统一使用task_struct来管理所有进程/线程,只是线程与线程之间的资源是共享的,这些资源可是是前面提到过的:虚存、文件系统、文件I/O以及信号处理函数甚至PID中的几种。
也就是说Linux中,每个线程都有一个task_struct,所以线程和进程可以使用同一调度器调度。其实Linux核心中,轻量级进程和进程没有质上的差别,因为Linux中进程的概念已经被抽象成了计算状态加资源的集合,这些资源在进程间可以共享。如果一个task独占所有的资源,则是一个HWP,如果一个task和其它task共享部分资源,则是LWP。
clone系统调用就是一个创建轻量级进程的系统调用:
int clone(int (*fn)(void * arg), void *stack, int flags, void * arg);
其中fn是轻量级进程所执行的过程,stack是轻量级进程所使用的堆栈,flags可以是前面提到的CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND,CLONE_PID的组合。Clone 和fork,vfork在实现时都是调用核心函数do_fork。
do_fork(unsigned long clone_flag, unsigned long usp, struct pt_regs);
和fork、vfork不同的是,fork时clone_flag = SIGCHLD;
vfork时clone_flag = CLONE_VM | CLONE_VFORK | SIGCHLD;
而在clone中,clone_flag由用户给出。
下面给出一个使用clone的例子。
Void * func(int arg)
{
. . . . . .
}
int main()
{
int clone_flag, arg;
. . . . . .
clone_flag = CLONE_VM | CLONE_SIGHAND | CLONE_FS |
CLONE_FILES;
stack = (char *)malloc(STACK_FRAME);
stack += STACK_FRAME;
retval = clone((void *)func, stack, clone_flag, arg);
. . . . . .
}
看起来clone的用法和pthread_create有些相似,两者的最根本的差别在于clone是创建一个LWP,对核心是可见的,由核心调度,而pthread_create通常只是创建一个用户线程,对核心是不可见的,由线程库调度。
Nanosleep & sleep
sleep和nanosleep都是使进程睡眠一段时间后被唤醒,但是二者的实现完全不同。
Linux中并没有提供系统调用sleep,sleep是在库函数中实现的,它是通过调用alarm来设定报警时间,调用sigsuspend将进程挂起在信号SIGALARM上,sleep只能精确到秒级上。
nanosleep则是Linux中的系统调用,它是使用定时器来实现的,该调用使调用进程睡眠,并往定时器队列上加入一个time_list型定时器,time_list结构里包括唤醒时间以及唤醒后执行的函数,通过nanosleep加入的定时器的执行函数仅仅完成唤醒当前进程的功能。系统通过一定的机制定时检查这些队列(比如通过系统调用陷入核心后,从核心返回用户态前,要检查当前进程的时间片是否已经耗尽,如果是则调用schedule()函数重新调度,该函数中就会检查定时器队列,另外慢中断返回前也会做此检查),如果定时时间已超过,则执行定时器指定的函数唤醒调用进程。当然,由于系统时间片可能丢失,所以nanosleep精度也不是很高。
alarm也是通过定时器实现的,但是其精度只精确到秒级,另外,它设置的定时器执行函数是在指定时间向当前进程发送SIGALRM信号。
2.存储相关的系统调用
在讲述文件映射的概念时,不可避免的要牵涉到虚存(SVR 4的VM)。实际上,文件映射是虚存的中心概念,文件映射一方面给用户提供了一组措施,似的用户将文件映射到自己地址空间的某个部分,使用简单的内存访问指令读写文件;另一方面,它也可以用于内核的基本组织模式,在这种模式种,内核将整个地址空间视为诸如文件之类的一组不同对象的映射。
Unix中的传统文件访问方式是,首先用open系统调用打开文件,然后使用read,write以及lseek等调用进行顺序或者随即的I/O。这种方式是非常低效的,每一次I/O操作都需要一次系统调用。另外,如果若干个进程访问同一个文件,每个进程都要在自己的地址空间维护一个副本,浪费了内存空间。而如果能够通过一定的机制将页面映射到进程的地址空间中,也就是说首先通过简单的产生某些内存管理数据结构完成映射的创建。当进程访问页面时产生一个缺页中断,内核将页面读入内存并且更新页表指向该页面。而且这种方式非常方便于同一副本的共享。
下面给出以上两种方式的对比图:
VM是面向对象的方法设计的,这里的对象是指内存对象:内存对象是一个软件抽象的概念,它描述内存区与后备存储之间的映射。系统可以使用多种类型的后备存储,比如交换空间,本地或者远程文件以及帧缓存等等。VM系统对它们统一处理,采用同一操作集操作,比如读取页面或者回写页面等。每种不同的后备存储都可以用不同的方法实现这些操作。这样,系统定义了一套统一的接口,每种后备存储给出自己的实现方法。
这样,进程的地址空间就被视为一组映射到不同数据对象上的的映射组成。所有的有效地址就是那些映射到数据对象上的地址。这些对象为映射它的页面提供了持久性的后备存储。映射使得用户可以直接寻址这些对象。
值得提出的是,VM体系结构独立于Unix系统,所有的Unix系统语义,如正文,数据及堆栈区都可以建构在基本VM系统之上。同时,VM体系结构也是独立于存储管理的,存储管理是由操作系统实施的,如:究竟采取什么样的对换和请求调页算法,究竟是采取分段还是分页机制进行存储管理,究竟是如何将虚拟地址转换成为物理地址等等(Linux中是一种叫Three Level Page Table的机制),这些都与内存对象的概念无关。
下面介绍Linux中VM的实现。
如下图所示,一个进程应该包括一个mm_struct(memory manage struct),该结构是进程虚拟地址空间的抽象描述,里面包括了进程虚拟空间的一些管理信息:start_code, end_code, start_data, end_data, start_brk, end_brk等等信息。另外,也有一个指向进程虚存区表(vm_area_struct :virtual memory area)的指针,该链是按照虚拟地址的增长顺序排列的。
在Linux进程的地址空间被分作许多区(vma),每个区(vma)都对应虚拟地址空间上一段连续的区域,vma是可以被共享和保护的独立实体,这里的vma就是前面提到的内存对象。这里给出vm_area_struct的结构,其中,前半部分是公共的,与类型无关的一些数据成员,如:指向mm_struct的指针,地址范围等等,后半部分则是与类型相关的成员,其中最重要的是一个指向vm_operation_struct向量表的指针vm_ops,vm_pos向量表是一组虚函数,定义了与vma类型无关的接口。每一个特定的子类,即每种vma类型都必须在向量表中实现这些操作。这里包括了:open, close, unmap, protect, sync, nopage, wppage, swapout这些操作。
struct vm_area_struct {
/*公共的,与vma类型无关的 */
unsigned long vm_start;
unsigned long vm_end;
struct vm_area_struct *vm_next;
pgprot_t vm_page_prot;
unsigned long vm_flags;
short vm_avl_height;
struct vm_area_struct * vm_avl_left;
struct vm_area_struct * vm_avl_right;
struct vm_area_struct *vm_next_share;
struct vm_area_struct **vm_pprev_share;
/* 与类型相关的 */
struct vm_operations_struct * vm_ops;
unsigned long vm_pgoff;
struct file * vm_file;
unsigned long vm_raend;
void * vm_private_data;
vm_ops: open, close, no_page, swapin, swapout . . . . . .
Mmap系统调用的实现过程是:
1.先通过文件系统定位要映射的文件;
2.权限检查,映射的权限不会超过文件打开的方式,也就是说如果文件是以只读方式打开,那么则不允许建立一个可写映射;
3.创建一个vma对象,并对之进行初始化;
4.调用映射文件的mmap函数,其主要工作是给vm_ops向量表赋值;
5.把该vma链入该进程的vma链表中,如果可以和前后的vma合并则合并;
6.如果是要求VM_LOCKED(映射区不被换出)方式映射,则发出缺页请求,把映射页面读入内存中;
该调用可以看作是mmap的一个逆过程。它将进程中从start开始length长度的一段区域的映射关闭,如果该区域不是恰好对应一个vma,则有可能会分割几个或几个vma。
Msync(void * start, size_t length, int flags) :
把映射区域的修改回写到后备存储中。因为munmap时并不保证页面回写,如果不调用msync,那么有可能在munmap后丢失对映射区的修改。其中flags可以是MS_SYNC, MS_ASYNC, MS_INVALIDATE,MS_SYNC要求回写完成后才返回,MS_ASYNC发出回写请求后立即返回,MS_INVALIDATE使用回写的内容更新该文件的其它映射。
该系统调用是通过调用映射文件的sync函数来完成工作的。
brk(void * end_data_segement):
将进程的数据段扩展到end_data_segement指定的地址,该系统调用和mmap的实现方式十分相似,同样是产生一个vma,然后指定其属性。不过在此之前需要做一些合法性检查,比如该地址是否大于mm->end_code,end_data_segement和mm->brk之间是否还存在其它vma等等。通过brk产生的vma映射的文件为空,这和匿名映射产生的vma相似,关于匿名映射不做进一步介绍。我们使用的库函数malloc就是通过brk实现的,通过下面这个例子很容易证实这点:
main()
{
char * m, * n;
int size;
m = (char *)sbrk(0);
printf("sbrk addr = %08lx\n", m);
do {
n = malloc(1024);
printf("malloc addr = %08lx\n", n);
m = (char *)sbrk(0);
}
malloc addr = 08049be0
malloc addr = 08049fe8
malloc addr = 0804a3f0
new sbrk addr = 0804b000
3.进程间通信(IPC)
- 进程间通讯可以通过很多种机制,包括signal, pipe, fifo, System V IPC, 以及socket等等,前几种概念都比较好理解,这里着重介绍关于System V IPC。
System V IPC包括三种机制:message(允许进程发送格式化的数据流到任意的进程)、shared memory(允许进程间共享它们虚拟地址空间的部分区域)和semaphore(允许进程间同步的执行)。
操作系统核心中为它们分别维护着一个表,这三个表是系统中所有这三种IPC对象的集合,表的索引是一个数值ID,进程通过这个ID可以查找到需要使用的IPC资源。进程每创建一个IPC对象,系统中都会在相应的表中增加一项。之后其它进程(具有许可权的进程)只要通过该IPC对象的ID则可以引用它。
IPC对象必须使用IPC_RMID命令来显示的释放,否则这个对象就处于活动状态,甚至所有的使用它的进程都已经终止。这种机制某些时候十分有用,但是也正因为这种特征,使得操作系统内核无法判断IPC对象是被用户故意遗留下来供将来其它进程使用还是被无意抛弃的。
Linux中只提供了一个系统调用接口ipc()来完成所有System V IPC操作,我们常使用的是建立在该调用之上的库函数接口。对于这三种IPC,都有很相似的三种调用:xxxget, (msgsnd, msgrcv)|semopt | (shmat, shmdt), xxxctl
Xxxget:获取调用,在系统中申请或者查询一个IPC资源,返回值是该IPC对象的ID,该调用类似于文件系统的open, create调用;
Xxxctl:控制调用,至少包括三种操作:XXX_RMID(释放IPC对象), XXX_STAT(查询状态), XXX_SET(设置状态信息);
(msgsnd, msgrcv) | Semopt | (shmat, shmdt)|:操作调用,这些调用的功能随IPC对象的类型不同而有较大差异。
4.文件系统相关的调用
文件是用来保存数据的,而文件系统则可以让用户组织,操纵以及存取不同的文件。内核允许用户通过一个严格定义的过程性接口与文件系统进行交互,这个接口对用户屏蔽了文件系统的细节,同时指定了所有相关系统调用的行为和语义。Linux支持许多中文件系统,如ext2,msdos, ntfs, proc, dev, ufs, nfs等等,这些文件系统都实现了相同的接口,因此给应用程序提供了一致性的视图。但每种文件系统在实现时可能对某个方面加以了一定的限制。如:文件名的长度,是否支持所有的文件系统接口调用。
为了支持多文件系统,sun提出了一种vnode/vfs接口,SVR4中将之实现成了一种工业标准。而Linux作为一种Unix的clone体,自然也实现了这种接口,只是它的接口定义和SVR4的稍有不同。Vnode/Vfs接口的设计体现了面向对象的思想,Vfs(虚拟文件系统)代表内核中的一个文件系统,Vnode(虚拟节点)代表内核中的一个文件,它们都可以被视为抽象基类,并可以从中派生出不同的子类以实现不同的文件系统。
由于篇幅原因,这里只是大概的介绍一下怎样通过Vnode/Vfs结构来实现文件系统和访问文件。
在Linux中支持的每种文件系统必须有一个file_system_type结构,此结构的核心是read_super函数,该函数将读取文件系统的超级块。Linux中支持的所有文件系统都会被注册在一条file_system_type结构链中,注册是在系统初始化时调用regsiter_filesystem()完成,如果文件系统是以模块的方式实现,则是在调用init_module时完成。
当mount某种块设备时,将调用系统调用mount,该调用中将会首先检查该类文件系统是否注册在系统种中,如果注册了则先给该文件系统分配一个super_block,并进行初始化,最后调用这种文件系统的read_super函数来完成super_block结构私有数据的赋值。其中最主要的工作是给super_block的s_ops赋值,s_ops是一个函数向量表,由文件系统各自实现了一组操作。
struct super_operations {
void (*read_inode) (struct inode *);
void (*write_inode) (struct inode *);
void (*put_inode) (struct inode *);
void (*delete_inode) (struct inode *);
void (*put_super) (struct super_block *);
void (*write_super) (struct super_block *);
int (*statfs) (struct super_block *, struct statfs *);
int (*remount_fs) (struct super_block *, int *, char *);
void (*clear_inode) (struct inode *);
void (*umount_begin) (struct super_block *);
};
由于这组操作中定义了文件系统中对于inode的操作,所以是之后对于文件系统中文件所有操作的基础。
在给super_block的s_ops赋值后,再给该文件系统分配一个vfsmount结构,将该结构注册到系统维护的另一条链vfsmntlist中,所有mount上的文件系统都在该链中有一项。在umount时,则从链中删除这一项并且释放超级块。
对于一个已经mount的文件系统中任何文件的操作首先应该以产生一个inode实例,即根据文件系统的类型生成一个属于该文件系统的内存i节点。这首先调用文件定位函数lookup_dentry查找目录缓存看是否使用过该文件,如果还没有则缓存中找不到,于是需要的i接点则依次调用路径上的所有目录I接点的lookup函数,在lookup函数中会调用iget函数,该函数中最终调用超级块的s_ops->read_inode读取目标文件的磁盘I节点(这一步再往下就是由设备驱动完成了,通过调用驱动程序的read函数读取磁盘I节点),read_inode函数的主要功能是初始化inode的一些私有数据(比如数据存储位置,文件大小等等)以及给inode_operations函数开关表赋值,最终该inode被绑定在一个目录缓存结构dentry中返回。
在获得了文件的inode之后,对于该文件的其它一切操作都有了根基。因为可以从inode 获得文件操作函数开关表file_operatoins,该开关表里给出了标准的文件I/O接口的实现,包括read, write, lseek, mmap, ioctl等等。这些函数入口将是所有关于文件的系统调用请求的最终处理入口,通过这些函数入口会向存储该文件的硬设备驱动发出请求并且由驱动程序返回数据。当然这中间还会牵涉到一些关于buffer的管理问题,这里就不赘述了。
通过讲述这些,我们应该明白了为什么可以使用统一的系统调用接口来访问不同文件系统类型的文件了:因为在文件系统的实现一层,都把低层的差异屏蔽了,用户可见的只是高层可见的一致的系统调用接口。
5.与module相关的系统调用
Linux中提供了往系统中添加和卸载模块的接口,create_module(),init_module (), delete_module(),这些系统调用通常不是直接为程序员使用的,它们仅仅是为实现一些系统命令而提供的接口,如insmod, rmmod,(在使用这些系统调用前必须先加载目标文件到用户进程的地址空间,这必须由目标文件格式所特定的库函数(如:libobj.a中的一些函数)来完成)。
Linux的核心中维护了一个module_list列表,每个被加载到核心中的模块都在其中占有一项,系统调用create_module()就是在该列表里注册某个指定的模块,而init_module则是使用模块目标文件内容的映射来初始化核心中注册的该模块,并且调用该模块的初始化函数,初始化函数通常完成一些特定的初始化操作,比如文件系统的初始化函数就是在操作系统中注册该文件系统。delete_module则是从系统中卸载一个模块,其主要工作是从module_list中删除该模块对应的module结构并且调用该模块的cleanup函数卸载其它私有信息。
- 1.编译内核前注意的事项
检查系统上其它资源是否符合新内核的要求。在linux/Document目录下有一个叫Changes的文件,里面列举了当前内核版本所需要的其它软件的版本号,
– Kernel modutils 2.1.121 ; insmod -V
– Gnu C 2.7.2.3 ; gcc –version
– Binutils 2.8.1.0.23 ; ld -v
– Linux libc5 C Library 5.4.46 ; ls -l /lib/libc*
– Linux libc6 C Library 2.0.7pre6 ; ls -l /lib/libc*
– Dynamic Linker (ld.so) 1.9.9 ; ldd –version or ldd -v
– Linux C++ Library 2.7.2.8 ; ls -l /usr/lib/libg++.so.*
. . . . . .
其中最后一项是列举该软件版本号的命令,如果不符合要求先给相应软件升级,这一步通常可以忽略。
2.配置内核
使用make config或者make menuconfig, make xconfig配置新内核。其中包括选择块设备驱动程序、网络选项、网络设备支持、文件系统等等,用户可以根据自己的需求来进行功能配置。每个选项至少有“y”和“n”两种选择,选择“y”表示把相应的支持编译进内核,选“n”表示不提供这种支持,还有的有第三种选择“m”,则表示把该支持编译成可加载模块,即前面提到的module,怎样编译和安装模块在后面会介绍。
这里,顺便讲述一下如何在内核中增加自己的功能支持。
假如我们现在需要在自己的内核中加入一个文件系统tfile,在完成了文件系统的代码后,在linux/fs下建立一个tfile目录,把源文件拷贝到该目录下,然后修改linux/fs下的Makefile,把对应该文件系统的目标文件加入目标文件列表中,最后修改linux/fs/Config.in文件,加入
bool ‘tfile fs support’ CONFIG_TFILE_FS或者
tristate ‘tfile fs support’ CONFIG_TFILE_FS
这样在Make menuconfig时在filesystem选单下就可以看到
< > tfile fs support一项了
3.编译内核
在配置好内核后就是编译内核了,在编译之前首先应该执行make dep命令建立好依赖关系,该命令将会修改linux中每个子目录下的.depend文件,该文件包含了该目录下每个目标文件所需要的头文件(绝对路径的方式列举)。
然后就是使用make bzImage命令来编译内核了。该命令运行结束后将会在linux/arch/asm/boot/产生一个名叫bzImage的映像文件。
4.使用新内核引导
把前面编译产生的映像文件拷贝到/boot目录下(也可以直接建立一个符号连接,这样可以省去每次编译后的拷贝工作),这里暂且命名为vmlinuz-new,那么再修改/etc/lilo.conf,在其中增加这么几条:
image = /boot/vmlinuz-new
root = /dev/hda1
label = new
read-only
并且运行lilo命令,那么系统在启动时就可以选用新内核引导了。
5.编译模块和使用模块