Maxw的小站

Maxw学习记录

系统调用的实现

在 Linux 中,实际实现一个系统调用时,不需要关心系统调用处理器(system call handler)的具体行为。因此,向 Linux 添加一个新的系统调用相对容易。难点在于设计和实现系统调用本身,而将其注册到内核则很简单。下面是编写新系统调用的主要步骤。

实现系统调用的步骤

  1. 明确目的
    首先要定义系统调用的用途。系统调用应该只做一件事。Linux 不鼓励“多路复用”系统调用(即通过一个参数让同一个系统调用做完全不同的事情),如 ioctl() 就是反面教材。

  2. 参数、返回值和错误码设计
    系统调用应有简洁、清晰的接口,参数数量应尽量少。其语义和行为必须稳定,不能随意更改,因为已有应用会依赖这些行为。要有前瞻性,考虑未来是否需要扩展功能,是否能在不破坏兼容性的前提下修复 bug。很多系统调用会设计一个 flag 参数,用于将来扩展功能(不是用来多路复用行为,而是为了兼容性和可扩展性)。

  3. 接口设计要通用、可移植
    不要让接口过于局限当前用途。系统调用的用途可能会变化,但其本质目的应保持不变。要考虑可移植性,不要假设特定架构的字长或字节序。Unix 的设计哲学是“提供机制,不规定策略”。

  4. 关注可移植性和健壮性
    编写系统调用时要考虑未来的可移植性和健壮性。Unix 的基本系统调用经受住了时间考验,几十年后依然适用。


参数校验

系统调用必须严格校验所有参数,确保其有效和合法。系统调用在内核空间运行,如果用户能随意传递无效参数,系统的安全和稳定性会受到威胁。

  • 例如,文件 I/O 系统调用要检查文件描述符是否有效;进程相关函数要检查 PID 是否有效。每个参数都要验证其正确性,防止进程请求访问其无权访问的资源。

  • 指针参数的校验尤为重要。如果进程能传递任意指针给内核,可能会让内核访问本不该访问的数据(如其他进程的数据或内核空间数据)。因此,在内核跟随用户空间指针前,必须确保:

    1. 指针指向用户空间内存,不能让进程让内核访问内核空间。
    2. 指针指向的是本进程的地址空间,不能访问其他进程的数据。
    3. 读操作时内存必须可读,写操作时必须可写,执行操作时必须可执行,不能绕过内存访问权限。
  • 内核提供了两种方法来进行这些检查和数据拷贝,内核代码绝不能直接跟随用户空间指针,必须使用以下两种方法之一:

    • copy_to_user():用于将数据从内核空间写入用户空间。参数分别为用户空间目标地址、内核空间源地址、拷贝字节数。
    • copy_from_user():用于从用户空间读取数据到内核空间。参数分别为内核空间目标地址、用户空间源地址、拷贝字节数。
  • 这两个函数在出错时返回未拷贝的字节数,成功时返回 0。系统调用遇到这种错误时,通常返回 -EFAULT

下面以一个简单的系统调用 silly_copy() 为例,说明如何在内核中安全地从用户空间读取和写入数据。这个系统调用的功能是:将用户空间 src 指向的数据拷贝到 dst,中间通过内核缓冲区作为中转。虽然实际用途不大,但有助于理解 copy_from_user()copy_to_user() 的用法。

核心代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SYSCALL_DEFINE3(silly_copy,
unsigned long *, src,
unsigned long *, dst,
unsigned long len)
{
unsigned long buf;
// 从用户空间 src 拷贝 len 字节到内核缓冲区 buf
if (copy_from_user(&buf, src, len))
return -EFAULT;
// 从内核缓冲区 buf 拷贝 len 字节到用户空间 dst
if (copy_to_user(dst, &buf, len))
return -EFAULT;
// 返回拷贝的字节数
return len;
}
- copy_from_user():将用户空间数据拷贝到内核空间,失败时返回未拷贝的字节数,成功返回0。 - copy_to_user():将内核空间数据拷贝到用户空间,失败时返回未拷贝的字节数,成功返回0。 - 如果拷贝失败,系统调用返回 -EFAULT

注意:
这两个函数在数据页不在物理内存时可能会阻塞(如数据被换出到磁盘),此时进程会休眠直到页面被调入内存。

权限检查与能力机制

在早期 Linux 版本中,系统调用如果需要超级用户权限,会用 suser() 检查是否为 root。现在,Linux 使用更细粒度的“能力(capabilities)”机制。通过 capable() 函数检查调用进程是否拥有某项能力。例如:

1
2
if (!capable(CAP_SYS_BOOT))
return -EPERM;
  • capable(CAP_SYS_BOOT) 检查调用者是否有重启系统的权限(CAP_SYS_BOOT)。
  • 超级用户(root)默认拥有所有能力,普通用户默认没有。

reboot() 系统调用部分实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SYSCALL_DEFINE4(reboot, int magic1, int magic2, unsigned int cmd, void __user *, arg)
{
char buffer[256];
// 只允许超级用户重启系统
if (!capable(CAP_SYS_BOOT))
return -EPERM;
// 检查 magic 参数,防止误操作
if (magic1 != LINUX_REBOOT_MAGIC1 ||
(magic2 != LINUX_REBOOT_MAGIC2 &&
magic2 != LINUX_REBOOT_MAGIC2A &&
magic2 != LINUX_REBOOT_MAGIC2B &&
magic2 != LINUX_REBOOT_MAGIC2C))
return -EINVAL;
// ... 省略后续命令处理 ...
}
- 首先检查权限,只有拥有 CAP_SYS_BOOT 能力的进程才能重启系统。 - 然后检查 magic 参数,只有传入特定的“魔数”才允许执行,防止误操作。

能力列表可参考 <linux/capability.h>,每种能力对应不同的系统资源访问权限。

系统调用上下文(System Call Context)

如第3章所述,在执行系统调用期间,内核处于进程上下文(process context)。此时,current 指针指向当前任务(即发起系统调用的进程)。

  • 在进程上下文中,内核可以休眠(比如系统调用阻塞或显式调用 schedule()),并且是完全可抢占的
    • 能够休眠意味着系统调用可以使用大部分内核功能,这极大简化了内核编程(相比中断处理程序,中断处理程序不能休眠,功能受限)。
    • 可抢占意味着当前任务可能被其他任务抢占,新的任务可能会执行同一个系统调用,因此系统调用实现必须可重入,这和多核并发下的同步问题类似。

当系统调用返回时,控制权回到 system_call(),最终切换回用户空间,继续执行用户进程。

系统调用注册的最后步骤

系统调用代码写好后,将其注册为正式系统调用的过程很简单:

  1. 在系统调用表中添加条目
    • 对于每个支持该系统调用的架构,都要在系统调用表(如 entry.S)末尾添加一项。表中每一项的位置(从0开始)就是系统调用号。例如,第10项的系统调用号是9。
    • 表示例(部分):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      ENTRY(sys_call_table)
      .long sys_restart_syscall /* 0 */
      .long sys_exit
      .long sys_fork
      .long sys_read
      .long sys_write
      .long sys_open /* 5 */
      ...
      .long sys_foo /* 新增的系统调用 */
    • 新系统调用自动获得下一个可用的系统调用号(如338)。
  2. 在 <asm/unistd.h> 中定义系统调用号
    • 每个架构都要在对应的 <asm/unistd.h> 文件中添加宏定义。例如:
      1
      #define __NR_foo 338
  3. 将系统调用编译进内核镜像
    • 系统调用必须编译进核心内核镜像(不能作为模块)。通常把实现代码放在 kernel/ 目录下相关的文件中,比如 sys.c。如果和调度相关,可以放在 kernel/sched.c。

示例:实现 foo() 系统调用

  • 在 kernel/sys.c 中实现:
    1
    2
    3
    4
    5
    6
    7
    8
    #include <asm/page.h>
    /*
    * sys_foo – 返回每个进程的内核栈大小
    */
    asmlinkage long sys_foo(void)
    {
    return THREAD_SIZE;
    }
  • 编译并启动新内核后,用户空间即可通过系统调用号调用 foo()。

用户空间访问系统调用

通常,C 标准库(如 glibc)会为系统调用提供支持。用户程序只需包含标准头文件并链接 C 库,就可以直接调用系统调用(或调用库函数间接使用系统调用)。但如果你刚刚实现了一个新的系统调用,glibc 很可能还没有为它提供支持!

