你真的知道什么是系统调用吗?

在现代操作系统里,由于系统资源可能同时被多个应用程序访问,如果不加保护,那各个应用程序之间可能会产生冲突,对于恶意应用程序更可能导致系统奔溃。这里所说的系统资源包括文件、网络、各种硬件设备等。比如要操作文件必须借助操作系统提供的api(比如linux下的fopen)。

系统调用在我们工作中无时无刻不打着交道,那系统调用的原理是什么呢?在其过程中做了哪些事情呢?

本文将阐述系统调用原理,让大家对于系统调用有一个清晰的认识。

更多文章见个人博客:https://github.com/farmerjohngit/myblog

概述

现代cpu通常有多种特权级别,一般来说特权级总共有4个,编号从Ring 0(最高特权)到Ring 3(最低特权),在Linux上之用到Ring 0和RIng 3,用户态对应Ring 3,内核态对应Ring 0。

普通应用程序运行在用户态下,其诸多操作都受到限制,比如改变特权级别、访问硬件等。特权高的代码能将自己降至低等级的级别,但反之则是不行的。而系统调用是运行在内核态的,那么运行在用户态的应用程序如何运行内核态的代码呢?操作系统一般是通过中断来从用户态切换到内核态的。学过操作系统课程的同学对中断这个词肯定都不陌生。

中断一般有两个属性,一个是中断号,一个是中断处理程序。不同的中断有不同的中断号,每个中断号都对应了一个中断处理程序。在内核中有一个叫中断向量表的数组来映射这个关系。当中断到来时,cpu会暂停正在执行的代码,根据中断号去中断向量表找出对应的中断处理程序并调用。中断处理程序执行完成后,会继续执行之前的代码。

中断分为硬件中断和软件中断,我们这里说的是软件中断,软件中断通常是一条指令,使用这条指令用户可以手动触发某个中断。例如在i386下,对应的指令是int,在int指令后指定对应的中断号,如int 0x80代表你调用第0x80号的中断处理程序。

中断号是有限的,所有不会用一个中断来对应一个系统调用(系统调用有很多)。Linux下用int 0x80触发所有的系统调用,那如何区分不同的调用呢?对于每个系统调用都有一个系统调用号,在触发中断之前,会将系统调用号放入到一个固定的寄存器,0x80对应的中断处理程序会读取该寄存器的值,然后决定执行哪个系统调用的代码。

在Linux2.5(具体版本不是很确定)之前的版本,是使用int 0x80这样的方式实现系统调用的,但其实int指令这样的形式性能不太好,原因如下(出自这篇文章):

在 x86 保护模式中,处理 INT 中断指令时,CPU 首先从中断描述表 IDT 取出对应的门描述符,判断门描述符的种类,然后检查门描述符的级别 DPL 和 INT 指令调用者的级别 CPL,当 CPL<=DPL 也就是说 INT 调用者级别高于描述符指定级别时,才能成功调用,最后再根据描述符的内容,进行压栈、跳转、权限级别提升。内核代码执行完毕之后,调用 IRET 指令返回,IRET 指令恢复用户栈,并跳转会低级别的代码。

其实,在发生系统调用,由 Ring3 进入 Ring0 的这个过程浪费了不少的 CPU 周期,例如,系统调用必然需要由 Ring3 进入 Ring0(由内核调用 INT 指令的方式除外,这多半属于 Hacker 的内核模块所为),权限提升之前和之后的级别是固定的,CPL 肯定是 3,而 INT 80 的 DPL 肯定也是 3,这样 CPU 检查门描述符的 DPL 和调用者的 CPL 就是完全没必要。

正是由于如此,在linux2.5开始支持一种新的系统调用,其基于Intel 奔腾2代处理器就开始支持的一组专门针对系统调用的指令sysenter/sysexitsysenter 指令用于由 Ring3 进入 Ring0,sysexit指令用于由 Ring0 返回 Ring3。由于没有特权级别检查的处理,也没有压栈的操作,所以执行速度比 INT n/IRET 快了不少。

本文分析的是int指令,新型的系统调用机制可以参见下面几篇文章:

https://www.ibm.com/developerworks/cn/linux/kernel/l-k26ncpu/index.html

https://www.jianshu.com/p/f4c04cf8e406

基于int的系统调用

触发中断

我们以系统调用fork为例,fork函数的定义在glibc(2.17版本)的unistd.h

/* Clone the calling process, creating an exact copy.
   Return -1 for errors, 0 to the new process,
   and the process ID of the new process to the old process.  */
extern __pid_t fork (void) __THROWNL;

fork函数的实现代码比较难找,在nptl\sysdeps\unix\sysv\linux\fork.c中有这么一段代码

weak_alias (__libc_fork, __fork)
libc_hidden_def (__fork)
weak_alias (__libc_fork, fork)

其作用简单的说就是将__libc_fork当作__fork的别名,所以fork函数的实现是在__libc_fork中,核心代码如下

#ifdef ARCH_FORK
  pid = ARCH_FORK ();
#else
# error "ARCH_FORK must be defined so that the CLONE_SETTID flag is used"
  pid = INLINE_SYSCALL (fork, 0);
#endif

我们分析定义了ARCH_FORK的情况,ARCH_FORK定义在nptl\sysdeps\unix\sysv\linux\i386\fork.c中,代码如下:

#define ARCH_FORK() \
  INLINE_SYSCALL (clone, 5,                           \
          CLONE_CHILD_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD, 0,     \
          NULL, NULL, &THREAD_SELF->tid)

INLINE_SYSCALL代码在sysdeps\unix\sysv\linux\i386\sysdep.h

#undef INLINE_SYSCALL
#define INLINE_SYSCALL(name, nr, args...) \
  ({                                          \
    unsigned int resultvar = INTERNAL_SYSCALL (name, , nr, args);         \
    if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (resultvar, ), 0))         \
      {                                       \
    __set_errno (INTERNAL_SYSCALL_ERRNO (resultvar, ));           \
    resultvar = 0xffffffff;                           \
      }                                       \
    (int) resultvar; })

INLINE_SYSCALL主要是调用同文件下的INTERNAL_SYSCALL

