0%

操作系统之基础(五):系统调用

1. 系统调用概述

  • 我们已经可以看到操作系统提供了两种功能:为用户提供应用程序抽象和管理计算机资源。对于大部分在应用程序和操作系统之间的交互主要是应用程序的抽象,例如创建、写入、读取和删除文件。计算机的资源管理对用户来说基本上是透明的。因此,用户程序和操作系统之间的接口主要是处理抽象。为了真正理解操作系统的行为,我们必须仔细的分析这个接口

  • 多数现代操作系统都有功能相同但是细节不同的系统调用,引发操作系统的调用依赖于计算机自身的机制,而且必须用汇编代码表达。任何单 CPU 计算机一次执行执行一条指令。如果一个进程在用户态下运行用户程序,例如从文件中读取数据。那么如果想要把控制权交给操作系统控制,那么必须执行一个异常指令或者系统调用指令。操作系统紧接着需要参数检查找出所需要的调用进程。操作系统紧接着进行参数检查找出所需要的调用进程。然后执行系统调用,把控制权移交给系统调用下面的指令。大致来说,系统调用就像是执行了一个特殊的过程调用,但是只有系统调用能够进入内核态而过程调用则不能进入内核态

  • 为了能够了解具体的调用过程,下面我们以 read 方法为例来看一下调用过程。像上面提到的那样,会有三个参数,第一个参数是指定文件、第二个是指向缓冲区、第三个参数是给定需要读取的字节数。就像几乎所有系统调用一样,它通过使用与系统调用相同的名称来调用一个函数库,从而从C程序中调用:read。count = read(fd,buffer,nbytes);

  • 系统调用在 count 中返回实际读出的字节数。这个值通常与 nbytes 相同,但也可能更小。比如在读过程中遇到了文件尾的情况

  • 如果系统调用不能执行,不管是因为无效的参数还是磁盘错误,count 的值都会被置成 -1,然后在全局变量 errno 中放入错误信号。程序应该进场检查系统调用的结果以了解是否出错

  • 系统调用是通过一系列的步骤实现的,为了更清楚的说明这个概念,我们还以 read 调用为例,在准备系统调用前,首先会把参数压入堆栈,如下所示

    系统调用

  • C 和 C++ 编译器使用逆序(必须把第一个参数赋值给 printf(格式字符串),放在堆栈的顶部)。第一个参数和第三个参数都是值调用,但是第二个参数通过引用传递,即传递的是缓冲区的地址(由 & 指示),而不是缓冲的内容。然后是 C 调用系统库的 read 函数,这也是第四步

  • 在由汇编语言写成的库过程中,一般把系统调用的编号放在操作系统所期望的地方,如寄存器(第五步)。然后执行一个 TRAP 指令,将用户态切换到内核态,并在内核中的一个固定地址开始执行第六步。TRAP 指令实际上与过程调用指令非常相似,它们后面都跟随一个来自远处位置的指令,以及供以后使用的一个保存在栈中的返回地址

  • TRAP 指令与过程调用指令存在两个方面的不同

    • TRAP 指令会改变操作系统的状态,由用户态切换到内核态,而过程调用不改变模式
    • 其次,TRAP 指令不能跳转到任意地址上。根据机器的体系结构,要么跳转到一个单固定地址上,或者指令中有一 8 位长的字段,它给定了内存中一张表格的索引,这张表格中含有跳转地址,然后跳转到指定地址上
  • 跟随在 TRAP 指令后的内核代码开始检查系统调用编号,然后dispatch给正确的系统调用处理器,这通常是通过一张由系统调用编号所引用的、指向系统调用处理器的指针表来完成第七步。此时,系统调用处理器运行第八步,一旦系统调用处理器完成工作,控制权会根据 TRAP 指令后面的指令中返回给函数调用库第九步。这个过程接着以通常的过程调用返回的方式,返回到客户应用程序,这是第十步。然后调用完成后,操作系统还必须清除用户堆栈,然后增加堆栈指针(increment stackpointer),用来清除调用 read 之前压入的参数。从而完成整个 read 调用过程

  • 在上面的第九步中我们说道,控制可能返回 TRAP 指令后面的指令,把控制权再移交给调用者这个过程中,系统调用会发生阻塞,从而避免应用程序继续执行。这么做是有原因的。例如,如果试图读键盘,此时并没有任何输入,那么调用者就必须被阻塞。在这种情形下,操作系统会检查是否有其他可以运行的进程。这样,当有用户输入 时候,进程会提醒操作系统,然后返回第 9 步继续运行

  • 下面,我们会列出一些常用的 POSIX 系统调用,POSIX 系统调用大概有 100 多个,它们之中最重要的一些调用见下表

  • 进程管理

    调用 说明
    pid = fork() 创建与父进程相同的子进程
    pid = waitpid(pid, &statloc,options) 等待一个子进程终止
    s = execve(name,argv,environp) 替换一个进程的核心映像
    exit(status) 终止进程执行并返回状态
  • 文件管理

    调用 说明
    fd = open(file, how,…) 打开一个文件使用读、写
    s = close(fd) 关闭一个打开的文件
    n = read(fd,buffer,nbytes) 把数据从一个文件读到缓冲区中
    n = write(fd,buffer,nbytes) 把数据从缓冲区写到一个文件中
    position = iseek(fd,offset,whence) 移动文件指针
    s = stat(name,&buf) 取得文件状态信息
  • 目录和文件系统管理

    调用 说明
    s = mkdir(nname,mode) 创建一个新目录
    s = rmdir(name) 删去一个空目录
    s = link(name1,name2) 创建一个新目录项 name2,并指向 name1
    s = unlink(name) 删去一个目录项
    s = mount(special,name,flag) 安装一个文件系统
    s = umount(special) 卸载一个文件系统
  • 其他

    调用 说明
    s = chdir(dirname) 改变工作目录
    s = chmod(name,mode) 修改一个文件的保护位
    s = kill(pid, signal) 发送信号给进程
    seconds = time(&seconds) 获取从 1970 年1月1日至今的时间
  • 上面的系统调用参数中有一些公共部分,例如 pid 系统进程 id,fd 是文件描述符,n 是字节数,position 是在文件中的偏移量、seconds 是流逝时间

  • 从宏观角度上看,这些系统调所提供的服务确定了多数操作系统应该具有的功能,下面分别来对不同的系统调用进行解释

