系统编程的基础

Linux系统编程有三大基石:系统调用、C库和C编译器。


系统调用 (syscall)

系统编程始于系统调用,也终于系统调用。系统调用(通常简称为syscall)是为了从操作系统请求一些服务或资源,是从用户空间如文本编辑器、游戏等向内核(系统的核心)发起的函数调用。

Linux实现的系统调用远远少于其他内核。举例来说,微软的Windows,其系统调用号称有几千个,而Linux x86-64体系结构的系统调用大概只有300个。在Linux内核中,每种体系结构(Alpha、x86-64或PowerPC)各自实现了标准系统调用。因此,不同体系结构支持的系统调用可能存在区别。然而,超过90%的系统调用在所有的体系结构上都实现了。

位于用户空间的应用程序无法直接访问内核空间,内核必须提供一种机制当用户空间的应用希望执行系统调用的时候,可以通过该机制通知内核。

虽然基本思想是一致的,但不同体系结构处理系统调用的方式不同。作为一名系统程序员,通常不需要了解内核是如何处理系统调用的。系统调用已经集成到各种体系结构的标准调用规范中,并通过编译器和C库自动处理。

C库

C库(libc)是UNIX应用程序的核心。即使你是使用其他语言编程,通常还是会通过高级语言封装的C库来提供核心服务,以方便系统调用。在现代Linux系统中,C库由GNU libc提供,简称glibc,发音是[gee-lib-see],或者有时发作[glib-see]。

GNU C库的功能远远超出了其名字的范畴。glibc中,除了标准C库,还提供了系统调用封装、线程支持和基本应用工具。

C编译器

在Linux中,标准C编译器是由GNU编译器工具集(GNU Compiler Collection,gcc)提供的。最初,gcc是GNU版的C编译器cc,因此,gcc表示GNU C编译器(GNU C Compiler)。随着时间推移,gcc支持越来越多的语言。时至今日,gcc已经成了GNU编译器家族的代名词。此外,gcc还表示C编译器二进制程序。除非特别指明,本书中提到gcc时,都是指gcc应用程序。

API和ABI

在系统层,有两组独立的影响可移植性的定义和描述。一是应用程序编程接口(Application Programming Interface,API),二是应用程序二进制接口(Application Binary Interface,ABI),它们都是用来定义和描述计算机软件的不同模块间的接口的。

API

“API定义了软件模块之间在源代码层交互的接口。它提供一组标准的接口(通常以函数的方式)实现了如下抽象:一个软件模块(通常是较高层的代码)如何调用另一个软件模块(通常位于较低层)。举个例子,API可以通过一组绘制文本函数,对在屏幕上绘制文本的概念进行抽象。API仅仅是定义接口,真正提供API的软件模块称为API的实现。 通常,人们把API称为“约定”,这并不合理,至少从API这个术语角度来讲,它并非一个双向协议。API用户(通常是高级软件)并没有对API及其实现提供任何贡献。API用户可以使用API,也可以完全不用它:用或不用,仅此而已!API的职能只是保证如果两个软件模块都遵循API,那么它们是“源码兼容”(source compatible),也就是说,不管API如何实现,API用户都能够成功编译。 API的一个实际例子就是由C标准定义的接口,通过标准C库实现。该API定义了一组基础函数,比如内存管理和字符串处理函数。

ABI

API定义了源码接口,而ABI定义了两个软件模块在特定体系结构上的二进制接口。它定义了应用内部如何交互,应用如何与内核交互,以及如何和库交互。API保证了源码兼容,而ABI保证了“二进制兼容(binary compatibility)”,确保对于同一个ABI,目标代码可以在任何系统上正常工作,而不需要重新编译。 ABI主要关注调用约定、字节序、寄存器使用、系统调用、链接、库的行为以及二进制目标格式。例如,调用约定定义了函数如何调用,参数如何传递,分别保留和使用哪些寄存器,调用方如何获取返回值。

文件和文件系统

文件打开方式有只读、只写和读写模式。

文件描述符(file descriptor):

  1. Linux中用一个整数表示文件
  2. 文件描述符在用户空间共享

普通文件

普通文件包含以字节流组织的数据。

在Linux中,可以从文件中的任意字节开始读写。对文件的操作是从某个字节开始,即文件“地址”。该地址称为文件位置(file location)或文件偏移(file offset)。文件位置是内核中与每个打开的文件关联的元数据中很重要的一项。第一次打开文件时,其偏移为0。通常,随着按字节对文件的读写,文件偏移也随之增加。文件偏移还可以手工设置成给定值,该值甚至可以超出文件结尾。在文件结尾后面追加一个字节会使得中间字节都被填充为0。虽然支持通过这种在文件末尾追加字节的操作,但是不允许在文件的起始位置之前写入字节。这种操作看起来就很荒谬,实际上也并无用处。文件位置的起始值为0,不能是负数。在文件中间位置写入字节会覆盖该位置原来的数据。因此,在中间写入数据并不会导致原始数据向后偏移。绝大多数文件写操作都是发生在文件结尾。