幸运的是,Linux 提供了一组宏来帮助用户空间访问系统调用。这些宏会设置好寄存器内容并发出陷阱指令。宏的名字为 _syscalln(),其中 n 取 0 到 6,表示系统调用参数的个数。宏需要知道参数个数,以便正确地将参数压入寄存器。

例如,open() 系统调用的原型为:

1
long open(const char *filename, int flags, int mode)
如果没有库支持,可以这样使用宏:
1
2
#define __NR_open 5
_syscall3(long, open, const char *, filename, int, flags, int, mode)
这样,应用程序就可以直接调用 open() 了。

每个宏的参数为 2 + 2 × n:第一个参数是返回类型,第二个是系统调用名,后面依次是每个参数的类型和名字。__NR_open 定义在 <asm/unistd.h>,表示系统调用号。_syscall3 宏会展开为带有内联汇编的 C 函数,自动完成系统调用号和参数的传递,并发出软中断进入内核。只需在应用中写这个宏,就能直接使用 open() 系统调用。

示例:用户空间调用自定义 foo() 系统调用

1
2
3
4
5
6
7
8
9
#define __NR_foo 283
_syscall0(long, foo)

int main() {
long stack_size;
stack_size = foo();
printf("The kernel stack size is %ld\n", stack_size);
return 0;
}

为什么不建议随意实现新系统调用

虽然实现新系统调用很容易,但这并不意味着你应该随意添加。实际上,添加新系统调用要非常谨慎。很多情况下,有更合适的替代方案。

实现新系统调用的优点: - 实现简单,使用方便。 - 在 Linux 上系统调用性能很高。

缺点: - 需要分配一个系统调用号,必须官方分配。 - 一旦进入稳定内核版本,接口就不能随意更改,否则会破坏用户空间应用的兼容性。 - 每个架构都要单独注册和支持该系统调用。 - 系统调用不能直接被脚本调用,也不能直接通过文件系统访问。 - 需要分配系统调用号,难以在主线内核树之外维护和使用。 - 对于简单信息交换,系统调用显得过于繁重。

常见替代方案: - 实现一个设备节点,通过 read()/write() 进行数据交换,使用 ioctl() 操作特定设置或获取信息。 - 某些接口(如信号量)可以用文件描述符表示并进行操作。 - 将信息作为文件添加到 sysfs 的合适位置。

对于许多接口,系统调用确实是正确的选择。但 Linux 一直避免为每个新抽象都添加系统调用,这使得系统调用层非常简洁、稳定,很少有废弃接口。新系统调用增加速度慢,说明 Linux 已经相对稳定且功能完善。

与内核通信

系统调用(system call)在硬件和用户空间进程之间提供了一层抽象,这一层有三个主要作用:

  1. 硬件抽象
    系统调用为用户空间提供了统一的硬件接口。例如,应用程序在读写文件时,无需关心底层的磁盘类型、介质类型,甚至文件所在的文件系统类型。

  2. 系统安全与稳定
    有了内核作为中介,内核可以根据权限、用户等标准仲裁资源访问。这防止了应用程序误用硬件、窃取其他进程资源或对系统造成破坏。

  3. 虚拟化与多任务支持
    用户空间与系统其他部分之间有统一的接口,便于实现进程虚拟化和多任务。如果应用能直接访问系统资源,将难以实现多任务和虚拟内存,更无法保证系统的稳定和安全。

在 Linux 中,系统调用是用户空间与内核交互的唯一合法入口(除了异常和陷阱)。即使是设备文件或 /proc 这样的接口,最终也要通过系统调用访问。值得一提的是,Linux 的系统调用数量比大多数系统要少。

API、POSIX 与 C 库

通常,应用程序是基于用户空间的 API(应用程序编程接口)开发的,而不是直接调用系统调用。这很重要,因为 API 与内核实际提供的接口之间不需要一一对应。API 定义了一组供应用程序使用的编程接口,这些接口可以通过一个系统调用实现,也可以通过多个系统调用,甚至完全不依赖系统调用。这样,同样的 API 可以在不同系统上实现,应用程序无需关心底层实现细节。

在 Unix 世界中,最常见的 API 之一是基于 POSIX 标准的。POSIX 是 IEEE 制定的一系列标准,旨在提供基于 Unix 的可移植操作系统标准。Linux 在适用的地方努力兼容 POSIX 和 SUSv3。

POSIX 很好地体现了 API 与系统调用的关系。在大多数 Unix 系统中,POSIX API 与系统调用高度相关,POSIX 标准本身就是参考早期 Unix 系统接口制定的。但有些非 Unix 系统(如 Windows)也提供了 POSIX 兼容库。

C 库的作用

在 Linux 和大多数 Unix 系统中,C 库(如 glibc)部分实现了系统调用接口。C 库不仅实现了标准 C 库,还实现了系统调用接口,是所有 C 程序的基础。由于 C 语言的特性,其他编程语言也可以很方便地调用 C 库。C 库还实现了大部分 POSIX API。

对应用开发者来说,系统调用的细节并不重要,他们只关心 API;而内核只关心系统调用,至于哪些库函数或应用程序会用到这些系统调用,内核并不关心。不过,内核需要保证系统调用的通用性和灵活性,以适应各种用途。

系统调用(Syscalls)

在 Linux 中,系统调用(syscall)通常通过 C 库中定义的函数进行访问。系统调用可以有零个、一个或多个参数(输入),并可能产生一个或多个副作用,例如写文件或将数据复制到指定指针。系统调用还会返回一个 long 类型的值,用于表示成功或错误——通常(但并非总是)负值表示错误,返回值为 0 通常(但也不是总是)表示成功。

当系统调用出错时,C 库会将一个特殊的错误码写入全局变量 errno。可以通过如 perror() 这样的库函数将 errno 转换为可读的错误信息。

系统调用有明确的行为定义。例如,getpid() 系统调用被定义为返回当前进程的 PID。其内核实现大致如下:

1
2
3
4
SYSCALL_DEFINE0(getpid) 
{
return task_tgid_vnr(current); // 返回 current->tgid
}

注:你可能会好奇为什么 getpid() 返回的是 tgid(线程组ID)?在普通进程中,TGID 等于 PID;而在线程中,同一线程组的所有线程 TGID 相同,这样所有线程调用 getpid() 时返回相同的 PID。

需要注意的是,定义只规定了行为,具体实现方式由内核决定,只要结果正确即可。SYSCALL_DEFINE0 是一个宏,用于定义无参数的系统调用(0 表示参数个数)。展开后类似于:

1
asmlinkage long sys_getpid(void)
  • asmlinkage 修饰符告诉编译器只从栈上获取参数,这是所有系统调用都需要的修饰符。
  • 返回类型为 long,是为了兼容 32 位和 64 位系统。即使用户空间定义为 int,内核中也返回 long。
  • 命名约定:内核中系统调用的实现函数名为 sys_xxx(),如 getpid() 对应 sys_getpid()

系统调用号(System Call Numbers)

在 Linux 中,每个系统调用都有唯一的系统调用号(syscall number),用于标识具体的系统调用。用户空间进程执行系统调用时,实际上是通过系统调用号来指定调用哪个系统调用,而不是用名字。

系统调用号非常重要,一旦分配就不能更改,否则已编译的应用程序会出错。同样,如果某个系统调用被移除,其编号也不能被回收,否则旧程序会调用到错误的系统调用。Linux 提供了一个“未实现”系统调用 sys_ni_syscall(),它只返回 -ENOSYS(表示无效系统调用),用于填补被移除或不可用的系统调用号。

内核通过系统调用表(sys_call_table)维护所有已注册的系统调用。在 x86-64 架构下,这个表定义在 arch/i386/kernel/syscall_64.c 文件中,每个有效的系统调用都分配有唯一的编号。

系统调用性能

Linux 的系统调用比许多其他操作系统更快,这部分归功于 Linux 的上下文切换速度快,进入和退出内核的过程非常简洁高效,系统调用处理器和各个系统调用本身也很简单。

系统调用处理器(System Call Handler)

用户空间的应用程序无法直接执行内核代码,也不能直接调用内核空间的方法,因为内核处于受保护的内存空间。如果应用可以直接读写内核地址空间,系统的安全性和稳定性将无法保证。

因此,用户空间的应用程序必须通过某种方式通知内核,让系统切换到内核态,由内核代表应用程序在内核空间执行系统调用。

进入内核的机制

