13系统调用

13系统调用

首先简要地看一下系统程序设计,把标准库的库例程和对应的系统调用区分清楚。接下来仔细考察内核源代码,以描述用于从用户空间切换到内核空间的机制。我们将描述用于实现系统调用的基础设施,并讨论特别的实现方面的特性

13.1 系统程序设计基础

13.1.1 追踪系统调用

例子,UNIX head命令的一个非常简单的版本:

#include<stdio.h> 
#include<fcntl.h> 
#include<unistd.h> 
#include<malloc.h> 
int main() { 
    int handle, bytes; 
    void* ptr; 
    handle = open("/tmp/test.txt", O_RDONLY); 
    ptr = (void*)malloc(150); 
    bytes = read(handle, ptr, 150); 
    printf("%s", ptr); 
    close(handle); 
    return 0; 
}

使用 strace 工具,它可以记录应用程序发出的所有系统调用并将该信息提供给程序员,在调试程序时,这个工具是不可缺少的.它使用了系统调用(ptrace)

使用strace命令将shead(上述的例子程序)发出的所有系统调用的列表写到log.txt中:

wolfgang@meitner> strace -o log.txt ./shead

log.txt 文件中内容如下:

execve("./shead", ["./shead"], [/* 27 vars */]) = 0 
uname(sys="Linux", node="jupiter", ...) = 0 
brk(0) = 0x8049750 
old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, ..., -1, 0) = 0x40017000 
open("/etc/ld.so.preload", O_RDONLY) = -1 ENOENT (No such file or directory) 
open("/etc/ld.so.cache", O_RDONLY) = 3 
fstat64(3, st_mode=S_IFREG|0644, st_size=85268, ...) = 0 
old_mmap(NULL, 85268, PROT_READ, MAP_PRIVATE, 3, 0) = 0x40018000 
close(3) = 0 
open("/lib/i686/libc.so.6", O_RDONLY) = 3 
read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\200\302"..., 1024) = 1024 
fstat64(3, st_mode=S_IFREG|0755, st_size=5634864, ...) = 0 
old_mmap(NULL, 1242920, PROT_READ|PROT_EXEC, MAP_PRIVATE, 3, 0) = 0x4002d000 
mprotect(0x40153000, 38696, PROT_NONE) = 0 
old_mmap(0x40153000, 24576, PROT_READ|PROT_WRITE, ..., 3, 0x125000) = 0x40153000 
old_mmap(0x40159000, 14120, PROT_READ|PROT_WRITE, ..., -1, 0) = 0x40159000 
close(3) = 0 
munmap(0x40018000, 85268) = 0 
getpid() = 10604 
open("/tmp/test.txt", O_RDONLY) = 3 
brk(0) = 0x8049750 
brk(0x8049800) = 0x8049800 
brk(0x804a000) = 0x804a000 
read(3, "A black cat crossing your path s"..., 150) = 109 
fstat64(1, st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...) = 0 
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x40018000 
ioctl(1, TCGETS, B38400 opost isig icanon echo ...) = 0 
write(1, "A black cat crossing your path s"..., 77) = 77 
write(1, " -- Groucho Marx\n", 32) = 32 
munmap(0x40018000, 4096) = 0 
_exit(0) = ?

其他的系统调用是由启动和运行应用程序所需的框架代码生成的,例如,C标准库是动态映射到进程内存区的。其他调用,如old_mmap和unmap,负责管理应用程序使用的动态内存区域。

3个直接使用的系统调用open、read和close,都转换为对相应的内核函数的调用.标准库的另外两个例程在内部使用了不同名的系统调用

malloc在内部执行了brk系统调用.系统调用的记录表明,malloc的内部算法执行了该调用3次,每次参数都不同

printf首先处理传递的参数,在这里是一个动态字符串,并用write系统调用显示结果

使用strace工具还有一个好处,无须接触所跟踪应用程序的源代码即可了解其内部结构和运作方式。

GNU标准库还包括一个通用例程,如果没有包装器例程可用,可以根据编号来执行系统调用

13.1.2 支持的标准

POSIX标准(这是Portable Operating System Interface for UNIX的首字母缩写词,也揭示了该标准的目的)已经成为该领域的主导标准。Linux和C标准库尽力遵循POSIX标准.Linux内核基本上与POSIX-1003.1标准兼容

除了POSIX之外,还有其他标准,这些不是由某个委员会制定的,而是来源于UNIX和类UNIX操作系统的开发。在UNIX的历史中,两条开发主线产生了两个独立的系统,一个是System V(直接起源于AT&T的原始代码),另一个是BSD(Berkeley Software Distribution,在加州大学开发,现在市场上的NetBSD、FreeBSD、OpenBSD都是基于BSD的,还有基于BSD的商业系统,如BSDI和MacOS X)

Linux提供的系统调用汲取自所有上述3个来源

例如,下面列出的3个著名的系统调用起源于3个不同的阵营。

  1. flock锁定一个文件,防止这个文件被几个进程并行访问,以确保文件的一致性。该调用是由POSIX标准规定的
  2. BSD UNIX提供了truncate调用,用于按指定的字节数截短一个文件。Linux也以同样的名称实现了该函数
  3. sysfs收集内核已知的文件系统有关的信息,在SVR4(System V Release 4)中引入。 Linux也采用了该系统调用。但Linux开发者并不全然同意System V设计者对该调用实际价值的观点,至少,源代码的注释中写了“Whee… Weird sysv syscall”.现在,该信息可以通过读取/proc/filesystems更容易地获取。

有些系统调用是所有3个标准都需要的。例如,time、gettimeofday、settimeofday在System V、POSIX、4.3BSD中的形式是相同的,在Linux内核中也是如此

同样,有些系统调用是特地为Linux开发的,在其他标准/系统中或者根本不存在,或者名称不同。一个例子是vm86系统调用它是在IA-32处理器上实现DOS仿真程序的基础。更一般的调用,诸如用于暂停进程执行很短一段时间的nanosleep,也是Linux特有的系统调用

有些情况下,两个系统调用是解决同一问题的不同方法。主要的例子是poll和select系统调用,前一个在System V中引入,后者在4.3BSD中引入。最终,二者执行的功能是相同的

