C---系统编程实用指南-一-

54 阅读1小时+

C++ 系统编程实用指南(一)

原文:zh.annas-archive.org/md5/F0907D5DE5A0BFF31E8751590DCE27D9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

通过本书,我们旨在为您提供对 Linux/Unix 系统编程的理解,对 Linux 系统调用的参考手册,以及使用 C++编写更智能、更快速代码的内幕指南。本书将解释 POSIX 标准函数与现代 C++提供的特殊服务之间的区别。

本书还将教读者有关基本 I/O 操作,如从文件中读取和写入,高级 I/O 接口,内存映射,优化技术,线程概念,多线程编程,POSIX 线程,分配内存和优化内存访问的接口,基本和高级信号接口以及它们在系统中的作用。本书还将解释时钟管理,包括 POSIX 时钟和高分辨率定时器。最后,本书使用现代示例和参考资料,为 C++和更广泛的社区提供最新的相关性,包括指导支持库及其在系统编程中的作用。

这本书适合谁

这本书适用于初学者到高级 Linux 和一般 UNIX 程序员,他们使用 C++,或者任何寻求 Linux、C++17 和/或使用 POSIX、C 和 C++进行系统编程的人。尽管本书涵盖了许多关于现代 C++的主题,但它的重点是系统编程。预期读者已经对 C 和 C++有一般的了解,因为本书将在整个过程中利用它们。

这本书涵盖了什么

第一章《开始系统编程》为本书奠定了基础,通过提供一些基本示例并解释使用 C++进行系统编程的好处来定义系统编程是什么。

第二章《学习 C、C++17 和 POSIX 标准》回顾了 C、C++和 POSIX 标准,提供了每个标准提供的设施的概述,以及对本书中将讨论的主题的一般概述。

第三章《C 和 C++的系统类型》提供了 C 和 C++提供的系统类型的全面概述,以及在进行系统编程时如何使用它们。本章还将讨论许多与本机类型相关的缺陷以及如何克服它们。

第四章《C++,RAII 和 GSL 复习》提供了 C++17 提供的增强功能的概述。本章还将讨论资源获取即初始化RAII)的好处,以及在进行系统编程时如何利用它。本章将以对指导支持库的概述结束,该库在本书中用于帮助维护 C++核心指导方针的合规性。

第五章《Linux/Unix 系统编程》提供了对基于 Linux/UNIX 系统的编程的全面概述,包括 System V 规范的概述,编程 Linux 进程和基于 Linux 的信号。

第六章《学习编程控制台输入/输出》提供了如何利用 C++来进行控制台输入和输出的完整概述,包括std::coutstd::cin。还将讨论更高级的主题,如如何处理自定义类型。

第七章《内存管理的全面视角》提供了对 C 和 C++提供的内存管理设施的完整审查。在本章中,我们将回顾 C 的不足之处,以及现代 C++如何克服其中许多不足之处。

第八章,学习使用文件输入/输出,回顾了如何使用 C++17 读取和写入文件,并将这些功能与 C 提供的功能进行比较。此外,我们还将深入研究 C++17 提供的用于处理磁盘上的文件和目录的std::filesystem附加功能。

第九章,分配器的实践方法,介绍了 C++分配器以及如何利用它们进行系统编程。与大多数其他描述 C++分配器的尝试不同,我们将指导您如何创建多个真实世界的有状态分配器示例,包括内存池分配器,并演示其潜在的性能优势。

第十章,使用 C++编程 POSIX 套接字,概述了如何使用 C++编程 POSIX 套接字(即网络编程)并提供了一系列示例。在本章中,我们还将讨论与 POSIX 套接字相关的一些问题以及如何克服这些问题。

第十一章,Unix 中的时间接口,全面介绍了 C 和 C++提供的时间接口,以及如何在系统编程中一起使用它们来处理时间,包括如何使用接口进行基准测试。

第十二章,学习使用 POSIX 和 C++线程,讨论了 POSIX 和 C++提供的线程编程和同步功能,以及它们之间的关系。我们还将提供一系列示例,演示如何利用这些功能。

第十三章,异常处理,涵盖了 C 和 C++的错误处理,包括 C 和 C++的异常。在本章中,我们还将演示一系列示例,展示利用 C++异常而不是传统的 C 错误处理的好处。

为了充分利用本书

读者应该对 C 和 C++有一般的了解,并且能够在 Linux 上编写、编译和执行 C 和 C++应用程序。为了执行本书中的示例,读者还应该能够访问一台运行 Ubuntu Linux 17.10 或更高版本的基于英特尔的计算机。读者还应该确保使用以下方法安装了 GCC 7.0 或更高版本:

