Linux系统编程涉及的一些相关概念
文章目录
- Linux系统编程涉及的一些相关概念
- 1、什么是系统编程
- 2、系统调用
- 2.1 调用系统调用
- 2.2 C库
- 2.3 C编译器
- 3、应用程序编程接口(APIs)和应用程序二进制接口(ABIs)
- 3.1 应用程序编程接口
- 3.2 应用程序二进制接口
- 4、标准
- 4.1 POSIX和SUS的历史
- 4.2 C语言标准
- 4.3 Linux和标准
- 5、Linux编程概念
- 5.1 文件和文件系统
- 5.2 普通文件
- 5.3 目录与链接
- 5.4 硬链接(Hard Links)
- 5.5 符号链接
- 5.6 特殊文件
- 6、进程
- 6.1 线程
- 6.2 进程层次结构
- 6.3 用户与组
- 6.4 权限
- 6.5 信号
- 6.5.1 进程间通信
- 6.5.2 头文件
- 6.5.3 错误处理
系统软件位于底层,直接与内核和核心系统库接口相连。系统软件包括你的外壳程序(shell)和文本编辑器、编译器和调试器、核心工具以及系统守护进程。这些组件完全基于内核和C库的系统软件。许多其他软件(如高级GUI应用程序)主要存在于更高层次,偶尔才会深入到低层。
Linux是一个现代的类Unix系统,由Linus Torvalds从头开始编写,并得到全球松散联合的黑客社区的支持。尽管Linux共享着Unix的目标和理念,但Linux并不是Unix。相反,Linux遵循自己的道路,在需要的地方分道扬镳,只在实用的情况下汇聚。
通常,Linux的核心系统编程与其他任何Unix系统相同。然而,在基础之外,Linux确实很好地区分了自己——与传统的Unix系统相比,Linux充满了额外的系统调用、不同的行为和新功能。
为了更好深入Linux系统编程,本文将介绍Linux系统编程涉及的一些相关概念。
1、什么是系统编程
传统上讲,所有Unix编程都是系统级编程。从历史上看,Unix系统并没有包含许多高层抽象。即使在像X Window System这样的开发环境中进行编程,核心的Unix系统API也完全暴露出来。
系统编程通常与应用编程形成对比。系统级和应用级编程在某些方面有所不同,但在其他方面则没有区别。系统编程的显著之处在于,系统程序员必须对他们工作的硬件和操作系统有深刻的认识。当然,所使用的库和调用也有所不同。根据应用程序编写所在的“层次”不同,两者实际上可能并不是非常可互换的,但一般来说,从应用编程转向系统编程(或反之)并不困难。即使应用程序位于栈的高层,远离系统的最低层级,了解系统编程仍然很重要。而且在所有形式的编程中都采用了相同的良好实践。
过去几年中,应用编程的趋势是远离系统级编程,转向非常高级别的开发,无论是通过Web软件(如JavaScript或PHP),还是通过托管代码(如C#或Java)。然而,这种发展并不意味着系统编程的消亡。事实上,仍然需要有人编写JavaScript解释器和C#运行时,这本身就是系统编程。此外,编写PHP或Java的开发人员仍然可以从了解系统编程中受益,因为对核心内部的理解允许无论在栈的哪一层编写代码都能写出更好的代码。
尽管应用编程有这样的趋势,但大多数Unix和Linux代码仍然是在系统级别编写的。其中大部分是C语言编写的,主要依赖于C库和内核提供的接口。这是传统的系统编程——包括Apache、bash、cp、Emacs、init、gcc、gdb、glibc、ls、mv、vim和X。这些应用程序不会很快消失。
在Linux中的系统编程有三个基石:系统调用、C库和C编译器。每个都值得介绍一下。
2、系统调用
系统编程从系统调用开始。系统调用(常简称为syscalls)是从用户空间——你的文字编辑器、喜爱的游戏等——向内核(系统的核心内部)发起的函数调用,目的是请求操作系统提供某些服务或资源。系统调用的范围从熟悉的如read()和write(),到不常见的如get_thread_area()和set_tid_address()。
Linux实现的系统调用数量远少于大多数其他操作系统内核。例如,i386架构的系统调用数量大约为300个,而据称微软Windows的系统调用有数千个。在Linux内核中,每种机器架构(如Alpha、i386或PowerPC)都实现了自己的可用系统调用列表。因此,一种架构上可用的系统调用可能与另一种架构上的不同。尽管如此,所有架构实现的系统调用中有非常大的一部分——超过90%——是相同的。
2.1 调用系统调用
用户空间应用程序不能直接与内核空间链接。出于安全性和可靠性的考虑,不允许用户空间应用程序直接执行内核代码或操作内核数据。相反,内核必须提供一个机制,使用户空间应用程序能够“信号”内核它希望调用系统调用。然后,应用程序可以通过这个定义明确的机制陷入内核,并只执行内核允许其执行的代码。具体的机制因架构而异。例如,在i386上,用户空间应用程序会执行一个软件中断指令int,值为0x80。这个指令会导致切换到内核空间,即内核的保护领域,在那里内核执行一个软件中断处理程序——那么中断0x80的处理程序是什么?正是系统调用处理程序!
应用程序通过机器寄存器告诉内核要执行哪个系统调用以及使用哪些参数。系统调用由数字表示,从0开始编号。在i386架构上,为了请求系统调用5(恰好是open()),用户空间应用程序会在发出int指令之前将5放入eax寄存器。
参数传递以类似方式处理。例如,在i386上,每个可能的参数都有一个寄存器——寄存器ebx、ecx、edx、esi和edi依次包含前五个参数。在少数情况下,如果一个系统调用有超过五个参数,会使用一个寄存器来指向用户空间中的一个缓冲区,所有参数都保存在这个缓冲区中。当然,大多数系统调用只有几个参数。
其他架构处理系统调用的方式有所不同,但核心理念是相同的。
作为系统程序员,你通常不需要了解内核如何处理系统调用的调用过程。这些知识被编码到了架构的标准调用约定中,并由编译器和C库自动处理。
2.2 C库
C库(libc)是Unix应用程序的核心。即使你在使用其他编程语言编程,C库也很可能通过高级库被调用,提供核心服务并促进系统调用的执行。在现代Linux系统中,C库由GNU libc提供,简称glibc,发音为gee-lib-see,或者较少见的glib-see。
GNU C库提供的功能超出了其名称所暗示的范围。除了实现标准的C库外,glibc还提供了系统调用的封装、线程支持和基本的应用设施。
2.3 C编译器
在Linux中,标准的C编译器由GNU编译器集合(gcc)提供。最初,gcc是GNU版本的cc,即C编译器。因此,gcc代表GNU C编译器。随着时间的推移,越来越多的语言得到了支持。因此,如今gcc通常被用作GNU编译器家族的通用名称。然而,gcc也是用于调用C编译器的二进制文件。在本书中,当我提到gcc时,我通常指的是程序gcc,除非上下文另有说明。
在Unix系统中使用的编译器——包括Linux在内——与系统编程密切相关,因为编译器有助于实现C标准和系统ABI,这两者都在本文后面详细讨论。
3、应用程序编程接口(APIs)和应用程序二进制接口(ABIs)
程序员自然对确保他们的程序能够在所有承诺支持的系统上运行(无论是现在还是将来)感兴趣。他们希望确信,在他们使用的Linux发行版上编写的程序也能在其他Linux发行版上运行,以及在其他受支持的Linux架构和更新(或更早)的Linux版本上运行。
在系统层面,有两个独立的定义和描述集合影响可移植性。一个是应用程序编程接口(API),另一个是应用程序二进制接口(ABI)。两者都定义并描述了计算机软件不同部分之间的接口。
3.1 应用程序编程接口
API通过在源代码级别定义软件之间通信的接口。它通过提供一组标准接口(通常是函数)来实现抽象,使得一个软件组件(通常但不必然是高层组件)可以调用另一个软件组件(通常是低层组件)中的这些接口。例如,API可能通过一组函数来抽象在屏幕上绘制文本的概念,这些函数提供了绘制文本所需的一切。API只是定义了接口;实际提供API的软件组件被称为API的实现。
人们通常称API为“契约”。这种说法至少在法律意义上是不正确的,因为API不是一种双向协议。API用户(通常是高层软件)对API及其实现没有任何输入。它可以使用这个API,也可以完全不使用:接受或放弃!API的作用仅仅是确保如果两个软件组件都遵循该API,那么它们在源代码上是兼容的;也就是说,API的用户能够成功地与API的实现一起编译。
一个现实世界的例子是由C标准定义并由标准C库实现的API。这个API定义了一系列基本且必要的函数,比如字符串操作例程。
3.2 应用程序二进制接口
API定义了源代码接口,而ABI则定义了特定架构上两个或多个软件组件之间的低层二进制接口。它定义了应用程序如何与自身交互、应用程序如何与内核交互以及应用程序如何与库交互。ABI确保二进制兼容性,保证一个目标代码片段在具有相同ABI的任意系统上都能正常运行,无需重新编译。
ABI涉及的问题包括调用约定、字节序、寄存器使用、系统调用方式、链接、库行为和二进制对象格式等。例如,调用约定定义了函数是如何被调用的,参数是如何传递给函数的,哪些寄存器需要保留,哪些会被破坏,以及调用者如何获取返回值。
尽管已经多次尝试为特定架构上的多个操作系统(特别是在Unix系统的i386架构)定义一个统一的ABI,但这些努力并未取得很大成功。相反,操作系统——包括Linux在内——往往根据自己的需求定义自己的ABI。ABI与架构密切相关;绝大多数ABI都是关于机器特定概念的,比如特定的寄存器或汇编指令。因此,每种机器架构在Linux上都有自己的ABI。实际上,我们通常会用机器名称来称呼特定的ABI,比如alpha或x86-64。
系统程序员应该了解ABI,但通常不需要记住它。ABI由工具链(编译器、链接器等)强制执行,通常不会在其他情况下出现。然而,了解ABI可以带来更优化的编程,并且在编写汇编代码或修改工具链本身(毕竟这是系统编程的一部分)时是必需的。
Linux上给定架构的ABI可以通过互联网获得,并由该架构的工具链和内核实现。
4、标准
Unix系统编程是一门古老的艺术。Unix编程的基础知识几十年来一直保持不变。然而,Unix系统是动态变化的。行为发生变化,新功能被添加。为了帮助在混乱中建立秩序,标准组织将系统接口编纂成官方标准。存在许多这样的标准,但从技术上讲,Linux并不正式符合其中任何一个。相反,Linux的目标是遵循两个最重要和最普遍的标准:POSIX和单一UNIX规范(SUS)。
POSIX和SUS记录了诸如Unix类操作系统接口的C API等事项。实际上,它们为兼容的Unix系统定义了系统编程,或者至少是其一个通用子集。
4.1 POSIX和SUS的历史
在20世纪80年代中期,电气和电子工程师协会(IEEE)率先努力标准化Unix系统的系统级接口。自由软件运动的创始人理查德·斯托曼建议将该标准命名为POSIX(发音为pahzicks),现在代表可移植操作系统接口。
这一努力的第一个成果发布于1988年,即IEEE Std 1003.1-1988(简称POSIX 1988)。1990年,IEEE修订了POSIX标准,发布了IEEE Std 1003.1-1990(简称POSIX 1990)。分别在1993年的IEEE Std 1003.1b-1993(POSIX 1993或POSIX.1b)和1995年的IEEE Std 1003.1c-1995(POSIX 1995或POSIX.1c)中记录了可选的实时和线程支持。2001年,这些可选标准与基础的POSIX 1990合并,创建了一个单一标准:IEEE Std 1003.1-2001(POSIX 2001)。最新的修订版本发布于2004年4月,即IEEE Std 1003.1-2004。所有核心POSIX标准都缩写为POSIX.1,其中2004年的版本是最新的。
在20世纪80年代末和90年代初,Unix系统供应商参与了“Unix战争”,每个供应商都在努力将自己的Unix变体定义为Unix操作系统。几个主要的Unix供应商围绕The Open Group联合起来,这是一个由开放软件基金会(OSF)和X/Open合并形成的行业联盟。The Open Group提供认证、白皮书和合规测试。在90年代初,随着Unix战争的激烈进行,The Open Group发布了单一UNIX规范(SUS)。由于其免费成本相对于POSIX标准的高成本,SUS迅速获得了广泛的欢迎。今天,SUS纳入了最新的POSIX标准。
第一个SUS发布于1994年。符合SUSv1的系统被标记为UNIX 95。第二个SUS发布于1997年,兼容的系统被标记为UNIX 98。第三版也是最新版的SUS,SUSv3,发布于2002年。兼容的系统被标记为UNIX 03。SUSv3修订并结合了IEEE Std 1003.1-2001和其他几个标准。
4.2 C语言标准
丹尼斯·里奇和布莱恩·柯林汉的著名书籍《C程序设计语言》(普伦蒂斯霍尔出版社)在其1978年出版后的许多年里,作为非正式的C语言规范。这一版本的C被称为K&R C。C语言已经迅速取代了BASIC和其他语言,成为微计算机编程的通用语言。因此,为了标准化当时已经相当流行的语言,美国国家标准学会(ANSI)于1983年成立了一个委员会,开发一个官方版本的C语言,纳入了来自各个供应商和新推出的C++语言的功能和改进。这个过程漫长而艰辛,但ANSI C在1989年完成。1990年,国际标准化组织(ISO)批准了ISO C90,基于ANSI C并做了少量修改。
1995年,ISO发布了C语言的一个更新版本(尽管很少被实现),即ISO C95。1999年,对语言进行了一次重大更新,推出了ISO C99,引入了许多新功能,包括内联函数、新的数据类型、变长数组、C++风格的注释以及新的库函数。
4.3 Linux和标准
如前所述,Linux致力于符合POSIX和SUS标准。它提供了在SUSv3和POSIX.1中记录的接口,包括可选的实时支持(POSIX.1b)和可选的线程支持(POSIX.1c)。更重要的是,Linux试图提供与POSIX和SUS要求一致的行为。通常,不符合标准被认为是一个错误。人们认为Linux符合POSIX.1和SUSv3标准,但由于没有进行过官方的POSIX或SUS认证(尤其是在每一个版本的Linux上),我不能说Linux正式符合POSIX或SUS标准。
在语言标准方面,Linux表现良好。gcc C编译器支持ISO C99。此外,gcc还提供了许多自己的C语言扩展。这些扩展统称为GNU C,并在附录中有文档说明。
Linux在向前兼容性方面的历史并不是很好,尽管如今情况有了很大改善。由标准定义的接口,例如标准C库,显然将始终保持源代码兼容。二进制兼容性至少会在glibc的给定主要版本中保持。随着C语言的标准化,gcc将始终正确编译合法的C代码,尽管gcc特有的扩展可能会被弃用并最终在新发布的gcc中移除。最重要的是,Linux内核保证了系统调用的稳定性。一旦在稳定版本的Linux内核中实现了某个系统调用,它就不会再改变。
经验丰富的Linux用户可能还记得从a.out切换到ELF,从libc5切换到glibc,以及gcc的变更等等。值得庆幸的是,那些日子已经过去了。
在各种Linux发行版中,Linux标准基础(LSB)规范了大部分Linux系统。LSB是几个Linux供应商在Linux基金会(以前称为自由标准组织)的支持下共同进行的一个项目。LSB扩展了POSIX和SUS,并增加了一些自己的标准;它试图提供一个二进制标准,允许目标代码在兼容的系统上无需修改即可运行。大多数Linux供应商在一定程度上都遵守LSB。
5、Linux编程概念
本节简要概述了Linux系统提供的服务。所有Unix系统,包括Linux在内,都提供了一组通用的抽象和接口。实际上,这种共性定义了Unix。文件和进程等抽象概念,以及管理管道和套接字的接口等,都是Unix的核心所在。
本概述假设您熟悉Linux环境:假定您能够在shell中操作,使用基本命令,并编译一个简单的C程序。这不是对Linux或其编程环境的概述,而是对构成Linux系统编程基础的“内容”的概述。
5.1 文件和文件系统
文件是Linux中最基本和最重要的抽象概念。Linux遵循“一切皆文件”的哲学(尽管不像其他一些系统,如Plan9,那样严格)。因此,很多交互都是通过读取和写入文件来进行的,即使所讨论的对象并不是您通常意义上的文件。
Plan9,一个源自贝尔实验室的操作系统,常被称为Unix的继任者。它具有几个创新的理念,并且坚持“一切皆文件”的哲学。
为了访问一个文件,必须先打开它。文件可以以只读、只写或读写两种方式打开。一个打开的文件通过一个唯一的描述符来引用,这个描述符是从与打开文件相关的元数据映射到特定文件本身的。在Linux内核内部,这个描述符由一个整数(C类型的int)处理,称为文件描述符,简称fd。文件描述符与用户空间共享,并且用户程序直接使用它们来访问文件。Linux系统编程的很大一部分包括打开、操作、关闭和使用文件描述符。
5.2 普通文件
我们大多数人所称的“文件”在Linux中被称为普通文件。普通文件包含字节数据,这些数据以称为字节流的线性数组形式组织。在Linux中,没有为文件指定进一步的组织或格式化。字节可以取任何值,并且可以在文件内以任何方式组织。在系统层面,Linux不会对文件施加超出字节流的结构。一些操作系统,如VMS,提供高度结构化的文件,支持记录等概念。而Linux则不支持。
文件中的任何字节都可以被读取或写入。这些操作从一个特定的字节开始,这是文件内的“位置”。这个位置称为文件位置或文件偏移量。文件位置是内核与每个打开的文件关联的元数据的重要组成部分。当文件首次打开时,文件位置为零。通常,随着文件中的字节被逐个读取或写入,文件位置会相应增加。文件位置也可以手动设置为某个值,甚至是超出文件末尾的值。将一个字节写入超过文件末尾的位置会导致中间的字节用零填充。虽然可以通过这种方式将字节写入到文件末尾之后的位置,但无法将字节写入到文件起始位置之前。这种做法听起来毫无意义,实际上也很少有用途。文件位置从零开始;它不能是负数。将一个字节写入到文件中间的某个位置会覆盖该偏移量处之前存在的字节。因此,通过写入中间部分来扩展文件是不可能的。大多数文件写入操作发生在文件末尾。文件位置的最大值仅受用于存储它的C类型的大小的约束,在现代Linux中是64位。
文件的大小以字节为单位测量,称为其长度。换句话说,长度只是组成文件的线性数组中的字节数。文件的长度可以通过截断操作来改变。文件可以被截断到小于其原始大小的新大小,这将导致从文件末尾移除字节。令人困惑的是,根据操作的名称,文件也可以被“截断”到大于其原始大小的新大小。在这种情况下,添加到文件末尾的新字节将被填充为零。文件可以是空的(长度为零),因此不包含有效字节。最大文件长度与最大文件位置一样,仅受Linux内核用来管理文件的C类型大小的限制。然而,特定文件系统可能会施加自己的限制,将最大长度降低到一个较小的值。
单个文件可以被多次打开,由不同甚至相同的进程打开。每次打开文件实例都会分配一个唯一的文件描述符;进程可以共享它们的文件描述符,允许多个进程使用同一个描述符。内核不对并发文件访问施加任何限制。多个进程可以同时自由地读写同一文件。这种并发访问的结果依赖于各个操作的顺序,通常是不可预测的。用户空间程序通常必须相互协调,以确保并发文件访问得到充分的同步。
尽管文件通常通过文件名访问,但实际上它们并不直接与这些名称相关联。相反,文件通过inode(最初是信息节点)引用,该inode被赋予一个唯一的数值。这个值称为inode号,通常缩写为i-number或ino。inode存储与文件相关的元数据,例如其修改时间戳、所有者、类型、长度以及文件数据的位置——但没有文件名!inode既是物理对象,位于Unix风格的文件系统中的磁盘上,又是概念实体,由Linux内核中的数据结构表示。
5.3 目录与链接
通过inode号访问文件是繁琐的(也可能存在安全漏洞),因此文件总是通过名称而非inode号从用户空间打开。目录用于提供访问文件的名称。目录充当将人类可读名称映射到inode号的工具。名称和inode对称为链接。这种映射的物理磁盘形式——无论是简单的表、哈希还是其他形式——由支持给定文件系统的内核代码实现和管理。从概念上讲,目录被视为任何普通文件,不同之处在于它只包含名称到inode的映射。内核直接使用此映射执行名称到inode的解析。
当用户空间应用程序请求打开给定文件名时,内核会打开包含该文件名的目录并搜索给定的名称。通过文件名,内核获取inode号。通过inode号,找到inode。Inode包含与文件相关的元数据,包括文件数据的磁盘位置。
最初,磁盘上只有一个目录,即根目录。这个目录通常用路径/表示。但是,众所周知,系统上通常有许多目录。内核如何知道要查找给定文件名应该查看哪个目录?
如前所述,目录很像普通文件。实际上,它们甚至有关联的inode。因此,目录中的链接可以指向其他目录的inode。这意味着目录可以嵌套在其他目录中,形成目录层次结构。这反过来又允许使用所有Unix用户都熟悉的路径名——例如,/home/piot/landscaping.txt。
当内核被要求打开像这样的路径名时,它会遍历路径名中的每个目录条目(在内核内部称为dentry)以找到下一个条目的inode。在前面的例子中,内核从/开始,获取home的inode,前往那里,获取blackbeard的inode,运行到那里,最后获取landscaping.txt的inode。这个操作称为目录或路径名解析。Linux内核还使用一个缓存,称为dentry缓存,来存储目录解析的结果,考虑到时间局部性,以便将来更快地查找。
时间局部性是指对某个特定资源的访问很可能紧接着再次访问同一资源。计算机上的许多资源都表现出时间局部性。
从根目录开始的路径名被称为完全限定路径名,也称为绝对路径名。有些路径名不是完全限定的;相反,它们是相对于其他某个目录提供的(例如,todo/plunder)。这些路径称为相对路径名。当提供相对路径名时,内核会从当前工作目录开始进行路径名解析。从当前工作目录,内核查找目录todo。从那里,内核获取plunder的inode。
尽管目录被视为普通文件,但内核不允许它们像常规文件那样被打开和操作。相反,必须使用一组特殊的系统调用来操作它们。这些系统调用允许添加和删除链接,这也是唯一两种合理的操作。如果用户空间在没有内核介入的情况下被允许操作目录,一个简单的错误就很容易破坏文件系统。
5.4 硬链接(Hard Links)
从概念上讲,到目前为止所讨论的内容并不排除多个名称解析到同一个inode。实际上,这是允许的。当多个链接将不同的名称映射到同一个inode时,我们称之为硬链接。
硬链接允许复杂的文件系统结构,其中多个路径名指向相同的数据。硬链接可以在同一个目录中,也可以在两个或更多不同的目录中。
任何一种情况下,内核只需将路径名解析为正确的inode。例如,一个特定inode指向特定的数据块,可以从/home/piot/map.txt和/home/piot/treasure.txt进行硬链接。
删除文件涉及将其从目录结构中解除链接,这通过简单地从目录中移除其名称和inode对来完成。然而,由于Linux支持硬链接,文件系统不能在每次解除链接操作时销毁inode及其相关数据。如果文件系统中其他地方还存在另一个硬链接怎么办?为了确保文件在所有链接都被移除之前不会被销毁,每个inode包含一个链接计数,用于跟踪文件系统中指向它的链接数量。当路径名被解除链接时,链接计数减一;只有当它达到零时,inode及其相关数据才会实际从文件系统中移除。
5.5 符号链接
符号链接比硬链接带来更多的开销,因为解析一个符号链接实际上涉及解析两个文件:符号链接本身和它所指向的文件。硬链接不会产生这种额外的开销——访问多次链接到文件系统的文件与只链接一次的文件没有区别。符号链接的开销虽然很小,但仍被视为一种负担。
符号链接也不如硬链接透明。使用硬链接完全是透明的;实际上,要发现一个文件被多次链接是需要花费一番努力的!另一方面,操作符号链接需要特殊的系统调用。这种不透明性通常被认为是积极的,符号链接更多地充当快捷方式而不是文件系统内部的链接。
5.6 特殊文件
特殊文件是表现为文件的内核对象。多年来,Unix系统支持几种不同的特殊文件。Linux支持四种:块设备文件、字符设备文件、命名管道和Unix域套接字。特殊文件是一种让某些抽象概念适应文件系统的方式,符合“一切皆是文件”的范式。Linux提供了一个系统调用来创建特殊文件。
在Unix系统中,设备访问通过设备文件进行,这些设备文件在文件系统上看起来和普通文件一样。设备文件可以被打开、读取和写入,从而使用户空间能够访问和操作系统上的设备(包括物理设备和虚拟设备)。Unix设备通常分为两类:字符设备和块设备。每种类型的设备都有自己的特殊设备文件。
字符设备被访问为一个线性字节队列。设备驱动程序将字节一个接一个地放到队列中,用户空间按它们被放入队列的顺序读取字节。键盘是字符设备的一个例子。例如,如果用户输入“peg”,应用程序会希望从键盘设备读取p、e,最后读取g。当没有更多字符可读时,设备返回文件结束符(EOF)。跳过一个字符或以任何其他顺序读取它们都没有意义。字符设备通过字符设备文件访问。
相比之下,块设备被访问为一个字节数组。设备驱动程序将这些字节映射到一个可寻址的设备上,用户空间可以自由地以任何顺序访问数组中的任何有效字节——它可能先读取第12个字节,然后读取第7个字节,再读取第12个字节。块设备通常是存储设备。硬盘、软驱、CD-ROM驱动器和闪存都是块设备的示例。它们通过块设备文件访问。
命名管道(通常称为FIFOs,代表“先进先出”)是一种进程间通信(IPC)机制,它通过一个特殊文件提供一个文件描述符上的通信通道。常规管道是将一个程序的输出“管道传输”到另一个程序的输入的方法;它们是通过系统调用在内存中创建的,不存在于任何文件系统上。命名管道像常规管道一样工作,但通过一个称为FIFO特殊文件的文件访问。无关的进程可以访问此文件并通信。
套接字是最后一种特殊文件。套接字是一种高级形式的IPC,允许两个不同进程之间进行通信,不仅在同一台机器上,还可以在两台不同的机器上。事实上,套接字构成了网络和互联网编程的基础。它们有多种类型,包括Unix域套接字,这是用于本地机器通信的套接字形式。而在互联网上通信的套接字可能会使用主机名和端口对来标识目标通信,Unix域套接字使用位于文件系统上的一个特殊文件,通常简称为套接字文件。
在历史上,Unix系统只有一个共享的命名空间,所有用户和系统上的所有进程都可以看到它。Linux采取了一种创新的方法,支持每个进程的命名空间,允许每个进程选择性地拥有系统文件和目录层次结构的独特视图。默认情况下,每个进程继承其父进程的命名空间,但一个进程可以选择创建自己的命名空间,拥有自己的一组挂载点和唯一的根目录。
6、进程
如果文件是Unix系统中最基本的抽象,那么进程就是次基本的。进程是正在执行的对象代码:活跃的、运行中的程序。但它们不仅仅是对象代码——进程包括数据、资源、状态和一台虚拟化的计算机。
进程以可执行对象代码的形式开始其生命周期,这是一种内核可以理解的可执行格式的机器可运行代码(Linux中最通用的格式是ELF)。可执行格式包含元数据以及多个代码和数据段。段是线性加载到内存中的线性块的对象代码。段中的所有字节都被相同对待,赋予相同的权限,并且通常用于类似的目的。最重要和最常见的段是文本段、数据段和bss段。文本段包含可执行代码和只读数据,如常量变量,通常被标记为只读和可执行。数据段包含已初始化的数据,如定义了值的C变量,通常被标记为可读写。bss段包含未初始化的全局数据。因为C标准规定了C变量默认值基本上是全零,所以没有必要在磁盘上的对象代码中存储这些零。相反,对象代码可以简单地在bss段中列出未初始化的变量,当它被加载到内存中时,内核可以将零页(一个全零的页面)映射到该段上。bss段纯粹是为了这个目的而设计的优化。这个名字是一个历史遗留;它代表由符号开始的块,或者块存储段。ELF可执行文件中的其他常见段包括绝对段(包含不可重定位的符号)和未定义段(一个总括段)。
进程还与各种系统资源相关联,这些资源由内核进行仲裁和管理。进程通常通过系统调用请求和操作资源。资源包括定时器、挂起的信号、打开的文件、网络连接、硬件和IPC机制。进程的资源,连同与进程相关的数据和统计信息,都存储在内核中的过程描述符内。
进程是一种虚拟化抽象。支持抢占式多任务和虚拟内存的Linux内核,为进程提供了一个虚拟化的处理器和一个虚拟化的内存视图。从进程的角度来看,系统的视图就像它独自控制着一切。也就是说,即使给定的进程可能与其他许多进程一起调度,但它运行时就好像它独自控制着系统一样。内核无缝且透明地抢占和重新调度进程,在所有运行中的进程之间共享系统的处理器。进程永远不知道区别。
同样,每个进程都有一个线性地址空间,好像它独自控制着系统中的所有内存一样。通过虚拟内存和分页,内核允许多个进程在系统中共存,每个进程在不同的地址空间中运行。内核通过现代处理器提供的硬件支持来管理这种虚拟化,允许操作系统同时管理多个独立进程的状态。
6.1 线程
每个进程包含一个或多个执行线程(通常简称为线程)。线程是进程中的活动单位,负责执行代码并维护进程的运行状态。
大多数进程只包含一个线程;它们被称为单线程的。包含多个线程的进程被称为多线程的。传统上,Unix程序是单线程的,这归功于Unix历史上的简洁性、快速的进程创建时间和健壮的IPC机制,这些因素都减少了对线程的需求。
一个线程包括一个堆栈(它存储其本地变量,就像非线程化系统中的进程堆栈一样)、处理器状态以及对象代码中的当前位置(通常存储在处理器的指令指针中)。进程的其他大部分部分在所有线程之间共享。
在内部,Linux内核实现了一种独特的线程视图:它们只是恰好共享某些资源(最值得注意的是,地址空间)的普通进程。在用户空间中,Linux根据POSIX 1003.1c(称为pthreads)实现线程。当前Linux线程实现的名称,它是glibc的一部分,是本地POSIX线程库(NPTL)。
6.2 进程层次结构
每个进程都由一个唯一的正整数标识,称为进程ID(pid)。第一个进程的pid是1,随后的每个进程都会接收一个新的、唯一的pid。
在Linux中,进程形成了一个严格的层次结构,称为进程树。进程树以第一个进程为根,这个进程通常被称为init进程,通常是init(8)程序。新进程通过fork()系统调用创建。这个系统调用会创建一个调用进程的副本。原始进程称为父进程;新进程称为子进程。除了第一个进程外,每个进程都有一个父进程。如果父进程在其子进程之前终止,内核会将子进程重新分配给init进程。
当一个进程终止时,它不会立即从系统中移除。相反,内核会在内存中保留进程的部分,以便进程的父进程在终止后可以查询其状态。这被称为等待已终止的进程。一旦父进程等待了其已终止的子进程,子进程就会被完全销毁。已经终止但尚未被等待的进程被称为僵尸进程。Init进程通常会定期等待其所有子进程,确保被重新分配的进程不会永远保持为僵尸状态。
6.3 用户与组
在Linux中,授权是通过用户和组提供的。每个用户都与一个唯一的正整数相关联,称为用户ID(uid)。反过来,每个进程也与一个确切的uid相关联,该uid标识运行该进程的用户,并被称为进程的真实uid。在Linux内核内部,uid是用户的唯一概念。然而,用户自己通过用户名而非数值来指代自己和其他用户。用户名及其对应的uid存储在/etc/passwd中,库例程将用户提供的用户名映射到相应的uid。
登录时,用户向login(1)程序提供用户名和密码。如果提供了有效的用户名和正确的密码,login(1)程序会生成用户的登录shell,这也在/etc/passwd中指定,并将shell的uid设置为用户的uid。子进程继承其父进程的uid。
uid 0与一个特殊用户相关联,称为root。root用户拥有特殊权限,可以在系统上执行几乎所有操作。例如,只有root用户可以更改进程的uid。因此,login(1)程序以root身份运行。
除了真实uid之外,每个进程还有一个有效uid、一个保存的uid和一个文件系统uid。尽管真实uid始终是启动进程的用户的uid,但有效uid可以根据各种规则改变,以允许进程以不同用户的权利执行。保存的uid存储原始的有效uid;其值用于决定用户可以切换到哪些有效uid值。文件系统uid通常等于有效uid,用于验证文件系统访问权限。
每个用户可能属于一个或多个组,包括在/etc/passwd中列出的主要或登录组,以及可能在/etc/group中列出的一些补充组。因此,每个进程也与一个相应的组ID(gid)相关联,并具有真实gid、有效gid、保存的gid和文件系统gid。进程通常与用户的登录组相关联,而不是任何补充组。
某些安全检查允许进程仅在满足特定标准时执行某些操作。历史上,Unix在这方面的决定非常明确:uid为0的进程有访问权限,而其他进程则没有。最近,Linux用更通用的功能系统取代了这种安全机制。功能允许内核基于更细粒度的设置来授予访问权限,而不是简单的二元检查。
6.4 权限
Linux中的标准文件权限和安全机制与历史上的Unix相同。
每个文件都与一个拥有者用户、一个拥有者组以及一组权限位相关联。这些位描述了拥有者用户、拥有者组和其他所有人读取、写入和执行文件的能力;每类有三个位,总共九位。所有者和权限存储在文件的inode中。
对于常规文件,权限相当明显:它们指定了打开文件进行读取、打开文件进行写入或执行文件的能力。特殊文件的读取和写入权限与常规文件相同,尽管具体读取或写入的内容取决于该特殊文件。在特殊文件上忽略执行权限。对于目录,读取权限允许列出目录内容,写入权限允许在目录内添加新链接,执行权限允许进入和使用路径名中的目录。下表列出了每个九个权限位,它们的八进制值(一种流行的表示这九位的方式)、它们的文本值(如ls可能显示的)及其相应含义。
位 | 八进制值 | 文本值 | 对应的权限 |
---|---|---|---|
8 | 400 | r----- | 所有者可读 |
7 | 200 | -w---- | 所有者可写 |
6 | 100 | –x— | 所有者可执行 |
5 | 040 | –r— | 组可读 |
4 | 020 | -w— | 组可写 |
3 | 010 | –x— | 组可执行 |
2 | 004 | -r— | 其他所有人可读 |
1 | 002 | -w— | 其他人可写 |
0 | 001 | -x— | 其他人可执行 |
除了传统的Unix权限外,Linux还支持访问控制列表(ACLs)。ACLs允许进行更详细和精确的权限和安全控制,但代价是增加了复杂性和磁盘存储。
6.5 信号
信号是一种单向异步通知机制。信号可以从内核发送到进程,从一个进程发送到另一个进程,或者从进程发送到自身。通常,信号表示某种事件的发生,例如段错误,或者是用户按下Ctrl-C。
Linux内核实现了大约30种信号(确切数量取决于体系结构)。每种信号由一个数值常量和一个文本名称表示。例如,SIGHUP表示终端挂起已发生,它在i386体系结构上的值为1。
除了SIGKILL(总是终止进程)和SIGSTOP(总是停止进程)之外,进程可以控制接收信号时的行为。它们可以接受默认操作,这可能包括终止进程、终止并转储核心文件、停止进程或什么也不做,具体取决于信号类型。另外,进程可以选择显式忽略或处理信号。被忽略的信号会被静默丢弃。处理的信号会执行用户提供的信号处理函数。程序在收到信号后立即跳转到此函数,并在(当信号处理函数返回时)控制权恢复至之前被中断的指令处。
6.5.1 进程间通信
允许进程交换信息并通知彼此事件是操作系统最重要的任务之一。Linux内核实现了大多数传统的Unix IPC机制——包括由System V和POSIX定义和标准化的机制,以及一两个自己的机制。
Linux支持的IPC机制包括管道、命名管道、信号量、消息队列、共享内存和futexes。
6.5.2 头文件
Linux系统编程主要围绕几个头文件展开。内核本身和glibc提供了用于系统级编程的头文件。这些头文件包括标准C库(例如,<string.h>),以及常见的Unix头文件(例如,<unistd.h>)。
6.5.3 错误处理
不用说,检查和处理错误是至关重要的。在系统编程中,错误通过函数的返回值来表示,并通过一个特殊的变量errno来描述。glibc透明地为库调用和系统调用提供errno支持。
函数通过一个特殊的返回值通知调用者发生了错误,该返回值通常是-1(具体值取决于函数)。错误值向调用者指示发生了错误,但不提供错误发生的原因。errno变量用于找到错误的原因。
该变量在<errno.h>中定义如下:
extern int errno;
errno的值仅在设置errno的函数指示错误后(通常通过返回-1)立即有效,因为在函数成功执行期间修改该变量是合法的。
errno变量可以直接读取或写入;它是一个可修改的左值。errno的值映射到特定错误的文本描述。预处理器#define也映射到数值errno值。例如,预处理器定义EACCESS等于1,并表示“权限被拒绝”。参见下表了解标准定义和匹配的错误描述列表。
预处理定义 | 描述 |
---|---|
E2BIG | 参数列表太长 |
EACCES | 权限被拒绝 |
EAAGAIN | 重试 |
EBADF | 错误的文件编号 |
EBUSY | 设备或资源忙 |
ECHILD | 没有子进程 |
EDOM | 数学参数超出功能域 |
EEXIT | 文件已存在 |
EFAULT | 错误的地址 |
EFBIG | 文件太大 |
EINTR | 系统调用被中断 |
EINVAL | 无效的参数 |
EIO | 输入/输出错误 |
EISDIR | 是一个目录 |
EMFILE | 打开的文件太多 |
EMLINK | 太多链接 |
ENFILE | 文件表溢出 |
ENODEV | 没有这样的设备 |
ENOENT | 没有这样的文件或目录 |
ENOEXEC | 执行格式错误 |
ENOMEM | 内存不足 |
ENOSPC | 设备上没有剩余空间 |
ENOTDIR | 不是目录 |
ENOTTY | 不适当的I/O控制操作 |
ENXIO | 没有这样的设备或地址 |
EPERM | 不允许的操作 |
EPIPE | 破损的管道 |
ERANGE | 结果太大 |
EROFS | 只读文件系统 |
ESISPE | 无效的请求 |
ESRCH | 没有这样的进程 |
ETXTBSY | 文本文件忙 |
EXDEV | 不适当的链接 |
C库提供了一些函数,用于将errno值转换为相应的文本表示。这仅在错误报告等情况下需要;检查和处理错误可以直接使用预处理定义和errno完成。
第一个函数是perror()
#include <stdio.h>
void perror (const char *str);
此函数将当前由errno描述的错误的字符串表示打印到stderr(标准错误),该字符串前缀为str指向的字符串,后跟一个冒号。为了有用,失败的函数名称应包含在字符串中。例如:
if (close (fd) == -1)
perror ("close");
C语言库还提供了strerror()
和strerror_r()
函数:
#include <string.h>
char * strerror (int errnum);
int strerror_r (int errnum, char *buf, size_t len);
前一个函数返回一个指向描述由errnum给出的错误的字符串的指针。该字符串可能不能被应用程序修改,但可以被后续的perror()和strerror()调用修改。以这种方式,它不是线程安全的。
strerror_r()函数是线程安全的。它将长度为len的缓冲区填充到buf指向的位置。对strerror_r()的调用在成功时返回0,在失败时返回-1。幽默的是,它在错误时设置errno。
对于一些函数,整个返回类型的范围都是合法的返回值。在这些情况下,必须在调用之前将errno清零,并在之后进行检查(这些函数承诺仅在实际错误时返回非零的errno)。例如:
errno = 0;
arg = strtoul (buf, NULL, 0);
if (errno)
perror ("strtoul");
在检查errno时的一个常见错误是忘记任何库或系统调用都可以修改它。例如,这段代码是有缺陷的:
if (fsync (fd) == -1) {
fprintf (stderr, "fsync failed!\n");
if (errno == EIO)
fprintf (stderr, "I/O error on %d!\n", fd);
}
如果您需要在函数调用之间保持errno的值,请保存它:
if (fsync (fd) == -1) {
int err = errno;
fprintf (stderr, "fsync failed: %s\n", strerror (errno));
if (err == EIO) {
/* if the error is I/O-related, jump ship */
fprintf (stderr, "I/O error on %d!\n", fd);
exit (EXIT_FAILURE);
}
}
在单线程程序中,如本节前面所示,errno是一个全局变量。然而,在多线程程序中,errno是按线程存储的,因此是线程安全的。