同一个文件可以由多个进程或同一个进程多次打开。系统会为每个打开的文件实例提供唯一文件描述符。因此,进程可以共享文件描述符,支持多个进程使用同一个文件描述符。Linux内核没有限制文件的并发访问。不同的进程可以同时读写同一个文件。对文件并发访问的结果取决于这些操作的顺序,通常是不可预测的。用户空间的程序往往需要自己协调,确保对文件的同步访问是合理的。

文件虽然是通过文件名访问,但文件本身其实并没有直接和文件名关联。相反地,与文件关联的是索引节点inode(最初称为信息节点 ,是information node的缩写),inode是文件系统为该文件分配的唯一整数值(但是在整个系统中不一定是唯一的)。该整数值称为inode number,通常简称为i-number或ino。索引节点中会保存和文件相关的元数据,如文件修改时间戳、所有者、类型、长度以及文件数据的位置——但不含文件名!索引节点就是UNIX文件在磁盘上的实际物理对象,也是在Linux内核中通过数据结构表示的概念实体。

目录和链接

目录用于提供访问文件需要的名称。目录是可读名称到索引编号之间的映射。名称和索引节点之间的配对称为链接(link)。映射在物理磁盘上的形式,如简单的表或散列,是通过特定文件系统的内核代码来实现和管理的。从概念上看,可以把目录看作普通文件,其区别在于它包含文件名称到索引节点的映射。内核直接通过该映射把文件名解析为索引节点。

硬链接

每个索引节点包含链接计数(link count),记录该索引节点在文件系统中的链接数。当unlink某个路径时,其链接计数会减1;只有当链接计数为0时,索引节点及其关联的数据才会从文件系统中真正删除。

符号链接

符号链接类似于普通文件,每个符号链接有自己的索引节点和数据块,包含要链接的文件的绝对路径。这意味着符号链接可以指向任何地方,包括不同的文件系统上的文件和路径,甚至指向不存在的文件和目录。指向不存在的文件的符号链接称为坏链接(broken link)。 比起硬链接,符号链接会带来更多的开销,因为有效解析符号链接需要解析两个文件:一是符号链接本身,二是该链接所指向的文件。硬链接不会带来这些额外开销——因为访问在文件系统中被多次链接的文件和单次链接的文件没有区别。虽然符号链接的开销很小,但还是被认为是个负面因素。

特殊文件

是指以文件来表示的内核对象。

“Linux只支持四种特殊文件:块设备文件、字符设备文件、命名管道以及UNIX域套接字。特殊文件是使得某些抽象可以适用于文件系统,贯彻一切皆文件的理念。Linux提供了系统调用来创建特殊文件。 在UNIX系统中,访问设备是通过设备文件来实现,把设备当作文件系统中的普通文件。设备文件支持打开、读和写操作,允许用户空间程序访问和控制系统上的(物理和虚拟)设备。UNIX设备通常可以划分成两组:字符设备(character devices)和块设备(block device)。每种设备都有自己的特殊文件。 字符设备是作为线性字节队列来访问。设备驱动程序把字节按顺序写入队列,用户空间程序按照写入队列的顺序读取数据。键盘就是典型的字符设备。举个例子,当用户输入“peg”,应用程序将顺序从键盘设备中读取p、e和g。如果没有更多的字符读取时,设备会返回end-of-file(EOF)。漏读数据或以其他顺序读取都是不可能的。字符设备通过字符设备文件(character device file)进行访问。 和字符设备不同,块设备是作为字节数组来访问。设备驱动把字节映射到可寻址的设备上,用户空间可以按任意顺序随意访问数组中的任何字节——可能读取字节12,然后读取字节7,然后又读取字节12。块设备通常是存储设备。硬盘、软盘、CD-ROM驱动和闪存都是典型的块设备。这些块设备通过块设备文件(block device file)来访问。 命名管道(named pipes),通常称为FIFO(是“先进先出first in, first out”的简称),是以文件描述符作为通信信道的进程间通信(IPC)机制,它可以通过特殊文件来访问。普通管道是将一个程序的输出以“管道”形式作为另一个程序的输入,普通管道是通过系统调用在内存中创建的,并不存在于任何文件系统中。命名管道和普通管道一样,但其是通过FIFO特殊文件访问,不相关进程可以访问该文件并进行交互。