总之,只实现POSIX标准并不能建立一个完整的UNIX系统,除了名称之外,这种做法一文不值POSIX无非是一组接口的集合,其具体实现并不是强制性的,也不一定要归入到内核来实现。因而,虽然有些操作系统本身的设计是非UNIX的,但它们却以普通的函数库完全实现了POSIX标准,以促进UNIX应用程序的移植

13.1.3 重启系统调用

在系统调用与信号冲突时,会发生一个有趣的问题。如果在一个进程执行系统调用时,向该进程发送一个信号,那么在处理时,二者的优先级如何分配呢?应该等到系统调用结束再处理信号,还是中断系统调用,以便尽快将信号投递到该进程?第一种方案导致的问题显然比较少,也是比较简单的方案。遗憾的是,只有在所有系统调用都能够快速结束、不会让进程等待太长时间的情况下,这个方案才能正确运作(在第5章提到过,信号投递的时机,总是在进程处理完一个系统调用、返回到用户态的时候)。情况不总是这样。系统调用不仅需要一定的执行时间,而且在最坏情况下,很可能使进程睡眠(例如,没有数据可供读取时)。对同时发生的信号而言,这意味着信号投递的严重延迟。因而,必须不惜任何代价防止这种情况

如果一个正在执行的系统调用被中断,内核应该向应用程序返回什么样的值?在通常的场景下,只有两种情况:调用成功或者失败。在出错的情况下,将返回一个错误码,使用户进程能够确定错误的原因,并适当地做出反应。倘若系统调用被中断,则发生了第三种情况:必须通知应用程序,如果系统调用在执行期间没有被信号中断,那么系统调用已经成功结束。在这种情况下,Linux(和其他System V变体)下将使用-EINTR常数

该过程的负面效应是很明显的。尽管该方案易于实现,但它迫使用户空间应用程序的程序员必须明确检查所有系统调用的返回值,并在返回值为-EINTR的情况下,重新启动被中断的系统调用,直至该调用不再被信号中断。用这种方法重启的系统调用称作可重启系统调用(restartable system call),该技术则称为重启(restarting)

该行为第一次引入是在System V UNIX中。该方案将新信号的快速投递和系统调用的中断组合起来,但它并非是唯一的组合方式,BSD所采用的方法即可证实这一点。我们来考察BSD内核在系统调用被信号中断时,会做出何种反应

BSD内核将中断系统调用的执行并切换到用户态执行信号处理程序。在发生这种情况时,该系统调用不会有返回值,内核在信号处理程序结束后将自动重启该调用。因为该行为对用户应用程序是透明的,也不再需要重复实现对-EINTR返回值的检查和调用的重启,所以与System V方法相比,这种方案更受程序员的欢迎

Linux通过SA_RESTART标志支持BSD方案,可以在安装信号处理例程时按需对具体信号指定该标志。System V提议的机制用作默认方案,因为BSD机制偶尔会导致一些困难,如下列例子所示(取自[ME02]第229页)。

#include <signal.h> 
#include <stdio.h> 
#include <unistd.h> 
volatile int signaled = 0; 
void handler (int signum) { 
    printf("signaled called\n"); 
    signaled = 1;
} 
int main() { 
    char ch; 
    struct sigaction sigact; 
    sigact.sa_handler = handler; 
    sigact.sa_flags = SA_RESTART; 
    sigaction(SIGINT, &sigact, NULL); 
    while (read(STDIN_FILENO, &ch, 1) != 1 && !signaled); 
}

这个简短的C程序在一个while循环中等待,直至用户通过标准输入键入了一个字符,或者程序被SIGINT信号中断(可使用kill-INT发送该信号,也可以按键CTRL+C)。我们来考察其代码的控制流。如果用户点了一个普通的按键,没有导致发送SIGINT,那么read将得到一个正的返回值,即读取字符的数目。

要结束while循环,循环的控制条件必须在逻辑上为false。这里的控制条件是由逻辑与(&&)运算连接的两个表达式,要结束循环,需要二者之一为false,或全部为false,如下。

  1. 按下了一个键,read返回1,检查read返回不等于1的表达式,其值为false
  2. signaled变量设置为1,该变量的反(!signaled)也将为false值

这些条件意味着,程序要结束,或者需要等到键盘输入,或者需要SIGINT信号到达

为在上述代码中应用Linux默认实现的System V行为,需要取消SA_RESTART标志的设置。换句话说,sigact.sa_flags = SA_RESTART一行需要删除或注释掉。在这样做之后,程序将按上面的描述运行,在按下一个键或接收到SIGINT时结束

如果激活了BSD行为模式,而read被SIGINT信号中断,那么示例程序的情况将更为有趣。在这种情况下,将调用信号处理程序,将signaled设置为1,并输出一个消息表示接收到了SIGINT,但程序不会结束。为什么?在运行处理程序之后,BSD机制将重启read调用,并再次等待输入一个字符。这种情况使得while循环控制条件中的!signaled部分无法进行求值,导致循环不能结束。因而该程序不能通过向其发送SIGNIT信号结束,尽管在表面上,代码的语义可以结束

13.2 可用的系统调用

每个系统调用都通过一个符号常数标识,符号常数的定义是平台相关的,在<asm-arch/unistd.h>中指定。因为并非所有体系结构都支持所有的系统调用(有些组合是无意义的),不同的平台上可用调用的数目会有一定的不同,粗略地说,总共有200多个系统调用。随着时间的流逝,内核对系统调用实现的各种更改使得一些调用现在是多余的,其编号现在已经不再使用。Linux在Sparc(32位处理器)上的移植版本就有很多废弃的系统调用,在调用编号列表中形成了“缺口”。