# define INTERNAL_SYSCALL(name, err, nr, args...) \
  ({                                          \
    register unsigned int resultvar;                          \
    EXTRAVAR_##nr                                 \
    asm volatile (                                \
    LOADARGS_##nr                                 \
    "movl %1, %%eax\n\t"                              \
    "int $0x80\n\t"                               \
    RESTOREARGS_##nr                                  \
    : "=a" (resultvar)                                \
    : "i" (__NR_##name) ASMFMT_##nr(args) : "memory", "cc");              \
    (int) resultvar; })


#define __NR_clone 120

这里是一段内联汇编代码, 其中__NR_##name的值为 __NR_clone即120。这里主要是两个步骤:

  1. 设置eax寄存器的值为120
  2. 执行int $0x80陷入中断

int $0x80指令会让cpu陷入中断,执行对应的0x80中断处理函数。不过在这之前,cpu还需要进行栈切换

因为在linux中,用户态和内核态使用的是不同的栈(可以看看这篇文章),两者负责各自的函数调用,互不干扰。在执行int $0x80时,程序需要由用户态切换到内核态,所以程序当前栈也要从用户栈切换到内核栈。与之对应,当中断程序执行结束返回时,当前栈要从内核栈切换回用户栈

这里说的当前栈指的就是ESP寄存器的值所指向的栈。ESP的值位于用户栈的范围,那程序的当前栈就是用户栈,反之亦然。此外寄存器SS的值指向当前栈所在的页。因此,将用户栈切换到内核栈的过程是:

  1. 将当前ESP、SS等寄存器的值存到内核栈上。
  2. 将ESP、SS等值设置为内核栈的相应值。

反之,从内核栈切换回用户栈的过程:恢复ESP、SS等寄存器的值,也就是用保存在内核栈的原ESP、SS等值设置回对应寄存器。

中断处理程序

在切换到内核栈之后,就开始执行中断向量表的0x80号中断处理程序。中断处理程序除了系统调用(0x80)还有如除0异常(0x00)、缺页异常(0x14)等等,在arch\i386\kernel\traps.c文件的trap_init方法中描述了中断处理程序向中断向量表注册的过程:

void __init trap_init(void)
{
#ifdef CONFIG_EISA
    void __iomem *p = ioremap(0x0FFFD9, 4);
    if (readl(p) == 'E'+('I'<<8)+('S'<<16)+('A'<<24)) {
        EISA_bus = 1;
    }
    iounmap(p);
#endif

#ifdef CONFIG_X86_LOCAL_APIC
    init_apic_mappings();
#endif

    set_trap_gate(0,&divide_error);
    set_intr_gate(1,&debug);
    set_intr_gate(2,&nmi);
    set_system_intr_gate(3, &int3); /* int3-5 can be called from all */
    set_system_gate(4,&overflow);
    set_system_gate(5,&bounds);
    set_trap_gate(6,&invalid_op);
    set_trap_gate(7,&device_not_available);
    set_task_gate(8,GDT_ENTRY_DOUBLEFAULT_TSS);
    set_trap_gate(9,&coprocessor_segment_overrun);
    set_trap_gate(10,&invalid_TSS);
    set_trap_gate(11,&segment_not_present);
    set_trap_gate(12,&stack_segment);
    set_trap_gate(13,&general_protection);
    set_intr_gate(14,&page_fault);
    set_trap_gate(15,&spurious_interrupt_bug);
    set_trap_gate(16,&coprocessor_error);
    set_trap_gate(17,&alignment_check);
#ifdef CONFIG_X86_MCE
    set_trap_gate(18,&machine_check);
#endif
    set_trap_gate(19,&simd_coprocessor_error);

    set_system_gate(SYSCALL_VECTOR,&system_call);

    /*
     * Should be a barrier for any external CPU state.
     */
    cpu_init();

    trap_init_hook();
}

SYSCALL_VECTOR定义如下:

#define SYSCALL_VECTOR      0x80

所以0x80对应的处理程序就是system_call这个方法,该方法位于arch\i386\kernel\entry.S

ENTRY(system_call)
    //code 1: 保存各种寄存器
    SAVE_ALL
    ...
    jnz syscall_trace_entry
    //如果传入的系统调用号大于最大的系统调用号,则跳转到无效调用处理
    cmpl $(nr_syscalls), %eax
    jae syscall_badsys
    
syscall_call:
    //code 2: 根据系统调用号(存储在eax中)来调用对应的系统调用程序
    call *sys_call_table(,%eax,4)
    //保存系统调用返回值到eax寄存器中
    movl %eax,EAX(%esp)     # store the return value
    ...
restore_all:
    //code 3:恢复各种寄存器的值 以及执行iret指令
    RESTORE_ALL
    ...
 

主要分为几步:

1.保存各种寄存器

2.根据系统调用号执行对应的系统调用程序,将返回结果存入到eax中

3.恢复各种寄存器

其中保存各种寄存器的SAVE_ALL定义在entry.S中:

#define SAVE_ALL \
    cld; \
    pushl %es; \
    pushl %ds; \
    pushl %eax; \
    pushl %ebp; \
    pushl %edi; \
    pushl %esi; \
    pushl %edx; \
    pushl %ecx; \
    pushl %ebx; \
    movl $(__USER_DS), %edx; \
    movl %edx, %ds; \
    movl %edx, %es;

sys_call_table定义在entry.S中:

.data
ENTRY(sys_call_table)
    .long sys_restart_syscall   /* 0 - old "setup()" system call, used for restarting */
    .long sys_exit
    .long sys_fork
    .long sys_read
    .long sys_write
    .long sys_open      /* 5 */
    ...
    .long sys_sigreturn
    .long sys_clone     /* 120 */
    ...

sys_call_table就是系统调用表,每一个long元素(4字节)都是一个系统调用地址,所以 *sys_call_table(,%eax,4)的含义就是sys_call_table上偏移量为0+%eax*4元素所指向的系统调用,即第%eax个系统调用。上文中fork系统调用最终设置到eax的值是120,那最终执行的就是sys_clone这个函数,注意其实现和第2个系统调用sys_fork基本一样,只是参数不同,关于fork和clone的区别可以看这里,代码如下:

//kernel\fork.c
asmlinkage int sys_fork(struct pt_regs regs)
{
    return do_fork(SIGCHLD, regs.esp, &regs, 0, NULL, NULL);
}

asmlinkage int sys_clone(struct pt_regs regs)
{
    unsigned long clone_flags;
    unsigned long newsp;
    int __user *parent_tidptr, *child_tidptr;

    clone_flags = regs.ebx;
    newsp = regs.ecx;
    parent_tidptr = (int __user *)regs.edx;
    child_tidptr = (int __user *)regs.edi;
    if (!newsp)
        newsp = regs.esp;
    return do_fork(clone_flags, newsp, &regs, 0, parent_tidptr, child_tidptr);
}

一次系统调用的基本过程已经分析完,剩下的具体处理逻辑和本文无关就不分析了,有兴趣的同学可以自己看看。

整体调用流程图如下:

1550410105156.png

End

想写这篇文章的原因主要是年前在看《《程序员的自我修养》》这本书,之前对于系统调用这块有一些了解但很零碎和模糊,看完本书系统调用这一章后消除了我许多疑问。总体来说这是一本不错的书,但我相关的基础比较薄弱,所以收获不多。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容