剖析Linux系统调用的执行路径

什么是操做系统这篇文章中,介绍过操做系统像是一个代理同样,为咱们去管理计算机的众多硬件,咱们须要计算机的一些计算服务、数据管理的服务,都由操做系统提供接口来完成。这样作的好处是让通常的计算机使用者不用关心硬件的细节。html

1. 什么是操做系统的接口

既然使用者是经过操做系统接口来使用计算机的,那究竟是什么是操做系统提供的接口呢?linux

接口(interface)这个词来源于电气工程学科,指的是插座与插头的链接口,起到将电与电器链接起为的功能。后来延伸到软件工程里指软件包向外提供的功能模块的函数接口。因此接口是用来链接两个东西、信号转换和屏蔽细节。数组

那对于操做系统来讲:操做系统经过接口的方式,创建了用户与计算机硬件的沟通方式。用户经过调用操做系统的接口来使用计算机的各类计算服务。为了用户友好性,操做系统通常会提供两个重要的接口来知足用户的一些通常性的使用需求:安全

  1. 命令行:实际是一个叫bash/sh的端终程序提供的功能,该程序底层的实质仍是调用一些操做系统提供的函数。
  2. 窗口界面:窗口界面经过编写的窗口程序接收来自操做系统消息队列的一些鼠标、键盘动做,进而作出一些响应。

对于非通常性使用需求,操做系统提供了一系列的函数调用给软件开发者,由软件开发者来实现一些用户须要的功能。这些函数调用因为是操做系统内核提供的,为了有别于通常的函数调用,被称为系统调用。好比咱们使用C语言进行软件开发时,常常用的printf函数,它的内部实际就是经过write这个系统调用,让操做系统内核为咱们把字符打印在屏幕上的。bash

为了规范操做系统提供的系统调用,IEEE制定了一个标准接口族,被称为POSIX(Portable Operating System Interface of Unix)。一些咱们熟悉的接口好比:forkpthread_createopen等。数据结构

2. 用户模式与内核模式

计算机硬件资源都是操做系统内核进行管理的,那咱们能够直接用内核中的一些功能模块来操做硬件资源吗?能够直接访问内核中维护的一些数据结构吗? 固然不行!有人会说,为何不行呢?我买的电脑,内核代码在内存中,那内存不都是我本身买的吗?,我本身不能访问吗?
如今咱们运行的操做系统都是一个多任务、多用户的操做系统。若是每一个用户进程均可以随便访问操做系统内核的模块,改变状态,那整个操做系统的稳定性、安全性都大大下降了。函数

为了将内核程序与用户程序隔离开,在硬件层面上提供了一次机制,将程序执行的状态分为了避免同的级别,从0到3,数字越小,访问级别越高。0表明内核态,在该特权级别下,全部内存上的数据都是可见的,可访问的。3表明用户态,在这个特权级下,程序只能访问一部分的内存区域,只能执行一些限定的指令。ui

操做系统在创建GTD表的时候,将GTD的每一个表项中的2位(4种特权级别)设置为特权位(DPL),而后操做系统将整个内存分为不一样的段,不一样的段,在GDT对应的表项中的DPL位是不一样的。好比内核内存段的全部特权位都为00。而用户程序访存时,在保护模式下都是经过段寄存器+IP寄存器来访问的,而段寄存器里则用两位表示当前进程的级别(CPL),是位于内核态仍是用户态。spa

既然如此,那咱们还有什么办法能够调用操做系统的内核代码呢?操做系统为了实现系统调用,提供了一个主动进入内核的唯一方式:中断指令intint指令会将GDT表中的DPL改成3,让咱们能够访问内核中的函数。因此全部的系统调用都必须经过调用int指令来实现,大体的过程以下:操作系统

  1. 用户程序中包含一段包含int指令的代码
  2. 操做系统写中断处理,获取相调程序的编号
  3. 操做系统根据编号执行相应的代码

3. 剖析printf函数

下面咱们以printf函数的调用为例,说明该函数是如何一步一步最终落在内核函数上去的。

图1:应用程序、库函数和内核系统调用之间的关系

