什么是接口
用户使用计算机的两种方式:命令行、图形。从这两种方式理解接口。
-
命令行
1、命令其实就是程序,经过编译后生成可执行文件,在命令行键入可执行文件名与参数输出结果。而实际上shell也是一个程序,开机时在main.c文件的所有xx_init()函数之后,通过/bin/sh这个可执行文件启动shell程序
2、关键是这些程序里面调用的函数:fork()控制进程 , printf()控制屏幕,scanf()控制键盘……通过这些函数对计算机硬件进行使用 -
图形
鼠标、硬盘点击之后,通过中断放入消息队列;然后应用程序写一个循环,不断从消息队列中取消息,又对不同的消息产生不同的反应(函数)
所以不管是命令行还是图形,用户实质上都是通过程序来使用计算机。而这些程序的关键就是里面的函数,比如read() , write()等,这些函数调用就是应用程序和操作系统的接口。又因为这些函数调用时系统提供的,所以又叫系统调用(system_call)。
POSIX标准
POSIX(Portable Operating System Interface of Unix),POSIX标准定义了操作系统应该为应用程序提供的接口标准,目的是为了增强程序的可移植性,在不同的操作系统上都能跑。
为什么需要接口?
如果用户程序能直接通过jmp跳到内核或者mov访问内核的数据,那么你从网上下载一段程序就可能进入系统内核获取你的root密码等等。
如何隔离应用程序与系统内核?
利用硬件将内存划分为用户段和内存段,在用户态下的程序不能直接访问内核段的数据。如何实现对这种访问的阻止呢?
-
GDT表:
GDT的由来详见
总结下来:
1、对一个内存地址的访问以段Base Address为单位。
2、在实模式下,使用16-bit段寄存器CS(Code Segment),DS(Data Segment),SS(Stack Segment)来指定段,直接左移4位与偏移量相加
3、在保护模式下,对一个段的描述则包括【Base Address, Limit, Access】,它们加在一起被放在一个64-bit长的数据结构中,被称为段描述符。而段寄存器只有16bit,所以将段寄存器中的值作为下标索引来间接引用(事实上,是将段寄存器中的高13 bit的内容作为索引)。这个全局的数组就是GDT。
注意此处的DPL(description privilege level描述特权级)那一个格,表示表示访问该段时CPU所需处于的最低特权级,与隔离原理相关,下面详述。 -
段选择子
在保护模式下,cs叫段选择子。
如上图,高13位作为被引用的段描述符在GDT/LDT中的下标索引,bit 2用来指定被引用段描述符被放在GDT中还是到LDT中,bit 0和bit 1是RPL——请求特权等级,请求特权级(RPL)则代表选择子的特权级,共有4个特权级(0级、1级、2级、3级)。
这个特权级与访问权限相关:
在head.s里面建立GDT表的时候就将内核段DPL置为0,而CPL(current privilege level)是当前指令的特权级,如果是在用户态,那么CPL就为3,否则为0;0的特权级是高于3,在访问某个地址的时候,如果CPL的特权级小于等于DPL的特权级,那么就不能访问。由此实现用户段和内核段的隔离。
在保护模式下如何利用段选择子将逻辑地址准换为线性地址?举个例子
(1)选择子SEL=21h=0000000000100 0 01b 它代表的意思是:选择子的index=4即选择GDT中的第4个描述符;TI=0代表选择子是在GDT选择;最后的01代表特权级RPL=1
(2)OFFSET=12345678h,若此时GDT第四个描述符中描述的段基址(Base)为11111111h,则线性地址=11111111h+12345678h=23456789h
流程如下图所示(图源)
有了隔离,用户程序如何才能调用系统内核
硬件提供了用户态访问内核态的唯一方法——中断,int指令将使CS中的CPL从3变为0,这样就可以进入内核,并且这个中断号只能是0x80(后面解释)。
具体理解:以printf为例
c代码里面的printf是printf(“%d”,a),在printf()内部其实调用了系统函数write,而write函数的函数头其实是这样的:
size_t write(int fd, const void *buf, size_t count);
可以看到,printf()函数的形参和write()的形参是不一样的,因此如果printf(“%d”,a)能调用write函数的话,肯定要对printf的形参进行处理,库函数printf()就起到这个作用——格式化输出write所需要的参数,再调用包含中断int 0x80的write(),最后在内核系统调用wirte()。
- 那么格式化参数之后是如何调用write()?
通过_syscall3这个宏展开成_syscall3(int, write, int, fd, const char* buf, off_t, count)
。
_syscall3的定义如下:
#define _syscall3(type,name,atype,a,btype,b,ctype,c)\
type name(atype a, btype b, ctype c) \
{ long __res;\
__asm__ volatile(“int 0x80”:”=a”(__res):””(__NR_##name),
”b”((long)(a)),”c”((long)(b)),“d”((long)(c)))); if(__res>=0) return
(type)__res; errno=-__res; return -1;}
要得到_syscall3(int, write, int, fd, const char* buf, off_t, count)
,就是进行如下替换:
type=int,name=write,atype=int,a=fd,btype=const char * ,b=buf,ctype=off_t,c=count;
因此type name(atype a, btype b, ctype c)
就变成了int write(int fd,const char * buf, off_t count)
调用库函数wirte()。
现在调用的还是库函数wirte(),由该函数代码可以看到目的就是展开成上面的一段汇编代码。这是一段内嵌汇编,:”=a”(__res):””(__NR_##name)
意思是把存储了返回值的eax赋给_res,把__NR_write赋值给eax,__NR_write称为系统调用号,后面有用;”b”((long)(a)),”c”((long)(b)),“d”((long)(c))
就是把形参的a、b、c依次赋值给ebx、ecx、edx。
这段代码的意思就是在int 0x80进入内核进行中断处理,处理完之后,返回_res,write这个系统调用就结束了。
-
什么是系统调用号呢?
int 0x80是唯一让应用程序进入内核的途径,所以所有的系统调用都是通过int 0x80这个中断来调用的,那么如何区分是write调用还是read调用or else?就是根据这个系统调用号来区分的,__NR_write表示write调用,会接着执行write对应的内核代码,其他同理。通过宏定义#define __NR_write 4
将真正的中断处理函数地址在函数指针表中的索引赋给系统调用号。 -
int 0x80具体是如何操作的呢?
1、int 0x80是进入中断服务函数的一条指令。 凡是int 指令都要要idt(interrupt description table)表转去哪里执行。
2、void sched_init(void){ set_system_gate(0x80,&system_call); }
这个初始化语句意思是int 0x80对应的中断处理程序就是system_call。那么系统是如何初始化IDT表,让能够通过int 0x80找到system_call呢?
通过set_system_gate这个宏,set_system_gate这个宏又调用了_set_gate这个宏,如下图:
关于上图的几处解释:
1、“movl %%eax,%1\n\t” “movl %%edx,%2”:
意思就是把gate_addr也就是&idt[n]的前4位赋给%eax,后四位赋给%edx
2、_set_gate(&idt[n],15,3,addr)
这里的3替换掉#define _set_gate(gate_addr, type, dpl, addr)
的dpl,也就是int0x80的表项在定义时就把DPL定义成了3,所以在用户态时也能通过int 0x80进入内核段——跳到idt表开始进一步查找。
3、对于表项的构成,0-15和49-64位存储的是addr也就是&system_call,16-31存储的是段选择符,在这里是”a”(0x00080000))
的前四位0x0008,也就是cs=0x8。在setup.s里面有一行jmpi 0,8
,这条指令表示根据gdt表跳转到内核代码的地址0处。所以在此处,CS=8,ip=system_call就是跳到内核代码0处,然后根据入口点偏移量找到system_call这个函数;同时因为CS=8=0x1000,那么CPL=0,也就是说当前程序的特权级变了,变成内核态的了,这样就什么都能干了(这就是0x80是唯一途径的原因)。将来int 0x80返回之后,CS最后两位又要变成3,变回用户态。
中断处理程序system_call又做了什么呢
……
call _sys_call_table(,%eax,4)
……
_sys_call_table(,%eax,4)=_sys_call_table+4*%eax:这是一种寻址方式。eax是系统调用号__NR_write,那_sys_call_table是什么?
fn_ptr sys_call_table[]=
{sys_setup, sys_exit, sys_fork, sys_read, sys_write, ...};
sys_call_table是一个fn_ptr类型的全局函数表,fn_ptr是一个函数指针,4个字节,所以_sys_call_table+4*%eax
要*4,得到的结果就是真正的中断服务函数sys_write的入口地址了。
总结: