C-语言高效编程-全-

C 语言高效编程(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C 语言是在 1970 年代作为系统编程语言开发的,即使经过了这么长时间,它仍然非常流行。系统语言旨在提供高效的性能,并便捷地访问底层硬件,同时提供高级编程特性。虽然其他语言可能提供更新的语言特性,但它们的编译器和库通常是用 C 语言编写的。

卡尔·萨根曾说过:“如果你想从头开始做一个苹果派,你必须首先发明宇宙。”C 语言的发明者并没有发明宇宙;他们设计了 C 语言,使其能够与各种计算硬件和架构协同工作,而这些硬件和架构又受限于物理学和数学。C 语言直接建立在计算硬件之上,使得它比那些通常依赖于 C 来实现高效的高级语言更能敏感地适应硬件特性的发展,例如向量化指令。

根据 TIOBE 指数(* <wbr>www<wbr>.tiobe<wbr>.com<wbr>/tiobe<wbr>-index<wbr>/ )——该排名基于每种语言的熟练工程师数量、课程和第三方供应商——C 语言自 2001 年起一直是最流行的编程语言或第二流行语言。C 语言的流行可能最能归因于该语言的一些基本原则,被称为C 语言精神*:

  • 信任程序员。C 语言假设你知道自己在做什么,并允许你这样做。这并不总是好事(例如,如果你不知道自己在做什么)。

  • 不要阻止程序员做他们需要做的事情。由于 C 是系统编程语言,它需要处理各种低级任务。

  • 保持语言的小巧和简单。该语言设计紧密贴近硬件,且占用空间小。

  • 只提供一种方法来完成操作。也被称为机制的保守性,C 语言力求限制引入重复机制。

  • 让它运行得更快,即使无法保证它具有可移植性。允许你编写最优化的高效代码是首要任务。确保代码可移植、安全和安全的责任交给你,程序员。

C 语言被用作编译器的目标语言,用于构建操作系统、教授计算机基础知识,以及用于嵌入式和通用编程。

有大量用 C 语言编写的遗留代码。C 语言标准委员会非常小心,不会破坏现有代码,为现代化这些代码以利用现代语言特性提供了平稳的过渡。

C 语言通常用于嵌入式系统,因为它是一种小巧高效的语言。嵌入式系统是嵌入到其他设备中的小型计算机,如汽车、家电和医疗设备。

你最喜欢的编程语言和库是用 C 编写的(或曾经是)。C 有许多可用的库,这使得可以轻松找到可用于执行常见任务的库。

总体而言,C 语言是一种强大而多用途的语言,至今仍被广泛使用。它是需要快速、高效和可移植语言的程序员的良好选择。

C 的简史

C 编程语言是在 1970 年代初期于贝尔实验室开发的,作为新兴 Unix 操作系统的系统实现语言,并且今天仍然非常流行(Ritchie 1993)。系统语言旨在提供高性能并易于访问底层硬件,同时提供高级编程功能。尽管其他语言可能提供更新的语言特性,但它们的编译器和库通常是用 C 编写的。它充当了在各种系统和语言之间翻译的“通用语”。

C 语言首次由 Kernighan 和 Ritchie 于 1978 年在《C 程序设计语言》一书中描述(Kernighan 和 Ritchie 1988)。现在,C 语言由 ISO/IEC 9899 标准(ISO/IEC 2024)和其他技术规范的修订版定义。C 标准委员会负责 C 编程语言的管理,致力于与更广泛的社区合作,以维护和发展 C 语言。1983 年,美国国家标准协会(ANSI)成立了 X3J11 委员会,旨在制定 C 语言的标准规范,并于 1989 年通过了 C 标准,定名为 ANSI X3.159-1989,“编程语言 C”。这一 1989 版本的语言被称为 ANSI CC89

1990 年,ANSI C 标准被国际标准化组织(ISO)和国际电工委员会(IEC)联合技术委员会采纳(未作更改),并作为第一版 C 标准发布,命名为 C90(ISO/IEC 9899:1990)。第二版 C 标准 C99 于 1999 年发布(ISO/IEC 9899:1999),第三版 C11 于 2011 年发布(ISO/IEC 9899:2011)。第四版于 2018 年发布,称为 C17(ISO/IEC 9899:2018),修复了 C11 中的缺陷。C 标准的最新版本(截至本文撰写时)为第五版,于 2024 年发布,称为 C23(ISO/IEC 9899:2024)。截至 2023 年 9 月,我是 ISO/IEC JTC1/SC22/WG14 的召集人,该工作组负责 C 编程语言的国际标准化。

在 TIOBE 编程社区指数跟踪编程语言流行度的 20 年中,C 一直位居第一或第二位(TIOBE Index 2022)。

C 标准

C 标准(ISO/IEC 9899:2024)定义了该语言,并且是语言行为的最终权威。尽管标准可能晦涩难懂,甚至难以理解,但如果你打算编写可移植、安全和可靠的代码,就需要理解它。C 标准为实现提供了相当大的灵活性,以便它们能够在不同的硬件平台上达到最优的效率。实现是 C 标准用来指代编译器的术语,定义如下:

一套特定的软件,在特定的翻译环境中,按照特定的控制选项运行,执行程序翻译并支持在特定执行环境中运行功能。

这个定义表明,任何带有特定命令行标志的编译器,以及 C 标准库,都被视为一个独立的实现,不同的实现可能具有显著不同的实现定义行为。这一点在 GNU 编译器集合(GCC)中尤为明显,GCC 使用 -std= 标志来确定语言标准。该选项的可能值包括 c89、c90、c99、c11、c17 和 c23。默认值取决于编译器的版本。如果没有指定 C 语言方言选项,GCC 13 的默认值是 -std=gnu17,它为 C 语言提供了扩展。为了保证移植性,应该指定使用的标准。为了访问新的语言特性,应指定较新的标准。C23 的特性自 GCC 11 起就已提供。要启用 C23 支持,请添加编译器选项 -std=c23(或可能是 -std=c2x)。本书中的所有示例都采用 C23 编写。

由于实现有各种不同的行为,且其中一些行为是未定义的,因此你不能仅通过编写简单的测试程序来理解 C 语言的行为。(如果你想尝试这个,编译器探索器是一个非常好的工具;请参见 <wbr>godbolt<wbr>.org。)代码的行为可能会在不同的平台上由不同的实现编译时发生变化,甚至在同一个实现上,使用不同的标志或不同的 C 标准库实现时,行为也可能不同。代码行为甚至可能因编译器的版本不同而有所变化。C 标准规定了哪些行为对于所有实现是保证的,以及哪些地方需要考虑到可变性。这个问题主要在开发可移植代码时需要关注,但也可能影响代码的安全性和可靠性。

CERT C 语言编码标准

CERT**^® C 语言编码标准:开发安全、可靠和安全系统的 98 条规则,第二版(Addison-Wesley Professional,2014 年),是我在卡内基梅隆大学软件工程研究所管理安全编码团队时编写的参考书。书中包含了常见的 C 编程错误及其修正方法。在整本书中,我们引用了一些规则,作为特定 C 语言编程主题的详细信息来源。

常见弱点枚举

MITRE 的常见弱点枚举(CWE)是一个列出常见硬件和软件弱点的清单,可用于识别源代码和操作系统中的弱点。CWE 清单由一个社区项目维护,旨在理解软件和硬件中的缺陷,并创建可用于识别、修复和防止这些缺陷的自动化工具。在本书中,我们偶尔会引用具体的 CWE,当讨论可能导致安全漏洞的缺陷类别时。欲了解更多关于 CWE 的信息,请参见 <wbr>cwe<wbr>.mitre<wbr>.org

本书适用对象

本书是 C 语言的入门书籍。它写得尽可能易于理解,适合任何希望学习 C 语言编程的人,而不降低难度。换句话说,我们没有像许多其他入门书籍和课程那样过度简化 C 语言编程。这些过度简化的教材可能会教你如何编译和运行代码,但代码可能仍然是错误的。从这些来源学习 C 编程的开发人员通常会编写出不合格、存在缺陷、不安全的代码,这些代码最终需要重写(通常比预期的要早)。希望这些开发人员最终能够从他们组织中的资深开发人员那里受益,帮助他们摒弃这些关于 C 语言编程的有害误解,开始编写专业质量的 C 代码。另一方面,本书将迅速教会你如何开发正确、可移植、专业质量的代码;为开发安全关键和安全关键系统打下基础;并可能教你一些即便是你组织中的资深开发人员也不知道的知识。

《有效的 C:专业 C 编程简介》(第二版)是一本简洁的 C 语言编程入门书籍,它将帮助你迅速编写程序、解决问题并构建工作系统。书中的代码示例具有惯用表达且直截了当。你还将学习开发正确、安全 C 代码的良好软件工程实践。

在本书中,你将学习 C 语言的基本编程概念,并通过每个主题的练习来实践编写高质量代码。书中的代码示例和其他资料可以在 GitHub 上找到,地址为<wbr>github<wbr>.com<wbr>/rcseacord<wbr>/effective<wbr>-c。你可以访问本书的页面<wbr>nostarch<wbr>.com<wbr>/effective<wbr>-c<wbr>-2nd<wbr>-edition,或者访问<wbr>www<wbr>.robertseacord<wbr>.com,查看更新和附加材料,或者如果你有额外问题或对培训感兴趣,可以联系我。

本书内容

本书从一个介绍性章节开始,涵盖足够的内容,使你从一开始就能开始编程。之后,我们将回顾并研究语言的基本构建块。本书的高潮部分是两个章节,它们将向你展示如何从这些基本构建块组成实际系统,以及如何调试、测试和分析你编写的代码。章节安排如下:

第一章:C 语言入门 你将编写一个简单的 C 程序,熟悉使用main函数。你还将了解一些编辑器和编译器的选择。

第二章:对象、函数与类型 本章探讨了声明变量和函数等基础知识。你还将研究如何使用基本类型的原则。

第三章:算术类型 你将学习整数和浮点数算术数据类型。

第四章:表达式与运算符 你将学习运算符,以及如何编写简单的表达式对各种对象类型进行操作。

第五章:控制流 你将学习如何控制各个语句的评估顺序。我们将介绍表达式和复合语句,它们定义了要执行的工作。然后,我们将涵盖控制语句,它们决定哪些代码块将被执行,以及执行的顺序:选择、迭代和跳转语句。

第六章:动态分配内存 你将学习动态分配内存,它在运行时从堆中分配。动态分配内存在程序的精确存储需求在运行前未知时非常有用。

第七章:字符与字符串 本章涵盖了可以用于组成字符串的各种字符集,包括 ASCII 和 Unicode。你将学习如何使用 C 标准库中的遗留函数、边界检查接口以及 POSIX 和 Windows 应用程序编程接口(API)来表示和操作字符串。

第八章:输入/输出 本章将教你如何执行输入/输出(I/O)操作,从终端和文件系统读取数据或写入数据。I/O 涉及所有信息进入或退出程序的方式。我们将涵盖使用 C 标准流和 POSIX 文件描述符的技术。

第九章:预处理器 你将学习如何使用预处理器来包含文件、定义类似对象和函数的宏,并根据特定实现特性有条件地包含代码。

第十章:程序结构 你将学习如何将程序结构化为多个翻译单元,这些单元由源文件和包含文件组成。你还将学习如何将多个目标文件链接在一起,创建库文件和可执行文件。

第十一章:调试、测试与分析 本章介绍了用于生成无错程序的工具和技术,包括编译时和运行时断言、调试、测试、静态分析和动态分析。章节还讨论了在软件开发过程中不同阶段推荐使用的编译器标志。

附录: C 标准第五版 (C23) 本附录列举了 C23 中的一些新增内容和变化。这是学习 C 新特性以及识别与上一版 C 标准(C17)变化的一种便捷方式。本书更新自之前的版本,涵盖了 C23 的新特性和行为。根据 JetBrains 2022 年的调查数据 (www.jetbrains.com/lp/devecosystem-2022/c/),44% 的 C 程序员使用 C99,33% 使用 C11,16% 使用 C17,15% 使用嵌入式版本的 C。

你即将踏上一段旅程,经过这段旅程后,你将成为一名全新的专业 C 开发者。

第一章:1 开始使用 C

在本章中,你将开发你的第一个 C 程序:传统的 “Hello, world!” 程序。我们将分析这个简单 C 程序的各个方面,编译并运行它。然后我会回顾一些编辑器和编译器选项,并列出你在编写 C 代码时会很快遇到的常见可移植性问题。

开发你的第一个 C 程序

学习 C 编程的最有效方法是开始编写 C 程序,传统的起步程序就是 “Hello, world!” 打开你最喜欢的文本编辑器,并输入 列表 1-1 中的程序。

hello.c

#include <stdio.h>
#include <stdlib.h>

int main() {
  puts("Hello, world!");
  return EXIT_SUCCESS;
}

列表 1-1: “Hello, world!” 程序

前两行使用了 #include 预处理指令,其行为就像你将其替换为指定文件的内容,并放在相同位置一样。在这个程序中,<stdio.h> 和 <stdlib.h> 都是头文件。头文件 是一种源文件,按照约定,它包含了对应源文件的定义、函数声明和常量定义。正如文件名所示,<stdio.h> 定义了 C 标准输入/输出 (I/O) 函数的接口,而 <stdlib.h> 声明了多个通用的实用类型和函数,并定义了多个宏。你需要包含你在程序中使用的任何库函数的声明。(你将会在 第九章 学到更多关于头文件的适当使用。)

在这里,我们包含 <stdio.h> 以访问 puts 函数的声明,该函数由 main 函数调用。我们包含 <stdlib.h> 以访问 EXIT_SUCCESS 宏的定义,该宏在 return 语句中使用。

这一行定义了程序启动时调用的 main 函数:

int main() {

main 函数定义了程序的入口点,当程序从命令行或另一个程序调用时,会在托管环境中执行。C 语言定义了两种可能的执行环境:独立环境和托管环境。独立环境可能不提供操作系统,通常用于嵌入式编程。这些实现提供了一组最小的库函数,程序启动时调用的函数的名称和类型由实现定义。本书中的大多数示例都假设 main 函数是唯一的入口点。

像其他过程式语言一样,C 程序包含可以接受参数并返回值的函数。每个函数都是一个可重用的工作单元,你可以根据需要在程序中多次调用。puts 函数从 main 函数中调用,用来打印出一行 Hello, world!:

 puts("Hello, world!");

puts 函数是一个 C 标准库函数,用于将一个字符串参数写入 stdout 流,并在输出中附加一个换行符。stdout 流通常代表控制台或终端窗口。"Hello, world!" 是一个字符串字面量,它表现得像一个只读字符串。此函数调用将 Hello, world! 输出到终端。

一旦程序执行完毕,你会希望它退出。return 语句会退出 main 函数,并将一个整数值返回给宿主环境或调用脚本:

 return EXIT_SUCCESS;

EXIT_SUCCESS 是一个类对象宏,可能定义如下:

#define EXIT_SUCCESS 0

每次出现 EXIT_SUCCESS 时都会被替换为 0,然后从对 main 的调用中返回给宿主环境。调用该程序的脚本可以检查其状态,以确定调用是否成功。从初始调用返回的 main 函数相当于调用 C 标准库的 exit 函数,并将 main 函数返回的值作为其参数。

该程序的最后一行包含一个闭合大括号(}),它关闭了我们用声明 main 函数打开的代码块:

int main() {
  // `--snip--`
}

你可以将开括号放在与声明同一行,也可以将其放在单独的一行,如下所示:

int main()
{
  // `--snip--`
}

这个决定完全是一个风格问题,因为空白字符(包括换行符)通常在语法上没有意义。在本书中,我通常将开括号放在函数声明所在的行,因为这样风格上更简洁。

现在,将这个文件保存为 hello.c。文件扩展名 .c 表示该文件包含 C 语言源代码。

注意

如果你购买了电子书,可以将程序剪切并粘贴到编辑器中。使用剪切和粘贴可以减少转录错误。

编译和运行程序

接下来,你需要编译并运行程序,这包括两个步骤。编译程序的命令取决于你使用的编译器。在 Linux 和其他类 Unix 操作系统中,在命令行中输入 cc,然后跟上你要编译的文件名:

$ **cc hello.c**

如果你正确输入了程序,编译命令将在与你的源代码相同的目录下创建一个名为 a.out 的新文件。

注意

在其他操作系统,如 Windows 或 macOS 上,编译器的调用方式有所不同。请参考你的特定编译器的文档。

使用以下命令检查你的目录:

$ **ls**
a.out  hello.c

输出中的 a.out 文件就是可执行程序,你现在可以在命令行中运行它:

$ **./a.out**
Hello, world!

如果一切顺利,程序应该会将 Hello, world! 打印到终端窗口。如果没有,比较一下来自 清单 1-1 的程序文本和你的程序,确保它们是一样的。

cc 命令接受许多编译器选项。例如,-o file 编译器选项允许你为可执行文件指定一个易记的名称,而不是 a.out。以下编译命令将可执行文件命名为 hello

$ **cc -o hello hello.c**
$ **./hello**
Hello, world!

在本书中,我们将介绍其他编译器和链接器选项(也称为标志),并将在 第十一章中专门讨论它们。

函数返回值

函数通常会返回一个值,该值是计算结果,或者表示函数是否成功完成了任务。例如,我们在“Hello, world!”程序中使用的 puts 函数接收一个字符串并打印,返回一个 int 类型的值。如果发生写入错误,puts 函数将返回宏 EOF 的值(一个负整数);否则,返回一个非负整数值。

尽管在我们的简单程序中,puts 函数发生失败并返回 EOF 的可能性很小,但它是可能发生的。由于调用 puts 函数可能会失败并返回 EOF,这意味着你的第一个 C 程序存在 bug,或者至少可以按以下方式进行改进:

#include <stdio.h>
#include <stdlib.h>
int main() {
  if (puts("Hello, world!") == EOF) {
    return EXIT_FAILURE;
    // code here never executes
  }
 return EXIT_SUCCESS;
  // code here never executes
}

这个修改版的“Hello, world!”程序检查 puts 调用是否返回 EOF,表示写入错误。如果函数返回 EOF,程序返回 EXIT_FAILURE 宏的值(该值为非零值)。否则,函数执行成功,程序返回 EXIT_SUCCESS。调用程序的脚本可以检查其状态以确定是否成功。紧随返回语句之后的代码是 死代码,永远不会执行。程序中的这一点通过单行注释表示,注释符号 // 后面的内容会被编译器忽略。

格式化输出

puts 函数是一个简单的方式将字符串写入 stdout,但是要打印除字符串以外的参数,你需要使用 printf 函数。printf 函数接受一个格式化字符串,定义输出的格式,然后跟随一个可变数量的参数,这些参数是你希望打印的实际值。例如,如果你想使用 printf 函数打印 Hello, world!,你可以这样写:

printf("%s\n", "Hello, world!");

第一个参数是格式化字符串 "%s\n"。%s 是一个转换说明符,它指示 printf 函数读取第二个参数(一个字符串字面量)并将其打印到 stdout。\n 是一个字母转义序列,用于表示非图形字符,并告诉函数在字符串后面插入一个新行。如果没有换行序列,接下来的字符(可能是命令提示符)将显示在同一行。这个函数调用输出如下:

Hello, world!

请小心不要将用户提供的数据作为第一个参数传递给 printf 函数,因为这样做可能会导致格式化输出的安全漏洞(Seacord 2013)。

输出字符串的最简单方法是使用 puts 函数,如前所示。然而,如果你在修改版的“Hello, world!”程序中使用 printf 而不是 puts,你会发现它不再起作用,因为 printf 函数返回的状态与 puts 函数不同。printf 函数成功时返回打印的字符数,如果发生输出或编码错误,则返回负值。作为练习,尝试修改“Hello, world!”程序以使用 printf 函数。

编辑器和集成开发环境

你可以使用多种编辑器和集成开发环境(IDE)来开发你的 C 程序。图 1-1 显示了根据 2023 年 JetBrains 调查(<wbr>www<wbr>.jetbrains<wbr>.com<wbr>/lp<wbr>/devecosystem<wbr>-2023<wbr>/c<wbr>/)使用最广泛的编辑器。

图 1-1:流行的 IDE 和编辑器

可用的工具具体取决于你使用的系统。

对于 Microsoft Windows,微软的 Visual Studio IDE (<wbr>visualstudio<wbr>.microsoft<wbr>.com) 是一个显而易见的选择。Visual Studio 有三个版本:Community、Professional 和 Enterprise。Community 版本的优势在于免费,而其他版本则需要付费以获得额外功能。本书中,你只需要使用 Community 版本。

对于 Linux,选择不那么明显,因为有多种选项。一种流行的选择是 Visual Studio Code(VS Code)。VS Code 是一个精简的代码编辑器,支持调试、任务运行和版本控制等开发操作(在第十一章中涵盖)。它提供了开发者进行快速代码构建调试周期所需的工具。VS Code 可以在 macOS、Linux 和 Windows 上运行,并且可以免费用于私人或商业用途。对于 Linux 和其他平台,安装说明可用(<wbr>code<wbr>.visualstudio<wbr>.com)。

图 1-2 显示了在 Ubuntu 上使用 VS Code 开发 “Hello, world!” 程序。调试控制台显示程序按预期以状态码 0 退出。

图 1-2:在 Ubuntu 上运行的 Visual Studio Code

Vim 是许多开发者和高级用户的首选编辑器。它是一个基于 Bill Joy 在 1970 年代为 Unix 版本编写的 vi 编辑器的文本编辑器。它继承了 vi 的快捷键绑定,但还增加了原始 vi 所没有的功能和扩展性。你可以选择安装 Vim 插件,如 YouCompleteMe (<wbr>github<wbr>.com<wbr>/ycm<wbr>-core<wbr>/YouCompleteMe) 或 deoplete (<wbr>github<wbr>.com<wbr>/Shougo<wbr>/deoplete<wbr>.nvim),它们为 C 编程提供原生语义代码补全。

GNU Emacs 是一个可扩展、可定制的免费文本编辑器。从本质上讲,它是 Emacs Lisp 的解释器,Emacs Lisp 是一种支持文本编辑的 Lisp 编程语言方言—尽管我从未发现这成为问题。完全公开:我开发的几乎所有生产级 C 代码都是在 Emacs 中编辑的。

编译器

有许多 C 编译器可供选择,因此我不会在这里讨论所有的编译器。不同的编译器实现了不同版本的 C 标准。许多嵌入式系统的编译器仅支持 C89/C90。流行的 Linux 和 Windows 编译器更努力地支持现代版本的 C 标准,直到包括对 C23 的支持。

GNU 编译器集合

GNU 编译器集合(GCC)包括 C、C++ 和 Objective-C 的前端,以及其他语言(<wbr>gcc<wbr>.gnu<wbr>.org)。GCC 在 GCC 指导委员会的指导下,遵循一个明确定义的开发计划。

GCC 已被采纳为 Linux 系统的标准编译器,虽然也有适用于 Microsoft Windows、macOS 和其他平台的版本。在 Linux 上安装 GCC 很容易。例如,以下命令应该会在 Ubuntu 上安装 GCC:

$ **sudo apt-get install gcc**

你可以使用以下命令测试你正在使用的 GCC 版本:

$ **gcc --version**

输出将显示已安装的 GCC 版本的版本和版权信息。

Clang

另一个流行的编译器是 Clang (<wbr>clang<wbr>.llvm<wbr>.org)。在 Linux 上安装 Clang 也很简单。你可以使用以下命令在 Ubuntu 上安装 Clang:

$ **sudo apt-get install clang**

你可以使用以下命令来测试你使用的 Clang 版本:

$ **clang --version**

这将显示已安装的 Clang 版本。

Microsoft Visual Studio

如前所述,Windows 最流行的开发环境是 Microsoft Visual Studio,它包括 IDE 和编译器。Visual Studio (<wbr>visualstudio<wbr>.microsoft<wbr>.com<wbr>/downloads<wbr>/) 附带了 Visual C++ 2022,其中包括 C 和 C++ 编译器。

你可以在项目属性页面中为 Visual Studio 设置选项。在 C/C++ 的“高级”选项卡下,确保使用编译为 C 代码(/TC)选项,而不是编译为 C++ 代码(/TP)选项。默认情况下,当你命名一个文件为 .c 扩展名时,它会使用 /TC 进行编译。如果文件命名为 .cpp.cxx 或其他几个扩展名,它将使用 /TP 进行编译。

可移植性

每个 C 编译器的实现都有一些不同。编译器在不断发展—例如,像 GCC 这样的编译器可能已经完全支持 C17,但正在向支持 C23 过渡,在这种情况下,它可能实现了一些 C23 特性,但没有实现其他特性。因此,编译器支持一整套 C 标准版本(包括中间版本)。C 实现的整体演进是缓慢的,许多编译器显著滞后于 C 标准。

为 C 编写的程序可以被认为是严格符合标准的,如果它们仅使用语言和库中标准所规定的特性。这些程序旨在实现最大程度的可移植性。然而,由于实现行为的差异,实际上没有任何真实世界中的 C 程序是严格符合标准的,也不可能是(而且可能不应该是)。相反,C 标准允许你编写符合标准的程序,这些程序可能依赖于不可移植的语言和库特性。

编写代码时,通常是为单一的参考实现编写代码,或者根据你计划部署代码的平台编写几个不同的实现。C 标准确保这些实现之间的差异不会太大,并允许你一次性面向多个平台,而无需每次都学习一种新的语言。

C 标准文档的附录 J 列出了五种可移植性问题:

  • 实现定义行为

  • 未指定行为

  • 未定义行为

  • 特定区域行为

  • 常见扩展

在学习 C 语言时,您将遇到所有五种行为的例子,因此准确理解它们是什么非常重要。

实现定义行为

实现定义行为是指 C 标准未指定的程序行为,它可能在不同的实现之间产生不同的结果,但在同一实现中有一致的、已记录的行为。实现定义行为的一个例子是字节中的位数。

实现定义的行为大多数是无害的,但在移植到不同的实现时可能会引起缺陷。尽可能避免编写依赖于实现定义行为的代码,这些行为在不同的 C 实现之间可能有所不同。实现定义行为的完整列表列在 C 标准的附录 J.3 中。您可以通过使用static_assert声明来记录您对这些实现定义行为的依赖,正如在第十一章中讨论的那样。

未指定行为

未指定行为是指程序行为,标准提供了两个或更多的选项,但并未强制规定在任何情况下选择哪个选项。每次执行给定表达式时,可能会产生不同的结果,或与前一次执行同一表达式时产生的值不同。未指定行为的一个例子是函数参数存储布局,这在同一个程序中的多个函数调用之间可能会有所不同。未指定行为列在 C 标准的附录 J.1 中。

未定义行为

未定义行为是指 C 标准未定义的行为,或者更准确地说,“行为,在使用不可移植或错误的程序结构或错误数据时,标准没有强制要求”(ISO/IEC 9899:2024)。未定义行为的例子包括有符号整数溢出和解引用无效指针值。具有未定义行为的代码通常是错误的,但并非总是如此。未定义行为在标准中标识如下:

  • 当“应当”或“不得”要求被违反,并且该要求出现在约束之外时,行为是未定义的。

  • 当行为明确由“未定义行为”这一术语指定时。

  • 通过省略任何明确的行为定义。

前两种未定义行为通常被称为显式未定义行为,而第三种则称为隐式未定义行为。这三者在强调上没有区别,它们都描述了未定义的行为。《C 标准》附录 J.2“未定义行为”列出了 C 语言中的显式未定义行为。

开发人员常常将未定义行为误解为 C 标准中的错误或遗漏,但将行为归类为未定义是故意的并且是经过深思熟虑的。C 标准委员会将行为归类为未定义的原因如下:

  • 授予实现者不捕捉难以诊断的程序错误的许可

  • 避免定义模糊的边界情况,这些情况可能会偏向某种实现策略

  • 确定可能符合的语言扩展领域,在这些领域中,实施者可以通过提供未定义行为的定义来扩展语言

这三种原因虽然截然不同,但都被视为可移植性问题。在本书的过程中,我们会逐一讨论这三种情况的示例。当遇到未定义行为时,编译器可以执行以下操作:

  • 完全忽视未定义行为,导致不可预测的结果

  • 以环境特征的已记录方式进行行为(可以不发出诊断信息)

  • 终止翻译或执行(并发出诊断信息)

这些选项都不好(尤其是第一个),因此最好避免未定义行为,除非编译器明确指定这些行为是已定义的,以便你调用语言扩展。编译器有时会有迂腐模式,帮助通知程序员这些可移植性问题。

区域特定行为和常见扩展

区域特定行为依赖于每个实现文档中规定的国家、文化和语言的本地习惯。常见扩展在许多系统中被广泛使用,但并不具有所有实现的可移植性。

摘要

在本章中,你学会了如何编写一个简单的 C 语言程序、编译它并运行。我们还看了几种编辑器和集成开发环境,以及一些你可以用来在 Windows、Linux 和 macOS 系统上开发 C 程序的编译器。你应该使用更新版本的编译器和其他工具,因为它们通常支持 C 语言的新特性,并提供更好的诊断和优化。然而,如果更新的编译器破坏了你现有的代码,或者如果你准备部署代码以避免在已经测试的应用中引入不必要的变化,可能不想使用更新版本的编译器。我们在本章的结尾讨论了 C 语言程序的可移植性。

随后的章节将探讨 C 语言及其库的具体特性,从下一章的对象、函数和类型开始。

第二章:2 对象、函数和类型

在本章中,你将学习对象、函数和类型。我们将探讨如何声明变量(具有命名标识符的对象)和函数,获取对象的地址,并解引用这些对象指针。每个对象或函数实例都有一个类型。你已经看到了一些 C 程序员可以使用的类型。本章中你将学到的第一件事,恰恰是我最后学到的:C 中的每个类型都是对象类型或函数类型。

实体

对象是用于表示值的存储空间。准确来说,C 标准(ISO/IEC 9899:2024)将对象定义为“执行环境中的一个数据存储区域,其内容可以表示值”,并补充说明,“当引用时,对象可以被解释为具有特定类型。”变量就是一个对象的例子。

变量有一个声明的类型,它告诉你变量值所代表的对象类型。例如,类型为int的对象包含一个整数值。类型很重要,因为代表某种类型对象的比特集合,如果被解释为另一种类型的对象,可能会得到不同的值。例如,数字 1 在 IEEE 浮点运算标准中由比特模式0x3f800000表示(IEEE 754-2019)。但如果你将这个比特模式解释为一个整数,你会得到值 1,065,353,216,而不是 1。

函数不是对象,但它们有类型。函数类型由其返回类型以及参数的数量和类型来描述。

C 语言中也有指针,它可以被看作是一个地址——内存中存储对象或函数的位置。

就像对象和函数一样,对象指针和函数指针是不同的东西,不能互换。在接下来的部分中,你将编写一个简单的程序,尝试交换两个变量的值,帮助你更好地理解对象、函数、指针和类型。

声明变量

当你声明一个变量时,你为它分配一个类型,并提供一个名称或标识符,通过该标识符引用该变量。你也可以选择性地初始化该变量。

清单 2-1 声明了两个具有初始值的整数对象。这个简单的程序还声明了一个swap函数来交换这些值,但没有定义它。

#include <stdio.h>
#include <stdlib.h>

❶ void swap(int, int); // defined in Listing 2-2

int main() {
  int a = 21;
  int b = 17;
❷ swap(a, b);
  printf("main: a = %d, b = %d\n", a, b);
  return EXIT_SUCCESS;
}

清单 2-1:一个用于交换两个整数的程序

这个示例程序展示了一个包含单个复合语句的 main 函数,该复合语句包括 {} 字符以及其中的所有语句(也称为)。我们在 main 函数内定义了两个变量,a 和 b。我们将这两个变量声明为类型 int,并分别初始化为 21 和 17。每个变量必须有一个声明。然后,main 函数调用 swap 函数 ❷ 尝试交换这两个整数的值。swap 函数在本程序中声明了 ❶,但没有定义。我们将在本节后面探讨该函数的一些可能实现。

交换值,第一次尝试

每个对象都有一个存储持续时间,决定了它的生命周期,即在程序执行过程中,对象存在、占用存储、拥有固定地址并保持最后存储的值的时间。对象不得在其生命周期外被引用。

本地变量,如来自 列表 2-1 的 a 和 b,具有自动存储持续时间,意味着它们存在直到程序执行离开声明它们的块为止。我们将尝试交换这两个变量中存储的值。列表 2-2 展示了我们第一次尝试实现 swap 函数的代码。

void swap(int a, int b) {
  int t = a;
  a = b;
 b = t;
  printf("swap: a = %d, b = %d\n", a, b);
}

列表 2-2:第一次尝试实现 swap 函数

swap 函数声明了两个参数,a 和 b,我们用它们来向这个函数传递参数。C 区分 参数实参,其中参数是作为函数声明的一部分声明的对象,在进入函数时会获取一个值,而实参是我们在函数调用表达式中传入的以逗号分隔的表达式。我们还在 swap 函数中声明了一个临时变量 t,其类型为 int,并将其初始化为 a 的值。此变量用于临时保存 a 中存储的值,以免在交换过程中丢失。

现在我们可以运行生成的可执行文件来测试程序:

% **./a.out**
swap: a = 17, b = 21
main: a = 21, b = 17

这个结果可能让人感到惊讶。变量 a 和 b 分别被初始化为 21 和 17。第一次在 swap 函数中的 printf 调用显示这两个值已被交换,但在 main 中的第二次 printf 调用则显示原始值未变。让我们看看发生了什么。

C 是一种 按值传递(也称为 传值)语言,这意味着当你向函数提供一个实参时,该实参的值会被复制到一个独立的变量中供函数使用。swap 函数将你作为实参传递的对象的值赋给它们各自的参数。当函数中的参数值发生变化时,调用者中的实参值不会受到影响,因为它们是独立的对象。因此,在第二次调用 printf 时,变量 a 和 b 保持其在 main 中的原始值。程序的目标是交换这两个对象的值。通过测试程序,我们发现它存在一个 bug 或缺陷。

交换值,第二次尝试

为了修复这个 bug,我们可以使用指针重写 swap 函数。我们使用间接操作符 (*) 来声明指针并对其解引用,如 Listing 2-3 所示。

void swap(int *pa, int *pb) {
  int t = *pa;
  *pa = *pb;
  *pb = t;
}

Listing 2-3: 修改后的 swap 函数使用指针

当在函数声明或定义中使用时,* 作为指针声明符的一部分,表示该参数是指向某种类型的对象或函数的指针。在重写的 swap 函数中,我们声明了两个参数,pa 和 pb,它们的类型都是指向 int 的指针。

一元操作符 * 表示间接引用。如果其操作数是指向 T 的指针类型,则操作结果的类型为 T。例如,考虑以下赋值:

pa = pb;

这将指针 pa 的值替换为指针 pb 的值。现在考虑 swap 函数中的赋值操作:

*pa = *pb;

*pb 操作读取 pb 引用的值,而 *pa 操作读取 pa 引用的位置。然后将 pb 引用的值写入 pa 引用的位置。

当你在 main 中调用 swap 函数时,你还必须在每个变量名前加上一个 &(&)字符:

swap(&a, &b);

一元操作符 &(取地址)操作符生成其操作数的指针。这个变化是必要的,因为 swap 函数现在接受指向 int 类型的指针作为参数,而不是 int 类型。

清单 2-4 显示了整个 swap 程序,注释描述了在执行此代码期间创建的对象及其值。

#include <stdio.h>
#include <stdlib.h>

void swap(int *pa, int *pb) {   // pa → a: 21    pb → b: 17
  int t = *pa;                  // t: 21
  *pa = *pb;                    // pa → a: 17    pb → b: 17
  *pb = t;                      // pa → a: 17    pb → b: 21
}

int main() {
  int a = 21;                   // a: 21
  int b = 17;                   // b: 17
  swap(&a, &b);
  printf("a = %d, b = %d\n", a, b);    // a: 17    b: 21
  return EXIT_SUCCESS;
}

清单 2-4:模拟的引用调用

进入 main 函数块后,变量 a 和 b 分别被初始化为 21 和 17。然后,代码获取这些对象的地址,并将它们作为参数传递给 swap 函数。

在 swap 函数内,参数 pa 和 pb 现在都声明为指向 int 类型的指针,并包含传递给 swap 的调用函数(在本例中为 main)的参数副本。这些地址副本仍然指向完全相同的对象,因此当它们所引用对象的值在 swap 函数中交换时,main 中声明的原始对象的内容也会被交换。这种方法通过生成对象地址、按值传递这些地址,然后解引用复制的地址来访问原始对象,从而模拟了 按引用调用(也称为 按引用传递)。

对象类型

本节介绍了 C 中的对象类型。具体来说,我们将涵盖布尔类型、字符类型和算术类型(包括整数类型和浮点类型)。

布尔

布尔数据类型有两个可能的值(true 或 false),表示逻辑和布尔代数中的两个真值。声明为 bool 的对象只能存储 true 和 false 这两个值。

以下示例声明了一个名为 arm_detonator 的函数,该函数接受一个 int 类型的参数,并返回一个 bool 类型的值:

bool arm_detonator(int);

void arm_missile(void) {
  bool armed = arm_detonator(3);
  if (armed) puts("missile armed");
  else puts("missile disarmed");
}

arm_missile 函数调用 arm_detonator 函数,并将返回值赋给布尔变量 armed。然后,可以测试该值以确定导弹是否已准备就绪。

历史上,布尔值是通过整数表示的,并且仍然表现得像整数一样。它们可以存储在整数变量中,并可以在任何整数有效的地方使用,包括索引、算术运算、解析和格式化。C 语言保证任何两个 true 值会相等比较(这是在引入 bool 类型之前无法实现的)。你应该使用 bool 类型来表示布尔值。

字符

C 语言定义了以下字符类型:char、signed char 和 unsigned char。每个编译器实现都将 char 定义为与 signed char 或 unsigned char 具有相同的对齐方式、大小、范围、表示和行为。无论选择哪一种,char 都是一个独立的类型,与其他两个类型不兼容。

char 类型通常用于表示 C 语言程序中的字符数据。char 类型的对象可以表示 基本执行字符集 —— 执行环境中所需的最小字符集,包括大小写字母、10 个十进制数字、空格字符、标点符号和控制字符。char 类型不适用于整数数据;使用 signed char 来表示小的有符号整数值,使用 unsigned char 来表示小的无符号整数值。

char 类型的对象大小始终为 1 字节,宽度为 CHAR_BIT 位。CHAR_BIT 宏来自 <limits.h>,用于定义一个字节中的位数。CHAR_BIT 宏的值不能小于 8,并且在大多数现代平台上,其值为 8。

基本的执行字符集适用于许多常规数据处理应用,但其缺乏非英语字母是国际用户接受的障碍。为了解决这一需求,C 标准委员会规定了一种新的宽字符类型,以允许更大的字符集。你可以使用 wchar_t 类型将大字符集的字符表示为 宽字符,它通常占用比基本字符更多的空间。通常,实施会选择 16 位或 32 位来表示一个宽字符。C 标准库提供了支持窄字符和宽字符类型的函数。wchar_t 类型并未设计用来支持 Unicode,因此在大多数实现中已逐渐被弃用,微软 Visual Studio 是一个显著的例外。

算术

C 提供了几种 算术类型,可用于表示整数、枚举值和浮点值。第三章详细介绍了其中的一些,但这里简要介绍一下。

整数

带符号整数类型 可以用于表示负数、正数和零。标准的带符号整数类型包括 signed char、short int、int、long int 和 long long int。

对于每种带符号整数类型,都有一个相应的 无符号整数类型,它使用相同的存储空间:unsigned char、unsigned short int、unsigned int、unsigned long int 和 unsigned long long int。无符号类型可以表示正数和零。这些无符号整数类型以及类型 bool 组成了标准的无符号整数类型。

除了 int 本身外,关键字 int 在这些类型的声明中可以省略,因此你可以通过使用 long long 来声明类型,而不是 long long int。

有符号和无符号整数类型用于表示各种宽度的整数。每个平台确定每种类型的宽度,给定一些约束。每种类型都有一个最小可表示范围。这些类型按宽度排序,确保更宽的类型至少与更窄的类型一样大。这意味着long long int类型的对象可以表示long int类型的对象可以表示的所有值,long int类型的对象可以表示的所有值都可以由int类型的对象表示,依此类推。整数类型的实现定义的最小和最大可表示值在<limits.h>头文件中指定。

扩展整数类型可能会在标准整数类型之外提供。它们是实现定义的,这意味着它们的宽度、精度和行为取决于编译器。扩展整数类型通常比标准整数类型大(例如,__int128)。

除了标准和扩展整数类型外,C23 还增加了比特精确整数类型。这些类型接受一个操作数来指定整数的宽度,因此_BitInt(32)是一个带符号的 32 位整数,而unsigned _BitInt(32)则是一个无符号的 32 位整数。比特精确整数类型不要求它们的宽度是 2 的幂;支持的最大宽度由BITINT_MAXWIDTH指定(其宽度至少与unsigned long long的宽度相同)。

int类型通常根据执行环境的架构被赋予自然的宽度(例如,在 16 位架构上是 16 位,在 32 位或 64 位架构上是 32 位)。您可以通过使用<stdint.h>或<inttypes.h>头文件中的类型定义(如uint32_t)来指定实际宽度整数。这些头文件还为最大宽度整数类型提供类型定义:uintmax_t和intmax_t。例如,intmax_t类型可以表示任何带符号整数类型的值,可能例外的是带符号比特精确整数类型和带符号扩展整数类型。

第三章详细讲解了整数类型。 #### enum

一个枚举,或称enum,允许你定义一个类型,在有枚举常量集合的情况下为整数值指定名称(枚举量)。以下是枚举的示例:

enum day {sun, mon, tue, wed, thu, fri, sat};
enum cardinal_points {north = 0, east = 90, south = 180, west = 270};
enum months {jan = 1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec};

如果你没有使用=操作符为第一个枚举量指定值,则其枚举常量的值为 0,之后每个没有=的枚举量将基于前一个枚举常量的值加 1。因此,sun在< samp class="SANS_TheSansMonoCd_W5Regular_11">day枚举中的值为 0,mon的值为 1,以此类推。

你也可以为每个枚举量指定特定的值,如cardinal_points枚举所示。使用=与枚举量可能会导致枚举常量具有重复的值,如果你错误地假设所有值都是唯一的,这可能会成为问题。months枚举将第一个枚举量的值设为 1,之后每个未特定赋值的枚举量将递增 1。

从 C23 开始,你可以指定枚举的底层类型。为了移植性和其他原因(Meneide 和 Pygott 2022),最好始终指定枚举类型。在以下示例中,枚举常量a0可以被赋值为0xFFFFFFFFFFFFFFFFULL,因为类型被指定为unsigned long long:

enum a : unsigned long long {
  a0 = 0xFFFFFFFFFFFFFFFFULL
};

如果省略类型,则由实现定义。Visual C++使用signed int作为类型,而 GCC 则使用unsigned int。

Floating

浮点运算与实数运算相似,并且常常作为实数运算的模型。C 语言支持多种浮点表示,包括大多数系统上采用的 IEEE 浮点运算标准(IEEE 754-2019)。ISO/IEC 60559:2011 与 IEEE 754-2019 内容相同,但由于它是由同一标准组织发布的,因此 C 标准引用了它。浮点表示的选择由实现决定。第三章详细讲解了浮点类型。

C 语言支持三种标准浮动类型:float、double 和 long double。类型 float 的值集是类型 double 的值集的子集;类型 double 的值集是类型 long double 的值集的子集。

C23 添加了三种十进制浮动类型(ISO/IEC TS 18661-2:2015),分别为 _Decimal32、_Decimal64 和 _Decimal128。这些类型分别对应于十进制 32、十进制 64 和十进制 128 IEC 60559 格式。

标准浮动类型和十进制浮动类型统称为实数浮动类型

还有三种复杂类型,分别为 float complex、double complex 和 long double complex。

实数浮动类型和复杂类型统称为浮动类型。图 2-1 展示了浮动类型的层次结构。

图 2-1:浮动类型的层次结构

本书没有详细介绍复杂类型和十进制浮动类型。

void

void 类型是一个相当特殊的类型。关键字 void(单独使用时)表示“不能持有任何值”。例如,你可以使用它来表示一个函数不返回任何值,或者作为函数的唯一参数,表示该函数不接受任何参数。另一方面,派生类型 void * 表示该指针可以引用任何对象。

派生类型

派生类型是由其他类型构造而来的。它们包括函数类型、指针类型、数组类型、类型定义、结构类型和联合类型——所有这些都会在此涵盖。

函数

函数类型由返回类型和参数的数量及类型派生而来。一个函数可以返回任何完整的对象类型,但不能返回数组类型。

当你声明一个函数时,使用 函数声明符 来指定函数的名称和返回类型。如果声明符包括参数类型列表和定义,则每个参数的声明必须包含一个标识符,除非参数列表只有一个类型为 void 的参数,这种情况不需要标识符。

这里是一些函数类型声明:

int f(void);
int fprime();
int *fip();
void g(int i, int j);
void h(int, int);

首先,我们声明两个函数,f 和 fprime,它们没有参数,返回一个 int。接下来,我们声明一个函数 fip,没有参数,返回一个指向 int 的指针。最后,我们声明两个函数,g 和 h,每个函数都返回 void 并接受两个 int 类型的参数。

指定带有标识符的参数(如这里的 g)可能会有问题,尤其是当标识符是宏时。然而,为参数提供名称是自文档化代码的好习惯,因此通常不建议省略标识符(就像在 h 中所做的那样)。

在函数声明中,指定参数是可选的。然而,如果不指定,可能会引发问题。在 C23 之前,fip 声明了一个接受任意数量的任意类型参数并返回一个 int * 的函数。相同的 fip 函数声明在 C++ 中声明了一个接受无参数并返回 int * 的函数。从 C23 开始,具有空参数列表的函数声明符声明了一个不接受任何参数的函数原型(就像在 C++ 中一样)。

函数类型也被称为 函数原型。函数原型向编译器告知函数接受的参数数量和类型。编译器使用这些信息来验证函数定义和任何对函数的调用中使用了正确数量和类型的参数。

函数定义 提供了函数的实际实现。考虑以下函数定义:

int max(int a, int b)
{return a > b ? a : b;}

返回类型说明符是 int;函数声明是 max(int a, int b);函数体是 {return a > b ? a : b;}。函数类型的规范不应包含任何类型限定符(见“类型限定符”部分,参见 第 31 页)。函数体本身使用了条件运算符(? :),这一运算符在 第四章 中有解释。该表达式表示,如果 a 大于 b,则返回 a;否则,返回 b。

指针

指针类型 是由称为 引用类型 的函数或对象类型派生而来。由引用类型 T 派生的指针类型称为指向 T 的指针。指针提供对引用类型实体的引用。

以下三条声明声明了指向 int、指向 char 和指向 void 的指针:

int *ip = 0; // compliant
char *cp = NULL; // good
void *vp = nullptr; // better

每个指针都初始化为一个空指针常量。空指针常量可以指定为值为 0 的整数常量表达式、(void *)0,或者预定义常量 nullptr。NULL 宏在 <stddef.h> 中定义。如果空指针常量被转换为指针类型,则生成的空指针保证与指向任何对象或函数的指针进行比较时不相等。

nullptr 常量是在 C23 中引入的,相较于使用 NULL,它具有优势(Gustedt 2022)。表 2-1 显示了 NULL 的常见值及其关联类型。

表 2-1: NULL 的常见值及其关联类型

类型
0 int
0L long
(void *)0 void *

这些不同类型在调用具有NULL参数的类型通用宏时可能会产生意想不到的结果。条件表达式(true ? 0 : NULL)始终是定义的,无论NULL的类型是什么。然而,如果NULL的类型是void *,则条件表达式(true ? 1 : NULL)将违反约束。

将NULL参数作为哨兵值传递给可变参数函数(例如便携式操作系统接口(POSIX)execl函数,它期望一个指针)可能会产生意外的结果。在大多数现代架构上,int和void *类型的大小是不同的。如果在这样的架构上将NULL定义为 0,那么会将大小不正确的参数传递给可变参数函数。

在本章前面,我介绍了取地址运算符(&)和间接寻址运算符(*)。你使用&运算符来获取对象或函数的地址。例如,获取一个int对象的地址会得到一个指向int的指针地址:

int i = 17;
int *ip = &i;

第二个声明声明了变量ip为指向int的指针,并将其初始化为i的地址。你还可以对*运算符的结果使用&运算符:

ip = &*ip;

使用运算符解引用ip将解析为实际对象i。使用&运算符取ip的地址将检索到指针,因此这两个操作相互抵消。

一元 * 运算符将指针类型 T 转换为类型 T 的值。它表示 间接寻址,并且仅适用于指针。如果操作数指向一个函数,使用 * 运算符的结果是函数指示符;如果操作数指向一个对象,结果是该对象的值。例如,如果操作数是指向 int 类型的指针,则间接寻址运算符的结果是 int 类型。如果指针未指向有效的对象或函数,则行为是未定义的。

数组

数组 是一系列按顺序分配的对象,这些对象都具有相同的元素类型。数组类型的特征由其元素类型和数组中元素的数量决定。这里我们声明了一个包含 11 个元素的 int 类型数组,标识为 ia,以及一个包含 17 个元素的指向 float 的指针类型数组,标识为 afp:

int ia[11];
float *afp[17];

你可以使用方括号([])来标识数组的一个元素。例如,下面的示例代码演示了如何给数组的元素赋值,以创建字符串 "0123456789":

char str[11];
for (unsigned int i = 0; i < 10; ++i) {
  str[i] = '0' + i;
}
str[10] = '\0';

第一行声明了一个大小为 11 的 char 数组。这为创建一个包含 10 个字符加一个空字符的字符串分配了足够的存储空间。for 循环迭代 10 次,i 的值从 0 到 9。每次迭代将表达式 '0' + i 的结果赋值给 str[i]。循环结束后,空字符被复制到数组的最后一个元素 str[10],此时 str 包含字符串 "0123456789"。

在表达式 str[i] 中,str 会自动转换为指向数组第一个成员的指针(指向 char 的指针),而 i 则是无符号整数类型。下标([])和加法(+)运算符被定义为使得 str[i] 与 *(str + i) 等价。当 str 是一个数组对象(如本例所示)时,表达式 str[i] 表示数组的第 i 个元素(从 0 开始计数)。因为数组的下标是从 0 开始的,所以数组 char str[11] 的下标范围是从 0 到 10,其中 10 为最后一个元素,如本例最后一行所示。

如果一元运算符 & 的操作数是 [] 运算符的结果,那么结果就好像是移除了 & 运算符并将 [] 运算符换成了 + 运算符。例如,&str[10] 和 str + 10 是等效的:

&str[10] → &*(str + 10) → str + 10

你还可以声明多维数组。列表 2-5 在函数 main 中声明了一个二维 3×5 的 arr 数组,类型为 int,也叫做 矩阵

#include <stdlib.h>
void func(int arr[5]);
int main() {
  unsigned int i = 0;
  unsigned int j = 0;
  int arr[3][5];
❶ func(arr[i]);
❷ int x = arr[i][j];
  return EXIT_SUCCESS;
}

列表 2-5:矩阵运算

更准确地说,arr 是一个包含三个元素的数组,每个元素又是一个包含五个 int 类型元素的数组。当你使用表达式 arr[i] ❶(等同于 *(arr+i))时,发生的情况如下:

1.  arr 被转换为指向包含五个 int 类型元素的初始数组的指针,从 arr[i] 开始。

2.  i 通过将 i 乘以一个包含五个 int 元素的数组的大小,将其缩放到 arr 的类型。

3.  步骤 1 和步骤 2 的结果被相加。

4.  间接访问应用于总和,生成一个包含五个 int 类型元素的数组。

当在表达式 arr[i][j] ❷ 中使用时,该数组会被转换为指向第一个 int 类型元素的指针,因此 arr[i][j] 会生成一个 int 类型的对象。

结构体

结构类型(也称为 struct)包含按顺序分配的成员。每个成员都有自己的名称,并且可以有不同的类型——与数组元素不同,数组元素必须都是相同类型。结构体类似于其他编程语言中的记录类型。

结构体用于声明一组相关对象的集合,可以用来表示日期、客户或人员记录等。它们尤其在将经常一起作为参数传递给函数的对象进行分组时非常有用,这样你就不需要重复传递单独的对象。

列表 2-6 声明了一个名为 sigline 的 struct,其类型为 struct sigrecord,并且有一个指向 struct sigrecord 的指针,名为 sigline_p。

struct sigrecord {
  int signum;
  char signame[20];
  char sigdesc[100];
} sigline, *sigline_p;

列表 2-6: A struct sigrecord

该结构体有三个成员对象:signum 是一个 int 类型的对象,signame 是一个包含 20 个元素的 char 类型数组,sigdesc 是一个包含 100 个元素的 char 类型数组。

一旦定义了一个结构体,你可能会想要引用其成员。你可以使用结构体成员(.)运算符来引用结构体类型对象的成员。如果你有一个结构体的指针,可以使用结构体指针(->)运算符来引用其成员。列表 2-7 展示了这两个运算符的使用。

sigline.signum = 5;
strcpy(sigline.signame, "SIGINT");
strcpy(sigline.sigdesc, "Interrupt from keyboard");

❶ sigline_p = &sigline;

sigline_p->signum = 5;
strcpy(sigline_p->signame, "SIGINT");
strcpy(sigline_p->sigdesc, "Interrupt from keyboard");

列表 2-7:引用结构体成员

列表 2-7 的前三行通过使用点号(.)运算符直接访问 sigline 对象的成员。我们将 sigline 对象的地址赋值给 sigline_p 指针 ❶。在程序的最后三行,我们通过 sigline_p 指针使用 -> 运算符间接访问 sigline 对象的成员。

联合体

联合类型类似于结构体,不同之处在于成员对象使用的内存是重叠的。联合体提供了多种不同的方式来查看相同的内存。

列表 2-8 显示了一个联合体,其中包含一个类型为 float 的单一成员 f,以及一个包含三个类型为 uint32_t 的位域的 struct:significand、exponent 和 sign。

static_assert(
  (__STDC_IEC_60559_BFP__ >= 202311L || __STDC_IEC_559__ == 1)
  && __STDC_ENDIAN_LITTLE__
);

union {
  float f;
  struct {
    uint32_t significand : 23;
    uint32_t exponent : 8;
    uint32_t sign : 1;
  };
} float_encoding;

列表 2-8:使用 union 分解一个 float

这使得(低级)C 程序员可以使用整个浮点值并检查(以及可能修改)其组成部分。这个联合体不可移植,因为不同的实现可能使用不同的浮点表示方式或字节序。static_assert 用于测试以确保该联合体与实现匹配。

示例 2-9 显示了一个包含成员 type 和联合体 u 的 struct n,其中联合体包含四个成员:inode、fnode、dnode 和 ldnode。

enum node_type {
  integer_type,
  float_type,
  double_float_type,
 long_double_type
};

struct node {
  enum node_type type;
  union {
    int inode;
    float fnode;
    double dnode;
    long double ldnode;
  } u;
} n;

n.type = double_type;
n.u.dnode = 3.14;

示例 2-9:通过联合体节省内存

这个结构体可能用于树、图或其他包含不同类型节点的数据结构。type 成员可能包含一个介于 0 到 3 之间的值,表示存储在结构体中的值的类型。由于它对所有节点都是通用的,它直接在 struct n 中声明。

与结构体一样,你可以通过 . 运算符访问联合体成员。使用指向联合体的指针时,你可以通过 -> 运算符访问其成员。在 示例 2-9 中,dnode 成员被引用为 n.u.dnode。使用该联合体的代码通常会通过检查 n.type 中存储的值来判断节点的类型,然后根据 n.type 中存储的值访问 n.u.inode、n.u.fnode、n.u.dnode 或 n.u.ldnode。如果没有联合体,每个节点将为所有四种数据类型分别分配存储空间。使用联合体可以让所有联合体成员共享同一存储空间。在 x86-64 GCC 版本 13.2 编译器上,使用联合体每个节点节省了 16 字节。

联合体通常用于描述网络或设备协议,特别是在你事先不知道将使用哪个协议的情况下。

标签

标签 是一种特殊的命名机制,用于结构体、联合体和枚举体。例如,以下结构体中的标识符 s 就是一个标签:

struct s {
  // `--snip--`
};

单独使用时,标签不是类型名称,不能用来声明变量(Saks 2002)。相反,你必须像以下示例一样声明该类型的变量:

struct s v;   // instance of struct s
struct s *p;  // pointer to struct s

联合体和枚举体的名称也是标签,而不是类型,这意味着它们不能单独用于声明变量。例如:

enum day {sun, mon, tue, wed, thu, fri, sat};
day today;  // error
enum day tomorrow;  // OK

结构体、联合体和枚举的标签在一个与普通标识符分开的命名空间中定义。这使得 C 程序可以在同一个作用域内同时拥有标签和其他拼写相同的标识符:

enum status {ok, fail};    // enumeration
enum status status(void);  // function

你甚至可以声明一个类型为struct s的对象s:

struct s s;

这可能不是一个好的实践,但它是有效的 C 语言。你可以将struct标签看作类型名称,并通过使用typedef为标签定义一个别名。以下是一个示例:

typedef struct s {int x;} t;

这现在允许你声明类型为t的变量,而不是struct s。struct、union和enum中的标签名称是可选的,因此你可以完全不使用它:

typedef struct {int x;} t;

这种方法在包含指向自身的指针的自引用结构体中不起作用:

struct tnode {
  int count;
  struct tnode *left;
  struct tnode *right;
};

C 语言要求使用标签类型(struct、union或enum)来包含标签名称。如果你在声明left和right指针时没有使用struct tnode,编译器会发出诊断信息。因此,你必须为结构体声明一个标签。

你可以使用typedef为结构体创建一个别名:

typedef struct tnode {
  int count;
  struct tnode *left;
  struct tnode *right;
} tnode;

left和right指针的声明仍然必须使用标签名,因为typedef名称直到struct声明完成后才会引入。你可以为标签和typedef使用相同的名称,但常见的惯用法是将标签命名为一些丑陋的名字,如tnode_,以鼓励程序员使用类型名称:

typedef struct tnode_ {
  int count;
  struct tnode_ *left;
  struct tnode_ *right;
} tnode;

你还可以在结构体之前定义此类型,以便使用它声明指向其他类型为tnode对象的left和right成员:

typedef struct tnode tnode;
struct tnode {
  int count;
  tnode *left;
  tnode *right;
};

类型定义不仅能改善结构体的可读性,还能在代码中其它地方提高可读性。例如,给定以下类型定义:

typedef void fv(int), (*pfv)(int);

这些对 signal 函数的声明都指定了相同的类型:

void (*signal(int, void (*)(int)))(int);
fv *signal(int, fv *);
pfv signal(int, pfv);

最后两种声明显然更容易阅读。

类型限定符

迄今为止,所有检查过的类型都是未限定类型。你可以通过使用以下一个或多个限定符来限定类型:const、volatile 和 restrict。每个限定符在访问限定类型的对象时会改变行为。

类型的限定版本和未限定版本可以互换使用,作为函数的参数、函数的返回值以及结构体和联合体的成员。

注意

_Atomic 类型限定符,自 C11 起可用,支持并发程序。

const

使用 const 限定符声明的对象(const 限定类型)不可赋值,但可以具有常量初始化值。这意味着编译器可以将具有 const 限定类型的对象放入只读内存中,任何尝试写入它们的操作都会导致运行时错误:

const int i = 1; // const-qualified int
i = 2; // error: i is const-qualified

你可能会不小心让编译器改变一个 const 限定对象。在下面的例子中,我们获取了一个 const 限定对象 i 的地址,并告诉编译器它实际上是一个指向 int 的指针:

const int i = 1;  // object of const-qualified type
int *ip = (int *)&i;
*ip = 2;  // undefined behavior

C 语言不允许你强制转换掉原本声明为 const 的对象。如果代码看起来能正常工作,它可能是有缺陷的,并且可能在后续运行时失败。例如,编译器可能会将 const 限定的对象放入只读内存中,在运行时尝试向该对象存储值时会导致内存错误。

C 语言允许你通过强制转换去除 const 限定符,来修改一个通过 const 限定指针引用的对象,只要原始对象未声明为 const:

int i = 12;
const int j = 12;
const int *ip = &i;
const int *jp = &j;
*(int *)ip = 42; // OK
*(int *)jp = 42; // undefined behavior

小心不要将一个 const 限定的指针传递给会修改该对象的函数。

volatile

对象被赋予 volatile 修饰符类型,以允许编译器外部的外在进程操作。这些对象中存储的值可能会在编译器不知道的情况下发生变化,或者写操作可能会进行外部同步。例如,每次读取实时钟表的值时,它可能会发生变化,即使该值没有被 C 程序写入。使用 volatile 修饰符类型让编译器知道该值可能在不知情的情况下发生变化,并确保每次访问实时钟表时都会发生。否则,对实时钟表的访问可能会被优化掉,或者被之前读取并缓存的值替代。

volatile 修饰符类型可用于访问内存映射寄存器,这些寄存器通过地址像其他内存一样被访问。输入/输出 (I/O) 设备通常有内存映射寄存器,你可以通过特定地址进行读写以设置或获取信息或数据。每次读写操作必须发生,即使编译器认为没有必要。将对象声明为 volatile 可确保该对象在运行时的每次读写操作发生次数与源代码中所指示的次数相同,并且顺序一致。例如,如果 port 被定义为 volatile 修饰的 int,编译器必须生成指令从 port 读取值,并将该值写回到 port

port = port;

如果没有 volatile 修饰符,编译器会将此视为无操作(一个什么也不做的编程语句),并可能会删除读取和写入操作。对 volatile 内存的读写操作会恰好执行一次。volatile 操作不能被消除或与后续操作合并,即使编译器认为它是无用的。volatile 操作不能被猜测,即使编译器能够撤销或以其他方式使该猜测无害。

带有 volatile 限定符的对象用于编译器无法感知外部交互的情况。例如,带有 volatile 限定符的类型可用于与不受信任代码共享的内存,以避免时检查与时使用(ToCToU)漏洞。这些类型用于从信号处理程序访问对象,并与 setjmp/longjmp 一起使用(有关信号处理程序和 setjmp/longjmp 的信息,请参阅 C 标准)。与 Java 和其他编程语言不同,volatile 限定符的类型不应在 C 中用于线程之间的同步。

内存映射 I/O 端口通过 static volatile 限定符的对象模型表示。内存映射输入端口,如实时钟,通过 static const volatile 限定符的对象表示。一个 const volatile 限定符的对象表示一个可能会被单独线程修改的变量。static 存储类说明符的含义将在本章后面解释。

restrict

一个带有 restrict 限定符的指针用于促进优化。通过指针间接访问的对象通常无法完全优化,因为可能会出现别名问题,当多个指针指向同一对象时,就会发生别名问题。例如,当另一个看似无关的对象被修改时,编译器无法确定对象是否会更改值,这种情况会抑制优化。

以下函数将从由 q 引用的存储区复制 n 字节到由 p 引用的存储区。函数参数 p 和 q 都是 restrict 限定符的指针:

void f(unsigned int n, int * restrict p, int * restrict q) {
  while (n-- > 0) {
    *p++ = *q++;
  }
}

由于 p 和 q 都是 restrict 限定符的指针,编译器可以假设通过其中一个指针参数访问的对象不会通过另一个指针参数访问。编译器可以仅通过参数声明来做出这个假设,而无需分析函数体。

尽管使用 restrict 限定指针可以提高代码的效率,但你必须确保指针不引用重叠的内存,以避免未定义的行为。

作用域

对象、函数、宏以及其他 C 语言标识符具有 作用域,作用域限定了它们可以访问的连续区域。C 语言有四种作用域:文件作用域、块作用域、函数原型作用域和函数作用域。

对象或函数标识符的作用域由其声明位置决定。如果声明位于任何块或参数列表之外,则该标识符具有 文件作用域,意味着它的作用域是它所在的整个文本文件以及任何包含的文件。

如果声明出现在块内部或参数列表内,则该标识符具有 块作用域,意味着该标识符仅能在块内部访问。来自 清单 2-4 中的 a 和 b 的标识符具有块作用域,并且只能在它们定义所在的 main 函数中的代码块内引用。

如果声明出现在函数原型的参数声明列表中(而不是函数定义的一部分),则该标识符具有 函数原型作用域,作用域在函数声明符的末尾结束。函数作用域 是函数定义的起始 { 和结束处之间的区域。标签名是唯一具有函数作用域的标识符。标签 是后跟冒号的标识符,用于标识同一函数中的一个语句,控制可以转移到该语句。(第五章 讲解了标签和控制转移。)

作用域也可以是 嵌套 的,具有 内层外层 作用域。例如,你可以在一个块作用域内部定义另一个块作用域,每个块作用域都定义在文件作用域内。内层作用域可以访问外层作用域,但反之则不行。顾名思义,任何内层作用域必须完全包含在它所包含的外层作用域内。

如果你在内层作用域和外层作用域中声明了相同的标识符,则外层作用域中声明的标识符会被内层作用域中声明的标识符 隐藏(也叫 遮蔽)。从内层作用域引用该标识符将引用内层作用域中的对象;外层作用域中的对象被隐藏,无法通过其名称引用。防止这个问题的最简单方法是使用不同的名称。清单 2-10 演示了不同的作用域以及内层作用域中声明的标识符如何隐藏外层作用域中声明的标识符。

int **j**;  // file scope of **j** begins

void f(int **i**) {         // block scope of **i** begins
  int **j** = 1;            // block scope of **j** begins; hides file-scope **j**
  i++;                  // **i** refers to the function parameter
  for (int **i** = 0; **i** < 2; i++) {  // block scope of loop-local **i** begins
    int **j** = 2;          // block scope of the inner **j** begins; hides outer **j**
    printf("%d\n", **j**);  // inner **j** is in scope, prints 2
  }                     // block scope of the inner **i** and **j** ends
  printf("%d\n", j);    // the outer **j** is in scope, prints 1
}  // the block scope of **i** and **j** ends

void g(int **j**);          // **j** has function prototype scope; hides file-scope **j**

清单 2-10:在内层作用域中声明的标识符隐藏外层作用域中声明的标识符

只要注释准确描述了你的意图,这段代码并没有问题。然而,最好为不同的标识符使用不同的名称,以避免混淆,进而导致 bug。像i和j这样简短的名称适用于小范围作用域的标识符。而大范围作用域的标识符应该具有较长的、描述性的名称,这样就不太可能在嵌套作用域中被隐藏。有些编译器会警告标识符被隐藏。

存储持续时间

对象具有一个存储持续时间,它决定了对象的生命周期。存储持续时间有四种类型:自动、静态、线程和动态分配。你已经看到,声明在块内或作为函数参数的对象具有自动存储持续时间。这些对象的生命周期从它们所在块的执行开始,到块的执行完成时结束。如果块是递归进入的,那么每次进入时都会创建一个新的对象,每个对象都有自己的存储空间。

注意

作用域和生命周期是完全不同的概念。作用域适用于标识符,而生命周期适用于对象。标识符的作用域是指可以通过标识符名称访问该对象的代码区域。对象的生命周期是指该对象存在的时间段。

在文件作用域内声明的对象具有静态存储持续时间。这些对象的生命周期是程序执行的整个过程,它们的存储值在程序启动之前就已经初始化。

线程存储持续时间用于并发编程,本书不涉及这一内容。动态分配存储持续时间涉及动态分配的内存,相关内容将在第六章中讨论。最后,如下节所述,存储类别说明符可以确定或影响存储持续时间。

存储类别

你可以使用存储类说明符来指定对象或函数的存储类。对于 C23,存储类说明符包括 autoconstexprexternregisterstaticthread_localtypedefconstexpr 存储类说明符是 C23 中的新特性,而 auto 存储类说明符发生了显著变化。

存储类说明符指定标识符和声明特性的各种属性:

  • 存储持续时间:块作用域中的 staticthread_localautoregister

  • 链接属性:文件作用域中的 externstaticconstexpr,以及 typedef

  • 值:constexpr

  • 类型:typedef

除少数例外情况外,每个声明只能使用一个存储类说明符。例如,auto 可以与其他所有说明符一起使用,除了 typedef

static

static 存储类说明符用于指定存储持续时间和链接属性。

作为 staticconstexpr 指定的文件作用域标识符,或指定为静态的函数,具有 internal 链接属性。

你还可以使用存储类说明符 static 来声明具有块作用域的变量,从而使其具有静态存储持续时间,正如在 Listing 2-11 中的计数示例所示。这些对象在函数退出后仍然存在。

#include <stdio.h>
#include <stdlib.h>

void increment(void) {
  static unsigned int counter = 0;
  counter++;
  printf("%d ", counter);
}

int main() {
  for (int i = 0; i < 5; i++) {
    increment();
  }
  return EXIT_SUCCESS;
}

Listing 2-11: 计数示例

该程序输出 1 2 3 4 5。静态变量 counter 在程序启动时初始化为 0,并且每次调用 increment 函数时递增。counter 的生命周期为程序的整个执行过程,它会在生命周期内保持其最后存储的值。通过使用文件作用域声明 counter 也可以实现相同的行为。但是,作为好的软件工程实践,尽可能限制对象的作用域是有益的。

extern

extern 说明符指定静态存储持续时间和外部链接。它可以与文件范围和块范围内的函数和对象声明一起使用(但不能用于函数参数列表)。如果 extern 被用于重新声明已经用内部链接声明的标识符,则链接仍为内部链接。否则(如果先前的声明是外部声明、没有链接,或不在作用域内),链接为外部链接。

thread_local

使用 thread_local 存储类说明符声明的对象具有 线程存储持续时间。它的初始化器在程序执行之前进行评估,其生命周期为创建该线程的整个执行过程,并且当线程启动时,它的存储值会用先前确定的值进行初始化。每个线程都有一个独立的对象,并且在表达式中使用声明的名称会引用与评估该表达式的线程关联的对象。(线程的相关内容超出了本书的范围。)

constexpr

使用 constexpr 存储类说明符声明的标量对象是一个常量,其值在翻译时固定。constexpr 存储类说明符可以与 auto、register 或 static 一起使用。如果原本没有,const 限定符会隐式地添加到对象的类型中。结果对象在运行时无法以任何方式修改。编译器可以在其他常量表达式中使用该值。

此外,用于常量初始化器的常量表达式将在编译时进行检查。在 C23 引入 constexpr 之前,可能会像下面这样声明一个非常大的对象常量:

static size_t const BFO = 0x100000000;

初始化器可能适合也可能不适合 size_t;不需要诊断。在 C23 中,使用 constexpr 可以像下面这样声明相同的对象:

constexpr size_t BFO = 0x100000000;

现在,对于 size_t 宽度为 32 或更小的实现,要求进行诊断。

静态对象必须使用常量值进行初始化,而不是变量:

int *func(int i) {
  const int j = i; // ok
  static int k = j; // error
  return &k;
}

算术常量表达式可以用于初始化器。常量值包括字面常量(例如,1,'a',或 0xFF)、enum 成员、使用 constexpr 存储类说明符声明的标量对象,以及操作符(如 alignof 或 sizeof)的结果(前提是操作数没有变长数组类型)。不幸的是,const 限定的对象不是常量值。从 C23 开始,实现可能会接受其他形式的常量表达式;是否是整数常量表达式由实现定义。

register

register 存储类说明符建议尽可能快速地访问对象。此类建议的有效性取决于实现。通常,编译器可以做出更好的寄存器分配决策,并忽略这些程序员的建议。register 存储类只能用于从未被取地址的对象。编译器可以将任何寄存器声明简单地视为 auto 声明。然而,无论是否使用可寻址存储,使用存储类说明符 register 声明的对象的任何部分的地址都不能被计算出来,无论是通过使用单目运算符 & 显式计算,还是通过将数组名称转换为指针隐式计算。

typedef

typedef 存储类说明符定义了一个标识符作为 typedef 名称,用来表示为标识符指定的类型。typedef 存储类说明符在“类型定义”框中已有讨论。

auto

在 C23 之前,auto 说明符仅允许用于在块作用域内声明的对象(函数参数列表除外)。它表示自动存储持续时间和无链接性,这些是此类声明的默认设置。

C23 通过扩展现有的 auto 存储类说明符的定义,将类型推断引入了 C 语言。在 C23 之前,声明一个变量时需要指定类型。然而,当声明中包含初始化器时,类型可以直接从用于初始化变量的表达式类型中推导出来。自 2011 年以来,这一特性已经是 C++ 的一部分。

auto 存储持续时间类说明符的行为类似于 C++,它允许从赋值的类型推断出类型。以下是文件作用域定义的示例:

static auto a = 3;
auto p = &a;

由于整数常量 3 隐式类型为 int,因此这些声明的解释方式就像它们被写成如下所示:

static int a = 3;
int * p = &a;

实际上,a 是一个 int 类型,而 p 是一个 int * 类型。在实现或调用类型通用宏时,类型推断非常有用,正如我们将在第九章中看到的那样。

typeof 运算符

C23 引入了 typeof 运算符 typeof 和 typeof_unqual。typeof 运算符可以作用于表达式或类型名称,并返回其操作数的类型。如果操作数的类型是一个可变修改的类型,则会评估操作数;否则,操作数不会被评估。

typeof 运算符和 auto 存储持续时间类说明符都执行自动类型推断。它们都可以用来确定表达式的类型。

auto 存储持续时间类说明符常用于声明初始化变量,在这种情况下,类型可以从初始值推断出来。然而,要形成衍生类型,你必须使用 typeof 操作符:

_Atomic(typeof(x)*) apx = &x;

auto 存储持续时间类说明符不能与 _Generic(在第九章中描述)和 typedef(本章稍后描述)一起使用。

typeof_unqual 操作符的结果是类型的非原子、未限定版本,该类型是通过 typeof 操作符得到的。typeof 操作符保留所有限定符。

typeof 操作符类似于 sizeof 操作符,它在未求值的上下文中执行表达式,以了解最终的类型。你可以在任何可以使用类型名称的地方使用 typeof 操作符。以下示例演示了两个 typeof 操作符的使用:

#include <stdlib.h>
const _Atomic int asi = 0;
const int si = 1;
const char* const beatles[] = {
    "John",
    "Paul",
    "George",
    "Ringo"
};

❶ typeof_unqual(si) main() {
  ❷ typeof_unqual(asi) plain_si;
  ❸ typeof(_Atomic ❹ typeof(si)) atomic_si;
  ❺ typeof(beatles) beatles_array;
  ❻ typeof_unqual(beatles) beatles2_array;
    return EXIT_SUCCESS;
}

在第一次使用 typeof_unqual 操作符 ❶ 时,操作数是 si,其类型是 const int。typeof_unqual 操作符会去掉 const 限定符,结果仅为普通的 int。这种使用 typeof_unqual 操作符的方式是示范性的,并不适用于生产代码。typeof_unqual 操作符再次作用于操作数 asi ❷,其类型是 const _Atomic int。所有限定符再次被去除,结果是普通的 int。在 ❸ 处,typeof 说明符的操作数包含另一个 typeof 说明符。如果 typeof 操作数本身就是一个 typeof 说明符,则会先评估该操作数,再评估当前的 typeof 操作符。这种评估是递归进行的,直到操作数不再是 typeof 说明符为止。在这种情况下,❸ 处的 typeof 说明符不做任何操作,可以省略。❹ 处的 typeof 操作符会在 ❸ 处的 typeof 操作符之前评估,返回 const int。然后,❸ 处的 typeof 操作符被评估,返回 const _Atomic int。❺ 处的 typeof 操作符返回一个 const 数组,包含四个 const char 指针。❻ 处的 typeof_unqual 操作符去掉限定符,返回一个包含四个 const char 指针的数组。在这种情况下,限定符仅从数组本身去除,而不去除数组元素类型中的限定符。

以下的 main 函数是等效的,但没有使用 typeof 操作符:

int main() {
  int plain_si;
  const _Atomic int atomic_si;
  const char* const beatles_array[4];
  const char* beatles2_array[4];
  return EXIT_SUCCESS;
}

你可以使用 typeof 操作符引用宏参数,以在不显式指定类型名称作为宏参数的情况下构造具有所需类型的对象。

对齐

对象类型有对齐要求,这些要求限制了该类型对象可以分配的地址。对齐 表示在连续地址之间分配给定对象的字节数。中央处理单元(CPU)在访问对齐数据(例如,当数据地址是数据大小的倍数时)与未对齐数据时,可能会有不同的行为。

一些机器指令可以在非字边界上执行多字节访问,但会带来性能惩罚。 是指令集或处理器硬件处理的自然、固定大小的数据单位。一些平台无法访问未对齐的内存。对齐要求可能取决于 CPU 的字大小(通常为 16、32 或 64 位)。

通常,C 程序员无需担心对齐要求,因为编译器会为各种类型选择合适的对齐方式。然而,在少数情况下,你可能需要覆盖编译器的默认选择——例如,为了将数据对齐到必须以二的幂地址边界开始的内存缓存行边界,或者满足其他系统特定的要求。传统上,这些要求通过链接器命令或类似的操作来满足,这些操作涉及其他非标准设施。

C11 引入了一种简单且向前兼容的机制,用于指定对齐方式。对齐方式表示为类型size_t的值。每个有效的对齐值都是非负整数的二的幂。一个对象类型对该类型的每个对象施加默认的对齐要求:可以使用对齐说明符(alignas)请求更严格的对齐(更大的二的幂)。你可以在声明中包含对齐说明符。列表 2-12 使用对齐说明符以确保 good_buff 被正确对齐(bad_buff 可能由于成员访问表达式导致对齐不正确)。

struct S {
  double d; int i; char c;
};

int main() {
  unsigned char bad_buff[sizeof(struct S)];
  alignas(struct S) unsigned char good_buff[sizeof(struct S)];
  struct S *bad_s_ptr = (struct S *)bad_buff;
  struct S *good_s_ptr = (struct S *)good_buff; // correct alignment
  good_s_ptr->i = 12;
  return good_s_ptr->i;
}

列表 2-12:使用 alignas 关键字

尽管good_buff具有适当的对齐方式,可以通过类型为struct S的左值访问,但该程序仍然存在未定义行为。这种未定义行为源于底层对象good_buff被声明为unsigned char类型的对象数组,并通过不同类型的左值进行访问。像任何指针类型转换一样,转换为(struct S *)并不会改变分配给每个数组的存储的有效类型。由于使用字符类型区域进行低级存储管理是一个既定的做法,我与他人合著了一篇论文,旨在使这样的代码在未来版本的 C 标准中符合规范(Seacord 等,2024 年)。

对齐方式从较弱到较强(也称为严格对齐)排列。严格对齐的对齐值更大。满足对齐要求的地址也会满足任何有效的、更弱的对齐要求。

动态分配内存的对齐方式在第六章中进行了详细讨论。

可变修改类型

可变修改类型(VMT)定义了一个基本类型和一个由运行时决定的范围(元素数量)。VMT 是 C23 的强制性特性。

VMT 可以作为函数参数使用。记住,在本章前面提到的,当在表达式中使用时,数组会被转换为指向数组第一个元素的指针。这意味着我们必须添加一个显式参数来指定数组的大小——例如,memset签名中的n参数:

void *memset(void *s, int c, size_t n);

当你调用这样的函数时,n应该准确地表示由s引用的数组的大小。如果这个大小超过数组的实际大小,将会导致未定义行为。

当声明一个函数以接受一个指定大小的数组作为参数时,我们必须在引用数组大小之前先声明数组的大小。例如,我们可以修改memset函数的签名,使其接受元素数量n和一个至少包含n个元素的数组:

void *memset_vmt(size_t n, char s[n], int c);

对于字符类型的数组,元素的数量等于大小。在这个函数签名中,s[n]是一个可变修改类型,因为s[n]依赖于n的运行时值。

我们已经改变了参数的顺序,使得在数组声明之前声明了大小参数n。数组参数s仍然会调整为指针,并且由于这个声明(除了指针本身)不会分配存储。当调用这个函数时,你必须为s所引用的数组声明实际的存储,并确保n是一个有效的大小。就像非 VMT 参数一样,实际的数组存储可能是一个固定大小的数组、可变长度的数组(在第六章中讨论)或动态分配的存储。

VMT 可以使你的函数更加通用,提高它们的实用性。例如,matrix_sum函数对二维数组中的所有值进行求和。以下版本的这个函数接受一个具有固定列数的矩阵:

int matrix_sum(size_t rows, int m[][4]);

当将一个多维数组传递给函数时,数组的初始维度(行数)的元素个数会丢失,必须作为参数传递。在这个例子中,rows参数提供了这一信息。你可以调用这个函数来求和任何列数恰好为四的矩阵,如示例 2-13 所示。

int main(void) {
  int m1[5][4];
  int m2[100][4];
  int m3[2][4];
  printf("%d.\n", matrix_sum(5, m1));
  printf("%d.\n", matrix_sum(100, m2));
  printf("%d.\n", matrix_sum(2, m3));
}

示例 2-13: 对具有四列的矩阵求和

这在你需要对一个列数不是四的矩阵求和时就会出问题。例如,如果将m3改为具有五列,可能会出现如下警告:

warning: incompatible pointer types passing 'int [2][5]' to parameter of type 'int (*)[4]'

要接受这个参数,你必须编写一个新的函数,其签名必须与多维数组的新维度相匹配。这个方法的问题在于,它无法做到足够的通用化。

我们可以将matrix_sum函数重写为使用 VMT,如示例 2-14 所示。这个修改使我们能够使用任何维度的矩阵来调用matrix_sum函数。

int matrix_sum(size_t rows, size_t cols, int m[rows][cols]) {
  int total = 0;

  for (size_t r = 0; r < rows; r++)
    for (size_t c = 0; c < cols; c++)
      total += ❶ m[r][c];
  return total;
}

示例 2-14: 使用 VMT 作为函数参数

编译器执行矩阵索引操作❶。如果没有 VMT,这将需要手动索引或双重间接引用,而这两者都容易出错。

同样,函数声明和函数定义都不会分配存储空间。与非 VMT 参数类似,你需要单独为矩阵分配存储空间,并且其维度必须与传递给函数的rows和cols参数相匹配。如果不这样做,可能会导致未定义的行为。

属性

从 C23 开始,你可以使用属性将附加信息与声明、语句或类型关联。这些信息可以被实现用来改善诊断、提升性能或以其他方式修改程序行为。属性的逗号分隔列表可以在一对双重方括号内指定,例如[[foo]]或[[foo, bar]]。

声明属性有两种指定方式。如果属性说明符位于声明的开头,则该属性将应用于声明组中的所有声明。否则,属性将应用于紧接着属性说明符左侧的声明。例如,在以下声明组中,foo属性应用于x、y和z:

[[foo]] int x, y, *z;

在第二个声明组中,foo和bar属性仅应用于b:

int a, b [[foo, bar]], *c;

C23 定义了多个适用于声明的属性,例如nodiscard和deprecated。nodiscard属性与函数声明一起使用,表示函数返回的值预期将在表达式或初始化器中使用。deprecated属性用于声明函数或类型,表示该函数或类型的使用应被诊断为不推荐使用。

除了标准属性外,编译器实现可能提供非可移植的属性。这些属性也在双重方括号内指定,但它们包括供应商前缀,用于区分不同供应商的属性。例如,[[clang::overloadable]] 属性用于函数声明,表示它可以在 C 语言中使用 C++ 风格的函数重载,[[gnu::packed]] 属性用于结构声明,表示结构成员声明应该尽量避免成员之间使用填充,以便获得更高效的空间布局。供应商通常使用自己的前缀,并且可以使用他们选择的任何前缀。例如,Clang 实现了许多带有 gnu 前缀的属性,以提高与 GCC 的兼容性。你的编译器应该忽略未知的属性,尽管它们仍然可能被诊断出来,帮助你了解该属性没有效果。有关支持的属性的完整列表,请参考你的编译器文档。

总结

在本章中,你了解了对象和函数,以及它们的区别。你学会了如何声明对象和函数,获取对象的地址,并解引用这些对象指针。你还了解了 C 程序员可用的大多数对象类型以及派生类型。

我们将在后续章节中返回这些类型,进一步探索如何最好地使用它们来实现你的设计。在下一章中,我将提供关于两种算术类型的详细信息:整数和浮点数。

第三章:3 算术类型

在本章中,你将了解两种算术类型:整数类型和浮动类型。C 语言中的大多数运算符都作用于算术类型。由于 C 是一种系统级语言,正确执行算术运算可能会很困难,导致频繁出现缺陷。这部分是因为在具有有限范围和精度的数字系统中,算术操作的结果往往与普通数学中的结果不同。正确地进行基本算术运算是成为专业 C 程序员的基础。

我们将深入探讨 C 语言中的算术运算,帮助你牢牢掌握这些基本概念。我们还将学习如何将一种算术类型转换为另一种类型,这是进行混合类型操作所必需的。

整数

正如在第二章中提到的,每种整数类型表示一有限范围的整数。带符号整数类型表示可以为负数、零或正数的值;无符号整数表示仅能为零或正数的值。每种整数类型能够表示的值的范围取决于具体的实现。

整数对象的是存储在对象中的普通数学值。整数对象值的表示是该值在对象分配存储空间中的具体编码。我们稍后将详细讨论该表示方法。

填充、宽度和精度

除了 char、signed char 和 unsigned char 外,所有整数类型可能包含未使用的位,这些位被称为填充,它们允许实现适应硬件的特殊情况(例如跳过多字表示中的符号位)或与目标架构进行最优对齐。表示给定类型值所使用的位数(不包括填充位,但包括符号位)称为宽度,通常表示为N精度是表示值所使用的位数,排除符号位和填充位。

整数范围

可表示的值是可以在特定类型的对象所能使用的位数内表示的值。无法表示的值将被编译器诊断为错误,或转换为一个可表示但不同(不正确)的值。<limits.h>头文件定义了类似对象的宏,这些宏展开为标准整数类型的各种限制和参数。为了编写可移植的代码,您应该使用这些宏,而不是像+2147483647(32 位整数所能表示的最大值)这样的整数字面量,因为这些字面量表示特定的限制,并且在移植到不同的实现时可能会发生变化。

C 标准仅对整数的大小施加了三项约束。首先,每个数据类型的存储空间占据若干个相邻的unsigned char对象(可能包括填充)。其次,每种整数类型必须支持最小的值范围,这使得您可以依赖跨任何实现的可移植值范围。第三,小类型的宽度不能大于大类型的宽度。因此,例如,short不能比int宽,但两者的宽度可以相同。

整数声明

除非声明为unsigned,否则整数类型默认为带符号类型(char除外,char的实现可以定义为带符号或无符号整数类型)。以下是无符号整数的有效声明:

unsigned int ui; // unsigned is required
unsigned u; // int can be omitted
unsigned long long ull2; // int can be omitted
unsigned char uc; // unsigned is required

在声明带符号整数类型时,您可以省略 signed 关键字——除了signed char,需要该关键字来区分signed char和普通的char。

在声明类型为short、long或long long的变量时,您也可以省略int。例如:

int i; // signed can be omitted
long long int sll; // signed can be omitted
long long sll2; // signed and int can be omitted
signed char sc; // signed is required

这些都是有效的带符号整数声明。

无符号整数

无符号整数的范围从 0 开始,它们的上限大于相应带符号整数类型的上限。无符号整数通常用于计数可能具有大且非负值的项目。

表示

无符号整数类型比有符号整数类型更容易理解和使用。它们使用纯二进制系统表示值,没有偏移:最低有效位的权重是 2⁰,次低有效位的权重是 2¹,以此类推。二进制数的值是所有设置位的权重之和。表 3-1 显示了使用未填充的 8 位表示法的无符号值的一些示例。

表 3-1: 8 位无符号值

十进制 二进制 十六进制
0 0b00000000 0x00
1 0b00000001 0x01
42 0b00101010 0x2A
255 0b11111111 0xFF

无符号整数类型没有符号位,因此它比对应的有符号整数类型能够提供更高的精度(多出 1 位)。无符号整数的值范围从 0 到一个最大值,这个最大值依赖于类型的宽度。该最大值为 2^N – 1,其中 N 是宽度。例如,大多数 x86 架构使用 32 位整数且没有填充位,因此一个类型为 unsigned int 的对象的值范围是 0 到 2³² – 1(4,294,967,295)。来自 <limits.h> 的常量表达式 UINT_MAX 指定了该类型的实现定义的最大值范围。表 3-2 显示了来自 <limits.h> 的每个无符号类型的常量表达式及标准要求的最小量级。

表 3-2: 无符号整数最小量级

常量表达式 最小量级 该类型对象的最大值
UCHAR_MAX 255 // 28 – 1 无符号字符型
USHRT_MAX 65,535 // 216 – 1 无符号短整型
UINT_MAX 65,535 // 216 – 1 无符号整型
ULONG_MAX 4,294,967,295 // 232 – 1 无符号长整型
ULLONG_MAX 18,446,744,073,709,551,615 // 264 – 1 无符号长长整型

你的编译器会将这些值替换为实现定义的数值。

回绕

C23 定义了回绕为“将一个值按 2^N 取模,其中 N 是结果类型的宽度。”当你执行的算术运算导致值太小(小于 0)或太大(大于 2^N – 1),无法表示为特定的无符号整数类型时,就会发生回绕。在这种情况下,值会按能够表示的最大值加一的数进行取模。回绕是 C 语言中明确定义的行为。它是否是代码中的缺陷取决于上下文。如果你在计数某个东西并且值发生回绕,很可能是个错误。然而,在某些算法中故意使用回绕是合理的。

列表 3-1 中的代码通过将无符号整数值 ui 初始化为其最大值并对其进行递增,演示了回绕。

unsigned int ui = UINT_MAX;  // 4,294,967,295 on x86
ui++;
printf("ui = %u\n", ui); // ui is 0
ui--;
printf("ui = %u\n", ui); // ui is 4,294,967,295

列表 3-1:无符号整数回绕

结果值不能表示为无符号整数,因此会回绕到 0。如果结果值被递减,它会再次超出范围并回绕回UINT_MAX。

由于回绕,无符号整数表达式永远不会小于 0。很容易忽略这一点,实施始终为真或始终为假的比较。例如,以下for循环中的i永远不会取负值,因此该循环将永远不会终止:

for (unsigned int i = n; i >= 0; --i)

这种行为导致了一些著名的实际世界中的错误。例如,波音 787 的所有六个发电系统都由相应的发电机控制单元管理。波音的实验室测试发现,发电机控制单元中的一个内部软件计数器在连续运行 248 天后会发生回绕(参见* <wbr>www<wbr>.federalregister<wbr>.gov<wbr>/documents<wbr>/2015<wbr>/05<wbr>/01<wbr>/2015<wbr>-10066<wbr>/airworthiness<wbr>-directives<wbr>-the<wbr>-boeing<wbr>-company<wbr>-airplanes *)。这个缺陷导致所有六个发动机上安装的发电机控制单元同时进入安全模式。

为了避免计划外的行为(例如让你的飞机从天空中掉下来),重要的是通过使用来自<limits.h>的限制来检查回绕。实现这些检查时要小心,因为很容易出错。例如,以下代码存在缺陷,因为sum + ui永远不会大于UINT_MAX:

extern unsigned int ui, sum;
// assign values to ui and sum
if (sum + ui > UINT_MAX)
  too_big();
else
  sum = sum + ui;

如果将sum和ui相加的结果大于UINT_MAX,则该结果将通过模运算减少到UINT_MAX + 1。因此,这个测试是没有意义的,生成的代码将无条件执行求和。高质量的编译器可能会发出警告指出这一点,但并非所有编译器都会这么做。为了解决这个问题,你可以将sum从不等式的两边减去,从而形成以下有效的测试:

extern unsigned int ui, sum;
// assign values to ui and sum
**if (ui > UINT_MAX - sum)**
  too_big();
else
  sum = sum + ui;

UINT_MAX宏是可以表示的最大unsigned int值,而sum是一个介于 0 和UINT_MAX之间的值。如果sum等于UINT_MAX,则减法结果为 0;如果sum等于 0,减法结果为UINT_MAX。因为这个操作的结果总是落在 0 到UINT_MAX的可允许范围内,所以它永远不会发生溢出。

当检查算术运算结果是否为 0 时,同样的问题会出现,这是最小的无符号值:

extern unsigned int i, j;
// assign values to i and j
if (**i - j < 0**)  // cannot happen
  negative();
else
  i = i - j;

由于无符号整数值永远不可能为负,因此减法操作将无条件执行。优质的编译器可能也会对此错误发出警告。与其进行这种无意义的测试,不如通过测试j是否大于i来检查是否发生溢出:

if (**j > i**)  // correct
  negative();
else
  i = i - j;

如果j > i,则差值会发生溢出,从而防止溢出的发生。通过从测试中去除减法操作,溢出的可能性也随之消除。

警告

请记住,溢出时使用的位宽取决于实现,这意味着你在不同平台上可能获得不同的结果。如果你没有考虑到这一点,你的代码将不具备可移植性。

有符号整数

每种无符号整数类型(排除bool)都有一个对应的有符号整数类型,且占用相同的存储空间。使用有符号整数表示负数、零和正数,这些值的范围取决于分配给类型的位数和表示方式。

表示

表示有符号整数类型比表示无符号整数类型更复杂。从历史上看,C 语言支持三种不同的负数表示方案:符号和大小表示、反码表示和补码表示。

从 C23 开始,仅支持二进制补码表示。在二进制补码表示中,符号位的权重为−(2^N ^(− 1)),其他值位的权重与无符号表示相同。本书其余部分假定使用二进制补码表示。

带符号的二进制补码整数类型,宽度为N,可以表示从 –2^N ^(– 1) 到 2^N ^(– 1) – 1 之间的任何整数值。这意味着,例如,8 位类型 signed char 的范围是 –128 到 127。与其他带符号整数表示方法相比,二进制补码可以表示一个额外的最小负值。8 位 signed char 的最小负值是 –128,而其绝对值 |–128| 无法表示为此类型。这导致了一些有趣的边界情况,我们将很快详细讨论。

表 3-3 显示了 <limits.h> 中每种带符号类型的常量表达式及标准要求的最小幅度。你的编译器将用实现定义的幅度替换这些值。

表 3-3: 带符号整数最小幅度

常量表达式 最小幅度 类型
SCHAR_MIN –128 // –27 signed char
SCHAR_MAX +127 // 27 – 1 signed char
SHRT_MIN –32,768 // –215 short int
SHRT_MAX +32,767 // 215 – 1 short int
INT_MIN –32,768 // –215 int
INT_MAX +32,767 // 215 – 1 int
LONG_MIN –2,147,483,648 // –231 long int
LONG_MAX +2,147,483,647 // 231 – 1 long int
LLONG_MIN –9,223,372,036,854,775,808 // –263 long long int
LLONG_MAX +9,223,372,036,854,775,807 // 263 – 1 long long int

要在二进制补码表示中取反一个值,只需切换每个非填充位,然后加 1(根据需要进位),如图 3-1 所示。

图 3-1:在二进制补码表示中取反一个 8 位值

表 3-4 显示了一个没有填充的 8 位二进制补码带符号整数类型的二进制和十进制表示(即,N = 8)。

表 3-4: 8 位二进制补码值

二进制 十进制 权重 常数
00000000 0 0
00000001 1 20
01111110 126 26 + 25 + 24 + 23 + 22 + 21
01111111 127 28 − 1 – 1 SCHAR_MAX
10000000 −128 −(28 − 1) + 0 SCHAR_MIN
10000001 −127 −(28 − 1) + 1
11111110 −2 −(28 − 1) + 126
11111111 −1 −(28 − 1) + 127

不一定需要知道数字的二进制表示,但作为一个 C 程序员,你可能会觉得这很有用。

整数溢出

整数溢出发生在有符号整数操作的结果值无法在结果类型中表示时。有符号整数溢出和无符号整数回绕常常被混淆。主要的区别是,有符号整数溢出是未定义行为,而无符号整数回绕是定义良好的行为。无符号整数不会发生溢出。

考虑以下类似函数的宏,它返回一个算术操作数的绝对值:

// undefined or wrong for the most-negative value
#define ABS(i) ((i) < 0 ? –(i) : (i))

我们将在第九章中详细讨论宏。现在,暂时将类似函数的宏视为对通用类型操作的函数。从表面上看,这个宏似乎通过返回 i 的非负值来正确地实现绝对值函数,而不考虑它的符号。我们使用条件运算符(? :)来测试 i 的值是否为负。如果是,i 被取反为 -(i);否则,返回未修改的值 (i)。

因为我们将 ABS 实现为类似函数的宏,它可以接受任何类型的参数。这个宏在传入有符号整数类型 int 或更大有符号整数类型时可能会发生溢出。当然,使用无符号整数调用这个宏是没有意义的,因为无符号整数永远不会是负数,因此宏的输出将直接复现该参数。让我们来探讨一下当传入有符号整数参数时 ABS 宏的行为:

signed int si = -25;
signed int abs_si = **ABS(si)**;
printf("%d\n", abs_si);  // prints 25

在这个例子中,我们传入一个类型为 signed int 且值为 -25 的对象作为参数给 ABS 宏。这个调用展开为:

signed int si = -25;
signed int abs_si = **((si) < 0 ? –(si) : (si))**;
printf("%d\n", abs_si);  // prints 25

宏正确地返回了 25 的绝对值。到目前为止,一切正常。问题在于,对于给定类型,二进制补码表示的最小负值的负值不能在该类型中表示,因此这种用法会导致有符号整数溢出。因此,这种实现方式的 ABS 宏是有缺陷的,可能会出现任何情况,包括意外返回负值:

signed int si = **INT_MIN**;
signed int abs_si = ABS(si);  // undefined behavior
printf("%d\n", abs_si);

ABS(INT_MIN) 应该返回什么以修正此行为?在 C 中,带符号整数溢出是未定义行为,允许实现悄无声息地环绕(最常见的行为)、陷阱或两者兼有(例如,某些操作环绕,而其他操作则触发陷阱)。陷阱会中断程序的执行,防止进一步的操作。像 x86 这样的常见架构会同时执行这两种行为。由于该行为未定义,因此没有普遍正确的解决方案,但我们至少可以在发生未定义行为之前测试其可能性并采取适当的措施。

为了使绝对值宏对各种类型都能有效,我们将向其添加一个类型相关的 标志 参数。该标志代表 *_MIN 宏,匹配第一个参数的类型。在以下有问题的情况下,这个值会被返回:

#define ABSM(i, flag) ((i) >= 0 ? (i) : ((i)==(flag) ? (flag) : -(i)))
signed int si = -25;  // try INT_MIN to trigger the undefined behavior
signed int abs_si = ABSM(si, INT_MIN);
if (abs_si == INT_MIN)
  overflow();  // handle special case
else
  printf("%d\n", abs_si);  // prints 25

ABSM 宏用于检测最小负值,并在发现时直接返回该值,而不是通过取反触发未定义行为。

在某些系统上,C 标准库实现了以下 仅适用于整数 的绝对值函数,以避免当函数传入 INT_MIN 作为参数时发生溢出:

int abs(int i) {
  return (i >= 0) ? i : -(unsigned)i;  // avoids overflow
}

在这种情况下,i 被转换为 无符号整数并取反。(我将在本章稍后讨论转换的更多细节。)

令人惊讶的是,一元减号(-)运算符对于无符号整数类型是有定义的。结果的无符号整数值会对大于结果类型能表示的最大值的数取模。最后,i 会根据 return 语句的要求隐式地转换回 带符号整数。由于 -INT_MIN 无法表示为 带符号整数,因此结果是实现定义的,这也是为什么该实现仅在某些系统上使用,即使在这些系统上,abs 函数也会返回错误的值。

ABSABSM 类似函数的宏会多次评估它们的参数,这可能会在参数改变程序状态时引发意外。这些被称为副作用(详见第四章)。另一方面,函数调用每次只评估一次每个参数。

无符号整数具有明确的环绕行为。应始终认为有符号整数溢出或可能发生溢出是一个缺陷。

位精确整数类型

如第二章所述,位精确的整数类型接受一个操作数来指定整数的宽度,因此 _BitInt(32) 是一个有符号 32 位整数,unsigned _BitInt(32) 是一个无符号 32 位整数。位精确的整数类型可以有任何宽度,最大为 BITINT_MAXWIDTH。位精确的整数类型在应用领域中非常有用,例如在加密对称密码(如高级加密标准(AES))中使用 256 位整数值,计算安全哈希算法(SHA)-256 哈希,表示 24 位色彩空间,或描述网络或串行协议的布局。

位精确整数类型在编程现场可编程门阵列(FPGAs)时也非常有用。FPGAs 是一种集成电路,通常现货出售,能够在制造过程后让客户重新配置硬件以满足特定的使用案例要求。在 FPGA 硬件的情况下,对于小值范围内未完全使用位宽的情况,使用普通的整数类型是极其浪费的,并会产生严重的性能和空间问题。在另一极端,FPGA 可以支持宽整数,基本上提供任意精度,并且现有的 FPGA 应用程序使用了大整数——例如,最多达到 2,031 位。在 C23 之前,程序员必须选择下一个更大尺寸的整数数据类型,并手动执行掩码和位移操作。然而,这种做法容易出错,因为整数宽度是由实现定义的。

一种位精确的有符号整数类型被指定为 _BitInt(*N*),其中 N 是一个整数常量表达式,指定类型的宽度。由于位精确的整数类型是包括符号位的,因此一个有符号的 _BitInt(1) 是无效的,因为它只有一个符号位而没有值位。无符号位精确的整数类型不包括符号位,因此指定一个 1 位整数的正确方式是 unsigned _BitInt(1)

_BitInt 类型遵循通常的 C 语言标准整数转换规则,详情请参见第 65 页的“整数转换规则”章节。通常的算术转换也同样适用,小范围的整数会被转换为大范围的整数。然而,_BitInt 类型不参与整数提升。

溢出发生在值超过给定数据类型允许的范围时。例如,(_BitInt(3))7 + (_BitInt(3))2 会发生溢出,结果是未定义的,和其他带符号整数类型一样。为了避免溢出,可以通过将其中一个操作数强制转换为 _BitInt(4),将操作类型扩展到 4 位。无符号 _BitInt 的回绕是明确定义的,值会以二进制补码语义进行回绕。

为了避免溢出,你可以将其中一个操作数强制转换为足够宽度的类型,以表示所有可能的值。例如,以下函数将其中一个操作数强制转换为 32 位:

_BitInt(32) multiply(_BitInt(8) a8, _BitInt(24) a24) {
  _BitInt(32) a32 = a8 * (_BitInt(32))a24;
  return a32;
}

这样可以保证结果的乘积能够正确表示。

整数常量

整数常量(或整数字面量)将整数值引入程序中。例如,你可以在声明中使用它们,将计数器初始化为 0。C 语言有四种整数常量,使用不同的进制:十进制常量、二进制常量、八进制常量和十六进制常量。

十进制常量总是以非零数字开头。例如,下面的代码使用了两个十进制常量:

unsigned int ui = 71;
int si;
si = -12;

在这个示例代码中,我们将 ui 初始化为十进制常量 71,并将 si 赋值为十进制常量值 -12。(严格来说,-12 是取反操作符 [-] 后跟一个整数常量 [12]。然而,表达式 -12 可以作为整数常量表达式使用,因此与值为–12 的整数常量本质上是不可区分的。)在代码中引入常规整数值时,使用十进制常量。

如果常量以 0 开头,并可选地跟随 0 到 7 之间的数字,它就是一个八进制常量。以下是一个示例:

int agent = 007;
int permissions = 0777;

在这个示例中,007八进制等于7十进制,八进制常量0777等于十进制值 511。八进制常量在处理像 POSIX 文件权限这样的 3 位字段时非常方便。

你还可以通过在一系列十进制数字和字母 a(或 A)到 f(或 F)前添加0x或0X来创建一个十六进制常量。例如:

int burger = 0xDEADBEEF;

当你引入的常量旨在表示比特模式而不是特定值时,使用十六进制常量——例如,在表示地址时。习惯上,大多数十六进制常量写作0xDEADBEEF,因为它类似于典型的十六进制转储。你最好将所有的十六进制常量都写成这样。

从 C23 开始,你还可以通过将一系列 1 和 0 十进制数字附加到0b后面来指定二进制常量。例如:

int mask = 0b110011;

二进制常量比八进制或十六进制常量更具可读性,特别是在该值作为位掩码使用时。

你还可以给常量添加后缀来指定其类型。如果没有后缀,十进制常量会被赋予类型,前提是它可以在该类型中表示。如果不能表示为类型,它将被表示为类型。后缀指定类型,而指定类型。你可以将这些后缀与结合使用,表示类型。例如,后缀指定类型。以下是一些示例:

unsigned int ui = 71U;
signed long int sli = 9223372036854775807L;
unsigned long long int ui = 18446744073709551615ULL;

这些后缀可以是大写或小写。通常建议使用大写字母以提高可读性,因为小写字母 l 可能会与数字 1 混淆。

精确位常量在 C23 中被添加,用来指定_BitInt字面量。后缀wb和uwb分别表示类型为_BitInt(N)和无符号的_BitInt(N)常量。宽度N是大于 1 的最小值,能够容纳值和符号位(如果存在)。

wb后缀会导致一个包含符号位的_BitInt,即使常数值为正或已使用二进制、八进制或十六进制表示:

-3wb 产生一个_BitInt(3),然后对其取反;两个值位,一个符号位

-0x3wb 产生一个_BitInt(3),然后对其取反;两个值位,一个符号位

3wb 产生一个_BitInt(3);两个值位,一个符号位

3ub 产生一个无符号的_BitInt(2)

-3uwb 产生一个无符号的_BitInt(2),然后对其取反,导致环绕现象

如果我们不使用后缀,且整数常量不是所需类型,它可能会被隐式转换。(我们将在“算术转换”部分讨论隐式转换,详见第 64 页。)这可能导致意外的转换或编译器诊断,因此最好指定一个合适类型的整数常量。C 标准第 6.4.4.1 节提供了更多关于整数常量的信息(ISO/IEC 2024)。

浮点数表示

浮动点表示法 是表示实数的最常见的数字表示方式。浮动点表示法是一种使用科学记数法来表示数值的技术,其中包含一个尾数和一个指数,基于给定的进制。例如,十进制数 123.456 可以表示为 1.23456 × 10²,而二进制数 0b10100.11 可以表示为 1.010011 × 2⁴。

C 标准定义了一个浮动点数的通用模型。然而,它并不要求所有实现都使用相同的表示方案或格式,并且允许实现提供不符合 C 模型的值。为了简化,我们假设符合附录 F。附录 F 包含 IEC 60559 标准中指定的最常见的浮动点格式。你可以通过测试 STDC_IEC_559 宏,或者在较新的编译器中测试 STDC_IEC_60559_BFP 宏,来确定实现是否符合附录 F。

本节解释了浮动类型、算术运算、值和常量,帮助你了解如何以及何时使用它们来模拟实数运算,以及何时避免使用它们。

浮动类型和编码

C 语言有三种标准的浮动类型:float、double 和 long double。

float 类型可以用于浮动点数据和结果,这些数据和结果可以通过该类型的精度和指数范围来充分表示。使用 float 算术运算从 float 数据计算 float 结果尤其容易产生舍入误差。常见的 IEC 60559 float 类型通过使用 1 位符号位、8 位指数位和 23 位尾数位来编码值。该值有一个 24 位的尾数,编码为 23 位(借助指数字段来确定隐式的领先位)。

double类型提供了更高的精度和指数范围,但需要额外的存储空间。使用double类型进行算术运算显著提高了从float数据中计算float结果的可靠性。IEC 60559 标准中的double类型使用 1 个符号位、11 个指数位和 52 个尾数位对值进行编码。该值的尾数有 53 位,其中 52 位由指数域帮助确定隐含的前导 1 位进行编码。

这些对于floatdouble的编码在图 3-2 中有所说明。

图 3-2:floatdouble类型

让我们通过一个例子来说明如何在float类型中进行编码:

1 1000 0001 011 0000 0000 0000 0000 0000

符号位是1,指数域是1000 0001,尾数域是011 0000 0000 0000 0000 0000。符号位编码了数字的符号,其中 0 表示正号,1 表示负号。因此,在此示例中表示的数字是负数。

由于指数域既不是全 0 也不是全 1,尾数域中的位被解释为二进制点右侧的位,其中隐含的 1 位位于二进制点的左侧。在此示例中,编码的数字的尾数为1.011 0000 0000 0000 0000 0000 = 1 + 2^(–2) + 2^(–3) = 1.375。

将这些内容结合起来得出以下实数:–2²(1 + 2^(–2) + 2^(–3)) = –5。

C 浮点模型

以下公式表示使用 C 模型的float类型的一个数字:

s 是符号位,可以是 1 或 -1。e 是指数,f[1] 到 f[24] 是有效数字位。注意,在 C 模型表示中,指数比我们从编码中得到的指数大 1,因为 C 模型将(显式)前导位放在二进制小数点的右侧,而编码则将(隐式)前导位放在二进制小数点的左侧。

以下公式表示 double 类型的一个数字:

通常,C 模型通过参数 bpe[min] 和 e[max] 来定义每种浮动类型中的浮动点数。参数 b 是基数(用于指数和有效数字的基数)。所有标准浮动类型的基数 b 由在 <float.h> 中定义的 FLT_RADIX 宏表示。在附录 F 中,FLT_RADIX 的值为 2。参数 p 是浮动点有效数字中基数 b 的位数。e[min] 参数是最小负整数,使得 b 的该指数减一后得到的值是标准化的浮动点数。最后,e[max] 参数是最大整数,使得 b 的该指数减一后得到的值是可表示的有限浮动点数,前提是该可表示的有限浮动点数是标准化的(对于所有 IEC 60559 类型,都会是标准化的)。表 3-5 显示了实际的宏名称。

表 3-5: 标准类型表征宏在 <float.h>

参数 float double long double
p FLT_MANT_DIG DBL_MANT_DIG LDBL_MANT_DIG
emin FLT_MIN_EXP DBL_MIN_EXP LDBL_MIN_EXP
emax FLT_MAX_EXP DBL_MAX_EXP LDBL_MAX_EXP

每个实现为 long double 类型分配以下格式之一:

  • IEC 60559 四倍精度(或 binary128)格式(IEC 60559 在 2011 年的修订中将 binary128 加入了其基本格式)

  • IEC 60559 binary64 扩展格式

  • 非 IEC 60559 扩展格式

  • IEC 60559 双精度(或 binary64)格式

编译器实现者的推荐做法是将 long double 类型与 IEC 60559 的 binary128 格式或 IEC 60559 的 binary64 扩展格式匹配。IEC 60559 的 binary64 扩展格式包括常见的 80 位 IEC 60559 格式。

使用 long double 类型进行算术运算应该考虑用于那些可靠性可能受益于实现所提供的标准浮动类型的最大范围和精度的计算。然而,long double 类型(与 double 相比)的额外范围和精度在不同实现之间差异很大,long double 算术运算的性能(速度)也有很大差异。因此,long double 类型不适合用于数据交换或可重现的结果(跨实现),也不适合便携式高性能计算。

较大的类型具有更高的精度,但需要更多的存储空间。任何可以表示为 float 的值也可以表示为 double,任何可以表示为 double 的值也可以表示为 long double。头文件 <float.h> 定义了几个宏,用于定义浮动类型的特性。

C23 附录 H 指定了 IEC 60559 中指定的具有算术交换和扩展浮动点格式的其他浮动类型。包括一系列具有无限精度和范围的类型以及一个 16 位类型。未来版本的 C 可能会包括其他浮动类型。

浮动点算术

浮点运算类似于并用于模拟实数的算术。然而,需要考虑一些差异。与实数算术不同,浮点数在大小上是有限的,并且具有有限精度。加法和乘法运算是结合的;分配律不成立,并且许多其他实数的性质也不成立。

浮点类型不能精确表示所有实数,即使它们能在少量小数位中表示。例如,常见的十进制常数如 0.1 不能精确表示为二进制浮点数。浮点类型可能缺乏足够的精度来满足各种应用需求,例如循环计数器或财务计算。有关更多信息,请参见 CERT C 规则 FLP30-C(不要将浮点变量用作循环计数器)。

浮点数值

有效位为 0(即所有 f[k] = 0)的浮点表示代表浮点零。零根据符号(s)进行签名,且有两个浮点零值:+0 和 –0。它们相等,但在一些操作中表现不同。一个显著的例子是 1.0/0.0 产生正无穷大,而 1.0/(-0.0) 产生负无穷大。

标准化浮点数的有效位中没有前导零(f[1] = 1);前导零通过调整指数来去除。这些是标准数,它们使用有效位的全部精度。因此,float具有 24 位有效精度,double具有 53 位有效精度,long double具有 113 位有效精度(假设使用 IEC 60559 binary128 格式)。

非标准数是非常小的正数和负数(但不是 0),它们的标准化表示会导致指数小于该类型的最小指数。它们的表示具有指数 e = e[min],并且前导有效位 f[1] = 0。 图 3-3 显示的是围绕 0 的非标准值范围的数轴。非标准数的精度低于标准化数。

图 3-3:非标准数的范围

浮点类型还可以表示不是浮点数的值,例如正负无穷大和非数(NaN)值。NaN 是不代表任何数值的特殊值。

将无限大作为特定值提供,使得操作可以继续进行,即使在溢出和除零的情况下,也能产生有用的结果,而无需特殊处理。将任何非零数除以(正或负)零会得到无限大。IEEE 浮点标准中对无限大值的操作是明确定义的。

一个安静的 NaN会在几乎所有算术操作中传播,而不会引发浮点异常,通常会在一系列操作后进行测试。具有信号 NaN操作数的算术操作通常会立即引发浮点异常。浮点异常是一个高级话题,在这里没有涉及。有关更多信息,请参阅 C 标准的附录 F。

在 C23 中,NAN和INFINITY宏位于<float.h>中,而nan函数位于<math.h>中,提供了 IEC 60559 安静 NaN 和无限大的标识。FLT_SNAN、DBL_SNAN 和 LDBL_SNAN 宏位于<float.h>中,提供了 IEC 60559 信号 NaN 的标识。C 附录 F 不要求完全支持信号 NaN。

你可以使用fpclassify类似宏来识别浮点值的类别,该宏将其参数值分类为 NaN、无限、正常、次正规或零:

#include <math.h>
int fpclassify(real-floating x);

在示例 3-2 中,我们在show_classification函数中使用fpclassify宏来确定一个类型为double的浮点值是正常值、次正规值、零、无限大还是 NaN。

const char *show_classification(double x) {
  switch(fpclassify(x)) {
    case FP_INFINITE:  return "Inf";
    case FP_NAN:       return "NaN";
    case FP_NORMAL:    return "normal";
    case FP_SUBNORMAL: return "subnormal";
    case FP_ZERO:      return "zero";
    default:           return "unknown";
  }

示例 3-2: The fpclassify 宏

函数参数x(在此示例中为double)被传递给fpclassify宏,该宏根据返回值进行切换。show_classification函数返回与存储在x中的值类别对应的字符串。

还有各种其他分类宏,包括isinf、isnan、isnormal、issubnormal、iszero等等,它们在许多应用中可能比fpclassify宏更有用。

浮动常量

浮动常量是表示实数的十进制或十六进制数字。你应该使用浮动点常量来表示不能改变的浮动点值。以下是一些浮动点常量的示例:

15.75
1.575E1   /* 15.75 */
1575e-2   /* 15.75 */
25E-4     /* 0.0025 */

以下展示了两种方式定义的常量:一种是十进制浮动常量,另一种是十六进制浮动常量。十六进制常量的值可以精确表示其(二进制)类型。十进制常量需要转换为二进制,并可能会受到舍入方向模式和求值方法的轻微影响。(舍入模式和求值方法本书中没有涉及。)如果你需要特定的值(到最后一位),则应使用十六进制常量。

DBL_EPSILON 2.2204460492503131E-16 // decimal constant
DBL_EPSILON 0X1P-52                // hex constant
DBL_MIN 2.2250738585072014E-308    // decimal constant
DBL_MIN 0X1P-1022                  // hex constant
DBL_MAX 1.7976931348623157E+308    // decimal constant
DBL_MAX 0X1.fffffffffffffP1023     // hex constant

所有浮动点常量都有一个类型。如果没有后缀,则类型为double;如果后缀为字母f或F,则类型为float;如果后缀为字母l或L,则类型为long double,如下所示:

10.0F  /* type float */
10.0   /* type double */
10.0L  /* type long double */

在这些示例中,小数点是必须的,但末尾的零不是。

算术转换

通常,一个类型的值(例如float)必须以不同的类型表示(例如int)。当你有一个float类型的对象,并且需要将其作为参数传递给接受int类型对象的函数时,就会发生这种情况。当需要进行此类转换时,你应该始终确保该值可以在新类型中充分表示。我将在《安全转换》章节中进一步讨论这个问题,见第 70 页。

值可以隐式或显式地从一种算术类型转换为另一种类型。你可以使用强制转换运算符进行显式转换。列表 3-3 展示了两个强制转换的例子。

int si = 5;
short ss = 8;
long sl = (long)si;
unsigned short us = (unsigned short)(ss + sl);

列表 3-3:类型转换操作符

要执行类型转换,在表达式前放置一个括号中的类型名称。这个类型转换将表达式转换为括号中类型名称的无限定版本。在这里,我们将si的值转换为类型long。因为si是类型int,这个类型转换是安全的,因为该值总是可以在具有相同符号位的较大整数类型中表示。

本例中的第二个类型转换将表达式(ss + sl)的结果转换为类型unsigned short。由于值被转换为一个精度较低的无符号类型(unsigned short),转换的结果可能与原值不相等。(一些编译器可能会对此发出警告,其他则不会。)在这个例子中,表达式(13)的结果可以在结果类型中正确表示。

隐式转换,也称为强制类型转换,在表达式中根据需要自动发生。值会在操作数类型混合时进行强制转换。例如,在列表 3-3 中,隐式转换用于将ss转换为sl的类型,以便可以在一个公共类型上执行加法操作ss + sl。有关哪些值会被隐式转换为哪些类型的规则有些复杂,涉及三个概念:整数转换等级、整数提升和常规算术转换。

整数转换等级

整数转换等级是一个整数类型的标准等级排序,用于确定计算时的公共类型。每种整数类型都有一个整数转换等级,用来决定何时以及如何隐式地执行转换。

C 标准,第 6.3.1.1 节,第 1 段(ISO/IEC 9899:2024),规定每种整数类型都有一个整数转换等级,以下内容适用:

  • 没有两个带符号的整数类型具有相同的等级,即使它们具有相同的表示方式。

  • 带符号整数类型的等级大于任何精度较低的带符号整数类型的等级。

  • long long int的等级大于long int的等级,而long int的等级大于int的等级,int的等级大于short int的等级,short int的等级大于signed char的等级。

  • 位精确的有符号整数类型的等级大于任何具有较小宽度的标准整数类型或具有较小宽度的位精确整数类型的等级。

  • 任何无符号整数类型的等级等于相应有符号整数类型的等级(如果有的话)。

  • 任何标准整数类型的等级都大于任何具有相同宽度的扩展整数类型或具有相同宽度的位精确整数类型的等级。

  • 相对于具有相同宽度的扩展整数类型,任何位精确整数类型的等级是由实现定义的。

  • char的等级等于signed charunsigned char的等级。

  • bool的等级小于所有其他标准整数类型的等级。

  • 任何枚举类型的等级等于兼容整数类型的等级。每个枚举类型都与char、有符号整数类型或无符号整数类型兼容。

相对于具有相同精度的另一个扩展有符号整数类型,任何扩展有符号整数类型的等级是由实现定义的,但仍然受到其他确定整数转换等级规则的约束。

整数提升

小类型是一个具有低于intunsigned int转换等级的整数。整数提升是将小类型的值转换为intunsigned int的过程。整数提升允许你在任何可以使用intunsigned int的表达式中使用小类型的表达式。例如,你可以在赋值的右侧或作为函数的参数使用一个较低等级的整数类型——通常是charshort

整数提升有两个主要目的。首先,它们鼓励在体系结构的自然大小(int)上执行操作,从而提高性能。其次,它们帮助避免由于中间值溢出而导致的算术错误,例如:

signed char cresult, c1, c2, c3;
c1 = 100; c2 = 3; c3 = 4;
cresult = c1 * c2 / c3;

在没有整数提升的情况下,c1 * c2 会导致在某些平台上 signed char 类型溢出,其中 signed char 被表示为 8 位补码值。这是因为 300 超出了该类型可以表示的值范围(–128 到 127)。然而,由于整数提升,c1、c2 和 c3 会隐式转换为 signed int 类型的对象,乘法和除法操作也会在这个大小上进行。这些操作在执行时不会发生溢出,因为结果值可以被这个更宽的类型表示。在这个特定的例子中,整个表达式的结果是 75,它在 signed char 类型的范围内,因此该值在存储到 cresult 时会被保留。

在第一个 C 标准之前,编译器使用两种方法之一进行整数提升:无符号保留方法或值保留方法。在无符号保留方法中,编译器将小的无符号类型提升为unsigned int。在值保留方法中,如果原类型的所有值都可以表示为int,则将原小类型的值转换为int。否则,它会被转换为unsigned int。在制定原始标准版本(C89)时,C 标准委员会决定采用值保留规则,因为它们比无符号保留方法更少出现不正确的结果。如果需要,你可以通过使用显式类型转换来覆盖这种行为,如 Listing 3-3 所示。

推广小型无符号类型的结果取决于整数类型的精度,这由实现决定。例如,x86-32 和 x86-64 架构具有 8 位的 char 类型、16 位的 short 类型和 32 位的 int 类型。对于面向这些架构的实现,unsigned char 和 unsigned short 类型的值都会被提升为 signed int,因为所有可以在这些较小类型中表示的值都可以作为 signed int 来表示。然而,16 位架构(如 Intel 8086/8088 和 IBM Series/1)具有 8 位的 char 类型、16 位的 short 类型和 16 位的 int 类型。对于面向这些架构的实现,unsigned char 类型的值会被提升为 signed int,而 unsigned short 类型的值会被提升为 unsigned int。这是因为所有可以表示为 8 位 unsigned char 类型的值都可以表示为 16 位的 signed int,但某些可以表示为 16 位的 unsigned short 的值无法表示为 16 位的 signed int。

_BitInt 类型不受整数提升的影响。整数提升可能会增加某些平台所需硬件的大小,因此 _BitInt 类型不受整数提升规则的约束。例如,在涉及 _BitInt(12) 和 unsigned _BitInt(3) 的二元表达式中,常规算术转换不会在确定通用类型之前将任何操作数提升为 int。因为一个类型是有符号的,另一个是无符号的,而且由于类型的位宽不同,有符号类型的等级高于无符号类型,因此无符号的 _BitInt(3) 会被转换为 _BitInt(12) 作为通用类型。

常规算术转换

常规算术转换是用于产生通用实数类型的规则,适用于算术运算的操作数和结果。忽略复数或虚数类型后,每个操作数都将转换为通用实数类型。许多接受整数操作数的运算符(包括 *、/、%、+、-、<、>、<=、>=、==、!=、&、^、| 和 ? 😃 会根据常规算术转换规则进行转换。常规算术转换应用于已提升的操作数。

常规算术转换首先检查平衡转换中的一个操作数是否为浮动类型。如果是,它将应用以下规则:

1.  如果任一操作数的类型是 long double,则另一个操作数会被转换为 long double。

2.  否则,如果任一操作数的类型是 double,则另一个操作数会被转换为 double。

3.  否则,如果任一操作数的类型是 float,则另一个操作数会被转换为 float 类型。

4.  否则,会对两个操作数执行整数提升。

如果一个操作数是 double 类型,另一个操作数是 int 类型,例如,类型为 int 的操作数会被转换为 double 类型的对象。如果一个操作数是 float 类型,另一个操作数是 double 类型,则类型为 float 的操作数会被转换为 double 类型的对象。特别需要注意的是 int 和 float 的情况,这时会将 int 操作数转换为 float 类型,尽管 int 的精度通常大于 float。

如果两个操作数都不是浮点类型,则应用以下常规算术转换规则来转换提升后的整数操作数:

1.  如果两个操作数类型相同,则不需要进一步的转换。

2.  否则,如果两个操作数都是有符号整数类型或都是无符号整数类型,则类型具有较小整数转换等级的操作数会被转换为具有较大等级的操作数的类型。例如,如果一个操作数是 int 类型,另一个操作数是 long 类型,则类型为 int 的操作数会被转换为 long 类型的对象。

3.  否则,如果具有无符号整数类型的操作数的等级大于或等于另一个操作数类型的等级,则具有有符号整数类型的操作数会被转换为无符号整数类型的操作数类型。例如,如果一个操作数是 signed int 类型,另一个操作数是 unsigned int 类型,则类型为 signed int 的操作数会被转换为 unsigned int 类型的对象。

4.  否则,如果带符号整数类型的操作数能够表示所有无符号整数类型操作数的值,那么无符号整数类型的操作数将被转换为带符号整数类型的操作数。例如,如果一个操作数的类型是unsigned int,另一个操作数的类型是signed long long,并且signed long long类型能够表示所有unsigned int类型的值,那么unsigned int类型的操作数将被转换为signed long long类型的对象。这对于具有 32 位int类型和 64 位long long类型的实现来说是这样的,例如 x86-32 和 x86-64。

5.  否则,两个操作数都被转换为与带符号整数类型的操作数对应的无符号整数类型。

_BitInt不受整数提升规则的影响,其中一个后果是二元运算符的_BitInt操作数并不总是像常规算术转换那样提升为int或unsigned int。相反,较低等级的操作数会被转换为较高等级的操作数类型,运算结果为较高等级的类型。例如,给定以下声明

_BitInt(2) a2 = 1;
_BitInt(3) a3 = 2;
_BitInt(33) a33 = 1;
signed char c = 3;

以下表达式中的a2操作数在乘法操作中被转换为_BitInt(3),并且结果类型为_BitInt(3):

a2 * a3;

在以下的乘法操作中,c被提升为int,a2被转换为int,并且结果类型为int:

a2 * c;

最后,在以下的乘法操作中,c被提升为int。然后,假设int的宽度不大于 32,它会被转换为_BitInt(33),并且结果类型为_BitInt(33):

a33 * c;

这些转换规则是随着新类型的添加而发展的,需要一些时间来适应。这些不规则的模式源自不同的架构特性(特别是 PDP-11 对 char 自动提升为 int)以及避免改变现有程序行为的需求,且在这些限制条件下追求一致性的目标。遇到疑问时,可以使用类型转换来明确强制你想要的转换。尽管如此,尽量不要过度使用显式转换,因为类型转换可能会禁用重要的诊断信息。

隐式转换的示例

以下示例说明了整数转换等级、整数提升和常规算术转换的使用。此代码将 signed char 类型的值 c 与 unsigned int 类型的值 ui 进行相等比较。我们假设这段代码是为 x86 架构编译的:

unsigned int ui = UINT_MAX;
signed char c = -1;
if (c == ui) {
  printf("%d equals %u\n", c, ui);
}

变量 c 是 signed char 类型。由于 signed char 的整数转换等级低于 int 或 unsigned int,因此在比较时,存储在 c 中的值会提升为 signed int 类型的对象。这是通过符号扩展原始值 0xFF 为 0xFFFFFFFF 来实现的。符号扩展 用于将有符号值转换为更大宽度的对象。符号位会被复制到扩展对象的每个比特位置。此操作在将较小的有符号整数类型转换为较大的有符号整数类型时,保留了符号和大小。

接下来,应用常规的算术转换。因为相等(==)运算符的操作数具有不同的符号性和相等的等级,所以符号整数类型的操作数将被转换为无符号整数类型的操作数类型。然后,比较作为 32 位无符号操作执行。因为 UINT_MAX 与 c 的提升和转换后的值相同,所以比较结果为 1,代码片段会输出以下内容:

-1 equals 4294967295

这个结果应该不再令人惊讶。

安全转换

隐式和显式转换(类型转换操作的结果)都可能产生无法在结果类型中表示的值。最好在同一类型的对象上执行操作,以避免转换。然而,当一个函数返回或接受不同类型的对象时,转换是不可避免的。在这些情况下,我们必须确保转换正确执行。

整数转换

整数转换发生在将整数类型的值转换为另一种整数类型时。转换到相同符号性的较大类型始终是安全的,无需检查。其他大多数转换可能会产生意外结果,如果结果值无法在目标类型中表示。为了正确执行这些转换,必须测试原始整数类型中存储的值是否在目标整数类型能表示的范围内。例如,列表 3-4 中显示的do_stuff函数接受一个signed long参数value,该参数需要在仅适用signed char的上下文中使用。

#include <errno.h>
#include <limits.h>

errno_t do_stuff(signed long value) {
  if (**(value < SCHAR_MIN) || (value > SCHAR_MAX)**) {
    return ERANGE;
  }
 signed char sc = (signed char)value; // cast quiets warning
  // `--snip--`
}

列表 3-4:安全转换

为了安全地执行此转换,函数会检查value是否能表示为在[SCHAR_MIN, SCHAR_MAX]范围内的signed char,如果不能,则返回错误。

具体的范围测试根据转换的不同而有所不同。有关更多信息,请参阅 CERT C 规则 INT31-C(“确保整数转换不会导致数据丢失或误解释”)。

整数类型到浮动类型的转换

符合附录 F 的浮动类型支持正负无穷大,因此所有整数值都在范围内。适用常规的 IEC 60559 转换规则。有关更多信息,请参阅 CERT C 规则 FLP36-C(“在将整数值转换为浮动类型时保持精度”)。

浮动类型到整数类型的转换

当浮动类型的有限值被转换为整数类型(除了bool)时,小数部分会被丢弃。如果整数部分的值无法被整数类型表示,附录 F 规定会引发“无效”浮动点异常,结果是未指定的。

浮动类型降级

将浮动值转换为更大浮动类型总是安全的。将浮动值降级(即转换为更小的浮动类型)就像将整数值转换为浮动类型一样。符合附录 F 的浮动类型支持正负无穷大。对于这些实现,降级浮动类型的值总是会成功,因为任何超出范围的值都会被转换为无穷大。有关浮动点转换的更多信息,请参见 CERT C 规则 FLP34-C(“确保浮动点转换在新类型的范围内”)。

在本章中,你学习了整数和浮动类型。你还学习了隐式和显式转换、整数转换等级、整数提升以及常见的算术转换。

这些基本类型的使用,尤其是整数类型,在 C 编程中是不可避免且普遍存在的。即便是“Hello, world!”程序也会返回一个int并打印一个字符串——一个类型为char的数组——这当然是一个整数类型。因为整数类型的使用如此频繁,你不能每次使用它们时都重新阅读这一章。你必须理解它们的行为,以便能够有效地编程。

在下一章中,你将学习运算符以及如何编写简单的表达式来对这些算术类型以及其他对象类型执行操作。

第四章:4 表达式与运算符

在本章中,你将学习运算符以及如何编写简单的表达式来对各种对象类型进行操作。运算符是用于执行操作的关键字或一个或多个标点符号。当运算符应用于一个或多个操作数时,它会成为一个计算值的表达式,并且可能具有副作用。表达式是由运算符和操作数组成的序列,用来计算一个值或完成其他目的。操作数可以是标识符、常量、字符串字面量或其他表达式。

在本章中,我们在深入探讨表达式的机制(包括运算符与操作数、值计算、副作用、优先级以及求值顺序)之前,先讨论简单赋值。接着,我们会讨论具体的运算符,包括 sizeof、算术运算符、按位运算符、强制类型转换运算符、条件运算符、对齐运算符、关系运算符、复合赋值运算符以及逗号运算符。我们在前几章中已经介绍了这些运算符和表达式;在这一章,我们详细讲解它们的行为及最佳使用方式。最后,我们将以指针运算的讨论结束本章内容。

简单赋值

简单赋值将左操作数指定的对象中的值替换为右操作数的值。右操作数的值会被转换为赋值表达式的类型。简单赋值有三个组成部分:左操作数、赋值(=)运算符和右操作数,如下例所示:

int i = 21; // declaration with initializer
int j = 7;  // declaration with initializer
i = j;      // simple assignment

前两行是声明,它们将变量 i 初始化为 21,将 j 初始化为 7。初始化不同于简单赋值,尽管它们具有相似的语法。初始化器是声明中的可选部分;如果存在,它会为对象提供初始值。如果没有初始化器,具有自动存储周期的对象将未初始化。

第三行包含一个简单赋值。为了使代码能够编译,你必须定义或声明所有在表达式中出现的标识符(例如在简单赋值中)。

在简单赋值中,左操作数总是一个表达式(其对象类型不为 void),称为 左值。左值中的 l 原本来源于它是 操作数,但更准确地说,可以理解为 定位值(locator value),因为它必须指向一个对象。右操作数也是一个表达式,但它可以仅仅是一个值,并不需要指向一个对象。我们将这个值称为 右值 操作数)或 表达式值。在这个例子中,两个对象的标识符 i 和 j 都是左值。左值也可以是一个表达式,例如 *(p + 4),只要它引用了内存中的一个对象。

在简单的赋值中,右值(rvalue)会被转换为左值(lvalue)的类型,然后存储到由左值指定的对象中。在赋值 i = j 中,值从 j 中读取并写入到 i 中。由于 i 和 j 是相同类型(int),因此不需要转换。赋值表达式的结果值是赋值的结果,类型为左值的类型。

右值不需要指向一个对象,如以下语句所示,它使用了前面示例中的类型和值:

j = i + 12; // j now has the value 19

表达式 i + 12 不是左值,因为没有底层对象存储结果。相反,单独的 i 是一个左值,会自动转换为右值,作为加法操作的操作数。加法操作的结果(没有与之关联的内存位置)也是一个右值。C 语言规定了左值和右值出现的位置。以下语句展示了左值和右值的正确与错误使用:

int i;
i = 5;     // i is an lvalue, 5 is an rvalue
int j = i; // lvalues can appear on the right side of an assignment
7 = i;     // error: rvalues can't appear on the left side of an assignment

赋值 7 = i 无法编译,因为右值必须始终出现在操作符的右侧。

在以下示例中,右操作数的类型与赋值表达式不同,因此i的值首先被转换为signed char类型。括号内的表达式值随后被转换为long int类型,并赋值给k:

signed char c;
int i = INT_MAX;
long k;
k = (c = i);

赋值必须处理现实世界的约束。具体来说,简单的赋值如果将值转换为较窄的类型,可能会导致截断。正如在第三章中提到的,每个对象都需要固定数量的字节存储。i的值始终可以由k(相同符号的较大类型)表示。然而,在这个例子中,i的值被转换为signed char(赋值表达式c = i的类型)。括号内的表达式值接着被转换为外层赋值表达式的类型——即long int类型。如果你的实现中signed char类型的宽度不足以完全表示存储在i中的值,则大于SCHAR_MAX的值会被截断,而存储在k中的值(−1)也会被截断。为了防止值被截断,确保选择足够宽的类型来表示可能出现的任何值。

评估

现在我们已经看过了简单赋值,让我们暂时回顾一下表达式是如何评估的。评估主要是指将表达式简化为单一的值。表达式的评估可以包括值计算和副作用的引发。

值计算是指通过表达式的评估计算出的结果值。计算最终值可能包括确定对象的身份或读取先前赋值给对象的值。例如,以下表达式包含多个值计算,用于确定i、a和a[i]的身份:

a[i] + f() + 9

因为 f 是一个函数而不是一个对象,所以表达式 f() 不涉及确定 f 的身份。操作数的值计算必须在操作符结果的值计算之前发生。在这个示例中,单独的值计算读取了 a[i] 的值,并确定了调用 f 函数返回的值。然后,第三次计算将这些值相加,得到整体表达式返回的值。如果 a[i] 是一个 int 类型的数组,而 f() 返回一个 int 类型的值,则该表达式的结果将是 int 类型。

副作用 是对执行环境状态的改变。副作用包括写入一个对象、访问(读取或写入)一个 volatile 修饰的对象、输入/输出(I/O)、赋值或调用任何执行这些操作的函数。我们可以稍微修改前面的示例,添加一个赋值。更新 j 的存储值是赋值的副作用:

int j;
j = a[i] + f() + 9;

对 j 的赋值是一个副作用,它改变了执行环境的状态。根据 f 函数的定义,调用 f 也可能会有副作用。

函数调用

函数设计符 是具有函数类型的表达式,用于调用函数。在以下函数调用中,max 是函数设计符:

int x = 11;
int y = 21;
int max_of_x_and_y = max(x, y);

max 函数返回两个参数中较大的一个。在表达式中,函数设计符在编译时会被转换为 返回类型的函数指针。每个参数的值必须是可以赋值给与其对应的参数类型(无限定符版本)相匹配的对象的类型。每个参数的数量和类型必须与函数接受的参数数量和类型一致。这里指的是两个整数参数。C 语言还支持 变参函数,例如 printf,它可以接受可变数量的参数。

我们还可以将一个函数传递给另一个函数,如 示例 4-1 所示。

int f() {
  // `--snip--`
 return 0;
}
void g(int (*func)()) {
  // `--snip--`
  if (func() != 0)
    printf("g failed\n");
  // `--snip--`
}
// `--snip--`
g(f); // call g with function-pointer argument
// `--snip--`

示例 4-1:将一个函数传递给另一个函数

这段代码将由f指定的函数的地址传递给另一个函数g。函数g接受一个指向无参数并返回int类型的函数的指针。作为参数传递的函数会隐式转换为函数指针。函数g的定义使这一点变得显式;等价的声明为void g(int func(void))。

增量和减量运算符

增量(++)和减量(--)运算符分别对可修改的左值进行增减操作。它们都是一元运算符,因为它们只接受一个操作数。

这些运算符可以作为前缀运算符使用,出现在操作数之前,也可以作为后缀运算符使用,出现在操作数之后。前缀和后缀运算符的行为不同,这意味着它们通常作为测验和面试中的难题。前缀增量运算符在返回值之前执行增量操作,而后缀增量运算符则在返回值之后执行增量操作。示例 4-2 通过执行前缀或后缀增量或减量操作,并将结果赋值给e,展示了这些行为。

int i = 5;
int e;    // expression result
e = i++;  // postfix increment: e ← 5, i ← 6
e = i--;  // postfix decrement: e ← 6, i ← 5
e = ++i;  // prefix increment: e ← 6, i ← 6
e = --i;  // prefix decrement: e ← 5, i ← 5

示例 4-2:前缀和后缀增量和减量运算符

本例中的i++操作返回未更改的值5,然后将其赋值给e。然后,i的值作为该操作的副作用被增量化。

前缀递增运算符增加操作数的值,并且该表达式返回递增后的操作数的新值。因此,表达式++i等同于i = i + 1,唯一的区别是i只会被评估一次。此示例中的++i操作返回递增后的值6,然后将其赋值给e。 ## 运算符优先级与结合性

在数学和计算机编程中,运算顺序(或运算符优先级)是一组规则,规定了在求值表达式时,操作执行的顺序。例如,乘法的优先级高于加法。因此,表达式 2 + 3 × 4 的值为 2 + (3 × 4) = 14,而不是(2 + 3) × 4 = 20。

结合性决定了在没有使用括号时,相同优先级的运算符如何进行分组。C 的结合性与数学中的结合性不同。例如,虽然浮点加法和乘法都是交换律成立的(a + b = b + aa × b = b × a),但它们不一定是结合律成立的。如果相邻的运算符具有相同的优先级,决定首先执行哪个操作的依据是结合性。左结合的运算符会使操作从左向右分组,而右结合的运算符则会使操作从右向左分组。你可以把分组理解为隐式地引入了括号。例如,加法(+)运算符是左结合的,因此表达式 a + b + c 会被解释为 ((a + b) + c)。赋值运算符是右结合的,因此表达式 a = b = c 会被解释为 (a = (b = c))。

表 4-1,来源于 C++ References 网站上的 C 运算符优先级表 (<wbr>en<wbr>.cppreference<wbr>.com<wbr>/w<wbr>/c<wbr>/language<wbr>/operator<wbr>_precedence),列出了 C 运算符的优先级和结合性,按照语言语法进行规定。运算符按照优先级递减的顺序列出(也就是说,越靠上的行优先级越高)。

表 4-1: 运算符优先级和结合性

优先级 运算符 描述 结合性
0 (...) 强制分组 左侧
1 ++ -- 后缀递增与递减 左侧
() 函数调用
[] 数组下标
. 结构体与联合体成员访问
-> 通过指针访问结构体与联合体成员
(type) 复合字面量
2 ++ -- 前缀递增与递减 右侧
+ - 一元加与减
! ~ 逻辑非与按位非
(type) 类型转换
* 间接寻址(解引用)
& 取地址
sizeof 大小
_Alignof 对齐要求
3 * / % 乘法、除法和余数
4 + - 加法和减法
5 << >> 按位左移和右移
6 < <= 关系运算符 < 和 ≤
> >= 关系运算符 > 和 ≥
7 == != 等于和不等于
8 & 按位与
9 ^ 按位异或(独占或)
10 | 按位或(包括或)
11 && 逻辑与
12 || 逻辑或
13 ? : 条件运算符
14 = 简单赋值
+= -= 加减赋值
*= /= %= 按乘积、商和余数赋值
<<= >>= 按位左移和右移赋值
&= ^= |= 按位与、异或和或赋值
15 , 表达式顺序

有时,运算符的优先级是直观的,但有时也可能会误导。例如,后缀运算符 ++ 和 -- 的优先级高于前缀运算符 ++ 和 --,而前缀运算符和一元运算符 * 的优先级相同。例如,如果 p 是一个指针,那么 p++ 等同于 (p++),而 ++p 等同于 ++(p),因为前缀运算符 ++ 和一元运算符 * 都是右结合的。如果两个运算符具有相同的优先级和结合性,它们将按从左到右的顺序进行求值。示例 4-3 展示了这些运算符之间的优先级规则。

char cba[] = "cba";
char *p = cba;
printf("%c", ++*p);

char xyz[] = "xyz";
char *q = xyz;
printf("%c", *q++);

示例 4-3: 运算符优先级规则

表达式 ++*p 中的指针首先被解引用,产生 c 字符。然后该值被递增,结果为字符 d。在这种情况下,前缀 ++ 运算符作用于指针 p 所指向的 char 类型的对象,而不是指针本身。另一方面,表达式 *q++ 中的指针先被递增,因此它指向 y 字符。然而,后缀递增运算符的结果是操作数的值,因此原始的指针值被解引用,产生了 x 字符。因此,这段代码打印出字符 dx。你可以使用括号来改变或明确操作顺序。

求值顺序

任何 C 运算符的操作数的求值顺序,包括任何子表达式的求值顺序,通常是未指定的。编译器会以任意顺序对它们进行求值,并且在相同表达式再次求值时,可能会选择不同的顺序。这种灵活性允许编译器通过选择最有效的顺序来生成更快的代码。求值顺序受运算符优先级和结合性的约束。

列表 4-4 演示了函数参数的求值顺序。

int glob;  // static storage initialized to 0

int f(void) {
  return glob + 10;
}
int g(void) {
  glob = 42;
  return glob;
}
int main(void) {
  int max_value = max(f(), g());
  // `--snip--`
}

列表 4-4:函数参数的求值顺序

函数 f 和 g 都访问全局变量 glob,意味着它们依赖于共享状态。由于函数 f 和 g 的评估顺序未指定,因此传递给 max 的参数在不同编译之间可能会有所不同。如果 f 首先被调用,它会返回 10,但如果最后调用,它会返回 52。函数 g 始终返回 42,无论评估顺序如何。因此,max 函数(返回两个值中的较大者)可能会根据评估参数的顺序返回 42 或 52。此代码提供的唯一顺序保证是,f 和 g 都会在 max 之前被调用,并且 f 和 g 的执行不会交叉进行。

我们可以将这段代码重写如下,以确保它总是以可预测、可移植的方式执行:

int f_val = f();
int g_val = g();
int max_value = max(f_val, g_val);

在这个修改后的程序中,f 被调用来初始化 f_val 变量。保证在执行 g 之前,f 会先被执行,g 会在后续声明中被调用来初始化 g_val 变量。如果一个评估是先于另一个评估执行的,第一个评估必须在第二个评估开始之前完成。你可以使用顺序点(在接下来的小节中讨论)来保证一个对象在被读取之前会被写入。f 的执行被保证在 g 的执行之前,因为在一个完整表达式的评估和下一个完整表达式的评估之间存在顺序点。

无序和不确定顺序的评估

无序评估的执行可以交错,意味着指令可以按任何顺序执行,只要按照程序指定的顺序执行读取和写入。一个按照程序指定顺序执行读取和写入的程序是顺序一致的(Lamport 1979)。

一些评估是不确定顺序的,这意味着它们不能交织在一起,但仍然可以以任何顺序执行。例如,以下语句包含几个值计算和副作用:

printf("%d\n", ++i + ++j * --k);

i、j和k的值必须在它们的值被递增或递减之前读取。这意味着,读取i必须在递增副作用之前按顺序进行。例如,乘法操作数的所有副作用必须在乘法发生之前完成。由于运算符优先级规则,乘法必须在加法之前完成。最后,加法操作数的所有副作用必须在加法发生之前完成。这些约束在这些操作之间产生了部分排序,因为它们并不要求在递增j之前必须递减k。这个表达式中的无顺序求值可以按任意顺序执行,这使得编译器可以重新排序操作并将值缓存到寄存器中,从而提高整体执行速度。另一方面,函数执行的顺序是不可确定的,它们不会交织在一起。

顺序点

顺序点是所有副作用完成的交点。这些由语言隐式定义,但你可以通过编码方式控制它们发生的时机。

顺序点在 C 标准的附录 C 中进行了枚举。顺序点发生在一个完整表达式(不是另一个表达式或声明符的一部分)和下一个待评估的完整表达式之间。顺序点还发生在进入或退出被调用函数时。

如果某个副作用相对于同一标量对象的另一个副作用或使用该标量对象值的计算没有顺序关系,则代码会有未定义行为。标量类型可以是算术类型或指针类型。表达式i++ * i++对进行两个无顺序操作,如以下代码片段所示:

int i = 5;
printf("Result = %d\n", i++ * i++);

你可能认为这段代码会产生值 30,但由于它具有未定义行为,结果并不保证。

保守地说,我们可以通过将每个副作用操作放入其自己的完整表达式中,确保副作用在读取值之前已完成。我们可以将代码重写如下,以消除未定义行为:

int i = 5;
int j = i++;
int k = i++;
printf("Result = %d\n", j * k);

这个示例现在包含了每个副作用操作之间的顺序点。然而,我们无法判断这段重写的代码是否代表了程序员的原始意图,因为原始代码没有定义的意义。如果你选择省略顺序点,必须确保你完全理解副作用的顺序。我们也可以按如下方式编写相同的代码,而不改变其行为:

int i = 5;
int j = i++;
printf("Result = %d\n", j * i++);

现在我们已经描述了表达式的机制,我们将回到讨论具体的运算符。

sizeof 运算符

我们可以使用 sizeof 运算符来查找其操作数的字节大小;具体来说,它返回一个无符号整数,类型为 size_t,表示该大小。了解操作数的正确大小对于大多数内存操作是必要的,包括分配和复制存储。size_t 类型在 <stddef.h> 中定义,也在其他头文件中定义。我们需要包含这些头文件之一,才能编译引用 size_t 类型的代码。

我们可以将 sizeof 运算符传递一个未求值的完整对象类型表达式,或者是该类型的括号化名称:

int i;
size_t i_size = sizeof i;      // the size of the object i
size_t int_size = sizeof(int); // the size of the type int

对 sizeof 的操作数加上括号始终是安全的,因为括号表达式不会改变操作数大小的计算方式。调用 sizeof 运算符的结果是常量表达式,除非操作数是一个可变长度数组。sizeof 运算符的操作数不会被求值。

如果你需要确定可用存储的位数,可以将对象的大小乘以 CHAR_BIT,它表示一个字节中包含的位数。例如,表达式 CHAR_BIT * sizeof(int) 将产生一个 int 类型对象的位数。

算术运算符

对于在算术类型上执行算术操作的运算符,以下章节进行了详细介绍。我们还可以将这些运算符中的一些与非算术操作数一起使用。

一元 + 和 –

一元 + 和 – 运算符 作用于单一的算术类型操作数。– 操作符返回其操作数的负值(即,它的行为就像操作数被乘以 -1)。一元 + 操作符仅返回值本身。这些运算符主要存在于表达正数和负数。

如果操作数是一个小整数类型,它会被提升(见 第三章),并且操作的结果会是提升后的类型的结果。顺便提一句,C 语言没有负整数字面量。像 –25 这样的值实际上是一个类型为 int 的右值,其值为 25,前面带有一元操作符 –。然而,表达式 -25 保证是一个常量整数表达式。

逻辑非

一元逻辑非操作符 (!) 的结果如下:

  • 0 如果其操作数的求值结果不是 0

  • 1 如果其操作数的求值结果是 0

操作数是标量类型。结果的类型为 int,出于历史原因。表达式 !E 等价于 (0 == E)。逻辑非操作符经常用于检查空指针;例如,!p 等价于 (nullptr == p)。空指针可能不持有零值,但保证评估为假。

加法运算

二元加法运算符包括加法 (+) 和减法 (−)。我们可以对两种算术类型的操作数应用加法和减法,也可以使用它们进行缩放的指针运算。我将在本章末尾讨论指针运算。

二元运算符 + 将两个操作数相加。二元运算符 - 将右操作数从左操作数中减去。对于这两种操作,都会对算术类型的操作数执行常规的算术转换。

乘法

二元乘法运算符包括乘法 (*)、除法 (/) 和余数 (%)。为了找出共同类型,通常会隐式地对乘法操作数进行算术转换。你可以对浮点数和整数操作数进行乘法和除法运算,但余数操作仅适用于整数操作数。

各种编程语言实现了不同类型的整数除法操作,包括欧几里得除法、向下取整除法和截断除法。在欧几里得除法中,余数总是非负的(Boute 1992)。在向下取整除法中,商会向负无穷大取整(Knuth 1997)。在截断除法中,商的小数部分会被舍弃,这通常被称为截断到零

C 编程语言实现了截断除法,这意味着余数总是与被除数具有相同的符号,如表 4-2 所示。

表 4-2: 截断除法

/ % 余数
10 / 3 3 10 % 3 1
10 / –3 –3 10 % –3 1
–10 / 3 –3 –10 % 3 –1
–10 / –3 3 –10 % –3 1

一般来说,如果商 a / b 可表示,那么表达式 (a / b) * b + a % b 等于 a。否则,如果除数为 0 或者 a / b 溢出,则 a / b 和 a % b 都会导致未定义行为。

花时间理解 % 运算符的行为是值得的,这样可以避免出现意外情况。例如,下面的代码定义了一个名为 is_odd 的有缺陷函数,试图判断一个整数是否为奇数:

bool is_odd(int n) {
  return n % 2 == 1;
}

由于余数操作的结果始终具有被除数 n 的符号,当 n 为负且为奇数时,n % 2 返回 −1,而函数返回 false。

一个正确的替代解决方案是检查余数是否不为 0(因为无论被除数的符号如何,余数为 0 的情况是一样的):

bool is_odd(int n) {
  return n % 2 != 0;
}

许多中央处理单元(CPU)将余数操作作为除法运算符的一部分实现,当被除数等于带符号整数类型的最小负值,并且除数等于 −1 时,可能会发生溢出。即使这种余数操作的数学结果是 0,也会出现这种情况。

C 标准库提供了浮点余数、截断和舍入函数,包括 fmod 等。

按位运算符

我们使用按位运算符来操作对象或任何整数表达式的位。按位运算符(|, &, ^, ~)将位当作纯二进制模型处理,而不关心这些位所表示的值。通常,它们用于表示掩码位图的对象,在这些对象中,每一位表示某些事物是“开启”还是“关闭”,“启用”还是“禁用”,或者是其他二元配对。通过使用掩码,可以在一次按位操作中设置、取消设置或翻转多个位。掩码和位图最好使用无符号整数类型来表示,因为符号位可以更好地作为值使用,而无符号操作不易导致未定义行为。

补码

一元补码运算符(~)作用于一个整数类型的单一操作数,并返回其操作数的按位补码,也就是说,返回一个值,其中原始值的每一位都被翻转。补码运算符用于应用 POSIX 的 umask,例如,umask 用来掩盖或减去权限。例如,umask 为 077 时,会关闭组用户和其他用户的读、写和执行权限。文件的权限模式是掩码的补码与进程请求的权限模式设置之间进行逻辑与运算的结果。

对补码运算符的操作数进行整数提升,结果会得到提升后的类型。例如,以下代码片段将 ~ 运算符应用于一个 unsigned char 类型的值:

unsigned char uc = UCHAR_MAX; // 0xFF
int i = ~uc;

在一个具有 8 位char类型和 32 位int类型的架构上,uc被赋值为0xFF。当uc作为操作数传递给~操作符时,uc通过零扩展被提升为 32 位的signed int,即0x000000FF。该值的补码为0xFFFFFF00。因此,在该平台上,对一个unsigned char类型进行取补操作总是会得到一个负值,该值类型为signed int。为了避免这种意外,一般来说,位运算应仅对足够宽的无符号整数类型的值进行操作。

移位

移位操作将整数类型操作数的每一位按照指定的位数进行移位。移位操作在系统编程中常见,其中位掩码被广泛使用。移位操作也常用于管理网络协议或文件格式的代码中,用于打包或解包数据。它们包括以下形式的左移操作:

`shift expression << additive expression`

以及以下形式的右移操作:

`shift expression >> additive expression`

移位表达式是需要移位的值,加法表达式是移位的位数。图 4-1 展示了 1 位的逻辑左移。

图 4-1:1 位的逻辑左移

加法表达式决定了需要移动值的位数。例如,E1 << E2的结果是将E1左移E2位;移出的位将用零填充。如果E1是无符号类型,结果值为E1 × 2E2。如果结果类型无法表示某些值,它们会发生溢出。如果E1是有符号类型并且值为非负数,且E1 × 2E2能在结果类型中表示,则结果值为该值;否则,行为未定义。类似地,E1 >> E2的结果是将E1右移E2位。如果E1是无符号类型,或者如果E1是有符号类型并且值为非负数,则结果值为E1/2E2的整数部分。如果E1是有符号类型并且值为负数,则结果值由实现定义,可能是算术(符号扩展)右移或逻辑(无符号)右移,如图 4-2 所示。

图 4-2:1 位的算术(有符号)右移和逻辑(无符号)右移

在这两种移位操作中,会对操作数进行整数提升,每个操作数都有整数类型。结果的类型与提升后的左操作数类型相同。通常的算术转换不会执行。

列表 4-5 展示了如何对有符号和无符号整数进行右移操作,且没有引发未定义行为。

extern int si1, si2, sresult;
extern unsigned int ui1, ui2, uresult;
// `--snip--`
❶ if ((si2 < 0) || (si2 >= sizeof(si1)*CHAR_BIT)) {
  /* error */
}
else {
  sresult = si1 >> si2;
}
❷ if (ui2 >= sizeof(unsigned int)*CHAR_BIT) {
  /* error */
}
else {
  uresult = ui1 >> ui2;
}

列表 4-5:正确的右移操作

对于有符号整数 ❶,必须确保移位的位数不为负数,且不大于或等于提升后的左操作数的宽度。对于无符号整数 ❷,可以省略负值检查,因为无符号整数永远不会是负数。你也可以通过类似的方式进行安全的左移操作。

按位与

二进制 按位与运算符(&)返回两个整数操作数的按位与。对两个操作数执行常规的算术转换。如果转换后的操作数中的每个位都为 1,则结果中对应的位也为 1,具体如下所示:表 4-3。

表 4-3: 按位与真值表

x y x & y
0 0 0
0 1 0
1 0 0
1 1 1

按位异或

按位异或运算符(^)返回两个整数操作数的按位异或。操作数必须为整数,并且对两个操作数执行常规的算术转换。如果转换后的操作数中的每个位正好有一个为 1,则结果中对应的位为 1,具体如下所示:表 4-4。你也可以将此操作理解为“要么是一个,要么是另一个,但不能同时是两个。”

表 4-4: 按位异或真值表

x y x ^ y
0 0 0
0 1 1
1 0 1
1 1 0

按位异或相当于对整数进行模 2 加法操作——即因为溢出,1 + 1 mod 2 = 0(Lewin 2012)。

初学者常常将按位异或运算符误认为是指数运算符,错误地认为表达式 2 ^ 7 会计算 2 的 7 次方。在 C 语言中,正确的计算方法是使用在 <math.h> 中定义的 pow 函数,如 示例 4-6 所示。pow 函数操作的是浮点数参数,并返回浮点结果,因此需要注意,这些函数可能由于截断或其他错误而未能产生预期的结果。

#include <math.h>
#include <stdio.h>

int main(void) {
  int i = 128;
  if (i == pow(2, 7)) {
    puts("equal");
  }
}

示例 4-6: 使用 pow 函数

这段代码调用了 pow 函数来计算 2 的 7 次方。由于 2⁷ 等于 128,因此该程序将输出 equal。

按位或

按位或 (|) 运算符 返回两个操作数的按位或。只有当转换后的操作数中的至少一个相应位为 1 时,结果中的对应位才为 1,具体见 表 4-5。

表 4-5: 按位或真值表

x y x | y
0 0 0
0 1 1
1 0 1
1 1 1

操作数必须是整数,并且会对两者执行通常的算术转换。

逻辑运算符

逻辑与 (&&) 和 逻辑或 (||) 运算符 主要用于逻辑连接两个或更多标量类型的表达式。它们通常用于条件测试中,将多个比较结合在一起,例如条件运算符的第一个操作数、if 语句的控制表达式,或 for 循环的控制表达式。你不应当使用逻辑运算符与位图操作数一起使用,因为它们主要用于布尔逻辑。

&& 运算符如果两个操作数都不等于 0,则返回 1,否则返回 0。从逻辑上讲,这意味着 a && b 只有在 a 和 b 都为真时才为真。

|| 运算符如果其操作数之一不等于 0,则返回 1,否则返回 0。从逻辑上讲,这意味着 a || b 为真,如果 a 为真,b 为真,或 a 和 b 都为真。

C 标准将这两种运算定义为“非零”的概念,因为操作数可以有 0 和 1 以外的值。这两种运算符都接受标量类型(整数、浮点数和指针)的操作数,运算结果的类型为 int。

与对应的按位二进制操作符不同,逻辑与操作符和逻辑或操作符保证从左到右的求值顺序。这两个操作符 短路:如果结果仅通过评估第一个操作数就能推断出来,第二个操作数就不会被评估。如果第二个操作数被评估,则第一个和第二个操作数之间有一个序列点。例如,表达式 0 && unevaluated 无论 unevaluated 的值如何都返回 0,因为没有任何可能的 unevaluated 值会产生不同的结果。由于这个原因,unevaluated 不会被评估来确定其值。对于 1 || unevaluated 也是如此,因为这个表达式始终返回 1。

短路操作通常用于指针操作:

bool isN(int* ptr, int n) {
  return ptr && *ptr == n; // avoid a null pointer dereference
}

这段代码测试 ptr 的值。如果 ptr 为 null,第二个 && 操作数将不会被求值,从而避免了空指针解引用。

短路操作也可以用于避免不必要的计算。在以下表达式中,is_file_ready 谓词函数在文件准备好时返回 true:

is_file_ready() || prepare_file()

当 is_file_ready 函数返回 true 时,第二个 || 操作数不会被求值,因为文件无需准备。这避免了潜在的错误,或者当 prepare_file 是幂等时,避免了不必要的计算,假设判断文件是否准备好的成本小于准备文件的成本。

如果第二个操作数包含副作用,程序员应当小心,因为可能不容易察觉这些副作用是否发生。例如,在以下代码中,只有当 i >= 0 时,i 的值才会递增:

enum {max = 15};
int i = 17;

if ((i >= 0) && ((i++) <= max)) {
  // `--snip--`
}

这段代码可能是正确的,但很可能是程序员的错误。

类型转换操作符

类型转换(也称为类型转换)明确地将一个类型的值转换为另一个类型的值。要执行类型转换,我们在表达式前加上带括号的类型名称,这会将表达式的值转换为该类型的无符号版本。以下代码演示了将 x 从 double 类型显式转换为 int 类型:

double x = 1.2;
int sum = (int)x + 1;  // explicit conversion from double to int

除非类型名称指定了 void 类型,否则类型名称必须是有符号或无符号的标量类型。操作数也必须是标量类型;指针类型不能转换为任何浮点类型,反之亦然。

类型转换非常强大,必须小心使用。一方面,类型转换可能会将现有的位重新解释为指定类型的值,而不更改这些位:

intptr_t i = (intptr_t)a_pointer; // reinterpret bits as an integer

类型转换也可能将这些位转换为所需的位,以表示结果类型中的原始值:

int i = (int)a_float; // change bits to an integer representation

类型转换还可以禁用诊断。例如:

char c;
// `--snip--`
while ((c = fgetc(in)) != EOF) {
  // `--snip--`
}

当使用 Visual C++ 2022 编译,并且警告级别为 /W4 时,这将生成以下诊断:

Severity  Code   Description
Warning   C4244  '=': conversion from 'int' to 'char', possible loss of data

将类型转换添加到 char 可以禁用诊断,但并没有解决问题:

char c;
while ((c = **(char)**fgetc(in)) != EOF) {
  // `--snip--`
}

为了减少这些风险,C++ 定义了自己的类型转换,它们的功能较弱。

条件运算符

条件运算符 (? 😃 是唯一一个需要三个操作数的 C 运算符。它根据条件返回一个结果。你可以像这样使用条件运算符:

result = condition ? valueReturnedIfTrue : valueReturnedIfFalse;

条件运算符评估第一个操作数,称为条件。如果条件为真,则评估第二个操作数(valueReturnedIfTrue);如果条件为假,则评估第三个操作数(valueReturnedIfFalse)。结果是第二个或第三个操作数的值(取决于哪个操作数被评估)。

这个结果会根据第二个和第三个操作数转换为一个通用类型。在第一个操作数和第二个或第三个操作数(无论哪一个先计算)之间有一个序列点,确保在评估第二个或第三个操作数之前,评估条件时产生的所有副作用已经完成。

条件运算符类似于一个if...else控制流块,但它返回一个值,像函数一样。与if...else控制流块不同,你可以使用条件运算符来初始化一个const限定的对象:

const int x = (a < b) ? b : a;

条件运算符的第一个操作数必须具有标量类型。第二个和第三个操作数必须具有兼容的类型(粗略地说)。有关此运算符的约束以及确定返回类型的具体细节,请参阅 C 标准(ISO/IEC 9899:2024)第 6.5.15 节。

alignof 运算符

alignof运算符返回一个整数常量,表示其操作数声明的完整对象类型的对齐要求。它不会对操作数进行求值。当应用于array类型时,它返回元素类型的对齐要求。此运算符还有一个可选的拼写形式_Alignof。在 C23 之前,alignof的拼写是通过头文件<stdalign.h>提供的便捷宏来实现的。alignof运算符在静态断言中非常有用,静态断言用于验证程序中的假设(在第十一章中有进一步讨论)。这些断言的目的是诊断假设无效的情况。清单 4-7 展示了如何使用alignof运算符。

#include <stdio.h>
#include <stddef.h>
#include <stdalign.h>
#include <assert.h>

int main(void) {
  int arr[4];
  static_assert(alignof(arr) == 4, "unexpected alignment");
  static_assert(alignof(max_align_t) == 16, "unexpected alignment");
  printf("Alignment of arr = %zu\n", alignof(arr));
  printf("Alignment of max_align_t = %zu\n", alignof(max_align_t));
}

清单 4-7: The alignof 运算符

这个简单的程序并没有完成任何特别有用的事情。它声明了一个包含四个整数的数组arr,随后进行了一个关于数组对齐的静态断言,以及一个关于max_align_t(一种对齐要求最大的基础对齐对象类型)的运行时断言。然后,它打印出这些值。如果其中任何静态断言为假,则该程序将无法编译,或者它将输出以下内容:

Alignment of arr = 4
Alignment of max_align_t = 16

这些对齐要求是 x86-64 架构的特征。 ## 关系运算符

关系运算符包括等于(==)、不等于(!=)、小于(<)、大于(>)、小于或等于(<=)和大于或等于(>=)。每个运算符如果指定的关系为真,则返回 1,如果为假,则返回 0。结果的类型为int,这也是由于历史原因。

注意,C 语言并不将表达式a < b < c 解释为 b 大于 a 并且小于 c,如同普通数学那样。相反,表达式被解释为 (a < b) < c。用英文来说,如果 a 小于 b,编译器将比较 1 与 c;否则,比较 0 与 c。如果这是你想要的结果,记得加上括号,以便让任何潜在的代码审查者清楚。像 GCC 和 Clang 这样的编译器提供了 -Wparentheses 标志来诊断这些问题。要判断 b 是否大于 a 且小于 c,你可以写如下测试:(a < b) && (b < c)。

等于和不等于运算符的优先级低于关系运算符——假设它们优先级更高是一个常见的错误。这意味着表达式a < b == c < d 的计算方式与(a < b) == (c < d) 相同。在这两种情况下,首先会计算a < b 和 c < d,然后将得到的值(0 或 1)进行相等性比较。

我们可以使用这些运算符来比较算术类型或指针。当我们比较两个指针时,结果取决于它们指向的对象在地址空间中的相对位置。如果两个指针指向相同的对象,则它们相等。

等式和不等式运算符与其他关系运算符不同。例如,你不能在两个指向不相关对象的指针上使用其他关系运算符,因为这样做没有意义,且会导致未定义行为:

int i, j;
bool b1 = &i < &j;  // undefined behavior
bool b2 = &i == &j; // OK, but tautologically false

你可能会比较指针,例如,判断是否已到达数组的末尾。

复合赋值运算符

复合赋值运算符,如表 4-6 所示,通过对对象执行某种操作来修改对象的当前值。

表 4-6: 复合赋值运算符

运算符 赋值方式
+= -= 和与差
*= /= %= 乘积、商和余数
<<= >>= 按位左移和右移
&= ^= |= 按位与、异或和或

形式为 E1 op = E2 的复合赋值与简单赋值表达式 E1 = E1 op (E2) 等效,区别在于 E1 仅被计算一次。复合赋值主要作为简写符号使用。逻辑运算符没有复合赋值运算符。

逗号运算符

在 C 中,我们以两种不同的方式使用逗号:作为运算符和用于分隔列表项(如函数的参数或声明的列表)。逗号 (,) 运算符 是一种在执行一个表达式之前先执行另一个表达式的方式。首先,逗号运算符的左操作数被当作 void 表达式来计算。左操作数和右操作数的计算之间有一个顺序点。然后,左操作数计算后,右操作数会被计算。逗号运算的结果具有右操作数的类型和值——这主要是因为它是最后一个被计算的表达式。

在需要使用逗号分隔列表项的上下文中,不能使用逗号运算符。相反,你可以在括号表达式内或条件运算符的第二个表达式中包含逗号。例如,假设在以下调用中,a、t 和 c 每个都有类型 int,调用 f:

f(a, (t=3, t+2), c)

第一个逗号分隔函数的第一个和第二个参数。第二个逗号是逗号运算符。赋值操作先被计算,再执行加法操作。由于顺序点,赋值操作在加法操作之前保证完成。逗号运算的结果具有类型 int 和值 5。第三个逗号分隔函数的第二个和第三个参数。

指针运算

在本章早些时候,我们提到过加法运算符(加法和减法)可以与算术操作数或对象指针一起使用。在本节中,我们讨论了将指针与整数相加、两个指针相减以及从指针中减去整数。

将具有整数类型的表达式加到指针上,或从指针中减去整数,返回一个与指针操作数类型相同的值。如果指针操作数指向数组中的一个元素,则结果指向相对于原始元素偏移的元素。如果结果指针超出了数组的边界,将会发生未定义行为。结果数组元素与原始数组元素的下标之差等于该整数表达式的值:

int arr[100];
int *arrp1 = &arr[40];
int *arrp2 = arrp1 + 20; // arrp2 points to arr[60]
printf("%td\n", arrp2 - arrp1); // prints 20

指针运算会自动根据数组元素的大小进行缩放,而不是按单个字节进行缩放。C 语言允许形成指向数组中每个元素的指针,包括指向数组对象最后一个元素之后的指针(也称为越界指针)。虽然这看起来不寻常或不必要,但许多早期的 C 程序会增加指针,直到它等于越界指针,C 标准委员会不希望破坏所有这些代码,这也是 C++迭代器中的常见用法。图 4-3 展示了如何形成越界指针。

图 4-3:数组对象最后一个元素之后的指针

如果指针操作数和结果指向的是同一个数组对象的元素或“越界”指针,则计算没有发生溢出;否则,行为是未定义的。为了满足越界要求,实现只需在对象的末尾提供一个额外的字节(该字节可以与程序中的其他对象重叠)。

C 语言还允许将对象视为只包含一个元素的数组,使你能够从标量值中获得一个越界指针。

越界特殊情况允许我们将指针递增,直到它等于越界指针,如以下函数所示:

int m[2] = {1, 2};

int sum_m_elems(void) {
  int *pi; int j = 0;
  for (pi = &m[0]; pi < &m[2]; ++pi) j += *pi;
  return j;
}

这里,for语句(将在下一章中详细解释)在sum_m_elems函数中循环执行,直到pi小于数组m的越界指针地址为止。在每次循环结束时,指针pi会递增,直到形成越界指针,这会导致循环条件在测试时计算为 0。

当我们从一个指针中减去另一个指针时,两个指针必须指向同一个数组对象的元素或越界元素。此操作返回两个数组元素下标的差值。结果的类型是ptrdiff_t(有符号整数类型)。在进行指针减法时要小心,因为ptrdiff_t的范围可能不足以表示指向非常大的字符数组元素之间的指针差异。

总结

在本章中,你学习了如何使用运算符编写简单的表达式,对各种对象类型执行操作。在此过程中,你还了解了一些核心的 C 语言概念,如左值、右值、值计算和副作用,这些概念决定了表达式的求值方式。你还学习了运算符优先级、结合性、求值顺序、序列化和交错执行等概念,它们会影响程序执行的整体顺序。在下一章中,你将学习更多如何通过使用选择、迭代和跳转语句来控制程序的执行。

第五章:5 控制流

在本章中,你将学习如何控制各个语句求值的顺序。我们将从表达式语句和复合语句开始,它们定义了要执行的工作。然后,我们将讨论三种语句,它们决定了哪些代码块被执行以及执行的顺序:选择语句、迭代语句和跳转语句。

表达式语句

一个表达式语句是一个可选的表达式,以分号结束。它是最常见的语句之一,也是基本的工作单元。以下示例展示了不同的表达式语句。

将一个值赋给a:

a = 6;

将a和b的和赋值给c:

c = a + b;

空语句:

; // null statement, does nothing

当语言的语法要求一个语句但不需要求值表达式时,可以使用空语句。空语句通常用作迭代语句中的占位符。

以下表达式语句将递增count的值:

++count;

在每个完整的表达式被求值之后,它的值(如果有的话)会被丢弃(包括赋值表达式,其中赋值本身是操作的副作用),以便任何有用的结果作为副作用的结果发生(如第四章所讨论)。在这个例子中,四个表达式语句中的三个都有副作用(空语句不执行任何操作)。一旦所有副作用完成,执行将继续进行到分号后的语句。

复合语句

一个复合语句,或称为代码块,是由零个或多个语句组成的列表,括在大括号内。代码块中的语句可以是本章中描述的任何类型的语句。其中一些语句可能是声明。(在早期的 C 语言版本中,块内的声明必须出现在所有非声明之前,但这个限制现在不再适用。)除非被控制语句修改,否则代码块中的每个语句都会按顺序执行。在最后一个语句执行完后,执行将继续进行到闭括号之后:

{
  static int count = 0;
  c += a;
  ++count;
}

这个例子声明了一个名为 count 的静态变量,类型为 int。第二行通过 a 中存储的值增加了在外部作用域中声明的变量 c。最后,count 被递增,用来追踪此代码块被执行的次数。

复合语句可以嵌套,以便一个复合语句完全包含另一个。你还可以有没有任何语句的代码块(只是空的大括号)。

选择语句

选择语句 允许你根据控制表达式的值有条件地执行子语句。控制表达式 决定了根据条件执行哪些语句。选择语句包括 if 语句和 switch 语句。

if

if 语句允许程序员根据标量类型的控制表达式的值来执行子语句。

有两种类型的 if 语句。第一种根据条件有条件地决定是否执行子语句:

if (`expression`)
  `substatement`

在这种情况下,如果 表达式 不等于 0,子语句 就会被执行。只有 if 语句的单个 子语句 会根据条件执行,尽管它也可以是一个复合语句。

示例 5-1 显示了一个使用 if 语句的除法函数。它将指定的被除数除以指定的除数,并将结果返回到 quotient 所引用的对象中。该函数测试了除以零和有符号整数溢出,并在这两种情况下返回 false。

bool safediv(int dividend, int divisor, int *quotient) {
❶ if (!quotient) return false;
❷ if ((divisor == 0) || ((dividend == INT_MIN) && (divisor == -1)))
  ❸ return false;
❹ *quotient = dividend / divisor;
  return true;
}

示例 5-1:一个安全的除法函数

该函数的第一行 ❶ 测试 quotient 以确保它不为 null。如果为 null,函数返回 false,表示无法返回值。(我们将在本章后面介绍 return 语句。)

函数的第二行❷包含一个更复杂的 if 语句。其控制表达式测试除数是否为 0,或者如果不检查除法是否会导致符号整数溢出。如果该表达式的结果不等于 0,函数返回 false ❸,表示无法得到商。如果 if 语句的控制表达式计算结果为 0,函数不返回,并且执行剩余语句❹来计算商并返回 true。

第二种 if 语句包含一个 else 子句,当初始子语句未被选择时,选择一个替代子语句执行:

if (`expression`)
  `substatement1`
else
  `substatement2`

在这种形式下,如果 expression 不等于 0,则执行 substatement1,如果 expression 等于 0,则执行 substatement2。这些子语句中会执行一个,但永远不会同时执行。

对于任何形式的 if 语句,条件执行的子语句也可以是 if 语句。这种常见用法就是 if...else 阶梯,如列表 5-2 所示。

if (`expr1`)
  `substatement1`
else if (`expr2`)
  `substatement2`
else if (`expr3`)
  `substatement3`
else
  `substatement4`

列表 5-2:The if...else 阶梯语法

在一个 if...else 阶梯中,四个语句中(且仅有一个)会执行:

  • substatement1 在 expr1 不等于 0 时执行。

  • substatement2 在 expr1 等于 0 且 expr2 不等于 0 时执行。

  • substatement3 在 expr1 和 expr2 都等于 0 且 expr3 不等于 0 时执行。

  • substatement4 仅在前面的所有条件都为 0 时执行。

示例 5-3 中的例子使用了一个 if...else 结构来打印成绩。

void printgrade(unsigned int marks) {
  if (marks >= 90) {
    puts("YOUR GRADE : A");
  } else if (marks >= 80) {
    puts("YOUR GRADE : B");
  } else if (marks >= 70) {
    puts("YOUR GRADE : C");
  } else {
    puts("YOUR GRADE : Failed");
  }
}

示例 5-3:使用 if...else 结构打印成绩

在这个 if...else 结构中,printgrade 函数测试 unsigned int 参数 marks 的值,判断它是否大于或等于 90。如果是,函数打印 YOUR GRADE : A。否则,函数会测试 marks 是否大于或等于 80,依此类推,直到 if...else 结构的末尾。如果 marks 不大于或等于 70,则函数打印 YOUR GRADE : Failed。这个例子使用了一种编码风格,其中闭合括号与 else 子句写在同一行。

只有紧跟在 if 语句后的单个语句会被执行。例如,在下面的代码片段中,只有当 condition 不等于 0 时,conditionally_executed 才会执行,而 unconditionally_executed 总是会执行:

if (condition)
  conditionally_executed();
unconditionally_executed(); // always executed

尝试添加另一个条件执行的函数是一个常见的错误来源:

if (condition)
  conditionally_executed();
  also_conditionally_executed(); // ????
unconditionally_executed(); // always executed

在这个代码片段中,also_conditionally_executed 是 无条件 执行的。变量名和缩进格式是误导性的,因为空格(一般而言)和缩进(特别是)对语法没有意义。通过添加大括号来限定一个复合语句或代码块,可以修复这段代码。然后,这个代码块作为单一的条件执行语句执行:

if (condition) {
  conditionally_executed();
  also_conditionally_executed(); // fixed it
}
unconditionally_executed(); // always executed

虽然原始代码片段并不错误,但许多编码规范建议始终包括大括号,以避免这种错误:

if (condition) {
  conditionally_executed();
}
unconditionally_executed(); // always executed

我的个人风格是,仅当我能将条件执行语句与 if 语句写在同一行时,才省略大括号:

if (!quotient) return false;

当你让集成开发环境(IDE)为你格式化代码时,这个问题就不那么严重了,因为它不会被代码缩进迷惑。GCC 和 Clang 编译器提供了 -Wmisleading-indentation 编译器标志,检查代码缩进并在其与控制流不符时发出警告。

switch

switch 语句的工作方式类似于 if...else 语句梯,唯一不同的是控制表达式必须是整数类型。例如,Listing 5-4 中的 switch 语句与 Listing 5-3 中的 if...else 语句执行相同的功能,前提是 marks 是一个 0 到 109 之间的整数。如果 marks 大于 109,将导致成绩不及格,因为结果商会大于 10,最终会被默认的 case 捕获。

switch (marks/10) {
  case 10:
  case 9:
    puts("YOUR GRADE : A");
    break;
  case 8:
    puts("YOUR GRADE : B");
    break;
  case 7:
    puts("YOUR GRADE : C");
    break;
  default:
    puts("YOUR GRADE : Failed");
}

Listing 5-4: 使用 switch 语句输出成绩

switch 语句根据控制表达式的值和每个 case 标签中的常量表达式,控制跳转到三个子语句之一。跳转后,代码按顺序执行,直到遇到下一个控制流语句。在我们的例子中,跳转到 case 10(该语句为空)后,会继续执行 case 9 中的后续语句。这是逻辑所必需的,以确保完美的 100 分会导致 A,而不是 F。

你可以通过插入一个break语句来终止switch语句的执行,控制将跳转到紧跟在整体switch语句之后的语句。(我们将在本章稍后详细讨论break语句。)确保在下一个case标签之前包含一个break语句。如果漏掉了,控制流将直接跳到下一个case,这是常见的错误来源。由于break语句并不是必须的,漏掉它通常不会产生编译器诊断。如果使用-Wimplicit-fallthrough标志,GCC 会对 fall-through 情况发出警告。C23 标准引入了[[fallthrough]]属性,允许程序员指定 fall-through 行为是期望的,前提是认为默默的 fall-through 是break语句的意外遗漏。

整型提升发生在控制表达式上。每个case标签中的常量表达式会被转换为控制表达式的提升类型。如果转换后的值与提升后的控制表达式匹配,控制将跳转到匹配的case标签后面的语句。否则,如果没有匹配的结果但有一个default标签,控制将跳转到该标签指向的语句。如果没有任何转换后的case常量表达式匹配,并且没有default标签,则不会执行switch体中的任何部分。当switch语句嵌套时,case或default标签仅在最靠近的外层switch语句中可访问。

关于使用 switch 语句有一些最佳实践。列表 5-5 显示了一个不太理想的 switch 语句实现,它根据账户类型为账户分配利率。AccountType 枚举表示银行提供的固定数量的账户类型。

typedef enum {Savings, Checking, MoneyMarket} AccountType;
void assignInterestRate(AccountType account) {
  double interest_rate;
  switch (account) {
    case Savings:
      interest_rate = 3.0;
      break;
    case Checking:
      interest_rate = 1.0;
      break;
    case MoneyMarket:
      interest_rate = 4.5;
      break;
  }
  printf("Interest rate = %g.\n", interest_rate);
}

列表 5-5:一个没有 default 标签的 switch 语句

assignInterestRate 函数定义了一个枚举类型参数 AccountType,并基于它进行切换,分配与每个账户类型相关的适当利率。

代码本身没有问题,但如果程序员想要进行更改,就需要在至少两个不同的地方更新代码。假设银行引入了一种新的账户类型:定期存款证书。程序员更新了 AccountType 枚举,如下所示:

typedef enum {Savings, Checking, MoneyMarket, CD} AccountType;

然而,如果程序员未能修改 assignInterestRate 函数中的 switch 语句,interest_rate 将不会被赋值,导致在函数尝试打印该值时出现未初始化读取的错误。这个问题很常见,因为枚举可能被声明在离 switch 语句很远的地方,而程序中可能包含许多类似的 switch 语句,它们都在控制表达式中引用了一个 AccountType 类型的对象。

无论是 Clang 还是 GCC,当你使用 -Wswitch-enum 标志时,它们都能在编译时帮助诊断这些问题。或者,你可以通过在 switch 语句中添加 default 情况来防止此类错误,并提高此代码的可测试性,如 列表 5-6 所示。

typedef enum {Savings, Checking, MoneyMarket, CD} AccountType;
void assignInterestRate(AccountType account) {
  double interest_rate;
  switch (account) {
    case Savings:
      interest_rate = 3.0;
      break;
    case Checking:
      interest_rate = 1.0;
      break;
    case MoneyMarket:
      interest_rate = 4.5;
      break;
    case CD:
      interest_rate = 7.5;
      break;
 **default: abort();**
  }
  printf("Interest rate = %g.\n", interest_rate);
  return;
}

列表 5-6:一个有 default 标签的 switch 语句

switch 语句现在包含了一个针对 CD 的案例,而 default 子句没有被使用。然而,保留 default 子句是一个好习惯,以防将来添加了其他账户类型。

包括 default 子句的缺点是抑制了编译器警告,且在运行时才会诊断出问题。因此,编译器警告(如果编译器支持的话)通常是更好的方法。## 迭代语句

迭代语句 使子语句(或复合语句)在满足终止条件的情况下执行零次或多次。英文单词 iteration 的意思是“一个过程的重复”。迭代语句更常见的非正式称呼是循环。循环 是“一个过程,其结尾与开头相连。”

while

while 语句导致循环体反复执行,直到控制表达式的值等于 0。在每次执行循环体之前,会先评估控制表达式。考虑以下示例:

void f(unsigned int x) {
  while (x > 0) {
    printf("%d\n," x);
    --x;
  }
  return;
}

如果 x 初始值不大于 0,while 循环会在不执行循环体的情况下退出。如果 x 大于 0,则输出其值,然后将其递减。一旦到达循环末尾,将再次测试控制表达式。这个模式会一直重复,直到表达式的值为 0。总体来说,这个循环会从 x 递减到 1。

while 循环是一个入口控制的循环,直到其控制表达式的值为 0 时才会终止。列表 5-7 展示了 C 标准库 memset 函数的实现。该函数将 val(转换为 unsigned char 类型)的值复制到由 dest 指向的对象的前 n 个字符中。

void *memset(void *dest, int val, size_t n) {
  unsigned char *ptr = (unsigned char*)dest;
  while (n-- > 0)
    *ptr++ = (unsigned char)val;
  return dest;
}

列表 5-7:C 标准库 memset 函数

memset 函数的第一行将 dest 转换为指向 unsigned char 的指针,并将结果赋值给 unsigned char 指针 ptr。这使得我们能够保留 dest 的值,以便在函数的最后一行返回。该函数的其余两行组成一个 while 循环,将值 val(转换为 unsigned char)复制到 dest 所指向的对象的前 n 个字符中。while 循环的控制表达式测试 n-- > 0。

n 参数是一个循环计数器,在每次迭代时作为控制表达式的副作用递减。在这种情况下,循环计数器单调递减,直到达到最小值(0)。该循环执行 n 次重复,其中 n 小于或等于 ptr 引用的内存边界。

<​samp class="SANS_TheSansMonoCd_W5Regular_11">ptr指针表示一系列<​samp class="SANS_TheSansMonoCd_W5Regular_11">unsigned char类型的对象,从<​samp class="SANS_TheSansMonoCd_W5Regular_11">ptr到<​samp class="SANS_TheSansMonoCd_W5Regular_11">ptr <​samp class="SANS_TheSansMonoCd_W5Regular_11">+ <​samp class="SANS_TheSansMonoCd_W5Regular_11">n - 1。<​samp class="SANS_TheSansMonoCd_W5Regular_11">val的值被转换为<​samp class="SANS_TheSansMonoCd_W5Regular_11">unsigned char并依次写入每个对象。如果<​samp class="SANS_TheSansMonoCd_W5Regular_11">n大于<​samp class="SANS_TheSansMonoCd_W5Regular_11">ptr引用的对象的边界,<​samp class="SANS_TheSansMonoCd_W5Regular_11">while循环会写入该对象的边界之外的内存。这是未定义的行为,通常称为缓冲区溢出溢出。如果<​samp class="SANS_TheSansMonoCd_W5Regular_11">ptr引用的对象至少有<​samp class="SANS_TheSansMonoCd_W5Regular_11">n个字节,<​samp class="SANS_TheSansMonoCd_W5Regular_11">while循环将不会发生未定义的行为而终止。在循环的最后一次迭代中,控制表达式<​samp class="SANS_TheSansMonoCd_W5Regular_11">n-- > 0的值为 0,导致循环终止。

你可以编写一个无限循环——一个永远不会终止的循环。为了避免编写一个无意中永远运行的<​samp class="SANS_TheSansMonoCd_W5Regular_11">while循环,请确保在<​samp class="SANS_TheSansMonoCd_W5Regular_11">while循环开始之前初始化由控制表达式引用的任何对象。同时,确保在<​samp class="SANS_TheSansMonoCd_W5Regular_11">while循环执行过程中,控制表达式以一种方式发生变化,导致循环在适当的迭代次数后终止。

<​samp class="SANS_Futura_Std_Bold_Condensed_Oblique_BI_11">do...while

<​samp class="SANS_TheSansMonoCd_W5Regular_11">do...while语句与<​samp class="SANS_TheSansMonoCd_W5Regular_11">while语句相似,不同之处在于,控制表达式的评估发生在每次执行循环体之后,而不是之前。因此,保证循环体在测试条件之前至少执行一次。<​samp class="SANS_TheSansMonoCd_W5Regular_11">do...while迭代语句具有以下语法:

do
  `statement`
while (`expression`);

在do...while迭代中,statement会无条件执行一次,然后对expression进行求值。如果expression不等于 0,则控制返回到循环的顶部,重新执行statement。否则,执行跳转到循环后的语句。

do...while迭代语句通常用于输入/输出(I/O)中,在这种情况下,在测试流状态之前从流中读取数据是有意义的,如清单 5-8 所示。

#include <stdio.h>
// `--snip--`
int count;
float quant;
char units[21], item[21];
do {
  count = fscanf(stdin, "%f%20s of %20s", &quant, units, item);
  fscanf(stdin,"%*[^\n]");
} while (!feof(stdin) && !ferror(stdin));
// `--snip--`

清单 5-8: 一个输入循环

这段代码从标准输入流stdin输入一个浮点数、一个度量单位(作为字符串)和一个物品名称(同样作为字符串),直到文件末尾指示符被设置或发生读取错误为止。我们将在第八章详细讨论输入/输出。

for

for语句可能是 C 语言中最“C 风格”的部分。for语句重复执行一个语句,通常用于当循环的迭代次数在进入循环之前就已知的情况。它具有以下语法:

for (`clause1`; `expression2`; `expression3`)
  `statement`

控制表达式(expression2)在每次执行循环体之前进行求值,而expression3则在每次执行循环体之后进行求值。如果clause1是一个声明,它声明的任何标识符的作用域将是声明的剩余部分以及整个循环,包括其他两个表达式。

当我们将for语句转换为等效的while循环时,clause1、expression2和expression3的作用变得显而易见,如图 5-1 所示。

图 5-1: 将 for 循环转化为 while 循环

清单 5-9 显示了清单 5-7 中 memset 实现的修改版本;我们已将 while 循环替换为 for 循环。

void *memset(void *dest, int val, size_t n) {
  unsigned char *ptr = (unsigned char *)dest;
  for (❶ size_t i = 0; ❷ i < n; ❸ ++i) {
 *(ptr + i) = (unsigned char)val;
  }
  return dest;
}

清单 5-9:使用 for 循环填充字符数组

for 循环在 C 程序员中很受欢迎,因为它为声明和/或初始化循环计数器❶、指定控制循环的表达式❷以及递增循环计数器❸提供了一个方便的位置,所有这些都可以在同一行内完成。 *(ptr + i) 左值表达式也可以等效地使用索引运算符写为 ptr[i]。

for 循环也可能有些误导。我们来看一个 C 语言中单向链表的例子,它声明了一个 node 结构体,其中包含一个 data 元素和指向链表中下一个节点的指针 next。我们还定义了一个指针 p 指向 node 结构:

struct node {
  int data;
  struct node *next;
};
struct node *p;

使用 p 的定义,下面的例子(用于释放链表的存储)错误地在释放后读取了 p 的值:

for (p = head; p != nullptr; p = p->next) {
  free(p);
}

在释放后读取 p 是未定义行为。

如果这个循环改写成一个 while 循环,那么代码会变得明显,它会在释放后读取 p:

p = head;
while (p != nullptr) {
  free(p);
  p = p->next;
}

for 循环可能会令人困惑,因为它在循环主体之后评估 expression3,即使在语法上它看起来出现在循环体之前。

执行这个操作的正确方式是在释放指针之前保存所需的指针,像这样:

for (p = head; p != nullptr; p = q) {
  q = p->next;
  free(p);
}

你可以在第六章中阅读更多关于动态内存管理的内容。 ## 跳转语句

跳转语句在遇到时会无条件地将控制转移到同一函数的另一个部分。这些是最低级别的控制流语句,通常与底层汇编语言代码密切对应。

goto

任何语句前面都可以加一个标签,标签是一个标识符,后面跟着一个冒号。C23 还允许你将标签放在声明前面以及复合语句的末尾,这是以前的 C 版本不允许的。goto语句会导致控制跳转到在封闭函数中由命名标签前缀标识的语句。这个跳转是无条件的,这意味着每次执行goto语句时都会发生。以下是一个goto语句的示例:

 /* executed statements */
  goto location;
  /* skipped statements */
location:
  /* executed statements */

执行会持续进行,直到遇到goto语句,此时控制跳转到紧随location标签后的语句,执行继续。goto语句与标签之间的语句会被跳过。

自从 Edsger Dijkstra 在 1968 年写了题为“Go To Statement Considered Harmful”的论文后,goto语句一直有着不好的声誉。他的批评是,如果使用不当,goto语句可能导致意大利面条式代码(代码的控制结构复杂且纠缠,程序流程概念上像一碗意大利面条一样错综复杂)。然而,goto语句如果以清晰、一致的方式使用,也可以使代码更易于阅读。

使用goto语句的一种有用方法是将它们串联起来,在发生错误并且必须退出函数时释放已分配的资源(如动态分配的内存或打开的文件)。这种情况通常发生在程序分配多个资源时,每个分配可能会失败,必须释放资源以防止泄漏。如果第一个资源分配失败,不需要清理,因为没有资源被分配。但是,如果第二个资源无法分配,则需要释放第一个资源。同样,如果第三个资源无法分配,则需要释放第二个和第一个资源,以此类推。这个模式导致重复的清理代码,而且由于重复和额外的复杂性,容易出错。

一种解决方案是使用嵌套的 if 语句,但如果嵌套过深,也会变得难以阅读。相反,我们可以使用如 示例 5-10 中所示的 goto 链来释放资源。

int do_something(void) {
  FILE *file1, *file2;
  object_t *obj;
  int ret_val = 0; // initially assume a successful return value

  file1 = fopen("a_file", "w");
  if (file1 == nullptr) {
    return -1;
  }

  file2 = fopen("another_file", "w");
  if (file2 == nullptr) {
    ret_val = -1;
 **goto FAIL_FILE2;**
  }

  obj = malloc(sizeof(*obj));
  if (obj == nullptr) {
    ret_val = -1;
 **goto FAIL_OBJ;**
  }

  // operate on allocated resources

  // clean up everything
  free(obj);
**FAIL_OBJ:**  // otherwise, close only the resources we opened
  fclose(file2);
**FAIL_FILE2:**
  fclose(file1);
  return ret_val;
}

示例 5-10:使用 goto 链释放资源

代码遵循一个简单的模式:资源按照一定顺序分配、操作,然后按相反的顺序(后进先出)释放。如果在分配资源时发生错误,代码使用 goto 跳转到清理代码中的适当位置,并且只释放那些已经分配的资源。

这样使用时,goto 语句可以使代码更易读。一个实际的例子是 Linux 内核 v6.7 中的 kernel/fork.c 文件中的 copy_process 函数(<wbr>elixir<wbr>.bootlin<wbr>.com<wbr>/linux<wbr>/v6<wbr>.7<wbr>/source<wbr>/kernel<wbr>/fork<wbr>.c#L2245),该函数使用了 20 个 goto 标签,在内部函数失败时执行清理代码。 ### continue

你可以在循环内部使用 continue 语句,跳转到循环体的末尾,跳过当前迭代中剩余语句的执行。例如,continue 语句等价于在 示例 5-11 中每个循环中使用 goto END_LOOP_BODY;。

while (/* _ */) {
  // `--snip--`
  continue;
  // `--snip--`
END_LOOP_BODY: ;
}
do {
  // `--snip--`
  continue;
  // `--snip--`
END_LOOP_BODY: ;
} while (/* _ */);
for (/* _ */) {
  // `--snip--`
  continue;
  // `--snip--`
END_LOOP_BODY: ;
}

示例 5-11:使用 continue 语句

continue 语句与条件语句配合使用,以便在当前循环迭代的目标完成后,继续处理后续的循环迭代。

break

break 语句终止 switch 或迭代语句的执行。在列表 5-4 中的 switch 语句里,我们使用了 break。在循环中,break 语句会导致循环终止,程序的执行将在循环后的语句处继续。例如,以下示例中的 for 循环只有在按下大写或小写 Q 键时才会退出:

#include <stdio.h>
int main(void) {
  char c;
  for(;;) {
    puts("Press any key, Q to quit: ");
    c = toupper(getchar());
    if (c == 'Q') **break**;
    // `--snip--`
  }
} // loop exits when either q or Q is pressed

我们通常使用 break 语句在完成循环任务后中止循环的执行。例如,列表 5-12 中的 break 语句会在找到数组中的指定键后退出循环。假设 key 在 arr 中是唯一的,find_element 函数没有 break 语句时也会表现相同,但根据数组的长度以及 key 被发现的位置,可能会运行得更慢。

size_t find_element(size_t len, int arr[len], int key) {
  size_t pos = (size_t)-1;
  // traverse arr and search for key
  for (size_t i = 0; i < len; ++i) {
 if (arr[i] == key) {
      pos = i;
 **break**;// terminate loop
    }
  }
  return pos;
}

列表 5-12:跳出循环

由于 continue 和 break 会跳过循环体的部分内容,因此使用这些语句时需要小心:这些语句后的代码将不会被执行。

return

return 语句终止当前函数的执行并将控制权返回给调用者。你已经在本书中看到过很多 return 语句。一个函数可以有零个或多个 return 语句。

return 语句可以简单地返回,或者返回一个表达式。在 void 函数(一个不返回值的函数)中,return 语句应该简单地返回。当一个函数返回一个值时,return 语句应该返回一个产生返回类型值的表达式。如果执行了带表达式的 return 语句,那么表达式的值将作为函数调用表达式的返回值返回给调用者:

int sum(int x, int y, int z) {
  return x + y + z;
}

这个简单的函数对它的参数求和并返回总和。返回表达式 x + y + z 产生一个类型为 int 的值,这与函数的返回类型相匹配。如果这个表达式产生了不同的类型,它会隐式转换为具有函数返回类型的对象。返回表达式也可以像返回 0 或 1 一样简单。然后可以在表达式中使用该函数结果,或者将其赋值给一个变量。

请注意,如果控制流在没有评估 return 语句且没有表达式的情况下到达一个非 void 函数(一个声明为返回值的函数)的闭括号,则使用该函数调用的返回值会导致未定义的行为。例如,当 a 为非负数时,以下函数未能返回一个值,因为条件 a < 0 为假:

int absolute_value(int a) {
  if (a < 0) {
    return -a;
  }
}

我们可以通过在 a 为非负数时提供一个返回值来轻松修复这个缺陷,正如 列表 5-13 中所示。

int absolute_value(int a) {
  if (a < 0) {
    return -a;
  }
 **return a;**
}

列表 5-13:该 absolute_value 函数沿所有路径返回一个值。

然而,这段代码仍然有一个 bug(请参见 第三章)。发现这个 bug 留给你自己练习。

总结

在本章中,你学习了控制流语句。控制流语句允许你创建灵活的程序,这些程序可以重复任务并根据程序输入更改执行方式:

  • 选择语句,如 if 和 switch,允许你根据控制表达式的值从一组语句中进行选择。

  • 迭代语句重复执行循环体,直到控制表达式等于 0。

  • 跳转语句无条件地将控制转移到一个新位置。

在下一章,你将学习动态分配的内存。与控制流语句类似,你可以使用动态内存来创建灵活的程序,依据程序输入来分配内存。

第六章:6 动态分配内存

在第二章中,你学到了每个对象都有一个存储持续时间,决定了它的生命周期,C 语言定义了四种存储持续时间:静态、线程、自动和分配。在本章中,你将学习动态分配内存,它是在运行时从堆中分配的。动态分配内存在程序运行前无法确定精确的存储需求时非常有用。

我们将首先描述分配存储、静态存储和自动存储持续时间之间的区别。我们将跳过线程存储分配,因为它涉及并行执行,而我们在此不涉及。然后我们将探讨你可以使用的函数来分配和释放动态内存、常见的内存分配错误以及避免这些错误的策略。在本章中,内存存储这两个术语可以互换使用,类似于它们在实际应用中的用法。

存储持续时间

对象占用存储空间,存储空间可能是可读写内存、只读内存或中央处理单元(CPU)寄存器。分配持续时间的存储与自动或静态存储持续时间的存储具有显著不同的特性。首先,我们将回顾自动和静态存储持续时间。

自动存储持续时间的对象在一个代码块内声明,或作为函数参数声明。这些对象的生命周期从包含它们的代码块开始执行时开始,到代码块执行结束时结束。如果该代码块递归进入,每次都会创建一个新的对象,每个对象都有自己的存储空间。

在文件作用域内声明的对象具有静态存储持续时间。这些对象的生命周期是程序执行的整个过程,它们的存储值在程序启动前就已经初始化。你还可以通过使用static存储类说明符,在一个代码块内声明一个变量,使其具有静态存储持续时间。

堆和内存管理器

动态分配的内存具有分配存储持续时间。分配对象的生命周期从分配开始,一直到释放为止。动态分配内存是从中分配的,堆只是一个或多个由内存管理器管理的大型、可细分的内存块。

内存管理器是通过提供本章所描述的标准内存管理函数实现的库,负责为你管理堆。内存管理器作为客户端进程的一部分运行。当客户端进程调用内存分配函数时,内存管理器会向操作系统(OS)请求一个或多个内存块,并将这些内存分配给客户端进程。分配请求不会直接发送到操作系统,因为操作系统较慢,并且只能处理大块内存,而分配器则将这些大块内存分割成小块,从而提高速度。

内存管理器仅管理未分配和已释放的内存。一旦内存被分配,调用者将管理该内存,直到它被归还。确保内存被释放是调用者的责任,尽管大多数实现会在程序终止时回收动态分配的内存。

何时使用动态分配的内存

如前所述,当程序的确切存储需求在运行时之前无法确定时,会使用动态分配的内存。动态分配的内存比静态分配的内存效率低,因为内存管理器需要在运行时堆中找到合适大小的内存块,而且调用者必须在不再需要时显式释放这些内存块,所有这些都需要额外的处理。动态分配的内存还需要额外的处理用于日常维护操作,例如碎片整理(合并相邻的空闲块),并且内存管理器通常会使用额外的存储来存放控制结构,以便支持这些操作。

内存泄漏发生在动态分配的内存不再需要时没有归还给内存管理器。如果内存泄漏严重,内存管理器最终将无法满足新的存储请求。

默认情况下,对于那些在编译时已知大小的对象,应声明为具有自动或静态存储持续时间的对象。当存储大小或对象数量在运行时才能确定时,应动态分配内存。例如,你可能会使用动态分配的内存在运行时从文件中读取表格,尤其是在编译时不知道表格的行数时。类似地,你可能会使用动态分配的内存来创建链表、哈希表、二叉树或其他数据结构,这些结构中每个容器所包含的数据元素的数量在编译时是无法知道的。

内存管理

C 标准库定义了内存管理函数,用于分配(例如,malloc、calloc 和 realloc)和释放(free)动态内存。OpenBSD 的 reallocarray 函数不是 C 标准库的一部分,但在内存分配中可能会有所帮助。C23 增加了两个额外的内存释放函数:free_sized 和 free_aligned_sized。

动态分配的内存需要根据请求的大小进行适当对齐,包括数组和结构体。C11 引入了 aligned_alloc 函数,以支持具有更严格内存对齐要求的硬件。

malloc

malloc 函数为指定大小的对象分配空间。返回的存储表示是未确定的。在 Listing 6-1 中,我们调用 malloc 函数为一个大小为 struct widget 的对象动态分配存储空间。

#include <stdlib.h>

typedef struct {
  double d;
  int i;
  char c[10];
} widget;

❶ widget *p = malloc(sizeof *p);
❷ if (p == nullptr) {
  // handle allocation error
}
// continue processing

Listing 6-1: 使用 malloc 函数为一个 widget 分配存储空间

所有内存分配函数都接受一个类型为 size_t 的参数,指定要分配的内存字节数 ❶。为了移植性,我们在计算对象的大小时使用 sizeof 运算符,因为不同类型的对象(如 int 和 long)的大小在不同实现中可能不同。

malloc 函数返回一个空指针表示错误,或者返回指向分配空间的指针。因此,我们检查 malloc 是否返回空指针 ❷,并适当地处理错误。

在函数成功返回分配的存储后,我们可以通过 p 指针引用 widget 结构体的成员。例如,p->i 访问 widget 的 int 成员,而 p->d 访问 double 成员。

在不声明类型的情况下分配内存

你可以将 malloc 的返回值存储为 void 指针,以避免为引用的对象声明类型:

void *p = malloc(size);

另外,你可以使用一个 char 指针,这是在引入 void 类型之前的惯例:

char *p = malloc(size);

无论哪种情况,p 指向的对象在将对象复制到此存储中之前是没有类型的。一旦发生这种情况,该对象将具有最后一个复制到此存储中的对象的有效类型,这会将类型印在分配的对象上。

在以下示例中,p 所引用的存储具有有效的类型为 widget:

widget w = {3.2, 9, "abc",};
memcpy(p, &w, sizeof(w));

在调用 memcpy 之后,有效类型的变化会影响优化,但不会产生其他影响。

由于分配的内存可以存储任何足够小的对象类型,因此分配函数返回的指针,包括 malloc,必须具有足够的对齐。例如,如果实现中有对齐为 1 字节、2 字节、4 字节、8 字节和 16 字节的对象,并且分配了 16 字节或更多字节的存储,则返回指针的对齐方式是 16 的倍数。

读取未初始化的内存

从 malloc 返回的内存内容是未初始化的,这意味着它具有不确定的表示。读取未初始化的内存从来都不是一个好主意;可以将其视为未定义行为。如果你想了解更多,我写了一篇关于未初始化读取的深入文章(Seacord 2017)。malloc 函数不会初始化返回的内存,因为你预计最终会覆盖这块内存。

即便如此,初学者常常犯一个错误,认为通过malloc返回的内存已包含零值。程序中示例 6-2 就犯了这个错误。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
  char *str = (char *)malloc(16);
  if (str) {
    strncpy(str, "123456789abcdef", 15);
    printf("str = %s.\n", str);
    free(str);
    return EXIT_SUCCESS;
  }
  return EXIT_FAILURE;
}

示例 6-2:初始化错误

该程序调用malloc分配 16 字节内存,然后使用strncpy将字符串的前 15 个字节复制到分配的内存中。程序员试图通过复制比分配的内存大小少一个字节来创建一个正确的空终止字符串。这样,程序员假设分配的存储区域已经包含零值,作为空字符。然而,存储区域很可能包含非零值,这样字符串就无法正确地以空字符终止,而调用printf将导致未定义行为。

一个常见的解决方案是将空字符写入分配的存储区域的最后一个字节,如下所示:

strncpy(str, "123456789abcdef", 15);
❶ str[15] = '\0';

如果源字符串(本例中的字符串字面量"123456789abcdef")的字节数少于 15 字节,则会复制空终止字符,此时不需要执行赋值操作❶。如果源字符串的字节数为 15 字节或更多,添加此赋值操作可以确保字符串正确地以空字符终止。

aligned_alloc

aligned_alloc函数与malloc函数类似,不同之处在于它要求你提供对齐方式以及分配对象的大小。该函数具有以下签名,其中size指定对象的大小,alignment指定其对齐方式:

void *aligned_alloc(size_t alignment, size_t size);

尽管 C 语言要求通过malloc分配的动态内存对所有标准类型(包括数组和结构体)进行适当对齐,但有时你可能需要覆盖编译器的默认选择。你可以使用aligned_alloc函数来请求比默认值更严格的对齐(换句话说,使用更大的 2 的幂次)。如果alignment的值不是实现所支持的有效对齐,函数会返回一个空指针来表示失败。有关对齐的更多信息,请参见第二章。

calloc

calloc函数为一个包含nmemb个对象的数组分配存储,每个对象的大小为size字节。它具有以下签名:

void *calloc(size_t nmemb, size_t size);

该函数将存储初始化为全零字节。这些零值可能不同于用来表示浮点零或空指针常量的值。你还可以使用calloc函数为单个对象分配存储,可以将其视为一个包含一个元素的数组。

在内部,calloc函数通过将nmemb乘以size来确定所需的字节数。历史上,一些calloc实现没有验证这些值在相乘时是否会发生溢出。C23 要求进行此测试,现代的calloc实现如果无法分配空间,或者如果乘积nmemb * size会发生溢出,则返回空指针。

realloc

realloc函数增加或减少先前分配的存储空间的大小。它接受一个指向由先前调用aligned _alloc、malloc、calloc或realloc(或者一个空指针)分配的内存的指针,以及一个大小,并具有以下签名:

void *realloc(void *ptr, size_t size);

你可以使用realloc函数来增加或(较少见的情况)减少数组的大小。

避免内存泄漏

为了避免在使用 realloc 时引入 bug,你应该理解该函数的具体规格。如果新分配的存储空间大于原有内容,realloc 会将额外的存储空间置为未初始化状态。如果 realloc 成功分配了新的对象,它会调用 free 来释放旧对象的存储空间。指向新对象的指针可能与指向旧对象的指针值相同。如果分配失败,realloc 函数会保留旧对象的数据并返回空指针。realloc 调用可能会失败,例如,当没有足够的内存来分配所请求的字节数时。以下使用 realloc 的方式可能是错误的:

size += 50;
if ((p = realloc(p, size)) == nullptr) return nullptr;

在这个示例中,size 在调用 realloc 增加 p 所引用的存储空间的大小之前,增加了 50。如果 realloc 调用失败,p 会被赋值为空指针,但 realloc 不会释放 p 所引用的存储空间,导致内存泄漏。

清单 6-3 演示了正确使用 realloc 函数的示例。

void *p = malloc(100);
void *p2;

// `--snip--`
if ((nsize == 0) || (p2 = realloc(p, nsize)) == nullptr) {
  free(p);
  return nullptr;
}
p = p2;

清单 6-3:正确使用 realloc 函数的示例

示例 6-3 声明了两个变量,p和p2。变量p引用动态分配的内存,这是malloc返回的内存,p2初始时未初始化。最终,这块内存会被调整大小,我们通过调用realloc函数,并传入p指针和新的nsize大小来完成。realloc的返回值会被赋给p2,以避免覆盖存储在p中的指针。如果realloc返回一个空指针,那么p引用的内存将会被释放,并且函数将返回空指针。如果realloc成功并返回指向大小为nsize的分配的指针,则p将被赋值为指向新分配内存的指针,程序继续执行。

这段代码还包括了对零字节分配的测试。避免将值为 0 的大小参数传递给realloc函数,因为这会导致未定义行为(如 C23 中所阐明)。

如果以下对realloc函数的调用没有返回空指针,那么存储在p中的地址就是无效的,不能再进行读取:

newp = realloc(p, ...);

特别是,以下测试是不允许的:

if (newp != p) {
  // update pointers to reallocated memory
}

任何引用p之前所指向内存的指针,都必须更新为引用调用realloc之后指向的新内存newp,无论realloc是否保留了相同的内存地址。

解决这个问题的一种方法是通过额外的间接方式,有时称为句柄。如果重新分配的指针的所有使用都是间接的,当该指针重新赋值时,它们都会被更新。

调用 realloc 时传递空指针

用空指针调用 realloc 等同于调用 malloc。只要 newsize 不等于 0,我们就可以用以下代码替代:

if (p == nullptr)
  newp = malloc(newsize);
else
  newp = realloc(p, newsize);

使用如下代码:

newp = realloc(p, newsize);

这个较长版本的代码首先调用 malloc 进行初始分配,然后根据需要调用 realloc 来调整大小。因为用空指针调用 realloc 等价于调用 malloc,所以第二个版本简洁地完成了相同的操作。

reallocarray

正如我们在前几章中看到的,带符号整数溢出和无符号整数回绕是严重的问题,可能导致缓冲区溢出和其他安全漏洞。例如,在下面的代码片段中,表达式 num * size 可能会在作为大小参数传递给 realloc 时发生回绕:

if ((newp = realloc(p, num * size)) == nullptr) {
  // `--snip--`

OpenBSD 中的 reallocarray 函数可以重新分配数组的存储空间,但像 calloc 一样,它在数组大小计算过程中会检查回绕,从而省去了你自己执行这些检查的需要。reallocarray 函数的函数签名如下:

void *reallocarray(void *ptr, size_t nmemb, size_t size);

reallocarray 函数为 nmemb 个大小为 size 的成员分配存储,并检查 nmemb * size 计算是否发生回绕。包括 GNU C 库 (libc) 在内的其他平台已经采用了此函数,并且该函数已被提议纳入下一个 POSIX 标准版本中。reallocarray 函数不会将分配的存储空间清零。

reallocarray 函数在需要通过两个值相乘来确定分配大小时非常有用:

if ((newp = reallocarray(p, num, size)) == nullptr) {
  // `--snip--`

如果 num * size 会发生回绕,则调用 reallocarray 函数将失败,并返回空指针。

free

当不再需要时,可以使用free函数释放内存。释放内存允许该内存被重用,减少了内存耗尽的机会,并且通常提供了更高效的堆使用。

我们可以通过将指向该内存的指针传递给free函数来释放内存,该函数具有以下签名:

void free(void *ptr);

ptr值必须是之前调用malloc、aligned_alloc、calloc或realloc返回的值。CERT C 规则 MEM34-C,“仅释放动态分配的内存”,讨论了当值没有被返回时会发生什么。内存是有限资源,因此必须回收。

如果我们使用空指针参数调用free,什么也不会发生,free函数只是返回:

 char *ptr = nullptr;
  free(ptr);

另一方面,重复释放同一指针是一个严重的错误。

free_sized

C23 引入了两个新的内存释放函数。free_sized函数具有以下签名:

void free_sized(void *ptr, size_t size);

如果ptr是空指针,或者是通过调用malloc、realloc或calloc获得的结果,其中size等于请求的分配大小,则此函数的行为与free(ptr)相同。不能将aligned_alloc的结果传递给此函数;必须使用free_aligned_sized函数(将在下一节中描述)。通过提醒分配器该分配的大小,您可以减少释放成本,并允许额外的安全性强化功能。但是,如果指定的大小不正确,则行为是未定义的。

使用free_sized函数,我们可以提高以下代码的性能和安全性。

void *buf = malloc(size);
use(buf, size);
free(buf);

通过将其重写为:

void *buf = malloc(size);
use(buf, size);
free_sized(buf, size);

当保留分配的大小或能够廉价地重新创建时,这是可行且实用的。

free_aligned_sized

C23 引入的两个新的内存释放函数中的第二个是 free_aligned_sized 函数。free_aligned_sized 函数具有以下签名:

void free_aligned_sized(void *ptr, size_t alignment, size_t size);

如果 ptr 是一个空指针,或者是通过调用 aligned_alloc 得到的结果,其中 alignment 等于请求的分配对齐,且 size 等于请求的分配大小,那么此函数等同于 free(ptr)。否则,行为是未定义的。换句话说,只有在释放显式对齐的内存时,才能使用此函数。

使用 free_aligned_sized 函数,我们可以通过以下方式提高以下代码的性能和安全性:

void *aligned_buf = aligned_alloc(alignment, size);
use_aligned(buf, size, alignment);
free(buf);

通过将其重写为:

void *aligned_buf = aligned_alloc(size, alignment);
use_aligned(buf, size, alignment);
free_aligned_sized(buf, alignment, size);

当分配的对齐和大小得以保留或可以低成本重新创建时,这种做法是可行且实用的。

处理悬空指针

如果你对同一个指针调用了多次 free 函数,会导致未定义的行为。这些缺陷可能会导致一个被称为 双重释放漏洞 的安全漏洞。一个后果是,它们可能被利用来以易受攻击进程的权限执行任意代码。双重释放漏洞的完整影响超出了本书的范围,但我在《C 和 C++中的安全编码》(Seacord 2013)中详细讨论了它们。双重释放漏洞在错误处理代码中尤其常见,因为程序员在尝试释放已分配资源时可能会遇到这些问题。

另一个常见的错误是访问已释放的内存。这种类型的错误通常不易察觉,因为代码可能看起来正常,但在实际错误发生时,它可能以一种意外的方式失败。在列表 6-4 中,取自一个实际应用,传递给 close 的参数无效,因为第二次调用 free 已经回收了之前由 dirp 指向的存储。

#include <dirent.h>
#include <stdlib.h>
#include <unistd.h>

int closedir(DIR *dirp) {
  free(dirp->d_buf);
  free(dirp);
  return close(dirp->d_fd);  // dirp has already been freed
}

列表 6-4:访问已释放的内存

我们称指向已释放内存的指针为 悬空指针。悬空指针是潜在的错误来源(就像地板上的香蕉皮)。每次使用悬空指针(不仅仅是解引用)都是未定义行为。当悬空指针用于访问已释放的内存时,可能会导致“释放后使用”漏洞(CWE 416)。当悬空指针传递给 free 函数时,可能会导致“双重释放”漏洞(CWE 415)。有关这些主题的更多信息,请参见 CERT C 规则 MEM30-C,“不要访问已释放的内存”。

将指针设置为 Null

为了减少悬空指针带来的缺陷风险,在完成对 free 函数的调用后,将指针设置为 nullptr:

char *ptr = malloc(16);
// `--snip--`
free(ptr);
ptr = nullptr;

任何后续尝试解引用指针通常会导致崩溃,从而增加在实施和测试过程中检测到错误的可能性。如果指针被设置为 nullptr,内存可以多次释放而不会产生后果。不幸的是,free 函数无法将指针设置为 nullptr,因为它接收到的是指针的副本,而不是实际的指针。

内存状态

动态分配的内存可以处于以下三种状态之一,如图 6-1 所示:内存管理器中未分配且未初始化、已分配但未初始化、已分配且已初始化。对 malloc 和 free 函数的调用,以及写入内存,会导致内存在这些状态之间转换。

图 6-1:内存状态

根据内存的状态,不同的操作是有效的。避免对未显示为有效或明确列为无效的内存执行任何操作。在此代码片段中执行 memset 函数后,

char *p = malloc(100);
memset(p, 0, 50);

前 50 个字节被分配并初始化,而最后 50 个字节被分配但未初始化。已初始化的字节可以读取,但未初始化的字节不得读取。

灵活数组成员

在 C 语言中,为包含数组的结构分配存储一直是一个有些棘手的问题。如果数组的元素数量是固定的,那么问题不大,因为结构的大小可以很容易地确定。然而,开发人员经常需要声明一个数组,其大小在运行时才会确定,而 C 最初并没有提供一种直接的方式来实现这一点。

灵活数组成员让你能够声明并为具有任意数量固定成员的结构分配存储空间,其中最后一个成员是一个大小未知的数组。从 C99 开始,具有多个成员的 struct 的最后一个成员可以是 不完整的数组类型,这意味着数组的大小未知,可以在运行时指定。灵活数组成员允许你访问一个可变长度的对象。

例如,清单 6-5 展示了在 widget 中使用灵活数组成员 data。我们通过调用 malloc 函数动态分配该对象的存储空间。

#include <stdlib.h>

constexpr size_t max_elem = 100;

typedef struct {
  size_t num;
❶ int data[];
} widget;

widget *alloc_widget(size_t num_elem) {
  if (num_elem > max_elem) return nullptr;
❷ widget *p = (widget *)malloc(sizeof(widget) + sizeof(int) * num_elem);
  if (p == nullptr) return nullptr;

  p->num = num_elem;
  for (size_t i = 0; i < p->num; ++i) {
  ❸ p->data[i] = 17;
  }
  return p;
}

清单 6-5: 灵活数组成员

我们首先声明一个 struct,其最后一个成员,即 data 数组 ❶,是一个不完整类型(没有指定大小)。然后,我们为整个 struct ❷ 分配存储空间。当使用 sizeof 运算符计算包含灵活数组成员的 struct 的大小时,灵活数组成员会被忽略。因此,在分配存储时,我们必须明确地为灵活数组成员指定适当的大小。为此,我们通过将数组元素的数量(num_elem)乘以每个元素的大小(sizeof(int))来为数组分配额外的字节。该程序假定 num_elem 的值是合适的,以至于当与 sizeof(int) 相乘时,不会发生溢出。

我们可以使用 . 或 -> 运算符 ❸ 访问这个存储空间,就像存储空间已经分配为 data[num_elem] 一样。有关分配和复制包含灵活数组成员的结构的更多信息,请参见 CERT C 规则 MEM33-C,“动态分配并复制包含灵活数组成员的结构”。

在 C99 之前,多个编译器支持一种类似的“`struct hack”方法,采用不同的语法。CERT C 规则 DCL38-C,“声明灵活数组成员时使用正确的语法”,提醒开发者使用 C99 及更高版本 C 标准中指定的语法。

其他动态分配的存储

C 语言除了支持动态分配存储的内存管理函数之外,还具有语言和库特性。这些存储通常在调用者的栈帧中分配(C 标准并未定义栈,但栈是常见的实现特性)。 是一种后进先出(LIFO)的数据结构,支持在运行时的函数嵌套调用。每次函数调用都会创建一个栈帧,其中存储了局部变量(自动存储持续时间)和与该函数调用相关的其他数据。

alloca

出于性能原因,alloca(一个由某些实现支持的非标准函数)允许在运行时从栈中动态分配内存,而不是从堆中分配。这块内存会在调用 alloca 的函数返回时自动释放。alloca 函数是一个内建(或内置)函数,专门由编译器处理。这使得编译器能够用一系列自动生成的指令替代原始的函数调用。例如,在 x86 架构下,编译器用一条指令来调整栈指针,以容纳额外的存储空间,从而替代对 alloca 的调用。

alloca 函数起源于贝尔实验室的早期 Unix 操作系统版本,但并未由 C 标准库或 POSIX 定义。清单 6-6 显示了一个名为 printerr 的示例函数,该函数使用 alloca 函数在打印错误信息到 stderr 之前分配存储空间。

void printerr(errno_t errnum) {
❶ rsize_t size = strerrorlen_s(errnum) + 1;
❷ **char *msg = (char *)alloca(size);**
  if (❸ strerror_s(msg, size, errnum) != 0) {
   ❹ fputs(msg, stderr);
  }
  else {
   ❺ fputs("unknown error", stderr);
  }
}

清单 6-6: printerr 函数

printerr 函数接受一个参数,errnum,类型为 errno_t。我们调用 strerrorlen_s 函数❶来确定与该错误号关联的错误字符串的长度。一旦我们知道了需要分配的数组大小来存储错误字符串,我们可以调用 alloca 函数❷来高效地为该数组分配存储空间。然后,我们通过调用 strerror_s 函数❸来检索错误字符串,并将结果存储在新分配的存储空间 msg 引用中。假设 strerror_s 函数成功执行,我们输出错误信息❹;否则,我们输出 unknown error❺。这个 printerr 函数是为了演示 alloca 的使用,写得比实际需要的要复杂。

alloca 函数的使用可能比较棘手。首先,调用 alloca 可能会导致分配超过堆栈的边界。然而,alloca 函数不会返回空指针值,因此无法检查错误。由于这个原因,避免使用 alloca 进行大规模或不定界的内存分配至关重要。本文中的 strerrorlen_s 调用应该返回一个合理的分配大小。

alloca 函数的另一个问题是,程序员可能会因需要释放由 malloc 分配的内存而非由 alloca 分配的内存而感到困惑。在一个没有通过 aligned_alloccallocreallocmalloc 调用获得的指针上调用 free 是一个严重的错误。由于这些问题,建议避免使用 alloca

GCC 和 Clang 都提供了一个 -Walloca 编译器标志,用于诊断所有对 alloca 函数的调用。GCC 还提供了一个 -Walloca-larger-than=size 编译器标志,用于诊断任何对 alloca 函数的调用,当请求的内存大于 size 时。

可变长度数组(VLA)

可变长度数组(VLA)是在 C99 中引入的。VLA 是一种变体类型的对象(见第二章)。VLA 的存储空间在运行时分配,其大小等于变体类型的基本类型的大小乘以运行时的大小。

数组的大小在创建后不能修改。所有 VLA 声明必须在 块作用域 内。

以下示例声明了一个大小为 size 的 VLA vla,作为函数 func 中的自动变量:

void func(size_t size) {
  int vla[size];
  // `--snip--`
}

当你在运行时才知道数组中元素的数量时,VLA 非常有用。与 alloca 函数不同,VLA 会在对应的代码块结束时自动释放,就像其他任何自动变量一样。列表 6-7 将 列表 6-6 中 printerr 函数对 alloca 的调用替换为 VLA。此更改仅修改了一行代码(如粗体所示)。

void print_error(int errnum) {
  size_t size = strerrorlen_s(errnum) + 1;
 **char msg[size];**
  if (strerror_s(msg, size, errnum) != 0) {
    fputs(msg, stderr);
  }
  else {
    fputs("unknown error", stderr);
  }
}

列表 6-7:重新编写的 print_error 函数,使用了 VLA

使用 VLA 代替 alloca 函数的主要优势在于语法与程序员对具有自动存储持续时间的数组工作的理解模型相匹配。VLA 的工作方式与自动变量相同(因为它们就是自动变量)。另一个优势是,迭代时内存不会积累(而使用 alloca 时,内存可能会在函数结束时释放,但也可能发生意外积累)。

VLAs 共享与 alloca 函数相似的问题,因为它们可能会尝试进行超出栈空间的分配。不幸的是,目前没有一种可移植的方式来确定剩余的栈空间以检测此类错误。此外,当你提供的大小乘以每个元素的大小时,数组大小的计算可能会发生溢出。因此,在声明数组之前验证其大小非常重要,以避免分配过大或大小错误。特别是在处理不可信输入或递归调用的函数时,这一点尤为重要,因为每次递归都会为函数(包括这些数组)创建一组全新的自动变量。在进行任何分配之前,必须验证不可信的输入,包括堆上的分配。

你应该在最坏情况下(最大分配和深度递归)判断是否有足够的栈空间。在某些实现中,还可以传递负值作为 VLA 的大小,因此请确保你的大小表示为 size_t 或其他无符号类型。有关更多信息,请参阅 CERT C 规则 ARR32-C,"确保变量长度数组的大小参数在有效范围内"。与使用最坏情况的固定大小数组相比,VLAs 减少了栈的使用。

以下文件作用域的声明展示了 VLAs 另一个令人困惑的方面:

static const unsigned int num_elem = 12;
double array[num_elem];

这段代码有效吗?如果 array 是一个 VLA,那么代码无效,因为声明是在文件作用域中。如果 array 是一个常量大小的数组,则代码有效。目前,GCC 会拒绝此示例,因为 array 是一个 VLA。然而,C23 允许实现扩展整数常量表达式的定义,而 Clang 通过将 array 变为常量大小的数组来实现这一点。

我们可以使用 constexpr 重写这些声明,使其在所有符合 C23 标准的实现中可移植:

constexpr unsigned int num_elem = 12;
double array[num_elem];

最后,当对 VLA 调用 sizeof 时,会发生另一个有趣且出乎意料的行为。编译器通常在编译时执行 sizeof 操作。然而,如果表达式改变了数组的大小,它将在运行时进行评估,包括任何副作用。typedef 也是如此,正如 清单 6-8 中的程序所示。

#include <stdio.h>
#include <stdlib.h>

int main() {
  size_t size = 12;
  (void)(sizeof(size++));
  printf("%zu\n", size); // prints 12
  (void)sizeof(int[size++]);
  printf("%zu\n", size); // prints 13
  typedef int foo[size++];
  printf("%zu\n", size); // prints 14
  typeof(int[size++]) f;
  printf("%zu\n", size); // prints 15
  return EXIT_SUCCESS;
}

清单 6-8:意外的副作用

在这个简单的测试程序中,我们声明了一个类型为 size_t 的变量 size 并将其初始化为 12。在 sizeof(size++) 中,操作数不会被求值,因为操作数的类型不是 VLA。因此,size 的值不会发生变化。接着,我们使用 sizeof 操作符,并将 int[size++] 作为参数传递。因为这个表达式改变了数组的大小,size 被递增,现在等于 13。typedef 同样将 size 的值递增至 14。最后,我们声明 f 为 typeof(int[size++]) 类型,这进一步递增了 size。由于这些行为并不十分明确,因此避免在使用 typeof 操作符、sizeof 操作符和 typedef 时使用带有副作用的表达式,以提高可理解性。

调试分配的存储问题

正如本章前面提到的,不当的内存管理可能导致错误,如内存泄漏、读取或写入已释放的内存、以及重复释放内存。避免这些问题的一种方法是在调用 free 后将指针设置为 null 指针值,正如我们之前讨论的那样。另一种策略是尽量简化动态内存管理。例如,你应该在同一个模块内、在相同的抽象层次上分配和释放内存,而不是在子程序中释放内存,这会导致关于何时、何地以及是否释放内存的困惑。

第三种选择是使用 动态分析工具,如 AddressSanitizer、Valgrind 或 dmalloc 来检测和报告内存错误。AddressSanitizer 以及调试、测试和分析的一般方法在 第十一章 中讨论,而 dmalloc 则在本节中介绍。如果你的环境支持,AddressSanitizer 或 Valgrind 是有效的工具,也是更好的选择。

dmalloc

调试内存分配(dmalloc)库是 Gray Watson 创建的,它替代了 malloc、realloc、calloc、free 以及其他内存管理功能,使用提供调试功能的例程,你可以在运行时配置这些功能。该库已在多种平台上测试。

按照 <wbr>dmalloc<wbr>.com 提供的安装说明配置、构建并安装库。清单 6-9 包含一个简短的程序,打印出使用信息并退出(它通常是更长程序的一部分)。该程序包含几个故意的错误和漏洞。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

**#ifdef DMALLOC**
**#include "dmalloc.h"**
**#endif**

void usage(char *msg) {
  fprintf(stderr, "%s", msg);
  free(msg);
  return;
}

int main(int argc, char *argv[]) {
 if (argc != 3 && argc != 4) {
    // the error message is less than 80 chars
    char *errmsg = (char *)malloc(80);
    sprintf(
      errmsg,
      "Sorry %s,\nUsage: caesar secret_file keys_file [output_file]\n",
      getenv("USER")
    );
    usage(errmsg);
    free(errmsg);
    return EXIT_FAILURE;
  }
  // `--snip--`

  return EXIT_SUCCESS;
}

清单 6-9:使用 dmalloc 捕获内存错误

glibc 的最新版本将检测到此程序中的至少一个漏洞:

Sorry (null),
Usage: caesar secret_file keys_file [output_file]
free(): double free detected in tcache 2
Program terminated with signal: SIGSEGV

修复此错误后,加入加粗字体所示的行,以便 dmalloc 报告导致问题的文件和行号。

我稍后会展示输出结果,但我们首先需要讨论一些事情。dmalloc 分发包还包含一个命令行工具。你可以运行以下命令,获取有关如何使用该工具的更多信息:

% **dmalloc --usage**

在使用 dmalloc 调试程序之前,请在命令行中输入以下内容:

% **dmalloc -l logfile -i 100 low**

此命令将日志文件名设置为 logfile,并指示库在 100 次调用后进行检查,正如 -i 参数所指定的那样。如果你指定更大的数字作为 -i 参数,dmalloc 将减少堆检查的频率,你的代码将运行得更快;较低的数字更有可能捕获内存问题。第三个参数启用了 低 数量的调试功能。其他选项包括 runtime 用于最小检查,或 medium 或 high 用于更广泛的堆验证。

执行此命令后,我们可以使用 GCC 按如下方式编译程序:

% **gcc -DDMALLOC caesar.c -ocaesar -ldmalloc**

当你运行程序时,应该会看到以下错误:

% **./caesar**
Sorry student,
Usage: caesar secret_file keys_file [output_file]
debug-malloc library: dumping program, fatal error
  Error: tried to free previously freed pointer (err 61)
Aborted (core dumped)

如果你检查日志文件,你会找到以下信息:

% **more logfile**
1571549757: 3: Dmalloc version '5.5.2' from 'https://dmalloc.com/'
1571549757: 3: flags = 0x4e48503, logfile 'logfile'
1571549757: 3: interval = 100, addr = 0, seen # = 0, limit = 0
1571549757: 3: starting time = 1571549757
1571549757: 3: process pid = 29531
1571549757: 3:   error details: finding address in heap
1571549757: 3:   pointer '0x7ff010812f88' from 'caesar.c:29' prev access 'unknown'
1571549757: 3: ERROR: free: tried to free previously freed pointer (err 61)

这些消息表明,我们试图两次释放由 errmsg 引用的存储,第一次在 usage 函数中,第二次在 main 函数中,这构成了双重释放漏洞。当然,这只是 dmalloc 可以检测到的错误类型之一,简单程序中还有其他缺陷。

安全关键系统

安全要求高的系统通常禁止使用动态内存,因为内存管理器可能具有不可预测的行为,显著影响性能。强制所有应用程序都在固定的、预分配的内存区域内运行,可以消除这些问题,并使得内存使用的验证变得更容易。在没有递归、alloca 和变长数组(VLA,安全关键系统中也禁止使用)情况下,栈内存的使用上限可以静态推导,从而证明在所有可能的输入下都能提供足够的存储来执行应用程序功能。

GCC 和 Clang 都有一个 -Wvla 标志,当使用变长数组(VLA)时会发出警告。GCC 还有一个 -Wvla-larger-than=字节大小 标志,警告声明那些大小不受限制或通过允许数组大小超过 字节大小 字节的参数限定的 VLA。

总结

在这一章中,你了解了如何处理具有分配存储期的内存,以及这与自动存储期或静态存储期的对象有何不同。我们描述了堆和内存管理器以及每个标准内存管理函数。我们识别了使用动态内存时常见的一些错误原因,例如内存泄漏和双重释放漏洞,并介绍了一些缓解措施,帮助避免这些问题。

我们还涵盖了一些更专业的内存分配主题,例如灵活数组成员、alloca 函数和变长数组(VLA)。我们在本章末尾讨论了通过使用 dmalloc 库来调试分配存储问题。

在下一章,你将学习字符和字符串。

第七章:7 字符与字符串

字符串是一个非常重要且有用的数据类型,几乎所有的编程语言都会以某种形式实现它。字符串通常用来表示文本,是用户与程序之间交换数据的大多数内容,包括文本输入字段、命令行参数、环境变量和控制台输入。

在 C 语言中,字符串数据类型是基于正式字符串的概念建模的(Hopcroft 和 Ullman 1979):

设 Σ 为一个非空有限字符集,称为字母表。 Σ 上的字符串是 Σ 中任何有限字符序列。例如,如果 Σ = {0, 1},那么 01011 就是 Σ 上的一个字符串。

在本章中,我们将讨论可以用来组成字符串的各种字符集,包括 ASCII 和 Unicode(来自正式定义的字母表)。我们将介绍如何使用 C 标准库中的传统函数、边界检查接口以及 POSIX 和 Windows 应用程序编程接口(API)来表示和操作字符串。

字符

人们用来沟通的字符并不是数字系统本能理解的,因为数字系统是基于位操作的。为了处理字符,数字系统使用字符编码,将唯一的整数值(称为码点)分配给特定的字符。如你所见,有多种方法可以在程序中对相同的字符进行编码。C 语言实现中常用的字符编码标准包括 Unicode、ASCII、扩展 ASCII、ISO 8859-1(拉丁文-1)、Shift-JIS 和 EBCDIC。

注意

C 标准明确引用了 Unicode 和 ASCII。

ASCII

7 位美国信息交换标准代码,更为人熟知的7 位 ASCII,指定了一组 128 个字符及其编码表示(ANSI X3.4-1986)。从 0x00 到 0x1f 的字符和字符 0x7f 是控制字符,例如空字符、退格符、水平制表符和删除符。 从 0x20 到 0x7e 的字符是所有可打印字符,如字母、数字和符号。

我们通常使用更新后的名称US-ASCII来指代这一标准,以明确该系统是在美国开发的,并且专注于该国主要使用的印刷符号。大多数现代字符编码方案都基于 US-ASCII,尽管它们支持许多附加字符。

0x80 到 0xFF 范围内的字符不是 US-ASCII 定义的字符,而是属于被称为 扩展 ASCII 的 8 位字符编码。这些范围有多个编码方式,实际的映射取决于代码页。代码页 是一种字符编码,它将一组可打印字符和控制字符映射到唯一的数字。

Unicode

Unicode 已成为计算机处理文本的通用字符编码标准。它支持的字符范围远大于 ASCII;当前的 Unicode 标准(Unicode 2023)对字符进行了编码,范围从 U+0000 到 U+10FFFF,这相当于一个 21 位的编码空间。单个 Unicode 值以 U+ 开头,后跟四个或更多十六进制数字,表示为印刷文本。Unicode 字符 U+0000 到 U+007F 与 US-ASCII 中的字符完全相同,而 U+0000 到 U+00FF 的范围与 ISO 8859-1 相同,包含了拉丁字母字符,这些字符在美洲、西欧、大洋洲以及非洲的大部分地区被使用。

Unicode 将代码点组织成 平面,即连续的 65,536 个代码点的组。共有 17 个平面,编号从 0 到 16。最常用的字符,包括那些出现在主要旧编码标准中的字符,都被放置在第一个平面(0x0000 到 0xFFFF)中,这个平面被称为 基本多语言平面(BMP) 或平面 0。

Unicode 还指定了几种 Unicode 转换格式(UTFs),这些是将每个 Unicode 标量值分配给唯一代码单元序列的字符编码格式。Unicode 标量值 是指除高代理项和低代理项外的任何 Unicode 代码点。(代理对将在本节稍后解释。)代码单元 是表示用于处理或交换的编码文本的最小位组合。Unicode 标准定义了三种 UTF,以允许不同大小的代码单元:

UTF-8 将每个字符表示为一个到四个 8 位代码单元的序列

UTF-16 将每个字符表示为一个或两个 16 位代码单元的序列

UTF-32 将每个字符表示为一个单一的 32 位代码单元

UTF-8 编码是 POSIX 操作系统中的主流编码。它具有以下优点:

  • 它将 US-ASCII 字符(U+0000 到 U+007F)编码为范围从 0x00 到 0x7F 的单字节。这意味着只包含 7 位 ASCII 字符的文件和字符串在 ASCII 和 UTF-8 下具有相同的编码方式。

  • 使用空字节终止字符串(我们稍后会讨论这个话题)与 ASCII 字符串的处理方式相同。

  • 当前定义的所有 Unicode 码点可以使用 1 到 4 个字节进行编码。

  • Unicode 通过扫描明确的比特模式,在任意方向上轻松识别字符边界。

在 Windows 上,你可以使用 Visual C++ 的 /utf8 标志编译并链接程序,以将源字符集和执行字符集设置为 UTF-8。你还需要配置 Windows 以使用 Unicode UTF-8 来支持全球语言。

UTF-16 目前是 Windows 操作系统中主流的编码方式。与 UTF-8 类似,UTF-16 是一种可变宽度编码。正如前面提到的,基本多文种平面(BMP)包含从 U+0000 到 U+FFFF 的字符。码点大于 U+FFFF 的字符被称为 补充字符。补充字符由一对称为 替代符 的编码单元定义。第一个编码单元位于高替代符范围(U+D800 到 U+DBFF),第二个编码单元位于低替代符范围(U+DC00 到 U+DFFF)。

与其他可变长度 UTF 编码不同,UTF-32 是一种固定长度的编码方式。UTF-32 的主要优点是 Unicode 码点可以直接索引,这意味着你可以在常数时间内找到序列中第 n 个码点。相比之下,可变长度编码需要访问每个码点,才能找到序列中的第 n 个码点。

源字符集和执行字符集

在 C 语言最初标准化时,并没有一种被普遍接受的字符编码方式,因此它被设计成能够与多种字符表示方式兼容。与 Java 不同,C 的每个实现都定义了 源字符集(即源文件编写时使用的字符集)和 执行字符集(即编译时使用的字符和字符串字面值的字符集)。

源字符集和执行字符集必须包含拉丁字母的大写和小写字母的编码;10 个十进制数字;29 个图形字符;以及空格、水平制表符、垂直制表符、换页符和换行符。执行字符集还包括警告、退格、回车和空字符。

字符转换和分类函数(例如 isdigit)会在运行时进行评估,基于调用时生效的本地化编码。程序的 区域设置 定义了它的代码集、日期和时间格式约定、货币约定、十进制格式约定和排序顺序。

数据类型

C 定义了几种数据类型来表示字符数据,其中一些我们已经见过。C 提供了不带修饰的 char 类型来表示 窄字符(那些可以用最多 8 位表示的字符),以及 wchar_t 类型来表示 宽字符(那些可能需要超过 8 位表示的字符)。

char

正如我之前提到的,char 是一种整数类型,但每种实现会定义它是有符号还是无符号的。在可移植的代码中,你可以假设它既不是有符号也不是无符号的。

使用 char 类型表示字符数据(在这种情况下符号性无关紧要),而不是用于表示整数数据(在这种情况下符号性很重要)。你可以安全地使用 char 类型表示 7 位字符编码,例如 US-ASCII。对于这些编码,高位总是 0,因此当将 char 类型的值转换为 int 时,你无需担心符号扩展问题,因为实现将其定义为有符号类型。

你还可以使用 char 类型来表示 8 位字符编码,例如扩展 ASCII、ISO/IEC 8859、EBCDIC 和 UTF-8。这些 8 位字符编码在将 char 定义为 8 位有符号类型的实现上可能会出现问题。例如,下面的代码在检测到 EOF 时会打印字符串 end of file:

char c = 'ÿ';  // extended character
if (c == EOF) puts("end of file");

假设实现定义的执行字符集为 ISO/IEC 8859-1,带有分音符的拉丁小写字母 y(ÿ)被定义为表示 255 (0xFF)。对于将 char 定义为有符号类型的实现,c 将被符号扩展为 signed int 的宽度,这使得ÿ字符与 EOF 无法区分,因为它们具有相同的表示。

当使用在 <ctype.h> 中定义的字符分类函数时,会发生类似的问题。这些库函数将字符参数作为 int 或宏 EOF 的值来接受。如果字符属于该函数描述定义的字符集合,则返回非零值;如果字符不属于该集合,则返回零。例如,isdigit 函数测试字符是否为当前区域设置中的十进制数字字符。任何不是有效字符或 EOF 的参数值都会导致未定义行为。

为了避免在调用这些函数时发生未定义行为,应在整数提升之前将 c 强制转换为 unsigned char,如下所示:

char c = 'ÿ';
if (isdigit((unsigned char)c)) {
  puts("c is a digit");
}

存储在 c 中的值会被零扩展到 signed int 的宽度,通过确保结果值能够作为 unsigned char 表示,从而消除未定义行为。请注意,将 c 初始化为 'ÿ' 可能会导致警告或错误。

int

对于可能是 EOF(一个负值)或作为 unsigned char 解释后再转换为 int 的字符数据,应使用 int 类型。从流中读取字符数据的函数,如 fgetc、getc 和 getchar,返回 int 类型。正如我们所见,来自 <ctype.h> 的字符处理函数也接受这种类型,因为它们可能会接收到 fgetc 或相关函数的结果。

wchar_t

wchar_t 类型是 C 语言中为了处理大字符集的字符而新增的整数类型。它可以是有符号或无符号整数类型,具体取决于实现,并且具有实现定义的包含范围,从 WCHAR_MIN 到 WCHAR_MAX。大多数实现将 wchar_t 定义为 16 位或 32 位无符号整数类型,但不支持本地化的实现可能将 wchar_t 定义为与 char 相同的宽度。C 语言不允许使用可变长度编码来表示宽字符串(尽管在 Windows 上实际使用 UTF-16 是这种方式)。实现可以有条件地将宏 STDC_ISO_10646 定义为整数常量,形式为 yyyymmL(例如,199712L),表示 wchar_t 类型用于表示对应于指定版本标准的 Unicode 字符。选择 16 位类型的实现无法满足定义 STDC_ISO_10646 的要求,特别是对于比 Unicode 3.1 更高版本的 ISO/IEC 10646。结果,定义 STDC_ISO_10646 的要求是要么使用大于 20 位的 wchar_t 类型,要么使用 16 位 wchar_t 类型和一个 STDC_ISO_10646 的值,且该值早于 200103L。wchar_t 类型还可以用于表示 Unicode 以外的编码,例如宽 EBCDIC。

使用 wchar_t 编写可移植代码可能会很困难,因为实现定义的行为范围很广。例如,Windows 使用 16 位无符号整数类型,而 Linux 通常使用 32 位无符号整数类型。计算宽字符字符串的长度和大小的代码容易出错,必须小心执行。

char16_t 和 char32_t

其他语言(包括 Ada95、Java、TCL、Perl、Python 和 C#)都具有用于表示 Unicode 字符的数据类型。C11 引入了 16 位和 32 位字符数据类型 char16_t 和 char32_t,这些类型在 <uchar.h> 中声明,用于分别表示 UTF-16 和 UTF-32 编码。C 不为这些新数据类型提供标准库函数,除了少数字符转换函数。没有库函数的支持,这些类型的用途非常有限。

C 定义了两个环境宏,用于指示这些类型中表示的字符是如何编码的。如果环境宏 STDC_UTF_16 的值为 1,则 char16_t 类型的值采用 UTF-16 编码。如果环境宏 STDC_UTF_32 的值为 1,则 char32_t 类型的值采用 UTF-32 编码。如果宏未定义,则使用另一个实现定义的编码方式。Visual C++ 不定义这些宏,表明在 Windows 上不能使用 char16_t 类型表示 UTF-16。

字符常量

C 允许你指定字符常量,也叫做字符字面量,它们是由一个或多个字符组成,并用单引号括起来,例如 'ÿ'。字符常量允许你在程序的源代码中指定字符值。表 7-1 展示了可以在 C 中指定的字符常量类型。

表 7-1: 字符常量类型

前缀 类型
unsigned char
u8'a' char8_t
L'a' 对应的无符号类型 wchar_t
u'a' char16_t
U'a' char32_t

包含多个字符的字符常量(例如,'ab')的值是由实现定义的。不能用单一代码单元表示的源字符的值也是如此。早前提到的例子 'ÿ' 就是一个这样的情况。如果执行字符集是 UTF-8,则该值可能是 0xC3BF,以反映表示 U+00FF 代码点值所需的两个代码单元的 UTF-8 编码。C23 为字符字面量添加了 u8 前缀,以表示 UTF-8 编码。UTF-8、UTF-16 或 UTF-32 字符常量不能包含多个字符。该值必须能够使用单一的 UTF-8、UTF-16 或 UTF-32 代码单元表示。因为 UTF-8 将 US-ASCII 字符(U+0000 到 U+007F)作为范围在 0x00 到 0x7F 之间的单字节表示,所以即使在字符编码依赖于本地环境的实现中(例如 EBCDIC 编码),也可以使用 u8 前缀来创建 ASCII 字符。

转义序列

单引号(')和反斜杠(\)具有特殊含义,因此不能直接表示为字符。相反,为了表示单引号,我们使用转义序列 ',为了表示反斜杠,我们使用 \。我们还可以使用转义序列表示其他字符,例如问号(?)以及任意整数值,具体请参见表 7-2。

表 7-2: 转义序列

字符 转义序列
单引号 '
双引号 "
问号 ?
反斜杠 \
警告 \a
退格符 \b
换页符 \f
换行符 \n
回车符 \r
水平制表符 \t
垂直制表符 \v
八进制字符 <最多三位八进制数字>
十六进制字符 <十六进制数字>

以下是通过转义序列表示的非图形字符,这些转义序列由反斜杠后跟小写字母组成:\a(警告),\b(退格符),\f(换页符),\n(换行符),\r(回车符),\t(水平制表符)和\v(垂直制表符)。

八进制数字可以被嵌入到八进制转义序列中,用于构建一个字符常量或一个宽字符常量。八进制整数的数值指定所需字符或宽字符的值。反斜杠后跟数字总是被解释为八进制值。 例如,你可以将退格符(十进制为 8)表示为八进制值\10,或者等效地表示为\010。

你还可以结合 \x 后的十六进制数字来构造字符常量或宽字符常量。十六进制整数的数值形成所需字符或宽字符的值。例如,你可以将退格符表示为十六进制值 \x8 或等效的 \x08。

Linux

字符编码在不同操作系统上的演变有所不同。在 UTF-8 出现之前,Linux 通常依赖于各种语言特定的 ASCII 扩展。最流行的包括欧洲的 ISO 8859-1 和 ISO 8859-2,希腊的 ISO 8859-7,俄罗斯的 KOI-8/ISO 8859-5/CP1251,日本的 EUC 和 Shift-JIS,以及台湾的 BIG5。Linux 发行版和应用程序开发者正在逐步淘汰这些旧的遗留编码,转而使用 UTF-8 来表示本地化的文本字符串(Kuhn 1999)。

GCC 有几个标志可以让你配置字符集。以下是一些你可能会觉得有用的标志:

-fexec-charset=`charset`

-fexec-charset 标志设置用于解释字符串和字符常量的执行字符集。默认值是 UTF-8。charset 可以是系统的 iconv 库例程所支持的任何编码,稍后在本章中会介绍。例如,设置 -fexec-charset=IBM1047 会指示 GCC 按照 EBCDIC 编码页 1047 来解释源代码中硬编码的字符串常量,例如 printf 格式字符串。

要选择用于宽字符串和字符常量的宽执行字符集,可以使用 -fwide-exec-charset 标志:

-fwide-exec-charset=`charset`

默认值是 UTF-32 或 UTF-16,对应于 wchar_t 的宽度。

输入字符集默认与系统区域设置相同(如果系统区域设置未配置,则为 UTF-8)。要覆盖用于将输入文件的字符集转换为 GCC 使用的源字符集的输入字符集,可以使用 -finput-charset 标志:

-finput-charset=`charset`

Clang 有 -fexec-charset 和 -finput-charset,但没有 -fwide-exec-charset。Clang 只允许将 charset 设置为 UTF-8,并拒绝任何将其设置为其他值的尝试。

Windows

Windows 中对字符编码的支持经历了不规则的发展。为 Windows 开发的程序可以使用 Unicode 接口或依赖于区域设置的字符编码接口来处理字符编码。对于大多数现代应用程序,默认应选择 Unicode 接口,以确保在处理文本时应用程序的行为符合预期。通常,这段代码的性能会更好,因为传递给 Windows 库函数的窄字符字符串通常会被转换为 Unicode 字符串。

main 和 wmain 入口点

Visual C++ 支持两种程序入口点:main,它允许你传递窄字符参数,以及 wmain,它允许你传递宽字符参数。你可以使用与 main 相似的格式声明 wmain 的正式参数,如表 7-3 所示。

表 7-3: Windows 程序入口点声明

窄字符参数 宽字符参数
int main(); int wmain();
int main(int argc, char *argv[]); int wmain(int argc, wchar_t *argv[]);
int main(int argc, char *argv[],char *envp[]); int wmain(int argc, wchar_t *argv[],wchar_t *envp[]);

对于任意入口点,字符编码最终取决于调用进程。然而,根据惯例,main 函数接收其可选参数和环境作为指向当前 Windows(也称为 ANSI)代码页编码文本的指针,而 wmain 函数接收 UTF-16 编码的文本。

当你从命令提示符等 shell 运行程序时,shell 的命令解释器会将参数转换为该入口点所需的正确编码。Windows 进程以 UTF-16 编码的命令行开始。编译器/链接器发出的启动代码调用 CommandLineToArgvW 函数,将命令行转换为调用 main 所需的 argv 形式,或者直接将命令行参数传递给调用 wmain 所需的 argv 形式。在对 main 的调用中,结果会被重新编码为当前的 Windows 代码页,而该代码页可能因系统而异。对于当前 Windows 代码页中无法表示的字符,ASCII 字符 ? 会被替代。

Windows 控制台在向控制台写入数据时使用原始设备制造商(OEM)代码页。实际使用的编码因系统而异,但通常与 Windows 代码页不同。例如,在美国英语版 Windows 上,Windows 代码页可能是 Windows Latin 1,而 OEM 代码页可能是 DOS Latin US。一般而言,向 stdout 或 stderr 写入文本数据需要先将文本转换为 OEM 代码页,或者需要将控制台的输出代码页设置为与写入文本的编码相匹配。如果未这样做,可能会导致控制台输出意外的内容。然而,即使你仔细匹配了程序和控制台之间的字符编码,控制台仍然可能无法按预期显示字符,原因可能是其他因素,比如当前为控制台选择的字体没有适当的字形来表示这些字符。此外,历史上,Windows 控制台无法显示 Unicode 基本多文种平面(BMP)之外的字符,因为它仅为每个单元格的字符数据存储一个 16 位值。

窄字符与宽字符

Win32 软件开发工具包(SDK)中的所有系统 API 都有两个版本:一个是带有 A 后缀的窄字符 Windows(ANSI)版本,另一个是带有 W 后缀的宽字符版本:

int SomeFuncA(LPSTR SomeString);
int SomeFuncW(LPWSTR SomeString);

确定你的应用程序是否使用宽字符(UTF-16)或窄字符,并据此编写代码。最佳实践是显式调用每个函数的窄字符或宽字符版本,并传递适当类型的字符串:

SomeFuncW(L"String");
SomeFuncA("String");

来自 Win32 SDK 的实际函数示例包括MessageBoxA/MessageBoxW和CreateWindowExA/CreateWindowExW函数。

字符转换

尽管国际文本越来越多地采用 Unicode 编码,但它仍然采用语言或国家依赖的字符编码,因此需要在这些编码之间进行转换。Windows 仍然在具有传统、有限字符编码的区域设置中运行,例如 IBM EBCDIC 和 ISO 8859-1。程序在进行输入/输出(I/O)时,通常需要在 Unicode 和传统编码方案之间进行转换。

不能将所有字符串转换为每种语言或国家依赖的字符编码。当编码为 US-ASCII 时,这一点尤其明显,因为 US-ASCII 无法表示需要超过 7 位存储的字符。Latin-1 永远无法正确编码字符愛,而许多非日语的字母和词语也无法在不丢失信息的情况下转换为 Shift-JIS。

C 标准库

C 标准库提供了一些函数,用于在窄字符单元(char)和宽字符单元(wchar_t)之间进行转换。mbtowc(多字节到宽字符)、wctomb(宽字符到多字节)、mbrtowc(可重启多字节到宽字符)和wcrtomb(可重启宽字符到多字节)函数一次转换一个字符单元,并将结果写入输出对象或缓冲区。mbstowcs(多字节字符串到宽字符字符串)、wcstombs(宽字符字符串到多字节字符串)、mbsrtowcs(可重启多字节字符串到宽字符字符串)和wcsrtombs(可重启宽字符字符串到多字节字符串)函数一次转换一系列字符单元,并将结果写入输出缓冲区。

转换函数需要存储数据,以便在函数调用之间正确处理转换序列。不可重启的形式将状态存储在内部,因此不适合多线程处理。可重启的版本具有一个额外的参数,它是指向类型为 mbstate_t 的对象的指针,该对象描述了相关多字节字符序列的当前转换状态。此对象保存状态数据,使得在另一次调用该函数执行不相关的转换后,可以从中断的地方重新启动转换。字符串版本用于一次执行多个字符单元的批量转换。

这些函数有一些限制。如前所述,Windows 使用 16 位字符单元表示 wchar_t。这可能会成为问题,因为 C 标准要求 wchar_t 类型的对象能够表示当前区域设置中的所有字符,而 16 位字符单元可能太小,无法做到这一点。技术上讲,C 语言不允许你使用多个 wchar_t 类型的对象来表示单个字符。因此,标准转换函数可能会导致数据丢失。另一方面,大多数 POSIX 实现使用 32 位字符单元表示 wchar_t,允许使用 UTF-32。由于单个 UTF-32 字符单元可以表示一个完整的代码点,因此使用标准函数的转换不会丢失或截断数据。

C 标准委员会在 C11 中新增了以下函数,以解决使用标准转换函数时可能发生的数据丢失问题:

mbrtoc16, c16rtomb 在窄字符单元和一个或多个 char16_t 字符单元之间进行转换

mbrtoc32, c32rtomb 将窄字符单元的序列转换为一个或多个 char32_t 字符单元

前两个函数在区域设置相关的字符编码(以 char 数组表示)和 UTF-16 数据(存储在 char16_t 数组中)之间进行转换(假设 STDC_UTF_16 的值为 1)。后两个函数在区域设置相关的编码和存储在 char32_t 数组中的 UTF-32 数据之间进行转换(假设 STDC_UTF_32 的值为 1)。清单 7-1 中的程序使用 mbrtoc16 函数将 UTF-8 输入字符串转换为 UTF-16 编码字符串。

#include <locale.h>
#include <uchar.h>
#include <stdio.h>
#include <wchar.h>

static_assert(__STDC_UTF_16__ == 1, "UTF-16 is not supported"); ❶

size_t utf8_to_utf16(size_t utf8_size, const char utf8[utf8_size], char16_t *utf16) {
  size_t code, utf8_idx = 0, utf16_idx = 0;
  mbstate_t state = {0};
  while ((code = ❷ mbrtoc16(&utf16[utf16_idx],
    &utf8[utf8_idx], utf8_size - utf8_idx, &state))) {
    switch(code) {
    case (size_t)-1: // invalid code unit sequence detected
    case (size_t)-2: // code unit sequence missing elements
      return 0;
    case (size_t)-3: // high surrogate from a surrogate pair
      utf16_idx++;
      break;
    default:         // one value written
      utf16_idx++;
      utf8_idx += code;
    }
  }
  return utf16_idx + 1;
}

int main() {
  setlocale(LC_ALL, "es_MX.utf8"); ❸
  char utf8[] = u8"I ♥ 🌮 s!";
  char16_t utf16[sizeof(utf8)]; // UTF-16 requires less code units than UTF-8
  size_t output_size = utf8_to_utf16(sizeof(utf8), utf8, utf16);
  printf("%s\nConverted to %zu UTF-16 code units: [", utf8, output_size);
  for (size_t x = 0; x < output_size; ++x) {
    printf("%#x ", utf16[x]);
  }
  puts("]");
}

清单 7-1:将 UTF-8 字符串转换为 char16_t 字符串,使用 mbrtoc16 函数

我们调用 setlocale 函数 ❸,通过传递一个实现定义的字符串来将多字节字符编码设置为 UTF-8。静态断言 ❶ 确保宏 STDC_UTF_16 的值为 1。(有关静态断言的更多信息,请参阅 第十一章)。结果是,每次调用 mbrtoc16 函数时,都会将 UTF-8 表示的单个代码点转换为 UTF-16 表示。如果结果的 UTF-16 代码单元是高代理(来自代理对),则 state 对象会更新,指示下一次调用 mbrtoc16 将写出低代理,而无需考虑输入字符串。

mbrtoc16 函数没有字符串版本,因此我们会通过一个 UTF-8 输入字符串进行迭代,调用 mbrtoc16 函数 ❷ 将其转换为 UTF-16 字符串。如果发生编码错误,mbrtoc16 函数将返回 (size_t)-1,如果代码单元序列缺少元素,则返回 (size_t)-2。如果发生任何一种情况,循环将终止,转换结束。

返回值为 (size_t)-3 表示该函数已经输出了代理对的高代理字符,并将一个指示符存储在状态参数中。该指示符将在下次调用 mbrtoc16 函数时使用,以便输出代理对的低代理字符,从而形成一个完整的 char16_t 序列,代表一个单一的代码点。所有 C 标准中的可重启编码转换函数在使用状态参数时行为类似。

如果该函数返回的值不是 (size_t)-1、(size_t)-2 或 (size_t)-3,则 utf16_idx 索引将增加,utf8_idx 索引将根据函数读取的代码单元数量进行增加,字符串的转换将继续进行。

libiconv

GNU libiconv 是一个常用的跨平台开源库,用于执行字符串编码转换。它包含了 iconv_open 函数,该函数分配了一个转换描述符,您可以使用它将字节序列从一种字符编码转换到另一种。该函数的文档(<wbr>www<wbr>.gnu<wbr>.org<wbr>/software<wbr>/libiconv<wbr>/) 定义了可以用来标识特定 charset 的字符串,例如 ASCII、ISO−8859−1、SHIFT_JIS 或 UTF−8,用来表示与区域相关的字符编码。

Win32 转换 API

Win32 SDK 提供了两个用于在宽字符和窄字符字符串之间转换的函数:MultiByteToWideChar 和 WideCharToMultiByte。

MultiByteToWideChar 函数将用任意字符代码页编码的字符串数据映射到 UTF-16 字符串。同样,WideCharToMultiByte 函数将 UTF-16 编码的字符串数据映射到任意字符代码页。由于并非所有代码页都能表示 UTF-16 数据,因此该函数可以指定一个默认字符,用于替代任何无法转换的 UTF-16 字符。

字符串

C 不支持原始的字符串类型,可能永远也不会支持。相反,它通过字符数组实现字符串。C 有两种类型的字符串:窄字符串和宽字符串。

窄字符串 的类型是 char 数组。它由一系列连续的字符组成,包括终止的空字符。指向字符串的指针引用其初始字符。字符串的大小是分配给后备数组存储的字节数。字符串的长度是位于第一个空字符之前的代码单元(字节)数。在图 7-1 中,字符串的大小为 7,字符串的长度为 5。后备数组中超出最后一个元素的部分不得访问。未初始化的数组元素不得读取。

图 7-1:示例窄字符串

宽字符串 的类型是 wchar_t 数组。它由一系列连续的宽字符组成,包括终止的空宽字符。指向宽字符串的指针引用其初始宽字符。宽字符串的长度是位于第一个空宽字符之前的代码单元数。图 7-2 展示了 hello 的 UTF-16BE(大端)和 UTF-16LE(小端)表示形式。数组的大小由实现定义。此数组大小为 14 字节,假设实现使用 8 位字节和 16 位 wchar_t 类型。该字符串的长度为 5,因为字符数量没有变化。

图 7-2:示例 UTF-16BE 和 UTF-16LE 宽字符字符串

后备数组中超出最后一个元素的部分不得访问。未初始化的数组元素不得读取。

字符串字面量

字符字符串字面量 是由零个或多个多字节字符组成的字符串常量,字符用双引号括起来——例如 "ABC"。你可以使用各种前缀来声明不同字符类型的字符串字面量:

  • char 字符串字面量类型,例如 "ABC"

  • wchar_t 字符串字面量类型,带有 L 前缀,例如 L"ABC"

  • 带有 u8 前缀的 UTF-8 字符串字面量类型,例如 u8"ABC"

  • char16_t字符串文字类型,带有u前缀,例如u"ABC"

  • char32_t字符串文字类型,带有U前缀,例如U"ABC"

C 标准并没有强制实现使用 ASCII 编码的字符串文字。然而,你可以使用u8前缀强制字符串文字使用 UTF-8 编码。如果字符串中的所有字符都是 ASCII 字符,编译器会生成 ASCII 字符串文字,即使实现通常会用其他编码(例如 EBCDIC)来编码字符串文字。

字符串文字具有非const数组类型。修改字符串文字是未定义的行为,并且被 CERT C 规则 STR30-C 所禁止,规则内容为:“不要尝试修改字符串文字。”这是因为这些字符串文字可能存储在只读内存中,或者多个字符串文字可能共享相同的内存,从而导致在修改一个字符串时,多个字符串也会被改变。

字符串文字常常用来初始化数组变量,你可以通过显式声明一个数组的大小,确保其与字符串文字中的字符数匹配。考虑以下声明:

#define S_INIT "abc"
// `--snip--`
const char s[4] = S_INIT;

数组s的大小是四,这正是初始化数组为字符串文字所需的精确大小,包括末尾的空字符空间。

如果你向初始化数组的字符串文字中添加另一个字符,代码的意义会发生显著变化:

#define S_INIT "abc**d**"
// `--snip--`
const char s[4] = S_INIT;

数组s的大小仍然是四,尽管字符串文字的大小现在是五。因此,数组s被初始化为字符数组"abcd",并且末尾的空字符被省略了。根据设计,这种语法允许你初始化一个字符数组,而不是字符串。因此,编译器不太可能将此声明诊断为错误。

如果在维护过程中字符串文字发生变化,可能会存在风险,字符串可能无意间变为没有终止空字符的字符数组,特别是当字符串文字与声明分开定义时,就像这个例子中那样。如果你的目的是始终将s初始化为字符串,你应该省略数组的大小。如果不指定数组的大小,编译器将为整个字符串文字分配足够的空间,包括终止空字符:

const char s[] = S_INIT;

这种方法简化了维护,因为即使字符串字面量的大小发生变化,数组的大小也始终可以确定。

使用这种语法声明的数组大小可以通过使用 sizeof 操作符在编译时确定:

size_t size = sizeof(s);

如果我们改为如下声明这个字符串

const char *foo = S_INIT;

我们需要调用 strlen 函数来获取长度。

size_t length = strlen(foo) + 1U;

这可能会导致运行时开销,并且与大小不同。

字符串处理函数

管理 C 中字符串的几种方法,其中最常用的是 C 标准库函数。窄字符字符串处理函数在 <string.h> 头文件中定义,宽字符字符串处理函数在 <wchar.h> 中定义。这些遗留的字符串处理函数近年来已与各种安全漏洞相关联。这是因为它们没有检查数组的大小(通常缺少执行此类检查所需的信息),并且依赖于你提供足够大的字符数组来存储输出。虽然使用这些函数可以编写安全、健壮且无错误的代码,但它们促进了一种编程风格,这种风格如果结果过大以致无法容纳在提供的数组中,就可能导致缓冲区溢出。这些函数本身并不不安全,但容易被误用,需要小心使用(或者根本不使用)。

因此,C11 引入了规范性的(但可选的)附录 K 边界检查接口。该附录提供了替代库函数,旨在通过要求提供输出缓冲区的长度(例如),并验证这些缓冲区是否足够大以容纳来自这些函数的输出,从而促进更安全、更可靠的编程。例如,附录 K 定义了 strcpy_s、strcat_s、strncpy_s 和 strncat_s 函数,作为 C 标准库中 strcpy、strcat、strncpy 和 strncat 函数的替代品。

<string.h> 和 <wchar.h>

C 标准库包括一些众所周知的函数,例如 strcpy、strncpy、strcat、strncat、strlen 等,以及 memcpy 和 memmove 函数,用于分别复制和移动字符串。C 标准还提供了一个宽字符接口,操作的是 wchar_t 类型的对象,而不是 char 类型的对象。(这些函数名称与窄字符串函数名称类似,唯一的区别是 str 被替换为 wcs,并且内存函数名称前加了一个 w。)表 7-4 给出了一些窄字符和宽字符字符串函数的示例。有关如何使用这些函数的更多信息,请参考 C 标准(ISO/IEC 9899:2024)或手册页。

表 7-4: 窄字符和宽字符字符串函数

窄字符(char) 宽字符(wchar_t) 描述
strcpy wcscpy 字符串复制
strncpy wcsncpy 截断的、零填充的复制
memcpy wmemcpy 复制指定数量的不重叠代码单元
memmove wmemmove 复制指定数量的(可能重叠的)代码单元
strcat wcscat 连接字符串
strncat wcsncat 连接字符串并进行截断
strcmp wcscmp 比较字符串
strncmp wcsncmp 比较截断后的字符串
strchr wcschr 在字符串中定位字符
strcspn wcscspn 计算互补字符串段的长度
strdup wcsdup 将字符串复制到分配的存储空间中
strndup 截断副本到分配的存储空间
strpbrk wcspbrk 查找字符串中字符集的第一次出现
strrchr wcsrchr 查找字符串中字符的第一次出现
strspn wcsspn 计算字符串段的长度
strstr wcsstr 查找子字符串
strtok wcstok 字符串标记器(修改被标记的字符串)
memchr wmemchr 在内存中查找代码单元
strlen wcslen 计算字符串长度
memset wmemset 用指定的编码单元填充内存
memset_explicit N/A 类似于 memset 但始终执行

这些字符串处理函数被认为是高效的,因为它们将内存管理交给调用者,并且可以与静态和动态分配的存储一起使用。在接下来的几节中,我将更详细地介绍一些常用的函数。

注意

表 7-4 中列出的 wcsdup 函数不是 C 标准库函数,而是由 POSIX 定义的。

大小和长度

如本章前面所提到的,字符串有大小(即分配给后台数组存储的字节数)和长度。你可以通过使用 sizeof 运算符在编译时确定静态分配的后台数组的大小:

char str[100] = "Here comes the sun";
size_t str_size = sizeof(str); // str_size is 100

你可以使用 strlen 函数计算字符串的长度:

char str[100] = "Here comes the sun";
size_t str_len = strlen(str); // str_len is 18

wcslen 函数计算宽字符字符串的长度,以终止空宽字符之前的编码单元数为度量标准:

wchar_t str[100] = L"Here comes the sun";
size_t str_len = wcslen(str); // str_len is 18

长度是某个事物的计数,但究竟计数的是什么可能并不清晰。以下是一些在计算字符串长度时可能被计数的内容:

字节 在分配存储时很有用。

编码单元 表示字符串所使用的单独编码单元的数量。这个长度取决于编码,也可以用于分配内存。

代码点 代码点(字符)可能占用多个编码单元。这个值在分配存储时没有用处。

扩展字符簇 由一个或多个 Unicode 标量值组成,近似于一个用户感知的字符。许多单独的字符,如 é、김 和 ,可能由多个 Unicode 标量值构成。Unicode 的边界算法将这些代码点组合成扩展字符簇。

strlen 和 wcslen 函数计算代码单元。对于 strlen 函数,这对应于字节数。使用 wcslen 函数确定所需存储空间更为复杂,因为 wchar_t 类型的大小是由实现定义的。清单 7-2 包含了动态分配窄字符串和宽字符串存储空间的示例。

// narrow strings
char *str1 = "Here comes the sun";
char *str2 = malloc(strlen(str1) + 1);

// wide strings
wchar_t *wstr1 = L"Here comes the sun";
wchar_t *wstr2 = malloc((wcslen(wstr1) + 1) * sizeof(*wstr1));

清单 7-2:动态分配窄字符串和宽字符串函数的存储空间

对于窄字符串,我们可以通过将 1 加到 strlen 函数的返回值上,以考虑终止空字符的大小。对于宽字符串,我们可以通过将 1 加到 wcslen 函数的返回值上,以考虑终止宽空字符的大小,然后将总和乘以 wchar_t 类型的大小。因为 str1 和 wstr1 被声明为指针(而不是数组),所以不能使用 sizeof 运算符来获取它们的大小。

代码点或扩展字形集计数不能用于存储分配,因为它们包含不可预测数量的代码单元。(有关字符串长度的有趣论述,请参阅“它并不错误,"![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/eff-c/img/manpalm.jpg)".length == 7” *[hsivonen.fi/string-length/`](https://hsivonen.fi/string-length/)*.) 扩展字形集用于确定何时截断字符串,例如由于存储不足。通过在扩展字形集边界处截断,可以避免切割用户感知的字符。

调用 strlen 函数可能是一个昂贵的操作,因为它需要遍历数组的长度以查找空字符。以下是 strlen 函数的直接实现:

size_t strlen(const char * str) {
  const char *s;
  for (s = str; *s; ++s) {}
  return s - str;
}

strlen函数无法知道str所引用的对象的大小。如果你使用一个缺少空字符的无效字符串调用strlen,函数将访问数组的边界之外,导致未定义行为。将空指针传递给strlen也会导致未定义行为(空指针解引用)。此版本的strlen函数对于大于PTRDIFF_MAX的字符串也会导致未定义行为。你应该避免创建这样的对象(在这种情况下,此实现是可行的)。

strcpy

计算动态分配内存的大小并不总是容易的。一种方法是在分配时存储大小,并稍后重新使用该值。清单 7-3 中的代码片段使用strcpy函数通过确定长度并加上1来适应终止空字符,从而复制了str。

char str[100] = "Here comes the sun";
size_t str_size = strlen(str) + 1;
char *dest = (char *)malloc(str_size);
if (dest) {
  strcpy(dest, str);
}
else {
  /* handle error */
}

清单 7-3:复制字符串

然后,我们可以使用存储在str_size中的值来动态分配用于复制的存储空间。strcpy函数将字符串从源字符串(str)复制到目标字符串(dest),包括终止空字符。strcpy函数返回目标字符串开始的地址,在此示例中被忽略。

以下是一个简单实现的strcpy函数:

char *strcpy(char *dest, const char *src) {
  char *save = dest;
  while ((*dest++ = *src++));
  return save;
}

这段代码在复制源数组到目标数组之前,将指向目标字符串的指针保存在save中(作为返回值使用)。while 循环在复制第一个空字节时终止。由于strcpy不知道源字符串的长度或目标数组的大小,它假设所有函数的参数都已由调用者验证,从而使得实现能够简单地将每个字节从源字符串复制到目标数组,而无需执行任何检查。

参数检查

参数检查可以由调用函数或被调用函数执行。调用方和被调用方都进行冗余的参数测试是一种已经被广泛摒弃的防御性编程风格。通常的做法是只在每个接口的一方进行验证。

最节省时间的方法是由调用方进行检查,因为调用方应该对程序的状态有更好的理解。在示例 7-3 中,我们可以看到对 strcpy 的参数进行的检查是有效的,并且没有引入额外的冗余测试:变量 str 引用的是在声明中已正确初始化的静态分配数组,而 dest 参数是一个有效的非空指针,引用的是足够大的动态分配存储,可以容纳 str 的副本,包括空字符。因此,调用 strcpy 是安全的,并且可以以高效的方式执行复制。此种参数检查方法通常被 C 标准库函数使用,因为它符合“C 的精神”,即最优化效率并信任程序员(传递有效的参数)。

更安全、更可靠的方法是由被调用方检查参数。这种方法出错的可能性较小,因为库函数的实现者会验证参数,因此我们不再需要信任程序员传递有效的参数。函数的实现者通常更能理解需要验证哪些参数。如果输入验证代码存在缺陷,修复只需要在一个地方进行。所有验证参数的代码都集中在一个地方,因此这种方法可以更高效地利用空间。然而,由于这些测试即使在不必要时也会运行,它们的时间效率可能较低。通常,调用这些函数的程序员会在可疑的调用之前进行检查,可能会对已经执行过类似检查的代码进行重复检查。这种方法还会给那些目前没有返回错误指示的被调用方带来额外的错误处理负担,但如果它们进行参数验证,可能就需要返回错误指示了。对于字符串,被调用函数并不总是能够确定参数是否是有效的空终止字符串,或者是否有足够的空间进行复制。

这里的教训是,不要假设 C 标准库函数会验证参数,除非标准明确要求它们进行验证。

memcpy

memcpy 函数将 size 个字符从 src 所引用的对象复制到 dest 所引用的对象中:

void *memcpy(void * restrict dest, const void * restrict src, size_t size);

当目标数组的大小大于或等于传递给 memcpy 的 size 参数时,可以使用 memcpy 函数代替 strcpy 复制字符串,前提是源数组在边界之前包含一个空字符,并且字符串长度小于 size - 1(以确保生成的字符串正确地以空字符结束)。最好的建议是,当复制字符串时使用 strcpy,而仅在复制原始未类型化的内存时使用 memcpy。同时记住,赋值(=)运算符在许多情况下可以高效地复制对象。

memccpy

大多数 C 标准库的字符串处理函数返回指向传递作为参数的字符串开头的指针,因此你可以将字符串函数的调用嵌套起来。例如,以下的嵌套函数调用序列通过复制并连接组成部分,使用西方的命名顺序构造一个全名:

strcat(strcat(strcat(strcat(strcpy(name, first), " "), middle), " "), last);

然而,将数组 name 从其组成子字符串拼接起来需要多次扫描 name,这是不必要的;如果字符串处理函数返回指向修改后字符串末尾的指针,能够避免这种需要重新扫描的情况。C23 引入了具有更好接口设计的 memccpy 函数。POSIX 环境应该已经提供了这个函数,但你可能需要启用它的声明,方法如下:

#define _XOPEN_SOURCE 700
#include <string.h>

memccpy 函数具有以下签名:

void *memccpy(void * restrict s1, const void * restrict s2, int c, size_t n);

类似于 memchr 函数,memccpy 函数扫描源序列,查找其参数之一指定的第一个字符。该字符可以具有任何值,包括零。它从源复制(最多)指定数量的字符到目标中,不会写入目标缓冲区的末尾之外。最后,如果指定的字符存在,它会返回指向该字符副本后一个位置的指针。

列表 7-4 使用 memccpy 函数重新实现了前面的嵌套字符串处理函数调用序列。这个实现更高效且更安全。

#include <stdarg.h>
#include <string.h>
#include <stdio.h>
#include <stdint.h>

constexpr size_t name_size = 18U;

char *vstrcat(char *buff, size_t buff_length, ...) {
  char *ret = buff;
  va_list list;
  va_start(list, buff_length);
  const char *part = nullptr;
  size_t offset = 0;
  while ((part = va_arg(list, const char *))) {
  ❶ buff = (char *)memccpy(buff, part, '\0', buff_length - offset);
    if (buff == nullptr) {
      ret[0] = '\0';
      break;
    }
  ❷ offset = --buff - ret;
  }
  va_end(list);
  return ret;
}

int main() {
  char name[name_size] = "";
  char first[] = "Robert";
  char middle[] = "C.";
  char last[] = "Seacord";

  puts(vstrcat(
    name, sizeof(name), first, " ",
    middle, " ", last, nullptr
  ));
}

列表 7-4:使用 memccpy 进行字符串连接

列表 7-4 定义了一个变参函数 vstrcat,该函数接受一个缓冲区(buff)和缓冲区长度(buff_length)作为固定参数,以及一个可变数量的字符串参数。空指针作为哨兵值,用于指示可变长度参数列表的结束。memccpy 函数被调用 ❶ 来将每个字符串连接到缓冲区。正如前面所提到的,memccpy 返回一个指针,指向指定字符复制后的下一个位置,在本例中是空终止字符 '\0'。

我们不再嵌套调用,而是为传递给 vstrcat 的每个字符串参数调用 memccpy 并将返回值存储在 buff 中。这样可以直接在字符串的末尾进行连接,而无需每次都找到空终止字符,从而使这个方案更加高效。

如果 buff 是一个空指针,我们无法复制整个字符串。在这种情况下,我们不会返回部分名称,而是返回一个空字符串。这个空字符串可以被打印出来或视为错误条件。

因为 memccpy 函数返回一个指向复制的空字节后一个字符的指针,所以我们使用前缀递减操作符来递减 buff,然后减去 ret 中存储的值,以获得新的 offset ❷。传递给 memccpy 函数的大小参数(该函数用来防止缓冲区溢出)是通过将 offset 从 buff_length 中减去来计算的。这种方法比嵌套函数调用更安全,因为嵌套函数调用总是有问题,因为无法检查是否发生了错误。

memset, memset_s 和 memset_explicit

memset 函数将 (unsigned char)c 的值复制到由 s 指向的对象的前 n 个字符中:

void *memset(void *s, int c, size_t n);

memset 函数常用于清除内存——例如,将通过 malloc 分配的内存初始化为零。然而,在下面的示例中,它被错误地使用了:

void check_password() {
  char pwd[64];
  if (get_password(pwd, sizeof(pwd))) {
    /* check password */
  }
  memset(pwd, 0, sizeof(pwd));
}

get_password 函数的一个问题是,它使用 memset 函数在最后一次读取后清除一个自动变量。这样做是出于安全原因,确保存储在此处的敏感信息无法访问。然而,编译器并不知道这一点,并且可能执行“死存储”优化。这是当编译器发现一个写操作后没有随之而来的读操作时,它会删除该写操作,就像本书一样,如果没有人会读取它,就没有理由写它。因此,get_password 函数中的 memset 调用可能会被编译器移除。

这个问题本应在 C11 中通过附录 K 的 memset_s 函数来解决(将在下一节中讨论)。不幸的是,本书中提到的任何编译器都没有实现这个函数。

为了解决这个问题,C23 引入了 memset_explicit 函数,用于使敏感信息无法访问。与 memset 函数不同,memset_explicit 的目的是始终执行内存存储(即从不省略),无论优化如何。

gets

gets 函数是一个有缺陷的输入函数,它接受输入,但没有提供任何方式来指定目标数组的大小。因此,它无法防止缓冲区溢出。因此,gets 函数在 C99 中被弃用,并在 C11 中被删除。然而,它已经存在了多年,大多数库仍然为向后兼容提供实现,因此你可能会在实际代码中看到它。你绝不应该使用这个函数,而且你应该替换你在维护的任何代码中找到的 gets 函数的使用。

由于gets函数非常糟糕,我们将花一些时间来分析它为何如此糟糕。清单 7-5 中的函数提示用户输入y或n来表示他们是否希望继续。

#include <stdio.h>
#include <stdlib.h>
#include <ctypes.h>

void get_y_or_n(void) {
  char response[8];
  puts("Continue? [y] n: ");
  gets(response);
  if (tolower(response[0]) == 'n') exit(EXIT_SUCCESS);
  return;
}

清单 7-5:错误使用了过时的 gets 函数

如果在提示时输入超过八个字符,该函数将表现出未定义行为。此未定义行为发生是因为gets函数无法知道目标数组的大小,并且会写入数组对象的末尾以外的内存区域。

清单 7-6 展示了一个简化版的gets函数实现。如你所见,调用此函数的用户无法限制读取的字符数量。

char *gets(char *dest) {
  int c;
  char *p = dest;
  while ((c = getchar()) != EOF && c != '\n') {
    *p++ = c;
  }
  *p = '\0';
  return dest;
}

清单 7-6:一个 gets 函数实现

gets函数通过一次读取一个字符的方式进行迭代。如果读取到EOF或换行符'\n'字符,循环终止。否则,函数将继续向dest数组写入数据,而不考虑对象的边界。

清单 7-7 展示了来自清单 7-5 的get_y_or_n函数,并将gets函数内联。

#include <stdio.h>
#include <stdlib.h>
void get_y_or_n(void) {
  char response[8];
  puts("Continue? [y] n: ");
  int c;
  char *p = response;
❶ while ((c = getchar()) != EOF && c != '\n') {
    *p++ = c;
  }
  *p = '\0';
  if (response[0] == 'n')
    exit(0);
}

清单 7-7:一个写得很糟糕的 while 循环

目标数组的大小现在是已知的,但while循环❶没有使用这个信息。你应该确保在像这样的循环中读取或写入数组时,将达到数组边界作为循环终止的条件。

附录 K 边界检查接口

C11 引入了附录 K 边界检查接口,提供了验证输出缓冲区是否足够大以容纳预期结果的替代函数,如果缓冲区不够大,则返回失败指示符。这些函数旨在防止数据写入数组末尾之外,并确保所有字符串结果都以 null 结尾。这些字符串处理函数将内存管理留给调用者,内存可以在调用这些函数之前静态或动态分配。

微软创建了 C11 附录 K 函数,帮助其改进旧代码库,以应对 1990 年代许多广为人知的安全事件。随后,这些函数被提议提交给 C 标准委员会进行标准化,并作为 ISO/IEC TR 24731-1(ISO/IEC TR 24731-1:2007)发布,之后又被纳入 C11 作为可选附录。尽管这些函数提供了更好的可用性和安全性,但在写作时,它们尚未广泛实现。

gets_s

附录 K 边界检查接口提供了一个可以用来消除调用 Listing 7-5 中gets函数所引起的未定义行为的gets_s函数,具体如 Listing 7-8 所示。

#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>

void get_y_or_n(void) {
  char response[8];
  puts("Continue? [y] n: ");
  gets_s(response, sizeof(response));
  if (tolower(response[0]) == 'n') {
    exit(EXIT_SUCCESS);
  }
}

Listing 7-8: 使用 gets_s 函数

这两个函数类似,区别在于gets_s函数会检查数组的边界。当输入的字符数超过最大值时,默认行为是由实现定义的,但通常会调用abort函数。你可以通过set_constraint_handler_s函数来改变这一行为,稍后我会在“运行时约束”一节中进一步解释,在第 163 页中可以找到。

你需要将STDC_WANT_LIB_EXT1定义为一个宏,并使其扩展为整数常量1,然后在包含定义边界检查接口的头文件之前使用该宏,以便在程序中使用这些接口。与gets函数不同,gets_s函数需要一个大小参数。因此,修改后的函数通过使用sizeof运算符计算目标数组的大小,并将该值作为参数传递给gets_s函数。实现定义的行为是由于运行时约束违规导致的。

strcpy_s

strcpy_s函数是对定义在<string.h>中的strcpy函数的一个近似替代。strcpy_s函数将字符从源字符串复制到目标字符数组中,直到并包括终止的空字符。以下是strcpy_s函数的签名:

errno_t strcpy_s(
  char * restrict s1, rsize_t s1max, const char * restrict s2
);

strcpy_s函数有一个额外的类型为rsize_t的参数,用来指定目标缓冲区的最大长度。rsize_t类型与size_t类型类似,除了接受此类型参数的函数会测试以确保该值不大于RSIZE_MAX。只有在能够完整地将源字符串复制到目标位置且不导致目标缓冲区溢出时,strcpy_s函数才会成功。strcpy_s函数会验证以下运行时约束是否未被违反:

  • s1和s2都不能是空指针。

  • s1max不大于RSIZE_MAX。

  • s1max不等于零。

  • s1max大于strnlen_s(s2, s1max)。

  • 复制不应发生在重叠的对象之间。

为了在一次传递中执行字符串复制,strcpy_s函数的实现会从源字符串中获取一个字符(或多个字符),并将其复制到目标数组中,直到复制完整个字符串或目标数组已满。如果无法复制整个字符串,并且s1max不为零,strcpy_s函数会将目标数组的第一个字节设置为空字符,从而创建一个空字符串。

运行时约束

运行时约束是违反函数运行时要求的情况,函数会通过调用处理程序来检测和诊断这些情况。如果处理程序返回,函数将向调用者返回失败指示符。

边界检查接口通过调用运行时约束处理程序来强制执行运行时约束,处理程序可以简单地返回。或者,运行时约束处理程序可能会向 stderr 打印消息和/或中止程序。你可以通过 set_constraint_handler_s 函数控制调用哪个处理程序函数,并使处理程序按如下方式简单地返回:

int main(void) {
  constraint_handler_t oconstraint =
    set_constraint_handler_s(ignore_handler_s);
  get_y_or_n();
}

如果处理程序返回,识别运行时约束违反并调用该处理程序的函数将通过返回值向其调用者指示失败。

边界检查接口函数通常会在进入时立即检查条件,或者在执行任务并收集足够的信息来确定是否违反了运行时约束时进行检查。边界检查接口的运行时约束是指那些在 C 标准库函数中会导致未定义行为的条件。

实现中有一个默认的约束处理程序,如果没有调用 set_constraint_handler_s 函数,它将被调用。默认处理程序的行为可能导致程序退出或中止,但建议实现提供合理的默认行为。例如,通常用于实现安全关键系统的编译器可以默认不进行中止。你必须检查可以返回的函数的返回值,而不是简单地假设其结果有效。在调用任何边界检查接口或使用任何调用运行时约束处理程序的机制之前,必须通过调用 set_constraint_handler_s 函数来消除实现定义的行为。

附录 K 提供了 abort_handler_s 和 ignore_handler_s 函数,它们代表两种常见的错误处理策略。C 实现的默认处理程序不一定是这两者之一。

POSIX

POSIX 还定义了几个字符串处理函数,如 strdup 和 strndup(IEEE Std 1003.1:2018),为如 GNU/Linux 和 Unix 等符合 POSIX 标准的平台提供了另一组与字符串相关的 API(IEEE Std 1003.1:2018)。这两个函数已被 C23 版本的 C 标准库采用。

这些替代函数使用动态分配的内存,以确保不会发生缓冲区溢出,并且它们实现了被调用者分配,调用者释放的模型。每个函数都确保有足够的内存可用(除非调用 malloc 失败)。例如,strdup 函数返回指向一个新字符串的指针,该字符串包含参数的副本。返回的指针应传递给 C 标准库的 free 函数,以便在不再需要时回收存储空间。

示例 7-9 包含一个代码片段,使用 strdup 函数复制由 getenv 函数返回的字符串。

const char *temp = getenv("TMP");
if (temp != nullptr) {
  char *tmpvar = strdup(temp);
  if (tmpvar != nullptr) {
    printf("TMP = %s.\n", tmpvar);
    free(tmpvar);
  }
}

示例 7-9:使用 strdup 函数复制字符串

C 标准库的 getenv 函数在由主机环境提供的环境列表中搜索与指定名称(例如此处的 TMP)所引用的字符串匹配的字符串。环境列表中的字符串被称为环境变量,它们为向进程传递字符串提供了额外的机制。这些字符串没有明确的编码,但通常与用于命令行参数、stdin 和 stdout 的系统编码匹配。

返回的字符串(变量的值)可能会被随后的 getenv 函数调用覆盖,因此在创建任何线程之前,最好先检索所需的任何环境变量,以消除竞态条件的可能性。如果预期以后还会使用该字符串,应该先复制该字符串,这样可以在需要时安全地引用副本,如 示例 7-9 中的典型示例所示。

strndup 函数与 strdup 等效,不同之处在于 strndup 最多将 n + 1 字节复制到新分配的内存中(而 strdup 复制整个字符串),并确保新创建的字符串总是正确终止。

这些 POSIX 函数通过自动分配存储空间来防止缓冲区溢出,但这要求在不再需要这些存储时引入额外的 free 调用。这意味着需要为每次调用 strdup 或 strndup 等函数匹配一个 free 调用,这对于更熟悉由 <string.h> 定义的字符串函数行为的程序员来说可能会很混乱。

微软

Visual C++ 提供了 C 标准库中定义的所有字符串处理函数,直到 C99,但并未实现完整的 POSIX 规范。然而,有时微软对这些 API 的实现与给定标准的要求不同,或者函数名与另一个标准中的标识符预留冲突。在这种情况下,微软通常会在函数名之前加上一个下划线。例如,POSIX 函数 strdup 在 Windows 上不可用,但 _strdup 函数是可用的,并且行为相同。

注意

有关微软 POSIX 支持的更多信息,请参见 <wbr>docs<wbr>.microsoft<wbr>.com<wbr>/en<wbr>-us<wbr>/cpp<wbr>/c<wbr>-runtime<wbr>-library<wbr>/compatibility.

Visual C++ 也支持 Annex K 中的许多安全字符串处理函数,并会诊断使用不安全变体的情况,除非在包含声明该函数的头文件之前定义了 _CRT_SECURE_NO_WARNINGS。不幸的是,Visual C++ 并不符合 C 标准的 Annex K,因为微软选择不更新其实现,以适应标准化过程中对 API 的修改。例如,Visual C++ 不提供 set_constraint_handler_s 函数,而是保留了一个行为相似但签名不兼容的旧版本函数:

_invalid_parameter_handler _set_invalid_parameter_handler(_invalid_parameter_handler)

微软也没有定义 abort_handler_s 和 ignore_handler_s 函数,memset_s 函数(该函数未被 ISO/IEC TR 24731-1 定义),或 RSIZE_MAX 宏。Visual C++ 也不会将重叠的源和目标序列视为运行时约束违规,而是对这种情况有未定义的行为。关于边界检查接口的更多信息,请参见《Bounds-Checking Interfaces: Field Experience and Future Directions》(Seacord 2019)。

总结

在本章中,你学习了字符编码,如 ASCII 和 Unicode。你还学习了用于表示 C 语言程序中字符的各种数据类型,如 char、int、wchar_t 等。然后我们介绍了字符转换库,包括 C 标准库函数、libiconv 和 Windows API。

除了字符,你还学习了字符串以及 C 标准库中用于处理字符串的传统函数和边界检查接口,以及一些 POSIX 和微软特定的函数。

操作字符和字符串数据是 C 语言中常见的编程任务,也是错误的常见来源。我们概述了处理这些数据类型的各种方法;你应该根据你的应用程序需求选择最适合的方式,并始终如一地应用这种方法。

在下一章,你将学习 I/O,它可以用来读取和写入字符和字符串。

第八章:8 输入/输出

本章将教你如何执行输入/输出(I/O)操作,从终端或文件系统读取数据或写入数据。信息可以通过命令行参数或环境进入程序,并通过返回状态退出程序。然而,大多数信息通常是通过 I/O 操作进入或退出程序的。我们将讨论使用 C 标准流和 POSIX 文件描述符的技术。我们将首先讨论 C 标准文本和二进制流。然后,我们将介绍使用 C 标准库和 POSIX 函数打开和关闭文件的不同方法。

接下来,我们将讨论字符和行的读取与写入、格式化文本的读取与写入,以及从二进制流中读取和写入。我们还将涵盖流缓冲、流定向和文件定位。

还有许多其他设备和 I/O 接口(例如ioctl)可用,但它们超出了本书的范围。

标准 I/O 流

C 提供了与存储在受支持的结构化存储设备和终端上的文件进行通信的流。是与文件和设备(如套接字、键盘、通用串行总线(USB)端口和打印机)通信的统一抽象,这些设备或文件消耗或生成顺序数据。

C 使用不透明的FILE数据类型来表示流。FILE对象保存与关联文件连接的内部状态信息,包括文件位置指示器、缓冲区信息、错误指示器和文件结束指示器。你不应该自己分配FILE对象。C 标准库函数操作的是类型为FILE (即指向FILE类型的指针)的对象。因此,流通常被称为文件指针*。

C 提供了一个广泛的应用程序接口(API),该接口可以在<stdio.h>中找到,用于操作流;我们将在本章稍后探讨此 API。然而,由于这些 I/O 函数需要与许多平台上各种各样的设备和文件系统协同工作,因此它们具有高度的抽象性,这使得它们不适用于超出最简单应用的场景。

例如,C 标准没有目录的概念,因为它必须能够与非层次化的文件系统兼容。C 标准对文件系统特定细节的引用较少,如文件权限或锁定。然而,函数规范通常指出,某些行为会在“底层系统支持的程度上”发生,这意味着只有在你的实现支持这些行为时,它们才会发生。

因此,你通常需要使用 POSIX、Windows 以及其他平台提供的较不便携的 API 来执行实际应用中的 I/O 操作。通常,应用程序会定义自己的 API,这些 API 又依赖于平台特定的 API 来提供安全、可靠且便携的 I/O 操作。

错误和文件结束指示符

如前所述,FILE对象保存与关联文件连接的内部状态信息,包括一个错误指示符,用于记录是否发生了读写错误,以及一个文件结束指示符,用于记录是否已到达文件末尾。文件打开时,流的错误指示符和文件结束指示符会被清除。以下 C 标准库函数会在发生错误时设置流的错误指示符:字节输入函数(getc、fgetc 和 getchar)、字节输出函数(putc、fputc 和 putchar)、fflush、fseek 和 fsetpos。输入函数,如fgetc 和 getchar,如果流已到达文件末尾,也会设置流的文件结束指示符。某些函数,如rewind 和 freopen,会清除流的错误指示符,而函数如rewind、freopen、ungetc、fseek 和 fsetpos,会清除流的文件结束指示符。宽字符 I/O 函数的行为类似。

这些指示符可以显式测试和清除:

  • ferror 函数测试指定流的错误指示符,并且仅在指定流的错误指示符被设置时返回非零值。

  • feof 函数测试指定流的文件结尾指示符,并且仅在指定流的文件结尾指示符被设置时返回非零值。

  • clearerr 函数清除指定流的文件结尾和错误指示符。

以下简短程序展示了这些函数与两个指示符之间的交互:

#include <stdio.h>
#include <assert.h>

int main() {
  FILE* tmp = tmpfile();
  fputs("Effective C\n", tmp);
  rewind(tmp);
  for (int c; (c = fgetc(tmp)) != EOF; putchar(c)) {}
  printf("%s", "End-of-file indicator ");
  puts(feof(tmp) ? "set" : "clear");
  printf("%s", "Error indicator ");
  puts(ferror(tmp) ? "set" : "clear");
  clearerr(tmp); // clear both indicators
  printf("%s", "End-of-file indicator ");
  puts(feof(tmp) ? "set" : "clear");
}

该程序在 stdout 上生成以下输出:

Effective C
End-of-file indicator set
Error indicator clear
End-of-file indicator clear

循环通过文件结尾终止,之后设置文件结尾指示符。这两个指示符会通过调用 clearerr 函数被清除。

流缓冲

缓冲 是将数据暂时存储在内存中的过程,数据在进程和设备或文件之间传递。缓冲提高了 I/O 操作的吞吐量,因为每个 I/O 操作通常会有较高的延迟。类似地,当程序请求写入块设备(如磁盘)时,驱动程序可以将数据缓存到内存中,直到累积足够的数据形成一个或多个设备块,此时会将数据一次性写入磁盘,从而提高吞吐量。这种策略称为刷新输出缓冲区。

一个流可以处于以下三种状态之一:

无缓冲 字符旨在尽可能快地从源或到达目的地。通常多个程序可能并发访问的数据流,最好使用无缓冲模式。用于错误报告或日志记录的流也可能是无缓冲的。

全缓冲 字符被设计成在缓冲区填满时作为一个块传输到主机环境或从主机环境传输。用于文件 I/O 的流通常采用全缓冲方式,以优化吞吐量。

行缓冲 字符在遇到换行符时,旨在作为一个块传输到主机环境或从主机环境传输。连接到交互设备(如终端)的流在打开时通常采用行缓冲模式。

在接下来的章节中,我们将介绍预定义流并描述它们是如何进行缓冲的。

预定义流

一个 C 程序在启动时会打开并可用三种 预定义文本流。这些预定义流在 <stdio.h> 中声明:

extern FILE * stdin;  // standard input stream
extern FILE * stdout; // standard output stream
extern FILE * stderr; // standard error stream

标准输出流(stdout)是程序的传统输出目标。这个流通常与启动程序的终端相关联,但可以被重定向到文件或其他流。在 Linux 或 Unix 的 shell 中,你可以输入以下命令:

$ **echo fred**
fred
$ **echo fred > tempfile**
$ **cat tempfile**
fred

在这里,echo命令的输出被重定向到tempfile

标准输入流(stdin)是程序的传统输入源。默认情况下,stdin与键盘相关联,但也可以被重定向为来自文件的输入,例如,使用以下命令:

$ **echo "one two three four five six seven" > tempfile**
$ **wc < tempfile**
1 7 34

文件tempfile的内容被重定向到stdin流,传递给wc命令,输出tempfile的换行符(1)、单词数(7)和字节数(34)。stdin和stdout流会在仅当流不指向交互式设备时,才完全缓冲。

标准错误流(stderr)用于写入诊断输出。stderr流不会完全缓冲,以便尽快查看错误信息。

图 8-1 显示了预定义的流stdin、stdout和stderr,它们附加在用户终端的键盘和显示器上。

图 8-1:附加到 I/O 通信通道的标准流

一个程序的输出流可以通过使用 POSIX 管道被重定向到另一个应用程序的输入流:

$ **echo "Hello Robert" | sed "s/Hello/Hi/" | sed "s/Robert/robot/"**
Hi robot

流编辑器sed是一个用于过滤和转换文本的 Unix 工具。竖线字符(|)在许多平台上可用于链式命令。

流方向

每个流都有一个方向,它表示该流是包含窄字符还是宽字符。在一个流与外部文件关联之后,但在进行任何操作之前,该流没有方向。一旦应用了宽字符 I/O 函数到一个没有方向的流,该流就变成了宽字符导向流。类似地,一旦应用了字节 I/O 函数到一个没有方向的流,该流就变成了字节导向流。可以作为 char 类型对象表示的多字节字符序列或窄字符(根据 C 标准,这些字符需要占用 1 个字节)可以写入字节导向流中。

你可以通过使用 fwide 函数或者通过关闭并重新打开文件来重置流的方向。如果对宽字符流应用字节 I/O 函数,或者对字节导向流应用宽字符 I/O 函数,将导致未定义的行为。永远不要将窄字符数据、宽字符数据和二进制数据混合存储在同一文件中。

所有三种预定义流(stderr,stdin 和 stdout)在程序启动时都是无方向的。

文本流与二进制流

C 标准支持文本流和二进制流。文本流 是由字符组成的有序序列,字符按行排列,每行由零个或多个字符及一个终止换行符序列组成。在类 Unix 系统中,你可以使用换行符(\n)表示单一的换行。大多数微软 Windows 程序使用回车符(\r)后跟换行符(\n)。

不同的换行符约定可能导致在不同约定的系统之间传输的文本文件显示或解析不正确,尽管在现代系统中这种情况已经变得不常见,因为这些系统现在能够理解外部的换行符约定。

二进制流 是一种有序的任意二进制数据序列。从二进制流读取的数据将与之前写入该流的数据相同,在相同的实现下也是如此。在非 POSIX 系统中,流的末尾可能会附加由实现定义的数量的空字节。

二进制流总是比文本流更强大、更可预测。然而,读取或写入一个普通的文本文件,且能与其他文本导向的程序兼容,最简单的方法是通过文本流。

打开和创建文件

当你打开或创建一个文件时,它会与一个流关联。fopen 和 POSIX open 函数用于打开或创建文件。

fopen

fopen 函数打开一个文件,该文件的名称由字符串给出并由 filename 指向,然后将一个流与之关联:

FILE *fopen(
  const char * restrict filename,
  const char * restrict mode
);

mode 参数指向表 8-1 中显示的字符串之一,用于确定如何打开文件。

表 8-1: 有效的文件模式字符串

模式字符串 描述
r 打开现有文本文件以供读取
w 截断为零长度或创建文本文件以供写入
a 附加、打开或创建文本文件以在文件末尾写入
rb 打开现有二进制文件以供读取
wb 截断文件为零长度或创建二进制文件以供写入
ab 附加、打开或创建二进制文件以在文件末尾写入
r+ 打开现有文本文件以供读写
w+ 截断为零长度或创建文本文件以供读写
a+ 附加、打开或创建文本文件以供更新,在文件当前末尾写入
r+b 或 rb+ 打开现有二进制文件以供读写
w+b 或 wb+ 截断为零长度或创建用于读取和写入的二进制文件
a+b 或 ab+ 追加,打开或创建用于更新的二进制文件,在当前文件末尾写入

以读取模式打开文件(通过将 r 作为 mode 参数的第一个字符传递)会失败,如果文件不存在或无法读取。

以追加模式打开文件(通过将 a 作为 mode 参数的第一个字符传递)会导致所有后续写入文件的操作发生在当前文件末尾,直到缓冲区刷新或实际写入时,无论是否有对 fseek、fsetpos 或 rewind 函数的调用。将当前文件末尾的指针按写入的数据量递增是原子性的,只要文件也以追加模式打开,并且其他线程在写入同一文件时不干扰。如果实现无法原子地递增当前文件末尾,它将失败,而不是进行非原子性写入。在某些实现中,以追加模式打开二进制文件(通过将 b 作为 mode 参数的第二个或第三个字符传递)可能会由于空字符填充而将文件位置指示器设置在最后一个数据写入之后。

你可以通过将 + 作为 mode 参数的第二个或第三个字符传递来以更新模式打开文件,从而可以对相关流执行读取和写入操作。在某些实现中,以更新模式打开(或创建)文本文件可能会改为打开(或创建)二进制流。在 POSIX 系统中,文本流和二进制流的行为完全相同。

C11 标准增加了独占模式,用于读取和写入二进制文件和文本文件,如 表 8-2 所示。

表 8-2: C11 添加的有效文件模式字符串

模式字符串 描述
< sAmp class="SANS_TheSansMonoCd_W5Regular_11">wx < sAmp class="SANS_Futura_Std_Book_11">创建独占文本文件用于写入
< sAmp class="SANS_TheSansMonoCd_W5Regular_11">wbx < sAmp class="SANS_Futura_Std_Book_11">创建独占二进制文件用于写入
< sAmp class="SANS_TheSansMonoCd_W5Regular_11">w+x < sAmp class="SANS_Futura_Std_Book_11">创建独占文本文件用于读写
< sAmp class="SANS_TheSansMonoCd_W5Regular_11">w+bx < sAmp class="SANS_Futura_Std_Book_11">或 < sAmp class="SANS_TheSansMonoCd_W5Regular_11">wb+x < sAmp class="SANS_Futura_Std_Book_11">创建独占二进制文件用于读写

以独占模式打开文件(通过在< sAmp class="SANS_TheSansMonoCd_W5Regular_11">mode 参数的最后一个字符传递 x)如果文件已存在或无法创建则会失败。文件存在性的检查和如果文件不存在则创建文件的操作是原子性的,涉及到其他线程和并发程序执行。如果实现无法原子地执行文件存在性检查和文件创建,它会失败,而不是进行非原子检查和创建。

最后,请确保永远不要复制一个 < sAmp class="SANS_TheSansMonoCd_W5Regular_11">FILE 对象。例如,下面的程序可能会失败,因为在调用 < sAmp class="SANS_TheSansMonoCd_W5Regular_11">fputs 时,使用了 < sAmp class="SANS_TheSansMonoCd_W5Regular_11">stdout 的按值复制:

#include <stdio.h>
#include <stdlib.h>

int main() {
  FILE my_stdout = *stdout;
  if (fputs("Hello, World!\n", &my_stdout) == EOF) {
    return EXIT_FAILURE;
  }
  return EXIT_SUCCESS;
}

该程序具有未定义的行为,通常会在运行时崩溃。

< sAmp class="SANS_Futura_Std_Bold_Condensed_Oblique_BI_11">open

在 POSIX 系统中,< sAmp class="SANS_TheSansMonoCd_W5Regular_11">open 函数(IEEE Std 1003.1:2018)建立了文件 < sAmp class="SANS_TheSansMonoCd_W5Regular_11">path 与一个叫做 文件描述符 的值之间的连接:

int open(const char *path, int oflag, ...);

文件描述符 是一个非负整数,指向表示文件的结构(称为 打开文件描述)。由< sAmp class="SANS_TheSansMonoCd_W5Regular_11">open 函数返回的文件描述符是未使用的最低编号文件描述符,并且是唯一的,属于调用该函数的进程。文件描述符被其他 I/O 函数用来引用该文件。< sAmp class="SANS_TheSansMonoCd_W5Regular_11">open 函数将文件偏移量设置为标记文件内当前的位置,从文件的开始位置开始。对于一个流的文件描述符,这个文件偏移量与流的文件位置指示器是分开的。

oflag 参数的值设置了打开文件描述符的 文件访问模式,指定文件是以读取、写入还是两者同时进行打开。oflag 的值是通过按位或操作组合文件访问模式和任何访问标志。应用程序必须在 oflag 的值中指定以下文件访问模式之一:

O_EXEC  仅用于执行(非目录文件)

O_RDONLY 仅用于读取

O_RDWR 同时用于读取和写入

O_SEARCH 仅用于搜索的目录打开

O_WRONLY 仅用于写入

oflag 参数的值还设置了 文件状态标志,这些标志控制 open 函数的行为,并影响文件操作的执行方式。这些标志包括以下内容:

O_APPEND 在每次写入之前,将文件偏移量设置为文件末尾

O_TRUNC 将文件长度截断为 0

O_CREAT 创建文件

O_EXCL 如果同时设置了 O_CREAT 并且文件已存在,则导致打开失败

open 函数接受可变数量的参数。紧随 oflag 参数后的值指定文件模式位(当创建新文件时的文件权限),类型为 mode_t。

清单 8-1 展示了一个使用 open 函数打开文件进行写入的示例。

#include <err.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
  int fd;
  int flags = O_WRONLY | O_CREAT | O_TRUNC;
❶ mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;
  const char *pathname = "/tmp/file";
  if ((fd = open(pathname, flags, mode) ❷) == -1) {
    err(EXIT_FAILURE, "Can't open %s", pathname);
  }
  // `--snip--`
}

清单 8-1:以写入模式打开文件

调用 open ❷ 时需要多个参数,包括文件的路径名、oflag 和模式。我们创建了一个 mode 标志 ❶,它是以下访问权限模式位的按位或组合:

S_IRUSR 文件所有者的读取权限位

S_IWUSR 文件所有者的写权限位

S_IRGRP 文件组所有者的读权限位

S_IROTH 其他用户的读权限位

open 函数仅在创建文件时设置这些权限。如果文件已存在,它的当前权限将保持不变。文件访问模式为 O_WRONLY,表示文件仅用于写入。O_CREAT 文件状态标志通知 open 创建该文件;O_TRUNC 文件状态标志通知 open 如果文件存在且成功打开,应丢弃文件的先前内容。

如果文件成功打开,open 函数返回一个非负整数,表示文件描述符。否则,open 返回 −1,并将 errno 设置为指示错误的值。Listing 8-1 检查是否返回 −1,如果发生错误,则将诊断消息写入预定义的 stderr 流,并退出。

除了 open,POSIX 还提供了其他有用的函数来处理文件描述符,例如 fileno 函数用于获取与现有文件指针关联的文件描述符,fdopen 函数用于通过现有的文件描述符创建一个新的流文件指针。通过文件描述符提供的 POSIX API 允许访问 POSIX 文件系统的功能,这些功能通常不会通过文件指针接口暴露出来,例如目录(posix_getdents,fdopendir,readdir)、文件权限(fchmod)和文件锁(fcntl)。

关闭文件

打开文件会分配资源。如果你不断地打开文件却没有关闭它们,最终你的进程将用尽可用的文件描述符或句柄,尝试打开更多文件时将会失败。因此,使用完文件后关闭文件是非常重要的。

fclose

C 标准库中的fclose函数用于关闭文件:

int fclose(FILE *stream);

流中任何未写入的缓冲数据将传递给主机环境,写入文件中。任何未读取的缓冲数据将被丢弃。

fclose函数可能会失败。例如,当<fclose写入剩余的缓冲输出时,可能会因为磁盘已满而返回错误。即使你知道缓冲区已空,如果使用网络文件系统(NFS)协议关闭文件时,仍然可能发生错误。尽管可能会失败,但通常无法恢复,因此程序员通常忽略<fclose返回的错误。关闭文件失败时,一种常见的做法是中止进程或截断文件,使其内容在下次读取时仍然有意义。

为了确保你的代码稳健,务必检查错误。文件 I/O 可能由于各种原因失败。如果检测到错误,fclose函数会返回EOF:

if (fclose(fp) == EOF) {
  err(EXIT_FAILURE, "Failed to close file\n");
}

你需要显式调用fflush或fclose,以刷新程序写入的任何缓冲流,而不是让exit(或从main返回)来刷新它,以执行错误检查。

在相关文件被关闭后,指向FILE对象的指针值是未定义的。是否存在一个零长度的文件(即没有写入任何数据的输出流)由实现定义。

你可以在同一程序或另一个程序中重新打开已关闭的文件,并且可以恢复或修改其内容。如果初始调用的main函数返回或调用了exit函数,所有打开的文件会在程序终止前关闭(并且所有输出流会被刷新)。

程序终止的其他路径,例如调用abort函数,可能无法正确关闭所有文件,这意味着尚未写入磁盘的缓冲数据可能会丢失。

close

在 POSIX 系统上,你可以使用close函数来释放由fd指定的文件描述符:

int close(int fd);

如果在调用 close 时,从文件系统读取或写入数据发生 I/O 错误,可能会返回 −1,并且 errno 会被设置为错误原因。如果返回错误,则 fd 的状态是未定义的,这意味着你无法再读取或写入数据到该描述符,也不能再次尝试关闭它——这实际上导致文件描述符泄漏。为了解决这个问题,posix_close 函数已被添加到《开放组基础规范》第 8 版中。

一旦文件成功关闭,文件描述符将不再存在,因为与其对应的整数不再指向任何文件。当拥有该文件描述符的进程终止时,文件也会被关闭。

除非在极少数情况下,使用 fopen 打开文件的应用程序会使用 fclose 来关闭文件;使用 open 打开文件的应用程序会使用 close 来关闭文件(除非它将描述符传递给了 fdopen,在这种情况下,它必须通过调用 fclose 来关闭文件)。

读取和写入字符与行

C 标准定义了用于读取和写入特定字符或行的函数。

大多数字节流函数都有相应的版本,可以使用宽字符(wchar_t)或宽字符字符串来替代窄字符(char)或字符串(参见 表 8-3)。字节流函数在头文件 <stdio.h> 中声明,而宽字符流函数在 <wchar.h> 中声明。宽字符函数在相同的流(例如 stdout)上操作。

表 8-3: 窄字符与宽字符 I/O 函数

char wchar_t 描述
fgetc fgetwc 从流中读取一个字符。
getc getwc 从流中读取一个字符。
getchar getwchar 从 stdin读取一个字符。
fgets fgetws 从流中读取一行。
fputc fputwc 将字符写入流中。
putc putwc 将字符写入流中。
fputs fputws 将字符串写入流中。
putchar putwchar 将字符写入 stdout。
puts N/A 将字符串写入 stdout。
ungetc ungetwc 将字符返回到流中。
scanf wscanf 从 stdin读取格式化的字符输入。
fscanf fwscanf 从流中读取格式化的字符输入。
sscanf swscanf 从缓冲区中读取格式化的字符输入。
printf wprintf 将格式化字符输出打印到 stdout。
fprintf fwprintf 将格式化字符输出打印到流中。
sprintf swprintf 将格式化字符输出打印到缓冲区。
snprintf N/A 这与 sprintf 相同,但带有截断功能。 swprintf 函数也接受一个长度参数,但其处理方式与 snprintf 不同。

在本章中,我们将只讨论字节流函数。如果可能的话,您可能想完全避免使用宽字符函数变体,专门使用 UTF-8 字符编码,因为这些函数不太容易导致程序员错误和安全漏洞。

fputc 函数将字符 c 转换为 unsigned char 类型,并将其写入 stream:

int fputc(int c, FILE *stream);

如果发生写入错误,它返回 EOF;否则,它返回已写入的字符。

putc 函数与 fputc 类似,唯一的区别是大多数库将其实现为宏:

int putc(int c, FILE *stream);

如果 putc 被实现为宏,它可能会多次评估其 stream 参数。通常使用 fputc 更加安全。更多信息请参见 CERT C 规则 FIO41-C,“不要使用具有副作用的流参数调用 getc()、putc()、getwc() 或 putwc()”。

putchar函数等同于putc函数,不同之处在于它使用stdout作为流参数的值。

fputs函数将字符串s写入流stream:

int fputs(const char * restrict s, FILE * restrict stream);

该函数不会写入字符串s中的空字符,也不会写入换行符,而只输出字符串中的字符。如果发生写入错误,fputs将返回EOF。否则,它将返回一个非负值。例如,以下语句输出文本I am Groot,后跟一个换行符:

fputs("I ", stdout);
fputs("am ", stdout);
fputs("Groot\n", stdout);

puts函数将字符串s写入流stdout,后跟一个换行符:

int puts(const char *s);

puts函数是打印简单消息时最方便的函数,因为它只需要一个参数。以下是一个示例:

puts("This is a message.");

fgetc函数从流中读取下一个字符,将其作为unsigned char类型,并返回其值,转换为int类型:

int fgetc(FILE *stream);

如果发生文件结束或读取错误,函数将返回EOF。

getc函数等同于fgetc,不同之处在于如果它作为宏实现,可能会多次评估其流参数。因此,该参数不应为带有副作用的表达式。类似于fputc函数,使用fgetc通常更安全,应优先使用fgetc而非getc。

getchar函数等同于getc函数,不同之处在于它使用stdout作为流参数的值。

你可能记得,gets 函数从 stdin 中读取字符,并将它们写入字符数组,直到遇到换行符或 EOF。gets 函数本质上是不安全的。它在 C99 中被弃用,并在 C11 中被移除,永远不应该使用。如果你需要从 stdin 读取字符串,考虑改用 fgets 函数。fgets 函数最多从流中读取 n 个字符减去 1,读取到指向的字符数组 s 中:

char *fgets(char * restrict s, int n, FILE * restrict stream);

在遇到(保留的)换行符或 EOF 后,不会再读取其他字符。读取的最后一个字符后会立即写入一个空字符。

流刷新

如本章前面所述,流可以是完全或部分缓冲的,这意味着你认为已经写入的数据可能尚未传递到主机环境中。当程序突然终止时,这可能会成为一个问题。fflush 函数将任何未写入的数据从指定流传递到主机环境,以便写入文件:

int fflush(FILE *stream);

如果流的最后一个操作是输入操作,则行为未定义。如果流是空指针,fflush 函数将在所有流上执行此刷新操作。如果这不是你的意图,请确保在调用 fflush 时,文件指针不是空指针。

设置文件中的位置

随机访问文件(例如磁盘文件,但不包括终端)维护一个与流关联的文件位置指示符。文件位置指示符 描述了流当前在文件中读取或写入的位置。

当你打开一个文件时,指示器会定位到文件的起始位置(除非你以追加模式打开它)。你可以将指示器放置在任何你想读取或写入文件部分的位置。ftell 函数获取当前文件位置指示器的值,而 fseek 函数则设置文件位置指示器。这些函数使用 long int 类型表示文件中的偏移量(位置),因此它们的偏移量限制在可以表示为 long int 的范围内。Listing 8-2 演示了 ftell 和 fseek 函数的使用。

#include <err.h>
#include <stdio.h>
#include <stdlib.h>

long int get_file_size(FILE *fp) {
  if (fseek(fp, 0, SEEK_END) != 0) {
    err(EXIT_FAILURE, "Seek to end-of-file failed");
  }
  long int fpi = ftell(fp);
  if (fpi == -1L) {
    err(EXIT_FAILURE, "ftell failed");
  }
  return fpi;
}

int main() {
  FILE *fp = fopen("fred.txt", "rb");
  if (fp  == nullptr) {
    err(EXIT_FAILURE, "Cannot open fred.txt file");
  }
  printf("file size: %ld\n", get_file_size(fp));
  if (fclose(fp) == EOF) {
    err(EXIT_FAILURE, "Failed to close file");
  }
  return EXIT_SUCCESS;
}

Listing 8-2: 使用 ftell 和 fseek 函数

该程序打开一个名为 fred.txt 的文件,并调用 get_file_size 函数来获取文件大小。get_file_size 函数调用 fseek 将文件位置指示器设置到文件的末尾(由 SEEK_END 指示),并调用 ftell 函数获取文件流当前的文件位置指示器值,作为 long int 类型返回。该值由 get_file_size 函数返回,并在 main 函数中打印出来。最后,我们关闭由 fp 文件指针引用的文件。

fseek 函数对文本文件和二进制文件有不同的限制。对于文本文件,偏移量必须为零或之前由 ftell 返回的值,而对于二进制文件,你可以使用计算出的偏移量。

为确保你的代码健壮,务必检查错误。文件输入输出(File I/O)可能因各种原因而失败。fopen 函数在失败时返回空指针。fseek 函数只有在无法满足请求时才会返回非零值。失败时,ftell 函数返回 −1L,并将一个由实现定义的值存储在 errno 中。如果 ftell 的返回值等于 −1L,我们使用 err 函数打印程序名称的最后一个组件、冒号字符、一个空格,然后是与存储在 errno 中的值对应的错误信息,最后是一个换行符。fclose 函数如果检测到任何错误,将返回 EOF。这个简短程序所展示的 C 标准库的一个不幸之处是,每个函数往往以独特的方式报告错误,因此通常需要参考文档,了解如何测试错误。

fgetposfsetpos 函数使用 fpos_t 类型来表示偏移量。该类型可以表示任意大的偏移量,这意味着你可以使用 fgetposfsetpos 来操作任意大的文件。宽字符流具有一个关联的 mbstate_t 对象,该对象存储流的当前解析状态。成功调用 fgetpos 会将此多字节状态信息作为 fpos_t 对象的一部分存储。之后,使用相同存储的 fpos_t 值成功调用 fsetpos 会恢复解析状态以及在控制流中的位置。除非间接通过调用 fsetpos 后跟 ftell,否则无法将 fpos_t 对象转换为流中的整数字节或字符偏移量。 清单 8-3 中展示了 fgetposfsetpos 函数的使用示例。

#include <err.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
  FILE *fp = fopen("fred.txt", "w+");
  if (fp == nullptr) {
    err(EXIT_FAILURE, "Cannot open fred.txt file");
  }
  fpos_t pos;
  if (fgetpos(fp, &pos) != 0) {
    err(EXIT_FAILURE, "get position");
  }
  if (fputs("abcdefghijklmnopqrstuvwxyz", fp) == EOF) {
      fputs("Cannot write to fred.txt file\n", stderr);
  }
  if (fsetpos(fp, &pos) != 0) {
    err(EXIT_FAILURE, "set position");
  }
  long int fpi = ftell(fp);
  if (fpi == -1L) {
    err(EXIT_FAILURE, "ftell");
  }
  printf("file position = %ld\n", fpi);
  if (fputs("0123456789", fp) == EOF) {
    fputs("Cannot write to fred.txt file\n", stderr);
  }
  if (fclose(fp) == EOF) {
    err(EXIT_FAILURE, "Failed to close file\n");
  }
  return EXIT_SUCCESS;
}

清单 8-3:使用 fgetpos 和 fsetpos 函数

该程序打开 fred.txt 文件进行写入,然后调用 fgetpos 来获取当前文件在文件中的位置,该位置存储在 pos 中。接着,我们向文件写入一些文本,然后调用 fsetpos 将文件位置指示符恢复到存储在 pos 中的位置。此时,我们可以使用 ftell 函数来检索并打印文件位置,结果应为 0。运行该程序后,fred.txt 包含以下文本:

0123456789klmnopqrstuvwxyz

你不能在写入流之后再读取它,除非先调用 fflush 函数来写入任何未写入的数据,或者调用文件定位函数(fseek、fsetpos 或 rewind)。同样,不能先从流中读取数据再写入,除非先调用文件定位函数。

rewind 函数将文件位置指示器设置为文件的开头:

void rewind(FILE *stream);

rewind 函数相当于调用 fseek,然后调用 clearerr 来清除流的错误指示器:

fseek(stream, 0L, SEEK_SET);
clearerr(stream);

因为无法确定 rewind 是否失败,所以应该使用 fseek,以便可以检查错误。

不应尝试在以追加模式打开的文件中使用文件位置,因为许多系统不会修改当前的文件位置指示器用于追加,或者在写入时强制将文件指示器重置为文件末尾。如果使用需要文件位置的 API,则文件位置指示器会通过随后的读取、写入和定位请求保持更新。POSIX 和 Windows 都有一些 API 永远不使用文件位置指示器;对于这些 API,始终需要指定执行 I/O 操作时的文件偏移量。POSIX 定义了 lseek 函数,它的行为与 fseek 类似,但它作用于打开的文件描述符(IEEE Std 1003.1:2018)。

删除和重命名文件

C 标准库提供了 remove 函数来删除文件,以及 rename 函数来移动或重命名文件:

int remove(const char *filename);
int rename(const char *old, const char *new);

在 POSIX 中,文件删除函数是 unlink,目录删除函数是 rmdir:

int unlink(const char *path);
int rmdir(const char *path);

POSIX 也使用 rename 来重命名文件。C 标准与 POSIX 之间一个显著的区别是,C 标准没有目录的概念,而 POSIX 有。因此,C 标准没有为处理目录定义特定的语义。

unlink 函数比 remove 函数具有更明确定义的语义,因为它是专门针对 POSIX 文件系统的。在 POSIX 和 Windows 中,我们可以有任意数量的文件链接,包括硬链接和打开的文件描述符。unlink 函数始终会删除文件的目录条目,但只有在没有更多的链接或打开的文件描述符引用该文件时,才会删除文件。即使在删除后,文件的内容可能仍然保存在永久存储中。rmdir 函数仅在目录为空时,删除由path指定的目录。

在 POSIX 中,当参数不是目录时,remove 函数的行为必须与 unlink 函数相同;当参数是目录时,它的行为必须与 rmdir 函数相同。remove 函数在其他操作系统上可能表现不同。

文件系统与其他与你的程序同时运行的程序共享。这些其他程序将在你的程序运行期间修改文件系统。这意味着文件条目可能会消失或被其他文件条目替代,这可能成为安全漏洞和意外数据丢失的来源。POSIX 提供了函数,允许你解除链接并重命名由打开的文件描述符或句柄引用的文件。可以使用这些函数来防止在共享公共文件系统中发生安全漏洞和可能的意外数据丢失。

使用临时文件

我们经常使用临时文件作为进程间通信机制,或者为了将信息暂时存储到磁盘中以释放随机存取内存(RAM)。例如,一个进程可能会写入一个临时文件,另一个进程则从该文件中读取。这些文件通常通过使用像 C 标准库的tmpfile和tmpnam,或 POSIX 的mkstemp等函数在临时目录中创建。

临时目录可以是全局的,也可以是用户特定的。在 Unix 和 Linux 中,TMPDIR 环境变量用于指定全局临时目录的位置,通常是 /tmp/var/tmp。运行 Wayland 或 X11 窗口系统的系统通常会通过 \(XDG_RUNTIME_DIR 环境变量定义用户特定的临时目录,该变量通常设置为 */run/user/\)uid。在 Windows 中,你可以在用户配置文件的 AppData 部分找到用户特定的临时目录,通常为 C:\Users\User Name\AppData\Local\Temp (%USERPROFILE%\AppData\Local\Temp)。在 Windows 中,全局临时目录由 TMP 或 TEMP 环境变量指定。C:\Windows\Temp* 目录是 Windows 用来存储临时文件的系统文件夹。

出于安全原因,最好为每个用户配置自己的临时目录,因为使用全局临时目录常常会导致安全漏洞。创建临时文件的最安全函数是 POSIX mkstemp 函数。然而,由于在共享目录中访问文件可能会很困难,甚至无法安全实现,因此我们建议你不要使用任何现有的函数,而是通过使用套接字、共享内存或其他为此目的设计的机制来执行进程间通信。

读取格式化文本流

在本节中,我们将演示如何使用 fscanf 函数来读取格式化输入。fscanf 函数是我们在第一章中介绍过的 fprintf 函数的对应输入版本,其函数签名如下:

int fscanf(FILE * restrict stream, const char * restrict format, ...);

fscanf 函数从由 stream 指向的流中读取输入,按照 format 字符串的控制来处理,该字符串告诉函数预期的参数数量、类型以及如何将它们转换为赋值。后续的参数是指向接收转换输入的对象的指针。如果 format 字符串的参数不足,结果是未定义的。如果提供的参数多于转换说明符,超出的参数会被评估,但会被忽略。fscanf 函数有很多功能,这里我们仅触及其中一部分。有关更多信息,请参阅 C 标准。

为了演示 fscanf 的使用,以及一些其他 I/O 函数,我们将实现一个程序,该程序读取 清单 8-4 中显示的 signals.txt 文件并打印出每一行。

1 HUP Hangup
2 INT Interrupt
3 QUIT Quit
4 ILL Illegal instruction
5 TRAP Trace trap
6 ABRT Abort
7 EMT EMT trap
8 FPE Floating-point exception

清单 8-4: The signals.txt 文件

该文件的每一行包含以下内容:一个信号号(一个小的正整数值)、信号 ID(最多六个字母数字字符的短字符串),以及一个简短的描述信号的字符串。字段之间由空格分隔,描述字段的分隔符为开始处的一个或多个空格或制表符字符,结束处是换行符。

清单 8-5 显示了信号程序,该程序读取此文件并打印出每一行。

#include <err.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#define TO_STR_HELPER(x) #x
#define TO_STR(x) TO_STR_HELPER(x)

#define DESC_MAX_LEN 99

int main() {
  int status = EXIT_SUCCESS;
  FILE *in;

  struct sigrecord {
    int signum;
    char signame[10];
    char sigdesc[DESC_MAX_LEN + 1];
❶} rec;

 if ((in = fopen("signals.txt", "r")) == nullptr) {
    err(EXIT_FAILURE, "Cannot open signals.txt file");
  }

❷ while (true) {
  ❸ int n = fscanf(in, "%d%9s%*[\t]%" TO_STR(DESC_MAX_LEN) "[^\n]",
      &rec.signum, rec.signame, rec.sigdesc
    );
    if (n == 3) {
      printf(
        "Signal\n  number = %d\n  name = %s\n  description = %s\n\n",
        rec.signum, rec.signame, rec.sigdesc
      );
    }
    else if (ferror(in)) {
      perror("Error indicated");
      status = EXIT_FAILURE;
      break;
    }
    else if (n == EOF) {
      // normal end-of-file
      break;
    }
    else if (feof(in)) {
      fputs("Premature end-of-file detected\n", stderr);
      status = EXIT_FAILURE;
      break;
    }
    else {
      fputs("Failed to match signum, signame, or sigdesc\n\n", stderr);
      int c;
      while ((c = getc(in)) != '\n' && c != EOF);
      status = EXIT_FAILURE;
    }
  }

❹ if (fclose(in) == EOF) {
    err(EXIT_FAILURE, "Failed to close file\n");
  }

  return status;
}

清单 8-5: 信号程序

我们在main函数中定义了几个变量,包括rec结构体❶,它用于存储文件中每一行找到的信号信息。rec结构体包含三个成员:一个类型为int的signum成员,用于保存信号编号;一个signame成员,它是一个类型的数组,用于保存信号 ID;还有一个sigdesc成员,也是一个类型的数组,用于保存信号的描述。两个数组的大小是固定的,我们确定它们的大小足以容纳从文件读取的字符串。如果从文件读取的字符串过长,无法容纳到这些数组中,程序会将其视为错误。

调用 fscanf ❸ 读取文件中的每一行输入。它出现在一个无限的 while (true) 循环 ❷ 内,我们必须打破这个循环才能终止程序。我们将 fscanf 函数的返回值赋给一个局部变量 n。如果在第一个转换完成之前发生输入错误,fscanf 函数会返回 EOF。否则,函数返回分配的输入项数,这个数可能少于预期的输入项数,甚至为零,如果发生了提前匹配失败。调用 fscanf 会分配三个输入项,因此只有当 n 等于 3 时,我们才会打印信号描述。接下来,我们调用 ferror(in) 来判断 fscanf 是否设置了错误指示器。如果设置了,我们通过调用 perror 函数打印 errno,然后将状态设置为 EXIT_FAILURE。接下来,如果 n 等于 EOF,我们会退出循环,因为我们已经成功处理了所有输入。最后的可能情况是,fscanf 返回的值既不是预期的输入项数量,也不是表示提前匹配失败的 EOF。在这种情况下,我们将该条件视为非致命错误:

fputs("Failed to match signum, signame, or sigdesc\n\n", stderr);
int c;
while ((c = getc(in)) != '\n' && c != EOF);
status = EXIT_FAILURE;

我们向 stderr 输出一条信息,通知用户文件中某个信号描述存在问题,但我们继续处理其余条目。循环丢弃有缺陷的行,并将 status 赋值为 EXIT_FAILURE,以指示调用程序发生了错误。你会注意到,程序中的错误处理占据了大部分代码。

fscanf 函数使用一个 格式字符串,该字符串决定了输入文本如何分配给每个参数。在这种情况下,"%d%9s%*[\t]%99[^\n]" 格式字符串包含四个 转换说明符,它们指定如何将从输入流中读取的输入转换为存储在格式字符串参数引用的对象中的值。我们通过百分号字符 (%) 引入每个转换说明符。在 % 后,可能按顺序出现以下内容:

  • 一个可选的字符 *,用于丢弃输入而不将其分配给任何参数

  • 一个大于零的可选整数,用于指定最大字段宽度(以字符为单位)

  • 一个可选的长度修饰符,用于指定对象的大小

  • 一个转换说明符字符,用于指定要应用的转换类型

格式字符串中的第一个转换说明符是 %d。此转换说明符匹配第一个可选符号的十进制整数,该整数应对应于文件中的信号编号,并将值存储在第三个由 rec.signum 引用的参数中。如果没有可选的长度修饰符,则输入的长度取决于转换说明符的默认类型。对于 d 转换说明符,参数必须指向一个 signed int。

此格式字符串中的第二个转换说明符是 %9s,它匹配输入流中的下一个非空白字符序列——对应于信号名称——并将这些字符作为字符串存储在第四个由 rec.signame 引用的参数中。长度修饰符防止输入超过九个字符,并在匹配的字符后在 rec.signame 中写入空字符。此示例中的 %10s 转换说明符将允许发生缓冲区溢出。即便如此,%9s 转换说明符仍然可能无法读取整个字符串,从而导致匹配错误。在将数据读取到固定大小的缓冲区时,如我们所做的,你应当测试精确匹配或稍微超出固定缓冲区长度的输入,以确保不会发生缓冲区溢出,并且字符串正确地以空字符结束。

我们暂时跳过第三个转换说明符,来讲讲第四个转换说明符:%99[\n]。这个复杂的转换说明符将匹配文件中的信号描述字段。括号([])包含一个*扫描集*,类似于正则表达式。这个扫描集使用脱字符()来排除 \n 字符。综合起来,%99[^\n] 会读取所有字符,直到遇到 \n(或 EOF)并将它们存储在由 rec.sigdesc 引用的第五个参数中。C 程序员通常使用这种语法来读取整行。此转换说明符还包括 99 字符的最大字符串长度,以避免缓冲区溢出。

现在我们可以重新审视第三个转换说明符:%[\t]。正如我们刚刚看到的,第四个转换说明符会读取所有字符,从信号 ID 的末尾开始。不幸的是,这包括信号 ID 和描述开始之间的任何空白字符。%[\t] 转换说明符的目的是消耗这两个字段之间的任何空格或水平制表符字符,并通过使用分配抑制说明符 * 来抑制它们。还可以在此转换说明符的扫描集内包含其他空白字符。

最后,我们调用 fclose 函数 ❹ 来关闭文件。

从二进制流中读取和写入

C 标准库中的 fread 和 fwrite 函数可以操作文本流和二进制流。fwrite 函数具有以下签名:

size_t fwrite(const void * restrict ptr, size_t size, size_t nmemb,
  FILE * restrict stream);

该函数将最多 nmemb 个 size 字节的元素,从 ptr 指向的数组写入到 stream。fwrite 函数的行为类似于将每个对象转换为 unsigned char 数组(每个对象都可以转换为这种类型的数组),然后调用 fputc 函数按顺序写入数组中每个字符的值。流的文件位置指示器会根据成功写入的字符数量进行更新。

POSIX 定义了类似的 read 和 write 函数,它们操作的是文件描述符而非流(IEEE Std 1003.1:2018)。

示例 8-6 演示了使用 fwrite 函数将信号记录写入 signals.bin 文件。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct sigrecord {
  int signum;
  char signame[10];
  char sigdesc[100];
} rec;

int main() {
  int status = EXIT_SUCCESS;
  FILE *fp;

❶ if ((fp = fopen("signals.bin", "wb")) == nullptr) {
    fputs("Cannot open signals.bin file\n", stderr);
    return EXIT_FAILURE;
  }

❷ rec sigrec30 = {30, "USR1", "user-defined signal 1"};
  rec sigrec31 = {
    .signum = 31, .signame = "USR2", .sigdesc = "user-defined signal 2"
  };

  size_t size = sizeof(rec);

❸ if (fwrite(&sigrec30, size, 1, fp) != 1) {
    fputs("Cannot write sigrec30 to signals.bin file\n", stderr);
    status = EXIT_FAILURE;
    goto close_files;
  }

  if (fwrite(&sigrec31, size, 1, fp) != 1) {
    fputs("Cannot write sigrec31 to signals.bin file\n", stderr);
    status = EXIT_FAILURE;
  }

close_files:
  if (fclose(fp) == EOF) {
    fputs("Failed to close file\n", stderr);
    status = EXIT_FAILURE;
  }

  return status;
}

示例 8-6:使用直接 I/O 向二进制文件写入

我们以 wb 模式打开 signals.bin 文件 ❶ 来创建一个用于写入的二进制文件。我们声明两个 rec 结构体 ❷ 并用我们想要写入文件的信号值初始化它们。为了比较,sigrec30 结构体使用位置初始化器初始化,而 sigrec31 则使用指定初始化器进行初始化。两种初始化方式的行为相同;指定初始化器使声明更加清晰,尽管稍微冗长。实际的写入操作从 ❸ 开始。我们检查每次调用 fwrite 函数的返回值,以确保它写入了正确数量的元素。

示例 8-7 使用 fread 函数从 signals.bin 文件中读取我们刚刚写入的数据。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct rec {
  int signum;
  char signame[10];
  char sigdesc[100];
} rec;

int main() {
  int status = EXIT_SUCCESS;
  FILE *fp;
  rec sigrec;

❶ if ((fp = fopen("signals.bin", "rb")) == nullptr) {
    fputs("Cannot open signals.bin file\n", stderr);
    return EXIT_FAILURE;
  }

  // read the second signal
❷ if (fseek(fp, sizeof(rec), SEEK_SET)  != 0) {
    fputs("fseek in signals.bin file failed\n", stderr);
    status = EXIT_FAILURE;
    goto close_files;
  }

❸ if (fread(&sigrec, sizeof(rec), 1, fp) != 1) {
    fputs("Cannot read from signals.bin file\n", stderr);
    status = EXIT_FAILURE;
    goto close_files;
  }

  printf(
    "Signal\n  number = %d\n  name = %s\n  description = %s\n\n",
    sigrec.signum, sigrec.signame, sigrec.sigdesc
  );

close_files:
  if (fclose(fp) == EOF) {
    fputs("Failed to close file\n", stderr);
    status = EXIT_FAILURE;
  }

  return status;
}

示例 8-7:使用直接 I/O 从二进制文件读取

我们使用 rb 模式 ❶ 打开二进制文件进行读取。接下来,为了让这个示例更加有趣,程序读取并打印特定信号的信息,而不是读取整个文件。我们可以通过程序参数指定读取哪个信号,但为了这个示例,我们将其硬编码为第二个信号。为此,程序调用 fseek 函数 ❷ 来设置由 fp 引用的流的文件位置指示器。如本章前面所述,文件位置指示器决定了随后的 I/O 操作的文件位置。对于二进制流,我们通过将偏移量(以字节为单位)加到由最后一个参数指定的位置(即文件开头,使用 SEEK_SET 指示)来设置新位置。第一个信号位于文件的 0 位置,随后的每个信号都位于文件开头的结构大小的整数倍位置。

文件位置指示器定位到第二个信号的开始后,我们调用 fread 函数 ❸ 从二进制文件读取数据到由 &sigrec 引用的结构中。调用 fread 读取一个元素,该元素的大小由 sizeof(rec) 指定,从由 fp 指向的流中读取。在大多数情况下,这个对象的大小和类型与相应的 fwrite 调用相同。流的文件位置指示器将根据成功读取的字符数而前进。我们检查 fread 函数的返回值,以确保读取了正确数量的元素,这里是一个元素。

字节序

除字符类型外的对象类型可能包含填充和数值表示位。不同的目标平台可以以不同的方式将字节打包为多字节字,这种方式称为 字节序

注意

字节序 这一术语来源于乔纳森·斯威夫特 1726 年的讽刺作品《格列佛游记》,其中发生了内战,争论的是煮蛋时,应该从蛋的大端还是小端打开。

大端序将最高有效字节放在最前面,最低有效字节放在最后,而小端序则相反。例如,考虑无符号十六进制数字0x1234,它需要至少两个字节来表示。在大端序中,这两个字节是0x12和0x34,而在小端序中,字节排列为0x34和0x12。Intel 和 AMD 处理器使用小端格式,而 ARM 和 POWER 系列处理器可以在小端和大端格式之间切换。然而,大端序是网络协议中占主导地位的顺序,如互联网协议(IP)、传输控制协议(TCP)和用户数据报协议(UDP)。字节序可能会导致问题,当一个计算机上创建了二进制文件并在另一台具有不同字节序的计算机上读取时。

C23 增加了一种机制,用于在运行时使用三个宏确定实现的字节顺序,这些宏扩展为整数常量表达式。__STDC_ENDIAN_LITTLE__宏表示一种字节顺序存储,其中最低有效字节最先放置,其余字节按升序排列。__STDC_ENDIAN_BIG__宏表示一种字节顺序存储,其中最高有效字节最先放置,其余字节按降序排列。

__STDC_ENDIAN_NATIVE__宏描述执行环境中与位精确整数类型、标准整数类型和大多数扩展整数类型相关的字节序。清单 8-8 中的简短程序通过测试__STDC_ENDIAN_NATIVE__宏的值来确定执行环境的字节顺序。如果执行环境既不是小端序也不是大端序,并且有某种其他实现定义的字节顺序,则__STDC_ENDIAN_NATIVE__宏将具有不同的值。

#include <stdbit.h>
#include <stdio.h>

int main (int argc, char* argv[]) {
  if (__STDC_ENDIAN_NATIVE__ == __STDC_ENDIAN_LITTLE__) {
    puts("little endian");
  }
  else if (__STDC_ENDIAN_NATIVE__ == __STDC_ENDIAN_BIG__) {
    puts("big endian");
  }
  else {
    puts("other byte ordering");
  }
  return 0;
}

清单 8-8:确定字节顺序

各平台之间的这些差异意味着,对于主机间通信,你应当采用一个标准的外部格式,并使用格式转换函数将外部数据的数组编组到多个字节的本地对象之间(使用精确宽度的类型)。POSIX 提供了一些适合此目的的函数,包括 htonl、htons、ntohl 和 ntohs,它们用于在主机字节序和网络字节序之间转换值。

在二进制数据格式中,可以通过始终以固定字节序存储数据,或在二进制文件中包含一个字段来指示数据的字节序,从而实现字节序的独立性。

总结

在本章中,你学习了流的相关内容,包括流缓冲、预定义流、流方向,以及文本流和二进制流之间的区别。

然后,你学习了如何使用 C 标准库和 POSIX API 创建、打开和关闭文件。你还学习了如何读取和写入字符和行,读取和写入格式化文本,以及从二进制流中读取和写入数据。你了解了如何刷新流、设置文件位置、删除文件和重命名文件。如果没有输入/输出,用户与程序的通信将仅限于程序的返回值。最后,你学习了临时文件以及如何避免使用它们。

在下一章中,你将学习编译过程和预处理器的相关内容,包括文件包含、条件包含和宏定义。

第九章:9 预处理器

与 Aaron Ballman 合作

预处理器是 C 编译器的一部分,它在编译的早期阶段运行,并在代码被翻译之前进行处理,例如将一个文件(通常是头文件)中的代码插入到另一个文件(通常是源文件)中。预处理器还允许你指定一个标识符,在宏扩展过程中自动用源代码片段替代它。在本章中,你将学习如何使用预处理器来包含文件、定义类似对象和函数的宏、根据特定实现功能有条件地包含代码,并将二进制资源嵌入到程序中。

编译过程

从概念上讲,编译过程由八个阶段组成,如图 9-1 所示。我们称这些为翻译阶段,因为每个阶段都将代码转换为下一阶段处理所需的格式。

图 9-1:翻译阶段

预处理器在翻译器将源代码转换为目标代码之前运行,这允许预处理器在翻译器处理之前修改用户编写的源代码。因此,预处理器对正在编译的程序的语义信息了解有限。它不了解函数、变量或类型。只有基本元素,如头文件名、标识符、字面值和标点符号(例如 +、- 和 !)对预处理器是有意义的。这些基本元素称为标记,是编译器能够理解的计算机程序中最小的有意义元素。

预处理器作用于你在源代码中包含的预处理指令,以编程预处理器的行为。你通过在前面加上 # 标记,然后跟随指令名称来拼写预处理指令,例如 #include、#define、#embed 或 #if。每个预处理指令以换行符结尾。你可以通过在行的开头和 # 之间添加空白字符来缩进指令。

 #define THIS_IS_FINE 1

或者位于 # 和指令之间:

#  define SO_IS_THIS 1

预处理指令指示预处理器修改结果翻译单元。如果你的程序包含预处理指令,翻译器所处理的代码与你编写的代码并不完全相同。编译器通常提供查看预处理器输出的方法,这些输出被称为翻译单元,传递给翻译器。查看预处理器的输出虽然不必要,但你可能会觉得看到实际传给翻译器的代码是很有帮助的。表 9-1 列出了常见编译器用来输出翻译单元的标志。

表 9-1: 输出翻译单元

编译器 示例命令行
Clang clang 其他选项 -E -o tu.i tu.c
GCC gcc 其他选项 -E -o tu.i tu.c
Visual C++ cl 其他选项 /P /Fitu.i tu.c

预处理输出文件通常使用.i 文件扩展名。## 文件包含

预处理器的一个强大功能是能够通过使用 #include 预处理指令,将一个源文件的内容插入到另一个源文件的内容中。被包含的文件称为头文件,以便与其他源文件区分开来。头文件通常包含供其他程序使用的声明。这是与程序其他部分共享函数、对象和数据类型外部声明的最常见方式。

你已经在本书的示例中看到许多包含 C 标准库函数头文件的例子。例如,表 9-2 中的程序被拆分为一个名为bar.h的头文件和一个名为foo.c的源文件。源文件foo.c中并未直接包含对 func 的声明,但该函数仍然能在 main 中通过名字成功引用。在预处理阶段,#include 指令会将 bar.h 的内容插入到 foo.c 中,替代掉 #include 指令本身。

表 9-2: 头文件包含

原始源代码 结果翻译单元
bar.hint func(void); int func(void);int main(void)
foo.c#include "bar.h"int main(void)

预处理器在遇到 #include 指令时会立即执行它。因此,包含操作具有传递性:如果一个源文件包含了一个头文件,而该头文件又包含了另一个头文件,那么预处理后的输出将包含两个头文件的内容。例如,给定 baz.hbar.h 头文件,以及 foo.c 源文件,在对 foo.c 源代码运行预处理器后的输出,如 表 9-3 所示。

表 9-3: 传递性头文件包含

原始源代码 结果翻译单元
baz.hint other_func(void); int other_func(void);int func(void);int main(void)
bar.h#include "baz.h"int func(void);
foo.c#include "bar.h"int main(void)

编译 foo.c 源文件时,预处理器会包含 "bar.h" 头文件。然后,预处理器会找到 "baz.h" 头文件的包含指令,并将其也包含进来,从而把 other_func 的声明引入到生成的翻译单元中。

最佳实践是避免依赖传递式包含,因为它们会让你的代码变得脆弱。可以考虑使用像 include-what-you-use (<wbr>include<wbr>-what<wbr>-you<wbr>-use<wbr>.org) 这样的工具来自动移除对传递式包含的依赖。

从 C23 开始,你可以在 #include 指令执行之前,使用 __has_include 预处理器操作符来检测一个包含文件是否存在。它只接受一个头文件名作为操作数。如果指定的文件能够找到,操作符返回 true,否则返回 false。你可以与条件包含一起使用它,以便在文件无法包含时提供替代的实现。例如,你可以使用 __has_include 预处理器操作符来检测 C 标准库线程或 POSIX 线程支持,如下所示:

#if __has_include(<threads.h>)
#  include <threads.h>
   typedef thrd_t thread_handle;
#elif __has_include(<pthread.h>)
   typedef pthread_t thread_handle;
#endif

你可以使用带引号的包含字符串(例如,#include "foo.h")或尖括号的包含字符串(例如,#include <foo.h>)来指定要包含的文件。这两种语法的区别由实现定义,但它们通常会影响用于查找包含文件的搜索路径。例如,Clang 和 GCC 都会尝试找到使用以下语法包含的文件:

  • 使用-isystem标志指定的系统包含路径上的尖括号

  • 使用-iquote和-isystem标志指定的引用包含路径上的引用字符串

请参阅您的编译器文档,了解这两种语法之间的具体差异。通常,标准或系统库的头文件位于默认系统包含路径中,而您自己的项目头文件位于引用包含路径中。

传递给__has_include预处理器操作符的头文件操作数可以使用引号或尖括号指定。该操作符使用与#include指令相同的搜索路径启发式方法。因此,您应确保对于#include指令和相应的__has_include操作符使用相同的形式,以确保结果的一致性。

条件包含

通常,您需要编写不同的代码来支持不同的实现。例如,您可能希望为不同的目标架构提供函数的替代实现。解决此问题的一种方法是维护两个文件,它们之间有细微的差异,并为特定实现编译相应的文件。更好的解决方案是根据预处理器定义来翻译或避免翻译目标特定的代码。

您可以使用如#if、#elif或#else等预处理指令在带有谓词条件的情况下有条件地包含源代码。谓词条件是控制常量表达式,用于确定预处理器应该选择程序的哪个分支。它们通常与预处理器的defined操作符一起使用,后者用于判断给定标识符是否是已定义宏的名称。

条件包含指令类似于 if 和 else 语句。当谓词条件计算为非零预处理器值时,#if 分支会被处理,其他所有分支不会被处理。当谓词条件计算为零时,下一条 #elif 分支(如果有)会对其谓词进行测试以决定是否包含。如果没有谓词条件计算为非零,则处理 #else 分支(如果存在)。#endif 预处理指令表示条件包含代码的结束。

defined 运算符如果给定的标识符被定义为宏,则计算为 1,否则计算为 0。例如,清单 9-1 中显示的预处理指令根据条件决定包含哪些头文件内容。预处理输出取决于 _WIN32 或 ANDROID 是否为已定义宏。如果两者都不是已定义宏,预处理器输出将为空。

#if defined(_WIN32)
#  include <Windows.h>
#elif defined(__ANDROID__)
#  include <android/log.h>
#endif

清单 9-1:条件包含示例

与 if 和 else 关键字不同,预处理器条件包含无法使用大括号来表示由谓词控制的语句块。相反,预处理器条件包含会将从 #if、#elif 或 #else 指令(紧随谓词后)到下一个平衡的 #elif、#else 或 #endif 令牌的所有标记,同时跳过在未选择的条件分支中的任何标记。条件包含指令可以嵌套。你可以写

#ifdef `identifier`

作为简写形式:

#if defined `identifier`

同样,你可以写

#ifndef `identifier`

作为简写形式:

#if !defined `identifier`

从 C23 开始,你可以写

#elifdef `identifier`

作为简写形式

#elif defined `identifier`

你可以写

#elifndef `identifier`

作为简写形式

#elif !defined `identifier`

或者等效地:

#elif !defined(`identifier`)

标识符周围的圆括号是可选的。

生成诊断信息

如果预处理器无法执行任何条件分支,因为没有合理的回退行为,那么可能需要生成一个错误信息。考虑示例 9-2 中的例子,它使用条件包含来选择是否包含 C 标准库头文件 <threads.h> 或 POSIX 线程库头文件 <pthread.h>。如果两者都不可用,应该提醒移植系统的程序员,代码必须修复。

#if __STDC__ && __STDC_NO_THREADS__ != 1
#  include <threads.h>
#elif POSIX_THREADS == 200809L
#  include <pthread.h>
#else
  int compile_error[-1]; // induce a compilation error
#endif

示例 9-2:引发编译错误

这里,代码生成了诊断信息,但没有描述实际的问题。为此,C 提供了 #error 预处理指令,导致实现生成编译时的诊断消息。你可以选择在该指令后跟一个或多个预处理器标记,以包含在生成的诊断消息中。通过这些,我们可以将示例 9-2 中的错误数组声明替换为如示例 9-3 所示的 #error 指令。

#if __STDC__ && __STDC_NO_THREADS__ != 1
#  include <threads.h>
#elif POSIX_THREADS == 200809L
#  include <pthread.h>
#else
#  error "Neither <threads.h> nor <pthread.h> is available"
#endif

示例 9-3:一个 #error 指令

如果没有线程库头文件可用,以下代码将生成错误消息:

Neither <threads.h> nor <pthread.h> is available

除了 #error 指令,C23 还增加了 #warning 指令。这个指令和 #error 指令类似,它们都会导致实现生成诊断信息。然而,不同的是,生成诊断消息后,编译继续进行(除非其他命令行选项禁用警告或将其升级为错误)。#error 指令应当用于致命问题,例如没有回退实现的缺失库,而 #warning 指令应当用于非致命问题,例如缺失库但有低质量回退实现。

使用头文件保护

编写头文件时,你会遇到的一个问题是防止程序员在一个翻译单元中多次包含同一文件。由于可以传递性地包含头文件,你可能会不小心多次包含同一头文件(甚至可能导致头文件之间的无限递归)。

头文件保护确保每个翻译单元中仅包含一次头文件。头文件保护是一种设计模式,根据是否已定义特定的宏来有条件地包含头文件的内容。如果该宏尚未定义,则会定义它,以确保后续的头文件保护测试不会有条件地重复包含代码。在表 9-4 中所示的程序中,bar.h使用了头文件保护(加粗显示)来防止它在foo.c中被(意外)重复包含。

表 9-4: 头文件保护

原始源代码 生成的翻译单元
bar.h#ifndef BAR_H#define BAR_Hinlineint func() { return 1; }#endif /* BAR_H */ inlineint func() { return 1; }extern inline int func();int main()
foo.c#include "bar.h"#include "bar.h" // 重复包含                 // 通常不是这么明显的extern inline int func();int main()

第一次包含 "bar.h" 时,#ifndef 测试会检查 BAR_H 是否未定义,并返回 true。然后,我们定义宏 BAR_H,并使用一个空的替换列表,这足以定义 BAR_H,并且包含了 func 的函数定义。第二次包含 "bar.h" 时,预处理器不会生成任何标记,因为条件包含测试返回 false。因此,func 只会在最终的翻译单元中定义一次。

选择用于头文件保护的标识符时,一种常见做法是使用文件路径、文件名和扩展名的显著部分,用下划线分隔并全部大写。例如,如果你有一个头文件会通过 #include "foo/bar/baz.h" 被包含,你可以选择 FOO_BAR_BAZ_H 作为头文件保护标识符。

一些集成开发环境(IDE)会自动为你生成头文件保护。避免使用保留的标识符作为头文件保护的标识符,因为这可能会引入未定义的行为。以下划线开头并紧跟大写字母的标识符是保留的。例如,_FOO_H 是一个保留的标识符,作为用户选择的头文件保护标识符并不好,即使你正在包含一个名为 *_foo.h* 的文件。使用保留的标识符可能会与实现中定义的宏发生冲突,导致编译错误或代码不正确。

宏定义

#define 预处理指令定义了一个宏。你可以使用来定义常量值或具有通用参数的类似函数的结构。宏定义包含一个(可能为空的)替换列表—这是一个代码模式,当预处理器扩展宏时会注入到翻译单元中:

#define `identifier` `replacement-list`

#define 预处理指令以换行符结束。在以下示例中,ARRAY_SIZE 的替换列表为 100

#define ARRAY_SIZE 100
int array[ARRAY_SIZE];

在这里,ARRAY_SIZE标识符会被100所替代。如果没有指定替换列表,预处理器将直接删除宏名。你通常可以在编译器的命令行上指定宏定义——例如,使用 Clang 和 GCC 中的-D标志,或者在 Visual C++中使用/D标志。对于 Clang 和 GCC,命令行选项-DARRAY_SIZE=100指定宏标识符ARRAY_SIZE被替换为100,产生与前面示例中的#define预处理指令相同的结果。如果你在命令行上没有指定宏替换列表,编译器通常会提供一个默认的替换列表。例如,-DFOO通常与#define FOO 1是等效的。

宏的作用范围持续到预处理器遇到一个#undef预处理指令,或者直到翻译单元的结束。与变量或函数声明不同,宏的作用范围独立于任何代码块结构。

你可以使用#define指令来定义一个类似对象的宏或类似函数的宏。类似函数的宏是有参数的,并且在调用时需要传递(可能为空的)参数集合,类似于你调用一个函数的方式。与函数不同,宏允许你使用程序的符号执行操作,这意味着你可以创建一个新的变量名,或者引用宏被调用时所在的源文件和行号。类似对象的宏是一个简单的标识符,将被一个代码片段所替代。

表 9-5 说明了函数式宏和对象式宏之间的区别。FOO 是一个对象式宏,在宏展开过程中被替换为标记 (1 + 1),而 BAR 是一个函数式宏,在宏展开过程中被替换为标记 (1 + (x)),其中 x 是调用 BAR 时指定的任何参数。

表 9-5: 宏定义

原始源 结果翻译单元
#define FOO (1 + 1)#define BAR(x) (1 + (x))int i = FOO;int j = BAR(10);int k = BAR(2 + 2); int i = (1 + 1);int j = (1 + (10));int k = (1 + (2 + 2));

函数式宏定义的左括号必须紧跟宏名,中间不能有空格。如果宏名和左括号之间出现空格,那么括号将成为替换列表的一部分,就像对象式宏FOO一样。宏的替换列表以宏定义中的第一个换行符为终止符。然而,你可以通过在换行符前添加反斜杠(\)将多行源代码连接起来,从而让你的宏定义更加易于理解。例如,考虑以下计算浮点数参数立方根的cbrt类型通用宏定义:

#define cbrt(X) _Generic((X), \
  long double: cbrtl(X),      \
  default: cbrt(X),           \
  float: cbrtf(X)             \
)

该定义等效于,但更易于阅读,以下内容:

#define cbrt(X) _Generic((X), long double: cbrtl(X), default: cbrt(X), float: cbrtf(X))

定义宏时的一个危险是,你不能在程序的其他部分继续使用宏的标识符,否则会导致宏替换。例如,由于宏展开,下面的无效程序无法编译:

#define foo (1 + 1)
void foo(int i);

这是因为预处理器从翻译器接收到的令牌会导致以下无效代码:

void (1 + 1)(int i);

你可以通过始终遵循一种习惯来解决这个问题,例如将宏名称定义为全大写字母,或在所有宏名称前加上助记符,就像某些匈牙利命名法风格中所做的那样。

注意

匈牙利命名法 是一种标识符命名约定,其中变量或函数的名称表示其意图或类型,并且在某些方言中,它还表示变量的类型。

定义宏后,重新定义宏的唯一方法是使用 #undef 指令。一旦取消定义,该命名标识符就不再表示一个宏。例如,表 9-6 中显示的程序定义了一个类似函数的宏,包含一个使用该宏的头文件,然后取消定义该宏,以便以后可以重新定义它。

表 9-6: 取消定义宏

原始源 结果翻译单元
header.hNAME(first)NAME(second)NAME(third) enum Names {  first,  second,  third,};void func(enum Names Name) {  switch (Name){    case first:    case second:    case third:  }}
file.cenum Names {#define NAME(X) X,#include "header.h"#undef NAME};void func(enum Names Name) {  switch (Name) {#define NAME(X) case X:#include "header.h"#undef NAME  }}

第一次使用NAME宏会声明枚举Names中的枚举项名称。NAME宏被取消定义,然后重新定义,用于在switch语句中生成case标签。

取消定义宏是安全的,即使命名标识符不是宏的名称。这个宏定义无论NAME是否已经定义,都能正常工作。为了简洁起见,本书中通常不遵循这种做法。

宏替换

类函数宏看起来像函数,但行为不同。当预处理器遇到一个宏标识符时,它会调用该宏,宏会展开标识符,并用宏定义中指定的替换列表中的令牌替换它。

对于类似函数的宏,预处理器会在展开宏时,用宏调用中的对应参数替换替换列表中的所有参数。任何在替换列表中以#符号为前缀的参数,会被替换为一个包含该参数预处理令牌文本的字符串字面量令牌(这个过程有时称为字符串化)。表 9-7 中的STRINGIZE宏会将x的值进行字符串化。

表 9-7: 字符串化

原始源代码 生成的翻译单元
#define STRINGIZE(x) #xconst char *str = STRINGIZE(12); const char *str = "12";

预处理器还会删除替换列表中所有出现的 ## 预处理标记,将前面的预处理标记与后面的标记连接起来,这个过程称为 标记粘贴。在表 9-8 中,PASTE 宏用于通过连接 foo、下划线字符 (_) 和 bar 来创建一个新的标识符。

表 9-8: 标记粘贴

原始源 翻译后的单元
#define PASTE(x, y) x ## _ ## yint PASTE(foo, bar) = 12; int foo_bar = 12;

在宏展开后,预处理器会重新扫描替换列表以展开其中的额外宏。如果预处理器在重新扫描时发现正在展开的宏名称——包括在替换列表中嵌套宏展开的重新扫描——它不会再次展开该名称。此外,如果宏展开结果形成了与预处理指令相同的程序文本片段,则该片段不会被当作预处理指令处理。

在宏展开过程中,替换列表中的重复参数名称会多次被调用时传入的参数所替代。如果宏调用的参数涉及副作用,这可能会产生令人意外的效果,正如在表 9-9 中所示。这一问题在 CERT C 规则 PRE31-C “避免在不安全宏的参数中使用副作用”中有详细说明。

表 9-9: 不安全的宏展开

原始源 翻译后的单元
#define bad_abs(x) (x >= 0 ? x : -x)int func(int i) {  }  return bad_abs(i++);} int func(int i)

在表 9-9 中的宏定义里,每个宏参数x都会被宏调用参数i++替换,导致i被递增两次,这种情况是程序员或审查者在阅读原始源代码时很容易忽视的。像x这样的参数以及替换列表本身,通常应当完全加括号,例如((x) >= 0 ? (x) : -(x)),以防止参数x与替换列表中的其他元素以意外的方式结合。

GNU 的语句表达式允许你在表达式中使用循环、switch 语句和局部变量。语句表达式是 GCC、Clang 和其他编译器支持的非标准编译器扩展。通过使用语句表达式,你可以将bad_abs(x)重写为如下形式:

#define abs(x) ({               \
  auto x_tmp = x;               \
  x_tmp >= 0 ? x_tmp : x_tmp;   \
})

你可以安全地使用带有副作用操作数的abs(x)宏。

另一个潜在的惊讶是,函数式宏调用中的逗号总是被解释为宏参数分隔符。C 标准中的ATOMIC_VAR_INIT宏(在 C23 中已删除)演示了这种危险(表 9-10)。

表 9-10: 该 ATOMIC_VAR_INIT 宏

原始源代码 结果翻译单元
stdatomic.h#define ATOMIC_VAR_INIT(value) (value)
foo.c#include <stdatomic.h>struct S {  int x, y;};Atomic struct S val = ATOMIC_VAR_INIT({1, 2});

这段代码无法正确翻译,因为 ATOMIC_VAR_INIT({1, 2}) 中的逗号被当作函数式宏参数分隔符,导致预处理器将宏解释为包含两个语法无效的参数 {1 和 2},而不是一个有效的参数 {1, 2}。这个可用性问题是 ATOMIC_VAR_INIT 宏在 C17 中被弃用,并在 C23 中被移除的原因之一。

类型通用宏

C 编程语言不允许像 Java 和 C++ 等其他语言那样,基于传递给函数的参数类型来重载函数。然而,有时你可能需要根据参数类型改变算法的行为。例如,<math.h> 中有三个 sin 函数(sin,sinf 和 sinl),因为每种浮点数类型(double,float 和 long double)的精度不同。通过使用通用选择表达式,你可以定义一个单一的类似函数的标识符,在调用时根据参数类型委托给正确的底层实现。

通用选择表达式将其未求值的操作数表达式的类型映射到一个关联表达式。如果没有任何关联类型匹配,它可以选择性地映射到一个默认表达式。你可以使用类型通用宏(包含通用选择表达式的宏)使你的代码更具可读性。在表 9-11 中,我们定义了一个类型通用宏,用于从<math.h>中选择正确的sin函数变体。

表 9-11: 作为宏的通用选择表达式

原始来源 结果 _ 通用 解析

|

#define singen(X) _Generic((X), \
  float: sinf,                  \
  double: sin                   \
  long double: sinl             \
)(X)

int main() {
  printf("%f, %Lf\n",
    singen(3.14159),
    singen(1.5708L)
  );
}

|

int main() {
  printf("%f, %Lf\n",
    sin(3.14159),
sinl(1.5708L) 
);
}

|

通用选择表达式的控制表达式(X)尚未求值;表达式的类型从type : expr映射列表中选择一个函数。通用选择表达式从这些函数设计符号中选择一个(可以是sinf、sin或sinl),然后执行该函数。在这个例子中,第一次调用singen时,参数类型是double,因此通用选择解析为sin,而第二次调用singen时,参数类型是long double,因此解析为sinl。因为该通用选择表达式没有default关联,如果的类型与任何已关联类型不匹配,则会发生错误。如果你为通用选择表达式包含了默认关联,它将匹配所有未作为关联使用的类型,包括一些你可能没有预料到的类型,如指针或结构体类型。

当结果值的类型依赖于宏参数的类型时,使用类型泛化宏扩展可能会变得困难,就像表格 9-11 中的singen示例。例如,调用singen宏并将结果赋值给特定类型的对象,或者将其结果作为参数传递给printf,可能会出现错误,因为所需的对象类型或格式说明符取决于调用的是sinsinf还是sinl。你可以在 C 标准库的<tgmath.h>头文件中找到数学函数的类型泛化宏示例。

C23 通过引入使用auto类型说明符的自动类型推断部分解决了这个问题,详细内容见第二章。在使用类型泛化宏初始化对象时,考虑使用自动类型推断,以避免在初始化时发生不必要的转换。例如,以下文件范围的定义

static auto a = sin(3.5f);
static auto p = &a;

被解释为如下形式:

static float a = sinf(3.5f);
static float *p = &a;

实际上,a是一个浮动类型,而p是一个浮动类型的指针*

在表格 9-12 中,我们将main中声明的两个变量的类型从表格 9-11 中的类型替换为auto类型说明符。这使得调用类型泛化宏更为方便,尽管这并不是严格必要的,因为程序员也可以推导出这些类型。auto类型说明符在调用类型泛化的类似函数的宏时非常有用,因为结果值的类型依赖于宏参数,从而避免在初始化时发生意外的类型转换。

表格 9-12: 具有自动类型推断的类型泛化宏

原始源代码 结果 _ 泛化 解析

|

#define singen(X) _Generic((X), \
  float: sinf,                  \
  double: sin,                  \
  long double: sinl             \
)(X)
int main(void) {
auto f = singen(1.5708f);
auto d = singen(3.14159);
}

|

int main(void) {
  auto f = sinf(1.5708f);
  auto d = sin(3.14159);
}

|

你还可以在声明类型泛化宏中的变量时使用auto类型说明符,尤其是在你不知道参数类型的情况下。

嵌入式二进制资源

你可能会发现,在运行时你需要动态加载数字资源,比如图像、声音、视频、文本文件或其他二进制数据。相反,将这些资源在编译时加载可能更为有利,这样它们可以作为可执行文件的一部分存储,而不是动态加载。

在 C23 之前,有两种常见的方法将二进制资源嵌入到程序中。对于有限的二进制数据,可以将数据指定为常量大小数组的初始化器。然而,对于较大的二进制资源,这种方法可能会引入显著的编译时间开销,因此需要使用链接脚本或其他后处理方法来保持合理的编译时间。

C23 增加了 #embed 预处理指令,可以像逗号分隔的整数常量列表一样,将数字资源直接嵌入源代码中。这个新指令允许实现优化编译时间效率,当使用嵌入的常量数据作为数组初始化器时,可以提高效率。通过使用 #embed,实现不需要分别解析每个整数常量和逗号标记;它可以直接检查字节,并使用更高效的资源映射。

表 9-13 展示了将二进制资源 file.txt 嵌入作为 buffer 数组声明的初始化器的示例。在这个示例中,file.txt 包含 ASCII 文本 meow,以简短代码列表为目的。通常,嵌入的是显著更大的二进制资源。

表 9-13: 嵌入二进制资源

原始源 结果翻译单元
unsigned char buffer[] = {#embed <file.txt>}; unsigned char buffer[] = {109, 101, 111, 119};

与 #include 类似,#embed 指令中指定的文件名可以用尖括号或双引号括起来。与 #include 不同,嵌入资源没有 系统用户 的概念,因此这两种形式的唯一区别是,双引号形式会先从与源文件相同的目录开始搜索资源,然后再尝试其他搜索路径。编译器提供了一个命令行选项来指定嵌入资源的搜索路径;有关更多细节,请参考编译器文档。

embed 指令支持多个参数来控制哪些数据嵌入到源文件中:limit、suffix、prefix 和 if_empty。其中最有用的参数是 limit 参数,用于指定嵌入多少数据(以字节为单位)。如果在编译时只需要文件头部的内容,或者文件是某些操作系统中像 /dev/urandom 这样的 无限 资源,这个参数会很有用。prefix 和 suffix 参数分别在嵌入的资源前后插入标记(如果资源已找到且不为空)。如果嵌入的资源被找到但没有内容(包括当 limit 参数被显式设置为 0 时),则 if_empty 参数会插入标记。

类似于 __has_include,你可以使用 __has_embed 预处理器操作符测试是否能找到嵌入的资源。该操作符返回:

  • STDC_EMBED_FOUND 如果资源已找到且不为空

  • STDC_EMBED_EMPTY 表示资源已找到且为空

  • STDC_EMBED_NOT_FOUND 表示未找到资源

预定义宏

实现定义了一些宏,无需你包含头文件。这些宏被称为预定义宏,因为它们是由预处理器隐式定义的,而不是由程序员显式定义的。例如,C 标准定义了各种宏,可以用来查询编译环境或提供基本功能。实现的其他方面(如编译器或目标操作系统)也会自动定义宏。表 9-14 列出了 C 标准定义的一些常见宏。你可以通过向 Clang 或 GCC 编译器传递 -E -dM 标志,获取完整的预定义宏列表。有关更多信息,请查阅你的编译器文档。

表 9-14: 预定义宏

宏名称 替换及其目的
DATE 一个字符串字面量,表示预处理翻译单元的翻译日期,格式为 Mmm dd yyyy。
TIME 一个字符串字面量,表示预处理翻译单元的翻译时间,格式为 hh:mm:ss。
FILE 一个字符串字面量,表示当前源文件的假定文件名。
LINE 一个整数常量,表示当前源代码行的假定行号。
STDC 如果实现符合 C 标准,则为整数常量 1。
STDC_HOSTED 如果实现是托管实现,则为整数常量 1;如果是独立实现,则为整数常量 0。此宏由实现条件性地定义。
STDC_VERSION 一个整数常量,表示编译器目标的 C 标准版本,例如 202311L 表示 C23 标准。
STDC_UTF_16 如果类型为 char16_t 的值是 UTF-16 编码,则为整数常量 1。该宏由实现条件性定义。
STDC_UTF_32 如果类型为 char32_t 的值是 UTF-32 编码,则为整数常量 1。该宏由实现条件性定义。
STDC_NO_ATOMICS 如果实现不支持原子类型,包括 _Atomic 类型限定符和 <stdatomic.h> 头文件,则为整数常量 1。该宏由实现条件性定义。
STDC_NO_COMPLEX 如果实现不支持复数类型或 <complex.h> 头文件,则为整数常量 1。该宏由实现条件性定义。
STDC_NO_THREADS 如果实现不支持 <threads.h> 头文件,则为整数常量 1。该宏由实现条件性定义。
STDC_NO_VLA 如果实现不支持可变长度数组,则为整数常量 1。该宏由实现条件性定义。

总结

在这一章中,你学习了预处理器提供的一些功能。你学习了如何将程序文本片段包含到翻译单元中,如何条件编译代码,如何将二进制资源嵌入到程序中,以及如何按需生成诊断信息。然后,你学习了如何定义和取消定义宏,宏是如何被调用的,以及实现中预定义的宏。预处理器在 C 语言编程中很常见,但在 C++ 编程中则较少使用。使用预处理器容易出错,因此最好遵循The CERT C Coding Standard中的建议和规则。

在下一章中,你将学习如何将程序结构化为多个翻译单元,从而创建更易维护的程序。

第十章:10 程序结构

与阿龙·巴尔曼合作

任何现实世界的系统都由多个组件构成,如源文件、头文件和库。许多组件包含资源,包括图像、声音和配置文件。从更小的逻辑组件组成程序是一种良好的软件工程实践,因为这些组件比一个单独的大文件更容易管理。

在本章中,你将学习如何将程序结构化为多个单元,这些单元包含源文件和头文件。你还将学习如何将多个目标文件链接在一起,创建库和可执行文件。

组件化原则

没有什么阻止你在单一源文件的< samp class="SANS_TheSansMonoCd_W5Regular_11">main函数中编写整个程序。然而,随着函数的增长,这种方法将迅速变得难以管理。因此,将程序分解为一组组件,通过共享边界或接口交换信息是有意义的。将源代码组织成组件使得它更易于理解,并允许你在程序的其他地方甚至与其他程序一起重用代码。

理解如何最佳地分解程序通常需要经验。程序员做出的许多决策都是由性能驱动的。例如,你可能需要最小化通过高延迟接口的通信。糟糕的硬件只能走这么远;你需要糟糕的软件才能真正破坏性能。

性能只是软件质量属性之一(ISO/IEC 25000:2014),必须与可维护性、代码可读性、可理解性、安全性和安全性平衡。例如,你可能会设计一个客户端应用程序来处理来自用户界面的输入字段验证,以避免到服务器的往返。这有助于性能,但如果服务器的输入没有验证,可能会危害安全性。一个简单的解决方案是在两个地方都验证输入。

开发人员常常做一些奇怪的事情来获得虚幻的收益。其中最奇怪的是通过调用有符号整数溢出的未定义行为来提高性能。通常,这些局部代码优化对整体系统性能没有影响,且被视为过早的优化。《计算机程序设计的艺术》(Addison-Wesley,1997)的作者唐纳德·克努斯将过早优化描述为“所有邪恶的根源”。

在本节中,我们将介绍一些基于组件的软件工程原则。

耦合与内聚

除了性能外,一个结构良好的程序的目标是实现诸如低耦合和高内聚等理想特性。内聚是衡量编程接口各元素之间共同性的标准。例如,假设一个头文件暴露了计算字符串长度、计算给定输入值的正切和创建线程的函数。这个头文件的内聚性较低,因为暴露的函数彼此无关。相反,一个暴露了计算字符串长度、连接两个字符串以及在字符串中查找子字符串的函数的头文件则具有较高的内聚性,因为所有功能都是相关的。这样,如果你需要处理字符串,你只需包含字符串头文件。类似地,构成公共接口的相关函数和类型定义应由同一个头文件暴露,以提供一个高度内聚且功能有限的接口。我们将在《数据抽象》一章中进一步讨论公共接口,见第 215 页。

耦合是衡量编程接口相互依赖程度的标准。例如,一个紧耦合的头文件不能单独被包含进程序中;相反,它必须与其他头文件按特定顺序一起包含。你可能因为多种原因将接口耦合在一起,比如共同依赖数据结构、函数之间的相互依赖或使用共享的全局状态。但当接口紧耦合时,修改程序行为变得困难,因为更改可能会对系统产生连锁反应。无论这些接口是公共接口的成员还是程序实现的细节,你都应始终力求保持接口组件之间的松耦合。

通过将程序逻辑分离成不同的、高内聚的组件,你可以更容易地推理各个组件的行为并测试程序(因为你可以独立验证每个组件的正确性)。结果是一个更易于维护、错误更少的系统。

代码重用

代码重用是一次性实现功能并在程序的不同部分重复使用,而不重复编写相同代码的做法。代码重复可能会导致微妙的意外行为、庞大臃肿的可执行文件以及增加维护成本。再说了,为什么要多次编写相同的代码呢?

函数是最低级别的可重用功能单元。任何你可能会重复多次的逻辑,都可以考虑封装成一个函数。如果功能之间只有细微差别,你可能能够创建一个参数化的函数,来实现多个用途。每个函数应当执行其他函数未重复的工作。然后,你可以将单独的函数组合起来,解决越来越复杂的问题。

将可重用的逻辑封装成函数可以提高可维护性并消除缺陷。例如,尽管你可以通过编写一个简单的 for 循环来确定一个以空字符结尾的字符串的长度,但使用 C 标准库中的 strlen 函数会更具可维护性。因为其他程序员已经熟悉 strlen 函数,他们更容易理解该函数的作用,而不是理解 for 循环的作用。此外,如果你重用现有的功能,就不太可能在临时实现中引入行为差异,还可以更容易地用性能更高或更安全的算法或实现来全局替换功能。

在设计功能接口时,必须在 通用性特定性 之间找到平衡。一个特定于当前需求的接口可能非常简洁有效,但当需求变化时,修改起来会很困难。一个通用接口可能适应未来的需求,但对于可预见的需求来说可能会显得繁琐。

数据抽象

数据抽象 是任何可重用的软件组件,它强制要求抽象的公共接口与实现细节之间有明确的分离。每个数据抽象的 公共接口 包括用户需要的类型定义、函数声明和常量定义,并放置在头文件中。数据抽象的实现细节,以及任何私有的辅助函数,都隐藏在源文件中或放在与公共接口头文件分开的地方。公共接口与私有实现的分离,使你可以在不破坏依赖于该组件的代码的情况下更改实现细节。

头文件 通常包含组件的函数声明和类型定义。例如,C 标准库的 <string.h> 头文件提供了与字符串相关功能的公共接口,而 <threads.h> 则提供了线程的实用函数。这样的逻辑分离具有低耦合性和高内聚性,使得你可以更容易地只访问所需的特定组件,减少编译时间和名称冲突的可能性。例如,如果你只需要 strlen 函数,你不需要了解线程应用程序编程接口(API)的任何内容。

另一个考虑因素是,是否应该显式包含你的头文件所需的头文件,还是要求头文件的使用者先自行包含它们。数据抽象最好是自包含的,并包括它们所使用的头文件。没有做到这一点会给抽象的使用者带来负担,并泄露关于数据抽象的实现细节。本书中的示例并不总是遵循这种做法,以保持文件列表的简洁。

源文件实现给定头文件声明的功能或执行特定程序所需的应用程序逻辑。例如,如果你有一个描述网络通信公共接口的network.h头文件,你可能会有一个network.c源文件(或者是network_win32.c用于仅 Windows,network_linux.c用于仅 Linux),它实现了网络通信逻辑。

可以通过使用头文件在两个源文件之间共享实现细节,但头文件应放在与公共接口不同的位置,以防止无意中暴露实现细节。

一个集合是数据抽象的一个很好的例子,它将基本功能与实现或底层数据结构分离开来。集合将数据元素分组,并支持诸如向集合中添加元素、从集合中移除数据元素以及检查集合是否包含特定数据元素等操作。

实现集合的方式有很多种。例如,一个数据元素的集合可以表示为一个平坦数组、一个二叉树、一个有向(可能是无环的)图,或其他不同的结构。数据结构的选择会影响算法的性能,具体取决于你表示的数据类型以及需要表示的数据量。例如,对于需要良好查找性能的大量数据,二叉树可能是更好的抽象,而对于少量固定大小的数据,平坦数组可能是更好的抽象。将集合数据抽象的接口与底层数据结构的实现分离开来,可以使实现方式发生变化,而无需更改依赖于集合接口的代码。

不透明类型

数据抽象在与隐藏信息的不透明数据类型一起使用时最为有效。在 C 语言中,不透明(或私有)数据类型是通过不完整类型表示的,如前向声明的结构体类型。不完整类型是描述一个标识符但缺少必要信息以确定该类型对象的大小或布局的类型。隐藏仅供内部使用的数据结构可以防止使用数据抽象的程序员编写依赖于实现细节的代码,因为这些细节可能会发生变化。不完整类型对数据抽象的使用者是可见的,而完全定义的类型仅对实现者可访问。

假设我们想要实现一个支持有限操作的集合,比如添加元素、移除元素和搜索元素。以下示例将<сamp class="SANS_TheSansMonoCd_W5Regular_11">collection_type实现为不透明类型,隐藏数据类型的实现细节,使得库的使用者无法访问。为此,我们创建了两个头文件:一个外部collection.h头文件由数据类型的使用者包含,另一个内部头文件仅在实现数据类型功能的文件中包含。

collection.h

typedef struct collection * collection_type;
// function declarations
extern errno_t create_collection(collection_type *result);
extern void destroy_collection(collection_type col);
extern errno_t add_to_collection(
  collection_type col, const void *data, size_t byteCount
);
extern errno_t remove_from_collection(
  collection_type col, const void *data, size_t byteCount
);
extern errno_t find_in_collection(
  const collection_type col, const void *data, size_t byteCount
);
// `--snip--`

collection_type标识符被别名为struct collection_type(一个不完整类型)。因此,公共接口中的函数必须接受指向此类型的指针,而不是实际的值类型,因为在 C 语言中使用不完整类型时有一定的限制。

在内部头文件中,struct collection_type是完全定义的,但对数据抽象的使用者不可见:

collection_priv.h

struct node_type {
  void *data;
  size_t size;
  struct node_type *next;
};

struct collection_type {
  size_t num_elements;
 struct node_type *head;
};

数据抽象的使用者仅包括外部collection.h文件,而实现抽象数据类型的模块还包括内部定义的collection_priv.h文件。这使得collection_type数据类型的实现保持私密。

可执行文件

在第九章,我们学习了编译器是一个由多个翻译阶段组成的流水线,编译器的最终输出是目标代码。翻译的最后一个阶段,称为链接阶段,将程序中所有翻译单元的目标代码链接在一起,形成最终的可执行文件。这可以是一个用户可以运行的可执行文件,比如a.outfoo.exe,一个库,或者一个更专业的程序,如设备驱动程序或固件映像(要烧录到只读存储器[ROM]中的机器代码)。链接使你能够将代码分割成独立的源文件,这些源文件可以独立编译,有助于构建可重用的组件。

是不能独立执行的可执行组件。相反,你需要将库集成到可执行程序中。你可以通过在源代码中包含库的头文件并调用已声明的函数来调用库的功能。C 标准库就是一个库的例子——你包含来自库的头文件,但不会直接编译实现库功能的源代码。相反,库的实现随预构建版本的库代码一起提供。

库允许你在他人的工作基础上构建程序的通用组件,从而可以专注于开发你程序中独特的逻辑。例如,在编写视频游戏时,重用现有库应当能够让你专注于开发游戏逻辑,而无需担心用户输入、网络通信或图形渲染的细节。使用一个编译器编译的库通常可以被用在使用不同编译器构建的程序中。

库被链接到你的应用程序中,可以是静态的或动态的。静态库,也称为归档文件,将其机器码或目标代码直接合并到生成的可执行文件中,这意味着静态库通常与程序的特定版本绑定在一起。由于静态库在链接时被集成,因此静态库的内容可以针对程序使用该库进行进一步优化。程序使用的库代码可以用于链接时优化(例如,使用-flto标志),而未使用的库代码则可以从最终的可执行文件中剥离。

动态库,也称为共享库动态共享对象,是一个没有启动例程的可执行文件。它可以与可执行文件一起打包,或单独安装,但在可执行文件调用动态库提供的函数时必须可用。许多现代操作系统会将动态库的代码加载到内存中一次,并在所有需要它的应用程序之间共享。你可以在应用程序部署后,根据需要替换不同版本的动态库。

让库与程序分开发展有其自身的优点和风险。例如,开发人员可以在应用程序已经发布后修复库中的 bug,而无需重新编译应用程序。然而,动态库提供了潜在的机会,让恶意攻击者用恶意库替换库,或者最终用户意外使用错误版本的库。也有可能在新库发布时做出破坏性更改,导致与使用该库的现有应用程序不兼容。静态库的执行速度可能稍微更快,因为目标代码(可执行文件中的二进制代码)被包含在可执行文件中,从而实现进一步的优化。通常使用动态库的好处大于其缺点。

每个库都有一个或多个头文件,包含库的公共接口,以及一个或多个源文件,实现库的逻辑。即使组件没有被转化为实际的库,通过将代码结构化为库的集合,你也能从中受益。使用实际的库可以减少意外设计紧密耦合接口的可能性,因为在这种接口中,一个组件对另一个组件的内部细节有特殊了解。

链接性

链接性是一个过程,控制接口是公共的还是私有的,并决定是否有两个标识符指向相同的实体。忽略在翻译阶段早期替换的宏和宏参数,一个标识符可以表示一个标准属性、属性前缀或属性名称;一个对象;一个函数;结构体、联合体或枚举的标签或成员;一个typedef名称;或一个标签名称。

C 语言提供了三种链接性:外部链接性、内部链接性或无链接性。每个具有外部链接性的标识符声明在程序中的所有地方都指向相同的函数或对象。引用内部链接性声明的标识符仅在包含该声明的翻译单元内指向同一个实体。如果两个翻译单元都引用相同的内部链接性标识符,它们指向的是该实体的不同实例。如果声明没有链接性,它在每个翻译单元中都是一个唯一的实体。

声明的链接性要么是显式声明的,要么是隐含的。如果你在文件作用域内声明一个实体,而没有显式指定extern或static,则该实体会被隐式赋予外部链接性。没有链接性的标识符包括函数参数、没有使用extern存储类说明符声明的块作用域标识符或枚举常量。

清单 10-1 显示了每种链接类型声明的示例。

static int i; // i has explicit internal linkage
extern void foo(int j) {
  // foo has explicit external linkage
  // j has no linkage because it is a parameter
}

清单 10-1:内部链接、外部链接和无链接的示例

如果你在文件作用域内显式声明一个标识符为 static 存储类说明符,它将具有内部链接。static 关键字仅对文件作用域的实体赋予内部链接。如果你在块作用域内将一个变量声明为 static,它将创建一个无链接的标识符,但它确实为该变量提供了静态存储持续时间。提醒一下,静态存储持续时间意味着它的生命周期是程序的整个执行过程,而且它的值只会初始化一次,在程序启动之前。static 在不同上下文中的不同含义显然是令人困惑的,因此它常常成为面试中的一个常见问题。

你可以通过使用 extern 存储类说明符声明一个外部链接标识符。只有在你之前没有声明该标识符的链接时,这才有效。如果之前的声明已经为该标识符指定了链接,那么 extern 存储类说明符将不起作用。

声明中有冲突的链接可能会导致未定义行为;有关更多信息,请参阅 CERT C 规则 DCL36-C,“不要声明具有冲突链接分类的标识符”。

清单 10-2 显示了带有隐式链接的示例声明。

foo.c

void func(int i) {// implicit external linkage
  // i has no linkage
}
static void bar(); // internal linkage, different bar from bar.c
extern void bar() {
  // bar still has internal linkage because the initial declaration
  // was declared as static; this extern specifier has no effect
}

清单 10-2:隐式链接的示例

清单 10-3 显示了带有显式链接的示例声明。

bar.c

extern void func(int i); // explicit external linkage
static void bar() {  // internal linkage; different bar from foo.c
  func(12); // calls func from foo.c
}
int i; // external linkage; doesn’t conflict with i from foo.c or bar.c
void baz(int k) {// implicit external linkage
  bar(); // calls bar from bar.c, not foo.c
}

清单 10-3:显式链接的示例

你的公共接口中的标识符应该具有外部链接,以便可以从其翻译单元外部调用它们。作为实现细节的标识符应该使用内部链接或无链接进行声明(前提是它们不需要从另一个翻译单元中引用)。实现这一目标的常见方法是在头文件中声明公共接口函数,可以使用也可以不使用 extern 存储类说明符(这些声明默认具有外部链接,但明确声明它们为 extern 也没有坏处),并以类似的方式在源文件中定义公共接口函数。

然而,在源文件中,所有实现细节的声明应显式声明为static,以保持它们的私有性——仅对该源文件可访问。你可以通过使用#include预处理指令来包含头文件中声明的公共接口,从而访问另一个文件中的接口。一个好的经验法则是,不需要在文件外部可见的文件作用域实体应声明为static。这种做法有助于减少全局命名空间污染,并降低翻译单元之间发生意外交互的可能性。

构建一个简单的程序

为了学习如何构建一个复杂的真实世界程序,我们先开发一个简单的程序来判断一个数字是否为质数。质数(或称为素数)是一个自然数,不能通过将两个较小的自然数相乘得到。我们将编写两个独立的组件:一个包含测试功能的静态库和一个提供库用户界面的命令行应用程序。

primetest程序接受以空格分隔的整数值列表作为输入,然后输出每个值是否为质数。如果任何输入无效,程序将输出一条有用的消息,解释如何使用该界面。

在探讨如何构建程序之前,我们先来看看用户界面。首先,我们打印命令行程序的帮助文本,如清单 10-4 所示。

// print command line help text
static void print_help() {
  puts("primetest num1 [num2 num3 ... numN]\n");
  puts("Tests positive integers for primality.");
  printf("Tests numbers in the range [2-%llu].\n", ULLONG_MAX);
}

清单 10-4:打印帮助文本

print_help函数将使用信息打印到标准输出,说明如何使用该命令。

接下来,由于命令行参数以文本形式传递给程序,我们定义了一个实用函数,将它们转换为整数值,如清单 10-5 所示。

// converts a string argument arg to an unsigned long long value referenced by val
// returns true if the argument conversion succeeds and false if it fails
static bool convert_arg(const char *arg, unsigned long long *val) {
  char *end;

  // strtoull returns an in-band error indicator; clear errno before the call
  errno = 0;
  *val = strtoull(arg, &end, 10);

  // check for failures where the call returns a sentinel value and sets errno
  if ((*val == ULLONG_MAX) && errno) return false;
  if (*val == 0 && errno) return false;
  if (end == arg) return false;

  // If we got here, the argument conversion was successful.
  // However, we want to allow only values greater than one,
  // so we reject values <= 1.
  if (*val <= 1) return false;
  return true;
}

清单 10-5:转换单个命令行参数

<samp class="SANS_TheSansMonoCd_W5Regular_11">convert_arg函数接受一个字符串参数作为输入,并使用输出参数报告转换后的结果。输出参数通过指针将函数结果返回给调用者,使得除了函数返回值外,还能返回多个值。若参数转换成功,函数返回<samp class="SANS_TheSansMonoCd_W5Regular_11">true,如果失败则返回<samp class="SANS_TheSansMonoCd_W5Regular_11">false。convert_arg函数使用<samp class="SANS_TheSansMonoCd_W5Regular_11">strtoull函数将字符串转换为<samp class="SANS_TheSansMonoCd_W5Regular_11">unsigned long long整数值,并注意妥善处理转换错误。此外,由于质数的定义排除了 0、1 和负数,convert_arg`函数会将这些视为无效输入。

我们在 sample 中使用了<samp class="SANS_TheSansMonoCd_W5Regular_11">convert_arg工具函数,这个函数位于<samp class="SANS_TheSansMonoCd_W5Regular_11">convert_cmd_line_args函数,主要作用是对所有提供的命令行参数进行循环,并尝试将每个参数从字符串转换为整数。

static unsigned long long *convert_cmd_line_args(int argc,
                                                 const char *argv[],
                                                 size_t *num_args) {
  *num_args = 0;

  if (argc <= 1) {
    // no command line arguments given (the first argument is the
    // name of the program being executed)
    print_help();
    return nullptr;
  }

  // We know the maximum number of arguments the user could have passed,
  // so allocate an array large enough to hold all the elements. Subtract
  // one for the program name itself. If the allocation fails, treat it as
  // a failed conversion (it is OK to call free(nullptr)).
 unsigned long long *args =
      (unsigned long long *)malloc(sizeof(unsigned long long) * (argc - 1));
  bool failed_conversion = (args == nullptr);
  for (int i = 1; i < argc && !failed_conversion; ++i) {
    // Attempt to convert the argument to an integer. If we
    // couldn't convert it, set failed_conversion to true.
    unsigned long long one_arg;
    failed_conversion |= !convert_arg(argv[i], &one_arg);
    args[i - 1] = one_arg;
  }

  if (failed_conversion) {
    // free the array, print the help, and bail out
    free(args);
    print_help();
    return nullptr;
  }

  *num_args = argc - 1;
  return args;
}

清单 10-6:处理所有命令行参数

如果任何一个参数无法转换,它会调用<samp class="SANS_TheSansMonoCd_W5Regular_11">print_help函数来向用户报告正确的命令行用法,并返回一个空指针。该函数负责分配一个足够大的缓冲区来存储整数数组。它还处理所有错误情况,比如内存不足或参数转换失败。如果函数成功,它会返回一个整数数组给调用者,并将转换后的参数个数写入<samp class="SANS_TheSansMonoCd_W5Regular_11">num_args参数。返回的数组是已分配的存储空间,当不再需要时必须进行释放。

有多种方法可以判断一个数字是否为质数。最直接的方法是通过测试一个值N,判断它是否能被[2..N – 1]整除。这种方法随着N值的增大,性能表现较差。相反,我们将使用一种为质数测试设计的算法。清单 10-7 展示了 Miller-Rabin 质数测试的非确定性实现,适用于快速测试一个值是否可能是质数(Schoof 2008)。请参阅 Schoof 的论文,了解 Miller-Rabin 质数测试算法背后的数学原理。

static unsigned long long power(unsigned long long x, unsigned long long y,
                                unsigned long long p) {
  unsigned long long result = 1;
  x %= p;

  while (y) {
    if (y & 1) result = (result * x) % p;
    y >>= 1;
    x = (x * x) % p;
  }
 return result;
}

static bool miller_rabin_test(unsigned long long d, unsigned long long n) {
  unsigned long long a = 2 + rand() % (n - 4);
  unsigned long long x = power(a, d, n);

  if (x == 1 || x == n - 1) return true;

  while (d != n - 1) {
    x = (x * x) % n;
    d *= 2;

    if (x == 1) return false;
    if (x == n - 1) return true;
  }
  return false;
}

清单 10-7:Miller-Rabin 质数测试算法

Miller-Rabin 素性测试的接口是 清单 10-8 中显示的 is_prime 函数。该函数接受两个参数:待测试的数字 (n) 和执行测试的次数 (k)。较大的 k 值提供更精确的结果,但会降低性能。我们将把 清单 10-6 中的算法与 is_prime 函数一起放入静态库中,该库将提供公共接口。

bool is_prime(unsigned long long n, unsigned int k) {
  if (n <= 1 || n == 4) return false;
  if (n <= 3) return true;

  unsigned long long d = n - 1;
  while (d % 2 == 0) d /= 2;

  for (; k != 0; --k) {
    if (!miller_rabin_test(d, n)) return false;
  }
  return true;
}

清单 10-8:Miller-Rabin 素性测试算法的接口

最后,我们需要将这些工具函数组合成一个程序。清单 10-9 展示了 main 函数的实现。它使用固定次数的 Miller-Rabin 测试,并报告输入值是可能是素数还是绝对不是素数。它还负责释放由 convert_cmd_line_args 分配的内存。

int main(int argc, char *argv[]) {
  size_t num_args;
  unsigned long long *vals = convert_cmd_line_args(argc, argv, &num_args);

 if (!vals) return EXIT_FAILURE;

  for (size_t i = 0; i < num_args; ++i) {
    printf("%llu is %s.\n", vals[i],
           is_prime(vals[i], 100) ? "probably prime" : "not prime");
  }

  free(vals);
  return EXIT_SUCCESS;
}

清单 10-9: main 函数

main 函数调用 convert_cmd_line_args 函数,将命令行参数转换为 unsigned long long 类型的整数数组。程序对该数组中的每个参数进行循环,调用 is_prime 来判断每个值是可能是素数,还是根据 Miller-Rabin 素性测试判断为非素数。

现在我们已经实现了程序逻辑,我们将生成所需的构建产物。我们的目标是生成一个静态库,其中包含 Miller-Rabin 实现和一个命令行应用程序驱动程序。

构建代码

创建一个名为isprime.c的新文件,包含来自清单 10-8 和 10-9 的代码(按此顺序),并在文件顶部添加 #include 指令,分别为 "isprime.h" 和 <stdlib.h>。引号和尖括号环绕头文件,对于告诉预处理器在哪里查找这些文件非常重要,正如第九章中讨论的那样。接下来,创建一个名为 isprime.h 的头文件,包含来自清单 10-10 的代码,以提供静态库的公共接口,并添加头文件保护。

#ifndef PRIMETEST_IS_PRIME_H
#define PRIMETEST_IS_PRIME_H

bool is_prime(unsigned long long n, unsigned k);

#endif // PRIMETEST_IS_PRIME_H

清单 10-10:静态库的公共接口

创建一个名为 driver.c 的新文件,包含来自清单 10-5、10-6、10-7 和 10-10 的代码(按此顺序),并在文件顶部添加以下 #include 指令:"isprime.h"、<assert.h>、<errno.h>、<limits.h>、<stdio.h> 和 <stdlib.h>。在我们的示例中,所有三个文件都在同一个目录中,但在实际项目中,您可能会根据构建系统的约定将文件放置在不同的目录中。创建一个名为 bin 的本地目录,用于存放本示例的构建产物。

我们使用 Clang 来创建静态库和可执行程序,但 GCC 和 Clang 都支持示例中的命令行参数,因此两者的编译器都能使用。首先,将两个 C 源文件编译成目标文件,并将它们放置在 bin 目录中:

% **cc -c -std=c23 -Wall -Wextra -pedantic isprime.c -o bin/isprime.o**
% **cc -c -std=c23 -Wall -Wextra -pedantic driver.c -o bin/driver.o**

对于旧版编译器,可能需要将 -std=c23 替换为 -std=c2x。

如果执行命令时出现错误,例如

unable to open output file 'bin/isprime.o': 'No such file or directory'

然后创建本地 bin 目录,并再次尝试该命令。-c 标志指示编译器将源代码编译为目标文件,而不调用链接器生成可执行输出。我们需要目标文件来创建库。-o 标志指定输出文件的路径名。

执行命令后,bin目录应包含两个对象文件:isprime.odriver.o。这些文件包含每个翻译单元的对象代码。您可以将它们直接链接在一起,生成可执行程序。然而,在这种情况下,我们将创建一个静态库。为此,执行ar命令,在bin目录中生成名为libPrimalityUtilities.a的静态库:

% **ar rcs bin/libPrimalityUtilities.a bin/isprime.o**

r选项指示ar命令用新文件替换档案中现有的文件,c选项创建档案,s选项将对象文件索引写入档案(这等同于运行ranlib命令)。这将创建一个单一的档案文件,其结构允许检索用于创建档案的原始对象文件,就像一个压缩的 tarball 或 ZIP 文件。根据约定,Unix 系统上的静态库以lib为前缀,文件扩展名为.a

现在,您可以将驱动程序对象文件链接到libPrimalityUtilities.a静态库,以生成名为primetest的可执行文件。您可以通过不带-c标志来调用编译器,这样会调用默认系统链接器并传递适当的参数,或者直接调用链接器来完成。通过以下方式调用编译器,使用默认系统链接器:

% **cc bin/driver.o -Lbin -lPrimalityUtilities -o bin/primetest**

-L标志指示链接器在本地bin目录中查找要链接的库,-l标志指示链接器将libPrimalityUtilities.a库链接到输出中。在命令行参数中省略lib前缀和.a后缀,因为链接器会自动添加它们。例如,要链接libm数学库,指定-lm作为链接目标。与编译源文件一样,链接文件的输出由-o标志指定。

现在,您可以测试程序,看它是否能判断值是可能为素数还是绝对不是素数。一定要尝试负数、已知的素数和非素数,以及不正确的输入,具体请参见 Listing 10-11。

% **./bin/primetest 899180**
899180 is not prime
% **./bin/primetest 8675309**
8675309 is probably prime
% **./bin/primetest 0**
primetest num1 [num2 num3 ... numN]

Tests positive integers for primality.
Tests numbers in the range [2-18446744073709551615].

Listing 10-11:使用示例输入运行 primetest 程序

数字 8,675,309 是素数。

总结

在本章中,你了解了松散耦合、高内聚、数据抽象和代码重用的好处。此外,你还学习了相关的语言构造,如不透明数据类型和链接。你了解了一些关于如何在项目中组织代码的最佳实践,并看到了一个通过不同类型的可执行组件构建简单程序的示例。这些技能在你从编写练习程序转向开发和部署现实世界的系统时非常重要。

在下一章中,我们将学习如何使用各种工具和技术来创建高质量的系统,包括断言、调试、测试,以及静态和动态分析。这些技能都是开发安全、可靠和高效的现代系统所必需的。

第十一章:11 调试、测试和分析

本章描述了用于生成正确、有效、安全、可靠和强健程序的工具和技术,包括静态(编译时)和运行时断言、调试、测试、静态分析和动态分析。章节还讨论了在软件开发过程中不同阶段推荐使用的编译器标志。

本章标志着从学习 C 语言编程到专业 C 语言编程的过渡。用 C 语言编程相对容易,但精通 C 语言编程是一个终生的事业。现代 C 语言编程要求采用有纪律的方法来开发和部署安全、可靠且高效的系统。

断言

断言是一个具有布尔值的函数,称为谓词,它表达了关于程序的逻辑命题。你使用断言来验证在实现程序时所做的特定假设是否保持有效。C 语言支持静态断言,可以在编译时使用static_assert进行检查,也支持运行时断言,这些断言在程序执行过程中通过assert进行检查。assert宏定义在<assert.h>头文件中。在 C23 中,static_assert成为了一个关键字。在 C11 中,static_assert作为一个宏定义在<assert.h>头文件中。在此之前,C 语言没有静态断言。

静态断言

静态断言可以使用static_assert关键字表示,如下所示:

static_assert(`integer-constant-expression`, `string-literal`);

自 C23 以来,static_assert还接受单一参数形式:

static_assert(`integer-constant-expression`);

如果整数常量表达式的值不等于 0,则static_assert声明没有效果。如果整数常量表达式等于 0,则编译器将生成一条诊断信息,并显示字符串文字的文本(如果存在的话)。

你可以使用静态断言在编译时验证假设,例如特定的实现定义行为。任何实现定义行为的变化将在编译时被诊断出来。

让我们来看三个使用静态断言的例子。首先,在示例 11-1 中,我们使用static_assert来验证struct packed没有填充字节。

struct packed {
  int i;
  char *p;
};

static_assert(
  sizeof(struct packed) == sizeof(int) + sizeof(char *),
  "struct packed must not have any padding"
);

示例 11-1:断言无填充字节

本例中的静态断言谓词测试了打包结构的大小是否与其int和char *成员的组合大小相同。例如,在 x86-32 架构上,int和char *都是 4 字节,结构体没有填充;而在 x86-64 架构上,int是 4 字节,char *是 8 字节,编译器会在这两个字段之间添加 4 字节的填充。

静态断言的一个好用例是记录所有与实现相关的假设行为。这将防止代码在移植到其他实现时编译失败,特别是当这些假设在新环境中无效时。

由于静态断言是一个声明,它可以出现在文件作用域中,紧跟其后的是它所验证的struct的定义。

对于第二个例子,clear_stdin函数,如示例 11-2 所示,调用getchar函数从stdin读取字符,直到文件末尾。

#include <stdio.h>
#include <limits.h>

void clear_stdin() {
  int c;

  do {
    c = getchar();
    static_assert(
      sizeof(unsigned char) < sizeof(int),
      "FIO34-C violation"
    );
  } while (c != EOF);
}

示例 11-2:使用 static_assert 验证整数大小

每个字符作为一个 unsigned char 被转换为 int 获取。通常做法是将 getchar 函数返回的字符与 EOF 进行比较,通常是在一个 do...while 循环中,用于判断是否已读取所有可用字符。为了使该函数循环正常工作,终止条件必须能够区分字符和 EOF。然而,C 标准允许 unsigned char 和 int 拥有相同的范围,这意味着在某些实现中,这个 EOF 测试可能会返回假阳性,在这种情况下,do...while 循环可能会过早终止。由于这是一个不常见的情况,你可以使用 static_assert 来验证 do...while 循环是否能正确区分有效字符和 EOF。

在这个示例中,静态断言验证了 sizeof(unsigned char) < sizeof(int)。静态断言被放置在依赖于该假设为真的代码附近,这样可以方便地定位如果假设被违反时需要修复的代码。由于静态断言在编译时进行评估,因此将其放入可执行代码中对程序的运行时效率没有影响。有关此主题的更多信息,请参见 CERT C 规则 FIO34-C,“区分从文件读取的字符与 EOF 或 WEOF”。

最后,在 清单 11-3 中,我们使用 static_assert 执行编译时边界检查。

static const char prefix[] = "Error No: ";
constexpr int size = 14;
char str[size];

// ensure that str has sufficient space to store at
// least one additional character for an error code
static_assert(
  sizeof(str) > sizeof(prefix),
  "str must be larger than prefix"
);
strcpy(str, prefix);

清单 11-3:使用 static_assert 执行边界检查

该代码片段使用 strcpy 将常量字符串 prefix 复制到静态分配的数组 str。静态断言确保 str 有足够的空间至少存储一个额外字符,以便在调用 strcpy 后存储错误代码。

如果开发人员在维护过程中减小了 size 或将前缀字符串改为 "Error Number: ",该假设可能会变得无效。添加了静态断言后,维护人员将收到有关该问题的警告。

请记住,字符串文字是为开发人员或维护人员提供的消息,而不是系统的最终用户。它旨在提供有助于调试的信息。

运行时断言

assert 宏将运行时诊断测试注入到程序中。它在 <assert.h> 头文件中定义,并接受一个标量表达式作为唯一参数:

#define assert(scalar-expression) /* implementation-defined */

assert 宏是由实现定义的。如果标量表达式等于 0,宏展开通常会将失败调用的信息(包括参数文本、源文件名 __FILE__、源行号 __LINE__,以及封闭函数的名称 __func__)写入标准错误流 stderr。写入该信息后,assert 宏会调用 abort 函数。

在 列表 11-4 中显示的 dup_string 函数使用运行时断言来检查 size 参数是否小于或等于 LIMIT,并且 str 不是空指针。

void *dup_string(size_t size, char *str) {
  assert(size <= LIMIT);
  assert(str != nullptr);
  // `--snip--`
}

列表 11-4:使用 assert 来验证程序条件

这些断言的消息可能会呈现以下形式:

Assertion failed: size <= LIMIT, function dup_string, file foo.c, line 122.
Assertion failed: str != nullptr, function dup_string, file foo.c, line 123.

隐式假设是调用者在调用 dup_string 之前验证参数,以确保函数永远不会以无效参数被调用。然后,在开发和测试阶段使用运行时断言来验证这个假设。

断言的谓词表达式通常会在断言失败时的错误消息中报告,这使得你可以在断言失败时,使用 && 将字符串字面量与断言谓词结合,生成额外的调试信息。这样做总是安全的,因为 C 中的字符串字面量永远不会有空指针值。例如,我们可以将 清单 11-4 中的断言重写为具有相同功能,但在断言失败时提供额外的上下文,如 清单 11-5 所示。

void *dup_string(size_t size, char *str) {
  assert(size <= LIMIT && "size is larger than the expected limit");
  assert(str != nullptr && "the caller must ensure str is not null");
  // `--snip--`
}

清单 11-5:使用 assert 并附加额外的上下文信息

在代码部署之前,应该通过定义 NDEBUG 宏来禁用断言(通常作为传递给编译器的标志)。如果在源文件中 <assert.h> 包含的地方定义了 NDEBUG 宏名称,则 assert 宏被定义如下:

#define assert(ignore) ((void)0)

宏之所以不会扩展为空,是因为如果它这样做,像这样的代码就会编译失败:

assert(thing1) // missing semicolon
assert(thing2);

这段代码在发布模式下会编译,但在调试模式下不会编译。它扩展为 ((void) 0) 而不是 0,目的是防止出现没有效果的语句警告。assert 宏会根据每次包含 <assert.h> 时的 NDEBUG 当前状态进行重新定义。

使用静态断言来检查在编译时可以检查的假设,使用运行时断言来检测在测试期间无效的假设。因为运行时断言通常在部署之前被禁用,所以避免使用它们来检查正常操作中可能出现的条件,例如以下情况:

  • 无效输入

  • 打开、读取或写入流时出错

  • 动态分配函数中的内存不足情况

  • 系统调用错误

  • 无效权限

你应该改为将这些检查实现为正常的错误检查代码,这些代码始终包含在可执行文件中。断言应该仅用于验证代码中设计的前置条件、后置条件和不变式(编程错误)。

编译器设置和标志

编译器通常默认不启用优化或安全加固。相反,你可以通过构建标志启用优化、错误检测和安全加固(Weimer 2018)。在下一节中,我将推荐 GCC、Clang 和 Visual C++的特定标志,首先介绍如何以及为什么你可能需要使用它们。

根据你想要实现的目标选择你的构建标志。软件开发的不同阶段需要不同的编译器和链接器配置。某些标志,例如警告,适用于所有阶段。其他标志,例如调试或优化级别,则特定于每个阶段。

构建 构建阶段的目标是充分利用编译器分析,在调试之前消除缺陷。在此阶段处理众多诊断信息可能会让人觉得烦琐,但比起通过调试和测试来发现这些问题,或等到代码发布后才发现问题,要好得多。在构建阶段,你应该使用能最大化诊断信息的编译器选项,以帮助你尽可能多地消除缺陷。

调试 在调试过程中,你通常是想确定代码为什么不工作。为了最好地完成这一任务,请使用一组包含调试信息、允许断言发挥作用并能快速进行编译-调试循环的编译器标志。

测试 在测试过程中,你可能希望保留调试信息并保持断言启用,以帮助识别发现的任何问题的根本原因。可以注入运行时插装来帮助检测错误。

配置引导优化 此配置定义了控制编译器如何向其正常生成的代码添加运行时插装的编译器和链接器标志。插装的一个目的是收集性能分析统计数据,这些数据可以用来找到程序的热点,从而进行配置引导优化。

发布 最后阶段是将代码构建到部署到操作环境的版本。在部署系统之前,确保彻底测试你的发布配置,因为使用不同的编译标志集可能会引发新的缺陷,例如由优化引起的潜在未定义行为或时序效应。

接下来,我将介绍一些你可能希望在编译器和软件开发阶段使用的特定编译器和链接器标志。

GCC 和 Clang 标志

表 11-1 列出了 GCC 和 Clang 的推荐编译器和链接器选项(即标志)。你可以在 GCC 手册中找到编译器和链接器选项的文档(<wbr>gcc<wbr>.gnu<wbr>.org<wbr>/onlinedocs<wbr>/gcc<wbr>/Invoking<wbr>-GCC<wbr>.html) 和 Clang 编译器用户手册中(<wbr>clang<wbr>.llvm<wbr>.org<wbr>/docs<wbr>/UsersManual<wbr>.html#command<wbr>-line<wbr>-options)。

表 11-1: GCC 和 Clang 的推荐编译器和链接器标志

标志 用途
-D_FORTIFY_SOURCE=2 检测缓冲区溢出
-fpie -Wl,-pie 地址空间布局随机化所需
-fpic -shared 禁用共享库的文本重定位
-g3 生成大量调试信息
-O2 优化代码以提高速度/空间效率
-Wall 启用推荐的编译器警告
-Wextra 启用更多推荐的编译器警告
-Werror 将警告转换为错误
-std=c23 指定语言标准
-pedantic 发出严格遵守标准所要求的警告
-Wconversion 警告可能改变值的隐式转换
-Wl,-z,noexecstack 标记栈段为不可执行
-fstack-protector-strong 为函数添加栈保护

-O

大写字母 -O 标志控制 编译器优化。在优化级别 0(-O0)下,大多数优化都被完全禁用。这是当没有通过命令行选项设置优化级别时的默认值。同样,-Og 标志会抑制那些可能妨碍调试体验的优化过程。

许多诊断仅在更高的优化级别下由 GCC 发出,例如 -02 或 -Os。为了确保在开发期间能够识别问题,请在编译和分析阶段使用与计划在生产中采用的相同(更高的)优化级别。另一方面,Clang 不需要优化器发出诊断。因此,Clang 可以在编译/分析和调试阶段禁用优化来运行。

-Os 编译器选项优化程序大小,启用所有 -O2 优化,除了那些通常会增加代码大小的优化。-Oz 编译器选项则更加激进地优化程序大小,而不是速度,这可能会增加执行的指令数,尤其是当这些指令需要更少的字节来编码时。-Oz 选项的行为类似于 -Os,并且可以在 Clang 中使用,但仅与 -mno-outline 一起使用。-Oz 编译器选项可以在 GCC 12.1 或更高版本中使用。

-glevel

-glevel 标志生成操作系统本地格式的调试信息。你可以通过设置调试 level 来指定生成多少信息。默认级别是 -g2。级别 3(-g3)包括额外的信息,例如程序中所有宏定义。级别 3 还允许你在支持此功能的调试器中展开宏。

对于调试,应该使用不同的设置。优化级别应保持较低或禁用,以便机器指令能够与源代码紧密对应。还应包含符号来帮助调试。-O0 -g3 编译器标志是一个不错的默认值,尽管其他选项也可以接受。

考虑以下程序:

#include <stdio.h>
#include <stdlib.h>

#define HELLO "hello world!"

int main()
{
  puts(HELLO);

  return EXIT_SUCCESS;
}

-Og编译器选项仅影响优化级别,而不启用调试符号:

$ **gcc -Og hello.c -o hello**
$ **gdb hello**
(...)
(No debugging symbols found in hello)
(gdb)

使用-Og -g编译会提供一些符号:

$ **gcc -Og -g hello.c -o hello**
$ **gdb hello**
(...)
Reading symbols from hello...
(gdb) break main
Breakpoint 1 at 0x1149: file hello.c, line 6.
(gdb) start
Temporary breakpoint 2 at 0x1149: file hello.c, line 6.
Starting program: /home/test/Documents/test/hello

Breakpoint 1, main () at hello.c:6
6      int main()
(gdb) print HELLO
No symbol "HELLO" in current context.
(gdb)

使用-Og -g3编译会添加更多符号:

$ **gcc -Og -g3 hello.c -o hello**
$ **gdb hello**
(...)
Reading symbols from hello...
(gdb) break main
Breakpoint 1 at 0x1149: file hello.c, line 6.
(gdb) start
Temporary breakpoint 2 at 0x1149: file hello.c, line 6.
Starting program: /home/test/Documents/test/hello

Breakpoint 1, main () at hello.c:6
6      int main()
(gdb) print HELLO
$1 = "hello world!"
(gdb)

-g3选项会生成操作系统本地格式的调试信息,而-ggdb3选项则指示 GCC 使用 GNU 项目调试器(GDB)可用的最具表达力的格式。因此,如果你仅使用 GDB 进行调试,-Og -ggdb3也是一个不错的选择。

-O0 -g3选项推荐用于标准的编辑-编译-调试周期。

-Wall 和 -Wextra

编译器通常默认只启用最保守的正确诊断信息。可以启用额外的诊断信息,以便更积极地检查源代码中的问题。使用以下标志,可以在用 GCC 和 Clang 编译代码时启用额外的诊断信息:-Wall和-Wextra。

-Wall和-Wextra编译器标志启用预定义的编译时警告集。-Wall集中的警告通常可以通过修改代码来轻松避免或消除。而-Wextra集中的警告要么是情境性的,要么是指示更难避免的问题构造,在某些情况下可能是必要的。

尽管名称如此,-Wall和-Wextra选项并不会启用所有可能的警告诊断;它们只启用预定义的子集。要查看由-Wall和-Wextra编译器标志启用的完整特定警告列表,请在 GCC 中运行:

$ **gcc -Wall -Wextra -Q --help=warning**

或者,你可以查阅 GCC 警告选项和 Clang 诊断标志的文档。

-Wconversion

数据类型转换可能以意想不到的方式改变数据值。从指针中加减这些值可能会导致内存安全问题。-Wconversion编译器选项会发出警告:

  • 可能会改变值的隐式转换,包括浮点数与整数值之间的转换

  • 有符号和无符号整数之间的转换,例如:

unsigned ui = -1;
  • 转换为较小的类型

关于有符号和无符号整数之间转换的警告可以通过使用 -Wno-sign-conversion 来禁用,但这些警告通常有助于发现某些类型的缺陷和安全漏洞。-Wconversion 命令行选项应保持启用状态。

-Werror

-Werror 标志将所有警告转化为错误,要求您在开始调试之前解决它们。此标志简单地鼓励良好的编程规范。

-std=

-std= 标志可用于指定语言标准为 c89、c90、c99、c11、c17 或 c23(如果使用较旧的编译器,可能需要指定 -std=c2x)。如果没有指定 C 语言方言选项,GCC 13 的默认值为 -std=gnu17,它为 C 语言提供了一些扩展,这些扩展在极少数情况下可能与 C 标准发生冲突。为了提高可移植性,请指定您使用的标准。为了访问新语言功能,请指定最近的标准。如果您正在阅读 Effective C 的第二版,一个不错的选择是 -std=c23。

-pedantic

-pedantic 标志会在代码偏离严格符合标准时发出警告。此标志通常与 -std= 标志一起使用,以提高代码的可移植性。

-D_FORTIFY_SOURCE=2

_FORTIFY_SOURCE 宏为检测对内存和字符串进行操作的函数中的缓冲区溢出提供了轻量级支持。该宏无法检测所有类型的缓冲区溢出,但通过使用 -D_FORTIFY_SOURCE=2 编译源代码,可以为那些复制内存并可能导致缓冲区溢出的函数(例如 memcpy、memset、strcpy、strcat 和 sprintf)提供额外的验证。一些检查可以在编译时执行并生成诊断信息;其他检查则在运行时执行,并可能导致运行时错误。

_FORTIFY_SOURCE 宏要求启用优化。因此,它必须在未优化的调试构建中禁用。

要覆盖预定义的 _FORTIFY_SOURCE 值,首先使用 -U_FORTIFY_SOURCE 关闭它,然后用 -D_FORTIFY_SOURCE=2 再次启用。这将消除宏被重新定义的警告。

自 GCC 12 版本和 GNU C 库(glibc)2.34 版本起,_FORTIFY_SOURCE=3 宏提供了改进的编译器检查,用于检测缓冲区溢出。glibc 的 -D_FORTIFY_SOURCE={1,2,3} 宏在很大程度上依赖于 GCC 特定的实现细节。Clang 实现了自己风格的强化函数调用。

对于使用 Clang 和 GCC 版本低于 12.0 的分析、测试和生产构建,请指定 -D_FORTIFY_SOURCE=2(推荐)或 -D_FORTIFY_SOURCE=1,对于 GCC 12.0 及更高版本,请使用 _FORTIFY_SOURCE=3。

-fpie -Wl, -pie, 和 -fpic -shared

地址空间布局随机化(ASLR) 是一种安全机制,通过随机化进程的内存空间来防止攻击者预测他们试图执行的代码的位置。你可以在 《C 和 C++ 安全编码》(Seacord 2013)中了解更多关于 ASLR 和其他安全缓解措施的信息。

必须指定 -fpie -Wl, 和 -pie 标志来创建位置无关的可执行程序,并使得能够为主程序(可执行文件)启用 ASLR。然而,尽管这些选项生成的主程序代码是位置无关的,但它使用了一些在共享库(动态共享对象)中不能使用的重定位。对于这些,使用 -fpic 并使用 -shared 进行链接,以避免在支持位置相关共享库的架构上进行文本重定位。动态共享对象始终是位置无关的,因此支持 ASLR。

-Wl,-z,noexecstack

包括 OpenBSD、Windows、Linux 和 macOS 在内的多个操作系统,在内核中强制执行减少权限,以防止进程地址空间的任何部分同时具有可写和可执行权限。这一政策称为 W^X。

-Wl,-z,noexecstack 链接器选项告诉链接器将栈段标记为不可执行,这使得操作系统(OS)在加载程序可执行文件到内存时能够配置内存访问权限。

-fstack-protector-strong

-fstack-protector-strong 选项通过添加栈金丝雀,保护应用程序免受最常见的栈缓冲区溢出攻击。-fstack-protector 选项通常被认为不足,而 -fstack-protector-all 选项则被认为过度。-fstack-protector-strong 选项作为这两者之间的折衷方案被引入。

Visual C++ 选项

Visual C++ 提供了种类繁多的编译器选项,其中许多选项与 GCC 和 Clang 的选项类似。一个显著的区别是,Visual C++ 通常使用斜杠(/)字符而不是连字符(-)来表示标志。表 11-2 列出了 Visual C++ 的推荐编译器和链接器标志。(有关 Visual C++ 选项的更多信息,请参阅 <wbr>docs<wbr>.microsoft<wbr>.com<wbr>/en<wbr>-us<wbr>/cpp<wbr>/build<wbr>/reference<wbr>/compiler<wbr>-options<wbr>-listed<wbr>-by<wbr>-category.)

表 11-2: Visual C++ 推荐的编译器标志

标志 目的
/guard:cf 添加控制流保护安全检查
/analyze 启用静态分析
/sdl 启用安全功能
/permissive- 指定编译器的标准符合模式
/O2 将优化级别设置为 2
/W4 将编译器警告设置为级别 4
/WX 将警告视为错误
/std:clatest 选择最新/最强的语言版本

这些选项中的几个类似于 GCC 和 Clang 编译器提供的选项。/O2优化级别适用于部署代码,而/Od禁用优化,以加速编译并简化调试。/W4警告级别适用于新代码,因为它大致相当于 GCC 和 Clang 中的-Wall。Visual C++ 中的/Wall选项不推荐使用,因为它会产生大量假阳性。/WX选项将警告转换为错误,相当于 GCC 和 Clang 中的-Werror标志。我将在接下来的部分中详细介绍其余的标志。

/guard:cf

当你指定控制流保护(CFG)选项时,编译器和链接器会插入额外的运行时安全检查,以检测是否存在危害代码的尝试。/guard:cf选项必须同时传递给编译器和链接器。

/analyze

/analyze 标志启用静态分析,它提供有关代码中可能存在缺陷的信息。我在《静态分析》一节中详细讨论了静态分析,见 第 251 页。

/sdl

/sdl 标志启用额外的安全特性,包括将额外的安全相关警告视为错误,并启用其他安全代码生成特性。它还启用了来自 Microsoft 安全开发生命周期 (SDL) 的其他安全功能。在所有涉及安全的生产构建中,都应该使用 /sdl 标志。

/permissive-

你可以使用 /permissive- 来帮助识别并修复代码中的合规性问题,从而提高代码的正确性和可移植性。这个选项禁用了宽松行为,并设置了 /Zc 编译器选项以严格遵守规范。在集成开发环境(IDE)中,这个选项还会下划线标出不符合规范的代码。

/std:clatest

/std:clatest 选项启用了所有当前实施的、为 C23 提出的编译器和标准库特性。在写作时,尚未提供 /std:c23,但一旦可用,你就可以使用它来构建 C23 代码。

调试

我从事专业编程工作已经 42 年了。在这段时间里,也许只有一两次,我写的程序在第一次尝试时就成功编译并运行了。对于所有其他情况,都会涉及调试。

让我们调试一个有问题的程序。示例中展示的程序是 vstrcat 函数的早期版本。我们在第七章中回顾了这个程序的最终版本,但这个版本尚未准备好部署。

#include <stdarg.h>
#include <string.h>
#include <stdio.h>
#include <stddef.h>

#define name_size 20U

char *vstrcat(char *buff, size_t buff_length, ...) {
  char *ret = buff;
 va_list list;
  va_start(list, buff_length);

  const char *part = nullptr;
  size_t offset = 0;
  while ((part = va_arg(list, const char *))) {
   buff = (char *)memccpy(buff, part, '\0', buff_length - offset) - 1;
   if (buff == nullptr) {
     ret[0] = '\0';
     break;
   }
   offset = buff - ret;
  }

  va_end(list);
  return ret;
}

int main() {
  char name[name_size] = "";
  char first[] = "Robert";
  char middle[] = "C.";
  char last[] = "Seacord";

  puts(
    vstrcat(
      name, sizeof(name), first, " ",
      middle, " ", last, nullptr
    )
  );
}

列表 11-6:打印错误

当我们按照示例运行这个程序时,它会按预期输出我的名字:

Robert C. Seacord

然而,我们还希望确保这个程序能够正确处理使用固定大小数组来存储 name 的情况,特别是当完整的名字超过 name 数组的大小时。为了测试这一点,我们可以将数组的大小更改为一个过小的值:

#define name_size 10U

现在,当我们运行程序时,我们得知程序有问题,但没有更多信息:

$ **./bug**
Segmentation fault

我们不会通过添加打印语句来调试,而是决定在 Linux 上使用 Visual Studio Code 调试这个程序。只需在调试器中运行程序,如 图 11-1 所示,就能获得一些我们之前没有的信息。

图 11-1:在 Visual Studio Code 中调试程序

从调用栈面板中我们可以看到,程序在 libc 中的 __memmove_avx_unaligned_erms 函数崩溃。

libc.so.6!__memmove_avx_unaligned_erms()
(\x86_64\multiarch\memmove-vec-unaligned-erms.S:314)
vstrcat(char * buff, size_t buff_length) (\home\rcs\bug.c:17)
main() (\home\rcs\bug.c:32)

我们还可以看到,段错误发生在调用 memccpy 的那一行。由于这一行没有其他复杂的操作,所以可以合理推测这是一个 memccpy 辅助函数。通常情况下,库函数的实现并不是 bug 的根源,所以我们暂时假设是传递了一组无效的参数。

在查看参数之前,让我们回顾一下 C23 标准中关于 memccpy 函数的描述:

include <string.h>

void *memccpy(void * restrict s1, const void * restrict s2, int c, size_t n);

memccpy 函数将从 s2 指向的对象中复制字符到 s1 指向的对象中,复制过程在遇到第一个字符 c(转为 unsigned char)时停止,或者在复制了 n 个字符后停止,以先到者为准。如果复制发生在重叠的对象之间,行为是未定义的。

从调试器中的变量面板来看,我们可以看到我们添加的 part 看起来是正确的:

**part:** 0x7fffffffdcd6 "Seacord"

ret 作为 ret 的别名也有一个预期的值:

**ret:** 0x7fffffffdcde "Robert C. "

然而,存储在 buff 中的值似乎有些奇怪,因为它的值与 EOF(–1)相同:

buff: 0xffffffffffffffff <error: Cannot access memory at address 0xffffffffffffffff>

buff 参数是一个字符指针,它被分配了来自 memccpy 的返回值。所以我们再一次查阅标准,看看这个函数返回了什么:

memccpy函数返回一个指向s1中复制的字符后一个字符的指针,或者如果在s2的前n个字符中未找到c,则返回空指针。

根据 C 标准,该函数只能返回空指针或指向s1中的某个字符的指针(在本程序中为buff)。buff的存储空间从0x7fffffffdcde开始,且仅延伸 10 个字节,因此这些都无法解释0xffffffffffffffff的值,谜团加深了。

现在是时候更仔细地检查vstrcat函数的行为了。我们将在函数开始处的第 12 行设置一个断点,并开始调试。标题栏左侧的按钮允许你继续、跳过、逐步进入、逐步退出、重新启动和停止调试。从第 12 行开始,我们可以通过点击“跳过”按钮逐步执行程序。vstrcat函数会循环执行多次,因此你需要逐步执行几次循环,观察 VARIABLES 窗格中的值。如果你小心操作,最终会看到在调用memccpy函数后,第 18 行的buff被设置为0xffffffffffffffff,如图 11-2 所示。这不会被空指针测试检测到,且在下一次迭代时发生了段错误。

就在这里,我有了灵感时刻。memccpy函数返回一个空指针,表示在buff_length - offset个字符内没有找到<сamp class="SANS_TheSansMonoCd_W5Regular_11">'\0'。然而,我们从<сamp class="SANS_TheSansMonoCd_W5Regular_11">memccpy返回的值中减去 1,这样<сamp class="SANS_TheSansMonoCd_W5Regular_11">buff就指向<сamp class="SANS_TheSansMonoCd_W5Regular_11">'\0'第一次出现的位置,而不是它之后的位置。当字符被找到时,这种方式是可行的,但如果没有找到字符,我们就从空指针减去 1,这在 C 语言中是技术上未定义的行为。在这个实现中,空指针由值 0 表示。将 1 从 0 中减去会导致值环绕并产生<сamp class="SANS_TheSansMonoCd_W5Regular_11">0xffffffffffffffff,然后才会对<сamp class="SANS_TheSansMonoCd_W5Regular_11">buff进行测试。因此,错误条件未被检测到,随后的<сamp class="SANS_TheSansMonoCd_W5Regular_11">memccpy调用导致了段错误。

图 11-2:一个有趣的程序状态

现在我们已经发现了根本原因,修复 bug 的方法是将减去 1 的操作移动到空指针检查之后,这样就得到了在第七章中展示的程序的最终版本。

单元测试

测试可以增强你对代码无缺陷的信心。单元测试是执行你代码的小程序。单元测试是一个验证软件每个单元按设计执行的过程。单元是任何软件中最小的可测试部分;在 C 语言中,通常是一个独立的函数或数据抽象。

你可以编写简单的测试,类似于普通应用程序代码(例如,参见列表 11-7),但使用单元测试框架可能更有益。有几种单元测试框架可供选择,包括 Google Test、CUnit、CppUnit、Unity 等。我们将根据 JetBrains 最近对 C 开发生态系统的调查(* www.jetbrains.com/lp/devecosystem-2023/c/*)来研究其中最受欢迎的框架:Google Test。

Google Test 适用于 Linux、Windows 和 macOS。测试是用 C++编写的,因此你可以学习另一种(相关的)编程语言来进行测试。如果你希望将测试限制在纯 C 语言中,CUnit 和 Unity 是不错的替代选择。

在 Google Test 中,你编写断言以验证被测试代码的行为。Google Test 的断言是类似函数的宏,它们是测试的实际语言。如果一个测试崩溃或断言失败,它就失败;否则,它就成功。断言的结果可以是成功、非致命失败或致命失败。如果发生致命失败,当前函数将被中止;否则,程序将继续正常运行。

我们将在 Ubuntu Linux 上使用 Google Test。要安装它,请按照 Google Test GitHub 页面上的说明进行操作,地址是 <wbr>github<wbr>.com<wbr>/google<wbr>/googletest<wbr>/tree<wbr>/main<wbr>/googletest

一旦安装了 Google Test,我们将为 列表 11-7 中展示的 get_error 函数设置单元测试。该函数返回与传递的错误编号相对应的错误消息字符串。你需要包含声明 errno_t 类型以及 strerrorlen_s 和 strerror_s 函数的头文件。将其保存为名为 error.c 的文件,以便后面本节中描述的构建说明能够正常工作。

error.c

char *get_error(errno_t errnum) {
  rsize_t size = strerrorlen_s(errnum) + 1;
  char* msg = malloc(size);
  if (msg != nullptr) {
    errno_t status = strerror_s(msg, size, errnum);
    if (status != 0) {
      strncpy_s(msg, size, "unknown error", size - 1);
    }
  }
  return msg;
}

列表 11-7:The get_error 函数

该函数调用了在规范但可选的附录 K 中定义的 strerrorlen_s 和 strerror_s 函数,附录 K 介绍了“边界检查接口”(在 第七章 中描述)。

不幸的是,GCC 和 Clang 都没有实现附录 K,因此我们将使用 Reini Urban 开发的 Safeclib 实现,该实现可以从 GitHub 上获取 (<wbr>github<wbr>.com<wbr>/rurban<wbr>/safeclib)。

你可以使用以下命令在 Ubuntu 上安装 libsafec-dev:

% **sudo apt install libsafec-dev**

示例 11-8 包含了一个名为 GetErrorTest 的 get_error 函数的单元测试套件。测试套件 是一组将在特定测试周期中执行的测试用例。GetErrorTest 套件包含两个测试用例:KnownError 和 UnknownError。测试用例 是一组预置条件、输入、操作(如适用)、预期结果和后置条件,这些都是根据测试条件(* <wbr>glossary<wbr>.istqb<wbr>.org *)开发的。将此代码保存在名为 tests.cc 的文件中。

tests.cc

#include <gtest/gtest.h>
#include <errno.h>
#define errno_t int

// implemented in a C source file
❶ extern "C" char* get_error(errno_t errnum);

namespace {
❷ TEST(GetErrorTest, KnownError) {
    EXPECT_STREQ(get_error(ENOMEM), "Cannot allocate memory");
 EXPECT_STREQ(get_error(ENOTSOCK), "Socket operation on non-socket");
    EXPECT_STREQ(get_error(EPIPE), "Broken pipe");
  }

  TEST(GetErrorTest, UnknownError) {
    EXPECT_STREQ(get_error(-1), "Unknown error -1");
  }
} // namespace

int main(int argc, char** argv) {
  ::testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

示例 11-8:get_error 函数 的单元测试

大部分 C++ 代码是模板代码,可以直接复制而无需修改,例如 main 函数,它调用类似宏的 RUN_ALL_TESTS 来执行测试。唯一不属于模板代码的部分是 extern "C" 声明 ❶ 和测试 ❷。extern "C" 声明改变了链接要求,避免 C++ 编译器链接器对函数名进行“修饰”,因为它通常会这样做。您需要为每个正在测试的函数添加类似声明,或者您可以像下面这样在 extern "C" 块内简单地包含 C 头文件:

extern "C" {
  #include "api_to_test.h"
}

只有在使用 C 编译并链接 C++ 时,才需要声明 extern "C"。

两个测试用例是使用 TEST 宏指定的,该宏接受两个参数。第一个参数是测试套件的名称,第二个参数是测试用例的名称。

在函数体内插入 Google Test 断言,以及您希望包含的任何其他 C++ 语句。在 示例 11-8 中,我们使用了 EXPECT _STREQ 断言,它验证两个字符串的内容是否相同。strerror_s 函数返回一个特定于区域设置的消息字符串,这在不同实现之间可能有所不同。

我们使用了对多个错误号的断言来验证函数是否为每个错误号返回正确的字符串。EXPECT_STREQ断言是一个非致命断言,因为即使该特定断言失败,测试仍然可以继续。这通常比致命断言更可取,因为它允许您在单次运行-编辑-编译周期中检测并修复多个错误。如果在初始失败后无法继续测试(例如,后续操作依赖于之前的结果),可以使用致命的ASSERT_STREQ断言。

清单 11-9 展示了一个简单的CMakeLists.txt文件,可以用于构建测试。这个文件假设我们正在测试的 C 函数可以在error.c文件中找到,并且附录 K 函数的实现由safec库提供。

cmake_minimum_required(VERSION 3.21)
cmake_policy(SET CMP0135 NEW)
project(chapter-11)

# GoogleTest requires at least C++14
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_STANDARD 23)

include(FetchContent)
FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip
)

FetchContent_MakeAvailable(googletest)

include(ExternalProject)
ExternalProject_Add(
  libsafec
  BUILD_IN_SOURCE 1
  URL https://github.com/rurban/safeclib/releases/download/v3.7.1/safeclib-3.7.1.tar.gz
  CONFIGURE_COMMAND autoreconf --install
  COMMAND ./configure --prefix=${CMAKE_BINARY_DIR}/libsafec
)
ExternalProject_Get_Property(libsafec install_dir)
include_directories(${install_dir}/src/libsafec/include)
link_directories(${install_dir}/src/libsafec/src/.libs/)

enable_testing()

add_library(error error.c)
add_dependencies(error libsafec)
add_executable(tests tests.cc)

target_link_libraries(
  tests
  error
  safec
  GTest::gtest_main
)

include(GoogleTest)
gtest_discover_tests(tests)

清单 11-9:The CMakeLists.txt 文件

如果您更喜欢通过apt install命令安装libsafec-dev,请删除特定于安装libsafec的行。

使用以下命令序列来构建和运行测试:

$ **cmake -S . -B build**
$ **cmake --build build**
$ **./build/tests**

测试用例测试了来自<errno.h>的多个错误号。需要测试多少个错误号取决于你想要达成的目标。理想情况下,测试应该是全面的,这意味着需要为<errno.h>中的每个错误号添加断言。然而,这样做可能会很繁琐;一旦你确定代码工作正常,实际上你只是在测试你使用的底层 C 标准库函数是否正确实现。不过,我们也可以测试我们可能会检索到的错误号,但这样做同样会变得繁琐,因为我们需要识别程序中调用的所有函数及其可能返回的错误码。我们选择实现一些针对从列表中不同位置随机选取的错误号的抽查。

清单 11-10 展示了运行此测试的结果。

$ **./build/tests**
[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from GetErrorTest
[RUN       ] GetErrorTest.KnownError
[        OK] GetErrorTest.KnownError (0 ms)
[RUN       ] GetErrorTest.UnknownError
/home/rcs/tests.cc:19: Failure
Expected equality of these values:
  get_error(-1)
    Which is: "Unknown error -1"
  "unknown error"
**[  FAILED  ] GetErrorTest.UnknownError (0 ms)**
[----------] 2 tests from GetErrorTest (0 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (0 ms total)
[  PASSED  ] 1 test.
**[  FAILED  ] 1 test, listed below:**
**[  FAILED  ] GetErrorTest.UnknownError**

1 FAILED TEST

清单 11-10:一次不成功的测试运行

从测试输出可以看出,来自同一测试套件的两个测试已执行。KnownError 测试用例通过,而 UnknownError 测试用例失败。UnknownError 测试失败的原因是以下断言返回了假:

EXPECT_STREQ(get_error(-1), "unknown error");

测试假设在 get_error 函数中的错误路径会执行并返回字符串 "unknown error")。然而,strerror_s 函数返回了字符串 "Unknown error -1"。通过检查 strerror_s 函数的源代码(位于 <wbr>github<wbr>.com<wbr>/rurban<wbr>/safeclib<wbr>/blob<wbr>/master<wbr>/src<wbr>/str<wbr>/strerror<wbr>_s<wbr>.c),我们可以看到该函数确实会返回错误代码。因此,很明显,该函数并没有将未知的错误号视为错误。检查 C 标准后,我们发现“strerror_s 应该将任何类型为 int 的值映射为一条消息,”因此,strerror_s 函数的实现是正确的,但我们对其行为的假设是错误的。

get_error 函数的实现存在缺陷,因为当 strerror_s 函数失败时,它会指示“unknown error”,但根据标准:

strerror_s 函数会在期望字符串的长度小于 maxsize 且没有运行时约束违例的情况下返回零。否则,strerror_s 函数将返回非零值。

因此,如果 strerror_s 函数返回非零值,表示发生了严重错误,足以需要重新考虑该函数的设计。它不应该在错误条件下返回字符串,而应该返回空指针或以其他方式处理错误,以便与系统的整体错误处理策略一致。列表 11-11 更新了该函数,使其返回空指针值。

char *get_error(errno_t errnum) {
  rsize_t size = strerrorlen_s(errnum) + 1;
  char* msg = malloc(size);
  if (msg != nullptr) {
    errno_t status = strerror_s(msg, size, errnum);
    if (status != 0) return nullptr;
  }
  return msg;
}

列表 11-11:The get_error 函数

我们需要修复测试,检查get_error(-1)返回的正确字符串:

EXPECT_STREQ(get_error(-1), “Unknown error -1”);

在做出此更改后,重新构建并重新运行测试,我们可以看到,如清单 11-12 所示,两个测试用例均成功。

$ **./build/tests**
[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from GetErrorTest
[RUN       ] GetErrorTest.KnownError
[        OK] GetErrorTest.KnownError (0 ms)
[RUN       ] GetErrorTest.UnknownError
[        OK] GetErrorTest.UnknownError (0 ms)
[----------] 2 tests from GetErrorTest (0 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (0 ms total)
[  PASSED  ] 2 tests.

清单 11-12: 成功的测试运行

除了发现设计错误,我们还发现我们的测试不完整,因为我们未能测试错误情况。我们应该添加更多的测试,以确保错误情况能够正确处理。添加这些测试留给读者作为练习。

静态分析

静态分析包括任何无需执行代码的评估过程(ISO/IEC TS 17961:2013),用于提供关于可能的软件缺陷的信息。

静态分析有实际的局限性,因为软件的正确性在计算上是不可判定的。例如,计算机科学中的停机定理指出,存在一些程序,其精确的控制流无法静态确定。因此,任何依赖于控制流的属性——比如停机——对某些程序可能是不可判定的。因此,静态分析可能无法报告缺陷,或者可能会报告不存在的缺陷。

未能报告代码中的实际缺陷称为假阴性。假阴性是严重的分析错误,因为它们可能让你产生一种虚假的安全感。大多数工具倾向于谨慎行事,因此会生成假阳性。假阳性是指测试结果错误地表明存在缺陷。工具可能会报告一些高风险缺陷,同时忽略其他缺陷,这是为了避免用假阳性信息淹没用户。假阳性也可能出现在代码过于复杂,无法完全分析的情况下。函数指针和库的使用会使假阳性更容易发生。

理想情况下,工具的分析既要完备又要可靠。如果分析工具无法给出假阴性结果,则被认为是可靠的。如果分析工具无法发出假阳性结果,则被认为是完备的。给定规则的可能性在图 11-3 中进行了概述。

图 11-3: 完备性与可靠性

编译器执行有限的分析,提供关于代码中不需要过多推理的高度局部化问题的诊断信息。例如,当将一个有符号值与一个无符号值进行比较时,编译器可能会发出关于类型不匹配的诊断,因为它不需要额外的信息来识别错误。如本章前面所提到的,有许多编译器标志,例如 Visual C++ 的 /W4 和 GCC 及 Clang 的 -Wall,它们用于控制编译器的诊断信息。

编译器通常提供高质量的诊断信息,您不应该忽视它们。始终尽量理解警告的原因,并重新编写代码以消除错误,而不是通过添加类型转换或随意修改代码直到警告消失来简单地抑制警告。有关此主题的更多信息,请参见 CERT C 规则 MSC00-C,“在高警告级别下干净编译”。

一旦解决了代码中的编译器警告,您可以使用单独的静态分析器来识别其他缺陷。静态分析器通过评估程序中的表达式、执行深入的控制流和数据流分析,以及推理可能的值范围和控制流路径,从而诊断出更复杂的缺陷。

使用工具定位并识别程序中的特定错误,比进行数小时的测试和调试要容易得多,成本也远低于部署有缺陷的代码。有许多免费的和商业的静态分析工具可供选择。例如,Visual C++ 集成了一个静态分析器,您可以通过 /analyze 标志调用。Visual C++ 分析允许您指定要运行的规则集(例如推荐、安全或国际化规则集),或者是否运行所有规则集。有关 Visual C++ 静态代码分析的更多信息,请访问 Microsoft 的网站 <wbr>learn<wbr>.microsoft<wbr>.com<wbr>/en<wbr>-us<wbr>/visualstudio<wbr>/code<wbr>-quality。类似地,Clang 集成了一个静态分析器,可以作为独立工具或在 Xcode 中运行 (<wbr>clang<wbr>-analyzer<wbr>.llvm<wbr>.org)。从版本 10 开始,GCC 引入了通过 -fanalyzer 选项启用的静态分析功能。也有商业工具,如 GitHub 的 CodeQL、TrustInSoft Analyzer、SonarSource 的 SonarQube、Synopsys 的 Coverity、LDRA Testbed、Perforce 的 Helix QAC 等。

许多静态分析工具具有互不重叠的功能,因此使用多个工具可能是有意义的。

动态分析

动态分析是评估系统或组件在执行过程中表现的过程。它也被称为运行时分析,以及其他类似的名称。

动态分析的常见方法是插桩代码——例如,通过启用编译时标志,将额外的指令注入到可执行文件中——然后运行插桩后的可执行文件。第六章中描述的调试内存分配库 dmalloc 采用了类似的方法。dmalloc 库提供了替代内存管理例程,并带有运行时可配置的调试功能。你可以通过使用命令行工具(也称为 dmalloc)来控制这些例程的行为,检测内存泄漏,并发现和报告缺陷,例如越界写入或在释放内存后继续使用指针。

动态分析的优点是它具有较低的误报率,因此如果这些工具标记了问题,就应该修复它!

动态分析的一个缺点是,它需要足够的代码覆盖率。如果在测试过程中没有执行到有缺陷的代码路径,那么缺陷就不会被发现。另一个缺点是,插桩可能会以不希望的方式改变程序的其他方面,例如增加性能开销或增加二进制文件的大小。与其他动态分析工具不同,本章前面提到的 FORTIFY_SOURCE 宏提供了轻量级的缓冲区溢出检测支持,因此可以在生产构建中启用,而不会对性能产生明显影响。

AddressSanitizer

AddressSanitizer (ASan, <wbr>github<wbr>.com<wbr>/google<wbr>/sanitizers<wbr>/wiki<wbr>/AddressSanitizer) 是一个有效的动态分析工具的示例,可供多个编译器(免费)使用。还有几个相关的清理工具,包括 ThreadSanitizer、MemorySanitizer、硬件加速的 AddressSanitizer 和 UndefinedBehaviorSanitizer。还有许多其他动态分析工具,包括商业工具和免费工具。如需更多关于清理工具的信息,请参见 <wbr>github<wbr>.com<wbr>/google<wbr>/sanitizers。我将通过详细讨论 AddressSanitizer 来展示这些工具的价值。

ASan 是一个用于 C 和 C++ 程序的动态内存错误检测器。它被集成进 LLVM 版本 3.1 和 GCC 版本 4.8,以及这些编译器的后续版本中。从 Visual Studio 2019 开始,ASan 也可以使用。

这个动态分析工具可以发现各种内存错误,包括以下几种:

  • 释放后使用(悬空指针解引用)

  • 堆、栈和全局缓冲区溢出

  • 返回后使用

  • 超出作用域使用

  • 初始化顺序错误

  • 内存泄漏

为了展示 ASan 的有效性,我们将通过将 列表 11-7 中的 get_error 函数替换为 列表 11-13 中显示的 print_error 函数来开始。

error.c

errno_t print_error(errno_t errnum) {
  rsize_t size = strerrorlen_s(errnum) + 1;
  char* msg = malloc(size);
  if (msg == nullptr) return ENOMEM;
  errno_t status = strerror_s(msg, size, errnum);
  if (status != 0) return EINVAL;
  fputs(msg, stderr);
  return EOK;
}

列表 11-13:The print_error 函数

我们还将用 列表 11-14 中显示的 print_error 函数的单元测试套件,替换 get_error 函数的单元测试套件。

tests.cc

TEST(PrintTests, ZeroReturn) {
  EXPECT_EQ(print_error(ENOMEM), 0);
  EXPECT_EQ(print_error(ENOTSOCK), 0);
  EXPECT_EQ(print_error(EPIPE), 0);
}

列表 11-14:The PrintTests 测试套件

这段 Google Test 代码定义了一个包含单个测试用例 ZeroReturn 的 PrintTests 测试套件。该测试用例使用非致命的 EXPECT_EQ 断言,测试通过多次调用 print_error 函数来打印一些随机选择的错误编号,返回值应为 0。接下来,我们需要在 Ubuntu Linux 上构建并运行此代码。

运行测试

从 列表 11-14 运行修改后的测试,得到的正面结果如 列表 11-15 所示。

$ **./build/tests**
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from PrintTests
[RUN       ] PrintTests.ZeroReturn
[        OK] PrintTests.ZeroReturn (0 ms)
[----------] 1 test from PrintTests (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (0 ms total)
**[  PASSED  ]** 1 test.

列表 11-15:The PrintTests 测试套件的测试运行

一个经验不足的测试人员可能会看这些结果,并错误地认为,“嘿,这段代码工作正常!”然而,你应该采取额外步骤来提高对代码没有缺陷的信心。现在我们已经有了一个工作的测试框架,是时候对代码进行插桩了。

代码插桩

你可以通过使用 ASan 将代码插桩,使用 -fsanitize=address 标志编译和链接程序。表 11-3 显示了一些常用的 ASan 编译器标志。

表 11-3: 与 AddressSanitizer 常用的编译器和链接器标志

标志 目的
-fsanitize=address 启用 AddressSanitizer(必须传递给编译器和链接器)
-g3 获取符号调试信息
-fno-omit-frame-pointer 保留帧指针,以便在错误信息中获取更有信息量的堆栈跟踪
-fsanitize-blacklist=path 传递一个黑名单文件
-fno-common 不将全局变量视为公共变量(允许 ASan 对它们进行插桩)

从表 11-3 中选择你想使用的编译器和链接器标志,并通过add_compile_options和add_link_options命令将它们添加到你的CMakeLists.txt文件中:

add_compile_options(-g3 -fno-omit-frame-pointer -fno-common -fsanitize=address)
add_link_options(-fsanitize=address)

不要在构建阶段启用 sanitization,因为插入的运行时插桩可能会导致假阳性。

如前所述,AddressSanitizer 可与 Clang、GCC 和 Visual C++ 一起使用。(有关 ASan 支持的更多信息,请参见 <wbr>devblogs<wbr>.microsoft<wbr>.com<wbr>/cppblog<wbr>/addresssanitizer<wbr>-asan<wbr>-for<wbr>-windows<wbr>-with<wbr>-msvc。)

根据你使用的编译器版本,你可能还需要定义以下环境变量:

ASAN_OPTIONS=symbolize=1
ASAN_SYMBOLIZER_PATH=/path/to/llvm_build/bin/llvm-symbolizer

尝试在设置了这些环境变量的情况下重新构建并重新运行你的测试。

运行插桩测试

你使用 Google Test 编写的单元测试套件应继续通过测试,但也将执行你的代码,从而允许 AddressSanitizer 检测到额外的问题。你现在应该能在清单 11-16 中看到来自运行./build/tests的额外输出。

==22489==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 31 byte(s) in 1 object(s) allocated from:
    #0 0x7f2bcf9f58ff in __interceptor_malloc
  ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
    #1 0x557d3105f6da in print_error /home/rcs/test/error.c:21
    #2 0x557d3105d314 in TestBody /home/rcs/test/tests.cc:28

// `--snip--`

清单 11-16:一个插桩测试运行结果,来自 PrintTests

清单 11-16 仅显示了多个结果中的第一个。大部分堆栈跟踪被编辑掉,因为它来自测试基础设施本身,并且没有帮助定位缺陷,因此不感兴趣。

AddressSanitizer 的 LeakSanitizer 组件“检测到内存泄漏”,并告诉我们这是一个直接从一个对象中泄漏的 31 字节。堆栈跟踪标识了与诊断相关的文件名和行号:

#1 0x557d3105f6da in print_error /home/rcs/test/**error.c:21**

这行代码包含了对 malloc 的调用,它出现在 print_error 函数中:

errno_t print_error(errno_t errnum) {
  rsize_t size = strerrorlen_s(errnum) + 1;
 **char* msg = malloc(size);**
  // `--snip--`
}

这是一个明显的错误;malloc 的返回值被赋值给了一个在 print_error 函数作用域内定义的自动变量,并且没有被释放。我们失去了在函数返回后释放这块已分配内存的机会,而持有指向已分配内存的指针的对象的生命周期也结束了。要解决这个问题,应该在不再需要已分配的存储但在函数返回之前,添加对 free(msg) 的调用。重新运行测试并修复任何额外的缺陷,直到你对程序的质量感到满意。 ## 总结

在本章中,你了解了静态和运行时断言,并介绍了一些更重要且推荐的 GCC、Clang 和 Visual C++ 编译器标志。你还学习了如何通过静态和动态分析调试、测试和分析你的代码。

这些是本书中的重要最后一课,因为你会发现,作为一个专业的 C 程序员,你会花费相当多的时间调试和分析你的代码。我之前在社交媒体上发布过以下内容,它总结了我(以及其他 C 程序员)与 C 编程语言的关系:

  • 我不喜欢的语言:C

  • 我勉强尊重的语言:C

  • 我认为被高估的语言:C

  • 我认为被低估的语言:C

  • 我喜欢的语言:C

未来方向

随着 C23 的完成,委员会可以将注意力转向 C 编程语言的下一个修订版 C2Y。预计将在 2029 年发布。虽然这看起来时间很长,但实际上是之前 C 标准版本所需时间的大约一半。

C 委员会已经批准了一份新的章程,用以记录我们的原则(Seacord 等,2024)。虽然委员会致力于保持 C 语言的传统精神,但将会更加关注安全性和可靠性。对于 C2Y,我们可能会改进自动类型推断,扩展constexpr的支持,并有可能采纳来自 C++的 lambda 表达式和其他特性。我们还在开发一种新的defer特性,用于错误处理和资源管理。C 浮点数小组将继续更新 IEEE 754:2019 标准的相关工作。有关 C 语言的一个面向溯源的内存对象模型的技术规范(ISO/IEC CD TS 6010:2024)应该很快发布,并希望能被纳入 C2Y 版本。

附录 C 语言标准第五版(C23)

与 Aaron Ballman 合作

最新的(第五版)C 语言标准(ISO/IEC 9899:2024)被称为 C23。C23 保持了C 的精神,同时增加了新特性和功能,以提高语言的安全性、可靠性和功能性。

属性

[[attributes]] 语法被加入到 C23 中,用于指定各种源构造(如类型、对象、标识符或块)的附加信息(Ballman 2019)。在 C23 之前,类似的功能是以实现定义(非便携)方式提供的:

__declspec(deprecated)
__attribute__((warn_unused_result))
int func(const char *str)__attribute__((nonnull(1)));

从 C23 开始,可以按如下方式指定属性:

[[deprecated, nodiscard]]
int func(
  const char *str [[gnu::nonnull]]
);

类似于 C++,语法位置决定了分配方式。属性包括 deprecated、fallthrough、maybe_unused、nodiscard、unsequenced 和 reproducible。属性语法支持标准属性和厂商特定的属性。__has_c_attribute 条件包含运算符可用于功能测试。

关键字

C 语言经常因其丑陋的关键字而受到嘲笑。C 语言通常使用以下划线字符(_)开头、后跟大写字母的保留标识符来定义新的关键字。

C23 引入了这些关键字的更自然拼写方式(Gustedt 2022)。在表 A-1 中,左侧展示了使用这种约定的 C11 关键字,而右侧则展示了 C23 引入的更自然拼写。

表 A-1: 关键字拼写

类型
_Bool bool
_Static_assert static_assert
_Thread_local thread_local
_Alignof alignof
_Alignas alignas

另一个更新是引入了 nullptr 常量。老旧的 NULL 宏具有指针类型或可能是整数类型。它会隐式转换为任何标量类型,因此在类型安全性上并不特别强。nullptr 常量的类型是 nullptr_t,并且仅会隐式转换为指针类型、void 或 bool。

整数常量表达式

整数常量表达式不是一种可移植的构造;厂商可以扩展它们。例如,func 中的 array 可能是,也可能不是一个可变长度数组(VLA):

void func() {
  static const int size = 12;
  int array[size]; // might be a VLA
}

C23 添加了 constexpr 变量(它意味着 const 限定符),当你确实需要某些东西作为常量时(Gilding 和 Gustedt 2022a):

void func() {
  static constexpr int Size = 12;
  int Array[Size]; // never a VLA
}

C23 目前不支持 constexpr 函数,仅支持对象。结构成员不能是 constexpr。

枚举类型

C 枚举类型在 C17 中看起来正常,但有一些奇怪的行为。例如,底层整数类型是实现定义的,可以是有符号整数类型或无符号整数类型。C23 现在允许程序员为枚举指定底层类型(Meneide 和 Pygott 2022):

enum E : unsigned short {
  Valid = 0, // has type unsigned short
  NotValid = 0x1FFFF // error, too big
};

// can forward declare with fixed type
enum F : int;

你还可以声明比 int 更大的枚举常量:

// has underlying type unsigned long
enum G {
  BiggerThanInt = 0xFFFF'FFFF'0000L,
};

类型推导

C23 增强了使用类型推导的单一对象定义的 auto 类型说明符(Gilding 和 Gustedt 2022b)。这基本上和 C++ 中的想法一样,但 auto 不能出现在函数签名中:

const auto l = 0L; // l is const long
auto huh = "test"; // huh is char *, not char[5] or const char *
void func();
auto f = func; // f is void (*)()
auto x = (struct S){  // x is struct S
  1, 2, 3.0
};
#define swap(a, b) \
  do {auto t = (a); (a) = (b); (b) = t;} \
  while (0)

typeof 运算符

C23 添加了对 typeof 和 typeof_unqual 运算符的支持。这些类似于 C++ 中的 decltype,用于根据另一种类型或表达式的类型来指定类型。typeof 运算符保留限定符,而 typeof_unqual 会去除限定符,包括 _Atomic。

K&R C 函数

K&R C 允许声明没有原型的函数:

int f();
int f(a, b) int a, b; {return 0;}

K&R C 函数在 35 年前已被弃用,并最终将从标准中移除。所有函数现在都有原型。空参数列表曾意味着“接受任意数量的参数”,现在意味着“接受零个参数”,与 C++ 一致。通过变参函数签名可以模拟“接受零个或多个参数”的情况:int f(...);,这现在是可能的,因为 va_start 不再要求在 ... 前传递参数。

预处理器

C23 新增了一些功能来改进预处理。#elifdef 指令是 #ifdef 的补充,还包括 #elifndef 形式。#warning 指令是 #error 的补充,但不会停止翻译过程。__has_include 操作符用于检测头文件的存在,__has_c_attribute 操作符用于检测标准或供应商属性的存在。

embed 指令通过预处理器将外部数据直接嵌入源代码中:

unsigned char buffer[] = {
#embed "/dev/urandom" limit(32) // embeds 32 chars from /dev/urandom
};
struct FileObject {
  unsigned int MagicNumber;
  unsigned _BitInt(8) RGBA[4];
  struct Point {
    unsigned int x, y;
  } UpperLeft, LowerRight;
} Obj = {
#if __has_embed(SomeFile.bin) == __STDC_EMBED_FOUND__
// embeds contents of file as struct
// initialization elements
#embed "SomeFile.bin"
#endif
};

整数类型与表示

从 C23 开始,二进制补码是唯一允许的整数表示方式(Bastien 和 Gustedt 2019)。有符号整数溢出仍然是未定义行为。int8_t、int16_t、int32_t 和 int64_t 类型现在可以在所有平台上便捷使用。[u]intmax_t 类型不再是最大类型,仅要求表示 long long 值,而非扩展或位精确的整数值。

C23 还引入了位精度整数类型(Blower 等,2020)。这些是有符号和无符号类型,允许你指定位宽。这些整数不会进行整数提升,因此它们保持你请求的大小。位宽包括符号位,因此 _BitInt(2) 是最小的有符号位精度整数。BITINT_MAXWIDTH 指定了位精度整数的最大宽度。它必须至少为 ULLONG_WIDTH,但可以大得多(Clang 支持大于 2M 位)。

在 C17 中,添加两个半字需要一些位操作:

unsigned int add(
  unsigned int L, unsigned int R)
{
  unsigned int LC = L & 0xF;
  unsigned int RC = R & 0xF;
  unsigned int Res = LC + RC;
  return Res & 0xF;
}

使用 _BitInt 会简单得多:

unsigned _BitInt(4) add(
  unsigned _BitInt(4) L,
  unsigned _BitInt(4) R)
{
  return L + R;
}

C23 还新增了二进制文字。整数文字 0b00101010101、0x155、341 和 0525 表示相同的值。现在,你还可以使用数字分隔符以提高可读性,例如:0b0000'1111'0000'1100、0xF'0C、3'852 和 07'414。

C23 最终检查了整数运算,能够检测加法、减法和乘法运算中的溢出和回绕(Svoboda 2021):

#include <stdckdint.h> // new header

bool ckd_add(Type1 *Result, Type2 L, Type3 R);
bool ckd_sub(Type1 *Result, Type2 L, Type3 R);
bool ckd_mul(Type1 *Result, Type2 L, Type3 R);

除法不受支持,并且它只适用于除普通的 char、bool 或位精度整数以外的整数类型。Type1、Type2 和 Type3 可以是不同的类型。如果运算的数学结果可以用 Type1 表示,这些函数将返回 false;否则,它们将返回 true。这些函数简化了遵循 CERT C 编码标准和 MISRA C 指南,但编写操作时仍然很笨重。

unreachable 函数宏

unreachable 函数宏在 <stddef.h> 中提供。它展开为一个无返回值的表达式;在执行过程中到达该表达式是未定义行为。这使得你可以向优化器提供有关无法到达的流程控制的提示(Gustedt 2021)。

就像你告诉优化器假设的任何内容一样,使用时要小心,因为即使你错误,优化器也会相信你。以下是一个典型的例子,展示了如何在实践中使用 unreachable:

#include <stdlib.h>
enum Color {Red, Green, Blue};
int func(enum Color C) {
  switch (C) {
    case Red: return do_red();
    case Green: return do_green();
    case Blue: return do_blue();
  }
  unreachable(); // unhandled value
}

位与字节工具

C23 在<stdbit.h>头文件中引入了一组位和字节工具(Meneide 2023)。这些包括以下函数:

  • 计算位模式中 1 或 0 的数量

  • 计算领先或尾随的 1 或 0 的数量

  • 查找第一个领先或尾随的 1 或 0

  • 测试是否设置了单个比特位

  • 确定表示一个值所需的最小比特数

  • 根据一个值确定下一个最小或最大二的幂

例如,以下代码可以用于统计值中连续 0 的位数,从最高有效位开始:

#include <stdbit.h>
void func(uint32_t V) {
  int N = stdc_leading_zeros(V);
  // use the leading zero count N
}

在 C23 之前,这个操作要复杂得多:

void func(uint32_t V) {
  int N = 32;
  unsigned R;
  R = V >> 16;
  if (R != 0) {N --= 16; V = R;}
  R = V >> 8;
  if (R != 0) {N --= 8; V = R;}
  R = V >> 4;
  if (R != 0) {N --= 4; V = R;}
  R = V >> 2;
  if (R != 0) {N --= 2; V = R;}
  R = V >> 1;
  if (R != 0) N -= 2;
  else        N -= V;
  // use the leading zero count N
}

IEEE 浮动点支持

C23 通过集成 TS 18661-1、2 和 3(ISO/IEC TS 18661-1 2014,ISO/IEC TS 18661-2 2015,ISO/IEC TS 18661-3 2015)更新了 IEEE 浮动点支持。附录 F 现在与 IEEE 浮动点运算标准(IEEE 754-2019)保持一致。附录 F 还适用于十进制浮动点:_Decimal32、_Decimal64和_Decimal128。但是,十进制运算不能与二进制、复数或虚数浮动点混合使用。附录 H(之前是与语言无关的算术附录)支持交换、扩展浮动类型和非算术交换格式。它允许使用 binary16、图形处理单元(GPU)数据、二进制或十进制表示。

数学库更改支持对<math.h>操作于_DecimalN、_FloatN和_FloatNx类型的支持。增加了对指数、对数、幂运算以及基于π的三角函数的特殊变体;对最小值/最大值、总排序和数值属性测试的改进函数;以及支持在浮动点值与整数或字符串之间的转换进行精细控制的函数。

已新增memset_explicit函数,适用于你真的需要清除内存的情况。它与memset相同,但优化器无法删除对它的调用。strdup和strndup函数已从 POSIX 中采纳。

第十二章:参考文献

posted @ 2025-11-25 17:06  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报