套接字(socket)是最后一种特殊文件。socket是进程间通信的高级形式,支持不同进程间的通信,这两个进程可以在同一台机器,也可以在不同机器。实际上,socket是网络和互联网编程的基础。socket演化出很多不同的变体,包括UNIX域套接字,它是本地机器进行交互的socket格式。虽然socket在互联网上的通信会使用主机名和端口号来标识通信目标,UNIX域套接字使用文件系统上的特殊文件进行交互,该文件称为socket文件。

文件系统和命名空间

文件系统是以合理有效的层次结构组织的文件和目录的集合。在文件和目录的全局命名空间中,可以分别添加和删除文件系统,这些操作称为挂载(mounting)和卸载(unmounting)。每个文件系统都需要挂载到命名空间的特定位置,该位置即挂载点(mount point)。在挂载点可以访问文件系统的根目录。举个例子,把CD挂载到/media/cdrom,CD上文件系统的根目录就可以通过/media/cdrom访问。第一个被挂载的文件系统是在命名空间的根目录/下,称为根文件系统(root filesystem)。Linux系统必定有个根文件系统,而其他文件系统的挂载点则是可选的。

从历史角度看,UNIX系统只有一个共享的命名空间,对系统上所有的用户和进程都可见。Linux独辟蹊径,支持进程间独立的命名空间,允许每个进程都可以持有系统文件和目录层次的唯一视图[4]。默认情况下,每个进程都继承父进程的命名空间,但是进程也可以选择创建自己的命名空间,包含通过自己的挂载点集和独立的根目录。

进程

进程是执行时的目标代码:活动的、正在运行的程序。但是进程不仅包含目标代码,它还包含数据、资源、状态和虚拟计算机。

进程的生命周期是从可执行目标代码开始,这些机器可运行的代码是以内核能够理解的形式存在,在Linux下,最常见的格式称为“可执行和可链接的格式(Executable and Linkable Format,ELF)”。可执行性格式包含元数据、多个代码段和数据段。代码段是线性目标代码块,可以加载到线性内存块中。数据段中的所有数据都一视同仁,有相同的权限,通常也用于相同的目的。

最重要和通用的段是文本段、数据段和bss段。文本段包含可执行代码和只读数据如常量,通常标记为只读和可执行。数据段包含初始化的数据,如包含给定值的C变量,通常标记为可读写。bss段包含未初始化的全局数据。

进程还和系统资源关联,系统资源是由内核决定和管理的。一般来说,进程只通过系统调用请求和管理资源。资源包括计时器、挂起的信号量、打开的文件、网络连接、硬件和IPC机制。进程资源以及该进程相关的数据和统计保存在内核中该进程的进程描述符中。

进程是一种虚拟抽象。进程内核同时支持抢占式多任务和虚拟内存,为每个进程提供虚拟处理器和虚拟内存视图。从进程角度看,系统看起来好像完全由进程控制。也就是说,虽然某个进程可以和其他进程一起调度,该进程在运行时看起来似乎独立控制整个系统。系统内核会无缝、透明地抢占和重新调度进程,所有进程共享系统处理器,而进程感不到其中的区别。同样,每个进程都获得独立的线性地址空间,好像它独立控制整个系统内存。通过虚拟内存和分页,内核支持多个进程共享系统,每个进程的操作都运行在独立的地址空间中。

线程

每个进程包含一个或多个执行线程(通常简称线程threads)。线程是进程内的活动单元,换句话说,线程是负责执行代码和管理进程运行状态的抽象。

线程包括栈(正如非线程系统的进程栈一样,用于存储局部变量)、处理器状态、目标代码的当前位置(通常是保存在处理器的指令指针中)。进程的其他部分由所有线程共享,最主要是进程地址空间。在这种情况下,线程在维护虚拟进程抽象时,也共享虚拟内存抽象。

在Linux系统内部,Linux内核实现了独特的线程模型:它们其实是共享某些资源的普通进程。

在Linux中,进程有严格的层次结构,即进程树。进程树的根是第一个进程,称为init进程,通常是init程序。新的进程是通过系统调用fork()创建的。fork()会创建调用进程的副本。原进程称为父进程,fork()创建的新进程称为子进程。除了第一个进程外,每个进程都有父进程。如果父进程先于子进程终止,内核会将init进程指定为它的父进程。

当进程终止时,并不会立即从系统中删除。相反地,内核将在内存中保存该进程的部分内容,允许父进程查询其状态,这被称为等待终止进程。一旦父进程确定某个子进程已经终止,该子进程就会完全被删除。如果一个进程已经终止,但是父进程不知道其状态,该进程称为僵尸进程(zombie)。init进程会等待所有的子进程结束,确保子进程永远不会处于僵死状态。

用户和组

