一 系统调用的意义
在操作系统内存中,肯定存在很多敏感的数据,只希望在特定的场景下才能访问。例如linux登录之后,用户的密码可能就被缓存在了内存的某个位置。
假如用户程序可以随意访问内存中的数据的话,另一个用户就可以把其他用户的用户名和密码从内存中读出来,这是非常不安全的。
因此操作系统就把内存划分了很多个区域,每个区域大小都是4K,这样的一个区域就被称为一个页,且每个页都有自己的访问权限及其他的信息。操作系统在初始化时会初时化一个GDT表格,用于记录每个页的所有信息,包括它的id,它的访问权限控制,它的超始地址等等。
GDT中为每一个页都定义了一个CPL和DPL数据,CPL就是这个页访问别的页时用的权限级别,而DPL是这个页被别的页访问时使用的权限级别。
如 当一个页A访问另一个页B时,操作系统会检查,要求 A的CPL <= B的DPL
,注意是 <=
,因为CPL/DPL的值越小,说明权限级别最高。linux一共定义了四种权限级别:
其中0是系统内核的DPL/CPL,这是最高权限级别,而用户程序DPL/CPL都是3,所以用户程序是无法随意访问内存数据的。
操作系统就是使用这样的方式保存内存数据的安全的。但是有一个问题是,如果用户就是需要访问内核中的数据怎么办?例如在linux中有一个用户级命令whoami
,会返回当前用户的用户名,而这个用户名是放在内核中的。
whoami
这个用户程序在执行时,它的代码段的DPL和CPL肯定都是3(因为是用户程序),而用户名这个数据所在的内存段的CPL/DPL都是0,需要如何才能访问到?
这时候就需要使用到系统调用:
操作系统提供了一个特殊的内核级程序system_all,在初始化时,把这个system_all加载到了内存中,并且把这个system_all所在的内存段的CPL置为0,而DPL置为3。
这样一来,由于 用户程序的CPL和system_all的DPL都为3,满足 CPL <= DPL
的要求,所有用户程序可以访问system_all。
而在system_all中,它的CPL=0,因此它又可以随意访问内存中任何段的内容了。通过这种方式,用户程序就可以通过system_call来间接访问内核了。
二 系统调用的功能限制
通过上面的描述,我们知道了用户程序可以通过系统调用system_call来间接访问内核中的数据。
可能你会觉得,虽然用户程序访问内核很麻烦,但是毕竟已经能访问了,那么内核中的数据不是又不安全了吗?其实不是的。
关键在于用户程序不可以随意
访问,因为系统调用程序system_call是操作系统内核提供的,这个程序只提供了一组功能可供用户程序使用,而那些system_call没有提供的功能,那用户程序就无法使用了。
三 系统调用号
系统调用system_call提供了很多可供使用的功能,如读取IO,写IO,向屏幕打印字符串,创建新的进程等,用户程序如何指定要调用哪个功能呢?
在用户程序调用system_call时,需要传递一个系统调用号,来标记需要调用的是哪个功能。
而在操作系统初始化时,会初始化一张_system_call_table系统调用表,用于记录每个调用号对应的功能信息。具体来说,它记录了每个系统调用号对应的程序所在的代码段的GDT id。
当我们调用系统调用时,system_call会先从_system_call_table中找到对应程序所在的代码段的GDT id,然后去GDT表中找到该段的信息。其中就包括段的超始位置,然后再跳转到该地址执行即可。
四 用户程序调用系统调用的方式
那么用户程序如何调用系统调用呢?直接在代码中写一个_system_call();
吗?并不是这样的。用户程序调用系统调用的唯一方法是使用0x80号中断
。
至于为什么嘛,linux就是这样实现的,我也不太明白为什么一定要这样实现。
在操作系统初始化时会初化IDT表,这个IDT表就是中断向量表。并且把0x80号中断对应的程序地址初始化成了system_all的地址。当用户调用0x80号中断时,自然就执行到了system_all。
而这个时候,由于调用为断的方式是int 0x80
在指令中并没有位置给我们传递参数,所以需要我们把参数先行存放到寄存器中。例如系统调用号就需要先存放在eax中。如果还需要其他参数,例如要读取磁盘某个扇区的内容,当然还需要传递扇区号,这时就可以把参数存放到ebx, ecx...等寄存器中。
不论是执行int 0x80还是修改寄存器中的内容, 都需要使用内嵌汇编代码, 比较麻烦, 所以c语言中就给我们封装好了。例如C语言中提供了一个fork()函数,当我们调用这个函数时,它的内部其实就是帮我们内嵌了一段汇编代码:
mov eax, xxxx ;xxxx就是fork对应的系统调用号
int 0x80
然后执行这段汇编代码。
五 系统调用的整体流程
以fork函数为例,总结一下系统调用的整体流程:
- 步骤一:在C语言中调用fork函数,fork函数中内嵌了一段汇编代码,调用了0x80号中断
- CPU去IDT表格中查找0x80号中断对应程序的指令位置,也就是得到了系统调用程序所在的代码段
- CPU检查当前代码段的CPL和系统调用函数的DPL,由于
当前代码段CPL=3
且在系统调用函数所有代码段的DPL也特意被设置为了3,所以可以调用。 - 在系统调用函数system_call中,先从eax寄存器中获取到了系统调用号,然后去_system_call_table中查到该调用号对应程序的指令位置。同样的这里获取到的也是一个代码段。
- CPU再检查CPL和DPL,由于system_call所在段的CPL为0,所以肯定可以访问。
- CPU跳转到具体的系统调用程序中执行,整个系统调用就算完成了