操作系统基础 | 3 系统编程概述

系统调用(System Calls)

  • 系统调用是进程进入内核、请求内核代表其执行某些操作的受控入口。内核通过系统调用 API 向程序提供各种服务,如创建新进程、执行 I/O、创建管道等。(可参考 syscalls(2) 手册页查看 Linux 系统调用列表。)
  • 系统调用的几个基本特点:
    • 系统调用会将处理器状态从用户态切换到内核态,使 CPU 能访问受保护的内核内存。
    • 系统调用集合是固定的,每个系统调用有唯一编号(程序通常通过名称而非编号调用)。
    • 每个系统调用可以有参数,用于在用户空间和内核空间之间传递信息。

系统调用的执行流程(以 x86-32 为例)

  1. 应用程序通过 C 库中的封装函数(wrapper function)发起系统调用。
  2. 封装函数将参数从栈传递到特定寄存器,以便内核处理。
  3. 封装函数将系统调用编号写入特定寄存器(如 %eax)。
  4. 封装函数执行 trap 指令(如 int 0x80),使处理器从用户态切换到内核态,执行内核 trap 向量表中对应位置的代码。新架构用 sysenter 指令,速度更快。
  5. 内核的 system_call() 例程被调用,主要步骤:
    • 保存寄存器到内核栈
    • 检查系统调用编号有效性
    • 根据编号查找并调用对应的系统调用服务例程(如 sys_execve()),并检查参数有效性,执行所需操作(如 I/O、内存操作等),返回结果状态
    • 恢复寄存器,并将返回值放到栈上
    • 返回到封装函数,同时切换回用户态
  6. 如果系统调用返回值表示错误,封装函数会设置全局变量 errno,并返回 -1 表示失败;成功时返回非负值。
  • Linux 系统调用服务例程约定:返回非负值表示成功,负值(为 errno 常量的相反数)表示错误。C 库封装函数会将负值转为正数赋给 errno,并返回 -1。
  • 这种约定假设系统调用不会在成功时返回负值,但极少数例外(如 fcntl()F_GETOWN 操作)。
  • 例如,execve() 系统调用在 sys_call_table 的第 11 项,指向 sys_execve() 服务例程。
  • 系统调用的实现虽然对程序员透明,但实际上涉及许多底层操作,因此系统调用有一定的性能开销。例如,getppid() 1,000 万次调用约需 2.2 秒,而等价的 C 函数只需 0.11 秒。
  • 在本书中,“调用系统调用 xyz()”通常指调用对应的 C 库封装函数。
  • 可用 strace 命令跟踪程序的系统调用,便于调试和分析。

库函数(Library Functions)

  • 库函数是标准 C 库(如 glibc)中包含的大量函数之一。它们的功能非常多样,比如打开文件、时间格式转换、字符串比较等。
  • 许多库函数并不涉及系统调用(如字符串处理函数),而有些库函数则是对系统调用的封装。例如,fopen() 库函数内部会调用 open() 系统调用来打开文件。
  • 库函数通常比底层系统调用更易用。例如,printf() 提供了格式化输出和缓冲功能,而 write() 只负责输出字节块。malloc()free() 也比底层的 brk() 系统调用更方便。

标准 C 库与 GNU C 库(glibc)

  • 不同 UNIX 实现有不同的标准 C 库实现。Linux 上最常用的是 GNU C 库(glibc)。

查看系统上的 glibc 版本

  • 可以直接运行 glibc 的共享库文件来查看版本信息,例如:

    1
    $ /lib/libc.so.6
    输出内容会包含 glibc 的版本号等信息。

  • 在某些发行版中,glibc 可能不在 /lib/libc.so.6,可以用 ldd 命令查看某个程序依赖的 glibc 路径:

    1
    2
    $ ldd myprog | grep libc
    libc.so.6 => /lib/tls/libc.so.6 (0x4004b000)

  • 程序可以通过两种方式获取 glibc 版本:

    1. 编译时检测常量:glibc 2.0 及以后定义了 __GLIBC____GLIBC_MINOR__ 两个常量,可用于 #ifdef 判断。
    2. 运行时调用函数:可用 gnu_get_libc_version() 获取运行时 glibc 版本号。
      1
      2
      #include <gnu/libc-version.h>
      const char *gnu_get_libc_version(void);
      该函数返回如 "2.12" 的版本号字符串。
  • 还可以用 confstr() 函数获取 _CS_GNU_LIBC_VERSION 配置变量,返回如 "glibc 2.12" 的字符串。

处理系统调用和库函数的错误

  • 几乎所有系统调用和库函数都会返回一个状态值,指示调用是否成功。必须始终检查这个返回值,如果失败,应采取适当措施(至少要输出错误信息)。
  • 虽然省略这些检查看似省事,但实际上会导致难以排查的 bug,浪费大量调试时间。

系统调用错误处理

  • 每个系统调用的手册页会说明其返回值,通常返回 –1 表示出错。例如:
    1
    2
    3
    4
    5
    6
    7
    fd = open(pathname, flags, mode);
    if (fd == -1) {
    /* 错误处理代码 */
    }
    if (close(fd) == -1) {
    /* 错误处理代码 */
    }
  • 系统调用失败时,会将全局变量 errno 设为正值,表示具体错误类型。需要包含 <errno.h> 头文件。
  • errno 的符号常量都以 E 开头,手册页的 ERRORS 部分会列出可能的 errno 值。
  • 示例:
    1
    2
    3
    4
    5
    6
    7
    8
    cnt = read(fd, buf, numbytes);
    if (cnt == -1) {
    if (errno == EINTR)
    fprintf(stderr, "read was interrupted by a signal\n");
    else {
    /* 其他错误 */
    }
    }
  • 成功的系统调用不会将 errno 设为 0,因此不能用 errno == 0 判断是否成功。应先检查返回值,再看 errno。
  • 少数系统调用(如 getpriority())在成功时也可能返回 –1。此时应在调用前将 errno 设为 0,调用后判断:如果返回 –1 且 errno 不为 0,则为错误。

错误信息输出

  • 常用 perror()strerror() 输出错误信息。
    • perror():输出自定义信息和 errno 对应的错误描述。
      1
      2
      #include <stdio.h>
      void perror(const char *msg);
      用法示例:
      1
      2
      3
      4
      5
      fd = open(pathname, flags, mode);
      if (fd == -1) {
      perror("open");
      exit(EXIT_FAILURE);
      }
    • strerror():返回 errno 对应的错误字符串。
      1
      2
      #include <string.h>
      char *strerror(int errnum);
      注意返回的字符串可能被后续调用覆盖。
  • 如果错误号未知,strerror() 返回 "Unknown error nnn" 或 NULL。
  • 这两个函数支持本地化,错误信息会用本地语言显示。

库函数错误处理

  • 不同库函数返回不同类型和数值表示失败(需查阅手册页)。
  • 常见几类:
    1. 与系统调用一致:返回 –1,errno 指示错误(如 remove())。
    2. 返回其他错误值:如 fopen() 出错返回 NULL,errno 反映具体错误。
    3. 不使用 errno:某些库函数不用 errno,具体错误判断方式见手册页。此时不应用 errno、perror() 或 strerror()。