操作系统中的L3系统调用:深入解析

发表时间: 2024-02-16 07:28

1、操作系统接口

接口是用来干嘛的呢?插座就是一个接口,可以想象一下:我们把插头接在插座上就可以使用电了。我们不用关心怎么发电,怎么把电送到家里来,而且通过插座使用电会比直接用电线安全许多。这就是接口,它屏蔽了一些细节,让使用资源变得简单,可靠。


操作系统接口用于连接操作系统和应用软件,这些接口是一些程序(像c语言程序这些),应用软件通过调用这些程序就可以将操作系统和自己连接起来。这些操作系统接口就是系统调用(通常系统调用使用函数的形式进行调用)。平时使用的 open,write ,close等这些函数都是系统调用。


就像 L1-操作系统的启动 和 L2-对GDT与LDT的理解 中提到的一样,操作系统将内存进行了分割,有内核区和用户区,应用程序只能运行在用户区中。应用程序不能随意的调用内核的数据,如果一个应用进程能够随意访问内核数据,那这个操作系统就太不安全了。处理器通过一些硬件设计(特权级检查)保证内核区不被应用程序访问。那有时候用户程序需要访问内核区怎么办呢?应用程序可以通过系统调用访问内核资源。系统调用是一段包含 int 指令的代码,而 int 指令会将 CS 寄存器的 CPL (当前特权级)修改为0,从而进入内核。在 L2-对GDT与LDT的理解 中介绍了段选择子的低两位为特权级,关于特权级检查请参考该章节的段选择子部分。

2、用户态和内核态

3、系统调用的实现

本节以 lib/close.c 中的 close() 系统调用为例,介绍系统调用的实现。系统调用的实现原理如下:

  1. 首先在执行应用程序时, CS 等段寄存器是不能被轻易修改的,这也就意味着 CPL 不能被轻易修改,应用程序直接访问内核时不能通过特权级检查。(不过具体不能修改的原因我还没有弄清楚,可能是因为保护模式下提供了某些保护机制,一旦用户修改段寄存器,程序就会运行错误。也可能是因为某些可以修改段寄存器的汇编指令只能在特权级为0的时候执行,而应用程序的特权级为3,不能执行这些指令)
  2. 系统调用函数会执行int 0x80; 指令。该指令会通过 IDTR(中断描述符表寄存器) 从 IDT 中找到索引为0x80 的 IDT描述符。IDT描述符结构如下:

对于索引为0x80 的 IDT描述符来说:过程入口点偏移值为 system_call 函数的偏移地址,DPL == 3 与应用程序的 CPL 相等,段选择符为8。由于DPL为3,且这里不需要检查 RPL ,因此应用程序可以访问该描述符。在应用程序执行 int 0x80; 时,硬件会将段选择符赋值给CS,让 CS = 8。于是可以进入内核了。

当CS=8,ip=0时,CPL=0(CS最低两位)

  1. 当系统调用执行完成后,将 CS 的 CPL 改为3,然后重新回到应用程序执行。

Linux0.11 中系统调用都是采用宏函数的形式,下面程序是 close()的API:

#define __LIBRARY__#include <unistd.h>_syscall1(int,close,int,fd)

_syscall1是一个宏,在include/unistd.h中定义

#define _syscall1(type,name,atype,a) \type name(atype a) \{ \long __res; \__asm__ volatile ("int name(atype a) \{ \long __res; \__asm__ volatile ("int "int $0x80"x80" \: "=a" (__res) \: "0" (__NR_##name),"b" ((long)(a))); \if (__res >= 0) \return (type) __res; \errno = -__res; \return -1; \}x80" \: "=a" (__res) \: "0" (__NR_##name),"b" ((long)(a))); \if (__res >= 0) \return (type) __res; \errno = -__res; \return -1; \}

