备注:本文通过三个问题,引出Linux 内核0.11的系统调用。
操作系统为什么要引出系统调用?
回答这个问题前,请先参看如下图:
由图可以看出,从操作系统的角度来看,一台计算机主要分为两级:用户级以及内核级,系统调用主要作用就是连接用户级和内核级的“插座”。上层用户的许多对计算机硬件的操作,如读写磁盘文件,让显示器输出字符等,都通过接口来完成。那再思考一个问题,不用接口直接操作计算机不可以嘛?答案当然是可以,可是这样带来的后果是什么?我把它总结为两点:
- 底层封装繁杂的硬件操作始终需要有人完成,遵循软件设计的原则,我们不能向用户层暴露太多的底层实现细节,否则会加大应用层编写的复杂性。
- 对底层的操作,如果不通过系统调用限制,会发生用户应用程序修改系统内核等误操作,造成操作系统运行瞬间奔溃,考虑到系统的稳定性、安全性等问题,我们需要向上提供接口,限制应用层连入内核的权限。
好了,系统调用既然非存在不可,那接下来,我们就探究下,它具体是怎么实现的呢?请看下个问题。o(∩_∩)o
操作系统如何做到用户态数据与核心态数据隔离?
请看此图:
这里为什么要引出一张内存图,我们首先要建立起操作系统内存是如何使用的,由图可以看出,在内存的低地址处,放置了真正的操作系统内核代码,而在高地址处才放置了我们的应用程序的代码。因此,自然而然的一个想法就是,通过对与内核模块代码段,数据段和对用户区的代码数据段做区分来阻止用户直接访问内核模块。Linux内核通过建立段级保护机制来完成上述区分核心与用户态区域的功能。好,请看下图:
由图可以总结出以下几点:
- 越处于核心地带,特权级越高,对应的数字越小;相反,用户态的特权级越低,对应的数字越大。
- 用户程序的特权级通过段寄存器cs的低两位来描述,CPL=3。(CPL表示当前特权级,current privilege level)
- 核心态的特权级,DPL =0。(DPL 为 描述符特权级,descriptor privilege level)
因此,当执行应用程序时,cs段寄存器为当前代码段,此时的特权级CPL为3,将和想要跳转的目标段的DPL特权级进行比较,当且仅当DPL<=CPL时,当前代码段才能跳转到目标代码段。这显然是可以通过硬件检查特权级的方法来阻止此种越界访问的违法操作。
既然操作系统能阻止这种违法操作,那用户程序怎么进入核心态呢?这就引出了我们接下来的一个话题。
操作系统通过什么方式进入核心态,开始它的系统调用之旅呢?
对于Intel x86系统,那就是通过中断调用号 int 0x80来完成。上段代码:
void sched_init(void)
{
int i;
struct desc_struct * p;
if (sizeof(struct sigaction) != 16)
panic("Struct sigaction MUST be 16 bytes");
set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));
p = gdt+2+FIRST_TSS_ENTRY;
for(i=1;i<NR_TASKS;i++) {
task[i] = NULL;
p->a=p->b=0;
p++;
p->a=p->b=0;
p++;
}
/* Clear NT, so that we won't have troubles with that later on */
__asm__("pushfl ; andl $0xffffbfff,(%esp) ; popfl");
ltr(0);
lldt(0);
outb_p(0x36,0x43); /* binary, mode 3, LSB/MSB, ch 0 */
outb_p(LATCH & 0xff , 0x40); /* LSB */
outb(LATCH >> 8 , 0x40); /* MSB */
set_intr_gate(0x20,&timer_interrupt);
outb(inb_p(0x21)&~0x01,0x21);
set_system_gate(0x80,&system_call);
}
这段代码位于/linux/kernel下,当操作系统运行起来后,将会进行一系列的初始化,注意代码段的最后:set_system_gate(0x80,&system_call);这是操作系统初始化IDT表后,将0x80中断号,和system_call函数进行了绑定。即以后当有应用程序执行了0x80中断,请记得它一定会执行system_call函数。再看(linux/include/asm/system.h):
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr);
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__("movw %%dx,%%ax\n\t" "movw %0,%%dx\n\t"\
"movl %%eax,%1\n\t" "movel %%edx,%2":\
:"i"((short)(0x8000+(dpl<<13)+type<<8))),"o"(*(( \
char*)(gate_addr))),"o"(*4+(char*)(gate_addr))), \
"d"((char*)(addr),"a"(0x00080000))
这段代码,内含内嵌汇编,这里不做解释,直接给出结果。内嵌汇编的详细知识点
回归正题,根据如下图给出的目的段描述符:
在执行汇编程序之前,operation constraint对寄存器做出了约束,%0、%1被指向了idt[0x80]的低四个字节,以及高四个字节的内存地址。并将addr的地址赋给edx(32位)。将0x00080000赋给了寄存eax(32位)。输入完成之后,执行汇编程序,将dx的低16位传给eax,由eax组装成了idt中断向量表的低四个字节。将dx的高16位和%0所代表的立即数组装成了idt中断向量表的高四个字节,最终交由硬件进行,地址跳转,特权级判断处理。值得注意的是:
- 此时的目标段描述符特权级DPL变成了3,意思就是可以让用户应用程序访问此目标代码段,但好景不长,这只是程序跳转的中转站,它将进一步跳转到system_call对应的段去执行。
- 那它能跳嘛?哈哈,来看看低四字节的值,在16~31位中为段选择符,即当前的cs为0x0008,而CPL为cs寄存器的低两位,刚好都是0,即当前CPL的值等于system_call代码段DPL的值。这就顺理成章的跳了过去!
系统调用,基本结束了,剩下的即是内核代码的编写。在下一节中,我们将在实际的操作系统编写两段内核代码函数,让用户程序能调用系统函数。尽请期待!o(∩_∩)o