2. 用于进程管理的系统调用

  • 在 UNIX 中,fork 是唯一可以在 POSIX 中创建进程的途径,它创建一个原有进程的副本,包括所有的文件描述符、寄存器等内容。在 fork 之后,原有进程以及副本(父与子)就分开了。在 fork 过程中,所有的变量都有相同的值,虽然父进程的数据通过复制给子进程,但是后续对其中任何一个进程的修改不会影响到另外一个。fork 调用会返回一个值,在子进程中该值为 0 ,并且在父进程中等于子进程的进程标识符(Process IDentified,PID)。使用返回的 PID,就可以看出来哪个是父进程和子进程

  • 在多数情况下, 在 fork 之后,子进程需要执行和父进程不一样的代码。从终端读取命令,创建一个子进程,等待子进程执行命令,当子进程结束后再读取下一个输入的指令。为了等待子进程完成,父进程需要执行 waitpid 系统调用,父进程会等待直至子进程终止(若有多个子进程的话,则直至任何一个子进程终止)。waitpid 可以等待一个特定的子进程,或者通过将第一个参数设为 -1 的方式,等待任何一个比较老的子进程。当 waitpid 完成后,会将第二个参数 statloc 所指向的地址设置为子进程的退出状态(正常或异常终止以及退出值)。有各种可使用的选项,它们由第三个参数确定。例如,如果没有已经退出的子进程则立刻返回

  • 那么 shell 该如何使用 fork 呢?在键入一条命令后,shell 会调用 fork 命令创建一个新的进程。这个子进程会执行用户的指令。通过使用 execve 系统调用可以实现系统执行,这个系统调用会引起整个核心映像被一个文件所替代,该文件由第一个参数给定。下面是一个简化版的例子说明 fork、waitpid 和 execve 的使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    #define TRUE 1

    /* 一直循环下去 */
    while(TRUE){

    /* 在屏幕上显示提示符 */
    type_prompt();

    /* 从终端读取输入 */
    read_command(command,parameters)

    /* fork 子进程 */
    if(fork() != 0){

    /* 父代码 */
    /* 等待子进程执行完毕 */
    waitpid(-1, &status, 0);
    }else{

    /* 执行命令 */
    /* 子代码 */
    execve(command,parameters,0)
    }
    }
  • 一般情况下,execve 有三个参数:将要执行的文件名称,一个指向变量数组的指针,以及一个指向环境数组的指针。这里对这些参数做一个简要的说明

  • 先看一个 shell 指令 cp file1 file2。此命令把 file1 复制到 file2 文件中,在 shell 执行 fork 之后,子进程定位并执行文件拷贝,并将源文件和目标文件的名称传递给它

  • cp 的主程序(以及包含其他大多数 C 程序的主程序)包含声明 main(argc,argv,envp)

  • 其中 argc 是命令行中参数数目的计数,包括程序名称。对于上面的例子,argc 是3。第二个参数argv 是数组的指针。该数组的元素 i 是指向该命令行第 i 个字符串的指针。在上面的例子中,argv[0] 指向字符串 cp,argv[1] 指向字符串 file1,argv[2] 指向字符串 file2。main 的第三个参数是指向环境的指针,该环境是一个数组,含有 name = value 的赋值形式,用以将诸如终端类型以及根目录等信息传送给程序。这些变量通常用来确定用户希望如何完成特定的任务(例如,使用默认打印机)。在上面的例子中,没有环境参数传递给 execve ,所以环境变量是 0 ,所以 execve 的第三个参数为 0

  • 可能你觉得 execve 过于复杂,这时候我要鼓励一下你,execve 可能是 POSIX 的全部系统调用中最复杂的一个了,其他都比较简单。作为一个简单的例子,我们再来看一下 exit ,这是进程在执行完成后应执行的系统调用。这个系统调用有一个参数,它的退出状态是 0 - 255 之间,它通过 waitpid 系统调用中的 statloc 返回给父级

  • UNIX 中的进程将内存划分成三个部分:text segment,文本区,例如程序代码,data segment,数据区,例如变量,stack segment,栈区域。数据向上增长而堆栈向下增长,如下图所示

    调用

  • 上图能说明三个部分的内存分配情况,夹在中间的是空闲区,也就是未分配的区域,堆栈在需要时自动的挤压空闲区域,不过数据段的扩展是显示地通过系统调用 brk 进行的,在数据段扩充后,该系统调用指向一个新地址。但是,这个调用不是 POSIX 标准中定义的,对于存储器的动态分配,鼓励程序员使用 malloc 函数,而 malloc 的内部实现则不是一个适合标准化的主题,因为几乎没有程序员直接使用它