printf函数是C语言的一个库函数,它并非真正的系统调用,在Unix下,它是经过调用write函数来完成功能的。

write函数内部就是调用了int中断。通常的系统调用都是调用0x80号中断。而操做系统中通常不会的显式的写出write的实现代码,而是经过_syscall3宏展开的实现。_syscall3是专门用来处理有3个参数的系统调用的函数的实现。同理还有_syscall0_syscall1_syscall2等,目前最大支持的参数个数为3个,这三个参数是经过ebx, ecx,edx传递的。若是有系统调用的参数超过了3个,那么能够经过一个参数结构体来进行传递。

// linux/lib/write.c
#define __LIBRARY__
#include <unistd.h>
// 
_syscall3(int,write,int,fd,const char *,buf,off_t,count)
// linux/include/unistd.h
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
    : "=a" (__res) \
    : "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
    return (type) __res; \
errno=-__res; \
return -1; \
}

因此宏展开后,write函数的实现实现为:

int write(int fd, const char *buf, off_t count)
{ 
    long __res; 
    __asm__ volatile ("int $0x80" 
        : "=a" (__res) 
        : "0" (__NR_write),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); 
    if (__res>=0) 
        return (type) __res; 
    errno=-__res; 
    return -1; 
}

咱们看到实际函数内部并无作太多的事情,主要就是调用int 0x80,将把相关的参数传递给一些通用寄存器,调用的结果经过eax返回。其中一个很重要的调用参数是__NR_write这个也是一个宏,就是wirte的系统调用号,在linux/include/unistd.h中被定义为4,一样还有不少其余系统调用号。由于全部的系统调用都是经过int 0x80,那怎么知道具体须要什么功能呢,只能经过系统调用号来识别。

下面咱们来看看int 0x80是如何执行的。这是一个系统中断,操做系统对于中断处理流程通常为:

  1. 关中断:CPU关闭中段响应,即再也不接受其它外部中断请求
  2. 保存断点:将发生中断处的指令地址压入堆栈,以使中断处理完后能正确地返回。
  3. 识别中断源:CPU识别中断的来源,肯定中断类型号,从而找到相应的中断服务程序的入口地址。
  4. 保护现场所:将发生中断处理有关寄存器(中断服务程序中要使用的寄存器)以及标志寄存器的内存压入堆栈。
  5. 执行中断服务程序:转到中断服务程序入口开始执行,可在适当时刻从新开放中断,以便容许响应较高优先级的外部中断。
  6. 恢复现场并返回:把“保护现场”时压入堆栈的信息弹回原寄存器,而后执行中断返回指令(IRET),从而返回主程序继续运行。

前3项一般由处理中断的硬件电路完成,后3项一般由软件(中断服务程序)完成。

图2:系统调用中断处理流程

那0x80号中断的处理程序是什么呢,咱们能够看一下操做系统是如何设置这个中断向量表的。在操做系统初始化时shecd_init函数里,调用了

set_system_gate(0x80, &system_call);

咱们深刻看一下set_system_gate函数作了什么

#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
    "movw %0,%%dx\n\t" \
    "movl %%eax,%1\n\t" \
    "movl %%edx,%2" \
    : \
    : "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
    "o" (*((char *) (gate_addr))), \
    "o" (*(4+(char *) (gate_addr))), \
    "d" ((char *) (addr)),"a" (0x00080000))

#define set_system_gate(n,addr) \
    _set_gate(&idt[n],15,3,addr)

经过上面的代码,咱们能够看出,set_system_gate把第0x80中断表的表项中中断处理程序入口地址设置为&system_call。而且把那一项IDT表中的DPL设置了为3, 方便用户程序能够去访问这个地址。

因此init 0x80最终会被system_call这个函数地址处的代码来实际处理。让咱们看下system_call作了什么事情。

# linux/kernel/system_call.s
nr_system_calls=72 # 最大的系统调用个数
.globl _system_call