这种通知内核的机制是一种软件中断:即触发一个异常,系统会切换到内核态并执行异常处理程序。在系统调用的场景下,这个异常处理程序就是系统调用处理器(system call handler)。

  • 在 x86 架构上,定义的软件中断号为 128,通过 int $0x80 指令触发。这会导致系统切换到内核态,并执行异常向量 128(即系统调用处理器)。
  • 系统调用处理器的函数名通常为 system_call(),它是与架构相关的代码(如 x86-64 下在 entry_64.S 汇编文件中实现)。
  • 近年来,x86 处理器增加了 sysenter 指令,这是一种比 int $0x80 更快、更专用的进入内核执行系统调用的方法。内核很快就支持了这种方式。
  • 无论采用哪种方式,核心思想都是:用户空间通过异常或陷阱(trap)进入内核。

指定正确的系统调用

仅仅进入内核空间还不够,因为有很多不同的系统调用,它们都是通过相同的方式进入内核的。因此,必须将系统调用号传递给内核

  • 在 x86 架构上,系统调用号通过 eax 32位寄存器传递。在触发陷阱进入内核前,用户空间会把所需系统调用的编号写入 eax

  • 系统调用处理器读取 eax 的值,判断其有效性(与 NR_syscalls 比较)。如果编号无效,返回 -ENOSYS 错误;否则,通过系统调用表调用对应的系统调用函数:

    1
    call *sys_call_table(,%rax,8)
    > rax和eax均为累加器,区别是rax是64位, eax32位

    这里每个表项 8 字节(64 位),所以用 8 乘以系统调用号定位表项(x86-32 下用 4 乘以系统调用号)。

参数传递

除了系统调用号,大多数系统调用还需要传递一个或多个参数。用户空间必须在陷阱捕获过程中时将参数传递给内核。

  • 最简单的方式是通过寄存器传递参数。在 x86-32 架构下,ebxecxedxesiedi 依次存放前五个参数。
  • 如果参数超过五个,则用一个寄存器传递指向用户空间参数数组的指针。
  • 返回值也通过寄存器传递,x86 下写入 eax

调用系统调用处理器并执行系统调用的流程

1
2
3
4
5
6
7
8
9
+-------------------+         +-------------------+
| User Space | | Kernel Space |
|-------------------| |-------------------|
| Application | | Syscall Handler |
| call read() | | system_call() |
|-------------------| |-------------------|
| C library | | sys_read() |
| read() wrapper | | |
+-------------------+ +-------------------+

  • 应用程序调用 read(),实际上先调用 C 库的 read() 封装函数。
  • C 库的 read() 封装函数通过软中断(如 int $0x80sysenter)进入内核,调用 system_call()
  • system_call() 读取系统调用号和参数,查找并调用内核中的 sys_read() 实现。
  • 执行完毕后,返回值通过寄存器传回用户空间,流程反向返回到应用程序。

可移植性问题(Portability Issues)

特性测试宏(Feature Test Macros)

  • 系统调用和库函数 API 的行为受多种标准规范(如 The Open Group 的 Single UNIX Specification、BSD、System V Release 4 及其接口定义)约束。
  • 为了让头文件只暴露符合某一标准的定义(如常量、函数原型等),可以在编译时定义一个或多个特性测试宏。定义方式有两种:
    1. 在源代码中包含头文件前定义宏:
      1
      #define _BSD_SOURCE 1
    2. 用编译器的 -D 选项定义:
      1
      $ cc -D_BSD_SOURCE prog.c
  • “特性测试宏”这个名字的由来是:实现会通过 #if 判断这些宏的值,决定头文件中哪些特性对应用可见。

常用特性测试宏

这些宏由相关标准规定,适用于所有支持这些标准的系统:

  • _POSIX_SOURCE
    定义后暴露符合 POSIX.1-1990 和 ISO C (1990) 的定义。已被 _POSIX_C_SOURCE 取代。
  • _POSIX_C_SOURCE
    • 值为 1 时,效果同 _POSIX_SOURCE
    • 值 ≥ 199309 时,暴露 POSIX.1b(实时)定义。
    • 值 ≥ 199506 时,暴露 POSIX.1c(线程)定义。
    • 值为 200112 时,暴露 POSIX.1-2001 基础规范(不含 XSI 扩展)。
    • 值为 200809 时,暴露 POSIX.1-2008 基础规范。
  • _XOPEN_SOURCE
    • 定义后暴露 POSIX.1、POSIX.2 和 X/Open (XPG4) 定义。
    • 值 ≥ 500 时,暴露 SUSv2(UNIX 98 和 XPG5)扩展。
    • 值 ≥ 600 时,暴露 SUSv3 XSI(UNIX 03)和 C99 扩展。
    • 值 ≥ 700 时,暴露 SUSv4 XSI 扩展。

glibc 特有的特性测试宏

  • _BSD_SOURCE
    定义后暴露 BSD 定义,同时定义 _POSIX_C_SOURCE=199506。如只定义此宏,部分标准冲突时优先 BSD 定义。
  • _SVID_SOURCE
    定义后暴露 System V 接口定义(SVID)。
  • _GNU_SOURCE
    定义后暴露所有上述宏的定义及 GNU 扩展。

默认行为与宏组合

  • 默认情况下,GNU C 编译器会定义 _POSIX_SOURCE_POSIX_C_SOURCE=200809(或更早版本的 200112/199506)、_BSD_SOURCE_SVID_SOURCE
  • 如果单独定义了某些宏,或用标准模式(如 cc -ansicc -std=c99)编译,则只暴露请求的定义。
  • 多个宏可以叠加定义。例如:
    1
    $ cc -D_POSIX_SOURCE -D_POSIX_C_SOURCE=199506 -D_BSD_SOURCE -D_SVID_SOURCE prog.c
  • <features.h> 头文件和 feature_test_macros(7) 手册页有详细说明。

POSIX.1/SUS 相关宏

  • POSIX.1-2001/SUSv3 只规定了 _POSIX_C_SOURCE_XOPEN_SOURCE 两个宏,要求值分别为 200112 和 600。
  • POSIX.1-2008/SUSv4 要求值分别为 200809 和 700。
  • 设置 _XOPEN_SOURCE=600 应包含 _POSIX_C_SOURCE=200112 的所有特性,SUSv4 也有类似要求。

示例代码与函数原型中的特性测试宏

  • 手册页会说明使用某个常量或函数声明时需要定义哪些特性测试宏。
  • 本书示例代码可用默认 GNU C 编译器选项或如下方式编译:
    1
    $ cc -std=c99 -D_XOPEN_SOURCE=600
  • 书中每个函数原型都会注明需要定义哪些特性测试宏。
  • 手册页有更详细的宏需求说明。

系统数据类型(System Data Types)

在 UNIX 系统中,许多实现相关的数据类型(如进程ID、用户ID、文件偏移量等)都用标准 C 类型来表示。虽然可以直接用 int、long 等基本类型声明这些变量,但这样会降低程序的可移植性,原因包括:

  • 不同 UNIX 实现中基本类型的大小可能不同(如 long 在某些系统上是4字节,在另一些系统上是8字节),甚至同一系统的不同编译环境也可能不同。
  • 不同实现可能用不同类型表示相同的信息。例如,进程ID在某些系统上是 int,在另一些系统上是 long。
  • 同一实现的不同版本也可能改变类型定义。例如,Linux 2.2 及以前用户和组ID是16位,2.4及以后是32位。

为避免这些移植性问题,SUSv3(Single UNIX Specification, Version 3)规定了一系列标准系统数据类型,并要求实现时正确使用这些类型。这些类型通常用 C 的 typedef 定义。例如,pid_t 用于表示进程ID,在 Linux/x86-32 上定义为:

1
typedef int pid_t;
大多数标准系统数据类型以 _t 结尾,通常声明在 <sys/types.h> 头文件中,部分类型在其他头文件中定义。

建议: 应用程序应使用这些类型来声明变量,以保证在所有符合 SUSv3 的系统上都能正确运行。例如:

1
pid_t mypid;

常用系统数据类型举例

数据类型 类型要求 说明
pid_t 有符号整数 进程ID、进程组ID、会话ID
uid_t 整数 用户ID
gid_t 整数 组ID
size_t 无符号整数 对象字节大小
ssize_t 有符号整数 字节计数或错误指示
off_t 有符号整数 文件偏移量或文件大小
time_t 整数或实数 自 Epoch 起的秒数
mode_t 整数 文件权限和类型
dev_t 算术类型 设备号(主次设备号)
ino_t 无符号整数 文件 i-node 号
socklen_t 至少32位整数 套接字地址结构体大小

