C-极限编程-全-

C 极限编程(全)

原文:zh.annas-archive.org/md5/4d55604f1685393b85ba603582d07905

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在这个现代时代,我们经常目睹令人惊叹的技术,并体验到远超几十年前想象的奢华与愉悦。我们发现自动驾驶汽车正逐渐成为我们街道上的现实。物理学和其他科学分支的进步正在改变我们感知现实本身的方式。我们阅读关于研究人员在量子计算领域迈出第一步的新闻,有关区块链技术和加密货币的传闻,以及殖民其他星球的计划。难以置信的是,如此多样化的突破仅源于少数几个核心技术。这本书就是关于这些技术之一:C。

我在高中的第一年就开始用 C++编程。在那里,我加入了一个面向青少年的 2D 足球模拟团队。不久之后,我就接触到了 Linux 和 C。我必须承认,在那些年里,我对 C 和 Unix 的重要性知之甚少,但随着时间的推移,通过在各种项目中使用它们获得更多经验,以及通过我的教育了解它们,我开始看到它们的关键作用和地位。我对 C 了解得越多,我对它的尊重就越大。最终,我决定成为这个吸引我兴趣的编程语言的专家。我还决定成为传播知识和让人们意识到 C 重要性的倡导者。这本书就是那个雄心的结果。

尽管存在 C 是一种已死语言的错误观念,以及技术人士对 C 的一般无知,但可以在www.tiobe.com/tiobe-index找到的 TIOBE 指数表明了相反的情况。事实上,C 在过去 15 年中是最受欢迎的编程语言之一,与 Java 并列,并且在近年来获得了更多的流行度。

我在多年的使用 C、C++、Golang、Java 和 Python 进行开发和设计经验的基础上来到这本书。本书的主要目的是提高读者的技能水平;使他们能够在使用 C 方面迈出下一步,并以经过艰苦努力获得的经验来实际应用。对我们来说,这不会是一次轻松的旅程,这就是为什么我们称这本书为《极端 C》。这本书的核心关注点是这次旅程,我们不会进入 C 与其他编程语言的辩论。本书试图保持实用性,但仍然不得不处理大量与实际应用相关的核心理论。本书充满了旨在帮助你应对真实系统中会遇到的问题的例子。

能够就如此重要的主题发表演讲确实是一种巨大的荣幸。言语无法表达,所以我只能说,有机会撰写一个如此贴近我心的话题是一种难以置信的喜悦。这一切的乐趣和惊讶都归功于 Andrew Waldron,他让我有机会承担这本书,这是我写作生涯的第一次尝试。

作为其中的一部分,我想向我的特别尊敬和最好的感谢对象 Ian Hough,这位发展编辑,他在这一旅程中与我一起逐章前行,感谢 Aliakbar Abbasi 不辞辛劳的同行评审反馈,以及感谢 Kishor Rit、Gaurav Gavas、Veronica Pais 和许多其他为准备和出版本书付出最大努力的宝贵人士。

话虽如此,我邀请您成为我在这漫长旅程中的同伴。我希望这本书的阅读能够证明是具有变革性的,帮助您以新的视角看待 C 语言,并在这一过程中成为一名更好的程序员。

本书面向对象

本书是为那些对 C 和 C++开发有一定了解的读者所写。初级和中级 C/C++工程师是本书的主要受众,他们可以从本书中获得最大收益,并利用他们的专业知识和技能。希望阅读本书后,他们能够在职位上获得提升,成为高级工程师。此外,阅读本书后,他们的专业知识将更好地匹配更多具有挑战性和通常薪酬较高的相关职位。一些主题对高级 C/C++工程师也可能有用,但预计大多数主题他们已经了解,只有一些额外的细节可能仍然有用。

另一个可以从阅读本书中受益的受众是学生和研究人员。任何科学或工程领域(如计算机科学、软件工程、人工智能、物联网IoT)、天文学、粒子物理学和宇宙学)的学士、硕士或博士研究生,以及这些领域的所有研究人员,都可以使用本书来提高他们对 C/C++、类 Unix 操作系统和相关编程技能的了解水平。对于从事复杂、多线程或甚至多进程系统(如远程设备控制、模拟、大数据处理、机器学习、深度学习等)的工程师和科学家来说,本书将是一个很好的选择。

本书涵盖的内容

本书分为 7 个部分。在这 7 个部分中,我们涵盖了 C 编程的一些特定方面。第一部分专注于如何构建 C 项目,第二部分关注内存,第三部分关注面向对象,第四部分主要探讨 Unix 及其与 C 的关系。第五部分讨论并发,第六部分涵盖进程间通信,最后,本书的第七部分是关于测试和维护。以下是本书中包含的 23 个章节的摘要。

第一章基本特性:本章讲述了 C 中的一些基本特性,这些特性对我们使用 C 的方式产生了深远的影响。在本书的整个过程中,我们将经常使用这些特性。主要内容包括预处理和定义宏、变量和函数指针、函数调用机制以及结构。

第二章编译和链接:在本章中,我们讨论了如何构建 C 项目。我们详细研究了编译管道,包括整个管道以及各个管道组件。

第三章目标文件:本章在通过编译管道构建 C 项目后,探讨了其产物。我们介绍了目标文件及其各种类型。我们还深入研究了这些目标文件,看看可以从中提取哪些信息。

第四章进程内存结构:在本章中,我们探讨了进程的内存布局。我们看到了在这个内存布局中可以找到哪些段,以及静态和动态内存布局的含义。

第五章栈和堆:在本章中,我们专门讨论了栈和堆段。我们讨论了栈和堆变量以及它们在 C 中的生命周期管理。我们还讨论了有关堆变量的最佳实践以及它们应该如何管理的相关内容。

第六章面向对象编程和封装:这是关于 C 中面向对象编程的四章中的第一章。在本章中,我们探讨了面向对象的理论,并给出了文献中常用术语的重要定义。

第七章组合和聚合:本章专注于组合及其特殊形式:聚合。我们讨论了组合和聚合之间的区别,并举例说明这些区别。

第八章继承和多态:继承是面向对象编程(OOP)中最重要的话题之一。在本章中,我们展示了如何建立两个类之间的继承关系,以及如何在 C 中实现它。多态也是本章讨论的另一个重要话题。

第九章C++中的抽象和面向对象编程:作为本书第三部分的最后一章,我们讨论了抽象。我们讨论了抽象数据类型以及如何在 C 中实现它们。我们还讨论了 C++的内部结构,并展示了面向对象的概念是如何在 C++中实现的。

第十章Unix – 历史 和 架构:在谈论 C 时,不能忘记 Unix。在本章中,我们描述了它们之间为何紧密相连,以及 Unix 和 C 是如何相互帮助至今仍能存活的。Unix 的架构也得到了研究,我们看到了程序是如何使用操作系统暴露的功能的。

第十一章系统调用和内核:在本章中,我们关注 Unix 架构中的内核环。我们更详细地讨论了系统调用,并为 Linux 添加了一个新的系统调用。我们还讨论了各种类型的内核,并编写了一个新的简单内核模块来演示内核模块的工作原理。

第十二章最新的 C 标准:作为本章的一部分,我们审视了最新的 C 标准版本,C18。我们探讨了它与之前版本 C11 的不同之处,并展示了与 C99 相比新增的一些特性。

第十三章并发:这是本书第五部分的第一章,关于并发。本章主要讨论并发环境和它们的各种属性,如交错。我们解释了为什么这些系统是非确定性的,以及这种属性如何导致并发问题,如竞争条件。

第十四章同步:在本章中,我们继续讨论并发环境,并讨论在并发系统中可能观察到的各种问题。竞争条件、数据竞争和死锁是我们讨论的问题之一。我们还讨论了可以用来克服这些问题的技术。本章讨论了信号量、互斥锁和条件变量。

第十五章线程执行:作为本章的一部分,我们演示了如何执行多个线程以及如何管理它们。我们还提供了关于前一章讨论的并发问题的真实 C 语言示例。

第十六章线程同步:在本章中,我们探讨了可以用来同步多个线程的技术。信号量、互斥锁和条件变量是本章讨论和演示的显著主题之一。

第十七章进程执行:本章讨论了创建或生成新进程的方法。我们还讨论了在多个进程之间共享状态的基于推和基于拉的技巧。我们还使用真实的 C 语言示例演示了在第十四章同步中讨论的并发问题。

第十八章进程同步:本章主要处理位于同一台机器上的多个进程的同步机制。进程共享信号量、进程共享互斥锁和进程共享条件变量是本章讨论的技术之一。

第十九章单主机 IPC 和套接字:在本章中,我们主要讨论基于推的进程间通信(IPC)技术。我们的重点是位于同一台机器上的进程可用的技术。我们还介绍了套接字编程,以及在网络中不同节点上的进程之间建立通道所需的相关背景知识。

第二十章套接字编程:作为本章的一部分,我们通过代码示例讨论套接字编程。我们通过提出一个将支持各种类型套接字示例来驱动我们的讨论。讨论了在流或数据报通道上运行的 Unix 域套接字、TCP 和 UDP 套接字。

第二十一章与其他语言的集成:在本章中,我们演示了如何将作为共享对象文件构建的 C 库加载并用于用 C++、Java、Python 和 Golang 编写的程序。

第二十二章单元测试和调试:本章专门用于测试和调试。在测试部分,我们解释了各种测试级别,但我们专注于 C 中的单元测试。我们还介绍了 CMocka 和 Google Test 作为两个可用于编写 C 测试套件的库。在调试部分,我们介绍了可用于调试不同类型错误的多种工具。

第二十三章构建系统:在本书的最后一章,我们讨论了构建系统和构建脚本生成器。Make、Ninja 和 Bazel 是我们在本章中解释的构建系统。CMake 也是我们本章中讨论的唯一构建脚本生成器。

为了充分利用本书

正如我们之前所解释的,这本书要求你具备一定的计算机编程知识和技能。最低要求如下:

  • 计算机体系结构基础知识:你应该了解内存、CPU、外围设备及其特性,以及程序如何在计算机系统中与这些元素交互。

  • 编程基础知识:你应该知道什么是算法,其执行过程如何追踪,什么是源代码,什么是二进制数,以及它们相关的算术运算如何进行。

  • 熟悉在类似 Linux 或 macOS 的 Unix-like 操作系统中使用终端和基本shell 命令

  • 关于编程主题(如条件语句、不同类型的循环、至少一种编程语言中的结构或类、C 或 C++中的指针、函数等)的中级知识。

  • 面向对象编程的基本知识:这不是强制性的,因为我们将详细解释 OOP,但它可以帮助你在阅读本书第三部分“面向对象”的章节时更好地理解。

此外,强烈建议下载代码仓库并遵循 shell 框中给出的命令。请使用已安装 Linux 或 macOS 的平台。其他符合 POSIX 标准的操作系统也可以使用。

下载示例代码文件

你可以从www.packt.com/上的账户下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载

  4. 搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本的软件解压缩或提取文件夹:

  • 适用于 Windows 的 WinRAR / 7-Zip

  • 适用于 Mac 的 Zipeg / iZip / UnRarX

  • 适用于 Linux 的 7-Zip / PeaZip

本书代码包也托管在 GitHub 上:github.com/PacktPublishing/Extreme-C。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他丰富的图书和视频的代码包可供使用,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

在本书中,我们使用了代码框和 Shell 框。代码框包含一段 C 代码或伪代码。如果代码框的内容来自代码文件,则代码文件名将显示在框的下方。下面是一个代码框的示例:

#include <stdio.h>
#include <unistd.h>
int main(int argc, char** argv) {
  printf("This is the parent process with process ID: %d\n",
          getpid());
  printf("Before calling fork() ...\n");
  pid_t ret = fork();
  if (ret) {
    printf("The child process is spawned with PID: %d\n", ret);
  } else {
    printf("This is the child process with PID: %d\n", getpid());
  }
  printf("Type CTRL+C to exit ...\n");
  while (1);
  return 0;
}

代码框 17-1 [ExtremeC_examples_chapter17_1.c]:使用 fork API 创建子进程

如您所见,上述代码可以在ExtremeC_examples_chapter17_1.c文件中找到,它是本书代码包的一部分,位于第十七章,进程执行目录中。您可以从 GitHub 获取代码包:github.com/PacktPublishing/Extreme-C

如果代码框没有关联的文件名,则它包含伪代码或无法在代码包中找到的 C 代码。以下是一个示例:

Task P {
    1\. num = 5
    2\. num++
    3\. num = num – 2
    4\. x = 10
    5\. num = num + x
}

代码框 13-1:一个包含 5 条指令的简单任务

有时在代码框中可能会显示一些粗体字行的内容。这些通常是代码框前后讨论的代码行。它们以粗体字显示,以便您更容易找到它们。

Shell 框用于显示在运行多个 shell 命令时终端的输出。命令通常以粗体字显示,输出以正常字体显示。以下是一个示例:

$ ls /dev/shm
shm0
$ gcc ExtremeC_examples_chapter17_5.c -lrt -o ex17_5.out
$ ./ex17_5.out
Shared memory is opened with fd: 3
The contents of the shared memory object: ABC
$ ls /dev/shm
$

Shell Box 17-6:从示例 17.4 中创建的共享内存对象中读取,最后将其删除

命令以$#开头。以$开头的命令应以普通用户身份运行,而以#开头的命令应以超级用户身份运行。

Shell 框的工作目录通常是代码包中找到的章节目录。在需要选择特定目录作为工作目录的情况下,我们将提供必要的信息。

粗体:表示新术语、重要词汇。屏幕上看到的单词,例如在菜单或对话框中,也会以这种方式出现在文本中。例如:“从管理面板中选择系统信息。”

警告或重要注意事项会像这样出现。

小贴士和技巧会像这样出现。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 如果你对此书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com发送邮件给我们。

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。

请访问www.packtpub.com/support/errata,选择你的书籍,点击“勘误表提交表单”链接,并输入详细信息。

盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过发送链接至copyright@packt.com与我们联系。

如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com

评论

请留下评论。一旦你阅读并使用了这本书,为何不在你购买它的网站上留下评论呢?潜在读者可以查看并使用你的客观意见来做出购买决定,Packt 公司可以了解你对我们的产品有何看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

想了解更多关于 Packt 的信息,请访问 packt.com。

第一章

Essential Features

极致 C》是一本将为你提供开发和维护真实 C 应用程序所需的基本和高级知识的书籍。通常,仅了解一种编程语言的语法是不够的,用它来编写成功的程序——在 C 语言中,这一点比大多数其他语言更为重要。因此,我们将涵盖你编写 C 语言优秀软件所需的所有概念,从简单的单进程程序到更复杂的多进程系统。

这第一章主要关注你在编写 C 程序时会发现极其有用的 C 语言特定特性。这些特性涉及你在 C 语言编程中会经常遇到的情况。尽管有许多关于 C 编程的优秀书籍和教程,它们详细解释了所有内容,并涵盖了 C 语法的几乎所有方面,但在我们深入 C 语言之前,考虑一些关键特性将是有用的。

这些特性包括预处理指令、变量指针、函数指针和结构体。当然,在今天的更现代的编程语言中,这些特性很常见,也很容易在 Java、C#、Python 等语言中找到它们的对应物。例如,Java 中的引用可以被认为是与 C 语言中的变量指针相似元素。这些特性和它们相关的概念是如此基础,以至于没有它们,任何软件都无法继续工作,即使它能够被执行!甚至一个简单的“hello world”程序也无法在没有加载需要使用函数指针的多个共享库的情况下运行!

所以,无论何时你看到像交通信号灯、你汽车的中央电脑、厨房里的微波炉、你智能手机的操作系统,或者可能任何其他你通常不会去想的其他设备,它们都有用 C 语言编写的软件组件。

我们的生活今天受到了 C 编程语言的发明巨大影响,没有 C 语言,我们的世界将会非常不同。

本章主要关注编写专家级 C 代码所需的必要特性和机制,并包含了一组精选的特性,供我们深入研究。我们将探讨以下主题:

  • 预处理指令、宏和条件编译:预处理是 C 语言中那些你在其他编程语言中难以找到的特性之一。预处理带来了许多优势,我们将深入探讨其中一些有趣的应用,包括条件指令

  • 变量指针:本节将深入探讨变量指针及其用途。我们还将通过观察一些可能因滥用变量指针而引入的缺陷来获得一些有价值的见解。

  • 函数:本章的这一部分深入探讨了关于函数的各个方面,而不仅仅是它们的语法。实际上,语法是最容易的部分!在本节中,我们将探讨函数作为编写过程式代码的构建块。本节还讨论了函数调用机制以及函数如何从调用函数接收其参数。

  • 函数指针:毫无疑问,函数指针是 C 语言最重要的特性之一。函数指针是指向现有函数的指针,而不是指向变量。在算法设计中,能够存储指向现有逻辑的指针至关重要,这就是为什么我们专门有一个章节来介绍这个话题。函数指针在从加载动态库到多态性的广泛应用中都有出现,在接下来的几章中,我们还将看到更多关于函数指针的内容。

  • 结构体:C 语言的结构体可能有简单的语法和传达简单的概念,但它们是编写组织良好和更面向对象的代码的主要构建块。它们的重要性,连同函数指针一起,绝对不能被高估!在本章的最后部分,我们将回顾关于 C 语言中结构体及其技巧的所有需要了解的内容。

C 语言的基本特性和它们周围的概念在 Unix 生态系统中扮演着关键角色,它们使得尽管 C 语言历史悠久且语法严格,C 语言仍然成为一个重要且具有影响力的技术。在接下来的章节中,我们将更多地讨论 C 语言和 Unix 之间的相互影响。现在,让我们从讨论预处理指令开始,开始这一章的学习。

在阅读本章之前,请记住你应该已经熟悉 C 语言。本章中的大多数例子都很简单,但强烈建议你在继续阅读其他章节之前了解 C 语言的语法。为了方便起见,以下是在继续阅读本书之前你应该熟悉的主题列表:

计算机体系结构的一般知识——你应该了解内存、CPU、外围设备及其特性,以及程序如何在计算机系统中与这些元素交互。

编程的一般知识——你应该了解算法是什么,其执行如何追踪,源代码是什么,二进制数是什么,以及它们相关的算术如何工作。

熟悉在类似 Linux 或 macOS 的 Unix 操作系统中使用终端和基本 shell 命令。

至少一种编程语言中关于编程主题的中级知识,如条件语句、不同类型的循环、结构或类,C 语言或 C++中的指针、函数等。

关于面向对象编程的基本知识——这不是强制性的,因为我们将在书中详细解释面向对象编程,但这样的知识将有助于你在阅读书的第三部分“面向对象”章节时更好地理解;面向对象

预处理器指令

预处理是 C 中的一个强大特性。我们将在 第二章编译和链接 中全面介绍它,但现在是时候定义预处理为一种允许你在提交给编译器之前对源代码进行工程化和修改的东西。这意味着与其他语言相比,C 的编译管道至少多了一步。在其他编程语言中,源代码会直接发送到编译器,但在 C 和 C++ 中,它应该先进行预处理。

这一步额外的操作使得 C(以及 C++)成为一门独特的编程语言,因为 C 程序员可以在提交给编译器之前有效地更改他们的源代码。这种特性在大多数高级编程语言中并不存在。

预处理的目的在于移除预处理指令,并用等效生成的 C 代码替换它们,准备一个最终可以提交给编译器的源代码。

可以使用一组 指令 来控制并影响 C 预处理器的行为。C 指令是以 # 字符开头的代码行,在头文件和源文件中都是如此。这些行只对 C 预处理器有意义,而对 C 编译器没有意义。C 中有各种指令,但其中一些非常重要,特别是用于宏定义的指令和用于条件编译的指令。

在下一节中,我们将解释宏并给出各种示例,以展示它们的多种用途。我们还将进一步分析它们,以找到它们的优缺点。

关于 C 宏有很多传言。一种说法是它们会使你的源代码变得过于复杂且难以阅读。另一种说法是,如果你在代码中使用了宏,在调试应用程序时可能会遇到问题。你可能自己听到过一些这样的说法。但它们在多大程度上是有效的?宏是应该避免的邪恶吗?还是它们对你的项目有一些可以带来的好处?

事实上,你会在任何知名的 C 项目中找到宏。作为证明,下载一个知名的 C 项目,例如 Apache HTTP 服务器,并使用 grep 命令搜索 #define。你将看到一系列定义宏的文件。对于你作为一个 C 开发者来说,你无法避免宏。即使你自己不使用它们,你也可能会在其他人的代码中看到它们。因此,你需要了解它们是什么以及如何处理它们。

grep 命令指的是 Unix 类操作系统中的一个标准壳工具程序,它会在字符流中搜索一个模式。它可以用来在给定路径下找到的所有文件的 内容中搜索文本或模式。

宏有多个应用,以下是一些示例:

  • 定义一个常量

  • 使用函数而不是编写 C 函数

  • 循环展开

  • 头文件保护

  • 代码生成

  • 条件编译

尽管宏有更多可能的用途,我们将在接下来的几节中关注上述内容。

定义一个宏

宏使用 #define 指令定义。每个宏都有一个名称和一个可能的参数列表。它还有一个 ,在预处理阶段通过称为 宏展开 的步骤用其 名称 替换。也可以使用 #undef 指令 取消定义 宏。让我们从一个简单的例子开始,示例 1.1

#define ABC 5
int main(int argc, char** argv) {
  int x = 2;
  int y = ABC;
  int z = x + y;
  return 0;
}

代码框 1-1 [ExtremeC_examples_chapter1_1.c]:定义一个宏

在先前的代码框中,ABC 不是一个持有整数值的变量,也不是一个整型常量。实际上,它是一个名为 ABC 的宏,其对应的值是 5。在宏展开阶段之后,可以提交给 C 编译器的结果代码看起来如下:

int main(int argc, char** argv) {
  int x = 2;
  int y = 5;
  int z = x + y;
  return 0;
}

代码框 1-2:示例 1.1 在宏展开阶段生成的代码

代码框 1-2 中的代码具有有效的 C 语法,现在编译器可以继续并编译它。在先前的例子中,预处理器执行了宏展开,作为其中的一部分,预处理器简单地用宏的值替换了宏的名称。预处理器还删除了开头几行的注释。

现在我们来看另一个示例,示例 1.2

#define ADD(a, b) a + b
int main(int argc, char** argv) {
  int x = 2;
  int y = 3;
  int z = ADD(x, y);
  return 0;
}

代码框 1-3 [ExtremeC_examples_chapter1_2.c]:定义一个函数式宏

在先前的代码框中,类似于 示例 1.1ADD 不是一个函数。它只是一个接受参数的 函数式宏。预处理之后,生成的代码将如下所示:

int main(int argc, char** argv) {
  int x = 2;
  int y = 3
  int z = x + y;
  return 0;
}

代码框 1-4:预处理和宏展开后的示例 1.2

如您在先前的代码框中看到的,所发生的展开如下。用作参数 a 的参数 x 被替换为宏值中所有 a 的实例。对于参数 b 和其对应的参数 y 也是如此。然后,进行最终替换,我们在预处理的代码中得到 x + y 而不是 ADD(a, b)

由于函数式宏可以接受输入参数,它们可以模仿 C 函数。换句话说,您可以将频繁使用的逻辑命名为函数式宏,并使用该宏代替 C 函数。

这样,宏的出现将被频繁使用的逻辑替换,作为预处理阶段的一部分,无需引入新的 C 函数。我们将在后面进一步讨论这一点,并比较两种方法。

宏只存在于编译阶段之前。这意味着理论上,编译器对宏一无所知。如果你打算使用宏而不是函数,这是一个非常重要的要点。编译器对函数了如指掌,因为它是 C 语法的一部分,并且被解析并保存在 解析树 中。但宏只是 C 预处理器指令,只有预处理器本身知道。

宏允许你在编译之前 生成 代码。在其他编程语言,如 Java 中,你需要使用 代码生成器 来完成这个目的。我们将给出关于宏这一应用的例子。

现代 C 编译器了解 C 预处理器指令。尽管普遍认为它们对预处理阶段一无所知,但实际上并非如此。现代 C 编译器在进入预处理阶段之前就了解源代码。看看以下代码:

#include <stdio.h>
#define CODE \
printf("%d\n", i);
int main(int argc, char** argv) {
 CODE
 return 0;
}

代码框 1-5 [example.c]:导致未声明的标识符错误的宏定义

如果你使用 macOS 中的clang编译上述代码,输出将会是:

$ clang example.c
code.c:7:3: error: use of undeclared identifier 'i'
CODE
^
code.c:4:16: note: expanded from macro 'CODE'
printf("%d\n", i);
               ^
1 error generated.
$

命令行框 1-1:编译输出的结果引用了宏定义

正如你所见,编译器生成了一条错误信息,它精确地指向了宏定义所在的行。

作为旁注,在大多数现代编译器中,你可以在编译前查看预处理结果。例如,当使用gccclang时,你可以使用-E选项来输出预处理后的代码。以下命令行框演示了如何使用-E选项。请注意,输出并未完全显示:

$ clang -E example.c
# 1 "sample.c"# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 361 "<built-in>" 3
...
# 412 "/Library/Developer/CommandLineTools/SDKs/MacOSX10.14.sdk/usr/include/stdio.h" 2 3 4
# 2 "sample.c" 2
...
int main(int argc, char** argv) {
 printf("%d\n", i);
 return 0;
}
$

命令行框 1-2:预处理阶段后的 example.c 代码

现在我们来定义一个重要的概念。一个 翻译单元(或一个 编译单元)是经过预处理的 C 代码,它已经准备好传递给编译器。

在翻译单元中,所有指令都被替换为包含或宏展开,生成了一段平坦的长 C 代码。

既然你对宏有了更多的了解,让我们来处理一些更复杂的例子。它们将展示宏的强大和危险之处。在我看来,极端的开发方式以熟练的方式处理危险和微妙的事物,这正是 C 语言的核心所在。

下一个例子很有趣。请注意宏是如何按顺序使用来生成循环的:

#include <stdio.h>
#define PRINT(a) printf("%d\n", a);
#define LOOP(v, s, e) for (int v = s; v <= e; v++) {
#define ENDLOOP }
int main(int argc, char** argv) {
 LOOP(counter, 1, 10)
 PRINT(counter)
 ENDLOOP
  return 0;
}

代码框 1-6 [ExtremeC_examples_chapter1_3.c]:使用宏生成循环

正如你在前面的代码框中看到的,main函数内部的代码在任何方面都不是有效的 C 代码!但在预处理之后,我们得到了一个正确的 C 源代码,它可以无问题地编译。以下是预处理的结果:

...
... content of stdio.h …
...
int main(int argc, char** argv) {
  for (int counter = 1; counter <= 10; counter++) {
    printf("%d\n", counter);
  }
  return 0;
}

代码框 1-7:预处理阶段后的示例 1.3

代码框 1-6main 函数中,我们只是使用了一组不同且看起来不像 C 的指令来编写我们的算法。然后预处理后,在 代码框 1-7 中,我们得到了一个完全功能且正确的 C 程序。这是宏的一个重要应用;定义一个新的 领域特定语言 (DSL) 并使用它来编写代码。

DSLs 在项目的不同部分非常有用;例如,它们在测试框架(如 Google Test 框架(gtest))中被大量使用,其中 DSL 用于编写断言、期望和测试场景。

我们应该注意,在最终的预处理器代码中我们没有任何 C 指令。这意味着 代码框 1-6 中的 #include 指令已被它所引用的文件的内容所替换。这就是为什么你在 代码框 1-7 中的 main 函数之前看到了 stdio.h 头文件的内容(我们用省略号替换了它)。

让我们现在看看下一个示例,示例 1.4,它介绍了关于宏参数的两个新运算符;### 运算符:

#include <stdio.h>
#include <string.h>
#define CMD(NAME) \
 char NAME ## _cmd[256]  = ""; \
 strcpy(NAME ## _cmd, #NAME);
int main(int argc, char** argv) {
 CMD(copy)
 CMD(paste)
 CMD(cut)
  char cmd[256];
  scanf("%s", cmd);
  if (strcmp(cmd, copy_cmd) == 0) {
    // ...
  }
  if (strcmp(cmd, paste_cmd) == 0) {
    // ...
  }
  if (strcmp(cmd, cut_cmd) == 0) {
    // ...
  }
  return 0;
}

代码框 1-8 [ExtremeC_examples_chapter1_4.c]:在宏中使用 # 和 ## 运算符

在扩展宏时,# 运算符将参数转换为带有引号的字符串形式。例如,在上面的代码中,# 运算符在 NAME 参数之前使用,将其转换为预处理器代码中的 "copy"

## 运算符有不同的含义。它只是将参数连接到宏定义中的其他元素,并通常形成变量名。以下是 示例 1.4 的最终预处理器源代码:

...
... content of stdio.h ...
...
... content of string.h ...
...
int main(int argc, char** argv) {
  char copy_cmd[256] = ""; strcpy(copy_cmd, "copy");
  char paste_cmd[256] = ""; strcpy(paste_cmd, "paste");
  char cut_cmd[256] = ""; strcpy(cut_cmd, "cut");
  char cmd[256];
  scanf("%s", cmd);
  if (strcmp(cmd, copy_cmd) == 0) {
  }
  if (strcmp(cmd, paste_cmd) == 0) {
  }
  if (strcmp(cmd, cut_cmd) == 0) {
  }
  return 0;
}

代码框 1-9:预处理阶段后的示例 1.4

比较预处理前后的源代码有助于你理解 ### 运算符是如何应用于宏参数的。请注意,在最终的预处理器代码中,从同一宏定义扩展的所有行都在同一行上。

将长宏拆分成多行是一个好的实践,但不要忘记使用 \ (一个反斜杠) 来让预处理器知道定义的其余部分将在下一行。请注意,\ 不会替换为 换行符。相反,它是一个指示符,表示下一行是同一宏定义的延续。

现在让我们讨论不同类型的宏。下一节将讨论可以接受可变数量参数的 可变参数宏

可变参数宏

下一个示例,示例 1.5,是关于可变参数宏的,它可以接受可变数量的输入参数。有时同一个可变参数宏接受 2 个参数,有时接受 4 个参数,有时接受 7 个。当不确定同一个宏的不同用法中参数的数量时,可变参数宏非常方便。以下是一个简单的示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define VERSION "2.3.4"
#define LOG_ERROR(format, ...) \
 fprintf(stderr, format, __VA_ARGS__)
int main(int argc, char** argv) {
  if (argc < 3) {
 LOG_ERROR("Invalid number of arguments for version %s\n.", VERSION);
    exit(1);
  }
  if (strcmp(argv[1], "-n") != 0) {
 LOG_ERROR("%s is a wrong param at index %d for version %s.", argv[1], 1, VERSION);
    exit(1);
  }
  // ...
  return 0;
}

代码框 1-10 [ExtremeC_examples_chapter1_5.c]:可变宏的定义和用法

在前面的代码框中,你看到了一个新的标识符:__VA_ARGS__。这是一个指示符,告诉预处理器用所有尚未分配给任何参数的剩余输入参数来替换它。

在前面的示例中,在第二次使用 LOG_ERROR 时,根据宏定义,参数 argv[1]1VERSION 是那些尚未分配给任何参数的输入参数。因此,它们将在宏展开时用来代替 __VA_ARGS__

作为旁注,函数 fprintf 将内容写入 文件描述符。在 示例 1.5 中,文件描述符是 stderr,它是进程的 错误流。注意每个 LOG_ERROR 使用后的分号结束。这是强制性的,因为宏在其定义中不提供这些分号,程序员 必须 添加这个分号以使最终预处理的代码在语法上正确。

以下代码是通过 C 预处理器处理后的最终输出:

...
... content of stdio.h ...
...
... content of stdlib.h ...
...
... content of string.h ...
...
int main(int argc, char** argv) {
  if (argc < 3) {
    fprintf(stderr, "Invalid number of arguments for version %s\n.", "2.3.4");
    exit(1);
  }
  if (strcmp(argv[1], "-n") != 0) {
    fprintf(stderr, "%s is a wrong param at index %d for version %s.", argv[1], 1, "2.3.4");
    exit(1);
  }
  // ...
  return 0;
}

代码框 1-11:预处理阶段后的示例 1.5

下一个示例,示例 1.6,是变长宏的渐进式使用,试图模拟循环。关于这一点有一个著名的例子。在 C++ 有 foreach 之前,boost 框架(现在仍然如此)通过一系列宏提供 foreach 行为。

在以下链接中,你可以看到 BOOST_FOREACH 宏是如何在头文件中定义的:www.boost.org/doc/libs/1_35_0/boost/foreach.hpp。它用于遍历 boost 集合,实际上是一个函数式宏。

我们接下来的示例,示例 1.6,是关于一个简单的循环,它与 boost 的 foreach 完全不可比,但仍然,它给你提供了一个如何使用变长宏重复一系列指令的思路:

#include <stdio.h>
#define LOOP_3(X, ...) \
 printf("%s\n", #X);
#define LOOP_2(X, ...) \
 printf("%s\n", #X); \
 LOOP_3(__VA_ARGS__)
#define LOOP_1(X, ...) \
 printf("%s\n", #X); \
 LOOP_2(__VA_ARGS__)
#define LOOP(...) \
 LOOP_1(__VA_ARGS__)
int main(int argc, char** argv) {
 LOOP(copy paste cut)
 LOOP(copy, paste, cut)
 LOOP(copy, paste, cut, select)
  return 0;
}

代码框 1-12 [ExtremeC_examples_chapter1_6.c]:使用变长宏模拟循环

在开始解释示例之前,让我们看看预处理后的最终代码。然后,对发生了什么进行解释将会更容易:

...
... content of stdio.h ...
...
int main(int argc, char** argv) {
  printf("%s\n", "copy paste cut"); printf("%s\n", ""); printf("%s\n", "");
  printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");
  printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");
  return 0;
}

代码框 1-13:预处理阶段后的示例 1.6

如果你仔细查看预处理后的代码,你会看到 LOOP 宏已经被展开为多个 printf 指令,而不是 forwhile 这样的循环指令。很明显为什么是这样,这是因为预处理器不会为我们编写智能的 C 代码。它的作用是将宏替换为我们提供的指令。

使用宏创建循环的唯一方法是将迭代指令一个接一个地放置,作为一些独立的指令。这意味着一个简单的宏循环,有 1000 次迭代,将在 C 中被替换为 1000 条指令,我们最终代码中不会有任何实际的 C 循环。

前面的技术会导致二进制文件大小增大,这可以被视为一个缺点。但将指令一个接一个地放置,而不是将它们放入循环中,这被称为循环展开,有其自身的应用,这些应用需要在受限和性能要求高的环境中达到可接受的性能水平。根据我们到目前为止所解释的,似乎使用宏进行循环展开是在二进制大小和性能之间的一种权衡。我们将在下一节中更多地讨论这个问题。

关于前面示例的另一个注意事项。正如你所见,main函数中LOOP宏的不同用法产生了不同的结果。在第一次使用中,我们传递了copy paste cut,单词之间没有逗号。预处理程序将其接受为单个输入,因此模拟循环只有一个迭代。

在第二次使用中,输入copy, paste, cut通过逗号分隔单词传递。现在,预处理程序将它们视为三个不同的参数;因此,模拟循环有三个迭代。这可以从下面的Shell Box 1-3中清楚地看出。

在第三次使用中,我们传递了四个值,copy, paste, cut, select,但只有三个被处理。正如你所见,预处理代码与第二次使用完全相同。这是因为我们的循环宏只能处理最多三个元素的列表。超过第三个的额外元素将被忽略。

注意,这不会产生编译错误,因为没有生成任何错误的最终 C 代码,但我们的宏在处理元素的数量上有限制:

$ gcc ExtremeC_examples_chapter1_6.c
$ ./a.out
copy paste cut
copy
paste
cut
$

Shell Box 1-3:示例 1.6 的编译和输出

宏的优缺点

让我们从讨论软件设计开始,稍微谈谈宏的定义和组合。这既是一门艺术,有时也是一种上瘾的行为!你甚至在没有定义任何宏之前,就在脑海中开始构建预期的预处理代码,并基于此来定义你的宏。由于这是一种复制代码并与之玩耍的简单方法,因此可能会过度使用。过度使用宏可能对你来说不是大问题,但可能对你的队友来说却是。但为什么是这样呢?

宏有一个重要的特性。如果你在宏中编写某些内容,它们将在编译阶段之前被其他代码行替换,最终你将得到一段没有模块性的平坦长代码。当然,你在心中和可能在你的宏中拥有模块性,但在你的最终二进制文件中并不存在。这正是使用宏可能开始引起设计问题的原因。

软件设计试图将类似的算法和概念打包成几个可管理的和可重用的模块,但宏试图使一切线性化和扁平化。因此,当你将宏作为软件设计中的某些逻辑构建块使用时,它们的信息可能会在预处理阶段丢失,成为最终翻译单元的一部分。这就是为什么架构师和设计师会使用关于宏的经验法则:

如果可以将宏写成 C 函数,那么你应该写一个 C 函数而不是宏!

从调试的角度来看,再次强调,宏被认为是有害的。开发者使用编译错误来查找日常开发任务中存在的语法错误的位置。他们还使用日志和可能编译警告来检测错误并修复它。编译错误和警告都对错误分析流程有益,并且都是由编译器生成的。

关于宏,尤其是对于旧的 C 编译器,编译器对宏一无所知,它将编译源代码(翻译单元)视为一段长、线性、扁平的代码。因此,对于查看带有宏的实际 C 代码的开发者和查看没有宏的预处理代码的 C 编译器来说,存在两个不同的世界。所以,开发者很难理解编译器报告的内容。

希望借助我们现代的 C 编译器,这个问题已经不再那么严重了。如今,著名的 C 编译器如gccclang对预处理阶段有了更深入的了解,并且它们会尽量保持、使用并报告开发者所看到的源代码。否则,宏的问题可能会通过#include指令重复出现,简单来说,因为翻译单元的主要内容只有在所有包含发生之后才会被知晓。因此,我们可以得出结论,调试问题没有我们之前段落中提到的软件设计问题那么严重。

如果你还记得,我们在解释示例 1.6时提出过一次讨论。这是关于程序的二进制大小和性能之间的权衡。这种权衡的更一般形式是在单个大二进制和多个小二进制之间。它们都提供相同的功能,但前者可能会有更好的性能。

在一个项目中使用的二进制文件数量,尤其是在项目很大时,或多或少与模块化的程度和投入的设计努力成正比。例如,一个拥有 60 个库(共享或静态)和一个可执行文件的项目似乎是根据一个软件计划开发的,该计划将依赖关系拆分成多个库,并在单个主可执行文件中使用它们。

换句话说,当一个项目根据软件设计原则和最佳实践进行开发时,二进制文件的数量和大小会经过精心设计,通常将由多个轻量级二进制文件组成,这些二进制文件具有适用的最小大小,而不是只有一个庞大的二进制文件。

软件设计试图将每个软件组件放置在一个巨大的层次结构中的合适位置,而不是将它们按线性顺序排列。尽管在大多数情况下其对性能的影响很小,但这种做法本质上与性能相悖。

因此,我们可以得出结论,关于示例 1.6的讨论是关于设计和性能之间的权衡。当你需要性能时,有时你需要牺牲设计,将事物置于线性构造中。例如,你可以避免循环,并使用循环展开

从另一个角度来看,性能始于在设计阶段定义的问题中选择合适的算法。接下来的步骤通常被称为优化性能调整。在这个阶段,获得性能等同于让 CPU 以线性且顺序的方式计算,而不是强迫它在不同部分的代码之间跳跃。这可以通过修改已经使用的算法或用一些性能更好且通常更复杂的算法来替换它们来实现。这个阶段可能会与设计哲学发生冲突。正如我们之前所说的,设计试图将事物置于层次结构中并使其非线性,但 CPU 期望事物是线性的,已经取出的并且准备好被处理。因此,这种权衡应该针对每个问题单独处理和平衡。

让我们更详细地解释一下循环展开。这项技术主要在嵌入式开发中使用,尤其是在处理能力有限的环境中。其技术是通过移除循环,使它们线性化,以提高性能并避免在迭代过程中产生循环开销。

这正是我们在示例 1.6中所做的;我们使用宏模拟了一个循环,这导致了一系列线性指令。从这个意义上说,我们可以说宏可以用于嵌入式开发中的性能调整,以及在指令执行方式的一点点改变就能带来显著性能提升的环境中。更重要的是,宏可以使代码更具可读性,我们可以提取重复的指令。

关于之前提到的那个说宏应该被等效的 C 函数替换的引用,我们知道这个引用是为了设计而存在的,在某些情况下可以忽略。在一个性能提升是关键要求的上下文中,有一系列线性指令可以带来更好的性能,这可能是必要的。

代码生成是宏的另一个常见应用。它们可以用于将 DSL 引入项目中。Microsoft MFCQtLinux KernelwxWidgets 是成千上万个使用宏来定义他们自己的 DSL 的项目中的几个。大多数是 C++ 项目,但它们使用这个 C 特性来简化他们的 API。

作为结论,如果调查并了解其预处理形式的影响,C 宏可以具有优势。如果你在一个团队项目中工作,总是要分享你在团队中关于宏使用的决策,并确保自己与团队做出的决策保持一致。

条件编译

条件编译是 C 的另一个独特特性。它允许你根据不同的条件拥有不同的预处理源代码。尽管它暗示的意义,编译器并没有进行任何条件操作,但传递给编译器的预处理代码可以根据一些指定的条件而不同。这些条件是在准备预处理代码时由预处理器评估的。有不同指令有助于条件编译。你可以看到以下列表:

  • #ifdef

  • #ifndef

  • #else

  • #elif

  • #endif

以下示例,示例 1.7,展示了这些指令的基本用法:

#define CONDITION
int main(int argc, char** argv) {
#ifdef CONDITION
  int i = 0;
  i++;
#endif
  int j= 0;
  return 0;
}

代码框 1-14 [ExtremeC_examples_chapter1_7.c]:条件编译的示例

在预处理前面的代码时,预处理器看到 CONDITION 宏的定义并将其标记为已定义。请注意,没有为 CONDITION 宏提出任何值,这是完全有效的。然后,预处理器继续向下直到它到达 #ifdef 语句。由于 CONDITION 宏已经定义,#ifdef#endif 之间的所有行都将复制到最终源代码中。

你可以在下面的代码框中看到预处理后的代码:

int main(int argc, char** argv) {
  int i = 0;
  i++;
  int j= 0;
  return 0;
}

代码框 1-15:预处理阶段后的示例 1.7

如果宏未定义,我们就不会看到任何 #if-#endif 指令的替换。因此,预处理后的代码可能如下所示:

int main(int argc, char** argv) {
  int j= 0;
  return 0;
}

代码框 1-16:预处理阶段后的示例 1.7,假设 CONDITION 宏未定义

注意到在代码框 1-151-16 中保留的空行,这些空行是在预处理阶段,将 #ifdef-#endif 部分替换为其评估值后留下的。

可以使用传递给编译命令的 -D 选项来定义宏。关于前面的例子,我们可以如下定义 CONDITION 宏:

$ gcc -DCONDITION -E main.c

这是一个很棒的特性,因为它允许你在源代码之外定义宏。这在只有一个源代码但需要为不同的架构编译它时特别有用,例如 Linux 或 macOS,它们有不同的默认宏定义和库。

#ifndef的一个非常常见的用法是作为头文件保护语句。这个语句保护头文件在预处理阶段不被重复包含,我们可以说几乎每个项目中几乎所有 C 和 C++的头文件都将这个语句作为它们的第一个指令。

以下代码,示例 1.8,展示了如何使用头文件保护语句。假设这是头文件的内容,并且意外地在一个编译单元中被包含两次。请注意,示例 1.8只是一个头文件,它不应该被编译:

#ifndef EXAMPLE_1_8_H
#define EXAMPLE_1_8_H
void say_hello();
int read_age();
#endif

代码框 1-17 [ExtremeC_examples_chapter1_8.h]:头文件保护器的示例

正如你所见,所有变量和函数声明都放在#ifndef#endif对之间,并且通过宏来防止多次包含。在下一段中,我们将解释如何做到这一点。

当第一次包含发生时,EXAMPLE_1_8_H宏尚未定义,因此预处理器继续进入#ifndef-#endif块。下一个语句定义了EXAMPLE_1_8_H宏,预处理器将所有内容复制到预处理的代码中,直到它达到#endif指令。当第二次包含发生时,EXAMPLE_1_8_H宏已经定义,因此预处理器跳过#ifndef-#endif部分内的所有内容,并移动到#endif之后的下一个语句(如果有的话)。

将整个头文件内容放在#ifndef-#endif对之间是一种常见的做法,除了注释外,外部不留下任何内容。

在本节的最后一点,为了避免#ifndef-#endif指令对,可以使用#pragma once来保护头文件免受双重包含问题的影响。条件指令与#pragma once指令的区别在于,尽管它被几乎所有 C 预处理器支持,但它并不是 C 标准的一部分。然而,如果你的代码需要可移植性,最好不要使用它。

以下代码框包含了一个如何在使用#pragma once指令而不是#ifndef-#endif指令的示例 1.8中的演示:

#pragma once
void say_hello();
int read_age();

代码框 1-18:在示例 1.8 中使用 #pragma once 指令

现在,我们已经展示了预处理指令的一些有趣特性和各种应用,我们将关闭预处理指令这一主题。下一节将介绍变量指针,这是 C 语言的另一个重要特性。

变量指针

变量指针的概念,或简称为指针,是 C 语言中最基本的概念之一。在大多数高级编程语言中,你几乎找不到它们的直接迹象。实际上,它们已经被一些双胞胎概念所取代,例如 Java 中的引用。值得注意的是,指针的独特之处在于它们所指向的地址可以直接由硬件使用,而高级的双胞胎概念如引用则不是这样。

深入理解指针及其工作方式对于成为一名熟练的 C 程序员至关重要。它们是内存管理中最基本的概念之一,尽管它们的语法很简单,但使用不当可能会导致灾难。我们将在第四章进程内存结构第五章栈和堆中涵盖与内存管理相关的主题,但在这里本章中,我们想要回顾关于指针的所有内容。如果你对指针的基本术语和概念感到自信,你可以跳过这一节。

语法

任何类型的指针背后的思想非常简单;它只是一个简单的变量,用于保存一个内存地址。你可能会首先想起它们中的星号字符,*,在 C 语言中用于声明指针。你可以在示例 1.9中看到它。下面的代码框演示了如何声明和使用变量指针:

int main(int argc, char** argv) {
  int var = 100;
  int* ptr = 0;
  ptr = &var;
  *ptr = 200;
  return 0;
}

代码框 1-19 [ExtremeC_examples_chapter1_9.c]: C 语言中声明和使用指针的示例

上述示例包含了你需要了解的所有关于指针语法的知识。第一行在栈段上声明了var变量。我们将在第四章进程内存结构中讨论栈段。第二行声明了初始值为零的指针ptr。具有零值的指针称为空指针。只要ptr指针保持其零值,它就被认为是空指针。如果你在声明时不打算存储有效的地址,那么置空指针非常重要。

正如你在代码框 1-19中看到的,没有包含任何头文件。指针是 C 语言的一部分,你不需要包含任何内容就能使用它们。实际上,我们可以有完全不包含任何头文件的 C 程序。

以下所有声明在 C 语言中都是有效的:

int* ptr = 0;

int * ptr = 0;

int *ptr = 0;

main函数中的第三行引入了&操作符,称为引用操作符。它返回其旁边变量的地址。我们需要这个操作符来获取变量的地址。否则,我们无法用有效的地址初始化指针。

同样地,返回的地址被存储到ptr指针中。现在,ptr指针不再是空指针了。在第四行,我们看到指针之前还有一个操作符,称为解引用操作符,表示为*。这个操作符允许你间接访问ptr指针所指向的内存单元。换句话说,它允许你通过指向它的指针来读取和修改var变量。第四行等价于var = 200;语句。

空指针不指向有效的内存地址。因此,解引用空指针必须避免,因为这被认为是未定义行为,通常会导致崩溃。

关于前面的示例的最后一句话,我们通常将默认宏 NULL 定义为值 0,并且可以在声明时用来使指针无效。使用这个宏而不是直接使用 0 是一个好习惯,因为它 使得区分变量和指针更容易:

char* ptr = NULL;

代码框 1-20:使用 NULL 宏来使指针无效

C++ 中的指针与 C 中的完全相同。它们需要通过在其中存储 0NULL 来使它们无效,但 C++11 有一个用于初始化指针的新关键字。它既不是像 NULL 这样的宏,也不是像 0 这样的整数。这个关键字是 nullptr,可以用来使指针无效或检查它们是否为空。以下示例演示了它在 C++11 中的用法:

char* ptr = nullptr;

代码框 1-21:在 C++11 中使用 nullptr 使指针无效

记住这一点至关重要,指针 必须 在声明时初始化。如果你在声明时不希望存储任何有效的内存地址,不要让它们保持未初始化状态。通过分配 0NULL! 来使其为空。否则,你可能会遇到致命的错误!

在大多数现代编译器中,未初始化的指针总是被置为空。这意味着所有未初始化的指针的初始值都是 0。但这不应被视为不正确初始化指针的借口。记住,你正在为不同的架构编写代码,新旧架构,这可能在旧系统上引起问题。此外,你将在大多数 内存分析器 中为这些类型的未初始化指针获得错误和警告列表。内存分析器将在 第四章进程内存结构第五章栈和堆 中详细解释。

变量指针的算术运算

内存最简单的图景是一个非常长的单维字节数组。带着这个图景,如果你站在一个字节上,你只能在数组中前后移动;没有其他可能的移动。所以,这也会适用于指向内存中不同字节的指针。增加指针会使指针向前移动,而减少它会使指针向后移动。指针不可能进行其他算术运算。

正如我们之前所说的,指针上的算术运算与字节数组中的移动类似。我们可以用这个图来介绍一个新概念:算术步长。我们需要这个新概念,因为当你将指针增加 1 时,它可能在内存中向前移动超过 1 个字节。每个指针都有一个算术步长,这意味着如果指针增加或减少 1,它将移动的字节数。这个算术步长由指针的 C 数据类型 决定。

在每个平台上,我们只有一个单一的内存单元,所有指针都存储在该内存中的地址。因此,从字节大小来看,所有指针应该具有相同的大小。但这并不意味着它们的算术步长都相同。正如我们之前提到的,指针的算术步长由其 C 数据类型决定。

例如,int 指针的大小与 char 指针相同,但它们的算术步长不同。int* 通常具有 4 字节的算术步长,而 char* 具有字节的算术步长。因此,增加整数指针会使它在内存中向前移动 4 字节(将 4 字节添加到当前地址),而增加字符指针只会使它在内存中向前移动 1 字节。以下示例,示例 1.10,演示了两种不同数据类型的两个指针的算术步长:

#include <stdio.h>
int main(int argc, char** argv) {
  int var = 1;
  int* int_ptr = NULL; // nullify the pointer
  int_ptr = &var;
  char* char_ptr = NULL;
  char_ptr = (char*)&var;
  printf("Before arithmetic: int_ptr: %u, char_ptr: %u\n",
          (unsigned int)int_ptr, (unsigned int)char_ptr);
  int_ptr++;    // Arithmetic step is usually 4 bytes
  char_ptr++;   // Arithmetic step in 1 byte
  printf("After arithmetic: int_ptr: %u, char_ptr: %u\n",
          (unsigned int)int_ptr, (unsigned int)char_ptr);
  return 0;
}

代码框 1-22 [ExtremeC_examples_chapter1_10.c]: 两个指针的算术步长

以下 shell 框显示 示例 1.10 的输出。请注意,在同一台机器上连续两次运行可能打印不同的地址,甚至从平台到平台,因此你可能在输出中观察到不同的地址:

$ gcc ExtremeC_examples_chapter1_10.c
$ ./a.out
Before arithmetic: int_ptr: 3932338348, char_ptr: 3932338348
After arithmetic:  int_ptr: 3932338352, char_ptr: 3932338349
$

Shell Box 1-4:第一次运行示例 1.10 的输出

从算术操作前后的地址比较中可以清楚地看出,整数指针的步长是 4 字节,而字符指针的步长是 1 字节。如果你再次运行示例,指针可能指向其他地址,但它们的算术步长保持不变:

$ ./a.out
Before arithmetic: int_ptr: 4009638060, char_ptr: 4009638060
After arithmetic:  int_ptr: 4009638064, char_ptr: 4009638061
$

Shell Box 1-5:第二次运行示例 1.10 的输出

现在你已经了解了算术步长,我们可以讨论使用指针算术来 迭代 内存区域的经典示例。示例 1.11 和 1.12 是关于打印整数数组所有元素的。没有使用指针的简单方法在 示例 1.11 中提出,而基于指针算术的解决方案作为 示例 1.12 的一部分给出。

以下代码框显示了 示例 1.11 的代码:

#include <stdio.h>
#define SIZE 5
int main(int argc, char** argv) {
 int arr[SIZE];
 arr[0] = 9;
 arr[1] = 22;
 arr[2] = 30;
 arr[3] = 23;
 arr[4] = 18;
 for (int i = 0; i < SIZE; i++) {
   printf("%d\n", arr[i]);
 }
 return 0;
}

代码框 1-23 [ExtremeC_examples_chapter1_11.c]: 不使用指针算术遍历数组

代码框 1-23 中的代码你应该很熟悉。它只是使用 循环计数器 来引用数组的特定索引并读取其内容。但如果你想使用指针而不是通过 索引器 语法([] 之间的整数)来访问元素,应该以不同的方式完成。以下代码框演示了如何使用指针遍历数组边界:

#include <stdio.h>
#define SIZE 5
int main(int argc, char** argv) {
 int arr[SIZE];
 arr[0] = 9;
 arr[1] = 22;
 arr[2] = 30;
 arr[3] = 23;
 arr[4] = 18;
 int* ptr = &arr[0];
 for (;;) {
   printf("%d\n", *ptr);
   if (ptr == &arr[SIZE - 1]) {
     break;
   }
   ptr++;
 }
 return 0;
}

代码框 1-24 [ExtremeC_examples_chapter1_12.c]: 使用指针算术遍历数组

第二种方法,在 代码框 1-24 中演示,使用无限循环,当 ptr 指针的地址与数组的最后一个元素相同时会中断。

我们知道数组是内存中的相邻变量,所以增加和减少一个指向元素的指针实际上使其在数组内部来回移动,最终指向不同的元素。

从前面的代码中可以看出,ptr指针的数据类型是int*。这是因为它必须能够指向数组的任何单个元素,该元素是类型为int的整数。请注意,数组的所有元素都是同一类型,因此它们具有相同的大小。因此,增加ptr指针使其指向数组内部的下一个元素。正如你所见,在for循环之前,ptr指向数组的第一个元素,通过进一步的增加,它沿着数组的内存区域向前移动。这是指针算术的一个非常经典的用法。

注意,在 C 语言中,数组实际上是一个指向其第一个元素的指针。因此,在示例中,arr的实际数据类型是int*。因此,我们可以将这一行写成如下:

int* ptr = arr;

代替以下行:

int* ptr = &arr[0];

泛型指针

类型为void*的指针被称为泛型指针。它可以指向任何地址,就像所有其他指针一样,但我们不知道它的实际数据类型,因此我们不知道它的算术步长。泛型指针通常用于存储其他指针的内容,但它们忘记了那些指针的实际数据类型。因此,泛型指针不能被解引用,并且不能对其进行算术运算,因为其底层数据类型是未知的。以下示例,示例 1.13,展示了解引用泛型指针是不可能的:

#include <stdio.h>
int main(int argc, char** argv) {
 int var = 9;
 int* ptr = &var;
 void* gptr = ptr;
 printf("%d\n", *gptr);
 return 0;
}

Code Box 1-25 [ExtremeC_examples_chapter1_13.c]:解引用泛型指针会生成编译错误!

如果你使用 Linux 中的gcc编译前面的代码,你会得到以下错误:

$ gcc ExtremeC_examples_chapter1_13.c
In function 'main':warning: dereferencing 'void *' pointer
  printf("%d\n", *gptr);
                 ^~~~~
error: invalid use of void expression
  printf("%d\n", *gptr);
$

Shell Box 1-6:在 Linux 中编译示例 1.13

如果你使用 macOS 中的clang编译它,错误信息不同,但它指的是相同的问题:

$ clang ExtremeC_examples_chapter1_13.c
error: argument type 'void' is incomplete
  printf("%d\n", *gptr);
                 ^
1 error generated.
$

Shell Box 1-7:在 macOS 中编译示例 1.13

正如你所见,这两个编译器都不接受对泛型指针进行解引用。实际上,对泛型指针进行解引用是没有意义的!那么,它们有什么用呢?实际上,泛型指针非常适合定义泛型函数,这些函数可以接受广泛范围的不同指针作为它们的输入参数。以下示例,示例 1.14,试图揭示有关泛型函数的细节:

#include <stdio.h>
void print_bytes(void* data, size_t length) {
  char delim = ' ';
  unsigned char* ptr = data;
  for (size_t i = 0; i < length; i++) {
    printf("%c 0x%x", delim, *ptr);
    delim = ',';
    ptr++;
  }
  printf("\n");
}
int main(int argc, char** argv) {
 int a = 9;
 double b = 18.9;
 print_bytes(&a, sizeof(int));
 print_bytes(&b, sizeof(double));
 return 0;
}

Code Box 1-26 [ExtremeC_examples_chapter1_14.c]:泛型函数的示例

在前面的代码框中,print_bytes函数接收一个地址作为void*指针和一个表示长度的整数。使用这些参数,函数从指定的地址开始打印所有字节,直到指定的长度。正如你所看到的,该函数接受一个通用指针,允许用户传递他们想要的任何指针。请注意,对void 指针(通用指针)的赋值不需要显式转换。这就是为什么我们没有对ab的地址进行显式转换。

print_bytes函数内部,我们必须使用一个unsigned char指针来在内存中移动。否则,我们无法直接对 void 指针参数data进行任何算术运算。正如你可能知道的,char*unsigned char*的步长为 1 字节。因此,它是逐字节遍历内存地址范围并逐个处理所有这些字节的最佳指针类型。

关于这个示例的最后一句话,size_t是一个标准的无符号数据类型,通常用于在 C 语言中存储大小。

size_t在 ISO/ICE 9899:TC3 标准的第 6.5.3.4 节中定义。这个 ISO 标准是著名的 2007 年修订的 C99 规范。这个标准一直是所有 C 语言实现的基础。ISO/ICE 9899:TC3 (2007)的链接是www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf

指针大小

如果你使用谷歌搜索“C 中指针的大小”,你可能会意识到你无法找到对该问题的明确答案。网上有很多答案,确实,你无法在不同的架构上为指针定义一个固定的大小。指针的大小取决于架构,而不是一个特定的 C 语言概念。C 语言并不太关心这样的与硬件相关的细节,它试图提供一种通用的方式来处理指针和其他编程概念。这就是为什么我们称 C 语言为标准。对 C 语言来说,只有指针及其上的算术运算才是重要的。

架构指的是计算机系统中使用的硬件。你将在即将到来的章节“编译和链接”中找到更多细节。

你始终可以使用sizeof函数来获取指针的大小。查看你的目标架构上sizeof(char*)的结果就足够了。一般来说,32 位架构上的指针是 4 字节,64 位架构上的指针是 8 字节,但在其他架构上你可能找到不同的大小。请记住,你编写的代码不应该依赖于指针大小的特定值,也不应该对其做出任何假设。否则,当你将代码移植到其他架构时,你可能会遇到麻烦。

悬挂指针

由于指针误用导致的问题有很多,悬垂指针的问题尤为著名。指针通常指向一个已分配变量的地址。读取或修改没有注册变量的地址是一个大错误,可能会导致崩溃或段错误情况。段错误是一个令人恐惧的错误,每个 C/C++开发者至少应该在编写代码时遇到过一次。这种情况通常发生在你误用指针时。你正在访问你无权访问的内存位置。之前那里有一个变量,但现在已经被释放了。

让我们尝试在以下示例中产生这种情况,示例 1.15

#include <stdio.h>
int* create_an_integer(int default_value) {
  int var = default_value;
  return &var;
}
int main() {
  int* ptr = NULL;
  ptr = create_an_integer(10);
  printf("%d\n", *ptr);
  return 0;
}

代码框 1-27 [ExtremeC_examples_chapter1_15.c]:产生段错误情况

在前面的例子中,create_an_integer函数用于创建一个整数。它声明了一个具有默认值的整数并返回其地址给调用者。在main函数中,接收创建的整数var的地址并将其存储在ptr指针中。然后,解引用ptr指针,并打印出var变量中的值。

但事情并不那么简单。当你想在 Linux 机器上使用gcc编译器编译这段代码时,它会生成以下警告,但仍然成功完成编译,并得到最终的可执行文件:

$ gcc ExtremeC_examples_chapter1_15.c
In function 'f':
warning: function returns address of local variable [-Wreturn-local-addr]
   return &var;
          ^~~~
$

Shell 框 1-8:在 Linux 中编译 example 1.15

这确实是一条重要的警告信息,程序员很容易忽略并忘记。我们将在第五章栈和堆中更详细地讨论这个问题。让我们看看如果我们继续执行生成的可执行文件会发生什么。

当你运行示例 1.15时,你会得到一个段错误错误,程序会立即崩溃:

$ ./a.out
Segmentation fault (core dumped)
$

Shell 框 1-9:运行 example 1.15 时发生段错误

那么,出了什么问题呢?ptr指针是悬垂的,它指向一个已释放的内存部分,这部分内存曾经是变量var的内存位置。

var变量是create_an_integer函数的局部变量,在离开函数后将被释放,但其地址可以从函数中返回。因此,在将返回的地址复制到main函数中的ptr作为部分内容后,ptr变成了一个悬垂指针,指向内存中的无效地址。现在,解引用指针会导致严重问题,程序会崩溃。

如果你回顾编译器生成的警告,它清楚地指出了问题。

它说你在返回局部变量的地址,这个地址在函数返回后将被释放。聪明的编译器!如果你认真对待这些警告,你就不会遇到这些令人恐惧的 bug。

但是,重写示例的正确方法是什么?是的,使用 堆内存。我们将在 第四章 进程内存结构第五章 栈和堆 中全面介绍堆内存,但现在,我们将使用 堆分配 来重写示例,你将看到如何从使用 而不是 中受益。

示例 1.16 以下展示了如何使用堆内存分配变量,并允许函数之间传递地址而不遇到任何问题:

#include <stdio.h>
#include <stdlib.h>
int* create_an_integer(int default_value) {
  int* var_ptr = (int*)malloc(sizeof(int));
  *var_ptr = default_value;
  return var_ptr;
}
int main() {
  int* ptr = NULL;
  ptr = create_an_integer(10);
  printf("%d\n", *ptr);
  free(ptr);
  return 0;
}

代码框 1-28 [ExtremeC_examples_chapter1_16.c]: 使用堆内存重写示例 1.15

正如你在前面的代码框中看到的,我们包含了一个新的头文件 stdlib.h,并且使用了两个新的函数,mallocfree。简单的解释是这样的:在 create_an_integer 函数内部创建的整型变量不再是局部变量了。相反,它是一个从堆内存分配的变量,其生命周期不再局限于声明它的函数。因此,它可以在调用者(外部)函数中被访问。指向这个变量的指针也不再是悬垂指针了,只要变量存在且没有被释放,它们就可以被解引用。最终,通过调用 free 函数来释放变量,结束其生命周期。请注意,当不再需要堆变量时,释放堆变量是强制性的。

在本节中,我们讨论了有关变量指针的所有基本问题。在下一节中,我们将讨论 C 中的函数及其解剖结构。

关于函数的一些细节

C 是一种 过程式 编程语言。在 C 中,函数充当过程,它们是 C 程序的构建块。因此,了解它们是什么,它们如何表现,以及当你进入或离开函数时会发生什么,是很重要的。一般来说,函数(或过程)类似于普通变量,它们存储算法而不是值。通过将变量和函数组合成新的类型,我们可以在相同的概念下存储相关的值和算法。这就是我们在 面向对象编程 中所做的事情,它将在本书的第三部分 面向对象 中介绍。在本节中,我们想要探索函数,并讨论它们在 C 中的属性。

函数的解剖结构

在本节中,我们希望在单个地方回顾有关 C 函数的所有内容。如果你觉得这对你来说很熟悉,你可以简单地跳过这一节。

函数是一个具有名称、输入参数列表和输出结果列表的逻辑盒子。在 C 语言以及受 C 语言影响的许多其他编程语言中,函数只返回一个值。在 C++和 Java 这样的面向对象语言中,函数(通常称为方法)也可以抛出异常,而 C 语言则不行。函数通过函数调用来调用,即简单地使用函数名称来执行其逻辑。正确的函数调用应该传递所有必需的参数给函数,并等待其执行。请注意,在 C 语言中,函数总是阻塞的。这意味着调用者必须等待被调用函数完成,然后才能收集返回的结果。

与阻塞函数相反,我们可以有一个非阻塞函数。在调用非阻塞函数时,调用者不需要等待函数完成,它可以继续执行。在这种方案中,通常有一个回调机制,当被调用(或被调用者)函数完成时触发。非阻塞函数也可以称为异步函数或简单地称为async 函数。由于 C 语言中没有异步函数,我们需要使用多线程解决方案来实现它们。我们将在本书的第五部分并发中更详细地解释这些概念。

有趣的是,现在对使用非阻塞函数而非阻塞函数的兴趣日益增长。这通常被称为事件驱动编程。在这种编程方法中,非阻塞函数是核心,大多数编写的函数都是非阻塞的。

在事件驱动编程中,实际的功能调用发生在事件循环内部,并且当事件发生时,会触发适当的回调。例如,libuvlibev这样的框架推广了这种编程方式,并允许你围绕一个或多个事件循环来设计你的软件。

设计中的重要性

函数是过程式编程的基本构建块。自从编程语言中正式支持它们以来,它们对我们编写代码的方式产生了巨大影响。使用函数,我们可以将逻辑存储在半变量实体中,并在需要时随时随地进行调用。使用它们,我们只需编写一次特定逻辑,就可以在多个地方多次使用。

此外,函数允许我们隐藏一段逻辑,使其不被其他现有逻辑看到。换句话说,它们在各个逻辑组件之间引入了一个抽象层次。例如,假设你有一个名为avg的函数,它计算输入数组的平均值。你还有一个名为main的函数,它调用avg函数。我们说avg函数内部的逻辑对main函数内部的逻辑是隐藏的。

因此,如果你想更改avg函数内部的逻辑,你不需要更改main函数内部的逻辑。这是因为main函数只依赖于avg函数的名称和可用性。这是一个巨大的成就,至少在我们不得不使用穿孔卡片来编写和执行程序的那些年里是这样!

我们仍然在使用这个特性来设计用 C 编写的库,甚至是 C++和 Java 这样的高级编程语言。

栈管理

如果你查看在类 Unix 操作系统中运行的过程的内存布局,你会注意到所有进程都共享一个类似的布局。我们将在 第四章进程内存结构 中更详细地讨论这个布局,但现在,我们想要介绍其 之一;栈段。栈段是所有局部变量、数组和结构默认分配的内存位置。所以,当你在一个函数中声明一个局部变量时,它就是从栈段分配的。这种分配总是在栈段顶部发生。

注意到段名称中的术语 。这意味着这个段的行为就像一个栈。变量和数组总是分配在其顶部,而顶部的变量是首先被移除的。记住这个与栈概念的类比。我们将在下一段中回到这个话题。

栈段也用于函数调用。当你调用一个函数时,一个包含返回地址和所有传递参数的 栈帧 被放置在栈段顶部,然后才执行函数逻辑。当从函数返回时,栈帧被弹出,由返回地址指定的指令被执行,这通常应该继续调用函数。

函数体内声明的所有局部变量都被放置在栈段顶部。所以,当离开函数时,所有栈变量都会被释放。这就是为什么我们称它们为 局部变量,也是为什么一个函数不能访问另一个函数中的变量。这种机制也解释了为什么在进入函数之前和离开函数之后不会定义局部变量。

理解栈段及其工作方式对于编写正确和有意义的代码至关重要。它还能防止常见的内存错误发生。它也是一个提醒,你不能在栈上创建任何你想要的尺寸的变量。栈是内存的一个有限部分,你可能会填满它并可能收到 栈溢出 错误。这通常发生在我们有很多函数调用消耗了所有的栈段,它们的栈帧时。这在处理递归函数时非常常见,当函数在没有任何中断条件或限制的情况下调用自己时。

值传递与引用传递

在大多数计算机编程书籍中,都有一个关于函数参数按值传递和按引用传递的章节。幸运的是,或者不幸的是,在 C 中我们只有按值传递。

在 C 中没有引用,因此也没有按引用传递。所有内容都复制到函数的局部变量中,函数退出后无法读取或修改它们。

尽管有许多示例似乎证明了按引用传递函数调用,但我应该说的是,在 C 中按引用传递是一种错觉。在本节的其余部分,我们想要揭露这个错觉,并说服你那些示例也是按值传递。以下示例将演示这一点:

#include <stdio.h>
void func(int a) {
  a = 5;
}
int main(int argc, char** argv) {
  int x = 3;
  printf("Before function call: %d\n", x);
  func(x);
  printf("After function call: %d\n", x);
  return 0;
}

Code Box 1-29 [ExtremeC_examples_chapter1_17.c]:一个按值传递函数调用的示例

预测输出很容易。x 变量没有变化,因为它是以值传递的。以下 shell box 展示了 示例 1.17 的输出并证实了我们的预测:

$ gcc ExtremeC_examples_chapter1_17.c
$ ./a.out
Before function call: 3
After function call: 3
$

Shell Box 1-10:示例 1.17 的输出

以下示例,示例 1.18,演示了在 C 中不存在按引用传递:

#include <stdio.h>
void func(int* a) {
  int b = 9;
  *a = 5;
  a = &b;
}
int main(int argc, char** argv) {
  int x = 3;
  int* xptr = &x;
  printf("Value before call: %d\n", x);
  printf("Pointer before function call: %p\n", (void*)xptr);
  func(xptr);
  printf("Value after call: %d\n", x);
  printf("Pointer after function call: %p\n", (void*)xptr);
  return 0;
}

Code Box 1-30 [ExtremeC_examples_chapter1_18.c]:一个按指针传递函数调用的示例,与按引用传递不同

这就是输出结果:

$ gcc ExtremeC_examples_chapter1_18.c
$ ./a.out
The value before the call: 3
Pointer before function call: 0x7ffee99a88ec
The value after the call: 5
Pointer after function call: 0x7ffee99a88ec
$

Shell Box 1-11:示例 1.18 的输出

如你所见,函数调用后指针的值没有改变。这意味着指针是以按值传递的参数传递的。在 func 函数内部解引用指针允许访问指针指向的变量。但你看到,在函数内部改变指针参数的值并不会改变调用函数中的对应参数。在 C 的函数调用过程中,所有参数都是按值传递的,解引用指针允许修改调用函数的变量。

值得注意的是,上述示例演示了一个按指针传递的示例,其中我们传递变量的指针而不是直接传递它们。通常建议使用指针作为参数而不是将大对象传递给函数,但为什么?这很容易猜测。复制指针参数的 8 个字节比复制大对象的数百个字节要高效得多。

意外地,在上面的示例中传递指针并不高效!这是因为 int 类型是 4 个字节,复制它比复制其指针的 8 个字节更高效。但结构体和数组的情况并非如此。由于结构体和数组的复制是按字节进行的,并且它们中的所有字节都应该一个接一个地复制,因此通常最好传递指针。

现在我们已经讨论了有关 C 中函数的一些细节,让我们来谈谈函数指针。

函数指针

函数指针是 C 编程语言的另一个超级特性。前两个部分是关于变量指针和函数的,本节将结合它们,讨论一个更有趣的话题:函数指针。

它们有众多应用,但将大型二进制文件拆分为较小的二进制文件,并在另一个小的可执行文件中重新加载,是其中最重要的应用之一。这导致了模块化和软件设计。函数指针是 C++中实现多态性的构建块,并允许我们扩展现有的逻辑。在本节中,我们将介绍它们,并为我们在接下来的章节中将要覆盖的更高级主题做好准备。

就像变量指针指向变量一样,函数指针指向函数,并允许你间接调用该函数。下面的例子,例子 1.19,可以作为这个主题的良好起点:

#include <stdio.h>
int sum(int a, int b) {
  return a + b;
}
int subtract(int a, int b) {
  return a - b;
}
int main() {
  int (*func_ptr)(int, int);
  func_ptr = NULL;
  func_ptr = &sum;
  int result = func_ptr(5, 4);
  printf("Sum: %d\n", result);
  func_ptr = &subtract;
  result = func_ptr(5, 4);
  printf("Subtract: %d\n", result);
  return 0;
}

Code Box 1-31 [ExtremeC_examples_chapter1_19.c]:使用单个函数指针调用不同的函数

在先前的代码框中,func_ptr是一个函数指针。它只能指向与它的签名相匹配的特定类别的函数。签名限制了指针只能指向接受两个整数参数并返回整数结果的函数。

正如你所见,我们定义了两个名为sumsubtract的函数,它们与func_ptr指针的签名相匹配。先前的例子使用func_ptr函数指针分别指向sumsubtract函数,然后使用相同的参数调用它们并比较结果。这是例子的输出:

$ gcc ExtremeC_examples_chapter1_19.c
$ ./a.out
Sum: 9
Subtract: 1
$

Shell Box 1-12:例子 1.19 的输出

正如你在例子 1.19中看到的,我们可以使用单个函数指针调用具有相同参数列表的不同函数,这是一个重要的特性。如果你熟悉面向对象编程,首先想到的可能是多态虚函数。实际上,这是在 C 语言中支持多态并模仿 C++虚函数的唯一方法。我们将作为本书第三部分对象 导向的一部分介绍面向对象编程。

就像变量指针一样,正确初始化函数指针非常重要。对于那些在声明时不会立即初始化的函数指针,必须将它们设置为 null。函数指针的 null 化在先前的例子中得到了演示,并且它与变量指针非常相似。

通常建议为函数指针定义一个新的类型别名。下面的例子,例子 1.20,演示了应该如何做:

#include <stdio.h>
typedef int bool_t;
typedef bool_t (*less_than_func_t)(int, int);
bool_t less_than(int a, int b) {
  return a < b ? 1 : 0;
}
bool_t less_than_modular(int a, int b) {
  return (a % 5) < (b % 5) ? 1 : 0;
}
int main(int argc, char** argv) {
  less_than_func_t func_ptr = NULL;
  func_ptr = &less_than;
  bool_t result = func_ptr(3, 7);
  printf("%d\n", result);
  func_ptr = &less_than_modular;
  result = func_ptr(3, 7);
  printf("%d\n", result);
  return 0;
}

Code Box 1-32 [ExtremeC_examples_chapter1_20.c]:使用单个函数指针调用不同的函数

typedef 关键字允许你为已定义的类型定义一个别名。在先前的例子中有两个新的类型别名:bool_t,它是 int 类型的别名,以及 less_than_func_t 类型,它是函数指针类型 bool_t (*)(int, int) 的别名类型。这些别名增加了代码的可读性,并允许你为长而复杂的类型选择一个更短的名字。在 C 语言中,新类型的名字通常以 _t 结尾,你可以在许多其他标准类型别名中找到这个约定,例如 size_ttime_t

结构体

从设计角度来看,结构体是 C 语言中最基本的概念之一。如今,它们不再仅限于 C 语言,你几乎可以在每种现代编程语言中找到它们对应的理念。

但我们应该在计算历史的背景下讨论它们,当时没有其他编程语言提供这样的概念。在许多努力摆脱机器级编程语言的尝试中,引入结构体是朝着在编程语言中实现封装迈出的重要一步。数千年来,我们的思维方式并没有发生太大的变化,封装一直是我们的逻辑推理的核心手段。

但在 C 语言之后,我们终于有了某种工具,在这种情况下,是一种编程语言,它能够理解我们的思维方式,并能存储和处理我们推理的构建块。最终,我们得到了一种类似于我们思维和想法的语言,所有这一切都发生在我们得到结构体的时候。与现代语言中发现的封装机制相比,C 语言的结构体并不完美,但它们足以让我们构建一个平台,在这个平台上创建我们最好的工具。

为什么需要结构体?

你知道每种编程语言都有一些基本数据类型PDTs)。使用这些基本数据类型,你可以设计你的数据结构,并围绕它们编写算法。这些基本数据类型是编程语言的一部分,它们不能被更改或删除。例如,没有 intdouble 这两种基本类型,你将无法使用 C 语言。

当你需要自定义数据类型,而语言中的数据类型又不足以满足需求时,结构体就派上用场了。用户定义类型UDTs)是由用户创建的类型,它们不是语言的一部分。

注意,用户定义类型(UDTs)与使用 typedef 定义的类型不同。关键字 typedef 并没有真正创建一个新类型,而是为已定义的类型定义了一个别名或同义词。但结构体允许你将全新的用户定义类型(UDTs)引入到你的程序中。

结构体在其他编程语言中具有对应的概念,例如 C++ 和 Java 中的类或 Perl 中的包。在这些语言中,它们被认为是类型创建者

为什么需要用户定义类型?

那么,为什么我们需要在程序中创建新的类型呢?这个问题的答案揭示了软件设计背后的原则以及我们用于日常软件开发的 方法。我们创建新的类型,因为我们每天都在用大脑进行常规分析时这样做。

我们不会把周围的环境看作整数、双精度浮点数或字符。我们已经学会了将相关的属性分组到同一个对象下。我们将在第六章面向对象编程和封装中更详细地讨论我们分析周围环境的方式。但作为对起始问题的回答,我们需要新的类型,因为我们使用它们在更高层次的逻辑上分析我们的问题,接近于人类的逻辑。

在这里,你需要熟悉商业逻辑这个术语。商业逻辑是在商业中找到的所有实体和规则的总和。例如,在银行系统的商业逻辑中,你会遇到客户、账户、余额、货币、现金、支付等概念,这些都是为了使货币提取等操作成为可能和有意义的。

假设你必须用纯整数、浮点数或字符来解释一些银行逻辑。这几乎是不可能的。如果对程序员来说可能,那么对商业分析师来说几乎毫无意义。在一个具有明确商业逻辑的真实软件开发环境中,程序员和商业分析师需要紧密合作。因此,他们需要拥有一套共享的术语、词汇表、类型、操作、规则、逻辑等等。

今天,不支持在其类型系统中创建新类型的编程语言可以被认为是一种死语言。也许这就是为什么大多数人认为 C 是一种死编程语言,主要是因为他们无法在 C 中轻松定义新的类型,他们更愿意转向 C++或 Java 等高级语言。是的,在 C 中创建一个良好的类型系统并不容易,但你需要的一切都在那里。

即使今天,选择 C 作为项目的主要语言,并接受在 C 项目中创建和维护一个良好的类型系统的努力,背后可能有多种原因。甚至今天,许多公司都在这样做。

尽管我们每天在软件分析中需要新的类型,但 CPU 并不理解这些新的类型。CPU 试图坚持 PDTs(程序数据类型)和快速计算,因为它们被设计成这样做。所以,如果你用高级语言编写了一个程序,它应该被翻译成 CPU 级别的指令,这可能会花费你更多的时间和资源。

在这种意义上,幸运的是,C 与 CPU 级别的逻辑并不遥远,它有一个可以轻松转换的类型系统。你可能听说过 C 是一种低级或硬件级别的编程语言。这是为什么一些公司和组织试图用 C 编写和维护他们的核心框架,即使今天也是如此。

结构有什么作用?

结构体将相关的值封装在单个统一类型下。作为一个早期示例,我们可以将redgreenblue变量组合在一个新的单一数据类型color_t下。新的类型color_t可以在各种程序中表示 RGB 颜色,例如图像编辑应用程序。我们可以定义相应的 C 结构体如下:

struct color_t {
  int red;
  int green;
  int blue;
};

代码框 1-33:C 语言中表示 RGB 颜色的结构体

如我们之前所述,结构体具有封装性。封装是软件设计中最基本的概念之一。它涉及到将相关的字段分组并封装在一个新的类型下。然后,我们可以使用这个新类型来定义所需的变量。我们将在第六章面向对象编程与封装中详细描述封装,同时讨论面向对象设计。

注意,我们使用_t后缀来命名新的数据类型。

内存布局

对于 C 程序员来说,了解结构变量的确切内存布局通常很重要。在内存中有一个糟糕的布局可能会在某些架构中导致性能下降。不要忘记,我们编写代码是为了生成 CPU 的指令。值存储在内存中,CPU 应该能够足够快地读写它们。了解内存布局有助于开发者理解 CPU 的工作方式,并调整他们的代码以获得更好的结果。

以下示例,示例 1.21,定义了一个新的结构类型sample_t,并声明了一个结构变量var。然后,它用一些值填充其字段,并打印变量在内存中的大小和实际字节数。这样,我们可以观察变量的内存布局:

#include <stdio.h>
struct sample_t {
  char first;
  char second;
  char third;
  short fourth;
};
void print_size(struct sample_t* var) {
  printf("Size: %lu bytes\n", sizeof(*var));
}
void print_bytes(struct sample_t* var) {
  unsigned char* ptr = (unsigned char*)var;
  for (int i = 0; i < sizeof(*var); i++, ptr++) {
    printf("%d ", (unsigned int)*ptr);
  }
  printf("\n");
}
int main(int argc, char** argv) {
  struct sample_t var;
  var.first = 'A';
  var.second = 'B';
  var.third = 'C';
  var.fourth = 765;
  print_size(&var);
  print_bytes(&var);
  return 0;
}

代码框 1-34 [ExtremeC_examples_chapter1_21.c]:打印分配给结构变量的字节数

对了解一切的确切内存布局的渴望是 C/C++特有的,随着编程语言变得高级,这种渴望会消失。例如,在 Java 和 Python 中,程序员对非常低级的内存管理细节了解较少,另一方面,这些语言不提供很多关于内存的细节。

正如你在代码框 1-34中看到的,在 C 语言中,在声明结构变量之前必须使用struct关键字。因此,在前面的例子中我们有struct sample_t var,这展示了你应该如何在声明语句中使用关键字在结构类型之前。提到这一点是显而易见的,你需要使用一个.(点)来访问结构变量的字段。如果它是一个结构指针,你需要使用->(箭头)来访问其字段。

为了防止在代码中大量使用struct,在定义一个新的结构类型和声明一个新的结构变量时,我们可以使用typedef来为结构定义一个新的别名类型。以下是一个示例:

typedef struct {
  char first;
  char second;
  char third;
  short fourth;
} sample_t;

现在,你可以声明变量而不使用struct关键字:

sample_t var;

以下是在 macOS 机器上编译并执行前一个示例后的输出。请注意,生成的数字可能因主机系统而异:

$ clang ExtremeC_examples_chapter1_21.c
$ ./a.out
Size: 6 bytes
65 66 67 0 253 2
$

Shell Box 1-13: 示例 1.21 的输出

正如您在前一个 Shell Box 中看到的,sizeof(sample_t) 返回了 6 个字节。结构变量的内存布局与数组非常相似。在数组中,所有元素在内存中相邻,这对于结构变量及其字段也是如此。区别在于,在数组中,所有元素具有相同的类型和大小,但在结构变量中并非如此。每个字段可以具有不同的类型,因此它也可以具有不同的大小。与内存大小容易计算的数组不同,结构变量在内存中的大小取决于几个因素,并且不容易确定。

起初,猜测结构变量的大小似乎很简单。对于前一个示例中的结构,它有四个字段,三个 char 字段和一个 short 字段。通过简单的计算,如果我们假设 sizeof(char) 是 1 个字节,sizeof(short) 是 2 个字节,那么 sample_t 类型的每个变量在其内存布局中应该有 5 个字节。但当我们查看输出时,我们看到 sizeof(sample_t) 是 6 个字节。多出 1 个字节!为什么会有这个额外的字节?再次,当我们查看结构变量 var 的内存布局中的字节时,我们可以看到它与我们的预期 65 66 67 253 2 略有不同。

为了使这一点更清晰并解释为什么结构变量的大小不是 5 个字节,我们需要引入 内存对齐 的概念。CPU 总是执行所有计算。除此之外,它需要在计算任何内容之前从内存中加载值,并在计算后需要将结果存储回内存。计算在 CPU 内部非常快,但与内存访问相比非常慢。了解 CPU 如何与内存交互非常重要,因为这样我们可以利用这些知识来提升程序或调试问题。

CPU 通常在每次内存访问中读取特定数量的字节。这个字节数通常被称为 。因此,内存被分成字,字是 CPU 用来从内存读取和写入的原子单元。一个字中实际的字节数是一个架构相关的因素。例如,在大多数 64 位机器上,字大小是 32 位或 4 个字节。关于内存对齐,我们说如果变量的起始字节位于字的开始处,则该变量在内存中是对齐的。这样,CPU 可以以优化的内存访问次数加载其值。

关于前面的例子,例子 1.21,前三个字段,firstsecondthird,每个字段都是 1 字节,它们位于结构布局的第一个单词中,并且它们都可以通过一次内存访问来读取。关于第四个字段,fourth 占用 2 字节。如果我们不考虑内存对齐,它的第一个字节将是第一个单词的最后一个字节,这使得它未对齐。

如果是这样的话,CPU 就需要一起进行两次内存访问和一些位移动,才能检索字段的值。这就是为什么我们在字节 67 后看到一个额外的零。这个零字节被添加是为了完成当前单词,并让第四个字段从下一个单词开始。在这里,我们说第一个单词被一个零字节填充。编译器使用 填充 技术在内存中对齐值。填充是为了匹配对齐而添加的额外字节。

可以关闭对齐。在 C 术语中,我们使用一个更具体的术语来表示对齐的结构。我们说结构不是打包的。打包结构没有对齐,使用它们可能会导致二进制不兼容和性能下降。你可以轻松地定义一个打包的结构。我们将在下一个例子,例子 1.22,中这样做,它与前面的例子,例子 1.21,非常相似。在这个例子中,sample_t 结构是打包的。下面的代码框显示了 例子 1.22。请注意,类似的代码被省略号替换了:

#include <stdio.h>
struct __attribute__((__packed__)) sample_t {
  char first;
  char second;
  char third;
  short fourth;
} ;
void print_size(struct sample_t* var) {
  // ...
}
void print_bytes(struct sample_t* var) {
  // ...
}
int main(int argc, char** argv) {
  // ...
}

代码框 1-35 [ExtremeC_examples_chapter1_22.c]:声明一个打包的结构

在下面的 shell 框中,前面的代码使用 clang 在 macOS 上编译并运行:

$ clang ExtremeC_examples_chapter1_22.c
$ ./a.out
Size: 5 bytes
65 66 67 253 2
$

Shell Box 1-14:例子 1.22 的输出

正如你在 Shell Box 1-14 中看到的那样,打印的大小正好是我们预期的 例子 1.21 的一部分。最终的布局也与我们的预期相匹配。打包结构通常用于内存受限的环境,但它们可能会对大多数架构的性能产生巨大的负面影响。只有新的 CPU 可以在不强制额外成本的情况下从多个单词中读取未对齐的值。请注意,默认情况下启用了内存对齐。

嵌套结构

正如我们在前面的章节中解释的那样,在 C 语言中,我们通常有两种数据类型。有一种是语言的基本类型,还有一种是由程序员使用 struct 关键字定义的类型。前者是 PDTs,后者是 UDTs。

到目前为止,我们的结构示例都是关于仅由 PDTs(结构)组成的 UDTs(结构)。但在这个部分,我们将给出一个由其他 UDTs(结构)组成的 UDTs(结构)的例子。这些被称为 复杂数据类型,它们是嵌套几个结构的结果。

让我们从例子,例子 1.23,开始:

typedef struct {
  int x;
  int y;
} point_t;
typedef struct {
  point_t center;
  int radius;
} circle_t;
typedef struct {
  point_t start;
  point_t end;
} line_t;

代码框 1-36 [ExtremeC_examples_chapter1_23.c]:声明一些嵌套的结构

在前面的代码框中,我们有三个结构;point_tcircle_tline_tpoint_t 结构是一个简单的 UDT,因为它仅由 PDT 组成,但其他结构包含 point_t 类型的变量,这使得它们成为复杂的 UDT。

复杂结构的大小计算方式与简单结构完全相同,即通过将所有字段的大小相加。当然,我们仍然应该注意对齐,因为它可能会影响复杂结构的大小。因此,如果 sizeof(int) 是 4 字节,则 sizeof(point_t) 将是 8 字节。然后,sizeof(circle_t) 是 12 字节,sizeof(line_t) 是 16 字节。

通常将结构变量称为对象。它们与面向对象编程中的对象完全类似,我们将看到它们可以封装值和函数。因此,称它们为 C 对象并没错。

结构指针

与指向 PDT 指针类似,我们也可以有指向 UDT 的指针。它们的工作方式与 PDT 指针完全相同。它们指向内存中的一个地址,你可以像对 PDT 指针那样对它们进行算术运算。UDT 指针也有与 UDT 大小相等的算术步长。如果你对指针或允许在它们上进行的算术运算一无所知,请前往 指针 部分,并阅读它。

重要的是要知道,结构变量指向结构变量第一个字段的位置。在前面的例子 示例 1.23 中,类型为 point_t 的指针将指向其第一个字段 x 的地址。这同样适用于类型 circle_t。类型为 circle_t 的指针将指向其第一个字段 center,因为它实际上是一个 point_t 对象,所以它将指向 point_t 类型中第一个字段 x 的地址。因此,我们可以有 3 个不同的指针指向内存中的同一单元格。以下代码将演示这一点:

#include <stdio.h>
typedef struct {
  int x;
  int y;
} point_t;
typedef struct {
  point_t center;
  int radius;
} circle_t;
int main(int argc, char** argv) {
  circle_t c;
  circle_t* p1 = &c;
  point_t*  p2 = (point_t*)&c;
  int*      p3 = (int*)&c;
  printf("p1: %p\n", (void*)p1);
  printf("p2: %p\n", (void*)p2);
  printf("p3: %p\n", (void*)p3);
  return 0;
}

代码框 1-37 [ExtremeC_examples_chapter1_24.c]:三个不同类型的指针指向内存中的同一字节

这是输出:

$ clang ExtremeC_examples_chapter1_24.c
$ ./a.out
p1: 0x7ffee846c8e0
p2: 0x7ffee846c8e0
p3: 0x7ffee846c8e0
$

Shell Box 1-15: 示例 1.24 的输出

正如你所见,所有指针都指向同一字节,但它们的类型不同。这通常用于通过添加更多字段来扩展来自其他库的结构。这也是我们在 C 中实现 继承 的方式。我们将在 第八章继承和多态 中讨论这一点。

这章的最后部分。在下一章中,我们将深入了解 C 编译器管道以及如何正确编译和链接 C 项目。

摘要

在本章中,我们回顾了 C 编程语言的一些重要特性。我们试图更进一步,展示这些特性的设计方面以及背后的概念。当然,正确使用一个特性需要对该特性的不同方面有更深入的了解。作为本章的一部分,我们讨论了以下内容:

  • 我们讨论了 C 语言的预处理阶段以及各种指令如何影响预处理器以不同的方式行动或为我们生成特定的 C 代码。

  • 宏和宏展开机制允许我们在将翻译单元传递到编译阶段之前生成 C 代码。

  • 条件指令允许我们根据某些条件修改预处理的代码,并允许我们针对不同情况有不同的代码。

  • 我们还研究了变量指针以及它们在 C 语言中的应用。

  • 我们介绍了泛型指针以及如何有一个可以接受任何类型指针的函数。

  • 我们讨论了一些问题,例如段错误和悬挂指针,以展示由于误用指针可能出现的几种灾难性情况。

  • 接下来讨论了函数,并回顾了它们的语法。

  • 我们探讨了函数的设计方面以及它们如何有助于构建一个结构良好的 C 程序过程。

  • 我们还解释了函数调用机制以及如何使用栈帧将参数传递给函数。

  • 本章探讨了函数指针。函数指针强大的语法允许我们将逻辑存储在类似变量的实体中,并在以后使用它们。实际上,这是当今每个程序使用的根本机制,用于加载和操作。

  • 结构与函数指针的结合在 C 语言中产生了封装。我们将在本书的第三部分面向对象中更多地讨论这一点。

  • 我们试图解释结构的设计方面以及它们对我们以 C 语言设计程序的影响。

  • 我们还讨论了结构变量的内存布局以及它们如何在内存中放置以最大化 CPU 利用率。

  • 还讨论了嵌套结构。我们也查看了一下复杂结构变量的内部,并讨论了它们的内存布局应该如何。

  • 作为本章的最后一部分,我们讨论了结构指针。

下一章将是构建 C 项目的第一步。下一章将讨论 C 编译管道和链接机制。彻底阅读它对于继续阅读本书并进入后续章节至关重要。

第二章

从源代码到二进制

在编程中,一切始于源代码。实际上,通常被称为代码库源代码通常由多个文本文件组成。在这些文件中,每个文件都包含用编程语言编写的文本指令。

我们知道 CPU 不能直接执行文本指令。实际上,这些指令应该首先被编译(或翻译)成机器级指令,以便 CPU 执行,最终生成一个可运行的程序。

在本章中,我们将介绍从 C 源代码生成最终产品所需的步骤。本章对该主题进行了深入探讨,因此我们将其分为五个不同的部分:

  1. 标准的 C 编译管道:在第一部分,我们将介绍标准的 C 编译过程,管道中的各个步骤以及它们如何有助于从 C 源代码生成最终产品。

  2. 预处理器:在本节中,我们将更深入地讨论预处理器组件,它驱动预处理步骤。

  3. 编译器:在本节中,我们将更深入地探讨编译器。我们将解释编译器如何从源代码生成中间表示,然后将其翻译成汇编语言,并驱动编译步骤。

  4. 汇编器:在编译器之后,我们还会谈到汇编器,它在将编译器接收到的汇编指令转换为机器级指令方面发挥着重要作用。汇编器组件驱动汇编步骤。

  5. 链接器:在最后一部分,我们将更深入地讨论链接器组件,它驱动链接步骤。链接器是一个构建组件,最终创建 C 项目的实际产品。该组件存在一些特定的构建错误,对链接器的充分了解将帮助我们预防和解决这些问题。我们还讨论了 C 项目的各种最终产品,并给出了一些关于反汇编目标文件和读取其内容的提示。此外,我们还简要讨论了C++名称修饰是什么以及它是如何防止在构建 C++代码时链接步骤中出现的某些缺陷的。

我们在本章中的讨论主要围绕类 Unix 系统展开,但我们也会讨论与其他操作系统(如 Microsoft Windows)的一些差异。

在第一部分,我们需要解释 C 编译管道。了解管道如何从源代码生成可执行文件和库文件至关重要。虽然涉及多个概念和步骤,但如果我们想要为本书和未来章节的内容做好准备,彻底理解它们是至关重要的。注意,下一章将详细讨论 C 项目的各种产品,即目标文件

编译管道

编译一些 C 文件通常只需要几秒钟,但在这短暂的时间内,源代码进入了一个包含四个不同组件的管道,每个组件都执行特定的任务。这些组件如下:

  • 预处理器

  • 编译器

  • 汇编器

  • 链接器

管道中的每个组件都接受来自前一个组件的特定输入,并为管道中的下一个组件产生特定的输出。这个过程一直持续到由管道的最后一个组件生成产品

如果源代码能够成功通过所有必需的组件,那么它可以被转换成一个产品。这意味着即使其中一个组件出现小小的失败,也可能导致编译链接失败,从而让你收到相关的错误信息。

对于某些中间产品,例如可重定位目标文件,只要单个源文件成功通过前三个组件就足够了。最后一个组件,即链接器,通常用于通过合并一些已经准备好的可重定位目标文件来创建更大的产品,例如可执行目标文件。因此,构建一组 C 源文件可以创建一个或有时多个目标文件,包括可重定位的、可执行的以及共享目标文件

目前有各种各样的 C 编译器可供选择。虽然其中一些是免费和开源的,而另一些则是专有和商业的。同样,一些编译器可能只适用于特定的平台,而其他则是跨平台的,尽管重要的是要注意,几乎每个平台至少有一个兼容的 C 编译器。

注意

要查看可用的 C 编译器的完整列表,请参阅以下维基百科页面:en.wikipedia.org/wiki/List_of_compilers#C_compilers

在讨论本章中我们使用的默认平台和 C 编译器之前,让我们更详细地谈谈术语平台以及我们对其的理解。

平台是在特定硬件(或架构)上运行的操作系统组合,其 CPU 的指令集是其中最重要的部分。操作系统是平台软件组件的一部分,而架构定义了硬件部分。例如,我们可以在 ARM 供电的板上运行 Ubuntu,或者我们可以在 AMD 64 位 CPU 上运行 Microsoft Windows。

跨平台软件可以在不同的平台上运行。然而,重要的是要知道跨平台便携性是不同的。跨平台软件通常为每个平台有不同的二进制文件(最终目标文件)和安装程序,而便携软件在所有平台上使用相同的生成二进制文件和安装程序。

一些 C 编译器,例如gccclang,是跨平台的——它们可以为不同的平台生成代码——而 Java 字节码是便携的。

关于 C 和 C++,如果我们说 C/C++代码是可移植的,我们的意思是我们可以编译它到不同的平台,无需任何更改或只需对源代码进行少量修改。但这并不意味着最终的目标文件是可移植的。

如果你已经看过我们之前提到的维基百科文章,你可以看到有大量的 C 编译器。幸运的是,它们都遵循相同的标准编译流程,我们将在本章中介绍。

在这些许多编译器中,我们需要在本章中选择其中一个来工作。在本章中,我们将使用gcc 7.3.0 作为我们的默认编译器。我们选择gcc是因为它在大多数操作系统上可用,此外,还有许多在线资源可以找到。

我们还需要选择我们的默认平台。在本章中,我们选择了 Ubuntu 18.04 作为我们的默认操作系统,运行在 AMD 64 位 CPU 上作为我们的默认架构。

注意

有时本章可能会提到不同的编译器、不同的操作系统或不同的架构,以比较不同的平台和编译器。如果我们这样做,新平台或新编译器的规格将在之前给出。

在接下来的章节中,我们将描述编译流程中的步骤。首先,我们将构建一个简单的示例,看看 C 项目中的源文件是如何编译和链接的。在整个示例中,我们将熟悉与编译过程相关的新术语和概念。只有在那之后,我们才会在单独的章节中逐个处理每个组件。在那里,我们将深入解释每个组件的更多内部概念和过程。

构建 C 项目

在本节中,我们将演示如何构建一个 C 项目。我们将要工作的项目包含多个源文件,这是几乎所有 C 项目的常见特征。然而,在我们转到示例并开始构建之前,我们需要确保我们理解了典型 C 项目的结构。

头文件与源文件

每个 C 项目都有源代码,或代码库,以及与项目描述和现有标准相关的其他文档。在 C 代码库中,我们通常有两种包含 C 代码的文件:

  • 头文件,通常其名称带有.h扩展名。

  • 源文件,具有.c扩展名。

注意

为了方便起见,在本章中,我们可能会使用术语头文件代替头文件,以及源文件代替源文件

头文件通常包含枚举、宏和类型定义,以及函数、全局变量和结构的声明。在 C 语言中,一些编程元素如函数、变量和结构可以将其声明与其定义分开,分别放在不同的文件中。

C++遵循相同的模式,但在其他编程语言中,如 Java,元素是在声明的地方定义的。虽然这是 C 和 C++的伟大特性之一,因为它使它们能够将声明与定义解耦,但它也使得源代码更加复杂。

按照惯例,声明存储在头文件中,相应的定义则放在源文件中。这对于函数声明和函数定义来说尤为重要。

强烈建议您只将函数声明放在头文件中,并将函数定义移动到相应的源文件中。虽然这不是必需的,但将那些函数定义从头文件中分离出来是一个重要的设计实践。

虽然结构也可以有单独的声明和定义,但在某些特殊情况下,我们会将声明和定义移动到不同的文件中。我们将在 第八章继承和多态 中看到一个这样的例子,我们将讨论类之间的 继承 关系。

注意

头文件可以包含其他头文件,但不能包含源文件。源文件只能包含头文件。让源文件包含另一个源文件是一种不良实践。如果这样做,通常意味着您的项目中存在严重的设计问题。

为了更详细地说明这一点,我们将通过一个示例来查看。以下代码是 average 函数的声明。函数声明由一个 返回类型 和一个 函数签名 组成。函数签名简单地说就是函数的名称及其输入参数列表:

double average(int*, int);

代码框 2-1:average 函数的声明

声明引入了一个名为 average 的函数签名,它接收一个指向整数数组的指针以及一个表示数组中元素数量的第二个整数参数。声明还指出该函数返回一个双精度值。请注意,返回类型是声明的一部分,但通常不被认为是函数签名的一部分。

正如您在 代码框 2-1 中所看到的,函数声明以分号 ";" 结尾,并且没有由花括号括起来的 主体。我们还应该注意,函数声明中的参数没有关联的名称,这在 C 语言中是有效的,但仅限于声明,而不是定义。因此,即使在声明中,也建议您为参数命名。

函数声明是关于如何使用函数的,而定义则定义了该函数的实现方式。用户不需要知道参数名称就能使用该函数,正因为如此,可以在函数声明中隐藏它们。

在下面的代码中,您可以找到我们之前声明的average函数的定义。函数定义包含表示函数逻辑的实际 C 代码。这始终由一对花括号包围的代码体:

double average(int* array, int length) {
  if (length <= 0) {
    return 0;
  }
  double sum = 0.0;
  for (int i = 0; i < length; i++) {
    sum += array[i];
  }
  return sum / length;
}

代码框 2-2:平均函数的定义

正如我们之前所说的,为了强调这一点,函数声明放在头文件中,而定义(或主体)放在源文件中。在极少数情况下,我们有足够的理由违反这一规则。此外,源文件需要包含头文件,以便查看和使用声明,这正是 C 和 C++的工作方式。

如果您现在还不完全理解,请不要担心,随着我们继续前进,这将会变得更加明显。

注意

在任何翻译单元中对任何声明有多个定义将导致编译错误。这对于所有函数、结构和全局变量都适用。因此,不允许为单个函数声明提供两个定义。

我们将通过介绍本章的第一个 C 语言示例来继续这次讨论。这个示例旨在展示正确编译由多个源文件组成的 C/C++项目的正确方法。

示例源文件

示例 2.1中,我们有三个文件,其中一个头文件,另外两个是源文件,它们都在同一个目录下。这个示例旨在计算包含五个元素的数组的平均值。

头文件用作两个独立源文件之间的桥梁,使得我们能够在两个独立的文件中编写代码,但一起构建它们。没有头文件,就无法在不违反上述规则(源文件不得包含源文件)的情况下将代码拆分为两个源文件。在这里,头文件包含了一个源文件使用另一个源文件的功能所需的所有内容。

头文件中只包含一个函数声明,avg,这是程序运行所必需的。其中一个源文件包含声明的函数的定义。另一个源文件包含main函数,它是程序的入口点。没有main函数,就无法有一个可执行的二进制文件来运行程序。编译器将main函数识别为程序的起点。

我们现在将继续前进,看看这些文件的内容。以下是头文件,它包含一个枚举和avg函数的声明:

#ifndef EXTREMEC_EXAMPLES_CHAPTER_2_1_H
#define EXTREMEC_EXAMPLES_CHAPTER_2_1_Htypedef enum {
  NONE,
  NORMAL,
  SQUARED
} average_type_t;
// Function declaration
double avg(int*, int, average_type_t);
#endif

代码框 2-3 [ExtremeC_examples_chapter2_1.h]:示例 2.1 的一部分头文件

如您所见,此文件包含一个枚举,一组命名的整数常量。在 C 语言中,枚举不能有单独的声明和定义,它们应该在同一位置声明和定义一次。

除了枚举之外,代码框中还可以看到avg函数的前置声明。在给出定义之前声明一个函数的行为称为前置声明。头文件也受到头文件保护语句的保护。它们将防止头文件在编译时被包含两次或更多。

以下代码展示了实际包含avg函数定义的源文件:

#include "ExtremeC_examples_chapter2_1.h"
double avg(int* array, int length, average_type_t type) {
  if (length <= 0 || type == NONE) {
    return 0;
  }
  double sum = 0.0;
  for (int i = 0; i < length; i++) {
    if (type == NORMAL) {
      sum += array[i];
    } else if (type == SQUARED) {
      sum += array[i] * array[i];
    }
  }
  return sum / length;
}

代码框 2-4 [ExtremeC_examples_chapter2_1.c]:包含avg函数定义的源文件

使用前面的代码,你应该注意到文件名以.c扩展名结尾。源文件已包含示例的头文件。这样做是因为在使用之前需要average_type_t枚举和avg函数的声明。在这种情况下,使用新的类型average_type_t枚举,在没有声明之前使用它会导致编译错误。

看看以下代码框,展示了包含main函数的第二个源文件:

#include <stdio.h>
#include "ExtremeC_examples_chapter2_1.h"
int main(int argc, char** argv) {
  // Array declaration
  int array[5];
  // Filling the array
  array[0] = 10;
  array[1] = 3;
  array[2] = 5;
  array[3] = -8;
  array[4] = 9;
  // Calculating the averages using the 'avg' function
  double average = avg(array, 5, NORMAL);
  printf("The average: %f\n", average);
  average = avg(array, 5, SQUARED);
  printf("The squared average: %f\n", average);
  return 0;
}

代码框 2-5 [ExtremeC_examples_chapter2_1_main.c]:示例 2.1 的main函数

在每个 C 项目中,main函数是程序的入口点。在上面的代码框中,main函数声明并初始化了一个整数数组,并计算了该数组的两个不同平均值。注意main函数是如何调用前面代码中的avg函数的。

构建示例

在上一节介绍了示例 2.1的文件之后,我们需要构建它们并创建一个最终的可执行二进制文件,该文件可以作为程序运行。构建 C/C++项目意味着我们将编译其代码库中的所有源代码,首先生成一些可重定位对象文件(也称为中间对象文件),最后将这些可重定位对象文件组合起来生成最终产品,例如静态库可执行二进制文件

在其他编程语言中构建项目也非常类似于在 C 或 C++中构建,但中间和最终产品的名称和文件格式可能不同。例如,在 Java 中,中间产品是包含Java 字节码的类文件,最终产品是 JAR 或 WAR 文件。

注意

要编译示例源代码,我们不会使用集成开发环境IDE)。相反,我们将直接使用编译器,而不依赖任何其他软件的帮助。我们构建示例的方法与 IDE 中使用的以及编译多个源文件时在后台执行的方法完全相同。

在我们继续之前,有两个重要的规则我们应该记住。

规则 1:我们只编译源文件

第一条规则是我们只编译源文件,因为编译头文件是没有意义的。头文件除了声明之外不应包含任何实际的 C 代码。因此,对于 示例 2.1,我们只需要编译两个源文件:ExtremeC_examples_chapter2_1.cExtremeC_examples_chapter2_1_main.c

规则 2:我们分别编译每个源文件

第二条规则是我们分别编译每个源文件。关于示例 2.1,这意味着我们必须运行编译器两次,每次传递一个源文件。

注意:

仍然可以一次性传递两个源文件,并要求编译器在一个命令中编译它们,但我们不推荐这样做,并且在这本书中我们也不会这样做。

因此,对于一个由 100 个源文件组成的工程,我们需要分别编译每个源文件,这意味着我们必须运行编译器 100 次!是的,这看起来很多,但这就是你应该编译 C 或 C++ 项目的正确方式。相信我,你将遇到需要编译数千个文件才能生成单个可执行二进制文件的项目!

注意:

如果一个头文件包含需要编译的 C 代码片段,我们不会编译该头文件。相反,我们将它包含在一个源文件中,然后编译该源文件。这样,头文件的 C 代码将作为源文件的一部分进行编译。

当我们编译一个源文件时,不会将其他源文件作为同一编译的一部分进行编译,因为它们都没有被编译源文件包含。记住,如果我们尊重 C/C++ 的最佳实践,则不允许包含源文件。

现在,让我们专注于构建 C 项目时应采取的步骤。第一步是预处理,我们将在下一节中讨论这一点。

第一步 - 预处理

C 编译管道的第一步是 预处理。一个源文件包含多个头文件。然而,在编译开始之前,这些文件的内容由预处理器收集为一个单一的 C 代码体。换句话说,在预处理步骤之后,我们得到一个由将头文件内容复制到源文件内容中创建的单个代码片段。

此外,其他 预处理指令 也必须在这一步中解决。这个预处理后的代码片段被称为 翻译单元。翻译单元是预处理器生成的单个 C 代码逻辑单元,并且已经准备好进行编译。翻译单元有时也被称为 编译单元

注意:

在一个翻译单元中,找不到任何预处理指令。提醒一下,C(和 C++)中的所有预处理指令都以 # 开头,例如 #include#define

您可以要求编译器在进一步编译之前转储翻译单元。在 gcc 的情况下,只需传递 -E 选项(区分大小写)即可。在某些罕见的情况下,尤其是在进行跨平台开发时,检查翻译单元在修复奇怪问题时可能很有用。

在以下代码中,您可以看到由 gcc 在我们的默认平台上生成的 ExtremeC_examples_chapter2_1.c 的翻译单元:

$ gcc -E ExtremeC_examples_chapter2_1.c
# 1 "ExtremeC_examples_chapter2_1.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "ExtremeC_examples_chapter2_1.c"
# 1 "ExtremeC_examples_chapter2_1.h" 1
typedef enum {
  NONE,
  NORMAL,
  SQUARED
} average_type_t;
double avg(int*, int, average_type_t);
# 5 "ExtremeC_examples_chapter2_1.c" 2
double avg(int* array, int length, average_type_t type) {
  if (length <= 0 || type == NONE) {
    return 0;
  }
  double sum = 0;
  for (int i = 0; i < length; i++) {
    if (type == NORMAL) {
      sum += array[i];
    } else if (type == SQUARED) {
      sum += array[i] * array[i];
    }
  }
  return sum / length;
}
$

Shell Box 2-1: 编译 ExtremeC_examples_chapter2_1.c 产生的翻译单元

如您所见,所有声明都已从头文件复制到翻译单元中。翻译单元中的注释也已删除。

ExtremeC_examples_chapter2_1_main.c 的翻译单元非常大,因为它包含了 stdio.h 头文件。

从这个头文件中以及它包含的内部头文件中,所有声明都将递归地复制到翻译单元中。为了展示 ExtremeC_examples_chapter2_1_main.c 的翻译单元可以有多大,在我们的默认平台上,它有 836 行 C 代码!

注意:

-E 选项也适用于 clang 编译器。

这完成了第一步。预处理步骤的输入是一个源文件,输出是相应的翻译单元。

第 2 步 – 编译

一旦您有了翻译单元,您就可以进行第二步,即 编译。编译步骤的输入是上一步骤检索到的翻译单元,输出是相应的 汇编代码。此汇编代码仍然是可读的,但它依赖于机器,并且接近硬件,仍需要进一步处理才能成为机器级指令。

您可以始终通过传递 -S 选项(大写 S)来请求 gcc 在执行第二步后停止,并转储生成的汇编代码。输出文件与给定的源文件具有相同的名称,但扩展名为 .s

在下面的 Shell Box 中,您可以看到 ExtremeC_examples_chapter2_1_main.c 源文件的汇编代码。然而,在阅读代码时,您应该注意到输出中的一些部分已被删除:

$ gcc -S ExtremeC_examples_chapter2_1.c
$ cat ExtremeC_examples_chapter2_1.s
    .file   "ExtremeC_examples_chapter2_1.c"
    .text
    .globl  avg
    .type   avg, @function
avg:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movq    %rdi, -24(%rbp)
    movl    %esi, -28(%rbp)
    movl    %edx, -32(%rbp)
    cmpl    $0, -28(%rbp)
    jle .L2
    cmpl    $0, -32(%rbp)
    jne .L3
.L2:
    pxor    %xmm0, %xmm0
    jmp .L4
.L3:
    ...
.L8:
    ...
.L6:
    ...
.L7:
    ...
.L5:
  ...
.L4:
  ...
.LFE0:
    .size   avg, .-avg
    .ident  "GCC: (Ubuntu 7.3.0-16ubuntu3) 7.3.0"
    .section    .note.GNU-stack,"",@progbits
$

Shell Box 2-2: 编译 ExtremeC_examples_chapter2_1.c 产生的汇编代码

作为编译步骤的一部分,编译器解析翻译单元并将其转换为针对 目标架构 的特定汇编代码。目标架构是指程序正在为其编译并最终运行的硬件或 CPU。目标架构有时也称为 宿主架构

壳盒 2-2显示了为 AMD 64 位架构生成的汇编代码,由在 AMD 64 位机器上运行的gcc生成。下面的壳盒包含了为 ARM 32 位架构生成的汇编代码,由在 Intel x86-64 架构上运行的gcc生成。这两个汇编输出都是为相同的 C 代码生成的:

$ cat ExtremeC_examples_chapter2_1.s
    .arch armv5t
    .fpu softvfp
    .eabi_attribute 20, 1
    .eabi_attribute 21, 1
    .eabi_attribute 23, 3
    .eabi_attribute 24, 1
    .eabi_attribute 25, 1
    .eabi_attribute 26, 2
    .eabi_attribute 30, 6
    .eabi_attribute 34, 0
    .eabi_attribute 18, 4
    .file	"ExtremeC_examples_chapter2_1.s"
    .global    __aeabi_i2d
    .global    __aeabi_dadd
    .global    __aeabi_ddiv
    .text
    .align	2
    .global    avg
    .syntax unified
    .arm
    .type  avg, %function
avg:
    @ args = 0, pretend = 0, frame = 32
    @ frame_needed = 1, uses_anonymous_args = 0
    push    {r4, fp, lr}
    add    fp, sp, #8
    sub    sp, sp, #36
    str    r0, [fp, #-32]
    str    r1, [fp, #-36]
    str    r2, [fp, #-40]
    ldr    r3, [fp, #-36]
    cmp    r3, #0
    ble    .L2
    ldr    r3, [fp, #-40]
    cmp    r3, #0
    bne    .L3
.L2:
    ...
.L3:
    ...
.L8:
    ...
.L6:
    ...
.L7:
    ...
.L5:
    ...
.L4:
    mov	r0, r3
    mov	r1, r4
    sub	sp, fp, #8
    @ sp needed
    pop	{r4, fp, pc}
    .size	avg, .-avg
    .ident	"GCC: (Ubuntu/Linaro 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
    .section    .note.GNU-stack,"",%progbits
$

壳盒 2-3:编译 ExtremeC_examples_chapter2_1.c 为 ARM 32 位架构时产生的汇编代码

正如你在壳盒2-22-3中看到的那样,为两种架构生成的汇编代码是不同的。尽管它们是为相同的 C 代码生成的,但这一点仍然成立。对于后者的汇编代码,我们在运行 Ubuntu 16.04 的 Intel x64-86 硬件集上使用了arm-linux-gnueabi-gcc编译器。

注意

目标(或宿主)架构是指源代码既被编译为该架构,也将在该架构上运行。构建架构是我们用来编译源代码的架构。它们可能不同。例如,你可以在 ARM 32 位机器上编译针对 AMD 64 位硬件的 C 源代码。

将 C 代码转换为汇编代码是编译管道中最重要的一步。

这是因为当你有了汇编代码,你就非常接近 CPU 可以执行的指令语言了。正因为这个重要的角色,编译器是计算机科学中最重要和最被研究的主题之一。

第 3 步 – 汇编

编译之后的下一步是汇编。这里的目的是根据编译器在前一步生成的汇编代码生成实际的机器级指令(或机器代码)。每个架构都有自己的汇编器,可以将自己的汇编代码翻译成自己的机器代码。

在本节中我们将要汇编的包含机器级指令的文件被称为目标文件。我们知道一个 C 项目可以有多个产品,它们都是目标文件,但在这个章节中,我们主要关注可重定位的目标文件。毫无疑问,这是我们在构建过程中能够获得的最重要临时产品。

注意

可重定位的目标文件可以被称为中间目标文件。

为了将前两个步骤结合起来,这个汇编步骤的目的是从编译器生成的汇编代码中生成一个可重定位的目标文件。我们创建的每一个其他产品都将基于本步骤中汇编器生成的可重定位目标文件。

我们将在本章的后续部分讨论这些其他产品。

注意

二进制文件目标文件是同义词,指代包含机器级指令的文件。然而请注意,在其他上下文中,“二进制文件”这个术语可能有不同的含义,例如与文本文件的区别。

在大多数类 Unix 操作系统中,我们有一个名为 as 的汇编器工具,它可以用来从汇编文件生成可重定位目标文件。

然而,这些目标文件是不可执行的,它们只包含为翻译单元生成的机器级指令。由于每个翻译单元由各种函数和全局变量组成,可重定位目标文件仅包含对应函数的机器级指令和全局变量的预分配条目。

在下面的 Shell Box 中,你可以看到 as 是如何用于生成 ExtremeC_examples_chapter2_1_main.s 的可重定位目标文件的:

$ as ExtremeC_examples_chapter2_1.s -o ExtremeC_examples_chapter2_1.o
$

Shell Box 2-4:从示例 2.1 中的一个源文件的汇编中生成一个目标文件

回顾前面的 Shell Box 中的命令,我们可以看到 -o 选项用于指定输出目标文件的名称。可重定位目标文件通常在其名称中具有 .o 扩展名(或在 Microsoft Windows 中为 .obj),这就是为什么我们传递了一个以 .o 结尾的文件名。

目标文件的内容,无论是 .o 还是 .obj,都不是文本的,因此你无法像人类一样阅读它。因此,通常会说目标文件具有 二进制内容

尽管汇编器可以直接使用,就像我们在 Shell Box 2-4 中所做的那样,但这并不推荐。相反,良好的做法是使用编译器本身间接调用 as 以生成可重定位目标文件。

注意

我们可以使用术语 目标文件可重定位目标文件 互换。但并非所有目标文件都是可重定位目标文件,在某些上下文中,它可能指其他类型的对象文件,例如共享对象文件。

如果你将 -c 选项传递给几乎所有的已知 C 编译器,它将直接为输入源文件生成相应的目标文件。换句话说,-c 选项相当于执行前三个步骤的全部。

查看以下示例,你可以看到我们已经使用了 -c 选项来编译 ExtremeC_examples_chapter2_1.c 并生成其对应的目标文件:

$ gcc -c ExtremeC_examples_chapter2_1.c
$

Shell Box 2-5:编译示例 2.1 中的一个源文件并生成其对应的目标文件

我们刚刚完成的全部步骤——预处理、编译和汇编——都是作为前面单个命令的一部分完成的。这意味着在运行前面的命令之后,将生成一个可重定位目标文件。这个可重定位目标文件将与输入源文件具有相同的名称;然而,它将具有 .o 扩展名。

重要

注意,通常,术语编译被用来指代编译管道中的前三个步骤,而不仅仅是第二个步骤。也有可能我们使用“编译”这个术语,但实际上意味着“构建”,包括所有四个步骤。例如,我们说C 编译管道,但实际上我们指的是C 构建管道

汇编是编译单个源文件的最后一步。换句话说,当我们有了源文件的相应可重定位对象文件时,我们就完成了它的编译。在这个阶段,我们可以把可重定位对象文件放在一边,继续编译其他源文件。

示例 2.1中,我们有两个需要编译的源文件。通过执行以下命令,它编译了这两个源文件,并因此产生了它们相应的对象文件:

$ gcc -c ExtremeC_examples_chapter2_1.c -o impl.o
$ gcc -c ExtremeC_examples_chapter2_1_main.c -o main.o
$

Shell Box 2-6:为示例 2.1 中的源代码生成可重定位对象文件

你可以从前面的命令中看到,我们已经通过使用-o选项指定我们希望的名字来更改了对象文件的名字。因此,编译完它们之后,我们得到了impl.omain.o的可重定位对象文件。

在这一点上,我们需要提醒自己,可重定位对象文件不是可执行的。如果一个项目最终产品是一个可执行文件,我们需要使用所有,或者至少是其中的一些,已经产生的可重定位对象文件,通过链接步骤构建目标可执行文件。

第 4 步 – 链接

我们知道示例 2.1需要构建成可执行文件,因为我们里面有一个main函数。然而,在这个时候,我们只有两个可重定位对象文件。因此,下一步是将这些可重定位对象文件组合起来,以创建另一个可执行的对象文件。链接步骤正是如此。

然而,在我们进入链接步骤之前,我们需要讨论如何将新架构或硬件的支持添加到现有的类 Unix 系统中。

支持新的架构

我们知道每个架构都有一系列制造的处理器,并且每个处理器都可以执行特定的指令集。

指令集是由 Intel 和 ARM 等供应商公司为他们的处理器设计的。此外,这些公司还为他们的架构设计了一种特定的汇编语言。

如果满足以下两个先决条件,可以为新的架构构建程序:

  1. 汇编语言是已知的。

  2. 必需的汇编器工具(或程序)必须由供应商公司提供。这使我们能够将汇编代码翻译成等效的机器级指令。

一旦这些先决条件就绪,就可以从 C 源代码生成机器级指令。只有在这种情况下,我们才能使用对象文件格式将生成的机器级指令存储在对象文件中。例如,这可以是ELFMach-O的形式。

当汇编语言、汇编工具和目标文件格式清晰时,我们可以使用它们来开发一些对我们开发者进行 C 编程时必要的进一步工具。然而,由于你经常在处理 C 编译器,并且它是在你的 behalf 使用这些工具,所以你几乎注意不到它们的存。

对于一个新的架构,所需的两个直接工具如下:

  • C 编译器

  • 链接器

这些工具就像是支持操作系统中新架构的第一个基本构建块。硬件与操作系统中的这些工具结合在一起,产生了一个新的平台。

关于类 Unix 系统,重要的是要记住 Unix 有一个模块化设计。如果你能够构建一些基本模块,比如汇编器、编译器和链接器,你将能够在这之上构建其他模块,不久整个系统就会在新架构上运行。

步骤详情

就像之前所说的那样,我们知道使用类 Unix 操作系统的平台必须具有之前讨论的强制工具,如汇编器和链接器,才能工作。记住,汇编器和链接器可以从编译器中单独运行。

在类 Unix 系统中,ld是默认的链接器。以下命令,你可以在下面的 shell box 中看到,展示了当我们想要从之前章节中为example 2.1生成的可重定位目标文件中直接使用ld创建可执行文件时如何使用ld。然而,正如你将看到的,直接使用链接器并不那么容易:

$ ld impl.o main.o
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000e8
main.o: In function 'main':
ExtremeC_examples_chapter3_1_main.c:(.text+0x7a): undefined reference to 'printf'
ExtremeC_examples_chapter3_1_main.c:(.text+0xb7): undefined reference to 'printf'
ExtremeC_examples_chapter3_1_main.c:(.text+0xd0): undefined reference to '__stack_chk_fail'
$

Shell Box 2-7:尝试使用 ld 工具直接链接目标文件

如你所见,命令失败了,并生成了一些错误信息。如果你注意这些错误信息,它们说在文本段的三个地方ld遇到了三个未定义的函数调用(或引用)。

这两个函数调用是调用printf函数,我们在main函数中实现了它。然而,另一个__stack_chk_fail函数我们没有调用过。它来自别处,但究竟在哪里呢?它是从编译器放入可重定位目标文件的补充代码中调用的,这个函数是针对 Linux 的,你可能在其他平台上生成的相同目标文件中找不到它。然而,无论它是什么,无论它做什么,链接器都在寻找它的定义,而且似乎它无法在提供的对象文件中找到定义。

就像我们之前说的那样,默认的链接器ld生成这些错误是因为它无法找到这些函数的定义。从逻辑上讲,这是有道理的,也是真实的,因为我们没有在example 2.1中自己定义printf__stack_chk_fail

这意味着我们应该给ld提供一些其他的目标文件,虽然不一定是可重定位的目标文件,它们包含printf__stack_chk_fail函数的定义。

阅读我们刚才所说的,应该可以解释为什么直接使用ld可能非常困难。也就是说,需要指定更多的对象文件和选项,以便使ld工作并生成一个可工作的可执行文件。

幸运的是,在类 Unix 系统中,最著名的 C 编译器通过传递适当的选项并指定额外的必需对象文件来使用ld。因此,我们不需要直接使用ld

因此,让我们看看一种更简单的方法来生成最终的可执行文件。下面的 shell 框图展示了我们如何使用gcc来链接来自示例 2.1的对象文件:

$ gcc impl.o main.o
$ ./a.out
The average: 3.800000
The squared average: 55.800000
$

Shell 框 2-8:使用 gcc 链接对象文件

运行这些命令的结果是,我们可以松一口气,因为我们终于成功构建了示例 2.1并运行了其最终的可执行文件!

注意

构建一个项目等同于首先编译源代码,然后将它们链接在一起,可能还有其他库,以创建最终产品。

重要的是花一分钟暂停并反思我们刚刚所做的事情。在过去的几个部分中,我们通过编译源代码为可重定位对象文件,并最终将这些生成的对象文件链接起来,成功地构建了示例 2.1

虽然这个过程对于任何 C/C++代码库都是相同的,但区别在于你需要编译源代码的次数,这本身取决于你的项目中源文件的数量。

虽然编译管道有一些步骤,但在每个步骤中,都涉及一个特定的组件。本章剩余部分的焦点将深入探讨管道中每个组件周围的关键信息。

要开始这个过程,我们将重点关注预处理程序组件。

预处理程序

在本书的第一章基本特性中,我们简要介绍了C 预处理程序的概念。具体来说,我们当时讨论了宏、条件编译和头文件保护。

你会记得,在本书的开头,我们讨论了 C 预处理作为 C 语言的一个基本特性。预处理之所以独特,是因为它不能轻易地在其他编程语言中找到。最简单的说法是,预处理允许你在发送代码进行编译之前修改你的源代码。同时,它允许你将源代码(尤其是声明)分成头文件,这样你就可以稍后将其包含到多个源文件中并重用这些声明。

记住这一点至关重要:如果你在源代码中有语法错误,预处理程序不会找到错误,因为它对 C 语法一无所知。相反,它只会执行一些简单的任务,这些任务通常围绕文本替换。例如,假设你有一个名为sample.c的文本文件,其内容如下:

#include <stdio.h>
#define file 1000
Hello, this is just a simple text file but ending with .c extension!
This is not a C file for sure!
But we can preprocess it!

代码框 2-6:包含一些文本的 C 代码!

在有了前面的代码之后,让我们使用gcc来预处理文件。请注意,以下 Shell Box 中的某些部分已被删除。这是因为包含stdio.h会使翻译单元变得非常大:

$ gcc -E sample.c
# 1 "sample.c"
# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 341 "<built-in>" 3
# 1 "<command line>" 1
# 1 "<built-in>" 2
# 1 "sample.c" 2
# 1 "/usr/include/stdio.h" 1 3 4
# 64 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/_stdio.h" 1 3 4
# 68 "/usr/include/_stdio.h" 3 4
# 1 "/usr/include/sys/cdefs.h" 1 3 4
# 587 "/usr/include/sys/cdefs.h" 3 4
# 1 "/usr/include/sys/_symbol_aliasing.h" 1 3 4
# 588 "/usr/include/sys/cdefs.h" 2 3 4
# 653 "/usr/include/sys/cdefs.h" 3 4
...
...
extern int __vsnprintf_chk (char * restrict, size_t, int, size_t,
       const char * restrict, va_list);
# 412 "/usr/include/stdio.h" 2 3 4
# 2 "sample.c" 2
Hello, this is just a simple text 1000 but ending with .c extension!
This is not a C 1000 for sure!
But we can preprocess it!
$

Shell Box 2-9:在代码框 2-6 中看到的预处理后的 C 代码示例

正如你在前面的 Shell Box 中看到的,stdio.h的内容在文本之前被复制。

如果你更加留意,你会看到另一个有趣的替换也发生了。文本中的file出现都被替换成了1000

这个例子确切地展示了预处理器是如何工作的。预处理器只执行简单的任务,例如通过复制文件内容或通过文本替换来扩展宏。尽管如此,它对 C 语言一无所知;在执行任何进一步的任务之前,它需要一个解析器来解析输入文件。这意味着 C 预处理器使用一个解析器,该解析器在输入代码中寻找指令。

注意

通常,解析器是一个程序,它处理输入数据并从中提取某些部分以进行进一步的分析和处理。解析器需要了解输入数据的结构,以便将其分解成一些更小且有用的数据块。

预处理器的解析器与 C 编译器使用的解析器不同,因为它使用的是几乎独立于 C 语法的语法。这使得我们能够在除了预处理 C 文件之外的其他情况下使用它。

注意

通过利用 C 预处理器的功能,你可以将文件包含和宏扩展用于除了构建 C 程序以外的其他目的。它们也可以用于处理其他文本文件。

GNU C 预处理器内部机制* – www.chiark.greenend.org.uk/doc/cpp-4.3-doc/cppinternals.html – 是学习更多关于gcc预处理器的好资源。这份文档是官方资料,描述了 GNU C 预处理器的工作原理。GNU C 预处理器被gcc编译器用于预处理源文件。

在前面的链接中,你可以找到预处理器如何解析指令以及它如何创建解析树的信息。文档还提供了不同宏扩展算法的解释。虽然这超出了本章的范围,但如果你想要为特定的内部编程语言实现自己的预处理器,或者只是处理一些文本文件,那么上述链接提供了很好的背景信息。

在大多数类 Unix 操作系统中,有一个名为cpp的工具,代表C 预处理器——而不是 C++!cpp是随每个 Unix 版本一起提供的 C 开发包的一部分。它可以用来预处理 C 文件。在后台,该工具被 C 编译器,如gcc,用来预处理 C 文件。如果你有一个源文件,你可以使用它,就像我们接下来要做的那样,来预处理源文件:

$ cpp ExtremeC_examples_chapter2_1.c
# 1 "ExtremeC_examples_chapter2_1.c"
# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 340 "<built-in>" 3
# 1 "<command line>" 1
# 1 "<built-in>" 2
...
...
# 5 "ExtremeC_examples_chapter2_1.c" 2
double avg(int* array, int length, average_type_t type) {
  if (length <= 0 || type == NONE) {
    return 0;
  }
  double sum = 0;
  for (int i = 0; i < length; i++) {
    if (type == NORMAL) {
      sum += array[i];
    } else if (type == SQUARED) {
      sum += array[i] * array[i];
    }
  }
  return sum / length;
}
$

Shell Box 2-10:使用 cpp 工具预处理源代码

在本节的最后一点,如果你将一个扩展名为.i的文件传递给 C 编译器,那么它将跳过预处理步骤。它这样做是因为具有.i扩展名的文件应该已经被预处理过。因此,它应该直接进入编译步骤。

如果你坚持要对扩展名为.i的文件运行 C 预处理器,那么你将得到以下警告信息。请注意,以下 shell 窗口是由clang编译器生成的:

$ clang -E ExtremeC_examples_chapter2_1.c  > ex2_1.i
$ clang -E ex2_1.i
clang: warning: ex2_1.i: previously preprocessed input
[-Wunused-command-line-argument]
$

Shell Box 2-11:将已预处理的文件(扩展名为.i)传递给 clang 编译器

正如你所见,clang警告我们该文件已经被预处理过。

在本章的下一节,我们将专门讨论 C 编译管道中的编译器组件。

编译器

正如我们在前面的章节中讨论的,编译器接受预处理程序准备的翻译单元,并生成相应的汇编指令。当多个 C 源文件编译成它们等价的汇编代码时,平台中现有的工具,如汇编器和链接器,通过将生成的汇编代码制作成可重定位对象文件来管理其余部分,并最终将它们(以及可能的其他对象文件)链接在一起,形成一个库或可执行文件。

例如,我们提到了asld作为 Unix 中许多 C 开发工具中的两个例子。这些工具主要用于创建与平台兼容的对象文件。这些工具必须在gcc或其他任何编译器之外存在。通过在任何编译器之外存在,我们实际上是指它们不是作为gcc(我们以gcc为例)的一部分开发的,并且它们应该在任何平台上都可用,即使没有安装gccgcc只在它的编译管道中使用它们,并且它们没有被嵌入到gcc中。

这是因为平台本身是关于其处理器接受的指令集以及特定操作系统的格式和限制最了解的实体。编译器通常不会意识到这些限制,除非它想要对翻译单元进行一些优化。因此,我们可以得出结论,gcc最重要的任务是将翻译单元翻译成汇编指令。这就是我们所说的编译过程。

C 编译中的一个挑战是生成目标架构可以接受的正确汇编指令。可以使用gcc为各种架构编译相同的 C 代码,例如 ARM、Intel x86、AMD 等等。正如我们之前讨论的,每个架构都有一个其处理器可以接受的指令集,gcc(或任何 C 编译器)是唯一负责生成特定架构正确汇编代码的实体。

gcc(或任何其他 C 编译器)克服这种困难的方法是将任务分为两个步骤,首先将翻译单元解析成一个可重定位且与 C 无关的数据结构,称为抽象语法树AST),然后使用创建的 AST 为目标架构生成等效的汇编指令。第一个步骤是架构无关的,可以在不考虑目标指令集的情况下完成。但第二个步骤是架构相关的,编译器应该了解目标指令集。执行第一个步骤的子组件称为编译器前端,执行后续步骤的子组件称为编译器后端

在接下来的章节中,我们将更深入地讨论这些步骤。首先,让我们谈谈 AST。

抽象语法树

正如我们在上一节中解释的,C 编译器前端应该解析翻译单元并创建一个中间数据结构。编译器通过根据C 语法解析 C 源代码来创建这个中间数据结构,并将结果保存在一个依赖于架构的树状数据结构中。最终的数据结构通常被称为 AST。

AST 可以为任何编程语言生成,而不仅仅是 C,因此 AST 结构必须足够抽象,以独立于 C 语法。

这就足以将编译器前端修改为支持其他语言。这正是为什么你可以找到GNU 编译器集合GCC),gcc是其一部分作为 C 编译器,或者低级虚拟机LLVM),clang是其一部分作为 C 编译器,作为一个支持许多语言的编译器集合,而不仅仅是 C 和 C++,例如 Java、Fortran 等等。

一旦生成了 AST,编译器后端就可以开始优化 AST,并基于优化后的 AST 为目标架构生成汇编代码。为了更好地理解 AST,我们将查看一个真实的 AST。在这个例子中,我们有以下 C 源代码:

int main() {
  int var1 = 1;
  double var2 = 2.5;
  int var3 = var1 + var2;
  return 0;
}

代码框 2-7 [ExtremeC_examples_chapter2_2.c]:将要生成 AST 的简单 C 代码

下一步是使用clang在前面代码中输出 AST。在下面的图中,图 2-1,你可以看到 AST:

图片

图 2-1:为示例 2.2 生成的 AST 及其输出

到目前为止,我们在多个地方使用了clang作为 C 编译器,但让我们正确地介绍它。clang是由 LLVM 开发者小组为llvm编译器后端开发的 C 编译器前端。LLVM 编译器基础设施项目使用中间表示——或LLVM IR——作为其前端和后端之间使用的抽象数据结构。LLVM 因其能够将其 IR 数据结构导出以供研究而闻名。前面的树形输出是从示例 2.2的源代码生成的 IR。

我们在这里所做的是向您介绍 AST(抽象语法树)的基础知识。我们不会深入探讨前面 AST 输出的细节,因为每个编译器都有自己的 AST 实现。要涵盖所有这些细节,我们需要几章内容,而这超出了本书的范围。

然而,如果你注意上面的图,你可以找到一个以-FunctionDecl开头的行。这代表main函数。在此之前,你可以找到有关传递给编译器的翻译单元的元信息。

如果你继续阅读FunctionDecl之后的内容,你会找到声明语句、二元运算符语句、返回语句甚至隐式转换语句的树形条目——或节点。AST 中有很多有趣的东西,有无数的东西可以学习!

拥有源代码的 AST 的另一个好处是,你可以重新排列指令的顺序,剪枝一些未使用的分支,并替换分支,以便你获得更好的性能,同时保留程序的目的。正如我们之前指出的,这被称为优化,并且通常任何 C 编译器都会在一定可配置的程度上进行优化。

我们接下来将要更详细讨论的下一个组件是汇编器。

汇编器

正如我们之前解释的,一个平台必须有一个汇编器来生成包含正确机器级指令的目标文件。在类 Unix 操作系统中,汇编器可以通过使用as实用程序来调用。在本节的其余部分,我们将讨论汇编器可以在目标文件中放入的内容。

如果你在同一架构上安装两个不同的类 Unix 操作系统,安装的汇编器可能不同,这非常重要。这意味着,尽管机器级指令相同,但由于在相同硬件上运行,生成的目标文件可能不同!

如果你为 Linux 上的 AMD64 架构编译一个程序并生成相应的目标文件,它可能与你在不同的操作系统(如 FreeBSD 或 macOS)上编译相同程序,或者在相同硬件上编译时有所不同。这意味着虽然目标文件可能不同,但它们确实包含相同的机器级指令。这证明了在不同的操作系统中,目标文件可以有不同的格式。

换句话说,每个操作系统在存储对象文件中的机器级指令时,都会定义自己的特定二进制格式或目标文件格式。因此,有两个因素指定了对象文件的内容:架构(或硬件)和操作系统。通常,我们将使用术语平台来指这种组合。

为了结束本节,我们通常会说,目标文件,因此生成它们的汇编器,是平台特定的。在 Linux 中,我们使用可执行和链接格式ELF)。正如其名所示,所有可执行文件、目标文件和共享库都应该使用此格式。换句话说,在 Linux 中,汇编器生成 ELF 目标文件。在即将到来的章节“目标文件”中,我们将更详细地讨论目标文件及其格式。

在下一节中,我们将更深入地探讨链接器组件。我们将演示并解释该组件实际上是如何在 C 项目中生成最终产品的。

链接器

构建 C 项目的第一步是将所有源文件编译成它们对应的目标文件。这一步骤是准备最终产品的必要步骤,但仅此还不够,还需要再进行一步。在详细讨论这一步骤之前,我们需要快速看一下 C 项目中可能的产品(有时被称为工件)。

C/C++项目可以导致以下产品:

  • 一系列可执行文件,通常在大多数类 Unix 操作系统中具有.out扩展名。这些文件通常在 Microsoft Windows 中具有.exe扩展名。

  • 一系列静态库,通常在大多数类 Unix 操作系统中具有.a扩展名。这些文件在 Microsoft Windows 中具有.lib扩展名。

  • 一系列动态库或共享目标文件,通常在大多数类 Unix 操作系统中具有.so扩展名。这些文件在 macOS 中具有.dylib扩展名,在 Microsoft Windows 中具有.dll扩展名。

可重定位目标文件不被视为这些产品之一;因此,您无法在前面列表中找到它们。可重定位目标文件仅仅是临时产品,因为它们只参与链接步骤以生成前面的产品,之后我们就不再需要它们了。链接器组件的唯一责任是从给定的可重定位目标文件生成前面的产品。

关于所使用的术语的最后一个重要说明:这三个产品都被称为目标文件。因此,在提到汇编器生成的作为中间产品的目标文件时,最好在目标文件之前使用术语可重定位

现在我们将简要描述每个最终产品。接下来的章节将完全致力于目标文件,并将更详细地讨论这些最终产品。

可执行对象文件可以作为进程运行。这个文件通常包含项目提供的许多功能。它必须有一个入口点,机器级指令在这里执行。虽然main函数是 C 程序的入口点,但可执行对象文件的入口点是平台相关的,并且不是main函数。main函数最终会在一组平台特定指令准备就绪后被调用,这些指令是由链接器在链接步骤中添加的。

静态库不过是一个包含多个可重定位对象文件的归档文件。因此,静态库文件不是由链接器直接生成的。相反,它是由系统的默认归档程序生成的,在类 Unix 系统中是ar程序。

静态库通常链接到其他可执行文件,然后成为这些可执行文件的一部分。这是封装逻辑的最简单和最直接的方法,以便你可以在以后使用它。操作系统内部存在大量的静态库,每个库都包含可以用来访问该操作系统特定功能的特定逻辑。

共享对象文件结构更复杂,不仅仅是归档,它们是由链接器直接创建的。它们的使用方式也不同;也就是说,在使用之前,它们需要在运行时加载到一个正在运行的过程中。

这与在链接时间使用的静态库形成对比,静态库被用来成为最终可执行文件的一部分。此外,单个共享对象文件可以被多个不同的进程同时加载和使用。作为下一章的一部分,我们将演示如何将共享对象文件在运行时加载并用于 C 程序。

在接下来的部分中,我们将解释链接步骤中发生的情况以及链接器所涉及和使用哪些元素来生成最终产品,特别是可执行文件。

链接器是如何工作的?

在本节中,我们将解释链接组件的工作原理以及我们所说的链接究竟是什么意思。假设你正在构建一个包含五个源文件的 C 项目,最终产品是一个可执行文件。作为构建过程的一部分,你已经编译了所有源文件,现在你有五个可重定位对象文件。你现在需要的是一个链接器来完成最后一步并生成最终的可执行文件。

根据我们到目前为止所说的,简单来说,链接器将所有可重定位对象文件以及指定的静态库结合起来,以创建最终的可执行对象文件。然而,如果你认为这一步很简单,那你就错了。

在将目标文件组合起来生成可工作的可执行目标文件时,有一些来自目标文件内容的问题需要考虑。为了了解链接器的工作原理,我们需要知道它如何使用可重定位目标文件,为此,我们需要找出目标文件内部的内容。

简单的答案是,目标文件包含翻译单元的等效机器级指令。然而,这些指令并不是随机地放入文件中。相反,它们被分组在称为符号的部分下。

实际上,目标文件中有许多内容,但符号是解释链接器如何工作以及如何将一些目标文件连接在一起以生成更大的目标文件的一个组成部分。为了解释符号,让我们在示例example 2.3的上下文中讨论它们:使用这个示例,我们想展示一些函数是如何编译并放置在相应的可重定位目标文件中的。看一下以下包含两个函数定义的代码:

int average(int a, int b) {
  return (a + b) / 2;
}
int sum(int* numbers, int count) {
  int sum = 0;
  for (int i = 0; i < count; i++) {
    sum += numbers[i];
  }
  return sum;
}

Code Box 2-8 [ExtremeC_examples_chapter2_3.c]:包含两个函数定义的代码

首先,我们需要编译前面的代码以生成相应的目标文件。以下命令生成了目标文件,target.o。我们正在默认平台上编译代码:

$ gcc -c ExtremeC_examples_chapter2_3.c -o target.o
$

Shell Box 2-12:编译示例 2.3 中的源文件

接下来,我们使用nm工具查看target.o目标文件。nm工具允许我们查看目标文件内可以找到的符号:

$ nm target.o
0000000000000000 T average
000000000000001d T sum
$

Shell Box 2-13:使用nm工具查看可重定位目标文件中定义的符号

上述 shell box 显示了目标文件中定义的符号。如您所见,它们的名称与Code Box 2-8中定义的函数名称完全相同。

如果您使用readelf工具,就像我们在以下 shell box 中所做的那样,您可以看到存在于目标文件中的符号表。符号表包含目标文件中定义的所有符号,并且可以为您提供有关符号的更多信息:

$ readelf -s target.o
Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS ExtremeC_examples_chapter
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4
     8: 0000000000000000    29 FUNC    GLOBAL DEFAULT    1 average
     9: 000000000000001d    69 FUNC    GLOBAL DEFAULT    1 sum
$

Shell Box 2-14:使用readelf工具查看可重定位目标文件的符号表

如您在readelf的输出中看到的,符号表中包含两个函数符号。表中还有其他符号,它们指向目标文件中的不同部分。我们将在本章和下一章讨论其中的一些符号。

如果您想查看机器级指令的汇编代码,可以在每个函数符号下使用objdump工具:

$ objdump -d target.o
target.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <average>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d fc                mov    %edi,-0x4(%rbp)
   7:   89 75 f8                mov    %esi,-0x8(%rbp)
   a:   8b 55 fc                mov    -0x4(%rbp),%edx
   d:   8b 45 f8                mov    -0x8(%rbp),%eax
  10:   01 d0                   add    %edx,%eax
  12:   89 c2                   mov    %eax,%edx
  14:   c1 ea 1f                shr    $0x1f,%edx
  17:   01 d0                   add    %edx,%eax
  19:   d1 f8                   sar    %eax
  1b:   5d                      pop    %rbp
  1c:   c3                      retq
000000000000001d <sum>:
  1d:   55                      push   %rbp
  1e:   48 89 e5                mov    %rsp,%rbp
  21:   48 89 7d e8             mov    %rdi,-0x18(%rbp)
  25:   89 75 e4                mov    %esi,-0x1c(%rbp)
  28:   c7 45 f8 00 00 00 00    movl   $0x0,-0x8(%rbp)
  2f:   c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)
  36:   eb 1d                   jmp    55 <sum+0x38>
  38:   8b 45 fc                mov    -0x4(%rbp),%eax
  3b:   48 98                   cltq
  3d:   48 8d 14 85 00 00 00    lea    0x0(,%rax,4),%rdx
  44:   00
  45:   48 8b 45 e8             mov    -0x18(%rbp),%rax
  49:   48 01 d0                add    %rdx,%rax
  4c:   8b 00                   mov    (%rax),%eax
  4e:   01 45 f8                add    %eax,-0x8(%rbp)
  51:   83 45 fc 01             addl   $0x1,-0x4(%rbp)
  55:   8b 45 fc                mov    -0x4(%rbp),%eax
  58:   3b 45 e4                cmp    -0x1c(%rbp),%eax
  5b:   7c db                   jl     38 <sum+0x1b>
  5d:   8b 45 f8                mov    -0x8(%rbp),%eax
  60:   5d                      pop    %rbp
  61:   c3                      retq
$

Shell Box 2-15:使用objdump工具查看定义在可重定位目标文件中的符号的指令

根据我们所看到的,每个函数符号对应于源代码中定义的函数。当你需要链接多个可重定位对象文件以生成可执行对象文件时,这表明每个可重定位对象文件只包含构建完整可执行程序所需的全部函数符号的一部分。

现在,回到本节的主题,链接器在将符号组合到一个更大的对象文件中以形成完整的可执行二进制文件之前,会从各种可重定位对象文件中收集所有符号。为了在一个实际场景中演示这一点,我们需要一个不同的示例,其中一些函数分布在多个源文件中。这样,我们可以展示链接器如何在给定的可重定位对象文件中查找符号,以生成可执行文件。

示例 2.4 由四个 C 文件组成——三个源文件和一个头文件。在头文件中,我们声明了两个函数,每个函数都在其各自的源文件中定义。第三个源文件包含 main 函数。

示例 2.4 中的函数非常简单,编译后,每个函数在其相应的对象文件中都将包含一些机器级指令。此外,示例 2.4 不会包含任何标准 C 头文件。我们选择这样做是为了使每个源文件都有一个小的翻译单元。

以下代码框显示了头文件:

#ifndef EXTREMEC_EXAMPLES_CHAPTER_2_4_DECLS_H
#define EXTREMEC_EXAMPLES_CHAPTER_2_4_DECLS_H
int add(int, int);
int multiply(int, int);
#endif

代码框 2-9 [ExtremeC_examples_chapter2_4_decls.h]:示例 2.4 中函数的声明

看看那段代码,你可以看到我们使用了头文件保护语句来防止 双重包含。不仅如此,还声明了两个具有相似 签名 的函数。每个函数接收两个整数作为输入,并将返回另一个整数作为结果。

正如我们之前所说的,这些函数都是分别在不同的源文件中实现的。第一个源文件看起来如下:

int add(int a, int b) {
  return a + b;
}

代码框 2-10 [ExtremeC_examples_chapter2_4_add.c]:add 函数的定义

我们可以清楚地看到,源文件没有包含任何其他头文件。然而,它定义了一个函数,其签名与我们头文件中声明的完全相同。

如我们所见,第二个源文件与第一个类似。这个文件包含了 multiply 函数的定义:

int multiply(int a, int b) {
  return a * b;
}

代码框 2-11 [ExtremeC_examples_chapter2_4_multiply.c]:multiply 函数的定义

我们现在可以转向第三个源文件,它包含了 main 函数:

#include "ExtremeC_examples_chapter2_4_decls.h"
int main(int argc, char** argv) {
  int x = add(4, 5);
  int y = multiply(9, x);
  return 0;
}

代码框 2-12 [ExtremeC_examples_chapter2_4_main.c]:示例 2.4 的主函数

第三个源文件必须包含头文件,以便获取两个函数的声明。否则,源文件将无法使用 addmultiply 函数,仅仅因为它们没有被声明,这可能会导致编译失败。

此外,main 函数对 add 或 multiply 的定义一无所知。因此,我们需要提出一个重要问题:当 main 函数甚至不知道其他源文件时,它是如何找到这些定义的?请注意,代码框 2-12 中显示的文件只包含了一个头文件,因此它与另外两个源文件没有关系。

上述问题可以通过考虑链接器来解决。链接器将从各种目标文件中收集所需的定义并将它们组合起来,这样,main 函数中编写的代码最终可以使用另一个函数中编写的代码。

注意

要编译使用函数的源文件,声明就足够了。然而,为了实际运行你的程序,定义应该提供给链接器,以便将其放入最终的可执行文件中。

现在,是时候编译 示例 2.4 并演示我们迄今为止所说的内容了。使用以下命令,我们创建相应的可重定位目标文件。你需要记住,我们只编译源文件:

$ gcc -c ExtremeC_examples_chapter2_4_add.c -o add.o
$ gcc -c ExtremeC_examples_chapter2_4_multiply.c -o multiply.o
$ gcc -c ExtremeC_examples_chapter2_4_main.c -o main.o
$

Shell Box 2-16:将 example 2.4 中的所有源文件编译成它们对应的可重定位目标文件

在下一步,我们将查看每个可重定位目标文件中包含的符号表:

$ nm add.o
0000000000000000 T add
$

Shell Box 2-17:列出 add.o 中定义的符号

如你所见,add 符号已被定义。下一个目标文件:

$ nm multiply.o
0000000000000000 T multiply
$

Shell Box 2-18:列出 multiply.o 中定义的符号

multiply.o 中的 multiply 符号也发生了同样的事情。最终的目标文件:

$ nm main.o
                 U add
                 U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
                 U multiply
$

Shell Box 2-19:列出 main.o 中定义的符号

尽管第三个源文件,代码框 2-12,只有 main 函数,但我们看到其对应的目标文件中有两个 add 和 multiply 的符号。然而,它们与具有目标文件内地址的 main 符号不同,它们被标记为 U,或 未解决。这意味着虽然编译器在翻译单元中看到了这些符号,但它还没有找到它们的实际定义。这正是我们之前预期和解释的。

包含 main 函数的源文件,代码框 2-12,如果它们不在同一个翻译单元中定义,则不应了解其他函数的定义,但 main 定义依赖于 add 和 multiply 的声明这一事实应该以某种方式在相应的可重定位目标文件中指出。

为了总结我们现在所处的位置,我们有三个中间目标文件,其中之一有两个未解决的符号。这现在使得链接器的任务变得明确;我们需要给链接器提供可以在其他目标文件中找到的必要符号。在找到所有必需的符号后,链接器可以继续将它们组合起来,以创建一个最终可执行的二进制文件。

如果链接器找不到未解决符号的定义,它将失败,并通过打印一个 链接错误 来通知我们。

在下一步中,我们想要链接前面的目标文件。以下命令将完成这个任务:

$ gcc add.o multiply.o main.o
$

Shell Box 2-20:将所有目标文件链接在一起

我们应该在这里注意,使用不带任何选项的对象文件列表运行 gcc,将导致链接步骤尝试从输入对象文件中创建一个可执行对象文件。实际上,它会在后台调用链接器,并使用给定的对象文件,以及一些其他平台所需的静态库和对象文件。

为了检查如果链接器找不到适当的定义会发生什么,我们将只向链接器提供两个中间目标文件,main.oadd.o

$ gcc add.o main.o
main.o: In function 'main':
ExtremeC_examples_chapter2_4_main.c:(.text+0x2c): undefined reference to 'multiply'
collect2: error: ld returned 1 exit status
$

Shell Box 2-21:仅链接两个目标文件:add.o 和 main.o

如您所见,链接器失败了,因为它在提供的对象文件中找不到 multiply 符号。

接下来,让我们提供另外两个目标文件,main.omultiply.o

$ gcc main.o multiply.o
main.o: In function 'main':
ExtremeC_examples_chapter2_4_main.c:(.text+0x1a): undefined reference to 'add'
collect2: error: ld returned 1 exit status
$

Shell Box 2-22:仅链接两个目标文件,multiply.o 和 main.o

如预期的那样,发生了同样的事情。这是因为提供的对象文件中没有找到 add 符号。

最后,让我们提供仅剩的两个对象文件的组合,add.omultiply.o。在我们运行它之前,我们应该预期它会工作,因为这两个对象文件在它们的符号表中都没有未解决的符号。让我们看看会发生什么:

$ gcc add.o multiply.o
/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o: In function '_start':
(.text+0x20): undefined reference to 'main'
collect2: error: ld returned 1 exit status
$

Shell Box 2-23:仅链接两个目标文件,add.o 和 multiply.o

如您所见,链接器再次失败了!查看输出,我们可以看到原因是没有任何对象文件包含创建可执行文件所必需的 main 符号。链接器需要一个程序的入口点,根据 C 标准,这是 main 函数。

在这一点上——我无法强调这一点——请注意对 main 符号的引用位置。它是在位于 /usr/lib/gcc/x86_64-Linux-gnu/7/../../../x86_64-Linux-gnu/Scrt1.o 的文件中的 _start 函数中进行的。

Scrt1.o 文件似乎是一个可重定位的目标文件,它不是由我们创建的。实际上,Scrt1.o 是一组默认 C 目标文件的一部分。这些默认对象文件是作为 gcc 套件的一部分为 Linux 编译的,并将链接到任何程序,以便使其可执行。

正如您刚才看到的,在您的源代码周围发生了很多不同的事情,这些都可能导致冲突。不仅如此,还有许多其他对象文件需要链接到您的程序中,以便使其可执行。

链接器可以被欺骗!

为了使我们的讨论更加有趣,在链接步骤按计划执行,但最终的二进制步骤不符合预期的情况下,也有一些罕见的情况。在本节中,我们将查看一个发生这种情况的示例。

示例 2.5基于由链接器收集的错误定义,并将其放入最终的可执行目标文件中。

此示例有两个源文件,其中一个包含具有相同名称但与main函数使用的声明不同的签名的函数定义。以下代码框是这两个源文件的内容。以下是第一个源文件:

int add(int a, int b, int c, int d) {
  return a + b + c + d;
}

代码框 2-13 [ExtremeC_examples_chapter2_5_add.c]:示例 2.5 中add函数的定义

接下来是第二个源文件:

#include <stdio.h>
int add(int, int);
int main(int argc, char** argv) {
  int x = add(5, 6);
  printf("Result: %d\n", x);
  return 0;
}

代码框 2-14 [ExtremeC_examples_chapter2_5_main.c]:示例 2.5 中的主函数

如您所见,main函数正在使用具有不同签名的另一个版本的add函数,接受两个整数,但第一个源文件代码框 2-13中定义的add函数接受四个整数。

这些函数通常被称为彼此的重载。当然,如果我们编译和链接这些源文件,肯定会有一些错误。看看我们是否可以成功构建示例是有趣的。

下一步是编译和链接可重定位的目标文件,我们可以通过运行以下代码来完成:

$ gcc -c ExtremeC_examples_chapter2_5_add.c -o add.o
$ gcc -c ExtremeC_examples_chapter2_5_main.c -o main.o
$ gcc add.o main.o -o ex2_5.out
$

壳命令框 2-24:构建示例 2.5

如您在 shell 输出中看到的,链接步骤进行得很顺利,最终的可执行文件已经生成!这清楚地表明符号可以欺骗链接器。现在让我们看看运行可执行文件后的输出:

$ ./ex2_5.out
Result: -1885535197
$ ./ex2_5.out
Result: 1679625283
$

壳命令框 2-25:运行示例 2.5 两次和奇怪的结果!

如您所见,输出是错误的;甚至在不同的运行中都会改变!这个例子表明,当链接器选择错误的符号版本时,会发生不好的事情。关于函数符号,它们只是名称,并不携带任何关于相应函数签名的信息。函数参数不过是 C 语言的一个概念;实际上,它们在汇编代码或机器级指令中并不真正存在。

为了进一步调查,我们将查看不同示例中add函数的反汇编。在示例 2.6中,我们有两个与示例 2.5中相同的签名add函数。

为了研究这个问题,我们将从以下想法开始,即我们在示例 2.6中有以下源文件:

int add(int a, int b, int c, int d) {
  return a + b + c + d;
}

代码框 2-15 [ExtremeC_examples_chapter2_6_add_1.c]:示例 2.6 中add的第一个定义

以下代码是另一个源文件:

int add(int a, int b) {
  return a + b;
}

代码框 2-16 [ExtremeC_examples_chapter2_6_add_2.c]:示例 2.6 中add的第二个定义

第一步,就像之前一样,是编译两个源文件:

$ gcc -c ExtremeC_examples_chapter2_6_add_1.c -o add_1.o
$ gcc -c ExtremeC_examples_chapter2_6_add_2.c -o add_2.o
$

壳命令框 2-26:将示例 2.6 中的源文件编译成相应的目标文件

然后,我们需要查看不同目标文件中add符号的反汇编。因此,我们首先从add_1.o目标文件开始:

$ objdump -d add_1.o
add_1.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d fc                mov    %edi,-0x4(%rbp)
   7:   89 75 f8                mov    %esi,-0x8(%rbp)
   a:   89 55 f4                mov    %edx,-0xc(%rbp)
   d:   89 4d f0                mov    %ecx,-0x10(%rbp)
  10:   8b 55 fc                mov    -0x4(%rbp),%edx
  13:   8b 45 f8                mov    -0x8(%rbp),%eax
  16:   01 c2                   add    %eax,%edx
  18:   8b 45 f4                mov    -0xc(%rbp),%eax
  1b:   01 c2                   add    %eax,%edx
  1d:   8b 45 f0                mov    -0x10(%rbp),%eax
  20:   01 d0                   add    %edx,%eax
  22:   5d                      pop    %rbp
  23:   c3
$

Shell 框 2-27:使用 objdump 查看add_1.o中的加法符号的反汇编

下面的 shell 框展示了在其他目标文件add_2.o中找到的add符号的反汇编:

$ objdump -d add_2.o
add_2.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d fc                mov    %edi,-0x4(%rbp)
   7:   89 75 f8                mov    %esi,-0x8(%rbp)
   a:   8b 55 fc                mov    -0x4(%rbp),%edx
   d:   8b 45 f8                mov    -0x8(%rbp),%eax
  10:   01 d0                   add    %edx,%eax
  12:   5d                      pop    %rbp
  13:   c3                      retq
$

Shell 框 2-28:使用 objdump 查看add_2.o中的加法符号的反汇编

当发生函数调用时,在栈顶创建一个新的栈帧。这个栈帧包含传递给函数的参数和返回地址。你将在第四章进程内存结构第五章栈和堆中了解更多关于函数调用机制的内容。

在 shell 框2-272-28中,你可以清楚地看到参数是如何从栈帧中收集的。在add_1.o的反汇编中,Shell 框2-27,你可以看到以下行:

4:  89 7d fc                mov    %edi,-0x4(%rbp)
7:  89 75 f8                mov    %esi,-0x8(%rbp)
a:  89 55 f4                mov    %edx,-0xc(%rbp)
d:  89 4d f0                mov    %ecx,-0x10(%rbp)

代码框 2-17:将参数从栈帧复制到寄存器以供第一个加法函数使用的汇编指令

这些指令从由%rbp寄存器指向的内存地址复制四个值,并将它们放入局部寄存器中。

注意

寄存器是 CPU 内部可以快速访问的位置。因此,对于 CPU 来说,首先将值从主内存传输到其寄存器中,然后对它们进行计算将是非常高效的。%rbp寄存器是指向当前栈帧的,其中包含传递给函数的参数。

如果你查看第二个目标文件的反汇编,虽然非常相似,但它没有进行四次复制操作:

4:  89 7d fc                mov    %edi,-0x4(%rbp)
7:  89 75 f8                mov    %esi,-0x8(%rbp)

代码框 2-18:将参数从栈帧复制到寄存器以供第二个加法函数使用的汇编指令

这些指令简单地复制两个值,因为函数只期望两个参数。这就是为什么我们在示例 2.5的输出中看到了那些奇怪值的原因。在调用add函数时,main函数只将两个值放入栈帧,但add的定义实际上期望四个参数。因此,很可能是错误的定义继续超出栈帧以读取缺失的参数,这导致求和操作的结果出现错误值。

我们可以通过根据输入类型更改函数符号名称来防止这种情况。这通常被称为名称修饰,并且由于它的函数重载特性,在 C++中广泛使用。我们在本章的最后部分简要讨论了这一点。

C++名称修饰

为了突出 C++中名称修饰的工作原理,我们将使用 C++编译器编译示例 2.6。因此,我们将使用 GNU C++编译器g++来完成这项任务。

一旦我们完成了这个,我们就可以使用readelf来转储每个生成的目标文件的符号表。通过这样做,我们可以看到 C++是如何根据输入参数的类型更改函数符号的名称的。

正如我们之前提到的,C 和 C++的编译管道非常相似。因此,我们可以预期 C++编译的结果将是有可重定位的目标文件。让我们看看编译示例 2.6时产生的两个目标文件:

$ g++ -c ExtremeC_examples_chapter2_6_add_1.o
$ g++ -c ExtremeC_examples_chapter2_6_add_2.o
$ readelf -s ExtremeC_examples_chapter2_6_add_1.o
Symbol table '.symtab' contains 9 entries:
  Num:    Value          Size Type    Bind   Vis      Ndx Name
   0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
   1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS ExtremeC_examples_chapter
   2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1
   3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2
   4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
   5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
   6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6
   7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4
   8: 0000000000000000    36 FUNC    GLOBAL DEFAULT    1 _Z3addiiii
$ readelf -s ExtremeC_examples_chapter2_6_add_2.o
Symbol table '.symtab' contains 9 entries:
  Num:    Value          Size Type    Bind   Vis      Ndx Name
   0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
   1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS ExtremeC_examples_chapter
   2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1
   3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2
   4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
   5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
   6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6
   7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4
   8: 0000000000000000    20 FUNC    GLOBAL DEFAULT    1 _Z3addii
$

Shell Box 2-29:使用 readelf 查看由 C++编译器产生的目标文件的符号表

正如你在输出中看到的,我们有add函数不同重载的两个不同的符号名称。接受四个整数的重载具有符号名称_Z3addiiii,而接受两个整数的另一个重载具有符号名称_Z3addii

符号名称中的每个i都指代一个整数输入参数。

从中,你可以看到符号名称是不同的,如果你尝试使用错误的一个,由于链接器无法找到错误符号的定义,你将得到一个链接错误。名称混淆是使 C++能够支持函数重载的技术,它有助于防止我们在上一节中遇到的问题。

摘要

在本章中,我们涵盖了构建 C 项目所需的基本步骤和组件。如果不了解如何构建项目,仅仅编写代码是没有意义的。在本章中:

  • 我们回顾了 C 编译管道及其各个步骤。我们讨论了每个步骤,并描述了输入和输出。

  • 我们定义了术语平台以及不同的汇编器如何导致相同的 C 程序有不同的机器级指令。

  • 我们继续详细讨论了每个步骤以及驱动该步骤的组件。

  • 作为编译器组件的一部分,我们解释了编译器前端和后端是什么,以及 GCC 和 LLVM 如何使用这种分离来支持多种语言。

  • 作为我们关于汇编器组件的讨论的一部分,我们看到了目标文件是平台相关的,并且它们应该有一个确切的文件格式。

  • 作为链接器组件的一部分,我们讨论了链接器的作用以及它是如何使用符号来查找缺失的定义,以便将它们组合在一起形成最终产品。我们还解释了 C 项目的各种可能的产品。我们解释了为什么可重定位(或中间)目标文件不应被视为产品。

  • 我们展示了当提供一个错误的定义时,链接器是如何被欺骗的。我们在示例 2.5中展示了这一点。

  • 我们解释了 C++名称混淆功能以及如何通过该功能防止像我们在示例 2.5中看到的问题。

我们将在下一章“目标文件”中继续讨论关于目标文件及其内部结构的内容。

第三章

对象文件

本章详细介绍了 C/C++ 项目可能产生的各种产品。可能的产品包括可重定位对象文件、可执行对象文件、静态库和共享对象文件。然而,可重定位对象文件被认为是临时产品,它们作为制作其他最终产品的原料。

看起来,在今天的 C 语言中,进一步讨论各种类型的对象文件及其内部结构至关重要。大多数 C 语言书籍只讨论 C 语法和语言本身;但在现实生活中,你需要更深入的知识才能成为一名成功的 C 语言程序员。

当你创建软件时,不仅仅是关于开发和编程语言。实际上,它是关于整个流程:编写代码、编译、优化、生产正确的产品,以及进一步的后续步骤,以便在目标平台上运行和维护这些产品。

你应该对这些中间步骤有所了解,以便能够解决你可能会遇到的问题。这对于嵌入式开发来说尤其严重,因为硬件架构和指令集可能具有挑战性和非典型性。

本章分为以下几部分:

  1. 应用程序二进制接口:在这里,我们首先将讨论 应用程序二进制接口ABI)及其重要性。

  2. 对象文件格式:在本节中,我们讨论今天存在或在过去几年中变得过时的各种对象文件格式。我们还介绍了 ELF 作为 Unix-like 系统中最常用的对象文件格式。

  3. 可重定位对象文件:在这里,我们讨论可重定位对象文件以及 C 项目的第一个产品。我们深入 ELF 可重定位对象文件内部,看看我们能找到什么。

  4. 可执行对象文件:作为本节的一部分,我们讨论可执行对象文件。我们还解释了它们是如何从多个可重定位对象文件中创建的。我们讨论了 ELF 可重定位对象文件和可执行对象文件在内部结构上的差异。

  5. 静态库:在本节中,我们讨论静态库以及如何创建它们。我们还演示了如何编写程序并使用已经构建的静态库。

  6. 动态库:在这里,我们讨论共享对象文件。我们演示了如何从多个可重定位对象文件中创建它们,以及如何在程序中使用它们。我们还简要地讨论了 ELF 共享对象文件的内部结构。

本章的讨论将主要围绕 Unix-like 系统,但我们也会讨论与其他操作系统(如 Microsoft Windows)的一些差异。

注意

在继续阅读本章之前,你需要熟悉构建 C 项目所需的基本思想和步骤。你需要知道什么是翻译单元以及链接与编译的不同之处。请在继续阅读本章之前先阅读上一章。

让我们从介绍 ABI(应用程序二进制接口)开始本章。

应用程序二进制接口 (ABI)

如你所知,每个库或框架,无论使用的技术或编程语言如何,都暴露了一组特定的功能,这被称为其应用程序编程接口API)。如果一个库应该被其他代码使用,那么消费者代码应该使用提供的 API。为了清楚起见,除了 API 之外,不应使用任何其他东西来使用库,因为它是库的公共接口,其他所有内容都被视为黑盒,因此不能使用。

现在假设经过一段时间后,库的 API 进行了某些修改。为了使消费者代码能够继续使用库的新版本,代码必须适应新的 API;否则,它将无法再使用它。消费者代码可以坚持使用库的某个版本(可能是一个旧版本)并忽略新版本,但让我们假设有一个升级到库最新版本的愿望。

简而言之,API 就像两个软件组件之间接受(或标准)的约定。ABI 与 API 非常相似,但处于不同的层面。虽然 API 保证了两个软件组件在功能合作方面的兼容性,但 ABI 保证了两个程序在机器级指令及其相应的目标文件级别上的兼容性。

例如,一个程序不能使用具有不同 ABI 的动态或静态库。或许更糟糕的是,一个可执行文件(实际上是一个目标文件)不能在支持与可执行文件构建的 ABI 不同的系统上运行。许多关键且明显的系统功能,如动态链接加载可执行文件函数调用约定,必须精确地按照约定的 ABI 执行。

一个 ABI 通常涵盖以下内容:

  • 目标架构的指令集,包括处理器指令、内存布局、字节序、寄存器等。

  • 现有的数据类型、它们的大小和对齐策略。

  • 函数调用约定描述了函数应该如何被调用。例如,栈帧的结构和参数的推入顺序都属于其中。

  • 定义在类 Unix 系统中如何调用系统调用

  • 使用目标文件格式,我们将在下一节中解释,以拥有可重定位、可执行共享目标文件

  • 关于由 C++ 编译器生成的目标文件,名称编码虚函数表布局是 ABI 的一部分。

System V ABI 是在 Linux 和 BSD 等类 Unix 操作系统中最广泛使用的 ABI 标准。可执行和链接格式ELF)是 System V ABI 中使用的标准目标文件格式。

注意

以下链接是 AMD 64 位架构的 System V ABI:www.uclibc.org/docs/psABI-x86_64.pdf。您可以查看目录列表,并了解它涵盖的领域。

在下一节中,我们将讨论目标文件格式,特别是 ELF。

目标文件格式

正如我们在上一章,即 第二章,编译和链接 中所解释的,在平台上,目标文件有其自己的特定格式来存储机器级指令。请注意,这是关于目标文件的结构,这与每个架构都有自己的指令集的事实不同。正如我们从之前的讨论中了解到的,这两个变体是平台中 ABI 的不同部分;目标文件格式和架构的指令集。

在本节中,我们将简要介绍一些广为人知的目标文件格式。首先,让我们看看各种操作系统中使用的某些目标文件格式:

  • ELF 被 Linux 和许多其他类 Unix 操作系统使用

  • 在 OS X(macOS 和 iOS)系统中使用的 Mach-O

  • 在 Microsoft Windows 中使用的 PE(可移植执行)格式

为了提供关于当前和过去目标文件格式的历史和背景信息,我们可以这样说,今天存在的所有目标文件格式都是旧 a.out 目标文件格式的继承者。它是为 Unix 的早期版本设计的。

术语 a.out 代表 汇编器输出。尽管今天该文件格式已经过时,但该名称仍然被用作大多数链接器生成的可执行文件的默认文件名。您应该记得在本书的第一章中看到过 a.out

然而,a.out 格式很快就被 COFF通用目标文件格式 所取代。COFF 是 ELF 的基础——我们在大多数类 Unix 系统中使用的目标文件格式。苹果公司也用 Mach-O 替换了 a.out 作为 OS/X 的一部分。Windows 使用 PE可移植执行 文件格式来处理其目标文件,该格式基于 COFF。

注意

更深入的目标文件格式历史可以在这里找到:en.wikipedia.org/wiki/COFF#History。了解特定主题的历史将有助于您更好地理解其演变路径以及当前和过去的特点。

如您所见,今天所有的主流目标文件格式都基于历史目标文件格式 a.out,然后是 COFF,并且在很多方面都拥有相同的血统。

ELF 是 Linux 和大多数类 Unix 操作系统中使用的标准对象文件格式。实际上,ELF 是作为 System V ABI 的一部分使用的对象文件格式,在大多数 Unix 系统中得到广泛使用。如今,它是操作系统使用最广泛的对象文件格式。

ELF 是包括但不限于以下操作系统的标准二进制文件格式:

  • Linux

  • FreeBSD

  • NetBSD

  • Solaris

这意味着,只要它们下面的架构保持不变,为这些操作系统之一创建的 ELF 对象文件可以在其他操作系统中运行和使用。ELF,像所有其他 文件格式 一样,有一个结构,我们将在接下来的章节中简要描述。

注意

更多关于 ELF 及其细节的信息可以在以下链接找到:www.uclibc.org/docs/psABI。请注意,此链接指的是 AMD 64 位(amd64)架构的 System V ABI。

您还可以在此处阅读 System V ABI 的 HTML 版本:www.sco.com/developers/gabi/2003-12-17/ch4.intro.html

在接下来的章节中,我们将讨论 C 项目的临时和最终产品。我们从可重定位对象文件开始。

可重定位对象文件

在本节中,我们将讨论可重定位对象文件。正如我们在上一章中解释的,这些对象文件是 C 编译管道中汇编步骤的输出。这些文件被认为是 C 项目的临时产品,并且是生产进一步和最终产品的主要成分。因此,深入了解它们并查看我们可以在可重定位对象文件中找到什么将是有用的。

在可重定位对象文件中,我们可以找到以下关于编译的翻译单元的项目:

  • 为翻译单元中找到的函数生成的机器级指令(代码)。

  • 在翻译单元(数据)中声明的初始化全局变量的值。

  • 包含翻译单元中找到的所有定义和引用符号的 符号表

以下是在任何可重定位对象文件中可以找到的关键项目。当然,它们的组合方式取决于对象文件格式,但使用适当的工具,你应该能够从可重定位对象文件中提取这些项目。我们将很快对 ELF 可重定位对象文件进行这样的操作。

但在深入示例之前,让我们谈谈为什么可重定位对象文件会这样命名。换句话说,可重定位究竟意味着什么?原因来自于链接器执行的过程,以便将一些可重定位对象文件组合在一起,形成一个更大的对象文件——可执行对象文件或共享对象文件。

我们将在下一节讨论可执行文件中可以找到的内容,但到目前为止,我们应该知道我们在可执行对象文件中找到的项目是所有构成可重定位对象文件中找到的项目之和。让我们只谈谈机器级指令。

在一个可重定位对象文件中找到的机器级指令应该放在来自另一个可重定位对象文件的机器级指令旁边。这意味着指令应该是容易 移动重定位 的。为了实现这一点,指令在可重定位对象文件中没有地址,并且它们只在链接步骤后获得地址。这就是我们称这些对象文件为可重定位的主要原因。为了更详细地说明这一点,我们需要在真实示例中展示。

示例 3.1 是关于两个源文件,一个包含两个函数的定义,maxmax_3,另一个源文件包含使用声明的函数 maxmax_3main 函数。接下来,您可以看到第一个源文件的内容:

int max(int a, int b) {
  return a > b ? a : b;
}
int max_3(int a, int b, int c) {
  int temp = max(a, b);
  return c > temp ? c : temp;
}

代码框 3-1 [ExtremeC_examples_chapter3_1_funcs.c]:包含两个函数定义的源文件

第二个源文件看起来像以下代码框:

int max(int, int);
int max_3(int, int, int);
int a = 5;
int b = 10;
int main(int argc, char** argv) {
  int m1 = max(a, b);
  int m2 = max_3(5, 8, -1);
  return 0;
}

代码框 3-2 [ExtremeC_examples_chapter3_1.c]:使用已声明的函数的 main 函数。定义被放入单独的源文件中。

让我们为前面的源文件生成可重定位对象文件。这样,我们可以调查内容和之前解释的内容。请注意,由于我们在 Linux 机器上编译这些源文件,我们期望看到 ELF 对象文件作为结果:

$ gcc -c ExtremeC_examples_chapter3_1_funcs.c  -o funcs.o
$ gcc -c ExtremeC_examples_chapter3_1.c -o main.o
$

Shell 框 3-1:将源文件编译成相应的可重定位对象文件

funcs.omain.o 都是可重定位的 ELF 对象文件。在 ELF 对象文件中,描述为可重定位对象文件中的项目被放入多个部分中。为了查看先前可重定位对象文件中的当前部分,我们可以使用以下 readelf 工具:

$ readelf -hSl funcs.o
[7/7]
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
...
  Number of section headers:         12
  Section header string table index: 11
Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000045  0000000000000000  AX       0     0     1
...
  [ 3] .data             PROGBITS         0000000000000000  00000085
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .bss              NOBITS           0000000000000000  00000085
       0000000000000000  0000000000000000  WA       0     0     1
...
  [ 9] .symtab           SYMTAB           0000000000000000  00000110
       00000000000000f0  0000000000000018          10     8     8
  [10] .strtab           STRTAB           0000000000000000  00000200
       0000000000000030  0000000000000000           0     0     1
  [11] .shstrtab         STRTAB           0000000000000000  00000278
       0000000000000059  0000000000000000           0     0     1
...
$

Shell 框 3-2:funcs.o 对象文件的 ELF 内容

如您在前面的 shell 框中看到的,可重定位对象文件有 11 个部分。粗体字体的部分是我们介绍为存在于对象文件中的项目。.text 部分包含翻译单元的所有机器级指令。.data.bss 部分包含初始化全局变量的值,以及未初始化全局变量所需的字节数。.symtab 部分包含符号表。

注意,前两个对象文件中存在的部分是相同的,但它们的内容是不同的。因此,我们不显示其他可重定位对象文件的部分。

正如我们之前提到的,ELF 对象文件中的一个部分包含符号表。在上一章中,我们对符号表及其条目进行了详细讨论。我们描述了链接器如何使用它来生成可执行和共享对象文件。在这里,我们想提醒您注意我们在上一章中没有讨论的符号表的一些内容。这将是根据我们关于为什么可重定位对象文件以这种方式命名的解释。

让我们导出 funcs.o 的符号表。在上一章中,我们使用了 objdump,但现在,我们将使用 readelf 来完成:

$ readelf -s funcs.o
Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
...
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
     8: 0000000000000000    22 FUNC    GLOBAL DEFAULT    1 max
     9: 0000000000000016    47 FUNC    GLOBAL DEFAULT    1 max_3
$

Shell Box 3-3:funcs.o 对象文件的符号表

如您在 Value 列中看到的,分配给 max 的地址是 0,分配给 max_3 的地址是 22(十六进制 16)。这意味着与这些符号相关的指令是相邻的,并且它们的地址从 0 开始。这些符号及其对应的机器级指令已准备好被重新定位到最终可执行文件中的其他位置。让我们看看 main.o 的符号表:

$ readelf -s main.o
Symbol table '.symtab' contains 14 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
...
     8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 a
     9: 0000000000000004     4 OBJECT  GLOBAL DEFAULT    3 b
    10: 0000000000000000    69 FUNC    GLOBAL DEFAULT    1 main
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND max
    13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND max_3
$

Shell Box 3-4:main.o 对象文件的符号表

如您所见,与全局变量 ab 相关的符号,以及 main 函数的符号被放置在似乎不是它们应该放置的最终地址上。这是可重定位对象文件的标志。正如我们之前所说的,可重定位对象文件中的符号没有最终和绝对地址,它们的地址将在链接步骤中确定。

在下一节中,我们继续从前面的可重定位对象文件中生成可执行文件。您将看到符号表是不同的。

可执行对象文件

现在,是时候讨论可执行对象文件了。您现在应该知道,可执行对象文件是 C 项目的最终产品之一。与可重定位对象文件一样,它们在:; 机器级指令、初始化全局变量的值、符号表中有相同的项;然而,它们的排列可能不同。我们可以通过 ELF 可执行对象文件来展示这一点,因为它们很容易生成并研究其内部结构。

为了生成可执行的 ELF 对象文件,我们继续进行 示例 3.1。在上一节中,我们为示例中的两个源生成了可重定位对象文件,在本节中,我们将它们链接起来形成一个可执行文件。

以下命令为您完成这些操作,如前一章所述:

$ gcc funcs.o main.o -o ex3_1.out
$ 

Shell Box 3-5:在示例 3.1 中链接之前构建的可重定位对象文件

在上一节中,我们讨论了存在于 ELF 对象文件中的段。我们应该指出,ELF 可执行对象文件中存在更多的段,但与一些段一起。每个 ELF 可执行对象文件,正如你将在本章后面看到的那样,每个 ELF 共享对象文件,除了段之外,还有许多。每个段由多个段(零个或更多)组成,并且根据其内容将段放入段中。

例如,所有包含机器级指令的段都放入同一个段中。你将在第四章进程内存结构中看到,这些段很好地映射到运行进程内存布局中找到的静态内存段

让我们看看可执行文件的内容,并了解这些段。同样,对于可重定位对象文件,我们可以使用相同的命令来显示段,以及在一个可执行 ELF 对象文件中找到的段。

$ readelf -hSl ex3_1.out
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file) 
  Machine:                           Advanced Micro Devices X86-64 
  Version:                           0x1
  Entry point address:               0x4f0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          6576 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         28
  Section header string table index: 27
Section Headers: 
  [Nr] Name              Type             Address           Offset 
       Size              EntSize          Flags  Link  Info  Align 
  [ 0]                   NULL             0000000000000000  00000000 
      0000000000000000  0000000000000000           0     0     0 
  [ 1] .interp           PROGBITS         0000000000000238  00000238 
       000000000000001c  0000000000000000   A       0     0     1 
  [ 2] .note.ABI-tag     NOTE             0000000000000254  00000254 
       0000000000000020  0000000000000000   A       0     0     4 
  [ 3] .note.gnu.build-i NOTE             0000000000000274  00000274 
       0000000000000024  0000000000000000   A       0     0     4 
... 
  [26] .strtab           STRTAB           0000000000000000  00001678 
       0000000000000239  0000000000000000           0     0     1 
  [27] .shstrtab         STRTAB           0000000000000000  000018b1 
       00000000000000f9  0000000000000000           0     0     1 
Key to Flags: 
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info), 
  L (link order), O (extra OS processing required), G (group), T (TLS), 
  C (compressed), x (unknown), o (OS specific), E (exclude), 
  l (large), p (processor specific) 
Program Headers: 
  Type           Offset             VirtAddr           PhysAddr 
                 FileSiz            MemSiz              Flags  Align 
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040 
                 0x00000000000001f8 0x00000000000001f8  R      0x8 
  INTERP         0x0000000000000238 0x0000000000000238 0x0000000000000238 
                 0x000000000000001c 0x000000000000001c  R      0x1 
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] 
... 
  GNU_EH_FRAME   0x0000000000000714 0x0000000000000714 0x0000000000000714 
                 0x000000000000004c 0x000000000000004c  R      0x4 
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000 
                 0x0000000000000000 0x0000000000000000  RW     0x10 
  GNU_RELRO      0x0000000000000df0 0x0000000000200df0 0x0000000000200df0 
                 0x0000000000000210 0x0000000000000210  R      0x1 
Section to Segment mapping: 
  Segment Sections... 
   00 
   01     .interp 
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .dynamic .got .data .bss 
   04     .dynamic 
   05     .note.ABI-tag .note.gnu.build-id 
   06     .eh_frame_hdr 
   07 
   08     .init_array .fini_array .dynamic .got 
$

Shell Box 3-6:ex3_1.out 可执行对象文件的 ELF 内容

关于上述输出有以下几点说明:

  • 从 ELF 的角度来看,我们可以看到对象文件的类型是共享对象文件。换句话说,在 ELF 中,可执行对象文件是一种具有特定段(如INTERP)的共享对象文件。这个段(实际上是此段引用的.interp段)由加载程序用于加载和执行可执行对象文件。

  • 我们将四个段加粗。第一个指的是上一条项目符号中解释的INTERP段。第二个是TEXT段,它包含所有包含机器级指令的段。第三个是DATA段,它包含所有用于初始化全局变量和其他早期结构的值。第四个段指的是可以找到动态链接相关信息的段。例如,需要作为执行部分加载的共享对象文件。

  • 如你所见,与可重定位共享对象相比,我们得到了更多的段,可能填充了加载和执行对象文件所需的数据。

如我们在上一节所述,可重定位目标文件的符号表中找到的符号没有任何绝对和确定的地址。这是因为包含机器级指令的部分尚未链接。

在更深层次上,链接多个可重定位对象文件实际上是将给定可重定位对象文件中的所有类似段收集起来,并将它们组合成一个更大的段,最后将生成的段放入输出可执行文件或共享对象文件中。因此,只有在这个步骤之后,符号才能最终确定并获得不会改变的地址。在可执行对象文件中,地址是绝对的,而在共享对象文件中,相对地址是绝对的。我们将在专门讨论动态库的章节中进一步讨论这个问题。

让我们看看在可执行文件ex3_1.out中找到的符号表。请注意,符号表有很多条目,这就是为什么以下 Shell Box 中的输出没有完全显示:

$ readelf -s ex3_1.out
Symbol table '.dynsym' contains 6 entries: 
   Num:    Value          Size Type    Bind   Vis      Ndx Name 
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
... 
     5: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5 (2) 
Symbol table '.symtab' contains 66 entries: 
   Num:    Value          Size Type    Bind   Vis      Ndx Name 
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
... 
    45: 0000000000201000     0 NOTYPE  WEAK   DEFAULT   22 data_start 
    46: 0000000000000610    47 FUNC    GLOBAL DEFAULT   13 max_3 
    47: 0000000000201014     4 OBJECT  GLOBAL DEFAULT   22 b 
    48: 0000000000201018     0 NOTYPE  GLOBAL DEFAULT   22 _edata 
    49: 0000000000000704     0 FUNC    GLOBAL DEFAULT   14 _fini 
    50: 00000000000005fa    22 FUNC    GLOBAL DEFAULT   13 max 
    51: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@@GLIBC_ 
... 
    64: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@@GLIBC_2.2 
    65: 00000000000004b8     0 FUNC    GLOBAL DEFAULT   10 _init 
$

Shell Box 3-7:在ex3_1.out可执行目标文件中找到的符号表

如前述 Shell Box 所示,一个可执行目标文件中有两个不同的符号表。第一个,.dynsym,包含在加载可执行文件时应解析的符号,但第二个符号表.symtab包含所有解析出的符号,以及从动态符号表中带来的未解析符号。换句话说,符号表包含来自动态表的未解析符号。

正如你所见,符号表中解析出的符号具有在链接步骤后获得的绝对对应地址。maxmax_3符号的地址以粗体显示。

在本节中,我们简要地了解了可执行目标文件。在下一节中,我们将讨论静态库。

静态库

如我们之前所解释的,静态库是 C 项目可能的产物之一。在本节中,我们将讨论静态库以及它们的创建和使用方式。然后,我们将在下一节中通过介绍动态库继续这一讨论。

静态库简单地说就是由可重定位目标文件组成的 Unix 归档。这样的库通常与其他目标文件链接在一起,形成一个可执行目标文件。

注意,静态库本身不被视为对象文件,而是一个容器。换句话说,静态库在 Linux 系统中不是 ELF 文件,在 macOS 系统中也不是 Mach-O 文件。它们只是由 Unix 的ar实用程序创建的归档文件。

当链接器在链接步骤中准备使用静态库时,它首先尝试从中提取可重定位的目标文件,然后开始查找并解析可能存在于其中的一些未定义符号。

现在,是时候为具有多个源文件的项目创建一个静态库了。第一步是创建一些可重定位的目标文件。一旦你编译了一个 C/C++项目中的所有源文件,你就可以使用 Unix 归档工具ar来创建静态库的归档文件。

在 Unix 系统中,静态库通常根据一个公认且广泛使用的约定命名。名称以lib开头,并以.a扩展名结尾。在其他操作系统中可能会有所不同;例如,在 Microsoft Windows 中,静态库带有.lib扩展名。

假设在一个虚构的 C 项目中,你拥有源文件 aa.cbb.c,一直到 zz.c。为了生成可重定位的目标文件,你需要以类似以下命令的方式编译源文件。注意,编译过程已经在上一章中进行了详细解释:

$ gcc -c aa.c -o aa.o
$ gcc -c bb.c -o bb.o
.
.
.
$ gcc -c zz.c -o zz.o
$

Shell Box 3-8:将多个源文件编译成相应的可重定位目标文件

通过运行前面的命令,我们将得到所有必需的可重定位目标文件。注意,如果项目很大且包含成千上万的源文件,这可能需要相当长的时间。当然,拥有一个强大的构建机器,以及并行运行编译任务,可以显著减少构建时间。

当涉及到创建静态库文件时,我们只需运行以下命令:

$ ar crs libexample.a aa.o bb.o ... zz.o
$

Shell Box 3-9:从多个可重定位目标文件中制作静态库的一般方法

因此,生成了 libexample.a 库,其中包含了前面所有的可重定位目标文件作为一个单独的归档。解释传递给 ar 命令的 crs 选项超出了本章的范围,但在以下链接中,你可以阅读关于其含义的说明:关于其含义:https://stackoverflow.com/questions/29714300/what-does-the-rcs-option-in-ar-do.

注意

ar 命令不一定创建一个 压缩 的归档文件。它仅用于将文件组合在一起形成一个包含所有这些文件的单一文件。工具 ar 是通用的,你可以用它将任何类型的文件组合在一起,并从中创建自己的归档。

现在我们已经知道了如何创建一个静态库,我们将创建一个真实的静态库作为 示例 3.2 的一部分。

首先,我们将假设 示例 3.2 是一个关于几何学的 C 项目。该示例由三个源文件和一个头文件组成。库的目的是定义一组可用于其他应用的几何相关函数。

要做到这一点,我们需要从三个源文件中创建一个名为 libgeometry.a 的静态库文件。通过拥有静态库,我们可以将头文件和静态库文件一起使用,以便编写另一个使用库中定义的几何函数的程序。

以下代码框是源文件和头文件的内容。第一个文件 ExtremeC_examples_chapter3_2_geometry.h 包含了需要从我们的几何库中导出的所有声明。这些声明将被未来使用该库的应用程序使用。

注意

提供的所有用于创建目标文件的命令都在 Linux 上运行并测试过。如果你要在不同的操作系统上执行它们,可能需要进行一些修改。

我们需要注意,未来的应用程序必须只依赖于声明,而完全不依赖于定义。因此,首先,让我们看看几何库的声明:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_3_2_H
#define EXTREME_C_EXAMPLES_CHAPTER_3_2_H
#define PI 3.14159265359
typedef struct {
  double x;
  double y;
} cartesian_pos_2d_t;
typedef struct {
  double length;
  // in degrees
  double theta;
} polar_pos_2d_t;
typedef struct {
  double x;
  double y;
  double z;
} cartesian_pos_3d_t;
typedef struct {
  double length;
  // in degrees
  double theta;
  // in degrees
  double phi;
} polar_pos_3d_t;
double to_radian(double deg);
double to_degree(double rad);
double cos_deg(double deg);
double acos_deg(double deg);
double sin_deg(double deg);
double asin_deg(double deg);
cartesian_pos_2d_t convert_to_2d_cartesian_pos(
        const polar_pos_2d_t* polar_pos);
polar_pos_2d_t convert_to_2d_polar_pos(
        const cartesian_pos_2d_t* cartesian_pos);
cartesian_pos_3d_t convert_to_3d_cartesian_pos(
        const polar_pos_3d_t* polar_pos);
polar_pos_3d_t convert_to_3d_polar_pos(
        const cartesian_pos_3d_t* cartesian_pos);
#endif

代码框 3-3 [ExtremeC_examples_chapter3_2_geometry.h]: 示例 3.2 的头文件

第二个文件是一个源文件,包含三角函数的定义,这是在前面头文件中声明的六个函数中的前六个:

#include <math.h>
// We need to include the header file since
// we want to use the macro PI
#include "ExtremeC_examples_chapter3_2_geometry.h"
double to_radian(double deg) {
  return (PI * deg) / 180;
}
double to_degree(double rad) {
  return (180 * rad) / PI;
}
double cos_deg(double deg) {
  return cos(to_radian(deg));
}
double acos_deg(double deg) {
  return acos(to_radian(deg));
}
double sin_deg(double deg) {
  return sin(to_radian(deg));
}
double asin_deg(double deg) {
  return asin(to_radian(deg));
}

代码框 3-4 [ExtremeC_examples_chapter3_2_trigon.c]: 包含三角函数定义的源文件

注意,源文件不需要包含头文件,除非它们将要使用头文件中声明的像 PIto_degree 这样的声明。

第三个文件,同样是一个源文件,包含所有 2D 几何函数的定义:

#include <math.h>
// We need to include the header file since we want
// to use the types polar_pos_2d_t, cartesian_pos_2d_t,
// etc and the trigonometry functions implemented in
// another source file.
#include "ExtremeC_examples_chapter3_2_geometry.h"
cartesian_pos_2d_t convert_to_2d_cartesian_pos(
        const polar_pos_2d_t* polar_pos) {
  cartesian_pos_2d_t result;
  result.x = polar_pos->length * cos_deg(polar_pos->theta);
  result.y = polar_pos->length * sin_deg(polar_pos->theta);
  return result;
}
polar_pos_2d_t convert_to_2d_polar_pos(
        const cartesian_pos_2d_t* cartesian_pos) {
  polar_pos_2d_t result;
  result.length = sqrt(cartesian_pos->x * cartesian_pos->x +
    cartesian_pos->y * cartesian_pos->y);
  result.theta =
      to_degree(atan(cartesian_pos->y / cartesian_pos->x));
  return result;
}

代码框 3-5 [ExtremeC_examples_chapter3_2_2d.c]: 包含 2D 函数定义的源文件

最后,包含 3D 几何函数定义的第四个文件:

#include <math.h>
// We need to include the header file since we want to
// use the types polar_pos_3d_t, cartesian_pos_3d_t,
// etc and the trigonometry functions implemented in
// another source file.
#include "ExtremeC_examples_chapter3_2_geometry.h"
cartesian_pos_3d_t convert_to_3d_cartesian_pos(
        const polar_pos_3d_t* polar_pos) {
  cartesian_pos_3d_t result;
  result.x = polar_pos->length *
      sin_deg(polar_pos->theta) * cos_deg(polar_pos->phi);
  result.y = polar_pos->length *
      sin_deg(polar_pos->theta) * sin_deg(polar_pos->phi);
  result.z = polar_pos->length * cos_deg(polar_pos->theta);
  return result;
}
polar_pos_3d_t convert_to_3d_polar_pos(
        const cartesian_pos_3d_t* cartesian_pos) {
  polar_pos_3d_t result;
  result.length = sqrt(cartesian_pos->x * cartesian_pos->x +
    cartesian_pos->y * cartesian_pos->y +
    cartesian_pos->z * cartesian_pos->z);
  result.theta =
      to_degree(acos(cartesian_pos->z / result.length));
  result.phi =
      to_degree(atan(cartesian_pos->y / cartesian_pos->x));
  return result;
}

代码框 3-6 [ExtremeC_examples_chapter3_2_3d.c]: 包含 3D 函数定义的源文件

现在我们将创建静态库文件。为此,首先我们需要将前面的源文件编译成它们对应的目标文件。需要注意的是,源文件不需要包含头文件,除非它们将要使用像 PIto_degree 这样的声明,这些声明在头文件中声明。

在本节中,我们选择将它们存档以创建一个静态库文件。以下命令将在 Linux 系统上执行编译:

$ gcc -c ExtremeC_examples_chapter3_2_trigon.c -o trigon.o
$ gcc -c ExtremeC_examples_chapter3_2_2d.c -o 2d.o
$ gcc -c ExtremeC_examples_chapter3_2_3d.c -o 3d.o
$

命令行框 3-10:将源文件编译成对应的目标文件

当涉及到将这些目标文件存档到静态库文件中时,我们需要运行以下命令:

$ ar crs libgeometry.a trigon.o 2d.o 3d.o
$ mkdir -p /opt/geometry
$ mv libgeometry.a /opt/geometry
$

命令行框 3-11:从可重定位的目标文件创建静态库文件

如我们所见,已经创建了文件 libgeometry.a。如您所见,我们已经将库文件移动到 /opt/geometry 目录,以便其他任何程序都能轻松找到。再次使用 ar 命令,并通过传递 t 选项,我们可以查看存档文件的内容:

$ ar t /opt/geometry/libgeometry.a
trigon.o
2d.o
3d.o
$

命令行框 3-12:列出静态库文件的内容

如前所述的命令行框所示,静态库文件包含三个可重定位的目标文件,正如我们预期的。下一步是使用静态库文件。

现在我们已经为我们的几何示例 example 3.2 创建了一个静态库,我们将将其用于一个新的应用程序。在使用 C 库时,我们需要访问库公开的声明以及与其静态库文件一起的声明。这些声明被认为是库的 公共接口,或者更常见的是,库的 API。

在编译阶段,我们需要声明,当编译器需要了解类型、函数签名等信息的存在时。头文件就起到这个作用。在后续阶段,如链接和加载,还需要其他详细信息,例如类型大小和函数地址。

正如我们之前所说的,我们通常将 C API(由 C 库公开的 API)作为一个头文件组找到。因此,example 3.2 的头文件和创建的静态库文件 libgeometry.a 就足够我们编写一个新的程序,该程序使用我们的几何库。

当涉及到使用静态库时,我们需要编写一个新的源文件,该文件包含库的 API 并使用其函数。我们将新代码作为一个新的示例,example 3.3。以下代码是 example 3.3 的源代码:

#include <stdio.h>
#include "ExtremeC_examples_chapter3_2_geometry.h"
int main(int argc, char** argv) {
  cartesian_pos_2d_t cartesian_pos;
  cartesian_pos.x = 100;
  cartesian_pos.y = 200;
  polar_pos_2d_t polar_pos =
      convert_to_2d_polar_pos(&cartesian_pos);
  printf("Polar Position: Length: %f, Theta: %f (deg)\n",
    polar_pos.length, polar_pos.theta);
  return 0;
}

Code Box 3-7 [ExtremeC_examples_chapter3_3.c]:测试一些几何函数的主函数

如你所见,example 3.3 包含了 example 3.2 的头文件。它这样做是因为它需要使用到的函数的声明。

现在,我们需要编译前面的源文件,在 Linux 系统中创建其对应的可重定位目标文件:

$ gcc -c ExtremeC_examples_chapter3_3.c -o main.o
$

Shell Box 3-13:编译示例 3.3

在完成这些之后,我们需要将其与为 example 3.2 创建的静态库进行链接。在这种情况下,我们假设文件 libgeometry.a 位于 /opt/geometry 目录中,就像我们在 Shell Box 3-11 中做的那样。以下命令将通过执行链接步骤并创建可执行目标文件 ex3_3.out 来完成构建:

$ gcc main.o -L/opt/geometry -lgeometry -lm -o ex3_3.out
$

Shell Box 3-14:使用示例 3.2 中创建的静态库进行链接

为了解释前面的命令,我们将分别解释每个传递选项:

  • -L/opt/geometry 告诉 gcc 将目录 /opt/geometry 视为静态和共享库可能存在的多个位置之一。默认情况下,链接器会在已知路径如 /usr/lib/usr/local/lib 中搜索库文件。如果你没有指定 -L 选项,链接器只会在默认路径中搜索。

  • -lgeometry 告诉 gcc 查找文件 libgeometry.alibgeometry.so。以 .so 结尾的文件是共享对象文件,我们将在下一节中解释。注意使用的约定。例如,如果你传递 -lxyz 选项,链接器将在默认和指定目录中搜索文件 libxyz.alibxyz.so。如果找不到文件,链接器将停止并生成错误。

  • -lm 告诉 gcc 查找另一个名为 libm.alibm.so 的库。这个库保存了 glibc 中的数学函数定义。我们需要它来使用 cossinacos 函数。请注意,我们正在 Linux 机器上构建 example 3.3,它使用 glibc 作为其默认 C 库的实现。在 macOS 和可能的一些其他类 Unix 系统中,你不需要指定此选项。

  • -o ex3_3.out 告诉 gcc 输出的可执行文件应该命名为 ex3_3.out

在运行前面的命令后,如果一切顺利,你将有一个包含在静态库 libgeometry.a 中找到的所有可重定位目标文件以及 main.o 的可执行二进制文件。

注意,在链接之后,不会对静态库文件的存在有任何依赖,因为所有内容都嵌入在可执行文件本身中。换句话说,最终的可执行文件可以独立运行,无需静态库存在。

然而,从许多静态库的链接中产生的可执行文件通常具有很大的体积。静态库越多,其中的可重定位目标文件越多,最终可执行文件的体积就越大。有时它可以达到几百兆字节,甚至几吉字节。

这是在二进制文件的大小和它可能具有的依赖项之间的一种权衡。你可以拥有一个更小的二进制文件,但通过使用共享库。这意味着最终的二进制文件并不完整,如果外部共享库不存在或找不到,则无法运行。我们将在接下来的章节中更多地讨论这个问题。

在本节中,我们描述了静态库是什么,以及它们应该如何创建和使用。我们还演示了另一个程序如何使用公开的 API 并将其链接到现有的静态库。在下一节中,我们将讨论动态库以及如何从 example 3.2 的源代码中生成共享对象文件(动态库),而不是使用静态库。

动态库

动态库,或共享库,是生成可重用库的另一种方式。正如其名所示,与静态库不同,动态库不是最终可执行文件本身的一部分。相反,它们应该在加载用于执行的过程时加载和引入。

由于静态库是可执行文件的一部分,链接器会将给定可重定位文件中找到的所有内容放入最终的可执行文件中。换句话说,链接器检测未定义的符号和所需的定义,并尝试在给定的可重定位目标文件中找到它们,然后将它们全部放入输出可执行文件中。

只有当所有未定义的符号都被找到时,最终产品才会被生成。从独特的角度来看,我们在链接时检测所有依赖关系并解决它们。至于动态库,在链接时可能存在未解决的未定义符号。这些符号将在可执行产品即将加载并开始执行时被搜索。

换句话说,当你有未定义的动态符号时,需要不同的链接步骤。一个动态链接器,或者简单地称为加载器,通常在加载可执行文件并准备将其作为进程运行时执行链接操作。

由于未定义的动态符号没有在可执行文件中找到,它们应该在别处找到。这些符号应该从共享对象文件中加载。这些文件是静态库文件的姐妹文件。虽然静态库文件在其名称中有.a扩展名,但共享对象文件在大多数类 Unix 系统中携带.so扩展名。在 macOS 中,它们有.dylib扩展名。

当加载一个进程并即将启动时,一个共享对象文件将被加载并映射到一个进程可访问的内存区域。这个步骤是由动态链接器(或加载器)完成的,它加载并执行可执行文件。

正如我们在专门讨论可执行对象文件的章节中所说的那样,ELF 可执行文件和共享对象文件在其 ELF 结构中都有段。每个段包含零个或多个节。ELF 可执行对象文件和 ELF 共享对象文件之间有两个主要区别。首先,符号具有相对绝对地址,这使得它们可以作为许多进程的一部分同时加载。

这意味着虽然每个进程中的每条指令的地址都不同,但两条指令之间的距离保持固定。换句话说,地址相对于偏移量是固定的。这是因为可重定位对象文件是位置无关的。我们将在本章的最后部分更多地讨论这一点。

例如,如果两条指令在一个进程中的地址是 100 和 200,在另一个进程中它们可能在 140 和 240,在另一个进程中可能是 323 和 423。相关的地址是绝对的,但实际地址可以改变。这两条指令之间的距离始终是 100 个地址。

第二个区别是,与加载 ELF 可执行对象文件相关的某些段在共享对象文件中不存在。这实际上意味着共享对象文件不能被执行。

在详细介绍如何从不同的进程访问共享对象之前,我们需要展示一个示例,说明它们是如何创建和使用的。因此,我们将为我们在上一节中工作的相同几何库,示例 3.2,创建动态库。

在上一节中,我们为几何库创建了一个静态库。在本节中,我们想要再次编译源代码,以便从中创建一个共享对象文件。以下命令显示了如何将三个源代码编译成它们对应的位置无关可重定位目标文件,与我们在 示例 3.2 中所做的工作只有一个不同。在以下命令中,请注意传递给 gcc-fPIC 选项:

$ gcc -c ExtremeC_examples_chapter3_2_2d.c -fPIC -o 2d.o
$ gcc -c ExtremeC_examples_chapter3_2_3d.c -fPIC -o 3d.o
$ gcc -c ExtremeC_examples_chapter3_2_trigon.c -fPIC -o trigon.o
$

Shell Box 3-15:将示例 3.2 的源代码编译成相应的位置无关可重定位目标文件

观察命令,你可以看到我们在编译源代码时传递了一个额外的选项 -fPICgcc。如果你打算从一些可重定位目标文件中创建共享对象文件,这个选项是强制性的PIC代表位置无关代码。正如我们之前解释的,如果一个可重定位目标文件是位置无关的,那么它仅仅意味着其中的指令没有固定的地址。相反,它们有相对地址;因此,它们可以在不同的进程中获得不同的地址。这是由于我们使用共享对象文件的方式所要求的。

没有保证加载程序将在不同进程中以相同的地址加载共享对象文件。实际上,加载程序为共享对象文件创建内存映射,这些映射的地址范围可能不同。如果指令地址是绝对的,我们就不能同时在不同的进程和不同的内存区域中加载相同的共享对象文件。

注意

要获取有关程序和共享对象文件动态加载工作方式的更详细信息,你可以查看以下资源:

要创建共享对象文件,你需要再次使用编译器,在这种情况下,是 gcc。与是一个简单的存档的静态库文件不同,共享对象文件本身就是一个目标文件。因此,它们应该由相同的链接程序创建,例如我们用来生成可重定位目标文件的 ld

我们知道,在大多数类 Unix 系统中,ld 会这样做。然而,强烈建议不要直接使用 ld 链接对象文件,原因我们在上一章中已经解释过。

以下命令显示了如何从使用 -fPIC 选项编译的多个可重定位目标文件中创建共享对象文件:

$ gcc -shared 2d.o 3d.o trigon.o -o libgeometry.so
$ mkdir -p /opt/geometry
$ mv libgeometry.so /opt/geometry
$

Shell Box 3-16:从可重定位目标文件中创建共享对象文件

如您在第一个命令中看到的,我们传递了 -shared 选项,要求 gcc 从可重定位目标文件中创建一个共享对象文件。结果是名为 libgeometry.so 的共享对象文件。我们已经将共享对象文件移动到 /opt/geometry 以便其他愿意使用它的程序可以轻松访问。下一步是再次编译和链接 示例 3.3

之前,我们使用创建的静态库文件 libgeometry.a 编译并链接了 示例 3.3。这里,我们将做同样的事情,但我们将使用 libgeometry.so,一个动态库来链接它。

虽然一切似乎都一样,特别是命令,但事实上它们是不同的。这次,我们将用 libgeometry.so 而不是 libgeometry.a 链接 示例 3.3,而且不仅如此,动态库不会嵌入到最终的执行文件中,而是在执行时加载库。在练习这个过程中,确保在再次链接 示例 3.3 之前,你已经从 /opt/geometry 中移除了静态库文件 libgeometry.a

$ rm -fv /opt/geometry/libgeometry.a
$ gcc -c ExtremeC_examples_chapter3_3.c -o main.o
$ gcc main.o -L/opt/geometry-lgeometry -lm -o ex3_3.out
$

Shell Box 3-17:将示例 3.3 链接到构建的共享对象文件

正如我们之前解释的那样,选项 -lgeometry 告诉编译器查找并使用一个库,无论是静态的还是共享的,以便将其与其它目标文件链接。由于我们已经移除了静态库文件,所以选择了共享对象文件。如果定义的库既有静态库文件又有共享对象文件,那么 gcc 会优先选择共享对象文件并将其与程序链接。

如果你现在尝试运行可执行文件 ex3_3.out,你很可能会遇到以下错误:

$ ./ex3_3.out
./ex3_3.out: error while loading shared libraries: libgeometry.so: cannot open shared object file: No such file or directory
$

Shell Box 3-18:尝试运行示例 3.3

我们之前没有看到这个错误,因为我们使用的是静态链接和静态库。但现在,通过引入动态库,如果我们打算运行一个具有 动态依赖性 的程序,我们应该提供所需的动态库以便它能够运行。但发生了什么,为什么我们会收到错误信息?

可执行文件 ex3_3.out 依赖于 libgeometry.so 库。这是因为它需要的某些定义只能在该共享对象文件中找到。我们应该注意,这并不适用于静态库 libgeometry.a。与静态库链接的可执行文件可以作为独立可执行文件运行,因为它已经从静态库文件中复制了所有内容,因此不再依赖于其存在。

对于共享对象文件来说,情况并非如此。我们收到错误是因为程序加载器(动态链接器)在其默认搜索路径中找不到 libgeometry.so。因此,我们需要将 /opt/geometry 添加到其搜索路径中,以便在那里找到 libgeometry.so 文件。为此,我们将更新环境变量 LD_LIBRARY_PATH 以指向当前目录。

加载程序将检查这个环境变量的值,并将在指定的路径中搜索所需的共享库。请注意,在这个环境变量中可以指定多个路径(使用冒号 : 作为分隔符)。

$ export LD_LIBRARY_PATH=/opt/geometry 
$ ./ex3_3.out
Polar Position: Length: 223.606798, Theta: 63.434949 (deg)
$

Shell Box 3-19:通过指定 LD_LIBRARY_PATH 运行示例 3.3

这次,程序已经成功运行!这意味着程序加载器已经找到了共享对象文件,动态链接器已经成功从其中加载所需的符号。

注意,在前面的 shell box 中,我们使用了 export 命令来更改 LD_LIBRARY_PATH。然而,将环境变量与执行命令一起设置是很常见的。你可以在下面的 shell box 中看到这一点。两种用法的结果将是相同的:

$ LD_LIBRARY_PATH=/opt/geometry ./ex3_3.out
Polar Position: Length: 223.606798, Theta: 63.434949 (deg)
$

Shell Box 3-20:通过指定 LD_LIBRARY_PATH 作为同一命令的一部分运行示例 3.3

通过将可执行文件与多个共享对象文件链接,就像我们之前做的那样,我们告诉系统这个可执行文件需要找到并加载在运行时所需的多个共享库。因此,在运行可执行文件之前,加载程序会自动搜索这些共享对象文件,并将所需的符号映射到进程可访问的正确地址。只有在这种情况下,处理器才能开始执行。

手动加载共享库

共享对象文件也可以以不同的方式加载和使用,在这种情况下,它们不是由加载程序(动态链接器)自动加载的。相反,程序员将使用一系列函数在需要使用共享库中可找到的一些符号(函数)之前手动加载共享对象文件。这种手动加载机制有一些应用,一旦我们讨论了本节中将要查看的示例,我们就会谈到它们。

Example 3.4 展示了如何懒加载或手动加载共享对象文件,而不在链接步骤中包含它。这个例子借鉴了 example 3.3 的相同逻辑,但不同之处在于它手动在程序内部加载共享对象文件 libgeometry.so

在进入 example 3.4 之前,我们需要以不同的方式生成 libgeometry.so,以便 example 3.4 能够工作。为此,我们必须在 Linux 中使用以下命令:

$ gcc -shared 2d.o 3d.o trigon.o -lm -o libgeometry.so
$

Shell Box 3-21:将几何共享对象文件链接到标准数学库

查看前面的命令,你可以看到一个新选项 -lm,它告诉链接器将共享对象文件链接到标准数学库 libm.so。这样做是因为当我们手动加载 libgeometry.so 时,它的依赖项应该以某种方式自动加载。如果不是这样,那么我们将得到关于 libgeometry.so 本身所需的符号的错误,例如 cossqrt。请注意,我们不会将最终的可执行文件与数学标准库链接,它将在加载 libgeometry.so 时由加载程序自动解析。

现在我们有了链接的共享对象文件,我们可以继续进行示例 3.4

#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include "ExtremeC_examples_chapter3_2_geometry.h"
polar_pos_2d_t (*func_ptr)(cartesian_pos_2d_t*);
int main(int argc, char** argv) {
  void* handle = dlopen ("/opt/geometry/libgeometry.so", RTLD_LAZY);
  if (!handle) {
    fprintf(stderr, "%s\n", dlerror());
    exit(1);
  }
  func_ptr = dlsym(handle, "convert_to_2d_polar_pos");
  if (!func_ptr)  {
    fprintf(stderr, "%s\n", dlerror());
    exit(1);
  }
  cartesian_pos_2d_t cartesian_pos;
  cartesian_pos.x = 100;
  cartesian_pos.y = 200;
  polar_pos_2d_t polar_pos = func_ptr(&cartesian_pos);
  printf("Polar Position: Length: %f, Theta: %f (deg)\n",
    polar_pos.length, polar_pos.theta);
  return 0;
}

Code Box 3-8 [ExtremeC_examples_chapter3_4.c]:示例 3.4 手动加载几何共享对象文件

通过查看前面的代码,你可以看到我们是如何使用dlopendlsym函数来加载共享对象文件,并在其中找到convert_to_2d_polar_pos符号。dlsym函数返回一个函数指针,可以用来调用目标函数。

值得注意的是,前面的代码在/opt/geometry中搜索共享对象文件,如果没有这样的对象文件,则会显示错误信息。请注意,在 macOS 中,共享对象文件的扩展名以.dylib结尾。因此,前面的代码应该修改为加载具有正确扩展名的文件。

以下命令编译了前面的代码并运行可执行文件:

$ gcc ExtremeC_examples_chapter3_4.c -ldl -o ex3_4.out
$ ./ex3_4.out
Polar Position: Length: 223.606798, Theta: 63.434949 (deg)
$

Shell Box 3-22:运行示例 3.4

正如你所见,我们没有将程序与libgeometry.so文件链接。我们没有这样做是因为我们希望在需要时手动加载它。这种方法通常被称为共享对象文件的延迟加载。尽管名称如此,但在某些场景下,延迟加载共享对象文件可能非常有用。

其中一个例子是当你有不同的共享对象文件用于同一库的不同实现或版本时。延迟加载让你有更大的自由度,可以根据自己的逻辑和需要加载所需的共享对象,而不是在加载时自动加载,那时你对它们的控制较少。

摘要

本章主要讨论了各种类型的对象文件,它们是 C/C++项目构建后的产物。作为本章的一部分,我们涵盖了以下内容:

  • 我们讨论了 API 和 ABI,以及它们之间的区别。

  • 我们探讨了各种对象文件格式,并简要回顾了它们的历史。它们都有相同的祖先,但它们在特定的路径上发生了变化,成为了今天的模样。

  • 我们讨论了可重定位对象文件及其内部结构,特别是关于 ELF 可重定位对象文件。

  • 我们讨论了可执行对象文件,以及它们与可重定位对象文件之间的区别。我们还查看了一个 ELF 可执行对象文件。

  • 我们展示了静态和动态符号表,以及如何使用一些命令行工具读取它们的内容。

  • 我们讨论了静态链接和动态链接,以及如何查找各种符号表以生成最终的二进制文件或执行程序。

  • 我们讨论了静态库文件,以及它们实际上是包含多个可重定位对象文件的归档文件。

  • 讨论了共享对象文件(动态库),并演示了如何将多个可重定位对象文件组合成它们。

  • 我们解释了什么是位置无关代码以及为什么参与创建共享库的可重定位目标文件必须是位置无关的。

在下一章中,我们将探讨进程的内存结构;这是 C/C++编程中的另一个关键主题。下一章将描述各种内存段,我们将看到如何编写没有内存问题的代码。

第四章

进程内存结构

在本章中,我们将讨论进程内部的内存及其结构。对于 C 程序员来说,内存管理始终是一个关键话题,而应用最佳实践需要关于内存结构的基本知识。实际上,这不仅仅局限于 C。在许多编程语言(如 C++或 Java)中,你需要对内存及其工作方式有一个基本理解;否则,你可能会遇到一些严重的问题,这些问题难以追踪和修复。

你可能知道,在 C 语言中内存管理是完全手动的,而且不仅如此,程序员是唯一负责分配内存区域并在不再需要时释放它们的责任人。

在高级编程语言(如 Java 或 C#)中,内存管理是不同的,它部分由程序员完成,部分由底层语言平台完成,例如在使用 Java 时,底层平台是Java 虚拟机JVM)。在这些语言中,程序员只负责发出内存分配的指令,但他们对释放操作不负责。一个称为垃圾回收器的组件负责释放和自动释放分配的内存。

由于 C 和 C++中没有这样的垃圾回收器,因此为内存管理相关的概念和问题设置一些专门的章节是必不可少的。这就是为什么我们专门用这一章和下一章来介绍与内存相关的概念,这两章合起来应该能给你一个关于 C/C++中内存如何工作的基本理解。

在本章中:

  • 我们首先查看进程的典型内存结构。这将帮助我们了解进程的解剖结构和它与内存的交互方式。

  • 我们讨论了静态和动态内存布局。

  • 我们介绍了上述内存布局中发现的段。我们发现其中一些位于可执行对象文件中,其余的则在进程加载时创建。

  • 我们介绍了可以帮助我们检测段及其内容的探测工具和命令,这些段既可以在对象文件内部,也可以在运行进程的深处看到。

作为本章的一部分,我们将了解两个称为的部分。它们是进程动态内存布局的一部分,所有的分配和释放操作都发生在这两个部分中。在下一章中,我们将更详细地讨论栈和堆部分,因为实际上,它们是程序员与之交互最多的部分。

让我们从讨论进程内存布局开始这一章。这将让你对正在运行的进程的内存是如何分段的,以及每个部分用于什么有一个整体的概念。

进程内存布局

每次运行可执行文件时,操作系统都会创建一个新的进程。进程是一个活跃且正在运行的程序,它被加载到内存中,并具有一个唯一的进程标识符PID)。操作系统是负责生成和加载新进程的唯一实体。

进程会一直运行,直到它正常退出,或者进程收到信号,如SIGTERMSIGINTSIGKILL,最终导致它退出。SIGTERMSIGINT信号可以被忽略,但SIGKILL会立即且强制地终止进程。

注意

上一个章节中提到的信号解释如下:

SIGTERM:这是终止信号。它允许进程进行清理。

SIGINT:这是中断信号,通常在按下Ctrl + C时发送给前台进程。

SIGKILL:这是终止信号,它会强制关闭进程,不允许它进行清理。

当创建进程时,操作系统首先做的事情之一是为进程分配一块专用的内存部分,然后应用预定义的内存布局。这种预定义的内存布局在不同的操作系统中大致相同,尤其是在类 Unix 操作系统中。

在本章中,我们将探讨这种内存布局的结构,并介绍一些重要且有用的术语。

普通进程的内存布局被划分为多个部分。每个部分被称为。每个段是内存的一个区域,它具有特定的任务,并存储特定类型的数据。您可以看到以下列表中的段是运行进程内存布局的一部分:

  • 未初始化的数据段或块起始符号BSS)段

  • 数据段

  • 文本段或代码段

  • 栈段

  • 堆段

在接下来的章节中,我们将分别研究这些段,并讨论它们如何有助于程序的执行。在下一章中,我们将重点关注栈和堆段,并对其进行详细讨论。作为我们探索的一部分,让我们介绍一些帮助我们检查内存的工具,然后再深入研究上述段的具体细节。

发现内存结构

类 Unix 操作系统提供了一套工具来检查进程的内存段。在本节中,您将了解到其中一些段位于可执行目标文件中,而其他段是在进程生成时动态创建的。

如您从前两个章节中应该已经了解到的,可执行目标文件和进程不是同一件事,因此预期会有不同的工具来检查它们中的每一个。

从前面的章节中,我们知道可执行目标文件包含机器指令,并且是由编译器生成的。但进程是由执行可执行目标文件而生成的正在运行的程序,它消耗主内存的一部分,而 CPU 则不断获取并执行其指令。

进程是一个在操作系统内部执行的生命实体,而可执行目标文件只是一个包含预先制作初始布局的文件,该布局作为未来进程生成的依据。确实,在运行进程的内存布局中,一些段直接来自基本可执行目标文件,其余的则在进程加载时动态构建。前者布局称为静态内存布局,后者称为动态内存布局

静态和动态内存布局都有一组预定义的段。静态内存布局的内容是由编译器在编译源代码时预先写入可执行目标文件的。另一方面,动态内存布局的内容是由分配变量和数组内存的进程指令写入的,并根据程序的逻辑对其进行修改。

就这样,我们可以通过仅查看源代码或编译后的目标文件来猜测静态内存布局的内容。但关于动态内存布局,这并不容易,因为它不能在没有运行程序的情况下确定。此外,同一可执行文件的多次运行可能导致动态内存布局中的内容不同。换句话说,进程的动态内容对该进程是唯一的,并且应该在进程仍在运行时进行调查。

让我们从检查进程的静态内存布局开始。

探测静态内存布局

用于检查静态内存布局的工具通常作用于目标文件。为了获得一些初步的见解,我们将从一个例子开始,即示例 4.1,这是一个没有包含任何变量或逻辑的最小 C 程序:

int main(int argc, char** argv) {
  return 0;
}

Code Box 4-1 [ExtremeC_examples_chapter4_1.c]:一个最小的 C 程序

首先,我们需要编译前面的程序。我们使用 gcc 在 Linux 中编译它:

$ gcc ExtremeC_examples_chapter4_1.c -o ex4_1-linux.out
$

Shell Box 4-1:在 Linux 中使用 gcc 编译 example 4.1

在成功编译并链接最终的可执行二进制文件后,我们得到一个名为 ex4_1-linux.out 的可执行目标文件。该文件包含一个特定于 Linux 操作系统的预定义静态内存布局,并且它将存在于基于此可执行文件启动的所有未来进程中。

size 命令是我们首先想要介绍的工具。它可以用来打印可执行目标文件的静态内存布局。

您可以通过以下方式查看 size 命令的用法,以查看作为静态内存布局一部分找到的各种段:

$ size ex4_1-linux.out
   text    data     bss     dec     hex   filename
   1099     544       8    1651     673   ex4_1-linux.out
$

Shell Box 4-2:使用 size 命令查看 ex4_1-linux.out 的静态段

如你所见,静态布局中包含文本、数据和 BSS 段。显示的大小以字节为单位。

现在,让我们在不同的操作系统上编译相同的代码,示例 4.1。我们选择了 macOS,并将使用clang编译器:

$ clang ExtremeC_examples_chapter4_1.c -o ex4_1-macos.out
$

Shell Box 4-3:在 macOS 中使用 clang 编译示例 4.1

由于 macOS 与 Linux 一样,是一个符合 POSIX 标准的操作系统,并且size命令被指定为 POSIX 实用程序的一部分,因此 macOS 也应该有size命令。因此,我们可以使用相同的命令来查看ex4_1-macos.out的静态内存段:

$ size ex4_1-macos.out
__TEXT __DATA  __OBJC  others       dec         hex
4096   0       0       4294971392   4294975488  100002000
$ size -m ex4_1-macos.out
Segment __PAGEZERO: 4294967296
Segment __TEXT: 4096
    Section __text: 22
    Section __unwind_info: 72
    total 94
Segment __LINKEDIT: 4096
total 4294975488
$

Shell Box 4-4:使用 size 命令查看 ex4_1-macos.out 的静态段

在前面的 shell 框中,我们运行了size命令两次;第二次运行提供了关于找到的内存段的更多详细信息。你可能已经注意到,macOS 中与 Linux 一样,有文本和数据段,但没有 BSS 段。请注意,BSS 段在 macOS 中也是存在的,但在size输出中并未显示。由于 BSS 段包含未初始化的全局变量,不需要在对象文件中分配一些字节,知道存储这些全局变量所需的字节数就足够了。

在前面的 shell 框中,有一个需要注意的有趣点。在 Linux 中,文本段的大小为 1,099 字节,而在 macOS 中为 4 KB。还可以看到,对于最小化的 C 程序,Linux 中的数据段具有非零大小,但在 macOS 中为空。很明显,不同平台上的低级内存细节是不同的。

尽管 Linux 和 macOS 之间存在这些小小的差异,但我们可以看到,这两个平台都将文本、数据和 BSS 段作为它们静态布局的一部分。从现在开始,我们将逐步解释每个段的使用目的。在接下来的章节中,我们将分别讨论每个段,并为每个段提供一个与示例 4.1略有不同的示例,以便看到每个段如何对代码中的微小变化做出不同的反应。

BSS 段

我们从 BSS 段开始。BSS代表Block Started by Symbol。从历史上看,这个名字被用来表示为未初始化的字保留的区域。基本上,这就是我们使用 BSS 段的目的;要么是未初始化的全局变量,要么是设置为 0 的全局变量。

让我们通过添加一些未初始化的全局变量来扩展示例 4.1。你会看到未初始化的全局变量将贡献到 BSS 段。以下代码框展示了示例 4.2

int global_var1;
int global_var2;
int global_var3 = 0;
int main(int argc, char** argv) {
  return 0;
}

代码框 4-2 [ExtremeC_examples_chapter4_2.c]:一个包含一些全局变量(未初始化或设置为 0)的最小化 C 程序

整数 global_var1global_var2global_var3 是未初始化的全局变量。为了观察与 example 4.1 相比,Linux 中生成的可执行目标文件中做出的更改,我们再次运行 size 命令:

$ gcc ExtremeC_examples_chapter4_2.c -o ex4_2-linux.out
$ size ex4_2-linux.out
   text    data     bss     dec     hex   filename
   1099     544      16    1659     67b   ex4_2-linux.out
$

Shell Box 4-5:使用 size 命令查看 ex4_2-linux.out 的静态段

如果你将前面的输出与 example 4.1 的类似输出进行比较,你会注意到 BSS 段的大小已更改。换句话说,声明未初始化或设置为零的全局变量将累加到 BSS 段。这些特殊的全局变量是静态布局的一部分,当进程加载时,它们被预分配,并且只有在进程存活时才不会被释放。换句话说,它们具有静态生命周期。

注意:

由于设计考虑,我们通常更喜欢在我们的算法中使用局部变量。全局变量过多会增加二进制文件的大小。此外,在全局作用域中保留敏感数据可能会引入安全风险。并发问题,特别是数据竞争、命名空间污染、未知所有权以及在全局作用域中变量过多,是全球变量引入的一些复杂问题。

让我们在 macOS 中编译 example 4.2 并查看 size 命令的输出:

$ clang ExtremeC_examples_chapter4_2.c -o ex4_2-macos.out
$ size ex4_2-macos.out 
__TEXT __DATA  __OBJC  others       dec         hex
4096   4096       0    4294971392   4294979584  100003000
$ size -m ex4_2-macos.out
Segment __PAGEZERO: 4294967296
Segment __TEXT: 4096
    Section __text: 22
    Section __unwind_info: 72
    total 94
Segment __DATA: 4096
    Section __common: 12
    total 12
Segment __LINKEDIT: 4096
total 4294979584
$

Shell Box 4-6:使用 size 命令查看 ex4_2-macos.out 的静态段

再次强调,这与 Linux 不同。在 Linux 中,我们没有全局变量时,为 BSS 段预分配了 8 个字节。在 example 4.2 中,我们添加了三个新的未初始化的全局变量,其大小总和为 12 个字节,Linux C 编译器将 BSS 段扩展了 8 个字节。但在 macOS 中,我们仍然没有 BSS 段作为 size 输出的一部分,但编译器已将 data 段从 0 字节扩展到 4KB,这是 macOS 中的默认页面大小。这意味着 clang 在布局内部为 data 段分配了一个新的内存页。再次强调,这仅仅显示了不同平台在内存布局细节上的差异有多大。

注意:

在分配内存时,程序需要分配多少字节并不重要。分配器总是以 内存页 为单位获取内存,直到总分配大小覆盖程序的需求。有关 Linux 内存分配器的更多信息,请参阅此处:www.kernel.org/doc/gorman/html/understand/understand009.html

Shell 框 4-6 中,我们在 _DATA 段内部有一个名为 __common 的部分,它占 12 字节,实际上它指的是未在 size 输出中显示的 BSS 段。它指的是 3 个未初始化的全局整数变量或 12 字节(每个整数是 4 字节)。值得注意的是,未初始化的全局变量默认设置为 。对于未初始化变量,没有其他可以想象出的值。

现在我们来谈谈静态内存布局中的下一个段;数据段。

数据段

为了显示存储在数据段中的变量类型,我们将声明更多的全局变量,但这次我们用非零值初始化它们。以下示例,示例 4.3,扩展了 示例 4.2 并添加了两个新的初始化全局变量:

int global_var1;
int global_var2;
int global_var3 = 0;
double global_var4 = 4.5;
char global_var5 = 'A';
int main(int argc, char** argv) {
  return 0;
}

代码框 4-3 [ExtremeC_examples_chapter4_3.c]:一个包含已初始化和未初始化全局变量的最小 C 程序

以下 shell 框显示 Linux 中 size 命令的输出,以及 示例 4.3

$ gcc ExtremeC_examples_chapter4_3.c -o ex4_3-linux.out
$ size ex4_3-linux.out
   text    data     bss     dec     hex filename
   1099     553      20    1672     688 ex4_3-linux.out
$

Shell 框 4-7:使用 size 命令查看 ex4_3-linux.out 的静态段

我们知道数据段用于存储设置为非零值的初始化全局变量。如果你比较 示例 4.24.3size 命令输出,你可以很容易地看到数据段增加了 9 字节,这是两个新添加的全局变量尺寸之和(一个 8 字节的 double 和一个 1 字节的 char)。

让我们看看 macOS 中的变化:

$ clang ExtremeC_examples_chapter4_3.c -o ex4_3-macos.out
$ size ex4_3-macos.out 
__TEXT __DATA  __OBJC  others       dec         hex
4096   4096       0    4294971392   4294979584  100003000
$ size -m ex4_3-macos.out
Segment __PAGEZERO: 4294967296
Segment __TEXT: 4096
    Section __text: 22
    Section __unwind_info: 72
    total 94
Segment __DATA: 4096
    Section __data: 9
    Section __common: 12
    total 21
Segment __LINKEDIT: 4096
total 4294979584
$

Shell 框 4-8:使用 size 命令查看 ex4_3-macos.out 的静态段

在第一次运行中,我们没有看到任何变化,因为所有全局变量的总和仍然远低于 4KB。但在第二次运行中,我们看到 _DATA 段的一部分出现了一个新的部分;__data 部分。为这个部分分配的内存是 9 字节,这与新引入的初始化全局变量的尺寸相符合。而且,我们仍然有 12 字节用于未初始化的全局变量,就像在 示例 4.2 和 macOS 中一样。

进一步来说,size 命令只显示段的尺寸,但不显示其内容。有其他特定于每个操作系统的命令可以用来检查在对象文件中找到的段的内容。例如,在 Linux 中,你有 readelfobjdump 命令来查看 ELF 文件的内容。这些工具也可以用来探测对象文件内部的静态内存布局。作为前两章的一部分,我们探索了一些这些命令。

除了全局变量之外,我们还可以在函数内部声明一些静态变量。这些变量在多次调用同一函数时保留其值。这些变量可以存储在数据段或 BSS 段中,具体取决于平台以及它们是否已初始化。以下代码框演示了如何在函数内部声明一些静态变量:

void func() {
  static int i;
  static int j = 1;
  ...
}

Code Box 4-4:两个静态变量的声明,一个已初始化,另一个未初始化

如你在Code Box 4-4中看到的,ij变量是静态的。i变量未初始化,而j变量初始化为值1。无论你进入和离开func函数多少次,这些变量都保持它们最新的值。

为了更详细地说明这是如何完成的,在运行时,func函数可以访问位于 Data 段或 BSS 段中的这些变量,这些段具有静态生命周期。这就是为什么这些变量被称为静态的原因。我们知道j变量位于 Data 段,仅仅因为它有一个初始值,而i变量应该位于 BSS 段,因为它没有初始化。

现在,我们想介绍第二个命令来检查 BSS 段的内容。在 Linux 中,可以使用objdump命令打印出在目标文件中找到的内存段的内容。在 macOS 中对应的命令是gobjdump,需要首先安装。

作为example 4.4的一部分,我们试图检查生成的可执行目标文件,以找到写入 Data 段的某些全局变量的数据。以下代码框显示了example 4.4的代码:

int     x = 33;            // 0x00000021
int     y = 0x12153467;
char z[6] = "ABCDE";
int main(int argc, char**argv) {
  return 0;
}

Code Box 4-5 [ExtremeC_examples_chapter4_4.c]:一些应该写入 Data 段的已初始化全局变量

前面的代码很容易理解。它只是声明了三个具有一些初始值的全局变量。在编译后,我们需要转储 Data 段的内容,以便找到写入的值。

以下命令将演示如何编译和使用objdump来查看 Data 段的内容:

$ gcc ExtremeC_examples_chapter4_4.c -o ex4_4.out
$ objdump -s -j .data ex4_4.out
a.out:     file format elf64-x86-64
Contents of section .data:
 601020 00000000 00000000 00000000 00000000  ...............
 601030 21000000 67341512 41424344 4500      !....4..ABCDE.
$

Shell Box 4-9:使用objdump命令查看 Data 段的内容

让我们来解释一下前面的输出应该如何读取,尤其是关于.data部分的说明。最左侧的列是地址列。接下来的四列是内容列,每列显示4个字节数据。因此,在每一行中,我们都有 16 个字节的内容。最右侧的列显示了中间列中相同字节 ASCII 表示。点字符表示该字符无法使用字母数字字符显示。请注意,选项-s告诉objdump显示所选部分的全部内容,而选项-j .data告诉它显示.data部分的内容。

第一行是 16 个字节填充为零。这里没有存储变量,所以对我们来说没有什么特殊之处。第二行显示了从地址0x601030开始的 Data 段的内容。前 4 个字节是example 4.4中找到的x变量的值。接下来的 4 个字节也包含了y变量的值。最后的 6 个字节是z数组中的字符。z的内容在最后一列中可以清楚地看到。

如果你足够关注 Shell Box 4-9 中显示的内容,你会看到,尽管我们以十进制基数写作 33,作为 0x00000021 的十六进制基数,它在段中的存储方式不同。它被存储为 0x21000000y 变量的内容也是如此。我们将其写作 0x12153467,但它以不同的方式存储为 0x67341512。这似乎是字节顺序被反转了。

解释的效果是由于字节序的概念。通常,我们有两种不同的字节序类型,大端序小端序。值 0x12153467 是数字 0x12153467 的大端序表示,因为最大的字节 0x12 是第一个。但值 0x67341512 是数字 0x12153467 的小端序表示,因为最小的字节 0x67 是第一个。

无论字节序如何,我们总是在 C 中读取正确的值。字节序是 CPU 的属性,不同的 CPU 可能会在最终的目标文件中得到不同的字节序。这也是为什么你不能在不同的字节序硬件上运行可执行目标文件的原因之一。

在 macOS 机器上看到相同的输出将很有趣。以下 shell 框演示了如何使用 gobjdump 命令来查看数据段的内容:

$ gcc ExtremeC_examples_chapter4_4.c -o ex4_4.out
$ gobjdump -s -j .data ex4_4.out
a.out:     file format mach-o-x86-64
Contents of section .data:
 100001000 21000000 67341512 41424344 4500      !...g4..ABCDE.
$

Shell Box 4-10:使用 macOS 中的 gobjdump 命令查看数据段的内容

应该像在 Shell Code 4-9 中找到的 Linux 输出一样精确地读取。正如你所见,在 macOS 中,数据段中没有 16 字节的零头。内容字节序也表明,二进制文件是为小端序处理器编译的。

在本节的最后,其他工具如 Linux 中的 readelf 和 macOS 中的 dwarfdump 可以用来检查目标文件的内容。目标文件的二进制内容也可以使用 hexdump 等工具读取。

在下一节中,我们将讨论文本段以及如何使用 objdump 检查它。

文本段

如我们从 第二章编译和链接 中所知,链接器将生成的机器级指令写入最终的执行目标文件。由于文本段,或代码段,包含程序的所有机器级指令,它应该位于执行目标文件中,作为其静态内存布局的一部分。这些指令由处理器检索并在进程运行时执行。

要深入了解,让我们看看一个真实可执行目标文件的文本段。为此,我们提出一个新的例子。下面的代码框展示了 示例 4.5,正如你所见,它只是一个空的 main 函数:

int main(int argc, char** argv) {
  return 0;
}

代码框 4-6 [ExtremeC_examples_chapter4_5.c]:一个最小的 C 程序

我们可以使用 objdump 命令转储生成的执行对象文件的各个部分。请注意,objdump 命令仅在 Linux 中可用,而其他操作系统有自己的命令集来完成相同的工作。

以下 Shell Box 展示了使用 objdump 命令提取由 示例 4.5 生成的可执行对象文件中存在的各个节的内容。请注意,输出被缩短了,只显示了 main 函数对应的节及其汇编指令:

$ gcc ExtremeC_examples_chapter4_5.c -o ex4_5.out
$ objdump -S ex4_5.out
ex4_5.out:     file format elf64-x86-64
Disassembly of section .init:
0000000000400390 <_init>:
... truncated.
.
.
Disassembly of section .plt:
00000000004003b0 <__libc_start_main@plt-0x10>:
... truncated
00000000004004d6 <main>:
  4004d6:   55                      push   %rbp
  4004d7:   48 89 e5                mov    %rsp,%rbp
  4004da:   b8 00 00 00 00          mov    $0x0,%eax
  4004df:   5d                      pop    %rbp
  4004e0:   c3                      retq
  4004e1:   66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  4004e8:   00 00 00
  4004eb:   0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
00000000004004f0 <__libc_csu_init>:
... truncated
.
.
.
0000000000400564 <_fini>:
... truncated
$

Shell Box 4-11:使用 objdump 显示与主函数对应的节的内容

如你在先前的 Shell Box 中所见,存在各种包含机器级指令的节:.text.init.plt 节以及其他一些节,所有这些节共同允许程序被加载并运行。所有这些节都是静态内存布局中可执行对象文件内的同一个文本段的一部分。

我们为 示例 4.5 编写的 C 程序只有一个函数,即 main 函数,但正如你所见,最终的执行对象文件中还有一打其他的函数。

先前的输出,作为 Shell Box 4-11 的一部分,显示 main 函数不是 C 程序中首先被调用的函数,并且在 main 之前和之后还有逻辑应该被执行。正如 第二章 中所解释的,在 Linux 中,这些函数通常是从 glibc 库中借用的,并且由链接器组合在一起形成最终的执行对象文件。

在下一节中,我们将开始探测进程的动态内存布局。

探测动态内存布局

动态内存布局实际上是进程的运行时内存,只要进程在运行,它就存在。当你执行一个可执行对象文件时,一个名为 loader 的程序负责执行。它启动一个新的进程,并创建初始的内存布局,这个布局应该是动态的。为了形成这个布局,静态布局中找到的段将从可执行对象文件中复制过来。不仅如此,还会添加两个新的段。只有这样,进程才能继续并运行。

简而言之,我们期望在运行进程的内存布局中有五个段。其中三个段直接从可执行对象文件中找到的静态布局复制而来。新增的两个段被称为栈段和堆段。这些段是动态的,并且只有在进程运行时才存在。这意味着你无法在可执行对象文件中找到它们的任何痕迹。

在本节中,我们的最终目标是探测栈和堆段,并介绍操作系统中的工具和位置,这些工具和位置可以用于此目的。不时地,我们可能会将这些段称为进程的动态内存布局,而不考虑从对象文件中复制的其他三个段,但你应该始终记住,进程的动态内存由这五个段共同组成。

栈段是我们从其中分配变量的默认内存区域。在大小方面,它是一个有限的区域,你不能在其中存放大型对象。相比之下,堆段是一个更大且可调整的内存区域,可以用来存放大型对象和巨大的数组。与堆段一起工作需要自己的 API,我们将在我们的讨论中介绍。

记住,动态内存布局与动态内存分配不同。你不应该混淆这两个概念,因为它们指的是两件不同的事情!随着我们的进展,我们将了解更多关于不同类型的内存分配,特别是动态内存分配。

进程动态内存中的五个部分指的是主内存中已经分配专用私有给运行进程的部分。这些部分,除了文本段,它是字面意义上的静态和常量,在某种程度上是动态的,因为它们的内 容在运行时总是变化的。这是由于这些部分不断地被进程执行的算法修改。

检查进程的动态内存布局需要特定的程序。这意味着在能够探测其动态内存布局之前,我们需要有一个正在运行的过程。这要求我们编写一些长时间运行以保持其动态内存不变的例子。然后,我们可以使用我们的检查工具来研究它们的动态内存结构。

在下一节中,我们给出了如何探测动态内存结构的示例。

内存映射

让我们从简单的例子开始。示例 4.6 将会运行一个不确定的时间长度。这样,我们就有了一个永远不会结束的过程,同时,我们可以在检查过程中探测其内存结构。当然,我们可以在检查完成后随时终止它。你可以在下面的代码框中找到这个例子:

#include <unistd.h> // Needed for sleep function
int main(int argc, char** argv) {
  // Infinite loop
  while (1) {
    sleep(1); // Sleep 1 second
  };
  return 0;
}

代码框 4-6 [ExtremeC_examples_chapter4_6.c]:用于探测动态内存布局的示例 4.6

如你所见,代码只是一个无限循环,这意味着进程将永远运行。因此,我们有足够的时间检查进程的内存。让我们首先构建它。

注意

unistd.h 头文件仅在类 Unix 操作系统上可用;更准确地说,在符合 POSIX 标准的操作系统中。这意味着在不符合 POSIX 标准的 Microsoft Windows 上,你必须包含 windows.h 头文件。

下面的 shell 框展示了如何在 Linux 中编译这个例子:

$ gcc ExtremeC_examples_chapter4_6.c -o ex4_6.out
$

Shell Box 4-12:在 Linux 中编译示例 4.6

然后,我们按照以下方式运行它。为了在进程运行期间使用相同的提示符来发出进一步的命令,我们应该在后台启动进程:

$ ./ ex4_6.out &
[1] 402
$

Shell Box 4-13:在后台运行示例 4.6

进程现在正在后台运行。根据输出,最近启动的进程的 PID 是 402,我们将使用这个 PID 在将来将其终止。每次运行程序时,PID 都会不同;因此,您可能在您的计算机上看到不同的 PID。请注意,每次您在后台运行进程时,shell 提示符都会立即返回,您可以发出进一步的命令。

注意

如果您有一个进程的 PID(进程 ID),您可以使用 kill 命令轻松地结束它。例如,如果 PID 是 402,以下命令将在类 Unix 操作系统中生效:kill -9 402

PID 是我们用来检查进程内存的标识符。通常,操作系统会提供自己的特定机制,根据 PID 查询进程的各种属性。但在这里,我们只对进程的动态内存感兴趣,我们将使用 Linux 中可用的机制来了解更多关于上述运行进程的动态内存结构。

在 Linux 机器上,进程的信息可以在 /proc 目录下的文件中找到。它使用一个称为 procfs 的特殊文件系统。这个文件系统不是一个普通的文件系统,用于保存实际的文件,而更像是一个查询单个进程或整个系统各种属性的分层接口。

注意

procfs 不仅限于 Linux。它通常是类 Unix 操作系统的一部分,但并非所有类 Unix 操作系统都使用它。例如,FreeBSD 使用这个文件系统,但 macOS 不使用。

现在,我们将使用 procfs 来查看运行进程的内存结构。进程的内存由多个 内存映射 组成。每个内存映射代表一个专用的内存区域,该区域作为进程的一部分映射到特定的文件或段。简而言之,您将看到栈(Stack)和堆(Heap)段在每个进程中都有自己的内存映射。

您可以使用 procfs 做的事情之一是观察进程当前的内存映射。接下来,我们将展示这一点。

我们知道进程正在以 PID 402 运行。使用 ls 命令,我们可以看到 /proc/402 目录的内容,如下所示:

$ ls -l /proc/402
total of 0
dr-xr-xr-x  2 root root 0 Jul 15 22:28 attr
-rw-r--r--  1 root root 0 Jul 15 22:28 autogroup
-r--------  1 root root 0 Jul 15 22:28 auxv
-r--r--r--  1 root root 0 Jul 15 22:28 cgroup
--w-------  1 root root 0 Jul 15 22:28 clear_refs
-r--r--r--  1 root root 0 Jul 15 22:28 cmdline
-rw-r--r--  1 root root 0 Jul 15 22:28 comm
-rw-r--r--  1 root root 0 Jul 15 22:28 coredump_filter
-r--r--r--  1 root root 0 Jul 15 22:28 cpuset
lrwxrwxrwx  1 root root 0 Jul 15 22:28 cwd -> /root/codes
-r--------  1 root root 0 Jul 15 22:28 environ
lrwxrwxrwx  1 root root 0 Jul 15 22:28 exe -> /root/codes/a.out
dr-x------  2 root root 0 Jul 15 22:28 fd
dr-x------  2 root root 0 Jul 15 22:28 fdinfo
-rw-r--r--  1 root root 0 Jul 15 22:28 gid_map
-r--------  1 root root 0 Jul 15 22:28 io
-r--r--r--  1 root root 0 Jul 15 22:28 limits
...
$

Shell Box 4-14:列出 /proc/402 的内容

如您所见,在 /proc/402 目录下有许多文件和目录。这些文件和目录中的每一个都对应于进程的特定属性。为了查询进程的内存映射,我们必须查看 PID 目录下 maps 文件的内容。我们使用 cat 命令来转储 /proc/402/maps 文件的内容。如下所示:

$ cat /proc/402/maps
00400000-00401000 r-xp 00000000 08:01 790655              .../extreme_c/4.6/ex4_6.out
00600000-00601000 r--p 00000000 08:01 790655              .../extreme_c/4.6/ex4_6.out
00601000-00602000 rw-p 00001000 08:01 790655              .../extreme_c/4.6/ex4_6.out
7f4ee16cb000-7f4ee188a000 r-xp 00000000 08:01 787362      /lib/x86_64-linux-gnu/libc-2.23.so
7f4ee188a000-7f4ee1a8a000 ---p 001bf000 08:01 787362      /lib/x86_64-linux-gnu/libc-2.23.so
7f4ee1a8a000-7f4ee1a8e000 r--p 001bf000 08:01 787362      /lib/x86_64-linux-gnu/libc-2.23.so
7f4ee1a8e000-7f4ee1a90000 rw-p 001c3000 08:01 787362      /lib/x86_64-linux-gnu/libc-2.23.so
7f4ee1a90000-7f4ee1a94000 rw-p 00000000 00:00 0
7f4ee1a94000-7f4ee1aba000 r-xp 00000000 08:01 787342      /lib/x86_64-linux-gnu/ld-2.23.so
7f4ee1cab000-7f4ee1cae000 rw-p 00000000 00:00 0
7f4ee1cb7000-7f4ee1cb9000 rw-p 00000000 00:00 0
7f4ee1cb9000-7f4ee1cba000 r--p 00025000 08:01 787342      /lib/x86_64-linux-gnu/ld-2.23.so
7f4ee1cba000-7f4ee1cbb000 rw-p 00026000 08:01 787342      /lib/x86_64-linux-gnu/ld-2.23.so
7f4ee1cbb000-7f4ee1cbc000 rw-p 00000000 00:00 0
7ffe94296000-7ffe942b7000 rw-p 00000000 00:00 0           [stack]
7ffe943a0000-7ffe943a2000 r--p 00000000 00:00 0           [vvar]
7ffe943a2000-7ffe943a4000 r-xp 00000000 00:00 0           [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0   [vsyscall]
$

Shell Box 4-15:导出 /proc/402/maps 的内容

如你在 Shell Box 4-15 中所见,结果由多行组成。每一行代表一个内存映射,它指示一组内存地址(一个区域)被分配并映射到进程动态内存布局中的特定文件或段。每个映射由一个或多个空格分隔的字段组成。接下来,你可以从左到右找到这些字段的描述:

  • 地址范围:这是映射范围的起始和结束地址。如果该区域映射到文件,则可以在它们前面找到文件路径。这是一种智能地映射在各个进程中加载的相同共享对象文件的方法。我们已经在 第三章对象文件 中讨论了这一点。

  • 权限:这表示内容是否可执行(x)、可读(r)或可修改(w)。该区域也可以由其他进程共享(s)或仅由拥有进程私有(p)。

  • 偏移量:如果该区域映射到一个文件,这将是从文件开始的偏移量。如果该区域未映射到文件,则通常为 0。

  • 设备:如果该区域映射到一个文件,这将是指示包含映射文件的设备号(形式为 m:n),表示包含映射文件的设备。例如,这将是指向包含共享对象文件的硬盘的设备号。

  • inode:如果该区域映射到一个文件,那么该文件应该位于一个文件系统上。然后,这个字段将是该文件系统中文件的 inode 号。inode 是在类似 ext4 这样的文件系统中的一个抽象概念,这些文件系统主要在类 Unix 操作系统中使用。每个 inode 可以代表文件和目录。每个 inode 都有一个用于访问其内容的数字。

  • 路径名或描述:如果该区域映射到一个文件,这将是指向该文件的路径。否则,它将被留空,或者它将描述该区域的目的。例如,[stack] 表示该区域实际上是栈段。

maps 文件提供了有关进程动态内存布局的更多有用信息。我们需要一个新的例子来正确演示这一点。

栈段

首先,让我们更详细地谈谈栈段。栈是每个进程动态内存的一个关键部分,它几乎存在于所有架构中。你在描述为 [stack] 的内存映射中已经看到了它。

栈和堆段都具有动态内容,这些内容在进程运行过程中不断变化。查看这些段的动态内容并不容易,大多数时候你需要一个调试器,如 gdb,在进程运行时遍历内存字节并读取它们。

如前所述,栈段通常大小有限,不是一个存储大对象的好地方。如果栈段满了,由于函数调用机制严重依赖于栈段的功能,进程将无法进行任何进一步的函数调用。

如果一个进程的栈段满了,操作系统会终止该进程。"栈溢出"是一个著名的错误,当栈段满了时发生。我们将在未来的段落中讨论函数调用机制。

如前所述,栈段是默认的内存区域,变量是从这里分配的。假设你在函数内部声明了一个变量,如下所示:

void func() {
  // The memory required for the following variable is
  // allocated from the stack segment.
  int a; 
  ... 
}

代码框 4-7:声明一个从栈段分配内存的局部变量

在前面的函数中,声明变量时,我们没有提到任何内容让编译器知道变量应该从哪个段分配。因此,编译器默认使用栈段。栈段是分配的第一个地方。

正如其名所示,它是一个。如果你声明一个局部变量,它就会分配在栈段顶部。当你离开声明局部变量的作用域时,编译器必须首先弹出局部变量,以便提升外部作用域中声明的局部变量。

注意

栈在抽象形式上是一个先进先出FILO)或后进先出LIFO)的数据结构。无论实现细节如何,每个条目都是存储(推入)在栈顶的,并且将被后续条目覆盖。如果不先移除上面的条目,就无法弹出任何一个条目。

存储在栈段中的不仅仅是变量。每次你调用一个函数时,都会在栈段顶部放置一个新的条目,称为栈帧。否则,你无法返回调用函数或将结果返回给调用者。

拥有一个健康的堆栈机制对于拥有一个正常工作的程序至关重要。由于栈的大小有限,声明小变量在其中是一个好习惯。此外,不应让太多的栈帧填充栈,这是由于无限递归调用或过多的函数调用造成的。

从另一个角度来看,栈段是程序员用来存储数据和声明算法中使用的局部变量的区域,以及操作系统作为程序运行者用来存储执行程序所需的数据的区域。

在这个意义上,当你处理这个段时应该小心,因为误用它或损坏其数据可能会中断运行过程,甚至导致程序崩溃。堆段是仅由程序员管理的内存段。我们将在下一节中介绍堆段。

如果我们只使用我们介绍的工具来探测静态内存布局,那么从外部看到栈段的内容并不容易。这部分内存包含私有数据,可能很敏感。它也是进程私有的,其他进程无法读取或修改它。

因此,为了在栈内存中航行,必须将某个东西附加到进程上,并通过该进程的视角查看栈段。这可以通过使用 调试器 程序来完成。调试器附加到进程上,允许程序员控制目标进程并调查其内存内容。我们将在下一章中使用这项技术来检查栈内存。现在,我们将栈段留给讨论堆段。我们将在下一章回到栈。

堆段

以下示例,示例 4.7,展示了如何使用内存映射来找到为堆段分配的区域。它与 示例 4.6 非常相似,但在进入无限循环之前,它从堆段分配了一部分字节。

因此,就像我们对 示例 4.6 所做的那样,我们可以遍历运行进程的内存映射,看看哪个映射指向堆段。

以下代码框包含了 示例 4.7 的代码:

#include <unistd.h> // Needed for sleep function
#include <stdlib.h> // Needed for malloc function
#include <stdio.h> // Needed for printf
int main(int argc, char** argv) {
  void* ptr = malloc(1024); // Allocate 1KB from heap
  printf("Address: %p\n", ptr);
  fflush(stdout); // To force the print
  // Infinite loop
  while (1) {
    sleep(1); // Sleep 1 second
  };
  return 0;
}

Code Box 4-8 [ExtremeC_examples_chapter4_7.c]:用于探测堆段的示例 4.7

在前面的代码中,我们使用了 malloc 函数。这是从堆段分配额外内存的主要方式。它接受应该分配的字节数,并返回一个通用指针。

作为提醒,通用指针(或空指针)包含一个内存地址,但它不能被 解引用 并直接使用。在使用之前,它应该被转换为特定的指针类型。

示例 4.7 中,我们在进入循环之前分配了 1024 字节(或 1KB)。程序还在开始循环之前打印了从 malloc 接收到的指针的地址。让我们编译这个示例并像对 示例 4.7 那样运行它:

$ g++ ExtremeC_examples_chapter4_7.c -o ex4_7.out
$ ./ex4_7.out &
[1] 3451
Address: 0x19790010
$

Shell Box 4-16:编译和运行示例 4.7

现在,进程在后台运行,并且已经获得了 PID 3451。

让我们通过查看其 maps 文件来查看这个进程映射了哪些内存区域:

$ cat /proc/3451/maps
00400000-00401000 r-xp 00000000 00:2f 176521             .../extreme_c/4.7/ex4_7.out
00600000-00601000 r--p 00000000 00:2f 176521             .../extreme_c/4.7/ex4_7.out
00601000-00602000 rw-p 00001000 00:2f 176521             .../extreme_c/4.7/ex4_7.out
01979000-0199a000 rw-p 00000000 00:00 0                  [heap]
7f7b32f12000-7f7b330d1000 r-xp 00000000 00:2f 30         /lib/x86_64-linux-gnu/libc-2.23.so
7f7b330d1000-7f7b332d1000 ---p 001bf000 00:2f 30         /lib/x86_64-linux-gnu/libc-2.23.so
7f7b332d1000-7f7b332d5000 r--p 001bf000 00:2f 30         /lib/x86_64-linux-gnu/libc-2.23.so
7f7b332d5000-7f7b332d7000 rw-p 001c3000 00:2f 30         /lib/x86_64-linux-gnu/libc-2.23.so
7f7b332d7000-7f7b332db000 rw-p 00000000 00:00 0 
7f7b332db000-7f7b33301000 r-xp 00000000 00:2f 27        /lib/x86_64-linux-gnu/ld-2.23.so
7f7b334f2000-7f7b334f5000 rw-p 00000000 00:00 0 
7f7b334fe000-7f7b33500000 rw-p 00000000 00:00 0 
7f7b33500000-7f7b33501000 r--p 00025000 00:2f 27         /lib/x86_64-linux-gnu/ld-2.23.so
7f7b33501000-7f7b33502000 rw-p 00026000 00:2f 27         /lib/x86_64-linux-gnu/ld-2.23.so
7f7b33502000-7f7b33503000 rw-p 00000000 00:00 0 
7ffdd63c2000-7ffdd63e3000 rw-p 00000000 00:00 0          [stack]
7ffdd63e7000-7ffdd63ea000 r--p 00000000 00:00 0          [vvar]
7ffdd63ea000-7ffdd63ec000 r-xp 00000000 00:00 0          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0  [vsyscall]
$

Shell Box 4-17:导出 /proc/3451/maps 的内容

如果你仔细查看 Shell Box 4-17,你会看到一个新映射被突出显示,并且它被描述为 [heap]。这个区域是由于使用了 malloc 函数而被添加的。如果你计算这个区域的大小,它是 0x21000 字节或 132 KB。这意味着为了在代码中只分配 1 KB,已经分配了一个大小为 132 KB 的区域。

这通常是为了防止未来再次使用 malloc 时进一步分配内存。这仅仅是因为从堆段分配内存并不便宜,并且它既有内存开销也有时间开销。

如果你回顾一下代码框 4-8中显示的代码,ptr指针所指向的地址也很有趣。堆的内存映射,如Shell 框 4-17所示,是从地址0x019790000x0199a000分配的,而存储在ptr中的地址是0x19790010,这显然在堆范围内,位于偏移量16字节的位置。

堆段可以增长到远大于 132 KB 的大小,甚至达到数十吉字节,通常它用于永久、全局和非常大的对象,如数组和位流。

正如之前指出的那样,在堆段内进行分配和释放需要程序调用 C 标准提供的特定函数。虽然你可以在栈段顶部有局部变量,并且可以直接使用它们与内存交互,但堆内存只能通过指针访问,这也是为什么了解指针并且能够使用它们对于每个 C 程序员来说至关重要。让我们来看一下示例 4.8,它展示了如何使用指针访问堆空间:

#include <stdio.h>   // For printf function
#include <stdlib.h>  // For malloc and free function
void fill(char* ptr) {
  ptr[0] = 'H';
  ptr[1] = 'e';
  ptr[2] = 'l';
  ptr[3] = 'l';
  ptr[5] = 0;
}
int main(int argc, char** argv) {
  void* gptr = malloc(10 * sizeof(char));
  char* ptr = (char*)gptr;
  fill(ptr);
  printf("%s!\n", ptr);
  free(ptr);
  return 0;
}

代码框 4-9 [ExtremeC_examples_chapter4_8.c]: 使用指针与堆内存交互

前面的程序使用malloc函数从堆空间分配了 10 个字节。malloc函数接收应该分配的字节数,并返回一个指向分配内存块第一个字节的通用指针。

为了使用返回的指针,我们必须将其转换为适当的指针类型。由于我们打算使用分配的内存来存储一些字符,我们选择将其转换为char指针。转换是在调用fill函数之前完成的。

注意,局部指针变量gptrptr是从栈中分配的。这些指针需要内存来存储它们的值,而这部分内存来自栈段。但是它们所指向的地址位于堆段内部。这是处理堆内存时的主题。你有从栈段分配的局部指针,但实际上它们指向的是从堆段分配的区域。我们将在下一章中展示更多这样的例子。

注意,fill函数内部的ptr指针也是从栈中分配的,但它处于不同的作用域,并且与main函数中声明的ptr指针不同。

当涉及到堆内存时,程序(或者实际上是程序员)负责内存分配。当不再需要时,程序也负责释放内存。有一块分配的堆内存是不可访问的,这被认为是内存泄漏。这里的不可访问是指没有指针可以用来定位那个区域。

内存泄漏对程序是致命的,因为持续的内存泄漏最终会耗尽整个允许的内存空间,这可能导致进程死亡。这就是为什么程序在从main函数返回之前调用free函数的原因。对free函数的调用将释放所获得的堆内存块,程序不应再使用这些堆地址。

下一章将详细介绍栈和堆段。

摘要

本章的初始目标是提供一个在类 Unix 操作系统中进程内存结构的概述。由于本章涵盖了大量的内容,请花一分钟时间回顾我们所经历的内容,因为你现在应该能够舒适地理解我们所取得的成果:

  • 我们描述了运行进程的动态内存结构以及可执行对象文件的静态内存结构。

  • 我们观察到静态内存布局位于可执行对象文件内部,并被分割成称为段的各个部分。我们发现文本、数据和 BSS 段是静态内存布局的一部分。

  • 我们看到文本段或代码段用于存储在从当前可执行对象文件中生成新进程时将要执行的机器级指令。

  • 我们看到 BSS 段用于存储未初始化或设置为零的全局变量。

  • 我们解释说数据段用于存储初始化的全局变量。

  • 我们使用了sizeobjdump命令来探测对象文件的内部结构。我们还可以使用对象文件转储工具,如readelf,以在对象文件中找到这些段。

  • 我们探测了进程的动态内存布局。我们看到所有段都是从静态内存布局复制到进程的动态内存中的。然而,在动态内存布局中有两个新的段;栈段和堆段。

  • 我们解释说栈段是默认的内存区域,用于分配。

  • 我们了解到局部变量始终分配在栈区域顶部。

  • 我们还观察到函数调用的秘密在于栈段及其工作方式。

  • 我们看到,为了分配和释放堆内存区域,我们必须使用特定的 API 或一系列函数。这个 API 由 C 标准库提供。

  • 我们讨论了内存泄漏及其在堆内存区域中可能发生的情况。

下一章将专门介绍栈和堆段。它将使用本章中涵盖的主题,并在此基础上添加更多内容。将给出更多示例,并介绍新的探测工具;这将完成我们对 C 中内存管理的讨论。

第五章

栈和堆

在前一章中,我们对正在运行的进程的内存布局进行了调查。如果不了解足够的内存结构和其各个段,进行系统编程就像在不知道人体解剖学的情况下进行手术一样。前一章只是提供了关于进程内存布局中不同段的基本信息,但本章希望我们只关注最常用的段:栈和堆。

作为程序员,你大部分时间都在忙于处理栈和堆段。其他段,如数据或 BSS 段,使用较少,或者你对它们控制较少。这基本上是因为数据和 BSS 段是由编译器生成的,通常,在进程的生命周期中,它们只占用整个内存的一小部分。这并不意味着它们不重要,实际上,有一些问题直接与这些段相关。但因为你大部分时间都在处理栈和堆,所以大多数内存问题都源于这些段。

作为本章的一部分,你将学习:

  • 如何探测栈段以及为此目的所需的工具

  • 栈段是如何自动进行内存管理的

  • 栈段的各项特性

  • 关于如何使用栈段的指南和最佳实践

  • 如何探测堆段

  • 如何分配和释放堆内存块

  • 关于堆段使用的指南和最佳实践

  • 内存受限环境和性能环境中的内存调整

让我们通过更详细地讨论栈段来开始我们的探索之旅。

一个进程可以在没有堆段的情况下继续工作,但不能在没有栈段的情况下工作。这说明了很多。栈是进程代谢的主要部分,没有它就无法继续执行。原因隐藏在驱动函数调用的机制背后。正如前一章简要解释的,调用函数只能通过使用栈段来完成。没有栈段,就无法进行函数调用,这意味着根本无法执行。

如此一来,栈段及其内容被精心设计,以确保过程的健康执行。因此,干扰栈内容可能会破坏执行并停止进程。从栈段分配内存速度快,且不需要任何特殊函数调用。更重要的是,释放内存和所有内存管理任务都是自动发生的。所有这些事实都非常诱人,并鼓励你过度使用栈。

你应该对此保持警惕。使用栈段会带来自己的复杂性。栈并不大,因此你无法在其中存储大对象。此外,栈内容的错误使用可能导致执行中断并引发崩溃。以下代码片段展示了这一点:

#include <string.h>
int main(int argc, char** argv) {
  char str[10];
  strcpy(str, "akjsdhkhqiueryo34928739r27yeiwuyfiusdciuti7twe79ye");
  return 0;
}

代码框 5-1:缓冲区溢出情况。strcpy 函数将覆盖栈的内容

当运行前面的代码时,程序很可能会崩溃。这是因为strcpy正在覆盖栈的内容,或者如通常所说的,破坏栈。正如你在代码框 5-1中看到的,str数组有10个字符,但strcpy正在向str数组写入超过 10 个字符。正如你很快就会看到的,这实际上是在写入之前推入的变量和栈帧,程序在从main函数返回后会跳转到错误的指令。这最终使得程序无法继续执行。

希望前面的例子已经帮助你理解了栈段的微妙之处。在本章的前半部分,我们将更深入地研究栈,并对其进行仔细检查。我们首先从探测栈开始。

探测栈

在了解更多关于栈的信息之前,我们需要能够读取它,也许还能修改它。正如前一章所述,栈段是只有所有者进程才有权读取和修改的私有内存。如果我们打算读取栈或更改它,我们需要成为拥有栈的进程的一部分。

这就是一套新工具的用武之地:调试器。调试器是一种程序,它可以附加到另一个进程上以对其进行调试。在调试进程时,一个常见的任务就是观察和操作各种内存段。只有在调试进程时,我们才能读取和修改私有内存块。作为调试的一部分,还可以控制程序指令的执行顺序。在本节中,我们将通过示例展示如何使用调试器来完成这些任务。

让我们从例子开始。在示例 5.1中,我们展示了如何编译程序并使其准备好进行调试。然后,我们演示了如何使用gdb(GNU 调试器)来运行程序并读取栈内存。此示例声明了一个在栈顶分配的字符数组,并用一些字符填充其元素,如下面的代码框所示:

#include <stdio.h>
int main(int argc, char** argv) {
  char arr[4];
  arr[0] = 'A';
  arr[1] = 'B';
  arr[2] = 'C';
  arr[3] = 'D';
  return 0;
}

代码框 5-2 [ExtremeC_examples_chapter5_1.c]:在栈顶分配的数组声明

程序简单易懂,但内存内部发生的事情很有趣。首先,arr数组所需的内存是从栈中分配的,因为它不是从堆段分配的,我们没有使用malloc函数。记住,栈段是分配变量和数组的默认位置。

为了从堆中分配一些内存,应该通过调用malloc或其他类似函数来获取它,例如calloc。否则,内存将从栈中分配,更确切地说,是在栈顶。

为了能够调试一个程序,二进制文件必须为调试目的而构建。这意味着我们必须告诉编译器我们想要一个包含调试 符号的二进制文件。这些符号将用于找到正在执行的代码行或导致崩溃的代码行。让我们编译example 5.1并创建一个包含调试符号的可执行目标文件。

首先,我们构建示例。我们在 Linux 环境中进行编译:

$ gcc -g ExtremeC_examples_chapter5_1.c -o ex5_1_dbg.out
$

Shell Box 5-1:使用调试选项-g 编译 example 5.1

-g选项告诉编译器最终的可执行目标文件必须包含调试信息。当使用和不使用调试选项编译源代码时,二进制文件的大小也会不同。接下来,你可以看到两个可执行目标文件大小的差异,第一个是未使用-g选项构建的,第二个是使用-g选项构建的:

$ gcc ExtremeC_examples_chapter2_10.c -o ex5_1.out
$ ls -al ex5_1.out
-rwxrwxr-x 1 kamranamini kamranamini 8640 jul 24 13:55 ex5_1.out
$ gcc -g ExtremeC_examples_chapter2_10.c -o ex5_1_dbg.out
$ ls -al ex5_1.out
-rwxrwxr-x 1 kamranamini kamranamini 9864 jul 24 13:56 ex5_1_dbg.out
$

Shell Box 5-2:带有和不带有-g选项的输出可执行目标文件的大小

现在我们有一个包含调试符号的可执行文件,我们可以使用调试器来运行程序。在这个例子中,我们将使用gdb来调试example 5.1。接下来,你可以找到启动调试器的命令:

$ gdb ex5_1_dbg.out

Shell Box 5-3:启动 example 5.1 的调试器

注意

gdb通常作为build-essentials包的一部分安装在 Linux 系统上。在 macOS 系统上,可以使用brew包管理器安装,如下所示:brew install gdb

运行调试器后,输出将类似于以下 Shell Box:

$ gdb ex5_1_dbg.out
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later http://gnu.org/licenses/gpl.html
...
Reading symbols from ex5_1_dbg.out...done.
(gdb)

Shell Box 5-4:启动调试器后的输出

如你所注意到的,我已在 Linux 机器上运行了前面的命令。gdb有一个命令行界面,允许你发出调试命令。输入r(或run)命令以执行调试器指定的可执行目标文件。以下 Shell Box 显示了run命令如何执行程序:

...
Reading symbols from ex5_1_dbg.out...done.
(gdb) run
Starting program: .../extreme_c/5.1/ex5_1_dbg.out
[Inferior 1 (process 9742) exited normally]
(gdb)

Shell Box 5-5:发出run命令后调试器的输出

在前面的 Shell Box 中,在发出run命令后,gdb已经启动了进程,附加到它上面,并让程序执行其指令并退出。它没有中断程序,因为我们没有设置断点。断点是一个指示器,告诉gdb暂停程序执行并等待进一步的指令。你可以设置任意多的断点。

接下来,我们使用b(或break)命令在main函数上设置断点。设置断点后,当程序进入main函数时,gdb会暂停执行。以下 Shell Box 显示了如何在main函数上设置断点:

(gdb) break main
Breakpoint 1 at 0x400555: file ExtremeC_examples_chapter5_1.c, line 4.
(gdb)

Shell Box 5-6:在 gdb 中设置 main 函数的断点

现在,我们再次运行程序。这会创建一个新的进程,并且gdb会附加到它上面。接下来,你可以找到结果:

(gdb) r
Starting program: .../extreme_c/5.1/ex5_1_dbg.out
Breakpoint 1, main (argc=1, argv=0x7fffffffcbd8) at ExtremeC_examples_chapter5_1.c:3
3       int main(int argc, char** argv) {
(gdb)

Shell Box 5-7:设置断点后再次运行程序

正如你所看到的,执行暂停在第 3 行,这正是 main 函数的行。然后,调试器等待下一个命令。现在,我们可以要求 gdb 运行下一行代码并再次暂停。换句话说,我们逐行逐行地运行程序。这样,你有足够的时间四处查看并检查内存中变量及其值。实际上,这是我们用来探测堆栈和堆段的技巧。

在下面的 Shell Box 中,你可以看到如何使用 n(或 next)命令来运行下一行代码:

(gdb) n
5         arr[0] = 'A';
(gdb) n
6         arr[1] = 'B';
(gdb) next
7        arr[2] = 'C';
(gdb) next
8        arr[3] = 'D';
(gdb) next
9        return 0;
(gdb)

Shell Box 5-8:使用 n(或 next)命令执行即将到来的代码行

现在,如果你在调试器中输入 print arr 命令,它将显示数组的内容作为一个字符串:

(gdb) print arr
$1 = "ABCD"
(gdb)

Shell Box 5-9:使用 gdb 打印 arr 数组的内容

为了回到主题,我们介绍了 gdb 以能够查看堆栈内存。现在,我们可以做到了。我们有一个具有堆栈段的进程,它是暂停的,并且我们有一个 gdb 命令行来探索其内存。让我们开始并打印 arr 数组分配的内存:

(gdb) x/4b arr
0x7fffffffcae0: 0x41    0x42    0x43    0x44
(gdb) x/8b arr
0x7fffffffcae0: 0x41    0x42    0x43    0x44    0xff    0x7f    0x00    0x00
(gdb)

Shell Box 5-10:从 arr 数组开始打印内存字节

第一条命令 x/4b 显示了从 arr 所指向的位置开始的 4 个字节。记住,arr 是一个指针,实际上它指向数组的第一个元素,因此它可以用来在内存中移动。

第二条命令 x/8barr 之后打印 8 个字节。根据为 example 5.1 编写的代码,并在 Code Box 5-2 中找到,值 ABCD 存储在数组 arr 中。你应该知道,ASCII 值存储在数组中,而不是真正的字符。A 的 ASCII 值是十进制的 65 或十六进制的 0x41。对于 B,它是 660x42。正如你所看到的,gdb 输出中打印的值就是我们刚刚存储在 arr 数组中的值。

第二条命令中的其他 4 个字节是什么?它们是堆栈的一部分,并且它们可能包含在调用 main 函数时放在堆栈顶部的最近堆栈帧中的数据。

注意,与其他段相比,堆栈段是以相反的方式填充的。

其他内存区域是从较小的地址开始填充,并向前移动到较大的地址,但堆栈段的情况并非如此。

堆栈段是从较大的地址开始填充,并向后移动到较小的地址。这种设计背后的原因部分在于现代计算机的开发历史,部分在于堆栈段的功能,它表现得像一个堆栈数据结构。

说了这么多,如果你像我们在Shell Box 5-10中做的那样,从地址段向更大的地址读取 Stack 段,你实际上是在将已推入的内容作为 Stack 段的一部分来读取,如果你尝试更改这些字节,你就是在更改 Stack,这是不好的。我们将在未来的段落中演示为什么这是危险的以及如何做到这一点。

为什么我们能看到比arr数组大小更多的内容?因为gdb会遍历我们请求的内存中的字节数。x命令不关心数组的边界。它只需要一个起始地址和要打印的字节数。

如果你想要更改 Stack 中的值,你必须使用set命令。这允许你修改现有的内存单元。在这种情况下,内存单元指的是arr数组中的单个字节:

(gdb) x/4b arr
0x7fffffffcae0: 0x41    0x42    0x43    0x44
(gdb) set arr[1] = 'F'
(gdb) x/4b arr
0x7fffffffcae0: 0x41    0x46    0x43    0x44
(gdb) print arr
$2 = "AFCD"
(gdb)

Shell Box 5-11:使用 set 命令更改数组中的单个字节

如你所见,使用set命令,我们已经将arr数组的第二个元素设置为F。如果你打算更改不在你的数组边界内的地址,仍然可以通过gdb来实现。

请仔细观察以下修改。现在,我们想要修改一个位于比arr大得多的地址的字节,正如我们之前解释的,我们将更改 Stack 中已推入的内容。记住,Stack 内存的填充方式与其他段相反:

(gdb) x/20x arr
0x7fffffffcae0: 0x41    0x42    0x43    0x44    0xff    0x7f    0x00    0x00
0x7fffffffcae8: 0x00    0x96    0xea    0x5d    0xf0    0x31    0xea    0x73
0x7fffffffcaf0: 0x90    0x05    0x40    0x00
(gdb) set *(0x7fffffffcaed) = 0xff
(gdb) x/20x arr
0x7fffffffcae0: 0x41    0x42    0x43    0x44    0xff    0x7f    0x00    0x00
0x7fffffffcae8: 0x00    0x96    0xea    0x5d    0xf0    0xff    0x00    0x00
0x7fffffffcaf0: 0x00    0x05    0x40    0x00
(gdb)

Shell Box 5-12:在数组边界之外更改单个字节

那就是全部。我们只是在0x7fffffffcaed地址中写入了值0xff,这个地址超出了arr数组的边界,可能是在进入main函数之前推入的栈帧中的某个字节。

如果我们继续执行会发生什么?如果我们修改了 Stack 中的关键字节,我们预计会看到崩溃,或者至少通过某种机制检测到这种修改,并使程序执行停止。c(或continue)命令将在gdb中继续进程的执行,正如你接下来可以看到的:

(gdb) c
Continuing.
*** stack smashing detected ***: .../extreme_c/5.1/ex5_1_dbg.out terminated
Program received signal SIGABRT, Aborted.
0x00007ffff7a42428 in __GI_raise (sig=sig@entry=6) at ../sysdeps/Unix/sysv/linux/raise.c:54
54      ../sysdeps/Unix/sysv/linux/raise.c: No such file or directory.
(gdb)

Shell Box 5-13:在 Stack 中更改关键字节会终止进程

如前所述的 shell box 所示,我们刚刚破坏了 Stack!在未分配给你的地址中修改 Stack 的内容,即使只修改 1 个字节,也可能非常危险,通常会导致崩溃或突然终止。

正如我们之前所说的,与程序执行相关的许多重要过程都是在 Stack 内存中完成的。因此,你在写入 Stack 变量时应该非常小心。你不应该在变量和数组的定义边界之外写入任何值,仅仅因为 Stack 内存中的地址是向后增长的,这使得覆盖已写入的字节变得很可能会发生。

当你完成调试,准备离开gdb时,你可以简单地使用命令q(或quit)。现在,你应该已经离开了调试器,回到了终端。

另外需要注意的是,将未经检查的值写入在栈顶(而非堆)分配的缓冲区(字节数组或字符数组的另一种称呼)被视为一个漏洞。攻击者可以精心设计一个字节数组并将其提供给程序,以控制程序。这通常被称为漏洞利用,因为它涉及到缓冲区溢出攻击。

以下程序展示了这个漏洞:

int main(int argc, char** argv) {
  char str[10];
  strcpy(str, argv[1]);
  printf("Hello %s!\n", str);
}

代码框 5-3:展示缓冲区溢出漏洞的程序

前面的代码没有检查argv[1]输入的内容和大小,直接将其复制到在栈顶分配的str数组中。

如果你很幸运,这可能导致崩溃,但在一些罕见但危险的情况下,这可能导致漏洞利用攻击。

使用栈内存的要点

现在你对栈段及其工作原理有了更好的理解,我们可以讨论最佳实践和你应该注意的要点。你应该熟悉作用域的概念。每个栈变量都有自己的作用域,作用域决定了变量的生命周期。这意味着栈变量在其生命周期开始于一个作用域,并在该作用域消失时结束。换句话说,作用域决定了栈变量的生命周期。

我们对栈变量也有自动的内存分配和释放,这仅适用于栈变量。这个特性,自动内存管理,来源于栈段的本质。

每次你声明一个栈变量时,它都会被分配在栈段顶部。分配是自动发生的,这可以标记为其生命周期的开始。在此之后,许多更多的变量和其他栈帧被放在栈的顶部。只要变量存在于栈中,并且有其他变量在其上方,它就会存活并继续存在。

然而,这些内容最终会从栈中弹出,因为将来某个时刻程序必须结束,此时栈应该是空的。因此,在未来的某个时刻,这个变量应该从栈中弹出。因此,释放或弹出是自动发生的,这可以标记为变量生命周期的结束。这基本上是我们说我们对栈变量具有自动内存管理,这种管理不由程序员控制的原因。

假设你在main函数中定义了一个变量,如下面的代码框所示:

int main(int argc, char** argv) {
  int a;
  ...
  return 0;
}

代码框 5-4:在栈上声明变量

这个变量将保留在栈中,直到main函数返回。换句话说,变量存在直到其作用域(main函数)有效。由于main函数是所有程序运行的函数,因此变量的生命周期几乎像一个在整个程序运行期间声明的全局变量。

它像一个全局变量,但又不完全一样,因为变量会在某个时刻从栈中弹出,而全局变量即使在main函数完成并且程序正在最终化时,其内存仍然存在。请注意,在main函数之前和之后运行了两段代码,分别是程序的引导和最终化。作为另一个注意事项,全局变量是从不同的段分配的,如数据或 BSS 段,它不像栈段那样表现。

让我们现在看看一个非常常见的错误的例子。这通常发生在编写第一个 C 程序时的业余程序员身上。它涉及到在函数内部返回局部变量的地址。

以下代码框显示了示例 5.2

int* get_integer() {
  int var = 10;
  return &var;
}
int main(int argc, char** argv) {
  int* ptr = get_integer();
  *ptr = 5;
  return 0;
}

代码框 5-5 [ExtremeC_examples_chapter5_2.c]:在栈顶声明一个变量

get_integer函数返回一个指向在get_integer函数作用域内声明的局部变量var的地址。get_integer函数返回局部变量的地址。然后,main函数尝试解引用接收到的指针并访问其后的内存区域。以下是在 Linux 系统上编译上述代码时gcc编译器的输出:

$ gcc ExtremeC_examples_chapter5_2.c -o ex5_2.out
ExtremeC_examples_chapter5_2.c: In function 'get_integer':
ExtremeC_examples_chapter5_2.c:3:11: warning: function returns address of local variable [-Wreturn-local-addr]
   return &var;
          ^~~~
$

Shell Box 5-14:在 Linux 中编译示例 5.2

如您所见,我们收到了一个警告消息。由于返回局部变量的地址是一个常见的错误,编译器已经知道这一点,并且会显示一个清晰的警告消息,如warning: function returns address of a local variable

这就是程序执行时发生的情况:

$ ./ex5_2.out
Segmentation fault (core dumped)
$

Shell Box 5-15:在 Linux 中执行示例 5.2

如您在Shell Box 5-15中看到的那样,发生了段错误。它可以被翻译为崩溃。这通常是因为对某个在某个时刻已分配但现在已经取消分配的内存区域的无效访问。

注意

应该将一些警告视为错误。例如,前面的警告应该是一个错误,因为它通常会导致崩溃。如果您想将所有警告都视为错误,只需将-Werror选项传递给gcc编译器即可。如果您只想将一个特定的警告视为错误,例如前面的警告,只需传递-Werror=return-local-addr选项即可。

如果您使用gdb运行程序,您将看到有关崩溃的更多详细信息。但请记住,您需要使用-g选项编译程序,否则gdb不会那么有帮助。

如果你打算使用gdb或其他调试工具(如valgrind)来调试程序,那么始终必须使用-g选项编译源代码。以下 shell 窗口演示了如何在调试器中编译和运行示例 5.2

$ gcc -g ExtremeC_examples_chapter5_2.c -o ex5_2_dbg.out
ExtremeC_examples_chapter5_2.c: In function 'get_integer':
ExtremeC_examples_chapter5_2.c:3:11: warning: function returns address of local variable [-Wreturn-local-addr]
   return &var;
          ^~~~
$ gdb ex5_2_dbg.out
GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
...
Reading symbols from ex5_2_dbg.out...done.
(gdb) run
Starting program: .../extreme_c/5.2/ex5_2_dbg.out
Program received signal SIGSEGV, Segmentation fault.
0x00005555555546c4 in main (argc=1, argv=0x7fffffffdf88) at ExtremeC_examples_chapter5_2.c:8
8    *ptr = 5;
(gdb) quit
$

Shell Box 5-16:在调试器中运行示例 5.2

gdb输出所示,崩溃的来源位于main函数的第 8 行,正好是程序尝试通过解引用返回的指针来写入返回地址的地方。但是,var变量已经成为get_integer函数的局部变量,并且它不再存在,仅仅因为我们在第 8 行已经从get_integer函数及其作用域返回,以及所有变量,都已经消失。因此,返回的指针是一个悬垂指针

通常,将指向当前作用域内变量的指针传递给其他函数,而不是相反,是一种常见的做法,因为只要当前作用域有效,变量就在那里。进一步的函数调用只会将更多东西放在栈段顶部,并且当前作用域不会在它们之前结束。

注意,上述关于并发程序的说法并不是一个好的实践,因为在将来,如果另一个并发任务想要使用指向当前作用域内变量的接收到的指针,当前作用域可能已经不存在了。

为了结束这一节,并对栈段得出结论,我们可以从到目前为止所解释的内容中提取以下要点:

  • 栈内存的大小有限;因此,它不是存储大对象的好地方。

  • 栈段的地址向后增长,因此,在栈内存中向前读取意味着读取已经推入的字节。

  • 栈具有自动内存管理,包括分配和释放。

  • 每个栈变量都有一个作用域,它决定了其生命周期。你应该根据这个生命周期来设计你的逻辑。你无法控制它。

  • 指针应该只指向那些仍然在作用域内的栈变量。

  • 当作用域即将结束时,栈变量的内存释放是自动完成的,你无法控制它。

  • 只有当我们确信当前作用域在调用函数中的代码即将使用该指针时仍然存在时,才能将指向当前作用域内变量的指针作为参数传递给其他函数。在具有并发逻辑的情况下,这种条件可能会被打破。

在下一节中,我们将讨论堆段及其各种特性。

几乎任何编程语言编写的代码都会以某种方式使用堆内存。这是因为堆有一些独特的优势,这些优势是使用栈无法实现的。

另一方面,它也有一些缺点;例如,与栈内存中的类似区域相比,分配堆内存区域要慢。

在本节中,我们将更详细地讨论堆本身以及在使用堆内存时应注意的指南。

堆内存之所以重要,是因为它具有独特的属性。并非所有这些属性都是有益的,事实上,其中一些可以被视为应该减轻的风险。一个伟大的工具总有优点和缺点,如果你要正确使用它,你必须非常了解这两方面。

在这里,我们将列出这些特性,并看看哪些是有益的,哪些是有风险的:

  1. 堆中没有自动分配的内存块。相反,程序员必须使用malloc或类似函数逐个获取堆内存块。实际上,这可以被视为栈内存的弱点,而堆内存则解决了这个问题。栈内存可以包含栈帧,这些栈帧不是由程序员分配和推送的,而是由于函数调用以自动方式产生的。

  2. 堆具有很大的内存大小。虽然栈的大小有限,并且不适合存储大对象,但堆允许存储非常大的对象,甚至可以存储数十个 GB 大小的对象。随着堆大小的增长,分配器需要从操作系统请求更多的堆页面,堆内存块在这些页面之间分散。请注意,与栈段不同,堆内存中的分配地址是向前移动到更大的地址。

  3. 堆内存中的内存分配和释放由程序员管理。这意味着程序员是唯一负责分配内存并在不再需要时释放它的实体。在许多现代编程语言中,释放分配的堆块是由一个称为垃圾回收器的并行组件自动完成的。但在 C 和 C++中,我们没有这样的概念,释放堆块应该手动完成。这确实是一种风险,C/C++程序员在使用堆内存时应该非常小心。未能释放分配的堆块通常会导致内存泄漏,这在大多数情况下都是致命的。

  4. 从堆中分配的变量没有作用域,这与栈中的变量不同。

  5. 这是一种风险,因为它使得内存管理变得更加困难。你不知道何时需要释放变量,你必须提出一些新的定义来有效地进行内存管理,包括内存块的作用域所有者。一些这些方法将在接下来的章节中介绍。

  6. 我们只能使用指针来访问堆内存块。换句话说,没有所谓的堆变量。堆区域是通过指针来访问的。

  7. 由于堆段对其所有者进程是私有的,我们需要使用调试器来探测它。幸运的是,C 指针与堆内存块的工作方式与与栈内存块的工作方式完全相同。C 在这方面做得很好,因此我们可以使用相同的指针来访问这两种内存。因此,我们可以使用检查栈的方法来探测堆内存。

在下一节中,我们将讨论如何分配和释放堆内存块。

堆内存分配和释放

正如我们在上一节中说的,堆内存应该手动获取和释放。这意味着程序员应该使用一组函数或 API(C 标准库的内存分配函数)来在堆中分配或释放内存块。

这些函数确实存在,并且它们在头文件stdlib.h中定义。用于获取堆内存块的函数有malloccallocrealloc,而用于释放堆内存块的唯一函数是free示例 5.3 展示了如何使用这些函数中的一些。

注意

在某些文本中,动态内存被用来指代堆内存。动态内存分配是堆内存分配的同义词。

以下代码框显示了 示例 5.3 的源代码。它分配了两个堆内存块,然后打印出其内存映射:

#include <stdio.h>  // For printf function
#include <stdlib.h> // For C library's heap memory functions
void print_mem_maps() {
#ifdef __linux__
  FILE* fd = fopen("/proc/self/maps", "r");
  if (!fd) {
    printf("Could not open maps file.\n");
    exit(1);
  }
  char line[1024];
  while (!feof(fd)) {
    fgets(line, 1024, fd);
    printf("> %s", line);
  }
  fclose(fd);
#endif
}
int main(int argc, char** argv) {
  // Allocate 10 bytes without initialization
  char* ptr1 = (char*)malloc(10 * sizeof(char));
  printf("Address of ptr1: %p\n", (void*)&ptr1);
  printf("Memory allocated by malloc at %p: ", (void*)ptr1);
  for (int i = 0; i < 10; i++) {
    printf("0x%02x ", (unsigned char)ptr1[i]);
  }
  printf("\n");
  // Allocation 10 bytes all initialized to zero
  char* ptr2 = (char*)calloc(10, sizeof(char));
  printf("Address of ptr2: %p\n", (void*)&ptr2);
  printf("Memory allocated by calloc at %p: ", (void*)ptr2);
  for (int i = 0; i < 10; i++) {
    printf("0x%02x ", (unsigned char)ptr2[i]);
  }
  printf("\n");
  print_mem_maps();
  free(ptr1);
  free(ptr2);
  return 0;
}

代码框 5-6 [ExtremeC_examples_chapter5_3.c]: 分配两个堆内存块后的内存映射示例 5.3

前面的代码是跨平台的,您可以在大多数类 Unix 操作系统上编译它。但是,print_mem_maps函数仅在 Linux 上工作,因为__linux__宏仅在 Linux 环境中定义。因此,在 macOS 上,您可以编译代码,但print_mem_maps函数不会做任何事情。

以下 shell 框是 Linux 环境中运行示例的结果:

$ gcc ExtremeC_examples_chapter5_3.c -o ex5_3.out
$ ./ex5_3.out
Address of ptr1: 0x7ffe0ad75c38
Memory allocated by malloc at 0x564c03977260: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 
Address of ptr2: 0x7ffe0ad75c40
Memory allocated by calloc at 0x564c03977690: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 
> 564c01978000-564c01979000 r-xp 00000000 08:01 5898436                    /home/kamranamini/extreme_c/5.3/ex5_3.out
> 564c01b79000-564c01b7a000 r--p 00001000 08:01 5898436                    /home/kamranamini/extreme_c/5.3/ex5_3.out
> 564c01b7a000-564c01b7b000 rw-p 00002000 08:01 5898436                    /home/kamranamini/extreme_c/5.3/ex5_3.out
> 564c03977000-564c03998000 rw-p 00000000 00:00 0           [heap]
> 7f31978ec000-7f3197ad3000 r-xp 00000000 08:01 5247803     /lib/x86_64-linux-gnu/libc-2.27.so
...
> 7f3197eef000-7f3197ef1000 rw-p 00000000 00:00 0 
> 7f3197f04000-7f3197f05000 r--p 00027000 08:01 5247775     /lib/x86_64-linux-gnu/ld-2.27.so
> 7f3197f05000-7f3197f06000 rw-p 00028000 08:01 5247775     /lib/x86_64-linux-gnu/ld-2.27.so
> 7f3197f06000-7f3197f07000 rw-p 00000000 00:00 0 
> 7ffe0ad57000-7ffe0ad78000 rw-p 00000000 00:00 0           [stack]
> 7ffe0adc2000-7ffe0adc5000 r--p 00000000 00:00 0           [vvar]
> 7ffe0adc5000-7ffe0adc7000 r-xp 00000000 00:00 0           [vdso]
> ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0   [vsyscall]
$

shell 框 5-17:Linux 环境中示例 5.3 的输出

前面的输出有很多要说的。程序打印了指针ptr1ptr2的地址。如果您在打印的内存映射中找到栈段的内存映射,您会看到栈区域从0x7ffe0ad57000开始,到0x7ffe0ad78000结束。指针位于这个范围内。

这意味着指针是从栈中分配的,但它们指向栈段之外的一个内存区域,在这种情况下,是堆段。使用栈指针来访问堆内存块是非常常见的。

请记住,ptr1ptr2指针具有相同的范围,它们将在main函数返回时被释放,但堆内存块没有范围。它们将保留分配状态,直到程序手动释放它们。您可以看到,在从main函数返回之前,使用指向它们的指针和free函数释放了两个内存块。

关于上述示例的进一步说明,我们可以看到malloccalloc函数返回的地址位于堆段内部。这可以通过比较返回的地址和描述为[heap]的内存映射来调查。标记为堆的区域从0x564c03977000开始,到0x564c03998000结束。ptr1指针指向地址0x564c03977260,而ptr2指针指向地址0x564c03977690,它们都在堆区域内部。

关于堆分配函数,正如它们的名称所暗示的,calloc代表清除并分配,而malloc代表内存分配。这意味着calloc在分配后清除内存块,而malloc则将其保留为未初始化状态,直到程序在必要时自行初始化。

注意

在 C++中,newdelete关键字分别与mallocfree相同。此外,新操作符从操作数类型推断分配的内存块的大小,并自动将返回的指针转换为操作数类型。

但如果你查看两个分配的块中的字节,它们都有零字节。所以,看起来malloc在分配后也初始化了内存块。但根据 C 规范中malloc的描述,malloc不会初始化分配的内存块。那么,这是为什么?为了进一步探讨,让我们在 macOS 环境中运行示例:

$ clang ExtremeC_examples_chapter5_3.c -o ex5_3.out
$ ./ ex5_3.out
Address of ptr1: 0x7ffee66b2888
Memory allocated by malloc at 0x7fc628c00370: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x80 0x00 0x00
Address of ptr2: 0x7ffee66b2878
Memory allocated by calloc at 0x7fc628c02740: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
$

Shell 框 5-18:在 macOS 上示例 5.3 的输出

如果你仔细观察,你可以看到malloc分配的内存块中有一些非零字节,但calloc分配的内存块全部为零。那么,我们应该怎么做?我们应该假设 Linux 中malloc分配的内存块总是零吗?

如果你打算编写一个跨平台程序,始终要与 C 规范保持一致。规范说明malloc不会初始化分配的内存块。

即使你只为 Linux 编写程序,而不是为其他操作系统编写,也要注意未来的编译器可能会有不同的行为。因此,根据 C 规范,我们必须始终假设由malloc分配的内存块未初始化,如果需要,应手动初始化。

注意,由于malloc不会初始化分配的内存,它通常比calloc更快。在某些实现中,malloc实际上不会分配内存块,而是在内存块被访问(无论是读取还是写入)时才延迟分配。这样,内存分配会更快。

如果你打算在malloc之后初始化内存,可以使用memset函数。以下是一个示例:

#include <stdlib.h> // For malloc
#include <string.h> // For memset
int main(int argc, char** argv) {
  char* ptr = (char*)malloc(16 * sizeof(char));
  memset(ptr, 0, 16 * sizeof(char));    // Fill with 0
  memset(ptr, 0xff, 16 * sizeof(char)); // Fill with 0xff
  ...
  free(ptr);
  return 0;
}

代码框 5-7:使用memset函数初始化内存块

realloc函数是作为堆分配函数的一部分引入的另一个函数。它没有在example 5.3中使用。实际上,它通过调整已分配内存块的大小来重新分配内存。以下是一个例子:

int main(int argc, char** argv) {
  char* ptr = (char*)malloc(16 * sizeof(char));
  ...
  ptr = (char*)realloc(32 * sizeof(char));
  ...
  free(ptr);
  return 0;
}

代码框 5-8:使用 realloc 函数改变已分配块的尺寸

realloc函数不会更改旧块中的数据,而只是将已分配的块扩展到新块。如果由于碎片化无法扩展当前分配的块,它将找到另一个足够大的块,并将旧块中的数据复制到新块中。在这种情况下,它也会释放旧块。正如你所看到的,在某些情况下,重新分配并不是一个便宜的操作,因为它涉及许多步骤,因此应该谨慎使用。

关于example 5.3的最后一点是关于free函数的。实际上,它通过传递块地址作为指针来释放已经分配的堆内存块。正如之前所说,任何已分配的堆块在不再需要时都应该被释放。未能这样做会导致内存泄漏。使用一个新的例子,example 5.4,我们将向您展示如何使用valgrind工具检测内存泄漏。

让我们先在example 5.4中产生一些内存泄漏:

#include <stdlib.h> // For heap memory functions
int main(int argc, char** argv) {
  char* ptr = (char*)malloc(16 * sizeof(char));
  return 0;
}

代码框 5-9:在从 main 函数返回时未释放分配的块产生内存泄漏

前一个程序存在内存泄漏,因为当程序结束时,我们分配了16字节堆内存但没有释放。这个例子非常简单,但当源代码增长并且涉及更多组件时,通过肉眼检测它就会变得非常困难,甚至不可能。

内存分析器是有用的程序,可以检测运行中的进程中的内存问题。著名的valgrind工具是最为人所知的之一。

为了使用valgrind分析example 5.4,首先我们需要使用调试选项-g构建示例。然后,我们应该使用valgrind运行它。在运行给定的可执行目标文件时,valgrind记录所有的内存分配和释放。最后,当执行完成或发生崩溃时,valgrind会打印出分配和释放的摘要以及未释放的内存量。这样,它可以让你知道在给定程序的执行过程中产生了多少内存泄漏。

下面的 shell box 演示了如何编译和使用valgrind来分析example 5.4

$ gcc -g ExtremeC_examples_chapter5_4.c -o ex5_4.out
$ valgrind ./ex5_4.out
==12022== Memcheck, a memory error detector
==12022== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==12022== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==12022== Command: ./ex5_4.out
==12022== 
==12022== 
==12022== HEAP SUMMARY:
==12022==     in use at exit: 16 bytes in 1 blocks
==12022==   total heap usage: 1 allocs, 0 frees, 16 bytes allocated
==12022== 
==12022== LEAK SUMMARY:
==12022==    definitely lost: 16 bytes in 1 blocks
==12022==    indirectly lost: 0 bytes in 0 blocks
==12022==      possibly lost: 0 bytes in 0 blocks
==12022==    still reachable: 0 bytes in 0 blocks
==12022==         suppressed: 0 bytes in 0 blocks
==12022== Rerun with --leak-chck=full to see details of leaked memory
==12022== 
==12022== For counts of detected and suppressed errors, rerun with: -v
==12022== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$

Shell Box 5-19:valgrind 输出的输出,显示了作为 example 5.4 执行一部分的 16 字节内存泄漏

如果你查看Shell Box 5-19中的HEAP SUMMARY部分,你可以看到我们进行了1次分配和0次释放,并且在退出时还保留了16字节的分配。如果你向下滚动一点到LEAK SUMMARY部分,它指出16字节肯定丢失了,这意味着存在内存泄漏!

如果您想确切知道提到的泄漏内存块是在哪一行分配的,您可以使用专门为此设计的valgrind特殊选项。在下面的 shell 框中,您将看到如何使用valgrind找到实际分配的责任行:

$ gcc -g ExtremeC_examples_chapter5_4.c -o ex5_4.out
$ valgrind --leak-check=full ./ex5_4.out
==12144== Memcheck, a memory error detector
==12144== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==12144== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==12144== Command: ./ex5_4.out
==12144== 
==12144== 
==12144== HEAP SUMMARY:
==12144==     in use at exit: 16 bytes in 1 blocks
==12144==   total heap usage: 1 allocs, 0 frees, 16 bytes allocated
==12144== 
==12144== 16 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12144==    at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12144==    by 0x108662: main (ExtremeC_examples_chapter5_4.c:4)
==12144== 
==12144== LEAK SUMMARY:
==12144==    definitely lost: 16 bytes in 1 blocks
==12144==    indirectly lost: 0 bytes in 0 blocks
==12144==      possibly lost: 0 bytes in 0 blocks
==12144==    still reachable: 0 bytes in 0 blocks
==12144==         suppressed: 0 bytes in 0 blocks
==12144== 
==12144== For counts of detected and suppressed errors, rerun with : -v
==12144== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
$

Shell Box 5-20:valgrind 输出的实际分配责任行的输出

如您所见,我们已经将--leak-check=full选项传递给了valgrind,现在它显示了负责泄漏堆内存的代码行。它清楚地显示了代码框 5-9中的第 4 行,这是一个malloc调用,泄漏的堆块就是在这里分配的。这可以帮助您进一步追踪并找到应该释放提到的泄漏块的正确位置。

好的,让我们修改前面的示例,使其释放分配的内存。我们只需要在return语句之前添加free(ptr)指令,就像我们在这里看到的那样:

#include <stdlib.h> // For heap memory functions
int main(int argc, char** argv) {
  char* ptr = (char*)malloc(16 * sizeof(char));
  free(ptr);
  return 0;
}

代码框 5-10:作为示例 5.4 的一部分释放分配的内存块

现在经过这个修改,唯一的分配堆块已经被释放。让我们再次构建并运行valgrind

$ gcc -g ExtremeC_examples_chapter5_4.c -o ex5_4.out
$ valgrind --leak-check=full ./ex5_4.out
==12175== Memcheck, a memory error detector
==12175== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==12175== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==12175== Command: ./ex5_4.out
==12175== 
==12175== 
==12175== HEAP SUMMARY:
==12175==     in use at exit: 0 bytes in 0 blocks
==12175==   total heap usage: 1 allocs, 1 frees, 16 bytes allocated
==12175== 
==12175== All heap blocks were freed -- no leaks are possible
==12175== 
==12175== For counts of detected and suppressed errors, rerun with  -v
==12175== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$

Shell Box 5-20:释放分配的内存块后的 valgrind 输出

如您所见,valgrind表示“所有堆块都已释放”,这实际上意味着我们的程序中没有进一步的内存泄漏。使用valgrind运行程序可能会将它们的速度降低 10 到 50 倍,但它可以帮助您非常容易地发现内存问题。让您的程序在内存分析器中运行并尽快捕获内存泄漏是一个好习惯。

内存泄漏可以被视为技术债务,如果您有一个导致泄漏的糟糕设计,或者作为风险,我们知道我们有一个泄漏,但我们不知道如果泄漏继续增长会发生什么。但在我看来,它们应该被视为错误;否则,您需要一段时间才能回顾它们。通常,在团队中,内存泄漏被视为应该尽快修复的错误。

除了valgrind之外,还有其他内存分析器。LLVM Address Sanitizer(或ASAN)和MemProf也是其他知名的内存分析器。内存分析器可以使用各种方法来分析内存使用和分配。接下来,我们将讨论其中的一些:

  • 一些分析器可以像沙盒一样运行,在沙盒内运行目标程序并监控所有内存活动。我们已经使用这种方法在valgrind沙盒中运行了示例 5.4。这种方法不需要您重新编译代码。

  • 另一种方法是使用一些内存分析器提供的库,这些库包装了内存相关的系统调用。这样,最终的二进制文件将包含用于分析任务的全部逻辑。

    valgrind和 ASAN 可以作为内存分析库链接到最终的可执行对象文件。这种方法需要重新编译你的目标源代码,甚至需要对源代码进行一些修改。

  • 程序也可以预加载不同的库,而不是默认的 C 标准库,这些库包含 C 库标准内存分配函数的函数替换。这样,你不需要编译你的目标源代码。你只需要在LD_PRELOAD环境变量中指定这样的分析库,以便预加载,而不是默认的libc库。MemProf使用这种方法。

注意

函数替换是在目标动态库之前加载的动态库中定义的包装函数,它将调用传播到目标函数。可以使用LD_PRELOAD环境变量预加载动态库。

堆内存原则

如前所述,堆内存与栈内存有几个不同之处。因此,堆内存有自己的内存管理指南。在本节中,我们将关注这些差异,并提出一些我们在处理堆空间时应考虑的“应该做”和“不应该做”的事项。

栈中的每个内存块(或变量)都有一个作用域。因此,根据其作用域定义内存块的生存期是一个简单的任务。每次我们超出作用域时,该作用域中的所有变量都会消失。但是这与堆内存不同,并且更加复杂。

堆内存块没有作用域,因此其生存期不明确,应该重新定义。这就是为什么在像 Java 这样的现代语言中,有手动释放或代际 垃圾回收的原因。堆的生存期不能由程序本身或使用的 C 库来确定,程序员是唯一定义堆内存块生存期的人。

当讨论到程序员的决策时,尤其是在这种情况下,情况变得复杂,很难提出一个通用的银弹解决方案。每个观点都是可辩论的,并且可能导致权衡。

解决堆生存期复杂性的最佳策略之一,当然这不是一个完整的解决方案,是为内存块定义一个所有者,而不是让作用域包含内存块。

所有者是唯一负责管理堆内存块生存期的实体,也是最初分配该块并在不再需要时释放它的人。

有许多经典的例子展示了如何使用这种策略。大多数知名的 C 库都使用这种策略来处理它们的堆内存分配。示例 5.5 是这种方法的一个非常简单的实现,用于管理用 C 编写的队列对象的生存期。下面的代码框试图展示所有权策略:

#include <stdio.h> // For printf function
#include <stdlib.h> // For heap memory functions
#define QUEUE_MAX_SIZE 100
typedef struct {
  int front;
  int rear;
  double* arr;
} queue_t;
void init(queue_t* q) {
  q->front = q->rear = 0;
  // The heap memory block allocated here is owned
  // by the queue object.
  q->arr = (double*)malloc(QUEUE_MAX_SIZE * sizeof(double));
}
void destroy(queue_t* q) {
  free(q->arr);
}
int size(queue_t* q) {
  return q->rear - q->front;
}
void enqueue(queue_t* q, double item) {
  q->arr[q->rear] = item;
  q->rear++;
}
double dequeue(queue_t* q) {
  double item = q->arr[q->front];
  q->front++;
  return item;
}
int main(int argc, char** argv) {
  // The heap memory block allocated here is owned
  // by the function main
  queue_t* q = (queue_t*)malloc(sizeof(queue_t));
  // Allocate needed memory for the queue object
  init(q);
  enqueue(q, 6.5);
  enqueue(q, 1.3);
  enqueue(q, 2.4);
  printf("%f\n", dequeue(q));
  printf("%f\n", dequeue(q));
  printf("%f\n", dequeue(q));
  // Release resources acquired by the queue object
  destroy(q);
  // Free the memory allocated for the queue object
  // acquired by the function main
  free(q);
  return 0;
}

代码框 5-11 [ExtremeC_examples_chapter5_5.c]:演示堆生命周期管理所有权策略的例子 5.5

前面的例子包含两种不同的所有权,每种所有权都拥有一个特定的对象。第一种所有权是关于由queue_t结构中的arr指针指向的堆内存块,该内存块由队列对象拥有。只要队列对象存在,这个内存块就必须保持在原地并分配。

第二种所有权是关于由main函数获取的堆内存块,作为队列对象q的占位符,该对象由main函数本身拥有。区分队列对象拥有的堆内存块和main函数拥有的堆内存块非常重要,因为释放其中一个并不会释放另一个。

为了演示在前面代码中内存泄漏是如何发生的,假设你忘记在队列对象上调用destroy函数。这肯定会引起内存泄漏,因为init函数内部获取的堆内存块仍然会被分配,而不会被释放。

注意,如果一个实体(一个对象、函数等)拥有一个堆内存块,应该在注释中表达出来。如果没有拥有该内存块,则不应该有任何东西释放堆内存块。

注意,对同一堆内存块进行多次释放会导致双重释放的情况。双重释放是一个内存损坏问题,就像任何其他内存损坏问题一样,应该在检测到后尽快处理和解决。否则,它可能会产生严重的后果,如突然崩溃。

除了所有权策略之外,还可以使用垃圾回收器。垃圾回收器是一种嵌入到程序中的自动机制,它试图收集没有任何指针指向它们的内存块。C 语言中一个老牌且广为人知的垃圾回收器是Boehm-Demers-Weiser 保守垃圾回收器,它提供了一组内存分配函数,应该代替malloc和其他标准 C 内存分配函数来调用。

进一步阅读

更多关于 Boehm-Demers-Weiser 垃圾回收器的信息可以在这里找到:www.hboehm.info/gc/

管理堆块生命周期的另一种技术是使用 RAII 对象。RAII代表资源获取即初始化。这意味着我们可以将资源的生命周期(可能是一个堆分配的内存块)绑定到对象的生命周期。换句话说,我们使用一个对象,在它的构造时初始化资源,在它的销毁时释放资源。不幸的是,这种技术在 C 语言中不能使用,因为我们没有得到关于对象销毁的通知。但在 C++中,使用析构函数,这种技术可以有效地使用。在 RAII 对象中,资源初始化发生在构造函数中,而用于反初始化资源的代码被放入析构函数中。请注意,在 C++中,当对象超出作用域或被删除时,析构函数会自动调用。

作为结论,当与堆内存一起工作时,以下指南非常重要:

  • 堆内存分配不是免费的,它有自己的成本。并非所有内存分配函数的成本相同,通常malloc是最便宜的。

  • 从堆空间分配的所有内存块都必须在不再需要时立即释放,或者在程序结束前释放。

  • 由于堆内存块没有作用域,程序必须能够管理内存,以避免任何可能的泄漏。

  • 对于每个堆内存块坚持使用所选的内存管理策略似乎是必要的。

  • 所选策略及其假设应该在代码中记录下来,无论在何处访问该块,以便未来的程序员了解它。

  • 在像 C++这样的某些编程语言中,我们可以使用 RAII 对象来管理资源,可能是一个堆内存块。

到目前为止,我们假设我们有足够的内存来存储大对象并运行任何类型的程序。但在下一节中,我们将对可用的内存施加一些限制,并讨论内存低或增加额外内存存储(在金钱、时间、性能等方面)成本高的环境。在这种情况下,我们需要以最有效的方式使用可用的内存。

受限环境中的内存管理

在某些环境中,内存是一种宝贵的资源,而且通常有限。也有其他环境,其中性能是一个关键因素,程序应该快速运行,不管我们有多少内存。关于内存管理,每个这样的环境都需要特定的技术来克服内存短缺和性能下降。首先,我们需要知道什么是受限环境。

限制性环境不一定具有低内存容量。通常有一些限制会限制程序的内存使用。这些限制可以是客户对内存使用的硬性限制,也可能是由于提供低内存容量的硬件,或者可能是由于不支持更大内存的操作系统(例如,MS-DOS)。

即使没有限制或硬件限制,我们作为程序员也会尽力使用尽可能少的内存,并以最优的方式使用它。内存消耗是一个项目中关键的非功能性需求之一,应该被仔细监控和调整。

在本节中,我们将首先介绍在低内存环境中用于克服短缺问题的技术,然后我们将讨论在性能环境中通常使用的内存技术,以提升运行程序的性能。

内存受限环境

在这些环境中,有限的内存总是个约束,算法应该设计成能够应对内存短缺。具有数十到数百兆字节内存大小的嵌入式系统通常属于这一类。关于这种环境下的内存管理有一些小贴士,但它们都不如有一个调校得很好的算法来得有效。在这种情况下,通常使用内存复杂度低的算法。这些算法通常具有更高的时间复杂度,需要与它们的低内存使用进行权衡。

为了更详细地说明这一点,每个算法都有特定的时间内存复杂度。时间复杂度描述了输入大小与算法完成所需时间之间的关系。同样,内存复杂度描述了输入大小与算法完成任务所消耗的内存之间的关系。这些复杂度通常用大 O 函数表示,我们不想在本节中处理这些。我们的讨论是定性的,因此我们不需要任何数学来讨论内存受限环境。

一个算法理想情况下应该具有低时间复杂度和低内存复杂度。换句话说,拥有一个快速且内存消耗低的算法是非常理想的,但这种“两者兼得”的情况很少见。同时,一个内存消耗高但性能不佳的算法也是令人意外的。

大多数时候,我们在内存和速度之间进行权衡,这代表了时间。例如,一个比另一个算法更快的排序算法通常会消耗比另一个更多的内存,尽管这两个算法都完成了相同的工作。

在编写程序时,即使我们知道最终的生产环境将有足够的内存,假设我们正在为内存受限的系统编写代码,这是一种很好的但保守的做法。我们做出这个假设是因为我们希望减轻过度消耗内存的风险。

注意,推动这个假设的动力应根据对最终设置中平均内存可用性的相当准确的估计进行控制和调整,包括大小。为内存受限环境设计的算法本质上较慢,您应该小心这个陷阱。

在接下来的章节中,我们将介绍一些可以帮助我们收集一些浪费的内存或在使用内存受限环境中使用更少内存的技术。

压缩结构

使用压缩结构是减少内存消耗的最简单方法之一。压缩结构放弃了内存对齐,并且它们有更紧凑的内存布局来存储它们的字段。

使用压缩结构实际上是一种权衡。你消耗更少的内存,因为你放弃了内存对齐,最终在加载结构变量时会有更多的内存读取时间。这将导致程序运行速度变慢。

这种方法简单,但不适用于所有程序。有关此方法的更多信息,您可以阅读第一章基本特性中找到的结构部分。

压缩

这是一种有效的技术,尤其是对于需要在内存中保留的大量文本数据的程序。与二进制数据相比,文本数据具有很高的压缩比。这种技术允许程序存储压缩形式而不是实际的文本数据,从而获得巨大的内存回报。

然而,节省内存并非没有代价;由于压缩算法是CPU 密集型的,程序最终的性能会变差。这种方法对于需要保存不常使用的文本数据的程序来说很理想;否则,需要大量的压缩/解压缩操作,程序最终几乎无法使用。

外部数据存储

使用网络服务、云基础设施或简单的硬盘作为外部数据存储形式,是一种非常常见且有用的技术,用于解决内存不足的问题。由于通常认为程序可能在有限的或内存较低的环境中运行,因此有很多示例使用这种方法,即使在有足够内存的环境中也能消耗更少的内存。

这种技术通常假设内存不是主要存储,而是作为缓存内存。另一个假设是我们不能将所有数据都保存在内存中,在任何时刻,只能将部分数据或一个页面的数据加载到内存中。

这些算法并不是直接解决低内存问题,而是在尝试解决另一个问题:慢速的外部数据存储。与主内存相比,外部数据存储总是太慢。因此,算法应该平衡从外部数据存储的读取和它们的内部内存。所有数据库服务,如 PostgreSQL 和 Oracle,都使用这种技术。

在大多数项目中,从头开始设计和编写这些算法并不是一个非常明智的选择,因为这些算法并不那么简单和容易编写。SQLite 等著名库背后的团队已经修复了多年的错误。

如果你需要在具有低内存占用的情况下访问外部数据存储,如文件、数据库或网络上的主机,总有适合你的选择。

性能环境

如我们在前几节关于算法的时间和内存复杂度的解释中所述,通常期望在想要获得更快的算法时消耗更多的内存。因此,在本节中,我们期望为了提高性能而消耗更多的内存。

这个陈述的一个直观例子是使用缓存来提高性能。缓存数据意味着消耗更多的内存,但如果缓存使用得当,我们预计可以获得更好的性能。

但增加额外的内存并不总是提高性能的最佳方式。还有其他直接或间接与内存相关的方法,可以对算法的性能产生重大影响。在跳到这些方法之前,让我们先谈谈缓存。

缓存

缓存是计算机系统中涉及不同读写速度的两个数据存储时使用的所有类似技术的通用术语。例如,CPU 有几个内部寄存器,在读写操作方面速度很快。此外,CPU 还需要从主内存中获取数据,其速度比寄存器慢得多。这里需要一个缓存机制;否则,主内存的较低速度将占主导地位,并掩盖 CPU 的高计算速度。

与数据库文件一起工作是另一个例子。数据库文件通常存储在外部硬盘上,其速度比主内存慢得多。毫无疑问,这里需要一个缓存机制;否则,最慢的速度将占主导地位,并决定整个系统的速度。

缓存及其相关细节值得有一个专门的章节,因为这里有一些抽象模型和特定的术语需要解释。

使用这些模型,可以预测缓存的表现如何以及引入缓存后可以期望获得多少性能提升。在这里,我们试图以简单直观的方式解释缓存。

假设你有一种可以包含许多项目的慢速存储。你还有一个快速的存储,但它只能包含有限数量的项目。这是一个明显的权衡。我们可以将更快但更小的存储称为缓存。如果你将项目从慢速存储带入快速存储并在此处理它们,这将是合理的,因为这样可以更快。

不时地,你必须前往慢速存储以带来更多的项目。显然,你不会只从慢速存储中带来一个项目,因为这会非常低效。相反,你将一桶项目带入更快的存储中。通常,人们会说项目被缓存到更快的存储中。

假设你正在处理一个需要从慢速存储中加载其他项目的项目。首先想到的事情是在当前缓存存储中的最近带来的桶内搜索所需的项目。

如果你能在缓存中找到项目,就没有必要从慢速存储中检索它,这被称为命中。如果项目缺失于缓存存储中,你必须前往慢速存储并读取另一个桶中的项目到缓存内存中。这被称为未命中。很明显,你观察到的命中越多,你获得的表现就越好。

上述描述可以应用于 CPU 缓存和主存储。CPU 缓存存储从主存储中读取的最近指令和数据,而与 CPU 缓存内存相比,主存储较慢。

在下一节中,我们讨论缓存友好代码,并观察为什么缓存友好代码可以由 CPU 更快地执行。

缓存友好代码

当 CPU 执行指令时,它必须首先获取所有所需的数据。数据位于由指令确定的特定地址的主存储中。

在进行任何计算之前,数据必须被传输到 CPU 寄存器。但 CPU 通常携带比预期要获取的更多块,并将它们放入其缓存中。

下次,如果需要在先前地址的附近的值,它应该存在于缓存中,CPU 可以使用缓存而不是主存储,这比从主存储中读取要快得多。正如我们在上一节中解释的,这被称为缓存命中。如果地址未在 CPU 缓存中找到,则称为缓存未命中,CPU 必须访问主存储以读取目标地址并带来所需的数据,这相当慢。一般来说,更高的命中率会导致更快的执行。

但为什么 CPU 会从地址周围的相邻地址(邻近性)中获取数据?这是因为 局部性原理。在计算机系统中,通常观察到位于相同区域的数据被更频繁地访问。因此,CPU 根据这个原理行事,并从局部引用中获取更多数据。如果一个算法可以利用这种行为,它就可以被 CPU 更快地执行。这就是我们为什么称这样的算法为 缓存友好算法

示例 5.6 展示了缓存友好代码和非缓存友好代码性能的差异:

#include <stdio.h>  // For printf function
#include <stdlib.h> // For heap memory functions
#include <string.h> // For strcmp function
void fill(int* matrix, int rows, int columns) {
  int counter = 1;
  for (int i = 0; i < rows; i++) {
    for (int j = 0; j < columns; j++) {
      *(matrix + i * columns + j) = counter;
    }
    counter++;
  }
}
void print_matrix(int* matrix, int rows, int columns) {
  int counter = 1;
  printf("Matrix:\n");
  for (int i = 0; i < rows; i++) {
    for (int j = 0; j < columns; j++) {
      printf("%d ", *(matrix + i * columns + j));
    }
    printf("\n");
  }
}
void print_flat(int* matrix, int rows, int columns) {
  printf("Flat matrix: ");
  for (int i = 0; i < (rows * columns); i++) {
    printf("%d ", *(matrix + i));
  }
  printf("\n");
}
int friendly_sum(int* matrix, int rows, int columns) {
  int sum = 0;
  for (int i = 0; i < rows; i++) {
    for (int j = 0; j < columns; j++) {
      sum += *(matrix + i * columns + j);
    }
  }
  return sum;
}
int not_friendly_sum(int* matrix, int rows, int columns) {
  int sum = 0;
  for (int j = 0; j < columns; j++) {
    for (int i = 0; i < rows; i++) {
      sum += *(matrix + i * columns + j);
    }
  }
  return sum;
}
int main(int argc, char** argv) {
  if (argc < 4) {
    printf("Usage: %s [print|friendly-sum|not-friendly-sum] ");
    printf("[number-of-rows] [number-of-columns]\n", argv[0]);
    exit(1);
  }
  char* operation = argv[1];
  int rows = atol(argv[2]);
  int columns = atol(argv[3]);
  int* matrix = (int*)malloc(rows * columns * sizeof(int));
  fill(matrix, rows, columns);
  if (strcmp(operation, "print") == 0) {
    print_matrix(matrix, rows, columns);
    print_flat(matrix, rows, columns);
  }
  else if (strcmp(operation, "friendly-sum") == 0) {
    int sum = friendly_sum(matrix, rows, columns);
    printf("Friendly sum: %d\n", sum);
  }
  else if (strcmp(operation, "not-friendly-sum") == 0) {
    int sum = not_friendly_sum(matrix, rows, columns);
    printf("Not friendly sum: %d\n", sum);
  }
  else {
    printf("FATAL: Not supported operation!\n");
    exit(1);
  }
  free(matrix);
  return 0;
}

代码框 5-12 [ExtremeC_examples_chapter5_6.c]: 示例 5.6 展示了缓存友好代码和非缓存友好代码的性能

前面的程序计算并打印矩阵中所有元素的总和,但它做的不仅仅是这些。

用户可以向此程序传递选项,这会改变其行为。假设我们想打印一个由 fill 函数编写的算法初始化的 2 行 3 列矩阵。用户必须传递 print 选项以及所需的行数和列数。接下来,你可以看到这些选项是如何传递给最终的可执行二进制文件的:

$ gcc ExtremeC_examples_chapter5_6.c -o ex5_6.out
$ ./ex5_6.out print 2 3
Matrix:
1 1 1
2 2 2
Flat matrix: 1 1 1 2 2 2
$

Shell Box 5-21: 示例 5.6 的输出,显示一个 2 行 3 列的矩阵

输出包括矩阵的两个不同打印。第一个是矩阵的二维表示,第二个是相同矩阵的 扁平 表示。正如你所看到的,矩阵在内存中按 行主序顺序 存储。这意味着我们按行存储它。所以,如果 CPU 获取了某行的数据,那么该行中的所有元素很可能也会被获取。因此,我们最好按行主序而不是 列主序 进行求和。

如果你再次查看代码,你可以看到在 friendly_sum 函数中执行的是行主序求和,而在 not_friendly_sum 函数中执行的是列主序求和。接下来,我们可以比较执行 20,000 行和 20,000 列矩阵求和所需的时间。正如你所看到的,差异非常明显:

$ time ./ex5_6.out friendly-sum 20000 20000
Friendly sum: 1585447424
real   0m5.192s
user   0m3.142s
sys    0m1.765s
$ time ./ex5_6.out not-friendly-sum 20000 20000
Not friendly sum: 1585447424
real   0m15.372s
user   0m14.031s
sys    0m0.791s
$

Shell Box 5-22: 列主序和行主序矩阵求和算法的时间差异演示

测量时间的差异大约是 10 秒!程序是在 macOS 机器上使用 clang 编译器编译的。这种差异意味着相同的逻辑,使用相同数量的内存,可能需要更长的时间——仅仅是通过选择不同的矩阵元素访问顺序!这个例子清楚地展示了缓存友好代码的影响。

注意

time 工具在所有类 Unix 操作系统中都可用。它可以用来测量程序完成所需的时间。

在继续到下一个技术之前,我们应该更多地讨论一下分配和释放成本。

分配和释放成本

在这里,我们想特别谈谈堆内存分配和释放的成本。如果你意识到堆内存分配和释放操作既耗时又耗内存,并且通常很昂贵,特别是当你需要每秒多次分配和释放堆内存块时,这可能会让你感到有些惊讶。

与相对快速且分配本身不需要额外内存的栈分配不同,堆分配需要找到足够大小的空闲内存块,这可能很昂贵。

设计了许多用于内存分配和释放的算法,并且在分配和释放操作之间总是存在权衡。如果你想快速分配,就必须在分配算法中消耗更多的内存;反之,如果你想减少内存消耗,可以选择花费更多时间进行较慢的分配。

除了通过mallocfree函数提供的默认 C 标准库之外,还有其他用于 C 语言的内存分配器。这些内存分配器库包括ptmalloctcmallocHaorddlmalloc

在这里详细介绍所有分配器超出了本章的范围,但对你来说,亲自尝试它们并体验一下是一个好的实践。

这个无声问题的解决方案是什么?很简单:减少分配和释放的频率。在某些程序中,这可能看起来是不可能的,因为这些程序需要以高频率进行堆内存分配。这些程序通常分配一大块堆内存,并尝试自行管理它。这就像在堆内存的大块上又增加了一层分配和释放逻辑(可能比mallocfree的实现简单)。

还有另一种方法,即使用内存池。在我们结束本章之前,我们将简要解释这项技术。

内存池

正如我们在上一节中描述的,内存分配和释放是昂贵的。使用预先分配的固定大小堆内存块池是一种有效的方法来减少分配次数并提高一些性能。池中的每个块通常都有一个标识符,可以通过为池管理设计的 API 获取。此外,当不再需要时,块也可以被释放。由于分配的内存量几乎保持不变,这对于希望在内存受限环境中具有确定性行为的算法来说是一个极佳的选择。

详细描述内存池超出了本书的范围;如果您想了解更多关于这个主题的信息,网上有许多有用的资源。

摘要

作为本章的一部分,我们主要介绍了栈和堆段以及它们的使用方式。之后,我们简要讨论了内存受限环境,并看到了缓存和内存池等技术如何提高性能。

在本章中:

  • 我们讨论了用于探测栈和堆段的工具和技术。

  • 我们介绍了调试器,并使用 gdb 作为我们的主要调试器来排查与内存相关的问题。

  • 我们讨论了内存分析器,并使用 valgrind 来查找运行时发生的内存问题,如泄漏或悬垂指针。

  • 我们比较了栈变量和堆块的生存期,并解释了如何判断此类内存块的生存期。

  • 我们看到,栈变量的内存管理是自动的,但堆块的内存管理是完全手动的。

  • 我们回顾了处理栈变量时常见的错误。

  • 我们讨论了受限环境,并展示了在这些环境中如何进行内存调优。

  • 我们讨论了高效的环境以及可以使用哪些技术来提高性能。

接下来的四章一起涵盖了 C 语言中的面向对象。乍一看,这似乎与 C 语言无关,但实际上,这是在 C 语言中编写面向对象代码的正确方式。作为这些章节的一部分,你将了解如何以面向对象的方式设计和解决问题,并且你将通过编写可读且正确的 C 代码获得指导。

下一章通过提供必要的理论讨论和示例来探讨所讨论的主题,涵盖了封装和面向对象编程的基础。

第六章

面向对象编程与封装

关于面向对象编程或 OOP 有很多优秀的书籍和文章。但我认为其中许多并没有使用非 OOP 语言如 C 来讨论相同的话题!这是怎么可能的?我们甚至能够使用不支持面向对象的编程语言来编写面向对象的程序吗?更精确地说,使用 C 语言编写面向对象的程序是可能的吗?

对于上述问题的简短回答是肯定的,但在解释如何之前,我们需要先解释为什么。我们需要将问题分解,看看面向对象编程(OOP)究竟是什么意思。为什么可以使用没有面向对象支持声明的语言来编写面向对象的程序?这似乎是一个悖论,但事实并非如此,我们本章的努力就是要解释这是为什么,以及应该如何去做。

可能还会让你感到困惑的另一个问题是,当你打算将 C 语言作为你的主要编程语言时,讨论这些话题和了解面向对象编程(OOP)有什么意义?几乎所有现有的成熟的 C 代码库,如开源内核、HTTPD、Postfix、nfsd、ftpd 等服务实现,以及许多其他 C 库如 OpenSSL 和 OpenCV,都是用面向对象的方式编写的。这并不意味着 C 是面向对象的;相反,这些项目组织其内部结构的方法源于面向对象的思想。

我强烈推荐您与下一章一起阅读本章,并更多地了解面向对象编程,因为首先,这将使您能够像设计前面提到的库的工程师一样思考和设计,其次,在阅读这些库的源代码时将非常有帮助。

C 语言在其语法中不支持诸如类、继承和虚函数等面向对象的概念。然而,它确实以间接的方式支持面向对象的概念。实际上,几乎历史上所有的通用编程语言都内在地支持面向对象编程——在 Smalltalk、C++和 Java 出现之前。这是因为每个通用编程语言都必须有一种方法来扩展其数据类型,而这正是面向对象的第一步。

C 语言不能,也不应该在其语法中支持面向对象的特性;这并非因为它的年代,而是因为我们将在本章中讨论的非常好的理由。简单来说,你仍然可以使用 C 语言编写面向对象的程序,但需要付出一些额外的努力来克服复杂性。

关于 C 语言中的 OOP 有一些书籍和文章,它们通常试图为编写类、实现继承、多态等创建一个类型系统,使用 C 语言来实现。这些书籍将添加 OOP 支持视为一系列函数、宏和预处理器,所有这些都可以一起使用来用 C 语言编写面向对象的程序。我们本章不会采取这种方法。我们不会从 C 中创建一个新的 C++;相反,我们想要推测 C 如何有可能被用于 OOP。

通常人们会说,面向对象编程(OOP)是与过程式和函数式编程范式并列的另一种编程范式。但 OOP 远不止于此。OOP 更像是一种思考和分析问题的方法。它是对宇宙及其内部对象层次的一种态度。它是我们古老、内在和继承下来的方法,用于理解和分析我们周围的物理和抽象实体。它对我们理解自然的基本性不言而喻。

我们总是从面向对象的角度思考每一个问题。OOP 只是将人类一直采用的观点应用到编程语言中,以解决计算问题。所有这些都解释了为什么 OOP 是编写软件时最常用的编程范式。

本章以及接下来的三章将展示,OOP 中的任何概念都可以在 C 语言中实现——即使实现起来可能很复杂。我们知道我们可以用 C 实现 OOP,因为有些人已经做到了,尤其是在他们基于 C 创建了 C++之后,并且他们已经在 C 中以面向对象的方式构建了许多复杂且成功的程序。

这些章节不会建议你使用特定的库或宏集来声明类、建立继承关系或处理其他 OOP 概念。此外,我们也不会强制任何方法或纪律,例如特定的命名约定。我们将简单地使用原始的 C 语言来实现 OOP 概念。

我们之所以将整整四章都用于 C 语言中的 OOP,是因为面向对象背后的理论很重,以及为了展示所有这些内容而必须探索的各种示例。OOP 背后的大部分基本理论将在本章中解释,而更实际的话题将在接下来的章节中处理。话虽如此,我们需要讨论理论,因为 OOP 概念通常对大多数熟练的 C 程序员来说都是新的,即使是那些有多年经验的人。

接下来的四章几乎涵盖了你在 OOP 中可能会遇到的所有内容。在本章中,我们将讨论以下内容:

  • 首先,我们将为面向对象文献中最基本的术语给出定义。我们将定义类、对象、属性、行为、方法、域等。这些术语将在接下来的四章中大量使用。它们对于你理解其他面向对象相关资源也是至关重要的,因为它们是面向对象接受的语言的基础部分。

  • 本章的第一部分并不完全关于术语;我们还将深入讨论面向对象的根源及其背后的哲学,探讨面向对象思维的本质。

  • 本章的第二部分致力于探讨 C 语言及其为什么不是,以及为什么不能成为面向对象的语言。这是一个应该被提出并得到恰当回答的重要问题。这个话题将在第十章“Unix – 历史 和架构”中进一步讨论,我们将探讨 Unix 及其与 C 语言的紧密关系。

  • 本章的第三部分讨论了封装,这是面向对象最基本的概念之一。简单来说,它允许你创建对象并使用它们。你可以在对象内部放置变量和方法的事实直接来自封装。这一点在第三部分中进行了详细讨论,并给出了几个示例。

  • 然后这一章转向信息隐藏,这是封装的一个副作用(尽管非常重要)。没有信息隐藏,我们就无法隔离和解除软件模块的耦合,并且我们实际上无法向客户提供实现无关的 API。这是本章最后讨论的内容。

正如之前提到的,整个主题将涵盖四章,接下来的章节将基于组合关系展开。从那里开始,接下来的章节将涵盖聚合继承多态抽象

在本章中,尽管如此,我们将从面向对象(OOP)的理论开始,探讨如何从我们对软件组件的思考过程中提取对象模型。

面向对象思维

正如我们在章节介绍中所说,面向对象思维是我们分解和分析周围事物的方式。当你看着桌子上的一只花瓶时,你能够理解花瓶和桌子是独立的对象,而不需要进行任何深入的分析。

无意识地,你意识到它们之间存在一个边界,将它们分开。你知道你可以改变花瓶的颜色,而桌子的颜色将保持不变。

这些观察结果表明,我们从面向对象的视角看待我们的环境。换句话说,我们只是在脑海中创建周围面向对象现实的反映。我们也在计算机游戏、3D 建模软件和工程软件中看到很多这种情况,所有这些都可以涉及许多对象之间的相互交互。

面向对象编程(OOP)是将面向对象思维应用于软件设计和开发。面向对象思维是我们处理周围环境的方式,这就是为什么面向对象编程成为了编写软件最常用的范式。

当然,如果你采用面向对象的方法,有些问题可能会很难解决,如果你选择了另一种范式,这些问题可能会更容易被分析和解决,但这些问题的出现相对较少。

在接下来的章节中,我们将了解更多关于将面向对象思维转化为面向对象代码的翻译。

心理概念

你很难找到一个完全不包含至少一些面向对象思维痕迹的程序,即使它是用 C 或某些其他非面向对象语言编写的。如果人类编写程序,它将自然地是面向对象的。这甚至可以从变量名中看出。看看以下例子。它声明了保存 10 名学生信息的所需变量:

  char*  student_first_names[10];
  char*  student_surnames[10];
   int   student_ages[10];
double   student_marks[10];

代码框 6-1:四个通过具有学生 _ 前缀相关联的数组,根据命名约定,旨在保存 10 名学生的信息

代码框 6-1中找到的声明显示了我们是怎样使用变量名将一些变量归入同一概念下的,在这个例子中是学生。我们必须这样做;否则,我们会因为那些对我们面向对象思维来说没有意义的临时命名而感到困惑。假设我们有如下这样的东西:

  char*  aaa[10];
  char*  bbb[10];
   int   ccc[10];
double   ddd[10];

代码框 6-2:四个具有特定名称的数组,旨在保存 10 名学生的信息!

使用如代码框 6-2中所示的那种变量名,无论你有多少编程经验,你必须承认在编写算法时你会遇到很多麻烦。变量命名一直很重要,因为名字提醒我们心中的概念以及数据与这些概念之间的关系。通过使用这种临时命名,我们在代码中失去了这些概念及其关系。这可能对计算机来说不是问题,但它会复杂化我们程序员的分析和故障排除,并增加我们犯错的几率。

让我们更详细地了解一下在我们当前语境中我们所说的“概念”是什么。概念是一种存在于心中的心理或抽象图像,它以思想或想法的形式存在。一个概念可能是由对现实世界实体的感知形成的,也可能完全是虚构和抽象的。当你看一棵树或思考一辆车时,它们相应的图像作为两个不同的概念出现在你的脑海中。

注意,有时我们在不同的语境中使用“概念”这个术语,例如在“面向对象的概念”中,显然这个“概念”一词的使用方式与我们刚才给出的定义并不相同。在与技术相关的话题中使用的“概念”一词,简单来说是指理解某个主题所需的原则。目前,我们将使用这个与技术相关的定义。

概念对于面向对象思维非常重要,因为如果你不能在心中形成并维持对对象的了解,你就不能提取关于它们所代表和关联的细节,也不能理解它们之间的关系。

因此,面向对象思维是关于以概念及其关系为前提的思考。由此可以推断,如果你想编写一个合适的面向对象程序,你需要在你心中对所有的相关对象、它们对应的概念以及它们之间的关系有一个恰当的理解。

在你心中形成的面向对象地图,由许多概念及其相互关系组成,不能轻易地传达给他人,例如在作为一个团队处理任务时。更重要的是,这种心理概念是易变的、难以捉摸的,并且很容易被遗忘。这也额外强调了这样一个事实,即你需要模型和其他工具来进行表示,以便将你的思维导图转化为可传达的想法。

思维导图和对象模型

在本节中,我们通过一个例子来进一步理解我们之前讨论的内容。假设我们有一个场景的书面描述。描述某物的目的是向观众传达相关的具体概念。可以这样想:描述者心中有一张地图,上面描绘了各种概念以及它们如何相互联系;他们的目标是向观众传达这张思维导图。你可以说,这基本上是所有艺术表达的目标;实际上,当你看一幅画、听一首音乐或读一本小说时,这正是发生的事情。

现在我们将来看一个书面描述。它描述了一个教室。放松你的心情,尽量想象你所读到的内容。你心中所看到的一切都是以下描述传达的概念:

我们的教室是一个老旧的房间,有两扇大窗户。当你进入房间时,你可以看到对面墙上的窗户。房间中间有一些棕色的木椅。有五个学生坐在椅子上,其中两个是男孩。你右边墙上有一块绿色的木制黑板,老师正在和学生交谈。他是一位穿着蓝色衬衫的老人。

现在,让我们看看在我们心中形成了哪些概念。不过,在我们这样做之前,请记住,你的想象力可能会在你没有注意到的情况下失控。所以,让我们尽力限制自己在这个描述的范围内。例如,我可以想象更多,比如说女孩们是金发的。但是描述中没有提到这一点,所以我们不会考虑这一点。在下一段中,我将解释在我心中形成了什么,在继续之前,你也应该尝试为自己做同样的事情。

在我心中,有五个概念(或心理图像,或对象),每个学生一个。还有五个概念用于椅子。还有一个概念用于木头,另一个概念用于玻璃。我知道每把椅子都是由木头制成的。这是一个关系,在木头概念和椅子概念之间。此外,我知道每个学生都坐在椅子上。因此,有五个关系——在椅子和学生之间。我们可以继续识别更多概念并将它们联系起来。很快,我们就会有一个描述数百个概念之间关系的巨大且复杂的图。

现在,暂停一下,看看你如何不同地提取概念及其关系。这是一个每个人都可以以不同方式完成的教训。当你想要解决特定问题时,这个过程也会发生。你需要在对问题发起攻击之前创建一个思维导图。这是我们称之为理解阶段的阶段。

你使用基于问题概念及其之间关系的途径来解决问题。你用这些概念来解释你的解决方案,如果有人想理解你的解决方案,他们首先应该理解这些概念及其关系。

如果我告诉你,当你尝试使用计算机解决问题时,这正是所发生的事情,你可能会感到惊讶,但这正是实际情况。你将问题分解为对象(与心理环境中的概念相同)以及它们之间的关系,然后尝试编写一个基于这些对象的程序,最终解决问题。

你所编写的程序模拟了你心中所拥有的概念及其关系。计算机运行解决方案,你可以验证它是否有效。你仍然是解决问题的那个人,但现在一台计算机是你的同事,因为它可以执行你的解决方案,这个解决方案现在被描述为一系列从你的思维导图中翻译出来的机器级指令,执行得更快、更准确。

面向对象的程序通过对象来模拟概念,当我们在大脑中为问题创建思维导图时,程序在其内存中创建一个对象模型。换句话说,如果我们把人类与面向对象的程序进行比较,那么术语概念思维思维导图分别等同于对象内存对象模型。这是我们在这个部分提供的重要关联,它将我们的思维方式与面向对象程序联系起来。

但我们为什么使用计算机来模拟我们的思维导图呢?因为计算机在速度和精度方面很擅长。这是一个非常经典的回答,但对我们的问题来说仍然是一个相关的回答。创建和维护一个大的思维导图及其相应的对象模型是一项复杂的任务,而计算机可以非常出色地完成这项任务。作为另一个优点,程序创建的对象模型可以存储在磁盘上并在以后使用。

思维导图可能会因为情绪而遗忘或改变,但计算机没有情绪,对象模型比人类思维更加健壮。这就是我们为什么要编写面向对象的程序:能够将我们思维中的概念转移到有效的程序和软件中。

注意

到目前为止,还没有发明出可以从某人的思维中下载和存储思维导图的东西——但也许在将来会!

对象不在代码中

如果你查看一个运行中的面向对象程序的内存,你会发现它充满了对象,它们都是相互关联的。对人类来说也是如此。如果你把人类看作是一部机器,你可以说他们总是处于运行状态,直到他们去世。这是一个重要的类比。对象只能存在于运行程序中,就像概念只能存在于活着的思维中。这意味着只有当你有一个运行程序时,你才有对象。

这可能看起来像是一个悖论,因为当你编写一个程序(面向对象的程序)时,程序还不存在,所以不能运行!那么,在没有运行程序和没有对象的情况下,我们如何编写面向对象的代码呢?

注意

当你编写面向对象的代码时,没有任何对象存在。只有当你将代码构建成可执行的程序并运行它时,对象才会被创建。

面向对象编程(OOP)实际上并不是关于创建对象。它是在程序运行时,创建一系列指令,这些指令将导致一个完全动态的对象模型。因此,面向对象的代码应该能够在编译和运行后创建、修改、关联甚至删除对象。

因此,编写面向对象代码是一项棘手的任务。在对象存在之前,你需要想象对象及其关系。这正是面向对象可能变得复杂,以及我们需要支持面向对象的编程语言的原因。想象尚未创造的事物并描述或设计其各种细节的艺术通常被称为设计。这就是为什么这个过程通常被称为面向对象设计OOD)的原因。

在面向对象代码中,我们只计划创建对象。面向对象编程导致了一系列关于何时以及如何创建对象的指令。当然,这不仅仅关于创建。所有关于对象的操作都可以使用编程语言详细说明。面向对象编程语言是一种具有一系列指令(和语法规则)的语言,允许你编写和计划不同的与对象相关的操作。

到目前为止,我们已经看到人类心智中的概念与程序内存中的对象之间存在明显的对应关系。因此,对概念和对象可以执行的操作之间也应该存在对应关系。

每个对象都有一个专属的生命周期。这一点也适用于心中的概念。在某个时刻,一个想法出现在脑海中,并作为一个概念形成心理图像,而在另一个时刻,它逐渐消失。对于对象来说也是如此。对象在某个时刻被构建,在另一个时刻被销毁。

最后一点需要注意的是,一些心理概念非常坚定和稳定(与波动和短暂的概念相对,这些概念来去不定)。似乎这些概念与任何心智无关,即使在没有心智去理解它们的时候就已经存在。它们大多是数学概念。数字 2 就是一个例子。整个宇宙中只有一个数字 2!这真是太神奇了。这意味着你和我心中对数字 2 的概念是完全相同的;如果我们试图改变它,它就不再是数字 2 了。这正是我们离开面向对象领域的时刻,我们进入另一个领域,充满了不可变对象,这被描述在函数式编程范式标题下。

对象属性

任何心智中的每个概念都与一些属性相关联。如果你记得,在我们的教室描述中,我们有一个名为chair1的椅子,它是棕色的。换句话说,每个椅子对象都有一个名为颜色的属性,对于chair1对象来说它是棕色。我们知道教室里还有四把其他的椅子,它们有自己的颜色属性,可能具有不同的值。在我们的描述中,它们都是棕色的,但可能在另一个描述中,其中一把或两把是黄色的。

一个对象可以有一个或多个属性或一组属性。我们将分配给这些属性值的总和称为对象的状态。状态可以简单地被视为一个值列表,每个值属于某个特定的属性,并附加到对象上。对象在其生命周期内可以被修改。这样的对象被称为可变的。这仅仅意味着状态可以在其生命周期内被改变。对象也可以是无状态的,这意味着它们不携带任何状态(或任何属性)。

一个对象也可以是不可变的,就像对应于数字 2 的概念(或对象)一样,不能被改变——不可变意味着状态在构造时确定,之后不能被修改。

注意

可以将无状态对象视为不可变对象,因为它的状态在其整个生命周期内不能被改变。实际上,它没有任何状态可以改变。

最后一点,不可变对象特别重要。它们的状态不能被改变的事实是一个优势,尤其是在它们在多线程环境中共享时。

领域

每个编写来解决特定问题(即使是极其微小的问题)的程序都有一个定义明确的领域。领域是另一个在软件工程文献中广泛使用的术语。领域定义了软件展示其功能边界的范围。它还定义了软件应该解决的问题的要求。

一个领域使用特定的和预定的术语(词汇表)来传达其使命并使工程师保持在它的边界内。参与任何软件项目的每个人都应该意识到他们的项目定义的领域。

例如,银行软件通常是为一个非常明确的领域构建的。它有一组作为其词汇表的已知术语,包括账户、信用、余额、转账、贷款、利息等等。

领域的定义通过其词汇表中的术语变得清晰;例如,在银行领域,你不会找到患者、药品和剂量的术语。

如果一种编程语言不提供处理特定领域(如医疗保健领域的患者和药品概念)的特定概念的功能(设施),那么使用该编程语言编写该领域的软件将会很困难——并非不可能,但肯定很复杂。此外,软件越大,开发和维护就越困难。

对象之间的关系

对象可以相互关联;它们可以相互引用以表示关系。例如,作为我们课堂描述的一部分,对象 student4(第四个学生)可能与对象 chair3(第三个椅子)在名为 sitting on 的关系上相关联。换句话说,student4 坐在 chair3 上。这样,系统中的所有对象都相互引用,形成一个称为对象模型的网络。正如我们之前所说的,对象模型是我们心中形成的思维导图的对应物。

当两个对象相关联时,一个对象状态的变化可能会影响另一个对象的状态。让我们通过一个例子来解释这一点。假设我们有两个无关的对象 p1p2,代表像素。

对象 p1 的属性如下所示:{x: 53, y: 345, red: 120, green: 45, blue: 178}。对象 p2 的属性为 {x: 53, y: 346, red: 79, green: 162, blue: 23}

注意:

我们使用的符号几乎与 JavaScript 对象表示法JSON 完全相同,但略有不同。在这个符号中,单个对象的属性被两个大括号包围,并且属性之间用逗号分隔。每个属性都有一个与它分开的值,由冒号分隔。

现在,为了使它们相关联,它们需要有一个额外的属性来表示它们之间的关系。对象 p1 的状态将变为 {x: 53, y: 345, red: 120, green: 45, blue: 178, adjacent_down_pixel: p2},而 p2 的状态将变为 {x: 53, y: 346, red: 79, green: 162, blue: 23, adjacent_up_pixel: p1}

adjacent_down_pixeladjacent_up_pixel 属性表示这些像素对象是相邻的;它们的 y 属性仅相差 1 个单位。使用这些额外的属性,对象意识到它们与其他对象之间存在关系。例如,p1 知道它的 adjacent_down_pixelp2,而 p2 知道它的 adjacent_up_pixelp1

因此,正如我们所看到的,如果两个对象之间形成关系,这些对象的状态(或对应于它们属性的值列表)将发生变化。因此,通过向它们添加新属性来创建对象之间的关系,这使得关系成为对象状态的一部分。这当然对这些对象的可变性和不可变性有影响。

注意,定义对象状态和不可变性的属性子集可以从一个域更改为另一个域,并且不一定包含所有属性。在一个域中,我们可能只使用非引用属性(如前例中的 xyredgreenblue)作为状态,而在另一个域中,我们可能将它们与引用属性(如前例中的 adjacent_up_pixeladjacent_down_pixel)一起组合。

面向对象操作

面向对象编程语言允许我们在即将运行的程序中计划对象的构建、对象的销毁以及改变对象的状态。因此,让我们首先看看对象的构建。

注意:

术语“构建”是经过精心选择的。我们本可以使用“创建”或“构建”,但这些术语在面向对象编程文献的标准术语中并不被接受。创建指的是为对象分配内存,而构建则意味着初始化其属性。

计划对象的构建有两种方式:

  • 第一种方法涉及构建一个空对象——一个在其状态中没有任何属性的无属性对象——或者更常见的是,具有一组最小属性的对象。

  • 随着代码的运行,将确定并添加更多属性。使用这种方法,同一个对象在不同的程序执行中可能会有不同的属性,这取决于周围环境的变化。

  • 每个对象都被视为一个独立的实体,任何两个对象,即使它们看起来属于同一个组(或类),由于它们具有共同属性列表,在程序继续执行时,它们的状态可能会有不同的属性。

  • 例如,已经提到的像素对象p1p2都是像素(或者它们都属于名为pixel的同一类),因为它们具有相同的属性——xyredgreenblue。在建立关系后,它们会有不同的状态,因为它们那时具有新的和不同的属性:p1adjacent_down_pixel属性,而p2adjacent_up_pixel属性。

  • 这种方法用于 JavaScript、Ruby、Python、Perl 和 PHP 等编程语言。其中大部分是解释型编程语言,它们的属性被保留为内部数据结构中的映射(或散列),可以轻松地在运行时更改。这种技术通常被称为基于原型的面向对象编程

  • 第二种方法涉及构建一个其属性预先确定且在执行过程中不会改变的实体。不允许在运行时向此类对象添加更多属性,并且对象将保持其结构。仅允许属性的值发生变化,而这种变化仅在对象可变时才可能发生。

  • 要应用这种方法,程序员应该创建一个预设计的对象模板,该模板跟踪在运行时需要存在于对象中的所有属性。然后,这个模板应该被编译并在运行时输入到面向对象的语言中。

  • 在许多编程语言中,这种对象模板被称为类。例如,Java、C++和 Python 等编程语言使用这个术语来表示它们的对象模板。这种技术通常被称为基于类的面向对象编程。请注意,Python 支持基于原型和基于类的面向对象编程。

注意:

类仅确定对象中存在的属性列表,但并不确定在运行时分配给它们的实际值。

注意,对象和实例是同一件事,它们可以互换使用。然而,在某些文本中,它们之间可能存在一些细微的差异。还有一个术语,引用,值得提及并解释。术语对象或实例用于指代该对象值的实际内存分配位置,而引用就像一个指向该对象的指针。因此,我们可以有许多引用指向同一个对象。一般来说,对象通常没有名称,但引用确实有名称。

注意:

在 C 中,我们有指针作为引用的对应语法。我们还有栈对象和堆对象。堆对象没有名称,我们使用指针来引用它。相比之下,栈对象实际上是一个变量,因此有一个名称。

虽然可以使用这两种方法,但 C 和特别是 C++官方设计的方式是为了支持基于类的构造方法。因此,当程序员想在 C 或 C++中创建一个对象时,他们首先需要有一个类。我们将在未来的章节中更多地讨论类及其在面向对象编程中的作用。

以下讨论可能看起来有些不相关,但实际上并非如此。关于人类如何通过生活成长,有两种不同的观点,它们与我们讨论过的对象构造方法非常吻合。其中一种哲学认为,人类出生时是空无一物的,没有本质(或状态)。

通过在生活中经历不同的好事和坏事,他们的本质开始成长并发展成为一个具有独立和成熟性格的东西。存在主义是一种哲学传统,它推广了这个观点。

它著名的格言是“存在先于本质”。这简单意味着人类首先来到存在,然后通过生活经验获得他们的本质。这个想法与我们的基于原型的对象构造方法非常接近,在这种方法中,对象是空无一物地构造的,然后在运行时发展。

另一种哲学更古老,主要是由宗教推广的。在这种观点中,人类是根据一个形象(或本质)创造的,而这个形象在人类存在之前就已经确定。这与我们计划根据模板或类来构造对象的方式非常相似。作为对象创造者,我们准备一个类,然后程序开始根据该类创建对象。

注意:

小说或故事中的人物,包括文学和历史资料中的人物,在克服某种困难时所采取的方法与我们在计算机科学中为解决类似问题而设计的算法之间存在很大的对应关系。我深信人类的生活方式和他们所经历的现实与我们对计算机科学中算法和数据结构的理解之间存在着极大的和谐。前面的讨论就是这种 OOP 与哲学之间和谐的一个很好的例子。

与对象构造类似,对象销毁发生在运行时;我们只能在代码中规划它。对象在其整个生命周期中分配的所有资源都应该在销毁时释放。当一个对象正在被销毁时,所有其他相关对象都应该被更改,以便它们不再引用被销毁的对象。一个对象不应该有引用一个不存在对象的属性,否则我们就会在我们的对象模型中失去引用完整性。这可能导致运行时错误,如内存损坏或段错误,以及逻辑错误,如计算错误。

修改对象(或改变对象的状态)可以通过两种不同的方式发生。它可能是现有属性值的改变,也可能是向该对象的属性集中添加或从集中移除属性。后者只有在选择了基于原型的对象构造方法时才能发生。记住,改变不可变对象的状态是被禁止的,通常面向对象的语言也不允许这样做。

对象具有行为

每个对象,连同其属性,都有它能够执行的一定列表的功能。例如,汽车对象能够加速、减速、转弯等。在面向对象编程(OOP)中,这些功能总是符合领域需求。例如,在银行对象模型中,客户可以订购新账户,但不能吃饭。当然,客户是人,可以吃饭,但只要吃饭功能与银行领域无关,我们就不会将其视为客户对象的一个必要功能。

每个功能都能通过改变其属性的值来改变对象的状态。作为一个简单的例子,一辆汽车对象可以加速。加速是汽车对象的一个功能,通过加速,汽车的速度,即其属性之一,发生了变化。

总结来说,对象仅仅是一组属性和功能。在后面的章节中,我们将更多地讨论如何将这些事物组合成一个对象。

到目前为止,我们已经解释了研究和理解面向对象编程(OOP)所需的基本术语。下一步是解释封装的基本概念。但是,作为一个休息,让我们了解一下为什么 C 语言不能成为面向对象的语言。

C 语言不是面向对象的,但为什么?

C 不是面向对象的,但这并不是因为它的年龄。如果年龄是原因,我们到现在应该已经找到了一种方法使其成为面向对象的语言。但是,正如你将在第十二章“最新的 C”中看到的那样,C 编程语言的最新标准 C18 并不试图将 C 变成面向对象的语言。

另一方面,我们有 C++,它是基于 C 的所有努力以实现面向对象语言的结果。如果 C 的命运是它被面向对象的语言所取代,那么今天就不会有任何对 C 的需求了,这主要是因为 C++——但当前对 C 工程师的需求表明情况并非如此。

人类以面向对象的方式思考,但 CPU 执行的是机器级指令,这些指令是过程式的。CPU 只是依次执行一系列指令,并且时不时地需要跳转、从内存中的不同地址获取并执行其他指令;这与使用像 C 这样的过程式编程语言编写的程序中的函数调用非常相似。

C 不能成为面向对象的语言,因为它位于面向对象和过程式编程之间的障碍上。面向对象是人类对问题的理解,而过程式执行是 CPU 能够执行的操作。因此,我们需要某种东西处于这个位置并形成这个障碍。否则,通常以面向对象方式编写的程序无法直接转换为过程式指令以供 CPU 执行。

如果你看看像 Java、JavaScript、Python、Ruby 等高级编程语言,它们在其架构中都有一个组件或层,它介于它们的运行环境与操作系统内部实际找到的 C 库(Unix-like 系统中的标准 C 库和 Windows 系统中的 Win32 API)之间。例如,Java 虚拟机JVM)在 Java 平台上就是这样做的。虽然并非所有这些环境都是必然面向对象的(例如 JavaScript 或 Python 可以是过程式和面向对象的),但它们需要这个层来将它们的高级逻辑转换为低级过程式指令。

封装

在前面的章节中,我们看到了每个对象都有一组属性和一组与之相关的功能。在这里,我们将讨论将这些属性和功能放入一个称为对象的实体中。我们通过一个称为“封装”的过程来完成这件事。

封装简单地说就是将相关的事物放在一起,形成一个代表对象的“胶囊”。这首先发生在你的脑海中,然后应该转移到代码中。当你觉得一个对象需要有一些属性和功能时,你就在脑海中进行了封装;然后这个封装需要转移到代码层面。

能够在编程语言中封装事物至关重要,否则将相关变量放在一起将变得难以承受(我们提到了使用命名约定来完成这一点)。

对象是由一组属性和一组功能组成的。这两者都应该封装到对象胶囊中。让我们首先谈谈属性封装

属性封装

正如我们之前看到的,我们总是可以使用变量名来进行封装,将不同的变量绑定在一起并将它们分组在同一个对象下。以下是一个例子:

int pixel_p1_x     = 56;
int pixel_p1_y     = 34;
int pixel_p1_red   = 123;
int pixel_p1_green = 37;
int pixel_p1_blue  = 127;
int pixel_p2_x     = 212;
int pixel_p2_y     = 994;
int pixel_p2_red   = 127;
int pixel_p2_green = 127;
int pixel_p2_blue  = 0;

代码框 6-3:一些按名称分组的代表两个像素的变量

这个例子清楚地说明了变量名是如何用来将变量分组在p1p2下的,它们在某种程度上是隐式对象。通过隐式,我们是指程序员是唯一一个意识到这些对象存在的人;编程语言对它们一无所知。

编程语言只看到 10 个似乎彼此独立的变量。这将是一个非常低级的封装,低到它甚至不会被正式认为是封装。通过变量名进行封装存在于所有编程语言中(因为你可以命名变量),即使在汇编语言中也是如此。

我们需要的是提供显式封装的方法。通过显式,我们是指程序员和编程语言都意识到封装以及存在的胶囊(或对象)。不提供显式属性封装的编程语言很难使用。

幸运的是,C 确实提供了显式封装,这也是我们能够相对容易地用 C 编写许多固有的面向对象程序的原因之一。另一方面,正如我们在下一节中将要看到的,C 没有提供显式行为封装,我们必须提出一种隐式纪律来支持这一点。

注意,在编程语言中拥有像封装这样的显式特性总是受欢迎的。在这里,我们只讨论了封装,但这可以扩展到许多其他面向对象特性,例如继承和多态。这样的显式特性允许编程语言在编译时而不是运行时捕获相关错误。

在运行时解决错误是一场噩梦,因此我们应始终尝试在编译时捕获错误。这是面向对象语言的主要优势,它完全了解我们的面向对象思维方式。面向对象语言可以在编译时找到并报告我们设计中的错误和违规,从而避免我们在运行时解决许多严重错误。事实上,这正是我们每天看到更多复杂编程语言的原因——让一切对语言来说都是明确的。

不幸的是,C 中并非所有面向对象特性都是显式的。这基本上是为什么用 C 编写面向对象程序很难的原因。但 C++中有更多显式特性,确实,这也是它被称为面向对象编程语言的原因。

在 C 语言中,结构提供了封装。让我们改变 代码框 6-3 中的代码,并使用结构来重写它:

typedef struct {
  int x, y;
  int red, green, blue;
} pixel_t;
pixel_t p1, p2;
p1.x = 56;
p1.y = 34;
p1.red = 123;
p1.green = 37;
p1.blue = 127;
p2.x = 212;
p2.y = 994;
p2.red = 127;
p2.green = 127;
p2.blue = 0;

代码框 6-4:pixel_t 结构和声明两个 pixel_t 变量

关于 代码框 6-4 有几点需要注意:

  • 属性封装发生在我们将 xyredgreenblue 属性放入一个新的类型 pixel_t 中时。

  • 封装总是创建一个新的类型;在 C 语言中,属性封装尤其如此。这一点非常重要,实际上,这是我们使封装显式化的方式。请注意 pixel_t 末尾的 _t 后缀。在 C 语言中,通常会在新类型的名称末尾添加 _t 后缀,但这不是强制的。我们在这本书中一直使用这个约定。

  • 当这段代码执行时,p1p2 将是我们的显式对象。它们都是 pixel_t 类型,并且只有结构中指定的属性。在 C 语言中,尤其是 C++ 中,类型决定了它们的对象的属性。

  • 新类型 pixel_t 只是一个类的属性(或对象模板)。记住,“类”这个词指的是包含属性和功能的对象模板。由于 C 结构只保留属性,它不能作为类的对应物。不幸的是,在 C 语言中我们没有类的对应概念;属性和功能是分开存在的,我们在代码中隐式地将它们关联起来。每个类在 C 语言中都是隐式的,它指的是一个结构以及一系列 C 函数。你将在接下来的例子中看到更多关于这一点的内容,这是本章以及未来章节的一部分。

  • 正如你所见,我们正在基于一个模板(这里是指 pixel_t 的结构)构建对象,并且模板具有对象在出生时应该拥有的预定义属性。就像我们之前说的那样,结构只存储属性而不存储功能。

  • 对象构造与声明新变量的声明非常相似。首先是类型,然后是变量名(在这里是对象名)。在声明对象时,几乎同时发生两件事:首先为对象分配内存(创建),然后使用默认值初始化属性(构造)。在上面的例子中,由于所有属性都是整数,C 语言中的默认整数值将是 0。

  • 在 C 语言和许多其他编程语言中,我们使用点 (.) 来访问对象内的属性,或者使用箭头 (->) 来通过存储在指针中的地址间接访问结构的属性。语句 p1.x(如果 p1 是指针,则为 p1->x)应读作 p1 对象中的 x 属性

如你所知,属性当然不是唯一可以封装到对象中的东西。现在我们来看看功能是如何封装的。

行为封装

对象只是一个属性和方法胶囊。方法是我们通常用来表示保存在对象中的逻辑或功能的标准术语。它可以被认为是一个具有名字、参数列表和返回类型的 C 函数。属性传达,方法传达行为。因此,一个对象有一系列值,可以在系统中执行某些行为。

在基于类的面向对象语言,如 C++中,将多个属性和方法组合到一个类中非常容易。在基于原型的语言,如 JavaScript 中,我们通常从一个空对象(ex nihilo,或“无中生有”)或从现有对象克隆开始。为了在对象中拥有行为,我们需要添加方法。看看以下例子,它有助于你了解原型编程语言是如何工作的。它是用 JavaScript 编写的:

// Construct an empty object
var clientObj = {};
// Set the attributes
clientObj.name = "John";
clientObj.surname = "Doe";
// Add a method for ordering a bank account
clientObj.orderBankAccount = function () {
  ...
}
...
// Call the method
clientObj.orderBankAccount();

代码框 6-5:在 JavaScript 中构造客户端对象

正如这个例子所示,在第 2 行,我们创建了一个空对象。在接下来的两行中,我们向我们的对象添加了两个新属性,namesurname。然后在下一行,我们添加了一个新的方法,orderBankAccount,它指向一个函数定义。这一行实际上是一个赋值操作。在右侧是一个匿名函数,它没有名字,被分配给了左侧对象的orderBankAccount属性。换句话说,我们将一个函数存储到了orderBankAccount属性中。在最后一行,调用了对象的orderBankAccount方法。这个例子是原型编程语言的优秀演示,这些语言最初只依赖于一个空对象,没有更多。

在基于类的编程语言中,前面的例子会有所不同。在这些语言中,我们首先编写一个类,因为没有类,我们就不能有任何对象。下面的代码框包含了之前的例子,但用 C++编写:

class Client {
public:
  void orderBankAccount() {
    ...
  }
  std::string name;
  std::string surname:
};
...
Client clientObj;
clientObj.name = "John";
clientObj.surname = "Doe";
...
clientObj.orderBankAccount ();

代码框 6-6:在 C++中构造客户端对象

正如你所见,我们首先声明了一个新的类,Client。在第 1 行,我们声明了一个类,它立即成为了一个新的 C++类型。它类似于一个胶囊,被大括号包围。在声明类之后,我们从Client类型构造了clientObj对象。

在接下来的几行中,我们设置了属性,最后,我们在clientObj对象上调用了orderBankAccount方法。

注意:

在 C++这样的基于类的面向对象语言中,方法通常被称为成员函数,属性被称为数据成员

如果你观察开源和知名的 C 项目所采用的封装某些项目的技术,你会发现它们之间有一个共同的主题。在本节的剩余部分,我们将提出一种基于此类项目中观察到的类似技术的行为封装技术。

由于我们经常会回溯到这个技术,我将给它起一个名字。我们称这个技术为隐式封装。它是隐式的,因为它不提供 C 语言所知的明确的行为封装。基于 ANSI C 标准中我们目前所拥有的内容,我们无法让 C 语言知道类。因此,所有试图在 C 语言中实现面向对象技术的技术都必须是隐式的。

隐式封装技术建议以下内容:

  • 使用 C 结构来保存对象的属性(显式属性封装)。这些结构被称为属性结构

  • 对于行为封装,使用 C 函数。这些函数被称为行为函数。正如你可能知道的,在 C 语言的结构中我们不能有函数。因此,这些函数必须存在于属性结构之外(隐式行为封装)。

  • 行为函数必须接受一个结构指针作为它们的参数之一(通常是第一个或最后一个参数)。这个指针指向对象的属性结构。这是因为行为函数可能需要读取或修改对象的属性,这是非常常见的。

  • 行为函数应该有合适的名字来表明它们与同一类对象相关。这就是为什么在使用这个技术时坚持一致的命名约定非常重要。这是我们在这几章中试图坚持的两个命名约定之一,以便实现清晰的封装。另一个是在属性结构的名称中使用_t后缀。然而,当然,我们并不强迫这样做,你可以使用你自己的自定义命名约定。

  • 对应于行为函数的声明语句通常放在用于保持属性结构声明的同一个头文件中。这个头文件被称为声明头文件

  • 行为函数的定义通常放在一个或多个单独的源文件中,这些源文件包括声明头文件。

注意,在隐式封装中,类确实存在,但它们是隐式的,只有程序员知道。以下示例示例 6.1展示了如何在真实的 C 程序中使用这种技术。它是一个关于汽车对象,直到耗尽燃料并停止加速的例子。

以下头文件作为示例 6.1的一部分,包含了新类型car_t的声明,它是Car类的属性结构。该头文件还包含了Car类行为函数所需的声明。我们使用“Car类”这个短语来指代 C 代码中缺失的隐式类,它集体包括了属性结构和行为函数:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_6_1_H
#define EXTREME_C_EXAMPLES_CHAPTER_6_1_H
// This structure keeps all the attributes
// related to a car object
typedef struct {
  char name[32];
  double speed;
  double fuel;
} car_t;
// These function declarations are 
// the behaviors of a car object
void car_construct(car_t*, const char*);
void car_destruct(car_t*);
void car_accelerate(car_t*);
void car_brake(car_t*);
void car_refuel(car_t*, double);
#endif

代码框 6-7 [ExtremeC_examples_chapter6_1.h]:Car 类的属性结构和行为函数的声明

正如你所见,属性结构car_t有三个字段——namespeedfuel——它们是汽车对象的属性。请注意,car_t现在是一个新的 C 语言类型,我们现在可以声明此类类型的变量。行为函数通常也声明在同一个头文件中,正如你可以在前面的代码框中看到的那样。它们以car_前缀开头,以强调它们都属于同一个类。

关于隐式封装技术的一个重要事项:每个对象都有自己的唯一属性结构变量,但所有对象共享相同的行为函数。换句话说,我们必须为每个对象从属性结构类型创建一个专用变量,但我们只编写一次行为函数,并为不同的对象调用它们。

注意,car_t属性结构本身不是一个类。它只包含Car类的属性。所有声明共同构成了隐式的Car类。随着我们的深入,你会看到更多这样的例子。

有许多著名的开源项目使用前面的技术来编写半面向对象的代码。一个例子是libcurl。如果你查看它的源代码,你会看到很多以curl_开头的结构和函数。你可以在这里找到此类函数的列表:curl.haxx.se/libcurl/c/allfuncs.html

以下源文件包含了作为示例 6.1 一部分的行为函数的定义:

#include <string.h>
#include "ExtremeC_examples_chapter6_1.h"
// Definitions of the above functions
void car_construct(car_t* car, const char* name) {
  strcpy(car->name, name);
  car->speed = 0.0;
  car->fuel = 0.0;
}
void car_destruct(car_t* car) {
  // Nothing to do here!
}
void car_accelerate(car_t* car) {
  car->speed += 0.05;
  car->fuel -= 1.0;
  if (car->fuel < 0.0) {
    car->fuel = 0.0;
  }
}
void car_brake(car_t* car) {
  car->speed -= 0.07;
  if (car->speed < 0.0) {
    car->speed = 0.0;
  }
  car->fuel -= 2.0;
  if (car->fuel < 0.0) {
    car->fuel = 0.0;
  }
}
void car_refuel(car_t* car, double amount) {
  car->fuel = amount;
}

代码框 6-8 [ExtremeC_examples_chapter6_1.c]:作为Car类一部分的行为函数的定义

Car的行为函数在代码框 6-8中定义。正如你所见,所有这些函数都接受一个car_t指针作为它们的第一个参数。这允许函数读取和修改对象的属性。如果一个函数没有接收属性结构的指针,那么它可以被认为是一个普通的 C 函数,它不表示对象的任何行为。

注意,行为函数的声明通常位于它们对应的属性结构声明旁边。这是因为程序员是负责维护属性结构和行为函数之间对应关系的唯一人员,维护应该是足够简单的。这就是为什么将这两组保持在一起,通常在同一个头文件中,有助于维护类的整体结构,并减轻未来工作的痛苦。

在下面的代码框中,你会找到包含main函数和执行主要逻辑的源文件。所有行为函数都将在这里使用:

#include <stdio.h>
#include "ExtremeC_examples_chapter6_1.h"
// Main function
int main(int argc, char** argv) {
  // Create the object variable
  car_t car;
  // Construct the object
  car_construct(&car, "Renault");
  // Main algorithm
  car_refuel(&car, 100.0);
  printf("Car is refueled, the correct fuel level is %f\n",
    car.fuel);
  while (car.fuel > 0) {
    printf("Car fuel level: %f\n", car.fuel);
    if (car.speed < 80) {
      car_accelerate(&car);
      printf("Car has been accelerated to the speed: %f\n", 
  car.speed);
    } else {
      car_brake(&car);
      printf("Car has been slowed down to the speed: %f\n",
  car.speed);
    }
  }
  printf("Car ran out of the fuel! Slowing down ...\n");
  while (car.speed > 0) {
    car_brake(&car);
    printf("Car has been slowed down to the speed: %f\n", 
      car.speed);
  }
  // Destruct the object
  car_destruct(&car);
  return 0;
} 

代码框 6-9 [ExtremeC_examples_chapter6_1_main.c]:示例 6.1 的主函数

作为main函数中的第一条指令,我们已从car_t类型声明了car变量。变量car是我们第一个car对象。在这一行,我们为对象的属性分配了内存。在下一行,我们构建了对象。现在在这一行,我们初始化了属性。你只能在为对象的属性分配了内存的情况下初始化对象。在代码中,构造函数接受第二个参数作为汽车的名字。你可能已经注意到,我们将car对象的地址传递给了所有的car_*行为函数。

while循环之后,main函数读取fuel属性并检查其值是否大于零。事实上,main函数,作为一个非行为函数,能够访问(读取和写入)car的属性,这是一个重要的事情。例如,fuelspeed属性是公共属性的例子,除了行为函数之外的其他函数(外部代码)也可以访问。我们将在下一节回到这个点。

在离开main函数并结束程序之前,我们已经销毁了car对象。这仅仅意味着对象分配的资源在这一阶段被释放了。关于这个例子中的car对象,没有需要执行销毁操作,但并不总是这样,销毁可能需要遵循一些步骤。我们将在接下来的例子中看到更多关于这一点的内容。销毁阶段是强制性的,可以防止堆分配时的内存泄漏。

看看我们如何将前面的例子写成 C++代码会很好。这有助于你了解面向对象的语言是如何理解类和对象的,以及它是如何减少编写正确面向对象代码的开销的。

以下代码框,作为示例 6.2的一部分,显示了包含 C++中Car类的头文件:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_6_2_H
#define EXTREME_C_EXAMPLES_CHAPTER_6_2_H
class Car {
public:
  // Constructor
  Car(const char*);
  // Destructor
  ~Car();
  void Accelerate();
  void Brake();
  void Refuel(double);
  // Data Members (Attributes in C)
  char name[32];
  double speed;
  double fuel;
};
#endif

代码框 6-10 [ExtremeC_examples_chapter6_2.h]: C++中 Car 类的声明

前面代码的主要特点是 C++知道类。因此,前面的代码演示了显式封装;属性和行为封装。不仅如此,C++还支持更多的面向对象的概念,如构造函数和析构函数。

在 C++代码中,所有的声明,无论是属性还是行为,都被封装在类定义中。这是显式封装。看看我们声明的第一个和第二个函数,我们将它们声明为类的构造函数和析构函数。C 不知道构造函数和析构函数;但 C++有它们自己的特定符号。例如,析构函数以~开头,并且与类的名字相同。

此外,正如你所看到的,行为函数缺少第一个指针参数。这是因为它们都可以访问类内部的属性。下一个代码框显示了包含已声明行为函数定义的源文件内容:

#include <string.h>
#include "ExtremeC_examples_chapter6_2.h"
Car::Car(const char* name) {
  strcpy(this->name, name);
  this->speed = 0.0;
  this->fuel = 0.0;
}
Car::~Car() {
  // Nothing to do
}
void Car::Accelerate() {
  this->speed += 0.05;
  this->fuel -= 1.0;
  if (this->fuel < 0.0) {
    this->fuel = 0.0;
  }
}
void Car::Brake() {
  this->speed -= 0.07;
  if (this->speed < 0.0) {
    this->speed = 0.0;
  }
  this->fuel -= 2.0;
  if (this->fuel < 0.0) {
    this->fuel = 0.0;
  }
}
void Car::Refuel(double amount) {
  this->fuel = amount;
}

代码框 6-11 [ExtremeC_examples_chapter6_2.cpp]:C++中 Car 类的定义

如果你仔细观察,你会发现 C 代码中的car指针已经被 C++中的this指针所取代,this是 C++中的一个关键字。关键字this简单地说就是当前对象。这里我就不再进一步解释了,但这是一个聪明的解决方案,用来消除 C 语言中的指针参数,并使行为函数更简单。

最后,以下代码框包含了使用前面类定义的main函数:

// File name: ExtremeC_examples_chapter6_2_main.cpp
// Description: Main function
#include <iostream>
#include "ExtremeC_examples_chapter6_2.h"
// Main function
int main(int argc, char** argv) {
  // Create the object variable and call the constructor
  Car car("Renault");
  // Main algorithm
  car.Refuel(100.0);
  std::cout << "Car is refueled, the correct fuel level is "
    << car.fuel << std::endl;
  while (car.fuel > 0) {
    std::cout << "Car fuel level: " << car.fuel << std::endl;
    if (car.speed < 80) {
      car.Accelerate();
      std::cout << "Car has been accelerated to the speed: "
        << car.speed << std::endl;
    } else {
      car.Brake();
      std::cout << "Car has been slowed down to the speed: "
        << car.speed << std::endl;
    }
  }
  std::cout << "Car ran out of the fuel! Slowing down ..."
    << std::endl;
  while (car.speed > 0) {
    car.Brake();
    std::cout << "Car has been slowed down to the speed: "
      << car.speed << std::endl;
  }
  std::cout << "Car is stopped!" << std::endl;
  // When leaving the function, the object 'car' gets
  // destructed automatically.
  return 0;
}

代码框 6-12 [ExtremeC_examples_chapter6_2_main.cpp]:示例 6.2 的主函数

为 C++编写的main函数与我们为 C 编写的非常相似,只是它为类变量分配内存而不是结构变量。

在 C 中,我们不能将属性和行为函数组合在一起,因为 C 语言知道这个组合。相反,我们必须使用文件来分组它们。但在 C++中,我们有这种组合的语法,即类定义。它允许我们将数据成员(或属性)和成员函数(或行为函数)放在同一个地方。

由于 C++知道封装,因此向行为函数传递指针参数是多余的,正如你所看到的,在 C++中,我们在成员函数声明中没有任何像 C 版本的Car类中那样的第一个指针参数。

那么,发生了什么?我们在 C 语言,这是一种过程式编程语言,和 C++语言,这是一种面向对象的语言,都编写了一个面向对象的程序。最大的变化是使用car.Accelerate()代替car_accelerate(&car),或者使用car.Refuel(1000.0)代替car_refuel(&car, 1000.0)

换句话说,如果我们在一个过程式编程语言中执行func(obj, a, b, c, ...)这样的调用,我们可以在面向对象的语言中这样做obj.func(a, b, c, ...)。它们是等效的,但来自不同的编程范式。就像我们之前说的那样,有无数使用这种技术的 C 项目例子。

注意:

第九章C++中的抽象和面向对象,你会看到 C++使用完全相同的先导技术,将高级 C++函数调用转换为低级 C 函数调用。

作为最后的注意事项,C 和 C++在对象销毁方面有一个重要的区别。在 C++中,每当在栈上分配一个对象并且它即将超出作用域时,析构函数会自动调用,就像任何其他栈变量一样。这是 C++内存管理的一个重大成就,因为在 C 中,你可能会忘记调用析构函数,最终导致内存泄漏。

现在是时候讨论封装的其他方面了。在下一节中,我们将讨论封装的一个后果:信息隐藏。

信息隐藏

到目前为止,我们已经解释了封装如何将属性(代表值)和功能(代表行为)捆绑在一起形成对象。但这并没有结束。

封装还有一个重要的目的或后果,那就是信息隐藏。信息隐藏是指保护(或隐藏)一些不应对外界可见的属性和行为。这里的“外界”指的是不属于对象行为的代码的所有部分。根据这个定义,如果没有是类公共接口的一部分,那么没有任何其他代码,或者简单地说,没有任何其他 C 函数可以访问对象的私有属性或私有行为。

注意,同一类型的两个对象的行为,例如Car类中的car1car2,可以访问同一类型的任何对象的属性。这是因为我们为类中的所有对象编写一次行为函数。

示例 6.1中,我们看到main函数很容易访问car_t属性结构中的speedfuel属性。这意味着car_t类型中的所有属性都是公共的。拥有公共属性或行为可能是一件坏事,因为它可能有一些长期和危险的影响。

作为结果,实现细节可能会泄露出来。假设你打算使用一个汽车对象。通常,对你来说,重要的是它有一个加速汽车的行为;而你并不好奇它是如何实现的。对象中可能有更多的内部属性有助于加速过程,但没有合理的理由让它们对消费者逻辑可见。

例如,提供给发动机启动器的电流量可能是一个属性,但它应该仅对对象本身是私有的。这也适用于对象内部的一些行为。例如,将燃料注入燃烧室是一种内部行为,不应该对你可见和可访问,否则你可能会干扰它并中断发动机的正常工作过程。

从另一个角度来看,实现细节(汽车是如何工作的)因汽车制造商而异,但能够加速汽车是一种所有汽车制造商都提供的行为。我们通常说,能够加速汽车是Car类的公共 API公共接口的一部分。

通常情况下,使用对象的代码会依赖于该对象的公共属性和行为。这是一个严重的问题。最初将内部属性声明为公共的,然后将其设置为私有,可能会导致依赖于该属性的代码构建失败。预期使用该属性作为公共事物的代码的其他部分在更改后不会编译。

这意味着你破坏了向后兼容性。这就是为什么我们选择保守的方法,默认将每个属性都设置为私有,直到我们找到将其公开的合理理由。

简而言之,从类中公开私有代码实际上意味着我们不是依赖于轻量级的公共接口,而是依赖于厚重的实现。这些后果是严重的,有可能导致项目中的大量返工。因此,保持属性和行为尽可能私有是很重要的。

下面的代码框,作为示例 6.3的一部分,将展示如何在 C 语言中拥有私有属性和行为。这个例子是关于一个List类,它应该存储一些整数值:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_6_3_H
#define EXTREME_C_EXAMPLES_CHAPTER_6_3_H
#include <unistd.h>
// The attribute structure with no disclosed attribute
struct list_t;
// Allocation function
struct list_t* list_malloc();
// Constructor and destructor functions
void list_init(struct list_t*);
void list_destroy(struct list_t*);
// Public behavior functions
int list_add(struct list_t*, int);
int list_get(struct list_t*, int, int*);
void list_clear(struct list_t*);
size_t list_size(struct list_t*);
void list_print(struct list_t*);
#endif

代码框 6-13 [ExtremeC_examples_chapter6_3.h]: List 类的公共接口

在前面的代码框中看到的是我们使属性私有的方式。如果另一个源文件,例如包含main函数的文件,包含了前面的头文件,它将无法访问list_t类型内部的属性。原因很简单。list_t只是一个声明而没有定义,仅通过结构声明,你无法访问结构体的字段。你甚至无法从它中声明一个变量。这样,我们保证了信息隐藏。这实际上是一项伟大的成就。

再次强调,在创建和发布头文件之前,必须仔细检查是否需要公开某些内容。通过公开公共行为或公共属性,你会创建依赖关系,其破坏将耗费你时间、开发努力,并最终导致金钱损失。

下面的代码框展示了list_t属性结构的实际定义。请注意,它是在源文件中定义的,而不是在头文件中:

#include <stdio.h>
#include <stdlib.h>
#define MAX_SIZE 10
// Define the alias type bool_t
typedef int bool_t;
// Define the type list_t
typedef struct {
 size_t size;
 int* items;
} list_t;
// A private behavior which checks if the list is full
bool_t __list_is_full(list_t* list) {
  return (list->size == MAX_SIZE);
}
// Another private behavior which checks the index
bool_t __check_index(list_t* list, const int index) {
  return (index >= 0 && index <= list->size);
}
// Allocates memory for a list object
list_t* list_malloc() {
  return (list_t*)malloc(sizeof(list_t));
}
// Constructor of a list object
void list_init(list_t* list) {
  list->size = 0;
  // Allocates from the heap memory
  list->items = (int*)malloc(MAX_SIZE * sizeof(int));
}
// Destructor of a list object
void list_destroy(list_t* list) {
  // Deallocates the allocated memory
  free(list->items);
}
int list_add(list_t* list, const int item) {
  // The usage of the private behavior
  if (__list_is_full(list)) {
    return -1;
  }
  list->items[list->size++] = item;
  return 0;
}
int list_get(list_t* list, const int index, int* result) {
  if (__check_index(list, index)) {
    *result = list->items[index];
    return 0;
  }
  return -1;
}
void list_clear(list_t* list) {
  list->size = 0;
}
size_t list_size(list_t* list) {
  return list->size;
}
void list_print(list_t* list) {
  printf("[");
  for (size_t i = 0; i < list->size; i++) {
    printf("%d ", list->items[i]);
  }
  printf("]\n");
}

代码框 6-14 [ExtremeC_examples_chapter6_3.c]: List 类的定义

在前面的代码框中看到的所有定义都是私有的。将要使用list_t对象的外部逻辑对前面的实现一无所知,头文件是外部代码唯一依赖的代码片段。

注意,前面的文件甚至没有包含头文件!只要定义和函数签名与头文件中的声明相匹配,这就足够了。然而,建议这样做,因为这保证了声明与其对应定义之间的兼容性。正如您在第二章编译和链接中看到的,源文件是分别编译的,最后链接在一起。

实际上,链接器将私有定义带到公共声明中,并从中制作出一个可工作的程序。

注意:

我们可以为私有行为函数使用不同的表示法。我们在它们的名称中使用前缀__。例如,__check_index函数是一个私有函数。请注意,私有函数在头文件中没有相应的声明。

下面的代码框包含示例 6.3main函数,该函数创建两个列表对象,填充第一个,并使用第二个列表存储第一个列表的逆序。最后,它将它们打印出来:

#include <stdlib.h>
#include "ExtremeC_examples_chapter6_3.h"
int reverse(struct list_t* source, struct list_t* dest) {
  list_clear(dest);
  for (size_t i = list_size(source) - 1; i >= 0; i--) {
    int item;
    if(list_get(source, i, &item)) {
      return -1;
    }
    list_add(dest, item);
  }
  return 0;
}
int main(int argc, char** argv) {
  struct list_t* list1 = list_malloc();
  struct list_t* list2 = list_malloc();
  // Construction
  list_init(list1);
  list_init(list2);
  list_add(list1, 4);
  list_add(list1, 6);
  list_add(list1, 1);
  list_add(list1, 5);
  list_add(list2, 9);
  reverse(list1, list2);
  list_print(list1);
  list_print(list2);
  // Destruction
  list_destroy(list1);
  list_destroy(list2);
  free(list1);
  free(list2);
  return 0;
}

代码框 6-15 [ExtremeC_examples_chapter6_3_main.c]:示例 6.3 的main函数

如您在前面的代码框中所见,我们只根据头文件中声明的功能编写了mainreverse函数。换句话说,这些函数仅使用了List类的公共 API(或公共接口);属性结构list_t及其行为函数的声明。这个例子很好地展示了如何打破依赖关系,并从代码的其他部分隐藏实现细节。

注意:

使用公共 API,您可以编写一个可以编译的程序,但除非您提供私有部分的相应对象文件并将它们链接在一起,否则它不能成为一个真正的可工作程序。

在前面的代码中,有一些相关点我们在下面进行了更详细的探讨。我们需要有一个list_malloc函数来为list_t对象分配内存。然后,当我们完成对象时,我们可以使用free函数释放分配的内存。

在前面的例子中,您不能直接使用malloc。这是因为如果您打算在main函数中使用malloc,您必须传递sizeof(list_t)作为应该分配的字节数。然而,您不能对不完整类型使用sizeof

从头文件中包含的list_t类型是一个不完整类型,因为它只是一个声明,没有提供有关其内部字段的信息,并且在编译时我们不知道它的大小。实际的大小只有在链接时间确定实现细节时才会确定。作为解决方案,我们必须定义list_malloc函数,并在sizeof(list_t)确定的地方使用malloc

为了构建示例 6.3,我们首先需要编译源文件。以下命令在链接阶段之前生成必要的对象文件:

$ gcc -c ExtremeC_examples_chapter6_3.c -o private.o
$ gcc -c ExtremeC_examples_chapter6_3_main.c -o main.o

Shell Box 6-1: 编译示例 6.3

如你所见,我们将私有部分编译成 private.o,将主要部分编译成 main.o。记住,我们不编译头文件。头文件中的公共声明包含在 main.o 对象文件中。

现在我们需要将前面的对象文件链接在一起,否则 main.o 单独不能变成可执行程序。如果你尝试仅使用 main.o 创建可执行文件,你会看到以下错误:

$ gcc main.o -o ex6_3.out
main.o: In function 'reverse':
ExtremeC_examples_chapter6_3_main.c:(.text+0x27): undefined reference to 'list_clear'
...
main.o: In function 'main':
ExtremeC_examples_chapter6_3_main.c:(.text+0xa5): undefined reference to 'list_malloc'
...                                                                                                                               collect2: error: ld returned 1 exit status
$

Shell Box 6-2: 仅通过提供 main.o 尝试链接示例 6.3

你会看到链接器找不到在头文件中声明的函数的定义。链接示例的正确方法是:

$ gcc main.o private.o -o ex6_3.out
$ ./ex6_3.out
[4 6 1 5 ]
[5 1 6 4 ]
$

Shell Box 6-3: 将示例 6.3 链接并运行

如果你更改 List 类背后的实现会发生什么?

假设,而不是使用数组,你使用链表。看起来我们不需要再次生成 main.o,因为它很好地独立于它使用的列表的实现细节。因此,我们只需要为新实现编译并生成一个新的对象文件;例如,private2.o。然后,我们只需要重新链接对象文件并获取新的可执行文件:

$ gcc main.o private2.o -o ex6_3.out
$ ./ex6_3.out
[4 6 1 5 ]
[5 1 6 4 ]
$

Shell Box 6-4: 将示例 6.3 与 List 类的不同实现链接并运行

如你所见,从用户的角度来看,没有任何变化,但底层实现已被替换。这是一项巨大的成就,这种方法在 C 项目中被广泛使用。

如果我们想在新的列表实现的情况下不重复链接阶段,该怎么办?在这种情况下,我们可以使用共享库(或 .so 文件)来包含私有对象文件。然后,我们可以在运行时动态加载它,从而无需再次重新链接可执行文件。我们已在 第三章对象文件 中讨论了共享库。

在这里,我们将结束本章,并在下一章继续我们的讨论。接下来的两章将讨论两个类之间可能存在的可能关系。

摘要

在本章中,以下主题已被讨论:

  • 我们对面向对象哲学进行了详尽的解释,以及如何从你的思维导图中提取对象模型。

  • 我们还介绍了领域概念及其如何用于过滤思维导图,仅保留相关概念和想法。

  • 我们还介绍了单个对象的属性和行为,以及它们应该如何从领域描述中的思维导图或需求中提取。

  • 我们解释了为什么 C 不能成为面向对象编程(OOP)语言,并探讨了它在将 OOP 程序翻译成最终将在 CPU 上运行的底层汇编指令中的作用。

  • 封装作为面向对象编程(OOP)的第一原则已被讨论。我们使用封装来创建胶囊(或对象),它们包含一组属性(值的占位符)和一组行为(逻辑的占位符)。

  • 信息隐藏也被讨论了,包括它如何导致可以不依赖底层实现而使用的接口(或 API)。

  • 在讨论信息隐藏时,我们展示了如何在 C 代码中使属性或方法私有化。

下一章将开启关于类之间可能存在关系的讨论。我们开始第七章组合与聚合,先讨论组合关系,然后,在第八章继承与多态中,我们继续讨论继承和多态。

第七章

组成和聚合

在前一章中,我们讨论了封装和信息隐藏。在这一章中,我们继续讨论 C 语言中的面向对象,我们将讨论两个类之间可能存在的各种关系。最终,这将使我们能够扩展我们的对象模型,并将对象之间的关系作为即将到来的章节中的内容表达出来。

作为本章的一部分,我们讨论:

  • 两个对象及其对应类之间可能存在的关系类型:我们将讨论拥有存在关系,但本章的重点将是拥有关系。

  • 组合作为我们的第一种拥有关系:我们将给出一个示例来演示两个类之间的真实组合关系。使用这个示例,我们探索了在组合情况下通常具有的内存结构。

  • 聚合作为第二种拥有关系:它与组合类似,因为它们都处理拥有关系。但它们是不同的。我们将给出一个单独的完整示例来涵盖聚合案例。聚合和组合之间的区别将在与这些关系相关的内存布局中显现。

这是涵盖 C 语言中面向对象编程的四个章节中的第二个。下一章将介绍被称为继承的“存在”关系。

类之间的关系

对象模型是一组相关对象。关系数量可能很多,但两个对象之间可能存在的关系类型却有限。通常,在对象(或其对应的类)之间可以发现两种关系类别:拥有关系和存在关系。

我们将在本章深入探讨拥有关系,并在下一章涵盖存在关系。此外,我们还将看到各种对象之间的关系如何导致它们对应类之间的关系。在处理这些关系之前,我们需要能够区分类和对象。

对象与类

如果您还记得前一章,我们有两种构建对象的方法。一种方法是原型基于的,另一种是类基于的。

在基于原型的方法中,我们构建一个对象要么是空的(没有任何属性或行为),要么是从现有对象克隆而来。在这种情况下,“实例”和“对象”意味着同一件事。因此,基于原型的方法可以读作基于对象的方法;一种从空对象而不是类开始的方法。

在基于类的方法中,我们无法在没有蓝图的情况下构建对象,这个蓝图通常被称为。因此,我们应该从类开始。然后,我们可以从这个类中实例化一个对象。在前一章中,我们解释了隐式封装技术,它将类定义为一组放入头文件中的声明。我们还给出了一些示例,展示了这在 C 语言中的实现方式。

现在,作为本节的一部分,我们想更多地讨论类和对象之间的差异。虽然这些差异似乎很微不足道,但我们想深入研究并仔细研究它们。我们首先通过一个例子开始。

假设我们定义一个类,Person。它具有以下属性:namesurnameage。我们不会讨论行为,因为差异通常来自属性,而不是行为。

在 C 中,我们可以这样编写具有公共属性的Person类:

typedef struct {
  char name[32];
  char surname[32];
  unsigned int age;
} person_t;

代码框 7-1:C 中的 Person 属性结构

在 C++中:

class Person {
public:
  std::string name;
  std::string family;
  uint32_t age;
};

代码框 7-2:C++中的 Person 类

上述代码框是相同的。事实上,当前的讨论可以应用于 C 和 C++,甚至其他面向对象的语言,如 Java。一个类(或对象模板)是一个蓝图,它只决定了每个对象所需的属性,而不是这些属性在一个特定对象中可能具有的值。实际上,每个对象都有其自己的特定值集,这些值与从同一类实例化的其他对象中存在的相同属性相对应。

当基于一个类创建一个对象时,首先分配其内存。这个分配的内存将作为属性值的占位符。之后,我们需要用一些值初始化属性值。这是一个重要的步骤,否则,对象在被创建后可能会处于无效状态。正如您已经看到的,这个步骤被称为构造

通常有一个专门执行构造步骤的函数,这被称为构造函数。在上一章中找到的示例中的list_initcar_construct函数是构造函数。完全有可能在构建对象的过程中,我们需要为该对象所需的其他对象、缓冲区、数组、流等资源分配更多的内存。对象拥有的资源必须在释放拥有者对象之前被释放。

我们还有一个另一个与构造函数类似的功能,它负责释放任何分配的资源。它被称为析构函数。同样,在上一章中找到的示例中的list_destroycar_destruct函数是析构函数。在析构一个对象后,其分配的内存被释放,但在那之前,所有拥有的资源和它们相应的内存必须被释放。

在继续之前,让我们总结一下到目前为止我们已经解释的内容:

  • 类是一个蓝图,用作创建对象的映射。

  • 可以从同一个类中创建许多对象。

  • 一个类决定了每个基于该类创建的未来对象应该具有哪些属性。它并没有说明它们可能具有的值。

  • 类本身不消耗任何内存(除了 C 和 C++以外的某些编程语言之外)并且只存在于源级别和编译时。但对象存在于运行时并且消耗内存。

  • 在创建对象时,首先发生内存分配。此外,内存释放是对象的最后一个操作。

  • 在创建对象时,应该在内存分配之后立即构造它。它也应该在分配之前立即销毁。

  • 一个对象可能拥有一些资源,如流、缓冲区、数组等,在对象被销毁之前必须释放。

既然你已经知道了类和对象之间的区别,我们可以继续解释两个对象及其对应类之间可能存在的不同关系。我们将从组合开始。

组合

正如“组合”一词所暗示的,当一个对象包含或拥有另一个对象——换句话说,它由另一个对象组成——我们说它们之间存在组合关系。

例如,一辆汽车有一个引擎;汽车是一个包含引擎对象的物体。因此,汽车和引擎对象之间存在组合关系。组合关系必须满足的一个重要条件是:包含对象的生存期绑定到容器对象的生存期

只要容器对象存在,包含对象就必须存在。但是当容器对象即将被销毁时,包含对象必须先被销毁。这个条件意味着包含对象通常是容器内部的私有对象。

包含对象的一些部分可能仍然可以通过容器类的公共接口(或行为函数)访问,但包含对象的生存期必须由容器对象内部管理。如果一段代码可以在不破坏容器对象的情况下破坏包含对象,那么它违反了组合关系,这种关系就不再是组合关系。

以下示例,示例 7.1,展示了汽车对象和引擎对象之间的组合关系。

它由五个文件组成:两个头文件,声明了 CarEngine 类的公共接口;两个源文件,包含了 CarEngine 类的实现;最后是一个源文件,包含了 main 函数并执行了一个使用汽车及其引擎对象的简单场景。

注意,在某些领域,我们可以在汽车对象之外拥有引擎对象;例如,在机械工程 CAD 软件中。因此,各种对象之间的关系类型由问题域决定。为了我们的示例,想象一个引擎对象不能存在于汽车对象之外的领域。

以下代码框显示了 Car 类的头文件:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_7_1_CAR_H
#define EXTREME_C_EXAMPLES_CHAPTER_7_1_CAR_H
struct car_t;
// Memory allocator
struct car_t* car_new();
// Constructor
void car_ctor(struct car_t*);
// Destructor
void car_dtor(struct car_t*);
// Behavior functions
void car_start(struct car_t*);
void car_stop(struct car_t*);
double car_get_engine_temperature(struct car_t*);
#endif

代码框 7-3 [ExtremeC_examples_chapter7_1_car.h]:Car 类的公共接口

正如你所见,前面的声明是以与我们上一章最后一个例子中 List 类所做的方式进行的,例子 6.3。其中一个不同之处在于我们为构造函数选择了一个新的后缀;car_new 而不是 car_construct。另一个不同之处在于我们只声明了属性结构 car_t。我们没有定义其字段,这被称为 前向声明car_t 结构的定义将在代码框 7-5 所示的源文件中。请注意,在前面的头文件中,类型 car_t 被视为一个不完整类型,尚未定义。

以下代码框包含了 Engine 类的头文件:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_7_1_ENGINE_H
#define EXTREME_C_EXAMPLES_CHAPTER_7_1_ENGINE_H
struct engine_t;
// Memory allocator
struct engine_t* engine_new();
// Constructor
void engine_ctor(struct engine_t*);
// Destructor
void engine_dtor(struct engine_t*);
// Behavior functions
void engine_turn_on(struct engine_t*);
void engine_turn_off(struct engine_t*);
double engine_get_temperature(struct engine_t*);
#endif

代码框 7-4 [ExtremeC_examples_chapter7_1_engine.h]: Engine 类的公共接口

以下代码框包含了为 CarEngine 类实现的代码。我们首先从 Car 类开始:

#include <stdlib.h>
// Car is only able to work with the public interface of Engine
#include "ExtremeC_examples_chapter7_1_engine.h"
typedef struct {
  // Composition happens because of this attribute
  struct engine_t* engine;
} car_t;
car_t* car_new() {
  return (car_t*)malloc(sizeof(car_t));
}
void car_ctor(car_t* car) {
  // Allocate memory for the engine object
  car->engine = engine_new();
  // Construct the engine object
  engine_ctor(car->engine);
}
void car_dtor(car_t* car) {
  // Destruct the engine object
  engine_dtor(car->engine);
  // Free the memory allocated for the engine object
  free(car->engine);
}
void car_start(car_t* car) {
  engine_turn_on(car->engine);
}
void car_stop(car_t* car) {
  engine_turn_off(car->engine);
}
double car_get_engine_temperature(car_t* car) {
  return engine_get_temperature(car->engine);
}

代码框 7-5 [ExtremeC_examples_chapter7_1_car.c]: Car 类的定义

前面的代码框展示了汽车是如何包含发动机的。正如你所见,我们有一个新的属性作为 car_t 属性结构的一部分,它是 struct engine_t* 类型。组合正是因为这个属性而发生的。

尽管在这个源文件中,类型 struct engine_t* 仍然是不完整的,但在运行时它可以指向一个完整的 engine_t 类型的对象。这个属性将指向作为 Car 类构造函数一部分将要构建的对象,它将在析构函数中释放。在两个地方,汽车对象都存在,这意味着发动机的生命周期包含在汽车的生命周期中。

engine 指针是私有的,并且没有指针从实现中泄漏出来。这是一个重要的注意事项。当你实现组合关系时,不应该有指针泄漏出来,否则它会使外部代码能够改变包含对象的内部状态。就像封装一样,当它提供对对象私有部分的直接访问时,不应该有指针泄漏出来。私有部分应该始终通过行为函数间接访问。

代码框中的 car_get_engine_temperature 函数提供了对发动机的 temperature 属性的访问。然而,关于这个函数有一个重要的注意事项。它使用了发动机的公共接口。如果你注意观察,你会看到 汽车的私有实现 正在消耗 发动机的公共接口

这意味着汽车本身对发动机的实现细节一无所知。这正是它应该的方式。

两个不同类型的对象,在大多数情况下,不应该知道彼此的实现细节。这是信息隐藏所规定的。记住,汽车的行为被认为是发动机的外部行为。

这样,我们可以用替代的实现替换引擎的实现,只要新的实现提供了引擎头文件中声明的相同公共函数的定义,它应该就能正常工作。

现在,让我们看看Engine类的实现:

#include <stdlib.h>
typedef enum {
  ON,
  OFF
} state_t;
typedef struct {
  state_t state;
  double temperature;
} engine_t;
// Memory allocator
engine_t* engine_new() {
  return (engine_t*)malloc(sizeof(engine_t));
}
// Constructor
void engine_ctor(engine_t* engine) {
  engine->state = OFF;
  engine->temperature = 15;
}
// Destructor
void engine_dtor(engine_t* engine) {
  // Nothing to do
}
// Behavior functions
void engine_turn_on(engine_t* engine) {
  if (engine->state == ON) {
    return;
  }
  engine->state = ON;
  engine->temperature = 75;
}
void engine_turn_off(engine_t* engine) {
  if (engine->state == OFF) {
    return;
  }
  engine->state = OFF;
  engine->temperature = 15;
}
double engine_get_temperature(engine_t* engine) {
  return engine->temperature;
}

Code Box 7-6 [ExtremeC_examples_chapter7_1_engine.c]:引擎类的定义

前面的代码只是使用了隐式封装方法来处理其私有实现,这与之前的示例非常相似。但有一点需要注意。如您所见,engine对象并不知道一个外部对象将要将其包含在组合关系中。这就像现实世界一样。当一家公司制造引擎时,并不清楚哪个引擎将进入哪辆汽车。当然,我们本可以保留对容器car对象的指针,但在这个例子中,我们不需要这样做。

下面的代码框演示了创建car对象并调用其一些公开 API 以提取有关汽车引擎信息的场景:

#include <stdio.h>
#include <stdlib.h>
#include "ExtremeC_examples_chapter7_1_car.h"
int main(int argc, char** argv) {
  // Allocate memory for the car object
  struct car_t *car = car_new();
  // Construct the car object
  car_ctor(car);
  printf("Engine temperature before starting the car: %f\n",
          car_get_engine_temperature(car));
  car_start(car);
  printf("Engine temperature after starting the car: %f\n",
          car_get_engine_temperature(car));
  car_stop(car);
  printf("Engine temperature after stopping the car: %f\n",
          car_get_engine_temperature(car));
  // Destruct the car object
  car_dtor(car);
  // Free the memory allocated for the car object
  free(car);
  return 0;
}

Code Box 7-7 [ExtremeC_examples_chapter7_1_main.c]:示例 7.1 的主函数

要构建前面的示例,首先我们需要编译前三个源文件。然后,我们需要将它们链接在一起以生成最终的可执行目标文件。请注意,主源文件(包含main函数的源文件)只依赖于汽车公开的接口。因此,在链接时,它只需要car对象的私有实现。然而,car对象的私有实现依赖于引擎接口的公开接口;因此,在链接时,我们需要提供engine对象的私有实现。因此,我们需要链接所有三个目标文件才能得到最终的可执行文件。

以下命令显示了如何构建示例并运行最终的可执行文件:

$ gcc -c ExtremeC_examples_chapter7_1_engine.c -o engine.o
$ gcc -c ExtremeC_examples_chapter7_1_car.c -o car.o
$ gcc -c ExtremeC_examples_chapter7_1_main.c -o main.o
$ gcc engine.o car.o main.o -o ex7_1.out
$ ./ex7_1.out
Engine temperature before starting the car: 15.000000
Engine temperature after starting the car: 75.000000
Engine temperature after stopping the car: 15.000000
$

Shell Box 7-1:示例 7.1 的编译、链接和执行

在本节中,我们解释了两个对象之间可能存在的一种关系类型。在下一节中,我们将讨论另一种关系。它与组合关系有相似的概念,但有一些显著的区别。

聚合

聚合也涉及一个包含另一个对象的容器对象。主要区别在于,在聚合中,包含对象的生存期独立于容器对象的生存期。

在聚合中,包含的对象甚至可以在容器对象构建之前就被构建。这与组合相反,在组合中,包含的对象的生存期应该短于或等于容器对象的生存期。

以下示例,示例 7.2,演示了聚合关系。它描述了一个非常简单的游戏场景,其中玩家拿起枪,射击多次,然后放下枪。

player 对象将暂时成为容器对象,而 gun 对象将作为被包含对象,只要玩家对象持有它。枪对象的生命周期独立于玩家对象的生命周期。

以下代码框展示了 Gun 类的头文件:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_7_2_GUN_H
#define EXTREME_C_EXAMPLES_CHAPTER_7_2_GUN_H
typedef int bool_t;
// Type forward declarations
struct gun_t;
// Memory allocator
struct gun_t* gun_new();
// Constructor
void gun_ctor(struct gun_t*, int);
// Destructor
void gun_dtor(struct gun_t*);
// Behavior functions
bool_t gun_has_bullets(struct gun_t*);
void gun_trigger(struct gun_t*);
void gun_refill(struct gun_t*);
#endif

代码框 7-8 [ExtremeC_examples_chapter7_2_gun.h]: 枪类的公共接口

正如你所见,我们只声明了 gun_t 属性结构,因为我们还没有定义其字段。正如我们之前解释的,这被称为前置声明,它导致了一个不完整类型,不能被实例化。

以下代码框展示了 Player 类的头文件:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_7_2_PLAYER_H
#define EXTREME_C_EXAMPLES_CHAPTER_7_2_PLAYER_H
// Type forward declarations
struct player_t;
struct gun_t;
// Memory allocator
struct player_t* player_new();
// Constructor
void player_ctor(struct player_t*, const char*);
// Destructor
void player_dtor(struct player_t*);
// Behavior functions
void player_pickup_gun(struct player_t*, struct gun_t*);
void player_shoot(struct player_t*);
void player_drop_gun(struct player_t*);
#endif

代码框 7-9 [ExtremeC_examples_chapter7_2_player.h]: 玩家类的公共接口

上述代码框定义了所有玩家对象的公共接口。换句话说,它定义了 Player 类的公共接口。

再次,我们必须转发 gun_tplayer_t 结构的声明。我们需要声明 gun_t 类型,因为 Player 类的一些行为函数的参数是这种类型。

Player 类的实现如下:

#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "ExtremeC_examples_chapter7_2_gun.h"
// Attribute structure
typedef struct {
  char* name;
  struct gun_t* gun;
} player_t;
// Memory allocator
player_t* player_new() {
  return (player_t*)malloc(sizeof(player_t));
}
// Constructor
void player_ctor(player_t* player, const char* name) {
  player->name =
      (char*)malloc((strlen(name) + 1) * sizeof(char));
  strcpy(player->name, name);
  // This is important. We need to nullify aggregation pointers
  // if they are not meant to be set in constructor.
  player->gun = NULL;
}
// Destructor
void player_dtor(player_t* player) {
  free(player->name);
}
// Behavior functions
void player_pickup_gun(player_t* player, struct gun_t* gun) {
  // After the following line the aggregation relation begins.
  player->gun = gun;
}
void player_shoot(player_t* player) {
  // We need to check if the player has picked up the gun
  // otherwise, shooting is meaningless
  if (player->gun) {
    gun_trigger(player->gun);
  } else {
    printf("Player wants to shoot but he doesn't have a gun!");
    exit(1);
  }
}
void player_drop_gun(player_t* player) {
  // After the following line the aggregation relation
  // ends between two objects. Note that the object gun
  // should not be freed since this object is not its
  // owner like composition.
  player->gun = NULL;
}

代码框 7-10 [ExtremeC_examples_chapter7_2_player.c]: 玩家类的定义

player_t 结构内部,我们声明了一个即将指向 gun 对象的指针属性 gun。我们需要在构造函数中将它置为空,因为与组合不同,这个属性不是作为构造函数的一部分设置的。

如果需要在构造时设置聚合指针,则应将目标对象的地址作为参数传递给构造函数。然后,这种情况被称为 强制聚合

如果聚合指针可以在构造函数中留为 null,那么它就是一个 可选聚合,如前面的代码所示。在构造函数中置空可选聚合指针是很重要的。

在函数 player_pickup_gun 中,聚合关系开始,当玩家丢弃枪时,在函数 player_drop_gun 中结束。

注意,在解除聚合关系后,我们需要将指针 gun 置为空。与组合不同,容器对象不是被包含对象的 所有者。因此,它对其生命周期没有控制权。因此,我们不应该在任何地方释放玩家实现代码中的枪对象。

在可选的聚合关系中,我们可能在程序中的某个点没有设置被包含对象。因此,在使用聚合指针时应该小心,因为对未设置或 null 的指针的任何访问都可能导致段错误。这就是为什么在函数 player_shoot 中,我们检查 gun 指针是否有效的原因。如果聚合指针为空,这意味着使用玩家对象的代码正在误用它。如果是这种情况,我们将通过返回进程的 退出 代码 1 来中止执行。

以下代码是 Gun 类的实现:

#include <stdlib.h>
typedef int bool_t;
// Attribute structure
typedef struct {
  int bullets;
} gun_t;
// Memory allocator
gun_t* gun_new() {
  return (gun_t*)malloc(sizeof(gun_t));
}
// Constructor
void gun_ctor(gun_t* gun, int initial_bullets) {
  gun->bullets = 0;
  if (initial_bullets > 0) {
    gun->bullets = initial_bullets;
  }
}
// Destructor
void gun_dtor(gun_t* gun) {
  // Nothing to do
}
// Behavior functions
bool_t gun_has_bullets(gun_t* gun) {
  return (gun->bullets > 0);
}
void gun_trigger(gun_t* gun) {
  gun->bullets--;
}
void gun_refill(gun_t* gun) {
  gun->bullets = 7;
}

代码框 7-11 [ExtremeC_examples_chapter7_2_gun.c]:枪类定义

上述代码很简单,并且以这种方式编写,枪对象不知道它将被包含在任何对象中。

最后,以下代码框演示了一个简短的场景,该场景创建了一个 player 对象和一个 gun 对象。然后,玩家拿起枪并使用它直到没有弹药。之后,玩家重新装填枪并重复同样的操作。最后,他们丢弃了枪:

#include <stdio.h>
#include <stdlib.h>
#include "ExtremeC_examples_chapter7_2_player.h"
#include "ExtremeC_examples_chapter7_2_gun.h"
int main(int argc, char** argv) {
  // Create and constructor the gun object
  struct gun_t* gun = gun_new();
  gun_ctor(gun, 3);
  // Create and construct the player object
  struct player_t* player = player_new();
  player_ctor(player, "Billy");
  // Begin the aggregation relation.
  player_pickup_gun(player, gun);
  // Shoot until no bullet is left.
  while (gun_has_bullets(gun)) {
    player_shoot(player);
  }
  // Refill the gun
  gun_refill(gun);
  // Shoot until no bullet is left.
  while (gun_has_bullets(gun)) {
    player_shoot(player);
  }
  // End the aggregation relation.
  player_drop_gun(player);
  // Destruct and free the player object
  player_dtor(player);
  free(player);
  // Destruct and free the gun object
  gun_dtor(gun);
  free(gun);
  return 0;
}

代码框 7-12 [ExtremeC_examples_chapter7_2_main.c]:示例 7.2 的主函数

正如你所见,gunplayer 对象是相互独立的。创建和销毁这些对象的责任逻辑是 main 函数。在执行过程中某个时刻,它们形成一个聚合关系并执行其角色,然后在另一个时刻,它们再次分离。在聚合关系中重要的是,容器对象不应改变包含对象的生存期,只要遵循这个规则,就不会出现内存问题。

下面的 shell 框中展示了如何构建示例并运行生成的可执行文件。正如你所见,代码框 7-12 中的 main 函数没有产生任何输出:

$ gcc -c ExtremeC_examples_chapter7_2_gun.c -o gun.o $ gcc -c ExtremeC_examples_chapter7_2_player.c -o player.o $ gcc -c ExtremeC_examples_chapter7_2_main.c -o main.o $ gcc gun.o player.o main.o -o ex7_2.out $ ./ex7_2.out $

Shell 框 7-2:示例 7.2 的编译、链接和执行

在为真实项目创建的对象模型中,聚合关系的数量通常大于组合关系的数量。此外,由于为了建立聚合关系,至少在容器对象的公共接口中需要一些专门的行为函数来设置和重置包含的对象,因此聚合关系在外部更明显。

如前例所示,gunplayer 对象从一开始就是分离的。它们短暂地建立关系,然后再次分离。这意味着聚合关系是临时的,而组合关系是永久的。这表明组合是对象之间一种更强的拥有(to-have)关系形式,而聚合则表现出较弱的关系。

现在,一个问题浮现在脑海中。如果两个对象之间的聚合关系是临时的,那么它们对应的类之间的聚合关系也是临时的吗?答案是:不是。聚合关系在类型之间是永久的。如果将来有极小的可能性,两个不同类型的对象基于聚合关系建立关系,那么它们的类型应该永久处于聚合关系中。这也适用于组合关系。

即使存在聚合关系的可能性很低,我们也应该在容器对象的属性结构中声明一些指针,这意味着属性结构将永久改变。当然,这仅适用于基于类的编程语言。

组合和聚合都描述了某些对象的拥有。换句话说,这些关系描述了一种“拥有”或“有”的情况;一个玩家拥有一把枪,或者一辆车一个引擎。每次你感觉到一个对象拥有另一个对象时,这意味着它们之间(以及它们对应的类)应该存在组合关系或聚合关系。

在下一章中,我们将通过查看继承扩展关系来继续我们关于关系类型的讨论。

摘要

在本章中,我们讨论了以下主题:

  • 类和对象之间可能的关系类型。

  • 类、对象、实例和引用之间的区别和相似之处。

  • 组合,意味着包含的对象完全依赖于其容器对象。

  • 聚合,其中包含的对象可以自由地生活,而不依赖于其容器对象。

  • 聚合可以在对象之间是临时的,但在它们的类型(或类)之间是永久定义的。

在下一章中,我们继续探索面向对象编程(OOP),主要解决它基于的两个进一步支柱:继承和多态。

第八章

继承和多态

本章是前两章的延续,在前两章中,我们介绍了如何在 C 中进行面向对象编程,并达到了组合和聚合的概念。本章主要继续讨论对象与其对应类之间的关系,并涵盖继承和多态。作为本章的一部分,我们总结了这个主题,并在下一章继续讨论抽象

本章在很大程度上依赖于前两章中解释的理论,在前两章中,我们讨论了类之间可能存在的关系。我们解释了组合聚合关系,现在我们将在本章中讨论扩展继承关系,以及一些其他主题。

本章将解释以下主题:

  • 如前所述,继承关系是我们首先讨论的主题。我们将介绍在 C 中实现继承关系的方法,并进行比较。

  • 下一个重要主题是多态性。多态性允许我们在子类中拥有相同行为的不同版本,在那些类之间存在继承关系的情况下。我们将讨论在 C 中实现多态函数的方法;这将是我们理解 C++如何提供多态性的第一步。

让我们从继承关系开始讨论。

继承

我们在前一章结束时讨论了拥有关系,这最终引导我们到了组合和聚合关系。在本节中,我们将讨论属于关系。继承关系是一种是关系。

继承关系也可以称为扩展关系,因为它只向现有的对象或类添加额外的属性和行为。在接下来的几节中,我们将解释继承的含义以及如何在 C 中实现它。

有时一个对象需要拥有存在于另一个对象中的相同属性。换句话说,新对象是另一个对象的扩展。

例如,一个学生具有人的所有属性,但也可能有额外的属性。参见代码框 8-1

typedef struct {
  char first_name[32];
  char last_name[32];
  unsigned int birth_year;
} person_t;
typedef struct {
  char first_name[32];
  char last_name[32];
  unsigned int birth_year;
 char student_number[16]; // Extra attribute
 unsigned int passed_credits; // Extra attribute
} student_t;

代码框 8-1:Person 类和 Student 类的属性结构

这个例子清楚地展示了student_t如何通过新的属性student_numberpassed_credits扩展了person_t的属性,这些属性是特定于学生的。

正如我们之前指出的,继承(或扩展)是一种将要成为的关系,与组合和聚合不同,它们是拥有关系。因此,对于前面的例子,我们可以说“一个学生是一个人”,这在教育软件的领域中似乎是正确的。每当一个将要成为的关系存在于一个领域中,它可能就是一个继承关系。在前面的例子中,person_t 通常被称为 超类型,或 基类型,或简单地称为 父类型,而 student_t 通常被称为 子类型继承子类型

继承的本质

如果你深入挖掘并了解继承关系真正是什么,你会发现它本质上实际上是一种组合关系。例如,我们可以说一个学生体内有人的本质。换句话说,我们可以假设在 Student 类的属性结构内部有一个私有的 person 对象。也就是说,继承关系可以等同于一对一的组合关系。

因此,代码框 8-1 中的结构可以写成如下:

typedef struct {
  char first_name[32];
  char last_name[32];
  unsigned int birth_year;
} person_t;
typedef struct {
 person_t person;
  char student_number[16]; // Extra attribute
  unsigned int passed_credits; // Extra attribute
} student_t;

代码框 8-2:Person 和 Student 类的属性结构,但这次是嵌套的

这种语法在 C 语言中是完全有效的,实际上通过使用结构变量(而非指针)嵌套结构是一种强大的设置。它允许你在新的结构中拥有一个结构变量,这实际上是对之前结构的扩展。

在上述设置中,必然有一个 person_t 类型的字段作为第一个字段,一个 student_t 指针可以轻松地转换为 person_t 指针,并且它们都可以指向内存中的相同地址。

这被称为 向上转型。换句话说,将子属性结构的指针类型转换为父属性结构类型的类型是向上转型。请注意,使用结构变量时,您无法拥有此功能。

示例 8.1 如下演示了这一点:

#include <stdio.h>
typedef struct {
  char first_name[32];
  char last_name[32];
  unsigned int birth_year;
} person_t;
typedef struct {
  person_t person;
  char student_number[16]; // Extra attribute
  unsigned int passed_credits; // Extra attribute
} student_t;
int main(int argc, char** argv) {
  student_t s;
 student_t* s_ptr = &s;
 person_t* p_ptr = (person_t*)&s;
  printf("Student pointer points to %p\n", (void*)s_ptr);
  printf("Person pointer points to %p\n", (void*)p_ptr);
  return 0;
}

代码框 8-3 [ExtremeC_examples_chapter8_1.c]:示例 8.1,展示了 Student 和 Person 对象指针之间的向上转型

如您所见,我们预计 s_ptrp_ptr 指针都指向内存中的相同地址。以下是在构建和运行 示例 8.1 后的输出:

$ gcc ExtremeC_examples_chapter8_1.c -o ex8_1.out
$ ./ex8_1.out
Student pointer points to 0x7ffeecd41810
Person pointer points to 0x7ffeecd41810
$

Shell 框 8-1:示例 8.1 的输出

是的,它们指向的是同一个地址。请注意,显示的地址在每次运行中可能不同,但重点是这些指针正在引用相同的地址。这意味着 student_t 类型的结构变量实际上在内存布局中继承了 person_t 结构。这暗示我们可以使用指向 student 对象的指针使用 Person 类的函数行为。换句话说,Person 类的行为函数可以用于 student 对象,这是一个巨大的成就。

注意以下内容是错误的,代码无法编译:

struct person_t;
typedef struct {
 struct person_t person; // Generates an error! 
  char student_number[16]; // Extra attribute
  unsigned int passed_credits; // Extra attribute
} student_t;

代码框 8-4:无法编译的继承关系建立!

声明person字段的行会生成错误,因为你不能从一个不完整类型创建变量。你应该记住,结构的向前声明(类似于代码框 8-4中的第一行)会导致不完整类型的声明。你可以只有不完整类型的指针,不能有变量。正如你之前看到的,你甚至不能为不完整类型分配堆内存。

那么,这意味着什么呢?这意味着,如果你打算使用嵌套结构变量来实现继承,student_t结构应该看到person_t的实际定义,根据我们关于封装所学的知识,它应该是私有的,并且对任何其他类不可见。

因此,你有两种实现继承关系的方法:

  • 让子类能够访问基类的私有实现(实际定义)。

  • 让子类只能访问基类的公共接口。

C 语言中实现继承的第一种方法

我们将在以下示例中演示第一种方法,即示例 8.2,第二种方法将在下一节中的示例 8.3中展示。它们都代表了相同的类,StudentPerson,具有一些行为函数,在main函数中的一些对象在一个简单场景中发挥作用。

我们将从示例 8.2开始,其中Student类需要访问Person类属性结构的实际私有定义。以下代码框展示了StudentPerson类的头部文件和源代码,以及main函数。让我们从声明Person类的头部文件开始:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_H
// Forward declaration
struct person_t;
// Memory allocator
struct person_t* person_new();
// Constructor
void person_ctor(struct person_t*,
                 const char*  /* first name */,
                 const char*  /* last name */,
                 unsigned int /* birth year */);
// Destructor
void person_dtor(struct person_t*);
// Behavior functions
void person_get_first_name(struct person_t*, char*);
void person_get_last_name(struct person_t*, char*);
unsigned int person_get_birth_year(struct person_t*);
#endif

代码框 8-5 [ExtremeC_examples_chapter8_2_person.h]: 示例 8.2,Person类的公共接口

看一下代码框 8-5中的构造函数。它接受创建person对象所需的所有值:first_namesecond_namebirth_year。正如你所看到的,属性结构person_t是不完整的,因此Student类不能使用前面章节中展示的头部文件来建立继承关系。

另一方面,前面的头部文件不应该包含属性结构person_t的实际定义,因为前面的头部文件将被代码的其他部分使用,这些部分不应该了解Person的内部情况。那么我们应该怎么做呢?我们希望逻辑的一部分了解结构定义,而代码的其他部分不应该了解这个定义。这就是私有头部文件介入的地方。

私有头文件是一个普通头文件,它应该被包含并用于代码的某个部分或某个实际需要它的类。关于 示例 8.2person_t 的实际定义应该是私有头文件的一部分。在下面的代码框中,您将看到一个私有头文件的示例:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_P_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_P_H
// Private definition
typedef struct {
  char first_name[32];
  char last_name[32];
  unsigned int birth_year;
} person_t;
#endif

代码框 8-6 [ExtremeC_ 示例 _ 第八章 _2_person_p.h]: 包含 person_t 实际定义的私有头文件

正如您所见,它只包含 person_t 结构的定义,没有其他内容。这是 Person 类应该保持私有的部分,但它需要成为 Student 类的公共部分。我们将需要这个定义来定义 student_t 属性结构。下一个代码框演示了 Person 类的私有实现:

#include <stdlib.h>
#include <string.h>
// person_t is defined in the following header file.
#include "ExtremeC_examples_chapter8_2_person_p.h"
// Memory allocator
person_t* person_new() {
  return (person_t*)malloc(sizeof(person_t));
}
// Constructor
void person_ctor(person_t* person,
                 const char* first_name,
                 const char* last_name,
                 unsigned int birth_year) {
  strcpy(person->first_name, first_name);
  strcpy(person->last_name, last_name);
  person->birth_year = birth_year;
}
// Destructor
void person_dtor(person_t* person) {
  // Nothing to do
}
// Behavior functions
void person_get_first_name(person_t* person, char* buffer) {
  strcpy(buffer, person->first_name);
}
void person_get_last_name(person_t* person, char* buffer) {
  strcpy(buffer, person->last_name);
}
unsigned int person_get_birth_year(person_t* person) {
  return person->birth_year;
}

代码框 8-7 [ExtremeC_ 示例 _ 第八章 _2_person.c]: Person 类的定义

Person 类的定义没有特别之处,它和所有之前的示例类似。下面的代码框显示了 Student 类的公共接口:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_STUDENT_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_2_STUDENT_H
//Forward declaration
struct student_t;
// Memory allocator
struct student_t* student_new();
// Constructor
void student_ctor(struct student_t*,
                  const char*  /* first name */,
                  const char*  /* last name */,
                  unsigned int /* birth year */,
                  const char*  /* student number */,
                  unsigned int /* passed credits */);
// Destructor
void student_dtor(struct student_t*);
// Behavior functions
void student_get_student_number(struct student_t*, char*);
unsigned int student_get_passed_credits(struct student_t*);
#endif

代码框 8-8 [ExtremeC_ 示例 _ 第八章 _2_student.h]: Student 类的公共接口

如您所见,类的构造函数接受与 Person 类构造函数相似的参数。这是因为 student 对象实际上包含一个 person 对象,并且它需要这些值来填充其组成的 person 对象。

这意味着 student 构造函数需要设置 studentperson 部分的属性。

注意,我们作为 Student 类的一部分只添加了两个额外的行为函数,这是因为我们可以使用 Person 类的行为函数来处理 student 对象。

下一个代码框包含了 Student 类的私有实现:

#include <stdlib.h>
#include <string.h>
#include "ExtremeC_examples_chapter8_2_person.h"
// person_t is defined in the following header
// file and we need it here.
#include "ExtremeC_examples_chapter8_2_person_p.h"
//Forward declaration
typedef struct {
  // Here, we inherit all attributes from the person class and
  // also we can use all of its behavior functions because of
  // this nesting.
 person_t person;
  char* student_number;
  unsigned int passed_credits;
} student_t;
// Memory allocator
student_t* student_new() {
  return (student_t*)malloc(sizeof(student_t));
}
// Constructor
void student_ctor(student_t* student,
                  const char* first_name,
                  const char* last_name,
                  unsigned int birth_year,
                  const char* student_number,
                  unsigned int passed_credits) {
  // Call the constructor of the parent class
 person_ctor((struct person_t*)student,
 first_name, last_name, birth_year);
  student->student_number = (char*)malloc(16 * sizeof(char));
  strcpy(student->student_number, student_number);
  student->passed_credits = passed_credits;
}
// Destructor
void student_dtor(student_t* student) {
  // We need to destruct the child object first.
  free(student->student_number);
  // Then, we need to call the destructor function
  // of the parent class
 person_dtor((struct person_t*)student);
}
// Behavior functions
void student_get_student_number(student_t* student,
                                char* buffer) {
  strcpy(buffer, student->student_number);
}
unsigned int student_get_passed_credits(student_t* student) {
  return student->passed_credits;
}

代码框 8-9 [ExtremeC_ 示例 _ 第八章 _2_student.c]: Student 类的私有定义

前面的代码框包含了关于继承关系最重要的代码。首先,我们需要包含 Person 类的私有头文件,因为作为定义 student_t 的一部分,我们希望有 person_t 类型的第一个字段。而且,由于该字段是一个实际变量而不是指针,这就要求我们已经有 person_t 定义。请注意,这个变量必须是结构的 第一个字段。否则,我们将失去使用 Person 类的行为函数的可能性。

再次,在前面的代码框中,作为 Student 类构造函数的一部分,我们调用父构造函数来初始化父(组合)对象的属性。看看我们如何将 student_t 指针转换为 person_t 指针,当传递给 person_ctor 函数时。这仅仅是因为 person 字段是 student_t 的第一个成员。

同样,作为 Student 类析构函数的一部分,我们调用了父类的析构函数。这种销毁应该首先在子级发生,然后在父级发生,与构建的顺序相反。下一个代码框包含 示例 8.2 的主场景,它将使用 Student 类并创建一个 Student 类型的对象:

#include <stdio.h>
#include <stdlib.h>
#include "ExtremeC_examples_chapter8_2_person.h"
#include "ExtremeC_examples_chapter8_2_student.h"
int main(int argc, char** argv) {
  // Create and construct the student object
  struct student_t* student = student_new();
  student_ctor(student, "John", "Doe",
          1987, "TA5667", 134);
  // Now, we use person's behavior functions to
  // read person's attributes from the student object
  char buffer[32];
  // Upcasting to a pointer of parent type
 struct person_t* person_ptr = (struct person_t*)student;
  person_get_first_name(person_ptr, buffer);
  printf("First name: %s\n", buffer);
  person_get_last_name(person_ptr, buffer);
  printf("Last name: %s\n", buffer);
  printf("Birth year: %d\n", person_get_birth_year(person_ptr)); 
  // Now, we read the attributes specific to the student object.
  student_get_student_number(student, buffer);
  printf("Student number: %s\n", buffer);
  printf("Passed credits: %d\n",
          student_get_passed_credits(student));
  // Destruct and free the student object
  student_dtor(student);
  free(student);
  return 0;
}

代码框 8-10 [ExtremeC_examples_chapter8_2_main.c]: 示例 8.2 的主场景

正如你在主场景中看到的那样,我们包含了 PersonStudent 类的公共接口(不是私有头文件),但我们只创建了一个 student 对象。正如你所看到的,student 对象从其内部的 person 对象继承了所有属性,并且可以通过 Person 类的行为函数来读取。

下面的 Shell 框展示了如何编译和运行 示例 8.2

$ gcc -c ExtremeC_examples_chapter8_2_person.c -o person.o
$ gcc -c ExtremeC_examples_chapter8_2_student.c -o student.o
$ gcc -c ExtremeC_examples_chapter8_2_main.c -o main.o
$ gcc person.o student.o main.o -o ex8_2.out
$ ./ex8_2.out
First name: John
Last name: Doe
Birth year: 1987
Student number: TA5667
Passed credits: 134
$

Shell 框 8-2:构建和运行示例 8.2

下面的示例,示例 8.3,将介绍在 C 语言中实现继承关系的第二种方法。输出应该与 示例 8.2 非常相似。

C 语言中的继承的第二种方法

使用第一种方法,我们将结构变量作为子属性结构中的第一个字段。现在,使用第二种方法,我们将保留对父结构变量的指针。这样,子类就可以独立于父类的实现,这在考虑信息隐藏问题时是个好事。

通过选择第二种方法,我们获得了一些优势,也失去了一些。在演示 示例 8.3 之后,我们将对两种方法进行比较,你将看到使用这些技术各自的优缺点。

下面的 示例 8.3示例 8.2 非常相似,尤其是在输出和最终结果方面。然而,主要区别在于,在这个示例中,Student 类只依赖于 Person 类的公共接口,而不是其私有定义。这很好,因为它解耦了类,使我们能够轻松地更改父类的实现,而不会更改子类的实现。

在前面的示例中,Student 类并没有严格违反信息隐藏原则,但它可以这样做,因为它可以访问 person_t 的实际定义及其字段。因此,它可以读取或修改字段,而无需使用 Person 的行为函数。

正如所述,示例 8.3示例 8.2 非常相似,但有一些基本的不同之处。Person 类在新示例中具有相同的公共接口。但这一点并不适用于 Student 类,其公共接口需要更改。下面的代码框展示了 Student 类的新公共接口:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_3_STUDENT_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_3_STUDENT_H
//Forward declaration
struct student_t;
// Memory allocator
struct student_t* student_new();
// Constructor
void student_ctor(struct student_t*,
                  const char*  /* first name */,
                  const char*  /* last name */,
                  unsigned int /* birth year */,
                  const char*  /* student number */,
                  unsigned int /* passed credits */);
// Destructor
void student_dtor(struct student_t*);
// Behavior functions
void student_get_first_name(struct student_t*, char*);
void student_get_last_name(struct student_t*, char*);
unsigned int student_get_birth_year(struct student_t*);
void student_get_student_number(struct student_t*, char*);
unsigned int student_get_passed_credits(struct student_t*);
#endif

代码框 8-11 [ExtremeC_examples_chapter8_3_student.h]: 学生类的新公共接口

由于你很快就会意识到的原因,Student 类必须重复所有作为 Person 类一部分声明的行为函数。这是因为我们不能再将 student_t 指针转换为 person_t 指针。换句话说,关于 StudentPerson 指针,向上转换不再起作用。

虽然 Person 类的公共接口与 示例 8.2 中没有变化,但其实现已经改变。以下代码框展示了 示例 8.3Person 类的实现:

#include <stdlib.h>
#include <string.h>
// Private definition
typedef struct {
  char first_name[32];
  char last_name[32];
  unsigned int birth_year;
} person_t;
// Memory allocator
person_t* person_new() {
  return (person_t*)malloc(sizeof(person_t));
}
// Constructor
void person_ctor(person_t* person,
                 const char* first_name,
                 const char* last_name,
                 unsigned int birth_year) {
  strcpy(person->first_name, first_name);
  strcpy(person->last_name, last_name);
  person->birth_year = birth_year;
}
// Destructor
void person_dtor(person_t* person) {
  // Nothing to do
}
// Behavior functions
void person_get_first_name(person_t* person, char* buffer) {
  strcpy(buffer, person->first_name);
}
void person_get_last_name(person_t* person, char* buffer) {
  strcpy(buffer, person->last_name);
}
unsigned int person_get_birth_year(person_t* person) {
  return person->birth_year;
}

代码框 8-12 [ExtremeC_examples_chapter8_3_person.c]:Person 类的新实现

如你所见,person_t 的私有定义放置在源文件中,我们不再使用私有头文件。这意味着我们根本不会与其他类,如 Student 类共享定义。我们希望对 Person 类进行完全封装,并隐藏其所有实现细节。

下面的内容是 Student 类的私有实现:

#include <stdlib.h>
#include <string.h>
// Public interface of the person class
#include "ExtremeC_examples_chapter8_3_person.h"
//Forward declaration
typedef struct {
  char* student_number;
  unsigned int passed_credits;
  // We have to have a pointer here since the type
  // person_t is incomplete.
 struct person_t* person;
} student_t;
// Memory allocator
student_t* student_new() {
  return (student_t*)malloc(sizeof(student_t));
}
// Constructor
void student_ctor(student_t* student,
                  const char* first_name,
                  const char* last_name,
                  unsigned int birth_year,
                  const char* student_number,
                  unsigned int passed_credits) {
  // Allocate memory for the parent object
 student->person = person_new();
 person_ctor(student->person, first_name,
 last_name, birth_year);
  student->student_number = (char*)malloc(16 * sizeof(char));
  strcpy(student->student_number, student_number);
  student->passed_credits = passed_credits;
}
// Destructor
void student_dtor(student_t* student) {
  // We need to destruct the child object first.
  free(student->student_number);
  // Then, we need to call the destructor function
  // of the parent class
 person_dtor(student->person);
  // And we need to free the parent object's allocated memory
 free(student->person);
}
// Behavior functions
void student_get_first_name(student_t* student, char* buffer) {
  // We have to use person's behavior function
  person_get_first_name(student->person, buffer);
}
void student_get_last_name(student_t* student, char* buffer) {
  // We have to use person's behavior function
  person_get_last_name(student->person, buffer);
}
unsigned int student_get_birth_year(student_t* student) {
  // We have to use person's behavior function
  return person_get_birth_year(student->person);
}
void student_get_student_number(student_t* student,
                                char* buffer) {
  strcpy(buffer, student->student_number);
}
unsigned int student_get_passed_credits(student_t* student) {
  return student->passed_credits;
}

代码框 8-13 [ExtremeC_examples_chapter8_3_student.c]:Student 类的新实现

如前一个代码框所示,我们通过包含其头文件使用了 Person 类的公共接口。此外,作为 student_t 定义的一部分,我们添加了一个指针字段,该字段指向父 Person 对象。这应该会让你想起上一章中作为组合关系实现的部分。

注意,这个指针字段不需要作为属性结构中的第一个项目。这与我们在第一种方法中看到的情况不同。student_tperson_t 类型的指针不再可以互换,它们指向内存中的不同地址,这些地址不一定相邻。这又与我们在之前的方法中做的不一样。

注意,作为 Student 类构造函数的一部分,我们实例化了父对象。然后,我们通过调用 Person 类的构造函数并传递所需的参数来构建它。这与析构函数相同,我们最后在 Student 类的析构函数中销毁父对象。

由于我们无法使用 Person 类的行为来读取继承的属性,Student 类需要提供其行为函数集来暴露那些继承的私有属性。

换句话说,Student 类必须提供一些包装函数来暴露其内部父 person 对象的私有属性。请注意,Student 对象本身对 Person 对象的私有属性一无所知,这与我们在第一种方法中看到的情况形成对比。

主要场景也与 示例 8.2 中的情况非常相似。以下代码框展示了这一点:

#include <stdio.h>
#include <stdlib.h>
#include "ExtremeC_examples_chapter8_3_student.h"
int main(int argc, char** argv) {
  // Create and construct the student object
  struct student_t* student = student_new();
  student_ctor(student, "John", "Doe",
          1987, "TA5667", 134);
  // We have to use student's behavior functions because the
  // student pointer is not a person pointer and we cannot
  // access to private parent pointer in the student object.
  char buffer[32];
  student_get_first_name(student, buffer);
  printf("First name: %s\n", buffer);
  student_get_last_name(student, buffer);
  printf("Last name: %s\n", buffer);
  printf("Birth year: %d\n", student_get_birth_year(student));
  student_get_student_number(student, buffer);
  printf("Student number: %s\n", buffer);
  printf("Passed credits: %d\n",
          student_get_passed_credits(student));
  // Destruct and free the student object
  student_dtor(student);
  free(student);
  return 0;
}

代码框 8-14 [ExtremeC_examples_chapter8_3_main.c]:示例 8.3 的主要场景

示例 8.2中的主函数相比,我们没有包含Person类的公共接口。我们还必须使用Student类的行为函数,因为student_tperson_t指针不再可以互换。

以下 shell 框演示了如何编译和运行示例 8.3。正如你可能猜到的,输出是相同的:

$ gcc -c ExtremeC_examples_chapter8_3_person.c -o person.o
$ gcc -c ExtremeC_examples_chapter8_3_student.c -o student.o
$ gcc -c ExtremeC_examples_chapter8_3_main.c -o main.o
$ gcc person.o student.o main.o -o ex8_3.out
$ ./ex8_3.out
First name: John
Last name: Doe
Birth year: 1987
Student number: TA5667
Passed credits: 134
$

Shell Box 8-3:构建和运行示例 8.3

在下一节中,我们将比较上述方法来实现 C 语言中的继承关系。

两种方法的比较

现在你已经看到了我们可以采取的两种不同的方法来实现 C 语言中的继承,我们可以比较它们。以下要点概述了两种方法之间的相似之处和不同之处:

  • 两种方法本质上都显示了组合关系。

  • 第一种方法在子类的属性结构中保留一个结构变量,并依赖于对父类私有实现的访问。然而,第二种方法保留指向父类属性结构不完整类型的结构指针,因此它不依赖于父类的私有实现。

  • 在第一种方法中,父类和子类之间有很强的依赖性。在第二种方法中,类之间相互独立,父类实现中的所有内容对子类都是隐藏的。

  • 在第一种方法中,你只能有一个父类。换句话说,这是一种在 C 语言中实现单继承的方法。然而,在第二种方法中,你可以有任意多的父类,从而演示了多继承的概念。

  • 在第一种方法中,父类的结构变量必须是子类属性结构中的第一个字段,但在第二种方法中,指向父对象指针可以放在结构中的任何位置。

  • 在第一种方法中,没有两个独立的父类和子类对象。父类对象包含在子类对象中,指向子类对象的指针实际上是指向父类对象的指针。

  • 在第一种方法中,我们可以使用父类的行为函数,但在第二种方法中,我们需要通过子类中的新行为函数来转发父类的行为函数。

到目前为止,我们只讨论了继承本身,还没有讨论其用法。继承最重要的用法之一是在你的对象模型中实现多态性。在下一节中,我们将讨论多态性以及如何在 C 语言中实现它。

多态性

多态性实际上并不是两个类之间的关系。它主要是一种在保持相同代码的同时实现不同行为的技术。它允许我们在不重新编译整个代码库的情况下扩展代码或添加功能。

在本节中,我们试图解释多态是什么以及我们如何在 C 语言中实现它。这也让我们更好地了解现代编程语言(如 C++)是如何实现多态的。我们将从定义多态开始。

多态是什么?

多态简单地说就是通过使用相同的公共接口(或行为函数集)来拥有不同的行为。

假设我们有两个类,CatDuck,它们各自有一个行为函数sound,这使得它们打印出它们特定的声音。解释多态不是一个容易的任务,我们将尝试自顶向下的方法来解释它。首先,我们将尝试给你一个多态代码看起来如何以及它如何表现的概念,然后我们将深入到在 C 语言中实现它。一旦你有了这个概念,进入实现就会更容易。在以下代码框中,我们首先创建一些对象,然后看看如果多态存在,我们期望多态函数会如何表现。首先,让我们创建三个对象。我们已经假设CatDuck类都是Animal类的子类:

struct animal_t* animal = animal_malloc();
animal_ctor(animal);
struct cat_t* cat = cat_malloc();
cat_ctor(cat);
struct duck_t* duck = duck_malloc();
duck_ctor(duck);

代码框 8-15:创建 Animal、Cat 和 Duck 三种类型的三个对象

没有多态的情况下,我们会像下面这样为每个对象调用sound行为函数:

// This is not a polymorphism
animal_sound(animal);
cat_sound(cat);
duck_sound(duck);

代码框 8-16:在创建的对象上调用发声行为函数

输出结果如下:

Animal: Beeeep
Cat: Meow
Duck: Quack

Shell 框 8-4:函数调用的输出

前面的代码框没有展示多态,因为它使用了不同的函数cat_soundduck_sound来从CatDuck对象中调用特定的行为。然而,下面的代码框展示了我们期望多态函数如何表现。下面的代码框包含了一个完美的多态示例:

// This is a polymorphism
animal_sound(animal);
animal_sound((struct animal_t*)cat);
animal_sound((struct animal_t*)duck);

代码框 8-17:在所有三个对象上调用相同的发声行为函数

尽管调用了三次相同的函数,但我们期望看到不同的行为。看起来传递不同的对象指针会改变animal_sound背后的实际行为。以下 Shell 框将显示如果animal_sound是多态的,则代码框 8-17的输出:

Animal: Beeeep
Cat: Meow
Duck: Quake

Shell 框 8-5:函数调用的输出

正如你在代码框 8-17中看到的,我们使用了相同的函数animal_sound,但是使用了不同的指针,结果在幕后调用了不同的函数。

注意

如果你在理解前面的代码时遇到困难,请不要继续前进;如果你遇到了,请回顾前面的章节。

之前的多态代码意味着Cat类和Duck类之间应该存在一个继承关系,并且有一个第三类Animal,因为我们希望能够将duck_tcat_t指针转换为animal_t指针。这也意味着另一件事:我们必须使用 C 语言中实现继承的第一种方法,以便从我们之前引入的多态机制中受益。

你可能还记得,在实现继承的第一种方法中,子类可以访问父类的私有实现,在这里,animal_t类型的结构变量应该被放在duck_tcat_t属性结构定义中的第一个字段。以下代码显示了这三个类之间的关系:

typedef struct {
  ...} animal_t;
typedef struct {
  animal_t animal;
  ...
} cat_t;
typedef struct {
  animal_t animal;
  ...
} duck_t;

代码框 8-18:Animal、Cat 和 Duck 类属性结构的定义

在这种设置下,我们可以将duck_tcat_t指针转换为animal_t指针,然后我们可以为这两个子类使用相同的行为函数。

到目前为止,我们已经展示了多态函数应该如何表现以及如何在类之间定义继承关系。我们没有展示的是如何实现这种多态行为。换句话说,我们还没有讨论多态背后的实际机制。

假设行为函数animal_sound的定义如代码框 8-19 所示。无论你发送什么指针作为参数,我们都会有相同的行为,并且没有底层机制,函数调用不会是多态的。这个机制将在示例 8.4中作为一部分进行解释,你很快就会看到:

void animal_sound(animal_t* ptr) {
  printf("Animal: Beeeep");
}
// This could be a polymorphism, but it is NOT!
animal_sound(animal);
animal_sound((struct animal_t*)cat);
animal_sound((struct animal_t*)duck);

代码框 8-19:animal_sound函数还不是多态的!

正如你接下来看到的,使用各种指针调用行为函数animal_sound不会改变行为函数的逻辑;换句话说,它不是多态的。我们将在下一个示例,示例 8.4中使这个函数成为多态。

Animal: Beeeep
Animal: Beeeep
Animal: Beeeep

Shell 框 8-6:代码框 8-19 中功能调用的输出

那么,使多态行为函数得以实现的底层机制是什么?我们将在接下来的章节中回答这个问题,但在那之前,我们需要知道为什么我们首先想要多态。

我们为什么需要多态?

在进一步讨论我们在 C 语言中实现多态的方法之前,我们应该花一些时间来谈谈多态需求背后的原因。多态之所以需要,主要原因是我们要保持一段代码“原样”,即使在使用它时与基类型的各种子类型一起使用。你将在接下来的示例中看到一些关于此的演示。

当我们向系统中添加新的子类型或改变一个子类型的行为时,我们不想经常修改当前的逻辑。当添加新功能时,完全没有变化是不现实的——总会有一些变化——但使用多态,我们可以显著减少所需更改的数量。

多态存在的另一个动机是由于抽象的概念。当我们有抽象类型(或类)时,它们通常有一些模糊或未实现的行为函数,这些函数需要在子类中重写,而多态是实现这一点的关键方式。

由于我们想使用抽象类型来编写我们的逻辑,我们需要一种在处理非常抽象类型的指针时调用适当实现的方法。这又是多态性发挥作用的地方。无论什么语言,我们都需要一种实现多态行为的方法,否则维护大型项目的成本可能会迅速增加,例如当我们准备向代码中添加新的子类型时。

既然我们已经确立了多态性的重要性,现在是时候解释如何在 C 语言中实现它了。

如何在 C 语言中实现多态行为

如果我们想在 C 语言中实现多态性,我们需要使用我们之前探索的 C 语言实现继承的第一种方法。为了实现多态行为,我们可以利用函数指针。然而,这次,这些函数指针需要作为属性结构中的某些字段来保留。让我们通过实现动物声音示例来说明这一点。

我们有三个类,AnimalCatDuck,其中CatDuckAnimal的子类型。每个类都有一个头文件和一个源文件。Animal类有一个额外的私有头文件,其中包含其实际的属性结构定义。由于我们正在采用第一种方法实现继承,这个私有头文件是必需的。私有头文件将被CatDuck类使用。

以下代码框展示了Animal类的公共接口:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_H
// Forward declaration
struct animal_t;
// Memory allocator
struct animal_t* animal_new();
// Constructor
void animal_ctor(struct animal_t*);
// Destructor
void animal_dtor(struct animal_t*);
// Behavior functions
void animal_get_name(struct animal_t*, char*);
void animal_sound(struct animal_t*);
#endif

代码框 8-20 [ExtremeC_examples_chapter8_4_animal.h]:Animal类的公共接口

Animal类有两个行为函数。animal_sound函数应该是多态的,可以被子类覆盖,而另一个行为函数animal_get_name不是多态的,子类不能覆盖它。

以下是对animal_t属性结构的私有定义:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_P_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_P_H
// The function pointer type needed to point to
// different morphs of animal_sound
typedef void (*sound_func_t)(void*);
// Forward declaration
typedef struct {
  char* name;
  // This member is a pointer to the function which
  // performs the actual sound behavior
 sound_func_t sound_func;
} animal_t;
#endif

代码框 8-21 [ExtremeC_examples_chapter8_4_animal_p.h]:Animal类的私有头文件

在多态性中,每个子类都可以提供自己的animal_sound函数版本。换句话说,每个子类都可以覆盖从其父类继承的函数。因此,我们需要为每个想要覆盖它的子类提供一个不同的函数。这意味着,如果子类覆盖了animal_sound,则应该调用其覆盖的函数。

正是因为这个原因,我们在这里使用函数指针。每个animal_t实例都将有一个专用于行为animal_sound的函数指针,而这个指针指向类内部的多态函数的实际定义。

对于每个多态行为函数,我们都有一个专用的函数指针。在这里,您将看到我们如何使用这个函数指针在各个子类中进行正确的函数调用。换句话说,我们展示了多态性实际上是如何工作的。

以下代码框展示了Animal类的定义:

#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "ExtremeC_examples_chapter8_4_animal_p.h"
// Default definition of the animal_sound at the parent level
void __animal_sound(void* this_ptr) {
 animal_t* animal = (animal_t*)this_ptr;
 printf("%s: Beeeep\n", animal->name);
}
// Memory allocator
animal_t* animal_new() {
  return (animal_t*)malloc(sizeof(animal_t));
}
// Constructor
void animal_ctor(animal_t* animal) {
  animal->name = (char*)malloc(10 * sizeof(char));
  strcpy(animal->name, "Animal");
  // Set the function pointer to point to the default definition
 animal->sound_func = __animal_sound;
}
// Destructor
void animal_dtor(animal_t* animal) {
  free(animal->name);
}
// Behavior functions
void animal_get_name(animal_t* animal, char* buffer) {
  strcpy(buffer, animal->name);
}
void animal_sound(animal_t* animal) {
  // Call the function which is pointed by the function pointer.
 animal->sound_func(animal);
}

代码框 8-22 [ExtremeC_examples_chapter8_4_animal.c]:Animal类的定义

实际的多态行为发生在代码框 8-22中,在animal_sound函数内部。私有函数__animal_sound是当子类决定不覆盖它时animal_sound函数的默认行为。你将在下一章中看到,多态行为函数有一个默认定义,如果子类没有提供覆盖版本,它将被继承并使用。

接下来,在构造函数animal_ctor中,我们将__animal_sound的地址存储到animal对象的sound_func字段中。记住,sound_func是一个函数指针。在这个设置中,每个子对象都继承了这个函数指针,它指向默认定义__animal_sound

最后一步,在行为函数animal_sound内部,我们只是调用由sound_func字段指向的函数。再次强调,sound_func是函数指针字段,指向实际的声音行为定义,在前面的例子中是__animal_sound。请注意,animal_sound函数更像是一个将实际行为函数作为中继的行为。

使用这种设置,如果sound_func字段指向另一个函数,那么在调用animal_sound时,就会调用那个函数。这就是我们在CatDuck类中用来覆盖默认sound行为定义的技巧。

现在,是时候展示CatDuck类了。以下代码框将展示Cat类的公共接口和私有实现。首先,我们展示Cat类的公共接口:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_CAT_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_4_CAT_H
// Forward declaration
struct cat_t;
// Memory allocator
struct cat_t* cat_new();
// Constructor
void cat_ctor(struct cat_t*);
// Destructor
void cat_dtor(struct cat_t*);
// All behavior functions are inherited from the animal class.
#endif

代码框 8-23 [ExtremeC_examples_chapter8_4_cat.h]: Cat类的公共接口

如你很快就会看到的,它将从其父类Animal类继承sound行为。

以下代码框展示了Cat类的定义:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ExtremeC_examples_chapter8_4_animal.h"
#include "ExtremeC_examples_chapter8_4_animal_p.h"
typedef struct {
  animal_t animal;
} cat_t;
// Define a new behavior for the cat's sound
void __cat_sound(void* ptr) {
 animal_t* animal = (animal_t*)ptr;
 printf("%s: Meow\n", animal->name);
}
// Memory allocator
cat_t* cat_new() {
  return (cat_t*)malloc(sizeof(cat_t));
}
// Constructor
void cat_ctor(cat_t* cat) {
  animal_ctor((struct animal_t*)cat);
  strcpy(cat->animal.name, "Cat");
  // Point to the new behavior function. Overriding
  // is actually happening here.
 cat->animal.sound_func = __cat_sound;
}
// Destructor
void cat_dtor(cat_t* cat) {
  animal_dtor((struct animal_t*)cat);
}

代码框 8-24 [ExtremeC_examples_chapter8_4_cat.c]: Cat类的私有实现

如你在前面的代码框中所见,我们为猫的声音定义了一个新函数__cat_sound。然后在构造函数中,我们将sound_func指针指向这个函数。

现在,覆盖正在发生,从现在起,所有cat对象实际上都会调用__cat_sound而不是__animal_sound。同样的技术也用于Duck类。

以下代码框展示了Duck类的公共接口:

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_DUCK_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_4_DUCK_H
// Forward declaration
struct duck_t;
// Memory allocator
struct duck_t* duck_new();
// Constructor
void duck_ctor(struct duck_t*);
// Destructor
void duck_dtor(struct duck_t*);
// All behavior functions are inherited from the animal class.
#endif

代码框 8-25 [ExtremeC_examples_chapter8_4_duck.h]: Duck类的公共接口

正如你所见,这与Cat类非常相似。让我们来看看Duck类的私有定义:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ExtremeC_examples_chapter8_4_animal.h"
#include "ExtremeC_examples_chapter8_4_animal_p.h"
typedef struct {
  animal_t animal;
} duck_t;
// Define a new behavior for the duck's sound
void __duck_sound(void* ptr) {
 animal_t* animal = (animal_t*)ptr;
 printf("%s: Quacks\n", animal->name);
}
// Memory allocator
duck_t* duck_new() {
  return (duck_t*)malloc(sizeof(duck_t));
}
// Constructor
void duck_ctor(duck_t* duck) {
  animal_ctor((struct animal_t*)duck);
  strcpy(duck->animal.name, "Duck");
  // Point to the new behavior function. Overriding
  // is actually happening here.
 duck->animal.sound_func = __duck_sound;
}
// Destructor
void duck_dtor(duck_t* duck) {
  animal_dtor((struct animal_t*)duck);
}

代码框 8-26 [ExtremeC_examples_chapter8_4_duck.c]: Duck类的私有实现

正如你所看到的,该技术已被用来覆盖 sound 行为的默认定义。定义了一个新的私有行为函数 __duck_sound,它执行鸭特有的声音,并且 sound_func 指针被更新以指向这个函数。这基本上是将多态引入 C++ 的方式。我们将在下一章中更多地讨论这一点。

最后,以下代码框演示了示例 8.4的主要场景:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Only public interfaces
#include "ExtremeC_examples_chapter8_4_animal.h"
#include "ExtremeC_examples_chapter8_4_cat.h"
#include "ExtremeC_examples_chapter8_4_duck.h"
int main(int argc, char** argv) {
  struct animal_t* animal = animal_new();
  struct cat_t* cat = cat_new();
  struct duck_t* duck = duck_new();
  animal_ctor(animal);
  cat_ctor(cat);
  duck_ctor(duck);
 animal_sound(animal);
 animal_sound((struct animal_t*)cat);
 animal_sound((struct animal_t*)duck);
  animal_dtor(animal);
  cat_dtor(cat);
  duck_dtor(duck);
  free(duck);
  free(cat);
  free(animal);
  return 0;
}

代码框 8-27 [ExtremeC_examples_chapter8_4_main.c]:示例 8.4 的主要场景

正如你在前面的代码框中看到的,我们只使用了 AnimalCatDuck 类的公共接口。因此,main 函数对类的内部实现一无所知。通过传递不同的指针调用 animal_sound 函数,展示了多态行为应该如何工作。让我们看看示例的输出。

以下 shell 框展示了如何编译和运行示例 8.4

$ gcc -c ExtremeC_examples_chapter8_4_animal.c -o animal.o
$ gcc -c ExtremeC_examples_chapter8_4_cat.c -o cat.o
$ gcc -c ExtremeC_examples_chapter8_4_duck.c -o duck.o
$ gcc -c ExtremeC_examples_chapter8_4_main.c -o main.o
$ gcc animal.o cat.o duck.o main.o -o ex8_4.out
$ ./ex8_4.out
Animal: Beeeep
Cat: Meow
Duck: Quake
$

Shell 框 8-7:示例 8.4 的编译、执行和输出

正如你在示例 8.4中看到的,在基于类的编程语言中,我们想要实现多态的行为函数需要特别注意,并且应该以不同的方式处理。否则,没有我们作为示例 8.4一部分讨论的底层机制的简单行为函数不能实现多态。这就是为什么我们为这些行为函数取了特殊名称,以及为什么我们在像 C++ 这样的语言中使用特定的关键字来表示函数是多态的。这些函数被称为虚函数。虚函数是可以被子类覆盖的行为函数。虚函数需要被编译器跟踪,并且应该在相应的对象中放置适当的指针,以便在覆盖时指向实际的定义。这些指针在运行时用于执行函数的正确版本。

在下一章中,我们将看到 C++ 如何处理类之间的面向对象关系。我们还将了解 C++ 如何实现多态。我们还将讨论抽象,这是多态的直接结果。

摘要

在本章中,我们继续探索面向对象编程(OOP)中的主题,从上一章结束的地方继续。本章讨论了以下主题:

  • 我们解释了继承是如何工作的,并查看了我们可以在 C 中实现继承的两种方法。

  • 第一种方法允许直接访问父类的所有私有属性,但第二种方法采取了一种更为保守的方法,隐藏了父类的私有属性。

  • 我们比较了这些方法,并看到它们中的每一个在某些用例中可能是合适的。

  • 多态是我们接下来探索的主题。简单来说,它允许我们拥有同一行为的不同版本,并使用抽象超类型的公共 API 调用正确的行为。

  • 我们看到了如何在 C 语言中编写多态代码,并了解了函数指针如何有助于在运行时选择特定行为的正确版本。

下一章将是关于面向对象编程的最后一章。作为其中的一部分,我们将探讨 C++如何处理封装、继承和多态。不仅如此,我们还将讨论抽象这个主题,以及它如何导致一种被称为抽象类的奇特类型的类。我们不能从这些类中创建对象!

第九章

C++中的抽象和 OOP

这是 C 中面向对象编程的最后一章。在本章中,我们将涵盖剩余的主题,并介绍一个新的编程范式。此外,我们将探索 C++,并查看它如何在幕后实现面向对象的概念。

作为本章的一部分,我们将涵盖以下主题:

  • 首先,我们讨论抽象。这继续了我们关于继承和多态的讨论,并将是我们作为 C 中面向对象(OOP)的一部分要覆盖的最后一个主题。我们展示了抽象如何帮助我们设计具有最大可扩展性和最小组件之间依赖性的对象模型。

  • 我们讨论了面向对象的概念是如何在一个著名的 C++编译器g++中实现的。作为这部分内容的一部分,我们看到我们之前讨论的方法与g++提供相同概念的方法是多么接近。

让我们通过讨论抽象来开始本章。

抽象

抽象在科学和工程学的各个领域可以有一个非常广泛的意义。但在编程中,尤其是在面向对象编程(OOP)中,抽象本质上处理的是抽象数据类型。在基于类的面向对象中,抽象数据类型等同于抽象类。抽象类是特殊的类,我们不能从它们中创建对象;它们还没有准备好或足够完整,不能用于对象创建。那么,为什么我们需要这样的类或数据类型呢?这是因为当我们与抽象和通用数据类型一起工作时,我们避免了在代码的各个部分之间创建强烈的依赖关系。

例如,我们可以有如下人类苹果类之间的关系:

人类类的一个对象吃的是苹果类的一个对象

人类类的一个对象吃的是橙子类的一个对象

如果一个人类对象可以吃的类被扩展到不仅仅是苹果橙子,我们就需要向人类类添加更多关系。然而,我们可以创建一个名为水果的抽象类,它是苹果橙子类的父类,并且我们可以将关系设置为人类水果之间。因此,我们可以将前面的两个陈述合并为一个:

人类类的一个对象吃的是水果类子类型的一个对象

水果类是抽象的,因为它缺少关于形状、味道、气味、颜色以及更多特定水果属性的详细信息。只有当我们拥有一个苹果或一个橙子时,我们才知道不同属性的确切值。苹果橙子类被称为具体类型

我们甚至可以添加更多的抽象。人类类可以吃沙拉巧克力。因此,我们可以这样说:

人类类型的一个对象吃的是可食用类子类型的一个对象

正如你所见,Eatable 的抽象级别甚至高于 Fruit。抽象是设计一个具有最小具体类型依赖的对象模型的一种伟大技术,它允许在系统中引入更多具体类型时,对对象模型进行最大程度的未来扩展。

关于前面的例子,我们还可以通过使用 Human 是一个 Eater 的这一事实来进一步抽象。然后,我们可以使我们的声明更加抽象:

来自 Eater 类子类的对象会吃来自 Eatable 类子类的对象

我们可以继续在对象模型中抽象一切,并找到比我们解决问题所需的级别更抽象的抽象数据类型。这通常被称为 过度抽象。这发生在你试图创建没有实际应用(无论是当前还是未来的需求)的抽象数据类型时。无论如何都应该避免这种情况,因为尽管抽象提供了许多好处,但它也可能引起问题。

关于我们需要多少抽象的一般指南可以在 抽象原则 中找到。我从其维基百科 页面 中获得了以下引言。它简单地陈述:

程序中每个重要的功能部分都应该在源代码的单一位置实现。当相似的功能由不同的代码块执行时,通常通过抽象出不同的部分将它们组合在一起是有益的

虽然乍一看你可能看不到任何面向对象或继承的迹象,但通过进一步思考,你会注意到我们使用继承所做的是基于这个原则。因此,作为一般规则,当你不期望在特定逻辑中存在变化时,在那个点引入抽象是没有必要的。

在一种编程语言中,继承和多态是创建抽象所必需的两个能力。例如,一个名为 Eatable 的抽象类相对于其具体类,如 Apple,是一个超类型,这是通过继承实现的。

多态也扮演着重要的角色。在抽象类型中,有一些行为在该抽象级别上 不能 有默认实现。例如,作为使用行为函数(如 eatable_get_taste)实现的属性 taste,在谈论 Eatable 对象时不能有一个确切值。换句话说,如果我们不知道如何定义 eatable_get_taste 行为函数,我们就不能直接从 Eatable 类创建对象。

上述函数只能在子类足够具体时才能定义。例如,我们知道 Apple 对象应该返回 作为它们的味道(我们在这里假设所有苹果都是甜的)。这正是多态发挥作用的地方。它允许子类覆盖其父类的行为并返回适当的味道,例如。

如果您还记得上一章的内容,可以被子类重写的函数称为 虚函数。请注意,一个虚函数可能根本没有任何定义。当然,这会使拥有该函数的类成为抽象类。

通过不断增加抽象层次,我们最终会到达没有任何属性且只包含没有默认定义的虚函数的类。这些类被称为 接口。换句话说,它们暴露了功能,但根本不提供任何实现,并且通常用于在软件项目中创建各种组件之间的依赖关系。例如,在我们前面的例子中,EaterEatable 类是接口。请注意,就像抽象类一样,您绝对不应该从接口创建对象。以下代码展示了为什么在 C 代码中不能这样做。

以下代码框是使用我们在上一章中介绍的技术为前面提到的接口 Eatable 在 C 中编写的等效代码,以实现继承和多态:

typedef enum {SWEET, SOUR} taste_t;
// Function pointer type
typedef taste_t (*get_taste_func_t)(void*);
typedef struct {
  // Pointer to the definition of the virtual function
  get_taste_func_t get_taste_func;
} eatable_t;
eatable_t* eatable_new() { ... }
void eatable_ctor(eatable_t* eatable) {
  // We don't have any default definition for the virtual function
  eatable->get_taste_func = NULL;
}
// Virtual behavior function
taste_t eatable_get_taste(eatable_t* eatable) {
  return eatable->get_taste_func(eatable);
}

代码框 9-1:C 中的 Eatable 接口

如您所见,在构造函数中,我们将 get_taste_func 指针设置为 NULL。因此,调用 eatable_get_taste 虚函数可能导致段错误。从编码的角度来看,这基本上是我们为什么不能从 Eatable 接口创建对象,除了我们从接口的定义和设计角度知道的原因之外。

以下代码框展示了从 Eatable 接口创建对象,这在 C 的角度来看是完全可能且允许的,但它可能导致崩溃,并且绝对不应该这样做:

eatable_t *eatable = eatable_new();
eatable_ctor(eatable);
taste_t taste = eatable_get_taste(eatable); // Segmentation fault!
free(eatable);

代码框 9-2:从 Eatable 接口创建对象并调用其纯虚函数时发生段错误

为了防止我们从一个抽象类型创建对象,我们可以从类的公共接口中移除 分配器函数。如果您还记得我们在上一章中用于在 C 中实现继承的方法,通过移除分配器函数,只有子类能够从父类的属性结构中创建对象。

外部代码随后将不再能够这样做。例如,在先前的例子中,我们不希望任何外部代码能够从结构 eatable_t 中创建任何对象。为了做到这一点,我们需要将属性结构提前声明并使其成为一个不完整类型。然后,我们需要从类中移除公共内存分配器 eatable_new

总结在 C 中创建抽象类所需做的事情,你需要将那些在该抽象级别不应该有默认定义的虚函数指针置为空。在极其高层次的抽象中,我们有一个所有函数指针都为空的接口。为了防止任何外部代码从抽象类型创建对象,我们应该从公共接口中移除分配器函数。

在下一节中,我们将比较 C 和 C++ 中的类似面向对象特性。这让我们了解 C++ 是如何从纯 C 发展而来的。

C++ 中的面向对象结构

在本节中,我们将比较我们在 C 中所做的工作以及著名 C++ 编译器 g++ 使用的底层机制来支持封装、继承、多态和抽象。

我们想展示在 C 和 C++ 中实现面向对象概念的方法之间有紧密的一致性。请注意,从现在开始,每当提到 C++ 时,我们实际上是在指 g++ 作为 C++ 编译器之一的具体实现,而不是 C++ 标准。当然,不同编译器的底层实现可能有所不同,但我们不期望看到很多差异。我们还将使用 g++ 在 64 位 Linux 环境中。

我们将使用之前讨论的技术在 C 中编写面向对象的代码,然后我们将用 C++ 编写相同的程序,最后得出最终结论。

封装

深入研究 C++ 编译器并查看它是如何使用我们迄今为止探索的技术来生成最终可执行文件是困难的,但我们可以使用一个巧妙的技巧来实际看到这一点。这样做的方法是比较两个类似 C 和 C++ 程序生成的汇编指令。

这正是我们将要做的,以证明 C++ 编译器最终生成的汇编指令与使用我们在前几章中讨论的 OOP 技术的 C 程序相同。

示例 9.1 讲述了两个 C 和 C++ 程序实现相同的简单面向对象逻辑。在这个例子中有一个 Rectangle 类,它有一个用于计算面积的行为函数。我们想查看并比较两个程序中相同行为函数生成的汇编代码。以下代码框展示了 C 版本:

#include <stdio.h>
typedef struct {
  int width;
  int length;
} rect_t;
int rect_area(rect_t* rect) {
  return rect->width * rect->length;
}
int main(int argc, char** argv) {
  rect_t r;
  r.width = 10;
  r.length = 25;
  int area = rect_area(&r);
  printf("Area: %d\n", area);
  return 0;
}

代码框 9-3 [ExtremeC_examples_chapter9_1.c]:C 中的封装示例

以下代码框显示了前面程序的 C++ 版本:

#include <iostream>
class Rect {
public:
  int Area() {
    return width * length;
  }
  int width;
  int length;
};
int main(int argc, char** argv) {
  Rect r;
  r.width = 10;
  r.length = 25;
  int area = r.Area();
  std::cout << "Area: " << area << std::endl;
  return 0;
}

代码框 9-4 [ExtremeC_examples_chapter9_1.cpp]:C++ 中的封装示例

因此,让我们生成前面 C 和 C++ 程序的汇编代码:

$ gcc -S ExtremeC_examples_chapter9_1.c -o ex9_1_c.s
$ g++ -S ExtremeC_examples_chapter9_1.cpp -o ex9_1_cpp.s
$

Shell 框 9-1:生成 C 和 C++ 代码的汇编输出

现在,让我们查看ex9_1_c.sex9_1_cpp.s文件,并寻找行为函数的定义。在ex9_1_c.s中,我们应该寻找rect_area符号,而在ex9_1_cpp.s中,我们应该寻找_ZN4Rect4AreaEv符号。请注意,C++会对符号名称进行名称修饰,这就是为什么你需要搜索这个奇怪的符号。C++中的名称修饰已在第二章编译和链接中讨论过。

对于 C 程序,以下是为rect_area函数生成的汇编代码:

$ cat ex9_1_c.s
...
rect_area:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
 movq    %rdi, -8(%rbp)
 movq    -8(%rbp), %rax
 movl    (%rax), %edx
 movq    -8(%rbp), %rax
 movl    4(%rax), %eax
    imull   %edx, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    Ret
    .cfi_endproc
...
$

Shell Box 9-2:rect_area 函数生成的汇编代码

以下是为Rect::Area函数生成的汇编指令:

$ cat ex9_1_cpp.s
...
_ZN4Rect4AreaEv:
.LFB1493:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
 movq    %rdi, -8(%rbp)
 movq    -8(%rbp), %rax
 movl    (%rax), %edx
 movq    -8(%rbp), %rax
 movl    4(%rax), %eax
    imull   %edx, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    Ret
    .cfi_endproc
...
$

Shell Box 9-3:Rect::Area 函数生成的汇编代码

令人难以置信的是,它们确实是相同的!我不确定 C++代码是如何变成前面的汇编代码的,但我确信为前面的 C 函数生成的汇编代码几乎在高度精确的程度上与为 C++函数生成的汇编代码相当。

从这个例子中,我们可以得出结论,C++编译器使用了与我们用于 C 的方法类似的方法,这作为第六章面向对象编程和封装隐式封装的一部分来实现封装。就像我们处理隐式封装一样,你可以在代码框 9-3中看到,将属性结构的指针传递给rect_area函数作为第一个参数。

在两个 shell 框中加粗的汇编指令部分,widthlength变量是通过向第一个参数传递的内存地址添加来读取的。根据System V ABI,第一个指针参数可以在%rdi寄存器中找到。因此,我们可以推断 C++已经将Area函数修改为接受一个指针参数作为其第一个参数,该参数指向对象本身。

关于封装的最后一句话,我们看到了 C 和 C++在封装方面密切相关,至少在这个简单的例子中是这样。让我们看看关于继承是否也是同样的情况。

继承

调查继承比封装更容易。在 C++中,子类的指针可以被分配给父类的指针。此外,子类应该能够访问父类的私有定义。

这两种行为都表明 C++正在使用我们之前章节中讨论的第一种实现继承的方法,以及第二种方法。如果你需要提醒自己这两种方法,请参阅上一章。

然而,C++的继承似乎更复杂,因为 C++支持多重继承,而我们的第一种方法无法支持。在本节中,我们将检查从 C 和 C++中两个类似类实例化的两个对象的内存布局,如示例 9.2所示。

示例 9.2 是关于一个简单的类从另一个简单的类继承,这两个类都没有行为函数。C 版本如下:

#include <string.h>
typedef struct {
  char c;
  char d;
} a_t;
typedef struct {
  a_t parent;
  char str[5];
} b_t;
int main(int argc, char** argv) {
  b_t b;
  b.parent.c = 'A';
  b.parent.d = 'B';
  strcpy(b.str, "1234");
  // We need to set a break point at this line to see the memory layout.
  return 0;
}

代码框 9-5 [ExtremeC_examples_chapter9_2.c]:C 中的继承示例

C++ 版本如下所示:

#include <string.h>
class A {
public:
  char c;
  char d;
};
class B : public A {
public:
  char str[5];
};
int main(int argc, char** argv) {
  B b;
  b.c = 'A';
  b.d = 'B';
  strcpy(b.str, "1234");
  // We need to set a break point at this line to see the memory layout.
  return 0;
}

代码框 9-6 [ExtremeC_examples_chapter9_2.cpp]:C++ 中的继承示例

首先,我们需要编译 C 程序并使用 gdbmain 函数的最后一行设置断点。当执行暂停时,我们可以检查内存布局以及现有的值:

$ gcc -g ExtremeC_examples_chapter9_2.c -o ex9_2_c.out
$ gdb ./ex9_2_c.out
...
(gdb) b ExtremeC_examples_chapter9_2.c:19
Breakpoint 1 at 0x69e: file ExtremeC_examples_chapter9_2.c, line 19.
(gdb) r
Starting program: .../ex9_2_c.out
Breakpoint 1, main (argc=1, argv=0x7fffffffe358) at ExtremeC_examples_chapter9_2.c:20
20    return 0;
(gdb) x/7c &b
0x7fffffffe261: 65 'A'  66 'B'  49 '1'  50 '2'  51 '3'  52 '4'  0 '\000'
(qdb) c
[Inferior 1 (process 3759) exited normally]
(qdb) q
$

Shell 框 9-4:在 gdb 中运行示例 9.2 的 C 版本

如您所见,我们已从 b 对象的地址开始打印了七个字符,如下所示:'A''B''1''2''3''4''\0'。让我们对 C++ 代码也做同样的操作:

$ g++ -g ExtremeC_examples_chapter9_2.cpp -o ex9_2_cpp.out
$ gdb ./ex9_2_cpp.out
...
(gdb) b ExtremeC_examples_chapter9_2.cpp:20
Breakpoint 1 at 0x69b: file ExtremeC_examples_chapter9_2.cpp, line 20.
(gdb) r
Starting program: .../ex9_2_cpp.out
Breakpoint 1, main (argc=1, argv=0x7fffffffe358) at ExtremeC_examples_chapter9_2.cpp:21
21    return 0;
(gdb) x/7c &b
0x7fffffffe251: 65 'A'  66 'B'  49 '1'  50 '2'  51 '3'  52 '4'  0 '\000'
(qdb) c
[Inferior 1 (process 3804) exited normally]
(qdb) q
$

Shell 框 9-5:在 gdb 中运行示例 9.2 的 C++ 版本

如您在前面的两个 Shell 框中看到的,内存布局和存储在属性中的值是相同的。您不应该因为 C++ 中类中行为函数和属性一起出现而感到困惑;它们将作为类外部分别处理。在 C++ 中,无论您在类中将属性放在哪里,它们总是收集在特定对象的同一内存块中,而函数将始终独立于属性,正如我们在 第六章面向对象编程和封装 中查看 隐式封装 时所看到的。

之前的示例演示了 单继承。那么,多继承又是如何呢?在前一章中,我们解释了为什么我们的 C 中实现继承的第一种方法不能支持多继承。我们再次在以下代码框中演示了原因:

typedef struct { ... } a_t;
typedef struct { ... } b_t;
typedef struct {
  a_t a;
  b_t b;
  ...
} c_t;
c_t c_obj;
a_t* a_ptr = (a_ptr*)&c_obj;
b_t* b_ptr = (b_ptr*)&c_obj;
c_t* c_ptr = &c_obj;

代码框 9-7:演示为什么多继承不能与我们在 C 中实现继承的提议的第一种方法一起工作

在前面的代码框中,c_t 类希望继承 a_tb_t 类。在声明这些类之后,我们创建了 c_obj 对象。在前面代码的以下行中,我们创建了不同的指针。

这里的一个重要注意事项是,所有这些指针都必须指向相同的地址a_ptrc_ptr 指针可以安全地与 a_tc_t 类的任何行为函数一起使用,但 b_ptr 指针的使用是危险的,因为它指向 c_t 类中的 a 字段,这是一个 a_t 对象。尝试通过 b_ptr 访问 b_t 内部的字段会导致未定义的行为。

以下代码是前面代码的正确版本,其中所有指针都可以安全使用:

c_t c_obj;
a_t* a_ptr = (a_ptr*)&c_obj;
b_t* b_ptr = (b_ptr*)(&c_obj + sizeof(a_t));
c_t* c_ptr = &c_obj;

代码框 9-8:演示如何更新类型转换以指向正确的字段

如你在 Code Box 9-8 的第三行所见,我们已将 a_t 对象的大小添加到 c_obj 的地址中;这最终导致一个指向 c_tb 字段的指针。请注意,C 中的类型转换并不做任何魔法;它在那里是为了转换类型,并且不会修改传递的值,即前一个案例中的内存地址。最终,在赋值之后,右侧的地址会被复制到左侧。

现在,让我们看看 C++ 中的相同示例,并查看 example 9.3。假设我们有一个 D 类,它从三个不同的类 ABC 继承。以下是为 example 9.3 编写的代码:

#include <string.h>
class A {
public:
  char a;
  char b[4];
};
class B {
public:
  char c;
  char d;
};
class C {
public:
  char e;
  char f;
};
class D : public A, public B, public C {
public:
  char str[5];
};
int main(int argc, char** argv) {
  D d;
  d.a = 'A';
  strcpy(d.b, "BBB");
  d.c = 'C';
  d.d = 'D';
  d.e = 'E';
  d.f = 'F';
  strcpy(d.str, "1234");
  A* ap = &d;
  B* bp = &d;
  C* cp = &d;
  D* dp = &d;
  // We need to set a break point at this line.
  return 0;
}

Code Box 9-9 [ExtremeC_examples_chapter9_3.cpp]:C++ 中的多重继承

让我们编译这个示例,并用 gdb 运行它:

$ g++ -g ExtremeC_examples_chapter9_3.cpp -o ex9_3.out
$ gdb ./ex9_3.out
...
(gdb) b ExtremeC_examples_chapter9_3.cpp:40
Breakpoint 1 at 0x100000f78: file ExtremeC_examples_chapter9_3.cpp, line 40.
(gdb) r
Starting program: .../ex9_3.out
Breakpoint 1, main (argc=1, argv=0x7fffffffe358) at ExtremeC_examples_chapter9_3.cpp:41
41    return 0;
(gdb) x/14c &d
0x7fffffffe25a: 65 'A'  66 'B'  66 'B'  66 'B'  0 '\000'    67 'C'  68 'D'  69 'E'
0x7fffffffe262: 70 'F'  49 '1'  50 '2'  51 '3'  52 '4'  0 '\000'
(gdb)
$

Shell Box 9-6:在 gdb 中编译和运行示例 9.3

如你所见,属性被放置在彼此相邻的位置。这表明父类中的多个对象被保存在 d 对象相同的内存布局中。那么 apbpcpdp 指针呢?如你所见,在 C++ 中,当我们将子指针赋值给父指针(向上转型)时,可以隐式地进行类型转换。

让我们检查当前执行中这些指针的值:

(gdb) print ap
$1 = (A *) 0x7fffffffe25a
(gdb) print bp
$2 = (B *) 0x7fffffffe25f
(gdb) print cp
$3 = (C *) 0x7fffffffe261
(gdb) print dp
$4 = (D *) 0x7fffffffe25a
(gdb)

Shell Box 9-7:打印指针中存储的地址,作为示例 9.3 的一部分

前面的 shell box 显示,d 对象的起始地址,显示为 $4,与 ap 所指向的地址相同,显示为 $1。因此,这清楚地表明 C++ 将类型 A 的对象作为 D 类相应属性结构中的第一个字段。基于指针中的地址和从 x 命令得到的结果,类型为 B 的对象然后是类型为 C 的对象,被放入属于对象 d 的相同内存布局中。

此外,前面的地址显示,C++ 中的类型转换不是一个被动的操作,它可以在转换类型的同时对传递的地址执行一些指针算术。例如,在 Code Box 9-9 中,当在 main 函数中赋值 bp 指针时,地址上增加了五个字节或 sizeof(A)。这是为了克服我们在 C 中实现多重继承时遇到的问题。现在,这些指针可以很容易地用于所有行为函数,而无需你自己进行算术运算。作为一个重要的注意事项,C 的类型转换和 C++ 的类型转换是不同的,如果你假设 C++ 的类型转换与 C 的类型转换一样被动,你可能会看到不同的行为。

现在是时候看看 C 和 C++ 在多态情况下的相似之处了。

多态

比较 C 和 C++中实现多态的底层技术并不是一件容易的事情。在前一章中,我们提出了在 C 中实现多态行为函数的简单方法,但 C++使用了一种更复杂的多态实现机制,尽管基本理念仍然是相同的。如果我们想将我们的方法推广到 C 中的多态实现,我们可以像以下代码框中的伪代码那样做:

// Typedefing function pointer types
typedef void* (*func_1_t)(void*, ...);
typedef void* (*func_2_t)(void*, ...);
...
typedef void* (*func_n_t)(void*, ...);
// Attribute structure of the parent class
typedef struct {
  // Attributes
  ...
  // Pointers to functions
  func_1_t func_1;
  func_2_t func_2;
  ...
  func_n_t func_t;
} parent_t;
// Default private definitions for the
// virtual behavior functions
void* __default_func_1(void* parent, ...) {  // Default definition }
void* __default_func_2(void* parent, ...) {  // Default definition }
...
void* __default_func_n(void* parent, ...) {  // Default definition }
// Constructor
void parent_ctor(parent_t *parent) {
  // Initializing attributes
  ...
  // Setting default definitions for virtual
  // behavior functions
  parent->func_1 = __default_func_1;
  parent->func_2 = __default_func_2;
  ...
  parent->func_n = __default_func_n;
}
// Public and non-virtual behavior functions
void* parent_non_virt_func_1(parent_t* parent, ...) { // Code }
void* parent_non_virt_func_2(parent_t* parent, ...) { // Code }
...
void* parent_non_virt_func_m(parent_t* parent, ...) { // Code }
// Actual public virtual behavior functions
void* parent_func_1(parent_t* parent, ...) {
  return parent->func_1(parent, ...); 
}
void* parent_func_2(parent_t* parent, ...) {
  return parent->func_2(parent, ...); 
}
...
void* parent_func_n(parent_t* parent, ...) { 
  return parent->func_n(parent, ...); 
}

代码框 9-10:伪代码,演示了如何在 C 代码中声明和定义虚函数

如您在前面伪代码中看到的,父类必须在它的属性结构中维护一个函数指针列表。这些函数指针(在父类中)要么指向虚函数的默认定义,要么是空的。作为代码框 9-10的一部分定义的伪类有m个非虚行为函数和n个虚行为函数。

注意

并非所有行为函数都是多态的。多态行为函数被称为虚行为函数或简单地称为虚函数。在某些语言中,例如 Java,它们被称为虚方法

非虚函数不是多态的,调用它们永远不会得到各种行为。换句话说,对非虚函数的调用只是一个简单的函数调用,它只是执行定义中的逻辑,并不将调用传递给另一个函数。然而,虚函数需要将调用重定向到由父类或子类构造函数设置的适当函数。如果一个子类想要覆盖一些继承的虚函数,它应该更新虚函数指针。

注意

输出变量的void*类型可以被替换为任何其他指针类型。我使用了一个通用指针来表明伪代码中的函数可以返回任何东西。

以下伪代码显示了子类如何覆盖代码框 9-10中找到的一些虚函数:

Include everything related to parent class ...
typedef struct {
  parent_t parent;
  // Child attributes
  ...
} child_t;
void* __child_func_4(void* parent, ...) { // Overriding definition }
void* __child_func_7(void* parent, ...) { // Overriding definition }
void child_ctor(child_t* child) {
  parent_ctor((parent_t*)child);
  // Initialize child attributes
  ...
  // Update pointers to functions
  child->parent.func_4 = __child_func_4;
  child->parent.func_7 = __child_func_7;
}
// Child's behavior functions
...

代码框 9-11:C 语言中的伪代码,演示了子类如何覆盖从父类继承的一些虚函数

如您在代码框 9-11中看到的,子类只需要更新父类属性结构中的几个指针。C++采取了类似的方法。当你将行为函数声明为虚函数(使用virtual关键字)时,C++创建一个函数指针数组,这与我们在代码框 9-10中做的方式非常相似。

如您所见,我们为每个虚函数添加了一个函数指针属性,但 C++有更智能的方式来保持这些指针。它只是使用一个名为虚表vtable的数组。虚表是在创建对象之前创建的。它首先在调用基类的构造函数时填充,然后作为子类构造函数的一部分,正如我们在代码框 9-109-11中所示。

由于虚表仅在构造函数中填充,因此应避免在父类或子类构造函数中调用多态方法,因为其指针可能尚未更新,可能指向错误的定义。

作为我们关于 C 和 C++中实现各种面向对象概念的底层机制的最后一次讨论,我们将讨论抽象。

抽象类

在 C++中,可以使用纯虚函数来实现抽象。在 C++中,如果你将成员函数定义为虚函数并将其设置为零,你就声明了一个纯虚函数。看看以下示例:

enum class Taste { Sweet, Sour };
// This is an interface
class Eatable {
public:
  virtual Taste GetTaste() = 0;
};

代码框 9-12:C++中的Eatable接口

在类Eatable内部,我们有一个被设置为零的GetTaste虚函数。GetTaste是一个纯虚函数,这使得整个类成为抽象类。你不能再从*Eatable*类型创建对象,C++不允许这样做。此外,*Eatable*是一个接口,因为它的所有成员函数都是纯虚的。这个函数可以在子类中被重写。

以下是一个重写GetTaste函数的类的示例:

enum class Taste { Sweet, Sour };
// This is an interface
class Eatable {
public:
  virtual Taste GetTaste() = 0;
};
class Apple : public Eatable {
public:
  Taste GetTaste() override {
    return Taste::Sweet;
  }
};

代码框 9-13:实现Eatable接口的两个子类

纯虚函数与虚函数非常相似。实际定义的地址以与虚函数相同的方式保存在虚表中,但有一个区别。纯虚函数指针的初始值是 null,而正常虚函数的指针需要在构造过程中指向默认定义。

与不知道抽象类型的 C 编译器不同,C++编译器知道抽象类型,如果你尝试从抽象类型创建对象,它会生成编译错误。

在本节中,我们比较了使用过去三章中介绍的技术在 C 中使用和g++编译器在 C++中使用的各种面向对象的概念,并比较了它们。我们展示了我们采用的方法在大多数情况下与g++之类的编译器使用的技巧是一致的。

摘要

在本章中,我们总结了面向对象编程(OOP)主题的探索,从抽象开始,通过展示 C 和 C++在面向对象概念方面的相似性继续前进。

在本章中,我们讨论了以下主题:

  • 我们最初讨论了抽象类和接口。使用它们,我们可以有一个接口或部分抽象的类,这可以用来创建具有多态和不同行为的具体子类。

  • 我们然后将我们在 C 中使用的技术带来的面向对象特征的输出与g++产生的输出进行了比较。这是为了展示结果是多么相似。我们得出结论,我们采用的技术在结果上可以非常相似。

  • 我们更深入地讨论了虚拟表。

  • 我们展示了如何使用纯虚函数(这是一个 C++概念,但确实有一个 C 语言的对应物)来声明没有默认定义的虚拟行为。

下一章将介绍 Unix 及其与 C 的关系。它将回顾 Unix 的历史和 C 的发明。它还将解释 Unix 系统的分层架构。

第十章

Unix – 历史 和 架构

您可能已经问过自己,为什么在关于专家级 C 语言的书中会有关于 Unix 的章节。如果您还没有,我邀请您思考,C 语言和 Unix 这两个主题是如何相关联的,以至于在本书中间需要两个专门的章节(这个和下一章)来讨论 C 语言?

答案很简单:如果您认为它们无关,那么您犯了一个大错误。这两个之间的关系很简单;Unix 是第一个使用相当高级的编程语言 C(专为这个目的设计)实现的操作系统,C 语言从 Unix 中获得了声誉和力量。当然,我们关于 C 是一种高级编程语言的陈述现在已经不再正确,C 语言也不再被认为具有如此高级。

回到 20 世纪 70 年代和 80 年代,如果贝尔实验室的 Unix 工程师决定使用另一种编程语言(而不是 C 语言)来开发 Unix 的新版本,那么我们现在就会在谈论那种语言了,这本书也不再是 Extreme C 了。让我们暂停一下,阅读一下 C 语言先驱之一 Dennis M. Ritchie 关于 Unix 对 C 语言成功影响的这段引言:

"毫无疑问,Unix 本身的成功是最重要的因素;它使这种语言对成千上万的人变得可用。当然,反过来,Unix 使用 C 语言及其随后在各种机器上的可移植性对系统的成功也很重要。"

  • Dennis M. Ritchie – C 语言的发展

可在 www.bell-labs.com/usr/dmr/www/chist.html 找到。

作为本章的一部分,我们涵盖了以下主题:

  • 我们简要地谈论了 Unix 的历史以及 C 语言的发明是如何发生的。

  • 我们解释了 C 语言是如何基于 B 语言和 BCPL 开发的。

  • 我们讨论 Unix 的洋葱架构以及它是如何基于 Unix 哲学设计的。

  • 我们描述了用户应用层以及 shell 环,以及程序是如何消耗 shell 环暴露的 API 的。SUS 和 POSIX 标准在本节中得到了解释。

  • 我们讨论内核层以及 Unix 内核应具备哪些特性和功能。

  • 我们讨论 Unix 设备及其在 Unix 系统中的使用方式。

让我们从谈论 Unix 的历史开始本章。

Unix 历史

在本节中,我们将简要介绍 Unix 的历史。这不是一本历史书,所以我们将尽量简短并直截了当,但这里的目的是为了在您的脑海中为 Unix 和 C 永远并排存在打下一些历史基础。

Multics 操作系统和 Unix

在拥有 Unix 之前,我们已经有了 Multics 操作系统。这是一个于 1964 年启动的联合项目,由麻省理工学院、通用电气和贝尔实验室合作领导。Multics 操作系统取得了巨大成功,因为它向世界介绍了一个真正的工作和安全的操作系统。Multics 在从大学到政府网站的所有地方都得到了安装。快进到 2019 年,今天所有的操作系统都在通过 Unix 间接借鉴 Multics 的某些想法。

1969 年,由于我们将很快讨论的各种原因,贝尔实验室的一些人,特别是 Unix 的先驱,如 Ken Thompson 和 Dennis Ritchie,放弃了 Multics,随后贝尔实验室退出了 Multics 项目。但这并不是贝尔实验室的终点;他们设计了一个更简单、更高效的操作系统,这就是 Unix。

你可以在以下链接中了解更多关于 Multics 及其历史的信息:Multics 和其历史

以下链接:Unix 为什么成功而 Multics 为什么失败,也是一个很好的解释 Unix 为什么能够继续存在而 Multics 则被废弃的链接。

比较 Multics 和 Unix 操作系统是很有意义的。在下面的列表中,你将看到在比较 Multics 和 Unix 时发现的相似之处和不同之处:

  • 两者都遵循洋葱架构作为其内部结构。我们的意思是,它们在洋葱架构中都有或多或少相同的环,特别是内核和外壳环。因此,程序员可以在外壳环上编写自己的程序。此外,Unix 和 Multics 提供了一系列实用程序,如 lspwd。在以下章节中,我们将解释 Unix 架构中发现的各个环。

  • Multics 需要昂贵的资源和机器才能运行。它无法安装在普通商品机器上,这是 Unix 兴盛并最终在约 30 年后使 Multics 过时的主要缺点之一。

  • Multics 的设计本身就非常复杂。这是贝尔实验室员工感到沮丧的原因,正如我们之前所说的,这也是他们离开项目的原因。但 Unix 试图保持简单。在第一个版本中,它甚至不是多任务或多用户操作!

你可以在网上了解更多关于 Unix 和 Multics 的信息,并关注那个时代发生的事件。这两个项目都取得了成功,但 Unix 能够繁荣至今并生存下来。

值得分享的是,贝尔实验室一直在开发一个新的分布式操作系统,名为 Plan 9,该系统基于 Unix 项目。你可以在维基百科上了解更多信息:贝尔实验室的 Plan 9

图片

图 10-1:贝尔实验室的 Plan 9(来自维基百科)

我想我们只需要知道 Unix 是对 Multics 提出的思想和创新的简化;它不是什么新东西,因此,我可以在这一点上停止谈论 Unix 和 Multics 的历史。

到目前为止,C 语言在历史上没有留下痕迹,因为它尚未被发明。Unix 的第一个版本完全是使用汇编语言编写的。直到 1973 年,Unix 版本 4 才使用 C 语言编写。

现在,我们即将讨论 C 语言本身,但在那之前,我们必须谈谈 BCPL 和 B 语言,因为它们是通往 C 语言的门户。

BCPL 和 B

BCPL 是由 Martin Richards 创建的,作为一种为编写编译器而发明的编程语言。当贝尔实验室的人作为 Multics 项目的一部分工作时,他们接触到了这种语言。在离开 Multics 项目后,贝尔实验室首先开始使用汇编语言编写 Unix。那是因为,在当时,使用除汇编语言之外的语言开发操作系统是一种反模式!

例如,Multics 项目组的人使用 PL/1 来开发 Multics 系统,这本身很奇怪,但通过这种方式,他们证明了操作系统可以用除了汇编语言之外的高级编程语言成功编写。因此,Multics 成为了使用另一种语言开发 Unix 的主要灵感来源。

尝试使用除汇编语言之外的语言编写操作系统模块的努力,一直伴随着贝尔实验室的 Ken Thompson 和 Dennis Ritchie。他们尝试使用 BCPL,但最终发现需要对语言进行一些修改才能在像 DEC PDP-7 这样的小型计算机上使用。这些变化导致了 B 编程语言的出现。

我们将避免深入探讨 B 语言的特点,但你可以通过以下链接了解更多关于它及其发展方式的信息:

Dennis Ritchie 亲自撰写了后一篇文章,这是解释 C 编程语言发展的好方法,同时分享了关于 B 语言及其特性的宝贵信息。

B 语言在作为系统编程语言方面也存在不足。B 语言是无类型的,这意味着在每次操作中只能处理一个(而不是一个字节)。这使得在具有不同字长的机器上使用该语言变得困难。

因此,随着时间的推移,对语言的进一步修改导致了NBNew B)语言的开发,后来它从 B 语言中继承了结构。这些结构在 B 语言中是无类型的,但在 C 语言中变成了有类型的。最终,在 1973 年,第四版 Unix 可以使用 C 语言开发,其中仍然包含许多汇编代码。

在下一节中,我们将讨论 B 语言和 C 语言之间的差异,以及为什么 C 语言是编写操作系统的顶级现代系统编程语言。

C 语言之路

我认为我们找不到比丹尼斯·里奇本人更好的解释者来解释为什么在遇到 B 语言的困难之后发明了 C 语言。在本节中,我们将列出导致丹尼斯·里奇、肯·汤普森(Ken Thompson)和其他人创造一种新的编程语言而不是使用 B 语言编写 Unix 的原因。

以下是发现 B 语言中的缺陷列表,这些缺陷导致了 C 语言的诞生:

  • B 语言只能处理内存中的字:每个操作都应该以字为单位进行。在当时,有一种能够处理字节的编程语言是一个梦想。这是因为当时的硬件,它以字为基础对内存进行寻址。

  • B 语言无类型:更准确的说法是,B 语言是一种单类型语言。所有变量都属于同一类型:字。因此,如果你有一个包含 20 个字符的字符串(加上末尾的空字符),你必须将其分成多个字并存储在多个变量中。例如,如果字是 4 字节,你将需要 6 个变量来存储 21 个字符的字符串。

  • 无类型意味着多个字节导向的算法,例如字符串操作算法,无法用 B 语言高效编写:这是因为 B 语言使用的是内存字而不是字节,它们不能被有效地用于管理多字节数据类型,如整数和字符串。

  • B 语言不支持浮点运算:在当时,这些运算在新的硬件上越来越普遍,但在 B 语言中并没有支持。

  • 随着像 PDP-1 这样的机器的出现,这些机器能够按字节寻址内存,B 语言显示了它在寻址内存字节方面的低效:这一点在 B 语言指针中变得更加明显,这些指针只能寻址内存中的字,而不能是字节。换句话说,对于想要访问内存中特定字节或字节范围的程序,必须进行更多的计算来计算相应的字索引。

B 语言在当时的困难,尤其是其缓慢的开发和在可用机器上的执行,迫使丹尼斯·里奇(Dennis Ritchie)创造了一种新的语言。这种新语言最初被称为 NB,即 New B,但最终演变成了 C 语言。

这种新开发的语言 C,试图克服 B 语言的困难和缺陷,并成为系统开发的事实上的编程语言,而不是汇编语言。在不到 10 年的时间里,Unix 的新版本完全是用 C 语言编写的,所有基于 Unix 的新操作系统都与 C 语言及其在系统中的关键作用紧密相连。

正如你所见,C 语言并非作为一种普通编程语言而生,而是通过考虑一套完整的需求来设计的,如今它没有竞争对手。你可能认为 Java、Python 和 Ruby 等语言是高级语言,但它们不能被视为直接竞争对手,因为它们不同,服务于不同的目的。例如,你不能用 Java 或 Python 编写设备驱动程序或内核模块,而且它们自身也是建立在用 C 语言编写的层之上的。

与许多编程语言不同,C 语言由 ISO 标准化,如果未来需要添加某些特性,那么标准可以被修改以支持新特性。

在下一节中,我们将讨论 Unix 架构。这是理解程序在 Unix 环境中如何演变的一个基本概念。

Unix 架构

在本节中,我们将探讨 Unix 创造者心中的哲学思想以及他们在创建架构时对它的期望。

正如我们在上一节中解释的,贝尔实验室参与 Unix 的人们当时正在 Multics 项目工作。Multics 是一个大型项目,其提出的架构复杂,并且是为了在昂贵的硬件上使用而调整的。但我们应该记住,尽管困难重重,Multics 有着宏伟的目标。Multics 项目背后的思想彻底改变了我们思考操作系统的方式。

尽管之前讨论了挑战和困难,但该项目提出的思想之所以成功,是因为 Multics 项目的寿命达到了大约 40 年,直到 2000 年。不仅如此,该项目还为它的所有者公司创造了一个巨大的收入来源。

尽管 Unix 最初的设计意图是简单,但像 Ken Thompson 和他的同事这样的人还是将思想引入了 Unix。Multics 和 Unix 都试图引入类似的架构,但它们有着截然不同的命运。Multics 自世纪之交开始逐渐被人们遗忘,而 Unix 以及基于它的操作系统家族,如 BSD,自那时起一直在不断发展。

我们将接着讨论 Unix 哲学。这仅仅是一套高级要求,Unix 的设计就是基于这些要求的。之后,我们将讨论 Unix 的多环、洋葱状架构以及每个环在系统整体行为中的作用。

哲学

Unix 的哲学已经被其创始人多次解释。因此,对整个主题的彻底分析超出了本书的范围。我们将要做的是总结所有的主要观点。

在我们这样做之前,我列出了以下一些关于 Unix 哲学主题的出色外部文献,这可能有助于您:

同样,在以下链接中,您将看到对 Unix 哲学的相当愤怒的反面观点。我包括这个链接,因为了解双方的观点总是很好的,因为本质上,没有什么是不完美的:

为了总结这些观点,我将主要的 Unix 哲学归纳如下:

  • Unix 主要设计和开发是为了供程序员使用,而不是普通终端用户使用:因此,许多涉及用户界面和用户体验要求的考虑因素不是 Unix 架构的一部分。

  • Unix 系统由许多小型和简单的程序组成:每个程序都是设计来执行一个小型简单任务的。有很多这样的小型简单程序的例子,例如 lsmkdirifconfiggrepsed

  • 可以通过执行一系列这些小型和简单的程序来执行复杂任务:这意味着在执行一个大型和复杂的任务时,本质上涉及到多个程序,并且这些程序可以一起执行多次以完成任务。一个很好的例子是使用 shell 脚本而不是从头开始编写程序。请注意,shell 脚本通常在 Unix 系统之间是可移植的,Unix 鼓励程序员将他们的大型和复杂程序分解成小型和简单的程序。

  • 每个小型和简单的程序都应该能够将其输出作为另一个程序的输入,并且这个链应该继续下去:这样,我们可以使用小型程序形成一个具有执行复杂任务潜力的链。在这个链中,每个程序都可以被视为一个转换器,它接收前一个程序的输出,根据其逻辑进行转换,并将其传递给链中的下一个程序。一个特别好的例子是在 Unix 命令之间的 管道,它由一个垂直线表示,例如 ls -l | grep a.out

  • Unix 非常注重文本: 所有配置都是文本文件,它有一个文本命令行。Shell 脚本也是文本文件,使用简单的语法编写算法来执行其他 Unix shell 程序。

  • Unix 建议选择简单而非完美:例如,如果简单的解决方案在大多数情况下都能工作,就不需要设计一个复杂得多的解决方案,而该解决方案仅略微优于简单解决方案。

  • 为特定 Unix 兼容操作系统编写的程序应该易于在其他 Unix 系统中使用:这主要通过拥有一个单一的代码库来实现,该代码库可以在各种 Unix 兼容系统中构建和执行。

我们刚才列出的一些观点已经被不同的人提取和解释,但总的来说,它们已经被普遍认为推动 Unix 哲学的主要原则,并因此塑造了 Unix 的设计。

如果你有过 Unix 类似操作系统的经验,例如 Linux,那么你将能够将你的经验与前面的陈述相匹配。正如我们在上一节关于 Unix 历史的解释中提到的,Unix 原本打算是一个更简单的 Multics 版本,Unix 创始人对 Multics 的经验引导他们形成了前面的理念。

但回到 C 的话题。你可能想知道 C 是如何贡献于前面的理念的?好吧,前面陈述中反映的几乎所有基本事物都是用 C 编写的。换句话说,推动 Unix 大部分工作的上述小型简单程序都是用 C 编写的。

通常来说,展示比简单讲述更好。所以,让我们来看一个例子。NetBSD 中 ls 程序的 C 源代码可以在这里找到:http://cvsweb.netbsd.org/bsdweb.cgi/checkout/src/bin/ls/ls.c?rev=1.67。正如你所知,ls 程序列出目录内容,仅此而已,这种简单的逻辑已经用 C 语言编写,你可以通过链接看到。但 C 语言在 Unix 中的贡献不止于此。我们将在未来的章节中详细解释,当谈到 C 标准库时。

Unix 洋葱

现在,是时候探索 Unix 架构了。正如我们之前简要提到的,洋葱模型可以描述 Unix 的整体架构。它之所以像洋葱,是因为它由几个组成,每个环都作为内部环的包装器。

图 10-2展示了 Unix 架构提出的著名洋葱模型:

图 10-2:Unix 架构的洋葱模型

这个模型乍一看很简单。然而,要完全理解它,你需要编写一些 Unix 程序。只有在那之后,你才能真正理解每个环究竟在做什么。我们将尽可能地简单解释这个模型,以便在编写真实示例之前建立一个初步的基础。

让我们从最内层环开始解释洋葱模型。

在前面模型的核心是 硬件。众所周知,操作系统的主要任务是允许用户与硬件交互并使用它。这就是为什么硬件是模型中 图 10-2 的核心。这仅仅表明 Unix 的一个主要目标是使硬件对希望访问它的程序可用。我们在上一节中关于 Unix 哲学的所有阅读都集中在以最佳方式提供这些服务。

围绕硬件的环是 内核。内核是操作系统的最重要部分。这是因为它是离硬件最近的一层,并充当包装层以直接暴露连接硬件的功能。由于这种直接访问,内核拥有使用系统内所有可用资源的最高权限。对一切的无限制访问是架构中存在没有这种无限制访问的其他环的最佳理由。事实上,这正是内核空间和用户空间分离背后的原因。我们将在本章和下一章中进一步详细讨论这一点。

注意,编写内核是编写新的类 Unix 操作系统时所需的大部分努力,正如你所看到的,它的环比其他环画得更粗。Unix 内核内部有许多不同的单元,每个单元都在 Unix 生态系统中发挥着至关重要的作用。在本章的后面部分,我们将解释更多关于 Unix 内核内部结构的内容。

下一个环被称为 Shell。它只是围绕内核的一个壳,允许用户应用与内核交互并使用其许多服务。请注意,仅壳层环就带来了我们在上一节中解释的 Unix 哲学中大部分提到的需求。我们将在接下来的段落中进一步阐述这一点。

壳层环由许多小型程序组成,这些程序共同形成一套工具,允许用户或应用程序使用内核服务。它还包含一组库,所有这些库都是用 C 语言编写的,这将允许程序员为 Unix 编写新的应用程序。

基于 简单 Unix 规范SUS)中找到的库,壳层环必须为程序员提供一个标准和精确定义的接口。这种标准化将使 Unix 程序在各种 Unix 实现上可移植,或者至少可编译。我们将在接下来的几节中揭示这个环的一些惊人的秘密!

最后,最外层环,用户应用,包括所有为在 Unix 系统上使用而编写的实际应用,例如数据库服务、网络服务、邮件服务、网络浏览器、电子表格程序和文字编辑程序。

这些应用应该使用 shell 环提供的 API 和工具,而不是直接访问内核(通过我们将在稍后讨论的系统调用)来完成它们的任务。这是由于 Unix 哲学中的可移植性原则。请注意,在我们当前的环境中,术语用户通常指的是用户应用,而不一定是使用这些应用的人。

仅限于使用 shell 环也有助于这些应用在各种非真正的 Unix 兼容操作系统中兼容。最好的例子是各种 Linux 发行版,它们只是 Unix-like。我们希望大型软件可以在 Unix 兼容和非 Unix 兼容的操作系统中都可用,并且使用单个代码库。随着我们的进展,你会了解到 Unix-like 系统和 Unix 兼容系统之间的更多差异。

在 Unix 洋葱中,一个普遍的主题是内环应该为外环提供一些接口,以便它们可以使用其服务。实际上,这些环之间的接口比环本身更重要。例如,我们更感兴趣的是了解如何使用现有的内核服务,而不是仅仅深入内核,因为不同的 Unix 实现之间是不同的。

同样,这也可以适用于 shell 环及其向用户应用暴露的界面。实际上,这些界面是我们在这两章讨论 Unix 时的主要关注点。在接下来的部分,我们将分别讨论每个环,并详细讨论其暴露的界面。

Shell 界面到用户应用

一个人类用户要么使用终端,要么使用特定的 GUI 程序,如网页浏览器,来使用 Unix 系统上可用的功能。这两者都被称为用户应用,或者简单地称为应用或程序,它们允许通过 shell 环使用硬件。内存、CPU、网络适配器和硬盘是典型的例子,大多数 Unix 程序通常通过 shell 环提供的 API 使用这些硬件。我们将会讨论的 API 是其中的一个主题。

从开发者的角度来看,应用和程序之间并没有太大的区别。但从一个人类用户的角度来看,应用是一种程序,它具有诸如图形用户界面GUI)或命令行界面CLI)等与用户交互的手段,而程序则是在没有用户界面(UI)的机器上运行的软件片段,例如正在运行的服务。本书不对程序和应用进行区分,我们使用这些术语是通用的。

已经为 Unix 开发了各种 C 程序。数据库服务、Web 服务器、邮件服务器、游戏、办公应用程序等等,都是 Unix 环境中存在的各种程序类型之一。这些应用程序中有一个共同的特点,那就是它们的代码可以在大多数 Unix 和 Unix-like 操作系统上移植,只需进行一些小的修改。但这是如何实现的?如何编写一个可以在各种 Unix 版本和不同类型的硬件上构建的程序?

答案很简单:所有 Unix 系统都从它们的壳环中暴露了相同的应用程序编程接口API)。仅使用暴露的标准接口的 C 源代码可以在所有 Unix 系统上构建和运行。

但我们究竟意味着什么通过暴露一个 API?正如我们之前所解释的,API 是一组包含一系列声明的头文件。在 Unix 中,这些头文件以及其中声明的函数在所有 Unix 系统中都是相同的,但那些函数的实现,换句话说,为每个 UNIX 兼容系统编写的静态和动态库,可能是独特的并且与其他的不同。

注意,我们是在将 Unix 视为一个标准而不是一个操作系统。有一些系统完全符合 Unix 标准,我们称之为Unix 兼容系统,例如 BSD Unix,而有一些系统部分符合 Unix 标准,我们称之为Unix-like 系统,例如 Linux。

大多数 Unix 系统都从壳环中暴露了相同的 API。例如,printf函数必须始终在 Unix 标准指定的stdio.h头文件中声明。无论何时你想要在 Unix 兼容系统中将某些内容打印到标准输出,你应该使用stdio.h头文件中的printffprintf

事实上,stdio.h虽然所有 C 书籍都解释了这个头文件及其声明的函数,但它并不是 C 的一部分。它是 SUS 标准中指定的 C 标准库的一部分。为 Unix 编写的 C 程序并不知道特定函数的实际实现,例如printffopen。换句话说,壳环被外环中的程序视为一个黑盒。

壳环暴露的各种 API 被收集在 SUS 标准之下。这个标准由开放集团联盟维护,并且自 Unix 创建以来已经经历了多次迭代。最新版本是 SUS 版本 4,它追溯到 2008 年。然而,最新版本本身在 2013 年、2016 年和最终在 2018 年进行了修订。

以下链接将带您进入解释 SUS 版本中暴露接口的文档解释 SUS 版本中暴露的接口 4: www.unix.org/version4/GS5_APIs.pdf。如链接所示,shell 环暴露了不同种类的 API。其中一些是强制性的,而另一些则是可选的。以下是在 SUS v4 中找到的 API 列表:

  • 系统接口:这是一个所有 C 程序都应能使用的函数列表。SUS v4 有 1,191 个函数需要 Unix 系统实现。表格还描述了特定函数对于特定版本的 C 是强制性的还是可选性的。请注意,我们感兴趣的是 C99 版本。

  • 头文件接口:这是一个在 SUS v4 兼容的 Unix 系统中可能可用的头文件列表。在这个版本的 SUS 中,有 82 个头文件对所有 C 程序都是可访问的。如果您查看列表,您会发现许多著名的头文件,例如stdio.hstdlib.hmath.hstring.h。根据 Unix 版本和使用的 C 版本,其中一些是强制性的,而另一些则是可选的。可选的头文件可能在 Unix 系统中缺失,但强制性的头文件肯定存在于文件系统中某个地方。

  • 实用程序接口:这是一个在 SUS v4 兼容的 Unix 系统中应可用的实用程序列表,或命令行程序列表。如果您查看表格,您会看到许多您熟悉的命令,例如mkdirlscpdfbc等等,这些构成了多达 160 个实用程序。请注意,这些通常是必须在 Unix 供应商发货时作为其安装包一部分之前已经编写好的程序。

    这些实用程序主要在终端或 shell 脚本中使用,并且通常不会被其他 C 程序调用。这些实用程序通常使用与在用户应用环中编写的普通 C 程序暴露的相同系统接口。

    例如,以下是一个指向为基于伯克利软件发行版(BSD)的 Unix 系统 macOS High Sierra 10.13.6 编写的mkdir实用程序源代码的链接。该源代码发布在 Apple 开源网站上,macOS High Sierra (10.13.6),可在opensource.apple.com/source/file_cmds/file_cmds-272/mkdir/mkdir.c找到。

    如果您打开链接并查看源代码,您会发现它使用了作为系统接口一部分声明的mkdirumask函数。

  • 脚本接口:这种接口是一种用于编写shell 脚本的语言。它主要用于编写使用实用程序的自动化任务。这个接口通常被称为shell 脚本语言shell 命令语言

  • XCURSES 接口:XCURSES 是一套接口,允许 C 程序以最小化文本界面方式与用户交互。

    在下面的屏幕截图中,你可以看到一个使用 ncurses 编写的 GUI 示例,而 ncurses 是 XCURSES 的一个实现。

    在 SUS v4 中,有 379 个函数分布在 3 个头文件中,以及 4 个实用程序,这些共同构成了 XCURSES 接口。

    许多程序今天仍在使用 XCURSES 通过更好的界面与用户交互。值得注意的是,通过使用基于 XCURSES 的接口,你不需要拥有图形引擎。同样,它也可以通过远程连接,如 Secure ShellSSH)进行使用。

图片

图 10-3:基于 ncurses 的配置菜单(维基百科)

如你所见,SUS 没有讨论文件系统层次结构和头文件应该找到的位置。它只说明了哪些头文件应该存在于系统中。一个广泛使用的标准头文件路径约定是,这些头文件应该位于 /usr/include/usr/local/include 中,但这最终还是取决于操作系统和用户来做出最终决定。这些是头文件的默认路径。然而,系统可以被配置为使用其他路径而不是默认路径。

如果我们将系统接口和头文件接口与每个 Unix 版本(或实现)中暴露函数的实现结合起来,那么我们就得到了 C 标准库libc。换句话说,libc 是一组函数,这些函数放置在特定的头文件中,所有这些都根据 SUS 规范,以及包含暴露函数实现的静态和共享库。

libc 的定义与 Unix 系统的标准紧密相连。在 Unix 系统中开发的每个 C 程序都使用 libc 来与内核和硬件级别进行通信。

重要的是要记住,并非所有操作系统都是完全兼容 Unix 的系统。例如,Microsoft Windows 和使用 Linux 内核的操作系统,如 Android,就是这样的例子。这些操作系统不是 Unix 兼容系统,但它们可以是类似 Unix 的系统。我们在前面的章节中使用了 Unix 兼容和类似 Unix 的术语,但没有解释它们的真正含义,但现在我们将仔细定义它们。

符合 Unix 标准的系统完全符合 SUS 标准,但符合 Unix 标准的类似系统只部分符合该标准。这意味着类似 Unix 的系统只符合 SUS 标准的特定子集,而不是全部。这意味着,理论上,为符合 Unix 标准的系统开发的程序应该可以移植到其他 Unix 兼容系统,但可能无法移植到类似 Unix 的操作系统。这尤其适用于从 Linux 移植到其他符合 Unix 标准的系统,或从其他符合 Unix 标准的系统移植到 Linux 的程序。

随着 Linux 诞生后类似 Unix 操作系统的开发增多,这个子集的 SUS 标准得到了一个特定的名称。它们称之为 可移植操作系统接口POSIX)。我们可以这样说,POSIX 是 Unix 类似系统选择遵守的 SUS 标准的子集。

在以下链接中,您可以找到所有应在 POSIX 系统中公开的不同接口:POSIX 系统中应公开的不同接口POSIX 系统中应公开的不同接口.

正如链接中所示,POSIX 中有类似的接口,就像 SUS 中一样。这些标准非常相似,但 POSIX 使 Unix 标准适用于更广泛的操作系统。

类 Unix 操作系统,如大多数 Linux 发行版,从一开始就符合 POSIX 标准。这就是为什么如果你使用过 Ubuntu,你同样可以用相同的方式使用 FreeBSD Unix。

然而,对于一些操作系统,如微软 Windows,情况并非如此。微软 Windows 不能被认为是 POSIX 兼容的,但可以安装额外的工具使其成为一个 POSIX 操作系统,例如,Cygwin,这是一个在 Windows 操作系统上本地运行的 POSIX 兼容环境。

这再次表明,POSIX 兼容性关乎拥有一个标准的 shell 环境而不是内核。

稍微偏一下题,当微软 Windows 在 1990 年代成为 POSIX 兼容时,那真是一个故事。然而,随着时间的推移,这种支持已经过时了。

SUS 和 POSIX 标准都规定了接口。它们都说明了应该有什么,但并没有讨论如何实现。每个 Unix 系统都有自己的 POSIX 或 SUS 实现。这些实现随后被放入 shell 环境的 libc 库中。换句话说,在 Unix 系统中,shell 环境包含一个以标准方式公开的 libc 实现。随后,shell 环境会将请求进一步传递到内核环境以进行处理。

内核到 shell 环境的接口

在前一节中,我们解释了 Unix 系统中的 shell 环暴露了 SUS 或 POSIX 标准中定义的接口。在 shell 环中调用特定逻辑主要有两种方式,要么通过 libc,要么使用 shell 实用程序。一个用户应用程序应该与 libc 库链接以执行 shell 例程,或者它应该执行系统中可用的现有实用程序。

注意,现有的实用程序程序本身正在使用 libc 库。因此,我们可以概括地说,所有 shell 例程都可以在 libc 库中找到。这使标准 C 库的重要性更加突出。如果你要从头开始创建一个新的 Unix 系统,你必须在使用并准备好内核之后编写自己的 libc。

如果你已经跟随了这本书的流程并阅读了前面的章节,你会发现拼图碎片正在拼凑在一起。我们需要有一个编译管道和链接机制,以便能够设计一个暴露接口并使用一系列库文件实现的操作系统。在这个阶段,你能够看到 C 的每个特性都在有利于 Unix。你对 C 和 Unix 之间关系的理解越深,你会发现它们联系得越紧密。

现在用户应用程序和 shell 环之间的关系已经清楚,我们需要看看 shell 环(或 libc)是如何与内核环通信的。在我们继续之前,请注意,在本节中,我们不会解释什么是内核。相反,我们将将其视为一个暴露某些功能的黑盒。

libc(或 shell 环中的函数)用来消耗内核功能的主要机制是通过使用 系统调用。为了解释这个机制,我们需要有一个例子来跟随洋葱模型的层级,以便找到系统调用用于执行某些操作的地方。

我们还需要选择一个真实的 libc 实现,这样我们就可以追踪源代码并找到系统调用。我们选择 FreeBSD 进行进一步的研究。FreeBSD 是一个从 BSD Unix 分支出来的类 Unix 操作系统。

[注意

FreeBSD 的 Git 仓库可以在以下位置找到:https://github.com/freebsd/freebsd。这个仓库包含了 FreeBSD 内核和 shell 环的源代码。FreeBSD libc 的源代码可以在 lib/libc 目录中找到。](https://github.com/freebsd/freebsd0)

让我们从以下示例开始。示例 10.1 是一个简单的程序,它仅仅等待一秒钟。同样,该程序被认为是处于应用环中,这意味着它是一个用户应用程序,尽管它非常简单。

因此,让我们首先看看 示例 10.1 的源代码:

#include <unistd.h>
int main(int argc, char** argv) {
  sleep(1);
  return 0;
}

代码框 10-1 [ExtremeC_examples_chapter10_1.c]:示例 10.1 调用来自 shell 环的 sleep 函数

如你所见,代码包含了unistd.h头文件,并调用了sleep函数,这两者都是 SUS 公开接口的一部分。但接下来会发生什么,尤其是在sleep函数中?作为一个 C 程序员,你可能以前从未问过自己这个问题,但了解这一点可以增强你对 Unix 系统的理解。

我们一直使用sleepprintfmalloc等函数,而不了解它们内部是如何工作的,但现在我们想要迈出一大步,发现 libc 与内核通信的机制。

我们知道,系统调用,或简称为syscalls,是由 libc 实现中编写的代码触发的。实际上,这就是触发内核例程的方式。在 SUS 中,以及随后的 POSIX 兼容系统中,有一个程序用于在程序运行时跟踪系统调用。

我们几乎可以肯定,一个没有调用系统调用的程序实际上什么都不能做。因此,结果是,我们知道我们编写的每个程序都必须通过调用 libc 函数来使用系统调用。

让我们编译前面的示例,找出它所触发的系统调用。我们可以通过运行以下命令开始这个过程:

$ cc ExtremeC_examples_chapter10_1.c -lc -o ex10_1.out
$ truss ./ex10_1.out
...                                           
$

Shell Box 10-1:使用 truss 跟踪调用系统调用的 10.1 示例的构建和运行

Shell Box 10-1中所示,我们使用了一个名为truss的实用程序。以下文本是 FreeBSD 的truss手册页的摘录:

"truss 实用程序跟踪指定进程或程序调用的系统调用。默认情况下,输出到指定的输出文件或标准错误。它是通过 ptrace(2)停止和重新启动被监控的进程来实现的。"

如描述所暗示的,truss是一个程序,用于查看程序在执行过程中所调用的所有系统调用。大多数类 Unix 系统中都有类似 truss 的实用程序。例如,Linux 系统中可以使用strace

以下 shell 框展示了使用truss监控前一个示例所调用的系统调用的输出:

$ truss ./ex10_1.out
mmap(0x0,32768,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANON,-1,0x0) = 34366160896 (0x800620000)
issetugid()                                      = 0 (0x0)
lstat("/etc",{ mode=drwxr-xr-x ,inode=3129984,size=2560,blksize=32768 }) = 0 (0x0)
lstat("/etc/libmap.conf",{ mode=-rw-r--r-- ,inode=3129991,size=109,blksize=32768 }) = 0 (0x0)                                                     openat(AT_FDCWD,"/etc/libmap.conf",O_RDONLY|O_CLOEXEC,00) = 3 (0x3)
fstat(3,{ mode=-rw-r--r-- ,inode=3129991,size=109,blksize=32768 }) = 0 (0x0)
...
openat(AT_FDCWD,"/var/run/ld-elf.so.hints",O_RDONLY|O_CLOEXEC,00) = 3 (0x3)                                                                       read(3,"Ehnt\^A\0\0\0\M^@\0\0\0Q\0\0\0\0"...,128) = 128 (0x80)
fstat(3,{ mode=-r--r--r-- ,inode=7705382,size=209,blksize=32768 }) = 0 (0x0)
lseek(3,0x80,SEEK_SET)                           = 128 (0x80)                                                                                     read(3,"/lib:/usr/lib:/usr/lib/compat:/u"...,81) = 81 (0x51)
close(3)                                         = 0 (0x0)
access("/lib/libc.so.7",F_OK)                    = 0 (0x0)
openat(AT_FDCWD,"/lib/libc.so.7",O_RDONLY|O_CLOEXEC|O_VERIFY,00) = 3 (0x3)
...
sigprocmask(SIG_BLOCK,{ SIGHUP|SIGINT|SIGQUIT|SIGKILL|SIGPIPE|SIGALRM|SIGTERM|SIGURG|SIGSTOP|SIGTSTP|SIGCONT|SIGCHLD|SIGTTIN|SIGTTOU|SIGIO|SIGXCPU|SIGXFSZ|SIGVTALRM|SIGPROF|SIGWINCH|SIGINFO|SIGUSR1|SIGUSR2 },{ }) = 0 (0x0)sigprocmask(SIG_SETMASK,{ },0x0)                 = 0 (0x0)
sigprocmask(SIG_BLOCK,{ SIGHUP|SIGINT|SIGQUIT|SIGKILL|SIGPIPE|SIGALRM|SIGTERM|SIGURG|SIGSTOP|SIGTSTP|SIGCONT|SIGCHLD|SIGTTIN|SIGTTOU|SIGIO|SIGXCPU|SIGXFSZ|SIGVTALRM|SIGPROF|SIGWINCH|SIGINFO|SIGUSR1|SIGUSR2 },{ }) = 0 (0x0)sigprocmask(SIG_SETMASK,{ },0x0)                 = 0 (0x0)
nanosleep({ 1.000000000 })                       = 0 (0x0)
sigprocmask(SIG_BLOCK,{ SIGHUP|SIGINT|SIGQUIT|SIGKILL|SIGPIPE|SIGALRM|SIGTERM|SIGURG|SIGSTOP|SIGTSTP|SIGCONT|SIGCHLD|SIGTTIN|SIGTTOU|SIGIO|SIGXCPU|SIGXFSZ|SIGVTALRM|SIGPROF|SIGWINCH|SIGINFO|SIGUSR1|SIGUSR2 },{ }) = 0 (0x0)
...
sigprocmask(SIG_SETMASK,{ },0x0)                 = 0 (0x0)
exit(0x0)
process exit, rval = 0                                                   $

Shell Box 10-2:truss 的输出,显示了 10.1 示例调用的系统调用

如前述输出所示,我们的简单示例触发了许多系统调用,其中一些是关于加载共享对象库的,尤其是在初始化进程时。第一个以粗体显示的系统调用是打开libc.so.7共享对象库文件。这个共享对象库包含了 FreeBSD 的 libc 的实际实现。

在同一个 shell 框中,你可以看到程序正在调用nanosleep系统调用。传递给这个系统调用的值是 1000000000 纳秒,相当于 1 秒。

系统调用就像函数调用一样。请注意,每个系统调用都有一个专用且预定的常数值,并且随后,与该值一起,它有一个特定的名称和参数列表。每个系统调用也执行特定的任务。在这种情况下,nanosleep使调用线程休眠指定的纳秒数。

关于系统调用的更多信息可以在 FreeBSD 的系统调用手册中找到。以下 shell box 显示了手册中专门针对nanosleep系统调用的页面:

$ man nanosleep
NANOSLEEP(2)              FreeBSD System Calls Manual             NANOSLEEP(2)
NAME
     nanosleep - high resolution sleep
LIBRARY      Standard C Library (libc, -lc)
SYNOPSIS      #include <time.h>
     Int
     clock_nanosleep(clockid_t clock_id, int flags,
         const struct timespec *rqtp, struct timespec *rmtp);
     int
     nanosleep(const struct timespec *rqtp, struct timespec *rmtp);
DESCRIPTION
     If the TIMER_ABSTIME flag is not set in the flags argument, then
     clock_nanosleep() suspends execution of the calling thread until either
     the time interval specified by the rqtp argument has elapsed, or a signal
     is delivered to the calling process and its action is to invoke a signal-
     catching function or to terminate the process.  The clock used to measure
     the time is specified by the clock_id argument
...
...
$

Shell Box 10-3:专门针对 nanosleep 系统调用的手册页

前面的手册页描述如下:

  • nanosleep是一个系统调用。

  • 系统调用可以通过从time.h中定义的 shell 环调用nanosleepclock_nanosleep函数来访问。请注意,我们使用了unitsd.h中的sleep函数。我们也可以使用time.h中的前两个函数。还值得注意的是,这两个头文件以及所有前面的函数,以及实际使用的函数,都是 SUS 和 POSIX 的一部分。

  • 如果您想要能够调用这些函数,您需要通过将-lc选项传递给链接器来将可执行文件链接到 libc。这可能仅适用于 FreeBSD。

  • 这个手册页没有讨论系统调用本身,而是讨论了从 shell 环公开的标准 C API。这些手册是为应用程序开发者编写的,因此它们不会经常讨论系统调用和内核内部。相反,它们专注于从 shell 环公开的 API。

现在,让我们找到在 libc 中实际调用系统调用的位置。我们将使用 GitHub 上的 FreeBSD 源代码。我们使用的提交哈希是来自 master 分支的bf78455d496。为了从存储库克隆并使用正确的提交,请运行以下命令:

$ git clone https://github.com/freebsd/freebsd
...
$ cd freebsd
$ git reset --hard bf78455d496
...
$

Shell Box 10-4:克隆 FreeBSD 项目并转到特定的提交

您也可以使用以下链接在 GitHub 网站上导航 FreeBSD 项目本身:github.com/freebsd/freebsd/tree/bf78455d496。无论您使用什么方法导航项目,您都应该能够找到以下代码行。

如果您进入lib/libc目录并使用grep搜索sys_nanosleep,您将找到以下文件条目:

$ cd lib/libc
$ grep sys_nanosleep . -R
./include/libc_private.h:int		__sys_nanosleep(const struct timespec *, struct timespec *);
./sys/Symbol.map:	__sys_nanosleep;
./sys/nanosleep.c:__weak_reference(__sys_nanosleep, __nanosleep);
./sys/interposing_table.c:	SLOT(nanosleep, __sys_nanosleep), 
$

Shell Box 10-5:在 FreeBSD libc 文件中查找与 nanosleep 系统调用相关的条目

如您在lib/libc/sys/interposing_table.c文件中所见,nanosleep函数映射到__sys_nanosleep函数。因此,任何针对nanosleep的函数调用都会导致__sys_nanosleep被调用。

__sys开头的函数是 FreeBSD 约定中的实际系统调用函数。请注意,这是 libc 实现的一部分,使用的命名约定和其他与实现相关的配置对 FreeBSD 来说非常具体。

说了这么多,前一个 shell 框中还有一个有趣的观点。lib/libc/include/libc_private.h文件包含了围绕系统调用所需的私有和内部函数声明。

到目前为止,我们已经看到了 shell 环是如何通过系统调用来将 libc 中的函数调用路由到内环的。但为什么我们最初需要系统调用呢?为什么它被称为系统调用而不是函数调用?当我们查看用户应用程序或 libc 中的普通函数时,它与内核环中的系统调用有何不同?在第十一章系统调用与内核中,我们将通过给出系统调用的更具体定义来进一步讨论这个问题。

下一节是关于内核环及其内部单元,这些单元在大多数符合 Unix 和 Unix-like 系统的内核中很常见。

内核

内核环的主要目的是管理连接到系统的硬件,并通过系统调用暴露其功能。以下图表显示了在用户应用程序最终可以使用之前,特定硬件功能是如何通过不同的环暴露的:

图 10-4:在各个 Unix 环之间进行的函数调用和系统调用,以暴露硬件功能

上述图表展示了我们到目前为止所解释的内容的总结。在本节中,我们将专注于内核本身,看看内核是什么。内核是一个像我们所知道的任何其他进程一样执行一系列指令的进程。但内核进程与我们所知的用户进程在本质上是有区别的。

以下列表比较了内核进程和用户进程。请注意,我们的比较是有偏见的,偏向于像 Linux 这样的单核内核。我们将在下一章解释不同类型的内核。

  • 内核进程是首先被加载和执行的东西,但用户进程在创建之前需要内核进程被加载并运行。

  • 我们只有一个内核进程,但我们可以同时运行许多用户进程。

  • 内核进程是由引导加载程序将内核镜像复制到主内存中创建的,但用户进程是通过execfork系统调用创建的。这些系统调用存在于大多数 Unix 系统中。

  • 内核进程处理和执行系统调用,但用户进程调用系统调用并等待由内核进程处理的执行。这意味着,当用户进程要求执行系统调用时,执行流程会转移到内核进程,并且是内核本身代表用户进程执行系统调用的逻辑。我们将在第二部分关于 Unix 的探讨中阐明这一点,即第十一章,系统调用和内核

  • 内核进程以 特权 模式看到物理内存和所有连接的硬件,但用户进程看到的是虚拟内存,它是映射到物理内存的一部分,用户进程对物理内存布局一无所知。同样,用户进程对资源和硬件拥有受控制和受监督的访问。我们可以这样说,用户进程是在操作系统模拟的沙盒中执行的。这也意味着一个用户进程无法看到另一个用户进程的内存。

如前所述的比较所理解的那样,在操作系统的运行时中,我们有两种不同的执行模式。其中一种是为内核进程专用的,另一种是为用户进程专用的。

前者执行模式被称为 内核空间内核地带,后者被称为 用户空间用户地带。用户进程通过调用系统调用来将这两个地带连接起来。基本上,我们发明系统调用是因为我们需要将内核空间和用户空间相互隔离。内核空间对系统资源拥有最高权限的访问,而用户空间拥有最低权限且受监督的访问。

典型 Unix 内核的内部结构可以通过内核执行的任务来识别。实际上,管理硬件并不是内核执行的唯一任务。以下是一个 Unix 内核职责的列表。请注意,我们在以下列表中也包括了硬件管理任务:

  • 进程管理:内核通过系统调用来创建用户进程。为新进程分配内存和加载其指令是运行进程之前应执行的操作之一。

  • 进程间通信IPC):同一台机器上的用户进程可以使用不同的方法在它们之间交换数据。这些方法中的一些是共享内存、管道和 Unix 域套接字。这些方法应由内核提供便利,其中一些需要内核控制数据的交换。我们将在第十九章,单主机 IPC 和套接字中解释这些方法,同时讨论 IPC 技术。

  • 调度:Unix 一直以多任务操作系统而闻名。内核管理对 CPU 核心的访问,并试图平衡对它们的访问。调度是根据它们的优先级和重要性在多个进程之间共享 CPU 时间的任务名称。我们将在接下来的章节中更详细地解释多任务、多线程和多进程。

  • 内存管理:毫无疑问,这是内核的关键任务之一。内核是唯一可以看到整个物理内存并对其具有超级用户访问权限的进程。因此,将内存分割成可分配的页面、在堆分配的情况下为新进程分配新页面、释放内存以及许多其他与内存相关的任务,都应该由内核执行和管理。

  • 系统启动:一旦内核映像被加载到主内存中并且内核进程启动,它应该初始化用户空间。这通常是通过创建第一个用户进程,即带有进程标识符PID)1 的进程来完成的。在一些 Unix 系统,如 Linux 中,这个进程被称为init。在启动了这个进程之后,它将启动更多的服务和守护进程。

  • 设备管理:除了 CPU 和内存之外,内核应该能够通过在所有这些硬件上创建的抽象来管理硬件。设备是连接到 Unix 系统的真实或虚拟硬件。典型的 Unix 系统使用/dev路径来存储映射的设备文件。所有连接的硬盘驱动器、网络适配器、USB 设备等都被映射到/dev路径下找到的文件。这些设备文件可以被用户进程用来与这些设备通信。

下面的图示显示了基于上述列表的 Unix 内核最常见的内部结构:

图片

图 10-5:Unix 架构中不同环的内部结构

上述图示是 Unix 环的详细说明。它清楚地显示了在 shell 环中,我们有三个暴露给用户应用的组成部分。它还显示了内核环的详细内部结构。

在内核环的最顶部,我们有系统调用接口。如图所示,所有位于用户空间的前置单元必须仅通过系统调用接口与底部的单元进行通信。这个接口就像用户和内核空间之间的一个门或障碍。

内核中有各种单元,例如负责管理可用物理内存的内存管理单元MMU)。进程管理单元在用户空间创建进程并为它们分配资源。它还使进程间通信(IPC)对进程可用。该图还显示了由设备驱动程序介导的字符设备块设备,它们暴露了各种 I/O 功能。我们将在下一节中解释字符和块设备。文件系统单元是内核的一个基本部分,它是对块设备和字符设备的抽象,并允许进程和内核本身使用相同的共享文件层次结构。

在下一节中,我们将讨论硬件。

硬件

每个操作系统的最终目的是允许用户和应用程序能够使用和与硬件交互。Unix 也旨在以抽象和透明的方式提供对连接硬件的访问,使用相同的实用程序和命令集在所有现有和未来的平台上。

通过这种透明性和抽象,Unix 将所有不同的硬件抽象为连接到系统的一组设备。因此,设备在 Unix 中是核心的,每个连接的硬件部件都被认为是连接到 Unix 系统的设备。

连接到计算机的硬件可以分为两个不同的类别:必需外围。CPU 和主内存是连接到 Unix 系统的必需设备。所有其他硬件,如硬盘、网络适配器、鼠标、显示器、显卡和 Wi-Fi 适配器,都是外围设备。

Unix 机器没有必需的硬件是无法工作的,但你可以有一个没有硬盘或网络适配器的 Unix 机器。请注意,虽然文件系统对于 Unix 内核的运行是必需的,但这并不一定需要硬盘!

Unix 内核完全隐藏了 CPU 和物理内存。它们直接由内核管理,不允许用户空间进行访问。Unix 内核中的内存管理调度器单元分别负责管理物理内存和 CPU。

Unix 系统中连接的其他外围设备并非如此。它们通过称为设备文件的机制暴露出来。你可以在 Unix 系统的 /dev 路径下看到这些文件。

以下是在普通 Linux 机器上可以找到的文件列表:

$ ls -l /dev
total 0
crw-r--r--  1 root   root     10, 235 Oct 14 16:55 autofs
drwxr-xr-x  2 root   root         280 Oct 14 16:55 block
drwxr-xr-x  2 root   root          80 Oct 14 16:55 bsg
crw-rw----  1 root   disk     10, 234 Oct 14 16:55 btrfs-control
drwxr-xr-x  3 root   root          60 Oct 14 17:02 bus
lrwxrwxrwx  1 root   root           3 Oct 14 16:55 cdrom -> sr0
drwxr-xr-x  2 root   root        3500 Oct 14 16:55 char
crw-------  1 root   root      5,   1 Oct 14 16:55 console
lrwxrwxrwx  1 root   root          11 Oct 14 16:55 core -> /proc/kcore
crw-------  1 root   root     10,  59 Oct 14 16:55 cpu_dma_latency
crw-------  1 root   root     10, 203 Oct 14 16:55 cuse
drwxr-xr-x  6 root   root         120 Oct 14 16:55 disk
drwxr-xr-x  3 root   root          80 Oct 14 16:55 dri
lrwxrwxrwx  1 root   root           3 Oct 14 16:55 dvd -> sr0
crw-------  1 root   root     10,  61 Oct 14 16:55 ecryptfs
crw-rw----  1 root   video    29,   0 Oct 14 16:55 fb0
lrwxrwxrwx  1 root   root          13 Oct 14 16:55 fd -> /proc/self/fd
crw-rw-rw-  1 root   root      1,   7 Oct 14 16:55 full
crw-rw-rw-  1 root   root     10, 229 Oct 14 16:55 fuse
crw-------  1 root   root    245,   0 Oct 14 16:55 hidraw0
crw-------  1 root   root     10, 228 Oct 14 16:55 hpet
drwxr-xr-x  2 root   root           0 Oct 14 16:55 hugepages
crw-------  1 root   root     10, 183 Oct 14 16:55 hwrng
crw-------  1 root   root     89,   0 Oct 14 16:55 i2c-0
...
crw-rw-r--  1 root   root     10,  62 Oct 14 16:55 rfkill
lrwxrwxrwx  1 root   root           4 Oct 14 16:55 rtc -> rtc0
crw-------  1 root   root    249,   0 Oct 14 16:55 rtc0
brw-rw----  1 root   disk      8,   0 Oct 14 16:55 sda
brw-rw----  1 root   disk      8,   1 Oct 14 16:55 sda1
brw-rw----  1 root   disk      8,   2 Oct 14 16:55 sda2
crw-rw----+ 1 root   cdrom    21,   0 Oct 14 16:55 sg0
crw-rw----  1 root   disk     21,   1 Oct 14 16:55 sg1
drwxrwxrwt  2 root   root          40 Oct 14 16:55 shm
crw-------  1 root   root     10, 231 Oct 14 16:55 snapshot
drwxr-xr-x  3 root   root         180 Oct 14 16:55 snd
brw-rw----+ 1 root   cdrom    11,   0 Oct 14 16:55 sr0
lrwxrwxrwx  1 root   root          15 Oct 14 16:55 stderr -> /proc/self/fd/2
lrwxrwxrwx  1 root   root          15 Oct 14 16:55 stdin -> /proc/self/fd/0
lrwxrwxrwx  1 root   root          15 Oct 14 16:55 stdout -> /proc/self/fd/1
crw-rw-rw-  1 root   tty       5,   0 Oct 14 16:55 tty
crw--w----  1 root   tty       4,   0 Oct 14 16:55 tty0
...
$

Shell Box 10-6:在 Linux 机器上列出 /dev 的内容

如你所见,这是一份连接到机器的设备列表。但当然,并非所有这些设备都是物理的。Unix 对硬件设备的抽象使其能够拥有虚拟设备

例如,你可以有一个没有物理对应物的虚拟网络适配器,但它能够对网络数据进行额外的操作。这是 VPN 在基于 Unix 的环境中应用的一种方式。物理网络适配器提供了真实的网络功能,而虚拟网络适配器则赋予了通过安全隧道传输数据的能力。

如前所述的输出所示,每个设备在/dev目录下都有自己的文件。以cb开头的行分别代表字符设备和块设备。字符设备应该按字节逐个交付和消耗数据。此类设备的例子有串行端口和并行端口。块设备应该交付和消耗超过一个字节的数据块。硬盘、网络适配器、摄像头等都是块设备的例子。在前面的 shell 框中,以l开头的行是其他设备的符号链接,以d开头的行代表可能包含其他设备文件的目录。

用户进程使用这些设备文件来访问相应的硬件。这些文件可以被写入或读取,以便向设备发送或从设备接收数据。

在这本书中,我们不会深入到这个程度,但如果你对设备和设备驱动程序感兴趣,你应该阅读更多关于这个主题的内容。在下一章,即第十一章“系统调用与内核”中,我们将更详细地讨论系统调用,并将一个新的系统调用添加到现有的 Unix 内核中。

摘要

在本章中,我们开始讨论 Unix 及其与 C 的相互关系。即使在非 Unix 操作系统中,你也能看到一些与 Unix 系统类似的设计痕迹。

作为本章的一部分,我们回顾了 20 世纪 70 年代初的历史,并解释了 Unix 是如何从 Multics 演变而来,以及 C 语言是如何从 B 语言派生出来的。之后,我们讨论了 Unix 架构,这是一个类似洋葱的四层结构:用户应用、shell、内核和硬件。

我们简要地介绍了 Unix 洋葱模型中的各个层次,并详细解释了 shell 层。我们介绍了 C 标准库及其如何通过 POSIX 和 SUS 标准使用,为程序员提供编写可以在各种 Unix 系统上构建的程序的能力。

在我们关于 Unix 的第二部分探讨中,即第十一章“系统调用与内核”,我们将继续讨论 Unix 及其架构,并更深入地解释内核及其周围的系统调用接口。

第十一章

系统调用和内核

在上一章中,我们讨论了 Unix 的历史及其洋葱式架构。我们还介绍了 POSIX 和 SUS 标准,这些标准规范了 Unix 中 shell 环的运作,然后在解释 C 标准库如何提供 Unix 兼容系统暴露的常用功能之前。

在本章中,我们将继续讨论 系统调用接口 和 Unix 内核。这将让我们对 Unix 系统的工作方式有一个完整的了解。

在阅读本章之后,你将能够分析程序调用的系统调用,你将能够解释进程如何在 Unix 环境中生存和演变,你还将能够直接或通过 libc 使用系统调用。我们还将讨论 Unix 内核开发,并展示你如何向 Linux 内核添加新的系统调用以及如何从 shell 环中调用它。

在本章的最后部分,我们将讨论 单一内核微内核 以及它们之间的区别。我们将介绍 Linux 内核作为一个单一内核,并为其编写一个可以动态加载和卸载的 内核模块

让我们以讨论系统调用开始本章。

系统调用

在上一章中,我们简要解释了什么是系统调用。在本节中,我们想要更深入地探讨并解释系统调用背后的机制,即从用户进程到内核进程的执行转移机制。

然而,在我们这样做之前,我们需要对内核空间和用户空间进行更多的解释,因为这将有助于我们理解系统调用在幕后是如何工作的。我们还将编写一个简单的系统调用来获得一些关于内核开发的思路。

我们即将要做的事情对于你想要在向内核添加之前不存在的新功能时编写新的系统调用至关重要。这也让你更好地理解内核空间以及它与用户空间的不同,因为实际上,它们是非常不同的。

系统调用的显微镜下

正如我们在上一章中讨论的,当从 shell 环移动到内核环时,会发生分离。你会发现位于前两个环中的任何内容,即用户应用程序和 shell,都属于用户空间。同样,出现在内核环或硬件环中的任何内容都属于内核空间。

关于这种分离有一条规则,那就是在两个最内层的环——内核和硬件——中的任何内容都不能被用户空间直接访问。换句话说,用户空间中的任何进程都不能直接访问硬件、内部内核数据结构和算法。相反,它们应该通过系统调用进行访问。

话虽如此,你可能认为这与你对类 Unix 操作系统(如 Linux)所知和所经历的东西似乎有些矛盾。如果你看不到问题,让我为你解释一下。这似乎是一种矛盾,因为例如,当程序从网络套接字读取一些字节时,实际上读取这些字节的不是程序,而是内核读取字节并将它们复制到用户空间,然后程序可以取回并使用它们。

我们可以通过一个例子来明确这一点,即从用户空间到内核空间以及相反方向的所有步骤。当你想从硬盘驱动器读取文件时,你会在用户应用程序环中编写一个程序。你的程序使用一个名为fread的 libc I/O 函数(或另一个类似函数),最终作为用户空间中的进程运行。当程序调用fread函数时,libc 背后的实现被触发。

到目前为止,一切仍然在用户进程中。然后,fread实现最终调用一个系统调用,而fread接收一个已经打开的文件描述符作为第一个参数,作为第二个参数,是分配在进程内存中的缓冲区的地址,该缓冲区位于用户空间,作为第三个参数,是缓冲区的长度。

当系统调用由 libc 实现触发时,内核代表用户进程控制执行。它从用户空间接收参数并将它们保存在内核空间中。然后,内核通过访问内核内部的文件系统单元来读取文件(如前一章中图 10-5所示)。

当内核环中的read操作完成时,读取的数据将被复制到由调用fread函数时指定的用户空间中的缓冲区,系统调用随后离开并将执行控制权返回给用户进程。同时,用户进程通常会在系统调用忙于操作时等待。在这种情况下,系统调用是阻塞的。

关于这种情况,有一些重要的事情需要注意:

  • 我们只有一个内核执行系统调用背后的所有逻辑。

  • 如果系统调用是阻塞的,当系统调用正在进行时,调用者用户进程必须等待,直到系统调用繁忙并完成。相反,如果系统调用是非阻塞的,系统调用会非常快地返回,但用户进程必须进行额外的系统调用以检查结果是否可用。

  • 参数以及输入和输出数据将从用户空间复制到/从用户空间。由于实际值被复制,系统调用应该设计成接受小的变量和指针作为输入参数。

  • 内核可以完全访问系统的所有资源。因此,应该有一个机制来检查用户进程是否能够执行这样的系统调用。在这种情况下,如果用户不是文件的所有者,fread 应该因为缺少所需权限而失败。

  • 用户空间和内核空间之间也存在类似的分离。用户进程只能访问用户空间内存。为了完成某个系统调用,可能需要多次传输。

在我们进入下一节之前,我想问你一个问题。系统调用是如何将执行控制权传递给内核的?花一分钟时间思考一下,因为在下一节中,我们将努力找到这个问题的答案。

越过标准 C – 直接调用系统调用

在回答提出的问题之前,让我们通过一个绕过标准 C 库并直接调用系统调用的示例。换句话说,程序调用系统调用而不通过 shell 环。正如我们之前所提到的,这被认为是一种反模式,但当某些系统调用没有通过 libc 暴露时,用户应用程序可以直接调用系统调用。

在每个 Unix 系统中,都有一个特定的方法可以直接调用系统调用。例如,在 Linux 中,有一个名为 syscall 的函数,位于 <sys/syscall.h> 头文件中,可以用于此目的。

以下代码框,示例 11.1,是一个不同的 Hello World 示例,它不使用 libc 将内容打印到标准输出。换句话说,该示例不使用作为 shell 环和 POSIX 标准一部分的 printf 函数。相反,它直接调用特定的系统调用,因此代码只能在 Linux 机器上编译,不能在其他 Unix 系统上编译。换句话说,代码在各种 Unix 版本之间不可移植:

// We need to have this to be able to use non-POSIX stuff
#define _GNU_SOURCE
#include <unistd.h>
// This is not part of POSIX!
#include <sys/syscall.h>
int main(int argc, char** argv) {
  char message[20] = "Hello World!\n";
  // Invokes the 'write' system call that writes
  // some bytes into the standard output.
  syscall(__NR_write, 1, message, 13);
  return 0;
}

代码框 11-1 [ExtremeC_examples_chapter11_1.c]:一个不同的 Hello World 示例,它直接调用 write 系统调用

作为前面代码框中的第一个语句,我们必须定义 _GNU_SOURCE 以指示我们将使用不属于 POSIX 或 SUS 标准的 GNU C 库glibc)的部分。这会破坏程序的可移植性,因此,你可能无法在其他 Unix 机器上编译你的代码。在第二个 include 语句中,我们包含了一个 glibc 特定的头文件,该文件在其他使用 glibc 作为主要 libc 核心的 POSIX 系统中不存在。

main 函数中,我们通过调用 syscall 函数来执行系统调用。首先,我们必须通过传递一个数字来指定系统调用。这是一个整数,它指向一个特定的系统调用。每个系统调用在 Linux 中都有一个独特的特定 系统调用号

在示例代码中,__R_write 常量被传递而不是系统调用号,我们不知道它的确切数值。在 unistd.h 头文件中查找后,显然 64 是 write 系统调用的编号。

在传递系统调用号之后,我们应该传递系统调用所需的参数。

注意,尽管前面的代码非常简单,它只包含一个简单的函数调用,但你应该知道 syscall 不是一个普通函数。它是一个汇编过程,它填充了一些适当的 CPU 寄存器,并且实际上将执行控制从用户空间转移到内核空间。我们很快就会讨论这一点。

对于 write,我们需要传递三个参数:文件描述符,在这里是 1,表示标准输出;第二个是用户空间中分配的 缓冲区指针;最后是 应该从缓冲区复制的字节数

下面的输出是 示例 11.1 的输出,使用 gcc 在 Ubuntu 18.04.1 上编译和运行:

$ gcc ExtremeC_examples_chapter11_1.c -o ex11_1.out
$ ./ex11_1.out
Hello World!
$

Shell Box 11-1:示例 11.1 的输出

现在是时候使用上一章中介绍过的 strace 来查看 示例 11.1 实际调用的系统调用。以下 strace 的输出显示了程序已经调用了所需的系统调用:

$ strace ./ex11_1.out
execve("./ex11_1.out", ["./ex11_1.out"], 0x7ffcb94306b0 /* 22 vars */) = 0
brk(NULL)                               = 0x55ebc30fb000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
...
...
arch_prctl(ARCH_SET_FS, 0x7f24aa5624c0) = 0
mprotect(0x7f24aa339000, 16384, PROT_READ) = 0
mprotect(0x55ebc1e04000, 4096, PROT_READ) = 0
mprotect(0x7f24aa56a000, 4096, PROT_READ) = 0
munmap(0x7f24aa563000, 26144)           = 0
write(1, "Hello World!\n", 13Hello World!
)          = 13
exit_group(0)                           = ?
+++ exited with 0 +++
$

Shell Box 11-2:运行示例 11.1 时 strace 的输出

正如你在 Shell Box 11-2 中的粗体中看到的,系统调用已被 strace 记录。看看返回值,它是 13。这意味着系统调用已成功将 13 个字节写入给定的文件,在这种情况下是标准输出。

注意

用户应用程序永远不应该尝试直接使用系统调用。在调用系统调用之前和之后通常需要采取一些步骤。Libc 实现这些步骤。当你不打算使用 libc 时,你必须自己执行这些步骤,你必须知道这些步骤在不同的 Unix 系统之间是不同的。

在 syscall 函数内部

然而,syscall 函数内部发生了什么?请注意,当前的讨论仅适用于 glibc,而不适用于其他 libc 实现。首先,我们需要在 glibc 中找到 syscall。这里是 syscall 定义的链接:https://github.com/lattera/glibc/blob/master/sysdeps/unix/sysv/linux/x86_64/syscall.S。

如果你在一个浏览器中打开前面的链接,你会看到这个函数是用汇编语言编写的。

注意

汇编语言可以与 C 语句一起在 C 源文件中使用。事实上,这是 C 的一个重要特性,使其适合编写操作系统。对于 syscall 函数,我们有一个用 C 编写的声明,但定义是在汇编中。

这里是作为 syscall.S 部分找到的源代码:

/* Copyright (C) 2001-2018 Free Software Foundation, Inc.
   This file is part of the GNU C Library.
...
   <http://www.gnu.org/licenses/>.  */
#include <sysdep.h>
/* Please consult the file sysdeps/unix/sysv/linux/x86-64/sysdep.h for
   more information about the value -4095 used below.  */
/* Usage: long syscall (syscall_number, arg1, arg2, arg3, arg4, arg5, arg6)
   We need to do some arg shifting, the syscall_number will be in
   rax.  */
	.text
ENTRY (syscall)
    movq %rdi, %rax            /* Syscall number -> rax.  */
    movq %rsi, %rdi            /* shift arg1 - arg5\.  */
    movq %rdx, %rsi
    movq %rcx, %rdx
    movq %r8, %r10
    movq %r9, %r8
    movq 8(%rsp),%r9           /* arg6 is on the stack.  */
    syscall                    /* Do the system call.  */
    cmpq $-4095, %rax          /* Check %rax for error.  */
    jae SYSCALL_ERROR_LABEL    /* Jump to error handler if error.  */
    ret                        /* Return to caller.  */
PSEUDO_END (syscall)

Code Box 11-2:glibc 中 syscall 函数的定义

尽管以这种方式进行系统调用似乎更复杂,但这些指令简短且简单。使用注释解释说,在 glibc 中,每次调用可以提供多达六个参数的系统调用。

这意味着如果底层内核支持具有超过六个参数的系统调用,glibc 无法提供某些内核功能,并且应该修改以支持它们。幸运的是,在大多数情况下,六个参数已经足够了,对于需要超过六个参数的系统调用,我们可以传递在用户空间内存中分配的结构变量的指针。

在前面的代码框中,在movq指令之后,汇编代码调用syscall子程序。它只是生成一个中断,这允许内核中等待此类中断的特定部分唤醒并处理中断。

如您在syscall过程的第 一行所看到的,系统调用号被移动到%rax寄存器。在接下来的几行中,我们将其他参数复制到不同的寄存器中。当系统调用中断被触发时,内核的中断处理单元接收到调用并收集系统调用号和参数。然后它搜索其系统调用表以找到应在内核端调用的适当函数。

一个有趣的观点是,当中断处理程序在 CPU 中执行时,已经离开 CPU 的发起系统调用的用户代码,内核正在执行这项工作。这是系统调用背后的主要机制。当你发起一个系统调用时,CPU 会改变其模式,内核指令被加载到 CPU 中,用户空间应用程序不再被执行。这就是我们说内核代表用户应用程序执行系统调用逻辑背后的逻辑的基本原因。

在下一节中,我们将通过编写一个打印 hello 消息的系统调用来给出一个例子。它可以被认为是示例 11.1的渐进版本,它接受一个输入字符串并返回一个问候字符串。

向 Linux 添加系统调用

在本节中,我们将向现有类 Unix 内核的系统调用表中添加一个新的系统调用。这可能是有很多读者阅读这本书时第一次编写的应该在内核空间运行的 C 代码。我们之前章节中编写的所有示例,以及我们将在未来章节中编写的几乎所有代码,都是在用户空间运行的。

事实上,我们编写的绝大多数程序都是打算在用户空间运行的。事实上,这就是我们所说的C 编程C 开发。然而,如果我们打算编写一个应该在内核空间运行的 C 程序,我们使用一个不同的名称;我们称之为内核开发

我们正在分析下一个示例,示例 11.2,但在那之前,我们需要探索内核环境,看看它与用户空间有何不同。

内核开发

本节对那些希望成为内核开发者或操作系统领域的安全研究员的你们来说将是有益的。在第一部分,在跳转到系统调用本身之前,我们想要解释内核开发与普通 C 开发之间的差异。

内核的开发与普通 C 程序的开发在许多方面都不同。在探讨这些差异之前,我们应该注意的一点是,C 开发通常发生在用户空间。

在以下列表中,我们提供了内核和用户空间开发过程中六个关键差异:

  • 只有一个内核进程在运行一切。这仅仅意味着如果你的代码在内核中导致崩溃,你可能需要重新启动机器并让内核重新初始化。因此,与内核进程相关,开发成本非常高,你不能在不重启机器的情况下尝试各种解决方案,而你可以在处理用户空间程序时轻松地这样做。在内核崩溃时,会生成一个内核崩溃转储,可以用来诊断原因。

  • 在内核环中没有像 glibc 这样的 C 标准库!换句话说,这是一个 SUS 和 POSIX 标准不再有效的领域。因此,你不能包含任何 libc 头文件,例如 stdio.hstring.h。在这种情况下,你有一组专门用于各种操作的函数。这些函数通常位于 内核头文件 中,并且可能因 Unix 版本的不同而不同,因为在这个领域没有标准化。

    例如,如果你在 Linux 上进行内核开发,你可能使用 printk 将消息写入内核的 消息缓冲区。然而,在 FreeBSD 中,你需要使用 printf 函数族,这些函数与 libc 的 printf 函数不同。你可以在 FreeBSD 系统的 <sys/system.h> 头文件中找到这些 printf 函数。在 XNU 内核开发中对应的函数是 os_log。请注意,XNU 是 macOS 的内核。

  • 你可以在内核中读取或修改文件,但不能使用 libc 函数。每个 Unix 内核都有自己的方法来访问内核环内的文件。这对于通过 libc 暴露的所有功能都是相同的。

  • 你可以完全访问内核环中的物理内存和许多其他服务。因此,编写安全可靠代码非常重要。

  • 内核中没有系统调用机制。系统调用是用户空间中使用户进程能够与内核环通信的主要机制。因此,一旦你处于内核中,就不再需要它。

  • 内核进程是通过将内核镜像复制到物理内存中创建的,由 引导加载程序 执行。您不能在不从头创建内核镜像并重新引导系统重新加载的情况下添加新的系统调用。在支持 内核模块 的内核中,您可以在内核运行时轻松添加或删除模块,但您不能对系统调用做同样的事情。

如您所看到的,与普通的 C 开发相比,内核开发发生在不同的流程中。测试编写的逻辑不是一件容易的事情,有缺陷的代码可能导致系统崩溃。

在下一节中,我们将通过添加一个新的系统调用来进行我们的第一次内核开发。我们这样做并不是因为当你想在内核中引入新的功能时,添加系统调用是常见的,但我们是想通过尝试来熟悉内核开发。

为 Linux 编写一个 Hello World 系统调用

在本节中,我们将为 Linux 编写一个新的系统调用。互联网上有许多优秀的资源解释了如何向现有的 Linux 内核添加系统调用,但以下论坛帖子,将 Hello World 系统调用添加到 Linux 内核 – 可在 https://medium.com/anubhav-shrimal/adding-a-hello-world-system-call-to-linux-kernel-dad32875872 找到 – 被用作构建我在 Linux 中自己的系统调用的基础。

示例 11.2示例 11.1 的一个高级版本,它使用了一个不同且定制的系统调用,我们将在本节中编写。新的系统调用接收四个参数。前两个参数用于输入名称,后两个参数用于输出问候字符串。我们的系统调用通过其前两个参数接受一个名称,一个指向用户空间中已分配缓冲区的 char 指针和一个表示缓冲区长度的整数,并使用其第二个两个参数返回问候字符串,一个不同于输入缓冲区的指针,并且再次在用户空间中分配,以及一个表示其长度的整数。

警告

请不要在打算用于工作或家庭用途的 Linux 安装上执行此实验。请在实验机器上运行以下命令,强烈建议使用虚拟机。您可以通过使用仿真应用程序(如 VirtualBox 或 VMware)轻松创建虚拟机。

如果不恰当地或以错误的顺序使用以下说明,它们可能会损坏您的系统,并导致您丢失部分甚至全部数据。如果您打算在非实验机器上运行以下命令,请始终考虑一些备份解决方案,以复制您的数据。

首先,我们需要下载 Linux 内核的最新源代码。我们将使用 Linux GitHub 仓库来克隆其源代码,然后我们将选择一个特定的发布版本。版本 5.3 于 2019 年 9 月 15 日发布,因此我们将使用这个版本进行本示例。

注意

Linux 是一个内核。这意味着它只能安装在类 Unix 操作系统的内核环中,但 Linux 发行版 是另一回事。Linux 发行版在其内核环中有一个特定的 Linux 内核版本,在其 shell 环中有一个特定的 GNU libc 和 Bash(或 GNU shell)版本。

每个 Linux 发行版通常都附带其外部环中完整的用户应用程序列表。因此,我们可以说 Linux 发行版是一个完整的操作系统。请注意,Linux 发行版Linux distroLinux flavor 都指的是同一件事。

在这个示例中,我正在使用 64 位机器上的 Ubuntu 18.04.1 Linux 发行版。

在我们开始之前,确保通过运行以下命令安装了先决条件软件包是非常重要的:

$ sudo apt-get update
$ sudo apt-get install -y build-essential autoconf libncurses5-dev libssl-dev bison flex libelf-dev git
...
...
$

Shell Box 11-3:安装示例 11.2 所需的先决条件软件包

关于前面指令的一些说明:apt 是基于 Debian 的 Linux 发行版中的主要软件包管理器,而 sudo 是一个我们用来以 超级用户 模式运行命令的实用程序。它在几乎每个类 Unix 操作系统中都可用。

下一步是克隆 Linux GitHub 仓库。在克隆仓库之后,我们还需要检出版本 5.3。可以通过使用发布标签名称来检出版本,如下面的命令所示:

$ git clone https://github.com/torvalds/linux
$ cd linux
$ git checkout v5.3
$

Shell Box 11-4:克隆 Linux 内核并检出版本 5.3

现在,如果你查看根目录中的文件,你会看到很多文件和目录,它们组合起来构成了 Linux 内核代码库:

$ ls
total 760K
drwxrwxr-x  33 kamran kamran 4.0K Jan 28  2018 arch
drwxrwxr-x   3 kamran kamran 4.0K Oct 16 22:11 block
drwxrwxr-x   2 kamran kamran 4.0K Oct 16 22:11 certs
...
drwxrwxr-x 125 kamran kamran  12K Oct 16 22:11 Documentation
drwxrwxr-x 132 kamran kamran 4.0K Oct 16 22:11 drivers
-rw-rw-r--   1 kamran kamran 3.4K Oct 16 22:11 dropped.txt
drwxrwxr-x   2 kamran kamran 4.0K Jan 28  2018 firmare
drwxrwxr-x  75 kamraln kamran 4.0K Oct 16 22:11 fs
drwxrwxr-x  27 kamran kamran 4.0K Jan 28  2018 include
...
-rw-rw-r--   1 kamran kamran  287 Jan 28  2018 Kconfig
drwxrwxr-x  17 kamran kamran 4.0K Oct 16 22:11 kernel
drwxrwxr-x  13 kamran kamran  12K Oct 16 22:11 lib
-rw-rw-r--   1 kamran kamran 429K Oct 16 22:11 MAINTAINERS
-rw-rw-r--   1 kamran kamran  61K Oct 16 22:11 Makefile
drwxrwxr-x   3 kamran kamran 4.0K Oct 16 22:11 mm
drwxrwxr-x  69 kamran kamran 4.0K Jan 28  2018 net
-rw-rw-r--   1 kamran kamran  722 Jan 28  2018 README
drwxrwxr-x  28 kamran kamran 4.0K Jan 28  2018 samples
drwxrwxr-x  14 kamran kamran 4.0K Oct 16 22:11 scripts
...
drwxrwxr-x   4 kamran kamran 4.0K Jan 28  2018 virt
drwxrwxr-x   5 kamran kamran 4.0K Oct 16 22:11 zfs
$

Shell Box 11-5:Linux 内核代码库的内容

如您所见,有一些目录可能看起来很熟悉:fsmmnetarch 等。我应该指出,我们不会对每个这些目录的详细信息进行更多说明,因为它们可以从一个内核到另一个内核有很大的不同,但一个共同的特点是所有内核几乎都遵循相同的内部结构。

现在我们已经有了内核源代码,我们应该开始添加我们新的 Hello World 系统调用。然而,在我们这样做之前,我们需要为我们的系统调用选择一个唯一的数值标识符;在这种情况下,我给它命名为 hello_world,并选择 999 作为它的编号。

首先,我们需要将系统调用函数声明添加到 include/linux/syscalls.h 头文件末尾。经过这次修改后,文件应该看起来像这样:

/*
 * syscalls.h - Linux syscall interfaces (non-arch-specific)
 *
 * Copyright (c) 2004 Randy Dunlap
 * Copyright (c) 2004 Open Source Development Labs
 *
 * This file is released under the GPLv2.
 * See the file COPYING for more details.
 */
#ifndef _LINUX_SYSCALLS_H
#define _LINUX_SYSCALLS_H
struct epoll_event;
struct iattr;
struct inode;
...
asmlinkage long sys_statx(int dfd, const char __user *path, unsigned flags,
                          unsigned mask, struct statx __user *buffer);
asmlinkage long sys_hello_world(const char __user *str,
 const size_t str_len,
 char __user *buf,
 size_t buf_len);
#endif

Code Box 11-3 [include/linux/syscalls.h]:新的 Hello World 系统调用的声明

顶部的描述说明这是一个包含 Linux syscall 接口的头文件,这些接口不是 架构特定的。这意味着在所有架构上,Linux 都暴露了相同的一组系统调用。

在文件末尾,我们声明了我们的系统调用函数,它接受四个参数。正如我们之前解释的,前两个参数是输入字符串及其长度,后两个参数是输出字符串及其长度。

注意,输入参数是 const,但输出参数不是。此外,__user 标识符表示指针指向用户空间内的内存地址。正如你所见,每个系统调用都有整数返回值作为其函数签名的一部分,这实际上是它的执行结果。返回值的范围及其含义因系统调用而异。在我们的系统调用中,0 表示成功,任何其他数字都表示失败。

现在,我们需要定义我们的系统调用。为此,我们必须首先在根目录下创建一个名为 hello_world 的文件夹,我们使用以下命令来完成:

$ mkdir hello_world
$ cd hello_world
$

Shell 框 11-6:创建 hello_world 目录

接下来,我们在 hello_world 目录内创建一个名为 sys_hello_world.c 的文件。该文件的 内容应如下所示:

#include <linux/kernel.h>   // For printk
#include <linux/string.h>   // For strcpy, strcat, strlen
#include <linux/slab.h>     // For kmalloc, kfree
#include <linux/uaccess.h>  // For copy_from_user, copy_to_user
#include <linux/syscalls.h> // For SYSCALL_DEFINE4
// Definition of the system call
SYSCALL_DEFINE4(hello_world,
          const char __user *, str,    // Input name
          const unsigned int, str_len, // Length of input name
          char __user *, buf,          // Output buffer
          unsigned int, buf_len) {     // Length of output buffer
  // The kernel stack variable supposed to keep the content
  // of the input buffer
  char name[64];
  // The kernel stack variable supposed to keep the final
  // output message.
  char message[96];
  printk("System call fired!\n");
  if (str_len >= 64) {
    printk("Too long input string.\n");
    return -1;
  }
  // Copy data from user space into kernel space
  if (copy_from_user(name, str, str_len)) {
    printk("Copy from user space failed.\n");
    return -2;
  }
  // Build up the final message
  strcpy(message, "Hello ");
  strcat(message, name);
  strcat(message, "!");
  // Check if the final message can be fit into the output binary
  if (strlen(message) >= (buf_len - 1)) {
    printk("Too small output buffer.\n");
    return -3;
  }
  // Copy back the message from the kernel space to the user space
  if (copy_to_user(buf, message, strlen(message) + 1)) {
    printk("Copy to user space failed.\n");
    return -4;
  }
  // Print the sent message into the kernel log
  printk("Message: %s\n", message);
  return 0;
}

代码框 11-4:Hello World 系统调用的定义

代码框 11-4 中,我们使用了 SYSCALL_DEFINE4 宏来定义我们的函数定义,其中 DEFINE4 后缀仅仅意味着它接受四个参数。

在函数体的开头,我们在内核栈顶部声明了两个字符数组。与普通进程类似,内核进程有一个包含栈的地址空间。在完成这一步之后,我们将用户空间的数据复制到内核空间。随后,我们通过连接一些字符串来创建问候信息。这个字符串仍然在内核内存中。最后,我们将消息复制回用户空间,使其对调用进程可用。

在出现错误的情况下,会返回适当的错误号,以便让调用进程知道系统调用的结果。

使我们的系统调用工作下一步是更新另一个表。x86 和 x64 架构只有一个系统调用表,新添加的系统调用应该添加到这个表中以供暴露。

只有完成这一步后,系统调用才在 x86 和 x64 机器上可用。要将系统调用添加到表中,我们需要添加 hello_word 和其函数名 sys_hello_world

要做到这一点,打开 arch/x86/entry/syscalls/syscall_64.tbl 文件,并在文件末尾添加以下行:

999      64     hello_world             __x64_sys_hello_world

代码框 11-5:将新添加的 Hello World 系统调用添加到系统调用表

修改后,文件应如下所示:

$ cat arch/x86/entry/syscalls/syscall_64.tbl
...
...
546     x32     preadv2                 __x32_compat_sys_preadv64v2
547     x32     pwritev2                __x32_compat_sys_pwritev64v2
999      64     hello_world             __x64_sys_hello_world
$

Shell 框 11-7:Hello World 系统调用添加到系统调用表

注意系统调用名称中的__x64_前缀。这是系统调用仅在 x64 系统中公开的指示。

Linux 内核使用 Make 构建系统编译所有源文件并构建最终的内核映像。接下来,您必须在hello_world目录中创建一个名为Makefile的文件。其内容,即一行文本,应该是以下内容:

obj-y := sys_hello_world.o

代码框 11-6:Hello World 系统调用的 Makefile

然后,您需要将hello_world目录添加到根目录中的主Makefile中。切换到内核的根目录,打开Makefile文件,找到以下行:

core-y  += kernel/certs/mm/fs/ipc/security/crypto/block/

代码框 11-7:应在根 Makefile 中修改的目标行

hello_world/添加到该列表中。所有这些目录都是应该作为内核构建部分构建的目录。

我们需要添加 Hello World 系统调用的目录,以便将其包含在构建过程中,并在最终的内核映像中包含它。修改后,该行应类似于以下代码:

core-y  += kernel/certs/mm/fs/hello_world/ipc/security/crypto/block/

代码框 11-8:修改后的目标行

下一步是构建内核。

构建内核

要构建内核,我们首先必须回到内核的根目录,因为在我们开始构建内核之前,您需要提供一个配置。配置包含应作为构建过程一部分构建的功能和单元列表。

以下命令尝试根据当前 Linux 内核的配置创建目标配置。它使用您内核中的现有值,并在我们试图构建的内核中存在较新的配置值时询问您进行确认。如果存在,您只需按Enter键即可简单地接受所有较新版本:

$ make localmodconfig
...
...
#
# configuration written to .config
#
$

Shell 框 11-8:基于当前运行内核创建内核配置

现在,您可以开始构建过程。由于 Linux 内核包含大量源文件,构建可能需要数小时才能完成。因此,我们需要并行运行编译。

如果您正在使用虚拟机,请配置您的机器具有超过一个核心,以便在构建过程中获得有效的提升:

$ make -j4
SYSHDR  arch/x86/include/generated/asm/unistd_32_ia32.h
SYSTBL  arch/x86/include/generated/asm/syscalls_32.h
HOSTCC  scripts/basic/bin2c
SYSHDR  arch/x86/include/generated/asm/unistd_64_x32.h
...
...
UPD     include/generated/compile.h
CC      init/main.o
CC      hello_world/sys_hello_world.o
CC      arch/x86/crypto/crc32c-intel_glue.o
...
...
LD [M]  net/netfilter/x_tables.ko
LD [M]  net/netfilter/xt_tcpudp.ko
LD [M]  net/sched/sch_fq_codel.ko
LD [M]  sound/ac97_bus.ko
LD [M]  sound/core/snd-pcm.ko
LD [M]  sound/core/snd.ko
LD [M]  sound/core/snd-timer.ko
LD [M]  sound/pci/ac97/snd-ac97-codec.ko
LD [M]  sound/pci/snd-intel8x0.ko
LD [M]  sound/soundcore.ko
$

Shell 框 11-9:内核构建的输出。请注意指示编译 Hello World 系统调用的行

注意

确保您已经安装了本节第一部分介绍的先决条件软件包;否则,您将遇到编译错误。

如您所见,构建过程已经开始,有四个作业正在并行尝试编译 C 文件。您需要等待其完成。完成后,您可以轻松地安装新的内核并重新启动机器:

$ sudo make modules_install install
INSTALL arch/x86/crypto/aes-x86_64.ko
INSTALL arch/x86/crypto/aesni-intel.ko
INSTALL arch/x86/crypto/crc32-pclmul.ko
INSTALL arch/x86/crypto/crct10dif-pclmul.ko
...
...
run-parts: executing /et/knel/postinst.d/initam-tools 5.3.0+ /boot/vmlinuz-5.3.0+
update-iniras: Generating /boot/initrd.img-5.3.0+
run-parts: executing /etc/keneostinst.d/unattende-urades 5.3.0+ /boot/vmlinuz-5.3.0+
...
...
Found initrd image: /boot/initrd.img-4.15.0-36-generic
Found linux image: /boot/vmlinuz-4.15.0-29-generic
Found initrd image: /boot/initrd.img-4.15.0-29-generic
done.  
$

Shell 框 11-10:创建和安装新的内核映像

如你所见,已经创建并安装了一个版本为 5.3.0 的新内核映像。现在我们准备好重启系统了。如果你不知道当前的内核版本,在重启之前不要忘记检查它。在我的情况下,我的版本是 4.15.0-36-generic。我使用了以下命令来找出它:

$ uname -r
4.15.0-36-generic $

Shell 框 11-11:检查当前安装的内核版本

现在,使用以下命令重启系统:

$ sudo reboot

Shell 框 11-12:重启系统

当系统启动时,新的内核映像将被选中并使用。请注意,引导加载程序不会选择旧内核;因此,如果你有一个版本高于 5.3 的内核,你需要手动加载构建的内核映像。这个链接可以帮助你:https://askubuntu.com/questions/82140/how-can-i-boot-with-an-older-kernel-version.

当操作系统启动完成时,你应该有新的内核正在运行。检查版本。它必须看起来像这样:

$ uname -r
5.3.0+
$

Shell 框 11-13:重启后检查内核版本

如果一切顺利,新的内核应该已经就位。现在我们可以继续编写一个调用我们新添加的 Hello World 系统调用的 C 程序。它将非常类似于 示例 11.1,它调用了 write 系统调用。你可以在下面找到 示例 11.2

// We need to have this to be able to use non-POSIX stuff
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
// This is not part of POSIX!
#include <sys/syscall.h>
int main(int argc, char** argv) {
  char str[20] = "Kam";
  char message[64] = "";
  // Call the hello world system call
  int ret_val = syscall(999, str, 4, message, 64);
  if (ret_val < 0) {
    printf("[ERR] Ret val: %d\n", ret_val);
    return 1;
  }
  printf("Message: %s\n", message);
  return 0;
}

代码框 11-9 [ExtremeC_examples_chapter11_2.c]:示例 11.2 调用新添加的 Hello World 系统调用

如你所见,我们使用数字 999 调用了系统调用。我们传递 Kam 作为输入,并期望收到 Hello Kam! 作为问候消息。程序等待结果并在内核空间中打印由系统调用填充的消息缓冲区。

在以下代码中,我们构建并运行了示例:

$ gcc ExtremeC_examples_chapter11_2.c -o ex11_2.out
$ ./ex11_2.out
Message: Hello Kam!
$

Shell 框 11-14:编译和运行示例 11.2

运行示例后,如果你使用 dmesg 命令查看内核日志,你会看到使用 printk 生成的日志:

$ dmesg
...
...
[  112.273783] System call fired!
[  112.273786] Message: Hello Kam!
$

Shell 框 11-15:使用 dmesg 查看 Hello World 系统调用生成的日志

如果你使用 strace 运行 示例 11.2,你可以看到它实际上调用了系统调用 999。你可以在以 syscall_0x3e7(...) 开头的行中看到它。请注意,0x3e7 是 999 的十六进制值:

$ strace ./ex11_2.out
...
...
mprotect(0x557266020000, 4096, PROT_READ) = 0
mprotect(0x7f8dd6d2d000, 4096, PROT_READ) = 0
munmap(0x7f8dd6d26000, 27048)           = 0
syscall_0x3e7(0x7fffe7d2af30, 0x4, 0x7fffe7d2af50, 0x40, 0x7f8dd6b01d80, 0x7fffe7d2b088) = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
brk(NULL)                               = 0x5572674f2000
brk(0x557267513000)
...
...
exit_group(0)                           = ?
+++ exited with 0 +++
$

Shell 框 11-16:监控示例 11.2 所做的系统调用

Shell 框 11-16 中,你可以看到已经调用了 syscall_0x3e7 并返回了 0。如果你将 示例 11.2 中的代码修改为传递一个超过 64 字节的名称,你会收到一个错误。让我们修改示例并再次运行它:

int main(int argc, char** argv) {
  char name[84] = "A very very long message! It is really hard to produce a big string!";
  char message[64] = "";
  ...
  return 0;
}

代码框 11-10:向我们的 Hello World 系统调用传递一个长消息(超过 64 字节)

让我们再次编译和运行它:

$ gcc ExtremeC_examples_chapter11_2.c -o ex11_2.out
$ ./ex11_2.out
[ERR] Ret val: -1
$

Shell 框 11-17:修改后编译和运行示例 11.2

如你所见,系统调用根据我们为其编写的逻辑返回 -1。使用 strace 运行也显示系统调用返回了 -1

$ strace ./ex11_2.out
...
...
munmap(0x7f1a900a5000, 27048)           = 0
syscall_0x3e7(0x7ffdf74e10f0, 0x54, 0x7ffdf74e1110, 0x40, 0x7f1a8fe80d80, 0x7ffdf74e1248) = -1 (errno 1)
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
brk(NULL)                               = 0x5646802e2000
...
...
exit_group(1)                           = ?
+++ exited with 1 +++
$

Shell 框 11-18:监控修改后示例 11.2 所做的系统调用

在下一节中,我们将讨论设计内核可以采取的方法。作为我们讨论的一部分,我们介绍了内核模块,并探讨了它们在内核开发中的应用。

Unix 内核

在本节中,我们将讨论过去 30 年中 Unix 内核所采用的架构。在讨论不同类型的内核之前——实际上种类并不多——我们应该知道,关于内核应该如何设计并没有标准化。

我们获得的最佳实践是基于多年的经验,它们引导我们形成了 Unix 内核内部单元的高级视图,这在上一章的图 10-5中有所体现。因此,每个内核与另一个内核相比都有所不同。它们共同的主要特点是它们应该通过系统调用接口来暴露其功能。然而,每个内核都有自己处理系统调用的独特方式。

这种多样性和围绕它的争论使它成为 20 世纪 90 年代最热门的计算机架构相关话题之一,有大量的人参与这些争论——其中坦能鲍姆-托瓦尔斯辩论被认为是其中最著名的一次。

我们不会深入这些辩论的细节,但我们要简要谈谈设计 Unix 内核的两种主要主导架构:单核微核。仍然存在其他架构,如混合内核纳米内核外核,它们都有自己特定的用途。

然而,我们将通过创建一个比较来关注单核内核和微内核,以便我们可以了解它们的特性。

单核内核与微内核

在上一章中,我们讨论 Unix 架构时,将内核描述为包含许多单元的单个进程,但实际上我们实际上是在谈论一个单核内核。

单核内核由一个内核进程和一个地址空间组成,该地址空间包含在同一进程内的多个较小的单元。微内核则采取相反的方法。微内核是一个最小的内核进程,它试图将文件系统、设备驱动程序和进程管理等服务推到用户空间,以使内核进程更小、更薄。

这两种架构都有其优缺点,因此它们成为了操作系统历史上最著名的辩论之一。它始于 1992 年,Linux 第一个版本发布之后不久。由安德鲁·S·坦能鲍姆撰写的一篇帖子在Usenet上引发了一场辩论。这场辩论被称为坦能鲍姆-托瓦尔斯辩论。你可以在 https://en.wikipedia.org/wiki/Tanenbaum–Torvalds_debate 了解更多信息。

那篇帖子是 Linux 创建者林纳斯·托瓦兹与谭宁邦以及其他一些爱好者之间引发激烈争论的起点,这些人后来成为了第一批 Linux 开发者。他们正在辩论单核内核和微内核的本质。在这次激烈争论中,讨论了许多内核设计和硬件架构对内核设计的影响的不同方面。

对所描述的辩论和主题的进一步讨论将会很长且复杂,因此超出了本书的范围,但我们想比较这两种方法,并让您熟悉每种方法的优缺点。

以下是比较单核内核和微内核之间差异的列表:

  • 单核内核由一个包含内核提供所有服务的单个进程组成。大多数早期的 Unix 内核都是这样开发的,这被认为是一种老方法。微内核与之不同,因为内核提供的每个服务都有单独的进程。

  • 单核内核进程位于内核空间,而微内核中的服务器进程通常位于用户空间。服务器进程是那些提供内核功能的过程,例如内存管理、文件系统等。微内核与之不同,它们允许服务器进程位于用户空间。这意味着一些操作系统比其他操作系统更类似于微内核。

  • 单核内核通常更快。这是因为所有内核服务都在内核进程中执行,但微内核需要在用户空间和内核空间之间进行一些消息传递,因此需要更多的系统调用和上下文切换。

  • 在单核内核中,所有设备驱动程序都加载到内核中。因此,第三方供应商编写的设备驱动程序将作为内核的一部分运行。任何设备驱动程序或内核内部其他单元的任何缺陷都可能导致内核崩溃。这与微内核的情况不同,因为所有的设备驱动程序和许多其他单元都在用户空间中运行,我们可以假设这就是为什么单核内核没有被用于关键任务项目的原因。

  • 在单核内核中,注入一小段恶意代码就足以破坏整个内核,进而破坏整个系统。然而,在微内核中这种情况不太可能发生,因为许多服务器进程位于用户空间,只有最小的一组关键功能集中在内核空间。

  • 在单一内核中,即使是内核源代码的简单更改也需要重新编译整个内核,并生成新的内核映像。加载新的映像还需要重新启动机器。但在微内核中,更改可以导致仅编译特定的服务器进程,并且可能在不重新启动系统的情况下加载新的功能。在单一内核中,可以通过内核模块在一定程度上获得类似的功能。

MINIX 是微内核最著名的例子之一。它是由 Andrew S. Tanenbaum 编写的,最初是一个教育操作系统。Linus Torvalds 在 1991 年为 80386 微处理器编写自己的内核 Linux 时,使用了 MINIX 作为他的开发环境。

由于 Linux 几乎是近 30 年来最大的、最成功的单一内核捍卫者,我们将在下一节中更多地讨论 Linux。

Linux

在本章前面的部分,当我们为它开发一个新的系统调用时,你已经了解了 Linux 内核。在本节中,我们想更多地关注 Linux 是单一内核的事实,以及每个内核功能都在内核内部。

然而,应该有一种方法可以在不重新编译内核的情况下添加新的功能。由于,正如你所看到的,添加一个新的系统调用需要更改许多基本文件,这意味着我们需要重新编译内核以获得新的功能。

新的方法是不同的。在这种技术中,内核模块被编写并动态地插入内核中,我们将在第一部分讨论这一点,然后再继续编写 Linux 内核模块。

内核模块

单一内核通常配备另一个设施,使内核开发者能够将新的功能热插拔到正在运行的内核中。这些可插入单元被称为内核模块。这些与微内核中的服务器进程不同。

与微内核中的服务器进程不同,微内核中的服务器进程实际上是使用 IPC 技术相互通信的独立进程,内核模块是已经编译好的内核对象文件,可以动态地加载到内核进程中。这些内核对象文件可以成为内核映像的一部分静态构建,或者当内核正在运行时动态加载。

注意,内核对象文件是 C 开发中产生的普通对象文件的双胞胎概念。

值得再次注意的是,如果内核模块在内核内部做了一些坏事,可能会发生内核崩溃。

与系统调用不同,与内核模块的通信方式不同,不能通过调用函数或使用给定的 API 来使用。通常,在 Linux 和一些类似操作系统中,与内核模块通信有三种方式:

  • /dev 目录中的设备文件:内核模块主要是为了被设备驱动程序使用而开发的,这也是为什么设备是与内核模块通信的最常见方式。正如我们在上一章中解释的,设备作为位于 /dev 目录中的设备文件是可访问的。你可以从这些文件中读取和写入,并使用它们,你可以向/从模块发送和接收数据。

  • procfs 中的条目/proc 目录中的条目可以用来读取特定内核模块的元信息。这些文件也可以用来传递元信息或控制命令给内核模块。我们将在下一示例中简要演示 procfs 的用法,即 示例 11.3,作为以下部分的内容。

  • sysfs 中的条目:这是 Linux 中的另一个文件系统,允许脚本和用户控制用户进程以及其他与内核相关的单元,例如内核模块。它可以被认为是 procfs 的新版本。

实际上,最好的方法是编写一个内核模块,这正是我们在下一节将要做的,我们将为 Linux 编写一个 Hello World 内核模块。请注意,内核模块不仅限于 Linux;像 FreeBSD 这样的单核内核也受益于内核模块机制。

将内核模块添加到 Linux

在本节中,我们将编写一个新的 Linux 内核模块。这是一个 Hello World 内核模块,它在 procfs 中创建一个条目。然后,使用这个条目,我们读取问候字符串。

在本节中,你将熟悉编写内核模块、编译它、将其加载到内核中、从内核中卸载它以及从 procfs 条目中读取数据。本示例的主要目的是让你亲自动手编写内核模块,从而可以自己进行更多开发。

注意

内核模块被编译成可以在运行时直接加载到内核中的内核对象文件。只要内核模块对象文件没有在内核中做任何导致内核崩溃的坏事,就不需要重新启动系统。卸载内核模块也是如此。

第一步是创建一个目录,该目录将包含所有与内核模块相关的文件。我们将其命名为 ex11_3,因为这是本章的第三个示例:

$ mkdir ex11_3
$ cd ex11_3
$

Shell 框 11-19:为示例 11.3 创建根目录

然后,创建一个名为 hwkm.c 的文件,它只是由 "Hello World Kernel Module" 的首字母组成的缩写,其内容如下:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/proc_fs.h>
// The structure pointing to the proc file
struct proc_dir_entry *proc_file;
// The read callback function
ssize_t proc_file_read(struct file *file, char __user *ubuf, size_t count, loff_t *ppos) {
  int copied = 0;
  if (*ppos > 0) {
    return 0;
  }
  copied = sprintf(ubuf, "Hello World From Kernel Module!\n");
  *ppos = copied;
  return copied;
}
static const struct file_operations proc_file_fops = {
 .owner = THIS_MODULE,
 .read  = proc_file_read
};
// The module initialization callback
static int __init hwkm_init(void) {
  proc_file = proc_create("hwkm", 0, NULL, &proc_file_fops);
  if (!proc_file) {
    return -ENOMEM;
  }
  printk("Hello World module is loaded.\n");
  return 0;
}
// The module exit callback
static void __exit hkwm_exit(void) {
  proc_remove(proc_file);
  printk("Goodbye World!\n");
}
// Defining module callbacks
module_init(hwkm_init);
module_exit(hkwm_exit);

代码框 11-11 [ex11_3/hwkm.c]:Hello World 内核模块

使用 代码框 11-11 中的最后两条语句,我们已经注册了模块的初始化和退出回调函数。这些函数分别在模块加载和卸载时被调用。初始化回调是首先执行的代码。

如您在 hwkm_init 函数内部所见,它会在 /proc 目录下创建一个名为 hwkm 的文件。还有一个退出回调。在 hwkm_exit 函数内部,它会从 /proc 路径中删除 hwkm 文件。/proc/hwkm 文件是用户空间与内核模块通信的接触点。

proc_file_read 函数是读取回调函数。当用户空间尝试读取 /proc/hwkm 文件时,会调用此函数。您很快就会看到,我们使用 cat 工具程序来读取文件。它简单地将 Hello World From Kernel Module! 字符串复制到用户空间。

注意,在这个阶段,内核模块内部编写的代码几乎可以访问内核内部的任何内容,并且它可以向用户空间泄露任何类型的信息。这是一个主要的安全问题,应该进一步阅读有关编写安全内核模块的最佳实践的资料。

要编译前面的代码,我们需要使用适当的编译器,可能还需要将其与适当的库链接。为了使生活更简单,我们创建了一个名为 Makefile 的文件,该文件将触发必要的构建工具以构建内核模块。

以下代码框显示了 Makefile 的内容:

obj-m += hwkm.o
all:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Code Box 11-12:Hello World 内核模块的 Makefile

然后,我们可以运行 make 命令。以下 shell 窗口演示了这一点:

$ make
make -C /lib/modules/54.318.0+/build M=/home/kamran/extreme_c/ch11/codes/ex11_3 modules
make[1]: Entering directory '/home/kamran/linux'
  CC [M]  /home/kamran/extreme_c/ch11/codes/ex11_3/hwkm.o
  Building modules, stage 2.
  MODPOST 1 modules
WARNING: modpost: missing MODULE_LICENSE() in /home/kamran/extreme_c/ch11/codes/ex11_3/hwkm.o
see include/linux/module.h for more information
  CC      /home/kamran/extreme_c/ch11/codes/ex11_3/hwkm.mod.o
  LD [M]  /home/kamran/extreme_c/ch11/codes/ex11_3/hwkm.ko
make[1]: Leaving directory '/home/kamran/linux'
$

Shell Box 11-20:构建 Hello World 内核模块

如您所见,编译器编译代码并生成一个对象文件。然后,它继续将对象文件与其他库链接以创建一个 .ko 文件。现在,如果您查看生成的文件,您会发现一个名为 hwkm.ko 的文件。

注意到 .ko 扩展名,它仅仅意味着输出文件是一个内核对象文件。它就像一个可以动态加载到内核并运行的共享库。

请注意,在 Shell Box 11-20 中,构建过程生成了一个警告消息。它表示该模块没有与之关联的许可证。在开发和部署内核模块的测试和生产环境中,生成授权模块是一种高度推荐的做法。

以下 shell 窗口显示了构建内核模块后可以找到的文件列表:

$ ls -l
total 556
-rw-rw-r-- 1 kamran kamran    154 Oct 19 00:36 Makefile
-rw-rw-r-- 1 kamran kamran      0 Oct 19 08:15 Module.symvers
-rw-rw-r-- 1 kamran kamran   1104 Oct 19 08:05 hwkm.c
-rw-rw-r-- 1 kamran kamran 272280 Oct 19 08:15 hwkm.ko
-rw-rw-r-- 1 kamran kamran    596 Oct 19 08:15 hwkm.mod.c
-rw-rw-r-- 1 kamran kamran 104488 Oct 19 08:15 hwkm.mod.o
-rw-rw-r-- 1 kamran kamran 169272 Oct 19 08:15 hwkm.o
-rw-rw-r-- 1 kamran kamran     54 Oct 19 08:15 modules.order
$

Shell Box 11-21:构建 Hello World 内核模块后的现有文件列表

注意

我们使用了 Linux 内核版本 5.3.0 的模块构建工具。如果您使用低于 3.10 的内核版本编译此示例,可能会得到编译错误。

要加载 hwkm 内核模块,我们使用 Linux 中的 insmod 命令,它简单地加载并安装内核模块,就像我们在以下 shell 窗口中做的那样:

$ sudo insmod hwkm.ko
$

Shell Box 11-22:加载和安装 Hello World 内核模块

现在,如果您查看内核日志,您将看到由初始化函数产生的行。只需使用 dmesg 命令查看最新的内核日志,这是我们接下来要做的:

$ dmesg
...
...
[ 7411.519575] Hello World module is loaded.
$

Shell Box 11-23:安装内核模块后的内核日志消息检查

现在,模块已经加载,应该已经创建了 /proc/hwkm 文件。我们可以通过使用 cat 命令来读取它:

$ cat /proc/hwkm
Hello World From Kernel Module!
$ cat /proc/hwkm
Hello World From Kernel Module!
$

Shell Box 11-24:使用 cat 读取 /proc/hwkm 文件

如您在前面的 shell 窗口中看到的,我们读取了文件两次,两次都返回了相同的 Hello World From Kernel Module! 字符串。请注意,该字符串是由内核模块复制到用户空间的,而 cat 程序只是将其打印到标准输出。

当涉及到卸载模块时,我们可以使用 Linux 中的 rmmod 命令,就像我们接下来要做的:

$ sudo rmmod hwkm
$

Shell Box 11-25:卸载 Hello World 内核模块

现在模块已经卸载,再次查看内核日志以查看再见信息:

$ dmesg
...
...
[ 7411.519575] Hello World module is loaded.
[ 7648.950639] Goodbye World!
$

Shell Box 11-26:卸载内核模块后的内核日志消息检查

正如您在前面的示例中看到的,内核模块在编写内核代码时非常方便。

为了完成本章,我相信提供一个关于我们迄今为止所看到的内核模块功能的列表将会有所帮助:

  • 内核模块可以在不重新启动机器的情况下加载和卸载。

  • 当加载时,它们成为内核的一部分,可以访问内核中的任何单元或结构。这可以被认为是一个漏洞,但 Linux 内核可以保护自己免受安装不受欢迎的模块的影响。

  • 在内核模块的情况下,您只需要编译它们的源代码。但对于系统调用,您必须编译整个内核,这可能会占用您一个小时的时间。

最后,当您要开发需要在系统调用背后运行的代码时,内核模块可能很有用。将要使用系统调用暴露的逻辑可以先通过内核模块加载到内核中,经过适当的开发和测试后,它可以放在真正的系统调用后面。

从头开始开发系统调用可能是一项繁琐的工作,因为您不得不无数次地重新启动您的机器。将逻辑首先作为内核模块的一部分编写和测试可以减轻内核开发的痛苦。请注意,如果您的代码试图导致内核崩溃,无论是内核模块还是系统调用之后,都会导致内核崩溃,您必须重新启动您的机器。

在本节中,我们讨论了各种类型的内核。我们还展示了如何在单核内核中通过动态加载和卸载来使用内核模块实现瞬态内核逻辑。

概述

我们现在已经完成了关于 Unix 的两章讨论。在本章中,我们学习了以下内容:

  • 系统调用是什么以及它是如何暴露特定功能的

  • 系统调用调用背后的发生情况

  • 如何直接从 C 代码中调用某个系统调用

  • 如何向现有的类 Unix 内核(Linux)添加一个新的系统调用以及如何重新编译内核

  • 什么是单核内核以及它与微内核的区别

  • 内核模块如何在单核内核中工作以及如何为 Linux 编写一个新的内核模块

在接下来的章节中,我们将讨论 C 标准以及最新的 C 标准版本,C18。您将熟悉其中引入的新特性。

第十二章

最新的 C

变化是无法阻止的,C 语言也不例外。C 编程语言由 ISO 标准标准化,并且由一群试图使其更好并为其带来新特性的群体不断修订。这并不意味着语言一定会变得更容易;我们可能会看到随着新内容的添加,语言中出现新颖和复杂的功能。

在本章中,我们将简要地看看 C11 的特性。你可能知道 C11 已经取代了旧的 C99 标准,并且已经被 C18 标准所取代。换句话说,C18 是 C 标准的最新版本,而在那之前我们有 C11。

有趣的是,C18 没有提供任何新特性;它只是对 C11 中发现的问题进行了修复。因此,谈论 C11 基本上等同于谈论 C18,这将引导我们到最新的 C 标准。正如你所看到的,我们在 C 语言中观察到持续改进……这与它是一个已经死去很长时间的语言的看法相反!

本章将简要概述以下主题:

  • 如何检测 C 版本以及如何编写兼容各种 C 版本的 C 代码

  • 用于编写优化和安全的代码的新特性,如不返回函数和边界检查函数

  • 新的数据类型和内存对齐技术

  • 类型泛型函数

  • C11 中的 Unicode 支持,这在旧标准中缺失

  • 匿名结构和联合

  • C11 中标准的多线程和同步技术支持

让我们从讨论 C11 及其新特性开始本章。

C11

收集一个使用超过 30 年的技术的新的标准并非易事。数百万(如果不是数十亿!)行 C 代码存在,如果你即将引入新特性,这必须在保持现有代码或特性完整的情况下完成。新特性不应为现有程序带来新问题,并且应该是无错误的。虽然这种观点看似理想化,但这是我们应该致力于做到的。

以下 PDF 文档位于开放标准网站上,包含了在开始塑造 C11 之前 C 社区中人们的担忧和思考:http://www.open-std.org/JTC1/SC22/wg14/www/docs/n1250.pdf。阅读它会有所帮助,因为它会向你介绍为基于数千个软件构建的编程语言编写新标准时的经验。

最后,考虑到这些因素,我们考虑 C11 的发布。当 C11 发布时,它并非处于理想状态,实际上正遭受一些严重的缺陷。你可以看到这些缺陷的列表这里:http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2244

C11 发布七年之后,推出了 C18,这是为了修复在 C11 中发现的问题。请注意,C18 也非正式地被称为 C17,C17 和 C18 都指的是相同的 C 标准。如果您打开前面的链接,您将看到缺陷及其当前状态。如果缺陷的状态是“C17”,这意味着该缺陷作为 C18 的一部分得到了解决。这显示了构建一个像 C 一样拥有众多用户的标准的艰难和精细过程。

在接下来的几节中,我们将讨论 C11 的新特性。然而,在通过它们之前,我们需要一种方法来确保我们确实在编写 C11 代码,并且我们使用的是兼容的编译器。下一节将解决这个问题。

查找支持的 C 标准版本

在撰写本文时,C11 发布已有近 8 年。因此,可以预期许多编译器应该支持该标准,这确实是事实。开源编译器如gccclang都完美支持 C11,并且如果需要,它们可以切换回 C99 或更早的版本。在本节中,我们将展示如何使用特定的宏来检测 C 版本,以及根据版本如何使用支持的功能。

当使用支持不同 C 标准版本的编译器时,首先必要的是能够识别当前正在使用的 C 标准版本。每个 C 标准都定义了一个特殊的宏,可以用来找出正在使用哪个版本。到目前为止,我们在 Linux 系统中使用了gcc,在 macOS 系统中使用了clang。从版本 4.7 开始,gcc将其支持的标准之一提供为 C11。

让我们看看以下示例,看看已经定义的宏如何用于在运行时检测当前的 C 标准版本:

#include <stdio.h>
int main(int argc, char** argv) {
#if __STDC_VERSION__ >=  201710L
  printf("Hello World from C18!\n");
#elif __STDC_VERSION__ >= 201112L
  printf("Hello World from C11!\n");
#elif __STDC_VERSION__ >= 199901L
  printf("Hello World from C99!\n");
#else
  printf("Hello World from C89/C90!\n");
#endif
  return 0;
}

Code Box 12-1 [ExtremeC_examples_chapter12_1.c]:检测 C 标准的版本

如您所见,前面的代码可以区分不同的 C 标准版本。为了看到不同的 C 版本如何导致不同的打印结果,我们必须多次使用编译器支持的 C 标准版本编译前面的源代码。

要让编译器使用特定的 C 标准版本,我们必须将-std=CXX选项传递给 C 编译器。查看以下命令和产生的输出:

$ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out
$ ./ex12_1.out
Hello World from C11!
$ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out -std=c11
$ ./ex12_1.out
Hello World from C11!
$ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out -std=c99
$ ./ex12_1.out
Hello World from C99!
$ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out -std=c90
$ ./ex12_1.out
Hello World from C89/C90!
$ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out -std=c89
$ ./ex12_1.out
Hello World from C89/C90!
$

Shell Box 12-1:使用各种 C 标准版本编译示例 12.1

如您所见,较新编译器的默认 C 标准版本是 C11。在较旧版本中,如果您想启用 C11,必须使用-std选项指定版本。注意文件开头所做的注释。我使用了/* ... */多行注释而不是//单行注释。这是因为 C99 之前的标准中不支持单行注释。因此,我们必须使用多行注释,以便前面的代码能够与所有 C 版本兼容地编译。

移除 gets 函数

在 C11 中,著名的 gets 函数被移除。gets 函数曾受到 缓冲区溢出 攻击,在旧版本中,它被决定为 已弃用。后来,作为 C11 标准的一部分,它被移除。因此,使用 gets 函数的旧源代码将无法使用 C11 编译器编译。

可以使用 fgets 函数代替 gets。以下是从 macOS 中 gets 手册页(man 页)摘录的内容:

安全考虑

gets() 函数不能安全使用。由于其缺乏边界检查,以及调用程序无法可靠地确定下一行输入的长度,使用此函数会使恶意用户通过缓冲区溢出攻击任意更改正在运行的程序的功能。强烈建议在所有情况下使用 fgets() 函数。(参见 FSA。)

fopen 函数的更改

fopen 函数通常用于打开文件并返回该文件的文件描述符。在 Unix 中,文件的概念非常通用,使用术语 文件 并不一定意味着位于文件系统上的文件。fopen 函数有以下签名:

FILE* fopen(const char *pathname, const char *mode);
FILE* fdopen(int fd, const char *mode);
FILE* freopen(const char *pathname, const char *mode, FILE *stream);

Code Box 12-2:fopen 函数家族的各种签名

如您所见,所有前面的签名都接受一个 mode 输入。此输入参数是一个字符串,它决定了文件应该如何打开。Shell Box 12-2 中的以下描述来自 FreeBSD 手册中的 fopen 函数,并解释了如何使用 mode

$ man 3 fopen
...
The argument mode points to a string beginning with one of the following letters:
     "r"     Open for reading.  The stream is positioned at the beginning
             of the file.  Fail if the file does not exist.
     "w"     Open for writing.  The stream is positioned at the beginning
             of the file.  Create the file if it does not exist.
     "a"     Open for writing.  The stream is positioned at the end of
             the file. Subsequent writes to the file will always end up
             at the then current end of file, irrespective of 
             any intervening fseek(3) or similar. Create the file 
             if it does not exist.
     An optional "+" following "r", "w", or "a" opens the file
     for both reading and writing.  An optional "x" following "w" or
     "w+" causes the fopen() call to fail if the file already exists.
     An optional "e" following the above causes the fopen() call to set
     the FD_CLOEXEC flag on the underlying file descriptor.
     The mode string can also include the letter "b" after either 
     the "+" or the first letter.
...
$

Shell Box 12-2:FreeBSD 中 fopen 手册页的摘录

fopen 手册页的前面摘录中解释的 x 模式是作为 C11 的一部分引入的。为了写入文件,应向 fopen 提供模式 ww+。问题是,如果文件已经存在,ww+ 模式将截断(清空)文件。

因此,如果程序员想要向文件追加内容并保留其当前内容,他们必须使用不同的模式,即 a。因此,他们必须在调用 fopen 之前使用文件系统 API(如 stat)检查文件是否存在,然后根据结果选择适当的模式。然而,现在有了新的模式 x,程序员首先尝试使用模式 wxw+x,如果文件已存在,fopen 将失败。然后程序员可以继续使用 a 模式。

因此,在不需要使用文件系统 API 检查文件是否存在的情况下打开文件,需要编写的样板代码更少。从现在开始,fopen 就足以以每种所需的模式打开文件。

C11 的另一个变化是引入了 fopen_s API。这个函数作为安全的 fopen。根据位于 https://en.cppreference.com/w/c/io/fopenfopen_s 文档,对提供的缓冲区和它们的边界进行额外检查,以检测其中的任何缺陷。

边界检查函数

C 程序在字符串和字节数组上操作时遇到的一个严重问题是容易超出为缓冲区或字节数组定义的边界。

作为提醒,缓冲区是内存中的一个区域,用作字节数组或字符串变量的占位符。超出缓冲区的边界会导致缓冲区溢出,基于此,恶意实体可以组织攻击(通常称为缓冲区溢出攻击)。这种攻击要么导致拒绝服务DOS),要么在受影响的 C 程序中进行利用

大多数此类攻击通常从一个操作字符或字节数组的函数开始。在string.h中找到的字符串操作函数,如strcpystrcat,是缺乏边界检查机制以防止缓冲区溢出攻击的易受攻击函数。

然而,作为 C11 的一部分,引入了一套新的函数。边界检查函数从字符串操作函数借用相同的名称,但以_s结尾。后缀_s将它们区分开来,作为安全安全版本的函数,这些函数在运行时进行更多的检查,以关闭漏洞。strcpy_sstrcat_s等函数作为 C11 中边界检查函数的一部分被引入。

这些函数接受一些额外的输入缓冲区参数,限制了它们执行危险操作的能力。例如,strcpy_s函数具有以下签名:

errno_t strcpy_s(char *restrict dest, rsize_t destsz, const char *restrict src);

代码框 12-3:strcpy_s 函数的签名

如您所见,第二个参数是dest缓冲区的长度。使用它,该函数执行一些运行时检查,例如确保src字符串的长度短于或与dest缓冲区的大小相同,以防止写入未分配的内存。

无返回函数

函数调用可以通过使用return关键字或到达函数块的末尾来结束。也存在函数调用永远不会结束的情况,这通常是有意为之。看看以下包含在代码框 12-4中的代码示例:

void main_loop() {
  while (1) {
    ...
  }
}

int main(int argc, char** argv) {
  ...
  main_loop();
  return 0;
}

代码框 12-4:永不返回的函数示例

如您所见,函数main_loop执行程序的主要任务,如果我们从函数返回,则程序可以被认为是结束的。在这些异常情况下,编译器可以执行一些额外的优化,但无论如何,它需要知道函数main_loop永远不会返回。

在 C11 中,您可以将一个函数标记为无返回函数。stdnoreturn.h头文件中的_Noreturn关键字可以用来指定一个函数永远不会退出。因此,代码框 12-4中的代码可以修改为 C11 的如下所示:

_Noreturn void main_loop() {
  while (true) {
    ...
  }
}

代码框 12-5:使用 _Noreturn 关键字标记 main_loop 为永不结束的函数

还有其他函数,如exitquick_exit(作为 C11 的一部分最近添加,用于快速终止程序),以及abort,被认为是不可返回的函数。此外,了解不可返回函数允许编译器识别那些无意中不会返回的函数调用,并产生适当的警告,因为这些可能是逻辑错误的迹象。请注意,如果一个标记为_Noreturn的函数返回,那么这将是一种未定义的行为,并且强烈不建议这样做。

输入通用宏

在 C11 中,引入了一个新的关键字:_Generic。它可以用来编写在编译时具有类型感知能力的宏。换句话说,你可以编写可以根据其参数类型改变其值的宏。这通常被称为泛型选择。请看以下代码示例在代码框 12-6

#include <stdio.h>
#define abs(x) _Generic((x), \
                        int: absi, \
                        double: absd)(x)
int absi(int a) {
  return a > 0 ? a : -a;
}
double absd(double a) {
  return a > 0 ? a : -a;
}
int main(int argc, char** argv) {
  printf("abs(-2): %d\n", abs(-2));
  printf("abs(2.5): %f\n", abs(2.5));;
  return 0;
}

代码框 12-6:通用宏示例

如您在宏定义中所见,我们根据参数x的类型使用了不同的表达式。如果它是整数值,我们使用absi;如果是双精度值,我们使用absd。这个特性对 C11 来说并不新鲜,您可以在较老的 C 编译器中找到它,但它不是 C 标准的一部分。截至 C11,它现在是标准的,您可以使用这种语法来编写类型感知宏。

Unicode

C11 标准中添加的最伟大的特性之一是通过 UTF-8、UTF-16 和 UTF-32 编码支持 Unicode。C 长期以来缺少这个特性,C 程序员必须使用第三方库,如IBM 国际组件 UnicodeICU),来满足他们的需求。

在 C11 之前,我们只有charunsigned char类型,它们是 8 位变量,用于存储 ASCII 和扩展 ASCII 字符。通过创建这些 ASCII 字符的数组,我们可以创建 ASCII 字符串。

注意:

ASCII 标准有 128 个字符,可以用 7 位存储。扩展 ASCII 是 ASCII 的扩展,增加了另外 128 个字符,使总数达到 256 个。然后,一个 8 位或单字节变量足以存储所有这些字符。在即将到来的文本中,我们只会使用术语 ASCII,并且通过这个术语我们指的是 ASCII 标准和扩展 ASCII。

注意,对 ASCII 字符和字符串的支持是基本的,并且永远不会从 C 中移除。因此,我们可以确信我们将在 C 中始终拥有 ASCII 支持。从 C11 开始,它们添加了对新字符的支持,因此产生了使用不同字节数的新字符串,而不仅仅是每个字符一个字节。

为了进一步解释,在 ASCII 编码中,每个字符占用一个字节。因此,字节和字符可以互换使用,但这种情况并不普遍。不同的编码定义了在多个字节中存储更广泛字符的新方法。

在 ASCII 编码中,总共有 256 个字符。因此,一个单字节(8 位)的字符就足以存储所有这些字符。然而,如果我们需要超过 256 个字符,我们必须使用超过一个字节来存储超过 255 的数值。需要超过一个字节来存储其值的字符通常被称为宽字符。根据这个定义,ASCII 字符不被认为是宽字符。

Unicode 标准引入了多种使用超过一个字节来编码 ASCII、扩展 ASCII 和宽字符的方法。这些方法被称为编码。通过 Unicode,有三种著名的编码:UTF-8、UTF-16 和 UTF-32。UTF-8 使用第一个字节来存储 ASCII 字符的前半部分,接下来的字节,通常最多 4 个字节,用于存储 ASCII 字符的后半部分以及所有其他宽字符。因此,UTF-8 被认为是一种可变长度的编码。它使用字符的第一个字节中的某些位来表示应该读取多少实际字节才能完全检索字符。UTF-8 被认为是一个 ASCII 的超集,因为对于 ASCII 字符(不是扩展 ASCII 字符)的表示是相同的。

与 UTF-8 类似,UTF-16 使用一个或两个(每个字内部有 16 位)来存储所有字符;因此,它也是一种可变长度的编码。UTF-32 使用恰好 4 字节来存储所有字符的值;因此,它是一种固定长度的编码。UTF-8 和 UTF-16 适用于需要为更频繁出现的字符使用更少字节的程序。

UTF-32 即使在 ASCII 字符上也使用固定数量的字节。因此,与使用其他编码相比,使用 UTF-32 编码存储字符串会消耗更多的内存空间;但使用 UTF-32 字符时所需的计算能力更少。UTF-8 和 UTF-16 可以被认为是压缩编码,但它们需要更多的计算来返回字符的实际值。

注意

更多关于 UTF-8、UTF-16 和 UTF-32 字符串及其解码方式的信息可以在维基百科或其他来源找到,例如:

unicodebook.readthedocs.io/unicode_encodings.html

javarevisited.blogspot.com/2015/02/difference-between-utf-8-utf-16-and-utf.html.

在 C11 中,我们支持所有上述 Unicode 编码。查看以下示例,example 12.3。它定义了各种 ASCII、UTF-8、UTF-16 和 UTF-32 字符串,并计算存储它们的实际字节数和观察到的字符数。我们分多步展示代码,以便对代码进行额外的注释。以下代码框演示了所需的包含和声明:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#ifdef __APPLE__
#include <stdint.h>
typedef uint16_t char16_t;
typedef uint32_t char32_t;
#else
#include <uchar.h> // Needed for char16_t and char32_t
#endif

Code Box 12-7 [ExtremeC_examples_chapter12_3.c]:示例 12.3 所需的包含和声明

前面的行是 example 12.3include 语句。如您所见,在 macOS 上我们没有 uchar.h 头文件,我们必须为 char16_tchar32_t 类型定义新类型。尽管如此,Unicode 字符串的整个功能都得到了支持。在 Linux 上,我们没有 C11 中 Unicode 支持的问题。

代码的下一部分演示了用于计算各种类型 Unicode 字符串的字节数和字符数的函数。请注意,C11 没有提供用于操作 Unicode 字符串的实用函数,因此我们必须为它们编写新的 strlen。实际上,我们的 strlen 函数不仅返回字符数,还返回消耗的字节数。实现细节将不会描述,但强烈建议您阅读它们:

typedef struct {
  long num_chars;
  long num_bytes;
} unicode_len_t;
unicode_len_t strlen_ascii(char* str) {
  unicode_len_t res;
  res.num_chars = 0;
  res.num_bytes = 0;
  if (!str) {
    return res;
  }
  res.num_chars = strlen(str) + 1;
  res.num_bytes = strlen(str) + 1;
  return res;
}
unicode_len_t strlen_u8(char* str) {
  unicode_len_t res;
  res.num_chars = 0;
  res.num_bytes = 0;
  if (!str) {
    return res;
  }
  // Last null character
  res.num_chars = 1;
  res.num_bytes = 1;
  while (*str) {
    if ((*str | 0x7f) == 0x7f) { // 0x7f = 0b01111111
      res.num_chars++;
      res.num_bytes++;
      str++;
    } else if ((*str & 0xc0) == 0xc0) { // 0xc0 = 0b11000000
      res.num_chars++;
      res.num_bytes += 2;
      str += 2;
    } else if ((*str & 0xe0) == 0xe0) { // 0xe0 = 0b11100000
      res.num_chars++;
      res.num_bytes += 3;
      str += 3;
    } else if ((*str & 0xf0) == 0xf0) { // 0xf0 = 0b11110000
      res.num_chars++;
      res.num_bytes += 4;
      str += 4;
    } else {
      fprintf(stderr, "UTF-8 string is not valid!\n");
      exit(1);
    }
  }
  return res;
}
unicode_len_t strlen_u16(char16_t* str) {
  unicode_len_t res;
  res.num_chars = 0;
  res.num_bytes = 0;
  if (!str) {
    return res;
  }
  // Last null character
  res.num_chars = 1;
  res.num_bytes = 2;
  while (*str) {
    if (*str < 0xdc00 || *str > 0xdfff) {
      res.num_chars++;
      res.num_bytes += 2;
      str++;
    } else {
      res.num_chars++;
      res.num_bytes += 4;
      str += 2;
    }
  }
  return res;
}
unicode_len_t strlen_u32(char32_t* str) {
  unicode_len_t res;
  res.num_chars = 0;
  res.num_bytes = 0;
  if (!str) {
    return res;
  }
  // Last null character
  res.num_chars = 1;
  res.num_bytes = 4;
  while (*str) {
      res.num_chars++;
      res.num_bytes += 4;
      str++;
  }
  return res;
}

Code Box 12-8 [ExtremeC_examples_chapter12_3.c]:示例 12.3 中使用的函数的定义

最后的部分是 main 函数。它声明了一些英文、波斯语和一些外星语言的字符串,以评估前面的函数:

int main(int argc, char** argv) {
  char ascii_string[32] = "Hello World!";
  char utf8_string[32] = u8"Hello World!";
  char utf8_string_2[32] = u8"درود دنیا!";
  char16_t utf16_string[32] = u"Hello World!";
  char16_t utf16_string_2[32] = u"درود دنیا!";
  char16_t utf16_string_3[32] = u"হহহ!";
  char32_t utf32_string[32] = U"Hello World!";
  char32_t utf32_string_2[32] = U"درود دنیا!";
  char32_t utf32_string_3[32] = U"হহহ!";
  unicode_len_t len = strlen_ascii(ascii_string);
  printf("Length of ASCII string:\t\t\t %ld chars, %ld bytes\n\n",
      len.num_chars, len.num_bytes);
  len = strlen_u8(utf8_string);
  printf("Length of UTF-8 English string:\t\t %ld chars, %ld bytes\n",
      len.num_chars, len.num_bytes);
  len = strlen_u16(utf16_string);
  printf("Length of UTF-16 english string:\t %ld chars, %ld bytes\n",
      len.num_chars, len.num_bytes);
  len = strlen_u32(utf32_string);
  printf("Length of UTF-32 english string:\t %ld chars, %ld bytes\n\n",
      len.num_chars, len.num_bytes);
  len = strlen_u8(utf8_string_2);
  printf("Length of UTF-8 Persian string:\t\t %ld chars, %ld bytes\n",
      len.num_chars, len.num_bytes);
  len = strlen_u16(utf16_string_2);
  printf("Length of UTF-16 persian string:\t %ld chars, %ld bytes\n",
      len.num_chars, len.num_bytes);
  len = strlen_u32(utf32_string_2);
  printf("Length of UTF-32 persian string:\t %ld chars, %ld bytes\n\n",
      len.num_chars, len.num_bytes);
  len = strlen_u16(utf16_string_3);
  printf("Length of UTF-16 alien string:\t\t %ld chars, %ld bytes\n",
      len.num_chars, len.num_bytes);
  len = strlen_u32(utf32_string_3);
  printf("Length of UTF-32 alien string:\t\t %ld chars, %ld bytes\n",
      len.num_chars, len.num_bytes);
  return 0;
}

Code Box 12-9 [ExtremeC_examples_chapter12_3.c]:示例 12.3 的主函数

现在,我们必须编译前面的示例。请注意,该示例只能使用 C11 编译器进行编译。您可以尝试使用较旧的编译器并查看产生的错误。以下命令编译并运行前面的程序:

$ gcc ExtremeC_examples_chapter12_3.c -std=c11 -o ex12_3.out
$ ./ex12_3.out
Length of ASCII string:            13 chars, 13 bytes
Length of UTF-8 english string:      13 chars, 13 bytes
Length of UTF-16 english string:     13 chars, 26 bytes
Length of UTF-32 english string:     13 chars, 52 bytes
Length of UTF-8 persian string:      11 chars, 19 bytes
Length of UTF-16 persian string:     11 chars, 22 bytes
Length of UTF-32 persian string:     11 chars, 44 bytes
Length of UTF-16 alien string:       5 chars, 14 bytes
Length of UTF-32 alien string:       5 chars, 20 bytes
$

Shell Box 12-3:编译和运行示例 12.3

如您所见,具有相同字符数的相同字符串使用不同数量的字节来编码和存储相同的值。UTF-8 使用最少的字节,尤其是在文本中有大量 ASCII 字符时,因为大多数字符将仅使用一个字节。

当我们遇到与拉丁字符更不同的字符时,例如亚洲语言的字符,UTF-16 在字符数和使用的字节数之间有更好的平衡,因为大多数字符将使用最多两个字节。

UTF-32 很少使用,但它可以用于需要固定长度 代码打印 的字符的系统;例如,如果系统计算能力较低或受益于某些并行处理管道。因此,UTF-32 字符可以用作从字符到任何类型数据的映射中的键。换句话说,它们可以用来构建一些索引以快速查找数据。

匿名结构和匿名联合

匿名结构和匿名联合是没有名称的类型定义,通常用作其他类型的嵌套类型。用示例更容易解释它们。在这里,你可以看到一个类型,它在一个地方同时具有匿名结构和匿名联合,显示在代码框 12-10 中:

typedef struct {
  union {
    struct {
      int x;
      int y;
    };
    int data[2];
  };
} point_t;

代码框 12-10:匿名结构和匿名联合的示例

前面的类型使用相同的内存来存储匿名结构和字节数组字段 data。以下代码框显示了它如何在实际示例中使用:

#include <stdio.h>
typedef struct {
  union {
    struct {
      int x;
      int y;
    };
    int data[2];
  };
} point_t;
int main(int argc, char** argv) {
  point_t p;
  p.x = 10;
  p.data[1] = -5;
  printf("Point (%d, %d) using an anonymous structure inside an anonymous union.\n", p.x, p.y);
  printf("Point (%d, %d) using byte array inside an anonymous union.\n",
      p.data[0], p.data[1]);
  return 0;
}

代码框 12-11 [ExtremeC_examples_chapter12_4.c]:使用匿名结构和匿名联合的主函数

在此示例中,我们创建了一个包含匿名结构的匿名联合。因此,相同的内存区域用于存储匿名结构的一个实例和两个元素的整数数组。接下来,你可以看到前面程序的输出:

$ gcc ExtremeC_examples_chapter12_4.c -std=c11 -o ex12_4.out
$ ./ex12_4.out
Point (10, -5) using anonymous structure.
Point (10, -5) using anonymous byte array.
$

脚本框 12-4:编译和运行示例 12.4

如你所见,对两个元素的整数数组的任何更改都可以在结构变量中看到,反之亦然。

多线程

C 语言通过 POSIX 线程函数或 pthreads 库长期以来一直支持多线程。我们在第十五章“线程执行”和第十六章“线程同步”中全面介绍了多线程。

如其名所示,POSIX 线程库仅在符合 POSIX 的系统(如 Linux 和其他类 Unix 系统)中可用。因此,如果你使用的是非 POSIX 兼容的操作系统,如 Microsoft Windows,你必须使用操作系统提供的库。作为 C11 的一部分,提供了一个标准线程库,可以在所有使用标准 C 的系统上使用,无论其是否符合 POSIX。这是我们在 C11 标准中看到的最大变化。

不幸的是,C11 线程在 Linux 和 macOS 上没有实现。因此,我们无法在撰写本文时提供工作示例。

关于 C18 的一些信息

如我们前面提到的,C18 标准包含了 C11 中所做的所有修复,并且没有在它中引入任何新功能。正如之前所说,以下链接带你到一个页面,你可以看到为 C11 创建并跟踪的问题以及围绕它们进行的讨论:http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2244.htm。

概述

在本章中,我们回顾了 C11、C18 以及最新的 C 标准,并探讨了 C11 的各种新特性。Unicode 支持、匿名结构和联合体,以及新的标准线程库(尽管到目前为止它尚未在最近的编译器和平台上可用)是现代 C 语言中引入的最重要特性之一。我们期待着未来看到 C 标准的新的版本。

在下一章中,我们将开始讨论并发以及并发系统的理论。这将开启一段长达六章的漫长旅程,我们将涵盖多线程和多进程,以实现我们编写并发系统的目的。

第十三章

并发

在接下来的两章中,我们将讨论并发以及开发并发程序所需的理论背景,这不仅适用于 C 语言,也必然适用于其他语言。因此,这两章将不包含任何 C 代码,而是使用伪代码来表示并发系统和它们的内在属性。

由于并发主题的长度,它已经被分为两个章节。在本章中,我们将探讨关于并发的核心基本概念,然后转向第十四章,同步,我们将讨论与并发相关的问题以及并发程序中用于解决这些问题的同步机制。这两个章节的总体目标是为你提供足够的理论知识,以便在后续章节中继续讨论多线程和多进程主题。

本章建立的知识背景在处理我们全书使用的POSIX 线程库时也将非常有用。

在本章关于并发的第一部分,我们将致力于理解:

  • 并行系统与并发系统的区别

  • 当我们需要并发时

  • 任务调度器是什么,以及广泛使用的调度算法有哪些

  • 并发程序是如何运行的以及什么是交错

  • 共享状态是什么以及各种任务如何访问它

让我们从对并发概念的介绍开始,广泛地了解它对我们意味着什么。

介绍并发

并发简单来说就是程序中有多个逻辑部分同时执行。现代软件系统通常是并发的,因为程序需要同时运行多个逻辑部分。因此,并发是今天每个程序都在一定程度上使用的。

我们可以说,并发是一种强大的工具,它允许你编写能够同时管理不同任务的程序,并且对它的支持通常位于内核中,这是操作系统的核心。

有许多例子表明,一个普通程序可以同时管理多个任务。例如,你可以在下载文件的同时上网冲浪。在这种情况下,任务是在浏览器进程的上下文中并发执行的。另一个值得注意的例子是在视频流场景中,比如你在 YouTube 上观看视频时。视频播放器可能正在下载视频的后续片段,而你仍在观看之前下载的片段。

即使是简单的文字处理软件也有几个并发任务在后台运行。当我在这本 Microsoft Word 上写这一章时,拼写检查器和格式化器正在后台运行。如果你在 iPad 上的 Kindle 应用程序上阅读这本书,你认为作为 Kindle 程序的一部分,可能正在并发运行哪些程序?

同时运行多个程序听起来很神奇,但就像大多数技术一样,并发除了带来好处外,还会带来一些头痛的问题。确实,并发给计算机科学历史带来了最痛苦的头痛问题!这些“头痛”问题,我们将在后面讨论,它们可能长时间隐藏,甚至在发布后数月,而且通常很难找到、重现和解决。

我们在本节开始时将并发描述为同时执行任务,或者说是并发执行。这种描述意味着任务是在并行运行,但这并不完全正确。这样的描述过于简单,也不准确,因为并发不同于并行,我们还没有解释这两者之间的区别。两个并发程序与两个并行程序不同,我们本章的一个目标就是阐明这些区别,并给出该领域官方文献中使用的某些定义。

在接下来的章节中,我们将解释一些基本的并发相关概念,例如任务调度交错状态共享状态,这些是在这本书中你将经常遇到的术语。值得注意的是,这些概念大多是抽象的,可以应用于任何并发系统,而不仅仅是 C 语言。

为了理解并行和并发之间的区别,我们将简要介绍并行系统。

注意,在本章中,我们坚持简单的定义。我们的唯一目的是给你一个并发系统如何工作的基本概念,因为超出这个范围就不在本书的 C 语言范畴之内了。

并行

并行简单来说就是同时运行两个任务,或者说是并行运行。短语“并行运行”是区分并行和并发的关键元素。为什么是这样呢?因为并行意味着两件事情同时发生。在并发系统中并不是这样;在并发系统中,你需要暂停一个任务以便让另一个任务继续执行。请注意,这个定义可能过于简单且不完整,特别是在现代并发系统中,但它足以让我们对基本概念有一个基本的了解。

我们在日常生活中经常遇到并行。当你和你的朋友同时进行两个不同的任务时,这些任务就是在并行进行。为了使多个任务并行,我们需要独立的、隔离的处理单元,每个单元被分配给特定的任务。例如,在计算机系统中,每个CPU 核心都是一个处理单元,一次可以处理一个任务。

暂时将你自己视为这本书的唯一读者。你不能并行阅读两本书;你不得不暂停阅读其中一本以便阅读另一本。然而,如果你让你的朋友加入进来,那么两本书就可以并行阅读了。

如果你有第三本书需要阅读会发生什么?既然你们两个人都不能同时阅读两本书,那么其中一个人在阅读自己的书时就需要暂停,以便继续阅读第三本书。这仅仅意味着你们中的任何一个人或者你的朋友都需要合理分配时间,以便阅读这三本书。

在计算机系统中,必须至少有两个独立且分离的处理单元,才能在该系统上执行两个并行任务。现代 CPU 内部有多个核心,这些核心是实际的处理单元。例如,一个 4 核心 CPU 有 4 个处理单元,因此可以同时支持 4 个并行任务运行。为了简化,在本章中,我们将假设我们的想象中的 CPU 内部只有一个核心,因此不能执行并行任务。在相关章节中,我们将在稍后讨论多核 CPU。

假设你得到两台装有我们想象中的 CPU 的笔记本电脑,一台播放音乐,另一台求解微分方程。它们都在并行工作,但如果你想在同一台笔记本电脑上使用单个 CPU 和单个核心同时完成这两项任务,那么这不可能是并行的,实际上它是并发的。

并行化是指可以并行化的任务。这意味着实际的算法可以被分割并在多个处理器单元上运行。但截至目前,我们编写的算法大多数都是顺序的,而不是并行的。即使在多线程中,每个线程也有一定数量的顺序指令,这些指令不能被分解成一些并行的执行流程

换句话说,顺序算法不能被操作系统自动轻易地分解成一些并行执行流程,这需要程序员来完成。因此,尽管拥有多核 CPU,你仍然需要将每个执行流程分配给特定的 CPU 核心,并且在该核心中,如果你分配了多个流程,你不能让它们同时并行运行,你将立即观察到并发行为。

简而言之,当然,将两个流程分别分配给不同的核心,可以最终实现两个并行流程,但将它们分配给同一个核心,就会导致两个并发流程。在多核 CPU 中,我们实际上观察到的是一种混合行为,既有核心间的并行性,也有同一核心上的并发性。

尽管并行化具有简单的意义和无数的日常例子,但在计算机体系结构中,它是一个复杂且困难的话题。实际上,它是一个与并发性分开的独立学术领域,拥有自己的理论、书籍和文献。能够拥有一个可以将顺序算法分解成一些并行执行流程的操作系统是一个开放的研究领域,而当前的操作系统无法做到这一点。

正如所述,本章的目的不是深入探讨并行性,而是只为这个概念提供一个初步的定义。由于关于并行性的进一步讨论超出了本书的范围,让我们从并发概念开始。

首先,我们将讨论并发系统以及它与并行性的真正含义。

并发

你可能听说过多任务处理——好吧,并发有同样的理念。如果你的系统正在同时管理多个任务,你需要理解这并不一定意味着任务正在并行运行。相反,中间可能有一个任务调度器;它只是非常快速地在不同的任务之间切换,并在相对较短的时间内执行每个任务的一小部分。

当你只有一个处理器单元时,这当然会发生。在本节接下来的讨论中,我们假设我们正在仅对一个处理器单元进行操作。

如果任务调度器足够公平,你不会注意到任务之间的切换,它们在你看来就像是并行运行的。这就是并发的魔法,也是它被广泛应用于大多数知名操作系统(包括 Linux、macOS 和 Microsoft Windows)中的根本原因。

并发可以看作是使用单个处理器单元模拟并行执行任务。实际上,整个想法可以被称为一种形式的人工并行性。对于只有单个 CPU、只有一个核心的旧系统来说,人们能够以多任务的方式使用那个单一核心是一项巨大的进步。

作为旁注,Multics 是最早设计为多任务处理和同时管理进程的操作系统之一。你可能会记得,在第十章Unix – 历史 和 架构中,Unix 是基于从 Multics 项目中获得的想法构建的。

正如我们之前解释的,几乎所有的操作系统都可以通过多任务执行并发任务,尤其是符合 POSIX 标准的操作系统,因为这种能力在 POSIX 标准中得到了明确的体现。

任务调度器单元

正如我们之前所说的,所有多任务操作系统都需要在其内核中有一个任务调度器单元,或者简单地称为调度器单元。在本节中,我们将看到这个单元是如何工作的,以及它是如何有助于一些并发任务的无缝执行的。

关于任务调度器单元的一些事实如下:

  • 调度器有一个用于等待执行的任务队列任务作业仅仅是应该在不同执行流中执行的工作片段。

  • 这个队列通常是优先级排序的,高优先级任务会被优先选择开始执行。

  • 处理器单元由任务调度器管理和共享。当处理器单元空闲(没有任务在使用它)时,任务调度器必须在让任务使用处理器单元之前,从其队列中选择另一个任务。当任务完成时,它释放处理器单元并使其再次可用,然后任务调度器选择另一个任务。这个过程持续进行。这被称为任务调度,这是任务调度器唯一的责任。

  • 有许多调度算法供任务调度器操作,但它们都应该满足特定的要求。例如,所有算法都应该公平,并且没有任何任务因为长时间未被选中而在队列中饥饿

  • 根据选择的调度策略,调度器应该为任务分配特定的时间片时间量子以使用处理器单元,或者调度器必须等待任务释放处理器单元。

  • 如果调度策略是抢占式的,调度器应该能够强制从正在运行的任务中收回 CPU 核心,以便将其分配给下一个任务。这被称为抢占式调度。还有一种方案是任务自愿释放 CPU,这被称为协作式调度

  • 抢占式调度算法试图在不同任务之间公平地平均分配时间片。优先级较高的任务可能会被更频繁地选中,或者根据调度器的实现,它们甚至可能获得更长的时间片。

任务是一个通用的抽象概念,用来指代在并发系统中应该完成的工作,不一定是计算机系统中的工作。我们很快就会看看这些非计算机系统究竟是什么。同样,CPU 也不是唯一可以共享给任务的资源。人类在存在以来就一直对任务进行调度和优先级排序,当我们面临无法同时完成的工作时。在接下来的几段中,我们将考虑这种情况作为理解调度的良好例子。

假设我们处于 20 世纪初,街上只有一个电话亭,有 10 个人在等待使用电话。在这种情况下,这 10 个人应该遵循一种调度算法,以便在他们之间公平地共享电话亭。

首先,他们需要排队。在这种情况下,文明心智做出的最基本决定就是排队并等待你的轮次。然而,这还不够;我们还需要一些规则来支持这种方法。目前正在使用电话的第一个人,在还有九个人在等待隔间时,不能像他们可能希望的那样说很多话。第一个人必须在一段时间后离开隔间,以便让队列中的下一个人有机会。

在极少数情况下,如果他们还没有结束对话,第一个人应该在一段时间后停止使用电话,离开隔间,并回到队伍的末尾。然后他们必须等待他们的下一轮,以便继续他们的谈话。这样,10 个人中的每一个人都需要继续进入隔间,直到他们完成他们的对话。

这只是一个例子。我们每天都会遇到多个消费者之间共享资源的例子,人类已经发明了许多方法来在这些资源之间公平地共享——直到人类本性允许的程度!在下一节中,我们将回到考虑计算机系统中的调度。

进程和线程

在整本书中,我们主要对计算机系统中的任务调度感兴趣。在操作系统内,任务要么是进程,要么是线程。我们将在接下来的章节中解释它们及其区别,但就目前而言,你应该知道大多数操作系统基本上以相同的方式处理它们:作为需要并发执行的一些任务。

操作系统需要使用任务调度器来在许多任务之间共享 CPU 核心,这些任务无论是进程还是线程,都愿意使用 CPU 来执行它们的任务。当创建一个新的进程或一个新的线程时,它作为新的任务进入调度队列,并在开始运行之前等待获取 CPU 核心。

在存在时间共享抢占式调度器的情况下,如果任务在一段时间内无法完成其逻辑,那么任务调度器将强制收回 CPU 核心,并将任务再次放入队列,就像电话亭场景中一样。

在这种情况下,任务应该在队列中等待,直到再次获得 CPU 核心,然后才能继续运行。如果它无法在第二轮中完成其逻辑,这个过程将继续进行,直到它能够完成。

每次抢占式调度器在运行过程中停止一个进程并将另一个进程放入运行状态时,就会发生一次上下文切换。上下文切换越快,用户就会感觉任务似乎是在并行运行。有趣的是,今天的大多数操作系统都使用抢占式调度器,这是我们本章剩余部分的主要焦点。

从现在开始,所有调度器都被假定为抢占式的。在不适用此情况时,我会进行说明。

当一个任务运行时,它可能经历数百甚至数千次上下文切换,才能完成。然而,上下文切换有一个非常奇特和独特的特性——它们是不可预测的。换句话说,我们无法预测上下文切换何时发生,甚至无法预测在哪个指令上发生。即使在同一平台上两个非常接近的连续程序运行中,上下文切换也会有所不同。

这一点的重要性及其影响不容小觑;上下文切换是不可预测的!简而言之,通过给出的例子,你将亲自观察到这一点带来的后果。

上下文切换高度不可预测,到了这种程度,处理这种不确定性的最佳方式是假设在特定指令上发生上下文切换的概率对所有指令都是相同的。换句话说,你应该预期所有指令在任何给定运行中都可能经历上下文切换。简单来说,这意味着你可能在任何两个相邻指令的执行之间有间隔。

话虽如此,我们现在继续前进,看看在并发环境中唯一确定存在的事情。

发生之前约束

在上一节中,我们确定了上下文切换是不可预测的;在我们程序中,它们可能发生的时间存在不确定性。尽管如此,同时执行的指令是确定的。

让我们继续用一个简单的例子来说明。首先,我们将基于我们有一个类似于你在代码框 13-1中看到的任务,它有五条指令。请注意,这些指令是抽象的,它们不代表任何真实的指令,如 C 或机器指令:

Task P {
    1\. num = 5
    2\. num++
    3\. num = num – 2
    4\. x = 10
    5\. num = num + x
}

代码框 13-1:一个包含 5 条指令的简单任务

如你所见,指令是有序的,这意味着它们必须按照指定的顺序执行,以满足任务的目的是。我们对此是确定的。从技术角度来说,我们说我们在每两个相邻指令之间有一个发生之前约束。指令 num++ 必须在 num = num - 2 之前发生,并且这个约束必须得到满足,无论上下文切换如何发生。

注意,我们仍然对上下文切换何时发生存在不确定性;记住这一点很重要,它们可以在指令之间任何地方发生。

在这里,我们将展示前述任务两种可能的执行方式,具有不同的上下文切换:

Run 1:
  1\. num = 5
  2\. num++
>>>>> Context Switch <<<<<
  3\. num = num – 2
  4\. x = 10
>>>>> Context Switch <<<<<
  5\. num = num + x

代码框 13-2:上述任务与上下文切换的可能运行之一

对于第二次运行,它执行如下:

Run 2:
  num = 5
  >> Context Switch <<
  num++
  num = num – 2
  >> Context Switch <<
  x = 10
  >> Context Switch <<
  num = num + x

代码框 13-3:另一个与上下文切换一起的可能运行

正如你在代码框 13-2中可以看到的,上下文切换的次数和它们发生的位置在每个运行中都可以改变。然而,正如我们之前所说的,有一些应该遵循的发生之前约束。

这就是为什么我们可以对特定任务有一个总体确定性行为的原因。无论上下文切换在不同运行中如何发生,任务的总体状态都保持不变。当我们说任务的总体状态时,我们指的是在任务中最后一条指令执行后,变量及其对应值的集合。例如,对于前面的任务,我们总是有最终状态,包括num变量值为14,以及变量x的值为10,无论上下文切换如何。

通过知道单个任务的总体状态在不同运行中不会改变,我们可能会倾向于得出结论,由于必须遵循执行顺序和发生之前约束,并发性不会影响任务的总体状态。然而,我们对此结论应持谨慎态度。

假设我们有一个并发任务系统,所有任务都有对共享资源的读写权限,比如一个变量。如果所有任务只读取共享变量,而没有任务将要写入它(改变其值),我们可以说,无论上下文切换如何发生,无论你运行任务多少次,我们总是得到相同的结果。请注意,这同样适用于没有共享变量的并发任务系统。

然而,如果只有一个任务将要写入共享变量,那么由任务调度器单元强加的上下文切换将影响所有任务的总体状态。这意味着它可能从一个运行到另一个运行而不同!因此,应该采用适当的控制机制来避免任何不希望的结果。这一切都是由于上下文切换无法预测,并且任务的中间状态可能从一个运行到另一个运行而变化。与总体状态相对,中间状态是在某个指令下变量及其值的集合。每个任务只有一个总体状态,这是在任务完成时确定的,但它有多个中间状态,对应于执行某个指令后的变量及其值。

总结来说,当你有一个包含多个任务且这些任务可以写入共享资源的并发系统时,系统的不同运行将产生不同的结果。因此,应该使用适当的同步方法来取消上下文切换的影响,并在各种运行中获得相同的确定性行为。

我们现在有一些并发的基本概念,这是本章的主题。本节中解释的概念对于理解许多主题是基本的,你将在本书未来的章节中反复听到它们。

你会记得我们也说过并发可能会出现问题,进而,它可能会使事情对我们来说更加复杂。所以,你可能想知道,我们什么时候需要它?在本章的下一节中,我们将回答这个问题。

何时使用并发

根据我们到目前为止的解释,似乎只有一个任务比多个任务同时做同一件事的问题要小得多。这是完全正确的;如果你能编写一个不需要引入并发就能正常运行得不错的程序,那么强烈建议你这样做。我们可以使用一些通用模式来了解何时必须使用并发。

在本节中,我们将探讨这些通用模式是什么,以及它们是如何引导我们将程序拆分为并发流程的。

不论使用哪种编程语言,程序本质上是一组应该按顺序执行的指令。换句话说,给定的指令不会执行,直到前面的指令已经执行。我们称这个概念为顺序执行。当前指令完成所需的时间有多长并不重要;下一条指令必须等待当前一条指令完成。通常说,当前指令正在阻塞下一条指令;这有时被描述为当前指令是一个阻塞指令

在每个程序中,所有的指令都是阻塞的,每个执行流程的执行流程都是顺序的。我们只能说程序运行得快,如果每个指令在几个毫秒内阻塞下一条指令的时间相对较短。然而,如果阻塞指令花费了太多时间(例如 2 秒或 2000 毫秒),或者它所需的时间无法确定,会发生什么?这两个模式告诉我们我们需要一个并发程序。

为了进一步阐述,每个阻塞指令在尝试完成时会消耗一定的时间。对我们来说,最佳情况是,给定的指令完成所需的时间相对较短,然后,下一条指令可以立即执行。然而,我们并不总是这么幸运。

有一些场景,我们无法确定阻塞指令完成所需的时间。这通常发生在阻塞指令正在等待某个事件发生,或者某些数据变得可用时。

让我们用一个例子继续。假设我们有一个服务器程序,它正在为多个客户端程序提供服务。服务器程序中有一个指令等待客户端程序连接。从服务器程序的角度来看,没有人能确切地说何时会有新的客户端连接。因此,下一条指令不能在服务器端执行,因为我们不知道何时才能完成当前的指令。这完全取决于新客户端尝试连接的时间。

一个更简单的例子是当你从用户那里读取一个字符串时。从程序的角度来看,没有人能确定用户何时会输入他们的输入;因此,未来的指令无法执行。这是导致并发任务系统的第一个模式

并发的第一个模式是当你有一个可以无限期阻塞执行流程的指令。在这种情况下,你应该将现有的流程分成两个独立的流程或任务。如果你需要执行后续指令,而又不能等待当前指令首先完成,你会这样做。对于这个场景来说,更重要的是,我们假设后续指令不依赖于当前指令完成的结果。

通过将前面的流程分成两个并发任务,当其中一个任务正在等待阻塞指令完成时,另一个任务可以继续执行先前非并发设置中阻塞的指令。

在本节中,我们将关注的下一个例子将展示第一个模式如何导致并发任务系统。我们将使用伪代码来表示每个任务中的指令。

注意

理解即将到来的例子不需要任何计算机网络知识。

我们将要关注的例子是关于一个具有三个目标的服务器程序:

  • 它计算从客户端读取的两个数字的和,并将结果返回给客户端。

  • 它定期将已服务的客户端数量写入文件,无论是否正在服务任何客户端。

  • 它还必须能够同时服务多个客户端。

在讨论满足上述目标的最终并发系统之前,让我们首先假设在这个例子中我们只使用一个任务(或流程),然后我们将展示单个任务无法完成上述目标。你可以看到服务器程序的伪代码,在单任务设置中,在代码框 13-4中:

Calculator Server {
    Task T1 {
        1\. N = 0
        2\. Prepare Server
        3\. Do Forever {
        4\.     Wait for a client C
        5\.     N = N + 1
        6\.     Read the first number from C and store it in X
        7\.     Read the second number from C and store it in Y
        8\.     Z = X + Y
        9\.     Write Z to C
       10\.     Close the connection to C
       11\.     Write N to file
           }
    }
}

代码框 13-4:使用单个任务操作的服务器程序

正如你所见,我们的单个流程等待网络上的客户端连接。然后从客户端读取两个数字,然后计算它们的和并将其返回给客户端。最后,它关闭客户端连接,在继续等待下一个客户端加入之前将已服务的客户端数量写入文件。很快,我们将展示前面的代码无法满足我们上述的目标。

这个伪代码只包含一个任务,T1。它有 12 条指令,正如我们之前所说的,它们是顺序执行的,并且所有指令都是阻塞的。那么,这段代码究竟向我们展示了什么呢?让我们一步步来看:

  • 第一条指令,N = 0,很简单,并且完成得很快。

  • 第二条指令,准备服务器,预计会在合理的时间内完成,这样就不会阻塞服务器程序的执行。

  • 第三条指令只是启动主循环,并且随着我们进入循环,它应该很快完成。

  • 第四条指令,等待客户 C,是一个具有未知完成时间的阻塞指令。因此,指令56以及其余的指令将不会执行。因此,它们似乎必须等待新客户加入,只有在此之后,这些指令才能执行。

正如我们之前所说的,指令510等待新客户是必须的。换句话说,这些指令依赖于指令4的输出,并且在没有接受客户的情况下不能执行。然而,指令11将 N 写入文件,需要执行,无论是否有客户。这是由我们为这个例子定义的第二项目标所决定的。根据前面的配置,我们只有在有客户的情况下才将N写入文件,尽管这与我们的初始要求相矛盾,即,无论是否有客户,我们都将N写入文件。

前面的代码在其指令流程中还有一个问题;指令67都有可能阻塞执行流程。这些指令等待客户输入两个数字,由于这取决于客户,我们无法准确预测这些指令何时会完成。这阻止了程序继续执行。

不仅如此,这些指令可能会阻止程序接受新的客户。这是因为如果指令67需要很长时间才能完成,执行流程将不会再次达到指令4。因此,服务器程序不能同时服务多个客户,这再次不符合我们定义的目标。

为了解决上述问题,我们需要将单个任务分解为三个并发任务,这三个任务共同满足我们对服务器程序的要求。

代码框 13-5中的后续伪代码中,您将找到三个执行流程,T1T2T3,它们基于并发解决方案满足我们定义的目标:

Calculator Server {
    Shared Variable: N
    Task T1 {
        1\. N = 0
        2\. Prepare Server
        3\. Spawn task T2
        4\. Do Forever {
        5\.     Write N to file
        6\.     Wait for 30 seconds
           }
    }
    Task T2 {
        1\. Do Forever {
        2\.     Wait for a client C
        3\.     N = N + 1
        4\.     Spawn task T3 for C
           }
    }
    Task T3 {
        1\. Read first number from C and store it in X
        2\. Read first number from C and store it in Y
        3\. Z = X + Y
        4\. Write Z to C
        5\. Close the connection to C
    }
}

代码框 13-5:使用三个并发任务运行的服务器程序

程序首先执行任务T1T1被称为程序的主要任务,因为它将是第一个要执行的任务。请注意,每个程序至少有一个任务,并且所有其他任务都是由这个任务直接或间接启动的。

在前面的代码框中,我们还有两个由主任务T1派生的其他任务。还有一个共享变量N,它存储已服务的客户数量,并且可以被所有任务访问(读取或写入)。

程序从任务 T1 的第一条指令开始;通过这条指令,它将变量 N 初始化为零。然后第二条指令准备服务器。作为这条指令的一部分,应该采取一些初步步骤,以便服务器程序能够接受传入的连接。请注意,到目前为止,还没有其他并发任务在任务 T1 旁边运行。

任务 T1 中的第三条指令创建了一个新的 实例,用于任务 T2。创建新任务通常很快,且不耗时。因此,任务 T1 在创建任务 T2 后立即进入无限循环,每 30 秒将共享变量 N 的值写入文件。这是我们为服务器程序定义的第一个目标,现在已经实现。基于此,在没有其他指令的干扰或阻塞的情况下,任务 T1 会定期将 N 的值写入文件,直到程序完成。

让我们谈谈派生的任务。任务 T2 的唯一责任是当客户端发送连接请求时立即接受它们。也值得记住的是,任务 T2 中的所有指令都在一个无限循环中运行。任务 T2 中的第二条指令等待新的客户端。在这里,它阻止了任务 T2 中其他指令的执行,但这仅适用于任务 T2 中的指令。请注意,如果我们派生了两个 T2 实例而不是一个,其中一个实例中的指令被阻塞不会阻止另一个实例中的指令。

其他并发任务,在这种情况下只有 T1,会继续执行它们的指令而没有任何阻塞。这正是并发所实现的;当一些任务因为某个事件而阻塞时,其他任务可以继续它们的工作而不会受到任何干扰。正如我们之前所说的,这有一个重要的设计原则作为其核心:每当遇到一个阻塞操作,要么其完成时间未知,要么完成时间很长,那么你应该将任务分成两个并发任务

现在,假设有一个新的客户端加入。我们已经在 代码框 13-4 中看到,在服务器程序的并发版本中,读取操作可能会阻塞新客户端的接受。基于我们刚才指出的设计原则,由于读取指令是阻塞的,我们需要将逻辑分成两个并发任务,这就是为什么我们引入了任务 T3

每当有新的客户端加入时,任务 T2 会派生一个新的任务 T3 实例,以便与新加入的客户端通信。这是通过任务 T2 中的指令 4 实现的,提醒一下,这是以下命令:

4\.     Spawn task T3 for C

代码框 13-6:任务 T2 中的指令 4

在派生新任务之前,任务 T2 会增加共享变量 N 的值,以表示已为新客户端提供服务。再次强调,派生指令相当快,不会阻塞新客户端的接受。

在任务 T2 中,当指令 4 执行完毕后,循环继续,并回到指令 2,等待另一个客户端加入。请注意,根据我们已有的伪代码,虽然我们只有一个任务 T1 的实例和一个任务 T2 的实例,但我们可以为每个客户端拥有多个 T3 的实例。

任务 T3 的唯一责任是与客户端通信并读取输入数字。然后,它继续计算总和并将结果发送回客户端。正如之前指出的,任务 T3 内部的阻塞指令不能阻止其他任务的执行,其阻塞行为仅限于同一实例的 T3。即使是特定实例的 T3 内的阻塞指令也不能阻止另一个实例的 T3 内的指令。这样,服务器程序可以以并发的方式满足我们所有的期望目标。

那么,下一个问题可能是,任务何时完成?我们知道,通常情况下,当任务内的所有指令都执行完毕后,任务就完成了。但当我们有一个无限循环包裹着任务内的所有指令时,任务就不会完成,其生命周期取决于创建它的父任务。我们将在未来的章节中具体讨论关于进程和线程的内容。为了我们的例子,在我们先前的并发程序中,所有 T3 实例的父任务是唯一的 T2 实例。正如你所看到的,一个特定的 T3 实例完成要么是在通过两个阻塞读取指令后关闭与客户端的连接,要么是唯一的 T2 实例完成。

在一种罕见但可能的情况中,如果所有读取操作完成所需的时间过长(这可能是有意的或意外的),并且进入的客户端数量迅速增加,那么我们可能会有一段时间内运行着过多的 T3 实例,并且它们都在等待客户端提供输入数字。这种情况会导致消耗大量的资源。然后,经过一段时间,由于越来越多的进入连接,服务器程序可能会被操作系统终止,或者它简单地无法再服务任何客户端。

无论前一种情况发生什么,服务器程序都会停止服务客户端。当这种情况发生时,我们称之为拒绝服务DoS)。对于具有并发任务的系统,应该设计成能够克服这些极端情况,从而以合理的方式为客户端提供服务。

注意

当受到 DoS 攻击时,服务器机器上的资源拥塞发生,以使其崩溃并使其无响应。DoS 攻击属于试图阻止某些服务以使其对客户端不可用的网络攻击组。它们包括广泛的攻击,包括漏洞利用,目的是停止服务。这甚至包括为了使网络基础设施崩溃而对网络进行洪水攻击

在服务器程序的先前列举的示例中,我们描述了一种情况,其中我们有一个阻塞指令,其完成时间无法确定,这是并发使用的第一个模式。还有一个与此类似但略有不同的模式。

如果一条指令或一组指令需要太长时间才能完成,那么我们可以将它们放入一个单独的任务中,并使新任务与主任务并发运行。这与第一个模式不同,因为虽然我们确实有一个完成时间的估计,尽管不是非常准确,但我们确实知道它不会很快完成。

关于前面示例中提到的共享变量N的最后一件事需要注意,那就是其中一个任务,特别是任务T2的实例,可能会改变其值。根据我们在本章前面的讨论,因此这个并发任务系统因此容易受到并发问题的困扰,因为它有一个可以被其中一个任务修改的共享变量。

重要的是要注意,我们为服务器程序提出的解决方案远非完美。在下一章中,你将了解到并发问题,通过它你将看到前面的示例在共享变量N上存在严重的数据竞争问题。因此,应该采用适当的控制机制来解决并发产生的问题。

在本章接下来的最后一节中,我们将讨论一些并发任务之间共享的状态。我们还将介绍交错的概念及其对具有可修改共享状态的并发系统的重要影响。

共享状态

在上一节中,我们讨论了表明我们需要一个并发任务系统的模式。在那之前,我们也简要解释了在执行多个并发任务期间,上下文切换模式的不确定性,以及有一个可修改的共享状态,可能会导致所有任务的总体状态中出现非确定性。本节提供了一个示例,以说明这种非确定性在简单程序中可能带来的问题。

在本节中,我们将继续我们的讨论,并引入共享状态,看看它们如何导致我们之前讨论的非确定性。作为一个程序员,术语状态应该让你想到一组变量及其在特定时间的对应值。因此,当我们谈论任务的整体状态时,正如我们在第一部分定义的那样,我们指的是在任务执行最后一条指令的确切时刻,所有现有非共享变量及其对应值的集合。

同样,一个任务的中间状态是所有现有非共享变量及其在任务执行特定指令时的值的集合。因此,一个任务对于其每条指令都有一个不同的中间状态,中间状态的数量等于指令的数量。根据我们的定义,最后一个中间状态与任务的整体状态相同。

共享状态也是一组变量及其在特定时间的对应值,这些变量可以被并发任务系统读取或修改。共享状态不属于任何任务(它不是任务本地的),它可以被系统中的任何任务读取或修改,当然是在任何时间。

通常,我们对只读的共享状态不感兴趣。它们通常可以安全地被许多并发任务读取,并且不会产生任何问题。然而,如果一个共享状态是可修改的,并且没有仔细保护,通常会导致一些严重的问题。因此,本节中涵盖的所有共享状态都被认为是至少可以被一个任务修改的。

自问一下这个问题:如果一个共享状态被系统中的一个并发任务修改,可能会出什么问题?为了回答这个问题,我们首先给出一个例子,即两个并发任务访问单个共享变量的系统,在这种情况下,是一个简单的整数变量。

假设我们有一个如下所示的系统,如代码框 13-7所示:

Concurrent System {
    Shared State {
        X : Integer = 0
    }

    Task P {
        A : Integer
            1\. A = X
            2\. A = A + 1
            3\. X = A
            4\. print X
    }
    Task Q {
        B : Integer
            1\. B = X
            2\. B = B + 2
            3\. X = B
            4\. print X
    }
}

代码框 13-7:具有可修改共享状态的两个并发任务系统

假设在前面的系统中,任务PQ不是并发运行的。因此,它们变成了顺序执行。假设首先执行P中的指令,然后是Q。如果是这样,那么整个系统整体状态,无论任何单个任务的整体状态如何,都将是一个值为 3 的共享变量X

如果你以相反的顺序运行系统,首先执行Q中的指令,然后执行P中的指令,你将得到相同的状态。然而,这通常不是情况,以相反的顺序运行两个不同的任务可能会导致不同的整体状态。

如你所见,按顺序运行这些任务会产生一个确定的结果,无需担心上下文切换。

现在,假设它们在同一个 CPU 核心上并发运行。根据各种指令处的上下文切换,将 PQ 的指令放入执行有许多可能的场景。

以下是一个可能的场景:

     Task P     |    Task Scheduler   |    Task Q    
----------------------------------------------------
                |    Context Switch   |             
                |                     |  B = X
                |                     |  B = B + 2
                |    Context Switch   |
  A = X         |                     |
                |    Context Switch   |
                |                     |  X = B
                |    Context Switch   |
  A = A + 1     |                     |
  X = A         |                     |
                |    Context Switch   |
                |                     |  print X
                |    Context Switch   |
  print X       |                     |
                |    Context Switch   |

代码框 13-8:当并发运行任务 P 和 Q 时另一种可能的交错

这种场景只是许多可能场景中的一种,这些场景涉及在特定位置发生上下文切换。每个场景都称为 交错。因此,对于并发任务系统,根据上下文切换可能发生的各种位置,存在多种可能的交错方式,而在每次运行中,只有这些众多交错方式中的一个会发生。这,结果,使得它们不可预测。

对于前面的交错,如您在第一列和最后一列中看到的那样,指令和 happens-before 约束的顺序得到了保留,但执行之间可能存在 间隙。这些间隙是不可预测的,并且当我们跟踪执行时,前面的交错导致了一个令人惊讶的结果。进程 P 打印值 1,进程 Q 打印值 2,但预期它们都会打印 3 作为它们的最终结果。

注意,在前面的例子中,接受最终结果的约束被定义为这样——程序应该在输出中打印两个 3。这个约束可能是其他东西,并且与程序的可视输出无关。更重要的是,存在其他关键的约束,在面临不可预测的上下文切换时应该保持 不变。这包括没有任何 数据竞争竞争条件,没有任何内存泄漏,甚至不崩溃。所有这些约束都比程序的可视输出更重要。在许多实际应用中,程序甚至没有输出。

以下在 代码框 13-9 中是另一种具有不同结果的交错:

   Task P    |    Task Scheduler   |    Task Q
-------------------------------------------------
             |    Context Switch   |
             |                     |    B = X
             |                     |    B = B + 2
             |                     |    X = B
             |    Context Switch   |
 A = X       |                     |
 A = A + 1   |                     |
             |    Context Switch   |
             |                     |    print X
             |    Context Switch   |
 X = A       |                     |
 print X     |                     |
             |    Context Switch   |

代码框 13-9:当并发运行任务 P 和 Q 时另一种可能的交错

在这种交错中,任务 P 打印 3,但任务 Q 打印 2。这是由于任务 P 在第三次上下文切换之前没有足够幸运地更新共享变量 X 的值。因此,任务 Q 只打印了 X 在那一刻的值,即 2。这种情况被称为变量 X数据竞争,我们将在下一章中进一步解释。

在实际的 C 程序中,我们通常编写 X++X = X + 1 而不是首先将 X 复制到 A 中,然后增加 A,最后将其放回 X。您将在 第十五章线程执行 中看到这个例子。

这清楚地表明,C 语言中的简单 X++ 语句实际上由三个更小的指令组成,这些指令不会在单个时间片中执行。换句话说,它不是一个 原子指令,但它由三个更小的原子指令组成。原子指令不能被分解成更小的操作,也不能被上下文切换中断。我们将在关于多线程的后续章节中看到更多关于这一点的内容。

在前一个例子中,还有另一件事需要考虑。在前面的例子中,任务 PQ 并不是系统中唯一正在运行的任务;还有其他任务与我们的任务 PQ 同时执行,但我们没有在分析中考虑它们,我们只讨论了这两个任务。为什么是这样?

这个问题的答案在于,这两个任务与系统中其他任务之间的不同交错组合不会改变任务 PQ 的中间状态。换句话说,其他任务与 PQ 没有共享状态,正如我们之前解释的,当某些任务之间没有共享资源时,交错组合就不会重要,就像在这个例子中我们看到的那样。因此,我们可以假设在我们的假设系统中除了 PQ 之外没有其他任务。

其他任务对 PQ 唯一的影响是,如果它们的数量太多,它们可以使 PQ 的执行变慢。这仅仅是 PQ 中两个连续指令之间有长间隔的结果。换句话说,CPU 核心需要被更多任务共享。因此,任务 PQ 需要更频繁地在队列中等待,从而延迟它们的执行。

通过这个例子,你看到了即使是两个并发任务之间的单一共享状态也可能导致整体结果缺乏确定性。我们已经展示了与缺乏确定性相关的问题;我们不希望有一个在不同的运行中产生不同结果的程序。我们例子中的任务相对简单,包含四个平凡的指令,但实际存在于生产环境中的并发应用程序比这要复杂得多。

更重要的是,我们有许多不同类型的共享资源,这些资源不一定驻留在内存中,例如网络上的文件或服务。

同样,尝试访问共享资源的任务数量可能很高,因此我们需要更深入地研究并发问题,并找到恢复确定性的机制。在下一章中,我们将继续讨论并发问题和解决这些问题的方法。

在结束这一章之前,让我们简要地谈谈任务调度器以及它是如何工作的。如果我们只有一个 CPU 核心,那么在任何给定时刻,我们只能有一个任务使用那个 CPU 核心。

我们也知道,任务调度器本身是一个需要占用 CPU 核心执行片段的程序。那么,当另一个任务正在使用 CPU 核心时,它是如何管理不同任务的?让我们假设任务调度器本身正在使用 CPU 核心。首先,它在设置一个定时器以发生定时中断之前,从其队列中选择一个任务,然后它离开 CPU 核心并将资源交给所选任务。

现在我们假设任务调度器会给每个任务一定的时间,那么中断将会发生,CPU 核心停止当前任务的执行,并立即将任务调度器加载回 CPU。现在,调度器存储前一个任务的最新状态,并从队列中加载下一个任务。所有这些都会一直进行,直到内核启动并运行。关于具有多核心 CPU 的机器,这可能会改变,内核可以在调度其他核心的任务时使用多个核心。

在本节中,我们简要介绍了共享状态的概念以及它们如何在并发系统中参与。讨论将在下一章继续,我们将讨论并发问题和同步技术。

摘要

在本章中,我们介绍了并发的基础知识,以及为了理解即将到来的多线程和多处理多进程主题,你需要了解的基本概念和术语。

具体来说,我们讨论了以下内容:

  • 并发和并行性的定义——即每个并行任务都需要自己的处理器单元,而并发任务可以共享单个处理器。

  • 并发任务使用单个处理器单元,而任务调度器管理处理器时间,并在不同任务之间共享。这会导致每个任务都有多个上下文切换和不同的交织。

  • 阻塞指令的介绍。我们还解释了表明我们需要并发的情况的模式,以及我们如何将单个任务分解成两个或三个并发任务。

  • 我们描述了什么是共享状态。我们还展示了共享状态如何导致严重的并发问题,如当多个任务尝试读取和写入相同的共享状态时,会出现数据竞争。

在下一章中,我们完成对并发主题的讨论,并解释了在并发环境中你将遇到的各种问题。关于并发相关问题的解决方案也将是我们下一章讨论的一部分。

第十四章

同步

在上一章中,我们介绍了并发的基本概念和广泛使用的术语。在本章中,我们将关注使用程序中的并发可能引起的问题。像上一章一样,我们不会处理任何 C 源代码;相反,我们将专注于并发问题和解决它们的概念和理论背景。

作为本章的一部分,我们将学习以下内容:

  • 与并发相关的问题,即竞态条件和数据竞争:我们将讨论多个任务之间共享状态的影响,以及同时访问共享变量如何导致问题。

  • 用于同步访问共享状态的并发控制技术:我们将主要从理论角度讨论这些技术,并解释我们可以采取的方法来克服与并发相关的问题。

  • POSIX 中的并发:作为这个主题的一部分,我们将讨论 POSIX 如何标准化我们开发并发程序的方式。我们将简要解释并比较多线程和多进程程序。

在第一部分,我们将进一步讨论并发环境的非确定性如何导致并发问题,正如我们在上一章中提到的。我们还将讨论如何对这些问题进行分类。

并发问题

在上一章中,我们已经看到,当一些并发任务能够改变共享状态值时,可修改的共享状态可能会引起问题。进一步探讨这个问题,我们可能会问,可能会出现什么类型的问题?它们背后的主要原因是什么?我们将在本节中回答这些问题。

首先,我们需要区分可能发生的不同类型的并发问题。一些并发问题只有在没有并发控制机制的情况下才会存在,而另一些则是通过使用并发控制技术引入的。

关于第一组问题,它们发生在你可以看到不同的交错导致不同的整体状态时。在识别出这些问题之一后,下一步当然就是开始考虑一个合适的修复方案来解决该问题。

关于第二组问题,它们只有在实施修复措施之后才会出现。这意味着当你修复一个并发问题时,你可能会引入一个具有完全不同性质和不同根本原因的新问题;这就是并发程序难以处理的原因。

例如,假设您有许多任务,这些任务都具有对同一共享数据源的读写访问权限。在多次运行任务后,您发现为不同任务编写的算法并没有按预期工作。这导致意外崩溃或随机发生的逻辑错误。由于崩溃和错误结果的发生是随机的,不可预测的,您可以合理地假设这可能是并发问题。

您开始反复分析算法,最终找到问题;在共享数据源上存在数据竞争。现在您需要想出一个解决方案来尝试控制对共享数据源的访问。您实施了一个解决方案并再次运行系统,令人惊讶的是,您发现有时某些任务根本无法访问数据源。我们技术上称这些任务为饥饿。由于您的更改引入了一个与第一个问题完全不同性质的新问题!

因此,我们现在有两种不同的并发问题组,它们是:

  • 在没有控制(同步)机制的情况下存在于并发系统中的问题。我们称它们为固有并发问题

  • 在第一组问题尝试解决后发生的问题。我们称它们为后同步问题

将第一组称为固有的原因是因为这些问题在所有并发系统中固有存在。您无法避免它们,您必须通过使用控制机制来处理它们。从某种意义上说,它们可以被视为并发系统的属性,而不是问题。尽管如此,我们将它们视为问题,因为它们的非确定性性质干扰了我们开发所需确定性程序的能力。

第二组问题仅在您错误地使用控制机制时才会出现。请注意,控制机制本身并不存在问题,实际上它们是必要的,可以将确定性带回我们的程序中。然而,如果它们被错误地使用,它们可能会导致二级并发问题。这些二级问题,或称为并发后的问题,可以被视为程序员引入的新错误,而不是并发系统的固有属性。

在接下来的章节中,我们将介绍两组问题。首先,我们从固有问题开始,并讨论在并发环境中存在这些固有问题属性的主要原因。

固有并发问题

每个具有多个任务的并发系统都可以有许多可能的交错,这可以被视为系统的固有属性。根据我们迄今为止所学的知识,我们知道这种属性具有非确定性,这导致不同任务的指令在每个运行中以混乱的顺序执行,同时仍然遵循发生之前约束。请注意,这已经在上一章中解释过了。

交错本身并不是问题,正如我们之前解释的,它们是并发系统的固有属性。但在某些情况下,这种属性不能满足一些旨在保持的约束。这正是交错产生问题的时刻。

我们知道在多个任务并发执行时,可能会有许多交错。但问题只会在系统的某个约束(本应是不变的)在运行之间被交错改变时出现。因此,我们的目标是采用一些控制机制,有时被称为同步机制,以保持该约束不变和不变性。

这个约束通常通过一系列条件和标准来表示,从现在起我们将它们称为不变约束。这些约束可以涉及并发系统中的几乎所有内容。

不变约束可以是某些非常简单的东西,就像我们在前几章中给出的例子,其中程序应该在输出中打印两个 3。它们也可以非常复杂,比如在一个巨大的并发软件程序中保持所有外部数据源的数据完整性。

注意

产生每个可能的交错是非常困难的。在某些情况下,特定的交错只有极低的概率才能发生。如果发生,可能只在一百万次中发生一次。

这是并发开发中另一个危险的方面。虽然某些交错可能只在一百万次中发生一次,但当它们出错时,错误会非常严重。例如,它们可能导致飞机坠毁或在脑部手术期间严重设备故障!

每个并发系统都有一些定义最不明确的不变约束。随着我们进入本章,我们将给出例子,并且对于每一个例子,我们将讨论其不变约束。这是因为我们需要这些约束来设计一个特定的并发系统,该系统能够满足这些约束并保持它们的不变性。

在并发系统中发生的交错应该满足已经定义的不变约束。如果不满足,系统就存在问题。这就是不变约束变得非常重要的地方。每当有交错不满足系统的不变约束时,我们说系统中存在竞争条件

竞态条件是由并发系统的内在属性或换句话说,交错操作引起的。每当出现竞态条件时,系统的不变约束就有可能被忽略。

未满足不变约束条件的结果可能表现为逻辑错误或突然崩溃。有许多例子表明,存储在共享变量中的值并没有反映真实状态。这主要是因为存在不同的交错操作,破坏了共享变量的数据完整性

我们将在本章后面解释与数据完整性相关的问题,但现在,让我们看看以下例子。正如我们之前所说的,我们必须在跳转到例子之前定义例子中的不变约束。例子 14.1代码框 14-1中显示,只有一个不变约束,即共享Counter变量中的最终正确值应该是3。在这个例子中,有三个并发任务。每个任务都应该将Counter增加一,这是我们以下代码框中追求的逻辑:

Concurrent System {
    Shared State {
      Counter : Integer = 0
    }

    Task T1 {
      A : Integer
        1.1\. A = Counter
        1.2\. A = A + 1
        1.2\. Counter = A
    }
    Task T2 {
      B : Integer
        2.1\. B = Counter
        2.2\. B = B + 1
        2.2\. Counter = B
    }
    Task T3 {
      A : Integer
        3.1\. A = Counter
        3.2\. A = A + 1
        3.2\. Counter = A
    }
}

代码框 14-1:三个并发任务操作单个共享变量的系统

在前面的代码框中,你可以看到用伪代码编写的并发系统。正如你所见,并发系统中有三个任务。还有一个共享状态的章节。在前面的系统中,Counter是唯一一个所有三个任务都可以访问的共享变量。

每个任务都可以有多个局部变量。这些局部变量仅对任务本身是私有的,其他任务无法看到它们。这就是为什么我们可以有两个具有相同A的局部变量,但每个变量都是不同的,并且仅属于其所有者任务。

注意,任务不能直接操作共享变量,它们只能读取或更改它们的值。这就是为什么你需要有一些局部变量的基本原因。正如你所见,任务只能增加局部变量,而不能直接增加共享变量。这与我们在多线程和多进程系统中看到的情况非常一致,这就是为什么我们选择了前面的配置来表示并发系统。

代码框 14-1中展示的例子告诉我们,竞态条件可能导致逻辑错误。很容易找到一个交错操作,导致共享Counter变量的值为2。只需查看代码框 14-2中的交错操作:

  Task Scheduler |    Task T1   |    Task T2   |  Task T3
---------------------------------------------------------
  Context Switch |              |              |
                 |  A = Counter |              | 
                 |  A = A + 1   |              | 
                 |  Counter = A |              | 
  Context Switch |              |              |
                 |              |  B = Counter |
                 |              |  B = B + 1   |
  Context Switch |              |              |
                 |              |              |  A = Counter
  Context Switch |              |              |
                 |              |  Counter = B |
  Context Switch |              |              |
                 |              |              |  A = A + 1
                 |              |              |  Counter = A

代码框 14-2:违反代码框 14.1 中定义的不变约束条件的交错操作

在这里,很容易追踪交错操作。指令2.33.3(如代码框 14-1所示)都在共享Counter变量中存储了值2。前面的情况被称为数据竞争,我们将在本节稍后详细解释。

下一个例子,在代码框 14-3中展示,演示了竞态条件如何导致崩溃。

注意

在下一节中,我们将使用一个 C 伪代码示例。这是因为我们还没有介绍 POSIX API,这对于编写创建和管理线程或进程的 C 代码是必要的。

以下代码是一个示例,如果用 C 语言编写,可能会导致段错误:

Concurrent System {
    Shared State {
      char *ptr = NULL; // A shared char pointer which is
                        // supposed to point to a memory
                        // address in the Heap space. It
                        // becomes null by default.
    }
    Task P {
        1.1\. ptr = (char*)malloc(10 * sizeof(char));
        1.2\. strcpy(ptr, "Hello!");
        1.3\. printf("%s\n", ptr);
    }
    Task Q {
        2.1\. free(ptr);
        2.2\. ptr = NULL;
    }
}

代码框 14-3:违反了代码框 14-1 中示例的不变约束的交织

我们在这个示例中关注的明显的不变约束之一是不要让任务崩溃,这在我们不变约束中是隐含包含的。如果一个任务无法完成其工作,那么首先拥有不变约束本身就是矛盾的。

有一些交织会导致前面的任务崩溃。接下来,我们将解释其中两种:

  • 首先,假设指令 2.1 首先执行。由于 ptr 是空指针,因此任务 Q 将崩溃,而任务 P 继续执行。因此,在多线程使用场景中,如果两个任务(线程)属于同一进程,整个包含两个任务的程序将崩溃。崩溃的主要原因是在空指针上删除。

  • 另一种交织情况是当指令 2.21.2 之前执行,但在 1.1 之后执行。在这种情况下,任务 P 将崩溃,而任务 Q 则无问题完成。崩溃的主要原因是对空指针进行解引用。

因此,正如你在前面的示例中看到的那样,并发系统中的竞态条件可能导致不同的情况,如逻辑错误或突然崩溃。显然需要妥善解决的两个问题。

值得花点时间确保我们理解,并非所有并发系统中的竞态条件都能轻易识别。一些竞态条件可能直到很久以后才会显现出来。这就是为什么我在本章开头说并发程序处理起来有问题的原因。

话虽如此,有时我们会使用 竞态检测器 来查找那些不太可能执行的代码分支中存在的竞态条件。实际上,它们可以用来识别导致竞态条件的交织。

注意

竞态条件可以通过一组称为 竞态检测器 的程序来检测。它们根据是静态的还是动态的来分组。

静态竞态检测器 会遍历源代码,并尝试根据观察到的指令生成所有可能的交织,而 动态竞态检测器 首先运行程序,然后等待一个疑似竞态条件的代码执行。两者结合使用,以降低出现竞态条件的风险。

现在,是时候提出一个问题了。所有竞态条件背后是否有一个单一的根本原因?我们需要回答这个问题,以便提出一个能够消除竞态条件的解决方案。我们知道,每当一个交错操作不满足不变性约束时,就会发生竞态条件。因此,为了回答这个问题,我们需要对可能的不变性约束进行更深入的分析,并看看它们是如何被忽略的。

从我们在各种并发系统中观察到的结果来看,为了保持不变性约束得到满足,总有一些指令在不同的任务中找到,这些指令应该在所有交错操作中严格按顺序执行。

因此,遵循这个顺序的交错操作不会违反不变性约束。我们对这些交错操作感到满意,并观察到期望的输出。那些不保持严格顺序的交错操作将不满足不变性约束,因此可以被认为是问题交错操作。

对于这些交错操作,我们需要采用机制来恢复顺序并确保不变性约束始终得到满足。示例 14.2可以在代码框 14-4中看到。应该保持不变性的约束是在输出中打印 1。虽然这个约束有点不成熟,你不会在真实的并发应用中看到它,但它有助于我们理解我们正在讨论的概念:

Concurrent System {
  Shared State {
    X : Integer = 0
  }
  Task P {
      1.1\. X = 1
  }
  Task Q {
      2.1\. print X
  }
}

代码框 14-4:一个存在竞态条件的非常简单的并发系统

前面的例子可以根据其交错操作有两个不同的输出。如果我们想在输出中打印1,这是由不变性约束强制执行的,那么我们需要为两个不同的任务中的指令定义一个严格的顺序。

为了这个目的,打印指令2.1必须只在指令1.1之后执行。由于存在另一个容易违反这种顺序的交错操作,因此违反了不变性约束,所以我们有竞态条件。我们需要在这些指令之间保持严格的顺序。然而,将它们放入期望的顺序并不是一件容易的事情。我们将在本章后面讨论恢复这种顺序的方法。

让我们看看下面的示例 14.3。在下面的代码中,我们有一个由三个任务组成的系统。我们应该注意,在这个系统中没有共享状态。然而,尽管如此,我们仍然有竞态条件。让我们定义以下系统的不变性约束为始终先打印 1,然后是 2,最后是 3

Concurrent System {
  Shared State {
  }
  Task P {
      1.1\. print 3
  }
  Task Q {
      2.1\. print 1
  }
  Task R {
      3.1\. print 2
  }
}

代码框 14-5:另一个非常简单的并发系统,存在竞态条件但没有共享状态

即使在这个非常简单的系统中,你也无法保证哪个任务会先开始,正因为如此,我们才有了竞态条件。因此,为了满足不变性约束,我们需要按照以下顺序执行指令:2.13.11.1。这个顺序必须在所有可能的交错操作中保持。

上述例子揭示了竞争条件的一个重要特性,即:在并发系统中,要产生竞争条件,我们不需要有共享状态。相反,为了避免竞争条件,我们需要始终保持某些指令的严格顺序。我们应该注意,竞争条件仅因为一小部分指令(通常称为临界区)执行顺序不当而出现,而其他指令可以按任何顺序执行。

同时拥有可写共享状态和针对该共享状态的具体不变约束,可以在针对该共享状态的读和写指令之间强加一个严格的顺序。关于可写共享状态的最重要约束之一是数据完整性。这简单意味着所有任务都应该始终能够读取共享状态的最新和最新鲜的值,并且在继续执行修改共享状态的自身指令之前,应该意识到对共享状态所做的任何更新。

示例 14.4,如代码框 14-6所示,解释了数据完整性约束,更重要的是,它说明了它如何容易被忽略:

Concurrent System {
  Shared State {
    X : Integer = 2
  }
  Task P {
    A : Integer
      1.1\. A = X
      1.2\. A = A + 1
      1.3\. X = A
  }
  Task Q {
    B : Integer
      2.1\. B = X
      2.2\. B = B + 3
      2.3\. X = B
  }
}

代码框 14-6:一个因共享变量 X 的数据竞争而受苦的并发系统

考虑以下交错情况。首先,执行指令1.1。因此,X的值被复制到局部变量A。然而,任务P并不幸运,所以发生上下文切换,CPU 被分配给任务Q。然后执行指令2.1,接着将X的值复制到局部变量B。因此,变量AB都有相同的值,2

现在,任务Q很幸运,可以继续执行。然后执行指令2.2B变为5。任务Q继续并将值5写入共享状态X。因此,X变为5

现在,发生下一个上下文切换,CPU 被交还给任务P。它继续执行指令1.2。这就是完整性约束被遗漏的地方。

共享状态X已被任务Q更新,但任务P使用旧值2进行其余的计算。最终,它将X的值重置为3,这几乎不是程序员希望得到的结果。为了保持数据完整性的约束得到满足,我们必须确保指令1.1仅在指令2.3之后执行,或者指令2.1仅在指令1.3之后执行,否则数据完整性可能会受到损害。

注意

你可能会问自己,为什么我们使用了局部变量AB,而不是简单地写X = X + 1X = X + 3

正如我们在上一章中解释的,指令X = X + 1,在 C 语言中写作X++,不是一个原子指令。它仅仅意味着它不能在单个指令中完成,需要多个指令。这是因为我们在对变量进行操作时,永远不会直接访问内存中的变量。

我们总是使用一个临时变量,或者 CPU 寄存器,来保存最新的值,并在临时变量或寄存器上执行操作,然后将结果传回内存。因此,无论你如何编写它,总会有一个与任务本地关联的临时变量。

你会发现,在具有多个 CPU 核心的系统中的情况更糟。我们还有 CPU 缓存,它会缓存变量,并且不会立即将结果传回主内存中的变量。

让我们再讨论另一个定义。当某些交错顺序使与共享状态相关的数据完整性约束无效时,我们说我们在该共享状态下有一个数据竞争。

数据竞争与竞态条件非常相似,但为了产生数据竞争,我们需要在不同任务之间有一个共享状态,并且这个共享状态必须至少可以被其中一个任务修改(可写)。换句话说,共享状态不应该对所有任务都是只读的,而且至少应该有一个任务可能会根据其逻辑向共享状态写入。

正如我们之前所说的,对于只读共享状态,我们不能有数据竞争。这是由于这样一个事实:由于共享状态的价值不能被修改,因此无法破坏只读共享状态的数据完整性。

示例 14.5,在代码框 14-7中展示,说明了我们如何产生竞态条件,同时在一个只读的共享状态下不可能发生数据竞争:

Concurrent System {
  Shared State {
    X : Integer (read-only) = 5
  }
  Task P {
    A : Integer
      1.1\. A = X
      1.2\. A = A + 1
      1.2\. print A
  }
  Task Q {
      2.1\. print X
  }
  Task R {
    B : Integer
      3.1\. B = X + 1
      3.2\. B = B + 1
      3.3\. print B
  }
}

代码框 14-7:具有只读共享状态的多线程系统

假设前一个示例的不变约束是保持 X 的数据完整性,并首先打印 5,然后是 6,最后是 7。当然,我们有一个竞态条件,因为不同的print指令之间需要一个严格的顺序。

然而,由于共享变量是只读的,因此不存在数据竞争。请注意,指令1.23.2只修改了它们的局部变量,因此它们不能被视为对共享状态的修改。

作为本节的最后一点:不要期望竞态条件能够轻易解决!你肯定需要采用一些同步机制,以便在来自不同任务的某些指令之间创建所需的顺序。这将迫使所有可能的交错顺序遵循给定的顺序。实际上,你将在下一节中看到,我们必须引入一些新的交错顺序,这些顺序遵循所需的顺序。

我们将在本章后面讨论这些机制;在那之前,我们需要解释在使用一些同步方法后出现的并发相关的问题。下一节将全部关于同步后的问题以及它们与内在问题的不同之处。

同步后问题

接下来,我们将讨论由于误用控制机制而预期会发生的三项关键问题。你可能会同时经历这些问题中的一个或所有这些问题,因为它们有不同的根本原因:

  • 新的内在问题:应用控制机制可能会导致不同的竞争条件或数据竞争。控制机制是用来强制指令之间的严格顺序,这可能会导致新的内在问题出现。控制机制引入新的交错是体验新的并发相关行为和问题的基本原因。由于出现了新的竞争条件和新的数据竞争,新的逻辑错误和崩溃可能会发生。你必须通过所使用的同步技术,并根据你程序的逻辑进行调整,以修复这些新问题。

  • 饥饿:当一个并发系统中的任务长时间无法访问共享资源,主要是因为采用了特定的控制机制,我们说该任务已经变得饥饿。饥饿的任务无法访问共享资源,因此无法有效地执行其目的。如果其他任务依赖于饥饿任务的协作,它们自己也可能变得饥饿。

  • 死锁:当一个并发系统中的所有任务都在互相等待,没有任何一个任务在前进时,我们说达到了死锁。这主要由于控制机制被错误地应用,这反过来使得任务进入一个无限循环,等待其他任务释放共享资源或解锁锁对象等。这通常被称为循环等待。在任务等待期间,它们中的任何一个都无法继续执行,结果,系统将进入类似昏迷的状态。一些描述死锁情况的说明可以在维基百科页面找到:https://en.wikipedia.org/wiki/Deadlock。

    在死锁情况下,所有任务都卡住了,互相等待。但通常情况下,只有一部分任务,可能只有一个或两个,卡住了,其余的可以继续。我们称这些为半死锁情况。我们将在接下来的章节中看到更多这样的半死锁情况。

  • 优先级反转:存在这样的情况,在采用同步技术之后,一个优先级较高的任务使用共享资源时被一个低优先级任务阻塞,从而它们的优先级被反转。这是由于同步技术实现错误而可能发生的另一种次级问题。

并发系统中默认情况下不会出现饥饿;当没有同步技术被施加在操作系统的任务调度器上时,系统是公平的,不会允许任何任务出现饥饿。只有当程序员使用了某些控制机制时,才会导致饥饿。同样,死锁在并发系统中也不会出现,直到程序员介入。大多数死锁情况的主要原因是当锁被以这种方式使用时,并发系统中的所有任务都在等待彼此释放锁。通常,死锁在并发系统中比饥饿更常见。

现在,我们应该继续讨论控制机制。在下一节中,我们将讨论各种同步技术,这些技术可以用来克服竞争条件。

同步技术

在本节中,我们将讨论同步技术,或并发控制技术,或并发控制机制,这些技术被用来克服固有的并发相关问题。回顾我们之前所解释的内容,控制机制试图克服部分交错可能在一个系统中引起的问题。

每个并发系统都有自己的不变约束,并不是所有的交错都会满足所有这些约束。对于那些不满足系统不变约束的交错,我们需要发明一种方法来在指令之间施加特定的顺序。换句话说,我们应该创建满足不变约束的新交错,并用它们替换不良的交错。使用某种同步技术之后,我们将拥有一个完全新的具有一些新交错的并发系统,我们希望新系统将保持不变约束得到满足,并且不会产生任何同步后的问题。

注意,为了使用同步技术,我们需要编写新的代码并更改现有的代码。当你更改现有代码时,你实际上是在改变指令的顺序,从而改变交错。更改代码只是创建了一个新的具有新交错的并发系统。

新的交错是如何解决我们的并发问题的呢?通过引入新添加的工程交错,我们在不同任务的不同指令之间施加了一些额外的“发生之前”约束,从而保持不变约束得到满足。

注意,在单个任务中的两个相邻指令之间始终存在 happens-before 约束,但在并发系统中,两个不同任务的两个指令之间没有这些约束。通过使用同步技术,我们定义了一些新的 happens-before 约束,这些约束控制着两个不同任务之间执行的顺序。

拥有一个全新的并发系统意味着会面临新的、不同的问题。最初的并发系统是一个自然系统,其中任务调度器是唯一驱动上下文切换的实体。但在后来的系统中,我们面对的是一个人工和工程化的并发系统,其中任务调度器不是唯一的有效元素。用于保持系统不变性约束的并发控制机制是其他重要因素。因此,将在上一节中讨论的新问题,称为后同步问题,将会出现。

使用适当的控制技术来同步多个任务并使它们遵循特定的顺序取决于原始并发环境。例如,在多进程程序中使用的控制机制可能与在多线程程序中使用的机制不同。

由于这个原因,我们无法在不使用真实 C 代码的情况下详细讨论控制机制。因此,我们将以适用于所有并发系统、无论其实现方法如何的抽象方式来讨论它们。以下技术和概念在所有并发系统中都是有效的,但它们的实现方式极大地依赖于周围环境和系统的真实本质。

忙等待和自旋锁

作为一种通用解决方案,为了确保一个任务中的指令在另一个任务中的指令之后执行,前一个任务应该等待后一个任务首先执行其指令。在此期间,前一个任务可能会因为上下文切换而获得 CPU,但它不应该继续执行,而应该继续等待。换句话说,前一个任务应该暂停并等待,直到后一个任务执行了其指令。

当后一个任务能够完成其指令的执行时,有两种选择。要么前一个任务再次检查并看到后一个任务已经完成了其工作,要么应该有一种方式让后一个任务通知前一个任务,让它知道现在可以继续执行其指令。

描述的场景类似于两个人试图按照定义的顺序做某事的情况。其中一个人必须等待另一个人完成他们的工作,然后另一个人才能继续自己的工作。我们可以这样说,几乎所有控制机制都使用与此类似的方法,但它们的实现多种多样,并且主要取决于特定环境中的可用机制。我们将解释这些环境中的一个,即 POSIX 兼容的系统,以及其中可用的机制,作为本章最后部分的最后一部分。

让我们用一个例子来解释前面的控制技术,这是所有其他技术的核心。示例 14.6,在 代码框 14-8 中展示,是一个由两个并发任务组成的系统,我们希望定义的不变约束为首先打印 A,然后打印 B。在没有控制机制的情况下,代码框看起来如下:

Concurrent System {
  Task P {
    1.1\. print 'A'
  }
  Task Q {
    2.1\. print 'B'
  }
}

代码框 14-8:在引入控制机制之前表示示例 14.6 的并发系统

很明显,我们有一个基于定义的不变约束的竞争条件。交织 {2.1, 1.1} 打印 B 然后是 A,这与不变约束相矛盾。因此,我们需要使用控制机制来保持前面指令之间的特定顺序。

我们希望只有在指令 1.1 执行之后才执行 2.1。以下在 代码框 14-9 中展示的伪代码演示了如何设计和应用之前解释的方法,以恢复指令之间的顺序:

Concurrent System {
  Shared State {
    Done : Boolean = False
  }
  Task P {
    1.1\. print 'A'
    1.2\. Done = True
  }
  Task Q {
    2.1\. While Not Done Do Nothing
    2.2\. print 'B'
  }
}

代码框 14-9:使用忙等待解决示例 14.6 的解决方案

如您所见,我们不得不添加更多指令来同步任务。因此,看起来我们增加了一堆新的交织。更准确地说,我们面临的是一个与之前完全不同的并发系统。这个新系统有其自己的交织集,这些交织与旧系统中的交织不可比。

所有这些新的交织中有一个共同点,那就是指令 1.1 总是发生在指令 2.2 之前;这正是我们通过添加控制机制想要实现的目标。无论选择哪种交织方式或上下文切换如何发生,我们都强制在指令 1.12.2 之间建立了一个“先发生”的约束。

这怎么可能呢?在先前的系统中,我们引入了一个新的共享状态 Done,它是一个初始设置为 False 的布尔变量。每当任务 P 打印 A 时,它将 Done 设置为 True。然后,等待 Done 变为 True 的任务 Q 在第 2.1 行退出 while 循环并打印 B。换句话说,任务 Qwhile 循环中等待,直到共享状态 Done 变为 True,这是任务 P 完成其 print 命令的指示。看起来提出的解决方案似乎一切正常,实际上它确实工作得很好。

尝试想象以下交错情况。当任务P失去 CPU 核心而任务Q获得 CPU 核心时,如果Done不为真,那么任务Q将保持在循环中,直到它再次失去 CPU 核心。这意味着,当任务Q拥有 CPU 核心时,所需的条件尚未满足,它不会离开循环,并试图使用其时间片来做几乎除了轮询和检查条件是否满足之外的事情。它一直做到 CPU 核心被收回。换句话说,任务Q等待并浪费了时间,直到 CPU 核心被归还给任务P,任务P现在可以打印A

在技术术语中,我们说任务Q在满足特定条件之前处于忙等待状态。它持续监控(或轮询)一个条件,直到它变为真,然后退出忙等待。无论你称它为什么,任务Q尽管先前的解决方案完美解决了我们的问题,但仍然在浪费 CPU 的宝贵时间。

注意

忙等待(Busy-waiting)并不是等待事件发生的有效方法,但它是一种简单的方法。由于在忙等待期间,任务无法执行任何特殊操作,它完全浪费了分配给它的时间片。在长时间等待中,通常会避免使用忙等待。浪费的 CPU 时间可以分配给其他任务,以便完成其部分工作。然而,在某些情况下,如果预计等待时间较短,则可以使用忙等待。

在实际的 C 程序中,以及其他编程语言中,通常用于强制执行某些严格的顺序。锁只是一个对象,或者是一个变量,我们使用它来等待某个条件满足或事件发生。请注意,在先前的例子中,Done不是一个锁,而是一个标志。

要理解术语,我们可以将其想象为在执行指令2.2之前尝试获取锁。只有获取到锁后,你才能继续并退出循环。在循环内部,我们正在等待锁变得可用。我们可以有各种类型的锁,我们将在未来的章节中解释。

在下一节中,我们将执行之前讨论的等待场景,但这次使用一种更有效的方法,不会浪费 CPU 核心的时间。它有许多名称,但我们可以称之为等待/通知休眠/通知机制。

休眠/通知机制

与上一节中讨论的忙等待循环不同,我们可以想象另一种场景。任务Q可以选择休眠而不是在Done标志上忙等待,而任务P可以在将标志设置为True时通知它标志的变化。

换句话说,任务Q一旦发现标志不是True就会进入休眠状态,并让任务P更快地获取 CPU 核心并执行其逻辑。作为交换,任务P会在将标志修改为True后唤醒任务Q。实际上,这种方法是大多数操作系统的实际实现,以避免忙等待并更有效地引入控制机制。

以下伪代码演示了如何使用这种方法重写上一节给出的示例的解决方案:

Concurrent System {
  Task P {
      1.1\. print 'A'
      1.2\. Notify Task Q
  }
  Task Q {
      2.1\. Go To Sleep Mode
      2.2\. print 'B'
  }
}

代码框 14-10:使用 sleep/notify 解决示例 14.6 的解决方案

在能够解释前面的伪代码之前,我们需要回顾一些新的概念。首先是任务如何休眠。只要任务处于休眠状态,它就不会获得任何 CPU 份额。当任务将自己置于休眠模式时,任务调度器会意识到这一点。之后,任务调度器不会给休眠中的任务分配任何时间片。

任务进入休眠状态的优点是什么?进入休眠状态的任务不会通过忙等待来浪费 CPU 时间。而不是启动忙等待来轮询条件,任务会进入休眠状态,并在条件满足时被通知。这将显著提高CPU 利用率因素,真正需要 CPU 份额的任务将获得它。

当一个任务进入休眠模式时,应该有一个机制来唤醒它。这个机制通常是通过通知信号休眠中的任务来实现的。可以通知一个任务离开休眠模式,一旦它醒来并被通知,任务调度器就会将它放回队列,并再次给它分配 CPU。然后任务将在将其置于休眠模式的代码行之后继续执行。

在我们编写的代码中,任务Q一旦开始执行就会进入休眠模式。当它进入休眠模式时,它将不会获得任何 CPU 份额,直到它被任务P通知并唤醒。任务P只有在打印了A之后才会通知任务Q。然后,任务Q醒来并获取 CPU,然后继续并打印B

使用这种方法没有忙等待,也没有浪费 CPU 时间。注意,当进入休眠模式并通知休眠中的任务时,两者都有特定的系统调用,并且大多数操作系统都支持,尤其是符合 POSIX 标准的操作系统。

乍一看,似乎前面的解决方案已经解决了我们的问题,并且以高效的方式解决了——确实如此!然而,存在一种交错,会导致后续同步问题。这发生在你按照以下顺序执行前面的系统时:

1.1 print 'A'
1.2\. Notify Task Q
2.1\. Go To Sleep Mode
2.2\. print 'B'

代码框 14-11:将代码框 14-10 中展示的并发系统置于半死锁状态

在前面的交错中,任务 P 打印了 A,然后它通知了任务 Q,因为任务 Q 还没有进入睡眠状态,因为它还没有获得 CPU。当任务 Q 获得 CPU 时,它会立即进入睡眠模式。然而,没有其他任务在运行来通知它。因此,任务 Q 将不会再次获得 CPU,仅仅是因为任务调度器不会将 CPU 核心分配给一个睡眠任务。这是使用同步技术并观察其后果作为后同步问题的第一个例子。

为了解决这个问题,我们再次需要使用一个布尔标志。现在,任务 Q 在进入睡眠状态之前应该检查标志。这是我们的最终解决方案:

Concurrent System {
  Shared State {
    Done : Boolean = False
  }
  Task P {
      1.1\. print 'A'
      1.2\. Done = True
      1.3\. Notify Task Q
  }
  Task Q {
      2.1\. While Not Done {
      2.2\.     Go To Sleep Mode If Done is False (Atomic)
      2.3\. }
      2.4\. print 'B'
  }
}

代码框 14-12:基于睡眠/通知方法的示例 14.6 的改进解决方案

正如你在前面的伪代码中所看到的,如果标志 Done 没有被设置为 True,任务 Q 会进入睡眠状态。指令 2.2 被放在一个循环中,该循环简单地检查标志,并且只有在 DoneFalse 时才会进入睡眠状态。关于指令 2.2 的重要一点是,它必须是一个原子指令,否则解决方案就不完整,并且会遭受相同的问题。

注意:

对于那些对并发系统有一定经验的人来说,将此指令声明为原子操作可能有点令人惊讶。背后的主要原因是我们前面提到的例子中,真正的可感知的同步只有在定义一个清晰的临界区并使用互斥锁来保护它时才会发生。随着我们继续前进,这一点变得更加明显,在经过更多概念性主题之后,我们最终可以提供一个真实和实际的解决方案。

循环是必需的,因为睡眠中的任务可能被系统中的任何东西通知,而不仅仅是任务 P。在真实系统中,操作系统和其他任务可以通知一个任务,但在这里,我们只对从任务 P 收到的通知感兴趣。

因此,当任务被通知并唤醒时,它应该再次检查标志,如果标志尚未设置,则返回睡眠状态。正如我们之前解释的那样,根据我们到目前为止的解释,这个解决方案似乎有效,但它不是一个完整的解决方案,因为它也可能在具有多个 CPU 核心的机器上引起半死锁情况。我们将在 多处理器单元 这一部分进一步解释这一点。

注意:

基于等待/通知机制的解决方案通常使用条件变量来开发。条件变量在 POSIX API 中也有对应物,我们将在一个专门的章节中从概念上介绍它们,该章节将很快出现。

所有的同步机制都涉及某种形式的等待。这是保持某些任务同步的唯一方法。在某个时刻,其中一些应该等待,而另一些应该继续。这就是我们需要引入 信号量 的地方;这些是在并发环境中使逻辑等待或继续的标准工具。我们将在下一节中关注这一点。

信号量和互斥锁

在 20 世纪 60 年代,Edsger Dijkstra,一位非常著名的荷兰计算机科学家和数学家,和他的团队为 Electrologica X8 计算机设计了一个名为THE Multiprogramming SystemTHE OS的新操作系统,当时它拥有自己独特的架构。

在 Unix 和后来的 C 语言发明之前不到 10 年,贝尔实验室正在使用汇编语言编写 THE OS。THE OS 是一个多任务操作系统,它有一个多级架构。最高级是用户,最低级是任务调度器。在 Unix 术语中,最低级相当于在内核环中同时拥有任务调度器进程管理单元。Dijkstra 和他的团队为了克服某些并发相关困难,以及在不同任务之间共享不同资源,发明了信号量这一概念。

信号量简单地说是变量,或者对象,用于同步对共享资源的访问。我们将在本节中详细解释它们,并介绍一种特定的信号量类型,即互斥锁(mutexes),它们在并发程序中广泛使用,并且几乎存在于今天的任何编程语言中。

当一个任务即将访问一个共享资源,这可能是一个简单的变量或一个共享文件时,该任务应该首先检查一个预定义的信号量,并请求继续访问共享资源的权限。我们可以用一个类似例子来解释信号量和它的作用。

想象一位医生和一些希望被医生接诊的病人。假设没有预约机制,病人可以随时去看医生。我们的医生有一位秘书管理病人,将他们排队,并授予他们进入医生办公室的权限。

我们假设医生可以同时看多个病人(达到一定数量),这在我们的日常经验中有点不寻常,但你可以假设我们的医生是非凡的,可以同时看多个病人;也许多个病人愿意坐在同一个咨询室内。在某些实际用例中,信号量保护着可以被许多消费者使用的资源。所以,请现在暂时接受这个假设。

每当一位新病人到达医生的办公室时,他们应该首先去秘书那里注册。秘书有一张写在一张纸上的名单,他们会在这里写下新病人的名字。现在,病人应该等待秘书召唤他们,并授予他们进入医生办公室的权限。另一方面,每当病人离开医生的房间时,这个信息就会传给秘书,秘书会从名单上移除病人的名字。

在每个时刻,秘书的名单反映了医生房间内的病人以及正在被访问的病人,以及那些等待被访问的病人。当一个新病人离开医生的房间时,名单上等待的新病人可以进入医生的房间。这个过程一直持续到所有病人都看过医生。

现在,让我们将其映射到一个并发计算机系统,看看信号量是如何在我们的类比中做与秘书相同的事情的。

在这个例子中,医生是一个共享资源。他们可以被多个病人访问,这些病人类似于希望访问共享资源的任务。秘书是一个信号量。就像秘书有一个名单一样,每个信号量都有一个等待获取对共享资源访问权限的任务队列。医生的房间可以被认为是 临界区

临界区简单地说是一组由信号量保护的指令。任务不能在没有等待信号量的情况下进入它。另一方面,保护临界区是信号量的工作。每当一个任务试图进入临界区时,它应该让一个特定的信号量知道这一点。

同样,当一个任务完成并想要退出临界区时,它应该让相同的信号量知道这一点。正如你所看到的,我们的医生例子和信号量之间有一个非常好的对应关系。让我们继续一个更程序化的例子,并尝试在其中找到信号量和其他元素。

注意

临界区应该满足某些条件。这些条件将在我们通过本章的过程中进行解释。

以下示例,示例 14.7,在 代码框 14-13 中,又是关于两个任务尝试增加共享计数器的情况。我们已经在之前的多个地方讨论了这个例子,但这次,我们将基于信号量给出一个解决方案:

Concurrent System {
  Shared State {
    S : Semaphore which allows only 1 task at a time
    Counter: Integer = 0
  }
  Task P {
    A : Local Integer
      1.1\. EnterCriticalSection(S)
      1.2\. A = Counter
      1.3\. A = A + 1
      1.4\. Counter = A
      1.5\. LeaveCriticalSection(S)
  }
  Task Q {
    B : Local Integer
      2.1\. EnterCriticalSection(S)
      2.2\. B = Counter
      2.3\. B = B + 2
      2.4\. Counter = B
      2.5\. LeaveCriticalSection(S)
  }
}

代码框 14-13:使用信号量同步两个任务

在先前的系统中,我们有两个不同的共享状态:一个共享信号量 S,它应该保护对另一个共享变量 Counter 的访问。S 只允许一次只有一个任务进入它所保护的临界区。临界区是由 EnterCriticalSection(S)LeaveCriticalSection(S) 指令所包含的指令,正如你所看到的,每个任务都有一个由 S 保护的不同临界区。

要进入临界区,一个任务应该执行指令 EnterCriticalSection(S)。如果另一个任务已经在自己的临界区中,指令 EnterCriticalSection(S) 变成阻塞的,不会完成,因此当前任务应该等待直到信号量允许它通过并进入它的临界区。

EnterCriticalSection(S)指令可以根据场景有多种实现方式。它可以简单地是一个忙等待,或者它可以让等待的任务进入睡眠模式。后一种方法更为常见,等待其临界区的任务通常会进入睡眠状态。

在前面的例子中,信号量S被用来确保一次只有一个任务能够进入其临界区。但信号量更为通用,它们可以允许超过一个任务(在创建信号量时定义的某个数量)进入它们的临界区。一次只允许一个任务进入临界区的信号量通常被称为二进制信号量互斥锁。互斥锁比信号量更常见,你将在并发代码中经常看到它们。POSIX API 公开了信号量和互斥锁,你可以根据情况使用它们。

术语互斥锁代表互斥。假设我们有两个任务,每个任务都有一个临界区访问相同的共享资源。为了有一个基于互斥且无竞态条件的解决方案,关于任务应该满足以下条件:

  • 任何时候只有一个任务可以进入临界区,其他任务应该等待直到前一个任务离开临界区。

  • 解决方案应该是无死锁的。等待在临界区后面的任务最终应该能够进入它。在某些情况下,假设了一个等待时间的上限(即竞争时间)。

  • 临界区中的任务不能被抢占以允许其他任务进入临界区。换句话说,解决方案应该是无抢占协作的。

互斥锁存在是为了允许基于互斥的解决方案得到发展。请注意,临界区也应该遵循类似的条件。它们应该只允许一个任务在其内部,并且它们应该是无死锁的。请注意,信号量也满足最后两个条件,但它们可以允许一次进入多个任务到它们的临界区。

我们可以说,互斥是并发中最重要的概念,是我们手中各种控制机制的主导因素。换句话说,在你知道的每一种同步技术中,你都会通过使用信号量和互斥锁(但主要是互斥锁)看到互斥的足迹。

信号量和互斥锁被称为可锁定对象。在另一种但更为正式的术语中,等待信号量并进入临界区的行为与锁定信号量相同。同样,离开临界区并更新信号量的行为与解锁信号量相同。

因此,锁定和解锁信号量可以被认为是两种算法,分别用于等待和获取临界区的访问权限以及释放临界区。例如,自旋锁是通过在信号量上忙等待来获取临界区的访问权限,当然,我们也可以有其他类型的锁定和解锁算法。当我们使用 POSIX API 开发并发程序时,我们将在第十六章“线程同步”中解释这些不同的锁定算法。

如果我们要根据锁定和解锁术语来编写前面的解决方案,它将类似于以下内容:

Concurrent System {
  Shared State {
    S : Semaphore which allows only 1 task at a time
    Counter: Integer = 0
  }
  Task P {
    A : Local Integer
      1.1\. Lock(S)
      1.2\. A = Counter
      1.3\. A = A + 1
      1.4\. Counter = A
      1.5\. Unlock(S)
  }
  Task Q {
    B : Local Integer
      2.1\. Lock(S)
      2.2\. B = Counter
      2.3\. B = B + 2
      2.4\. Counter = B
      2.5\. Unlock(S)
  }
}

代码框 14-14:使用锁定和解锁操作与信号量一起工作

从现在起,我们将在我们的伪代码片段中使用锁定和解锁术语,这些术语在 POSIX API 中也得到了广泛应用。

我们将通过给出最终定义来完成本节。当多个任务都愿意进入临界区时,它们会尝试锁定一个信号量,但只有其中一定数量的任务(取决于信号量)可以获取锁并进入临界区。其他任务将等待获取锁。在信号量上等待获取锁的行为称为竞争。更多的任务会导致更多的竞争,而竞争时间则是衡量任务执行速度降低程度的指标。

显然,竞争中的任务获取锁需要一些时间,并且随着我们获取的任务越多,它们等待进入它们的临界区的时间应该越长。任务在竞争状态中等待的时间通常被称为竞争时间。竞争时间可以是并发系统的非功能性要求,应该被仔细监控,以防止任何性能下降。

我们可以得出结论,互斥锁是我们同步一些并发任务的主要工具。我们还在 POSIX 线程 API 和几乎支持并发的所有编程语言中都有互斥锁。除了互斥锁之外,条件变量在需要等待不定时间以满足特定条件时也起着重要作用。

我们将讨论条件变量,但在那之前,我们需要谈论内存屏障和具有多个处理器单元的并发环境,无论是多个 CPU 还是具有多个核心的 CPU。因此,下一节将专门讨论这个主题。

多处理器单元

当你的计算机系统中只有一个处理器单元,即只有一个核心的 CPU 时,试图访问主内存中特定地址的任务总是会读取最新的和最新的值,即使该地址已缓存在 CPU 核心中。在 CPU 核心内部缓存某些内存地址的值作为其本地缓存的一部分,甚至将这些地址的更改保留在缓存中,这是一种常见的做法。这将通过减少对主内存的读写操作次数来提高性能。在某些事件发生时,CPU 核心会将其本地缓存中的更改传播回主内存,以保持其缓存和主内存同步。

当你有多个处理器单元时,这些本地缓存仍然存在。当我们说多个处理器单元时,我们指的是一个具有多个核心的 CPU 或多个具有任意数量核心的 CPU。请注意,每个 CPU 核心都有自己的本地缓存。

因此,当两个不同的任务在两个不同的 CPU 核心上执行,并在主内存中操作相同的地址时,每个 CPU 核心都会在其自己的本地缓存中缓存相同内存地址的值。这意味着如果其中一个尝试写入共享内存地址,更改只会应用到其本地缓存,而不会应用到主内存和其他 CPU 核心的本地缓存。

这样做会导致许多不同的问题,因为当运行在另一个 CPU 核心中的任务试图从共享内存地址读取最新值时,它无法看到最新的更改,因为它会从其本地缓存中读取,而这个缓存中没有最新的更改。

这个问题,即每个 CPU 核心都有自己的不同本地缓存,通过在 CPU 核心之间引入内存一致性协议来解决。因此,通过遵循一致性协议,当其中一个 CPU 核心更改值时,所有运行在不同 CPU 核心上的任务都会在其本地缓存中看到相同的值。换句话说,我们说内存地址对所有其他处理器都是可见的。遵循内存一致性协议为所有在不同处理器单元上运行的任务提供了内存可见性。缓存一致性和内存可见性是在多于一个处理器单元上运行的并发系统中应考虑的两个重要因素。

让我们回到之前两节中解释的第一个基于 sleep-notify 的解决方案,即示例 14.6。对于示例 14.6的不变约束是要在输出中首先有 A 然后是 B

以下伪代码是我们的最终解决方案,我们使用了 sleep/notify 机制来强制执行print指令之间的期望顺序。我们说这个解决方案不是无错误的,可能会产生同步后的问题。在接下来的段落中,我们将解释问题是如何出现的:

Concurrent System {
  Shared State {
    Done : Boolean = False
  }
  Task P {
      1.1\. print 'A'
      1.2\. Done = True
      1.3\. Notify Task Q
  }
  Task Q {
      2.1\. While Not Done {
      2.2\.     Go To Sleep Mode If Done is False (Atomic)
      2.3\. }
      2.4\. print 'B'
  }
}

代码框 14-15:基于 sleep/notify 技术提出的针对示例 14.6 的解决方案

假设任务 PQ 在不同的 CPU 核心上运行。在这种情况下,每个 CPU 核心在其本地缓存中都有一个共享变量 Done 的条目。请注意,再次声明,我们已将指令 2.2 声明为原子的,并且请注意,这是直到我们提出一个合适的基于互斥锁的解决方案来解决这个问题的一个基本假设。假设一个任务 P 执行指令 1.2 并通知可能正在睡眠的任务 Q 的交织。因此,任务 P 更新其本地缓存中 Done 的值,但这并不意味着它将其写回主内存或更新其他 CPU 核心的本地缓存。

话虽如此,我们无法保证我们会看到主内存和任务 Q 的本地缓存中的变化。因此,有可能当任务 Q 获得 CPU 并读取其本地缓存时,它会看到 Done 的值为 False 并进入睡眠模式,而任务 P 已经完成并提前发送了通知信号,任务 P 将不再发出任何通知信号。最终,任务 Q 将永远进入睡眠状态,并发生半死锁情况。

为了解决这个问题,需要使用内存屏障或内存栅栏。它们是像屏障一样的指令,在执行(通过)它们时,所有仅在一个本地缓存中更新的值都会传播到主内存和其他本地缓存。它们对在其他 CPU 核心中执行的所有任务可见。换句话说,内存屏障同步所有 CPU 核心的本地缓存和主内存。

最后,我们可以提出我们的完整解决方案如下。请注意,再次声明,我们已将指令 2.3 声明为原子的,并且请注意,这是直到我们提出一个合适的基于互斥锁的解决方案来解决这个问题的一个基本假设:

Concurrent System {
  Shared State {
      Done : Boolean = False
  }
  Task P {
      1.1\. print 'A'
      1.2\. Done = True
      1.3\. Memory Barrier
      1.4\. Notify Task Q
  }
  Task Q {
      2.1\. Do {
      2.2\.     Memory Barrier
      2.3\.     Go To Sleep Mode If Done is False (Atomic)
      2.4\. } While Not Done
      2.5\. print 'B'
  }
}

代码框 14-16:使用内存屏障改进针对示例 14.6 提出的解决方案

通过在前面伪代码中使用内存屏障,我们确信对共享变量 Done 的任何更新都可以被任务 Q 看到。

注意,创建任务、锁定信号量和解锁信号量是三种充当内存屏障的操作,并同步所有 CPU 核心的本地缓存和主内存,并传播对共享状态所做的最近更改。

以下伪代码与前面的解决方案相同,但这次使用互斥锁。作为以下解决方案的一部分,我们将使用互斥锁并最终解决使我们声明指令 Go To Sleep Mode If DoneFalse 是原子的那个问题。尽管请注意,互斥锁是信号量,它允许每次只有一个任务处于临界区,并且,像信号量一样,锁定和解锁互斥锁可以充当内存屏障:

Concurrent System {
  Shared State {
      Done : Boolean = False
      M : Mutex
  }
  Task P {
      1.1\. print 'A'
      1.2\. Lock(M)
      1.3\. Done = True
      1.4\. Unlock(M)
      1.5\. Notify Task Q
  }
  Task Q {
      2.1\. Lock(M)
      2.2\. While Not Done {
      2.3\.     Go To Sleep Mode And Unlock(M) (Atomic)
      2.4\.     Lock(M)
      2.5\. }
      2.6\. Unlock(M)
      2.7\. print 'B'
  }
}

代码框 14-17:使用互斥锁改进针对示例 14.6 提出的解决方案

指令 Lock(M)Unlock(M) 充当内存屏障,因此确保所有任务中的内存可见性。作为提醒,Lock(M)Unlock(M) 之间的指令在每个任务中都被认为是临界区。

注意,当一个任务锁定互斥锁(或信号量)时,有三种情况会导致互斥锁自动解锁:

  • 任务使用 Unlock 命令解锁互斥锁。

  • 当一个任务完成时,所有已锁定的互斥锁都会解锁。

  • 当一个任务进入睡眠模式时,锁定的互斥锁会解锁。

    注意

    前面的列表中的第三个项目点并不总是正确的。如果一个任务想在由互斥锁保护的临界区中睡眠一定的时间,它当然可以在不解锁互斥锁的情况下睡眠。这就是为什么我们将指令 2.3 声明为原子的,并且我们向其中添加了 Unlock(M)。为了完全理解这种场景,我们需要涉及到 条件变量,这些将在接下来的章节中简要介绍。

因此,当指令 2.3 作为原子指令执行时,已经锁定的互斥锁 M 变得解锁。当任务再次被通知时,它将使用指令 2.4 重新获取锁,然后它可以再次进入其临界区。

在本节的最后一点,当一个任务已经锁定互斥锁时,它不能再次锁定它,并且尝试进一步锁定通常会导致死锁情况。只有 递归互斥锁 可以被单个任务多次锁定。请注意,当递归互斥锁被锁定(无论锁定多少次)时,所有其他任务在尝试锁定它时都会被阻塞。锁定和解锁操作总是成对出现,因此如果一个任务已经锁定递归互斥锁两次,它也应该解锁两次。

到目前为止,我们已经讨论并使用了许多示例中的睡眠/通知技术。只有当你接触到一个新的概念:条件变量时,你才能完全理解睡眠/通知技术。条件变量与互斥锁一起构成了实现控制技术的基础,这些技术有效地同步了许多任务在单个共享资源上。但在那之前,让我们谈谈另一个可能的解决方案来 示例 14.6

自旋锁

在开始讨论条件变量和睡眠/通知技术应该真正实现的方式之前,让我们稍微回顾一下,并使用忙等待与互斥锁一起为 示例 14.6 编写一个新的解决方案。作为提醒,该示例是关于 首先在标准输出中打印 A 然后打印 B

以下是一个使用带有自旋锁算法的互斥锁的解决方案。互斥锁充当内存屏障,因此我们不会有任何内存可见性问题,并且它有效地同步了任务 PQDone 共享标志上:

Concurrent System {
  Shared State {
      Done : Boolean = False
      M : Mutex
  }
  Task P {
      1.1\. print 'A'
      1.2\. SpinLock(M)
      1.2\. Done = True
      1.3\. SpinUnlock(M)
  }
  Task Q {
      2.1  SpinLock(M)
      2.2\. While Not Done {
      2.3\.   SpinUnlock(M)
      2.4\.   SpinLock(M)
      2.5\. }
      2.6\. SpinUnlock(M)
      2.4\. print 'B'
  }
}

代码框 14-18:使用互斥量和自旋锁定算法解决示例 14.6 的解决方案

上述伪代码是第一个可以用 POSIX 线程 API 编写为有效 C 代码的解决方案。之前给出的所有伪代码都无法编写成真正的程序,因为它们要么过于抽象而无法实现,要么在某些场景下存在问题,比如在具有多个处理器单元的系统上运行。但前面的伪代码可以被翻译成任何支持并发的编程语言。

在前面的代码中,我们使用的是 自旋锁,它们简单地是忙等待算法。每次你锁定自旋锁互斥量时,它都会进入一个忙等待循环,直到互斥量变得可用,然后继续。

我认为前面的伪代码中的所有内容都很容易理解,除了指令 2.32.4,它们是在循环内部奇怪的连续加锁和解锁指令!实际上,这是代码中最美丽的一部分。当任务 Q 获取 CPU 核心时,一系列对自旋锁互斥量 M 的加锁和解锁操作正在进行。

如果我们没有指令 2.32.4 会怎样?那么在指令 2.1 处的锁将保持互斥量锁定,直到指令 2.6,这意味着任务 P 永远找不到机会访问共享标志 Done。这些加锁和解锁指令允许任务 P 找到机会,并通过指令 1.2 更新标志 Done。否则,互斥量将一直被任务 Q 持有,任务 P 将永远无法继续到指令 1.2。换句话说,系统进入了一种半死锁状态。伪代码展示了加锁/解锁操作的美丽和谐,巧妙地使用自旋锁解决了我们的问题。

注意,在高性能系统中,与系统事件发生速率相比,将任务置于睡眠模式非常昂贵,因此自旋锁非常常见。当使用自旋锁时,任务应该编写得尽可能快地解锁互斥量。为了实现这一点,关键部分应该足够小。正如您在我们的代码中所看到的,我们有一个只有单个布尔检查(循环条件)的关键部分。

在下一节中,我们将探讨条件变量及其属性。

条件变量

我们在前面几节中提供的解决方案,以满足 示例 14.6,无法使用编程语言实现,因为我们不知道如何将任务置于睡眠模式,以及如何程序化地通知另一个任务。在本节中,我们将介绍条件变量,这是一个新的概念,可以帮助我们使任务等待并相应地得到通知。

条件变量是简单的变量(或对象),可以用来将任务置于睡眠模式或通知其他睡眠任务并将它们唤醒。请注意,这里讨论的睡眠模式与为了延迟而睡眠几秒钟或几毫秒是不同的,它特别意味着任务不想再获得任何 CPU 份额。像用于保护关键区的互斥锁一样,条件变量用于在不同的任务之间启用 信号

再次,就像具有相关 锁定解锁 操作的互斥锁一样,条件变量有 睡眠通知 操作。然而,每种编程语言在这里都有自己的术语,在某些语言中,你可能找到的是 等待信号 而不是睡眠和通知,但它们背后的逻辑是相同的。

条件变量必须与互斥锁一起使用。使用没有互斥锁的条件变量的解决方案简单地缺乏 互斥排他 属性。记住,条件变量必须被多个任务共享才有用,作为一个共享资源,我们需要同步访问它。这通常是通过一个互斥锁来实现的,该锁保护关键区。以下伪代码展示了我们如何使用条件变量和互斥锁等待某个条件,或一般的事件,特别是等待共享标志 Done示例 14.6 中变为 True

Concurrent System {
  Shared State {
      Done : Boolean = False
      CV   : Condition Variable
      M    : Mutex
  }
  Task P {
        1.1\. print 'A'
        1.2\. Lock(M)
        1.3\. Done = True
        1.4\. Notify(CV)
        1.5\. Unlock(M)
  }
  Task Q {
        2.1\. Lock(M)
        2.2\. While Not Done {
        2.3\.     Sleep(M, CV)
        2.4\. }
        2.5\. Unlock(M)
        2.6\. print 'B'
  }
}

代码框 14-19:使用条件变量解决示例 14.6 的方案

前面的解决方案是使用条件变量在并发系统中在两个指令之间实现严格顺序的最真实方式。指令 1.42.3 正在使用条件变量 CV。正如你所见,Sleep 操作需要了解互斥锁 M 和条件变量 CV,因为它需要在任务 Q 睡觉时解锁 M,并在它被通知时重新获取 M 的锁。

注意,当任务 Q 被通知时,它将继续其 Sleep 操作内部的逻辑,再次锁定 M 是其中的一部分。指令 1.4 也只有在获得 M 的锁时才会起作用,否则会发生竞争条件。去探索可能的交错情况,看看前面的互斥锁和条件变量将如何始终在指令 1.12.6 之间强制执行所需的顺序,这将是一个好且有益的挑战。

作为本节最后的定义,一个互斥对象与一个条件变量通常被称为 监视器 对象。我们还有一个与并发相关的设计模式,称为 监视器对象,它涉及到使用前面的技术来重新排序某些并发任务中的指令。

在前面的章节中,我们展示了如何使用信号量、互斥锁和条件变量以及锁定、解锁、睡眠和通知算法来实现控制机制,这些机制用于在各个并发任务中的某些指令之间强制执行严格的顺序,并保护关键部分。这些概念将在接下来的章节中使用,以便用 C 语言编写多线程和多进程程序。下一节将讨论 POSIX 标准中实现的并发支持,许多 Unix-like 操作系统都提供了这种支持。

POSIX 中的并发

正如我们在前面的章节中解释的那样,并发或多任务是由操作系统内核提供的一种功能。并非所有内核从出生起就是并发的,但今天大多数内核都支持并发。知道 Unix 的第一个版本不是并发的,但它在诞生后很快就获得了这一特性,这是令人欣慰的。

如果你记得第十章Unix – 历史 与 架构,我们解释了单 Unix 规范和 POSIX 如何试图标准化 Unix-like 操作系统 shell 环暴露的 API。并发一直是这些标准的一部分,到目前为止,它已经允许许多开发者为符合 POSIX 标准的操作系统编写并发程序。POSIX 中的并发支持已经在广泛的操作系统(如 Linux 和 macOS)中得到广泛使用和实现。

在符合 POSIX 标准的操作系统中的并发通常以两种方式提供。你可以让一个并发程序以不同的进程执行,这被称为多进程,或者你可以让你的并发程序作为同一进程的一部分以不同的线程运行,这被称为多线程。在本节中,我们将讨论这两种方法,并从程序员的角度进行比较。但在那之前,我们需要更多地了解支持并发的内核的内部结构。下一节简要地解释了在这样的内核中你会找到什么。

支持并发的内核

几乎所有正在开发和维护的内核都是多任务的。正如我们已经知道的,每个内核都有一个任务调度单元,它将 CPU 核心在许多运行中的进程和线程之间共享,这些进程和线程通常在本章和上一章中被统称为任务。

在继续前进之前,我们需要描述进程和线程以及它们在并发方面的区别。每次运行程序时,都会创建一个新的进程,程序的逻辑就在该进程中运行。进程是相互隔离的,一个进程无法访问另一个进程的内部,比如它的内存。

线程与进程非常相似,但它们是局部于某个进程的。它们通过拥有多个执行线程来引入单个进程的并发性,这些线程共同以并发方式执行多个指令序列。单个线程不能在两个进程之间共享,它是局部的,并绑定到其所属进程。进程中的所有线程都能够作为共享资源访问其所属进程的内存,而每个线程都有自己的堆栈区域,当然,同一进程中的其他线程也可以访问。此外,进程和线程都可以使用 CPU 份额,并且大多数内核中的任务调度器使用相同的调度算法来在它们之间共享 CPU 核心。

注意,当我们谈论内核级别时,我们更喜欢使用任务这个词而不是线程进程。从内核的角度来看,有一个等待获得 CPU 核心以执行其指令的任务队列,而任务调度单元的职责是以公平的方式为所有这些任务提供这种设施。

注意

在类 Unix 内核中,我们通常将任务这个词用于进程和线程。实际上,线程或进程是用户空间术语,不能用于内核术语。因此,类 Unix 内核有任务调度单元,试图在各个任务之间公平地管理对 CPU 核心的访问。

在不同的内核中,任务调度器使用不同的策略和算法来进行调度。但它们中的大多数都可以分为两大类调度算法:

  • 协同调度

  • 抢占式调度

协同调度是指将 CPU 核心分配给一个任务,并等待该任务的合作以释放 CPU 核心。这种方法在大多数正常情况下不是抢占式的,因为没有强制手段从任务中收回 CPU 核心。应该有一个高优先级的抢占信号来使调度器通过抢占收回 CPU 核心。否则,调度器和系统中的所有任务都应该等待直到活动任务随意释放 CPU 核心。现代内核通常不是这样设计的,但你仍然可以找到为非常特定的应用,如实时处理,采用协同调度的内核。早期版本的 macOS 和 Windows 使用协同调度,但现在它们使用抢占式调度方法。

与协作调度相比,我们有抢占式调度。在抢占式调度中,任务被允许使用 CPU 核心,直到被调度器接管。在一种特定的抢占式调度中,任务被允许在一定时间内使用给定的 CPU 核心。这种类型的抢占式调度被称为时间共享,它是当前内核中最常用的调度策略。任务被分配到 CPU 的时间间隔在不同的学术来源中可能有不同的名称,它可以被称为时间片时间槽量子

根据使用的算法,还有各种基于时间共享的调度类型。轮询是最广泛使用的时间共享算法,并被各种内核采用,当然也有一些修改。轮询算法允许对共享资源(在这种情况下是 CPU 核心)进行公平且无饥饿的访问。

尽管轮询算法简单且没有优先级,但它可以被修改以允许任务具有多个优先级。在现代内核中,通常需要不同的优先级,因为有一些类型的任务是由内核本身或其他内核中的重要单元发起的,并且这些任务应该在任何其他普通任务之前得到服务。

正如我们之前所说的,将并发引入软件有两种方式。第一种方法是多进程,它使用用户进程在多任务环境中并行执行任务。第二种方法是多线程,它使用用户线程将任务分解为单个进程内的并行执行流。在大型软件项目中,同时使用这两种技术也是非常常见的。尽管这两种技术都为软件带来了并发性,但它们在本质上有根本的不同。

在接下来的两个部分中,我们将更详细地讨论多进程和多线程。在接下来的两个章节中,我们将介绍 C 语言中的多线程开发,而在之后的两个章节中,我们将讨论多进程。

多进程

多进程简单来说就是使用进程来完成并发任务。一个很好的例子是网络服务器中使用的通用网关接口CGI)标准。采用这种技术的网络服务器为每个 HTTP 请求启动一个新的解释器进程。这样,它们可以同时处理多个请求。

在这样的网络服务器上,为了处理高吞吐量的请求,你可能会看到许多解释器进程同时被生成并运行,每个进程都处理不同的 HTTP 请求。由于它们是不同的进程,它们是隔离的,无法看到彼此的内存区域。幸运的是,在 CGI 用例中,解释器进程不需要相互通信或共享数据。但情况并不总是如此。

有许多例子表明,多个进程正在执行一些并发任务,并且它们需要共享关键信息片段,以便让软件继续运行。作为一个例子,我们可以参考 Hadoop 基础设施。Hadoop 集群中有许多节点,每个节点都有多个进程来保持集群的运行。

这些进程需要不断共享信息片段,以保持集群的运行。有许多这样的分布式系统示例,具有多个节点,如 Gluster、Kafka 和加密货币网络。所有这些都需要在不同节点上的进程之间进行大量的互通信息传递和消息传递,以保持运行。

只要进程或线程在没有共享状态的情况下运行,多进程和多线程之间并没有太大的区别。你可能可以使用进程代替线程,反之亦然。但是,一旦在它们之间引入共享状态,使用进程或线程,甚至两者的组合,我们就会看到巨大的差异。一个差异在于可用的同步技术。虽然用于使用这些机制的 API 大致相同,但在多进程环境中的工作复杂度要高得多,并且底层实现也不同。多进程和多线程之间的另一个差异是我们用于共享状态的技术。虽然线程能够使用所有可用于进程的技术,但它们有这样一个优势:它们可以使用相同的内存区域来共享状态。正如你将在接下来的章节中看到的,这造成了很大的差异。

为了更详细地说明,进程有一个私有内存,其他进程无法读取或修改它,因此使用进程的内存与其他进程共享某物并不那么容易。但是,使用线程就简单得多。同一个进程内的所有线程都可以访问同一个进程的内存;因此,它们可以使用它来存储共享状态。

接下来,你可以找到进程可以用来访问彼此之间共享状态的技术。关于这些技术的更多信息将在接下来的章节中给出:

  • 文件系统:这可以被认为是多个进程之间共享数据的最简单方式。这种方法非常古老,并且几乎所有的操作系统都支持它。例如,在软件项目中,许多进程都会读取配置文件。如果文件将要被某个进程写入,应该采用同步技术来防止数据竞争和其他并发相关的问题。

  • 内存映射文件:在所有符合 POSIX 标准的操作系统和 Microsoft Windows 中,我们可以拥有映射到磁盘上文件的内存区域。这些内存区域可以在多个进程之间共享,以便读取和修改。

    这种技术与文件系统方法非常相似,但它减少了使用文件 API 将数据流到和从文件描述符带来的头痛。如果映射区域的内容可以被任何有权访问它的进程修改,应采用适当的同步机制。

  • 网络:对于位于不同计算机上的进程,唯一的通信方式是通过使用网络基础设施和套接字编程 API。套接字编程 API 是 SUS 和 POSIX 标准的一个重要部分,它几乎存在于每个操作系统中。

    关于这项技术的细节非常庞大,仅为了涵盖这项技术就有许多书籍存在。各种协议、各种架构、处理数据流的不同方法以及更多细节都作为这项技术的子部分存在。我们试图在第二十章套接字编程中涵盖其中的一部分,但可能需要一本完全独立的书籍来探讨通过网络进行 IPC 的不同方面。

  • 信号:在同一操作系统中运行的进程可以向彼此发送信号。虽然这主要用于传递命令信号,但它也可以用于共享一个小状态(有效载荷)。共享状态的价值可以携带在信号上,并由目标进程截获。

  • 共享内存:在符合 POSIX 规范的操作系统和 Microsoft Windows 中,我们可以有一个区域内存被多个进程共享。因此,它们可以使用这个共享区域来存储变量并共享一些值。共享内存不能防止数据竞争,所以愿意将其用作可修改共享状态的占位符的进程需要采用适当的同步机制,以避免任何并发问题。共享内存区域可以同时被许多进程使用。

  • 管道:在符合 POSIX 规范的操作系统和 Microsoft Windows 中,管道是单向通信通道。它们可以用来在两个进程之间传输共享状态。一个进程向管道写入,另一个进程从中读取。

    管道可以是命名的或匿名的,每种都有其特定的用例。当讨论单机上的各种可用 IPC 技术时,我们将在第十九章单主机 IPC 和套接字中提供更多细节和示例。

  • Unix 域套接字:在符合 POSIX 标准的操作系统和最近的 Windows 10 中,我们有称为Unix 套接字的通信端点。位于同一台机器上并在同一操作系统中运行的进程可以使用Unix 域套接字通过全双工通道传递信息。Unix 域套接字与网络套接字非常相似,但所有数据都是通过内核传输的,因此它提供了一种非常快速的数据传输方式。多个进程可以使用同一个 Unix 域套接字来进行通信和共享数据。Unix 域套接字还可以用于特殊用途案例,例如在同一台机器上的进程之间传输文件描述符。Unix 域套接字的好处是,我们不需要使用与网络套接字相同的套接字编程 API。

  • 消息队列:几乎每个操作系统都存在。消息队列在内核中维护,可以被各种进程用来发送和接收多个消息。进程不需要相互了解,它们只需要能够访问消息队列即可。

    这种技术仅用于使同一台机器上的进程能够相互通信。

  • 环境变量:类 Unix 操作系统和 Microsoft Windows 提供了一组存储在操作系统本身的变量。

    这些变量被称为环境变量,并且它们可以供系统内的进程访问。

例如,本节第一段中介绍的方法在 CGI 实现中被广泛使用,尤其是在主 Web 服务器进程想要将 HTTP 请求数据传递给派生的解释器进程时。

关于使多个线程/进程同步的控制技术,你会发现,在多进程和多线程环境中使用的这些技术,与 POSIX 标准提供的 API 非常相似。但可能互斥量或条件变量的底层实现,在多线程和多进程使用中是不同的。我们将在接下来的章节中给出这方面的例子。

多线程

多线程是关于在并发环境中使用用户线程来执行并行任务。几乎找不到只有单个线程的非平凡程序;你遇到的几乎每个程序都是多线程的。线程只能存在于进程内部;我们不能有任何没有所有者的线程。每个进程至少有一个线程,通常称为主线程。使用单个线程执行所有任务的程序称为单线程程序。进程内的所有线程都可以访问相同的内存区域,这意味着我们不需要像在多进程中那样想出复杂的场景来共享数据。

由于线程与进程非常相似,它们可以使用进程用来共享或传递状态的任何技术。因此,上一节中解释的所有技术都可以由线程用来访问共享状态或在他们之间传输数据。但线程在这方面还有另一个优势,那就是可以访问相同的内存区域。因此,在多个线程之间共享数据的一种常见方法是通过声明一些变量来使用内存。

由于每个线程都有自己的堆栈内存,它可以用作保持共享状态的占位符。一个线程可以向另一个线程提供一个指向其堆栈内部某处的地址,并且另一个线程可以轻松访问它,因为这些内存地址都属于进程的堆栈段。线程还可以轻松访问进程拥有的相同堆空间,并将其用作占位符来存储它们的共享状态。我们将在下一章给出几个使用堆栈和堆区域作为共享状态占位符的示例。

同步技术也与进程使用的技巧非常相似。甚至 POSIX API 在进程和线程之间也是相同的。这很可能是因为符合 POSIX 的操作系统几乎以相同的方式处理进程和线程。在下一章中,我们将解释如何使用 POSIX API 在多线程程序中声明信号量、互斥量和条件变量等。

作为对 Windows 的最后一点说明,关于 POSIX 线程 API(pthreads),Microsoft Windows 不支持它。因此,Windows 有自己的 API,用于创建和管理线程。这个 API 是 Win32 本地库的一部分,我们在这本书中不会介绍,但您可以在网上找到许多涵盖它的资源。

摘要

在本章中,我们讨论了在开发并发程序时可能遇到的这些问题,以及我们应该采取的解决方案。以下是本章中我们讨论的主要要点。

  • 我们已经涵盖了并发问题。在所有并发系统中,当不同的交错不满足系统的不变性约束时,都会存在固有问题。

  • 我们讨论了在以糟糕和错误的方式使用同步技术后才会出现的问题。

  • 我们探讨了保持不变性约束得到满足所使用的控制机制。

  • 信号量是实现控制机制的关键工具。互斥量是信号量的一个特殊类别,它允许基于互斥条件,一次只允许一个任务进入临界区。

  • 监视封装互斥量和条件变量的对象可以在任务等待满足条件时使用。

  • 我们通过在 POSIX 标准中引入多进程和多线程,迈出了并发开发的第一步。

下一章是成对章节中的第一部分(第十五章线程执行,和第十六章线程同步),讨论了符合 POSIX 标准的操作系统中的多线程开发。第十五章线程执行主要讲述了线程及其执行方式。第十六章线程同步介绍了多线程环境中的可用并发控制机制,这两章共同传达了编写多线程程序所需的所有主题。

第十五章

线程执行

正如我们在上一章中解释的,在 POSIX 兼容系统中,可以使用多线程多进程方法中的任何一个来实现并发。由于这两个主题都非常广泛,因此它们被分为四个单独的章节,以便为每个主题提供所需的覆盖范围:

  • 多线程方法将在本章和第十六章“线程同步”中讨论。

  • 多进程方法将在第十七章“进程执行”和第十八章“进程同步”中介绍。

在本章中,我们将探讨线程的结构以及可以用来创建和管理线程的 API。在下一章,即第十六章“线程同步”中,我们将研究多线程环境中的并发控制机制,以研究它们应该如何解决与并发相关的问题。

多进程的概念是将并发引入软件,通过将逻辑分解为并发进程来实现,这最终导致多进程软件。由于多线程和多进程之间存在现有差异,我们决定将多进程的讨论移至两个单独的章节。

相比之下,多线程,前两章的重点,局限于单进程系统。这是关于线程的最基本事实,也是我们首先关注它的原因。

在上一章中,我们简要介绍了多线程和多进程之间的差异和相似之处。在本章中,我们将专注于多线程,并探讨它们应该如何使用,以便在单个进程中无缝运行多个执行线程。

本章涵盖了以下主题:

  • 我们首先讨论线程。本节中解释了用户线程内核线程,并讨论了线程的一些最重要的属性。这些属性有助于我们更好地理解多线程环境。

  • 然后我们进入下一节,该节专门介绍使用POSIX 线程库进行的基本编程,简称pthread库。这个库是主要的标准化库,允许我们在 POSIX 系统上开发并发程序,但这并不意味着不兼容 POSIX 的操作系统不支持并发。对于像 Microsoft Windows 这样的不兼容操作系统,它们仍然能够提供自己的 API 来开发并发程序。POSIX 线程库为线程和进程提供支持。然而,在本章中,我们的重点是线程部分,我们将探讨 pthread 库如何被用来创建线程并进一步管理它。

  • 在进一步的研究中,我们还演示了在某些使用 pthread 库的示例 C 代码中产生的竞态条件和数据竞争。这为我们在下一章继续讨论线程同步奠定了基础。

注意

为了能够完全理解我们将要讨论的多线程方法,强烈建议你在进入第十六章线程同步之前完成本章。这是因为本章引入的主题贯穿于我们下一章将要探讨的线程同步的第二部分。

在继续之前,请记住,在本章中,我们只将涵盖 POSIX 线程库的基本用法。深入探讨 POSIX 线程库的多个有趣元素超出了本书的范围,因此,建议你花些时间更详细地探索 pthread 库,并通过书面示例获得足够的实践,以便你能够熟悉它。POSIX 线程库的更高级用法将在本书剩余的章节中演示。

然而,现在,让我们深入探讨线程的概念,从概述我们所知道的一切开始。这是我们理解的关键要素,因为我们将在本章剩余的页面上介绍其他关键概念。

线程

在上一章中,我们讨论了线程作为多线程方法的一部分,当你在 POSIX 兼容的操作系统上编写并发程序时可以使用这种方法。

在本节中,你将找到关于线程你应该知道的所有内容的回顾。我们还将引入一些与我们将要讨论的主题相关的新信息。请记住,所有这些信息都将作为继续开发多线程程序的基础。

每个线程都是由一个进程初始化的。然后它将永远属于该进程。不可能有一个共享的线程或将线程的所有权转让给另一个进程。每个进程至少有一个线程,即它的主线程。在一个 C 程序中,main函数作为主线程的一部分被执行。

所有线程共享相同的进程 IDPID)。如果你使用tophtop等工具,可以很容易地看到线程共享相同的进程 ID,并且被分组在其下。不仅如此,所有线程的属性都继承自其所属进程,例如,组 ID、用户 ID、当前工作目录和信号处理器。例如,线程的当前工作目录与其所属进程相同。

每个线程都有一个独特且专用的线程 IDTID)。这个 ID 可以用来向该线程传递信号或在进行调试时跟踪它。你将看到在 POSIX 线程中,线程 ID 可以通过pthread_t变量访问。此外,每个线程还有一个专用的信号屏蔽,可以用来过滤它可能接收到的信号。

同一进程内的所有线程都可以访问该进程内其他线程打开的所有文件描述符。因此,所有线程都可以读取或修改那些文件描述符背后的资源。这也适用于套接字描述符和打开的套接字。在接下来的章节中,你将了解更多关于文件描述符和套接字的内容。

线程可以使用在第十四章中介绍的进程的所有技术来共享或传递状态。请注意,在共享位置(如数据库)中共享状态与在网络上传输它(例如)是不同的,这导致了两种不同的 IPC 技术类别。我们将在未来的章节中回到这一点。

在这里,你可以找到线程在 POSIX 兼容系统中可以用来共享或传递状态的列表:

  • 所属进程的内存(数据、堆栈和堆段)。这种方法适用于线程,不适用于进程。

  • 文件系统。

  • 内存映射文件。

  • 网络(使用互联网套接字)。

  • 线程间的信号传递。

  • 共享内存。

  • POSIX 管道。

  • Unix 域套接字。

  • POSIX 消息队列。

  • 环境变量。

在处理线程属性时,同一进程内的所有线程可以使用该进程的内存空间来存储和维护共享状态。这是在多个线程之间共享状态最常见的方式。进程的堆段通常用于此目的。

线程的生命周期依赖于其所属进程的生命周期。当一个进程被杀死终止时,该进程所属的所有线程也将被终止。

当主线程结束时,进程会立即退出。然而,如果有其他分离的线程正在运行,进程会等待它们全部完成后再终止。分离线程将在解释 POSIX 中线程创建时进行说明。

创建线程的进程可以是内核进程。同时,它也可以是在用户空间中启动的用户进程。如果进程是内核,则该线程被称为内核级线程或简称为内核线程,否则,该线程被称为用户级线程。内核线程通常执行重要逻辑,因此它们比用户线程有更高的优先级。例如,设备驱动程序可能使用内核线程来等待硬件信号。

与可以访问相同内存区域的用户线程类似,内核线程也能够访问内核的内存空间,从而能够访问内核中的所有过程和单元。

在整本书中,我们将主要讨论用户线程,而不是内核线程。这是因为与用户线程一起工作的 API 由 POSIX 标准提供。但是,没有标准接口用于创建和管理内核线程,它们仅针对每个内核特定。

创建和管理内核线程超出了本书的范围。因此,从现在开始,当我们使用术语线程时,我们指的是用户线程,而不是内核线程。

用户不能直接创建线程。用户需要首先启动一个进程,因为只有进程的主线程才能启动另一个线程。请注意,只有线程可以创建线程。

关于线程的内存布局,每个线程都有自己的栈内存区域,可以视为为该线程专用的私有内存区域。然而,在实践中,当有指针指向它时,其他线程(在同一进程内)也可以访问它。

您应该记住,所有这些栈区域都是同一进程内存空间的一部分,并且可以被同一进程中的任何线程访问。

关于同步技术,用于同步进程的相同控制机制也可以用于同步多个线程。信号量、互斥锁和条件变量是可用于同步线程的工具之一,以及进程。

当其线程同步且没有进一步的数据竞争或竞争条件可以观察到时,程序通常被称为线程安全程序。同样,一个库或一组函数,可以轻松地用于多线程程序而不会引入任何新的并发问题,被称为线程安全库。作为程序员,我们的目标是生成线程安全的代码。

注意:

在以下链接中,您可以找到有关 POSIX 线程及其共享属性的信息。以下链接是关于 POSIX 线程接口的 NTPL 实现。这是针对 Linux 环境的,但其中大部分也适用于其他类 Unix 操作系统。

http://man7.org/linux/man-pages/man7/pthreads.7.html.

在本节中,我们探讨了有关线程的一些基础概念和属性,以便更好地理解即将到来的章节。您将在我们讨论各种多线程示例时看到许多这些属性的实际应用。

下一节将向您介绍如何创建 POSIX 线程的第一个代码示例。这一节将会很简单,因为它只涉及 POSIX 中线程的基本知识。这些基础知识将引导我们进入更高级的主题。

POSIX 线程

本节专门介绍 POSIX 线程 API,也称为pthread 库。这个 API 非常重要,因为它是创建和管理 POSIX 兼容操作系统中的线程的主要 API。

在非 POSIX 兼容的操作系统,例如 Microsoft Windows 中,应该有另一个为这个目的设计的 API,并且可以在该操作系统的文档中找到。例如,在 Microsoft Windows 的情况下,线程 API 作为 Windows API 的一部分提供,称为 Win32 API。这是关于 Microsoft 的Windows 线程 API的文档链接:docs.microsoft.com/en-us/windows/desktop/procthread/process-and-thread-functions

然而,作为 C11 的一部分,我们期望有一个统一的 API 来处理线程。换句话说,无论你是在为 POSIX 系统还是非 POSIX 系统编写程序,你都应该能够使用 C11 提供的相同 API。虽然这是非常理想的,但在当前这个时间点,在各种 C 标准实现中,如 glibc,对这种通用 API 的支持并不多。

要继续讨论这个主题,pthread 库简单地说是一组头文件函数,可以用来在 POSIX 兼容的操作系统上编写多线程程序。每个操作系统都有自己的 pthread 库实现。这些实现可能与其他 POSIX 兼容操作系统的实现完全不同,但最终,它们都公开了相同的接口(API)。

一个著名的例子是原生 POSIX 线程库,简称NPTL,它是 Linux 操作系统中 pthread 库的主要实现。

如 pthread API 所述,所有线程功能都通过包含头文件pthread.h来提供。还有一些对 pthread 库的扩展,只有当你包含semaphore.h时才可用。例如,其中一个扩展涉及特定于信号量的操作,例如创建信号量、初始化它、销毁它等。

POSIX 线程库公开了以下功能。由于我们在前面的章节中已经对它们进行了详细解释,因此你应该很熟悉:

  • 线程管理,包括线程创建、线程连接和线程分离

  • 互斥锁

  • 信号量

  • 条件变量

  • 各种类型的锁,如自旋锁和递归锁

为了解释前面的功能,我们必须从pthread_前缀开始。所有 pthread 函数都以这个前缀开始。这适用于所有情况,除了信号量,它最初不是 POSIX 线程库的一部分,后来作为扩展添加。在这种情况下,函数将以sem_前缀开始。

在本章的后续部分,我们将看到如何在编写多线程程序时使用一些前面的功能。首先,我们将学习如何创建 POSIX 线程以与主线程并发运行。在这里,我们将了解 pthread_createpthread_join 函数,它们分别属于用于 创建连接 线程的主要 API。

创建 POSIX 线程

在前几章中,我们已经学习了诸如交织、锁、互斥锁和条件变量等基本概念,并在本章介绍了 POSIX 线程的概念,现在是时候编写一些代码了。

第一步是创建一个 POSIX 线程。在本节中,我们将演示如何使用 POSIX 线程 API 在进程内创建新线程。接下来的 示例 15.1 描述了如何创建一个执行简单任务(如将字符串打印到输出)的线程:

#include <stdio.h>
#include <stdlib.h>
// The POSIX standard header for using pthread library
#include <pthread.h>
// This function contains the logic which should be run
// as the body of a separate thread
void* thread_body(void* arg) {
  printf("Hello from first thread!\n");
  return NULL;
}
int main(int argc, char** argv) {
  // The thread handler
  pthread_t thread;
  // Create a new thread
  int result = pthread_create(&thread, NULL, thread_body, NULL);
  // If the thread creation did not succeed
  if (result) {
    printf("Thread could not be created. Error number: %d\n",
            result);
    exit(1);
  }
  // Wait for the created thread to finish
  result = pthread_join(thread, NULL);
  // If joining the thread did not succeed
  if (result) {
    printf("The thread could not be joined. Error number: %d\n",
            result);
    exit(2);
  }
  return 0;
}

代码框 15-1 [ExtremeC_examples_chapter15_1.c]:创建一个新的 POSIX 线程

代码框 15-1 中看到的示例代码创建了一个新的 POSIX 线程。这是本书中第一个包含两个线程的示例。所有之前的示例都是单线程的,代码始终在主线程中运行。

让我们解释一下我们刚刚看到的代码。在顶部,我们包含了一个新的头文件:pthread.h。这是一个标准头文件,它公开了所有 pthread 功能。我们需要这个头文件,以便我们可以引入 pthread_createpthread_join 函数的声明。

main 函数之前,我们声明了一个新的函数:thread_body。这个函数遵循一个特定的签名。它接受一个 void* 指针并返回另一个 void* 指针。作为一个提醒,void* 是一个通用指针类型,可以表示任何其他指针类型,如 int*double*

因此,这是 C 函数可以拥有的最一般签名。这是由 POSIX 标准强制的,所有希望成为线程(用作线程逻辑)的 伴随函数 的函数都应该遵循这个通用签名。这就是为什么我们定义了 thread_body 函数是这样的。

注意:

main 函数是主线程逻辑的一部分。当主线程被创建时,它作为其逻辑的一部分执行 main 函数。这意味着在 main 函数之前和之后可能还有其他代码被执行。

回到代码,作为 main 函数中的第一条指令,我们声明了一个类型为 pthread_t 的变量。这是一个线程句柄变量,在其声明时,它不指向任何特定的线程。换句话说,这个变量还没有包含任何有效的线程 ID。只有成功创建了一个线程之后,这个变量才包含对新创建线程的有效句柄。

创建线程后,线程句柄实际上指的是新创建线程的线程 ID。虽然线程 ID 是操作系统中的线程标识符,但线程句柄是程序中线程的表示。大多数情况下,存储在线程句柄中的值与线程 ID 相同。每个线程都能通过获取一个指向自身的 pthread_t 变量来访问其线程 ID。一个线程可以使用 pthread_self 函数来获取一个自引用的句柄。我们将在未来的示例中演示这些函数的用法。

线程创建发生在调用 pthread_create 函数时。如您所见,我们已经将 thread 句柄变量的地址传递给 pthread_create 函数,以便将其填充为适当的句柄(或线程 ID),指向新创建的线程。

第二个参数确定线程的属性。每个线程都有一些属性,如 堆栈大小堆栈地址分离状态,可以在创建线程之前进行配置。

我们将展示更多如何配置这些属性以及它们如何影响线程行为的示例。如果第二个参数传递了 NULL,这意味着新线程应该使用其属性的默认值。因此,在先前的代码中,我们创建了一个具有默认属性值的线程。

传递给 pthread_create 的第三个参数是一个函数指针。这个指针指向线程的 伴随函数,其中包含了线程的逻辑。在先前的代码中,线程的逻辑是在 thread_body 函数中定义的。因此,应该传递其地址以便将其绑定到句柄变量 thread 上。

第四个也是最后一个参数是线程逻辑的输入参数,在我们的例子中是 NULL。这意味着我们不希望向函数传递任何内容。因此,thread_body 函数中的参数 arg 在线程执行时将是 NULL。在下一节提供的示例中,我们将看看如何向这个函数传递一个值而不是 NULL

所有 pthread 函数,包括 pthread_create,在成功执行后都应返回零。因此,如果返回了除零以外的任何数字,则意味着函数已失败,并返回了一个 错误号

注意,使用 pthread_create 创建线程并不意味着线程的逻辑会立即执行。这是一个调度问题,无法预测新线程何时获得一个 CPU 核心并开始执行。

在创建线程后,我们加入新创建的线程,但这究竟意味着什么呢?正如我们之前解释的,每个进程都以一个线程开始,这个线程是主线程。除了主线程,其父进程是拥有进程外,所有其他线程都有一个父线程。在默认情况下,如果主线程完成,进程也将完成。当进程被终止时,所有其他正在运行或休眠的线程也将立即被终止。

因此,如果创建了一个新线程,它还没有开始运行(因为它还没有获得 CPU 的使用权),同时,父进程被终止(无论什么原因),线程将在执行第一条指令之前就死亡。因此,主线程需要等待第二个线程通过加入它来执行并完成。

线程只有在它的伴随函数返回时才完成。在前面的例子中,派生的线程在thread_body伴随函数返回时完成,这发生在函数返回NULL时。当新派生的线程完成时,被pthread_join调用阻塞的主线程被释放并可以继续,这最终导致程序成功终止。

如果主线程没有加入新创建的线程,那么新派生的线程根本不可能被执行。正如我们之前解释的,这是因为主线程在派生线程进入执行阶段之前就已经退出。

我们也应该记住,创建一个线程并不足以使其被执行。创建的线程可能需要一段时间才能获得访问 CPU 核心的权限,并通过这种方式最终开始运行。如果在此时,进程被终止,那么新创建的线程将没有机会成功运行。

现在我们已经讨论了代码的设计,Shell Box 15-1显示了运行example 15.1的输出:

$ gcc ExtremeC_examples_chapter15_1.c -o ex15_1.out -lpthread
$ ./ex15_1.out
Hello from first thread!
$

Shell Box 15-1:构建和运行示例 15.1

正如你在前面的 shell 框中看到的,我们需要在编译命令中添加-lpthread选项。这样做是因为我们需要将我们的程序与现有的 pthread 库实现链接。在某些平台,如 macOS,即使没有-lpthread选项,你的程序也可能被链接;然而,强烈建议在链接使用 pthread 库的程序时使用此选项。这条建议的重要性在于确保你的构建脚本在任何平台上都能工作,并在构建 C 项目时防止任何跨兼容性问题。

可以被加入的线程被称为可加入的。线程默认是可加入的。与可加入线程相反,我们有分离的线程。分离的线程不能被加入。

示例 15.1 中,主线程可以分离新产生的线程而不是连接它。这样,我们就让进程知道,它必须等待分离线程完成才能终止。请注意,在这种情况下,主线程可以退出,而父进程不会被终止。

在本节的最后代码中,我们想要使用分离线程重写前面的示例。而不是连接新创建的线程,主线程将其设置为分离,然后退出。这样,尽管主线程已经退出,但进程仍然会继续运行,直到第二个线程完成:

#include <stdio.h>
#include <stdlib.h>
// The POSIX standard header for using pthread library
#include <pthread.h>
// This function contains the logic which should be run
// as the body of a separate thread
void* thread_body(void* arg) {
  printf("Hello from first thread!\n");
  return NULL;
}
int main(int argc, char** argv) {
  // The thread handler
  pthread_t thread;
  // Create a new thread
  int result = pthread_create(&thread, NULL, thread_body, NULL);
  // If the thread creation did not succeed
  if (result) {
    printf("Thread could not be created. Error number: %d\n",
            result);
    exit(1);
  }
  // Detach the thread
  result = pthread_detach(thread);
  // If detaching the thread did not succeed
  if (result) {
    printf("Thread could not be detached. Error number: %d\n",
            result);
    exit(2);
  }
  // Exit the main thread
  pthread_exit(NULL);
  return 0;
}

代码框 15-2 [ExtremeC_examples_chapter15_1_2.c]: 示例 15.1 生成分离线程

上述代码的输出与之前使用可连接线程编写的代码完全相同。唯一的区别是我们管理新创建线程的方式。

在新线程创建后,主线程立即将其分离。然后,主线程退出。指令 pthread_exit(NULL) 是必要的,以便让进程知道它应该等待其他分离线程完成。如果线程没有被分离,进程会在主线程退出时终止。

注意

分离状态 是在创建新线程之前可以设置的一个线程属性,以便使其分离。这是创建新分离线程的另一种方法,而不是在可连接线程上调用 pthread_detach。区别在于,这种方式下,新创建的线程从一开始就是分离的。

在下一节中,我们将介绍我们的第一个示例,演示竞态条件。我们将使用本节中介绍的所有函数来编写未来的示例。因此,你将有机会在不同的场景中再次回顾它们。

竞态条件示例

对于我们的第二个示例,我们将探讨一个更具挑战性的场景。示例 15.2,如 代码框 15-3 所示,展示了交织是如何发生的,以及我们在实践中无法可靠地预测示例的最终输出,这主要是因为并发系统的非确定性本质。该示例涉及一个程序,几乎同时创建了三个线程,并且每个线程都打印不同的字符串。

以下代码的最终输出包含三个不同线程打印的字符串,但顺序不可预测。如果以下示例的不可变约束(在前一章中介绍)是要在输出中看到特定的字符串顺序,那么以下代码将无法满足该约束,主要是因为不可预测的交织。让我们看看以下代码框:

#include <stdio.h>
#include <stdlib.h>
// The POSIX standard header for using pthread library
#include <pthread.h>
void* thread_body(void* arg) {
  char* str = (char*)arg;
  printf("%s\n", str);
  return NULL;
}
int main(int argc, char** argv) {
  // The thread handlers
  pthread_t thread1;
  pthread_t thread2;
  pthread_t thread3;
  // Create new threads
  int result1 = pthread_create(&thread1, NULL,
          thread_body, "Apple");
  int result2 = pthread_create(&thread2, NULL,
          thread_body, "Orange");
  int result3 = pthread_create(&thread3, NULL,
          thread_body, "Lemon");
  if (result1 || result2 || result3) {
    printf("The threads could not be created.\n");
    exit(1);
  }

  // Wait for the threads to finish
  result1 = pthread_join(thread1, NULL);
  result2 = pthread_join(thread2, NULL);
  result3 = pthread_join(thread3, NULL);
  if (result1 || result2 || result3) {
    printf("The threads could not be joined.\n");
    exit(2);
  }
  return 0;
}

代码框 15-3 [ExtremeC_examples_chapter15_2.c]: 示例 15.2 向输出打印三个不同的字符串

我们刚才看到的代码与为 example 15.1 编写的代码非常相似,但它创建了三个线程而不是一个。在这个例子中,我们为所有三个线程使用相同的伴随函数。

如前述代码所示,我们向 pthread_create 函数传递了第四个参数,而在我们之前的例子 15.1 中,它是 NULL。这些参数将通过 thread_body 伴随函数中的通用指针参数 arg 被线程访问。

thread_body 函数内部,线程将通用指针 arg 强制转换为 char* 指针,并使用 printf 函数从该地址开始打印字符串。这就是我们能够向线程传递参数的方式。同样,它们的大小并不重要,因为我们只传递一个指针。

如果你需要在创建线程时向其发送多个值,你可以使用一个结构来包含这些值,并传递一个指向填充了所需值的结构变量的指针。我们将在下一章的 线程同步 中演示如何做到这一点。

注意

我们可以将指针传递给线程的事实意味着新线程应该能够访问主线程可以访问的相同内存区域。然而,访问并不限于拥有进程内存中的特定段或区域,并且所有线程都可以完全访问进程中的栈、堆、文本和数据段。

如果你多次运行 example 15.2,你会看到打印的字符串顺序可以变化,因为每次运行都预计会打印相同的字符串,但顺序不同。

Shell Box 15-2 展示了在连续三次运行后 example 15.2 的编译和输出:

$ gcc ExtremeC_examples_chapter15_2.c -o ex15_2.out -lpthread
$ ./ex15_2.out
Apple
Orange
Lemon
$ ./ex15_2.out
Orange
Apple
Lemon
$ ./ex15_2.out
Apple
Orange
Lemon
$

Shell Box 15-2:运行示例 15.2 三次以观察现有的竞态条件和各种交织情况

产生第一个和第二个线程在第三个线程之前打印它们的字符串的交织情况很容易,但要产生第三个线程打印其字符串 Lemon 作为输出中的第一个或第二个字符串的交织情况就困难得多。然而,这肯定会发生,尽管概率很低。你可能需要多次运行示例才能产生那种交织。这可能需要一些耐心。

上述代码也被认为不是线程安全的。这是一个重要的定义;一个多线程程序只有在没有根据定义的不变约束条件出现竞态条件的情况下才是线程安全的。因此,由于上述代码存在竞态条件,它不是线程安全的。我们的任务就是通过使用将在下一章中介绍的正确控制机制来使上述代码成为线程安全的。

如前一个示例的输出所示,我们并没有在 AppleOrange 的字符之间看到任何交织。例如,我们没有看到以下输出:

$ ./ex15_2.out
AppOrle
Ange
Lemon
$

15-3 号 Shell 盒:对于上述示例不会发生的想象中的输出

这表明printf函数是线程安全的,这仅仅意味着无论交织如何发生,当一个线程正在打印字符串时,其他线程中的printf实例不会打印任何内容。

此外,在前面给出的代码中,thread_body伴随函数在三个不同的线程的上下文中运行了三次。在之前的章节中,以及在给出多线程示例之前,所有函数都是在主线程的上下文中执行的。从现在起,每个函数调用都发生在特定线程的上下文中(不一定是主线程)。

两个线程无法启动单个函数调用。原因很明显,因为每个函数调用都需要创建一个栈帧,这个栈帧应该放在只有一个线程的栈顶上,而两个不同的线程有两个不同的栈区域。因此,函数调用只能由一个线程启动。换句话说,两个线程可以分别调用同一个函数,这会导致两个单独的函数调用,但它们不能共享同一个函数调用。

我们应该注意,传递给线程的指针不应该是一个悬空指针。这会导致一些严重的内存问题,难以追踪。作为提醒,悬空指针指向内存中的一个地址,该地址没有分配的变量。更具体地说,这种情况是,在某个时刻,那里可能原本有一个变量或数组,但到了指针即将被使用的时候,它已经被释放了。

在前面的代码中,我们向每个线程传递了三个字面量。由于这些字符串字面量所需的内存是从数据段分配的,而不是从堆或栈段分配的,因此它们的地址永远不会被释放,arg指针也不会变成悬空。

很容易将前面的代码写成指针悬空的形式。下面是同样的代码,但使用了悬空指针,你很快就会看到这会导致不良的内存行为:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// The POSIX standard header for using pthread library
#include <pthread.h>
void* thread_body(void* arg) {
  char* str = (char*)arg;
  printf("%s\n", str);
  return NULL;
}
int main(int argc, char** argv) {
  // The thread handlers
  pthread_t thread1;
  pthread_t thread2;
  pthread_t thread3;
  char str1[8], str2[8], str3[8];
  strcpy(str1, "Apple");
  strcpy(str2, "Orange");
  strcpy(str3, "Lemon");
  // Create new threads
  int result1 = pthread_create(&thread1, NULL, thread_body, str1);
  int result2 = pthread_create(&thread2, NULL, thread_body, str2);
  int result3 = pthread_create(&thread3, NULL, thread_body, str3);
  if (result1 || result2 || result3) {
    printf("The threads could not be created.\n");
    exit(1);
  }
  // Detach the threads
  result1 = pthread_detach(thread1);
  result2 = pthread_detach(thread2);
  result3 = pthread_detach(thread3);
  if (result1 || result2 || result3) {
    printf("The threads could not be detached.\n");
    exit(2);
  }
  // Now, the strings become deallocated.
  pthread_exit(NULL);
  return 0;
}

代码盒 15-4 [ExtremeC_examples_chapter15_2_1.c]:从主线程的栈区域分配字面量的 15.2 示例

前面的代码几乎与示例 15.2中给出的代码相同,但有两点不同。

首先,传递给线程的指针并不是指向数据段中驻留的字符串字面量,而是指向从主线程的栈区域分配的字符数组。作为main函数的一部分,这些数组已经被声明,在接下来的几行中,它们被一些字符串字面量填充。

我们需要记住,字符串字面量仍然驻留在数据段中,但声明后的数组在用strcpy函数填充后现在具有与字符串字面量相同的值。

第二个区别是关于主线程的行为。在之前的代码中,它加入了线程,但在这段代码中,它解除了线程并立即退出。这将释放主线程栈上声明的数组,在某些交错中,其他线程可能会尝试读取这些已释放的区域。因此,在某些交错中,传递给线程的指针可能会变成悬空。

注意

一些约束,如没有崩溃、没有悬空指针以及通常没有内存相关的问题,都可以被视为程序的不变约束的一部分。因此,在某些交错中产生悬空指针问题的并发系统肯定存在严重的竞态条件。

为了能够检测悬空指针,你需要使用一个内存分析器。作为一个更简单的方法,你可以运行程序多次,等待崩溃发生。然而,你并不总是有幸看到这一点,在这个例子中,我们也没有看到崩溃。

为了检测这个例子中的不良内存行为,我们将使用valgrind。你还记得我们在第四章进程内存结构第五章栈和堆中介绍了这个内存分析器,用于查找内存泄漏。回到这个例子,我们想用它来找到发生不良内存访问的地方。

值得记住的是,使用悬空指针并访问其内容,并不一定会导致崩溃。这在之前的代码中尤其如此,其中的字符串被放置在主线程的栈上。

当其他线程运行时,栈段保持与主线程退出时相同,因此即使str1str2str3数组在离开main函数时被释放,你仍然可以访问这些字符串。换句话说,在 C 或 C++中,运行时环境不会检查指针是否悬空,它只是遵循语句的顺序。

如果一个悬空指针及其底层内存被更改,那么可能会发生像崩溃或逻辑错误这样的坏事,但只要底层内存是未触及的,使用悬空指针可能不会导致崩溃,这是非常危险且难以追踪的。

简而言之,仅仅因为你可以通过悬空指针访问一个内存区域,并不意味着你被允许访问该区域。这就是为什么我们需要使用像valgrind这样的内存分析器,它会报告这些无效的内存访问。

在下面的 shell 框中,我们编译程序,并使用valgrind运行两次。在第一次运行中,没有发生任何坏事,但在第二次运行中,valgrind报告了内存访问错误。

Shell Box 15-4显示了第一次运行:

$ gcc -g ExtremeC_examples_chapter15_2_1.c -o ex15_2_1.out -lpthread
$ valgrind ./ex15_2_1.out
==1842== Memcheck, a memory error detector
==1842== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==1842== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==1842== Command: ./ex15_2_1.out
==1842==
Orange
Apple
Lemon
==1842==
==1842== HEAP SUMMARY:
==1842==     in use at exit: 0 bytes in 0 blocks
==1842==   total heap usage: 9 allocs, 9 frees, 3,534 bytes allocated
==1842==
==1842== All heap blocks were freed -- no leaks are possible
==1842==
==1842== For counts of detected and suppressed errors, rerun with: -v
==1842== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$

Shell Box 15-4:第一次使用 valgrind 运行示例 15.2

在第二次运行中,valgrind 报告了一些内存访问问题(注意,当你运行它时,完整的输出将可查看,但为了篇幅考虑,我们已进行了精简):

$ valgrind ./ex15_2_1.out
==1854== Memcheck, a memory error detector
==1854== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==1854== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==1854== Command: ./ex15_2_1.out
==1854==
Apple
Lemon
==1854== Thread 4:
==1854== Conditional jump or move depends on uninitialised value(s)
==1854==    at 0x50E6A65: _IO_file_xsputn@@GLIBC_2.2.5 (fileops.c:1241)
==1854==    by 0x50DBA8E: puts (ioputs.c:40)
==1854==    by 0x1087C9: thread_body (ExtremeC_examples_chapter15_2_1.c:17)
==1854==    by 0x4E436DA: start_thread (pthread_create.c:463)
==1854==    by 0x517C88E: clone (clone.S:95)
==1854==
...
==1854==
==1854== Syscall param write(buf) points to uninitialised byte(s)
==1854==    at 0x516B187: write (write.c:27)
==1854==    by 0x50E61BC: _IO_file_write@@GLIBC_2.2.5 (fileops.c:1203)
==1854==    by 0x50E7F50: new_do_write (fileops.c:457)
==1854==    by 0x50E7F50: _IO_do_write@@GLIBC_2.2.5 (fileops.c:433)
==1854==    by 0x50E8402: _IO_file_overflow@@GLIBC_2.2.5 (fileops.c:798)
==1854==    by 0x50DBB61: puts (ioputs.c:41)
==1854==    by 0x1087C9: thread_body (ExtremeC_examples_chapter15_2_1.c:17)
==1854==    by 0x4E436DA: start_thread (pthread_create.c:463)
==1854==    by 0x517C88E: clone (clone.S:95)
...
==1854==
Orange
==1854==
==1854== HEAP SUMMARY:
==1854==     in use at exit: 272 bytes in 1 blocks
==1854==   total heap usage: 9 allocs, 8 frees, 3,534 bytes allocated
==1854==
==1854== LEAK SUMMARY:
==1854==    definitely lost: 0 bytes in 0 blocks
==1854==    indirectly lost: 0 bytes in 0 blocks
==1854==      possibly lost: 272 bytes in 1 blocks
==1854==    still reachable: 0 bytes in 0 blocks
==1854==         suppressed: 0 bytes in 0 blocks
==1854== Rerun with --leak-check=full to see details of leaked memory
==1854==
==1854== For counts of detected and suppressed errors, rerun with: -v
==1854== Use --track-origins=yes to see where uninitialised values come from
==1854== ERROR SUMMARY: 13 errors from 3 contexts (suppressed: 0 from 0)
$

Shell Box 15-5:第二次运行示例 15.2 并使用 valgrind

如你所见,第一次运行顺利,没有内存访问问题,尽管上述竞争条件对我们来说仍然很明显。然而,在第二次运行中,当其中一个线程试图访问由 str2 指向的字符串 Orange 时,出现了问题。

这意味着传递给第二个线程的指针已经悬空。在前面的输出中,你可以清楚地看到堆栈跟踪指向 thread_body 函数内部的行,那里有 printf 语句。请注意,堆栈跟踪实际上指的是 puts 函数,因为我们的 C 编译器已将 printf 语句替换为等效的 puts 语句。前面的输出还显示,write 系统调用正在使用一个名为 buf 的指针,该指针指向一个未初始化或分配的内存区域。

观察前面的例子,valgrind 并不结论指针是否悬空。它只是报告无效的内存访问。

在关于不良内存访问的错误信息之前,你可以看到即使读取 Orange 的访问是无效的,字符串 Orange 仍然被打印出来。这仅仅表明,当我们的代码以并发方式运行时,事情可以多么容易变得复杂。

在本节中,我们向前迈出了重要的一步,了解了编写不安全的代码是多么容易。接下来,我们将演示另一个有趣的例子,该例子会产生数据竞争。在这里,我们将看到对 pthread 库及其各种函数的更复杂使用。

数据竞争示例

示例 15.3 展示了数据竞争。在先前的例子中,我们没有共享状态,但在这个例子中,我们将有两个线程之间共享的变量。

本例的不变约束是保护共享状态的数据完整性,以及所有其他明显的约束,如没有崩溃、没有不良内存访问等。换句话说,输出看起来如何无关紧要,但线程在共享变量的值被其他线程更改且写入线程不知道最新值时,不得写入新值。这就是我们所说的“数据完整性”:

#include <stdio.h>
#include <stdlib.h>
// The POSIX standard header for using pthread library
#include <pthread.h>
void* thread_body_1(void* arg) {
  // Obtain a pointer to the shared variable
  int* shared_var_ptr = (int*)arg;
  // Increment the shared variable by 1 by writing
  // directly to its memory address
  (*shared_var_ptr)++;
  printf("%d\n", *shared_var_ptr);
  return NULL;
}
void* thread_body_2(void* arg) {
  // Obtain a pointer to the shared variable
  int* shared_var_ptr = (int*)arg;
  // Increment the shared variable by 2 by writing
  // directly to its memory address
  *shared_var_ptr += 2;
  printf("%d\n", *shared_var_ptr);
  return NULL;
}
int main(int argc, char** argv) {
  // The shared variable
  int shared_var = 0;
  // The thread handlers
  pthread_t thread1;
  pthread_t thread2;
  // Create new threads
  int result1 = pthread_create(&thread1, NULL,
          thread_body_1, &shared_var);
  int result2 = pthread_create(&thread2, NULL,
          thread_body_2, &shared_var);
  if (result1 || result2) {
    printf("The threads could not be created.\n");
    exit(1);
  }
  // Wait for the threads to finish
  result1 = pthread_join(thread1, NULL);
  result2 = pthread_join(thread2, NULL);
  if (result1 || result2) {
    printf("The threads could not be joined.\n");
    exit(2);
  }
  return 0;
}

代码框 15-5 [ExtremeC_examples_chapter15_3.c]:示例 15.3,两个线程操作单个共享变量

共享状态已在 main 函数的第一行声明。在这个例子中,我们处理的是主线程堆栈区域分配的单个整型变量,但在实际应用中可能要复杂得多。整型变量的初始值为零,每个线程通过写入其内存位置直接增加其值。

在这个例子中,没有局部变量在每个线程中保留共享变量值的副本。然而,您应该小心线程中的增量操作,因为它们不是原子操作,因此可能会经历不同的交错。我们已在上一章中详细解释了这一点。

每个线程都可以通过在其伴随函数中通过参数arg接收到的指针来更改共享变量的值。正如您在两次调用pthread_create中可以看到的,我们将变量shared_var的地址作为第四个参数传递。

值得注意的是,指针在线程中永远不会成为悬垂指针,因为主线程不会退出,它通过连接线程来等待线程完成。

Shell Box 15-6展示了前面代码多次运行的输出,以产生不同的交错。请记住,我们希望共享变量shared_var的数据完整性得到保留。

因此,根据thread_body_1thread_body_2中定义的逻辑,我们只能有1 32 3作为可接受的输出:

$ gcc ExtremeC_examples_chapter15_3.c -o ex15_3.out -lpthread
$ ./ex15_3.out
1
3
$
...
...
...
$ ./ex15_3.out
3
1
$
...
...
...
$ ./ex15_3.out
1
2
$

Shell Box 15-6:示例 15.3 的多次运行,最终我们看到共享变量的数据完整性没有得到保留

如您所见,最后一次运行表明共享变量的数据完整性条件没有得到满足。

在最后一次运行中,第一个线程,即具有thread_body_1作为其伴随函数的线程,读取了共享变量的值,它是0

第二个线程,即具有thread_body_2作为其伴随函数的线程,也读取了共享值,它是0。在此之后,两个线程都试图增加共享变量的值并立即打印它。这是对数据完整性的违反,因为当一个线程正在操作共享状态时,另一个线程不应该能够写入它。

正如我们之前所解释的,在这个例子中,我们对shared_var有明确的数据竞争。

注意

当您自己执行示例 15.3时,请耐心等待,以查看1 2输出。这可能在运行可执行文件 100 次之后发生!我曾在 macOS 和 Linux 上观察到数据竞争。

为了解决前面的数据竞争,我们需要使用控制机制,例如信号量或互斥锁,来同步对共享变量的访问。在下一章中,我们将向前面的代码引入互斥锁,这将为我们完成这项工作。

摘要

本章是我们使用 POSIX 线程库在 C 中编写多线程程序的第一步。作为本章的一部分:

  • 我们学习了 POSIX 线程库的基础知识,这是在 POSIX 兼容系统中编写多线程应用程序的主要工具。

  • 我们探讨了线程及其内存结构的各种属性。

  • 我们对线程可用的通信和共享状态的机制提供了一些见解。

  • 我们解释了对于同一进程内所有线程可用的内存区域是共享数据和通信的最佳方式。

  • 我们讨论了内核线程和用户级线程,以及它们之间的差异。

  • 我们解释了可连接线程和分离线程,以及它们在执行点上的区别。

  • 我们演示了如何使用pthread_createpthread_join函数以及它们接收的参数。

  • 使用实际的 C 代码演示了竞态条件和数据竞争的例子,并展示了如何使用悬垂指针可能导致严重的内存问题,最终可能发生崩溃或逻辑错误。

在接下来的章节中,我们将通过探讨并发相关问题和可用的机制来防止和解决这些问题,继续并发展我们对多线程的讨论。

第十六章

线程同步

在上一章中,我们解释了如何创建和管理 POSIX 线程。我们还演示了两种最常见的并发问题:竞态条件和数据竞争。

在本章中,我们将继续讨论使用 POSIX 线程库进行多线程编程,并为您提供控制多个线程所需的技术。

如果您还记得第十四章“同步”,我们展示了与并发相关的问题实际上并不是问题;相反,它们是并发系统基本属性的后果。因此,您很可能在任何并发系统中都会遇到它们。

我们在上一章中展示了我们确实可以使用 POSIX 线程库产生这些问题。上一章的示例 15.215.3演示了竞态条件和数据竞争问题。因此,它们将成为我们使用 pthread 库提供的同步机制来同步多个线程的起点。

在本章中,我们将涵盖以下主题:

  • 使用 POSIX 互斥锁来保护访问共享资源的临界区。

  • 使用 POSIX 条件变量等待特定条件。

  • 使用各种类型的锁与互斥锁和条件变量一起使用。

  • 使用 POSIX 障碍(barriers)及其如何帮助同步多个线程。

  • 信号量的概念及其在 pthread 库中的对应对象:POSIX 信号量。您将发现互斥锁实际上是二进制信号量。

  • 线程的内存结构和这种结构如何影响多核系统中的内存可见性。

我们从本章开始,先对并发控制进行一般性讨论。接下来的几节将为您提供编写良好行为的多线程程序所需的工具和结构。

POSIX 并发控制

在本节中,我们将探讨 pthread 库提供的可能控制机制。信号量、互斥锁和条件变量以及不同类型的锁以各种组合使用,以使多线程程序具有确定性。首先,我们从 POSIX 互斥锁开始。

POSIX 互斥锁

pthread 库中引入的互斥锁可以用于同步进程和线程。在本节中,我们将使用它们在多线程 C 程序中同步多个线程。

作为提醒,互斥锁是一种信号量,它一次只允许一个线程进入临界区。通常,信号量有让多个线程进入其临界区的潜力。

注意

互斥锁也被称为二进制信号量,因为它们是只接受两种状态的信号量。

我们从解决前一章中作为示例 15.3一部分观察到的数据竞争问题开始本节,使用 POSIX 互斥锁。互斥锁一次只允许一个线程进入临界区,并对共享变量执行读写操作。这样,它保证了共享变量的数据完整性。以下代码框包含了解决数据竞争问题的解决方案:

#include <stdio.h>
#include <stdlib.h>
// The POSIX standard header for using pthread library
#include <pthread.h>
// The mutex object used to synchronize the access to
// the shared state.
pthread_mutex_t mtx;
void* thread_body_1(void* arg) {
  // Obtain a pointer to the shared variable
  int* shared_var_ptr = (int*)arg;
  // Critical section
  pthread_mutex_lock(&mtx);
  (*shared_var_ptr)++;
  printf("%d\n", *shared_var_ptr);
  pthread_mutex_unlock(&mtx);
  return NULL;
}
void* thread_body_2(void* arg) {
  int* shared_var_ptr = (int*)arg;
  // Critical section
  pthread_mutex_lock(&mtx);
  *shared_var_ptr += 2;
  printf("%d\n", *shared_var_ptr);
  pthread_mutex_unlock(&mtx);
  return NULL;
}
int main(int argc, char** argv) {
  // The shared variable
  int shared_var = 0;
  // The thread handlers
  pthread_t thread1;
  pthread_t thread2;
  // Initialize the mutex and its underlying resources
  pthread_mutex_init(&mtx, NULL);
  // Create new threads
  int result1 = pthread_create(&thread1, NULL,
          thread_body_1, &shared_var);
  int result2 = pthread_create(&thread2, NULL,
          thread_body_2, &shared_var);
  if (result1 || result2) {
    printf("The threads could not be created.\n");
    exit(1);
  }
  // Wait for the threads to finish
  result1 = pthread_join(thread1, NULL);
  result2 = pthread_join(thread2, NULL);
  if (result1 || result2) {
    printf("The threads could not be joined.\n");
    exit(2);
  }
  pthread_mutex_destroy(&mtx);
  return 0;
}

代码框 16-1 [ExtremeC_examples_chapter15_3_mutex.c]:使用 POSIX 互斥锁解决前一章中作为示例 15.3 一部分发现的数据竞争问题

如果你编译前面的代码并运行多次,你将只看到输出中的1 32 3。那是因为我们正在使用 POSIX 互斥锁对象来同步前面代码中的临界区。

在文件开头,我们已声明一个全局 POSIX 互斥锁对象作为mtx。然后在main函数中,我们使用pthread_mutex_init函数使用默认属性初始化互斥锁。第二个参数是NULL,可以是程序员指定的自定义属性。我们将在接下来的章节中通过一个示例来了解如何设置这些属性。

互斥锁在两个线程中都被用来保护由pthread_mutex_lock(&mtx)pthread_mutex_unlock(&mtx)语句包围的临界区。

最后,在离开main函数之前,我们销毁互斥锁对象。

在伴随函数thread_body_1中的第一对pthread_mutex_lock(&mtx)pthread_mutex_unlock(&mtx)语句,构成了第一个线程的临界区。同样,伴随函数thread_body_2中的第二对构成了第二个线程的临界区。这两个临界区都由互斥锁保护,并且每次只有一个线程可以在其临界区中,而其他线程应该在临界区外等待,直到忙线程离开。

一旦一个线程进入临界区,它就会锁定互斥锁,而其他线程应该在pthread_mutex_lock(&mtx)语句后面等待,直到互斥锁再次解锁。

默认情况下,等待互斥锁解锁的线程会进入睡眠模式,并且不会进行忙等待。但如果我们想进行忙等待而不是进入睡眠状态呢?那么我们可以使用自旋锁。只需要使用以下函数代替所有前面的互斥锁相关函数即可。幸运的是,pthread 在函数命名上使用了一致的约定。

与自旋锁相关的类型和函数如下。

  • pthread_spin_t:用于创建自旋锁对象的类型。它类似于pthread_mutex_t类型。

  • pthread_spin_init:初始化一个自旋锁对象。它类似于pthread_mutex_init

  • pthread_spin_destroy:类似于pthread_mutex_destory

  • pthread_spin_lock:类似于pthread_mutex_lock

  • pthread_spin_unlock:类似于pthread_mutex_unlock

如你所见,只需用自旋锁类型和函数替换前面的互斥锁类型和函数,就可以很容易地实现不同的行为,在等待互斥锁对象释放时进行忙等待。

在本节中,我们介绍了 POSIX 互斥锁及其如何用于解决数据竞争问题。在下一节中,我们将演示如何使用条件变量来等待某个事件的发生。我们将解决在示例 15.2中发生的竞争条件,但我们将对原始示例进行一些修改。

POSIX 条件变量

如果你还记得上一章中的示例 15.2,我们遇到了竞争条件。现在,我们想要提出一个新的例子,它与示例 15.2非常相似,但在这个例子中,使用条件变量会更简单。示例 16.1有两个线程而不是三个(这是示例 15.2的情况),它们需要将字符AB打印到输出,但我们希望它们始终按照特定的顺序;首先A然后是B

我们这个例子中的不变约束是要在输出中首先看到 A 然后看到 B(以及所有共享状态的数据完整性,没有坏内存访问,没有悬垂指针,没有崩溃,以及其他明显的约束)。以下代码演示了我们如何使用条件变量来为这个例子提供一个用 C 语言编写的解决方案:

#include <stdio.h>
#include <stdlib.h>
// The POSIX standard header for using pthread library
#include <pthread.h>
#define TRUE  1
#define FALSE 0
typedef unsigned int bool_t;
// A structure for keeping all the variables related
// to a shared state
typedef struct {
  // The flag which indicates whether 'A' has been printed or not
  bool_t          done;
  // The mutex object protecting the critical sections
  pthread_mutex_t mtx;
  // The condition variable used to synchronize two threads
  pthread_cond_t  cv;
} shared_state_t;
// Initializes the members of a shared_state_t object
void shared_state_init(shared_state_t *shared_state) {
  shared_state->done = FALSE;
  pthread_mutex_init(&shared_state->mtx, NULL);
  pthread_cond_init(&shared_state->cv, NULL);
}
// Destroy the members of a shared_state_t object
void shared_state_destroy(shared_state_t *shared_state) {
  pthread_mutex_destroy(&shared_state->mtx);
  pthread_cond_destroy(&shared_state->cv);
}
void* thread_body_1(void* arg) {
  shared_state_t* ss = (shared_state_t*)arg;
  pthread_mutex_lock(&ss->mtx);
  printf("A\n");
  ss->done = TRUE;
  // Signal the threads waiting on the condition variable
  pthread_cond_signal(&ss->cv);
  pthread_mutex_unlock(&ss->mtx);
  return NULL;
}
void* thread_body_2(void* arg) {
  shared_state_t* ss = (shared_state_t*)arg;
  pthread_mutex_lock(&ss->mtx);
  // Wait until the flag becomes TRUE
  while (!ss->done) {
    // Wait on the condition variable
    pthread_cond_wait(&ss->cv, &ss->mtx);
  }
  printf("B\n");
  pthread_mutex_unlock(&ss->mtx);
  return NULL;
}
int main(int argc, char** argv) {
  // The shared state
  shared_state_t shared_state;
  // Initialize the shared state
  shared_state_init(&shared_state);
  // The thread handlers
  pthread_t thread1;
  pthread_t thread2;
  // Create new threads
  int result1 =
    pthread_create(&thread1, NULL, thread_body_1, &shared_state);
  int result2 =
    pthread_create(&thread2, NULL, thread_body_2, &shared_state);
  if (result1 || result2) {
    printf("The threads could not be created.\n");
    exit(1);
  }
  // Wait for the threads to finish
  result1 = pthread_join(thread1, NULL);
  result2 = pthread_join(thread2, NULL);
  if (result1 || result2) {
    printf("The threads could not be joined.\n");
    exit(2);
  }
  // Destroy the shared state and release the mutex
  // and condition variable objects
  shared_state_destroy(&shared_state);
  return 0;
}

代码框 16-2 [ExtremeC_examples_chapter16_1_cv.c]:使用 POSIX 条件变量来指定两个线程之间的特定顺序

在前面的代码中,使用一个结构体来封装共享互斥锁、共享条件变量和共享标志是一个好主意。请注意,我们只能为每个线程传递一个指针。因此,我们必须将所需的共享变量堆叠到一个单独的结构体变量中。

在示例中的第二个类型定义(在bool_t之后),我们定义了一个新的类型,shared_state_t,如下所示:

typedef struct {
  bool_t          done;
  pthread_mutex_t mtx;
  pthread_cond_t  cv;
} shared_state_t;

代码框 16-3:将示例 16.1 所需的所有共享变量放入一个结构体中

在类型定义之后,我们定义了两个函数来初始化和销毁shared_state_t实例。它们可以被认为是类型shared_state_t构造函数析构函数。要了解更多关于构造函数和析构函数的信息,请参阅第六章面向对象编程和封装

这就是使用条件变量的方法。一个线程可以在条件变量上等待(或睡眠),然后在将来,它会被通知醒来。不仅如此,一个线程还可以通知(或唤醒)所有其他在条件变量上等待(或睡眠)的线程。所有这些操作必须由互斥锁保护,这就是为什么你应该始终将条件变量与互斥锁一起使用。

我们在前面代码中也做了同样的事情。在我们的共享状态对象中,我们有一个条件变量,以及一个应该保护条件变量的伴随互斥锁。再次强调,条件变量应该只在由其伴随互斥锁保护的临界区中使用。

那么,前面的代码中发生了什么?在应该打印 A 的线程中,它尝试使用指向共享状态对象的指针来锁定 mtx 互斥锁。当锁被获取后,线程打印 A,设置标志 done,并最终通过调用 pthread_cond_signal 函数通知其他线程,该线程可能正在等待条件变量 cv

另一方面,如果在此时第二个线程变得活跃,而第一个线程还没有打印 A,第二个线程将尝试获取 mtx 上的锁。如果成功,它会检查标志 done,如果它是假的,这仅仅意味着第一个线程还没有进入其临界区(否则标志应该是真的)。因此,第二个线程在条件变量上等待,并通过调用 pthread_cond_wait 函数立即释放 CPU。

非常重要的是要注意,在等待条件变量时,相关的互斥锁被释放,其他线程可以继续。同样,在变得活跃并退出等待状态后,应该再次获取相关的互斥锁。对于条件变量的良好实践,你可以查看其他可能的交错情况。

注意

函数 pthread_cond_signal 只能用来通知单个线程。如果你要通知所有等待条件变量的线程,你必须使用 pthread_cond_broadcast 函数。我们很快就会给出一个例子。

但为什么我们使用 while 循环来检查标志 done,而不是一个简单的 if 语句呢?那是因为第二个线程可以由其他来源而不是仅仅由第一个线程通知。在这些情况下,如果线程在退出等待状态并再次变得活跃时能够获取其互斥锁,它可以检查循环的条件,如果条件尚未满足,它应该再次等待。在循环中等待条件变量是一种可接受的技术,直到其条件匹配我们等待的内容。

前面的解决方案也满足了内存可见性约束。正如我们在前面的章节中解释的,所有锁定和解锁操作都可能触发各个 CPU 核之间的内存一致性;因此,在标志 done 的不同缓存版本中看到的值总是最新且相同的。

在例子 15.2 和 16.1 中观察到的竞争条件问题(在没有控制机制的情况下),也可以使用 POSIX 障碍来解决。在下一节中,我们将讨论它们,并使用不同的方法重写 例子 16.1

POSIX 障碍

POSIX 屏障使用不同的方法来同步多个线程。就像一群人计划并行执行一些任务,并在某些时刻需要会合、重组并继续一样,线程(甚至进程)也可能发生类似的情况。有些线程完成任务更快,而有些则较慢。但是,可以有一个检查点(或会合点),所有线程都必须停止并等待其他线程加入。这些检查点可以通过使用POSIX 屏障来模拟。

以下代码使用屏障来解决在示例 16.1中看到的问题。作为提醒,在示例 16.1中,我们有两个线程。其中一个线程是打印 A,另一个线程是打印 B,我们希望无论各种交错如何,输出中总是先看到A然后是B

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
// The barrier object
pthread_barrier_t barrier;
void* thread_body_1(void* arg) {
  printf("A\n");
  // Wait for the other thread to join
  pthread_barrier_wait(&barrier);
  return NULL;
}
void* thread_body_2(void* arg) {
  // Wait for the other thread to join
  pthread_barrier_wait(&barrier);
  printf("B\n");
  return NULL;
}
int main(int argc, char** argv) {
  // Initialize the barrier object
  pthread_barrier_init(&barrier, NULL, 2);
  // The thread handlers
  pthread_t thread1;
  pthread_t thread2;
  // Create new threads
  int result1 = pthread_create(&thread1, NULL,
          thread_body_1, NULL);
  int result2 = pthread_create(&thread2, NULL,
          thread_body_2, NULL);
  if (result1 || result2) {
    printf("The threads could not be created.\n");
    exit(1);
  }
  // Wait for the threads to finish
  result1 = pthread_join(thread1, NULL);
  result2 = pthread_join(thread2, NULL);
  if (result1 || result2) {
    printf("The threads could not be joined.\n");
    exit(2);
  }
  // Destroy the barrier object
  pthread_barrier_destroy(&barrier);
  return 0;
}

代码框 16-4 [ExtremeC_examples_chapter16_1_barrier.c]:使用 POSIX 屏障解决示例 16.1 的解决方案

如您所见,前面的代码比我们使用条件变量编写的代码要小得多。使用 POSIX 屏障,在执行过程中的一些特定点同步一些线程会非常容易。

首先,我们声明了一个全局屏障对象,其类型为pthread_barrier_t。然后,在main函数内部,我们使用pthread_barrier_init函数初始化了屏障对象。

第一个参数是屏障对象的指针。第二个参数是屏障对象的自定义属性。由于我们传递了NULL,这意味着屏障对象将使用其属性的默认值进行初始化。第三个参数很重要;它是通过调用pthread_barrier_wait函数应该等待在同一个屏障对象上的线程数,只有在这之后,它们才会全部释放并被允许继续。

对于前面的例子,我们将其设置为 2。因此,只有当有两个线程在等待屏障对象时,它们才会被解锁并继续执行。其余的代码与前面的例子非常相似,并在上一节中进行了解释。

可以使用互斥锁和条件变量来实现屏障对象,就像我们在上一节中所做的那样。事实上,符合 POSIX 规范的操作系统在其系统调用接口中并不提供屏障这样的东西,而大多数实现都是使用互斥锁和条件变量来完成的。

这基本上是为什么像 macOS 这样的操作系统不提供 POSIX 屏障的实现。前面的代码在 macOS 机器上无法编译,因为 POSIX 屏障函数未定义。前面的代码已在 Linux 和 FreeBSD 上测试,并在两者上都能正常工作。因此,在使用屏障时要小心,因为使用它们会使你的代码的可移植性降低。

macOS 不提供 POSIX 屏障函数的事实仅仅意味着它是部分符合 POSIX 标准的,使用屏障的程序(当然,这是标准)无法在 macOS 机器上编译。这与 C 哲学相悖,即一次编写,到处编译

作为本节的最后一条注释,POSIX 屏障保证了内存可见性。与锁定和解锁操作类似,等待屏障确保在离开屏障点时,各个线程中相同变量的所有缓存版本都同步。

在下一节中,我们将给出一个关于信号量的示例。它们在并发开发中并不常用,但它们有自己的特殊用途。

一种特定的信号量类型,二进制信号量(可以互换地称为互斥锁),经常被使用,你已经在前面的章节中看到了许多相关的例子。

POSIX 信号量

在大多数情况下,互斥锁(或二进制信号量)足以同步访问共享资源的多个线程。这是因为,为了使读写操作顺序进行,一次只能允许一个线程进入临界区。这被称为互斥,因此称为“mutex”。

然而,在某些情况下,你可能希望有多个线程进入临界区并操作共享资源。这就是你应该使用通用信号量的场景。

在我们进入关于通用信号量的示例之前,让我们先举一个关于二进制信号量(或互斥锁)的例子。在这个例子中,我们不会使用pthread_mutex_*函数;相反,我们将使用sem_*函数,这些函数旨在公开与信号量相关的功能。

二进制信号量

以下代码是使用信号量解决示例 15.3的解决方案。提醒一下,它涉及两个线程;每个线程以不同的值递增共享整数。我们想要保护共享变量的数据完整性。注意,在以下代码中我们不会使用 POSIX 互斥锁:

#include <stdio.h>
#include <stdlib.h>
// The POSIX standard header for using pthread library
#include <pthread.h>
// The semaphores are not exposed through pthread.h
#include <semaphore.h>
// The main pointer addressing a semaphore object used
// to synchronize the access to the shared state.
sem_t *semaphore;
void* thread_body_1(void* arg) {
  // Obtain a pointer to the shared variable
  int* shared_var_ptr = (int*)arg;
  // Waiting for the semaphore
  sem_wait(semaphore);
  // Increment the shared variable by 1 by writing directly
  // to its memory address
  (*shared_var_ptr)++;
  printf("%d\n", *shared_var_ptr);
  // Release the semaphore
  sem_post(semaphore);
  return NULL;
}
void* thread_body_2(void* arg) {
  // Obtain a pointer to the shared variable
  int* shared_var_ptr = (int*)arg;
  // Waiting for the semaphore
  sem_wait(semaphore);
  // Increment the shared variable by 1 by writing directly
  // to its memory address
  (*shared_var_ptr) += 2;
  printf("%d\n", *shared_var_ptr);
  // Release the semaphore
  sem_post(semaphore);
  return NULL;
}
int main(int argc, char** argv) {
  // The shared variable
  int shared_var = 0;
  // The thread handlers
  pthread_t thread1;
  pthread_t thread2;
#ifdef __APPLE__
  // Unnamed semaphores are not supported in OS/X. Therefore
  // we need to initialize the semaphore like a named one using
  // sem_open function.
  semaphore = sem_open("sem0", O_CREAT | O_EXCL, 0644, 1);
#else
  sem_t local_semaphore;
  semaphore = &local_semaphore;
  // Initiliaze the semaphore as a mutex (binary semaphore)
  sem_init(semaphore, 0, 1);
#endif
  // Create new threads
  int result1 = pthread_create(&thread1, NULL,
          thread_body_1, &shared_var);
  int result2 = pthread_create(&thread2, NULL,
          thread_body_2, &shared_var);
  if (result1 || result2) {
    printf("The threads could not be created.\n");
    exit(1);
  }
  // Wait for the threads to finish
  result1 = pthread_join(thread1, NULL);
  result2 = pthread_join(thread2, NULL);
  if (result1 || result2) {
    printf("The threads could not be joined.\n");
    exit(2);
  }
#ifdef __APPLE__
  sem_close(semaphore);
#else
  sem_destroy(semaphore);
#endif
  return 0;
}

代码框 16-5 [ExtremeC_examples_chapter15_3_sem.c]:使用 POSIX 信号量解决示例 15.3 的解决方案

你可能会首先注意到前面代码中我们使用的不同信号量函数。在 Apple 操作系统(macOS、OS X 和 iOS)中,未命名信号量不受支持。因此,我们无法直接使用sem_initsem_destroy函数。未命名信号量没有名称(令人惊讶的是),它们只能在进程内部,由多个线程使用。另一方面,命名信号量是系统范围的,可以在系统中的各个进程中看到和使用。

在 Apple 系统中,创建未命名信号量所需的函数已被标记为已弃用,并且信号量对象不会被sem_init初始化。因此,我们不得不使用sem_opensem_close函数来定义命名信号量。

命名信号量用于同步进程,我们将在第十八章进程同步中解释它们。在其他 POSIX 兼容的操作系统上,特别是 Linux,我们仍然可以使用无名称信号量,并使用sem_initsem_destroy函数分别初始化和销毁它们。

在前面的代码中,我们包含了一个额外的头文件,semaphore.h。正如我们之前解释的,信号量作为 POSIX 线程库的扩展被添加,因此它们不是作为pthread.h头文件的一部分公开。

在头文件包含语句之后,我们声明了一个指向信号量对象的全局指针。这个指针将指向一个适当的地址,该地址指向实际的信号量对象。在这里我们必须使用指针,因为在 Apple 系统中,我们必须使用sem_open函数,该函数返回一个指针。

然后,在main函数内部,在 Apple 系统中,我们创建了一个命名的信号量sem0。在其他 POSIX 兼容的操作系统上,我们使用sem_init初始化信号量。请注意,在这种情况下,指针semaphore指向在主线程栈上分配的变量local_semaphore。由于主线程不会退出并等待线程通过连接它们来完成,因此semaphore指针不会成为悬空指针。

注意,我们可以通过使用宏__APPLE__来区分 Apple 和非 Apple 系统。这是一个在 Apple 系统中默认由 C 预处理器定义的宏。因此,我们可以通过使用这个宏来排除不应该在 Apple 系统上编译的代码。

让我们看看线程内部的情况。在伴随函数中,关键部分由sem_waitsem_post函数保护,这些函数分别对应于 POSIX 互斥锁 API 中的pthread_mutex_lockpthread_mutex_unlock函数。请注意,sem_wait可能允许多个线程进入关键部分。

允许在关键部分中的最大线程数是在初始化信号量对象时确定的。我们将1作为最大线程数传递给sem_opensem_init函数的最后一个参数;因此,信号量应该表现得像互斥锁。

为了更好地理解信号量,让我们更深入地探讨一下细节。每个信号量对象都有一个整数值。每当一个线程通过调用sem_wait函数等待信号量时,如果信号量的值大于零,则该值减 1,线程被允许进入关键部分。如果信号量的值为 0,线程必须等待直到信号量的值再次变为正数。每当一个线程通过调用sem_post函数退出关键部分时,信号量的值增加 1。因此,通过指定初始值1,我们最终会得到一个二进制信号量。

我们通过调用sem_destroy(或在 Apple 系统中使用sem_close)来结束前面的代码,这实际上释放了信号量对象及其所有底层资源。至于命名信号量,由于它们可以在多个进程之间共享,关闭信号量时可能会出现更复杂的场景。我们将在第十八章进程同步中讨论这些场景。

通用信号量

现在,是时候给出一个使用通用信号量的经典例子了。其语法与前面的代码非常相似,但允许多个线程进入临界区的场景可能很有趣。

这个经典例子涉及了 50 个水分子的创建。对于 50 个水分子,你需要有 50 个氧原子和 100 个氢原子。如果我们用线程模拟每个原子,我们需要两个氢线程和一个氧线程进入它们的临界区,以便生成一个水分子并对其进行计数。

在下面的代码中,我们首先创建了 50 个氧线程和 100 个氢线程。为了保护氧线程的临界区,我们使用互斥锁,但对于氢线程的临界区,我们使用允许两个线程同时进入临界区的通用信号量。

对于信号量,我们使用 POSIX 屏障,但由于屏障在 Apple 系统中没有实现,我们需要使用互斥锁和条件变量来实现它们。下面的代码框包含了相应的代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <errno.h> // For errno and strerror function
// The POSIX standard header for using pthread library
#include <pthread.h>
// Semaphores are not exposed through pthread.h
#include <semaphore.h>
#ifdef __APPLE__
// In Apple systems, we have to simulate the barrier functionality.
pthread_mutex_t barrier_mutex;
pthread_cond_t  barrier_cv;
unsigned int    barrier_thread_count;
unsigned int    barrier_round;
unsigned int    barrier_thread_limit;
void barrier_wait() {
  pthread_mutex_lock(&barrier_mutex);
  barrier_thread_count++;
  if (barrier_thread_count >= barrier_thread_limit) {
    barrier_thread_count = 0;
    barrier_round++;
    pthread_cond_broadcast(&barrier_cv);
  } else {
    unsigned int my_round = barrier_round;
    do {
      pthread_cond_wait(&barrier_cv, &barrier_mutex);
    } while (my_round == barrier_round);
  }
  pthread_mutex_unlock(&barrier_mutex);
}
#else
// A barrier to make hydrogen and oxygen threads synchronized
pthread_barrier_t water_barrier;
#endif
// A mutex in order to synchronize oxygen threads
pthread_mutex_t   oxygen_mutex;
// A general semaphore to make hydrogen threads synchronized
sem_t*            hydrogen_sem;
// A shared integer counting the number of made water molecules
unsigned int      num_of_water_molecules;
void* hydrogen_thread_body(void* arg) {
  // Two hydrogen threads can enter this critical section
  sem_wait(hydrogen_sem);
  // Wait for the other hydrogen thread to join
#ifdef __APPLE__
  barrier_wait();
#else
  pthread_barrier_wait(&water_barrier);
#endif
  sem_post(hydrogen_sem);
  return NULL;
}
void* oxygen_thread_body(void* arg) {
  pthread_mutex_lock(&oxygen_mutex);
  // Wait for the hydrogen threads to join
#ifdef __APPLE__
  barrier_wait();
#else
  pthread_barrier_wait(&water_barrier);
#endif
  num_of_water_molecules++;
  pthread_mutex_unlock(&oxygen_mutex);
  return NULL;
}
int main(int argc, char** argv) {
  num_of_water_molecules = 0;
  // Initialize oxygen mutex
  pthread_mutex_init(&oxygen_mutex, NULL);
  // Initialize hydrogen semaphore
#ifdef __APPLE__
  hydrogen_sem = sem_open("hydrogen_sem",
          O_CREAT | O_EXCL, 0644, 2);
#else
  sem_t local_sem;
  hydrogen_sem = &local_sem;
  sem_init(hydrogen_sem, 0, 2);
#endif
  // Initialize water barrier
#ifdef __APPLE__
  pthread_mutex_init(&barrier_mutex, NULL);
  pthread_cond_init(&barrier_cv, NULL);
  barrier_thread_count = 0;
  barrier_thread_limit = 0;
  barrier_round = 0;
#else
  pthread_barrier_init(&water_barrier, NULL, 3);
#endif
  // For creating 50 water molecules, we need 50 oxygen atoms and
  // 100 hydrogen atoms
  pthread_t thread[150];
  // Create oxygen threads
  for (int i = 0; i < 50; i++) {
    if (pthread_create(thread + i, NULL,
                oxygen_thread_body, NULL)) {
      printf("Couldn't create an oxygen thread.\n");
      exit(1);
    }
  }
  // Create hydrogen threads
  for (int i = 50; i < 150; i++) {
    if (pthread_create(thread + i, NULL,
                hydrogen_thread_body, NULL)) {
      printf("Couldn't create an hydrogen thread.\n");
      exit(2);
    }
  }
  printf("Waiting for hydrogen and oxygen atoms to react ...\n");
  // Wait for all threads to finish
  for (int i = 0; i < 150; i++) {
    if (pthread_join(thread[i], NULL)) {
      printf("The thread could not be joined.\n");
      exit(3);
    }
  }
  printf("Number of made water molecules: %d\n",
          num_of_water_molecules);
#ifdef __APPLE__
  sem_close(hydrogen_sem);
#else
  sem_destroy(hydrogen_sem);
#endif
  return 0;
}

代码框 16-6 [ExtremeC_examples_chapter16_2.c]:使用通用信号量模拟从 50 个氧原子和 100 个氢原子中创建 50 个水分子的过程

在代码的开始部分,有一些被#ifdef __APPLE__#endif包围的行。这些行只在 Apple 系统中编译。这些行主要是模拟 POSIX 屏障行为的实现和变量。在其他除了 Apple 之外的 POSIX 兼容系统中,我们使用普通的 POSIX 屏障。在这里我们不会深入讲解 Apple 系统中屏障实现的细节,但阅读并彻底理解代码是很有价值的。

作为前面代码中定义的多个全局变量的一部分,我们声明了互斥锁oxygen_mutex,它应该保护氧线程的临界区。在任何时候,只有一个氧线程(或氧原子)可以进入临界区。

然后在它的临界区中,一个氧线程等待两个其他氢线程加入,然后它继续增加水分子计数器。增加操作发生在氧的临界区内部。

为了更详细地解释在关键部分内部发生的事情,我们需要解释通用信号量的作用。在前面的代码中,我们已声明了通用信号量 hydrogen_sem,它应该用来保护氢线程的关键部分。在任何时候,最多只能有两个氢线程进入它们的关键部分,并且它们在氧气和氢线程之间共享的屏障对象上等待。

当等待在共享屏障对象上的线程数量达到两个时,这意味着我们有一个氧原子和两个氢原子,然后 voilà:一个水分子就形成了,所有等待的线程都可以继续。氢线程立即退出,但氧线程只有在增加水分子计数器之后才会退出。

我们以这个最后的笔记结束本节。在 示例 16.2 中,我们在为苹果系统实现屏障时使用了 pthread_cond_broadcast 函数。它向所有等待屏障条件变量的线程发出信号,这些线程在其他线程加入后应该继续执行。

在下一节中,我们将讨论 POSIX 线程背后的内存模型以及它们如何与它们所属进程的内存交互。我们还将查看使用栈和堆段的示例以及它们如何导致一些严重的内存相关问题。

POSIX 线程和内存

本节将讨论线程与进程内存之间的交互。正如你所知,进程的内存布局中有多个段。文本段、栈段、数据段和堆段都是这个内存布局的一部分,我们在 第四章进程内存结构 中讨论了它们。线程与这些内存段中的每一个都有不同的交互。作为本节的一部分,我们只讨论栈和堆内存区域,因为它们是在编写多线程程序时最常用且最容易出现问题的区域。

此外,我们讨论了线程同步以及真正理解线程背后的内存模型如何帮助我们开发更好的并发程序。这些概念在堆内存方面尤为明显,因为那里的内存管理是手动的,并且在并发系统中,线程负责分配和释放堆块。一个简单的竞态条件可能导致严重的内存问题,因此必须实施适当的同步以避免此类灾难。

在下一小节中,我们将解释不同的线程如何访问栈段以及应该采取哪些预防措施。

栈内存

每个线程都有自己的栈区域,这个区域应该是仅对该线程私有的。线程的栈区域是所属进程的栈段的一部分,并且默认情况下,所有线程都应该从栈段分配其栈区域。也有可能线程有一个从堆段分配的栈区域。我们将在未来的例子中展示如何做到这一点,但到目前为止,我们假设线程的栈是进程的栈段的一部分。

由于同一进程中的所有线程都可以读取和修改进程的栈段,因此它们可以有效地读取和修改彼此的栈区域,但它们不应该这样做。请注意,与其他线程的栈区域一起工作被认为是一种危险的行为,因为定义在各个栈区域顶部的变量可能随时被释放,尤其是在线程退出或函数返回时。

正是因为这个原因,我们试图假设一个栈区域只能被其所属线程访问,而不能被其他线程访问。因此,局部变量(那些在栈顶部声明的变量)被认为是线程私有的,不应该被其他线程访问。

在单线程应用程序中,我们始终只有一个线程,即主线程。因此,我们像使用进程的栈段一样使用其栈区域。这是因为,在单线程程序中,主线程和进程本身之间没有界限。但对于多线程程序来说,情况就不同了。每个线程都有自己的栈区域,这个区域与其他线程的栈区域不同。

在创建新线程时,会为栈区域分配一个内存块。如果程序员在创建时没有指定,栈区域将具有默认的栈大小,并且它将从进程的栈段中分配。默认栈大小是平台相关的,并且因架构而异。您可以使用命令ulimit -s在 POSIX 兼容系统中检索默认栈大小。

在我当前的平台上,这是一个基于 Intel 64 位机器的 macOS,默认栈大小是 8 MB:

$ ulimit -s
8192
$

Shell Box 16-1:读取默认栈大小

POSIX 线程 API 允许你为新的线程设置栈区域。在下面的例子,示例 16.3中,我们有两个线程。对于其中一个线程,我们使用默认的栈设置,而对于另一个线程,我们将从堆段分配一个缓冲区并将其设置为该线程的栈区域。请注意,在设置栈区域时,分配的缓冲区应该有一个最小大小;否则它不能用作栈区域:

#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <pthread.h>
void* thread_body_1(void* arg) {
  int local_var = 0;
  printf("Thread1 > Stack Address: %p\n", (void*)&local_var);
  return 0;
}
void* thread_body_2(void* arg) {
  int local_var = 0;
  printf("Thread2 > Stack Address: %p\n", (void*)&local_var);
  return 0;
}
int main(int argc, char** argv) {
  size_t buffer_len = PTHREAD_STACK_MIN + 100;
  // The buffer allocated from heap to be used as
  // the thread's stack region
  char *buffer = (char*)malloc(buffer_len * sizeof(char));
  // The thread handlers
  pthread_t thread1;
  pthread_t thread2;
  // Create a new thread with default attributes
  int result1 = pthread_create(&thread1, NULL,
          thread_body_1, NULL);
  // Create a new thread with a custom stack region
  pthread_attr_t attr;
  pthread_attr_init(&attr);
  // Set the stack address and size
  if (pthread_attr_setstack(&attr, buffer, buffer_len)) {
    printf("Failed while setting the stack attributes.\n");
    exit(1);
  }
  int result2 = pthread_create(&thread2, &attr,
          thread_body_2, NULL);
  if (result1 || result2) {
    printf("The threads could not be created.\n");
    exit(2);
  }
  printf("Main Thread > Heap Address: %p\n", (void*)buffer);
  printf("Main Thread > Stack Address: %p\n", (void*)&buffer_len);
  // Wait for the threads to finish
  result1 = pthread_join(thread1, NULL);
  result2 = pthread_join(thread2, NULL);
  if (result1 || result2) {
    printf("The threads could not be joined.\n");
    exit(3);
  }
  free(buffer);
  return 0;
}

Code Box 16-7 [ExtremeC_examples_chapter16_3.c]:将堆块设置为线程的栈区域

要启动程序,我们使用默认的堆栈设置创建第一个线程。因此,其堆栈应该从进程的堆栈段分配。之后,我们通过指定一个缓冲区的内存地址来创建第二个线程,该缓冲区应作为线程的堆栈区域。

注意,指定的尺寸比由PTHREAD_STACK_MIN宏指示的已定义最小堆栈大小多100字节。这个常量在不同的平台上有不同的值,它包含在头文件limits.h中。

如果你构建前面的程序并在 Linux 设备上运行它,你将看到以下类似的内容:

$ gcc ExtremeC_examples_chapter16_3.c -o ex16_3.out -lpthread
$ ./ex16_3.out
Main Thread > Heap Address: 0x55a86a251260
Main Thread > Stack Address: 0x7ffcb5794d50
Thread2 > Stack Address: 0x55a86a2541a4
Thread1 > Stack Address: 0x7fa3e9216ee4
$

Shell Box 16-2:构建和运行示例 16.3

Shell Box 16-2中看到的输出所示,分配在第二个线程堆栈顶部的局部变量local_var的地址属于不同的地址范围(堆空间的范围)。这意味着第二个线程的堆栈区域在堆内。然而,这并不适用于第一个线程。

如输出所示,第一个线程中局部变量的地址位于进程堆栈段的地址范围内。因此,我们可以成功地为新创建的线程分配一个从堆段分配的新堆栈区域。

设置线程堆栈区域的能力在某些用例中可能至关重要。例如,在内存受限的环境中,由于总内存量低,无法拥有大的堆栈,或者在性能要求高的环境中,无法容忍为每个线程分配堆栈的成本,使用一些预分配的缓冲区可能很有用,并且可以使用前面的过程将预分配的缓冲区设置为新创建线程的堆栈区域。

以下示例演示了在某个线程的堆栈中共享一个地址如何导致一些内存问题。当一个线程的地址被共享时,该线程应该保持活动状态,否则所有保持该地址的指针都将悬空。

以下代码不是线程安全的,因此我们预计在连续运行中会不时出现崩溃。线程也有默认的堆栈设置,这意味着它们的堆栈区域是从进程的堆栈段分配的:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
int* shared_int;
void* t1_body(void* arg) {
  int local_var = 100;
  shared_int = &local_var;
  // Wait for the other thread to print the shared integer
  usleep(10);
  return NULL;
}
void* t2_body(void* arg) {
  printf("%d\n", *shared_int);
  return NULL;
}
int main(int argc, char** argv) {
  shared_int = NULL;
  pthread_t t1;
  pthread_t t2;
  pthread_create(&t1, NULL, t1_body, NULL);
  pthread_create(&t2, NULL, t2_body, NULL);
  pthread_join(t1, NULL);
  pthread_join(t2, NULL);
  return 0;
}

Code Box 16-8 [ExtremeC_examples_chapter16_4.c]:尝试读取从另一个线程的堆栈区域分配的变量

在开始时,我们声明了一个全局共享指针。由于它是一个指针,它可以接受任何地址,无论该地址指向进程内存布局中的哪个位置。它可能来自堆栈段、堆段,甚至是数据段。

在前面的代码中,在t1_body伴随函数内部,我们将局部变量的地址存储在共享指针中。这个变量属于第一个线程,并且它是在第一个线程的堆栈顶部分配的。

从现在开始,如果第一个线程退出,共享指针就会变成悬垂指针,任何解引用可能都会导致崩溃、逻辑错误或最坏情况下的隐藏内存问题。在某些交错中,这可能会发生,如果你多次运行前面的程序,你可能会时不时地看到崩溃。

作为一个重要的注意事项,如果某个线程打算使用从另一个线程的栈区域分配的变量,应该采用适当的同步技术。由于栈变量的生命周期与其作用域绑定,同步应该旨在保持作用域活跃,直到消费者线程完成对该变量的使用。

注意,为了简单起见,我们没有检查 pthread 函数的结果。始终建议这样做并检查返回值。并非所有 pthread 函数在不同平台上的行为都相同;如果出现问题,通过检查返回值你会意识到这一点。

在本节中,一般来说,我们展示了为什么栈区域所属的地址不应该共享,以及为什么最好不要从栈区域分配共享状态。下一节将讨论堆内存,这是存储共享状态最常见的地方。正如你可能已经猜到的,与堆一起工作也很棘手,你应该小心内存泄漏。

堆内存

堆段和数据段对所有线程都是可访问的。与在编译时生成的数据段不同,堆段是动态的,它在运行时形成。线程可以读取和修改堆的内容。此外,堆的内容可以持续到进程的生命周期,并且与单个线程的生命周期独立。此外,大对象可以放入堆中。所有这些因素共同导致堆成为存储将要由一些线程共享的状态的绝佳地方。

当涉及到堆分配时,内存管理变得像噩梦一样,这是因为分配的内存应该在某个时刻由运行中的某个线程释放,否则可能会导致内存泄漏。

关于并发环境,交错很容易产生悬垂指针;因此会出现崩溃。同步的关键作用是将事物置于特定的顺序,这样就不会产生悬垂指针,这是难点。

让我们来看以下示例,示例 16.5。在这个例子中有五个线程。第一个线程从堆中分配一个数组。第二个和第三个线程以这种形式填充数组。第二个线程将数组中的偶数索引填充为大写字母,从Z开始向后移动到A,第三个线程将奇数索引填充为小写字母,从a开始向前移动到z。第四个线程打印数组。最后,第五个线程释放数组并回收堆内存。

为了防止这些线程在堆空间中表现不当,应使用前几节中描述的所有关于 POSIX 并发控制的技巧。以下代码没有设置任何控制机制,显然,它不是线程安全的。请注意,代码并不完整。带有并发控制机制的完整版本将在下一个代码块中给出:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#define CHECK_RESULT(result) \
if (result) { \
  printf("A pthread error happened.\n"); \
  exit(1); \
}
int TRUE = 1;
int FALSE = 0;
// The pointer to the shared array
char* shared_array;
// The size of the shared array
unsigned int shared_array_len;
void* alloc_thread_body(void* arg) {
  shared_array_len = 20;
  shared_array = (char*)malloc(shared_array_len * sizeof(char*));
  return NULL;
}
void* filler_thread_body(void* arg) {
  int even = *((int*)arg);
  char c = 'a';
  size_t start_index = 1;
  if (even) {
    c = 'Z';
    start_index = 0;
  }
  for (size_t i = start_index; i < shared_array_len; i += 2) {
    shared_array[i] = even ? c-- : c++;
  }
  shared_array[shared_array_len - 1] = '\0';
  return NULL;
}
void* printer_thread_body(void* arg) {
  printf(">> %s\n", shared_array);
  return NULL;
}
void* dealloc_thread_body(void* arg) {
  free(shared_array);
  return NULL;
}
int main(int argc, char** argv) {
  … Create threads ...
}

代码框 16-9 [ExtremeC_examples_chapter16_5_raw.c]:没有同步机制的 16.5 示例

很容易看出,前面的代码不是线程安全的,并且由于分配器线程在释放数组时发生干扰,导致严重的崩溃。

当分配器线程获得 CPU 时,它会立即释放堆分配的缓冲区,之后指针 shared_array 变得悬空,其他线程开始崩溃。应使用适当的同步技术来确保分配器线程最后运行,并且不同线程的逻辑顺序正确。

在以下代码块中,我们使用 POSIX 并发控制对象装饰前面的代码,使其线程安全:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#define CHECK_RESULT(result) \
if (result) { \
  printf("A pthread error happened.\n"); \
  exit(1); \
}
int TRUE = 1;
int FALSE = 0;
// The pointer to the shared array
char* shared_array;
// The size of the shared array
size_t shared_array_len;
pthread_barrier_t alloc_barrier;
pthread_barrier_t fill_barrier;
pthread_barrier_t done_barrier;
void* alloc_thread_body(void* arg) {
  shared_array_len = 20;
  shared_array = (char*)malloc(shared_array_len * sizeof(char*));
  pthread_barrier_wait(&alloc_barrier);
  return NULL;
}
void* filler_thread_body(void* arg) {
  pthread_barrier_wait(&alloc_barrier);
  int even = *((int*)arg);
  char c = 'a';
  size_t start_index = 1;
  if (even) {
    c = 'Z';
    start_index = 0;
  }
  for (size_t i = start_index; i < shared_array_len; i += 2) {
    shared_array[i] = even ? c-- : c++;
  }
  shared_array[shared_array_len - 1] = '\0';
  pthread_barrier_wait(&fill_barrier);
  return NULL;
}
void* printer_thread_body(void* arg) {
  pthread_barrier_wait(&fill_barrier);
  printf(">> %s\n", shared_array);
  pthread_barrier_wait(&done_barrier);
  return NULL;
}
void* dealloc_thread_body(void* arg) {
  pthread_barrier_wait(&done_barrier);
  free(shared_array);
  pthread_barrier_destroy(&alloc_barrier);
  pthread_barrier_destroy(&fill_barrier);
  pthread_barrier_destroy(&done_barrier);
  return NULL;
}
int main(int argc, char** argv) {
  shared_array = NULL;
  pthread_barrier_init(&alloc_barrier, NULL, 3);
  pthread_barrier_init(&fill_barrier, NULL, 3);
  pthread_barrier_init(&done_barrier, NULL, 2);
  pthread_t alloc_thread;
  pthread_t even_filler_thread;
  pthread_t odd_filler_thread;
  pthread_t printer_thread;
  pthread_t dealloc_thread;
  pthread_attr_t attr;
  pthread_attr_init(&attr);
  int res = pthread_attr_setdetachstate(&attr,
          PTHREAD_CREATE_DETACHED);
  CHECK_RESULT(res);
  res = pthread_create(&alloc_thread, &attr,
          alloc_thread_body, NULL);
  CHECK_RESULT(res);
  res = pthread_create(&even_filler_thread,
          &attr, filler_thread_body, &TRUE);
  CHECK_RESULT(res);
  res = pthread_create(&odd_filler_thread,
          &attr, filler_thread_body, &FALSE);
  CHECK_RESULT(res);
  res = pthread_create(&printer_thread, &attr,
          printer_thread_body, NULL);
  CHECK_RESULT(res);
  res = pthread_create(&dealloc_thread, &attr,
          dealloc_thread_body, NULL);
  CHECK_RESULT(res);
  pthread_exit(NULL);
  return 0;
}

代码框 16-10 [ExtremeC_examples_chapter16_5.c]:带有同步机制的 16.5 示例

为了使 代码框 16-9 中的代码线程安全,我们只使用了新的代码中的 POSIX 障碍。这是在多个线程之间形成顺序执行顺序的最简单方法。

如果你比较 代码框 16-916-10,你会看到 POSIX 障碍是如何在各个线程之间强加顺序的。唯一的例外是在两个填充线程之间。填充线程可以独立运行而不会相互阻塞,并且由于它们分别改变奇数和偶数索引,不会引发并发问题。请注意,前面的代码不能在苹果系统上编译。你需要在这些系统中使用互斥锁和条件变量来模拟障碍行为(就像我们在 示例 16.2 中做的那样)。

以下是对应代码的输出。无论你运行程序多少次,它都不会崩溃。换句话说,前面的代码可以防止各种交错,并且是线程安全的:

$ gcc ExtremeC_examples_chapter16_5.c -o ex16_5 -lpthread
$ ./ex16_5
>> ZaYbXcWdVeUfTgShRiQ
$ ./ex16_5
>> ZaYbXcWdVeUfTgShRiQ
$

脚本框 16-3:构建和运行 16.5 示例

在本节中,我们给出了使用堆空间作为共享状态占位符的示例。与自动进行内存释放的栈内存不同,堆空间的内存释放应显式执行。否则,内存泄漏将是一个不可避免的副作用。

从程序员最少的内存管理努力的角度来看,最容易且有时也是最佳的可共享状态存储位置是数据段,其中分配和释放都是自动发生的。位于数据段中的变量被认为是全局的,并且具有可能的最长生命周期,从进程诞生的最初时刻到其最后的时刻。但这个长生命周期在某些用例中可能被视为负面因素,尤其是在你打算在数据段中保持一个大对象时。

在下一节中,我们将讨论内存可见性以及 POSIX 函数如何保证这一点。

内存可见性

我们在上一章中解释了内存可见性缓存一致性,涉及具有多个 CPU 核心的系统。在本节中,我们想看看 pthread 库,看看它是如何保证内存可见性的。

如你所知,CPU 核心之间的缓存一致性协议确保所有 CPU 核心中单个内存地址的所有缓存版本都保持同步并更新,以反映其中一个 CPU 核心所做的最新更改。但这个协议需要以某种方式触发。

系统调用接口中存在 API 来触发缓存一致性协议,并使内存对所有 CPU 核心可见。在 pthread 中,也有许多函数在执行前保证内存可见性。

你可能之前遇到过一些这些函数。下面列出了它们的一些列表:

  • pthread_barrier_wait

  • pthread_cond_broadcast

  • pthread_cond_signal

  • pthread_cond_timedwait

  • pthread_cond_wait

  • pthread_create

  • pthread_join

  • pthread_mutex_lock

  • pthread_mutex_timedlock

  • pthread_mutex_trylock

  • pthread_mutex_unlock

  • pthread_spin_lock

  • pthread_spin_trylock

  • pthread_spin_unlock

  • pthread_rwlock_rdlock

  • pthread_rwlock_timedrdlock

  • pthread_rwlock_timedwrlock

  • pthread_rwlock_tryrdlock

  • pthread_rwlock_trywrlock

  • pthread_rwlock_unlock

  • pthread_rwlock_wrlock

  • sem_post

  • sem_timedwait

  • sem_trywait

  • sem_wait

  • semctl

  • semop

除了 CPU 核心中的本地缓存之外,编译器还可以为常用变量引入缓存机制。为了实现这一点,编译器需要分析代码并以一种方式优化它,即频繁使用的变量被写入和读取到编译器缓存中。这些是由编译器放入最终二进制文件中的软件缓存,以优化和提升程序的执行。

虽然这些缓存可能有益,但它们在编写多线程代码时可能会增加另一个头疼的问题,并引发一些内存可见性问题。因此,有时必须禁用特定变量的这些缓存。

不应该被编译器通过缓存优化的变量可以声明为 易失性。请注意,易失性变量仍然可以在 CPU 级别被缓存,但编译器不会通过将其保留在编译器缓存中来进行优化。可以使用关键字 volatile 声明一个易失性变量。以下是一个易失性整型变量的声明:

volatile int number;

代码框 16-11:声明一个易失性整型变量

易失性变量的重要之处在于它们并不能解决多线程系统中的内存可见性问题。为了解决这个问题,你需要正确地使用前面提到的 POSIX 函数,以确保内存可见性。

概述

在本章中,我们介绍了 POSIX 线程 API 提供的并发控制机制。我们讨论了:

  • POSIX 互斥锁及其使用方法

  • POSIX 条件变量和屏障及其使用方法

  • POSIX 信号量和它们如何与二进制信号量和通用信号量不同

  • 线程如何与栈区域交互

  • 如何为线程定义一个新的堆分配的栈区域

  • 线程如何与堆空间交互

  • 内存可见性和保证内存可见性的 POSIX 函数

  • 易失性变量和编译器缓存

在下一章中,我们将继续我们的讨论,我们将讨论在软件系统中实现并发性的另一种方法:多进程。我们将讨论进程的执行方式以及它与线程的不同之处。

第十七章

进程执行

现在我们已经准备好讨论由多个进程组成的整体架构的软件系统。这些系统通常被称为多进程或多个进程系统。本章以及下一章试图涵盖多进程的概念,并进行利弊分析,以便与我们在第十五章“线程执行”、第十六章“线程同步”中讨论的多线程进行比较。

在本章中,我们的重点是可用的 API 和技术来启动一个新的进程以及进程执行实际上是如何发生的,在下一章中,我们将探讨由多个进程组成的并发环境。我们将解释各种状态如何在多个进程之间共享,以及在多进程环境中访问共享状态的常见方式。

本章的一部分基于比较多进程和多线程环境。此外,我们还简要地讨论了单主机多进程系统和分布式多进程系统。

进程执行 API

每个程序都是以进程的形式执行的。在我们拥有进程之前,我们只有一个包含一些内存段和可能大量机器级指令的可执行二进制文件。相反,每个进程都是一个正在执行的程序的独立实例。因此,单个编译程序(或可执行二进制文件)可以通过不同的进程多次执行。事实上,这就是为什么我们关注的是进程,而不是程序本身。

在前两个章节中,我们讨论了单进程软件中的线程,但为了实现本章的目标,我们将讨论具有多个进程的软件。但首先,我们需要了解如何以及通过哪个 API 来生成一个新的进程。

注意,我们主要关注在类 Unix 操作系统中执行进程,因为它们都遵循 Unix 洋葱架构并公开非常知名且相似的 API。其他操作系统可能有它们自己执行进程的方式,但由于它们大多数或多或少遵循 Unix 洋葱架构,我们期望看到类似的过程执行方法。

在类 Unix 操作系统中,在系统调用级别执行进程的方法并不多。如果你还记得第十一章系统调用 与 内核中的内核环,它是在硬件环之后的内部环,它为外部环、shell用户提供系统调用接口,以便它们执行各种内核特定的功能。其中两个暴露的系统调用是专门用于进程创建和进程执行的;分别是forkexec(Linux 中的execve)。在进程创建中,我们创建一个新进程,但在进程执行中,我们使用一个现有进程作为宿主,并用一个新的程序替换它;因此,在进程执行中不会创建新的进程。

由于使用这些系统调用,程序总是以新进程的形式执行,但这个过程并不总是被创建!fork系统调用创建一个新进程,而exec系统调用则用一个新的进程替换调用者(宿主)进程。我们将在后面讨论forkexec系统调用的区别。在那之前,让我们看看这些系统调用是如何暴露给外部环的。

如我们在第十章Unix – 历史 与 架构中所述,我们有两个针对类 Unix 操作系统的标准,特别是关于它们应该从其 shell 环中暴露的接口。这些标准是单一 Unix 规范SUS)和POSIX。有关这些标准的更多信息,包括它们的相似之处和不同之处,请参阅第十章Unix – 历史 与 架构

应从 shell 环中暴露的接口在 POSIX 接口中得到了详细规定,实际上,标准中确实有部分内容涉及进程执行和进程管理。

因此,我们预计在 POSIX 中会找到用于进程创建和进程执行的头部和函数。这些函数确实存在,并且我们可以在提供所需功能的不同头部文件中找到它们。以下是负责进程创建和进程执行的 POSIX 函数列表:

  • 可以在unistd.h头文件中找到的fork函数负责进程创建。

  • 可以在spawn.h头文件中找到的posix_spawnposix_spawnp函数。这些函数负责进程创建。

  • 例如,在unistd.h头文件中可以找到的exec*函数组,如execlexeclp。这些函数负责进程执行。

注意,前面的函数不应与 forkexec 系统调用混淆。这些函数是来自 shell 环境暴露的 POSIX 接口的一部分,而系统调用则是来自内核环暴露的。虽然大多数 Unix-like 操作系统都是 POSIX 兼容的,但我们也可以有一个非 Unix-like 系统也是 POSIX 兼容的。那么,前面的函数存在于该系统中,但系统调用级别的进程创建的底层机制可能不同。

一个具体的例子是使用 Cygwin 或 MinGW 使 Microsoft Windows 兼容 POSIX。通过安装这些程序,你可以编写和编译使用 POSIX 接口的标准 C 程序,从而使 Microsoft Windows 部分兼容 POSIX,但在 Microsoft Windows 中没有 forkexec 系统调用!这实际上既令人困惑又非常重要,你应该知道 shell 环境并不一定暴露与内核环暴露相同的接口。

注意

你可以在 Cygwin 中找到 fork 函数的实现细节:https://github.com/openunix/cygwin/blob/master/winsup/cygwin/fork.cc。注意,它并没有调用通常存在于 Unix-like 内核中的 fork 系统调用;相反,它包含了 Win32 API 的头文件,并调用了一些关于进程创建和进程管理的知名函数。

根据 POSIX 标准,Unix-like 系统上的 shell 环境暴露出来的不仅仅是 C 标准库。当使用终端时,有一些预先编写的 shell 实用程序被用来提供复杂的 C 标准 API 的使用。关于进程创建,每当用户在终端中输入一个命令时,就会创建一个新的进程。

即使是简单的 lssed 命令也会启动一个新的进程,这个进程可能只持续不到一秒钟。你应该知道,这些实用程序大多是用 C 语言编写的,并且它们正在消耗与你在编写自己的程序时所使用的相同的精确 POSIX 接口。

Shell 脚本也是在单独的进程中执行的,但方式略有不同。我们将在未来的章节中讨论如何在 Unix-like 系统中执行进程。

进程创建发生在内核中,尤其是在单核内核中。每当用户进程启动一个新的进程或甚至一个新的线程时,请求会被系统调用接口接收,并传递到内核环。在那里,为传入的请求创建一个新的 任务,无论是进程还是线程。

类似于 Linux 或 FreeBSD 这样的单核内核会跟踪内核内的任务(进程和线程),这使得在内核本身创建进程变得合理。

注意,每当内核中创建一个新的任务时,它会被放入 任务调度单元 的队列中,并且它可能需要一点时间才能获得 CPU 并开始执行。

为了创建一个新的进程,需要一个父进程。这就是为什么每个进程都有一个父进程。实际上,每个进程只能有一个父进程。父亲和祖父母的链条可以追溯到第一个用户进程,通常称为 init,而内核进程是其父进程。

它是 Unix-like 系统中所有其他进程的祖先,存在于系统关闭之前。通常,init 进程成为所有 孤儿进程 的父进程,这些进程的父进程已经终止,这样就不会有进程没有父进程。

这种父子关系最终会形成一个大的进程树。这个树可以通过命令工具 pstree 来检查。我们将在未来的示例中展示如何使用这个工具。

现在,我们知道了可以执行新进程的 API,我们需要给出一些实际的 C 语言示例来说明这些方法是如何实际工作的。我们首先从 fork API 开始,它最终调用 fork 系统调用。

进程创建

正如我们在上一节中提到的,fork API 可以用来创建一个新的进程。我们还解释了,新的进程只能作为正在运行进程的子进程来创建。在这里,我们看到了一些示例,展示了进程如何使用 fork API 来创建新的子进程。

为了创建一个新的子进程,父进程需要调用 fork 函数。fork 函数的声明可以从 unistd.h 头文件中包含,它是 POSIX 头文件的一部分。

当调用 fork 函数时,会创建调用进程(称为父进程)的一个精确副本,并且两个进程从 fork 调用语句之后的下一个指令开始并发运行。请注意,子进程(或被 fork 的进程)从父进程继承了包括所有内存段及其内容在内的大量内容。因此,它有权访问数据、堆栈和堆段中的相同变量,以及文本段中的程序指令。我们将在接下来的段落中讨论其他继承的内容,在讨论示例之后。

由于我们现在有两个不同的进程,fork 函数会返回两次;一次在父进程中,另一次在子进程中。此外,fork 函数对每个进程返回不同的值。它对子进程返回 0,对父进程返回 forked(或子)进程的 PID。示例 17.1 展示了 fork 在其最简单用法中的工作方式:

#include <stdio.h>
#include <unistd.h>
int main(int argc, char** argv) {
  printf("This is the parent process with process ID: %d\n",
          getpid());
  printf("Before calling fork() ...\n");
  pid_t ret = fork();
  if (ret) {
    printf("The child process is spawned with PID: %d\n", ret);
  } else {
    printf("This is the child process with PID: %d\n", getpid());
  }
  printf("Type CTRL+C to exit ...\n");
  while (1);
  return 0;
}

代码框 17-1 [ExtremeC_examples_chapter17_1.c]: 使用 fork API 创建子进程

在前面的代码框中,我们使用了 printf 来打印一些日志,以便跟踪进程的活动。正如你所见,我们调用了 fork 函数来创建一个新的进程。显然,它不接受任何参数,因此其使用非常简单直接。

在调用fork函数后,一个新的进程从调用进程(现在是父进程)中分叉(或克隆)出来,之后,它们作为两个不同的进程继续并发工作。

当然,对fork函数的调用将在系统调用级别上引发进一步的调用,然后,内核中的负责逻辑才能创建一个新的分叉进程。

return语句之前,我们使用了一个无限循环来保持两个进程同时运行并防止它们退出。请注意,进程最终应该达到这个无限循环,因为它们在文本段中具有完全相同的指令。

我们有意保持进程运行,以便能够在pstreetop命令显示的进程列表中看到它们。在此之前,我们需要编译前面的代码,看看新的进程是如何通过Shell Box 17-1进行分叉的:

$ gcc ExtremeC_examples_chapter17_1.c -o ex17_1.out
$ ./ex17_1.out
This is the parent process with process ID: 10852
Before calling fork() …
The child process is spawned with PID: 10853
This is the child process with PID: 10853
Type CTRL+C to exit ...
$

Shell Box 17-1:构建和运行示例 17.1

如你所见,父进程打印其 PID,那是10852。请注意,PID 将在每次运行时改变。在分叉子进程后,父进程打印fork函数返回的 PID,它是10853

在下一行,子进程打印其 PID,再次是10853,这与父进程从fork函数接收到的相符。最后,两个进程都进入无限循环,给我们一些时间在探测工具中观察它们。

如你在Shell Box 17-1中看到的那样,分叉进程从其父进程继承了相同的stdout文件描述符和相同的终端。因此,它可以打印到其父进程写入的相同输出。分叉进程从其父进程继承了在fork函数调用时的所有打开文件描述符。

此外,还有其他继承属性,可以在fork的手册页中找到。Linux 的fork手册页可以在以下链接中找到:http://man7.org/linux/man-pages/man2/fork.2.html。

如果你打开链接并查看属性,你会看到有一些属性是父进程和分叉进程之间共享的,还有一些属性是不同的,并且针对每个进程,例如,PID、父 PID、线程等。

使用像pstree这样的实用程序可以很容易地看到进程之间的父子关系。每个进程都有一个父进程,所有进程共同构建了一个大树。请记住,每个进程只有一个父进程,一个进程不能有两个父进程。

尽管前一个例子中的过程陷入了无限循环,但我们可以使用pstree实用命令来查看系统中所有进程的列表,这些进程以树状结构显示。以下是在 Linux 机器上使用pstree的输出。请注意,pstree命令默认安装在 Linux 系统上,但在其他类 Unix 操作系统中可能需要安装:

$ pstree -p
systemd(1)─┬─accounts-daemon(877)─┬─{accounts-daemon}(960)
           │                      └─{accounts-daemon}(997)
...
...
...
           ├─systemd-logind(819)
           ├─systemd-network(673)
           ├─systemd-resolve(701)
           ├─systemd-timesyn(500)───{systemd-timesyn}(550)
           ├─systemd-udevd(446)
           └─tmux: server(2083)─┬─bash(2084)───pstree(13559)
                                └─bash(2337)───ex17_1.out(10852)───ex17_1.out(10853)
$

Shell 框 17-2:使用pstree查找作为示例 17.1 一部分生成的进程

Shell 框 17-2的最后行所示,我们有两个进程,其 PID 分别为1085210853,它们处于父子关系。请注意,进程10852的父进程 PID 为2337,这是一个bash进程。

有趣的是,在最后一行之前的一行中,我们可以看到pstree进程本身作为 PID 为2084的 bash 进程的子进程。这两个 bash 进程都属于同一个 PID 为2083tmux终端模拟器。

在 Linux 中,第一个进程是调度器进程,它是内核镜像的一部分,其 PID 为 0。下一个进程,通常称为init,其 PID 为 1,它是调度器进程创建的第一个用户进程。它从系统启动存在直到系统关闭。所有其他用户进程都是init进程的直接或间接子进程。失去父进程的进程成为孤儿进程,它们被init进程作为其直接子进程收养。

然而,在几乎所有著名 Linux 发行版的较新版本中,init进程已被systemd 守护进程取代,这就是为什么您在Shell 框 17-2的第一行看到systemd(1)的原因。以下链接是一个很好的资源,可以阅读更多关于initsystemd之间的差异以及为什么 Linux 发行版开发者做出了这样的决定:www.tecmint.com/systemd-replaces-init-in-linux

当使用forkAPI 时,父进程和派生进程是并发执行的。这意味着我们应该能够检测到并发系统的某些行为。

可以观察到的最著名的行为是一些交错。如果您不熟悉这个术语或者以前没有听说过,强烈建议您阅读第十三章并发,和第十四章同步

以下示例,示例 17.2,展示了父进程和派生进程可以具有非确定性的交错。我们将打印一些字符串,并观察在两次连续运行中可能发生的各种交错:

#include <stdio.h>
#include <unistd.h>
int main(int argc, char** argv) {
  pid_t ret = fork();
  if (ret) {
    for (size_t i = 0; i < 5; i++) {
      printf("AAA\n");
      usleep(1);
    }
  } else {
    for (size_t i = 0; i < 5; i++) {
      printf("BBBBBB\n");
      usleep(1);
    }
  }
  return 0;
}

代码框 17-2 [ExtremeC_examples_chapter17_2.c]:两个进程向标准输出打印一些行

上述代码与我们为示例 17.1编写的代码非常相似。它创建了一个分支进程,然后父进程和分支进程向标准输出打印一些文本行。父进程打印AAA五次,分支进程打印BBBBBB五次。以下是对同一编译可执行文件连续两次运行的输出:

$ gcc ExtremeC_examples_chapter17_2.c -o ex17_2.out
$ ./ex17_2.out
AAA
AAA
AAA
AAA
AAA
BBBBBB
BBBBBB
BBBBBB
BBBBBB
BBBBBB
$ ./ex17_2.out
AAA
AAA
BBBBBB
AAA
AAA
BBBBBB
BBBBBB
BBBBBB
AAA
BBBBBB
$

Shell 框 17-3:示例 17.2 连续两次运行的输出

从前面的输出中很明显,我们有不同的交错。这意味着如果我们根据标准输出的内容定义我们的不变约束,我们可能会在这里遭受潜在的竞争条件。这最终会导致我们在编写多线程代码时遇到的所有问题,我们需要使用类似的方法来克服这些问题。在下一章中,我们将更详细地讨论这些解决方案。

在下一节中,我们将讨论进程执行以及如何使用exec*函数实现它。

进程执行

执行新进程的另一种方式是使用exec*函数家族。与 fork API 相比,这个函数族在执行新进程时采取不同的方法。exec*函数背后的哲学是首先创建一个简单的基进程,然后在某个时刻,加载目标可执行文件并将其作为新的进程映像替换基进程。进程映像是可执行文件的加载版本,其内存段已分配,并准备好执行。在未来的章节中,我们将讨论加载可执行文件的不同步骤,并更深入地解释进程映像。

因此,在使用exec*函数时,不会创建新的进程,而是发生进程替换。这是forkexec*函数之间最重要的区别。不是通过分支新的进程,而是将基础进程完全替换为一个新的内存段和代码指令集。

代码框 17-3,包含示例 17.3,展示了execvp函数,它是exec*函数家族中的一个函数,是如何用来启动一个 echo 进程的。execvp函数是exec*函数组中的一个函数,它从父进程继承了环境变量PATH,并像父进程一样搜索可执行文件:

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
int main(int argc, char** argv) {
  char *args[] = {"echo", "Hello", "World!", 0};
  execvp("echo", args);
  printf("execvp() failed. Error: %s\n", strerror(errno));
  return 0;
}

代码框 17-3 [ExtremeC_examples_chapter17_3.c]:展示execvp的工作原理

如您在先前的代码框中看到的那样,我们调用了execvp函数。正如我们之前解释的那样,execvp函数从基础进程继承了环境变量PATH,以及它查找现有可执行文件的方式。它接受两个参数;第一个是要加载和执行的可执行文件或脚本的名称,第二个是要传递给可执行文件的参数列表。

注意,我们传递的是echo而不是绝对路径。因此,execvp应该首先定位echo可执行文件。这些可执行文件可以位于类 Unix 操作系统的任何位置,从/usr/bin/usr/local/bin或甚至其他地方。可以通过遍历PATH环境变量中找到的所有目录路径来找到echo的绝对位置。

exec*函数可以执行一系列可执行文件。以下是一些可以通过exec*函数执行的一些文件格式列表:

  • ELF 可执行文件

  • 包含指示脚本解释器shebang行的脚本文件

  • 传统a.out格式的二进制文件

  • ELF FDPIC 可执行文件

找到echo可执行文件后,execvp完成剩余的工作。它使用一组准备好的参数调用exec(在 Linux 中为execve)系统调用,然后内核从找到的可执行文件中准备进程映像。当一切准备就绪时,内核用准备好的映像替换当前进程映像,基本进程永远消失。现在,控制权返回到新进程,它从其main函数开始执行,就像正常执行一样。

由于这个过程,如果在execvp函数调用语句之后,printf语句无法被执行,因为现在我们有一个全新的进程,拥有新的内存段和新的指令。如果execvp语句没有成功,那么printf应该被执行,这是execvp函数调用失败的标志。

正如我们之前所说的,我们有一组exec*函数,而execvp函数只是其中之一。虽然它们的行为相似,但它们之间有一些细微的差别。接下来,你可以找到这些函数的比较:

  • execl(const char* path, const char* arg0, ..., NULL): 接受指向可执行文件的绝对路径以及一系列应该传递给新进程的参数。它们必须以空字符串、0NULL结尾。如果我们想使用execl重写示例 17.3,我们会使用execl("/usr/bin/echo", "echo", "Hello", "World", NULL)

  • execlp(const char* file, const char* arg0, ..., NULL): 接受一个相对路径作为其第一个参数,但由于它可以访问PATH环境变量,它可以轻松地定位可执行文件。然后,它接受一系列应该传递给新进程的参数。它们必须以空字符串、0NULL结尾。如果我们想使用execlp重写示例 17.3,我们会使用execlp("echo", "echo," "Hello," "World," NULL)

  • execle(const char* path, const char* arg0, ..., NULL, const char* env0, ..., NULL): 作为其第一个参数接受指向可执行文件的绝对路径。然后,它接受一系列应传递给新进程的参数,后面跟一个空字符串。随后,它接受一系列表示环境变量的字符串。它们也必须以空字符串结尾。如果我们想使用 execle 重写 示例 17.3,我们将使用 execle("/usr/bin/echo", "echo", "Hello", "World", NULL, "A=1", "B=2", NULL)。请注意,在这个调用中,我们向新进程传递了两个新的环境变量,AB

  • execv(const char* path, const char* args[]): 接受指向可执行文件的绝对路径以及应传递给新进程的参数数组。数组中的最后一个元素必须是一个空字符串,0NULL。如果我们想使用 execl 重写 示例 17.3,我们将使用 execl("/usr/bin/echo", args),其中 args 的声明如下:char* args[] = {"echo", "Hello", "World", NULL}

  • execvp(const char* file, const char* args[]): 它接受一个相对路径作为其第一个参数,但由于它可以访问 PATH 环境变量,因此可以轻松地定位可执行文件。然后,它接受一个数组,该数组包含应传递给新进程的参数。数组中的最后一个元素必须是一个空字符串,0NULL。这是我们在 示例 17.3 中使用的函数。

exec* 函数成功时,之前的进程就消失了,取而代之的是一个新的进程。因此,根本就没有第二个进程。因此,我们无法像对 fork API 那样演示交错。在下一节中,我们将比较 fork API 和 exec* 函数以执行新程序。

比较进程创建和进程执行

基于我们之前的讨论和前几节给出的示例,我们可以对用于执行新程序的两个方法进行比较:

  • 成功调用 fork 函数会导致两个独立进程的结果;一个调用 fork 函数的父进程和一个分叉的(或子)进程。但任何 exec* 函数的成功调用都会导致调用进程被新的进程映像替换,因此不会创建新的进程。

  • 调用 fork 函数会复制父进程的所有内存内容,分叉进程会看到相同的内存内容和变量。但调用 exec* 函数会破坏基本进程的内存布局,并基于加载的可执行文件创建一个新的布局。

  • 一个分叉进程可以访问父进程的某些属性,例如,打开的文件描述符,但使用 exec* 函数。新的进程对此一无所知,并且它不会从基本进程继承任何内容。

  • 在这两个 API 中,我们最终得到一个只有一个主线程的新进程。父进程中的线程不是使用fork API 进行克隆的。

  • 可以使用exec* API 运行脚本和外部可执行文件,但只能使用fork API 创建一个实际上是相同 C 程序的新进程。

在下一节中,我们将讨论大多数内核加载和执行新进程所采取的步骤。这些步骤及其细节因内核而异,但我们尽力涵盖大多数已知内核执行进程所采取的一般步骤。

进程执行步骤

要从可执行文件执行进程,大多数操作系统中的用户空间和内核空间需要采取一些通用步骤。正如我们在上一节中提到的,可执行文件大多是可执行对象文件,例如 ELF、Mach 或需要解释器来执行它们的脚本文件。

从用户环的角度来看,应该调用像exec这样的系统调用。请注意,我们在这里不解释fork系统调用,因为它实际上不是执行。它更多的是当前运行进程的克隆操作。

当用户空间调用exec系统调用时,内核内部会创建一个新的执行可执行文件请求。内核试图根据其类型找到指定的可执行文件的处理程序,并根据该处理程序,使用加载程序来加载可执行文件的内容。

注意,对于脚本文件,解释程序的可执行二进制文件通常在脚本的第一行的shebang 行中指定。为了执行进程,加载程序有以下职责:

  • 它检查请求执行的用户的执行上下文和权限。

  • 它从主内存为新进程分配内存。

  • 它将可执行文件的二进制内容复制到分配的内存中。这主要涉及数据和文本段。

  • 它为栈段分配一个内存区域,并准备初始内存映射。

  • 创建主线程及其栈内存区域。

  • 它将命令行参数作为栈帧复制到主线程栈区域的顶部。

  • 它初始化执行所需的必要寄存器。

  • 它执行程序入口点的第一条指令。

在脚本文件的情况下,脚本文件的路径被复制为解释器进程的命令行参数。大多数内核都采取这些一般步骤,但实现细节可能因内核而异。

要了解更多关于特定操作系统的信息,你需要查看其文档或简单地通过谷歌搜索。以下来自 LWN 的文章是那些寻求更多关于 Linux 进程执行细节的人的绝佳起点:lwn.net/Articles/631631/lwn.net/Articles/630727/

在下一节中,我们将开始讨论与并发相关的话题。我们为下一章做准备,下一章将深入探讨多进程特定的同步技术。我们首先从讨论共享状态开始,这些状态可以在多进程软件系统中使用。

共享状态

与线程一样,我们可以在进程之间有一些共享状态。唯一的区别是线程能够访问它们所属进程拥有的相同内存空间,但进程没有这样的奢侈。因此,应该采用其他机制来在多个进程之间共享状态。

在本节中,我们将讨论这些技术,作为本章的一部分,我们将重点关注其中一些作为存储功能的技术。在第一部分,我们将讨论不同的技术,并尝试根据它们的性质对它们进行分组。

共享技术

如果你看看你可以在两个进程之间共享状态(一个变量或一个数组)的方法,你会发现这可以通过有限的方式完成。理论上,在多个进程之间共享状态主要有两大类,但在实际的计算机系统中,每一类都有一些子类别。

你要么必须将状态放在一个可以被多个进程访问的“地方”,要么你必须将你的状态发送传输为消息、信号或事件给其他进程。同样,你要么必须拉取检索一个现有的状态从一个“地方”,要么接收它作为消息、信号或事件。第一种方法需要存储或一种介质,如内存缓冲区或文件系统,而第二种方法要求你在进程之间有一个消息机制或通道

作为第一种方法的例子,我们可以有一个共享内存区域作为介质,其中包含一个数组,多个进程可以访问并修改这个数组。作为第二种方法的例子,我们可以有一个计算机网络作为通道,允许网络中不同主机上的多个进程之间传输一些消息。

我们目前关于如何在一些进程之间共享状态的讨论实际上并不局限于进程;它也可以应用于线程。线程之间也可以进行信号传递以共享状态或传播事件。

在不同的术语中,第一组中发现的、需要像存储这样的介质来共享状态的技巧被称为基于拉取的技巧。这是因为想要读取状态的进程必须从存储中拉取它们。

第二组中需要通道来传输状态的技巧被称为基于推送的技巧。这是因为状态是通过通道推送到接收进程的,它不需要从中拉取。从现在开始,我们将使用这些术语来指代这些技巧。

基于推送的技术多样性导致了现代软件行业中各种分布式架构的出现。与基于推送的技术相比,基于拉取的技术被认为是遗留的,你可以在许多企业应用中看到这一点,在这些应用中,单个中央数据库被用来在整个系统中共享各种状态。

然而,基于推送的方法目前正在兴起,并导致了诸如事件溯源和其他一些类似的分布式方法的出现,这些方法用于保持大型软件系统的各个部分之间的一致性,而无需将所有数据存储在中央位置。

在讨论的两种方法中,我们特别关注本章中的第一种方法。我们将在第十九章“单主机 IPC 和套接字”,第二十章“套接字编程”中更多地关注第二种方法。在这些章节中,我们将介绍作为进程间通信(IPC)技术一部分的用于在进程之间传输消息的各种通道。只有在这种情况下,我们才能探索各种基于推送的技术,并给出一些观察到的并发问题和可以采用的控制机制的实例。

以下是由 POSIX 标准支持的基于拉取的技术列表,可以在所有 POSIX 兼容的操作系统上广泛使用:

  • 共享内存:这只是一个在主内存中的共享区域,可以被多个进程访问,它们可以像普通内存块一样使用它来存储变量和数组。共享内存对象不是磁盘上的文件,但它确实是内存。即使没有进程使用它,它也可以作为操作系统中的独立对象存在。当不再需要时,共享内存对象可以被进程移除,或者通过重启系统来移除。因此,从重启生存性的角度来看,共享内存对象可以被视为临时对象。

  • 文件系统:进程可以使用文件来共享状态。这是一种在软件系统中在多个进程之间共享某些状态的最古老的技术之一。最终,同步访问共享文件的问题,以及许多其他有效的原因,导致了数据库管理系统(DBMS)的发明,但仍然,在某些用例中仍在使用共享文件。

  • 网络服务:一旦对所有进程可用,进程可以使用网络存储或网络服务来存储和检索共享状态。在这种情况下,进程并不确切知道幕后发生了什么。他们只是通过一个定义良好的 API 使用网络服务,该 API 允许他们对共享状态执行某些操作。例如,我们可以提到网络文件系统(NFS)或数据库管理系统(DBMS)。它们提供网络服务,允许通过定义良好的模型和一系列伴随操作来维护状态。更具体的例子,我们可以提到关系型数据库管理系统,它允许您通过使用 SQL 命令在关系模型中存储您的状态。

在以下小节中,我们将讨论作为 POSIX 接口一部分的上述每种方法。我们首先从 POSIX 共享内存开始,展示它如何导致从第十六章线程同步中熟悉的数据竞争。

POSIX 共享内存

由 POSIX 标准支持,共享内存是广泛用于在多个进程之间共享信息的技术之一。与可以访问相同内存空间的线程不同,进程没有这种能力,操作系统禁止进程访问其他进程的内存。因此,我们需要一种机制来在两个进程之间共享内存的一部分,共享内存正是这种技术。

在以下示例中,我们将详细介绍创建和使用共享内存对象的过程,我们的讨论从创建共享内存区域开始。以下代码展示了如何在 POSIX 兼容系统中创建和填充共享内存对象:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#define SH_SIZE 16
int main(int argc, char** argv) {
  int shm_fd = shm_open("/shm0", O_CREAT | O_RDWR, 0600);
  if (shm_fd < 0) {
    fprintf(stderr, "ERROR: Failed to create shared memory: %s\n",
        strerror(errno));
    return 1;
  }
  fprintf(stdout, "Shared memory is created with fd: %d\n",
          shm_fd);
  if (ftruncate(shm_fd, SH_SIZE * sizeof(char)) < 0) {
    fprintf(stderr, "ERROR: Truncation failed: %s\n",
            strerror(errno));
    return 1;
  }
  fprintf(stdout, "The memory region is truncated.\n");
  void* map = mmap(0, SH_SIZE, PROT_WRITE, MAP_SHARED, shm_fd, 0);
  if (map == MAP_FAILED) {
    fprintf(stderr, "ERROR: Mapping failed: %s\n",
            strerror(errno));
    return 1;
  }
  char* ptr = (char*)map;
  ptr[0] = 'A';
  ptr[1] = 'B';
  ptr[2] = 'C';
  ptr[3] = '\n';
  ptr[4] = '\0';
  while(1);
  fprintf(stdout, "Data is written to the shared memory.\n");
  if (munmap(ptr, SH_SIZE) < 0) {
    fprintf(stderr, "ERROR: Unmapping failed: %s\n",
            strerror(errno));
    return 1;
  }
  if (close(shm_fd) < 0) {
    fprintf(stderr, "ERROR: Closing shared memory failed: %s\n",
        strerror(errno));
    return 1;
  }
  return 0;
}

代码框 17-4 [ExtremeC_examples_chapter17_4.c]:创建和写入 POSIX 共享内存对象

上述代码创建了一个名为/shm0的共享内存对象,其中包含 16 个字节。然后它使用字面量ABC\n填充共享内存,最后通过取消映射共享内存区域来退出。请注意,即使进程退出,共享内存对象仍然保留。未来的进程可以反复打开和读取相同的共享内存对象。共享内存对象要么通过系统重启来销毁,要么通过进程将其取消链接(移除)。

注意

在 FreeBSD 中,共享内存对象的名称应该以/开头。在 Linux 或 macOS 中这不是强制性的,但我们为了与 FreeBSD 保持兼容,对它们也做了同样的处理。

在前面的代码中,我们首先使用 shm_open 函数打开一个共享内存对象。它接受一个名称和共享内存对象应创建的模式。O_CREATO_RDWR 表示应创建共享内存,并且它可以用于读取和写入操作。

注意,如果共享内存对象已经存在,创建操作不会失败。最后一个参数表示共享内存对象的权限。0600 表示它仅对启动共享内存对象的拥有者进程的读取和写入操作可用。

在接下来的几行中,我们通过使用 ftruncate 函数截断共享内存区域的大小来定义共享内存区域的大小。请注意,如果您即将创建一个新的共享内存对象,这是一个必要的步骤。对于前面的共享内存对象,我们已定义了 16 字节进行分配,然后进行了截断。

随着我们的进行,我们使用 mmap 函数将共享内存对象映射到进程可访问的区域。因此,我们有一个指向映射内存的指针,可以用来访问后面的共享内存区域。这也是一个必要的步骤,使得共享内存对我们的 C 程序可访问。

函数 mmap 通常用于将文件或共享内存区域(最初从内核的内存空间分配)映射到调用进程可访问的地址空间。然后,映射的地址空间可以使用普通指针作为常规内存区域进行访问。

如您所见,该区域被映射为一个可写区域,由 PROT_WRITE 指示,并且作为进程间的共享区域,由 MAP_SHARED 参数指示。MAP_SHARED 简单地意味着对映射区域的任何更改都将对映射相同区域的其它进程可见。

除了 MAP_SHARED,我们还可以使用 MAP_PRIVATE;这意味着对映射区域的更改不会传播到其他进程,而是对映射进程是私有的。除非您只想在进程内部使用共享内存,否则这种用法并不常见。

在映射共享内存区域后,前面的代码将一个以空字符终止的字符串 ABC\n 写入共享内存。注意字符串末尾的新行换行符。作为最后一步,进程通过调用 munmap 函数取消映射共享内存区域,然后关闭分配给共享内存对象的文件描述符。

注意

每个操作系统都提供了一种不同的方式来创建一个未命名的或匿名的共享内存对象。在 FreeBSD 中,只需将 SHM_ANON 作为共享内存对象的路径传递给 shm_open 函数即可。在 Linux 中,可以使用 memfd_create 函数创建一个匿名文件,而不是创建共享内存对象,并使用返回的文件描述符创建一个映射区域。匿名共享内存仅对拥有进程是私有的,不能用于在多个进程之间共享状态。

上述代码可以在 macOS、FreeBSD 和 Linux 系统上编译。在 Linux 系统中,共享内存对象可以在目录/dev/shm中看到。请注意,这个目录不是一个常规的文件系统,你看到的东西不是磁盘设备上的文件。相反,/dev/shm使用shmfs文件系统。它的目的是通过挂载的目录来暴露内存中创建的临时对象,并且它仅在 Linux 中可用。

让我们在 Linux 中编译并运行示例 17.4,并检查/dev/shm目录的内容。在 Linux 中,必须将最终二进制文件与rt库链接,才能使用共享内存功能,这就是为什么你在下面的 shell 框中看到-lrt选项的原因:

$ ls /dev/shm
$ gcc ExtremeC_examples_chapter17_4.c -lrt -o ex17_4.out
$ ./ex17_4.out
Shared memory is created with fd: 3
The memory region is truncated.
Data is written to the shared memory.
$ ls /dev/shm
shm0
$

Shell Box 17-4:构建和运行示例 17.4 并检查共享内存对象是否创建

如你在第一行所见,/dev/shm目录中没有共享内存对象。在第二行,我们构建了示例 17.4,在第三行,我们执行了生成的可执行文件。然后我们检查/dev/shm,我们看到那里有一个新的共享内存对象,shm0

程序的输出也确认了共享内存对象的创建。前一个 shell 框中另一个重要的事情是文件描述符3,它被分配给了共享内存对象。

对于你打开的每个文件,每个进程都会打开一个新的文件描述符。这个文件不一定在磁盘上,它可以是共享内存对象、标准输出等等。在每个进程中,文件描述符从 0 开始,直到最大允许的数字。

注意,在每个进程中,文件描述符012分别预分配给了stdoutstdinstderr流。在main函数运行之前,为每个新进程打开了这些文件描述符。这就是为什么前一个例子中的共享内存对象得到3作为其文件描述符的原因。

注意

在 macOS 系统上,你可以使用pics实用程序检查系统中的活动 IPC 对象。它可以显示活动的消息队列和共享内存。它还显示了活动的信号量。

/dev/shm目录还有一个有趣的属性。你可以使用cat实用程序查看共享内存对象的内容,但同样,这仅在 Linux 中可用。让我们在我们的创建的shm0对象上使用它。如你在下面的 shell 框中看到的,共享内存对象的内容被显示出来。它是字符串ABC加上一个换行符\n

$ cat /dev/shm/shm0
ABC
$

Shell Box 17-5 使用 cat 程序查看作为示例 17.4 部分创建的共享内存对象的内容

正如我们之前解释的,只要至少有一个进程在使用,共享内存对象就会存在。即使其中一个进程已经请求操作系统删除(或解除链接)共享内存,它实际上也不会被删除,直到最后一个进程使用它。即使没有进程解除链接共享内存对象,当系统重启时,它也会被删除。共享内存对象无法在重启后存活,进程应该再次创建它们以用于通信。

以下示例展示了进程如何打开并读取已存在的共享内存对象,以及如何最终解除链接它。Example 17.5example 17.4中创建的共享内存对象中读取。因此,它可以被视为与我们在example 17.4中做的事情的补充:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#define SH_SIZE 16
int main(int argc, char** argv) {
  int shm_fd = shm_open("/shm0", O_RDONLY, 0600);
  if (shm_fd < 0) {
    fprintf(stderr, "ERROR: Failed to open shared memory: %s\n",
        strerror(errno));
    return 1;
  }
  fprintf(stdout, "Shared memory is opened with fd: %d\n", shm_fd);
  void* map = mmap(0, SH_SIZE, PROT_READ, MAP_SHARED, shm_fd, 0);
  if (map == MAP_FAILED) {
    fprintf(stderr, "ERROR: Mapping failed: %s\n",
            strerror(errno));
    return 1;
  }
  char* ptr = (char*)map;
  fprintf(stdout, "The contents of shared memory object: %s\n",
          ptr);
  if (munmap(ptr, SH_SIZE) < 0) {
    fprintf(stderr, "ERROR: Unmapping failed: %s\n",
            strerror(errno));
    return 1;
  }
  if (close(shm_fd) < 0) {
    fprintf(stderr, "ERROR: Closing shared memory fd filed: %s\n",
        strerror(errno));
    return 1;
  }
  if (shm_unlink("/shm0") < 0) {
    fprintf(stderr, "ERROR: Unlinking shared memory failed: %s\n",
        strerror(errno));
    return 1;
  }
  return 0;
}

代码框 17-5 [ExtremeC_examples_chapter17_5.c]:从作为示例 17.4 部分创建的共享内存对象中读取

作为main函数中的第一条语句,我们打开了一个名为/shm0的现有共享内存对象。如果没有这样的共享内存对象,我们将生成一个错误。如您所见,我们以只读方式打开了共享内存对象,这意味着我们不会向共享内存中写入任何内容。

在接下来的几行中,我们映射了共享内存区域。同样,我们通过传递PROT_READ参数表明映射的区域是只读的。之后,我们最终得到了共享内存区域的指针,并使用它来打印其内容。当我们完成共享内存的使用后,我们取消映射该区域。随后,关闭分配的文件描述符,最后通过使用shm_unlink函数解除链接共享内存对象。

在这一点之后,当所有使用相同共享内存的其他进程完成使用后,共享内存对象将从系统中删除。请注意,只要有一个进程在使用,共享内存对象就会存在。

以下是在运行前面代码后的输出。注意在运行example 17.5前后/dev/shm的内容:

$ ls /dev/shm
shm0
$ gcc ExtremeC_examples_chapter17_5.c -lrt -o ex17_5.out
$ ./ex17_5.out
Shared memory is opened with fd: 3
The contents of the shared memory object: ABC
$ ls /dev/shm
$

Shell 框 17-6:从示例 17.4 中创建的共享内存对象中读取,并最终删除它

使用共享内存的数据竞争示例

现在,是时候演示使用 fork API 和共享内存的组合来产生数据竞争了。这可以与第十五章中给出的示例类似,以演示多个线程之间的数据竞争。

example 17.6中,我们有一个放置在共享内存区域内的计数器变量。该示例从主运行进程中派生出一个子进程,它们都尝试增加共享计数器。最终的输出显示了共享计数器上的明显数据竞争:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#define SH_SIZE 4
// Shared file descriptor used to refer to the
// shared memory object
int shared_fd = -1;
// The pointer to the shared counter
int32_t* counter = NULL;
void init_shared_resource() {
  // Open the shared memory object
  shared_fd = shm_open("/shm0", O_CREAT | O_RDWR, 0600);
  if (shared_fd < 0) {
    fprintf(stderr, "ERROR: Failed to create shared memory: %s\n",
        strerror(errno));
    exit(1);
  }
  fprintf(stdout, "Shared memory is created with fd: %d\n",
          shared_fd);
}
void shutdown_shared_resource() {
  if (shm_unlink("/shm0") < 0) {
    fprintf(stderr, "ERROR: Unlinking shared memory failed: %s\n",
        strerror(errno));
    exit(1);
  }
}
void inc_counter() {
  usleep(1);
  int32_t temp = *counter;
  usleep(1);
  temp++;
  usleep(1);
  *counter = temp;
  usleep(1);
}
int main(int argc, char** argv) {
  // Parent process needs to initialize the shared resource
  init_shared_resource();
  // Allocate and truncate the shared memory region
  if (ftruncate(shared_fd, SH_SIZE * sizeof(char)) < 0) {
    fprintf(stderr, "ERROR: Truncation failed: %s\n",
            strerror(errno));
    return 1;
  }
  fprintf(stdout, "The memory region is truncated.\n");
  // Map the shared memory and initialize the counter
  void* map = mmap(0, SH_SIZE, PROT_WRITE,
          MAP_SHARED, shared_fd, 0);
  if (map == MAP_FAILED) {
    fprintf(stderr, "ERROR: Mapping failed: %s\n",
            strerror(errno));
    return 1;
  }
  counter = (int32_t*)map;
  *counter = 0;
  // Fork a new process
  pid_t pid = fork();
  if (pid) { // The parent process
    // Increment the counter
    inc_counter();
    fprintf(stdout, "The parent process sees the counter as %d.\n",
        *counter);
    // Wait for the child process to exit
    int status = -1;
    wait(&status);
    fprintf(stdout, "The child process finished with status %d.\n",
        status);
  } else { // The child process
    // Incrmenet the counter
    inc_counter();
    fprintf(stdout, "The child process sees the counter as %d.\n",
        *counter);
  }
  // Both processes should unmap shared memory region and close
  // its file descriptor
  if (munmap(counter, SH_SIZE) < 0) {
    fprintf(stderr, "ERROR: Unmapping failed: %s\n",
            strerror(errno));
    return 1;
  }
  if (close(shared_fd) < 0) {
    fprintf(stderr, "ERROR: Closing shared memory fd filed: %s\n",
        strerror(errno));
    return 1;
  }
  // Only parent process needs to shutdown the shared resource
  if (pid) {
    shutdown_shared_resource();
  }
  return 0;
}

代码框 17-6 [ExtremeC_examples_chapter17_6.c]:使用 POSIX 共享内存和 fork API 演示数据竞争

在前面的代码中,除了 main 函数之外,还有三个函数。函数 init_shared_resource 创建共享内存对象。我之所以将此函数命名为 init_shared_resource 而不是 init_shared_memory,是因为在前面的示例中我们可以使用另一种基于拉取的技术,并且为这个函数取一个通用的名字,使得 main 函数在未来示例中保持不变。

函数 shutdown_shared_resource 销毁共享内存并解除链接。此外,函数 inc_counter 通过 1 增加共享计数器。

main 函数截断并映射共享内存区域,就像我们在 example 17.4 中所做的那样。在将共享内存区域映射后,分叉逻辑开始。通过调用 fork 函数,会创建一个新的进程,并且两个进程(分叉进程和分叉进程)都会尝试通过调用 inc_counter 函数来增加计数器。

当父进程向共享计数器写入时,它会等待子进程完成,然后才尝试取消映射、关闭和解除链接共享内存对象。请注意,取消映射和文件描述符的关闭在两个进程中都会发生,但只有父进程会解除链接共享内存对象。

正如您在 Code Box 17-6 中所看到的那样,我们在 inc_counter 函数中使用了某些不寻常的 usleep 调用。原因是强制调度器从某个进程收回 CPU 核心并将其分配给另一个进程。如果没有这些 usleep 函数调用,CPU 核心通常不会在进程之间转移,而且很难经常看到不同交织的效果。

导致这种效果的一个原因是每个进程中的指令数量较少。如果每个进程的指令数量显著增加,即使没有睡眠调用,也可以看到交织的非确定性行为。例如,在每个进程中有一个循环,计数 10,000 次,并在每次迭代中增加共享计数器,这很可能揭示数据竞争。您可以自己尝试一下。

关于前面代码的最后一个注意事项,父进程在创建和打开共享内存对象并将其分配给文件描述符之前,会进行子进程的创建和分叉。分叉后的进程不会打开共享内存对象,但它可以使用相同的文件描述符。所有打开的文件描述符都是从父进程继承的事实,帮助子进程继续使用文件描述符,并引用相同的共享内存对象。

Shell Box 17-7 中的以下内容是多次运行 example 17.6 的输出。正如您所看到的,我们在共享计数器上存在明显的数据竞争。有时父进程或子进程在未获取最新修改值的情况下更新计数器,这导致两个进程都打印出 1

$ gcc ExtremeC_examples_chapter17_6 -o ex17_6.out
$ ./ex17_6.out
Shared memory is created with fd: 3
The memory region is truncated.
The parent process sees the counter as 1.
The child process sees the counter as 2.
The child process finished with status 0.
$ ./ex17_6
...
...
...
$ ./ex17_6.out
Shared memory is created with fd: 3
The memory region is truncated.
The parent process sees the counter as 1.
The child process sees the counter as 1.
The child process finished with status 0.
$

Shell Box 17-7:运行示例 17.6 并演示在共享计数器上发生的数据竞争

在本节中,我们展示了如何创建和使用共享内存。我们还演示了一个数据竞争的例子以及并发进程在访问共享内存区域时的行为。在下一节中,我们将讨论文件系统作为另一种广泛使用的基于拉的共享状态方法,在多个进程之间共享状态。

文件系统

POSIX 提供了一个类似的 API 来处理文件系统中的文件。只要涉及到文件描述符,并且它们被用来引用各种系统对象,就可以使用与用于共享内存相同的 API。

我们使用文件描述符来引用文件系统中的实际文件,如 ext4,以及共享内存、管道等;因此,可以采用相同的语义来打开、读取、写入,将它们映射到本地内存区域等。因此,我们预计会看到与共享内存类似的讨论,也许还有类似的 C 代码。这在 示例 17.7 中可以看到。

注意

我们通常映射文件描述符。然而,也有一些特殊情况,其中 套接字描述符 可以被映射。套接字描述符类似于文件描述符,但用于网络或 Unix 套接字。这个链接提供了一个有趣的映射 TCP 套接字背后的内核缓冲区的用例,这被称为 零拷贝接收机制https://lwn.net/Articles/752188/。

注意,用于使用文件系统的 API 与我们用于共享内存的 API 非常相似,但这并不意味着它们的实现也相似。事实上,由硬盘支持的文件系统中的文件对象与共享内存对象在本质上是有区别的。让我们简要讨论一些区别:

  • 共享内存对象基本上位于内核进程的内存空间中,而文件系统中的文件位于磁盘上。这样的文件最多只有一些用于读写操作的分配缓冲区。

  • 写入共享内存的状态在系统重启后会被清除,但写入共享文件的状态,如果它由硬盘或永久存储支持,重启后可以保留。

  • 通常情况下,访问共享内存比访问文件系统要快得多。

以下代码是我们在上一节中为共享内存给出的相同数据竞争示例。由于文件系统的 API 与我们用于共享内存的 API 非常相似,我们只需要从 example 17.6 中更改两个函数;init_shared_resourceshutdown_shared_resource。其余的将保持不变。这是通过使用相同的 POSIX API 在文件描述符上操作而取得的一项伟大成就。让我们来看看代码:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#define SH_SIZE 4
// The shared file descriptor used to refer to the shared file
int shared_fd = -1;
// The pointer to the shared counter
int32_t* counter = NULL;
void init_shared_resource() {
  // Open the file
  shared_fd = open("data.bin", O_CREAT | O_RDWR, 0600);
  if (shared_fd < 0) {
    fprintf(stderr, "ERROR: Failed to create the file: %s\n",
        strerror(errno));
    exit(1);
  }
  fprintf(stdout, "File is created and opened with fd: %d\n",
          shared_fd);
}
void shutdown_shared_resource() {
  if (remove("data.bin") < 0) {
    fprintf(stderr, "ERROR: Removing the file failed: %s\n",
        strerror(errno));
    exit(1);
  }
}
void inc_counter() {
  ... As exmaple 17.6 ...
}
int main(int argc, char** argv) {
  ... As exmaple 17.6 ...
}

Code Box 17-7 [ExtremeC_examples_chapter17_7.c]:使用常规文件和 fork API 演示数据竞争

如你所见,前面的大部分代码来自 示例 17.6。其余的是使用 openremove 函数代替 shm_openshm_unlink 函数的替代方案。

注意,文件 data.bin 是在当前目录中创建的,因为我们没有给 open 函数提供一个绝对路径。运行前面的代码也会产生相同的共享计数器数据竞争。它可以像我们对 示例 17.6 的方法一样进行检查。

到目前为止,我们已经看到我们可以使用共享内存和共享文件来存储状态,并从多个进程中并发地访问它。现在,是时候更深入地讨论多线程和多进程,并彻底比较它们了。

多线程与多进程

第十四章 中讨论了多线程和多进程,以及我们在最近几章中介绍的概念之后,我们现在处于一个很好的位置来比较它们,并给出一个高级描述,说明在哪些情况下应该采用每种方法。假设我们正在设计一个旨在并发处理多个输入请求的软件。我们将在三种不同的情况下讨论这个问题。让我们从第一种情况开始。

多线程

第一种情况是当你可以编写一个只有单个进程的软件时,所有请求都进入同一个进程。所有逻辑都应该作为同一进程的一部分来编写,结果你得到一个庞大的进程,它执行系统中的一切。由于这是单进程软件,如果你想并发处理许多请求,你需要通过创建线程来以多线程的方式处理多个请求。此外,选择一个具有有限线程数的 线程池 可能是一个更好的设计决策。

在并发和同步方面有以下考虑事项需要注意。请注意,我们在这里不讨论使用事件循环或异步 I/O,尽管它仍然可以是多线程的有效替代方案。

如果请求的数量显著增加,线程池中有限的线程数量应该增加以克服需求。这实际上意味着升级运行主进程的机器的硬件和资源。这被称为 向上扩展垂直扩展。这意味着你升级单台机器上的硬件,以便能够响应更多的请求。除了客户在升级到新硬件期间可能经历的可能的停机时间(尽管可以防止这种情况发生)之外,升级是昂贵的,而且当请求的数量再次增长时,你必须进行另一次扩展。

如果处理请求最终涉及到操作共享状态或数据存储,可以通过知道线程可以访问相同的内存空间这一事实,轻松地实现同步技术。当然,无论它们是否有一个需要维护的共享数据结构,或者它们是否有访问非事务性的远程数据存储,这都是必要的。

所有线程都在同一台机器上运行,因此它们可以使用我们之前解释的用于共享状态的相同技术,这些技术由线程和进程使用。这是一个很棒的功能,并且在处理线程同步时减轻了很多痛苦。

让我们谈谈下一个情况,当我们可以有一个以上的进程,但它们都在同一台机器上。

单主机多进程

在这种情况下,我们编写了一个具有多个进程的软件,但所有这些进程都部署在单个机器上。所有这些进程可以是单线程的,或者它们可以在内部有一个线程池,允许每个进程一次处理多个请求。

当请求的数量增加时,可以创建新的进程而不是创建更多的线程。这通常被称为横向扩展水平扩展。然而,当你只有一台单机时,你必须向上扩展,换句话说,你必须升级其硬件。这可能会引起我们在前一个子节中提到的多线程程序向上扩展时提到的问题。

当涉及到并发时,进程是在并发环境中执行的。它们只能使用多进程方式共享状态或同步进程。当然,这并不像编写多线程代码那样方便。此外,进程可以使用基于拉或基于推的技术来共享状态。

在单台机器上实现多进程并不十分有效,而且当涉及到编码的劳动强度时,似乎多线程更为方便。

下一个子节将讨论分布式多进程环境,这是创建现代软件的最佳设计。

分布式多进程

在最终的情况下,我们编写了一个程序,作为多个进程运行,这些进程运行在多个主机上,所有主机都通过网络相互连接,并且在单个主机上可以运行多个进程。在这种部署中可以看到以下特点。

当面临请求数量的显著增长时,这个系统可以无限扩展。这是一个很棒的功能,使你能够在面对如此高的峰值时使用通用硬件。使用通用硬件的集群而不是强大的服务器是谷歌能够在机器集群上运行其PageRankMap Reduce算法的其中一个想法。

本章讨论的技术几乎无助于解决问题,因为它们有一个重要的先决条件:即所有进程都在同一台机器上运行。因此,应该采用一组完全不同的算法和技术来使进程同步,并使共享状态对系统中的所有进程可用。应该研究和调整诸如延迟容错性可用性数据一致性等多个因素,以适应这样的分布式系统。

不同主机上的进程使用网络套接字以基于推送的方式通信,但同一主机上的进程可能使用本地 IPC 技术,例如消息队列、共享内存、管道等,以传输消息和共享状态。

在本节的最后,在现代软件行业中,我们更倾向于横向扩展而不是纵向扩展。这将引发许多关于数据存储、同步、消息传递等方面的新想法和技术。它甚至可能对硬件设计产生影响,使其适合横向扩展。

摘要

在本章中,我们探讨了多进程系统以及可以用于在多个进程之间共享状态的各种技术。本章涵盖了以下主题:

  • 我们介绍了用于进程执行的 POSIX API。我们解释了fork API 和exec*函数的工作原理。

  • 我们解释了内核执行进程所采取的步骤。

  • 我们讨论了状态如何在多个进程之间共享的方法。

  • 我们介绍了基于拉取和基于推送的技术作为所有其他可用技术的两个顶级类别。

  • 文件系统上的共享内存和共享文件是常见的基于拉取方式共享状态的技术。

  • 我们解释了多线程和多进程部署之间的差异和相似之处,以及分布式软件系统中的垂直和水平扩展的概念。

在下一章中,我们将讨论单主机多进程环境中的并发问题。这包括对并发问题的讨论以及同步多个进程以保护共享资源的方法。这些主题与你在第十六章线程同步中遇到的主题非常相似,但它们的焦点在于进程而不是线程。

第十八章

进程同步

本章继续上一章的讨论,即进程执行,我们的主要焦点将是进程同步。多进程程序中的控制机制与我们在多线程程序中遇到的控制技术不同。不仅仅是内存不同;还有其他因素在多线程程序中找不到,它们存在于多进程环境中。

尽管线程绑定到进程上,但进程可以在任何机器上自由运行,使用任何操作系统,位于互联网大小的网络中的任何位置。正如你可能想象的那样,事情变得复杂。在这样一个分布式系统中同步多个进程将不会容易。

本章专门讨论仅在一台机器上发生的进程同步。换句话说,它主要讨论单主机同步及其相关技术。我们简要讨论了分布式系统中的进程同步,但不会深入探讨。

本章涵盖了以下主题:

  • 首先,我们描述了多进程软件,其中所有进程都在同一台机器上运行。我们介绍了单主机环境中的可用技术。我们利用前一章的知识来给出一些示例,以展示这些技术。

  • 在我们尝试同步多个进程的第一步中,我们使用了命名 POSIX 信号量。我们解释了它们应该如何使用,然后给出了一个示例,解决了我们在前几章中遇到的竞态条件问题。

  • 之后,我们将讨论命名 POSIX 互斥锁,并展示如何使用共享内存区域来创建并使命名互斥锁生效。作为一个例子,我们解决了一个由信号量解决的相同竞态条件问题,这次使用的是命名互斥锁。

  • 作为同步多个进程的最后一种技术,我们讨论了命名 POSIX 条件变量。像命名互斥锁一样,它们需要放在共享内存区域中才能被多个进程访问。我们给出了一个关于这一技术的详细示例,展示了如何使用命名 POSIX 条件变量来同步多进程系统。

  • 作为本章的最终讨论,我们简要讨论了那些在其网络周围分布有自己进程的多进程系统。我们讨论了它们的特性和与单主机多进程系统相比的问题性差异。

让我们以更多关于单主机并发控制和其中可用的技术为话题开始本章。

单主机并发控制

在某些情况下,一个机器上同时运行多个进程,这些进程需要同时访问共享资源是很常见的。由于所有进程都在同一个操作系统下运行,它们可以访问操作系统提供的所有设施。

在本节中,我们展示如何使用这些设施中的某些部分来创建一个同步进程的控制机制。共享内存在这些控制机制中起着关键作用;因此,我们高度依赖我们在上一章中解释的关于共享内存的内容。

以下是一个列表,列出了 POSIX 提供的控制机制,可以在所有进程都在同一 POSIX 兼容机器上运行时使用:

  • 命名 POSIX 信号量:与我们第十六章“线程同步”中解释的相同 POSIX 信号量,但有一个区别:现在它们有名字,可以在整个系统中全局使用。换句话说,它们不再是匿名私有的信号量了。

  • 命名互斥锁:再次,与第十六章“线程同步”中解释的具有相同属性的相同 POSIX 互斥锁,但现在它们被命名,可以在整个系统中使用。这些互斥锁应放置在共享内存中,以便多个进程可以使用。

  • 命名条件变量:与我们在第十六章“线程同步”中解释的相同 POSIX 条件变量,但像互斥锁一样,它们应放置在共享内存对象中,以便多个进程可以使用。

在接下来的章节中,我们将讨论所有上述技术,并给出示例以展示它们是如何工作的。在下一节中,我们将讨论命名 POSIX 信号量。

命名 POSIX 信号量

正如你在第十六章“线程同步”中看到的,信号量是同步多个并发任务的主要工具。我们在多线程程序中看到了它们,并看到了它们如何帮助克服并发问题。

在本节中,我们将展示它们如何在一些进程之间使用。示例 18.1展示了如何使用 POSIX 信号量来解决我们在上一章“进程执行”中给出的示例 17.617.7中遇到的数据竞争问题。该示例与示例 17.6非常相似,并且它再次使用共享内存区域来存储共享计数器变量。但它使用命名信号量来同步对共享计数器的访问。

以下代码框显示了我们在访问共享变量时使用命名信号量同步两个进程的方式。以下代码框显示了示例 18.1的全局声明:

#include <stdio.h>
...
#include <semaphore.h>  // For using semaphores
#define SHARED_MEM_SIZE 4
// Shared file descriptor used to refer to the
// shared memory object
int shared_fd = -1;
// The pointer to the shared counter
int32_t* counter = NULL;
// The pointer to the shared semaphore
sem_t* semaphore = NULL;

代码框 18-1 [ExtremeC_examples_chapter18_1.c]:示例 18.1 的全局声明

代码框 18-1中,我们声明了一个全局计数器和指向信号量对象的全球指针,该指针稍后将设置。这个指针将由父进程和子进程使用,以同步访问由计数器指针指向的共享计数器。

以下代码显示了预期执行实际进程同步的功能定义。其中一些定义与我们在示例 17.6中使用的相同,这些行已从以下代码框中删除:

void init_control_mechanism() {
  semaphore = sem_open("/sem0", O_CREAT | O_EXCL, 0600, 1);
  if (semaphore == SEM_FAILED) {
    fprintf(stderr, "ERROR: Opening the semaphore failed: %s\n",
        strerror(errno));
    exit(1);
  }
}
void shutdown_control_mechanism() {
  if (sem_close(semaphore) < 0) {
    fprintf(stderr, "ERROR: Closing the semaphore failed: %s\n",
        strerror(errno));
    exit(1);
  }
  if (sem_unlink("/sem0") < 0) {
    fprintf(stderr, "ERROR: Unlinking failed: %s\n",
        strerror(errno));
    exit(1);
  }
}
void init_shared_resource() {
  ... as in the example 17.6 ...
}
void shutdown_shared_resource() {
  ... as in the example 17.6 ...
}

代码框 18-2 [ExtremeC_examples_chapter18_1.c]: 同步函数的定义

示例 17.6 相比,我们添加了两个新函数:init_control_mechanismshutdown_control_mechanism。我们还对 inc_counter 函数(在 代码框 18-3 中显示)进行了修改,以使用信号量并在其中形成一个关键部分。

init_control_mechanismshutdown_control_mechanism 函数内部,我们使用与共享内存 API 类似的 API 来打开、关闭和解除命名信号量的链接。

函数 sem_opensem_closesem_unlink 可以看作与 shm_openshm_closeshm_unlink 类似。有一个区别,那就是 sem_open 函数返回一个信号量指针而不是文件描述符。

注意,在这个示例中用于处理信号量的 API 与我们之前看到的相同,因此其余的代码可以保持不变,就像 示例 17.6 一样。在这个示例中,信号量初始化为值 1,这使得它成为一个互斥锁。下面的代码框显示了关键部分以及如何使用信号量来同步对共享计数器执行的读写操作:

void inc_counter() {
  usleep(1);
  sem_wait(semaphore); // Return value should be checked.
  int32_t temp = *counter;
  usleep(1);
  temp++;
  usleep(1);
  *counter = temp;
  sem_post(semaphore); // Return value should be checked.
  usleep(1);
}

代码框 18-3 [ExtremeC_examples_chapter18_1.c]: 共享计数器增加的关键部分

示例 17.6 相比,在 inc_counter 函数中,使用 sem_waitsem_post 函数分别进入和退出关键部分。

在下面的代码框中,你可以看到 main 函数。它与 示例 17.6 几乎相同,我们只看到初始和最终部分的一些变化,这符合在 代码框 18-2 中看到的两个新函数的添加:

int main(int argc, char** argv) {
  // Parent process needs to initialize the shared resource
  init_shared_resource();
  // Parent process needs to initialize the control mechanism
  init_control_mechanism();
  ... as in the example 17.6 ...
  // Only parent process needs to shut down the shared resource
  // and the employed control mechanism
  if (pid) {
    shutdown_shared_resource();
    shutdown_control_mechanism();
  }
  return 0;
}

代码框 18-4 [ExtremeC_examples_chapter18_1.c]: 示例 18.1 的主函数

在下面的 Shell 框中,你可以看到 示例 18.1 连续运行两次的输出:

$ gcc ExtremeC_examples_chapter18_1.c -lrt -lpthread -o ex18_1.out
$ ./ex18_1.out
Shared memory is created with fd: 3
The memory region is truncated.
The child process sees the counter as 1.
The parent process sees the counter as 2.
The child process finished with status 0.
$ ./ex18_1.out
Shared memory is created with fd: 3
The memory region is truncated.
The parent process sees the counter as 1.
The child process sees the counter as 2.
The child process finished with status 0.
$

Shell 框 18-1:在 Linux 中构建并连续运行示例 18.1

注意,我们需要将上述代码与 pthread 库链接,因为我们正在使用 POSIX 信号量。我们还需要在 Linux 中将其与 rt 库链接,以便使用共享内存。

前面的输出是清晰的。有时子进程首先获得 CPU 并增加计数器,有时父进程这样做。它们从未同时进入关键部分,因此它们满足了共享计数器的数据完整性。

注意,使用命名信号量不需要使用 fork API。完全分离的进程,如果不是父子进程,如果它们在同一台机器上运行并在同一操作系统中,仍然可以打开和使用相同的信号量。在 示例 18.3 中,我们展示了这是如何实现的。

作为本节的最后一条注意事项,您应该知道在类 Unix 操作系统中,我们有两种类型的命名信号量。一种是系统 V 信号量,另一种是POSIX 信号量。在本节中,我们解释了 POSIX 信号量,因为它们因其良好的 API 和性能而享有更好的声誉。以下链接是一个 Stack Overflow 问题,它很好地解释了系统 V 信号量和 POSIX 信号量之间的区别:stackoverflow.com/questions/368322/differences-between-system-v-and-posix-semaphores

注意:

在使用信号量的方面,Microsoft Windows 不符合 POSIX 标准,并且它有自己的 API 来创建和管理信号量。

在下一节中,我们将讨论命名互斥锁。简而言之,命名互斥锁是将普通互斥锁对象放入共享内存区域。

命名互斥锁

POSIX 互斥锁在多线程程序中工作简单;我们在第十六章线程同步中展示了这一点。然而,在多个进程环境中则不是这样。为了使互斥锁在多个进程之间工作,它需要在所有进程都可以访问的地方定义。

对于这样一个共享位置,最佳选择是共享内存区域。因此,为了在多进程环境中使用互斥锁,它应该分布在共享内存区域中。

第一个示例

以下示例,示例 18.2,是示例 18.1的一个克隆,但它使用命名互斥锁而不是命名信号量来解决潜在的竞态条件。它还展示了如何创建共享内存区域并使用它来存储共享互斥锁。

由于每个共享内存对象都有一个全局名称,存储在共享内存区域中的互斥锁可以被认为是命名的,并且可以通过系统中的其他进程访问。

以下代码框显示了示例 18.2所需的声明。它显示了需要共享互斥锁的内容:

#include <stdio.h>
...
#include <pthread.h> // For using pthread_mutex_* functions
#define SHARED_MEM_SIZE 4
// Shared file descriptor used to refer to shared memory object
int shared_fd = -1;
// Shared file descriptor used to refer to the mutex's shared
// memory object
int mutex_shm_fd = -1;
// The pointer to the shared counter
int32_t* counter = NULL;
// The pointer to shared mutex
pthread_mutex_t* mutex = NULL;

代码框 18-5 [ExtremeC_examples_chapter18_2.c]:示例 18.2 的全局声明

如您所见,我们已经声明了:

  • 一个全局文件描述符,用于指向一个存储共享计数器变量的共享内存区域

  • 存储共享互斥锁的共享内存区域的全局文件描述符

  • 共享计数器的指针

  • 共享互斥锁的指针

这些变量将由即将到来的逻辑相应填充。

以下代码框显示了我们在示例 18.1中拥有的所有函数,但如您所见,定义已更新以使用命名互斥锁而不是命名信号量:

void init_control_mechanism() {
  // Open the mutex shared memory
  mutex_shm_fd = shm_open("/mutex0", O_CREAT | O_RDWR, 0600);
  if (mutex_shm_fd < 0) {
    fprintf(stderr, "ERROR: Failed to create shared memory: %s\n"
        , strerror(errno));
    exit(1);
  }
  // Allocate and truncate the mutex's shared memory region
  if (ftruncate(mutex_shm_fd, sizeof(pthread_mutex_t)) < 0) {
    fprintf(stderr, "ERROR: Truncation of mutex failed: %s\n",
        strerror(errno));
    exit(1);
  }
  // Map the mutex's shared memory
  void* map = mmap(0, sizeof(pthread_mutex_t),
          PROT_READ | PROT_WRITE, MAP_SHARED, mutex_shm_fd, 0);
  if (map == MAP_FAILED) {
    fprintf(stderr, "ERROR: Mapping failed: %s\n",
            strerror(errno));
    exit(1);
  }
  mutex = (pthread_mutex_t*)map;
  // Initialize the mutex object
  int ret = -1;
  pthread_mutexattr_t attr;
  if ((ret = pthread_mutexattr_init(&attr))) {
    fprintf(stderr, "ERROR: Failed to init mutex attrs: %s\n",
        strerror(ret));
    exit(1);
  }
  if ((ret = pthread_mutexattr_setpshared(&attr,
                  PTHREAD_PROCESS_SHARED))) {
    fprintf(stderr, "ERROR: Failed to set the mutex attr: %s\n",
        strerror(ret));
    exit(1);
  }
  if ((ret = pthread_mutex_init(mutex, &attr))) {
    fprintf(stderr, "ERROR: Initializing the mutex failed: %s\n",
        strerror(ret));
    exit(1);
  }
  if ((ret = pthread_mutexattr_destroy(&attr))) {
    fprintf(stderr, "ERROR: Failed to destroy mutex attrs : %s\n"
        , strerror(ret));
    exit(1);
  }
}

代码框 18-6 [ExtremeC_examples_chapter18_2.c]:示例 18.2 中的 init_control_mechanism 函数

作为函数init_control_mechanism的一部分,我们创建了一个名为/mutex0的新共享内存对象。共享内存区域的大小初始化为sizeof(pthread_mutex_t),这表明我们的意图是在那里共享一个 POSIX 互斥锁对象。

接下来,我们得到共享内存区域的指针。现在我们有一个从共享内存分配的互斥锁,但它仍然需要初始化。因此,下一步是使用函数pthread_mutex_init初始化互斥锁对象,并使用属性指示互斥锁对象应该是共享的,并且可以被其他进程访问。这一点尤为重要;否则,即使在共享内存区域内部,互斥锁在多进程环境中也不会工作。正如你在前面的代码框中看到的,以及在函数init_control_mechanism中,我们已经设置了属性PTHREAD_PROCESS_SHARED来标记互斥锁为共享的。让我们看看下一个函数:

void shutdown_control_mechanism() {
  int ret = -1;
  if ((ret = pthread_mutex_destroy(mutex))) {
    fprintf(stderr, "ERROR: Failed to destroy mutex: %s\n",
        strerror(ret));
    exit(1);
  }
  if (munmap(mutex, sizeof(pthread_mutex_t)) < 0) {
    fprintf(stderr, "ERROR: Unmapping the mutex failed: %s\n",
        strerror(errno));
    exit(1);
  }
  if (close(mutex_shm_fd) < 0) {
    fprintf(stderr, "ERROR: Closing the mutex failed: %s\n",
        strerror(errno));
    exit(1);
  }
  if (shm_unlink("/mutex0") < 0) {
    fprintf(stderr, "ERROR: Unlinking the mutex failed: %s\n",
        strerror(errno));
    exit(1);
  }
}

代码框 18-7 [ExtremeC_examples_chapter18_2.c]:示例 18.2 中的函数 destroy_control_mechanism

在函数destroy_control_mechanism中,我们销毁了互斥锁对象,然后关闭并解除链接其底层的共享内存区域。这与销毁一个普通共享内存对象的方式相同。让我们继续看示例中的其他代码:

void init_shared_resource() {
  ... as in the example 18.1 ...
}
void shutdown_shared_resource() {
  ... as in the example 18.1 ...
}

代码框 18-8 [ExtremeC_examples_chapter18_2.c]:这些函数与我们之前在示例 18.1 中看到的是一样的

正如你所见,前面的函数完全没有改变,它们与我们之前在示例 18.1中看到的是一样的。让我们看看函数inc_counter内部的临界区,现在它使用命名互斥锁而不是命名信号量。

void inc_counter() {
  usleep(1);
  pthread_mutex_lock(mutex); // Should check the return value.
  int32_t temp = *counter;
  usleep(1);
  temp++;
  usleep(1);
  *counter = temp;
  pthread_mutex_unlock(mutex); // Should check the return value.
  usleep(1);
}
int main(int argc, char** argv) {
  ... as in the example 18.1 ...
}

代码框 18-9 [ExtremeC_examples_chapter18_2.c]:现在关键部分使用命名互斥锁来保护共享计数器

通常,正如你在前面的代码框中看到的,只有几个地方与示例 18.1不同,我们只对三个函数进行了重大修改。例如,函数main完全没有改变,它与示例 18.1中的相同。这仅仅是因为我们与示例 18.1相比使用了不同的控制机制,而其余的逻辑是相同的。

关于代码框 18-9的最后一项说明,在函数inc_counter中,我们使用了互斥对象,就像我们在多线程程序中所做的那样。API 是相同的,并且它被设计成可以在多线程和多进程环境中使用相同的 API 来使用互斥锁。这是 POSIX 互斥锁的一个伟大特性,因为它使我们能够在多线程和多进程环境中使用相同的代码来消费这些对象——当然,初始化和销毁可以不同。

上述代码的输出与我们观察到的 示例 18.1 非常相似。虽然在这个例子中共享计数器由互斥锁保护,但在上一个例子中它是由信号量保护的。上一个例子中使用的信号量实际上是一个二进制信号量,正如我们在 第十六章线程同步 中所解释的,二进制信号量可以模拟互斥锁。因此,除了将二进制信号量替换为互斥锁之外,示例 18.2 中并没有太多新内容。

第二个示例

命名的共享内存和互斥锁可以被系统中的任何进程使用。不需要有派生的进程才能使用这些对象。下面的例子,示例 18.3,试图展示我们如何使用共享互斥锁和共享内存同时终止所有同时运行的进程。我们期望在按下其中一个进程的键组合 Ctrl + C 后,所有进程都将终止。

注意,代码将分多步提供。与每个步骤相关的注释将紧随其后。让我们先展示第一步。

步骤 1 – 全局声明

在这个例子中,我们编写一个可以编译和执行多次的单个源文件,以创建多个进程。这些进程使用一些共享内存区域来同步它们的执行。其中一个进程被选为共享内存区域的拥有者,并管理它们的创建和销毁。其他进程只是使用创建的共享内存。

第一步是声明一些我们在整个代码中需要的全局对象。我们将在代码的后面部分初始化它们。请注意,在以下代码框中定义的全局变量,如 mutex,实际上并不是在进程间共享的。它们在自己的内存空间中有这些变量,但每个进程都将自己的全局变量映射到位于各个共享内存区域中的对象或变量:

#include <stdio.h>
...
#include <pthread.h> // For using pthread_mutex_* functions
typedef uint16_t bool_t;
#define TRUE 1
#define FALSE 0
#define MUTEX_SHM_NAME "/mutex0"
#define SHM_NAME "/shm0"
// Shared file descriptor used to refer to the shared memory
// object containing the cancel flag
int cancel_flag_shm_fd = -1;
// A flag which indicates whether the current process owns the
// shared memory object
bool_t cancel_flag_shm_owner = FALSE;
// Shared file descriptor used to refer to the mutex's shared
// memory object
int mutex_shm_fd = -1;
// The shared mutex
pthread_mutex_t* mutex = NULL;
// A flag which indicates whether the current process owns the
// shared memory object
bool_t mutex_owner = FALSE;
// The pointer to the cancel flag stored in the shared memory
bool_t* cancel_flag = NULL;

代码框 18-10 [ExtremeC_examples_chapter18_3.c]:示例 18.3 中的全局声明

在前面的代码中,我们可以看到代码中使用的全局声明。我们将使用一个共享标志让进程知道取消信号。请注意,在这个例子中,我们将采用忙等待的方法来等待取消标志变为 true

我们有一个专门的共享内存对象用于取消标志,还有一个共享内存对象用于保护标志的互斥锁,就像我们在 示例 18.2 中做的那样。请注意,我们可以构造一个单一的结构,并将取消标志和互斥锁对象定义为它的字段,然后使用一个单一的共享内存区域来存储它们。但我们选择使用单独的共享内存区域来实现我们的目的。

在这个例子中,关于共享内存对象的一个重要注意事项是,清理工作应由最初创建和初始化它们的进程执行。由于所有进程都在使用相同的代码,我们 somehow 需要知道哪个进程创建了一个特定的共享内存对象,并使该进程成为该对象的拥有者。然后,在清理对象时,只有拥有者进程可以继续并进行实际的清理。因此,我们不得不为此目的声明了两个布尔变量:mutex_ownercancel_flag_shm_owner

第 2 步 - 取消标志的共享内存

以下代码框展示了专门用于取消标志的共享内存区域的初始化:

void init_shared_resource() {
  // Open the shared memory object
  cancel_flag_shm_fd = shm_open(SHM_NAME, O_RDWR, 0600);
  if (cancel_flag_shm_fd >= 0) {
    cancel_flag_shm_owner = FALSE;
    fprintf(stdout, "The shared memory object is opened.\n");
  } else if (errno == ENOENT) {
    fprintf(stderr,
            "WARN: The shared memory object doesn't exist.\n");
    fprintf(stdout, "Creating the shared memory object ...\n");
    cancel_flag_shm_fd = shm_open(SHM_NAME,
            O_CREAT | O_EXCL | O_RDWR, 0600);
    if (cancel_flag_shm_fd >= 0) {
      cancel_flag_shm_owner = TRUE;
      fprintf(stdout, "The shared memory object is created.\n");
    } else {
      fprintf(stderr,
          "ERROR: Failed to create shared memory: %s\n",
          strerror(errno));
      exit(1);
    }
  } else {
      fprintf(stderr,
          "ERROR: Failed to create shared memory: %s\n",
          strerror(errno));
    exit(1);
  }
  if (cancel_flag_shm_owner) {
    // Allocate and truncate the shared memory region
    if (ftruncate(cancel_flag_shm_fd, sizeof(bool_t)) < 0) {
      fprintf(stderr, "ERROR: Truncation failed: %s\n",
              strerror(errno));
      exit(1);
    }
    fprintf(stdout, "The memory region is truncated.\n");
  }
  // Map the shared memory and initialize the cancel flag
  void* map = mmap(0, sizeof(bool_t), PROT_WRITE, MAP_SHARED,
      cancel_flag_shm_fd, 0);
  if (map == MAP_FAILED) {
    fprintf(stderr, "ERROR: Mapping failed: %s\n",
            strerror(errno));
    exit(1);
  }
  cancel_flag = (bool_t*)map;
  if (cancel_flag_shm_owner) {
    *cancel_flag = FALSE;
  }
}

代码框 18-11 [ExtremeC_examples_chapter18_3.c]:取消标志共享内存的初始化

我们采取的方法与我们之前在示例 18.2中所做的方法不同。这是因为每当运行一个新的进程时,它都应该检查共享内存对象是否已经被另一个进程创建。请注意,在这个例子中,我们没有使用fork API 来创建新进程,用户可以使用他们的 shell 随意启动新进程。

因此,新进程首先尝试仅通过提供标志O_RDWR来打开共享内存区域。如果成功,则表明当前进程不是该区域的拥有者,然后它继续映射共享内存区域。如果失败,则表示共享内存区域不存在,这是当前进程应该创建该区域并成为其拥有者的一个指示。因此,它继续尝试以不同的标志打开区域;O_CREATO_EXCL。这些标志在不存在的情况下创建共享内存对象。

如果创建成功,则当前进程是拥有者,并且它继续通过截断和映射共享内存区域。

在前一个场景中,shm_open函数的两次连续调用之间,另一个进程可能会创建相同的共享内存区域,因此第二次shm_open调用失败。标志O_EXCL防止当前进程创建一个已经存在的对象,然后通过显示适当的错误消息退出。如果发生这种情况,这应该非常罕见,我们总是可以尝试再次运行该进程,并且在第二次运行中它不会遇到同样的问题。

以下代码是用于撤销取消标志及其共享内存区域的反向操作:

void shutdown_shared_resource() {
  if (munmap(cancel_flag, sizeof(bool_t)) < 0) {
    fprintf(stderr, "ERROR: Unmapping failed: %s\n",
            strerror(errno));
    exit(1);
  }
  if (close(cancel_flag_shm_fd) < 0) {
    fprintf(stderr,
        "ERROR: Closing the shared memory fd filed: %s\n",
        strerror(errno));
    exit(1);
  }
  if (cancel_flag_shm_owner) {
    sleep(1);
    if (shm_unlink(SHM_NAME) < 0) {
      fprintf(stderr,
          "ERROR: Unlinking the shared memory failed: %s\n",
          strerror(errno));
      exit(1);
    }
  }
}

代码框 18-12 [ExtremeC_examples_chapter18_3.c]:关闭为取消标志共享内存分配的资源

如您在代码框 18-12中看到的,书写的逻辑与我们之前在释放共享内存对象的部分示例中看到的内容非常相似。但这里有一个区别,那就是只有所有者进程才能解除共享内存对象的链接。请注意,所有者进程在解除共享内存对象的链接之前会等待 1 秒,以便让其他进程完成资源释放。由于在大多数 POSIX 兼容系统中,共享内存对象会一直保留,直到所有依赖的进程退出,因此这种等待通常是不必要的。

第 3 步 – 命名互斥锁的共享内存

以下代码框显示了如何初始化共享互斥锁及其关联的共享内存对象:

void init_control_mechanism() {
  // Open the mutex shared memory
  mutex_shm_fd = shm_open(MUTEX_SHM_NAME, O_RDWR, 0600);
  if (mutex_shm_fd >= 0) {
    // The mutex's shared object exists and I'm now the owner.
    mutex_owner = FALSE;
    fprintf(stdout,
            "The mutex's shared memory object is opened.\n");
  } else if (errno == ENOENT) {
    fprintf(stderr,
            "WARN: Mutex's shared memory doesn't exist.\n");
    fprintf(stdout,
            "Creating the mutex's shared memory object ...\n");
    mutex_shm_fd = shm_open(MUTEX_SHM_NAME,
            O_CREAT | O_EXCL | O_RDWR, 0600);
    if (mutex_shm_fd >= 0) {
      mutex_owner = TRUE;
      fprintf(stdout,
              "The mutex's shared memory object is created.\n");
    } else {
      fprintf(stderr,
          "ERROR: Failed to create mutex's shared memory: %s\n",
          strerror(errno));
      exit(1);
    }
  } else {
    fprintf(stderr,
        "ERROR: Failed to create mutex's shared memory: %s\n",
        strerror(errno));
    exit(1);
  }
  if (mutex_owner) {
    // Allocate and truncate the mutex's shared memory region
  }
  if (mutex_owner) {
    // Allocate and truncate the mutex's shared memory region
    if (ftruncate(mutex_shm_fd, sizeof(pthread_mutex_t)) < 0) {
      fprintf(stderr,
          "ERROR: Truncation of the mutex failed: %s\n",
          strerror(errno));
      exit(1);
    }
  }
  // Map the mutex's shared memory
  void* map = mmap(0, sizeof(pthread_mutex_t),
          PROT_READ | PROT_WRITE, MAP_SHARED, mutex_shm_fd, 0);
  if (map == MAP_FAILED) {
    fprintf(stderr, "ERROR: Mapping failed: %s\n",
            strerror(errno));
    exit(1);
  }
  mutex = (pthread_mutex_t*)map;
  if (mutex_owner) {
    int ret = -1;
    pthread_mutexattr_t attr;
    if ((ret = pthread_mutexattr_init(&attr))) {
      fprintf(stderr,
          "ERROR: Initializing mutex attributes failed: %s\n",
          strerror(ret));
      exit(1);
    }
    if ((ret = pthread_mutexattr_setpshared(&attr,
                    PTHREAD_PROCESS_SHARED))) {
      fprintf(stderr,
          "ERROR: Setting the mutex attribute failed: %s\n",
          strerror(ret));
      exit(1);
    }
    if ((ret = pthread_mutex_init(mutex, &attr))) {
      fprintf(stderr,
          "ERROR: Initializing the mutex failed: %s\n",
          strerror(ret));
      exit(1);
    }
    if ((ret = pthread_mutexattr_destroy(&attr))) {
      fprintf(stderr,
          "ERROR: Destruction of mutex attributes failed: %s\n",
          strerror(ret));
      exit(1);
    }
  }
}

代码框 18-13 [ExtremeC_examples_chapter18_3.c]:初始化共享互斥锁及其底层共享内存区域

与我们尝试创建与取消标志关联的共享内存区域时所做的操作类似,我们为创建和初始化共享互斥锁下的共享内存区域做了同样的事情。请注意,就像在 示例 18.2 中一样,互斥锁已被标记为 PTHREAD_PROCESS_SHARED,这允许它被多个进程使用。

以下代码框显示了如何最终化共享互斥锁:

void shutdown_control_mechanism() {
  sleep(1);
  if (mutex_owner) {
    int ret = -1;
    if ((ret = pthread_mutex_destroy(mutex))) {
      fprintf(stderr,
          "WARN: Destruction of the mutex failed: %s\n",
          strerror(ret));
    }
  }
  if (munmap(mutex, sizeof(pthread_mutex_t)) < 0) {
    fprintf(stderr, "ERROR: Unmapping the mutex failed: %s\n",
        strerror(errno));
    exit(1);
  }
  if (close(mutex_shm_fd) < 0) {
    fprintf(stderr, "ERROR: Closing the mutex failed: %s\n",
        strerror(errno));
    exit(1);
  }
  if (mutex_owner) {
    if (shm_unlink(MUTEX_SHM_NAME) < 0) {
      fprintf(stderr, "ERROR: Unlinking the mutex failed: %s\n",
          strerror(errno));
      exit(1);
    }
  }
}

代码框 18-14 [ExtremeC_examples_chapter18_3.c]:关闭共享互斥锁及其关联的共享内存区域

再次强调,所有者进程只能解除共享互斥锁的共享内存对象的链接。

第 4 步 – 设置取消标志

以下代码框显示了允许进程读取或设置取消标志的函数:

bool_t is_canceled() {
  pthread_mutex_lock(mutex); // Should check the return value
  bool_t temp = *cancel_flag;
  pthread_mutex_unlock(mutex); // Should check the return value
  return temp;
}
void cancel() {
  pthread_mutex_lock(mutex); // Should check the return value
  *cancel_flag = TRUE;
  pthread_mutex_unlock(mutex); // Should check the return value
}

代码框 18-15 [ExtremeC_examples_chapter18_3.c]:受共享互斥锁保护的读取和设置取消标志的同步函数

前两个函数允许我们对共享取消标志进行同步访问。函数 is_canceled 用于检查标志的值,而函数 cancel 用于设置标志。如您所见,这两个函数都受到相同的共享互斥锁的保护。

第 5 步 – 主函数

最后,以下代码框显示了 main 函数和一个我们将简要解释的 信号处理程序

void sigint_handler(int signo) {
  fprintf(stdout, "\nHandling INT signal: %d ...\n", signo);
  cancel();
}
int main(int argc, char** argv) {
  signal(SIGINT, sigint_handler);
  // Parent process needs to initialize the shared resource
  init_shared_resource();
  // Parent process needs to initialize the control mechanism
  init_control_mechanism();
  while(!is_canceled()) {
    fprintf(stdout, "Working ...\n");
    sleep(1);
  }
  fprintf(stdout, "Cancel signal is received.\n");
  shutdown_shared_resource();
  shutdown_control_mechanism();
  return 0;
}

代码框 18-16 [ExtremeC_examples_chapter18_3.c]:示例 18.3 中的 main 函数和信号处理函数

如您所见,main 函数内部的逻辑清晰且直接。它初始化共享标志和互斥锁,然后进入忙等待状态,直到取消标志变为 true。最后,它关闭所有共享资源并终止。

这里新的是 signal 函数的使用,它将信号处理器分配给特定的 信号 集合。信号是所有 POSIX 兼容操作系统提供的一种设施,使用它可以,系统内的进程可以向彼此发送信号。终端 是用户与之交互的一个普通进程,它可以用来向其他进程发送信号。按下 Ctrl + C 是向终端中运行的前台进程发送 SIGINT 的一种方便方法。

SIGINT 是进程可以接收的 中断信号。在前面代码中,我们将函数 sigint_handler 分配为 SIGINT 信号的处理器。换句话说,每当进程接收到 SIGINT 信号时,函数 sigint_handler 将被调用。如果未处理 SIGINT 信号,则默认操作是终止进程,但可以使用如上所示的信号处理器来覆盖此操作。

向进程发送 SIGINT 信号有许多方法,但其中最简单的一种是在键盘上按下 Ctrl + C 键。进程将立即接收到 SIGINT 信号。正如你所见,在信号处理程序中,我们将共享取消标志设置为 true,从这一点开始,所有进程开始退出它们的忙等待循环。

以下是如何编译和运行前面代码的演示。让我们构建前面的代码并运行第一个进程:

$ gcc ExtremeC_examples_chapter18_3.c -lpthread -lrt -o ex18_3.out
$ ./ex18_3.out
WARN: The shared memory object doesn't exist.
Creating a shared memory object ...
The shared memory object is created.
The memory region is truncated.
WARN: Mutex's shared memory object doesn't exist.
Creating the mutex's shared memory object ...
The mutex's shared memory object is created.
Working ...
Working ...
Working ...

Shell Box 18-2:编译示例 18.3 并运行第一个进程

正如你所见,前面的进程是首先运行的,因此它是互斥锁和取消标志的所有者。以下为第二个进程的运行情况:

$ ./ex18_3.out
The shared memory object is opened.
The mutex's shared memory object is opened.
Working ...
Working ...
Working ...

Shell Box 18-3:运行第二个进程

正如你所见,第二个进程只打开了共享内存对象,它不是所有者。以下输出是在第一个进程上按下 Ctrl + C 后的输出:

...
Working ...
Working ...
^C
Handling INT signal: 2 ...
Cancel signal is received.
$

Shell Box 18-4:按下 Ctrl + C 后第一个进程的输出

正如你所见,第一个进程打印出它正在处理编号为 2 的信号,这是 SIGINT 的标准信号编号。它设置了取消标志,并立即退出。随后,第二个进程退出。以下为第二个进程的输出:

...
Working ...
Working ...
Working ...
Cancel signal is received.
$

Shell Box 18-5:当第二个进程看到取消标志被设置时的输出

此外,你也可以向第二个进程发送 SIGINT 信号,结果将相同;两个进程都会接收到信号并退出。你也可以创建超过两个进程,并且它们都将使用相同的共享内存和互斥锁同步退出。

在下一节中,我们将演示如何使用条件变量。就像命名互斥锁一样,如果你在共享内存区域中放置一个条件变量,它可以通过共享内存的名称被多个进程访问和使用。

命名条件变量

如我们之前所解释的,与命名 POSIX 互斥锁类似,为了在多进程系统中使用它,我们需要从共享内存区域分配一个 POSIX 条件变量。以下示例,示例 18.4,展示了如何这样做,以便让多个进程按特定顺序计数。正如您从 第十六章线程同步 中所知,每个条件变量都应该与一个保护它的伴随互斥对象一起使用。因此,在 示例 18.4 中,我们将有三个共享内存区域;一个用于共享计数器,一个用于共享 命名条件变量,还有一个用于保护共享条件变量的共享 命名互斥锁

注意,我们也可以使用单个共享内存而不是三个不同的共享内存。这是通过定义一个包含所有所需对象的结构的实现。在这个例子中,我们不会采取这种方法,我们将为每个对象定义一个单独的共享内存区域。

示例 18.4 是关于一些进程,它们应该按升序计数。每个进程都会被分配一个数字,从 1 开始,到进程的数量,给定的数字表示该进程在其他进程中的排名。进程必须等待排名(编号)较小的其他进程先计数,然后它才能计数并退出。当然,分配编号 1 的进程将首先计数,即使它是最后创建的进程。

由于我们将有三个不同的共享内存区域,每个区域都需要自己的初始化和终止步骤,如果我们想要采取与之前示例中相同的方法,那么我们将有大量的代码重复。为了减少我们编写的代码量,将重复的部分提取到一些函数中,并使代码组织得更好,我们将根据 第六章面向对象编程和封装第七章组合和聚合,以及 第八章继承和多态 中讨论的主题和程序,将其做成面向对象的。我们将以面向对象的方式编写 示例 18.4,并使用继承来减少重复代码的数量。

我们将为所有需要建立在共享内存区域之上的类定义一个父类。因此,在拥有父共享内存类的同时,我们将定义一个子类用于共享计数器,一个子类用于共享命名互斥锁,另一个子类用于共享命名条件变量。每个类都将有自己的头文件和源文件对,所有这些最终都将用于示例的主函数中。

以下章节将逐个介绍所提到的类。首先,让我们从父类:共享内存开始。

第 1 步 – 共享内存类

以下代码框显示了共享内存类的声明:

struct shared_mem_t;
typedef int32_t bool_t; 
struct shared_mem_t* shared_mem_new();
void shared_mem_delete(struct shared_mem_t* obj);
void shared_mem_ctor(struct shared_mem_t* obj,
                     const char* name,
                     size_t size);
void shared_mem_dtor(struct shared_mem_t* obj);
char* shared_mem_getptr(struct shared_mem_t* obj);
bool_t shared_mem_isowner(struct shared_mem_t* obj);
void shared_mem_setowner(struct shared_mem_t* obj, bool_t is_owner);

代码框 18-17 [ExtremeC_examples_chapter18_4_shared_mem.h]: 共享内存类的公共接口

前面的代码包含了使用共享内存对象所需的所有声明(公共 API)。函数 shared_mem_getptrshared_mem_isownershared_mem_setowner 是这个类的行为。

如果这个语法对你来说不熟悉,请阅读 第六章面向对象编程和封装第七章组合和聚合,以及 第八章继承和多态

以下代码框展示了作为类公共接口一部分的函数定义,正如在 代码框 18-17 中所见:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/mman.h>
#define TRUE 1
#define FALSE 0
typedef int32_t bool_t;
bool_t owner_process_set = FALSE;
bool_t owner_process = FALSE;
typedef struct {
  char* name;
  int shm_fd;
  void* map_ptr;
  char* ptr;
  size_t size;
} shared_mem_t;
shared_mem_t* shared_mem_new() {
  return (shared_mem_t*)malloc(sizeof(shared_mem_t));
}
void shared_mem_delete(shared_mem_t* obj) {
  free(obj->name);
  free(obj);
}
void shared_mem_ctor(shared_mem_t* obj, const char* name,
        size_t size) {
  obj->size = size;
  obj->name = (char*)malloc(strlen(name) + 1);
  strcpy(obj->name, name);
  obj->shm_fd = shm_open(obj->name, O_RDWR, 0600);
  if (obj->shm_fd >= 0) {
    if (!owner_process_set) {
      owner_process = FALSE;
      owner_process_set = TRUE;
    }
    printf("The shared memory %s is opened.\n", obj->name);
  } else if (errno == ENOENT) {
    printf("WARN: The shared memory %s does not exist.\n",
            obj->name);
    obj->shm_fd = shm_open(obj->name,
            O_CREAT | O_RDWR, 0600);
    if (obj->shm_fd >= 0) {
      if (!owner_process_set) {
        owner_process = TRUE;
        owner_process_set = TRUE;
      }
      printf("The shared memory %s is created and opened.\n",
              obj->name);
      if (ftruncate(obj->shm_fd, obj->size) < 0) {
        fprintf(stderr, "ERROR(%s): Truncation failed: %s\n",
            obj->name, strerror(errno));
        exit(1);
      }
    } else {
      fprintf(stderr,
          "ERROR(%s): Failed to create shared memory: %s\n",
          obj->name, strerror(errno));
      exit(1);
    }
  } else {
      fprintf(stderr,
          "ERROR(%s): Failed to create shared memory: %s\n",
          obj->name, strerror(errno));
    exit(1);
  }
  obj->map_ptr = mmap(0, obj->size, PROT_READ | PROT_WRITE,
      MAP_SHARED, obj->shm_fd, 0);
  if (obj->map_ptr == MAP_FAILED) {
    fprintf(stderr, "ERROR(%s): Mapping failed: %s\n",
        name, strerror(errno));
    exit(1);
  }
  obj->ptr = (char*)obj->map_ptr;
}
void shared_mem_dtor(shared_mem_t* obj) {
  if (munmap(obj->map_ptr, obj->size) < 0) {
    fprintf(stderr, "ERROR(%s): Unmapping failed: %s\n",
        obj->name, strerror(errno));
    exit(1);
  }
  printf("The shared memory %s is unmapped.\n", obj->name);
  if (close(obj->shm_fd) < 0) {
    fprintf(stderr,
        "ERROR(%s): Closing the shared memory fd failed: %s\n",
        obj->name, strerror(errno));
    exit(1);
  }
  printf("The shared memory %s is closed.\n", obj->name);
  if (owner_process) {
    if (shm_unlink(obj->name) < 0) {
      fprintf(stderr,
          "ERROR(%s): Unlinking the shared memory failed: %s\n",
          obj->name, strerror(errno));
      exit(1);
    }
    printf("The shared memory %s is deleted.\n", obj->name);
  }
}
char* shared_mem_getptr(shared_mem_t* obj) {
  return obj->ptr;
}
bool_t shared_mem_isowner(shared_mem_t* obj) {
  return owner_process;
}
void shared_mem_setowner(shared_mem_t* obj, bool_t is_owner) {
    owner_process = is_owner;
}

代码框 18-18 [ExtremeC_examples_chapter18_4_shared_mem.c]: 共享内存类中找到的所有函数的定义

如你所见,我们只是复制了之前示例中为共享内存编写的代码。结构 shared_mem_t 封装了我们用来访问 POSIX 共享内存对象所需的所有内容。注意全局布尔变量 process_owner。它表示当前进程是否是所有共享内存区域的拥有者。它只设置一次。

第 2 步 – 共享 32 位整数计数器类

以下代码框包含共享计数器类的声明,这是一个 32 位整数计数器。这个类从共享内存类继承。正如你可能已经注意到的,我们正在使用 第八章继承和多态 中描述的第二种方法来实现继承关系:

struct shared_int32_t;
struct shared_int32_t* shared_int32_new();
void shared_int32_delete(struct shared_int32_t* obj);
void shared_int32_ctor(struct shared_int32_t* obj,
                       const char* name);
void shared_int32_dtor(struct shared_int32_t* obj);
void shared_int32_setvalue(struct shared_int32_t* obj,
                           int32_t value);
void shared_int32_setvalue_ifowner(struct shared_int32_t* obj,
                                   int32_t value);
int32_t shared_int32_getvalue(struct shared_int32_t* obj);

代码框 18-19 [ExtremeC_examples_chapter18_4_shared_int32.h]: 共享计数器类的公共接口

以下代码框展示了前面声明的函数的实现:

#include "ExtremeC_examples_chapter18_4_shared_mem.h"
typedef struct {
  struct shared_mem_t* shm;
  int32_t* ptr;
} shared_int32_t;
shared_int32_t* shared_int32_new(const char* name) {
  shared_int32_t* obj =
      (shared_int32_t*)malloc(sizeof(shared_int32_t));
  obj->shm = shared_mem_new();
  return obj;
}
void shared_int32_delete(shared_int32_t* obj) {
  shared_mem_delete(obj->shm);
  free(obj);
}
void shared_int32_ctor(shared_int32_t* obj, const char* name) {
  shared_mem_ctor(obj->shm, name, sizeof(int32_t));
  obj->ptr = (int32_t*)shared_mem_getptr(obj->shm);
}
void shared_int32_dtor(shared_int32_t* obj) {
  shared_mem_dtor(obj->shm);
}
void shared_int32_setvalue(shared_int32_t* obj, int32_t value) {
  *(obj->ptr) = value;
}
void shared_int32_setvalue_ifowner(shared_int32_t* obj,
                                   int32_t value) {
  if (shared_mem_isowner(obj->shm)) {
    *(obj->ptr) = value;
  }
}
int32_t shared_int32_getvalue(shared_int32_t* obj) {
  return *(obj->ptr);
}

代码框 18-20 [ExtremeC_examples_chapter18_4_shared_int32.c]: 共享计数器类中找到的所有函数的定义

如你所见,由于继承,我们编写了更少的代码。管理相关共享内存对象所需的所有代码都通过结构 shared_int32_t 中的字段 shm 带入。

第 3 步 – 共享互斥器类

以下代码框包含共享互斥器类的声明:

#include <pthread.h>
struct shared_mutex_t;
struct shared_mutex_t* shared_mutex_new();
void shared_mutex_delete(struct shared_mutex_t* obj);
void shared_mutex_ctor(struct shared_mutex_t* obj,
                       const char* name);
void shared_mutex_dtor(struct shared_mutex_t* obj);
pthread_mutex_t* shared_mutex_getptr(struct shared_mutex_t* obj);
void shared_mutex_lock(struct shared_mutex_t* obj);
void shared_mutex_unlock(struct shared_mutex_t* obj);
#if !defined(__APPLE__)
void shared_mutex_make_consistent(struct shared_mutex_t* obj);
#endif

代码框 18-21 [ExtremeC_examples_chapter18_4_shared_mutex.h]: 共享互斥器类的公共接口

如你所见,上述类有三种预期的公开行为;shared_mutex_lockshared_mutex_unlockshared_mutex_make_consistent。但有一个例外,即行为 shared_mutex_make_consistent 只在 POSIX 系统中可用,不包括基于 macOS(苹果)的系统。这是因为苹果系统不支持 健壮互斥锁。我们将在接下来的段落中讨论什么是健壮互斥锁。请注意,我们使用了宏 __APPLE__ 来检测我们是否在苹果系统上编译。

以下代码框展示了前面类实现的代码:

#include "ExtremeC_examples_chapter18_4_shared_mem.h"
typedef struct {
  struct shared_mem_t* shm;
  pthread_mutex_t* ptr;
} shared_mutex_t;
shared_mutex_t* shared_mutex_new() {
  shared_mutex_t* obj =
      (shared_mutex_t*)malloc(sizeof(shared_mutex_t));
  obj->shm = shared_mem_new();
  return obj;
}
void shared_mutex_delete(shared_mutex_t* obj) {
  shared_mem_delete(obj->shm);
  free(obj);
}
void shared_mutex_ctor(shared_mutex_t* obj, const char* name) {
  shared_mem_ctor(obj->shm, name, sizeof(pthread_mutex_t));
  obj->ptr = (pthread_mutex_t*)shared_mem_getptr(obj->shm);
  if (shared_mem_isowner(obj->shm)) {
    pthread_mutexattr_t mutex_attr;
    int ret = -1;
    if ((ret = pthread_mutexattr_init(&mutex_attr))) {
      fprintf(stderr,
          "ERROR(%s): Initializing mutex attrs failed: %s\n",
          name, strerror(ret));
      exit(1);
    }
#if !defined(__APPLE__)
    if ((ret = pthread_mutexattr_setrobust(&mutex_attr,
                    PTHREAD_MUTEX_ROBUST))) {
      fprintf(stderr,
          "ERROR(%s): Setting the mutex as robust failed: %s\n",
          name, strerror(ret));
      exit(1);
    }
#endif
    if ((ret = pthread_mutexattr_setpshared(&mutex_attr,
                    PTHREAD_PROCESS_SHARED))) {
      fprintf(stderr,
          "ERROR(%s): Failed to set as process-shared: %s\n",
          name, strerror(ret));
      exit(1);
    }
    if ((ret = pthread_mutex_init(obj->ptr, &mutex_attr))) {
      fprintf(stderr,
          "ERROR(%s): Initializing the mutex failed: %s\n",
          name, strerror(ret));
      exit(1);
    }
    if ((ret = pthread_mutexattr_destroy(&mutex_attr))) {
      fprintf(stderr,
          "ERROR(%s): Destruction of mutex attrs failed: %s\n",
          name, strerror(ret));
      exit(1);
    }
  }
}
void shared_mutex_dtor(shared_mutex_t* obj) {
  if (shared_mem_isowner(obj->shm)) {
    int ret = -1;
    if ((ret = pthread_mutex_destroy(obj->ptr))) {
      fprintf(stderr,
          "WARN: Destruction of the mutex failed: %s\n",
          strerror(ret));
    }
  }
  shared_mem_dtor(obj->shm);
}
pthread_mutex_t* shared_mutex_getptr(shared_mutex_t* obj) {
  return obj->ptr;
}
#if !defined(__APPLE__)
void shared_mutex_make_consistent(shared_mutex_t* obj) {
  int ret = -1;
  if ((ret = pthread_mutex_consistent(obj->ptr))) {
    fprintf(stderr,
        "ERROR: Making the mutex consistent failed: %s\n",
        strerror(ret));
    exit(1);
  }
}
#endif
void shared_mutex_lock(shared_mutex_t* obj) {
  int ret = -1;
  if ((ret = pthread_mutex_lock(obj->ptr))) {
#if !defined(__APPLE__)
    if (ret == EOWNERDEAD) {
        fprintf(stderr,
                "WARN: The owner of the mutex is dead ...\n");
        shared_mutex_make_consistent(obj);
        fprintf(stdout, "INFO: I'm the new owner!\n");
        shared_mem_setowner(obj->shm, TRUE);
        return;
    }
#endif
    fprintf(stderr, "ERROR: Locking the mutex failed: %s\n",
        strerror(ret));
    exit(1);
  }
}
void shared_mutex_unlock(shared_mutex_t* obj) {
  int ret = -1;
  if ((ret = pthread_mutex_unlock(obj->ptr))) {
    fprintf(stderr, "ERROR: Unlocking the mutex failed: %s\n",
        strerror(ret));
    exit(1);
  }
}

代码框 18-22 [ExtremeC_examples_chapter18_4_shared_mutex.c]:共享命名互斥类中找到的所有函数的定义

在前面的代码中,我们只进行了 POSIX 互斥锁的初始化、终止和暴露一些简单的行为,例如锁定和解锁。与共享内存对象相关的所有其他事情都在共享内存类中处理。这就是使用继承的好处。

注意,在构造函数shared_mutex_ctor中,我们将互斥锁设置为共享进程互斥锁,使其对所有进程可访问。这对于多进程软件来说是绝对必要的。注意,在非苹果系统上,我们更进一步,将互斥锁配置为健壮互斥锁

对于被进程锁定的普通互斥锁,如果进程突然死亡,则互斥锁进入非一致状态。对于健壮互斥锁,如果发生这种情况,互斥锁可以被放回一致状态。下一个通常等待互斥锁的进程只能通过使其一致来锁定互斥锁。你可以在shared_mutex_lock函数中看到如何做到这一点。注意,这种功能在苹果系统中不存在。

第 4 步 – 共享条件变量类

以下代码框显示了共享条件变量类的声明:

struct shared_cond_t;
struct shared_mutex_t;
struct shared_cond_t* shared_cond_new();
void shared_cond_delete(struct shared_cond_t* obj);
void shared_cond_ctor(struct shared_cond_t* obj,
                      const char* name);
void shared_cond_dtor(struct shared_cond_t* obj);
void shared_cond_wait(struct shared_cond_t* obj,
                      struct shared_mutex_t* mutex);
void shared_cond_timedwait(struct shared_cond_t* obj,
                           struct shared_mutex_t* mutex,
                           long int time_nanosec);
void shared_cond_broadcast(struct shared_cond_t* obj);

代码框 18-23 [ExtremeC_examples_chapter18_4_shared_cond.h]:共享条件变量类的公共接口

暴露了三种行为;shared_cond_waitshared_cond_timedwaitshared_cond_broadcast。如果你还记得第十六章,即线程同步shared_cond_wait行为会在条件变量上等待信号。

上面,我们添加了一个新的等待行为版本;shared_cond_timedwait。它等待指定时间内的信号,如果条件变量没有收到信号,则超时。另一方面,shared_cond_wait只有在收到某种信号时才会存在。我们将在示例 18.4中使用等待的定时版本。注意,这两个等待行为函数都接收一个指向伴随共享互斥锁的指针,就像我们在多线程环境中看到的那样。

以下代码框包含了共享条件变量类的实际实现:

#include "ExtremeC_examples_chapter18_4_shared_mem.h"
#include "ExtremeC_examples_chapter18_4_shared_mutex.h"
typedef struct {
  struct shared_mem_t* shm;
  pthread_cond_t* ptr;
} shared_cond_t;
shared_cond_t* shared_cond_new() {
  shared_cond_t* obj =
      (shared_cond_t*)malloc(sizeof(shared_cond_t));
  obj->shm = shared_mem_new();
  return obj;
}
void shared_cond_delete(shared_cond_t* obj) {
  shared_mem_delete(obj->shm);
  free(obj);
}
void shared_cond_ctor(shared_cond_t* obj, const char* name) {
  shared_mem_ctor(obj->shm, name, sizeof(pthread_cond_t));
  obj->ptr = (pthread_cond_t*)shared_mem_getptr(obj->shm);
  if (shared_mem_isowner(obj->shm)) {
    pthread_condattr_t cond_attr;
    int ret = -1;
    if ((ret = pthread_condattr_init(&cond_attr))) {
      fprintf(stderr,
          "ERROR(%s): Initializing cv attrs failed: %s\n",
          name, strerror(ret));
      exit(1);
    }
    if ((ret = pthread_condattr_setpshared(&cond_attr,
                    PTHREAD_PROCESS_SHARED))) {
      fprintf(stderr,
          "ERROR(%s): Setting as process shared failed: %s\n",
          name, strerror(ret));
      exit(1);
    }
    if ((ret = pthread_cond_init(obj->ptr, &cond_attr))) {
      fprintf(stderr,
          "ERROR(%s): Initializing the cv failed: %s\n",
          name, strerror(ret));
      exit(1);
    }
    if ((ret = pthread_condattr_destroy(&cond_attr))) {
      fprintf(stderr,
          "ERROR(%s): Destruction of cond attrs failed: %s\n",
          name, strerror(ret));
      exit(1);
    }
  }
}
void shared_cond_dtor(shared_cond_t* obj) {
  if (shared_mem_isowner(obj->shm)) {
    int ret = -1;
    if ((ret = pthread_cond_destroy(obj->ptr))) {
      fprintf(stderr, "WARN: Destruction of the cv failed: %s\n",
          strerror(ret));
    }
  }
  shared_mem_dtor(obj->shm);
}
void shared_cond_wait(shared_cond_t* obj,
                      struct shared_mutex_t* mutex) {
  int ret = -1;
  if ((ret = pthread_cond_wait(obj->ptr,
                  shared_mutex_getptr(mutex)))) {
    fprintf(stderr, "ERROR: Waiting on the cv failed: %s\n",
            strerror(ret));
    exit(1);
  }
}
void shared_cond_timedwait(shared_cond_t* obj,
                           struct shared_mutex_t* mutex,
                           long int time_nanosec) {
  int ret = -1;
  struct timespec ts;
  ts.tv_sec = ts.tv_nsec = 0;
  if ((ret = clock_gettime(CLOCK_REALTIME, &ts))) {
    fprintf(stderr,
            "ERROR: Failed at reading current time: %s\n",
            strerror(errno));
    exit(1);
  }
  ts.tv_sec += (int)(time_nanosec / (1000L * 1000 * 1000));
  ts.tv_nsec += time_nanosec % (1000L * 1000 * 1000);
  if ((ret = pthread_cond_timedwait(obj->ptr,
                  shared_mutex_getptr(mutex), &ts))) {
#if !defined(__APPLE__)
    if (ret == EOWNERDEAD) {
      fprintf(stderr,
              "WARN: The owner of the cv's mutex is dead ...\n");
      shared_mutex_make_consistent(mutex);
      fprintf(stdout, "INFO: I'm the new owner!\n");
      shared_mem_setowner(obj->shm, TRUE);
      return;
    } else if (ret == ETIMEDOUT) {
#else
    if (ret == ETIMEDOUT) {
#endif
      return;
    }
    fprintf(stderr, "ERROR: Waiting on the cv failed: %s\n",
            strerror(ret));
    exit(1);
  }
}
void shared_cond_broadcast(shared_cond_t* obj) {
  int ret = -1;
  if ((ret = pthread_cond_broadcast(obj->ptr))) {
    fprintf(stderr, "ERROR: Broadcasting on the cv failed: %s\n",
        strerror(ret));
    exit(1);
  }
}

代码框 18-24 [ExtremeC_examples_chapter18_4_shared_cond.c]:共享条件变量类中找到的所有函数的定义

在我们的共享条件变量类中,我们只暴露了广播行为。我们也可以暴露信号行为。正如你可能从第十六章,即线程同步中记得的,向条件变量发送信号只会唤醒许多等待进程中的一个,而没有能力指定或预测是哪一个。相比之下,广播会唤醒所有等待进程。在示例 18.4中,我们只会使用广播,这就是为什么我们只暴露了那个函数。

注意,由于每个条件变量都有一个伴随的互斥锁,共享互斥锁类应该能够使用共享互斥锁类的实例,这就是为什么我们将shared_mutex_t声明为前向声明的原因。

第 5 步 – 主要逻辑

下面的代码框包含了为我们示例实现的主要逻辑:

#include "ExtremeC_examples_chapter18_4_shared_int32.h"
#include "ExtremeC_examples_chapter18_4_shared_mutex.h"
#include "ExtremeC_examples_chapter18_4_shared_cond.h"
int int_received = 0;
struct shared_cond_t* cond = NULL;
struct shared_mutex_t* mutex = NULL;
void sigint_handler(int signo) {
  fprintf(stdout, "\nHandling INT signal: %d ...\n", signo);
  int_received = 1;
}
int main(int argc, char** argv) {
  signal(SIGINT, sigint_handler);
  if (argc < 2) {
    fprintf(stderr,
            "ERROR: You have to provide the process number.\n");
    exit(1);
  }
  int my_number = atol(argv[1]);
  printf("My number is %d!\n", my_number);
  struct shared_int32_t* counter = shared_int32_new();
  shared_int32_ctor(counter, "/counter0");
  shared_int32_setvalue_ifowner(counter, 1);
  mutex = shared_mutex_new();
  shared_mutex_ctor(mutex, "/mutex0");
  cond = shared_cond_new();
  shared_cond_ctor(cond, "/cond0");
  shared_mutex_lock(mutex);
  while (shared_int32_getvalue(counter) < my_number) {
    if (int_received) {
        break;
    }
    printf("Waiting for the signal, just for 5 seconds ...\n");
    shared_cond_timedwait(cond, mutex, 5L * 1000 * 1000 * 1000);
    if (int_received) {
        break;
    }
    printf("Checking condition ...\n");
  }
  if (int_received) {
    printf("Exiting ...\n");
    shared_mutex_unlock(mutex);
    goto destroy;
  }
  shared_int32_setvalue(counter, my_number + 1);
  printf("My turn! %d ...\n", my_number);
  shared_mutex_unlock(mutex);
  sleep(1);
  // NOTE: The broadcasting can come after unlocking the mutex.
  shared_cond_broadcast(cond);
destroy:
  shared_cond_dtor(cond);
  shared_cond_delete(cond);
  shared_mutex_dtor(mutex);
  shared_mutex_delete(mutex);
  shared_int32_dtor(counter);
  shared_int32_delete(counter);
  return 0;
}

代码框 18-25 [ExtremeC_examples_chapter18_4_main.c]:示例 18.4 的主函数

如您所见,程序接受一个参数来指示其数字。一旦进程得知其数字,它就开始初始化共享计数器、共享互斥锁和共享条件变量。然后它进入由共享互斥锁保护的临界区。

在循环内部,它等待计数器等于其数字。由于它等待 5 秒钟,可能会出现超时,我们可能在 5 秒后离开shared_cond_timedwait函数。这基本上意味着在这 5 秒钟内没有通知条件变量。然后进程再次检查条件,并再次休眠 5 秒钟。这个过程会一直持续到进程获得轮次。

当发生这种情况时,进程打印其数字,增加共享计数器,并通过在共享条件变量对象上广播信号,通知其他等待进程它对共享计数器所做的修改。然后它才准备退出。

同时,如果用户按下Ctrl + C,作为主逻辑一部分定义的信号处理程序将设置局部标志int_received,并且一旦进程在主循环中离开shared_mutex_timedwait函数,它就会注意到中断信号并退出循环。

下面的 shell box 展示了如何编译示例 18.4。我们将在 Linux 上编译它:

$ gcc -c ExtremeC_examples_chapter18_4_shared_mem.c -o shared_mem.o
$ gcc -c ExtremeC_examples_chapter18_4_shared_int32.c -o shared_int32.o
$ gcc -c ExtremeC_examples_chapter18_4_shared_mutex.c -o shared_mutex.o
$ gcc -c ExtremeC_examples_chapter18_4_shared_cond.c -o shared_cond.o
$ gcc -c ExtremeC_examples_chapter18_4_main.c -o main.o
$ gcc shared_mem.o shared_int32.o shared_mutex.o shared_cond.o \  main.o -lpthread -lrt -o ex18_4.out
$

Shell Box 18-6:编译示例 18.4 的源代码并生成最终的可执行文件

现在我们已经得到了最终的可执行文件ex18_4.out,我们可以运行三个进程并观察它们如何按顺序计数,无论您如何分配它们的数字以及它们的运行顺序如何。让我们运行第一个进程。我们通过将数字作为选项传递给可执行文件来给这个进程分配数字 3:

$ ./ex18_4.out 3
My number is 3!
WARN: The shared memory /counter0 does not exist.
The shared memory /counter0 is created and opened.
WARN: The shared memory /mutex0 does not exist.
The shared memory /mutex0 is created and opened.
WARN: The shared memory /cond0 does not exist.
The shared memory /cond0 is created and opened.
Waiting for the signal, just for 5 seconds ...
Checking condition ...
Waiting for the signal, just for 5 seconds ...
Checking condition ...
Waiting for the signal, just for 5 seconds ...

Shell Box 18-7:运行第一个进程,该进程取数字 3

正如您在前面的输出中看到的,第一个进程创建了所有必需的共享对象,并成为共享资源的所有者。现在,让我们在另一个终端中运行第二个进程。它取数字 2:

$ ./ex18_4.out 2
My number is 2!
The shared memory /counter0 is opened.
The shared memory /mutex0 is opened.
The shared memory /cond0 is opened.
Waiting for the signal, just for 5 seconds ...
Checking condition ...
Waiting for the signal, just for 5 seconds ...

Shell Box 18-8:运行第二个进程,该进程取数字 2

最后,最后一个进程取数字 1。由于这个进程被分配了数字 1,它立即打印其数字,增加共享计数器,并通知其他进程这一情况:

$ ./ex18_4.out 1
My number is 1!
The shared memory /counter0 is opened.
The shared memory /mutex0 is opened.
The shared memory /cond0 is opened.
My turn! 1 ...
The shared memory /cond0 is unmapped.
The shared memory /cond0 is closed.
The shared memory /mutex0 is unmapped.
The shared memory /mutex0 is closed.
The shared memory /counter0 is unmapped.
The shared memory /counter0 is closed.
$

Shell Box 18-9:运行第三个进程,该进程取数字 1。由于它具有数字 1,这个进程将立即退出。

现在,如果你回到第二个进程,它打印出其编号,增加共享计数器,并通知第三个进程:

...
Waiting for the signal, just for 5 seconds ...
Checking condition ...
My turn! 2 ...
The shared memory /cond0 is unmapped.
The shared memory /cond0 is closed.
The shared memory /mutex0 is unmapped.
The shared memory /mutex0 is closed.
The shared memory /counter0 is unmapped.
The shared memory /counter0 is closed.
$

Shell Box 18-10:第二个进程打印其编号并退出

最后,回到第一个进程,它被第二个进程通知,然后打印出其编号并退出。

...
Waiting for the signal, just for 5 seconds ...
Checking condition ...
My turn! 3 ...
The shared memory /cond0 is unmapped.
The shared memory /cond0 is closed.
The shared memory /cond0 is deleted.
The shared memory /mutex0 is unmapped.
The shared memory /mutex0 is closed.
The shared memory /mutex0 is deleted.
The shared memory /counter0 is unmapped.
The shared memory /counter0 is closed.
The shared memory /counter0 is deleted.
$

Shell Box 18-11:第一个进程打印其编号并退出。它还删除了所有共享内存条目。

由于第一个进程是所有共享内存的所有者,它应该在退出时删除它们。在多进程环境中释放分配的资源可能相当复杂,因为一个简单的错误就足以导致所有进程崩溃。当要从系统中移除共享资源时,需要进一步的同步。

假设在前面的例子中,我们用数字 2 运行第一个进程,用数字 3 运行第二个进程。因此,第一个进程应该在第二个进程之前打印其编号。当第一个进程由于它是所有共享资源的创建者而退出时,它删除共享对象,而第二个进程在试图访问它们时立即崩溃。

这只是一个简单的例子,说明了在多进程系统中,终止操作可能会变得复杂且问题重重。为了减轻这种崩溃的风险,需要在进程间引入进一步的同步。

在前面的章节中,我们介绍了可以用于同步同一主机上运行的多个进程的机制。在接下来的章节中,我们将简要讨论分布式并发控制机制及其特性。

分布式并发控制

到目前为止,在本章中,我们假设所有进程都存在于同一个操作系统内,也就是说,同一个机器。换句话说,我们一直在谈论单主机软件系统。

但实际的软件系统通常超出了这一点。与单主机软件系统相反,我们有分布式软件系统。这些系统在网络中分布有进程,并且通过网络通信来运行。

关于进程的分布式系统,我们可以看到一些在集中式或单主机系统中不那么明显的挑战。接下来,我们将简要讨论其中的一些:

  • 在分布式软件系统中,你可能会遇到并行性而不是并发性。由于每个进程运行在单独的机器上,并且每个进程都有自己的特定处理器,我们将观察到并行性而不是并发性。并发通常局限于单个机器的边界。请注意,交错仍然存在,我们可能会遇到与并发系统相同的非确定性。

  • 并非分布式软件系统中的所有流程都是使用单一编程语言编写的。在分布式软件系统中看到各种编程语言被使用是很常见的。在单主机软件系统的流程中,也常常看到这种多样性。尽管我们对系统内流程的隐含假设是它们都使用 C 语言编写,但我们仍然可以使用任何其他语言来编写流程。不同的语言提供了不同的并发和控制机制。例如,在某些语言中,你可能很难轻松地使用命名互斥锁。在软件系统中使用的各种技术和编程语言,无论是单主机还是分布式,都迫使我们使用足够抽象的并发控制机制,以便在所有这些系统中都可用。这可能会限制我们只能使用在特定技术或编程语言中可用的特定同步技术。

  • 在分布式系统中,你总是有一个网络作为不在同一台机器上的两个进程之间的通信通道。这与我们对单主机系统的隐含假设相反,在单主机系统中,所有流程都在同一操作系统中运行,并使用可用的消息基础设施相互通信。

  • 中间存在网络意味着你会有延迟。在单主机系统中也存在轻微的延迟,但它是可以确定和管理的。它也比你在网络中可能遇到的延迟要低得多。延迟简单地说就是,由于许多原因,一个进程可能不会立即收到消息,这些原因根植于网络基础设施。在这些系统中,没有什么应该被认为是即时的。

  • 中间存在网络也会导致安全问题。当你有一个系统中的所有流程,并且它们都在使用具有极低延迟的机制在同一边界内进行通信时,安全问题就大不相同了。攻击者必须首先访问系统本身才能攻击系统,但在分布式系统中,所有消息传递都是通过网络进行的。你可能会在中间遇到一个监听者来窃听或,更糟糕的是,篡改消息。关于我们在分布式系统中关于同步的讨论,这也适用于旨在同步分布式系统内流程的消息。

  • 除了延迟和安全问题之外,你可能会遇到在单主机多进程系统中远较少发生的交付问题。消息应该被传递以进行处理。当一个进程向系统内的另一个进程发送消息时,发送进程应确保其消息被另一端接收。交付保证机制是可能的,但它们成本高昂,在某些情况下,甚至根本无法使用它们。在这些情况下,会出现一种特殊的消息问题,这通常由著名的两个将军问题来建模。

前面的差异和可能的问题足以迫使我们发明新的进程和大型分布式系统各个组件之间的同步方式。通常,有两种方式可以使分布式系统事务性和同步:

  • 集中式进程同步:这些技术需要一个中心进程(或节点)来管理进程。系统中的所有其他进程都应该与这个中心节点保持持续通信,并且它们需要其批准才能进入它们的临界区。

  • 分布式(或对等)进程同步:拥有一个没有中心节点的进程同步基础设施并不是一件容易的事情。这实际上是一个活跃的研究领域,并且有一些专门的算法。

在本节中,我们试图对分布式多进程系统中并发控制的复杂性进行一些解释。关于分布式并发控制的进一步讨论将超出本书的范围。

摘要

在本章中,我们完成了关于多进程环境的讨论。作为本章的一部分,我们讨论了以下内容:

  • 什么是命名信号量以及它是如何被多个进程创建和使用的。

  • 什么是命名互斥锁以及它是如何通过共享内存区域使用的。

  • 我们给出了一个关于终止编排的例子,其中多个进程正在等待一个终止信号,信号被其中一个进程接收和处理,然后传播给其他进程。我们使用共享互斥锁实现了这个例子。

  • 什么是命名条件变量以及它是如何通过共享内存区域实现共享和命名的。

  • 我们展示了另一个计数进程的例子。作为这个例子的一部分,我们使用了继承来减少具有相关共享内存区域的互斥锁和条件变量对象的代码重复量。

  • 我们简要探讨了分布式系统中存在的差异和挑战。

  • 我们简要讨论了可以将并发控制引入分布式软件的方法。

在即将到来的章节中,我们开始讨论进程间通信IPC)技术。我们的讨论将涵盖两个章节,我们将涉及许多主题,例如计算机网络、传输协议、套接字编程以及更多有用的主题。

第十九章

单主机 IPC 和套接字

在上一章中,我们讨论了两个进程如何能够同时以同步方式操作同一共享资源的技术。在本章中,我们将扩展这些技术,并介绍一种新的方法类别,允许两个进程传输数据。这些技术,包括上一章中介绍的技术和本章将要讨论的技术,统称为进程间通信(IPC)技术。

在本章和接下来的章节中,我们将讨论 IPC 技术,尽管我们在上一章讨论了方法,但这些方法涉及两个进程之间的一种消息传递信号。传输的消息不会存储在任何共享位置,如文件或共享内存,而是由进程发出和接收。

在本章中,我们将涵盖两个主要主题。首先,我们巩固 IPC 技术,并讨论单主机 IPC 和 POSIX API。其次,我们开始介绍套接字编程及其相关主题。这些主题包括计算机网络、监听器-连接器模型以及两个进程建立连接时存在的序列。

作为本章的一部分,我们将讨论以下主题:

  • 各种 IPC 技术。我们介绍了基于推送和基于拉取的 IPC 技术,并将上一章中讨论的技术定义为基于拉取的 IPC 技术。

  • 通信协议以及协议通常具有的特性。我们介绍序列化和反序列化的含义以及它们如何有助于实现完全操作的进程间通信(IPC)。

  • 文件描述符及其在建立 IPC 通道中的关键作用。

  • 本章讨论了 POSIX 信号、POSIX 管道和 POSIX 消息队列的公开 API。对于每种技术,都提供了一个示例来演示基本用法。

  • 计算机网络以及两个进程如何通过现有网络进行通信。

  • 监听器-连接器模型以及两个进程如何在多个网络上建立传输连接。这是我们未来关于套接字编程讨论的基础。

  • 套接字编程是什么以及套接字对象是什么。

  • 参与监听器-连接器连接的每个进程存在的序列,以及它们必须从 POSIX 套接字库中使用的 API。

在第一部分,我们将回顾 IPC 技术。

IPC 技术

IPC 技术通常指的是进程用来通信和传输数据的方法。在上一章中,我们讨论了文件系统和共享内存作为我们在两个进程之间共享数据的起点。在那个阶段,我们没有使用“IPC”这个术语,但实际上这正是它们所代表的!在本章中,我们将添加一些我们已遇到的 IPC 技术,但我们应该记住,它们在许多方面是不同的。在尝试比较差异并尝试对它们进行分类之前,让我们列出一些 IPC 技术:

  • 共享内存

  • 文件系统(包括磁盘和内存中的)

  • POSIX 信号

  • POSIX 管道

  • POSIX 消息队列

  • Unix 域套接字

  • 互联网(或网络)套接字

从编程的角度来看,共享内存和文件系统技术在某些方面是相似的,因此它们可以被归入同一个组,称为基于拉取的 IPC 技术。其余的技术突出,并且它们有自己的类别。我们将它们称为基于推送的 IPC 技术。本章以及下一章将致力于基于推送的 IPC,并讨论各种技术。

注意,所有的 IPC 技术都负责在两个进程之间传输多条消息。由于我们将在接下来的段落中大量使用术语消息,因此首先定义它是有意义的。

每条消息都包含一系列字节,这些字节根据一个定义良好的接口、协议或标准组合在一起。消息的结构应该为处理该消息的两个进程所知晓,并且它通常作为通信协议的一部分来处理。

以下是可以看到基于拉取和基于推送技术之间差异的列表:

  • 在基于拉取的技术中,我们有一个共享资源或介质,它位于两个进程外部,并在用户空间中可用。文件、共享内存,甚至像网络文件系统NFS)服务器这样的网络服务都可以是共享资源。这些介质是进程创建和消费消息的主要占位符。而在基于推送的技术中,没有这样的共享资源或介质,而是有一个通道。进程通过这个通道发送和接收消息,而这些消息不会存储在任何中间介质中。

  • 在基于拉取的技术中,每个进程必须从介质中拉取可用的消息。在基于推送的技术中,传入的消息被推送交付)到接收端。

  • 在基于拉取的技术中,由于存在共享资源或介质,对介质的并发访问必须同步。这就是为什么我们在上一章中探讨了各种同步技术,用于此类 IPC 技术。请注意,这与基于推送的技术不同,并且不需要同步。

  • 在基于拉的技术中,进程可以独立操作。这是因为消息可以存储在共享资源中,稍后可以检索。换句话说,进程可以以异步方式操作。相反,在基于推的 IPC 技术中,两个进程应该同时运行,并且由于消息是即时推送的,如果接收进程处于关闭状态,它可能会丢失一些传入的消息。换句话说,进程以同步方式操作。

    注意

    在基于推的技术中,我们为每个进程都有一个临时消息缓冲区,用于存储传入的推送消息。这个消息缓冲区位于内核中,只要进程在运行,它就会存在。这个消息缓冲区可能被并发访问,但同步必须由内核本身来保证。

当使用基于推的技术时,消息要么在 IPC 通道中传输,要么在基于拉的技术中使用 IPC 介质存储,其内容应该是接收进程可理解的。这意味着两个进程——发送端和接收端——都必须知道如何创建和解析消息。由于消息由字节组成,这意味着两个进程都必须知道如何将对象(文本或视频)转换为一系列字节,以及如何从接收到的字节中恢复相同的对象。我们将很快看到,进程之间的互操作性是由它们共同采用的通信协议所覆盖的。

在下一节中,我们将更深入地讨论通信协议。

通信协议

仅有一个通信通道或介质是不够的。愿意通过共享通道进行通信的两个当事人还需要相互理解!一个非常简单的例子是当两个人想用同一种语言(如英语或日语)相互交谈时。在这里,语言可以被认为是两个当事人用来进行通信的协议。

在进程间通信(IPC)的背景下,进程也不例外;它们需要一个共同的语言以便进行通信。技术上,我们使用术语协议来指代任何两个当事人之间的这种共同语言。作为本节的一部分,我们将讨论通信协议及其各种特性,如消息长度消息内容。在能够讨论这些特性之前,我们需要更深入地描述一个通信协议。请注意,本章的主要重点是 IPC 技术;因此,我们只讨论两个进程之间的通信协议。本章不涵盖除进程之外的其他当事人之间的任何类型的通信。

进程只能传输字节。这实际上意味着在通过任何 IPC 技术传输之前,每条信息都必须被转换为一组字节。这被称为序列化打包。一段文本、一段音频、一首音乐或任何其他类型的对象在通过 IPC 通道发送或存储在 IPC 介质之前都必须进行序列化。因此,关于 IPC 通信协议,这意味着进程之间传输的消息是一系列字节,它们按照非常具体和明确的顺序排列。

相反,当进程从一个 IPC 通道接收一系列字节时,它应该能够从传入的字节中重建原始对象。这被称为反序列化反序列化

为了在同一个流程中解释序列化和反序列化,当进程想要通过任何已经建立的 IPC 通道向另一个进程发送对象时,发送进程首先将对象序列化为字节数组。然后它将字节数组传输给另一方。在接收端,进程将传入的字节反序列化,并恢复发送的对象。正如你所看到的,这些操作是彼此的逆操作,并且它们被双方使用,以便使用面向字节的 IPC 通道传输信息。这是你无法避免的,并且每种基于 IPC 的技术(RPC、RMI 等)都严重依赖于各种对象的序列化和反序列化。从现在开始,我们使用术语序列化来指代序列化和反序列化操作。

注意,序列化并不仅限于我们之前讨论的基于推的 IPC 技术。在基于拉的 IPC 技术(如文件系统或共享内存)中,我们仍然需要序列化。这是因为这些技术中的底层介质可以存储一系列字节,如果进程想要将对象存储在共享文件中,例如,它必须在存储之前将其序列化。因此,序列化对所有 IPC 技术都是通用的;无论你使用哪种 IPC 方法,在使用底层通道或介质时,你都必须处理大量的序列化和反序列化操作。

选择一个通信协议隐式地决定了序列化,因为作为协议的一部分,我们非常仔细地定义了字节顺序。这一点至关重要,因为序列化的对象必须在接收端反序列化回相同的对象。因此,序列化和反序列化都必须遵守协议规定的相同规则。两端都使用不兼容的序列化和反序列化工具实际上意味着根本无法通信,仅仅因为接收端无法重建传输的对象。

注意

有时,我们将术语parsing用作反序列化的同义词,但它们实际上在本质上是有区别的。

为了使讨论更加具体,让我们来谈谈一些真实例子。一个网络服务器和一个网络客户端使用超文本传输协议HTTP)进行通信。因此,双方都需要使用兼容的 HTTP 序列化和反序列化器来相互交流。作为另一个例子,让我们来谈谈域名系统DNS)协议。DNS 客户端和服务器必须使用兼容的序列化和反序列化器,以便它们能够通信。请注意,与具有文本内容的 HTTP 不同,DNS 是一个二进制协议。我们将在接下来的部分中简要讨论这一点。

由于序列化操作可以在软件项目的各种组件中使用,因此它们通常作为一些库提供,这些库可以添加到任何希望使用它们的组件中。对于像 HTTP、DNS 和 FTP 这样的著名协议,有众所周知的第三方库可以无障碍地使用。但对于专门为项目设计的自定义协议,序列化库必须由团队自己编写。

注意:

如 HTTP、FTP 和 DNS 这样的知名协议是标准,它们在称为请求评论RFC)的官方公开文档中进行了描述。例如,HTTP/1.1 协议在 RFC-2616 中进行了描述。通过简单的谷歌搜索就可以找到 RFC 页面。

作为关于序列化库的进一步说明,它们可以以各种编程语言提供。请注意,特定的序列化本身并不依赖于任何编程语言,因为它只涉及字节的顺序以及它们应该如何被解释。因此,序列化和反序列化算法可以使用任何编程语言来开发。这是一个关键要求。在一个大型软件项目中,我们可以有多个用各种编程语言编写的组件,并且在这些组件之间必须传输信息的情况。因此,我们需要用各种语言编写的相同的序列化算法。例如,我们有用 C、C++、Java、Python 等语言编写的 HTTP 序列化器。

为了总结本节的主要观点,我们需要在双方之间建立一个定义良好的协议,以便它们能够相互交流。IPC 协议是一种标准,它规定了整体通信必须如何进行,以及关于字节顺序和它们在各个消息中的含义必须遵守哪些细节。我们必须使用一些序列化算法来消费面向字节的 IPC 通道以传输对象。

在下一节中,我们将描述 IPC 协议的特点。

协议特点

IPC 协议具有各种特性。简而言之,每个协议都可以为通过 IPC 通道传输的消息指定不同的内容类型。在另一个协议中,消息可以具有固定长度或可变长度。一些协议规定提供的操作必须以同步方式消费,而有些协议允许异步使用。在接下来的章节中,我们将介绍这些区分因素。请注意,现有的协议可以根据这些特性进行分类。

内容类型

通过 IPC 通道发送的消息可以是文本内容或二进制内容,或者两者的组合。二进制内容具有值在 0 到 255 之间所有可能数值的字节。但文本内容只有用于文本的字符。换句话说,只有字母数字字符和一些符号允许在文本内容中使用。

虽然文本内容可以被视为二进制内容的特例,但我们尽量将它们分开,并分别对待。例如,文本消息在发送前进行压缩是很好的候选,而二进制消息则受制于较差的压缩比(实际大小除以压缩大小)。了解一些协议完全是文本的,如 JSON,而另一些则是完全二进制的,如 DNS,是有益的。还有一些协议,如 BSON 和 HTTP,允许消息内容是文本和二进制数据的组合。在这些协议中,原始字节可以与文本混合,形成最终的消息。

注意,二进制内容可以作为文本发送。有多种编码方式允许您使用文本字符来表示二进制内容。Base64 是最著名的二进制到文本编码算法之一,它允许这种转换。这些编码算法在纯文本协议(如 JSON)中广泛用于发送二进制数据。

消息长度

根据 IPC 协议产生的消息可以是固定长度可变长度。固定长度意味着所有消息都具有相同的长度。相反,可变长度意味着产生的消息可以有不同的长度。接收固定长度或可变长度的消息在反序列化消息内容时对接收方有直接影响。使用总是产生固定长度消息的协议可以减少解析接收消息的负担,因为接收方已经知道它应该从通道中读取的字节数,并且通常(但不总是)具有相同大小的消息具有相同的结构。当从 IPC 通道读取固定长度消息时,如果所有消息都遵循相同的结构,我们就有机会使用 C 结构通过一些已定义的字段来引用这些字节,类似于我们在上一章中为放置在共享内存中的对象所做的那样。

对于产生可变长度消息的协议,找到单个消息的结束并不容易,接收方(我们将在稍后解释)应决定它是否已经读取了一个完整的消息或还需要从通道中读取更多字节。请注意,接收方在读取一个完整的消息之前可能会从通道中读取多个数据块,并且一个数据块可能包含两个相邻消息的数据。我们将在下一章中看到一个例子。

由于大多数协议都是可变长度的,而且通常没有处理固定长度消息的便利,因此讨论各种协议采用的方法来使它们的可变长度消息可区分或可分离是值得的。换句话说,这些协议使用一种机制来标记消息的结束,这样接收者就可以使用这些标记来指示它已经读取了一个完整的消息。接下来,你可以看到一些这些方法:

  • 使用分隔符或分隔符:分隔符或分隔符是一系列字节(在二进制消息中)或字符(在文本消息中),它指示消息的结束。应根据消息的内容选择分隔符,因为它应该很容易与实际内容区分开来。

  • 长度前缀帧定界:在这些协议中,每个消息都有一个固定长度的前缀(通常是 4 个字节或更多),它携带接收者应该读取的字节数,以便获得一个完整的消息。各种协议,如所有标签-值-长度TLV)协议,以抽象语法表示ASN)为例,都使用这种技术。

  • 使用有限状态机:这些协议遵循一种正规文法,可以用有限状态机来建模。接收方应了解协议的文法,并应使用基于有限状态机的适当反序列化器来从 IPC 通道中读取一个完整的消息。

顺序性

在大多数协议中,两个进程之间发生的是一种遵循请求-响应方案的对话。一方发送请求,另一方回复。这种方案通常用于客户端-服务器场景。监听器进程,通常是服务器进程,等待消息,并在收到消息后相应地回复。

如果协议是同步或顺序的,发送者(客户端)将等待监听器(服务器)完成请求并发送回响应。换句话说,发送者将保持在阻塞状态,直到监听器回复。在异步协议中,发送者进程不会被阻塞,它可以在监听器处理请求的同时继续执行其他任务。也就是说,发送者在准备回复时不会被阻塞。

在异步协议中,应该有一个拉取推送机制,这允许发送者检查回复。在拉取场景中,发送者将定期询问监听者的结果。在推送场景中,监听者将通过相同的或不同的通信通道将回复推送给发送者。

协议的顺序性不仅限于请求-响应场景。消息应用通常使用这种技术以在服务器端和客户端都实现最大的响应性。

单主机通信

在本节中,我们将讨论单主机进程间通信(IPC)。多主机 IPC 将是下一章讨论的主题。当进程位于同一台机器上时,可以使用以下四种主要技术进行通信:

  • POSIX 信号

  • POSIX 管道

  • POSIX 消息队列

  • Unix 域套接字

与其他前述技术不同,POSIX 信号不会在进程之间创建通信通道,但可以用作通知进程有关事件的方式。在某些情况下,这些信号可以被进程用来通知彼此关于系统中特定事件的详细信息。

在跳转到第一种 IPC 技术 POSIX 信号之前,让我们先讨论文件描述符。除了 POSIX 信号之外,无论你使用哪种 IPC 技术,你都将处理某种类型的文件描述符。因此,我们现在将专门为它们设立一个单独的部分,并进一步讨论它们。

文件描述符

两个通信进程可以运行在同一台机器上,也可以运行在通过计算机网络连接的两个不同的机器上。在本节以及本章的大部分内容中,我们的重点是第一种情况,即进程位于同一台机器上。这就是文件描述符变得极其重要的地方。请注意,在多主机 IPC 中,我们仍然会处理文件描述符,但它们在那里被称为套接字。我们将在下一章中详细讨论它们。

文件描述符是对系统内对象的抽象句柄,可以用来读写数据。正如你所见,尽管名称如此,文件描述符可以引用一系列处理读写字节流的机制。

正规文件当然是可以由文件描述符引用的对象之一。这些文件位于文件系统上,无论是在硬盘上还是在内存中。

可以通过文件描述符引用和访问的其他事物包括设备。正如我们在第十章“Unix - 历史 和 架构”中看到的,每个设备都可以通过设备文件来访问,该文件通常位于/dev目录中。

关于基于推送的 IPC 技术,文件描述符可以表示一个 IPC 通道。在这种情况下,文件描述符可以用来从表示的通道中读取和写入数据。这就是为什么设置 IPC 通道的第一步是定义一定数量的文件描述符。

现在你已经了解了文件描述符及其代表的含义,我们可以继续讨论在单主机多进程系统中可以使用的第一个 IPC 技术;然而,POSIX 信号不使用文件描述符。你将在未来关于 POSIX 管道和 POSIX 消息队列的章节中了解更多关于文件描述符的内容。让我们从 POSIX 信号开始。

POSIX 信号

在 POSIX 系统中,进程和线程可以发送和接收许多预定义的信号。信号可以由进程、线程或内核本身发送。信号实际上是为了通知进程或线程有关事件或错误。例如,当系统即将重启时,系统向所有进程发送 SIGTERM 信号,让它们知道正在重启,它们必须立即退出。一旦进程收到这个信号,它应该做出相应的反应。在某些情况下,可能不需要做任何事情,但在某些情况下,进程的当前状态应该被保存。

以下表格显示了 Linux 系统中可用的信号。该表格是从 Linux 信号 手册页 提取的:

 Signal      Standard   Action   Comment ───────────────────────────────────────────────────────────
SIGABRT      P1990      Core    Abort signal from abort(3)SIGALRM      P1990      Term    Timer signal from alarm(2)SIGBUS       P2001      Core    Bus error (bad memory access)SIGCHLD      P1990      Ign     Child stopped or terminated SIGCLD         -        Ign     A synonym for SIGCHLD SIGCONT      P1990      Cont    Continue if stopped SIGEMT         -        Term    Emulator trap SIGFPE       P1990      Core    Floating-point exception SIGHUP       P1990      Term    Hangup detected on controlling terminal                                 or death of controlling process SIGILL       P1990      Core    Illegal Instruction SIGINFO        -                A synonym for SIGPWR SIGINT       P1990      Term    Interrupt from keyboard SIGIO          -        Term    I/O now possible (4.2BSD)SIGIOT         -        Core    IOT trap. A synonym for SIGABRT SIGKILL      P1990      Term    Kill signal SIGLOST        -        Term    File lock lost (unused)SIGPIPE      P1990      Term    Broken pipe: write to pipe with no                                 readers; see pipe(7)SIGPOLL      P2001      Term    Pollable event (Sys V).                                Synonym for SIGIO SIGPROF      P2001      Term    Profiling timer expired SIGPWR         -        Term    Power failure (System V)SIGQUIT      P1990      Core    Quit from keyboard SIGSEGV      P1990      Core    Invalid memory reference SIGSTKFLT      -        Term    Stack fault on coprocessor (unused)SIGSTOP      P1990      Stop    Stop process SIGTSTP      P1990      Stop    Stop typed at terminal SIGSYS       P2001      Core    Bad system call (SVr4);                                see also seccomp(2)SIGTERM      P1990      Term    Termination signal SIGTRAP      P2001      Core    Trace/breakpoint trap SIGTTIN      P1990      Stop    Terminal input for background process SIGTTOU      P1990      Stop    Terminal output for background process SIGUNUSED      -        Core    Synonymous with SIGSYS SIGURG       P2001      Ign     Urgent condition on socket (4.2BSD)SIGUSR1      P1990      Term    User-defined signal 1 SIGUSR2      P1990      Term    User-defined signal 2 SIGVTALRM    P2001      Term    Virtual alarm clock (4.2BSD)SIGXCPU      P2001      Core    CPU time limit exceeded (4.2BSD);                                see setrlimit(2)SIGXFSZ      P2001      Core    File size limit exceeded (4.2BSD);                                see setrlimit(2)SIGWINCH       -        Ign     Window resize signal (4.3BSD, Sun)

表 19-1:Linux 系统中所有可用的信号列表

如前表所示,并非所有信号都是 POSIX 信号,Linux 有自己的信号。虽然大多数信号对应于已知事件,但有两个 POSIX 信号可以被用户定义。这通常用于当你想在程序运行时调用某些功能时。示例 19.1 展示了如何在 C 程序中使用信号以及如何处理它们。接下来,你可以找到 示例 19.1 的代码:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handle_user_signals(int signal) {
  switch (signal) {
    case SIGUSR1:
      printf("SIGUSR1 received!\n");
      break;
    case SIGUSR2:
      printf("SIGUSR2 received!\n");
      break;
    default:
      printf("Unsupported signal is received!\n");
  }
}
void handle_sigint(int signal) {
  printf("Interrupt signal is received!\n");
}
void handle_sigkill(int signal) {
  printf("Kill signal is received! Bye.\n");
  exit(0);
}
int main(int argc, char** argv) {
  signal(SIGUSR1, handle_user_signals);
  signal(SIGUSR2, handle_user_signals);
  signal(SIGINT, handle_sigint);
  signal(SIGKILL, handle_sigkill);
  while (1);
  return 0;
}

代码框 19-1 [ExtremeC_examples_chapter19_1.c]: 处理 POSIX 信号

在前面的示例中,我们使用了 signal 函数将各种信号处理程序分配给一些特定的信号。正如你所见,我们有一个用于用户定义信号的信号处理程序,一个用于 SIGINT 信号的信号处理程序,以及一个用于 SIGKILL 信号的信号处理程序。

程序只是一个永无止境的循环,我们只想处理一些信号。以下命令显示了如何在后台编译和运行示例:

$ gcc ExtremeC_examples_chapter19_1.c -o ex19_1.out
$ ./ex19_1.out &
[1] 4598
$

Shell 框 19-1:编译和运行示例 19.1

现在我们知道了程序的 PID,我们可以向它发送一些信号。PID 是 4598,程序正在后台运行。请注意,对于你来说,PID 可能是不同的。你可以使用 kill 命令向进程发送信号。以下命令用于检查前面的示例:

$ kill -SIGUSR2 4598
SIGUSR2 received!
$ kill -SIGUSR1 4598
SIGUSR2 received!
$ kill -SIGINT 4598
Interrupt signal is received!
$ kill -SIGKILL 4598
$
[1]+  Stopped         ./ex19_1.out
$

Shell 框 19-2:向后台进程发送不同的信号

如你所见,程序处理了除 SIGKILL 信号之外的所有信号。SIGKILL 不能被任何进程处理,通常,创建进程的父进程可以收到其子进程被杀的通知。

注意,可以通过按下 Ctrl + C 发送 SIGINT 信号,或中断信号,到前台程序。因此,每次你按下这个键组合时,实际上是在向正在运行的程序发送中断信号。默认的处理程序只是停止程序,但正如你在前面的示例中看到的,我们可以处理 SIGINT 信号并忽略它。

除了可以使用 shell 命令向进程发送信号的能力之外,如果知道目标进程的 PID,进程还可以向另一个进程发送信号。你可以使用 kill 函数(在 signal.h 中声明),它与它的命令行版本完全一样。它接受两个参数:第一个是目标 PID,第二个是信号号。进程或线程也可以使用 killraise 函数向自身发送信号。请注意,raise 函数向当前线程发送信号。这些函数在你想通知程序的其他部分有关事件的情况下非常有用。

关于前面示例的最后一点是,正如你在Shell Box 19-2中看到的,主线程忙于无限循环并不重要,信号是异步传递的。因此,你可以确信你总是能接收到传入的信号。

现在是时候讨论 POSIX 管道作为另一种单主机 IPC 技术的时候了,在某些情况下这可能很有用。

POSIX 管道

在 Unix 中,POSIX 管道是单向通道,可以在需要交换消息的两个进程之间使用。创建 POSIX 管道时,你会得到两个文件描述符。一个文件描述符用于向管道写入,另一个用于从管道读取。以下示例展示了 POSIX 管道的基本用法:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
int main(int argc, char** argv) {
  int fds[2];
  pipe(fds);
  int childpid = fork();
  if (childpid == -1) {
    fprintf(stderr, "fork error!\n");
    exit(1);
  }
  if (childpid == 0) {
    // Child closes the read file descriptor
    close(fds[0]);
    char str[] = "Hello Daddy!";
    // Child writes to the write file descriptor
    fprintf(stdout, "CHILD: Waiting for 2 seconds ...\n");
    sleep(2);
    fprintf(stdout, "CHILD: Writing to daddy ...\n");
    write(fds[1], str, strlen(str) + 1);
  } else {
    // Parent closes the write file descriptor
    close(fds[1]);
    char buff[32];
    // Parent reads from the read file descriptor
    fprintf(stdout, "PARENT: Reading from child ...\n");
    int num_of_read_bytes = read(fds[0], buff, 32);
    fprintf(stdout, "PARENT: Received from child: %s\n", buff);
  }
  return 0;
}

代码框 19-2 [ExtremeC_examples_chapter19_2.c]: 使用 POSIX 管道的示例 19.2

如你所见,在 main 函数的第二行,我们使用了 pipe 函数。正如我们之前所说的,它接受一个包含两个文件描述符的数组,并打开两个文件描述符,一个用于从管道读取,另一个用于向管道写入。第一个文件描述符位于索引 0,应用于读取;第二个文件描述符位于索引 1,应用于向管道写入。

为了拥有两个进程,我们使用了 fork API。正如我们在第十七章进程执行中解释的,fork API 会克隆父进程并创建一个新的子进程。因此,在调用 fork 函数后,打开的文件描述符对子进程也是可用的。

当子进程被创建时,父进程进入else块,而子进程进入if块。首先,每个进程都应该关闭它不打算使用的文件描述符。在这个例子中,父进程想要从管道读取,而子进程想要向管道写入。这就是为什么父进程关闭第二个文件描述符(写入文件描述符),而子进程关闭第一个文件描述符(读取文件描述符)。请注意,管道是单向的,反向通信是不可能的。

下面的 shell box 展示了前面示例的输出:

$ gcc ExtremeC_examples_chapter19_2.c -o ex19_2.out
$ ./ex19_2.out
PARENT: Reading from child ...
CHILD: Waiting for 2 seconds ...
CHILD: Writing to daddy ...
PARENT: Received from child: Hello Daddy!
$

Shell Box 19-3:运行示例 19.2 的输出

如你在Code Box 19-2中看到的,对于读写操作,我们使用readwrite函数。正如我们之前提到的,在基于推的 IPC 中,文件描述符指向一个字节通道,当你有一个指向通道的文件描述符时,你可以使用文件描述符的相关函数。readwrite函数接受一个文件描述符,无论背后的 IPC 通道是什么类型,它们都以相同的方式操作底层的通道。

在前面的例子中,我们使用了 fork API 来创建一个新的进程。如果出现这样的情况,即我们分别创建了两个不同的进程,问题是如何通过共享管道进行通信?如果一个进程需要在系统中访问管道对象,它应该有相应的文件描述符。有两种选择可用:

  • 其中一个进程应该设置管道并将相应的文件描述符传输给另一个进程。

  • 进程应该使用命名管道。

在第一种场景中,进程必须使用 Unix 域套接字通道来交换文件描述符。问题是,如果两个进程之间存在这样的通道,它们可以使用它进行进一步的通信,并且不需要设置另一个通道(POSIX 管道),该通道的 API 比 Unix 域套接字不友好。

第二种场景似乎更有前景。其中一个进程可以使用mkfifo函数,通过提供路径来创建一个队列文件。然后,第二个进程可以使用已创建文件的路径并打开它以进行进一步的通信。请注意,通道仍然是单向的,并且根据场景的不同,其中一个进程应该以只读模式打开文件,而另一个进程应该以只写模式打开文件。

关于前面的示例,还有一点需要讨论。正如你所看到的,子进程在写入管道之前会等待 2 秒。在此期间,父进程在read函数上被阻塞。因此,在没有消息写入管道的情况下,从管道读取的进程会变得阻塞。

作为本节的最后一点,我们知道 POSIX 管道是基于推送的。正如我们之前所解释的,基于推送的 IPC 技术都有一个相应的临时内核缓冲区来存储传入的推送消息。POSIX 管道也不例外,内核会保留写入的消息,直到它们被读取。请注意,如果所有者进程退出,管道对象及其对应的内核缓冲区将被销毁。

在下一节中,我们将讨论 POSIX 消息队列。

POSIX 消息队列

内核托管的消息队列是 POSIX 标准的一部分。它们在许多方面与 POSIX 管道有显著的不同。在这里,我们考察这些基本的不同点:

  • 管道内部是字节。相反,消息队列持有消息。管道对写入的字节中存在的任何结构都不知情,而消息队列则保留实际的消息,每次调用 write 函数都会在队列中添加一条新消息。消息队列保留了写入消息之间的边界。为了更详细地说明这一点,假设我们有三个消息:第一个消息有 10 个字节,第二个消息有 20 个字节,第三个消息有 30 个字节。我们将这些消息同时写入 POSIX 管道和 POSIX 消息队列。管道只知道它内部有 60 个字节,并允许程序读取 15 个字节。但消息队列只知道它有 3 条消息,并且不允许程序读取 15 个字节,因为我们没有 15 个字节的任何消息。

  • 管道有一个最大大小,单位是字节数。而消息队列则有一个最大消息数。在消息队列中,每个消息都有一个以字节为单位的最大大小。

  • 每个消息队列,就像一个命名共享内存或命名信号量,都会打开一个文件。虽然这些文件不是常规文件,但未来的进程可以使用它们来访问相同的消息队列实例。

  • 消息队列可以被赋予优先级,而管道对字节优先级并不关心。

它们还具有以下共同属性:

  • 它们都是单向的。为了实现双向通信,你需要创建两个管道或队列的实例。

  • 它们都有有限的容量;你不能写入任何你想要的字节数或消息数。

  • 在大多数 POSIX 系统中,它们都使用文件描述符来表示;因此,可以使用 readwrite 等 I/O 函数。

  • 这两种技术都是无连接的。换句话说,如果两个不同的进程写入两条不同的消息,其中一条消息有可能被另一个进程读取。换句话说,没有为消息定义所有权,任何进程都可以读取它们。这可能会成为一个问题,尤其是在有多个进程同时操作同一个管道或消息队列的情况下。

注意

本章中解释的 POSIX 消息队列不应与在 消息队列中间件 (MQM) 架构中使用的消息队列代理混淆。

互联网上有各种资源解释 POSIX 消息队列。以下链接专门解释了 QNX 操作系统上的 POSIX 消息队列,但大部分内容仍然适用于其他 POSIX 系统:https://users.pja.edu.pl/~jms/qnx/help/watcom/clibref/mq_overview.html。

现在是时候举一个例子了。示例 16.3 与我们之前在 示例 16.2 中的场景相同,但它使用 POSIX 消息队列而不是 POSIX 管道。与 POSIX 管道和 POSIX 消息队列相比,与 POSIX 消息队列相关的所有函数都声明在 mqueue.h 头文件中。我们将在稍后解释其中的一些。

注意,以下代码在 macOS 上无法编译,因为 OS/X 不支持 POSIX 消息队列:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <mqueue.h>
int main(int argc, char** argv) {
  // The message queue handler
  mqd_t mq;
  struct mq_attr attr;
  attr.mq_flags = 0;
  attr.mq_maxmsg = 10;
  attr.mq_msgsize = 32;
  attr.mq_curmsgs = 0;
  int childpid = fork();
  if (childpid == -1) {
    fprintf(stderr, "fork error!\n");
    exit(1);
  }
  if (childpid == 0) {
    // Child waits while the parent is creating the queue
    sleep(1);
    mqd_t mq = mq_open("/mq0", O_WRONLY);
    char str[] = "Hello Daddy!";
    // Child writes to the write file descriptor
    fprintf(stdout, "CHILD: Waiting for 2 seconds ...\n");
    sleep(2);
    fprintf(stdout, "CHILD: Writing to daddy ...\n");
    mq_send(mq, str, strlen(str) + 1, 0);
    mq_close(mq);
  } else {
    mqd_t mq = mq_open("/mq0", O_RDONLY | O_CREAT, 0644, &attr);
    char buff[32];
    fprintf(stdout, "PARENT: Reading from child ...\n");
    int num_of_read_bytes = mq_receive(mq, buff, 32, NULL);
    fprintf(stdout, "PARENT: Received from child: %s\n", buff);
    mq_close(mq);
    mq_unlink("/mq0");
  }
  return 0;
}

代码框 19-3 [ExtremeC_examples_chapter19_3.c]: 使用 POSIX 消息队列的示例 19.3

为了编译前面的代码,请运行以下命令。请注意,前面的代码应在 Linux 上与 rt 库链接:

$ gcc ExtremeC_examples_chapter19_3.c -lrt -o ex19_3.out
$

Shell Box 19-4: 在 Linux 上构建示例 19.3

以下 Shell 框演示了 示例 19.3 的输出。正如您所看到的,输出与我们之前在 示例 19.2 中得到的结果完全相同,但它使用 POSIX 消息队列来执行我们在 示例 19.2 中编写的相同逻辑:

$ ./ex19_3.out
PARENT: Reading from child ...
CHILD: Waiting for 2 seconds ...
CHILD: Writing to daddy ...
PARENT: Received from child: Hello Daddy!
$

Shell Box 19-5: 在 Linux 上运行示例 19.3

注意,POSIX 管道和消息队列在内核中都有一个有限的缓冲区。因此,在没有消费者读取其内容的情况下向管道和消息队列写入可能会导致所有写入操作被阻塞。换句话说,任何 write 函数调用都会保持阻塞,直到消费者从消息队列中读取消息或从管道中读取一些字节。

在以下部分,我们将简要解释 Unix 域套接字。在单主机设置中连接两个本地进程时,它们通常是首选。

Unix 域套接字

另一种可以在单主机部署中由多个进程使用的通信技术是使用 Unix 域套接字。它们是一种仅在相同机器上操作的特定类型的套接字。因此,它们与允许两个不同机器上的两个进程通过现有网络相互通信的网络套接字不同。Unix 域套接字具有各种特性,使它们与 POSIX 管道和 POSIX 消息队列相比显得重要且复杂。最重要的特性是 Unix 域套接字是双向的。因此,单个套接字对象就足够从底层通道读取和写入。换句话说,Unix 域套接字操作的通道是全双工的。此外,Unix 域套接字可以是 会话感知消息感知 的。这使得它们更加灵活。我们将在以下章节中讨论会话感知性和消息感知性。

由于没有了解套接字编程的基础,Unix 域套接字无法讨论,所以我们不会在本章中走得更远。相反,在接下来的几节中,我们将介绍套接字编程及其相关概念。关于 Unix 域套接字的全面讨论将在下一章中给出。让我们从套接字编程开始。

套接字编程简介

作为本章的一部分,我们决定在下一章通过实际的 C 代码示例之前讨论套接字编程。这是因为有一些基本概念在你跳到代码之前需要了解。

套接字编程可以在单主机和多主机部署上执行。正如你可能已经猜到的,单主机系统中的套接字编程是通过 Unix 域套接字完成的。在多主机设置中,套接字编程是关于创建和使用网络套接字。Unix 套接字和网络套接字在某种程度上使用相同的 API 并共享相同的概念,因此在下一章中一起介绍它们是有意义的。

在使用网络套接字之前的一个关键概念是计算机网络是如何工作的。在接下来的部分,我们将讨论这个问题,并介绍计算机网络。在能够编写你的第一个套接字编程示例之前,你应该了解许多术语和概念。

计算机网络

我们在本节中解释网络概念的方法与你在其他关于这个主题的文本中可能找到的方法不同。我们的目标是创建对计算机网络中事物如何工作的基本理解,特别是在两个进程之间。我们希望从程序员的视角来看待这个概念。我们讨论中的主要角色是进程,而不是计算机。因此,你可能会觉得章节的顺序一开始有点奇怪,但它将帮助你理解在计算机网络上 IPC 是如何工作的。

注意,本节不应被视为计算机网络的一个完整描述,当然,它不可能在几页纸和仅一个章节中完成。

物理层

首先,让我们忘记进程,只考虑计算机,或者简单地说是机器。在继续前进之前,请注意我们使用各种术语来指代网络中的计算机。我们可以称之为计算机、机器、主机、节点,甚至系统。当然,上下文有助于你找出给定术语背后的真正含义。

拥有多主机软件的第一步是连接在一起的一组计算机,或者更精确地说,是一个计算机网络。现在,让我们专注于我们想要连接的两台计算机。为了将这两台物理机器连接起来,我们当然需要某种物理介质,比如一根电线或无线设置。

当然,如果没有这样的物理介质(它不需要是可见的,例如在无线网络中),连接将无法实现。这些物理连接类似于城市之间的道路。我们将坚持这个类比,因为它可以非常接近地解释计算机网络内部发生的事情。

所需的所有硬件设备,用于在物理上连接两台机器,都被认为是物理层的一部分。这是我们探索的第一个也是最基本的一层。没有这一层,就无法在两台计算机之间传输数据并假定它们已连接。这一层之上的一切都不是物理的,你所能找到的只是一系列关于数据如何传输的各种标准。

让我们谈谈下一层,即链路层。

链路层

虽然仅有道路不足以让交通在其上移动,但计算机之间的物理连接也是如此。为了使用道路,我们需要有关车辆、标志、材料、边界、速度、车道、方向等的法律和法规,没有它们,沿道路行驶将会混乱且有问题。在两个计算机之间的直接物理连接中也需要类似的规则。

虽然连接多个计算机所需的物理组件和设备都属于物理层,但管理物理层上数据传输方式的强制规定和协议都属于一个称为链路层的上层。

作为链路协议实施的规则的一部分,消息应该被分成称为的部分。这类似于道路系统中定义的车辆在特定道路上行驶的最大长度的规定。你不能在道路上驾驶 1 公里长的拖车(假设在物理上可能),你必须将其分解成更小的部分,或者更小的车辆。同样,长数据块应该被分解成多个帧,并且每个帧都必须在网络中自由传输,独立于其他帧。

值得注意的是,网络可以存在于任何两个计算设备之间。它们不一定是计算机。在工业界有许多设备和机器可以相互连接以形成一个网络。工业网络有自己的物理布线、连接器、终端等标准,它们有自己的链路协议和标准。

许多标准描述了这样的链路连接,例如,如何将台式计算机连接到工业机器。设计用于通过有线方式连接多个计算机的最突出的链路协议之一是以太网。以太网描述了管理计算机网络上数据传输的所有规则和规定。我们还有另一个广泛使用的链路协议,称为 IEEE 802.11,它管理无线网络。

由通过特定链路协议通过物理连接连接的计算机(或任何其他同质计算机器或设备组)组成的网络称为局域网LAN)。请注意,任何希望加入局域网的设备都必须使用一个称为网络适配器网络接口控制器NIC)的物理组件。例如,想要加入以太网网络的计算机必须有一个以太网 NIC

一台计算机可以连接多个 NIC。每个 NIC 可以连接到特定的局域网,因此具有三个 NIC 的计算机能够同时连接到三个不同的局域网。

也可能它使用其所有三个 NIC 连接到同一个局域网。配置 NIC 的方式以及将计算机连接到各种局域网的方式应该在事先设计,并且应该有一个精确的计划。

每个 NIC 都有一个由治理链路协议定义的特定且唯一的地址。这个地址将用于局域网内节点之间的数据传输。以太网和 IEEE 802.11 协议为每个兼容的 NIC 定义了一个媒体访问控制MAC)地址。因此,任何以太网 NIC 或 IEEE 802.11 Wi-Fi 适配器都应该有一个唯一的 MAC 地址,以便加入一个兼容的局域网。在局域网内,分配的 MAC 地址应该是唯一的。请注意,理想情况下,任何 MAC 地址应该是全球唯一且不可更改的。然而,情况并非如此,你甚至可以设置 NIC 的 MAC 地址。

总结到目前为止我们所解释的内容,我们有一个两层堆栈,下面是物理层,上面是链路层。这足以连接单个局域网上的多个计算机。但这并没有结束。我们需要在这些两层之上再添加一层,以便能够连接来自不同局域网的计算机,无论是否有中间局域网。

网络层

到目前为止,我们已经看到 MAC 地址在以太网局域网中用于连接多个节点。但如果来自两个不同局域网的计算机需要相互连接会发生什么?请注意,这些局域网网络不一定兼容。

例如,其中一个局域网可能是有线以太网网络,而另一个局域网可能是主要使用光纤作为物理层的光纤分布式数据接口FDDI)网络。另一个例子是连接到普通以太网局域网的工业以太网IE)局域网上的工业机器,需要连接到操作员计算机。这些例子以及更多例子表明,我们需要在上述协议之上添加另一层,以便连接来自不同局域网的各个节点。请注意,我们甚至需要这个第三层来连接兼容的局域网。如果我们打算通过多个中间局域网将数据从一个局域网传输到另一个局域网(兼容或异构),这将更加关键。我们将在接下来的段落中进一步解释这一点。

就像链路层中的帧一样,我们在网络层中有数据包。长消息被分成更小的部分,称为数据包。虽然帧和数据包在不同的层中指的是两个不同的概念,但为了简单起见,我们考虑它们是相同的,并在本章的其余部分使用术语数据包

作为关键的区别,你应该知道帧封装数据包,换句话说,一个帧包含一个数据包。我们不会深入探讨帧和数据包,但你可以在互联网上找到许多描述这些概念各个方面的资源。

网络协议用于填补各种局域网之间的差距,以便将它们相互连接。虽然每个局域网都可以有自己的特定物理层、自己的特定链路层标准和协议,但所有这些局域网应遵循相同的网络协议。否则,异构(不兼容)的局域网无法相互连接。目前最著名的网络协议是互联网协议IP)。它在通常由较小的以太网或 Wi-Fi 局域网组成的大型计算机网络中得到广泛使用。IP 根据其地址长度分为两个版本:IPv4 和 IPv6。

但如何将来自两个不同局域网的计算机连接起来呢?答案在于路由机制。为了接收来自外部局域网的数据,应该有一个路由器节点。假设我们要连接两个不同的局域网:LAN1 和 LAN2。路由器就是一个节点,它通过拥有两个网络接口卡(NICs)同时存在于两个网络中。一个 NIC 位于 LAN1,另一个位于 LAN2。然后,一个特殊的路由算法决定哪些数据包需要传输以及如何在网络之间传输。

通过路由机制,多个网络可以通过路由器节点进行双向数据流动。为了实现这一点,每个局域网内都应该有一个路由器节点。因此,当你想要向位于不同地理区域的计算机发送数据时,你的数据可能需要通过数十个路由器才能到达目标。我不会深入探讨路由概念,但在网络上可以找到大量关于这一机制的优秀信息。

注意

有一个名为traceroute的实用程序,它允许你查看你的计算机和目标计算机之间的路由器。

到目前为止,来自两个不同局域网的两个主机可以相互连接,无论是否有中间局域网。任何进一步的具体连接尝试都应该在这个层次之上进行。因此,两个不同节点上的两个程序之间的任何通信都必须在三个协议层(物理层、链路层和最终的网络层)之上进行。但当我们说两台计算机连接在一起时,这究竟意味着什么呢?

说两个节点是连接的,至少对于程序员来说有点模糊。为了更精确,这些节点的操作系统是相互连接的,它们是传输数据的执行者。加入网络并与同一局域网或不同局域网中的其他节点通信的能力,在大多数当前操作系统中是固有的。基于 Unix 的操作系统能够成为我们本书的主要焦点,它们都是支持网络的操作系统能够安装在参与网络的节点上。

Linux、Microsoft Windows 和几乎任何现代操作系统都支持网络。实际上,一个操作系统如果不能在网络中运行,可能无法生存。请注意,是内核,或者更准确地说,内核中的一个单元,管理网络连接,因此更确切地说,实际的网络功能是由内核提供的。

由于网络功能由内核提供,用户空间中的任何进程都可以从中受益,并且它可以连接到网络中不同节点上的另一个进程。作为一个程序员,你不需要担心内核操作(物理层、链路层和网络层)的层级,你可以专注于它们之上的层级,那些与你的代码相关的层级。

IP 网络中的每个节点都有一个 IP 地址。正如我们之前所说的,我们有两种 IP 地址版本:IP 版本 4IPv4)和IP 版本 6IPv6)。IPv4 地址由四个段组成,每个段可以存储介于 0 到 255 之间的数值。因此,IPv4 地址从0.0.0.0开始,到255.255.255.255结束。正如你所见,我们只需要 4 个字节(或 32 位)来存储一个 IPv4 地址。对于 IPv6 地址,这增加到 16 个字节(或 128 位)。此外,我们还有私有和公共 IP 地址,但详细内容远远超出了本章的主题。我们只需要知道,IP 网络中的每个节点都有一个唯一的 IP 地址。

在上一节的基础上,在单个局域网中,每个节点都有一个链路层地址和一个 IP 地址,但我们将使用 IP 地址来连接该节点,而不是链路层地址。例如,在一个以太网局域网中,每个节点有两个地址;一个是 MAC 地址,另一个是 IP 地址。MAC 地址由链路层协议用于在局域网内传输数据,而 IP 地址由驻留在各个节点上的程序用于在同一局域网内或多个局域网之间建立网络连接。

网络层的主要功能是连接两个或更多局域网。这最终会导致一个庞大的网络网状结构,这些网络相互连接,形成一个包含许多独立局域网的大型网络。实际上,这样的网络是存在的,我们称之为互联网。

像任何其他网络一样,每个可访问互联网的节点都必须有一个 IP 地址。但可访问互联网的节点和不可访问的节点的主要区别在于,互联网节点必须有一个公网 IP 地址,而通常不可通过互联网访问的节点有一个私有地址。

以一个例子来说明,你的家庭网络可能连接到互联网,但互联网上的外部节点无法连接到你的笔记本电脑,因为你的笔记本电脑有一个私有 IP 地址而没有公网 IP 地址。虽然你的笔记本电脑仍然可以在家庭网络内部访问,但它不在互联网上。因此,如果你的软件要在互联网上可用,它应该在具有公网 IP 地址的机器上运行。

关于 IP 网络的信息量巨大,我们不会涵盖所有内容,但作为一个程序员,了解私有地址和公网地址之间的区别是非常重要的。

在网络中,确保节点之间的连接不是程序员的职责;能够检测网络缺陷被认为是你的技能的一部分。这一点非常重要,因为它可以让你知道一个错误或不正常行为是否根植于你的代码,或者它是一个基础设施(或网络)问题。这就是为什么我们必须在这里涉及一些更多概念和工具。

确保两个主机(节点),无论是在同一局域网内还是在不同的局域网中,能够传输数据,或者它们能够“看到”对方的基本工具是ping工具。你可能已经知道了它。它发送一系列互联网控制消息协议ICMP)数据包,如果收到回复,则表示另一台主机正在运行、已连接并响应。

注意

ICMP 是另一种网络层协议,主要用于在出现连接性或服务质量问题及故障时,对基于 IP 的网络进行监控和管理。

假设你要检查你的电脑是否可以看到公网 IP 地址8.8.8.8(如果它连接到互联网,它应该可以看到)。以下命令将帮助你检查连接性:

$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=123 time=12.190 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=123 time=25.254 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=123 time=15.478 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=123 time=22.287 ms
64 bytes from 8.8.8.8: icmp_seq=4 ttl=123 time=21.029 ms
64 bytes from 8.8.8.8: icmp_seq=5 ttl=123 time=28.806 ms
64 bytes from 8.8.8.8: icmp_seq=6 ttl=123 time=20.324 ms
^C
--- 8.8.8.8 ping statistics ---
7 packets transmitted, 7 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 12.190/20.767/28.806/5.194 ms
$

Shell Box 19-6:使用 ping 实用程序检查互联网连接性

如你在输出中看到的那样,它表示已经发送了 7 个 ICMP ping 数据包,在传输过程中没有丢失任何一个。这意味着 IP 地址8.8.8.8背后的操作系统正在运行并响应。

注意

公网 IP 地址8.8.8.8指的是谷歌公共 DNS 服务。更多信息请参阅:https://en.wikipedia.org/wiki/Google_Public_DNS。

在本节中,我们解释了如何通过网络将两台计算机连接起来。现在,我们正接近这样一个点,即两个进程实际上可以相互连接并跨越多个局域网传输数据。为此,我们需要在网络层之上添加另一个层。这就是网络编程开始的地方。

传输层

到目前为止,我们已经看到,两台计算机可以通过三层堆栈相互连接:物理层、链路层和网络层。对于进程间通信,我们实际上需要两个进程连接并相互交谈。但是,通过这三层连接的两台计算机上可以运行许多进程,并且运行在第一台机器上的任何进程都可能想要与位于第二台机器上的另一个进程建立连接。因此,仅基于网络层的连接过于通用,无法支持由各种进程发起的多个不同的连接。

正因如此,我们才需要在网络层之上再添加一层。传输层就是为了满足这一需求而存在的。虽然主机通过网络层连接,但运行在这些主机上的进程可以通过在网络层之上建立的传输层进行连接。像任何其他具有自己独特标识符或地址的层一样,这一层也有一个新概念作为其唯一标识符,通常被称为端口。我们将在接下来的章节中对此进行详细阐述,但在那之前,我们必须解释监听器-连接器模型,该模型允许双方通过一个通道进行通信。在下一节中,我们将通过将计算机网络与电话网络进行类比来解释这一模型。

电话网络的类比

最佳的例子就是公共交换电话网络(或PSTN)。虽然计算机网络与电话网络之间的相似性可能看起来并不很有希望,但它们之间存在着强烈的相似性,这使我们能够以合理的方式解释传输层。

在我们的类比中,使用电话网络的人就像计算机网络中的进程。因此,电话通话相当于传输连接。人们只有当必要的基础设施已经安装时才能进行通话。这类似于应该存在的网络基础设施,以便使进程能够进行通信。

我们假设所需的底层基础设施已经到位并且运行完美,基于这一点,我们希望在这两个系统中存在两个实体来建立一个通道并传输数据。这类似于 PSTN 中的两个人和计算机网络中两个不同主机上的两个进程。

任何想要使用 PSTN 的人都需要一部电话设备。这类似于计算机节点需要网络接口卡(NIC)的要求。在这些设备之上,有多个由各种协议组成的层。这些构建底层基础设施的层使得创建传输通道成为可能。

现在,在 PSTN 中,连接到 PSTN 的其中一个电话设备会等待接收到电话。我们称之为听者一方。请注意,连接到 PSTN 的电话设备总是等待来自网络的呼叫信号,并且一旦接收到信号,就会响起铃声。

现在,让我们谈谈另一方,它是发起通话的一方。请注意,发起通话相当于创建一个传输通道。另一方也有一个用于发起通话的电话设备。听者可以通过电话号码访问,这可以被视为听者的地址。连接者一方必须知道这个电话号码才能发起通话。因此,连接者拨打听者的电话号码,底层基础设施会让听者知道有一个来电。

当听者一方接听电话时,它接受传入的连接,并在听者和连接者之间建立一条通道。从现在起,两端坐着的人就可以通过创建的 PSTN 通道进行交谈和继续讨论。请注意,如果一方无法理解另一方的语言,通信将无法继续,并且一方会挂断电话,通道将被销毁。

面向连接与无连接的传输通信

上述类比试图解释计算机网络中的传输通信,但实际上它描述的是面向连接的通信。在这里,我们将介绍并描述另一种类型的通信:无连接通信。但在那之前,让我们更深入地了解面向连接的通信。

在面向连接的通信中,为连接者创建了一个特定的专用通道。因此,如果我们有一个听者与三个连接者通信,我们就有了三个专用通道。无论传输的消息有多大,消息都会以正确的形式到达另一方,而不会在通道内丢失。如果向同一位置发送多个消息,发送消息的顺序将被保留,接收进程不会注意到底层基础设施中的任何干扰。

如我们在前几节中解释的那样,任何消息在通过计算机网络传输时,总是被分解成更小的块,称为数据包。然而,在面向连接的方案中,无论是听者还是连接者,都不会注意到底层数据包交换的任何情况。即使发送的数据包接收到的顺序不同,接收者的操作系统也会重新排列数据包,以便以正确的形式重建消息,接收进程不会注意到任何异常。

更重要的是,如果在传输过程中某个数据包丢失,接收方的操作系统将请求重新发送,以恢复完整消息。例如,传输控制协议TCP)是一种传输层协议,其行为与我们上面解释的完全一致。因此,TCP 通道是面向连接的。

除了面向连接的通道外,我们还有无连接通信。在面向连接的通信中,我们保证两个因素:单个数据包的交付和数据包的顺序。例如,TCP 这样的面向连接的传输协议同时保持这些因素。相反,无连接传输协议不保证它们。

换句话说,你可能无法保证消息被分割成单独的数据包的交付,或者你可能无法保证所有数据包都将按正确的顺序排列。或者你可能两者都没有保证!例如,用户数据报协议UDP)不保证数据包的交付或数据包的顺序。请注意,单个数据包内容的正确性保证由网络层和链路层的协议提供。

现在是时候解释两个在网络编程中常用的术语了。是通过面向连接的通道传输的字节序列。这意味着无连接传输实际上不提供数据流。我们有一个特定的术语来描述通过无连接通道传输的数据单元。我们称之为数据报。数据报是在无连接通道中可以整体交付的数据块。任何大于最大数据报大小的数据块都无法确保交付,或者最终的顺序可能错误。数据报是在传输层定义的概念,它是网络层中数据包的对立概念。

例如,关于 UDP 数据包,可以保证每个单独的 UDP 数据报(数据包)被正确传输,但关于两个相邻数据报(数据包)之间的相关性,则无话可说。人们普遍认为,UDP 数据报之外不应存在完整性,但 TCP 并非如此。在 TCP 中,由于交付保证和发送数据包顺序的保持,我们可以将单个数据包放在一边,将其视为两个进程之间传输的字节流。

传输初始化序列

在本小节中,我们将讨论每个进程为了建立传输通信所采取的步骤。我们为面向连接和无连接方案有不同的序列,因此我们将分别在接下来的两个小节中分别讨论它们。请注意,差异仅出现在通道的初始化过程中,之后,双方将使用更多或更少的相同 API 来读取和写入创建的通道。

监听器过程始终绑定一个端点(通常是 IP 地址与端口号的组合),而连接器过程始终连接到该端点。这无论是有连接还是无连接的通道都是如此。

注意,在以下序列中,我们假设监听器和连接器过程所在的主机之间已经建立了 IP 网络。

无连接的初始化序列

为了建立无连接的通信通道,监听器过程将执行以下操作:

  1. 监听器过程将在现有的一个或所有网络接口卡(NIC)上绑定一个端口。这意味着监听器过程请求其宿主操作系统将传入数据重定向到该端口,从而重定向到监听器过程。端口是一个介于 0 到 65535(2 字节)之间的数字,并且不能被另一个监听器过程绑定。尝试绑定一个已使用的端口将导致错误。请注意,在绑定特定 NIC 上的端口时,操作系统将重定向所有针对该绑定端口且在该特定 NIC 上接收到的目标数据包到监听器过程。

  2. 进程等待并读取在创建的通道上可用的消息,并通过将响应写回通道来对这些消息进行响应。

连接器过程将执行以下操作:

  1. 它必须知道监听器过程的 IP 地址和端口号。因此,它尝试通过向其宿主操作系统提供 IP 地址和端口号来连接监听器端。如果目标过程没有在指定的端口上监听,或者 IP 地址指向无效或错误的主机,连接将失败。

  2. 当连接成功建立后,连接器过程可以几乎以相同的方式写入通道并从中读取,这意味着与监听器过程使用的相同 API。

注意,除了执行上述步骤之外,监听器和连接器过程都应该使用相同的传输协议,否则消息不能被它们的宿主操作系统读取和理解。

面向连接的初始化序列

在面向连接的场景中,监听器过程将按照以下顺序进行初始化:

  1. 绑定端口,就像之前解释的无连接场景一样。端口与之前章节中解释的完全相同,并遵循相同的约束。

  2. 监听器过程继续通过配置其后备队列的大小来进行配置。后备队列是尚未被监听器过程接受的挂起连接的队列。在面向连接的通信中,监听器端应在能够传输任何数据之前接受传入的连接。配置后备队列后,监听器过程进入监听模式

  3. 现在,监听器进程开始接受传入的连接。这是建立传输通道的一个基本步骤。只有在接受传入连接之后,它们才能传输数据。请注意,如果连接器进程向监听器进程发送一个连接,但监听器进程无法接受该连接,它将保留在队列中,直到被接受或超时。这可能会发生在监听器进程忙于处理其他连接,无法接受任何新的连接时。然后,传入的连接将堆积在队列中,当队列满时,主机操作系统将立即拒绝新的连接。

连接器进程的顺序与我们之前章节中解释的无连接通信非常相似。连接器通过提供 IP 地址和端口号连接到某个端点,并在被监听器进程接受后,可以使用相同的 API 从连接导向的通道中读取和写入。

由于建立的通道是面向连接的,监听器进程有专门连接到连接器侧的通道;因此,它们可以交换一个没有字节数量上限的字节流。因此,两个进程可以传输大量数据,其正确性由管理和网络传输协议保证。

作为关于传输层的最后一项说明,我们提到监听器进程(无论底层通道是面向连接的还是无连接的)都需要绑定一个端点。具体到 UDP 和 TCP,这个端点由一个 IP 地址和一个端口号组成。

应用层

当两个位于不同端点的进程之间建立传输通道时,它们应该能够相互通信。我们在这里所说的“通信”是指传输一系列双方都能理解的字节。正如我们在本章前面的部分所解释的,这里需要一个通信协议。由于该协议位于应用层,并且由进程(或作为进程运行的应用程序)使用,因此它被称为应用协议

虽然在链路、网络和传输层使用的协议并不多,它们大多是众所周知的,但我们有大量的应用协议在应用层使用。这又类似于电信网络。虽然电话网络没有多少标准,但人们用来通信的语言数量庞大,差异很大。在计算机网络中,每个作为进程运行的应用都需要使用一个应用协议才能与其他进程通信。

因此,程序员要么使用一个众所周知的应用协议,如 HTTP 或 FTP,要么必须使用一个本地设计和构建的定制应用协议。

到目前为止,我们已经讨论了五层;物理层、链路层、网络层、传输层和应用层。现在是我们将它们全部放入一个单一体系并用作设计部署计算机网络的参考的时候了。在接下来的部分,我们将讨论互联网协议套件。

互联网协议套件

我们每天看到的并且广泛应用的网络安全模型是互联网协议套件IPS)。IPS 主要在互联网上使用,由于几乎所有的计算机都想访问互联网,它们普遍采用了 IPS,尽管 IPS 并不是由 ISO 正式批准的标准。计算机网络的标准模型是开放系统互联OSI)模型,它更多的是一个理论模型,几乎从未公开部署和使用。IPS 具有以下层。请注意,以下列表中提到了每一层的突出协议:

  • 物理层

  • 链路层:以太网,IEEE 802.11 Wi-Fi

  • 互联网层:IPv4,IPv6 和 ICMP

  • 传输层:TCP,UDP

  • 应用层:包括 HTTP、FTP、DNS 和 DHCP 等众多协议。

如你所见,这些层与我们在本章中讨论的层有很好的对应关系,但只有一个例外;网络层被重命名为互联网层。这是因为作为 IPS 的一部分,在这个层中突出的网络协议只有 IPv4 和 IPv6。其余的解释可以应用于 IPS 层。IPS 是我们将在整本书和实际工作环境中处理的主要模型。

现在我们已经了解了计算机网络的工作原理,我们处于一个很好的位置继续前进,看看什么是套接字编程。作为本章剩余部分和即将到来的章节的一部分,你将看到传输层中讨论的概念与套接字编程中的概念之间存在深刻的对应关系。

什么是套接字编程?

现在我们已经了解了 IPS 模型和不同的网络层,解释什么是套接字编程就变得容易多了。在深入讨论套接字编程的技术细节之前,我们应该将其定义为一个 IPC 技术,它允许我们连接位于同一节点或两个不同节点上的两个进程,这两个节点之间有网络连接。如果我们不考虑单主机套接字编程,另一种形式要求我们在两个节点之间有一个可操作的网络安全环境。正是这个事实将套接字编程与计算机网络以及我们迄今为止所解释的一切联系在一起。

要使其更加技术化,我们应该说套接字编程主要发生在传输层。正如我们之前所说的,传输层负责在现有的网络层(网络层)上连接两个进程。因此,传输层是建立套接字编程上下文的关键层。基本上,这就是为什么作为程序员,你应该更多地了解传输层及其各种协议。一些与套接字编程相关的错误其根源在于底层的传输通道。

在套接字编程中,套接字是建立传输通道的主要工具。请注意,尽管我们迄今为止已经讨论了,套接字编程可以超越传输层或进程间通信,它还可以包括网络层(网络层)或主机间通信。这意味着我们可以有网络层特定的套接字以及传输层套接字。考虑到这一点,我们看到的并与之交互的大多数套接字都是传输套接字,在本章的剩余部分和下一章中,我们将主要讨论传输套接字。

什么是套接字?

正如我们在上一节中解释的那样,传输层是实际套接字编程发生的地方。其上的一切只是使套接字编程更加具体;然而,实际的底层通道已经在传输层建立。

我们还讨论了,在传输通道已建立的互联网连接(网络连接)实际上是操作系统之间的连接,或者更具体地说,是那些操作系统的内核之间的连接。因此,内核中应该有一个类似于连接的概念。不仅如此,同一个内核可以发起或接受许多已建立的连接,仅仅是因为可能有多个进程在该操作系统中运行并托管,并且愿意建立网络连接。

我们正在寻找的概念是 套接字。对于系统中已建立或即将建立的任何连接,都有一个专门的套接字来标识该连接。对于两个进程之间建立的单一连接,每一边恰好有一个套接字指向相同的连接。正如我们之前解释的那样,其中一个套接字属于连接器端,另一个套接字属于监听器端。允许我们定义和管理套接字对象的 API 由操作系统公开的 套接字库 描述。

由于我们主要讨论的是 POSIX 系统,我们期望有一个这样的套接字库作为 POSIX API 的一部分,实际上我们确实有这样的库。在本章的剩余部分,我们将讨论 POSIX 套接字库,并解释如何使用它来在两个进程之间建立连接。

POSIX 套接字库

每个套接字对象都有三个属性:类型协议。虽然操作系统的手册页对这些属性解释得很好,但我们想讨论一些这些属性常用的值。我们首先从域属性开始,它也被称为地址族AF)或协议族PF)。以下列表中可以看到一些广泛使用的值。请注意,这些地址族支持面向连接和无连接的传输连接。

  • AF_LOCALAF_UNIX:这些是本地套接字,仅在连接器和监听器进程都位于同一主机上时才工作。

  • AF_INET:这些套接字允许两个进程通过 IPv4 连接相互连接。

  • AF_INET6:这些套接字允许两个进程通过 IPv6 连接相互连接。

注意

在某些 POSIX 系统中,您可能会在用于域属性的常量中找到前缀PF_而不是AF_。通常情况下,AF_常量与PF_常量具有相同的值,因此它们可以互换使用。

在下一章中,我们将演示AF_UNIXAF_INET域的使用,但应该很容易找到使用AF_INET6域的示例。此外,可能存在特定于某些操作系统的地址族,在其他系统上找不到。

套接字对象类型属性的已知值如下:

  • SOCK_STREAM:这意味着套接字将表示面向连接的传输通信,保证发送内容的交付、正确性和顺序。正如我们在前面的章节中解释的流,术语STREAM也暗示了这一点。请注意,在此阶段,您无法预测实际底层的传输协议是 TCP,因为这与属于AF_UNIX地址族的本地区套接字不符。

  • SOCK_DGRAM:这意味着套接字将表示无连接的传输通信。请注意,术语数据报(datagram),缩写为DGRAM,正如我们在前面的章节中解释的那样,指的是一系列无法被视为流的字节。相反,它们可以被视为一些称为数据报的独立数据块。在更技术性的背景下,数据报表示通过网络传输的数据包。

  • SOCK_RAW:原始套接字可以表示面向连接和无连接的通道。SOCK_RAWSOCK_DGRAMSOCK_STREAM之间的主要区别在于内核实际上知道底层使用的传输协议(UDP 或 TCP),并且它可以解析数据包并提取头部和内容。但是,对于原始套接字,内核并不这样做,而是由打开套接字的程序负责读取和提取各个部分。

    换句话说,当使用SOCK_RAW时,数据包会直接传递给程序,程序应该自行提取和理解数据包结构。请注意,如果底层通道是流通道(面向连接的),丢失的数据包恢复和数据包重排序不是由内核完成的,程序应该自行完成这些操作。这意味着,当您选择 TCP 作为传输协议时,恢复和数据包重排序实际上是由内核完成的。

第三个属性,协议,标识了应该用于套接字对象的协议。由于大多数地址族与类型一起确定了一个特定的协议,因此该属性可以在套接字创建时由操作系统选择。在存在多个可能协议的情况下,应该定义此属性。

套接字编程为单主机和多主机 IPC 提供了解决方案。换句话说,虽然使用互联网(网络)套接字连接位于两个不同主机和两个不同局域网上的两个进程是完全可能的,但使用 Unix 域套接字连接同一主机上的两个进程也是完全可能的。

作为本节的最后一项注意,我们应该补充说明,套接字连接是双向和全双工的。这意味着双方都可以从底层通道读取和写入,而不会干扰对方。这是一个期望的特性,因为在大多数与进程间通信(IPC)相关的场景中,通常都有这样的要求。

现在您已经了解了套接字的概念,我们必须回顾之前章节中关于监听器和连接器进程的序列。但这次,我们将更深入地探讨,并描述如何使用套接字来执行这些序列。

回顾监听器-连接器序列

正如我们之前提到的,作为计算机网络的一部分,几乎在每一次连接中,一端总是处于监听状态,等待传入的连接,而另一端则尝试连接到监听端。我们也讨论了一个关于电话网络的例子,解释了电话是如何用来监听传入的呼叫,以及它是如何用来发起呼叫并连接到其他监听设备的。在套接字编程中,也存在类似的情况。在这里,我们想要探讨两个不同端点的进程应该遵循的序列,以便建立成功的传输连接。

在以下小节中,我们将更深入地探讨套接字创建的细节以及想要建立连接的两个进程应该执行的各种操作。以下小节中解释的监听器和连接器进程的序列是基础设施无关的,并得益于套接字编程在底层传输连接上提供的通用化。

正如你应该记得的,我们分别就面向连接和无连接通信讨论了监听器和连接器序列。我们在这里采取同样的方法,首先从流(面向连接)监听序列开始。

流监听序列

想要监听新的流连接的过程应遵循以下步骤。您已经在之前的章节中介绍了绑定、监听和接受阶段,但在这里我们将从套接字编程的角度来讨论它们。请注意,大部分实际功能都是由内核提供的,进程只需从套接字库中调用正确的函数,以便将自己置于监听模式:

  1. 该过程应使用 socket 函数创建一个套接字对象。这个套接字对象通常被称为 监听套接字。套接字对象代表整个监听进程,它将被用来接受新的连接。根据底层通道的不同,发送给 socket 函数的参数可能会有所不同。我们可以传递 AF_UNIXAF_INET 作为套接字的地址族,但我们必须使用 SOCK_STREAM 作为套接字类型,因为我们将要有一个流通道。套接字对象协议属性可以由操作系统确定。例如,如果你为套接字对象选择了 AF_INETSOCK_STREAM,则默认选择 TCP 作为协议属性。

  2. 现在,套接字必须使用 bind 函数绑定到一个 端点,该端点可以通过连接器进程访问。所选端点的详细信息严重依赖于所选的地址族。例如,对于互联网通道,端点应该是 IP 地址和端口的组合。对于 Unix 域套接字,端点应该是文件系统上 套接字文件 的路径。

  3. 套接字必须配置为监听。在这里,我们使用 listen 函数。正如我们之前所解释的,它只是为监听套接字创建一个队列。队列是一系列尚未被监听进程接受的等待连接。当监听进程无法接受新的传入连接时,内核将保留在相应队列中的传入连接,直到监听进程变得空闲并开始接受它们。一旦队列满,内核将拒绝任何进一步的传入连接。选择较小的队列大小可能导致在监听进程拥塞时许多连接被拒绝,而选择较大的队列大小可能导致大量等待连接最终超时并断开。队列大小应根据监听程序的动态性来选择。

  4. 在配置了 backlog 之后,是时候接受传入的连接了。对于每一个传入的连接,都应该调用accept函数。因此,将accept函数放在一个永不结束的循环中是一个广泛使用的模式。当监听器进程停止接受新的连接时,连接器进程将被放入 backlog 中,一旦 backlog 满了,它们将被拒绝。请注意,每次对accept函数的调用只是简单地从套接字的 backlog 中取出下一个等待的连接。如果 backlog 为空,并且监听套接字被配置为阻塞模式,那么对accept函数的任何调用都将被阻塞,直到有新的连接到来。

注意,accept函数返回一个新的套接字对象。这意味着内核为每个接受的连接分配了一个新的唯一套接字对象。换句话说,一个已经接受 100 个客户端的监听器进程至少使用了 101 个套接字:1 个用于监听套接字,100 个用于其传入的连接。从accept函数返回的套接字应用于与通道另一端的客户端进行进一步的通信。

注意,这个函数调用序列对所有类型的流(面向连接的)基于套接字的 IPC 都保持一致。在下一章中,我们将展示如何使用 C 语言编程实现这些步骤的实例。在下一小节中,我们将处理流连接器序列。

流连接器序列

当连接器进程想要连接到一个已经处于监听模式的监听器进程时,它应该遵循以下序列。请注意,监听器进程应该处于监听模式,否则连接将被目标主机的内核拒绝:

  1. 连接器进程应该通过调用socket函数来创建一个套接字。这个套接字将用于连接到目标进程。这个套接字的特征应该与为监听套接字设置的相似,或者至少是兼容的,否则我们无法建立连接。因此,我们需要设置与监听套接字相同的地址族。并且类型应保持为SOCK_STREAM

  2. 然后它应该通过传递唯一标识监听端点的参数来使用connect函数。监听端点应该可以被连接器进程访问,并且它应该已经被目标进程提供。如果connect函数成功,这意味着连接已经被目标进程接受。在此之前,连接可能已经在目标进程的 backlog 中等待。如果指定的目标端点由于任何原因不可用,连接将失败,并且连接器进程将收到一个错误。

就像监听进程中的 accept 函数调用一样,connect 函数返回一个套接字对象。这个套接字标识了连接,并且应该用于与监听进程的进一步通信。在下一章中,我们将通过计算器示例演示前面的序列。

数据报监听器序列

为了初始化,数据报监听进程将按以下顺序执行:

  1. 与流监听器一样,数据报监听器进程通过调用 socket 函数创建一个套接字对象。但这次,它必须将套接字类型属性设置为 SOCK_DGRAM

  2. 现在监听套接字已经创建,监听进程应该将其绑定到一个端点。端点和其约束与流监听端类似。请注意,对于数据报监听套接字,不会有监听模式或接受阶段,因为底层通道是无连接的,我们无法为每个传入连接分配一个专用会话。

正如解释的那样,数据报服务器套接字没有监听模式或接受阶段。此外,数据报监听器应该使用 recvfromsendto 函数来从连接进程读取和写入。读取仍然可以使用 read 函数完成,但仅使用简单的 write 函数调用无法写入响应。当我们查看下一章中的数据报监听器示例时,您将看到原因。

数据报连接序列

数据报连接器几乎与流连接器有相同的序列。唯一的区别是套接字类型,对于数据报连接器必须是 SOCK_DGRAM。对于数据报 Unix 域连接套接字有一个特殊情况,它们必须绑定到一个 Unix 域套接字文件,以便接收来自服务器的响应。我们将在下一章中,作为使用 Unix 域套接字的数据报计算器示例的一部分,对此进行详细说明。

现在我们已经讨论了所有可能的序列,是时候解释套接字和 套接字描述符 之间的关系了。这是本章的最后一节,通过开始下一章,我们将给出涵盖所有序列的真实 C 语言示例。

套接字有自己的描述符!

与其他使用文件描述符工作的基于推的 IPC 技术不同,基于套接字的技术处理套接字对象。每个套接字对象都由一个整数值引用,这是内核中的套接字描述符。这个套接字描述符可以用来引用底层通道。

注意,文件描述符和套接字描述符是不同的。文件描述符指的是常规文件或设备文件,而套接字描述符指的是由 socketacceptconnect 函数调用创建的套接字对象。

虽然文件描述符和套接字描述符不同,但我们仍然可以使用相同的 API 或函数集来读取和写入它们。因此,可以使用readwrite函数来处理套接字,就像处理文件一样。

这些描述符还有一个相似之处;它们都可以通过相同的 API 配置为非阻塞。非阻塞描述符可以用于以非阻塞方式处理背后的文件或套接字。

摘要

在本章中,我们开始讨论允许两个进程进行通信和传输数据的 IPC 技术。本章的讨论将在下一章中完成,我们将具体讨论套接字编程,并提供各种真实的 C 语言示例。

作为本章的一部分,我们涵盖了以下主题:

  • 拉模型和推模型的 IPC 技术以及它们的不同和相似之处。

  • 我们比较了单主机 IPC 技术与多主机 IPC 技术。

  • 你学习了关于通信协议及其各种特性的内容。

  • 我们回顾了序列化和反序列化概念以及它们如何操作以满足特定的通信协议。

  • 我们解释了协议的内容、长度和顺序特性如何影响接收器进程。

  • 我们解释了 POSIX 管道,并通过示例演示了如何使用它们。

  • 你看到了 POSIX 消息队列是什么以及它如何被用来使两个进程能够通信。

  • 我们简要介绍了 Unix 域套接字及其基本特性。

  • 我们解释了计算机网络是什么以及各种网络层堆栈如何导致传输连接。

  • 我们解释了套接字编程是什么。

  • 我们解释了监听器和连接器进程的初始化序列以及它们成为初始化状态所采取的步骤。

  • 我们比较了文件描述符和套接字描述符。

在下一章中,我们将继续讨论套接字编程,重点提供真实的 C 语言示例。我们将定义一个计算器客户端和一个计算器服务器的示例。之后,我们将使用 Unix 域套接字和互联网套接字在计算器客户端和其服务器之间建立完全功能化的客户端-服务器通信。

第二十章

套接字编程

在前一章中,我们讨论了单主机进程间通信(IPC)并介绍了套接字编程。在这一章中,我们想要完成我们的介绍,并使用一个真实的客户端-服务器示例(计算器项目)深入探讨套接字编程。

本章中主题的顺序可能看起来有些不寻常,但目的是让你更好地理解各种类型的套接字以及它们在实际项目中的行为。作为本章的一部分,我们讨论以下主题:

  • 首先,我们回顾一下前一章中我们解释的内容。请注意,这个回顾只是一个简短的总结,你必须阅读前一章关于套接字编程的第二部分。

  • 作为回顾的一部分,我们讨论了各种类型的套接字、流和数据报序列,以及对我们继续计算器示例至关重要的其他主题。

  • 客户端-服务器示例,即计算器项目,被描述并全面分析。这为我们继续讨论示例中的各种组件和展示 C 代码做好了准备。

  • 作为示例的关键组件,我们开发了一个序列化/反序列化库。这个库将代表计算器客户端与其服务器之间使用的主要协议。

  • 理解这一点至关重要:计算器客户端和计算器服务器必须能够通过任何类型的套接字进行通信。因此,我们在示例中展示了各种类型的套接字,并以Unix 域套接字(UDS)作为起点。

  • 在我们的示例中,我们展示了它们如何在单主机设置中建立客户端-服务器连接。

  • 为了继续讨论其他类型的套接字,我们讨论网络套接字。我们展示了如何在计算器项目中集成 TCP 和 UDP 套接字。

让我们从总结我们关于套接字和套接字编程的一般知识开始这一章。在深入本章内容之前,强烈建议你熟悉前一章的后半部分,因为我们在这里假设了一些先验知识。

套接字编程回顾

在本节中,我们将讨论什么是套接字,它们的各种类型是什么,以及如果我们说我们在进行套接字编程,这通常意味着什么。这将是一个简短的回顾,但这是建立这个基础所必需的,以便我们可以在后续章节中进行更深入的讨论。

如果您还记得前几章的内容,我们有两种 IPC 技术类别,用于两个或更多进程进行通信和共享数据。第一类包含基于拉取的技术,这些技术需要一个可访问的介质(例如共享内存或常规文件)来存储数据和检索数据。第二类包含基于推送的技术。这些技术需要一个通道来建立,并且该通道应该对所有进程都是可访问的。这两类技术的主要区别在于,在基于拉取的技术中,数据是从介质中检索的方式,或者在基于推送的技术中,是从通道中检索的方式。

简单来说,在基于拉取的技术中,数据应该从介质中拉取或读取,但在基于推送的技术中,数据会自动推送到或交付给读取进程。在基于拉取的技术中,由于进程从共享介质中拉取数据,如果多个进程可以写入该介质,就容易出现竞态条件。

要更精确地描述基于推送的技术,数据始终被发送到内核中的一个缓冲区,并且该缓冲区可以通过使用描述符(文件或套接字)被接收进程访问。

然后,接收进程可以选择阻塞,直到该描述符上有可用的新数据,或者它可以轮询该描述符以查看内核是否在该描述符上接收到了新数据;如果没有,则继续执行其他工作。前者是阻塞 I/O,后者是非阻塞 I/O异步 I/O。在本章中,所有基于推送的技术都使用阻塞方法。

我们知道,套接字编程是一种特殊的 IPC(进程间通信)类型,属于第二类。因此,所有基于套接字的 IPC 都是基于推送的。但将套接字编程与其他基于推送的 IPC 技术区分开来的主要特征是,在套接字编程中我们使用套接字。套接字是类 Unix 操作系统中的一种特殊对象,甚至在非 Unix-like 的 Microsoft Windows 系统中,它代表双向通道

换句话说,单个套接字对象可以用来从同一个通道中读取和写入。这样,位于同一通道两端的两个进程可以实现双向通信

在前一章中,我们了解到套接字由套接字描述符表示,就像文件由文件描述符表示一样。虽然套接字描述符和文件描述符在某些方面相似,例如 I/O 操作和可轮询性,但它们实际上是不同的。单个套接字描述符始终代表一个通道,但文件描述符可以代表一个介质,如常规文件,或者一个通道,如 POSIX 管道。因此,与文件相关的某些操作,如 seek,不支持套接字描述符,甚至当文件描述符代表通道时也不支持。

基于套接字的通信可以是面向连接的或无连接的。在面向连接的通信中,通道代表两个特定进程之间传输的字节,而在无连接通信中,数据报可以沿着通道传输,并且两个进程之间没有特定的连接。多个进程可以使用同一个通道来共享状态或传输数据。

因此,我们有两种类型的通道:流通道数据报通道。在程序中,每个流通道都由一个流套接字表示,每个数据报通道都由一个数据报套接字表示。在设置通道时,我们必须决定它应该是流还是数据报。我们很快就会看到我们的计算器示例可以支持这两种通道。

套接字有多种类型。每种类型的套接字都是为了特定的用途和情况而存在的。通常,我们有两种类型的套接字:Unix 域套接字(UDS)和网络套接字。正如您可能知道的那样,以及我们在上一章中解释的那样,UDS 可以在所有希望参与进程间通信(IPC)的进程都位于同一台机器上时使用。换句话说,UDS 只能在单主机部署中使用。

相比之下,网络套接字几乎可以在任何部署中使用,无论进程如何部署以及它们位于何处。它们可以全部位于同一台机器上,也可以分布在整个网络中。在单主机部署的情况下,UDS 更受欢迎,因为它们更快,并且与网络套接字相比,开销更小。作为我们计算器示例的一部分,我们提供了对 UDS 和网络套接字的支持。

UDS 和网络套接字可以代表流和数据报通道。因此,我们有四种类型:流通道上的 UDS、数据报通道上的 UDS、流通道上的网络套接字,以及最后是数据报通道上的网络套接字。所有这四种变化都在我们的示例中得到了涵盖。

提供流通道的网络套接字通常是 TCP 套接字。这是因为,大多数情况下,我们使用 TCP 作为此类套接字的传输协议。同样,提供数据报通道的网络套接字通常是 UDP 套接字。这是因为,大多数情况下,我们使用 UDP 作为此类套接字的传输协议。请注意,提供流或数据报通道的 UDS 套接字没有特定的名称,因为没有底层传输协议。

为了编写针对不同类型套接字和通道的实际 C 代码,最好是在您处理真实示例时进行。这就是我们采取这种不寻常方法的基本原因。这样,您将注意到不同类型套接字和通道之间的共同部分,我们可以将它们提取为可重用的代码单元。在下一节中,我们将讨论计算器项目及其内部结构。

计算器项目

我们将专门用一节来解释计算器项目的目的。这是一个篇幅较长的示例,因此在深入之前有一个坚实的基础将非常有帮助。该项目应帮助你实现以下目标:

  • 观察一个具有多个简单且定义明确的功能的完全功能化示例。

  • 从各种类型的套接字和通道中提取公共部分,并将它们作为一些可重用的库。这显著减少了我们需要编写的代码量,从学习的角度来看,它展示了不同类型的套接字和通道之间的共同边界。

  • 使用定义良好的应用程序协议来维护通信。普通的套接字编程示例缺乏这个非常重要的功能。它们通常处理客户端与其服务器之间非常简单且通常是单次通信场景。

  • 在一个示例中工作,这个示例包含了一个完全功能化的客户端-服务器程序所需的所有成分,例如应用程序协议、支持各种类型的通道、具有序列化/反序列化功能等,这为你提供了关于套接字编程的不同视角。

话虽如此,我们将把这个项目作为本章的主要示例来介绍。我们将一步一步地进行,我会引导你通过各种步骤,最终完成一个完整且可工作的项目。

第一步是提出一个相对简单且完整的应用程序协议。这个协议将在客户端和服务器之间使用。正如我们之前所解释的,如果没有一个定义良好的应用程序协议,双方就无法进行通信。他们可以连接并传输数据,因为这是套接字编程提供的功能,但他们无法相互理解。

因此,我们需要花一些时间来理解计算器项目中使用的应用程序协议。在讨论应用程序协议之前,让我们先展示项目代码库中可以看到的源代码层次结构。然后,我们可以在项目代码库中更容易地找到应用程序协议和相关的序列化/反序列化库。

源代码层次结构

从程序员的视角来看,POSIX 套接字编程 API 无论关联的套接字对象是 uds 还是网络套接字,都同等对待所有流通道。如果你还记得上一章的内容,对于流通道,我们有监听端和连接端的特定序列,并且这些序列对于不同类型的流套接字来说是相同的。

因此,如果您打算支持各种类型的套接字以及各种类型的通道,最好提取公共部分并一次性编写。这正是我们对待计算器项目的方法,这也是您在源代码中看到的方法。因此,预计在项目中会看到各种库,其中一些包含其他代码部分复用的公共代码。

现在,是时候深入代码库了。首先,项目的源代码可以在这里找到:https://github.com/PacktPublishing/Extreme-C/tree/master/ch20-socket-programming。如果您打开链接并查看代码库,您会看到有多个包含多个源文件的目录。显然,我们无法演示所有这些目录,因为这会花费太多时间,但我们将解释代码的重要部分。我们鼓励您查看代码,并尝试构建和运行它;这将给您一个关于示例是如何开发的思路。

注意,所有与 UDS、UDP 套接字和 TCP 套接字示例相关的代码都已放入一个单独的层次结构中。接下来,我们将解释源层次结构和您在代码库中找到的目录。

如果您进入示例的根目录并使用tree命令显示文件和目录,您将找到类似于Shell Box 20-1的内容。

下面的 Shell Box 演示了如何克隆本书的 GitHub 仓库以及如何导航到示例的根目录:

$ git clone https://github.com/PacktPublishing/Extreme-C
Cloning into 'Extreme-C'...
...
Resolving deltas: 100% (458/458), done.
$ cd Extreme-C/ch20-socket-programming
$ tree
.
├── CMakeLists.txt
├── calcser
...
├── calcsvc
...
├── client
│   ├── CMakeLists.txt
│   ├── clicore
...
│   ├── tcp
│   │   ├── CMakeLists.txt
│   │   └── main.c
│   ├── udp
│   │   ├── CMakeLists.txt
│   │   └── main.c
│   └── Unix
│       ├── CMakeLists.txt
│       ├── datagram
│       │   ├── CMakeLists.txt
│       │   └── main.c
│       └── stream
│           ├── CMakeLists.txt
│           └── main.c
├── server
│   ├── CMakeLists.txt
│   ├── srvcore
...
│   ├── tcp
│   │   ├── CMakeLists.txt
│   │   └── main.c
│   ├── udp
│   │   ├── CMakeLists.txt
│   │   └── main.c
│   └── Unix
│       ├── CMakeLists.txt
│       ├── datagram
│       │   ├── CMakeLists.txt
│       │   └── main.c
│       └── stream
│           ├── CMakeLists.txt
│           └── main.c
└── types.h
18 directories, 49 files
$

Shell Box 20-1:克隆计算器项目的代码库并列出文件和目录

如您在文件和目录列表中所见,计算器项目由多个部分组成,其中一些是库,每个部分都有自己的专用目录。接下来,我们将解释这些目录:

  • /calcser:这是一个序列化/反序列化库。它包含与序列化/反序列化相关的源文件。这个库决定了计算器客户端和计算器服务器之间定义的应用协议。这个库最终被构建成一个名为libcalcser.a的静态库文件。

  • /calcsvc:这个库包含计算服务器的源代码。计算服务与服务器进程不同。这个服务库包含计算器的核心功能,并且与是否位于服务器进程之后无关,它可以作为一个独立的独立 C 库单独使用。这个库最终被构建成一个名为libcalcsvc.a的静态库文件。

  • /server/srvcore: 此库包含流和数据报服务器进程之间共有的源代码,无论套接字类型如何。因此,所有计算器服务器进程,无论它们是否使用 UDS 或网络套接字,以及无论它们是在流通道还是数据报通道上操作,都可以依赖这个通用部分。此库的最终输出是一个名为 libsrvcore.a 的静态库文件。

  • /server/unix/stream: 此目录包含使用 UDS 后端流通道的服务器程序的源代码。此目录的最终构建结果是名为 unix_stream_calc_server 的可执行文件。这是本项目中可能生成的输出可执行文件之一,我们可以使用它来启动计算器服务器,该服务器监听 UDS 以接收流连接。

  • /server/unix/datagram: 此目录包含使用 UDS 后端数据报通道的服务器程序的源代码。此目录的最终构建结果是名为 unix_datagram_calc_server 的可执行文件。这是本项目中可能生成的输出可执行文件之一,我们可以使用它来启动计算器服务器,该服务器监听 UDS 以接收数据报消息。

  • /server/tcp: 此目录包含使用 TCP 网络套接字后端流通道的服务器程序的源代码。此目录的最终构建结果是名为 tcp_calc_server 的可执行文件。这是本项目中可能生成的输出可执行文件之一,我们可以使用它来启动计算器服务器,该服务器监听 TCP 套接字以接收流连接。

  • /server/udp: 此目录包含使用 UDP 网络套接字后端数据报通道的服务器程序的源代码。此目录的最终构建结果是名为 udp_calc_server 的可执行文件。这是本项目中可能生成的输出可执行文件之一,我们可以使用它来启动计算器服务器,该服务器监听 UDP 套接字以接收数据报消息。

  • /client/clicore: 此库包含流和数据报客户端进程之间共有的源代码,无论套接字类型如何。因此,所有计算器客户端进程,无论它们是否使用 UDS 或网络套接字,以及无论它们是在流通道还是数据报通道上操作,都可以依赖这个通用部分。它将被构建成一个名为 libclicore.a 的静态库文件。

  • /client/unix/stream: 此目录包含使用 UDS 后端流通道的客户端程序的源代码。此目录的最终构建结果是名为 unix_stream_calc_client 的可执行文件。这是本项目中可能生成的输出可执行文件之一,我们可以使用它来启动计算器客户端,该客户端连接到 UDS 端点并建立流连接。

  • /client/unix/datagram:此目录包含使用 UDS 后端数据报通道的客户端程序源代码。此目录的最终构建结果是名为unix_datagram_calc_client的可执行文件。这是本项目可能的输出可执行文件之一,我们可以使用它来启动计算器客户端,该客户端连接到 UDS 端点并发送一些数据报消息。

  • /client/tcp:此目录包含使用 TCP 套接字后端流通道的客户端程序源代码。此目录的最终构建结果是名为tcp_calc_client的可执行文件。这是本项目可能的输出可执行文件之一,我们可以使用它来启动计算器客户端,该客户端连接到 TCP 套接字端点并建立一个流连接。

  • /client/udp:此目录包含使用 UDP 套接字后端数据报通道的客户端程序源代码。此目录的最终构建结果是名为udp_calc_client的可执行文件。这是本项目可能的输出可执行文件之一,我们可以使用它来启动计算器客户端,该客户端连接到 UDP 套接字端点并发送一些数据报消息。

构建项目

现在我们已经查看了项目中的所有目录,我们需要展示如何构建它。该项目使用 CMake,在构建项目之前,您应该已经安装了它。

为了构建项目,在章节根目录中运行以下命令:

$ mkdir -p build
$ cd build
$ cmake ..
...
$ make
...
$

Shell Box 20-2:构建计算器项目的命令

运行项目

没有什么比亲自运行项目来看到它是如何工作的更好的了。因此,在深入研究技术细节之前,我想让您启动一个计算器服务器,然后是一个计算器客户端,最后看看它们是如何互相通信的。

在运行进程之前,您需要有两个独立的终端(或 shell),以便输入两个不同的命令。在第一个终端中,为了运行监听 UDS 的流服务器,请输入以下命令。

注意,在输入以下命令之前,您需要处于build目录中。build目录是上一节构建项目中创建的:

$ ./server/unix/stream/unix_stream_calc_server

Shell Box 20-3:运行监听 UDS 的流服务器

确保服务器正在运行。在第二个终端中,运行为使用 UDS 构建的流客户端:

$ ./client/unix/stream/unix_stream_calc_client
? (type quit to exit) 3++4
The req(0) is sent.
req(0) > status: OK, result: 7.000000
? (type quit to exit) mem
The req(1) is sent.
req(1) > status: OK, result: 7.000000
? (type quit to exit) 5++4
The req(2) is sent.
req(2) > status: OK, result: 16.000000
? (type quit to exit) quit
Bye.
$

Shell Box 20-4:运行计算器客户端并发送一些请求

正如您在先前的 Shell Box 中看到的那样,客户端进程有自己的命令行。它从用户那里接收一些命令,根据应用程序协议将它们转换为一些请求,并将它们发送到服务器进行进一步处理。然后,它等待响应,并在收到响应后立即打印结果。请注意,此命令行是所有客户端共同编写的通用代码的一部分,因此,无论客户端使用的是哪种通道类型或套接字类型,您总是看到客户端命令行。

现在,是时候深入了解应用协议,看看请求和响应消息看起来像什么。

应用协议

任何想要通信的两个进程都必须遵守一个应用协议。这个协议可以是定制的,比如计算器项目,也可以是众所周知的一些协议,如 HTTP。我们称我们的协议为 计算器协议

计算器协议是一个可变长度的协议。换句话说,每个消息都有自己的长度,每个消息都应该使用分隔符与下一个消息分开。只有一个请求消息类型和一个响应消息类型。该协议也是文本的。这意味着我们只使用字母数字字符以及一些其他字符作为请求和响应消息中的有效字符。换句话说,计算器消息是可读的。

请求消息有四个字段:请求 ID方法第一个操作数第二个操作数。每个请求都有一个唯一的 ID,服务器使用这个 ID 将响应与其对应的请求相关联。

方法是计算器服务可以执行的操作。接下来,你可以看到 calcser/calc_proto_req.h 头文件。这个文件描述了计算器协议的请求消息:

#ifndef CALC_PROTO_REQ_H
#define CALC_PROTO_REQ_H
#include <stdint.h>
typedef enum {
  NONE,
  GETMEM, RESMEM,
  ADD, ADDM,
  SUB, SUBM,
  MUL, MULM,
  DIV
} method_t;
struct calc_proto_req_t {
  int32_t id;
  method_t method;
  double operand1;
  double operand2;
};
method_t str_to_method(const char*);
const char* method_to_str(method_t);
#endif

代码框 20-1 [calcser/calc_proto_req.h]:计算器请求对象的定义

正如你所见,我们定义了九种方法作为我们协议的一部分。作为一个好的计算器,我们的计算器有一个内部内存,因此我们有关加法、减法和乘法的内存操作。

例如,ADD 方法只是简单地相加两个浮点数,但 ADDM 方法是 ADD 方法的变体,它将这两个数与内部存储的值相加,并最终更新内存中的值以供进一步使用。这就像你使用台式计算器的内存按钮一样。你可以找到一个标记为 +M 的按钮。

我们还有一个用于读取和重置计算器内部内存的特殊方法。除法方法不能在内部内存上执行,所以我们没有其他变体。

假设客户端想要使用 ADD 方法创建一个 ID 为 1000 的请求,并且操作数为 1.55.6。在 C 语言中,需要从 calc_proto_req_t 类型(在前面头文件中作为 代码框 20-1 部分声明)创建一个对象,并填充所需的值。接下来,你可以看到如何操作:

struct calc_proto_req_t req;
req.id = 1000;
req.method = ADD;
req.operand1 = 1.5;
req.operand2 = 5.6;

代码框 20-2:在 C 语言中创建计算器请求对象

正如我们在上一章中解释的,前面代码框中的 req 对象在发送到服务器之前需要序列化为请求消息。换句话说,我们需要将前面的 请求对象 序列化为等效的 请求消息。根据我们的应用协议,计算器项目中的序列化器将 req 对象序列化如下:

1000#ADD#1.5#5.6$

代码框 20-3:与代码框 20-2 中定义的 req 对象等价的序列化消息

如您所见,#字符用作字段分隔符,而$字符用作消息分隔符。此外,每个请求消息恰好有四个字段。通道另一端的反序列化器对象使用这些事实来解析传入的字节并重新恢复请求对象。

相反,服务器进程在回复请求时需要序列化响应对象。计算器响应对象有三个字段:请求 ID状态结果。请求 ID 确定相应的请求。每个请求都有一个唯一的 ID,这样服务器就可以指定它想要响应的请求。

calcser/calc_proto_resp.h头文件描述了计算器响应应该是什么样子,您可以在下面的代码框中看到:

#ifndef CALC_PROTO_RESP_H
#define CALC_PROTO_RESP_H
#include <stdint.h>
#define STATUS_OK              0
#define STATUS_INVALID_REQUEST 1
#define STATUS_INVALID_METHOD  2
#define STATUS_INVALID_OPERAND 3
#define STATUS_DIV_BY_ZERO     4
#define STATUS_INTERNAL_ERROR  20
typedef int status_t;
struct calc_proto_resp_t {
  int32_t req_id;
  status_t status;
  double result;
};
#endif

代码框 20-4 [calcser/calc_proto_resp.h]:计算器响应对象的定义

同样,为了为前面提到的代码框 20-2中的req请求对象创建一个响应对象,服务器进程应该这样做:

struct calc_proto_resp_t resp;
resp.req_id = 1000;
resp.status = STATUS_OK;
resp.result = 7.1;

代码框 20-5:为代码框 20-2 中定义的请求对象 req 创建响应对象

前面的响应对象按以下方式序列化:

1000#0#7.1$

代码框 20-6:与代码框 20-5 中创建的 resp 对象等价的序列化响应消息

再次,我们使用#作为字段分隔符,$作为消息分隔符。请注意,状态是数值型的,它表示请求的成功或失败。在失败的情况下,它是一个非零数字,其含义在响应头文件中描述,或者更确切地说,在计算器协议中描述。

现在,是时候更详细地谈谈序列化/反序列化库及其内部结构了。

序列化/反序列化库

在上一节中,我们描述了请求和响应消息的格式。在本节中,我们将更详细地讨论计算器项目中使用的序列化和反序列化算法。我们将使用serializer类,其属性结构为calc_proto_ser_t,以提供序列化和反序列化功能。

如前所述,这些功能作为名为libcalcser.a的静态库提供给项目的其他部分。在这里,您可以看到calcser/calc_proto_ser.h中找到的serializer类的公共 API:

#ifndef CALC_PROTO_SER_H
#define CALC_PROTO_SER_H
#include <types.h>
#include "calc_proto_req.h"
#include "calc_proto_resp.h"
#define ERROR_INVALID_REQUEST          101
#define ERROR_INVALID_REQUEST_ID       102
#define ERROR_INVALID_REQUEST_METHOD   103
#define ERROR_INVALID_REQUEST_OPERAND1 104
#define ERROR_INVALID_REQUEST_OPERAND2 105
#define ERROR_INVALID_RESPONSE         201
#define ERROR_INVALID_RESPONSE_REQ_ID  202
#define ERROR_INVALID_RESPONSE_STATUS  203
#define ERROR_INVALID_RESPONSE_RESULT  204
#define ERROR_UNKNOWN  220
struct buffer_t {
  char* data;
  int len;
};
struct calc_proto_ser_t;
typedef void (*req_cb_t)(
        void* owner_obj,
        struct calc_proto_req_t);
typedef void (*resp_cb_t)(
        void* owner_obj,
        struct calc_proto_resp_t);
typedef void (*error_cb_t)(
        void* owner_obj,
        const int req_id,
        const int error_code);
struct calc_proto_ser_t* calc_proto_ser_new();
void calc_proto_ser_delete(
        struct calc_proto_ser_t* ser);
void calc_proto_ser_ctor(
        struct calc_proto_ser_t* ser,
        void* owner_obj,
        int ring_buffer_size);
void calc_proto_ser_dtor(
        struct calc_proto_ser_t* ser);
void* calc_proto_ser_get_context(
        struct calc_proto_ser_t* ser);
void calc_proto_ser_set_req_callback(
        struct calc_proto_ser_t* ser,
        req_cb_t cb);
void calc_proto_ser_set_resp_callback(
        struct calc_proto_ser_t* ser,
        resp_cb_t cb);
void calc_proto_ser_set_error_callback(
        struct calc_proto_ser_t* ser,
        error_cb_t cb);
void calc_proto_ser_server_deserialize(
        struct calc_proto_ser_t* ser,
        struct buffer_t buffer,
        bool_t* req_found);
struct buffer_t calc_proto_ser_server_serialize(
        struct calc_proto_ser_t* ser,
        const struct calc_proto_resp_t* resp);
void calc_proto_ser_client_deserialize(
        struct calc_proto_ser_t* ser,
        struct buffer_t buffer,
        bool_t* resp_found);
struct buffer_t calc_proto_ser_client_serialize(
        struct calc_proto_ser_t* ser,
        const struct calc_proto_req_t* req);
#endif

代码框 20-7 [calcser/calc_proto_ser.h]:序列化器类的公共接口

除了创建和销毁序列化器对象所需的构造函数和析构函数之外,我们还有一对应由服务器进程使用的函数,以及另一对应由客户端进程使用的函数。

在客户端,我们序列化请求对象,并反序列化响应消息。同时,在服务器端,我们反序列化请求消息,并序列化响应对象。

除了序列化和反序列化函数之外,我们还有三个 回调函数

  • 接收从底层通道反序列化的请求对象的回调

  • 接收从底层通道反序列化的响应对象的回调

  • 接收序列化或反序列化失败时错误的回调

这些回调由客户端和服务器进程用于接收传入的请求和响应,以及序列化和反序列化消息过程中发现的错误。

现在,让我们更深入地看看服务器端的序列化/反序列化函数。

服务器端序列化/反序列化函数

我们有两个函数用于服务器进程序列化响应对象和反序列化请求消息。我们首先从响应序列化函数开始。

以下代码框包含响应序列化函数 calc_proto_ser_server_serialize 的代码:

struct buffer_t calc_proto_ser_server_serialize(
    struct calc_proto_ser_t* ser,
    const struct calc_proto_resp_t* resp) {
  struct buffer_t buff;
  char resp_result_str[64];
  _serialize_double(resp_result_str, resp->result);
  buff.data = (char*)malloc(64 * sizeof(char));
  sprintf(buff.data, "%d%c%d%c%s%c", resp->req_id,
          FIELD_DELIMITER, (int)resp->status, FIELD_DELIMITER,
      resp_result_str, MESSAGE_DELIMITER);
  buff.len = strlen(buff.data);
  return buff;
}

代码框 20-8 [calcser/calc_proto_ser.c]:服务器端响应序列化函数

如您所见,resp 是一个指向需要序列化的响应对象的指针。此函数返回一个 buffer_t 对象,该对象在 calc_proto_ser.h 头文件中声明如下:

struct buffer_t {
  char* data;
  int len;
};

代码框 20-9 [calcser/calc_proto_ser.h]:buffer_t 的定义

序列化代码很简单,主要由一个创建响应字符串消息的 sprintf 语句组成。现在,让我们看看请求反序列化函数。反序列化通常更难实现,如果您查看代码库并跟踪函数调用,您会看到它可以多么复杂。

代码框 20-9 包含请求反序列化函数:

void calc_proto_ser_server_deserialize(
    struct calc_proto_ser_t* ser,
    struct buffer_t buff,
    bool_t* req_found) {
  if (req_found) {
    *req_found = FALSE;
  }
  _deserialize(ser, buff, _parse_req_and_notify,
          ERROR_INVALID_REQUEST, req_found);
}

代码框 20-9 [calcser/calc_proto_ser.c]:服务器端请求反序列化函数

前一个函数看起来很简单,但实际上它使用了 _deserialize_parse_req_and_notify 私有函数。这些函数在 calc_proto_ser.c 文件中定义,该文件包含 Serializer 类的实际实现。

将我们为提到的私有函数编写的代码引入并讨论可能会非常复杂,超出了本书的范围,但为了给您一个概念,尤其是当您想阅读源代码时,反序列化器使用一个固定长度的 环形缓冲区 并尝试找到 $ 作为消息分隔符。

每当它找到$时,它就会调用函数指针,在这个例子中,它指向_parse_req_and_notify函数(在_deserialize函数中传入的第三个参数)。_parse_req_and_notify函数试图提取字段并恢复请求对象。然后,它通知已注册的观察者,在这种情况下是等待通过回调函数接收请求的服务器对象,以继续处理请求对象。

现在,让我们看看客户端使用的函数。

客户端序列化/反序列化函数

就像服务器端一样,客户端也有两个函数。一个用于序列化请求对象,另一个用于反序列化传入的响应。

我们从请求序列化器开始。你可以在代码框 20-10中看到其定义:

struct buffer_t calc_proto_ser_client_serialize(
    struct calc_proto_ser_t* ser,
    const struct calc_proto_req_t* req) {
  struct buffer_t buff;
  char req_op1_str[64];
  char req_op2_str[64];
  _serialize_double(req_op1_str, req->operand1);
  _serialize_double(req_op2_str, req->operand2);
  buff.data = (char*)malloc(64 * sizeof(char));
  sprintf(buff.data, "%d%c%s%c%s%c%s%c", req->id, FIELD_DELIMITER,
          method_to_str(req->method), FIELD_DELIMITER,
          req_op1_str, FIELD_DELIMITER, req_op2_str,
          MESSAGE_DELIMITER);
  buff.len = strlen(buff.data);
  return buff;
}

代码框 20-10 [calcser/calc_proto_ser.c]:客户端请求序列化函数

正如你所见,它接受一个请求对象并返回一个buffer对象,与服务器端响应序列化器完全相同。它甚至使用了相同的技巧;使用sprintf语句创建请求消息。

代码框 20-11 包含响应反序列化函数:

void calc_proto_ser_client_deserialize(
    struct calc_proto_ser_t* ser,
    struct buffer_t buff, bool_t* resp_found) {
  if (resp_found) {
    *resp_found = FALSE;
  }
  _deserialize(ser, buff, _parse_resp_and_notify,
          ERROR_INVALID_RESPONSE, resp_found);
}

代码框 20-11 [calcser/calc_proto_ser.c]:客户端响应反序列化函数

正如你所见,使用了相同的机制,并且使用了一些类似的私有函数。强烈建议仔细阅读这些源代码,以便更好地理解代码的各个部分是如何组合在一起以实现最大程度的代码复用。

我们不会深入探讨Serializer类;深入代码并找出它是如何工作的,这取决于你。

现在我们有了序列化库,我们可以继续编写客户端和服务器程序。拥有一个基于协议序列化对象和反序列化消息的库是编写多进程软件的重要一步。请注意,部署是单主机还是包含多个主机无关紧要;进程应该能够相互理解,并且应该已经定义了适当的应用程序协议。

在跳转到关于套接字编程的代码之前,我们还需要解释一件事:计算器服务。它是服务器进程的核心,并执行实际的计算。

计算器服务

计算器服务是我们示例的核心逻辑。请注意,这个逻辑应该独立于底层 IPC 机制工作。下面的代码显示了计算器服务类的声明。

正如你所见,它被设计成即使在非常简单的程序中也可以使用,只需要一个main函数,以至于它甚至不做任何 IPC 操作:

#ifndef CALC_SERVICE_H
#define CALC_SERVICE_H
#include <types.h>
static const int CALC_SVC_OK = 0;
static const int CALC_SVC_ERROR_DIV_BY_ZERO = -1;
struct calc_service_t;
struct calc_service_t* calc_service_new();
void calc_service_delete(struct calc_service_t*);
void calc_service_ctor(struct calc_service_t*);
void calc_service_dtor(struct calc_service_t*);
void calc_service_reset_mem(struct calc_service_t*);
double calc_service_get_mem(struct calc_service_t*);
double calc_service_add(struct calc_service_t*, double, double b,
    bool_t mem);
double calc_service_sub(struct calc_service_t*, double, double b,
    bool_t mem);
double calc_service_mul(struct calc_service_t*, double, double b,
    bool_t mem);
int calc_service_div(struct calc_service_t*, double,
        double, double*);
#endif

代码框 20-12 [calcsvc/calc_service.h]:计算器服务类的公共接口

如您所见,前面的类甚至有自己的错误类型。输入参数是纯 C 类型,并且它完全不依赖于 IPC 相关或序列化相关的类或类型。由于它是作为一个独立的逻辑单元隔离的,我们将其编译成一个名为libcalcsvc.a的独立静态库。

每个服务器进程都必须使用计算器服务对象来进行实际的计算。这些对象通常被称为服务对象。因此,最终的服务器程序必须与这个库链接。

在我们继续之前的一个重要注意事项:如果对于特定的客户端,计算不需要特定的上下文,那么只需要一个服务对象就足够了。换句话说,如果一个客户端的服务不需要我们记住该客户端之前请求的任何状态,那么我们可以使用一个单例服务对象。我们称之为无状态服务对象

相反,如果处理当前请求需要了解之前请求中的某些信息,那么对于每个客户端,我们需要有一个特定的服务对象。这种情况适用于我们的计算器项目。正如您所知,计算器有一个针对每个客户端独特的内部存储。因此,我们不能为两个客户端使用同一个对象。这些对象被称为有状态的服务对象

总结我们上面所说的,对于每个客户端,我们必须创建一个新的服务对象。这样,每个客户端都有自己的计算器,并拥有自己专用的内部存储。计算器服务对象是有状态的,并且需要加载一些状态(内部存储的值)。

现在,我们处于一个很好的位置来继续前进,讨论各种类型的套接字,并在计算器项目的上下文中给出示例。

Unix 域套接字

从上一章,我们知道如果我们打算在同一台机器上的两个进程之间建立连接,UDS 是最佳选择之一。在这一章中,我们扩展了我们的讨论,并更多地讨论了基于推的 IPC 技术,以及流和数据报通道。现在,是时候将之前和当前章节的知识结合起来,看看 UDS 的实际应用了。

在本节中,我们有四个小节专门讨论在监听器侧或连接器侧的进程,并在流或数据报通道上操作。所有这些进程都在使用 UDS。我们根据上一章讨论的序列,逐步说明它们建立通道的步骤。作为第一个进程,我们从在流通道上操作的监听器进程开始。这将是一个流服务器

UDS 流服务器

如果您还记得上一章的内容,我们为传输通信中的监听器和连接器端有多个序列。服务器位于监听器的位置。因此,它应该遵循监听器序列。更具体地说,由于我们本节讨论的是流通道,它应该遵循流监听器序列。

作为该序列的一部分,服务器需要首先创建一个 socket 对象。在我们的计算器项目中,愿意通过 UDS 接收连接的流服务器进程必须遵循相同的序列。

以下代码片段位于计算器服务器程序的主函数中,如代码框 20-13所示,该过程首先创建了一个socket对象:

int server_sd = socket(AF_UNIX, SOCK_STREAM, 0);
if (server_sd == -1) {
  fprintf(stderr, "Could not create socket: %s\n", strerror(errno));
  exit(1);
}

代码框 20-13 [server/unix/stream/main.c]: 创建流 UDS 对象

如您所见,socket函数用于创建一个 socket 对象。此函数包含在<sys/socket.h>中,这是一个 POSIX 头文件。请注意,这只是一个 socket 对象,而且尚未确定它将是一个客户端 socket 还是一个服务器 socket。只有后续的函数调用才能确定这一点。

正如我们在上一章中解释的,每个 socket 对象都有三个属性。这些属性由传递给socket函数的三个参数确定。这些参数分别指定了在该 socket 对象上使用的地址族、类型和协议。

根据流监听器序列,特别是关于创建 socket 对象之后的 UDS,服务器程序必须将其绑定到一个socket 文件。因此,下一步是将 socket 绑定到 socket 文件。计算器项目中使用了代码框 20-14来将 socket 对象绑定到由sock_file字符数组指定的预定义路径上的文件:

struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, sock_file, sizeof(addr.sun_path) - 1);
int result = bind(server_sd, (struct sockaddr*)&addr, sizeof(addr));
if (result == -1) {
  close(server_sd);
  fprintf(stderr, "Could not bind the address: %s\n", strerror(errno));
  exit(1);
}

代码框 20-14 [server/unix/stream/main.c]: 将流 UDS 对象绑定到由 sock_file 字符数组指定的 socket 文件

前面的代码有两个步骤。第一步是创建一个名为addrstruct sockaddr_un类型的实例,然后通过将其指向 socket 文件来初始化它。第二步是将addr对象传递给bind函数,以便让它知道应该将哪个 socket 文件绑定到 socket 对象。只有当没有其他 socket 对象绑定到相同的 socket 文件时,bind函数调用才会成功。因此,在 UDS 中,两个 socket 对象,可能位于不同的进程中,不能绑定到同一个 socket 文件。

注意

在 Linux 中,UDS 可以绑定到抽象 socket 地址。当没有文件系统挂载用于创建 socket 文件时,它们非常有用。一个以空字符\0开头的字符串可以用来初始化前面的代码框中的地址结构addr,然后提供的名称绑定到内核中的 socket 对象。提供的名称在系统中应该是唯一的,并且不应该有其他 socket 对象绑定到它。

关于套接字文件路径的进一步说明,在大多数 Unix 系统中,路径长度不能超过 104 字节。然而,在 Linux 系统中,这个长度是 108 字节。请注意,用于保存套接字文件路径的字符串变量始终包含一个额外的空字符,作为 C 中的char数组。因此,实际上,根据操作系统,可以使用 103 和 107 字节作为套接字文件路径的一部分。

如果bind函数返回0,则表示绑定成功,您可以继续配置 backlog 的大小;这是绑定端点后流监听序列的下一步。

以下代码展示了如何为监听 UDS 的流计算服务器配置 backlog:

result = listen(server_sd, 10);
if (result == -1) {
  close(server_sd);
  fprintf(stderr, "Could not set the backlog: %s\n", strerror(errno));
  exit(1);
}

代码框 20-15 [server/unix/stream/main.c]:配置已绑定流套接字的 backlog 大小

listen函数配置已绑定套接字的 backlog 大小。正如我们在上一章中解释的,当繁忙的服务器进程无法接受更多传入客户端时,一定数量的这些客户端可以在 backlog 中等待,直到服务器程序可以处理它们。这是在接受客户端之前准备流套接字的一个基本步骤。

根据我们在流监听序列中的内容,在流套接字绑定并配置其 backlog 大小后,我们可以开始接受新客户端。代码框 20-16展示了如何接受新客户端:

while (1) {
  int client_sd = accept(server_sd, NULL, NULL);
  if (client_sd == -1) {
    close(server_sd);
    fprintf(stderr, "Could not accept the client: %s\n",
        strerror(errno));
    exit(1);
  }
  ...
}

代码框 20-16 [server/unix/stream/main.c]:在流监听套接字上接受新客户端

魔法在于accept函数,每当接收到新的客户端时,它都会返回一个新的套接字对象。返回的套接字对象指向服务器和已接受客户端之间的底层流通道。请注意,每个客户端都有自己的流通道,因此也有自己的套接字描述符。

注意,如果流监听套接字是阻塞的(默认情况下是阻塞的),accept函数将阻塞执行,直到接收到新的客户端。换句话说,如果没有传入客户端,调用accept函数的线程将阻塞在其后面。

现在,让我们在一个地方看到上述步骤。以下代码框展示了计算器项目中的流服务器,它监听 UDS:

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <stream_server_core.h>
int main(int argc, char** argv) {
  char sock_file[] = "/tmp/calc_svc.sock";
  // ----------- 1\. Create socket object ------------------
  int server_sd = socket(AF_UNIX, SOCK_STREAM, 0);
  if (server_sd == -1) {
    fprintf(stderr, "Could not create socket: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 2\. Bind the socket file ------------------
  // Delete the previously created socket file if it exists.
  unlink(sock_file);
  // Prepare the address
  struct sockaddr_un addr;
  memset(&addr, 0, sizeof(addr));
  addr.sun_family = AF_UNIX;
  strncpy(addr.sun_path, sock_file, sizeof(addr.sun_path) - 1);
  int result = bind(server_sd,
 (struct sockaddr*)&addr, sizeof(addr));
  if (result == -1) {
    close(server_sd);
    fprintf(stderr, "Could not bind the address: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 3\. Prepare backlog ------------------
  result = listen(server_sd, 10);
  if (result == -1) {
    close(server_sd);
    fprintf(stderr, "Could not set the backlog: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 4\. Start accepting clients ---------
  accept_forever(server_sd);
  return 0;
}

代码框 20-17 [server/unix/stream/main.c]:监听 UDS 端点的流计算服务的主函数

应该很容易找到执行初始化服务器套接字上述步骤的代码块。唯一缺少的是客户端接受代码。接受新客户端的实际代码放在一个单独的函数中,该函数名为accept_forever。请注意,此函数是阻塞的,它会阻塞主线程直到服务器停止。

在下面的代码框中,你可以看到 accept_forever 函数的定义。该函数是位于 srvcore 目录的服务器通用库的一部分。这个函数应该在那里,因为它的定义对于其他流套接字(如 TCP 套接字)也是相同的。因此,我们可以重用现有的逻辑,而不是再次编写它:

void accept_forever(int server_sd) {
  while (1) {
    int client_sd = accept(server_sd, NULL, NULL);
    if (client_sd == -1) {
      close(server_sd);
      fprintf(stderr, "Could not accept the client: %s\n",
              strerror(errno));
      exit(1);
    }
    pthread_t client_handler_thread;
    int* arg = (int *)malloc(sizeof(int));
    *arg = client_sd;
    int result = pthread_create(&client_handler_thread, NULL,
            &client_handler, arg);
    if (result) {
      close(client_sd);
      close(server_sd);
      free(arg);
      fprintf(stderr, "Could not start the client handler thread.\n");
      exit(1);
    }
  }
}

代码框 20-18 [server/srvcore/stream_server_core.c]:在监听 UDS 端点的流套接字上接受新客户端的函数

正如你在前面的代码框中所见,在接收新客户端后,我们启动了一个新的线程来处理客户端。这实际上包括从客户端的通道读取字节,将读取的字节传递给反序列化器,并在检测到请求时产生适当的响应。

为每个客户端创建一个新的线程通常是每个在阻塞流通道上操作的服务器进程的通用模式,无论套接字类型如何。因此,在这种情况下,多线程及其相关主题变得极其重要。

注意

关于非阻塞流通道,通常使用一种称为 事件循环 的不同方法。

当你拥有客户端的套接字对象时,你可以用它来从客户端读取,也可以向客户端写入。如果我们遵循在 srvcore 库中迄今为止所采取的路径,下一步是查看客户端线程的伴随函数;client_handler。该函数可以在代码库中 accept_forever 旁边找到。接下来,你可以看到包含函数定义的代码框:

void* client_handler(void *arg) {
  struct client_context_t context;
  context.addr = (struct client_addr_t*)
      malloc(sizeof(struct client_addr_t));
  context.addr->sd = *((int*)arg);
  free((int*)arg);
 context.ser = calc_proto_ser_new();
  calc_proto_ser_ctor(context.ser, &context, 256);
  calc_proto_ser_set_req_callback(context.ser, request_callback);
  calc_proto_ser_set_error_callback(context.ser, error_callback);
  context.svc = calc_service_new();
  calc_service_ctor(context.svc);
  context.write_resp = &stream_write_resp;
  int ret;
  char buffer[128];
  while (1) {
    int ret = read(context.addr->sd, buffer, 128);
    if (ret == 0 || ret == -1) {
      break;
    }
    struct buffer_t buf;
    buf.data = buffer; buf.len = ret;
    calc_proto_ser_server_deserialize(context.ser, buf, NULL);
  }
  calc_service_dtor(context.svc);
  calc_service_delete(context.svc);
  calc_proto_ser_dtor(context.ser);
  calc_proto_ser_delete(context.ser);
  free(context.addr);
  return NULL;
}

代码框 20-19 [server/srvcore/stream_server_core.c]:处理客户端线程的伴随函数

关于前面的代码有很多细节,但有一些重要的细节我想提一下。正如你所见,我们正在使用 read 函数从客户端读取数据块。如果你记得,read 函数接受一个文件描述符,但在这里我们传递的是一个套接字描述符。这表明,尽管在 I/O 函数方面文件描述符和套接字描述符之间存在差异,我们仍然可以使用相同的 API。

在前面的代码中,我们从输入读取字节数据,并通过调用 calc_proto_ser_server_deserialize 函数将它们传递给反序列化器。在完全反序列化一个请求之前,可能需要调用这个函数三到四次。这高度依赖于从输入读取的字块大小以及通过通道传输的消息长度。

进一步来说,每个客户端都有自己的序列化对象。这也适用于计算器服务对象。这些对象作为同一线程的一部分被创建和销毁。

关于前面的代码框的最后一点,我们正在使用一个函数将响应写回客户端。该函数是stream_write_response,它旨在在流套接字上使用。这个函数可以在前面的代码框所在的同一文件中找到。接下来,你可以看到这个函数的定义:

void stream_write_resp(
        struct client_context_t* context,
        struct calc_proto_resp_t* resp) {
  struct buffer_t buf =
      calc_proto_ser_server_serialize(context->ser, resp);
  if (buf.len == 0) {
    close(context->addr->sd);
    fprintf(stderr, "Internal error while serializing response\n");
    exit(1);
  }
  int ret = write(context->addr->sd, buf.data, buf.len);
  free(buf.data);
  if (ret == -1) {
    fprintf(stderr, "Could not write to client: %s\n",
            strerror(errno));
    close(context->addr->sd);
    exit(1);
  } else if (ret < buf.len) {
    fprintf(stderr, "WARN: Less bytes were written!\n");
    exit(1);
  }
}

代码框 20-20 [server/srvcore/stream_server_core.c]: 用于将响应写回客户端的函数

如前所述的代码所示,我们正在使用write函数将消息写回客户端。正如我们所知,write函数可以接受文件描述符,但似乎套接字描述符也可以使用。所以,这清楚地表明 POSIX I/O API 对文件描述符和套接字描述符都有效。

上述语句也适用于close函数。正如你所见,我们已用它来终止一个连接。当我们知道它对文件描述符也有效时,传递套接字描述符就足够了。

现在我们已经了解了 UDS 流服务器的一些最重要的部分,并对它的操作有了大致的了解,是时候继续讨论 UDS 流客户端了。当然,代码中还有很多我们没有讨论的地方,但你应该花时间仔细研究它们。

UDS 流客户端

与上一节中描述的服务器程序一样,客户端也需要首先创建一个套接字对象。记住,我们现在需要遵循流连接器序列。它使用与服务器完全相同的代码,使用完全相同的参数来指示它需要一个 UDS。之后,它需要通过指定 UDS 端点来连接到服务器进程,就像服务器那样。当流通道建立后,客户端进程可以使用打开的套接字描述符来读取和写入通道。

接下来,你可以看到连接到 UDS 端点的流客户端的main函数:

int main(int argc, char** argv) {
  char sock_file[] = "/tmp/calc_svc.sock";
  // ----------- 1\. Create socket object ------------------
  int conn_sd = socket(AF_UNIX, SOCK_STREAM, 0);
  if (conn_sd == -1) {
    fprintf(stderr, "Could not create socket: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 2\. Connect to server ---------------------
  // Prepare the address
  struct sockaddr_un addr;
  memset(&addr, 0, sizeof(addr));
  addr.sun_family = AF_UNIX;
  strncpy(addr.sun_path, sock_file, sizeof(addr.sun_path) - 1);
  int result = connect(conn_sd,
 (struct sockaddr*)&addr, sizeof(addr));
  if (result == -1) {
    close(conn_sd);
    fprintf(stderr, "Could no connect: %s\n", strerror(errno));
    exit(1);
  }
 stream_client_loop(conn_sd);
  return 0;
}

代码框 20-21 [client/unix/stream/main.c]: 连接到 UDS 端点的流客户端的主函数

如你所见,代码的第一部分与服务器代码非常相似,但之后,客户端调用connect而不是bind。请注意,地址准备代码与服务器完全相同。

connect成功返回时,它已经将conn_sd套接字描述符关联到打开的通道。因此,从现在开始,conn_sd可以用来与服务器通信。我们将其传递给stream_client_loop函数,该函数启动客户端的命令行并执行客户端执行的其他操作。它是一个阻塞函数,运行客户端直到它退出。

注意,客户端也使用readwrite函数在服务器之间来回传输消息。代码框 20-22包含了stream_client_loop函数的定义,这是客户端通用库的一部分,所有流客户端都会使用它,无论套接字类型如何,并且 UDS 和 TCP 套接字之间是共享的。正如你所见,它使用write函数向服务器发送一个序列化的请求消息:

void stream_client_loop(int conn_sd) {
  struct context_t context;
  context.sd = conn_sd;
  context.ser = calc_proto_ser_new();
  calc_proto_ser_ctor(context.ser, &context, 128);
  calc_proto_ser_set_resp_callback(context.ser, on_response);
  calc_proto_ser_set_error_callback(context.ser, on_error);
  pthread_t reader_thread;
 pthread_create(&reader_thread, NULL,
stream_response_reader, &context);
  char buf[128];
  printf("? (type quit to exit) ");
  while (1) {
    scanf("%s", buf);
    int brk = 0, cnt = 0;
    struct calc_proto_req_t req;
    parse_client_input(buf, &req, &brk, &cnt);
    if (brk) {
      break;
    }
    if (cnt) {
      continue;
    }
    struct buffer_t ser_req =
        calc_proto_ser_client_serialize(context.ser, &req);
    int ret = write(context.sd, ser_req.data, ser_req.len);
    if (ret == -1) {
      fprintf(stderr, "Error while writing! %s\n",
              strerror(errno));
      break;
    }
    if (ret < ser_req.len) {
      fprintf(stderr, "Wrote less than anticipated!\n");
      break;
    }
    printf("The req(%d) is sent.\n", req.id);
  }
  shutdown(conn_sd, SHUT_RD);
  calc_proto_ser_dtor(context.ser);
  calc_proto_ser_delete(context.ser);
  pthread_join(reader_thread, NULL);
  printf("Bye.\n");
}

代码框 20-22 [client/clicore/stream_client_core.c]:执行流客户端的函数

正如你在前面的代码中所见,每个客户端进程只有一个序列化对象,这是有道理的。这与服务器进程相反,其中每个客户端都有一个单独的序列化对象。

更重要的是,客户端进程为从服务器端读取响应启动了一个单独的线程。这是因为从服务器进程读取是一个阻塞任务,应该在单独的执行流中完成。

作为主线程的一部分,我们有客户端的命令行,它通过终端接收用户的输入。正如你所见,主线程在退出时加入读取线程,并等待其完成。

关于前面代码的进一步说明,客户端进程使用相同的 I/O API 从流通道读取和写入。正如我们之前所说的,使用了readwrite函数,write函数的使用可以在代码框 20-22中看到。

在接下来的部分,我们将讨论数据报通道,但仍然使用 UDS 来完成这个目的。我们首先从数据报服务器开始。

UDS 数据报服务器

如果你记得上一章的内容,数据报进程在传输传输方面有自己的监听器和连接器序列。现在,是时候展示如何基于 UDS 开发数据报服务器了。

根据数据报监听器序列,进程首先需要创建一个套接字对象。以下代码框展示了这一点:

int server_sd = socket(AF_UNIX, SOCK_DGRAM, 0);
if (server_sd == -1) {
  fprintf(stderr, "Could not create socket: %s\n",
          strerror(errno));
  exit(1);
}

代码框 20-23 [server/unix/datagram/main.c]:创建一个用于数据报通道的 UDS 对象

你可以看到我们使用了SOCK_DGRAM而不是SOCK_STREAM。这意味着套接字对象将操作在数据报通道上。其他两个参数保持不变。

作为数据报监听器序列的第二步,我们需要将套接字绑定到 UDS 端点。正如我们之前所说的,这是一个套接字文件。这一步与流服务器完全相同,因此我们在这里不展示它,你可以在代码框 20-14中看到它。

对于数据报监听器进程,这些步骤是唯一需要执行的,并且与数据报套接字相关的配置没有队列。更重要的是,没有客户端接受阶段,因为我们不能在某些专用的 1-to-1 通道上有流连接。

接下来,你可以看到数据报服务器在 UDS 端点监听的main函数,这是计算器项目的一部分:

int main(int argc, char** argv) {
  char sock_file[] = "/tmp/calc_svc.sock";
  // ----------- 1\. Create socket object ------------------
  int server_sd = socket(AF_UNIX, SOCK_DGRAM, 0);
  if (server_sd == -1) {
    fprintf(stderr, "Could not create socket: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 2\. Bind the socket file ------------------
  // Delete the previously created socket file if it exists.
  unlink(sock_file);
  // Prepare the address
  struct sockaddr_un addr;
  memset(&addr, 0, sizeof(addr));
  addr.sun_family = AF_UNIX;
  strncpy(addr.sun_path, sock_file, sizeof(addr.sun_path) - 1);
  int result = bind(server_sd,
          (struct sockaddr*)&addr, sizeof(addr));
  if (result == -1) {
    close(server_sd);
    fprintf(stderr, "Could not bind the address: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 3\. Start serving requests ---------
  serve_forever(server_sd);
  return 0;
}

代码框 20-24 [server/unix/datagram/main.c]:监听 UDS 端点的数据报服务器的主函数

如你所知,数据报通道是无连接的,它们的工作方式不像流通道。换句话说,两个进程之间不能有一个专用的 1-to-1 连接。因此,进程只能通过通道传输数据报。客户端进程只能发送一些单独和独立的 数据报,同样,服务器进程只能接收数据报并作为响应发送其他数据报。

因此,数据报通道的关键之处在于请求和响应消息应该适合在一个数据报中。否则,它们不能被分成两个数据报,服务器或客户端也无法处理这些消息。幸运的是,计算器项目中的消息大多数足够短,可以适合在一个数据报中。

数据报的大小高度依赖于底层通道。例如,对于数据报 UDS 来说,这是相当灵活的,因为它通过内核进行,但对于 UDP 套接字,你将受到网络配置的限制。关于 UDS,以下链接可以给你一个更好的想法,了解如何设置正确的大小:stackoverflow.com/questions/21856517/whats-the-practical-limit-on-the-size-of-single-packet-transmitted-over-domain

关于数据报和流套接字,我们可以提到的另一个区别是用于在它们之间传输数据的 I/O API。虽然 readwrite 函数仍然可以像流套接字一样用于数据报套接字,但我们使用其他函数从数据报通道读取和发送。通常使用 recvfromsendto 函数。

这是因为在流套接字中,通道是专用的,当你向一个通道写入时,两端都是确定的。至于数据报套接字,我们只有一个通道被许多方使用。因此,我们可能会失去对特定数据报的所有权。这些函数可以跟踪并将数据报发送回期望的过程。

接下来,你可以在 main 函数的末尾找到 代码框 20-24 中使用的 serve_forever 函数的定义。这个函数属于服务器通用库,并且专门用于数据报服务器,无论套接字类型如何。你可以清楚地看到 recvfrom 函数是如何被使用的:

void serve_forever(int server_sd) {
  char buffer[64];
  while (1) {
    struct sockaddr* sockaddr = sockaddr_new();
    socklen_t socklen = sockaddr_sizeof();
    int read_nr_bytes = recvfrom(server_sd, buffer,
 sizeof(buffer), 0, sockaddr, &socklen);
    if (read_nr_bytes == -1) {
      close(server_sd);
      fprintf(stderr, "Could not read from datagram socket: %s\n",
              strerror(errno));
      exit(1);
    }
    struct client_context_t context;
    context.addr = (struct client_addr_t*)
 malloc(sizeof(struct client_addr_t));
    context.addr->server_sd = server_sd;
    context.addr->sockaddr = sockaddr;
    context.addr->socklen = socklen;
    context.ser = calc_proto_ser_new();
    calc_proto_ser_ctor(context.ser, &context, 256);
    calc_proto_ser_set_req_callback(context.ser, request_callback);
    calc_proto_ser_set_error_callback(context.ser, error_callback);
    context.svc = calc_service_new();
    calc_service_ctor(context.svc);
    context.write_resp = &datagram_write_resp;
    bool_t req_found = FALSE;
    struct buffer_t buf;
    buf.data = buffer;
    buf.len = read_nr_bytes;
    calc_proto_ser_server_deserialize(context.ser, buf, &req_found);
    if (!req_found) {
      struct calc_proto_resp_t resp;
      resp.req_id = -1;
      resp.status = ERROR_INVALID_RESPONSE;
      resp.result = 0.0;
      context.write_resp(&context, &resp);
    }
    calc_service_dtor(context.svc);
    calc_service_delete(context.svc);
    calc_proto_ser_dtor(context.ser);
    calc_proto_ser_delete(context.ser);
    free(context.addr->sockaddr);
    free(context.addr);
  }
}

代码框 20-25 [server/srvcore/datagram_server_core.c]:处理服务器通用库中找到的数据报的函数,并专门用于数据报服务器

如您在先前的代码框中看到的,数据报服务器是一个单线程程序,并且在其周围没有多线程。不仅如此,它对每个数据报进行单独和独立的操作。它接收一个数据报,反序列化其内容并创建请求对象,通过服务对象处理请求,序列化响应对象并将其放入一个新的数据报中,然后将它发送回拥有原始数据报的进程。对于每个传入的数据报,它都会重复进行相同的周期。

请注意,每个数据报都有自己的序列化对象和自己的服务对象。我们可以设计成只有一个序列化对象和一个服务对象适用于所有数据报。这可能对您思考如何实现以及为什么这可能不适合计算器项目是有趣的。这是一个有争议的讨论,您可能会从不同的人那里得到不同的观点。

注意,在代码框 20-25中,我们在接收到数据报时存储了数据报的客户端地址。稍后,我们可以使用这个地址直接向该客户端写入。看看我们如何将数据报写回发送客户端是值得一看的。就像流服务器一样,我们为此使用了一个函数。代码框 20-26显示了datagram_write_resp函数的定义。该函数位于数据报服务器公共库中,紧邻serve_forever函数:

void datagram_write_resp(struct client_context_t* context,
        struct calc_proto_resp_t* resp) {
  struct buffer_t buf =
      calc_proto_ser_server_serialize(context->ser, resp);
  if (buf.len == 0) {
    close(context->addr->server_sd);
    fprintf(stderr, "Internal error while serializing object.\n");
    exit(1);
  }
  int ret = sendto(context->addr->server_sd, buf.data, buf.len,
 0, context->addr->sockaddr, context->addr->socklen);
  free(buf.data);
  if (ret == -1) {
    fprintf(stderr, "Could not write to client: %s\n",
            strerror(errno));
    close(context->addr->server_sd);
    exit(1);
  } else if (ret < buf.len) {
    fprintf(stderr, "WARN: Less bytes were written!\n");
    close(context->addr->server_sd);
    exit(1);
  }
}

代码框 20-26 [server/srvcore/datagram_server_core.c]:将数据报写回客户端的函数

您可以看到我们使用了排序后的客户端地址,并将其与序列化的响应消息一起传递给sendto函数。其余的由操作系统处理,数据报直接发送回发送客户端。

既然我们已经足够了解数据报服务器以及如何使用套接字,让我们来看看数据报客户端,它使用的是相同类型的套接字。

UDS 数据报客户端

从技术角度来看,流客户端和数据报客户端非常相似。这意味着您应该看到几乎相同的整体结构,但在处理数据报而不是流通道时有一些差异。

但它们之间有一个很大的差异,这是相当独特且专门针对连接到 UDS 端点的数据报客户端的。

差异在于,数据报客户端需要绑定一个套接字文件,就像服务器程序一样,以便接收指向它的数据报。对于使用网络套接字的数据报客户端来说,情况并非如此,您很快就会看到。请注意,客户端应绑定不同的套接字文件,而不是服务器的套接字文件。

这种差异背后的主要原因是服务器程序需要一个地址来发送响应,如果数据报客户端没有绑定套接字文件,则没有端点绑定到客户端套接字文件。但是,对于网络套接字来说,客户端始终有一个对应的套接字描述符,它绑定到一个 IP 地址和一个端口,因此这个问题不会发生。

如果我们忽略这个差异,我们可以看到代码是多么相似。在代码框 20-26中,你可以看到数据报计算器客户端的main函数:

int main(int argc, char** argv) {
  char server_sock_file[] = "/tmp/calc_svc.sock";
  char client_sock_file[] = "/tmp/calc_cli.sock";
  // ----------- 1\. Create socket object ------------------
  int conn_sd = socket(AF_UNIX, SOCK_DGRAM, 0);
  if (conn_sd == -1) {
    fprintf(stderr, "Could not create socket: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 2\. Bind the client socket file ------------
  // Delete the previously created socket file if it exists.
  unlink(client_sock_file);
  // Prepare the client address
  struct sockaddr_un addr;
  memset(&addr, 0, sizeof(addr));
  addr.sun_family = AF_UNIX;
  strncpy(addr.sun_path, client_sock_file,
          sizeof(addr.sun_path) - 1);
  int result = bind(conn_sd,
          (struct sockaddr*)&addr, sizeof(addr));
  if (result == -1) {
    close(conn_sd);
    fprintf(stderr, "Could not bind the client address: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 3\. Connect to server --------------------
  // Prepare the server address
  memset(&addr, 0, sizeof(addr));
  addr.sun_family = AF_UNIX;
  strncpy(addr.sun_path, server_sock_file,
          sizeof(addr.sun_path) - 1);
  result = connect(conn_sd,
          (struct sockaddr*)&addr, sizeof(addr));
  if (result == -1) {
    close(conn_sd);
    fprintf(stderr, "Could no connect: %s\n", strerror(errno));
    exit(1);
  }
  datagram_client_loop(conn_sd);
  return 0;
}

代码框 20-26 [server/srvcore/datagram_server_core.c]: 将数据报文写回客户端的函数

正如我们之前所解释的,并且可以从代码中看到,客户端需要绑定一个套接字文件。当然,我们不得不在main函数的末尾调用一个不同的函数来启动客户端循环。数据报客户端调用datagram_client_loop函数。

如果你查看datagram_client_loop函数,你仍然会在流客户端和数据报客户端之间看到许多相似之处。尽管存在一些小的差异,但一个大的差异是使用recvfromsendto函数而不是readwrite函数。在上一节中对这些函数的解释,对于数据报客户端仍然适用。

现在是时候讨论网络套接字了。正如你将看到的,客户端和服务器程序中的main函数是唯一在从 UDS 转换为网络套接字时发生变化的代码。

网络套接字

另一个广泛使用的套接字地址族是AF_INET。它简单地指代在网络上建立的所有通道。与没有分配协议名称的 UDS 流和数据报套接字不同,网络套接字之上存在两个知名协议。TCP 套接字在两个进程之间建立流通道,而 UDP 套接字建立的数据报通道可以被多个进程使用。

在接下来的章节中,我们将解释如何使用 TCP 和 UDP 套接字开发程序,并作为计算器项目的一部分展示一些真实示例。

TCP 服务器

使用 TCP 套接字监听和接受多个客户端的程序,换句话说,是一个 TCP 服务器,它在两个方面的不同之处在于:首先,在调用socket函数时指定了不同的地址族,即AF_INET而不是AF_UNIX;其次,它使用了一个不同的结构来绑定所需的套接字地址。

尽管存在这两个差异,但从 I/O 操作的角度来看,TCP 套接字的其他一切都将与 UDP 套接字相同。我们应该注意,TCP 套接字是一个流套接字,因此为流套接字编写的代码也应该适用于 TCP 套接字。

如果我们回到计算器项目,我们期望看到的主要区别仅在于我们创建套接字对象并将其绑定到端点处的 main 函数。除此之外,其余的代码应该保持不变。事实上,这正是我们所看到的。以下代码框包含了 TCP 计算器服务器的 main 函数:

int main(int argc, char** argv) {
  // ----------- 1\. Create socket object ------------------
  int server_sd = socket(AF_INET, SOCK_STREAM, 0);
  if (server_sd == -1) {
    fprintf(stderr, "Could not create socket: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 2\. Bind the socket file ------------------
  // Prepare the address
  struct sockaddr_in addr;
  memset(&addr, 0, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = INADDR_ANY;
  addr.sin_port = htons(6666);
  ...
  // ----------- 3\. Prepare backlog ------------------
  ...
  // ----------- 4\. Start accepting clients ---------
  accept_forever(server_sd);
  return 0;
}

代码框 20-27 [server/tcp/main.c]:TCP 计算器客户端的 main 函数

如果您将前面的代码与 代码框 20-17 中看到的 main 函数进行比较,您将注意到我们之前解释过的差异。我们不是使用 sockaddr_un 结构,而是使用 sockaddr_in 结构来为绑定端点地址。listen 函数的使用相同,甚至调用了相同的 accept_forever 函数来处理传入的连接。

作为最后的说明,关于 TCP 套接字上的 I/O 操作,由于 TCP 套接字是一个流套接字,它继承了流套接字的所有属性;因此,它可以像任何其他流套接字一样使用。换句话说,相同的 readwriteclose 函数都可以使用。

现在,让我们谈谈 TCP 客户端。

TCP 客户端

再次强调,一切应该与在 UDS 上运行的流客户端非常相似。上一节中提到的差异对于连接器侧的 TCP 套接字仍然适用。变化再次仅限于 main 函数。

接下来,您可以查看 TCP 计算器客户端的 main 函数:

int main(int argc, char** argv) {
  // ----------- 1\. Create socket object ------------------
  int conn_sd = socket(AF_INET, SOCK_STREAM, 0);
  if (conn_sd == -1) {
    fprintf(stderr, "Could not create socket: %s\n",
            strerror(errno));
    exit(1);
  }
  // ------------ 2\. Connect to server-- ------------------
  // Find the IP address behind the hostname
  ...
  // Prepare the address
  struct sockaddr_in addr;
  memset(&addr, 0, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr = *((struct in_addr*)host_entry->h_addr);
  addr.sin_port = htons(6666);
  ...
  stream_client_loop(conn_sd);
  return 0;
}

代码框 20-27 [server/tcp/main.c]:TCP 计算器服务器的 main 函数

变化与我们在 TCP 服务器程序中看到的变化非常相似。使用了不同的地址族和不同的套接字地址结构。除此之外,其余的代码相同,因此我们不需要详细讨论 TCP 客户端。

由于 TCP 套接字是流套接字,我们可以使用相同的通用代码来处理新客户端。您可以通过调用 stream_client_loop 函数来看到这一点,该函数是计算器项目中的客户端通用库的一部分。现在,您应该明白为什么我们提取了两个通用库,一个用于客户端程序,一个用于服务器程序,以便编写更少的代码。当我们可以在两种不同的场景中使用相同的代码时,将其提取为库并在场景中重用总是最好的。

让我们来看看 UDP 服务器和客户端程序;我们会发现它们与我们所看到的 TCP 程序大致相似。

UDP 服务器

UDP 套接字是网络套接字。除此之外,它们是数据报套接字。因此,我们预计将观察到我们在 TCP 服务器代码和数据报服务器代码(在 UDS 上操作)中编写的代码之间的高度相似性。

此外,无论在客户端还是服务器程序中使用,UDP 套接字与 TCP 套接字的主要区别在于 UDP 套接字的套接字类型是 SOCK_DGRAM。地址族保持不变,因为它们都是网络套接字。以下代码框包含了计算器 UDP 服务器的主体函数:

int main(int argc, char** argv) {
  // ----------- 1\. Create socket object ------------------
  int server_sd = socket(AF_INET, SOCK_DGRAM, 0);
  if (server_sd == -1) {
    fprintf(stderr, "Could not create socket: %s\n",
            strerror(errno));
    exit(1);
  }
  // ----------- 2\. Bind the socket file ------------------
  // Prepare the address
  struct sockaddr_in addr;
  memset(&addr, 0, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = INADDR_ANY;
  addr.sin_port = htons(9999);
  ...
  // ----------- 3\. Start serving requests ---------
  serve_forever(server_sd);
  return 0;
}

代码框 20-28 [server/udp/main.c]:UDP 计算器服务器的主体函数

注意,UDP 套接字是数据报套接字。因此,为在 UDS 上操作的数据报套接字编写的所有代码仍然适用于它们。例如,我们必须使用 recvfromsendto 函数来处理 UDP 套接字。所以,如您所见,我们使用了相同的 serve_forever 函数来服务传入的数据报。这个函数是服务器通用库的一部分,旨在包含与数据报相关的代码。

关于 UDP 服务器代码,我们已经说得够多了。让我们看看 UDP 客户端代码的样子。

UDP 客户端

UDP 客户端代码与 TCP 客户端代码非常相似,但它使用不同的套接字类型,并调用不同的函数来处理传入的消息,这个函数与基于 UDS 的数据报客户端使用的函数相同。您可以看到以下 main 函数:

int main(int argc, char** argv) {
  // ----------- 1\. Create socket object ------------------
  int conn_sd = socket(AF_INET, SOCK_DGRAM, 0);
  if (conn_sd == -1) {
    fprintf(stderr, "Could not create socket: %s\n",
            strerror(errno));
    exit(1);
  }
  // ------------ 2\. Connect to server-- ------------------
  ...
  // Prepare the address
  ...
  datagram_client_loop(conn_sd);
  return 0;
}

代码框 20-28 [client/udp/main.c]:UDP 计算器客户端的主体函数

那是本章的最后一个概念。在本章中,我们探讨了各种众所周知的套接字类型,并展示了如何在 C 中实现流和数据报通道的监听器和连接器序列。

计算器项目中有很多我们没有讨论的事情。因此,强烈建议您阅读代码,找到那些地方,并尝试阅读和理解它。一个完全工作的示例可以帮助您在真实应用中检验这些概念。

摘要

在本章中,我们讨论了以下主题:

  • 我们在回顾 IPC 技术时介绍了各种类型的通信、通道、介质和套接字。

  • 我们通过描述其应用协议和所使用的序列化算法来探索了一个计算器项目。

  • 我们演示了如何使用 UDS 建立客户端-服务器连接,并展示了它们在计算器项目中的应用。

  • 我们分别讨论了使用 Unix 域套接字建立的流和数据报通道。

  • 我们演示了如何使用 TCP 和 UDP 套接字来创建客户端-服务器 IPC 通道,并在计算器示例中使用了它们。

下一章将介绍 C 语言与其他编程语言的集成。通过这种方式,我们可以在其他编程语言(如 Java)中加载并使用 C 库。作为下一章的一部分,我们将涵盖与 C++、Java、Python 和 Golang 的集成。

第二十一章

与其他语言的集成

了解如何编写 C 程序或库可能比你想象的更有价值。由于 C 在开发操作系统中的重要作用,C 并不仅限于其自身的世界。C 库有潜力在其他编程语言中加载和使用。当你从编写高级编程语言的代码中获得好处时,你可以在你的语言环境中作为加载的库拥有 C 的火箭动力。

在本章中,我们将更详细地讨论这个问题,并演示如何将 C 共享库与其他一些知名编程语言集成。

在本章中,我们将涵盖以下关键主题:

  • 我们讨论了集成之所以可能的原因。这次讨论很重要,因为它为你提供了集成是如何工作的基本概念。

  • 我们设计了一个 C 堆栈库。我们将其构建为一个共享对象文件。这个共享对象文件将被许多其他编程语言使用。

  • 我们将探讨 C++、Java、Python 和 Golang,看看堆栈库是如何首先加载然后使用的。

作为本章的一般说明,由于我们将要处理五个不同的子项目,每个项目都有不同的编程语言,我们只展示了 Linux 的构建,以防止任何关于构建和执行的问题。当然,我们提供了足够关于 macOS 系统的信息,但我们的重点是构建和运行 Linux 上的源代码。本书的 GitHub 仓库中还有其他脚本,可以帮助你构建 macOS 的源代码。

第一部分讨论了集成本身。我们探讨了为什么与其他编程语言的集成是可能的,这为我们扩展在其他环境中的讨论而不是 C 环境中的讨论奠定了基础。

为什么集成是可能的?

如我们在第十章Unix – 历史 与 架构中所述,C 彻底改变了我们开发操作系统的方法。这不仅仅是 C 的魔法;它还赋予了我们构建其他通用编程语言的能力,这些语言我们现在称之为高级编程语言。这些语言的编译器大多是用 C 编写的,如果不是,它们也是由用 C 编写的其他工具和编译器开发的。

一个不能使用或提供系统功能的通用编程语言实际上什么都没做。你可以用它写东西,但无法在任何系统上执行它。虽然从理论角度来看,这样的编程语言可能有用途,但从工业角度来看,这显然是不切实际的。因此,编程语言,特别是通过其编译器,应该能够生成可工作的程序。正如你所知,系统的功能是通过操作系统暴露的。无论操作系统本身如何,编程语言都应该能够提供这些功能,并且在该语言编写的程序,在运行在该系统上时,应该能够使用它们。

这就是 C 语言的作用所在。在类 Unix 操作系统中,C 标准库提供了使用系统可用功能的 API。如果编译器想要创建一个可工作的程序,它应该能够允许编译后的程序以间接的方式使用 C 标准库。无论编程语言是什么,以及它是否提供一些特定的和本机标准库,例如 Java 提供的Java 标准版Java SE),任何由编写的程序提出的特定功能请求(如打开文件)都应该传递给 C 标准库,然后从那里,它可以到达内核并执行。

例如,让我们再详细谈谈 Java。Java 程序被编译成一种称为字节码的中间语言。为了执行 Java 字节码,需要安装Java 运行时环境JRE)。JRE 的核心是一个虚拟机,它加载 Java 字节码并在其中运行。这个虚拟机必须能够模拟 C 标准库暴露的功能和服务,并将它们提供给运行在其内部的程序。由于每个平台在 C 标准库及其对 POSIX 和 SUS 标准的兼容性方面都可能不同,因此我们需要为每个平台构建一些特定的虚拟机。

最后关于在其他语言中可以加载的库的说明,我们只能加载共享对象文件,并且无法加载和使用静态库。静态库只能链接到可执行文件或共享对象文件。在大多数类 Unix 系统中,共享对象文件有.so扩展名,但在 macOS 中它们有.dylib扩展名。

在本节中,尽管篇幅很短,但我试图给你一个基本的概念,解释为什么我们能够加载 C 库,特别是共享库,以及大多数编程语言是如何已经使用 C 库的,因为它们中的大多数都存在加载共享对象库并使用它的能力。

下一步将是编写一个 C 库,然后将其加载到各种编程语言中以供使用。这正是我们很快就要做的事情,但在那之前,你需要知道如何获取章节材料以及如何运行在 shell 框中看到的命令。

获取必要的材料

由于本章充满了来自五种不同编程语言的源代码,而且我的希望是让你们所有人都能构建和运行示例,所以我将这一节专门用于介绍一些基本注意事项,这些注意事项你应该在构建源代码时注意。

首先,你需要获取章节材料。正如你现在应该知道的,这本书有一个仓库,其中这一章有一个名为ch21-integration-with-other-languages的特定目录。以下命令显示了如何克隆仓库并切换到章节的根目录:

$ git clone https://github.com/PacktPublishing/Extreme-C.git
...
$ cd Extreme-C/ch21-integration-with-other-languages
$

Shell 代码 21-1:克隆书籍的 GitHub 仓库并切换到章节的根目录

关于本章中的 shell 框,我们假设在执行 shell 框中的命令之前,我们位于章节的根目录ch21-integration-with-other-languages文件夹中。如果我们需要切换到其他目录,我们将提供所需的命令,但所有操作都在章节目录内进行。

此外,为了能够构建源代码,你需要在你的机器上安装Java 开发工具包JDK)、Python 和 Golang。根据你使用的是 Linux 还是 macOS,以及你的 Linux 发行版,安装命令可能会有所不同。

作为最后的注意事项,用除 C 以外的其他语言编写的源代码应该能够使用我们在下一节中讨论的 C 栈库。构建这些源代码需要你已经构建了 C 库。因此,请确保你首先阅读以下章节,并在继续下一章节之前构建其共享对象库。现在,既然你知道了如何获取章节材料,我们就可以继续讨论我们的目标 C 库。

栈库

在本节中,我们将编写一个小型库,该库将被其他编程语言编写的程序加载和使用。该库是关于一个栈类,它提供了一些基本的操作,如对栈对象进行pushpop。栈对象由库本身创建和销毁,并且有一个构造函数以及一个析构函数来满足这一目的。

接下来,你可以找到库的公共接口,它作为cstack.h头文件的一部分存在:

#ifndef _CSTACK_H_
#define _CSTACK_H_
#include <unistd.h>
#ifdef __cplusplus
extern "C" {
#endif
#define TRUE 1
#define FALSE 0
typedef int bool_t;
typedef struct {
  char* data;
  size_t len;
} value_t;
typedef struct cstack_type cstack_t;
typedef void (*deleter_t)(value_t* value);
value_t make_value(char* data, size_t len);
value_t copy_value(char* data, size_t len);
void free_value(value_t* value);
cstack_t* cstack_new();
void cstack_delete(cstack_t*);
// Behavior functions
void cstack_ctor(cstack_t*, size_t);
void cstack_dtor(cstack_t*, deleter_t);
size_t cstack_size(const cstack_t*);
bool_t cstack_push(cstack_t*, value_t value);
bool_t cstack_pop(cstack_t*, value_t* value);
void cstack_clear(cstack_t*, deleter_t);
#ifdef __cplusplus
}
#endif
#endif

代码框 21-1 [cstack.h]:栈库的公共接口

正如在 第六章面向对象编程和封装 中所解释的,前述声明引入了 Stack 类的公共接口。正如你所见,类的伴随属性结构是 cstack_t。我们使用 cstack_t 而不是 stack_t,因为后者在 C 标准库中使用,并且我更喜欢避免在此代码中产生任何歧义。通过前述声明,属性结构被前置声明且其中没有字段。相反,细节将在实际实现该结构的源文件中给出。该类还有一个构造函数、一个析构函数以及一些其他行为,如 push 和 pop。正如你所见,所有这些函数都将 cstack_t 类型的指针作为它们的第一个参数,该指针指示它们应该作用的对象。我们编写 Stack 类的方式在第六章,面向对象编程和封装 中的 隐式封装 部分进行了说明。

代码框 21-2 包含了栈类的实现。它还包含了 cstack_t 属性结构的实际定义:

#include <stdlib.h>
#include <assert.h>
#include "cstack.h"
struct cstack_type {
  size_t top;
  size_t max_size;
  value_t* values;
};
value_t copy_value(char* data, size_t len) {
  char* buf = (char*)malloc(len * sizeof(char));
  for (size_t i = 0; i < len; i++) {
    buf[i] = data[i];
  }
  return make_value(buf, len);
}
value_t make_value(char* data, size_t len) {
  value_t value;
  value.data = data;
  value.len = len;
  return value;
}
void free_value(value_t* value) {
  if (value) {
    if (value->data) {
      free(value->data);
      value->data = NULL;
    }
  }
}
cstack_t* cstack_new() {
  return (cstack_t*)malloc(sizeof(cstack_t));
}
void cstack_delete(cstack_t* stack) {
  free(stack);
}
void cstack_ctor(cstack_t* cstack, size_t max_size) {
  cstack->top = 0;
  cstack->max_size = max_size;
  cstack->values = (value_t*)malloc(max_size * sizeof(value_t));
}
void cstack_dtor(cstack_t* cstack, deleter_t deleter) {
  cstack_clear(cstack, deleter);
  free(cstack->values);
}
size_t cstack_size(const cstack_t* cstack) {
  return cstack->top;
}
bool_t cstack_push(cstack_t* cstack, value_t value) {
  if (cstack->top < cstack->max_size) {
    cstack->values[cstack->top++] = value;
    return TRUE;
  }
  return FALSE;
}
bool_t cstack_pop(cstack_t* cstack, value_t* value) {
  if (cstack->top > 0) {
    *value = cstack->values[--cstack->top];
    return TRUE;
  }
  return FALSE;
}
void cstack_clear(cstack_t* cstack, deleter_t deleter) {
  value_t value;
  while (cstack_size(cstack) > 0) {
    bool_t popped = cstack_pop(cstack, &value);
    assert(popped);
    if (deleter) {
      deleter(&value);
    }
  }
}

代码框 21-2 [cstack.c]: 栈类的定义

正如你所见,定义暗示了每个栈对象都由一个数组支持,而且不仅如此,我们可以在栈中存储任何值。让我们构建库并从中生成一个共享对象库。这将是在接下来的章节中将被其他编程语言加载的库文件。

下面的 Shell 框展示了如何使用现有的源文件创建共享对象库。文本框中的命令在 Linux 上有效,并且为了在 macOS 上运行,它们应该进行轻微的修改。请注意,在运行构建命令之前,你应该处于该章节的根目录,正如之前所解释的:

$ gcc -c -g -fPIC cstack.c -o cstack.o
$ gcc -shared cstack.o -o libcstack.so
$

Shell 框 21-2:在 Linux 中构建栈库并生成共享对象库文件

作为旁注,在 macOS 中,如果我们知道 gcc 是一个命令并且它指向 clang 编译器,我们可以运行前述的精确命令。否则,我们可以使用以下命令在 macOS 上构建库。请注意,在 macOS 中共享对象文件的扩展名是 .dylib

$ clang -c -g -fPIC cstack.c -o cstack.o
$ clang -dynamiclib cstack.o -o libcstack.dylib
$

Shell 框 21-3:在 macOS 中构建栈库并生成共享对象库文件

我们现在有了共享对象库文件,我们可以编写其他语言中的程序来加载它。在我们演示如何在前述库中加载和使用它在其他环境中的方法之前,我们需要编写一些测试来验证其功能。以下代码创建了一个栈并执行了一些可用的操作,并将结果与预期进行了比较:

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include "cstack.h"
value_t make_int(int int_value) {
  value_t value;
  int* int_ptr = (int*)malloc(sizeof(int));
  *int_ptr = int_value;
  value.data = (char*)int_ptr;
  value.len = sizeof(int);
  return value;
}
int extract_int(value_t* value) {
  return *((int*)value->data);
}
void deleter(value_t* value) {
  if (value->data) {
    free(value->data);
  }
  value->data = NULL;
}
int main(int argc, char** argv) {
  cstack_t* cstack = cstack_new();
  cstack_ctor(cstack, 100);
  assert(cstack_size(cstack) == 0);
  int int_values[] = {5, 10, 20, 30};
  for (size_t i = 0; i < 4; i++) {
    cstack_push(cstack, make_int(int_values[i]));
  }
  assert(cstack_size(cstack) == 4);
  int counter = 3;
  value_t value;
  while (cstack_size(cstack) > 0) {
    bool_t popped = cstack_pop(cstack, &value);
    assert(popped);
    assert(extract_int(&value) == int_values[counter--]);
    deleter(&value);
  }
  assert(counter == -1);
  assert(cstack_size(cstack) == 0);
  cstack_push(cstack, make_int(10));
  cstack_push(cstack, make_int(20));
  assert(cstack_size(cstack) == 2);
  cstack_clear(cstack, deleter);
  assert(cstack_size(cstack) == 0);
   // In order to have something in the stack while
  // calling destructor.
  cstack_push(cstack, make_int(20));
  cstack_dtor(cstack, deleter);
  cstack_delete(cstack);
  printf("All tests were OK.\n");
  return 0;
}

代码框 21-3 [cstack_tests.c]: 测试 Stack 类功能性的代码

正如你所见,我们使用了断言来检查返回的值。以下是在 Linux 环境下构建并执行前述代码的输出。再次提醒,我们处于该章节的根目录:

$ gcc -c -g cstack_tests.c -o tests.o
$ gcc tests.o -L$PWD -lcstack -o cstack_tests.out
$ LD_LIBRARY_PATH=$PWD ./cstack_tests.out
All tests were OK.
$

Shell 框 21-4: 构建和运行库测试

注意,在前面的 Shell 框中,当运行最终的可执行文件 cstack_tests.out 时,我们必须设置环境变量 LD_LIBRARY_PATH 以指向包含 libcstack.so 的目录,因为执行程序需要找到共享对象库并将它们加载。

如您在 Shell 框 21-4 中所见,所有测试都成功通过。这意味着从功能角度来看,我们的库运行正确。检查库是否符合非功能性要求,如内存使用或没有内存泄漏,将会很棒。

以下命令显示了如何使用 valgrind 检查测试的执行以检查任何可能的内存泄漏:

$ LD_LIBRARY_PATH=$PWD valgrind --leak-check=full ./cstack_tests.out
==31291== Memcheck, a memory error detector
==31291== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==31291== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==31291== Command: ./cstack_tests.out
==31291==
All tests were OK.
==31291==
==31291== HEAP SUMMARY:
==31291==     in use at exit: 0 bytes in 0 blocks
==31291==   total heap usage: 10 allocs, 10 frees, 2,676 bytes allocated
==31291==
==31291== All heap blocks were freed -- no leaks are possible
==31291==
==31291== For counts of detected and suppressed errors, rerun with: -v
==31291== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$

Shell 框 21-5: 使用 valgrind 运行测试

如您所见,我们没有任何内存泄漏,这使我们对我们编写的库更有信心。因此,如果我们看到另一个环境中的任何内存问题,首先应该在那里调查根本原因。

在下一章中,我们将介绍 C 的单元测试。作为 代码框 21-3 中看到的 assert 语句的合适替代品,我们可以编写单元测试并使用单元测试框架如 CMocka 来执行它们。

在接下来的几节中,我们将把堆栈库集成到用四种编程语言编写的程序中。我们将从 C++ 开始。

与 C++ 的集成

与 C++ 的集成可以认为是最容易的。C++ 可以被视为 C 的面向对象扩展。C++ 编译器生成的目标文件与 C 编译器生成的类似。因此,C++ 程序比其他任何编程语言更容易加载和使用 C 共享对象库。换句话说,共享对象文件是 C 还是 C++ 项目的输出并不重要;两者都可以被 C++ 程序消费。在某些情况下可能存在问题的唯一事情是 第二章 中描述的 C++ 名称修饰 功能。作为提醒,我们将在以下部分简要回顾它。

C++ 中的名称修饰

为了更详细地说明这一点,我们应该说,与函数(类中的全局函数和成员函数)对应的符号名称在 C++ 中会被修饰。名称修饰主要是为了支持 命名空间函数重载,这些在 C 中是缺失的。名称修饰默认启用,因此如果 C 代码使用 C++ 编译器编译,我们期望看到修饰过的符号名称。看看以下 代码框 21-4 中的示例:

int add(int a, int b) {
  return a + b;
}

代码框 21-4 [test.c]: C 语言中的一个简单函数

如果我们使用 C 编译器编译前面的文件,在这种情况下是 clang,我们将在生成的目标文件中看到以下符号,如 Shell 框 21-6 所示。请注意,书中的 GitHub 仓库中不存在 test.c 文件:

$ clang -c test.c -o test.o
$ nm test.o
0000000000000000 T _add
$

Shell 框 21-6: 使用 C 编译器编译 test.c

正如你所见,我们有一个名为 _add 的符号,它指向上面定义的 add 函数。现在,让我们使用 C++ 编译器编译这个文件,在这种情况下是 clang++

$ clang++ -c test.c -o test.o
clang: warning: treating 'c' input as 'c++' when in C++ mode, this behavior is deprecated [-Wdeprecated]
$ nm test.o
0000000000000000 T __Z3addii
$

Shell 框 21-7:使用 C++ 编译器编译 test.c

正如你所见,clang++ 生成了一条警告,说明在不久的将来,将 C 代码编译为 C++ 代码的支持将被删除。但是,由于这种行为尚未被删除(并且只是已弃用),我们看到为前面函数生成的符号名称被修饰,并且与 clang 生成的不同。这肯定会在链接阶段查找特定符号时导致问题。

为了消除这个问题,需要将 C 代码封装在一个特殊的范围内,以防止 C++ 编译器对符号名称进行修饰。然后,使用 clangclang++ 编译它会产生相同的符号名称。看看 代码框 21-5 中的以下代码,这是 代码框 21-4 中引入的代码的修改版本:

#ifdef __cplusplus
extern "C" {
#endif
int add(int a, int b) {
  return a + b;
}
#ifdef __cplusplus
}
#endif

代码框 21-5 [test.c]:将函数声明放入特殊的 C 范围

前面的函数仅在宏 __cplusplus 已经定义的情况下放入 extern "C" { ... } 范围内。拥有宏 __cplusplus 是代码正在由 C++ 编译器编译的标志。让我们再次使用 clang++ 编译前面的代码:

$ clang++ -c test.c -o test.o
clang: warning: treating 'c' input as 'c++' when in C++ mode, this behavior is deprecated [-Wdeprecated]
$ nm test.o
0000000000000000 T _add
$

Shell 框 21-8:使用 clang++ 编译 test.c 的新版本

正如你所见,生成的符号不再被修饰。关于我们的堆栈库,根据我们到目前为止的解释,我们需要将所有声明放在 extern "C" { … } 范围内,这正是 代码框 21-1 中存在该范围的原因。因此,当将 C++ 程序与堆栈库链接时,符号可以在 libcstack.so(或 libcstack.dylib)中找到。

注意:

extern "C" 是一个 链接规范。更多信息可以通过以下链接找到:

https://isocpp.org/wiki/faq/mixing-c-and-cpp

https://stackoverflow.com/questions/1041866/what-is-the-effect-of-extern-c-in-c.

现在,是时候编写使用我们的堆栈库的 C++ 代码了。正如你很快就会看到的,这是一个简单的集成。

C++ 代码

既然我们已经知道了如何在将 C 代码引入 C++ 项目时禁用名称修饰,我们可以通过编写一个使用堆栈库的 C++ 程序来继续。我们首先将堆栈库封装在一个 C++ 类中,这是面向对象 C++ 程序的主要构建块。以面向对象的方式暴露堆栈功能,而不是直接调用堆栈库的 C 函数,更为合适。

代码框 21-6 包含了从堆栈库派生出的封装堆栈功能的类:

#include <string.h>
#include <iostream>
#include <string>
#include "cstack.h"
template<typename T>
value_t CreateValue(const T& pValue);
template<typename T>
T ExtractValue(const value_t& value);
template<typename T>
class Stack {
public:
  // Constructor
  Stack(int pMaxSize) {
    mStack = cstack_new();
    cstack_ctor(mStack, pMaxSize);
  }
  // Destructor
  ~Stack() {
    cstack_dtor(mStack, free_value);
    cstack_delete(mStack);
  }
  size_t Size() {
    return cstack_size(mStack);
  }
  void Push(const T& pItem) {
    value_t value = CreateValue(pItem);
    if (!cstack_push(mStack, value)) {
      throw "Stack is full!";
    }
  }
  const T Pop() {
    value_t value;
    if (!cstack_pop(mStack, &value)) {
      throw "Stack is empty!";
    }
    return ExtractValue<T>(value);
  }
  void Clear() {
    cstack_clear(mStack, free_value);
  }
private:
  cstack_t* mStack;
};

代码框 21-6 [c++/Stack.cpp]: 一个封装了堆栈库暴露的功能的 C++ 类

关于前面的类,我们可以指出以下重要注意事项:

  • 前面的类保留了一个指向 cstack_t 变量的私有指针。这个指针指向由静态库的 cstack_new 函数创建的对象。这个指针可以被视为一个指向在 C 级别存在的对象的 句柄,由一个单独的 C 库创建和管理。指针 mStack 类似于文件描述符(或文件句柄),它指向一个文件。

  • 该类封装了堆栈库公开的所有行为函数。这并不一定适用于围绕 C 库的任何面向对象的包装器,通常只公开有限的功能集。

  • 前面的类是一个模板类。这意味着它可以操作多种数据类型。正如你所看到的,我们声明了两个模板函数用于序列化和反序列化具有各种类型的对象:CreateValueExtractValue。前面的类使用这些函数从 C++ 对象创建字节数组(序列化)以及从字节数组创建 C++ 对象(反序列化)。

  • 我们为 std::string 类型定义了一个专门的模板函数。因此,我们可以使用前面的类来存储 std::string 类型的值。请注意,std::string 是 C++ 中用于字符串变量的标准类型。

  • 作为堆栈库的一部分,你可以将来自不同类型的多个值推入单个堆栈实例。值可以转换为/从字符数组转换。查看 代码框 21-1 中的 value_t 结构。它只需要一个 char 指针,仅此而已。与堆栈库不同,前面的 C++ 类是 类型安全的,并且它的每个实例只能操作特定的数据类型。

  • 在 C++ 中,每个类至少有一个构造函数和一个析构函数。因此,将底层堆栈对象作为构造函数的一部分进行初始化,并在析构函数中终止它是非常容易的。这正是前面代码所展示的。

我们希望我们的 C++ 类能够操作字符串值。因此,我们需要编写适当的序列化和反序列化函数,这些函数可以在类内部使用。以下代码包含将 C 字符数组转换为 std::string 对象以及相反转换的函数定义:

template<>
value_t CreateValue(const std::string& pValue) {
  value_t value;
  value.len = pValue.size() + 1;
  value.data = new char[value.len];
  strcpy(value.data, pValue.c_str());
  return value;
}
template<>
std::string ExtractValue(const value_t& value) {
  return std::string(value.data, value.len);
}

代码框 21-7 [c++/Stack.cpp]:专门为 std::string 类型的序列化和反序列化设计的模板函数。这些函数作为 C++ 类的一部分被使用。

前面的函数是类中声明的模板函数的 std::string 特化。正如你所看到的,它定义了如何将 std::string 对象转换为 C 字符数组,以及相反地,如何将 C 字符数组转换为 std::string 对象。

代码框 21-8 包含使用 C++ 类的 main 方法:

int main(int argc, char** argv) {
  Stack<std::string> stringStack(100);
  stringStack.Push("Hello");
  stringStack.Push("World");
  stringStack.Push("!");
  std::cout << "Stack size: " << stringStack.Size() << std::endl;
  while (stringStack.Size() > 0) {
    std::cout << "Popped > " << stringStack.Pop() << std::endl;
  }
  std::cout << "Stack size after pops: " <<
      stringStack.Size() << std::endl;
  stringStack.Push("Bye");
  stringStack.Push("Bye");
  std::cout << "Stack size before clear: " <<
      stringStack.Size() << std::endl;
  stringStack.Clear();
  std::cout << "Stack size after clear: " <<
      stringStack.Size() << std::endl;
  return 0;
}

代码框 21-8 [c++/Stack.cpp]:使用 C++ 堆栈类的主体函数

上述场景涵盖了栈库公开的所有功能。我们执行了一系列操作并检查了它们的结果。请注意,前面的代码使用Stack<std::string>对象进行功能测试。因此,只能将std::string值推入或从栈中弹出。

下面的 shell box 展示了如何构建和运行前面的代码。请注意,本节中所有看到的 C++代码都是使用 C++11 编写的,因此应该使用兼容的编译器进行编译。正如我们之前所说的,当我们处于章节的根目录时,我们将运行以下命令:

$ cd c++
$ g++ -c -g -std=c++11 -I$PWD/.. Stack.cpp -o Stack.o
$ g++ -L$PWD/.. Stack.o -lcstack -o cstack_cpp.out 
$ LD_LIBRARY_PATH=$PWD/.. ./cstack_cpp.out
Stack size: 3
Popped > !
Popped > World
Popped > Hello
Stack size after pops: 0
Stack size before clear: 2
Stack size after clear: 0
$

Shell Box 21-9:构建和运行 C++代码

如您所见,我们已经通过传递-std=c++11选项来指示我们将使用 C++11 编译器。请注意-I-L选项,它们分别用于指定自定义包含和库目录。选项-lcstack要求链接器将 C++代码与库文件libcstack.so链接起来。请注意,在 macOS 系统上,共享对象库具有.dylib扩展名,因此您可能会找到libcstack.dylib而不是libcstack.so

要运行cstack_cpp.out可执行文件,加载器需要找到libcstack.so。请注意,这与构建可执行文件不同。在这里,我们想要运行它,并且库文件必须在可执行文件运行之前位于正确的位置。因此,通过更改环境变量LD_LIBRARY_PATH,我们让加载器知道它应该在何处查找共享对象。我们已在第二章编译和链接中对此进行了更多讨论。

C++代码也应该针对内存泄漏进行测试。valgrind帮助我们查看内存泄漏,我们用它来分析生成的可执行文件。下面的 shell box 展示了valgrind运行cstack_cpp.out可执行文件的输出:

$ cd c++
$ LD_LIBRARY_PATH=$PWD/.. valgrind --leak-check=full ./cstack_cpp.out
==15061== Memcheck, a memory error detector
==15061== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==15061== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==15061== Command: ./cstack_cpp.out
==15061==
Stack size: 3
Popped > !
Popped > World
Popped > Hello
Stack size after pops: 0
Stack size before clear: 2
Stack size after clear: 0
==15061==
==15061== HEAP SUMMARY:
==15061==     in use at exit: 0 bytes in 0 blocks
==15061==   total heap usage: 9 allocs, 9 frees, 75,374 bytes allocated
==15061==
==15061== All heap blocks were freed -- no leaks are possible
==15061==
==15061== For counts of detected and suppressed errors, rerun with: -v
==15061== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0) 
$

Shell Box 21-10:使用 valgrind 构建和运行 C++代码

如前述输出所示,我们的代码中没有泄漏。请注意,still reachable部分中有 1081 字节并不意味着您的代码中存在泄漏。您可以在valgrind的手册中找到更多关于此的信息。

在本节中,我们解释了如何编写一个围绕我们的 C 栈库的 C++包装器。虽然混合 C 和 C++代码看起来很简单,但在 C++中应该注意一些额外的名称修饰规则。在下一节中,我们将简要介绍 Java 编程语言以及我们将如何在 Java 编写的程序中加载我们的 C 库。

与 Java 的集成

Java 程序由 Java 编译器编译成 Java 字节码。Java 字节码类似于在应用程序二进制接口ABI)中指定的对象文件格式。包含 Java 字节码的文件不能像普通可执行文件那样执行,它们需要一个特殊的环境来运行。

Java 字节码只能在 Java 虚拟机 (JVM) 中运行。JVM 本身是一个模拟 Java 字节码工作环境的进程。它通常用 C 或 C++ 编写,并且具有加载和使用 C 标准库以及在该层公开的功能的能力。

Java 编程语言不是唯一可以编译成 Java 字节码的语言。Scala、Kotlin 和 Groovy 等编程语言也可以编译成 Java 字节码,因此它们可以在 JVM 中运行。它们通常被称为 JVM 语言

在本节中,我们将把已经构建的栈库加载到 Java 程序中。对于那些没有 Java 基础知识的人来说,我们采取的步骤可能看起来很复杂,难以理解。因此,强烈建议读者在进入本节之前对 Java 编程有一些基本了解。

编写 Java 部分

假设我们有一个构建成共享对象库的 C 项目。我们希望将其引入 Java 并使用其函数。幸运的是,我们可以编写和编译 Java 部分,而无需任何 C(或本地)代码。它们通过 Java 中的 native 方法 被很好地分离。显然,如果没有加载共享对象库文件,仅使用 Java 部分运行 Java 程序并调用 C 函数是不可能的。我们提供了必要的步骤和源代码来实现这一点,并成功运行了一个加载共享对象库并调用其函数的 Java 程序。

JVM 使用 Java 本地接口 (JNI) 来加载共享对象库。请注意,JNI 不是 Java 编程语言的一部分;相反,它是 JVM 规范的一部分,因此导入的共享对象库可以在所有 JVM 语言(如 Scala)中使用。

在接下来的段落中,我们将展示如何使用 JNI 加载我们的目标共享对象库文件。

如我们之前所说,JNI 使用本地方法。本地方法在 Java 中没有定义;它们的实际定义是用 C 或 C++ 编写的,并驻留在外部共享库中。换句话说,本地方法是 Java 程序与 JVM 外部世界通信的端口。以下代码显示了一个包含多个静态本地方法的类,它应该公开我们的栈库提供的功能:

package com.packt.extreme_c.ch21.ex1;
class NativeStack {
  static {
    System.loadLibrary("NativeStack");
  }
  public static native long newStack();
  public static native void deleteStack(long stackHandler);
  public static native void ctor(long stackHandler, int maxSize);
  public static native void dtor(long stackHandler);
  public static native int size(long stackHandler);
  public static native void push(long stackHandler, byte[] item);
  public static native byte[] pop(long stackHandler);
  public static native void clear(long stackHandler);
}

代码框 21-9 [java/src/com/packt/extreme_c/ch21/ex1/Main.java]: NativeStack 类

如方法签名所示,它们对应于我们在 C 栈库中的函数。请注意,第一个操作数是一个 long 变量。它包含从本地库中读取的本地地址,并作为指针传递给其他方法以表示栈实例。请注意,在编写前面的类时,我们不需要事先有一个完全工作的共享对象文件。我们所需的是定义栈 API 所需的声明列表。

前一个类还有一个静态构造函数。构造函数加载位于文件系统上的共享对象库文件,并尝试将本地方法与该共享对象库中找到的符号匹配。请注意,前面的共享对象库不是libcstack.so。换句话说,这并不是为我们自己的堆栈库生成的共享对象文件。JNI 有一个非常精确的配方来查找与本地方法相对应的符号。因此,我们不能使用定义在libcstack.so中的符号;相反,我们需要创建 JNI 正在寻找的符号,然后从那里使用我们的堆栈库。

这可能目前有点不清楚,但在下一节中,我们将澄清这一点,你将看到如何实现。让我们继续 Java 部分。我们仍然需要添加一些更多的 Java 代码。

下面的是一个名为Stack<T>的通用 Java 类,它封装了 JNI 公开的本地方法。通用 Java 类可以被视为与 C++中我们拥有的模板类的孪生概念。它们用于指定可以操作其他类型的某些通用类型。

正如你在Stack<T>类中看到的,有一个marshaller对象,其类型为Marshaller<T>,用于序列化和反序列化方法的输入参数(类型为T),以便将它们放入或从底层 C 堆栈中检索:

interface Marshaller<T> {
  byte[] marshal(T obj);
  T unmarshal(byte[] data);
}
class Stack<T> implements AutoCloseable {
  private Marshaller<T> marshaller;
  private long stackHandler;
  public Stack(Marshaller<T> marshaller) {
    this.marshaller = marshaller;
    this.stackHandler = NativeStack.newStack();
    NativeStack.ctor(stackHandler, 100);
  }
  @Override
  public void close() {
    NativeStack.dtor(stackHandler);
    NativeStack.deleteStack(stackHandler);
  }
  public int size() {
    return NativeStack.size(stackHandler);
  }
  public void push(T item) {
    NativeStack.push(stackHandler, marshaller.marshal(item));
  }
  public T pop() {
    return marshaller.unmarshal(NativeStack.pop(stackHandler));
  }
  public void clear() {
    NativeStack.clear(stackHandler);
  }
}

代码框 21-10 [java/src/com/packt/extreme_c/ch21/ex1/Main.java]: Stack<T>类和Marshaller<T>接口

关于前面的代码,以下几点似乎值得关注:

  • Stack<T>类是一个通用类。这意味着它的不同实例可以操作各种类,如StringIntegerPoint等,但每个实例只能操作在实例化时指定的类型。

  • 在底层堆栈中存储任何数据类型的能力需要堆栈使用外部 marshaller 来执行对象的序列化和反序列化。C 堆栈库能够将字节数组存储在堆栈数据结构中,而愿意使用其功能的高级语言应该能够通过序列化输入对象来提供该字节数组。你很快就会看到String类的Marshaller接口的实现。

  • 我们使用构造函数注入Marshaller实例。这意味着我们应该有一个已经创建的与类T通用类型兼容的 marshaller 实例。

  • Stack<T>类实现了AutoCloseable接口。这仅仅意味着它有一些应该在销毁时释放的本地资源。请注意,实际的栈是在本地代码中创建的,而不是在 Java 代码中。因此,当不再需要栈时,JVM 的垃圾回收器无法释放栈。AutoCloseable对象可以用作具有特定作用域的资源,当它们不再需要时,它们的close方法会被自动调用。简而言之,你将看到我们如何在测试场景中使用前面的类。

  • 如你所见,我们有一个构造函数,并且使用本地方法初始化了底层的栈。我们在类中保留了一个指向栈的long字段。请注意,与 C++不同,我们在这个类中没有任何析构函数。因此,有可能不释放底层的栈,并且最终可能导致内存泄漏。这就是为什么我们将这个类标记为AutoCloseable。当一个AutoCloseable对象不再需要时,它的close方法会被调用,正如你在前面的代码中所看到的,我们调用了 C 栈库的析构函数来释放由 C 栈分配的资源。

通常,你不能信任垃圾回收器机制在 Java 对象上调用终结器方法,使用AutoCloseable资源是管理本地资源的正确方式。

下面的内容是StringMarshaller的实现。由于String类在处理字节数组方面提供了很好的支持,所以实现非常直接:

class StringMarshaller implements Marshaller<String> {
  @Override
  public byte[] marshal(String obj) {
    return obj.getBytes();
  }
  @Override
  public String unmarshal(byte[] data) {
    return new String(data);
  }
}

代码框 21-11 [java/src/com/packt/extreme_c/ch21/ex1/Main.java]: StringMarshaller

以下代码是我们的Main类,它包含了通过 Java 代码演示 C 栈功能的测试场景:

public class Main {
  public static void main(String[] args) {
    try (Stack<String> stack = new Stack<>(new StringMarshaller())) {
      stack.push("Hello");
      stack.push("World");
      stack.push("!");
      System.out.println("Size after pushes: " + stack.size());
      while (stack.size() > 0) {
        System.out.println(stack.pop());
      }
      System.out.println("Size after pops: " + stack.size());
      stack.push("Ba");
      stack.push("Bye!");
      System.out.println("Size after before clear: " + stack.size());
      stack.clear();
      System.out.println("Size after clear: " + stack.size());
    }
  }
}

代码框 21-12 [java/src/com/packt/extreme_c/ch21/ex1/Main.java]: 包含测试场景以检查 C 栈库功能的Main

如你所见,引用变量stack是在一个try块内部创建和使用的。这种语法通常被称为try-with-resources,并且它是作为 Java 7 的一部分被引入的。当try块执行完毕后,会在资源对象上调用close方法,并且底层的栈被释放。测试场景与我们在上一节为 C++编写的场景相同,但这次是在 Java 中。

在本节中,我们涵盖了 Java 部分以及我们需要导入本地部分的所有 Java 代码。上述所有源代码都可以编译,但你不能运行它们,因为你还需要本地部分。只有两者结合才能生成可执行程序。在下一节中,我们将讨论我们应该采取的步骤来编写本地部分。

编写本地部分

在上一节中,我们介绍的最重要的事情是本地方法的概念。本地方法在 Java 中声明,但其定义位于 JVM 外部的共享对象库中。但是 JVM 如何在加载的共享对象文件中找到本地方法的定义呢?答案是简单的:通过在共享对象文件中查找某些符号名称。JVM 根据其各种属性(如包、包含的类和名称)为每个本地方法提取一个符号名称。然后,它在加载的共享对象库中查找该符号,如果找不到,它会给你一个错误。

根据我们在上一节中建立的内容,JVM 强制我们使用特定的符号名称来编写作为加载的共享对象文件一部分的函数。但我们在创建栈库时没有使用任何特定的约定。因此,JVM 将无法从栈库中找到我们公开的函数,我们必须想出另一种方法。通常,C 库是在没有任何假设会被用于 JVM 环境的情况下编写的。

图 21-1 展示了我们可以如何使用一个中间的 C 或 C++ 库作为 Java 部分和本地部分之间的粘合剂。我们给 JVM 它想要的符号,并将对代表这些符号的函数的调用委托给 C 库中的正确函数。这基本上就是 JNI 的工作方式。

我们将通过一个假设的例子来解释这一点。假设我们想从 Java 中调用一个 C 函数 func,该函数的定义可以在 libfunc.so 共享对象文件中找到。我们还在 Java 部分有一个名为 Clazz 的类,其中有一个名为 doFunc 的本地函数。我们知道 JVM 在尝试找到本地函数 doFunc 的定义时会查找符号 Java_Clazz_doFunc。我们创建一个中间共享对象库 libNativeLibrary.so,其中包含一个具有与 JVM 寻找的符号完全相同的函数。然后,在该函数内部,我们调用 func 函数。我们可以这样说,函数 Java_Clazz_doFunc 作为中继,将调用委托给底层的 C 库,最终是 func 函数。

fig10-1

图 21-1:中间共享对象库 libNativeStack.so,用于将函数调用从 Java 委托到实际的底层 C 栈库,libcstack.so。

为了与 JVM 符号名称保持一致,Java 编译器通常会将 Java 代码中找到的本地方法生成一个 C 头文件。这样,你只需要编写头文件中找到的函数的定义。这可以防止我们在符号名称上犯任何错误,这些错误最终会被 JVM 查找。

以下命令展示了如何编译一个 Java 源文件,以及如何请求编译器为其中找到的本地方法生成头文件。在这里,我们将编译我们唯一的 Java 文件 Main.java,它包含了之前代码框中引入的所有 Java 代码。请注意,在运行以下命令时,我们应该位于章节的根目录中:

$ cd java
$ mkdir -p build/headers
$ mkdir -p build/classes
$ javac -cp src -h build/headers -d build/classes \
src/com/packt/extreme_c/ch21/ex1/Main.java
$ tree build
build
├── classes
│   └── com
│       └── packt
│           └── extreme_c
│               └── ch21
│                   └── ex1
│                       ├── Main.class
│                       ├── Marshaller.class
│                       ├── NativeStack.class
│                       ├── Stack.class
│                       └── StringMarshaller.class
└── headers
    └── com_packt_extreme_c_ch21_ex1_NativeStack.h
7 directories, 6 files
$

Shell Box 21-11:编译 Main.java 并为文件中找到的本地方法生成头文件

如前述 shell box 所示,我们向 Java 编译器 javac 传递了选项 -h。我们还指定了一个目录,所有头文件都应该放在那里。tree 工具以树形格式显示了 build 目录的内容。注意 .class 文件。它们包含 Java 字节码,当将这些类加载到 JVM 实例时将使用这些字节码。

除了类文件外,我们还看到了一个头文件 com_packt_extreme_c_ch21_ex1_NativeStack.h,它包含了在 NativeStack 类中找到的本地方法的相应 C 函数声明。

如果你打开头文件,你会看到类似于 Code Box 21-13 的内容。它包含了许多具有长而奇怪的名称的函数声明,每个名称都由包名、类名和相应的本地方法名称组成:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_packt_extreme_c_ch21_ex1_NativeStack */
#ifndef _Included_com_packt_extreme_c_ch21_ex1_NativeStack
#define _Included_com_packt_extreme_c_ch21_ex1_NativeStack
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_packt_extreme_c_ch21_ex1_NativeStack
 * Method:    newStack
 * Signature: ()J
 */
JNIEXPORT jlong JNICALL Java_com_packt_extreme_1c_ch21_ex1_NativeStack_newStack
  (JNIEnv *, jclass);
/*
 * Class:     com_packt_extreme_c_ch21_ex1_NativeStack
 * Method:    deleteStack
 * Signature: (J)V
 */
JNIEXPORT void JNICALL Java_com_packt_extreme_1c_ch21_ex1_NativeStack_deleteStack
  (JNIEnv *, jclass, jlong);

...
...
...
#ifdef __cplusplus
}
#endif
#endif

Code Box 21-13:生成的 JNI 头文件的内容(不完整)

在前面的头文件中声明的函数携带了 JVM 在加载对应于本地方法的 C 函数时将寻找的符号名称。我们已经修改了前面的头文件,并使用宏使其紧凑,以便在更小的区域内放置所有函数声明。你可以在 Code Box 21-14 中看到它:

// Filename: NativeStack.h
// Description: Modified JNI generated header file
#include <jni.h>
#ifndef _Included_com_packt_extreme_c_ch21_ex1_NativeStack
#define _Included_com_packt_extreme_c_ch21_ex1_NativeStack
#define JNI_FUNC(n) Java_com_packt_extreme_1c_ch21_ex1_NativeStack_##
#ifdef __cplusplus
extern "C" {
#endif
JNIEXPORT jlong JNICALL JNI_FUNC(newStack)(JNIEnv* , jclass);
JNIEXPORT void JNICALL JNI_FUNC(deleteStack)(JNIEnv* , jclass, jlong);
JNIEXPORT void JNICALL JNI_FUNC(ctor)(JNIEnv* , jclass, jlong, jint);
JNIEXPORT void JNICALL JNI_FUNC(dtor)(JNIEnv* , jclass, jlong);
JNIEXPORT jint JNICALL JNI_FUNC(size)(JNIEnv* , jclass, jlong);
JNIEXPORT void JNICALL JNI_FUNC(push)(JNIEnv* , jclass, jlong, jbyteArray);
JNIEXPORT jbyteArray JNICALL JNI_FUNC(pop)(JNIEnv* , jclass, jlong);
JNIEXPORT void JNICALL JNI_FUNC(clear)(JNIEnv* , jclass, jlong);
#ifdef __cplusplus
}
#endif
#endif

Code Box 21-14 [java/native/NativeStack.h]:生成的 JNI 头文件的修改版本

正如你所见,我们创建了一个新的宏 JNI_FUNC,它提取了所有声明中通用的函数名的大部分。我们还移除了注释,以便使头文件更加紧凑。

我们将在头文件和随后的源文件中使用宏 JNI_FUNC,这些文件作为 Code Box 21-15 的一部分展示。

注意

修改生成的头文件不是一个被接受的行为。我们这样做是因为教育目的。在真实的构建环境中,我们希望直接使用生成的文件,而不做任何修改。

Code Box 21-15 中,你可以找到前面函数的定义。正如你所见,定义仅将调用传递给从 C 栈库包含的底层 C 函数:

#include <stdlib.h>
#include "NativeStack.h"
#include "cstack.h"
void defaultDeleter(value_t* value) {
  free_value(value);
}
void extractFromJByteArray(JNIEnv* env,
                           jbyteArray byteArray,
                           value_t* value) {
  jboolean isCopy = false;
  jbyte* buffer = env->GetByteArrayElements(byteArray, &isCopy);
  value->len = env->GetArrayLength(byteArray);
  value->data = (char*)malloc(value->len * sizeof(char));
  for (size_t i = 0; i < value->len; i++) {
    value->data[i] = buffer[i];
  }
  env->ReleaseByteArrayElements(byteArray, buffer, 0);
}
JNIEXPORT jlong JNICALL JNI_FUNC(newStack)(JNIEnv* env,
                                           jclass clazz) {
  return (long)cstack_new();
}
JNIEXPORT void JNICALL JNI_FUNC(deleteStack)(JNIEnv* env,
                                            jclass clazz,
                                            jlong stackPtr) {
  cstack_t* cstack = (cstack_t*)stackPtr;
  cstack_delete(cstack);
}
JNIEXPORT void JNICALL JNI_FUNC(ctor)(JNIEnv *env,
                                      jclass clazz,
                                      jlong stackPtr,
                                      jint maxSize) {
  cstack_t* cstack = (cstack_t*)stackPtr;
  cstack_ctor(cstack, maxSize);
}
JNIEXPORT void JNICALL JNI_FUNC(dtor)(JNIEnv* env,
                                      jclass clazz,
                                      jlong stackPtr) {
  cstack_t* cstack = (cstack_t*)stackPtr;
  cstack_dtor(cstack, defaultDeleter);
}
JNIEXPORT jint JNICALL JNI_FUNC(size)(JNIEnv* env,
                                      jclass clazz,
                                      jlong stackPtr) {
  cstack_t* cstack = (cstack_t*)stackPtr;
  return cstack_size(cstack);
}
JNIEXPORT void JNICALL JNI_FUNC(push)(JNIEnv* env,
                                      jclass clazz,
                                      jlong stackPtr,
                                      jbyteArray item) {
  value_t value;
  extractFromJByteArray(env, item, &value);
  cstack_t* cstack = (cstack_t*)stackPtr;
  bool_t pushed = cstack_push(cstack, value);
  if (!pushed) {
    jclass Exception = env->FindClass("java/lang/Exception");
    env->ThrowNew(Exception, "Stack is full!");
  }
}
JNIEXPORT jbyteArray JNICALL JNI_FUNC(pop)(JNIEnv* env,
                                           jclass clazz,
                                           jlong stackPtr) {
  value_t value;
  cstack_t* cstack = (cstack_t*)stackPtr;
  bool_t popped = cstack_pop(cstack, &value);
  if (!popped) {
    jclass Exception = env->FindClass("java/lang/Exception");
    env->ThrowNew(Exception, "Stack is empty!");
  }
  jbyteArray result = env->NewByteArray(value.len);
  env->SetByteArrayRegion(result, 0,
          value.len, (jbyte*)value.data);
  defaultDeleter(&value);
  return result;
}
JNIEXPORT void JNICALL JNI_FUNC(clear)(JNIEnv* env,
                                       jclass clazz,
                                       jlong stackPtr) {
  cstack_t* cstack = (cstack_t*)stackPtr;
  cstack_clear(cstack, defaultDeleter);
}

Code Box 21-15 [java/native/NativeStack.cpp]:JNI 头文件中声明的函数的定义

上一段代码是用 C++ 编写的。也可以用 C 语言编写定义。唯一需要关注的是在 push 和 pop 函数中发生的从 C 字节数组到 Java 字节数组的转换。已经添加了 extractFromJByteArray 函数,用于根据从 Java 部分接收到的 Java 字节数组创建一个 C 字节数组。

以下命令在 Linux 中创建中间共享对象 libNativeStack.so,它将被 JVM 加载和使用。注意,在运行以下命令之前,您需要设置环境变量 JAVA_HOME

$ cd java/native
$ g++ -c -fPIC -I$PWD/../.. -I$JAVA_HOME/include \
 -I$JAVA_HOME/include/linux NativeStack.cpp -o NativeStack.o
$ g++ -shared -L$PWD/../.. NativeStack.o -lcstack -o libNativeStack.so
$

Shell Box 21-12: 构建中间共享对象库 libNativeStack.so

如您所见,最终的共享对象文件链接到 C 堆栈库的共享对象文件 libcstack.so,这仅仅意味着 libNativeStack.so 必须加载 libcstack.so 才能工作。因此,JVM 加载 libNativeStack.so 库,然后加载 libcstack.so 库,最终 Java 部分和本地部分可以合作,使 Java 程序得以执行。

以下命令运行 代码框 21-12 中显示的测试场景:

$ cd java
$ LD_LIBRARY_PATH=$PWD/.. java -Djava.library.path=$PWD/native \
  -cp build/classes com.packt.extreme_c.ch21.ex1.Main
Size after pushes: 3
!
World
Hello
Size after pops: 0
Size after before clear: 2
Size after clear: 0
$

Shell Box 21-13: 运行 Java 测试场景

如您所见,我们已将选项 -Djava.library.path=... 传递给 JVM。它指定了共享对象库可以找到的位置。如您所见,我们已指定应包含 libNativeStack.so 共享对象库的目录。

在本节中,我们展示了如何将本地 C 库加载到 JVM 中,并与其他 Java 源代码一起使用。相同的机制也可以用于加载更大的多部分本地库。

现在,是时候通过 Python 集成来了解如何从 Python 代码中使用 C 堆栈库了。

与 Python 集成

Python 是一种 解释型 编程语言。这意味着 Python 代码是由一个称为 解释器 的中间程序读取和运行的。如果我们打算使用外部本地共享库,那么是解释器加载共享库并将其提供给 Python 代码。Python 有一个用于加载外部共享库的特殊框架。它被称为 ctypes,我们将在本节中使用它。

使用 ctypes 加载共享库非常简单。它只需要加载库并定义将要使用的函数的输入和输出。以下类封装了 ctypes 相关逻辑,并将其提供给我们的主 Stack 类,如即将显示的代码框所示:

from ctypes import *
class value_t(Structure):
  _fields_ = [("data", c_char_p), ("len", c_int)]
class _NativeStack:
  def __init__(self):
    self.stackLib = cdll.LoadLibrary(
            "libcstack.dylib" if platform.system() == 'Darwin'
            else "libcstack.so")
    # value_t make_value(char*, size_t)
    self._makevalue_ = self.stackLib.make_value
    self._makevalue_.argtypes = [c_char_p, c_int]
    self._makevalue_.restype = value_t
    # value_t copy_value(char*, size_t)
    self._copyvalue_ = self.stackLib.copy_value
    self._copyvalue_.argtypes = [c_char_p, c_int]
    self._copyvalue_.restype = value_t
    # void free_value(value_t*)
    self._freevalue_ = self.stackLib.free_value
    self._freevalue_.argtypes = [POINTER(value_t)]
    # cstack_t* cstack_new()
    self._new_ = self.stackLib.cstack_new
    self._new_.argtypes = []
    self._new_.restype = c_void_p
    # void cstack_delete(cstack_t*)
    self._delete_ = self.stackLib.cstack_delete
    self._delete_.argtypes = [c_void_p]
    # void cstack_ctor(cstack_t*, int)
    self._ctor_ = self.stackLib.cstack_ctor
    self._ctor_.argtypes = [c_void_p, c_int]
    # void cstack_dtor(cstack_t*, deleter_t)
    self._dtor_ = self.stackLib.cstack_dtor
    self._dtor_.argtypes = [c_void_p, c_void_p]
    # size_t cstack_size(cstack_t*)
    self._size_ = self.stackLib.cstack_size
    self._size_.argtypes = [c_void_p]
    self._size_.restype = c_int
    # bool_t cstack_push(cstack_t*, value_t)
    self._push_ = self.stackLib.cstack_push
    self._push_.argtypes = [c_void_p, value_t]
    self._push_.restype = c_int
    # bool_t cstack_pop(cstack_t*, value_t*)
    self._pop_ = self.stackLib.cstack_pop
    self._pop_.argtypes = [c_void_p, POINTER(value_t)]
    self._pop_.restype = c_int
    # void cstack_clear(cstack_t*, deleter_t)
    self._clear_ = self.stackLib.cstack_clear
    self._clear_.argtypes = [c_void_p, c_void_p]

代码框 21-17 [python/stack.py]: 使堆栈库的 C 函数对 Python 的其余部分可用的 ctypes 相关代码

如你所见,所有需要在我们的 Python 代码中使用的函数都被放在了类定义中。C 函数的句柄存储在类的实例的私有字段中(私有字段在两边都有 _),并且可以用来调用底层的 C 函数。请注意,在上面的代码中,我们加载了 libcstack.dylib,因为我们是在 macOS 系统上。而对于 Linux 系统,我们需要加载 libcstack.so

下面的类是主要的 Python 组件,它使用了上面的包装类。所有其他的 Python 代码都使用这个类来获得栈功能:

class Stack:
  def __enter__(self):
    self._nativeApi_ = _NativeStack()
    self._handler_ = self._nativeApi_._new_()
    self._nativeApi_._ctor_(self._handler_, 100)
    return self
  def __exit__(self, type, value, traceback):
    self._nativeApi_._dtor_(self._handler_, self._nativeApi_._freevalue_)
    self._nativeApi_._delete_(self._handler_)
  def size(self):
    return self._nativeApi_._size_(self._handler_)
  def push(self, item):
    result = self._nativeApi_._push_(self._handler_,
            self._nativeApi_._copyvalue_(item.encode('utf-8'), len(item)));
    if result != 1:
      raise Exception("Stack is full!")
  def pop(self):
    value = value_t()
    result = self._nativeApi_._pop_(self._handler_, byref(value))
    if result != 1:
      raise Exception("Stack is empty!")
    item = string_at(value.data, value.len)
    self._nativeApi_._freevalue_(value)
    return item
  def clear(self):
    self._nativeApi_._clear_(self._handler_, self._nativeApi_._freevalue_)

代码框 21-16 [python/stack.py]:使用从栈库加载的 C 函数的 Python 中的 Stack

如你所见,Stack 类保持对 _NativeStack 类的引用,以便能够调用底层的 C 函数。请注意,前面的类覆盖了 __enter____exit__ 函数。这使得该类可以用作资源类,并在 Python 的 with 语法中使用。你很快就会看到这种语法的样子。请注意,前面的 Stack 类仅对字符串项进行操作。

以下是一个测试场景,它与 Java 和 C++ 的测试场景非常相似:

if __name__ == "__main__":
  with Stack() as stack:
    stack.push("Hello")
    stack.push("World")
    stack.push("!")
    print("Size after pushes:" + str(stack.size()))
    while stack.size() > 0:
      print(stack.pop())
    print("Size after pops:" + str(stack.size()))
    stack.push("Ba");
    stack.push("Bye!");
    print("Size before clear:" + str(stack.size()))
    stack.clear()
    print("Size after clear:" + str(stack.size()))

代码框 21-18 [python/stack.py]:使用 Stack 类编写的 Python 测试场景

在前面的代码中,你可以看到 Python 的 with 语句。

当进入 with 块时,会调用 __enter__ 函数,并通过 stack 变量引用 Stack 类的实例。当离开 with 块时,会调用 __exit__ 函数。这给了我们机会在不需要底层原生资源(在这种情况下是 C 栈对象)时释放它们。

接下来,你可以看到如何运行前面的代码。请注意,所有的 Python 代码框都存在于同一个名为 stack.py 的文件中。在运行以下命令之前,你需要位于章节的根目录中:

$ cd python
$ LD_LIBRARY_PATH=$PWD/.. python stack.py
Size after pushes:3
!
World
Hello
Size after pops:0
Size before clear:2
Size after clear:0
$

Shell 框 21-14:运行 Python 测试场景

注意,解释器应该能够找到并加载 C 栈共享库;因此,我们将 LD_LIBRARY_PATH 环境变量设置为指向包含实际共享库文件的目录。

在下一节中,我们将展示如何在 Go 语言中加载和使用 C 栈库。

与 Go 的集成

Go 编程语言(或简称 Golang)与本地共享库的集成非常容易。它可以被认为是 C 和 C++ 编程语言的下一代,并且它将自己称为系统编程语言。因此,当我们使用 Golang 时,我们期望能够轻松地加载和使用本地库。

在 Golang 中,我们使用一个名为 cgo 的内置包来调用 C 代码和加载共享对象文件。在下面的 Go 代码中,你可以看到如何使用 cgo 包,并使用它来调用从 C 栈库文件加载的 C 函数。它还定义了一个新的类,Stack,该类被其他 Go 代码用来使用 C 栈功能:

package main
/*
#cgo CFLAGS: -I..
#cgo LDFLAGS: -L.. -lcstack
#include "cstack.h"
*/
import "C"
import (
  "fmt"
)
type Stack struct {
  handler *C.cstack_t
}
func NewStack() *Stack {
  s := new(Stack)
  s.handler = C.cstack_new()
  C.cstack_ctor(s.handler, 100)
  return s
}
func (s *Stack) Destroy() {
  C.cstack_dtor(s.handler, C.deleter_t(C.free_value))
  C.cstack_delete(s.handler)
}
func (s *Stack) Size() int {
  return int(C.cstack_size(s.handler))
}
func (s *Stack) Push(item string) bool {
  value := C.make_value(C.CString(item), C.ulong(len(item) + 1))
  pushed := C.cstack_push(s.handler, value)
  return pushed == 1
}
func (s *Stack) Pop() (bool, string) {
  value := C.make_value(nil, 0)
  popped := C.cstack_pop(s.handler, &value)
  str := C.GoString(value.data)
  defer C.free_value(&value)
  return popped == 1, str
}
func (s *Stack) Clear() {
  C.cstack_clear(s.handler, C.deleter_t(C.free_value))
}

Code Box 21-19 [go/stack.go]:使用加载的 libcstack.so 共享对象文件的 Stack 类

为了使用 cgo 包,需要导入C包。它加载在伪#cgo指令中指定的共享对象库。正如你所看到的,我们指定了libcstack.so库作为指令#cgo LDFLAGS: -L.. -lcstack的一部分。请注意,CFLAGSLDFLAGS包含直接传递给 C 编译器和链接器的标志。

我们还指出了应该搜索共享对象文件的路径。之后,我们可以使用C结构体来调用加载的本地函数。例如,我们使用了C.cstack_new()来调用栈库中的相应函数。使用 cgo 非常简单。请注意,前面的Stack类仅适用于字符串项。

以下代码展示了用 Golang 编写的测试场景。请注意,当退出main函数时,我们必须在stack对象上调用Destroy函数:

func main() {
  var stack = NewStack()
  stack.Push("Hello")
  stack.Push("World")
  stack.Push("!")
  fmt.Println("Stack size:", stack.Size())
  for stack.Size() > 0 {
    _, str := stack.Pop()
    fmt.Println("Popped >", str)
  }
  fmt.Println("Stack size after pops:", stack.Size())
  stack.Push("Bye")
  stack.Push("Bye")
  fmt.Println("Stack size before clear:", stack.Size())
  stack.Clear()
  fmt.Println("Stack size after clear:", stack.Size())
  stack.Destroy()
}

Code Box 21-20 [go/stack.go]:使用 Stack 类的 Go 测试场景

以下 shell box 演示了如何构建和运行测试场景:

$ cd go
$ go build -o stack.out stack.go
$ LD_LIBRARY_PATH=$PWD/.. ./stack.out
Stack size: 3
Popped > !
Popped > World
Popped > Hello
Stack size after pops: 0
Stack size before clear: 2
Stack size after clear: 0
$

Shell Box 21-15:运行 Go 测试场景

正如你在 Golang 中看到的,与 Python 不同,你需要首先编译你的程序,然后运行它。此外,我们仍然需要设置LD_LIBRARY_PATH环境变量,以便允许可执行文件定位libcstack.so库并将其加载。

在本节中,我们展示了如何使用 Golang 中的cgo包加载和使用共享对象库。由于 Golang 类似于 C 代码的薄包装器,因此它比使用 Python 和 Java 加载外部共享对象库并使用它更容易。

摘要

在本章中,我们介绍了 C 语言与其他编程语言的集成。作为本章的一部分:

  • 我们设计了一个 C 库,该库暴露了一些栈功能,例如 push、pop 等。我们构建了库,并最终生成了一个共享对象库,供其他语言使用。

  • 我们讨论了 C++中的名称混淆功能,以及我们在使用 C++编译器时应该如何避免在 C 中使用它。

  • 我们编写了一个围绕栈库的 C++包装器,该包装器可以加载库的共享对象文件并在 C++中执行加载的功能。

  • 我们继续编写了一个围绕 C 库的 JNI 包装器。我们使用了本地方法来实现这一点。

  • 我们展示了如何使用 JNI 编写本地代码,并将本地部分和 Java 部分连接起来,最终运行一个使用 C 栈库的 Java 程序。

  • 我们成功地编写了使用 ctypes 包加载和使用库的共享对象文件的 Python 代码。

  • 作为最后一部分,我们用 Golang 编写了一个程序,该程序可以在cgo包的帮助下加载库的共享对象文件。

下一章将介绍 C 语言中的单元测试和调试。我们将介绍一些用于编写单元测试的 C 语言库。不仅如此,我们还将讨论 C 语言的调试,以及一些可用于调试或监控程序的现有工具。

第二十二章

单元测试和调试

实际上,您使用哪种编程语言或开发什么类型的应用程序并不重要,在交付给客户之前彻底测试它始终很重要。

编写测试并不是一件新鲜事,截至今天,您几乎可以在每个软件项目中找到数百甚至数千个测试。如今,编写软件测试是必须的,没有经过适当测试的代码或功能被强烈反对。这就是为什么我们有一个专门的章节来讨论用 C 编写的软件测试,以及今天存在用于此目的的各种库。

然而,本章不仅仅讨论测试。我们还将讨论可用于调试 C 程序的调试工具和技术。测试和调试从一开始就相互补充,每当测试失败时,都会进行一系列调查,调试目标代码是常见的后续行动。

在本章中,我们不会讲解测试的哲学,而是假设测试是好的。相反,我们将为您提供一个关于基本术语和开发者在编写可测试代码时应遵循的指南的简要介绍。

本章分为两部分。第一部分,我们讨论测试和可用于现代 C 开发的现有库。本章的第二部分将讨论调试,从讨论各种类型的错误开始。内存问题、并发问题和性能问题是似乎需要进一步调试以建立成功调查的最常见情况。

我们还将涵盖适用于 C(和 C++)的最常用的调试工具。本章的最终目标是让您了解 C 和调试工具,并为您提供一些基本背景知识。

第一部分向您介绍了软件测试的基本术语。它不仅限于 C,这些思想和概念也可以应用于其他编程语言和技术。

软件测试

软件测试是计算机编程中的一个庞大且重要的主题,它有自己的特定术语和许多概念。在本节中,我们将为您提供一个关于软件测试的非常基础的介绍。我们的目的是定义一些我们将在本章前半部分使用的术语。因此,您应该意识到这不是一个关于测试的详尽章节,强烈建议进一步学习。

当谈到测试软件时,首先想到的问题是,我们在测试什么,这次测试是关于什么的?一般来说,我们测试软件系统的一个方面。这个方面可以是 功能非功能 的。换句话说,这个方面可能与系统的某个功能相关,或者当执行功能时,可能与系统的某个变量相关。接下来,我们给出一些例子。

功能测试 是关于测试作为 功能需求 部分请求的特定功能。这些测试向一个 软件元素,例如一个 函数、一个 模块、一个 组件 或一个 软件系统,提供一定的输入,并期望从它们那里获得一定的输出。只有当期望的输出被视为测试的一部分时,该测试才被认为是 通过 的。

非功能测试 是关于软件元素,例如一个函数、一个模块、一个组件或整个软件系统,完成特定功能的质量水平。这些测试通常旨在 测量 各种 变量,如 内存使用完成时间锁竞争安全性级别,并评估该元素完成其工作的程度。只有当测量的变量在预期的范围内时,测试才被认为是 通过 的。这些变量的 预期值 来自为系统定义的 非功能需求

除了功能和非功能测试之外,我们还可以有不同的 测试级别。这些级别的设计方式是为了覆盖一些正交方面。这些方面中的一些是测试元素的大小、测试的参与者以及应该测试的功能范围的广度。

例如,就元素的大小而言,这些级别是从可能的最小功能块定义的,我们称之为函数(或方法),到从整个软件系统暴露出的可能最大的功能块。

在以下部分,我们将更深入地介绍这些级别。

测试级别

对于每个软件系统,可以考虑和计划以下测试级别。这些并不是唯一的测试级别,你可以在其他参考资料中找到更多:

  • 单元测试

  • 集成测试

  • 系统测试

  • 接受测试

  • 回归测试

单元测试 中,我们测试一个 功能单元。这个单元可以是一个执行特定任务的函数,或者是一组函数组合起来满足需求,或者是一个有最终目标执行特定功能的类,甚至是一个有特定任务要完成的组件。一个 组件 是软件系统的一部分,它有一组定义良好的功能,并且与其他组件一起结合,成为整个软件系统。

在组件作为单元的情况下,我们将测试过程称为组件测试。功能和非功能测试都可以在单元层面进行。在测试单元时,该单元应与其周围单元隔离,为此,周围环境应以某种方式模拟。这个层面将是本章涵盖的唯一层面,我们提供实际代码来演示如何在 C 中进行单元测试和组件测试。

当单元组合在一起时,它们形成一个组件。在组件测试中,组件单独进行测试。但当我们将这些组件分组时,我们需要不同层面的测试来检查该特定组件组的函数性或变量。这个层面称为集成测试。正如其名所示,这个层面的测试检查某些组件的集成是否良好,并且它们一起仍然满足系统定义的要求。

在不同层面,我们测试整个系统的功能。这将包含所有完全集成的组件的完整集合。这样,我们测试暴露的系统功能性和系统变量是否与为软件系统定义的要求一致。

在不同层面,我们评估一个软件系统是否与从利益相关者最终用户角度定义的业务需求一致。这个层面被称为验收测试。虽然系统测试和验收测试都是关于整个软件系统,但它们实际上相当不同。以下是一些差异:

  • 系统测试由开发人员和测试人员执行,但验收测试通常由最终用户或利益相关者执行。

  • 系统测试是关于检查功能和非功能需求,但验收测试只关注功能需求。

  • 在系统测试中,我们通常使用准备好的小数据集作为输入,但在验收测试中,实际实时数据被输入到系统中。

一个很好的链接,解释了所有这些差异,可以在www.javatpoint.com/acceptance-testing 找到。

当向软件系统引入变更时,需要检查当前的功能和非功能测试是否仍然处于良好状态。这在不同层面进行,称为回归测试。回归测试的目的是确认在引入变更后没有发生回归。作为回归测试的一部分,所有作为单元测试、集成测试和端到端(系统)测试发现的测试都会再次运行,以查看是否有任何测试在变更后失败。

在本节中,我们介绍了各种测试级别。在本章的其余部分,我们将讨论单元测试。在接下来的部分,我们将通过给出一个 C 示例并尝试为其编写测试用例来开始讨论它。

单元测试

如我们在上一节中解释的,作为单元测试的一部分,我们测试独立的单元,一个单元可以小到函数,也可以大到组件。在 C 中,它可以是一个函数,也可以是整个用 C 编写的组件。同样的讨论也适用于 C++,但在那里我们可以有其他单元,如类。

单元测试最重要的地方是单元应该单独进行测试。例如,如果目标函数依赖于另一个函数的输出,我们需要找到一种方法来单独测试目标函数。我们将通过一个真实示例来解释这一点。

示例 22.1 打印小于 10 的偶数的阶乘,但不是以通常的方式。代码在一个头文件和两个源文件中组织得很好。本例涉及两个函数;其中一个函数生成小于 10 的偶数,另一个函数接收一个函数指针并将其用作读取整数数的源,并最终计算其阶乘。

以下代码框包含包含函数声明的头文件:

#ifndef _EXTREME_C_EXAMPLE_22_1_
#define _EXTREME_C_EXAMPLE_22_1_
#include <stdint.h>
#include <unistd.h>
typedef int64_t (*int64_feed_t)();
int64_t next_even_number();
int64_t calc_factorial(int64_feed_t feed);
#endif

Code Box 22-1 [ExtremeC_examples_chapter22_1.h]:示例 22.1 的头文件

如你所见,calc_factorial 函数接受一个返回整数的函数指针。它将使用该函数指针来读取一个整数并计算其阶乘。以下代码是前面函数的定义:

#include "ExtremeC_examples_chapter22_1.h"
int64_t next_even_number() {
  static int feed = -2;
  feed += 2;
  if (feed >= 10) {
    feed = 0;
  }
  return feed;
}
int64_t calc_factorial(int64_feed_t feed) {
  int64_t fact = 1;
  int64_t number = feed();
  for (int64_t i = 1; i <= number; i++) {
    fact *= i;
  }
  return fact;
}

Code Box 22-2 [ExtremeC_examples_chapter22_1.c]:示例 22.1 中使用的函数的定义

next_even_number 函数有一个内部静态变量,它作为调用函数的输入。请注意,它永远不会超过 8,之后它回到 0。因此,你可以简单地多次调用这个函数,而永远不会得到一个大于 8 且小于零的数字。以下代码框包含包含 main 函数的源文件的内容:

#include <stdio.h>
#include "ExtremeC_examples_chapter22_1.h"
int main(int argc, char** argv) {
  for (size_t i = 1; i <= 12; i++) {
    printf("%lu\n", calc_factorial(next_even_number));
  }
  return 0;
}

Code Box 22-3 [ExtremeC_examples_chapter22_1_main.c]:示例 22.1 的主函数

如你所见,main 函数调用了 calc_function 12 次,并打印了返回的阶乘。为了运行前面的示例,你需要首先编译这两个源文件,然后将它们相应的可重定位目标文件链接在一起。以下 shell box 包含构建和运行示例所需的命令:

$ gcc -c ExtremeC_examples_chapter22_1.c -o impl.o
$ gcc -c ExtremeC_examples_chapter22_1_main.c -o main.o
$ gcc impl.o main.o -o ex22_1.out
$ ./ex22_1.out
1
2
24
720
40320
1
2
24
720
40320
1
2
$

Shell Box 22-1:构建和运行示例 22.1

为了编写前面函数的测试,我们首先需要做一些介绍。正如你所见,示例中有两个函数(不包括main函数)。因此,存在两个不同的单元,在这种情况下是函数,应该分别且独立于彼此进行测试;一个是next_even_number函数,另一个是calc_factorial函数。但是,正如主函数中所示,calc_factorial函数依赖于next_even_number函数,有人可能会认为这种依赖会使calc_factorial函数的隔离比我们预期的要困难得多。但这并不是真的。

事实上,calc_factorial函数根本不依赖于next_even_number函数。它只依赖于next_even_number签名,而不是其定义。因此,我们可以用一个遵循相同签名的函数来替换next_even_number,但总是返回一个固定的整数。换句话说,我们可以提供一个简化的next_even_number版本,这个版本仅打算在测试用例中使用。

那么,什么是测试用例呢?正如你所知,有各种场景可以用来测试特定的单元。最简单的例子是向某个单元提供各种输入并期望得到预定的输出。在先前的例子中,我们可以为calc_factorial函数提供0作为输入,并等待其输出为1。我们也可以提供-1并等待其输出为1

这些场景中的每一个都可以成为一个测试用例。因此,针对单个单元,我们可以有多个测试用例,以解决该单元的所有不同边界情况。测试用例的集合称为测试套件。测试套件中找到的所有测试用例不一定与同一个单元相关。

我们首先为next_even_number函数创建一个测试套件。由于next_even_number可以很容易地独立测试,因此不需要额外的工作。以下是为next_even_number函数编写的测试用例:

#include <assert.h>
#include "ExtremeC_examples_chapter22_1.h"
void TESTCASE_next_even_number__even_numbers_should_be_returned() {
  assert(next_even_number() == 0);
  assert(next_even_number() == 2);
  assert(next_even_number() == 4);
  assert(next_even_number() == 6);
  assert(next_even_number() == 8);
}
void TESTCASE_next_even_number__numbers_should_rotate() {
  int64_t number = next_even_number();
  next_even_number();
  next_even_number();
  next_even_number();
  next_even_number();
  int64_t number2 = next_even_number();
  assert(number == number2);
}

代码框 22-4 [ExtremeC_examples_chapter22_1 __next_even_number__tests.c]:为next_even_number函数编写的测试用例

如你所见,我们在先前的测试套件中定义了两个测试用例。请注意,我使用了自己的约定来命名上述测试用例;然而,这并没有标准。整个命名测试用例的目的是从其名称中了解测试用例的作用,更重要的是,当测试用例失败或需要修改时,可以在代码中轻松找到它。

我使用大写TESTCASE作为函数名称的前缀,以便与其它普通函数区分开来。函数的名称也试图描述测试用例及其所关注的问题。

两个测试用例都在最后使用了 assert。这是所有测试用例函数在评估期望时都会做的事情。如果 assert 括号内的条件不成立,测试运行器,一个正在运行测试的程序,会退出并打印错误信息。不仅如此,测试运行器还会返回一个非零的 退出码,这表明一个或多个测试用例失败了。当所有测试都成功时,测试运行器程序必须返回 0。

很好,你可以自己走一遍测试用例,尝试理解它们是如何通过调用前面两个场景中的 next_even_number 函数来评估我们的期望的。

现在,是时候为 calc_factorial 函数编写测试用例了。为 calc_factorial 函数编写测试用例需要一个 存根函数 作为其输入。我们简要解释一下存根是什么。

以下是三个仅测试 calc_factorial 单元的测试用例:

#include <assert.h>
#include "ExtremeC_examples_chapter22_1.h"
int64_t input_value = -1;
int64_t feed_stub() {
  return input_value;
}
void TESTCASE_calc_factorial__fact_of_zero_is_one() {
  input_value = 0;
  int64_t fact = calc_factorial(feed_stub);
  assert(fact == 1);
}
void TESTCASE_calc_factorial__fact_of_negative_is_one() {
  input_value = -10;
  int64_t fact = calc_factorial(feed_stub);
  assert(fact == 1);
}
void TESTCASE_calc_factorial__fact_of_5_is_120() {
  input_value = 5;
  int64_t fact = calc_factorial(feed_stub);
  assert(fact == 120);
}

代码框 22-5 [ExtremeC_examples_chapter22_1 __calc_factorial__tests.c]:为 calc_factorial 函数编写的测试用例

如你所见,我们为 calc_factorial 函数定义了三个测试用例。注意 feed_stub 函数。它遵循与 next_even_number 相同的契约,如 代码框 22-2 所示,但它有一个非常简单的定义。它只是返回存储在静态变量 input_value 中的值。这个变量可以在调用 calc_factorial 函数之前由测试用例设置。

使用前面的存根函数,我们可以隔离 calc_factorial 并单独测试它。同样的方法也适用于 C++ 或 Java 这样的面向对象编程语言,但我们在那里定义 存根类存根对象

在 C 语言中,存根 是一个符合目标单元逻辑中使用的函数声明的函数定义,更重要的是,存根没有复杂的逻辑,它只是返回一个将被测试用例使用的值。

在 C++ 中,存根仍然可以是一个符合函数声明的函数定义,或者是一个实现接口的类。在其他无法有独立函数的对象导向语言中,例如 Java,存根只能是一个实现接口的类。然后,存根对象是从这样的存根类中创建的对象。请注意,在所有情况下,存根都应该有一个简单的定义,仅适用于测试,而不适用于生产。

最后,我们需要能够运行测试用例。正如我们之前所说的,我们需要一个测试运行器来运行测试。因此,我们需要一个包含 main 函数的特定源文件,该函数只依次运行测试用例。下面的代码框包含了测试运行器的代码:

#include <stdio.h>
void TESTCASE_next_even_number__even_numbers_should_be_returned();
void TESTCASE_next_even_number__numbers_should_rotate();
void TESTCASE_calc_factorial__fact_of_zero_is_one();
void TESTCASE_calc_factorial__fact_of_negative_is_one();
void TESTCASE_calc_factorial__fact_of_5_is_120();
int main(int argc, char** argv) {
  TESTCASE_next_even_number__even_numbers_should_be_returned();
  TESTCASE_next_even_number__numbers_should_rotate();
  TESTCASE_calc_factorial__fact_of_zero_is_one();
  TESTCASE_calc_factorial__fact_of_negative_is_one();
  TESTCASE_calc_factorial__fact_of_5_is_120();
  printf("All tests are run successfully.\n");
  return 0;
}

代码框 22-6 [ExtremeC_examples_chapter22_1 _tests.c]:示例 22.1 中使用的测试运行器

上述代码仅在main函数中的所有测试用例都成功执行时返回0。为了构建测试运行器,我们需要运行以下命令。注意-g选项,它将调试符号添加到最终的测试运行器可执行文件中。进行调试构建是构建测试的最常见方式,因为如果测试用例失败,我们立即需要精确的堆栈跟踪和进一步的调试信息以继续调查。更重要的是,assert语句通常从发布构建中删除,但我们需要在测试运行器可执行文件中保留它们:

$ gcc -g -c ExtremeC_examples_chapter22_1.c -o impl.o
$ gcc -g -c ExtremeC_examples_chapter22_1__next_even_number__tests.c -o tests1.o
$ gcc -g -c ExtremeC_examples_chapter22_1__calc_factorial__tests.c -o tests2.o
$ gcc -g -c ExtremeC_examples_chapter22_1_tests.c -o main.o
$ gcc impl.o tests1.o tests2.o main.o -o ex22_1_tests.out
$ ./ex22_1_tests.out
All tests are run successfully.
$ echo $?
0
$

Shell Box 22-2:构建和运行示例 22.1 的测试运行器

前面的 shell 框显示所有测试都已通过。您也可以通过使用echo $?命令来检查测试运行进程的退出代码,并看到它已返回零。

现在,通过在其中一个函数中应用简单的更改,我们可以使测试失败。让我们看看当我们按照以下方式更改calc_factorial时会发生什么:

int64_t calc_factorial(int64_feed_t feed) {
  int64_t fact = 1;
  int64_t number = feed();
  for (int64_t i = 1; i <= (number + 1); i++) {
    fact *= i;
  }
  return fact;
}

代码框 22-7:将calc_factorial函数更改为使测试失败

通过前面的更改,以粗体显示,关于0和负输入的测试用例仍然通过,但最后一个测试用例,即关于计算5的阶乘的测试用例失败了。我们将再次构建测试运行器,以下是在 macOS 机器上执行的输出:

$ gcc -g -c ExtremeC_examples_chapter22_1.c -o impl.o
$ gcc -g -c ExtremeC_examples_chapter22_1_tests.c -o main.o
$ ./ex22_1_tests.out
Assertion failed: (fact == 120), function TESTCASE_calc_factorial__fact_of_5_is_120, 
file .../22.1/ExtremeC_examples_chapter22_1__calc_factorial__tests.c, line 29.
Abort trap: 6
$ echo $?
134
$

Shell Box 22-3:更改calc_factorial函数后构建和运行测试运行器

如您所见,输出中出现了Assertion failed,退出代码为134。这个退出代码通常由定期运行测试的系统使用和报告,例如Jenkins,以检查测试是否成功运行。

作为一项经验法则,每当您有一个需要独立测试的单元时,您需要找到一种方法来提供其依赖项作为某种输入。因此,单元本身应该以使其可测试的方式编写。并非所有代码都是可测试的,可测试性不仅限于单元测试,这一点非常重要,需要意识到。此链接提供了有关如何编写可测试代码的良好信息:blog.gurock.com/highly-testable-code/

为了澄清上述讨论,假设我们已经像下面这样编写了calc_factorial函数,直接使用next_even_number函数而不是使用函数指针。请注意,在以下代码框中,函数不接收函数指针参数,并且它直接调用next_even_number函数:

int64_t calc_factorial() {
  int64_t fact = 1;
  int64_t number = next_even_number();
  for (int64_t i = 1; i <= number; i++) {
    fact *= i;
  }
  return fact;
}

代码框 22-8:将calc_factorial函数的签名更改为不接受函数指针

上述代码的可测试性较低。没有方法在不调用next_even_number的情况下测试calc_factorial——也就是说,没有使用一些技巧来更改最终可执行文件中符号next_even_number背后的定义,就像我们在示例 22.2中所做的那样。

事实上,calc_factorial的两个版本都做了同样的事情,但代码框 22-2中的定义更易于测试,因为我们可以在隔离的情况下对其进行测试。编写可测试的代码并不容易,你应该始终仔细思考,以便实现代码并使其可测试。

编写可测试的代码通常需要更多的工作。关于编写可测试代码的额外开销百分比存在各种观点,但可以肯定的是,编写测试确实会在时间和精力上带来一些额外的成本。但这种额外的成本确实带来了巨大的好处。如果没有为单元编写测试,随着时间的推移和单元中引入的更多更改,你将失去对它的跟踪。

测试双胞胎

在前面的例子中,在编写测试用例时,我们引入了存根函数。还有一些其他术语是关于试图模仿单元依赖的对象。这些对象被称为测试双胞胎。接下来,我们将介绍另外两种测试双胞胎:模拟伪造函数。首先,让我们再次简要解释一下存根函数是什么。

在这个简短的部分中,请注意两点。首先,关于这些测试双胞胎的定义永远存在争论,我们试图给出一个符合本章使用的适当定义。其次,我们只将讨论与 C 语言相关的内容,因此没有对象,我们有的都是函数。

当一个单元依赖于另一个函数时,它只是依赖于该函数的签名,因此该函数可以被一个新的函数所替代。这个新函数,基于它可能具有的一些属性,可以被称作存根、模拟或伪造函数。这些函数只是编写来满足测试要求,它们不能在生产环境中使用。

我们将存根(stub)解释为一个非常简单的函数,通常只是返回一个常量值。正如你在示例 22.1中看到的,它间接地返回了由正在运行的测试用例设置的值。在以下链接中,你可以了解更多关于我们正在讨论的测试双胞胎以及一些其他的:en.wikipedia.org/wiki/Test_double。如果你打开链接,存根被定义为向测试代码提供间接输入的东西。如果你接受这个定义,代码框 22-5中看到的feed_stub函数就是一个存根函数。

模拟函数,或者更普遍地说,作为面向对象语言的一部分的模拟对象,可以通过指定某个输入的输出来进行操作。这样,在运行测试逻辑之前,你可以设置模拟函数对于某个输入应该返回的内容,在逻辑运行期间,它将按照你事先设置的方式行动。一般来说,模拟对象也可以有期望,并且它们将相应地执行所需的断言。正如前一个链接中所述,对于模拟对象,我们在运行测试之前设置期望。我们将在组件测试部分给出一个 C 语言的模拟函数示例。

最后,可以使用一个假函数来为运行测试中的真实且可能复杂的函数提供非常简化的功能。例如,而不是使用真实的文件系统,可以使用一些简化的内存存储。在组件测试中,例如,具有复杂功能的其他组件可以在测试中用假实现替换。

在结束本节之前,我想谈谈 代码覆盖率。在理论上,所有单位都应该有相应的测试套件,并且每个测试套件都应该包含通过所有可能代码分支的所有测试用例。正如我们所说的,这是在理论上,但在实践中,您通常只为一部分单位有测试单元。通常,您没有覆盖所有可能代码分支的测试用例。

拥有适当测试用例的单位比例称为代码覆盖率或 测试覆盖率。比例越高,您就越有可能被通知关于不希望修改的情况。这些不希望修改通常不是由糟糕的开发者引入的。事实上,这些破坏性更改通常是在某人正在修复代码中的错误或实现新功能时引入的。

在讨论了测试替身之后,我们将在下一节中讨论组件测试。

组件测试

正如我们在上一节中解释的,单位可以被定义为单个函数、一组函数或整个组件。因此,组件测试是单元测试的一种特殊类型。在本节中,我们想要定义一个假设的组件作为 示例 22.1 的一部分,并将示例中找到的两个函数放入该组件中。请注意,组件通常会产生一个可执行文件或库。我们可以假设我们的假设组件将产生一个包含两个函数的库。

正如我们之前所说的,我们必须能够测试组件的功能。在本节中,我们仍然想要编写测试用例,但本节中编写的测试用例与上一节的不同之处在于应该隔离的单位。在上一节中,我们有应该隔离的函数,但在本节中,我们有一个由两个协同工作的函数组成的组件,需要隔离。因此,当它们一起工作时,必须对这些函数进行测试。

接下来,您可以找到我们为作为 示例 22.1 部分定义的组件编写的测试用例:

#include <assert.h>
#include "ExtremeC_examples_chapter22_1.h"
void TESTCASE_component_test__factorials_from_0_to_8() {
  assert(calc_factorial(next_even_number) == 1);
  assert(calc_factorial(next_even_number) == 2);
  assert(calc_factorial(next_even_number) == 24);
  assert(calc_factorial(next_even_number) == 720);
  assert(calc_factorial(next_even_number) == 40320);
}
void TESTCASE_component_test__factorials_should_rotate() {
  int64_t number = calc_factorial(next_even_number);
  for (size_t i = 1; i <= 4; i++) {
    calc_factorial(next_even_number);
  }
  int64_t number2 = calc_factorial(next_even_number);
  assert(number == number2);
}
int main(int argc, char** argv) {
  TESTCASE_component_test__factorials_from_0_to_8();
  TESTCASE_component_test__factorials_should_rotate();
  return 0;
}

代码框 22-9 [ExtremeC_examples_chapter22_1_component_tests.c]: 为我们假设的组件作为示例 22.1 的一部分编写的某些组件测试

正如您所见,我们已经编写了两个测试用例。正如我们之前所说的,在我们的假设组件中,函数 calc_factorialnext_even_number 必须协同工作,如您所见,我们已经将 next_even_number 作为 calc_factorial 的输入。前面的测试用例和其他类似的测试用例应该保证组件正常工作。

准备编写测试用例的基础需要付出很多努力。因此,使用测试库来完成此目的非常常见。这些库为测试用例准备舞台;它们初始化每个测试用例,运行测试用例,并最终拆除测试用例。在下一节中,我们将讨论 C 可用的一些测试库。

C 的测试库

在本节中,我们将演示两个用于为 C 程序编写测试的知名库。对于 C 的单元测试,我们使用用 C 或 C++编写的库,因为我们可以轻松地将它们集成并直接从 C 或 C++测试环境中使用单元。在本节中,我们的重点是 C 的单元测试和组件测试。

对于集成测试,我们可以自由选择其他编程语言。通常,集成和系统测试要复杂得多,因此我们需要使用一些测试自动化框架来更容易地编写测试并轻松运行它们。使用领域特定语言DSL)是这一自动化过程的一部分,以便更容易地编写测试场景并使测试执行更加简单。许多语言都可以用于此目的,但像 Unix shell、Python、JavaScript 和 Ruby 这样的脚本语言是最受欢迎的。一些其他编程语言,如 Java,也在测试自动化中得到了广泛使用。

以下是一些用于为 C 程序编写单元测试的知名单元测试框架列表。以下列表可以在以下链接中找到:http://check.sourceforge.net/doc/check_html/check_2.html#SEC3:

  • Check(来自前一个链接的作者)

  • AceUnit

  • GNU Autounit

  • cUnit

  • CUnit

  • CppUnit

  • CuTest

  • embUnit

  • MinUnit

  • Google Test

  • CMocka

在以下几节中,我们将介绍两个流行的测试框架:用 C 编写的CMocka和用 C++编写的Google Test。我们不会探索这些框架的所有功能,但这是为了给你一个单元测试框架的初步感觉。在这个领域进一步学习是非常鼓励的。

在下一节中,我们将使用 CMocka 为example 22.1编写单元测试。

CMocka

CMocka 的第一个优点是它完全用 C 编写,并且只依赖于 C 标准库——不依赖于任何其他库。因此,你可以使用 C 编译器编译测试,这让你有信心测试环境非常接近实际的生产环境。CMocka 可在许多平台如 macOS、Linux 甚至 Microsoft Windows 上使用。

CMocka 是 C 语言单元测试的事实上的框架。它支持测试夹具。测试夹具可能允许你在每个测试用例之前和之后初始化和清理测试环境。CMocka 还支持函数模拟,这在尝试模拟任何 C 函数时非常有用。作为提醒,模拟函数可以被配置为在提供特定输入时返回特定值。我们将给出模拟 example 22.2 中使用的 rand 标准函数的示例。

以下代码框包含与 example 22.1 中看到的相同的测试用例,但这次是用 CMocka 编写的。我们将所有测试用例都放在了一个文件中,该文件有自己的 main 函数:

// Required by CMocka
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
#include "ExtremeC_examples_chapter22_1.h"
int64_t input_value = -1;
int64_t feed_stub() {
  return input_value;
}
void calc_factorial__fact_of_zero_is_one(void** state) {
  input_value = 0;
  int64_t fact = calc_factorial(feed_stub);
  assert_int_equal(fact, 1);
}
void calc_factorial__fact_of_negative_is_one(void** state) {
  input_value = -10;
  int64_t fact = calc_factorial(feed_stub);
  assert_int_equal(fact, 1);
}
void calc_factorial__fact_of_5_is_120(void** state) {
  input_value = 5;
  int64_t fact = calc_factorial(feed_stub);
  assert_int_equal(fact, 120);
}
void next_even_number__even_numbers_should_be_returned(void** state) {
  assert_int_equal(next_even_number(), 0);
  assert_int_equal(next_even_number(), 2);
  assert_int_equal(next_even_number(), 4);
  assert_int_equal(next_even_number(), 6);
  assert_int_equal(next_even_number(), 8);
}
void next_even_number__numbers_should_rotate(void** state) {
  int64_t number = next_even_number();
  for (size_t i = 1; i <= 4; i++) {
    next_even_number();
  }
  int64_t number2 = next_even_number();
  assert_int_equal(number, number2);
}
int setup(void** state) {
  return 0;
}
int tear_down(void** state) {
  return 0;
}
int main(int argc, char** argv) {
  const struct CMUnitTest tests[] = {
    cmocka_unit_test(calc_factorial__fact_of_zero_is_one),
    cmocka_unit_test(calc_factorial__fact_of_negative_is_one),
    cmocka_unit_test(calc_factorial__fact_of_5_is_120),
    cmocka_unit_test(next_even_number__even_numbers_should_be_returned),
    cmocka_unit_test(next_even_number__numbers_should_rotate),
  };
  return cmocka_run_group_tests(tests, setup, tear_down);
}

代码框 22-10 [ExtremeC_examples_chapter22_1_cmocka_tests.c]:示例 22.1 的 CMocka 测试用例

在 CMocka 中,每个测试用例都应该返回 void 并接收一个 void** 参数。指针参数将被用来接收一段信息,称为 state,它对于每个测试用例是特定的。在 main 函数中,我们创建一个测试用例列表,然后最终调用 cmocka_run_group_tests 函数来运行所有单元测试。

除了测试用例函数外,你还会看到两个新的函数:setuptear_down。正如我们之前所说的,这些函数被称为测试夹具。测试夹具在每次测试用例之前和之后被调用,其责任是设置和清理测试用例。夹具 setup 在每个测试用例之前被调用,而夹具 tear_down 在每个测试用例之后被调用。请注意,名称是可选的,它们可以命名为任何名称,但我们使用 setuptear_down 以便清晰。

我们之前编写的测试用例和用 CMocka 编写的测试用例之间的重要区别在于使用了不同的断言函数。这是使用单元测试框架的优点之一。测试库中包含了一系列断言函数,可以提供更多关于它们失败的信息,而不是标准的 assert 函数,后者会立即终止程序且不提供太多信息。正如你所看到的,我们已经在前面的代码中使用了 assert_int_equal,它检查两个整数的相等性。

为了编译前面的程序,你首先需要安装 CMocka。在基于 Debian 的 Linux 系统上,只需运行 sudo apt-get install libcmocka-dev 即可,而在 macOS 系统上,只需使用命令 brew install cmocka 进行安装。网上将会有很多帮助信息,可以帮助你完成安装过程。

在安装了 CMocka 之后,你可以使用以下命令来构建前面的代码:

$ gcc -g -c ExtremeC_examples_chapter22_1.c -o impl.o
$ gcc -g -c ExtremeC_examples_chapter22_1_cmocka_tests.c -o cmocka_tests.o
$ gcc impl.o cmocka_tests.o -lcmocka -o ex22_1_cmocka_tests.out
$ ./ex22_1_cmocka_tests.out
[==========] Running 5 test(s).
[ RUN      ] calc_factorial__fact_of_zero_is_one
[       OK ] calc_factorial__fact_of_zero_is_one
[ RUN      ] calc_factorial__fact_of_negative_is_one
[       OK ] calc_factorial__fact_of_negative_is_one
[ RUN      ] calc_factorial__fact_of_5_is_120
[       OK ] calc_factorial__fact_of_5_is_120
[ RUN      ] next_even_number__even_numbers_should_be_returned
[       OK ] next_even_number__even_numbers_should_be_returned
[ RUN      ] next_even_number__numbers_should_rotate
[       OK ] next_even_number__numbers_should_rotate
[==========] 5 test(s) run.
[  PASSED  ] 5 test(s).
$

Shell 框 22-4:构建和运行为示例 22.1 编写的 CMocka 单元测试

如您所见,我们必须使用 -lcmocka 来将前面的程序与已安装的 CMocka 库链接。输出显示了测试用例名称和通过测试的数量。接下来,我们更改一个测试用例使其失败。我们只是修改了 next_even_number__even_numbers_should_be_returned 测试用例中的第一个断言:

void next_even_number__even_numbers_should_be_returned(void** state) {
  assert_int_equal(next_even_number(), 1);
  ...
}

代码框 22-11: 修改示例 22.1 中的一个 CMocka 测试用例

现在,构建测试并再次运行它们:

$ gcc -g -c ExtremeC_examples_chapter22_1_cmocka_tests.c -o cmocka_tests.o
$ gcc impl.o cmocka_tests.o -lcmocka -o ex22_1_cmocka_tests.out
$ ./ex22_1_cmocka_tests.out
[==========] Running 5 test(s).
[ RUN      ] calc_factorial__fact_of_zero_is_one
[       OK ] calc_factorial__fact_of_zero_is_one
[ RUN      ] calc_factorial__fact_of_negative_is_one
[       OK ] calc_factorial__fact_of_negative_is_one
[ RUN      ] calc_factorial__fact_of_5_is_120
[       OK ] calc_factorial__fact_of_5_is_120
[ RUN      ] next_even_number__even_numbers_should_be_returned
[  ERROR   ] --- 0 != 0x1
[   LINE   ] --- .../ExtremeC_examples_chapter22_1_cmocka_tests.c:37: error: Failure!
[  FAILED  ] next_even_number__even_numbers_should_be_returned
[ RUN      ] next_even_number__numbers_should_rotate
[       OK ] next_even_number__numbers_should_rotate
[==========] 5 test(s) run.
[  PASSED  ] 4 test(s).
[  FAILED  ] 1 test(s), listed below:
[  FAILED  ] next_even_number__even_numbers_should_be_returned
 1 FAILED TEST(S)
 $

Shell 框 22-5:修改其中一个测试用例后构建和运行 CMocka 单元测试

在前面的输出中,您可以看到有一个测试用例失败了,原因在日志中间显示为一个错误。它显示了一个整数相等断言失败。正如我们之前解释的,使用 assert_int_equal 而不是使用普通的 assert 调用允许 CMocka 在执行日志中打印出有用的消息,而不是仅仅终止程序。

我们接下来的示例是关于使用 CMocka 的函数模拟功能。CMocka 允许您模拟一个函数,这样,您可以在提供特定输入时使函数返回特定的结果。

在下一个示例,即 示例 22.2 中,我们想展示如何使用模拟功能。在这个示例中,标准函数 rand 用于生成随机数。还有一个名为 random_boolean 的函数,它根据 rand 函数返回的数字的奇偶性返回一个布尔值。在展示 CMocka 的模拟功能之前,我们想展示如何为 rand 函数创建存根。您可以看到这个示例与 示例 22.1 不同。接下来,您可以看到 random_boolean 函数的声明:

#ifndef _EXTREME_C_EXAMPLE_22_2_
#define _EXTREME_C_EXAMPLE_22_2_
#define TRUE 1
#define FALSE 0
typedef int bool_t;
bool_t random_boolean();
#endif

代码框 22-12 [ExtremeC_examples_chapter22_2.h]: 示例 22.2 的头文件

以下代码框包含定义:

#include <stdlib.h>
#include <stdio.h>
#include "ExtremeC_examples_chapter22_2.h"
bool_t random_boolean() {
  int number = rand();
  return (number % 2);
}

代码框 22-13 [ExtremeC_examples_chapter22_2.c]: 示例 22.2 中 random_boolean 函数的定义

首先,我们不能让 random_boolean 在测试中使用实际的 rand 定义,因为,正如其名称所暗示的,它生成随机数,我们测试中不能有随机元素。测试是关于检查预期的,而预期和提供的输入必须是可预测的。更重要的是,rand 函数的定义是 C 标准库的一部分,例如 Linux 中的 glibc,使用存根函数对它进行操作不会像我们在 示例 22.1 中做的那样简单。

在上一个示例中,我们可以非常容易地将函数指针发送到存根定义。但在这个示例中,我们直接使用 rand 函数。我们不能更改 random_boolean 的定义,我们必须想出另一个技巧来使用存根函数 rand

为了使用 rand 函数的不同定义,在 C 中最简单的方法是玩弄最终目标文件的 symbols。在结果目标文件的 symbol table 中,有一个指向 rand 的条目,它引用了其在 C 标准库中的实际定义。如果我们更改此条目以引用测试二进制文件中 rand 函数的不同定义,我们就可以轻松地用我们的存根定义替换 rand 的定义。

在以下代码框中,您可以看到我们如何定义存根函数和测试。这会非常类似于我们在 example 22.1 中所做的那样:

#include <stdlib.h>
// Required by CMocka
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
#include "ExtremeC_examples_chapter22_2.h"
int next_random_num = 0;
int __wrap_rand() {
  return next_random_num;
}
void test_even_random_number(void** state) {
  next_random_num = 10;
  assert_false(random_boolean());
}
void test_odd_random_number(void** state) {
  next_random_num = 13;
  assert_true(random_boolean());
}
int main(int argc, char** argv) {
  const struct CMUnitTest tests[] = {
    cmocka_unit_test(test_even_random_number),
    cmocka_unit_test(test_odd_random_number)
  };
  return cmocka_run_group_tests(tests, NULL, NULL);
}

Code Box 22-14 [ExtremeC_examples_chapter22_2_cmocka_tests_with_stub.c]:使用存根函数编写 CMocka 测试用例

如您所见,前面的代码主要遵循我们在 Code Box 22-10 中的 example 22.1 编写的 CMocka 测试中看到的相同模式。让我们构建前面的文件并运行测试。我们期望所有测试都失败,因为无论您如何定义存根函数,random_boolean 都会从 C 标准库中选取 rand

$ gcc -g -c ExtremeC_examples_chapter22_2.c -o impl.o
$ gcc -g -c ExtremeC_examples_chapter22_2_cmocka_tests_with_stub.c -o tests.o
$ gcc impl.o tests.o -lcmocka -o ex22_2_cmocka_tests_with_stub.out
$ ./ex22_2_cmocka_tests_with_stub.out
[==========] Running 2 test(s).
[ RUN      ] test_even_random_number
[  ERROR   ] --- random_boolean()
[   LINE   ] --- ExtremeC_examples_chapter22_2_cmocka_tests_with_stub.c:23: error: Failure!
[  FAILED  ] test_even_random_number
[ RUN      ] test_odd_random_number
[  ERROR   ] --- random_boolean()
[   LINE   ] --- ExtremeC_examples_chapter22_2_cmocka_tests_with_stub.c:28: error: Failure!
[  FAILED  ] test_odd_random_number
[==========] 2 test(s) run.
[  PASSED  ] 0 test(s).
[  FAILED  ] 2 test(s), listed below:
[  FAILED  ] test_even_random_number
[  FAILED  ] test_odd_random_number
 2 FAILED TEST(S)
$

Shell Box 22-6:构建和运行示例 22.2 的 CMocka 单元测试

现在是时候施展技巧,更改 rand 符号背后的定义,该定义作为 ex22_2_cmocka_tests_with_stub.out 可执行文件的一部分。请注意,以下命令仅适用于 Linux 系统。我们这样做:

$ gcc impl.o tests.o -lcmocka -Wl,--wrap=rand -o ex22_2_cmocka_tests_with_stub.out
$ ./ex22_2_cmocka_tests_with_stub.out
[==========] Running 2 test(s).
[ RUN      ] test_even_random_number
[       OK ] test_even_random_number
[ RUN      ] test_odd_random_number
[       OK ] test_odd_random_number
[==========] 2 test(s) run.
[  PASSED  ] 2 test(s).
$

Shell Box 22-7:在包装 rand 符号后构建和运行示例 22.2 的 CMocka 单元测试

如您在输出中看到的,标准的 rand 函数不再被调用,取而代之的是存根函数返回我们告诉它返回的内容。使函数 __wrap_rand 被调用而不是标准 rand 函数的技巧主要在于在 gcc 链接命令中使用选项 -Wl,--wrap=rand

注意,此选项仅适用于 Linux 中的 ld 程序,您必须使用其他技巧,如 inter-positioning,在 macOS 或使用非 GNU 链接器的其他系统中调用不同的函数。

选项 --wrap=rand 告诉链接器更新最终可执行文件符号表中 rand 符号的条目,这将引用 __wrap_rand 函数的定义。请注意,这不是一个自定义名称,您必须将存根函数命名为这样。函数 __wrap_rand 被称为 wrapper function。更新符号表后,对 rand 函数的任何调用都会导致调用 __wrap_func 函数。这可以通过查看最终测试二进制的符号表来验证。

除了在符号表中更新 rand 符号外,链接器还创建另一个条目。新条目具有符号 __real_rand,它指向标准 rand 函数的实际定义。因此,如果我们需要运行标准的 rand,我们仍然可以使用函数名 __real_rand。这是符号表及其符号的出色用法,以便调用包装函数,尽管有些人不喜欢这样做,他们更喜欢预加载一个包装实际 rand 函数的共享对象。无论你使用哪种方法,你最终都需要将调用重定向到 rand 符号的另一个存根函数。

上述机制将是演示 CMocka 中函数模拟如何工作的基础。与 代码框 22-14 中看到的全局变量 next_random_num 不同,我们可以使用一个模拟函数来返回指定的值。接下来,你可以看到相同的 CMocka 测试,但使用模拟函数来读取测试输入:

#include <stdlib.h>
// Required by CMocka
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
#include "ExtremeC_examples_chapter22_2.h"
int __wrap_rand() {
  return mock_type(int);
}
void test_even_random_number(void** state) {
  will_return(__wrap_rand, 10);
  assert_false(random_boolean());
}
void test_odd_random_number(void** state) {
  will_return(__wrap_rand, 13);
  assert_true(random_boolean());
}
int main(int argc, char** argv) {
  const struct CMUnitTest tests[] = {
    cmocka_unit_test(test_even_random_number),
    cmocka_unit_test(test_odd_random_number)
  };
  return cmocka_run_group_tests(tests, NULL, NULL);
}

代码框 22-15 [ExtremeC_examples_chapter22_2_cmocka_tests_with_mock.c]:使用模拟函数编写 CMocka 测试用例

现在我们知道了包装函数 __wrap_rand 的调用方式,我们可以解释模拟部分。模拟功能由 will_returnmock_type 函数对提供。首先,应该调用 will_return,指定模拟函数应返回的值。然后,当模拟函数(在这种情况下为 __wrap_rand)被调用时,mock_type 函数返回指定的值。

例如,我们通过使用 will_return(__wrap_rand, 10)__wrap_rand 定义为返回 10,然后在 __wrap_rand 内部调用 mock_type 函数时返回值 10。请注意,每个 will_return 都必须与一个 mock_type 调用配对;否则,测试将失败。因此,如果由于任何原因没有调用 __wrap_rand,则测试将失败。

作为本节的最后一条注释,前面代码的输出将与我们在 Shell Boxes 22-622-7 中看到的一样。此外,当然对于源文件 ExtremeC_examples_chapter22_2_cmocka_tests_with_mock.c,必须使用相同的命令来构建代码并运行测试。

在本节中,我们展示了如何使用 CMocka 库编写测试用例、执行断言和编写模拟函数。在下一节中,我们将讨论 Google Test,这是另一个可以用于单元测试 C 程序的测试框架。

Google Test

Google Test 是一个 C++ 测试框架,可用于单元测试 C 和 C++ 程序。尽管它是用 C++ 开发的,但它可以用于测试 C 代码。有些人认为这是一种不好的做法,因为测试环境不是使用你将用于设置生产环境的相同编译器和链接器来设置的。

在能够使用 Google Test 为 示例 22.1 编写测试用例之前,我们需要稍微修改 示例 22.1 中的头文件。以下是新头文件:

#ifndef _EXTREME_C_EXAMPLE_22_1_
#define _EXTREME_C_EXAMPLE_22_1_
#include <stdint.h>
#include <unistd.h>
#if __cplusplus
extern "C" {
#endif
typedef int64_t (*int64_feed_t)();
int64_t next_even_number();
int64_t calc_factorial(int64_feed_t feed);
#if __cplusplus
}
#endif
#endif

Code Box 22-16 [ExtremeC_examples_chapter22_1.h]: 作为示例 22.1 一部分修改的头文件

正如你所见,我们将声明放在了extern C { ... }块中。我们只在定义了宏_cplusplus时这样做。前面的更改简单地说,就是当编译器是 C++时,我们希望在生成的目标文件中拥有未混淆的符号,否则当链接器尝试查找混淆符号的定义时,我们将得到链接错误。如果你不了解 C++的名称混淆,请参阅第二章的最后部分,编译和链接

现在,让我们继续使用 Google Test 编写测试用例:

// Required by Google Test
#include <gtest/gtest.h>
#include "ExtremeC_examples_chapter22_1.h"
int64_t input_value = -1;
int64_t feed_stub() {
  return input_value;
}
TEST(calc_factorial, fact_of_zero_is_one) {
  input_value = 0;
  int64_t fact = calc_factorial(feed_stub);
  ASSERT_EQ(fact, 1);
}
TEST(calc_factorial, fact_of_negative_is_one) {
  input_value = -10;
  int64_t fact = calc_factorial(feed_stub);
  ASSERT_EQ(fact, 1);
}
TEST(calc_factorial, fact_of_5_is_120) {
  input_value = 5;
  int64_t fact = calc_factorial(feed_stub);
  ASSERT_EQ(fact, 120);
}
TEST(next_even_number, even_numbers_should_be_returned) {
  ASSERT_EQ(next_even_number(), 0);
  ASSERT_EQ(next_even_number(), 2);
  ASSERT_EQ(next_even_number(), 4);
  ASSERT_EQ(next_even_number(), 6);
  ASSERT_EQ(next_even_number(), 8);
}
TEST(next_even_number, numbers_should_rotate) {
  int64_t number = next_even_number();
  for (size_t i = 1; i <= 4; i++) {
    next_even_number();
  }
  int64_t number2 = next_even_number();
   ASSERT_EQ(number, number2);
}
int main(int argc, char** argv) {
  ::testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

Code Box 22-17 [ExtremeC_examples_chapter22_1_gtests.cpp]: 使用 Google Test 为示例 22.1 编写的测试用例

测试用例使用TEST(...)宏定义。这是一个如何有效地使用宏来形成领域特定语言的例子。还有其他宏,如TEST_F(...)TEST_P(...), 这些是 C++特定的。传递给宏的第一个参数是测试类的名称(Google Test 是为面向对象的 C++编写的),可以将其视为包含多个测试用例的测试套件。第二个参数是测试用例的名称。

注意ASSERT_EQ宏,它用于断言对象的相等性,而不仅仅是整数。Google Test 中有大量的期望检查宏,使其成为一个完整的单元测试框架。最后一部分是main函数,它运行所有定义的测试。请注意,上述代码应该使用符合 C++11 规范的编译器(如g++clang++)进行编译。

以下命令构建前面的代码。注意使用g++编译器和传递给它的选项-std=c++11,这表示应使用 C++11:

$ gcc -g -c ExtremeC_examples_chapter22_1.c -o impl.o
$ g++ -std=c++11 -g -c ExtremeC_examples_chapter22_1_gtests.cpp -o gtests.o
$ g++ impl.o gtests.o -lgtest -lpthread -o ex19_1_gtests.out
$ ./ex19_1_gtests.out
[==========] Running 5 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 3 tests from calc_factorial
[ RUN      ] calc_factorial.fact_of_zero_is_one
[       OK ] calc_factorial.fact_of_zero_is_one (0 ms)
[ RUN      ] calc_factorial.fact_of_negative_is_one
[       OK ] calc_factorial.fact_of_negative_is_one (0 ms)
[ RUN      ] calc_factorial.fact_of_5_is_120
[       OK ] calc_factorial.fact_of_5_is_120 (0 ms)
[----------] 3 tests from calc_factorial (0 ms total)
[----------] 2 tests from next_even_number
[ RUN      ] next_even_number.even_numbers_should_be_returned
[       OK ] next_even_number.even_numbers_should_be_returned (0 ms)
[ RUN      ] next_even_number.numbers_should_rotate
[       OK ] next_even_number.numbers_should_rotate (0 ms)
[----------] 2 tests from next_even_number (0 ms total)
[----------] Global test environment tear-down
[==========] 5 tests from 2 test suites ran. (1 ms total)
[  PASSED  ] 5 tests.
$

Shell Box 22-8:构建和运行示例 22.1 的 Google Test 单元测试

上述输出显示与 CMocka 输出类似。它表明有五个测试用例已经通过。让我们改变与 CMocka 相同的测试用例来破坏测试套件:

TEST(next_even_number, even_numbers_should_be_returned) {
  ASSERT_EQ(next_even_number(), 1);
  ...
}

Code Box 22-18:修改 Google Test 编写的测试用例之一

让我们再次构建测试并运行它们:

$ g++ -std=c++11 -g -c ExtremeC_examples_chapter22_1_gtests.cpp -o gtests.o
$ g++ impl.o gtests.o -lgtest -lpthread -o ex22_1_gtests.out
$ ./ex22_1_gtests.out
[==========] Running 5 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 3 tests from calc_factorial
[ RUN      ] calc_factorial.fact_of_zero_is_one
[       OK ] calc_factorial.fact_of_zero_is_one (0 ms)
[ RUN      ] calc_factorial.fact_of_negative_is_one
[       OK ] calc_factorial.fact_of_negative_is_one (0 ms)
[ RUN      ] calc_factorial.fact_of_5_is_120
[       OK ] calc_factorial.fact_of_5_is_120 (0 ms)
[----------] 3 tests from calc_factorial (0 ms total)
[----------] 2 tests from next_even_number
[ RUN      ] next_even_number.even_numbers_should_be_returned
.../ExtremeC_examples_chapter22_1_gtests.cpp:34: Failure
Expected equality of these values:
  next_even_number()
    Which is: 0
  1
[  FAILED  ] next_even_number.even_numbers_should_be_returned (0 ms)
[ RUN      ] next_even_number.numbers_should_rotate
[       OK ] next_even_number.numbers_should_rotate (0 ms)
[----------] 2 tests from next_even_number (0 ms total)
[----------] Global test environment tear-down
[==========] 5 tests from 2 test suites ran. (0 ms total)
[  PASSED  ] 4 tests.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] next_even_number.even_numbers_should_be_returned
 1 FAILED TEST
$

Shell Box 22-9:修改一个测试用例后构建和运行示例 22.1 的 Google Test 单元测试

正如你所见,并且与 CMocka 完全一样,Google Test 也会打印出测试失败的位置,并显示一个有用的报告。关于 Google Test 的最后一句话,它支持测试固定值,但不是像 CMocka 那样支持。测试固定值应该在测试类中定义。

注意

为了拥有模拟对象和模拟功能,可以使用Google Mock(或gmock)库,但我们在本书中不涉及它。

在本节中,我们介绍了 C 语言中最著名的两个单元测试库。在章节的下一部分,我们将深入探讨调试这一主题,这对于每一位程序员来说当然是一项必要的技能。

调试

有时候一个测试或一组测试会失败。也有时候你会发现一个错误。在这两种情况下,都存在错误,你需要找到根本原因并修复它。这涉及到许多调试会话,通过查看源代码来寻找错误的原因并规划所需的修复。但“调试”一段软件究竟意味着什么呢?

注意

人们普遍认为,“调试”这个术语起源于计算机如此庞大,以至于真正的虫子(如蛾子)可以卡在系统机械中并导致故障的时代。因此,一些人,官方称为调试器,被派到硬件室去从设备中移除虫子。更多信息请见此链接:https://en.wikipedia.org/wiki/Debugging。

调试是一项调查任务,通过查看程序内部和/或外部来找到观察到的错误的根本原因。当运行程序时,你通常将其视为一个黑盒。然而,当结果出现问题时或执行被中断时,你需要更深入地查看并了解问题是如何产生的。这意味着你必须将程序视为一个白盒,其中一切都可以被看到。

这基本上是我们可以为程序拥有两种不同构建的原因:发布构建。在发布构建中,重点是执行和功能,程序主要被视为一个黑盒,但在调试构建中,我们可以跟踪所有发生的事件,并将程序视为一个白盒。调试构建通常用于开发和测试环境,而发布构建则针对部署和生产环境。

为了拥有调试构建版本,软件项目的所有产品或其中的一部分需要包含调试符号,这些符号允许开发者跟踪和查看程序的堆栈跟踪和执行流程。通常,发布产品(可执行文件或库)不适合调试目的,因为它不够透明,无法让观察者检查程序的内部结构。在第四章进程内存结构第五章栈和堆中,我们讨论了如何为调试目的构建 C 源代码。

为了调试程序,我们主要使用调试器。调试器是独立程序,它们附着到目标进程上以控制或监视它。当我们在处理问题时,调试器是我们调查的主要工具,但其他调试工具也可以用来研究内存、并发执行流程或程序的性能。我们将在接下来的章节中讨论这些工具。

大多数错误都是可复现的,但也有一些错误无法复现或在调试会话中观察到;这主要是因为观察者效应。它说,当你想查看程序的内幕时,你会改变它的工作方式,这可能会阻止一些错误发生。这类问题非常严重,通常很难修复,因为你不能使用你的调试工具来调查问题的根本原因!

在高性能环境中,一些线程错误可以归入这一类。

在接下来的章节中,我们将讨论不同类别的错误。然后,我们将介绍我们在现代 C/C++开发中使用的工具,以调查错误。

错误类别

在软件被客户使用的过程中,可能会有成千上万的错误被报告。但如果你看看这些错误的类型,它们并不多。接下来,你可以看到我们认为重要且需要特殊技能来处理的一些错误类别列表。当然,这个列表并不完整,可能还有我们遗漏的其他类型的错误:

  • 逻辑错误:为了调查这些错误,你需要了解代码和代码的执行流程。为了看到程序的实际执行流程,应该将调试器附加到正在运行的过程中。只有这样,才能追踪和分析执行流程。在调试程序时,执行日志也可以使用,尤其是在最终二进制文件中没有调试符号或调试器无法附加到程序的实际运行实例时。

  • 内存错误:这些错误与内存相关。它们通常是由于悬挂指针、缓冲区溢出、双重释放等原因引起的。这些错误应该使用内存分析器进行调查,它作为一种调试工具,用于观察和监控内存。

  • 并发错误:多进程和多线程程序一直是软件行业中一些最难以解决的错误的发源地。你需要特殊的工具,如线程检查器,来检测诸如竞态条件和数据竞争等特别困难的问题。

  • 性能错误:新的发展可能会导致性能下降或性能错误。这些错误应该使用更深入和更专注的测试甚至调试来调查。包含先前执行的历史数据的执行日志在寻找导致下降的确切变化或变化时可能很有用。

在接下来的章节中,我们将讨论前面列表中介绍的各种工具。

调试器

我们在第四章进程内存结构中讨论了调试器,特别是gdb,我们用它来查看进程的内存。在本节中,我们将再次审视调试器,并描述它们在日常软件开发中的作用。以下是由大多数现代调试器提供的常见功能列表:

  • 调试器是一个程序,就像所有其他程序一样,它作为一个进程运行。调试器进程可以附加到另一个进程,前提是给出目标进程 ID。

  • 调试器可以在成功附加到目标进程后控制目标进程中的指令执行;因此,用户可以使用交互式调试会话暂停并继续目标进程的执行流程。

  • 调试器可以查看进程的保护内存。它们还可以修改内容,因此开发者可以在故意更改内存内容的同时运行相同的指令组。

  • 几乎所有已知的调试器,如果在编译源代码到可重定位目标文件时提供了调试符号,都可以追踪指令到源代码。换句话说,当你暂停在一条指令上时,你可以转到源文件中对应的代码行。

  • 如果目标对象文件中没有提供调试符号,调试器可以显示目标指令的汇编代码,这仍然可能是有用的。

  • 一些调试器是针对特定语言的,但大多数不是。Java 虚拟机JVM)语言,如 Java、Scala 和 Groovy,必须使用 JVM 调试器才能查看和控制 JVM 实例的内部结构。

  • 解释型语言如 Python 也有它们自己的调试器,可以用来暂停和控制脚本。虽然像gdb这样的低级调试器仍然可用于 JVM 或脚本语言,但它们试图调试 JVM 或解释器进程,而不是执行 Java 字节码或 Python 脚本。

可以在以下链接的维基百科上找到调试器的列表:https://en.wikipedia.org/wiki/List_of_debuggers。从这个列表中,以下调试器引人注目:

  1. 高级调试器adb):默认的 Unix 调试器。它根据实际的 Unix 实现有不同的实现。它一直是 Solaris Unix 的默认调试器。

  2. GNU 调试器gdb):Unix 调试器的 GNU 版本,它是许多类 Unix 操作系统的默认调试器,包括 Linux。

  3. LLDB:主要设计用于调试由 LLVM 编译器生成的目标文件的调试器。

  4. Python 调试器:用于 Python 调试 Python 脚本。

  5. Java 平台调试架构JPDA):这不是一个调试器,但它是一个为在 JVM 实例中运行的程序设计的 API。

  6. OllyDbg:用于 Microsoft Windows 调试 GUI 应用的调试器和反汇编器。

  7. Microsoft Visual Studio 调试器:Microsoft Visual Studio 使用的调试器。

除了gdb,还可以使用cgdbcgdb程序在gdb交互式 shell 旁边显示一个终端代码编辑器,这使得你更容易在代码行之间移动。

在本节中,我们讨论了调试器作为调查问题的主要工具。在下一节中,我们将讨论内存分析器,这对于调查内存相关错误至关重要。

内存检查器

有时候当你遇到与内存相关的错误或崩溃时,仅使用调试器并不能提供太多帮助。你需要另一个工具来检测内存损坏以及对内存单元的无效读写。你需要的是内存检查器内存分析器。它可能是调试器的一部分,但通常作为一个独立的程序提供,并且它检测内存异常行为的方式与调试器不同。

我们通常可以期待内存检查器具有以下功能:

  • 报告分配的内存总量、释放的内存、使用的静态内存、堆分配、栈分配等。

  • 内存泄漏检测,这可以被认为是内存检查器提供的最重要的功能。

  • 检测无效的内存读写操作,如缓冲区和数组越界访问、写入已释放的内存区域等。

  • 检测双重释放问题。当程序尝试释放已释放的内存区域时会发生这种情况。

到目前为止,我们在一些章节中看到了内存检查器,如Memcheck(Valgrind 的工具之一),尤其是在第五章栈和堆。我们在第五章也讨论了不同类型的内存检查器和内存分析器。在这里,我们再次解释它们,并给出每个的更多细节。

内存检查器都做同样的事情,但它们用于监控内存操作的技术可能不同。因此,我们根据它们使用的技术将它们分组:

  1. 编译时覆盖:对于使用这种技术的内存检查器,你需要对你的源代码进行一些通常很小的修改,比如包含内存检查器库的头文件。然后,你需要重新编译你的二进制文件。有时,有必要将二进制文件链接到内存检查器提供的库。优点是执行二进制文件的性能下降小于其他技术,但缺点是需要重新编译你的二进制文件。LLVM AddressSanitizerASan)、Memwatch、Dmalloc 和 Mtrace 都是使用这种技术的内存分析器。

  2. 链接时覆盖:这个内存检查器组类似于之前的内存检查器组,但不同之处在于你不需要更改源代码。相反,你只需要将生成的二进制文件与内存检查器提供的库链接起来,而无需更改源代码。gperftools中的heap checker实用程序可以用作链接时内存检查器。

  3. 运行时拦截:使用这种技术的内存检查器位于程序和操作系统之间,试图拦截和跟踪所有与内存相关的操作,并在发现任何不当行为或无效访问时报告。它还可以根据总分配和释放的内存块生成泄漏报告。使用这种技术的最大优点是您无需重新编译或重新链接程序即可使用内存检查器。其重大缺点是它给程序执行引入了显著的开销。此外,内存占用会比在没有内存检查器运行程序时高得多。这绝对不是调试高性能和嵌入式程序的理想环境。Valgrind 中的 Memcheck 工具可以用作运行时拦截内存检查器。这些内存分析器应该与代码库的调试构建一起使用。

  4. 预加载库:一些内存检查器使用插入位置来包装标准内存函数。因此,通过使用LD_PRELOAD环境变量预加载内存检查器的共享库,程序可以使用包装函数,内存检查器可以拦截对底层标准内存函数的调用。堆检查器实用程序在gperftools中可以这样使用。

通常,仅使用特定工具来解决所有内存问题是不够的,因为每个工具都有其自身的优缺点,这使得该工具特定于某个特定环境。

在本节中,我们介绍了可用的内存分析器,并根据它们记录内存分配和释放的技术进行了分类。在下一节中,我们将讨论线程清理器。

线程调试器

线程清理器线程调试器是用于在程序运行时调试多线程程序以查找并发相关问题的程序。它们可以找到的一些问题如下:

  • 数据竞争,以及在不同线程中读写操作导致数据竞争的确切位置

  • 错误使用线程 API,尤其是在 POSIX 兼容系统中的 POSIX 线程 API

  • 可能的死锁

  • 锁定顺序问题

线程调试器和内存检查器都可能检测到假阳性问题。换句话说,它们可能会找到并报告一些问题,但在调查后,它们变得明显不是问题。这实际上取决于这些库用于跟踪事件的技巧以及对该事件的最终决定。

在以下列表中,您可以找到许多知名的可用线程调试器:

  • Helgrind来自 Valgrind):它是 Valgrind 内的另一个工具,主要用于线程调试。DRD 也是 Valgrind 工具包的一部分,另一个线程调试器。功能和差异的列表可以在以下链接中查看:http://valgrind.org/docs/manual/hg-manual.htmlhttp://valgrind.org/docs/manual/drd-manual.html。像 Valgrind 的所有其他工具一样,使用 Helgrind 不需要您修改源代码。要运行 Helgrind,您需要运行命令 valgrind --tool=helgrind [path-to-executable]

  • Intel Inspector:这是 Intel Thread Checker 的继任者,它执行线程错误和内存问题的分析。因此,它既是线程调试器也是内存检查器。与 Valgrind 不同,它不是免费的,使用此工具需要购买适当的许可证。

  • LLVM ThreadSanitizerTSan):这是 LLVM 工具包的一部分,并附带 LLVM AddressSanitizer,这在前面章节中已描述。为了使用调试器和重新编译代码库,需要进行一些轻微的编译时修改。

在本节中,我们讨论了线程调试器,并介绍了一些可用的线程调试器,以便调试线程问题。在下一节中,我们将提供用于调整程序性能的程序和工具包。

性能分析器

有时,一组非功能性测试的结果表明性能有所下降。有一些专门的工具用于调查性能下降的原因。在本节中,我们将快速查看可用于分析性能和找到性能瓶颈的工具。

这些性能调试器通常提供以下功能的子集:

  • 收集每个单独函数调用的统计数据

  • 提供一个用于跟踪函数调用的 函数调用图

  • 收集每个函数调用的内存相关统计数据

  • 收集锁竞争统计数据

  • 收集内存分配/释放统计数据

  • 缓存分析,提供缓存使用统计数据,并显示不友好的代码部分

  • 收集关于线程和同步事件的统计数据

以下是可以用于性能分析的最知名程序和工具包列表:

  • Google 性能工具gperftools):这实际上是一个高性能的 malloc 实现,但正如其主页上所述,它提供了一些性能分析工具,如 heap checker,这在前面章节中作为内存分析器被介绍。为了使用它,需要将其链接到最终二进制文件。

  • Callgrind作为 Valgrind 的一部分):主要收集关于函数调用以及两个函数之间调用者/被调用者关系的统计数据。无需更改源代码或链接最终二进制文件,它可以在运行时使用,当然,前提是使用调试构建。

  • Intel VTune:这是一个来自 Intel 的性能分析套件,包含了前面列表中提到的所有功能。为了使用它,必须购买适当的许可证。

摘要

本章是关于单元测试和调试 C 程序。作为总结,在本章中:

  • 我们讨论了测试,以及为什么它对我们作为软件工程师和开发团队来说很重要。

  • 我们还讨论了不同级别的测试,如单元测试、集成测试和系统测试。

  • 功能性和非功能性测试也被涵盖。

  • 回归测试被解释了。

  • CMocka 和 Google Test,作为两个著名的 C 语言测试库,被探索,并给出了一些示例。

  • 我们讨论了调试以及各种类型的错误。

  • 我们讨论了调试器、内存分析器、线程调试器和性能调试器,这些可以帮助我们在处理错误时进行更成功的调查。

下一章将介绍适用于 C 项目的构建系统。我们将讨论构建系统是什么以及它能够带来哪些功能,这最终将帮助我们自动化构建大型 C 项目的流程。

第二十三章

构建系统

对于我们程序员来说,构建项目并运行其各种组件是开发新功能或修复项目中报告的错误的第一个步骤。实际上,这不仅仅限于 C 或 C++;几乎任何包含用编译型编程语言(如 C、C++、Java 或 Go)编写的组件的项目,都需要首先进行构建。

因此,能够快速轻松地构建软件项目是几乎任何在软件生产流程中工作的一方的基本需求,无论是开发者、测试人员、集成人员、DevOps 工程师,甚至是客户支持人员。

更重要的是,当你作为一个新手加入一个团队时,你做的第一件事就是构建你将要工作的代码库。考虑到所有这些,很明显,解决构建软件项目的能力是合理的,鉴于它在软件开发过程中的重要性。

程序员需要频繁地构建代码库以查看他们更改的结果。仅使用少量源文件构建项目似乎既简单又快捷,但当源文件数量增加(相信我,这种情况会发生)时,频繁构建代码库就变成了开发任务的真正障碍。因此,一个适当的软件项目构建机制至关重要。

人们过去常常编写 shell 脚本来构建大量的源文件。尽管它有效,但需要大量的努力和维护来保持脚本足够通用,以便在各种软件项目中使用。随后,大约在 1976 年,贝尔实验室开发了第一个(或者至少是其中之一)名为 Make构建系统,并在内部项目中使用。

此后,Make 在所有 C 和 C++ 项目中得到了大规模的应用,甚至在其他 C/C++ 不是主要语言的项目中也是如此。

在本章中,我们将讨论广泛使用的 C 和 C++ 项目的 构建系统构建脚本生成器。作为本章的一部分,我们将讨论以下主题:

  • 首先,我们将探讨什么是构建系统以及它们有什么好处。

  • 然后,我们将介绍 Make 是什么以及如何使用 Makefile。

  • CMake 是下一个主题。你将了解构建脚本生成器,并学习如何编写简单的 CMakeLists.txt 文件。

  • 我们将了解 Ninja 是什么以及它与 Make 的区别。

  • 本章还将探讨如何使用 CMake 生成 Ninja 构建脚本。

  • 我们将深入研究 Bazel 是什么以及如何使用它。你将了解 WORKSPACEBUILD 文件,以及在一个简单的用例中应该如何编写它们。

  • 最后,你将获得一些已发布的各种构建系统比较的链接。

注意,本章中使用的所有构建工具都需要事先安装在你的系统上。由于这些构建工具正在大规模使用,因此互联网上应有适当资源和文档。

在第一部分,我们将探讨构建系统实际上是什么。

什么是构建系统?

简而言之,构建系统是一组程序和配套的文本文件,它们共同构建一个软件代码库。如今,每种编程语言都有自己的构建系统。例如,在 Java 中,有AntMavenGradle等等。但“构建代码库”究竟是什么意思呢?

构建代码库意味着从源文件中生成最终产品。例如,对于一个 C 代码库,最终产品可以是可执行文件、共享对象文件或静态库,而 C 构建系统的目标就是从代码库中找到的 C 源文件生成这些产品。为此目的所需的操作细节在很大程度上取决于编程语言或代码库中涉及的语言。

许多现代构建系统,尤其是在用JVM 语言(如 Java 或 Scala)编写的项目中,提供额外的服务。

它们也进行依赖管理。这意味着构建系统检测目标代码库的依赖关系,并下载所有这些依赖关系,在构建过程中使用下载的工件。这非常方便,尤其是在项目中有很多依赖关系的情况下,这在大型代码库中通常是常见的情况。

例如,Maven是 Java 项目中最著名的构建系统之一;它使用 XML 文件并支持依赖管理。不幸的是,我们在 C/C++项目中没有很好的依赖管理工具。为什么我们还没有得到类似 Maven 的构建系统,这是一个值得讨论的问题,但它们尚未开发的事实可能表明我们并不需要它们。

构建系统的另一个方面是能够构建包含多个模块的大型项目。当然,这可以通过使用 shell 脚本和编写递归的Makefiles来实现,这些 Makefiles 可以遍历任何级别的模块,但我们谈论的是对这种需求的原生支持。不幸的是,Make 并不提供这种原生支持。另一个著名的构建工具 CMake 则提供了这种支持。我们将在专门介绍 CMake 的章节中进一步讨论这个问题。

到目前为止,许多项目仍然使用 Make 作为它们的默认构建系统,然而,通过使用 CMake。事实上,这是使 CMake 非常重要的一个点,在加入 C/C++项目之前,你需要学习它。请注意,CMake 不仅限于 C 和 C++,也可以用于使用各种编程语言的项目。

在下一节中,我们将讨论 Make 构建系统以及它是如何构建项目的。我们将给出一个多模块 C 项目的示例,并在本章中用它来展示如何使用各种构建系统构建这个项目。

Make

Make 构建系统使用 Makefile。Makefile 是一个名为 "Makefile"(确切地说是这个名字,没有任何扩展名)的文本文件,位于源目录中,它包含 构建目标 和命令,告诉 Make 如何构建当前的代码库。

让我们从简单的多模块 C 项目开始,并为其配备 Make。以下 shell box 显示了项目中的文件和目录。如您所见,它有一个名为 calc 的模块,还有一个名为 exec 的模块正在使用它。

calc模块的输出将是一个静态对象库,而exec模块的输出是一个可执行文件:

$ tree ex23_1
ex23_1/
├── calc
│   ├── add.c
│   ├── calc.h
│   ├── multiply.c
│   └── subtract.c
└── exec
    └── main.c
2 directories, 5 files 
$

Shell Box 23-1:目标项目中的文件和目录

如果我们想在没有使用构建系统的情况下构建上述项目,我们必须按以下顺序运行以下命令。请注意,我们已将 Linux 作为此项目的目标平台:

$ mkdir -p out
$ gcc -c calc/add.c -o out/add.o
$ gcc -c calc/multiply.c -o out/multiply.o
$ gcc -c calc/subtract.c -o out/subtract.o
$ ar rcs out/libcalc.a out/add.o out/multiply.o out/subtract.o
$ gcc -c -Icalc exec/main.c -o out/main.o
$ gcc -Lout out/main.o -lcalc -o out/ex23_1.out
$

Shell Box 23-2: 构建目标项目

如您所见,项目有两个工件:一个静态库,libcalc.a,和一个可执行文件,ex23_1.out。如果您不知道如何编译 C 项目,或者前面的命令对您来说很陌生,请阅读 第二章编译和链接,以及 第三章目标文件

Shell Box 23-2 中的第一个命令创建了一个名为 out 的目录。这个目录应该包含所有可重定位目标文件和最终产品。

接着,接下来的三个命令使用 gcc 编译 calc 目录中的源文件,并生成它们相应的可重定位目标文件。然后,这些目标文件在第五个命令中使用,以生成静态库 libcalc.a

最后,最后两个命令从 exec 目录编译文件 main.c,并将其与 libcalc.a 链接在一起,生成最终的执行文件,ex23_1.out。请注意,所有这些文件都放在 out 目录内。

前面的命令会随着源文件数量的增加而增长。我们可以将前面的命令保存在一个名为 build script 的 shell 脚本文件中,但有一些方面我们在事先应该考虑:

  • 我们是否将在所有平台上运行相同的命令?不同的编译器和环境中有一些细节是不同的;因此,命令可能因系统而异。在最简单的情况下,我们应该为不同的平台维护不同的 shell 脚本。那么,这意味着我们的脚本不是 可移植的

  • 当项目添加新的目录或新的模块时会发生什么?我们需要更改构建脚本吗?

  • 如果我们添加新的源文件,构建脚本会发生什么?

  • 如果我们需要一个新的产品,比如一个新的库或一个新的可执行文件,会发生什么?

一个好的构建系统应该处理上述所有或大多数情况。让我们展示我们的第一个 Makefile。此文件将构建上述项目并生成其产品。本节和以下各节中编写的所有用于构建系统的文件都可以用来构建这个特定的项目,而不会涉及更多。

以下代码框显示了我们可以为上述项目编写的最简单的 Makefile 的内容:

build:
    mkdir -p out
    gcc -c calc/add.c -o out/add.o
    gcc -c calc/multiply.c -o out/multiply.o
    gcc -c calc/subtract.c -o out/subtract.o
    ar rcs out/libcalc.a out/add.o out/multiply.o out/subtract.o
    gcc -c -Icalc exec/main.c -o out/main.o
    gcc -Lout -lcalc out/main.o -o out/ex23_1.out
clean:
    rm -rfv out

代码框 23-1 [Makefile-very-simple]:为特定项目编写的非常简单的 Makefile

前面的 Makefile 包含两个目标:buildclean。目标有一组命令,当调用目标时应该执行这些命令。这组命令被称为目标的 配方

为了运行 Makefile 中的命令,我们需要使用 make 命令。你需要告诉 make 命令要运行哪个目标,但如果留空,make 总是执行第一个目标。

要使用 Makefile 构建前面的项目,只需将 代码框 23-1 中的行复制到名为 Makefile 的文件中,并将其放在项目的根目录下。项目的目录内容应类似于以下 shell 框中所示:

$ tree ex23_1
ex23_1/
├── Makefile
├── calc
│   ├── add.c
│   ├── calc.h
│   ├── multiply.c
│   └── subtract.c
└── exec
    └── main.c
2 directories, 6 files 
$

Shell 框 23-3:在添加 Makefile 后在目标项目中找到的文件和目录

之后,你只需运行 make 命令。make 程序会自动在当前目录中查找 Makefile 文件并执行其第一个目标。如果我们想运行 clean 目标,我们必须使用 make clean 命令。clean 目标可以用来删除构建过程中产生的文件,这样我们就可以从头开始进行全新的构建。

以下 shell 框显示了运行 make 命令的结果:

$ cd ex23_1
$ make
mkdir -p out
gcc -c -Icalc exec/main.c -o out/main.o
gcc -c calc/add.c -o out/add.o
gcc -c calc/multiply.c -o out/multiply.o
gcc -c calc/subtract.c -o out/subtract.o
ar rcs out/libcalc.a out/add.o out/multiply.o out/subtract.o
gcc -Lout -lcalc out/main.o -o out/ex23_1.out
$

Shell 框 23-4:使用非常简单的 Makefile 构建目标项目

你可能会问,“构建脚本(用 shell 脚本编写的)和上面的 Makefile 之间有什么区别?”你提出这个问题是正确的!前面的 Makefile 并不代表我们通常使用 Make 构建项目的方式。

实际上,前面的 Makefile 是对 Make 构建系统的天真使用,它没有从 Make 提供的已知特性中受益。

换句话说,到目前为止,Makefile 与 shell 脚本非常相似,我们仍然可以使用 shell 脚本(尽管当然这会涉及更多的工作)。现在我们到了 Makefile 变得有趣并且真正不同的地方。

下面的 Makefile 仍然很简单,但它介绍了我们感兴趣的 Make 构建系统的更多方面:

CC = gcc
build: prereq out/main.o out/libcalc.a
    ${CC} -Lout -lcalc out/main.o -o out/ex23_1.out
prereq:
    mkdir -p out
out/libcalc.a: out/add.o out/multiply.o out/subtract.o
    ar rcs out/libcalc.a out/add.o out/multiply.o out/subtract.o
out/main.o: exec/main.c calc/calc.h
    ${CC} -c -Icalc exec/main.c -o out/main.o
out/add.o: calc/add.c calc/calc.h
    ${CC} -c calc/add.c -o out/add.o
out/subtract.o: calc/subtract.c calc/calc.h
    ${CC} -c calc/subtract.c -o out/subtract.o
out/multiply.o: calc/multiply.c calc/calc.h
    ${CC} -c calc/multiply.c -o out/multiply.o
clean: out
    rm -rf out

代码框 23-2 [Makefile-simple]:为特定项目编写的新的但仍然简单的 Makefile

正如你所看到的,我们可以在 Makefile 中声明一个变量并在多个地方使用它,就像我们在先前的代码框中声明的 CC 一样。变量,加上 Makefile 中的条件,允许我们用比编写一个能够实现相同灵活性的 shell 脚本更少的努力来编写灵活的构建指令。

Makefile 的另一个酷特性是能够包含其他 Makefile。这样,你可以从你之前项目中编写的现有 Makefile 中受益。

正如你在先前的 Makefile 中看到的,每个 Makefile 可以有多个目标。目标从行的开头开始,以冒号“:”结束。必须使用一个制表符字符来缩进目标(即配方)内的所有指令,以便让make程序能够识别。关于目标的一个酷地方是:它们可以依赖于其他目标。

例如,在先前的 Makefile 中,build目标依赖于prereqout /main.oout/libcalc.a目标。然后,每当调用build目标时,首先会检查其依赖的目标,如果它们尚未生成,那么这些目标将首先被调用。现在,如果你更仔细地观察先前的 Makefile 中的目标,你应该能够看到目标之间的执行流程。

这绝对是我们在一个 shell 脚本中缺少的东西;为了使 shell 脚本像这样工作,我们需要很多控制流机制(循环、条件等等)。Makefile 比 shell 脚本更简洁,更声明式,这就是我们使用它的原因。我们只想声明需要构建的内容,而不需要知道构建路径。虽然使用 Make 并不能完全实现这一点,但它是一个拥有完整功能的构建系统的起点。

Makefile 中目标的另一个特性是,如果它们引用的是磁盘上的文件或目录,例如out/multiply.omake程序会检查该文件或目录的最新修改,如果没有自上次构建以来的修改,它将跳过该目标。这也适用于out/multiply.o的依赖项calc/multiply.c。如果源文件calc/multiply.c最近没有更改并且已经编译过,再次编译它就没有意义了。这又是一个你不能仅仅通过编写 shell 脚本就能获得的功能。

通过这个特性,你只编译自上次构建以来已修改的源文件,这大大减少了自上次构建以来未更改的源文件的编译量。当然,这个特性在至少编译整个项目一次之后才会工作。之后,只有修改过的源文件才会触发编译或链接。

前面的 Makefile 中还有一个关键点,即calc/calc.h目标。正如你所见,有多个目标,主要是源文件,依赖于头文件calc/calc.h。因此,根据我们之前解释的功能,对头文件的简单修改可以触发依赖于该头文件的源文件的多次编译。

这正是我们试图在源文件中只包含所需的头文件,并在可能的情况下使用前向声明而不是包含的原因。前向声明通常不在源文件中制作,因为在那里,我们通常需要访问结构或函数的实际定义,但在头文件中可以轻松完成。

头文件之间有很多依赖通常会导致构建灾难。即使是包含在许多其他头文件中,最终被许多源文件包含的一个头文件的微小修改,也可能触发整个项目或类似规模的构建。这将有效降低开发质量,并导致开发者需要在构建之间等待数分钟。

前面的 Makefile 仍然过于冗长。每当我们添加一个新的源文件时,我们必须更改目标。我们期望在添加新的源文件时更改 Makefile,而不是通过添加新的目标并改变 Makefile 的整体结构。这实际上阻止了我们重用相同的 Makefile 在另一个类似当前项目的项目中。

更重要的是,许多目标遵循相同的模式,我们可以利用 Make 中可用的模式匹配功能来减少目标数量,并在 Makefile 中编写更少的代码。这是 Make 的另一个超级特性,其效果你很难通过编写 shell 脚本轻易实现。

以下 Makefile 将是本项目中的最后一个,但仍然不是一位 Make 专家能写出的最佳 Makefile:

BUILD_DIR = out
OBJ = ${BUILD_DIR}/calc/add.o \
                ${BUILD_DIR}/calc/subtract.o \
                ${BUILD_DIR}/calc/multiply.o \
                ${BUILD_DIR}/exec/main.o
CC = gcc
HEADER_DIRS = -Icalc
LIBCALCNAME = calc
LIBCALC = ${BUILD_DIR}/lib${LIBCALCNAME}.a
EXEC = ${BUILD_DIR}/ex23_1.out
build: prereq ${BUILD_DIR}/exec/main.o ${LIBCALC}
    ${CC} -L${BUILD_DIR} -l${LIBCALCNAME} ${BUILD_DIR}/exec/main.o -o ${EXEC}
prereq:
    mkdir -p ${BUILD_DIR}
    mkdir -p ${BUILD_DIR}/calc
    mkdir -p ${BUILD_DIR}/exec
${LIBCALC}: ${OBJ}
    ar rcs ${LIBCALC} ${OBJ}
${BUILD_DIR}/calc/%.o: calc/%.c
    ${CC} -c ${HEADER_DIRS} $< -o $@
${BUILD_DIR}/exec/%.o: exec/%.c
    ${CC} -c ${HEADER_DIRS} $< -o $@
clean: ${BUILD_DIR}
    rm -rf ${BUILD_DIR}

代码框 23-3 [Makefile-by-pattern]:为针对目标项目编写的新 Makefile,使用模式匹配

前面的 Makefile 在其目标中使用模式匹配。变量OBJ保存预期可重定位目标文件的列表,并在需要目标文件列表的所有其他地方使用。

这不是一本关于 Make 的模式匹配如何工作的书,但你可以看到,有一些通配符,如%$<$@,在模式中使用。

运行前面的 Makefile 将产生与其他 Makefile 相同的结果,但我们可以从 Make 提供的各种优秀功能中受益,并最终拥有一个可重用和维护的 Make 脚本。

下面的 shell 框展示了如何运行前面的 Makefile 以及输出结果:

$ make
mkdir -p out
mkdir -p out/calc
mkdir -p out/exec
gcc -c -Icalc exec/main.c -o out/exec/main.o
gcc -c -Icalc calc/add.c -o out/calc/add.o
gcc -c -Icalc calc/subtract.c -o out/calc/subtract.o
gcc -c -Icalc calc/multiply.c -o out/calc/multiply.o
ar rcs out/libcalc.a out/calc/add.o out/calc/subtract.o out/calc/multiply.o out/exec/main.o
gcc -Lout -lcalc out/exec/main.o -o out/ex23_1.out
$

Shell 框 23-5:使用最终 Makefile 构建目标项目

在接下来的章节中,我们将讨论 CMake,这是一个用于生成真正的 Makefiles 的出色工具。事实上,在 Make 变得流行之后,新一代的构建工具出现了,构建脚本生成器,可以从给定的描述中生成 Makefiles 或其他构建系统的脚本。CMake 就是其中之一,它可能是最受欢迎的。

注意

这里是阅读更多关于 GNU Make 的主要链接,它是为 GNU 项目制作的 Make 的实现:GNU Make:https://www.gnu.org/software/make/manual/html_node/index.html/html_node/index.html。

CMake – 并非一个构建系统!

CMake 是一个构建脚本生成器,并作为其他构建系统(如 Make 和 Ninja)的生成器。编写有效的跨平台 Makefiles 是一项繁琐且复杂的工作。CMake 或类似工具,如Autotools,被开发出来以提供精心调校的跨平台构建脚本,如 Makefiles 或 Ninja 构建文件。请注意,Ninja 是另一个构建系统,将在下一节中介绍。

注意

你可以在这里阅读更多关于 Autotools 的信息:Autotools:https://www.gnu.org/software/automake/manual/html_node/Autotools-Introduction.html

依赖管理也很重要,这不是通过 Makefiles 实现的。这些生成工具还可以检查已安装的依赖项,如果系统中缺少所需的依赖项,则不会生成构建脚本。检查编译器和它们的版本,以及找到它们的位置、它们支持的功能等等,都是这些工具在生成构建脚本之前所做的工作的一部分。

类似于 Make,它会寻找名为Makefile的文件,CMake 会寻找名为CMakeLists.txt的文件。无论你在项目中找到这个文件的位置,都意味着 CMake 可以使用来生成适当的 Makefiles。幸运的是,与 Make 不同,CMake 支持嵌套模块。换句话说,你可以在其他目录中拥有多个CMakeLists.txt文件作为项目的一部分,并且只需在项目根目录中运行 CMake,就可以找到它们并为它们生成适当的 Makefiles。

让我们通过添加 CMake 支持到我们的示例项目来继续本节。为此,我们添加了三个CMakeLists.txt文件。接下来,你可以看到添加这些文件后项目的层次结构:

$ tree ex23_1
ex23_1/
├── CMakeLists.txt
├── calc
│   ├── CMakeLists.txt
│   ├── add.c
│   ├── calc.h
│   ├── multiply.c
│   └── subtract.c
└── exec
    ├── CMakeLists.txt
    └── main.c
2 directories, 8 files
$

Shell Box 23-6:引入三个 CMakeLists.txt 文件后的项目层次结构

如你所见,我们有三个CMakeLists.txt文件:一个在根目录中,一个在calc目录中,另一个在exec目录中。下面的代码框显示了在根目录中找到的CMakeLists.txt文件的内容。如你所见,它添加了calcexec的子目录。

这些子目录必须包含一个CMakeLists.txt文件,实际上,根据我们的设置,它们确实包含:

cmake_minimum_required(VERSION 3.8)
include_directories(calc)
add_subdirectory(calc)
add_subdirectory(exec)

代码框 23-4 [CMakeLists.txt]: 在项目根目录中找到的 CMakeLists.txt 文件

前面的 CMake 文件将 calc 目录添加到编译源文件时编译器将使用的 include 目录中。正如我们之前所说的,它还添加了两个子目录:calcexec。这些目录有自己的 CMakeLists.txt 文件,解释了如何编译它们的内容。以下是在 calc 目录中找到的 CMakeLists.txt 文件:

add_library(calc STATIC
  add.c
  subtract.c
  multiply.c
)

代码框 23-5 [calc/CMakeLists.txt]: 在 calc 目录中找到的 CMakeLists.txt 文件

如您所见,它只是一个简单的 目标声明,针对 calc 目标,这意味着我们需要一个名为 calc 的静态库(实际上在构建后为 libcalc.a),该库应包含对应于源文件 add.csubtract.cmultiply.c 的可重定位目标文件。请注意,CMake 目标通常代表代码库的最终产品。因此,对于 calc 模块,我们只有一个产品,即一个静态库。

如您所见,对于 calc 目标没有指定其他内容。例如,我们没有指定静态库的扩展名或库的文件名(尽管我们可以)。构建此模块所需的所有其他配置要么是从父 CMakeLists.txt 文件继承的,要么是从 CMake 本身的默认配置中获得的。

例如,我们知道在 Linux 和 macOS 上共享对象文件的扩展名不同。因此,如果目标是共享库,就没有必要在目标声明中指定扩展名。CMake 能够处理这种非常平台特定的差异,并且最终共享对象文件将根据构建的平台具有正确的扩展名。

以下 CMakeLists.txt 文件是在 exec 目录中找到的:

add_executable(ex23_1.out
  main.c
)
target_link_libraries(ex23_1.out
  calc
)

代码框 23-6 [exec/CMakeLists.txt]: 在 exec 目录中找到的 CMakeLists.txt 文件

如您所见,前面 CMakeLists.txt 中声明的目标是可执行文件,并且它应该链接到另一个 CMakeLists.txt 文件中已经声明的 calc 目标。

这实际上给了您在项目的某个角落创建库,并在另一个角落使用它们的能力,只需编写一些指令即可。

现在是时候向您展示如何根据根目录中找到的 CMakeLists.txt 文件生成 Makefile。请注意,我们在名为 build 的单独目录中这样做,以便将生成的可重定位和最终目标文件与实际源文件保持分离。

如果您使用的是 源代码管理SCM)系统如 git,您可以忽略 build 目录,因为它应该在每个平台上单独生成。唯一重要的文件是 CMakeLists.txt 文件,这些文件始终保存在源代码控制仓库中。

以下 shell box 演示了如何为根目录中找到的CMakeLists.txt文件生成构建脚本(在这种情况下,是一个 Makefile):

$ cd ex23_1
$ mkdir -p build
$ cd build
$ rm -rfv *
...
$ cmake ..
-- The C compiler identification is GNU 7.4.0
-- The CXX compiler identification is GNU 7.4.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: .../extreme_c/ch23/ex23_1/build
$

Shell Box 23-7:基于根目录中找到的 CMakeLists.txt 文件生成 Makefile

如您从输出中看到的,CMake 命令已经能够检测到工作编译器、它们的 ABI 信息(关于 ABI 的更多信息,请参阅第三章目标文件)、它们的功能等等,最后它在build目录中生成了一个 Makefile。

注意

Shell Box 23-7中,我们假设我们可以在build目录中;因此,我们首先删除了其所有内容。

您可以看到build目录的内容和生成的 Makefile:

$ ls
CMakeCache.txt  CMakeFiles  Makefile  calc  cmake_install.cmake  exec
$

Shell Box 23-8:在 build 目录中生成的 Makefile

现在您已经在build目录中有了 Makefile,您可以自由地运行 make 命令。它将负责编译,并为您优雅地显示进度。

注意,在运行make命令之前,您应该在build目录中:

$ make
Scanning dependencies of target calc
[ 16%] Building C object calc/CMakeFiles/calc.dir/add.c.o
[ 33%] Building C object calc/CMakeFiles/calc.dir/subtract.c.o
[ 50%] Building C object calc/CMakeFiles/calc.dir/multiply.c.o
[ 66%] Linking C static library libcalc.a
[ 66%] Built target calc
Scanning dependencies of target ex23_1.out
[ 83%] Building C object exec/CMakeFiles/ex23_1.out.dir/main.c.o
[100%] Linking C executable ex23_1.out
[100%] Built target ex23_1.out
$

Shell Box 23-9:执行生成的 Makefile

目前,许多大型项目使用 CMake,您可以使用我们在之前的 shell boxes 中展示的大致相同的命令来构建它们的源代码。"Vim"就是这样一个项目。甚至 CMake 本身也是在使用由 Autotools 构建的最小 CMake 系统之后,用 CMake 构建的!CMake 现在有很多版本和功能,要详细讨论它们需要一本书。

注意

以下链接是最新版本 CMake 的官方文档,它可以帮助您了解它如何工作以及它有哪些功能:https://cmake.org/cmake/help/latest/index.html。

在本节的最后,CMake 可以为 Microsoft Visual Studio、Apple 的 Xcode 和其他开发环境创建构建脚本文件。

在下一节中,我们将讨论 Ninja 构建系统,这是一个比 Make 更快的替代方案,最近正逐渐流行起来。我们还将解释如何使用 CMake 生成 Ninja 构建脚本文件而不是 Makefile。

Ninja

Ninja 是 Make 的替代品。我犹豫是否称它为替代品,但它是更快的替代品。它通过移除 Make 提供的一些功能(如字符串操作、循环和模式匹配)来实现高性能。

通过移除这些功能,Ninja 减少了开销,因此从头开始编写 Ninja 构建脚本并不是明智之举。

编写 Ninja 脚本可以与编写 shell 脚本相比较,我们之前章节中解释了其缺点。这就是为什么建议与 CMake 这样的构建脚本生成工具一起使用。

在本节中,我们将展示当 Ninja 构建脚本由 CMake 生成时如何使用 Ninja。因此,在本节中,我们不会像对 Makefile 那样介绍 Ninja 的语法。这是因为我们不会自己编写它们;相反,我们将要求 CMake 为我们生成它们。

注意

想了解更多关于 Ninja 语法的知识,请点击此链接:ninja-build.org/manual.html#_writing_your_own_ninja_files.

正如我们之前所解释的,最好使用构建脚本生成器来生成 Ninja 构建脚本文件。在下面的 shell 框中,您可以查看如何使用 CMake 生成 Ninja 构建脚本,build.ninja,而不是为我们的目标项目生成 Makefile:

$ cd ex23_1
$ mkdir -p build
$ cd build
$ rm -rfv *
...
$ cmake -GNinja ..
-- The C compiler identification is GNU 7.4.0
-- The CXX compiler identification is GNU 7.4.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: .../extreme_c/ch23/ex23_1/build
$

Shell Box 23-10:基于根目录中找到的 CMakeLists.txt 生成 build.ninja

如您所见,我们已经传递了选项-GNinja来让 CMake 知道我们要求的是 Ninja 构建脚本文件而不是 Makefile。CMake 生成build.ninja文件,您可以在以下build目录中找到它:

$ ls
CMakeCache.txt  CMakeFiles  build.ninja  calc  cmake_install.cmake  exec  rules.ninja
$

Shell Box 23-11:在 build 目录中生成的 build.ninja

要编译项目,只需运行以下ninja命令即可。请注意,就像make程序在当前目录中查找Makefile一样,ninja程序在当前目录中查找build.ninja

$ ninja
[6/6] Linking C executable exec/ex23_1.out
$

Shell Box 23-12:执行生成的 build.ninja

在以下部分,我们将讨论Bazel,这是另一个可以用于构建 C 和 C++项目的构建系统。

Bazel

Bazel 是 Google 开发的一个构建系统,旨在解决内部需要有一个快速且可扩展的构建系统,无论编程语言是什么,都能构建任何项目。Bazel 支持构建 C、C++、Java、Go 和 Objective-C 项目。不仅如此,它还可以用于构建 Android 和 iOS 项目。

Bazel 大约在 2015 年成为开源软件。它是一个构建系统,因此它可以与 Make 和 Ninja 进行比较,但不能与 CMake 相比。几乎所有的 Google 开源项目都使用 Bazel 进行构建。例如,我们可以提到Bazel本身、gRPCAngularKubernetesTensorFlow

Bazel 是用 Java 编写的。它以并行和可扩展的构建而闻名,在大项目中确实能带来很大的差异。Make 和 Ninja 都支持并行构建,通过传递-j选项(Ninja 默认是并行的)。

注意

Bazel 的官方文档可以在以下链接找到:docs.bazel.build/versions/master/bazel-overview.html.

使用 Bazel 的方式与我们对 Make 和 Ninja 所做的方式类似。Bazel 需要在一个项目中存在两种类型的文件:WORKSPACEBUILD 文件。WORKSPACE 文件应该在根目录中,而 BUILD 文件应该放入作为同一工作区(或项目)一部分的模块中。这在大约程度上类似于 CMake 的情况,我们有三份 CMakeLists.txt 文件分布在项目中,但请注意,在这里,Bazel 本身是构建系统,我们不会为另一个构建系统生成任何构建脚本。

如果我们想将 Bazel 支持添加到我们的项目中,我们应该在项目中获得以下层次结构:

$ tree ex23_1
ex23_1/
├── WORKSPACE
├── calc
│   ├── BUILD
│   ├── add.c
│   ├── calc.h
│   ├── multiply.c
│   └── subtract.c
└── exec
    ├── BUILD
    └── main.c
2 directories, 8 files
$

Shell 框 23-13:引入 Bazel 文件后的项目层次结构

在我们的示例中,WORKSPACE 文件的内容将是空的。它通常用于指示代码库的根目录。请注意,如果您有更多嵌套和更深的模块,您需要参考文档以了解这些文件(WORKSPACEBUILD)应该如何在整个代码库中传播。

BUILD 文件的内容表明了在该目录(或模块)中应该构建的目标。以下代码框显示了 calc 模块的 BUILD 文件:

c_library(
  name = "calc",
  srcs = ["add.c", "subtract.c", "multiply.c"],
  hdrs = ["calc.h"],
  linkstatic = True,
  visibility = ["//exec:__pkg__"]
)

代码框 23-7 [calc/BUILD]:calc 目录中找到的 BUILD 文件

正如您所看到的,一个新的目标 calc 被声明。它是一个静态库,包含目录中找到的三个源文件。该库对 exec 目录中的目标也是可见的。

让我们看看 exec 目录中的 BUILD 文件:

cc_binary(
  name = "ex23_1.out",
  srcs = ["main.c"],
  deps = [
    "//calc:calc"
  ],
  copts = ["-Icalc"]
)

代码框 23-8 [exec/BUILD]:exec 目录中找到的 BUILD 文件

在这些文件放置到位后,我们现在可以运行 Bazel 并构建项目。您需要进入项目的根目录。请注意,与 CMake 一样,我们不需要有构建目录:

$ cd ex23_1
$ bazel build //...
INFO: Analyzed 2 targets (14 packages loaded, 71 targets configured).
INFO: Found 2 targets...
INFO: Elapsed time: 1.067s, Critical Path: 0.15s
INFO: 6 processes: 6 linux-sandbox.
INFO: Build completed successfully, 11 total actions
$

Shell 框 23-14:使用 Bazel 构建示例项目

现在,如果您查看根目录中找到的 bazel-bin 目录,您应该能够找到产品:

$ tree bazel-bin
bazel-bin
├── calc
│   ├── _objs
│   │   └── calc
│   │       ├── add.pic.d
│   │       ├── add.pic.o
│   │       ├── multiply.pic.d
│   │       ├── multiply.pic.o
│   │       ├── subtract.pic.d
│   │       └── subtract.pic.o
│   ├── libcalc.a
│   └── libcalc.a-2.params
└── exec
    ├── _objs
    │   └── ex23_1.out
    │       ├── main.pic.d
    │       └── main.pic.o
    ├── ex23_1.out
    ├── ex23_1.out-2.params
    ├── ex23_1.out.runfiles
    │   ├── MANIFEST
    │   └── __main__
    │       └── exec
    │           └── ex23_1.out -> .../bin/exec/ex23_1.out
    └── ex23_1.out.runfiles_manifest
9 directories, 15 files
$

Shell 框 23-15:构建后 bazel-bin 的内容

如您在前面的列表中所见,项目已成功构建,产品已被定位。

在下一节中,我们将结束本章的讨论,并比较现有的 C 和 C++ 项目构建系统。

比较构建系统

在本章中,我们尝试介绍了三种最著名和最广泛使用的构建系统。我们还介绍了 CMake 作为构建脚本生成器。您应该知道还有其他构建系统可以用于构建 C 和 C++ 项目。

请注意,您对构建系统的选择应被视为一项长期承诺;如果您以特定的构建系统开始一个项目,将其更改为另一个系统将需要大量的努力。

构建系统可以根据各种属性进行比较。依赖管理、能够处理复杂的嵌套项目层次结构、构建速度、可扩展性、与现有服务的集成、添加新逻辑的灵活性等等,都可以用来进行公平的比较。我不会用构建系统的比较来结束这本书,因为这是一项繁琐的工作,而且更重要的是,已经有了一些关于这个主题的优秀在线文章。

在 Bitbucket 上有一个很好的 Wiki 页面,对可用的构建系统进行了优缺点比较,以及构建脚本生成系统,可以在这里找到:bitbucket.org/scons/scons/wiki/SconsVsOtherBuildTools

注意,比较的结果可能因人而异。你应该根据你项目的需求和可用的资源来选择构建系统。以下链接提供了可用于进一步研究和比较的补充资源:www.reddit.com/r/cpp/comments/8zm66h/an_overview_of_build_systems_mostly_for_c_projects/

www.reddit.com/r/cpp/comments/8zm66h/an_overview_of_build_systems_mostly_for_c_projects/iew_of_build_systems_mostly_for_c_projects/

github.com/LoopPerfect/buckaroo/wiki/Build-Systems-Comparison

medium.com/@julienjorge/an-overview-of-build-systems-mostly-for-c-projects-ac9931494444

摘要

在本章中,我们讨论了用于构建 C 或 C++项目的常用构建工具。作为本章的一部分:

  • 我们讨论了构建系统的必要性。

  • 我们介绍了 Make,这是可用于 C 和 C++项目的最古老的构建系统之一。

  • 我们介绍了 Autotools 和 CMake,两种著名的构建脚本生成器。

  • 我们展示了如何使用 CMake 生成所需的 Makefiles。

  • 我们讨论了 Ninja,并展示了如何使用 CMake 生成 Ninja 构建脚本。

  • 我们展示了如何使用 Bazel 构建 C 项目。

  • 最后,我们提供了一些链接,指向关于各种构建系统比较的在线讨论。

结语

最后的话 ...

如果你正在阅读这篇文档,这意味着我们的旅程已经结束!作为本书的一部分,我们探讨了几个主题和概念,我希望这次旅程能让你成为一个更好的 C 程序员。当然,它不能给你带来经验;你必须通过参与各种项目来获得。本书中讨论的方法和技巧将提升你的专业水平,这将使你能够参与更复杂的项目。现在你对软件系统有了更深入的了解,从更广阔的角度来看,并且对内部运作有了顶尖的知识。

尽管这本书比你的常规阅读更厚重、更长,但它仍然无法涵盖 C、C++和系统编程中所有的话题。因此,我肩上仍然有一份责任;旅程还没有结束!我希望能继续研究更多极端话题,也许更具体的领域,比如异步 I/O、高级数据结构、套接字编程、分布式系统、内核开发和函数式编程,在适当的时候。

希望在下次旅程中再次见到你!

Kamran

第二十四章:您可能还喜欢的其他书籍

如果您喜欢这本书,您可能会对 Packt 的以下其他书籍感兴趣:

C# 8.0 和.NET Core 3.0 – 现代跨平台开发 - 第四版

马克·J·普莱斯

ISBN: 978-1-78847-812-0

  • 为 Windows、macOS、Linux、iOS 和 Android 构建跨平台应用程序

  • 使用 C# 8.0 和.NET Core 3.0 探索应用程序开发

  • 探索 ASP.NET Core 3.0 并创建专业网络应用程序

  • 学习面向对象编程和 C#多任务处理

  • 使用 LINQ 查询和操作数据

  • 使用 Entity Framework Core 并与关系型数据库一起工作

  • 发现使用通用 Windows 平台和 XAML 的 Windows 应用程序开发

  • 使用 Xamarin.Forms 为 iOS 和 Android 构建移动应用程序

DevOps 悖论

维克托·法齐克

ISBN: 978-1-78913-363-9

关于以下内容的专家意见:

  • 将 DevOps 引入现实世界的混乱商业环境

  • 在采用尖端工具或坚持使用经过验证的方法之间做出决定

  • 在没有职位权力的情况下启动必要的业务变革

  • 在 DevOps 实施中管理和克服变革的恐惧

  • 预测 DevOps 的未来趋势以及如何为它们做准备

  • 充分利用 Kubernetes、Docker、Puppet、Chef 和 Ansible

  • 在整个组织中创造 DevOps 成功的正确激励

  • 新技术(如 Lambda、无服务器和调度器)对 DevOps 实践的影响

留下评论 - 让其他读者了解您的想法

请通过在您购买书籍的网站上留下评论来与其他人分享您对这本书的看法。如果您从亚马逊购买了这本书,请在本书的亚马逊页面上留下一个诚实的评论。这对其他潜在读者来说至关重要,他们可以通过您的无偏见意见做出购买决定,我们可以了解客户对我们产品的看法,我们的作者可以查看他们与 Packt 合作创作的标题的反馈。这只需要您几分钟的时间,但对其他潜在客户、我们的作者和 Packt 来说都很有价值。谢谢!

目录

A

抽象类 289, 307

抽象

关于 289, 290, 291, 292

抽象原则

参考链接 290

抽象语法树 (AST) 80, 81, 82

AF_INET6 套接字 625

AF_INET 套接字 625

AF_LOCAL 套接字 625

AF_UNIX 套接字 625

聚合关系 249, 250, 252, 253, 254, 255

匿名结构体

关于 388, 389

匿名联合体

关于 388, 389

应用程序编程接口 (API),SUS v4

头文件接口 321

脚本接口 322

系统接口 321

工具接口 321, 322

XCURSES 接口 322

应用二进制接口 (ABI)

关于 2, 3

应用程序编程接口 (API)

暴露 320

元数据 83

ASCII 标准 381

汇编器

关于 82, 83

汇编器输出 (a.out) 4

异步函数 35

异步 I/O 方法 634

属性封装 216, 217, 218

属性结构 221

自动工具

关于 765

参考链接 765

平均函数 57, 58

B

Bazel

关于 773, 774, 776

参考链接 773

BCPL 312

行为封装 219, 220, 222, 224, 225, 226, 227, 228, 229

行为函数 221

贝尔实验室

参考链接 311

伯克利软件发行版 (BSD) 321

Big-O 函数 191

二进制内容 70

二进制文件 70

二进制信号量 436

二进制信号量 482, 492

Bitbucket

参考链接 777

阻塞函数 35

阻塞 I/O 方法 634

由符号启动的块 (BSS)

关于 138

Boehm-Demers-Weiser 垃圾回收器

关于 189

参考链接 189

边界检查函数 379

B 编程语言

关于 312

缺陷 313

参考链接 312

断点 165

BSS 段

关于 138, 139, 140

缓冲区溢出攻击 170

缺陷类别

关于 748

并发错误 748

逻辑错误 748

内存错误 748

性能错误 748

错误 185

构建脚本 759

构建脚本生成器 765

构建系统

关于 756, 757, 759

比较 776

比较,参考链接 776

构建目标 757

忙等待模式 430, 484

忙等待模式 484

忙等待 427, 429

字节码 678

C

C

发展 313, 314

继承,实现 51

库,测试 731, 732

不是一个面向对象的语言 215

指针,大小 31

C++

与 687 集成

名称混淆 98, 99, 100, 687, 688, 689

面向对象结构 293

C++ 代码

编写 689, 691, 693, 694

C11

关于 374

缺陷,参考链接 374

问题,参考链接 390

参考链接 374

C17 374

C18 374

关于 390

缓存一致性协议 511

缓存友好算法 195

缓存命中 194

缓存未命中 194

计算器项目

关于 636

应用协议 642, 643, 644, 645

构建 641

计算器服务 651, 652, 653

执行 642

目的 636

请求消息 643

序列化/反序列化库 646

源代码,参考链接 637

源层次结构 637, 639, 640

使用,以实现目标 636

计算器协议 642

回调函数 648

回调机制 34

取消标志

设置 564, 566, 567

共享内存 559, 560, 561

C 构建管道。参见 C 编译管道

C/C++项目

产品 83

C 编译管道

关于 54, 55, 56

汇编 69, 70, 71

编译 65, 67, 68, 69

组件 54

链接步骤 72

预处理 63, 64

C 编译器

参考链接 55

集中式进程同步 588

C 文件

编译 54

cgo 包 712

循环等待 425

之间的关系 239, 240

与对象相比 240, 241, 242

基于类的方案 240

基于类的面向对象编程 213

清理和分配(calloc) 179

CMake

关于 765, 766, 767, 768, 769, 770

参考链接 770

CMocka

用于编写单元测试 732, 734, 735, 736, 737, 738, 739, 740, 741

命令行界面(CLI) 320

通用对象文件格式 (COFF) 4

通信协议

关于 594, 595, 596

伴随函数 462

编译 71

编译失败 54

编译单元 63

编译错误 59

编译器

关于 79

抽象语法树 (AST) 80, 81, 82

编译器后端 80

编译器前端 80

组件,C 编译管道

关于 54

汇编器 82

编译器 79

链接器 83

预处理器 75

组件测试

关于 729, 731

组合关系

关于 242, 243

实现 245, 247, 248

计算机网络

关于 611

应用层 622

链接层 612

网络层 613, 614, 615, 616, 617

物理层 611

传输层 617

具体类型 290

并发

关于 392,393,394,395

使用 400,401,402,403,404,405,406,407

并发错误 748

并发问题

关于 413,414

内在并发问题 414,415

后续同步问题 414,425

并发,POSIX

关于 446

内核 447,448,449

条件编译 19,21

条件变量 438

无连接通信 635

无连接初始化序列 621

无连接传输通信

与面向连接的传输通信相对 619,620

面向连接的通信 635

面向连接的初始化序列 621,622

受限环境

内存管理 190

约束 190

竞争 438

竞争时间 437,438

上下文切换

在约束之前实施 398,399,400

控制机制 547

cpp 工具 (C 预处理器) 78

C 预处理器 4

C 编程语言 373

C 程序,单元测试

参考链接 731

C 项目

构建 56

示例,构建 61, 62, 63

示例源文件 59, 60, 61

可执行对象文件 10

头文件,与源文件对比 56, 57, 58, 59

规则 62

关键部分 421, 435

跨平台软件 55

C 标准

支持的版本,查找 374, 375, 376

C 标准库 (libc) 323

ctypes 708

Cygwin 323

参考链接 517

D

悬挂指针 470

悬挂指针 31, 32

数据库管理系统 (DBMS)

网络服务 529

数据报客户端 669, 671

数据报连接序列

关于 630

数据报监听序列

关于 629

数据报 635

数据报服务器 664, 665, 668, 669

数据竞争 414

示例 476, 477, 479

数据竞争示例

通过共享内存进行演示 536, 539, 540, 541

数据段

关于 140, 141, 142, 143, 144, 145

死锁

关于 425

参考链接 425

调试器 163

调试器

关于 749, 750

特征 749

调试器列表

高级调试器 (adb) 750

GNU 调试器 (gdb) 750

Java 平台调试架构 (JDPA) 750

LLDB 750

微软 Visual Studio 调试器 750

OllyDbg 750

Python 调试器 750

参考链接 749

调试

关于 746, 747

参考链接 747

声明头 221

事实上的实现 430

分隔符 598

服务拒绝 (DOS) 379

依赖管理 757, 766

反序列化 594

反序列化对象 644

分离线程 465

分离状态 466

目录,计算器项目

/calcser 639

/calcsvc 639

/client/slicore 640

/client/tcp 641

/client/udp 641

/client/Unix/datagram 640

/client/Unix/stream 640

/server/srvcore 639

/server/tcp 640

/server/udp 640

/server/Unix/datagram 640

/server/Unix/stream 640

分布式并发控制

关于 587

挑战 587, 588

分布式多进程 545

分布式(点对点)进程同步 589

域名系统 (DNS) 595

领域特定语言 (DSL) 731

双重释放情况 189

双重包含

防止 89

动态库

关于 24, 25, 26, 27, 28, 29

动态链接 13

动态加载

参考链接 26

动态内存布局

关于 135, 147

堆段 154, 155, 156, 157, 158

内存映射 148, 149, 151, 152

栈段 153, 154

动态竞态检测器 419

E

ELF 可执行文件

参考链接 527

封装 293, 295, 296

关于 216

属性封装 216, 217, 218

行为封装 219, 220, 222, 224, 225, 226, 227, 229

信息隐藏 230, 231, 233, 234, 235, 237

编码 382

事件循环 658

事件驱动编程 35

exec* 函数 524

可执行和链接格式 (ELF) 83

关于 3

参考链接 5

可执行二进制文件 61

可执行目标文件 55

可执行目标文件 10, 13, 14, 15

extern 689

F

纤维分布式数据接口 (FDDI) 613

字段分隔符 644

文件描述符 599

终结器方法 699

有限状态机 598

先入后出 (FILO)

关于 154

fopen 函数

修改 377, 378

参考链接 378

fork 函数

手册页,参考链接 520

ftruncate 函数 532

函数插入 186

函数指针 2

函数指针 39, 40, 41

函数

参考链接 223

函数,C

关于 34

解剖学 34

重要性,在设计中的 35

值传递,与引用传递 36, 37, 38

栈管理 35, 36

函数签名 57

G

垃圾回收器 175

通用信号量 492, 496, 499, 500

代际垃圾回收 186

泛型指针 28, 29, 30

gets 函数

移除 376

安全考虑 376

GNU C 库 (glibc) 342

Gnu 编译器集合 (GCC) 80

GNU C 预处理器内部机制

参考链接 77

GNU Make

参考链接 765

Google 公共 DNS 服务

参考链接 617

Google 测试

关于 742, 744, 745, 746

Go 编程语言 (Golang)

与 712, 714 集成

图形用户界面 (GUI) 320

grep 命令

关于 4

H

硬件 335, 337

硬件环 516

头文件

关于 57

与源文件对比 56, 57, 58, 59

关于 175

功能 175, 176

堆内存

优点 175

分配 176, 177, 179, 180, 181, 182, 183, 184, 185

释放 176, 177, 179, 180, 181, 182, 183, 184, 185

缺点 175

原则 186, 188, 189

与之合作,指南 189

堆段

关于 154, 155, 156, 157, 158

Helgrind(来自 Valgrind)

关于 752

参考链接 752

Hello World 系统调用

写入,针对 Linux 348, 349, 350, 352, 353, 354, 355

击中 194

水平扩展 544

主机架构 66

人类用户 319

超文本传输协议 (HTTP) 595

I

IBM 国际组件 Unicode (ICU) 381

隐式封装 221, 297

工业以太网 (IE) 613

信息隐藏 230, 232, 233, 234, 235, 236, 237

继承 297, 298, 299, 300, 301, 303

关于 258, 259, 260, 261

方法,比较 274, 275

方法,在 C 中 261, 262, 263, 264, 266, 267, 268, 269, 271, 272, 273, 274

inode

关于 152

基于实例的方法 240

集成开发环境 (IDE) 62

集成

与其他编程语言 678

英特尔检查器 753

接口 291

交织 415, 419

中间目标文件。参见 可重定位目标文件

互联网控制消息协议 (ICMP) 616

互联网协议套件 (IPS) 623

解释器 708

进程间通信 (IPC)

关于 591

中断信号 566

内在并发问题

关于 415, 417, 418, 419, 420, 421, 422, 423, 424

不变约束 415

IPC 协议,特性

关于 596

内容类型 597

消息长度 597, 598

顺序性 598, 599

IPC 技术

关于 592

IP 版本 4 (IPv4) 615

IP 版本 6 (IPv6) 615

J

Java

与 695 的集成

本地部分,编写 700, 701, 702, 704, 707, 708

部分,编写 695, 696, 698, 699, 700

Java 本地接口 (JNI) 695

Java 运行时环境 (JRE) 678

JavaScript 对象表示法 (JSON) 211

Java 标准版 (Java SE) 678

Java 虚拟机 (JVM) 215, 749

关于 133

可连接的线程 465

JVM 语言 695

K

内核

构建 355, 356, 357, 359, 360, 361

使用 331, 332, 333, 334

内核开发 346, 347

内核空间

与用户进程对比 332

内核级线程 458

内核模块

关于 364

添加,到 Linux 365, 366, 367, 368, 369, 370

内核进程

关于 331

与用户进程对比 331, 332

内核空间 332

内核线程 458

L

后进先出 (LIFO)

关于 154

长度前缀帧 598

测试,针对 C 语言 731, 732

链接错误 92

链接失败 54

链接规范 689

链接器

关于 55, 83, 84

工作 84, 85, 86, 87, 88, 89, 90, 91, 92, 93

链接步骤,C 编译器管道

关于 72, 73, 75

错误示例 93, 94, 95, 96, 97, 98

程序,为新架构构建 72

工具,为新架构 73

链接时间 84

Linux

Hello World 系统调用,编写于 348, 349, 350, 352, 353, 354, 355

内核模块,添加到 365, 366, 367, 368, 369, 370

系统调用,添加到 346

Linux 内核 363

Linux 内存分配器

参考链接 140

监听器-连接器序列

关于 627

数据报连接器序列 630

数据报监听器序列 629

流连接器序列 628

流监听序列 627, 628

LLVM 地址检查器 (ASan) 751

LLVM 地址检查器 (ASAN) 185

LLVM 线程检查器 (TSan) 753

加载器

关于 147

局域网 (LAN) 612

局部变量 501

可锁定对象 437

锁 430

逻辑错误 748

循环展开 18

低级虚拟机 (LLVM) 80

M

关于 4

优点 16, 17, 18

应用程序 5

定义 5, 6, 7, 8, 9, 10, 11, 12

缺点 16, 17, 18

可变参数宏 12, 13, 15

主线程 452, 457

Make 755, 757, 758, 759, 760, 761, 762, 763, 764, 765

Makefile 757

序列化对象 697

序列化 594

材料

获取 679

Maven 757

媒体访问控制 (MAC) 613

memfd_create 函数 533

内存分配 (malloc) 179

内存错误 748

内存检查器

关于 750, 752

功能 750

技术 751

内存一致性协议 439

内存受限环境

关于 191

压缩 192

外部数据存储 192, 193

打包结构 192

内存泄漏 181

内存泄漏 175

内存管理单元 (MMU) 334

内存映射

关于 148, 149, 151, 152

地址范围 152

描述 152

设备 152

索引节点 152

偏移 152

路径名 152

权限 152

内存池

使用 199

内存分析器 472

内存结构

关于 135, 136

消息队列中间件 (MQM) 607

方法 34

微内核

与单核内核 362, 363 对比

缺失 194

mmap 函数 532

模块化 39

监视对象 446

Multics 操作系统

与 Unix 310, 311, 312 对比

参考链接 310

与 Unix 311 对比

多处理器单元 439, 440, 441, 443

多进程 455

多进程 447, 449, 451, 452

多线程 447, 452, 453, 543, 544

关于 390

多线程方法 457

多线程,与多进程对比

关于 543, 544

互斥锁 434, 435, 437, 438

互斥锁 (mutual exclusion) 436

互斥 492

互斥属性 445

N

命名互斥锁

关于 548, 553

取消标志,设置 564, 566, 568

示例 553, 554, 555, 556, 557

全局声明 558, 559

共享内存 562, 563, 564

取消标志的共享内存 559, 560, 561

命名 POSIX 信号量

关于 548, 549

命名变量条件 549, 550, 551, 552

命名变量条件

关于 548, 568

共享 32 位整数计数器的类别 572, 574

共享条件变量的类别 577, 578, 581

共享内存的类别 569, 572

共享互斥锁的类别 574, 577

主逻辑 581, 583, 584, 585, 586, 587

名字混淆,C++ 98, 99, 100

本地 POSIX 线程库 (NPTL) 460

NB (New B) 语言 313

嵌套结构 49, 50

网络文件系统 (NFS) 593

网络文件系统 (NFS) 530

网络接口控制器 (NIC) 612

网络套接字

关于 635, 671

TCP 客户端 672, 673

TCP 服务器 671, 672

UDP 客户端 675

UDP 服务器 674, 675

与 Unix 域套接字 635, 636

Ninja

关于 771, 772

非阻塞 I/O 方法 634

无返回函数 379, 380

O

对象

与类 240, 241, 242

对象构造

规划 212

对象文件 69, 70, 83

对象文件格式

关于 4, 5

操作系统 4

参考链接 4

面向对象构造,C++

关于 293

抽象类 307

封装 293, 295, 296

继承 297, 298, 299, 300, 301, 303

多态 304, 305, 306

面向对象设计 (OOD) 208

面向对象思维

关于 203

域 210

心理概念 204, 205

思维导图 206, 207

对象属性 209

对象行为 214, 215

对象模型 206, 207

面向对象操作 211, 212, 213, 214

对象,避免在代码中 208, 209

对象,关系 210, 211

洋葱模型,Unix 架构

关于 317, 319

硬件 318, 335

内核 318, 331

Shell 318, 324, 325, 326, 327, 328, 329, 330

用户应用程序 318

开放系统互联(OSI)623

优化 18

过度抽象 290

所有权者 176

所有权策略 187

P

填充技术 47

并行性 393, 394

父线程 464

解析器 77

值传递

与按引用传递相反 36, 37, 38

模式匹配功能 763

性能错误 748

性能分析器

关于 753

功能 753

程序和工具包 753

性能调整 18

性能环境

关于 193

分配和释放成本 198, 199

缓存友好代码 194, 197, 198

缓存 193, 194

内存池 199

pics 工具 534

平台 55

指针

大小 31

POISX 线程 API

关于 460

轮询 430

多态 40, 304, 305, 306

关于 275, 276, 277, 278

在 C 中 280, 281, 282, 283, 284, 285, 286, 287

需求 279

可移植执行 (PE) 4

可移植操作系统接口 (POSIX)

关于 323

参考链接 323

位置无关代码 (PIC) 26

POSIX

并发 446

POSIX 障碍

使用 489, 491, 492

POSIX 兼容的系统

方法,用于状态转移 458

POSIX 并发控制 482

POSIX 条件变量 485, 487, 488, 489

POSIX 内存

关于 500

堆内存 506, 508, 510, 511

内存可见性 511, 513

栈内存 501, 502, 503, 504, 505

POSIX 消息队列

关于 606, 607, 609

POSIX 互斥锁 482, 484

POSIX 管道

关于 604, 605, 606

POSIX 提供的控制机制

命名互斥锁 548

命名 POSIX 信号量 548

命名变量条件 548

POSIX 信号量

关于 492

二进制信号量 492, 494, 495

通用信号量 496, 499, 500

POSIX 共享内存

关于 530, 531, 532, 533, 534, 536

POSIX 信号

关于 600, 601, 602, 603

POSIX 套接字库

关于 625

POSIX 线程 (pthread) 库 456

POSIX 线程

关于 460

参考链接 459

分叉 461, 462, 463, 464, 466

POSIX 线程 500

后同步问题

关于 425, 426, 427

死锁 425

新的内建问题 425

优先级反转 425

饥饿 425

预占 437

预处理 3, 4

预处理器

关于 75, 77, 78

预处理器指令

关于 3

条件编译 19

宏 4

原始数据类型 (PDTs) 42

进程 397, 398

进程执行

加载程序,职责 527

步骤 527, 528

进程执行 API

关于 515, 516, 517

进程创建,与进程执行 526

用于创建进程 518, 519, 520, 521, 522, 523

用于执行进程 523, 524, 525, 526

进程标识符 (PID) 333

关于 134

进程 ID (PID) 457

进程映像 523

进程管理单元 334

进程内存布局

关于 134, 135

procfs 文件系统

关于 150

产品 83

基于原型的 212

pstree 518

pthread_barrier_init 函数 491

pthread_cond_signal 函数 489

pthread 库

关于 460

公共交换电话网络 (PSTN) 618

基于拉取的技术

关于 528

文件系统 529

网络服务 529

共享内存 529

与基于推送的技术对比 593

拉取状态 528

纯虚函数 307

基于推送的技术

关于 529

Python

与其他语言的集成 708, 710, 711, 712

R

竞态条件

示例 467, 468, 469, 470, 472, 473, 474, 475, 476

竞态检测器

关于 419

使用 419

接收状态 528

参考 22, 74

关系型数据库管理系统示例 530

可重定位目标文件

关于 55, 61, 5, 6, 7, 9

评论请求 (RFC) 596

资源获取即初始化 (RAII) 189

责任,Unix 内核

设备管理 333

进程间通信 (IPC) 333

内存管理 333

进程管理 332

调度 333

系统启动 333

恢复状态 528

风险 185

强健的互斥锁 577

轮询算法 448

S

扩展规模 544

扩展规模 544

范围 176

安全壳 (SSH) 322

关于 135

信号量

关于 434, 435, 436, 437, 438

锁定 437

解锁 437

半死锁情况 425

发送状态 528

分隔符 598

序列化 594

序列化/反序列化库,计算器项目

关于 646, 648

客户端序列化/反序列化函数 650, 651

服务器端序列化/反序列化函数 648, 650

服务器程序

示例 402

共享库文件 55

共享内存

用于演示数据竞争示例 536, 539, 540, 541

共享内存对象

与文件系统中的文件对象相对比 541

共享对象文件

手动加载 31

手动加载 29

共享对象文件的工作原理

参考链接 26

共享进程互斥锁 577

共享状态 407, 408, 409, 410, 411

共享状态

关于 528

文件系统 541, 543

共享技术 528, 529

Shell 324, 325, 326, 327, 328, 329, 330

短指针

关于 22

信号处理程序 565

信号行为 581

简单 Unix 规范 (SUS) 318

单主机通信

关于 599

文件描述符 599

POSIX 消息队列 606, 608, 609

POSIX 管道 604, 605, 606

POSIX 信号 600, 601, 602, 603

Unix 域套接字 610

单主机并发控制 548

单主机多进程 544

单线程程序 452

睡眠函数 325

睡眠/通知机制 430, 431, 433

破坏 162

套接字

关于 624

描述符 630

套接字描述符 457, 541

套接字对象,属性

SOCK_DGRAM 626

SOCK_RAW 626

SOCK_STREAM 625

套接字编程

关于 610, 624

套接字编程

通道,建立 634

概述 634, 635, 636

基于拉取的技术 634

基于推送的技术 634

套接字 457

使用 634

软件系统

测试 718

测试,级别 718, 720

版本控制管理 (SCM) 768

自旋锁 484

自旋锁定 437

与自旋锁相关的类型和函数 485

自旋锁

关于 427, 428, 429, 443

条件变量 445, 446

使用 444

栈 36

关于 162, 174

探测 163, 164, 165, 166, 167, 168, 169, 170

范围概念 170

栈帧

创建 470

栈帧

关于 154

栈库

编写 680, 681, 683, 685, 686

栈管理 35, 36

栈内存

使用 170, 171, 172, 173, 174

栈溢出错误 36

栈段

关于 147, 153, 154

标准 C 库

绕过 341, 342, 343, 344

饥饿任务 414

带状态的服务对象 653

无状态服务对象 652

静态构造函数 696

静态库 61

静态库

关于 15, 16, 17, 19, 21, 22, 23, 24

静态内存布局

关于 135, 136, 137, 138

BSS 段 138, 139, 140

数据段 140, 141, 142, 143, 144, 145

文本段 145, 146, 147

静态竞态检测器 419

流客户端 661, 662, 663

流连接器序列

关于 628

流监听器序列 627, 628

流服务器 653, 654, 655, 656, 657, 658, 660, 661

结构指针 50, 51

结构

关于 41, 42

42 需求

嵌套结构 49, 50

任务,执行 44

用户定义类型 (UDTs) 42, 43

结构变量

内存变量 44, 45, 46, 47, 48

符号 85

同步机制 415

同步技术

关于 426, 427

忙等待 427, 428

多处理器单元 439

互斥锁 434

信号量 434

睡眠/通知机制 430

自旋锁 427, 428

系统调用函数 344, 345, 346

系统调用 325

关于 339, 340, 341

添加到 Linux 346

直接调用 341, 342, 343, 344

System V ABI,HTML 版本

参考链接 5

System V 信号量,与 POSIX 信号量对比

参考链接 552

T

标签-值-长度 (TLV) 598

目标架构 66

任务 447

任务调度单元

关于 395

事实 395, 396

任务调度 395

TCP 客户端 672, 673

TCP 服务器 671, 672

技术债务 185

技术,用于访问共享状态

环境变量 452

文件系统 450

内存映射文件 450

消息队列 452

网络 450

管道 451

共享内存 451

信号 451

Unix 域套接字 451

电话网络

类比 618

终端 566

可测试代码

参考链接 727

测试替身

关于 728, 729

参考链接 729

文本段

关于 145, 146, 147

线程调试器

关于 752

Helgrind(来自 Valgrind)752

Intel 检查器 753

LLVM ThreadSanitizer (TSan) 753

类型 752

线程 ID (TID) 457

线程池 543

线程 397, 457, 458, 459

线程安全库 459

线程安全程序 459

分时 448

时间效用 198

转移状态 528

译文单元 59, 63

传输控制协议 (TCP) 619

传输初始化序列 620

truss 实用程序 326

try-with-resources 700

双向通道 635

双向通信 635

类型泛型宏 380

U

UDP 客户端 675

UDP 服务器 674, 675

Unicode 381, 383, 384, 386, 387, 388

单元测试

关于 720, 721, 723, 724, 725, 726, 727, 728

Unix

和 Multics 操作系统 310, 311, 312

架构 314

历史 310

管理器 (MM) 335

洋葱模型 317, 318, 319

哲学 315, 316, 317

哲学,参考链接 315

调度器 335

Unix 域套接字

关于 610

Unix 域套接字 (UDS) 633

关于 635, 653

数据报客户端 669, 670

数据报服务器 664, 665, 666, 668, 669

流客户端 661, 662, 663

流服务器 653, 654, 655, 656, 657, 658, 660, 661

与网络套接字相比 635

Unix 内核

单一内核,与微内核相比 362, 363

反序列化 594

未命名的信号量 494

用户应用程序 319, 320, 321, 322, 323

用户数据报协议 (UDP) 620

用户定义类型 (UDTs) 42, 43

用户空间 332

用户级线程 458

用户进程 331

用户空间 332

用户空间术语 448

UTF-16

关于 382,388

参考链接 383

UTF-18

参考链接 383

UTF-32

关于 382,388

参考链接 383

V

可变指针

关于 22

算术运算,执行 24,26,28

语法 22,23

可变参数宏 12,14,15

垂直缩放 544

虚拟函数 40,291

虚拟方法 305

虚拟表 (vtable) 306

易失性变量 513

W

宽字符 382

Win32 API 460

Windows 线程 API

参考链接 460

Z

零拷贝接收机制

参考链接 541

posted @ 2025-10-06 13:13  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报