Linux系统调用指南

Linux系统调用指南

Apr 5, 2016 • packagecloud

Tags:

TL;DR

这篇blog解释linux程序如何调用linux内核函数。

这篇文章概述不同的几个做系统调用的方法,如何自己写系统调用(包含例子),系统调用的内核入口,内核出口,glibc封装器,bugs等等。

What is a system call?

什么是系统调用

当你运行的程序调用了open, fork, read, write等等,你就做了系统调用。

系统调用就是程序如何进入内核执行任务。程序使用系统调用执行一系列的操作诸如:创建进程,网络和文件IO等等。

你可以在man page for syscalls(2)里面看到系统调用的列表。

用户程序做系统调用有不同的方法,CPU架构不同做系统调用的底层指令也不同。

作为应用开发者,你不需要经常思考系统调用如何正确执行。你只需要把头文件引入,然后像普通功能一样调用。

glibc作为装饰器,抽象组装你传递的参数然后进入内核的细节。

在我们详细研究系统调用如何实现之前,需要定义一些后面将会出现的条款和核心概念。

Prerequisite information

前提信息

Hardware and software

硬件和软件

本文做如下假设:

  • 你使用的是Intel或者AMD的32位或者64位CPU。本文讨论的方法可能对其他系统也有用,但是例子中的代码包含一些CPU专用代码。
  • 你对3.13.0版本的Linux内核感兴趣。其他版本内核是相似的,但是代码准确的行数,代码的组织和文件路径是不一样的。建议从GitHub上链接3.13.0版本内核源码树。
  • 你对glibc或者由glibc得到的libc实现感兴趣。

本文所指的x86-64是基于x86架构的64位Intel和AMDCPU。

User programs, the kernel, and CPU privilege levels

用户程序,内核,CPU权限等级

用户程序(比如编辑器,终端,ssh守护程序等等)需要和linux内核交互,所以有些用户程序无法自己执行的行为可以调用内核执行。

比如,如果用户程序需要做IO操作(open, read, write等等)或者修改自己地址空间(mmap, sbrk等等),必须触发内核运行来完成这些操作行为。

是什么阻止用户程序自己执行这些操作?

原来是x86-64的CPU有一个权限等级概念。权限等级是个复杂的题目适合单独一片博客来阐述。在这片博客中,我们简单地把权限等级概念解释为:

  1. 权限等级意味着访问控制。当前权限等级决定了那些CPU指令和IO操作可以执行。
  2. 内核运行在最高权限等级,叫做“Ring 0”。用户程序运行在较低等级,叫做“Ring 3”。

用户程序为了要执行某些高权限操作,必须修改权限等级(从“Ring 3”到“Ring 0”),所以由内核执行。

这里有一些方法可以改变权限等级,触发内核执行操作。

先介绍一个内核调用的普通方法:中断。

Interrupts

中断

你可以认为中断时由硬件或者软件产生的事件。

一个硬件中断时由硬件设备产生的通知内核有特殊事件发生了。这种中断较常见的例子是网卡收到包产生的中断。

一个软件中断是执行某条代码的时候产生的。在x86-64系统中,执行int指令可以产生一个软件中断。

中断一般有一个分配的中断号。有些中断号有特殊意义。

你可以想象CPU存储器中有一个数组。数组中的每一个条目都指向一个中断号。每个条目都包含一个函数的入口地址,当某个操作产生中断的时候,CPU可以通过入口地址执行这个函数。[TODO]

Intel CPU指南里面这张图展示了数组中各个条目的布局:

Screenshot of Interrupt Descriptor Table entry diagram for x86_64 CPUs

如果你仔细看这个图,会发现2bit字段DPL(Descriptor Privilege Level)。这个字段的值决定了CPU执行程序的权限等级。

这就CPU是如何知道需要执行哪个地址的指令以及这个指令的权限等级(当一个特殊类型事件发生的时候)。

实际上x86-64系统有很多种方法可以处理中断。如果你对这方面感兴趣可以读8259 Programmable Interrupt Controller, Advanced Interrupt Controllers, 和 IO Advanced Interrupt Controllers.

处理硬件/软件中断还要处理一些其它复杂的事情,比如中断号冲突和重映射。

讨论系统调用的时候我们不需要关心这些细节。

Model Specific Registers (MSRs)

特殊模块寄存器

特殊模块寄存器(MSRs)是以提供CPU的某些控制功能为目的的寄存器。CPU文档列出了这些MSRs地址。

你可以分别使用rdmsrwrmsr来读写MSRs。

也有命令行工具可以读写MSRs,但是不推荐因为改变MSRs值是危险的(特别是当操作系统正在运行的时候),除非你真的很小心。

如果你不介意系统的崩溃或者数据的不可逆失效风险,可以安装msr-tools然后加载msr内核模块来读写MSRs。

