系统调用的工作机制

所有的程序员在写程序的时候都离不开通过库函数的方式和系统调用打交道


什么是用户态和内核态?(从CPU指令级别的角度)

一般现代CPU都有几种不同的指令执行级别,什么样的程序可以执行什么的指令
在高执行级别下,代码可以执行特权指令,访问任意的物理地址,这时CPU执行级别就对应着内核态
而在相应的低级别执行状态下,代码的掌控范围会受到限制。只能在对应级别允许的范围内活动
举例:intel x86 CPU有四种不同的执行级别0-3,Linux只使用了其中的0级3级分别来表示内核态用户态


如何区分用户态和内核态?(从进程地址空间的角度)

cs寄存器的最低两位表明了当前代码的特权级
CPU每条指令的读取都是通过cs:eip这两个寄存器:
      其中  cs是代码段选择寄存器,eip是偏移量寄存器
上述判断由硬件完成

在32位x86的机器上,有4G的进程地址空间(逻辑地址),在内核态的时候全都可以访问,在用户态的时候,只能访问0x00000000-0xbfffffff的地址空间。也就是说0xc0000000以上的地址空间只能在内核态下访问


中断处理是从用户态进入内核态主要的方式

当从用户态切换到内核态的时候,必须用户态的寄存器上下文保存起来,同时设置内核态的寄存器内容
中断/int指令会在堆栈上保存一些寄存器的值
      如:用户态栈顶地址、当时的状态字、当时的 cs:eip 的值
同时设置内核态的栈顶地址、内核态的状态字,中断处理程序的入口地址 cs:eip 的值(对于系统调用来讲,它是指向system_call函数)


中断/int指令发生后第一件事就是保护现场

保护现场就是进入中断程序保存需要用到的寄存器的数据

当进入到中断处理程序后,一开始就执行SAVE_ALL,把其它的一些寄存器的值push到内核堆栈里面去

SAVE_ALL

中断处理结束前最后一件事是恢复现场

恢复现场就是退出中断程序恢复寄存器的数据

当中断处理程序结束之后,它会RESTORE_ALL,把保存的用户态的寄存器再pop出来到当前的CPU里面,最后iret,iret指令与中断信号(包括int指令)发生时CPU做的动作刚好相反

RESTORE_ALL

中断处理的完整过程

interrupt(ex:int 0x80)
save cs:eip/ss:esp/eflags(current) to kernel stack, then load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack)
SAVE_ALL
....  // 内核代码,完成中断服务,发生进程调度
RESTORE_ALL
iret
pop cs:eip/ss:esp/eflags from kernel stack

SAVE_ALL....如果发生了进程调度,那么当前的状态都会暂时保存在系统里面,当下一次发生进程调度切换回当前进程的时候,就会接着把它执行完,RESTORE_ALL....


以系统调用为例,看中断具体是怎么执行的

系统调用通过软中断向内核发出一个明确的请求,是操作系统为用户态进程与硬件设备进行交互提供的一组接口

封装例程 (wrapper routine),唯一目的就是发布系统调用,让程序员在写代码的时候不需要用汇编指令来触发一个系统调用,而是直接调用一个函数就可以触发一个系统调用

应用编程接口(application program interface, API) 只是一个函数定义。一般每个系统调用对应一个封装例程,库再用这些封装例程定义出给用户的API。但并不是每个API都对应一个特定的系统调用,API可能直接提供用户态的服务,例如一些数学函数。一个单独的API可能调用几个系统调用,不同的API可能调用了同一个系统调用

应用程序、封装例程、系统调用处理程序及系统调用服务例程之间的关系

User Mode 用户态      Kernel Mode 内核态

xyz()函数,是系统调用对应的API,这个应用程序编程接口里面封装了一个系统调用,这个系统调用会触发一个int 0x80的中断,0x80这个中断向量对应着system_call这个内核代码的起点,这个内核代码里面会有SAVE_ALL,然后执行到sys_xyz()中断服务程序,进入程序里面处理,在中断服务程序执行完之后会ret_from_sys_call,在return的过程中可能会发生进程调度(这是一个进程调度的时机),如果没有进程调度,就会iret,回到用户态接着执行

Summary

系统调用的三层皮:API、中断向量对应的system_call、中断服务程序sys_xyz

当用户态进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核函数,在Linux中是通过执行int $0x80来执行系统调用的,这条汇编指令产生向量为128的编程异常。(Intel Pentium II中引入了sysenter指令(快速系统调用),2.6已经支持)

内核实现了很多不同的系统调用,进程必须指明需要哪个系统调用,这需要传递一个名为系统调用号的参数,使用eax寄存器(系统调用号将xyz()和sys_xyz()关联起来了)


系统调用的参数传递方法

普通函数调用的时候,可以采用把参数压栈的方式传递参数。但是从用户态到内核态,怎么传递参数呢?

system_call是linux中所有系统调用的入口点,每个系统调用至少有一个参数,即由eax传递的系统调用号

一个应用程序调用fork()封装例程,那么在执行int $0x80之前就把eax寄存器的值置为2(即__NR_fork)
这个寄存器的设置是libc库中的封装例程进行的,因此用户一般不关心系统调用号
进入sys_call之后,立即将eax的值压入内核堆栈

寄存器传递参数具有如下限制:
1)每个参数的长度不能超过寄存器的长度,即32位
2)在系统调用号(eax)之外,参数的个数不能超过6个(ebx, ecx,edx,esi,edi,ebp)
超过6个怎么办?

如果超过6个,就把某一个寄存器作为一个指针,指向一块内存,进入到内核态之后可以访问到所有的地址空间,通过内存来传递参数


通过库函数API使用系统调用获取系统当前时间

通过库函数API使用系统调用获取系统当前时间

用汇编方式触发系统调用获取系统当前时间

用汇编方式触发系统调用获取系统当前时间

系统调用传递第一个参数使用ebx,这里是NULL
使用eax传递系统调用号,这里time是13
系统调用的返回值使用eax存储,和普通函数一样


(完)


推荐阅读更多精彩内容