Linux中通过用户和组进行权限认证,每个用户和一个唯一的正整数关联,该整数称为用户ID(uid)。相应地,每个进程和一个uid关联,用来识别运行这个进程的用户,称为进程的真实uid(real uid)。在Linux内核中,uid是用户的唯一标识。

但是,用户一般通过用户名而不是id来表示。用户名及其对应的uid保存在/etc/passwd中,而系统库会把用户名映射到对应的uid上。

在登录过程中,用户向login程序提供用户名和密码。如果提供的用户名和密码都正确,login程序会根据/etc/passwd为用户生成login shell,并把用户id作为该shell进程的uid。子进程继承父进程的uid。

超级用户root的uid是0。root用户有特殊的权限,几乎可以执行所有的操作。举个例子,只有root用户可以修改进程的uid。因此,login进程是以root身份运行的。

除了真实uid以外,每个进程还包含有效的uid(effective uid),保留uid(saved uid)和文件系统uid(filesystem uid)。真实uid总是启动进程的用户uid,有效的uid在不同情况下会发生变化,从而支持进程切换成其他用户权限来执行。保留uid保存原来的有效uid,其值决定了用户将切换成哪个有效uid。文件系统uid通常和有效uid等效,用于检测文件系统的访问权限。

每个用户属于一个或多个组,包括在/etc/passwd中给出的基础组(primary group)或登录组(login group),也可能是/etc/group中给出的很多其他附加组(supplemental group)。因此,每个进程和相应的组ID(gid)关联,也包括真实gid、有效gid、保留gid、文件系统gid。进程通常是和用户的登录组关联,而不是和附加组关联。

一些安全机制只允许进程在满足特定标准时才执行某些操作。对于这一点,UNIX的安全机制非常简单:uid为0的进程可以访问,而其他进程不能访问。最近,Linux采用更通用的安全系统来取代UNIX这种安全机制。通过安全系统,不是做简单的二元判断,而是允许内核执行更细粒度的访问控制。

权限

每个文件都有文件所有者、所属组以及三个权限位集合。每个权限位描述了所有者、所属组以及其他人对文件的读、写和执行的权限。这三类每个对应3个位,共9位。文件所有者和权限信息保存在文件的索引节点中。

对于普通文件,权限非常清晰:三位分别表示读、写和执行权限。特殊文件的读写权限和普通文件的一样,虽然特殊文件的读写内容由特殊文件自己确定。特殊文件忽略执行权限。对于目录,读权限表示允许列出目录的内容,写权限表示允许在目录中添加新的链接,执行权限表示允许在路径中输入和使用该目录。

信号

信号是一种单向异步通知机制。信号可能是从内核发送到进程,也可能是从进程到进程,或者进程发送给自己。信号一般用于通知进程发生了某些事件,如段错误或用户按下Ctrl-C。

Linux内核实现了约30种信号(准确数值和每个体系结构有关)。每个信号是由一个数值常量和文本名表示。举个例子,SIGHUP用于表示终端挂起,在x86-64体系结构上值为1。

信号会“干扰”正在执行的进程,不管当前进程正在做什么,都会立即执行预定义的操作。除了SIGKILL(进程中断)和SIGSTOP(进程停止),当进程接收到信号时,可以控制正在执行的操作。进程可以接受默认的信号处理操作,可能是中断进程、中断并coredump进程、停止进程或者什么都不做,具体的操作取决于信号值。此外,进程还可以选择显式忽略或处理信号。忽略的信号会被丢弃,不做处理。处理信号会执行用户提供的信号处理函数,程序接收到信号时会立即跳到处理函数执行。当信号处理函数返回时,程序控制逻辑将返回之前终端的指令处继续执行。由于信号的异步性,信号处理函数需要注意不要破坏之前的代码,只执行异步安全(async-safe,也称为信号安全)的函数。

进程间通信

Linux支持的进程间通信机制包括管道、命名管道、信号量、消息队列、共享内存和快速用户空间互斥(futex)。

头文件

Linux系统编程离不开大量的头文件。内核本身和glibc都提供了用于系统编程的头文件。这些头文件包括标准C库(如<string.h>)以及一些UNIX的贡献(如<unistd.h>)。

错误处理

在系统编程中,错误是通过函数的返回值和特殊变量errno描述。glibc为库函数和系统调用提供透明errno支持。本书中给出的绝大多数接口都使用这种机制来报告错误。

函数通过特殊返回值(通常是-1,具体值取决于函数)通知调用函数发生了错误。错误值告诉调用函数发生了错误,但是并没有给出错误发生的原因。变量errno用于定位错误的原因。

errno的值只有当errno设置函数显示错误后(通常返回-1)才生效,而在程序的后续执行过程中都可以修改其值。可以直接读写errno变量,它是可修改的左值。errno的值和特定错误的文本描述一一对应。预处理器#define也和数值errno值一一对应。