% sudo apt-get install msr-tools
% sudo modprobe msr
% sudo rdmsr

稍后我们将会看到一些系统调用使用MSRs。

Calling system calls with assembly is a bad idea

用汇编做系统调用是坏主意

自己写汇编代码执行系统调用不是个好办法。

其中的一个原因是在有些系统调用前/调用后,glibc要执行一些额外的代码。

下面的例子我们会使用exit系统调用。使用atexit注册函数,当程序调用exit时就会执行你注册的函数。

那些代码是通过glibc调用的而不是内核。所以,如果你写汇编语言像下面那样执行exit,你注册的函数不会被执行因为绕过了glibc。

然而,用汇编语言做系统调用有利于学习经验。

Legacy system calls

传统系统调用

有两个需要预先准备的知识:

  1. 我们可以通过生成软中断触发内核调用。

  2. 我们可以用汇编指令int生成软中断。

结合这两个概念让我们看Linux传统系统调用接口。

用户空间程序可以取到Linux内核软中断号,这样就可以进入内核和执行系统调用。

Linux内核给128(0x80)中断注册了名为ia32_syscall的中断执行程序。让我们看看具体做这件事的代码。

内核3.13.0,arch/x86/kernel/traps.c源码中的trap_init函数。

void __init trap_init(void)
{
        /* ..... other code ... */

        set_system_intr_gate(IA32_SYSCALL_VECTOR, ia32_syscall);

IA32_SYSCALL_VECTORarch/x86/include/asm/irq_vectors.h中定义的,值是0x80

但是,虽然用户空间程序可以发出内核系统存储的软中断信号来触发内核,内核如何知道需要执行哪个系统调用?

用户空间程序会把系统调用号放在eax寄存器中,系统调用的参数放在其它通用寄存器中。

arch/x86/ia32/ia32entry.S注释中有它的说明。

* Emulated IA32 system calls via int 0x80.
 *
 * Arguments:
 * %eax System call number.
 * %ebx Arg1
 * %ecx Arg2
 * %edx Arg3
 * %esi Arg4
 * %edi Arg5
 * %ebp Arg6    [note: not saved in the stack frame, should not be touched]
 *

我们已经知道如何做系统调用和参数存在哪里,现在我们通过写内联汇编做一个系统调用。

Using legacy system calls with your own assembly

用汇编做传统系统调用

你可以写一小段内联汇编做传统系统调用。虽然以学习的观点来看这很有趣,但我还是建议读者永远不要手动写汇编函数做系统调用。

在这个例子中,我们试着做exit系统调用,这个调用有一个参数:退出状态。

首先,我们先找到exit的系统调用号。Linux内核包含一个文件,这个文件在一个表格中列出了各个系统调用。这个文件在构建阶段被不同的脚本加工然后生成可以被用户程序使用的头文件。

让我们看看这个在arch/x86/syscalls/syscall_32.tbl发现的表格:

1 i386 exit sys_exit

exit系统调用号是1。根据上面的接口描述,我们只需要把系统调用号放到eax寄存器,第一个参数(推出状态)放到ebx寄存器。

这里是一段含有内联汇编代码的C语言程序。我们把退出状态设置成“42”:

(这段代码可以被简化,但我想这样写可以让那些不知道GCC内联汇编的人理解和参考。)

int
main(int argc, char *argv[])
{
  unsigned int syscall_nr = 1;
  int exit_status = 42;

  asm ("movl %0, %%eax\n"
             "movl %1, %%ebx\n"
       "int $0x80"
    : /* output parameters, we aren't outputting anything, no none */
      /* (none) */
    : /* input parameters mapped to %0 and %1, repsectively */
      "m" (syscall_nr), "m" (exit_status)
    : /* registers that we are "clobbering", unneeded since we are calling exit */
      "eax", "ebx");
}

下一步,编译,执行,然后检查退出状态:

$ gcc -o test test.c
$ ./test
$ echo $?
42

成功了!我们使用传统系统调用方法通过发出软中断来执行exit

Kernel-side: int $0x80 entry point

内核内部:int $0x80入口

我们已经看了用户空间程序如何触发系统调用,接下来看看内核如何使用系统调用号执行系统调用代码。

上一章我们提到,内核注册了一个系统调用执行函数叫做ia32_syscall

这个函数是在arch/x86/ia32/ia32entry.S中用汇编实现的,

ia32_do_call:
        IA32_ARG_FIXUP
        call *ia32_sys_call_table(,%rax,8) # xxx: rip relative

IA32_ARG_FIXUP是一个宏,它重新排列了传统参数好让当前系统调用层恰当理解。

ia32_sys_call_table标示符引用了arch/x86/ia32/syscall_ia32.c中定义的表格。注意代码结尾处的#include行。

const sys_call_ptr_t ia32_sys_call_table[__NR_ia32_syscall_max+1] = {
        /*
         * Smells like a compiler bug -- it doesn't work
         * when the & below is removed.
         */
        [0 ... __NR_ia32_syscall_max] = &compat_ni_syscall,
#include <asm/syscalls_32.h>
};

回忆我们之前看到的在arch/x86/syscalls/syscall_32.tbl中定义的表格。

编译期有几个脚本会取这个表格并且生成syscalls_32.h文件。生成的头文件由有效的C语言组成,就是上面用#include插入的代码,它把根据系统调用号得到的函数地址索引写进ia32_sys_call_table

这就是你如何通过传统系统调用进入内核的。

Returning from a legacy system call with iret

iret从传统系统调用返回

我们已经看到用软中断如何进入内核,但是内核是如何返回用户空间的,并且内核结束执行之后如何丢弃权限等级的?

我们可以在这个文档(注意:大PDF)Intel Software Developer’s Manual 看到一副有用的图解说明了当权限级别改变的时候程序栈是如何安排的。

如图:

Screenshot of the Stack Usage on Transfers to Interrupt and Exception-Handling Routines

当用户程序触发软中断,程序转移到内核函数ia32_syscall的时候权限级别发生改变。结果就是当进入ia32_syscall 的时候程序栈就会想上面图例一样。

这就意味着返回地址,译成权限等级等的CPU标志,还有很多在ia32_syscall执行前都被保存在程序栈中。

所以,为了恢复执行,内核只需要把程序栈中的值拷贝回寄存器,这样程序又恢复到了用户空间。

好的,你该怎么做?

只有很少的方法可以做到,但是最简单的方法是用iret指令。

在Intel指令集手册的解释是:iret指令把返回地址和存贮的寄存器值从栈中压出。

随着实地址模式中断的返回,IRET指令从栈中分别压出返回指令指针,返回代码段选择器,EFLAGS镜像到EIP, CS, 和 EFLAGS 寄存器,然后恢复执行被中断的程序或进程。

在内核里面找到这段代码有点困难,因为它隐藏在一些宏代码之下,并且处理信号和ptrace退出跟踪需要特别小心。

最终在内核中挖出的汇编语言宏代码揭露了iret如何从系统调用返回用户程序。

arch/x86/kernel/entry_64.S中的irq_return :

irq_return:
  INTERRUPT_RETURN

INTERRUPT_RETURNarch/x86/include/asm/irqflags.h中定义为iretq

你现在知道了传统系统调用时如何工作了。

Fast system calls

快速系统调用

传统方法看起来非常合理,但是现在有新的方法触发系统调用,不需要包含软中断并且比使用软中断要快得多

两个快速方法都是由两个指令组成。一个进入内核,一个离开内核。两个方法都在Intel CPU文档中“快速系统调用”里介绍。

不幸的是,当CPU在32位或64位模式的时候,在哪个方法有效的问题上,Intel和AMD的实现是不一致的。

为了最大的兼容Intel和AMD的CPU:

  • 在32位系统使用: sysentersysexit.
  • 在64位系统使用: syscallsysret.

32-bit fast system calls

32位快速系统调用

sysenter/sysexit

使用sysenter做系统调用比传统通断方法更复杂并且在用户空间和内核之间要做更多适配(通过glibc)。

我们一步一步的做并挑出其中的细节。首先我们看看Intel指令集参考文档(注意大文件PDF)对sysenter的介绍和怎么使用。

我们看一看:

执行SYSENTER指令前,软件必须通过把值写入下面的MSRs中来指定权限等级0的代码段和代码入口,并且指定权限等级0的堆栈段和堆栈指针。

• IA32_SYSENTER_CS (MSR address 174H) — MSR的低16位是权限等级0代码段的段选择器。这个值也用来决定权限等级0堆栈段的段选择器(见Operation章)。这个值不能指示一个空选择器。

• IA32_SYSENTER_EIP (MSR address 176H) — MSR的这个值加载到RIP(这样,这个值就指向了被选择的操作程序或常规程序的第一条指令的地方)。在保护模式,只有31:0位会被加载。

• IA32_SYSENTER_ESP (MSR address 175H) — MSR的这个值加载到RSP(这样,这个值包含了权限等级0栈的栈指针)。这个值不能表示一个不按规则的地址。在保护模式,只有31:0位会被加载。

换句话说:为了让内核收到sysenter系统调用,内核必须设置3个特殊模块寄存器(MSRs)。我们最需要关注的MSR是IA32_SYSENTER_EIP(含有0x176地址)。当用户程序执行sysenter指令,这个MSR就是内核指定的将要执行的程序的地址。

我们可以在内核中arch/x86/vdso/vdso32-setup.c找到写MSR的代码:

void enable_sep_cpu(void)
{
        /* ... other code ... */

        wrmsr(MSR_IA32_SYSENTER_EIP, (unsigned long) ia32_sysenter_target, 0);

MSR_IA32_SYSENTER_EIParch/x86/include/uapi/asm/msr-index.h中赋值为0x00000176

就像传统软中断系统调用,这是用sysenter做系统调用的惯例。

arch/x86/ia32/ia32entry.S中的注释做了说明:

* 32bit SYSENTER instruction entry.
 *
 * Arguments:
 * %eax System call number.
 * %ebx Arg1
 * %ecx Arg2
 * %edx Arg3
 * %esi Arg4
 * %edi Arg5
 * %ebp user stack
 * 0(%ebp) Arg6 

回忆传统系统调用方法对返回被中断的用户空间程序有个一机制:iret指令。

理解让sysenter正确执行的逻辑是复杂的,因为不像软中断,sysenter没有保存返回地址。

确切的讲,在执行sysenter指令之前,内核存储地址和其它数据是如何实现的。(你将会在下面的Bugs那章看到它确实实现了)。

为了避免未来的变化,用户程序原打算调用__kernel_vsyscall函数,这个函数是内核实现的,但是在进程开始的时候映射到了各个用户进程。

这有点旧了;这段代码来自内核,却在用户空间执行。

__kernel_vsyscall被证明是虚拟动态共享对象(vDSO)的一部分,vDSO是用来让程序在用户空间执行内核代码。

接下来我们将会深度的检查vDSO是什么,有什么功能,和如何工作的。

现在我们开始检测__kernel_vsyscall的内部构件。

__kernel_vsyscall internals

__kernel_vsyscall内部构件

__kernel_vsyscall函数封装了sysenter调用惯例,可以在 arch/x86/vdso/vdso32/sysenter.S中看到:

__kernel_vsyscall:
.LSTART_vsyscall:
        push %ecx
.Lpush_ecx:
        push %edx
.Lpush_edx:
        push %ebp
.Lenter_kernel:
        movl %esp,%ebp
        sysenter

__kernel_vsyscall是动态共享对象(也被叫做共享库)的一部分。用户程序在运行时如何找到动态共享函数的地址的?

__kernel_vsyscall函数地址写在 ELF 辅助向量,这个向量在用户程序或者库(特别是glibc)可以找到和使用的地方。

有一些方法可以找到ELF辅助向量:

  1. 使用getauxval ,参数是AT_SYSINFO
  2. 迭代环境变量,在内存中解析。

第一种方法最简单,但是glibc的2.16版本之后才有。下面例子的代码对第二种方法做了解释。

就像我们上面看到的代码,__kernel_vsyscallsysenter调用前做了一些记账。

所以,我们手动用sysenter进入内核需要做的全部事情是:

  • 找到AT_SYSINFO的ELF辅助向量,找到__kernel_vsyscall地址。
  • 就像传统系统调用一样把系统调用号和参数放倒寄存器中。
  • 调用__kernel_vsyscall函数。

你永远也不应该自己写sysenter封装函数因为内核使用sysenter进入和退出系统调用的传统会变化,你的代码会被中断。

你应该一直用__kernel_vsyscall来执行sysenter系统调用。

好的,让我们这么做。

Using sysenter system calls with your own assembly

写汇编使用sysenter系统调用

就像之前我们的传统系统调用例子,我们将会执行退出状态是42的exit

exit系统调用号是1。根据上面的接口描述,我们只需要把系统调用号传入eax寄存器,把第一个参数(退出状态码)传入ebx

(这段代码可以被简化,但我想这样写可以让那些不知道GCC内联汇编的人理解和参考。)

#include <stdlib.h>
#include <elf.h>

int
main(int argc, char* argv[], char* envp[])
{
  unsigned int syscall_nr = 1;
  int exit_status = 42;
  Elf32_auxv_t *auxv;

  /* auxilliary vectors are located after the end of the environment
   * variables
   *
   * check this helpful diagram: https://static.lwn.net/images/2012/auxvec.png
   */
  while(*envp++ != NULL);

  /* envp is now pointed at the auxilliary vectors, since we've iterated
   * through the environment variables.
   */
  for (auxv = (Elf32_auxv_t *)envp; auxv->a_type != AT_NULL; auxv++)
  {
    if( auxv->a_type == AT_SYSINFO) {
      break;
    }
  }

  /* NOTE: in glibc 2.16 and higher you can replace the above code with
   * a call to getauxval(3):  getauxval(AT_SYSINFO)
   */

  asm(
      "movl %0,  %%eax    \n"
      "movl %1, %%ebx    \n"
      "call *%2          \n"
      : /* output parameters, we aren't outputting anything, no none */
        /* (none) */
      : /* input parameters mapped to %0 and %1, repsectively */
        "m" (syscall_nr), "m" (exit_status), "m" (auxv->a_un.a_val)
      : /* registers that we are "clobbering", unneeded since we are calling exit */
        "eax", "ebx");
}

下一步,编译,执行,然后检查退出状态:

$ gcc -m32 -o test test.c
$ ./test
$ echo $?
42

成功了!我们在没有生成软中断的情况下使用传统的sysenter方法做了exit系统调用。

Kernel-side: sysenter entry point

内核侧:sysenter入口

好的,我们已经看到用户空间程序是如何用__kernel_vsyscallsysenter来触发系统调用的,让我们看看内核如何使用系统调用号执行系统调用代码的。

回忆上一章内核注册了ia32_sysenter_target系统调用执行函数。

这个功能是arch/x86/ia32/ia32entry.S里面用汇编实现的。让我们看看为了执行系统调用eax寄存器中的值是在哪里被使用的。

sysenter_dispatch:
        call    *ia32_sys_call_table(,%rax,8)

和我们看到了和传统系统调用模式一样的代码:一个叫ia32_sys_call_table的表格,里面有系统调用号。

在所有需要的记录存储以后,传统系统调用模式和sysenter系统调用模式使用了相同的机制和系统调用表格来分发系统调用。

相关int $0x80 入口章来学习ia32_sys_call_table是在哪定义的和如何构造的。

这就是你如何通过sysenter系统调用进入内核。

Returning from a sysenter system call with sysexit

sysexitsysenter调用中返回

内核可以用sysexit恢复用户程序执行。

使用这个这个指令不像iret那么直接。调用着需要把返回的地址放入rdx寄存器,把程序栈指针放入rcx寄存器。

就是说你的软件必须计算程序恢复执行的地址,保存这个值,在执行sysexit之前恢复这个值。

我们可以看到这么做的代码:arch/x86/ia32/ia32entry.S:

sysexit_from_sys_call:
        andl    $~TS_COMPAT,TI_status+THREAD_INFO(%rsp,RIP-ARGOFFSET)
        /* clear IF, that popfq doesn't enable interrupts early */
        andl  $~0x200,EFLAGS-R11(%rsp)
        movl    RIP-R11(%rsp),%edx              /* User %eip */
        CFI_REGISTER rip,rdx
        RESTORE_ARGS 0,24,0,0,0,0
        xorq    %r8,%r8
        xorq    %r9,%r9
        xorq    %r10,%r10
        xorq    %r11,%r11
        popfq_cfi
        /*CFI_RESTORE rflags*/
        popq_cfi %rcx                           /* User %esp */
        CFI_REGISTER rsp,rcx
        TRACE_IRQS_ON
        ENABLE_INTERRUPTS_SYSEXIT32

ENABLE_INTERRUPTS_SYSEXIT32arch/x86/include/asm/irqflags.h 里定义的宏,包含sysexit指令。

现在你知道了32位快速系统调用如何工作的了。

64-bit fast system calls

64位快速系统调用

旅程的下一步是64位快速系统调用。这些系统调用分别使用syscallsysret指令进入和返回。

syscall/sysret

Intel指令集参考文档( 大PDF文件)解释了syscall指令如何工作。

SYSCALL触发一个权限等级0的操作系统系统调用执行程序。它通过从IA32_LSTAR MSR加载RIP来实现(把SYSCALL后面指令的地址保存到RCX之后)。

换句话说:为了让内核收到接下来的系统调用,必须把系统调用发生时候执行的代码地址储存在IA32_LSTAR` MSR中。

我们可以在arch/x86/kernel/cpu/common.c里面看到这段代码:

void syscall_init(void)
{
        /* ... other code ... */
        wrmsrl(MSR_LSTAR, system_call);

MSR_LSTAR值在arch/x86/include/uapi/asm/msr-index.h中定义为0xc0000082

就像传统软中断系统调用,有一个惯例是用syscall做系统调用。

用户空间程序需要把系统调用号存入rax寄存器。syscall的参数存入其它通用寄存器中。

x86-64 ABI文档第A.2.1章写到:

  1. 用户程序使用%rdi, %rsi, %rdx, %rcx, %r8 和 %r9寄存器传递参数序列,内核接口使用%rdi, %rsi, %rdx, %r10, %r8 和 %r9寄存器。
  2. syscall指令完成系统调用,内核会破坏%rcx 和 %r11寄存器值。
  3. 系统调用号用%rax寄存器传递。
  4. 系统调用最多6个参数,不能用直接用栈传递。
  5. syscall的返回结果保存在%rax寄存器中。-4095到-1的值表示错误,他是错误号。
  6. 只能传递整数或者内存值到内核。

arch/x86/kernel/entry_64.S中的注释也做了说明。

现在我们知道如何做系统调用,如何传递参数,下面我们通过写内联汇编程序实现一个。

Using syscall system calls with your own assembly

自己写汇编做 syscall系统调用

继续前面的例子,我们用内联汇编的C程序执行exit系统调用,退出状态码为42 。

首先,我们需要找到 exit 系统调用号。在这个例子里我们需要在arch/x86/syscalls/syscall_64.tbl里找:

60 common exit sys_exit

exit 系统调用号是60。根据上面的接口描述,我们只需要把60传入rax寄存器,把第一个参数(退出状态码)传入rdi寄存器。

这里是一段有内联汇编的C代码实现。就像上面的例子,这个例子不是最简单实现,是为了方便说明:

int
main(int argc, char *argv[])
{
  unsigned long syscall_nr = 60;
  long exit_status = 42;

  asm ("movq %0, %%rax\n"
       "movq %1, %%rdi\n"
       "syscall"
    : /* output parameters, we aren't outputting anything, no none */
      /* (none) */
    : /* input parameters mapped to %0 and %1, repsectively */
      "m" (syscall_nr), "m" (exit_status)
    : /* registers that we are "clobbering", unneeded since we are calling exit */
      "rax", "rdi");
}

下面,编译,执行,然后检查退出状态码:

$ gcc -o test test.c
$ ./test
$ echo $?
42

成功了!我们用syscall执行了exit系统调用。我们没有生成软中断并且(如果我们做计时)它执行的更快。

Kernel-side: syscall entry point

内核侧:系统调用入口

现在我们已经看了如何从用户空间触发系统调用,让我们看看内核如何用系统调用号执行系统调用代码。

回忆上一章我们看到了system_call函数的地址被写入了LSTAR MSR。

让我们看看这个函数的代码和它使用rax值如何准确传递执行到系统调用。arch/x86/kernel/entry_64.S:

`call *sys_call_table(,%rax,8)  # XXX:    rip relative`

非常像传统系统调用方法,sys_call_table是定义在C文件中的表格,这个C文件是脚本生成的,在C代码中用#include引入。

arch/x86/kernel/syscall_64.c,底部的#include

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
        /*
         * Smells like a compiler bug -- it doesn't work
         * when the & below is removed.
         */
        [0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};

早先我们在arch/x86/syscalls/syscall_64.tbl里看到了syscall表格。很像传统中断模式,有一个在内核编译期运行的脚本根据syscall_64.tbl表格生成了syscalls_64.h文件。

上面的代码引入了生成的C代码,C代码里面包含了以系统调用号为索引的函数指针队列。

这就是如何通过syscall系统调用进入内核。

Returning from a syscall system call with sysret

sysretsyscall系统调用返回

内核可以使用sysret指令恢复用户程序到调用syscall时离开的地方。

sysretsysexit简单,因为syscall执行的时候,用户程序需要恢复的地址已经拷贝到rcx寄存器了。

由于你保存了这个值并且在执行sysret之前又恢复到了rcx,程序可以恢复到它调用syscall离开的地方继续执行。

这是方便的因为sysenter需要你除了多破坏一个寄存器值之外,还要你自己计算这个地址。

我们可以在arch/x86/kernel/entry_64.S看到这段代码:

movq RIP-ARGOFFSET(%rsp),%rcx
CFI_REGISTER    rip,rcx
RESTORE_ARGS 1,-ARG_SKIP,0
/*CFI_REGISTER  rflags,r11*/
movq    PER_CPU_VAR(old_rsp), %rsp
USERGS_SYSRET64

USERGS_SYSRET64arch/x86/include/asm/irqflags.h定义的宏,它里面包含sysret指令。

现在你知道64位快速系统调用时如何工作的了。

Calling a syscall semi-manually with syscall(2)

用syscall(2)做半自动系统调用

很好,我们已经看到不同系统调用如何通过手动装配代码实现的。

一般你不需要自己写装配代码,glibc提供的封装器函数可以为你处理所有装配代码。

有一些系统调用不存在glibc封装器。其中一个例子是futex,用户空间快速同步锁系统调用。

但是,为什么futex没有系统调用封装器?

futex计划是只能被类库调用而不是应用代码,所以要调用futex你必须按照下面做:

  1. 为你希望支持的平台生成汇编存根。
  2. 使用glibc提供的 syscall 封装器。

如果你自己发现需要调用不存在封装器的系统调用,你肯定该做第二个选择:使用glibc提供的 syscall 封装器。

让我们用glibc提供的 syscall 封装器调用退出状态码是42exit调用:

#include <unistd.h>

int
main(int argc, char *argv[])
{
  unsigned long syscall_nr = 60;
  long exit_status = 42;

  syscall(syscall_nr, exit_status);
}

接下来,编译,执行,检查退出状态:

$ gcc -o test test.c
$ ./test
$ echo $?
42

成功了!我们用glibc提供的syscall封装器调用了exit系统调用。

glibc syscall wrapper internals

glibcsyscall封装器内部实现

让我们看看我们上个例子使用的syscall封装器函数和它在glibc中如何工作的。

sysdeps/unix/sysv/linux/x86_64/syscall.S:

/* Usage: long syscall (syscall_number, arg1, arg2, arg3, arg4, arg5, arg6)
   We need to do some arg shifting, the syscall_number will be in
   rax.  */

        .text
ENTRY (syscall)
        movq %rdi, %rax         /* Syscall number -> rax.  */
        movq %rsi, %rdi         /* shift arg1 - arg5\.  */
        movq %rdx, %rsi
        movq %rcx, %rdx
        movq %r8, %r10
        movq %r9, %r8
        movq 8(%rsp),%r9        /* arg6 is on the stack.  */
        syscall                 /* Do the system call.  */
        cmpq $-4095, %rax       /* Check %rax for error.  */
        jae SYSCALL_ERROR_LABEL /* Jump to error handler if error.  */
L(pseudo_end):
        ret                     /* Return to caller.  */

刚才我们在x86_64 ABI文档中看到用户空间和内核调用惯例的摘要。

这段汇编代码很酷因为它展现了两种调用惯例。传进这个函数的参数遵循用户空间调用惯例,然后这个函数把参数放到了与之前不同的符合内核调用惯例的寄存器,然后才用syscall进入内核。

当你没有使用默认封装器而使用glibc syscall封装器时候它的工作原理。

Virtual system calls

虚拟系统调用

我们已经覆盖了所有系统调用方法和如何手动(或半自动)做系统调用,让系统从用户空间过渡到内核。

如果程序可以调用系统调用却完全不进入内核是怎样的?

这就是为什么Linux虚拟动态共享对象(vDSO)存在。vDSO是一组属于内核但却把地址空间映射到用户空间的代码。

这么做是因为有些系统调用可以不用进入内核而被调用,比如:gettimeofday。

程序调用gettimeofday系统调用不需要进入内核。

没有软中断,没有sysenter或者syscall复杂的状态备份。gettimeofday只是一个普通函数调用。

你可以用ldd,vDSO作为第一个条目被列举出来。

$ ldd `which bash`
  linux-vdso.so.1 =>  (0x00007fff667ff000)
  libtinfo.so.5 => /lib/x86_64-linux-gnu/libtinfo.so.5 (0x00007f623df7d000)
  libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f623dd79000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f623d9ba000)
  /lib64/ld-linux-x86-64.so.2 (0x00007f623e1ae000)

让我们看看vDSO是如何在内核中设置的。

vDSO in the kernel

你可以在 arch/x86/vdso/看到vDSO源码。它有一些和连接器脚本在一起的汇编和C源文件。

连接器脚本,内容很酷。

源自arch/x86/vdso/vdso.lds.S:

/*
 * This controls what userland symbols we export from the vDSO.
 */
VERSION {
        LINUX_2.6 {
        global:
                clock_gettime;
                __vdso_clock_gettime;
                gettimeofday;
                __vdso_gettimeofday;
                getcpu;
                __vdso_getcpu;
                time;
                __vdso_time;
        local: *;
        };
}

连接器脚本很有用但是很少有人知道。这个连接器脚本列出了vDSO导出的特征。

我们看到vDSO导出了四个不同的函数,每个函数有两个名字。你可以在这个字典中找到这些函数的C源码。

比如,gettimeofday源码在 arch/x86/vdso/vclock_gettime.c:

int gettimeofday(struct timeval *, struct timezone *)
        __attribute__((weak, alias("__vdso_gettimeofday")));

这里定义了gettimeofday有一个弱别名叫做__vdso_gettimeofday

在用户空间执行的gettimeofday系统调用源码在同一个文件__vdso_gettimeofday函数中。

Locating the vDSO in memory

在内存中定位vDSO

因为地址空间布局随机性,程序开始的时候vDSO将会被加载到一个随机的地址。

如果vDSO被加载到随机的地址,用户程序怎么找到呢?

如果回忆之前测试sysenter系统调用,我们看到用户程序需要调用__kernel_vsyscall而不是写他们自己的sysenter调用汇编函数。

这个函数也是vDSO的一部分。

例子代码提供了定位__kernel_vsyscall地址的方法,通过查找ELF auxilliary headers去找到AT_SYSINFO类型的header,这里包含了__kernel_vsyscall地址。

相似的,如果定位vDSO,用户程序可以在ELF auxilliary header找AT_SYSINFO_EHDR类型。它里面含有连接器脚本生成的vDSO地址。

各种情况都是,当程序加载的时候内核会把地址写入ELF头。这就是正确的地址是如何在AT_SYSINFO_EHDRAT_SYSINFO结束的。

一旦header被定位了,用户程序可以按照需要解析ELF对象(可能使用libelf)和在ELF对象调用函数。

这很好因为它意味着vDSO可以使用一些ELF高级特性比如symbol versioning

在vDSO中解析和调用函数的例子在内核文档 Documentation/vDSO/中有提供。

vDSO in glibc

很多时候,人们使用vDSO却并不知道,因为glibc通过使用前面章节介绍的接口对它进行了封装。

当程序加载的时候,dynamic linker and loader加载了程序需要的所有DSO,包括vDSO。

glibc解析被加载程序的ELF headers的时候保存了vDSO位置的数据。他也包含一些简短的stub函数,这些函数可以在vDSO中找之前做具体系统调用的符号名。

比如,glibc里的gettimeofday函数,在sysdeps/unix/sysv/linux/x86_64/gettimeofday.c:

void *gettimeofday_ifunc (void) __asm__ ("__gettimeofday");

void *
gettimeofday_ifunc (void)
{
  PREPARE_VERSION (linux26, "LINUX_2.6", 61765110);

  /* If the vDSO is not available we fall back on the old vsyscall.  */
  return (_dl_vdso_vsym ("gettimeofday", &linux26)
          ?: (void *) VSYSCALL_ADDR_vgettimeofday);
}
__asm (".type __gettimeofday, %gnu_indirect_function");

这段glibc代码在vDSO中找gettimeofday函数然后返回地址。这被用indirect function很好的封装了。

这就是程序如何经过glibc调用gettimeofday,没有切换到内核模式,发生权限等级变化或者生成软中断的情况下命中vDSO。

这就总结了32位或者64位Intel和AMD CPU可用的单系统调用方法的所有情况。

glibc system call wrappers

当我们讨论系统调用的时候,简单的提及glibc如何处理系统调用是情理之中的事情。

对于很多系统调用,glibc封装函数只是简单的把参数调整到相应的寄存器位置然后执行syscall 或者 int $0x80 指令,或者调用__kernel_vsyscall

它是通过使用一系列table实现的,这些table是在文本文件中定义,脚本处理这些文本文件然后输出C代码。

比如,sysdeps/unix/syscalls.list 文件描述了一些常见的系统调用:

access          -       access          i:si    __access        access
acct            -       acct            i:S     acct
chdir           -       chdir           i:s     __chdir         chdir
chmod           -       chmod           i:si    __chmod         chmod

查看处理这个文件的脚本的注释sysdeps/unix/make-syscalls.sh来学习每行的系统调用。

一些比较复杂的系统调用就像exit触发由C或者汇编代码实现的执行者,这些执行者不能像上面那样在模版文件中被发现。

下一篇博客将会介绍glibc实现和Linux内核给对系统调用感兴趣的读者。

在这里提及两个Linux系统调用显著的bug。

让我们看一下!

CVE-2010-3301

This security exploit allows local users to gain root access.

This security exploit 允许本地用户获取root权限。

The cause is a small bug in the assembly code which allows user programs to make legacy system calls on x86-64 systems.

到这这个问题的原因是汇编代码里的小bug,这个汇编代码允许用户程序在x86-64系统里做传统系统调用。

The exploit code is pretty clever: it generates a region of memory with mmap at a particular address and uses an integer overflow to cause this code:

这段代码很清晰:在一个特殊的地址用mmap生成一个内存区域,然后用Integer溢出:

(Remember this code from the legacy interrupts section above?)

`call *ia32_sys_call_table(,%rax,8)`

to hand execution off to an arbitrary address which runs as kernel code and can escalate the running process to root.

Android sysenter ABI breakage

Remember the part about not hardcoding the sysenter ABI in your application code?

Unfortunately, the android-x86 folks made this mistake. The kernel ABI changed and suddenly android-x86 stopped working.

The kernel folks ended up restoring the old sysenter ABI to avoid breaking the Android devices in the wild with stale hardcoded sysenter sequences.

Here’s the fix that was added to the Linux kernel. You can find a link to the offending commit in the android source in the commit message.

Remember: never write your own sysenter assembly code. If you have to implement it directly for some reason, use a piece of code like the example above and go through __kernel_vsyscall at the very least.

Conclusion

The system call infrastructure in the Linux kernel is incredibly complex. There are many different methods for making system calls each with their own advantages and disadvantages.

Calling system calls by crafting your own assembly is generally a bad idea as the ABI may break underneath you. Your kernel and libc implementation will (probably) choose the fastest method for making system calls on your system.

If you can’t use the glibc provided wrappers (or if one doesn’t exist), you should at the very least use the syscall wrapper function, or try to go through the vDSO provided __kernel_vsyscall.

Stay tuned for future blog posts investigating individual system calls and their implementations.

If you enjoyed this post, you may also enjoy other low level technical posts such as:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值