打印系统数据类型的数值

  • 在用 printf() 打印表3-1中这些数值型系统数据类型(如 pid_tuid_t)时,要避免实现相关的依赖问题。

  • 由于 C 的参数提升规则,short 类型会被提升为 int,但 intlong 类型保持不变。因此,系统数据类型的底层实现不同,传递给 printf() 的参数类型可能是 intlong

  • 由于 printf() 在运行时无法判断参数类型,调用者必须用合适的格式说明符(如 %d%ld)明确指定类型。但直接写死某个说明符会导致实现依赖。

  • 通常的解决办法是统一用 %ld,并将对应的值强制转换为 long,例如:

    1
    2
    3
    pid_t mypid;
    mypid = getpid(); /* 获取当前进程ID */
    printf("My PID is %ld\n", (long) mypid);

  • 有一个例外:off_t 类型在某些环境下是 long long,因此应强制转换为 long long 并用 %lld 打印(详见5.10节)。

  • C99 标准定义了 z 长度修饰符,用于 size_tssize_t 类型,可以用 %zd 替代 %ld+强转。但该说明符并非所有 UNIX 实现都支持,所以本书避免使用。

  • C99 还定义了 j 长度修饰符,指定参数为 intmax_t(或 uintmax_t),这种类型足够大,可以表示任何整数类型。理论上,使用 (intmax_t) 强转加 %jd 是最通用的做法,能处理 long long 及扩展整数类型(如 int128_t)。但由于并非所有 UNIX 实现都支持,本书也避免使用这种方式。

其他可移植性问题(Miscellaneous Portability Issues)

结构体的初始化与使用

  • 各 UNIX 实现规定了一系列标准结构体,用于系统调用和库函数。例如,sembuf 结构体用于信号量操作(semop):
    1
    2
    3
    4
    5
    struct sembuf {
    unsigned short sem_num; /* 信号量编号 */
    short sem_op; /* 要执行的操作 */
    short sem_flg; /* 操作标志 */
    };
  • 虽然 SUSv3 规定了这些结构体,但需要注意:
    • 一般来说,结构体成员的顺序未必有标准规定。
    • 某些实现可能会在结构体中添加额外的字段。
  • 因此,不建议用如下方式初始化结构体(因为不同实现成员顺序可能不同):
    1
    struct sembuf s = { 3, -1, SEM_UNDO };
    这种写法在 Linux 下可用,但在其他实现中可能出错。可移植的做法是用显式赋值:
    1
    2
    3
    4
    struct sembuf s;
    s.sem_num = 3;
    s.sem_op = -1;
    s.sem_flg = SEM_UNDO;
    如果使用 C99,可以用新的结构体初始化语法:
    1
    struct sembuf s = { .sem_num = 3, .sem_op = -1, .sem_flg = SEM_UNDO };
  • 如果要将结构体内容写入文件,也要注意成员顺序。不能直接二进制写入结构体,而应按指定顺序逐个字段写入(最好用文本形式)。

某些宏可能并非所有实现都支持

  • 有些宏在所有 UNIX 实现中并不一定存在。例如,WCOREDUMP() 宏(用于检测子进程是否产生 core dump 文件)虽然常见,但 SUSv3 并未规定,因此某些系统可能没有。
  • 可移植的做法是用 #ifdef 判断宏是否存在:
    1
    2
    3
    #ifdef WCOREDUMP
    /* 使用 WCOREDUMP() 宏 */
    #endif

不同实现对头文件的要求不同

  • 某些系统调用和库函数所需的头文件在不同 UNIX 实现中可能不同。本书以 Linux 为主,并注明与 SUSv3 的差异。
  • 书中部分函数原型会注明某个头文件后加注释 /* For portability */,表示该头文件在 Linux 或 SUSv3 下不是必需的,但为了兼容其他(尤其是老旧)实现,建议在可移植程序中包含。
  • POSIX.1-1990 要求在包含与某些函数相关的头文件前,先包含 <sys/types.h>,但这一要求后来被 SUSv1 移除。尽管如此,为了可移植性,建议将 <sys/types.h> 作为首个头文件包含(本书示例为简洁起见省略了它)。

LPI示例程序说明

命令行选项与参数

  • 本书中的许多示例程序依赖命令行选项和参数来决定其行为。
  • 传统 UNIX 命令行选项格式为:一个连字符(-)加一个字母,后面可跟参数。GNU 工具支持扩展格式:两个连字符(--)加选项名和可选参数。
  • 示例程序通常使用标准库函数 getopt() 解析命令行选项(详见附录B)。
  • 只要程序的命令行语法不简单,都会实现一个帮助功能:如果用 --help 选项运行,程序会显示用法说明,指明命令行选项和参数的语法。

公共函数与头文件

  • 大多数示例程序都包含一个公共头文件,定义常用类型和宏,并引用常用的库函数和系统调用声明,使代码更简洁。

公共头文件(lib/tlpi_hdr.h)

  • 该头文件包含了许多常用头文件,定义了布尔类型和求最小/最大值的宏。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #ifndef TLPI_HDR_H
    #define TLPI_HDR_H /* 防止重复包含 */
    #include <sys/types.h> /* 常用类型定义 */
    #include <stdio.h> /* 标准I/O函数 */
    #include <stdlib.h>
    #include <unistd.h>
    #include <errno.h>
    #include <string.h>
    #include "get_num.h" /* 常用数值处理函数声明 */
    #include "error_functions.h" /* 错误处理函数声明 */
    typedef enum { FALSE, TRUE } Boolean;
    #define min(m,n) ((m) < (n) ? (m) : (n))
    #define max(m,n) ((m) > (n) ? (m) : (n))
    #endif

错误诊断函数(lib/error_functions.h)

  • 为简化错误处理,示例程序使用一组通用的错误诊断函数,其声明如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #ifndef ERROR_FUNCTIONS_H
    #define ERROR_FUNCTIONS_H
    void errMsg(const char *format, ...);
    #ifdef __GNUC__
    #define NORETURN __attribute__ ((__noreturn__))
    #else
    #define NORETURN
    #endif
    void errExit(const char *format, ...) NORETURN ;
    void err_exit(const char *format, ...) NORETURN ;
    void errExitEN(int errnum, const char *format, ...) NORETURN ;
    void fatal(const char *format, ...) NORETURN ;
    void usageErr(const char *format, ...) NORETURN ;
    void cmdLineErr(const char *format, ...) NORETURN ;
    #endif

使用 errMsg()、errExit()、err_exit() 和 errExitEN() 诊断系统调用和库函数错误

1
2
3
4
5
#include "tlpi_hdr.h"
void errMsg(const char *format, ...);
void errExit(const char *format, ...);
void err_exit(const char *format, ...);
void errExitEN(int errnum, const char *format, ...);
  • errMsg()
    在标准错误输出错误信息。参数列表与 printf() 相同,输出末尾自动加换行。会输出当前 errno 对应的错误文本(如错误名 EPERM 和 strerror() 返回的描述),再加上格式化输出内容。

  • errExit()
    功能类似 errMsg(),但会终止程序。终止方式为调用 exit(),如果环境变量 EF_DUMPCORE 被设置为非空字符串,则调用 abort() 生成 core dump 文件(用于调试)。

  • err_exit()
    与 errExit() 类似,但有两点不同:

    1. 打印错误信息前不会刷新标准输出。
    2. 通过 _exit() 终止进程,而不是 exit(),这样不会刷新 stdio 缓冲区,也不会调用 exit 处理函数。 这种方式适合在库函数中创建子进程后,因错误需要立即终止子进程时使用,避免影响父进程的缓冲区和退出处理。
  • errExitEN()
    与 errExit() 类似,但输出的是参数 errnum 指定的错误号对应的错误文本(EN 代表 Error Number),而不是当前 errno 的内容。主要用于 POSIX 线程 API(pthread)相关的程序。

    • 传统 UNIX 系统调用出错返回 –1,POSIX 线程函数出错则直接返回错误号(正整数),成功返回 0。
    • 示例:
      1
      2
      3
      4
      int s;
      s = pthread_create(&thread, NULL, func, &arg);
      if (s != 0)
      errExitEN(s, "pthread_create");
    • 这样比直接用 errno 更高效,因为在多线程程序中 errno 是一个宏,会展开为函数调用,返回线程私有的存储区域指针。
  • lvalue(左值) 说明
    lvalue 是指向存储区域的表达式,最常见的是变量名。某些操作符也能产生 lvalue,比如指针解引用 *p。在 POSIX 线程 API 下,errno 被重定义为返回线程私有存储区指针的函数。