sudo apt-get install build-essential

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在**github.com/PacktPublishing/**上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“例如,看看使用std::array{}还是std::vector{}命令的区别。”

代码块设置如下:

int array[10];

auto r1 = array + 1;
auto r2 = *(array + 1);
auto r3 = array[1];

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

int main()
{
    auto ptr1 = mmap_unique_server<int>(42);
    auto ptr2 = mmap_unique_client<int>();
    std::cout << *ptr1 << '\n';
    std::cout << *ptr2 << '\n';
}

任何命令行输入或输出都以以下方式编写:

> cmake -DCMAKE_BUILD_TYPE=Release ..
> make

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

警告或重要说明会以这种方式出现。

技巧和窍门会以这种方式出现。

第一章:开始系统编程

在本章中,我们将讨论系统编程是什么(即,向操作系统发出系统调用以代表您执行操作),并深入探讨系统编程和使用 C++进行系统编程的利弊。

在本章中,我们将回顾以下内容:

  • 系统调用,包括它们是什么,如何执行它们以及与它们相关的潜在安全风险

  • 在系统编程时使用 C++的好处

技术要求

为了遵循本章的示例,读者必须具备:

  • 能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

理解系统调用

操作系统是一种旨在同时执行一个或多个应用程序的软件,同时还提供这些应用程序执行所需的资源。为了实现这一点,操作系统必须能够在同一时间将硬件资源分配给系统上执行的所有应用程序。

例如,大多数个人电脑(PC)都有一个存储所有文件的硬盘,这些文件是 PC 所有者正在使用的。在现代 PC 上,用户可能希望同时执行几个应用程序,例如网络浏览器和办公套件。

这两个应用程序都需要在执行时不同的时间独占访问硬盘。对于网络浏览器,这可能是将网站缓存到磁盘中,而对于办公套件,这可能是存储文档。

操作系统有责任管理应用程序及其对硬盘的访问,以确保网络浏览器和办公套件都能正常执行。

为了实现这一点,操作系统提供了应用程序编程接口(API),应用程序可以利用这些接口来完成其任务。访问硬盘就是其中一个任务的例子。read()write()函数是 POSIX 兼容操作系统提供的 API 的例子,用于从文件描述符读取和写入数据。

在底层,这些 API 使用称为系统调用的应用程序二进制接口(ABI)向操作系统发出调用。执行系统调用以完成操作系统提供的任务的行为称为系统编程,这是本书的主要重点。

系统调用的解剖学

在本节中,我们将重点关注英特尔 x86 架构的示例,尽管这些示例适用于大多数其他 CPU 架构。

原始的 x86 架构利用中断提供系统调用 ABI。操作系统提供的 API 将在 CPU 上编程特定寄存器,并使用中断调用操作系统。

例如,使用 BIOS,应用程序可以使用int 0x13从硬盘中读取数据,其寄存器布局如下:

  • AH = 2

  • AL:要读取的扇区

  • CH:柱面

  • CL:扇区

  • DH:磁头

  • DL:驱动器

  • ES:BX:缓冲区地址

应用程序作者将使用read()API 命令来读取这些数据,而在底层,read()将使用前面的 ABI 执行系统调用。当int 0x13执行时,应用程序将被硬件暂停,操作系统(在本例中为 BIOS)将代表应用程序执行从磁盘中读取数据,并将结果返回到应用程序提供的缓冲区中。

完成后,BIOS 将执行iret(中断返回)以返回到应用程序,然后应用程序将从磁盘中读取的数据等待在其缓冲区中以供使用。

采用这种方法,应用程序不需要知道如何在特定计算机上与硬盘进行物理接口,以便读取数据;这是操作系统及其设备驱动程序应该处理的任务。

应用程序也不必担心可能正在执行的其他应用程序。它只需利用提供的 API(或 ABI,取决于操作系统),其余繁琐的细节由操作系统处理。

换句话说,系统调用提供了应用程序之间的清晰界限,以帮助用户完成特定任务,并帮助操作系统管理这些应用程序和它们所需的硬件资源。

然而,中断是缓慢的。硬件不会对操作系统的编写方式或操作系统正在执行的应用程序的编写或组织方式做任何假设。因此,中断必须在执行中断处理程序之前保存 CPU 状态,并在执行iret命令时恢复此状态,导致性能不佳。

正如将要展示的那样,应用程序在尝试执行其任务时会进行大量的系统调用,这种性能不佳成为 x86 架构(以及其他 CPU 架构)的瓶颈。

为了解决这个问题,现代版本的 Intel x86 CPU 提供了快速系统调用指令。这些指令专门设计用于解决基于中断的系统调用的性能瓶颈。然而,它们需要 CPU、操作系统和在该操作系统上执行的应用程序之间的协调,以减少开销。

具体来说,操作系统必须以 CPU 指定的特定方式构造自身和正在运行的应用程序的内存布局。通过预定义操作系统及其相关应用程序的内存布局,CPU 在执行系统调用时不再需要保存和恢复太多 CPU 状态,从而减少开销。如何实现这一点取决于您是在 Intel 还是 AMD x86 CPU 上执行。

关于系统调用的执行方式最重要的一点是,系统调用并不廉价。即使有快速系统调用支持,系统调用也必须执行大量的工作。在通过read()API 从硬盘读取数据的情况下,必须设置 CPU 寄存器状态并执行系统调用指令。CPU 控制权被移交给操作系统,以从硬盘中读取数据。

由于可能有多个应用程序正在执行,并且尝试同时从硬盘中读取数据,因此操作系统可能必须暂停应用程序,以便为另一个应用程序提供服务。

一旦操作系统准备好为应用程序提供服务,它必须首先弄清楚应用程序试图读取的数据,这最终决定了它需要与哪个物理设备进行交互。在我们的例子中,这是一个硬盘,但在符合 POSIX 标准的系统中,它可以是任何类型的块设备。

接下来,操作系统必须利用其设备驱动程序之一从这个硬盘中读取数据。这需要时间,因为操作系统必须在硬盘上物理编程,要求从特定位置请求数据,通过一个几乎肯定不以与 CPU 本身相同的速度执行的硬件总线。

一旦硬盘最终向操作系统提供所请求的数据,操作系统可以将这些信息提供给应用程序,并返回控制权,将 CPU 状态恢复给应用程序。所有这些繁琐的工作都被一个read()调用所隐藏。

因此,系统调用应该谨慎执行,只在绝对需要时执行,以防止导致应用程序性能不佳。

值得注意的是,这种优化类型需要对应用程序利用的 API 有深入的了解,因为更高级的 API 会代表 API 自己进行系统调用。例如,分配内存,稍后将讨论,也是另一种系统调用。

例如,看看使用std::array{}std::vector{}命令之间的区别。std::vector{}支持在底层管理的数组大小调整,这需要内存分配。这不仅可能导致内存碎片化(这本书后面将讨论的一个主题),还可能导致性能不佳,因为内存分配可能需要向操作系统请求更多的系统 RAM。

了解不同类型的系统调用

几乎在符合 POSIX 标准的操作系统上执行的每个应用程序都必须进行一些系统调用。在这里,我们概述了本书中将探讨的一些系统调用类型。

控制台输入/输出

如果您曾经执行过命令行应用程序,您将会熟悉基于控制台的输入/输出的概念。这在符合 POSIX 标准的操作系统中尤其如此。在向控制台输出时,您可以将输出发送到stdout(通常用于正常输出)或stderr(通常用于输出错误消息)。

通过应用程序执行系统调用来输出到stdoutstderr。 (值得注意的是,在本书中,我们通常说我们输出到stdout,而不是打印到控制台。)

这是因为,在符合 POSIX 标准的系统上,您的应用程序实际上并不知道将文本发送到哪里。应用程序利用 API 输出到stdout。这可以通过以下方式实现:

  • 写入专用文件句柄(即stdout

  • 使用 C API,如printf

  • 使用 C++ API,如std::cout

  • 为您输出到stdout的应用程序分叉(例如,使用echo

大多数情况下,这些示例在说到底都会向操作系统发出系统调用,将字符缓冲区传输到管理stdoutstderr的设备。在某些情况下,这会导致操作系统将生成的字符缓冲区传递给父进程(可能是您的 shell),最终父进程会再次进行系统调用,将字符缓冲区显示在屏幕上。

无论您的操作系统如何处理,操作系统中存在一个设备驱动程序,用于管理用于显示文本的物理监视器,应用程序调用的简单 API(例如printfstd::cout)最终会向该设备驱动程序提供所请求的字符缓冲区。

尽管在大多数系统上,输出到stdout的文本通常会提供给您的 shell,并最终显示在屏幕上,但这并非一定如此。由于应用程序正在进行系统调用以输出字符缓冲区,因此操作系统可以自由地将这些数据转发到串行设备、日志文件、作为另一个应用程序的输入等。

这种灵活性是符合 POSIX 标准的操作系统如此强大的原因之一,也是学习如何正确进行系统调用如此重要的原因。

内存分配

内存是应用程序必须使用系统调用请求的另一种资源。当应用程序首次执行时,大多数应用程序会获得全局和堆栈内存资源,以及应用程序在调用诸如malloc()free()等函数时可以使用的一小块堆内存。

如果应用程序只使用最初在堆中给定的内存,那么应用程序不需要请求额外的内存。然而,如果堆内存用尽,应用程序的malloc()free()引擎将不得不向操作系统(通过系统调用)请求更多内存。

为了做到这一点,操作系统将通过向应用程序添加更多的物理内存来扩展应用程序的末端。然后,malloc()free()引擎能够利用这些额外的内存,直到需要更多内存。

在内存有限的系统上,当请求额外的内存时,操作系统必须从当前未执行的其他应用程序中获取内存。它通过将这些应用程序交换到磁盘上来实现这一点,这是一种昂贵的操作。

因此,在资源受限的系统上,不应该在时间关键的代码中调用malloc()free(),因为执行这些函数所需的时间可能会有很大的变化。

我们将在第七章中更详细地介绍内存管理,全面了解内存管理

文件输入/输出

读写文件是大多数应用程序的另一个常见用例,需要进行系统调用。

值得注意的是,在符合 POSIX 的系统上,读取和写入文件描述符并不总是意味着读取和写入存储设备上的文件。相反,您所做的系统调用会写入字符设备。这可能是一个存储设备,但也可能是一个控制台设备,甚至是一个虚拟设备,比如/dev/random,在读取时提供随机数据。

在第八章中,学习文件输入/输出编程,我们将提供有关文件输入/输出系统编程的更多信息。

网络

网络是另一个常见的用例,需要进行系统调用。在符合 POSIX 的系统上,我们通过使用 POSIX 套接字进行基于网络的系统编程。套接字提供了与操作系统中的网络接口控制器NIC)和支持逻辑(例如 TCP/IP 协议栈)进行编程的 API。

网络本身是一个非常复杂的主题,值得有一本专门的书来讨论,但幸运的是,执行这种类型的编程所需的系统调用是简单的,大部分繁琐的细节由操作系统处理。

在第十章中,使用 C++编程 POSIX 套接字,我们将更详细地介绍如何使用套接字 API 进行这些类型的系统调用。

时间

一些读者可能会感到惊讶,甚至执行简单的任务,如获取当前日期和时间,都需要系统调用来向操作系统请求这些信息。直到今天,系统上都提供了一个专用芯片(带有电池,以防断电)来维护当前的日期和时间。

如果需要这些信息,必须进行系统调用来请求。当这种情况发生时,操作系统将询问负责管理芯片的设备驱动程序当前存储的日期和时间,然后将此信息返回给应用程序。

值得注意的是,并非所有时间接口都需要系统调用。例如,大多数高分辨率定时器,它们旨在在操作发生前后比较高分辨率数字,不需要操作系统执行此操作。这是因为这些高分辨率定时器通常直接存在于 CPU 中,并且它们的值可以使用简单的指令提取。

这些类型的定时器的缺点是它们的值本身通常是没有意义的(也就是说,返回的值之间的差异才提供了意义,而不是值本身)。基本上,这些定时器通常只是一个计数器,每次 CPU 滴答(也就是执行一条指令)时递增。

由于现代 CPU 可以动态改变其频率,这些计数器存储的值取决于 CPU 自上次上电以来执行的时间长短,以及 CPU 在执行时设置的频率。

甚至不能保证一个计数器中的值与另一个物理核上的另一个计数器中读取的值相同,因为每个物理核都能够独立于多核 CPU 上的其他核改变自己的频率。

高分辨率定时器的好处是它们可以非常快地执行(因为您只是执行一个读取 CPU 中计数器的指令)。两个测量值之间的差异可以用来执行诸如测量执行小函数所需时间的任务,这通常无法使用标准定时器完成,因为它们没有足够的粒度。

在第十一章中,《Unix 中的时间接口》,我们将详细介绍这些细节,甚至提供如何自己实现的示例。

线程和进程创建

同时执行多个任务可以通过请求操作系统创建额外的线程(甚至新进程)来实现。这是系统编程中的常见任务,有许多系统调用可以完成这项工作。

进程是一个执行单元,为其分配了一组资源(例如内存、文件描述符等)。每个应用程序至少由一个进程组成,但它们可以包含多个进程(例如,shell 是一个专门设计用于运行多个子进程的应用程序)。

每个进程由操作系统安排执行一定的时间,然后下一个进程获得 CPU 的访问权限,这个循环根据需要继续进行。

线程类似于进程,但它们与同一进程的其他线程共享相同的资源。线程为应用程序提供了一个机会,可以创建能够并行执行的任务,而无需使用进程间通信方法。在第十二章中,《学习使用 POSIX 和 C++线程编程》,我们将学习如何使用 POSIX 和 C++ API 编程线程。

系统调用安全风险

系统调用并非没有安全风险。即使在现代硬件上,使用英特尔以外的 CPU 架构,在操作系统中执行多个进程并实现进程之间的完全隔离几乎是不可能的。

尽管现代硬件和现代操作系统都在努力提供最佳的隔离和安全性,但应始终假定与您同时执行的其他恶意进程可能能够窥探您的操作,包括解密用户数据等敏感任务。

这是另一个值得一本专门书籍的主题,但在这里,我们将简要讨论影响系统编程的两种不同的最近的安全漏洞。

SYSRET

英特尔和 AMD 提供的快速系统调用接口并非没有问题。如前所述,为了使快速系统调用正常工作,硬件、操作系统和应用程序必须协调。这是为了确保 ABI 信息得到正确处理,以允许操作系统在执行系统调用之前无需硬件保存整个 CPU 状态。

当系统调用完成并且必须将控制权交还给应用程序时也是如此。为了实现这一点,操作系统必须加载应用程序的堆栈,然后执行SYSRET指令,将控制权返回给应用程序。

这种方法的问题在于不可屏蔽中断(NMI)可能会在操作系统加载应用程序的堆栈和执行SYSRET之间触发。这种竞争条件的结果是,NMI(以根权限执行的代码)将使用应用程序的堆栈而不是内核的堆栈执行,从而可能导致安全漏洞或损坏。

值得庆幸的是,现代操作系统有办法防止这种类型的攻击,大多数操作系统,如 Linux,都可以并且确实利用这些方法。

Meltdown 和 Spectre

熔断和幽灵攻击是系统调用实现的复杂性的现代例子。为了支持系统调用的快速执行,内核的内存被映射到每个应用程序中,使用一种称为 3:1 分割的内存布局技术,指的是应用程序内存与内核内存的三比一的比例。

为了防止应用程序读取/写入内核内存,这些内核内存可能包含高度敏感的信息,如加密密钥和密码,现代 CPU 架构提供了一种机制来锁定内核内存的部分,以便只有内核能够看到所有内容。应用程序只能看到其部分特权内存。

为了提高这些现代 CPU 的性能,包括英特尔、AMD 和 ARM 在内的大多数架构都采用了一种称为推测执行的技术。例如,看下面的代码:

if (x) {
    do_y();
}

do_z();

CPU 在执行这条指令之前不知道x是真还是假。如果 CPU 假设x是真,它可以通过节省一些 CPU 周期来提高性能。如果x实际上是真的,CPU 就能节省周期,而如果x实际上是假的,惩罚通常是值得冒的风险,特别是如果 CPU 能够对x是真还是假进行合理猜测(例如,如果 CPU 在过去执行过这个语句并且x是真的)。

这种优化称为推测执行。CPU 正在执行代码,即使可能以后代码可能被证明是无效的并需要撤销。

像熔断和幽灵这样的推测执行攻击利用这一过程,绕过保护系统调用接口的内存保护,这个接口位于应用程序和其内核之间。这是通过说服 CPU 进行推测执行一个通常会导致安全违规的指令(例如,尝试从内核内存中读取密码)来完成的。

如果 CPU 推测执行这种类型的指令,CPU 将在 CPU 加载密码到 CPU 缓存和 CPU 发现发生安全违规之间存在一个间隙。如果 CPU 在这个间隙期间被中断(使用所谓的瞬态指令),密码将留在 CPU 缓存中,即使指令实际上并没有完成执行。

为了从缓存中恢复密码,攻击者利用了对 CPU 的额外攻击,称为侧信道攻击,这些攻击专门设计用来读取 CPU 缓存的内容,而不执行直接的内存操作。

最终结果是,攻击者能够设置一系列复杂的条件,最终允许他们使用一个非特权应用程序(可能是你在寻找猫视频时点击的网站)恢复存储在内核中的敏感信息。

如果这看起来很复杂,那是因为它确实很复杂。这些类型的攻击非常复杂。这些例子的目标是提供关于系统调用并非没有问题的简要概述。根据你所执行的 CPU 和操作系统,你在处理敏感信息时可能需要特别小心。

在系统编程时使用 C++的好处

尽管本书的重点是系统编程而不是 C++,我们确实提供了很多 C++的例子,但与标准 C 相比,C++在系统编程中有几个好处。

请注意,本节假定读者对 C++有一些基本知识。有关 C++标准的更完整解释将在第二章中提供,学习 C、C++17 和 POSIX 标准

C++中的类型安全

标准 C 不是一种类型安全的语言。类型安全是指为防止一种类型与另一种类型混淆而采取的保护措施。一些语言,如 ADA,非常类型安全,提供了许多保护措施,以至于有时使用该语言可能会令人沮丧。

相反,像 C 这样的语言是如此不安全,以至于很难找到类型错误,而且经常导致不稳定性。

C++在这两种方法之间提供了一个折衷方案,鼓励默认情况下合理的类型安全性,同时在需要时提供规避这一点的机制。

例如,考虑以下代码:

/* Example: C */
int *p = malloc(sizeof(int));

// Example: C++
auto p = new int;

在 C 中在堆上分配整数需要使用malloc(),它返回void *。这段代码存在几个问题,在 C++中得到了解决:

  • C 自动将void *类型转换为int *,这意味着即使用户声明的类型与返回的类型之间没有连接,隐式类型转换仍然发生了。用户可以轻松地分配short(这与int不同,这是我们将在第三章中讨论的一个主题,C 和 C++的系统类型)。类型转换仍然会被应用,这意味着编译器无法正确地检测到分配的空间对于用户尝试分配的类型来说不够大。

  • 程序员必须声明分配的大小。与 C++不同,C 不了解正在分配的类型。因此,它不知道类型的大小,因此程序员必须明确声明这一点。这种方法的问题在于可能引入难以发现的分配错误。通常,提供给sizeof()的类型是不正确的(例如,程序员可能提供指针而不是类型本身,或者程序员可能稍后更改代码,但忘记更改提供给sizeof()的值)。如前所述,malloc()分配和返回的内容与用户尝试分配的类型之间没有关联,这提供了引入难以发现的逻辑错误的机会。

  • 类型必须明确声明两次。malloc()返回void *,但 C 隐式转换为用户声明的任何指针类型,这意味着类型已经声明了两次(在这种情况下,void *int *)。在 C++中,使用auto意味着类型只声明一次(在这种情况下,int表示类型是int *),并且auto将采用返回的任何类型。使用auto和去除隐式类型转换意味着分配中声明的任何类型都是p变量将采用的类型。如果在此分配后的代码期望p采用不同的类型,编译器将在 C++中在编译时知道这一点,而在 C 中,这样的错误可能直到运行时才会被捕获,当程序崩溃时(我们希望这段代码不控制飞机!)。

除了隐式类型转换的危险示例之外,C++还提供了运行时类型信息RTTI)。这些信息有许多用途,但最重要的用例涉及dynamic_cast<>运算符,它执行运行时类型检查。

具体来说,可以在运行时检查从一种类型转换为另一种类型,以确保不会发生类型错误。这在执行以下操作时经常看到:

  • 多态类型转换:在 C 中,多态是可能的,但必须手动完成,这是内核编程中经常见到的模式。然而,C 无法确定指针是否为基本类型分配,从而导致可能出现类型错误的可能性。相反,C++能够在运行时确定提供的指针是否被转换为正确的类型,包括在使用多态性时。

  • 异常支持:在捕获异常时,C++ 使用 RTTI(本质上是 dynamic_cast<>)来确保被抛出的异常被适当的处理程序捕获。

C++ 对象

尽管 C++ 支持使用内置构造进行面向对象编程,但面向对象编程也经常在 C 中以及 POSIX 中使用。看下面的例子:

/* Example: C */

struct point 
{
    int x;
    int y;
};

void translate(point *p; int val)
{
    if (p == NULL) {
        return;
    }

    p->x += val;
    p->y += val;
}

在前面的例子中,我们有一个存储 point{} 的结构体,其中包含 xy 位置。然后我们提供一个函数,能够使用给定的值(即对角线平移)来翻译这个 point{}xy 位置。

关于这个例子,有几点需要注意:

  • 人们经常声称不喜欢面向对象的编程,但是在他们的代码中却会看到这种情况,实际上这是一种面向对象的设计。使用类并不是创建面向对象设计的唯一方式。C++ 的不同之处在于语言提供了额外的构造来清晰、安全地处理对象,而在 C 中,这个功能必须手动完成,这个过程容易出错。

  • translate() 函数只与 point{} 对象相关,因为它将 point{} 作为参数。因此,编译器没有上下文信息来理解如何操作 point{} 结构,没有给 translate() 提供指针作为参数。这意味着每个公共函数都必须以指针作为第一个参数来操作 point{} 结构,并验证指针是否有效。这不仅是一个笨拙的接口,而且速度慢。

在 C++ 中,前面的例子可以写成如下形式:

// Example: C++

struct point 
{
    int x;
    int y;

    void translate(int val)
    {
        p->x += val;
        p->y += val;
    }
};

在这个例子中,仍然使用了一个 struct。C++ 中类和结构体的唯一区别是,结构体中的所有变量和函数默认都是公共的,而类中默认是私有的。

不同之处在于 translate() 函数是 point{} 的成员,这意味着它可以访问其结构的内容,因此不需要指针来执行翻译。因此,这段代码更安全、更确定,并且更容易理解,因为永远不会出现空指针解引用的情况。

最后,C++ 中的对象提供了构造和销毁例程,有助于防止对象未被正确初始化或正确销毁。看下面的例子:

// Example: C++

struct myfile 
{
    int fd{0};

    ~myfile() {
        close(fd);
    }
};

在前面的例子中,我们创建了一个自定义文件对象,它保存了一个文件描述符,在使用 POSIX API 进行系统编程时经常看到和使用。

在 C 中,程序员需要记住在初始化时手动将文件描述符设置为 0,并在不再使用时关闭文件描述符。在 C++ 中,使用前面的例子,每次使用 myfile 时都会为您执行这两个操作。

这是一个使用资源获取即初始化RAII)的例子,这个主题将在 第四章 中详细讨论,C++,RAII 和 GSL 刷新,因为这种模式在 C++ 中经常被使用。在系统编程时,我们将利用这种技术来避免许多常见的 POSIX 风格陷阱。

C++ 中使用的模板

模板编程经常被低估和误解,它是 C++ 中一个没有得到足够赞扬的补充。大多数程序员只需要尝试创建一个通用链表就能理解为什么。

C++ 模板使您能够在不提前定义类型信息的情况下定义代码。

在 C 中创建链表的一种方式是使用指针和动态内存分配,就像在这个简单的例子中看到的那样:

struct node 
{
    void *data;
    node next;
};

void add_data(node *n, void *val);

在前面的例子中,我们使用 void * 存储链表中的数据。使用方法如下:

node head;
add_data(&head, malloc(sizeof(int)));
*(int*)head.data = 42;

这种方法存在一些问题:

  • 这种类型的链表显然不是类型安全的。数据的使用和数据的分配完全无关,需要使用这个链表的程序员在没有错误的情况下管理所有这些。

  • 节点和数据都需要动态内存分配。正如前面讨论的,内存分配很慢,因为它们需要系统调用。

  • 总的来说,这段代码很难阅读,而且笨拙。

创建通用链表的另一种方法是使用宏。在互联网上有几种这些类型的链表(和其他数据结构)的实现,它们提供了一个通用的链表实现,无需动态分配数据。这些宏为用户提供了一种在编译时定义链表将管理的数据类型的方法。

除了可靠性之外,这些实现使用宏来实现模板编程的方式远不如优雅。换句话说,向 C 添加通用数据结构的解决方案是使用 C 的宏语言手动实现模板编程。程序员最好只使用 C++模板。

在 C++中,可以创建像链表这样的数据结构,而无需在声明之前声明链表管理的类型,如下所示:

template<typename T>
class mylinked_list
{
    struct node 
    {
        T data;
        node *next;
    };

public:

    ...

private:

    node m_head;
};

在上面的例子中,我们不仅能够创建一个不需要宏或动态分配(以及使用void *指针带来的所有问题)的链表,而且还能够封装功能,提供更清晰的实现和用户 API。

关于模板编程经常提出的一个抱怨是它生成的代码量。模板的大部分代码膨胀通常源于编程错误。例如,程序员可能没有意识到整数和无符号整数不是相同的类型,导致在使用模板时出现代码膨胀(因为为每种类型创建了一个定义)。

即使不考虑这个问题,使用宏也会产生相同的代码膨胀。没有免费的午餐。如果你想避免使用动态分配和类型转换,同时仍然提供通用算法,你必须为你计划使用的每种类型创建你的算法的实例。如果可靠性是你的目标,允许编译器生成确保程序正确执行所需的代码,将会超过这些缺点。

与 C++相关的函数式编程

函数式编程是 C++的另一个补充,它以 lambda 函数的形式为用户提供编译器的帮助。目前,这在 C 中必须手动完成。

在 C 中,可以使用回调来实现函数式编程构造。例如,考虑以下代码:

void
guard(void (*ptr)(int *val), int *val)
{
    lock();
    ptr(val);
    unlock();
}

void 
inc(int *val)
{
    *val++;
}

void 
dec(int *val)
{
    *val--;
}

void
foo() 
{
    int count = 0;
    guard(inc, &count);
    guard(dec, &count);
}

在上面的代码示例中,我们创建了一个guard函数,它锁定互斥锁,调用一个操作值的函数,然后在退出时解锁互斥锁。然后,我们创建了两个函数,一个增加给定的值,一个减少给定的值。最后,我们创建一个函数,实例化一个计数,然后使用guard函数递增计数和递减计数。

这段代码存在一些问题:

  • 第一个问题是需要指针逻辑来确保我们可以操作所需操作的变量。我们还需要手动传递这个指针来跟踪它。这使得 API 笨拙,因为我们必须为这样一个简单的例子手动编写大量额外的代码。

  • 辅助函数的函数签名是静态的。guard函数是一个简单的函数。它锁定互斥锁,调用一个函数,然后解锁它。问题在于,由于在编写代码时必须知道函数的参数,而不是在编译时,我们无法将此函数重用于其他任务。我们需要手动为计划支持的每种函数签名类型编写相同的函数。

可以使用以下 C++编写相同的示例:

template<typename FUNC>
guard(FUNC f)
{
    lock();
    f();
    unlock();
}

void
foo() 
{
    int count = 0;
    guard(inc, [&]{ count++ });
    guard(inc, [&]{ count-- });
}

在前面的例子中,提供了相同的功能,但不需要指针。此外,守卫函数是通用的,可以用于多种情况。这是通过利用模板编程和函数式编程实现的。

Lambda 提供了回调,但是回调的参数被编码到 lambda 的函数签名中,这由模板函数的使用吸收。编译器能够生成一个用于接受参数(在本例中是对count变量的引用)并将其存储在代码本身中以供使用的守卫函数的版本,从而消除了用户手动执行此操作的需要。

在本书中,前面的例子将被大量使用,特别是在创建基准测试示例时,因为这种模式使您能够将功能包装在旨在计时回调执行的代码中。

C++中的错误处理机制

错误处理是 C 的另一个问题。问题是,至少在添加了 set jump 异常之前,从函数获取错误代码的唯一方法是:

  • 限制函数的输出,以便将函数的某些输出值视为错误

  • 获取函数返回一个结构,然后手动解析该结构

例如,考虑以下代码:

struct myoutput 
{
    int val;
    int error_code;
}

struct myoutput myfunc(int val)
{
    struct myoutput = {0};

    if (val == 42) {
        myoutput.error_code = -1;
    }

    myoutput.val = val;
    return myoutput;
}

void 
foo(void)
{
    struct myoutput = myfunc(42);

    if (myoutput.error_code == -1) {
        printf("yikes\n");
        return;
    }
}

前面的例子提供了一个简单的机制,用于从函数输出错误,而无需限制函数的输出(例如,假设-1始终是一个错误)。

在 C++中,可以使用以下 C++17 逻辑来实现:

std::pair<int, int>
myfunc(int val)
{
    if (val == 42) {
        return {0, -1};
    }

    return {val, 0};
}

void 
foo(void)
{
    if (auto [val, error_code] = myfunc(42); error_code == -1) {
        printf("yikes\n");
        return;
    }
}

在前面的例子中,我们能够通过利用std::pair{}来消除对专用结构的需求,并且通过利用initializer_list{}和 C++17 结构化绑定来消除对std::pair{}的需求。

然而,还有一种更简单的处理错误的方法,而无需检查您执行的每个函数的输出,那就是使用异常。C 通过 set jump API 提供异常,而 C++提供 C++异常支持。这两者将在第十三章中详细讨论,即使用异常处理错误

API 和 C++容器在 C++中

除了 C++提供的语言原语外,它还带有标准模板库(STL)及相关 API,这些 API 极大地帮助系统编程。本书的很大一部分将专注于这些 API 以及它们如何支持系统编程。

应该注意,本书的重点是系统编程而不是 C++,因此我们不会详细介绍 C++容器,而是假设读者对它们是什么以及它们如何工作有一些基本知识。话虽如此,C++容器通过防止用户手动重写它们来支持系统编程。

我们教导学生如何编写自己的数据结构,不是为了当他们需要数据结构时知道如何编写一个,而是为了当他们需要一个时,知道使用哪种数据结构以及为什么。C++已经提供了大部分,如果不是全部,您在系统编程时可能需要的数据结构。

总结

在本章中,我们了解了什么是系统编程。我们涵盖了系统调用的一般解剖,不同类型的系统调用以及一些最近与系统调用相关的安全问题。

此外,我们讨论了使用 C++进行系统编程的优势,而不仅仅是严格使用标准 C。在下一章中,我们将详细介绍 C、C++和 POSIX 标准以及它们与系统编程的关系。

问题

  1. 什么是系统编程?

  2. 快速系统调用之前,系统调用是如何执行的?

  3. 支持快速系统调用所做的关键更改是什么?

  4. 分配内存是否总是导致系统调用?

  5. Meltdown 和 Spectre 攻击利用了什么类型的执行?

  6. 什么是类型安全?

  7. 在 C++中模板编程至少提供一个好处是什么?

进一步阅读

第二章:学习 C、C++17 和 POSIX 标准

如第一章所述,开始系统编程,系统编程是通过进行系统调用与底层操作系统协调来执行各种操作的行为。每个操作系统都有自己的一套系统调用,以及这些系统调用的执行方式也各不相同。

为了防止系统程序员不得不为每个不同的操作系统重新编写他们的程序,已经制定了几个标准,这些标准用一个明确定义的 API 包装了操作系统的 ABI。

在本章中,我们将讨论三个标准——C 标准、C++标准和 POSIX 标准。C 和 POSIX 标准提供了包装操作系统 ABI 的基本语言语法和 API。具体来说,C 标准定义了程序链接和执行,标准 C 语法(许多高级语言,如 C++,都是基于此),以及提供 ABI 到 API 包装的 C 库。

C 库可以被视为更大的 POSIX 标准的子集,后者定义了更大的 API 子集,包括但不限于文件系统、网络和线程库。

最后,C++标准定义了 C++语法、程序链接和执行,以及提供 C 和 POSIX 标准更高级抽象的 C++库。本书的大部分内容将围绕这些标准 API 以及如何在 C++17 中使用它们。

本章有以下目标:

  • 学习 C、C++和 POSIX 标准

  • 理解程序链接和执行,以及 C 和 C++之间的区别

  • 简要概述这些标准提供的功能,每个功能将在本书的后面更详细地讨论

技术要求

为了跟随本章的示例,读者必须具备:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请转到以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter02

从 C 标准语言开始

C 编程语言是最古老的语言之一。与其他高级语言不同,C 足够类似汇编语言编程,同时又提供了一些高级编程抽象,因此成为系统、嵌入式和内核级程序员的首选。

几乎每个主要的操作系统都源自 C。此外,大多数高级语言,包括 C++,都是基于 C 构建其高级构造,因此仍然需要 C 标准的一些组件。

C 标准是由国际标准化组织ISO)管理的一个庞大标准。我们假设读者对 C 标准和如何编写 C 代码有一些基本知识:www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf

因此,本节的目标是讨论一些在其他书中讨论得较少的主题,以及本书和系统编程相关的 C 标准的部分,但在其他章节中缺失。

有关 C 编程语言和如何编写 C 程序的更多信息,请参阅本章的进一步阅读部分。

标准的组织方式

该规范分为三个部分:

  • 环境

  • 语言

让我们简要讨论每个部分的目的。之后,我们将讨论 C 标准的特定部分,这些部分与系统编程相关,但在本书的其他地方没有讨论。

环境

标准的环境部分提供了主要由编译器编写者需要的信息,以更好地理解如何为 C 创建编译器。

它描述了编译器必须遵守的最低限制(例如必须支持的最小嵌套if()语句数量),以及程序是如何链接和启动的。

在本章中,我们将讨论程序链接和执行,以更好地理解创建 C 程序所需的内容。

语言

标准的语言部分提供了与 C 语法相关的所有细节,包括变量是什么,如何编写函数,for()循环和while()循环之间的区别,以及支持的所有运算符以及它们的工作原理。

