逐帧分析系统调用

系统调用的基本概念

  Linux内核提供了一组用来实现各种系统功能的API函数,这就称为系统调用。系统调用除了将底层接口(例如驱动)的实现包装了起来,还能做到权限控制以及保护。试想一下,假设现在任意程序都能绕开系统调用直接操纵底层接口,例如能绕开页表向任意的物理内存地址写入或者读取数据,那么进程之间的数据隔离也就无法做到了,严重一点地说整个系统内核都能被任意进程破坏,也就没有秩序可言。

  为了解决这个问题,现代操作系统一般都在硬件级(也就是CPU)支持指令集权限控制,例如Intel的x86指令集处理器就提供了4个等级的状态:ring0-ring3,CPU在某一时刻可能处于这4个状态的其中一个,在ring0状态下,CPU能够调用所有指令,而在ring3状态下,CPU仅能调用基本的运算指令。

  而为什么操作系统能占据Ring0状态,而进程无法进入Ring0状态呢,这就要从系统启动过程说起了。

系统启动过程分析

  计算机从上电开始是如何一步步从硬盘加载操作系统的,这篇文章讲得已经很清楚了。在Bootloader执行之后,位于/boot目录下的Linux内核会被加载到内存并执行,这个过程又分为两步:第一步是在实模式下执行boot/setup.bin,这一步主要也是执行一些初始化的操作和硬件的检查工作;第二步是在保护模式下执行boot/vmlinux.bin,在这个过程中会初始化页表、开启分页机制,最终会跳转到start_kernel()函数,到此为止内核便加载成功了。之后会执行/sbin/init,这便是Linux系统中的第一个进程。

  上面提到了实模式保护模式,下面就来解释一下。实模式指的就是实地址模式,顾名思义,实模式是将整个物理内存看成分段的区域,程序代码和数据位于不同区域,系统程序和用户程序没有区别对待,而且每一个指针都是指向"实在"的物理地址。而保护模式保护的就是物理内存的访问,通过段式内存管理或者页式内存管理将请求的内存地址映射到实际的物理地址,而段表或页表只有系统内核才有权限修改,从而通过这种方式实现了内存访问的控制。

  计算机的外设都会通过各种方式挂载在系统总线上的,具体来说,挂载的设备在CPU上能对应上一些物理内存地址,而通过读写这些内存地址就能达到控制外设(网卡、声卡)或者读写外设(磁盘)的目的。在实现了对物理内存访问控制之后,便能够限制普通进程操纵这些设备。

  在进入保护模式之后,进程所能够寻址的地址就全都由内核通过页表来控制了。

进程启动过程分析

  上面一节解释了为什么用户的进程无法访问实际的物理地址,接下来就要介绍一下为什么进程无法运行ring0权限下的指令。

  在Linux中能通过fork()或者exec()函数创建一个进程。以exec函数族为例,其中一个声明是这样的:int execv(const char *path, char *const argv[]);,它会把第一个参数路径对应的可执行文件(例如ELF)加载到内存,紧接着执行分配资源(例如页表)、拷贝环境变量和命令行参数、将此进程加入进程调度队列开始调度和执行,至此大致的进程启动过程便结束了。

  那么有一个问题,Linux内核将时间片分配给这个进程并将CPU交给此进程来使用,那么在此进程的时间片耗尽或者需要重新调度的时候,Linux内核是如何重新获得CPU的使用权的呢。

  在内核加载的过程中,所有的中断向量表对应的地址都会被写入Linux内核的相应的处理函数的基址。简单一点来说,只需要定义一个硬件定时器(这个由CPU的硬件实现),并将这个定时器的中断向量注册为Linux的相应处理函数,那么时间一到,CPU会自动打断当前正在运行的代码,并将PC指向定时器的中断向量,此时Linux内核便重新获取到了CPU的使用权。

  到了这一步就能解释为什么操作系统能占据ring0状态,而普通进程无法进入ring0状态了。原因很简单,Linux在将CPU使用权交给进程之前,主动从ring0退到ring3,这个过程是不可逆的,因而普通进程执行的全过程都是在ring3下的。而在定时器中断或者其他硬件中断被捕获到的时候,CPU会恢复到ring0,而此时CPU的控制权又已经在Linux内核手中了,所以在一般情况下普通进程是无法得到ring0权限的。

系统调用过程

  虽然普通进程无法在ring0权限下操纵底层设备或者执行命令,但是很多情况下往往也是需要和设备进行交互的(例如读写磁盘),因而系统调用应运而生。

  下面来分析一个简单的系统调用过程,考虑一段最简单的系统调用代码:

#include <unistd.h>

int main() 
{
    char* message = "Hello, World!";
    write(1, message, 13);
    return 0;
}

  write的第一个参数代表stdout,这个程序会在屏幕上打印出字符串message。如前面所说,普通进程是无法操纵底层设备的,那么毫无疑问,write中执行了系统调用,Linux内核帮我们完成了将字符串输出到屏幕的这个过程。来看一下write函数在32bit下的汇编实现:

#include <sys/linux-syscalls.h>

    .text
    .type write, @function
    .globl write
    .align 4

write:
    pushl   %ebx
    pushl   %ecx
    pushl   %edx
    mov     16(%esp), %ebx
    mov     20(%esp), %ecx
    mov     24(%esp), %edx
    movl    $__NR_write, %eax
    int     $0x80
    cmpl    $-129, %eax
    jb      1f
    negl    %eax
    pushl   %eax
    call    __set_errno
    addl    $4, %esp
    orl     $-1, %eax
1:
    popl    %edx
    popl    %ecx
    popl    %ebx
    ret

  可以很清楚的看到,在将参数保存到寄存器后,write函数调用了int指令产生了一个软件触发的硬件中断。此时如上面所说,CPU的控制权将在此交接给Linux内核,并执行Linux相应的中断处理函数。中断处理函数只需读取eax寄存器,就能够知道是要执行哪个系统调用(此处为__NR_write),内核根据这个交给不同的系统调用去处理。处理完毕后内核将执行调度,并降低运行权限,再将CPU运行权交给下一个就绪的进程。

  (注:在64bit模式下,系统调用不再使用int指令,转而使用syscall指令)

评论卡