根据功能将系统调用分类如下(只列出比较重要的):

  1. 进程管理
    • fork和vfork将一个现存进程分支为两个新进程,如第2章所述。clone是fork的增强版,除了具有fork的功能,还支持创建线程。
    • exit结束一个进程并释放其资源
    • 有一大堆系统调用可用于查询(和设置)进程的属性,如PID、UID、等等,其中大多数调用只是读取或修改task_struct中的字段而已。可以读取下列属性:PID、GID、PPID、SID、UID、EUID、PGID、EGID、PGRP。可以设置下列属性:UID、GID、REUID、REGID、SID、SUID和FSGID。使用的名称诸如setgid、setuid和geteuid等
    • personality定义了应用程序的执行环境,例如,可用于二进制仿真的实现
    • ptrace使得能够跟踪系统调用,它是strace工具的基础。
    • nice设置普通进程的优先级,它给进程分配的优先级在-20和19之间,随数值的升高优先级递减。只有root进程(或有CAP_SYS_NICE权限的进程)才能指定负的优先级值
    • setrlimit用于设置一定的资源限制,例如,CPU时间或子进程的最大容许数目。getrlimit查询当前的限制(即允许的最大值),而getrusage查询当前资源使用情况,检查进程是否合乎定义的资源限制
  2. 时间操作
    • adjtimex读取和设置基于时间的内核变量,以控制内核在时间方面的行为
    • alarm和setitimer建立报警器和间隔定时器,将操作延迟到一个稍后的时间执行。getitimer读取设置
    • gettimeofday和settimeofday分别获取和设置当前系统时间。与time不同,这两个函数还考虑了当前时区和夏令时的因素
    • sleep和nanosleep让进程执行暂停一个指定的时间段。nanosleep可以高精度的时间单位来指定暂停的时间段
    • time返回自1970年1月1日零时(这个日期是UNIX系统经典的时间基线)以来经过的秒数。stime设置这个值,因而也会改变当前系统的日期
  3. 信号处理
    • signal设置信号处理函数。sigaction是signal的现代增强版本,支持附加的选项,并提供了更大的灵活性
    • sigpending检查进程当前是否有待决信号被阻塞。
    • sigsuspend将进程置于等待队列上,直至某个特定(一组信号中的一个)的信号到达。
    • setmask启用信号的阻塞机制,而getmask返回所有当前阻塞信号的列表。
    • kill用于向一个进程发送任何信号。
    • 还有一组处理实时信号的系统调用,但其对应的函数名带有前缀rt_。例如,rt_sigaction设置一个实时信号处理程序,而rt_sigsuspend将进程置于等待状态,直至某个特定(一组信号中的一个)信号到达。
  4. 调度
    • setpriority和getpriority分别设置和获取进程的优先级,因而是用于调度目的的关键系统调用
    • 请注意,Linux不仅支持不同的进程优先级,还提供了多种调度类,以适应应用程序在时间方面具体的行为和需求。sched_setscheduler和sched_getscheduler分别设置和查询调度类。sched_setparam和sched_getparam分别设置和查询进程的附加调度参数(当前,只使用了实时优先级的参数)。
    • sched_yield自愿释放CPU的控制权,即使进程当前仍然有CPU时间可用
  5. 模块
    • init_module添加一个新模块
    • delete_module从内核移除一个模块
  6. 文件系统
    • 一些系统调用被用作用户空间中同名实用程序的直接基础,用来创建和修改目录结构:chdir、mkdir、rmdir、rename、symlink、getcwd、chroot、umask和mknod。
    • 文件和目录属性可以用chown和chmod修改
    • 下列实用程序用于处理文件内容,其实现在标准库中,与对应的系统调用同名:open、close、read与readv、write与writev、truncate和llseek
    • readdir和getdents读取目录结构
    • link、symlink和unlink创建和删除链接(或文件,如果该文件是某个硬链接的最后一个成员)。readlink读取链接的内容
    • mount和umount用于文件系统的装载和卸载
    • poll和select用于等待某些事件
    • execve装载一个新进程,替换旧的进程。在与fork联合使用时,它会启动一个新的程序
  7. 内存管理
    • 就动态内存管理而言,最重要的调用是brk,它修改进程数据段的长度。调用了malloc或相似函数的程序(几乎所有非平凡的代码,都符合这个条件)会频繁使用该系统调用
    • mmap、mmap2、munmap和mremap执行内存映射、解除映射和重新映射操作,而mprotect控制对虚拟内存中特定区域的访问,madvice提出对特定虚拟内存区域的使用建议.mmap和mmap2的参数稍有不同,更多细节请参考手册页。默认情况下,GNU C库使用mmap2;现在mmap只是一个用户层包装器函数.根据malloc的实现,它在内部可以使用mmap或mmap2。这是可行的,因为匿名映射允许建立没有文件作为后备存储的映射。与使用brk相比,该方法更加灵活
    • swapon和swapoff分别启用和禁用外存储器设备上(附加)的交换区
  8. 进程间通信和网络功能
    • socketcall处理网络方面的问题,用于实现套接字抽象。它管理各种类型的连接和协议,总共实现了17种功能,通过SYS_ACCEPT、SYS_SENDTO等常数来区分。参数必须以指针形式传递,指向一个与函数类型相关的用户空间结构,其中保存了所需的数据
    • ipc与socketcall相对应,用于处理计算机本地的连接,而不是通过网络建立的连接。因为该系统调用“只”需要实现11种功能,它使用了固定数目的参数来从用户空间向内核空间传递数据,总共是5个
  9. 系统信息和设置
    • syslog向系统日志写入消息,并允许设置不同的优先级(根据消息的优先级不同,用户空间工具或者向持久性的日志文件发送消息,或者直接向控制台输出消息以通知用户某些关键情况
    • sysinfo返回有关系统状态的信息,特别有关内存使用的统计量(物理内存、缓冲区、交换区)
    • sysctl用于“微调”内核参数。内核现在支持大量的动态可配置选项,可以使用proc文件系统读取和修改
  10. 系统安全和能力
    传统的UNIX安全模型基于用户、组和一个“万能的”root用户,对现代需求而言已经不够灵活。这就导致引入了能力系统,该系统根据细粒度方案,使得非root进程能够拥有额外的权限和能力。
    此外,LSM(Linux security modules,Linux安全模块)子系统提供了一个通用接口,支持内核在各个位置通过挂钩调用模块函数来执行安全检查。
    • capset和capget负责设置和查询进程的能力
    • security是一个系统调用的多路分解器,用于实现LSM。

13.3 系统调用的实现

13.3.1 系统调用的结构

用于实现系统调用的内核代码划分为两个颇为不同的部分。系统调用执行的实际任务实现为一个C例程,与其余内核代码几乎没有差别。用于调用该例程的机制则充满了平台相关的特性,必须考虑大量细节,因而最终实现使用汇编语言代码是必然的

  1. 处理程序函数的实现(C语言实现部分)
    例如,所有文件相关的系统调用都在fs/内核子目录下,因为它们与虚拟文件系统直接交互。同样地,所有的内存管理调用都在mm/子目录的文件中
    用于实现系统调用的处理程序函数,在形式上有如下几个共同的特性

    1. 每个函数的名称前缀都是sys_,将该函数唯一地标识为一个系统调用,更精确地说,标识为一个系统调用的处理程序函数。
    2. 所有的处理程序函数都最多接受5个参数。这些参数在参数列表中指定,与普通的C函数相同(提供参数值的方式与传统方法稍有不同)
    3. 所有的系统调用都在核心态执行

    在内核将控制权转移给处理程序例程后,控制流就进入了平台中立的代码,即不依赖于特定的CPU或体系结构。但因为各种原因,也有一些例外。有少量处理程序函数是针对各个平台分别实现的。在返回结果时,处理程序函数无须进行特别的操作,简单的一个return后接返回值即可。在核心态和用户态之间的切换,由特定于平台的内核代码执行,这与中断处理程序是无关的。如下图:

    在这里插入图片描述

    所有处理程序函数都有一个共同点。每个函数说明都包括了额外的(asmlinkage)限定符,这不是C语言语法的标准成分。asmlinkage是一个汇编语言宏,定义在<linkage.h>中。对大多数平台来说,它根本什么都不做!
    但该宏连同附录C讨论的GCC增强特性(attribute)一同在IA-32和IA-64系统上使用时,只是为了通知编译器该函数的特别的调用规范

  2. 调用分派和参数传递
    系统调用由内核分配的一个编号唯一标识。所有的系统调用都由一处中枢代码处理,根据调用编号和一个静态表,将调用分派到具体的函数。传递的参数也由中枢代码处理,这样参数的传递独立于实际的系统调用。
    从用户态切换到核心态,以及调用分派和参数传递,都是由汇编语言代码实现的,这其中考虑了许多平台相关的特性。由于Linux支持大量体系结构,描述仅限于广泛使用的IA-32体系结构。其他处理器上的实现方法几乎相同
    为容许用户态和核心态之间的切换,用户进程必须通过一条专用的机器指令,引起处理器/内核对该进程的关注,这需要C标准库的协助。内核也必须提供一个例程,来满足切换请求并关注技术细节。该例程不能在用户空间中实现,因为其中需要执行普通应用程序不允许执行的命令。

    • 参数传递
      不同的平台使用不同的汇编语言方法来执行系统调用(细节很容易在GNU标准库的源代码中找到,可参考sysdeps/unix/sysv/linux/arch/syscall.S文件。特定平台所需的汇编语言代码可以在syscall标号下找到,这些代码为库其余部分提供了一个通用接口,可用于调用系统调用)。在所有平台上,系统调用参数都是通过寄存器直接传递的,对具体的处理程序函数而言,参数与寄存器之间的映射是精确定义的。还需要一个寄存器来定义系统调用编号,将系统调用分派给匹配的处理程序函数
      流行的体系结构上的系统调用如下:

      1. 在IA-32系统上,使用汇编语言指令int $0x80来引发软件中断128。这是一个调用门(call gate),为此指派了一个特定的函数来继续进行系统调用的处理。系统调用编号通过寄存器eax传递,而参数通过寄存器ebx、ecx、edx、esi和edi传递(除了0x80调用门,内核在IA-32处理器上的实现提供了其他两种进入核心态执行系统调用的方法,分别是lcall7和lcall27调用门。这些用于执行对BSD和Solaris的二进制仿真,因为这些系统分别以本机方式进行系统调用。它们只与Linux的标准方法稍有区别)。在IA-32系列中,更为现代的处理器(Pentium II和后续处理器)采用了两个汇编语言指令(sysenter和sysexit)来快速进入和退出核心态。其中仍然采用同样的方法传递参数,但在特权级别之间切换的速度更快。为使sysenter调用更快,而又不失去与旧处理器的向下兼容性,内核将一个内存页面映射到地址空间的顶端(0xffffe000)。根据处理机类型的不同,该页上的系统调用代码可能包含int 0x80或者sysenter。调用存储在该地址(0xffffe000)的代码使得标准库可以自动选择与使用的处理器相匹配的方法。
      2. Alpha处理器提供了一种特权系统状态(privileged architecture level,PAL),在其中可以存储系统的各种内核例程。内核利用该机制将一个函数存储到PAL代码中,而执行系统调用必须激活该函数。call_pal PAL_callsys将控制流转移到目标例程。v0用于传递系统调用编号,而5个可能的参数分别保存在a0到a4(请注意,与较早期的系统如IA-32相比,较新的体系结构上寄存器的命名更为系统化)
      3. PowerPC处理器提供了一条优雅的汇编语言指令,称作sc(system call)。该指令专门用于实现系统调用。寄存器r3保存系统调用编号,而参数保存在寄存器r4到r8中。
      4. AMD64体系结构在实现系统调用时,也提供了自身的汇编语言指令,其名称为syscall。系统调用编号保存在raw寄存器中,而参数保存在rdi、rsi、rdx、r10、r8和r9中

      在应用程序借助于标准库切换到核心态后,内核面临的任务是查找与该系统调用匹配的处理程序函数,并向该处理函数提供传递的参数。sys_call_table表中保存了一组指向处理程序例程的函数指针,可用于查找处理程序(在所有平台上)。因为该表是用汇编语言指令在内核的数据段中产生的,其内容因平台而不同。但原理总是同样的:内核根据系统调用编号找到表中适当的位置,由此获得指向目标处理程序函数的指针

    • 系统调用表
      考察一下Sparc64系统上的sys_call_table,定义在arch/sparc/kernel/systlbs.S中(其他系统的系统调用表,通常可以在与处理器类型对应的目录下的entry.S文件中找到)。

      //arch/sparc64/kernel/systbls.S
      sys_call_table64: 
      sys_call_table: 
      /*0*/ .word sys_restart_syscall, sparc_exit, sys_fork, sys_read, sys_write 
      /*5*/ .word sys_open, sys_close, sys_wait4, sys_creat, sys_link 
      /*10*/ .word sys_unlink, sys_nis_syscall, sys_chdir, sys_chown, sys_mknod 
      /*15*/ .word sys_chmod, sys_lchown, sparc_brk, sys_perfctr, sys_lseek 
      /*20*/ .word sys_getpid, sys_capget, sys_capset, sys_setuid, sys_getuid 
      /*25*/ .word sys_vmsplice, sys_ptrace, sys_alarm, sys_sigaltstack, sys_nis_syscall 
      /*30*/ .word sys_utime, sys_nis_syscall, sys_nis_syscall, sys_access, sys_nice 
      .word sys_nis_syscall, sys_sync, sys_kill, sys_newstat, sys_sendfile64 
      /*40*/ .word sys_newlstat, sys_dup, sys_pipe, sys_times, sys_nis_syscall 
      .word sys_umount, sys_setgid, sys_getgid, sys_signal, sys_geteuid 
      /*50*/ .word sys_getegid, sys_acct, sys_memory_ordering, sys_nis_syscall, sys_ioctl 
      .word sys_reboot, sys_nis_syscall, sys_symlink, sys_readlink, sys_execve 
      /*60*/ .word sys_umask, sys_chroot, sys_newfstat, sys_fstat64, sys_getpagesize 
      ... 
      /*280*/ .word sys_tee, sys_add_key, sys_request_key, sys_keyctl, sys_openat 
      .word sys_mkdirat, sys_mknodat, sys_fchownat, sys_futimesat, sys_fstatat64 
      /*290*/ .word sys_unlinkat, sys_renameat, sys_linkat, sys_symlinkat, sys_readlinkat 
      .word sys_fchmodat, sys_faccessat, sys_pselect6, sys_ppoll, sys_unshare 
      /*300*/ .word sys_set_robust_list, sys_get_robust_list, sys_migrate_pages, sys_mbind, 
      sys_get_mempolicy 
      .word sys_set_mempolicy, sys_kexec_load, sys_move_pages, sys_getcpu, sys_epoll_pwait 
      /*310*/ .word sys_utimensat, sys_signalfd, sys_timerfd, sys_eventfd, sys_fallocate
      

      IA-32处理器上,该表的定义是类似的

      //arch/x86/kernel/syscall_table_32.S
      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_close 
          ... 
          .long sys_utimensat /* 320 */ 
          .long sys_signalfd 
          .long sys_timerfd 
          .long sys_eventfd 
          .long sys_fallocate
      

      .long语句的作用是在内存中对齐各个表项

      用这种方法定义的表,与C数组类似,也可以用指针运算处理。sys_call_table是基指针,指向数组的起始处,即(按C语言的术语)指向索引为0的数组项。如果一个用户空间程序调用open系统调用,传递的系统调用编号是5。分配器例程将编号5加到sys_call_table的基地址,得到该数组的第6项,其中保存了sys_open的地址,这是独立于处理器的处理程序函数。在将保存在寄存器中的参数值复制到栈上之后,内核调用处理程序例程,并切换到系统调用处理中独立于处理器的部分

      因为核心态和用户态使用两个不同的栈,系统调用参数不能像通常那样在栈上传递。在两个栈之间的切换,或者由进入核心态时调用的体系结构相关的汇编语言代码进行,或者在特权级别从用户态切换到核心态时由处理器自动进行。

  3. 返回用户态
    通常,系统调用的返回值有如下约定:负值表示错误,而正值(和0)表示成功结束
    当然,程序和内核都不会用纯粹的数字来处理错误码,这里使用了借助于预处理器在include/asm-generic/errno-base.hinclude/asm-generic/errno.h中定义的符号常数(SPARC、Alpha、PA-RISC和MIPS体系结构对这些文件定义了自身的版本,因为它们与Linux的其他移植版使用的错误码数值不同。这是因为不同平台使用的二进制规范未必使用同样的魔数所致)。<errno.h>文件中包含了几个额外的错误码,但这些是特定于内核的,用户应用程序从来不会看到。511之前(含)的错误码用于一般性错误,内核相关的错误码使用512以上的值

    使用UNIX系统调用时可能出现的典型错误都列出在errno-base.h中。另一方面,errno.h包含的错误码比较少见一些,即使老练的程序员也未必能立即弄清楚其语义。例如,EOPNOTSUPP表示“Operation not supported on transport endpoint”(传输端点不支持此操作),而ELNRNG表示“Link number out of range”(链接数目越界),这些都不能归类到常识中

    Linux使用long数据类型从内核空间向用户空间传输系统调用返回结果。根据使用的处理器类型不同,这可能是32或64位。其中1位是符号位(当然,二进制补码计数法用来防止错误,其中有两个符号不同的0。有关该格式的更多信息,请参见http://en.wikipedia.org/wiki/Two%27s_complement)。对大多数系统调用来说,这不会导致问题,如open。返回的正值通常很小,不会超出long的范围。

    如果返回比较大的数字,可能占据unsigned long的整个范围时。如果分配的内存地址位于虚拟内存空间的顶部,malloc和long的情形就是如此。内核会将返回的指针解释为负数,因为它超出了signed long的正值范围,尽管系统调用成功结束,仍然会报告错误。内核如何阻止这样的事故呢

    能够返回到用户空间的错误码符号常数不会大于511。换句话说,返回的错误码从-1到-511。因而,小于-511的返回值都排除在错误码之外,可以正确地解释为成功的系统调用的(很大的)返回值

    成功地结束系统调用还需要完成的工作,就是从核心态切换回用户态。返回值的传递方式,与调用时参数的传递方式类似。实现系统调用处理程序的C函数使用return将返回值放置在内核栈上。该值被复制到一个特定的处理器寄存器(IA-32系统上的eax,Alpha系统上的a3,等等),标准库会处理该寄存器并将返回值传递给应用程序

13.3.2 访问用户空间

有些情况下,内核代码必须访问用户应用程序的虚拟内存。当然,这只在内核执行由用户应用程序发起的同步操作时才有意义,而不适用于任意进程进行的读或写访问

对系统调用的处理就是此类情况的一个典型的例子,内核忙于同步执行应用程序指派的任务。因为如下两种原因,内核必须访问应用程序的地址空间

  1. 如果一个系统调用需要超过6个不同的参数,它们只能借助进程内存空间中的C结构实例来传递。系统调用将借助寄存器,将指向该结构实例的一个指针传递给内核
  2. 由系统调用的副效应产生的大量数据,不能通过返回值机制传递给用户进程。相反,必须通过指定的内存区交换该数据。当然,该内存区必须在用户空间中,使得用户应用程序能够访问。

在内核访问自身的内存区时,虚拟地址和物理内存页之间的映射总是存在的。但用户空间中的情况有所不同。页可能被换出,甚至可能尚未分配物理内存页。

因而,内核不能简单地反引用用户空间的指针,而必须采用特定的函数,确保目标内存区已经在物理内存中。为确保内核遵守了这种约定,用户空间指针通过__user属性标记,以支持C check tools对源代码的自动化检查(Linus Torvalds设计了该工具,用于发现内核源代码中直接反引用用户空间指针之处)

在用户空间和内核空间之间复制数据的函数大多数情况下,是copy_to_user和copy_from_user,但还有更多的变体可用

13.3.3 追踪系统调用

strace工具用来追踪进程的系统调用,它使用了ptrace系统调用

sys_ptrace处理程序例程的实现是体系结构相关的,定义在arch/arch/kernel/ptrace.c中。幸运的是,各个体系结构的对应版本之间,代码只有微小的差别。

ptrace本质上是一个用于读取和修改进程地址空间中的值的工具,不能用于直接跟踪系统调用。只有从正确的位置提取出所需的信息,才能跟踪进程并就进行的系统调用得出结论。即使调试器如gdb的实现也完全依赖于ptrace。ptrace不仅能用于跟踪系统调用,还提供了更多的选项。

ptrace在内核源代码中的定义需要4个参数

//include/linux/syscalls.h
/*
pid:标识了目标进程。进程标识符根据调用者的命名空间来解释。尽管strace的处理方式暗示必须从开始就启用进程追踪,但这不是真实的。跟踪者程序必须通过ptrace将自身连接到目标进程,而且这可以在进程已经运行后进行(不仅能在进程开始时进行)。strace负责连接到进程,通常是用fork和exec启动目标程序后立即进行
addr和data:向内核传递一个内存地址和附加信息。其语义因选择的操作而不同
request:借助于符号常数,request用于选择一个操作,由ptrace执行。手册页ptrace(2)、内核源代码中的<ptrace.h>列出了所有可能值。
*/
asmlinkage long sys_ptrace(long request, long pid, long addr, long data)

//include/linux/ptrace.h
//sys_ptrace函数的request参数值
#define PTRACE_TRACEME		   0 /*开始对当前进程的跟踪。当前进程的父进程自动承担跟踪者的角色,必须准备好从子进程接收信息*/
#define PTRACE_PEEKTEXT		   1 /*从进程地址空间读取数据,从进程的代码段读取任意字*/
#define PTRACE_PEEKDATA		   2 /*从进程地址空间读取数据,从进程的数据段读取任意字*/
#define PTRACE_PEEKUSR		   3 /*从进程地址空间读取数据,读取普通的CPU寄存器和使用的任何其他调试寄存器(因为在调用ptrace系统调用时,执行的进程显然不是被跟踪进程,CPU物理寄存器自然保存的是跟踪者进程的值,而不是被跟踪进程。这也是使用pt_regs实例的数据的原因。这些数据在进程经过进程切换后重新激活时,将被复制到寄存器集合中。操作该结构的数据,相当于操作寄存器本身)(当然,会根据标识符只读取一个寄存器的内容,而不是读取整个寄存器集合的内容)*/
#define PTRACE_POKETEXT		   4 /*向被监控进程的代码段写入值*/
#define PTRACE_POKEDATA		   5 /*向被监控进程的数据段写入值*/
#define PTRACE_POKEUSR		   6 /*向被监控进程的普通的CPU寄存器和使用的任何其他调试寄存器写入值,因而可以操作进程地址空间的内容。这在交互式调试程序时是非常重要的.操作CPU的调试寄存器,该选项支持对高级调试技术的使用。例如可监控此类事件:在一定的条件满足时,在特定位置暂停程序的执行*/
#define PTRACE_CONT		   7 /*恢复被跟踪进程的执行,但不自动暂停该进程的具体条件,被跟踪的进程将在
接收到信号时暂停*/
#define PTRACE_KILL		   8 /*发送KILL信号,关闭被追踪进程*/
#define PTRACE_SINGLESTEP	   9 /*将处理器在执行被追踪进程期间,置于单步执行模式。在这种模式下,跟踪者进程在每个汇编语言指令之后,可以访问被跟踪进程。这仍然是一种非常流行的应用程序调试技术,特别是在试图跟踪编译器错误或其他比较微秒的问题时.单步功能的实现非常强烈地依赖于所使用的CPU,毕竟,内核此时是在一个面向机器的层次上运作的。尽管如此,在所有平台上都可以向跟踪者进程提供一个一致的接口。在汇编指令执行之后,向跟踪者发送一个SIGCHLD信号,跟踪者接下来会使用其他的ptrace选项,收集被跟踪进程状态相关的详细信息。该循环不断重复,在用PTRACE_SINGLESTEP参数调用ptrace之后将执行下一条汇编指令,被跟踪进程进入睡眠,通过SIGCHLD信号通知跟踪者,等等*/

#define PTRACE_GETREGS            12 /*读取CPU的特权寄存器集合的值*/
#define PTRACE_SETREGS            13 /*设置CPU的特权寄存器集合的值*/
#define PTRACE_GETFPREGS          14 /*读取用于浮点计算的寄存器。这些操作在测试和交互式调试应用程序时也非常有用*/
#define PTRACE_SETFPREGS          15 /*设置用于浮点计算的寄存器。这些操作在测试和交互式调试应用程序时也非常有用*/

#define PTRACE_ATTACH		  16 /*发出一个请求,连接到一个进程并开始跟踪*/
#define PTRACE_DETACH		  17 /*从该进程断开
并结束跟踪。当被跟踪的进程有待决信号时,进程总是会被终止。该选项使得被跟踪进程在系统调用后或一条汇编语言指令之后暂停,在被跟踪的进程暂停时,跟踪者程序通过SIGCHLD信号得到一个通知:在被跟踪进程暂停前,跟踪者可用wait函数等待。在设置了跟踪之后,将SIGSTOP信号发送给被跟踪进程,这导致跟踪者进程第一次被中断。在跟踪系统调用时,这是必要的*/

#define PTRACE_SYSCALL		  24 /*系统调用追踪是基于PTRACE_SYSCALL的。如果用该选项激活ptrace,那么内核将开始执行进程,直至调用一个系统调用。在被追踪进程停止后,wait通知跟踪者进程,跟踪者接下来可以使用上述的ptrace选项,来分析被跟踪进程的地址空间,以收集有关系统调用的信息。在完成系统调用之后,被跟踪的进程第二次暂停,使得跟踪者进程可以检查调用是否成功。因为系统调用机制因平台而不同,跟踪程序如strace必须针对每个体系结构分别实现数据的读取;这是一个乏味的任务,很快会致使可移植程序的源代码变得不可读(strace的源
代码中有大量预处理器条件,阅读其代码非常痛苦)*/
  1. 系统调用追踪
    下列简短示例程序说明了ptrace的使用。ptrace将当前进程连接到一个进程,并检测系统调用的使用。就这点而论,它是最简版本的strace

    /* strace(1)的简单替换 */ 
    #include<stdio.h> 
    #include<stdlib.h> 
    #include<signal.h> 
    #include<unistd.h> 
    #include<sys/ptrace.h> 
    #include<sys/wait.h> 
    #ifdef x86_64
    #include<sys/reg.h> /*用于 ORIG_RAX */
    #else
    #include<asm/ptrace.h> /*用于ORIG_EAX */ 
    #endif
    static long pid; 
    int upeek(int pid, long off, long *res) { 
        long val; 
    
        val = ptrace(PTRACE_PEEKUSER, pid, off, 0); 
        if (val == -1) { 
            return -1; 
        } 
    
        *res = val;
        return 0; 
    }
    
    void trace_syscall() { 
        long res; 
    
        res = ptrace(PTRACE_SYSCALL, pid, (char*) 1, 0); 
        if (res < 0) { 
            printf("Failed to execute until next syscall: %d\n", res); 
        } 
    }
    
    void sigchld_handler (int signum) { 
        long scno; 
        int res; 
    
        /* 查明系统调用(系统相关的)……*/ 
        #ifdef x86_64
        if (upeek(pid, 4*ORIG_RAX, &scno) < 0) { 
        #else
        if (upeek(pid, 4*ORIG_EAX, &scno) < 0) { 
        #endif
            return; 
        }
    
        /* ……并输出信息 */ 
        if (scno != 0) { 
            printf("System call: %u\n", scno); 
        } 
    
        /* 激活追踪直至下一个系统调用 */ 
        trace_syscall(); 
    }
    
    int main(int argc, char** argv) { 
        int res; 
    
        /* 检查参数数目 */ 
        if (argc != 2) { 
            printf("Usage: ptrace <pid>\n"); 
            exit(-1); 
        } 
    
        /* 从命令行参数读取目标pid */ 
        pid = strtol(argv[1], NULL, 10); 
        if (pid <= 0) { 
            printf("No valid pid specified\n"); 
            exit(-1); 
        } else { 
            printf("Tracing requested for PID %u\n", pid); 
        } 
    
        /* 安装SIGCHLD的处理程序 */ 
        struct sigaction sigact; 
        sigact.sa_handler = sigchld_handler; 
        sigaction(SIGCHLD, &sigact, NULL);
    
        /* 连接到目标进程 */ 
        res = ptrace(PTRACE_ATTACH, pid, 0, 0); 
        if (res < 0) { 
            printf("Failed to attach: %d\n", res); 
            exit(-1); 
        } else { 
            printf("Attached to %u\n", pid); 
        } 
        for (;;) { 
            wait(&res); 
            if (res == 0) { 
                exit(1); 
            } 
        } 
    }
    

    程序结构大体上如下。

    1. 从命令行读取被跟踪程序的pid,并进行通常的检查。

    2. 安装CHLD信号的一个处理程序,因为每次被跟踪程序中断时,内核都会向跟踪者进程发送该信号。

    3. 跟踪者进程通过ptrace请求PTRACE_ATTACH,将自身连接到目标应用程序。

    4. 跟踪者程序的主体由一个简单的无限循环组成,其中重复调用wait目录,等待新的CHLD信号的到达。该程序的结构并不依赖于特定的处理器类型,可以用于Linux支持的所有系统。
      但用于确定所调用系统调用编号的方法,却是与体系结构非常相关的。该程序给出的方法只适用于IA-32系统,该平台将系统调用编号放置在所保存的寄存器集合中一个特定的偏移量处。 该偏移量保存在asm/ptrace.h定义的ORIG_EAX常数中。其值可以使用PTRACE_PEEKUSER读取,必须乘以4,因为此体系结构上寄存器的字长是4字节

      当然,在其他体系结构上的实现会是不同的。详细情况,请参见内核源代码中与系统调用相关的部分,以及标准的strace工具的源代码

      我们主要的目标是说明如何用ptrace检查被监控的进程。在通过PTRACE_ATTACH开始跟踪进程之后,大部分工作都委托给CHLD信号的处理程序函数,其实现在sigchld_handler中。该函数负责执行下列任务

      1. 使用平台相关的方法,帮助查找所调用系统调用的编号。如果结果是一个不等于0的系统调用编号,则输出该信息。 检测0是必要的,因为只要求记录系统调用,而不是发送给被跟踪进程的信号
      2. 帮助恢复被跟踪进程的控制流。当然,必须通知内核,该进程的执行将在下一个系统调用时暂停。这是使用ptrace请求PTRACE_SYSCALL完成的

    在开始运转之后,程序的控制流是很明显的。被跟踪进程请求的系统调用会触发内核中的ptrace机制,进而向跟踪者进程发送CHLD信号。跟踪者进程的信号处理程序会读取所需信息,即系统调用的编号,并输出它,然后再次使用ptrace机制。被跟踪进程的执行将恢复,并在再次调用系统调用时中断。

    但整个过程是如何开始运转的?无论如何,为启用对系统调用的跟踪,都需要第一次调用CHLD的处理程序。如上所述,在一个信号发送到被追踪进程时,内核也会向跟踪者发送一个SIGCHLD信号,则会调用对应的处理程序,与系统调用发生时相同。事实上,在发起跟踪时,内核会自动向被跟踪进程发送一个STOP信号,以确保在跟踪开始时调用处理程序(即使跟踪者没有收到其他信号)。这使得跟踪过程运转起来

  2. 内核端实现
    ptrace系统调用的处理程序函数称作sys_ptrace。除少数例外,所有体系结构都使用了该实现中体系结构无关的部分,这可以在kernel/ptrace.c中找到。而体系结构相关的部分即函数arch_ptrace,位于arch/arch/kernel/ptrace.c中。如下图:

    在这里插入图片描述

    函数流程:

    • sys_ptrace
      • 获取所传递PID对应的task_struct结构实例,使用了find_task_by_vpid ptrace_get_task_struct
      • 如果设置了PTRACE_ATTACH(发出一个请求,连接到一个进程并开始跟踪),则建立跟踪者进程与目标进程之间的关联 ptrace_attach
        • 目标进程的ptrace成员设置为 PT_PTRACED,跟踪者进程变为目标进程的父进程(真正的父进程保存在real_parent)
        • 被跟踪进程添加到跟踪者的ptrace_children链表,使用task_struct的ptrace_list成员作为链表元素 __ptrace_link
        • 向被跟踪的进程发送一个STOP信号 force_sig_specific
      • 如果请求了一个不同于PTRACE_ATTACH的操作,检查跟踪者是否已经连接到目标进程 ptrace_check_attach
      • 根据特定的ptrace操作进行分支(处理不同的request参数),由不同体系结构实现该函数,里面是很长的switch/case,与体系结构无关的部分调用ptrace_request arch_ptrace
        • 只介绍几个重要的分支
        • PTRACE_CONT 在被跟踪的进程因为收到信号暂停后,PTRACE_CONT将恢复其执行,删除被跟踪进程的 TIF_SYSCALL_TRACE 标志,唤醒被跟踪的进程
        • PTRACE_SYSCALL 该请求在信号到达后或系统调用执行前后,都会暂停被跟踪进程的执行,将在被监控进程的task_struct中,设置TIF_SYSCALL_TRACE标志,然后,唤醒被跟踪的进程。该标志的效果需要到汇编语言源代码entry.S中才能看到。如果设置了该标志,在系统调用完成后会调用C函数do_syscall_trace,但只针对IA-32、PowerPC、和PowerPC64平台。该标志的效果在所有支持的平台上都是相同的。在被监控进程执行一个系统调用前后,进程状态设置为TASK_STOPPED,而且会通过CHLD信号通知跟踪者。接下来,所需的信息可以从寄存器或特定内存区的内容提取
        • 与体系结构无关的request请求分支使用 ptrace_request 函数处理 ptrace_request
          • PTRACE_DETACH 停止跟踪,调用 ptrace_detach 函数
            • 体系结构相关的挂钩ptrace_disable用来执行停止追踪所需的底层操作 ptrace_disable
            • 从子进程的线程标志中,清除TIF_SYSCALL_TRACE clear_tsk_thread_flag
            • 目标进程task_struct的ptrace成员重置为0,将目标进程从跟踪者进程的ptrace_children链表删除,将被跟踪进程的父进程重置为原父进程,即将task_struct->parent赋值为real_parent __ptrace_detach
              • __ptrace_unlink
                • 目标进程task_struct的ptrace成员重置为0
                • 将目标进程从跟踪者进程的ptrace_children链表删除
                • 将被跟踪进程的父进程重置为原父进程
              • 唤醒被跟踪的进程 wake_up_process
        • PTRACE_PEEKDATA 从目标进程的数据段读取信息 generic_ptrace_peekdata
          • access_process_vm 该函数使用get_user_pages在用户空间内存中查找匹配目标地址的页。使用内核中的一个临时内存区来缓冲所需的数据。在一些清理工作之后,控制返回到分配器.将结果复制到用户空间中的内存位置
        • PTRACE_POKEDATA 向被监控进程的数据段写入值 generic_ptrace_pokedata
          • access_process_vm

    内核执行的所有进一步的跟踪操作,都位于信号处理程序代码中。在投递一个信号时,内核会检查task_struct的ptrace字段是否设置了PT_TRACED标志。如果是这样,进程的状态则设置为 TASK_STOPPED( 在 kernel/signal.cget_signal_to_deliver 中 ),以中断执行。notify_parent以及 CHLD信号用于通知跟踪者进程。(如果跟踪者刚好处于睡眠状态,则唤醒该进程。)跟踪者接下来按照剩余的ptrace选项,对目标进行所要的检查

    //include/linux/sched.h
    //进程描述符(PCB:Process Control Block进程控制块)
    struct task_struct {
        ...
        unsigned int ptrace;
        ...
        struct list_head ptrace_children;//链表头,结点为 task_struct->ptrace_list,ptrace将被跟踪进程添加到跟踪者的ptrace_children链表
        struct list_head ptrace_list;//链表结点,表头为 task_struct->ptrace_children,ptrace将被跟踪进程添加到跟踪者的ptrace_children链表
        ...
        struct task_struct *real_parent;//ptrace将跟踪者进程变为目标进程的父进程(真正的父进程保存在real_parent)
        ...
    };
    

总结

系统调用有多种标准

POSIX标准
System V
BSD

在系统调用与信号冲突时
System V内核需要用户空间程序检查所有系统调用返回值,并在返回值为-EINTR的情况下,重新启动被中断的系统调用
BSD内核会中断系统调用,先执行信号处理然后在处理结束后自动重启系统调用

Linux通过SA_RESTART标志支持BSD方案,默认使用System V机制

系统调用以 sys_ 开头,最多接受5个参数.
内核为每个系统调用分配一个编号.从用户态切换到核心态,以及调用分派和参数传递,都是由汇编语言代码实现的,系统提供了一条专用的机器指令

ptrace系统调用是一个用于读取和修改进程地址空间中的值的工具,可用于追踪系统调用,调试工具也依赖于该系统调用

=========================================

涉及的命令和配置:

strace工具记录应用程序发出的系统调用

POSIX标准的电子形式可在www.opengroup.org/onlinepubs/007904975/获得

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值