这本书假设读者对标准的这一部分有一般的了解,并且只涉及标准 C 语法的系统编程特定细微差别,读者可能会遇到的问题(比如与指针相关的问题)。

标准的库部分描述了标准 C 语言提供的所有库设施。这包括向stdout输出字符串、分配内存和处理时间等设施。

系统编程主要围绕这些库设施展开,本书的大部分内容将集中在这些库设施上,它们提供了什么以及如何使用它们。

C 程序的启动方式

标准中与系统编程相关但在文献中没有被广泛讨论的一部分是 C 程序如何启动。一个常见的误解是 C 程序从以下两个入口点开始:

int main(void) {}
int main(int argc, char *argv[]) {}

虽然这实际上是 C 程序员提供的第一个函数调用,但它并不是 C 程序启动时调用的第一个函数。它也不是执行的第一段代码,也不是用户提供的第一段执行的代码。

main()函数执行之前,操作系统和标准 C 环境以及用户都进行了大量的工作。

让我们看看编译器如何创建一个简单的Hello World\n示例:

#include <stdio.h>

int main(void) 
{
    printf("Hello World\n");
}

为了更好地理解 C 程序的启动过程,让我们看看这个简单程序是如何编译的:

> gcc -v scratchpad.c; ./a.out

Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
Configured with: ...
...

通过向 GCC 添加-v选项,我们可以看到编译器编译我们的简单的Hello World\n程序所采取的每一步。

首先,编译器将程序转换为可以由gnu-as处理的格式:

/usr/lib/gcc/x86_64-linux-gnu/7/cc1 -quiet -v -imultiarch x86_64-linux-gnu scratchpad.c -quiet -dumpbase scratchpad.c -mtune=generic -march=x86-64 -auxbase scratchpad -version -fstack-protector-strong -Wformat -Wformat-security -o /tmp/ccMSWHgC.s

你不仅可以看到初始编译是如何执行的,还可以看到操作系统提供的默认标志。

接下来,编译器将输出转换为一个目标文件,如下所示:

/usr/bin/x86_64-linux-gnu-as -v --64 -o /tmp/cc9oaJWV.o /tmp/ccMSWHgC.s

最后,最后一步使用collect2实用程序将生成的目标文件链接成一个单独的可执行文件,这是一个围绕链接器的包装器:

/usr/lib/gcc/x86_64-linux-gnu/7/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/7/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper -plugin-opt=-fresolution=/tmp/ccWQB2Gf.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --sysroot=/ --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/7/../../.. /tmp/cc9oaJWV.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/7/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o

在这里有几个重要的事情需要注意关于程序是如何链接的:

  • -lc:使用此标志告诉链接器链接libc。像这里讨论的其他库一样,我们没有告诉编译器链接libc。默认情况下,GCC 会为我们链接libc

  • -lgcc_s:这是一个静态库,由 GCC 自动链接,用于支持特定于编译器的操作,包括在 32 位 CPU 上进行 64 位操作,以及诸如异常展开(这是一个将在第十三章中讨论的主题,异常处理)。

  • Scrt1.ocrti.ocrtbeginS.ocrtendS.ocrtn.o:这些库提供了启动和停止应用程序所需的代码。

具体来说,C 运行时 CRT)库是这里感兴趣的库。这些库提供了引导应用程序所需的代码,包括:

  • 执行全局构造函数和析构函数(尽管这不是标准 C 的功能,GCC 支持 C 中的构造函数和析构函数)。

  • 设置展开以支持异常支持。虽然这主要是为了 C++异常,对于仅需要标准 C 的应用程序来说是不需要的,但它们仍然需要链接到 set jump 异常逻辑中,这个话题将在第十三章中进行解释,异常处理

  • 提供_start函数,这是使用默认 GCC 编译器的任何基于 C 的应用程序的实际入口点。

最后,所有这些库都负责为main()函数提供传递给它的参数,并拦截main()函数的返回值,并在需要时代表您执行exit()函数。

这里最重要的一点是,在您的程序中执行的第一段代码不是main()函数,如果您注册了全局构造函数,它也不是您提供的第一段代码。在系统编程中,如果您遇到程序初始化的问题,这是首先要查看的地方。

关于链接的一切

链接是一个非常复杂的主题,因操作系统而异。例如,Windows 和 Linux 链接程序的方式完全不同。因此,我们将把讨论限制在 Linux 上。

当 C 源文件被编译时,它被编译成所谓的目标文件,其中包含以二进制格式定义程序中每个函数的编译源代码,如下所示:

> gcc -c scratchpad.c; objdump -d scratchpad.o

...

0000000000000000 <main>:
   0: 55 push %rbp
   1: 48 89 e5 mov %rsp,%rbp
   4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # b <main+0xb>
   b: e8 00 00 00 00 callq 10 <main+0x10>
  10: b8 00 00 00 00 mov $0x0,%eax
  15: 5d pop %rbp
  16: c3 retq

如此所示,编译器创建了一个目标文件,其中包含源代码的编译(即二进制)版本。这里的一个重要说明是,main()函数被标记为main,以纯文本形式。

让我们扩展这个例子来包括另一个函数:

int test(void)
{
    return 0;
}

int main(void)
{
    return test();
}

编译这个源文件,我们得到以下结果:

> gcc -c scratchpad.c; objdump -d scratchpad.o

...

0000000000000000 <test>:
   0: 55 push %rbp
   1: 48 89 e5 mov %rsp,%rbp
   4: b8 00 00 00 00 mov $0x0,%eax
   9: 5d pop %rbp
   a: c3 retq

000000000000000b <main>:
   b: 55 push %rbp
   c: 48 89 e5 mov %rsp,%rbp
   f: e8 00 00 00 00 callq 14 <main+0x9>
  14: 5d pop %rbp
  15: c3 retq

如此所示,每个编译的函数都使用与函数相同的名称标记。也就是说,每个函数的名称都不是混编的(不像 C++)。名称混编将在下一节中进一步详细解释,以及为什么这在链接方面很重要。

超越简单的源文件,C 程序被分成编译和链接在一起的源文件组。具体来说,可执行文件是目标文件和库的组合。库是额外目标文件的组合,分为两种不同的类型:

  • 静态库:在编译时链接的库

  • 动态库:在加载时链接的库

静态库

静态库是一组在编译时链接的目标文件。在 Linux(和大多数基于 UNIX 的系统中),静态库只是一组目标文件的存档。您可以轻松地获取现有的静态库并使用AR工具来提取原始的目标文件。

与作为程序一部分链接的目标文件不同,作为静态库一部分链接的目标文件只包括静态库所需的源代码,提供了优化,从程序中删除未使用的代码,最终减少了程序的总大小。

这种方法的缺点是,使用静态库链接程序的顺序很重要。如果在提供需要该库的代码之前(即在命令行上)链接库,将会发生链接错误,因为静态库中的代码将被优化掉。

操作系统提供的库通常也不支持静态链接,并且通常不需要静态链接操作系统库,因为这些库可能已经被操作系统加载到内存中。

动态库

动态库是在加载时链接的库。动态库更像是没有入口点的可执行文件。它们包含程序所需的代码,加载时链接器负责为程序提供每个所需函数的位置。

程序也可以在运行时链接自身作为优化,只链接需要的函数(这个过程称为延迟加载)。

操作系统提供的大多数库都是动态库。要查看程序需要哪些动态库,可以使用 LDD 工具,如下所示:

> ldd a.out
  linux-vdso.so.1 (0x00007ffdc5bfd000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f92878a0000)
  /lib64/ld-linux-x86-64.so.2 (0x00007f9287e93000)

在这个例子中,我们使用 LDD 工具列出了我们简单的Hello World\n示例所需的动态库。如下所示,需要以下库:

  • vdso:操作系统提供的库,用于加速系统调用的过程

  • libc:标准 C 库

  • ld-linux-x86-64:动态链接器本身,负责延迟加载

作用域

C 语言的一个特点是它使用作用域,这使它与汇编语言编程有了明显的区别。在汇编中,函数的前缀和后缀必须手动编码,而这个过程完全取决于 CPU 提供的指令集架构(ISA)和程序员决定使用的 ABI。

在 C 中,函数的作用域会自动使用{}语法为您定义。例如:

#include <stdio.h>

int main(void) 
{
    printf("Hello World\n");
}

在我们简单的Hello World\n示例中,作用域用于定义main()函数的开始和结束。其他基本类型的作用域也可以使用{}语法来定义。例如:

#include <stdio.h>

int main(void)
{
    int i;

    for (i = 0; i < 10; i++) {
        printf("Hello World: %d\n", i);
    }
}

在上一个例子中,我们定义了main()函数和for()循环的作用域。

{}语法也可以用于为任何内容创建作用域。例如:

#include <stdio.h>

int main(void)
{
    {
        int i;
        ...
    }

    {
        int i;
        ...
    }
}

在上一个例子中,我们能够在不小心重新定义它的情况下两次使用i变量,因为我们将i的定义包裹在{}中。这不仅告诉编译器i的作用域,还告诉编译器如果需要的话自动为我们创建前缀和后缀(因为优化可能消除了前缀和后缀的需要)。

作用域还用于定义编译器在链接方面公开的内容。在标准 C 中,static关键字告诉编译器变量只对正在编译的目标文件可见(即具有作用域),这不仅为链接器提供了优化,还防止两个全局变量或函数相互冲突。

因此,如果一个函数不打算被另一个源文件(或库)调用,它应该被标记为静态。

在系统编程的上下文中,作用域很重要,因为系统编程通常需要获取系统级资源。正如将在第四章中看到的那样,C++,RAII 和 GSL 刷新器,C++提供了使用标准 C{}语法创建生命周期可作用域对象的能力,为资源获取和释放提供了安全机制。

指针和数组

在学校里,我有一位老师曾经告诉过我:

“无论你有多有经验,没有人完全理解指针。”

没有比这更真实的陈述了。在标准 C 中,指针是一个值指向内存中的位置的变量。标准 C 的问题在于,这个内存位置与特定类型无关。相反,指针类型本身定义了指针指向的内存类型,如下例所示:

int main(void)
{
    int i;
    int *p = &i;
}

// > gcc scratchpad.c; ./a.out

在上一个例子中,我们创建了一个整数,然后创建了一个指针,并将其指向先前定义的整数。但是,我们可以这样做:

int main(void)
{
    int i;
    void *p = &i;

    int *int_p = p;
    float *float_p = p;
}

// > gcc scratchpad.c; ./a.out

在这个程序中,我们创建了一个指向整数的指针,但我们将指针类型定义为void *,这告诉编译器我们正在创建一个没有类型的指针。然后我们创建了另外两个指针——一个指向整数,一个指向浮点数。这两个额外的指针都是使用我们之前创建的void *指针进行初始化的。

这个例子的问题在于标准 C 编译器执行自动类型转换,将void *转换为整数指针和浮点数指针。如果同时使用这两个指针,将会发生一些损坏:

  • 根据架构的不同,缓冲区溢出可能会发生,因为整数可能比浮点数大,反之亦然。这取决于所使用的 CPU;这是一个将在第三章中更详细讨论的话题,C 和 C++的系统类型

  • 在内部,整数和浮点数在同一内存中以不同的方式存储,这意味着任何尝试设置一个值都会破坏另一个值。

值得庆幸的是,现代 C 编译器具有能够检测这种类型转换错误的标志,但是这些警告必须启用,因为它们默认情况下是关闭的,如前所示。

指针的明显问题不仅在于它们可以指向内存中的任何内容并重新定义该内存的含义,而且它们还可以取空值。换句话说,指针被认为是可选的。它们可以选择包含有效值并指向内存,或者它们可以是空的。

因此,在确定其值有效之前,不应使用指针,如下所示:

#include <stdio.h>

int main(void)
{
    int i = 42;
    int *p = &i;

    if (p) {
        printf("The answer is: %d\n", *p);
    }
}

// > gcc scratchpad.c; ./a.out
// The answer is: 42

在前面的例子中,我们创建了一个指向整数的指针,它被初始化为先前定义的一个初始值为42的整数的位置。我们检查确保p不是一个空指针,然后将其值输出到stdout

if()语句的添加不仅麻烦,而且性能不佳。因此,大多数程序员会省略if()语句,因为在这个例子中,p永远不会是一个空指针。

这个问题在于,程序员可能会在这个简单的例子中添加与这个假设相矛盾的代码,同时忘记添加if()语句,导致潜在生成难以发现的分段错误的代码。

正如将在下一节中所示,C++标准通过引入引用的概念来解决这个问题,它是一个非可选指针,这意味着它必须始终指向一个有效的、有类型的内存位置。为了解决这个问题,在标准 C 中,通常(虽然不总是)通过公共 API 来检查空指针。私有 API 通常不会检查空指针以提高性能,这样做的假设是,只要公共 API 不能接受空指针,私有 API 很可能永远不会看到无效的指针。

标准 C 数组类似于指针。唯一的区别在于 C 数组利用了一种能够对指针指向的内存进行索引的语法,就像下面的例子中所示:

#include <stdio.h>

