原文:
zh.annas-archive.org/md5/9713B9F84CB12A4F8624F3E68B0D4320
译者:飞龙
前言
Linux 操作系统及其嵌入式和服务器应用程序是当今分散和网络化宇宙中关键软件基础设施的关键组成部分。对熟练的 Linux 开发人员的行业需求不断增加。本书旨在为您提供两方面的内容:扎实的理论基础和实用的、与行业相关的信息——通过代码进行说明——涵盖 Linux 系统编程领域。本书深入探讨了 Linux 系统编程的艺术和科学,包括系统架构、虚拟内存、进程内存和管理、信号、定时器、多线程、调度和文件 I/O。
这本书试图超越使用 API X 来实现 Y 的方法;它着力解释了理解编程接口、设计决策以及有经验的开发人员在使用它们时所做的权衡和背后的原理所需的概念和理论。故障排除技巧和行业最佳实践丰富了本书的内容。通过本书,您将具备与 Linux 系统编程接口一起工作所需的概念知识和实践经验。
这本书适合谁
《Linux 系统编程实践》是为 Linux 专业人士准备的:系统工程师、程序员和测试人员(QA)。它也适用于学生;任何想要超越使用 API 集合理解强大的 Linux 系统编程 API 背后的理论基础和概念的人。您应该熟悉 Linux 用户级别的知识,包括登录、通过命令行界面使用 shell 以及使用 find、grep 和 sort 等工具。需要具备 C 编程语言的工作知识。不需要有 Linux 系统编程的先前经验。
这本书涵盖了什么
《Linux 系统架构》一章涵盖了关键基础知识:Unix 设计理念和 Linux 系统架构。同时,还涉及了其他重要方面——CPU 特权级别、处理器 ABI 以及系统调用的真正含义。
《虚拟内存》一章澄清了关于虚拟内存的常见误解以及为什么它对现代操作系统设计至关重要;还介绍了进程虚拟地址空间的布局。
《资源限制》一章深入探讨了每个进程资源限制以及管理其使用的 API。
《动态内存分配》一章首先介绍了流行的 malloc API 系列的基础知识,然后深入探讨了更高级的方面,如程序断点、malloc 的真正行为、需求分页、内存锁定和保护,以及使用 alloca 函数。
《Linux 内存问题》一章介绍了(不幸地)普遍存在的内存缺陷,这些缺陷由于对内存 API 的正确设计和使用缺乏理解而出现在我们的项目中。涵盖了未定义行为(一般)、溢出和下溢错误、泄漏等缺陷。
《内存问题调试工具》一章展示了如何利用现有工具,包括编译器本身、Valgrind 和 AddressSanitizer,用于检测前一章中出现的内存问题。
《进程凭证》一章是两章中第一章,重点是让您从系统角度思考和理解安全性和特权。在这里,您将了解传统安全模型——一组进程凭证——以及操作它们的 API。重要的是,还深入探讨了 setuid-root 进程及其安全影响。
第八章,进程能力,向你介绍了现代 POSIX 能力模型以及当应用程序开发人员学会使用和利用这一模型而不是传统模型时,安全性可以得到的好处。我们还探讨了能力是什么,如何嵌入它们以及安全性的实际设计。
第九章,进程执行,是处理广泛的进程管理领域(执行、创建和信号)的四章中的第一章。在本章中,你将学习 Unix exec 公理的行为方式以及如何使用 API 集(exec 家族)来利用它。
第十章,进程创建,深入探讨了fork(2)
系统调用的行为和使用方法;我们通过七条 fork 规则来描述这一过程。我们还描述了 Unix 的 fork-exec-wait 语义(并深入探讨了等待 API),还涵盖了孤儿进程和僵尸进程。
第十一章,信号-第一部分,涉及了 Linux 平台上信号的重要主题:信号的含义、原因和方式。我们在这里介绍了强大的sigaction(2)
系统调用,以及诸如可重入和信号异步安全性、sigaction 标志、信号堆栈等主题。
第十二章,信号-第二部分,继续我们对信号的覆盖,因为这是一个庞大的主题。我们将指导你正确地编写一个处理臭名昭著的致命段错误的信号处理程序,以及处理实时信号、向进程发送信号、使用信号进行进程间通信以及处理信号的其他替代方法。
第十三章,定时器,教会你如何在现实世界的 Linux 应用程序中设置和处理定时器这一重要(和与信号相关的)主题。我们首先介绍传统的定时器 API,然后迅速转向现代的 POSIX 间隔定时器以及如何使用它们。我们还介绍并演示了两个有趣的小项目。
第十四章,使用 Pthreads 进行多线程编程第一部分-基础知识,是关于在 Linux 上使用 pthread 框架进行多线程编程的三部曲中的第一部分。在这里,我们向你介绍了线程究竟是什么,它与进程的区别,以及使用线程的动机(在设计和性能方面)。本章还指导你了解在 Linux 上编写 pthread 应用程序的基础知识,包括线程的创建、终止、加入等。
第十五章,使用 Pthreads 进行多线程编程第二部分-同步,是一个专门讨论同步和竞争预防这一非常重要主题的章节。你将首先了解问题的本质,然后深入探讨原子性、锁定、死锁预防等关键主题。接下来,本章将教你如何使用 pthread 同步 API 来处理互斥锁和条件变量。
第十六章,使用 Pthreads 进行多线程编程第三部分,完成了我们关于多线程的工作;我们阐明了线程安全、线程取消和清理以及在多线程应用程序中处理信号的关键主题。我们在本章中讨论了多线程的利弊,并解答了一些常见问题。
第十七章,Linux 上的 CPU 调度,向您介绍了系统程序员应该了解的与调度相关的主题。我们涵盖了 Linux 进程/线程状态机,实时概念以及 Linux 操作系统提供的三种(最小)POSIX CPU 调度策略。通过利用可用的 API,您将学习如何在 Linux 上编写软实时应用程序。我们最后简要介绍了一个有趣的事实,即 Linux可以被打补丁以作为实时操作系统。
第十八章,高级文件 I/O,完全专注于在 Linux 上执行更高级的 IO 以获得最佳性能(因为 IO 通常是瓶颈)。您将简要了解 Linux IO 堆栈的架构(页面缓存至关重要),以及向操作系统提供文件访问模式建议的 API。编写性能 IO 代码,正如您将了解的那样,涉及使用诸如 SG-I/O、内存映射、DIO 和 AIO 等技术。
第十九章,故障排除和最佳实践,是对 Linux 故障排除关键要点的重要总结。您将了解到使用强大工具,如 perf 和跟踪工具。然后,本章试图总结一般软件工程和特别是 Linux 编程的关键要点,探讨行业最佳实践。我们认为这些对于任何程序员来说都是至关重要的收获。
附录 A,文件 I/O 基础知识,向您介绍了如何在 Linux 平台上执行高效的文件 I/O,通过流式(stdio 库层)API 集以及底层系统调用。在此过程中,还涵盖了有关缓冲及其对性能的影响的重要信息。
请参考本章:www.packtpub.com/sites/default/files/downloads/File_IO_Essentials.pdf
。
附录 B,守护进程,以简洁的方式向您介绍了 Linux 上守护进程的世界。您将了解如何编写传统的 SysV 风格守护进程。还简要介绍了构建现代新风格守护进程所涉及的内容。
请参考本章:www.packtpub.com/sites/default/files/downloads/Daemon_Processes.pdf
。
为了充分利用本书
正如前面提到的,本书旨在面向 Linux 软件专业人员——无论是开发人员、程序员、架构师还是 QA 人员,以及希望通过 Linux 操作系统的系统编程主题扩展知识和技能的认真学生。
我们假设您熟悉通过命令行界面、shell 使用 Linux 系统。我们还假设您熟悉使用 C 语言进行编程,知道如何使用编辑器和编译器,并熟悉 Makefile 的基础知识。我们不假设您对书中涉及的主题有任何先前的知识。
为了充分利用本书——我们非常明确地指出——您不仅必须阅读材料,还必须积极地动手尝试、修改提供的代码示例,并尝试完成作业!为什么?简单:实践才是真正教会您并内化主题的方法;犯错误并加以修正是学习过程中至关重要的一部分。我们始终主张经验主义方法——不要轻信任何东西。实验,亲自尝试并观察。
因此,我们建议您克隆本书的 GitHub 存储库(请参阅以下部分的说明),浏览文件,并尝试它们。显然,为了进行实验,使用虚拟机(VM)是绝对推荐的(我们已经在 Ubuntu 18.04 LTS 和 Fedora 27/28 上测试了代码)。书的 GitHub 存储库中还提供了在系统上安装的强制和可选软件包的清单;请阅读并安装所有必需的实用程序,以获得最佳体验。
最后,但绝对不是最不重要的,每一章都有一个进一步阅读部分,在这里提到了额外的在线链接和书籍(在某些情况下);我们建议您浏览这些内容。您将在书的 GitHub 存储库上找到每一章的进一步阅读材料。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接将文件发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packt.com。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名并按照屏幕上的说明操作。
文件下载后,请确保使用最新版本的解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-on-System-Programming-with-Linux
。我们还有其他丰富书籍和视频代码包可供查看,网址为**github.com/PacktPublishing/
**。请查看。
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/9781788998475_ColorImages.pdf
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“让我们通过我们的membugs.c
程序的源代码来检查这些。”
代码块设置如下:
include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:
include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
任何命令行输入或输出都以以下方式编写:
$ ./membugs 3
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:“通过下拉菜单选择 C 作为语言。”
警告或重要说明会以这种方式出现。
提示和技巧会以这种方式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:发送电子邮件至customercare@packtpub.com
,并在主题中提及书名。如果您对本书的任何方面有疑问,请发送电子邮件至customercare@packtpub.com
与我们联系。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在本书中发现错误,我们将不胜感激地向我们报告。请访问www.packt.com/submit-errata,选择您的书,单击勘误提交表单链接,然后输入详细信息。
盗版:如果您在互联网上发现我们作品的任何形式的非法复制,请您提供给我们地址或网站名称,我们将不胜感激。请通过copyright@packt.com
与我们联系,并附上材料链接。
如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有兴趣撰写或为一本书做出贡献,请访问authors.packtpub.com。
评论
请留下评论。当您阅读并使用了这本书后,为什么不在购买它的网站上留下评论呢?潜在的读者可以看到并使用您公正的意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者也可以看到您对他们书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问packt.com。
第一章:Linux 系统架构
本章介绍了 Linux 生态系统的系统架构。首先介绍了优雅的 Unix 哲学和设计基础,然后深入探讨了 Linux 系统架构的细节。将涵盖 ABI 的重要性、CPU 特权级别以及现代操作系统如何利用它们,以及 Linux 系统架构的分层和 Linux 是一个单体架构。还将介绍系统调用 API 的(简化的)流程以及内核代码执行上下文等关键点。
在本章中,读者将学习以下主题:
-
Unix 哲学简介
-
架构初步
-
Linux 架构层
-
Linux——单体操作系统
-
内核执行上下文
在这个过程中,我们将使用简单的例子来阐明关键的哲学和架构观点。
技术要求
需要一台现代台式电脑或笔记本电脑;Ubuntu 桌面版指定以下为安装和使用该发行版的推荐系统要求:
-
2GHz 双核处理器或更好
-
RAM
-
在物理主机上运行:2GB 或更多系统内存
-
作为客户操作系统运行:主机系统应至少具有 4GB RAM(内存越大,体验越好,更加流畅)
-
25GB 的可用硬盘空间
-
安装介质需要 DVD 驱动器或 USB 端口
-
互联网访问肯定是有帮助的
我们建议读者使用以下 Linux 发行版(可以安装为 Windows 或 Linux 主机系统的客户操作系统,如前所述):
-
Ubuntu 18.04 LTS 桌面版(Ubuntu 16.04 LTS 桌面版也是一个不错的选择,因为它也有长期支持,几乎所有功能都应该可以使用)
-
Ubuntu 桌面版下载链接:
www.ubuntu.com/download/desktop
-
Fedora 27(工作站)
请注意,这些发行版在默认情况下是开源软件和非专有软件,可以免费使用。
有时书中并未包含完整的代码片段。因此,GitHub 链接可用于参考代码:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux
。
此外,在进一步阅读部分,请参考上述 GitHub 链接。
Linux 和 Unix 操作系统
摩尔定律著名地指出,集成电路中的晶体管数量将每两年(大约)翻一番(并附带成本将以几乎相同的速度减半)。这个定律在很多年内保持相当准确,这是清楚地表明了人们对电子和信息技术行业的创新和技术范式转变的速度的认识和庆祝;这里创新和技术范式转变的速度是无与伦比的。以至于现在,每年,甚至在某些情况下每几个月,新的创新和技术出现,挑战并最终淘汰旧的技术,几乎没有仪式感。
在这个快速变化的背景下,有一个引人入胜的反常现象:一个操作系统的基本设计、哲学和架构在近五十年来几乎没有发生任何变化。是的,我们指的是古老的 Unix 操作系统。
Unix 起源于 AT&T 贝尔实验室的一个注定失败的项目(Multics),大约在 1969 年。Unix 曾一度风靡全球。至少在一段时间内是如此。
但是,你可能会说,这是一本关于 Linux 的书;为什么要提供这么多关于 Unix 的信息?简单地说,因为在本质上,Linux 是古老 Unix 操作系统的最新化身。Linux 是一种类 Unix 操作系统(还有其他几种)。出于法律需要,代码是独特的;然而,Linux 的设计、哲学和架构与 Unix 的几乎完全相同。
Unix 哲学简介
要理解任何人(或任何事物),必须努力首先理解他们(或它的)基本哲学;要开始理解 Linux 就是开始理解 Unix 哲学。在这里,我们不打算深入到每一个细节;相反,我们的目标是对 Unix 哲学的基本要点有一个整体的理解。此外,当我们使用术语 Unix 时,我们也非常指的是 Linux!
软件(特别是工具)在 Unix 上的设计、构建和维护方式慢慢演变成了一种被称为 Unix 设计哲学的模式。在其核心,这里是 Unix 哲学、设计和架构的支柱:
-
一切都是一个进程;如果不是进程,就是一个文件
-
一个工具做一件事
-
三个标准 I/O 通道
-
无缝地组合工具
-
首选纯文本
-
命令行界面,而不是图形界面
-
模块化,设计为供他人重新利用
-
提供机制,而不是策略
让我们更仔细地检查这些支柱,好吗?
一切都是一个进程 - 如果不是进程,就是一个文件
进程是正在执行的程序的一个实例。文件是文件系统上的一个对象;除了具有纯文本或二进制内容的常规文件之外;它还可以是一个目录、一个符号链接、一个设备特殊文件、一个命名管道或者一个(Unix 域)套接字。
Unix 设计哲学将外围设备(如键盘、显示器、鼠标、传感器和触摸屏)抽象为文件 - 它称之为设备文件。通过这样做,Unix 允许应用程序员方便地忽略细节,只是将(外围)设备视为普通的磁盘文件。
内核提供了一个处理这种抽象的层 - 它被称为虚拟文件系统开关(VFS)。因此,有了这个层,应用程序开发人员可以打开设备文件并对其进行 I/O(读取和写入),所有这些都使用提供的通常 API 接口(放心,这些 API 将在后续章节中介绍)。
实际上,每个进程在创建时都会继承三个文件:
-
标准输入(
stdin
:fd 0):默认情况下是键盘设备 -
标准输出(
stdout
:fd 1):默认情况下是监视器(或终端)设备 -
标准错误(
stderr
:fd 2):默认情况下是监视器(或终端)设备
fd是文件描述符的常见缩写,特别是在代码中;它是一个指向所讨论的打开文件的整数值。
另外,注意我们提到默认情况下是某个设备 - 这意味着默认值可以被更改。事实上,这是设计的一个关键部分:改变标准输入、输出或错误通道被称为重定向,通过使用熟悉的<、>和 2> shell 操作符,这些文件通道被重定向到其他文件或设备。
在 Unix 上,存在一类被称为过滤器的程序。
过滤器是一个从其标准输入读取的程序,可能修改输入,并将过滤后的结果写入其标准输出。
Unix 上的过滤器是非常常见的实用工具,比如cat
、wc
、sort
、grep
、perl
、head
和tail
。
过滤器允许 Unix 轻松地规避设计和代码复杂性。如何做到的?
让我们以sort
过滤器作为一个快速的例子。好的,我们需要一些数据来排序。假设我们运行以下命令:
$ cat fruit.txt
orange
banana
apple
pear
grape
pineapple
lemon
cherry
papaya
mango
$
现在我们考虑使用sort
的四种情况;根据我们传递的参数,我们实际上正在执行显式或隐式的输入、输出和/或错误重定向!
场景 1:对文件进行字母排序(一个参数,输入隐式重定向到文件):
$ sort fruit.txt
apple
banana
cherry
grape
lemon
mango
orange
papaya
pear
pineapple
$
好的!
不过,等一下。如果sort
是一个过滤器(它是),它应该从其stdin
(键盘)读取,并将其写入stdout
(终端)。它确实是写入终端设备,但它是从一个文件fruit.txt
中读取的。
这是故意的;如果提供了参数,sort 程序会将其视为标准输入,这一点显而易见。
另外,注意sort fruit.txt
和sort < fruit.txt
是相同的。
情景 2:按字母顺序对任何给定的输入进行排序(无参数,输入和输出从 stdin/stdout 进行):
$ sort
mango
apple
pear
^D
apple
mango
pear
$
一旦输入sort
并按下Enter键,排序过程就开始运行并等待。为什么?它在等待你,用户,输入。为什么?回想一下,默认情况下,每个进程都从标准输入或 stdin - 键盘设备读取输入!所以,我们输入一些水果名称。当我们完成时,按下Ctrl + D。这是表示文件结束(EOF)的默认字符序列,或者在这种情况下,表示输入结束。哇!输入已经排序并写入。写到哪里?写到sort
进程的 stdout - 终端设备,因此我们可以看到它。
情景 3:按字母顺序对任何给定的输入进行排序,并将输出保存到文件中(显式输出重定向):
$ sort > sorted.fruit.txt
mango
apple
pear
^D
$
与情景 2 类似,我们输入一些水果名称,然后按Ctrl + D告诉 sort 我们已经完成了。不过这次要注意的是,输出是通过>
元字符重定向到sorted.fruits.txt
文件!
因此,预期的输出如下:
$ cat sorted.fruit.txt
apple
mango
pear
$
情景 4:按字母顺序对文件进行排序,并将输出和错误保存到文件中(显式输入、输出和错误重定向):
$ sort < fruit.txt > sorted.fruit.txt 2> /dev/null
$
有趣的是,最终结果与前一个情景中的结果相同,还有一个额外的优势,即将任何错误输出重定向到错误通道。在这里,我们将错误输出重定向(回想一下,文件描述符 2 总是指向stderr
)到/dev/null
特殊设备文件;/dev/null
是一个设备文件,其作用是充当一个接收器(一个黑洞)。写入空设备的任何内容都将永远消失!(谁说 Unix 上没有魔法?)此外,它的补充是/dev/zero
*;*零设备是一个源 - 一个无限的零源。从中读取将返回零(第一个 ASCII 字符,而不是数字 0);它没有文件结束!
一个工具做一件事
在 Unix 设计中,人们试图避免创建一把瑞士军刀;相反,人们为一个非常具体的指定目的创建一个工具,只为这一个目的。没有如果,没有但是;没有杂物,没有混乱。这就是设计的简单性。
“简单是终极的复杂。”
- 列奥纳多·达·芬奇
举个常见的例子:在 Linux CLI(命令行界面)上工作时,您可能想知道您本地挂载的文件系统中哪个有最多的可用(磁盘)空间。
我们可以通过适当的开关获取本地挂载的文件系统的列表(只需df
也可以):
$ df --local
Filesystem 1K-blocks Used Available Use% Mounted on
rootfs 20640636 1155492 18436728 6% /
udev 10240 0 10240 0% /dev
tmpfs 51444 160 51284 1% /run
tmpfs 5120 0 5120 0% /run/lock
tmpfs 102880 0 102880 0% /run/shm
$
要对输出进行排序,首先需要将其保存到一个文件中;可以使用临时文件进行此操作,tmp,然后使用sort
实用程序进行排序。最后,我们删除这个临时文件。(是的,有一个更好的方法,管道;请参考无缝组合工具部分)
请注意,可用空间是第四列,因此我们相应地进行排序:
$ df --local > tmp
$ sort -k4nr tmp
rootfs 20640636 1155484 18436736 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
Filesystem 1K-blocks Used Available Use% Mounted on
$
哎呀!输出包括标题行。让我们首先使用多功能的sed
实用程序 - 一个强大的非交互式编辑工具 - 从df
的输出中消除第一行,即标题行:
$ df --local > tmp
$ sed --in-place '1d' tmp
$ sort -k4nr tmp
rootfs 20640636 1155484 18436736 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
$ rm -f tmp
那又怎样?关键是,在 Unix 上,没有一个实用程序可以同时列出挂载的文件系统并按可用空间进行排序。
相反,有一个用于列出挂载的文件系统的实用程序:df
。它做得很好,有选择的选项开关。(如何知道哪些选项?学会使用 man 页面,它们非常有用。)
有一个用于对文本进行排序的实用程序:sort
。同样,它是对文本进行排序的最后一个单词,有很多选项开关可供选择,几乎可以满足每一个可能需要的排序。
Linux man 页面:man是manual的缩写;在终端窗口上,输入man man
以获取有关使用 man 的帮助。请注意,手册分为 9 个部分。例如,要获取有关 stat 系统调用的手册页,请输入man 2 stat
,因为所有系统调用都在手册的第二部分。使用的约定是 cmd 或 API;因此,我们称之为stat(2)
。
正如预期的那样,我们获得了结果。那么到底是什么意思呢?就是这个:我们使用了三个实用程序*,*而不是一个。df
,用于列出已挂载的文件系统(及其相关的元数据),sed
,用于消除标题行,以及sort
,以任何可想象的方式对其给定的输入进行排序。
df
可以查询和列出已挂载的文件系统,但它不能对它们进行排序。sort
可以对文本进行排序;它不能列出已挂载的文件系统。
想一想这一刻。
将它们组合起来,你会得到比其各部分更多的东西! Unix 工具通常只做一项任务,并且他们会把它做到逻辑上的结论;没有人做得比他们更好!
说到这一点,我想有点羞怯地指出,备受推崇的工具 Busybox。 Busybox(http://busybox.net
)被宣传为嵌入式 Linux 的瑞士军刀。它确实是一个非常多才多艺的工具;它在嵌入式 Linux 生态系统中有其位置 - 正是因为在嵌入式盒子上为每个实用程序都有单独的二进制可执行文件太昂贵(而且会消耗更多的 RAM)。 Busybox 通过具有单个二进制可执行文件(以及从其每个 applet(如 ls、ps、df 和 sort)到它的符号链接)来解决这个问题。
因此,除了嵌入式场景和它所暗示的所有资源限制之外,确实要遵循一个工具只做一项任务的规则!
三个标准 I/O 通道
再次,一些流行的 Unix 工具(技术上称为过滤器)是故意设计为从称为标准输入(stdin)的标准文件描述符读取它们的输入 - 可能修改它,并将它们的结果输出写入称为标准输出(stdout)的标准文件描述符。任何错误输出都可以写入一个名为标准错误(stderr)的单独错误通道。
与 shell 的重定向操作符(>
用于输出重定向和<
用于输入重定向,2>
用于 stderr 重定向)以及更重要的是管道(参见章节,无缝组合工具),这使得程序设计师能够高度简化。不需要硬编码(或者甚至软编码,无论如何)输入和输出源或接收器。它就像预期的那样工作。
让我们回顾一些快速示例,以说明这一重要观点。
字数统计
我下载的 C netcat.c
源文件中有多少行源代码?(在这里,我们使用了流行的开源netcat
实用程序代码库的一小部分。)我们使用wc
实用程序。在我们进一步之前,wc
是什么?word count(wc)是一个过滤器:它从 stdin 读取输入,计算输入流中的行数、单词数和字符数,并将结果写入其 stdout。此外,作为一种便利,可以将文件名作为参数传递给它;传递-l
选项开关使 wc 只打印行数:
$ wc -l src/netcat.c
618 src/netcat.c
$
在这里,输入是作为参数传递给wc
的文件名。
有趣的是,我们现在应该意识到,如果我们不向它传递任何参数,wc
将从 stdin 读取其输入,默认情况下是键盘设备。例如如下所示:
$ wc -l
hey, a small
quick test
of reading from stdin
by wc!
^D
4
$
是的,我们在 stdin 中输入了4
行;因此结果是 4,写入 stdout - 默认情况下是终端设备。
这就是它的美丽之处:
$ wc -l < src/netcat.c > num
$ cat num
618
$
正如我们所看到的,wc 是 Unix 过滤器的一个很好的例子。
猫
Unix,当然还有 Linux,用户学会快速熟悉日常使用的cat
实用程序。乍一看,cat 所做的就是将文件的内容输出到终端。
例如,假设我们有两个纯文本文件,myfile1.txt
和myfile2.txt
:
$ cat myfile1.txt
Hello,
Linux System Programming,
World.
$ cat myfile2.txt
Okey dokey,
bye now.
$
好的。现在看看这个:
$ cat myfile1.txt myfile2.txt
Hello,
Linux System Programming,
World.
Okey dokey,
bye now.
$
我们只需要运行cat
一次,通过将两个文件名作为参数传递给它。
理论上,可以向 cat 传递任意数量的参数:它将一个接一个地使用它们!
不仅如此,还可以使用 shell 通配符(*
和?
;实际上,shell 将首先扩展通配符,并将结果路径名作为参数传递给被调用的程序):
$ cat myfile?.txt
Hello,
Linux System Programming,
World.
Okey dokey,
bye now.
$
事实上,这实际上说明了另一个关键点:任何数量的参数或没有参数都被认为是设计程序的正确方式。当然,每个规则都有例外:有些程序要求强制参数。
等等,还有更多。cat
也是 Unix 过滤器的一个很好的例子(回想一下:过滤器是一个从其标准输入读取的程序,以某种方式修改其输入,并将结果写入其标准输出的程序)。
那么,快速测验,如果我们只是运行cat
而没有参数,会发生什么?
好吧,让我们试一试看看:
$ cat
hello,
hello,
oh cool
oh cool
it reads from stdin,
it reads from stdin,
and echoes whatever it reads to stdout!
and echoes whatever it reads to stdout!
ok bye
ok bye
^D
$
哇,看看:cat
在其标准输入处阻塞(等待),用户输入一个字符串并按 Enter 键,cat
通过将其标准输入复制到其标准输出来做出响应-毫不奇怪,因为这就是猫的工作要点!
我们意识到以下命令如下所示:
-
cat fname
等同于cat < fname
-
cat > fname
创建或覆盖fname
文件
没有理由我们不能使用 cat 将几个文件追加在一起:
$ cat fname1 fname2 fname3 > final_fname
$
这不一定要使用纯文本文件;也可以合并二进制文件。
事实上,这就是这个实用程序所做的-它连接文件。因此它的名字;与 Unix 上的规范一样,高度缩写-从 concatenate 到 cat。再次,干净而优雅-Unix 的方式。
猫将文件内容输出到标准输出,按顺序。如果想要以相反的顺序(最后一行先)显示文件的内容怎么办?使用 Unix 的tac
实用程序-是的,就是猫的拼写反过来!
另外,FYI,我们看到 cat 可以用来高效地连接文件。猜猜:split (1)
实用程序可以用来将文件分割成多个部分。
无缝地组合工具
我们刚刚看到,常见的 Unix 实用程序通常被设计为过滤器,这使它们能够从它们的标准输入读取,并将结果写入它们的标准输出。这个概念被优雅地扩展到无缝地组合多个实用程序,使用一个叫做管道的 IPC 机制。
此外,我们还记得 Unix 哲学拥抱只做一项任务的设计。如果我们有一个执行任务 A 的程序和另一个执行任务 B 的程序,我们想要将它们组合起来怎么办?啊,这正是管道所做的!参考以下代码:
prg_does_taskA | prg_does_taskB
管道本质上是重定向执行两次:左侧程序的输出成为右侧程序的输入。当然,这意味着左侧的程序必须写入 stdout,右侧的程序必须从 stdin 读取。
例如:按可用空间(以相反顺序)对挂载的文件系统列表进行排序。
正如我们已经在一个工具只做一项任务部分讨论过的例子一样,我们不会重复相同的信息。
选项 1:使用临时文件执行以下代码(参考部分,一个工具只做一项任务):
$ df --local | sed '1d' > tmp
$ sed --in-place '1d' tmp
$ sort -k4nr tmp
rootfs 20640636 1155484 18436736 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
$ rm -f tmp
选项 2:使用管道-干净而优雅:
$ df --local | sed '1d' | sort -k4nr
rootfs 20640636 1155492 18436728 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
$
这不仅优雅,而且在性能上也更加出色,因为写入内存(管道是一个内存对象)比写入磁盘要快得多。
一个可以扩展这个概念,并通过多个管道组合多个工具;实际上,可以通过组合它们来构建一个超级工具。
例如:显示占用最多(物理)内存的三个进程;仅显示它们的 PID,虚拟大小(VSZ),驻留集大小(RSS)(RSS 是对物理内存使用的相当准确的度量),以及名称:
$ ps au | sed '1d' | awk '{printf("%6d %10d %10d %-32s\n", $2, $5, $6, $11)}' | sort -k3n | tail -n3
10746 3219556 665252 /usr/lib64/firefox/firefox
10840 3444456 1105088 /usr/lib64/firefox/firefox
1465 5119800 1354280 /usr/bin/gnome-shell
$
在这里,我们通过四个管道组合了五个实用程序,ps
,sed
,awk
,sort
和tail
。不错!
另一个例子:显示占用最多内存(RSS)的进程,不包括守护进程*:
ps aux | awk '{if ($7 != "?") print $0}' | sort -k6n | tail -n1
守护进程是系统后台进程;我们将在守护进程这里介绍这个概念:www.packtpub.com/sites/default/files/downloads/Daemon_Processes.pdf
。
纯文本优先
Unix 程序通常设计为使用文本,因为它是一个通用接口。当然,有一些实用程序确实操作二进制对象(如对象和可执行文件);我们在这里不是指它们。重点是:Unix 程序设计为在文本上工作,因为它简化了程序的设计和架构。
一个常见的例子:一个应用程序在启动时解析配置文件。配置文件可以格式化为二进制数据块。另一方面,将其作为纯文本文件使其易于阅读(无价!),因此更容易理解和维护。有人可能会认为解析二进制会更快。也许在某种程度上是这样,但考虑以下情况:
-
在现代硬件上,差异可能并不显著
-
标准化的纯文本格式(如 XML)将优化代码以解析它,从而产生双重好处
记住,简单是关键!
CLI,而不是 GUI
Unix 操作系统及其所有应用程序、实用程序和工具都是为了从命令行界面(CLI)而构建的,通常是 shell。从 20 世纪 80 年代开始,对图形用户界面(GUI)的需求变得明显。
麻省理工学院的 Robert Scheifler 被认为是 X Window 系统的首席设计架构师,他构建了一个非常干净和优雅的架构,其中的一个关键组成部分是:GUI 形成了 OS 上方的一层(实际上是几层),为 GUI 客户端即应用程序提供库。
GUI 从来不是设计为应用程序或操作系统的固有部分 - 它始终是可选的。
这种架构今天仍然有效。话虽如此,尤其是在嵌入式 Linux 上,出于性能原因,新架构的出现,比如帧缓冲区和 Wayland。此外,尽管使用 Linux 内核的 Android 需要为最终用户提供 GUI,但系统开发人员与 Android 的接口 ADB 是 CLI。
大量的生产嵌入式和服务器 Linux 系统纯粹依靠 CLI 界面运行。GUI 几乎就像是一个附加功能,为最终用户的操作方便。
在适当的地方,设计您的工具以在 CLI 环境中工作;稍后将其适应 GUI 就变得简单了。
清晰而谨慎地将项目或产品的业务逻辑与其 GUI 分离是良好设计的关键。
模块化,设计为他人重新利用
从 Unix 操作系统的早期开始,它就被有意地设计和编码,假定多个程序员将在系统上工作。因此,编写干净、优雅和易于理解的代码的文化,以便其他有能力的程序员阅读和使用,已经根深蒂固。
后来,随着 Unix 战争的出现,专有和法律上的关注超越了这种共享模式。有趣的是,历史表明 Unix 在相关性和行业使用方面逐渐失去了地位,直到及时出现了 Linux 操作系统 - 这是一个开源生态系统的最佳体现!今天,Linux 操作系统被广泛认为是最成功的 GNU 项目。确实讽刺!
提供机制,而不是政策
让我们用一个简单的例子来理解这个原则。
在设计应用程序时,您需要用户输入登录name
和password
。执行获取和检查密码工作的函数称为,比如说,mygetpass()
。它由mylogin()
函数调用:mylogin() → mygetpass()
。
现在,要遵循的协议是:如果用户连续三次输入错误密码,程序不应允许访问(并应记录该情况)。好吧,但我们在哪里检查这个?
Unix 哲学:如果密码在mygetpass()
函数中被错误指定三次,不要实现逻辑,而是让mygetpass()
返回一个布尔值(密码正确时为 true,密码错误时为 false),并让调用mylogin()
函数实现所需的逻辑。
伪代码
以下是错误的方法:
mygetpass()
{
numtries=1
<get the password>
if (password-is-wrong) {
numtries ++
if (numtries >= 3) {
<write and log failure message>
<abort>
}
}
<password correct, continue>
}
mylogin()
{
mygetpass()
}
现在让我们来看看正确的方法:Unix 的方式!参考以下代码:
mygetpass()
{
<get the password>
if (password-is-wrong)
return false;
return true;
}
mylogin()
{
maxtries = 3
while (maxtries--) {
if (mygetpass() == true)
<move along, call other routines>
}
// If we're here, we've failed to provide the
// correct password
<write and log failure message>
<abort>
}
mygetpass()
的工作是从用户那里获取密码并检查它是否正确;它将成功或失败的结果返回给调用者-就是这样。这就是机制。它的工作不是决定如果密码错误该怎么办-这是策略,留给调用者决定。
现在我们已经简要介绍了 Unix 哲学,那么对于你作为 Linux 系统开发者来说,重要的要点是什么呢?
在设计和实现 Linux 操作系统上的应用程序时,从 Unix 哲学中学习并遵循将会带来巨大的回报。你的应用程序将会做到以下几点:
-
成为系统的自然适应部分;这一点非常重要
-
大大减少了复杂性
-
拥有一个干净而优雅的模块化设计
-
更易于维护
Linux 系统架构
为了清楚地理解 Linux 系统架构,首先需要了解一些重要的概念:处理器应用二进制接口(ABI)、CPU 特权级别以及这些如何影响我们编写的代码。因此,在几个代码示例中,我们将在这里深入探讨这些内容,然后再深入了解系统架构的细节。
准备工作
如果有人问“CPU 是用来做什么的?”,答案显而易见:CPU 是机器的核心,它读取、解码和执行机器指令,处理内存和外围设备。它通过各种阶段来实现这一点。
简单来说,在指令获取阶段,它从内存(RAM)或 CPU 缓存中读取机器指令(我们以各种人类可读的方式表示,如十六进制、汇编和高级语言)。然后,在指令解码阶段,它继续解析指令。在此过程中,它利用控制单元、寄存器集、ALU 和内存/外围接口。
ABI
让我们想象一下,我们编写了一个 C 程序,并在机器上运行它。
等一下。C 代码不可能直接被 CPU 解析;它必须被转换成机器语言。因此,我们了解到在现代系统上我们将安装一个工具链 - 这包括编译器、链接器、库对象和各种其他工具。我们编译和链接 C 源代码,将其转换为可在系统上运行的可执行格式。
处理器指令集架构(ISA)- 记录了机器的指令格式、支持的寻址方案和寄存器模型。事实上,CPU原始设备制造商(OEMs)发布了一份描述机器工作原理的文档;这份文档通常被称为 ABI。ABI 不仅描述了 ISA,还描述了机器指令格式、寄存器集细节、调用约定、链接语义和可执行文件格式,比如 ELF。尝试在谷歌上搜索 x86 ABI - 这应该会显示出有趣的结果。
出版商在他们的网站上提供了本书的完整源代码;我们建议读者在以下 URL 上进行快速的 Git 克隆。构建并尝试它:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux
。
让我们试一试。首先,我们编写一个简单的Hello, World
类型的 C 程序:
$ cat hello.c /*
* hello.c
*
****************************************************************
* This program is part of the source code released for the book
* "Linux System Programming"
* (c) Kaiwan N Billimoria
* Packt Publishers
*
* From:
* Ch 1 : Linux System Architecture
****************************************************************
* A quick 'Hello, World'-like program to demonstrate using
* objdump to show the corresponding assembly and machine
* language.
*/
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
int a;
printf("Hello, Linux System Programming, World!\n");
a = 5;
exit(0);
} $
我们通过Makefile
和make
构建应用程序。理想情况下,代码必须在没有警告的情况下编译通过。
$ gcc -Wall -Wextra hello.c -o hello
hello.c: In function ‘main':
hello.c:23:6: warning: variable ‘a' set but not used [-Wunused-but-set-variable]
int a;
^
$
重要!不要忽略生产代码中的编译器警告。努力消除所有警告,即使看似微不足道的警告也是如此;这将对正确性、稳定性和安全性有很大帮助。
在这个简单的示例代码中,我们理解并预期了gcc
发出的未使用变量警告,并且只是为了演示目的而忽略它。
您在系统上看到的确切警告和/或错误消息可能与您在此处看到的不同。这是因为我的 Linux 发行版(和版本)、编译器/链接器、库版本,甚至可能是 CPU,可能与您的不同。我在运行 Fedora 27/28 Linux 发行版的 x86_64 框上构建了这个。
同样,我们构建了hello
程序的调试版本(暂时忽略警告),并运行它:
$ make hello_dbg
[...]
$ ./hello_dbg
Hello, Linux System Programming, World!
$
我们使用强大的**objdump
**实用程序来查看程序的源代码、汇编语言和机器语言的混合(objdump
的–source 选项开关)
-S, --source 将源代码与反汇编混合
):
$ objdump --source ./hello_dbg ./hello_dbg: file format elf64-x86-64
Disassembly of section .init:
0000000000400400 <_init>:
400400: 48 83 ec 08 sub $0x8,%rsp
[...]
int main(void)
{
400527: 55 push %rbp
400528: 48 89 e5 mov %rsp,%rbp
40052b: 48 83 ec 10 sub $0x10,%rsp
int a;
printf("Hello, Linux System Programming, World!\n");
40052f: bf e0 05 40 00 mov $0x4005e0,%edi
400534: e8 f7 fe ff ff callq 400430 <puts@plt>
a = 5;
400539: c7 45 fc 05 00 00 00 movl $0x5,-0x4(%rbp)
exit(0);
400540: bf 00 00 00 00 mov $0x0,%edi
400545: e8 f6 fe ff ff callq 400440 <exit@plt>
40054a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
[...]
$
您在系统上看到的确切汇编和机器代码很可能与您在此处看到的不同;这是因为我的 Linux 发行版(和版本)、编译器/链接器、库版本,甚至可能是 CPU,可能与您的不同。我在运行 Fedora Core 27 的 x86_64 框上构建了这个。
好吧。让我们看一下源代码行a = 5;
,objdump
显示了相应的机器和汇编语言:
a = 5;
400539: c7 45 fc 05 00 00 00 movl $0x5,-0x4(%rbp)
我们现在可以清楚地看到以下内容:
C 源代码 | 汇编语言 | 机器指令 |
---|---|---|
a = 5; | movl $0x5,-0x4(%rbp) | c7 45 fc 05 00 00 00 |
因此,当进程运行时,它将在某个时刻获取并执行机器指令,产生期望的结果。确实,这正是可编程计算机的设计目的!
虽然我们已经展示了显示(甚至写了一点)英特尔 CPU 的汇编和机器代码的示例,但是这个讨论背后的概念和原则对其他 CPU 架构,如 ARM、PPC 和 MIPS,也同样适用。涵盖所有这些 CPU 的类似示例超出了本书的范围;然而,我们建议感兴趣的读者研究处理器数据表和 ABI,并尝试一下。
通过内联汇编访问寄存器的内容
现在我们已经编写了一个简单的 C 程序并看到了它的汇编和机器代码,让我们继续进行一些更具挑战性的工作:一个带有内联汇编的 C 程序,以访问 CPU 寄存器的内容。
有关汇编语言编程的详细信息超出了本书的范围;请参阅 GitHub 存储库上的进一步阅读部分。
x86_64 有几个寄存器;让我们就以普通的 RCX 寄存器为例。我们确实使用了一个有趣的技巧:x86 ABI 调用约定规定函数的返回值将是放在累加器中的值,也就是 x86_64 的 RAX。利用这个知识,我们编写一个使用内联汇编将我们想要的寄存器内容放入 RAX 的函数。这确保了这是它将返回给调用者的内容!
汇编微基础包括以下内容:
at&t 语法:
movq <src_reg>, <dest_reg>
寄存器:前缀名称为%
立即值:前缀为$
有关更多信息,请参阅 GitHub 存储库上的进一步阅读部分。
让我们来看一下以下代码:
$ cat getreg_rcx.c
/*
* getreg_rcx.c
*
****************************************************************
* This program is part of the source code released for the book
* "Linux System Programming"
* (c) Kaiwan N Billimoria
* Packt Publishers
*
* From:
* Ch 1 : Linux System Architecture
****************************************************************
* Inline assembly to access the contents of a CPU register.
* NOTE: this program is written to work on x86_64 only.
*/
#include <stdio.h>#include <unistd.h>
#include <stdlib.h>
typedef unsigned long u64;
static u64 get_rcx(void)
{
/* Pro Tip: x86 ABI: query a register's value by moving its value into RAX.
* [RAX] is returned by the function! */
__asm__ __volatile__(
"push %rcx\n\t"
"movq $5, %rcx\n\t"
"movq %rcx, %rax");
/* at&t syntax: movq <src_reg>, <dest_reg> */
__asm__ __volatile__("pop %rcx");
}
int main(void)
{
printf("Hello, inline assembly:\n [RCX] = 0x%lx\n",
get_rcx());
exit(0);}
$ gcc -Wall -Wextra getreg_rcx.c -o getreg_rcx
getreg_rcx.c: In function ‘get_rcx':
getreg_rcx.c:32:1: warning: no return statement in function returning non-void [-Wreturn-type]
}
^
$ ./getreg_rcx Hello, inline assembly:
[RCX] = 0x5
$
在这里;它按预期工作。
通过内联汇编访问控制寄存器的内容
在 x86_64 处理器上有许多引人入胜的寄存器,其中有六个控制寄存器,命名为 CR0 到 CR4 和 CR8。没有必要详细讨论它们;可以说它们对系统控制至关重要。
为了举例说明,让我们暂时考虑一下 CR0 寄存器。英特尔的手册指出:CR0-包含控制处理器操作模式和状态的系统控制标志。
英特尔的手册可以从这里方便地下载为 PDF 文档(包括英特尔® 64 和 IA-32 体系结构软件开发人员手册,第 3 卷(3A、3B 和 3C):系统编程指南):
software.intel.com/en-us/articles/intel-sdm
显然,CR0 是一个重要的寄存器!
我们修改了之前的程序以访问并显示其内容(而不是普通的RCX
寄存器)。唯一相关的代码(与之前的程序不同)是查询CR0
寄存器值的函数:
static u64 get_cr0(void)
{
/* Pro Tip: x86 ABI: query a register's value by moving it's value into RAX.
* [RAX] is returned by the function! */
__asm__ __volatile__("movq %cr0, %rax");
/* at&t syntax: movq <src_reg>, <dest_reg> */
}
构建并运行它:
$ make getreg_cr0
[...]
$ ./getreg_cr0
Segmentation fault (core dumped)
$
它崩溃了!
嗯,这里发生了什么?继续阅读。
CPU 特权级别
正如本章前面提到的,CPU 的基本工作是从内存中读取机器指令,解释并执行它们。在计算机的早期,这几乎是处理器所做的全部工作。但后来,工程师们更深入地思考了这个问题,意识到其中存在一个关键问题:如果程序员可以向处理器提供任意的机器指令流,而处理器又盲目地、顺从地执行它们,那么就存在损害、黑客攻击机器的可能性!
如何?回想一下前一节中提到的英特尔处理器的 CR0 控制寄存器:包含控制处理器操作模式和状态的系统控制标志。如果有无限(读/写)访问 CR0 寄存器的权限,就可以切换位,从而可以做到以下几点:
-
打开或关闭硬件分页
-
禁用 CPU 缓存
-
更改缓存和对齐属性
-
禁用操作系统标记为只读的内存(技术上是页面)上的 WP(写保护)
哇,黑客确实可以造成严重破坏。至少,只有操作系统应该被允许这种访问。
正是出于安全、健壮性和操作系统及其控制的硬件资源的正确性等原因,所有现代 CPU 都包括特权级别的概念。
现代 CPU 将支持至少两个特权级别或模式,通常称为以下内容:
-
管理员
-
用户
您需要了解的是,即机器指令在 CPU 上以给定的特权级别或模式运行。设计和实现操作系统的人可以利用处理器特权级别。这正是现代操作系统的设计方式。看一下以下表格通用 CPU 特权级别:
特权级别或模式名称 | 特权级别 | 目的 | 术语 |
---|---|---|---|
管理员 | 高 | 操作系统代码在这里运行 | 内核空间 |
用户 | 低 | 应用程序代码在这里运行 | 用户空间(或用户区) |
表 1:通用 CPU 特权级别
x86 特权级或环
为了更好地理解这个重要概念,让我们以流行的 x86 架构作为一个真实的例子。从 i386 开始,英特尔处理器支持四个特权级别或环:Ring 0、Ring 1、Ring 2 和 Ring 3。在英特尔 CPU 上,这就是这些级别的工作方式:
图 1:CPU 环级别和特权
让我们将图 1以表 2:x86 特权或环级别的形式进行可视化:
特权或环级别 | 特权 | 目的 |
---|---|---|
环 0 | 最高 | 操作系统代码在这里运行 |
环 1 | <环 0 | <未使用> |
环 2 | <环 1 | <未使用> |
环 3 | 最低 | 应用程序代码在这里运行(用户空间) |
表 2:x86 特权或环级别
最初,环级别 1 和 2 是为设备驱动程序而设计的,但现代操作系统通常在环 0 中运行驱动程序代码。一些虚拟化程序(例如 VirtualBox)曾经使用环 1 来运行客户机内核代码;在没有硬件虚拟化支持(如 Intel VT-x、AMD SV)时,这是早期的情况。
ARM(32 位)处理器有七种执行模式;其中六种是特权的,只有一种是非特权模式。在 ARM 上,相当于英特尔的 Ring 0 是 Supervisor(SVC)模式,相当于英特尔的 Ring 3 是用户模式。
对于感兴趣的读者,在 GitHub 存储库的进一步阅读部分中有更多链接。
以下图表清楚地显示了所有现代操作系统(Linux、Unix、Windows 和 macOS)在 x86 处理器上利用处理器特权级别:
图 2:用户-内核分离
重要的是,处理器 ISA 为每条机器指令分配了一个特权级别或允许执行的多个特权级别。允许在用户特权级别执行的机器指令自动意味着它也可以在监管特权级别执行。对于寄存器访问,也适用于区分在哪种模式下可以做什么和不能做什么。
用英特尔的术语来说,当前特权级别(CPL)是处理器当前执行代码的特权级别。
例如,在给定的处理器上,如下所示:
-
foo1 机器指令的允许特权级别为监管者(或 x86 的 Ring 0)
-
foo2 机器指令的允许特权级别为用户(或 x86 的 Ring 3)
因此,对于执行这些机器指令的运行应用程序,出现了以下表格:
机器指令 | 允许的模式 | CPL(当前特权级别) | 可行? |
---|---|---|---|
foo1 | 监管者(0) | 0 | 是 |
3 | 否 | ||
foo2 | 用户(3) | 0 | 是 |
3 | 是 |
表 3:特权级别示例
因此,考虑到 foo2 在用户模式下被允许执行,也将被允许以任何 CPL 执行。换句话说,如果 CPL <= 允许的特权级别,则可以执行,否则不行。
当在 Linux 上运行应用程序时,应用程序作为一个进程运行(稍后会详细介绍)。但应用程序代码运行在什么特权级别(或模式或环)下?参考前面的表格:用户模式(x86 上的 Ring 3)。
啊哈!现在我们明白了。前面的代码示例getreg_rcx.c
之所以能够工作,是因为它试图访问通用寄存器RCX
的内容,在用户模式(Ring 3)下是允许的,当然在其他级别也是允许的!
但getreg_cr0.c
的代码失败了;它崩溃了,因为它试图访问CR0
控制寄存器的内容,在用户模式(Ring 3)下是不允许的,只有在 Ring 0 特权级别下才允许!只有操作系统或内核代码才能访问控制寄存器。这对其他一些敏感的汇编语言指令也是适用的。这种方法非常有道理。
从技术上讲,它崩溃是因为处理器引发了通用保护故障(GPF)。
Linux 架构
Linux 系统架构是分层的。以一种非常简单的方式来说,但是理想的开始我们理解这些细节的路径,以下图表说明了 Linux 系统架构:
图 3:Linux - 简化的分层架构
层有助于减少复杂性,因为每一层只需要关注它的上一层和下一层。这带来了许多优势:
-
清晰的设计,减少复杂性
-
标准化,互操作性
-
能够在堆栈中轻松地切换层
-
能够根据需要轻松引入新的层
在最后一点上,存在 FTSE。直接引用维基百科的话:
“软件工程的基本定理(FTSE)”是由安德鲁·科尼格创造的术语,用来描述对已故的大卫·J·惠勒所做的评论。
我们可以通过引入额外的间接层来解决任何问题。
现在我们理解了 CPU 模式或特权级别的概念,以及现代操作系统如何利用它们,Linux 系统架构的更好的图表(在前一个图表的基础上扩展)如下所示:
图 4:Linux 系统架构
在上图中,P1、P2、…、Pn 只是用户空间进程(进程 1、进程 2)或者换句话说,正在运行的应用程序。例如,在 Linux 笔记本上,我们可能有 vim 编辑器、一个网页浏览器和终端窗口(gnome-terminal)正在运行。
库
当然,库是代码的存档(集合);正如我们所知,使用库对于代码的模块化、标准化、防止重复发明轮子综合症等方面有很大帮助。Linux 桌面系统可能有数百个库,甚至可能有几千个!
经典的 K&R hello, world
C 程序使用printf
API 将字符串写入显示器:
printf(“hello, world\n”);
显然,printf
的代码不是hello, world
源代码的一部分。那它是从哪里来的?它是标准 C 库的一部分;在 Linux 上,由于其 GNU 起源,这个库通常被称为GNU libc(glibc)。
Glibc 是 Linux 盒子上的一个关键和必需的组件。它不仅包含通常的标准 C 库例程(APIs),事实上,它是操作系统的编程接口!如何?通过它的较低层,系统调用。
系统调用
系统调用实际上是可以通过 glibc 存根例程从用户空间调用的内核功能。它们提供了关键功能;它们将用户空间连接到内核空间。如果用户程序想要请求内核的某些东西(从文件中读取,写入网络,更改文件权限),它会通过发出系统调用来实现。因此,系统调用是用户空间进入内核的唯一合法入口。用户空间进程没有其他方法可以调用内核。
有关所有可用 Linux 系统调用的列表,请参阅 man 页面的第二部分(linux.die.net/man/2/
)。也可以执行:man 2 syscalls 来查看所有支持的系统调用的 man 页面
另一种思考方式:Linux 内核内部实际上有成千上万的 API(或函数)。其中,只有很小一部分是可见或可用的,也就是暴露给用户空间的;这些暴露的内核 API 就是系统调用!同样,作为一个近似值,现代 Linux glibc 大约有 300 个系统调用。
在运行 4.13.16-302.fc27.x86_64 内核的 x86_64 Fedora 27 盒子上,有接近 53000 个内核 API!
系统调用与所有其他(通常是库)API 非常不同。由于它们最终调用内核(操作系统)代码,它们有能力跨越用户-内核边界;实际上,它们有能力从普通的非特权用户模式切换到完全特权的监督员或内核模式!
如何?不深入了解细节,系统调用基本上是通过调用具有内置能力从用户模式切换到监督员的特殊机器指令来工作的。所有现代 CPU ABI 都将提供至少一条这样的机器指令;在 x86 处理器上,实现系统调用的传统方式是使用特殊的 int 0x80 机器指令。是的,这确实是一个软件中断(或陷阱)。从奔腾 Pro 和 Linux 2.6 开始,使用 sysenter/syscall 机器指令。请参阅 GitHub 存储库上的进一步阅读部分。
从应用程序开发人员的角度来看,关于系统调用的一个关键点是,系统调用似乎是可以被开发人员调用的常规函数(APIs);这种设计是故意的。实际情况:开发人员调用的系统调用 API(如open()
、read()
、chmod()
、dup()
和write()
)只是存根。它们是一种很好的机制,可以访问内核中的实际代码(通过在 x86 上将累加器寄存器填充为系统调用编号,并通过其他通用寄存器传递参数)来执行内核代码路径,并在完成后返回到用户模式。参考以下表格:
CPU | 用于从用户模式陷入监督员(内核)模式的机器指令 | 用于系统调用编号的分配寄存器 |
---|---|---|
x86[_64] | int 0x80 或 syscall | EAX / RAX |
ARM | swi / svc | R0 到 R7 |
Aarch64 | svc | X8 |
MIPS | syscall | $v0 |
表 4:各种 CPU 架构上的系统调用,以便更好地理解
Linux - 一个单体操作系统
操作系统通常被认为遵循两种主要的架构风格之一:单体或微内核。
Linux 显然是一个单体操作系统。
这是什么意思?
英语单词 monolith 字面上意味着一个大的单立的石块:
图 5:科林斯柱 - 它们是单体的!
在 Linux 操作系统上,应用程序作为独立实体称为进程运行。一个进程可以是单线程(原始 Unix)或多线程。不管怎样,现在,我们将进程视为 Linux 上的执行单元;进程被定义为正在执行的程序的一个实例。
当用户空间进程发出库调用时,库 API 可能会或可能不会发出系统调用。例如,发出atoi(3)
API 并不会导致 glibc 发出系统调用,因为它不需要内核支持来实现将字符串转换为整数。<api-name>(n)
; n 是 man 手册部分。
为了帮助澄清这些重要概念,让我们再次看看著名的经典 K&R Hello, World
C 程序:
#include <stdio.h>
main()
{
printf(“hello, world\n”);
}
好的,应该可以。确实可以。
但问题是,printf(3)
API 究竟如何写入监视器设备?
简短的答案:不是这样的。
事实上,printf(3)
只有智能来格式化指定的字符串;就是这样。一旦完成,printf
实际上调用了write(2)
API - 一个系统调用。写系统调用确实有能力将缓冲区内容写入到一个特殊的设备文件 - 监视器设备,被写入视为标准输出。回到我们关于Unix 哲学的核心的讨论:如果不是一个进程,那就是一个文件!当然,在内核下面它变得非常复杂;长话短说,写的内核代码最终切换到正确的驱动程序代码;设备驱动程序是唯一可以直接与外围硬件交互的组件。它执行实际的写入到监视器,并且返回值一直传播回应用程序。
在下图中,P是hello, world
在运行时的进程:
图 6:代码流程:printf 到内核
此外,从图中我们可以看到,glibc 被认为由两部分组成:
-
与架构无关的 glibc:常规的 libc API(如[s|sn|v]printf,memcpy,memcmp,atoi)
-
与架构相关的 glibc:系统调用存根
在这里,arch 指的是 CPU。
另外,省略号(…)代表内核空间中我们没有展示或深入探讨的额外逻辑和处理。
现在hello, world
的代码流路径更清晰了,让我们回到单体的东西!
很容易假设它是这样工作的:
-
这个
hello, world
应用程序(进程)发出了printf(3)
库调用。 -
printf
发出了write(2)
系统调用。 -
我们从用户模式切换到监管者(内核)模式。
-
内核接管 - 它将
hello, world
写入监视器。 -
切换回非特权用户模式。
实际上,情况并非如此。
事实上,在单体设计中,没有内核;换句话说,内核实际上是进程本身的一部分。它的工作方式如下:
-
这个
hello, world
应用程序(进程)发出了printf(3)
库调用。 -
printf 发出了
write(2)
系统调用。 -
发出系统调用的进程现在从用户模式切换到监管者(内核)模式。
-
进程运行底层内核代码,底层设备驱动程序代码,因此,将
hello, world
写入监视器! -
然后进程被切换回非特权用户模式。
总之,在单片内核中,当一个进程(或线程)发出系统调用时,它会切换到特权的监督者或内核模式,并运行系统调用的内核代码(处理内核数据)。完成后,它会切换回非特权的用户模式,并继续执行用户空间代码(处理用户数据)。
这一点非常重要要理解:
图 7:进程的特权模式生命周期
前面的图尝试说明 X 轴是时间线,Y 轴代表用户模式(顶部)和监督者(内核)模式(底部):
-
时间 t[0]:一个进程在内核模式下诞生(当然,创建进程的代码在内核中)。一旦完全诞生,它就会切换到用户(非特权)模式,并运行其用户空间代码(同时处理其用户空间数据项)。
-
时间 t[1]:进程直接或间接(可能通过库 API)调用系统调用。现在它陷入内核模式(参考表格CPU 架构上的系统调用显示了根据 CPU 的机器指令)并在特权监督者模式下执行内核代码(处理内核数据项)。
-
时间 t[2]:系统调用完成;进程切换回非特权用户模式并继续执行其用户空间代码。这个过程会一直持续,直到未来的某个时间点。
-
时间 t[n]:进程死亡,要么是通过调用退出 API 故意退出,要么是被信号杀死。现在它切换回监督者模式(因为 exit(3)库 API 调用 _exit(2)系统调用),执行 _exit()的内核代码,并终止。
事实上,大多数现代操作系统都是单片内核的(尤其是类 Unix 的操作系统)。
从技术上讲,Linux 并不被认为是 100%的单片内核。它被认为是大部分单片内核,但也是模块化的,因为 Linux 内核支持模块化(通过一种称为可加载内核模块(LKM)的技术插入和拔出内核代码和数据)。
有趣的是,MS Windows(特别是从 NT 内核开始)遵循了既是单片内核又是微内核的混合架构。
内核内的执行上下文
内核代码总是在两种上下文中的一种中执行:
-
进程
-
中断
在这里很容易混淆。请记住,这个讨论适用于内核代码执行的上下文,而不是用户空间代码。
进程上下文
现在我们明白了可以通过发出系统调用来调用内核服务。当这种情况发生时,调用进程以内核模式运行系统调用的内核代码。这被称为进程上下文 - 内核代码现在在调用系统调用的进程的上下文中运行。
进程上下文代码具有以下属性:
-
总是由进程(或线程)发出系统调用来触发
-
自上而下的方法
-
进程通过同步执行内核代码
中断上下文
乍一看,似乎没有其他方式可以执行内核代码。好吧,想想这种情况:网络接收路径。一个发送到您以太网 MAC 地址的网络数据包到达硬件适配器,硬件检测到它是为它而来的,收集它并缓冲它。现在它必须让操作系统知道;更准确地说,它必须让网络接口卡(NIC)设备驱动程序知道,以便它可以在到达时获取和处理数据包。它通过断言硬件中断来激活 NIC 驱动程序。
回想一下,设备驱动程序驻留在内核空间,因此它们的代码在特权或内核模式下运行。现在(内核特权)驱动程序代码中断服务例程(ISR)执行,获取数据包,并将其发送到操作系统网络协议栈进行处理。
网卡驱动程序的 ISR 代码是内核代码,但它在什么上下文中运行?显然不是在任何特定进程的上下文中。实际上,硬件中断可能中断了某个进程。因此,我们只是称之为中断上下文。
中断上下文代码具有以下属性:
-
始终由硬件中断触发(不是软件中断、故障或异常;那仍然是进程上下文)
-
自下而上的方法
-
通过中断异步执行内核代码
如果在某个时候报告内核错误,指出执行上下文会有所帮助。
从技术上讲,在中断上下文中,我们有进一步的区分,比如硬中断和软中断,底半部分和任务。然而,这个讨论超出了本书的范围。
总结
本章首先解释了 Unix 的设计哲学,包括 Unix 哲学、设计和架构的核心原则或支柱。然后我们描述了 Linux 系统架构,其中涵盖了 CPU-ABI(应用程序二进制接口)、ISA 和工具链的含义(使用objdump
来反汇编一个简单的程序,并使用内联汇编访问 CPU 寄存器)。讨论了 CPU 特权级别及其在现代操作系统中的重要性,引出了 Linux 系统架构的层次 - 应用程序、库、系统调用和内核。本章以讨论 Linux 是一个单片操作系统开始,然后探讨了内核执行上下文。
在下一章中,读者将深入探讨虚拟内存的奥秘,并对其有一个扎实的理解 - 它到底意味着什么,为什么它存在于所有现代操作系统中,以及它提供的关键好处。我们将讨论进程虚拟地址空间的相关细节。
第二章:虚拟内存
回到这一章,我们将探讨虚拟内存(VM)的含义和目的,以及为什么它是一个关键概念和必需的概念。我们将涵盖 VM、分页和地址转换的含义和重要性,使用 VM 的好处,进程在执行中的内存布局,以及内核所看到的进程的内部布局。我们还将深入探讨构成进程虚拟地址空间的各个段。在难以调试的情况下,这些知识是不可或缺的。
在本章中,我们将涵盖以下主题:
-
虚拟内存
-
进程虚拟地址空间
技术要求
需要现代台式机或笔记本电脑;Ubuntu 桌面指定以下作为安装和使用发行版的推荐系统要求:
-
2 GHz 双核处理器或更好
-
RAM
-
在物理主机上运行:2 GB 或更多系统内存
-
作为客人运行:主机系统应至少具有 4 GB RAM(越多越好,体验越流畅)
-
25 GB 的空闲硬盘空间
-
安装介质需要 DVD 驱动器或 USB 端口
-
互联网访问肯定是有帮助的
我们建议读者使用以下 Linux 发行版之一(可以安装为 Windows 或 Linux 主机系统上的客户操作系统,如前所述):
-
Ubuntu 18.04 LTS 桌面(Ubuntu 16.04 LTS 桌面也是一个不错的选择,因为它也有长期支持,并且几乎所有功能都应该可以使用)
-
Ubuntu 桌面下载链接:
www.ubuntu.com/download/desktop
-
Fedora 27(工作站)
请注意,这些发行版在其默认形式下是开源的,非专有的,并且作为最终用户可以免费使用。
有时整个代码片段并未包含在书中。因此,GitHub URL 可以参考代码:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux
。
另外,关于进一步阅读的部分,请参考前面的 GitHub 链接。
虚拟内存
现代操作系统基于称为 VM 的内存模型。这包括 Linux、Unix、MS Windows 和 macOS。要真正理解现代操作系统在底层是如何工作的,需要对 VM 和内存管理有深入的理解 - 这些并不是我们在本书中深入讨论的主题;然而,对 VM 概念的扎实掌握对于 Linux 系统开发人员至关重要。
没有 VM - 问题
让我们想象一下,如果 VM 以及它携带的所有复杂负担不存在。因此,我们正在使用一个(虚构的)纯平面物理内存平台,比如说,64 MB RAM。这实际上并不那么不寻常 - 大多数旧的操作系统(比如 DOS)甚至现代的实时操作系统(RTOS)都是这样运行的:
图 1:64 MB 的平面物理地址空间
显然,运行在这台机器上的所有东西都必须共享这个物理内存空间:操作系统、设备驱动程序、库和应用程序。我们可以这样想象(当然,这并不是要反映一个实际的系统 - 这只是一个极为简化的例子,帮助你理解事情):一个操作系统,几个设备驱动程序(驱动硬件外围设备),一组库和两个应用程序。这个虚构的(64 MB 系统)平台的物理内存映射(比例不准确)可能是这样的:
对象 | 占用空间 | 地址范围 |
---|---|---|
操作系统(OS) | 3 MB | 0x03d0 0000 - 0x0400 0000 |
设备驱动程序 | 5 MB | 0x02d0 0000 – 0x0320 0000 |
库 | 10 MB | 0x00a0 0000 – 0x0140 0000 |
应用程序 2 | 1 MB | 0x0010 0000 – 0x0020 0000 |
应用程序 1 | 0.5 MB | 0x0000 0000 – 0x0008 0000 |
总体空闲内存 | 44.5 MB | <各种> |
表 1:物理内存映射
同样的虚构系统在下图中表示:
图 2:我们虚构的 64MB 系统的物理内存映射
当然,系统在发布之前会经过严格的测试,并且会按预期运行;除了,我们行业中可能会出现的问题,你可能听说过,叫做 bug。是的,确实。
但是,让我们想象一下,一个危险的 bug 潜入了 Application 1,比如说在使用普遍的memcpy(3)
glibc API 时,由于以下原因之一:
-
无意的编程错误
-
故意的恶意意图
作为一个快速提醒,memcpy
库 API 的使用如下所示:
void *memcpy(void *dest, const void *src, size_t n).
目标
以下 C 程序片段意图使用通常的memcpy(3)
glibc API 复制一些内存,比如说 1,024 字节,从程序中的源位置 300KB 到程序中的目标位置 400KB。由于 Application 1 是物理内存低端的程序(参见前面的内存映射),它从0x0
物理偏移开始。
我们知道,在现代操作系统上,没有什么会从地址0x0
开始;那是经典的 NULL 内存位置!请记住,这只是一个用于学习目的的虚构示例
首先,让我们看看正确的使用情况。
参考以下伪代码:
phy_offset = 0x0;
src = phy_offset + (300*1024); /* = 0x0004 b000 */
dest = phy_offset + (400*1024); /* = 0x0006 4000 */
n = 1024;
memcpy(dest, src, n);
上述代码的效果如下图所示:
图 3:放大到 App 1:正确的 memcpy()
如前图所示,这是有效的!(大)箭头显示了从源到目的地的复制路径,共 1,024 字节。很好。
现在来看看有 bug 的情况。
一切都一样,只是这一次,由于一个 bug(或者恶意意图),dest
指针被修改如下:
phy_offset = 0x0;
src = phy_offset + (300*1024); /* = 0x0004 b000 */
dest = phy_offset + (400*1024*156); /* = 0x03cf 0000 *!*BUG*!* */
n = 1024;
memcpy(dest, src, n);
目标位置现在大约在 64KB(0x03cf0000 - 0x03d00000)进入操作系统!最好的部分是:代码本身并没有失败*。* memcpy()
完成了它的工作。当然,现在操作系统可能已经损坏,整个系统将(最终)崩溃。
请注意,这里的意图不是为了调试原因(我们知道);这里的意图是要清楚地意识到,尽管有这个 bug,memcpy 仍然成功。
为什么?这是因为我们在用 C 语言编程 - 我们可以自由地读写物理内存,任何意外的错误都是我们的问题,而不是语言的问题!
那现在呢?啊,这就是 VM 系统出现的一个关键原因之一。
虚拟内存
不幸的是,虚拟内存(VM)这个术语经常被工程师大部分人误解或模糊地理解。在本节中,我们试图澄清这个术语及其相关术语(如内存金字塔、寻址和分页)的真正含义;开发人员清楚地理解这一关键领域是很重要的。
首先,什么是进程?
进程是正在执行的程序的一个实例*。*
程序是一个二进制可执行文件:一个死的、磁盘上的对象。例如,拿cat
程序来说:$ ls -l /bin/cat
-rwxr-xr-x 1 root root 36784 Nov 10 23:26 /bin/cat
$
当我们运行cat
时,它变成了一个可以运行的实体,我们在 Unix 宇宙中称之为进程。
为了更清楚地理解更深层次的概念,我们从一个小的、简单的、虚构的机器开始。想象一下,它有一个带有 16 个地址线的微处理器。因此,很容易看出,它将可以访问总共潜在的内存空间(或地址空间)为 2¹⁶ = 65,536 字节 = 64KB:
图 4:64KB 的虚拟内存
但是,如果机器上的物理内存(RAM)少得多,比如 32KB 呢?
显然,前图描述的是虚拟内存,而不是物理内存。
同时,物理内存(RAM)如下所示:
图 5:32KB 的物理内存
尽管系统向每个活动的进程做出了承诺:每个进程都将有整个 64KB 的虚拟地址空间可用。听起来很荒谬,对吧?是的,直到人们意识到内存不仅仅是 RAM;事实上,内存被视为一个层次结构 - 通常被称为存储金字塔:
图 6:存储金字塔
就像生活一样,一切都是一种权衡。在金字塔的顶端,我们在速度方面获得了优势,但代价是尺寸;在金字塔的底部,情况正好相反:尺寸以牺牲速度为代价。人们也可以认为 CPU 寄存器位于金字塔的顶端;由于其尺寸几乎微不足道,因此没有显示。
交换是一种文件系统类型 - 系统安装时,原始磁盘分区被格式化为交换。它被操作系统视为第二级 RAM。当操作系统的 RAM 用完时,它使用交换。作为一个粗略的启发式方法,系统管理员有时会将交换分区的大小配置为可用 RAM 的两倍。
为了帮助量化这一点,根据《计算机体系结构,定量方法,第 5 版》(Hennessy & Patterson)提供了相当典型的数字:
类型 | CPU 寄存器 | CPU 缓存 | RAM | 交换/存储 |
---|---|---|---|---|
L1 | L2 | L3 | ||
服务器 | 1000 字节 | 64KB | 256KB | 2-4MB |
300ps | 1ns | 3-10ns | 10-20ns | 50-100ns |
嵌入式 | 500 字节 | 64KB | 256KB | - |
500ps | 2ns | 10-20ns | - | 50-100ns |
表 2:存储器层次结构数字
许多(如果不是大多数)嵌入式 Linux 系统不支持交换分区;原因很简单:嵌入式系统主要使用闪存作为辅助存储介质(而不是像笔记本电脑、台式机和服务器那样使用传统的 SCSI 磁盘)。写入闪存芯片会使其磨损(它有限制的擦写周期);因此,嵌入式系统设计者宁愿牺牲交换,只使用 RAM。(请注意,嵌入式系统仍然可以是基于虚拟内存的,这在 Linux 和 Win-CE 等系统中是常见情况)。
操作系统将尽最大努力将页面的工作集保持在尽可能高的金字塔位置,以优化性能。
读者需要注意,在接下来的章节中,虽然本书试图解释一些高级主题的内部工作,比如虚拟内存和寻址(分页),但我们故意没有描绘完整、真实世界的视图。
原因很简单:深入和血腥的技术细节远远超出了本书的范围。因此,读者应该记住,以下几个领域中的一些是以概念而不是实际情况来解释的。进一步阅读部分提供了对这些问题感兴趣的读者的参考资料。请在 GitHub 存储库上查看。
寻址 1 - 简单的有缺陷的方法
好的,现在来看看存储金字塔;即使我们同意虚拟内存现在是可能的,一个关键且困难的障碍仍然存在。要解释这一点,请注意,每个活动的进程都将占用整个可用的虚拟地址空间(VAS)。因此,每个进程在 VAS 方面与其他每个进程重叠。但这会怎么样?它本身不会。为了使这个复杂的方案工作,系统必须以某种方式将每个进程中的每个虚拟地址映射到物理地址!参考以下虚拟地址到物理地址的映射:
进程 P:虚拟地址(va)→ RAM:物理地址(pa)
因此,现在的情况是这样的:
图 7:包含虚拟地址的进程
进程 P1、P2 和 Pn 在虚拟内存中活跃。它们的虚拟地址空间覆盖 0 到 64KB,并相互重叠。在这个(虚构的)系统上存在 32KB 的物理内存 RAM。
例如,每个进程的两个虚拟地址以以下格式显示:
P'r':va'n'
;其中r
是进程编号,n
是 1 和 2。
如前所述,现在的关键是将每个进程的虚拟地址映射到物理地址。因此,我们需要映射以下内容:
P1:va1 → P1:pa1
P1:va2 → P1:pa2
...
P2:va1 → P2:pa1
P2:va2 → P2:pa2
...
[...]
Pn:va1 → Pn:pa1
Pn:va2 → Pn:pa2
...
我们可以让操作系统执行这种映射;然后操作系统将维护每个进程的映射表来执行此操作。从图解和概念上看,它如下所示:
图 8:将虚拟地址直接映射到物理 RAM 地址
那就这样了?实际上似乎相当简单。嗯,不,实际上不会这样:要将每个进程的所有可能的虚拟地址映射到 RAM 中的物理地址,操作系统需要维护每个地址每个进程的 va-to-pa 翻译条目!这太昂贵了,因为每个表可能会超过物理内存的大小,使该方案无用。
快速计算表明,我们有 64KB 的虚拟内存,即 65,536 字节或地址。这些虚拟地址中的每一个都需要映射到一个物理地址。因此,每个进程都需要:
- 65536 * 2 = 131072 = 128 KB,用于每个进程的映射表。
实际情况更糟糕;操作系统需要存储一些元数据以及每个地址转换条目;假设 8 字节的元数据。所以现在,每个进程都需要:
- 65536 * 2 * 8 = 1048576 = 1 MB,用于每个进程的映射表。
哇,每个进程需要 1 兆字节的 RAM!这太多了(想象一下嵌入式系统);而且在我们的虚构系统中,总共只有 32KB 的 RAM。哎呀。
好吧,我们可以通过不映射每个字节而映射每个字来减少这种开销;比如,将 4 个字节映射到一个字。所以现在,每个进程都需要:
- (65536 * 2 * 8)/ 4 = 262144 = 256 KB,用于每个进程的映射表。
更好,但还不够好。如果只有 20 个进程在运行,我们需要 5MB 的物理内存来存储映射元数据。在 32KB 的 RAM 中,我们做不到这一点。
地址 2-简要分页
为了解决这个棘手的问题,计算机科学家提出了一个解决方案:不要试图将单个虚拟字节(甚至单词)映射到它们的物理对应物;这太昂贵了。相反,将物理和虚拟内存空间分割成块并进行映射。
有两种广义的方法来做到这一点:
-
硬件分段
-
硬件分页
硬件分段:将虚拟和物理地址空间分割成称为段的任意大小块。最好的例子是英特尔 32 位处理器。
硬件分页:将虚拟和物理地址空间分割成称为页面的等大小块。大多数现实世界的处理器都支持硬件分页,包括英特尔、ARM、PPC 和 MIPS。
实际上,甚至不是由操作系统开发人员选择使用哪种方案:选择由硬件 MMU 决定。
再次提醒读者:这本书的复杂细节超出了范围。请参阅 GitHub 存储库上的“进一步阅读”部分。
假设我们采用分页技术。关键要点是,我们停止尝试将每个进程的所有可能的虚拟地址映射到 RAM 中的物理地址,而是将虚拟页(称为页)映射到物理页(称为页框)。
常见术语
虚拟地址空间:VAS
进程 VAS 中的虚拟页:页
RAM 中的物理页:页框(pf)
不起作用:虚拟地址(va)→物理地址(pa)
起作用:(虚拟)页→页框
左到右的箭头表示映射。
作为一个经验法则(和通常被接受的规范),页面的大小为 4 千字节(4,096 字节)。再次强调,是处理器的内存管理单元(MMU)决定页面的大小。
那么这种方案如何以及为什么有帮助呢?
想一想;在我们的虚构机器中,我们有:64 KB 的虚拟内存,即 64K/4K = 16 页,和 32 KB 的 RAM,即 32K/4K = 8 页帧。
将 16 页映射到相应的页面帧需要每个进程只有 16 个条目的表;这是可行的!
就像我们之前的计算一样:
16 * 2 * 8 = 256 字节,每个进程的映射表。
非常重要的一点,值得重复:我们将(虚拟)页映射到(物理)页面帧!
这是由操作系统基于每个进程进行的。因此,每个进程都有自己的映射表,用于在运行时将页面转换为页面帧;通常称为分页表(PT):
图 9:将(虚拟)页映射到(物理)页面帧
分页表–简化
同样,在我们的虚构机器中,我们有:64 KB 的虚拟内存,即 64K/4K = 16 页,和 32 KB 的 RAM,即 32K/4K = 8 页帧。
将 16 个(虚拟)页映射到相应的(物理)页面帧只需要每个进程一个只有 16 个条目的表,这使得整个交易可行。
非常简单地说,单个进程的操作系统创建的页表如下所示:
(虚拟)页 | (物理)页面帧 |
---|---|
0 | 3 |
1 | 2 |
2 | 5 |
[...] | [...] |
15 | 6 |
表 3:操作系统创建的页表
当然,敏锐的读者会注意到我们有一个问题:我们有 16 页,只有 8 页帧可以映射到它们中–剩下的八页怎么办?
好吧,考虑一下:
-
实际上,每个进程都不会使用每个可用的页面来存储代码或数据或其他内容;虚拟地址空间的几个区域将保持空白(稀疏),
-
即使我们需要它,我们也有办法:不要忘记内存金字塔。当我们用完 RAM 时,我们使用交换。因此,进程的(概念性)页表可能如下所示(例如,页面 13 和 14 驻留在交换中):
(虚拟)页 | (物理)页面帧 |
---|---|
0 | 3 |
1 | 2 |
2 | 5 |
[...] | [...] |
13 | <交换地址> |
14 | <交换地址> |
15 | 6 |
表 4:概念性页表
请注意,这些页表的描述纯粹是概念性的;实际的页表更复杂,且高度依赖于体系结构(CPU/MMU)。
间接
通过引入分页,我们实际上引入了一级间接:我们不再将(虚拟)地址视为从零的绝对偏移,而是作为相对数量:va = (page, offset)
。
我们将每个虚拟地址视为与页号和从该页开头的偏移相关联。这被称为使用一级间接。
因此,每当进程引用虚拟地址时(当然,几乎一直在发生),系统必须根据该进程的页表将虚拟地址转换为相应的物理地址。
地址转换
因此,在运行时,进程查找一个虚拟地址,比如说,距离 0 有 9,192 字节,也就是说,它的虚拟地址:va = 9192 = 0x000023E8
。如果每页大小为 4,096 字节,这意味着 va 地址在第三页(第 2 页),从该页的开头偏移 1,000 字节。
因此,通过一级间接,我们有:va = (page, offset) = (2, 1000)
。
啊哈!现在我们可以看到地址转换是如何工作的:操作系统看到进程想要一个地址在第 2 页。它在该进程的页表上查找,并发现第 2 页映射到第 5 页帧。计算如下所示的物理地址:
pa = (pf * PAGE_SIZE) + offset
= (5 * 4096) + 1000
= 21480 = 0x000053E8
哇!
系统现在将物理地址放在总线上,CPU 像往常一样执行其工作。看起来很简单,但再次强调,这并不现实,请参见下面的信息框。
分页模式带来的另一个优势是,操作系统只需要存储页到页框的映射。这自动让我们能够通过添加偏移量将页面中的任何字节转换为页面框中对应的物理字节,因为页面和页面框之间存在一对一的映射(两者大小相同)。
实际上,执行地址转换的并不是操作系统。这是因为在软件中执行这个操作会太慢(记住,查找虚拟地址是一个几乎一直在进行的活动)。事实是,地址查找和转换是由硅片——CPU 内的硬件内存管理单元(MMU)来完成的!
记住以下几点:
• 操作系统负责为每个进程创建和维护页表。
• MMU 负责执行运行时地址转换(使用操作系统的页表)。
• 此外,现代硬件支持硬件加速器,如 TLB、CPU 缓存的使用和虚拟化扩展,这在很大程度上有助于获得良好的性能。
使用虚拟内存的好处
乍一看,由于虚拟内存和相关的地址转换引入的巨大开销似乎不值得使用它。是的,开销很大,但事实如下:
-
现代硬件加速(通过 TLB/CPU 缓存/预取)减轻了这种开销,并提供了足够的性能。
-
从虚拟内存中获得的好处超过了性能问题。
在基于虚拟内存的系统上,我们获得以下好处:
-
进程隔离
-
程序员不需要担心物理内存
-
内存区域保护
更好地理解这些是很重要的。
进程隔离
有了虚拟内存,每个进程都在一个沙盒中运行,这是它的虚拟地址空间的范围。关键规则:它不能窥视到盒子外面。
因此,想想看,一个进程不可能窥视或篡改任何其他进程的虚拟地址空间的内存。这有助于使系统安全稳定。
例如:我们有两个进程 A 和 B。进程 A 想要写入进程 B 中的虚拟地址0x10ea
。它不能,即使它试图写入该地址,它实际上只是写入自己的虚拟地址0x10ea
!读取也是一样的。
因此我们得到了进程隔离——每个进程完全与其他进程隔离。
对于进程 A 的虚拟地址 X 来说,它与进程 B 的虚拟地址 X 并不相同;很可能它们会通过它们的页表转换为不同的物理地址。
利用这一特性,Android 系统被设计得非常有意识地使用进程模型来进行 Android 应用程序:当一个 Android 应用程序启动时,它成为一个 Linux 进程,它存在于自己的虚拟地址空间中,被隔离并因此受到保护,不受其他 Android 应用程序(进程)的影响!
-
再次强调,不要误以为给定进程中的每个(虚拟)页面都对该进程本身有效。只有在映射了页面的情况下,页面才是有效的,也就是说,它已经被分配并且操作系统对它有有效的转换(或者有办法获取它)。事实上,特别是对于庞大的 64 位虚拟地址空间,进程的虚拟地址空间被认为是稀疏的。
-
如果进程隔离是如此描述的,那么如果进程 A 需要与进程 B 通信会怎样呢?实际上,这是许多真实的 Linux 应用程序的频繁设计要求——我们需要一些机制来能够读取/写入另一个进程的虚拟地址空间。现代操作系统提供了实现这一点的机制:进程间通信(IPC)机制。(有关 IPC 的简要介绍可以在第十五章中找到,使用 Pthreads 进行多线程编程第二部分-同步。)
程序员不需要担心物理内存
在旧的操作系统甚至现代的实时操作系统中,程序员需要详细了解整个系统的内存布局,并相应地使用内存(回想一下图 1)。显然,这给开发人员带来了很大的负担;他们必须确保他们在系统的物理限制内工作良好。
大多数在现代操作系统上工作的现代开发人员甚至从不这样思考:如果我们想要,比如说,512 Kb 的内存,我们不是只需动态分配它(使用malloc(3)
,稍后在第四章中详细介绍,动态内存分配),将如何和在哪里完成的精确细节留给库和操作系统层?事实上,我们可以做这种事情数十次而不必担心诸如“是否有足够的物理 RAM?应该使用哪些物理页框?碎片化/浪费怎么办?”之类的问题。
我们得到的额外好处是系统返回给我们的内存是保证连续的;当然,它只是虚拟连续的,不一定是物理上连续的,但这种细节正是虚拟内存层要处理的!
所有这些都由库层和操作系统中的底层内存管理系统高效处理。
内存区域保护
也许 VM 最重要的好处就是:能够在虚拟内存上定义保护,并且这些保护会被操作系统遵守。
Unix 和其它类似系统(包括 Linux),允许在内存页面上有四个保护或权限值:
保护或权限类型 | 意义 |
---|---|
无 | 无权限在页面上执行任何操作 |
读取 | 页面可以读取 |
写入 | 页面可以写入 |
执行 | 页面(代码)可以执行 |
表 5:内存页面上的保护或权限值
让我们考虑一个小例子:我们在我们的进程中分配了四个页面的内存(编号为 0 到 3)。默认情况下,页面的默认权限或保护是RW(读-写),这意味着页面既可以读取又可以写入。
有了虚拟内存操作系统级别的支持,操作系统提供了 API(mmap(2)
和mprotect(2)
系统调用),可以更改默认的页面保护!请看下表:
内存页# | 默认保护 | 更改后的保护 |
---|---|---|
0 | RW- | -无- |
1 | RW- | 只读(R–) |
2 | RW- | 仅写入(-W-) |
3 | RW- | 读取-执行(R-X) |
有了这样强大的 API,我们可以将内存保护设置到单个页面的粒度!
应用程序(甚至操作系统)可以利用这些强大的机制;事实上,这正是操作系统在进程地址空间的特定区域所做的(正如我们将在下一节学到的那样,侧边栏::测试 memcpy() 'C’程序)。
好的,很好,我们可以在某些页面上设置某些保护,但是如果应用程序违反了它们怎么办?例如,在设置页面#3(如前表所示)为读取-执行后,如果应用程序(或操作系统)尝试写入该页面会怎样?
这就是虚拟内存(和内存管理)的真正威力所在:事实上,在启用了虚拟内存的系统上,操作系统(更确切地说是 MMU)能够陷入每个内存访问,并确定最终用户进程是否遵守规则。如果是,访问将成功进行;如果不是,MMU 硬件会引发异常(类似但不完全相同于中断)。操作系统现在会跳转到一个称为异常(或故障)处理程序的代码例程。操作系统的异常处理例程确定访问是否确实非法,如果是,操作系统立即终止尝试进行此非法访问的进程。
这就是内存保护吗?事实上,这几乎就是段错误或段错误的定义;在第十二章中会更详细地介绍这一点,信号-第二部分。异常处理例程称为操作系统的故障处理程序。
侧边栏:测试memcpy()
C 程序
现在我们更好地理解了虚拟内存系统的什么和为什么,让我们回到本章开头考虑的有 bug 的伪代码示例:我们使用memcpy(3)
来复制一些内存,但指定了错误的目标地址(在我们虚构的仅物理内存的系统中,它会覆盖操作系统本身)。
一个在 Linux 上运行的概念上类似的 C 程序,一个完整的虚拟内存启用的操作系统,被展示并在这里尝试。让我们看看这个有 bug 的程序在 Linux 上是如何工作的:
$ cat mem_app1buggy.c /*
* mem_app1buggy.c
*
***************************************************************
* This program is part of the source code released for the book
* "Linux System Programming"
* (c) Kaiwan N Billimoria
* Packt Publishers
*
* From:
* Ch 2 : Virtual Memory
****************************************************************
* A simple demo to show that on Linux - full-fledged Virtual
* Memory enabled OS - even a buggy app will _NOT_ cause system
* failure; rather, the buggy process will be killed by the
* kernel!
* On the other hand, if we had run this or a similar program in a flat purely
* physical address space based OS, this seemingly trivial bug
* can wreak havoc, bringing the entire system down.
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include "../common.h"
int main(int argc, char **argv)
{
void *ptr = NULL;
void *dest, *src = "abcdef0123456789";
void *arbit_addr = (void *)0xffffffffff601000;
int n = strlen(src);
ptr = malloc(256 * 1024);
if (!ptr)
FATAL("malloc(256*1024) failed\n");
if (argc == 1)
dest = ptr; /* correct */
else
dest = arbit_addr; /* bug! */
memcpy(dest, src, n);
free(ptr);
exit(0);
}
malloc(3)
API 将在下一章中详细介绍;现在,只需了解它用于为进程动态分配 256 KB 的内存。当然,memcpy(3)
也用于将内存从源指针复制到目标指针,共 n 个字节:
void *memcpy(void *dest, const void *src, size_t n);
有趣的是我们有一个名为arbit_addr
的变量;它被设置为一个任意的无效(虚拟)地址。从代码中可以看出,当用户向程序传递任何参数时,我们将目标指针设置为arbit_addr
,从而使其成为有 bug 的测试用例。让我们尝试运行这个程序,看看正确和错误的情况。
这是正确的情况:
$ ./mem_app1buggy
$
它运行良好,没有错误。
这是错误的情况:
$ ./mem_app1buggy buggy-case pass-params forcing-argc-to-not-be-1
Segmentation fault (core dumped)
$
它崩溃了!正如前面所述,有 bug 的 memcpy 导致 MMU 出现故障;操作系统的故障处理代码意识到这确实是一个 bug,于是杀死了有问题的进程!进程死于自己的过错,而不是系统的过错。这不仅是正确的,而且段错误也提醒开发人员他们的代码有 bug,必须修复。
- 到底什么是核心转储?
核心转储是进程在崩溃时某些动态区域(段)的快照(从技术上讲,至少是数据和堆栈段的快照)。核心转储可以在崩溃后使用诸如 GDB 之类的调试器进行分析。我们在本书中不涵盖这些领域。
- 嘿,它说(核心已转储),但我没有看到任何核心文件?
嗯,为什么没有核心文件呢?这可能有几个原因,细节超出了本书的范围。请参考core(5)
的 man 页面获取详细信息:linux.die.net/man/5/core
。
稍微详细地考虑一下这里发生了什么:目标指针的值是0xffffffffff601000;
在 x86_64 处理器上,这实际上是一个内核虚拟地址。现在我们,一个用户模式进程,试图向这个目标区域写入一些内存,这个区域受到了来自用户空间的访问保护。从技术上讲,它位于内核虚拟地址空间,这对用户模式进程不可用(回想一下我们在第一章中对CPU 特权级别的讨论,Linux 系统架构)。所以当我们——一个用户模式进程——试图写入内核虚拟地址空间时,保护机制会启动并阻止我们这样做,从而导致我们死亡。
高级:系统如何知道这个区域受到保护以及它有什么样的保护?这些细节被编码到进程的分页表项(PTEs)中,并且在每次访问时由 MMU 进行检查!
这种高级内存保护在硬件和软件中都没有支持是不可能的:
-
通过所有现代微处理器中的 MMU 提供的硬件支持
-
通过操作系统的软件支持
虚拟内存提供了许多其他好处,包括(但不限于)使强大的技术成为可能,例如需求分页、写时复制(COW)处理、碎片整理、内存过度承诺、内存压缩、内核同页合并(KSM)和超然内存(TM)。在本书的范围内,我们将在以后的章节中涵盖其中的一些。
进程内存布局
进程是正在执行的程序的实例。它被操作系统视为一个活动的、可调度的实体。换句话说,当我们启动一个程序时,运行的是进程。
操作系统或内核在内核内存中的数据结构中存储有关进程的元数据;在 Linux 上,这个结构通常被称为进程描述符,尽管术语任务结构更准确。进程属性存储在任务结构中;进程PID(进程标识符)- 用于标识进程的唯一整数,进程凭证,打开文件信息,信号信息等等,都驻留在这里。
从之前的讨论中,虚拟内存,我们了解到进程除了许多其他属性外,还有一个 VAS*。VAS 是它可能可用的总空间。就像我们之前的例子,使用 16 个地址线的虚构计算机,每个进程的 VAS 将是 2¹⁶ = 64 KB。
现在,让我们考虑一个更现实的系统:一个具有 32 位寻址的 32 位 CPU。显然,每个进程的 VAS 为 2³²,相当大的 4 GB。
16 进制格式的 4 GB 是0x100000000;
所以 VAS 从低地址0x0
到高地址4GB - 1 = 0xffff ffff
。
然而,我们还需要了解更多细节(参见高级:VM 分割)关于 VAS 高端的确切使用。因此,至少目前,让我们将其称为高地址,而不给它一个特定的数值。
这是它的图示表示:
图 10:进程虚拟地址空间(VAS)
因此,现在要理解的是,在 32 位 Linux 上,每个活动的进程都有这个映像:0x0到 0xffff ffff = 4 GB 的虚拟地址空间*.*
段或映射
当创建一个新进程(详细信息见第十章,进程创建)时,其 VAS 必须由操作系统设置。所有现代操作系统都将进程 VAS 划分为称为段的同质区域(不要将这些段与硬件分段方法混淆,该方法在地址 2 - 简要分页部分中提到)。
段是进程 VAS 的同质或统一区域;它由虚拟页面组成。段具有属性,如起始和结束地址,保护(RWX/none)和映射类型。现在的关键点是:属于段的所有页面共享相同的属性。
从技术上讲,从操作系统的角度来看,段被称为映射。
从现在开始,当我们使用“segment”这个词时,我们也指的是 mapping,反之亦然。
简而言之,从低到高端,每个 Linux 进程都会有以下段(或映射):
-
文本(代码)
-
数据
-
库(或其他)
-
堆栈
图 11:带有段的进程 VAS 的整体视图
继续阅读有关这些段的更多详细信息。
文本段
文本就是代码:构成馈送给 CPU 消耗的机器指令的实际操作码和操作数。读者可能还记得我们在第一章,Linux 系统架构中所做的objdump --source ./hello_dbg
,显示 C 代码转换为汇编和机器语言。这个机器代码驻留在进程 VAS 中,称为文本段。例如,假设一个程序有 32 KB 的文本;当我们运行它时,它变成一个进程,文本段占用 32 KB 的虚拟内存;这是 32K/4K = 8(虚拟)页。
为了优化和保护,操作系统标记,即保护,所有这八页文本都标记为读-执行(r-x)。这是有道理的:代码将从内存中读取并由 CPU 执行,而不是写入它。
在 Linux 上,文本段总是朝着进程 VAS 的低端。请注意,它永远不会从0x0
地址开始。
作为一个典型的例子,在 IA-32 上,文本段通常从0x0804 8000
开始。尽管这在架构上是非常特定的,而且在 Linux 安全机制如地址空间布局随机化(ASLR)存在时会发生变化。
数据段
文本段的上方是数据段,这是进程保存程序的全局和静态变量(数据)的地方。
实际上,这不是一个映射(段);数据段由三个不同的映射组成。从低地址开始,它包括:初始化数据段,未初始化数据段和堆段。
我们知道,在 C 程序中,未初始化的全局和静态变量会自动初始化为零。那么初始化的全局变量呢?初始化数据段是一个地址空间的区域,用于存储显式初始化的全局和静态变量。
未初始化的数据段是地址空间的一个区域,当然,未初始化的全局和静态变量驻留在这里。关键是:这些变量会被隐式初始化为零(实际上是 memset 为零)。此外,旧的文献经常将这个区域称为 BSS。BSS 是一个旧的汇编指令-由符号开始的块-可以忽略;今天,BSS 区域或段仅仅是进程 VAS 的未初始化数据段。
堆应该是大多数 C 程序员熟悉的一个术语;它指的是为动态内存分配(和随后的释放)保留的内存区域。把堆想象成一个在启动时为进程提供的免费内存页面的礼物。
一个关键点:文本、初始化数据和未初始化数据段的大小是固定的;堆是一个动态段-它可以在运行时增长或缩小。重要的是要注意,堆段向更高的虚拟地址增长。有关堆及其用法的更多细节可以在下一章中找到。
库段
在链接程序时,我们有两个广泛的选择:
-
静态链接
-
动态链接
静态链接意味着任何和所有库文本(代码)和数据都保存在程序的二进制可执行文件中(因此它更大,加载速度更快)。
动态链接意味着任何和所有共享库文本(代码)和数据都不保存在程序的二进制可执行文件中;相反,它被所有进程共享,并在运行时映射到进程 VAS 中(因此二进制可执行文件要小得多,尽管加载速度可能会慢一些)。动态链接始终是默认的。
想想Hello, world
C 程序。你调用了printf(3)
,但你有写它的代码吗?当然没有;我们知道它在 glibc 中,并且会在运行时链接到我们的进程中。这正是动态链接的发生方式:在进程加载时,程序依赖(使用)的所有库文本和数据段都会内存映射到进程 VAS 中。在哪里?在堆的顶部和栈的底部之间的区域:库段(参见前面的图表)。
另一件事:除了库文本和数据之外,其他映射可能会进入这个地址空间的区域。一个典型的情况是开发人员进行的显式内存映射(使用mmap(2)
系统调用),隐式映射,比如 IPC 机制所做的映射,比如共享内存映射,以及 malloc 例程(参见第四章,动态内存分配)。
栈段
这一节解释了进程栈:什么,为什么,以及如何。
栈内存是什么?
你可能记得被教过,栈内存只是内存,但具有特殊的推/弹出语义;你最后推入的内存位于栈的顶部,如果执行弹出操作,那个内存就会从栈中弹出-从中移除。
将晚餐盘子堆叠的教学示例是一个很好的例子:你最后放置的盘子在顶部,你从顶部取下盘子给你的晚餐客人(当然,你可以坚持说你从堆栈的中间或底部给他们盘子,但我们认为顶部的盘子最容易取下)。
一些文献还将这种推送/弹出行为称为后进先出(LIFO)。好吧。
进程 VAS 的高端用于堆栈段(参见图 11)。好吧,但它到底是用来做什么的?它如何帮助?
为什么需要进程堆栈?
我们被教导要编写良好的模块化代码:将工作分解为子例程,并将其实现为小型、易读、易维护的 C 函数。这很好。
然而,CPU 实际上并不了解如何调用 C 函数,如何传递参数,存储局部变量,并将结果返回给调用函数。我们的救世主,编译器接管,将 C 代码转换为能够使整个函数工作的汇编语言。
编译器生成汇编代码来调用函数,传递参数,为局部变量分配空间,最后将返回结果发回给调用者。为了做到这一点,它使用堆栈!因此,类似于堆,堆栈也是一个动态段。
每次调用函数时,在堆栈区域(或段或映射)中分配内存来保存具有函数调用、参数传递和函数返回机制的元数据。每个函数的这个元数据区域称为堆栈帧*。*
堆栈帧保存了实现函数调用-参数使用-返回值机制所需的元数据。堆栈帧的确切布局高度依赖于 CPU(和编译器);这是 CPU ABI 文档涵盖的关键领域之一。
在 IA-32 处理器上,堆栈帧布局基本上如下:
[ <-- 高地址
[ 函数参数 ... ]
[ 返回地址 ]
[ 保存的帧指针 ] (可选)
[ 局部变量 ... ]
] <-- SP: 最低地址
考虑一些伪代码:
bar() { jail();}
foo() { bar();}
main() { foo();}
调用图相当明显:
main --> foo --> bar --> jail
箭头绘制为 --> 表示调用;所以,main 调用 foo,依此类推。
要理解的是:每次函数调用在进程的堆栈中都由一个堆栈帧表示。
如果处理器发出了推送或弹出指令,它将继续执行。但是,想想看,CPU 如何知道确切地在哪里 - 在哪个堆栈内存位置或地址 - 它应该推送或弹出内存?答案是:我们保留一个特殊的 CPU 寄存器,堆栈指针(通常缩写为SP),用于确切地这个目的:SP 中的值始终指向堆栈顶部。
下一个关键点:堆栈段向较低的虚拟地址增长。这通常被称为堆栈向下增长的语义。还要注意,堆栈增长的方向是由该 CPU 的 ABI 规定的 CPU 特定特性;大多数现代 CPU(包括英特尔、ARM、PPC、Alpha 和 Sun SPARC)都遵循堆栈向下增长的语义。
SP 始终指向堆栈顶部;由于我们使用的是向下增长的堆栈,这是堆栈上的最低虚拟地址!
为了清楚起见,让我们看一张图,它展示了在调用main()
之后的进程堆栈(main()
由一个__libc_start_main()
glibc 例程调用):
图 12:调用main()
后的进程堆栈
进入jail()
函数时的进程堆栈:
图 13:调用jail()
后的进程堆栈
窥视堆栈
我们可以以不同的方式窥视进程堆栈(技术上来说,是main()
的堆栈)。这里,我们展示了两种可能性:
-
通过
gstack(1)
实用程序自动 -
通过 GDB 调试器手动
首先,通过gstack(1)
查看用户模式堆栈:
警告!Ubuntu 用户,你们可能会在这里遇到问题。在撰写时(Ubuntu 18.04),gstack
似乎对 Ubuntu 不可用(它的替代方法pstack
也不太好用!)。请使用第二种方法(通过 GDB),如下。
作为一个快速的例子,我们查看bash
的堆栈(参数是进程的 PID):
$ gstack 14654
#0 0x00007f3539ece7ea in waitpid () from /lib64/libc.so.6
#1 0x000056474b4b41d9 in waitchld.isra ()
#2 0x000056474b4b595d in wait_for ()
#3 0x000056474b4a5033 in execute_command_internal ()
#4 0x000056474b4a52c2 in execute_command ()
#5 0x000056474b48f252 in reader_loop ()
#6 0x000056474b48dd32 in main ()
$
堆栈帧编号出现在左边,前面有#
符号;请注意,帧#0
是堆栈的顶部(最低的帧)。以自下而上的方式读取堆栈,即从帧#6
(main()
函数的帧)到帧#0
(waitpid()
函数的帧)。还要注意,如果进程是多线程的,gstack
将显示每个线程的堆栈。
接下来,通过 GDB 查看用户模式堆栈。
GNU Debugger (GDB)是一个著名的、非常强大的调试工具(如果你还没有使用它,我们强烈建议你学习一下;请查看进一步阅读部分中的链接)。在这里,我们将使用 GDB 来附加到一个进程,并且一旦附加,就可以查看它的进程堆栈。
一个小的测试 C 程序,进行了几个嵌套的函数调用,将作为一个很好的例子。基本上,调用图将如下所示:
main() --> foo() --> bar() --> bar_is_now_closed() --> pause()
pause(2)
系统调用是一个阻塞调用的很好的例子 - 它让调用进程进入睡眠状态,等待(或阻塞)事件的发生;这里它所阻塞的事件是向进程传递任何信号。(耐心点;我们将在第十一章中学到更多,信号 - 第一部分,和第十二章,信号 - 第二部分)。
这里是相关的代码(ch2/stacker.c)
:
static void bar_is_now_closed(void)
{
printf("In function %s\n"
"\t(bye, pl go '~/' now).\n", __FUNCTION__);
printf("\n Now blocking on pause()...\n"
" Connect via GDB's 'attach' and then issue the 'bt' command"
" to view the process stack\n");
pause(); /*process blocks here until it receives a signal */
}
static void bar(void)
{
printf("In function %s\n", __FUNCTION__);
bar_is_now_closed();
}
static void foo(void)
{
printf("In function %s\n", __FUNCTION__);
bar();
}
int main(int argc, char **argv)
{
printf("In function %s\n", __FUNCTION__);
foo();
exit (EXIT_SUCCESS);
}
请注意,为了让 GDB 看到符号(函数名称、变量、行号),必须使用-g
开关编译代码(生成调试信息)。
现在,我们在后台运行进程:
$ ./stacker_dbg &
[2] 28957
In function main
In function foo
In function bar
In function bar_is_now_closed
(bye, pl go '~/' now).
Now blocking on pause()...
Connect via GDB's 'attach' and then issue the 'bt' command to view the process stack
$
接下来,打开 GDB;在 GDB 中,附加到进程(PID 在前面的代码中显示),并使用backtrace(bt)命令查看其堆栈:
$ gdb --quiet
(gdb) attach 28957 *# parameter to 'attach' is the PID of the process to attach to*
Attaching to process 28957
Reading symbols from <...>/Hands-on-System-Programming-with-Linux/ch2/stacker_dbg...done.
Reading symbols from /lib64/libc.so.6...Reading symbols from /usr/lib/debug/usr/lib64/libc-2.26.so.debug...done.
done.
Reading symbols from /lib64/ld-linux-x86-64.so.2...Reading symbols from /usr/lib/debug/usr/lib64/ld-2.26.so.debug...done.
done.
0x00007fce204143b1 in __libc_pause () at ../sysdeps/unix/sysv/linux/pause.c:30
30 return SYSCALL_CANCEL (pause);
(gdb) bt
#0 0x00007fce204143b1 in __libc_pause () at ../sysdeps/unix/sysv/linux/pause.c:30
#1 0x00000000004007ce in bar_is_now_closed () at stacker.c:31
#2 0x00000000004007ee in bar () at stacker.c:36
#3 0x000000000040080e in foo () at stacker.c:41
#4 0x0000000000400839 in main (argc=1, argv=0x7ffca9ac5ff8) at stacker.c:47
(gdb)
在 Ubuntu 上,由于安全原因,GDB 不允许附加到任何进程;可以通过以 root 身份运行 GDB 来克服这一问题;然后它就可以正常工作了。
通过gstack
查看相同的进程如何?(在撰写时,Ubuntu 用户,你们没那么幸运)。这是在 Fedora 27 上的情况:
$ gstack 28957
#0 0x00007fce204143b1 in __libc_pause () at ../sysdeps/unix/sysv/linux/pause.c:30
#1 0x00000000004007ce in bar_is_now_closed () at stacker.c:31
#2 0x00000000004007ee in bar () at stacker.c:36
#3 0x000000000040080e in foo () at stacker.c:41
#4 0x0000000000400839 in main (argc=1, argv=0x7ffca9ac5ff8) at stacker.c:47
$
你猜怎么着?原来gstack
实际上是一个包装的 shell 脚本,它以非交互方式调用 GDB,并发出了我们刚刚使用的backtrace
命令!
作为一个快速的学习练习,查看一下gstack
脚本。
高级 - VM 分割
到目前为止,我们所看到的实际上并不是完整的画面;实际上,这个地址空间需要在用户空间和内核空间之间共享。
这一部分被认为是高级的。我们把决定是否深入了解接下来的细节留给读者。虽然它们非常有用,特别是从调试的角度来看,但严格来说并不是跟随本书其余部分所必需的。
回想一下我们在库段部分提到的内容:如果一个Hello, world
应用程序要工作,它需要将printf(3)
glibc 例程映射到它。这是通过在运行时将动态或共享库内存映射到进程 VAS 中(由加载程序)来实现的。
对于进程发出的任何系统调用,都可以提出类似的论点:我们从第一章中了解到,系统调用代码实际上位于内核地址空间内。因此,如果发出系统调用成功,我们需要将 CPU 的指令指针(IP或 PC 寄存器)重新定位到系统调用代码的地址,这当然是在内核地址空间内。现在,如果进程 VAS 只包括文本、数据、库和栈段,正如我们迄今所暗示的那样,它将如何工作?回想一下虚拟内存的基本规则:你不能看到盒子外面(可用地址空间)。
因此,为了使整个方案成功,即使内核虚拟地址空间 - 是的,请注意,即使内核地址空间也被认为是虚拟的 - 也必须以某种方式映射到进程 VAS 中。
正如我们之前看到的,在 32 位系统上,进程可用的总 VAS 为 4 GB。到目前为止,隐含的假设是 32 位系统上进程 VAS 的顶部是 4 GB。没错。同样,再次暗示的假设是栈段(由栈帧组成)位于这里 - 在顶部的 4 GB 点。嗯,那是不正确的(请参阅图 11)。
事实是:操作系统创建了进程 VAS,并为其中的段进行了安排;但是,它在顶端保留了一定量的虚拟内存供内核或 OS 映射使用(即内核代码、数据结构、堆栈和驱动程序)。顺便说一句,这个包含内核代码和数据的段通常被称为内核段。
内核段保留了多少 VM?啊,这是一个可调整的或可配置的参数,由内核开发人员(或系统管理员)在内核配置时间设置;它被称为VMSPLIT。这是 VAS 中我们在 OS 内核和用户模式内存之间分割地址空间的点 - 文本、数据、库和栈段!
实际上,为了清晰起见,让我们再次重现图 11(作为图 14),但这次明确显示 VM 分割:
图 14:进程 VM 分割
让我们不要在这里深入细节:可以说在 IA-32(Intel x86 32 位)上,分割点通常是 3 GB 点。因此,我们有一个比例:用户空间 VAS:内核 VAS :: 3 GB:1 GB;在 IA-32 上。
请记住,这是可调的。在其他系统上,例如典型的 ARM-32 平台,分割可能是这样的:用户空间 VAS:内核 VAS :: 2 GB:2 GB;在 ARM-32 上。
在具有庞大的2⁶⁴
VAS(这是一个令人难以置信的 16 艾字节!)的 x86_64 上,它将是:用户空间 VAS:内核 VAS :: 128 TB:128 TB;在 x86_64 上。
现在可以清楚地看到为什么我们使用术语“单片式”来描述 Linux OS 架构 - 每个进程确实就像一块单一的大石头!
每个进程都包含以下两者:
-
用户空间映射
-
文本(代码)
-
数据
-
初始化数据
-
未初始化的数据(BSS)
-
堆
-
库映射
-
其他映射
-
堆栈
-
内核段
每个活动进程都映射到内核 VAS(或内核段,通常被称为)的顶端。
这是一个关键点。让我们看一个现实世界的例子:在运行 Linux OS 的 Intel IA-32 上,VMSPLIT
的默认值为 3 GB(即0xc0000000
)。因此,在这个处理器上,每个进程的 VM 布局如下:
-
0x0到0xbfffffff:用户空间映射,即文本、数据、库和栈。
-
0xc0000000到0xffffffff:内核空间或内核段。
这在下图中清楚地表示出来:
图 15:IA-32 上的完整进程 VAS
注意每个进程的顶部 1GB 的 VAS 都是相同的 - 内核段。还要记住,这种布局在所有系统上都不相同 - VMSPLIT 和用户和内核段的大小因 CPU 架构而异。
自 Linux 3.3 特别是 3.10(当然是内核版本)以来,Linux 支持prctl(2)
系统调用。查阅其手册页面会发现各种有趣的,尽管不可移植(仅限于 Linux)的事情。例如,prctl(2)
与PR_SET_MM
参数一起使用,让一个进程(具有 root 权限)基本上可以指定其 VAS 布局,其段,以文本、数据、堆和栈的起始和结束虚拟地址为单位。这对于普通应用程序当然是不需要的。
总结
本章深入解释了 VM 概念,为什么 VM 很重要,以及对现代操作系统和在其上运行的应用程序的许多好处。然后我们介绍了 Linux OS 上进程虚拟地址空间的布局,包括一些关于文本、(多个)数据和堆栈段的信息。还介绍了堆栈的真正原因及其布局。
在下一章中,读者将了解每个进程的资源限制:为什么需要它们,它们如何工作,当然还有与它们一起工作所需的程序员接口。