system_call:
    cmpl $nr_system_calls-1,%eax    # eax中放的系统调用号,在write的调用过程当中为__NR_write = 4
    ja bad_sys_call
    push %ds        # 下面是一些寄存器保护,后面还要弹出
    push %es
    push %fs
    pushl %edx
    pushl %ecx              # push %ebx,%ecx,%edx as parameters
    pushl %ebx              # to the system call
    movl $0x10,%edx         # set up ds,es to kernel space
    mov %dx,%ds             # 把ds的段标号设置为0001 0000(最后2位是特权级),因此段号为4,内核态数据段
    mov %dx,%es
    movl $0x17,%edx     # 把fs的段标号设置为0001 0111(最后2位是特权级),因此段号为5,用户态数据段
    mov %dx,%fs
    call sys_call_table(,%eax,4)        # 实际的系统调用
    pushl %eax
    movl current,%eax
    cmpl $0,state(%eax)     # state 检测是否为就绪状态
    jne reschedule                        # 进入调度程序
    cmpl $0,counter(%eax)       # counter 查看信号状态
    je reschedule
ret_from_sys_call:
    movl current,%eax       # task[0] cannot have signals
    cmpl task,%eax
    je 3f
    cmpw $0x0f,CS(%esp)     # was old code segment supervisor ?
    jne 3f
    cmpw $0x17,OLDSS(%esp)      # was stack segment = 0x17 ?
    jne 3f
    movl signal(%eax),%ebx
    movl blocked(%eax),%ecx
    notl %ecx
    andl %ebx,%ecx
    bsfl %ecx,%ecx
    je 3f
    btrl %ecx,%ebx
    movl %ebx,signal(%eax)
    incl %ecx
    pushl %ecx
    call do_signal
    popl %eax
3:  popl %eax
    popl %ebx
    popl %ecx
    popl %edx
    pop %fs
    pop %es
    pop %ds
    iret

咱们能够发现,上面代码中大部分代码是寄存器状态保存与恢复,堆栈段的切换。核心代码为call sys_call_table(,%eax,4),它是一个函数调用,函数的地址为sys_call_table(,%eax,4) = sys_call_table + 4*%eax说明sys_call_table为一个数组入口,数组中的元素长度都为4个字节,咱们要访问数组中的第%eax个元素。而%eax即为系统调用号。sys_call_table就是全部系统调用的函数指针数组。

// 定义在 linux/include/linux/sys.h
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_write,这个是操做实现的内核代码,全部的屏幕打印就是由该函数最终实现。它里面涉及IO的一些硬件驱动函数,咱们在这里就再也不继续深刻了。

到此,咱们已经经过printf这样一个上层的函数接口,清楚操做系统是如何一步步为了咱们提供了一个内核调用的方法。如此的精细控制,让人感叹。

4. 咱们如何为操做系统添加一个系统调用

下面简单说明一下,如何在操做系统源码中添加两个咱们本身的系统调用whoamiiam

  • iam系统调用把咱们指定的一个字符串保存在内核中。
  • whoami把内核中的经过iam设置的那个字符串读取出来。

下面是具体的操做步骤。

  1. 在linux/kernel文件夹加入一个自定义的文件who.c
  2. 在who.c中实现sys_iam和sys_whoami,须要注意的实现这两个函数时,须要用于用户栈数据与内核栈数据拷贝。
  3. 在linux/include/linux/sys.h中的sys_call_table中添加两个数组项。
  4. 修改linux/kernel/system_call.s中的系统调用个数nr_system_calls。
  5. 用int 0x80实现iam和whoami函数。
  6. 编写用户程序调用上面两个函数。

要注意的是:在系统调用的过程当中,段寄存器ds和es指向内核数据空间,而fs被设置指向用户数据空间。所以在实际数据块信息传递过程当中Linux内核就能够利用fs寄存器来执行内核数据空间与用户数据空间之间的数据复制工做,而且在复制过程当中内核程序不须要对数据边界范围做任何检查操做。边界检查操做由CPU自动完成。内核程序的实际数据传送工做可使用get_fs_byte()puts_fs_bypte()等函数进行。

5. 参考资料

[1] 《Linux内核彻底剖析基于0.12内核》 赵炯著。 [2] 网易云课堂,哈尔滨工业大学《操做系统之应用》 李治军。

相关文章
相关标签/搜索