int main(void)
{
    int i[2] = {42, 43};
    int *p = i;

    if (p) {
        // method #1
        printf("The answer is: %d and %d\n", i[0], p[0]);
        printf("The answer is: %d and %d\n", i[1], p[1]);

        // method #2
        printf("The answer is: %d and %d\n", *(i + 0), *(p + 0));
        printf("The answer is: %d and %d\n", *(i + 1), *(p + 1));
    }
}

// > gcc scratchpad.c; ./a.out
// The answer is: 42 and 42
// The answer is: 43 and 43
// The answer is: 42 and 42
// The answer is: 43 and 43

在前面的例子中,我们创建了一个包含 2 个元素的整数数组,初始化为值4243。然后我们创建一个指向该数组的指针。请注意,不再需要&。这是因为数组本身就是一个指针,因此我们只是将一个指针设置为另一个指针的值(而不是必须从现有内存位置提取指针)。

最后,我们使用指针算术来打印数组中每个元素的值,既使用数组本身,又使用指向数组的指针。

正如将在第四章中讨论的那样,数组和指针之间几乎没有区别。当尝试访问数组中的元素时,两者都执行所谓的指针算术

在系统编程方面,指针被广泛使用。例如:

  • 由于标准 C 不像 C++那样包含引用的概念,必须通过引用传递的系统 API(因为它们太大而无法通过值传递,或者必须由 API 修改)必须通过指针传递,因此在进行系统调用时会大量使用指针。

  • 系统编程通常涉及与内存中的位置指针交互,旨在定义该内存的布局。指针提供了一种方便的方法来实现这一点。

标准 C 不仅定义了语法、环境和程序链接方式,还提供了一组库,程序员可以利用这些库来进行系统编程。其中一些库如下:

  • errno.h:提供处理错误所需的代码。这个库将在第十三章中进一步讨论,异常处理

  • inttypes.h:提供类型信息,将在第三章中讨论,C 和 C++的系统类型

  • limits.h:提供每种类型的限制信息,将在第三章中讨论,C 和 C++的系统类型

  • setjump.h:提供 C 风格的异常处理的 API,将在第十三章中讨论,异常处理

  • signal.h:提供处理系统发送到程序的信号的 API,将在第五章中讨论,Linux/Unix 系统编程

  • stdbool.h:提供类型信息,将在第三章中讨论,C 和 C++的系统类型

  • stddef.h:提供类型信息,将在第三章中讨论,C 和 C++的系统类型

  • stdint.h:提供类型信息,将在第三章中讨论,C 和 C++的系统类型

  • stdio.h:提供在系统编程中处理输入和输出的函数,将在第六章和第八章中讨论,学习控制台输入/输出学习文件输入/输出

  • stdlib.h:提供各种实用程序,包括动态内存分配 API,将在第七章中讨论,全面了解内存管理

  • time.h:提供处理时钟的功能,将在第十一章中讨论,Unix 中的时间接口

正如前面所述,本书的大部分内容将集中在这些功能上,以及它们如何支持系统编程。

学习 C++标准

C++编程语言(最初称为带类的 C)专门设计为提供比 C 更高级的功能,包括更好的类型安全性和面向对象编程,同时考虑了系统编程。具体来说,C++旨在提供 C 程序的性能和效率,同时仍提供更高级语言的特性。

如今,C++是世界上最流行的编程语言之一,应用于从航空电子学到银行业的各个领域。

与 C 标准一样,C++标准也很庞大,并由 ISO 管理。我们假设读者对 C++标准和如何编写 C 代码有一些基本的了解:www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4713.pdf

因此,本节的目标是讨论一些在其他书中没有详细讨论的主题,以及与本书和系统编程相关的 C++标准的部分,但在其他章节中缺失。有关 C++编程语言以及如何编写 C++程序的更多信息,请参阅本章的进一步阅读部分。

标准的组织方式

与 C 标准规范一样,C++规范分为三大组部分:

  • 一般约定和概念

  • 语言语法

应该注意到,C++标准比 C 标准要大得多。

一般约定和概念

标准中的前四个部分专门讨论约定和概念。它们定义了类型、程序的启动和关闭、内存和链接。它们还概述了理解规范其余部分所需的所有定义和关键字。

与标准 C 规范一样,在这些部分中定义了许多对系统程序员很重要的东西,因为它们定义了编译器在编译程序时将输出什么,以及程序将如何执行。

语言语法

规范中的接下来 12 个部分定义了 C++语言的语法本身。这包括 C++的特性,如类、重载、模板和异常处理。有整本书只是针对规范的这些部分写的。

我们假设读者对 C++有一般的了解,我们在书中不再讨论这部分规范,除了第四章中关于 C++17 的修改,C++,RAII 和 GSL 刷新

规范中剩下的 14 个部分定义了 C++作为规范一部分提供的库。应该注意到,本书的大部分内容都围绕着规范的这一部分。

具体来说,我们详细讨论了 C++为系统程序员提供的设施,以及如何在实践中使用这些设施。

链接 C++应用程序

与 C 一样,C++应用程序通常从一个具有与 C 相同签名的main()函数开始。同样,与 C 程序一样,代码的实际入口点实际上是_start函数。

然而,与 C 不同,C++要复杂得多,包括了更多的代码来演示一个简单的例子。为了证明这一点,让我们看一个简单的Hello World\n示例:

#include <iostream>

int main(void)
{
    std::cout << "Hello World\n";
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

首先,C++应用程序示例比上一节中等价的 C 示例略长:

> gcc scratchpad.c -o c_example
> g++ scratchpad.cpp -o cpp_example
> stat -c "%s %n" *
8352 c_example
8768 cpp_example

如果我们看一下我们的示例中的符号,我们得到以下内容:

> nm -gC cpp_example
                 U __cxa_atexit@@GLIBC_2.2.5
                 w __cxa_finalize@@GLIBC_2.2.5
00000000000008f4 T _fini
0000000000000688 T _init
00000000000007fa T main
00000000000006f0 T _start
                 U std::ios_base::Init::Init()@@GLIBCXX_3.4
                 U std::ios_base::Init::~Init()@@GLIBCXX_3.4
0000000000201020 B std::cout@@GLIBCXX_3.4
                 U std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)@@GLIBCXX_3.4

...

如前所述,我们的程序包含一个main()函数和一个_start()函数。_start()函数是应用程序的实际入口点,而main()函数在初始化完成后由_start()函数调用。

_init()_fini()函数负责全局构造和销毁。在我们的示例中,_init()函数创建了 C++库支持std::cout所需的代码,而_fini()函数负责销毁这些全局对象。为此,全局对象使用__cxa_atexit()函数注册,并最终使用__cxa_finalize()函数销毁。

其余的符号构成了std::cout的代码,包括对ios_base{}basic_ostream{}的引用。

这里需要注意的重要事情是,与 C 语言一样,有很多代码在main()函数之前和之后执行,并且在 C++中使用全局对象只会增加启动和停止应用程序的复杂性。

在前面的例子中,我们使用_C选项来解开我们的函数名。让我们看看使用这个选项的相同输出:

> nm -gC cpp_example
                 U __cxa_atexit@@GLIBC_2.2.5
                 w __cxa_finalize@@GLIBC_2.2.5
00000000000008f4 T _fini
0000000000000688 T _init
00000000000007fa T main
00000000000006f0 T _start
                 U _ZNSt8ios_base4InitC1Ev@@GLIBCXX_3.4
                 U _ZNSt8ios_base4InitD1Ev@@GLIBCXX_3.4
0000000000201020 B _ZSt4cout@@GLIBCXX_3.4
                 U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@@GLIBCXX_3.4

...

如上所示,一些函数仍然可读,而另一些则不可读。具体来说,C++规范规定某些支持函数使用 C 链接进行链接,防止名称编码。在我们的例子中,这包括__cxa_xxx()函数、_init()_fini()main()_start()

然而,支持std::cout的 C++库函数的语法几乎无法阅读。在大多数符合 POSIX 标准的系统上,可以使用C++filt命令来解开这些编码的名称,如下所示:

> c++filt _ZSt4cout
std::cout

这些名称被编码是因为它们的名称中包含了整个函数签名,包括参数和特化(例如,noexcept关键字)。为了证明这一点,让我们创建两个函数重载:

void test(void) {}
void test(bool b) {}

int main(void)
{
    test();
    test(false);
}

// > g++ scratchpad.cpp; ./a.out

在前面的例子中,我们创建了两个具有相同名称但不同函数签名的函数,这个过程称为函数重载,这是 C++特有的。

现在让我们看看我们测试应用程序中的符号:

> nm -g a.out
...

0000000000000601 T _Z4testb
00000000000005fa T _Z4testv

函数名在 C++中被编码的原因有几个:

  • 在函数名中编码函数参数意味着函数可以重载,并且编译器和链接器将知道哪个函数做什么。没有名称编码,具有相同名称但不同参数的两个函数对于链接器来说看起来是相同的,会导致错误。

  • 通过在函数名中编码这种类型的信息,链接器能够识别库函数是否使用了不同的签名进行编译。没有这些信息,链接器可能会将使用不同签名(因此不同实现)编译的库链接到相同的函数名,这将导致难以发现的错误,很可能会导致损坏。

C++名称编码的最大问题是对公共 API 进行微小更改会导致库无法再与已经存在的代码链接。

有许多方法可以克服这个问题,但总的来说,重要的是要理解 C++在函数名中编码了关于你如何编写代码的大量信息,这使得公共 API 不改变除非期望进行版本更改至关重要。

作用域

C 和 C++之间的一个主要区别是对象的构造和销毁是如何处理的。让我们看下面的例子:

#include <iostream>

struct mystruct {
    int data1{42};
    int data2{42};
};

int main(void)
{
    mystruct s;
    std::cout << s.data1 << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 42

与 C 语言不同,在 C++中,我们可以使用{}运算符来定义结构的数据值应该如何初始化。这是可能的,因为在 C++中,对象(包括结构和类)包含构造函数和析构函数,定义了对象在构造时如何初始化和在销毁时如何销毁。

在系统编程时,这种方案将被广泛使用,并且在处理系统资源时,构造和销毁对象的概念将贯穿本书。具体来说,将利用作用域来定义对象的生命周期,从而定义对象拥有的系统资源,使用一种称为资源获取即初始化RAII)的概念。

指针与引用

在前一节中,我们详细讨论了指针,包括指针可以取两个值——有效或空(假设损坏不是方程式的一部分)。

问题在于用户必须检查指针是否有效。当使用指针来定义内存的内容(例如,使用数据结构布局内存)时,通常不会出现问题,但在 C 中,指针经常必须简单地用于减少将大型对象传递给函数的开销,就像以下示例中一样:

struct mystruct {
    int data1{};
    int data2{};
    int data3{};
    int data4{};
    int data5{};
    int data6{};
    int data7{};
    int data8{};
};

void test(mystruct *s)
{
}

int main(void)
{
    mystruct s;
    test(&s);
}

// > g++ scratchpad.cpp; ./a.out

在前面的例子中,我们创建了一个包含八个变量的结构。以值传递这种类型的结构将导致使用堆栈(即,多次内存访问)。在 C 中,通过指针传递这种结构以减少将结构传递给单个寄存器的成本更加高效,很可能完全消除所有内存访问。

问题在于,现在,test 函数必须在使用指针之前检查指针是否有效。因此,该函数将一组内存访问交换为分支语句和可能导致 CPU 流水线刷新的操作,而我们所要做的只是减少将大型对象传递给函数的成本。

如前一节所述,解决方案就是简单地不验证指针的有效性。然而,在 C++中,我们还有另一个选择,那就是通过引用传递结构,如下所示:

struct mystruct {
    int data1{};
    int data2{};
    int data3{};
    int data4{};
    int data5{};
    int data6{};
    int data7{};
    int data8{};
};

void test(mystruct &s)
{
}

int main(void)
{
    mystruct s;
    test(s);
}

// > g++ scratchpad.cpp; ./a.out

在前面的例子中,我们的test()函数接受了mystruct{}的引用,而不是指针。当我们调用test()函数时,无需获取结构的地址,因为我们没有使用指针。

C++引用将在本书中大量使用,因为它们极大地提高了程序的性能和稳定性,特别是在系统编程中,资源、性能和稳定性至关重要。

C++不仅定义了基本的环境和语言语法,还提供了一组库,程序员可以利用这些库进行系统编程。这些包括以下内容:

  • 控制台输入/输出库:这些包括iostreamiomanipstring库,它们提供了处理字符串、格式化字符串和输出字符串(或从用户那里获取输入)的能力。我们将在第六章中讨论大多数这些库,学习编程控制台输入/输出

  • 内存管理库:这些包括内存库,其中包含有助于防止悬空指针的内存管理实用程序。它们将在第七章中讨论全面了解内存管理

  • 文件输入/输出库:这些包括fstreamfilesystem(C++17 中新增)库,在第八章中将讨论学习文件输入/输出

  • 时间库:这些包括chrono库,在第十一章中将讨论Unix 中的时间接口

  • 线程库:这些包括threadmutexconditional_variable库,在第十二章中将讨论学习编程 POSIX 和 C++线程

  • 错误处理库:这些包括异常支持库,在第十三章中将讨论使用异常进行错误处理

从 POSIX 标准开始