3. 用于文件管理的系统调用

  • 许多系统调用都与文件系统有关,要读写一个文件,必须先将其打开。这个系统调用通过绝对路径名或指向工作目录的相对路径名指定要打开文件的名称,而代码 O_RDONLYO_WRONLYO_RDWR 的含义分别是只读、只写或者两者都可以,为了创建一个新文件,使用 O_CREATE 参数。然后可使用返回的文件描述符进行读写操作。接着,可以使用 close 关闭文件,这个调用使得文件描述符在后续的 open 中被再次使用
  • 最常用的调用还是 read 和 write,我们再前面探讨过 read 调用,write 具有与 read 相同的参数
  • 尽管多数程序频繁的读写文件,但是仍有一些应用程序需要能够随机访问一个文件的任意部分。与每个文件相关的是一个指向文件当前位置的指针。在顺序读写时,该指针通常指向要读出(写入)的下一个字节。Iseek 调用可以改变该位置指针的值,这样后续的 read 或 write 调用就可以在文件的任何地方开始
  • Iseek 有三个参数position = iseek(fd,offset,whence),第一个是文件描述符,第二个是文件位置,第三个是说明该文件位置是相对于文件起始位置,当前位置还是文件的结尾。在修改了指针之后,Iseek 所返回的值是文件中的绝对位置
  • UNIX 为每个文件保存了该文件的类型(普通文件、特殊文件、目录等)、大小,最后修改时间以及其他信息,程序可以通过 stat 系统调用查看这些信息。s = stat(name,&buf),第一个参数指定了被检查的文件;第二个参数是一个指针,该指针指向存放这些信息的结构。对于一个打开的文件而言,fstat 调用完成同样的工作

