本章将介绍操作系统的系统调用的实现。
1 操作系统接口
接口是用来干嘛的呢?插座就是一个接口,可以想象一下:我们把插头接在插座上就可以使用电了。我们不用关心怎么发电,怎么把电送到家里来,而且通过插座使用电会比直接用电线安全许多。这就是接口,它屏蔽了一些细节,让使用资源变得简单,可靠。
操作系统接口用于连接操作系统和应用软件,这些接口是一些程序(像c语言程序这些),应用软件通过调用这些程序就可以将操作系统和自己连接起来。这些操作系统接口就是系统调用(通常系统调用使用函数的形式进行调用)。平时使用的 open
,write
,close
等这些函数都是系统调用。
就像 L1 操作系统的启动 和 L2 对GDT与LDT的理解 中提到的一样,操作系统将内存进行了分割,有内核区和用户区,应用程序只能运行在用户区中。应用程序不能随意的调用内核的数据,如果一个应用进程能够随意访问内核数据,那这个操作系统就太不安全了。处理器通过一些硬件设计(特权级检查)保证内核区不被应用程序访问。那有时候用户程序需要访问内核区怎么办呢?应用程序可以通过系统调用访问内核资源。系统调用是一段包含 int 指令的代码,而 int 指令会将 CS 寄存器的 CPL (当前特权级)修改为0,从而进入内核。在 L2 对GDT与LDT的理解 中介绍了段选择子的低两位为特权级,关于特权级检查请参考该章节的段选择子部分。
2 系统调用的实现
本节以 lib/close.c
中的 close()
系统调用为例,介绍系统调用的实现。系统调用的实现原理如下:
- 首先在执行应用程序时, CS 等段寄存器是不能被轻易修改的,这也就意味着 CPL 不能被轻易修改,应用程序直接访问内核时不能通过特权级检查。(不过具体不能修改的原因我还没有弄清楚,可能是因为保护模式下提供了某些保护机制,一旦用户修改段寄存器,程序就会运行错误。也可能是因为某些可以修改段寄存器的汇编指令只能在特权级为0的时候执行,而应用程序的特权级为3,不能执行这些指令)
- 系统调用函数会执行
int 0x80;
指令。该指令会通过 IDTR(中断描述符表寄存器) 从 IDT 中找到索引为0x80 的 IDT描述符。IDT描述符结构如下:
对于索引为0x80 的 IDT描述符来说:过程入口点偏移值为 system_call 函数的偏移地址,DPL == 3 与应用程序的 CPL 相等,段选择符为8。由于DPL为3,且这里不需要检查 RPL ,因此应用程序可以访问该描述符。在应用程序执行 int 0x80;
时,硬件会将段选择符赋值给CS,让 CS == 8。于是可以进入内核了。
- 当系统调用执行完成后,将 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 $0x80" \
: "=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 $0x80
。在 void sched_init(void)
中调用了一个函数 :set_system_gate(0x80,&system_call);
,该函数会设置 IDT 表,将 int $0x80
的中断处理函数设置为 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 $0x80
,其中断处理函数为 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,让程序回到用户态。
3 总结
最后总结一下系统调用的核心:
- 用户程序中包含一段包含int指令的代码(这个用户程序就是库函数)
- 操作系统写中断处理,获取想调程序的编号(如 __NR_close)
- 操作系统根据编号执行相应代码(如 sys_close() )
下一章为系统调用相关实验。
参考
[1]操作系统_哈尔滨工业大学_中国大学MOOC
[2]操作系统实验指导书
[3]《Linux内核完全剖析——基于0.12内核》