Linux-工作原理第三版-全-
Linux 工作原理第三版(全)
原文:
zh.annas-archive.org/md5/242ce3a69632616d45ca6bfd73adf3b3译者:飞龙
前言
你的系统不应该是一个谜。你应该能够让你的软件按照你的意愿运行,而不需要依靠“魔法”咒语或仪式。获得这种能力的关键在于理解软件的基本原理以及它是如何工作的,这正是本书的核心内容。你永远不需要与电脑“斗争”。
Linux 是一个很好的学习平台,因为它不会对你隐藏任何东西。特别是,你可以在易于阅读的纯文本文件中找到大部分系统配置细节。唯一棘手的部分是弄清楚哪些部分负责什么以及它们是如何组合在一起的。
谁应该阅读本书?
你对学习 Linux 如何运作的兴趣可能来自许多不同的来源。在专业领域,操作和 DevOps 人员需要了解你将在本书中找到的几乎所有内容。Linux 软件架构师和开发人员也应该掌握这些内容,以便充分利用操作系统。研究人员和学生,通常也会自己运行 Linux 系统,会发现本书提供了为什么事物是这样设置的有用解释。
还有一些动手实验者——那些仅仅因为兴趣、盈利或两者兼而喜欢摆弄自己电脑的人。想知道为什么某些东西能工作,而其他的却不能?想知道如果把某个东西移动了会发生什么?你可能就是一个动手实验者。
前提条件
虽然 Linux 深受程序员喜爱,但你不需要是程序员才能阅读本书;你只需要具备基本的计算机用户知识。也就是说,你应该能在图形用户界面(尤其是 Linux 发行版的安装程序和设置界面)中随意操作,知道什么是文件和目录(文件夹)。你还应该准备好查阅你系统上的额外文档和网上的资料。你最需要的,是准备好并愿意尝试操作你的电脑。
如何阅读本书
构建所需的知识是处理任何技术主题的挑战。解释软件系统如何工作可能会变得非常复杂。过多的细节可能会让读者陷入困境,并使得重要概念难以理解(人脑无法同时处理这么多新的信息),而细节过少又可能让读者摸不着头脑,为后续内容做好准备。
我设计了大部分章节,首先解决最重要的内容:你在进阶过程中需要的基本信息。在某些地方,我简化了一些内容,以便集中注意力。随着章节的深入,你会看到更多细节,特别是在最后几节。你是否需要立刻了解这些内容?在大多数情况下,不需要;我会在适当的地方做出说明。如果你面对刚学过的概念时,眼睛开始发蒙,遇到大量额外细节,不妨跳到下一章或者休息一下。那些繁琐的细节仍然会在等着你。
实践方法
无论你选择如何进行这本书的学习,你都应该有一台 Linux 机器在你面前,最好是一台你敢用实验折腾的机器。你也许会更喜欢通过虚拟安装进行操作——我使用了 VirtualBox 来测试本书中的大部分内容。你还应该拥有超级用户(root)权限,但大多数时候尽量使用普通用户帐户。你将主要在命令行界面工作,可以是在终端窗口或远程会话中。如果你在这个环境中没有太多经验,没问题;第二章会让你熟悉起来。
本书中的命令通常如下所示:
$ **ls /**
[some output]
输入粗体文本;后面的非粗体文本是机器返回的内容。$是普通用户帐户的提示符。如果你看到#作为提示符,你需要是超级用户。(第二章会对此做进一步介绍。)
本书的组织结构
我将本书的章节分为三个基本部分。第一部分是入门部分,提供系统的鸟瞰图,然后提供一些你在使用 Linux 时会需要的工具的实践经验。接下来,你将更详细地探索系统的每个部分,从设备管理到网络配置,按照系统启动的一般顺序。最后,你将参观运行中的系统的一些组件,学习一些基本技能,并了解程序员使用的工具。
除了第二章,早期的大部分章节都涉及到 Linux 内核,但随着书本的进展,你会逐步进入用户空间。(如果你不明白我在说什么,别担心;我会在第一章解释。)
本书内容旨在尽可能不依赖于特定发行版。话虽如此,涵盖所有系统软件的变体可能会很繁琐,因此我尝试覆盖两大主要发行版系列:Debian(包括 Ubuntu)和 RHEL/Fedora/CentOS。我还集中讨论了桌面和服务器安装。大量内容也适用于嵌入式系统,如 Android 和 OpenWRT,但你需要自己探索这些平台的差异。
第三版新增了哪些内容?
第二版是在 Linux 系统过渡时期发布的。几个传统组件正在被替换,这使得处理一些主题变得棘手,因为读者可能会遇到各种各样的配置。然而,现在这些新组件(特别是 systemd)几乎得到了普遍采用,因此我能够简化讨论的内容。
我保留了对内核在 Linux 系统中角色的强调。这部分内容已经证明很受欢迎,你可能比你意识到的更频繁地与内核交互。
我增加了一章介绍虚拟化。尽管 Linux 在虚拟机上(例如云服务)一直很受欢迎,但这类虚拟化超出了本书的范围,因为虚拟机上的系统操作方式几乎与“裸机”硬件上的方式相同。因此,这里的讨论主要集中在解读您会遇到的术语。然而,自第二版出版以来,容器的流行度不断上升,它们也适合本书的内容,因为它们基本上由一些像本书其余部分描述的 Linux 特性组成。容器广泛使用 cgroups,这部分在第三版中有了新的处理。
其他我愉快地扩展的主题(不一定与容器相关)包括逻辑卷管理器、journald 日志系统和网络材料中的 IPv6。
尽管我增加了大量内容,但这本书的篇幅仍然适中。我希望提供您快速入门所需的信息,这其中包括解释一些可能难以理解的细节,但我不希望您需要成为举重运动员才能拿起这本书。一旦掌握了书中的重要主题,您应该能够轻松地寻找并理解更多的细节。
第一版包含了历史信息,但为了提高聚焦性,我后来将这些内容删除了。如果您对 Linux 及其与 Unix 历史的关系感兴趣,可以阅读彼得·H·萨卢斯(Peter H. Salus)的《守护进程、GNU 和企鹅》(Reed Media Services, 2008)。它很好地解释了我们使用的软件如何随着时间的推移不断演变。
关于术语的说明
某些操作系统元素的名称历史上引发了相当多的争议——甚至连 Linux 这个词本身也不例外。它应该是“Linux”,还是应该是“GNU/Linux”,以反映操作系统中也包含了来自 GNU 项目的组件?在本书中,我尽量使用最常见、最不别扭的名称。
第一章:整体视角

一开始,像 Linux 这样的现代操作系统看起来非常复杂,成千上万的组件同时运行并进行通信。例如,一个 Web 服务器可以与一个数据库服务器进行通信,后者又可以使用一个多个其他程序都在使用的共享库。这一切是如何顺利工作的呢?你又该如何理解它呢?
理解操作系统如何工作的最有效方式是通过抽象——这是一种高大上的说法,意思是你可以忽略大多数构成你要理解的部分的细节,而专注于它的基本功能和操作。例如,当你乘坐汽车时,通常不需要考虑像固定发动机的安装螺栓或建造和维护道路的人员这些细节。你真正需要知道的是汽车的功能(把你带到别的地方)以及如何使用它的一些基本知识(如何操作车门和安全带)。
如果你只是车上的乘客,这种层次的抽象可能已经足够。但如果你还需要驾驶它,你就必须深入挖掘,并将抽象拆分成几个部分。现在,你的知识扩展到了三个领域:汽车本身(如其大小和功能)、如何操作控制装置(方向盘、油门踏板等)以及道路的特征。
在寻找和修复问题时,抽象化可以是一个很大的帮助。例如,假设你正在开车,车程很颠簸。你可以迅速评估刚才提到的三种基本的与汽车相关的抽象,来确定问题的来源。如果问题不在前两个抽象(你的车或你的驾驶方式),那么你可以迅速排除它们,将问题缩小到路面本身。你可能会发现路面很颠簸。如果你愿意,你可以进一步深入探究路面的抽象,找出为什么路面会破损,或者如果路面是新的,为什么施工人员工作不当。
软件开发者在构建操作系统及其应用程序时,会将抽象作为一种工具。在计算机软件中,有许多用于表示抽象子分区的术语——包括子系统、模块和包——但在本章中我们将使用组件这个术语,因为它比较简单。在构建软件组件时,开发者通常不会过多考虑其他组件的内部结构,但他们会考虑能使用的其他组件(这样他们就不需要编写任何额外的、不必要的软件)以及如何使用它们。
本章提供了 Linux 系统组件的高层次概述。虽然每个组件在其内部构造中有大量的技术细节,但我们将忽略这些细节,专注于这些组件在整个系统中的作用。我们将在后续章节中详细探讨这些细节。
1.1 Linux 系统中的抽象层次与层级
使用抽象将计算系统划分为多个组件有助于理解,但如果没有组织结构是行不通的。我们将组件安排成层或级别,根据组件在用户与硬件之间的位置来分类(或分组)。网页浏览器、游戏等处于最上层;在底层,我们有计算机硬件中的内存——0 和 1。操作系统占据了中间的许多层。
一个 Linux 系统有三个主要层级。图 1-1 展示了这些层级以及每个层级中的一些组件。硬件处于底层。硬件包括内存,以及一个或多个中央处理单元(CPU),用于执行计算并读写内存。磁盘和网络接口等设备也是硬件的一部分。
下一级是内核,它是操作系统的核心。内核是驻留在内存中的软件,告诉 CPU 在哪里查找下一个任务。作为中介,内核管理硬件(特别是主内存),并且是硬件与任何正在运行的程序之间的主要接口。
进程——即内核管理的运行程序——共同构成了系统的上层,称为用户空间。(进程的一个更具体的术语是用户进程,无论用户是否直接与进程交互。例如,所有的网页服务器都作为用户进程运行。)

图 1-1:通用的 Linux 系统组织
内核和用户进程运行方式之间有一个关键的区别:内核运行在内核模式下,而用户进程运行在用户模式下。运行在内核模式下的代码可以无限制地访问处理器和主内存。这是一个强大但危险的特权,允许内核轻易地破坏并使整个系统崩溃。只有内核可以访问的内存区域称为内核空间。
相比之下,用户模式限制了对(通常非常小的)内存子集和安全 CPU 操作的访问。用户空间指的是用户进程可以访问的主内存部分。如果一个进程发生错误并崩溃,其后果是有限的,可以由内核进行清理。这意味着,如果你的网页浏览器崩溃,它可能不会影响后台运行了几天的科学计算。
理论上,一个出现故障的用户进程不会对系统的其他部分造成严重损害。实际上,这取决于你对“严重损害”的定义,以及进程的特定权限,因为某些进程被允许做的事情比其他进程更多。例如,一个用户进程能否完全破坏磁盘上的数据?在正确的权限下,答案是肯定的——而你可能认为这相当危险。然而,也有一些安全机制可以防止这种情况发生,大多数进程通常不被允许以这种方式造成破坏。
1.2 硬件:理解主内存
在所有计算机硬件中,主内存也许是最重要的。从最原始的形式来看,主内存仅仅是一个存储大量 0 和 1 的大区域。每一个 0 或 1 的存储位置被称为比特。这是运行中的内核和进程所在的地方——它们只是大量比特的集合。所有来自外围设备的输入和输出都流经主内存,也是以比特的形式传输。CPU 本质上是内存的操作员;它从内存中读取指令和数据,并将数据写回内存。
你常常会听到状态一词,通常用来描述内存、进程、内核和计算机系统的其他部分。严格来说,状态是比特的一种特定排列。例如,如果你的内存中有四个比特,0110、0001 和 1011 代表三种不同的状态。
当你考虑到一个单独的进程可能由数百万个比特组成时,谈论状态时通常更容易使用抽象的术语。你不再用比特来描述状态,而是描述某物当前正在做的事情。例如,你可能会说,“进程正在等待输入”或,“进程正在执行启动的第二阶段”。
1.3 内核
为什么我们要讨论主内存和状态?几乎所有内核的操作都围绕主内存展开。内核的任务之一是将内存划分为许多子区,并且它必须始终维护关于这些子区的状态信息。每个进程都有自己的一部分内存,内核必须确保每个进程只能使用其分配的内存。
内核负责管理四个主要系统区域的任务:
-
进程 内核负责确定哪些进程被允许使用 CPU。
-
内存 内核需要跟踪所有内存——当前分配给某个进程的内存、可能在进程间共享的内存以及空闲内存。
-
设备驱动程序 内核充当硬件(如磁盘)和进程之间的接口。通常是内核的工作来操作硬件。
-
系统调用和支持进程通常使用系统调用与内核进行通信。
现在我们将简要探讨这些领域。
1.3.1 进程管理
进程管理描述了进程的启动、暂停、恢复、调度和终止。启动和终止进程的概念相对简单,但描述一个进程在其正常操作过程中如何使用 CPU 则稍显复杂。
在任何现代操作系统中,许多进程是“同时”运行的。例如,你可能在桌面电脑上同时打开了一个网页浏览器和一个电子表格。然而,事情并不像它们看起来的那样:这些应用程序背后的进程通常不会在完全相同的时间运行。
假设一个系统有一个单核 CPU。许多进程可能能够使用 CPU,但在任何给定的时间内,只有一个进程可以实际使用 CPU。实际上,每个进程使用 CPU 的时间很短,然后暂停;接着另一个进程使用 CPU 短暂的时间;然后另一个进程轮流使用 CPU,依此类推。一个进程将 CPU 控制权交给另一个进程的行为称为上下文切换。
每一段时间——称为时间片——为进程提供足够的时间进行重要的计算(实际上,进程通常在一个时间片内完成当前任务)。然而,由于时间片非常短,人类无法察觉它们,因此系统似乎在同时运行多个进程(这种能力被称为多任务处理)。
内核负责上下文切换。为了理解这一过程,让我们设想一个场景,其中一个进程正在用户模式下运行,但它的时间片已用完。接下来会发生什么:
-
CPU(实际硬件)根据内部定时器中断当前进程,切换到内核模式,并将控制权交回内核。
-
内核记录了当前 CPU 和内存的状态,这对于恢复刚刚被中断的进程至关重要。
-
内核执行在前一个时间片期间可能发生的任何任务(例如,收集输入输出或 I/O 操作的数据)。
-
内核现在准备让另一个进程运行。内核分析准备运行的进程列表,并选择一个进程。
-
内核为这个新进程准备内存,然后为 CPU 做准备。
-
内核告知 CPU 新进程的时间片将持续多长时间。
-
内核将 CPU 切换到用户模式,并将 CPU 的控制权交给进程。
上下文切换回答了一个重要问题——何时内核运行。答案是,它在上下文切换期间在进程时间片之间运行。
在多 CPU 系统的情况下(如大多数当前机器一样),情况会稍微复杂一些,因为内核不需要放弃对当前 CPU 的控制来允许进程在其他 CPU 上运行,并且可能同时有多个进程在运行。然而,为了最大化所有可用 CPU 的使用,内核通常会执行这些步骤(并可能使用某些技巧为自己争取更多的 CPU 时间)。
1.3.2 内存管理
内核必须在上下文切换期间管理内存,这可能是一个复杂的任务。必须满足以下条件:
-
内核必须在内存中有自己的私有区域,用户进程无法访问。
-
每个用户进程需要自己的一段内存区域。
-
一个用户进程不能访问另一个进程的私有内存。
-
用户进程可以共享内存。
-
一些用户进程的内存是只读的。
-
系统可以通过使用磁盘空间作为辅助内存,来使用比物理内存更多的内存。
幸运的是,内核并非孤立无援。现代 CPU 包含一个内存管理单元(MMU),它启用了一个名为虚拟内存的内存访问方案。使用虚拟内存时,进程不会直接按硬件中的物理位置访问内存。相反,内核为每个进程设置,使其像拥有一台完整的机器一样。当进程访问某些内存时,MMU 会拦截该访问,并使用内存地址映射将进程视角中的内存位置转换为机器中的实际物理内存位置。内核仍然需要初始化并持续维护和修改这个内存地址映射。例如,在上下文切换期间,内核必须将映射从即将退出的进程切换到即将进入的进程。
你将在第八章中学习更多关于如何查看内存性能的内容。
1.3.3 设备驱动与管理
内核在设备管理中的角色相对简单。设备通常只能在内核模式下访问,因为不当访问(例如,用户进程要求关闭电源)可能会导致机器崩溃。一个显著的难点是,不同设备的编程接口通常不同,即使它们执行相同的任务(例如,两个不同的网卡)。因此,设备驱动程序通常是内核的一部分,它们努力向用户进程提供统一的接口,以简化软件开发人员的工作。
1.3.4 系统调用与支持
还有其他几种内核特性可供用户进程使用。例如,系统调用(或 syscalls)执行一些用户进程单独无法或不容易完成的特定任务。例如,打开、读取和写入文件的操作都涉及系统调用。
fork() 和 exec() 两个系统调用对于理解进程如何启动至关重要:
-
fork()当一个进程调用fork()时,内核会创建一个几乎完全相同的进程副本。 -
exec()当一个进程调用exec(``program``)时,内核加载并启动program,替换当前进程。
除了 init(参见第六章),所有新的用户进程在 Linux 系统中都是通过fork()启动的,并且大多数情况下,你还会运行exec()来启动一个新程序,而不是运行一个现有进程的副本。一个非常简单的例子是你在命令行运行的任何程序,比如ls命令,用来显示目录内容。当你在终端窗口中输入ls时,终端窗口中运行的 shell 会调用fork()来创建一个 shell 的副本,然后新副本的 shell 会调用exec(ls)来运行ls。图 1-2 展示了启动像ls这样的程序时,进程和系统调用的流程。

图 1-2:启动一个新进程
内核还通过支持其他特性来支持用户进程,这些特性不同于传统的系统调用,其中最常见的是伪设备。伪设备对于用户进程来说看起来像是设备,但它们完全通过软件实现。这意味着它们在技术上不需要在内核中,但通常出于实际原因,它们还是会在内核中。例如,内核的随机数生成器设备(/dev/random)如果用用户进程来实现,安全性会很难保障。
1.4 用户空间
如前所述,内核为用户进程分配的主内存称为用户空间。因为一个进程只是内存中的一个状态(或镜像),所以用户空间也指的是所有正在运行的进程的内存(你可能会听到更口语化的术语用户域来指代用户空间;有时它也指代在用户空间中运行的程序)。
大多数在 Linux 系统上真正发生的操作都是在用户空间中。尽管从内核的角度来看,所有进程本质上是平等的,但它们为用户执行不同的任务。用户进程所代表的系统组件有一个基本的服务层级结构。图 1-3 展示了在 Linux 系统中,一组示例组件是如何组合在一起并相互交互的。基本服务位于最底层(最接近内核),实用服务位于中间,用户接触到的应用程序则位于顶部。图 1-3 是一个大大简化的示意图,因为只显示了六个组件,但你可以看到,最顶部的组件最接近用户(用户界面和网页浏览器);中间层的组件包括网页浏览器使用的域名缓存服务器;底层则有几个较小的组件。

图 1-3:进程类型与交互
最底层通常由执行单一、简单任务的小组件组成。中间层则包含更大的组件,如邮件、打印和数据库服务。最后,顶层的组件执行复杂的任务,用户通常会直接控制这些任务。组件之间也会相互使用。一般来说,如果一个组件想要使用另一个组件,第二个组件要么在同一服务层级,要么在更低层级。
然而,图 1-3 仅仅是对用户空间排列的近似描述。实际上,用户空间没有固定规则。例如,大多数应用程序和服务会写诊断信息,也就是日志。大多数程序使用标准的 syslog 服务来写日志消息,但也有一些程序更倾向于自己完成所有日志记录。
此外,一些用户空间组件很难分类。例如,像网页和数据库服务器这样的服务器组件可以视为高级应用程序,因为它们的任务通常比较复杂,因此你可能会将它们放在图 1-3 的顶层。然而,用户应用程序可能依赖这些服务器来完成它们自己不想做的任务,因此你也可以将它们放在中间层。
1.5 用户
Linux 内核支持传统的 Unix 用户概念。用户是可以运行进程并拥有文件的实体。用户通常与用户名相关联;例如,系统可以有一个名为billyjoe的用户。然而,内核并不管理用户名;相反,它通过简单的数字标识符来识别用户,这些标识符称为用户 ID。 (你将在第七章了解更多关于用户名与用户 ID 之间的关系。)
用户的存在主要是为了支持权限和边界。每个用户空间进程都有一个用户所有者,进程被认为是以所有者身份运行的。用户可以终止或修改自己的进程行为(在某些限制范围内),但不能干扰其他用户的进程。此外,用户可以拥有文件并决定是否与其他用户共享这些文件。
一个 Linux 系统通常除了与真实用户对应的用户外,还会有多个其他用户。你将在第三章中详细了解这些内容,但最重要的用户是root。root 用户是前述规则的例外,因为 root 可以终止和修改其他用户的进程,并访问本地系统上的任何文件。因此,root 被称为超级用户。可以以 root 身份操作的人——也就是拥有root 访问权限的人——在传统 Unix 系统中是管理员。
组是用户的集合。组的主要目的是允许一个用户与其他组成员共享文件访问权限。
1.6 展望未来
到目前为止,你已经了解了构成运行中Linux 系统的基本内容。用户进程构成了你直接交互的环境;内核管理进程和硬件。内核和进程都驻留在内存中。
这些是很好的背景信息,但仅仅通过阅读你无法了解 Linux 系统的细节;你需要动手实践。下一章将通过教授一些用户空间的基础知识来开启你的旅程。在此过程中,你将学习到本章没有讨论的 Linux 系统的一个重要部分:长期存储(磁盘、文件等)。毕竟,你需要将程序和数据存储在某个地方。
第二章:基本命令和目录层次结构

本章是一本指导你了解在本书中会遇到的 Unix 命令和工具的指南。这是初步材料,你可能已经掌握了其中的相当一部分。即使你认为自己已经掌握,也请花些时间浏览一下这一章,确保理解,尤其是关于目录层次结构的内容(见第 2.19 节)。
为什么要学习 Unix 命令?这不是一本关于 Linux 如何工作的书吗?当然是,但 Linux 本质上是一种 Unix 变种。在本章中,你会看到Unix这个词比Linux出现得更多,因为你学到的内容可以直接应用到 BSD 和其他 Unix 变种系统上。我试图避免讲解过多的 Linux 特有的用户界面扩展,不仅是为了帮助你更好地使用其他操作系统,也是因为这些扩展通常不稳定。如果你掌握了核心命令,你会在面对新的 Linux 版本时更加快速适应。此外,掌握这些命令可以增强你对内核的理解,因为许多命令直接对应于系统调用。
2.1 Bourne Shell:/bin/sh
Shell 是 Unix 系统中最重要的部分之一。Shell是一个运行命令的程序,类似于用户在终端窗口中输入的命令。这些命令可以是其他程序,也可以是 Shell 的内置功能。Shell 还作为一个小型的编程环境。Unix 程序员通常将常见任务分解为更小的组件,并使用 Shell 来管理任务和将它们拼接起来。
系统的许多重要部分实际上是Shell 脚本——包含一系列 Shell 命令的文本文件。如果你以前使用过 MS-DOS,可以把 Shell 脚本看作是非常强大的.BAT文件。因为它们很重要,第十一章将完全讲解 Shell 脚本。
随着你在本书中的学习和实践,你会不断增加使用 Shell 操作命令的知识。Shell 的一个最大优点就是,如果你犯了错误,你可以很容易地看到自己输入的内容,找出出错的原因,然后重新尝试。
Unix 有许多不同的 Shell,但所有 Shell 都从 Bourne Shell(/bin/sh)中继承了特性,Bourne Shell 是由贝尔实验室为早期版本的 Unix 开发的标准 Shell。每个 Unix 系统都需要一个 Bourne Shell 版本才能正常工作,正如你在本书中将看到的那样。
Linux 使用的是增强版的 Bourne Shell,称为bash,即“Bourne-again” Shell。bash是大多数 Linux 发行版的默认 Shell,而在 Linux 系统中,/bin/sh通常是指向bash的链接。在本书中的示例中,你应使用bash Shell。
2.2 使用 Shell
当你安装 Linux 时,应该至少创建一个普通用户作为你的个人账户。在本章中,你应该以普通用户身份登录。
2.2.1 Shell 窗口
登录后,打开一个 shell 窗口(通常称为终端)。从 Gnome 或 KDE 这样的图形界面打开的最简单方法是启动一个终端应用程序,它会在新窗口中启动一个 shell。一旦打开了 shell,它应该在顶部显示一个提示符,通常以美元符号($)结尾。在 Ubuntu 中,提示符应该像name@host:path``$,而在 Fedora 中,则是[name@host path]$,其中name是你的用户名,host是你的计算机名称,path是你当前的工作目录(见 2.4.1 节)。如果你熟悉 Windows,shell 窗口看起来有点像 DOS 命令提示符;在 macOS 中,终端应用程序本质上与 Linux shell 窗口相同。
本书包含了许多你将在 shell 提示符下输入的命令。它们都以单个美元符号$开始,用来表示 shell 提示符。例如,输入以下命令(只输入粗体部分,不包括$)并按回车:
$ **echo Hello there.**
现在输入这个命令:
$ **cat /etc/passwd**
这个命令显示/etc/passwd系统信息文件的内容,然后返回你的 shell 提示符。现在不用担心这个文件的作用;你将在第七章中详细了解它。
命令通常以要运行的程序开始,后面可能跟着参数,这些参数告诉程序要操作的对象以及如何操作。在这里,程序是cat,并且有一个参数/etc/passwd。许多参数是选项,用来修改程序的默认行为,通常以破折号(-)开头。稍后在讨论ls时,你会看到这一点。然而,也有一些不遵循这种正常命令结构的例外情况,比如 shell 内建命令和环境变量的临时使用。
2.2.2 cat
cat程序是 Unix 中最容易理解的程序之一;它只输出一个或多个文件的内容,或者来自其他输入源的数据。cat命令的基本语法如下:
$ **cat** `file1``file2` **...**
当你运行这个命令时,cat会打印file1、file2以及你指定的任何其他文件的内容(在前面的例子中由...表示),然后退出。这个程序叫做cat,因为当它打印多个文件的内容时,它会执行连接操作。运行cat的方法有很多种;让我们用它来探索 Unix 的输入输出(I/O)。
2.2.3 标准输入和标准输出
Unix 进程使用 I/O 流来读取和写入数据。进程从输入流中读取数据,并将数据写入输出流。流是非常灵活的。例如,输入流的来源可以是文件、设备、终端窗口,甚至是另一个进程的输出流。
要查看输入流的工作情况,输入cat(不带任何参数)并按回车。这时你不会立即看到输出,也不会看到 shell 提示符,因为cat仍在运行。现在输入任何内容,并在每行结束时按回车。以这种方式使用时,cat命令会重复你输入的每一行。当你感到足够无聊时,按 ctrl-D 结束空行以终止cat并返回到 shell 提示符。
cat在这里采用交互行为的原因与流有关。当你没有指定输入文件名时,cat从 Linux 内核提供的标准输入流中读取,而不是从连接到文件的流中读取。在这种情况下,标准输入连接到你运行cat的终端。
标准输出类似。内核为每个进程提供一个标准输出流,供其写入输出。cat命令始终将输出写入标准输出。当你在终端运行cat时,标准输出连接到该终端,因此你会在终端看到输出。
标准输入和输出通常缩写为stdin和stdout。许多命令的操作方式与cat相似;如果你没有指定输入文件,命令就会从 stdin 读取。输出稍有不同。一些程序(如cat)仅将输出发送到 stdout,而其他程序则有将输出直接发送到文件的选项。
还有一个第三个标准 I/O 流,称为标准错误。你将在第 2.14.1 节中看到它。
标准流的一个最佳特点是你可以轻松地操作它们,将数据读取和写入到终端以外的地方,正如你将在第 2.14 节中学到的那样。特别是,你将学会如何将流连接到文件和其他进程。
2.3 基本命令
现在让我们看一下更多的 Unix 命令。以下大多数程序都接受多个参数,有些有如此多的选项和格式,以至于列出所有的细节没有意义。这是一个简化的基础命令列表;你现在不需要所有的细节。
2.3.1 ls
ls命令列出目录的内容。默认情况下是当前目录,但你可以添加任何目录或文件作为参数,并且有许多有用的选项。例如,使用ls -l进行详细(长格式)列出,使用ls -F显示文件类型信息。以下是一个示例的长格式列出;它包括文件的所有者(第 3 列)、组(第 4 列)、文件大小(第 5 列)以及修改日期/时间(在第 5 列和文件名之间):
$ **ls -l**
total 3616
-rw-r--r-- 1 juser users 3804 May 28 10:40 abusive.c
-rw-r--r-- 1 juser users 4165 Aug 13 10:01 battery.zip
-rw-r--r-- 1 juser users 131219 Aug 13 10:33 beav_1.40-13.tar.gz
-rw-r--r-- 1 juser users 6255 May 20 14:34 country.c
drwxr-xr-x 2 juser users 4096 Jul 17 20:00 cs335
-rwxr-xr-x 1 juser users 7108 Jun 16 13:05 dhry
-rw-r--r-- 1 juser users 11309 Aug 13 10:26 dhry.c
-rw-r--r-- 1 juser users 56 Jul 9 15:30 doit
drwxr-xr-x 6 juser users 4096 Feb 20 13:51 dw
drwxr-xr-x 3 juser users 4096 Jul 1 16:05 hough-stuff
你将在第 2.17 节中进一步了解此输出的第一列。第二列现在可以忽略;它表示文件的硬链接数,详细信息将在第 4.6 节中解释。
2.3.2 cp
cp命令最简单的形式是复制文件。例如,要将file1复制到file2,可以输入:
$ **cp** `file1``file2`
你还可以将文件复制到另一个目录,并保持该目录中的相同文件名:
$ **cp** `file``dir`
要将多个文件复制到名为dir的目录(文件夹)中,可以尝试如下示例,复制三个文件:
$ **cp `file1` `file2` `file3`**`dir`
2.3.3 mv
mv(移动)命令的工作方式与cp类似。以最简单的形式,它用于重命名文件。例如,要将file1重命名为file2,请输入:
$ **mv** `file1``file2`
你也可以使用mv命令像cp一样将文件移动到其他目录。
2.3.4 touch
touch命令可以创建一个文件。如果目标文件已经存在,touch不会改变文件,但它会更新文件的修改时间戳。例如,要创建一个空文件,请输入:
$ **touch** `file`
然后对那个文件运行ls -l命令。你应该看到类似以下的输出,其中的日期和时间表示你运行touch命令的时间:
$ **ls -l** `file`
-rw-r--r-- 1 juser users 0 May 21 18:32 `file`
要查看时间戳更新,至少等一分钟,然后再次运行相同的touch命令。ls -l返回的时间戳会更新。
2.3.5 rm
rm命令删除(移除)一个文件。删除文件后,它通常会从系统中消失,通常无法恢复,除非你从备份中恢复它。
$ **rm** `file`
2.3.6 echo
echo命令将其参数打印到标准输出:
$ **echo Hello again.**
Hello again.
echo命令对于查找 shell 通配符(如*)和变量(如$HOME)的扩展非常有用,你将在本章稍后遇到它们。
2.4 导航目录
Unix 目录层级从/开始,也称为根目录。目录分隔符是斜杠(/),不是反斜杠(\)。根目录下有几个标准的子目录,如/usr,你将在 2.19 节学习到。
当你引用一个文件或目录时,你需要指定一个路径或路径名。当路径以/(例如/usr/lib)开始时,它是一个完整或绝对路径。
一个由两个点(..)表示的路径组件指定了目录的父目录。例如,如果你在/usr/lib工作,路径..将指向/usr。类似地,../bin将指向/usr/bin。
一个点(.)表示当前目录;例如,如果你在/usr/lib目录中,那么路径.仍然是/usr/lib,而./X11则是/usr/lib/X11。你不需要经常使用.,因为大多数命令会默认使用当前目录,如果路径没有以/开始(因此你可以在前面的例子中直接使用X11而不是./X11)。
以/开头的路径称为相对路径。大多数时候,你会使用相对路径名,因为你通常已经在需要的目录中或靠近该目录。
现在你对基本的目录操作有了了解,下面是一些基本的目录命令。
2.4.1 cd
当前工作目录是一个进程(如 shell)当前所在的目录。除了大多数 Linux 发行版中的默认 shell 提示符,你还可以使用pwd命令查看当前目录,详细信息见 2.5.3 节。
每个进程都可以独立设置自己的当前工作目录。cd命令改变 shell 的当前工作目录:
$ **cd** `dir`
如果省略dir,Shell 会返回到你的主目录,即你第一次登录时所在的目录。一些程序用~符号(波浪线)来缩写你的主目录。
2.4.2 mkdir
mkdir命令创建一个新的目录dir:
$ **mkdir** `dir`
2.4.3 rmdir
rmdir命令删除目录dir:
$ **rmdir** `dir`
如果dir目录不为空,这个命令会失败。然而,如果你急于操作,可能不想先繁琐地删除dir内部的所有文件和子目录。你可以使用rm -r dir来删除一个目录及其内容,但要小心!这是少数几个可能造成严重损害的命令之一,特别是当你以超级用户身份运行它时。-r选项指定递归删除,它会反复删除dir内部的所有内容。不要与像星号(*)这样的通配符一起使用-r标志。最重要的是,运行命令之前务必三思而后行。
2.4.4 Shell 通配符匹配(“通配符”)
Shell 可以将简单模式匹配到文件和目录名称,这个过程叫做通配符匹配。这与其他系统中的通配符概念类似。最简单的通配符是*,它告诉 Shell 匹配任意数量的任意字符。例如,下面的命令会打印当前目录中的文件列表:
$ **echo ***
Shell 会将包含通配符的参数匹配到文件名,并用这些文件名替换相应的参数,然后执行修改后的命令行。这个替换过程叫做扩展,因为 Shell 会用所有匹配的文件名替换简化的表达式。以下是一些使用*来扩展文件名的方法:
-
at*会展开为所有以at开头的文件名。 -
*at会展开为所有以at结尾的文件名。 -
*at*会展开为所有包含at的文件名。
如果没有文件匹配通配符,bash Shell 不会进行扩展,命令将以字面字符(如*.)运行。例如,可以尝试命令echo *dfkdsafh。
另一个 Shell 通配符字符是问号(?),它告诉 Shell 匹配恰好一个任意字符。例如,b?at可以匹配boat和brat。
如果你不希望 Shell 扩展命令中的通配符,可以将通配符用单引号('')括起来。例如,命令echo '*'会打印一个星号。你会发现这对于接下来章节中描述的某些命令(如grep和find)非常有用。(你将在第 11.2 节学到更多关于引用的内容。)
Shell 的模式匹配能力还有更多内容,但*和?是现在需要了解的内容。第 2.7 节描述了包含点(.)的特殊文件的通配符行为。
2.5 中级命令
本节介绍了最基本的中级 Unix 命令。
2.5.1 grep
grep命令会打印出文件或输入流中与某个表达式匹配的行。例如,要打印/etc/passwd文件中包含文本root的行,可以输入以下命令:
$ **grep root /etc/passwd**
grep 命令在同时操作多个文件时非常方便,因为它会在匹配行之外打印文件名。例如,如果你想检查 /etc 中的每个文件是否包含 root 这个词,可以使用以下命令:
$ **grep root /etc/***
grep 的两个最重要的选项是 -i(用于不区分大小写的匹配)和 -v(反转搜索——即打印所有 不 匹配的行)。还有一个更强大的变体叫做 egrep(它只是 grep -E 的别名)。
grep 理解 正则表达式,正则表达式是计算机科学理论中的模式,广泛应用于 Unix 工具中。正则表达式比通配符样式的模式更强大,并且具有不同的语法。关于正则表达式,有三件重要的事需要记住:
-
.*匹配任意数量的字符,包括没有字符(就像通配符中的*)。 -
.+匹配一个或多个任意字符。 -
.匹配任意一个字符。
2.5.2 less
less 命令在文件非常大或命令输出过长并滚动超出屏幕时非常有用。
要逐页查看像 /usr/share/dict/words 这样的大文件,可以使用命令 less /usr/share/dict/words。运行 less 后,你会一次看到文件的一个屏幕内容。按空格键向前翻页,按 b(小写)向后翻页。要退出,按 q。
你也可以在 less 中搜索文本。例如,向前搜索一个词,可以输入 / word,向后搜索可以用 ? word。当找到匹配项时,按 n 继续搜索。
正如你将在 2.14 节中学到的,你可以将几乎任何程序的标准输出直接传递到另一个程序的标准输入。这在你有大量输出需要筛选时特别有用,像 less 这样的工具可以帮助你查看输出。以下是将 grep 命令的输出传递给 less 的示例:
$ **grep ie /usr/share/dict/words | less**
自己试试这个命令。你可能会发现 less 有许多类似的用途。
2.5.3 pwd
pwd(打印工作目录)程序简单地输出当前工作目录的名称。你可能会想,既然大多数 Linux 发行版在提示符中已设置当前工作目录,为什么还需要这个命令呢?这里有两个原因。
首先,并非所有的提示符都包括当前工作目录,尤其是你可能想在自己的提示符中去掉它,因为它会占用大量空间。如果你这样做了,你需要使用 pwd。
其次,你将在 2.17.2 节中学到的符号链接有时会掩盖当前工作目录的真实完整路径。使用 pwd -P 可以消除这种混淆。
2.5.4 diff
要查看两个文本文件之间的差异,可以使用 diff:
$ **diff** `file1``file2`
有几个选项可以控制输出格式,默认的输出格式通常是最容易理解的。然而,大多数程序员在需要将输出发送给别人时更喜欢使用diff -u的输出格式,因为自动化工具在这个格式下处理起来更容易。
2.5.5 file
如果你看到一个文件,并且不确定它的格式,可以尝试使用file命令查看系统是否能够猜测出它的格式:
$ **file** `file`
你可能会惊讶于这个看似无害的命令能做这么多事情。
2.5.6 find 和 locate
当你知道某个文件在某个目录树中但就是不知道在哪里时,使用find命令查找file在dir中的位置,如下所示:
$ **find** `dir` **-name** `file` **-print**
和本节中的大多数程序一样,find也能执行一些复杂的操作。然而,在你完全掌握并理解为什么需要-name和-print选项之前,不要尝试使用像-exec这样的选项。find命令接受特殊的模式匹配字符,例如*,但你必须将它们用单引号('*')括起来,以防止特殊字符被 Shell 的通配符特性所扩展。(回想一下第 2.4.4 节,Shell 会在执行命令之前扩展通配符。)
大多数系统还具有locate命令用于查找文件。locate不会实时搜索文件,而是搜索系统定期建立的索引。使用locate进行搜索比使用find要快得多,但如果你要找的文件比索引更新,locate将找不到它。
2.5.7 head 和 tail
head和tail命令允许你快速查看文件或数据流的一部分。例如,head /etc/passwd显示密码文件的前 10 行,tail /etc/passwd显示密码文件的最后 10 行。
要更改显示的行数,请使用-n选项,其中n是您想要查看的行数(例如,head -5 /etc/passwd)。要从第n行开始打印行,请使用tail +``n。
2.5.8 sort
sort命令可以快速将文本文件的行按字母数字顺序排列。如果文件的行以数字开头,并且你希望按数字顺序排序,请使用-n选项。-r选项会反转排序顺序。
2.6 更改密码和 shell
使用passwd命令更改密码。系统会要求你输入旧密码,然后提示你输入新密码两次。
最佳密码通常是长的“胡言乱语”句子,容易记住。密码越长(字符长度越多),就越好;尝试 16 个字符或更多。(在早期,您可以使用的字符数有限,因此建议添加一些奇怪的字符等。)
你可以使用chsh命令更改你的 Shell(例如更换为zsh、ksh或tcsh),但请记住,本书假设你正在使用bash,所以如果你进行了更改,某些示例可能无法正常工作。
2.7 点文件
如果你不在家目录中,先切换到家目录,输入ls查看内容,然后运行ls -a。你看到输出的区别了吗?当你运行没有-a选项的ls时,你看不到以点文件(dot files)命名的配置文件。这些是以点(.)开头的文件和目录。常见的点文件有.bashrc和.login,也有点目录,例如.ssh。
点文件和点目录没有什么特别之处。有些程序默认不显示它们,这样在列出家目录的内容时你就不会看到一团乱麻。例如,ls默认不列出点文件,除非你使用-a选项。此外,shell 的通配符默认也不会匹配点文件,除非你显式地使用诸如.*这样的模式。
2.8 环境变量与 Shell 变量
Shell 可以存储临时变量,称为shell 变量,它们包含文本字符串的值。Shell 变量在脚本中非常有用,可以用来跟踪值,并且有些 shell 变量控制 shell 的行为。(例如,bash shell 在显示提示符之前会读取PS1变量。)
要给一个 shell 变量赋值,可以使用等号(=)。这是一个简单的示例:
$ **STUFF=blah**
上面的例子将名为STUFF的变量的值设置为blah。要访问这个变量,可以使用$STUFF(例如,尝试运行echo $STUFF)。你将在第十一章了解更多关于 shell 变量的用法。
环境变量就像 shell 变量,但它不是特定于 shell 的。所有 Unix 系统上的进程都有环境变量存储。环境变量和 shell 变量的主要区别在于,操作系统会将你 shell 的所有环境变量传递给 shell 运行的程序,而 shell 变量则无法在你运行的命令中访问。
你可以使用 shell 的export命令来赋值环境变量。例如,如果你想将$STUFF这个 shell 变量变成环境变量,可以使用以下命令:
$ **STUFF=blah**
$ **export STUFF**
因为子进程会继承父进程的环境变量,许多程序会读取这些变量来进行配置和选项设置。例如,你可以把自己喜欢的less命令行选项放在LESS环境变量中,当你运行less时,它会使用这些选项。(许多手册页中有一个名为 ENVIRONMENT 的部分,描述了这些变量。)
2.9 命令路径
PATH是一个特殊的环境变量,包含命令路径(简称路径),它是一个系统目录的列表,当 shell 尝试定位命令时,它会搜索这些目录。例如,当你运行ls时,shell 会在PATH中列出的目录中搜索ls程序。如果路径中的多个目录有相同名称的程序,shell 会运行第一个匹配的程序。
如果你运行echo $PATH,你会看到路径组件通过冒号(:)分隔。例如:
$ **echo $PATH**
/usr/local/bin:/usr/bin:/bin
要让 shell 在更多位置查找程序,可以更改 PATH 环境变量。例如,通过使用此命令,可以将目录 dir 添加到路径的开头,以便 shell 在查找其他 PATH 目录之前,首先查找 dir:
$ **PATH=**`dir`**:$PATH**
或者你可以将目录名附加到 PATH 变量的末尾,使得 shell 最后查找 dir:
$ **PATH=$PATH:**`dir`
2.10 特殊字符
在与他人讨论 Linux 时,你应该了解一些你将遇到的特殊字符的名称。如果你对这些感兴趣,可以参阅“行话文件”(http://www.catb.org/jargon/html/) 或其印刷版,《黑客新字典》第三版,由 Eric S. Raymond 编写(MIT 出版社,1996 年)。
表 2-1 描述了一些常见的特殊字符,你在本章中已经看到了其中很多字符。一些工具,如 Perl 编程语言,几乎使用了所有这些特殊字符!(请记住,这些是字符的美国名称。)
表 2-1:特殊字符
| 字符 | 名称 | 用途 |
|---|---|---|
* |
星号,星形符号 | 正则表达式,通配符 |
. |
点号 | 当前目录,文件/主机名分隔符 |
! |
bang | 否定,命令历史 |
| |
管道符号 | 命令管道 |
/ |
正斜杠 | 目录分隔符,搜索命令 |
\ |
反斜杠 | 字面值,宏(绝不是目录) |
| ` | 字符 | 名称 |
| --- | --- | --- |
* |
星号,星形符号 | 正则表达式,通配符 |
. |
点号 | 当前目录,文件/主机名分隔符 |
! |
bang | 否定,命令历史 |
| |
管道符号 | 命令管道 |
/ |
正斜杠 | 目录分隔符,搜索命令 |
\ |
反斜杠 | 字面值,宏(绝不是目录) |
| 美元符号 | 变量,行尾 | |
' |
反引号,单引号 | 字面字符串 |
` |
反引号 | 命令替换 |
" |
双引号 | 半字面字符串 |
^ |
插入符号 | 否定,行首 |
~ |
波浪符,敲击符 | 否定,目录快捷方式 |
# |
井号,尖锐符号,磅符号 | 注释,预处理器,替换 |
[ ] |
方括号 | 范围 |
{ } |
大括号,圆括号 | 语句块,范围 |
_ |
下划线,底线 | 当不想或不允许使用空格时,或当自动补全算法混淆时,常用作空格的廉价替代 |
2.11 命令行编辑
当你在使用 shell 时,注意到你可以使用左右箭头键编辑命令行,同时也可以使用上下箭头键浏览之前的命令。这在大多数 Linux 系统中是标准配置。
然而,最好忘记箭头键,而改用控制键组合。如果你学会了 表 2-2 中列出的快捷键,你会发现自己能更好地在许多使用这些标准快捷键的 Unix 程序中输入文本。
表 2-2:命令行快捷键
| 快捷键 | 动作 |
|---|---|
| ctrl-B | 将光标向左移动 |
| ctrl-F | 将光标向右移动 |
| ctrl-P | 查看上一条命令(或将光标向上移动) |
| ctrl-N | 查看下一条命令(或将光标向下移动) |
| ctrl-A | 将光标移动到行首 |
| ctrl-E | 将光标移动到行末 |
| ctrl-W | 删除前一个单词 |
| ctrl-U | 从光标处删除到行首 |
| ctrl-K | 从光标处删除到行尾 |
| ctrl-Y | 粘贴已删除的文本(例如,从 ctrl-U) |
2.12 文本编辑器
说到编辑,到了学习编辑器的时候了。要认真对待 Unix,你必须能够编辑文本文件而不损坏它们。系统的许多部分使用纯文本配置文件(如/etc中的文件)。编辑文件并不难,但你会做得如此频繁,以至于需要一个强大的工具来完成这项工作。
你应该尝试学习两种事实上的标准 Unix 文本编辑器之一:vi 和 Emacs。大多数 Unix 大师对于他们选择的编辑器有着宗教般的偏执,但不要听他们的。自己选择一个适合你的工具。如果你选择一个与你的工作方式匹配的编辑器,你会发现学习起来更容易。基本上,选择可以归结为以下几点:
-
如果你想要一个几乎可以做任何事情并且拥有广泛在线帮助的编辑器,而且你不介意额外的打字工作来获得这些功能,可以试试 Emacs。
-
如果速度最重要,试试 vi;它有点像玩视频游戏。
学习 vi 和 Vim 编辑器:Unix 文本处理(第七版),作者:Arnold Robbins、Elbert Hannah 和 Linda Lamb(O’Reilly, 2008),可以告诉你关于 vi 的一切。对于 Emacs,可以使用在线教程:启动 Emacs,按 ctrl-H,然后输入 T,或者阅读 GNU Emacs 手册(第十八版),作者:Richard M. Stallman(自由软件基金会,2018)。
刚开始时,你可能会想尝试一个更友好的编辑器,比如 nano、Pico 或众多图形界面编辑器之一,但如果你倾向于养成使用第一个工具的习惯,你就不想走这条路。
2.13 获取在线帮助
Linux 系统自带大量文档。对于基础命令,手册页(或称*man 页)会告诉你所需的信息。例如,要查看ls命令的手册页,可以运行man命令,如下所示:
$ **man ls**
大多数手册页主要集中在参考信息上,可能会包含一些示例和交叉引用,但就这些。不要指望它是教程,也不要指望它有吸引力的文学风格。
当程序有很多选项时,手册页通常会以某种系统化的方式列出这些选项(例如,按字母顺序),但它不会告诉你哪些选项是重要的。如果你有耐心,通常可以在手册页中找到你需要了解的内容。如果你不耐烦,可以请朋友帮忙——或者付钱请个朋友,这样你就可以向他或她请教了。
要按关键词搜索手册页,可以使用 -k 选项:
$ **man -k** `keyword`
这对于你不太确定自己想要的命令名时很有帮助。例如,如果你在寻找一个排序命令,可以运行:
$ **man -k sort**
--`snip`--
comm (1) - compare two sorted files line by line
qsort (3) - sorts an array
sort (1) - sort lines of text files
sortm (1) - sort messages
tsort (1) - perform topological sort
--`snip`--
输出内容包括手册页的名称、手册章节(见下文)以及对手册页内容的简要描述。
手册页通过编号的章节进行引用。当有人引用手册页时,他们通常会在名称旁边加上章节号,如 ping(8)。表 2-3 列出了章节及其编号。
表 2-3:在线手册章节
| 章节 | 描述 |
|---|---|
| 1 | 用户命令 |
| 2 | 内核系统调用 |
| 3 | 高级 Unix 编程库文档 |
| 4 | 设备接口和驱动程序信息 |
| 5 | 文件描述(系统配置文件) |
| 6 | 游戏 |
| 7 | 文件格式、约定和编码(ASCII、后缀等) |
| 8 | 系统命令和服务器 |
章节 1、5、7 和 8 应该是本书的良好补充。第四章可能用处不大,第六章如果再大一点会更好。如果你不是程序员,可能无法使用第三章,但一旦你阅读了更多关于系统调用的内容,第二章的一些材料你可能能理解。
一些常见术语在多个章节中有许多匹配的手册页。默认情况下,man 会显示它找到的第一个页面。你可以通过章节选择手册页。例如,要阅读 /etc/passwd 文件描述(而不是 passwd 命令),你可以在页面名称前加上章节号,如下所示:
$ **man 5 passwd**
手册页涵盖了基本内容,但还有许多其他方式可以获取在线帮助(除了搜索互联网)。如果你只是想找某个命令的选项,可以尝试输入命令名称后加 --help 或 -h(每个命令的选项不同)。你可能会得到大量的输出(如 ls --help),或者你可能找到你需要的信息。
前些时候,GNU 项目决定不再太喜欢手册页,而转向了一种叫做 info(或 texinfo)的格式。通常这种文档比典型的手册页要深入,但它也可能更复杂。要访问 info 手册,可以使用 info 命令加上命令名:
$ **info** `command`
如果你不喜欢 info 阅读器,可以将输出发送到 less(只需添加 | less)。
一些软件包将它们可用的文档直接转储到 /usr/share/doc 目录,而不考虑像 man 或 info 这样的在线手册系统。如果你发现自己在寻找文档时,可以查看此目录——当然,也可以搜索互联网。
2.14 Shell 输入与输出
现在你已经熟悉了基本的 Unix 命令、文件和目录,你准备好学习如何重定向标准输入和输出了。我们从标准输出开始。
要将 command 的输出发送到文件而不是终端,可以使用 > 重定向字符:
$ `command` **>** `file`
如果文件 file 不存在,shell 会创建它。如果 file 已经存在,shell 会首先删除(覆盖)原始文件。(有些 shell 有防止覆盖的参数。例如,你可以在 bash 中输入 set -C 来避免覆盖。)
你可以使用 >> 重定向语法将输出附加到文件,而不是覆盖文件:
$ `command` **>>** `file`
这是一种在执行相关命令序列时将输出收集到一个地方的便捷方式。
要将一个命令的标准输出发送到另一个命令的标准输入中,使用管道符号(|)。要查看这个是如何工作的,试试下面这两个命令:
$ **head /proc/cpuinfo**
$ **head /proc/cpuinfo | tr a-z A-Z**
你可以将输出通过任意多的管道命令传输;只需在每个附加命令前添加另一个管道符号。
2.14.1 标准错误
偶尔,你可能会重定向标准输出,但发现程序仍然会向终端输出内容。这被称为 标准错误(stderr);它是用于诊断和调试的额外输出流。例如,这个命令会产生一个错误:
$ **ls /fffffffff > f**
完成后,f 应该为空,但你仍然会在终端看到以下作为标准错误的错误信息:
ls: cannot access /fffffffff: No such file or directory
你可以根据需要重定向标准错误。例如,要将标准输出重定向到 f 并将标准错误重定向到 e,可以使用 2> 语法,如下所示:
$ **ls /fffffffff > f 2> e**
数字 2 指定了 shell 修改的 流 ID。流 ID 1 是标准输出(默认),而 2 是标准错误。
你还可以使用 >& 语法将标准错误发送到与标准输出相同的位置。例如,要将标准输出和标准错误都发送到名为 f 的文件中,可以使用以下命令:
$ **ls /fffffffff > f 2>&1**
2.14.2 标准输入重定向
要将文件通道到程序的标准输入中,使用 < 操作符:
$ **head < /proc/cpuinfo**
你偶尔会遇到需要这种重定向的程序,但由于大多数 Unix 命令接受文件名作为参数,这种情况并不常见。例如,上面的命令可以写成 head /proc/cpuinfo。
2.15 理解错误信息
当你在类似 Unix 的系统(如 Linux)上遇到问题时,必须 阅读错误信息。与其他操作系统的消息不同,Unix 错误通常会直接告诉你出了什么问题。
2.15.1 Unix 错误信息的构成
大多数 Unix 程序生成并报告相同的基本错误信息,但不同程序的输出可能会有细微差别。下面是一个你一定会以某种形式遇到的例子:
$ **ls /dsafsda**
ls: cannot access /dsafsda: No such file or directory
这个信息包含三个部分:
-
程序名
ls。有些程序省略这个标识信息,当你写 shell 脚本时,这可能会很烦人,但其实不算大问题。 -
文件名
/dsafsda,这是更具体的信息。这个路径存在问题。 -
错误信息
No such file or directory表示文件名存在问题。
综合来看,你会看到类似“ls 尝试打开 /dsafsda,但由于该文件不存在,无法打开。”这样的信息。这可能看起来显而易见,但当你运行包含错误命令的 shell 脚本时,这些信息可能会有些混淆。
在排查错误时,始终先解决第一个错误。有些程序在报告其他问题之前,会先报告它们无法执行任何操作。例如,假设你运行了一个虚拟的程序 scumd,并且看到以下错误信息:
scumd: cannot access /etc/scumd/config: No such file or directory
紧接着是一长串看起来像灾难的其他错误信息。不要让那些错误分散你的注意力。你可能只需要创建 /etc/scumd/config。
2.15.2 常见错误
你在 Unix 程序中遇到的许多错误都源于文件和进程可能出现的各种问题。其中许多错误直接源于内核系统调用遇到的情况,因此你可以通过查看这些错误,了解内核是如何将问题反馈给进程的。
没有这样的文件或目录
这是最常见的错误。你试图访问一个不存在的文件。由于 Unix 文件 I/O 系统对文件和目录的区分不大,这条错误信息涵盖了这两种情况。当你试图读取一个不存在的文件时,当你试图切换到一个不存在的目录时,当你试图在一个不存在的目录下写入文件时,等等。这条错误也被称为 ENOENT,表示“没有实体错误”。
文件已存在
在这种情况下,你可能试图创建一个已存在的文件。这通常发生在你尝试创建一个与文件同名的目录时。
不是目录,是目录
当你尝试将一个文件当作目录使用,或者将一个目录当作文件使用时,这些信息会弹出。例如:
$ **touch a**
$ **touch a/b**
touch: a/b: Not a directory
注意,错误信息仅适用于 a/b 中的 a 部分。当你遇到这个问题时,你可能需要深入挖掘,找出哪个路径组件被当作目录处理。
设备上没有剩余空间
你已用尽磁盘空间。
权限被拒绝
当你尝试读取或写入一个你没有权限访问的文件或目录时(你没有足够的权限),会出现此错误。如果你尝试执行一个没有设置执行权限的文件(即使你可以读取该文件),也会出现此错误。你将在 2.17 节中了解更多关于权限的信息。
操作不允许
通常发生在你试图结束一个你不拥有的进程时。
段错误,公交错误
段错误 本质上意味着你刚刚运行的程序的作者在某个地方出错了。程序试图访问一个它不允许触及的内存区域,操作系统因此终止了它。类似地,公交错误 表示程序试图以不应有的方式访问某些内存。当你遇到这些错误时,可能是你向程序输入了它没有预料到的数据。少数情况下,这可能是硬件故障导致的内存问题。
2.16 列出和操作进程
回忆一下第一章中,进程是正在运行的程序。系统上的每个进程都有一个数字 进程 ID (PID)。要快速列出正在运行的进程,只需在命令行中运行 ps。你应该会得到如下所示的列表:
$ **ps**
PID TTY STAT TIME COMMAND
520 p0 S 0:00 -bash
545 ? S 3:59 /usr/X11R6/bin/ctwm -W
548 ? S 0:10 xclock -geometry -0-0
2159 pd SW 0:00 /usr/bin/vi lib/addresses
31956 p3 R 0:00 ps
各字段说明如下:
-
PID进程 ID。 -
TTY进程正在运行的终端设备。稍后会详细讲解。 -
STAT进程状态——即进程正在做什么以及其内存的位置。例如,S表示休眠,R表示运行。(有关所有符号的描述,请参见 ps(1) 手册页面。) -
TIME进程已使用的 CPU 时间,以分钟和秒为单位。换句话说,这是进程在处理器上运行指令所消耗的总时间。记住,由于进程不会持续运行,这与进程启动以来的时间(或“挂钟时间”)是不同的。 -
COMMAND这个看起来可能很明显,是用来运行程序的命令,但请注意,进程可以将此字段从原始值更改。此外,shell 可以执行通配符扩展,并且该字段将反映扩展后的命令,而不是你在提示符下输入的命令。
2.16.1 ps 命令选项
ps 命令有许多选项。为了避免混淆,你可以使用三种不同的样式来指定选项——Unix、BSD 和 GNU。许多人发现 BSD 样式最为舒适(也许是因为它需要输入更少的内容),所以本书中将使用这种样式。以下是一些最有用的选项组合:
-
ps x显示所有正在运行的进程。 -
ps ax显示系统上的所有进程,而不仅仅是你拥有的进程。 -
ps u显示关于进程的更详细信息。 -
ps w显示完整的命令名称,而不仅仅是适合一行显示的部分。
与其他程序一样,你可以组合选项,例如 ps aux 和 ps auxw。
要检查特定进程,可以将其 PID 添加到 ps 命令的参数列表中。例如,要检查当前的 shell 进程,可以使用 ps u $$($$ 是一个 shell 变量,它会解析为当前 shell 的 PID)。你可以在第八章找到关于管理命令 top 和 lsof 的信息。这些命令即使在你进行系统维护之外也可以帮助你定位进程。
2.16.2 进程终止
要终止一个进程,你需要通过 kill 命令向它发送一个 信号——来自内核的消息。在大多数情况下,你只需要执行以下操作:
$ **kill** `pid`
有许多类型的信号。默认信号(如上所示)是 TERM,即终止信号。你可以通过向 kill 添加额外选项来发送不同的信号。例如,要冻结一个进程而不是终止它,可以使用 STOP 信号:
$ **kill -STOP** `pid`
一个停止的进程仍然在内存中,准备从停止的位置继续执行。使用 CONT 信号可以继续运行该进程:
$ **kill -CONT** `pid`
内核在收到信号后会给大多数进程一个机会让它们自行清理(通过 信号处理程序 机制)。然而,有些进程可能会选择在响应信号时采取非终止的行动,卡在处理信号的过程中,或者根本忽视信号,因此你可能会发现某个进程在你尝试终止它后仍然在运行。如果发生这种情况,如果你真的需要杀死一个进程,最直接的方法是使用 KILL 信号。与其他信号不同,KILL 无法被忽略;实际上,操作系统甚至不给进程一个机会。内核会直接终止进程并强制将其从内存中移除。只有在最后手段下才使用这种方法。
你不应该随意杀死进程,尤其是当你不知道它们在做什么时。你可能会自作自受。
你可能会看到其他用户在使用 kill 时输入数字而不是名称——例如,输入 kill -9 而不是 kill -KILL。这是因为内核使用数字来表示不同的信号;如果你知道想要发送的信号的数字,可以使用 kill 命令。运行 kill -l 可以查看信号数字与名称的对应关系。
2.16.3 作业控制
Shell 支持 作业控制,这是一种通过使用各种按键和命令向程序发送 TSTP(类似于 STOP)和 CONT 信号的方式。这允许你暂停并在正在使用的程序之间切换。例如,你可以通过 ctrl-Z 发送 TSTP 信号,然后通过输入 fg(将其带到前台)或 bg(将其移到后台;见下一节)重新启动进程。但尽管作业控制具有实用性,并且许多经验丰富的用户习惯使用它,它对初学者来说并不必要,甚至可能会令人困惑:用户常常会按错键,按下 ctrl-Z 而不是 ctrl-C,忘记自己正在运行什么程序,最终导致有大量暂停的进程。
如果你想运行多个程序,可以将每个程序运行在单独的终端窗口中,将非交互式进程放到后台(如下一节所述),并学习使用 screen 和 tmux 工具。
2.16.4 后台进程
通常,当你从 shell 执行 Unix 命令时,直到程序执行完成,你才会得到 shell 提示符。然而,你可以通过使用符号 ampersand (&) 将进程从 shell 中分离并将其置于“后台”;这样可以立即返回提示符。例如,如果你有一个需要用 gunzip 解压的大文件(你将在 2.18 节看到这个),并且希望在运行过程中做其他事情,可以像这样运行命令:
$ **gunzip** `file`**.gz &**
shell 应该通过打印新后台进程的 PID 来响应,提示符应立即返回,以便你可以继续工作。如果进程需要很长时间,它甚至可以在你注销后继续运行,这在运行需要大量计算的程序时特别有用。如果进程在你注销或关闭终端窗口之前完成,shell 通常会根据你的设置通知你。
运行后台进程的黑暗面在于,它们可能期望与标准输入进行交互(或者更糟糕的是,直接从终端读取)。如果一个程序在后台运行时试图从标准输入读取数据,它可能会冻结(可以尝试fg将其恢复)或终止。此外,如果程序向标准输出或标准错误输出写入数据,输出可能会直接出现在终端窗口中,而不会考虑任何其他正在运行的进程,这意味着当你在做其他事情时,可能会出现意外的输出。
确保后台进程不干扰你的最佳方法是重定向它的输出(以及可能的输入),如第 2.14 节所述。
如果后台进程的多余输出妨碍了你的工作,学习如何重新绘制终端窗口的内容。bash shell 和大多数全屏交互程序都支持 ctrl-L 来重新绘制整个屏幕。如果程序正在从标准输入读取数据,ctrl-R 通常会重新绘制当前行,但在错误的时机按下错误的组合键可能会让你陷入比之前更糟的情况。例如,在bash提示符下输入 ctrl-R 会进入反向搜索模式(按 esc 退出)。
2.17 文件模式和权限
每个 Unix 文件都有一组权限,决定你是否可以读取、写入或执行该文件。运行ls -l可以显示权限。以下是此类显示的示例:
-rw-r--r--1 1 juser somegroup 7041 Mar 26 19:34 endnotes.html
文件的模式表示文件的权限和一些附加信息。模式有四个部分,如图 2-1 所示。

图 2-1:文件模式的组成部分
模式的第一个字符是文件类型。如示例中所示,该位置的破折号(-)表示一个常规文件,这意味着文件没有特别之处,它仅包含二进制或文本数据。这是最常见的文件类型。目录也是常见的,它们通过文件类型位置的d来表示。(第 3.1 节列出了其他文件类型。)
文件模式的其余部分包含权限,分为三组:用户、组和其他,按此顺序。例如,示例中的rw-字符是用户权限,接下来的r--字符是组权限,最后的r--字符是其他权限。
每个权限集可以包含四种基本表示形式:
-
r表示该文件是可读的。 -
w表示该文件是可写的。 -
x表示该文件是可执行的(您可以将其作为程序运行)。 -
-表示“无”(更具体地说,该位置的权限尚未授予)。
用户权限(第一组)与拥有文件的用户相关。在前面的示例中,用户是juser。第二组是组权限,适用于文件的组(示例中的somegroup)。该组中的任何用户都可以使用这些权限。(使用groups命令查看您所在的组,并参阅第 7.3.5 节了解更多信息。)
系统中的其他所有人根据第三组权限——其他权限,通常称为全体权限——进行访问。
一些可执行文件在用户权限列表中显示s而不是x。这表示该可执行文件是setuid,意味着当您执行程序时,它将以文件所有者的身份运行,而不是以您自己的身份。许多程序使用 setuid 位以 root 身份运行,以便获得更改系统文件所需的权限。一个例子是passwd程序,它需要更改/etc/passwd文件。
2.17.1 修改权限
要更改文件或目录的权限,请使用chmod命令。首先,选择您要更改的权限集,然后选择要更改的位。例如,要为file添加组(g)和其他(o)用户的读(r)权限,您可以执行以下两个命令:
$ **chmod g+r** `file`
$ **chmod o+r** `file`
或者您也可以一次性完成所有操作:
$ **chmod go+r** `file`
要移除这些权限,使用go-r代替go+r。
有时您会看到人们使用数字来更改权限,例如:
$ **chmod 644** `file`
这称为绝对更改,因为它一次性设置所有的权限位。要理解这如何工作,您需要知道如何以八进制形式表示权限位(每个数字表示一个 8 进制数,范围从 0 到 7,并对应一个权限集)。更多内容请参阅 chmod(1)手册页或 info 手册。
如果您更喜欢使用绝对模式,实际上并不需要知道如何构建它们;只需记住您最常用的模式。表 2-4 列出了最常见的几种。
表 2-4:绝对权限模式
| 模式 | 含义 | 用途 |
|---|---|---|
644 |
用户:读/写;组,其他:读 | 文件 |
600 |
用户:读/写;组,其他:无 | 文件 |
755 |
用户:读/写/执行;组,其他:读/执行 | 目录,程序 |
700 |
用户:读/写/执行;组,其他:无 | 目录,程序 |
711 |
用户:读/写/执行;组,其他:执行 | 目录 |
目录也有权限。如果目录可读,您可以列出其内容,但只有当目录是可执行的,您才能访问目录中的文件。在大多数情况下,您需要同时具备这两项;设置目录权限时,人们常犯的一个错误是使用绝对模式时不小心去掉了执行权限。
最后,你可以使用umask shell 命令来指定一组默认权限,它会将一组预定义的权限应用于你创建的任何新文件。一般来说,如果你希望所有人都能查看你创建的所有文件和目录,请使用umask 022,如果你不希望这样,则使用umask 077。如果你希望使你的所需权限掩码适用于新窗口和后续会话,你需要将umask命令与所需的模式放入启动文件中,正如第十三章所讨论的那样。
2.17.2 使用符号链接
符号链接是一个指向另一个文件或目录的文件,有效地创建了一个别名(类似于 Windows 中的快捷方式)。符号链接提供了对隐蔽目录路径的快速访问。
在长目录列表中,符号链接的显示如下(请注意文件模式中的l,它表示文件类型):
lrwxrwxrwx 1 ruser users 11 Feb 27 13:52 somedir -> /home/origdir
如果你尝试访问此目录中的somedir,系统会给你返回/home/origdir。符号链接只是指向其他名称的文件名。它们的名称和指向的路径不需要有任何实际意义。在前面的例子中,/home/origdir不需要存在。
事实上,如果/home/origdir不存在,任何访问somedir的程序都会返回一个错误,报告somedir不存在(除了ls somedir,这个命令愚蠢地告诉你somedir就是somedir)。这可能会让人困惑,因为你眼前就能看到一个名为somedir的东西。
这并不是符号链接可能令人困惑的唯一方式。另一个问题是,你不能仅凭链接的名称就识别链接目标的特征;你必须跟踪该链接以查看它是指向文件还是目录。你的系统中可能还有指向其他链接的链接,这些被称为链式符号链接,在你试图追踪它们时可能会很麻烦。
要从target创建一个指向linkname的符号链接,请使用ln -s,如下所示:
$ **ln -s `target` `linkname`**
linkname参数是符号链接的名称,target参数是链接指向的文件或目录的路径,-s标志指定这是一个符号链接(请参阅后面的警告)。
创建符号链接时,请在运行命令之前检查两遍,因为可能会出现几种错误情况。例如,如果你不小心反转了参数的顺序(ln -s linkname target),如果linkname是一个已经存在的目录,你将面临一些麻烦。如果是这种情况(而且这种情况非常常见),ln会在linkname目录中创建一个名为target的链接,而该链接将指向它自己,除非linkname是一个完整路径。如果创建符号链接到目录时出现问题,请检查该目录是否有错误的符号链接并将其删除。
当你不知道符号链接存在时,它们也会引起头疼。例如,你可能会不小心编辑一个你认为是文件副本的文件,但它实际上是指向原始文件的符号链接。
在了解了关于符号链接的所有警告之后,你可能会好奇为什么有人会想使用它们。事实证明,它们的缺陷远远被它们在文件组织方面提供的强大功能以及轻松解决小问题的能力所弥补。一个常见的使用场景是,当程序期望在你的系统中某个地方找到特定的文件或目录时,你不想复制它,如果无法更改程序,你可以直接创建一个符号链接,将它指向实际的文件或目录位置。
2.18 文件归档与压缩
现在你已经了解了文件、权限和可能的错误,接下来需要掌握gzip和tar这两个常用工具,用于压缩和打包文件与目录。
2.18.1 gzip
程序gzip(GNU Zip)是当前标准的 Unix 压缩程序之一。以.gz结尾的文件是 GNU Zip 归档文件。使用gunzip file``.gz来解压gzip file。
2.18.2 tar
与其他操作系统的 ZIP 程序不同,gzip并不会创建文件归档;也就是说,它不会将多个文件和目录打包成一个文件。要创建归档,请改用tar:
$ **tar cvf** `archive`**.tar** `file1``file2` **...**
tar创建的归档文件通常以.tar为后缀(这是惯例,并非强制要求)。例如,在之前的命令中,file1、file2等是你希望归档的文件和目录的名称,存储为c标志启用创建模式。v和f标志有更具体的作用。
v标志启用详细诊断输出,当tar遇到文件和目录时,会打印它们的名称。再加一个v,tar会打印如文件大小和权限等详细信息。如果不想让tar告诉你它正在做什么,可以省略v标志。
f标志表示文件选项。在f标志后面的命令行参数必须是tar将创建的归档文件(在之前的示例中,它是-)。
解包.tar 文件
要使用tar解包.tar文件,请使用x标志:
$ **tar xvf** `archive`**.tar**
在此命令中,x标志将tar设置为提取(解包)模式。你可以通过在命令行末尾输入归档部分的名称来提取单独的部分,但你必须知道它们的确切名称。(要确认名称,请参阅接下来介绍的目录列表模式。)
使用目录列表模式
在解包之前,通常最好使用目录内容模式来检查.tar文件的内容,通过使用t标志而不是x标志。此模式验证归档的基本完整性,并列出归档中所有文件的名称。如果你在解包前没有测试归档,可能会把大量文件乱七八糟地解压到当前目录中,这样清理起来会非常困难。
当你使用t模式检查归档时,确保所有内容都位于合理的目录结构中;也就是说,归档中的所有文件路径名应该都以相同的目录开头。如果不确定,可以创建一个临时目录,切换到该目录,然后再进行解压。(如果归档没有搞乱文件结构,你可以随时使用mv * ..来移动文件。)
解包时,可以考虑使用p选项来保留权限。在提取模式下使用此选项,以覆盖你的umask,并获得归档中指定的精确权限。对于超级用户来说,p选项是默认选项。如果你在以超级用户身份解包归档时遇到权限和所有权问题,确保等到命令执行完毕并返回到命令行提示符。尽管你可能只想提取归档的一小部分,但tar必须运行整个归档过程,你不能中断它,因为它只有在检查完整个归档后才会设置权限。
将本节中所有的tar选项和模式都记住。如果你有困难,可以制作一些记忆卡片。听起来可能像是小学的做法,但这对避免在使用此命令时犯粗心错误非常重要。
2.18.3 压缩归档(.tar.gz)
许多初学者发现压缩归档文件(以.tar.gz结尾)很容易混淆。要解压一个压缩归档,从右到左操作;先去掉.gz,然后再处理.tar。例如,这两个命令解压和解包
$ **gunzip** `file`**.tar.gz**
$ **tar xvf** `file`**.tar**
在开始时,可以一步步地进行,先运行gunzip来解压,然后再运行tar来验证和解包。要创建压缩归档文件,只需反过来做:先运行tar,再运行gzip。经常这样做,你很快就会记住归档和压缩的过程是如何工作的。但即便你不常这样做,也能体会到所有输入的命令会变得多么繁琐,你会开始寻找快捷方式。让我们现在来看一下这些方法。
2.18.4 zcat
刚才展示的方法不是调用tar解压压缩归档的最快或最高效的方式,它浪费了磁盘空间和内核 I/O 时间。一种更好的方法是将归档和压缩功能结合在一个管道中。例如,这个命令管道解压
$ **zcat** `file`**.tar.gz | tar xvf -**
zcat命令与gunzip -dc相同。-d选项用于解压,-c选项将结果发送到标准输出(在此情况下为tar命令)。
由于使用zcat非常常见,Linux 中随附的tar版本提供了一个快捷方式。你可以使用z作为选项,自动在归档文件上调用gzip;这适用于提取归档(使用x或t模式的tar)和创建归档(使用c模式)。例如,使用以下命令来验证一个压缩的归档:
$ **tar ztvf** `file`**.tar.gz**
然而,请记住,当你使用快捷方式时,实际上是在执行两个步骤。
2.18.5 其他压缩工具
另外两个压缩程序是xz和bzip2,它们的压缩文件分别以.xz和.bz2结尾。虽然它们比gzip稍慢,但通常能更紧凑地压缩文本文件。解压缩程序是unxz和bunzip2,它们的选项与gzip相似,所以你不需要学习新的命令。
大多数 Linux 发行版都自带与 Windows 系统上的 ZIP 压缩文件兼容的zip和unzip程序。它们可以处理常见的.zip文件,以及以.exe结尾的自解压档案。但如果遇到以.Z结尾的文件,那是由曾经是 Unix 标准的compress程序创建的遗物。gunzip程序可以解压这些文件,但gzip不会创建它们。
2.19 Linux 目录层次结构要点
现在你已经知道如何检查文件、切换目录和阅读手册页,你可以开始探索系统文件和目录了。Linux 目录结构的详细信息在文件系统层次标准(Filesystem Hierarchy Standard,简称 FHS)中有说明,网址为https://refspecs.linuxfoundation.org/fhs.shtml,但现在简要的介绍就足够了。
图 2-2 提供了简化的层次结构概览,展示了/、/usr和/var下的一些目录。注意,/usr下的目录结构包含了一些与/相同的目录名。

图 2-2:Linux 目录层次结构
这是根目录下最重要的子目录:
-
/bin 包含准备运行的程序(也称为可执行文件),包括大多数基本的 Unix 命令,如
ls和cp。/bin中的大多数程序都是二进制格式的,由 C 编译器创建,但在现代系统中,也有一些是 shell 脚本。 -
/dev 包含设备文件。你将在第三章学习更多关于这些文件的内容。
-
/etc 该核心系统配置目录(发音为EHT-see)包含用户密码、启动、设备、网络和其他设置文件。
-
/home 包含普通用户的个人目录。大多数 Unix 安装都遵循这个标准。
-
/lib library的缩写,此目录存放包含可执行程序可使用代码的库文件。有两种类型的库:静态库和共享库。/lib目录应仅包含共享库,但其他 lib 目录(如/usr/lib)包含这两种库以及其他辅助文件。(我们将在第十五章详细讨论共享库。)
-
/proc 通过可浏览的目录和文件接口提供系统统计信息。Linux 上许多/proc子目录的结构是独特的,但许多其他 Unix 变种也具有类似的功能。/proc目录包含有关当前正在运行的进程以及一些内核参数的信息。
-
/run 包含特定于系统的运行时数据,包括某些进程 ID、套接字文件、状态记录,以及在许多情况下,系统日志。这是根目录的一个相对较新的添加项;在较老的系统中,你可以在/var/run找到它。在新系统中,/var/run是指向/run的符号链接。
-
/sys 此目录与/proc类似,提供设备和系统接口。你将在第三章中了解更多关于/sys的内容。
-
/sbin 系统可执行文件所在的地方。/sbin目录中的程序与系统管理相关,因此普通用户通常不会在其命令路径中包含/sbin组件。这里找到的许多实用程序如果不是以 root 身份运行,则无法工作。
-
/tmp 一个用于存储较小临时文件的区域,这些文件你不太在意。任何用户都可以读写/tmp,但用户可能没有权限访问其他用户在那里存放的文件。许多程序将此目录用作工作空间。如果某些东西非常重要,不要将其放在/tmp中,因为大多数发行版会在机器启动时清空/tmp,有些甚至会定期删除其中的旧文件。此外,不要让/tmp堆满垃圾文件,因为它的空间通常与某些关键部分共享(例如根目录的其余部分)。
-
/usr 尽管发音为“user”,但这个子目录并不包含用户文件。相反,它包含一个庞大的目录层次结构,包括 Linux 系统的大部分内容。/usr中的许多目录名称与根目录中的相同(如/usr/bin和/usr/lib),并且它们包含相同类型的文件。(根目录不包含完整系统的原因主要是历史上的考虑——过去这样做是为了降低根目录的空间需求。)
-
/var 变量子目录,用于记录可能随时间变化的程序信息。系统日志、用户跟踪、缓存以及其他系统程序创建和管理的文件都在这里。(你会注意到这里有一个/var/tmp目录,但系统在启动时不会清空它。)
2.19.1 其他根子目录
根目录下还有一些其他有趣的子目录:
-
/boot 包含内核启动加载程序文件。这些文件仅与 Linux 启动过程的第一阶段有关,因此你不会在此目录中找到有关 Linux 如何启动其服务的信息。关于此内容,请参见第五章。
-
/media 一个用于可移动媒体(如闪存驱动器)的基础挂载点,许多发行版中都有此目录。
-
/opt 此目录可能包含额外的第三方软件。许多系统不使用/opt。
2.19.2 /usr 目录
/usr目录乍一看可能比较干净,但快速查看/usr/bin和/usr/lib,就会发现这里有很多内容;/usr是大多数用户空间程序和数据所在的地方。除了/usr/bin、/usr/sbin和/usr/lib之外,/usr还包含以下内容:
-
/include 包含 C 编译器使用的头文件。
-
/local 管理员可以在此安装自己的软件。其结构应类似于/和/usr。
-
/man 包含手册页。
-
/share 包含应在其他类型的 Unix 机器上无功能损失地运行的文件。这些通常是程序和库根据需要读取的辅助数据文件。过去,机器网络会从文件服务器共享这个目录,但如今在现代系统上,使用这种方式的share目录已经很少见,因为这些类型的文件在现代系统上没有实际的空间限制。相反,在 Linux 发行版中,你会在此目录下找到/man、/info和许多其他子目录,因为这是一个易于理解的约定。
2.19.3 内核位置
在 Linux 系统中,内核通常是一个二进制文件/vmlinuz或/boot/vmlinuz。一个启动加载程序在系统启动时将这个文件加载到内存并启动它。(你将在第五章中找到关于启动加载程序的详细信息。)
一旦启动加载程序启动了内核,主内核文件将不再被正在运行的系统使用。然而,你会发现许多内核模块,它们在正常系统操作过程中按需加载和卸载。被称为可加载内核模块,它们位于/lib/modules下。
2.20 以超级用户身份运行命令
在继续之前,你应该学会如何以超级用户身份运行命令。你可能会想要启动一个 root shell,但这样做有许多缺点:
-
你没有系统更改命令的记录。
-
你没有记录执行系统更改命令的用户信息。
-
你无法访问你的正常 shell 环境。
-
你需要输入根密码(如果你有的话)。
2.20.1 sudo
大多数发行版使用一个名为sudo的程序,允许管理员在以自己身份登录时以 root 身份执行命令。例如,在第七章中,你将学习如何使用vipw编辑/etc/passwd文件。你可以这样做:
$ **sudo vipw**
当你运行此命令时,sudo会通过 syslog 服务在 local2 设施下记录此操作。你还将在第七章中了解更多关于系统日志的内容。
2.20.2 /etc/sudoers
当然,系统不会让任何用户都可以以超级用户身份执行命令;你必须在你的/etc/sudoers文件中配置特权用户。sudo包有许多选项(你可能永远不会使用),这使得/etc/sudoers中的语法有些复杂。例如,这个文件授予user1和user2执行任何命令作为 root 的权限,而不需要输入密码:
User_Alias ADMINS = `user1`, `user2`
ADMINS ALL = NOPASSWD: ALL
root ALL=(ALL) ALL
第一行定义了一个包含两个用户的ADMINS用户别名,第二行授予了权限。ALL = NOPASSWD: ALL部分意味着ADMINS别名中的用户可以使用sudo以 root 身份执行命令。第二个ALL意味着“任何命令”。第一个ALL意味着“任何主机”。(如果你有多台机器,可以为每台机器或机器组设置不同的访问权限,但我们在这里不会讲解这个功能。)
root ALL=(ALL) ALL的意思是超级用户也可以使用sudo在任何主机上运行任何命令。额外的(ALL)意味着超级用户也可以作为其他用户执行命令。你可以通过将(ALL)添加到第二行的/etc/sudoers中来将这个权限扩展给ADMINS用户,如下所示:
ADMINS ALL = (ALL) NOPASSWD: ALL
2.20.3 sudo 日志
尽管我们会在本书后面更详细地讲解日志,但你可以通过以下命令在大多数系统上找到sudo日志:
$ **journalctl SYSLOG_IDENTIFIER=sudo**
在旧系统上,你需要在/var/log中查找日志文件,如/var/log/auth.log。
这就是目前关于sudo的内容。如果你需要使用它的更高级功能,请参阅 sudoers(5)和 sudo(8)手册页。(用户切换的实际机制将在第七章中介绍。)
2.21 展望未来
现在,你应该已经知道如何在命令行中执行以下操作:运行程序、重定向输出、与文件和目录交互、查看进程列表、查看手册页,并且大致了解如何在 Linux 系统的用户空间中操作。你也应该能够以超级用户身份运行命令。你可能还不了解用户空间组件的内部细节或内核中的操作,但掌握了文件和进程的基础,你已经在正确的道路上了。在接下来的几章中,你将使用刚刚学到的命令行工具,处理内核和用户空间系统组件。
第三章:设备

本章是对在一个正常运行的 Linux 系统中内核提供的设备基础设施的基本介绍。Linux 的历史中,内核向用户展示设备的方式经历了许多变化。我们将从传统的设备文件系统入手,看看内核如何通过 sysfs 提供设备配置信息。我们的目标是能够提取系统中设备的信息,以理解一些基本的操作。后续章节将更详细地介绍与特定类型设备的交互。
理解内核在遇到新设备时如何与用户空间交互非常重要。udev 系统使得用户空间程序能够自动配置并使用新设备。你将看到内核如何通过 udev 向用户空间进程发送消息,以及该进程如何处理这些消息。
3.1 设备文件
在 Unix 系统中操作大多数设备非常容易,因为内核将许多设备 I/O 接口作为文件呈现给用户进程。这些 设备文件 有时也叫 设备节点。除了程序员使用常规文件操作与设备交互外,一些设备也可以被标准程序如 cat 访问,所以你不必是程序员就可以使用设备。然而,通过文件接口能做的操作是有限的,因此并不是所有设备或设备功能都可以通过标准的文件 I/O 进行访问。
Linux 使用与其他 Unix 系统一样的设备文件设计。设备文件位于 /dev 目录中,运行 ls /dev 会显示 /dev 中的很多文件。那么,如何与设备交互呢?
要开始,可以考虑以下命令:
$ **echo blah blah > /dev/null**
像其他重定向输出的命令一样,这会将标准输出的一些内容发送到一个文件中。然而,该文件是 /dev/null,一个设备,因此内核绕过其常规文件操作,并使用设备驱动程序处理写入该设备的数据。在 /dev/null 的情况下,内核简单地接受输入数据并将其丢弃。
要识别一个设备并查看其权限,可以使用 ls -l。以下是一些示例:
$ **ls -l**
brw-rw---- 1 root disk 8, 1 Sep 6 08:37 sda1
crw-rw-rw- 1 root root 1, 3 Sep 6 08:37 null
prw-r--r-- 1 root root 0 Mar 3 19:17 fdata
srw-rw-rw- 1 root root 0 Dec 18 07:43 log
请注意每行的第一个字符(文件模式的第一个字符)。如果这个字符是 b、c、p 或 s,那么该文件是一个设备。它们分别代表 块设备、字符设备、管道 和 套接字:
块设备
- 程序从块设备中按固定块读取数据。前面的例子中的 sda1 是一个 磁盘设备,是一种块设备。磁盘可以很容易地分割成数据块。由于块设备的总大小是固定的且易于索引,进程可以借助内核快速随机访问设备中的任何一个块。
字符设备
- 字符设备与数据流一起工作。你只能从字符设备读取字符,或者将字符写入字符设备,正如之前在/dev/null中演示的那样。字符设备没有大小;当你从字符设备读取或写入时,内核通常会对其执行读取或写入操作。直接连接到计算机的打印机由字符设备表示。需要注意的是,在字符设备交互过程中,内核无法在将数据传递给设备或进程之后备份并重新检查数据流。
管道设备
- 命名管道就像字符设备,其 I/O 流的另一端是另一个进程,而不是内核驱动程序。
套接字设备
- 套接字是特殊用途的接口,通常用于进程间通信。它们经常出现在/dev目录之外。套接字文件表示 Unix 域套接字;你将在第十章中了解更多关于它们的信息。
在ls -l命令列出的块设备和字符设备的文件列表中,日期前面的数字是内核用来标识设备的主设备号和次设备号。类似的设备通常具有相同的主设备号,例如sda3和sdb1(它们都是硬盘分区)。
3.2 sysfs 设备路径
传统的 Unix /dev目录是用户进程引用和与内核支持的设备接口的便捷方式,但它也是一个非常简化的方案。/dev中的设备名称能告诉你一些关于设备的信息,但通常不足以帮助你。另一个问题是,内核按照设备找到的顺序分配设备,因此设备在重新启动后可能会有不同的名称。
为了根据附加设备的实际硬件属性提供统一的视图,Linux 内核通过一个文件和目录系统提供sysfs接口。设备的基本路径是/sys/devices。例如,/dev/sda上的 SATA 硬盘在 sysfs 中可能具有以下路径:
/sys/devices/pci0000:00/0000:00:17.0/ata3/host0/target0:0:0/0:0:0:0/block/sda
如你所见,这条路径与/dev/sda文件名相比相当长,/dev/sda也是一个目录。但你不能真正比较这两条路径,因为它们有不同的用途。/dev文件使得用户进程可以使用设备,而/sys/devices路径用于查看信息和管理设备。如果你列出一个设备路径的内容,比如前面的路径,你会看到类似以下的内容:
alignment_offset discard_alignment holders removable size uevent
bdi events inflight ro slaves
capability events_async power sda1 stat
dev events_poll_msecs queue sda2 subsystem
device ext_range range sda5 trace
这里的文件和子目录主要是供程序读取的,而非人类,但你可以通过查看例如/dev文件来大致了解它们包含和表示的内容。在这个目录中运行cat dev命令会显示数字8:0,它恰好是/dev/sda的主设备号和次设备号。
在 /sys 目录中有一些快捷方式。例如,/sys/block 应该包含系统上所有的块设备。然而,这些只是符号链接;你可以运行 ls -l /sys/block 来显示真实的 sysfs 路径。
在 /dev 中找到设备的 sysfs 位置可能很困难。使用 udevadm 命令如下所示,展示路径及其他几个有趣的属性:
$ **udevadm info --query=all --name=/dev/sda**
你可以在 3.5 节中找到关于 udevadm 和整个 udev 系统的更多细节。
3.3 dd 和设备
程序 dd 在处理块设备和字符设备时非常有用。它的唯一功能是从输入文件或流中读取数据并写入输出文件或流中,可能会在过程中进行一些编码转换。关于块设备,dd 的一个特别有用的功能是,你可以处理文件中间的一块数据,而忽略前后部分。
dd 以固定大小的块复制数据。以下是如何在字符设备上使用 dd,并使用一些常见选项:
$ **dd if=/dev/zero of=new_file bs=1024 count=1**
如你所见,dd 选项的格式与大多数其他 Unix 命令的选项格式不同;它基于旧版 IBM 作业控制语言(JCL)样式。不是使用破折号(-)字符表示选项,而是通过名称指定选项,并用等号(=)设置其值。前面的示例将一个 1,024 字节的块从 /dev/zero(一个连续的零字节流)复制到 new_file。
这些是重要的 dd 选项:
-
if=``file输入文件。默认是标准输入。 -
of=``file输出文件。默认是标准输出。 -
bs=``size块大小。dd每次读取和写入这么多字节的数据。为了简化大块数据,你可以使用b和k来分别表示 512 字节和 1,024 字节。因此,前面的示例可以写作bs=1k,而不是bs=1024。 -
ibs=``size,obs=``size输入和输出块大小。如果输入和输出使用相同的块大小,使用bs选项;如果不同,分别使用ibs和obs来指定输入和输出的块大小。 -
count=``num要复制的块总数。当处理一个巨大文件或一个提供无穷数据流的设备时(例如 /dev/zero),你希望dd在一个固定位置停止;否则,你可能会浪费大量磁盘空间、CPU 时间,或两者。使用count配合skip参数来从大文件或设备中复制一小块数据。 -
skip=``num跳过输入文件或流中的前num块,并且不将它们复制到输出。
3.4 设备名称总结
有时,找到设备的名称可能会很困难(例如,在对磁盘进行分区时)。以下是几种方法来查找设备名称:
-
使用
udevadm查询 udevd(参见 3.5 节)。 -
在 /sys 目录中查找设备。
-
从
journalctl -k命令的输出(它打印内核消息)或内核系统日志(参见第 7.1 节)中猜测设备名称。这个输出可能包含系统上设备的描述。 -
对于系统中已可见的磁盘设备,你可以查看
mount命令的输出。 -
运行
cat /proc/devices查看系统当前为哪些块设备和字符设备提供驱动。每一行由一个数字和一个名称组成。这个数字是设备的主设备号,如第 3.1 节所描述的。如果你能从名称猜到设备,可以在 /dev 中查找具有相应主设备号的字符设备或块设备,这样你就找到了设备文件。
在这些方法中,只有第一种方法是可靠的,但它确实需要 udev。如果你遇到 udev 不可用的情况,可以尝试其他方法,但请记住,内核可能没有为你的硬件提供设备文件。
以下章节列出了最常见的 Linux 设备及其命名规范。
3.4.1 硬盘:/dev/sd*
目前大多数连接到 Linux 系统的硬盘对应的设备名称带有 sd 前缀,例如 /dev/sda、/dev/sdb 等。这些设备代表整个硬盘;内核会为硬盘上的每个分区创建单独的设备文件,如 /dev/sda1 和 /dev/sda2。
命名规范需要一些解释。名称中的 sd 部分代表 SCSI 硬盘。小型计算机系统接口(SCSI) 最初被开发为硬件和协议标准,用于在硬盘和其他外设之间进行通信。尽管大多数现代机器不再使用传统的 SCSI 硬件,但由于其适应性,SCSI 协议无处不在。例如,USB 存储设备就使用它进行通信。关于 SATA(串行 ATA,一种常见的 PC 存储总线)硬盘的情况稍微复杂一些,但 Linux 内核在与其通信时仍会使用 SCSI 命令。
要列出系统上的 SCSI 设备,可以使用一个遍历 sysfs 提供的设备路径的工具。最简洁的工具之一是 lsscsi。运行它时你可以期待看到以下输出:
$ **lsscsi**
[0:0:0:0]1 disk2 ATA WDC WD3200AAJS-2 01.0 /dev/sda3
[2:0:0:0] disk FLASH Drive UT_USB20 0.00 /dev/sdb
第一列 1 标识了设备在系统中的地址,第二列 2 描述了它是什么类型的设备,最后一列 3 指示了在哪里可以找到设备文件。其他内容是厂商信息。
Linux 按照驱动程序遇到设备的顺序将设备分配给设备文件。因此,在之前的例子中,内核首先找到了硬盘,然后是闪存驱动器。
不幸的是,这种设备分配方案通常在重新配置硬件时会引发问题。举个例子,假设你有一个系统,其中有三个磁盘:/dev/sda,/dev/sdb 和 /dev/sdc。如果 /dev/sdb 硬盘损坏并且你必须将其移除以使机器恢复正常,那么原本的 /dev/sdc 会变成 /dev/sdb,此时不再有 /dev/sdc。如果你直接在 fstab 文件中引用了设备名称(参见第 4.2.8 节),你需要对该文件进行一些修改才能让系统恢复(基本)正常。为了解决这个问题,许多 Linux 系统使用了通用唯一标识符(UUID;参见第 4.2.4 节)和/或逻辑卷管理器(LVM)稳定的磁盘设备映射。
这次讨论仅仅触及了如何在 Linux 系统上使用磁盘和其他存储设备的皮毛。有关如何使用磁盘的更多信息,请参阅第四章。在本章稍后,我们将研究 SCSI 支持如何在 Linux 内核中工作。
3.4.2 虚拟磁盘:/dev/xvd,/dev/vd
一些磁盘设备针对虚拟机进行了优化,例如 AWS 实例和 VirtualBox。Xen 虚拟化系统使用 /dev/xvd 前缀,/dev/vd 是类似的类型。
3.4.3 非易失性存储设备:/dev/nvme*
一些系统现在使用非易失性存储器快车(NVMe)接口来访问某些类型的固态存储。在 Linux 中,这些设备显示为 */dev/nvme**。你可以使用 nvme list 命令来列出系统中的这些设备。
3.4.4 设备映射器:/dev/dm-,/dev/mapper/
在某些系统中,磁盘和其他直接块存储之上的一层是 LVM,它使用一种名为设备映射器(device mapper)的内核系统。如果你看到以 /dev/dm- 开头的块设备和在 /dev/mapper 中的符号链接,那么你的系统可能在使用它。你将在第四章中学习到更多关于这个的内容。
3.4.5 光盘和 DVD 驱动器:/dev/sr*
Linux 将大多数光学存储驱动器识别为 SCSI 设备 /dev/sr0,/dev/sr1 等。然而,如果驱动器使用的是较旧的接口,它可能会显示为 PATA 设备,下面会进一步讨论。*/dev/sr** 设备是只读的,仅用于从光盘读取数据。对于光学设备的写入和重写功能,你将使用“通用”SCSI 设备,如 /dev/sg0。
3.4.6 PATA 硬盘:/dev/hd*
PATA(并行 ATA)是较旧的存储总线类型。Linux 块设备 /dev/hda,/dev/hdb,/dev/hdc 和 /dev/hdd 在较旧版本的 Linux 内核和老旧硬件上较为常见。这些是基于接口 0 和 1 上的设备对的固定分配。有时,你可能会发现一个 SATA 硬盘被识别为其中之一。这意味着 SATA 硬盘正处于兼容模式,这会影响性能。检查你的 BIOS 设置,看看是否可以将 SATA 控制器切换到本地模式。
3.4.7 终端:/dev/tty,/dev/pts/ 和 /dev/tty
终端是用于在用户进程和输入输出设备之间传输字符的设备,通常用于将文本输出到终端屏幕。终端设备接口可以追溯到很久以前,当时终端是基于打字机的设备,许多终端连接到一台机器。
大多数终端是伪终端设备,即模拟终端,能够理解真实终端的输入输出功能。与其直接与硬件交互,内核将输入输出接口提供给一个软件,例如你通常输入大部分命令的 shell 终端窗口。
两种常见的终端设备是/dev/tty1(第一个虚拟控制台)和/dev/pts/0(第一个伪终端设备)。/dev/pts目录本身是一个专用的文件系统。
/dev/tty设备是当前进程的控制终端。如果一个程序当前正在读取和写入终端,这个设备就等同于该终端。一个进程不需要附加到终端。
显示模式和虚拟控制台
Linux 有两种主要的显示模式:文本模式和图形模式(第十四章介绍了使用此模式的窗口系统)。尽管 Linux 系统传统上在文本模式下启动,但现在大多数发行版使用内核参数和临时的图形显示机制(如 plymouth 引导画面)在系统启动时完全隐藏文本模式。在这种情况下,系统会在启动过程接近结束时切换到完全的图形模式。
Linux 支持虚拟控制台来多路复用显示。每个虚拟控制台可以运行图形模式或文本模式。在文本模式下,你可以通过 alt–功能键组合在控制台之间切换——例如,alt-F1 进入/dev/tty1,alt-F2 进入/dev/tty2,依此类推。许多虚拟控制台可能被getty进程占用,运行登录提示,如第 7.4 节所述。
在图形模式下使用的虚拟控制台略有不同。图形环境不会从 init 配置中获取虚拟控制台分配,而是会接管一个空闲的虚拟控制台,除非指定使用特定的控制台。例如,如果你在tty1和tty2上运行getty进程,一个新的图形环境会接管tty3。此外,一旦进入图形模式,通常需要按 ctrl-alt–功能键组合才能切换到另一个虚拟控制台,而不是简单的 alt–功能键组合。
所有这些的结果是,如果你想在系统启动后看到你的文本控制台,按下 ctrl-alt-F1。要返回图形环境,按下 alt-F2、alt-F3,以此类推,直到进入图形环境。
如果由于输入机制故障或其他原因在切换控制台时遇到问题,你可以尝试使用chvt命令强制系统切换控制台。例如,要切换到tty1,以 root 身份运行以下命令:
# chvt 1
3.4.8 串行端口:/dev/ttyS、/dev/ttyUSB、/dev/ttyACM*
较旧的 RS-232 类型和类似的串行端口被表示为真正的终端设备。你不能在命令行上对串行端口设备做太多操作,因为有太多设置需要关注,例如波特率和流控,但你可以使用 screen 命令通过添加设备路径作为参数来连接到终端。你可能需要设备的读写权限;有时,你可以通过将自己添加到特定的组(例如 dialout)来实现这一点。
在 Windows 上被称为 COM1 的端口是 /dev/ttyS0;COM2 是 /dev/ttyS1;依此类推。插入的 USB 串行适配器会显示为 USB 和 ACM,其名称分别为 /dev/ttyUSB0、/dev/ttyACM0、/dev/ttyUSB1、/dev/ttyACM1,以此类推。
涉及串行端口的一些最有趣的应用是基于微控制器的开发板,你可以将它们插入到 Linux 系统中进行开发和测试。例如,你可以通过 USB 串行接口访问 CircuitPython 开发板的控制台和读-评估-打印循环。你只需插入一个设备,查找该设备(通常是 /dev/ttyACM0),然后使用 screen 连接到它。
3.4.9 并行端口:/dev/lp0 和 /dev/lp1
代表一种已经被 USB 和网络取代的接口类型,单向并行端口设备 /dev/lp0 和 /dev/lp1 在 Windows 中对应 LPT1: 和 LPT2:。你可以直接使用 cat 命令将文件(例如要打印的文件)发送到并行端口,但你可能需要在之后给打印机一个额外的换页符或重置。像 CUPS 这样的打印服务器更擅长处理与打印机的交互。
双向并行端口是 /dev/parport0 和 /dev/parport1。
3.4.10 音频设备:/dev/snd/*、/dev/dsp、/dev/audio 等
Linux 有两套音频设备。一套是为高级 Linux 声音架构(ALSA)系统接口提供的设备,另一套是为较旧的开放声音系统(OSS)提供的设备。ALSA 设备位于 /dev/snd 目录中,但直接操作它们是比较困难的。使用 ALSA 的 Linux 系统如果当前加载了 OSS 内核支持,则可以支持 OSS 向后兼容的设备。
使用 OSS 的 dsp 和 audio 设备可以进行一些基本操作。例如,计算机可以播放你发送到 /dev/dsp 的任何 WAV 文件。然而,由于频率不匹配,硬件可能无法按照预期工作。此外,在大多数系统上,设备通常会在你登录时变得繁忙。
3.4.11 设备文件创建
在任何相对较新的 Linux 系统上,你不需要自己创建设备文件;它们是由 devtmpfs 和 udev 创建的(参见第 3.5 节)。然而,了解如何进行操作是很有帮助的,偶尔你可能需要创建命名管道或套接字文件。
mknod 命令用于创建一个设备。你必须知道设备名称以及其主次设备号。例如,创建 /dev/sda1 就是使用以下命令:
# mknod /dev/sda1 b 8 1
b 8 1 表示一个主设备号为 8、次设备号为 1 的块设备。对于字符设备或命名管道设备,请使用 c 或 p 替代 b(命名管道省略主设备号和次设备号)。
在早期版本的 Unix 和 Linux 中,维护 /dev 目录是一个挑战。每次进行重大内核升级或添加驱动程序时,内核可能支持更多种类的设备,这意味着需要为设备文件名分配新的主设备号和次设备号。为了解决这个维护难题,每个系统都会在 /dev 中有一个 MAKEDEV 程序来创建设备组。当你升级系统时,会尝试找到 MAKEDEV 的更新版本,然后运行它来创建新设备。
这个静态系统变得笨重,因此需要进行替换。修复它的第一个尝试是 devfs,这是 /dev 的内核空间实现,包含了当前内核支持的所有设备。然而,它存在一些限制,这导致了 udev 和 devtmpfs 的开发。
3.5 udev
我们已经讨论过内核中不必要的复杂性是危险的,因为它很容易引发系统不稳定。设备文件管理就是一个例子:你可以在用户空间中创建设备文件,那为什么还要在内核中做这件事呢?当系统检测到新设备(例如,当有人插入 USB 闪存驱动器)时,Linux 内核可以向一个名为 udevd 的用户空间进程发送通知。这个 udevd 进程可以检查新设备的特性,创建设备文件,并执行任何设备初始化。
这就是理论上的方法。不幸的是,这种方法有一个问题——设备文件在启动过程中非常必要,因此 udevd 也必须尽早启动。但是,为了创建设备文件,udevd 不能依赖于它需要创建的任何设备,并且它需要非常快速地完成初始化启动,以便系统的其他部分不会因为等待 udevd 启动而被阻塞。
3.5.1 devtmpfs
devtmpfs 文件系统是在启动过程中为了解决设备可用性问题而开发的(有关文件系统的更多细节,请参见第 4.2 节)。该文件系统类似于较旧的 devfs 支持,但进行了简化。内核根据需要创建设备文件,但它还会通知 udevd 有新设备可用。接收到这个信号后,udevd 不会创建设备文件,但会执行设备初始化,并设置权限,同时通知其他进程新设备已经可用。此外,它还会在 /dev 目录下创建一些符号链接,以进一步标识设备。你可以在目录 /dev/disk/by-id 中找到示例,其中每个附加的磁盘都有一个或多个条目。
例如,考虑一个典型磁盘(附加在 /dev/sda)及其分区在 /dev/disk/by-id 中的链接:
$ **ls -l /dev/disk/by-id**
lrwxrwxrwx 1 root root 9 Jul 26 10:23 scsi-SATA_WDC_WD3200AAJS-_WD-WMAV2FU80671 -> ../../sda
lrwxrwxrwx 1 root root 10 Jul 26 10:23 scsi-SATA_WDC_WD3200AAJS-_WD-WMAV2FU80671-part1 ->
../../sda1
lrwxrwxrwx 1 root root 10 Jul 26 10:23 scsi-SATA_WDC_WD3200AAJS-_WD-WMAV2FU80671-part2 ->
../../sda2
lrwxrwxrwx 1 root root 10 Jul 26 10:23 scsi-SATA_WDC_WD3200AAJS-_WD-WMAV2FU80671-part5 ->
../../sda5
udevd 进程按接口类型命名链接,然后是制造商和型号信息、序列号,以及分区(如果适用)。
但 udevd 如何知道创建哪些符号链接,并且它是如何创建这些符号链接的呢?下一节将描述 udevd 如何完成其工作。然而,你不需要了解这些内容或本章中剩余的其他内容来继续阅读本书。实际上,如果这是你第一次接触 Linux 设备,强烈建议你跳到下一章开始学习如何使用磁盘。
3.5.2 udevd 操作和配置
udevd 守护进程的操作如下:
-
内核通过一个内部网络链接发送 udevd 一个通知事件,称为 uevent。
-
udevd 加载 uevent 中的所有属性。
-
udevd 解析其规则,基于这些规则过滤并更新 uevent,并根据需要执行操作或设置更多属性。
从内核接收到的 incoming uevent 可能看起来像这样(你将在 3.5.4 节学习如何通过 udevadm monitor --property 命令获得这个输出):
ACTION=change
DEVNAME=sde
DEVPATH=/devices/pci0000:00/0000:00:1a.0/usb1/1-1/1-1.2/1-1.2:1.0/host4/
target4:0:0/4:0:0:3/block/sde
DEVTYPE=disk
DISK_MEDIA_CHANGE=1
MAJOR=8
MINOR=64
SEQNUM=2752
SUBSYSTEM=block
UDEV_LOG=3
这个特定事件是设备的变化。在接收到 uevent 后,udevd 知道设备的名称、sysfs 设备路径以及与属性相关的其他多个属性;它现在准备开始处理规则。
规则文件位于 * /lib/udev/rules.d* 和 * /etc/udev/rules.d* 目录中。* /lib* 中的规则是默认规则,* /etc* 中的规则是覆盖规则。对于规则的详细解释会很繁琐,你可以从 udev(7) 手册页中了解更多,但这里是关于 udevd 如何读取规则的一些基本信息:
-
udevd 从规则文件的开始到结束读取规则。
-
在读取规则并可能执行其操作后,udevd 继续读取当前规则文件以寻找更多适用的规则。
-
有一些指令(如
GOTO)如果需要,可以跳过规则文件的某些部分。这些通常放在规则文件的顶部,用来跳过整个文件,如果该文件与 udevd 正在配置的特定设备无关。
让我们看一下 3.5.1 节中 * /dev/sda* 示例的符号链接。这些链接是由 * /lib/udev/rules.d/60-persistent-storage.rules* 中的规则定义的。里面,你会看到以下几行:
# ATA
KERNEL=="sd*[!0-9]|sr*", ENV{ID_SERIAL}!="?*", SUBSYSTEMS=="scsi", ATTRS{vendor}=="ATA", IMPORT{program}="ata_id --export $devnode"
# ATAPI devices (SPC-3 or later)
KERNEL=="sd*[!0-9]|sr*", ENV{ID_SERIAL}!="?*", SUBSYSTEMS=="scsi", ATTRS{type}=="5",ATTRS{scsi_level}=="[6-9]*", IMPORT{program}="ata_id --export $devnode"
这些规则匹配通过内核的 SCSI 子系统(参见 3.6 节)呈现的 ATA 磁盘和光盘介质。你可以看到有一些规则捕获设备可能以不同方式表示的情况,但基本思想是 udevd 会尝试匹配以 sd 或 sr 开头但没有数字(使用 KERNEL=="sd*[!0-9]|sr*" 表达式)的设备,以及一个子系统(SUBSYSTEMS=="scsi"),最后是一些其他属性,取决于设备的类型。如果这些条件表达式在任何规则中都为真,udevd 会进入下一个和最终的表达式:
IMPORT{program}="ata_id --export $tempnode"
这不是一个条件,而是一个指令,用来从/lib/udev/ata_id命令导入变量。如果你有这样的磁盘,可以在命令行上试一试。它会像这样显示:
# /lib/udev/ata_id --export /dev/sda
ID_ATA=1
ID_TYPE=disk
ID_BUS=ata
ID_MODEL=WDC_WD3200AAJS-22L7A0
ID_MODEL_ENC=WDC\x20WD3200AAJS22L7A0\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20
\x20\x20\x20\x20\x20\x20\x20\x20\x20
ID_REVISION=01.03E10
ID_SERIAL=WDC_WD3200AAJS-22L7A0_WD-WMAV2FU80671
--`snip`--
导入操作现在设置了环境,使得输出中所有变量名称的值都被设置为所示的值。例如,接下来的任何规则都会将ENV{ID_TYPE}识别为disk。
在我们看到的两个规则中,特别需要注意的是ID_SERIAL。在每个规则中,这个条件出现在第二位:
ENV{ID_SERIAL}!="?*"
如果ID_SERIAL未设置,则此表达式会评估为 true。因此,如果ID_SERIAL 已 设置,则条件为 false,当前规则不适用,udevd 会移动到下一个规则。
为什么这里会有这个?这两个规则的目的是运行ata_id来查找磁盘设备的序列号,然后将这些属性添加到当前工作中的 uevent 副本中。你会在许多 udev 规则中发现这种常见模式。
设置了ENV{ID_SERIAL}后,udevd 现在可以在规则文件中的后续规则中评估这个规则,这个规则会查找任何附加的 SCSI 磁盘:
KERNEL=="sd*|sr*|cciss*", ENV{DEVTYPE}=="disk", ENV{ID_SERIAL}=="?*",SYMLINK+="disk/by-id/$env{ID_BUS}-$env{ID_SERIAL}"
你可以看到这个规则要求设置ENV{ID_SERIAL},并且它有一个指令:
SYMLINK+="disk/by-id/$env{ID_BUS}-$env{ID_SERIAL}"
这个指令告诉 udevd 为传入的设备添加符号链接。所以现在你知道设备符号链接的来源了!
你可能会想知道如何区分条件表达式和指令。条件表达式由两个等号(==)或一个叹号等号(!=)表示,指令由一个等号(=)、加等号(+=)或冒号等号(:=)表示。
3.5.3 udevadm
udevadm程序是 udevd 的管理工具。你可以重新加载 udevd 规则并触发事件,但udevadm最强大的功能可能是能够搜索和探索系统设备,以及能够监控 uevent,查看 udevd 如何从内核接收它们。不过,命令语法可能有点复杂。大多数选项有长格式和短格式;我们这里使用的是长格式。
让我们从检查一个系统设备开始。回到第 3.5.2 节中的示例,为了查看与设备如/dev/sda相关联的规则使用和生成的所有 udev 属性,请运行以下命令:
`$` **udevadm info --query=all --name=/dev/sda**
输出如下所示:
P: /devices/pci0000:00/0000:00:1f.2/host0/target0:0:0/0:0:0:0/block/sda
N: sda
S: disk/by-id/ata-WDC_WD3200AAJS-22L7A0_WD-WMAV2FU80671
S: disk/by-id/scsi-SATA_WDC_WD3200AAJS-_WD-WMAV2FU80671
S: disk/by-id/wwn-0x50014ee057faef84
S: disk/by-path/pci-0000:00:1f.2-scsi-0:0:0:0
E: DEVLINKS=/dev/disk/by-id/ata-WDC_WD3200AAJS-22L7A0_WD-WMAV2FU80671 /dev/disk/by-id/scsi
-SATA_WDC_WD3200AAJS-_WD-WMAV2FU80671 /dev/disk/by-id/wwn-0x50014ee057faef84 /dev/disk/by
-path/pci-0000:00:1f.2-scsi-0:0:0:0
E: DEVNAME=/dev/sda
E: DEVPATH=/devices/pci0000:00/0000:00:1f.2/host0/target0:0:0/0:0:0:0/block/sda
E: DEVTYPE=disk
E: ID_ATA=1
E: ID_ATA_DOWNLOAD_MICROCODE=1
E: ID_ATA_FEATURE_SET_AAM=1
--`snip`--
每行前缀表示设备的某个属性或特征。在这个例子中,顶部的P:是 sysfs 设备路径,N:是设备节点(即分配给/dev文件的名称),S:表示 udevd 根据其规则放置在/dev中的设备节点符号链接,E:是 udevd 规则中提取的其他设备信息。(这个例子中的输出比实际需要展示的更多;你可以自己尝试一下这个命令,感受一下它的作用。)
3.5.4 设备监控
要使用udevadm监控 uevent,可以使用monitor命令:
$ **udevadm monitor**
输出(例如,当你插入闪存设备时)如下所示的简短示例:
KERNEL[658299.569485] add /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2 (usb)
KERNEL[658299.569667] add /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2/2-1.2:1.0 (usb)
KERNEL[658299.570614] add /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2/2-1.2:1.0/host15
(scsi)
KERNEL[658299.570645] add /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2/2-1.2:1.0/
host15/scsi_host/host15 (scsi_host)
UDEV [658299.622579] add /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2 (usb)
UDEV [658299.623014] add /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2/2-1.2:1.0 (usb)
UDEV [658299.623673] add /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2/2-1.2:1.0/host15
(scsi)
UDEV [658299.623690] add /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2/2-1.2:1.0/
host15/scsi_host/host15 (scsi_host)
--`snip`--
该输出中有每条消息的两份副本,因为默认行为是同时打印来自内核的传入消息(标记为KERNEL)和 udevd 的处理消息。要仅查看内核事件,添加--kernel选项;要仅查看 udevd 处理事件,使用--udev选项。要查看整个传入的 uevent,包括第 3.5.2 节中显示的属性,使用--property选项。--udev和--property选项一起使用可以显示处理后的 uevent。
你还可以通过子系统来过滤事件。例如,要仅查看与 SCSI 子系统变化相关的内核消息,可以使用以下命令:
$ **udevadm monitor --kernel --subsystem-match=scsi**
有关udevadm的更多信息,请参见 udevadm(8)手册页。
udev 的功能远不止这些。例如,有一个守护进程叫做 udisksd,它监听事件,以便自动挂载磁盘,并通知其他进程新的磁盘已可用。
3.6 深入分析:SCSI 与 Linux 内核
本节中,我们将了解 Linux 内核中对 SCSI 的支持,以此来探索 Linux 内核架构的一部分。你不需要了解这些信息就能使用磁盘,所以如果你急于使用磁盘,可以跳过本章。此外,这里的内容比你之前看到的更为高级和理论性,因此如果你更倾向于实践操作,应该跳到下一章。
让我们先了解一些背景知识。传统的 SCSI 硬件配置是一个主机适配器通过 SCSI 总线与一链设备相连,如图 3-1 所示。主机适配器连接到计算机。主机适配器和设备各自都有一个 SCSI ID,每个总线最多可有 8 或 16 个 ID,具体取决于 SCSI 版本。一些管理员可能会使用SCSI 目标这个术语来指代设备及其 SCSI ID,因为在 SCSI 协议中,一个会话的一端被称为目标。

图 3-1:带主机适配器和设备的 SCSI 总线
任何设备都可以通过 SCSI 命令集以对等关系与另一个设备通信。计算机并没有直接连接到设备链,因此它必须通过主机适配器与磁盘和其他设备进行通信。通常,计算机会向主机适配器发送 SCSI 命令,主机适配器再将命令转发给设备,设备则通过主机适配器将响应传回。
更新版本的 SCSI,例如串行附加 SCSI(SAS),提供了卓越的性能,但你可能在大多数机器中找不到真正的 SCSI 设备。你更常见到的是使用 SCSI 命令的 USB 存储设备。此外,支持 ATAPI 的设备(例如 CD/DVD-ROM 驱动器)使用 SCSI 命令集的一个版本。
SATA 磁盘也会作为 SCSI 设备出现在系统中,但它们略有不同,因为大多数 SATA 磁盘通过 libata 库中的翻译层进行通信(见第 3.6.2 节)。一些 SATA 控制器(特别是高性能 RAID 控制器)在硬件中执行此翻译。
这些是如何结合在一起的呢?请考虑以下系统中显示的设备:
$ **lsscsi**
[0:0:0:0] disk ATA WDC WD3200AAJS-2 01.0 /dev/sda
[1:0:0:0] cd/dvd Slimtype DVD A DS8A5SH XA15 /dev/sr0
[2:0:0:0] disk USB2.0 CardReader CF 0100 /dev/sdb
[2:0:0:1] disk USB2.0 CardReader SM XD 0100 /dev/sdc
[2:0:0:2] disk USB2.0 CardReader MS 0100 /dev/sdd
[2:0:0:3] disk USB2.0 CardReader SD 0100 /dev/sde
[3:0:0:0] disk FLASH Drive UT_USB20 0.00 /dev/sdf
方括号中的数字是从左到右依次表示 SCSI 主机适配器号、SCSI 总线号、设备 SCSI ID 和 LUN(逻辑单元号,设备的进一步细分)。在本示例中,有四个附加的适配器(scsi0、scsi1、scsi2 和 scsi3),每个适配器都有一个总线(所有总线号为 0),每个总线下有一个设备(所有目标为 0)。位于 2:0:0 的 USB 卡读卡器有四个逻辑单元——每个插入的闪存卡类型对应一个逻辑单元。内核已为每个逻辑单元分配了不同的设备文件。
尽管 NVMe 设备不是 SCSI 设备,它们有时会在lsscsi输出中以N作为适配器号出现。
图 3-2 展示了该特定系统配置中内核内的驱动程序和接口层级,从各个设备驱动程序到块设备驱动程序。它不包括 SCSI 通用(sg)驱动程序。
虽然这是一个庞大的结构,初看时可能会感到压倒性,但图中的数据流是非常线性的。让我们从分析 SCSI 子系统及其三层驱动程序开始:
-
顶层处理一类设备的操作。例如,sd(SCSI 磁盘)驱动程序位于此层;它知道如何将内核块设备接口的请求转换为 SCSI 协议中的磁盘特定命令,反之亦然。
-
中间层调节并路由 SCSI 消息在顶层和底层之间,并跟踪系统中所有附加的 SCSI 总线和设备。
-
底层处理硬件特定的操作。这里的驱动程序将 SCSI 协议消息发送到特定的主机适配器或硬件,并从硬件中提取传入的消息。之所以与顶层分开,是因为尽管 SCSI 消息对于一个设备类(例如磁盘类)是统一的,不同种类的主机适配器在发送相同消息时有不同的程序。
![f03002]()
图 3-2:Linux SCSI 子系统示意图
顶层和底层包含许多不同的驱动程序,但重要的是要记住,对于系统中的任何给定设备文件,内核(几乎总是)使用一个顶层驱动程序和一个底层驱动程序。以我们的示例中的/dev/sda磁盘为例,内核使用sd顶层驱动程序和 ATA 桥接底层驱动程序。
有时候,你可能会为一个硬件设备使用多个上层驱动程序(参见第 3.6.3 节)。对于真正的硬件 SCSI 设备,如连接到 SCSI 主机适配器或硬件 RAID 控制器的磁盘,底层驱动程序直接与硬件进行通信。然而,对于大多数附加到 SCSI 子系统的硬件情况则不同。
3.6.1 USB 存储与 SCSI
为了使 SCSI 子系统能够与常见的 USB 存储硬件进行通信,如图 3-2 所示,内核需要的不仅仅是一个底层 SCSI 驱动程序。一个由/dev/sdf表示的 USB 闪存驱动器能够理解 SCSI 命令,但为了实际与驱动器通信,内核需要知道如何通过 USB 系统进行通信。
从抽象层次来看,USB 与 SCSI 非常相似——它具有设备类别、总线和主机控制器。因此,Linux 内核包括一个三层 USB 子系统,这一点也就不足为奇,它与 SCSI 子系统非常相似,设备类别驱动程序位于顶部,总线管理核心位于中间,主机控制器驱动程序位于底部。就像 SCSI 子系统在其各个组件之间传递 SCSI 命令一样,USB 子系统在其各个组件之间传递 USB 消息。甚至还有一个lsusb命令,它与lsscsi类似。
我们在这里真正感兴趣的部分是最上层的 USB 存储驱动程序。这个驱动程序充当了翻译器。一端,它使用 SCSI 协议,另一端,它使用 USB 协议。由于存储硬件在其 USB 消息中包含 SCSI 命令,因此该驱动程序的工作相对简单:它主要负责重新打包数据。
在 SCSI 和 USB 子系统都就绪的情况下,你几乎拥有与闪存驱动器通信所需的所有东西。最终缺失的环节是 SCSI 子系统中的底层驱动程序,因为 USB 存储驱动程序属于 USB 子系统,而非 SCSI 子系统。(出于组织原因,这两个子系统不应共享驱动程序。)为了使这两个子系统能够互相通信,一个简单的底层 SCSI 桥接驱动程序连接到 USB 子系统的存储驱动程序。
3.6.2 SCSI 与 ATA
图 3-2 中所示的 SATA 硬盘和光驱都使用相同的 SATA 接口。为了将内核的 SATA 特定驱动程序与 SCSI 子系统连接,内核使用了桥接驱动程序,和 USB 驱动器一样,但采用了不同的机制,并增加了一些复杂性。光驱使用 ATAPI,它是 ATA 协议中的一种 SCSI 命令编码版本。然而,硬盘不使用 ATAPI,也不对 SCSI 命令进行任何编码!
Linux 内核使用名为 libata 的库的一部分,将 SATA(以及 ATA)驱动与 SCSI 子系统对接。对于使用 ATAPI 协议的光驱,这是一个相对简单的任务——将 SCSI 命令打包到 ATA 协议中,或从 ATA 协议中提取 SCSI 命令。但对于硬盘来说,这个任务要复杂得多,因为该库必须进行完整的命令翻译。
光驱的工作类似于将一本英文书籍输入计算机。你不需要理解这本书的内容,也不需要懂英文,就能完成这项工作。而硬盘的任务更像是将一本德文书籍翻译成英文并输入计算机。在这种情况下,你需要理解两种语言以及书本的内容。
尽管存在这种困难,libata 执行了这一任务,并使得将 ATA/SATA 接口和设备连接到 SCSI 子系统成为可能。(通常涉及的驱动程序比 图 3-2 中显示的一个 SATA 主机驱动程序要多,但为了简洁起见,未显示其余部分。)
3.6.3 通用 SCSI 设备
当用户空间进程与 SCSI 子系统通信时,通常是通过块设备层和/或其他位于 SCSI 设备类驱动程序(如sd 或 sr)之上的内核服务来进行。换句话说,大多数用户进程根本不需要了解 SCSI 设备及其命令。
然而,用户进程可以绕过设备类驱动程序,直接通过其通用设备向设备发送 SCSI 协议命令。例如,考虑第 3.6 节中描述的系统,但这次看看当你在 lsscsi 中添加 -g 选项以显示通用设备时发生了什么:
$ **lsscsi -g**
[0:0:0:0] disk ATA WDC WD3200AAJS-2 01.0 /dev/sda 1/dev/sg0
[1:0:0:0] cd/dvd Slimtype DVD A DS8A5SH XA15 /dev/sr0 /dev/sg1
[2:0:0:0] disk USB2.0 CardReader CF 0100 /dev/sdb /dev/sg2
[2:0:0:1] disk USB2.0 CardReader SM XD 0100 /dev/sdc /dev/sg3
[2:0:0:2] disk USB2.0 CardReader MS 0100 /dev/sdd /dev/sg4
[2:0:0:3] disk USB2.0 CardReader SD 0100 /dev/sde /dev/sg5
[3:0:0:0] disk FLASH Drive UT_USB20 0.00 /dev/sdf /dev/sg6
除了常规的块设备文件外,每一项在最后一列还列出了一个 SCSI 通用设备文件。例如,光驱的通用设备文件是 /dev/sr0 对应的 /dev/sg1。
为什么你要使用通用设备?答案与内核中代码的复杂性有关。随着任务的复杂化,最好将它们从内核中剔除。以 CD/DVD 写入和读取为例。读取光盘是一个相对简单的操作,且有专门的内核驱动程序来处理它。
然而,写入光盘比读取要困难得多,并且没有关键系统服务依赖于写入操作。因此,没有必要让内核空间承担这个任务。因此,在 Linux 中要写入光盘,你需要运行一个与通用 SCSI 设备(如 /dev/sg1)通信的用户空间程序。这个程序可能比内核驱动程序稍微低效一些,但构建和维护起来要容易得多。
3.6.4 单个设备的多种访问方法
从用户空间访问光驱的两个点(sr 和 sg)在 Linux SCSI 子系统中的示意图如 图 3-3 所示(SCSI 下层的任何驱动程序已被省略)。进程 A 使用 sr 驱动程序从驱动器读取,进程 B 使用 sg 驱动程序向驱动器写入。然而,像这样的进程通常不会同时运行以访问同一个设备。

图 3-3:光学设备驱动程序示意图
在图 3-3 中,进程 A 从块设备中读取数据。但是用户进程真的以这种方式读取数据吗?通常,答案是否定的,数据并非直接这样读取。块设备之上有更多层次,硬盘的访问点也更多,正如你将在下一章中学到的那样。
第四章:磁盘和文件系统

在第三章中,我们概览了一些内核提供的顶级磁盘设备。在本章中,我们将详细讨论如何在 Linux 系统中操作磁盘。你将学习如何分区、创建并维护磁盘分区内的文件系统,以及如何使用交换空间。
回想一下,磁盘设备有类似/dev/sda的名称,这是第一个 SCSI 子系统磁盘。这种类型的块设备代表整个磁盘,但磁盘内部有许多不同的组件和层次。
图 4-1 展示了一个简单 Linux 磁盘的示意图(注意该图并非按比例绘制)。随着你学习本章内容,你将了解每个部分是如何相互配合的。

图 4-1:典型的 Linux 磁盘示意图
分区是整个磁盘的子划分。在 Linux 中,分区通过在整个块设备后加上数字来表示,因此它们的名称像/dev/sda1和/dev/sdb3。内核将每个分区呈现为一个块设备,就像它处理整个磁盘一样。分区定义在磁盘的一个小区域,称为分区表(也叫磁盘标签)。
内核使你能够同时访问整个磁盘和其某个分区,但除非你是在复制整个磁盘,否则通常不会这样做。
Linux 的逻辑卷管理器(LVM)为传统磁盘设备和分区增加了更多的灵活性,现在已被许多系统使用。我们将在第 4.4 节讨论 LVM。
分区之上的下一层是文件系统,它是你在用户空间中习惯与之交互的文件和目录的数据库。我们将在第 4.2 节深入探讨文件系统。
如图 4-1 所示,如果你想访问文件中的数据,需要从分区表中使用适当的分区位置,然后在该分区的文件系统数据库中查找所需的文件数据。
要访问磁盘上的数据,Linux 内核使用图 4-2 中所示的层次结构。SCSI 子系统及第 3.6 节中描述的其他内容被表示为一个单独的框。请注意,你可以通过文件系统以及直接通过磁盘设备来操作磁盘。在本章中,你将看到这两种方法如何工作。为了简化,LVM 在图 4-2 中未被表示,但它在块设备接口中有组件,并在用户空间中有一些管理组件。
为了更好地理解各部分如何组合在一起,我们从最底层的分区开始。

图 4-2:磁盘访问的内核示意图
4.1 磁盘设备分区
分区表有很多种类型。分区表本身并没有什么特别之处——它只是一堆数据,用来表示磁盘上的块是如何划分的。
传统的分区表可以追溯到 PC 时代,存在于主引导记录(MBR)中,并且有许多限制。大多数较新的系统使用全局唯一标识符分区表(GPT)。
以下是一些常见的 Linux 分区工具:
-
parted(“分区编辑器”) 一款支持 MBR 和 GPT 的基于文本的工具。 -
gpartedparted的图形化版本。 -
fdisk传统的基于文本的 Linux 磁盘分区工具。fdisk的新版支持 MBR、GPT 以及许多其他类型的分区表,但旧版本仅支持 MBR。
由于parted已经支持 MBR 和 GPT 很长时间了,并且可以很容易地通过单个命令获取分区标签,我们将使用parted来显示分区表。不过,在创建和修改分区表时,我们将使用fdisk。这将展示两种界面,以及为什么许多人更喜欢fdisk界面,因为它是交互式的,并且在你有机会查看变更之前,它不会对磁盘做任何修改(我们稍后会讨论这一点)。
4.1.1 查看分区表
你可以使用parted -l查看系统的分区表。以下是示例输出,展示了两个不同分区表类型的磁盘设备:
# parted -l
Model: ATA KINGSTON SM2280S (scsi)
1 Disk /dev/sda: 240GB
Sector size (logical/physical): 512B/512B
Partition Table: msdos
Disk Flags:
Number Start End Size Type File system Flags
1 1049kB 223GB 223GB primary ext4 boot
2 223GB 240GB 17.0GB extended
5 223GB 240GB 17.0GB logical linux-swap(v1)
Model: Generic Flash Disk (scsi)
2 Disk /dev/sdf: 4284MB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags:
Number Start End Size File system Name Flags
1 1049kB 1050MB 1049MB myfirst
2 1050MB 4284MB 3235MB mysecond
第一个设备(/dev/sda)1 使用传统的 MBR 分区表(parted称之为msdos),而第二个设备(/dev/sdf)2 包含一个 GPT。注意,两个分区表类型存储的参数集不同。特别是,MBR 表没有Name列,因为在这种方案下没有名字。(我在 GPT 中随意选择了myfirst和mysecond作为名称。)
MBR 基础知识
本示例中的 MBR 表包含主分区、扩展分区和逻辑分区。主分区是磁盘的正常子分区;分区 1 就是一个示例。基础的 MBR 最多支持四个主分区,因此如果你需要超过四个分区,你必须将其中一个标记为扩展分区。扩展分区可以划分为逻辑分区,操作系统可以像使用任何其他分区一样使用这些逻辑分区。在这个示例中,分区 2 是一个扩展分区,它包含了逻辑分区 5。
LVM 分区:快速预览
在查看你的分区表时,如果你看到标记为 LVM(分区类型代码为8e)、设备名为/dev/dm-*,或者提到“设备映射器”的内容,那么说明你的系统使用了 LVM。我们的讨论将从传统的直接磁盘分区开始,这看起来与使用 LVM 的系统稍有不同。
为了让你知道该期待什么,我们先快速看一下在一台使用 LVM 的系统上(在 VirtualBox 中安装全新 Ubuntu 并使用 LVM)运行parted -l命令时的输出。首先,是实际分区表的描述,整体看起来和你预期的差不多,唯一不同的是lvm标志:
Model: ATA VBOX HARDDISK (scsi)
Disk /dev/sda: 10.7GB
Sector size (logical/physical): 512B/512B
Partition Table: msdos
Disk Flags:
Number Start End Size Type File system Flags
1 1049kB 10.7GB 10.7GB primary boot, lvm
然后是一些看起来应该是分区,但被称为磁盘的设备:
Model: Linux device-mapper (linear) (dm)
Disk /dev/mapper/ubuntu--vg-swap_1: 1023MB
Sector size (logical/physical): 512B/512B
Partition Table: loop
Disk Flags:
Number Start End Size File system Flags
1 0.00B 1023MB 1023MB linux-swap(v1)
Model: Linux device-mapper (linear) (dm)
Disk /dev/mapper/ubuntu--vg-root: 9672MB
Sector size (logical/physical): 512B/512B
Partition Table: loop
Disk Flags:
Number Start End Size File system Flags
1 0.00B 9672MB 9672MB ext4
一种简单的理解方式是,分区在某种程度上已经与分区表分离。你将在第 4.4 节看到实际发生了什么。
初始内核读取
在最初读取 MBR 表时,Linux 内核会输出类似这样的调试信息(记得你可以通过journalctl -k查看):
sda: sda1 sda2 < sda5 >
输出中的sda2 < sda5 >部分表示/dev/sda2是一个扩展分区,包含一个逻辑分区/dev/sda5。通常,你会忽略扩展分区本身,因为你通常只关心访问它包含的逻辑分区。
4.1.2 修改分区表
查看分区表是一个相对简单且无害的操作。更改分区表也相对容易,但对磁盘进行这种更改涉及一定的风险。请记住以下几点:
-
更改分区表会使恢复你删除或重新定义的分区上的任何数据变得非常困难,因为这样做可能会删除这些分区上文件系统的位置。如果你正在分区的磁盘包含重要数据,请确保你有备份。
-
确保目标磁盘上的分区当前没有被使用。这是一个重要问题,因为大多数 Linux 发行版会自动挂载任何检测到的文件系统。(关于挂载和卸载的更多内容,请参见第 4.2.3 节。)
当你准备好时,选择你的分区程序。如果你想使用parted,你可以使用命令行工具parted或图形界面工具,如gparted;fdisk在命令行中也非常容易使用。这些工具都有在线帮助,且容易学习。(如果你没有备用磁盘,试着在闪存设备或类似设备上使用它们。)
话虽如此,fdisk和parted的工作方式存在一个主要区别。使用fdisk时,你在实际更改磁盘之前先设计好新的分区表,并且只有在退出程序时才会进行更改。但使用parted时,分区是在你发出命令时创建、修改和删除的。在更改之前,你没有机会查看分区表。
这些差异对于理解这两种工具如何与内核交互也非常关键。fdisk和parted都完全在用户空间修改分区;不需要提供内核支持来重写分区表,因为用户空间可以读取和修改整个块设备。
然而,在某个时刻,内核必须读取分区表,以便将分区作为块设备呈现,这样你就可以使用它们。fdisk工具采用一种相对简单的方法。在修改分区表后,fdisk会发出一个系统调用,告诉内核应该重新读取磁盘的分区表(你很快会看到如何与fdisk交互的示例)。然后,内核会生成调试输出,你可以通过journalctl -k查看。例如,如果你在/dev/sdf上创建了两个分区,你将看到如下信息:
sdf: sdf1 sdf2
parted 工具不会使用这种磁盘范围的系统调用;相反,它们在修改单个分区时会向内核发送信号。处理单个分区更改后,内核不会产生前面提到的调试输出。
有几种方法可以查看分区更改:
-
使用
udevadm监视内核事件变化。例如,命令udevadm monitor --kernel将显示旧的分区设备被移除并添加新的分区设备。 -
检查 /proc/partitions 获取完整的分区信息。
-
检查 /sys/block/device/ 以查看修改后的分区系统接口,或 /dev 以查看修改后的分区设备。
4.1.3 创建分区表
让我们通过在一个新的空磁盘上创建一个新的分区表来应用你刚刚学到的所有内容。这个例子展示了以下场景:
-
4GB 磁盘(一个小型 USB 闪存设备,未使用;如果你想跟着这个示例操作,可以使用任何你手头的设备)
-
MBR 风格分区表
-
两个分区打算使用 ext4 文件系统:200MB 和 3.8GB
-
磁盘设备位于 /dev/sdd;你需要使用
lsblk查找自己的设备位置
你将使用 fdisk 来完成工作。回想一下,这是一条交互式命令,所以在确保磁盘上没有任何挂载内容后,你会在命令提示符下输入设备名称:
# fdisk /dev/sdd
你将收到一个介绍性消息,然后是类似这样的命令提示符:
Command (m for help):
首先,使用 p 命令打印当前的分区表(fdisk 命令简洁明了)。你的操作大致如下:
Command (m for help): **p**
Disk /dev/sdd: 4 GiB, 4284481536 bytes, 8368128 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x88f290cc
Device Boot Start End Sectors Size Id Type
/dev/sdd1 2048 8368127 8366080 4G c W95 FAT32 (LBA)
大多数设备已经包含一个 FAT 风格的分区,比如这个位于 /dev/sdd1 的分区。因为你要为 Linux 创建新分区(当然,你确定不需要这里的任何内容),所以你可以像这样删除现有的分区:
Command (m for help): **d**
Selected partition 1
Partition 1 has been deleted.
请记住,fdisk 在你明确写入分区表之前不会做任何更改,因此你还没有修改磁盘。如果你犯了无法恢复的错误,可以使用 q 命令退出 fdisk 而不保存更改。
现在,你将使用 n 命令创建第一个 200MB 的分区:
Command (m for help): **n**
Partition type
p primary (0 primary, 0 extended, 4 free)
e extended (container for logical partitions)
Select (default p): **p**
Partition number (1-4, default 1): `1`
First sector (2048-8368127, default 2048): **2048**
Last sector, +sectors or +size{K,M,G,T,P} (2048-8368127, default 8368127): **+200M**
Created a new partition 1 of type 'Linux' and of size 200 MiB.
在这里,fdisk 会提示你选择 MBR 分区风格、分区号、分区的起始位置及其结束位置(或大小)。默认值通常是你需要的。这里唯一变化的是使用 + 语法来指定分区的结束位置/大小和单位。
创建第二个分区的方式与此相同,只是你会使用所有默认值,因此我们不再赘述。当你完成分区布局后,使用 p(打印)命令进行查看:
Command (m for help): **p**
[--snip--]
Device Boot Start End Sectors Size Id Type
/dev/sdd1 2048 411647 409600 200M 83 Linux
/dev/sdd2 411648 8368127 7956480 3.8G 83 Linux
当你准备好写入分区表时,使用 w 命令:
Command (m for help): **w**
The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks.
注意,fdisk 不会像安全措施一样询问你是否确定,它会直接执行操作并退出。
如果你对其他诊断信息感兴趣,可以使用 journalctl -k 查看之前提到的内核读取信息,但请记住,只有在使用 fdisk 时你才能看到这些信息。
到目前为止,您已经掌握了开始分区硬盘的基础知识,但如果您想要更多关于硬盘的细节,请继续阅读。否则,跳到第 4.2 节了解如何在硬盘上放置文件系统。
4.1.4 导航硬盘和分区几何
任何带有移动部件的设备都会向软件系统引入复杂性,因为存在抵制抽象的物理元素。硬盘也不例外;即使您可以将硬盘视为具有对任何块的随机访问的块设备,如果系统在如何在硬盘上布局数据方面不小心,可能会导致严重的性能后果。考虑图示的简单单盘硬盘的物理特性图 4-3。

图 4-3:硬盘的俯视图
硬盘由安装在主轴上的旋转盘片和连接到移动臂的磁头组成,臂可以在盘片半径上扫动。当盘片在磁头下旋转时,磁头读取数据。当臂处于一个位置时,磁头只能从一个固定圆圈读取数据。这个圆圈因为较大的硬盘有多个盘片,所有盘片都围绕同一主轴旋转,被称为柱面。每个盘片可以有一个或两个磁头,用于盘片的顶部和/或底部,并且所有磁头都连接到同一个臂上并协调移动。由于臂的移动,硬盘上有许多柱面,从中心周围的小柱面到盘片边缘周围的大柱面。最后,您可以将柱面分为扇区。这种关于硬盘几何结构的思维方式称为CHS,代表柱面-磁头-扇区;在旧系统中,您可以通过这三个参数的地址找到硬盘的任何部分。
内核和各种分区程序可以告诉您,硬盘报告的柱面数是多少。然而,在任何半新的硬盘上,报告的值都是虚构的!使用 CHS 的传统寻址方案无法与现代硬盘硬件兼容,也不能解释外圈柱面可以存放更多数据的事实。硬盘硬件支持逻辑块地址寻址(LBA)通过块编号来寻址硬盘上的位置(这是一个更直接的接口),但 CHS 的遗留物仍然存在。例如,MBR 分区表包含 CHS 信息以及 LBA 等效信息,并且一些引导加载程序仍然愚蠢到相信 CHS 值(不用担心——大多数 Linux 引导加载程序使用 LBA 值)。
4.1.5 从固态硬盘读取
没有移动部件的存储设备,如固态硬盘(SSD),在访问特性上与旋转硬盘有根本的不同。对于这些设备,随机访问不是问题,因为没有磁头在盘片上扫过,但某些特性可能会改变 SSD 的性能表现。
影响 SSD 性能的最重要因素之一是分区对齐。当你从 SSD 读取数据时,你是按块读取数据(称为页面,不要与虚拟内存页面混淆)——例如每次读取 4,096 或 8,192 字节——而且读取必须从该大小的倍数开始。这意味着如果你的分区及其数据不位于边界上,你可能需要做两次读取而不是一次,这对于小的常见操作(例如读取目录内容)来说会增加开销。
较新的分区工具版本包含将新创建的分区放置在磁盘起始位置适当偏移处的逻辑,因此你可能不需要担心分区对齐不当的问题。目前,分区工具不进行任何计算;它们只是将分区对齐到 1MB 边界,或者更精确地说,是 2,048 个 512 字节的块。这是一种相当保守的方法,因为该边界与 4,096、8,192 等页面大小对齐,一直到 1,048,576。
然而,如果你有兴趣或想确保你的分区以边界开始,你可以很容易地在/sys/block目录中找到此信息。以下是分区/dev/sdf2的一个例子:
$ **cat /sys/block/sdf/sdf2/start**
1953126
这里的输出是分区相对于设备起始位置的偏移量,以 512 字节为单位(Linux 系统将其混淆地称为扇区)。如果这个 SSD 使用 4,096 字节的页面,那么每个页面里有八个这样的扇区。你只需要检查是否能够将分区偏移量除以 8 整除。此时,你不能整除,因此该分区将无法达到最佳性能。
4.2 文件系统
磁盘上内核与用户空间之间的最后一个连接通常是文件系统;这就是你在运行像ls和cd这样的命令时与之交互的内容。正如前面提到的,文件系统是一种数据库形式;它提供了将简单的块设备转化为用户可以理解的复杂文件和子目录层次结构的结构。
曾几何时,所有文件系统都存在于磁盘和其他仅用于数据存储的物理介质上。然而,文件系统的树状目录结构和 I/O 接口非常通用,因此文件系统现在执行各种任务,例如在/sys和/proc中看到的系统接口。文件系统传统上是在内核中实现的,但来自 Plan 9 的 9P 创新(en.wikipedia.org/wiki/9P_(protocol))激发了用户空间文件系统的发展。用户空间文件系统 (FUSE) 功能允许 Linux 中的用户空间文件系统。
虚拟文件系统(VFS)抽象层完成了文件系统的实现。就像 SCSI 子系统通过标准化不同设备类型与内核控制命令之间的通信一样,VFS 确保所有文件系统实现都支持标准接口,使得用户空间应用程序能够以相同的方式访问文件和目录。VFS 的支持使得 Linux 能够支持数量庞大的文件系统。
4.2.1 文件系统类型
Linux 文件系统支持包括为 Linux 优化的本地设计;外来类型,如 Windows FAT 系列;通用文件系统,如 ISO 9660;以及许多其他文件系统。以下列表包括用于数据存储的最常见文件系统类型。Linux 识别的类型名称位于文件系统名称旁边的括号内。
-
第四扩展文件系统(ext4)是 Linux 本地文件系统系列的最新版本。第二扩展文件系统(ext2)曾是 Linux 系统的默认文件系统,灵感来自传统的 Unix 文件系统,如 Unix 文件系统(UFS)和快速文件系统(FFS)。第三扩展文件系统(ext3)增加了日志功能(一个在正常文件系统数据结构之外的小缓存)以增强数据完整性并加速启动。ext4 文件系统是在 ext2 和 ext3 的基础上的增量改进,支持比 ext2 或 ext3 更大的文件以及更多的子目录。
扩展文件系统系列具有一定的向后兼容性。例如,你可以将 ext2 和 ext3 文件系统互相挂载,也可以将 ext2 和 ext3 文件系统挂载为 ext4,但你不能将 ext4 挂载为 ext2 或 ext3。
-
Btrfs,或 B 树文件系统(btrfs)是一个较新的 Linux 本地文件系统,旨在超越 ext4 的能力。
-
FAT 文件系统(msdos, vfat, exfat)属于微软系统。简单的 msdos 类型支持 MS-DOS 系统中的非常原始的单一字母大小写。大多数可移动闪存介质,如 SD 卡和 USB 驱动器,默认包含 vfat(最多 4GB)或 exfat(4GB 及以上)分区。Windows 系统可以使用 FAT 文件系统或更高级的 NT 文件系统(ntfs)。
-
XFS 是一种高性能文件系统,一些发行版(如 Red Hat Enterprise Linux 7.0 及更高版本)默认使用该文件系统。
-
HFS+(hfsplus)是苹果公司在大多数 Macintosh 系统上使用的标准。
-
ISO 9660(iso9660)是一个 CD-ROM 标准。大多数 CD-ROM 使用某种变种的 ISO 9660 标准。
4.2.2 创建文件系统
如果你正在准备一个新的存储设备,一旦完成了 4.1 节中描述的分区过程,你就可以开始创建文件系统了。与分区一样,你将在用户空间中进行此操作,因为用户空间进程可以直接访问和操作块设备。
mkfs 工具可以创建多种类型的文件系统。例如,你可以使用以下命令在 /dev/sdf2 上创建一个 ext4 分区:
# mkfs -t ext4 /dev/sdf2
mkfs程序会自动确定设备中的块数并设置一些合理的默认值。除非你非常清楚自己在做什么并且愿意详细阅读文档,否则不要修改这些设置。
当你创建一个文件系统时,mkfs会在工作过程中打印诊断输出,包括关于超级块的输出。超级块是文件系统数据库顶层的一个关键组件,它非常重要,以至于mkfs会创建多个备份,以防原始的超级块丢失。考虑在mkfs运行时记录一些超级块备份编号,以防在磁盘故障时需要恢复超级块(详见第 4.2.11 节)。
4.2.3 挂载文件系统
在 Unix 中,将文件系统附加到运行中的系统的过程称为挂载。当系统启动时,内核会读取一些配置数据并根据这些配置数据挂载根目录(/)。
要挂载文件系统,你必须知道以下内容:
-
文件系统的设备、位置或标识符(例如磁盘分区——实际文件系统数据所在的地方)。一些特殊用途的文件系统,如 proc 和 sysfs,没有具体的位置。
-
文件系统类型。
-
挂载点——当前系统目录层次结构中将附加文件系统的位置。挂载点始终是一个普通目录。例如,你可以使用/music作为包含音乐的文件系统的挂载点。挂载点不必直接位于/下,它可以位于系统中的任何位置。
挂载文件系统的常用术语是“将设备挂载到挂载点”。要查看当前系统的文件系统状态,可以运行mount命令。输出(可能相当长)应如下所示:
$ **mount**
/dev/sda1 on / type ext4 (rw,errors=remount-ro)
proc on /proc type proc (rw,noexec,nosuid,nodev)
sysfs on /sys type sysfs (rw,noexec,nosuid,nodev)
fusectl on /sys/fs/fuse/connections type fusectl (rw)
debugfs on /sys/kernel/debug type debugfs (rw)
securityfs on /sys/kernel/security type securityfs (rw)
udev on /dev type devtmpfs (rw,mode=0755)
devpts on /dev/pts type devpts (rw,noexec,nosuid,gid=5,mode=0620)
tmpfs on /run type tmpfs (rw,noexec,nosuid,size=10%,mode=0755)
--`snip`--
每一行对应一个当前挂载的文件系统,项目顺序如下:
-
设备,例如/dev/sda3。注意,其中一些不是实际的设备(例如
proc),而是实际设备名称的代替符,因为这些特殊用途的文件系统不需要设备。 -
单词
on。 -
挂载点。
-
单词
type。 -
文件系统类型,通常以简短标识符的形式表示。
-
挂载选项(在括号中)。更多详情见第 4.2.6 节。
要手动挂载文件系统,可以使用mount命令,指定文件系统类型、设备和所需的挂载点:
`#` **mount -t** `type device mountpoint`
例如,要将位于设备/dev/sdf2上的第四扩展文件系统挂载到/home/extra,可以使用以下命令:
# mount -t ext4 /dev/sdf2 /home/extra
通常你不需要提供-t type选项,因为mount通常会为你自动识别。不过,有时需要区分两种相似的类型,例如各种 FAT 样式的文件系统。
要卸载(分离)文件系统,可以使用以下umount命令:
# umount `mountpoint`
你也可以使用设备而非挂载点来卸载文件系统。
4.2.4 文件系统 UUID
前面部分讨论的挂载文件系统的方法依赖于设备名称。然而,设备名称可能会发生变化,因为它们取决于内核找到设备的顺序。为了解决这个问题,您可以通过它们的全局唯一标识符(UUID)来识别和挂载文件系统。UUID 是一种行业标准,用于为计算机系统中的对象分配唯一的“序列号”。像mke2fs这样的文件系统创建程序在初始化文件系统数据结构时会生成 UUID。
要查看系统上设备及其对应的文件系统和 UUID 列表,请使用blkid(块 ID)程序:
# blkid
/dev/sdf2: UUID="b600fe63-d2e9-461c-a5cd-d3b373a5e1d2" TYPE="ext4"
/dev/sda1: UUID="17f12d53-c3d7-4ab3-943e-a0a72366c9fa" TYPE="ext4" PARTUUID="c9a5ebb0-01"
/dev/sda5: UUID="b600fe63-d2e9-461c-a5cd-d3b373a5e1d2" TYPE="swap" PARTUUID="c9a5ebb0-05"
/dev/sde1: UUID="4859-EFEA" TYPE="vfat"
在此示例中,blkid发现了四个包含数据的分区:两个带有 ext4 文件系统,一个带有交换空间标识符(参见第 4.3 节),还有一个带有 FAT 文件系统。Linux 本地分区都有标准 UUID,但 FAT 分区没有。您可以通过其 FAT 卷序列号(在此例中为 4859-EFEA)引用 FAT 分区。
要通过 UUID 挂载文件系统,请使用UUID挂载选项。例如,要将前面列表中的第一个文件系统挂载到/home/extra,请输入:
# mount UUID=b600fe63-d2e9-461c-a5cd-d3b373a5e1d2 /home/extra
通常您不会像这样手动通过 UUID 挂载文件系统,因为您通常知道设备,并且通过设备名称挂载比通过 UUID 更简单。然而,理解 UUID 很重要。一方面,它们是自动在启动时通过/etc/fstab挂载非 LVM 文件系统的首选方法(参见第 4.2.8 节)。此外,许多发行版在插入可移动媒体时使用 UUID 作为挂载点。在前面的例子中,FAT 文件系统位于闪存介质卡上。当有人登录的 Ubuntu 系统插入该卡时,会在/media/user/4859-EFEA处挂载此分区。第三章中描述的 udevd 守护进程处理设备插入的初始事件。
如果需要,您可以更改文件系统的 UUID(例如,如果您将整个文件系统从其他地方复制过来,现在需要将其与原始文件系统区分开)。有关如何在 ext2/ext3/ext4 文件系统上执行此操作,请参阅 tune2fs(8)手册页。
4.2.5 磁盘缓冲、缓存与文件系统
Linux 与其他 Unix 变种一样,会缓冲写入磁盘。这意味着内核通常不会立即在进程请求更改时将更改写入文件系统。相反,它将这些更改存储在 RAM 中,直到内核确定一个合适的时机将它们实际写入磁盘。这个缓冲系统对用户是透明的,并提供了显著的性能提升。
当您使用umount卸载文件系统时,内核会自动与磁盘进行同步,将其缓冲区中的更改写入磁盘。您也可以通过运行sync命令强制内核随时执行此操作,默认情况下该命令会同步系统上的所有磁盘。如果由于某种原因您在关闭系统前无法卸载文件系统,请务必先运行sync命令。
此外,内核使用 RAM 来缓存从磁盘读取的块。因此,如果一个或多个进程反复访问某个文件,内核就不需要一次又一次地访问磁盘,它可以直接从缓存中读取,从而节省时间和资源。
4.2.6 文件系统挂载选项
有许多方法可以改变mount命令的行为,这在处理可移动媒体或进行系统维护时经常需要做。实际上,mount选项的总数非常庞大。详尽的mount(8)手册页是一个很好的参考,但很难知道从哪里开始以及哪些可以安全忽略。在本节中,你将看到最有用的选项。
选项大致分为两类:通用选项和特定于文件系统的选项。通用选项通常适用于所有类型的文件系统,包括早前提到的-t,用于指定文件系统类型。相反,特定于文件系统的选项仅适用于某些文件系统类型。
要启用文件系统选项,请使用 -o 交换符,后面跟上选项。例如,-o remount,rw 将已挂载为只读的文件系统重新挂载为读写模式。
短通用选项
通用选项有简短的语法。最重要的选项包括:
-
-r-r选项以只读模式挂载文件系统。它有多种用途,从写保护到引导。访问只读设备(如 CD-ROM)时,你无需指定此选项;系统会自动为你处理(并告知你只读状态)。 -
-n-n选项确保mount不尝试更新系统运行时的挂载数据库*/etc/mtab*。默认情况下,当无法写入此文件时,mount操作会失败,因此在启动时这个选项非常重要,因为根分区(包括系统挂载数据库)初始时是只读的。当你尝试在单用户模式下修复系统问题时,这个选项也很有用,因为此时系统挂载数据库可能不可用。 -
-t-ttype选项用于指定文件系统类型。
长选项
像 -r 这样的短选项对于不断增加的 mount 选项来说过于有限;字母表中的字母太少,无法容纳所有可能的选项。短选项也很麻烦,因为仅凭一个字母很难确定选项的含义。许多通用选项和所有特定于文件系统的选项都使用更长且更灵活的选项格式。
要在命令行中使用 mount 的长选项,可以从 -o 开始,后面跟着用逗号分隔的适当关键词。以下是一个完整的示例,长选项紧跟在 -o 后面:
# mount -t vfat /dev/sde1 /dos -o ro,uid=1000
这里的两个长选项是 ro 和 uid=1000。ro 选项指定只读模式,和 -r 短选项相同。uid=1000 选项告诉内核将文件系统上的所有文件视为用户 ID 为 1000 的所有者。
最有用的长选项如下:
-
exec、noexec启用或禁用在文件系统上执行程序。 -
suid、nosuid启用或禁用setuid程序。 -
ro以只读模式挂载文件系统(与-r短选项相同)。 -
rw以读写模式挂载文件系统。
4.2.7 重新挂载文件系统
有时你需要更改当前已挂载文件系统的mount选项;最常见的情况是在崩溃恢复期间,你需要将只读文件系统变为可写。在这种情况下,你需要在相同的挂载点重新附加文件系统。
以下命令将根目录以读写模式重新挂载(由于mount命令在根目录为只读时无法写入系统挂载数据库,因此你需要使用-n选项):
# mount -n -o remount /
此命令假定/*的正确设备列表已经在/etc/fstab中(如下一节所述)。如果没有,你必须将设备作为额外选项指定。
4.2.8 /etc/fstab 文件系统表
为了在启动时挂载文件系统并简化mount命令,Linux 系统将文件系统及其选项的永久列表保存在/etc/fstab中。这是一个非常简单格式的纯文本文件,如清单 4-1 所示。
UUID=70ccd6e7-6ae6-44f6-812c-51aab8036d29 / ext4 errors=remount-ro 0 1
UUID=592dcfd1-58da-4769-9ea8-5f412a896980 none swap sw 0 0
/dev/sr0 /cdrom iso9660 ro,user,nosuid,noauto 0 0
清单 4-1:/etc/fstab中的文件系统及其选项列表
每一行对应一个文件系统,并分为六个字段。从左到右,这些字段分别是:
-
设备或 UUID 当前大多数 Linux 系统不再使用/etc/fstab中的设备,倾向于使用 UUID。
-
挂载点 指示将文件系统附加到哪里。
-
文件系统类型 你可能不会在这个列表中识别
swap,它是交换分区(见第 4.3 节)。 -
选项 长选项,用逗号分隔。
-
用于
dump命令的备份信息dump命令是一个早已废弃的备份工具;该字段不再相关。你应该始终将其设置为0。 -
文件系统完整性测试顺序 为确保
fsck始终首先在根文件系统上运行,请始终将根文件系统的该值设置为1,并将任何其他本地附加的硬盘或 SSD 文件系统的该值设置为2。使用0可以禁用对其他所有文件系统的启动检查,包括只读设备、交换分区以及/proc文件系统(请参见第 4.2.11 节中的fsck命令)。
使用mount时,如果你想要操作的文件系统已经在/etc/fstab中,你可以使用一些快捷方式。例如,如果你使用的是清单 4-1 并且挂载了一个 CD-ROM,你只需运行mount /cdrom。
你也可以尝试同时挂载所有在/etc/fstab中没有包含noauto选项的条目,使用以下命令:
# mount -a
清单 4-1 介绍了一些新的选项——即errors、noauto和user,因为它们不适用于/etc/fstab文件之外。此外,你还会经常看到defaults选项。这些选项的定义如下:
-
defaults这会设置mount的默认选项:读写模式,启用设备文件,执行文件,setuid位,等等。若不想为文件系统提供任何特别选项,但又想填写/etc/fstab中的所有字段,则使用此选项。 -
errors这是一个特定于 ext2/3/4 的参数,用来设置系统在挂载文件系统时遇到问题时的内核行为。默认值通常是errors=continue,意味着内核应返回错误代码并继续运行。若要让内核在只读模式下重新尝试挂载,使用errors=remount-ro。errors=panic设置则会告诉内核(以及你的系统)在挂载出现问题时停止。 -
noauto此选项告诉mount -a命令忽略此条目。若想防止可移动媒体设备(如闪存存储设备)在启动时挂载,可以使用此选项。 -
user此选项允许非特权用户对特定条目执行mount命令,这对于允许某些类型的可移动媒体访问非常方便。因为用户可以在可移动媒体上放置setuid-root文件并用另一个系统访问该媒体,所以此选项还会设置nosuid、noexec和nodev(以禁止特殊设备文件)。请记住,对于可移动媒体和其他一般情况,此选项现在的使用有限,因为大多数系统通过ubus及其他机制自动挂载插入的媒体。然而,在某些特殊情况下,若你想授予控制挂载特定目录的权限,此选项仍然有用。
4.2.9 /etc/fstab 的替代方案
尽管/etc/fstab文件一直是表示文件系统及其挂载点的传统方式,但仍有两种替代方案。第一种是/etc/fstab.d目录,其中包含单独的文件系统配置文件(每个文件系统一个文件)。这个想法与本书中将看到的许多其他配置目录非常相似。
第二种替代方案是为文件系统配置systemd 单元。你将在第六章了解更多关于 systemd 及其单元的内容。然而,systemd 单元配置通常是从(或基于)/etc/fstab文件生成的,因此你可能会在系统中发现一些重叠部分。
4.2.10 文件系统容量
若要查看当前挂载的文件系统的大小和利用率,请使用df命令。输出可能非常庞大(并且随着专用文件系统的增多而越来越长),但应该包含有关实际存储设备的信息。
$ **df**
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/sda1 214234312 127989560 75339204 63% /
/dev/sdd2 3043836 4632 2864872 1% /media/user/uuid
这是df输出字段的简要说明:
-
Filesystem文件系统设备 -
1K-blocks文件系统的总容量,以 1,024 字节为单位的块数 -
Used已占用的块数 -
Available剩余的空闲块数 -
Use%已使用的块的百分比 -
Mounted on挂载点
应该很容易看出,这两个文件系统的大小分别大约为 215GB 和 3GB。然而,容量数值可能看起来有点奇怪,因为 127,989,560 加 75,339,204 并不等于 214,234,312,而且 127,989,560 也不是 214,234,312 的 63%。在这两种情况下,总容量的 5% 没有被计算在内。实际上,空间是存在的,但它隐藏在 保留 块中。只有超级用户才能在文件系统开始填满时使用这些保留块。这一功能能防止系统服务器在磁盘空间用尽时立即崩溃。
4.2.11 检查和修复文件系统
Unix 文件系统所提供的优化功能得益于一个复杂的数据库机制。为了让文件系统无缝工作,内核必须信任已挂载的文件系统没有错误,并且硬件能够可靠地存储数据。如果存在错误,可能会导致数据丢失和系统崩溃。
除了硬件问题,文件系统错误通常是由于用户粗暴关闭系统(例如拔掉电源线)导致的。在这种情况下,内存中的先前文件系统缓存可能与磁盘上的数据不匹配,且当你给计算机“踢”一脚时,系统可能正在修改文件系统。尽管许多文件系统支持日志功能,从而使文件系统损坏的情况变得不那么常见,但你仍然应该正确关闭系统。无论使用哪种文件系统,定期检查文件系统仍然是必要的,以确保一切正常。
检查文件系统的工具是 fsck。与 mkfs 程序一样,Linux 支持的每种文件系统类型都有一个不同版本的 fsck。例如,当在扩展文件系统系列(ext2/ext3/ext4)上运行时,fsck 会识别文件系统类型并启动 e2fsck 工具。因此,通常情况下你不需要输入 e2fsck,除非 fsck 无法识别文件系统类型,或者你需要查阅 e2fsck 的手册页。
本节中提供的信息特定于扩展文件系统系列和 e2fsck。
要在交互式手动模式下运行 fsck,请将设备或挂载点(如 /etc/fstab 中列出的)作为参数。例如:
# fsck /dev/sdb1
在手动模式下,fsck 会打印详细的状态报告,报告的内容在没有问题时应该类似于以下内容:
Pass 1: Checking inodes, blocks, and sizes
Pass 2: Checking directory structure
Pass 3: Checking directory connectivity
Pass 4: Checking reference counts
Pass 5: Checking group summary information
/dev/sdb1: 11/1976 files (0.0% non-contiguous), 265/7891 blocks
如果fsck在手动模式下发现问题,它会停止并提出一个与修复该问题相关的问题。这些问题涉及文件系统的内部结构,例如重新连接松散的 inode 和清理块(inode是文件系统的构建模块;你将在第 4.6 节中了解它们的工作原理)。当fsck询问你是否要重新连接一个 inode 时,它已发现一个看起来没有名称的文件。重新连接这样的文件时,fsck会将该文件放入文件系统中的lost+found目录,并以数字作为文件名。如果发生这种情况,你需要根据文件的内容来猜测文件名;原始文件名可能已经丢失。
通常,如果你只是非正常关闭了系统,坐等fsck修复过程是没有意义的,因为fsck可能需要修复很多小问题。幸运的是,e2fsck有一个-p选项,它会自动修复普通问题而不询问,并在遇到严重错误时中止。事实上,Linux 发行版在启动时会运行一个变种的fsck -p。(你也可能会看到fsck -a,它执行相同的操作。)
如果你怀疑系统出现了重大故障,比如硬件故障或设备配置错误,你需要决定一个行动方案,因为fsck在文件系统出现较大问题时,可能会真的破坏文件系统。(一个明显的信号是,如果fsck在手动模式下问了很多问题,说明系统可能有严重问题。)
如果你认为发生了严重问题,尝试运行fsck -n检查文件系统而不修改任何内容。如果你认为设备配置有问题(例如松动的电缆或分区表中的块数量不正确),在真正运行fsck之前,先修复它,否则你很可能会丢失大量数据。
如果你怀疑只有超级块损坏(例如,因为某人写入了磁盘分区的开始位置),你可能可以使用mkfs创建的超级块备份之一来恢复文件系统。使用fsck -b num将损坏的超级块替换为块num处的备用超级块,并希望一切顺利。
如果你不知道在哪儿找到备份超级块,你可以在设备上运行mkfs -n,查看超级块备份号列表,而不会破坏你的数据。(再次提醒,确保你使用的是-n,否则你会真的破坏文件系统。)
检查 ext3 和 ext4 文件系统
通常,你不需要手动检查 ext3 和 ext4 文件系统,因为日志确保了数据完整性(回想一下,日志是一个小的数据缓存,尚未写入文件系统中的特定位置)。如果你没有正常关闭系统,你可以预期日志中会包含一些数据。要将 ext3 或 ext4 文件系统中的日志刷新到常规文件系统数据库,运行e2fsck,方法如下:
# e2fsck –fy /dev/`disk_device`
然而,你可能想将损坏的 ext3 或 ext4 文件系统以 ext2 模式挂载,因为内核不会挂载带有非空日志的 ext3 或 ext4 文件系统。
最坏的情况
更严重的磁盘问题会让你面临很少的选择:
-
你可以尝试使用
dd从磁盘提取整个文件系统镜像,并将其传输到另一个相同大小的磁盘上的分区。 -
你可以尽量修补文件系统,将其挂载为只读模式,并尽可能恢复数据。
-
你可以尝试
debugfs。
在前两种情况下,你仍然需要修复文件系统,然后才能挂载它,除非你愿意手动浏览原始数据。如果你愿意,你可以选择对所有fsck问题回答y,通过输入fsck -y,但最好作为最后的手段,因为在修复过程中可能会出现你更愿意手动处理的问题。
debugfs工具允许你浏览文件系统中的文件并将它们复制到其他地方。默认情况下,它以只读模式打开文件系统。如果你正在恢复数据,最好保持文件的完整性,以避免进一步破坏。
现在,如果你真的绝望——比如在发生灾难性的磁盘故障且没有备份的情况下——你能做的也不多,除了希望专业服务能够“刮取盘片”。
4.2.12 特殊用途文件系统
并非所有的文件系统都代表物理媒体上的存储。大多数 Unix 版本都有作为系统接口的文件系统。也就是说,文件系统不仅仅是用来存储数据的工具,它还可以代表系统信息,如进程 ID 和内核诊断信息。这个概念追溯到/dev机制,这是早期通过文件作为 I/O 接口的模型。/proc的概念来自于第八版的研究 Unix,由 Tom J. Killian 实现,并在贝尔实验室(包括许多原始 Unix 设计者)创建 Plan 9 时得到了加速——这是一个将文件系统抽象提升到全新水平的研究操作系统 (en.wikipedia.org/wiki/Plan_9_from_Bell_Labs)。
在 Linux 上常见的一些特殊文件系统类型包括:
-
proc挂载在/proc上。proc是进程的缩写。/proc下的每个编号目录表示系统上当前进程的 ID;每个目录中的文件表示该进程的各种方面。目录/proc/self表示当前进程。Linux 的proc文件系统在像/proc/cpuinfo这样的文件中包含了大量额外的内核和硬件信息。请记住,内核设计指南建议将与进程无关的信息从/proc移动到/sys,因此/proc中的系统信息可能不是最当前的接口。 -
sysfs挂载在/sys上。(你在第三章已经看过这一点。) -
tmpfs挂载在/run和其他位置。使用tmpfs,您可以将物理内存和交换空间用作临时存储。您可以根据需要将tmpfs挂载到任何位置,并使用size和nr_blocks长选项控制最大大小。然而,请小心不要不断向tmpfs位置写入数据,因为您的系统最终会耗尽内存,程序将开始崩溃。 -
squashfs一种只读文件系统,其中内容以压缩格式存储,并通过回环设备按需提取。一个典型的应用是 snap 软件包管理系统,它将软件包挂载到/snap目录下。 -
overlay一种将目录合并为复合体的文件系统。容器通常使用 overlay 文件系统;您将在第十七章看到它们的工作原理。
4.3 交换空间
磁盘上的并非每个分区都包含文件系统。也可以使用磁盘空间扩展机器的 RAM。如果您的真实内存用尽,Linux 虚拟内存系统可以自动将内存的一部分转移到磁盘存储中。这被称为交换,因为空闲的程序部分被交换到磁盘上,以换取磁盘上的活动部分。用于存储内存页面的磁盘区域称为交换空间(或简称交换)。
free命令的输出包括当前的交换使用情况,单位为千字节,如下所示:
$ **free**
total used free
--`snip`--
Swap: 514072 189804 324268
4.3.1 使用磁盘分区作为交换空间
要使用整个磁盘分区作为交换空间,请按照以下步骤操作:
-
确保分区为空。
-
执行
mkswapdev命令,其中dev是分区的设备。此命令会在分区上放置一个交换签名,将其标记为交换空间(而非文件系统或其他用途)。 -
执行
swapondev命令将该空间注册到内核中。
创建交换分区后,您可以在/etc/fstab文件中添加一个新的交换条目,以便在机器启动时立即使用交换空间。以下是一个示例条目,使用/dev/sda5作为交换分区:
/dev/sda5 none swap sw 0 0
交换签名具有 UUID,因此请记住,现在许多系统使用 UUID 而非原始设备名称。
4.3.2 使用文件作为交换空间
如果您处于必须重新分区磁盘以创建交换分区的情况,您可以使用普通文件作为交换空间。这样做时,您不应遇到任何问题。
使用以下命令创建一个空文件,将其初始化为交换,并将其添加到交换池中:
# dd if=/dev/zero of=`swap_file` **bs=1024k count=**`num_mb`
# mkswap `swap_file`
# swapon `swap_file`
在这里,swap_file是新交换文件的名称,num_mb是所需的大小(单位为兆字节)。
要从内核的活动池中移除交换分区或文件,使用swapoff命令。您的系统必须有足够的剩余内存(物理内存和交换空间总和),以容纳您要移除的交换池部分中的任何活动页面。
4.3.3 确定您需要多少交换空间
曾几何时,Unix 的传统观念认为你应该为真实内存至少保留两倍的交换空间。今天,不仅是巨大的磁盘和内存容量让这个问题变得模糊,而且我们使用系统的方式也影响了这一点。一方面,磁盘空间如此充足,以至于人们有时会分配超过两倍内存大小的交换空间;另一方面,可能你永远都不会使用交换空间,因为你拥有如此多的真实内存。
“双倍真实内存”的规则起源于多用户登录同一台机器的时代。当时并非所有用户都在活跃,因此可以方便地将不活跃用户的内存交换出去,以便活跃用户能够使用更多内存。
对于单用户机器来说,这种情况可能仍然成立。如果你正在运行许多进程,通常交换掉不活跃进程的部分内容,甚至是活跃进程的某些不活跃部分也是可以的。然而,如果你频繁访问交换空间,因为许多活跃进程希望同时使用内存,那么你将遭遇严重的性能问题,因为磁盘 I/O(即使是 SSD 的 I/O)也无法跟上系统的其他部分。唯一的解决办法是购买更多内存、终止一些进程或抱怨。
有时,Linux 内核可能会选择交换出一个进程,以腾出更多磁盘缓存。为了防止这种行为,一些管理员会将某些系统配置为完全不使用交换空间。例如,高性能服务器绝不应该使用交换空间,并应尽量避免磁盘访问。
你将在第八章中学习更多关于内存系统如何工作的内容。
4.4 逻辑卷管理器
到目前为止,我们已经通过分区直接管理和使用磁盘,指定在存储设备上某些数据应存放的位置。你知道,访问像/dev/sda1这样的块设备会根据/dev/sda上的分区表将你引导到特定设备的某个位置,即使确切的位置可能由硬件决定。
这种方法通常效果良好,但它也有一些缺点,特别是在安装之后对磁盘进行更改时。例如,如果你想升级磁盘,你必须安装新磁盘、分区、添加文件系统、可能还需要做一些引导加载程序的更改以及其他任务,最后切换到新磁盘。这个过程容易出错,并且需要多次重启。当你想安装额外的磁盘以获得更多容量时,情况可能更糟——在这种情况下,你必须为该磁盘上的文件系统选择一个新的挂载点,并希望你能够手动将数据在旧磁盘和新磁盘之间分配。
LVM 通过在物理块设备和文件系统之间增加一层来解决这些问题。其思想是,你选择一组 物理卷(通常是块设备,如磁盘分区),将它们包含到 卷组 中,卷组充当一个类似于通用数据池的角色。然后,你可以从卷组中分割出 逻辑卷。
图 4-4 显示了这些如何在一个卷组中组合的示意图。该图展示了多个物理卷和逻辑卷,但许多基于 LVM 的系统通常只有一个物理卷和两个逻辑卷(分别用于根和交换分区)。

图 4-4:物理卷和逻辑卷如何在卷组中组合
逻辑卷仅仅是块设备,通常包含文件系统或交换分区标记,因此你可以将卷组和其逻辑卷之间的关系比作磁盘和其分区之间的关系。关键的不同之处在于,你通常不会定义逻辑卷在卷组中的布局—LVM 会自动处理这些问题。
LVM 允许一些强大且极为有用的操作,例如:
-
向卷组添加更多的物理卷(如另一个磁盘),以增加其大小。
-
在卷组中,移除物理卷,只要剩余的空间足以容纳现有的逻辑卷。
-
调整逻辑卷的大小(因此,也可以通过
fsadm工具调整文件系统的大小)。
你可以在不重启机器的情况下完成所有这些操作,在大多数情况下,也不需要卸载任何文件系统。虽然添加新的物理磁盘硬件可能需要关闭系统,但云计算环境通常允许你动态添加新的块存储设备,这使得 LVM 成为需要这种灵活性的系统的绝佳选择。
我们将适度详细地探讨 LVM。首先,我们将学习如何与逻辑卷及其组件交互并进行操作,然后我们将更深入地了解 LVM 的工作原理以及它所依赖的内核驱动程序。然而,这里的讨论并不完全必要,理解本书的其余部分,因此如果你觉得内容过于繁琐,可以随时跳到第五章。
4.4.2 使用 LVM 进行操作
LVM 提供了一些用于管理卷和卷组的用户空间工具。大多数工具都基于 lvm 命令,这是一个交互式的通用工具。还有一些独立的命令(这些命令实际上是 LVM 的符号链接)用于执行特定任务。例如,vgs 命令的效果与在 lvm> 提示符下输入 vgs 相同,你会发现 vgs(通常位于 /sbin 目录)是 lvm 的符号链接。在本书中,我们将使用这些独立的命令。
在接下来的几节中,我们将讨论使用逻辑卷的系统组件。第一个示例来自使用 LVM 分区选项的标准 Ubuntu 安装,因此许多名称将包含Ubuntu一词。然而,所有技术细节与该发行版无关。
列出和理解卷组
vgs命令显示当前在系统上配置的卷组。输出相当简洁。以下是我们示例 LVM 安装中的输出:
# vgs
VG #PV #LV #SN Attr VSize VFree
ubuntu-vg 1 2 0 wz--n- <10.00g 36.00m
第一行是标题,每一行表示一个卷组。列内容如下:
-
VG卷组名称。ubuntu-vg是 Ubuntu 安装程序在配置 LVM 系统时分配的通用名称。 -
#PV卷组存储所包含的物理卷数量。 -
#LV卷组内的逻辑卷数量。 -
#SN逻辑卷快照的数量。我们不会详细讲解这些。 -
Attr卷组的多个状态属性。这里,w(可写)、z(可调整大小)和n(正常分配策略)是活动状态。 -
VSize卷组的大小。 -
VFree卷组中未分配空间的大小。
这个卷组的概述足以满足大多数需求。如果你想更深入地了解卷组,可以使用vgdisplay命令,这对于了解卷组的属性非常有用。以下是使用vgdisplay查看同一卷组的输出:
# vgdisplay
--- Volume group ---
VG Name ubuntu-vg
System ID
Format lvm2
Metadata Areas 1
Metadata Sequence No 3
VG Access read/write
VG Status resizable
MAX LV 0
Cur LV 2
Open LV 2
Max PV 0
Cur PV 1
Act PV 1
VG Size <10.00 GiB
PE Size 4.00 MiB
Total PE 2559
Alloc PE / Size 2550 / 9.96 GiB
Free PE / Size 9 / 36.00 MiB
VG UUID 0zs0TV-wnT5-laOy-vJ0h-rUae-YPdv-pPwaAs
你之前见过其中的一些,但也有一些新的项目需要注意:
-
Open LV当前正在使用的逻辑卷数量。 -
Cur PV卷组包含的物理卷数量。 -
Act LV卷组中活动物理卷的数量。 -
VG UUID卷组的全局唯一标识符。系统上可能会有多个同名的卷组;在这种情况下,UUID 可以帮助你定位特定的卷组。大多数 LVM 工具(如vgrename,可以帮助你解决这种情况)接受 UUID 作为卷组名称的替代。请注意,你将看到许多不同的 UUID;LVM 的每个组件都有一个。
物理区(在vgdisplay输出中缩写为PE)是物理卷的一部分,类似于一个块,但规模要大得多。在这个示例中,PE 的大小为 4MB。你可以看到,这个卷组中大多数 PE 都在使用中,但这并不意味着有问题。这仅仅是卷组中分配给逻辑分区(在此案例中为文件系统和交换空间)的空间量;它并不反映文件系统中的实际使用情况。
列出逻辑卷
类似于卷组,列出逻辑卷的命令是lvs(简短列表)和lvdisplay(详细列表)。以下是lvs的示例:
# lvs
LV VG Attr LSize Pool Origin Data% Meta% Move Log Cpy%Sync Convert
root ubuntu-vg -wi-ao---- <9.01g
swap_1 ubuntu-vg -wi-ao---- 976.00m
在基础 LVM 配置中,只有前四列对理解是重要的,其余列可能为空,如这里所示(我们不讨论那些)。这里相关的列是:
-
LV逻辑卷名称。 -
VG逻辑卷所在的卷组。 -
Attr逻辑卷的属性。这里它们是w(可写)、i(继承的分配策略)、a(活动)、o(打开)。在更高级的卷组配置中,这些槽位中的更多项是激活的,特别是第一、七和第九项。 -
LSize逻辑卷的大小。
运行更详细的lvdisplay有助于揭示逻辑卷在系统中的位置。以下是我们某个逻辑卷的输出:
# lvdisplay /dev/ubuntu-vg/root
--- Logical volume ---
LV Path /dev/ubuntu-vg/root
LV Name root
VG Name ubuntu-vg
LV UUID CELZaz-PWr3-tr3z-dA3P-syC7-KWsT-4YiUW2
LV Write Access read/write
LV Creation host, time ubuntu, 2018-11-13 15:48:20 -0500
LV Status available
# open 1
LV Size <9.01 GiB
Current LE 2306
Segments 1
Allocation inherit
Read ahead sectors auto
- currently set to 256
Block device 253:0
这里有很多有趣的内容,其中大多数都比较自解释(请注意,逻辑卷的 UUID 与其卷组的 UUID 不同)。也许你还没有看到的最重要的内容是:LV Path,即逻辑卷的设备路径。一些系统,但不是所有系统,都将其用作文件系统或交换空间的挂载点(在 systemd 挂载单元或/etc/fstab中)。
即使你可以看到逻辑卷的块设备的主设备号和次设备号(这里是 253 和 0),以及看起来像设备路径的东西,实际上这并不是内核使用的路径。快速查看/dev/ubuntu-vg/root会发现其实有其他事情在发生:
$ **ls -l /dev/ubuntu-vg/root**
lrwxrwxrwx 1 root root 7 Nov 14 06:58 /dev/ubuntu-vg/root -> ../dm-0
正如你所看到的,这只是一个指向/dev/dm-0的符号链接。我们来简要看一下。
使用逻辑卷设备
一旦 LVM 在系统中完成设置,逻辑卷块设备就会出现在/dev/dm-0、/dev/dm-1等位置,并且可以以任何顺序排列。由于这些设备名称的不确定性,LVM 还会创建指向设备的符号链接,这些链接基于卷组和逻辑卷名称,具有稳定的名称。你在前一节中已经看到了这个,/dev/ubuntu-vg/root。
在大多数实现中,还有一个符号链接的额外位置:/dev/mapper。这里的名称格式也基于卷组和逻辑卷,但没有目录层级;相反,链接的名称像是ubuntu--vg-root。在这里,udev 将卷组中的单个破折号转化为双破折号,并用单个破折号将卷组和逻辑卷的名称分开。
许多系统在它们的/etc/fstab、systemd 和引导加载器配置中使用/dev/mapper中的链接,以便将系统指向用于文件系统和交换空间的逻辑卷。
无论如何,这些符号链接指向逻辑卷的块设备,你可以像操作其他块设备一样与它们交互:创建文件系统,创建交换分区等等。
使用物理卷
LVM 中需要检查的最后一个主要部分是 物理卷(PV)。卷组是由一个或多个 PV 构建的。虽然 PV 可能看起来是 LVM 系统中的一个简单组成部分,但它包含的信息比眼睛所见的要多。像卷组和逻辑卷一样,查看 PV 的 LVM 命令是 pvs(用于简短列表)和 pvdisplay(用于更深入查看)。这是我们示例系统的 pvs 显示:
# pvs
PV VG Fmt Attr PSize PFree
/dev/sda1 ubuntu-vg lvm2 a-- <10.00g 36.00m
这里是 pvdisplay:
# pvdisplay
--- Physical volume ---
PV Name /dev/sda1
VG Name ubuntu-vg
PV Size <10.00 GiB / not usable 2.00 MiB
Allocatable yes
PE Size 4.00 MiB
Total PE 2559
Free PE 9
Allocated PE 2550
PV UUID v2Qb1A-XC2e-2G4l-NdgJ-lnan-rjm5-47eMe5
从之前对卷组和逻辑卷的讨论中,你应该能理解大部分输出内容。这里有一些注释:
-
PV 除了块设备外没有特别的名称。没有必要使用特殊名称——所有引用逻辑卷所需的名称都在卷组层级及其以上。然而,PV 确实有一个 UUID,这是构建卷组所必需的。
-
在这种情况下,PE 的数量与卷组中的使用情况匹配(我们之前看到过),因为这是该组中唯一的 PV。
-
有一小部分空间被 LVM 标记为不可用,因为它不足以填充一个完整的 PE。
-
pvs输出中的a对应于pvdisplay输出中的Allocatable,它仅仅意味着,如果你想在卷组中为逻辑卷分配空间,LVM 可以选择使用这个 PV。然而,在这种情况下,只有九个未分配的 PEs(总共 36MB),所以没有多少空间可以用于新逻辑卷。
正如前面提到的,PV 不仅包含有关其对卷组贡献的个人信息。每个 PV 都包含 物理卷元数据,即有关其卷组及其逻辑卷的详细信息。我们稍后将深入探讨 PV 元数据,但首先让我们通过实际操作,看看我们学到的内容是如何结合在一起的。
构建逻辑卷系统
让我们看一个如何通过两个磁盘设备创建新的卷组和一些逻辑卷的示例。我们将两个 5GB 和 15GB 的磁盘设备组合成一个卷组,然后将这个空间划分为两个各 10GB 的逻辑卷——如果没有 LVM,这几乎是不可能完成的任务。这里展示的示例使用了 VirtualBox 磁盘。虽然在当代系统中这些容量相当小,但足以用于演示。
图 4-5 显示了卷的示意图。新磁盘位于 /dev/sdb 和 /dev/sdc,新卷组将命名为 myvg,两个新的逻辑卷分别命名为 mylv1 和 mylv2。

图 4-5:构建逻辑卷系统
第一个任务是在这些磁盘上创建一个分区,并将其标记为 LVM。使用分区程序(参见第 4.1.2 节)完成此操作,使用分区类型 ID 8e,这样分区表看起来如下:
# parted /dev/sdb print
Model: ATA VBOX HARDDISK (scsi)
Disk /dev/sdb: 5616MB
Sector size (logical/physical): 512B/512B
Partition Table: msdos
Disk Flags:
Number Start End Size Type File system Flags
1 1049kB 5616MB 5615MB primary lvm
# parted /dev/sdc print
Model: ATA VBOX HARDDISK (scsi)
Disk /dev/sdc: 16.0GB
Sector size (logical/physical): 512B/512B
Partition Table: msdos
Disk Flags:
Number Start End Size Type File system Flags
1 1049kB 16.0GB 16.0GB primary lvm
您不一定需要对磁盘进行分区以使其成为 PV。PV 可以是任何块设备,甚至是整个磁盘设备,例如/dev/sdb。但是,分区可以启动磁盘,并且还提供了一种标识块设备作为 LVM 物理卷的手段。
创建物理卷和卷组
拥有新的/dev/sdb1和/dev/sdc1分区后,LVM 的第一步是将其中一个分区指定为 PV,并将其分配给一个新的卷组。单个命令vgcreate完成此任务。以下是如何使用/dev/sdb1创建名为myvg的卷组的示例:
# vgcreate myvg /dev/sdb1
Physical volume "/dev/sdb1" successfully created.
Volume group "myvg" successfully created
此时,大多数系统会自动检测到新的卷组;运行vgs之类的命令来验证(请记住,您的系统可能有现有的卷组,除了您刚创建的卷组之外还会显示出来):
# vgs
VG #PV #LV #SN Attr VSize VFree
myvg 1 0 0 wz--n- <5.23g <5.23g
现在可以使用vgextend命令将第二个 PV dev/sdc1 添加到卷组中:
# vgextend myvg /dev/sdc1
Physical volume "/dev/sdc1" successfully created.
Volume group "myvg" successfully extended
现在运行vgs会显示两个 PV,并且大小是两个分区组合后的大小:
# vgs
VG #PV #LV #SN Attr VSize VFree
my-vg 2 0 0 wz--n- <20.16g <20.16g
创建逻辑卷
在块设备级别的最后一步是创建逻辑卷。如前所述,我们将创建两个大小均为 10GB 的逻辑卷,但可以尝试其他可能性,如一个大的逻辑卷或多个较小的逻辑卷。
lvcreate命令在卷组中分配一个新的逻辑卷。创建简单逻辑卷的唯一复杂之处在于在一个卷组中有多个逻辑卷时确定大小,并指定逻辑卷的类型。请记住,PV 被分成 extent;可用的 PE 数量可能不会完全符合您的期望大小。不过,应该足够接近,以至于不会引起担忧,因此,如果这是您第一次使用 LVM,您实际上不需要关注 PE。
使用lvcreate时,可以使用--size选项按字节指定逻辑卷的大小,也可以使用--extents选项按 PE 的数量指定大小。
为了查看它是如何工作的,并完成图 4-5 中的 LVM 示意图,我们将使用--size创建名为mylv1和mylv2的逻辑卷:
# lvcreate --size 10g --type linear -n mylv1 myvg
Logical volume "mylv1" created.
# lvcreate --size 10g --type linear -n mylv2 myvg
Logical volume "mylv2" created.
此处的类型是线性映射,当您不需要冗余或任何其他特殊功能时(我们不会在本书中使用其他类型)。在这种情况下,--type linear是可选的,因为它是默认映射。
运行这些命令后,请使用lvs命令验证逻辑卷是否存在,然后使用vgdisplay查看卷组的当前状态:
# vgdisplay myvg
--- Volume group ---
VG Name myvg
System ID
Format lvm2
Metadata Areas 2
Metadata Sequence No 4
VG Access read/write
VG Status resizable
MAX LV 0
Cur LV 2
Open LV 0
Max PV 0
Cur PV 2
Act PV 2
VG Size 20.16 GiB
PE Size 4.00 MiB
Total PE 5162
Alloc PE / Size 5120 / 20.00 GiB
Free PE / Size 42 / 168.00 MiB
VG UUID 1pHrOe-e5zy-TUtK-5gnN-SpDY-shM8-Cbokf3
注意有 42 个空闲 PE,因为我们选择的逻辑卷大小没有完全使用卷组中所有可用的 extent。
操作逻辑卷:创建分区
拥有了新的逻辑卷后,你现在可以通过在设备上创建文件系统并像操作普通磁盘分区一样挂载它们来使用它们。如前所述,设备会在/dev/mapper中创建符号链接,并且(在这个例子中)会为卷组创建一个/dev/myvg目录。因此,举个例子,你可以运行以下三个命令来创建文件系统、临时挂载它,并查看逻辑卷上的实际可用空间:
# mkfs -t ext4 /dev/mapper/myvg-mylv1
mke2fs 1.44.1 (24-Mar-2018)
Creating filesystem with 2621440 4k blocks and 655360 inodes
Filesystem UUID: 83cc4119-625c-49d1-88c4-e2359a15a887
Superblock backups stored on blocks:
32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632
Allocating group tables: done
Writing inode tables: done
Creating journal (16384 blocks): done
Writing superblocks and filesystem accounting information: done
# mount /dev/mapper/myvg-mylv1 /mnt
# df /mnt
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/mapper/myvg-mylv1 10255636 36888 9678076 1% /mnt
移除逻辑卷
我们还没有涉及对另一个逻辑卷mylv2的操作,所以我们可以用它来使这个例子更有趣。假设你发现自己并没有真正使用第二个逻辑卷。你决定移除它,并调整第一个逻辑卷的大小,以占用卷组中剩余的空间。图 4-6 显示了我们的目标。
假设你已经移动或备份了要删除的逻辑卷上的任何重要数据,并且该卷没有在当前系统中使用(即你已将其卸载),首先使用lvremove命令将其删除。在使用此命令操作逻辑卷时,你需要使用不同的语法——通过斜杠(myvg/mylv2)来分隔卷组和逻辑卷的名称:
# lvremove myvg/mylv2
Do you really want to remove and DISCARD active logical volume myvg/mylv2? [y/n]: **y**
Logical volume "mylv2" successfully removed

图 4-6:重新配置逻辑卷的结果
正如你从这个交互中看到的那样,lvremove会通过再次确认你是否真的要删除每个目标逻辑卷,来帮助你避免犯错。它也不会尝试删除正在使用的卷。但不要仅仅假设你应该对任何问题都回答y。
调整逻辑卷和文件系统的大小
现在你可以调整第一个逻辑卷mylv1的大小。即使该卷正在使用且其文件系统已挂载,你仍然可以进行此操作。然而,重要的是要理解,这个过程有两个步骤。为了使用更大的逻辑卷,你需要同时调整它和其内部的文件系统大小(即使文件系统已挂载,你也可以调整)。不过,由于这是一个非常常见的操作,lvresize命令提供了一个选项(-r),可以在调整逻辑卷大小的同时,也帮你调整文件系统的大小。
为了说明问题,我们将使用两个独立的命令来看这个过程是如何工作的。调整逻辑卷大小有几种方法,但在这种情况下,最简单的办法是将卷组中所有空闲的 PE(物理扩展)添加到逻辑卷中。回忆一下,你可以通过vgdisplay命令查找这个数值;在我们当前的示例中,它是 2,602。以下是将所有这些 PE 添加到mylv1的lvresize命令:
# lvresize -l +2602 myvg/mylv1
Size of logical volume myvg/mylv1 changed from 10.00 GiB (2560 extents) to 20.16 GiB (5162 extents).
Logical volume myvg/mylv1 successfully resized.
现在你需要调整内部文件系统的大小。你可以使用fsadm命令来实现。你可以通过开启详细模式(使用-v选项)来观察它的工作过程:
# fsadm -v resize /dev/mapper/myvg-mylv1
fsadm: "ext4" filesystem found on "/dev/mapper/myvg-mylv1".
fsadm: Device "/dev/mapper/myvg-mylv1" size is 21650997248 bytes
fsadm: Parsing tune2fs -l "/dev/mapper/myvg-mylv1"
fsadm: Resizing filesystem on device "/dev/mapper/myvg-mylv1" to 21650997248 bytes (2621440 -> 5285888 blocks of 4096 bytes)
fsadm: Executing resize2fs /dev/mapper/myvg-mylv1 5285888
resize2fs 1.44.1 (24-Mar-2018)
Filesystem at /dev/mapper/myvg-mylv1 is mounted on /mnt; on-line resizing required
old_desc_blocks = 2, new_desc_blocks = 3
The filesystem on /dev/mapper/myvg-mylv1 is now 5285888 (4k) blocks long.
从输出中可以看到,fsadm只是一个脚本,它知道如何将参数转化为文件系统特定工具(如resize2fs)使用的参数。默认情况下,如果你没有指定大小,它会自动调整为适合整个设备。
现在你已经看到了调整卷大小的细节,可能在寻找捷径。一个更简单的方法是使用不同的语法来指定大小,并让lvresize通过这一条命令为你执行分区调整:
# lvresize -r -l +100%FREE myvg/mylv1
能够在挂载的状态下扩展 ext2/ext3/ext4 文件系统是相当不错的。不幸的是,它不能反向操作。你不能在文件系统挂载时进行缩小。你不仅必须卸载文件系统,而且缩小逻辑卷的过程需要你倒着进行。所以,在手动调整大小时,你需要先调整分区的大小,再调整逻辑卷,确保新的逻辑卷仍然足够大以容纳文件系统。同样,使用带有-r选项的lvresize来协调文件系统和逻辑卷的大小要容易得多。
4.4.3 LVM 的实现
在讲解了 LVM 的更多实用操作基础之后,我们现在可以简要地了解它的实现方式。和书中的几乎所有其他话题一样,LVM 包含多个层次和组件,在内核空间和用户空间之间有着相当谨慎的划分。
正如你将很快看到的,找到物理卷(PVs)来发现卷组和逻辑卷的结构有点复杂,Linux 内核更愿意不去处理这一切。其实没有理由让这些操作发生在内核空间;物理卷只是块设备,用户空间对块设备有随机访问的权限。实际上,LVM(更准确地说,当前系统中的 LVM2)本身只是一个用户空间工具的集合,它们知道 LVM 的结构。
另一方面,内核负责将对逻辑卷块设备位置的请求路由到实际设备的真实位置。执行这项任务的是设备映射器(有时缩写为devmapper),这是一个位于普通块设备和文件系统之间的新层。顾名思义,设备映射器执行的任务就像是跟随地图;你可以把它几乎当作是将街道地址转换为像全球纬度/经度坐标那样的绝对位置。(这是一种虚拟化形式;我们在本书的其他地方看到的虚拟内存概念与此类似。)
LVM 用户空间工具和设备映射器之间有一些连接:一些在用户空间运行的工具,用于管理内核中的设备映射。让我们从 LVM 方面和内核方面一起看,首先从 LVM 开始。
LVM 工具和物理卷扫描
在执行任何操作之前,LVM 工具必须首先扫描可用的块设备以寻找物理卷。LVM 在用户空间中必须执行的步骤大致如下:
-
查找系统上所有的物理卷。
-
通过 UUID 查找所有物理卷所属的卷组(这些信息包含在物理卷中)。
-
验证所有内容是否完整(即所有属于卷组的必需物理卷都已存在)。
-
查找卷组中的所有逻辑卷。
-
确定从物理卷到逻辑卷的数据映射方案。
每个物理卷的开头都有一个头部,标识该卷以及它的卷组和逻辑卷。LVM 工具可以将这些信息组合起来,确定卷组所需的所有物理卷(及其逻辑卷)是否都存在。如果一切正常,LVM 将能够将信息传递给内核。
任何 LVM 工具,例如pvscan、lvs或vgcreate,都能够执行扫描和处理物理卷的工作。
设备映射器
在 LVM 确定了所有物理卷头部中的逻辑卷结构后,它会与内核的设备映射器驱动程序进行通信,以初始化逻辑卷的块设备并加载其映射表。它通过在/dev/mapper/control设备文件上的 ioctl(2)系统调用来实现这一点(这是一个常用的内核接口)。尽管实际上很难监控这一交互,但可以通过dmsetup命令查看结果的详细信息。
要查看当前设备映射器服务的已映射设备的清单,请使用dmsetup info。以下是你可能会得到的一个逻辑卷的输出,这个逻辑卷是在本章前面创建的:
# dmsetup info
Name: myvg-mylv1
State: ACTIVE
Read Ahead: 256
Tables present: LIVE
Open count: 0
Event number: 0
Major, minor: 253, 1
Number of targets: 2
UUID: LVM-1pHrOee5zyTUtK5gnNSpDYshM8Cbokf3OfwX4T0w2XncjGrwct7nwGhpp7l7J5aQ
设备的主设备号和次设备号对应于已映射设备的/dev/dm-**设备文件;该设备映射器的主设备号是 253。由于次设备号为 1,因此设备文件名为/dev/dm-1*。注意,内核有一个名称以及映射设备的另一个 UUID。LVM 将这些信息提供给内核(内核 UUID 是卷组和逻辑卷 UUID 的拼接)。
你也可以通过执行命令dmsetup table来查看 LVM 提供给设备映射器的表。以下是我们之前示例的输出,当时有两个 10GB 的逻辑卷(mylv1和mylv2),它们分布在两个物理卷上,分别为 5GB(/dev/sdb1)和 15GB(/dev/sdc1):
# dmsetup table
myvg-mylv2: 0 10960896 linear 8:17 2048
myvg-mylv2: 10960896 10010624 linear 8:33 20973568
myvg-mylv1: 0 20971520 linear 8:33 2048
每一行提供一个已映射设备的映射片段。对于设备myvg-mylv2,有两个片段,而对于myvg-mylv1,只有一个片段。名称后的字段依次为:
-
映射设备的起始偏移量。单位是 512 字节的“扇区”,这是许多其他设备中常见的标准块大小。
-
该片段的长度。
-
映射方案。这里是简单的一对一线性映射方案。
-
源设备的主设备号和次设备号对——也就是 LVM 所称的物理卷。这里 8:17 是/dev/sdb1,8:33 是/dev/sdc1。
-
源设备上的起始偏移量。
有趣的是,在我们的例子中,LVM 选择在/dev/sdc1上使用空间为我们创建的第一个逻辑卷(mylv1)。LVM 决定要以连续的方式布局前 10GB 的逻辑卷,而做到这一点的唯一方法就是在/dev/sdc1上。然而,在创建第二个逻辑卷(mylv2)时,LVM 别无选择,只能将其分布到两个 PV 的两个分段中。图 4-7 展示了这种排列。

图 4-7:LVM 如何安排mylv1和mylv2
作为进一步的结果,当我们移除mylv2并扩展mylv1以适应卷组中剩余的空间时,原始的启动偏移量仍然保持在/dev/sdc1上,但其他所有内容都发生了变化,包含了剩余的 PV 部分:
# dmsetup table
myvg-mylv1: 0 31326208 linear 8:33 2048
myvg-mylv1: 31326208 10960896 linear 8:17 2048
图 4-8 展示了这种排列。

图 4-8:移除mylv2并扩展mylv1后的布局
你可以在虚拟机中尽情地实验逻辑卷和设备映射,看看映射结果如何。许多功能,如软件 RAID 和加密磁盘,都是建立在设备映射器上的。
4.5 展望未来:磁盘和用户空间
在 Unix 系统的与磁盘相关的组件中,用户空间和内核之间的边界可能很难界定。正如你所看到的,内核处理来自设备的原始块 I/O,而用户空间工具则通过设备文件使用这些块 I/O。然而,用户空间通常仅在初始化操作时使用块 I/O,例如分区、文件系统创建和交换空间创建。在正常使用中,用户空间仅使用内核在块 I/O 之上提供的文件系统支持。同样,内核在处理虚拟内存系统中的交换空间时,也处理大部分繁琐的细节。
本章的其余部分简要探讨 Linux 文件系统的内部结构。这是更高级的内容,你完全不需要了解它就可以继续阅读本书。如果这是你第一次阅读,跳过到下一章,开始学习 Linux 是如何启动的。
4.6 传统文件系统内部
一个传统的 Unix 文件系统有两个主要组件:一个用于存储数据的数据块池和一个管理数据池的数据库系统。数据库围绕着 inode 数据结构展开。inode是一组描述特定文件的数据,包含其类型、权限,以及—或许最重要的是—文件数据在数据池中的存储位置。inodes 通过 inode 表中的数字来标识。
文件名和目录也作为 inodes 实现。一个目录 inode 包含一个文件名列表和指向其他 inodes 的链接。
为了提供一个现实的例子,我创建了一个新的文件系统,挂载它并切换到挂载点目录。然后,我通过以下命令添加了一些文件和目录:
$ **mkdir dir_1**
$ **mkdir dir_2**
$ **echo a > dir_1/file_1**
$ **echo b > dir_1/file_2**
$ **echo c > dir_1/file_3**
$ **echo d > dir_2/file_4**
$ **ln dir_1/file_3 dir_2/file_5**
请注意,我创建了 dir_2/file_5,它是 dir_1/file_3 的硬链接,这意味着这两个文件名实际上代表同一个文件(稍后会详细介绍)。你也可以尝试一下,不一定要在新的文件系统上进行。
如果你探索这个文件系统中的目录,其内容将如 图 4-9 所示。

图 4-9:文件系统的用户级表示
文件系统的实际布局作为一组 inode,如 图 4-10 所示,并不像用户级表示那样整洁。

图 4-10:显示在 图 4-9 中的文件系统的 inode 结构
我们如何理解这一点呢?对于任何 ext2/3/4 文件系统,你从 inode 编号 2 开始,它是 根 inode(不要把它与系统根文件系统混淆)。从 图 4-10 中的 inode 表,你可以看到它是一个目录 inode (dir),因此可以跟随箭头到达数据池,在那里你会看到根目录的内容:两个条目,分别是名为 dir_1 和 dir_2 的目录,它们对应着 inode 12 和 7633。要查看这些条目的内容,可以返回 inode 表并查看这两个 inode 中的任何一个。
要在这个文件系统中查看 dir_1/file_2,内核执行以下操作:
-
确定路径的各个组成部分:一个名为 dir_1 的目录,后面是一个名为 file_2 的组件。
-
跟随根 inode 到其目录数据。
-
在 inode 2 的目录数据中找到名称 dir_1,它指向 inode 编号 12。
-
在 inode 表中查找 inode 12,并验证它是一个目录 inode。
-
跟随 inode 12 的数据链接到它的目录信息(数据池中的第二个框)。
-
在 inode 12 的目录数据中找到路径的第二个组件(file_2)。这个条目指向 inode 编号 14。
-
查找目录表中的 inode 14。这是一个文件 inode。
此时,内核已经知道文件的属性,并可以通过跟随 inode 14 的数据链接来打开它。
这种系统,使用 inode 指向目录数据结构,目录数据结构指向 inode,使得你能够创建你所熟悉的文件系统层级结构。此外,请注意,目录 inode 包含 .(当前目录)和 ..(父目录,根目录除外)条目。这使得获取参考点并向下导航文件系统结构变得容易。
4.6.1 Inode 详细信息与链接计数
要查看任何目录的 inode 编号,可以使用 ls -i 命令。在此示例的根目录下,你会得到如下输出(要查看更详细的 inode 信息,可以使用 stat 命令):
$ **ls -i**
12 dir_1 7633 dir_2
你可能在想 inode 表中的链接计数。你已经在常见的ls -l命令输出中看过链接计数,但你可能忽略了它。链接计数如何与图 4-9 中的文件相关,特别是“硬链接”的file_5?链接计数字段是指向一个 inode 的所有目录条目的总数(跨越所有目录)。大多数文件的链接计数为 1,因为它们只出现在目录条目中一次。这是预期的。通常情况下,当你创建一个文件时,你会创建一个新的目录条目和一个新的 inode。然而,inode 15 出现了两次。首先,它被创建为dir_1/file_3,然后它被链接为dir_2/file_5。硬链接就是手动在目录中创建一个指向已存在 inode 的条目。ln命令(不带-s选项)允许你手动创建新的硬链接。
这也是为什么删除文件有时被称为取消链接。如果你运行rm dir_1/file_2,内核会在 inode 12 的目录条目中搜索名为file_2的条目。在找到file_2对应 inode 14 后,内核会删除该目录条目,然后将 inode 14 的链接计数减 1。因此,inode 14 的链接计数将为 0,内核会知道没有任何名称再指向该 inode。因此,内核可以删除该 inode 及其相关数据。
然而,如果你运行rm dir_1/file_3,最终结果是 inode 15 的链接计数从 2 变为 1(因为dir_2/file_5仍然指向那里),内核知道不需要删除该 inode。
对于目录,链接计数的工作方式也差不多。请注意,inode 12 的链接计数为 2,因为那里有两个 inode 链接:一个是dir_1在 inode 2 的目录条目中的链接,另一个是它自己目录条目中的自引用(.)。如果你创建一个新目录dir_1/dir_3,inode 12 的链接计数将增加到 3,因为新目录将包括一个指向 inode 12 的父目录(..)条目,就像 inode 12 的父链接指向 inode 2 一样。
链接计数有一个小例外。根 inode 2 的链接计数为 4。然而,图 4-10 只显示了三个目录条目链接。这个“第四个”链接位于文件系统的超级块中,因为超级块告诉你在哪里找到根 inode。
不要害怕在你的系统上进行实验。创建一个目录结构,然后使用ls -i或stat逐步检查每个部分是无害的。你不需要是 root(除非你挂载并创建一个新的文件系统)。
4.6.2 块分配
还有一个问题我们还没有讨论。为新文件分配数据池块时,文件系统如何知道哪些块已被使用,哪些是可用的?最基本的方法之一是使用一个额外的管理数据结构,叫做 块位图。在这种方案中,文件系统保留一系列字节,每个位对应数据池中的一个块。0 表示该块是空闲的,1 表示该块正在使用。因此,分配和释放块只需翻转位即可。
当文件系统出现问题时,通常是因为 inode 表数据与块分配数据不匹配,或者链接计数不正确;例如,当你没有正常关闭系统时,就有可能发生这种情况。因此,在检查文件系统时,如 4.2.11 节所述,fsck 程序会遍历 inode 表和目录结构,生成新的链接计数和新的块分配图(例如块位图),然后将新生成的数据与磁盘上的文件系统进行比较。如果存在不匹配,fsck 必须修复链接计数,并确定如何处理在遍历目录结构时没有找到的 inode 和/或数据。大多数 fsck 程序会将这些“孤儿”文件放入文件系统的 lost+found 目录中。
4.6.3 在用户空间中处理文件系统
在用户空间操作文件和目录时,你不需要太担心它们下面的实现细节。进程通过内核系统调用访问已挂载文件系统中的文件和目录内容。值得注意的是,你确实可以访问一些似乎不属于用户空间的文件系统信息——尤其是,stat() 系统调用返回 inode 编号和链接计数。
当你不再维护一个文件系统时,是否需要担心 inode 编号、链接计数和其他实现细节?通常不需要。这些内容对用户模式程序的可访问性主要是为了向后兼容。此外,并不是 Linux 中所有的文件系统都有这些文件系统内部结构。VFS 接口层确保系统调用始终返回 inode 编号和链接计数,但这些数字不一定具有实际意义。
在非传统文件系统上,你可能无法执行传统的 Unix 文件系统操作。例如,你不能在已挂载的 VFAT 文件系统上使用 ln 创建硬链接,因为它的目录条目结构是为 Windows 设计的,而不是为 Unix/Linux 设计的,因此不支持这个概念。
幸运的是,Linux 系统提供给用户空间的系统调用提供了足够的抽象,便于无痛的文件访问——你无需了解底层实现即可访问文件。此外,文件名格式灵活,支持大小写混合的文件名,使得支持其他层次化文件系统变得更加容易。
记住,特定的文件系统支持不一定需要在内核中。例如,在用户空间文件系统中,内核只需要充当系统调用的中介。
第五章:Linux 内核如何引导

你现在了解了 Linux 系统的物理和逻辑结构,了解了内核是什么以及如何处理进程。本章将教你内核是如何启动的,或者说是如何引导的。换句话说,你将学习内核如何进入内存,并且它在第一个用户进程启动之前做了什么。
引导过程的简化视图如下所示:
-
机器的 BIOS 或引导固件加载并运行引导加载程序。
-
引导加载程序找到磁盘上的内核镜像,加载到内存中并启动它。
-
内核初始化设备及其驱动程序。
-
内核挂载根文件系统。
-
内核启动一个名为 init 的程序,进程 ID 为 1。这个点就是 用户空间启动。
-
init 启动其余系统进程。
-
在某个时刻,init 启动一个进程,允许你登录,通常是在引导过程的末尾或接近末尾时。
本章介绍了引导过程的前几个阶段,重点讲解引导加载程序和内核。第六章继续讲解用户空间的启动,详细介绍了 systemd,这是 Linux 系统上最广泛使用的 init 版本。
能够识别引导过程的每个阶段,对解决引导问题和理解系统整体结构是非常宝贵的。然而,许多 Linux 发行版的默认行为常常使得在引导过程中难以甚至无法识别最初的几个引导阶段,因此你可能只能在它们完成并且你登录后才能清楚看到。
5.1 启动消息
传统的 Unix 系统在引导时会产生许多诊断消息,告诉你引导过程的情况。这些消息首先来自内核,然后来自 init 启动的进程和初始化程序。然而,这些消息既不美观也不一致,在某些情况下甚至不太有用。此外,硬件的改进使得内核启动比以前更快;消息快速闪过,可能让你很难看清发生了什么。因此,大多数当前的 Linux 发行版都会尽量隐藏引导诊断信息,通过启动画面和其他形式的填充来分散你的注意力。
查看内核引导和运行时诊断信息的最佳方式是通过 journalctl 命令检索内核的日志。运行 journalctl -k 可以显示当前引导的消息,但你也可以使用 -b 选项查看以前的引导。我们将在第七章详细介绍日志。
如果你没有 systemd,你可以检查类似 /var/log/kern.log 的日志文件,或者运行 dmesg 命令查看 内核环形缓冲区 中的消息。
这是你可以从 journalctl -k 命令中看到的一个示例:
microcode: microcode updated early to revision 0xd6, date = 2019-10-03
Linux version 4.15.0-112-generic (buildd@lcy01-amd64-027) (gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)) #113-Ubuntu SMP Thu Jul 9 23:41:39 UTC 2020 (Ubuntu 4.15.0-112.113-generic 4.15.18)
Command line: BOOT_IMAGE=/boot/vmlinuz-4.15.0-112-generic root=UUID=17f12d53-c3d7-4ab3-943e-a0a72366c9fa ro quiet splash vt.handoff=1
KERNEL supported cpus:
--`snip`--
scsi 2:0:0:0: Direct-Access ATA KINGSTON SM2280S 01.R PQ: 0 ANSI: 5
sd 2:0:0:0: Attached scsi generic sg0 type 0
sd 2:0:0:0: [sda] 468862128 512-byte logical blocks: (240 GB/224 GiB)
sd 2:0:0:0: [sda] Write Protect is off
sd 2:0:0:0: [sda] Mode Sense: 00 3a 00 00
sd 2:0:0:0: [sda] Write cache: enabled, read cache: enabled, doesn't support DPO or FUA
sda: sda1 sda2 < sda5 >
sd 2:0:0:0: [sda] Attached SCSI disk
--`snip`--
在内核启动后,用户空间启动过程通常会生成一些消息。由于在大多数系统上你不会在单一的日志文件中找到这些消息,它们可能更难查看和回顾。启动脚本被设计为将消息发送到控制台,消息会在引导过程完成后被清除。然而,这在 Linux 系统上并不是问题,因为 systemd 会捕获启动和运行时的诊断消息,这些消息通常会发送到控制台。
5.2 内核初始化和启动选项
在启动时,Linux 内核按照以下一般顺序初始化:
-
CPU 检查
-
内存检查
-
设备总线发现
-
设备发现
-
辅助内核子系统设置(如网络)
-
根文件系统挂载
-
用户空间启动
前两个步骤并不特别引人注目,但当内核处理设备时,就会出现依赖性的问题。例如,磁盘设备驱动程序可能依赖于总线支持和 SCSI 子系统支持,正如你在第三章中看到的那样。然后,在初始化过程的后期,内核必须挂载根文件系统,然后才能启动 init。
一般来说,你不需要担心依赖关系,除了某些必要的组件可能是可加载的内核模块,而不是主内核的一部分。一些机器可能需要在真正的根文件系统挂载之前加载这些内核模块。我们将在第 6.7 节中讨论这个问题及其初始 RAM 文件系统(initrd)解决方法。
内核发出某些类型的消息,表示它准备开始启动第一个用户进程:
Freeing unused kernel memory: 2408K
Write protecting the kernel read-only data: 20480k
Freeing unused kernel memory: 2008K
Freeing unused kernel memory: 1892K
在这里,内核不仅清理了一些未使用的内存,还保护了自己的数据。然后,如果你运行的是较新的内核,你将看到内核启动第一个用户空间进程——init:
Run /init as init process
with arguments:
--`snip`--
稍后,你应该能够看到根文件系统被挂载,并且 systemd 开始启动,将一些自己的消息发送到内核日志:
EXT4-fs (sda1): mounted filesystem with ordered data mode. Opts: (null)
systemd[1]: systemd 237 running in system mode. (+PAM +AUDIT +SELINUX +IMA +APPARMOR +SMACK +SYSVINIT +UTMP +LIBCRYPTSETUP +GCRYPT +GNUTLS +ACL +XZ +LZ4 +SECCOMP +BLKID +ELFUTILS +KMOD -IDN2 +IDN -PCRE2 default-hierarchy=hybrid)
systemd[1]: Detected architecture x86-64.
systemd[1]: Set hostname to <duplex>.
此时,你肯定知道用户空间已经启动。
5.3 内核参数
当 Linux 内核启动时,它会接收到一组基于文本的内核参数,其中包含一些额外的系统详细信息。这些参数指定了许多不同类型的行为,例如内核应该生成的诊断输出量和设备驱动程序特定的选项。
你可以通过查看/proc/cmdline文件来查看传递给当前运行内核的参数:
$ **cat /proc/cmdline**
BOOT_IMAGE=/boot/vmlinuz-4.15.0-43-generic root=UUID=17f12d53-c3d7-4ab3-943e-a0a72366c9fa ro quiet splash vt.handoff=1
这些参数可以是简单的单词标志,如ro和quiet,也可以是key``=``value对,如vt.handoff=1。许多参数并不重要,例如用于显示启动画面的splash标志,但一个关键的参数是root参数。这个参数是根文件系统的位置;没有它,内核无法正确执行用户空间启动。
根文件系统可以指定为设备文件,如下例所示:
root=/dev/sda1
在大多数现代系统中,有两种更常见的选择。首先,你可能会看到一个逻辑卷,像这样:
root=/dev/mapper/my-system-root
你也可能会看到一个 UUID(请参见第 4.2.4 节):
root=UUID=17f12d53-c3d7-4ab3-943e-a0a72366c9fa
这两者都是首选,因为它们不依赖于特定的内核设备映射。
ro参数指示内核在用户空间启动时以只读模式挂载根文件系统。这是正常的;只读模式确保在执行任何重要操作之前,fsck可以安全地检查根文件系统。检查之后,启动过程会将根文件系统重新挂载为读写模式。
当遇到它不理解的参数时,Linux 内核会保存该参数。内核稍后会在执行用户空间启动时将该参数传递给 init。例如,如果你将-s添加到内核参数中,内核会将-s传递给 init 程序,表示它应该以单用户模式启动。
如果你对基本的引导参数感兴趣,可以查看 bootparam(7)手册页,它提供了概述。如果你在寻找非常具体的信息,可以查看kernel-params.txt,这是一个随 Linux 内核一起提供的参考文件。
在掌握了这些基础知识后,你可以跳到第六章,学习用户空间启动、初始 RAM 磁盘以及内核作为第一个进程运行的初始化程序的具体内容。本章的其余部分详细介绍了内核如何加载到内存并启动,包括它如何获取参数。
5.4 引导加载程序
在启动过程中,内核和初始化程序启动之前,引导加载程序会启动内核。引导加载程序的工作听起来很简单:它从磁盘的某个位置将内核加载到内存中,然后用一组内核参数启动内核。然而,这项工作比看起来复杂。为了理解为什么,考虑引导加载程序必须回答的问题:
-
内核在哪里?
-
启动时应传递给内核哪些内核参数?
答案通常是:内核及其参数通常位于根文件系统的某个位置。听起来内核参数应该很容易找到,但请记住,内核本身还没有启动,通常是内核遍历文件系统来查找必要的文件。更糟糕的是,通常用于访问磁盘的内核设备驱动程序也不可用。可以将其看作是一种“先有鸡还是先有蛋”的问题。情况可能比这更复杂,但现在,我们来看看引导加载程序如何克服驱动程序和文件系统的障碍。
引导加载器确实需要一个驱动程序来访问磁盘,但它并不是内核使用的那个驱动程序。在 PC 上,引导加载器使用传统的基本输入输出系统(BIOS)或较新的统一可扩展固件接口(UEFI)来访问磁盘。(可扩展固件接口,或EFI,以及 UEFI 将在第 5.8.2 节中详细讨论。)当代磁盘硬件包括固件,允许 BIOS 或 UEFI 通过逻辑块寻址(LBA)访问附加的存储硬件。LBA 是一种通用的、简单的方式来访问任何磁盘上的数据,但其性能较差。然而,这不是问题,因为引导加载器通常是唯一必须使用这种模式来访问磁盘的程序;一旦启动,内核就可以访问其自己的高性能驱动程序。
一旦解决了对磁盘原始数据的访问问题,引导加载器必须完成在文件系统中定位所需数据的工作。大多数常见的引导加载器可以读取分区表,并且内置支持只读访问文件系统。因此,它们可以找到并读取它们需要的文件,将内核加载到内存中。这种能力使得动态配置和增强引导加载器变得更加容易。Linux 引导加载器并不总是具备这种能力;没有它,配置引导加载器会更加困难。
总体来说,内核添加新特性(尤其是在存储技术方面)后,引导加载器会增加这些特性的独立简化版本,以作补充。
5.4.1 引导加载器任务
Linux 引导加载器的核心功能包括以下几项:
-
从多个内核中进行选择。
-
在内核参数集之间切换。
-
允许用户手动覆盖并编辑内核镜像名称和参数(例如,进入单用户模式)。
-
提供对启动其他操作系统的支持。
自 Linux 内核诞生以来,引导加载器已经变得相当先进,拥有命令行历史记录和菜单系统等功能,但其基本需求始终是灵活选择内核镜像和参数。(一个令人惊讶的现象是,一些需求实际上已经减少。例如,由于你可以从 USB 存储设备进行紧急或恢复启动,你几乎不需要担心手动输入内核参数或进入单用户模式。)当前的引导加载器比以往更强大,特别是在构建自定义内核或只是想调整参数时,它们非常方便。
5.4.2 引导加载器概述
以下是你可能遇到的主要引导加载器:
-
GRUB 在 Linux 系统中几乎是通用标准,具有 BIOS/MBR 和 UEFI 版本。
-
LILO 最早的 Linux 引导加载器之一。ELILO 是其 UEFI 版本。
-
SYSLINUX 可以配置为从多种不同的文件系统中运行。
-
LOADLIN 从 MS-DOS 启动内核。
-
systemd-boot 一个简单的 UEFI 引导管理器。
-
coreboot(前身为 LinuxBIOS) 一种高性能的 PC BIOS 替代品,可以包含内核。
-
Linux 内核 EFISTUB 一个用于直接从 EFI/UEFI 系统分区(ESP)加载内核的内核插件。
-
efilinux 一个 UEFI 引导加载程序,旨在作为其他 UEFI 引导加载程序的模型和参考。
本书几乎完全讨论 GRUB。使用其他引导加载程序的理由是它们比 GRUB 更容易配置,它们的速度更快,或者它们提供其他特定功能。
你可以通过进入一个引导提示符,输入内核名称和参数,从而了解很多关于引导加载程序的信息。要做到这一点,你需要知道如何进入引导提示符或菜单。不幸的是,这有时可能很难弄清楚,因为 Linux 发行版会对引导加载程序的行为和外观进行高度自定义。通常,仅通过观看引导过程无法判断发行版使用的是哪种引导加载程序。
接下来的章节将告诉你如何进入引导提示符,以便输入内核名称和参数。一旦你熟悉了这一点,你将看到如何配置和安装引导加载程序。
5.5 GRUB 介绍
GRUB 代表Grand Unified Boot Loader。我们将介绍 GRUB 2,但也有一个叫做 GRUB Legacy 的旧版本,目前已不再使用。
GRUB 最重要的功能之一是文件系统导航,它允许轻松选择内核镜像和配置。了解 GRUB 的一种最佳方式是查看它的菜单。这个界面易于导航,但你很可能从未见过它。
要访问 GRUB 菜单,请在 BIOS 启动屏幕首次出现时按住 shift 键,或如果你的系统使用 UEFI 则按 esc 键。否则,在加载内核之前,引导加载程序配置可能不会暂停。图 5-1 显示了 GRUB 菜单。

图 5-1:GRUB 菜单
尝试以下操作以探索引导加载程序:
-
重启或开机你的 Linux 系统。
-
在 BIOS 自检期间按住 shift 键,或在固件启动画面按 esc 键以进入 GRUB 菜单。(有时这些画面不可见,因此你必须猜测何时按下按钮。)
-
按 e 键查看默认引导选项的引导加载程序配置命令。你应该看到类似图 5-2 的内容(你可能需要向下滚动才能看到所有细节)。
![f05002]()
图 5-2:GRUB 配置编辑器
该屏幕告诉我们,对于此配置,根目录是通过 UUID 设置的,内核镜像是/boot/vmlinuz-4.15.0-45-generic,内核参数包括 ro、quiet 和 splash。初始 RAM 文件系统是/boot/initrd.img-4.15.0-45-generic。但如果你以前从未见过这种配置,可能会感到有些困惑。为什么有多个 root 的引用,它们为什么不同?为什么这里有 insmod?如果你以前见过这些,你可能会记得这是一个 Linux 内核特性,通常由 udevd 运行。
这种反复思考是有原因的,因为 GRUB 并不使用 Linux 内核(记住,它的工作是启动内核)。你看到的配置完全由 GRUB 内部的特性和命令组成,GRUB 存在于它自己的独立世界中。
这种困惑部分来源于 GRUB 借用了来自多个来源的术语。GRUB 有自己的“内核”和 insmod 命令来动态加载 GRUB 模块,完全独立于 Linux 内核。许多 GRUB 命令类似于 Unix shell 命令;甚至有一个 ls 命令用来列出文件。
到目前为止,最大的困惑来自于 GRUB 对“root”一词的使用。通常,你会将 root 视为系统的根文件系统。在 GRUB 配置中,这是一个内核参数,位于 linux 命令的镜像名称之后的某个位置。
配置中每个其他的 root 引用都是指 GRUB 根目录,它仅存在于 GRUB 内部。GRUB 的“根目录”是 GRUB 用来搜索内核和 RAM 文件系统镜像文件的文件系统。
在图 5-2 中,GRUB 根目录首先设置为 GRUB 特定设备(hd0,msdos1),这是该配置的默认值 1。接下来的命令中,GRUB 会在一个分区中搜索特定的 UUID 2。如果找到了该 UUID,它将把 GRUB 根目录设置为该分区。
总结一下,linux 命令的第一个参数(/boot/vmlinuz-. . .)是 Linux 内核镜像文件的位置 3。GRUB 从 GRUB 根目录加载此文件。initrd 命令类似,指定了用于初始 RAM 文件系统的文件,详细内容在第六章 4 中。
你可以在 GRUB 内编辑此配置;这样做通常是临时修复启动错误的最简单方法。要永久修复启动问题,你需要更改配置(见第 5.5.2 节),但现在,让我们深入一步,使用命令行界面来检查一些 GRUB 内部的内容。
5.5.1 使用 GRUB 命令行探索设备和分区
如你在图 5-2 中所见,GRUB 有自己的设备寻址方案。例如,找到的第一个硬盘被命名为 hd0,接着是 hd1,依此类推。设备名称的分配是有可能变化的,但幸运的是,GRUB 可以搜索所有分区的 UUID,找到包含内核的那个分区,就像你刚刚在图 5-2 中看到的那样,使用 search 命令。
列出设备
为了了解 GRUB 如何引用系统上的设备,通过在启动菜单或配置编辑器中按 c 进入 GRUB 命令行。你应该会看到 GRUB 提示符:
grub>
你可以在这里输入你在配置中看到的任何命令,但为了开始,试试一个诊断命令:ls。没有参数时,输出将是 GRUB 已知的设备列表:
grub> **ls**
(hd0) (hd0,msdos1)
在这种情况下,存在一个主要的磁盘设备,表示为 (hd0),以及一个单独的分区 (hd0,msdos1)。如果磁盘上有交换分区,它也会显示出来,如 (hd0,msdos5)。分区上的 msdos 前缀表示该磁盘包含 MBR 分区表;如果是 GPT 分区表,则会以 gpt 开头,出现在 UEFI 系统中。(甚至还可以有更深层次的组合,带有第三个标识符,其中 BSD 磁盘标签映射位于分区内部,但通常除非你在一台机器上运行多个操作系统,否则无需担心这个问题。)
要获取更详细的信息,可以使用 ls -l。这个命令特别有用,因为它会显示分区文件系统的任何 UUID。例如:
grub> **ls -l**
Device hd0: No known filesystem detected – Sector size 512B - Total size 32009856KiB
Partition hd0,msdos1: Filesystem type ext* – Last modification time
2019-02-14 19:11:28 Thursday, UUID 8b92610e-1db7-4ba3-ac2f-30ee24b39ed0 - Partition start at 1024Kib - Total size 32008192KiB
这个特定的磁盘在第一个 MBR 分区上有一个 Linux ext2/3/4 文件系统。使用交换分区的系统将显示另一个分区,但你无法从输出中看出它的类型。
文件导航
现在让我们来看看 GRUB 的文件系统导航功能。使用 echo 命令确定 GRUB 根目录(记得这是 GRUB 期望找到内核的位置):
grub> **echo $root**
hd0,msdos1
要使用 GRUB 的 ls 命令列出该根目录中的文件和目录,你可以在分区后加上一个斜杠:
grub> **ls (hd0,msdos1)/**
由于直接输入实际的根分区不方便,你可以用 root 变量来节省时间:
grub> **ls ($root)/**
输出的是该分区文件系统的简短文件和目录列表,如 etc/、bin/ 和 dev/。这现在是 GRUB ls 命令的一个完全不同的功能。之前,你是在列出设备、分区表,可能还有一些文件系统头信息。现在,你实际上是在查看文件系统的内容。
你可以以类似的方式深入查看分区上的文件和目录。例如,要查看 /boot 目录,从以下命令开始:
grub> **ls ($root)/boot**
你还可以通过 set 命令查看所有当前设置的 GRUB 变量:
grub> **set**
?=0
color_highlight=black/white
color_normal=white/black
--`snip`--
prefix=(hd0,msdos1)/boot/grub
root=hd0,msdos1
这些变量中最重要的之一是 $prefix,这是 GRUB 期望找到其配置和辅助支持的文件系统和目录。接下来我们将讨论 GRUB 配置。
完成 GRUB 命令行界面后,你可以按 esc 返回 GRUB 菜单。或者,如果你已设置好所有必要的启动配置(包括 linux 和可能的 initrd 变量),你可以输入 boot 命令以启动该配置。无论如何,启动你的系统。我们将探索 GRUB 配置,最好在你能启动完整系统时进行。
5.5.2 GRUB 配置
GRUB 配置目录通常是 /boot/grub 或 /boot/grub2。它包含中央配置文件 grub.cfg,一个特定架构的目录(如 i386-pc),该目录包含带有 .mod 后缀的可加载模块,还有一些其他项目,如字体和本地化信息。我们不会直接修改 grub.cfg;相反,我们将使用 grub-mkconfig 命令(在 Fedora 中为 grub2-mkconfig)。
审查 grub.cfg
首先,快速查看 grub.cfg,了解 GRUB 是如何初始化其菜单和内核选项的。你会看到该文件由 GRUB 命令组成,通常以一系列初始化步骤开始,接着是不同内核和启动配置的菜单项。初始化部分并不复杂,但开始部分有很多条件语句,可能会让你误以为这很复杂。第一部分只是由一堆函数定义、默认值和视频设置命令组成,例如:
if loadfont $font ; then
set gfxmode=auto
load_video
insmod gfxterm
--`snip`--
在配置文件的后面,你会找到可用的启动配置,每个配置都以 menuentry 命令开头。你应该能够根据前面部分学到的内容,理解这个示例:
menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-8b92610e-1db7-4ba3-ac2f-30ee24b39ed0' {
recordfail
load_video
gfxmode $linux_gfx_mode
insmod gzio
if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi
insmod part_msdos
insmod ext2
set root='hd0,msdos1'
search --no-floppy --fs-uuid --set=root 8b92610e-1db7-4ba3-ac2f-30ee24b39ed0
linux /boot/vmlinuz-4.15.0-45-generic root=UUID=8b92610e-1db7-4ba3-ac2f-30ee24b39ed0 ro quiet splash $vt_handoff
initrd /boot/initrd.img-4.15.0-45-generic
}
检查你的 grub.cfg 文件,查找包含多个 menuentry 命令的 submenu 命令。许多发行版使用 submenu 命令来管理旧版本的内核,以避免它们挤占 GRUB 菜单。
生成新配置文件
如果你想更改 GRUB 配置,不要直接编辑 grub.cfg 文件,因为它是自动生成的,并且系统会定期覆盖它。你应该在其他地方设置新的配置,然后运行 grub-mkconfig 以生成新的配置。
要了解配置生成是如何工作的,可以查看 grub.cfg 的开头部分。应该有这样的注释行:
### BEGIN /etc/grub.d/00_header ###
经过进一步检查,你会发现 /etc/grub.d 中几乎每个文件都是一个 shell 脚本,用于生成 grub.cfg 文件的一部分。grub-mkconfig 命令本身就是一个 shell 脚本,它会执行 /etc/grub.d 中的所有内容。请记住,GRUB 本身不会在启动时运行这些脚本;我们在用户空间运行这些脚本,以生成 GRUB 运行的 grub.cfg 文件。
尝试以 root 身份自己操作。不要担心覆盖当前配置。此命令本身只是将配置打印到标准输出。
# grub-mkconfig
如果你想向 GRUB 配置中添加菜单项和其他命令该怎么办?简短的回答是,你应该将自定义内容放入 GRUB 配置目录中的一个新的 custom.cfg 文件中(通常是 /boot/grub/custom.cfg)。
长答案稍微复杂一些。/etc/grub.d 配置目录为您提供了两个选项:40_custom 和 41_custom。第一个,40_custom,是一个您可以自己编辑的脚本,但它的稳定性最差;软件包升级可能会破坏您所做的任何更改。41_custom 脚本较为简单;它只是一系列命令,在 GRUB 启动时加载 custom.cfg。如果选择第二个选项,您的更改在生成配置文件时不会显示,因为 GRUB 在启动时会完成所有工作。
自定义配置文件的两个选项并不是特别广泛,而且没有什么能阻止您添加自己的脚本来生成配置数据。您可能会在 /etc/grub.d 目录中看到一些特定于您特定发行版的附加内容。例如,Ubuntu 会将内存测试引导选项(memtest86+)添加到配置中。
要写入并安装新生成的 GRUB 配置文件,您可以使用 -o 选项将配置写入 GRUB 目录,像这样使用 grub-mkconfig:
# grub-mkconfig -o /boot/grub/grub.cfg
和往常一样,备份您的旧配置并确保您正在安装到正确的目录。
现在我们将深入讨论一些更技术性的 GRUB 和引导加载程序的细节。如果您厌倦了听引导加载程序和内核的内容,可以跳到第六章。
5.5.3 GRUB 安装
安装 GRUB 比配置它要复杂一些。幸运的是,通常您无需担心安装,因为您的发行版应该会为您处理它。然而,如果您试图复制或恢复一个可启动磁盘,或者准备自己的引导顺序,您可能需要自行安装。
在继续之前,阅读第 5.4 节,了解 PC 如何启动,并确定您是使用 MBR 还是 UEFI 启动。接下来,构建 GRUB 软件集并确定您的 GRUB 目录将在哪里;默认情况下是 /boot/grub。如果您的发行版为您处理了 GRUB,您可能不需要构建它,但如果需要,请参阅第十六章了解如何从源代码构建软件。确保构建正确的目标:MBR 或 UEFI 启动有所不同(32 位和 64 位 EFI 之间甚至存在差异)。
在您的系统上安装 GRUB
安装引导加载程序需要您或安装程序确定以下内容:
-
当前运行系统所看到的目标 GRUB 目录。如前所述,通常是 /boot/grub,但如果您正在将 GRUB 安装到另一个磁盘以供其他系统使用,它可能会有所不同。
-
GRUB 目标磁盘的当前设备。
-
对于 UEFI 启动,当前的 EFI 系统分区挂载点(通常是 /boot/efi)。
记住,GRUB 是一个模块化系统,但为了加载模块,它必须读取包含 GRUB 目录的文件系统。你的任务是构建一个能够读取该文件系统的 GRUB 版本,以便它能够加载其余的配置文件 (grub.cfg) 和所需的模块。在 Linux 上,这通常意味着构建一个预加载了 ext2.mod 模块(可能还包括 lvm.mod)的 GRUB 版本。一旦你有了这个版本,你所需要做的就是将其放置在可启动的磁盘部分,并将其余所需的文件放入 /boot/grub 中。
幸运的是,GRUB 提供了一个名为 grub-install 的工具(不要与某些旧系统中可能找到的 install-grub 混淆),该工具可以为你完成大部分安装 GRUB 文件和配置的工作。例如,如果你当前的磁盘是 /dev/sda,并且你想在该磁盘的 MBR 上安装 GRUB,并使用当前的 /boot/grub 目录,可以使用以下命令:
# grub-install /dev/sda
在外部存储设备上使用 MBR 安装 GRUB
要在当前系统之外的存储设备上安装 GRUB,你必须手动指定该设备上的 GRUB 目录,因为你当前的系统会看到这个设备的目录。例如,假设你有一个目标设备 /dev/sdc,并且该设备的根文件系统包含 /boot(例如,/dev/sdc1)已挂载在当前系统的 /mnt 上。这意味着当你安装 GRUB 时,当前系统将会看到 /mnt/boot/grub 中的 GRUB 文件。在运行 grub-install 时,告诉它这些文件应该放置的位置,如下所示:
# grub-install --boot-directory=/mnt/boot /dev/sdc
在大多数 MBR 系统中,/boot 是根文件系统的一部分,但一些安装将 /boot 放置在单独的文件系统中。确保你知道目标 /boot 所在的位置。
使用 UEFI 安装 GRUB
UEFI 安装应该更简单,因为你所需要做的只是将引导加载程序复制到指定位置。但你还需要通过 efibootmgr 命令“公告”引导加载程序给固件,也就是将加载程序配置保存到 NVRAM 中。如果 grub-install 命令可用,它会自动运行此操作,因此通常你可以像这样在 UEFI 系统上安装 GRUB:
# grub-install --efi-directory=`efi_dir` –-bootloader-id=`name`
在这里,efi_dir 是当前系统中 UEFI 目录所在的位置(通常是 /boot/efi/EFI,因为 UEFI 分区通常挂载在 /boot/efi),而 name 是引导加载程序的标识符。
不幸的是,在安装 UEFI 引导加载程序时可能会遇到很多问题。例如,如果你要安装到最终会进入另一个系统的磁盘,你必须弄清楚如何将该引导加载程序公告给新系统的固件。而且,针对可移动媒体的安装过程也存在差异。
但其中一个最大的问题是 UEFI 安全启动。
5.6 UEFI 安全启动问题
一个影响 Linux 安装的较新问题是处理最近 PC 中发现的安全启动功能。当启用时,此 UEFI 机制要求任何启动加载器必须由受信任的机构进行数字签名才能运行。微软要求硬件供应商在提供 Windows 8 及以后版本的系统时,必须使用安全启动。结果是,如果你试图在这些系统上安装未签名的启动加载器,固件会拒绝该加载器,操作系统将无法启动。
主要的 Linux 发行版在安全启动方面没有问题,因为它们包含已签名的启动加载器,通常是基于 UEFI 版本的 GRUB。通常会有一个小的已签名的 shim,它位于 UEFI 和 GRUB 之间;UEFI 运行 shim,shim 再执行 GRUB。如果你的计算机不在可信环境中,或者需要满足某些安全要求,保护防止启动未经授权的软件是一个重要功能,因此一些发行版更进一步,要求整个启动过程(包括内核)都必须被签名。
安全启动系统有一些缺点,特别是对于那些尝试构建自己启动加载器的人来说。你可以通过在 UEFI 设置中禁用它来绕过安全启动要求。然而,这对于双启动系统来说无法干净地工作,因为 Windows 在未启用安全启动的情况下无法运行。
5.7 链式引导其他操作系统
UEFI 使得支持加载其他操作系统相对容易,因为你可以在 EFI 分区中安装多个启动加载器。然而,较旧的 MBR 风格不支持此功能,即使你拥有 UEFI,你可能仍然会有一个带 MBR 风格启动加载器的单独分区,你可能希望使用它。GRUB 可以加载并在磁盘的特定分区上运行不同的启动加载器,而不是配置和运行 Linux 内核;这就是所谓的链式引导。
要进行链式引导,请在 GRUB 配置中创建一个新的菜单项(使用“生成新配置文件”部分中描述的方法之一)。以下是一个关于在磁盘第三分区上安装 Windows 的示例:
menuentry "Windows" {
insmod chain
insmod ntfs
set root=(hd0,3)
chainloader +1
}
+1选项告诉chainloader加载分区的第一个扇区中的内容。你还可以通过使用类似的命令直接加载一个文件,例如加载io.sys MS-DOS 加载器:
menuentry "DOS" {
insmod chain
insmod fat
set root=(hd0,3)
chainloader /io.sys
}
5.8 启动加载器详细信息
现在我们快速看一下启动加载器的内部工作原理。为了理解像 GRUB 这样的启动加载器是如何工作的,我们首先来了解一下当你打开 PC 时它是如何启动的。由于必须解决传统 PC 启动机制的许多不足,启动加载方案有几种变化,但主要有两种:MBR 和 UEFI。
5.8.1 MBR 启动
除了第 4.1 节中描述的分区信息外,MBR 还包括一个 441 字节的小区域,PC BIOS 会在自检(POST)后加载并执行该区域的内容。不幸的是,这个空间不足以容纳几乎任何引导加载程序,因此需要额外的空间,这就导致了有时被称为 多阶段引导加载程序 的情况。在这种情况下,MBR 中的初始代码除了加载其余的引导加载程序代码外不做任何事情。引导加载程序的其余部分通常会被塞入 MBR 和磁盘上第一个分区之间的空间中。这并不特别安全,因为任何东西都可以覆盖那里存放的代码,但大多数引导加载程序都这么做,包括大多数 GRUB 安装。
在使用 BIOS 启动的 GPT 分区磁盘上,传统的将引导加载程序代码放在 MBR 之后的方式不起作用,因为 GPT 信息位于 MBR 后面的区域。(GPT 为了向后兼容,保留了传统的 MBR。)GPT 的解决方法是创建一个名为 BIOS 启动分区 的小分区,使用特殊的 UUID(21686148-6449-6E6F-744E-656564454649)为完整的引导加载程序代码提供一个存放位置。然而,这并不是一种常见配置,因为 GPT 通常与 UEFI 一起使用,而不是传统的 BIOS。它通常只出现在具有非常大磁盘(大于 2TB)的旧系统中,这些磁盘太大,无法使用 MBR。
5.8.2 UEFI 启动
PC 制造商和软件公司意识到传统的 PC BIOS 存在严重的局限性,因此他们决定开发一个替代方案,称为可扩展固件接口(EFI),我们在本章的多个地方已经简要讨论过 EFI。虽然 EFI 花了一些时间才在大多数 PC 上得到普及,但如今它已经成为最常见的标准,尤其是在微软要求 Windows 启用安全启动之后。目前的标准是统一 EFI(UEFI),它包括内置 shell、读取分区表和浏览文件系统的能力等功能。GPT 分区方案是 UEFI 标准的一部分。
在 UEFI 系统上,启动过程与 MBR 系统截然不同。大多数情况下,它更容易理解。与其将可执行的引导代码存放在文件系统之外,UEFI 系统总是有一个名为 EFI 系统分区(ESP)的特殊 VFAT 文件系统,该分区包含一个名为 EFI 的目录。ESP 通常挂载在你的 Linux 系统的 /boot/efi 目录下,因此你很可能会在 /boot/efi/EFI 开始找到大部分 EFI 目录结构。每个引导加载程序都有自己的标识符和相应的子目录,例如 efi/microsoft、efi/apple、efi/ubuntu 或 efi/grub。引导加载程序文件具有 .efi 扩展名,并位于这些子目录中,与其他支持文件一起。如果你进行探索,可能会发现一些文件,比如 grubx64.efi(GRUB 的 EFI 版本)和 shimx64.efi。
但是有一个问题:你不能直接把旧的引导加载程序代码放入 ESP 中,因为旧的代码是为 BIOS 接口编写的。相反,你必须提供一个为 UEFI 编写的引导加载程序。例如,使用 GRUB 时,你必须安装 UEFI 版本的 GRUB,而不是 BIOS 版本。而且,正如在《使用 UEFI 安装 GRUB》中解释的那样,你必须将新的引导加载程序宣布给固件。
最后,正如第 5.6 节所述,我们必须应对“安全引导”问题。
5.8.3 GRUB 的工作原理
让我们通过看看 GRUB 是如何工作的来总结我们的讨论:
-
PC BIOS 或固件初始化硬件,并搜索其引导顺序存储设备中的引导代码。
-
在找到引导代码后,BIOS/固件加载并执行它。这是 GRUB 开始的地方。
-
GRUB 核心加载。
-
核心初始化。此时,GRUB 现在可以访问磁盘和文件系统。
-
GRUB 识别其引导分区并加载配置。
-
GRUB 给用户一个机会来更改配置。
-
在超时或用户操作后,GRUB 执行配置(即grub.cfg文件中的命令序列,如第 5.5.2 节所述)。
-
在执行配置的过程中,GRUB 可能会加载额外的代码(模块)到引导分区。一些模块可能是预加载的。
-
GRUB 执行
boot命令来加载并执行配置中的linux命令所指定的内核。
此序列中的第 3 步和第 4 步,在 GRUB 核心加载时,可能会因传统 PC 引导机制的不足而变得复杂。最大的问题是“GRUB 核心在哪里?”有三种基本可能性:
-
部分被填充在 MBR 和第一个分区的开始之间
-
在常规分区中
-
在一个特殊的引导分区中:一个 GPT 引导分区、ESP 或其他地方
除非你有 UEFI/ESP,否则 PC BIOS 从 MBR 加载 512 字节的数据,这就是 GRUB 开始的地方。这一小段(源自 GRUB 目录中的boot.img)还不是核心,但它包含了核心的起始位置,并从这里加载核心。
然而,如果你有 ESP,GRUB 核心作为文件存在于那里。固件可以浏览 ESP 并直接执行存放在其中的所有 GRUB 或其他操作系统引导加载程序。(你可能会在 ESP 中有一个 shim,位于 GRUB 之前,用来处理安全引导问题,但基本思想是相同的。)
然而,在大多数系统中,这还不是完整的图景。引导加载程序可能还需要在加载并执行内核之前,将初始 RAM 文件系统镜像加载到内存中。这就是initrd配置参数的作用,我们将在第 6.7 节中详细介绍。但在了解初始 RAM 文件系统之前,你应该先了解用户空间启动——这正是下一章的内容。
第六章:用户空间如何启动

内核启动 init,即它的第一个用户空间进程,具有重要意义——不仅仅是因为内存和 CPU 终于准备好进行正常的系统操作,而且因为在这个时候,你可以看到系统其余部分是如何整体构建起来的。在此之前,内核遵循由少数软件开发人员定义的严格执行路径。用户空间则更加模块化和可定制,也很容易看到用户空间启动和操作的内容。如果你有点冒险精神,你可以利用这一点,因为理解和更改用户空间的启动并不需要低级编程知识。
用户空间大致按以下顺序启动:
-
init
-
基本低层服务,如 udevd 和 syslogd
-
网络配置
-
中高层服务(cron、打印等)
-
登录提示、GUI 和高层应用程序,如 Web 服务器
6.1 init 简介
init是一个用户空间程序,就像 Linux 系统上的其他程序一样,你可以在/sbin目录下找到它,以及其他许多系统二进制文件。它的主要作用是启动和停止系统上的基本服务进程。
在当前所有主要 Linux 发行版的版本中,init 的标准实现是 systemd。本章将重点介绍 systemd 是如何工作的,以及如何与之交互。
你可能会在旧系统上遇到两种其他版本的 init。System V init 是传统的顺序化 init(Sys V,通常发音为“sys-five”,起源于 Unix System V),在 Red Hat Enterprise Linux(RHEL)7.0 版本之前和 Debian 8 中使用。Upstart 是 Ubuntu 发行版 15.04 版本之前的 init。
还存在其他版本的 init,尤其是在嵌入式平台上。例如,Android 有自己的 init 版本,名为runit的版本在轻量级系统中很受欢迎。BSD 系统也有自己版本的 init,但你不太可能在当代 Linux 机器上看到它们。(一些发行版也修改了 System V init 配置,使其类似于 BSD 风格。)
为了解决 System V init 中的若干缺点,开发了不同实现的 init。为了理解这些问题,可以考虑传统 init 的内部工作原理。它基本上是一系列的脚本,init 按顺序逐一执行每个脚本。每个脚本通常启动一个服务或配置系统的某个部分。在大多数情况下,解决依赖关系相对容易,而且通过修改脚本,具有相当大的灵活性来适应特殊的启动需求。
然而,这种方案存在一些显著的局限性。这些问题可以分为“性能问题”和“系统管理麻烦”两大类。最重要的包括以下几点:
-
性能受到影响,因为引导序列的两个部分通常不能同时运行。
-
管理一个正在运行的系统可能会很困难。启动脚本需要启动服务守护进程。要找到服务守护进程的 PID,你需要使用
ps,或者某些特定于该服务的机制,或者使用半标准化的 PID 记录系统,如/var/run/myservice.pid。 -
启动脚本往往包含大量标准的“模板”代码,有时使得它们的阅读和理解变得困难。
-
对于大多数系统来说,服务和配置的按需加载并不常见。大多数服务在启动时启动;系统配置通常也在此时设定。曾几何时,传统的 inetd 守护进程能够处理按需网络服务,但它已大多不再使用。
当代的 init 系统通过改变服务的启动方式、监督方式以及依赖项的配置方式来解决这些问题。你很快就会看到在 systemd 中是如何实现的,但首先,你应该确保你的系统正在运行 systemd。
6.2 确认你的 init
确定系统的 init 版本通常不难。查看 init(1)手册页通常会立刻告诉你,但如果你不确定,可以按如下方法检查你的系统:
-
如果你的系统有/usr/lib/systemd和/etc/systemd目录,那么你正在使用 systemd。
-
如果你有一个包含多个.conf文件的/etc/init目录,你可能正在运行 Upstart(除非你正在运行 Debian 7 或更早版本,在这种情况下你可能使用的是 System V init)。本书不会讨论 Upstart,因为它已经被 systemd 广泛取代。
-
如果上述两种情况都不成立,但你有一个/etc/inittab文件,那么你可能正在运行 System V init。请参见第 6.5 节。
6.3 systemd
systemd init 是 Linux 上最新的 init 实现之一。除了处理常规的启动过程外,systemd 还旨在集成许多标准 Unix 服务的功能,如 cron 和 inetd。它也受到 Apple 的 launchd 的启发。
systemd 相较于其前辈真正脱颖而出的是其先进的服务管理能力。与传统的 init 不同,systemd 在服务启动后能够追踪单个服务守护进程,并将与服务相关的多个进程组合在一起,这使你可以更好地掌握并洞察系统中到底有什么正在运行。
systemd 是目标导向的。在最高层级,你可以将定义一个系统任务的目标称为一个单元(unit)。一个单元可以包含常见启动任务的指令,例如启动一个守护进程,它也有依赖项,即其他单元。在启动(或激活)一个单元时,systemd 会尝试激活其依赖项,然后继续执行单元的详细内容。
启动服务时,systemd 并不遵循严格的顺序;相反,它会在服务准备好时激活它们。系统启动后,systemd 可以通过响应系统事件(例如第三章中列出的 uevents)来激活其他单元。
我们从一个顶层视图开始,了解单元、激活以及初始启动过程。然后你将准备好查看单元配置的具体细节以及各种单元依赖关系。在这个过程中,你将掌握如何查看和控制正在运行的系统。
6.3.1 单元和单元类型
systemd 比之前版本的 init 更为雄心勃勃的一个方面是,它不仅仅管理进程和服务;它还可以管理文件系统挂载、监控网络连接请求、运行定时器等。每个功能被称为单元类型,每个具体的功能(如服务)被称为单元。当你启动一个单元时,你是在激活它。每个单元都有自己的配置文件;我们将在 6.3.3 节中探讨这些文件。
这些是执行典型 Linux 系统启动任务的最重要单元类型:
-
服务单元控制 Unix 系统中的服务守护进程。
-
目标单元控制其他单元,通常通过将它们分组来实现。
-
套接字单元表示传入的网络连接请求位置。
-
挂载单元表示文件系统与系统的连接。
在这些单位中,服务单元和目标单元是最常见且最容易理解的。我们来看一下它们在系统启动时是如何相互配合的。
6.3.2 启动与单元依赖图
当你启动系统时,你激活的是一个默认单元,通常是一个目标单元,称为default.target,它将多个服务单元和挂载单元作为依赖项进行分组。因此,了解启动时发生的事情就显得相对容易。你可能会期望单元依赖关系形成一棵树——树顶有一个单元,下面分支出多个单元,用于启动过程的后续阶段——但实际上,它们形成了一个图。启动过程后期的单元可能依赖于前面几个单元,从而使依赖树的早期分支重新汇合。你甚至可以使用 systemd-analyze dot 命令创建依赖图。整个图在典型系统上相当庞大(需要大量计算资源来渲染),并且难以阅读,但有一些方法可以过滤单元并专注于某些部分。
图 6-1 显示了在典型系统上找到的 default.target 单元的依赖图的一个非常小的部分。当你激活该单元时,所有在其下的单元也会被激活。

图 6-1:单元依赖图
这张图是一个大大简化的视图。在你自己的系统中,你不会发现仅凭查看单元配置文件从顶部向下工作,就能绘制出依赖关系。我们将在 6.3.6 节中更详细地了解依赖关系是如何工作的。
6.3.3 systemd 配置
systemd 配置文件分布在系统的多个目录中,因此当你寻找特定文件时,可能需要进行一些查找。systemd 配置有两个主要目录:system unit目录(全局配置;通常是/lib/systemd/system或/usr/lib/systemd/system)和system configuration目录(本地定义;通常是/etc/systemd/system)。
为了避免混淆,遵循这个规则:避免对系统单元目录进行更改,因为你的发行版会为你维护它。将本地更改应用到系统配置目录。这一通用规则也适用于整个系统。当有选择在/usr和/etc之间修改时,总是选择修改/etc。
你可以使用以下命令检查当前的 systemd 配置搜索路径(包括优先级):
$ **systemctl -p UnitPath show**
`UnitPath=/etc/systemd/system.control /run/systemd/system.control /run/systemd/transient /etc/systemd/system /run/systemd/system /run/systemd/generator /lib/systemd/system /run/systemd/generator.late`
要查看系统单元和配置目录,请使用以下命令:
$ **pkg-config systemd --variable=systemdsystemunitdir**
/lib/systemd/system
$ **pkg-config systemd --variable=systemdsystemconfdir**
/etc/systemd/system
单元文件
单元文件的格式源自 XDG 桌面条目规范(用于.desktop文件,它们与 Microsoft 系统上的.ini文件非常相似),其中部分名称使用方括号([]),每个部分内包含变量和值的分配(选项)。
举个例子,考虑桌面总线守护进程的dbus-daemon.service单元文件:
[Unit]
Description=D-Bus System Message Bus
Documentation=man:dbus-daemon(1)
Requires=dbus.socket
RefuseManualStart=yes
[Service]
ExecStart=/usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
ExecReload=/usr/bin/dbus-send --print-reply --system --type=method_call --dest= org.freedesktop.DBus / org.freedesktop.DBus.ReloadConfig
该单元文件包含两个部分,[Unit]和[Service]。[Unit]部分提供了关于该单元的一些详细信息,并包含描述和依赖信息。特别是,该单元需要dbus.socket单元作为依赖。
在像这样的服务单元中,你会在[Service]部分找到关于该服务的详细信息,包括如何准备、启动和重新加载服务。你可以在 systemd.service(5)和 systemd.exec(5)手册页中找到完整的列表,也可以在第 6.3.5 节的进程跟踪讨论中找到相关内容。
许多其他单元配置文件也同样简单明了。例如,服务单元文件sshd.service通过启动 sshd 来启用远程安全 Shell 登录。
变量
你经常会在单元文件中找到变量。以下是来自另一个单元文件的一个部分,这是你将在第十章学习的安全 Shell 的部分:
[Service]
EnvironmentFile=/etc/sysconfig/sshd
ExecStartPre=/usr/sbin/sshd-keygen
ExecStart=/usr/sbin/sshd -D $OPTIONS $CRYPTO_POLICY
ExecReload=/bin/kill -HUP $MAINPID
以美元符号($)开头的所有内容都是变量。尽管这些变量具有相同的语法,但它们的来源不同。$OPTIONS和$CRYPTO_POLICY选项是你可以在单元激活时传递给 sshd 的,这些选项在由EnvironmentFile设置指定的文件中定义。在这个特定的情况下,你可以查看/etc/sysconfig/sshd来确定变量是否已设置,如果已设置,则查看其值。
相比之下,$MAINPID 包含服务的 跟踪进程 的 ID(见第 6.3.5 节)。在单元激活时,systemd 会记录并存储此 PID,以便你稍后可以使用它来操作特定服务的进程。sshd.service 单元文件使用 $MAINPID 向 sshd 发送挂起信号(HUP),当你希望重新加载配置时使用此方法(这是处理重新加载和重启 Unix 守护进程的常见技巧)。
说明符
说明符是单元文件中常见的类似变量的特性。说明符以百分号(%)开头。例如,%n 说明符表示当前单元名称,%H 说明符表示当前主机名。
你还可以使用说明符从单个单元文件创建多个单元副本。一个例子是控制虚拟控制台登录提示符的 getty 进程集,例如 tty1 和 tty2。要使用此功能,请在单元名称的末尾添加 @ 符号,紧接着单元文件名中的点号之前。
例如,getty 单元文件名在大多数发行版中为 getty@.service,允许动态创建单元,如 getty@tty1 和 getty@tty2。@ 后面的部分被称为 实例。当你查看其中一个单元文件时,你也可能看到 %I 或 %i 说明符。通过带有实例的单元文件激活服务时,systemd 会用实例替换 %I 或 %i 说明符,以创建新的服务名称。
6.3.4 systemd 操作
你将主要通过 systemctl 命令与 systemd 交互,该命令允许你激活和停用服务、列出状态、重新加载配置等等。
最基本的命令帮助你获取单元信息。例如,要查看系统上活动单元的列表,执行 list-units 命令。(这是 systemctl 的默认命令,因此从技术上讲,你不需要 list-units 参数。)
$ **systemctl list-units**
输出格式典型于 Unix 信息列出命令。例如,-.mount(根文件系统)的标题和行如下所示:
UNIT LOAD ACTIVE SUB DESCRIPTION
-.mount loaded active mounted Root Mount
默认情况下,systemctl list-units 会产生大量输出,因为典型系统中有许多活动单元,但这仍然是简化的形式,因为 systemctl 会截断任何非常长的单元名称。要查看单元的完整名称,使用 --full 选项,若要查看所有单元(不仅仅是活动单元),使用 --all 选项。
一个特别有用的 systemctl 操作是获取特定单元的状态。例如,这是一个典型的 status 命令及其输出:
$ **systemctl status sshd.service**
· sshd.service - OpenBSD Secure Shell server
Loaded: loaded (/usr/lib/systemd/system/sshd.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2021-04-16 08:15:41 EDT; 1 months 1 days ago
Main PID: 1110 (sshd)
Tasks: 1 (limit: 4915)
CGroup: /system.slice/sshd.service
⌙1110 /usr/sbin/sshd -D
这段输出后面可能还会跟着一些日志消息。如果你习惯于传统的 init 系统,你可能会对这条命令提供的大量有用信息感到惊讶。你不仅能看到单元的状态,还能看到与服务关联的进程、单元启动时间以及若有日志消息,也会显示出来。
其他单元类型的输出也包括类似的有用信息;例如,挂载单元的输出包括挂载发生的时间、用于挂载的确切命令行及其退出状态。
输出中的一个有趣部分是控制组(cgroup)名称。在上面的示例中,控制组是/system.slice/sshd.service,其下显示了该控制组中的进程。然而,你还可能会看到以systemd:/system开头的控制组名称, 如果某个单元的进程(例如挂载单元)已经终止。你可以使用systemd-cgls命令查看与 systemd 相关的控制组,而不显示其他单元状态。你将在第 6.3.5 节中了解更多关于 systemd 如何使用控制组的信息,在第 8.6 节中了解控制组如何工作。
status 命令还仅显示单元的最新诊断日志信息。你可以通过以下方式查看单元的所有消息:
$ **journalctl --unit=**`unit_name`
在第七章,你将学到更多关于journalctl的内容。
作业与启动、停止和重新加载单元的关系
要激活、停用和重启单元,你可以使用命令systemctl start、systemctl stop和systemctl restart。然而,如果你已经更改了单元配置文件,你可以通过以下两种方式之一告诉 systemd 重新加载该文件:
-
systemctl reloadunit仅重新加载unit的配置。 -
systemctl daemon-reload重新加载所有单元配置。
在 systemd 中,激活、重新激活和重启单元的请求被称为作业,本质上它们是单元状态的变化。你可以使用以下命令查看系统上当前的作业:
$ **systemctl list-jobs**
如果系统已经运行了一段时间,你可以合理地预期没有活跃的作业,因为启动系统所需的所有激活操作应该都已经完成。然而,在启动时,你有时可以快速登录,看到启动非常慢的单元的作业。例如:
JOB UNIT TYPE STATE
1 graphical.target start waiting
2 multi-user.target start waiting
71 systemd-...nlevel.service start waiting
75 sm-client.service start waiting
76 sendmail.service start running
120 systemd-...ead-done.timer start waiting
在这种情况下,作业 76,即sendmail.service单元的启动,花费了非常长的时间。其他列出的作业处于等待状态,很可能是因为它们都在等待作业 76。当sendmail.service启动完成并完全激活后,作业 76 将完成,其他作业也将完成,作业列表将变为空。
请参见第 6.6 节,了解如何关闭和重启系统。
将单元添加到 systemd 中
将单元添加到 systemd 中,主要是创建单元文件,然后激活并可能启用它们。你应该通常将自己的单元文件放在系统配置目录(/etc/systemd/system)中,这样你就不会把它们与发行版自带的文件混淆,而且当你升级时,发行版不会覆盖它们。
由于很容易创建实际上不起作用或干扰系统的目标单元,试试看吧。要创建两个目标,其中一个依赖于另一个,请按照以下步骤操作:
-
在/etc/systemd/system中创建名为test1.target的单元文件:
[Unit] Description=test 1 -
创建一个依赖于test1.target的test2.target文件:
[Unit] Description=test 2 Wants=test1.targetWants关键字在这里定义了一个依赖关系,当你激活 test2.target 时,test1.target 会激活。激活 test2.target 单元查看其实际效果:# `systemctl start test2.target` -
验证两个单元是否都处于活动状态:
# `systemctl status test1.target test2.target` · test1.target - test 1 Loaded: loaded (/etc/systemd/system/test1.target; static; vendor preset: enabled) Active: active since Tue 2019-05-28 14:45:00 EDT; 16s ago May 28 14:45:00 duplex systemd[1]: Reached target test 1. · test2.target - test 2 Loaded: loaded (/etc/systemd/system/test2.target; static; vendor preset: enabled) Active: active since Tue 2019-05-28 14:45:00 EDT; 17s ago -
如果你的单元文件有一个
[Install]部分,在激活之前你需要“启用”该单元:`# systemctl enable` `unit`
[Install] 部分是创建依赖关系的另一种方式。我们将在第 6.3.6 节中更详细地讨论它(以及整体依赖关系)。
从 systemd 中移除单元
要移除一个单元,请按照以下步骤操作:
-
如有必要,请停用该单元:
# `systemctl stop` `unit` -
如果该单元有一个
[Install]部分,请禁用该单元,以移除依赖系统创建的任何符号链接:# `systemctl disable` `unit`
如果需要,你可以移除单元文件。
6.3.5 systemd 进程跟踪与同步
systemd 希望对它启动的每个进程有合理的控制和信息。这在历史上是很困难的。服务的启动方式可以多种多样;它可能会分叉出新的实例,甚至会守护进程并与原始进程分离。还无法预知服务器会启动多少个子进程。
为了便于管理已激活的单元,systemd 使用前面提到的 cgroups,它是 Linux 内核的一个特性,可以更细粒度地跟踪进程层次结构。使用 cgroups 也有助于减少软件包开发者或管理员在创建有效单元文件时需要做的工作。在 systemd 中,你不需要担心考虑每种可能的启动行为;你只需要知道服务的启动进程是否会分叉。使用服务单元文件中的 Type 选项来指示启动行为。有两种基本的启动方式:
-
Type=simple服务进程不会分叉并终止;它保持为主要的服务进程。 -
Type=forking服务会进行分叉,systemd 期望原始服务进程终止。终止后,systemd 假设服务已准备好。
Type=simple 选项没有考虑到服务可能需要一些时间来启动,因此 systemd 无法知道何时启动任何依赖于该服务准备好的单元。一种处理方法是使用延迟启动(见第 6.3.7 节)。不过,某些 Type 启动方式可以表明服务会在准备好时通知 systemd:
-
Type=notify当准备好时,服务会发送一个特定于 systemd 的通知,并进行特殊的函数调用。 -
Type=dbus当准备好时,服务会在 D-Bus(桌面总线)上注册自己。
另一种服务启动风格是通过Type=oneshot指定的;在这种情况下,服务进程在启动后会完全终止且没有子进程。它像Type=simple一样,唯一的区别是 systemd 不认为服务已启动,直到服务进程终止。任何严格依赖(你很快就会看到)都不会启动,直到该进程终止。使用Type=oneshot的服务还会得到默认的RemainAfterExit=yes指令,因此即使服务进程终止,systemd 仍然会认为该服务是活动的。
最终的选项是Type=idle。它的作用类似于simple风格,但它指示 systemd 在所有活动任务完成之前不启动服务。这里的思路是仅仅延迟服务的启动,直到其他服务已经启动,以防服务之间互相干扰输出。记住,一旦服务启动,启动它的 systemd 任务就会终止,因此等待所有其他任务完成可以确保没有其他任务在启动。
如果你对 cgroups 的工作原理感兴趣,我们将在第 8.6 节中详细探讨它们。
6.3.6 systemd 依赖关系
一种灵活的系统启动时和操作时依赖关系需要一定的复杂性,因为过于严格的规则可能会导致系统性能不佳和不稳定。例如,假设你希望在启动数据库服务器后显示登录提示,因此你将登录提示与数据库服务器定义为严格依赖。这意味着如果数据库服务器失败,登录提示也会失败,你甚至无法登录机器来修复问题!
Unix 启动时的任务具有相当高的容错性,通常可以在不造成标准服务严重问题的情况下失败。例如,如果你移除了系统的数据磁盘,但保留了其/etc/fstab条目(或者在 systemd 中的挂载单元),那么启动时的文件系统挂载会失败。尽管这个失败可能会影响应用服务器(如 Web 服务器),但通常不会影响标准的系统操作。
为了满足灵活性和容错性的需求,systemd 提供了几种依赖类型和风格。我们首先来看一下基本的类型,它们通过关键词语法进行标记:
-
Requires严格依赖。当激活一个具有Requires依赖项的单元时,systemd 会尝试激活依赖单元。如果依赖单元失败,systemd 也会停用该依赖单元。 -
Wants仅为激活依赖。在激活一个单元时,systemd 会激活该单元的Wants依赖关系,但如果这些依赖失败,systemd 并不关心。 -
Requisite单元必须已经激活。在激活一个具有Requisite依赖关系的单元之前,systemd 会首先检查依赖项的状态。如果依赖项尚未激活,systemd 在激活该单元时会失败。 -
Conflicts负依赖关系。当激活一个带有Conflict依赖的单元时,如果对立的依赖关系处于活动状态,systemd 会自动停用它。冲突单元的同时激活会失败。
Wants 依赖类型特别重要,因为它不会将失败传播到其他单元。systemd.service(5) 手册页面指出,如果可能的话,应该指定这种依赖关系,原因也很容易理解。这种行为使系统更加稳健,带来了传统初始化系统的好处,即早期启动组件的失败不一定会阻止后续组件的启动。
你可以使用 systemctl 命令查看单元的依赖关系,只要你指定依赖关系的类型,例如 Wants 或 Requires:
# systemctl show -p `type unit`
排序
到目前为止,你看到的依赖语法并没有明确指定顺序。例如,使用 Requires 或 Wants 依赖激活大多数服务单元时,这些单元会同时启动。这是最优的,因为你希望尽可能快地启动尽可能多的服务,以减少启动时间。然而,也有一些情况需要一个单元在另一个单元之后启动。例如,在图 6-1 所基于的系统中,default.target 单元被设置为在 multi-user.target 之后启动(图中未显示这一顺序区别)。
若要按特定顺序激活单元,请使用以下依赖修饰符:
-
Before当前单元将在列出的单元之前激活。例如,如果Before=bar.target出现在 foo.target 中,systemd 会在 bar.target 之前激活 foo.target。 -
After当前单元在列出的单元激活后激活。
当你使用排序时,systemd 会等待一个单元处于活动状态后,才会激活它的依赖单元。
默认和隐式依赖
在探索依赖关系时(尤其是使用 systemd-analyze 时),你可能会开始注意到一些单元获得了在单元文件或其他可见机制中没有显式声明的依赖关系。你最可能遇到这种情况的是在带有 Wants 依赖的目标单元中——你会发现 systemd 会在任何列为 Wants 依赖的单元旁边添加一个 After 修饰符。这些额外的依赖关系是 systemd 内部的,在启动时计算出来的,并且不存储在配置文件中。
添加的 After 修饰符称为 默认依赖关系,它是对单元配置的自动添加,旨在避免常见错误并保持单元文件的简洁。这些依赖关系根据单元类型而有所不同。例如,systemd 为目标单元添加的默认依赖关系与为服务单元添加的不同。这些差异在单元配置手册页面的 DEFAULT DEPENDENCIES 部分列出,例如 systemd.service(5) 和 systemd.target(5)。
你可以通过在配置文件中添加 DefaultDependencies=no 来禁用单元的默认依赖关系。
条件依赖关系
你可以使用多个条件依赖参数来测试不同的操作系统状态,而不是 systemd 单元。例如:
-
ConditionPathExists=p如果路径 p 在系统中存在,则为真。 -
ConditionPathIsDirectory=p如果 p 是一个目录,则为真。 -
ConditionFileNotEmpty=p如果 p 是一个文件且其长度非零,则为真。
如果系统尝试激活单元时,单元中的条件依赖为假,则该单元不会激活,尽管这仅适用于其中出现的单元。也就是说,如果你激活一个具有条件依赖和一些单元依赖的单元,systemd 会尝试激活这些单元依赖,不管条件是否为真。
其他依赖关系主要是前述依赖关系的变种。例如,RequiresOverridable 依赖关系在正常运行时与 Requires 相同,但如果单元是手动激活的,它就像 Wants 依赖关系。完整的依赖列表请参见 systemd.unit(5) 手册页。
[Install] 部分和启用单元
到目前为止,我们一直在讨论如何在依赖单元的配置文件中定义依赖关系。也可以“反向”进行操作——即通过在依赖项的单元文件中指定依赖单元。你可以通过在 [Install] 部分添加 WantedBy 或 RequiredBy 参数来实现这一点。这个机制允许你在不修改其他配置文件的情况下更改单元启动的时机(例如,当你不想编辑系统单元文件时)。
为了了解这个是如何工作的,请参考第 6.3.4 节中的示例单元。我们有两个单元,test1.target 和 test2.target,其中 test2.target 依赖于 test1.target。我们可以将它们修改为 test1.target 看起来像这样:
[Unit]
Description=test 1
[Install]
WantedBy=test2.target
test2.target 如下所示:
[Unit]
Description=test 2
因为你现在有了一个包含 [Install] 部分的单元,在启动之前,你需要使用 systemctl 来启用该单元。以下是如何通过 test1.target 来实现的:
# systemctl enable test1.target
Created symlink /etc/systemd/system/test2.target.wants/test1.target → /etc/systemd/system/test1.target.
注意这里的输出——启用单元的效果是创建一个符号链接,该链接位于对应于依赖单元的 .wants 子目录中(在本例中是 test2.target)。现在,由于依赖关系已建立,你可以通过 systemctl start test2.target 同时启动两个单元。
要禁用单元(并删除符号链接),可以按如下方式使用 systemctl:
# systemctl disable test1.target
Removed /etc/systemd/system/test2.target.wants/test1.target.
这个例子中的两个单元还为你提供了实验不同启动场景的机会。例如,查看当你仅尝试启动 test1.target 时会发生什么,或者当你尝试在没有启用 test1.target 的情况下启动 test2.target 时会发生什么。或者,尝试将 WantedBy 更改为 RequiredBy。(记住,你可以通过 systemctl status 检查单元的状态。)
在正常操作期间,systemd 会忽略单元中的 [Install] 部分,但会注意到它的存在,并默认将该单元视为禁用状态。启用单元后,其状态会在重启后保留。
[Install]部分通常负责系统配置目录(/etc/systemd/system)中的.wants和.requires目录。然而,单元配置目录([/usr]/lib/systemd/system)也包含.wants目录,你还可以添加与单元文件中的[Install]部分不对应的链接。这些手动添加是一种简单的方法,可以在不修改可能在未来被覆盖的单元文件(例如通过软件升级)的情况下添加依赖项,但并不特别推荐,因为手动添加很难追踪。
6.3.7 systemd 按需和资源并行启动
systemd 的一个特点是能够延迟单元的启动,直到绝对需要时再启动。通常的设置流程如下:
-
你为你希望提供的系统服务创建一个 systemd 单元(称为单元 A)。
-
你确定一个系统资源,比如网络端口/套接字、文件或设备,单元 A 用来提供其服务。
-
你创建另一个 systemd 单元,单元 R,用来表示该资源。这些单元被分类为不同类型,如 socket 单元、path 单元和 device 单元。
-
你定义单元 A 和单元 R 之间的关系。通常,这基于单元的名称是隐式的,但它也可以是显式的,正如我们稍后会看到的那样。
一旦设置好,操作过程如下:
-
当激活单元 R 时,systemd 监控该资源。
-
当任何东西尝试访问资源时,systemd 会阻塞资源,且对资源的输入会被缓冲。
-
systemd 激活单元 A。
-
准备好后,来自单元 A 的服务接管资源,读取缓冲的输入并正常运行。
这里有几个需要注意的点:
-
你必须确保你的资源单元覆盖了服务提供的每个资源。通常这不是问题,因为大多数服务只有一个访问点。
-
你需要确保你的资源单元与它所代表的服务单元绑定在一起。这可以是隐式的或显式的,在某些情况下,许多选项代表了 systemd 为服务单元执行交接的不同方式。
-
并非所有服务器都知道如何与 systemd 可以提供的资源单元进行交互。
如果你已经了解传统的工具如 inetd、xinetd 和 automount 的功能,你会发现它们之间有很多相似之处。事实上,这个概念并不新鲜;systemd 甚至包括对 automount 单元的支持。
示例 Socket 单元和服务
让我们来看一个例子,一个简单的网络回显服务。这些内容有点高级,直到你阅读了第九章关于 TCP、端口和监听的讨论以及第十章关于套接字的内容后,你可能才完全理解,但你应该能够理解基本概念。
一个回显服务的理念是重复网络客户端连接后发送的任何内容;我们的服务将监听 TCP 端口 22222。我们将通过一个socket 单元来表示该端口,以下是echo.socket单元文件的构建示例:
[Unit]
Description=echo socket
[Socket]
ListenStream=22222
Accept=true
请注意,在单元文件中没有提到该套接字支持的服务单元。那么,那个对应的服务单元文件是什么呢?
它的名称是echo@.service。通过命名约定建立了连接;如果一个服务单元文件与一个.socket文件有相同的前缀(在此例中为echo),systemd 会在该套接字单元有活动时激活该服务单元。在这种情况下,当echo.socket上有活动时,systemd 会创建一个echo@.service实例。以下是echo@.service单元文件:
[Unit]
Description=echo service
[Service]
ExecStart=/bin/cat
StandardInput=socket
要运行这个示例单元,你需要启动echo.socket单元:
# systemctl start echo.socket
现在你可以通过像telnet这样的工具连接到本地 TCP 端口 22222 来测试该服务。服务会重复你输入的内容;以下是一个示例交互:
$ **telnet localhost 22222**
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
**Hi there.**
Hi there.
当你厌倦了这一切,想回到你的 shell 时,按下 ctrl-]单独一行,然后按 ctrl-D。要停止服务,可以像这样停止套接字单元:
# systemctl stop echo.socket
实例与交接
由于echo@.service单元支持多个同时实例,所以名称中有一个@(回想一下,@修饰符表示参数化)。为什么需要多个实例呢?假设有多个网络客户端同时连接到该服务,并且你希望每个连接都有自己的实例。在这种情况下,服务单元必须支持多个实例,因为我们在echo.socket中包含了Accept=true选项。该选项指示 systemd 不仅要监听端口,还要代表服务单元接受传入的连接并将其传递给它们,为每个连接创建一个单独的实例。每个实例将数据作为标准输入读取,但它不一定需要知道这些数据来自网络连接。
如果一个服务单元可以完成接受连接的工作,就不要在其单元文件名中放置@,也不要在套接字单元中放置Accept=true。在这种情况下,服务单元将完全控制套接字,不会再尝试监听网络端口,直到服务单元完成工作。
由于交接到服务单元的资源和选项种类繁多,很难提供一个分类的总结。不仅如此,这些选项的文档分散在多个手册页中。对于面向资源的单元,查看 systemd.socket(5)、systemd.path(5)和 systemd.device(5)。一个常常被忽视的文档是 systemd.exec(5),它包含了有关服务单元如何在激活时接收资源的信息。
使用辅助单元进行启动优化
systemd 的一个总体目标是简化依赖顺序并加快启动时间。像套接字单元这样的资源单元提供了一种类似于按需启动的方法。我们仍然有一个服务单元和代表服务单元提供资源的辅助单元,但在这种情况下,systemd 在激活辅助单元后立即启动服务单元,而不是等待请求。
这种方案的原因是,像 systemd-journald.service 这样的重要启动时服务单元需要一些时间来启动,并且许多其他单元依赖于它们。然而,systemd 可以非常快速地提供单元的重要资源(如套接字单元),然后它可以立即激活不仅是该重要单元,还有任何依赖于它的单元。一旦重要单元准备就绪,它就控制该资源。
图 6-2 展示了这在传统顺序系统中可能是如何工作的。在这个启动时间线中,服务 E 提供了一个重要的资源 R。服务 A、B 和 C 依赖于此资源(但彼此之间并不依赖),必须等到服务 E 启动完成。因为系统在启动完前一个服务之前不会启动新的服务,所以启动服务 C 需要相当长的时间。

图 6-2:顺序启动时间线与资源依赖关系
图 6-3 展示了一个可能的等效 systemd 启动配置。服务由单元 A、B、C 和 E 表示,新的单元 R 表示单元 E 提供的资源。因为 systemd 可以在单元 E 启动时为单元 R 提供接口,所以单元 A、B、C 和 E 可以同时启动。准备就绪后,单元 E 接管单元 R。一个有趣的地方是,单元 A、B 或 C 在完成启动之前可能不需要访问单元 R 提供的资源。我们正在做的是尽快为它们提供访问资源的选择。

图 6-3:带有资源单元的 systemd 启动时间线
关键在于,尽管在这种情况下您没有创建按需单元启动,但您正在使用使按需启动成为可能的相同功能。对于常见的真实世界示例,请参见运行 systemd 的机器上的 journald 和 D-Bus 配置单元;它们很可能以这种方式并行化。
6.3.8 systemd 辅助组件
随着 systemd 的普及,它不仅直接支持启动和服务管理相关的任务,还通过辅助兼容层支持一些与之无关的任务。您可能会注意到 /lib/systemd 中的众多程序;这些是与这些功能相关的可执行文件。
这里有几个特定的系统服务:
-
udevd 您在第三章学到了这一点;它是 systemd 的一部分。
-
journald 是一种日志服务,处理多种不同的日志机制,包括传统的 Unix
syslog服务。你将在第七章了解更多。 -
resolved 是一个 DNS 名称服务缓存守护进程;你将在第九章了解更多。
这些服务的所有可执行文件都以 systemd- 为前缀。例如,systemd 集成的 udevd 被称为 systemd-udevd。
如果深入了解,你会发现这些程序其实是相对简单的包装器。它们的功能是运行标准的系统工具,并将结果通知 systemd。一个例子是systemd-fsck。
如果你在/lib/systemd目录中看到一个无法识别的程序,可以查看手册页。很可能它不仅会描述该工具,还会描述它所要增强的单元类型。
6.4 System V 运行级别
现在你已经了解了 systemd 及其工作原理,让我们转变一下,看看传统的 System V 初始化的某些方面。在 Linux 系统中,任何给定时刻都会有一组基本的进程(如 crond 和 udevd)在运行。在 System V 初始化中,这种机器的状态被称为运行级别,用从 0 到 6 的数字表示。一个系统大多数时间都处于一个运行级别,但当你关闭机器时,init 会切换到另一个运行级别,以便有序地终止系统服务并告诉内核停止。
你可以通过 who -r 命令来检查系统的运行级别,如下所示:
$ **who -r**
run-level 5 2019-01-27 16:43
这个输出告诉我们当前的运行级别是 5,以及该运行级别被设置的日期和时间。
运行级别有多种用途,但最常见的是区分系统启动、关机、单用户模式和控制台模式。例如,大多数系统传统上使用运行级别 2 到 4 来表示文本控制台;运行级别 5 表示系统启动图形用户界面登录。
但是,运行级别正逐渐成为过去式。即使 systemd 支持它们,它也认为运行级别作为系统的结束状态已经过时,更倾向于使用目标单元。对于 systemd 来说,运行级别主要存在是为了启动仅支持 System V 初始化脚本的服务。
6.5 System V 初始化
System V 初始化实现是 Linux 上最古老的实现之一;其核心思想是支持有序启动到不同的运行级别,并通过精心构建的启动顺序来实现。System V 初始化在大多数服务器和桌面安装中已经不常见,但你可能会在版本 7.0 之前的 RHEL 版本中遇到它,或者在嵌入式 Linux 环境中遇到,例如路由器和手机。此外,一些旧的软件包可能仅提供为 System V 初始化设计的启动脚本;systemd 可以通过兼容模式处理这些脚本,我们将在 6.5.5 节讨论这一点。在这里我们将介绍基本知识,但请记住,你可能不会实际遇到这一部分中涵盖的内容。
一个典型的 System V init 安装包含两个部分:一个中央配置文件和一组大量的启动脚本,后者通过符号链接构成。配置文件 /etc/inittab 是一切的起点。如果你使用的是 System V init,查看你的 inittab 文件中是否有类似以下的行:
id:5:initdefault:
这表示默认的运行级别是 5\。
所有 inittab 中的行都采取以下形式,字段由冒号分隔,顺序如下:
-
一个唯一的标识符(一个短字符串,例如前面示例中的
id)。 -
适用的运行级别编号。
-
init 应该执行的操作(在前面的示例中默认运行级别是 5)。
-
一个要执行的命令(可选)。
要查看命令如何在 inittab 文件中工作,可以参考这一行:
l5:5:wait:/etc/rc.d/rc 5
这一行特别重要,因为它触发了大多数系统配置和服务。在这里,wait 动作决定了 System V init 何时以及如何执行命令:在进入运行级别 5 时运行 /etc/rc.d/rc 5 一次,然后等待该命令执行完成后再进行其他操作。rc 5 命令按数字顺序执行 /etc/rc5.d 中以数字开头的所有内容。稍后我们将更详细地讨论这一点。
除了initdefault和wait之外,以下是一些最常见的 inittab 动作:
respawn
-
respawn动作告诉 init 执行后续的命令,并且如果命令执行完毕,就再次运行它。你可能会在 inittab 文件中看到如下内容:1:2345:respawn:/sbin/mingetty tty1 -
getty程序提供登录提示。前面的这一行用于第一个虚拟控制台(/dev/tty1),这是你按下 alt-F1 或 ctrl-alt-F1 时看到的控制台(参见第 3.4.7 节)。respawn动作会在你注销后再次显示登录提示。
ctrlaltdel
ctrlaltdel动作控制当你在虚拟控制台上按下 ctrl-alt-del 时系统的行为。在大多数系统中,这通常是某种使用shutdown命令的重启命令(在第 6.6 节中讨论)。
sysinit
sysinit动作是 init 启动时应该首先执行的内容,在进入任何运行级别之前。
6.5.1 System V init:启动命令顺序
现在,让我们看看 System V init 是如何启动系统服务的,在允许你登录之前。回想一下之前提到的 inittab 行:
l5:5:wait:/etc/rc.d/rc 5
这一简短的行触发了许多其他程序。事实上,rc 代表 运行命令,许多人称之为 脚本、程序 或 服务。但是这些命令在哪里呢?
这一行中的 5 告诉我们我们正在讨论运行级别 5\。这些命令可能位于 /etc/rc.d/rc5.d 或 /etc/rc5.d 目录中。(运行级别 1 使用 rc1.d,运行级别 2 使用 rc2.d,以此类推。)例如,你可能会在 rc5.d 目录中找到以下项目:
S10sysklogd S20ppp S99gpm
S12kerneld S25netstd_nfs S99httpd
S15netstd_init S30netstd_misc S99rmnologin
S18netbase S45pcmcia S99sshd
S20acct S89atd
S20logoutd S89cron
rc 5 命令通过按以下顺序执行命令来启动 rc5.d 目录中的程序:
S10sysklogd start
S12kerneld start
S15netstd_init start
S18netbase start
--`snip`--
S99sshd start
注意每个命令中的start参数。命令名中的大写S表示该命令应该以start模式运行,数字(00 到 99 之间)决定了rc在序列中启动命令的位置。rc.d中的命令通常是启动/sbin或/usr/sbin*目录中程序的 shell 脚本。
通常,你可以通过使用less或其他分页程序查看脚本来弄清楚特定命令的作用。
你可以手动运行这些命令;然而,通常你会通过init.d目录而不是rc.d*目录来执行,我们接下来将讨论这一点。
6.5.2 System V 初始化链接农场
rc.d目录的内容实际上是指向另一个目录init.d中文件的符号链接。如果你的目标是与rc.d目录中的服务进行交互、添加、删除或修改服务,你需要理解这些符号链接。比如,rc5.d目录的长列表显示出如下结构:
lrwxrwxrwx . . . S10sysklogd -> ../init.d/sysklogd
lrwxrwxrwx . . . S12kerneld -> ../init.d/kerneld
lrwxrwxrwx . . . S15netstd_init -> ../init.d/netstd_init
lrwxrwxrwx . . . S18netbase -> ../init.d/netbase
--`snip`--
lrwxrwxrwx . . . S99httpd -> ../init.d/httpd
--`snip`--
这样的多个子目录中的大量符号链接被称为链接农场。Linux 发行版包含这些链接,以便它们可以在所有运行级别中使用相同的启动脚本。这是一种约定,而不是要求,但它简化了组织结构。
启动和停止服务
要手动启动和停止服务,使用init.d目录中的脚本。例如,手动启动 httpd 网页服务器程序的一种方法是运行init.d/httpd start。类似地,要停止正在运行的服务,可以使用stop参数(例如httpd stop)。
修改启动序列
在 System V 初始化中,通常通过修改链接农场来更改启动序列。最常见的更改是阻止init.d目录中的某个命令在特定运行级别下运行。然而,在进行此操作时需要小心。例如,你可能会考虑在适当的rc.d*目录中删除符号链接。但如果你需要恢复该链接,可能会很难记住它的确切名称。最好的方法之一是,在链接名称的开头添加一个下划线(_),像这样:
# mv S99httpd _S99httpd
这个更改导致rc忽略了_S99httpd,因为文件名不再以S或K开头,但原始名称仍然表明其用途。
要添加服务,创建一个像init.d目录中的脚本,然后在正确的rc.d目录中创建一个符号链接。最简单的做法是复制并修改你理解的init.d*中现有的一个脚本(有关 shell 脚本的更多信息,请参见第十一章)。
添加服务时,选择启动序列中的合适位置来启动它。如果服务启动得太早,可能因为依赖其他服务而无法正常工作。对于非关键服务,大多数系统管理员更倾向于选择 90 年代的数字,这样服务就会在大多数系统自带的服务之后启动。
6.5.3 run-parts
System V init 用于运行init.d脚本的机制已被许多 Linux 系统采用,无论它们是否使用 System V init。它是一个名为run-parts的工具,唯一的作用就是按照某种可预测的顺序运行给定目录中的一堆可执行程序。你可以把run-parts看作是一个人,他在某个目录中输入ls命令,然后运行列在输出中的所有程序。
默认行为是运行目录中的所有程序,但你通常可以选择某些程序并忽略其他程序。在一些发行版中,你不需要太多控制正在运行的程序。例如,Fedora 配备了一个非常简单的run-parts实用程序。
其他发行版,如 Debian 和 Ubuntu,拥有更复杂的run-parts程序。它们的功能包括基于正则表达式运行程序(例如,使用S[0-9]{2}表达式来运行/etc/init.d中的所有“启动”脚本),以及向程序传递参数。这些功能允许你使用单个命令启动和停止 System V 运行级别。
你不需要真正理解如何使用run-parts的细节;事实上,大多数人甚至不知道它的存在。需要记住的主要事项是,它偶尔会出现在脚本中,且它仅用于运行给定目录中的程序。
6.5.4 System V init 控制
偶尔,你需要给 init 一点“推力”,告诉它切换运行级别、重新读取配置,或关闭系统。控制 System V init 时,你使用telinit。例如,要切换到运行级别 3,输入:
# telinit 3
切换运行级别时,init 会尝试杀死所有不在新运行级别的inittab文件中的进程,因此在更改运行级别时要小心。
当你需要添加或移除作业,或对inittab文件进行其他更改时,必须告知 init 进行更改并重新加载该文件。执行此操作的telinit命令是:
# telinit q
你还可以使用telinit s切换到单用户模式。
6.5.5 systemd System V 兼容性
使 systemd 与其他新一代 init 系统不同的一个特点是,它尝试更完整地跟踪由 System V 兼容 init 脚本启动的服务。其工作方式如下:
-
首先,systemd 激活runlevel
.target ,其中N是运行级别。 -
对于/etc/rc
.d 中的每个符号链接,systemd 会识别/etc/init.d中的脚本。 -
systemd 将脚本名称与服务单元关联起来(例如,/etc/init.d/foo将是foo.service)。
-
systemd 激活服务单元,并根据其在rc
.d 中的名称,使用start或stop参数运行脚本。 -
systemd 尝试将脚本中的任何进程与服务单元关联起来。
由于 systemd 与服务单元名称相关联,你可以使用 systemctl 来重启服务或查看其状态。但不要指望在 System V 兼容模式下有什么奇迹;它仍然必须按顺序执行 init 脚本,例如。
6.6 关闭系统
init 控制系统如何关闭和重启。关闭系统的命令在不同版本的 init 中是相同的。正确的关机方式是使用 shutdown 命令。
使用 shutdown 有两种基本方法。如果你 停止 系统,它将关闭机器并保持关闭状态。要立即停止机器,可以运行以下命令:
# shutdown -h now
在大多数机器和 Linux 版本上,halt 会切断机器的电源。你也可以 重启 机器。若要重启,使用 -r 代替 -h。
关机过程需要几秒钟。你应该避免在关机过程中重置或关闭机器。
在前面的示例中,now 表示关机的时间。包括时间参数是必须的,但有很多种方法可以指定它。例如,如果你希望机器在未来某个时间关机,可以使用 +n,其中 n 是 shutdown 在执行前等待的分钟数。有关其他选项,请参阅 shutdown(8) 手册页。
要使系统在 10 分钟后重启,请输入:
# shutdown -r +10
在 Linux 上,shutdown 告知所有已登录用户机器将关闭,但它本身不会做太多实际工作。如果你指定了除 now 以外的时间,shutdown 命令会创建一个名为 /etc/nologin 的文件。当该文件存在时,系统将禁止除超级用户外的任何人登录。
当系统关机时间最终到来时,shutdown 告诉 init 开始关机过程。在 systemd 中,这意味着激活关机单元,而在 System V init 中,这意味着将运行级别更改为 0(停止)或 6(重启)。无论是哪个 init 实现或配置,程序大致如下:
-
init 请求每个进程干净地关闭。
-
如果进程在一段时间后没有响应,init 会终止它,首先尝试发送 TERM 信号。
-
如果 TERM 信号不起作用,init 会对任何未结束的进程使用 KILL 信号。
-
系统将系统文件锁定并为关机做其他准备。
-
系统卸载除了根文件系统之外的所有文件系统。
-
系统将根文件系统重新挂载为只读模式。
-
系统使用
sync程序将所有缓冲数据写入文件系统。 -
最后一步是通过
reboot(2)系统调用告诉内核重启或停止。这个操作可以由 init 或辅助程序来执行,例如reboot、halt或poweroff。
reboot 和 halt 程序的行为取决于它们的调用方式,这可能会引起混淆。默认情况下,这些程序会使用 -r 或 -h 选项调用 shutdown。然而,如果系统已经处于停止或重启的运行级别,这些程序会指示内核立即关闭。如果你真的想要快速关闭计算机,不管不规范关机可能带来的任何损坏,可以使用 -f(强制)选项。
6.7 初始 RAM 文件系统
Linux 启动过程大体上是相当简单的。然而,有一个组件一直让人困惑:initramfs,即初始 RAM 文件系统。可以把它看作是一个在正常用户模式启动之前的小型用户空间插入。首先,让我们来探讨一下它为何存在。
问题源于多种存储硬件的可用性。请记住,Linux 内核并不直接与 PC BIOS 接口或 EFI 交互来从磁盘获取数据,因此,为了挂载其根文件系统,它需要为底层存储机制提供驱动支持。例如,如果根文件系统在一个通过第三方控制器连接的 RAID 阵列上,内核首先需要该控制器的驱动。不幸的是,由于存储控制器驱动程序种类繁多,发行版无法将所有驱动都包含在内核中,因此许多驱动作为可加载模块发布。但是,可加载模块是文件,如果你的内核本身没有挂载文件系统,它就无法加载所需的驱动模块。
解决方法是将一小部分内核驱动模块和一些其他工具集合到一个归档文件中。引导加载程序在运行内核之前将这个归档文件加载到内存中。启动时,内核会将归档内容读取到一个临时的 RAM 文件系统(initramfs)中,并将其挂载到 /,然后将用户模式的控制权交给 initramfs 上的 init。接着,initramfs 中包含的工具允许内核加载真实根文件系统所需的驱动模块。最后,这些工具挂载真实的根文件系统并启动真正的 init。
实现方式各不相同,并且不断发展变化。在一些发行版中,initramfs 上的 init 是一个相当简单的 shell 脚本,它启动 udevd 来加载驱动程序,然后挂载真实的根文件系统并在那里执行 init。在使用 systemd 的发行版中,你通常会看到一个完整的 systemd 安装,没有单元配置文件,只有几个 udevd 配置文件。
初始 RAM 文件系统的一个基本特性是自其创建以来(至今)未曾改变过,那就是如果不需要它,可以绕过它。也就是说,如果你的内核已经具备挂载根文件系统所需的所有驱动程序,你可以在启动加载器配置中省略初始 RAM 文件系统。成功时,去除初始 RAM 文件系统会稍微缩短启动时间。你可以在启动时尝试,通过使用 GRUB 菜单编辑器删除 initrd 行。(最好不要通过更改 GRUB 配置文件来实验,因为你可能会犯下难以修复的错误。)由于某些特性(如按 UUID 挂载)在通用发行版内核中可能不可用,绕过初始 RAM 文件系统变得越来越困难。
你可以检查初始 RAM 文件系统的内容,但需要做一些侦探工作。现在大多数系统使用 mkinitramfs 创建的归档文件,你可以使用 unmkinitramfs 解压它。其他系统可能使用较旧的压缩 cpio 归档文件(请参阅 cpio(1) 手册页)。
一个特别值得关注的部分是初始 RAM 文件系统中 init 过程接近结束时的“pivot”操作。该部分负责移除临时文件系统的内容(以节省内存),并永久切换到真实的根文件系统。
你通常不会自己创建初始 RAM 文件系统,因为这是一个费力的过程。有许多工具可以用来创建初始 RAM 文件系统镜像,你的发行版可能已经自带了其中之一。最常见的两个工具是 mkinitramfs 和 dracut。
6.8 紧急启动和单用户模式
当系统出现问题时,你的第一个应急措施通常是使用发行版的“实时”镜像或使用专门的救援镜像启动系统,例如你可以放在可移动媒体上的 SystemRescueCD。实时镜像只是一个无需安装过程即可启动和运行的 Linux 系统;大多数发行版的安装镜像也可以作为实时镜像使用。修复系统的常见任务包括以下内容:
-
在系统崩溃后检查文件系统。
-
重置遗忘的密码。
-
修复关键文件中的问题,如/etc/fstab 和 /etc/passwd。
-
系统崩溃后从备份中恢复。
另一个快速启动到可用状态的选项是单用户模式。这个方法的思想是系统快速启动到根 shell,而不是经过一系列复杂的服务。在 System V init 中,单用户模式通常是运行级别 1。在 systemd 中,它由rescue.target表示。你通常通过在启动加载器中使用 -s 参数进入此模式。你可能需要输入 root 密码才能进入单用户模式。
单用户模式的最大问题是它没有提供很多便利功能。网络几乎肯定不可用(即使可以使用,也很难操作),你也不会拥有图形用户界面(GUI),甚至终端可能无法正常工作。因此,实时镜像几乎总是被认为是更好的选择。
6.9 展望未来
你现在已经了解了 Linux 系统的内核和用户空间启动阶段,以及 systemd 如何在服务启动后跟踪它们。接下来,我们将更深入地探讨用户空间。首先,我们将查看一些系统配置文件,这些文件是所有 Linux 程序在与用户空间的某些元素交互时使用的。然后,我们将看到 systemd 启动的基本服务。
第七章:系统配置:日志记录、系统时间、批处理作业和用户

当你第一次查看/etc目录以探索系统的配置时,你可能会感到有些不知所措。好消息是,尽管你看到的大多数文件在某种程度上会影响系统的操作,但只有少数文件是基础性文件。
本章介绍了使第四章中讨论的基础设施可供用户空间软件(我们通常使用的工具,例如第二章中介绍的工具)使用的系统部分。具体来说,我们将关注以下内容:
-
系统日志
-
系统库访问的配置文件,用于获取服务器和用户信息
-
一些在系统启动时运行的选定服务器程序(有时称为守护进程)
-
可用于调整服务器程序和配置文件的配置实用程序
-
时间配置
-
定期任务调度
systemd 的广泛使用减少了在典型 Linux 系统中发现的基本独立守护进程的数量。一个例子是系统日志(syslogd)守护进程,其功能现在主要由 systemd 内置的守护进程(journald)提供。不过,仍然有一些传统的守护进程存在,比如 crond 和 atd。
与前几章一样,本章几乎没有涉及网络内容,因为网络是系统的一个独立构建模块。在第九章中,你将看到网络如何融入其中。
7.1 系统日志
大多数系统程序将其诊断输出作为消息写入syslog服务。传统的 syslogd 守护进程通过等待消息并在收到后将其发送到适当的通道(如文件或数据库)来执行此服务。在大多数现代系统中,journald(随 systemd 一起提供)完成了大部分工作。尽管本书将集中讨论 journald,但我们也会涉及传统 syslog 的许多方面。
系统日志记录器是系统中最重要的部分之一。当出现问题而你不知道从哪里开始时,检查日志总是明智的。如果你使用的是 journald,你可以通过journalctl命令来完成这项工作,相关内容我们将在 7.1.2 节中介绍。在较旧的系统中,你需要检查日志文件本身。在这两种情况下,日志消息通常如下所示:
Aug 19 17:59:48 duplex sshd[484]: Server listening on 0.0.0.0 port 22.
一条日志消息通常包含重要信息,如进程名称、进程 ID 和时间戳。还可能包含另外两个字段:设施(一个通用类别)和严重性(消息的紧急程度)。我们稍后会更详细地讨论这些内容。
由于较旧和较新的软件组件的多种组合,了解 Linux 系统中的日志记录可能会有些挑战。一些发行版,例如 Fedora,已将默认设置为仅使用 journald,而其他发行版则同时运行旧版本的 syslogd(如 rsyslogd)和 journald。较旧的发行版和一些专用系统可能根本不使用 systemd,仅使用其中一个 syslogd 版本。此外,一些软件系统完全绕过标准化的日志记录,直接写入自己的日志。
7.1.1 检查你的日志设置
你应该检查自己的系统,了解安装了什么类型的日志记录。以下是如何操作:
-
检查 journald,如果你正在运行 systemd,几乎可以肯定你有 journald。尽管可以在进程列表中查找 journald,但最简单的方法是直接运行
journalctl。如果系统中启用了 journald,你将看到一份分页的日志消息列表。 -
检查 rsyslogd。查看进程列表中是否有 rsyslogd,并查找/etc/rsyslog.conf。
-
如果没有 rsyslogd,请检查是否有 syslog-ng(syslogd 的另一个版本),方法是查找名为/etc/syslog-ng的目录。
继续查看/var/log中的日志文件。如果你有一个 syslogd 版本,通常该目录应包含许多文件,大部分由你的 syslog 守护进程创建。不过,这里也会有一些由其他服务维护的文件;例如,wtmp和lastlog,是last和lastlog等工具访问的日志文件,用来获取登录记录。
此外,/var/log中可能还有其他子目录,包含日志文件。这些日志几乎总是来自其他服务。其中一个子目录,/var/log/journal,是 journald 存储其(二进制)日志文件的地方。
7.1.2 搜索和监控日志
除非你的系统没有 journald,或者你正在查找由其他工具维护的日志文件,否则你将查看日志。journalctl工具默认显示所有日志消息,从最旧的开始(就像它们在日志文件中出现一样)。幸运的是,journalctl默认使用分页器,如less,来显示消息,这样就不会使你的终端被信息淹没。你可以通过分页器搜索消息,并使用journalctl -r反转消息的时间顺序,但有更好的方法来查找日志。
通常,你可以通过在命令行中添加单个字段来搜索日志条目;例如,运行journalctl _PID=8792来搜索来自进程 ID 8792 的消息。然而,最强大的过滤功能则更为通用。如果你需要多个标准,可以指定一个或多个。
按时间过滤
-S(自)选项是缩小到特定时间范围的最有用选项之一。以下是其中一种最简单且最有效的用法示例:
$ **journalctl -S -4h**
该命令中的-4h部分可能看起来像一个选项,但实际上,它是一个时间规范,告诉journalctl在当前时区内查找过去四小时的消息。你也可以使用特定的日期和/或时间的组合:
$ **journalctl -S 06:00:00**
$ **journalctl -S 2020-01-14**
$ **journalctl -S '2020-01-14 14:30:00'**
-U(直到)选项以相同的方式工作,指定journalctl应检索消息的时间范围。然而,它通常不如有用,因为你通常会翻页或搜索消息直到找到你需要的,然后直接退出。
按单元过滤
另一种快速有效的方法是通过 systemd 单元进行过滤。你可以使用-u选项,像这样:
$ **journalctl -u cron.service**
通常在按单元过滤时,你可以省略单元类型(在本例中是.service)。
如果你不知道某个特定单元的名称,可以尝试这个命令来列出日志中的所有单元:
$ **journalctl -F _SYSTEMD_UNIT**
-F选项显示日志中特定字段的所有值。
查找字段
有时你只需要知道要搜索哪个字段。你可以通过以下命令列出所有可用的字段:
$ **journalctl -N**
任何以下划线开头的字段(如之前示例中的_SYSTEMD_UNIT)都是受信任的字段;发送消息的客户端无法更改这些字段。
按文本过滤
一种经典的搜索日志文件的方法是运行grep命令查找所有文件中的相关行,或者在文件中找出可能包含更多信息的位置。同样,你可以使用-g选项通过正则表达式搜索日志消息,像这个示例一样,它将返回包含kernel并且后面跟着memory的消息:
`$ journalctl -g 'kernel.*memory'`
不幸的是,当你以这种方式搜索日志时,你只会得到仅匹配该表达式的消息。通常,重要的信息可能在时间上附近。尝试从匹配中提取时间戳,然后使用journalctl -S和一个稍早的时间,查看同一时间段内的其他消息。
按启动过滤
通常,你会在日志中查找机器启动或关闭(并重启)时的消息。非常容易获得仅来自一次启动的消息,从机器启动到停止。例如,如果你要查找当前启动的开始时间,只需使用-b选项:
$ **journalctl -b**
你也可以添加偏移量;例如,要从上一次启动开始,使用-1的偏移量。
$ j**ournalctl -b -1**
$ `journalctl -r -b -1`
-- Logs begin at Wed 2019-04-03 12:29:31 EDT, end at Fri 2019-08-02 19:10:14 EDT. --
Jul 18 12:19:52 mymachine systemd-journald[602]: Journal stopped
Jul 18 12:19:52 mymachine systemd-shutdown[1]: Sending SIGTERM to remaining processes...
Jul 18 12:19:51 mymachine systemd-shutdown[1]: Syncing filesystems and block devices.
除了像-1这样的偏移量,你还可以通过 ID 查看启动记录。运行以下命令来获取启动 ID:
$ **journalctl --list-boots**
-1 e598bd09e5c046838012ba61075dccbb Fri 2019-03-22 17:20:01 EDT—Fri 2019-04-12 08:13:52 EDT
0 5696e69b1c0b42d58b9c57c31d8c89cc Fri 2019-04-12 08:15:39 EDT—Fri 2019-08-02 19:17:01 EDT
最后,你可以显示内核消息(无论是否选择特定启动)使用journalctl -k。
按严重性/优先级过滤
一些程序会产生大量的诊断消息,这些消息可能会掩盖重要的日志。你可以通过指定一个介于 0(最重要)和 7(最不重要)之间的值来过滤严重性级别,使用-p选项。例如,要获取级别 0 到 3 的日志,可以运行:
$ **journalctl -p 3**
如果你只想获取特定严重性级别的日志,可以使用..范围语法:
$ **journalctl -p 2..3**
按严重性过滤日志听起来可能节省很多时间,但你可能不会觉得它很有用。大多数应用程序默认不会生成大量的消息数据,尽管一些应用程序提供配置选项以启用更详细的日志记录。
简单的日志监控
监控日志的传统方法是使用tail -f或less的跟随模式(less +F)来查看系统日志器实时生成的消息。这并不是一个非常有效的常规系统监控方法(因为太容易错过某些信息),但当你尝试查找问题或实时查看启动和操作时,它很有用。
使用tail -f不适用于 journald,因为它不使用纯文本文件;相反,你可以使用journalctl的-f选项,达到打印实时日志的相同效果:
$ **journalctl -f**
这个简单的调用已经足够满足大多数需求。然而,如果你的系统有一个相对稳定的日志消息流,而这些日志消息与您要查找的内容无关,你可能希望添加一些前述的过滤选项。
7.1.3 日志文件轮转
当你使用 syslog 守护进程时,系统记录的任何日志消息都会进入某个日志文件,这意味着你需要偶尔删除旧消息,以防它们最终占满所有存储空间。不同的发行版有不同的做法,但大多数都使用logrotate工具。
这个机制被称为日志轮转。因为传统的文本日志文件将最旧的消息放在前面,最新的消息放在后面,所以要从文件中删除较旧的消息以释放空间是相当困难的。相反,logrotate维护的日志会被分成多个部分。
假设你有一个名为auth.log的日志文件,位于/var/log目录中,包含最新的日志消息。然后,还有auth.log.1、auth.log.2和auth.log.3,它们分别包含逐渐变旧的数据。当logrotate决定是时候删除一些旧数据时,它会像这样“旋转”文件:
-
删除最旧的文件,auth.log.3。
-
将auth.log.2重命名为auth.log.3。
-
将auth.log.1重命名为auth.log.2。
-
将auth.log重命名为auth.log.1。
各个发行版的名称和某些细节会有所不同。例如,Ubuntu 配置指定logrotate应该压缩从“1”位置移到“2”位置的文件,因此在前面的示例中,你会看到auth.log.2.gz和auth.log.3.gz。在其他发行版中,logrotate会用日期后缀重新命名日志文件,如-20200529。这种方案的一个优势是,更容易找到来自特定时间的日志文件。
你可能会想知道,如果logrotate在另一个工具(如 rsyslogd)想要向日志文件添加内容时执行旋转,会发生什么情况。例如,假设日志程序打开日志文件以进行写入,但在logrotate执行重命名之前并没有关闭它。在这种不太常见的情况下,日志信息会成功写入,因为在 Linux 中,一旦文件被打开,I/O 系统就无法知道它已被重命名。但请注意,消息出现的文件将是具有新名称的文件,例如auth.log.1。
如果logrotate在日志程序尝试打开文件之前已经重命名了该文件,那么open()系统调用将创建一个新的日志文件(例如auth.log),就像logrotate没有运行时一样。
7.1.4 日志维护
存储在/var/log/journal中的日志不需要旋转,因为 journald 本身可以识别并删除旧的消息。与传统的日志管理不同,journald 通常根据日志文件系统上剩余的空间、日志应该占用的空间百分比以及设置的最大日志大小来决定是否删除消息。还有其他日志管理选项,例如日志消息的最大允许年龄。你可以在 journald.conf(5)手册页中找到有关默认值和其他设置的描述。
7.1.5 系统日志详解
现在你已经了解了一些 syslog 和日志的操作细节,是时候稍微退后一步,看看为什么以及如何日志以这种方式工作的。这一讨论更倾向于理论性,而非实际操作;你可以直接跳到本书的下一个主题。
在 1980 年代,出现了一个空白:Unix 服务器需要一种记录诊断信息的方法,但当时没有标准的做法。随着 syslog 与 sendmail 电子邮件服务器一起出现,它的设计非常合理,其他服务的开发者也很容易采用它。RFC 3164 描述了 syslog 的演变。
这个机制相当简单。传统的 syslogd 监听并等待 Unix 域套接字/dev/log上的消息。syslogd 的另一个强大功能是能够在网络套接字上监听,除了/dev/log,这使得客户端机器可以通过网络发送消息。
这使得将整个网络的所有 syslog 消息集中到一个日志服务器成为可能,因此 syslog 在网络管理员中变得非常流行。许多网络设备,如路由器和嵌入式设备,可以作为 syslog 客户端,将它们的诊断消息发送到服务器。
Syslog 采用经典的客户端-服务器架构,并拥有自己的协议(目前在 RFC 5424 中定义)。然而,该协议并不总是标准的,早期版本没有太多结构,仅限于一些基础内容。使用 syslog 的程序员需要为自己的应用程序设计一种描述性、清晰且简短的日志消息格式。随着时间的推移,协议增加了新特性,同时尽可能保持向后兼容性。
设施、严重性和其他字段
因为 syslog 会将来自不同服务的各种类型的消息发送到不同的目的地,所以它需要一种方法来对每条消息进行分类。传统的方法是使用编码的设施和严重性值,这些值通常(但不总是)包含在消息中。除了文件输出,甚至非常旧版本的 syslogd 也能够根据消息的设施和严重性,将重要消息发送到控制台,并直接发送给特定的登录用户——这是一种早期的系统监控工具。
设施是服务的一个通用类别,用来标识发送消息的来源。设施包括内核、邮件系统、打印机等服务和系统组件。
严重性是日志消息的紧急程度。共有八个级别,编号从 0 到 7。它们通常通过名称来引用,尽管这些名称不太一致,而且在不同实现中有所变化:
0: emerg |
4: warning |
|---|---|
1: alert |
5: notice |
2: crit |
6: info |
3: err |
7: debug |
设施和严重性共同构成了 优先级,在 syslog 协议中以一个数字的形式打包。你可以在 RFC 5424 中阅读这些字段的详细信息,学习如何在应用程序中指定它们,可以查看 syslog(3) 手册页面,了解如何在 rsyslog.conf(5) 手册页面中进行匹配。然而,当将它们转换到 journald 时,你可能会遇到一些困惑,在那里,严重性被称为优先级(例如,当你运行 journalctl -o json 获取机器可读的日志输出时)。
不幸的是,当你开始检查协议的优先级部分的细节时,你会发现它没有跟上操作系统其他部分的变化和需求。严重性的定义仍然有效,但可用的设施是硬编码的,包含了如 UUCP 等很少使用的服务,并且没有定义新设施的方法(只有一些通用的 local0 到 local7 插槽)。
我们已经讨论了一些日志数据中的其他字段,但 RFC 5424 还包括了 结构化数据 的规定,即一组任意的键值对,应用程序开发者可以用它们来定义自己的字段。尽管在使用 journald 时需要做一些额外的工作才能使用它们,但将这些数据发送到其他类型的数据库要常见得多。
Syslog 和 journald 之间的关系
journald 已经完全取代了某些系统上的 syslog,这可能会让你产生疑问,为什么 syslog 仍然存在于其他系统上。主要有两个原因:
-
Syslog 有一个明确的跨多台机器聚合日志的方法。当日志仅存储在一台机器上时,监控它们要容易得多。
-
类似 rsyslogd 的 syslog 版本是模块化的,并能够输出到多种不同的格式和数据库(包括 journal 格式)。这使得它们更容易与分析和监控工具连接。
相比之下,journald 强调将单台机器的日志输出收集和组织成单一格式。
当你想做更复杂的事情时,journald 将其日志输出到不同的日志记录器的能力提供了极大的灵活性。尤其是考虑到 systemd 可以收集服务器单元的输出并将其发送到 journald,这让你能够访问比应用程序发送到 syslog 的日志数据更多的信息。
关于日志的最后说明
Linux 系统上的日志记录在其历史中发生了重大变化,并且几乎可以肯定,它将继续发展。目前,在单台机器上收集、存储和检索日志的过程已得到良好的定义,但仍有一些日志记录的其他方面没有标准化。
首先,当你想要在多个机器的网络上聚合和存储日志时,有一系列让人眼花缭乱的选项可供选择。现在,日志不仅仅存储在文本文件中,集中式日志服务器通常会被互联网服务所取代,而日志可以存储在数据库中。
接下来,日志的消费方式发生了变化。曾几何时,日志并不被视为“真实”的数据;它们的主要用途是作为管理员(人类)在出现问题时可以阅读的资源。然而,随着应用程序变得更加复杂,日志需求也随之增长。这些新的需求包括能够搜索、提取、显示和分析日志中的数据。虽然我们有许多将日志存储到数据库中的方法,但在应用程序中使用日志的工具仍处于起步阶段。
最后,确保日志是可信的也是一个问题。最初的 syslog 没有任何身份验证机制;你只是相信任何发送日志的应用程序和/或机器都在说真话。此外,日志没有加密,使其容易被网络中的窃听者监视。这在需要高安全性的网络中是一个严重的风险。现代的 syslog 服务器有标准的方法来加密日志信息并验证其来源的机器。然而,当涉及到单独的应用程序时,情况就变得不那么清晰了。例如,你如何确认自称为你的网站服务器的东西真的是网站服务器?
我们将在本章稍后探讨一些稍微高级的身份验证话题。但现在,让我们继续讨论系统中配置文件的基本组织方式。
7.2 /etc 的结构
大多数 Linux 系统的配置文件都位于 /etc 目录中。历史上,每个程序或系统服务都会在该目录下有一个或多个配置文件,并且由于 Unix 系统组件的数量庞大,/etc 会迅速积累大量文件。
这种方法有两个问题:首先,难以在运行的系统上找到特定的配置文件,其次,维护这种配置的系统也非常困难。例如,如果你想更改 sudo 配置,你必须编辑 /etc/sudoers。但在你更改之后,系统升级可能会覆盖你的自定义配置,因为它会覆盖 /etc 中的所有内容。
多年来的趋势是将系统配置文件放入 /etc 下的子目录中,正如你已经看到的 systemd,它使用了 /etc/systemd。虽然 /etc 下仍然有一些单独的配置文件,但如果你运行 ls -F /etc,你会发现那里大多数内容现在都是子目录。
为了解决覆盖配置文件的问题,现在你可以将自定义配置放入配置子目录中的单独文件中,比如 /etc/grub.d 中的文件。
在 /etc 中可以找到什么类型的配置文件?基本的指南是,单台机器的可定制配置,如用户信息(/etc/passwd)和网络详情(/etc/network),应该放在 /etc 中。然而,一些通用的应用程序详情,如分发版的用户界面默认设置,则不应放在 /etc 中。那些不打算定制的系统默认配置文件通常会放在其他地方,像是 /usr/lib/systemd 中的预打包 systemd 单元文件。
你已经看到了一些与启动相关的配置文件。接下来,让我们继续了解一下系统中如何配置用户。
7.3 用户管理文件
Unix 系统支持多个独立的用户。在内核层面,用户仅仅是数字(用户 ID),但是因为记住一个名字比记住一个数字要容易得多,所以在管理 Linux 时你通常会使用 用户名(或 登录名)。用户名只存在于用户空间中,因此任何与用户名交互的程序都需要在与内核沟通时找到相应的用户 ID。
7.3.1 /etc/passwd 文件
纯文本文件 /etc/passwd 将用户名映射到用户 ID。它的内容如下所示 列表 7-1。
root:x:0:0:Superuser:/root:/bin/sh
daemon:*:1:1:daemon:/usr/sbin:/bin/sh
bin:*:2:2:bin:/bin:/bin/sh
sys:*:3:3:sys:/dev:/bin/sh
nobody:*:65534:65534:nobody:/home:/bin/false
juser:x:3119:1000:J. Random User:/home/juser:/bin/bash
beazley:x:143:1000:David Beazley:/home/beazley:/bin/bash
列表 7-1:/etc/passwd 中的用户列表
每一行代表一个用户,并且有七个字段,通过冒号分隔。第一个字段是用户名。
接下来是用户的加密密码,或者至少曾经是密码字段。在大多数 Linux 系统中,密码不再实际存储在passwd文件中,而是存储在shadow文件中(请参见第 7.3.3 节)。shadow文件的格式与passwd相似,但普通用户没有读取shadow的权限。passwd或shadow文件中的第二个字段是加密密码,它看起来像一堆无法读取的乱码,如d1CVEWiB/oppc。Unix 密码从不以明文存储;事实上,该字段并不是密码本身,而是密码的衍生值。在大多数情况下,从这个字段中获取原始密码非常困难(假设密码不是容易猜测的)。
在第二个passwd文件字段中的x表示加密密码存储在shadow文件中(该文件应该在你的系统上进行配置)。星号(*)表示用户无法登录。
如果此密码字段为空(即你看到两个冒号并排,如::),则登录时不需要密码。请小心这种空白密码。你绝不应该允许用户没有密码就能登录。
剩余的passwd字段如下:
-
用户 ID(UID),它是用户在内核中的表示。你可以有两个条目具有相同的用户 ID,但这样会让你困惑——并且可能也会困扰你的软件——因此请保持用户 ID 的唯一性。
-
组 ID(GID),应为/etc/group文件中编号的条目之一。组决定文件权限,除此之外几乎没有其他作用。这个组也称为用户的主组。
-
用户的真实姓名(通常称为GECOS字段)。有时你会在这个字段中找到逗号,表示房间号和电话号码。
-
用户的主目录。
-
用户的 shell(当用户运行终端会话时执行的程序)。
图 7-1 标识了示例 7-1 中一个条目的各个字段。

图 7-1:密码文件中的一条记录
/etc/passwd文件的语法相当严格,不允许有注释或空行。
7.3.2 特殊用户
你会在/etc/passwd中找到一些特殊用户。超级用户(root)总是具有 UID 0 和 GID 0,如示例 7-1 所示。一些用户,如 daemon,没有登录权限。nobody 用户是一个特权较低的用户;一些进程以 nobody 身份运行,因为它(通常)无法在系统上写入任何内容。
无法登录的用户称为伪用户。尽管它们不能登录,但系统可以以它们的用户 ID 启动进程。像 nobody 这样的伪用户通常是出于安全原因创建的。
同样,这些都是用户空间的约定。这些用户对内核没有特殊意义;唯一对内核有特殊意义的用户 ID 是超级用户的 ID,0。你可以像其他任何用户一样,给 nobody 用户访问系统上的所有内容。
7.3.3 /etc/shadow 文件
Linux 系统上的影子密码文件(/etc/shadow)通常包含用户认证信息,包括与/etc/passwd中用户对应的加密密码和密码过期信息。
shadow文件的引入是为了提供一种更灵活(或许更安全)的存储密码的方法。它包含了一套库和工具,许多工具很快被 PAM(可插拔认证模块)中的组件所取代(我们将在 7.10 节讲解这个高级话题)。为了避免为 Linux 引入一套全新的文件,PAM 使用了/etc/shadow,但并没有使用一些对应的配置文件,例如/etc/login.defs。
7.3.4 操作用户和密码
普通用户通过passwd命令和其他一些工具与/etc/passwd进行交互。使用passwd来更改密码。你可以使用chfn和chsh分别更改真实姓名和登录 shell(shell 必须在/etc/shells中列出)。这些都是 suid-root 可执行文件,因为只有超级用户才能更改/etc/passwd文件。
作为超级用户修改/etc/passwd
由于/etc/passwd只是一个普通的纯文本文件,从技术上讲,超级用户是允许使用任何文本编辑器进行修改的。添加用户时,可以简单地添加一行并为用户创建一个主目录;删除用户时,可以执行相反操作。
然而,像这样直接编辑passwd并不是一个好主意。这样做不仅容易出错,而且如果有其他进程在同时修改passwd,你还可能遇到并发问题。使用终端或图形界面提供的独立命令来修改用户信息会更加简单(也更安全)。例如,要设置用户密码,可以以超级用户身份运行passwd user。使用adduser和userdel分别添加和删除用户。
然而,如果你确实需要直接编辑文件(例如,如果文件已损坏),可以使用vipw程序,它会在你编辑时备份并锁定/etc/passwd作为额外的保护措施。如果你需要编辑/etc/shadow而不是/etc/passwd,请使用vipw -s。(希望你永远不需要做这些操作。)
7.3.5 与用户组的操作
用户组在 Unix 中提供了一种在特定用户之间共享文件的方法。其原理是,你可以为特定组设置读或写权限位,排除其他所有用户。这个功能曾经非常重要,因为许多用户共享一台机器或网络,但近年来随着工作站共享的减少,它的重要性已大大降低。
/etc/group 文件定义了组 ID(如在 /etc/passwd 文件中找到的)。列表 7-2 是一个示例。
root:*:0:juser
daemon:*:1:
bin:*:2:
sys:*:3:
adm:*:4:
disk:*:6:juser,beazley
nogroup:*:65534:
user:*:1000:
列表 7-2:一个示例 /etc/group 文件
与 /etc/passwd 文件一样,/etc/group 中的每一行都是由冒号分隔的字段集。每个条目中的字段如下,从左到右:
-
组名 在你运行类似
ls -l的命令时会显示。 -
组密码 Unix 组密码几乎从不使用,也不应该使用(大多数情况下的一个不错的替代方案是
sudo)。使用*或任何其他默认值。这里的x表示在 /etc/gshadow 中有一个相应的条目,这通常也是一个禁用的密码,用*或!表示。 -
组 ID(一个数字) GID 必须在
group文件中唯一。这个数字会出现在用户的 /etc/passwd 条目中的组字段里。 -
一个可选的用户列表,表示属于该组的用户 除了这里列出的用户外,具有相应组 ID 的用户也属于该组,这些用户在其 passwd 文件条目中有相应的组 ID。
图 7-2 标识了group文件条目中的字段。

图 7-2:group 文件中的一条条目
要查看你所属的组,运行 groups。
7.4 getty 和 login
getty 程序连接到终端并显示登录提示。在大多数 Linux 系统中,getty 很简单,因为系统仅在虚拟终端登录时使用它。在进程列表中,它通常看起来像这样(例如,在 /dev/tty1 上运行时):
$ **ps ao args | grep getty**
/sbin/agetty -o -p -- \u --noclear tty1 linux
在许多系统上,直到你通过类似 Ctrl-Alt-F1 的方式访问虚拟终端之前,你可能根本看不见 getty 进程。这个例子展示了 agetty,这是许多 Linux 发行版默认包含的版本。
在你输入登录名后,getty 用 login 程序替换自己,login 程序会要求你输入密码。如果你输入正确的密码,login 使用 exec() 替换自己,启动你的 shell。否则,你会看到“登录错误”的信息。login 程序的大部分实际认证工作是由 PAM 处理的(见第 7.10 节)。
现在你知道了 getty 和 login 的作用,但你可能永远不需要配置或更改它们。实际上,你很少使用它们,因为大多数用户现在都通过图形界面(如 gdm)或远程使用 SSH 登录,而这两者都不使用 getty 或 login。
7.5 设置时间
Unix 系统依赖于准确的时间记录。内核维护着 系统时钟,这是你运行像 date 这样的命令时参考的时钟。你也可以使用 date 命令设置系统时钟,但通常不建议这样做,因为你永远无法完全准确地设置时间。你的系统时钟应尽可能接近正确的时间。
个人电脑硬件有一个电池供电的实时时钟(RTC)。RTC 不是世界上最精确的时钟,但比没有时钟要好。内核通常会在启动时根据 RTC 设置时间,你可以通过hwclock命令将系统时钟重置为当前硬件时间。为了避免时区或夏令时调整带来的麻烦,建议将硬件时钟设置为协调世界时(UTC)。你可以使用以下命令将 RTC 设置为内核的 UTC 时钟:
# hwclock --systohc --utc
不幸的是,内核在保持时间的准确性方面比 RTC 更差,而且由于 Unix 系统通常在单次启动后会运行数月或数年,因此它们容易发生时间漂移。时间漂移是内核时间与真实时间(由原子钟或其他非常精确的时钟定义)之间的当前差异。
你不应该尝试通过hwclock修复时间漂移,因为基于时间的系统事件可能会丢失或被弄乱。你可以运行像adjtimex这样的工具,根据 RTC 平滑更新时钟,但通常最好通过网络时间守护进程(参见第 7.5.2 节)保持系统时间的正确。
7.5.1 内核时间表示和时区
内核的系统时钟表示自 1970 年 1 月 1 日 00:00 UTC 以来的秒数。要查看此时的数字,可以运行:
$ **date +%s**
为了将这个数字转换成人类可以读懂的格式,用户空间的程序会将其转换为当地时间,并补偿夏令时以及任何其他特殊情况(比如居住在印第安纳州)。当地时区由文件/etc/localtime控制。(不用尝试查看它,它是一个二进制文件。)
你系统上的时区文件位于/usr/share/zoneinfo。你会发现这个目录包含了很多时区文件以及时区别名。要手动设置系统的时区,你可以将/usr/share/zoneinfo中的某个文件复制到/etc/localtime(或者创建一个符号链接),也可以通过你发行版的时区工具来进行更改。命令行程序tzselect可以帮助你识别时区文件。
若要在一个 shell 会话中使用与系统默认时区不同的时区,可以将TZ环境变量设置为/usr/share/zoneinfo中某个文件的名称,并测试更改,如下所示:
$ **export TZ=US/Central**
$ **date**
与其他环境变量一样,你也可以像这样设置单个命令的时区:
$ **TZ=US/Central date**
7.5.2 网络时间
如果你的机器始终连接到互联网,你可以运行网络时间协议(NTP)守护进程,通过远程服务器来维持时间。这曾经由 ntpd 守护进程处理,但与许多其他服务一样,systemd 已用其自己的包(名为 timesyncd)替代了它。大多数 Linux 发行版都包括 timesyncd,并且默认启用。你通常不需要配置它,但如果你有兴趣了解如何配置,可以参考 timesyncd.conf(5)手册页。最常见的配置覆盖是更改远程时间服务器。
如果你希望运行 ntpd 而不是 timesyncd,则需要禁用已安装的 timesyncd。请访问www.ntppool.org/查看那里的说明。如果你仍然希望使用 timesyncd 与不同的服务器,也可以参考该站点。
如果你的机器没有持续的互联网连接,可以使用像 chronyd 这样的守护进程,在断网期间保持时间的同步。
你还可以根据网络时间设置硬件时钟,以帮助系统在重启时保持时间的一致性。许多发行版会自动执行此操作,但如果手动执行,请确保系统时间是从网络同步的,然后运行以下命令:
# hwclock --systohc –-utc
7.6 使用 cron 和定时器单元调度定期任务
有两种方法可以按照重复的时间表运行程序:cron 和 systemd 定时器单元。这项功能对于自动化系统维护任务至关重要。一个例子是日志文件旋转工具,确保硬盘不会因旧日志文件而填满(如本章前面讨论的那样)。cron 服务长期以来一直是执行此任务的事实标准,我们将详细介绍它。然而,systemd 的定时器单元是 cron 的替代方案,在某些情况下具有优势,因此我们也会学习如何使用它们。
你可以在任何适合你的时间运行任何程序。通过 cron 运行的程序称为cron 任务。要安装一个 cron 任务,你需要在crontab 文件中创建一行条目,通常通过运行crontab命令来完成。例如,以下 crontab 文件条目安排每天上午 9:15(本地时区)运行/home/juser/bin/spmake命令:
15 09 * * * /home/juser/bin/spmake
这一行开头的五个字段,空格分隔,指定了计划的时间(另见图 7-3)。这些字段的顺序如下:
-
分钟(0 到 59)。此 cron 任务设置为第 15 分钟执行。
-
小时(0 到 23)。此任务设置为第九小时执行。
-
每月的日期(1 到 31 号)。
-
月份(1 到 12)。
-
星期几(0 到 7)。数字 0 和 7 代表星期天。
![f07003]()
图 7-3:crontab 文件中的一条条目
任何字段中的星号(*)表示匹配每一个值。前面的例子每天运行spmake,因为日期、月份和星期几字段都填充了星号,cron 将其理解为“每天、每月、每周的每一天都运行这个任务”。
如果你只希望在每月的第 14 天运行spmake,可以使用如下的 crontab 行:
15 09 **14** * * /home/juser/bin/spmake
你可以为每个字段选择多个时间。例如,要在每月的第 5 天和第 14 天运行程序,可以在第三个字段中输入5,14:
15 09 **5,14** * * /home/juser/bin/spmake
crontab(5)手册页提供了 crontab 格式的完整信息。
7.6.1 安装 Crontab 文件
每个用户都可以拥有自己的 crontab 文件,这意味着每个系统可能有多个 crontab,通常位于 /var/spool/cron/crontabs。普通用户不能写入这个目录;crontab 命令用于安装、列出、编辑和移除用户的 crontab。
安装 crontab 的最简单方法是将你的 crontab 条目放入一个文件中,然后使用 crontab file 将 file 安装为当前的 crontab。crontab 命令会检查文件格式,确保你没有犯任何错误。要列出你的 cron 作业,运行 crontab -l。要移除 crontab,使用 crontab -r。
在你创建了初始的 crontab 后,使用临时文件进行进一步编辑可能会有些混乱。相反,你可以通过 crontab -e 命令一步到位地编辑并安装你的 crontab。如果你犯了错误,crontab 应该会告诉你错误所在,并询问是否希望再次进行编辑。
7.6.2 系统 Crontab 文件
许多常见的 cron 任务是以超级用户身份运行的。然而,Linux 发行版通常会为整个系统提供一个 /etc/crontab 文件,而不是编辑和维护超级用户的 crontab 来安排这些任务。你不能使用 crontab 来编辑这个文件,并且它的格式略有不同:在要执行的命令之前,有一个额外的字段指定应当运行任务的用户。(这使得你即使不是由同一个用户运行所有任务,也能将系统任务归组在一起。)例如,这个在 /etc/crontab 中定义的 cron 作业会在早上 6:42 作为超级用户(root 1)运行:
42 6 * * * root1 /usr/local/bin/cleansystem > /dev/null 2>&1
7.6.3 定时器单元
创建周期性任务的另一种方法是构建一个 systemd 定时器单元。对于一个全新的任务,你必须创建两个单元:一个定时器单元和一个服务单元。之所以需要两个单元,是因为定时器单元不包含任何关于要执行的任务的具体信息;它只是一个激活机制,用来运行服务单元(或从概念上讲,另一种类型的单元,但最常见的用法是服务单元)。
让我们看一下一个典型的定时器/服务单元对,从定时器单元开始。我们将其命名为 loggertest.timer;与其他自定义单元文件一样,我们将其放在 /etc/systemd/system 目录下(参见 Listing 7-3)。
[Unit]
Description=Example timer unit
[Timer]
OnCalendar=*-*-* *:00,20,40
Unit=loggertest.service
[Install]
WantedBy=timers.target
Listing 7-3: loggertest.timer
这个定时器每 20 分钟运行一次,OnCalendar 选项类似于 cron 语法。在这个示例中,它会在每小时的开始时,以及每小时的 20 分钟和 40 分钟运行。
OnCalendar 时间格式为 年``-``月``-``日``小时``:``分钟``:``秒钟。秒字段是可选的。与 cron 类似,* 表示一种通配符,逗号允许多个值。周期性 / 语法也是有效的;在前面的示例中,你可以将 *:00,20,40 改为 *:00/20(每 20 分钟)以达到相同效果。
关联的服务单元名为loggertest.service(见列表 7-4)。我们在定时器中通过Unit选项显式命名了它,但这并非严格必要,因为 systemd 会查找与定时器单元文件同名的.service文件。这个服务单元也位于/etc/systemd/system,看起来和你在第六章看到的服务单元非常相似。
[Unit]
Description=Example Test Service
[Service]
Type=oneshot
ExecStart=/usr/bin/logger -p local3.debug I\'m a logger
列表 7-4:loggertest.service
其中的核心是ExecStart行,即服务激活时运行的命令。这个特定的示例向系统日志发送一条消息。
注意使用oneshot作为服务类型,表示该服务预计运行并退出,systemd 直到指定的ExecStart命令完成才会认为服务已启动。这对定时器有几个优势:
-
你可以在单元文件中指定多个
ExecStart命令。我们在第六章看到的其他服务单元样式不允许这么做。 -
在使用
Wants和Before依赖指令激活其他单元时,更容易控制严格的依赖顺序 -
你在日志中可以更好地记录单元的开始和结束时间。
7.6.4 cron 与定时器单元
cron 工具是 Linux 系统中最古老的组件之一;它已经存在几十年(早于 Linux 本身),其配置格式多年来变化不大。当某样东西存在这么久时,它就成了替代的目标。
你刚才看到的 systemd 定时器单元可能看起来像是一个逻辑替代方案,实际上,许多发行版现在已经将系统级的定期维护任务移到了定时器单元中。但事实证明,cron 还是有一些优势:
-
配置更简单
-
与许多第三方服务兼容
-
用户更容易安装自己的任务
定时器单元提供以下优势:
-
通过 cgroups 对与任务/单元相关的进程进行更精确的追踪
-
在日志中极好的诊断信息追踪
-
激活时间和频率的附加选项
-
能够使用 systemd 依赖关系和激活机制
也许有一天会为 cron 任务提供一个兼容层,类似于挂载单元和/etc/fstab。然而,仅仅配置这一点就足以证明 cron 格式在短期内不太可能消失。正如你将在下一节看到的,名为systemd-run的工具确实允许创建定时器单元和相关的服务,而无需创建单元文件,但其管理和实现方式有足够的差异,许多用户可能会更喜欢 cron。你将很快看到这一点,当我们讨论at时。
7.7 使用 at 调度一次性任务
要在未来某个时间运行一个任务而不使用 cron,可以使用at服务。例如,要在晚上 10:30 运行myjob,请输入以下命令:
$ **at 22:30**
at> **myjob**
用 ctrl-D 结束输入。(at工具从标准输入中读取命令。)
要检查任务是否已被调度,可以使用atq。要删除任务,可以使用atrm。你还可以通过在DD.MM.YY格式中添加日期来调度几天后的任务——例如,at 22:30 30.09.15。
at 命令没有太多其他功能。虽然它不常用,但在需要时,它可以是非常宝贵的工具。
7.7.1 定时器单元等效项
你可以使用 systemd 定时器单元作为 at 的替代。相比于你之前看到的周期性定时器单元,这些单元更容易创建,并且可以像这样在命令行上运行:
# systemd-run --**on-calendar='2022-08-14 18:00' /bin/echo this is a test**
Running timer as unit: run-rbd000cc6ee6f45b69cb87ca0839c12de.timer
Will run service as unit: run-rbd000cc6ee6f45b69cb87ca0839c12de.service
systemd-run 命令创建一个临时定时器单元,你可以通过常规的 systemctl list-timers 命令查看它。如果你不关心具体时间,也可以通过 --on-active 来指定时间偏移(例如,--on-active=30m 表示 30 分钟后的时间)。
7.8 以常规用户身份运行的定时器单元
到目前为止,我们看到的所有 systemd 定时器单元都是以 root 身份运行的。也可以作为常规用户创建定时器单元。为此,需要在 systemd-run 命令中添加--user选项。
但是,如果你在定时器单元运行之前注销,定时器单元将不会启动;如果你在定时器单元完成之前注销,定时器单元将终止。这是因为 systemd 有一个与已登录用户相关联的用户管理器,而这是运行定时器单元所必需的。你可以使用以下命令告诉 systemd 在你注销后保留用户管理器:
$ **loginctl enable-linger**
作为 root,你还可以为另一个用户启用管理器:
# loginctl enable-linger`user`
7.9 用户访问主题
本章的其余部分涵盖了用户如何获得登录权限、切换到其他用户以及执行其他相关任务的几个主题。这些内容稍微有些高级,如果你准备好深入了解一些进程的内部实现,欢迎跳到下一章。
7.9.1 用户 ID 和用户切换
我们已经讨论了如何使用 setuid 程序(如 sudo 和 su)让你临时更改用户,并且我们已经介绍了像 login 这样的系统组件,它们控制用户的访问权限。也许你会好奇这些组件如何协同工作,以及内核在用户切换中的作用。
当你临时切换到另一个用户时,实际上你所做的只是更改你的用户 ID。实现这一点有两种方式,内核处理这两种方式。第一种是通过 setuid 可执行文件,这在第 2.17 节中有介绍。第二种是通过setuid()系列系统调用。为了适应与进程相关的各种用户 ID,存在几种不同版本的此系统调用,正如你在第 7.9.2 节中将会了解的那样。
内核对进程可以做什么或不能做什么有基本规则,但这里有三条基本规则,涵盖了 setuid 可执行文件和setuid():
-
进程可以运行 setuid 可执行文件,只要它具有足够的文件权限。
-
以 root(用户 ID 0)身份运行的进程可以使用
setuid()变成任何其他用户。 -
非 root 用户身份下的进程在使用
setuid()时受到严格限制;在大多数情况下,它不能使用此功能。
根据这些规则,如果你想从普通用户切换到另一个用户,通常需要结合多种方法。例如,sudo 可执行文件是 setuid root,一旦运行,它可以调用 setuid() 成为另一个用户。
7.9.2 进程所有权、有效 UID、真实 UID 和保存 UID
到目前为止,我们对用户 ID 的讨论是简化过的。实际上,每个进程有多个用户 ID。到目前为止,你已经熟悉了 有效用户 ID(有效 UID,或 euid),它定义了进程的访问权限(最重要的是文件权限)。第二个用户 ID,真实用户 ID(真实 UID,或 ruid),指示谁启动了进程。通常,这些 ID 是相同的,但当你运行一个 setuid 程序时,Linux 在执行过程中将 euid 设置为程序的拥有者,但它会将你的原始用户 ID 保留在 ruid 中。
有效 UID 和真实 UID 之间的区别是令人困惑的,以至于很多关于进程所有权的文档都是错误的。
可以把 euid 想象成 执行者,而 ruid 是 拥有者。ruid 定义了可以与正在运行的进程交互的用户——最重要的是,哪个用户可以终止进程并发送信号。例如,如果用户 A 启动了一个以用户 B 身份运行的新进程(基于 setuid 权限),用户 A 仍然拥有该进程并可以终止它。
我们已经看到,大多数进程的 euid 和 ruid 是相同的。因此,ps 和其他系统诊断程序的默认输出只显示 euid。要查看系统上两个用户 ID,可以尝试这样做,但如果发现所有进程的两个用户 ID 列是相同的,也不必惊讶:
$ **ps -eo pid,euser,ruser,comm**
为了创建一个例外,以便你可以在列中看到不同的值,尝试通过创建一个 setuid 的 sleep 命令副本,运行几秒钟,然后在副本终止之前在另一个窗口中运行前面的 ps 命令。
更令人困惑的是,除了真实 UID 和有效 UID,还有一个 保存的用户 ID(通常不缩写)。在执行过程中,进程可以将其 euid 切换为 ruid 或保存的用户 ID。(为了更复杂,Linux 还有另一个用户 ID:文件系统用户 ID,或 fsuid,它定义了访问文件系统的用户,但很少使用。)
典型的 Setuid 程序行为
ruid 的概念可能与你以前的经验相矛盾。为什么你不需要频繁处理其他用户 ID?例如,在使用 sudo 启动进程后,如果你想终止它,仍然需要使用 sudo;你不能作为普通用户终止它。那么在这种情况下,难道你的普通用户不应该是 ruid,从而给你正确的权限吗?
这种行为的原因在于,sudo 和许多其他 setuid 程序会显式地使用 setuid() 系列系统调用来更改 euid 和 ruid。这些程序这样做是因为当所有用户 ID 不匹配时,常常会产生意外的副作用和访问问题。
有些程序不喜欢将 ruid 设置为 root。为了防止 sudo 更改 ruid,可以在你的 /etc/sudoers 文件中添加这一行(并且要注意对其他你希望以 root 用户身份运行的程序可能带来的副作用!):
Defaults stay_setuid
安全影响
因为 Linux 内核通过 setuid 程序和随后的系统调用处理所有用户切换(因此也包括文件访问权限),所以系统开发者和管理员在处理两件事情时必须格外小心:
-
拥有 setuid 权限的程序的数量和质量
-
这些程序的功能
如果你创建了一个 setuid root 的 bash shell 复制品,任何本地用户都可以执行它并完全控制系统。事情就是这么简单。此外,即便是一个特定用途的 setuid root 程序,如果它有漏洞,也可能构成危险。利用运行在 root 用户下的程序的弱点是系统入侵的主要方法,而且这样的漏洞太多,数不胜数。
由于有太多方法可以突破系统,因此防止入侵是一项多方面的工作。保持系统不受不良活动干扰的最基本方式之一就是强制实施用户身份验证,要求使用用户名和强密码。
7.9.3 用户身份识别、认证与授权
多用户系统必须在三个领域提供基本的用户安全支持:身份识别、认证和授权。安全中的 身份识别 部分回答了“用户是谁”的问题。认证 部分要求用户 证明 自己就是所声称的身份。最后,授权 用于定义和限制用户 允许 做什么。
在用户身份识别方面,Linux 内核只知道进程和文件所有权的数字用户 ID。内核知道如何运行 setuid 可执行文件的授权规则,以及如何使用 setuid() 系列系统调用将用户从一个 ID 切换到另一个 ID。然而,内核并不知晓任何有关身份验证的内容:如用户名、密码等等。实际上,所有与身份验证相关的操作都发生在用户空间。
我们在第 7.3.1 节中讨论了用户 ID 和密码之间的映射;现在我们将讨论用户进程如何访问这一映射。我们将从一个过于简化的情况开始,其中一个用户进程想知道它的用户名(即与 euid 相对应的名称)。在传统的 Unix 系统上,进程可以通过类似以下的方式来获取其用户名:
-
进程通过
geteuid()系统调用向内核请求其 euid。 -
进程打开 /etc/passwd 文件并从头开始读取。
-
进程读取 /etc/passwd 文件的一行。如果没有内容可以读取,说明进程未能找到用户名。
-
该过程将行解析为字段(分隔所有冒号之间的内容)。第三个字段是当前行的用户 ID。
-
该过程将步骤 4 中的 ID 与步骤 1 中的 ID 进行比较。如果它们相同,则步骤 4 中的第一个字段就是所需的用户名,过程可以停止搜索并使用此名称。
-
该过程继续处理 /etc/passwd 中的下一行,并返回到步骤 3。
这是一个长流程,现实中的实现通常更加复杂。
7.9.4 使用库获取用户信息
如果每个需要知道当前用户名的开发者都必须编写您刚刚看到的所有代码,那么系统将是一个令人毛骨悚然、不连贯、错误百出的、臃肿且不可维护的烂摊子。幸运的是,我们通常可以使用标准库来执行这些重复性任务;在这种情况下,通常只需在从 geteuid() 获取答案后,调用标准库中的 getpwuid() 函数即可获得用户名。(有关如何使用它们的更多信息,请参阅手册页。)
标准库在系统上的可执行文件之间共享,因此您可以在不更改任何程序的情况下,对认证实现进行重大更改。例如,您可以通过只更改系统配置,从使用 /etc/passwd 作为用户信息转变为使用网络服务如 LDAP。
这种方法对于识别与用户 ID 关联的用户名效果良好,但密码问题更为棘手。第 7.3.1 节描述了传统上加密密码如何存储在 /etc/passwd 中,因此如果您想验证用户输入的密码,您需要对用户输入的内容进行加密,并与 /etc/passwd 文件的内容进行比较。
这种传统实现有许多局限性,包括:
-
它不允许您设置系统范围内的加密协议标准。
-
它假设您可以访问加密密码。
-
它假设每次用户想访问需要认证的内容时,都提示用户输入密码(这会很烦人)。
-
它假设您想使用密码。如果您想使用一次性令牌、智能卡、生物识别或其他形式的用户认证,您必须自己添加支持。
其中一些局限性促使了shadow密码包的发展,该包在第 7.3.3 节中讨论,迈出了允许系统范围内密码配置的第一步。但解决大部分问题的方案是通过 PAM 的设计和实现。
7.10 可插拔认证模块
为了适应用户身份验证的灵活性,1995 年 Sun Microsystems 提出了一个名为 可插拔认证模块(PAM) 的新标准,这是一个用于身份验证的共享库系统(Open Software Foundation RFC 86.0,1995 年 10 月)。为了验证用户,应用程序将用户交给 PAM,PAM 决定用户是否能成功进行身份验证。通过这种方式,增加对额外身份验证技术的支持变得相对简单,比如双因素认证和物理密钥。除了身份验证机制支持,PAM 还为服务提供了有限的授权控制(例如,如果你想禁止某些用户使用 cron 服务)。
由于存在多种身份验证场景,PAM 使用了许多动态加载的 身份验证模块。每个模块执行特定任务,是一个可以被进程动态加载并在其可执行空间中运行的共享对象。例如,pam_unix.so 是一个可以检查用户密码的模块。
这可不是件简单的事。编程接口并不容易,而且也不清楚 PAM 是否能解决所有现有的问题。尽管如此,几乎每个需要身份验证的 Linux 系统程序都支持 PAM,并且大多数发行版都使用 PAM。而且,因为它是在现有的 Unix 身份验证 API 之上工作的,所以将支持集成到客户端中几乎不需要额外的工作。
7.10.1 PAM 配置
我们将通过检查 PAM 的配置来探讨其基本工作原理。你通常可以在 /etc/pam.d 目录下找到 PAM 的应用配置文件(较老的系统可能使用一个单独的 /etc/pam.conf 文件)。大多数安装都会包含多个文件,因此你可能不知道从哪里开始。一些文件名,如 cron 和 passwd,对应你已经熟悉的系统部分。
由于这些文件中的具体配置在不同的发行版之间差异很大,可能很难找到一个普遍适用的示例。我们将查看一个你可能会在 chsh(更改 shell 程序)中找到的配置行示例:
auth requisite pam_shells.so
这一行表示用户的 shell 必须列在 /etc/shells 中,才能让用户通过 chsh 程序成功进行身份验证。我们来看一下具体如何操作。每个配置行都有三个字段:功能类型、控制参数和模块,依次排列。以下是这个示例中的含义:
-
功能类型:用户应用程序请求 PAM 执行的功能。在这里是
auth,即验证用户的任务。 -
控制参数:此设置控制 PAM 在当前行的操作成功或失败后的行为(例如本示例中的
requisite)。我们稍后会详细介绍。 -
模块:此行运行的身份验证模块,决定了该行的实际功能。在这里,pam_shells.so 模块检查用户当前的 shell 是否列在 /etc/shells 中。
PAM 配置的详细信息可以在 pam.conf(5) 手册页中找到。让我们来看一下其中的一些要点。
功能类型
用户应用程序可以要求 PAM 执行以下四种功能之一:
-
auth验证用户身份(检查用户是否为他们声称的身份)。 -
account检查用户账户状态(例如,查看用户是否被授权执行某些操作)。 -
session仅为用户当前会话执行某些操作(例如,显示每日信息)。 -
password更改用户的密码或其他凭证。
对于任何配置行,模块和功能一起决定 PAM 的操作。一个模块可以有多个功能类型,因此在确定配置行的目的时,始终记得将功能和模块作为一对来考虑。例如,pam_unix.so 模块在执行 auth 功能时检查密码,但在执行 password 功能时设置密码。
控制参数和堆叠规则
PAM 的一个重要特性是其配置行指定的规则会堆叠,这意味着在执行某个功能时,可以应用多条规则。这就是控制参数重要性的原因:一条规则中操作的成功或失败会影响后续的规则,甚至可能导致整个功能的成功或失败。
控制参数有两种类型:简单语法和更高级的语法。这里是你将在规则中找到的三种主要简单语法控制参数:
-
sufficient如果此规则成功,认证成功,PAM 不再查看其他规则。如果规则失败,PAM 将继续执行其他规则。 -
requisite如果此规则成功,PAM 将继续执行其他规则。如果规则失败,认证将失败,PAM 不再查看后续规则。 -
required如果此规则成功,PAM 将继续执行其他规则。如果规则失败,PAM 仍会继续执行其他规则,但无论后续规则的最终结果如何,都会返回认证失败。
继续前面的例子,下面是 chsh 身份验证功能的堆叠示例:
auth sufficient pam_rootok.so
auth requisite pam_shells.so
auth sufficient pam_unix.so
auth required pam_deny.so
在此配置下,当 chsh 命令请求 PAM 执行身份验证功能时,PAM 会执行以下操作(见 图 7-4 了解流程图):
-
pam_rootok.so 模块检查是否是 root 用户在尝试进行身份验证。如果是,它会立即成功并且不再进行进一步的身份验证。这是因为控制参数被设置为
sufficient,意味着此操作的成功足以让 PAM 立即向chsh返回成功。否则,它会继续执行第 2 步。![f07004]()
图 7-4:PAM 规则执行流程
-
pam_shells.so 模块会检查用户的 shell 是否在 /etc/shells 文件中。如果不在该文件中,模块将返回失败,并且
requisite控制参数表示 PAM 必须立即将此失败报告给chsh,并且不再尝试进一步的身份验证。否则,模块将返回成功,并执行requisite控制标志;继续执行步骤 3。 -
pam_unix.so 模块会要求用户输入密码并进行检查。控制参数设置为
sufficient,因此该模块的成功(即正确密码)足以使 PAM 向chsh报告成功。如果密码不正确,PAM 会继续执行步骤 4。 -
pam_deny.so 模块总是失败,因为控制参数设置为
required,因此 PAM 会将失败报告返回给chsh。这是当没有其他可以尝试的方法时的默认配置。(请注意,required控制参数不会立即导致 PAM 失败,它会执行堆栈上剩下的所有行,但 PAM 始终会将失败报告返回给应用程序。)
高级控制参数语法以方括号 ([]) 表示,允许你根据模块的具体返回值(不仅仅是成功或失败)手动控制反应。详细信息请参见 pam.conf(5) 手册页;理解了简单语法后,你就能轻松应对高级语法。
模块参数
PAM 模块可以在模块名称后面添加参数。你会经常看到以下示例,使用 pam_unix.so 模块:
auth sufficient pam_unix.so nullok
这里的 nullok 参数表示用户可以没有密码(默认情况下,如果用户没有密码,则会失败)。
7.10.2 PAM 配置语法提示
由于其控制流能力和模块参数语法,PAM 配置语法具有编程语言的许多特性,并且具备一定的强大功能。到目前为止,我们只是触及了表面,但以下是一些关于 PAM 的其他提示:
-
要找出系统上有哪些 PAM 模块,可以尝试
man -k pam_(注意下划线)。追踪模块的具体位置可能会比较困难。你可以尝试使用locate pam_unix.so命令,看看能找到哪里。 -
手册页包含每个模块的函数和参数。
-
许多发行版会自动生成某些 PAM 配置文件,因此直接更改 /etc/pam.d 下的文件可能不明智。在编辑 /etc/pam.d 文件之前,请阅读其中的注释;如果它们是生成的文件,注释会告诉你它们的来源。
-
/etc/pam.d/other 配置文件定义了任何缺乏自身配置文件的应用程序的默认配置。默认情况下通常会拒绝所有操作。
-
在 PAM 配置文件中包含额外配置文件有不同的方式。
@include语法加载整个配置文件,但你也可以使用控制参数只加载特定功能的配置。不同的发行版可能会有所不同。 -
PAM 配置不仅仅包括模块参数。一些模块可以访问 /etc/security 中的附加文件,通常用于配置每个用户的限制。
7.10.3 PAM 与密码
由于 Linux 密码验证多年来的演变,存在一些密码配置遗留问题,这些问题有时会引起困惑。首先需要注意的是文件 /etc/login.defs。这是原始影子密码套件的配置文件。它包含了用于 /etc/shadow 密码文件的加密算法信息,但在安装了 PAM 的系统中很少使用,因为 PAM 配置中已经包含了这些信息。话虽如此,/etc/login.defs 中的加密算法应该与 PAM 配置一致,尤其是在你遇到不支持 PAM 的应用程序时。
PAM 是如何获取密码加密方案的信息的?记住,PAM 与密码交互有两种方式:auth 函数(用于验证密码)和 password 函数(用于设置密码)。追踪设置密码的参数最为简单。最好的方法可能就是直接使用 grep:
$ **grep password.*unix /etc/pam.d/***
匹配的行应包含 pam_unix.so,并且类似于以下内容:
password sufficient pam_unix.so obscure sha512
参数 obscure 和 sha512 告诉 PAM 在设置密码时该如何操作。首先,PAM 会检查密码是否“足够复杂”(也就是说,密码与旧密码是否过于相似,等等),然后 PAM 会使用 SHA512 算法加密新密码。
但是,只有在用户 设置 密码时才会发生这种情况,而不是 PAM 在 验证 密码时。那么,PAM 如何知道在认证时使用哪种算法呢?不幸的是,配置文件不会告诉你任何信息;对于 auth 函数的 pam_unix.so 并没有加密相关的参数。手册页也没有提供任何信息。
结果显示,(截至本文写作时)pam_unix.so 仅仅是尝试猜测算法,通常是通过请求 libcrypt 库来执行一些繁琐的操作,尝试一系列方法直到某个方法有效,或者尝试完所有方法仍然没有结果。因此,通常你不需要担心验证加密算法。
7.11 展望未来
我们现在大约已进入本书的中期,已经涵盖了 Linux 系统中的许多重要构建模块。关于 Linux 系统上的日志记录和用户的讨论展示了如何将服务和任务划分为小而独立的模块,同时这些模块仍然能够在一定程度上相互作用。
本章几乎专注于用户空间,现在我们需要进一步完善对用户空间进程及其消耗的资源的理解。为了做到这一点,我们将在第八章回到内核层面。
第八章:更深入地了解进程和资源利用

本章将深入探讨进程、内核和系统资源之间的关系。硬件资源有三种基本类型:CPU、内存和 I/O。进程争夺这些资源,内核的任务是公平地分配资源。内核本身也是一种资源——一个软件资源,供进程用来执行诸如创建新进程和与其他进程通信等任务。
本章中你看到的许多工具被视为性能监控工具。当你的系统变得非常缓慢,你试图找出原因时,它们特别有用。然而,你不应该过于关注性能。试图优化一个已经正常工作的系统是浪费时间。大多数系统的默认设置已经经过精心选择,因此只有在你有非常特殊的需求时才应该更改它们。相反,应该专注于理解工具实际衡量的内容,这样你将深入了解内核的工作方式以及它如何与进程交互。
8.1 跟踪进程
在第 2.16 节中,你学会了如何使用 ps 命令列出在特定时间运行的系统进程。ps 命令列出了当前的进程及其使用统计信息,但它对进程如何随时间变化几乎没有提供帮助。因此,它并不会立即帮助你确定哪个进程占用了过多的 CPU 时间或内存。
top 程序提供了一个交互式界面,用于显示 ps 命令显示的信息。它展示了当前的系统状态以及 ps 列表中的字段,并且每秒更新一次。或许最重要的是,top 会将最活跃的进程(默认情况下是当前占用最多 CPU 时间的进程)列在显示的顶部。
你可以通过按键向 top 发送命令。其最常用的命令是更改排序顺序或过滤进程列表:
-
空格键 立即更新显示
-
M 按当前常驻内存使用量排序
-
T 按总 (累计) CPU 使用量排序
-
P 按当前 CPU 使用率排序(默认)
-
u 显示仅一个用户的进程
-
f 选择显示不同的统计数据
-
? 显示所有
top命令的使用摘要
atop 和 htop 两个相似的工具提供了增强的视图和功能集。它们的大多数额外功能添加了其他工具中存在的功能。例如,htop 共享了许多在下一节中描述的 lsof 命令的功能。
8.2 使用 lsof 查找打开的文件
lsof 命令列出了打开的文件以及使用它们的进程。因为 Unix 非常重视文件,lsof 是找出故障点时最有用的工具之一。但是,lsof 不仅仅局限于常规文件——它还可以列出网络资源、动态库、管道等。
8.2.1 阅读 lsof 输出
在命令行运行 lsof 通常会产生大量的输出。以下是你可能会看到的一部分。这些输出(略作调整以提高可读性)包括了来自 systemd(init)进程以及正在运行的 vi 进程的打开文件:
# lsof
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
systemd 1 root cwd DIR 8,1 4096 2 /
systemd 1 root rtd DIR 8,1 4096 2 /
systemd 1 root txt REG 8,1 1595792 9961784 /lib/systemd/systemd
systemd 1 root mem REG 8,1 1700792 9961570 /lib/x86_64-linux-gnu/libm-2.27.so
systemd 1 root mem REG 8,1 121016 9961695 /lib/x86_64-linux-gnu/libudev.so.1
--`snip`--
vi 1994 juser cwd DIR 8,1 4096 4587522 /home/juser
vi 1994 juser 3u REG 8,1 12288 786440 /tmp/.ff.swp
--`snip`--
输出在顶部行列出以下字段:
-
COMMAND持有文件描述符的进程的命令名称。 -
PID进程 ID。 -
USER运行进程的用户。 -
FD该字段可以包含两种元素。在前面的输出中,FD列显示文件的用途。FD字段还可以列出打开文件的 文件描述符 —— 这是进程与系统库和内核一起用来标识和操作文件的数字;最后一行显示了一个文件描述符为3的文件。 -
TYPE文件类型(常规文件、目录、套接字等)。 -
DEVICE持有文件的设备的主次设备号。 -
SIZE/OFF文件的大小。 -
NODE文件的 inode 号。 -
NAME文件名。
lsof(1) 手册页包含了你可能在每个字段中看到的完整列表,但输出应该是自解释的。例如,查看 FD 字段中带有 cwd 的条目。这些行表示进程的当前工作目录。另一个例子是最后一行,它显示了一个用户的 vi 进程(PID 1994)正在使用的临时文件。
8.2.2 使用 lsof
运行 lsof 有两种基本方法:
-
列出所有内容并将输出管道传输到像
less这样的命令,然后搜索你要查找的内容。由于输出量巨大,这可能需要一些时间。 -
使用命令行选项缩小
lsof提供的列表。
你可以使用命令行选项提供一个文件名作为参数,让 lsof 只列出匹配该参数的条目。例如,以下命令显示了在 /usr 及其所有子目录中打开的文件条目:
$ **lsof +D /usr**
要列出某个特定进程 ID 的打开文件,请运行:
$ **lsof -p** `pid`
要简要查看 lsof 的众多选项,请运行 lsof -h。大多数选项与输出格式有关。(有关 lsof 网络功能的讨论,请参见第十章。)
8.3 跟踪程序执行和系统调用
我们到目前为止看到的工具检查的是活动进程。然而,如果你根本不知道为什么程序在启动后几乎立即死掉,lsof 是帮不上忙的。事实上,你甚至很难在命令失败时同时运行 lsof。
strace(系统调用跟踪)和 ltrace(库跟踪)命令可以帮助你发现程序试图执行的操作。这些工具会产生非常大量的输出,但一旦你知道该查找什么,你将能够获得更多的信息来追踪问题。
8.3.1 strace
回想一下,系统调用是用户空间进程请求内核执行的特权操作,比如打开文件并读取数据。strace工具会打印进程执行的所有系统调用。要查看它的实际操作,运行以下命令:
$ **strace cat /dev/null**
默认情况下,strace将其输出发送到标准错误。如果你想将输出保存到文件中,可以使用-o save_file选项。你也可以通过在命令行中附加2> save_file来重定向输出,但这也会捕获你正在检查的命令的任何标准错误。
在第一章中,你学到当一个进程想要启动另一个进程时,它调用fork()系统调用来生成自己的副本,然后副本使用exec()系列系统调用中的一个来启动新程序。strace命令在fork()调用之后开始跟踪新进程(即原始进程的副本)。因此,来自此命令的输出的第一行应该显示execve()的执行,接着是内存初始化调用brk(),如下所示:
execve("/bin/cat", ["cat", "/dev/null"], 0x7ffef0be0248 /* 59 vars */) = 0
brk(NULL) = 0x561e83127000
输出的下一部分主要涉及加载共享库。除非你真的想深入研究共享库系统,否则可以忽略这一部分:
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=119531, ...}) = 0
mmap(NULL, 119531, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fa9db241000
close(3) = 0
--`snip`--
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260\34\2\0\0\0\0\0"..., 832) = 832
此外,跳过mmap输出,直到你看到输出末尾附近的类似如下的行:
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 1), ...}) = 0
openat(AT_FDCWD, "/dev/null", O_RDONLY) = 3
fstat(3, {st_mode=S_IFCHR|0666, st_rdev=makedev(0x1, 3), ...}) = 0
fadvise64(3, 0, 0, POSIX_FADV_SEQUENTIAL) = 0
mmap(NULL, 139264, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa9db21b000
read(3, "", 131072) = 0
munmap(0x7fa9db21b000, 139264) = 0
close(3) = 0
close(1) = 0
close(2) = 0
exit_group(0) = ?
+++ exited with 0 +++
这部分输出展示了命令的执行情况。首先,查看openat()调用(open()的一个略微变化),它打开了一个文件。3是一个表示成功的结果(3是内核在打开文件后返回的文件描述符)。在其下方,你可以看到cat从/dev/null读取数据(即read()调用,文件描述符也是3)。然后没有更多的内容可以读取,所以程序关闭文件描述符并通过exit_group()退出。
当命令遇到错误时会发生什么?尝试运行strace cat not_a_file并检查结果输出中的open()调用:
openat(AT_FDCWD, "not_a_file", O_RDONLY) = -1 ENOENT (No such file or directory)
因为open()无法打开文件,所以它返回-1以指示错误。你可以看到strace报告了具体的错误,并给出了简短的错误描述。
丢失的文件是 Unix 程序中最常见的问题,因此,如果系统日志和其他日志信息没有提供太多帮助,而且你在追踪丢失的文件时别无他法,strace可以发挥很大的作用。你甚至可以在那些会fork()或自我分离的守护进程上使用它。例如,要追踪一个名为crummyd的虚拟守护进程的系统调用,可以输入:
$ **strace -o crummyd_strace -ff crummyd**
在这个示例中,strace的-o选项将crummyd产生的任何子进程的操作记录到crummyd_strace. pid文件中,其中pid是子进程的进程 ID。
8.3.2 ltrace
ltrace命令跟踪共享库调用。它的输出与strace类似,因此在这里提到它,但它不会跟踪内核级别的任何内容。请注意,共享库调用比系统调用要多得多。你肯定需要过滤输出,ltrace本身有很多内建选项可以帮助你。
8.4 线程
在 Linux 中,一些进程被划分为称为线程的部分。线程与进程非常相似——它有一个标识符(线程 ID,或TID),并且内核像调度进程一样调度和运行线程。然而,不同于独立进程通常不共享系统资源(如内存和 I/O 连接),同一进程中的所有线程共享其系统资源和部分内存。
8.4.1 单线程和多线程进程
许多进程只有一个线程。一个有一个线程的进程是单线程的,而一个有多个线程的进程是多线程的。所有进程在启动时都是单线程的。这个起始线程通常称为主线程。主线程可能会启动新的线程,使进程变为多线程,类似于进程通过调用fork()启动一个新进程。
多线程进程的主要优势是,当进程有很多任务需要执行时,线程可以在多个处理器上同时运行,从而可能加速计算。尽管使用多个进程也能实现并行计算,但线程的启动速度比进程快,而且线程之间通常比进程通过网络连接或管道等通道进行通信更容易或更高效,因为它们共享内存。
一些程序使用线程来克服管理多个 I/O 资源的问题。传统上,进程有时会使用fork()启动一个新的子进程,以处理新的输入或输出流。线程提供了类似的机制,但没有启动新进程的开销。
8.4.2 查看线程
默认情况下,ps和top命令的输出只显示进程。要在ps中显示线程信息,需添加m选项。清单 8-1 展示了一些示例输出。
$ **ps m**
PID TTY STAT TIME COMMAND
3587 pts/3 - 0:00 bash1
- - Ss 0:00 -
3592 pts/4 - 0:00 bash2
- - Ss 0:00 -
12534 tty7 - 668:30 /usr/lib/xorg/Xorg -core :03
- - Ssl+ 659:55 -
- - Ssl+ 0:00 -
- - Ssl+ 0:00 -
- - Ssl+ 8:35 -
清单 8-1:使用ps m查看线程
这个清单显示了进程及其线程。PID 列中带有数字的每一行(在 1、2 和 3 的位置)表示一个进程,类似于正常的ps输出。PID 列中带有破折号的行表示与进程关联的线程。在这个输出中,1 和 2 位置的进程每个只有一个线程,但 3 位置的进程 12534 是多线程的,有四个线程。
如果你想使用ps查看 TID,你可以使用自定义输出格式。清单 8-2 仅显示 PID、TID 和命令:
$ **ps m -o pid,tid,command**
PID TID COMMAND
3587 - bash
- 3587 -
3592 - bash
- 3592 -
12534 - /usr/lib/xorg/Xorg -core :0
- 12534 -
- 13227 -
- 14443 -
- 14448 -
清单 8-2:使用ps m显示 PID 和 TID
本列表中的示例输出对应于列表 8-1 中显示的线程。请注意,单线程进程的 TID 与 PID 相同;这就是主线程。对于多线程进程 12534,线程 12534 也是主线程。
线程在资源监控中可能会引起混淆,因为多线程进程中的各个线程可以同时消耗资源。例如,top默认不显示线程;你需要按 H 来启用它。对于你即将看到的大多数资源监控工具,你需要做一些额外的操作才能启用线程显示。
8.5 资源监控简介
现在,我们将讨论一些资源监控的话题,包括处理器(CPU)时间、内存和磁盘 I/O。我们将从系统范围和每个进程的角度来检查利用率。
许多人为了提高性能而接触 Linux 内核的内部机制。然而,大多数 Linux 系统在默认设置下已经能够很好地运行,你可以花费几天时间调整机器的性能,但如果你不知道该关注什么,可能得不到有意义的结果。所以,在你实验本章工具时,不要考虑性能,而是要关注观察内核如何在各个进程间分配资源。
8.5.1 测量 CPU 时间
要随时间监控一个或多个特定进程,可以使用top的-p选项,语法如下:
$ **top -p** `pid1``[`**-p** `pid2 ...]`
要了解一个命令在其生命周期内使用了多少 CPU 时间,可以使用time。不幸的是,这里有一些混淆,因为大多数 shell 都有内置的time命令,它提供的统计信息并不丰富,而在/usr/bin/time中有一个系统工具。你可能首先遇到的是bash的内置命令,因此尝试使用ls命令运行time:
$ **time ls**
在ls终止后,time应该打印出类似以下的输出:
real 0m0.442s
user 0m0.052s
sys 0m0.091s
用户时间(user)是 CPU 运行程序的自有代码所花费的秒数。有些命令执行得非常快,以至于 CPU 时间接近 0。系统时间(sys或system)是内核执行进程工作所花费的时间(例如,读取文件和目录)。最后,真实时间(real)(也叫经过时间)是从开始到结束运行进程所花费的总时间,包括 CPU 执行其他任务的时间。这个数字通常对性能测量不是很有用,但从经过时间中减去用户时间和系统时间,可以大致了解进程在等待系统和外部资源上的时间。例如,等待网络服务器响应请求所花费的时间会显示在经过时间中,但不会显示在用户时间或系统时间中。
8.5.2 调整进程优先级
你可以改变内核如何调度进程,从而使该进程比其他进程获取更多或更少的 CPU 时间。内核根据进程的调度 优先级 运行每个进程,优先级是一个介于 –20 和 20 之间的数字,其中 –20 代表最高优先级。(是的,这可能会让人感到困惑。)
ps -l 命令列出了进程的当前优先级,但通过 top 命令查看优先级的实际效果会更直观,如下所示:
$ **top**
Tasks: 244 total, 2 running, 242 sleeping, 0 stopped, 0 zombie
Cpu(s): 31.7%us, 2.8%sy, 0.0%ni, 65.4%id, 0.2%wa, 0.0%hi, 0.0%si, 0.0%st
Mem: 6137216k total, 5583560k used, 553656k free, 72008k buffers
Swap: 4135932k total, 694192k used, 3441740k free, 767640k cached
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28883 bri 20 0 1280m 763m 32m S 58 12.7 213:00.65 chromium-browse
1175 root 20 0 210m 43m 28m R 44 0.7 14292:35 Xorg
4022 bri 20 0 413m 201m 28m S 29 3.4 3640:13 chromium-browse
4029 bri 20 0 378m 206m 19m S 2 3.5 32:50.86 chromium-browse
3971 bri 20 0 881m 359m 32m S 2 6.0 563:06.88 chromium-browse
5378 bri 20 0 152m 10m 7064 S 1 0.2 24:30.21 xfce4-session
3821 bri 20 0 312m 37m 14m S 0 0.6 29:25.57 soffice.bin
4117 bri 20 0 321m 105m 18m S 0 1.8 34:55.01 chromium-browse
4138 bri 20 0 331m 99m 21m S 0 1.7 121:44.19 chromium-browse
4274 bri 20 0 232m 60m 13m S 0 1.0 37:33.78 chromium-browse
4267 bri 20 0 1102m 844m 11m S 0 14.1 29:59.27 chromium-browse
2327 bri 20 0 301m 43m 16m S 0 0.7 109:55.65 xfce4-panel
在这个 top 输出中,PR(优先级)列列出了内核为该进程分配的当前调度优先级。数字越大,内核在其他进程需要 CPU 时间时调度该进程的可能性就越小。然而,仅凭调度优先级并不能完全决定内核是否分配 CPU 时间给一个进程,内核还可能根据进程消耗的 CPU 时间在程序执行过程中调整优先级。
在优先级列旁边是 NI(nice 值)列,它向内核调度器提供了一个提示。这是你在尝试影响内核决策时需要关注的内容。内核将 nice 值与当前优先级相加,确定下一个进程的时间片。当你将 nice 值设置得更高时,你是在对其他进程“更友好”,因为内核会优先考虑这些进程。
默认情况下,nice 值为 0。如果你正在后台运行一个大规模的计算任务,而不希望它影响到你的交互式会话,你可以通过 renice 命令将该进程的 nice 值更改为 20,使得该进程在其他任务没有工作时才会运行(其中 pid 是你要更改的进程的进程 ID):
$ **renice 20** `pid`
如果你是超级用户,你可以将 nice 值设置为负数,但这样做几乎总是个坏主意,因为系统进程可能得不到足够的 CPU 时间。事实上,你可能不需要经常更改 nice 值,因为许多 Linux 系统只有一个用户,而这个用户并不进行大量的实际计算。(nice 值在多用户共享单台机器时更为重要。)
8.5.3 使用负载平均值衡量 CPU 性能
整体 CPU 性能是最容易衡量的指标之一。负载平均值 是当前准备运行的进程的平均数量。也就是说,它是任何给定时刻能够使用 CPU 的进程数量的估计——这包括正在运行的进程以及那些等待使用 CPU 的进程。在考虑负载平均值时,请记住,系统中的大多数进程通常在等待输入(例如来自键盘、鼠标或网络的输入),这意味着它们没有准备好运行,因此不应对负载平均值产生影响。只有那些实际在执行某些任务的进程才会影响负载平均值。
使用 uptime
uptime 命令除了显示内核运行的时间外,还会告诉你三个负载平均值:
$ **uptime**
... up 91 days, ... load average: **0.08, 0.03, 0.01**
这三个加粗的数字分别表示过去 1 分钟、5 分钟和 15 分钟的负载平均值。如你所见,这个系统并不是很繁忙:在过去的 15 分钟里,所有处理器上平均只有 0.01 个进程在运行。换句话说,如果你只有一个处理器,它在过去的 15 分钟里只有 1%的时间在运行用户空间应用程序。
传统上,大多数桌面系统在你进行任何操作时,负载平均值大约为 0,除了编译程序或玩游戏以外。负载平均值为 0 通常是一个好兆头,因为这意味着处理器没有受到挑战,你也节省了电力。
然而,当前桌面系统上的用户界面组件通常会占用更多的 CPU 资源,尤其是某些网站(尤其是它们的广告)导致 Web 浏览器变成资源消耗大户。
如果负载平均值上升到大约 1,可能是某个单一进程几乎一直在使用 CPU。要识别这个进程,可以使用top命令;该进程通常会出现在显示的顶部。
现代系统大多数都有多个处理器核心或 CPU,因此多个进程可以轻松并行运行。如果你有两个核心,负载平均值为 1 意味着任何给定时刻只有一个核心可能在活动,而负载平均值为 2 则意味着两个核心的工作量恰好足够它们全天候运行。
管理高负载
高负载平均值并不一定意味着你的系统出现问题。如果系统拥有足够的内存和 I/O 资源,它可以轻松地处理许多正在运行的进程。如果你的负载平均值很高且系统响应良好,不要惊慌;系统只是在大量进程共享 CPU 资源。这些进程必须相互竞争处理器时间,因此它们需要比平时更长时间才能完成计算。另一个高负载平均值可能是正常的情况是在 Web 服务器或计算服务器上,进程的启动和终止非常迅速,以至于负载平均值的测量机制无法有效地工作。
然而,如果负载平均值非常高,并且你感觉系统变慢了,可能是遇到了内存性能问题。当系统内存不足时,内核可能会开始交换,或者迅速将内存交换到磁盘上并再交换回来。当这种情况发生时,许多进程会变得准备就绪,但是它们的内存可能不可用,因此它们会保持在准备就绪状态(导致负载平均值增高),比正常情况下停留的时间要长得多。接下来,我们将通过更详细地探讨内存,来了解为什么会发生这种情况。
8.5.4 监控内存状态
检查系统内存状态的最简单方法之一是运行free命令或查看/proc/meminfo,看看有多少真实内存用于缓存和缓冲区。如前所述,性能问题可能是由于内存不足引起的。如果没有使用太多缓存/缓冲区内存(而其余的真实内存已被占用),你可能需要更多内存。然而,将每个性能问题归咎于内存不足是过于简单的做法。
内存工作原理
如第一章所解释的,CPU 有一个内存管理单元(MMU),它为内存访问提供灵活性。内核通过将进程使用的内存分解为称为页面的较小块来协助 MMU。内核维护一个数据结构,称为页表,它将进程的虚拟页面地址映射到内存中的真实页面地址。当进程访问内存时,MMU 根据内核的页表将进程使用的虚拟地址转换为真实地址。
用户进程实际上并不需要所有内存页面立即可用才能运行。内核通常会根据进程的需要加载和分配页面;这种系统被称为按需分页,或简称为需求分页。为了理解这个过程,考虑程序如何作为新进程启动和运行:
-
内核将程序指令代码的开头加载到内存页面中。
-
内核可能会为新进程分配一些工作内存页面。
-
当进程运行时,它可能会到达一个点,此时其代码中的下一条指令并不在内核最初加载的任何页面中。此时,内核接管,加载必要的页面到内存中,然后让程序继续执行。
-
同样,如果程序需要比最初分配的更多工作内存,内核会通过寻找空闲页面(或腾出空间)并分配给进程来处理。
你可以通过查看内核配置来获取系统的页面大小:
$ **getconf PAGE_SIZE**
4096
这个数字是以字节为单位的,对于大多数 Linux 系统,4k 是典型的值。
内核不会随意将真实内存页面映射到虚拟地址;也就是说,它不会把所有可用的页面放入一个大池中并从中分配。真实内存有许多分区,这些分区依赖于硬件限制、内核对连续页面的优化以及其他因素。然而,当你刚开始学习时,不必担心这些细节。
页面错误
如果进程想要使用的内存页面还没准备好,进程会触发一个页面错误。在页面错误发生时,内核从进程那里接管 CPU,以准备该页面。页面错误有两种类型:轻微页面错误和重大页面错误。
轻微页面错误
- 次要页错误发生在所需页面实际上已经在主内存中,但内存管理单元(MMU)不知道它的位置。这可能发生在进程请求更多内存时,或者当 MMU 没有足够的空间存储所有进程的页面位置时(MMU 的内部映射表通常很小)。在这种情况下,内核会告知 MMU 页面的位置,并允许进程继续执行。次要页错误不需要担心,许多次要页错误会在进程运行时发生。
重大页错误
-
重大页错误发生在所需的内存页根本不在主内存中时,这意味着内核必须从磁盘或其他较慢的存储机制加载它。大量的重大页错误会拖慢系统速度,因为内核必须进行大量工作才能提供这些页面,剥夺了正常进程的运行机会。
-
一些重大页错误是不可避免的,例如首次运行程序时从磁盘加载代码时发生的错误。最大的难题出现在内存不足时,内核必须开始将工作内存的页面交换到磁盘,以便为新页面腾出空间,这可能导致频繁的页面交换。
您可以使用ps、top和time命令深入查看单个进程的页错误。您需要使用系统版本的time(/usr/bin/time),而不是 Shell 内建的版本。以下是time命令如何显示页错误的简单示例(cal命令的输出无关紧要,因此我们通过将其重定向到/dev/null来丢弃它):
$ **/usr/bin/time cal > /dev/null**
0.00user 0.00system 0:00.06elapsed 0%CPU (0avgtext+0avgdata 3328maxresident)k
648inputs+0outputs (**2major+254minor**)pagefaults 0swaps
如您从加粗文本中所见,当此程序运行时,共发生了 2 个重大页错误和 254 个次要页错误。重大页错误发生在内核第一次需要从磁盘加载程序时。如果您再次运行此命令,您可能不会遇到任何重大页错误,因为内核会从磁盘缓存这些页面。
如果您希望在进程运行时查看页错误,请使用top或ps。运行top时,使用f来更改显示字段,并选择nMaj作为其中一个列,以显示重大页错误的数量。如果您正在追踪可能出现问题的进程,选择vMj(自上次更新以来的重大页错误数量)可能会有所帮助。
使用ps时,您可以使用自定义输出格式查看特定进程的页错误。以下是 PID 为 20365 的示例:
$ **ps -o pid,min_flt,maj_flt 20365**
PID MINFL MAJFL
20365 834182 23
MINFL和MAJFL列显示了次要和重大页错误的数量。当然,您可以结合任何其他进程选择选项,如 ps(1)手册页中所述。
按进程查看页错误有助于您锁定某些问题组件。然而,如果您对系统的整体性能感兴趣,您需要一种工具来总结所有进程的 CPU 和内存活动。
8.5.5 使用 vmstat 监控 CPU 和内存性能
在众多用于监控系统性能的工具中,vmstat命令是最古老且开销最小的工具之一。你会发现它非常方便,能帮助你从高层次了解内核交换页面的频率、CPU 的繁忙程度,以及 I/O 资源的使用情况。
解锁vmstat功能的诀窍是理解其输出。例如,以下是vmstat 2的输出,每两秒报告一次统计数据:
$ **vmstat 2**
procs -----------memory---------- ---swap-- -----io---- -system-- ----cpu----
r b swpd free buff cache si so bi bo in cs us sy id wa
2 0 320416 3027696 198636 1072568 0 0 1 1 2 0 15 2 83 0
2 0 320416 3027288 198636 1072564 0 0 0 1182 407 636 1 0 99 0
1 0 320416 3026792 198640 1072572 0 0 0 58 281 537 1 0 99 0
0 0 320416 3024932 198648 1074924 0 0 0 308 318 541 0 0 99 1
0 0 320416 3024932 198648 1074968 0 0 0 0 208 416 0 0 99 0
0 0 320416 3026800 198648 1072616 0 0 0 0 207 389 0 0 100 0
输出分为几个类别:procs表示进程,memory表示内存使用情况,swap表示交换进出的页面,io表示磁盘使用情况,system表示内核切换到内核代码的次数,cpu表示系统各部分使用的时间。
上述输出典型地反映了一个系统在没有太多活动时的情况。你通常会关注第二行输出——第一行是系统整个运行时间的平均值。例如,这里系统有 320,416KB 的内存被交换到磁盘(swpd),大约有 3,027,000KB(3GB)的实际内存是free的。即使有一些交换空间正在使用,零值的si(swap-in)和so(swap-out)列表明内核当前并没有将任何数据从磁盘交换进或交换出。buff列显示了内核用于磁盘缓冲区的内存量(参见第 4.2.5 节)。
在最右侧的 CPU 部分,你可以看到us、sy、id和wa列中的 CPU 时间分布。这些分别表示 CPU 在用户任务、系统(内核)任务、空闲时间和等待 I/O 上的时间百分比。在前面的示例中,用户进程并不多(它们使用最多 1%的 CPU);内核几乎没有做任何事情,CPU99%的时间都处于空闲状态。
列表 8-3 显示了一个大型程序启动时的情况。
procs -----------memory---------- ---swap-- -----io---- -system-- ----cpu----
r b swpd free buff cache si so bi bo in cs us sy id wa
1 0 320412 2861252 198920 1106804 0 0 0 0 2477 4481 25 2 72 0 1
1 0 320412 2861748 198924 1105624 0 0 0 40 2206 3966 26 2 72 0
1 0 320412 2860508 199320 1106504 0 0 210 18 2201 3904 26 2 71 1
1 1 320412 2817860 199332 1146052 0 0 19912 0 2446 4223 26 3 63 8
2 2 34 2791608 200612 1157752 202 0 4960 854 3371 5714 27 3 51 18 2
1 1 320252 2772076 201076 1166656 10 0 2142 1190 4188 7537 30 3 53 14
0 3 320244 2727632 202104 1175420 20 0 1890 216 4631 8706 36 4 46 14
列表 8-3:内存活动
如列表 8-3 中 1 所示,CPU 开始长时间有一些使用,尤其是用户进程的使用。由于有足够的空闲内存,随着内核使用磁盘的频率增加,缓存和缓冲区的使用量也开始增加。
接下来,我们看到一些有趣的情况:注意第 2 行,内核将一些曾经被交换出去的页面拉回到内存中(si列)。这意味着刚刚运行的程序可能访问了一些由其他进程共享的页面,这很常见——许多进程仅在启动时使用某些共享库中的代码。
同时,从b列可以看到,有几个进程被阻塞(不能运行),因为它们在等待内存页面。总体来看,空闲内存的量在减少,但仍远未耗尽。磁盘活动也相当频繁,正如bi(blocks in)和bo(blocks out)列中数字的增加所示。
当内存用尽时,输出会有所不同。随着空闲空间的减少,缓冲区和缓存的大小会缩小,因为内核越来越需要这些空间来处理用户进程。一旦没有剩余空间,你会在so(已交换出去)列中看到活动,因为内核开始将页面移到磁盘上,这时几乎所有其他输出列都会发生变化,反映出内核正在进行的工作量。你会看到更多的系统时间、更多数据进出磁盘,以及更多进程被阻塞,因为它们想使用的内存不可用(已被交换出去)。
我们还没有探索完vmstat输出的所有列。你可以在vmstat(8)手册页中深入了解这些列,但你可能需要先通过课堂或像 Silberschatz、Gagne 和 Galvin 的《操作系统概念》第 10 版(Wiley,2018)这样的书籍,学习更多关于内核内存管理的知识,才能理解它们。
8.5.6 I/O 监控
默认情况下,vmstat 提供一些通用的 I/O 统计信息。尽管你可以通过vmstat -d获取非常详细的每个分区的资源使用情况,但这选项的输出可能会让你感到不知所措。相反,尝试使用一个专门用于 I/O 的工具 iostat。
使用 iostat
和 vmstat 一样,iostat 在不带任何选项运行时,会显示机器当前的运行时统计信息:
$ **iostat**
[`kernel information`]
avg-cpu: %user %nice %system %iowait %steal %idle
4.46 0.01 0.67 0.31 0.00 94.55
Device: tps kB_read/s kB_wrtn/s kB_read kB_wrtn
sda 4.67 7.28 49.86 9493727 65011716
sde 0.00 0.00 0.00 1230 0
顶部的avg-cpu部分报告了与本章其他工具相同的 CPU 利用率信息,因此跳到底部,底部会显示每个设备的以下信息:
-
tps每秒的数据传输平均次数 -
kB_read/s每秒读取的千字节数 -
kB_wrtn/s每秒写入的千字节数 -
kB_read读取的千字节总数 -
kB_wrtn写入的千字节总数
另一个和 vmstat 相似的是,你可以提供一个间隔参数,比如 iostat 2,以便每两秒更新一次。在使用间隔时,你可能只想通过使用 -d 选项显示设备报告(例如 iostat -d 2)。
默认情况下,iostat 输出省略了分区信息。要显示所有分区信息,可以使用-p ALL选项。由于典型系统有许多分区,你会得到大量的输出。以下是你可能看到的部分内容:
$ **iostat -p ALL**
--`snip`--
Device: tps kB_read/s kB_wrtn/s kB_read kB_wrtn
--`snip`--
sda 4.67 7.27 49.83 9496139 65051472
sda1 4.38 7.16 49.51 9352969 64635440
sda2 0.00 0.00 0.00 6 0
sda5 0.01 0.11 0.32 141884 416032
scd0 0.00 0.00 0.00 0 0
--`snip`--
sde 0.00 0.00 0.00 1230 0
在这个例子中,sda1、sda2 和 sda5 都是 sda 磁盘的分区,因此读取和写入的列会有一些重叠。然而,分区列的总和不一定等于磁盘列。尽管从 sda1 的读取也计为从 sda 的读取,但请记住,你可以直接从 sda 读取,例如读取分区表时。
每进程 I/O 利用率与监控:iotop
如果你需要更深入地了解各个进程使用的 I/O 资源,iotop工具可以提供帮助。使用iotop类似于使用top。它会生成一个持续更新的显示,展示使用最多 I/O 的进程,并在顶部提供一个概览:
# iotop
Total DISK READ: 4.76 K/s | Total DISK WRITE: 333.31 K/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
260 be/3 root 0.00 B/s 38.09 K/s 0.00 % 6.98 % [jbd2/sda1-8]
2611 be/4 juser 4.76 K/s 10.32 K/s 0.00 % 0.21 % zeitgeist-daemon
2636 be/4 juser 0.00 B/s 84.12 K/s 0.00 % 0.20 % zeitgeist-fts
1329 be/4 juser 0.00 B/s 65.87 K/s 0.00 % 0.03 % soffice.b~ash-pipe=6
6845 be/4 juser 0.00 B/s 812.63 B/s 0.00 % 0.00 % chromium-browser
19069 be/4 juser 0.00 B/s 812.63 B/s 0.00 % 0.00 % rhythmbox
除了用户、命令和读/写列外,请注意,有一个 TID 列,而不是 PID 列。iotop是为数不多的显示线程而不是进程的工具之一。
PRIO(优先级)列表示 I/O 优先级。它类似于你已经看到的 CPU 优先级,但它影响内核调度进程的 I/O 读写的速度。在像be/4这样的优先级中,be部分是调度类,数字表示优先级级别。与 CPU 优先级一样,数字越小越重要;例如,内核允许一个优先级为be/3的进程比一个优先级为be/4的进程有更多的 I/O 时间。
内核使用调度类来为 I/O 调度提供更多的控制。你将在iotop中看到三种调度类:
-
be最佳努力。内核尽力为这一类别公平地调度 I/O。大多数进程都运行在这个 I/O 调度类别下。 -
rt实时。无论如何,内核在调度任何其他类别的 I/O 之前,都会首先调度实时 I/O。 -
idle空闲。内核仅在没有其他 I/O 任务时才会为这一类执行 I/O 操作。空闲调度类没有优先级。
你可以使用ionice工具查看和更改进程的 I/O 优先级;有关详细信息,请参见 ionice(1)手册页。不过,你可能永远不需要关心 I/O 优先级。
8.5.7 使用 pidstat 进行每个进程的监控
你已经看过如何使用像top和iotop这样的工具来监控特定进程。然而,这个显示会随着时间刷新,每次更新都会覆盖前面的输出。pidstat工具允许你以vmstat的风格查看一个进程随时间变化的资源消耗。以下是一个简单的例子,用于每秒更新监控进程 1329:
$ **pidstat -p 1329 1**
Linux 5.4.0-48-generic (duplex) 11/09/2020 _x86_64_ (4 CPU)
09:26:55 PM UID PID %usr %system %guest %CPU CPU Command
09:27:03 PM 1000 1329 8.00 0.00 0.00 8.00 1 myprocess
09:27:04 PM 1000 1329 0.00 0.00 0.00 0.00 3 myprocess
09:27:05 PM 1000 1329 3.00 0.00 0.00 3.00 1 myprocess
09:27:06 PM 1000 1329 8.00 0.00 0.00 8.00 3 myprocess
09:27:07 PM 1000 1329 2.00 0.00 0.00 2.00 3 myprocess
09:27:08 PM 1000 1329 6.00 0.00 0.00 6.00 2 myprocess
默认输出显示用户时间和系统时间的百分比,以及 CPU 时间的总体百分比,它甚至会告诉你进程在哪个 CPU 上运行。(这里的%guest列有点奇怪——它表示进程在虚拟机中运行的时间百分比。除非你在运行虚拟机,否则不用担心这个。)
虽然pidstat默认显示 CPU 使用率,但它可以做更多的事情。例如,你可以使用-r选项来监控内存,使用-d来开启磁盘监控。试试看,然后查看 pidstat(1)手册页,了解更多关于线程、上下文切换或我们在本章中讨论的其他任何选项。
8.6 控制组(cgroups)
到目前为止,你已经了解了如何查看和监控资源使用情况,但如果你想要限制进程的资源消耗,超过 nice 命令的作用呢?有几种传统的系统可以做到这一点,比如 POSIX 的 rlimit 接口,但在 Linux 系统中,最灵活的选择是 cgroup(控制组)内核特性,它可以用于大多数资源限制类型。
基本思路是,你将多个进程放入一个 cgroup,这样你就可以基于整个组来管理它们消耗的资源。例如,如果你想限制一组进程总共可以消耗的内存量,cgroup 可以实现这一点。
创建 cgroup 后,你可以将进程添加到其中,然后使用 控制器 来改变这些进程的行为。例如,cpu 控制器允许你限制处理器时间,memory 控制器等也可以做到这一点。
8.6.1 区分 cgroup 版本
cgroups 有两个版本,1 和 2,不幸的是,这两个版本当前都在使用,并且可以在同一系统上同时配置,可能会引起混淆。除了功能集有所不同外,这两个版本的结构差异可以总结如下:
-
在 cgroups v1 中,每种类型的控制器(
cpu、memory等)都有自己的一组 cgroup。一个进程可以属于每个控制器中的一个 cgroup,这意味着一个进程可以属于多个 cgroup。例如,在 v1 中,一个进程可以同时属于cpucgroup 和memorycgroup。 -
在 cgroups v2 中,一个进程只能属于一个 cgroup。你可以为每个 cgroup 设置不同类型的控制器。
为了更好地理解差异,可以考虑三个进程集合 A、B 和 C。我们希望在每个集合上使用 cpu 和 memory 控制器。图 8-1 显示了 cgroups v1 的示意图。我们总共需要六个 cgroup,因为每个 cgroup 只能使用一个控制器。

图 8-1:cgroups v1。一个进程可能属于每个控制器中的一个 cgroup。
图 8-2 显示了如何在 cgroups v2 中操作。我们只需要三个 cgroup,因为每个 cgroup 可以设置多个控制器。

图 8-2:cgroups v2。一个进程只能属于一个 cgroup。
你可以通过查看进程的 cgroup 文件(位于 /proc/
$ **cat /proc/self/cgroup**
12:rdma:/
11:net_cls,net_prio:/
10:perf_event:/
9:cpuset:/
8:cpu,cpuacct:/user.slice
7:blkio:/user.slice
6:memory:/user.slice
5:pids:/user.slice/user-1000.slice/session-2.scope
4:devices:/user.slice
3:freezer:/
2:hugetlb:/testcgroup 1
1:name=systemd:/user.slice/user-1000.slice/session-2.scope
0::/user.slice/user-1000.slice/session-2.scope
如果你发现输出比系统上的要短,不必惊慌;这只是意味着你的系统可能只有 cgroups v2。这里的每一行输出都以一个数字开头,并代表一个不同的 cgroup。以下是如何阅读这些输出的一些提示:
-
数字 2-12 是用于 cgroups v1 的。每个数字旁边列出了对应的控制器。
-
数字 1 也是用于版本 1,但它没有控制器。这个 cgroup 仅用于管理目的(在这种情况下是由 systemd 配置的)。
-
最后一行,第 0 行,是 cgroups v2 的部分。这里没有控制器可见。如果系统没有 cgroups v1,这将是唯一的输出行。
-
名称是层级结构的,类似于文件路径的一部分。在这个例子中,你可以看到一些 cgroups 命名为/user.slice,而其他则命名为/user.slice/user-1000.slice/session-2.scope。
-
名称/testcgroup 1 是为了展示在 cgroups v1 中,进程的 cgroups 可以完全独立。
-
在user.slice下,包含session的名称表示登录会话,由 systemd 分配。当你查看 shell 的 cgroups 时会看到它们。你系统服务的 cgroups 会在system.slice下。
你可能已经推测到,cgroups v1 在某些方面比 v2 更具灵活性,因为你可以将不同的 cgroups 组合分配给进程。然而,事实证明,没有人真正以这种方式使用它们,而且这种方法比每个进程只使用一个 cgroup 的设置和实施更为复杂。
由于 cgroups v1 正在逐步淘汰,我们从现在开始将重点讨论 cgroups v2。请注意,如果在 cgroups v1 中使用了某个控制器,那么由于潜在的冲突,该控制器不能同时在 v2 中使用。这意味着我们接下来要讨论的控制器特定部分,如果你的系统仍在使用 v1,将无法正常工作,但如果你查看正确的地方,你仍然能够跟随 v1 的等效部分。
8.6.2 查看 cgroups
与传统的 Unix 系统调用接口不同,cgroups 完全通过文件系统访问,通常挂载为cgroup2文件系统,位于/sys/fs/cgroup下。(如果你同时运行的是 cgroups v1,通常会在/sys/fs/cgroup/unified下。)
让我们来探索一下 shell 的 cgroup 设置。打开一个 shell,找到它的 cgroup,路径为/proc/self/cgroup(如前所示)。然后查看/sys/fs/cgroup(或/sys/fs/cgroup/unified)。你会找到一个与之同名的目录;进入该目录并四处查看:
$ **cat /proc/self/cgroup**
0::/user.slice/user-1000.slice/session-2.scope
$ **cd /sys/fs/cgroup/user.slice/user-1000.slice/session-2.scope/**
$ **ls**
在这里可能会有许多文件,其中主要的 cgroup 接口文件以cgroup开头。首先查看cgroup.procs(使用cat命令查看即可),该文件列出了 cgroup 中的进程。类似的文件cgroup.threads也包含线程。
要查看当前为该 cgroup 使用的控制器,查看cgroup.controllers:
$ **cat cgroup.controllers**
memory pids
大多数用于 shell 的 cgroups 有这两个控制器,可以控制 cgroup 中使用的内存量和进程总数。要与控制器交互,查找与控制器前缀匹配的文件。例如,如果你想查看 cgroup 中运行的线程数,请查阅pids.current:
$ **cat pids.current**
4
要查看该 cgroup 可以消耗的最大内存,查看memory.max:
$ **cat memory.max**
max
max的值表示该 cgroup 没有特定的限制,但由于 cgroups 是层级结构,子目录链中的某个 cgroup 可能会限制它。
8.6.3 操作和创建 cgroup
虽然你可能永远不需要修改 cgroup,但其实这很简单。要将进程放入 cgroup,只需将其 PID 以 root 用户身份写入 cgroup.procs 文件:
# echo `pid` **> cgroup.procs**
这就是许多 cgroup 变更的工作方式。例如,如果你想限制一个 cgroup 的最大 PID 数量(比如 3,000 个 PID),可以按以下方式操作:
# echo 3000 > pids.max
创建 cgroup 较为复杂。技术上,它就像在 cgroup 树中创建一个子目录一样简单;当你这么做时,内核会自动创建接口文件。如果一个 cgroup 中没有进程,即使接口文件存在,你也可以使用 rmdir 删除该 cgroup。可能会让你困扰的是管理 cgroup 的规则,包括:
-
你只能将进程放入外层(“叶子”)cgroup。例如,如果你有名为 /my-cgroup 和 /my-cgroup/my-subgroup 的 cgroup,你不能将进程放入 /my-cgroup,但是可以放入 /my-cgroup/my-subgroup。(一个例外是如果 cgroup 没有控制器,但我们不再深入讨论这个话题。)
-
一个 cgroup 不能有一个不在其父 cgroup 中的控制器。
-
必须显式指定子 cgroup 的控制器。你可以通过 cgroup.subtree_control 文件来实现;例如,如果你希望子 cgroup 拥有
cpu和pids控制器,可以将+cpu +pids写入该文件。
这些规则的例外是位于层级结构底部的根 cgroup。你可以将进程放入该 cgroup。你可能希望这么做的一个原因是将进程从 systemd 的控制中分离出来。
8.6.4 查看资源利用情况
除了可以通过 cgroup 限制资源外,你还可以查看所有进程在其 cgroup 中的当前资源使用情况。即使没有启用控制器,你也可以通过查看其 cpu.stat 文件来看到一个 cgroup 的 CPU 使用情况:
$ **cat cpu.stat**
usage_usec 4617481
user_usec 2170266
system_usec 2447215
因为这是该 cgroup 在其整个生命周期中的累计 CPU 使用情况,你可以看到一个服务如何消耗处理器时间,即使它启动了许多最终终止的子进程。
如果启用了适当的控制器,你可以查看其他类型的资源利用情况。例如,memory 控制器可以访问 memory.current 文件,查看当前的内存使用情况,并访问 memory.stat 文件,该文件包含 cgroup 生命周期内的详细内存数据。这些文件在根 cgroup 中不可用。
你可以从 cgroup 中获得更多信息。如何使用每个单独控制器的完整细节,以及创建 cgroup 的所有规则,都可以在内核文档中找到;只需在线搜索“cgroups2 文档”即可找到。
不过,目前你应该已经对 cgroup 的工作原理有了较好的了解。理解它们的基本操作有助于解释 systemd 如何组织进程。稍后,当你阅读关于容器的内容时,你将看到它们如何用于截然不同的目的。
8.7 进一步的话题
工具如此之多,用来测量和管理资源利用率的一个原因是,不同类型的资源被消耗的方式各不相同。在本章中,你已经看到 CPU、内存和 I/O 作为系统资源被进程、进程内部的线程以及内核消耗。
这些工具存在的另一个原因是资源是有限的,为了让系统良好运行,它的组件必须尽力减少资源的消耗。在过去,许多用户共享同一台机器,因此有必要确保每个用户公平地分配资源。如今,尽管现代桌面计算机可能没有多个用户,但它仍然有许多进程争夺资源。同样,高性能的网络服务器也需要密切监控系统资源,因为它们运行多个进程来处理多个请求。
你可能还想探索的资源监控和性能分析的进一步主题包括:
-
sar(系统活动报告器)sar软件包具备vmstat的许多连续监控功能,但它还记录了资源利用情况随时间的变化。使用sar,你可以回顾特定时刻系统的状态,这在你想分析过去的系统事件时非常有用。 -
acct(进程记账)acct软件包可以记录进程及其资源利用情况。 -
配额 你可以通过
quota系统限制用户使用的磁盘空间。
如果你特别对系统调优和性能感兴趣,布伦丹·格雷格(Brendan Gregg)所著的《系统性能:企业与云计算》(第 2 版,Addison-Wesley 出版社,2020 年)会讲解得更加详细。
我们还没有涉及到许多可以用来监控网络资源利用率的工具。不过,要使用这些工具,首先需要理解网络是如何工作的。这正是我们接下来要探讨的内容。
第九章:了解你的网络及其配置

网络是连接计算机并在它们之间传输数据的实践。听起来很简单,但要理解它是如何工作的,你需要提出两个基本问题:
-
发送数据的计算机如何知道把数据发送到哪里呢?
-
当目标计算机接收到数据时,它如何知道它刚刚接收到的是什么呢?
计算机通过使用一系列组件来回答这些问题,每个组件负责发送、接收和识别数据的某个方面。这些组件被组织成多个网络层,这些网络层依次堆叠,形成一个完整的系统。Linux 内核处理网络的方式与第三章中描述的 SCSI 子系统类似。
因为每一层往往是独立的,所以可以通过许多不同的组件组合来构建网络。这也是网络配置变得非常复杂的地方。正因如此,我们将从研究非常简单的网络中的层开始。本章将教你如何查看自己的网络设置,当你理解了每一层的基本工作原理后,你就能自己配置这些层。最后,你将进入更高级的主题,比如构建自己的网络和配置防火墙。(如果这些内容让你感到困惑,可以跳过;你总可以回来继续学习。)
9.1 网络基础
在深入探讨网络层的理论之前,先看看图 9-1 中展示的简单网络。

图 9-1:一个典型的局域网,路由器提供互联网接入
这种类型的网络是无处不在的;大多数家庭和小型办公室的网络都是这样配置的。每一台连接到网络的计算机称为主机。其中之一是路由器,它是一个可以将数据从一个网络移动到另一个网络的主机。在这个例子中,这四台主机(主机 A、B、C 和路由器)组成了一个局域网(LAN)。局域网中的连接可以是有线的,也可以是无线的。局域网没有严格的定义;局域网上的计算机通常彼此物理接近,且共享相似的配置和访问权限。你很快就会看到一个具体的例子。
路由器也连接到互联网——图中的云。这个连接被称为上行链路或广域网(WAN)连接,因为它将更小的局域网(LAN)连接到更大的网络。由于路由器同时连接到局域网和互联网,局域网上的所有计算机也可以通过路由器访问互联网。本章的目标之一就是了解路由器是如何提供这种访问的。
你的初步视角将来自一个基于 Linux 的机器,例如图 9-1 中局域网上的主机 A。
9.2 数据包
计算机通过网络传输数据时,会将数据分成小块,称为数据包,每个数据包由两部分组成:头部和负载。头部包含标识信息,如源主机和目标主机,以及基本协议。另一方面,负载则是计算机希望发送的实际应用数据(例如 HTML 或图像数据)。
主机可以以任何顺序发送、接收和处理数据包,无论它们来自哪里或将要去往何处,这使得多个主机可以“同时”进行通信。例如,如果一台主机需要同时向两台主机传输数据,它可以在发送的数据包中交替选择目的地。将消息拆分成较小的单元也使得在传输过程中更容易检测并弥补错误。
大部分情况下,你不需要担心在数据包和应用程序使用的数据之间进行转换,因为操作系统会为你处理这个问题。然而,了解数据包在你即将看到的网络层中的角色是很有帮助的。
9.3 网络层
一个完全功能的网络包括一组被称为网络栈的网络层。任何功能性的网络都有一个栈。典型的互联网栈,从上到下的层级如下:
-
应用层包含应用程序和服务器用于通信的“语言”——通常是一种高级协议。常见的应用层协议包括超文本传输协议(HTTP,网页使用)、加密协议如 TLS 和文件传输协议(FTP)。应用层协议通常可以组合使用。例如,TLS 常与 HTTP 配合使用,形成 HTTPS。
-
应用层处理发生在用户空间。
-
传输层定义了应用层的数据传输特性。该层包括数据完整性检查、源和目标端口、以及在主机端将应用数据拆分为数据包(如果应用层尚未这样做)并在目标端重新组装的规范。传输控制协议(TCP)和用户数据报协议(UDP)是最常见的传输层协议。传输层有时被称为协议层。
-
在 Linux 中,传输层及以下的所有层主要由内核处理,但也有一些例外,数据包会被发送到用户空间进行处理。
-
网络层或互联网层定义了如何将数据包从源主机传输到目标主机。互联网的特定数据包传输规则集被称为互联网协议(IP)。因为本书只讨论互联网网络,所以我们实际上只讨论互联网层。然而,由于网络层旨在硬件独立,你可以在一台主机上同时配置多个独立的网络层——如 IP(IPv4)、IPv6、IPX 和 AppleTalk。
-
物理层 定义了如何通过物理介质发送原始数据,例如以太网或调制解调器。这有时被称为链路层或主机到网络层。
理解网络堆栈的结构非常重要,因为你的数据必须至少通过这些层两次才能到达目标程序。例如,如果你从主机 A 向主机 B 发送数据,如图 9-1 所示,你的数据字节会离开主机 A 的应用层,穿过主机 A 的传输层和网络层;然后它们下降到物理介质,通过介质,再通过各个较低层次,最终到达主机 B 的应用层。如果你通过路由器向互联网上的主机发送数据,它会经过路由器的某些(但通常不是全部)层以及任何其他中间设备。
层有时会以奇怪的方式互相重叠,因为按顺序处理所有层可能效率低下。例如,历史上仅处理物理层的设备现在有时会同时查看传输层和互联网层的数据,以便快速过滤和路由数据。此外,术语本身可能会让人困惑。例如,TLS 代表传输层安全性,但实际上它位于更高的层次——应用层。(当你学习基础知识时,不用太担心这些烦人的细节。)
我们将首先看看你的 Linux 机器是如何连接到网络的,以回答本章开头的where问题。这是堆栈的底部——物理层和网络层。稍后,我们将查看回答what问题的上面两个层。
9.4 互联网层
我们将从网络层开始,而不是从网络堆栈的最底层——物理层开始,因为网络层可能更容易理解。我们目前所知道的互联网是基于互联网协议版本 4(IPv4)和版本 6(IPv6)。互联网层的一个最重要方面是,它旨在成为一个软件网络,不对硬件或操作系统提出任何特定要求。其想法是,你可以通过任何类型的硬件,使用任何操作系统,发送和接收互联网数据包。
我们的讨论将从 IPv4 开始,因为它的地址稍微容易阅读(并且理解其局限性),但我们会解释 IPv6 的主要区别。
互联网的拓扑是去中心化的;它由称为子网的较小网络组成。这个想法是,所有子网以某种方式相互连接。例如,在图 9-1 中,局域网通常是一个单一的子网。
一台主机可以连接到多个子网。正如你在第 9.1 节看到的,如果主机能够在不同子网之间传输数据,它就被称为路由器(路由器的另一个术语是网关)。图 9-2 通过将局域网标识为子网,并为每个主机和路由器指定互联网地址,完善了图 9-1。图中的路由器有两个地址,一个是本地子网 10.23.2.1,另一个是连接到互联网的地址(互联网连接的地址目前并不重要,因此它被标记为上行地址)。我们首先来看看这些地址,然后再看子网表示法。
每个互联网主机至少有一个数字的IP 地址。对于 IPv4,它的格式是a.b.c.d,例如 10.23.2.37。这样的地址表示法称为点分四段序列。如果一台主机连接到多个子网,它在每个子网上至少有一个 IP 地址。每台主机的 IP 地址应该在整个互联网中是唯一的,但正如你稍后会看到的,私有网络和网络地址转换(NAT)可能会让这个问题变得有些复杂。
不用担心图 9-2 中的子网表示法,我们稍后会讨论。

图 9-2:带有 IP 地址的网络
IP 地址在某些方面类似于邮政地址。为了与另一台主机通信,你的机器必须知道另一台主机的 IP 地址。
让我们来看看你机器上的地址。
9.4.1 查看 IP 地址
一台机器可以拥有多个 IP 地址,支持多个物理接口、虚拟内部网络等。要查看在 Linux 机器上活动的地址,请运行:
$ **ip address show**
可能会有大量的输出(按物理接口分组,详见第 9.10 节),但它应该包括如下内容:
2: enp0s31f6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 40:8d:5c:fc:24:1f brd ff:ff:ff:ff:ff:ff
inet 10.23.2.4/24 brd 10.23.2.255 scope global noprefixroute enp0s31f6
valid_lft forever preferred_lft forever
ip命令的输出包含来自互联网层和物理层的许多详细信息。(有时它甚至根本不会显示互联网地址!)我们稍后会更详细地讨论输出内容,但现在,请关注第四行,它报告主机已配置一个 IPv4 地址(以inet表示)为 10.23.2.4。地址后面的/24有助于定义该 IP 地址所属的子网。我们来看一下它是如何工作的。
9.4.2 子网
如前所述,子网是一个由 IP 地址位于特定范围内的主机组成的连接组。例如,10.23.2.1 到 10.23.2.254 范围内的主机可以组成一个子网,同样,10.23.1.1 到 10.23.255.254 之间的所有主机也可以组成一个子网。通常,子网中的主机位于同一个物理网络中,如图 9-2 所示。
你定义一个子网有两个部分:一个 网络前缀(也叫 路由前缀)和一个 子网掩码(有时叫做 网络掩码 或 路由掩码)。假设你想创建一个包含 10.23.2.1 到 10.23.2.254 之间 IP 地址的子网。网络前缀是所有子网地址中 共同 的部分;在这个例子中,它是 10.23.2.0,子网掩码是 255.255.255.0。让我们看看这些数字是如何来的。
为了了解前缀和子网掩码如何协同工作,从而给出子网上所有可能的 IP 地址,我们将查看二进制形式。子网掩码标记了 IP 地址中与子网共有的位位置。例如,下面是 10.23.2.0 和 255.255.255.0 的二进制形式。
10.23.2.0: 00001010 00010111 00000010 00000000
255.255.255.0: 11111111 11111111 11111111 00000000
现在,让我们使用粗体标记 10.23.2.0 中在 255.255.255.0 中为 1 的位位置:
10.23.2.0: **00001010 00010111 00000010** 00000000
任何包含粗体位配置的地址都在该子网中。查看那些 不 是粗体的位(最后一组八个 0),将这些位中的任何数量设置为 1 都会得到该子网中的有效 IP 地址,唯一的例外是全 0 或全 1。
将它们结合起来,你可以看到一个 IP 地址为 10.23.2.1 且子网掩码为 255.255.255.0 的主机,和任何其他 IP 地址以 10.23.2 开头的计算机在同一个子网中。你可以用 10.23.2.0/255.255.255.0 来表示整个子网。
现在让我们看看这如何变成你从工具(如 ip)中看到的简写表示法(如 /24)。
9.4.3 常见子网掩码和 CIDR 表示法
在大多数互联网工具中,你会遇到一种不同的子网表示法,称为 无类域间路由(CIDR)表示法,其中像 10.23.2.0/255.255.255.0 这样的子网会写作 10.23.2.0/24。这种简写方式利用了子网掩码遵循的简单模式。
查看前一节中你看到的二进制形式的子网掩码。你会发现所有子网掩码都是(或者根据 RFC 1812,应该是)一个 1 的块后跟一个 0 的块。例如,你刚刚看到的 255.255.255.0 的二进制形式是 24 个 1 位后跟 8 个 0 位。CIDR 表示法通过子网掩码中的 前导 1 位的数量来标识子网掩码。因此,像 10.23.2.0/24 这样的组合包括了子网前缀和其子网掩码。
表 9-1 显示了几个示例子网掩码及其 CIDR 形式。/24 子网掩码是本地终端用户网络中最常见的;它通常与你将在 9.22 节中看到的某个私有网络一起使用。
表 9-1:子网掩码
| 长格式 | CIDR 格式 |
|---|---|
| 255.0.0.0 | /8 |
| 255.255.0.0 | /16 |
| 255.240.0.0 | /12 |
| 255.255.255.0 | /24 |
| 255.255.255.192 | /26 |
更进一步,你可能已经注意到,如果你有 IP 地址和子网掩码,实际上你甚至不需要单独定义网络。你可以将它们组合在一起,就像你在第 9.4.1 节中看到的那样;ip address show的输出包括了 10.23.2.4/24。
识别子网及其主机是理解互联网如何工作的第一步。然而,你仍然需要连接这些子网。
9.5 路由和内核路由表
连接互联网子网通常是通过连接到多个子网的主机发送数据来实现的。回到图 9-2,考虑一下 IP 地址为 10.23.2.4 的主机 A。该主机连接到 10.23.2.0/24 的本地网络,并且可以直接访问该网络中的主机。为了访问互联网其他主机,它必须通过 10.23.2.1 的路由器(主机)进行通信。
Linux 内核通过使用路由表来区分这两种不同的目标,以确定其路由行为。要查看路由表,请使用ip route show命令。以下是你可能会看到的简单主机(例如 10.23.2.4)的输出:
$ **ip route show**
default via 10.23.2.1 dev enp0s31f6 proto static metric 100
10.23.2.0/24 dev enp0s31f6 proto kernel scope link src 10.23.2.4 metric 100
这个输出可能有些难以阅读。每一行都是一个路由规则;我们从本例的第二行开始,将其拆分成各个字段。
你遇到的第一个字段是10.23.2.0/24,它是一个目标网络。和之前的例子一样,这是主机的本地子网。该规则表示主机可以通过其网络接口直接访问本地子网,这由目标后面的dev enp0s31f6机制标签指示。(在该字段之后是关于路由的更多细节,包括它是如何设置的。目前你不需要担心这些细节。)
然后我们可以回到输出的第一行,该行的目标网络是default。这个规则匹配任何主机,因此也叫做默认路由,在下一节中会解释。该机制是via 10.23.2.1,表示使用默认路由的流量会被发送到 10.23.2.1(在我们示例网络中,这是一台路由器);dev enp0s31f6表示物理传输将在该网络接口上进行。
9.6 默认网关
路由表中default条目的意义特殊,因为它匹配互联网中的任何地址。在 CIDR 表示法中,它是 0.0.0.0/0 用于 IPv4。这是默认路由,配置为默认路由中间人的地址被称为默认网关。当没有其他规则匹配时,默认路由始终匹配,默认网关就是没有其他选择时发送消息的地方。你可以配置一个没有默认网关的主机,但它将无法访问路由表中目的地之外的主机。
在大多数子网掩码为/24(255.255.255.0)的网络中,路由器通常位于子网的地址 1 处(例如,在 10.23.2.0/24 中是 10.23.2.1)。这只是一种约定,当然也有例外。
9.7 IPv6 地址与网络
如果你回顾第 9.4 节,你会看到 IPv4 地址由 32 位或 4 字节组成。这样大约可以提供 43 亿个地址,但对于当前规模的互联网来说,数量是不够的。由于 IPv4 地址不足,造成了几个问题,因此,互联网工程任务组(IETF)开发了下一版本的 IPv6。在查看更多网络工具之前,我们先讨论一下 IPv6 地址空间。
一个 IPv6 地址有 128 位——32 字节,分为八组 4 字节。以长格式表示时,地址写作如下:
2001:0db8:0a0b:12f0:0000:0000:0000:8b6e
表示是十六进制的,每个数字范围从 0 到 f。有几种常用的缩写表示方法。首先,你可以省略任何前导零(例如,0db8 可以写作 db8),而且可以有一个——且只能有一个——连续零组变为 ::(两个冒号)。因此,你可以将前面的地址写为:
2001:db8:a0b:12f0::8b6e
子网仍然使用 CIDR 表示法。对于最终用户来说,它们通常覆盖地址空间中一半的可用位(/64),但也有使用更少位的情况。地址空间中对每个主机唯一的部分称为接口 ID。图 9-3 显示了一个带有 64 位子网的示例地址的拆解。

图 9-3:典型 IPv6 地址的子网和接口 ID
关于 IPv6 目前需要知道的最后一件事是,主机通常至少有两个地址。第一个是有效的互联网地址,称为全局单播地址。第二个是用于本地网络的地址,称为链路本地地址。链路本地地址总是具有 fe80::/10 前缀,后面跟着一个全零的 54 位网络 ID,最后是一个 64 位的接口 ID。结果是,当你在系统上看到链路本地地址时,它将位于 fe80::/64 子网中。
9.7.1 在您的系统上查看 IPv6 配置
如果你的系统配置了 IPv6,你应该从之前运行的 ip 命令中获取了一些 IPv6 信息。要单独查看 IPv6 配置,使用 -6 选项:
$ **ip -6 address show**
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN qlen 1000
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: enp0s31f6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
inet6 2001:db8:8500:e:52b6:59cc:74e9:8b6e/64 scope global dynamic noprefixroute
valid_lft 86136sec preferred_lft 86136sec
inet6 fe80::d05c:97f9:7be8:bca/64 scope link noprefixroute
valid_lft forever preferred_lft forever
除了回环接口(我们稍后会讨论)外,你还可以看到另外两个地址。全局单播地址用 scope global 表示,而链路本地地址则会标记为 scope link。
查看路由类似:
$ **ip -6 route show**
::1 dev lo proto kernel metric 256 pref medium
1 2001:db8:8500:e::/64 dev enp0s31f6 proto ra metric 100 pref medium
2 fe80::/64 dev enp0s31f6 proto kernel metric 100 pref medium
3 default via fe80::800d:7bff:feb8:14a0 dev enp0s31f6 proto ra metric 100 pref medium
这比 IPv4 的设置稍微复杂一些,因为既配置了链路本地地址,也配置了全局子网。第二行 1 是针对本地附加的全局单播地址子网中的目标;主机知道它可以直接到达它们,下面的链路本地行 2 也类似。对于默认路由 3(在 IPv6 中也写作::/0;记住这是任何未直接连接的东西),该配置安排将流量通过链路本地地址 fe80::800d:7bff:feb8:14a0 的路由器,而不是其在全局子网上的地址。你稍后会看到,路由器通常不关心它如何接收流量,只关心流量应该去哪里。使用链路本地地址作为默认网关的优势在于,如果全局 IP 地址空间发生变化,它不需要改变。
9.7.2 配置双栈网络
正如你现在可能已经猜到的那样,配置主机和网络以同时运行 IPv4 和 IPv6 是可能的。这有时被称为双栈网络,虽然使用栈这个词有些值得商榷,因为在这种情况下,真正被复制的仅仅是典型网络栈中的一层(真正的双栈应该像是 IP+IPX 那样)。抛开这些细节不谈,IPv4 和 IPv6 协议是彼此独立的,并且可以同时运行。在这样的主机上,由应用程序(比如 Web 浏览器)来选择使用 IPv4 还是 IPv6 连接到另一个主机。
原本为 IPv4 编写的应用程序并不会自动支持 IPv6。幸运的是,由于网络层之上的栈层没有改变,与 IPv6 通信所需的代码非常少,且容易添加。现在,大多数重要的应用程序和服务器都已包含 IPv6 支持。
9.8 基本的 ICMP 和 DNS 工具
现在是时候看一些基本的实用工具来帮助你与主机互动了。这些工具使用了两个特别重要的协议:互联网控制消息协议(ICMP),它可以帮助你排查连接性和路由问题;以及域名服务(DNS)系统,它将名称映射到 IP 地址,以便你不必记住一堆数字。
ICMP 是一种传输层协议,用于配置和诊断互联网网络;它与其他传输层协议的不同之处在于,它不携带任何真正的用户数据,因此在它之上没有应用层。相比之下,DNS 是一种应用层协议,用于将人类可读的名称映射到互联网地址。
9.8.1 ping
ping(参见ftp.arl.army.mil/~mike/ping.html)是最基本的网络调试工具之一。它向主机发送 ICMP 回显请求包,请求接收主机将该包返回给发送者。如果接收主机收到该包并配置为回应,它会返回一个 ICMP 回显响应包。
例如,假设你运行ping 10.23.2.1并得到以下输出:
$ **ping 10.23.2.1**
PING 10.23.2.1 (10.23.2.1) 56(84) bytes of data.
64 bytes from 10.23.2.1: icmp_req=1 ttl=64 time=1.76 ms
64 bytes from 10.23.2.1: icmp_req=2 ttl=64 time=2.35 ms
64 bytes from 10.23.2.1: icmp_req=4 ttl=64 time=1.69 ms
64 bytes from 10.23.2.1: icmp_req=5 ttl=64 time=1.61 ms
第一行表示你正在向 10.23.2.1 发送 56 字节的包(如果包括头部的话是 84 字节),默认情况下,每秒发送一个包,剩下的行则显示来自 10.23.2.1 的响应。输出中最重要的部分是序列号(icmp_req)和往返时间(time)。返回的字节数是发送的包大小加上 8。(包的内容对你来说并不重要。)
序列号之间的间隙,比如 2 和 4 之间的间隙,通常意味着某种连接问题。包不应该乱序到达,因为 ping 每秒只发送一个包。如果响应花费超过一秒(1,000 毫秒)才到达,那么连接速度极慢。
往返时间是从请求包发送的时刻到响应包到达的时刻之间的总耗时。如果无法到达目标,最终看到该包的路由器会向 ping 返回一个 ICMP “主机不可达” 包。
在有线局域网中,你应该完全不遇到包丢失,并且往返时间应该非常低。(上面的示例输出来自无线网络。)你还应该期望从你的网络到 ISP 之间没有包丢失,并且往返时间保持相对稳定。
你可以使用 -4 和 -6 选项分别强制 ping 使用 IPv4 或 IPv6。
9.8.2 DNS 和主机
IP 地址难以记忆且易变,这就是为什么我们通常使用像 www.example.com 这样的名称。系统上的域名服务(DNS)库通常会自动处理这种转换,但有时你会需要手动转换名称和 IP 地址之间的关系。要找到域名背后的 IP 地址,可以使用 host 命令:
$ **host www.example.com**
example.com has address 172.17.216.34
example.com has IPv6 address 2001:db8:220:1:248:1893:25c8:1946
注意这个示例中既有 IPv4 地址 172.17.216.34,又有更长的 IPv6 地址。一个主机名可能有多个地址,输出还可能包含其他信息,如邮件交换器。
你也可以反向使用 host 命令:输入一个 IP 地址,而不是主机名,尝试发现该 IP 地址背后的主机名。然而,不要指望它能可靠工作。一个单独的 IP 地址可能与多个主机名相关联,DNS 并不知道如何确定应该对应哪个主机名。此外,主机的管理员需要手动设置反向查找,然而管理员通常不会这样做。
DNS 的内容远不止 host 命令。我们将在第 9.15 节中讨论基本的客户端配置。
host 命令有 -4 和 -6 选项,但它们的作用可能和你预期的不同。它们强制 host 命令通过 IPv4 或 IPv6 获取信息,但因为无论使用哪种网络协议,信息应该是一样的,所以输出可能会同时包含 IPv4 和 IPv6。
9.9 物理层与以太网
理解互联网的一个关键点是,它是一个软件网络。到目前为止,我们讨论的内容与硬件无关,实际上,互联网之所以成功的一个原因是它可以在几乎任何类型的计算机、操作系统和物理网络上运行。然而,如果你确实想与另一台计算机通信,你仍然需要在某种硬件上方放置一个网络层。这个接口就是物理层。
本书将讨论最常见的物理层类型:以太网网络。IEEE 802 标准族的文档定义了多种不同类型的以太网网络,从有线到无线,但它们都有一些共同点:
-
以太网网络上的所有设备都有媒体访问控制(MAC)地址,有时也称为硬件地址。该地址与主机的 IP 地址无关,并且是唯一的,专属于主机的以太网网络(但不一定是更大的软件网络,如互联网)。一个示例 MAC 地址为 10:78:d2:eb:76:97。
-
以太网网络上的设备通过帧发送消息,帧是数据的封装。一个帧包含源和目标的 MAC 地址。
以太网实际上并不试图在单一网络上超越硬件。例如,如果你有两个不同的以太网网络,并且一个主机连接到这两个网络(并且有两个不同的网络接口设备),你无法直接将一个帧从一个以太网网络传输到另一个,除非你设置了以太网桥。这就是更高层网络(如互联网层)发挥作用的地方。根据惯例,每个以太网网络通常也是一个互联网子网。尽管帧无法离开一个物理网络,但路由器可以将数据从帧中提取出来,重新打包,并将其发送到另一个物理网络上的主机,这正是互联网运作的方式。
9.10 理解内核网络接口
物理层和互联网层必须连接,以便互联网层保持其硬件独立的灵活性。Linux 内核在这两层之间保持着自己的分割,并提供了一种称为(内核)网络接口的通信标准,用于将它们连接起来。当你配置网络接口时,你将互联网层的 IP 地址设置与物理设备上的硬件标识关联起来。网络接口通常有名称,表示底层硬件类型,如enp0s31f6(一个 PCI 插槽中的接口)。这样的名称被称为可预测的网络接口设备名称,因为它在重启后保持不变。在启动时,接口有传统的名称,如eth0(计算机中的第一个以太网卡)和wlan0(无线接口),但在大多数运行 systemd 的机器上,它们会很快被重命名。
在第 9.4.1 节中,你学习了如何使用 ip address show 查看网络接口设置。输出按接口组织。这里是我们之前看到的:
2: enp0s31f6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state 1 UP group default qlen 1000
2 link/ether 40:8d:5c:fc:24:1f brd ff:ff:ff:ff:ff:ff
inet 10.23.2.4/24 brd 10.23.2.255 scope global noprefixroute enp0s31f6
valid_lft forever preferred_lft forever
inet6 2001:db8:8500:e:52b6:59cc:74e9:8b6e/64 scope global dynamic noprefixroute
valid_lft 86054sec preferred_lft 86054sec
inet6 fe80::d05c:97f9:7be8:bca/64 scope link noprefixroute
valid_lft forever preferred_lft forever
每个网络接口都会分配一个编号;这个接口的编号是 2。接口 1 通常是第 9.16 节中描述的回环接口。标志 UP 表示该接口正在正常工作 1。除了我们已经讲解的互联网层部分之外,你还会看到物理层上的 MAC 地址,link/ether 2。
尽管 ip 显示一些硬件信息,但它主要用于查看和配置附加到接口的软件层。要深入了解网络接口背后的硬件和物理层,可以使用类似 ethtool 的命令来显示或更改以太网卡的设置。(我们将在第 9.27 节简要讨论无线网络。)
9.11 网络接口配置简介
你现在已经了解了构成网络栈底层的所有基本元素:物理层、网络(互联网)层以及 Linux 内核的网络接口。为了将这些部分组合起来将 Linux 机器连接到互联网,你或某个软件必须执行以下操作:
-
连接网络硬件并确保内核有相应的驱动程序。如果驱动程序存在,即使设备尚未配置,
ip address show也会显示该设备的条目。 -
执行任何额外的物理层设置,如选择网络名称或密码。
-
为内核网络接口分配 IP 地址和子网,使得内核的设备驱动(物理层)和互联网子系统(互联网层)能够相互通信。
-
添加任何必要的附加路由,包括默认网关。
当所有机器都是大的固定盒子并通过线缆连接时,这一过程相对简单:内核执行第 1 步,你不需要第 2 步,而你可以通过旧的 ifconfig 命令执行第 3 步,通过旧的 route 命令执行第 4 步。我们将简要看一下如何使用 ip 命令执行这些操作。
9.11.1 手动配置接口
现在我们将看到如何手动设置接口,但我们不会深入讨论,因为这种操作很少需要,且容易出错。通常只有在试验系统时才会执行这种操作。即使在配置时,你也可能希望使用诸如 Netplan 这样的工具,在文本文件中构建配置,而不是像接下来所示的那样使用一系列命令。
你可以使用 ip 命令将接口绑定到互联网层。要为内核网络接口添加 IP 地址和子网,可以执行如下操作:
# ip address add `address/subnet` dev `interface`
在这里,interface 是接口的名称,例如 enp0s31f6 或 eth0。这同样适用于 IPv6,唯一不同的是你需要添加参数(例如,指示链路本地状态)。如果你想查看所有选项,请参阅 ip-address(8) 手册页。
9.11.2 手动添加和删除路由
在接口启动后,你可以添加路由,通常这只是设置默认网关的问题,像这样:
# ip route add default via `gw-address` **dev** `interface`
gw-address 参数是默认网关的 IP 地址;它 必须 是分配给你网络接口的本地连接子网中的一个地址。
要删除默认网关,请运行:
# ip route del default
你可以轻松地用其他路由覆盖默认网关。例如,假设你的机器位于 10.23.2.0/24 子网,你想要访问 192.168.45.0/24 子网,并且你知道 10.23.2.44 这台主机可以充当该子网的路由器。运行以下命令,将前往 192.168.45.0 的流量发送到该路由器:
# ip route add 192.168.45.0/24 via 10.23.2.44
删除路由时,你不需要指定路由器:
# ip route del 192.168.45.0/24
在处理路由之前,你应该知道,配置路由通常比看起来更复杂。以这个特定的例子为例,你还必须确保所有 192.163.45.0/24 网段的主机能够返回到 10.23.2.0/24 网段,否则你添加的第一条路由基本上是无效的。
通常,你应该尽可能保持简洁,设置本地网络,使得它们的主机只需要一个默认路由。如果你需要多个子网并且能够在它们之间路由,通常最好配置作为默认网关的路由器来处理不同本地子网之间的路由工作。(你将在第 9.21 节看到一个示例。)
9.12 启动激活网络配置
我们已经讨论了手动配置网络的方法,确保机器网络配置正确的传统方法是通过 init 运行脚本,在启动时执行手动配置。这归结为在启动事件链中的某个地方运行像 ip 这样的工具。
在 Linux 中,有许多尝试标准化启动时网络配置文件的方案。ifup 和 ifdown 工具就是其中之一;例如,启动脚本可以(理论上)运行 ifup eth0 来执行正确的 ip 命令以设置接口。不幸的是,不同的发行版对 ifup 和 ifdown 有完全不同的实现,因此它们的配置文件也不同。
由于网络配置元素存在于不同的网络层中,因此存在更深层的差异;其结果是,负责实现网络功能的软件位于内核和用户空间工具的多个部分,由不同的开发者编写和维护。在 Linux 中,普遍的共识是不在不同的工具套件或库之间共享配置文件,因为为一个工具所做的更改可能会破坏另一个工具。
在多个不同位置处理网络配置使得系统管理变得困难。因此,有几种不同的网络管理工具,每种工具都有自己解决配置问题的方法。然而,这些工具通常专门针对 Linux 机器可能扮演的特定角色。某个工具可能在桌面上有效,但在服务器上则不合适。
一个叫做 Netplan 的工具提供了不同的配置方法。与其管理网络,Netplan 仅仅是一个统一的网络配置标准和一个将该配置转换为现有网络管理工具使用的文件的工具。目前,Netplan 支持 NetworkManager 和 systemd-networkd,我们将在本章后面讨论这两者。Netplan 文件使用 YAML 格式,并存储在/etc/netplan目录下。
在我们讨论网络配置管理器之前,让我们先深入了解一些它们面临的问题。
9.13 手动和引导激活的网络配置问题
尽管大多数系统用于配置网络时会使用启动机制——许多服务器仍然如此——但现代网络的动态特性意味着大多数机器并没有静态(不变的)IP 地址。在 IPv4 中,机器不会将 IP 地址和其他网络信息存储在本地,而是在首次连接到本地物理网络时从网络的某个地方获取这些信息。大多数常见的网络客户端应用程序并不特别关心你的机器使用什么 IP 地址,只要它能够正常工作。动态主机配置协议(DHCP,详见 9.19 节)工具会在典型的 IPv4 客户端上进行基本的网络层配置。在 IPv6 中,客户端能够在一定程度上自我配置;我们将在 9.20 节简要讨论这一点。
然而,事情还有更多内容。例如,无线网络为接口配置增加了更多维度,如网络名称、认证和加密技术。当你从宏观角度来看待这个问题时,你会发现系统需要能够回答以下问题:
-
如果机器有多个物理网络接口(例如一台既有有线以太网又有无线以太网的笔记本),应该如何选择使用哪个接口?
-
机器应该如何设置物理接口?对于无线网络,这包括扫描网络名称、选择一个名称并协商认证。
-
一旦物理网络接口连接上,机器应该如何设置软件网络层,例如互联网层?
-
如何让用户选择连接选项?例如,如何让用户选择一个无线网络?
-
如果机器在网络接口丢失连接时应该怎么做?
解答这些问题通常超出了简单的启动脚本所能处理的范围,手动完成这一切也非常麻烦。解决方案是使用一个系统服务,它可以监控物理网络,并根据一组对用户有意义的规则选择(并自动配置)内核网络接口。该服务还应能够响应用户的请求,而用户应能够在不成为 root 用户的情况下更改所连接的无线网络。
9.14 网络配置管理器
在基于 Linux 的系统中,有几种自动配置网络的方式。桌面和笔记本电脑上最常用的选项是 NetworkManager。还有一个附加组件,叫做 systemd-networkd,它可以执行基本的网络配置,适用于不需要太多灵活性的机器(如服务器),但它没有 NetworkManager 的动态功能。其他网络配置管理系统主要针对较小的嵌入式系统,例如 OpenWRT 的 netifd、Android 的 ConnectivityManager 服务、ConnMan 和 Wicd。
我们将简要讨论 NetworkManager,因为它是你最有可能遇到的。尽管如此,我们不会深入讨论,因为在了解了基本概念后,NetworkManager 和其他配置系统将更容易理解。如果你对 systemd-networkd 感兴趣,可以查看 systemd.network(5) 手册页,其中描述了相关设置,配置目录为 /etc/systemd/network。
9.14.1 网络管理器操作
NetworkManager 是一个守护进程,系统在启动时会启动它。像大多数守护进程一样,它不依赖于正在运行的桌面组件。它的工作是监听来自系统和用户的事件,并根据一组规则更改网络配置。
在运行时,NetworkManager 保持两个基本的配置层次。第一个是关于可用硬件设备的信息集合,它通常通过内核收集并通过监控 udev 通过桌面总线(D-Bus)进行维护。第二个配置层次是更具体的连接列表:硬件设备和额外的物理与网络层配置参数。例如,一个无线网络可以表示为一个连接。
为了激活连接,NetworkManager 通常会将任务委派给其他专用的网络工具和守护进程,例如 dhclient,从本地连接的物理网络获取互联网层配置。由于网络配置工具和方案在各个发行版之间有所不同,NetworkManager 使用插件与它们交互,而不是强加自己的标准。例如,Debian/Ubuntu 和 Red Hat 风格的接口配置都有对应的插件。
启动时,NetworkManager 会收集所有可用的网络设备信息,搜索其连接列表,然后决定尝试激活其中一个。以下是它如何为以太网接口做出决策:
-
如果有有线连接可用,请尝试使用它。如果没有,尝试使用无线连接。
-
扫描可用的无线网络列表。如果有一个你以前连接过的网络可用,NetworkManager 会再次尝试连接。
-
如果有多个以前连接过的无线网络可用,选择最近连接的网络。
在建立连接后,NetworkManager 会保持连接,直到连接丢失、出现更好的网络(例如,你在无线连接的同时插入了网络电缆)或用户强制更改。
9.14.2 NetworkManager 交互
大多数用户通过桌面上的小程序与 NetworkManager 交互;它通常是位于屏幕右上角或右下角的图标,显示连接状态(有线、无线或未连接)。当你点击该图标时,会出现多个连接选项,如选择无线网络和断开当前网络的选项。每个桌面环境都有自己的版本,所以每个环境中的小程序看起来稍有不同。
除了小程序外,还有一些工具可以让你在 Shell 中查询和控制 NetworkManager。要快速查看当前连接状态,可以使用不带参数的nmcli命令。你将看到一个接口和配置参数的列表。在某些方面,这类似于ip命令,只不过当你查看无线连接时,nmcli会提供更多细节。
nmcli命令允许你从命令行控制 NetworkManager。这是一个比较复杂的命令;实际上,除了常规的 nmcli(1)手册页之外,还有一个 nmcli-examples(5)手册页。
最后,工具nm-online会告诉你网络是连接还是断开。如果网络连接,命令返回0作为退出码;否则,返回非零值。(有关如何在脚本中使用退出码的更多信息,请参见第十一章。)
9.14.3 NetworkManager 配置
NetworkManager 的一般配置目录通常是/etc/NetworkManager,并且有几种不同类型的配置。通用配置文件是NetworkManager.conf。其格式类似于 XDG 风格的.desktop文件和 Microsoft 的.ini文件,使用键值对参数并分为不同的部分。几乎每个配置文件中都会有一个[main]部分,用于定义要使用的插件。以下是一个简单的示例,激活了 Ubuntu 和 Debian 使用的 ifupdown 插件:
[main]
plugins=ifupdown,keyfile
其他特定发行版的插件包括 ifcfg-rh(适用于 Red Hat 风格的发行版)和 ifcfg-suse(适用于 SuSE)。你在这里看到的 keyfile 插件支持 NetworkManager 的本地配置文件支持。在使用此插件时,你可以在/etc/NetworkManager/system-connections中查看系统所有已知的连接。
大多数情况下,你无需更改NetworkManager.conf,因为更具体的配置选项通常在其他文件中。
未管理的接口
尽管你可能希望 NetworkManager 管理大多数网络接口,但也会有一些时候你希望它忽略某些接口。例如,大多数用户不需要对 localhost (lo;见第 9.16 节) 接口进行任何动态配置,因为它的配置永远不会改变。你还希望在启动过程的早期配置此接口,因为基本的系统服务通常依赖于它。大多数发行版会让 NetworkManager 忽略 localhost。
你可以通过使用插件让 NetworkManager 忽略某个接口。如果你使用的是 ifupdown 插件(例如,在 Ubuntu 和 Debian 中),请将接口配置添加到 /etc/network/interfaces 文件中,然后在 NetworkManager.conf 文件的 ifupdown 部分将 managed 的值设置为 false:
[ifupdown]
managed=false
对于 Fedora 和 Red Hat 使用的 ifcfg-rh 插件,请在 /etc/sysconfig/network-scripts 目录中查找类似这样的行,其中包含 ifcfg-* 配置文件:
NM_CONTROLLED=yes
如果此行不存在或其值设置为 no,NetworkManager 将忽略该接口。在 localhost 的情况下,你会在 ifcfg-lo 文件中发现它被停用。你也可以指定一个要忽略的硬件地址,如下所示:
HWADDR=10:78:d2:eb:76:97
如果你不使用这两种网络配置方案中的任何一种,仍然可以使用 keyfile 插件,通过其 MAC 地址在 NetworkManager.conf 文件中直接指定未管理的设备。以下是一个显示两个未管理设备的示例:
[keyfile]
unmanaged-devices=mac:10:78:d2:eb:76:97;mac:1c:65:9d:cc:ff:b9
分派
NetworkManager 配置的一个最终细节涉及在网络接口启动或停止时指定额外的系统操作。例如,一些网络守护进程需要知道何时开始或停止在接口上监听,以便正确工作(例如下章中讨论的安全外壳守护进程)。
当系统的网络接口状态发生变化时,NetworkManager 会在 /etc/NetworkManager/dispatcher.d 中运行所有脚本,并传递类似 up 或 down 的参数。这相对直接,但许多发行版有自己的网络控制脚本,因此不会将单独的分派脚本放在此目录中。例如,Ubuntu 只有一个名为 01ifupdown 的脚本,它会在 /etc/network 的适当子目录中运行所有脚本,例如 /etc/network/if-up.d。
与其余的 NetworkManager 配置一样,这些脚本的细节相对不那么重要;你需要知道的是如何在需要添加或更改时找到合适的位置(或者使用 Netplan,让它为你找到位置)。像往常一样,不要害怕查看你系统上的脚本。
9.15 主机名解析
在任何网络配置中,最基本的任务之一是通过 DNS 进行主机名解析。你已经看到过 host 解析工具,它将像 www.example.com 这样的名称转换为像 10.23.2.132 这样的 IP 地址。
DNS 与我们迄今为止所讨论的网络元素不同,因为它处于应用层,完全在用户空间内。因此,它在这一章中与互联网和物理层的讨论相比,技术上略显不合适。然而,没有正确的 DNS 配置,你的互联网连接几乎没有价值。正常人不会为网站和电子邮件地址发布 IP 地址(更不用说 IPv6 地址了),因为主机的 IP 地址是会变化的,而且一堆数字也不容易记住。
几乎所有 Linux 系统上的网络应用程序都执行 DNS 查找。解析过程通常是这样展开的:
-
应用程序调用一个函数来查找主机名背后的 IP 地址。这个函数位于系统的共享库中,因此应用程序无需了解它是如何工作的或实现是否会改变。
-
当共享库中的函数运行时,它会根据一组规则(这些规则可以在/etc/nsswitch.conf中找到;见 9.15.4 节)来决定查找的行动方案。例如,规则通常会说,在去 DNS 之前,先检查/etc/hosts文件中是否有手动覆盖。
-
当函数决定使用 DNS 进行名称查找时,它会查阅一个额外的配置文件来查找 DNS 名称服务器。该名称服务器作为 IP 地址提供。
-
函数向名称服务器发送 DNS 查找请求(通过网络)。
-
名称服务器回复主机名的 IP 地址,函数将该 IP 地址返回给应用程序。
这是简化版。在一个典型的现代系统中,通常会有更多的参与者试图加快交易或增加灵活性。暂时忽略这些,先来看一些基本的组成部分。像其他类型的网络配置一样,你可能不需要更改主机名解析,但了解它的工作原理会很有帮助。
9.15.1 /etc/hosts
在大多数系统上,你可以通过/etc/hosts文件覆盖主机名查找。它通常如下所示:
127.0.0.1 localhost
10.23.2.3 atlantic.aem7.net atlantic
10.23.2.4 pacific.aem7.net pacific
::1 localhost ip6-localhost
你几乎总是会在这里看到 localhost 的条目(或条目)(见 9.16 节)。这里的其他条目展示了一种简单的方式来在本地子网中添加主机。
9.15.2 resolv.conf
DNS 服务器的传统配置文件是/etc/resolv.conf。在简单的情况下,典型的例子可能如下所示,其中 ISP 的名称服务器地址是 10.32.45.23 和 10.3.2.3:
search mydomain.example.com example.com
nameserver 10.32.45.23
nameserver 10.3.2.3
search行定义了不完整主机名的规则(仅主机名的第一部分——例如,使用myserver而不是myserver.example.com)。在这里,解析器库会尝试查找host``.mydomain.example.com和host``.example.com。
通常,名称查找已经不再这么直接。DNS 配置已经做了许多增强和修改。
9.15.3 缓存和零配置 DNS
传统 DNS 配置有两个主要问题。首先,本地机器不缓存名称服务器的回复,因此频繁的重复网络访问可能会因为名称服务器请求而变得不必要地慢。为了解决这个问题,许多机器(以及作为名称服务器的路由器)运行一个中间守护进程,用来拦截名称服务器请求并缓存回复,然后在可能的情况下使用缓存的答案。这些守护进程中最常见的是 systemd-resolved;你可能还会在系统上看到 dnsmasq 或 nscd。你也可以将 BIND(标准的 Unix 名称服务器守护进程)设置为缓存。如果你在 /etc/resolv.conf 文件中看到 127.0.0.53 或 127.0.0.1,或者在运行 nslookup -debug host 时看到这些,通常说明你正在运行名称服务器缓存守护进程。不过,再仔细看看。如果你正在运行 systemd-resolved,你可能会注意到 resolv.conf 甚至不在 /etc 中;它是一个指向 /run 中自动生成文件的链接。
systemd-resolved 的功能远不止于此,它能够结合多个名称查找服务,并为每个接口提供不同的呈现方式。这解决了传统名称服务器设置中的第二个问题:如果你想在不进行大量配置的情况下,能够查找本地网络上的名称,传统的设置尤其显得不够灵活。例如,如果你在网络上设置了一个网络设备,你会希望能够立刻通过名称来访问它。这也是零配置名称服务系统(如多播 DNS(mDNS)和链路本地多播名称解析(LLMNR))背后的理念之一。如果某个进程想要通过名称在本地网络上查找主机,它只需广播一个请求到网络上;如果目标主机存在,它会回复自己的地址。这些协议不仅超越了主机名解析,还提供了有关可用服务的信息。
你可以通过 resolvectl status 命令查看当前的 DNS 设置(注意,在旧系统上,这个命令可能叫做 systemd-resolve)。你将看到一份全局设置列表(通常没什么用处),接着是每个独立接口的设置。它看起来像这样:
Link 2 (enp0s31f6)
Current Scopes: DNS
LLMNR setting: yes
MulticastDNS setting: no
DNSSEC setting: no
DNSSEC supported: no
DNS Servers: 8.8.8.8
DNS Domain: ~.
你可以在这里看到各种支持的名称协议,以及 systemd-resolved 用来查询它不认识的名称的名称服务器。
我们不会进一步讨论 DNS 或 systemd-resolved,因为这是一个庞大的话题。如果你想修改设置,可以查看 resolved.conf(5) 手册页,并继续修改 /etc/systemd/resolved.conf。然而,你可能需要深入阅读 systemd-resolved 的大量文档,同时也要通过像 DNS and BIND(第 5 版,Cricket Liu 和 Paul Albitz 著,O'Reilly,2006 年)这样的资料,熟悉 DNS 的基本概念。
9.15.4 /etc/nsswitch.conf
在我们结束关于名称查找的话题之前,还有一个你应该了解的最后一个设置。/etc/nsswitch.conf 文件是传统的接口,用于控制系统上多个名称相关的优先级设置,比如用户和密码信息,并且它有一个主机查找设置。你系统中的文件应该有如下的这一行:
hosts: files dns
将 files 放在 dns 前面,确保在查找主机时,系统会首先检查 /etc/hosts 文件中的主机查找,然后再询问任何 DNS 服务器,包括 systemd-resolved。这通常是一个好主意(特别是对于接下来的本地主机查找),但你的 /etc/hosts 文件应该尽可能简短。不要在里面放入任何东西来提升性能;这么做会在以后给你带来麻烦。你可以在 /etc/hosts 中放入一个小型私有局域网的主机,但一般规则是,如果一个特定的主机有 DNS 条目,它就不应该出现在 /etc/hosts 中。(/etc/hosts 文件也在启动的早期阶段非常有用,那时网络可能尚未可用。)
所有这一切都通过系统库中的标准调用实现。记住所有名称查找可能发生的地方可能会很复杂,但如果你需要从底层追踪某些东西,首先从 /etc/nsswitch.conf 开始。
9.16 本地主机
当运行 ip address show 时,你会看到 lo 接口:
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
lo 接口是一个虚拟网络接口,被称为 loopback,因为它“回环”到自身。效果是,连接到 127.0.0.1(或在 IPv6 中的 ::1)就是连接到你当前使用的机器。当发往本地主机的数据到达 lo 的内核网络接口时,内核会将其重新封装为入站数据,并通过 lo 发送回去,供任何正在监听的服务器程序使用(默认情况下,大多数程序都在监听)。
lo 回环接口通常是你在启动时脚本中看到静态网络配置的唯一地方。例如,Ubuntu 的 ifup 命令会读取 /etc/network/interfaces。然而,这通常是多余的,因为 systemd 会在启动时配置回环接口。
回环接口有一个特殊之处,你可能已经注意到了。子网掩码是 /8,任何以 127 开头的地址都会分配给回环接口。这允许你在回环空间中运行不同的服务器,而无需配置额外的接口。利用这一点的一个服务器是 systemd-resolved,它使用 127.0.0.53\。这样,它就不会干扰另一个运行在 127.0.0.1\ 上的名称服务器。到目前为止,IPv6 只定义了一个回环地址,但有提案要对此进行更改。
9.17 传输层:TCP、UDP 和服务
到目前为止,我们只看到了数据包如何从主机传输到主机——换句话说,回答了章节开头的在哪里的问题。现在我们开始回答什么被传输的问题。了解你的计算机如何将从其他主机接收到的包数据呈现给其正在运行的进程非常重要。如果用户空间程序必须像内核那样处理一堆原始数据包,那将会既困难又不方便。灵活性尤其重要:多个应用程序应该能够同时与网络通信(例如,你可能同时运行电子邮件和多个网页客户端)。
传输层协议桥接了互联网层的原始数据包和应用程序的精细需求之间的鸿沟。两种最流行的传输协议是传输控制协议(TCP)和用户数据报协议(UDP)。我们将集中讨论 TCP,因为它是使用最广泛的协议,但我们也会快速了解 UDP。
9.17.1 TCP 端口和连接
TCP 通过网络端口为一台机器上的多个网络应用提供支持,端口只是与 IP 地址结合使用的数字。如果一个主机的 IP 地址像一座公寓楼的邮政地址,那么端口号就像是邮箱号码——它是进一步的细分。
使用 TCP 时,应用程序会在自己机器的一个端口与远程主机的端口之间打开一个连接(不要与 NetworkManager 连接混淆)。例如,像网页浏览器这样的应用程序可以在自己机器的 36406 端口和远程主机的 80 端口之间建立连接。从应用程序的角度来看,36406 端口是本地端口,80 端口是远程端口。
你可以通过 IP 地址和端口号对来识别一个连接。要查看当前机器上打开的连接,可以使用netstat。下面是一个显示 TCP 连接的示例;-n选项禁用主机名解析(DNS),-t选项将输出限制为 TCP:
$ **netstat -nt**
Active Internet connections (w/o servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 10.23.2.4:47626 10.194.79.125:5222 ESTABLISHED
tcp 0 0 10.23.2.4:41475 172.19.52.144:6667 ESTABLISHED
tcp 0 0 10.23.2.4:57132 192.168.231.135:22 ESTABLISHED
本地地址和外部地址字段表示从你机器的角度来看连接情况,因此这里的机器在 10.23.2.4 配置了一个接口,本地端的 47626、41475 和 57132 端口都已连接。第一个连接显示端口 47626 连接到 10.194.79.125 的 5222 端口。
要仅显示 IPv6 连接,可以在netstat选项中添加-6。
建立 TCP 连接
要建立一个传输层连接,一台主机上的进程会通过一系列特殊的包从它的本地端口向第二台主机的端口发起连接。为了识别这个传入连接并作出响应,第二台主机必须有一个进程在正确的端口上监听。通常,发起连接的进程被称为客户端,而监听的进程被称为服务器(第十章会详细介绍)。
关于端口的一个重要知识点是,客户端选择一个本地端口,该端口当前未被占用,并且几乎总是连接到服务器端的某个知名端口。回想一下前一节中netstat命令的输出:
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 10.23.2.4:47626 10.194.79.125:5222 ESTABLISHED
通过一些端口编号约定的知识,你可以看出,这个连接很可能是由本地客户端发起到远程服务器的,因为本地端口(47626)看起来像是一个动态分配的端口,而远程端口(5222)是/etc/services中列出的知名服务(具体来说是 Jabber 或 XMPP 消息服务)。在大多数桌面机器上,你会看到许多连接到端口 443(HTTPS 的默认端口)。
然而,如果输出中的本地端口是知名端口,那么很可能是远程主机发起了连接。在这个例子中,远程主机 172.24.54.234 已连接到本地主机的 443 端口:
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 10.23.2.4:443 172.24.54.234:43035 ESTABLISHED
如果远程主机连接到你的机器上的知名端口,那么说明你的本地机器上有一个服务器在该端口上监听。为确认这一点,可以使用netstat列出你机器上所有正在监听的 TCP 端口,这次使用-l选项,显示进程正在监听的端口:
$ **netstat -ntl**
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
1 tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN
2 tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN
3 tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN
--`snip`--
本地地址为 0.0.0.0:80 的第 1 行显示,本地机器正在监听端口 80,接受来自任何远程机器的连接;端口 443(第 2 行)也是如此。服务器可以限制对特定接口的访问,如第 3 行所示,其中某个进程仅在本地接口上监听连接。在这种情况下,是 systemd-resolved;我们曾在第 9.16 节讨论过它为什么使用 127.0.0.53 而不是 127.0.0.1 来监听。欲了解更多信息,请使用lsof命令识别正在监听的特定进程(如第 10.5.1 节所讨论)。
端口号与/etc/services
如何判断一个端口是否为知名端口?没有单一的方法可以确定,但一个好的起点是查看/etc/services,该文件将知名端口号转换为名称。这是一个纯文本文件,你应该能看到如下条目:
ssh 22/tcp # SSH Remote Login Protocol
smtp 25/tcp
domain 53/udp
第一列是名称,第二列表示端口号和特定的传输层协议(可能是 TCP 以外的协议)。
在 Linux 上,只有以超级用户身份运行的进程才能使用 1 到 1023 号端口,这些端口也称为系统端口、知名端口或特权端口。所有用户进程可以在 1024 及以上端口上监听并创建连接。
TCP 的特点
TCP 作为传输层协议非常流行,因为它对应用层要求相对较少。一个应用进程只需要知道如何打开(或监听)连接、从中读取、写入以及关闭连接。对于应用程序来说,似乎存在着传入和传出的数据流;这个过程几乎和处理文件一样简单。
然而,背后有很多工作在进行。首先,TCP 实现需要知道如何将进程中的输出数据流拆分成数据包。然而,更难的部分是如何将一系列传入的数据包转换成进程可以读取的输入数据流,特别是当数据包的到达顺序不一定正确时。此外,使用 TCP 的主机必须检查错误:数据包在通过互联网发送时可能会丢失或损坏,TCP 实现必须检测并修正这些情况。图 9-4 展示了主机如何使用 TCP 发送消息的简化示意图。
幸运的是,除了 Linux 的 TCP 实现主要在内核中,并且与传输层相关的工具往往会操作内核数据结构外,你几乎不需要了解这个复杂的内容。一个例子是第 9.25 节中讨论的 iptables 数据包过滤系统。
9.17.2 UDP
UDP 是比 TCP 更简单的传输层协议。它仅定义单一消息的传输;没有数据流。同时,与 TCP 不同,UDP 不会修正丢失或乱序的数据包。实际上,尽管 UDP 有端口,它甚至没有连接!一个主机只需将消息从其某个端口发送到服务器的端口,如果服务器愿意,它可以回复消息。然而,UDP 确实有数据包内的错误检测;主机可以检测到数据包是否损坏,但它不必对此做任何处理。
如果说 TCP 就像进行电话通话,UDP 就像发送信件、电报或即时消息(只不过即时消息更可靠)。使用 UDP 的应用程序通常关注速度——尽可能快速地发送消息。它们不希望有 TCP 的开销,因为它们假设两个主机之间的网络通常是可靠的。它们不需要 TCP 的错误修正,因为它们要么有自己的错误检测系统,要么根本不在乎错误。

图 9-4:使用 TCP 发送消息
使用 UDP 的一个应用示例是 网络时间协议(NTP)。客户端向服务器发送一个简短的请求以获取当前时间,服务器的响应同样简短。由于客户端希望尽快得到响应,UDP 适合这个应用;如果服务器的响应在网络中丢失,客户端可以重新发送请求或放弃。另一个例子是视频聊天。在这种情况下,图像通过 UDP 发送,如果过程中某些部分丢失,接收端的客户端会尽力进行补偿。
9.18 再次回顾一个简单的局部网络
现在我们将看一下在第 9.4 节中介绍的简单网络的其他组件。该网络由一个局域网(LAN)作为子网,以及一个将子网连接到互联网其他部分的路由器组成。你将学习以下内容:
-
子网中的主机如何自动获取其网络配置
-
如何设置路由
-
路由器到底是什么
-
如何知道子网应使用哪些 IP 地址
-
如何设置防火墙来过滤来自互联网的不必要流量
在大多数情况下,我们将专注于 IPv4(如果不是因为地址更容易阅读的话),但当 IPv6 有所不同时,你会看到它的变化。
让我们首先看看子网中的主机如何自动获取其网络配置。
9.19 理解 DHCP
在 IPv4 下,当你设置网络主机从网络自动获取配置时,你是在告诉它使用动态主机配置协议(DHCP)来获取 IP 地址、子网掩码、默认网关和 DNS 服务器。除了不需要手动输入这些参数外,网络管理员还可以通过 DHCP 获得其他优势,例如防止 IP 地址冲突和最小化网络变化的影响。几乎很少见到没有使用 DHCP 的网络。
为了让主机通过 DHCP 获取其配置,它必须能够向连接网络上的 DHCP 服务器发送消息。因此,每个物理网络应该有自己的 DHCP 服务器,在简单的网络(如第 9.1 节中的网络)中,路由器通常充当 DHCP 服务器。
当一台机器请求 DHCP 服务器分配一个 IP 地址时,它实际上是在请求某个地址的租约,并且该租约会持续一段时间。当租约到期时,客户端可以请求续租。
9.19.1 Linux DHCP 客户端
尽管有许多不同类型的网络管理系统,但实际上只有两个 DHCP 客户端负责获取租约。传统的标准客户端是互联网软件联盟(ISC)dhclient程序。然而,systemd-networkd 现在也包括了一个内置的 DHCP 客户端。
启动时,dhclient将其进程 ID 存储在/var/run/dhclient.pid中,并将其租约信息存储在/var/lib/dhcp/dhclient.leases中。
你可以手动在命令行上测试dhclient,但在此之前你必须移除任何默认网关路由(参见第 9.11.2 节)。要运行测试,只需指定网络接口名称(这里是 enp0s31f6):
# dhclient enp0s31f6
与dhclient不同,systemd-networkd DHCP 客户端不能在命令行中手动运行。配置文件位于/etc/systemd/network,如 systemd.network(5)手册页所描述,但与其他类型的网络配置一样,可以通过 Netplan 自动生成。
9.19.2 Linux DHCP 服务器
你可以将 Linux 机器配置为运行 DHCP 服务器,这样可以很好地控制它分配的地址。然而,除非你在管理一个拥有多个子网的大型网络,否则你可能更倾向于使用内置 DHCP 服务器的专用路由器硬件。
可能最重要的一点是,关于 DHCP 服务器,你只希望在同一子网中运行一个,以避免 IP 地址冲突或配置错误的问题。
9.20 自动 IPv6 网络配置
DHCP 在实践中工作得相当好,但它依赖于某些假设,包括必须有一个可用的 DHCP 服务器,服务器需要正确实现且稳定,并且能够跟踪和维护租约。虽然有一种适用于 IPv6 的 DHCP 版本,称为 DHCPv6,但还有一种更为常见的替代方式。
IETF 利用了广阔的 IPv6 地址空间,设计了一种新的网络配置方式,不需要中央服务器。这被称为无状态配置,因为客户端不需要存储任何数据,如租约分配。
无状态 IPv6 网络配置从链路本地网络开始。回想一下,这个网络包括以 fe80::/64 为前缀的地址。由于链路本地网络上有大量可用地址,主机可以生成一个不太可能在网络中任何地方重复的地址。此外,网络前缀已经固定,因此主机可以广播到网络,询问网络上是否有其他主机正在使用该地址。
一旦主机拥有了链路本地地址,它就可以确定一个全球地址。它通过监听路由器偶尔在链路本地网络上发送的路由器广告(RA)消息来实现这一点。RA 消息包括全球网络前缀、路由器 IP 地址以及可能的 DNS 信息。有了这些信息,主机可以尝试填写全球地址的接口 ID 部分,类似于它在链路本地地址时所做的。
无状态配置依赖于一个最大为 64 位长的全球网络前缀(换句话说,其子网掩码是/64 或更低)。
9.21 配置 Linux 作为路由器
路由器只是具有多个物理网络接口的计算机。你可以轻松地配置一台 Linux 机器作为路由器。
我们来看一个例子。假设你有两个局域网子网,10.23.2.0/24 和 192.168.45.0/24。为了连接它们,你有一台 Linux 路由器机器,具有三个网络接口:两个用于局域网子网,一个用于互联网上行链接,如图 9-5 所示。
如你所见,这看起来与本章其余部分使用的简单网络示例并没有太大区别。路由器在局域网子网中的 IP 地址分别是 10.23.2.1 和 192.168.45.1。当这些地址被配置后,路由表大致如下所示(接口名称在实践中可能有所不同;暂时忽略互联网上行链接):
# ip route show
10.23.2.0/24 dev enp0s31f6 proto kernel scope link src 10.23.2.1 metric 100
192.168.45.0/24 dev enp0s1 proto kernel scope link src 192.168.45.1 metric 100
现在假设每个子网中的主机都将路由器作为默认网关(10.23.2.1 对于 10.23.2.0/24,192.168.45.1 对于 192.168.45.0/24)。如果 10.23.2.4 想要发送一个数据包到 10.23.2.0/24 以外的任何地方,它会将数据包传递给 10.23.2.1。例如,为了将数据包从 10.23.2.4(主机 A)发送到 192.168.45.61(主机 E),数据包会通过其 enp0s31f6 接口发送到 10.23.2.1(路由器),然后通过路由器的 enp0s1 接口返回出去。

图 9-5:通过路由器连接的两个子网
然而,在一些基本配置中,Linux 内核并不会自动将数据包从一个子网移动到另一个子网。要启用这一基本路由功能,你需要通过以下命令在路由器的内核中启用IP 转发:
# sysctl -w net.ipv4.ip_forward=1
一旦你输入此命令,机器应该开始在子网之间路由数据包,前提是这些子网中的主机知道将数据包发送到你刚创建的路由器。
为了在重启时使此更改永久生效,你可以将其添加到/etc/sysctl.conf 文件中。根据你的发行版,你可能有选择将其放入/etc/sysctl.d 文件的选项,这样发行版的更新就不会覆盖你的更改。
当路由器也具有第三个带有互联网上行链路的网络接口时,这种设置允许所有子网中的主机访问互联网,因为它们已配置为使用路由器作为默认网关。但问题在于,这也让事情变得更加复杂。问题在于,某些 IPv4 地址,如 10.23.2.4,实际上并不能被整个互联网看到;它们属于所谓的私有网络。为了提供互联网连接,你必须在路由器上设置一个名为网络地址转换(NAT)的功能。几乎所有专用路由器的软件都能完成这一点,所以这并不算什么特别的事情,但让我们稍微更详细地研究一下私有网络的问题。
9.22 私有网络(IPv4)
假设你决定建立自己的网络。你已经准备好了计算机、路由器和网络硬件。根据你目前对简单网络的理解,你的下一个问题是:“我应该使用什么 IP 子网?”
如果你想要一个全互联网可见的地址块,你可以向你的 ISP 购买一个。然而,由于 IPv4 地址的范围非常有限,这样做成本高昂,且仅适合用于运行一个互联网能够看到的服务器。大多数人并不需要这种服务,因为他们作为客户端访问互联网。
常规的、廉价的替代方法是从 RFC 1918/6761 互联网标准文档中选择一个私有子网地址,如表 9-2 所示。
表 9-2:RFC 1918 和 6761 定义的私有网络
| 网络 | 子网掩码 | CIDR 形式 |
|---|---|---|
| 10.0.0.0 | 255.0.0.0 | 10.0.0.0/8 |
| 192.168.0.0 | 255.255.0.0 | 192.168.0.0/16 |
| 172.16.0.0 | 255.240.0.0 | 172.16.0.0/12 |
你可以根据需要划分私有子网。除非你计划在单一网络中拥有超过 254 个主机,否则可以选择一个小的子网,如 10.23.2.0/24,就像本章中所使用的那样。(具有这种子网掩码的网络有时被称为Class C 子网,虽然这一术语在技术上已经过时,但仍然有用。)
那么问题在哪里?真实互联网中的主机对私有子网一无所知,并且不会向它们发送数据包,因此,如果没有帮助,私有子网中的主机无法与外部世界通信。连接到互联网的路由器(具有真实的、非私有的地址)需要有某种方式来填补该连接与私有网络中主机之间的空白。
9.23 网络地址转换(IP 伪装)
NAT 是最常用的方式来与私有网络共享单一的 IP 地址,并且在家庭和小型办公室网络中几乎是普遍使用的。在 Linux 中,大多数人使用的 NAT 变体被称为 IP 伪装。
NAT 的基本思想是,路由器不仅仅是将数据包从一个子网移动到另一个子网,它还在移动的过程中转换这些数据包。互联网上的主机知道如何连接到路由器,但它们不知道路由器后面的私有网络。私有网络上的主机不需要特殊配置;路由器是它们的默认网关。
系统大致是这样的工作原理:
-
内部私有网络上的主机想要连接到外部世界,因此它通过路由器发送连接请求数据包。
-
路由器拦截连接请求数据包,而不是将其传递到互联网(否则它会丢失,因为公共互联网对私有网络一无所知)。
-
路由器确定连接请求数据包的目的地,并打开到该目的地的连接。
-
当路由器获得连接时,它会假装向原始内部主机发送一个“连接已建立”消息。
-
现在,路由器成为了内部主机和目的地之间的中介。目的地对内部主机一无所知;远程主机上的连接看起来像是来自路由器。
这并不像听起来那么简单。普通的 IP 路由只知道互联网层中的源地址和目的地址。然而,如果路由器仅处理互联网层,内部网络上的每个主机一次只能与单个目的地建立一个连接(还有其他的限制),因为数据包的互联网层部分没有信息来区分同一主机向同一目的地发出的多个请求。因此,NAT 必须超越互联网层,拆解数据包以提取更多的识别信息,特别是来自传输层的 UDP 和 TCP 端口号。UDP 相对简单,因为它有端口但没有连接,而 TCP 传输层则较为复杂。
要将 Linux 机器设置为执行 NAT 路由器,你必须在内核配置中激活以下所有选项:网络数据包过滤(“防火墙支持”)、连接跟踪、iptables 支持、完全的 NAT 以及 MASQUERADE 目标支持。大多数发行版内核都提供这些支持。
接下来,你需要运行一些看起来复杂的iptables命令,使路由器对其私有子网执行 NAT。这里有一个例子,适用于 enp0s2 上的内部以太网网络,分享 enp0s31f6 上的外部连接(你将在第 9.25 节学习更多关于 iptables 的语法):
# sysctl -w net.ipv4.ip_forward=1
# iptables -P FORWARD DROP
# iptables -t nat -A POSTROUTING -o enp0s31f6 -j MASQUERADE
# iptables -A FORWARD -i enp0s31f6 -o enp0s2 -m state --state ESTABLISHED,RELATED -j ACCEPT
# iptables -A FORWARD -i enp0s2 -o enp0s31f6 -j ACCEPT
除非你在开发自己的软件,否则你可能永远不需要手动输入这些命令,尤其是现在有这么多专用路由器硬件可用。然而,各种虚拟化软件可以设置 NAT,用于虚拟机和容器的网络配置。
尽管 NAT 在实际中运行良好,但请记住,它本质上是一种延长 IPv4 地址空间生命周期的技巧。由于 IPv6 的地址空间更大、更复杂,正如第 9.7 节所述,它不需要 NAT。
9.24 路由器与 Linux
在宽带的早期,需求较低的用户通常直接将计算机连接到互联网。但很快,许多用户就希望将一个宽带连接共享到他们自己的网络,尤其是 Linux 用户,通常会设置一台额外的机器作为路由器运行 NAT。
厂商通过提供专门的路由器硬件来回应这一新市场,这些硬件通常包括高效的处理器、一些闪存和几个网络端口——足以管理一个典型的简单网络,运行重要的软件如 DHCP 服务器,并使用 NAT。在软件方面,许多厂商选择使用 Linux 来驱动它们的路由器。它们添加了必要的内核特性,精简了用户空间的软件,并创建了基于图形界面的管理界面。
几乎在这些路由器出现的第一时间,许多人就开始对深入挖掘硬件产生兴趣。一个厂商 Linksys 被要求根据其某一组件的许可条款发布软件的源代码,很快,像 OpenWRT 这样的专门针对路由器的 Linux 发行版就出现了。(这些名称中的 “WRT” 来自 Linksys 的型号编号。)
除了爱好者的角度外,安装这些发行版在路由器上也有充分的理由。它们通常比厂商固件更稳定,特别是在老旧的路由器硬件上,并且通常提供更多功能。例如,要通过无线连接桥接网络,许多厂商要求你购买匹配的硬件,但如果安装了 OpenWRT,硬件的品牌和年代并不重要。这是因为你在路由器上使用的是一个真正开放的操作系统,它并不关心你使用什么硬件,只要你的硬件被支持。
你可以使用本书中的大部分知识来检查自定义 Linux 固件的内部结构,尽管你会遇到一些差异,尤其是在登录时。与许多嵌入式系统一样,开源固件通常使用 BusyBox 来提供许多 shell 功能。BusyBox 是一个单一的可执行程序,提供许多 Unix 命令的有限功能,例如 shell、ls、grep、cat 和 more。 (这节省了大量的内存。)此外,嵌入式系统上的启动时 init 通常非常简单。然而,你通常不会觉得这些限制有问题,因为自定义 Linux 固件通常包括一个 Web 管理界面,类似于制造商所提供的界面。
9.25 防火墙
路由器应始终包含某种类型的防火墙,以防止不需要的流量进入你的网络。防火墙是一个软件和/或硬件配置,通常位于路由器上,位于互联网和较小网络之间,试图确保互联网中的任何“坏东西”不会伤害到较小的网络。你也可以在任何主机上设置防火墙功能,筛选所有进出数据包的数据(与在应用层进行的访问控制不同,服务器程序通常会尝试在应用层执行某些访问控制)。在单独的机器上进行防火墙过滤有时被称为IP 过滤。
系统可以在接收到数据包、发送数据包或将数据包转发(路由)到另一个主机或网关时进行数据包过滤。
如果没有防火墙,系统只会处理数据包并将它们发送出去。防火墙在刚才提到的数据传输点上设置了数据包的检查点。检查点会丢弃、拒绝或接受数据包,通常根据以下一些标准:
-
源 IP 地址或目标 IP 地址或子网
-
源端口或目标端口(在传输层信息中)
-
防火墙的网络接口
防火墙提供了一个机会,让你可以与处理 IP 数据包的 Linux 内核子系统进行交互。我们现在来看看这个。
9.25.1 Linux 防火墙基础
在 Linux 中,你可以通过一系列称为链的规则来创建防火墙规则。一组链组成一个表。当数据包在 Linux 网络子系统的各个部分中传输时,内核会根据规则将数据包应用到某些链。例如,一个新的数据包从物理层到达时,内核将其分类为“输入”,因此它会激活对应输入链中的规则。
所有这些数据结构都是由内核维护的。整个系统被称为iptables,并通过一个 iptables 用户空间命令来创建和操作规则。
由于可能有多个表—每个表有自己的一组链,而这些链又可能包含多个规则—数据包流动可能变得相当复杂。然而,你通常主要会处理一个名为filter的单一表,它控制基本的数据包流动。过滤表中有三个基本链:用于入站数据包的 INPUT,出站数据包的 OUTPUT,以及路由数据包的 FORWARD。
图 9-6 和 9-7 显示了简化的流程图,展示了在过滤表中应用规则的位置。之所以有两张图,是因为数据包可以通过网络接口进入系统(图 9-6),或者由本地进程生成(图 9-7)。

图 9-6:来自网络的入站数据包的链处理顺序

图 9-7:来自本地进程的入站数据包的链处理顺序
正如你所看到的,来自网络的入站数据包可以被用户进程消耗,并且可能不会到达 FORWARD 链或 OUTPUT 链。用户进程生成的数据包不会到达 INPUT 或 FORWARD 链。
这变得更复杂,因为除了这三条链外,数据包还会经过许多其他步骤。例如,数据包会受到 PREROUTING 和 POSTROUTING 链的影响,链处理也可以在任何一个三层网络层进行。要查看所有发生的步骤的大图,可以在网上搜索 “Linux netfilter packet flow”,但请记住,这些图试图涵盖数据包输入和流动的每种可能场景。通常将图按数据包来源分解,如图 9-6 和 9-7 中所示,会更有帮助。
9.25.2 设置防火墙规则
让我们看看 iptables 系统如何在实践中工作。首先使用此命令查看当前配置:
# iptables -L
输出通常是一个空的链集,如下所示:
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
每个防火墙链都有一个默认的策略,该策略指定如果没有规则匹配数据包时应该如何处理数据包。此示例中所有三个链的策略都是 ACCEPT,这意味着内核允许数据包通过数据包过滤系统。DROP 策略告诉内核丢弃数据包。要在链上设置策略,可以像这样使用 iptables -P:
# iptables -P FORWARD DROP
假设 192.168.34.63 上的某人让你感到烦恼。为了防止他们与你的机器通信,可以运行此命令:
# iptables -A INPUT -s 192.168.34.63 -j DROP
-A INPUT 参数将规则追加到 INPUT 链中。-s 192.168.34.63 部分指定规则中的源 IP 地址,而 -j DROP 告诉内核丢弃任何匹配该规则的数据包。因此,你的机器将丢弃来自 192.168.34.63 的任何数据包\。
要查看已应用的规则,请再次运行 iptables -L:
Chain INPUT (policy ACCEPT)
target prot opt source destination
DROP all -- 192.168.34.63 anywhere
不幸的是,192.168.34.63 上的你的朋友已经通知他子网中的每个人打开连接到你的 SMTP 端口(TCP 端口 25)。为了摆脱这些流量,请运行:
# iptables -A INPUT -s 192.168.34.0/24 -p tcp --destination-port 25 -j DROP
这个示例向源地址添加了一个子网掩码限定符,并且使用-p tcp来指定仅允许 TCP 数据包。进一步的限制--destination-port 25表示规则应仅适用于端口 25 的流量。现在,INPUT 的 IP 表列表如下所示:
Chain INPUT (policy ACCEPT)
target prot opt source destination
DROP all -- 192.168.34.63 anywhere
DROP tcp -- 192.168.34.0/24 anywhere tcp dpt:smtp
一切都好,直到你听到来自 192.168.34.37 的某个熟人的消息,她说她无法给你发邮件,因为她的机器被你阻止了。认为这是一个快速修复,你运行了这个命令:
# iptables -A INPUT -s 192.168.34.37 -j ACCEPT
但是,它不起作用。要查看原因,请查看新的链:
Chain INPUT (policy ACCEPT)
target prot opt source destination
DROP all -- 192.168.34.63 anywhere
DROP tcp -- 192.168.34.0/24 anywhere tcp dpt:smtp
ACCEPT all -- 192.168.34.37 anywhere
内核从上到下读取链,使用第一个匹配的规则。
第一条规则不匹配 192.168.34.37,但第二条规则匹配,因为它适用于从 192.168.34.1 到 192.168.34.254 的所有主机,并且第二条规则指示丢弃数据包。当规则匹配时,内核执行该动作并不再向下查看链中的其他规则。(你可能会注意到,192.168.34.37 可以向你机器的任何端口发送数据包,除了端口 25,因为第二条规则仅适用于端口 25。)
解决方案是将第三条规则移到顶部。首先,用这个命令删除第三条规则:
# iptables -D INPUT 3
然后用iptables -I将该规则插入链的顶部:
# iptables -I INPUT -s 192.168.34.37 -j ACCEPT
要在链中其他位置插入规则,请将规则编号放在链名之后(例如,iptables -I INPUT 4 ...)。
9.25.3 防火墙策略
尽管前面的教程展示了如何插入规则以及内核如何处理 IP 链,但你还没有看到实际有效的防火墙策略。现在让我们来谈谈这个。
防火墙策略有两种基本情况:一种是保护单个机器(你在每台机器的 INPUT 链中设置规则),另一种是保护一组机器的网络(你在路由器的 FORWARD 链中设置规则)。在这两种情况下,如果你使用默认的 ACCEPT 策略,并持续插入规则以丢弃来自开始发送恶意内容的源的数据包,你就无法实现严密的安全。你必须只允许你信任的数据包,通过其他的数据包则一律拒绝。
例如,假设你的机器上有一个 SSH 服务器,使用 TCP 端口 22。没有任何原因让随机主机尝试连接你机器的任何其他端口,而且你不应当给任何这样的主机机会。要设置此功能,首先将 INPUT 链策略设置为 DROP:
# iptables -P INPUT DROP
要启用 ICMP 流量(用于ping和其他工具),请使用这一行:
# iptables -A INPUT -p icmp -j ACCEPT
确保你能够接收发送到自己网络 IP 地址和 127.0.0.1(localhost)的数据包。假设你的主机 IP 地址是my_addr,请执行以下操作:
# iptables -A INPUT -s 127.0.0.1 -j ACCEPT
# iptables -A INPUT -s `my_addr` **-j ACCEPT**
如果你控制整个子网(并信任子网上的所有内容),你可以用你的子网地址和子网掩码替换my_addr,例如,10.23.2.0/24。
现在,虽然你仍然希望拒绝传入的 TCP 连接,但你仍然需要确保你的主机能够与外界建立 TCP 连接。因为所有的 TCP 连接都以 SYN(连接请求)数据包开始,所以如果你允许所有不是 SYN 数据包的 TCP 数据包通过,仍然是可以的:
# iptables -A INPUT -p tcp '!' --syn -j ACCEPT
! 符号表示否定,因此 ! --syn 匹配任何非 SYN 包。
接下来,如果你使用的是基于远程 UDP 的 DNS,你必须允许来自你的域名服务器的流量,这样你的机器才能通过 DNS 查找名称。对/etc/resolv.conf 中的 所有 DNS 服务器执行此操作。使用以下命令(其中域名服务器的地址为 ns_addr):
# iptables -A INPUT -p udp --source-port 53 -s `ns_addr` **-j ACCEPT**
最后,允许来自任何地方的 SSH 连接:
# iptables -A INPUT -p tcp --destination-port 22 -j ACCEPT
前面的 iptables 设置适用于许多情况,包括任何直接连接(尤其是宽带连接),在这种情况下,入侵者更有可能对你的机器进行端口扫描。你还可以通过使用 FORWARD 链代替 INPUT,并在适当的地方使用源和目标子网,将这些设置调整为适用于防火墙路由器的情况。对于更高级的配置,你可能会发现像 Shorewall 这样的配置工具非常有用。
本讨论仅涉及了安全策略。记住,关键思想是仅允许你认为可以接受的东西,而不是试图找出并排除坏东西。此外,IP 防火墙仅是安全防护的一部分。(你将在下一章看到更多内容。)
9.26 以太网、IP、ARP 和 NDP
在以太网上实现 IP 传输中有一个基本的细节我们还没有涉及。回想一下,主机必须将一个 IP 数据包放入以太网帧中,以便将数据包通过物理层传输到另一台主机。还记得,帧本身不包括 IP 地址信息;它们使用的是 MAC(硬件)地址。问题是:在构建 IP 数据包的以太网帧时,主机如何知道哪个 MAC 地址对应目标 IP 地址?
我们通常不会过多考虑这个问题,因为网络软件包含了一个自动查找 MAC 地址的系统。在 IPv4 中,这叫做 地址解析协议(ARP)。使用以太网作为物理层、IP 作为网络层的主机会维护一个叫做 ARP 缓存 的小表,它将 IP 地址映射到 MAC 地址。在 Linux 中,ARP 缓存位于内核中。要查看你机器的 ARP 缓存,可以使用 ip neigh 命令。(“neigh”部分等你看到 IPv6 对应命令时会更明白。旧的命令是 arp。)
$ **ip -4 neigh**
10.1.2.57 dev enp0s31f6 lladdr 1c:f2:9a:1e:88:fb REACHABLE
10.1.2.141 dev enp0s31f6 lladdr 00:11:32:0d:ca:82 STALE
10.1.2.1 dev enp0s31f6 lladdr 24:05:88:00:ca:a5 REACHABLE
我们使用 -4 选项将输出限制为 IPv4。你可以看到内核知道的主机的 IP 和硬件地址。最后一列表示缓存条目的状态。REACHABLE 表示最近与主机发生了通信,而 STALE 表示已经有一段时间没有通信,条目应该被刷新。
当一台机器启动时,它的 ARP 缓存是空的。那么这些 MAC 地址是如何进入缓存的呢?这一切始于机器想要发送数据包到另一台主机时。如果目标 IP 地址不在 ARP 缓存中,将会发生以下步骤:
-
原始主机创建一个特殊的以太网帧,其中包含一个 ARP 请求包,用于请求与目标 IP 地址对应的 MAC 地址。
-
源主机将此帧广播到目标子网的整个物理网络。
-
如果子网中的其他主机知道正确的 MAC 地址,它会创建一个包含该地址的回复包和帧,并将其发送回源主机。通常,回复的主机 就是 目标主机,并仅仅是回复自己的 MAC 地址。
-
源主机将 IP-MAC 地址对添加到 ARP 缓存中,并可以继续操作。
与 ARP 相关的唯一实际问题是,如果你将 IP 地址从一个网卡转移到另一个网卡(因为网卡有不同的 MAC 地址,例如,在测试机器时),系统的缓存可能会过时。Unix 系统会在一段时间后使 ARP 缓存条目失效,因此除了因无效数据而导致的短暂延迟外,不应该有其他问题,但你可以通过以下命令立即删除 ARP 缓存条目:
# ip neigh del `host` **dev** `interface`
ip-neighbour(8) 手册页解释了如何手动设置 ARP 缓存条目,但你不需要这样做。请注意拼写。
9.27 无线以太网
原则上,无线以太网(“Wi-Fi”)网络与有线网络没有太大区别。就像任何有线硬件一样,它们有 MAC 地址,并使用以太网帧来传输和接收数据,因此 Linux 内核可以像与有线网络接口一样与无线网络接口进行通信。网络层及以上的内容是相同的;主要的区别在于物理层的额外组件,比如频率、网络 ID 和安全特性。
与有线网络硬件不同,有线网络硬件非常擅长在物理设置中的细微差别上自动调整而无需过多干扰,无线网络配置则更为开放。为了使无线接口正常工作,Linux 需要额外的配置工具。
让我们快速看一下无线网络的附加组件。
-
传输细节 这些是物理特征,例如无线频率。
-
网络识别 由于多个无线网络可以共享同一个基本介质,因此你必须能够区分它们。服务集标识符(SSID,也称为“网络名称”)是无线网络标识符。
-
管理 尽管可以配置无线网络使主机直接相互通信,但大多数无线网络是由一个或多个 接入点 管理的,所有流量都会经过这些接入点。接入点通常将无线网络与有线网络桥接,使得两者看起来像是一个单一的网络。
-
身份验证 你可能希望限制无线网络的访问。为此,你可以配置接入点,要求在与客户端通信之前输入密码或其他身份验证密钥。
-
加密 除了限制无线网络的初始访问外,通常还需要加密所有通过无线电波传输的流量。
处理这些组件的 Linux 配置和工具分布在多个领域。有些位于内核中;Linux 具有一套无线扩展,标准化了用户空间对硬件的访问。就用户空间而言,无线配置可能变得复杂,因此大多数人更喜欢使用图形用户界面(GUI)前端,例如 NetworkManager 的桌面小程序,以便使其正常工作。尽管如此,了解一些幕后发生的事情还是值得的。
9.27.1 iw
你可以使用一个名为iw的工具查看和更改内核空间的设备和网络配置。使用iw时,你通常需要知道设备的网络接口名称,如wlp1s0(可预测设备名称)或wlan0(传统名称)。这里有一个示例,它展示了可用无线网络的扫描结果。(如果你处于城市区域,预计会有大量输出。)
# iw dev wlp1s0 scan
如果网络接口已连接到无线网络,你可以像这样查看网络详情:
# iw dev wlp1s0 link
该命令输出中的 MAC 地址来自你当前正在连接的接入点。
使用iw将网络接口连接到一个无加密的无线网络,如下所示:
# iw wlp1s0 connect `network_name`
连接到安全网络是另一回事。对于相当不安全的有线等效隐私(WEP)系统,你可以在iw connect命令中使用keys参数。然而,你不应该使用 WEP,因为它不安全,而且你不会找到许多支持它的网络。
9.27.2 无线安全
对于大多数无线安全设置,Linux 依赖 wpa_supplicant 守护进程来管理无线网络接口的认证和加密。这个守护进程可以处理 WPA2 和 WPA3(WiFi 受保护访问;不要使用较旧的、不安全的 WPA)认证方案,以及几乎所有无线网络上使用的加密技术。当守护进程首次启动时,它会读取一个配置文件(默认情况下是/etc/wpa_supplicant.conf),并尝试识别自己并与接入点建立通信,基于给定的网络名称。该系统有很好的文档支持;特别是,wpa_supplicant(8)手册页非常详细。
每次想要建立连接时手动运行守护进程是很繁琐的。实际上,仅仅创建配置文件就很麻烦,因为有许多可选项。更糟糕的是,运行iw和 wpa_supplicant 的所有工作仅仅是让你的系统加入一个无线物理网络;它甚至没有设置网络层。正因如此,像 NetworkManager 这样的自动网络配置管理器在整个过程中大大减轻了工作负担。虽然它们不会自己做任何工作,但它们知道每一步所需的正确顺序和配置,以便让无线网络正常运行。
9.28 总结
正如你所看到的,理解各个网络层的位置和作用对于理解 Linux 网络如何工作以及如何执行网络配置至关重要。虽然我们只讲解了基础内容,但物理层、网络层和传输层的更高级主题与这里所见类似。各层本身也通常会被细分,就像你刚才看到的无线网络中物理层的各个部分。
本章中你所看到的大量操作发生在内核中,辅以一些基本的用户空间控制工具来操作内核的内部数据结构(例如路由表)。这就是传统的网络工作方式。然而,正如本书讨论的许多主题一样,由于复杂性和对灵活性的需求,一些任务并不适合在内核中完成,这时用户空间工具就会接管。例如,NetworkManager 会监视和查询内核,然后操作内核配置。另一个例子是对动态路由协议(如边界网关协议 BGP)的支持,BGP 广泛应用于大型互联网路由器中。
但你现在可能有些厌倦了网络配置。让我们转向使用网络——应用层。
第十章:网络应用与服务

本章探讨了基础的网络应用——在用户空间中运行的客户端和服务器,它们位于应用层。因为这一层位于堆栈的顶部,靠近终端用户,你可能会发现这部分内容比第九章的内容更容易理解。实际上,你每天都会与网络客户端应用程序(如 Web 浏览器)进行交互。
为了完成工作,网络客户端会连接到相应的网络服务器。Unix 网络服务器有多种形式。服务器程序可以单独监听某个端口,或者通过一个二级服务器进行监听。我们将了解一些常见的服务器以及帮助你理解和调试服务器操作的工具。
网络客户端使用操作系统的传输层协议和接口,因此理解 TCP 和 UDP 传输层的基础非常重要。让我们通过实验使用一个利用 TCP 的网络客户端来开始了解网络应用。
10.1 服务基础
TCP 服务是最容易理解的,因为它们建立在简单、连续的双向数据流之上。也许最好的方法是直接与 TCP 端口 80 上的未加密 Web 服务器进行交互,了解数据是如何通过连接传输的。例如,运行以下命令连接到 IANA 文档示例 Web 服务器:
$ **telnet example.org 80**
你应该得到像这样的响应,表示成功连接到服务器:
Trying `some address`...
Connected to example.org.
Escape character is '^]'.
现在输入这两行:
**GET / HTTP/1.1**
**Host: example.org**
在最后一行后按两次回车。服务器应该会发送一大段 HTML 文本作为响应。要终止连接,请按 ctrl-D。
本练习演示了:
-
远程主机上有一个 Web 服务器进程监听 TCP 端口 80\。
-
telnet是发起连接的客户端。
你必须通过 ctrl-D 来终止连接的原因是,由于大多数网页需要多次请求才能加载,保持连接开启是有意义的。如果你从协议层面探索 Web 服务器,你可能会发现这种行为有所不同。例如,如果服务器在连接打开后不久没有接收到请求,许多服务器会迅速断开连接。
10.2 更深入的了解
在前面的示例中,你通过telnet手动与网络上的 Web 服务器进行了交互,使用了 HTTP 应用层协议。虽然通常你会使用 Web 浏览器来建立这种连接,但让我们从telnet稍微向上迈一步,使用一个了解如何与 HTTP 应用层进行通信的命令行程序。我们将使用带有特殊选项的curl工具来记录其通信细节:
$ **curl --trace-ascii** `trace_file` **http://www.example.org/**
你将获得大量的 HTML 输出。忽略它(或者将其重定向到/dev/null),而是查看新创建的文件trace_file。如果连接成功,文件的第一部分应该类似于以下内容,即curl尝试建立与服务器的 TCP 连接时:
== Info: Trying 93.184.216.34...
== Info: TCP_NODELAY set
== Info: Connected to www.example.org (93.184.216.34) port 80 (#0)
到目前为止,您看到的一切都发生在传输层或以下。然而,如果这个连接成功,curl接着会尝试发送请求(即“头部”);这就是应用层开始的地方:
1 => Send header, 79 bytes (0x4f)
2 0000: **GET / HTTP/1.1**
0010: **Host: www.example.org**
0027: **User-Agent: curl/7.58.0**
0040: **Accept: */***
004d:
第 1 行是curl的调试输出,告诉你接下来将做什么。其余行显示了curl发送给服务器的内容。粗体文本是发送给服务器的内容;开头的十六进制数字只是调试偏移量,curl加上这些偏移量以帮助你跟踪发送或接收的数据量。
在第 2 行,你可以看到curl开始向服务器发出GET命令(就像你使用telnet做的那样),接着是一些额外的信息和一个空行。接下来,服务器发送回复,首先是它自己的头部,显示在这里的粗体中:
<= Recv header, 17 bytes (0x11)
0000: **HTTP/1.1 200 OK**
<= Recv header, 22 bytes (0x16)
0000: **Accept-Ranges: bytes**
<= Recv header, 12 bytes (0xc)
0000: **Age: 17629**
--`snip`--
与之前的输出类似,<=行是调试输出,0000:位于输出行前,告诉你偏移量(在curl中,头部不会计入偏移量;这就是为什么所有这些行都以 0 开头)。
服务器回复中的头部可能相当长,但在某个时候,服务器会从传输头部切换到发送实际请求的文档,如下所示:
<= Recv header, 22 bytes (0x16)
0000: **Content-Length: 1256**
<= Recv header, 2 bytes (0x2)
1 0000:
<= Recv data, 1256 bytes (0x4e8)
0000: **<!doctype html>.<html>.<head>. <title>Example Domain</title>.**
0040: **. <meta charset="utf-8" />. <meta http-equiv="Content-type**
--`snip`--
该输出还展示了应用层的一个重要特性。即使调试输出中显示了Recv header和Recv data,暗示这些是服务器发送的两种不同类型的消息,但curl与操作系统交互以检索这两条消息的方式、操作系统如何处理它们、或网络如何处理这些包都没有区别。区别完全在于用户空间的curl应用程序。curl知道,在此之前它一直在获取头部,但当它接收到一个空行 1 时,这个空行表示 HTTP 头部的结束,它知道接下来要将其余部分视为请求的文档。
发送这些数据的服务器也是如此。当服务器发送回复时,服务器的操作系统没有区分头部和文档数据;这些区分发生在用户空间的服务器程序内部。
10.3 网络服务器
大多数网络服务器就像你系统上的其他服务器守护进程(例如 cron),不同之处在于它们与网络端口进行交互。实际上,syslogd(第七章讨论)在启动时带有-r选项,会在 514 端口接收 UDP 数据包。
以下是一些您可能在系统上运行的其他常见网络服务器:
-
httpd, apache, apache2, nginx 网页服务器
-
sshd 安全外壳守护进程
-
postfix, qmail, sendmail 邮件服务器
-
cupsd 打印服务器
-
nfsd, mountd 网络文件系统(文件共享)守护进程
-
smbd, nmbd Windows 文件共享守护进程(见第十二章)
-
rpcbind 远程过程调用(RPC)端口映射服务守护进程
大多数网络服务器的一个共同特点是,它们通常作为多个进程运行。至少有一个进程监听网络端口,接收到新的连接后,监听进程使用fork()创建一个子进程,然后由该子进程负责处理新连接。这个子进程,通常称为工作进程,在连接关闭时终止。与此同时,原始的监听进程继续监听网络端口。这一过程使得服务器能够轻松处理多个连接而不会出现太多问题。
然而,这一模型也有一些例外。调用fork()会增加大量的系统开销。为了避免这种情况,像 Apache Web 服务器这样的高性能 TCP 服务器可能会在启动时创建多个工作进程,以便根据需要处理连接。接受 UDP 数据包的服务器根本不需要 fork,因为它们不需要监听连接;它们只需接收数据并对其作出反应。
10.3.1 安全外壳协议(SSH)
每个网络服务器程序的工作方式略有不同。为了更好地了解服务器的配置和运行情况,让我们仔细研究一下独立的安全外壳(SSH)服务器。作为最常见的网络服务应用之一,SSH 已成为远程访问 Unix 机器的事实标准。SSH 旨在允许安全的 shell 登录、远程程序执行、简单的文件共享等——用公钥加密来进行身份验证,并用更简单的密码算法处理会话数据,取代了不安全的telnet和rlogin远程访问系统。大多数互联网服务提供商和云服务商要求通过 SSH 进行 shell 访问,而许多基于 Linux 的网络设备(如网络附加存储设备,简称 NAS)也提供通过 SSH 访问的功能。OpenSSH(www.openssh.com/)是一个流行的免费 SSH 实现,适用于 Unix,几乎所有 Linux 发行版都预装了它。OpenSSH 客户端程序是ssh,而服务器是 sshd。SSH 协议有两个主要版本:1 和 2。OpenSSH 只支持版本 2,已经由于漏洞和使用率低而放弃对版本 1 的支持。
在其众多有用的功能和特性中,SSH 执行以下操作:
-
加密你的密码和所有其他会话数据,保护你免受窥探者的侵害。
-
隧道其他网络连接,包括来自 X Window System 客户端的连接。(你将在第十四章中了解更多关于 X 的内容。)
-
提供几乎适用于任何操作系统的客户端。
-
使用密钥进行主机身份验证。
SSH 确实有一些缺点。例如,为了建立 SSH 连接,你需要远程主机的公共密钥,而你不一定能以安全的方式获得它(尽管你可以手动检查它,以确保不会被伪造)。要了解几种加密方法的概述,可以阅读 Jean-Philippe Aumasson 所著的《Serious Cryptography: A Practical Introduction to Modern Encryption》(No Starch Press, 2017)。关于 SSH 的两本深入书籍分别是 Michael W. Lucas 所著的《SSH Mastery: OpenSSH, PuTTY, Tunnels, and Keys》第二版(Tilted Windmill Press, 2018),以及 Daniel J. Barrett、Richard E. Silverman 和 Robert G. Byrnes 所著的《SSH, The Secure Shell: The Definitive Guide》第二版(O’Reilly, 2005)。
10.3.2 sshd 服务器
运行 sshd 服务器以允许远程连接到你的系统需要一个配置文件和主机密钥。大多数发行版将配置保存在 /etc/ssh 配置目录中,并在你安装它们的 sshd 包时会为你配置好一切。(服务器配置文件名 sshd_config 容易与客户端的 ssh_config 设置文件混淆,所以要小心。)
你不需要更改 sshd_config 中的任何内容,但检查一下也无妨。该文件由键值对组成,如这个片段所示。
Port 22
#AddressFamily any
#ListenAddress 0.0.0.0
#ListenAddress ::
#HostKey /etc/ssh/ssh_host_rsa_key
#HostKey /etc/ssh/ssh_host_ecdsa_key
#HostKey /etc/ssh/ssh_host_ed25519_key
以 # 开头的行是注释,许多注释在你的 sshd_config 文件中指示了各种参数的默认值,正如你从这个摘录中看到的那样。sshd_config(5) 手册页包含了参数和可能的值的描述,但以下是其中最重要的:
-
HostKeyfile使用file作为主机密钥。(主机密钥将在下一节中描述。) -
PermitRootLoginvalue如果value设置为yes,允许超级用户通过 SSH 登录。将value设置为no可防止这种情况。 -
LogLevellevel使用 syslog 级别level记录消息(默认值为INFO)。 -
SyslogFacilityname使用 syslog 设施name记录消息(默认值为AUTH)。 -
X11Forwardingvalue如果value设置为yes,则启用 X Window 系统客户端隧道。 -
XAuthLocationpath指定系统上xauth工具的位置。如果没有此路径,X 隧道将无法工作。如果xauth不在 /usr/bin 中,请将path设置为xauth的完整路径。
创建主机密钥
OpenSSH 有多个主机密钥集。每个密钥集都有一个公共密钥(以 .pub 为扩展名)和一个私有密钥(没有扩展名)。
SSH 版本 2 使用 RSA 和 DSA 密钥。RSA 和 DSA 是公钥加密算法。密钥文件名列在 表 10-1 中。
表 10-1:OpenSSH 密钥文件
| 文件名 | 密钥类型 |
|---|---|
| ssh_host_rsa_key | 私有 RSA 密钥 |
| ssh_host_rsa_key.pub | 公共 RSA 密钥 |
| ssh_host_dsa_key | 私有 DSA 密钥 |
| ssh_host_dsa_key.pub | 公共 DSA 密钥 |
创建密钥涉及一个数值计算,生成公钥和私钥。通常你不需要自己创建密钥,因为 OpenSSH 的安装程序或你发行版的安装脚本会为你做这件事,但如果你计划使用像ssh-agent这样无需密码的身份验证服务,你需要知道如何创建密钥。要创建 SSH 协议版本 2 的密钥,请使用 OpenSSH 自带的ssh-keygen程序:
# ssh-keygen -t rsa -N '' -f /etc/ssh/ssh_host_rsa_key
# ssh-keygen -t dsa -N '' -f /etc/ssh/ssh_host_dsa_key
SSH 服务器和客户端还使用一个密钥文件,叫做ssh_known_hosts,用来存储来自其他主机的公钥。如果你打算使用基于远程客户端身份的身份验证,服务器的ssh_known_hosts文件必须包含所有受信任客户端的公钥。了解密钥文件是很有用的,特别是当你需要替换一台机器时。从零安装新机器时,你可以从旧机器导入密钥文件,以确保用户在连接新机器时不会遇到密钥不匹配的问题。
启动 SSH 服务器
虽然大多数发行版都自带 SSH,但它们通常默认不会启动 sshd 服务器。在 Ubuntu 和 Debian 上,新的系统不会安装 SSH 服务器;安装其包会创建密钥,启动服务器,并将服务器启动项加入开机配置。
在 Fedora 上,sshd 默认已安装但处于关闭状态。要在启动时启动 sshd,可以使用systemctl,像这样:
# systemctl enable sshd
如果你想在不重启的情况下立即启动服务器,可以使用:
# systemctl start sshd
Fedora 通常会在第一次启动 sshd 时创建任何缺失的主机密钥文件。
如果你使用的是其他发行版,你可能不需要手动配置 sshd 的启动。然而,你应该知道有两种启动模式:独立模式和按需模式。独立模式服务器更为常见,只需以 root 身份运行 sshd。sshd 服务器进程会将其 PID 写入/var/run/sshd.pid(当然,当由 systemd 运行时,它也会被其 cgroup 跟踪,正如你在第六章看到的)。
作为替代方案,systemd 可以通过套接字单元按需启动 sshd。这通常不是一个好主意,因为服务器偶尔需要生成密钥文件,而这个过程可能会很长。
10.3.3 fail2ban
如果你在机器上设置了 SSH 服务器并将其开放到互联网,你很快就会发现不断的入侵尝试。这些暴力破解攻击如果你的系统配置正确且密码足够强大,是不会成功的。然而,它们会很烦人,消耗 CPU 时间,并且无谓地增加日志的杂乱。
为了防止这种情况,你需要设置一个机制来阻止重复的登录尝试。截至目前,fail2ban 软件包是最流行的解决方案;它只是一个监视日志消息的脚本。当从某个主机在指定时间范围内出现一定数量的失败请求时,fail2ban 使用iptables创建规则来拒绝该主机的流量。经过一段时间(在这段时间内该主机可能已经放弃了连接尝试)后,fail2ban 会删除该规则。
大多数 Linux 发行版提供了带有预配置默认设置的 fail2ban 包,适用于 SSH。
10.3.4 SSH 客户端
要登录到远程主机,运行:
$ **ssh** `remote_username`**@**`remote_host`
如果你的本地用户名与remote_host上的用户名相同,可以省略remote_username``@。你还可以像以下示例中那样,通过ssh命令运行管道,将目录dir复制到另一台主机:
$ **tar zcvf -** `dir` **| ssh** `remote_host` **tar zxvf -**
全局的 SSH 客户端配置文件ssh_config应该位于/etc/ssh,与sshd_config文件的路径相同。与服务器配置文件一样,客户端配置文件也包含键值对,但通常不需要修改它们。
使用 SSH 客户端时最常见的问题是,当你本地的ssh_known_hosts或.ssh/known_hosts文件中的 SSH 公钥与远程主机上的密钥不匹配时,会导致类似下面的错误或警告:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that the RSA host key has just been changed.
The fingerprint for the RSA key sent by the remote host is
38:c2:f6:0d:0d:49:d4:05:55:68:54:2a:2f:83:06:11.
Please contact your system administrator.
Add correct host key in /home/`user`/.ssh/known_hosts to get rid of this message.
1 Offending key in /home/`user`/.ssh/known_hosts:12
RSA host key for `host` has changed and you have requested
strict checking.
Host key verification failed.
这通常意味着远程主机的管理员更改了密钥(这在硬件或云服务器升级时经常发生),但如果不确定,还是可以联系管理员确认。无论如何,前面的消息告诉你,错误的密钥位于某个用户的known_hosts文件的第 12 行 1。
如果你不怀疑有恶意行为,只需删除有问题的行或用正确的公钥替换它。
SSH 文件传输客户端
OpenSSH 包含文件传输程序scp和sftp,它们是较旧的、不安全的程序rcp和ftp的替代品。你可以使用scp将文件从远程机器传输到本地机器,或从一台主机传输到另一台主机。它的使用方法类似于cp命令。以下是一些示例。
从远程主机复制文件到当前目录:
$ **scp** `user`**@**`host`**:**`file` **.**
从本地机器复制一个文件到远程主机:
$ **scp** `file``user`**@**`host`**:**`dir`
从一台远程主机复制文件到第二台远程主机:
$ **scp** `user1`**@**`host1`**:**`file``user2`**@**`host2`**:**`dir`
sftp程序的工作方式类似于过时的命令行ftp客户端,使用get和put命令。远程主机必须安装有sftp-server程序,如果远程主机也使用 OpenSSH,你可以预期它已经安装。
非 Unix 平台的 SSH 客户端
所有流行操作系统都有 SSH 客户端。你应该选择哪一个?PuTTY 是一个很好的基础 Windows 客户端,包含一个安全的文件复制程序。macOS 基于 Unix,并包含 OpenSSH。
10.4 系统 d 之前的网络连接服务器:inetd/xinetd
在 systemd 和第 6.3.7 节中提到的 socket 单元广泛使用之前,有一些服务器提供了一种标准的构建网络服务的方式。许多小型网络服务在连接需求上非常相似,因此为每个服务实现独立的服务器可能效率不高。每个服务器都必须单独配置来处理端口监听、访问控制和端口配置。这些操作对于大多数服务来说是以相同的方式执行的;只有当服务器接受连接时,通信才会有所不同。
简化服务器使用的传统方式之一是通过 inetd 守护进程,这是一种旨在标准化网络端口访问和服务器程序与网络端口之间接口的 超级服务器。启动 inetd 后,它会读取其配置文件,然后监听文件中定义的网络端口。当新的网络连接到来时,inetd 会将新启动的进程附加到该连接上。
inetd 的一个新版本叫做 xinetd,提供了更简单的配置和更好的访问控制,但 xinetd 几乎完全被淘汰,取而代之的是 systemd。然而,你可能会在较旧的系统或没有使用 systemd 的系统上看到它。
10.5 诊断工具
让我们看一些有用的诊断工具,这些工具适合深入应用层。有些工具深入到传输和网络层,因为应用层中的一切最终都会映射到这些较低的层级。
如第九章所述,netstat 是一个基本的网络服务调试工具,可以显示许多传输和网络层统计信息。表 10-2 回顾了查看连接的一些有用选项。
表 10-2:netstat 的有用连接报告选项
| 选项 | 描述 |
|---|---|
-t |
打印 TCP 端口信息 |
-u |
打印 UDP 端口信息 |
-l |
打印监听端口 |
-a |
打印所有活动端口 |
-n |
禁用名称查找(加速操作;如果 DNS 不工作时也很有用) |
-4, -6 |
限制输出为 IP 版本 4 或 6 |
10.5.1 lsof
在第八章中,你学习到 lsof 不仅可以追踪打开的文件,还可以列出当前正在使用或监听端口的程序。要查看所有这样的程序,运行:
# lsof -i
当以普通用户身份运行此命令时,它只会显示该用户的进程。当以 root 用户身份运行时,输出应该类似于以下内容,显示多种进程和用户:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
rpcbind 700 root 6u IPv4 10492 0t0 UDP *:sunrpc 1
rpcbind 700 root 8u IPv4 10508 0t0 TCP *:sunrpc (LISTEN)
avahi-dae 872 avahi 13u IPv4 21736375 0t0 UDP *:mdns 2
cupsd 1010 root 9u IPv6 42321174 0t0 TCP ip6-localhost:ipp (LISTEN) 3
ssh 14366 juser 3u IPv4 38995911 0t0 TCP thishost.local:55457-> 4
somehost.example.com:ssh (ESTABLISHED)
chromium- 26534 juser 8r IPv4 42525253 0t0 TCP thishost.local:41551-> 5
anotherhost.example.com:https (ESTABLISHED)
此示例输出显示了服务器和客户端程序的用户和进程 ID,从顶部的旧式 RPC 服务 1,到 avahi 提供的多播 DNS 服务 2,再到一个支持 IPv6 的打印服务 cupsd 3。最后两个条目显示了客户端连接:一个 SSH 连接 4 和一个来自 Chromium 浏览器的安全网页连接 5。由于输出可能非常庞大,通常最好应用一个过滤器(如下一节所讨论)。
lsof程序类似于netstat,它尝试将找到的每个 IP 地址反向解析为主机名,这会减慢输出速度。使用-n选项禁用名称解析:
# lsof -n -i
您也可以指定-P来禁用/etc/services端口名称查找。
按协议和端口过滤
如果您在寻找特定的端口(例如,您知道某个进程正在使用特定的端口,您想知道那个进程是什么),请使用以下命令:
# lsof -i:`port`
完整的语法如下:
# lsof -i`protocol`**@**`host`**:**`port`
protocol、@``host和:``port参数都是可选的,并将相应地过滤lsof输出。与大多数网络实用程序一样,host和port可以是名称或数字。例如,如果您只想查看 TCP 端口 443(HTTPS 端口)上的连接,请使用:
# lsof -iTCP:443
要根据 IP 版本进行过滤,使用-i4(IPv4)或-i6(IPv6)。您可以将此作为单独的选项添加,也可以将该数字与更复杂的过滤器一起添加(例如,-i6TCP:443)。
您可以从/etc/services中指定服务名称(如-iTCP:ssh),而不是使用数字。
按连接状态过滤
一个特别有用的lsof过滤器是连接状态。例如,要仅显示监听 TCP 端口的进程,请输入:
# lsof -iTCP -sTCP:LISTEN
此命令可以为您提供系统上当前运行的网络服务器进程的良好概览。但是,由于 UDP 服务器不进行监听并且没有连接,因此您必须使用-iUDP来查看正在运行的客户端和服务器。通常这不是问题,因为您的系统上可能不会有太多 UDP 服务器。
10.5.2 tcpdump
您的系统通常不会处理未地址指向其任何 MAC 地址的网络流量。如果您需要查看究竟是什么内容穿越您的网络,tcpdump会将您的网络接口卡置于混杂模式,并报告经过的每个数据包。输入tcpdump命令而不带任何参数将产生如下输出,其中包括 ARP 请求和 Web 连接:
# tcpdump
tcpdump: listening on eth0
20:36:25.771304 arp who-has mikado.example.com tell duplex.example.com
20:36:25.774729 arp reply mikado.example.com is-at 0:2:2d:b:ee:4e
20:36:25.774796 duplex.example.com.48455 > mikado.example.com.www: S 3200063165:3200063165(0) win 5840 <mss 1460,sackOK,timestamp 38815804[|tcp]> (DF)
20:36:25.779283 mikado.example.com.www > duplex.example.com.48455: S 3494716463:3494716463(0) ack 3200063166 win 5792 <mss 1460,sackOK,timestamp 4620[|tcp]> (DF)
20:36:25.779409 duplex.example.com.48455 > mikado.example.com.www: . ack 1 win 5840 <nop,nop,timestamp 38815805 4620> (DF)
20:36:25.779787 duplex.example.com.48455 > mikado.example.com.www: P 1:427(426) ack 1 win 5840 <nop,nop,timestamp 38815805 4620> (DF)
20:36:25.784012 mikado.example.com.www > duplex.example.com.48455: . ack 427 win 6432 <nop,nop,timestamp 4620 38815805> (DF)
20:36:25.845645 mikado.example.com.www > duplex.example.com.48455: P 1:773(772) ack 427 win 6432 <nop,nop,timestamp 4626 38815805> (DF)
20:36:25.845732 duplex.example.com.48455 > mikado.example.com.www: . ack 773 win 6948 <nop,nop,timestamp 38815812 4626> (DF)
9 packets received by filter
0 packets dropped by kernel
您可以通过添加过滤器让tcpdump更加具体。您可以基于源和目标主机、网络、以太网地址、网络模型中多个不同层次的协议等进行过滤。tcpdump识别的许多数据包协议中,包括 ARP、RARP、ICMP、TCP、UDP、IP、IPv6、AppleTalk 和 IPX 数据包。例如,要让tcpdump仅输出 TCP 数据包,请运行:
# tcpdump tcp
要查看 Web 数据包和 UDP 数据包,请输入:
# tcpdump udp or port 80 or port 443
关键字or指定左侧或右侧的条件只要满足一个即可通过过滤器。同样,and关键字要求两个条件都为真。
原语
在前面的示例中,tcp、udp和port 80是被称为原语的过滤器基本元素。最重要的原语列在表 10-3 中。
表 10-3:tcpdump 原语
| 原语 | 数据包规格 |
|---|---|
tcp |
TCP 数据包 |
udp |
UDP 数据包 |
ip |
IPv4 数据包 |
ip6 |
IPv6 数据包 |
port port |
向或来自端口 port 的 TCP 和/或 UDP 数据包 |
host host |
向或来自 host 的数据包 |
net network |
向或来自 network 的数据包 |
操作符
前面使用的 or 是一个 操作符。tcpdump 可以使用多个操作符(例如 and 和 !),你还可以在括号中组合操作符。如果你打算使用 tcpdump 进行任何深入工作,一定要阅读 pcap-filter(7) 手册页,特别是描述原始操作符的部分。
10.5.3 netcat
如果你需要比 telnet host``port 命令更多的远程主机连接灵活性,可以使用 netcat(或 nc)。netcat 可以连接到远程的 TCP/UDP 端口,指定本地端口,监听端口,扫描端口,重定向标准输入输出到网络连接等。要通过 netcat 打开 TCP 连接到端口,运行:
$ **netcat host** `port`
netcat 在对方断开连接时终止,这可能会让人困惑,尤其是当你将标准输入重定向到 netcat 时,因为在发送数据后你可能无法立即看到提示符(这与几乎所有其他命令管道不同)。你可以随时通过按 ctrl-C 来结束连接。(如果你希望程序和网络连接根据标准输入流来终止,可以尝试使用 sock 程序。)
要在特定端口上监听,运行:
$ **netcat -l** `port_number`
如果 netcat 成功在端口上监听,它将等待连接,并在建立连接后打印该连接的输出,并将任何标准输入发送到该连接。
这里是一些关于 netcat 的附加说明:
-
默认情况下没有太多调试输出。如果某些操作失败,
netcat会默默失败,但会设置适当的退出代码。如果你需要更多信息,可以加上-v(“详细”)选项。 -
默认情况下,
netcat客户端会尝试使用 IPv4 和 IPv6 连接。然而,在服务器模式下,netcat默认使用 IPv4。要强制指定协议,可以使用-4来指定 IPv4,使用-6来指定 IPv6。 -
-u选项指定使用 UDP 而不是 TCP。
10.5.4 端口扫描
有时你甚至不知道网络中机器提供了哪些服务,或者哪些 IP 地址正在使用。网络映射器(Nmap)程序扫描机器或一组机器上的所有端口,寻找开放的端口,并列出它找到的端口。大多数发行版都有 Nmap 包,或者你可以在www.insecure.org/下载。(有关 Nmap 可以做的所有功能,请参阅 Nmap 手册页和在线资源。)
在列出自己机器上的端口时,通常建议从至少两个点运行 Nmap 扫描:从自己的机器和另一台机器(可能是在你的本地网络之外)。这样做能让你概览防火墙在阻挡哪些内容。
运行 nmap``host 对主机进行通用扫描。例如:
$ **nmap****10.1.2.2**
Starting Nmap 5.21 ( http://nmap.org ) at 2015-09-21 16:51 PST
Nmap scan report for 10.1.2.2
Host is up (0.00027s latency).
Not shown: 993 closed ports
PORT STATE SERVICE
22/tcp open ssh
25/tcp open smtp
80/tcp open http
111/tcp open rpcbind
8800/tcp open unknown
9000/tcp open cslistener
9090/tcp open zeus-admin
Nmap done: 1 IP address (1 host up) scanned in 0.12 seconds
如你所见,许多服务是开启的,其中很多在大多数发行版中默认并未启用。事实上,唯一一个通常默认开启的是端口 111,即 rpcbind 端口。
如果你添加 -6 选项,Nmap 也能够扫描 IPv6 上的端口。这是识别不支持 IPv6 的服务的一个方便方法。
10.6 远程过程调用
上一节中的 rpcbind 服务扫描结果怎么样?RPC 代表远程过程调用(RPC),它是一个位于应用层底部的系统。它旨在帮助程序员更容易地构建客户端/服务器网络应用程序,其中客户端程序调用在远程服务器上执行的函数。每种类型的远程服务器程序都有一个分配的程序编号。
RPC 实现使用 TCP 和 UDP 等传输协议,并且它们需要一个特殊的中介服务来将程序编号映射到 TCP 和 UDP 端口。这个服务被称为 rpcbind,任何想要使用 RPC 服务的机器都必须运行它。
要查看你的计算机有哪些 RPC 服务,请运行:
$ **rpcinfo -p localhost**
RPC 是一种始终不愿消失的协议。网络文件系统(NFS)和网络信息服务(NIS)系统使用 RPC,但它们在独立机器上完全不必要。但是,每当你认为已经消除了对 rpcbind 的所有需求时,总会出现一些新的需求,比如 GNOME 中的文件访问监视器(FAM)支持。
10.7 网络安全
因为 Linux 是一种在 PC 平台上非常流行的 Unix 变种,特别是因为它在 Web 服务器中广泛使用,所以它吸引了许多试图入侵计算机系统的不法分子。第 9.25 节讨论了防火墙,但这并不是安全的全部内容。
网络安全吸引了极端分子——那些真正喜欢入侵系统的人(无论是为了乐趣还是金钱),以及那些想出复杂保护方案并真正喜欢打击试图入侵其系统的人。(这也可以非常有利可图。)幸运的是,你不需要知道太多就能保持系统安全。这里有一些基本的经验法则:
-
尽可能运行最少的服务 入侵者无法攻击系统中不存在的服务。如果你知道某个服务是什么,而你又不使用它,不要因为你可能“以后某个时候”需要它就开启它。
-
尽可能通过防火墙屏蔽 不同的 Unix 系统可能有一些你不知道的内部服务(例如,RPC 端口映射服务器的 TCP 端口 111),而世界上没有其他系统应该知道这些服务。追踪和管理你系统上的服务可能非常困难,因为许多不同类型的程序会监听各种端口。为了防止入侵者发现你系统中的内部服务,请使用有效的防火墙规则,并在路由器上安装防火墙。
-
跟踪你向互联网提供的服务 如果你运行 SSH 服务器、Postfix 或类似服务,请保持软件更新,并获取适当的安全警报。(请参阅第 10.7.2 节,了解一些在线资源。)
-
对于服务器,使用“长期支持”发行版 安全团队通常将工作重点集中在稳定、受支持的发行版上。开发和测试版,如 Debian Unstable 和 Fedora Rawhide,受到的关注较少。
-
不要为系统上不需要账户的任何人创建账户 从本地账户获取超级用户权限比从远程入侵要容易得多。实际上,考虑到大多数系统中可用的大量软件(以及由此产生的漏洞和设计缺陷),一旦获得 shell 提示符,获得超级用户权限相对容易。不要假设你的朋友知道如何保护他们的密码(或者一开始就选择了好的密码)。
-
避免安装可疑的二进制软件包 它们可能包含木马程序。
这就是保护自己的一项实际操作。那么为什么这样做很重要呢?有三种基本的网络攻击可以针对 Linux 机器进行:
-
完全妥协 这意味着获得机器的超级用户访问权限(完全控制)。入侵者可以通过尝试服务攻击(例如缓冲区溢出漏洞)来实现这一点,或者通过接管一个保护不力的用户账户,然后尝试利用一个编写不规范的 setuid 程序。
-
拒绝服务(DoS)攻击 这会阻止机器执行其网络服务,或迫使计算机以其他方式发生故障,而无需使用任何特殊的访问权限。通常,DoS 攻击仅仅是网络请求的洪水,但它也可能是利用服务器程序中的漏洞导致崩溃。这些攻击更难以预防,但响应起来相对容易。
-
恶意软件 Linux 用户通常对恶意软件(如电子邮件蠕虫和病毒)免疫,原因是他们的电子邮件客户端通常不会傻到去运行附加在消息中的程序。但 Linux 恶意软件确实存在。避免从你从未听说过的地方下载和安装可执行软件。
10.7.1 典型漏洞
有两种基本类型的漏洞需要关注:直接攻击和明文密码嗅探。直接攻击尝试在不太隐蔽的情况下接管机器。最常见的一种方式是定位系统中未保护或其他易受攻击的服务。这可以是像管理员账户没有密码这样的简单服务,一些服务默认没有身份验证。入侵者一旦访问到系统中的一个服务,就可以利用它尝试攻破整个系统。过去,一种常见的直接攻击是缓冲区溢出漏洞,程序员不小心没有检查缓冲区数组的边界。这个问题在内核中通过地址空间布局随机化(ASLR)技术和其他地方的保护措施有所缓解。
明文密码嗅探攻击通过捕获以明文形式发送的密码,或者利用从多个数据泄露事件中获取的密码数据库,来实施攻击。一旦攻击者获取了你的密码,游戏就结束了。从那时起,攻击者必然会尝试在本地获取超级用户权限(如前所述,这比进行远程攻击容易得多),尝试利用该机器作为攻击其他主机的中介,或者两者兼而有之。
一些服务由于糟糕的实现和设计,成为了长期的攻击目标。你应该始终禁用以下服务(它们现在都非常过时,且大多数系统默认不启用):
-
ftpd 无论出于什么原因,所有的 FTP 服务器似乎都有漏洞。此外,大多数 FTP 服务器使用明文密码。如果你需要将文件从一台机器转移到另一台机器,考虑使用基于 SSH 的解决方案或 rsync 服务器。
-
telnetd、rlogind、rexecd 这些服务都以明文形式传输远程会话数据(包括密码)。除非你有启用 Kerberos 的版本,否则应避免使用它们。
10.7.2 安全资源
这里有三个不错的安全资源:
-
SANS Institute(
www.sans.org/)提供培训、服务、每周免费的时事通讯(列出当前的主要漏洞)、样本安全政策等。 -
卡内基梅隆大学软件工程研究所的 CERT 部门(
www.cert.org/)是查找最严重问题的好地方。 -
Insecure.org 是黑客和 Nmap 创始人戈登·“费奥多尔”·里昂(
www.insecure.org/)的一个项目,是了解 Nmap 和各种网络漏洞测试工具的好地方。与许多其他网站相比,它在漏洞方面提供了更多开放且具体的信息。
如果你对网络安全感兴趣,你应该了解传输层安全性(TLS)及其前身安全套接字层(SSL)。这些用户空间的网络层通常被添加到网络客户端和服务器中,以通过使用公钥加密和证书支持网络交易。一本好的指南是戴维斯的使用密码学和 PKI 实现 SSL/TLS(Wiley,2011)或让·菲利普·奥马松的严肃的密码学:现代加密的实用入门(No Starch Press,2017)。
10.8 展望未来
如果你对自己动手操作一些复杂的网络服务器感兴趣,一些常见的服务器包括 Apache 或 nginx Web 服务器和 Postfix 电子邮件服务器。尤其是 Web 服务器容易安装,大多数发行版都提供了相关包。如果你的机器位于防火墙或 NAT 启用的路由器后面,你可以随心所欲地进行配置实验,而不必担心安全问题。
在过去的几章中,我们逐渐从内核空间转向用户空间。本章中讨论的只有少数工具,如tcpdump,与内核交互。本章其余部分描述了套接字如何弥合内核的传输层与用户空间应用层之间的差距。这是更高级的内容,特别适合程序员,因此如果你愿意,可以跳到下一章。
10.9 网络套接字
接下来,我们将转变话题,看看进程是如何从网络读取数据和写入数据的。对于已经建立的网络连接,进程读取和写入数据是非常简单的:你只需要一些系统调用,相关内容可以在 recv(2)和 send(2)手册页中找到。从进程的角度来看,或许最重要的是如何在使用这些系统调用时访问网络。在 Unix 系统中,进程使用套接字来标识与网络通信的时间和方式。套接字是进程通过内核访问网络的接口;它们代表了用户空间和内核空间之间的边界。它们通常也用于进程间通信(IPC)。
存在不同类型的套接字,因为进程需要以不同的方式访问网络。例如,TCP 连接由流式套接字(SOCK_STREAM,从程序员的角度来看)表示,UDP 连接由数据报套接字(SOCK_DGRAM)表示。
设置网络套接字可能有些复杂,因为你需要在特定的时刻考虑套接字类型、IP 地址、端口和传输协议。然而,在所有初步细节搞定之后,服务器使用某些标准方法来处理来自网络的传入流量。图 10-1 中的流程图展示了许多服务器如何处理传入流式套接字的连接。

图 10-1:接收和处理传入连接的一种方法
请注意,这种类型的服务器涉及两种套接字:一种用于监听,另一种用于读写。主进程使用监听套接字来查找来自网络的连接。当有新连接到来时,主进程使用accept()系统调用来接受连接,这会创建一个专门用于该连接的读写套接字。接下来,主进程使用fork()创建一个新的子进程来处理该连接。最后,原始套接字仍然作为监听器,继续代表主进程寻找更多的连接。
在进程设置了特定类型的套接字后,它可以以适合该套接字类型的方式与其交互。这就是套接字灵活性的体现:如果你需要更改底层的传输层,你不必重写所有发送和接收数据的部分;你只需要修改初始化代码。
如果你是程序员,并且想学习如何使用套接字接口,Unix Network Programming, Volume 1(第 3 版),作者是 W. Richard Stevens、Bill Fenner 和 Andrew M. Rudoff(Addison-Wesley Professional,2003),是一本经典指南。第 2 卷也涵盖了进程间通信。
10.10 Unix 域套接字
使用网络功能的应用程序不必涉及两个独立的主机。许多应用程序以客户端-服务器或点对点机制构建,在这种机制中,运行在同一台机器上的进程通过进程间通信来协商需要做什么工作以及由谁来做。例如,回想一下,像 systemd 和 NetworkManager 这样的守护进程使用 D-Bus 来监控和响应系统事件。
进程能够通过本地主机(127.0.0.1 或 ::1)使用常规的 IP 网络进行通信,但它们通常使用一种叫做 Unix 域套接字 的特殊类型套接字作为替代。当进程连接到 Unix 域套接字时,它的行为几乎与网络套接字相同:它可以监听并接受套接字上的连接,你甚至可以在不同的套接字类型之间进行选择,使其表现得像 TCP 或 UDP。
开发者喜欢使用 Unix 域套接字进行进程间通信,主要有两个原因。首先,它们允许在文件系统中使用特殊的套接字文件来控制访问,因此任何没有访问套接字文件的进程就无法使用它。而且,由于不与网络交互,这样会更简单,且不容易受到常规网络入侵的影响。例如,你通常会在 /var/run/dbus 找到 D-Bus 的套接字文件:
$ **ls -l /var/run/dbus/system_bus_socket**
srwxrwxrwx 1 root root 0 Nov 9 08:52 /var/run/dbus/system_bus_socket
其次,由于 Linux 内核在处理 Unix 域套接字时不必通过其网络子系统的多个层次,因此性能往往更好。
为 Unix 域套接字编写代码与支持普通网络套接字没有太大区别。由于好处显著,一些网络服务器提供通过网络和 Unix 域套接字进行通信的功能。例如,MySQL 数据库服务器 mysqld 可以接受来自远程主机的客户端连接,但它通常也会在 /var/run/mysqld/mysqld.sock 提供一个 Unix 域套接字。
你可以使用 lsof -U 查看当前系统上使用中的 Unix 域套接字列表:
# lsof -U
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
mysqld 19701 mysql 12u unix 0xe4defcc0 0t0 35201227 /var/run/mysqld/mysqld.sock
chromium- 26534 juser 5u unix 0xeeac9b00 0t0 42445141 socket
tlsmgr 30480 postfix 5u unix 0xc3384240 0t0 17009106 socket
tlsmgr 30480 postfix 6u unix 0xe20161c0 0t0 10965 private/tlsmgr
--`snip`--
列表会相当长,因为许多应用程序广泛使用未命名的套接字,这些套接字在 NAME 输出列中由 socket 表示。
第十一章:Shell 脚本简介

如果你能够在 shell 中输入命令,你就能编写 shell 脚本。Shell 脚本(也称为 Bourne shell 脚本)是一系列写入文件中的命令;shell 会从文件中读取这些命令,就像你在终端中输入一样。
11.1 Shell 脚本基础
Bourne shell 脚本通常以以下行开始,指示 /bin/sh 程序应执行脚本文件中的命令。(确保脚本文件开头没有空白字符。)
#!/bin/sh
#! 部分被称为 shebang;你将在本书的其他脚本中看到它。你可以在#!/bin/sh行后列出任何你希望 shell 执行的命令。例如:
#!/bin/sh
#
# Print something, then run ls
echo About to run the ls command.
ls
和 Unix 系统上的任何程序一样,你需要为 shell 脚本文件设置可执行位,但你还必须设置读取位,以便 shell 能够读取该文件。做到这一点的最简单方法如下:
$ **chmod +rx** `script`
这个chmod命令允许其他用户读取并执行script。如果你不希望这样,可以使用绝对模式700来代替(更多权限内容请参见第 2.17 节)。
创建 shell 脚本并设置读取和执行权限后,你可以将脚本文件放置在命令路径中的一个目录里,然后在命令行上运行脚本名称来执行它。如果脚本位于当前工作目录中,你也可以运行./``script,或者使用完整路径名来运行。
使用 shebang 运行脚本几乎(但不完全)等同于在你的 shell 中运行一个命令;例如,运行一个名为myscript的脚本会导致内核运行/bin/sh myscript。
基本内容讲解完毕后,让我们来看一看 shell 脚本的一些限制。
11.1.1 Shell 脚本的限制
Bourne shell 轻松地操作命令和文件。在第 2.14 节中,你已经看到 shell 如何重定向输出,这是 shell 脚本编程的一个重要元素。然而,shell 脚本只是 Unix 编程的一个工具,尽管脚本拥有相当强大的功能,但它们也有局限性。
Shell 脚本的一个主要优点是,它们可以简化和自动化你在 shell 提示符下执行的任务,例如批量操作文件。但如果你想分解字符串、进行重复的算术计算、访问复杂的数据库,或者需要函数和复杂的控制结构,那么最好使用像 Python、Perl 或 awk 这样的脚本语言,或者甚至是像 C 这样的编译语言。(这是很重要的,所以你会在整章中看到它。)
最后,请注意你的 shell 脚本大小。保持脚本简短。Bourne shell 脚本并不适合做成很大,尽管你可能会遇到一些庞然大物。
11.2 引号和字面量
在与 Shell 和脚本打交道时,最令人困惑的元素之一就是知道什么时候以及为什么使用引号(引用符)和其他标点符号。假设你想打印字符串 $100,并且你这样做了:
$ **echo $100**
00
为什么它打印了 00?因为 $1 具有 $ 前缀,Shell 将其解释为一个 Shell 变量(我们稍后会讲解这些)。你想,也许如果用双引号将它括起来,Shell 就不会动 $1 了:
$ **echo "$100"**
00
这仍然没有成功。你问朋友,朋友说你应该改用单引号:
$ **echo '$100'**
$100
为什么这个特定的命令能成功?
11.2.1 字面量
当你使用引号时,你通常是试图创建一个字面量,这是一个 Shell 在传递给命令行之前不应分析(或试图改变)的字符串。除了你刚才看到的示例中的 $,当你想要将 * 字符传递给像 grep 这样的命令,而不是让 Shell 展开它时,或者当你需要在命令中使用分号(;)时,这种情况经常会出现。
在编写脚本和使用命令行时,记住当 Shell 运行命令时会发生什么:
-
在运行命令之前,Shell 会查找变量、通配符和其他替换,并在它们出现时执行替换。
-
Shell 将替换结果传递给命令。
涉及字面量的问题可能会比较微妙。假设你正在查找 /etc/passwd 中所有匹配正则表达式 r.*t 的条目(也就是说,包含 r 并且稍后在行中有 t 的行,这样你就能搜索到像 root、ruth 和 robot 这样的用户名)。你可以运行以下命令:
$ **grep r.*t /etc/passwd**
它大多数时候是有效的,但有时神秘地失败了。为什么?答案可能在你当前的目录中。如果该目录包含名为 r.input 和 r.output 的文件,那么 Shell 会将 r.*t 展开为 r.input r.output 并创建以下命令:
$ **grep r.input r.output /etc/passwd**
避免此类问题的关键是首先识别可能导致问题的字符,然后应用正确类型的引号来保护这些字符。
11.2.2 单引号
创建字面量并让 Shell 保持字符串不变的最简单方法是将整个字符串用单引号(')括起来,如这个示例中使用 grep 和 * 字符:
$ **grep 'r.*t' /etc/passwd**
就 Shell 而言,两个单引号之间的所有字符,包括空格,构成一个单一的参数。因此,以下命令不有效,因为它要求 grep 命令在标准输入中查找字符串 r.*t /etc/passwd(因为 grep 只有一个参数):
$ **grep 'r.*t /etc/passwd'**
当你需要使用字面量时,你应该首先使用单引号,因为这样可以确保 Shell 不会尝试进行任何替换。因此,这是一种相对简洁的语法。然而,有时你需要更多的灵活性,这时你可以转而使用双引号。
11.2.3 双引号
双引号(")的功能与单引号相同,只是 Shell 会展开双引号内的任何变量。你可以通过运行以下命令并将双引号替换为单引号,再次运行来看出区别。
$ **echo "There is no * in my path: $PATH"**
当你运行命令时,注意到 Shell 会替换 $PATH,但不会替换 *。
11.2.4 文字单引号
当你传递一个文字单引号给命令时,使用 Bourne shell 可能会很棘手。实现这一点的一种方法是,在单引号字符前加上反斜杠:
$ **echo I don\'t like contractions inside shell scripts.**
反斜杠和引号 必须 出现在任何一对单引号外。像 'don\'t 这样的字符串会导致语法错误。奇怪的是,你可以将单引号放入双引号中,如以下示例所示(输出与前面的命令相同):
$ **echo "I don't like contractions inside shell scripts."**
如果你陷入困境并且需要一个通用规则来引用整个字符串而不进行替换,请按照此过程操作:
-
将所有出现的
'(单引号)替换为'\''(单引号、反斜杠、单引号、单引号)。 -
将整个字符串放入单引号中。
因此,你可以像下面这样引用一个尴尬的字符串 this isn't a forward slash: \:
$ **echo 'this isn'\''t a forward slash: \'**
11.3 特殊变量
大多数 Shell 脚本理解命令行参数,并与它们运行的命令进行交互。为了让你的脚本不仅仅是简单的命令列表,而成为更灵活的 Shell 脚本程序,你需要了解如何使用特殊的 Bourne shell 变量。这些特殊变量与第 2.8 节中描述的其他 Shell 变量类似,不同之处在于你不能更改某些变量的值。
11.3.1 单个参数:$1,$2,等等
$1、$2 和所有命名为正整数的变量包含脚本参数或参数的值。例如,假设以下脚本的名称是 pshow:
#!/bin/sh
echo First argument: $1
echo Third argument: $3
尝试按照以下方式运行脚本,看看它是如何打印参数的:
$ **./pshow one two three**
First argument: one
Third argument: three
内置的 Shell 命令 shift 可以与参数变量一起使用,移除第一个参数($1)并使其余参数前移,使得 $2 成为 $1,$3 成为 $2,依此类推。例如,假设以下脚本的名称是 shiftex:
#!/bin/sh
echo Argument: $1
shift
echo Argument: $1
shift
echo Argument: $1
按照下面的方式运行它来查看它如何工作:
$ **./shiftex one two three**
Argument: one
Argument: two
Argument: three
如你所见,shiftex 通过打印第一个参数、移动剩余参数并重复该过程,来打印所有三个参数。
11.3.2 参数个数:$#
$# 变量保存传递给脚本的参数数量,在你运行 shift 循环以逐个处理参数时特别重要。当 $# 为 0 时,表示没有参数剩余,因此 $1 为空。(有关循环的描述,请参见第 11.6 节。)
11.3.3 所有参数:$@
$@ 变量表示脚本的所有参数,对于将它们传递给脚本内的命令非常有用。例如,Ghostscript 命令(gs)通常很长且复杂。假设你想要为以 150 dpi 的分辨率光栅化 PostScript 文件并使用标准输出流,同时又希望能够传递其他选项给 gs。你可以编写这样的脚本来允许添加更多命令行选项:
#!/bin/sh
gs -q -dBATCH -dNOPAUSE -dSAFER -sOutputFile=- -sDEVICE=pnmraw $@
#!/bin/sh
gs -q -dBATCH -dNOPAUSE -dSAFER **\**
-sOutputFile=- -sDEVICE=pnmraw $@
11.3.4 脚本名称:$0
$0 变量保存脚本的名称,并且在生成诊断信息时非常有用。例如,假设你的脚本需要报告一个存储在 $BADPARM 变量中的无效参数。你可以使用以下代码打印诊断信息,以便错误消息中出现脚本名称:
echo $0: bad option $BADPARM
所有的诊断错误信息应该输出到标准错误流。如在第 2.14.1 节中所解释,2>&1 将标准错误重定向到标准输出。若要写入标准错误流,可以通过 1>&2 反转这一过程。要为上述示例实现此操作,请使用:
echo $0: bad option $BADPARM 1>&2
11.3.5 进程 ID:$$
$$ 变量保存了 shell 的进程 ID。
11.3.6 退出代码:$?
$? 变量保存了 shell 执行的最后一条命令的退出代码。退出代码是掌握 shell 脚本的关键,接下来将讨论这一点。
11.4 退出代码
当一个 Unix 程序完成时,它会留下一个退出代码,这是一个数值,也称为错误代码或退出值,供启动该程序的父进程使用。当退出代码为零(0)时,通常表示程序没有问题地运行。然而,如果程序出现错误,它通常会以非零数字退出(但并不总是如此,正如你接下来会看到的那样)。
shell 会将最后一条命令的退出代码保存在 $? 特殊变量中,因此你可以在 shell 提示符下查看它:
$ **ls / > /dev/null**
$ **echo $?**
0
$ **ls /asdfasdf > /dev/null**
ls: /asdfasdf: No such file or directory
$ **echo $?**
1
你可以看到成功的命令返回了 0,而失败的命令返回了 1(当然,前提是你的系统中没有名为 /asdfasdf 的目录)。
如果你打算使用一个命令的退出代码,你必须在运行命令后立即使用或存储该代码(因为下一个运行的命令会覆盖前一个代码)。例如,如果你连续运行两次 echo $?,第二条命令的输出总是 0,因为第一次 echo 命令执行成功。
在编写 shell 代码时,你可能会遇到需要因为错误(例如错误的文件名)而使脚本停止的情况。可以在脚本中使用 exit 1 来终止并将退出代码 1 返回给执行脚本的父进程。(如果你的脚本有不同的异常退出条件,你可以使用不同的非零数字。)
请注意,某些程序(如diff和grep)使用非零的退出代码来表示正常情况。例如,如果grep找到匹配的模式,它会返回0,如果没有找到则返回1。对于这些程序,退出代码1并不是错误,因此grep和diff在遇到实际问题时会返回退出代码2。如果你认为某个程序可能使用非零退出代码来表示成功,可以阅读该程序的手册页面。退出代码通常会在 EXIT VALUE 或 DIAGNOSTICS 部分进行说明。
11.5 条件语句
Bourne Shell 有专门用于条件判断的结构,包括if/then/else和case语句。例如,这个包含if条件的简单脚本会检查脚本的第一个参数是否为hi:
#!/bin/sh
if [ $1 = hi ]; then
echo 'The first argument was "hi"'
else
echo -n 'The first argument was not "hi" -- '
echo It was '"'$1'"'
fi
前述脚本中的if、then、else和fi是 Shell 关键字;其他的则是命令。这一点非常重要,因为很容易将条件表达式[ $1 = "hi" ]误认为是特殊的 Shell 语法。实际上,[字符是 Unix 系统中的一个实际程序。所有 Unix 系统都有一个名为[的命令,它用于执行 Shell 脚本中的条件测试。这个程序也被称为test;test和[的手册页面是一样的。(你很快会学到,Shell 并不总是运行[,但现在你可以把它当作一个独立的命令来理解。)
在这里,理解退出代码的重要性正如第 11.4 节所解释的那样。让我们看看前一个脚本实际是如何工作的:
-
Shell 会运行
if关键字后面的命令,并收集该命令的退出代码。 -
如果退出代码是
0,Shell 会执行紧跟在then关键字后的命令,直到遇到else或fi关键字为止。 -
如果退出代码不是
0并且有else语句块,Shell 会运行else关键字后面的命令。 -
条件表达式在
fi处结束。
我们已经确定了if后面的测试是一个命令,那么让我们来看一下分号(;)。它只是 Shell 中用于表示命令结束的常规标记,之所以出现,是因为我们将then关键字放在了同一行。如果没有分号,Shell 会将then作为参数传递给[命令,这通常会导致难以追踪的错误。你可以通过将then关键字放在单独的行上来避免使用分号,如下所示:
if [ $1 = hi ]
then
echo 'The first argument was "hi"'
fi
11.5.1 空参数列表的解决方法
在前面的示例中,条件语句可能存在一个潜在问题,这是一个常被忽视的情景:$1可能为空,因为用户可能没有传递任何参数来运行脚本。如果$1为空,测试就变成了[ = hi ],此时[命令会因错误而中止。你可以通过以下两种常见方式之一,将参数用引号括起来来修复这个问题:
if [ "$1" = hi ]; then
if [ x"$1" = x"hi" ]; then
11.5.2 其他测试命令
除了[,还有许多其他命令可以用于条件测试。这里有一个使用grep的示例:
#!/bin/sh
if grep -q daemon /etc/passwd; then
echo The daemon user is in the passwd file.
else
echo There is a big problem. daemon is not in the passwd file.
fi
11.5.3 elif
还有一个elif关键字,它可以将多个if条件语句连接起来,如下所示:
#!/bin/sh
if [ "$1" = "hi" ]; then
echo 'The first argument was "hi"'
elif [ "$2" = "bye" ]; then
echo 'The second argument was "bye"'
else
echo -n 'The first argument was not "hi" and the second was not "bye"-- '
echo They were '"'$1'"' and '"'$2'"'
fi
请记住,控制流只会通过第一个成功的条件,因此,如果你使用hi bye作为参数运行此脚本,你将只得到hi参数的确认。
11.5.4 逻辑构造
有两种快速的一行条件构造,你可能时不时会看到,使用&&("与")和||("或")语法。&&构造如下工作:
`command1` `&&` `command2`
在这里,shell 运行command1,如果退出代码为0,shell 还会运行command2。
||构造类似;如果||前的命令返回非零退出代码,shell 将运行第二个命令。
&&和||构造通常用于if测试,在这两种情况下,最后运行的命令的退出代码决定了 shell 如何处理条件。在&&构造的情况下,如果第一个命令失败,shell 将使用它的退出代码作为if语句,但如果第一个命令成功,shell 将使用第二个命令的退出代码作为条件。在||构造的情况下,如果第一个命令成功,shell 使用第一个命令的退出代码,否则如果第一个命令失败,则使用第二个命令的退出代码。
例如:
#!/bin/sh
if [ "$1" = hi ] || [ "$1" = bye ]; then
echo 'The first argument was "'$1'"'
fi
如果你的条件语句中包含测试命令([),如这里所示,你可以使用-a和-o来代替&&和||,例如:
#!/bin/sh
if [ "$1" = hi -o "$1" = bye ]; then
echo 'The first argument was "'$1'"'
fi
你可以通过在测试前放置!运算符来反转测试(即逻辑非)。例如:
#!/bin/sh
if [ ! "$1" = hi ]; then
echo 'The first argument was not hi'
fi
在这种特定的比较情况下,你可能会看到!=作为一种替代方法,但!可以与接下来章节中描述的任何条件测试一起使用。
11.5.5 条件测试
你已经了解了[的工作原理:如果测试为真,退出代码为0,如果测试失败,则退出代码为非零。你也知道如何使用[ str1 = str2 ]来测试字符串相等性。然而,请记住,shell 脚本非常适合对整个文件进行操作,因为许多有用的[测试涉及文件属性。例如,以下行检查file是否为常规文件(不是目录或特殊文件):
[ -f `file` ]
在脚本中,你可能会看到-f测试出现在类似于此的循环中,它测试当前工作目录中的所有项目(你将在 11.6 节中学到更多关于循环的内容):
for filename in *; do
if [ -f $filename ]; then
ls -l $filename
file $filename
else
echo $filename is not a regular file.
fi
done
有数十种测试操作,所有操作都可归为三大类:文件测试、字符串测试和算术测试。info 手册包含完整的在线文档,但 test(1)手册页是快速参考。以下部分概述了主要的测试。(我省略了一些不太常见的测试。)
文件测试
大多数文件测试,如-f,被称为一元操作,因为它们只需要一个参数:要测试的文件。例如,以下是两个重要的文件测试:
-
-e如果文件存在,则返回 true -
-s如果文件不为空,则返回 true
一些操作符检查文件类型,这意味着它们可以确定某个文件是否为常规文件、目录或某种特殊设备,如 表 11-1 所列。此外,还有一些单目操作符检查文件的权限,如 表 11-2 所列。(有关权限的概述,请参见第 2.17 节。)
表 11-1:文件类型操作符
| 操作符 | 测试条件 |
|---|---|
-f |
常规文件 |
-d |
目录 |
-h |
符号链接 |
-b |
块设备 |
-c |
字符设备 |
-p |
命名管道 |
-S |
套接字 |
表 11-2:文件权限操作符
| 操作符 | 权限 |
|---|---|
-r |
可读 |
-w |
可写 |
-x |
可执行 |
-u |
设置 UID |
-g |
设置 GID |
-k |
“粘滞” |
最后,三个二进制操作符(需要两个文件作为参数的测试)用于文件测试,但它们并不常见。考虑以下命令,它包括 -nt(“比...新”):
[ `file1` -nt `file2` ]
如果 file1 的修改日期比 file2 新,则此命令返回 true。-ot(“比...旧”)操作符则执行相反的操作。如果你需要检测相同的硬链接,-ef 会比较两个文件,如果它们共享 inode 编号和设备,则返回 true。
字符串测试
你已经见过二进制字符串操作符 =,它在操作数相等时返回 true,和 != 操作符,它在操作数不相等时返回 true。还有两个额外的单目字符串操作:
-
-z如果参数为空则返回 true([ -z "" ]返回0) -
-n如果参数不为空则返回 true([ -n "" ]返回1)
算术测试
请注意,等号(=)用于检查字符串相等,而非数值相等。因此,[ 1 = 1 ] 返回 0(真),但 [ 01 = 1 ] 返回 false。处理数字时,使用 -eq 而非等号:[ 01 -eq 1 ] 返回 true。表 11-3 提供了完整的数值比较操作符列表。
表 11-3:算术比较操作符
| 操作符 | 当第一个参数是 ___________ 第二个时返回 true |
|---|---|
-eq |
等于 |
-ne |
不等于 |
-lt |
小于 |
-gt |
大于 |
-le |
小于或等于 |
-ge |
大于或等于 |
11.5.6 case
case 关键字形成另一种条件构造,非常适用于匹配字符串。它不会执行任何测试命令,因此不会评估退出码。然而,它可以进行模式匹配。这个示例讲述了大部分内容:
#!/bin/sh
case $1 in
bye)
echo Fine, bye.
;;
hi|hello)
echo Nice to see you.
;;
what*)
echo Whatever.
;;
*)
echo 'Huh?'
;;
esac
Shell 执行如下:
-
脚本将
$1与每个用)字符标记的 case 值进行匹配。 -
如果 case 的值匹配
$1,shell 会执行 case 下面的命令,直到遇到;;,此时它会跳到esac关键字。 -
条件语句以
esac结束。
对于每个情况值,你可以匹配单个字符串(比如前面的示例中的 bye)或使用 | 匹配多个字符串(hi|hello 当 $1 等于 hi 或 hello 时返回真),或者你可以使用 * 或 ? 模式(what*)。要创建一个默认情况来匹配除指定的情况值之外的所有值,可以使用一个单独的 *,如前面示例中的最后一个情况所示。
11.6 循环
Bourne shell 中有两种循环:for 循环和 while 循环。
11.6.1 for 循环
for 循环(也叫做“for each”循环)是最常见的。以下是一个示例:
#!/bin/sh
for str in one two three four; do
echo $str
done
在这个列表中,for、in、do 和 done 都是 shell 关键字。Shell 执行以下操作:
-
将变量
str设置为in关键字后面四个以空格分隔的值中的第一个值(one)。 -
在
do和done之间运行echo命令。 -
返回到
for行,将str设置为下一个值(two),执行do和done之间的命令,并重复这个过程,直到它完成in关键字后面的所有值。
这个脚本的输出如下所示:
one
two
three
four
11.6.2 while 循环
Bourne shell 的 while 循环使用退出码,就像 if 条件语句一样。例如,以下脚本执行 10 次迭代:
#!/bin/sh
FILE=/tmp/whiletest.$$;
echo firstline > $FILE
while tail -10 $FILE | grep -q firstline; do
# add lines to $FILE until tail -10 $FILE no longer prints "firstline"
echo -n Number of lines in $FILE:' '
wc -l $FILE | awk '{print $1}'
echo newline >> $FILE
done
rm -f $FILE
在这里,grep -q firstline 的退出码就是测试条件。只要退出码为非零(在本例中,当字符串 firstline 不再出现在 $FILE 的最后 10 行中时),循环就会退出。
你可以使用 break 语句跳出 while 循环。Bourne shell 还有一个 until 循环,作用与 while 类似,不同之处在于,当遇到退出码为零时,它会终止循环,而不是遇到非零退出码。也就是说,你不需要经常使用 while 和 until 循环。事实上,如果你发现需要使用 while,你可能应该使用一个更适合你任务的语言,如 Python 或 awk。
11.7 命令替换
Bourne shell 可以将命令的标准输出重定向回 shell 自身的命令行。也就是说,你可以将命令的输出作为另一个命令的参数,或者通过将命令包裹在 $() 中,将命令输出存储在 shell 变量中。
这个示例将命令的输出存储在 FLAGS 变量中。第二行中的粗体代码展示了命令替换。
#!/bin/sh
FLAGS=**$(grep ^flags /proc/cpuinfo | sed 's/.*://' | head -1)**
echo Your processor supports:
for f in $FLAGS; do
case $f in
fpu) MSG="floating point unit"
;;
3dnow) MSG="3DNOW graphics extensions"
;;
mtrr) MSG="memory type range register"
;;
*) MSG="unknown"
;;
esac
echo $f: $MSG
done
这个示例有些复杂,因为它展示了你可以在命令替换中使用单引号和管道符。grep 命令的结果被传递给 sed 命令(关于 sed 的更多内容请参见第 11.10.3 节),sed 去除所有与表达式 .*: 匹配的内容,sed 的结果再传递给 head。
使用命令替换时很容易过度使用。例如,不要在脚本中使用$(ls),因为使用 shell 来扩展 * 更快。此外,如果你想对多个通过 find 命令获得的文件名执行命令,考虑使用管道将其传递给 xargs,而不是命令替换,或者使用 -exec 选项(这两种方法都将在 11.10.4 节中讨论)。
11.8 临时文件管理
有时需要创建一个临时文件来收集输出,以便后续命令使用。创建这样的文件时,确保文件名足够独特,以免其他程序意外地写入此文件。有时,使用简单的 shell PID($$)作为文件名就能实现,但当你需要确保没有冲突时,像 mktemp 这样的工具通常是更好的选择。
这是使用 mktemp 命令创建临时文件名的方法。这个脚本显示了过去两秒内发生的设备中断:
#!/bin/sh
TMPFILE1=$(mktemp /tmp/im1.XXXXXX)
TMPFILE2=$(mktemp /tmp/im2.XXXXXX)
cat /proc/interrupts > $TMPFILE1
sleep 2
cat /proc/interrupts > $TMPFILE2
diff $TMPFILE1 $TMPFILE2
rm -f $TMPFILE1 $TMPFILE2
mktemp 的参数是一个模板。mktemp 命令将 XXXXXX 转换为一组唯一的字符,并使用该名称创建一个空文件。请注意,这个脚本使用变量名来存储文件名,这样如果你想更改文件名,只需更改一行即可。
使用临时文件的脚本中常见的问题是,如果脚本被中止,临时文件可能会被遗留。在前面的例子中,在第二个 cat 命令之前按下 ctrl-C 会将临时文件留在 /tmp 中。尽可能避免这种情况。相反,使用 trap 命令创建一个信号处理器,捕获 ctrl-C 生成的信号并删除临时文件,如以下处理器所示:
#!/bin/sh
TMPFILE1=$(mktemp /tmp/im1.XXXXXX)
TMPFILE2=$(mktemp /tmp/im2.XXXXXX)
trap "rm -f $TMPFILE1 $TMPFILE2; exit 1" INT
--`snip`--
必须在处理器中使用 exit 来显式结束脚本执行,否则 shell 在运行信号处理器后会继续照常运行。
11.9 Here Documents
假设你想打印一大段文本或将大量文本传递给另一个命令。与其使用多个echo命令,你可以使用 shell 的 here document 特性,如以下脚本所示:
#!/bin/sh
DATE=$(date)
cat **<<EOF**
Date: $DATE
The output above is from the Unix date command.
It's not a very interesting command.
**EOF**
粗体部分控制着 here document。<<EOF 告诉 shell 将随后的所有行重定向到 <<EOF 前面的命令的标准输入中,在这个例子中是 cat。重定向会在 EOF 标记单独出现在一行时停止。该标记实际上可以是任何字符串,但请记住,在 here document 的开始和结束使用相同的标记。另外,惯例是标记应使用全大写字母。
注意 here document 中的 shell 变量 $DATE。shell 会在 here document 中扩展 shell 变量,这在你打印包含多个变量的报告时尤其有用。
11.10 重要的 Shell 脚本工具
在 Shell 脚本中,有几个程序特别有用。某些工具,如basename,只有与其他程序一起使用时才实际有效,因此不常在 Shell 脚本外找到应用。然而,像awk这样的程序也可以在命令行上非常有用。
11.10.1 basename
如果你需要去除文件名的扩展名或去掉完整路径名中的目录部分,请使用basename命令。尝试在命令行中运行这些示例,看看命令是如何工作的:
$ **basename example.html .html**
$ **basename /usr/local/bin/example**
在这两种情况下,basename都会返回example。第一个命令去除了example.html的.html后缀,第二个命令则从完整路径名中去除了目录部分。
这个示例展示了如何在脚本中使用basename将 GIF 图像文件转换为 PNG 格式:
#!/bin/sh
for file in *.gif; do
# exit if there are no files
if [ ! -f $file ]; then
exit
fi
b=$(basename $file .gif)
echo Converting $b.gif to $b.png...
giftopnm $b.gif | pnmtopng > $b.png
done
11.10.2 awk
awk命令不是一个简单的单用途命令;它实际上是一种强大的编程语言。不幸的是,awk的使用如今已经成为一种失传的技艺,被像 Python 这样的大型语言所取代。
关于awk的书籍有很多,包括阿尔弗雷德·V·阿霍(Alfred V. Aho)、布赖恩·W·科尼根(Brian W. Kernighan)和彼得·J·温伯格(Peter J. Weinberger)所著的The AWK Programming Language(1988 年,Addison-Wesley)。不过,很多人仅仅用awk来做一件事——从输入流中提取单个字段,像这样:
$ **ls -l | awk '{print $5}'**
该命令打印ls输出中的第五个字段(文件大小)。结果是一个文件大小的列表。
11.10.3 sed
sed(“流编辑器”)程序是一个自动文本编辑器,它接受一个输入流(一个文件或标准输入),根据某些表达式对其进行修改,并将结果输出到标准输出。在很多方面,sed像是原始的 Unix 文本编辑器ed。它有数十种操作、匹配工具和寻址功能。与awk类似,关于sed的书籍也有很多,包括一本快速参考书,sed & awk Pocket Reference(第二版,Arnold Robbins,O’Reilly,2002 年)。
尽管sed是一个大型程序,且深入分析超出了本书的范围,但我们很容易看出它是如何工作的。一般来说,sed接受一个地址和一个操作作为参数。地址是一组行,命令则决定如何处理这些行。
sed的一个非常常见的任务是用正则表达式替换某些文本(参见第 2.5.1 节),像这样:
$ **sed 's/**`exp`**/**`text`**/'**
如果你想将每行中的第一个冒号替换为%并将结果发送到标准输出,你可以这样做:
$ **sed 's/:/%/' /etc/passwd**
要替换/etc/passwd中的所有冒号,可以在操作末尾添加g(全局)修饰符,像这样:
$ **sed 's**`/:/`**%/g' /etc/passwd**
这是一个按行操作的命令;它读取/etc/passwd,删除第三行到第六行,并将结果输出到标准输出:
$ **sed** **3,6d /etc/passwd**
在这个例子中,3,6 是地址(行号范围),而 d 是操作(删除)。如果省略地址,sed 将作用于其输入流中的所有行。最常用的两个 sed 操作可能是 s(搜索和替换)和 d。
你还可以使用正则表达式作为地址。此命令删除与正则表达式 exp 匹配的任何行:
$ **sed '/**`exp`**/d'**
在所有这些示例中,sed 写入标准输出,这是最常见的用法。如果没有文件参数,sed 会从标准输入读取,这种模式你在 Shell 管道中经常会遇到。
11.10.4 xargs
当你必须对大量文件执行一个命令时,命令或 Shell 可能会提示无法将所有参数放入其缓冲区。使用 xargs 可以解决这个问题,它通过对标准输入流中的每个文件名执行命令来绕过这个限制。
许多人将 xargs 与 find 命令一起使用。例如,以下脚本可以帮助你验证当前目录树中每个以 .gif 结尾的文件是否确实是 GIF 图像:
$ **find . -name '*.gif' -print | xargs file**
在这里,xargs 运行 file 命令。然而,这种调用可能会导致错误或使系统面临安全问题,因为文件名中可能包含空格和换行符。编写脚本时,请使用以下形式,该形式将 find 输出的分隔符和 xargs 参数分隔符从换行符改为 NULL 字符:
$ **find . -name '*.gif' -print0 | xargs -0 file**
xargs 启动了大量进程,因此,如果你有一个很大的文件列表,别指望能得到很好的性能。
如果目标文件名可能以单个短横线(-)开头,你可能需要在 xargs 命令的末尾添加两个短横线(--)。双短横线(--)告诉程序,后续的任何参数都是文件名,而不是选项。然而,请记住,并非所有程序都支持双短横线。
当你使用 find 时,有一个替代方案是 xargs:-exec 选项。然而,语法有些复杂,因为你需要提供大括号 {} 来替代文件名,并且使用字面量的 ; 来表示命令的结束。以下是仅使用 find 完成前述任务的方法:
$ **find . -name '*.gif' -exec file {} \;**
11.10.5 expr
如果你需要在 Shell 脚本中使用算术运算,expr 命令可以提供帮助(甚至能做一些字符串操作)。例如,命令 expr 1 + 2 会输出 3。(运行 expr --help 获取完整的操作列表。)
expr 命令是一个笨拙且缓慢的数学运算方法。如果你发现自己经常使用它,可能应该使用像 Python 这样的语言,而不是 Shell 脚本。
11.10.6 exec
exec 命令是一个内建的 Shell 功能,它将当前的 Shell 进程替换为你在 exec 后指定的程序。它执行了第一章中描述的 exec() 系统调用。这个功能是为了节省系统资源,但请记住,执行 exec 后没有返回;当你在脚本中运行 exec 时,脚本和运行脚本的 Shell 都会被新的命令替代。
要在 Shell 窗口中测试这个,试着运行exec cat。当你按 ctrl-D 或 ctrl-C 终止cat程序后,窗口应该会消失,因为它的子进程已经不存在了。
11.11 子 Shell
假设你需要稍微改变 Shell 中的环境,但不希望进行永久更改。你可以使用 Shell 变量改变并恢复环境的一部分(例如路径或工作目录),但这是一种笨重的方法。更简单的选择是使用子 Shell,一个全新的 Shell 进程,你可以创建它来运行一两个命令。新 Shell 有一个原 Shell 环境的副本,当新 Shell 退出时,你对其环境所做的任何更改都会消失,初始的 Shell 将正常运行。
使用子 Shell 时,将要由子 Shell 执行的命令放在括号中。例如,以下这一行将在uglydir中执行uglyprogram命令,同时保持原 Shell 不变:
$ **(cd uglydir; uglyprogram)**
这个例子展示了如何将一个组件添加到路径中,这可能作为永久更改引发问题:
$ **(PATH=/usr/confusing:$PATH; uglyprogram)**
使用子 Shell 对环境变量进行一次性更改是非常常见的任务,甚至有一个内置语法可以避免使用子 Shell:
$ **PATH=/usr/confusing:$PATH uglyprogram**
管道和后台进程也可以与子 Shell 一起工作。以下示例使用tar来归档orig中的整个目录树,然后将归档文件解压到新目录target中,从而有效地复制orig中的文件和文件夹(这很有用,因为它保留了所有权和权限,并且通常比使用如cp -r等命令更快):
$ **tar cf -** `orig` **| (cd** `target`**; tar xvf -)**
11.12 包含其他文件到脚本中
如果你需要在 Shell 脚本中包含来自另一个文件的代码,使用点(.)操作符。例如,这会执行config.sh文件中的命令:
. config.sh
这种包含方法也称为源文件,它在读取变量(例如,在共享配置文件中)和其他类型的定义时非常有用。这不同于执行另一个脚本;当你运行一个脚本(作为命令)时,它会在一个新的 Shell 中启动,除了输出和退出代码,你无法获取其他任何东西。
11.13 读取用户输入
read命令从标准输入中读取一行文本,并将文本存储在一个变量中。例如,以下命令将输入存储在$var中:
$ **read** `var`
这个内置的 Shell 命令与书中未提到的其他 Shell 特性一起使用时非常有用。通过read,你可以创建简单的交互,例如提示用户输入,而不是要求他们在命令行中列出所有内容,并构建“你确定吗?”的确认操作,作为危险操作的前置步骤。
11.14 何时(不)使用 Shell 脚本
Shell 功能如此丰富,以至于很难将其重要元素浓缩到单一章节。如果你对 shell 还能做什么感兴趣,可以看看一些关于 shell 编程的书籍,比如 Stephen G. Kochan 和 Patrick Wood 合著的《Unix Shell 编程》第三版(SAMS 出版社,2003 年),或 Brian W. Kernighan 和 Rob Pike 合著的《UNIX 编程环境》(Prentice Hall,1984 年)中的 shell 脚本讨论。
然而,在某个时刻(特别是当你开始过度使用read内置命令时),你必须问问自己,是否仍然使用适合该任务的工具。记住,shell 脚本最擅长的事情是:操作简单的文件和命令。正如前面所说,如果你发现自己写的东西看起来复杂,尤其是涉及到复杂的字符串或算术操作时,不要害怕转向像 Python、Perl 或 awk 这样的脚本语言。
第十二章:网络文件传输和共享

本章概述了在网络上分发和共享文件的选项。我们将首先看看除了你已经看到的scp和sftp工具外的其他文件复制方法。然后我们将讨论真正的文件共享,即将一台机器上的目录附加到另一台机器上。
由于有许多方式可以分发和共享文件,以下是一些场景及其对应的解决方案:
| 将 Linux 机器上的文件或目录临时提供给其他机器。 | Python SimpleHTTPServer(第 12.1 节) |
|---|---|
| 在机器之间分发(复制)文件,特别是定期进行。 | rsync(第 12.2 节) |
| 定期将 Linux 机器上的文件共享到 Windows 机器上。 | Samba(第 12.4 节) |
| 在 Linux 机器上挂载 Windows 共享。 | CIFS(第 12.4 节) |
| 在 Linux 机器之间实现小规模共享,设置最小。 | SSHFS(第 12.5 节) |
| 从 NAS 或其他服务器挂载更大的文件系统到你的受信本地网络。 | NFS(第 12.6 节) |
| 将云存储挂载到 Linux 机器上。 | 各种基于 FUSE 的文件系统(第 12.7 节) |
注意,这里没有涉及到多个地点之间、多个用户进行大规模共享的内容。虽然不是不可能,但这种解决方案通常需要大量的工作,并不在本书的范围内。我们将在本章结束时讨论为什么会这样。
与本书中的许多其他章节不同,本章的最后部分并非高级内容。事实上,你可能从中获得最大价值的是那些“理论性”的部分。第 12.3 节和第 12.8 节将帮助你理解为什么这里列出了这么多选项。
12.1 快速复制
假设你想将一个文件(或多个文件)从你的 Linux 机器复制到你个人网络中的另一台机器上,而且你不关心将它复制回来或其他复杂操作——你只希望快速将文件传输过去。可以通过 Python 实现这种方便的方式。只需进入包含文件的目录,并运行:
$ **python -m SimpleHTTPServer**
这会启动一个基本的 Web 服务器,使当前目录可以被网络上的任何浏览器访问。默认情况下,它运行在 8000 端口,因此如果你在地址为 10.1.2.4 的机器上运行此服务,访问目标系统的浏览器时,只需访问http://10.1.2.4:8000,你就能获取所需的文件。
12.2 rsync
当你需要开始复制的不仅仅是一个或两个文件时,可以使用需要目标服务器支持的工具。例如,你可以使用scp -r将整个目录结构复制到另一个地方,前提是远程目标支持 SSH 和 SCP 服务器(Windows 和 macOS 都可以支持)。我们在第十章已经看到过这种方法:
$ **scp -r** `directory``user`**@**`remote_host[`**:**`dest_dir]`
这种方法能够完成任务,但并不非常灵活。特别是,在传输完成后,远程主机可能没有目录的精确副本。如果directory在远程机器上已经存在,并且包含一些多余的文件,那么这些文件会在传输后继续存在。
如果你预计要经常进行这种操作(特别是如果你计划自动化这个过程),你应该使用一个专门的同步系统,它还可以执行分析和验证。在 Linux 上,rsync是标准的同步工具,提供良好的性能和许多有用的传输方式。在本节中,我们将介绍一些rsync的基本操作模式,并探讨一些它的特殊性。
12.2.1 开始使用 rsync
要在两个主机之间使用rsync,你必须在源和目标主机上都安装rsync程序,并且你需要一种从一台机器访问另一台机器的方式。传输文件的最简单方法是使用远程 shell 账户,假设你想通过 SSH 访问来传输文件。然而,请记住,rsync即使在单台机器上复制文件和目录之间的位置时也非常有用,例如从一个文件系统复制到另一个文件系统。
从表面上看,rsync命令与scp没有太大区别。实际上,你可以使用相同的参数运行rsync。例如,要将一组文件复制到host上的主目录,输入:
$ **rsync** `file1``file2` **...** `host`**:**
在任何现代系统上,rsync假设你使用 SSH 连接到远程主机。
小心这个错误信息:
rsync not found
rsync: connection unexpectedly closed (0 bytes read so far)
rsync error: error in rsync protocol data stream (code 12) at io.c(165)
这个提示表示你的远程 shell 无法在系统中找到rsync。如果rsync已安装在远程系统上,但不在该系统用户的命令路径中,请使用--rsync-path=``path手动指定其位置。
如果两个主机上的用户名不同,请在命令参数中的远程主机名之前加上user@,其中user是你在host上的用户名:
$ **rsync** `file1 file2` **...** `user`**@**`host`**:**
除非你提供额外选项,否则rsync仅复制文件。事实上,如果你只指定了到目前为止描述的选项,并且提供了一个目录dir作为参数,你将看到以下消息:
skipping directory `dir`
要传输整个目录结构——包括符号链接、权限、模式和设备——使用-a选项。此外,如果你想复制到远程主机上不是你主目录的目录,可以将其名称放在远程主机名后面,如下所示:
$ **rsync -a** `dir``host`**:**`dest_dir`
复制目录可能会比较棘手,所以如果你不完全确定在传输文件时会发生什么,使用-nv选项组合。-n选项告诉rsync以“干跑”模式运行——也就是说,运行一个试验而不实际复制任何文件。-v选项用于详细模式,它显示有关传输和文件的详细信息:
$ **rsync -nva** `dir``host`**:**`dest_dir`
输出看起来像这样:
building file list ... done
ml/nftrans/nftrans.html
[more files]
wrote 2183 bytes read 24 bytes 401.27 bytes/sec
12.2.2 精确复制目录结构
默认情况下,rsync 复制文件和目录时不会考虑目标目录中的先前内容。例如,如果你将包含文件 a 和 b 的目录 d 传输到已经有文件 d/c 的机器上,那么在 rsync 执行后,目标目录将包含 d/a、d/b 和 d/c。
为了精确复制源目录,你必须删除目标目录中不存在于源目录中的文件,例如此示例中的 d/c。使用 --delete 选项来实现这一点:
$ **rsync -a --delete** `dir``host`**:**`dest_dir`
12.2.3 使用尾部斜杠
在指定目录作为 rsync 命令行中的源时,特别需要小心。考虑一下我们迄今为止一直在使用的基本命令:
$ **rsync -a** `dir``host`**:**`dest_dir`
完成后,你将在 host 上的 dest_dir 内拥有一个名为 dir 的目录。图 12-1 显示了 rsync 如何处理一个包含名为 a 和 b 的文件的目录。

图 12-1:正常的 rsync 复制
然而,在源名称后添加一个斜杠(/)会显著改变其行为:
$ **rsync -a** `dir`**/** `host`**:**`dest_dir`
在这里,rsync 会将 dir 内的所有内容复制到 host 上的 dest_dir,但不会在目标主机上实际创建 dir。因此,你可以将 dir/ 的传输视为类似于在本地文件系统上执行 cp dir/* dest_dir 的操作。
例如,假设你有一个包含文件 a 和 b(dir/a 和 dir/b)的目录 dir。你运行带有尾部斜杠的命令,将它们传输到 host 上的 dest_dir 目录:
$ **rsync -a dir/** `host`**:**`dest_dir`
当传输完成后,dest_dir 会包含 a 和 b 的副本,但不包含 dir。然而,如果你省略了 dir 后面的尾部斜杠,dest_dir 会得到一个名为 dir 的副本,其中包含 a 和 b。然后,作为传输的结果,你将在远程主机上看到名为 dest_dir/dir/a 和 dest_dir/dir/b 的文件和目录。图 12-2 演示了在使用尾部斜杠时,rsync 如何处理来自 图 12-1 的目录结构。
当将文件和目录传输到远程主机时,不小心在路径后加一个 / 通常只是一个小麻烦;你可以去远程主机,添加 dir 目录,并将所有传输的项目放回 dir 中。不幸的是,当你将尾部斜杠与 --delete 选项结合使用时,可能会发生更严重的灾难;务必小心,因为这样你很容易删除不相关的文件。

图 12-2:尾部斜杠在 rsync 中的作用
12.2.4 排除文件和目录
rsync 的一个重要特性是它能够从传输操作中排除文件和目录。例如,假设你想将一个名为 src 的本地目录传输到 host,但你希望排除任何名为 .git 的文件。你可以这样做:
$ **rsync -a --exclude=.git src** `host`**:**
请注意,这条命令排除了所有名为.git的文件和目录,因为--exclude接受的是模式,而不是绝对文件名。要排除特定项,请指定以/*开头的绝对路径,如下所示:
$ **rsync -a --exclude=/src/.git src** `host`**:**
以下是一些关于如何排除模式的技巧:
-
你可以根据需要添加任意多的
--exclude参数。 -
如果你重复使用相同的模式,可以将它们放入一个纯文本文件(每行一个模式),并使用
--exclude-from=``file。 -
要排除名为item的目录,但包括具有该名称的文件,可以使用尾部斜杠:
--exclude=``item``/。 -
排除模式基于完整的文件或目录名称组件,可以包含简单的通配符。例如,
t*s匹配this,但不匹配ethers。 -
如果你排除一个目录或文件名,但发现你的模式太严格,可以使用
--include来专门包括另一个文件或目录。
12.2.5 检查传输、添加保障和使用详细模式
为了加速操作,rsync使用快速检查来确定传输源中的文件是否已经存在于目标端。该检查结合了文件大小和最后修改日期。当你第一次将整个目录层级传输到远程主机时,rsync会发现目标端没有这些文件,因此会传输所有文件。使用rsync -n进行传输测试可以验证这一点。
运行一次rsync后,再次使用rsync -v运行它。这时你应该会看到传输列表中没有文件,因为文件集在两端都存在,且修改日期相同。
当源端的文件与目标端的文件不一致时,rsync会传输源文件并覆盖远程端的任何文件。但默认行为可能不够充分,因为你可能需要额外的确认,以确保文件确实相同,或者你可能希望添加一些额外的保障。以下是一些有用的选项:
-
--checksum(缩写:-c)计算文件的校验和(通常是唯一的签名),以检查文件是否相同。此选项在传输过程中会消耗少量的 I/O 和 CPU 资源,但如果你处理的是敏感数据或经常具有相同大小的文件,这是必须的。 -
--ignore-existing不会覆盖目标端已存在的文件。 -
--backup(缩写:-b)不会覆盖目标端已存在的文件,而是通过在文件名后添加~后缀来重命名这些已存在的文件,然后再传输新文件。 -
--suffix=s将--backup使用的后缀从~改为s。 -
--update(缩写:-u)不会覆盖目标中比源文件更新的文件。
在没有特别选项的情况下,rsync 默默运行,只有在出现问题时才会产生输出。然而,你可以使用 rsync -v 启用详细模式,或者使用 rsync -vv 获取更多的细节。(你可以根据需要添加任意多个 v 选项,但通常两个 v 就足够了。)要在传输完成后获得综合总结,可以使用 rsync --stats。
12.2.6 压缩数据
许多用户喜欢将 -z 选项与 -a 一起使用,在传输前压缩数据:
$ **rsync -az** `dir``host`**:**`dest_dir`
压缩可以在某些情况下提高性能,例如当你需要通过慢速连接(如慢速上行链路)上传大量数据,或当两个主机之间的延迟较高时。然而,在快速的局域网中,两个端点机器可能会受到压缩和解压数据所需的 CPU 时间的限制,因此未压缩的传输可能更快。
12.2.7 限制带宽
当你向远程主机上传大量数据时,很容易堵塞互联网连接的上行链路。尽管在这样的传输过程中你不会使用(通常较大的)下行带宽,如果你让 rsync 以最快速度运行,连接仍然会变得相当缓慢,因为如 HTTP 请求等传出的 TCP 数据包将与你的传输竞争上行带宽。
为了避免这个问题,使用 --bwlimit 给你的上行链路留一些喘息的空间。例如,要将带宽限制为 100,000Kbps,你可以像这样操作:
$ **rsync --bwlimit=100000 -a** `dir``host`**:**`dest_dir`
12.2.8 将文件传输到你的计算机
rsync 命令不仅仅用于将文件从本地机器复制到远程主机。你也可以通过将远程主机和远程源路径作为命令行的第一个参数,来将文件从远程机器传输到本地主机。例如,要将远程系统上的src_dir传输到本地主机上的dest_dir,请运行以下命令:
$ **rsync -a** `host`**:**`src_dir``dest_dir`
12.2.9 更多 rsync 主题
每当你需要复制大量文件时,rsync 应该是你首先想到的工具之一。在批处理模式下运行 rsync 对于将相同的文件集复制到多个主机特别有用,因为它能加速长时间的传输并且在中断时能够恢复。
你还会发现 rsync 对于制作备份非常有用。例如,你可以将互联网存储(如 Amazon 的 S3)附加到你的 Linux 系统上,然后使用 rsync --delete 定期将文件系统与网络存储同步,从而实现一个非常有效的备份系统。
这里描述的命令行选项只是其中的一部分。要获取大致的概览,请运行 rsync --help。你还可以在 rsync 的手册页和官网(rsync.samba.org/)找到更详细的信息。
12.3 文件共享简介
你的 Linux 机器可能并不是在网络上独自存在,当你网络中有多台机器时,几乎总是有理由在它们之间共享文件。在本章的其余部分,我们将首先探讨 Windows 和 macOS 机器之间的文件共享,你将了解更多关于 Linux 如何与完全不同的环境进行交互的内容。为了共享 Linux 机器之间的文件或访问网络存储设备(NAS)上的文件,我们最后将讨论如何使用 SSHFS 和网络文件系统(NFS)作为客户端。
12.3.1 文件共享使用和性能
在使用任何类型的文件共享系统时,你需要问自己一个问题,那就是你最初为什么要这么做。在传统的基于 Unix 的网络中,有两个主要原因:方便和缺乏本地存储。用户可以登录到网络中的一台机器,每台机器都可以访问用户的主目录。将存储集中在少数几个集中式服务器上,比为网络中的每台机器购买和维护大量本地存储要经济得多。
这种模型的优点被一个长期存在的主要缺点所掩盖:与本地存储相比,网络存储性能通常较差。一些数据访问方式是可以接受的;例如,现代硬件和网络没有问题将视频和音频数据从服务器流式传输到媒体播放器,部分原因在于数据访问模式非常可预测。服务器在发送大文件或流数据时,可以有效地预加载并缓冲数据,因为它知道客户端很可能按顺序访问数据。
然而,如果你进行更复杂的操作或同时访问许多不同的文件,你会发现你的 CPU 更常在等待网络响应。延迟是主要的罪魁祸首之一。这是接收任何随机(任意)网络文件访问数据所需的时间。在将数据发送到客户端之前,服务器必须接受并解读请求,然后定位并加载数据。前几个步骤通常是最慢的,几乎每次新的文件访问都要执行。
故事的寓意是,当你开始考虑网络文件共享时,要问自己为什么要这么做。如果是为了存储大量不需要频繁随机访问的数据,你很可能不会遇到问题。但如果你在编辑视频或开发一个大型软件系统时,你会希望将所有文件保存在本地存储中。
12.3.2 文件共享安全
传统上,文件共享协议中的安全性并未被视为高优先级。这会影响你如何以及在何处实施文件共享。如果你有任何理由怀疑共享文件的机器之间网络的安全性,你需要在配置中考虑授权/认证和加密。良好的授权和认证意味着只有拥有正确凭证的人员才能访问文件(并且服务器是它所声称的身份),而加密确保没有人能够在文件数据传输到目的地的过程中窃取它。
最容易配置的文件共享选项通常是最不安全的,不幸的是,目前没有标准化的方法来保障这些访问的安全性。然而,如果你愿意付出努力连接正确的组件,像 stunnel、IPSec 和 VPN 等工具可以确保基本文件共享协议下方的安全层。
12.4 使用 Samba 共享文件
如果你有运行 Windows 的机器,你可能希望通过标准的 Windows 网络协议 Server Message Block (SMB) 允许从这些 Windows 机器访问你 Linux 系统上的文件和打印机。macOS 也支持 SMB 文件共享,但你也可以使用 SSHFS,详见第 12.5 节。
Unix 的标准文件共享软件套件称为 Samba。Samba 不仅允许你网络中的 Windows 计算机访问你的 Linux 系统,而且还可以反向操作:你可以通过其 Samba 客户端软件,在 Linux 机器上打印并访问 Windows 服务器上的文件。
设置 Samba 服务器的步骤如下:
-
创建一个 smb.conf 文件。
-
向 smb.conf 中添加文件共享部分。
-
将打印机共享部分添加到 smb.conf 中。
-
启动 Samba 守护进程
nmbd和smbd。
当你从发行版包中安装 Samba 时,系统应该会执行这些步骤,并为服务器设置一些合理的默认值。然而,它可能无法确定你希望将 Linux 机器上的哪些 共享(资源)提供给客户端。
12.4.1 服务器配置
中央的 Samba 配置文件是 smb.conf,大多数发行版将其放置在 etc 目录下,例如 /etc/samba。然而,你可能需要到处查找才能找到该文件,因为它也可能位于 lib 目录下,例如 /usr/local/samba/lib。
smb.conf 文件格式类似于你在其他地方见过的 XDG 风格(例如 systemd 配置格式),并分为几个用方括号表示的部分,如 [global] 和 [printers]。smb.conf 中的 [global] 部分包含适用于整个服务器和所有共享的常规选项。这些选项主要涉及网络配置和访问控制。以下是一个示例 [global] 部分,展示了如何设置服务器名称、描述和工作组:
[global]
# server name
netbios name = `name`
# server description
server string = My server via Samba
# workgroup
workgroup = MYNETWORK
这些参数的作用如下:
-
netbios name服务器名称。如果省略此参数,Samba 将使用 Unix 主机名。NetBIOS 是一种 API,SMB 主机经常使用它进行相互通信。 -
server string服务器的简短描述。默认值是 Samba 版本号。 -
workgroupWindows 工作组名称。如果你在 Windows 域中,请将此参数设置为你的域名。
12.4.2 服务器访问控制
你可以在smb.conf文件中添加选项,以限制哪些机器和用户可以访问你的 Samba 服务器。以下是你可以在[global]部分和控制单独共享资源的部分中设置的一些选项(本章后面会详细描述):
-
interfaces设置此项,使 Samba 在指定的网络或接口上监听(接受连接)。例如:interfaces = 10.23.2.0/255.255.255.0 interfaces = enp0s31f6 -
bind interfaces only当使用interfaces参数时,将此设置为yes,以将访问限制为仅能直接通过这些接口访问的机器。 -
valid users设置此项以允许指定用户访问。例如:valid users = jruser, bill -
guest ok将此参数设置为true,以使共享资源对网络上的匿名用户可用。仅当你确定网络是私有的时,才这样做。 -
browseable将此设置为使共享资源可以被网络浏览器查看。如果你将此参数设置为no,你仍然可以访问 Samba 服务器上的共享资源,但你需要知道它们的确切名称才能访问。
12.4.3 密码
通常,你应该仅允许使用密码认证访问你的 Samba 服务器。不幸的是,Unix 上的基本密码系统与 Windows 上的不同,因此除非你指定明文网络密码或使用 Windows 域服务器进行密码验证,否则你必须设置一个替代的密码系统。本节将向你展示如何使用 Samba 的简易数据库(TDB)后端设置替代密码系统,这适用于小型网络。
首先,在你的smb.conf [global]部分使用以下条目定义 Samba 密码数据库的特性:
# use the tdb for Samba to enable encrypted passwords
security = user
passdb backend = tdbsam
obey pam restrictions = yes
smb passwd file = /etc/samba/passwd_smb
这些行允许你使用smbpasswd命令操作 Samba 密码数据库。obey pam restrictions参数确保任何通过smbpasswd命令更改密码的用户都必须遵守 PAM(可插拔认证模块,第七章中介绍)对常规密码更改的限制。对于passdb backend参数,你可以选择性地在冒号后指定 TDB 文件的路径名,例如,tdbsam:/etc/samba/private/passwd.tdb。
添加和删除用户
让 Windows 用户访问你的 Samba 服务器的第一步是使用smbpasswd -a命令将用户添加到密码数据库中:
# smbpasswd -a `username`
smbpasswd命令中的username参数必须是你 Linux 系统上的有效用户名。
像常规系统的passwd程序一样,smbpasswd会要求你输入新 SMB 用户的密码两次。密码通过必要的安全检查后,smbpasswd会确认已创建新用户。
要删除用户,请使用smbpasswd的-x选项:
# smbpasswd -x `username`
要暂时禁用用户,使用-d选项;稍后可以使用-e选项重新启用该用户:
# smbpasswd -d`username`
# smbpasswd -e`username`
更改密码
你可以通过使用smbpasswd(除了用户名外不带任何选项或关键字)以超级用户身份更改 Samba 密码:
# smbpasswd `username`
然而,如果 Samba 服务器正在运行,任何用户都可以通过在命令行中输入smbpasswd单独更改自己的 Samba 密码。
最后,这是配置中的一个需要注意的地方。如果在你的smb.conf文件中看到如下行,请小心:
unix password sync = yes
这一行导致smbpasswd在更改用户的普通密码同时更改 Samba 密码。这个结果可能会非常令人困惑,特别是当用户将 Samba 密码更改为与其 Linux 密码不同的内容时,他们会发现无法再登录到 Linux 系统。有些发行版在其 Samba 服务器包中默认设置此参数!
12.4.4 手动启动服务器
通常,如果你是从发行版包安装 Samba,应该不需要担心启动服务器。可以通过systemctl --type=service查看列表进行验证。然而,如果你是从源代码安装的,运行nmbd和smbd并传入以下参数,其中smb_config_file是你的smb.conf文件的完整路径:
# nmbd -D -s `smb_config_file`
# smbd -D -s `smb_config_file`
nmbd守护进程是一个 NetBIOS 名称服务器,而smbd则负责处理共享请求。-D选项指定守护进程模式。如果在smbd运行时修改了smb.conf文件,可以通过 HUP 信号通知守护进程这些更改,尽管通常来说,如果让 systemd 管理服务器会更好,在这种情况下,你可以让systemctl为你处理这些工作。
12.4.5 诊断与日志文件
如果 Samba 服务器启动时出现问题,命令行上会出现错误消息。然而,运行时诊断消息会被写入到log.nmbd和log.smbd日志文件中,通常位于/var/log目录下,例如/var/log/samba。你还会在那里找到其他日志文件,比如每个单独客户端的日志。
12.4.6 文件共享配置
要将目录导出到 SMB 客户端(即与客户端共享目录),请将以下类似内容添加到你的smb.conf文件中,其中label是你希望为共享命名的名称(例如mydocuments),path是完整的目录路径:
[`label`]
path = `path`
comment = `share description`
guest ok = no
writable = yes
printable = no
这些参数在目录共享中非常有用:
-
guest ok在此设置为yes时,允许来宾访问共享。public参数是其同义词。 -
writable设置为yes或true时,表示共享为可读写。不要允许来宾访问可读写的共享。 -
printable显然,在目录共享上,必须将此参数设置为no或false。 -
veto files该参数防止导出与给定模式匹配的任何文件。您必须将每个模式用斜杠括起来(使其看起来像/pattern/)。此示例禁止对象文件以及任何名为bin的文件或目录:veto files = /*.o/bin/
12.4.7 主目录
如果您希望将用户的主目录导出,可以在您的smb.conf文件中添加一个名为[homes]的部分。该部分应如下所示:
[homes]
comment = home directories
browseable = no
writable = yes
默认情况下,Samba 读取已登录用户的/etc/passwd条目,以确定其[homes]的主目录。然而,如果您不希望 Samba 遵循此行为(即,您希望将 Windows 主目录存放在与常规 Linux 主目录不同的位置),您可以在path参数中使用%S替代符。例如,您可以通过以下方式将用户的[homes]目录切换到/u/user:
path = /u/%S
Samba 将当前用户名替换为%S。
12.4.8 打印机共享
您可以通过在smb.conf文件中添加[printers]部分,将打印机导出到 Windows 客户端。当您使用 CUPS(标准 Unix 打印系统)时,该部分如下所示:
[printers]
comment = Printers
browseable = yes
printing = CUPS
path = cups
printable = yes
writable = no
要使用printing = CUPS参数,您的 Samba 安装必须配置并链接到 CUPS 库。
12.4.9 Samba 客户端
Samba 客户端程序smbclient可以打印和访问远程 Windows 共享。当您处于必须与不提供 Unix 友好通信方式的 Windows 服务器交互的环境中时,这个程序非常有用。
要开始使用smbclient,请使用-L选项获取名为SERVER的远程服务器的共享列表:
$ **smbclient -L -U** `username SERVER`
如果您的 Linux 用户名与SERVER上的用户名相同,则不需要-U username。
运行此命令后,smbclient会要求输入密码。要尝试以访客身份访问共享,请按回车;否则,在SERVER上输入您的密码。成功后,您应该会看到如下共享列表:
Sharename Type Comment
--------- ---- -------
Software Disk Software distribution
Scratch Disk Scratch space
IPC$ IPC IPC Service
ADMIN$ IPC IPC Service
Printer1 Printer Printer in room 231A
Printer2 Printer Printer in basement
使用Type字段帮助您理解每个共享的类型,并仅关注Disk和Printer共享(IPC共享用于远程管理)。此列表包含两个磁盘共享和两个打印机共享。使用Sharename列中的名称访问每个共享。
作为客户端访问文件
如果您仅需要偶尔访问磁盘共享中的文件,可以使用以下命令(如果您的 Linux 用户名与服务器上的用户名相同,可以省略-U username):
$ **smbclient -U** `username` **'\\**`SERVER`**\**`sharename`**'**
如果成功,您将看到类似于此的提示,表示现在可以传输文件:
smb: \>
在此文件传输模式下,smbclient类似于 Unix 的ftp,您可以运行以下命令:
-
getfile将file从远程服务器复制到当前本地目录。 -
putfile将file从本地计算机复制到远程服务器。 -
cddir将远程服务器上的目录更改为dir。 -
lcdlocaldir将当前本地目录更改为localdir。 -
pwd打印远程服务器上的当前目录,包括服务器和共享名称。 -
!``command在本地主机上运行command。两个特别实用的命令是!pwd和!ls,用于查看本地的目录和文件状态。 -
help显示完整的命令列表。
使用 CIFS 文件系统
如果你希望更方便地访问 Windows 服务器上的文件,可以直接使用mount将共享连接到你的系统。命令语法如下(注意使用SERVER``:``sharename而不是正常的\\``SERVER``\``sharename格式):
# mount -t cifs `SERVER`**:**`sharename``mountpoint` **-o user=**`username`**,pass=**`password`
为了像这样使用mount,你必须在系统上安装通用互联网文件系统(CIFS)工具。大多数发行版将其作为一个单独的包提供。
12.5 SSHFS
在解决了 Windows 文件共享系统的问题后,本节将讨论 Linux 系统之间的文件共享。对于一些不特别复杂的场景,SSHFS 是一个方便的选择。这不过是一个用户空间的文件系统,它打开一个 SSH 连接并在你的机器上以挂载点的形式呈现远程端的文件。大多数发行版默认不安装它,因此你可能需要安装对应发行版的 SSHFS 包。
在命令行中使用 SSHFS 的语法在表面上看起来与以前看到的 SSH 命令相似。当然,你需要提供共享目录(在远程主机上)和所需的挂载点:
$ **sshfs** `username`**@**`host`**:**`dir` `mountpoint`
就像在 SSH 中一样,如果远程主机上的用户名相同,你可以省略username``@,如果你只想挂载远程主机的主目录,也可以省略:``dir。如果需要,命令会要求输入远程主机的密码。
由于这是一个用户空间的文件系统,如果以普通用户身份运行,你必须使用fusermount来卸载它:
$ **fusermount -u** `mountpoint`
超级用户也可以使用umount卸载这些文件系统。为了确保所有权和安全性的一致性,通常最好以普通用户身份挂载此类文件系统。
SSHFS 具有以下优点:
-
它的设置非常简单。远程主机的唯一要求是启用 SFTP,而大多数 SSH 服务器默认启用它。
-
它不依赖于任何特定的网络配置。如果你能够建立 SSH 连接,SSHFS 就能工作,无论是在安全的本地网络还是不安全的远程网络上。
SSHFS 的缺点是:
-
性能较差。加密、转换和传输存在大量开销(但可能没有你想象的那么糟糕)。
-
多用户设置有限。
如果你认为 SSHFS 可能对你有用,它绝对值得一试,因为它非常容易设置。
12.6 NFS
在 Unix 系统中,NFS 是最常用的传统文件共享系统之一,而且根据不同的场景,NFS 有许多不同的版本。你可以通过 TCP 和 UDP 提供 NFS,提供大量的认证和加密选项(但不幸的是,默认情况下很少启用)。由于选项繁多,NFS 可能是一个庞大的话题,所以我们这里只讨论最基本的内容。
要在服务器上挂载远程目录,使用与挂载 CIFS 目录相同的基本语法:
# mount -t nfs `server`**:**`directory``mountpoint`
从技术上讲,你不需要-t nfs选项,因为mount应该能够自动识别,但你可能需要查看 nfs(5)手册页中的选项。你会发现有几个不同的安全选项可以使用sec选项进行配置。许多小型封闭网络的管理员使用基于主机的访问控制。更复杂的方法,如基于 Kerberos 的认证,需要在系统的其他部分进行额外配置。
当你发现自己更多地依赖网络上的文件系统时,设置自动挂载器,这样你的系统只有在你实际尝试使用这些文件系统时才会挂载它们,从而避免启动时由于依赖关系问题导致的故障。传统的自动挂载工具叫做 automount,新的版本叫做 amd,但许多功能现在已经被 systemd 中的自动挂载单元类型所取代。
12.7 云存储
说到云备份,另一种网络文件存储选项是云存储,例如 AWS S3 或 Google Cloud Storage。这些系统的性能不及本地网络存储,但它们提供了两个重要的优势:你不必维护它们,而且你不必担心备份。
除了所有云存储提供商提供的网页(和程序接口)之外,还有方法可以在 Linux 系统上挂载大多数类型的云存储。与我们迄今为止看到的大多数文件系统不同,这些几乎都作为 FUSE(用户空间文件系统)接口实现。对于一些流行的云存储提供商,如 S3,甚至有多个选项可供选择。这是有道理的,因为 FUSE 处理程序只不过是一个用户空间的守护进程,充当数据源和内核之间的中介。
本书不涉及设置云存储客户端的具体细节,因为每个客户端的设置方式都不同。
12.8 网络文件共享的现状
此时,你可能会觉得关于 NFS 和文件共享的讨论似乎有些不完整——这也许是,因为文件共享系统本身就是如此。我们在第 12.3.1 节和 12.3.2 节讨论了性能和安全性问题。特别是,NFS 的基本安全性较低,需要做大量额外工作来提升。CIFS 系统在这方面稍微好一些,因为必要的加密层已经内置到当代软件中。然而,性能的限制很难克服,更不用说当系统暂时无法访问其网络存储时,它的性能会变得多么糟糕。
已经有多次尝试来解决这个问题。也许最广泛的尝试是 Andrew 文件系统(AFS),它最早在 1980 年代设计,围绕这些问题的解决方案构建。那么,为什么不是每个人都使用 AFS 或类似的系统呢?
这个问题没有唯一答案,但很大程度上归结于设计中的某些部分缺乏灵活性。例如,安全机制需要 Kerberos 身份验证系统。尽管它在全球范围内都有提供,但它从未在 Unix 系统上成为标准,而且需要进行不小的工作才能设置和维护(你必须为其设置一个服务器)。
对于大型机构,满足像 Kerberos 这样的要求并不成问题。这正是 AFS 得以蓬勃发展的环境;大学和金融机构是大型 AFS 站点。但对于小型用户来说,简单的选择如 NFS 或 CIFS 共享更为便捷,因此他们更倾向于不使用这些复杂的选项。即便是 Windows 系统也存在这种限制;从 Windows 2000 开始,微软将 Kerberos 作为其服务器产品的默认身份验证方式,但小型网络通常不会是具有这种服务器的 Windows 域。
除了身份验证的前提条件外,还有一个来自技术原因的问题。许多网络文件系统客户端传统上是内核代码,特别是 NFS。不幸的是,网络文件系统的需求复杂到开始出现问题。仅身份验证就无法放置在内核中。内核客户端实现还严重限制了网络文件系统的潜在开发者基础,从而阻碍了整个系统的发展。在某些情况下,客户端代码位于用户空间,但底层总有一些内核定制。
目前,在 Linux/Unix 世界中,我们没有一种真正标准的网络文件共享方式(至少如果你不是大型站点或者不愿意投入大量精力的话)。然而,这种情况不一定会永远如此。
当提供商开始提供云存储时,很明显传统的网络文件共享方式已经不再适用。在云中,访问方法建立在安全机制之上,例如 TLS,使得无需设置像 Kerberos 这样的大型系统就能访问存储。正如前一部分提到的,通过 FUSE 有许多选项可以访问云存储。我们不再依赖内核来处理客户端的任何部分;任何形式的身份验证、加密或处理都可以轻松地在用户空间完成。
所有这些意味着,未来可能会看到一些文件共享设计在安全性和其他领域(如文件名转换)方面融入更多灵活性。
第十三章:用户环境

本书的主要内容聚焦于 Linux 系统中通常支持服务器进程和交互式用户会话的部分。但最终,系统和用户必须在某个地方相遇。启动文件在这一点上起着重要作用,因为它们为 shell 和其他交互式程序设置默认值。它们决定了用户登录时系统的行为。
大多数用户并不会密切关注自己的启动文件,只有在想要添加一些方便的内容(例如别名)时才会修改它们。随着时间的推移,这些文件会被不必要的环境变量和测试混杂,可能会导致令人烦恼(甚至相当严重)的错误。
如果你已经使用 Linux 一段时间,你可能注意到,随着时间的推移,你的主目录会累积一堆令人困惑的启动文件。这些文件有时被称为 dot 文件,因为它们几乎总是以一个点(.)开头,这使得它们不会出现在 ls 和大多数文件管理器的默认显示中。这些文件中的许多在你第一次运行程序时会自动创建,你也永远不需要修改它们。本章主要讨论的是 shell 启动文件,这些文件是你最可能修改或从头编写的。首先,让我们看看在处理这些文件时你需要多么小心。
13.1 创建启动文件的指南
在设计启动文件时,要考虑到用户。如果你是机器上唯一的用户,你无需太多担心,因为任何错误只会影响你,而且修复起来相对简单。然而,如果你正在创建启动文件,目的是作为所有新用户在一台机器或网络上的默认文件,或者如果你认为有人可能会将你的文件复制到其他机器使用,那么这个过程就变得非常重要。如果你在启动文件中犯了一个错误,并将其分发给了 10 个用户,那么你可能需要修复 10 次这个错误。
在为其他用户创建启动文件时,记住两个基本目标:
-
简洁性:保持启动文件数量尽可能少,且尽量使文件简短而简单,这样它们便于修改,同时不容易出错。每个启动文件中的项目都可能成为导致问题的因素。
-
可读性:在文件中使用大量注释,让用户清楚地了解文件中每一部分的作用。
13.2 何时修改启动文件
在修改启动文件之前,问问自己是否真的应该这么做。以下是更改启动文件的一些合理理由:
-
你想要更改默认提示符。
-
你需要适应一些关键的本地安装软件。(不过,可以先考虑使用包装脚本。)
-
你的现有启动文件已损坏。
如果你的 Linux 发行版一切正常,要小心。有时,默认的启动文件与 /etc 中的其他文件会相互作用。
话虽如此,如果你不想改变默认设置,你可能不会阅读这一章,因此让我们来看看重要的内容。
13.3 Shell 启动文件元素
启动文件中应该包含什么?一些内容看起来可能显而易见,例如命令路径和提示符设置。但到底应该在路径中包含什么?合理的提示符应该是什么样的?启动文件中放入多少内容算是过多?
本节讨论了 shell 启动文件的基本内容——从命令路径、提示符、别名到权限掩码。
13.3.1 命令路径
任何 shell 启动文件中最重要的部分是命令路径。路径应该涵盖包含每个普通用户感兴趣的应用程序的目录。至少,路径应该按顺序包含以下组件:
/usr/local/bin
/usr/bin
/bin
这个顺序确保你可以使用位于/usr/local中的特定站点变体来覆盖标准的默认程序。
大多数 Linux 发行版将几乎所有打包的用户软件的可执行文件安装在/usr/bin中。随着时间的推移,也有一些偶尔的差异,例如将游戏放在/usr/games,将图形应用程序放在单独的位置,因此首先检查系统的默认设置。并确保系统上每个通用程序都可以通过刚才列出的某个目录访问。如果不能,那么你的系统可能已经失控。不要为了适应每个新的软件安装目录而改变用户环境中的默认路径。一个廉价的解决方案是使用符号链接将其指向/usr/local/bin。
许多用户会创建自己的 bin 目录来存储 shell 脚本和程序,因此你可能想将其添加到路径的最前面:
$HOME/bin
如果你对系统工具(如sysctl、fdisk和lsmod)感兴趣,可以将 sbin 目录添加到你的路径中:
/usr/local/sbin
/usr/sbin
/sbin
13.3.2 手册页路径
传统的手册页路径是由MANPATH环境变量决定的,但你不应该设置它,因为这样会覆盖系统默认的/etc/manpath.config。
13.3.3 提示符
经验丰富的用户倾向于避免冗长、复杂且无用的提示符。相比之下,许多管理员和发行版将所有内容都拖入默认提示符中。即使是许多 shell 默认提示符也杂乱无章或大多数情况下毫无用处。例如,默认的bash提示符包含了 shell 的名称和版本号。你的选择应反映用户的需求;如果对用户有帮助,可以在提示符中放置当前工作目录、主机名和用户名。
最重要的是,避免使用对 shell 来说具有特殊意义的字符,例如以下这些:
{ } = & < >
这个简单的bash提示符设置以传统的 $ 结束(传统的 csh 提示符以 % 结束):
PS1='\u\$ '
\u 是 shell 会评估为当前用户名的表达式(请参阅 bash(1) 手册页中的 PROMPTING 部分)。其他常用的表达式包括:
-
\h主机名(简短形式,不带域名)。 -
\!历史编号。 -
\w当前目录。由于这可能变得很长,你可以通过使用\W来限制显示只显示最后一个组件。 -
\$如果作为用户账户运行,则为$;如果为 root 用户,则为#。
13.3.4 别名
当代用户环境中的一些棘手问题之一是别名的角色,这是一个在执行命令之前将一个字符串替换为另一个字符串的 shell 功能。别名可以是节省打字的有效快捷方式。然而,它们也有几个缺点:
-
操作参数可能会很棘手。
-
它们容易让人困惑;shell 的内置
which命令可以告诉你某个东西是否是别名,但它不会告诉你它是在哪里定义的。 -
在子 shell 和非交互式 shell 中,它们不受欢迎;它们不会传递给子 shell。
定义别名时一个经典的错误是为现有命令添加额外的参数——例如,将ls别名为ls -F。充其量,这会使你在不需要时很难移除-F参数。最糟糕的是,它可能会对不理解自己没有使用默认参数的用户造成严重后果。
鉴于这些缺点,你应该尽可能避免使用别名;编写一个 shell 函数或一个全新的 shell 脚本要更容易。计算机可以非常快速地启动并执行 shell,因此别名和全新命令之间的差异应该是不可察觉的。
也就是说,当你希望更改 shell 环境的一部分时,别名确实会派上用场。你不能通过 shell 脚本来更改环境变量,因为脚本作为子 shell 运行。(但是,你可以定义 shell 函数来执行这个任务。)
13.3.5 权限掩码
如第二章所述,shell 的内置umask(权限掩码)功能设置了你的默认权限。在你的启动文件中包含umask命令,以确保你运行的任何程序创建的文件具有你期望的权限。有两种合理的选择:
-
077这个掩码是最严格的权限掩码;它不允许其他用户访问新创建的文件和目录。这通常适用于多用户系统,在这种系统中,你不希望其他用户查看你的任何文件。然而,作为默认设置时,当你的用户希望共享文件但不理解如何正确设置权限时,这个掩码有时会导致问题。(缺乏经验的用户往往会将文件设置为世界可写模式。) -
022这个掩码允许其他用户读取新创建的文件和目录。在单用户系统上,这可能是一个不错的选择,因为许多以伪用户身份运行的守护进程将无法看到使用更严格的077umask 创建的文件和目录。
13.4 启动文件顺序和示例
现在你已经知道了应该把什么内容放入 shell 启动文件中,接下来是一些具体的示例。令人惊讶的是,创建启动文件时最困难和最令人困惑的部分之一是确定使用多个可能的启动文件中的哪一个。本节将介绍两种最流行的 Unix shell:bash 和 tcsh。
13.4.1 bash Shell
在 bash 中,你可以选择启动文件名 .bash_profile、.profile、.bash_login 和 .bashrc。哪个文件适合你的命令路径、手册页路径、提示符、别名和权限掩码呢?答案是,你应该有一个 .bashrc 文件,并附带一个指向 .bashrc 的 .bash_profile 符号链接,因为 bash shell 实例类型有几种不同的选择。
两种主要的 shell 实例类型是交互式和非交互式,但我们只关心交互式 shell,因为非交互式 shell(例如运行 shell 脚本时)通常不会读取任何启动文件。交互式 shell 是你用来从终端运行命令的 shell,就像本书中提到的那些,它们可以分为 登录 或 非登录。
登录 shell
传统上,登录 shell 是你通过终端登录系统时获得的 shell,通常使用如 /bin/login 的程序。通过 SSH 远程登录也会提供登录 shell。基本思想是登录 shell 是一个初始 shell。你可以通过运行 echo $0 来判断一个 shell 是否是登录 shell;如果第一个字符是 -,则该 shell 是登录 shell。
当 bash 作为登录 shell 运行时,它会运行 /etc/profile。然后,它会查找用户的 .bash_profile、.bash_login 和 .profile 文件,并只运行它看到的第一个文件。
听起来很奇怪,但实际上你可以通过将非交互式 shell 作为登录 shell 来强制其运行启动文件。为此,可以使用 -l 或 --login 选项启动 shell。
非登录 shell
非登录 shell 是你登录后运行的附加 shell。它只是任何非登录 shell 的交互式 shell。窗口系统终端程序(如 xterm、GNOME Terminal 等)通常会启动非登录 shell,除非你特别要求使用登录 shell。
当 bash 启动为非登录 shell 时,它会运行 /etc/bash.bashrc,然后运行用户的 .bashrc 文件。
两种 shell 的后果
两种不同启动文件背后的原因是,在早期,用户通过传统终端登录系统时会使用登录 shell,然后使用窗口系统或 screen 程序启动非登录子 shell。对于非登录子 shell,反复设置用户环境和运行已经运行过的一堆程序被认为是一种浪费。对于登录 shell,你可以在如 .bash_profile 这样的文件中运行一些复杂的启动命令,仅将别名和其他“轻量级”内容留给 .bashrc。
目前,大多数桌面用户通过图形显示管理器登录(你将在下一章了解更多关于这些的信息)。其中大多数以一个非交互式登录 Shell 启动,以保持登录与非登录模型的区分。如果它们没有这样做,你需要在你的 .bashrc 中设置整个环境(路径、手册路径等),否则你将无法在终端窗口的 Shell 中看到任何环境变量。然而,如果你希望通过控制台或远程登录,你还需要一个 .bash_profile,因为那些登录 Shell 根本不会处理 .bashrc。
示例 .bashrc
为了同时满足非登录和登录 Shell,如何创建一个 .bashrc 也可以作为你的 .bash_profile 使用?这里有一个非常基础(但完全足够)的示例:
# Command path.
PATH=/usr/local/bin:/usr/bin:/bin:/usr/games
PATH=$HOME/bin:$PATH
# PS1 is the regular prompt.
# Substitutions include:
# \u username \h hostname \w current directory
# \! history number \s shell name \$ $ if regular user
PS1='\u\$ '
# EDITOR and VISUAL determine the editor that programs such as less
# and mail clients invoke when asked to edit a file.
EDITOR=vi
VISUAL=vi
# PAGER is the default text file viewer for programs such as man.
PAGER=less
# These are some handy options for less.
# A different style is LESS=FRX
# (F=quit at end, R=show raw characters, X=don't use alt screen)
LESS=meiX
# You must export environment variables.
export PATH EDITOR VISUAL PAGER LESS
# By default, give other users read-only access to most new files.
umask 022
在这个启动文件中,路径将 $HOME/bin 放在最前面,以便那里可执行的文件优先于系统版本。如果你需要使用系统可执行文件,可以添加 /sbin 和 /usr/sbin。
如前所述,你可以通过符号链接将这个 .bashrc 文件与 .bash_profile 共享,或者你可以通过创建如下的一行命令,使得它们的关系更加清晰:
. $HOME/.bashrc
检查登录和交互式 Shell
使用与你的 .bash_profile 匹配的 .bashrc 时,通常不需要为登录 Shell 运行额外的命令。然而,如果你想为登录和非登录 Shell 定义不同的操作,你可以在 .bashrc 中添加以下测试,它会检查 Shell 的 $- 变量中是否包含 i 字符:
case $- in
*i*) # interactive commands go here
`command`
`--snip--`
;;
*) # non-interactive commands go here
`command`
--`snip`--
;;
esac
13.4.2 tcsh Shell
几乎所有 Linux 系统上的标准 csh 都是 tcsh,这是一种增强版的 C shell,流行的特性包括命令行编辑和多模式的文件名和命令补全。即使你不使用 tcsh 作为默认的新用户 Shell(bash 应该是默认的),你仍然应该提供 tcsh 启动文件,以防用户遇到 tcsh。
在 tcsh 中,你不必担心登录 Shell 和非登录 Shell 之间的区别。启动时,tcsh 会查找一个 .tcshrc 文件。如果没有找到,它会查找 csh shell 的 .cshrc 启动文件。这样做的顺序是因为你可以使用 .tcshrc 文件来扩展 tcsh,而这些扩展在 csh 中不可用。你应该坚持使用传统的 .cshrc 文件,而不是 .tcshrc;几乎不可能有人会在 csh 中使用你的启动文件。如果某个用户在其他系统上遇到 csh,你的 .cshrc 将可以正常工作。
示例 .cshrc
这是一个示例 .cshrc 文件:
# Command path.
setenv PATH $HOME/bin:/usr/local/bin:/usr/bin:/bin
# EDITOR and VISUAL determine the editor that programs such as less
# and mail clients invoke when asked to edit a file.
setenv EDITOR vi
setenv VISUAL vi
# PAGER is the default text file viewer for programs such as man.
setenv PAGER less
# These are some handy options for less.
setenv LESS meiX
# By default, give other users read-only access to most new files.
umask 022
# Customize the prompt.
# Substitutions include:
# %n username %m hostname %/ current directory
# %h history number %l current terminal %% %
set prompt="%m%% "
13.5 默认用户设置
编写启动文件并为新用户选择默认设置的最佳方法是在系统上实验一个新的测试用户。创建测试用户时,使用一个空的家目录,并避免将自己的启动文件复制到测试用户的目录中。重新编写新的启动文件。
当你认为配置已经正常时,尝试以所有可能的方式登录新测试用户(在控制台上、远程登录等)。确保测试尽可能多的功能,包括窗口系统操作和手册页。当你对测试用户满意后,创建第二个测试用户,将第一个测试用户的启动文件复制过去。如果一切正常,你现在拥有了一组可以分发给新用户的启动文件。
本节概述了新用户的合理默认设置。
13.5.1 Shell 默认设置
对于 Linux 系统上的新用户,默认的 shell 应该是 bash,因为:
-
用户与他们用于编写 shell 脚本的相同 shell 进行交互。(由于种种原因,我在这里不展开讨论,
csh是一个臭名昭著的糟糕脚本工具——甚至不要考虑它。) -
bash是 Linux 发行版的默认 shell。 -
bash使用 GNU readline 库来接收输入,因此它的界面与许多其他工具的界面相同。 -
bash为你提供了精细、易于理解的 I/O 重定向和文件句柄控制。
然而,许多经验丰富的 Unix 专家使用 csh 或 tcsh 等 shell,仅仅是因为他们最熟悉这种 shell,无法忍受切换。当然,你可以选择任何你喜欢的 shell,但如果没有特别偏好,建议选择 bash,并将其设置为新用户的默认 shell。(用户可以通过 chsh 命令更改其 shell 以适应个人喜好。)
13.5.2 编辑器
在传统系统中,默认编辑器是 vi 或 emacs。这些编辑器几乎可以保证在几乎所有 Unix 系统上都能找到(或者至少可用),这意味着它们对于新用户来说会在长期内造成最少的麻烦。然而,Linux 发行版通常将 nano 配置为默认编辑器,因为它对初学者更友好。
与 shell 启动文件一样,避免使用过大的默认编辑器启动文件。在 .exrc 启动文件中加入少量的 set showmatch(让 vi 显示匹配的括号)不会对任何人造成困扰,但避免任何显著改变编辑器行为或外观的设置,如 showmode 特性、自动缩进和换行边距等。
13.5.3 分页程序
分页程序是一个程序,比如 less,它一次显示一页文本。将默认的 PAGER 环境变量设置为 less 是完全合理的。
13.6 启动文件的陷阱
避免在启动文件中出现以下陷阱:
-
不要在 shell 启动文件中放入任何图形化命令。并不是所有的 shell 都在图形环境中运行。
-
不要在 shell 启动文件中设置
DISPLAY环境变量。我们还没有涉及图形环境,但这可能会导致你的图形会话出现问题。 -
不要在 shell 启动文件中设置终端类型。
-
不要在默认的启动文件中省略描述性的注释。
-
不要在启动文件中运行会输出到标准输出的命令。
-
永远不要在 shell 启动文件中设置
LD_LIBRARY_PATH(参见第 15.1.3 节)。
13.7 进一步的启动话题
因为本书仅涉及基础的 Linux 系统,我们不会讨论窗口环境的启动文件。这确实是一个大问题,因为现代 Linux 系统的显示管理器有自己的一套启动文件,比如.xsession、.xinitrc,以及与 GNOME 和 KDE 相关的无尽组合。
窗口环境的选择可能让人感到困惑,并且在 Linux 中没有统一的方式来启动窗口环境。下一章将描述一些可能的选择。然而,当你了解了系统的工作方式后,你可能会对与图形环境相关的文件过于着迷。没问题,但不要将其强加给新用户。在 shell 启动文件中保持简单的原则同样适用于 GUI 启动文件。实际上,你可能根本不需要更改你的 GUI 启动文件。
第十四章:Linux 桌面与打印的简要概述

本章简要介绍了典型 Linux 桌面系统中的组件。在所有 Linux 系统上的不同类型的软件中,桌面领域是最为多样和丰富多彩的,因为有这么多环境和应用程序可以选择,而且大多数发行版都使你可以相对容易地尝试它们。
与 Linux 系统的其他部分(如存储和网络)不同,创建桌面结构并不涉及一个复杂的层次结构。相反,每个组件执行特定的任务,并根据需要与其他组件进行通信。有些组件确实共享一些共同的构建块(特别是图形工具包的库),你可以将它们视为简单的抽象层,但这也就是它的深度了。
本章提供了对桌面组件的一般讨论,但我们将更详细地探讨其中的两个部分:大多数桌面背后的核心基础设施,以及D-Bus,一个在系统许多部分使用的进程间通信服务。我们将把实践讨论和示例限制在一些诊断工具上,尽管这些工具在日常使用中并不特别有用(大多数图形用户界面不需要你输入命令行指令来与之交互),但它们将帮助你理解系统的底层机制,并可能在过程中带来一些娱乐。我们还将简要介绍打印功能,因为桌面工作站通常共享一台公共打印机。
14.1 桌面组件
Linux 桌面配置提供了极大的灵活性。Linux 用户所体验的大部分内容(桌面的“外观和感觉”)来自于应用程序或应用程序的构建块。如果你不喜欢某个特定的应用程序,通常可以找到替代品。如果你想要的东西不存在,你可以自己编写。Linux 开发者对桌面应如何表现有各种各样的偏好,这也带来了大量的选择。
为了使得应用程序能够协同工作,所有应用程序需要有一些共同点。在本文写作时,Linux 桌面的核心正处于过渡状态。从一开始到最近,Linux 桌面使用的是 X(X Window System,也称为Xorg,源于其维护组织)。然而,这种情况现在正在发生变化;许多发行版已过渡到基于Wayland协议的软件集来构建窗口系统。
为了理解是什么驱动了基础技术的变化,让我们退后一步,回顾一下图形学的一些基础。
14.1.1 帧缓冲
所有图形显示机制的底层是帧缓存,这是一个内存块,图形硬件读取并传输到屏幕上进行显示。帧缓存中的几个字节代表显示器的每个像素,因此,如果你想改变某个事物的显示效果,就需要将新值写入帧缓存内存。
窗口系统必须解决的一个问题是如何管理对帧缓存的写入。在任何现代系统中,窗口(或窗口集)属于各自的进程,并独立进行所有图形更新。因此,如果用户被允许移动窗口并让它们部分重叠,应用程序如何知道在哪里绘制图形,如何确保一个应用程序不会覆盖其他窗口的图形?
14.1.2 X 窗口系统
X 窗口系统采用的方法是拥有一个服务器(称为X 服务器),它充当桌面的“内核”,管理从渲染窗口到配置显示屏、处理来自设备(如键盘和鼠标)的输入等所有事务。X 服务器并不规定任何事物应该如何操作或显示。相反,X 客户端程序负责处理用户界面。基本的 X 客户端应用程序,如终端窗口和网页浏览器,会与 X 服务器建立连接,并请求绘制窗口。作为回应,X 服务器确定窗口放置的位置以及在哪里渲染客户端图形,并承担一定的责任来渲染图形到帧缓存。X 服务器还会在适当的时候将输入传递给客户端。
由于 X 服务器充当所有事务的中介,它可能成为一个重要的瓶颈。此外,它包含许多已经不再使用的功能,而且它的历史相当悠久,最早可追溯到 1980 年代。尽管如此,它的灵活性足以容纳许多扩展功能,延长了它的生命周期。我们将在本章稍后介绍如何与 X 窗口系统交互的基本方法。
14.1.3 Wayland
与 X 不同,Wayland 在设计上显著去中心化。没有一个大型显示服务器为多个图形客户端管理帧缓存,也没有集中式的图形渲染权威。相反,每个客户端都有自己的内存缓冲区(可以将其视为一种子帧缓存)用于自己的窗口,并且有一款名为合成器的软件,将所有客户端的缓冲区合并成必要的形式,再将其复制到屏幕的帧缓存中。由于通常有硬件支持这个任务,合成器的效率可以相当高。
在某些方面,Wayland 中的图形模型与大多数 X 客户端多年来一直执行的实践并没有太大不同。大多数客户端并没有从 X 服务器获得任何帮助,而是将它们自己的数据作为位图进行渲染,然后将该位图发送到 X 服务器。为了承认这一点,X 拥有一个合成扩展,该扩展已经使用了好几年。
在将输入通道传递到正确的应用程序任务中,大多数 Wayland 设置和许多 X 服务器使用一个名为libinput的库来将事件标准化到客户端。这个库并不是 Wayland 协议所要求的,但在桌面系统上,它几乎是通用的。我们将在 14.3.2 节中讨论 libinput。
14.1.4 窗口管理器
X 和 Wayland 系统之间的一个主要区别在于窗口管理器,即决定如何安排屏幕上窗口的那部分软件,它对用户体验至关重要。在 X 中,窗口管理器是一个客户端,作为服务器的辅助程序;它绘制窗口的装饰(如标题栏和关闭按钮),处理输入事件并告诉服务器移动窗口的位置。
然而,在 Wayland 中,窗口管理器实际上就是服务器。它负责将所有客户端窗口缓冲区合成到显示帧缓冲区中,并处理输入设备事件的传递。因此,它需要做的工作比 X 中的窗口管理器要多,但其中许多代码在不同窗口管理器实现之间是可以共享的。
两个系统中都有许多窗口管理器实现,但 X 由于其长久的存在,拥有更多窗口管理器。然而,许多流行的窗口管理器,如 Mutter(在 GNOME 中)和 Kwin(来自 KDE)也已扩展以支持 Wayland 合成。不管底层技术如何,似乎永远不会有一个标准的 Linux 窗口管理器;因为用户的口味和需求多种多样且不断变化,新窗口管理器不断涌现。
14.1.5 工具包
桌面应用程序包括某些常见元素,如按钮和菜单,称为小部件。为了加速开发并提供统一的外观,程序员使用图形工具包来提供这些元素。在 Windows 或 macOS 等操作系统中,供应商提供了一个通用的工具包,大多数程序员都会使用它。在 Linux 上,GTK+工具包是最常见的之一,但你也会经常看到基于 Qt 框架和其他工具包构建的小部件。
工具包通常由共享库和支持文件组成,如图像和主题信息。
14.1.6 桌面环境
尽管工具包为用户提供了统一的外观,但桌面的一些细节仍需要不同应用之间的协作。例如,一个应用可能希望与另一个应用共享数据,或者更新桌面上的公共通知栏。为了满足这些需求,工具包和其他库被打包成更大的软件包,称为桌面环境。GNOME、KDE 和 Xfce 是一些常见的 Linux 桌面环境。
工具包是大多数桌面环境的核心,但要创建一个统一的桌面,环境还必须包括许多支持文件,例如图标和配置文件,这些文件构成了主题。所有这些都通过描述设计规范的文档结合在一起,比如应用程序菜单和标题应如何显示,以及应用程序应如何响应某些系统事件。
14.1.7 应用程序
桌面顶部是一些应用程序,例如网页浏览器和终端窗口。X 应用程序的范围可以从简单的(例如古老的xclock程序)到复杂的(例如 Chrome 浏览器和 LibreOffice 套件)。这些应用通常是独立运行的,但它们通常会使用进程间通信来察觉相关事件。例如,当你插入一个新的存储设备或收到新的电子邮件或即时消息时,应用程序可以表达兴趣。这种通信通常通过 D-Bus 进行,具体描述见第 14.5 节。
14.2 你正在运行 Wayland 还是 X?
在开始我们的实践讨论之前,你需要确定你拥有的是哪种图形系统。只需打开一个 shell 并检查$WAYLAND_DISPLAY环境变量的值。如果值是类似wayland-0的东西,那说明你正在运行 Wayland。如果该变量没有设置,那你可能在运行 X(大概率是这样;虽然也有例外,但通过这个测试你不太可能遇到)。
这两个系统并不是互相排斥的。如果你的系统使用 Wayland,它也很可能在运行一个 X 兼容服务器。也可以在 X 内启动 Wayland 合成器,但那可能会有点奇怪(稍后会详细介绍)。
14.3 更深入了解 Wayland
我们将从 Wayland 开始,因为它是新兴标准,目前在许多发行版中默认使用。不幸的是,由于其设计和年轻的历史,关于 Wayland 的工具不像 X 那样丰富。我们会尽力而为。
但首先,让我们来谈谈 Wayland是什么以及不是。Wayland这个名称指的是合成窗口管理器与图形客户端程序之间的通信协议。如果你去寻找一个大的 Wayland 核心包,你是找不到的,但你会找到大多数客户端用来与该协议进行通信的 Wayland 库(至少目前是这样)。
还有一个参考性的合成窗口管理器,叫做 Weston,以及一些相关的客户端和工具。这里的 参考 意思是 Weston 包含了合成器所需的基本功能,但它不适合普通用户使用,因为它的界面非常简陋。这个设计的目的是让合成窗口管理器的开发者可以查看 Weston 的源代码,了解如何正确实现关键功能。
14.3.1 合成窗口管理器
虽然听起来有些奇怪,但你可能并不知道自己实际运行的是哪个 Wayland 合成窗口管理器。你也许能通过界面中的信息选项找到它的名称,但没有固定的位置可以查看。然而,你几乎总是可以通过追踪它与客户端通信所使用的 Unix 域套接字来找到正在运行的合成器进程。这个套接字就是 WAYLAND_DISPLAY 环境变量中的显示名称,通常是 wayland-0,并且通常可以在 /run/user/$XDG_RUNTIME_DIR 环境变量)。以 root 用户身份运行时,你可以通过 ss 命令找到监听此套接字的进程,但输出可能看起来有些混乱:
# ss -xlp | grep wayland-
u_str LISTEN 0 128 /run/user/1000/wayland-0 755881 * 0 users:(("gnome-shell",pid=1522,fd=30),("gnome-shell",pid=1522,fd=28))
然而,你只需要筛选一下;你可以看到,这里合成器进程是gnome-shell,PID 1522。遗憾的是,这里又有一层间接性;GNOME shell 是 Mutter 的插件,而 Mutter 是 GNOME 桌面环境中使用的合成窗口管理器。(在这里,称 GNOME shell 为插件只是意味着它将 Mutter 作为库来调用。)
在 Wayland 合成器的上下文中,你可以将 显示 理解为可视区域,由帧缓冲区表示。一个显示可以跨多个显示器,如果计算机连接了多个显示器的话。
虽然比较罕见,但你可以同时运行多个合成器。实现这一点的一种方式是将合成器分别运行在不同的虚拟终端上。在这种情况下,第一个合成器通常会将显示名称设置为 wayland-0,第二个为 wayland-1,依此类推。
你可以通过 weston-info 命令来获得一些关于合成器的见解,该命令显示合成器可用的接口的一些特性。不过,你不应期望看到太多,除了显示和一些输入设备的信息。
14.3.2 libinput
为了将来自设备(如键盘)的输入从内核传递到客户端,Wayland 合成器需要收集这些输入并以标准化的形式将其传递给适当的客户端。libinput 库提供了必要的支持,能够从各种 /dev/input 内核设备中收集输入并进行处理。在 Wayland 中,合成器通常不会直接将输入事件原样传递;它会将事件翻译成 Wayland 协议后再发送给客户端。
通常,像 libinput 这样的工具不会特别引人注目,但它带有一个小工具,也叫 libinput,允许你检查由内核呈现的输入设备和事件。
尝试以下方法查看可用的输入设备(你可能会得到很多输出,因此要准备好翻页查看):
# libinput list-devices
`--snip--`
Device: Cypress USB Keyboard
Kernel: /dev/input/event3
Group: 6
Seat: seat0, default
Capabilities: keyboard
Tap-to-click: n/a
Tap-and-drag: n/a
Tap drag lock: n/a
Left-handed: n/a
`--snip--`
在这个局部视图中,你可以看到设备的类型(键盘)以及内核 evdev 设备的位置(/dev/input/event3)。当你监听这些事件时,该设备会出现:
# libinput debug-events --show-keycodes
-event2 DEVICE_ADDED Power Button seat0 default group1 cap:k
`--snip--`
-event5 DEVICE_ADDED Logitech T400 seat0 default group5 cap:kp left scroll-nat scroll-button
-event3 DEVICE_ADDED Cypress USB Keyboard seat0 default group6 cap:k
`--snip--`
event3 KEYBOARD_KEY +1.04s KEY_H (35) pressed
event3 KEYBOARD_KEY +1.10s KEY_H (35) released
event3 KEYBOARD_KEY +3.06s KEY_I (23) pressed
event3 KEYBOARD_KEY +3.16s KEY_I (23) released
当你运行此命令时,移动鼠标指针并按下某些键。你将获得一些描述这些事件的输出。
记住,libinput 库只是一个用于捕捉内核事件的系统。因此,它不仅在 Wayland 下使用,也在 X 窗口系统下使用。
14.3.3 Wayland 中的 X 兼容性
在讨论 X 窗口系统之前,让我们先探索它与 Wayland 的兼容性。X 有无数的应用程序,任何从 X 迁移到 Wayland 的尝试都将因缺乏 X 支持而受到极大的阻碍。有两种同步的方法可以弥合这个差距。
第一个方法是为应用程序添加 Wayland 支持,从而创建一个原生的 Wayland 应用程序。大多数在 X 上运行的图形应用程序已经使用了像 GNOME 和 KDE 中的工具包。由于为这些工具包添加 Wayland 支持的工作已经完成,所以将一个 X 应用程序转变为原生的 Wayland 应用程序并不困难。除了关注窗口装饰和输入设备配置的支持外,开发者只需要处理应用程序中那些稀有的 X 库依赖。对于许多主要的应用程序,这项工作已经完成。
另一种方法是通过 Wayland 中的兼容层运行 X 应用程序。这是通过作为 Wayland 客户端运行整个 X 服务器来实现的。这个服务器叫做 Xwayland,其实只是另一个被嵌入在 X 客户端下的层,默认情况下由大多数合成器启动序列运行。Xwayland 服务器需要翻译输入事件,并单独维护其窗口缓冲区。引入像这样的中间人总会稍微减慢速度,但大多数情况下,这并没有什么实质性影响。
反向操作则不太奏效。你不能像在 X 上运行 Wayland 客户端那样运行它(理论上,写出这样的系统是可能的,但没有太大意义)。然而,你可以在 X 窗口内运行一个合成器。例如,如果你正在运行 X,可以在命令行中运行 weston 来启动一个合成器。你可以在合成器内打开终端窗口和任何其他 Wayland 应用程序,甚至可以在正确启动了 Xwayland 后,在合成器内运行 X 客户端。
然而,如果你保持这个合成器运行并返回到你的常规 X 会话,你可能会发现某些工具无法按预期工作,甚至可能会出现在合成器窗口中,而你预期它们应该出现在 X 窗口中。原因是许多 GNOME 和 KDE 等系统上的应用程序现在都同时支持 X 和 Wayland。它们首先会寻找一个 Wayland 合成器,如果WAYLAND_DISPLAY环境变量没有设置,libwayland中的代码默认会查找wayland-0。找到一个可用的合成器时,应用程序会使用它。
避免这种情况的最好方法是简单地不要在 X 内部或与 X 服务器同时运行合成器。
14.4 更深入了解 X 窗口系统
与基于 Wayland 的系统相比,X 窗口系统(www.x.org/)历来非常庞大,基本发行版包括 X 服务器、客户端支持库和客户端。由于 GNOME 和 KDE 等桌面环境的出现,X 的角色随着时间的推移发生了变化,现在的重点更多放在管理渲染和输入设备的核心服务器上,并简化了客户端库。
X 服务器在你的系统上很容易识别。它被称为X或Xorg。查看进程列表,你通常会看到它运行时带有一些选项,像这样:
Xorg -core :0 -seat seat0 -auth /var/run/lightdm/root/:0 -nolisten tcp vt7 -novtswitch
这里显示的:0称为X 显示器,是一个标识符,代表你通过共同的键盘和/或鼠标访问的一个或多个显示器。通常,显示器对应于连接到计算机的单个显示器,但你也可以将多个显示器放置在同一个显示器下。对于在 X 会话下运行的进程,DISPLAY环境变量会设置为显示器标识符。
在 Linux 上,X 服务器运行在虚拟终端上。在这个例子中,vt7参数表明它被指示在/dev/tty7上运行(通常,服务器会在第一个可用的虚拟终端上启动)。你可以通过在不同的虚拟终端上运行多个 X 服务器来同时运行多个 X 服务器,每个服务器都有一个唯一的显示标识符。你可以通过按 ctrl-alt-fn 键或使用chvt命令在服务器之间切换。
14.4.1 显示管理器
通常你不会在命令行上启动 X 服务器,因为启动服务器并不会定义任何应该在其上运行的客户端。如果你单独启动服务器,你只会看到一个空白屏幕。相反,启动 X 服务器最常见的方法是使用显示管理器,这是一种启动服务器并在屏幕上显示登录框的程序。当你登录时,显示管理器会启动一组客户端程序,比如窗口管理器和文件管理器,这样你就可以开始使用机器了。
有许多不同的显示管理器可用,如gdm(用于 GNOME)和kdm(用于 KDE)。在前述 X 服务器调用的参数列表中的lightdm是一个跨平台的显示管理器,旨在能够启动 GNOME 或 KDE 会话。
如果你坚持从虚拟控制台启动 X 会话,而不是使用显示管理器,可以运行startx或xinit命令。然而,得到的会话可能非常简单,看起来完全不像显示管理器的界面,因为其机制和启动文件不同。
14.4.2 网络透明性
X 的一个特点是网络透明性。由于客户端通过协议与服务器通信,可以在网络上跨不同机器直接运行客户端,X 服务器监听 TCP 端口 6000 上的连接。连接到该端口的客户端可以进行身份验证,并将窗口发送到服务器。
不幸的是,这种方法通常不提供加密,因此存在安全隐患。为了弥补这一漏洞,大多数发行版现在会禁用 X 服务器的网络监听(通过向服务器添加-nolisten tcp选项)。然而,你仍然可以通过 SSH 隧道从远程机器运行 X 客户端,正如第十章所描述的那样,将 X 服务器的 Unix 域套接字连接到远程机器上的套接字。
14.4.3 探索 X 客户端的方式
尽管通常不会想到从命令行操作图形用户界面,但有几个工具可以让你探索 X 窗口系统的各个部分。特别是,你可以检查正在运行的客户端。
最简单的工具之一是xwininfo。当没有任何参数运行时,它会提示你点击一个窗口:
$ **xwininfo**
xwininfo: Please select the window about which you
would like information by clicking the
mouse in that window.
点击后,它会打印有关窗口的信息列表,如其位置和大小:
xwininfo: Window id: 0x5400024 "xterm"
Absolute upper-left X: 1075
Absolute upper-left Y: 594
--`snip`--
注意这里的窗口 ID。X 服务器和窗口管理器使用这个标识符来跟踪窗口。要获取所有窗口 ID 和客户端的列表,可以使用xlsclients -l命令。
14.4.4 X 事件
X 客户端通过事件系统获取输入和服务器状态的其他信息。X 事件像其他异步进程间通信事件一样工作,例如 udev 事件和 D-Bus 事件。X 服务器接收来自输入设备等来源的信息,然后将这些输入作为事件重新分发给任何感兴趣的 X 客户端。
你可以通过xev命令来实验事件。运行它会打开一个新窗口,你可以将鼠标移动到窗口中,点击并输入。当你这么做时,xev会生成描述它从服务器接收到的 X 事件的输出。例如,下面是鼠标移动的示例输出:
$ **xev**
`--snip--`
MotionNotify event, serial 36, synthetic NO, window 0x6800001,
root 0xbb, subw 0x0, time 43937883, (47,174), root:(1692,486),
state 0x0, is_hint 0, same_screen YES
MotionNotify event, serial 36, synthetic NO, window 0x6800001,
root 0xbb, subw 0x0, time 43937891, (43,177), root:(1688,489),
state 0x0, is_hint 0, same_screen YES
注意括号中的坐标。第一对坐标表示鼠标指针在窗口中的 x 和 y 坐标,第二对(root:)表示指针在整个显示器上的位置。
其他低级事件包括按键和按钮点击,但更高级的事件则表示鼠标是否进入或退出窗口,或者窗口是否获得或失去来自窗口管理器的焦点。例如,下面是相应的退出和失去焦点事件。
LeaveNotify event, serial 36, synthetic NO, window 0x6800001,
root 0xbb, subw 0x0, time 44348653, (55,185), root:(1679,420),
mode NotifyNormal, detail NotifyNonlinear, same_screen YES,
focus YES, state 0
FocusOut event, serial 36, synthetic NO, window 0x6800001,
mode NotifyNormal, detail NotifyNonlinear
xev 的一个常见用途是提取不同键盘的键码和按键符号,特别是在重新映射键盘时。以下是按下 L 键时的输出;这里的键码是 46:
KeyPress event, serial 32, synthetic NO, window 0x4c00001,
root 0xbb, subw 0x0, time 2084270084, (131,120), root:(197,172),
state 0x0, keycode 46 (keysym 0x6c, l), same_screen YES,
XLookupString gives 1 bytes: (6c) "l"
XmbLookupString gives 1 bytes: (6c) "l"
XFilterEvent returns: False
你还可以将 xev 附加到现有的窗口 ID 上,使用 -id id 选项。用从 xwininfo 获取的 ID 替换 id(它将是一个以 0x 开头的十六进制数字)。
14.4.5 X 输入和首选项设置
X 的一个潜在困惑特性是,设置首选项通常有多种方式,而且某些方法可能无法生效。例如,Linux 系统上一种常见的键盘设置是将 Caps Lock 键重新映射为 Ctrl 键。实现此操作有多种方法,从使用旧的xmodmap命令进行小幅调整,到通过setxkbmap工具提供一个全新的键盘映射。你如何知道该使用哪一种(如果有的话)呢?这取决于了解系统中哪些部分负责处理,但要确定这一点可能会很困难。请记住,桌面环境可能会提供自己的设置并进行覆盖。话虽如此,以下是一些关于底层基础设施的提示。
输入设备(一般)
X 服务器使用 X 输入扩展 来管理来自多个设备的输入。有两种基本类型的输入设备——键盘和指针(鼠标)——你可以连接任意多的设备。为了同时处理多个相同类型的设备,X 输入扩展创建了一个 虚拟核心 设备,将设备输入引导到 X 服务器。
要查看你机器上的设备配置,可以运行 xinput --list 命令:
$ **xinput --list**
∣ Virtual core pointer id=2 [master pointer (3)]
∣ ↳Virtual core XTEST pointer id=4 [slave pointer (2)]
∣ ↳ Logitech Unifying Device id=8 [slave pointer (2)]
⌊ Virtual core keyboard id=3 [master keyboard (2)]
↳ Virtual core XTEST keyboard id=5 [slave keyboard (3)]
↳ Power Button id=6 [slave keyboard (3)]
↳ Power Button id=7 [slave keyboard (3)]
↳ Cypress USB Keyboard id=9 [slave keyboard (3)]
每个设备都有一个关联的 ID,你可以在 xinput 和其他命令中使用该 ID。在此输出中,ID 2 和 3 是核心设备,ID 8 和 9 是实际设备。注意,机器上的电源按钮也被视为 X 输入设备。
大多数 X 客户端监听来自核心设备的输入,因为它们无需关心是哪个特定设备触发了事件。事实上,大多数客户端对 X 输入扩展一无所知。然而,客户端可以使用该扩展来单独指定某个设备。
每个设备都有一组相关的 属性。要查看这些属性,可以使用带设备编号的 xinput:
$ **xinput --list-props 8**
Device 'Logitech Unifying Device. Wireless PID:4026':
Device Enabled (126): 1
Coordinate Transformation Matrix (128): 1.000000, 0.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000, 0.000000, 1.000000
Device Accel Profile (256): 0
Device Accel Constant Deceleration (257): 1.000000
Device Accel Adaptive Deceleration (258): 1.000000
Device Accel Velocity Scaling (259): 10.000000
`--snip--`
你可以使用 --set-prop 选项更改多个属性。有关更多信息,请参阅 xinput(1) 手册页。
鼠标
你可以使用xinput命令操作与设备相关的设置,许多最有用的选项与鼠标(指针)相关。你可以直接将许多设置作为属性进行更改,但通常使用专门的--set-ptr-feedback和--set-button-map选项来操作xinput会更容易。例如,如果你有一个三按钮鼠标,位于dev上,并且你想要反转按钮的顺序(这对左撇子用户很有用),可以尝试这样做:
$ **xinput --set-button-map** `dev` **3 2 1**
键盘
不同国家/地区提供的多种键盘布局在集成到任何窗口系统时会带来特别的困难。X 一直在其核心协议中提供一个内部的键盘映射功能,你可以使用xmodmap命令进行操作,但任何较新的系统都会使用 XKB(X 键盘扩展)来获得更精细的控制。
XKB 很复杂,以至于许多人在需要快速更改时仍然使用xmodmap。XKB 的基本概念是,你可以定义一个键盘映射,用xkbcomp命令编译它,然后使用setxkbmap命令将该映射加载并激活到 X 服务器中。这个系统有两个特别有趣的特点:
-
你可以定义部分映射来补充现有的映射。这对于一些任务非常有用,例如将 Caps Lock 键改为 Ctrl 键,许多桌面环境中的图形键盘偏好设置工具都会用到它。
-
你可以为每个附加的键盘定义单独的映射。
桌面背景
你的 X 服务器的根窗口是显示器的背景。旧的 X 命令xsetroot允许你设置根窗口的背景颜色和其他特性,但由于根窗口通常不可见,它在大多数机器上没有效果。相反,大多数桌面环境将一个大的窗口放置在所有其他窗口的后面,以启用诸如“动态壁纸”和桌面文件浏览等功能。有一些方法可以通过命令行更改背景(例如,在一些 GNOME 安装中使用gsettings命令),但如果你真的想这样做,你可能是有点闲暇时间。
xset
可能最古老的偏好命令是xset。虽然它现在用得不多,但你可以快速运行xset q来查看一些功能的状态。最有用的功能可能是屏幕保护程序和显示电源管理信号(DPMS)设置。
14.5 D-Bus
从 Linux 桌面中涌现出的最重要的进展之一是桌面总线(D-Bus),一个消息传递系统。D-Bus 之所以重要,是因为它作为一个进程间通信机制,允许桌面应用程序相互通信,并且因为大多数 Linux 系统都使用它来通知进程系统事件,比如插入 USB 驱动器。
D-Bus 本身由一个库组成,该库通过协议标准化了进程间通信,并提供支持功能,使任何两个进程能够互相通信。单独来看,这个库不过是普通 IPC(进程间通信)设施的一个高级版本,例如 Unix 域套接字。D-Bus 的真正价值在于一个名为 dbus-daemon 的中心“枢纽”。需要响应事件的进程可以连接到 dbus-daemon 并注册接收某些类型的事件。连接的进程也会生成这些事件。例如,进程 udisks-daemon 监控 udev 中的磁盘事件,并将它们发送给 dbus-daemon,然后后者会将事件转发给对磁盘事件感兴趣的应用程序。
14.5.1 系统与会话实例
D-Bus 已成为 Linux 系统的核心部分,并且现在已超越桌面应用。例如,systemd 和 Upstart 都有 D-Bus 通信渠道。然而,将桌面工具的依赖添加到核心系统中违背了 Linux 的设计原则。
为了解决这个问题,实际上有两种 dbus-daemon 实例(进程)可以运行。第一种是 系统实例,它由 init 在启动时通过 --system 选项启动。系统实例通常以 D-Bus 用户身份运行,其配置文件是 /etc/dbus-1/system.conf(不过你可能不应该修改该配置)。进程可以通过 /var/run/dbus/system_bus_socket Unix 域套接字连接到系统实例。
独立于系统 D-Bus 实例,还有一个可选的 会话实例,它只在你启动桌面会话时运行。你运行的桌面应用程序会连接到这个实例。
14.5.2 D-Bus 消息监控
查看系统和会话 dbus-daemon 实例之间差异的最好方法之一是监控总线上的事件。尝试在系统模式下使用 dbus-monitor 工具,如下所示:
# dbus-monitor --system
signal sender=org.freedesktop.DBus -> dest=:1.952 serial=2 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameAcquired
string ":1.952"
启动消息表明监视器已经连接并获得了一个名称。当你这样运行时,通常不会看到太多活动,因为系统实例通常不会很忙。为了看到一些活动,尝试插入一个 USB 存储设备。
相比之下,会话实例的工作要复杂得多。假设你已经登录到桌面会话,尝试以下操作:
$ **dbus-monitor --session**
现在尝试使用桌面应用程序,例如文件管理器;如果你的桌面环境支持 D-Bus,你应该会看到一系列指示各种变化的消息。请记住,并非所有应用程序都会生成消息。
14.6 打印
在 Linux 上打印文档是一个多阶段过程:
-
执行打印的程序通常会将文档转换为 PostScript 格式。这一步是可选的。
-
程序将文档发送给打印服务器。
-
打印服务器接收文档并将其放入打印队列。
-
当文档在队列中的顺序到来时,打印服务器将文档发送给打印过滤器。
-
如果文档不是 PostScript 格式,打印过滤器可能会进行转换。
-
如果目标打印机不理解 PostScript,打印机驱动程序会将文档转换为与打印机兼容的格式。
-
打印机驱动程序会向文档添加可选指令,例如纸盘和双面打印选项。
-
打印服务器使用后端将文档发送到打印机。
这个过程最令人困惑的部分是为什么如此多的内容都围绕着 PostScript 展开。PostScript 实际上是一种编程语言,因此当你使用它打印文件时,你实际上是在将一个程序发送到打印机。PostScript 作为 Unix-like 系统中打印的标准,就像.tar格式作为归档标准一样。(现在一些应用程序使用 PDF 输出,但这相对容易转换。)
我们稍后会详细讨论打印格式,但首先让我们来看一下排队系统。
14.6.1 CUPS
Linux 中的标准打印系统是CUPS(www.cups.org/),它与 macOS 上使用的系统相同。CUPS 服务器守护进程称为cupsd,你可以使用lpr命令作为简单客户端将文件发送到守护进程。
CUPS 的一个显著特点是它实现了互联网打印协议(IPP),这是一种允许客户端和服务器在 TCP 端口 631 上进行类似 HTTP 的事务的系统。事实上,如果你的系统上运行着 CUPS,你可能可以连接到http://localhost:631/来查看当前配置并检查打印任务。大多数网络打印机和打印服务器都支持 IPP,Windows 也是如此,这使得设置远程打印机成为一项相对简单的任务。
你可能无法通过网页界面管理系统,因为默认设置并不非常安全。相反,你的发行版很可能有一个图形设置界面来添加和修改打印机。这些工具会操作配置文件,通常位于/etc/cups目录下。通常最好让这些工具为你工作,因为配置可能会很复杂。即使你确实遇到问题并需要手动配置,通常最好通过图形工具创建一个打印机,这样你就有了一个起点。
14.6.2 格式转换与打印过滤器
许多打印机,包括几乎所有低端型号,都不理解 PostScript 或 PDF。为了支持这些打印机,Linux 打印系统必须将文档转换为特定打印机的格式。CUPS 将文档发送到光栅图像处理器(RIP)来生成位图。RIP 几乎总是使用 Ghostscript(gs)程序来完成大部分实际工作,但这有点复杂,因为位图必须适应打印机的格式。因此,CUPS 使用的打印机驱动程序会查阅特定打印机的PostScript 打印机定义(PPD)文件,以确定分辨率和纸张大小等设置。
14.7 其他桌面主题
Linux 桌面环境的一个有趣特点是,你通常可以选择自己想使用的部分,并且停止使用那些你不喜欢的部分。对于各种桌面项目的调查,可以查看 https://www.freedesktop.org/ 上的邮件列表和项目链接。
Linux 桌面领域的另一个重要进展是 Chromium OS 开源项目及其在 Chromebook PC 上的 Google Chrome OS 对应系统。这是一个 Linux 系统,使用了本章中描述的许多桌面技术,但其核心是 Chromium/Chrome 网络浏览器。许多传统桌面系统中的功能在 Chrome OS 中被去除。
尽管桌面环境在视觉上可能很有趣且值得尝试,但我们需要在这里结束讨论。然而,如果这一章引发了你的兴趣,并且你认为自己可能想要在这些方面工作,那么你就需要了解开发者工具的工作原理,而这正是我们接下来要探讨的内容。
第十五章:开发工具

Linux 在程序员中非常受欢迎,不仅仅因为提供了丰富的工具和环境,而且因为该系统的文档非常完善且透明。在 Linux 系统上,你不必是程序员就可以利用开发工具,这个好消息很重要,因为开发工具在管理 Linux 系统方面的作用,比其他操作系统要大得多。至少,你应该能识别出开发工具,并对如何使用它们有个大致了解。
本章在小小的篇幅中包含了大量信息,但你不需要掌握这里的所有内容。示例会非常简单,你不需要知道如何编写代码就能跟得上。你也可以很容易地浏览这些材料,稍后再回来。关于共享库的讨论可能是你需要知道的最重要的内容,但要理解共享库的来源,你首先需要一些关于如何构建程序的背景知识。
15.1 C 编译器
了解如何使用 C 语言编译器,可以让你深入理解你在 Linux 系统上看到的程序的来源。大多数 Linux 工具和许多 Linux 系统上的应用程序都是用 C 或 C++ 编写的。本章主要使用 C 语言的示例,但你也可以将这些知识转移到 C++。
C 程序遵循传统的开发流程:你编写程序,编译它们,然后运行它们。也就是说,当你编写 C 程序代码并想要运行它时,你必须 编译 可供人类阅读的代码,转化为计算机处理器可以理解的二进制低级格式。你编写的代码叫做 源代码,可能包含多个文件。你可以将其与稍后将讨论的脚本语言做对比,后者不需要编译任何东西。
大多数 Unix 系统上的 C 编译器可执行文件是 GNU C 编译器,gcc(通常被称为传统名称 cc),尽管来自 LLVM 项目的更新版 clang 编译器正逐渐获得更多关注。C 源代码文件以 .c 结尾。下面是 The C Programming Language(第二版,由 Brian W. Kernighan 和 Dennis M. Ritchie 编写,Prentice Hall,1988)中的一个独立的 C 源代码文件 hello.c:
#include <stdio.h>
int main() {
printf("Hello, World.\n");
}
将此源代码保存为名为 hello.c 的文件,然后使用以下命令运行编译器:
$ **cc hello.c**
结果是一个名为 a.out 的可执行文件,你可以像运行系统上的其他可执行文件一样运行它。然而,你可能应该给可执行文件起个更合适的名字(比如 hello)。要做到这一点,请使用编译器的 -o 选项:
$ **cc -o hello hello.c**
对于小型程序,编译过程也就这些内容。你可能需要额外添加一个库或包含目录,但在深入这些话题之前,我们先看一下稍大一些的程序。
15.1.1 编译多个源文件
大多数 C 程序的规模太大,无法合理地包含在一个源代码文件中。庞大的文件变得过于杂乱,程序员难以管理,编译器有时甚至很难处理大型文件。因此,开发人员通常将源代码拆分成多个组件,每个组件都有自己的文件。
在编译大多数 .c 文件时,你不会立即创建一个可执行文件。相反,你需要使用编译器的 -c 选项来处理每个文件,生成包含二进制目标代码的目标文件,这些目标文件最终会被包含到最终的可执行文件中。为了理解这个过程,假设你有两个文件,main.c(用于启动程序)和 aux.c(用于执行实际工作),它们的内容如下:
main.c:
void hello_call();
int main() {
hello_call();
}
aux.c:
#include <stdio.h>
void hello_call() {
printf("Hello, World.\n");
}
以下两个编译命令完成了大部分构建程序的工作:创建目标文件。
$ **cc -c main.c**
$ **cc -c aux.c**
在这些命令完成后,你将得到两个目标文件:main.o 和 aux.o。
目标文件是一个二进制文件,处理器几乎可以理解它,除了还有一些松散的部分。首先,操作系统不知道如何启动目标文件,其次,你可能需要将多个目标文件和一些系统库结合起来,才能构建一个完整的程序。
要从一个或多个目标文件构建一个完全可执行的程序,你必须运行链接器,即 Unix 系统中的 ld 命令。然而,程序员很少直接在命令行上使用 ld,因为 C 编译器知道如何运行链接器程序。为了从这两个目标文件创建一个名为 myprog 的可执行文件,运行以下命令来链接它们:
$ **cc -o myprog main.o aux.o**
现在将注意力转回到文件 aux.c。如前所述,它的代码完成程序的实际工作,可能会有许多像它生成的 aux.o 目标文件一样的文件,这些文件对于构建程序是必要的。现在假设其他程序可能会利用我们编写的例程。我们能否重用这些目标文件?这就是我们接下来要探讨的问题。
15.1.2 与库的链接
在源代码上运行编译器通常不会生成足够的目标代码来单独创建一个有用的可执行程序。你需要库来构建完整的程序。C 库是常见的预编译组件的集合,你可以将它们集成到你的程序中,它实际上就是一组目标文件(以及一些头文件,我们将在 15.1.4 节中讨论)。例如,有一个标准的数学库,许多可执行文件都需要从中调用,因为它提供了三角函数等功能。
库主要在链接时发挥作用,当链接器程序(ld)从目标文件创建可执行文件时。使用库进行链接通常称为与库链接。在这里你最有可能遇到问题。例如,如果你有一个使用 curses 库的程序,但忘记告诉编译器去链接那个库,你会看到像这样的链接错误:
**badobject.o**(.text+0x28): undefined reference to '**initscr**'
这些错误信息中最重要的部分是加粗显示的。当链接器程序检查 badobject.o 对象文件时,它找不到加粗显示的函数,因此无法创建可执行文件。在这种情况下,你可能怀疑是忘记了 curses 库,因为缺失的函数是 initscr();如果你搜索这个函数名,几乎总能找到一个手册页或者其他有关该库的参考资料。
为了解决这个问题,你必须首先找到 curses 库,然后使用编译器的 -l 选项来链接该库。库文件散布在整个系统中,不过大多数库都位于一个名为 lib 的子目录中(/usr/lib 是系统默认的位置)。对于前面的例子,基本的 curses 库文件是 libcurses.a,所以库名是 curses。把所有这些放在一起,你可以这样链接程序:
$ **cc -o badobject badobject.o -lcurses**
你必须告诉链接器非标准的库位置;这个参数是 -L。假设 badobject 程序需要在 /usr/junk/lib 中找到 libcrud.a。为了编译并创建可执行文件,可以使用如下命令:
$ **cc -o badobject badobject.o -lcurses -L/usr/junk/lib -lcrud**
你的系统中有一个名为 C 标准库的库,包含了被认为是 C 编程语言一部分的基本组件。它的基本文件是 libc.a。当你编译程序时,这个库总是会被包含进来,除非你特别排除它。你系统中的大多数程序使用的是共享版本,所以我们来讨论一下它是如何工作的。
15.1.3 使用共享库
以 .a 结尾的库文件(例如 libcurses.a)被称为 静态库。当你将一个程序与静态库链接时,链接器会将库文件中所需的机器码复制到你的可执行文件中。一旦完成,最终的可执行文件在运行时不再需要原始的库文件,并且因为你的可执行文件已经有了库代码的副本,所以如果 .a 文件发生变化,可执行文件的行为也不会受到影响。
然而,库的大小总是在增加,使用的库数量也在增多,这使得静态库在磁盘空间和内存上变得浪费。此外,如果后来发现某个静态库不足或不安全,就无法更改之前链接过该库的可执行文件,除非找到并重新编译每一个可执行文件。
共享库解决了这些问题。将程序与共享库链接并不会将代码复制到最终的可执行文件中;它只是将库文件中名称的引用添加到代码中。当你运行程序时,系统只会在必要时将库的代码加载到进程的内存空间中。许多进程可以共享相同的共享库代码在内存中。如果你需要稍微修改库代码,通常可以做到,而无需重新编译任何程序。在更新 Linux 发行版的软件时,你正在更新的软件包可能包括共享库。当更新管理器要求你重启机器时,有时是为了确保系统的每个部分都在使用共享库的新版本。
共享库确实有其自身的成本:管理困难且链接过程相对复杂。然而,如果你知道四个关键点,就可以控制共享库。
-
如何列出可执行文件所需的共享库
-
可执行文件如何查找共享库
-
如何将程序与共享库链接
-
如何避免常见的共享库陷阱
以下章节将介绍如何使用和维护系统的共享库。如果你对共享库的工作原理感兴趣,或者想了解链接器的相关知识,可以参考 John R. Levine 的《Linkers and Loaders》(Morgan Kaufmann,1999);David M. Beazley、Brian D. Ward 和 Ian R. Cooke 的《共享库与动态加载内幕》(《Computing in Science & Engineering》,2001 年 9/10 月);或者在线资源,如程序库 HOWTO(https://bit.ly/3q3MbS6)。ld.so(8) 手册页也值得一读。
如何列出共享库的依赖关系
共享库文件通常与静态库文件位于相同的位置。Linux 系统中的两个标准库目录是/lib和/usr/lib,尽管许多库可能分布在系统的其他位置。/lib目录不应包含静态库。
共享库的后缀名包含.so(共享对象),例如libc-2.15.so和libc.so.6。要查看程序使用了哪些共享库,可以运行ldd prog,其中prog是可执行文件的名称。下面是一个针对 shell 的示例:
$ **ldd /bin/bash**
linux-vdso.so.1 (0x00007ffff31cc000)
libgtk3-nocsd.so.0 => /usr/lib/x86_64-linux-gnu/libgtk3-nocsd.so.0 (0x00007f72bf3a4000)
libtinfo.so.5 => /lib/x86_64-linux-gnu/libtinfo.so.5 (0x00007f72bf17a000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f72bef76000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f72beb85000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f72be966000)
/lib64/ld-linux-x86-64.so.2 (0x00007f72bf8c5000)
为了实现最佳的性能和灵活性,单独的可执行文件通常不知道共享库的位置;它们只知道库的名称,可能还有一些关于如何找到它们的提示。一个名为ld.so的小程序(运行时动态链接器/加载器)在程序运行时查找并加载共享库。前面的ldd输出显示了左侧=>的库名称;这就是可执行文件所知道的内容。=>右侧显示了ld.so查找到库的位置。
最后一行输出显示了ld.so的实际位置:/lib/ld-linux.so.2。
如何 ld.so 查找共享库
共享库的常见问题之一是动态链接器无法找到库。动态链接器通常应该首先在可执行文件的预配置的 运行时库搜索路径 (rpath) 中查找共享库,如果该路径存在的话。稍后你将看到如何创建这个路径。
接下来,动态链接器会查看系统缓存 /etc/ld.so.cache,以查看库是否位于标准位置。这是一个快速缓存,存储了在缓存配置文件 /etc/ld.so.conf 中列出的目录中找到的库文件的名称。
ld.so.conf 中的每一行(或它包含的文件)都是你希望包含在缓存中的目录名。目录列表通常很短,包含类似以下内容的内容:
/lib/i686-linux-gnu
/usr/lib/i686-linux-gnu
标准库目录 /lib 和 /usr/lib 是隐式的,这意味着你不需要将它们包含在 /etc/ld.so.conf 中。
如果你更改了 ld.so.conf 或对某个共享库目录进行了更改,你必须手动通过以下命令重建 /etc/ld.so.cache 文件:
# ldconfig -v
-v 选项提供有关 ldconfig 添加到缓存中的库的详细信息,以及它检测到的任何更改。
ld.so 查找共享库的另一个位置是环境变量 LD_LIBRARY_PATH。我们很快会讨论这个。
不要养成往 /etc/ld.so.conf 中添加内容的习惯。你应该知道系统缓存中有哪些共享库,如果你将每个奇怪的共享库目录都放入缓存中,就有可能导致冲突,系统变得极其混乱。当你编译需要一个不常见库路径的软件时,为你的可执行文件设置一个内建的运行时库搜索路径。我们来看看如何做到这一点。
如何将程序链接到共享库
假设你有一个名为 libweird.so.1 的共享库,位于 /opt/obscure/lib,你需要将其链接到 myprog。你不应该在 /etc/ld.so.conf 中包含这个奇怪的路径,因此你需要将该路径传递给链接器。如下所示链接程序:
$ **cc -o myprog myprog.o -Wl,-rpath=/opt/obscure/lib -L/opt/obscure/lib -lweird**
-Wl,-rpath 选项告诉链接器将指定的目录包含到可执行文件的运行时库搜索路径中。然而,即使你使用了 -Wl,-rpath,你仍然需要 -L 标志。
如果你需要更改现有二进制文件的运行时库搜索路径,你可以使用 patchelf 程序,但通常最好在编译时完成此操作。(ELF,执行和可链接格式,是 Linux 系统中可执行文件和库的标准格式。)
如何避免共享库的问题
共享库提供了卓越的灵活性,更不用说一些真正不可思议的技巧了,但你可以滥用它们,导致系统变得一团糟。可能会发生三种特别糟糕的情况:
-
缺失的库
-
糟糕的性能
-
不匹配的库
所有共享库问题的首要原因是环境变量LD_LIBRARY_PATH。将此变量设置为由冒号分隔的目录名集合,使得ld.so在查找共享库时,会首先搜索给定的目录。这是一种廉价的方法,可以使程序在你移动库时仍能正常工作,如果你没有程序的源代码且不能使用patchelf,或者只是懒得重新编译可执行文件。不幸的是,你得到的就是你付出的代价。
永远不要在 shell 启动文件或编译软件时设置LD_LIBRARY_PATH。当动态运行时链接器遇到此变量时,它通常必须多次搜索每个指定目录的所有内容,次数可能比你想知道的还要多。这会导致性能严重下降,但更重要的是,你可能会遇到冲突和不匹配的库,因为运行时链接器会在这些目录中查找每个程序。
如果你必须使用LD_LIBRARY_PATH来运行一些低劣程序,而你没有该程序的源代码(或者你不想重新编译某些应用程序,如 Firefox 或其他某个程序),请使用包装脚本。假设你的可执行文件是/opt/crummy/bin/crummy.bin,并且需要在/opt/crummy/lib中查找一些共享库。编写一个名为crummy的包装脚本,内容如下:
#!/bin/sh
LD_LIBRARY_PATH=/opt/crummy/lib
export LD_LIBRARY_PATH
exec /opt/crummy/bin/crummy.bin $@
避免使用LD_LIBRARY_PATH可以防止大多数共享库问题。但开发人员偶尔会遇到的另一个重大问题是,库的 API 可能会在每个次要版本之间略有变化,从而破坏已安装的软件。解决这一问题的最佳方法是预防性措施:要么使用一致的方法通过-Wl,-rpath安装共享库,以创建运行时链接路径,要么干脆使用一些不常见库的静态版本。
15.1.4 处理头文件(包含文件)和目录
C 头文件是额外的源代码文件,通常包含类型和库函数声明,通常是为你刚才看到的库所准备的。例如,stdio.h就是一个头文件(参见 15.1 节中的简单程序)。
许多编译问题与头文件有关。大多数此类问题发生在编译器找不到头文件和库时。甚至有些情况下,程序员忘记在代码中添加#include指令来包含所需的头文件,从而导致部分源代码无法编译。
包含文件问题
查找正确的头文件并不总是容易的。有时你会很幸运,用locate找到它们,但在其他情况下,可能会在不同的目录中找到多个同名的包含文件,且不清楚哪个是正确的。当编译器找不到包含文件时,错误信息看起来是这样的:
badinclude.c:1:22: fatal error: notfound.h: No such file or directory
此消息报告编译器找不到notfound.h头文件,而badinclude.c文件引用了它。如果我们查看badinclude.c(如错误所示,在第 1 行),我们会发现有一行类似这样的内容:
#include <notfound.h>
像这样的包含指令并没有指定头文件应该位于何处,只是说明它应该位于默认位置或编译器命令行中指定的位置。这些位置的名称通常包含include。Unix 中的默认包含目录是/usr/include;编译器总是会在那里查找,除非你明确告诉它不查找。当然,如果包含文件位于默认位置,你不太可能看到上面的错误,那么我们来看看如何让编译器查找其他包含目录。
例如,假设你在/usr/junk/include中找到了notfound.h。要告诉编译器将该目录添加到搜索路径中,请使用-I选项:
$ **cc -c -I/usr/junk/include badinclude.c**
现在,编译器应该不再在badinclude.c中遇到引用头文件的代码行错误。
你还应该注意使用双引号(" ")而不是尖括号(< >)的包含方式,例如:
#include "myheader.h"
双引号意味着头文件不在系统包含目录中,通常表示包含的文件与源文件位于同一目录。如果你遇到双引号问题,你可能正在尝试编译不完整的源代码。
C 预处理器
事实证明,C 编译器并不实际执行查找这些包含文件的工作。这个任务由C 预处理器完成,它是编译器在解析实际程序之前对源代码进行处理的程序。预处理器将源代码重写成编译器理解的形式;它是一个使源代码更易读(并提供快捷方式)的工具。
源代码中的预处理命令称为指令,并且它们以#字符开头。有三种基本类型的指令:
-
包含文件
#include指令指示预处理器包含整个文件。请注意,编译器的-I标志实际上是一个选项,它使得预处理器在指定目录中查找包含文件,就像你在前面的章节中看到的那样。 -
宏定义 像
#define BLAH something这样的行告诉预处理器将something替换为源代码中所有出现的BLAH。惯例规定宏应全大写,但程序员有时会使用名称看起来像函数和变量的宏(偶尔这会引起一堆麻烦,许多程序员乐于滥用预处理器)。 -
条件语句 你可以使用
#ifdef、#if和#endif标记出某些代码块。#ifdefMACRO指令检查预处理宏MACRO是否已定义,#ifcondition测试condition是否非零。对于这两个指令,如果紧随“if”语句的条件为假,预处理器不会将#if和下一个#endif之间的任何程序文本传递给编译器。如果你计划查看任何 C 代码,最好习惯这个。让我们看一个条件指令的例子。当预处理器看到以下代码时,它会检查宏
DEBUG是否已定义,如果已定义,则将包含fprintf()的行传递给编译器。否则,预处理器跳过这一行,继续处理#endif后面的文件:#ifdef DEBUG fprintf(stderr, "This is a debugging message.\n"); #endif
在 Unix 中,C 预处理器的名称是 cpp,但你也可以通过 gcc -E 来运行它。然而,你很少需要单独运行预处理器。
15.2 make
一个有多个源代码文件或需要特殊编译器选项的程序,在手动编译时会非常繁琐。这类问题已经存在多年,解决这个问题的传统 Unix 编译管理工具叫做 make。如果你正在运行一个 Unix 系统,你应该了解一些关于 make 的知识,因为系统工具有时依赖于 make 来操作。不过,本章内容仅仅是冰山一角。关于 make 的书籍有很多,比如 Robert Mecklenburg 所著的 Managing Projects with GNU Make 第三版(O’Reilly, 2005)。此外,大多数 Linux 软件包都是通过围绕 make 或类似工具构建的。世上有许多构建系统;我们将在第十六章了解一个名为 autotools 的系统。
make 是一个庞大的系统,但它并不难理解其工作原理。当你看到一个名为 Makefile 或 makefile 的文件时,你就知道你正在使用 make。(试试运行 make 看看能否构建出任何东西。)
make 的基本思想是 目标,即你希望实现的目标。目标可以是一个文件(如 .o 文件、可执行文件等)或一个标签。此外,一些目标依赖于其他目标;例如,在链接可执行文件之前,你需要一个完整的 .o 文件集。这些要求被称为 依赖关系。
要构建一个目标,make 遵循一个 规则,例如指定如何从 .c 源文件生成 .o 对象文件的规则。make 已经知道了几个规则,但你可以自定义它们来创建你自己的规则。
15.2.1 一个示例 Makefile
基于 15.1.1 节中的示例文件,下面这个非常简单的 Makefile 从 aux.c 和 main.c 构建一个名为 myprog 的程序:
1 # object files
2 OBJS=aux.o main.o
3 all: 4myprog
myprog: 5$(OBJS)
6$(CC) -o myprog $(OBJS)
这个 Makefile 1 的第一行中的 # 表示注释。
下一行仅仅是一个宏定义,它将 OBJS 变量设置为两个对象文件名 2。这在后面会很重要。目前,注意如何定义宏以及如何在后面引用它($(OBJS))。
Makefile 中的下一个项目包含其第一个目标,all 3。第一个目标总是默认目标,也就是当你在命令行中单独运行 make 时,make 想要构建的目标。
用来构建目标的规则位于冒号后面。对于all,这个 Makefile 表示需要满足名为myprog的目标 4。这是文件中的第一个依赖关系;all依赖于myprog。注意,myprog可以是一个实际文件,也可以是另一个规则的目标。在这种情况下,它既是(all的规则也是OBJS的目标)。
为了构建myprog,这个 Makefile 在依赖关系 5 中使用了宏$(OBJS)。这个宏展开为aux.o和main.o,表明myprog依赖于这两个文件(它们必须是实际的文件,因为在 Makefile 中没有任何与这些名称匹配的目标)。
这个 Makefile 假定你在同一目录下有两个 C 源文件,分别是aux.c和main.c。运行make时,将输出以下内容,显示make正在运行的命令:
$ **make**
cc -c -o aux.o aux.c
cc -c -o main.o main.c
cc -o myprog aux.o main.o
依赖关系的图示见图 15-1。
15.2.2 内置规则
make是如何知道从aux.c到aux.o的转换过程的呢?毕竟,aux.c并没有出现在 Makefile 中。答案是make有一些内置规则可以遵循。它知道当你想要一个.o文件时,要查找一个.c文件,而且它知道如何在那个.c文件上运行cc -c,从而达到创建.o文件的目标。

图 15-1:Makefile 依赖关系
15.2.3 最终程序构建
到达myprog的最后一步有点棘手,但概念相当清晰。在你拥有$(OBJS)中的两个目标文件之后,可以按照以下行运行 C 编译器(其中$(CC)展开为编译器名称):
$(CC) -o myprog $(OBJS)
如前所述,$(CC)前的空白是一个制表符。你必须在任何系统命令前插入一个制表符,并且它必须单独占一行。
注意:
Makefile:7: *** missing separator. Stop.
这样的错误意味着 Makefile 有问题。制表符是分隔符,如果没有分隔符或有其他干扰,你将看到此错误。
15.2.4 依赖关系更新
最后一条需要了解的make基本概念是,通常情况下,目标是让目标与其依赖关系保持同步。此外,它被设计为只执行必要的最小步骤,这可以节省大量时间。如果你连续输入两次make命令,对于前面的例子,第一次命令构建了myprog,但第二次则输出以下内容:
make: Nothing to be done for 'all'.
第二次运行时,make查看了它的规则并发现myprog已经存在,所以它没有再次构建myprog,因为自上次构建以来,依赖关系没有发生变化。要进行此实验,请执行以下操作:
-
运行
touch aux.c。 -
再次运行
make。这一次,make判断出aux.c比目录中已有的aux.o更新,因此它重新编译了aux.o。 -
myprog依赖于aux.o,而现在aux.o比已有的myprog更新,因此make必须重新创建myprog。
这种类型的连锁反应是非常典型的。
15.2.5 命令行参数和选项
如果你了解 make 的命令行参数和选项的工作原理,你可以从中获得极大的效益。
最有用的选项之一是在命令行上指定单个目标。对于前面的 Makefile,如果你只想要 aux.o 文件,可以运行 make aux.o。
你还可以在命令行上定义宏。例如,要使用 clang 编译器,可以尝试:
$ **make CC=clang**
在这里,make 使用你定义的 CC,而不是默认的编译器 cc。命令行宏在测试预处理器定义和库时特别有用,尤其是在我们稍后讨论的 CFLAGS 和 LDFLAGS 宏中。
事实上,你甚至不需要 Makefile 就能运行 make。如果内置的 make 规则匹配目标,你可以直接让 make 尝试创建该目标。例如,如果你有一个名为 blah.c 的简单程序源代码,可以尝试运行 make blah。make 执行的过程如下:
$ **make blah**
cc blah.o -o blah
这种使用 make 的方法仅适用于最基础的 C 程序;如果你的程序需要库或特殊的包含目录,你可能应该编写一个 Makefile。当你处理像 Fortran、Lex 或 Yacc 这样的工具,并且不知道编译器或工具的工作方式时,运行没有 Makefile 的 make 实际上最为有用。为什么不让 make 为你尝试弄清楚呢?即使 make 未能创建目标,它也很可能给你一个相当不错的提示,帮助你了解如何使用该工具。
有两个 make 选项从其他选项中脱颖而出:
-
-n打印构建所需的命令,但阻止make实际运行任何命令。 -
-ffile告诉make从file读取,而不是从Makefile或makefile读取。
15.2.6 标准宏和变量
make 有许多特殊的宏和变量。很难区分宏和变量之间的区别,但在这里,“宏”一词用于表示在 make 开始构建目标后通常不会改变的内容。
正如你之前所看到的,你可以在 Makefile 开始时设置宏。这些是最常见的宏:
-
CFLAGSC 编译器选项。在从 .c 文件创建目标代码时,make会将其作为参数传递给编译器。 -
LDFLAGS类似于CFLAGS,但这些选项是用于链接器,在从目标代码创建可执行文件时使用。 -
LDLIBS如果你使用了LDFLAGS,但不想将库名称选项与搜索路径合并,可以将库名称选项放入此文件中。 -
CCC 编译器。默认是cc。 -
CPPFLAGSC 预处理器 选项。当make运行 C 预处理器时,它会将此宏的扩展作为参数传递。 -
CXXFLAGSGNUmake用于 C++ 编译器标志。
一个 make 变量 会随着你构建目标而变化。变量以美元符号($)开头。有几种方法可以设置变量,但一些最常见的变量会在目标规则中自动设置。你可能会看到如下内容:
-
$@当处于规则内部时,此变量扩展为当前目标。 -
$<当在规则中时,这个变量展开为目标的第一个依赖项。 -
$*这个变量展开为当前目标的 基本名 或主干。例如,如果你正在构建 blah.o,它会展开为 blah。
下面是一个示例,展示了一种常见的模式——使用 myprog 规则从 .in 文件生成 .out 文件:
.SUFFIXES: .in
.in.out: $<
myprog $< -o $*.out
你会在许多 Makefile 中遇到类似 .c.o: 的规则,它定义了一个自定义方式来运行 C 编译器生成对象文件。
在 Linux 上,最全面的 make 变量列表可以在 make 信息手册中找到。
15.2.7 常规目标
大多数开发者会在他们的 Makefile 中包含几个常见的额外目标,这些目标执行与编译相关的辅助任务:
-
cleanclean目标无处不在;执行make clean通常会指示make删除所有对象文件和可执行文件,以便你能够重新开始或打包软件。以下是myprogMakefile 的示例规则:clean: rm -f $(OBJS) myprog -
distclean通过 GNU autotools 系统创建的 Makefile 通常会包含一个distclean目标,用来删除所有非原始发行版的一部分内容,包括 Makefile 本身。你将在第十六章看到更多内容。在极少数情况下,开发者可能选择不通过这个目标删除可执行文件,而是倾向于使用类似realclean的方法。 -
install这个目标将文件和编译过的程序复制到 Makefile 认为是系统上适当的位置。这可能是危险的,所以在实际运行任何命令之前,始终先运行make -n install来查看会发生什么。 -
test或check一些开发者提供test或check目标,以确保在执行构建后,一切工作正常。 -
depend这个目标通过使用-M选项调用编译器来检查源代码,从而创建依赖关系。这个目标看起来有些不寻常,因为它通常会改变 Makefile 本身。虽然这已经不再是常见做法,但如果你遇到一些指示要求你使用这个规则,请务必遵循。 -
all如前所述,这通常是 Makefile 中的第一个目标。你通常会看到对这个目标的引用,而不是实际的可执行文件。
15.2.8 Makefile 组织结构
尽管有许多不同的 Makefile 风格,大多数程序员还是遵循一些通用的经验法则。首先,在 Makefile 的前半部分(宏定义部分),你应该看到按包分组的库和包含文件:
MYPACKAGE_INCLUDES=-I/usr/local/include/mypackage
MYPACKAGE_LIB=-L/usr/local/lib/mypackage -lmypackage
PNG_INCLUDES=-I/usr/local/include
PNG_LIB=-L/usr/local/lib -lpng
每种类型的编译器和链接器标志通常会像这样使用宏:
CFLAGS=$(CFLAGS) $(MYPACKAGE_INCLUDES) $(PNG_INCLUDES)
LDFLAGS=$(LDFLAGS) $(MYPACKAGE_LIB) $(PNG_LIB)
对象文件通常按可执行文件进行分组。例如,假设你有一个包,它创建名为 boring 和 trite 的可执行文件。每个文件都有自己的 .c 源文件,并且需要 util.c 中的代码。你可能会看到类似这样的内容:
UTIL_OBJS=util.o
BORING_OBJS=$(UTIL_OBJS) boring.o
TRITE_OBJS=$(UTIL_OBJS) trite.o
PROGS=boring trite
Makefile 的其余部分可能如下所示:
all: $(PROGS)
boring: $(BORING_OBJS)
$(CC) -o $@ $(BORING_OBJS) $(LDFLAGS)
trite: $(TRITE_OBJS)
$(CC) -o $@ $(TRITE_OBJS) $(LDFLAGS)
你可以将这两个可执行目标合并为一个规则,但通常不推荐这样做,因为你将很难将规则移动到另一个 Makefile 中,删除可执行文件或以不同方式分组可执行文件。此外,依赖关系将会错误:如果你只有一个规则处理 boring 和 trite,trite 将依赖于 boring.c,而 boring 将依赖于 trite.c,并且每当你更改其中一个源文件时,make 将总是尝试重新构建这两个程序。
15.3 Lex 和 Yacc
在编译读取配置文件或命令的程序过程中,你可能会遇到 Lex 和 Yacc。这些工具是编程语言的构建模块。
-
Lex 是一个 标记化器,它将文本转换为带有标签的编号标记。GNU/Linux 版本被称为
flex。你可能需要与 Lex 一起使用-ll或-lfl链接器标志。 -
Yacc 是一个 解析器,它根据 语法 尝试读取标记。GNU 解析器是
bison;为了获得 Yacc 兼容性,请运行bison -y。你可能需要-ly链接器标志。
15.4 脚本语言
很久以前,普通的 Unix 系统管理员几乎不需要关心除了 Bourne shell 和 awk 之外的其他脚本语言。Shell 脚本(第十一章中讨论)仍然是 Unix 的重要组成部分,但 awk 在脚本领域的使用有所减少。然而,许多强大的继任者已经出现,许多系统程序实际上已经从 C 转向了脚本语言(例如 whois 程序的合理版本)。让我们来看看一些脚本语言的基础知识。
你需要知道的关于任何脚本语言的第一件事是,脚本的第一行看起来像 Bourne shell 脚本的 shebang。例如,一个 Python 脚本是这样开始的:
#!/usr/bin/python
或者这个版本,它会运行命令路径中第一个找到的 Python 版本,而不是始终去 /usr/bin 目录:
#!/usr/bin/env python
如你在第十一章所见,任何以 #! shebang 开头的可执行文本文件都是脚本。此前缀后的路径名是脚本语言解释器的可执行文件。当 Unix 尝试运行以 #! 开头的可执行文件时,它会运行 #! 后面的程序,并将剩余的文件作为标准输入。因此,甚至这个也是脚本:
#!/usr/bin/tail -2
This program won't print this line,
but it will print this line...
and this line, too.
一个 shell 脚本的第一行经常包含最常见的基本脚本问题之一:无效的脚本语言解释器路径。例如,假设你将之前的脚本命名为 myscript。如果 tail 实际上位于 /bin 而不是 /usr/bin 目录中呢?在这种情况下,运行 myscript 会产生以下错误:
bash: ./myscript: /usr/bin/tail: bad interpreter: No such file or directory
不要指望脚本第一行中的多个参数能够正常工作。也就是说,上例中的 -2 可能有效,但如果你再添加一个参数,系统可能会将 -2 和 新的参数视为一个大参数(包括空格)。这在不同的系统之间可能有所不同;不要为了这种微不足道的事而考验你的耐心。
现在,让我们来看看一些其他的编程语言。
15.4.1 Python
Python 是一种脚本语言,拥有强大的用户群体和一系列强大的功能,如文本处理、数据库访问、网络编程和多线程。它具有强大的交互模式和非常有组织的对象模型。
Python 的可执行文件是python,通常位于/usr/bin。然而,Python 不仅仅通过命令行用于脚本编写,它在从数据分析到 Web 应用程序等各个领域都有应用。Python Distilled,由 David M. Beazley(Addison-Wesley,2021 年)所著,是入门的好书。
15.4.2 Perl
其中一种较为古老的第三方 Unix 脚本语言是 Perl。它是最初的“瑞士军刀”编程工具。尽管近年来 Perl 在许多方面被 Python 超越,但它在文本处理、转换和文件操作等方面表现尤为突出,你可能会遇到很多用 Perl 编写的工具。Learning Perl 第七版,由 Randal L. Schwartz、brian d foy 和 Tom Phoenix(O'Reilly,2016 年)编写,是一本教程式的入门书;更为全面的参考书是 Modern Perl 第四版,由 chromatic(Onyx Neon Press,2016 年)编写。
15.4.3 其他脚本语言
你可能还会遇到以下脚本语言:
-
PHP 这是一种超文本处理语言,常见于动态网页脚本中。有些人也会使用 PHP 来编写独立脚本。PHP 的官网地址是
www.php.net/。 -
Ruby 面向对象的狂热者和许多 Web 开发者喜欢使用这种语言进行编程 (
www.ruby-lang.org/)。 -
JavaScript 这种语言主要用于网页浏览器中操控动态内容。由于其许多缺陷,大多数经验丰富的程序员不推荐将其作为独立脚本语言,但在进行 Web 编程时几乎无法避免。近年来,一种名为 Node.js 的实现已经在服务器端编程和脚本中变得越来越普遍;它的可执行文件名是
node。 -
Emacs Lisp 这是一种 Lisp 编程语言的变种,用于 Emacs 文本编辑器。
-
MATLAB,Octave MATLAB 是一种商业矩阵和数学编程语言及库。Octave 是一个非常相似的自由软件项目。
-
R 这是一种流行的免费统计分析语言。更多信息请访问
www.r-project.org/和 The Art of R Programming,作者 Norman Matloff(No Starch Press,2011 年)。 -
Mathematica 这是一种带有库的商业数学编程语言。
-
m4 这是一种宏处理语言,通常只出现在 GNU autotools 中。
-
Tcl Tcl(工具命令语言)是一种简单的脚本语言,通常与 Tk 图形用户界面工具包和 Expect 自动化工具相关联。尽管 Tcl 不再像以前那样被广泛使用,但它的强大功能不容小觑。许多资深开发者仍偏爱 Tk,尤其是在其嵌入式功能方面。更多信息请访问
www.tcl.tk/。
15.5 Java
Java 是一种类似于 C 的编译语言,具有更简洁的语法,并且对面向对象编程提供了强大的支持。它在 Unix 系统中有一些特定的应用领域。例如,它常用于作为 Web 应用程序环境,并且在特定应用程序中非常流行。Android 应用通常使用 Java 编写。虽然在典型的 Linux 桌面上不常见 Java,但你应该了解 Java 是如何工作的,至少要了解如何使用它编写独立应用程序。
有两种类型的 Java 编译器:用于生成你系统的机器代码的本地编译器(类似于 C 编译器),以及用于字节码解释器(有时称为虚拟机,它与第十七章中描述的超管提供的虚拟机不同)的字节码编译器。你几乎总是会在 Linux 上遇到字节码。
Java 字节码文件的扩展名是.class。Java 运行时环境(JRE)包含你运行 Java 字节码所需的所有程序。要运行字节码文件,请使用:
$ **java** `file`**.class**
你也可能会遇到以.jar结尾的字节码文件,它们是已归档的.class文件的集合。要运行.jar文件,使用以下语法:
$ **java -jar** `file`**.jar**
有时你需要设置JAVA_HOME环境变量为你的 Java 安装目录。如果你运气不太好,你可能需要使用CLASSPATH来包含程序期望的类所在的目录。这是一个由冒号分隔的目录集,类似于用于可执行文件的PATH变量。
如果你需要将一个.java文件编译成字节码,你需要 Java 开发工具包(JDK)。你可以使用javac编译器从 JDK 中创建一些.class文件:
$ **javac** `file`**.java**
JDK 还附带了jar程序,它可以创建和解压.jar文件。它的工作方式类似于tar。
15.6 展望未来:编译软件包
编译器和脚本语言的世界是庞大且不断扩展的。到目前为止,像 Go(golang)和 Rust 这样的新编译语言在应用程序和系统编程中越来越受欢迎。
LLVM 编译器基础设施集(llvm.org/)极大地简化了编译器开发。如果你对如何设计和实现编译器感兴趣,有两本很好的书籍:《编译原理:技术与工具》第 2 版,作者 Alfred V. Aho 等人(Addison-Wesley,2006 年)和《现代编译器设计》第 2 版,作者 Dick Grune 等人(Springer,2012 年)。对于脚本语言的开发,通常最好查找在线资源,因为实现方式差异较大。
现在你已经了解了系统上编程工具的基本知识,接下来你可以看到它们能做什么。下一章将介绍如何从源代码在 Linux 上构建软件包。
第十六章:从 C 源代码编译软件的简介

大多数非专有的第三方 Unix 软件包以源代码形式提供,允许你构建和安装。这样做的一个原因是,Unix(以及 Linux 本身)有太多不同的版本和架构,难以为所有可能的平台组合分发二进制软件包。另一个至少同样重要的原因是,Unix 社区广泛分发源代码,鼓励用户为软件贡献错误修复和新功能,这也赋予了 开源 这个词意义。
你可以通过源代码获取 Linux 系统上几乎所有看到的内容——从内核和 C 库到网页浏览器。实际上,你甚至可以通过(重新)安装系统的一部分源代码来更新和增强你的整个系统。然而,除非你非常喜欢这个过程或者有其他原因,否则你可能不应该通过从源代码安装所有内容来更新你的机器。
Linux 发行版通常提供方便的方式来更新系统的核心部分,如 /bin 中的程序,而发行版的一个特别重要的特点是它们通常能非常快速地修复安全问题。但不要指望你的发行版会为你提供所有内容。以下是你可能需要自己安装某些软件包的几个原因:
-
控制配置选项。
-
可以将软件安装到你喜欢的任何位置。你甚至可以安装同一个软件包的多个不同版本。
-
控制你安装的版本。不同的发行版并不总是与所有软件包的最新版本保持同步,尤其是软件包的附加组件(例如 Python 库)。
-
更好地理解某个软件包是如何工作的。
16.1 软件构建系统
在 Linux 上有许多编程环境,从传统的 C 语言到解释型脚本语言如 Python。每种环境通常都有至少一个独特的系统用于构建和安装软件包,除此之外还有 Linux 发行版提供的工具。
本章我们将介绍如何使用其中一个构建系统——由 GNU autotools 套件生成的配置脚本,来编译和安装 C 源代码。这个系统通常被认为是稳定的,许多基本的 Linux 工具都使用它。由于它是基于现有工具(如 make)构建的,因此当你看到它的实际操作后,你将能够将你的知识转移到其他构建系统上。
从 C 源代码安装软件包通常涉及以下步骤:
-
解压源代码归档。
-
配置软件包。
-
运行
make或其他构建命令来构建程序。 -
运行
make install或发行版特定的安装命令来安装软件包。
16.2 解压 C 源代码包
包的源代码分发通常是作为.tar.gz、.tar.bz2或.tar.xz文件提供的,你应该按照第 2.18 节中描述的方式解压该文件。然而,在解压之前,使用tar tvf或tar ztvf验证归档的内容,因为有些包不会在解压归档的目录中创建自己的子目录。
这样的输出意味着该包可能适合解压:
package-1.23/Makefile.in
package-1.23/README
package-1.23/main.c
package-1.23/bar.c
--`snip`--
然而,你可能会发现并非所有文件都位于一个公共目录中(就像前面的例子中的package-1.23):
Makefile
README
main.c
--`snip`--
解压像这样的归档文件可能会在当前目录中留下很多杂乱的文件。为了避免这种情况,在解压归档内容之前,创建一个新目录并cd到该目录。
最后,注意包含绝对路径名的文件包,例如下面这样:
/etc/passwd
/etc/inetd.conf
你可能不会遇到类似这样的情况,但如果遇到,请从系统中删除该归档文件。它可能包含木马或其他恶意代码。
一旦你提取了源归档的内容,并且面前有一堆文件,试着对包的内容有个大致了解。特别是,查找名为README和INSTALL的文件。始终先查看任何README文件,因为它们通常包含包的描述、简短手册、安装提示和其他有用的信息。许多包还附带INSTALL文件,包含如何编译和安装该包的说明。特别注意特殊的编译器选项和定义。
除了README和INSTALL文件,你还会找到其他文件,这些文件大致可以分为三类:
-
与
make系统相关的文件,例如Makefile、Makefile.in、configure和CMakeLists.txt。一些非常旧的包附带一个 Makefile,你可能需要修改它,但大多数包使用配置工具,如 GNU autoconf 或 CMake。它们附带一个脚本或配置文件(例如configure或CMakeLists.txt),帮助你根据系统设置和配置选项从Makefile.in生成 Makefile。 -
以.c、.h或.cc结尾的源代码文件。C 源代码文件可能出现在包目录的任何位置。C++源代码文件通常具有.cc、.C或.cxx后缀。
-
以.o结尾的目标文件或二进制文件。通常,源代码分发包中没有目标文件,但在某些罕见情况下,当包的维护者无法发布某些源代码时,你可能会找到目标文件,这时你需要做一些特别的操作才能使用这些目标文件。在大多数情况下,源代码分发包中的目标文件(或二进制可执行文件)意味着包的组织不够规范,因此你应该运行
make clean,以确保进行一次全新的编译。
16.3 GNU Autoconf
尽管 C 源代码通常是相当可移植的,但每个平台的差异使得大多数软件包无法使用单一的 Makefile 进行编译。对此问题的早期解决方案是为每个操作系统提供单独的 Makefile,或者提供一个易于修改的 Makefile。这个方法演变成了基于对构建软件包所使用系统的分析,自动生成 Makefile 的脚本。
GNU autoconf 是一个流行的自动生成 Makefile 的系统。使用该系统的包会附带名为configure、Makefile.in和config.h.in的文件。.in文件是模板;其目的是运行configure脚本来发现系统的特性,然后在.in文件中进行替换,从而生成真实的构建文件。对最终用户来说,这很简单;只需运行configure来从Makefile.in生成 Makefile:
$ **./configure**
在脚本检查你的系统是否满足前提条件时,你应该会看到很多诊断输出。如果一切顺利,configure将生成一个或多个 Makefile 和一个config.h文件,以及一个缓存文件(config.cache),以便它在以后不需要再次运行某些测试。
现在你可以运行make来编译软件包。成功的configure步骤并不意味着make步骤一定会成功,但成功的几率相当大。(有关解决配置和编译失败的提示,请参见第 16.6 节。)
让我们亲自体验一下这个过程。
16.3.1 一个 Autoconf 示例
在讨论如何更改 autoconf 的行为之前,让我们先看一个简单的示例,以便了解你可以期待什么。你将会在自己的主目录中安装 GNU coreutils 包(以确保不会破坏系统)。从ftp.gnu.org/gnu/coreutils/获取该包(最新版本通常是最好的),解压缩它,进入其目录并像下面这样进行配置:
$ ./**configure --prefix=$HOME/mycoreutils**
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
--`snip`--
config.status: executing po-directories commands
config.status: creating po/POTFILES
config.status: creating po/Makefile
现在运行make:
$ **make**
GEN lib/alloca.h
GEN lib/c++defs.h
--`snip`--
make[2]: Leaving directory '/home/juser/coreutils-8.32/gnulib-tests'
make[1]: Leaving directory '/home/juser/coreutils-8.32'
接下来,尝试运行你刚刚创建的其中一个可执行文件,例如./src/ls,并尝试运行make check以对软件包进行一系列测试。(这可能需要一些时间,但看看结果还是挺有趣的。)
最后,你准备好安装软件包了。首先使用make -n进行干运行,查看make install会做什么,而不实际执行安装:
$ **make -n install**
浏览输出内容,如果没有什么奇怪的地方(例如软件包安装到mycoreutils目录以外的位置),就可以正式安装了:
$ **make install**
现在,你应该在你的主目录下拥有一个名为mycoreutils的子目录,其中包含bin、share和其他子目录。查看一下bin中的一些程序(你刚刚构建了许多你在第二章学习的基础工具)。最后,由于你已经将mycoreutils目录配置为独立于你系统的其他部分,你可以完全删除它,而不必担心会造成损坏。
16.3.2 使用打包工具进行安装
在大多数发行版中,可以将新软件安装为一个包,之后可以使用发行版的打包工具进行维护。基于 Debian 的发行版,如 Ubuntu,可能是最容易的;你不是运行简单的make install,而是使用checkinstall工具来安装软件包,如下所示:
# checkinstall make install
运行此命令将显示与即将构建的包相关的设置,并提供更改它们的机会。安装时,checkinstall会跟踪所有要安装到系统上的文件,并将它们放入.deb文件中。然后你可以使用dpkg安装(或移除)新包。
创建 RPM 包稍微复杂一些,因为你必须首先为你的包创建一个目录树。你可以使用rpmdev-setuptree命令来完成这一步;完成后,你可以使用rpmbuild工具继续进行其余的步骤。最好根据在线教程进行此过程。
16.3.3 配置脚本选项
你刚刚看到的是configure脚本最有用的选项之一:使用--prefix来指定安装目录。默认情况下,自动生成的 Makefile 中的install目标使用/usr/local作为prefix—即,二进制程序放在/usr/local/bin,库文件放在/usr/local/lib,以此类推。你通常会想要更改这个前缀,方法如下:
$ **./configure --prefix=**`new_prefix`
大多数版本的configure都有一个--help选项,可以列出其他配置选项。不幸的是,列表通常很长,有时很难弄清楚哪些选项可能重要,因此这里列出了一些基本的选项:
-
--bindir=``directory将可执行文件安装到directory中。 -
--sbindir=``directory将系统可执行文件安装到directory中。 -
--libdir=``directory将库安装到directory。 -
--disable-shared防止构建共享库。根据库的不同,这可以避免后续出现麻烦(参见第 15.1.3 节)。 -
--with-``package``=``directory告诉configure,package位于directory中。当必要的库位于非标准位置时,这个选项非常有用。不幸的是,并非所有的configure脚本都支持这种类型的选项,而且确定确切的语法可能很困难。
16.3.4 环境变量
你可以通过环境变量影响configure,因为configure脚本会将这些变量传递给make。最重要的环境变量包括CPPFLAGS、CFLAGS和LDFLAGS。但需要注意的是,configure对环境变量非常挑剔。例如,你通常应该使用CPPFLAGS而不是CFLAGS来指定头文件目录,因为configure经常在独立于编译器的情况下运行预处理器。
在bash中,发送环境变量给configure的最简单方法是将变量赋值放在命令行的./configure之前。例如,要为预处理器定义一个DEBUG宏,可以使用以下命令:
**$ CPPFLAGS=-DDEBUG ./configure**
你还可以将一个变量作为选项传递给configure;例如:
**$ ./configure CPPFLAGS=-DDEBUG**
环境变量在configure不知道在哪里查找第三方包含文件和库时特别有用。例如,要让预处理器在include_dir中查找,运行以下命令:
$ **CPPFLAGS=-I**`include_dir` **./configure**
如第 15.2.6 节所示,要让链接器在lib_dir中查找,使用以下命令:
$ **LDFLAGS=-L**`lib_dir` **./configure**
如果lib_dir中包含共享库(参见第 15.1.3 节),上面的命令可能无法设置运行时动态链接器路径。在这种情况下,除了使用-L外,还需要使用-rpath链接器选项:
$ **LDFLAGS="-L**`lib_dir` **-Wl,-rpath=**`lib_dir`**" ./configure**
设置变量时要小心。一个小小的错误可能会让编译器出错,导致configure失败。例如,假设你忘记了-,如这里所示:
$ **CPPFLAGS=I**`include_dir` **./configure**
这将产生如下错误:
configure: error: C compiler cannot create executables
See 'config.log' for more details
从这个失败的尝试生成的config.log中查看,结果如下:
configure:5037: checking whether the C compiler works
configure:5059: gcc Iinclude_dir conftest.c >&5
gcc: error: Iinclude_dir: No such file or directory
configure:5063: $? = 1
configure:5101: result: no
16.3.5 Autoconf 目标
一旦你让configure正常工作,你会发现它生成的 Makefile 除了标准的all和install之外,还有许多有用的目标:
-
make clean如第十五章所述,这个命令会删除所有目标文件、可执行文件和库文件。 -
make distclean这与make clean类似,除了它会删除所有自动生成的文件,包括 Makefile、config.h、config.log等。其目的是让源代码树在运行make distclean后看起来像是一个刚解压的分发包。 -
make check一些软件包附带一系列测试,用于验证编译后的程序是否正常工作;make check命令会运行这些测试。 -
make install-strip这与make install类似,除了它在安装时会去除可执行文件和库中的符号表和其他调试信息。去除调试信息的二进制文件占用更少的空间。
16.3.6 Autoconf 日志文件
如果在配置过程中出了问题,且原因不明显,你可以查看config.log以找到问题所在。不幸的是,config.log通常是一个非常大的文件,这会让你很难找到问题的具体源头。
在这种情况下,通常的做法是转到config.log的最末尾(例如,在less中输入大写的 G),然后向上翻页,直到你看到问题。然而,日志的末尾依然会有很多内容,因为configure会将整个环境输出在那里,包括输出变量、缓存变量和其他定义。因此,最好不要直接向上翻页,而是先到达文件末尾,再向后搜索一个字符串,比如for more details或其他靠近configure失败输出末尾的文本片段。(记住,你可以在less中用?命令进行反向搜索。)很有可能,错误就在你搜索到的内容上方。
16.3.7 pkg-config
系统中大量的第三方库意味着将它们都放在一个公共位置可能会显得杂乱无章。然而,使用单独的前缀安装每个库可能会导致构建需要这些第三方库的包时出现问题。例如,如果你想编译 OpenSSH,你需要 OpenSSL 库。你如何告诉 OpenSSH 配置过程 OpenSSL 库的位置以及需要哪些库?
现在,许多库不仅使用pkg-config程序来公开它们的头文件和库的位置,还指定编译和链接程序所需的确切标志。语法如下:
$ **pkg-config** `options package1 package2 ...`
例如,要查找流行压缩库所需的库,你可以运行以下命令:
$ **pkg-config --libs zlib**
输出应该类似于以下内容:
-lz
要查看pkg-config知道的所有库,包括每个库的简要描述,请运行以下命令:
$ **pkg-config --list-all**
pkg-config 如何工作
如果你深入了解幕后,你会发现pkg-config通过读取以.pc结尾的配置文件来查找包信息。例如,这是 Ubuntu 系统上 OpenSSL 套接字库的openssl.pc文件(位于/usr/lib/x86_64-linux-gnu/pkgconfig):
prefix=/usr
exec_prefix=${prefix}
libdir=${exec_prefix}/lib/x86_64-linux-gnu
includedir=${prefix}/include
Name: OpenSSL
Description: Secure Sockets Layer and cryptography libraries and tools
Version: 1.1.1f
Requires:
Libs: -L${libdir} -lssl -lcrypto
Libs.private: -ldl -lz
Cflags: -I${includedir} exec_prefix=${prefix}
你可以修改这个文件,例如,通过将-Wl,-rpath=${libdir}添加到库标志中,以设置运行时库搜索路径。然而,更大的问题是pkg-config最初是如何找到.pc文件的。默认情况下,pkg-config会在其安装前缀的lib/pkgconfig目录中查找。例如,使用/usr/local前缀安装的pkg-config会在/usr/local/lib/pkgconfig目录中查找。
如何在非标准位置安装 pkg-config 文件
不幸的是,默认情况下,pkg-config不会读取其安装前缀外的任何.pc文件。这意味着位于非标准位置的.pc文件,如/opt/openssl/lib/pkgconfig/openssl.pc,将无法被任何标准的pkg-config安装所访问。有两种基本方法可以使.pc文件在pkg-config安装前缀外可用:
-
从实际的.pc文件创建符号链接(或复制)到中央pkgconfig目录。
-
设置
PKG_CONFIG_PATH环境变量,将任何额外的pkgconfig目录包括在内。该策略在系统范围内效果不佳。
16.4 安装实践
知道如何构建和安装软件是好的,但知道何时和在哪里安装自己的包更有用。Linux 发行版尽量在安装时包含尽可能多的软件,因此你应该始终检查是否自己安装包会更好。自己安装的优点如下:
-
你可以自定义包的默认设置。
-
安装包时,你通常会更清楚如何使用它。
-
你可以控制你运行的版本。
-
备份自定义包更容易。
-
在网络中分发自安装的包更容易(前提是架构一致且安装位置相对隔离)。
以下是缺点:
-
如果你要安装的包已经安装在系统中,你可能会覆盖重要文件,导致问题。通过使用稍后会介绍的 /usr/local 安装前缀可以避免这种情况。即使包没有安装在你的系统上,你也应该检查分发包是否可用。如果有,你需要记住这一点,以防你以后不小心安装了分发包。
-
这需要时间。
-
自定义包不会自动升级。分发包会保持大多数包的最新状态,且不需要你做太多工作。对于与网络交互的包,这是一个特别需要关注的问题,因为你希望确保始终拥有最新的安全更新。
-
如果你实际上并不使用这个包,那么你是在浪费时间。
-
存在错误配置包的潜在风险。
除非你正在构建一个非常自定义的系统,否则安装像本章早些时候构建的 coreutils 包(ls、cat 等)等包没什么意义。另一方面,如果你对像 Apache 这样的网络服务器有重要兴趣,最好的方式是自己安装这些服务器,从而获得完全的控制权。
16.4.1 安装位置
GNU autoconf 和许多其他包的默认前缀是 /usr/local,这是本地安装软件的传统目录。操作系统升级时会忽略 /usr/local,因此在操作系统升级过程中不会丢失那里安装的任何东西,对于小型本地软件安装,/usr/local 也足够了。唯一的问题是,如果你安装了大量自定义软件,可能会变得一团糟。成千上万的奇怪小文件可能会进入 /usr/local 目录结构,你可能根本不知道这些文件来自哪里。
如果事情开始变得难以管理,你应该按照第 16.3.2 节中描述的方法创建你自己的包。
16.5 应用补丁
大多数软件源代码的更改作为开发者在线源代码版本的分支提供(例如 Git 仓库)。然而,偶尔你可能会得到一个需要应用于源代码的 补丁,用于修复错误或添加新功能。你也可能会看到 diff 这个术语作为补丁的同义词,因为 diff 程序生成补丁。
补丁的开始看起来像这样:
--- src/file.c.orig 2015-07-17 14:29:12.000000000 +0100
+++ src/file.c 2015-09-18 10:22:17.000000000 +0100
@@ -2,16 +2,12 @@
补丁通常包含对多个文件的更改。查找补丁中的三个连字符(---)以查看哪些文件有更改,并始终查看补丁的开头以确定所需的工作目录。注意,前面的示例提到的是 src/file.c。因此,在应用补丁之前,你应该切换到包含 src 的目录,而不是直接切换到 src 目录。
要应用补丁,运行patch命令:
$ **patch -p0 <** `patch_file`
如果一切顺利,patch会顺利退出,更新一组文件。然而,patch可能会问你这个问题:
File to patch:
这通常意味着你不在正确的目录中,但也可能表示你的源代码与补丁中的源代码不匹配。在这种情况下,你可能就没有好运了。即使你能识别出一些文件需要打补丁,其他文件也无法正确更新,最终你将得到无法编译的源代码。
在某些情况下,你可能会遇到一个补丁,引用了类似这样的包版本:
--- package-3.42/src/file.c.orig 2015-07-17 14:29:12.000000000 +0100
+++ package-3.42/src/file.c 2015-09-18 10:22:17.000000000 +0100
如果你有一个稍微不同的版本号(或者只是更改了目录名称),你可以告诉patch去除路径中的前导部分。例如,假设你在包含src的目录中(如前所述)。要告诉patch忽略路径中的package-3.42/部分(即去除一个前导路径组件),使用-p1:
$ **patch -p1 <** `patch_file`
16.6 编译和安装故障排除
如果你理解编译器错误、编译器警告、链接器错误以及共享库问题的区别(如第十五章所述),你就不太会遇到在构建软件时出现的许多故障。此部分涵盖了一些常见问题。虽然在使用 autoconf 构建时不太可能遇到这些问题,但知道它们的表现方式总是有益的。
在讨论具体细节之前,确保你能读取某些类型的make输出。了解错误和被忽略的错误之间的区别非常重要。以下是一个需要调查的真实错误:
make: *** [`target`] Error 1
然而,一些 Makefile 知道某些错误条件可能会发生,但它们知道这些错误是无害的。你通常可以忽略类似的消息:
make: *** [`target`] Error 1 (ignored)
此外,GNU make在大型包中通常会多次调用自身,每次make实例的错误信息中会标记为[``N``],其中N是一个数字。你通常可以通过查看编译器错误信息后直接跟随的make错误来快速找到问题。例如:
`compiler error message involving` file.c
make[3]: *** [file.o] Error 1
make[3]: Leaving directory '/home/src/package-5.0/src'
make[2]: *** [all] Error 2
make[2]: Leaving directory '/home/src/package-5.0/src'
make[1]: *** [all-recursive] Error 1
make[1]: Leaving directory '/home/src/package-5.0/'
make: *** [all] Error 2
这里的前三行提供了你需要的信息。问题集中在file.c,它位于/home/src/package-5.0/src。不幸的是,输出信息过多,可能很难找到重要的细节。学习如何过滤掉后续的make错误对于帮助你找到真正的原因非常有用。
16.6.1 具体错误
以下是你可能遇到的一些常见构建错误。
问题
-
编译器错误信息:
src.c:22: conflicting types for '`item`' /usr/include/`file`.h:47: previous declaration of '`item`'
解释和修复
- 程序员在src.c的第 22 行错误地重新声明了
item。你通常可以通过删除有问题的那一行(添加注释、#ifdef,或任何有效的方法)来修复此问题。
问题
-
编译器错误信息:
src.c:37: 'time_t' undeclared (first use this function) --`snip`-- src.c:37: parse error before '...'
解释和修复
-
程序员忘记了一个关键的头文件。手册页是查找缺失头文件的最佳方法。首先查看出错的行(在本例中是src.c中的第 37 行)。它可能是如下的变量声明:
time_t v1;在程序中向前搜索
v1,查看它在函数调用中的使用情况。例如:v1 = time(NULL);现在运行
man 2 time或man 3 time,查找名为time()的系统和库调用。在这种情况下,第二部分手册页包含你需要的信息:SYNOPSIS #include <time.h> time_t time(time_t *t);这意味着
time()需要time.h。在src.c的开头添加#include <time.h>并重新尝试。
问题
-
编译器(预处理器)错误信息:
src.c:4: `pkg`.h: No such file or directory (long list of errors follows)
解释与修复
-
编译器对src.c运行了 C 预处理器,但无法找到pkg.h包含文件。源代码可能依赖于你需要安装的库,或者你可能只需要提供给编译器非标准的包含路径。通常,你只需要向 C 预处理器标志(
CPPFLAGS)中添加-I包含路径选项。(请记住,你可能还需要一个-L链接器标志来配合包含文件一起使用。)如果看起来不是缺少库,可能是你正在尝试为一个此源代码不支持的操作系统进行编译。请检查 Makefile 和README文件,了解有关平台的详细信息。
如果你使用的是基于 Debian 的发行版,尝试在头文件名上运行
apt-file命令:$ `apt-file search` `pkg.h`这可能找到你需要的开发包。对于使用
yum的发行版,你可以尝试改为使用以下命令:$ `yum provides */``pkg.h`
问题
-
make错误信息:make: `prog:` Command not found
解释与修复
-
要构建该软件包,你的系统中需要安装
prog。如果prog类似于cc、gcc或ld,说明你的系统没有安装开发工具。另一方面,如果你认为prog已经安装在系统上,尝试修改 Makefile,指定prog的完整路径名。在少数情况下,由于源代码配置不当,
make构建了prog后立即使用prog,假设当前目录(.)在你的命令路径中。如果你的$PATH没有包含当前目录,你可以编辑 Makefile,将prog改为./prog。或者,你可以临时将.添加到你的路径中。
16.7 展望未来
我们仅触及了构建软件的基础知识。在你掌握了自己的构建方法后,尝试以下内容:
-
学习如何使用除 autoconf 以外的构建系统,如 CMake 和 SCons。
-
为你的软件设置构建环境。如果你在编写自己的软件,你需要选择一个构建系统并学习如何使用它。关于 GNU autoconf 打包,John Calcote 的《Autotools》第二版(No Starch Press, 2019)可以帮助你。
-
编译 Linux 内核。内核的构建系统与其他工具的构建系统完全不同。它拥有自己的配置系统,专门用于定制自己的内核和模块。尽管如此,过程相对直接,如果你理解引导加载程序是如何工作的,通常不会遇到任何问题。然而,在进行此操作时要小心;确保始终保留旧的内核,以防新内核无法启动。
-
探索特定发行版的源代码包。Linux 发行版维护自己版本的软件源代码,作为特殊的源代码包。有时你可以找到有用的补丁,扩展功能或修复其他未维护包中的问题。源代码包管理系统包括自动构建工具,如 Debian 的
debuild和基于 RPM 的mock。
构建软件通常是学习编程和软件开发的第一步。你在本章和上一章中看到的工具揭开了系统软件来源的神秘面纱。向前迈出一步,查看源代码、进行修改并创建自己的软件并不困难。
第十七章:虚拟化

在计算机系统中,虚拟这个词可能比较模糊。它主要用来表示一个中介,翻译复杂或碎片化的底层层次,提供一个可以被多个消费者使用的简化接口。考虑我们已经见过的一个例子——虚拟内存,它允许多个进程像拥有自己隔离的内存块一样访问一个大的内存池。
这个定义还是有点吓人,所以更好的方法可能是解释虚拟化的典型用途:创建隔离的环境,以便你可以让多个系统运行而不会发生冲突。
由于虚拟机在更高层次上相对容易理解,我们将在虚拟化之旅中从这里开始。然而,讨论将仍然停留在更高的层次,旨在解释你在使用虚拟机时可能遇到的众多术语,而不深入实现的细节。
我们将更深入地讨论容器的技术细节。它们是使用本书中已经介绍的技术构建的,因此你可以看到这些组件是如何组合在一起的。此外,交互式探索容器相对容易。
17.1 虚拟机
虚拟机基于与虚拟内存相同的概念,只不过是以整个机器的硬件为基础,而不仅仅是内存。在这种模型中,你可以借助软件创建一台全新的机器(处理器、内存、I/O 接口等),并在其中运行一个完整的操作系统——包括内核。这样的虚拟机更具体地被称为系统虚拟机,它已经存在了几十年。例如,IBM 的大型机通常使用系统虚拟机来创建多用户环境;反过来,用户会得到自己运行 CMS 的虚拟机,CMS 是一个简单的单用户操作系统。
你可以完全通过软件构建一个虚拟机(通常称为模拟器),或者尽可能多地利用底层硬件,就像虚拟内存中所做的那样。就我们在 Linux 中的用途而言,我们将重点关注后一种方式,因为它具有更好的性能,但需要注意的是,许多流行的模拟器支持旧的计算机和游戏系统,如 Commodore 64 和 Atari 2600。
虚拟机的世界非常广泛,充满了大量的术语需要了解。我们对虚拟机的探索将主要集中在这些术语与典型的 Linux 用户可能遇到的情况之间的关系。我们还会讨论在虚拟硬件中你可能会遇到的一些差异。
17.1.1 虚拟机监控程序
在计算机上管理一个或多个虚拟机的软件叫做虚拟机监控器(hypervisor)或虚拟机监视器 (VMM),它的工作方式类似于操作系统管理进程的方式。虚拟机监控器有两种类型,虚拟机的使用方式取决于类型。对于大多数用户来说,第二类型虚拟机监控器是最熟悉的,因为它运行在像 Linux 这样的普通操作系统上。例如,VirtualBox 就是第二类型虚拟机监控器,你可以在系统上运行它而无需进行大量修改。在阅读本书时,你可能已经使用它来测试和探索不同的 Linux 系统。
另一方面,第一类型虚拟机监控器更像是一个操作系统(尤其是内核),专门构建来快速高效地运行虚拟机。这种虚拟机监控器可能偶尔会使用像 Linux 这样的传统伴随系统来帮助管理任务。即使你可能永远不会在自己的硬件上运行一个,你每天都会与第一类型虚拟机监控器交互。所有云计算服务都是在第一类型虚拟机监控器下运行虚拟机,比如 Xen。当你访问一个网站时,几乎可以肯定你在访问一个运行在虚拟机上的软件。在 AWS 等云服务上创建操作系统实例就是在第一类型虚拟机监控器上创建虚拟机。
通常,带有操作系统的虚拟机称为来宾。主机是运行虚拟机监控器的任何设备。对于第二类型虚拟机监控器,主机就是你的本地系统。对于第一类型虚拟机监控器,主机就是虚拟机监控器本身,可能与一个专门的伴随系统结合使用。
17.1.2 虚拟机中的硬件
理论上,虚拟机监控器为来宾系统提供硬件接口应该是直接的。例如,为了提供一个虚拟磁盘设备,你可以在主机的某个位置创建一个大文件,并通过标准的设备 I/O 仿真将其作为磁盘提供访问。这个方法是严格的硬件虚拟机,但效率较低。为了使虚拟机适用于各种需求,需要做一些改动。
你可能会遇到的一些虚拟硬件与真实硬件之间的差异,是因为一种桥接技术使得来宾系统能够更直接地访问主机资源。绕过主机与来宾之间的虚拟硬件的方式被称为准虚拟化。网络接口和块设备是最有可能接受这种处理的;例如,在云计算实例上,* /dev/xvd *设备是一个 Xen 虚拟磁盘,使用 Linux 内核驱动程序直接与虚拟机监控器进行通信。有时候,为了方便,准虚拟化被使用;例如,在像 VirtualBox 这样的桌面系统上,驱动程序可以协调虚拟机窗口和主机环境之间的鼠标移动。
无论机制如何,虚拟化的目标始终是将问题简化到足够的程度,使得来宾操作系统可以像对待其他设备一样处理虚拟硬件。这确保了设备上方的所有层都能正常运行。例如,在 Linux 来宾系统上,你希望内核能够将虚拟磁盘视为块设备,以便你可以使用常规工具对其进行分区并创建文件系统。
虚拟机 CPU 模式
虚拟机如何工作的大部分细节超出了本书的范围,但 CPU 需要提及,因为我们已经讨论过内核模式和用户模式的区别。这些模式的具体名称因处理器而异(例如,x86 处理器使用一种叫做特权环的系统),但其核心思想始终相同。在内核模式下,处理器几乎可以执行任何操作;而在用户模式下,一些指令是不允许的,且内存访问受到限制。
第一个运行在 x86 架构上的虚拟机是在用户模式下运行的。这就出现了一个问题,因为虚拟机中的内核希望处于内核模式。为了解决这个问题,虚拟机管理程序可以检测并响应(“陷阱”)来自虚拟机的任何受限指令。经过一些工作,虚拟机管理程序能够模拟这些受限指令,从而使虚拟机能够在不支持该模式的架构上以内核模式运行。因为大多数内核执行的指令并不受限,所以这些指令能正常运行,并且性能影响非常小。
在这种类型的虚拟机管理程序(hypervisor)引入后不久,处理器制造商意识到市场上存在可以通过消除指令陷阱和仿真需求来辅助虚拟机管理程序的处理器需求。英特尔和 AMD 分别发布了这些功能集,称为 VT-x 和 AMD-V,大多数虚拟机管理程序现在都支持这些功能。在某些情况下,它们是必需的。
如果你想深入了解虚拟机,可以从 Jim Smith 和 Ravi Nair 的《虚拟机:系统与进程的多功能平台》(Elsevier,2005 年)开始阅读。书中还涉及了进程虚拟机,例如 Java 虚拟机(JVM),但我们在此不做讨论。
17.1.3 虚拟机的常见用途
在 Linux 环境中,虚拟机的使用通常可以分为几种类型:
-
测试和试验 当你需要在不同于正常或生产操作环境的情况下尝试某些东西时,虚拟机有很多用途。例如,在开发生产软件时,必须在与开发者的机器分开的虚拟机上测试软件。另一个用途是尝试新的软件,比如新的发行版,在一个安全且“可丢弃”的环境中。虚拟机让你可以做到这一点,而无需购买新的硬件。
-
应用兼容性 当你需要在与常规操作系统不同的操作系统下运行某些软件时,虚拟机是必不可少的。
-
服务器和云服务 如前所述,所有云服务都是建立在虚拟机技术之上的。如果你需要运行一个互联网服务器,比如 Web 服务器,最快的方式就是付费给云提供商租用一个虚拟机实例。云提供商还提供专用服务器,比如数据库,这些实际上就是运行在虚拟机上的预配置软件集。
17.1.4 虚拟机的缺点
多年来,虚拟机一直是隔离和扩展服务的首选方法。因为你可以通过几次点击或 API 创建虚拟机,所以无需安装和维护硬件,就能方便地创建服务器。尽管如此,日常操作中仍然有一些方面令人头疼:
-
安装和/或配置系统和应用可能既繁琐又耗时。像 Ansible 这样的工具可以自动化这个过程,但从零开始搭建一个系统仍然需要相当多的时间。如果你正在使用虚拟机来测试软件,你可以预见到这些时间会迅速积累。
-
即使配置正确,虚拟机的启动和重启速度相对较慢。虽然有一些方法可以解决这个问题,但你仍然需要启动一个完整的 Linux 系统。
-
你需要维护一个完整的 Linux 系统,确保每台虚拟机上的更新和安全性。这些系统有 systemd 和 sshd,以及任何你的应用所依赖的工具。
-
你的应用可能与虚拟机上的标准软件集存在一些冲突。有些应用有奇怪的依赖关系,它们并不总是与生产环境中的软件兼容。此外,像库这样的依赖项在机器升级时可能会发生变化,导致以前能正常运行的功能出问题。
-
将你的服务隔离在单独的虚拟机上可能是浪费资源且成本高昂的。行业的标准做法是每台系统上只运行一个应用服务,这样更健壮且更易于维护。此外,一些服务可以进一步细分;如果你运行多个网站,最好将它们放在不同的服务器上。然而,这与降低成本的目标相冲突,尤其是当你使用按虚拟机实例收费的云服务时。
这些问题实际上和你在真实硬件上运行服务时遇到的问题没有什么不同,对于小型操作来说,它们不一定是阻碍因素。然而,一旦你开始运行更多的服务,它们就会变得更加明显,消耗时间和金钱。这时候,你可能会考虑为你的服务使用容器。
17.2 容器
虚拟机非常适合隔离整个操作系统及其运行的应用程序,但有时你需要一个更轻量级的替代方案。容器技术现在是满足这一需求的流行方式。在我们深入细节之前,让我们回顾一下它的演变。
传统的计算机网络操作方式是将多个服务运行在同一台物理机器上;例如,一个名称服务器也可以充当邮件服务器并执行其他任务。然而,你不应该完全信任任何软件,包括服务器,认为它们是安全或稳定的。为了增强系统的安全性并避免服务之间相互干扰,通常有一些基本的方法来为服务器守护进程设置隔离,特别是当你不太信任其中某些服务时。
一种服务隔离的方法是使用 chroot() 系统调用将根目录更改为实际系统根目录以外的路径。一个程序可以将根目录更改为类似 /var/spool/my_service 的路径,从而无法访问该目录外的任何内容。事实上,存在一个 chroot 程序,可以让你在新的根目录下运行程序。这种隔离方式有时被称为 chroot 监狱,因为进程通常无法逃脱。
另一种限制是内核的资源限制(rlimit)功能,它限制了一个进程可以消耗的 CPU 时间或文件的最大大小。
这些就是容器构建的基本思想:你正在改变环境并限制进程运行时可用的资源。尽管没有单一的定义特征,容器可以宽泛地定义为一组进程的受限运行时环境,意味着这些进程无法接触到该环境外的系统资源。一般来说,这被称为操作系统级虚拟化。
需要记住的是,运行一个或多个容器的机器仍然只有一个底层的 Linux 内核。然而,容器内部的进程可以使用与底层系统不同的 Linux 发行版的用户空间环境。
容器中的限制是通过许多内核功能构建的。运行在容器中的进程的一些重要方面包括:
-
它们有自己的 cgroups。
-
它们有自己的设备和文件系统。
-
它们不能看到或与系统中的任何其他进程交互。
-
它们有自己的网络接口。
将这些因素整合在一起是一项复杂的任务。虽然可以手动更改一切,但这可能会很具挑战性;仅仅掌握进程的 cgroups 就很棘手。为了帮助你,许多工具可以执行创建和管理有效容器所需的子任务。最受欢迎的两个工具是 Docker 和 LXC。本章将重点介绍 Docker,但我们也会简要介绍 LXC,看看它与 Docker 的不同之处。
17.2.1 Docker、Podman 与权限
要运行本书中的示例,你需要一个容器工具。这里的示例是使用 Docker 构建的,通常你可以通过发行版的包管理器轻松安装 Docker。
有一种替代 Docker 的工具叫做 Podman。两者之间的主要区别是 Docker 在使用容器时需要运行一个服务器,而 Podman 不需要。这影响了两者设置容器的方式。大多数 Docker 配置需要超级用户权限来访问容器所使用的内核功能,dockerd 守护进程负责执行相关工作。相比之下,你可以以普通用户身份运行 Podman,称为 无根 操作。以这种方式运行时,它使用不同的技术来实现隔离。
你也可以以超级用户身份运行 Podman,这样它会切换到 Docker 使用的一些隔离技术。相反,较新的 dockerd 版本支持无根模式。
幸运的是,Podman 与 Docker 在命令行上是兼容的。这意味着你可以在这里的示例中将 podman 替换为 docker,它们仍然会正常工作。然而,它们在实现上有所不同,特别是在你以无根模式运行 Podman 时,所以在适用的地方会特别说明。
17.2.2 Docker 示例
熟悉容器的最简单方法是亲自操作。这里的 Docker 示例展示了容器工作的主要特性,但提供一本深入的用户手册超出了本书的范围。阅读完本书后,你应该能轻松理解在线文档,如果你在寻找一份详细的指南,可以尝试 Nigel Poulton 的 Docker Deep Dive(作者,2016 年)。
首先,你需要创建一个 镜像,它包含文件系统以及一些其他定义容器运行所需特征。你的镜像几乎总是基于从互联网仓库下载的预构建镜像。
在你的系统上安装 Docker(你发行版的附加包应该没问题),然后在某个地方创建一个新目录,进入该目录,创建一个名为 Dockerfile 的文件,并将以下内容添加到文件中:
FROM alpine:latest
RUN apk add bash
CMD ["/bin/bash"]
这个配置使用了轻量级的 Alpine 发行版。我们唯一的更改是添加 bash shell,这不仅是为了提高交互性,还为了创建一个独特的镜像并查看该过程是如何工作的。实际上,使用公共镜像并不做任何更改是可能的(也是常见的),在这种情况下,你不需要 Dockerfile。
使用以下命令构建镜像,它会读取当前目录中的 Dockerfile,并将标识符 hlw_test 应用到镜像上:
$ **docker build -t hlw_test .**
准备好大量的输出信息。不要忽视它;第一次阅读这些输出将帮助你理解 Docker 的工作原理。让我们将其分解为与 Dockerfile 中的行对应的步骤。第一步是从 Docker 注册表中获取最新版本的 Alpine 发行版容器:
Sending build context to Docker daemon 2.048kB
Step 1/3 : FROM alpine:latest
latest: Pulling from library/alpine
cbdbe7a5bc2a: Pull complete
Digest: sha256:9a839e63dad54c3a6d1834e29692c8492d93f90c59c978c1ed79109ea4b9a54
Status: Downloaded newer image for alpine:latest
---> f70734b6a266
注意到大量使用 SHA256 摘要和更短的标识符。习惯它们吧;Docker 需要跟踪许多小部分。在这一步中,Docker 创建了一个 ID 为f70734b6a266的新镜像,用于基本的 Alpine 发行版镜像。你以后可以引用这个特定的镜像,但你可能不需要这么做,因为它不是最终镜像。Docker 会在其上继续构建。一个不是最终产品的镜像被称为中间镜像。
我们配置的下一部分是在 Alpine 中安装 bash shell 包。阅读以下内容时,你可能会识别出由apk add bash命令生成的输出(以粗体显示):
Step 2/3 : RUN apk add bash
---> Running in 4f0fb4632b31
`fetch http://dl-cdn.alpinelinux.org/alpine/v3.11/main/x86_64/APKINDEX.tar.gz`
`fetch http://dl-cdn.alpinelinux.org/alpine/v3.11/community/x86_64/APKINDEX.tar.gz`
`(1/4) Installing ncurses-terminfo-base (6.1_p20200118-r4)`
`(2/4) Installing ncurses-libs (6.1_p20200118-r4)`
`(3/4) Installing readline (8.0.1-r0)`
**(4/4) Installing bash (5.0.11-r1)**
**Executing bash-5.0.11-r1.post-install**
**Executing busybox-1.31.1-r9.trigger**
**OK: 8 MiB in 18 packages**
Removing intermediate container 4f0fb4632b31
---> 12ef4043c80a
不太明显的是它是如何发生的。想一想,你可能并不是在自己的机器上运行 Alpine。那么你怎么能运行已经属于 Alpine 的apk命令呢?
关键是那行显示 Running in 4f0fb4632b31。你还没有请求容器,但 Docker 已经用上一步的中间 Alpine 镜像设置了一个新的容器。容器也有标识符;不幸的是,它们看起来与镜像标识符没有区别。更混淆的是,Docker 将临时容器称为中间容器,这与中间镜像不同。中间镜像在构建后会保留;中间容器则不会。
在设置了 ID 为4f0fb4632b31的(临时)容器后,Docker 在该容器内运行了apk命令以安装 bash,然后将生成的文件系统更改保存到 ID 为12ef4043c80a的新中间镜像中。注意,Docker 完成后也会删除该容器。
最后,Docker 在从新镜像启动容器时做出了运行 bash shell 所需的最终更改:
Step 3/3 : CMD ["/bin/bash"]
---> Running in fb082e6a0728
Removing intermediate container fb082e6a0728
---> 1b64f94e5a54
Successfully built 1b64f94e5a54
Successfully tagged hlw_test:latest
在这个例子中,你现在有了一个 ID 为1b64f94e5a54的最终镜像,但因为你标记了它(分两步进行),你也可以称它为hlw_test或hlw_test:latest。运行docker images来验证你的镜像和 Alpine 镜像是否存在:
$ **docker images**
REPOSITORY TAG IMAGE ID CREATED SIZE
hlw_test latest 1b64f94e5a54 1 minute ago 9.19MB
alpine latest f70734b6a266 3 weeks ago 5.61MB
运行 Docker 容器
现在你准备好启动一个容器了。使用 Docker 在容器中运行某些东西有两种基本方法:你可以先创建容器,然后在其中运行某些东西(分两步进行),或者你可以直接一步创建并运行。让我们直接开始,使用你刚刚构建的镜像启动一个容器:
$ **docker run -it hlw_test**
你应该会得到一个 bash shell 提示符,在那里你可以在容器中运行命令。该 shell 将以 root 用户身份运行。
如果你是好奇型的人,可能会想看看容器内部。运行一些命令,如mount和ps,并大致探索一下文件系统。你会很快注意到,虽然大部分内容看起来像典型的 Linux 系统,但也有一些不同。例如,如果你运行完整的进程列表,你只会看到两个条目:
# ps aux
PID USER TIME COMMAND
1 root 0:00 /bin/bash
6 root 0:00 ps aux
在容器中,shell 是进程 ID 1(记住,在普通系统中,这是 init),而除了你正在执行的进程列表,其他任何东西都没有运行。
这一点很重要,需要记住这些进程仅仅是你在正常(主机)系统上能看到的进程。如果你在主机系统上打开另一个 shell 窗口,你可以在进程列表中找到一个容器进程,尽管这需要稍微查找一下。它应该看起来像这样:
root 20189 0.2 0.0 2408 2104 pts/0 Ss+ 08:36 0:00 /bin/bash
这是我们第一次遇到容器使用的内核特性之一:专门为进程 ID 提供的 Linux 内核命名空间。一个进程可以为它自己及其子进程创建一整套新的进程 ID,从 PID 1 开始,然后它们只能看到这些进程 ID。
覆盖文件系统
接下来,探索容器中的文件系统。你会发现它相当简约;这是因为它基于 Alpine 发行版。我们使用 Alpine 不仅是因为它小,还因为它可能与你习惯的系统不同。然而,当你查看根文件系统的挂载方式时,你会发现它与普通的基于设备的挂载方式非常不同:
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/
C3D66CQYRP4SCXWFFY6HHF6X5Z:/var/lib/docker/overlay2/l/K4BLIOMNRROX3SS5GFPB
7SFISL:/var/lib/docker/overlay2/l/2MKIOXW5SUB2YDOUBNH4G4Y7KF1,upperdir=/
var/lib/docker/overlay2/d064be6692c0c6ff4a45ba9a7a02f70e2cf5810a15bcb2b728b00
dc5b7d0888c/diff,workdir=/var/lib/docker/overlay2/d064be6692c0c6ff4a45ba9a7a02
f70e2cf5810a15bcb2b728b00dc5b7d0888c/work)
这就是覆盖文件系统,一个内核特性,允许通过将现有目录作为层结合起来创建一个文件系统,同时将更改存储在一个位置。如果你查看你的主机系统,你将看到它(并且可以访问组件目录),你还会发现 Docker 在哪里附加了原始挂载。
在挂载输出中,你会看到 lowerdir、upperdir 和 workdir 目录参数。下级目录实际上是一个由冒号分隔的目录序列,如果你在主机系统上查找它们,你会发现最后一个是第一次构建镜像时设置的基本 Alpine 发行版(只需查看其中,你会看到发行版的根目录)。如果你跟随前两个目录,你会看到它们对应的是另外两个构建步骤。因此,这些目录从右到左依次“堆叠”在一起。
上级目录位于这些目录之上,且任何对挂载文件系统的更改都会出现在这里。挂载时,它不必为空,但对于容器来说,一开始把任何东西放在那里没有太大意义。工作目录是文件系统驱动程序在将更改写入上级目录之前进行操作的地方,且挂载时必须为空。
正如你所想,拥有许多构建步骤的容器镜像会有相当多的层。这有时会成为一个问题,针对这一点,有多种策略可以最小化层数,比如合并RUN命令和多阶段构建。我们在这里不深入讨论这些细节。
网络
尽管你可以选择让容器在与主机相同的网络中运行,但通常你希望在网络堆栈中实现某种隔离以确保安全。Docker 有几种方式可以实现这一点,但默认(也是最常见的)是桥接网络,使用另一种命名空间——网络命名空间(netns)。在运行任何内容之前,Docker 会在主机系统上创建一个新的网络接口(通常是 docker0),通常分配给一个私有网络,如 172.17.0.0/16,因此在这种情况下,接口将被分配给 172.17.0.1。这个网络用于主机和其容器之间的通信。
然后,在创建容器时,Docker 会创建一个新的网络命名空间,该命名空间几乎是完全空的。最初,新的命名空间(即容器中的命名空间)仅包含一个新的私有回环 (lo) 接口。为了使命名空间准备好实际使用,Docker 会在主机上创建一个 虚拟接口,它模拟了两个实际网络接口之间的连接(每个接口都有自己的设备),并将其中一个设备放置到新的命名空间中。在该命名空间中的设备上配置使用 Docker 网络(在我们的例子中是 172.17.0.0/16)上的地址,进程就可以在该网络上发送数据包,并在主机上接收。这可能会让人困惑,因为不同命名空间中的不同接口可以有相同的名称(例如,容器的接口可以是 eth0,主机机器的接口也是如此)。
因为这使用的是私有网络(而且网络管理员可能不希望盲目地将数据路由到这些容器中或从容器中路由),如果保持这种方式,使用该命名空间的容器进程将无法访问外部世界。为了能够访问外部主机,主机上的 Docker 网络配置了 NAT。
图 17-1 显示了一个典型的设置。它包括带有接口的物理层,以及 Docker 子网的互联网层和将该子网与主机的其余部分及其外部连接相连的 NAT。

图 17-1:Docker 中的桥接网络。粗线表示虚拟接口对的绑定。
在 Podman 中,rootless 操作的网络不同,因为设置虚拟接口需要超级用户权限。Podman 仍然使用新的网络命名空间,但它需要一个可以在用户空间操作的接口。这是一个 TAP 接口(通常是 tap0),并配合一个名为 slirp4netns 的转发守护进程,容器进程可以访问外部世界。这种方式能力较弱;例如,容器之间无法相互连接。
网络还有很多内容,包括如何暴露容器网络堆栈中的端口供外部服务使用,但网络拓扑是最重要的理解内容。
Docker 操作
在这一点上,我们可以继续讨论 Docker 启用的各种隔离和限制,但这会花费很长时间,你大概现在已经明白了。容器并不是由单一功能产生的,而是由多个功能的集合。其结果是,Docker 必须追踪我们创建容器时所做的一切,并且还必须能够清理它们。
Docker 将容器定义为“运行中”,只要它有一个进程在运行。你可以使用 docker ps 显示当前运行的容器:
$ **docker ps**
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bda6204cecf7 hlw_test "/bin/bash" 8 hours ago Up 8 hours boring_lovelace
8a48d6e85efe hlw_test "/bin/bash" 20 hours ago Up 20 hours awesome_elion
一旦所有进程终止,Docker 会将容器置于退出状态,但它仍然保留容器(除非你使用 --rm 选项启动)。这包括对文件系统所做的更改。你可以使用 docker export 容易地访问文件系统。
你需要注意这一点,因为默认情况下,docker ps 不会显示已退出的容器;你必须使用 -a 选项来查看所有容器。很容易积累大量已退出的容器,如果容器中运行的应用创建了大量数据,你可能会耗尽磁盘空间而不知道原因。使用 docker rm 删除已终止的容器。
这同样适用于旧镜像。开发镜像通常是一个重复的过程,当你用与现有镜像相同的标签来标记镜像时,Docker 不会删除原始镜像。旧镜像只是失去了那个标签。如果你运行 docker images 来显示系统上的所有镜像,你可以看到所有镜像。下面是一个例子,显示了没有标签的镜像的先前版本:
$ **docker images**
REPOSITORY TAG IMAGE ID CREATED SIZE
hlw_test latest 1b64f94e5a54 43 hours ago 9.19MB
<none> <none> d0461f65b379 46 hours ago 9.19MB
alpine latest f70734b6a266 4 weeks ago 5.61MB
使用 docker rmi 来删除一个镜像。这也会删除该镜像所依赖的任何不必要的中间镜像。如果你不删除镜像,它们会随着时间的推移逐渐堆积。根据镜像中的内容以及它们的构建方式,这可能会占用系统大量的存储空间。
一般来说,Docker 做了大量细致的版本控制和检查点管理。这种管理方式与像 LXC 这样的工具相比,反映了一种独特的哲学,你很快就会见到。
Docker 服务进程模型
Docker 容器的一个可能让人困惑的方面是容器内进程的生命周期。在一个进程完全终止之前,它的父进程应该通过 wait() 系统调用来收集(“回收”)它的退出码。然而,在容器中,存在一些情况下死掉的进程仍然存在,因为它们的父进程不知道如何处理。结合许多镜像的配置方式,这可能会让你得出结论:你不应该在 Docker 容器内运行多个进程或服务。这个结论是错误的。
你可以在一个容器内运行多个进程。我们在示例中运行的 shell 在你执行命令时会启动一个新的子进程。真正重要的是,当你有子进程时,父进程会在它们退出时进行清理。大多数父进程都会做到这一点,但在某些情况下,你可能会遇到某些父进程做不到这一点,尤其是当它不知道自己有子进程时。这种情况可能发生在多级进程生成的情况下,容器内的 PID 1 最终会成为一个它不知道的子进程的父进程。
为了解决这个问题,如果你有一个简单的单一服务,它仅仅是启动一些进程,并且即使容器应该终止时,仍然会留下残留的进程,你可以在docker run命令中添加--init选项。这会创建一个非常简单的初始化进程,在容器内作为 PID 1 运行,并作为父进程,知道在子进程终止时该做什么。
然而,如果你在一个容器内运行多个服务或任务(例如某个工作服务器的多个工作进程),你可能会考虑使用像 Supervisor(supervisord)这样的进程管理守护进程来启动和监控它们,而不是通过脚本启动它们。这样不仅提供了必要的系统功能,还能让你对服务进程有更多的控制。
关于这一点,如果你正在考虑这种模型的容器,有一个不同的选项你可以考虑,它不涉及 Docker。
17.2.3 LXC
我们的讨论围绕着 Docker 展开,不仅因为它是构建容器镜像最流行的系统,还因为它让你非常容易开始并跳入容器通常提供的隔离层次。然而,还有其他创建容器的软件包,它们采取了不同的方式。其中,LXC 是最古老的之一。实际上,Docker 的第一个版本就是基于 LXC 构建的。如果你理解了 Docker 是如何工作的,那么理解 LXC 的技术概念就不成问题了,因此我们不会讲解具体的例子,而是会探索一些实际的差异。
LXC这个术语有时用于指代使容器成为可能的一组内核功能,但大多数人用它来特指一个包含用于创建和操作 Linux 容器的多个工具的库和软件包。与 Docker 不同,LXC 涉及相当多的手动设置。例如,你必须创建自己的容器网络接口,并且需要提供用户 ID 映射。
最初,LXC 的目的是尽可能在容器内实现一个完整的 Linux 系统——包括初始化(init)。在安装了一个特殊版本的发行版后,你可以安装运行容器内部所需的一切。这部分和你在 Docker 中看到的没有太大区别,但需要做更多的设置;而在 Docker 中,你只需要下载一堆文件就能开始使用。
因此,你可能会发现 LXC 在适应不同需求时更加灵活。例如,默认情况下,LXC 不会使用你在 Docker 中看到的覆盖文件系统,尽管你可以添加一个。因为 LXC 是基于 C API 构建的,所以如果需要,你可以在自己的软件应用程序中使用这种粒度。
一个名为 LXD 的管理工具包可以帮助你处理 LXC 的一些更细致的手动操作(例如网络创建和镜像管理),并提供一个 REST API,您可以使用它来访问 LXC,而不是使用 C API。
17.2.4 Kubernetes
说到管理,容器已成为许多类型的 Web 服务器的流行选择,因为你可以从单个镜像在多台机器上启动一堆容器,从而提供出色的冗余。不幸的是,这可能很难管理。你需要执行如下任务:
-
跟踪哪些机器能够运行容器。
-
启动、监控并重启这些机器上的容器。
-
配置容器启动。
-
根据需要配置容器网络。
-
加载新的容器镜像版本,并优雅地更新所有正在运行的容器。
这不是一个完整的列表,也没有准确传达每个任务的复杂性。软件早已开始为此开发,而在出现的解决方案中,Google 的 Kubernetes 已经成为主流。或许其中一个最大的推动因素是它能够运行 Docker 容器镜像。
Kubernetes 有两个基本方面,就像任何客户端-服务器应用程序一样。服务器涉及可用于运行容器的机器,客户端主要是一组命令行工具,用于启动和操作容器集合。容器(以及它们组成的组)的配置文件可能非常庞大,你很快会发现大部分客户端工作是创建适当的配置。
你可以自行探索配置。如果你不想自己设置服务器,可以使用 Minikube 工具在你的机器上安装一个运行 Kubernetes 集群的虚拟机。
17.2.5 容器的陷阱
如果你考虑一下像 Kubernetes 这样的服务是如何工作的,你也会意识到,利用容器的系统并非没有其成本。至少,你仍然需要一台或多台机器来运行容器,这些机器必须是完整的 Linux 机器,无论是在真实硬件上还是虚拟机上。尽管维持这一核心基础设施可能比需要许多自定义软件安装的配置更简单,但仍然存在维护成本。
这些成本可以有多种形式。如果你选择自己管理基础设施,那是一项巨大的时间投资,仍然涉及硬件、托管和维护成本。如果你选择使用像 Kubernetes 集群这样的容器服务,你将支付将工作交给别人做的货币成本。
在考虑容器本身时,请记住以下几点:
-
容器在存储方面可能是浪费的。为了使应用程序能够在容器内运行,容器必须包含 Linux 操作系统所需的所有支持,如共享库。如果你没有特别注意你为容器选择的基础发行版,这可能会变得非常庞大。然后,考虑你的应用程序本身:它有多大?当你使用叠加文件系统并且多个容器共享同一个基础文件时,这种情况会有所缓解,因为它们共享相同的基础文件。然而,如果你的应用程序生成大量运行时数据,那么这些叠加层的上层可能会变得很大。
-
你仍然需要考虑其他系统资源,如 CPU 时间。你可以配置容器的资源限制,但你仍然受到底层系统能承载多少的限制。系统仍然有内核和块设备。如果你超载了系统,那么你的容器、底层系统或两者都会受到影响。
-
你可能需要重新思考数据存储的位置。在像 Docker 这样的容器系统中,使用的是叠加文件系统,运行时对文件系统的更改在进程终止后会被丢弃。在许多应用程序中,所有用户数据都会存储在数据库中,然后这个问题就变成了数据库管理。但是,日志怎么办呢?这些对于良好运作的服务器应用程序是必不可少的,你仍然需要一种方法来存储它们。对于任何大规模的生产环境,必须有一个独立的日志服务。
-
大多数容器工具和操作模型都是针对 Web 服务器设计的。如果你正在运行一个典型的 Web 服务器,你会发现关于在容器中运行 Web 服务器的大量支持和信息。特别是 Kubernetes,它有许多安全功能可以防止服务器代码失控。这可能是一个优势,因为它弥补了大多数 Web 应用程序(坦率地说)写得很糟糕的问题。然而,当你尝试运行其他类型的服务时,有时会感觉像是在试图将方钉插入圆孔。
-
粗心的容器构建可能导致膨胀、配置问题和故障。你创建了一个隔离的环境,但这并不能避免你在该环境中犯错。你可能不必太担心 systemd 的复杂性,但其他许多问题仍然可能发生。当任何系统出现问题时,缺乏经验的用户往往会加入一些东西,试图让问题消失,往往是草率地进行。这种情况可能会持续(常常是盲目地)直到最终有一个相对正常运行的系统——但问题变得更多了。你需要理解你所做的更改。
-
版本控制可能会出现问题。我们在本书中的示例使用了
latest标签。这应该是容器的最新(稳定)版本,但这也意味着,当你基于最新版本的发行版或软件包构建容器时,下面的某些东西可能会发生变化,从而破坏你的应用程序。一种标准做法是使用基础容器的特定版本标签。 -
信任可能是一个问题。这尤其适用于使用 Docker 构建的镜像。当你将容器基于 Docker 镜像库中的容器时,你就是在将信任交给一个额外的管理层,这些容器没有被修改成引入比通常更多的安全问题,并且当你需要时,它们会在那里。这与 LXC 相对立,后者鼓励你在一定程度上自己构建容器。
在考虑这些问题时,你可能会认为与管理系统环境的其他方式相比,容器有很多缺点。然而,事实并非如此。无论你选择哪种方式,这些问题都会以某种程度和形式存在——而且其中一些问题在容器中更容易管理。只需记住,容器并不能解决所有问题。例如,如果你的应用程序在正常系统上启动缓慢(启动后),它在容器中也会启动得很慢。
17.3 基于运行时的虚拟化
另一种虚拟化类型是基于开发应用程序时使用的环境类型。这与我们之前看到的系统虚拟机和容器不同,因为它不使用将应用程序放到不同机器上的概念。相反,它是一种仅适用于特定应用程序的隔离。
这些环境存在的原因是,在同一系统上运行的多个应用程序可能会使用相同的编程语言,导致潜在的冲突。例如,Python 在典型的发行版中使用得很广泛,并且可能包含许多附加包。如果你想在自己的包中使用系统版本的 Python,当你需要不同版本的某个附加包时,可能会遇到麻烦。
让我们看看 Python 的虚拟环境功能如何创建一个仅包含你想要的包的 Python 版本。开始的方式是像这样创建一个新的目录来存放环境:
$ **python3 -m venv test-venv**
现在,查看新创建的 test-venv 目录。你会看到一些类似系统的目录,如 bin、include 和 lib。要激活虚拟环境,你需要源(而不是执行)test-venv/bin/activate 脚本:
$ **. test-env/bin/activate**
源代码执行的原因是激活本质上是设置环境变量,而你无法通过运行可执行文件来完成此操作。在此时,当你运行 Python 时,你会得到位于test-venv/bin目录中的版本(该目录本身只是一个符号链接),并且VIRTUAL_ENV环境变量会被设置为环境的基础目录。你可以运行deactivate来退出虚拟环境。
就这么简单。设置了这个环境变量后,你会在test-venv/lib中得到一个新的空的包库,并且你在虚拟环境中安装的任何新内容都会安装到那里,而不是安装到主系统的库中。
并非所有编程语言都像 Python 那样支持虚拟环境,但了解它还是值得的,即使仅仅是为了澄清一些关于虚拟一词的误解。
参考书目
Abrahams, Paul W., 和 Bruce Larson, 急于上手的 UNIX,第 2 版。波士顿:Addison-Wesley Professional,1995 年。
Aho, Alfred V., Brian W. Kernighan 和 Peter J. Weinberger, AWK 编程语言。波士顿:Addison-Wesley,1988 年。
Aho, Alfred V., Monica S. Lam, Ravi Sethi 和 Jeffery D. Ullman, 编译器:原理、技术与工具,第 2 版。波士顿:Addison-Wesley,2006 年。
Aumasson, Jean-Philippe, 严肃的加密学:现代加密的实用入门。旧金山:No Starch Press,2017 年。
Barrett, Daniel J., Richard E. Silverman 和 Robert G. Byrnes, SSH,安全外壳:权威指南,第 2 版。塞巴斯托波尔,加利福尼亚州:O'Reilly,2005 年。
Beazley, David M., Python 精粹。Addison-Wesley,2021 年。
Beazley, David M., Brian D. Ward 和 Ian R. Cooke,“共享库和动态加载的内幕故事。” 计算机科学与工程 3,第 5 期(2001 年 9 月/10 月):90–97。
Calcote, John, Autotools:GNU Autoconf,Automake 和 Libtool 实践指南,第 2 版。旧金山:No Starch Press,2019 年。
Carter, Gerald, Jay Ts 和 Robert Eckstein, 使用 Samba:Linux、Unix 和 Mac OS X 的文件和打印服务器,第 3 版。塞巴斯托波尔,加利福尼亚州:O'Reilly,2007 年。
Christiansen, Tom, brian d foy, Larry Wall 和 Jon Orwant, 编程 Perl:无与伦比的处理和脚本能力,第 4 版。塞巴斯托波尔,加利福尼亚州:O'Reilly,2012 年。
chromatic, 现代 Perl,第 4 版。希尔斯伯勒,俄勒冈州:Onyx Neon Press,2016 年。
Davies, Joshua. 使用加密学和 PKI 实现 SSL/TLS。霍博肯,新泽西州:Wiley,2011 年。
Friedl, Jeffrey E. F., 精通正则表达式,第 3 版。塞巴斯托波尔,加利福尼亚州:O'Reilly,2006 年。
Gregg, Brendan, 系统性能:企业与云计算,第 2 版。波士顿:Addison-Wesley,2020 年。
Grune, Dick, Kees van Reeuwijk, Henri E. Bal, Ceriel J. H. Jacobs 和 Koen Langendoen, 现代编译器设计,第 2 版。纽约:Springer,2012 年。
Hopcroft, John E., Rajeev Motwani 和 Jeffrey D. Ullman, 自动机理论、语言与计算导论,第 3 版。上萨德尔河,新泽西州:Prentice Hall,2006 年。
Kernighan, Brian W. 和 Rob Pike, UNIX 编程环境。新泽西州上萨德尔河:普伦蒂斯·霍尔出版社,1984 年。
Kernighan, Brian W. 和 Dennis M. Ritchie, C 程序设计语言,第 2 版。新泽西州上萨德尔河:普伦蒂斯·霍尔出版社,1988 年。
Kochan, Stephen G. 和 Patrick Wood, Unix Shell 编程,第 3 版。印第安纳波利斯:SAMS 出版社,2003 年。
Levine, John R., 连接器与加载程序。旧金山:Morgan Kaufmann 出版社,1999 年。
Lucas, Michael W., SSH 掌握:OpenSSH、PuTTY、隧道与密钥,第 2 版。底特律:Tilted Windmill Press 出版社,2018 年。
Matloff, Norman, R 编程艺术:统计软件设计导览。旧金山:No Starch Press 出版社,2011 年。
Mecklenburg, Robert, 使用 GNU Make 管理项目,第 3 版。加利福尼亚州塞巴斯托波尔:O'Reilly 出版社,2005 年。
Peek, Jerry, Grace Todino-Gonguet 和 John Strang, 学习 UNIX 操作系统:新用户简明指南,第 5 版。加利福尼亚州塞巴斯托波尔:O'Reilly 出版社,2001 年。
Pike, Rob, Dave Presotto, Sean Dorward, Bob Flandrena, Ken Thompson, Howard Trickey 和 Phil Winterbottom, “Plan 9 来自贝尔实验室。” 访问于 2020 年 2 月 1 日,9p.io/sys/doc/.
Poulton, Nigel, Docker 深度探索。作者,2016 年。
Quinlan, Daniel, Rusty Russell 和 Christopher Yeoh, 编,“文件系统层次结构标准,版本 3.0。”Linux 基金会,2015 年,refspecs.linuxfoundation.org/fhs.shtml。
Raymond, Eric S., 编, 新黑客字典。第 3 版。马萨诸塞州剑桥:麻省理工学院出版社,1996 年。
Robbins, Arnold, sed & awk 口袋参考,第 2 版。加利福尼亚州塞巴斯托波尔:O'Reilly 出版社,2002 年。
Robbins, Arnold, Elbert Hannah 和 Linda Lamb, 学习 vi 和 Vim 编辑器:Unix 文本处理,第 7 版。加利福尼亚州塞巴斯托波尔:O'Reilly 出版社,2008 年。
Salus, Peter H., 守护进程、Gnu 和企鹅。华盛顿州塔科马:Reed Media Services 出版社,2008 年。
Samar, Vipin 和 Roland J. Schemers III. “使用可插拔认证模块(PAM)的统一登录,”1995 年 10 月,开放软件基金会(RFC 86.0),www.opengroup.org/rfc/rfc86.0.html。
Schwartz, Randal L., brian d foy 和 Tom Phoenix, 学习 Perl:让简单的事情更简单,让难的事情成为可能,第 7 版。加利福尼亚州塞巴斯托波尔:O'Reilly 出版社,2016 年。
Shotts, William, Linux 命令行,第 2 版。旧金山:No Starch Press 出版社,2019 年。
Silberschatz, Abraham, Peter B. Galvin 和 Greg Gagne, 操作系统概念,第 10 版。新泽西州霍博肯:Wiley 出版社,2018 年。
Smith, Jim 和 Ravi Nair, 虚拟机:适用于系统和进程的多功能平台。马萨诸塞州剑桥:Elsevier 出版社,2005 年。
Stallman, Richard M., GNU Emacs 手册,第 18 版。波士顿:自由软件基金会出版社,2018 年。
Stevens, W. Richard, Bill Fenner 和 Andrew M. Rudoff, Unix 网络编程,第 1 卷:套接字网络 API,第 3 版。波士顿:Addison-Wesley Professional 出版社,2003 年。
Tanenbaum, Andrew S. 和 Herbert Bos, 现代操作系统,第 4 版。新泽西州上萨德尔河:普伦蒂斯·霍尔出版社,2014 年。
Tanenbaum, Andrew S., 和 David J. Wetherall, 计算机网络, 第 5 版. 上萨德尔河, 新泽西州: 普伦蒂斯·霍尔出版社, 2010.






浙公网安备 33010602011771号