POSIX 标准定义了符合 POSIX 的操作系统必须实现的所有功能。在系统编程方面,POSIX 标准定义了操作系统必须支持的系统调用接口(即 API,而不是 ABI)。

在底层,C 和 C ++提供的大多数系统级 API 实际上执行 POSIX 函数,或者它们本身就是 POSIX 函数(就像很多 C 库 API 一样)。事实上,libc通常被认为是更大的 POSIX 标准的子集,而 C ++利用libc和 POSIX 来实现其更高级的 API,如线程,内存管理,错误处理,文件操作和输入/输出。有关更多信息,请参阅ieeexplore.ieee.org/document/8277153/

在本节中,我们将讨论与系统编程相关的 POSIX 标准的一些组件。所有这些主题将在后面的章节中进一步详细讨论。

内存管理

libc提供的所有内存管理函数也被视为 POSIX API。此外,还有一些libc不提供的 POSIX 特定内存管理函数,如对齐内存。

例如,以下演示了如何使用 POSIX 分配对齐的动态(堆)内存:

#include <iostream>

int main()
{
    void *ptr;

    if (posix_memalign(&ptr, 0x1000, 42 * sizeof(int))) {
        std::clog << "ERROR: unable to allocate aligned memory\n";
        ::exit(EXIT_FAILURE);
    }

    std::cout << ptr << '\n';
    free(ptr);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55c5d31d1000

在这个例子中,我们使用posix_memalign()函数来分配一个对齐到页面的42个整数的数组。这是一个 POSIX 特定的函数。

此外,我们还利用std::clog()函数将错误输出到stderr,在底层利用了 POSIX 特定函数将字符串输出到stderr。我们还使用了::exit(),这是一个用于退出应用程序的libc和 POSIX 函数。

最后,我们利用了std::cout()free()函数。std::cout()使用 POSIX 函数将字符串输出到stdout,而free()是用于释放内存的libc和 POSIX 特定函数。

在这个简单的例子中,我们利用了几个 C、C++和 POSIX 特定的功能来执行系统编程。在本书中,我们将讨论如何大量利用 POSIX 来编程系统以完成特定任务。

文件系统

POSIX 不仅定义了如何从符合 POSIX 的操作系统中读取和写入文件,还定义了文件应该位于文件系统上的位置。在第八章中,《学习使用 C、C++和 POSIX 进行文件输入/输出编程》,我们将详细介绍如何使用 C、C++和 POSIX 读取和写入文件系统。

关于文件系统的布局,POSIX 定义了文件应该位于的位置,包括以下常见文件夹:

  • /bin:所有用户使用的二进制文件

  • /boot:启动操作系统所需的文件

  • /dev:物理和虚拟设备

  • /etc:操作系统需要的配置文件

  • /home:用户特定的文件

  • /lib:可执行文件需要的库

  • /mnt 和/media:用作临时挂载点

  • /sbin:系统特定的二进制文件

  • /tmp:在重启时删除的文件

  • /usr:前述文件夹的用户特定版本

套接字

要在符合 POSIX 的操作系统上进行网络编程,您需要利用 POSIX 套接字 API。POSIX 提供的套接字编程接口是一个很好的例子,它既不是由 C 也不是由 C++提供的一组 API,但在符合 POSIX 的操作系统上进行网络编程时是必需的。

在第十章中,《使用 C++编程 POSIX 套接字》,我们将讨论如何使用 POSIX 套接字 API 执行网络编程,同时利用 C ++。具体来说,我们将展示如何利用 C ++简化基于套接字的网络编程的实现,并提供如何执行网络编程的几个示例。

线程

线程为系统程序员提供了执行并行执行的手段。具体来说,线程是操作系统在适当时安排的执行单元。C++和 POSIX 都提供了用于处理线程的 API,其中 C++的 API 可能更容易使用。

应该注意,在幕后,C++利用了 POSIX 线程库(pthreads)-因此,即使 C++提供了一组用于处理线程的 API,最终,POSIX 线程负责所有情况下的线程。

这是因为原因很简单。 POSIX 定义了程序与操作系统交流的接口。在这种情况下,如果您希望告诉操作系统创建一个线程,您必须通过利用操作系统定义的 API 来实现。如果操作系统符合 POSIX 标准,那么这些接口就是 POSIX,而不管可能会被放置在那里以使 API 更容易使用的任何抽象。

总结

在本章中,我们了解了三种不同的标准:C、C++和 POSIX。 C 标准定义了流行的 C 语法,C 风格的程序链接和执行,以及提供跨平台 API 的标准 C 库,以包装操作系统的 ABI。

我们还了解了 C++标准,以及它如何定义 C++语法,程序链接和执行,以及高级 C++ API,以包装底层的 C 和 POSIX API 到 C++。

最后,我们看到 POSIX 标准提供了超出 C 的额外 API。这些 API 包括(但不限于)内存管理、网络和线程。一般来说,POSIX 标准定义了应用程序在任何符合 POSIX 标准的操作系统上以跨平台方式执行其功能所需的所有标准。

本书的其余部分将重点关注这些标准中定义的 API,以及它们如何用于在 C++17 中进行系统编程。在下一章中,我们将专门介绍由 C、C++和 POSIX 提供的系统类型,以及它们如何影响系统编程。

问题

  1. C 标准是否是 POSIX 标准的一部分?如果是,列举一个在两个标准中都常见的 API。

  2. _start()main()函数之间有什么区别?

  3. 列举 C 运行时的一个职责。

  4. 全局构造函数是在main()函数之前还是之后执行的?

  5. C++名称修饰是什么,为什么需要?

  6. 列举 C 和 C++程序链接之间的一个区别。

  7. 指针和引用之间有什么区别?

进一步阅读

第三章:C 和 C++的系统类型

通过系统程序,诸如整数类型之类的简单事物变得复杂。整个章节都致力于解决在进行系统编程时出现的常见问题,特别是在为多个 CPU 架构、操作系统和用户空间/内核通信(如系统调用)进行系统编程时出现的问题。

本章包括以下主题:

  • 解释 C 和 C++提供的默认类型,包括大多数程序员熟悉的类型,如charint

  • 回顾stdint.h提供的一些标准整数类型,以解决默认类型的限制

  • 结构打包和与优化和类型转换相关的复杂性

技术要求

要编译和执行本章中的示例,读者必须具备以下条件:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请访问以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter03

探索 C 和 C++的默认类型

C 和 C++语言提供了几种内置类型,无需额外的头文件或语言特性。在本节中,我们将讨论以下内容:

  • charwchar_t

  • short intintlong int

  • floatdoublelong double

  • bool(仅限 C++)

字符类型

C 和 C++中最基本的类型是以下字符类型:

#include <iostream>

int main(void)
{
    char c = 0x42;
    std::cout << c << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// B

char是一个整数类型,在大多数平台上,它的大小为 8 位,必须能够接受无符号的值范围为[0255],有符号的值范围为[-127127]。char与其他整数类型的区别在于,char具有特殊含义,对应着美国信息交换标准代码ASCII)。在前面的示例中,大写字母B由 8 位值0x42表示。需要注意的是,虽然char可以用来简单表示 8 位整数类型,但它的默认含义是字符类型;这就是为什么它具有特殊含义。例如,考虑以下代码:

#include <iostream>

int main(void)
{
    int i = 0x42;
    char c = 0x42;

    std::cout << i << '\n';
    std::cout << c << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 66
// B

在前面的示例中,我们使用int(稍后将解释)和char来表示相同的整数类型0x42。然而,这两个值以两种不同的方式输出到stdout。整数以整数形式输出,而使用相同的 API,char以其 ASCII 表示形式输出。此外,char类型的数组在 C 和 C++中被认为是 ASCII 字符串类型,这也具有特殊含义。以下代码显示了这一点:

#include <iostream>

int main(void)
{
    const char *str = "Hello World\n";
    std::cout << str;
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

从前面的示例中,我们了解到以下内容。我们使用char指针(在这种情况下,无界数组类型也可以)定义了一个 ASCII 字符串;std::cout默认情况下知道如何处理这种类型,而char数组具有特殊含义。将数组类型更改为int将无法编译,因为编译器不知道如何将字符串转换为整数数组,而std::cout默认情况下也不知道如何处理整数数组,尽管在某些平台上,intchar实际上可能是相同的类型。

boolshort int一样,字符类型在表示 8 位整数时并不总是最有效的类型,正如前面的代码所暗示的,在某些平台上,char实际上可能比 8 位更大,这是我们在讨论整数时将进一步详细讨论的一个主题。

为了进一步研究char类型,以及本节讨论的其他类型,让我们利用std::numeric_limits{}类。这个类提供了一个简单的包装器,围绕着limits.h,它为我们提供了一种查询在给定平台上如何实现类型的方法,使用一组静态成员函数实时地。

例如,考虑下面的代码:

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed char);
    auto min_signed = std::numeric_limits<signed char>().min();
    auto max_signed = std::numeric_limits<signed char>().max();

    auto num_bytes_unsigned = sizeof(unsigned char);
    auto min_unsigned = std::numeric_limits<unsigned char>().min();
    auto max_unsigned = std::numeric_limits<unsigned char>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << +min_signed << '\n';
    std::cout << "max value (signed): " << +max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << +min_unsigned << '\n';
    std::cout << "max value (unsigned): " << +max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 1
// min value (signed): -128
// max value (signed): 127

// num bytes (unsigned): 1
// min value (unsigned): 0
// max value (unsigned): 255

在前面的例子中,我们利用std::numeric_limits{}来告诉我们有符号和无符号char的最小和最大值(应该注意的是,本书中的所有示例都是在标准的英特尔 64 位 CPU 上执行的,假设这些相同的示例实际上可以在不同的平台上执行,返回的值可能是不同的)。std::numeric_limits{}类可以提供关于类型的实时信息,包括以下内容:

  • 有符号或无符号

  • 转换限制,如四舍五入和表示类型所需的总位数

  • 最小值和最大值信息

在前面的例子中,64 位英特尔 CPU 上的char大小为 1 字节(即 8 位),对于无符号char取值范围为[0,255],有符号char取值范围为[-127,127],这是规范规定的。让我们来看一下宽字符charwchar_t

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed wchar_t);
    auto min_signed = std::numeric_limits<signed wchar_t>().min();
    auto max_signed = std::numeric_limits<signed wchar_t>().max();

    auto num_bytes_unsigned = sizeof(unsigned wchar_t);
    auto min_unsigned = std::numeric_limits<unsigned wchar_t>().min();
    auto max_unsigned = std::numeric_limits<unsigned wchar_t>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << +min_signed << '\n';
    std::cout << "max value (signed): " << +max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << +min_unsigned << '\n';
    std::cout << "max value (unsigned): " << +max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 4
// min value (signed): -2147483648
// max value (signed): 2147483647

// num bytes (unsigned): 4
// min value (unsigned): 0
// max value (unsigned): 4294967295

wchar_t表示 Unicode 字符,其大小取决于操作系统。在大多数基于 Unix 的系统上,wchar_t为 4 字节,可以表示 UTF-32 字符类型,如前面的例子所示,而在 Windows 上,wchar_t为 2 字节,可以表示 UTF-16 字符类型。在这两种操作系统上执行前面的例子将得到不同的输出。

这是非常重要的,这个问题定义了整个章节的基本主题;C 和 C++提供的默认类型取决于 CPU 架构、操作系统,有时还取决于应用程序是在用户空间还是内核中运行(例如,当 32 位应用程序在 64 位内核上执行时)。在系统编程时,永远不要假设在与系统调用进行接口时,你的应用程序对特定类型的定义与 API 所假定的类型相同。这种假设往往是无效的。

整数类型

为了进一步解释默认的 C 和 C++类型是由它们的环境定义的,而不是由它们的大小定义的,让我们来看一下整数类型。有三种主要的整数类型——short intintlong int(不包括long long int,在 Windows 上实际上是long int)。

short int通常比int小,在大多数平台上表示为 2 字节。例如,看下面的代码:

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed short int);
    auto min_signed = std::numeric_limits<signed short int>().min();
    auto max_signed = std::numeric_limits<signed short int>().max();

    auto num_bytes_unsigned = sizeof(unsigned short int);
    auto min_unsigned = std::numeric_limits<unsigned short int>().min();
    auto max_unsigned = std::numeric_limits<unsigned short int>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << min_signed << '\n';
    std::cout << "max value (signed): " << max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << min_unsigned << '\n';
    std::cout << "max value (unsigned): " << max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 2
// min value (signed): -32768
// max value (signed): 32767

// num bytes (unsigned): 2
// min value (unsigned): 0
// max value (unsigned): 65535

如前面的例子所示,代码获取了有符号short int和无符号short int的最小值、最大值和大小。这段代码的结果表明,在运行 Ubuntu 的英特尔 64 位 CPU 上,short int,无论是有符号还是无符号,都返回 2 字节的表示。

英特尔 CPU 相对于其他 CPU 架构提供了一个有趣的优势,因为英特尔 CPU 被称为复杂指令集计算机CISC),这意味着英特尔指令集架构ISA)提供了一长串复杂的指令,旨在为英特尔汇编的编译器和手动作者提供高级功能。其中的一个特性是英特尔处理器能够在字节级别执行算术逻辑单元ALU)操作(包括基于内存的操作),尽管大多数英特尔 CPU 都是 32 位或 64 位。并非所有的 CPU 架构都提供相同级别的细粒度。