诊断其他类型错误的函数

1
2
3
4
#include "tlpi_hdr.h"
void fatal(const char *format, ...);
void usageErr(const char *format, ...);
void cmdLineErr(const char *format, ...);
  • fatal()
    用于诊断一般性错误,包括那些不会设置 errno 的库函数错误。参数列表与 printf() 相同,输出自动换行。该函数会将格式化信息输出到标准错误,并像 errExit() 一样终止程序。

  • usageErr()
    用于诊断命令行参数用法错误。参数同 printf(),输出以 "Usage:" 开头,后跟格式化内容,输出到标准错误,然后调用 exit() 终止程序。(有些示例程序会用扩展版 usageError()。)

  • cmdLineErr()
    类似于 usageErr(),但用于诊断命令行参数本身的错误。输出以 "Command-line usage error:" 开头,后跟格式化内容,输出到标准错误并终止程序。

错误处理函数实现说明

  • 错误处理函数的实现会用到 ename.c.inc 文件,该文件定义了一个字符串数组 ename,用于将 errno 错误号映射为符号名(如 EPERM、EAGAIN/EWOULDBLOCK 等)。
  • 这样做的好处是:strerror() 只返回错误描述,不包含符号名,而手册页用符号名描述错误。输出符号名便于查阅手册定位错误原因。
  • ename.c.inc 文件内容与硬件架构相关,不同平台 errno 值可能不同。可以用书中提供的脚本(lib/Build_ename.sh)为特定平台生成合适的版本。
  • ename 数组中有些字符串为空,对应未使用的错误号;有些字符串包含两个错误名(如 "EAGAIN/EWOULDBLOCK"),表示这两个符号对应同一个错误号。
  • 例如,EAGAIN 和 EWOULDBLOCK 在大多数 UNIX 系统上值相同,分别用于 System V 和 BSD 的不同场景。SUSv3 规范允许非阻塞调用返回这两个错误之一。

解析数字型命令行参数的函数

  • 头文件(如清单3-5)声明了两个常用来解析整型命令行参数的函数:getInt()getLong()
  • atoi()atol()strtol() 等标准函数相比,这两个函数的主要优点是能对数字参数进行基本有效性检查。
  • getInt()getLong() 分别将参数 arg 指向的字符串转换为 int 或 long 类型。如果 arg 不是有效的整数字符串(即只包含数字和 +、- 号),函数会输出错误信息并终止程序。
1
2
3
4
#include "tlpi_hdr.h"
int getInt(const char *arg, int flags, const char *name);
long getLong(const char *arg, int flags, const char *name);
// 返回 arg 转换后的数值
  • 如果 name 参数非 NULL,应传入一个字符串,用于标识 arg 参数。该字符串会包含在错误信息中,便于定位问题。

  • flags 参数用于控制 getInt()getLong() 的行为。默认情况下,这两个函数期望参数为有符号十进制整数。通过将一个或多个 GN_* 常量(见清单3-5)按位或(|)赋给 flags,可以选择不同的进制或限制数值范围(如只允许非负数或大于0)。

  • 这两个函数的实现见清单3-6。

  • 虽然 flags 参数可以强制范围检查,但在某些示例程序中我们并未启用这些检查。例如,在清单47-1中,未检查信号量初始值参数,用户可以输入负数,导致后续 semctl() 系统调用出错(ERANGE),因为信号量不能为负。省略范围检查有助于实验系统调用和库函数的正确与错误用法,便于学习。实际应用中通常会对命令行参数做更严格的检查。