4. 用于目录管理的系统调用

  • 下面我们探讨目录和整个文件系统的系统调用,上面探讨的是和某个文件有关的系统调用。 mkdir 和 rmdir 分别用于创建s = mkdir(nname,mode) 和删除 s = rmdir(name) 空目录,下一个调用是 s = link(name1,name2) 它的作用是允许同一个文件以两个或者多个名称出现,多数情况下是在不同的目录中使用 link ,下面我们探讨一下 link 是如何工作的

    目录

  • 图中有两个用户 ast 和 jim,每个用户都有他自己的一个目录和一些文件,如果 ast 要执行一个包含下面系统调用的应用程序 link("/usr/jim/memo", "/usr/ast/note");

  • jim 中的 memo 文件现在会进入到 ast 的目录中,在 note 名称下。此后,/usr/jim/memo/usr/ast/note 会有相同的名称

    用户目录是保存在 /usr,/user,/home 还是其他位置,都是由本地系统管理员决定的

  • 要理解 link 是如何工作的需要清楚 link 做了什么操作。UNIX 中的每个文件都有一个独一无二的版本,也称作 i - number,i-编号,它标示着不同文件的版本。这个 i - 编号是 i-nodes,i-节点 表的索引。每个文件都会表明谁拥有这个文件,这个磁盘块的位置在哪,等等。目录只是一个包含一组(i编号,ASCII名称)对应的文件。UNIX 中的第一个版本中,每个目录项都会有 16 个字节,2 个字节对应 i - 编号和 14 个字节对应其名称。现在需要一个更复杂的结构需要支持长文件名,但是从概念上讲一个目录仍是一系列(i-编号,ASCII 名称)的集合。在上图中,mail 的 i-编号为 16,依此类推。link 只是利用某个已有文件的 i-编号,创建一个新目录项(也许用一个新名称)。在上图 b 中,你会发现有两个相同的 70 i-编号的文件,因此它们需要有相同的文件。如果其中一个使用了 unlink 系统调用的话,其中一个会被移除,另一个将保留。如果两个文件都移除了,则 UNIX 会发现该文件不存在任何没有目录项(i-节点中的一个域记录着指向该文件的目录项),就会把该文件从磁盘中移除

  • 就像我们上面提到过的那样,mount 系统 s = mount(special,name,flag) 调用会将两个文件系统合并为一个。通常的情况是将根文件系统分布在硬盘(子)分区上,并将用户文件分布在另一个(子)分区上,该根文件系统包含常用命令的二进制(可执行)版本和其他使用频繁的文件。然后,用户就会插入可读取的 USB 硬盘

  • 通过执行 mount 系统调用,USB 文件系统可以被添加到根文件系统中。如果用 C 语言来执行那就是 mount("/dev/sdb0","/mnt",0)

  • 这里,第一个参数是 USB 驱动器 0 的块特殊文件名称,第二个参数是被安装在树中的位置,第三个参数说明将要安装的文件系统是可读写的还是只读的。当不再需要一个文件系统时,可以使用 umount 移除之

5. 其他系统调用

  • 除了进程、文件、目录系统调用,也存在其他系统调用的情况,下面我们来探讨一下。我们可以看到上面其他系统调用只有四种,首先来看第一个 chdir,chdir 调用更改当前工作目录,在调用 chdir("/usr/ast/test"); 后,打开 xyz 文件,会打开 /usr/ast/test/xyz 文件,工作目录的概念消除了总是需要输入长文件名的需要
  • 在 UNIX 系统中,每个文件都会有保护模式,这个模式会有一个读-写-执行位,它用来区分所有者、组和其他成员。chmod 系统调用提供改变文件模式的操作。例如,要使一个文件除了对所有者之外的用户可读,你可以执行 chmod("file",0644);
  • kill 系统调用是用户和用户进程发送信号的方式,如果一个进程准备好捕捉一个特定的信号,那么在信号捕捉之前,会运行一个信号处理程序。如果进程没有准备好捕捉特定的信号,那么信号的到来会杀掉该进程(此名字的由来)
  • POSIX 定义了若干时间处理的进程。例如,time 以秒为单位返回当前时间,0 对应着 1970 年 1月 1日。在一台 32 位字的计算机中,time 的最大值是 (2^32) - 1秒,这个数字对应 136 年多一点。所以在 2106 年,32 位的 UNIX 系统会发飙。如果读者现在有 32 位 UNIX 系统,建议在 2106 年更换位 64 位操作系统(偷笑~)