为了更好地解释这一点,让我们看一个涉及short int的例子:

#include <iostream>

int main(void)
{
    short int s = 42;

    std::cout << s << '\n';
    s++;
    std::cout << s << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 42
// 43

在前面的例子中,我们取一个short int,将其设置为值42,使用std::cout将这个值输出到stdout,然后将short int增加1,再次使用std::cout将结果输出到stdout。这是一个简单的例子,但在底层,发生了很多事情。在这种情况下,一个 2 字节的值,在包含 8 字节寄存器的系统上执行(即 64 位),必须初始化为42,存储在内存中,递增,然后再次存储在内存中以输出到stdout。所有这些操作都必须涉及 CPU 寄存器来执行这些操作。

在基于英特尔的 CPU 上(32 位或 64 位),这些操作可能涉及使用 CPU 寄存器的 2 字节版本。具体来说,英特尔的 CPU 可能是 32 位或 64 位,但它们提供的寄存器大小为 1、2、4 和 8 字节(特别是在 64 位 CPU 上)。在前面的例子中,这意味着 CPU 加载一个 2 字节寄存器,存储这个值到内存(使用 2 字节的内存操作),将这个 2 字节寄存器增加 1,然后再次将这个 2 字节寄存器存储回内存中。

精简指令集计算机RISC)上,这个相同的操作可能会更加复杂,因为 2 字节寄存器不存在。要加载、存储、递增和再次存储只有 2 字节的数据将需要使用额外的指令。具体来说,在 32 位 CPU 上,必须将 32 位值加载到寄存器中,当这个值存储在内存中时,必须保存和恢复上 32 位(或下 32 位,取决于对齐)以确保实际上只影响了 2 字节的内存。如果进行了大量的操作,额外的对齐检查,即内存读取、掩码和存储,将导致显著的性能影响。

因此,C 和 C++提供了默认的int类型,通常表示 CPU 寄存器。也就是说,如果架构是 32 位,那么int就是 32 位,反之亦然(64 位除外,稍后将解释)。应该注意的是,像英特尔这样的 CISC 架构可以自由地以比 CPU 寄存器大小更小的粒度实现 ALU 操作,这意味着在底层,仍然可能进行相同的对齐检查和掩码操作。重点是,除非你有非常特定的原因要使用short int(对此有一些原因;我们将在本章末讨论这个话题),而不是int,在大多数情况下,使用更小的类型,即使你不需要完整的 4 或 8 字节,仍然更有效率。

让我们看一下int类型:

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed int);
    auto min_signed = std::numeric_limits<signed int>().min();
    auto max_signed = std::numeric_limits<signed int>().max();

    auto num_bytes_unsigned = sizeof(unsigned int);
    auto min_unsigned = std::numeric_limits<unsigned int>().min();
    auto max_unsigned = std::numeric_limits<unsigned int>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << min_signed << '\n';
    std::cout << "max value (signed): " << max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << min_unsigned << '\n';
    std::cout << "max value (unsigned): " << max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 4
// min value (signed): -2147483648
// max value (signed): 2147483647

// num bytes (unsigned): 4
// min value (unsigned): 0
// max value (unsigned): 4294967295

在前面的例子中,int在 64 位英特尔 CPU 上显示为 4 字节。这是因为向后兼容性,这意味着在一些 RISC 架构上,默认的寄存器大小,导致最有效的处理,可能不是int,而是long int。问题在于实时确定这一点是痛苦的(因为使用的指令是在编译时完成的)。让我们看一下long int来进一步解释这一点:

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed long int);
    auto min_signed = std::numeric_limits<signed long int>().min();
    auto max_signed = std::numeric_limits<signed long int>().max();

    auto num_bytes_unsigned = sizeof(unsigned long int);
    auto min_unsigned = std::numeric_limits<unsigned long int>().min();
    auto max_unsigned = std::numeric_limits<unsigned long int>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << min_signed << '\n';
    std::cout << "max value (signed): " << max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << min_unsigned << '\n';
    std::cout << "max value (unsigned): " << max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 8
// min value (signed): -9223372036854775808
// max value (signed): 9223372036854775807

// num bytes (unsigned): 8
// min value (unsigned): 0
// max value (unsigned): 18446744073709551615

如前面的代码所示,在运行 Ubuntu 的 64 位英特尔 CPU 上,long int是一个 8 字节的值。这在 Windows 上并不成立,它将long int表示为 32 位,而long long int为 64 位(再次是为了向后兼容)。

在系统编程中,您正在处理的数据大小通常非常重要,正如本节所示,除非您确切知道应用程序将在哪种 CPU、操作系统和模式上运行,否则几乎不可能知道在使用 C 和 C++提供的默认类型时您的整数类型的大小。大多数这些类型在系统编程中不应该使用,除了int之外,它几乎总是表示与 CPU 寄存器相同位宽的数据类型,或者至少是一个不需要额外对齐检查和掩码来执行简单算术操作的数据类型。在下一节中,我们将讨论克服这些大小问题的其他类型,并讨论它们的优缺点。

浮点数

在系统编程中,浮点数很少使用,但我们在这里简要讨论一下以供参考。浮点数通过减少精度来增加可以存储的值的大小。例如,使用浮点数可以存储代表1.79769e+308的数字,这是使用整数值甚至long long int都不可能实现的。然而,无法将这个值减去1并看到数字值的差异,浮点数也无法在保持与整数值相同的粒度的同时表示如此大的值。浮点数的另一个好处是它们能够表示次整数数值,在处理更复杂的数学计算时非常有用(这在系统编程中很少需要,因为大多数内核不使用浮点数来防止内核中发生浮点错误,最终导致没有接受浮点值的系统调用)。

主要有三种不同类型的浮点数——floatdoublelong double。例如,考虑以下代码:

#include <iostream>