系统调用(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()。

伪终端(Pseudoterminals)

  • 伪终端是一对虚拟设备,称为 master(主)和 slave(从)。
  • 这对设备提供了一个双向通信的 IPC 通道,可以在两端传递数据。
  • 伪终端的关键在于:slave 设备的接口行为和真实终端一样,这样可以让面向终端的程序连接到 slave 端,而另一个程序通过 master 端驱动它。
  • 驱动程序写入 master 的输出会经过终端驱动的常规输入处理(如回车转为换行),然后作为输入传递给连接在 slave 的终端程序。终端程序写入 slave 的内容也会经过输出处理后传递给驱动程序。
  • 换句话说,驱动程序扮演了传统终端用户的角色。
  • 伪终端广泛用于 X Window 系统下的终端窗口、telnet、ssh 等网络登录服务的实现。

日期与时间(Date and Time)

  • 进程关心两类时间:
    1. 实时时间(real time):以某个标准点(如日历时间)或进程生命周期的某个固定点(如启动时刻)为基准。UNIX 系统的日历时间以自 1970 年 1 月 1 日 0 点(UTC)以来的秒数计,这一时刻称为 Epoch。
    2. 进程时间(process time/CPU time):进程自启动以来所用的 CPU 时间,包括:
      • 系统 CPU 时间:在内核态执行(如系统调用)所用时间
      • 用户 CPU 时间:在用户态执行普通代码所用时间
  • time 命令可以显示进程的实时时间、系统 CPU 时间和用户 CPU 时间。

客户端-服务器架构(Client-Server Architecture)

  • 客户端-服务器应用由两部分组成:
    • 客户端(client):向服务器发送请求消息,请求服务
    • 服务器(server):接收请求,执行操作,并返回响应消息
  • 客户端和服务器之间可能有多轮请求-响应的对话。
  • 通常,客户端与用户交互,服务器则提供对某些共享资源的访问。常见情况是多个客户端进程与一个或少数几个服务器进程通信。
  • 客户端和服务器可以在同一台主机,也可以在通过网络连接的不同主机上。它们之间通过 IPC 机制进行通信。
  • 将服务封装在单一服务器中的好处包括:
    • 效率:集中管理资源(如打印机)比每台机器都配备资源更经济。
    • 控制、协调与安全:集中资源便于统一管理、协调访问和安全控制(如防止多个客户端同时修改同一数据)。
    • 异构环境兼容:在网络中,客户端和服务器可以运行在不同硬件和操作系统平台上。

实时(Realtime)

  • 实时应用(realtime applications)是指需要对输入做出及时响应的应用。通常,这类输入来自外部传感器或专用输入设备,输出则用于控制外部硬件。
  • 典型例子包括:自动化装配线、银行ATM、飞机导航系统等。
  • 许多实时应用要求快速响应,但其本质特征是:系统必须保证在触发事件后的一定时间内完成响应(有严格的截止时间)。
  • 实现高实时性(尤其是极短响应时间)需要操作系统的支持。大多数操作系统并不原生支持实时性,因为实时需求与多用户分时系统的需求可能冲突。传统 UNIX 不是实时操作系统,但有实时变种。Linux 也有实时版本,且新内核正逐步支持原生实时应用。
  • POSIX.1b 标准为实时应用定义了一系列扩展,包括:异步I/O、共享内存、内存映射文件、内存锁定、实时时钟和定时器、可选调度策略、实时信号、消息队列和信号量等。虽然大多数 UNIX 实现并非严格意义上的实时系统,但现在普遍支持这些扩展中的部分或全部。
  • “real time”指日历时间或经过时间,“realtime”专指具备上述响应能力的操作系统或应用。

/proc 文件系统

  • 和其他一些 UNIX 实现类似,Linux 提供了 /proc 文件系统,它是一组挂载在 /proc 目录下的目录和文件。
  • /proc虚拟文件系统,它以文件和目录的形式向用户提供内核数据结构的接口,便于查看和修改各种系统属性。
  • /proc 下有一组以 /proc/PID 命名的目录(PID为进程ID),用于查看系统中每个进程的信息。
  • /proc 文件内容通常为人类可读的文本,可被 shell 脚本解析。程序可以直接打开、读取或写入这些文件。大多数情况下,只有特权进程才能修改 /proc 下的文件内容。
  • 本书在介绍 Linux 编程接口时,也会介绍相关的 /proc 文件。更多信息见第12.1节。需要注意,/proc 文件系统不是标准规定的,具体细节是 Linux 特有的。

内存映射(Memory Mappings)

  • mmap() 系统调用可以在进程的虚拟地址空间中创建新的内存映射。
  • 映射分为两类:
    1. 文件映射(file mapping):将文件的一部分映射到进程的虚拟内存,映射后可以像操作内存一样访问文件内容,所需页面会自动从文件加载。
    2. 匿名映射(anonymous mapping):不对应任何文件,映射区域的内容初始化为0。
  • 不同进程之间可以共享同一内存映射(如两个进程映射同一文件区域,或子进程继承父进程的映射)。
  • 如果映射是私有的(private),对映射内容的修改不会影响其他进程,也不会写回文件;如果是共享的(shared),修改会被其他进程看到,并同步到文件。
  • 内存映射的用途包括:初始化进程代码段、分配新内存、文件I/O(内存映射I/O)、进程间通信(共享映射)等。

静态库与共享库(Static and Shared Libraries)

静态库(Static libraries)

  • 静态库(archive)是包含一组已编译函数的文件,便于程序开发和维护。
  • 使用静态库时,链接器会将需要的目标模块从库中复制到最终的可执行文件中,称为静态链接
  • 典型例子:/usr/lib/libm.a(数学库的静态库,扩展名为 .a
  • 缺点:
    • 每个可执行文件都包含一份库函数代码,浪费磁盘空间。
    • 多个程序同时运行时,每个都需加载自己的库函数副本,浪费内存。
    • 如果库函数更新,所有用到该函数的程序都必须重新链接。

共享库(Shared libraries)

  • 共享库为了解决静态库的问题而设计。
  • 程序链接共享库时,链接器只在可执行文件中记录需要的共享库,运行时由动态链接器加载和链接共享库。
  • 典型例子:/lib/x86_64-linux-gnu/libm.so.6(数学库的共享库,扩展名为 .so),/lib/x86_64-linux-gnu/libc.so.6(C 标准库的共享库)
  • 优点:
    • 只需在内存中保留一份共享库代码,所有程序共享,节省内存和磁盘空间。
    • 更新共享库后,所有程序下次运行时自动使用新版本,无需重新链接。

进程间通信与同步(Interprocess Communication and Synchronization)

在运行中的 Linux 系统中,存在大量进程,其中许多进程彼此独立运行。但有些进程需要协作完成任务,这就需要进程间通信(IPC)和同步机制。

  • 最简单的通信方式是通过读写磁盘文件,但这种方式通常太慢且不灵活。

  • 因此,Linux(和所有现代 UNIX 系统)提供了丰富的进程间通信机制,包括:

    • 信号(signals):用于通知进程某个事件发生。
    • 管道(pipes)和命名管道(FIFOs):用于在进程间传递数据(如 shell 中的 | 操作符)。
    • 套接字(sockets):可用于同一主机或不同主机间的进程数据传输。
    • 文件锁(file locking):允许进程锁定文件的某些区域,防止其他进程读取或修改内容。
    • 消息队列(message queues):用于在进程间交换消息(数据包)。
    • 信号量(semaphores):用于进程间的同步操作。
    • 共享内存(shared memory):允许多个进程共享一块内存区域,任何进程对其内容的更改都能被其他进程立即看到。

信号(Signals)

  • 信号常被称为“软件中断”。信号的到来通知进程某个事件或异常情况发生。
  • 信号有多种类型,每种类型代表不同的事件或条件。每种信号类型由一个整数标识,并有类似 SIGxxxx 的符号名。
  • 信号可以由内核、其他进程(有权限时)或进程自身发送。例如,内核会在以下情况下向进程发送信号:
    • 用户在键盘上输入中断字符(通常是 Ctrl+C)
    • 进程的某个子进程终止
    • 进程设置的定时器(闹钟)到期
    • 进程试图访问无效内存地址
  • 在 shell 中,可以用 kill 命令向进程发送信号;在程序中可以用 kill() 系统调用实现同样的功能。

当进程收到信号时,会根据信号类型采取以下动作之一: - 忽略信号 - 被信号杀死 - 被挂起,直到收到特定信号后恢复

对于大多数信号类型,程序可以选择忽略信号(如果默认动作不是忽略),或者设置信号处理函数(signal handler)。信号处理函数是程序员自定义的函数,在信号送达进程时自动调用,用于处理相应的事件。

信号从产生到送达进程之间的这段时间,称为信号“挂起”(pending)。通常,挂起信号会在进程下次被调度运行时立即送达,如果进程正在运行则立即送达。但也可以通过将信号加入进程的信号屏蔽字(signal mask)来阻塞信号。如果信号在被阻塞时产生,它会一直处于挂起状态,直到被解除阻塞(即从信号屏蔽字中移除)后才送达。

线程(Threads)

  • 在现代 UNIX 实现中,每个进程可以拥有多个执行线程。
  • 可以将线程理解为一组共享同一虚拟内存(以及其他属性)的“轻量级进程”。每个线程执行相同的程序代码,并共享数据区和堆区,但每个线程有自己的栈(用于存放局部变量和函数调用信息)。
  • 线程之间可以通过共享的全局变量进行通信。线程库还提供了条件变量(condition variables)和互斥锁(mutexes)等原语,用于线程间的通信和同步,特别是对共享变量的访问控制。
  • 线程也可以使用前面介绍的进程间通信(IPC)和同步机制进行通信。
  • 使用线程的主要优点:
    • 线程间共享数据(通过全局变量)非常方便。
    • 某些算法用多线程实现比多进程实现更自然。
    • 多线程应用可以充分利用多处理器硬件,实现并行处理。

进程组与 Shell 作业控制(Process Groups and Shell Job Control)

  • shell 启动的每个程序都会在新进程中运行。例如,下面的管道命令会创建三个进程:
    1
    $ ls -l | sort -k5n | less
  • 除 Bourne shell 外,所有主流 shell 都支持“作业控制”功能,允许用户同时执行和管理多个命令或管道。
  • 在支持作业控制的 shell 中,管道中的所有进程会被放入一个新的进程组(job)中。对于单条命令,也会创建只包含一个进程的进程组。
  • 每个进程组中的进程都有相同的进程组标识符(process group ID),该 ID 通常等于组内某个进程(称为进程组领导者)的进程 ID。
  • 内核支持对进程组的操作(如发送信号),shell 利用这一特性实现对整个管道作业的挂起、恢复等控制。

程序(Programs)

  • 程序通常有两种形式:
    1. 源代码:人类可读的文本,使用如 C 这样的编程语言编写。
    2. 二进制机器码:计算机可执行的指令。源代码需经过编译和链接,才能变为机器码。
  • 脚本(script)是包含命令的文本文件,由 shell 或其他命令解释器直接处理。
  • “程序”这两个含义通常可以互换,因为编译和链接会将源代码转换为等价的二进制代码。

过滤器(Filters)

  • 过滤器是指一类程序:从标准输入(stdin)读取数据,处理后将结果写到标准输出(stdout)。
  • 常见过滤器有:catgreptrsortwcsedawk 等。

命令行参数(Command-line arguments)

  • 在 C 语言中,程序可以通过 main 函数的参数访问命令行参数:
    1
    int main(int argc, char *argv[])
    • argc:参数个数
    • argv:参数字符串数组,argv[0] 通常是程序名

进程(Processes)

  • 进程是正在运行的程序实例。
  • 当程序被执行时,内核会将其代码加载到虚拟内存,分配变量空间,并建立记录进程信息的数据结构(如进程ID、终止状态、用户ID、组ID等)。
  • 内核负责在进程间分配和管理有限资源(如内存、CPU、网络带宽等),进程结束时,资源会被回收。

进程内存布局

进程的内存逻辑上分为以下几个部分(段): - Text:程序指令(代码段) - Data:静态变量 - Heap:动态分配内存区域 - Stack:函数调用和局部变量的栈空间

进程创建与程序执行

  • 进程可通过 fork() 系统调用创建新进程。调用 fork() 的为父进程,新创建的为子进程。
  • 子进程会复制父进程的数据、堆和栈(代码段通常只读并共享)。
  • 子进程可以继续执行父进程的代码,也可以通过 execve() 系统调用加载并执行新程序。execve() 会用新程序的代码和数据替换原有的段。
  • C 标准库还提供了一系列以 exec 开头的函数,都是对 execve() 的封装,统称为 exec()

进程ID与父进程ID

  • 每个进程有唯一的进程ID(PID)。
  • 每个进程还有一个父进程ID(PPID),表示哪个进程创建了它。

进程终止与终止状态

  • 进程可以通过 _exit() 系统调用(或相关的 exit() 库函数)主动终止,也可以被信号杀死。
  • 进程终止时会返回一个终止状态(小的非负整数),父进程可以通过 wait() 系统调用获取。
  • 约定:终止状态为 0 表示成功,非零表示出错。大多数 shell 用变量 $? 保存上一个程序的终止状态。 整理与翻译如下:

进程的用户和组标识(凭证)

每个进程都关联有多个用户ID(UID)和组ID(GID),包括:

  • 实际用户ID(real UID)和实际组ID(real GID):标识进程所属的用户和组。新进程从父进程继承这些ID。登录 shell 的实际UID和GID来自系统密码文件(/etc/passwd)中的相应字段。
  • 有效用户ID(effective UID)和有效组ID(effective GID):这两个ID(加上补充组ID)用于判断进程访问受保护资源(如文件、进程间通信对象)时的权限。通常有效ID与实际ID相同。更改有效ID可以让进程临时获得其他用户或组的权限。
  • 补充组ID(supplementary group IDs):标识进程所属的其他组。新进程从父进程继承这些ID,登录 shell 的补充组ID来自系统组文件(/etc/group)。

特权进程

  • 在传统 UNIX 系统中,有效用户ID为0(即超级用户 root)的进程被称为特权进程,可以绕过内核的权限检查。
  • 其他用户运行的进程称为非特权进程,其有效用户ID非0,必须遵守内核的权限规则。
  • 进程可以通过由特权进程创建(如 root 启动的 shell),或通过 set-user-ID 机制(程序文件设置了 setuid 位,进程获得该文件所有者的有效UID)获得特权。

能力(Capabilities)

  • 从 Linux 2.2 内核开始,传统上属于超级用户的权限被细分为多个能力(capabilities)
  • 每个特权操作都对应一个能力,进程只有拥有相应能力才能执行该操作。
  • 传统的超级用户进程(有效UID为0)等价于拥有所有能力的进程。
  • 只授予进程部分能力,可以让它执行部分特权操作,同时限制其他操作。
  • 能力名称以 CAP_ 开头,如 CAP_KILL

init 进程

  • 系统启动时,内核会创建一个特殊进程 init(所有进程的“父进程”),其程序文件为 /sbin/init
  • 系统中所有进程都是由 init 或其子孙进程通过 fork() 创建的。
  • init 进程的进程ID永远为1,拥有超级用户权限,不能被杀死(即使是超级用户也不行),只会在系统关机时终止。
  • init 的主要任务是创建和管理系统运行所需的各种进程。

守护进程(Daemon processes)

  • 守护进程是一类特殊用途的进程,由系统创建和管理,其特点包括:
    • 生命周期长:通常在系统启动时启动,直到系统关闭才结束。
    • 后台运行:没有控制终端,无法直接读取输入或输出到终端。
  • 常见的守护进程有 syslogd(记录系统日志)、httpd(提供网页服务)等。

环境变量列表(Environment list)

  • 每个进程都有一个环境变量列表,存储在进程的用户空间内存中。每个环境变量由名称和值组成。
  • 通过 fork() 创建新进程时,子进程会继承父进程的环境变量列表。这为父进程向子进程传递信息提供了机制。
  • 进程用 exec() 执行新程序时,可以选择继承原有环境变量,或指定新的环境变量。
  • 在大多数 shell 中,用 export 命令(C shell 用 setenv)创建环境变量,例如:
    1
    export MYVAR='Hello world'
  • C 程序可以通过外部变量 char **environ 访问环境变量,也可用相关库函数获取和修改环境变量。
  • 环境变量用途广泛,如 HOME(用户主目录)、PATH(shell 查找命令的目录列表)等。

资源限制(Resource limits)

  • 每个进程都会消耗资源,如打开的文件数、内存、CPU 时间等。
  • 进程可用 setrlimit() 系统调用设置资源消耗的上限。每种资源限制有两个值:
    • 软限制(soft limit):进程实际可用的资源上限。
    • 硬限制(hard limit):软限制可调整的最大值。
  • 非特权进程可以将软限制调整到硬限制以内的任意值,但只能降低硬限制,不能提高。
  • 新进程通过 fork() 创建时,会继承父进程的资源限制设置。
  • shell 可用 ulimit 命令(C shell 用 limit)调整资源限制,这些设置会被子进程继承。

Shell(壳/命令行解释器)

Shell 是一种专门的程序,用于读取用户输入的命令,并根据这些命令执行相应的程序。Shell 也被称为命令解释器(command interpreter)。

  • 登录 Shell(login shell):指用户首次登录时系统为其启动的运行 Shell 的进程。

在某些操作系统中,命令解释器是内核的一部分;但在 UNIX 系统中,Shell 是一个普通的用户进程。UNIX 系统支持多种 Shell,不同用户(甚至同一用户的不同会话)可以同时使用不同的 Shell。

用户与用户组(Users and Groups)

用户(Users)

  • 系统中的每个用户都有唯一的登录名(username)和对应的数字用户ID(UID)。
  • 每个用户的信息都记录在 /etc/passwd 文件的一行中,内容包括:
    • 用户名
    • 用户ID(UID)
    • 所属的第一个用户组的组ID(GID)
    • 家目录(home directory):用户登录后进入的初始目录
    • 登录Shell:用于解释用户命令的程序名
    • 密码(通常为加密形式),但出于安全考虑,密码一般存储在只有特权用户可读的 shadow 密码文件中。

用户组(Groups)

  • 为了管理和控制对文件及其他系统资源的访问,用户可以被组织到用户组中。
  • 例如,一个项目组的成员可以被加入同一个用户组,以便共享文件。
  • 早期 UNIX 系统中,一个用户只能属于一个组。BSD 及后来的 UNIX 和 POSIX 标准允许用户同时属于多个组。
  • 每个用户组的信息记录在 /etc/group 文件的一行中,内容包括:
    • 组名(唯一)
    • 组ID(GID)
    • 用户列表:以逗号分隔的用户名列表,表示属于该组的用户(不包括那些通过 /etc/passwd 文件的 GID 字段已属于该组的用户)

超级用户(Superuser)

  • 系统中有一个特殊用户,称为超级用户(superuser),拥有系统内的特殊权限。
  • 超级用户的用户ID为0,通常用户名为 root。
  • 超级用户可以绕过系统的所有权限检查。例如,可以访问系统中的任何文件、向任何进程发送信号等。
  • 系统管理员使用超级用户账户执行各种系统管理任务。

单一目录层次结构、目录、链接与文件

Linux 内核维护着一个单一的分层目录结构来组织系统中的所有文件(与 Windows 等操作系统每个磁盘有独立目录树不同)。这个层次结构的顶端是根目录 /,所有文件和目录都是根目录的子孙。

文件类型

  • 普通文件(regular file):普通数据文件。
  • 目录(directory):特殊文件,内容是文件名和对应文件引用的表。
  • 其他类型:还包括设备文件、管道、套接字、符号链接等。

目录与链接

  • 目录是特殊文件,内容是“文件名+引用”的表,这种关联称为链接(link)
  • 一个文件可以有多个链接(即多个名字),可以存在于同一目录或不同目录。
  • 目录可以包含指向文件和其他目录的链接,形成树状层次结构。
  • 每个目录至少有两个特殊条目:
    • . :指向自身
    • .. :指向父目录(根目录的 .. 也指向自身)
  • 符号链接(软链接,soft link)是一个特殊文件,内容是另一个文件的路径名。
  • 当系统调用中指定路径时,内核会自动解析(跟随)符号链接,直到找到目标文件(递归解析,内核会限制递归次数以防死循环)。
  • 如果符号链接指向的目标不存在,则称为悬挂链接(dangling link)
  • 硬链接(hard link)软链接(soft link)是两种不同的链接类型。

文件名(Filenames)

  • 在大多数 Linux 文件系统中,文件名最长可达 255 个字符。
  • 文件名可以包含除斜杠(/)和空字符(\0)以外的任意字符。
  • 建议只使用字母、数字、点(.)、下划线(_)和连字符(-),即字符集 [-._a-zA-Z0-9],这被称为 SUSv3 标准的“可移植文件名字符集”。
  • 避免使用不在可移植字符集内的字符,因为这些字符在 shell、正则表达式等环境下可能有特殊含义。如果必须使用,需要用反斜杠()转义,否则可能无法正确使用。
  • 避免以连字符(-)开头的文件名,因为在 shell 命令中可能被误认为是选项。

路径名(Pathnames)

  • 路径名是由可选的开头斜杠(/)和一系列用斜杠分隔的文件名组成的字符串。
  • 除最后一个部分外,路径中的每个部分都应是目录(或能解析为目录的符号链接),最后一个部分可以是任意类型的文件。
  • 路径名分为两类:
    • 绝对路径名:以斜杠(/)开头,从根目录开始定位文件。例如 /home/mtk/.bashrc/usr/include/
    • 相对路径名:不以斜杠开头,相对于当前工作目录。例如,从 usr 目录访问 types.h 可用 include/sys/types.h,从 avr 目录访问 .bashrc 可用 ../mtk/.bashrc
  • 路径名可以包含 ..,表示上一级目录。

当前工作目录(Current working directory)

  • 每个进程都有一个当前工作目录(current working directory),即进程在目录树中的“当前位置”,相对路径名都是基于这个目录解析的。
  • 进程的当前工作目录由父进程继承。登录 shell 的初始工作目录由 /etc/passwd 文件中的 home 字段指定。
  • 可以用 cd 命令更改当前工作目录。

文件所有权与权限(File ownership and permissions)

  • 每个文件都有一个关联的用户ID(UID)和组ID(GID),分别表示文件的所有者和所属组。
  • 文件的所有权决定了哪些用户可以访问该文件。
  • 系统将用户分为三类:
    1. 文件所有者(user)
    2. 与文件组ID匹配的组成员(group)
    3. 其他所有用户(other)
  • 每类用户有三种权限(共九个权限位):
    • 读(read):允许读取文件内容
    • 写(write):允许修改文件内容
    • 执行(execute):允许执行该文件(如程序或脚本)
  • 目录的权限含义略有不同:
    • 读:允许列出目录内容(文件名)
    • 写:允许修改目录内容(添加、删除、重命名文件)
    • 执行(或称搜索):允许访问目录中的文件(前提是对文件本身也有权限)

文件 I/O 模型

I/O 的通用性

UNIX 系统 I/O 模型的一个显著特点是I/O 的通用性。这意味着同一组系统调用(如 open()read()write()close() 等)可以用于所有类型的文件,包括设备文件。内核会将应用程序的 I/O 请求转换为相应的文件系统或设备驱动操作,从而对目标文件或设备进行实际的 I/O 操作。因此,使用这些系统调用的程序可以对任何类型的文件进行操作。

内核本质上只提供一种文件类型:按字节顺序排列的字节流。对于磁盘文件、磁盘和磁带设备,可以通过 lseek() 系统调用实现随机访问。

许多应用和库将换行符(ASCII 码 10,linefeed)视为一行文本的结束和下一行的开始。UNIX 系统没有专门的文件结束符(EOF),文件结束通过 read() 返回无数据来检测。

文件描述符(File descriptors)

I/O 系统调用通过文件描述符(file descriptor)来引用已打开的文件。文件描述符是一个(通常很小的)非负整数。通常通过 open() 调用获得文件描述符,open() 需要一个路径名参数,指定要进行 I/O 操作的文件。

当进程由 shell 启动时,通常会继承三个已打开的文件描述符: - 0:标准输入(standard input),进程从中读取输入 - 1:标准输出(standard output),进程向其写入输出 - 2:标准错误(standard error),进程向其写入错误信息和异常通知

在交互式 shell 或程序中,这三个描述符通常都连接到终端。在 stdio 库中,它们分别对应于 stdinstdoutstderr

stdio 库

C 语言程序通常使用标准 C 库中的 I/O 函数(即 stdio 库)进行文件 I/O。常用的 stdio 函数包括 fopen()fclose()scanf()printf()fgets()fputs() 等。这些 stdio 函数是建立在底层 I/O 系统调用(如 open()close()read()write() 等)之上的。

树莓派镜像下载、配置与自定义内核安装流程

1. 下载并写入指定树莓派镜像

为避免因购买或借用的树莓派设备时间不同导致的系统版本不一致,建议大家统一下载指定的树莓派镜像作为起点。

  • 将 MicroSD 卡插入 USB 读卡器,并连接到你的电脑。
  • 下载并安装最新版 Raspberry Pi Imager。
  • 下载课程指定的树莓派镜像:2022-01-28-raspios-bullseye-armhf.zip(约1.2GB,解压后近4GB)。
  • 解压 zip 文件,得到 .img 镜像文件。
  • 打开 Raspberry Pi Imager,选择“CHOOSE OS”→“Use custom”,选中刚才解压的 .img 文件。
  • 选择“CHOOSE SD CARD”,选中你的 MicroSD 卡(注意不要选错,否则会清空数据)。
  • 进入高级设置(齿轮图标,或 Windows 下用 Ctrl+Shift+X),建议修改主机名为唯一值(如包含你的用户名),以免与他人冲突。
  • 勾选“Enable SSH”,选择“Use password authentication”,设置用户名和强密码(建议不要用默认密码)。
  • 勾选“Set locale settings”,设置时区(如美国中部用 America/Chicago),键盘布局选 us。
  • 点击“Save”,然后点击“WRITE”写入镜像。
  • 写入完成后,卸载 MicroSD 卡,插入树莓派并开机。

2. 首次启动与 SSH 连接

  • 树莓派首次启动可能需要几分钟,启动后通过 SSH 连接(如 ssh piuser@pihost,用户名和主机名为你设置的)。
  • 若主机名无法解析,可在路由器管理页面查找树莓派的 IP 地址,再用 ssh 连接。

3. 初始设置与系统升级

  • 首次登录会看到“Welcome to the Raspberry Pi”向导,按提示设置国家、语言、时区、键盘等。
  • 可能会再次提示设置密码,可直接关闭。
  • 为防止欢迎界面反复出现,运行:
    1
    sudo apt purge piwiz
  • 升级系统和驱动:
    1
    2
    sudo apt-get update
    sudo apt-get upgrade
  • 升级完成后重启树莓派。

4. 网络设置与信息收集

  • 连接 WiFi,打开终端,运行:
    1
    ifconfig wlan0
    记录 MAC 地址(ether 后面的六组十六进制数)。
  • 运行:
    1
    hostname -I
    记录 IP 地址。

5. 传输并安装自编译内核与模块

  • 进入你交叉编译内核的目录:
    1
    cd ~/linux_source
  • 打包模块和内核:
    1
    2
    tar -C modules/lib -czf modules.tgz modules
    tar -C linux/arch/arm -czf boot.tgz boot
  • 在树莓派上新建 linux_source 目录,进入后用 sftp ,或其它方式下载上述两个压缩包:
    1
    2
    3
    4
    5
    sftp [学校统一登陆平台key]@shell.cec.学校曾用简称.edu
    cd /project/scratch01/compile/"your username"/linux_source
    get modules.tgz
    get boot.tgz
    quit
  • 备份 /usr/lib/modules/boot 目录(或 /lib/modules,视系统而定):
    1
    2
    sudo cp -r /usr/lib/modules ~/Desktop/modules_backup
    sudo cp -r /boot ~/Desktop/boot_backup
  • 解压并安装新内核和模块:
    1
    2
    3
    4
    5
    6
    7
    8
    tar -xzf modules.tgz
    tar -xzf boot.tgz
    cd modules
    sudo cp -rd * /usr/lib/modules # 或 /lib/modules
    cd ..
    sudo cp boot/dts/*.dtb /boot/
    sudo cp boot/dts/overlays/*.dtb* /boot/overlays
    sudo cp boot/dts/overlays/README /boot/overlays
  • 树莓派 3B+:
    1
    sudo cp boot/zImage /boot/kernel7.img
  • 树莓派 4/4B:
    1
    sudo cp boot/zImage /boot/kernel7l.img

6. 验证新内核

  • 重启树莓派,运行:
    1
    uname -a
    检查输出是否包含你设置的本地版本字符串、编译日期等。
  • 若未生效,编辑 /boot/config.txt,在 [pi4] 段落前加一行:
    1
    arm_64bit=0
    再重启并用 uname -a 检查。

7. 备份与后续建议

  • 建议用 svn、git 等工具备份你的代码,也可在多台树莓派间做冗余,防止系统崩溃或锁死导致数据丢失。
  • sudo passwd root更新root密码(忘记密码用)
  • sudo raspi-config中可以更改主机名
  • 在树莓派的桌面环境中,可以使用快捷键 Ctrl + Alt + T 快速打开终端

用户管理

  • sudo adduser 新用户名创建新用户
  • usermod -aG sudo username将username用户加入sudoers组
  • 执行visudo命令并在文件中添加username ALL=(ALL) NOPASSWD:ALL-赋予username用户执行所有sudo命令权限,不需要密码提示
  • sudo userdel --remove --force pi删除默认账号
0%