在include/unistd.h中定义_syscalln(n可以是0~3,代表传入参数的个数),_syscalln 通常用 eax 存放系统调用号,ebx,ecx,edx存放传入参数,同时 eax 也作为返回值。_syscalln 中会包含一个中段调用 int 在include/unistd.h中定义_syscalln(n可以是0~3,代表传入参数的个数),_syscalln 通常用 eax 存放系统调用号,ebx,ecx,edx存放传入参数,同时 eax 也作为返回值。_syscalln 中会包含一个中段调用 int $0x80 。在 void sched_init(void) 中调用了一个函数 :set_system_gate(0x80,&system_call); ,该函数会设置 IDT 表,将 int $0x80 的中断处理函数设置为 system_call 。x80 。在 void sched_init(void) 中调用了一个函数 :set_system_gate(0x80,&system_call); ,该函数会设置 IDT 表,将 int 在include/unistd.h中定义_syscalln(n可以是0~3,代表传入参数的个数),_syscalln 通常用 eax 存放系统调用号,ebx,ecx,edx存放传入参数,同时 eax 也作为返回值。_syscalln 中会包含一个中段调用 int $0x80 。在 void sched_init(void) 中调用了一个函数 :set_system_gate(0x80,&system_call); ,该函数会设置 IDT 表,将 int $0x80 的中断处理函数设置为 system_call 。x80 的中断处理函数设置为 system_call 。

我们将 _syscall1(int,close,int,fd) 展开:

int close(int fd) {     long __res;      // 返回值,返回系统调用执行结果,0表示成功,负值表示失败    // 下面是嵌入汇编程序    __asm__ volatile ("int $0x80"            // 汇编语句        : "=a" (__res)                       // 输出寄存器        : "0" (__NR_close),"b" ((long)(fd)));// 输入寄存器,__NR_close为系统调用号,是一个宏定义,其值为6。                                             // system_call 会根据 __NR_close 对应的需要执行函数    // 下面是返回值处理         if (__res >= 0)        return (int) __res;     errno = -__res; // __res为错误类型码,它被存在全局变量 errno 中,                    // 可以通过库函数 perror() 将错误类型码及其对应的字符串打印出来    return -1; }

在应用程序调用 close() 时,close() 会发出一个中断调用 int 在应用程序调用 close() 时,close() 会发出一个中断调用 int $0x80 ,其中断处理函数为 system_call 。int 指令会将 cs 寄存器中的 CPL 修改为 0 ,以便进入内核区。下面看一下system_call 关于系统调用的主要部分:x80 ,其中断处理函数为 system_call 。int 指令会将 cs 寄存器中的 CPL 修改为 0 ,以便进入内核区。下面看一下system_call 关于系统调用的主要部分:

system_call:······	movl $0x10,%edx		# set up ds,es to kernel space	mov %dx,%ds	mov %dx,%es	movl $0x17,%edx		# fs points to local data space	mov %dx,%fs	call sys_call_table(,%eax,4)#eax是系统调用号······ret_from_sys_call:······3:	popl %eax	popl %ebx	popl %ecx	popl %edx	pop %fs	pop %es	pop %ds	iret

sys_call_table是一张函数指针表:

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,sys_setreuid,sys_setregid };

sys_call_table(,%eax,4) 表示 sys_call_table + %eax * 4;当 eax = __NR_close 时 call 调用的就是 sys_close() 函数。也就是说close() 函数的真正实现就是 sys_close() 。执行完 sys_close()后,会接着返回执行 system_call ,system_call最后会执行 iret 指令,利用该指令出栈的原理将 CS 的 CPL 重新设置为3,让程序回到用户态。

图解

jmpi 0,8

当CS=8,IP=0时,CPL=0(CS最低两位)

sys_call_table 一定是一个函数指针数组的起始地址

4、总结

最后总结一下系统调用的核心:

  1. 用户程序中包含一段包含int指令的代码(这个用户程序就是库函数)
  2. 操作系统写中断处理,获取想调程序的编号(如 __NR_close)
  3. 操作系统根据编号执行相应代码(如 sys_close() )


API 并不能完成系统调用的真正功能,它要做的是去调用真正的系统调用,过程是:

  • 系统调用的编号存入 EAX;
  • 把函数参数存入其它通用寄存器;
  • 触发 0x80 号中断(int 0x80)