6. Win 32 API

  • 上面我们提到的都是 UNIX 系统调用,现在我们来聊聊 Win 32 中的系统调用。Windows 和 UNIX 在各自的编程方式上有着根本的不同。UNIX 程序由执行某些操作或执行其他操作的代码组成,进行系统调用以执行某些服务。Windows 系统则不同,Windows 应用程序通常是由事件驱动的。主程序会等待一些事件发生,然后调用程序去处理。最简单的事件处理是键盘敲击和鼠标滑过,或者是鼠标点击,或者是插入 USB 驱动,然后操作系统调用处理器去处理事件,更新屏幕和更新程序内部状态。这是与 UNIX 不同的设计风格

  • 当然,Windows 也有系统调用。在 UNIX 中,系统调用(比如 read)和系统调用所使用的调用库(例如 read)几乎是一对一的关系。而在 Windows 中,情况则大不相同。首先,函数库的调用和实际的系统调用几乎是不对应的。微软定义了一系列过程,称为 Win32应用编程接口(Application Programming Interface),程序员通过这套标准的接口来实现系统调用。这个接口支持从 Windows 95 版本以来所有的 Windows 版本

  • Win32 API 调用的数量是非常巨大的,有数千个多。但这些调用并不都是在内核态的模式下运行时,有一些是在用户态的模型下运行。Win32 API 有大量的调用,用来管理视窗、几何图形、文本、字体、滚动条、对话框、菜单以及 GUI 的其他功能。为了使图形子系统在内核态下运行,需要系统调用,否则就只有函数库调用

  • 我们把关注点放在和 Win32 系统调用中来,我们可以简单看一下 Win32 API 中的系统调用和 UNIX 中有什么不同(并不是所有的系统调用)

    UNIX Win32 说明
    fork CreateProcess 创建一个新进程
    waitpid WaitForSingleObject 等待一个进程退出
    execve none CraeteProcess = fork + servvice
    exit ExitProcess 终止执行
    open CreateFile 创建一个文件或打开一个已有的文件
    close CloseHandle 关闭文件
    read ReadFile 从单个文件中读取数据
    write WriteFile 向单个文件写数据
    lseek SetFilePointer 移动文件指针
    stat GetFileAttributesEx 获得不同的文件属性
    mkdir CreateDirectory 获得不同的文件属性
    rmdir RemoveDirectory 移除一个空的目录
    link none Win32 不支持 link
    unlink DeleteFile 销毁一个已有的文件
    mount none Win32 不支持 mount
    umount none Win32 不支持 mount,所以也不支持mount
    chdir SetCurrentDirectory 切换当前工作目录
    chmod none Win32 不支持安全
    kill none Win32 不支持安全
    time GetLocalTime Win32 不支持安全
  • 上表中是 UNIX 调用大致对应的 Win32 API 系统调用,简述一下上表。CreateProcess 用于创建一个新进程,它把 UNIX 中的 fork 和 execve 两个指令合成一个,一起执行。它有许多参数用来指定新创建进程的性质。Windows 中没有类似 UNIX 中的进程层次,所以不存在父进程和子进程的概念。在进程创建之后,创建者和被创建者是平等的。WaitForSingleObject 用于等待一个事件,等待的事件可以是多种可能的事件。如果有参数指定了某个进程,那么调用者将等待指定的进程退出,这通过 ExitProcess 来完成

  • 然后是6个文件操作,在功能上和 UNIX 的调用类似,然而在参数和细节上是不同的。和 UNIX 中一样,文件可以打开,读取,写入,关闭。SetFilePointer 和 GetFileAttributesEx 设置文件的位置并取得文件的属性

  • Windows 中有目录,目录分别用 CreateDirectory 以及 RemoveDirectory API 调用创建和删除。也有对当前的目录的标记,这可以通过 SetCurrentDirectory 来设置。使用GetLocalTime 可获得当前时间

  • Win32 接口中没有文件的链接、文件系统的 mount、umount 和 stat ,当然, Win32 中也有大量 UNIX 中没有的系统调用,特别是对 GUI 的管理和调用

-------------------- 本文结束感谢您的阅读 --------------------