int main(void)
{
    auto num_bytes = sizeof(float);
    auto min = std::numeric_limits<float>().min();
    auto max = std::numeric_limits<float>().max();

    std::cout << "num bytes: " << num_bytes << '\n';
    std::cout << "min value: " << min << '\n';
    std::cout << "max value: " << max << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes: 4
// min value: 1.17549e-38
// max value: 3.40282e+38

在前面的例子中,我们利用std::numeric_limits来检查float类型,在英特尔 64 位 CPU 上是 4 字节大小。double如下:

#include <iostream>

int main(void)
{
    auto num_bytes = sizeof(double);
    auto min = std::numeric_limits<double>().min();
    auto max = std::numeric_limits<double>().max();

    std::cout << "num bytes: " << num_bytes << '\n';
    std::cout << "min value: " << min << '\n';
    std::cout << "max value: " << max << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes: 8
// min value: 2.22507e-308
// max value: 1.79769e+308

对于long double,代码如下:

#include <iostream>

int main(void)
{
    auto num_bytes = sizeof(long double);
    auto min = std::numeric_limits<long double>().min();
    auto max = std::numeric_limits<long double>().max();

    std::cout << "num bytes: " << num_bytes << '\n';
    std::cout << "min value: " << min << '\n';
    std::cout << "max value: " << max << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes: 16
// min value: 3.3621e-4932
// max value: 1.18973e+4932

如前面的代码所示,在英特尔 64 位 CPU 上,long double是 16 字节大小(或 128 位),可以存储绝对庞大的数字。

布尔值

标准 C 语言没有本地定义布尔类型。然而,C++有,并使用bool关键字定义。在 C 中,布尔值可以用任何整数类型表示,通常false表示0true表示1。有趣的是,一些 CPU 能够比较寄存器或内存位置与0更快,这意味着在某些 CPU 上,布尔算术和分支实际上更快地导致典型情况下的false

让我们看一下使用以下代码的bool

#include <iostream>

int main(void)
{
    auto num_bytes = sizeof(bool);
    auto min = std::numeric_limits<bool>().min();
    auto max = std::numeric_limits<bool>().max();

    std::cout << "num bytes: " << num_bytes << '\n';
    std::cout << "min value: " << min << '\n';
    std::cout << "max value: " << max << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes: 1
// min value: 0
// max value: 1

在前面的代码中,使用 C++在 64 位英特尔 CPU 上的布尔值大小为 1 字节,可以取值为01。值得注意的是,出于相同的原因,布尔值可以是 32 位或者 64 位,取决于 CPU 架构。在英特尔 CPU 上,支持 8 位寄存器大小(即 1 字节),布尔值只需要 1 字节大小。

布尔值的总大小很重要,特别是在磁盘上存储布尔值时。从技术上讲,布尔值只需要一个位来存储其值,但很少(如果有的话)CPU 架构支持位式寄存器和内存访问,这意味着布尔值通常占用多于一个位,有些情况下甚至可能占用多达 64 位。如果您的结果文件的大小很重要,使用内置的布尔类型存储布尔值可能不是首选(最终需要位掩码)。

学习标准整数类型

为了解决 C 和 C++提供的默认类型的不确定性,它们都提供了标准整数类型,可以从stdint.h头文件中访问。此头文件定义了以下类型:

  • int8_tuint8_t

  • int16_tuint16_t

  • int32_tuint32_t

  • int64_tuint64_t

此外,stdint.h提供了上述类型的最小最快版本,以及最大类型和整数指针类型,这些都超出了本书的范围。前面的类型正是您所期望的;它们定义了具有特定位数的整数类型的宽度。例如,int8_t是一个有符号的 8 位整数。无论 CPU 架构、操作系统或模式如何,这些类型始终相同(唯一未定义的是它们的字节顺序,通常仅在处理网络和外部设备时才需要)。

一般来说,如果您正在处理的数据类型的大小很重要,应使用标准整数类型,而不是语言提供的默认类型。尽管标准类型确实解决了许多已经确定的问题,但它们也有自己的问题。具体来说,stdint.h是一个由编译器提供的头文件,对于可能的每个 CPU 架构和操作系统组合,都定义了不同的头文件。此文件中定义的类型通常在底层使用默认类型表示。这是因为编译器知道int32_tint还是long int。为了证明这一点,让我们创建一个能够比较整数类型的应用程序。

我们将从以下头文件开始:

#include <typeinfo>
#include <iostream>

#include <string>
#include <cstdint>
#include <cstdlib>
#include <cxxabi.h>

typeinfo头文件将为我们提供 C++支持的类型信息,最终为我们提供特定整数类型的根类型。问题在于typeinfo为我们提供了这些类型信息的编码版本。为了解码这些信息,我们需要cxxabi.h头文件,它提供了对 C++本身内置的解码器的访问:

template<typename T>
std::string type_name()
{
    int status;
    std::string name = typeid(T).name();

    auto demangled_name =
        abi::__cxa_demangle(name.c_str(), nullptr, nullptr, &status);

    if (status == 0) {
        name = demangled_name;
        std::free(demangled_name);
    }

    return name;
}

前一个函数返回提供的类型T的根名称。首先从 C++中获取类型的名称,然后使用解码器将编码的类型信息转换为人类可读的形式。最后,返回结果名称:

template<typename T1, typename T2>
void
are_equal()
{
    #define red "\0331;31m"
    #define reset "\033[0m"

    std::cout << type_name<T1>() << " vs "
              << type_name<T2>() << '\n';

    if (sizeof(T1) == sizeof(T2)) {
        std::cout << " - size: both == " << sizeof(T1) << '\n';
    }
    else {
        std::cout << red " - size: "
                  << sizeof(T1)
                  << " != "
                  << sizeof(T2)
                  << reset "\n";
    }

    if (type_name<T1>() == type_name<T2>()) {
        std::cout << " - name: both == " << type_name<T1>() << '\n';
    }
    else {
        std::cout << red " - name: "
                  << type_name<T1>()
                  << " != "
                  << type_name<T2>()
                  << reset "\n";
    }
}

前一个函数检查类型的名称和大小是否相同,因为它们不需要相同(例如,大小可能相同,但类型的根可能不同)。应该注意的是,我们向此函数的输出(输出到stdout)添加了一些奇怪的字符。这些奇怪的字符告诉控制台在找不到匹配项时以红色输出,提供了一种简单的方法来查看哪些类型是相同的,哪些类型是不同的:

int main()
{
    are_equal<uint8_t, int8_t>();
    are_equal<uint8_t, uint32_t>();

    are_equal<signed char, int8_t>();
    are_equal<unsigned char, uint8_t>();

    are_equal<signed short int, int16_t>();
    are_equal<unsigned short int, uint16_t>();
    are_equal<signed int, int32_t>();
    are_equal<unsigned int, uint32_t>();
    are_equal<signed long int, int64_t>();
    are_equal<unsigned long int, uint64_t>();
    are_equal<signed long long int, int64_t>();
    are_equal<unsigned long long int, uint64_t>();
}

最后,我们将比较每种标准整数类型与预期(更恰当地说是典型)默认类型,以查看在任何给定架构上这些类型是否实际相同。可以在任何架构上运行此示例,以查看默认类型和标准整数类型之间的差异,以便在需要系统编程时查找不一致之处。

对于在 Ubuntu 上的基于英特尔 64 位 CPU 的uint8_t,结果如下:

are_equal<uint8_t, int8_t>();
are_equal<uint8_t, uint32_t>();

// unsigned char vs signed char
// - size: both == 1
// - name: unsigned char != signed char

// unsigned char vs unsigned int
// - size: 1 != 4
// - name: unsigned char != unsigned int

以下显示了char的结果:


are_equal<signed char, int8_t>();
are_equal<unsigned char, uint8_t>();

// signed char vs signed char
// - size: both == 1
// - name: both == signed char

// unsigned char vs unsigned char
// - size: both == 1
// - name: both == unsigned char

最后,以下代码显示了剩余的int类型的结果:

are_equal<signed short int, int16_t>();
are_equal<unsigned short int, uint16_t>();
are_equal<signed int, int32_t>();
are_equal<unsigned int, uint32_t>();
are_equal<signed long int, int64_t>();
are_equal<unsigned long int, uint64_t>();
are_equal<signed long long int, int64_t>();
are_equal<unsigned long long int, uint64_t>();

// short vs short
// - size: both == 2
// - name: both == short

// unsigned short vs unsigned short
// - size: both == 2
// - name: both == unsigned short

// int vs int
// - size: both == 4
// - name: both == int

// unsigned int vs unsigned int
// - size: both == 4
// - name: both == unsigned int

// long vs long
// - size: both == 8
// - name: both == long

// unsigned long vs unsigned long
// - size: both == 8
// - name: both == unsigned long

// long long vs long
// - size: both == 8
// - name: long long != long

// unsigned long long vs unsigned long
// - size: both == 8
// - name: unsigned long long != unsigned long

所有类型都相同,但有一些显著的例外:

  • 前两个测试是特意提供的,以确保实际上会检测到错误。

  • 在 Ubuntu 上,int64_t是使用long实现的,而不是long long,这意味着在 Ubuntu 上,longlong long是相同的。但在 Windows 上不是这样。

这个演示最重要的是要认识到输出中不包括标准整数类型名称,而只包含默认类型名称。这是因为,如前所示,编译器在 Ubuntu 上的 Intel 64 位 CPU 上使用int实现int32_t,对编译器来说,这些类型是一样的。不同之处在于,在另一个 CPU 架构和操作系统上,int32_t可能是使用long int实现的。

如果您关心整数类型的大小,请使用标准整数类型,并让头文件为您选择默认类型。如果您不关心整数类型的大小,或者 API 规定了类型,请使用默认类型。在下一节中,我们将向您展示,即使标准整数类型也不能保证特定大小,并且刚刚描述的规则在使用常见的系统编程模式时可能会出现问题。

结构打包

标准整数提供了一个编译器支持的方法,用于在编译时指定整数类型的大小。具体来说,它们将位宽映射到默认类型,这样编码人员就不必手动执行此操作。然而,标准类型并不总是保证类型的宽度,结构是一个很好的例子。为了更好地理解这个问题,让我们看一个简单的结构示例:

#include <iostream>

struct mystruct {
    uint64_t data1;
    uint64_t data2;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

在前面的例子中,我们创建了一个包含两个 64 位整数的结构。然后,使用sizeof()函数,我们输出了结构的大小到stdout,使用std::cout。如预期的那样,结构的总大小,以字节为单位,是16。值得注意的是,和本书的其余部分一样,本节中的例子都是在 64 位 Intel CPU 上执行的。

现在,让我们看看相同的例子,但其中一个数据类型被更改为 16 位整数,而不是 64 位整数,如下所示:

#include <iostream>

struct mystruct {
    uint64_t data1;
    uint16_t data2;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

在前面的例子中,我们有一个结构,其中有两种数据类型,但它们不匹配。然后,我们使用std::cout输出数据结构的大小到stdout,报告的大小是 16 字节。问题在于,我们期望是 10 字节,因为我们将结构定义为 64 位(8 字节)和 16 位(2 字节)整数的组合。

在幕后,编译器正在用 64 位整数替换 16 位整数。这是因为 C 和 C++的基本类型是int,编译器允许将小于int的类型更改为int,即使我们明确声明第二个整数为 16 位整数。换句话说,使用unit16_t并不要求使用 16 位整数,而是在 64 位 Intel CPU 上的 Ubuntu 上是short inttypedef,根据 C 和 C++规范,编译器可以随意将short int更改为int

我们指定整数的顺序也不重要:

#include <iostream>

struct mystruct {
    uint16_t data1;
    uint64_t data2;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

如前面的例子所示,编译器再次声明结构的总大小为 16 字节,而我们期望是 10。在这个例子中,编译器更有可能进行这种类型的替换,因为它能够识别到存在对齐问题。具体来说,这段代码编译的 CPU 是 64 位 CPU,这意味着用unit64_t替换uint16_t可能会改善内存缓存,并且将data2对齐到 64 位边界,而不是 16 位边界,如果结构在内存中正确对齐,它将跨越两个 64 位内存位置。

结构并不是唯一可以重现这种类型替换的方法。让我们来看看以下例子:

#include <iostream>

int main()
{
    int16_t s = 42;
    auto result = s + 42;
    std::cout << "size: " << sizeof(result) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 4

在前面的例子中,我们创建了一个 16 位整数,并将其设置为42。然后我们创建了另一个整数,并将其设置为我们的 16 位整数加上42。值42可以表示为 8 位整数,但实际上并没有。相反,编译器将42表示为int,在这种情况下,这意味着这段代码编译的系统大小为 4 字节。

编译器将42表示为int,加上int16_t,结果为int,因为这是更高宽度类型。在前面的例子中,我们使用auto定义了result变量,这确保了结果类型反映了编译器由于这种算术操作而创建的类型。我们也可以将result定义为另一个int16_t,这样也可以工作,除非我们打开整数类型转换警告。这样做会导致一个转换警告,因为编译器构造了一个int,作为加上s加上42的结果,然后必须自动将结果的int转换回int16_t,这将执行一个缩小转换,可能导致溢出(因此会有警告)。

所有这些问题都是编译器能够执行类型转换的结果,从较小宽度类型转换为更高宽度类型,以优化性能,减少溢出的可能性。在这种情况下,一个数字值总是一个int,除非该值需要更多的存储空间(例如,用0xFFFFFFFF00000000替换42)。

这种类型的转换并不总是保证的。考虑以下例子:

#include <iostream>

struct mystruct {
    uint16_t data1;
    uint16_t data2;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 4

在前面的例子中,我们有一个包含两个 16 位整数的结构。结构的总大小报告为 4 字节,这正是我们所期望的。在这种情况下,编译器并没有看到改变整数大小的好处,因此保持了它们不变。

位域也不会改变编译器执行这种类型转换的能力,如下例所示:

#include <iostream>

struct mystruct {
    uint16_t data1 : 2, data2 : 14;
    uint64_t data3;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

在前面的例子中,我们创建了一个包含两个整数(一个 16 位整数和一个 64 位整数)的结构,但我们不仅定义了 16 位整数,还定义了位域,使我们可以直接访问整数中的特定位(这种做法在系统编程中应该避免,即将要解释的原因)。定义这些位域并不能阻止编译器将第一个整数的总大小从 16 位改为 64 位。

前面例子的问题在于,位域经常是系统程序员在直接与硬件接口时使用的一种模式。在前面的例子中,第二个 64 位整数预计应该距离结构顶部 2 字节。然而,在这种情况下,第二个 64 位整数实际上距离结构顶部 8 字节。如果我们使用这个结构直接与硬件接口,将会导致一个难以发现的逻辑错误。

克服这个问题的方法是对结构进行打包。以下例子演示了如何做到这一点:

#include <iostream>

#pragma pack(push, 1)
struct mystruct {
    uint64_t data1;
    uint16_t data2;
};
#pragma pack(pop)

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 10

前面的例子类似于本节中的第一个例子。创建了一个包含 64 位整数和 16 位整数的结构。在前面的例子中,结构的大小为 16 字节,因为编译器用 64 位整数替换了 16 位整数。为了解决这个问题,在前面的例子中,我们用#pragma pack#pragma pop宏包装了结构。这些宏告诉编译器(因为我们向宏传递了1,表示一个字节)使用字节粒度对结构进行打包,告诉编译器不允许进行替换优化。

使用这种方法,将变量的顺序更改为编译器尝试执行这种类型优化的更可能情况,仍然会导致结构不被转换,如下例所示:

#include <iostream>

#pragma pack(push, 1)
struct mystruct {
    uint16_t data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 10

如前面的例子所示,结构的大小仍然是 10 字节,无论整数的顺序如何。

将结构打包与标准整数类型结合使用足以(假设字节顺序不是问题)直接与硬件进行接口,但是这种模式仍然不鼓励,而是更倾向于构建访问器和利用位掩码,为用户提供一种方式来确保以受控的方式进行直接访问硬件寄存器,而不受编译器的干扰,或者优化产生不希望的结果。

为了解释为什么应该避免打包结构和位字段,让我们看一个与对齐问题相关的例子:

#include <iostream>

#pragma pack(push, 1)
struct mystruct {
    uint16_t data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    mystruct s;
    std::cout << "addr: " << &s << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// addr: 0x7fffd11069cf

在上一个例子中,我们创建了一个包含 16 位整数和 64 位整数的结构,然后对结构进行了打包,以确保结构的总大小为 10 字节,并且每个数据字段都正确对齐。然而,结构的总对齐方式并不是缓存对齐,这在上一个例子中得到了证明,方法是在堆栈上创建结构的一个实例,然后使用std::cout将结构的地址输出到stdout。如图所示,地址是字节对齐的,而不是缓存对齐的。

为了对结构进行缓存对齐,我们将利用alignas()函数,这将在[第七章中进行解释,内存管理的全面视图

#include <iostream>

#pragma pack(push, 1)
struct alignas(16) mystruct {
    uint16_t data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    mystruct s;
    std::cout << "addr: " << &s << '\n';
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// addr: 0x7fff44ee3f40
// size: 16

在上一个例子中,我们在结构的定义中添加了alignas()函数,它在堆栈上对结构进行了缓存对齐。我们还输出了结构的总大小,就像以前的例子一样,如图所示,结构不再是紧凑的。换句话说,使用#pragma pack#并不能保证结构实际上会被打包。在所有情况下,编译器都可以根据需要进行更改,即使#pragma pack宏也只是一个提示,而不是要求。

在前面的情况下,应该注意编译器实际上在结构的末尾添加了额外的内存,这意味着结构中的数据成员仍然在它们的正确位置,如下所示:

#include <iostream>

#pragma pack(push, 1)
struct alignas(16) mystruct {
    uint16_t data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    mystruct s;
    std::cout << "addr data1: " << &s.data1 << '\n';
    std::cout << "addr data2: " << &s.data2 << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// addr data1: 0x7ffc45dd8c90
// addr data2: 0x7ffc45dd8c92

在上一个例子中,每个数据成员的地址都输出到stdout,并且如预期的那样,第一个数据成员对齐到0,第二个数据成员距离结构顶部 2 字节,即使结构的总大小是 16 字节,这意味着编译器通过在结构底部添加额外的整数来获得额外的 6 字节。虽然这可能看起来无害,如果创建了这些结构的数组,并且假定由于使用了#pragma pack,结构的大小为 10 字节,那么将引入一个难以发现的逻辑错误。

为了结束本章,应该提供一个关于指针大小的注释。具体来说,指针的大小完全取决于 CPU 架构、操作系统和应用程序运行的模式。让我们来看下面的例子:

#include <iostream>

#pragma pack(push, 1)
struct mystruct {
    uint16_t *data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

在上一个例子中,我们存储了一个指针和一个整数,并使用std::cout将结构的总大小输出到stdout。在运行 Ubuntu 的 64 位英特尔 CPU 上,这个结构的总大小是 16 字节。在运行 Ubuntu 的 32 位英特尔 CPU 上,这个结构的总大小将是 12 字节,因为指针只有 4 字节大小。更糟糕的是,如果应用程序被编译为 32 位应用程序,但在 64 位内核上执行,应用程序将看到这个结构为 12 字节,而内核将看到这个结构为 16 字节。尝试将这个结构传递给内核将导致错误,因为应用程序和内核会以不同的方式看待这个结构。

总结

在本章中,我们回顾了 C 和 C++为系统编程提供的不同整数类型(并简要回顾了浮点类型)。我们从讨论 C 和 C++提供的默认类型以及与这些类型相关的利弊开始,包括常见的int类型,解释了它是什么以及如何使用它。接下来,我们讨论了由stdint.h提供的标准整数类型以及它们如何解决默认类型的一些问题。最后,我们结束了本章,讨论了结构打包以及编译器在不同情况下可以进行的类型转换和优化的问题。

在下一章中,我们将介绍 C++17 所做的更改,一种 C++特定的技术称为资源获取即初始化RAII),并概述指导支持库GSL)。

问题

  1. short intint之间有什么区别?

  2. int的大小是多少?

  3. signed intunsigned int的大小不同吗?

  4. int32_tint之间有什么区别?

  5. int16_t保证是 16 位吗?

  6. #pragma pack是做什么的?

  7. 是否可能保证在所有情况下进行结构打包?

进一步阅读