卓越代码之道第二卷-全-

卓越代码之道第二卷(全)

原文:zh.annas-archive.org/md5/7b568b0dee052bc5a3e903ec0c873eaf

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

image

我们所说的优秀代码是什么意思?不同的程序员会有不同的看法。因此,提供一个能让每个人都满意的包罗万象的定义是不可能的。以下是本书将使用的定义:

优秀的代码是使用一致且优先考虑良好的软件特性编写的软件。特别是,优秀的代码遵循一套规则,这些规则指导程序员在将算法实现为源代码时所做的决策。

然而,正如我在《编写优秀代码,第一卷:理解机器》(以下简称WGC1)中提到的,几乎每个人都能达成共识的是,优秀代码具有一些共性特征。具体来说,优秀的代码:

  • 高效使用 CPU(即,它很快)

  • 高效使用内存(即,它很小)

  • 高效使用系统资源

  • 易于阅读和维护

  • 遵循一致的风格指南

  • 使用明确的设计,遵循已建立的软件工程规范

  • 容易扩展

  • 经过充分测试且稳健(即,它能够正常工作)

  • 具有良好的文档

我们可以轻松地在这个列表中添加数十个条目。例如,一些程序员可能认为,优秀的代码必须具备可移植性,必须遵循一套特定的编程风格指南,或者必须用某种语言编写(或者不能用某种语言编写)。有些人可能认为优秀的代码必须尽可能简洁,而另一些人则认为它必须快速编写。还有一些人可能认为优秀的代码是在规定时间内按预算完成的。

鉴于优秀代码涉及的方面有很多——太多了,无法在一本书中完全描述——《编写优秀代码》系列的第二卷主要集中在其中一个方面:高效的性能。尽管效率可能并非软件开发努力的首要目标——也并非代码要成为优秀代码的必要条件——人们普遍认为低效的代码不是优秀代码。而低效正是现代应用程序的主要问题之一,因此这是一个需要强调的重要话题。

优秀代码的性能特征

随着计算机系统性能从兆赫兹提升到数百兆赫兹,再到千兆赫兹,计算机软件性能逐渐被其他问题所取代。如今,软件工程师常常会大声疾呼:“你永远不应该优化你的代码!”有趣的是,你很少听到软件用户说出这样的话。

尽管这本书描述了如何编写高效代码,但它并不是一本关于优化的书。优化是软件开发生命周期(SDLC)中的一个阶段,在这个阶段,软件工程师确定为什么他们的代码未能满足性能规范,并相应地进行优化。但不幸的是,如果他们直到优化阶段才考虑应用程序的性能,那么优化可能就不会实现实际效果。确保应用程序符合合理的性能基准的时间点是在 SDLC 的开始阶段,即设计和实现阶段。优化可以微调系统的性能,但它很少能带来奇迹。

虽然这句名言常常被归因于唐纳德·克努斯(Donald Knuth),是他使其广为人知,但最初是托尼·霍尔(Tony Hoare)说的:“过早优化是万恶之源。”这句话长期以来一直是那些忽视应用性能,直到 SDLC 的最后阶段才开始关注的工程师们的口号——在这个阶段,优化通常会因为经济或市场时间的原因而被忽视。然而,霍尔并没有说,“在应用程序开发的早期阶段关心性能是万恶之源。”他特别说的是过早优化,而那时的过早优化意味着在汇编语言代码中计算周期和指令——这并不是你在程序设计初期,代码库仍在不断变化时应该做的事情。因此,霍尔的评论是非常准确的。

以下是查尔斯·库克(Charles Cook)的一篇短文摘录(* bit.ly/38NhZkT *),进一步描述了过度解读霍尔言论的问题:

我一直认为,这句名言常常导致软件设计师犯下严重错误,因为它被应用到了一个不同的问题领域,偏离了最初的意图。

这段话的完整版本是:“我们应该忘记小的效率问题,大约 97%的时间都不必考虑:过早优化是万恶之源。”我同意这一观点。在性能瓶颈明显之前,通常不值得花费大量时间在代码的微优化上。但是,相反地,在系统级别设计软件时,性能问题应该从一开始就被考虑进去。一个好的软件开发者会自动做到这一点,因为他们已经对性能问题可能导致的问题有了直觉。而一个经验不足的开发者则不会在意,错误地认为稍后在优化阶段做一些微调就能解决问题。

事实上,霍尔(Hoare)是在说,软件工程师应该先关心其他问题,比如良好的算法设计和实现,再去关注传统的优化问题,比如某条语句执行所需的 CPU 周期数。

尽管您肯定可以在优化阶段应用本书的许多概念,但这里大部分技术实际上需要在初始编码阶段应用。有经验的软件工程师可能会争辩说,这样做只会产生性能上的轻微改进。在某些情况下,这是正确的——但请记住,这些轻微的效果是累积的。如果您推迟到达“代码完成”时才实施这些想法,它们很可能永远不会出现在您的软件中。在已经工作的代码上实施这些变更是太麻烦了,也太冒险了。

本书的目标

本书(以及WGC1)试图填补当前一代程序员教育中的空白,以便他们能够编写高质量的代码。特别是,它涵盖了以下概念:

  • 为什么考虑您的高级程序的低级执行方式是重要的

  • 编译器如何从高级语言(HLL)语句生成机器码

  • 编译器如何使用低级别、原始数据类型来表示各种数据类型

  • 如何编写您的高级语言代码,以帮助编译器生成更好的机器码

  • 如何利用编译器的优化功能

  • 如何在编写高级语言代码时“思考”汇编语言(低级术语)

本书将教会您如何选择适当的高级语言语句,以便与现代优化编译器生成高效的机器码。在大多数情况下,不同的高级语言语句提供了许多实现给定结果的方法,其中一些在机器级别上自然比其他方法更有效。虽然有选择比较低效的语句序列背后可能存在非常好的理由(例如可读性),但事实上,大多数软件工程师并不了解高级语言语句的运行时成本,因此无法做出明智的选择。本书的目标就是改变这种现状。

再次强调,本书并非只讨论无论如何选择最高效的语句序列。而是要了解各种高级语言结构的成本,这样当面对多个选择时,您就可以明智地决定使用哪个序列最为合适。

章节组织

尽管您不需要成为汇编语言专家才能编写高效的代码,但您至少需要基本的汇编语言知识才能理解本书中的编译器输出。第一章和第二章讨论了学习汇编语言的几个方面,涵盖了常见误解、关于编译器的考虑以及可用的资源。第三章为 80x86 汇编语言提供了一个快速入门。在线附录(www.randallhyde.com/)为 PowerPC、ARM、Java 字节码和公共中间语言(CIL)汇编语言提供了入门指南。

在第四章和第五章中,你将通过检查编译器输出,学习如何确定你的高级语言(HLL)语句的质量。这些章节描述了反汇编器、目标代码转储工具、调试器、各种 HLL 编译器选项用于显示汇编语言代码的功能以及其他有用的软件工具。

本书的其余部分,第六章至第十五章,描述了编译器如何为不同的 HLL 语句和数据类型生成机器码。掌握这些知识后,你将能够选择最合适的数据类型、常量、变量和控制结构,从而生成高效的应用程序。

假设和前提条件

本书的编写假设了你具备某些先前的知识。如果你的个人技能集符合以下要求,你将从本书中获得最大的收益:

  • 你应该至少精通一种命令式(过程式)或面向对象的编程语言。这包括 C 和 C++、Pascal、Java、Swift、BASIC、Python 以及汇编语言,还包括 Ada、Modula-2 和 FORTRAN 等语言。

  • 你应该能够根据一个小问题描述,设计并实现一个软件解决方案。大学或高等院校的一个学期或季度课程(或几个月的自学经验)应该足以做为准备。

  • 你应该对计算机组织和数据表示有基本的理解。你应该了解十六进制和二进制数字系统。你应该理解计算机如何在内存中表示各种高级数据类型,如有符号整数、字符和字符串。尽管接下来的几章会提供机器语言的入门知识,但如果你已经掌握了这些信息,将会大有帮助。如果你觉得这一方面的知识有些薄弱,WGC1全面涵盖了计算机组织的相关内容。

本书的环境

虽然本书提供的是通用信息,但讨论的某些部分必然与系统相关。由于 Intel 架构的 PC 是目前使用最广泛的,因此当讨论特定的系统相关概念时,我将以此平台为例。然而,这些概念同样适用于其他系统和 CPU——比如旧款 Power Macintosh 系统中的 PowerPC CPU,手机、平板和单板计算机(SBC,例如 Raspberry Pi 或更高端的 Arduino 板)中的 ARM CPU,以及 Unix 系统中的其他 RISC CPU——尽管你可能需要针对你特定平台的示例进行一些额外的研究。

本书中的大多数示例可以在 macOS、Windows 和 Linux 下运行。在创建示例时,我尽量遵循标准库接口与操作系统的交互,只有在无法避免时才使用操作系统特定的调用。

本文中的大多数具体示例将在现代 Intel 架构(包括 AMD)CPU 上运行,支持 Windows、macOS 和 Linux 操作系统,配备适量的 RAM 和现代 PC 上常见的其他系统外设。即使是软件本身无法直接适用,这些概念也可以应用于 Mac、Unix 系统、单板计算机(SBC)、嵌入式系统甚至大型主机。

更多信息

Mariani, Rico. “性能设计。” 2003 年 12 月 11 日。docs.microsoft.com/en-us/archive/blogs/ricom/designing-for-performance/

Wikipedia. “程序优化。” en.wikipedia.org/wiki/Program_optimization/

第一章:低级思维,高级编程

如果你想编写最好的高级语言代码,学习汇编语言吧。

—常见的编程建议

image

本书并没有教授任何革命性的东西。而是描述了一种经过时间考验、验证过的方法来编写优秀的代码——理解你编写的代码如何在真实机器上执行。通向这一理解的旅程始于这一章。在这一章中,我们将探讨以下主题:

  • 程序员对典型编译器生成的代码质量的误解

  • 为什么学习汇编语言仍然是一个好主意

  • 在编写高级语言代码时如何保持低级思维

那么,事不宜迟,让我们开始吧!

1.1 关于编译器质量的误解

在个人计算机革命的初期,高性能软件是用汇编语言编写的。随着时间的推移,优化编译器不断改进,编译器的作者们开始声称,编译器生成的代码的性能与手工优化的汇编代码相差 10%到 50%。这样的声明推动了高级语言在 PC 应用程序开发中的兴起,并敲响了汇编语言的丧钟。许多程序员开始引用诸如“我的编译器达到了汇编语言速度的 90%,所以用汇编语言简直是疯狂”的统计数据。问题是,他们从未费心去编写手工优化的汇编版本应用程序来验证他们的说法。通常,他们对编译器性能的假设是错误的。更糟糕的是,当像 C 和 C++这样的语言的编译器成熟到能够生成非常好的输出代码时,程序员们开始偏好那些高级语言,如 Java、Python 和 Swift,这些语言要么是解释型(或半解释型)的,要么是拥有非常不成熟的代码生成器,生成糟糕的输出代码。

优化编译器的作者们并没有说谎。在合适的条件下,优化编译器可以生成几乎与手工优化的汇编语言代码一样优秀的代码。然而,高级语言(HLL)代码必须以合适的方式编写,才能达到这些性能水平。以这种方式编写高级语言代码需要对计算机如何操作和执行软件有深刻的理解。

1.2 为什么学习汇编语言仍然是一个好主意

当程序员们首次放弃汇编语言,转而使用高级语言时,他们通常理解所使用的高级语言的低级影响,并能够适当地选择高级语言语句。不幸的是,随后的程序员一代并没有掌握汇编语言的优势。因此,他们并没有能力明智地选择那些能够高效转化为机器代码的语句和数据结构。如果将他们的应用程序与相应的手工优化汇编语言程序的性能进行比较,结果无疑会证明其性能更差。

资深程序员意识到这个问题后,向新程序员提供了明智的建议:“如果你想学会写出优秀的高级语言代码,你需要学习汇编语言。”通过学习汇编语言,程序员可以理解他们代码的低级含义,并做出关于如何在高级语言中编写应用程序的明智决策。^(1) 第二章将进一步讨论汇编语言。

1.3 为什么学习汇编语言并非绝对必要

尽管任何全面的程序员学习编程汇编语言是个好主意,但这并不是写出优秀、高效代码的必要条件。最重要的是理解高级语言如何将语句翻译成机器代码,以便你可以选择合适的高级语言语句。虽然成为汇编语言的专家是一种方法,但这种方法需要相当多的时间和精力。

那么,问题是:“程序员是否可以仅研究机器的低级性质,改善他们编写的高级语言代码,而不必成为汇编语言专家?”根据前述观点,答案是有条件的肯定。这本书的目的就是教你写出优秀代码所需的知识,而不必成为汇编语言专家。

1.4 从低级角度思考

当 Java 在 1990 年代末期开始流行时,这门语言收到了以下类似的抱怨:

Java 的解释性代码让我在编写软件时需要更加小心;我不能像在 C/C++中那样使用线性查找。我必须使用像二分查找这样良好(且实现更困难)的算法。

这些语句展示了使用优化编译器的主要问题:它们使程序员变得懒惰。尽管优化编译器在过去几十年取得了巨大进展,但没有任何一个编译器能够弥补编写不良高级语言源代码的问题。

当然,许多初学者高级语言(HLL)程序员读过关于现代编译器优化算法多么神奇的文章,并假设编译器无论输入什么都会生成高效的代码。然而,事实并非如此:尽管编译器在将编写良好的高级语言代码转换为高效机器代码方面做得很好,但编写不良的源代码会妨碍编译器的优化算法。事实上,常常能听到 C/C++程序员称赞他们的编译器,却从未意识到由于他们编写的代码方式,编译器实际上做得很差。问题在于,他们从未真正查看过编译器从高级语言源代码生成的机器代码。他们假设编译器做得很好,因为他们被告知编译器生成的代码几乎与专家的汇编语言程序员所能生成的代码一样好。

1.4.1 编译器的好坏取决于你提供的源代码

不用说,编译器不会改变你的算法来提高软件的性能。例如,如果你使用线性搜索而不是二分搜索,你不能指望编译器为你使用更好的算法。当然,优化器可能会通过一个常数因子(比如让你的代码速度加倍或三倍)来提高线性搜索的速度,但这种提高可能和使用更好的算法相比微不足道。实际上,很容易证明,在数据库足够大的情况下,通过没有优化的解释器执行二分搜索会比通过最佳编译器执行线性搜索更快。

1.4.2 如何帮助编译器生成更好的机器代码

假设你已经为你的应用程序选择了最好的算法,并且你花费额外的费用购买了最好的编译器。有没有办法让你写的高级语言代码比你平常编写的更高效?一般来说,答案是肯定的。

编译器世界中一个最被保密的秘密就是,大多数编译器基准测试都是被操控的。大多数真实世界中的编译器基准测试都会指定一个算法,但具体的算法实现由编译器厂商来完成,并且这些厂商通常知道他们的编译器在处理特定代码序列时的表现,因此他们会编写出能生成最佳可执行文件的代码序列。

有些人可能觉得这是一种作弊行为,但其实并不是。如果编译器能够在正常情况下生成相同的代码序列(也就是说,这种代码生成技巧并不是专门为基准测试开发的),那么展示其性能是完全没有问题的。而且,如果编译器厂商能够使用类似的小技巧,那么你也完全可以。通过精心选择你在高级语言源代码中使用的语句,你可以“手动优化”编译器生成的机器代码。

手动优化有多个层级。在最抽象的层面上,你可以通过为软件选择更好的算法来优化程序。这种技术与编译器和语言无关。

降低抽象级别,下一步就是根据你使用的高级语言来手动优化代码,同时保持优化与该语言的具体实现无关。虽然这种优化可能不适用于其他语言,但它应该适用于同一种语言的不同编译器。

降低到另一个层次,你可以开始考虑如何构造代码,使得优化仅适用于某个特定的编译器厂商,或者仅适用于某个特定版本的编译器。

最后,在可能的最低层次,你可以考虑编译器发出的机器代码,并调整在 HLL 中编写语句的方式,以迫使编译器生成某些机器指令序列。Linux 内核就是这种方法的一个例子。传说中,内核开发者不断调整他们在 Linux 内核中编写的 C 代码,以控制 GNU C 编译器(GCC)所生成的 80x86 机器代码。

虽然这个开发过程可能有些被夸大,但有一点是肯定的:采用这种方法的程序员将能够从编译器中生成最优的机器代码。这种代码与合格的汇编语言程序员所写的代码相当,也是高级语言(HLL)程序员在辩论时提到的,认为编译器生成的代码可以与手写汇编语言相媲美的那种编译器输出。大多数人并不会为了编写 HLL 代码而走到这些极端,这点从未被提出作为论点。然而,事实仍然是,精心编写的 HLL 代码可以接近于高效的汇编代码。

编译器是否会生成和专家级汇编语言程序员所写的代码一样好,甚至更好?正确答案是否定的;毕竟,专家级汇编语言程序员总能查看编译器的输出并加以改进。然而,精心编写代码的程序员,如果使用像 C/C++ 这样的 HLL,仍然可以接近这一目标,只要他们编写的程序能够让编译器轻松地将其转换为高效的机器代码。因此,真正的问题是:“我该如何编写 HLL 代码,使编译器能够最有效地转换它?”嗯,回答这个问题正是本书的主题。但简短的答案是:“用汇编语言思考;用高级语言编写。”我们来快速看看如何做到这一点。

1.4.3 在编写 HLL 代码时如何思考汇编语言

HLL 编译器将该语言中的语句翻译为一个或多个机器语言(或汇编语言)指令的序列。应用程序在内存中占用的空间大小,以及应用程序执行时所花费的时间,直接与编译器发出的机器指令的数量和类型相关。

然而,你可以通过两种不同的代码序列在 HLL 中实现相同的结果,这并不意味着编译器为每种方法生成相同的机器指令序列。HLL 中的 ifswitch/case 语句就是经典的例子。大多数入门编程教材都建议将一连串的 if-elseif-else 语句等同于 switch/case 语句。考虑以下简单的 C 语言示例:


			switch( x )
    {
        case 1:
            printf( "X=1\n" );
            break;

        case 2:
            printf( "X=2\n" );
            break;

        case 3:
            printf( "X=3\n" );
            break;

        case 4:
            printf( "X=4\n" );
            break;

        default:
            printf( "X does not equal 1, 2, 3, or 4\n" );
    }

/* equivalent if statement */

    if( x == 1 )
        printf( "X=1\n" );
    else if( x== 2 )
        printf( "X=2\n" );
    else if( x==3 )
        printf( "X=3\n" );
    else if( x==4 )
        printf( "X=4\n" );
    else
        printf( "X does not equal 1, 2, 3, or 4\n" );

尽管这两个代码序列在语义上可能是等效的(也就是说,它们计算相同的结果),但不能保证编译器会为两者生成相同的机器指令序列。

哪一个会更好呢?除非你了解编译器如何将这些语句转换为机器代码,并且对不同机器之间的效率差异有基本的了解,否则你可能无法回答这个问题。完全理解编译器如何转换这两个序列的程序员可以对它们进行评估,然后根据预期输出代码的质量明智地选择其中一个。

通过在编写 HLL 代码时使用低级术语,程序员可以帮助优化编译器接近手工优化的汇编语言代码所达到的代码质量水平。遗憾的是,通常情况相反:如果程序员没有考虑 HLL 代码的低级影响,编译器很少会生成最优的机器代码。

1.5 编写高级语言代码

在编写高级语言代码时,如果用低级术语思考,其中一个问题是,按这种方式编写 HLL 代码几乎和编写汇编代码一样费劲。这消除了编写 HLL 程序时许多熟悉的好处,比如更快的开发时间、更好的可读性和更容易的维护。如果你正在牺牲使用 HLL 编写应用程序的好处,为什么不干脆从一开始就用汇编语言编写呢?

事实证明,使用低级术语思考并不会像你预期的那样大幅延长整体项目的进度。虽然它确实会减缓初期的编码过程,但最终生成的高级语言(HLL)代码仍然是可读的、可移植的,并且能够保持良好代码的其他特性。但更重要的是,它还会获得一些本来没有的效率。一旦代码编写完成,在软件开发生命周期(SDLC)的维护和增强阶段,你就不必再用低级术语去思考它了。简而言之,在初期软件开发阶段使用低级术语思考,既保留了低级和高级编码的优势(效率加上易于维护),又避免了相应的缺点。

1.6 跨语言的方法

虽然本书假设你至少熟悉一种命令式语言,但它并不是完全针对某一种语言的;其概念跨越了你使用的任何编程语言。为了帮助使示例更易理解,我们将使用多种语言的编程示例,诸如 C/C++、Pascal、BASIC、Java、Swift 和汇编语言。在展示示例时,我会详细解释代码的运行方式,这样即使你不熟悉特定的编程语言,也能通过阅读附带的描述理解其运作方式。

本书在各种示例中使用了以下语言和编译器:

  • C/C++: GCC 和微软的 Visual C++

  • Pascal: Borland 的 Delphi 和 Free Pascal

  • 汇编语言: 微软的 MASM、HLA(高级汇编语言)和 Gas(GNU 汇编器)

  • Basic: 微软的 Visual Basic

如果你不习惯使用汇编语言,不用担心:80x86 汇编语言的入门教程和在线参考 (www.writegreatcode.com/ )将帮助你读取编译器输出。如果你想扩展你对汇编语言的了解,可以查看本章末尾列出的资源。

1.7 额外提示

没有一本书能完全涵盖写出优秀代码所需的所有知识。因此,这本书专注于编写优秀软件最相关的领域,为那些有兴趣编写最佳代码的人提供 90%的解决方案。要获得剩下的 10%,您需要额外的帮助。以下是一些建议:

成为一名精通汇编语言的程序员。 至少精通一种汇编语言,将填补许多从这本书中无法获得的细节。如前所述,本书的目的是教你如何编写最佳代码,而不是成为一名汇编语言程序员。然而,额外的努力将提高你以低级语言思考的能力。

学习编译器构造理论。 虽然这是计算机科学中的一个高级话题,但没有比研究编译器背后的理论更好的方式来理解编译器如何生成代码。尽管有许多关于这个主题的教材,其中一些需要相当的先备知识。在购买任何书籍之前,请仔细审查,确定它是否以适合您技能水平的方式编写。您还可以在线搜索一些优秀的网络教程。

学习高级计算机架构。 机器组织和汇编语言编程是计算机架构学习的一个子集。虽然你可能不需要知道如何设计自己的 CPU,但学习计算机架构可能帮助你发现改进你编写的高级语言(HLL)代码的其他方法。

1.8 获取更多信息

Duntemann, Jeff. 《汇编语言逐步教程》(第 3 版)。印第安纳波利斯:Wiley,2009 年。

Hennessy, John L.,David A. Patterson. 《计算机架构:定量方法》(第 5 版)。沃尔瑟姆,马萨诸塞州:Morgan Kaufmann,2012 年。

Hyde, Randall. 《汇编语言的艺术》(第 2 版)。旧金山:No Starch Press,2010 年。

第二章:你不应该学习汇编语言吗?

image

尽管本书将教你如何在不精通汇编语言的情况下编写更好的代码,但真正出色的高级语言程序员都知道汇编语言,而这种知识正是他们编写优秀代码的原因之一。如第一章所提到的,尽管本书能为你提供一个 90%的解决方案,帮助你编写优秀的高级语言代码,但要填补最后的 10%,你需要学习汇编语言。虽然教授汇编语言超出了本书的范围,但它仍然是一个重要的主题,值得讨论。因此,本章将探讨以下内容:

  • 学习汇编语言的难点

  • 高级汇编器及其如何使学习汇编语言变得更容易

  • 如何使用现实世界的工具,如 Microsoft Macro Assembler (MASM)、Gas(Gnu Assembler)和 HLA(高级汇编语言),轻松学习汇编语言编程

  • 汇编语言程序员如何思考(即汇编语言编程范式)

  • 可用的资源,帮助你学习汇编语言编程

2.1 学习汇编语言的好处与障碍

学习汇编语言——真正学习汇编语言——有两个好处。首先,你将完全理解编译器能够生成的机器代码。通过掌握汇编语言,你将实现前面所述的 100%解决方案,并能够编写更好的高级语言代码。其次,当你的高级语言编译器即使在你的帮助下也无法生成最佳代码时,你将能够用汇编语言编写应用程序的关键部分。掌握本书接下来的章节,磨练你的高级语言技巧后,继续学习汇编语言是一个非常好的选择。

然而,学习汇编语言有一个难点。在过去,学习汇编语言是一个漫长、困难且令人沮丧的过程。汇编语言编程范式与高级语言编程有足够的差异,导致大多数人在学习汇编语言时会觉得自己像是从头开始。这非常令人沮丧,因为你已经能在 C/C++、Java、Swift、Pascal 或 Visual Basic 等编程语言中做到某些事情,但在汇编语言中却无法找到解决方案。

大多数程序员喜欢在学习新知识时能够应用以往的经验。不幸的是,传统的汇编语言学习方法往往迫使高级语言(HLL)程序员忘记他们过去所学的内容。与此相反,本书提供了一种方法,帮助你在学习汇编语言时高效利用现有的知识。

2.2 本书如何提供帮助

一旦你读完本书,你会发现有三个理由让学习汇编语言变得更加容易:

  • 你会更有动力去学习它,因为你会理解这样做能帮助你编写更好的代码。

  • 你将已经学习过五个简要的汇编语言入门(80x86、PowerPC、ARM、Java 字节码和微软 IL),所以即使你之前从未接触过,等你读完这本书时,你也会掌握一些汇编语言的知识。

  • 你已经看到过编译器如何为所有常见的控制和数据结构生成机器代码,因此你已经学会了作为初学汇编语言程序员最困难的课题之一——如何用汇编语言实现那些在高级语言(HLL)中已经知道怎么做的事情。

尽管这本书不会教你如何成为一名专家级的汇编语言程序员,但大量的示例程序展示了编译器如何将高级语言翻译成机器代码,这些将让你了解许多汇编语言编程技巧。如果你决定在阅读完这本书后学习汇编语言,你会发现这些技巧很有用。

当然,如果你已经掌握了汇编语言,这本书会更容易阅读。然而,一旦你读完这本书,你也会发现汇编语言更容易掌握。由于学习汇编语言可能比读这本书更耗时,因此更有效的方式是先从这本书开始。

2.3 高级汇编器的拯救

早在 1995 年,我与加利福尼亚大学河滨分校计算机科学系主任进行了一次讨论。我感叹学生们在学习汇编课程时不得不重新开始,浪费宝贵的时间重新学习许多内容。随着讨论的深入,问题显然并不在于汇编语言本身,而是在于现有汇编器的语法(比如微软宏汇编器,或 MASM)。学习汇编语言不仅仅是学习几条机器指令。首先,你需要学习一种新的编程风格。掌握汇编语言不仅仅是理解几条机器指令的语义,更是要学会如何将这些指令组合起来解决现实世界中的问题。才是最难的部分。

其次,汇编语言并不是你可以高效地一次学习几条指令的东西。即使是写最简单的程序,也需要相当的知识和一些几十条甚至更多的机器指令。当你将这些指令与学生在典型汇编课程中必须学习的所有其他机器组织知识结合时,往往需要几周的时间,才能准备好写出除了“填鸭式”简单应用程序以外的任何东西。

1995 年 MASM 的一个重要特点是支持类似于高阶语言的控制语句,如 .if.while。虽然这些语句并非真正的机器指令,但它们确实允许学生在课程初期使用熟悉的编程结构,直到他们有足够的时间学习足够的低级机器指令,以便在他们的应用中使用。通过在学期初期使用这些高级结构,学生可以集中精力学习汇编语言编程的其他方面,而不必一次性吸收所有内容。这使得他们可以在课程中更早地开始编写代码,因此到学期结束时,他们能够覆盖更多的学习内容。

像 MASM(32 位 v6.0 及更高版本)这样的汇编器提供了类似于高阶语言中控制语句的功能——除了执行相同操作的传统低级机器指令外——这种汇编器被称为高级汇编器。理论上,借助一本使用这些高级汇编器教授汇编语言编程的合适教材,学生可以在课程的第一周就开始编写简单的程序。

像 MASM 这样的高级汇编器唯一的问题是,它们只提供了少数几个类似高阶语言的控制语句和数据类型。几乎所有其他内容对于熟悉高阶语言编程的人来说都是陌生的。例如,MASM 中的数据声明与大多数高阶语言中的数据声明完全不同。尽管存在类似高阶语言的控制语句,初学汇编的程序员仍然需要重新学习大量的信息。

2.4 高级汇编语言

在与我的系主任讨论之后,我意识到没有理由让汇编器不能采用更高层次的语法,而不改变汇编语言的语义。例如,考虑以下 C/C++ 和 Pascal 中声明整数数组变量的语句:


			int intVar[8]; // C/C++

var intVar: array[0..7] of integer; (* Pascal *)

现在考虑一下 MASM 对同一对象的声明:

intVar sdword 8 dup (?) ;MASM

尽管 C/C++ 和 Pascal 的声明各不相同,但汇编语言版本与两者的差异更加显著。一名 C/C++ 程序员即使从未见过 Pascal 代码,也能大致理解 Pascal 的声明,反之亦然。然而,Pascal 和 C/C++ 程序员可能完全无法理解汇编语言的声明。这只是高阶语言(HLL)程序员在学习汇编语言时面临的一个问题。

令人遗憾的是,汇编语言中的变量声明没有理由与高级语言中的声明如此截然不同。在最终的可执行文件中,汇编器使用何种语法来声明变量并不会造成任何差别。既然如此,为什么汇编器不使用更类似高级语言的语法,这样从高级语言转过来的程序员就能更容易学习汇编语言呢?思考这个问题让我开发了一种新的汇编语言,专门为那些已经掌握高级语言的学生设计,用于教学汇编语言编程,这种语言叫做高级汇编语言(HLA)。在 HLA 中,上述的数组声明看起来是这样的:

var intVar:int32[8]; // HLA

虽然语法与 C/C++和 Pascal 略有不同(实际上,它是两者的结合),但大多数高级语言(HLL)程序员大概能理解这个声明的含义。

HLA 设计的整体目的是提供一个尽可能类似传统(命令式)高级编程语言的汇编语言编程环境,同时不牺牲编写真实汇编语言程序的能力。语言中与机器指令无关的部分使用熟悉的高级语言语法,而机器指令仍然与底层的 80x86 机器指令一一对应。

使 HLA 尽可能类似于各种高级语言意味着,学习汇编语言编程的学生不需要花太多时间去适应一个截然不同的语法。相反,他们可以运用已有的高级语言知识,这使得学习汇编语言的过程更轻松、更快捷。

然而,单单一个舒适的声明语法和一些类似高级语言的控制语句并不足以让学习汇编语言变得尽可能高效。一个关于学习汇编语言的常见抱怨是,它对程序员几乎没有任何支持,程序员必须在编写汇编代码时不断重新发明轮子。例如,当使用 MASM 学习汇编语言时,你会很快发现,汇编语言并没有提供有用的输入输出功能,比如将整数值作为字符串打印到用户的控制台。汇编程序员必须自己编写这样的代码。不幸的是,编写一套体面的 I/O 例程需要相当复杂的汇编语言编程知识。获得这些知识的唯一途径是首先编写大量的代码,但在没有 I/O 例程的情况下这么做是非常困难的。因此,一个好的汇编语言教育工具也需要提供一套 I/O 例程,允许初学的汇编程序员在自己具备编写这些例程的编程能力之前,能够完成一些简单的 I/O 任务,比如读取和写入整数值。HLA 通过HLA 标准库实现了这一点,HLA 标准库是一个子程序和宏的集合,使得编写复杂应用程序变得非常容易。

由于 HLA 的流行以及它是一个免费、开源并且面向公共领域的产品,支持 Windows 和 Linux,因此本书在涉及汇编语言的编译器无关示例时,使用了 HLA 语法。尽管它已经有超过 20 年的历史,且仅支持 32 位的 Intel 指令集,但 HLA 仍然是学习汇编语言编程的极好方式。虽然最新的 Intel CPU 直接支持 64 位寄存器和操作,但学习 32 位汇编语言对 HLL 程序员而言,依然与学习 64 位汇编语言同样相关。

2.5 高级思维,低级编程

HLA 的目标是让初学者在编写低级代码时能够用高级语言的术语进行思考(换句话说,正好与本书试图教授的内容相反)。对于第一次接触汇编语言的学生来说,能够以高级语言的思维方式进行思考是一个天赐之物——他们可以在面对特定的汇编编程问题时,应用已经在其他语言中学到的技巧。以这种方式控制学生学习新概念的速度,可以使教育过程更高效。

最终,目标当然是学习低级编程范式。这意味着逐渐放弃类似高级语言(HLL)的控制结构,编写纯粹的低级代码(也就是“低级思维,低级编程”)。尽管如此,从“高层思维,低级编程”开始,是学习汇编语言编程的一个极好的渐进方式。

2.6 汇编编程范式(低级思维)

现在应该很清楚,汇编语言编程与常见的高级语言编程有很大的不同。幸运的是,在本书中,你不需要从头开始编写汇编语言程序。然而,如果你了解汇编程序是如何编写的,你将能够理解编译器为何生成特定的代码序列。为此,我将在这里花些时间描述汇编语言程序员(和编译器)如何“思考”。

汇编语言编程范式的最基本方面——也就是汇编编程如何实现的模型——是将大项目分解为机器可以处理的小任务。从根本上讲,CPU 每次只能执行一个小任务;即使对于复杂指令集计算机(CISC)也是如此。因此,像高级语言中那样的复杂操作必须被分解成机器可以直接执行的较小组件。举个例子,考虑以下 Visual Basic (VB) 赋值语句:

profits = sales - costOfGoods - overhead - commissions

没有任何实用的 CPU 会允许你将整个 VB 语句作为单个机器指令执行。相反,你必须将该赋值语句分解为一系列机器指令,计算其中的各个组成部分。例如,许多 CPU 提供了一个减法指令,可以让你从机器寄存器中减去一个值。由于该示例中的赋值语句包含三个减法操作,你将需要将赋值操作分解为至少三个不同的减法指令。

80x86 CPU 系列提供了一个相当灵活的减法指令:sub()。该指令允许以下几种形式(在 HLA 语法中):


			sub( constant, reg );       // reg = reg - constant
sub( constant, memory );    // memory = memory - constant
sub( reg1, reg2 );          // reg2 = reg2 - reg1
sub( memory, reg );         // reg = reg - memory
sub( reg, memory );         // memory = memory - reg

假设原始 VB 代码中的所有标识符都代表变量,我们可以使用 80x86 sub()mov() 指令来实现相同的操作,HLA 代码序列如下:


			// Get sales value into EAX register:

mov( sales, eax );

// Compute sales-costOfGoods (EAX := EAX - costOfGoods)

sub( costOfGoods, eax );

// Compute (sales-costOfGoods) - overhead
// (note: EAX contains sales-costOfGoods)

sub( overhead, eax );

// Compute (sales-costOfGoods-overhead)-commissions
// (note: EAX contains sales-costOfGoods-overhead)

sub( commissions, eax );

// Store result (in EAX) into profits:

mov( eax, profits );

这段代码将单一的 VB 语句分解为五个不同的 HLA 语句,每个语句都执行总计算的一部分。汇编语言编程范式背后的秘密是知道如何将像这样的复杂操作分解为一串简单的机器指令。我们将在第十三章中再次探讨这个过程。

高级语言(HLL)控制结构是另一个将复杂操作分解为简单语句序列的重要领域。例如,考虑以下 Pascal if() 语句:


			if( i = j ) then begin

    writeln( "i is equal to j" );

end;

CPU 不支持if机器指令。相反,您比较两个值,设置条件码标志,然后通过使用条件跳转指令测试这些条件码的结果。将高级语言if语句转换为汇编语言的常见方法是测试相反的条件(i <> j),然后跳过如果原始条件(i = j)评估为true时要执行的语句。例如,下面是将之前的 Pascal if语句转换为 HLA(使用纯汇编语言,即不使用高级语言样式的构造)的一个示例:


			    mov( i, eax );      // Get i's value into eax register
    cmp( eax, j );      // Compare eax to j's value
    jne skipIfBody;     // Skip body of if statement if i <> j

    << code to print string >>

skipIfBody:

随着高级语言控制结构中的布尔表达式变得越来越复杂,对应的机器指令数量也会增加。但这个过程保持不变。稍后,我们将看看编译器是如何将高级语言控制结构转换为汇编语言的(请参见第十三章和第十四章)。

将参数传递给过程或函数、访问这些参数,然后访问该过程或函数本地的其他数据,是汇编语言相较于典型的高级语言更为复杂的另一个领域。这是一个重要话题,但超出了本章的范围,因此我们将在第十五章中再次讨论。

最终,问题的关键是,当将算法从高级语言转换时,必须将问题分解成更小的部分,以便能够在汇编语言中进行编码。如前所述,好消息是,当您只是在阅读汇编代码时,您无需自己决定使用哪些机器指令——编译器(或汇编程序员)在最初创建代码时已经为您完成了这项工作。您所需要做的只是建立高级语言代码与汇编代码之间的对应关系。如何完成这一点是本书余下部分的主要内容。

2.7 获取更多信息

Bartlett, Jonathan. 《从零开始编程》. 编辑:Dominick Bruno, Jr. 自费出版,2004 年。此书的较旧、免费的版本,使用 Gas 教授汇编语言编程,可以在网上找到:www.plantation-productions.com/AssemblyLanguage/ProgrammingGroundUp-1-0-booksize.pdf

Blum, Richard. 《专业汇编语言》. 印第安纳波利斯:Wiley,2005 年。

Carter, Paul. 《PC 汇编语言》. 自费出版,2019 年。pacman128.github.io/static/pcasm-book.pdf

Duntemann, Jeff. 《汇编语言一步步》. 第 3 版. 印第安纳波利斯:Wiley,2009 年。

Hyde, Randall. 《汇编语言的艺术》. 第 2 版. 旧金山:No Starch Press,2010 年。

———. “Webster:在互联网上学习汇编语言的地方。”plantation-productions.com/Webster/index.html

第三章:80X86 为 HLL 程序员提供的汇编语言

image

在本书中,你将研究高级语言代码,并将其与编译器为其生成的机器码进行对比。理解编译器的输出需要一定的汇编语言知识,但幸运的是,你不需要成为专家级汇编程序员。正如前几章所讨论的,你真正需要的只是能够阅读编译器和其他汇编程序员生成的代码。

本章提供了专门讲解 80x86 汇编语言的入门知识,涵盖以下主题:

  • 基本的 80x86 机器架构

  • 如何读取由各种编译器生成的 80x86 输出

  • 32 位和 64 位 80x86 CPU 支持的寻址模式

  • 几种常见 80x86 汇编器(HLA、MASM 和 Gas)使用的语法

  • 如何在汇编语言程序中使用常量并声明数据

3.1 学习一种汇编语言很好,学习更多更好

如果你打算为除 80x86 之外的其他处理器编写代码,你应该至少学习两种不同的汇编语言。通过这样做,你可以避免在高级语言中为 80x86 编写代码,然后发现你的“优化”只能在 80x86 CPU 上工作。出于这个原因,本书包含了几个在线附录,提供了额外的资源:

  • 附录 A 介绍了最小的 x86 指令集。

  • 附录 B 是 PowerPC CPU 的入门知识。

  • 附录 C 讲解了 ARM 处理器。

  • 附录 D 描述了 Java 字节码汇编语言。

  • 附录 E 介绍了 Microsoft 中间语言。

你将看到所有五种架构都依赖于许多相同的概念,但它们之间存在一些重要的差异,每种架构也有其优点和缺点。

也许复杂指令集计算机(CISC)精简指令集计算机(RISC)架构之间的主要区别是它们使用内存的方式。RISC 架构将内存访问限制为特定的指令,因此应用程序会尽力避免访问内存。另一方面,80x86 架构允许大多数指令访问内存,应用程序通常会利用这一功能。

Java 字节码(JBC)和 Microsoft 中间语言(IL)架构与 80x86、PowerPC 和 ARM 架构不同,JBC 和 IL 是虚拟机,而不是实际的 CPU。通常,软件在运行时解释或尝试编译 JBC(IL 代码始终在运行时编译)。^(1) 这意味着 JBC 和 IL 代码通常比真正的机器码运行得要慢。

3.2 80x86 汇编语法

尽管 80x86 程序员可以选择多种程序开发工具,但这种丰富性也有一个小小的缺点:语法不兼容性。不同的 80x86 编译器和调试器对于完全相同的程序输出不同的汇编语言列表。这是因为这些工具为不同的汇编器生成代码。例如,微软的 Visual C++ 套件生成与微软宏汇编器 (MASM) 兼容的汇编代码。GNU 编译器套件(GCC)生成与 Gas 兼容的源代码(Gas 是来自自由软件基金会的 GNU 汇编器)。除了编译器生成的代码外,你还会看到大量使用像 FASM、NASM、GoAsm 和 HLA(高级汇编)等汇编器编写的汇编编程示例。

本书中最好使用单一的汇编语言语法,但由于我们的方法并不特定于某一编译器,因此必须考虑几种不同常见汇编器的语法。本书通常会使用 HLA 提供与编译器无关的示例。因此,本章将讨论 HLA 语法以及其他两种常见汇编器的语法:MASM 和 Gas。幸运的是,一旦你掌握了某一汇编器的语法,学习其他汇编器的语法就非常容易。

3.2.1 基本的 80x86 架构

英特尔 CPU 通常被归类为 冯·诺依曼机。冯·诺依曼计算机系统包含三个主要构建模块:中央处理单元 (CPU)内存输入/输出 (I/O) 设备。这三部分通过 系统总线 连接(系统总线由地址总线、数据总线和控制总线组成)。图 3-1 显示了这种关系。

Image

图 3-1:冯·诺依曼系统的框图

CPU 通过在 地址总线 上放置一个数字值来与内存和 I/O 设备进行通信,以选择其中一个内存位置或 I/O 设备端口位置,每个位置都有一个唯一的二进制数字地址。然后,CPU、I/O 设备和内存设备通过将数据放置在 数据总线 上相互传递数据。控制总线 包含信号,决定数据传输的方向(是向内存传输还是从内存传输,或是向 I/O 设备传输还是从 I/O 设备传输)。

3.2.2 寄存器

寄存器组是 CPU 内最显著的特征。几乎所有在 80x86 CPU 上的运算都涉及至少一个寄存器。例如,要将两个变量的值相加并将它们的和存储到第三个变量中,必须先将其中一个变量加载到寄存器中,再将第二个操作数加到该寄存器中,最后将寄存器的值存储到目标变量中。寄存器几乎是每次计算中的中介,因此在 80x86 汇编语言程序中非常重要。

80x86 CPU 寄存器可以分为四类:通用寄存器、特殊用途的应用程序可访问寄存器、段寄存器和特殊用途的内核模式寄存器。我们不会考虑最后两类,因为在现代操作系统中段寄存器的使用并不广泛(例如,Windows、BSD、macOS 和 Linux),而特殊用途的内核模式寄存器是为编写操作系统、调试器以及其他系统级工具而设计的——这超出了本书的讨论范围。

3.2.3 80x86 32 位通用寄存器

32 位 80x86(Intel 家族)CPU 提供了多个供应用程序使用的通用寄存器。这些包括八个 32 位寄存器:EAX、EBX、ECX、EDX、ESI、EDI、EBP 和 ESP。

每个寄存器名称前的E前缀代表扩展。这个前缀区分了 32 位寄存器和原始的八个 16 位寄存器:AX、BX、CX、DX、SI、DI、BP 和 SP。

最后,80x86 CPU 提供了八个 8 位寄存器:AL、AH、BL、BH、CL、CH、DL 和 DH。

关于通用寄存器,最重要的要点是它们不是独立的。也就是说,80x86 架构并没有提供 24 个独立的寄存器。相反,它将 32 位寄存器与 16 位寄存器重叠,并且将 16 位寄存器与 8 位寄存器重叠。图 3-2 展示了这种关系。

Image

图 3-2:Intel 80x86 CPU 通用寄存器

修改一个寄存器可能会同时修改多达三个其他寄存器这一点非常重要。例如,修改 EAX 寄存器可能也会修改 AL、AH 和 AX 寄存器。你将经常看到编译器生成的代码利用这一点。例如,编译器可能会清除(设置为0)EAX 寄存器中的所有位,然后将10加载到 AL 中,以产生一个 32 位的true1)或false0)值。某些机器指令仅操作 AL 寄存器,但程序可能需要将这些指令的结果返回到 EAX 中。通过利用寄存器的重叠,编译器生成的代码可以使用操作 AL 的指令,并将该值返回到整个 EAX 寄存器中。

尽管 Intel 将这些寄存器称为通用寄存器,但这并不意味着你可以将任何寄存器用于任何目的。例如,SP/ESP 寄存器对具有非常特殊的功能,实际上阻止你将它用于其他任何目的(它是堆栈指针)。同样,BP/EBP 寄存器也有一个特殊用途,限制了它作为通用寄存器的有效性。所有 80x86 寄存器都有各自的特殊用途,这限制了它们在某些上下文中的使用;我们将在讨论使用这些寄存器的机器指令时考虑这些特殊用途(见在线资源)。

当代版本的 80x86 CPU(通常称为 x86-64 CPU)为 32 位寄存器集提供了两个重要扩展:一组 64 位寄存器和另一组八个寄存器(64 位、32 位、16 位和 8 位)。主要的 64 位寄存器具有以下名称:RAX、RBX、RCX、RDX、RSI、RDI、RBP 和 RSP。

这些 64 位寄存器与 32 位的“E”寄存器重叠。也就是说,32 位寄存器包含了这些寄存器的低(低序)32 位。例如,EAX 是 RAX 的低 32 位。同样,AX 是 RAX 的低 16 位,AL 是 RAX 的低 8 位。

除了提供现有 80x86 32 位寄存器的 64 位变体外,x86-64 CPU 还增加了八个其他的 64/32/16/8 位寄存器:R15、R14、R13、R12、R11、R10、R9 和 R8。

你可以将这些寄存器的低 32 位称为 R15d、R14d、R13d、R12d、R11d、R10d、R9d 和 R8d。

你可以将这些寄存器的低 16 位称为 R15w、R14w、R13w、R12w、R11w、R10w、R9w 和 R8w。

最后,你可以将这些寄存器的低字节称为 R15b、R14b、R13b、R12b、R11b、R10b、R9b 和 R8b。

3.2.4 80x86 EFLAGS 寄存器

32 位 EFLAGS 寄存器封装了许多单个位的布尔值(true/false)(或 标志)。其中大多数位要么是为内核模式(操作系统)功能保留,要么对于应用程序开发者而言没有太大意义。然而,有 8 位对于编写(或读取)汇编语言代码的应用程序开发者是相关的:溢出标志、方向标志、禁止中断标志、符号标志、零标志、辅助进位标志、奇偶标志和进位标志。图 3-3 显示了它们在 EFLAGS 寄存器中的布局。

在应用程序开发者可以使用的八个标志中,尤其有四个标志非常有价值:溢出标志、进位标志、符号标志和零标志。我们将这四个标志称为 条件码。每个标志都有一个状态——设置或清除——你可以利用它来测试之前计算的结果。例如,在比较两个值之后,条件码标志会告诉你哪个值小于、等于或大于另一个值。

Image

图 3-3:80x86 标志寄存器的布局(低 16 位)

x86-64 64 位 RFLAGS 寄存器保留了从位 32 到位 63 的所有位。EFLAGS 寄存器的上 16 位通常只对操作系统代码有用。

因为 RFLAGS 寄存器在读取编译器输出时不包含任何有用信息,本书将简单地将 x86 和 x86-64 标志寄存器称为 EFLAGS,即使在 64 位 CPU 变体上也是如此。

3.3 字面常量

大多数汇编器支持字面数值(包括二进制、十进制和十六进制)、字符和字符串常量。不幸的是,几乎所有的汇编器都使用不同的字面常量语法。本节将描述本书中使用的汇编器的语法。

3.3.1 二进制字面常量

所有汇编器都提供了指定基数 2(二进制)字面常量的能力。很少有编译器生成二进制常量,因此你可能不会在编译器输出中看到这些值,但在手写的汇编代码中你可能会看到它们。C++ 14 也支持二进制字面常量(0bxxxxx)。

3.3.1.1 HLA 中的二进制字面常量

HLA 中的二进制字面常量以百分号字符(%)开头,后跟一个或多个二进制数字(01)。下划线字符可以出现在二进制数字的任何两个数字之间。根据惯例,HLA 程序员使用下划线分隔每四个数字一组。例如:


			%1011
%1010_1111
%0011_1111_0001_1001
%1011001010010101
3.3.1.2 Gas 中的二进制字面常量

Gas 中的二进制字面常量以特殊的 0b 前缀开头,后跟一个或多个二进制数字(01)。例如:


			0b1011
0b10101111
0b0011111100011001
0b1011001010010101
3.3.1.3 MASM 中的二进制字面常量

MASM 中的二进制字面常量由一个或多个二进制数字(01)组成,并以特殊的 b 后缀结尾。例如:


			1011b
10101111b
0011111100011001b
1011001010010101b

3.3.2 十进制字面常量

大多数汇编器中的十进制常量采用标准格式——一系列一个或多个十进制数字,没有任何特殊的前缀或后缀。这是编译器生成的两种常见数字格式之一,因此你经常会在编译器输出的代码中看到十进制字面常量。

3.3.2.1 HLA 中的十进制字面常量

HLA 允许你在十进制数字的任何两个数字之间插入下划线。HLA 程序员通常使用下划线来分隔十进制数字中的每组三个数字。例如,对于以下数字:


			123
1209345

HLA 程序员可以按如下方式插入下划线:


			1_024
1_021_567
3.3.2.2 Gas 和 MASM 中的十进制字面常量

Gas 和 MASM 使用一串十进制数字(标准的“计算机”格式用于表示十进制值)。例如:


			123
1209345

与 HLA 不同,Gas 和 MASM 不允许在十进制字面常量中嵌入下划线。

3.3.3 十六进制字面常量

十六进制(基数 16)字面常量是汇编语言程序中另一种常见的数字格式(尤其是编译器生成的程序)。

3.3.3.1 HLA 中的十六进制字面常量

HLA 中的十六进制字面常量由一串十六进制数字(0..9a..fA..F)组成,并以 $ 为前缀。下划线可以选择性地出现在数字的任何两个十六进制数字之间。根据惯例,HLA 程序员使用下划线分隔每四个数字一组。例如:


			$1AB0
$1234_ABCD
$dead
3.3.3.2 Gas 中的十六进制字面常量

Gas 中的十六进制字面常量由一串十六进制数字(0..9a..fA..F)组成,并以 0x 为前缀。例如:


			0x1AB0
0x1234ABCD
0xdead
3.3.3.3 MASM 中的十六进制字面常量

MASM 中的十六进制字面常量由一串十六进制数字(0..9a..fA..F)组成,并以 h 为后缀。值必须以十进制数字开始(如果常量通常以 a..f 范围内的字母开头,则以 0 开头)。例如:


			1AB0h
1234ABCDh
0deadh

3.3.4 字符和字符串字面常量

字符和字符串数据也是汇编程序中常见的数据类型。MASM 不区分字符字面常量和字符串字面常量。然而,HLA 和 Gas 使用不同的内部表示来处理字符和字符串,因此在这些汇编器中,区分这两种字面常量非常重要。

3.3.4.1 HLA 中的字符和字符串字面常量

HLA 中的字符字面常量有几种不同的形式。最常见的是一个单一的可打印字符,周围有一对撇号,如 'A'。为了指定实际的撇号作为字符字面常量,HLA 要求你用一对撇号包围另一对撇号('''')。最后,你也可以使用 # 符号后跟一个二进制、十进制或十六进制的数值,来指定你要使用的字符的 ASCII 码。例如:


			'a'
''''
' '
#$d
#10
#%0000_1000

HLA 中的字符串字面常量由零个或多个字符组成,字符由引号包围。为了在字符串常量中表示实际的引号字符,你需要使用两个相邻的引号。例如:


			"Hello World"
"" -- The empty string
"He said ""Hello"" to them"
"""" -- string containing one quote character
3.3.4.2 Gas 中的字符和字符串字面常量

Gas 中的字符字面常量由一个撇号后跟一个字符组成。更新版本的 Gas(以及 Mac 上的 Gas)也允许类似 'a' 的字符常量。例如:


			'a
''
'!
'a'   // Modern versions of Gas and Mac's assembler
'!'   // Modern versions of Gas and Mac's assembler

Gas 中的字符串字面常量由零个或多个字符组成,字符由引号包围,并且使用与 C 字符串相同的语法。你可以使用 \ 转义序列在 Gas 字符串中嵌入特殊字符。例如:


			"Hello World"
"" -- The empty string
"He said \"Hello\" to them"
"\"" -- string containing one quote character
3.3.4.3 MASM 中的字符和字符串字面常量

MASM 中的字符和字符串字面常量采用相同的形式:一个或多个字符组成的序列,由撇号或引号包围。MASM 不区分字符常量和字符串常量。例如:


			'a'
"'" - An apostrophe character
'"' - A quote character
"Hello World"
"" -- The empty string
'He said "Hello" to them'

3.3.5 浮点字面常量

汇编语言中的浮点字面常量通常采用你在高级语言(HLL)中会看到的形式(一个数字序列,可能包含小数点,可选地跟着一个带符号的指数)。例如:


			3.14159
2.71e+2
1.0e-5
5e2

3.4 汇编语言中的显式(符号)常量

几乎每个汇编器都提供了一种声明符号(命名)常量的机制。事实上,大多数汇编器提供了几种方式,将一个值与源文件中的标识符关联起来。

3.4.1 HLA 中的显式常量

HLA 汇编器,名副其实,采用高级语法在源文件中声明命名常量。你可以通过三种方式定义常量:在const部分、在val部分,或使用?编译时运算符。constval部分出现在 HLA 程序的声明部分,它们的语法非常相似。它们之间的区别在于,你可以重新赋值给val部分中定义的标识符,但不能重新赋值给const部分中的标识符。尽管 HLA 在这些声明部分中支持多种选项,但基本声明的形式如下:


			const
    someIdentifier := someValue;

在源文件中,任何出现某个标识符的地方(在此声明之后),HLA 将用该标识符所对应的值someValue进行替换。例如:


			const
    aCharConst := 'a';
    anIntConst := 12345;
    aStrConst := "String Const";
    aFltConst := 3.12365e-2;

val
    anotherCharConst := 'A';
    aSignedConst := -1;

在 HLA 中,?语句允许你在源文件中任何允许空格的地方嵌入val声明。这有时很有用,因为在声明部分声明常量并不总是方便。例如:

?aValConst := 0;

3.4.2 Gas 中的清单常量

Gas 使用.equ(“等式”)语句在源文件中定义符号常量。该语句的语法如下:


			.equ        symbolName, value

下面是一些 Gas 源文件中等式的示例:


			.equ        false, 0
.equ        true, 1
.equ        anIntConst, 12345

3.4.3 MASM 中的清单常量

MASM 也提供了几种不同的方式来在源文件中定义清单常量。一种方式是使用equ指令:


			false       equ    0
true        equ    1
anIntConst  equ    12345

另一个是使用=运算符:


			false       =    0
true        =    1
anIntConst  =    12345

两者之间的区别很小;有关详细信息,请参阅 MASM 文档。

注意

在大多数情况下,编译器倾向于生成 equ 形式而非=形式。

3.5 80x86 寻址模式

寻址模式是访问指令操作数的硬件特定机制。80x86 系列提供三种不同类型的操作数:寄存器、立即数和内存操作数。本节讨论了每种寻址模式。

3.5.1 80x86 寄存器寻址模式

大多数 80x86 指令可以操作 80x86 的通用寄存器集。你通过指定寄存器的名称作为指令操作数来访问寄存器。

让我们考虑一些示例,看看我们的汇编器如何使用 80x86 的mov(移动)指令来实现这一策略。

3.5.1.1 HLA 中的寄存器访问

HLA 的mov指令如下所示:


			mov( source, destination );

该指令将数据从源操作数复制到目标操作数。8 位、16 位和 32 位寄存器是该指令的有效操作数;唯一的限制是两个操作数必须是相同的大小。

现在让我们来看一些实际的 80x86 mov指令:


			mov( bx, ax );      // Copies the value from BX into AX
mov( al, dl );      // Copies the value from AL into DL
mov( edx, esi );    // Copies the value from EDX into ESI

请注意,HLA 仅支持 32 位的 80x86 寄存器集,不支持 64 位寄存器集。

3.5.1.2 Gas 中的寄存器访问

Gas 在每个寄存器名称前加上百分号(%)。例如:


			%al, %ah, %bl, %bh, %cl, %ch, %dl, %dh
%ax, %bx, %cx, %dx, %si, %di, %bp, %sp
%eax, %ebx, %ecx, %edx, %esi, %edi, %ebp, %esp
%rax, %rbx, %rcx, %rdx, %rsi, %rdi, %rbp, %rsp
%r15b, %r14b, %r13b, %r12b, %r11b, %r10b, %r9b, %r8b
%r15w, %r14w, %r13w, %r12w, %r11w, %r10w, %r9w, %r8w
%r15d, %r14d, %r13d, %r12d, %r11d, %r10d, %r9d, %r8d
%r15, %r14, %r13, %r12, %r11, %r10, %r9, %r8

mov 指令的 Gas 语法与 HLA 类似,不同之处在于它去掉了括号和分号,并要求汇编语言语句必须完全适应于一行源代码。例如:


			mov %bx, %ax       // Copies the value from BX into AX
mov %al, %dl       // Copies the value from AL into DL
mov %edx, %esi     // Copies the value from EDX into ESI
3.5.1.3 在 MASM 中的寄存器访问

MASM 汇编器使用与 HLA 相同的寄存器名称,但增加了对 64 位寄存器组的支持:


			al, ah, bl, bh, cl, ch, dl, dh
ax, bx, cx, dx, si, di, bp, sp
eax, ebx, ecx, edx, esi, edi, ebp, esp
rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp
r15b, r14b, r13b, r12b, r11b, r10b, r9b, r8b
r15w, r14w, r13w, r12w, r11w, r10w, r9w, r8w
r15d, r14d, r13d, r12d, r11d, r10d, r9d, r8d
r15, r14, r13, r12, r11, r10, r9, r8

MASM 使用的基础语法与 Gas 类似,不同之处在于 MASM 交换了操作数的顺序(这是标准的 Intel 语法)。即,像 mov 这样的典型指令采用以下形式:


			mov destination, source

下面是 MASM 语法中的一些 mov 指令示例:


			mov ax, bx       ; Copies the value from BX into AX
mov dl, al       ; Copies the value from AL into DL
mov esi, edx     ; Copies the value from EDX into ESI

3.5.2 立即寻址模式

大多数允许寄存器和内存操作数的指令也允许立即数或 常量 操作数。例如,以下 HLA mov 指令会将适当的值加载到相应的目标寄存器中:


			mov( 0, al );
mov( 12345, bx );
mov( 123_456_789, ecx );

大多数汇编器在使用立即寻址模式时,允许你指定各种字面常量类型。例如,你可以提供十六进制、十进制或二进制形式的数字。你还可以将字符常量作为操作数提供。规则是常量必须符合目标操作数指定的大小。

下面是一些使用 HLA、Gas 和 MASM 的附加示例(请注意,Gas 需要在立即操作数前加上 $):


			mov( 'a', ch );  // HLA
mov $'a', %ch    // Gas
mov ch, 'a'       ; MASM

mov( $1234, ax ); // HLA
mov $0x1234, %ax  // Gas
mov ax, 1234h      ; MASM

mov( 4_012_345_678, eax ); // HLA
mov $4012345678, %eax      // Gas
mov eax, 4012345678         ; MASM

几乎每个汇编器都允许你创建符号常量名并将其作为源操作数提供。例如,HLA 预定义了两个布尔常量 truefalse,因此你可以将这些名称作为 mov 指令的操作数:


			mov( true, al );
mov( false, ah );

一些汇编器甚至允许指针常量和其他抽象数据类型常量。(有关详细信息,请参阅汇编器的参考手册。)

3.5.3 仅位移内存寻址模式

最常见的 32 位寻址模式,也是最容易理解的一种,是 仅位移(或 直接)寻址模式,在这种模式下,32 位常量指定内存位置的地址,这个地址可以是源操作数或目标操作数。请注意,这种寻址模式仅在 32 位 x86 处理器或在 64 位处理器的 32 位模式下可用。

例如,假设变量 J 是一个位于地址 $8088 的字节变量,HLA 指令 mov(J,al); 会将内存地址 $8088 处的字节加载到 AL 寄存器中。类似地,如果字节变量 K 位于内存地址 $1234,那么指令 mov(dl,K); 会将 DL 寄存器中的值写入内存地址 $1234(参见 图 3-4)。

Image

图 3-4:仅位移(直接)寻址模式

仅位移寻址模式非常适合访问简单的标量变量。这是你通常用来访问 HLL 程序中的静态或全局变量的寻址模式。

注意

英特尔将此寻址模式命名为“仅位移”,因为一个 32 位常量(位移)紧跟在 mov 操作码后面,存储在内存中。在 80x86 处理器上,这个位移是从内存起始位置(即地址0)开始的偏移量。

本章中的示例将经常访问内存中的字节大小对象。然而,别忘了,你也可以通过指定其第一个字节的地址来访问 80x86 处理器上的字和双字(见图 3-5)。

Image

图 3-5:使用直接寻址模式访问字或双字

MASM 和 Gas 在位移寻址模式的语法上与 HLA 相同:对于操作数,只需指定要访问的对象名称。一些 MASM 程序员会将变量名放在方括号中,尽管这些汇编器中并不严格要求这样做。

以下是使用 HLA、Gas 和 MASM 语法的几个示例:


			mov( byteVar, ch );  // HLA
movb byteVar, %ch    // Gas
mov ch, byteVar       ; MASM

mov( wordVar, ax ); // HLA
movw wordVar, %ax   // Gas
mov ax, wordVar      ; MASM

mov( dwordVar, eax );   // HLA
movl dwordVar, %eax     // Gas
mov eax, dwordVar        ; MASM

3.5.4 RIP 相对寻址模式

x86-64 CPU 在 64 位模式下不支持 32 位直接寻址模式。由于不希望在指令末尾添加一个 64 位常量(以支持整个 64 位地址空间),AMD 工程师选择创建一个 RIP 相对寻址模式,通过将一个带符号的 32 位常量(替代直接地址)加到 RIP(指令指针)寄存器中的值来计算有效的内存地址。这允许在当前指令周围的±2GB 范围内访问数据。^(3)

3.5.5 寄存器间接寻址模式

80x86 CPU 允许通过寄存器使用寄存器间接寻址模式间接访问内存。这些模式被称为间接,因为操作数不是实际地址;相反,它的值指定了要使用的内存地址。在寄存器间接寻址模式中,寄存器的值就是要访问的地址。例如,HLA 指令mov(eax,[ebx]);告诉 CPU 将 EAX 的值存储在 EBX 中保存的地址所指向的位置。

x86-64 CPU 在 64 位模式下还支持使用 64 位寄存器(例如 RAX、RBX、……、R15)进行寄存器间接寻址模式。寄存器间接寻址模式允许完全访问 64 位地址空间。例如,MASM 指令mov eax, [rbx]指示 CPU 从 RBX 中存储的地址加载 EAX 寄存器的值。

3.5.5.1 HLA 中的寄存器间接模式

在 80x86 上,这种寻址模式有八种形式。使用 HLA 语法,它们看起来是这样的:


			mov( [eax], al );
mov( [ebx], al );
mov( [ecx], al );
mov( [edx], al );
mov( [edi], al );
mov( [esi], al );
mov( [ebp], al );
mov( [esp], al );

这八种寻址模式通过寄存器中的偏移量引用内存位置,该寄存器用方括号括起来(分别为 EAX、EBX、ECX、EDX、EDI、ESI、EBP 或 ESP)。

注意

HLA 的寄存器间接寻址模式需要一个 32 位寄存器。使用间接寻址模式时,不能指定 16 位或 8 位寄存器。

3.5.5.2 MASM 中的寄存器间接模式

MASM 在 32 位模式下使用的寄存器间接寻址模式与 HLA 的语法完全相同(不过需要注意的是,MASM 会反转指令操作数;只有寻址模式语法是相同的)。在 64 位模式下,语法相同——即寄存器名称周围有一对方括号——不过这种模式使用的是 64 位寄存器,而不是 32 位寄存器。

以下是前面给出的指令的 MASM 等效实现:


			mov al, [eax]
mov al, [ebx]
mov al, [ecx]
mov al, [edx]
mov al, [edi]
mov al, [esi]
mov al, [ebp]
mov al, [esp]

以下是 MASM 64 位寄存器间接寻址模式的示例:


			mov al,   [rax]
mov ax,   [rbx]
mov eax,  [rcx]
mov rax,  [rdx]
mov r15b, [rdi]
mov r15w, [rsi]
mov r15d, [rbp]
mov r15,  [rsp]
mov al,   [r8]
mov ax,   [r9]
mov eax,  [r10]
mov rax,  [r11]
mov r15b, [r12]
mov r15w, [r13]
mov r15d, [r14]
mov r15,  [r15]
3.5.5.3 Gas 中的寄存器间接模式

Gas 使用括号而不是方括号来表示寄存器名称。以下是前述 32 位 HLA mov 指令的 Gas 变体:


			movb (%eax), %al
movb (%ebx), %al
movb (%ecx), %al
movb (%edx), %al
movb (%edi), %al
movb (%esi), %al
movb (%ebp), %al
movb (%esp), %al

以下是 Gas 的 64 位寄存器间接寻址变体:


			movb (%rax), %al
movb (%rbx), %al
movb (%rcx), %al
movb (%rdx), %al
movb (%rdi), %al
movb (%rsi), %al
movb (%rbp), %al
movb (%rsp), %al
movb (%r8),  %al
movb (%r9),  %al
movb (%r10), %al
movb (%r11), %al
movb (%r12), %al
movb (%r13), %al
movb (%r14), %al
movb (%r15), %al

3.5.6 索引寻址模式

有效地址是指令在所有地址计算完成后将访问的最终内存地址。索引寻址模式通过将变量的地址(也称为位移量偏移)与方括号内的 32 位或 64 位寄存器的值相加,来计算有效地址。它们的和提供了指令访问的内存地址。例如,如果 VarName 在内存中的地址为 $1100,而 EBX 寄存器包含值 8,那么 mov(VarName[ebx],al); 将从地址 $1108 加载字节到 AL 寄存器中(见图 3-6)。

在 x86-64 CPU 上,寻址模式使用其中一个 64 位寄存器。但是,值得注意的是,作为指令一部分编码的位移量仍然是 32 位的。因此,寄存器必须保存基地址,而位移量则提供相对于基地址的偏移(索引)。

Image

图 3-6:索引寻址模式

3.5.6.1 HLA 中的索引寻址模式

索引寻址模式使用以下 HLA 语法,其中 VarName 是你程序中某个静态变量的名称:


			mov( VarName[ eax ], al );
mov( VarName[ ebx ], al );
mov( VarName[ ecx ], al );
mov( VarName[ edx ], al );
mov( VarName[ edi ], al );
mov( VarName[ esi ], al );
mov( VarName[ ebp ], al );
mov( VarName[ esp ], al );
3.5.6.2 MASM 中的索引寻址模式

MASM 在 32 位模式下支持与 HLA 相同的语法,但它还允许多种变体的语法,用于指定索引寻址模式。以下是展示 MASM 支持的某些变体的等效格式:

varName[reg32]
[reg32 + varName]
[varName][reg32]
[varName + reg32]
[reg32][varName]
varName[reg32 + const]
[reg32 + varName + const]
[varName][reg32][const]
varName[const + reg32]
[const + reg32 + varName]
[const][reg32][varName]
varName[reg32 - const]
[reg32 + varName - const]
[varName][reg32][-const]

由于加法的交换律,MASM 还允许许多其他组合。它将括号内的两个并排项视为被 + 运算符分隔。

以下是 MASM 对前述 HLA 示例的等效实现:


			mov  al, VarName[ eax ]
mov  al, VarName[ ebx ]
mov  al, VarName[ ecx ]
mov  al, VarName[ edx ]
mov  al, VarName[ edi ]
mov  al, VarName[ esi ]
mov  al, VarName[ ebp ]
mov  al, VarName[ esp ]

在 64 位模式下,MASM 要求你为索引寻址模式指定 64 位寄存器名称。在 64 位模式下,寄存器保存内存中变量的基地址,而编码到指令中的位移量提供了从该基地址的偏移。这意味着你不能使用寄存器作为全局数组的索引(通常使用的是 RIP 相对寻址模式)。

以下是 64 位模式下有效的 MASM 索引寻址模式示例:


			mov  al, [ rax + SomeConstant ]
mov  al, [ rbx + SomeConstant ]
mov  al, [ rcx + SomeConstant ]
mov  al, [ rdx + SomeConstant ]
mov  al, [ rdi + SomeConstant ]
mov  al, [ rsi + SomeConstant ]
mov  al, [ rbp + SomeConstant ]
mov  al, [ rsp + SomeConstant ]
3.5.6.3 Gas 中的索引寻址模式

与寄存器间接寻址模式一样,Gas 使用括号而非方括号。以下是 Gas 的索引寻址模式语法:

varName(%reg32)
const(%reg32)
varName + const(%reg32)

以下是 Gas 对应于前面给出的 HLA 指令的等价形式:


			movb VarName( %eax ), al
movb VarName( %ebx ), al
movb VarName( %ecx ), al
movb VarName( %edx ), al
movb VarName( %edi ), al
movb VarName( %esi ), al
movb VarName( %ebp ), al
movb VarName( %esp ), al

在 64 位模式下,Gas 要求你为索引寻址模式指定 64 位寄存器名称。与 MASM 一样,适用相同的规则。

以下是 64 位模式下有效的 Gas 索引寻址模式示例:


			mov  %al, SomeConstant(%rax)
mov  %al, SomeConstant(%rbx)
mov  %al, SomeConstant(%rcx)
mov  %al, SomeConstant(%rdx)
mov  %al, SomeConstant(%rsi)
mov  %al, SomeConstant(%rdi)
mov  %al, SomeConstant(%rbp)
mov  %al, SomeConstant(%rsp)

3.5.7 缩放索引寻址模式

缩放索引寻址模式与索引寻址模式相似,但有两个不同点。缩放索引寻址模式使你能够:

  • 组合两个寄存器加上一个位移量

  • 将索引寄存器乘以 1、2、4 或 8 的(缩放)因子

要了解是什么使其成为可能,请考虑以下 HLA 示例:


			mov( eax, VarName[ ebx + esi*4 ] );

缩放索引寻址模式与索引寻址模式之间的主要区别是包含了 esi*4 这一部分。这个示例通过加上 ESI 乘以 4 的值来计算有效地址(见 图 3-7)。

图片

图 3-7:缩放索引寻址模式

在 64 位模式下,替换基址寄存器和索引寄存器为 64 位寄存器。

3.5.7.1 HLA 中的缩放索引寻址

HLA 的语法提供了几种不同的方法来指定缩放索引寻址模式。以下是各种语法形式:

VarName[ IndexReg[32]*scale ]
VarName[ IndexReg[32]*scale + displacement ]
VarName[ IndexReg[32]*scale - displacement ]

[ BaseReg[32] + IndexReg[32]*scale ]
[ BaseReg[32] + IndexReg[32]*scale + displacement ]
[ BaseReg[32] + IndexReg[32]*scale - displacement ]

VarName[ BaseReg[32] + IndexReg[32]*scale ]
VarName[ BaseReg[32] + IndexReg[32]*scale + displacement ]
VarName[ BaseReg[32] + IndexReg[32]*scale - displacement ]

在这些示例中,BaseReg[32] 表示任何通用的 32 位寄存器,IndexReg[32] 表示除 ESP 之外的任何通用 32 位寄存器,缩放因子必须是 1248 中的常量。VarName 表示一个静态变量名,位移量表示一个 32 位常量。

3.5.7.2 MASM 中的缩放索引寻址

MASM 支持与 HLA 相同的寻址模式语法,但有一些额外的形式,与索引寻址模式的呈现方式类似。这些形式只是基于 + 运算符的交换性而产生的语法变体。

MASM 还支持 64 位缩放索引寻址,它与 32 位模式具有相同的语法,只不过你需要使用 64 位寄存器名称。32 位和 64 位缩放索引寻址模式之间的主要区别在于,64 位 disp[reg*index] 寻址模式不存在。在 64 位寻址模式中,这是一个相对程序计数器的索引寻址模式,其中位移量是与当前指令指针值的 32 位偏移量。

3.5.7.3 Gas 中的缩放索引寻址

和往常一样,Gas 使用括号而非方括号来包围缩放索引操作数。Gas 还使用三操作数语法来指定 基址寄存器索引寄存器缩放因子,而不是其他汇编程序使用的算术表达式语法。Gas 缩放索引寻址模式的通用语法是:

expression( baseReg[32], indexReg[32], scaleFactor )

更具体地说:

VarName( ,IndexReg[32], scale )
VarName + displacement( ,IndexReg[32], scale )
VarName - displacement( ,IndexReg[32], scale )
( BaseReg[32], IndexReg[32], scale )
displacement( BaseReg[32], IndexReg[32], scale)

VarName( BaseReg[32], IndexReg[32], scale )
VarName + displacement( BaseReg[32], IndexReg[32], scale )
VarName - displacement( BaseReg[32], IndexReg[32], scale )

其中缩放因子是 1248 中的一个值。

Gas 还支持 64 位缩放索引寻址。它使用与 32 位模式相同的语法,只是将 64 位寄存器名称交换进来。在使用 64 位寻址时,不能同时指定 RIP 相关的变量名(例如这些示例中的 VarName);只能使用 32 位位移量。

3.6 在汇编语言中声明数据

80x86 架构提供的仅有少数几种低级机器数据类型,供各个机器指令操作:

byte 用于存储任意 8 位值。

word 用于存储任意 16 位值。

dword “双字”;用于存储任意 32 位值。

qword “四字双字”;用于存储任意 64 位值。

real32 (也叫 real4 用于存储 32 位单精度浮点值。

real64 (也叫 real8 用于存储 64 位双精度浮点值。

注意

80x86 汇编器通常支持 tbyte(“十字节”)和 real80/real10 数据类型,但我们在这里不讨论这些类型,因为大多数现代(64 位)高级语言编译器不使用它们。(然而,某些 C/C++编译器使用 long double 数据类型支持 real80 值;Swift 在 Intel 机器上也通过 float80 类型支持 real80 值。)

3.6.1 在 HLA 中声明数据

HLA 汇编器忠于其高级语言的特性,提供了多种单字节数据类型,包括字符、带符号整数、无符号整数、布尔值和枚举类型。如果你用汇编语言编写应用程序,拥有所有这些不同的数据类型(以及 HLA 提供的类型检查)将非常有用。然而,对于我们的目的,我们可以简单地为字节变量分配存储空间,并为更大的数据结构预留一块字节空间。对于 8 位和数组对象,HLA 的byte类型就足够了。

你可以在 HLA 的static部分声明byte对象,方法如下:


			static
    variableName : byte;

要为一块字节分配存储空间,可以使用以下 HLA 语法:


			static
    blockOfBytes : byte[ sizeOfBlock ];

这些 HLA 声明创建了未初始化的变量。从技术上讲,HLA 始终将static对象初始化为0,因此它们并不真正是未初始化的,但关键点是这段代码没有显式地为这些字节对象赋初值。不过,你可以告诉 HLA 在操作系统将程序加载到内存时,使用如下语句来初始化你的字节变量:


			static
    // InitializedByte has the initial value 5:

    InitializedByte : byte := 5;

    // InitializedArray is initialized with 0, 1, 2, and 3;

    InitializedArray : byte[4] := [0,1,2,3];

3.6.2 MASM 中的数据声明

在 MASM 中,通常会在.data节中使用dbbyte指令来为字节对象或字节对象数组预留存储空间。单个声明的语法会采取以下等效形式:

variableName    db      ?
variableName    byte    ?

上述声明创建了未初始化的对象(实际上与 HLA 一样,初始化为0)。db/byte指令的操作数字段中的?告诉汇编器你不想显式地为声明附加值。

要声明一个字节块变量,可以使用如下语法:

variableName    db      sizeOfBlock dup (?)
variableName    byte    sizeOfBlock dup (?)

要创建一个初始值不为零的对象,你可以使用如下语法:


			                        .data
InitializedByte         db      5
InitializedByte2        byte    6
InitializedArray0       db      4 dup (5)   ; array is 5,5,5,5
InitializedArray1       db      5 dup (6)   ; array is 6,6,6,6,6

要创建一个初始化的字节数组,其值并不完全相同,你只需在 MASM 的db/byte指令的操作数字段中指定一个以逗号分隔的值列表:


			                    .data
InitializedArray2   byte    0,1,2,3
InitializedArray3   byte    4,5,6,7,8

3.6.3 Gas 中的数据声明

Gas 在.data节中使用.byte指令声明字节变量。该指令的通用形式是:

variableName: .byte 0

Gas 没有提供显式格式来创建未初始化的变量;相反,你只需为未初始化的变量提供一个0操作数。以下是两个 Gas 中的字节变量声明示例:


			InitializedByte: .byte   5
ZeroedByte       .byte   0  // Zeroed value

Gas 没有提供一个显式的指令来声明字节对象数组,但你可以使用.rept/.endr指令创建多个.byte指令的副本,如下所示:

variableName:
        .rept   sizeOfBlock
        .byte   0
        .endr

注意

如果你想使用不同的值初始化数组,你也可以提供一个以逗号分隔的值列表。

这里是一些 Gas 中数组声明的示例:


			            .section    .data
InitializedArray0:      // Creates an array with elements 5,5,5,5
            .rept       4
            .byte       5
            .endr

InitializedArray1:
            .byte       0,1,2,3,4,5
3.6.3.1 在汇编语言中访问字节变量

在访问字节变量时,你只需在 80x86 寻址模式之一中使用变量声明的名称。例如,给定一个名为byteVar的字节对象和一个名为byteArray的字节数组,你可以使用以下任一指令,通过mov指令将该变量加载到 AL 寄存器中(这些示例假设是 32 位代码):


			// HLA's mov instruction uses "src, dest" syntax:

mov( byteVar, al );
mov( byteArray[ebx], al ); // EBX is the index into byteArray

// Gas's movb instruction also uses a "src, dest" syntax:

movb byteVar, %al
movb byteArray(%ebx), %al

; MASM's mov instructions use "dest, src" syntax

mov al, byteVar
mov al, byteArray[ebx]

对于 16 位对象,HLA 使用word数据类型,MASM 使用dwword指令,Gas 使用.int指令。除了这些指令声明的对象大小外,它们的使用方式与字节声明完全相同。例如:


			// HLA example:

static

    // HLAwordVar: 2 bytes, initialized with 0s:

    HLAwordVar : word;

    // HLAwordArray: 8 bytes, initialized with 0s:

    HLAwordArray : word[4];

    // HLAwordArray2: 10 bytes, initialized with 0, ..., 5:

    HLAwordArray2 : word[5] := [0,1,2,3,4];

; MASM example:

                    .data
MASMwordVar         word    ?
MASMwordArray       word    4 dup (?)
MASMwordArray2      word    0,1,2,3,4

// Gas example:

                    .section    .data
GasWordVar:         .int    0
GasWordArray:
                    .rept   4
                    .int    0
                    .endr

GasWordArray2:      .int    0,1,2,3,4

对于 32 位对象,HLA 使用dword数据类型,MASM 使用dddword指令,Gas 使用.long指令。例如:


			// HLA example:

static
    // HLAdwordVar: 4 bytes, initialized with 0s:

    HLAdwordVar : dword;

    // HLAdwordArray: 16 bytes, initialized with 0s.

    HLAdwordArray : dword[4];

    // HLAdwordArray: 20 bytes, initialized with 0, ..., 4:

    HLAdwordArray2 : dword[5] := [0,1,2,3,4];

; MASM/TASM example:

                    .data
MASMdwordVar        dword   ?
MASMdwordArray      dword   4 dup (?)
MASMdwordArray2     dword   0,1,2,3,4

// Gas example:

                    .section    .data
GasDWordVar:        .long   0
GasDWordArray:
                    .rept   4
                    .long   0
                    .endr

GasDWordArray2:     .long   0,1,2,3,4

3.7 在汇编语言中指定操作数大小

80x86 汇编器使用两种机制来指定它们的操作数大小:

  • 操作数通过类型检查指定大小(大多数汇编器都会这样做)。

  • 指令本身指定了大小(Gas 这样做)。

例如,考虑以下三个 HLA 的mov指令:


			mov( 0, al );
mov( 0, ax );
mov( 0, eax );

在每种情况下,寄存器操作数指定mov指令将数据复制到该寄存器时的数据大小。MASM 使用类似的语法(尽管操作数顺序相反):


			mov al,  0 ; 8-bit data movement
mov ax,  0 ; 16-bit data movement
mov eax, 0 ; 32-bit data movement

这里需要记住的是,指令助记符(mov)在所有六种情况下都是完全相同的。是操作数,而不是指令助记符,指定了数据传输的大小。

注意

现代版本的 Gas 也允许你通过操作数(寄存器)大小来指定操作的大小,而不使用如 b 或 w 的后缀。然而,本书将继续使用类似movbmovw的助记符,以避免与旧版本 Gas 产生混淆。有关“Gas 中的类型强制”请参见第 45 页。

3.7.1 HLA 中的类型强制

关于指定操作数大小的前一种方法有一个问题。考虑以下 HLA 示例:


			mov( 0, [ebx] );  // Copy 0 to the memory location
                  // pointed at by EBX.

该指令不明确。EBX 所指向的内存位置可能是一个字节、一个字或一个双字。指令中没有任何内容可以告诉汇编器操作数的大小。当遇到这样的指令时,汇编器会报告错误,你必须显式地告诉它内存操作数的大小。在 HLA 的情况下,可以通过如下类型强制操作符来完成:

mov( 0, (type word [ebx]) );  // 16-bit data movement.

通常,你可以使用以下 HLA 语法将任何内存操作数强制为适当的大小:


			(type new_type memory)

其中,new_type 代表数据类型(如 byteworddword),memory 代表你希望覆盖类型的内存地址。

3.7.2 MASM 中的类型强制

MASM 也面临同样的问题。你需要使用像以下这样的强制操作符来强制内存位置的类型:

mov  word ptr [ebx], 0   ; 16-bit data movement.

当然,你可以在这两个例子中将 bytedword 替换为 word,以将内存位置强制为字节或双字大小。

3.7.3 Gas 中的类型强制

Gas 不需要类型强制操作符,因为它采用不同的技术来指定操作数的大小。与使用单一助记符 mov 不同,Gas 使用四个助记符,其中包括 mov 和一个单字符后缀,后缀表示大小:

movb 复制一个 8 位(byte)值

movw 复制一个 16 位(word)值

movl 复制一个 32 位(long)值

movq 复制一个 64 位(long long)值

使用这些助记符时,永远不会存在任何歧义,即使它们的操作数没有明确的大小。例如:


			movb $0, (%ebx) // 8-bit data copy
movw $0, (%ebx) // 16-bit data copy
movl $0, (%ebx) // 32-bit data copy
movq $0, (%rbx) // 64-bit data copy

通过这些基本信息,你现在应该能够理解来自典型编译器的输出。

3.8 更多信息

Bartlett, Jonathan. 从零开始学编程。由 Dominick Bruno Jr. 编辑。自出版,2004 年。该书的一个较旧且免费的版本,使用 Gas 教授汇编语言编程,可以在网上找到,链接为 www.plantation-productions.com/AssemblyLanguage/ProgrammingGroundUp-1-0-booksize.pdf

Blum, Richard. 专业汇编语言。印第安纳波利斯:Wiley,2005 年。

Duntemann, Jeff. 从基础开始学汇编语言。第 3 版。印第安纳波利斯:Wiley,2009 年。

Hyde, Randall. 汇编语言的艺术。第 2 版。旧金山:No Starch Press,2010 年。

Intel. “Intel 64 和 IA-32 架构软件开发者手册。”更新于 2019 年 11 月 11 日。 software.intel.com/en-us/articles/intel-sdm/

第四章:编译器操作与代码生成

image

为了编写生成高效机器代码的高级语言代码,首先必须理解编译器和链接器如何将高级源语句翻译成可执行的机器代码。完整的编译器理论涵盖超出了本书的范围;然而,本章将解释翻译过程的基础知识,帮助你理解并在高级语言编译器的局限性内工作。我们将涵盖以下主题:

  • 编程语言使用的不同类型的输入文件

  • 编译器与解释器的区别

  • 典型编译器如何处理源文件以生成可执行程序

  • 优化过程以及为什么编译器无法为给定的源文件生成最佳代码

  • 编译器生成的不同类型的输出文件

  • 常见的目标文件格式,如 COFF 和 ELF

  • 影响编译器生成的可执行文件大小和效率的内存组织和对齐问题

  • 链接器选项如何影响代码的效率

本材料为后续各章奠定了基础,对于帮助编译器生成最佳代码至关重要。我们将从编程语言使用的文件格式开始讨论。

4.1 编程语言使用的文件类型

一个典型的程序可以有多种形式。源文件是程序员创建并提供给语言翻译器(如编译器)的可读文本形式。典型的编译器将源文件或多个源文件翻译成目标代码文件。链接程序将多个独立的目标模块合并,生成可重定位或可执行文件。最后,加载程序(通常是操作系统)将可执行文件加载到内存中,并在执行之前对目标代码进行最终修改。请注意,修改是针对现在在内存中的目标代码进行的;磁盘上的实际文件不会被修改。这些并不是语言处理系统操作的唯一文件类型,但它们是典型的。要充分理解编译器的局限性,了解语言处理器如何处理这些文件类型是非常重要的。我们首先来看源文件。

4.2 源文件

传统上,源文件包含程序员使用文本编辑器创建的纯 ASCII 或 Unicode 文本(或其他字符集)。使用纯文本文件的一个优点是,程序员可以使用任何处理文本文件的程序来操作源文件。例如,一个计算任意文本文件中行数的程序也会计算程序中的源行数。由于有成百上千个小型过滤程序可以操作文本文件,因此以纯文本格式维护源文件是一种不错的方法。这种格式有时称为纯原生文本

4.2.1 标记化源文件

一些语言处理系统(尤其是解释器)将源文件以标记化形式存储。标记化源文件通常使用特殊的单字节标记值来压缩源语言中的保留字和其他词法元素,因此它们通常比文本源文件小。此外,处理标记化代码的解释器通常比处理纯文本的解释器要快一个数量级,因为处理单字节标记的字符串比识别保留字字符串要高效得多。

通常,解释器的标记化文件由一系列字节组成,这些字节直接映射到源文件中的字符串,如ifprint。因此,通过使用一个字符串表和一些额外的逻辑,你可以解码一个标记化的程序,恢复出原始的源代码。(通常,你会失去插入到源文件中的额外空白,但这几乎是唯一的区别。)许多早期 PC 系统上的 BASIC 解释器都是这样工作的。你在解释器中输入一行 BASIC 源代码,解释器会立即将该行进行标记化,并将标记化后的形式存储在内存中。稍后,当你执行LIST命令时,解释器会去标记化内存中的源代码,生成源代码列表。

另一方面,标记化的源文件通常使用专有格式。这意味着它们不能利用像wc(单词计数)、entabdetab(分别用来计算文本文件中的行数、单词数和字符数;用制表符替换空格;用空格替换制表符)这样的通用文本处理工具。

为了克服这一限制,大多数使用标记化文件的语言都允许你去标记化源文件,生成标准的文本文件。(它们也允许你根据输入文本文件重新标记化源文件。)然后,你将生成的文本文件通过某个过滤程序处理,并重新标记化过滤程序的输出,以生成一个新的标记化源文件。虽然这需要相当大的工作量,但它允许与标记化文件一起工作的语言翻译器利用各种基于文本的实用程序。

4.2.2 专用源文件

一些编程语言,例如 Embarcadero 的 Delphi 和 Free Pascal 的类似 Lazarus 程序,根本不使用传统的基于文本的文件格式。相反,它们通常使用图形元素,如流程图和表单,来表示程序要执行的指令。其他例子包括 Scratch 编程语言,它允许你使用位图显示上的图形元素编写简单程序,以及 Microsoft Visual Studio 和 Apple Xcode 集成开发环境(IDE),这两个 IDE 都允许你使用图形操作来指定屏幕布局,而不是基于文本的源文件。

4.3 计算机语言处理器的类型

计算机语言处理系统通常分为四类:纯解释器、解释器、编译器和增量编译器。这些系统在处理源程序和执行结果的方式上有所不同,进而影响它们各自的效率。

4.3.1 纯解释器

纯解释器直接作用于文本源文件,通常效率非常低。它们不断扫描源文件(通常是 ASCII 文本文件),将其处理为字符串数据。识别词素(如保留字、字面常量等语言成分)是一个耗时的过程。实际上,许多纯解释器花费更多的时间来处理词素(即执行词法分析)而不是执行程序。因为词素的实际即时执行只需要比词法分析稍多的努力,纯解释器通常是计算机语言处理程序中最小的。这也是为什么当需要一个非常紧凑的语言处理器时,纯解释器非常流行。它们也广泛用于脚本语言和允许在程序执行期间将语言源代码作为字符串数据操作的高级语言。

4.3.2 解释器

解释器在运行时执行程序源文件的某种表示形式。这个表示形式不一定是人类可读的文本文件。如前一节所述,许多解释器处理的是标记化的源文件,以避免在执行过程中进行词法分析。一些解释器会将文本源文件作为输入,并在执行前将输入文件转换为标记化格式。这使得程序员可以在他们喜欢的编辑器中使用文本文件,同时享受标记化格式带来的快速执行。唯一的成本是标记化源文件的初始延迟(在大多数现代机器上这一延迟几乎不被察觉),以及可能无法执行包含程序语句的字符串这一事实。

4.3.3 编译器

编译器将文本形式的源程序转换为可执行的机器代码。这是一个复杂的过程,特别是在优化编译器中。关于编译器生成的代码,有几点需要注意。首先,编译器生成的是底层 CPU 可以直接执行的机器指令。因此,CPU 在执行程序时无需解码源文件,所有的 CPU 资源都用于执行机器代码。因此,生成的程序通常比解释执行的版本运行得要快得多。当然,一些编译器在将高级语言源代码转换为机器代码时比其他编译器做得更好,但即使是低质量的编译器也比大多数解释器做得要好。

编译器从源代码到机器代码的翻译是一个单向函数。与解释器不同,如果只给出程序的机器代码输出,通常很难,甚至不可能,重建原始源文件。

4.3.4 增量编译器

增量编译器 是编译器和解释器之间的结合体。增量编译器有许多不同的类型,但通常,它们像解释器一样工作,即不直接将源文件编译成机器代码;而是将源代码翻译成一种中间形式。然而,与解释器的标记化代码不同,这种中间形式通常与原始源文件没有强相关性。中间形式通常是虚拟机语言的机器代码——“虚拟”是因为没有实际的 CPU 可以执行这些代码。然而,编写一个可以执行它的解释器是很容易的。因为虚拟机(VM)的解释器通常比标记化代码的解释器高效得多,所以执行 VM 代码通常比执行解释器中的标记列表要快。像 Java 这样的语言采用这种编译技术,并结合Java 字节码引擎(一种解释程序),来解释执行 Java 的“机器代码”(参见图 4-1)。虚拟机执行的最大优势是 VM 代码是可移植的;也就是说,运行在虚拟机上的程序可以在任何有解释器的地方执行。相比之下,真正的机器代码只能在为其编写的 CPU(系列)上执行。通常,解释执行的 VM 代码比解释执行的标记化代码快约 2 到 10 倍,而纯机器代码比解释执行的 VM 代码快约 2 到 10 倍。

Image

图 4-1:JBC 解释器

为了提高通过增量编译器编译的程序的性能,许多供应商(特别是 Java 系统供应商)采用了一种称为即时编译(JIT)的技术。这个概念基于这样的事实:解释所花费的时间大部分被运行时获取和解码 VM 代码所消耗。随着程序的执行,这种解释会反复发生。JIT 编译在首次遇到 VM 指令时将其翻译成实际的机器代码。这样可以避免每次遇到相同的语句(例如在循环中)时都重复解释过程。尽管 JIT 编译远不如真正的编译器,但通常可以将程序的性能提高 2 到 5 倍。

注意

较旧的编译器和一些免费提供的编译器将源代码编译成汇编语言,然后由另一个被称为汇编器的编译器将该输出汇编成所需的机器代码。大多数现代且高效的编译器完全跳过这一步骤。有关此主题的更多信息,请参见 第 67 页的“编译器输出”。

在刚才描述的四种计算机语言处理器类别中,本章将重点讨论编译器。通过理解编译器如何生成机器代码,你可以选择合适的高级语言(HLL)语句来生成更好、更高效的机器代码。如果你想提高用解释器或增量编译器编写的程序的性能,最好的方法是使用优化编译器来处理你的应用程序。例如,GNU 提供了一个 Java 编译器,它生成优化后的机器代码,而不是解释执行的 Java 字节码(JBC);生成的可执行文件运行速度比解释执行的 JBC 或 JIT 编译的字节码快得多。

4.4 翻译过程

典型的编译器被分解成多个逻辑组件,称为阶段。尽管不同编译器之间这些阶段的确切数量和名称可能会有所不同,但最常见的五个阶段是词法分析语法分析中间代码生成本地代码生成,以及对于支持优化的编译器,优化

图 4-2 展示了编译器如何逻辑性地安排这些阶段,将高级语言(HLL)源代码翻译成机器(目标)代码。

Image

图 4-2:编译阶段

虽然 图 4-2 建议编译器按顺序执行这些阶段,但大多数编译器并非如此。相反,这些阶段通常并行执行,每个阶段完成一小部分工作,将输出传递给下一个阶段,然后等待前一个阶段的输入。在典型的编译器中,解析器(语法分析阶段)可能是最接近主程序或主进程的部分。解析器通常主导整个编译过程,它调用扫描器(词法分析阶段)获取输入,并调用中间代码生成器处理其输出。中间代码生成器可能(可选)调用优化器,然后调用本地代码生成器。本地代码生成器也可能(可选)调用优化器。来自本地代码生成阶段的输出是可执行代码。在本地代码生成器/优化器生成一些代码后,执行将返回到中间代码生成器,然后到解析器,解析器请求扫描器提供更多输入,整个过程重新开始。

注意

其他编译器的组织形式是可能的。例如,一些编译器允许用户选择是否运行优化阶段,而其他编译器则根本没有优化阶段。类似地,一些编译器省略了中间代码生成,直接调用本地代码生成器。一些编译器还包括其他阶段,处理不同时间编译的目标模块。

因此,尽管图 4-2 未能准确描绘典型(并行)执行路径,但它展示的数据流是正确的。扫描器读取源文件,将其转换为另一种形式,然后将这些转换后的数据传递给解析器。解析器接受来自扫描器的输入,将其转换为另一种形式,然后将新的数据传递给中间代码生成器。同样,其他阶段也从前一个阶段读取输入,将输入转换为(可能的)不同形式,然后将该输入传递给下一个阶段。编译器将其最后一个阶段的输出写入可执行对象文件。

让我们更仔细地看一下代码翻译过程的每个阶段。

4.4.1 扫描(词法分析)

扫描器(也叫词法分析器词法分析器)负责读取源文件中的字符/字符串数据,并将这些数据分解为表示源文件中词法项或词素的标记。如前所述,词素是我们在源文件中会识别为语言的原子组件的字符序列。例如,C 语言的扫描器会将ifwhile这样的子字符串识别为 C 语言保留字。然而,扫描器不会将标识符ifReady中的if提取出来并视为保留字。相反,扫描器会考虑保留字使用的上下文,以便区分保留字和标识符。对于每个词素,扫描器会创建一个小的数据包——标记——并将其传递给解析器。标记通常包含几个值:

  • 一个小整数,用于唯一标识符的类别(无论是保留字、标识符、整数常量、运算符还是字符字符串字面量)

  • 区分同一类别中标记的另一个值(例如,这个值将指示扫描器已经处理的保留字)

  • 扫描器可能与词素关联的任何其他属性

注意

不要将此对标记的引用与前面讨论的解释器中的压缩式标记混淆。在此上下文中,标记仅仅是一个可变大小的数据结构,包含与词素相关的信息供解释器/编译器使用。

当扫描器在源文件中看到字符字符串 12345 时,例如,它可能将标记的类识别为字面常量,标记的第二个值为整数类型常量,标记的属性为字符串的数字等价物(即一万二千三百四十五)。图 4-3 演示了这个标记在内存中的表现。

Image

图 4-3:词法单元“12345”的标记

该标记的枚举值为 345(表示整数常量),标记类的值为 5(表示字面常量),标记的属性值为 12345(词法单元的数字形式),词法单元字符串为扫描器返回的 "12345"。编译器中的不同代码序列可以根据需要引用此标记数据结构。

严格来说,词法分析阶段是可选的。语法分析器可以直接处理源文件。然而,标记化使得编译过程更加高效,因为它允许语法分析器将标记视为整数值而不是字符串数据。由于大多数 CPU 可以比处理字符串数据更高效地处理小的整数值,而且因为语法分析器在处理过程中必须多次引用标记数据,词法分析在编译期间节省了大量时间。通常,纯解释器是唯一在语法分析时重新扫描每个标记的语言处理器,这也是它们速度如此慢的一个主要原因(与例如将源文件以标记化形式存储的解释器相比,以避免不断处理纯文本源文件)。

4.4.2 语法分析(语法分析)

语法分析器是编译器的一部分,负责检查源程序在语法(和语义)上的正确性。如果源文件中有错误,通常是语法分析器发现并报告该错误。语法分析器还负责将标记流(即源代码)重新组织成一个更复杂的数据结构,以表示程序的意义或语义。扫描器和语法分析器通常按顺序从源文件的开始到结束处理源文件,编译器通常只读取源文件一次。然而,后续阶段需要以更临时的方式引用源程序的主体。通过构建源代码的数据结构表示(通常称为抽象语法树,或AST),语法分析器使得代码生成和优化阶段能够轻松地引用程序的不同部分。

图 4-4 显示了编译器如何使用 AST 中的三个节点来表示表达式 12345+643 是加法运算符的值,7 是表示算术运算符的子类)。

Image

图 4-4:抽象语法树的一部分

4.4.3 中间代码生成

中间代码生成阶段负责将源文件的 AST 表示转换为一种准机器代码形式。编译器通常将程序翻译成中间形式而不是直接转换为本地机器代码,有两个原因。

首先,编译器的优化阶段可以在这种中间形式上更轻松地进行某些类型的优化,如公共子表达式消除。

其次,许多编译器,称为交叉编译器,为多种不同的 CPU 生成可执行的机器代码。通过将代码生成阶段分为两部分——中间代码生成器和本地代码生成器——编译器开发者可以将所有与 CPU 无关的活动放入中间代码生成阶段,并且只需要编写一次此代码。这简化了本地代码生成阶段。也就是说,由于编译器只需要一个中间代码生成阶段,但可能需要为每个编译器支持的 CPU 分别进行本地代码生成阶段,将尽可能多的与 CPU 无关的代码放入中间代码生成器将减少本地代码生成器的体积。出于同样的原因,优化阶段通常也被分为两个部分(参见图 4-2):一个与 CPU 无关的部分(位于中间代码生成器之后)和一个与 CPU 相关的部分。

一些语言系统,如微软的 VB.NET 和 C#,实际上将中间代码作为编译器的输出(在.NET 系统中,微软称这种代码为公共中间语言,或CIL)。本地代码生成和优化实际上是由微软的公共语言运行时(CLR)系统处理的,该系统对.NET 编译器生成的 CIL 代码进行即时编译(JIT)。

4.4.4 优化

优化阶段,在中间代码生成之后,将中间代码转换为更高效的形式。这通常涉及从 AST 中消除不必要的条目。例如,编译器的优化器可能会将以下中间代码转换为:


			move the constant 5 into the variable i
move a copy of i into j
move a copy of j into k
add k to m

转换成如下形式:


			move the constant 5 into k
add k to m

如果没有更多对ij的引用,优化器可以消除对它们的所有引用。事实上,如果k再也没有被使用,优化器可以将这两条指令替换为单条指令add 5 to m。请注意,这种类型的转换几乎在所有 CPU 上都是有效的。因此,这种类型的转换/优化非常适合第一阶段的优化。

4.4.4.1 优化的问题

将中间代码“转换为更高效的形式”不是一个明确的过程——是什么使得某种程序形式比另一种更高效呢?效率的主要定义是程序尽量减少某种系统资源的使用,通常是内存(空间)或 CPU 周期(速度)。编译器的优化器可能会管理其他资源,但空间和速度是程序员最关心的主要因素。但是,即使我们仅仅考虑这两方面的优化,描述“最优”结果也是困难的。问题在于,优化一个目标(比如更好的性能)可能会与另一个优化目标(比如减少内存使用)发生冲突。因此,优化过程通常是一个妥协的过程,你需要进行权衡,牺牲某些子目标(例如,让某些代码部分运行得稍慢一些),以便生成一个合理的结果(比如生成一个不消耗太多内存的程序)。

4.4.4.2 优化对编译时间的影响

你可能认为可以设定一个单一的目标(例如,最高的性能),并严格地为此进行优化。然而,编译器还必须能够在合理的时间内生成可执行的结果。优化过程是复杂性理论所称的NP 完全问题的一个例子。这些问题是,至少就目前所知,是无法解决的;也就是说,你不能在不先计算所有可能性并从中选择的情况下,产生一个保证正确的结果(例如,一个程序的最优版本)。不幸的是,解决一个 NP 完全问题所需的时间通常会随着输入大小的增加呈指数级增长,在编译器优化的情况下,这意味着大致是源代码的行数。

这意味着在最坏的情况下,生成一个真正最优的程序可能需要比它所值的时间还要长。增加一行源代码可能会大约翻倍编译和优化代码所需的时间。增加两行可能会四倍增加所需的时间。实际上,一个现代应用程序的完整保证优化可能需要的时间,甚至可能超过已知宇宙的生命周期。

除了最小的源文件(几十行)之外,一个完美的优化器将花费过长的时间,无法在实际应用中发挥任何价值(事实上,这样的优化器已经被编写出来了;可以在线搜索“超优化器”以了解更多细节)。因此,编译器优化器很少能生成一个真正最优的程序。它们只会在用户愿意为该过程分配的有限 CPU 时间内,生成它们能做到的最佳结果。

注意

依赖即时编译(JIT)的语言(如 Java、C#和 VB.Net)将部分优化阶段推迟到运行时。因此,优化器的性能直接影响到应用程序的运行时。由于 JIT 编译器系统与应用程序同时运行,它无法花费大量时间进行代码优化,而不对运行时产生巨大影响。这就是为什么像 Java 和 C#这样的语言,即使最终编译成低级机器代码,也往往无法像传统语言(如 C/C++和 Pascal)编译的高度优化代码那样表现得更好。

现代优化器不会尝试所有可能性并选择最佳结果,而是使用启发式方法和基于案例的算法来确定它们将应用于所生成机器代码的转换。你需要了解这些技术,这样你才能以一种允许优化器轻松处理并生成更好机器代码的方式编写你的高级语言代码。

4.4.4.3 基本块、可化简代码与优化

如果你想更高效地帮助优化器完成工作,理解编译器如何组织中间代码(以便在后续阶段输出更好的机器代码)是非常重要的。随着控制流在程序中流动,优化器通过一种被称为数据流分析(DFA)的过程来追踪变量值。在仔细进行数据流分析后,编译器可以确定变量何时未初始化、变量何时包含某些值、程序何时不再使用该变量,以及(同样重要的是)编译器何时根本不知道变量的值。例如,考虑以下 Pascal 代码:


			    path := 5;
    if( i = 2 ) then begin

        writeln( 'Path = ', path );

    end;
    i := path + 1;
    if( i < 20 ) then begin

        path := path + 1;
        i := 0;

    end;

一个好的优化器会将这段代码替换为如下内容:


			    if( i = 2 ) then begin

        (* Because the compiler knows that path = 5 *)

        writeln( 'path = ', 5 );

    end;
    i := 0;     (* Because the compiler knows that path < 20 *)
    path := 6;  (* Because the compiler knows that path < 20 *)

事实上,编译器可能根本不会为最后两条语句生成代码;相反,它会在后续引用中将i替换为值0,将path替换为值6。如果这让你觉得很了不起,请注意,某些编译器甚至可以通过嵌套函数调用和复杂表达式跟踪常量赋值和表达式。

尽管编译器如何分析数据流的完整描述超出了本书的范围,但你应该对这一过程有基本的理解,因为写得不够规范的程序可能会妨碍编译器的优化能力。优秀的代码与编译器是协同工作的,而不是与之对抗的。

一些编译器在优化高级代码方面能做出一些非常惊人的事情。然而,优化本身是一个固有的慢过程。正如前面所提到的,这是一个无法解决的问题。幸运的是,大多数程序并不需要完全优化。即使程序运行略慢于最佳程序,当与不可解决的编译时间相比时,一个好的近似是一个可以接受的折中方案。

编译器在优化过程中对编译时间的主要妥协是,它们仅在一定数量的可能改进之后才会继续处理代码的某个部分。因此,如果你的编程风格让编译器感到困惑,它可能无法生成一个优化的(甚至接近优化的)可执行文件,因为它需要考虑的可能性太多。诀窍是学习编译器如何优化源文件,这样你就能适应它们。

为了分析数据流,编译器将源代码划分为称为基本块的序列——进入和离开基本块的机器指令之间没有分支,除了在开始和结束时。例如,考虑以下 C 代码:


			    x = 2;              // Basic block 1
    j = 5;
    i = f( &x, j );     // End of basic block 1
    j = i * 2 + j;      // Basic block 2
    if( j < 10 )        // End of basic block 2
    {
        j = 0;          // Basic block 3
        i = i + 10;
        x = x + i;      // End of basic block 3
    }
    else
    {
        temp = i;       // Basic block 4
        i = j;
        j = j + x;
        x = temp;       // End of basic block 4
    }
    x = x * 2;          // Basic block 5
    ++i;
    --j;

    printf( "i=%d, j=%d, x=%d\n", i, j, x ); // End basic block 5

    // Basic block 6 begins here

这段代码包含五个基本块。基本块 1 从源代码的开始处开始。一个基本块在存在跳转进入或离开指令序列的地方结束。基本块 1 在调用f()函数时结束。基本块 2 从调用f()函数后的语句开始,然后在if语句的开头结束,因为if语句可以将控制转移到两个不同的地方。else子句终止了基本块 3,并且标志着基本块 4 的开始,因为从ifthen子句存在一个跳转到else子句后面第一个语句。基本块 4 的结束不是因为代码将控制转移到别处,而是因为从基本块 2 到基本块 5 开始的第一条语句有一个跳转(来自ifthen部分)。基本块 5 在调用 C 语言的printf()函数时结束。

确定基本块开始和结束位置的最简单方法是考虑编译器将生成的汇编代码。每当出现条件分支/跳转、无条件跳转或调用指令时,一个基本块就会结束。然而,请注意,基本块包括转移控制到新位置的指令。新的基本块将在转移控制到新位置的指令后立即开始。还需要注意的是,任何条件分支、无条件跳转或调用指令的目标标签开始一个基本块。

基本块使得编译器能够轻松跟踪变量和其他程序对象的变化。当编译器处理每条语句时,它可以(象征性地)跟踪一个变量将持有的值,这些值是基于其初始值以及在基本块内的计算得出的。

当两个基本块的路径汇聚成一个单一的代码流时,会出现问题。例如,在当前示例中的基本块 3 的末尾,编译器可以轻松确定变量j的值为零,因为基本块中的代码将0赋值给j,并且之后没有对j进行其他赋值。同样,在基本块 3 的末尾,程序知道j的值为j0 + x0(假设j0表示j进入基本块时的初始值,x0表示x进入基本块时的初始值)。但是,当路径在基本块 4 的开始汇合时,编译器可能无法确定j的值是零还是j0 + x0。这意味着编译器必须注意到此时j的值可能是两个不同的值之一。

虽然追踪一个变量在给定点可能包含的两个值对一个合适的优化器来说不算困难,但不难想象,编译器需要追踪许多不同可能值的情况。事实上,如果你有几个if语句代码按顺序执行,并且每个路径都修改了某个变量的值,那么每个变量的可能值数量会随着每个if语句的增加而翻倍。换句话说,随着代码序列中if语句的增多,可能性数目呈指数增长。到某个时候,编译器无法跟踪一个变量可能包含的所有值,因此必须停止对该变量的信息监控。发生这种情况时,编译器能考虑的优化可能性就会减少。

幸运的是,尽管循环、条件语句、switch/case语句和过程/函数调用可能会使代码的路径数呈指数级增长,但实际上编译器在处理典型的良好编写的程序时很少遇到问题。这是因为随着基本块路径的汇合,程序通常会对其变量进行新的赋值(从而消除编译器所追踪的旧值)。编译器通常假设程序在每个不同的路径上不会为变量赋予不同的值,并且它们的内部数据结构反映了这一点。请记住,如果违反了这个假设,编译器可能会失去对变量值的跟踪,从而生成较差的代码。

结构不良的程序会产生控制流路径,使编译器感到困惑,从而减少优化的机会。良好的程序会生成可简化的控制流图,即控制流路径的图示。图 4-5 是之前代码片段的控制流图。

Image

图 4-5:一个示例控制流图

如你所见,箭头将每个基本块的结尾与它们控制流转移的下一个基本块的开头连接起来。在这个特定的例子中,所有的箭头都指向下方,但并非总是如此。例如,循环会在流图中将控制转移回头。再举个例子,考虑以下 Pascal 代码:


			    write( 'Input a value for i:' );
    readln( i );
    j := 0;
    while( ( j < i ) and ( i > 0 ) ) do begin

        a[j] := i;
        b[i] := 0;
        j := j + 1;
        i := i - 1;
    end; (* while *)
    k := i + j;
    writeln( 'i = ', i, 'j = ', j, 'k = ', k );

图 4-6 展示了这个简单代码片段的流图。

图片

图 4-6:while 循环的流图

如前所述,结构良好的程序中的流图是可简化的。尽管完整描述可简化流图的定义超出了本书的范围,但任何只包含结构化控制语句(如ifwhilerepeat..until等)并避免使用goto语句的程序都将是可简化的。这是一个重要的点,因为编译器优化器通常在处理可简化程序时表现更好。相比之下,无法简化的程序往往会使它们感到困惑。

使优化器更容易处理可简化程序的是,它们的基本块可以以大纲的方式进行合并,封闭的基本块将继承一些属性(例如,修改了哪些变量)。通过这种方式处理源文件,优化器可以处理较少的基本块,而不是大量的语句。这种层次化的优化方法更加高效,并且使优化器能够保持关于程序状态的更多信息。此外,优化问题的指数时间复杂度在此情况下对我们有利。通过减少代码需要处理的块数量,显著减少了优化器需要做的工作。再次强调,编译器如何实现这一点的具体细节在此并不重要。关键是,使程序可简化能够让优化器更有效地完成其工作。试图通过加入大量goto语句来“优化”代码——以避免重复代码和执行不必要的测试——实际上可能适得其反。虽然你可能在当前工作区域节省了一些字节或周期,但最终结果可能会让编译器迷惑,从而无法有效优化,导致整体效率下降。

4.4.4.4 常见的编译器优化

第十二章将提供常见编译器优化的完整定义和示例,适用于编译器通常使用这些优化的编程环境。但现在,先简要预览一下基本类型:

常量折叠

常量折叠在编译时计算常量表达式或子表达式的值,而不是在运行时计算。有关更多信息,请参见第 397 页的“常量折叠”。

常量传播

常量传播将一个变量替换为常量值,如果编译器确定程序在代码的早期已经将常量赋值给该变量。更多信息请参见第 400 页的“常量传播”。

死代码消除

死代码消除移除与特定源代码语句相关的目标代码,当程序永远不会使用该语句的结果,或者当条件块永远不会为true时。更多信息请参见第 404 页的“死代码消除”。

公共子表达式消除

通常,一个表达式的部分会在当前函数的其他地方出现;这称为子表达式。如果子表达式中变量的值没有变化,程序就不需要在子表达式出现的每个地方重新计算它们。程序可以简单地在第一次计算时保存子表达式的值,然后在子表达式的每个其他出现处使用它。更多信息请参见第 410 页的“公共子表达式消除”。

强度缩减

通常,CPU 可以使用与源代码指定的不同操作符直接计算一个值。例如,shift指令可以实现乘法或除法,乘除的常数是 2 的幂,而按位and指令可以计算某些模(余数)运算(shiftand指令通常比乘法和除法指令执行得更快)。大多数编译器优化器善于识别这种操作,并将成本较高的计算替换为一系列成本较低的机器指令。更多信息请参见第 417 页的“强度缩减”。

归纳

在许多表达式中,特别是在循环中出现的表达式,某个变量的值完全依赖于其他变量。通常,编译器可以消除新值的计算,或者在该循环期间将两次计算合并为一次。更多信息请参见第 422 页的“归纳”。

循环不变式

目前为止的优化方法都是编译器用来改进已经编写得较好的代码的技术。与此相比,处理循环不变式是一种用于修复糟糕代码的编译器优化。循环不变式是指在某个循环的每次迭代中都不会变化的表达式。优化器可以在循环外部仅计算一次该计算的结果,然后在循环体内使用计算出的值。许多优化器足够聪明,可以发现循环不变式的计算,并使用代码移动将不变式计算移到循环外部。更多信息请参见第 427 页的“循环不变式”。

优秀的编译器可以执行许多其他优化,但这些是任何一个合格编译器都应该能够做的标准优化。

4.4.4.5 编译器优化控制

默认情况下,大多数编译器不会做太多或任何优化,除非你明确告诉它们。这可能看起来有些反直觉;毕竟,我们通常希望编译器为我们生成最好的代码。然而,“最优”有很多种定义,没有任何一种编译器输出能满足所有可能的需求。

你可能会争辩说,某种优化,即使它不是你感兴趣的那种,至少比没有优化要好。然而,有一些原因解释了为什么没有优化是编译器的默认状态:

  • 优化是一个缓慢的过程。当你关闭优化时,编译的周转时间会更快。这在快速编辑-编译-测试循环中非常有帮助。

  • 许多调试器在优化后的代码中无法正常工作,因此你需要关闭优化才能在应用程序中使用调试器(这也使得分析编译器输出更加容易)。

  • 大多数编译器缺陷发生在优化器中。通过生成未优化的代码,你更不容易遇到编译器缺陷(不过,编译器的作者也更不容易收到有关编译器缺陷的反馈)。

大多数编译器提供命令行选项,让你控制编译器执行的优化类型。早期的 Unix C 编译器使用了像-O-O1-O2这样的命令行参数。许多后来的编译器(包括 C 编译器及其他语言编译器)也采用了这一策略,尽管它们的命令行选项可能不完全相同。

如果你在想为什么编译器会提供多个选项来控制优化,而不仅仅是一个选项(优化与否),请记住,“最优”对不同的人有不同的定义。有些人可能希望代码在空间上得到优化;而其他人可能更关注代码的速度优化(在某些情况下,这两种优化可能是互斥的)。有些人可能希望优化他们的文件,但又不希望编译器花费过多时间来处理它们,因此他们愿意通过一组小而快速的优化来妥协。其他人可能希望针对特定的 CPU 系列(例如 80x86 系列中的 Core i9 处理器)控制优化。此外,某些优化只有在程序以特定方式编写时才是“安全”的(即,它们总是生成正确的代码)。你肯定不希望启用这些优化,除非程序员保证他们已经按照要求编写了代码。最后,对于那些精心编写 HLL 代码的程序员来说,编译器执行的某些优化实际上可能会生成较差的代码,在这种情况下,能够指定优化就非常有用。基于这些原因以及更多原因,大多数现代编译器在执行优化时提供了相当大的灵活性。

考虑一下 Microsoft Visual C++编译器。它提供了以下命令行选项来控制优化:


			                              -OPTIMIZATION-

/O1 minimize space
/O2 maximize speed
/Ob<n> inline expansion (default n=0)
/Od disable optimizations (default)
/Og enable global optimization
/Oi[-] enable intrinsic functions
/Os favor code space
/Ot favor code speed
/Ox maximum optimizations
/favor:<blend|AMD64|INTEL64|ATOM> select processor to optimize for, one of:
    blend - a combination of optimizations for several different x64 processors
    AMD64 - 64-bit AMD processors
    INTEL64 - Intel(R)64 architecture processors
    ATOM - Intel(R) Atom(TM) processors

                             -CODE GENERATION-

/Gw[-] separate global variables for linker
/GF enable read-only string pooling
/Gm[-] enable minimal rebuild
/Gy[-] separate functions for linker
/GS[-] enable security checks
/GR[-] enable C++ RTTI
/GX[-] enable C++ EH (same as /EHsc)
/guard:cf[-] enable CFG (control flow guard)
/EHs enable C++ EH (no SEH exceptions)
/EHa enable C++ EH (w/ SEH exceptions)
/EHc extern "C" defaults to nothrow
/EHr always generate noexcept runtime termination checks
/fp:<except[-]|fast|precise|strict> choose floating-point model:
    except[-] - consider floating-point exceptions when generating code
    fast - "fast" floating-point model; results are less predictable
    precise - "precise" floating-point model; results are predictable
    strict - "strict" floating-point model (implies /fp:except)
/Qfast_transcendentals generate inline FP intrinsics even with /fp:except
/Qspectre[-] enable mitigations for CVE 2017-5753
/Qpar[-] enable parallel code generation
/Qpar-report:1 auto-parallelizer diagnostic; indicate parallelized loops
/Qpar-report:2 auto-parallelizer diagnostic; indicate loops not parallelized
/Qvec-report:1 auto-vectorizer diagnostic; indicate vectorized loops
/Qvec-report:2 auto-vectorizer diagnostic; indicate loops not vectorized
/GL[-] enable link-time code generation
/volatile:<iso|ms> choose volatile model:
    iso - Acquire/release semantics not guaranteed on volatile accesses
    ms  - Acquire/release semantics guaranteed on volatile accesses
/GA optimize for Windows Application
/Ge force stack checking for all funcs
/Gs[num] control stack checking calls
/Gh enable _penter function call
/GH enable _pexit function call
/GT generate fiber-safe TLS accesses
/RTC1 Enable fast checks (/RTCsu)
/RTCc Convert to smaller type checks
/RTCs Stack Frame runtime checking
/RTCu Uninitialized local usage checks
/clr[:option] compile for common language runtime, where option is:
    pure - produce IL-only output file (no native executable code)
    safe - produce IL-only verifiable output file
    initialAppDomain - enable initial AppDomain behavior of Visual C++ 2002
    noAssembly - do not produce an assembly
    nostdlib - ignore the default \clr directory
/homeparams Force parameters passed in registers to be written to the stack
/GZ Enable stack checks (/RTCs)
/arch:AVX enable use of instructions available with AVX-enabled CPUs
/arch:AVX2 enable use of instructions available with AVX2-enabled CPUs
/Gv __vectorcall calling convention

GCC 有一个类似的—但要长得多—的列表,您可以通过在 GCC 命令行中指定-v --help来查看。大多数单独的优化标志以-f开头。您还可以使用-On,其中 n 是一个单数字整数值,以指定不同级别的优化。使用-O3(或更高)时需要小心,因为在某些情况下这样做可能会执行一些不安全的优化。

4.4.5 编译器基准测试

我们生成优质代码的一个现实约束是,不同的编译器提供的优化集差异极大。即使是两个不同编译器执行相同的优化,其效果也可能差异显著。

幸运的是,有几个网站对各种编译器进行了基准测试。(一个好的例子是 Willus.com。)只需在网上搜索类似“编译器基准”或“编译器比较”的主题,就可以尽情享受。

4.4.6 本地代码生成

本地代码生成阶段负责将中间代码转换为目标 CPU 的机器代码。例如,80x86 本地代码生成器可能会将之前给出的中间代码序列转换为如下内容:


			mov( 5, eax ); // move the constant 5 into the EAX register.
mov( eax, k ); // Store the value in EAX (5) into k.
add( eax, m ); // Add the value in EAX to variable m.

第二个优化阶段发生在本地代码生成之后,处理的是在所有机器上不存在的机器特性。例如,针对 Pentium II 处理器的优化器可能会将add(1, eax);这一指令替换为inc(eax);。而针对后续 CPU 的优化器可能会做相反的事情。某些 80x86 处理器的优化器可能会按照某种方式排列指令序列,以最大化超标量 CPU 中指令的并行执行,而针对不同(80x86)CPU 的优化器可能会以不同方式排列指令。

4.5 编译器输出

上一节指出,编译器通常将机器代码作为输出。严格来说,这既不是必需的,也不常见。大多数编译器输出的不是给定 CPU 可以直接执行的代码。一些编译器输出汇编语言源代码,执行之前需要进一步由汇编器处理。其他编译器则生成类似于可执行代码的目标文件,但无法直接执行。还有一些编译器实际上会生成需要通过其他 HLL 编译器进一步处理的源代码输出。在本节中,我将讨论这些不同的输出格式及其优缺点。

4.5.1 以 HLL 代码作为编译器输出

一些编译器实际上会输出作为另一种高级编程语言的源代码(见图 4-7)。例如,许多编译器(包括最初的 C++编译器)将 C 代码作为输出。实际上,那些从编译器中输出一些高级源代码的编译器,通常选择 C 编程语言。

输出 HLL 代码作为编译器输出有几个优点。输出是人类可读的,通常容易验证。输出的 HLL 代码通常可以跨多个平台移植;例如,如果编译器输出 C 代码,你通常可以在不同的机器上编译该输出,因为大多数平台都有 C 编译器。最后,通过输出 HLL 代码,翻译器可以依赖目标语言编译器的优化器,从而节省编写自己优化器的工作。换句话说,输出 HLL 代码使得编译器开发者可以创建一个较简单的代码生成模块,并依赖其他编译器的强大功能来处理编译过程中的最复杂部分。

Image

图 4-7:编译器输出 HLL 代码

输出 HLL 代码也有几个缺点。首先,这种方法通常比直接生成可执行代码需要更多的处理时间。为了生成可执行文件,可能需要一个第二个、本不必要的编译器。更糟糕的是,这个第二个编译器的输出可能需要通过另一个编译器或汇编器进一步处理,问题因此更加严重。另一个缺点是,在 HLL 代码中很难嵌入调试信息,供调试程序使用。然而,这种方法的最根本问题是,HLL 通常是对底层机器的抽象。因此,编译器在 HLL 中生成与低级机器代码高效对应的语句可能相当困难。

通常,输出 HLL 语句作为编译器输出的编译器是将非常高级语言(VHLL)转换为较低级语言。例如,C 通常被认为是相对较低级的 HLL,这也是它成为许多编译器流行输出格式的原因之一。尝试创建专门用于此目的的特殊便携式低级语言的项目从未获得过广泛的流行。你可以查看互联网中的任何“C--”项目,了解这类系统的示例。

如果你想通过分析编译器输出编写高效的代码,你可能会发现处理输出 HLL 代码的编译器更为困难。使用标准编译器,你只需要学习编译器生成的特定机器代码语句。然而,当编译器输出 HLL 语句时,学习如何用该编译器编写优秀的代码就变得更加困难。你需要理解主语言如何生成 HLL 语句,以及第二个编译器如何将代码转换为机器代码。

通常,生成高级语言代码作为输出的编译器要么是面向非常高级语言(VHLL)的实验性编译器,要么是尝试将遗留代码从较旧的语言翻译成现代语言的编译器(例如,将 FORTRAN 转换为 C)。因此,期望这些编译器生成高效代码通常是要求过高。因此,你可能会明智地避免使用那些输出高级语言语句的编译器。直接生成机器代码(或汇编语言代码)的编译器更有可能生成更小、更快速运行的可执行文件。

4.5.2 将汇编语言作为编译器输出

许多编译器会输出人类可读的汇编语言源文件,而不是二进制机器代码文件(参见图 4-8)。最著名的例子可能是 FSF/GNU 的 GCC 编译器套件,它为 FSF/GNU 的汇编器 Gas 输出汇编语言代码。与输出高级语言源代码的编译器一样,输出汇编语言也有一些优缺点。

Image

图 4-8:编译器发出的汇编代码

输出汇编代码的主要缺点与输出高级语言(HLL)源代码的缺点相似。首先,你需要运行第二个语言翻译器(即汇编器)来生成实际的可执行目标代码。其次,一些汇编器可能不允许嵌入调试元数据,这些元数据允许调试器与原始源代码协同工作(尽管许多汇编器支持此功能)。如果编译器为合适的汇编器生成代码,这两个缺点实际上是最小的。例如,Gas 非常快,并且支持插入调试信息供源级调试器使用。因此,FSF/GNU 编译器不会因为输出 Gas 代码而受到影响。

输出汇编语言的优点,特别是对我们的目的来说,是可以轻松地阅读编译器的输出并确定编译器发出的机器指令。事实上,本书就使用了这一编译器功能来分析编译器输出。输出汇编代码使得编译器作者不必担心多种不同的目标代码输出格式——底层汇编器处理这些——这使得编译器作者能够创建更具可移植性的编译器。确实,汇编器必须能够为不同的操作系统生成代码,但你只需要为每个目标文件格式重复这一过程一次,而不是每个格式都要为每个编写的编译器重复一次。FSF/GNU 编译器套件很好地利用了 Unix 哲学,即使用小工具串联起来完成更大、更复杂的任务——即,最小化冗余。

能够输出汇编语言的编译器的另一个优点是,它们通常允许你在高级语言代码中嵌入内联汇编语言语句。这样,你可以将一些机器指令直接插入到代码中对时间要求严格的部分,而不需要麻烦地创建一个单独的汇编语言程序并将其输出与高级语言程序链接。

4.5.3 作为编译器输出生成目标文件

大多数编译器将源语言翻译成目标文件格式,这是一种包含机器指令和二进制运行时数据以及某些元数据的中间文件格式。这些元数据允许链接器/加载程序将不同的目标模块组合在一起,生成一个完整的可执行文件。这使得程序员能够链接库模块和他们自己编写并单独编译的其他目标模块与主应用程序模块。

输出目标文件的优点是,你不需要单独的编译器或汇编器将编译器的输出转换为目标代码格式,这在编译过程中节省了一些时间。然而,需要注意的是,链接器程序仍然必须处理目标文件输出,这会在编译后消耗一些时间。尽管如此,链接器通常非常快速,因此将单个模块编译并与多个之前编译的模块链接在一起,通常比将所有模块一起编译生成可执行文件更具成本效益。

目标模块是二进制文件,不包含可供人类阅读的数据,因此分析这种格式的编译器输出比我们讨论的其他格式更困难。幸运的是,有一些实用程序可以将目标模块的输出反汇编成易于人类阅读的形式。虽然结果不如直接的汇编编译器输出那样容易读取,但你仍然可以进行合理的分析。

由于目标文件难以分析,许多编译器作者提供了一个选项,允许输出汇编代码而不是目标代码。这个实用功能使分析变得更加容易,因此我们将在本书中使用它与各种编译器一起工作。

注意

“目标文件格式”部分在第 71 页详细介绍了目标文件的元素,重点讲解了 COFF(通用目标文件格式)。

4.5.4 作为编译器输出生成可执行文件

一些编译器直接输出可执行文件。这些编译器通常非常快速,在编辑-编译-运行-测试-调试循环中几乎能即时完成。不过,遗憾的是,它们的输出通常是最难以阅读和分析的,需要使用调试器或反汇编程序,并且需要大量的手动工作。尽管如此,快速的编译周期使得这些编译器非常受欢迎,因此在本书后面,我们将探讨如何分析它们生成的可执行文件。

4.6 目标文件格式

如前所述,目标文件是编译器输出中最常见的格式之一。虽然可以创建专有的目标文件格式——这种格式只有单一的编译器及其相关工具能使用——但大多数编译器会使用一种或多种标准化的目标文件格式来生成代码。这使得不同的编译器可以共享相同的目标文件工具集,包括链接器、库管理器、转储工具和反汇编器。常见的目标文件格式包括:OMF(目标模块格式)、COFF(通用目标文件格式)、PE/COFF(微软的 PE 格式,COFF 的变种)和 ELF(可执行与可链接格式)。这些文件格式有几个变体,也有许多完全不同的目标文件格式。

大多数程序员理解目标文件代表应用程序执行的机器码,但他们常常没有意识到目标文件的组织结构会对应用程序的性能和大小产生影响。尽管编写优秀的代码不需要深入了解目标文件的内部表示,但对其有基本的理解将有助于你组织源文件,以更好地利用编译器和汇编器生成应用程序代码的方式。

目标文件通常以一个包含文件前几个字节的头部开始。这个头部包含某些签名信息,用来标识该文件是一个有效的目标文件,同时还包含一些其他值,用于定义文件中各种数据结构的位置。头部之后,目标文件通常会分为多个部分,每部分包含应用数据、机器指令、符号表项、重定位数据及其他元数据(程序的相关数据)。在某些情况下,实际的代码和数据只是整个目标文件中的一小部分。

为了更好地理解目标文件是如何构建的,值得详细了解某一特定目标文件格式。我将在接下来的讨论中使用 COFF,因为大多数目标文件格式(例如 ELF 和 PE/COFF)都基于 COFF 或与其非常相似。COFF 文件的基本布局见图 4-9,之后我将依次描述每个部分。

Image

图 4-9:COFF 文件布局

4.6.1 COFF 文件头

每个 COFF 文件的开头都有一个COFF 文件头。以下是微软 Windows 和 Linux 用来定义 COFF 文件头结构的定义:


			// Microsoft Windows winnt.h version:

typedef struct _IMAGE_FILE_HEADER {
    WORD    Machine;
    WORD    NumberOfSections;
    DWORD   TimeDateStamp;
    DWORD   PointerToSymbolTable;
    DWORD   NumberOfSymbols;
    WORD    SizeOfOptionalHeader;
    WORD    Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

// Linux coff.h version:

struct COFF_filehdr {
        char f_magic[2];        /* magic number */
        char f_nscns[2];        /* number of sections */
        char f_timdat[4];       /* time & date stamp */
        char f_symptr[4];       /* file pointer to symtab */
        char f_nsyms[4];        /* number of symtab entries */
        char f_opthdr[2];       /* sizeof(optional hdr) */
        char f_flags[2];        /* flags */
};

Linux 的coff.h头文件使用传统的 Unix 命名方式;微软的winnt.h头文件则使用(可以说)更易读的命名方式。以下是头文件中每个字段的总结,Unix 命名在斜杠左侧,微软的对应命名在右侧:

f_magic/Machine

标识此 COFF 文件为哪个系统创建。在原 Unix 定义中,该值标识为特定 Unix 端口创建的代码。如今的操作系统定义此值有所不同,但最重要的是,这个值是一个签名,用来指定 COFF 文件是否包含当前操作系统和 CPU 适用的数据或机器指令。

表 4-1 提供了 f_magic/Machine 字段的编码。

表 4-1: f_magic/Machine 字段编码

描述
0x14c 英特尔 386
0x8664 x86-64
0x162 MIPS R3000
0x168 MIPS R10000
0x169 MIPS 小端 WCI v2
0x183 旧版 Alpha AXP
0x184 Alpha AXP
0x1a2 Hitachi SH3
0x1a3 Hitachi SH3 DSP
0x1a6 Hitachi SH4
0x1a8 Hitachi SH5
0x1c0 ARM 小端
0x1c2 Thumb
0x1c4 ARMv7
0x1d3 松下 AM33
0x1f0 PowerPC 小端
0x1f1 支持浮点运算的 PowerPC
0x200 Intel IA64
0x266 MIPS16
0x268 摩托罗拉 68000 系列
0x284 Alpha AXP 64 位
0x366 带 FPU 的 MIPS
0x466 带 FPU 的 MIPS16
0xebc EFI 字节码
0x8664 AMD AMD64
0x9041 三菱 M32R 小端
0xaa64 ARM64 小端
0xc0ee CLR 纯 MSIL

f_nscns/节数量

指定 COFF 文件中存在多少个段(节)。链接器程序可以使用此值迭代一组节头(稍后会描述)。

f_timdat/时间戳

包含一个 Unix 风格的时间戳(自 1970 年 1 月 1 日起的秒数),指定文件的创建日期和时间。

f_symptr/指向符号表的指针

包含一个文件偏移量值(即从文件开始位置算起的字节数),指定符号表在文件中的位置。符号表是一个数据结构,指定 COFF 文件中所有外部、全局和其他符号的名称及其他信息。链接器使用符号表来解决外部引用。这个符号表信息也可能出现在最终的可执行文件中,供符号调试器使用。

f_nsyms/符号数量

符号表中的条目数量。

f_opthdr/可选头大小

指定紧跟在文件头后面的可选头的大小(即可选头的第一个字节紧随f_flags/Characteristics字段之后)。链接器或其他目标代码操作程序会使用此字段中的值来确定可选头的结束位置,以及文件中节头的开始位置。节头紧跟在可选头之后,但可选头的大小并不是固定的。COFF 文件的不同实现可以有不同的可选头结构。如果 COFF 文件中没有可选头,则f_opthdr/SizeOfOptionalHeader字段将包含零,且第一个节头将紧随文件头之后。

f_flags/Characteristics

一个小型位图,用于指定某些布尔标志,如文件是否可执行、是否包含符号信息,以及是否包含行号信息(供调试器使用)。

4.6.2 COFF 可选头

COFF 可选头包含与可执行文件相关的信息。如果文件包含不可执行的目标代码(因为存在未解析的引用),则该头可能不存在。然而,请注意,即使文件不可执行,Linux 的 COFF 和微软的 PE/COFF 文件中此可选头仍然存在。Windows 和 Linux 的结构体对于这个可选文件头在 C 语言中采用以下形式。

// Microsoft PE/COFF Optional Header (from winnt.h)

typedef struct _IMAGE_OPTIONAL_HEADER {
    //
    // Standard fields.
    //

    WORD    Magic;
    BYTE    MajorLinkerVersion;
    BYTE    MinorLinkerVersion;
    DWORD   SizeOfCode;
    DWORD   SizeOfInitializedData;
    DWORD   SizeOfUninitializedData;
    DWORD   AddressOfEntryPoint;
    DWORD   BaseOfCode;
    DWORD   BaseOfData;

    //
    // NT additional fields.
    //

    DWORD   ImageBase;
    DWORD   SectionAlignment;
    DWORD   FileAlignment;
    WORD    MajorOperatingSystemVersion;
    WORD    MinorOperatingSystemVersion;
    WORD    MajorImageVersion;
    WORD    MinorImageVersion;
    WORD    MajorSubsystemVersion;
    WORD    MinorSubsystemVersion;
    DWORD   Win32VersionValue;
    DWORD   SizeOfImage;
    DWORD   SizeOfHeaders;
    DWORD   CheckSum;
    WORD    Subsystem;
    WORD    DllCharacteristics;
    DWORD   SizeOfStackReserve;
    DWORD   SizeOfStackCommit;
    DWORD   SizeOfHeapReserve;
    DWORD   SizeOfHeapCommit;
    DWORD   LoaderFlags;
    DWORD   NumberOfRvaAndSizes;
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

// Linux/COFF Optional Header format (from coff.h)

typedef struct
{
  char  magic[2];  /* type of file */
  char  vstamp[2]; /* version stamp */
  char  tsize[4];  /* text size in bytes, padded to
                      FW bdry */
  char  dsize[4]; /* initialized   data "   " */
  char  bsize[4]; /* uninitialized data "   " */
  char  entry[4]; /* entry pt. */
  char  text_start[4]; /* base of text used for this file */
  char  data_start[4]; /* base of data used for this file */
} COFF_AOUTHDR;

首先需要注意的是,这些结构并不完全相同。微软版本的信息比 Linux 版本要多得多。f_opthdr/SizeOfOptionalHeader字段存在于文件头中,用于确定可选头的实际大小。

magic/Magic

提供 COFF 文件的另一个签名值。此签名值标识文件类型(即 COFF),而不是创建它的系统。链接器使用此字段的值来确定它们是否真正在操作一个 COFF 文件(而不是某个可能会混淆链接器的任意文件)。

vstamp/MajorLinkerVersion/MinorLinkerVersion

指定 COFF 格式的版本号,以便为旧版本文件格式编写的链接器不会尝试处理针对新版本链接器的文件。

tsize/SizeOfCode

尝试指定文件中代码部分的大小。如果 COFF 文件包含多个代码部分,则此字段的值未定义,尽管通常它指定 COFF 文件中第一个代码/文本部分的大小。

dsize/SizeOfInitializedData

指定此 COFF 文件中数据段的大小。再次说明,如果文件中有两个或多个数据部分,则此字段未定义。通常,如果文件中有多个数据部分,此字段指定第一个数据部分的大小。

bsize/SizeOfUninitializedData

指定 COFF 文件中由符号(BSS)段开始的块的大小——未初始化的数据段。与文本和数据段类似,如果有两个或更多的 BSS 段,则此字段未定义;在这种情况下,此字段通常指定文件中第一个 BSS 段的大小。

注意

参见第 81 页的“页面、段和文件大小”了解更多关于 BSS 段的信息。

entry/AddressOfEntryPoint

包含可执行程序的起始地址。像 COFF 文件头中的其他指针一样,这个字段实际上是一个文件中的偏移量;它不是一个实际的内存地址。

text_start/BaseOfCode

指定 COFF 文件中代码段开始的文件偏移量。如果有两个或更多的代码段,则此字段未定义,但通常它指定 COFF 文件中第一个代码段的偏移量。

data_start/BaseOfData

指定 COFF 文件中数据段开始的文件偏移量。如果有两个或更多的数据段,则此字段未定义,但通常它指定 COFF 文件中第一个数据段的偏移量。

不需要bss_start/StartOfUninitializedData字段。COFF 文件格式假设操作系统的程序加载器将在程序加载到内存时自动为 BSS 段分配存储空间。对于未初始化的数据,COFF 文件中不需要占用空间(然而,“可执行文件格式”在第 80 页中描述了一些编译器如何实际上为了性能原因将 BSS 和 DATA 段合并在一起)。

可选的文件头结构实际上是* a.out*格式的回溯,这是一种在 Unix 系统中使用的较旧的目标文件格式。这就是为什么它无法处理多个文本/代码和数据段,即使 COFF 格式允许它们。

Windows 变体的可选头中其余的字段包含 Windows 链接器允许程序员指定的值。虽然这些值的目的对于任何手动从命令行运行 Microsoft 链接器的人来说可能很清楚,但在这里并不重要。重要的是,COFF 并不要求可选头使用特定的数据结构。不同的 COFF 实现(如 Microsoft 的)可以自由扩展可选头的定义。

4.6.3 COFF 段头

节头位于 COFF 文件的可选头之后。与文件头和可选头不同,COFF 文件可以包含多个节头。文件头中的f_nscns/节的数量字段指定 COFF 文件中找到的节头的确切数量(因此也就指定了节的数量)。请记住,第一个节头并不从文件中的固定偏移量开始。由于可选头的大小是可变的(实际上,如果可选头不存在,其大小甚至可以为 0),因此你必须将文件头中的f_opthdr/可选头的大小字段与文件头的大小相加,以获取第一个节头的起始偏移量。节头是固定大小的,因此一旦知道第一个节头的地址,你就可以通过将所需节头编号乘以节头的大小,并将结果加到第一个节头的基准偏移量来轻松计算出其他节头的地址。

以下是 Windows 和 Linux 节头的 C 结构定义:


			// Windows section header structure (from winnt.h)

typedef struct _IMAGE_SECTION_HEADER {
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];
    union {
            DWORD   PhysicalAddress;
            DWORD   VirtualSize;
    } Misc;
    DWORD   VirtualAddress;
    DWORD   SizeOfRawData;
    DWORD   PointerToRawData;
    DWORD   PointerToRelocations;
    DWORD   PointerToLinenumbers;
    WORD    NumberOfRelocations;
    WORD    NumberOfLinenumbers;
    DWORD   Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

// Linux section header definition (from coff.h)

struct COFF_scnhdr
{
  char s_name[8]; /* section name */
  char s_paddr[4]; /* physical address, aliased s_nlib */
  char s_vaddr[4]; /* virtual address */
  char s_size[4]; /* section size */
  char s_scnptr[4]; /* file ptr to raw data */
  char s_relptr[4]; /* file ptr to relocation */
  char s_lnnoptr[4]; /* file ptr to line numbers */
  char s_nreloc[2]; /* number of relocation entries */
  char s_nlnno[2]; /* number of line number entries */
  char s_flags[4]; /* flags */
};

如果你仔细检查这两个结构,你会发现它们大致是等效的(唯一的结构性区别是,Windows 重载了物理地址字段,而在 Linux 中,这个字段始终等同于VirtualAddress字段,用来存放VirtualSize字段)。

以下是每个字段的总结:

s_name/名称

指定节的名称。如 Linux 定义所示,该字段的长度限制为八个字符,因此节名称最多为八个字符长。(通常,如果源文件指定了更长的名称,编译器/汇编器在创建 COFF 文件时会将其截断为 8 个字符。)如果节名称恰好为八个字符,这八个字符将占用该字段的全部 8 个字节,并且没有零终止字节。如果节名称少于八个字符,则名称后面会跟一个零终止字节。该字段的值通常是.textCODE.dataDATA之类的内容。但需要注意的是,节名称并不定义该段的类型。你可以创建一个代码/文本节,并将其命名为DATA;你也可以创建一个数据节,并将其命名为.textCODEs_flags/特性字段决定了该节的实际类型。

s_paddr/物理地址/虚拟大小

大多数工具不使用此字段。在类 Unix 操作系统(如 Linux)中,通常将此字段设置为与VirtualAddress字段相同的值。不同的 Windows 工具将此字段设置为不同的值(包括零);链接器/加载器似乎忽略此字段中出现的任何值。

s_vaddr/虚拟地址

指定节在内存中的加载地址(即其虚拟内存地址)。请注意,这个是运行时的内存地址,而不是文件中的偏移量。程序加载器使用这个值来确定将节加载到内存中的位置。

s_size/原始数据的大小

指定节的大小,以字节为单位。

s_scnptr/原始数据的指针

提供文件中节数据起始位置的偏移量。

s_relptr/PointerToRelocations

提供指向该特定节的重定位列表的文件偏移量。

s_lnnoptr/PointerToLinenumbers

包含指向当前节的行号记录的文件偏移量。

s_nreloc/NumberOfRelocations

指定在该文件偏移位置找到的重定位条目的数量。重定位条目是小型结构体,它提供文件偏移量,指向节数据区域中必须在文件加载到内存时进行修补的地址数据。我们在本书中不会讨论这些重定位条目,但如果你有兴趣了解更多细节,请参阅本章末尾的参考资料。

s_nlnno/NumberOfLinenumbers

指定在该偏移量处可以找到多少行号记录。行号信息由调试器使用,超出了本章的讨论范围。如果你对行号条目有兴趣,详见本章末尾的参考资料。

s_flags/Characteristics

一个位图,指定该节的特性。特别是,该字段会告诉你节是否需要重定位,是否包含代码,是否是只读的,等等。

4.6.4 COFF 节

节头提供了一个目录,描述了目标文件中实际数据和代码的位置。s_scnptr/PointerToRawData 字段包含了文件中原始二进制数据或代码的偏移位置,而 s_size/SizeOfRawData 字段指定了该节数据的长度。由于重定位的需求,实际出现在节块中的数据可能并不完全代表操作系统加载到内存中的数据。这是因为节中出现的许多指令操作数地址和指针值可能需要进行修补,以便根据操作系统将文件加载到内存的位置进行重定位。重定位列表(与节数据分开)包含了节中的偏移量,操作系统必须在这些位置修补可重定位的地址。操作系统在从磁盘加载节数据时执行这些修补操作。

尽管 COFF 部分中的字节在运行时可能不是内存中数据的精确表示,但 COFF 格式要求该部分中的所有字节映射到内存中相应的地址。这使得加载器能够将部分数据直接从文件复制到顺序的内存位置。重定位操作永远不会插入或删除部分中的字节;它只会改变部分中某些字节的值。这个要求有助于简化系统加载器,并提高应用程序性能,因为操作系统在加载应用程序到内存时不需要移动大块内存。这个方案的缺点是,COFF 格式错过了压缩部分数据区域中冗余数据的机会。COFF 设计者认为,在设计中,强调性能比节省空间更重要。

4.6.5 重定位部分

COFF 文件中的重定位部分包含指向 COFF 部分指针的偏移量,这些指针在系统将这些部分的代码或数据加载到内存时必须进行重定位。

4.6.6 调试和符号信息

图 4-9 中显示的最后三个部分包含调试器和链接器使用的信息。一个部分包含调试器用来将源代码的行与可执行机器代码指令关联的行号信息。符号表和字符串表部分存储 COFF 文件的公共和外部符号。链接器使用这些信息来解析目标模块之间的外部引用;调试器使用这些信息在调试过程中显示符号化的变量和函数名。

注意

本书没有提供 COFF 文件格式的完整描述,但如果你有兴趣编写诸如汇编器、编译器和链接器等应用程序,肯定会希望更深入地了解它以及其他目标代码格式(ELF、MACH-O、OMF 等)。要进一步研究这个领域,请参阅本章末尾的参考资料。

4.7 可执行文件格式

大多数操作系统使用一种特殊的文件格式来处理可执行文件。通常,可执行文件格式类似于目标文件格式,主要区别在于可执行文件中通常没有未解决的外部引用。

除了机器代码和二进制数据外,可执行文件还包含其他元数据,包括调试信息、动态链接库的链接信息以及操作系统如何将文件的不同部分加载到内存中的详细信息。根据 CPU 和操作系统的不同,可执行文件还可能包含重定位信息,以便操作系统在加载文件到内存时修补绝对地址。目标代码文件包含相同的信息,因此许多操作系统使用的可执行文件格式与它们的目标文件格式相似也就不足为奇了。

可执行与可链接格式(ELF)被 Linux、QNX 和其他类 Unix 操作系统广泛使用,是一种典型的结合对象文件格式和可执行文件格式。事实上,该格式的名称也表明了它的双重性质。举个例子,微软的 PE 文件格式是 COFF 格式的简单变种。对象文件格式与可执行文件格式之间的相似性使得操作系统设计师可以在加载器(负责执行程序)和链接器应用程序之间共享代码。鉴于这种相似性,几乎没有必要再讨论可执行文件中的特定数据结构,因为这样做基本上会重复前面章节中的信息。

然而,值得一提的是,这两种文件类型的布局有一个非常实际的区别。对象文件通常设计得尽可能小,而可执行文件则通常设计得尽可能快地加载到内存中,即使这意味着它们的大小超出了绝对必要的范围。看似矛盾的是,一个较大的文件可能比较小的文件加载到内存的速度更快;然而,如果操作系统支持虚拟内存,它可能一次只加载可执行文件的一小部分。正如我们接下来将讨论的,一个设计良好的可执行文件格式可以利用这一点,通过合理安排文件中的数据和机器指令布局,从而减少虚拟内存的开销。

4.7.1 页面、段和文件大小

虚拟内存子系统和内存保护机制通常基于内存中的页面进行操作。典型处理器中的页面大小通常在 1KB 到 64KB 之间。不论其大小如何,页面是可以应用离散保护特性的最小内存单位(例如,判断该页面中的数据是只读、可读写还是可执行)。特别地,你不能将只读/可执行代码与可读写数据混合在同一页面中——它们必须分别出现在内存中的不同页面。以 80x86 CPU 家族为例,内存中的页面大小为 4KB。因此,如果我们有可读写的数据,并且希望将机器指令放在只读内存中,那么我们可以分配给进程的最小代码空间和最小数据空间为 8KB。事实上,大多数程序包含多个段或区(如前面在对象文件中看到的),我们可以对这些段应用个别的保护权限,每个段都需要在内存中一个唯一的页面集,且这些页面不与其他任何段共享。一个典型的程序在内存中有四个或更多的段:代码或文本、静态数据、未初始化数据和栈是最常见的。此外,许多编译器还会生成堆段、链接段、只读段、常量数据段和应用程序命名的数据段(参见 图 4-10)。

Image

图 4-10:内存中典型的段

由于操作系统将段映射到页面,一个段总是需要一定数量的字节,这个数量是页面大小的倍数。例如,如果一个程序的段只包含一个字节的数据,该段仍然会在 80x86 处理器上消耗 4,096 字节。类似地,如果一个 80x86 应用程序由六个不同的段组成,那么无论程序使用多少机器指令和数据字节,也无论可执行文件的大小如何,该应用程序至少会消耗 24KB 的内存。

许多可执行文件格式(如 ELF 和 PE/COFF)在内存中提供了一个 BSS 段选项,程序员可以在其中放置未初始化的静态变量。由于这些变量的值未初始化,因此无需在可执行文件中为每个变量填充随机数据值。因此,某些可执行文件格式中的 BSS 段只是一个小的占位符,告诉操作系统加载器 BSS 段的大小。这样,你可以向应用程序添加新的未初始化静态变量,而不影响可执行文件的大小。当你增加 BSS 数据量时,编译器只需要调整一个值,告诉加载器为未初始化的变量预留多少字节。如果你将这些变量添加到已初始化的数据段中,随着每个新增字节的添加,可执行文件的大小将随之增长。显然,节省存储设备空间是一个好主意,因此,使用 BSS 段来减小可执行文件大小是一种有用的优化。

然而,许多人往往忽略的一点是,BSS 段在运行时仍然需要主内存。尽管可执行文件的大小可能较小,但你在程序中声明的每一个字节数据都会转换为内存中的 1 个字节。某些程序员误以为可执行文件的大小能够反映程序实际消耗的内存量。然而,这种看法并不一定正确,正如我们 BSS 的例子所示。一个特定应用程序的可执行文件可能只有 600 字节,但如果该程序使用了四个不同的段,每个段在内存中消耗一个 4KB 的页面,那么当操作系统将程序加载到内存时,该程序将需要 16,384 字节的内存。这是因为底层的内存保护硬件要求操作系统为给定进程分配完整的内存页面。

4.7.2 内部碎片化

可执行文件比应用程序的执行内存占用(应用程序在运行时消耗的内存量)小的另一个原因是内部碎片化。内部碎片化发生在你必须以固定大小的块分配内存段,即使你可能只需要每个块的一部分(参见图 4-11)。

Image

图 4-11:内部碎片化

请记住,内存中的每个部分都占用整数页数,即使该部分的数据大小不是页大小的倍数。从部分中的最后一个数据/代码字节到包含该字节的页末的所有字节都是浪费的;这就是内部碎片。一些可执行文件格式允许您在不填充到页大小的倍数的情况下打包每个部分。然而,正如您很快会看到的,以这种方式打包部分可能会导致性能损失,因此一些可执行格式不这样做。

最后,不要忘记,可执行文件的大小不包括运行时动态分配的任何数据(包括堆上的数据对象和放置在 CPU 堆栈上的值)。正如您所看到的,一个应用程序实际上可能比可执行文件的大小消耗更多的内存。

程序员通常竞争,看看谁能用自己喜欢的语言写出最小的“Hello World”程序。汇编语言程序员尤其喜欢吹嘘他们能够在汇编中比在 C 或其他高级语言中写的程序更小。这是一个有趣的心理挑战,但无论程序的可执行文件是 600 字节还是 16,000 字节长,这个程序在运行时消耗的内存几乎是一样的,一旦操作系统为程序的不同部分分配了四到五页。虽然写出世界上最短的“Hello World”应用程序可能会赢得吹嘘的权利,但从实际角度来看,由于内部碎片,这样的应用程序几乎节省不了任何资源。

4.7.3 优化空间的原因

这并不是在暗示优化空间不值得。写出优秀代码的程序员考虑他们的应用程序使用的所有机器资源,并且避免浪费这些资源。然而,试图将这个过程推向极端是一种浪费。一旦您将某个部分减小到 4,096 字节以下(在 80x86 或其他具有 4KB 页大小的 CPU 上),额外的优化将不会为您节省任何资源。请记住,分配粒度——即最小分配块大小——为 4,096 字节。如果您有一个有 4,097 字节数据的部分,它将在运行时消耗 8,192 字节。在这种情况下,最好将该部分减少 1 字节(从而在运行时节省 4,096 字节)。但是,如果您有一个消耗 16,380 字节的数据部分,并试图将其大小减少 4,092 字节以减小文件大小,除非数据组织非常糟糕,否则这将是困难的。

请注意,大多数操作系统以集群(或块)的方式分配磁盘空间,这些集群通常与 CPU 中的内存管理单元的页面大小相当(甚至更大)。因此,如果你试图通过将可执行文件的大小压缩到 700 字节来节省磁盘空间(即使考虑到现代磁盘驱动子系统的庞大容量,这依然是一个值得称赞的目标),节省的空间可能并不像你预期的那样大。例如,这个 700 字节的应用程序,仍然会在磁盘表面消耗至少一个块。通过减少应用程序的代码或数据大小,你所做的只是浪费更多的磁盘空间——当然,这要受段/块分配粒度的影响。

对于较大的可执行文件,特别是那些大于磁盘块大小的文件,内部碎片对浪费空间的影响较小。如果一个可执行文件将数据和代码段打包在一起,并且段与段之间没有任何浪费的空间,那么内部碎片只会出现在文件的末尾,即最后一个磁盘块中。假设文件大小是随机的(均匀分布),那么每个文件的内部碎片大约会浪费半个磁盘块(也就是说,当磁盘块大小为 4KB 时,每个文件大约浪费 2KB)。对于一个非常小的文件,尤其是小于 4KB 的文件,这可能会占据文件空间的相当大一部分。然而,对于较大的应用程序来说,浪费的空间则变得微不足道。因此,看起来只要可执行文件将程序的所有段按顺序打包在文件中,该文件就会尽可能小。但这真的是我们所期望的吗?

假设所有条件相同,拥有较小的可执行文件是好事。然而,所有条件并非总是相同,所以有时候创建尽可能小的可执行文件并不一定是最好的选择。要理解为什么,回想一下之前讨论的操作系统的虚拟内存子系统。当操作系统将应用程序加载到内存中执行时,它并不一定需要读取整个文件。相反,操作系统的分页系统只会加载启动应用程序所需的那些页面。这通常包括可执行代码的第一页、一页用于存放堆栈数据的内存页,以及可能的某些数据页。理论上,应用程序可以在只用两三页内存的情况下开始执行,并根据需要(当应用程序请求那些页面中包含的数据或代码时)加载其余的代码和数据页。这就是所谓的按需分页内存管理。在实践中,大多数操作系统为了提高效率,会预加载页面(保持一个工作集的页面在内存中)。然而,操作系统通常不会将整个可执行文件加载到内存中;相反,它们会根据应用程序的需求加载不同的块。因此,从文件加载一个内存页所需的努力,可能会显著影响程序的性能。那么,有没有办法组织可执行文件,以便在操作系统使用按需分页内存管理时提升性能呢?有的——如果你让文件稍微大一点。

提高性能的诀窍在于将可执行文件的块组织成与内存页面布局相匹配的方式。这意味着内存中的各个部分应该在可执行文件中对齐到页面大小的边界。这也意味着磁盘块的大小应该与磁盘扇区或块的大小相等或是其倍数。这样,虚拟内存管理系统就可以迅速将磁盘上的一个块复制到一个内存页中,更新任何必要的重定位值,然后继续程序执行。另一方面,如果一页数据跨越磁盘上的两个块并且没有对齐到磁盘块边界,操作系统就必须从磁盘读取两个块(而不是一个)到一个内部缓冲区,然后将数据页从该缓冲区复制到它应该存放的目标页面。这额外的工作可能会非常耗时,并且会影响应用程序的性能。

因此,一些编译器实际上会填充可执行文件,以确保可执行文件中的每个部分都从虚拟内存管理子系统能够直接映射到内存中的页边界开始。采用这种技术的编译器通常会生成比那些不采用这种技术的编译器更大的可执行文件。这在可执行文件包含大量 BSS(未初始化)数据且打包文件格式能够非常紧凑地表示这些数据时尤其如此。

由于一些编译器在牺牲执行时间的前提下生成紧凑的文件,而其他编译器则生成加载和运行速度更快的扩展文件,因此仅仅根据可执行文件的大小来比较编译器的质量是危险的。判断编译器输出质量的最佳方式是直接分析这些输出,而不是使用像输出文件大小这样的弱指标。

注意

分析编译器输出是下一个章节的内容,如果你对这个话题感兴趣,请继续阅读。

4.8 目标文件中的数据和代码对齐

正如我在WGC1中指出的,将数据对象对齐到一个“自然”的地址边界上,可以提高性能,这个边界通常是该对象大小的倍数。同样地,将一个过程的代码起始位置或一个循环的起始指令对齐到某个合适的边界上,也可以提高性能。编译器作者都非常清楚这一点,因此他们通常会在数据或代码流中插入填充字节,以将数据或代码序列对齐到适当的边界上。然而,请注意,链接器在链接两个目标文件生成一个可执行文件时,可以自由地调整代码段的位置。

各个段通常会对齐到内存中的页面边界。对于一个典型的应用程序,文本/代码段会从一个页面边界开始,数据段会从另一个页面边界开始,BSS 段(如果存在的话)会从其自己的页面边界开始,等等。然而,这并不意味着与目标文件中的段头相关的每一个段都会在内存中从它自己的页面开始。链接器程序会将具有相同名称的段合并成一个段,在最终的可执行文件中。例如,如果两个不同的目标文件都包含 .text 段,链接器会将它们合并为最终可执行文件中的一个 .text 段。通过合并具有相同名称的段,链接器避免了为了内部碎片化而浪费大量内存。

链接器是如何遵守它所组合的各个部分的对齐要求的呢?答案当然取决于你使用的具体目标文件格式和操作系统,但通常可以在目标文件格式本身中找到。例如,在 Windows PE/COFF 文件中,IMAGE_OPTIONAL_HEADER32 结构体包含一个名为 SectionAlignment 的字段。这个字段指定了链接器和操作系统在组合段并将段加载到内存时必须遵守的地址边界。在 Windows 中,PE/COFF 可选头部的 SectionAlignment 字段通常包含 32 或 4,096 字节。4KB 的值将把一个段对齐到内存中的 4KB 页面边界。32 的对齐值可能是因为这是一个合理的缓存行大小(有关缓存行的讨论,请参见WGC1)。当然,也有其他可能的值——应用程序程序员通常可以通过使用链接器(或编译器)命令行参数来指定段对齐值。

4.8.1 选择节对齐大小

在每个节内,编译器、汇编器或其他代码生成工具可以保证任何是节对齐值的子倍数的对齐。例如,如果节的对齐值是 32,则在该节内可以实现 1、2、4、8、16 和 32 的对齐。不可能实现更大的对齐值。如果节的对齐值是 32 字节,则无法保证该节内的对齐是 64 字节的边界,因为操作系统或链接器将只尊重节的对齐值,它可以将该节放置在任何是 32 字节倍数的边界上,而这些边界中大约一半不会是 64 字节的边界。

也许不那么显而易见,但同样正确的是,不能在节内将对象对齐到一个不是节对齐值的子倍数的边界。例如,一个对齐值为 32 字节的节不能允许对齐值为 5 字节的对象。确实,你可以确保节内某个对象的偏移量是 5 的倍数;然而,如果节的起始内存地址不是 5 的倍数,那么你试图对齐的对象的地址可能不会落在 5 字节的倍数上。唯一的解决方法是选择一个节对齐值,它是 5 的倍数。

由于内存地址是二进制值,大多数语言翻译器和链接器将对齐值限制为一个小于或等于某个最大值的 2 的幂,通常是内存管理单元的页面大小。许多语言将对齐值限制为一个小的 2 的幂(例如 32、64 或 256)。

4.8.2 合并节

当链接器合并两个节时,它必须尊重与每个节相关的对齐值,因为应用程序可能依赖于该对齐值以确保正确操作。因此,链接器或其他合并目标文件中节的程序在构建合并节时不能简单地将两个节的数据连接在一起。

当合并两个节时,如果一个或两个节的长度不是节对齐值的倍数,链接器可能需要在节与节之间添加填充字节。例如,如果两个节的对齐值是 32,而一个节的长度是 37 字节,另一个节的长度是 50 字节,链接器必须在第一个节和第二个节之间添加 27 字节的填充,或者它也可以在第二个节和第一个节之间添加 14 字节的填充(链接器通常可以选择将节放置在合并文件中的顺序)。

如果两个节的对齐值不同,情况就变得更加复杂。当链接器合并两个节时,必须确保两个节的数据都满足对齐要求。如果一个节的对齐值是另一个节对齐值的倍数,那么链接器可以简单地选择两个对齐值中的较大者。例如,如果对齐值始终是 2 的幂次方(大多数链接器要求如此),那么链接器可以简单地选择两个对齐值中的较大者作为合并节的对齐值。

如果一个节的对齐值不是另一个节对齐值的倍数,那么在将它们组合时,确保两个节的对齐要求唯一的方式是使用一个两者值的乘积作为对齐值(或者更好的是,使用两者值的最小公倍数)。例如,将一个 32 字节对齐的节与一个 5 字节对齐的节组合,需要使用 160 字节的对齐值(5 × 32)。由于组合这类节的复杂性,大多数链接器要求节的大小是 2 的幂次方,这样可以确保较大的节对齐值总是较小对齐值的倍数。

4.8.3 控制节对齐

你通常使用链接器选项来控制程序中的节对齐。例如,使用微软的link.exe程序时,/ALIGN:value命令行参数告诉链接器将所有节对齐到指定的边界(该边界必须是 2 的幂次方)。GNU 的ld链接器程序允许你通过在链接脚本文件中使用BLOCK(value)选项来指定节的对齐。macOS 链接器(ld)提供了-segalign value命令行选项,允许你指定节对齐。具体的命令和可能的值取决于链接器;然而,几乎所有现代链接器都允许你指定节对齐属性。有关详细信息,请参见链接器的文档。

关于设置节对齐,有一点需要注意:大多数情况下,链接器要求一个文件中的所有节都对齐到相同的边界(必须是 2 的幂次方)。因此,如果所有节的对齐要求不同,那么你需要选择最大的对齐值作为对象文件中所有节的对齐值。

4.8.4 在库模块中对齐节

如果你使用了许多短小的库例程,节对齐会对可执行文件的大小产生很大的影响。假设例如你为与库中的目标文件关联的节指定了 16 字节的对齐大小。链接器处理的每个库函数都会被放置在一个 16 字节的边界上。如果这些函数很小(长度小于 16 字节),那么当链接器创建最终的可执行文件时,函数之间的空间将不会被使用。这是另一种形式的内部碎片。

要理解为什么你可能希望在给定的边界上对代码(或数据)进行对齐,可以考虑缓存行是如何工作的(参见WGC1了解更多)。通过将函数的开始位置对齐到缓存行,你可能能够略微提高该函数的执行速度,因为它在执行过程中可能会产生更少的缓存未命中。因此,许多程序员喜欢将所有函数对齐到缓存行的起始位置。尽管缓存行的大小因 CPU 而异,但典型的缓存行长度为 16 到 64 字节,因此许多编译器、汇编器和链接器会尝试将代码和数据对齐到这些边界之一。在 80x86 处理器上,16 字节对齐有其他一些好处,因此许多基于 80x86 的工具默认将目标文件的节对齐到 16 字节。

例如,考虑以下由微软工具处理的短小 HLA(高级汇编)程序,它调用了两个相对较小的库例程:


			program t;
#include( "bits.hhf" )

begin t;

bits.cnt( 5 );
bits.reverse32( 10 );

end t;

Here is the source code to the bits.cnt library module:

unit bitsUnit;

#includeonce( "bits.hhf" );

    // bitCount-
    //
    //  Counts the number of "1" bits in a dword value.
    //  This function returns the dword count value in EAX.

    procedure bits.cnt( BitsToCnt:dword ); @nodisplay;

    const
        EveryOtherBit       := $5555_5555;
        EveryAlternatePair  := $3333_3333;
        EvenNibbles         := $0f0f_0f0f;

    begin cnt;

        push( edx );
        mov( BitsToCnt, eax );
        mov( eax, edx );

        // Compute sum of each pair of bits
        // in EAX. The algorithm treats
        // each pair of bits in EAX as a
        // 2-bit number and calculates the
        // number of bits as follows (description
        // is for bits 0 and 1, but it generalizes
        // to each pair):
        //
        //  EDX =   BIT1  BIT0
        //  EAX =      0  BIT1
        //
        //  EDX-EAX =   00 if both bits were 0.
        //              01 if Bit0 = 1 and Bit1 = 0.
        //              01 if Bit0 = 0 and Bit1 = 1.
        //              10 if Bit0 = 1 and Bit1 = 1.
        //
        // Note that the result is left in EDX.

        shr( 1, eax );
        and( EveryOtherBit, eax );
        sub( eax, edx );

        // Now sum up the groups of 2 bits to
        // produces sums of 4 bits. This works
        // as follows:
        //
        //  EDX = bits 2,3, 6,7, 10,11, 14,15, ..., 30,31
        //        in bit positions 0,1, 4,5, ..., 28,29 with
        //        0s in the other positions.
        //
        //  EAX = bits 0,1, 4,5, 8,9, ... 28,29 with 0s
        //        in the other positions.
        //
        //  EDX + EAX produces the sums of these pairs of bits.
        //  The sums consume bits 0,1,2, 4,5,6, 8,9,10, ...
        //                                            28,29,30
        //  in EAX with the remaining bits all containing 0.
        mov( edx, eax );
        shr( 2, edx );
        and( EveryAlternatePair, eax );
        and( EveryAlternatePair, edx );
        add( edx, eax );

        // Now compute the sums of the even and odd nibbles in
        // the number. Since bits 3, 7, 11, etc. in EAX all
        // contain 0 from the above calculation, we don't need
        // to AND anything first, just shift and add the two
        // values.
        // This computes the sum of the bits in the 4 bytes
        // as four separate values in EAX (AL contains number of
        // bits in original AL, AH contains number of bits in
        // original AH, etc.)

        mov( eax, edx );
        shr( 4, eax );
        add( edx, eax );
        and( EvenNibbles, eax );

        // Now for the tricky part.
        // We want to compute the sum of the 4 bytes
        // and return the result in EAX. The following
        // multiplication achieves this. It works
        // as follows:
        //  (1) the $01 component leaves bits 24..31
        //      in bits 24..31.
        //
        //  (2) the $100 component adds bits 17..23
        //      into bits 24..31.
        //
        //  (3) the $1_0000 component adds bits 8..15
        //      into bits 24..31.
        //
        //  (4) the $1000_0000 component adds bits 0..7
        //      into bits 24..31.
        //
        //  Bits 0..23 are filled with garbage, but bits
        //  24..31 contain the actual sum of the bits
        //  in EAX's original value. The SHR instruction
        //  moves this value into bits 0..7 and zeros
        //  out the HO bits of EAX.

        intmul( $0101_0101, eax );
        shr( 24, eax );

        pop( edx );

    end cnt;

end bitsUnit;

这是bits.reverse32()库函数的源代码。请注意,这个源文件还包括了bits.reverse16()bits.reverse8()函数(为了节省空间,这些函数的具体实现不在下面显示)。虽然它们的操作与我们的讨论无关,但请注意,这些函数交换了 HO(高位)和 LO(低位)比特位置的值。由于这三个函数位于同一个源文件中,任何包含其中一个函数的程序都会自动包含这三个函数(因为编译器、汇编器和链接器的工作方式)。


			unit bitsUnit;

#include( "bits.hhf" );

    procedure bits.reverse32( BitsToReverse:dword ); @nodisplay; @noframe;
    begin reverse32;

        push( ebx );
        mov( [esp+8], eax );

        // Swap the bytes in the numbers:

        bswap( eax );

        // Swap the nibbles in the numbers

        mov( $f0f0_f0f0, ebx );
        and( eax, ebx );
        and( $0f0f_0f0f, eax );
        shr( 4, ebx );
        shl( 4, eax );
        or( ebx, eax );

        // Swap each pair of 2 bits in the numbers:

        mov( eax, ebx );
        shr( 2, eax );
        shl( 2, ebx );
        and( $3333_3333, eax );
        and( $cccc_cccc, ebx );
        or( ebx, eax );

        // Swap every other bit in the number:

        lea( ebx, [eax + eax] );
        shr( 1, eax );
        and( $5555_5555, eax );
        and( $aaaa_aaaa, ebx );
        or( ebx, eax );
        pop( ebx );
        ret( 4 );
    end reverse32;

    procedure bits.reverse16( BitsToReverse:word );
        @nodisplay; @noframe;
    begin reverse16;

        // Uninteresting code that is very similar to
        // that appearing in reverse32 has been snipped...

    end reverse16;

    procedure bits.reverse8( BitsToReverse:byte );
        @nodisplay; @noframe;
    begin reverse8;

        // Uninteresting code snipped...

    end reverse8;

end bitsUnit;

微软的dumpbin.exe工具允许你检查.obj.exe文件的各个字段。使用dumpbin命令并加上/headers选项运行bitcnt.objreverse.obj文件(这些文件是为 HLA 标准库生成的),可以看到每个段都对齐到 16 字节边界。因此,当链接器将bitcnt.objreverse.obj的数据与前面给出的示例程序结合时,它将把bitcnt.obj文件中的bits.cnt()函数对齐到 16 字节边界,将reverse.obj文件中的三个函数对齐到 16 字节边界。(注意,它不会把文件中的每个函数都对齐到 16 字节边界。这项任务是创建对象文件的工具的责任,如果需要这种对齐的话。)通过使用dumpbin.exe程序并加上/disasm命令行选项运行可执行文件,你可以看到链接器已经遵循了这些对齐请求(注意,对齐到 16 字节边界的地址在低位十六进制数中会有一个0):


			  Address   opcodes            Assembly Instructions
  --------- ------------------ -----------------------------
  04001000: E9 EB 00 00 00     jmp         040010F0
  04001005: E9 57 01 00 00     jmp         04001161
  0400100A: E8 F1 00 00 00     call        04001100

; Here's where the main program starts.

  0400100F: 6A 00              push        0
  04001011: 8B EC              mov         ebp,esp
  04001013: 55                 push        ebp
  04001014: 6A 05              push        5
  04001016: E8 65 01 00 00     call        04001180
  0400101B: 6A 0A              push        0Ah
  0400101D: E8 0E 00 00 00     call        04001030
  04001022: 6A 00              push        0
  04001024: FF 15 00 20 00 04  call        dword ptr ds:[04002000h]

;The following INT3 instructions are used as padding in order
;to align the bits.reverse32 function (which immediately follows)
;to a 16-byte boundary:

  0400102A: CC                 int         3
  0400102B: CC                 int         3
  0400102C: CC                 int         3
  0400102D: CC                 int         3
  0400102E: CC                 int         3
  0400102F: CC                 int         3

; Here's where bits.reverse32 starts. Note that this address
; is rounded up to a 16-byte boundary.

  04001030: 53                 push        ebx
  04001031: 8B 44 24 08        mov         eax,dword ptr [esp+8]
  04001035: 0F C8              bswap       eax
  04001037: BB F0 F0 F0 F0     mov         ebx,0F0F0F0F0h
  0400103C: 23 D8              and         ebx,eax
  0400103E: 25 0F 0F 0F 0F     and         eax,0F0F0F0Fh
  04001043: C1 EB 04           shr         ebx,4
  04001046: C1 E0 04           shl         eax,4
  04001049: 0B C3              or          eax,ebx
  0400104B: 8B D8              mov         ebx,eax
  0400104D: C1 E8 02           shr         eax,2
  04001050: C1 E3 02           shl         ebx,2
  04001053: 25 33 33 33 33     and         eax,33333333h
  04001058: 81 E3 CC CC CC CC  and         ebx,0CCCCCCCCh
  0400105E: 0B C3              or          eax,ebx
  04001060: 8D 1C 00           lea         ebx,[eax+eax]
  04001063: D1 E8              shr         eax,1
  04001065: 25 55 55 55 55     and         eax,55555555h
  0400106A: 81 E3 AA AA AA AA  and         ebx,0AAAAAAAAh
  04001070: 0B C3              or          eax,ebx
  04001072: 5B                 pop         ebx
  04001073: C2 04 00           ret         4

; Here's where bits.reverse16 begins. As this function appeared
; in the same file as bits.reverse32, and no alignment option
; was specified in the source file, HLA and the linker won't
; bother aligning this to any particular boundary. Instead, the
; code immediately follows the bits.reverse32 function
; in memory.

  04001076: 53                 push        ebx
  04001077: 50                 push        eax
  04001078: 8B 44 24 0C        mov         eax,dword ptr [esp+0Ch]

        .
        .    ; uninteresting code for bits.reverse16 and
        .    ; bits.reverse8 was snipped
; end of bits.reverse8 code

  040010E6: 88 04 24           mov         byte ptr [esp],al
  040010E9: 58                 pop         eax
  040010EA: C2 04 00           ret         4

; More padding bytes to align the following function (used by
; HLA exception handling) to a 16-byte boundary:

  040010ED: CC                 int         3
  040010EE: CC                 int         3
  040010EF: CC                 int         3

; Default exception return function (automatically generated
; by HLA):

  040010F0: B8 01 00 00 00     mov         eax,1
  040010F5: C3                 ret

; More padding bytes to align the internal HLA BuildExcepts
; function to a 16-byte boundary:

  040010F6: CC                 int         3
  040010F7: CC                 int         3
  040010F8: CC                 int         3
  040010F9: CC                 int         3
  040010FA: CC                 int         3
  040010FB: CC                 int         3
  040010FC: CC                 int         3
  040010FD: CC                 int         3
  040010FE: CC                 int         3
  040010FF: CC                 int         3

; HLA BuildExcepts code (automatically generated by the
; compiler):

  04001100: 58                 pop         eax
  04001101: 68 05 10 00 04     push        4001005h
  04001106: 55                 push        ebp

        .
        .    ; Remainder of BuildExcepts code goes here
        .    ; along with some other code and data
        .

; Padding bytes to ensure that bits.cnt is aligned
; on a 16-byte boundary:

  0400117D: CC                 int         3
  0400117E: CC                 int         3
  0400117F: CC                 int         3

; Here's the low-level machine code for the bits.cnt function:

  04001180: 55                 push        ebp
  04001181: 8B EC              mov         ebp,esp
  04001183: 83 E4 FC           and         esp,0FFFFFFFCh
  04001186: 52                 push        edx
  04001187: 8B 45 08           mov         eax,dword ptr [ebp+8]
  0400118A: 8B D0              mov         edx,eax
  0400118C: D1 E8              shr         eax,1
  0400118E: 25 55 55 55 55     and         eax,55555555h
  04001193: 2B D0              sub         edx,eax
  04001195: 8B C2              mov         eax,edx
  04001197: C1 EA 02           shr         edx,2
  0400119A: 25 33 33 33 33     and         eax,33333333h
  0400119F: 81 E2 33 33 33 33  and         edx,33333333h
  040011A5: 03 C2              add         eax,edx
  040011A7: 8B D0              mov         edx,eax
  040011A9: C1 E8 04           shr         eax,4
  040011AC: 03 C2              add         eax,edx
  040011AE: 25 0F 0F 0F 0F     and         eax,0F0F0F0Fh
  040011B3: 69 C0 01 01 01 01  imul        eax,eax,1010101h
  040011B9: C1 E8 18           shr         eax,18h
  040011BC: 5A                 pop         edx
  040011BD: 8B E5              mov         esp,ebp
  040011BF: 5D                 pop         ebp
  040011C0: C2 04 00           ret         4

这个程序的具体操作其实并不重要(毕竟,它并没有做任何有用的事情)。要点是,链接器如何在源文件中一个或多个函数的前面插入额外的字节($cc,即int 3指令),以确保它们在指定的边界上对齐。

在这个特定的例子中,bits.cnt()函数实际上是 64 字节长,链接器仅插入了 3 个字节来将其对齐到 16 字节边界。这种浪费的百分比——填充字节与函数大小的比率——相当低。然而,如果有大量的小函数,浪费的空间可能会变得显著(就像这个例子中的默认异常处理程序,它只有两条指令)。在创建自己的库模块时,你需要权衡填充的额外空间带来的低效与通过使用对齐代码所获得的微小性能提升。

对象代码转储工具(如dumpbin.exe)对于分析目标代码和可执行文件非常有用,可以用来确定诸如段大小和对齐方式等属性。Linux(以及大多数类 Unix 系统)提供了类似的objdump工具。我将在下一章中讨论这些工具,因为它们对于分析编译器输出非常有用。

4.9 链接器如何影响代码

对象文件格式(如 COFF 和 ELF)的局限性对编译器生成的代码质量有很大影响。由于对象文件格式的设计方式,链接器和编译器通常需要将额外的代码插入到可执行文件中,而这些代码在其他情况下是不必要的。在本节中,我们将探讨像 COFF 和 ELF 这样的通用对象代码格式对可执行代码造成的一些问题。

像 COFF 和 ELF 这样的通用目标文件格式的一个问题是,它们并没有设计成为特定 CPU 生成高效的可执行文件。相反,它们是为了支持多种不同的 CPU,并使目标模块的链接变得容易。遗憾的是,它们的通用性往往使它们无法生成最佳的目标文件。

COFF 和 ELF 格式的最大问题之一是,目标文件中的重定位值必须适用于目标代码中的 32 位和 64 位指针。例如,当指令使用少于 32 位(或 64 位)的位移或地址值时,这就会产生问题。在某些处理器上,比如 80x86,位移小于 32 位的值非常小(例如,80x86 的 8 位位移),你永远不会用它们来引用当前目标模块外部的代码。然而,在一些 RISC 处理器上,比如 PowerPC 或 ARM,位移要大得多(以 PowerPC 的分支指令为例,位移为 26 位)。这可能导致像 GCC 为外部函数调用生成的函数存根一样的代码变通。考虑下面的 C 程序以及 GCC 为其生成的 PowerPC 代码:


			#include <stdio.h>
int main( int argc )
{
      .
      .
      .
    printf
    (
        "%d %d %d %d %d ",
        .
        .
        .
    );
    return( 0 );
}

; PowerPC assembly output from GCC:

            .
            .
            .
        ;The following sets up the
        ; call to printf and calls printf:

        addis r3,r31,ha16(LC0-L1$pb)
        la r3,lo16(LC0-L1$pb)(r3)
        lwz r4,64(r30)
        lwz r5,80(r30)
        lwz r6,1104(r30)
        lwz r7,1120(r30)
        lis r0,0x400
        ori r0,r0,1120
        lwzx r8,r30,r0
        bl L_printf$stub ; Call to printf "stub" routine.

        ;Return from main program:

        li r0,0
        mr r3,r0
        lwz r1,0(r1)
        lwz r0,8(r1)
        mtlr r0
        lmw r30,-8(r1)
        blr

; Stub, to call the external printf function.
; This code does an indirect jump to the printf
; function using the 32-bit L_printf$lazy_ptr
; pointer that the linker can modify.

        .data
        .picsymbol_stub
L_printf$stub:
        .indirect_symbol _printf
        mflr r0
        bcl 20,31,L0$_printf
L0$_printf:
        mflr r11
        addis r11,r11,ha16(L_printf$lazy_ptr-L0$_printf)
        mtlr r0
        lwz r12,lo16(L_printf$lazy_ptr-L0$_printf)(r11)
        mtctr r12
        addi r11,r11,lo16(L_printf$lazy_ptr-L0$_printf)
        bctr
.data
.lazy_symbol_pointer
L_printf$lazy_ptr:
        .indirect_symbol _printf

; The following is where the compiler places a 32-bit
; pointer that the linker can fill in with the address
; of the actual printf function:

        .long dyld_stub_binding_helper

编译器必须生成L_printf$stub存根,因为它不知道当链接器将printf()例程添加到最终可执行文件时,实际的printf()例程会离得多远。printf()不太可能超出 PowerPC 24 位分支位移支持的±32MB 范围(扩展到 26 位),但这并不能保证。如果printf()是一个在运行时动态链接的共享库的一部分,那么它很可能会超出这个范围。因此,编译器必须做出安全的选择,使用 32 位位移来表示printf()函数的地址。不幸的是,PowerPC 指令不支持 32 位位移,因为所有 PowerPC 指令都是 32 位长的。32 位位移将没有空间放置指令的操作码。因此,编译器必须在一个变量中存储指向printf()例程的 32 位指针,并通过该变量进行间接跳转。如果没有将指针的地址存储在寄存器中,在 PowerPC 上访问 32 位内存指针需要相当多的代码,因此在L_printf$stub标签后会有额外的代码。

如果链接器能够调整 26 位位移,而不仅仅是 32 位值,那么就不需要L_printf$stub例程或L_printf$lazy_ptr指针变量了。相反,bl L_printf$stub指令就可以直接跳转到printf()例程(假设它不超过±32MB)。因为单个程序文件通常不包含超过 32MB 的机器指令,所以通常不需要像这段代码那样做大量的处理才能调用外部例程。

不幸的是,你无法对目标文件格式做出任何改变;你只能使用操作系统指定的格式(在现代 32 位和 64 位机器上通常是 COFF 或 ELF 的变种)。然而,你可以在这些限制内进行工作。

如果你希望你的代码能够在像 PowerPC 或 ARM(或其他 RISC 处理器)这样的 CPU 上运行,而这些 CPU 无法在指令中直接编码 32 位偏移量,你可以通过尽量避免跨模块调用来进行优化。尽管将所有源代码都放在一个源文件中(或通过单次编译处理)来创建单体应用并不是一个好的编程实践,但实际上并不需要将所有自己的函数放在单独的源模块中,并分别编译它们——特别是当这些例程相互调用时。通过将一组你代码中使用的常见例程放入一个单一的编译单元(源文件),你可以让编译器优化这些函数之间的调用,并避免像 PowerPC 这样的处理器生成所有的存根代码。这并不是建议你简单地将所有外部函数放入一个源文件。只有当一个模块中的函数相互调用或共享其他全局对象时,代码才会更好。如果这些函数完全独立,并且只由编译单元外部的代码调用,那么你并没有节省任何东西,因为编译器仍然可能需要在外部代码中生成存根例程。

4.10 更多信息

Aho, Alfred V., Monica S. Lam, Ravi Sethi, 和 Jeffrey D. Ullman. 编译原理:技术与工具。第二版。Essex, UK: Pearson Education Limited, 1986。

Gircys, Gintaras. 理解和使用 COFF。Sebastopol, CA: O'Reilly Media, 1988。

Levine, John R. 链接器与加载器。San Diego: Academic Press, 2000。

第五章:分析编译器输出的工具

image

为了编写优秀的代码,你必须能够识别编程语言序列之间的差异,即那些完成工作相对充分的序列与那些优秀的序列之间的差异。在我们讨论的背景下,优秀的代码序列比平庸的代码序列使用更少的指令、更少的机器周期或更少的内存。如果你在汇编语言中工作,CPU 制造商的技术手册和一些实验就足以确定哪些代码序列是优秀的,哪些不是。然而,在使用高级语言时,你需要某种方式将程序中的高级语言语句映射到相应的机器代码,以便你能够确定这些高级语言语句的质量。在本章中,我们将讨论如何:

  • 查看并分析编译器的机器语言输出,以便你可以利用这些信息编写更好的高级语言代码

  • 告诉某些编译器生成易于人类阅读的汇编语言输出文件

  • 使用dumpbinobjdump等工具分析二进制目标输出文件

  • 使用反汇编器检查编译器生成的机器代码输出

  • 使用调试器分析编译器输出

  • 比较两个不同的汇编语言清单,以确定哪个版本更好

分析编译器输出是你区分优秀机器代码与仅仅足够的机器代码所需的主要技能之一。要分析编译器输出,你需要学习几件事情。首先,你需要学习足够的汇编语言编程,以便能够有效地阅读编译器输出。^(1) 其次,你需要学习如何告诉编译器(或其他工具)生成易于人类阅读的汇编语言输出。最后,你必须学会如何将汇编指令与高级语言代码进行关联。第三章和第四章为你提供了阅读基本汇编代码所需的基础。本章讨论如何将编译器输出转化为人类可读的形式。本书其余部分则讲解如何分析这些汇编代码,从而通过明智选择高级语言语句生成更好的机器代码。

让我们从一些编译器输出的背景和优化时需要注意的事项开始。

5.1 背景

正如第四章所讨论的,大多数现有编译器生成目标代码输出,链接程序读取并处理这些目标代码以生成可执行程序。由于目标代码文件通常由不可读的二进制数据组成,因此许多编译器还提供生成汇编语言版本的输出代码的选项。通过启用此选项,你可以分析编译器的输出,并在必要时相应地改进 HLL 源代码。事实上,使用特定的编译器并对其优化有深入了解时,你可以编写出几乎与最佳手写汇编语言代码一样高效的 HLL 源代码,经过编译后生成的机器代码几乎达到最佳水平。尽管不能期望这种优化在每个编译器上都能有效,但这种技巧使你能够编写出优秀的代码,在一个编译器上运行良好,并且仍然能够在其他处理器上运行(尽管可能效率较低)。这对于需要在某些特定机器上尽可能高效运行的代码,同时还需要在其他 CPU 上运行的情况,是一个很好的解决方案。

注意

请记住,检查编译器输出可能会导致你实现不可移植的优化。也就是说,当你检查编译器的输出时,你可能会决定修改 HLL 源代码以产生更好的输出;然而,这些优化可能不会在不同的编译器上生效。

生成汇编语言输出的能力是编译器特有的。有些编译器默认就会这样做。例如,GCC 总是生成一个汇编语言文件(尽管通常在编译后会删除该文件)。然而,大多数编译器必须显式地告诉它们生成汇编语言清单。有些编译器生成的汇编清单可以通过汇编程序处理生成目标代码。有些编译器可能只在清单文件中生成汇编注释,而该“汇编代码”与现有的任何汇编程序都不兼容。对于你的用途而言,不需要关心现实世界中的汇编程序是否能够处理编译器的汇编输出;你只需要阅读这些输出,以确定如何调整 HLL 代码,从而生成更好的目标代码。

对于那些能够生成汇编语言输出的编译器,汇编代码的可读性差异很大。有些编译器将原始的高级语言(HLL)源代码作为注释插入到汇编输出中,这使得将汇编指令与 HLL 代码关联变得容易。其他编译器(如 GCC)则输出纯汇编语言代码;因此,除非你对特定 CPU 的汇编语言非常熟悉,否则分析输出可能会很困难。

另一个可能影响编译器输出可读性的问题是你选择的优化级别。如果你禁用所有优化,通常更容易确定哪些汇编指令对应于高级语言语句。不幸的是,禁用优化后,大多数编译器会生成低质量的代码。如果查看编译器汇编输出的目的是选择更好的高级语言序列,那么你必须指定与生产版本应用程序相同的优化级别。你绝不应该调整高级语言代码,以便在某一优化级别下生成更好的汇编代码,然后再为生产代码更改优化级别。如果你这样做,你可能会做一些优化器通常会为你做的额外工作。更糟糕的是,这些手动优化可能实际上会妨碍编译器在你提高优化级别时完成良好的工作。

当你为编译器指定更高的优化级别时,编译器通常会在汇编输出文件中移动代码,完全消除代码,或进行其他代码转换,这些操作使得高级语言代码与汇编输出之间的对应关系变得模糊。然而,通过一些实践,你仍然可以确定哪些机器指令对应于高级语言代码中的某个语句。

5.2 告诉编译器生成汇编输出

如何告诉编译器生成汇编语言输出文件是特定于编译器的。有关该信息,你需要查阅特定编译器的文档。本节将讨论两个常用的 C/C++ 编译器:GCC 和微软的 Visual C++。

5.2.1 来自 GNU 编译器的汇编输出

要使用 GCC 编译器生成汇编输出,你需要在命令行中调用编译器时指定 -S 选项。以下是 GCC 的示例命令行:

gcc -O2 -S t1.c     # -O2 option is for optimization

当提供给 GCC 时,-S 选项实际上并不会告诉编译器生成汇编输出文件。GCC 总是 会生成汇编输出文件。-S 只是告诉 GCC 在生成汇编文件后停止所有处理。GCC 会生成一个汇编输出文件,其根文件名与原始 C 文件相同(在这些示例中是 t1),并带有 .s 后缀(在正常编译过程中,GCC 会在汇编后删除 .s 文件)。

5.2.2 来自 Visual C++ 的汇编输出

Visual C++ 编译器(VC++)使用 -FA 命令行选项来指定与 MASM 兼容的汇编语言输出。以下是告诉 VC++ 生成汇编清单的典型命令行:

cl -O2 -FA t1.c

5.2.3 示例汇编语言输出

作为生成编译器汇编语言输出的示例,考虑以下 C 程序:


			#include <stdio.h>
int main( int argc, char **argv )
{
    int i;
    int j;

    i = argc;
    j = **argv;

    if( i == 2 )
    {
        ++j;
    }
    else
    {
        --j;
    }

    printf( "i=%d, j=%d\n", i, j );
    return 0;
}

以下各小节提供了来自 Visual C++ 和 GCC 的编译器输出,以突出它们各自的汇编语言清单之间的差异。

5.2.3.1 Visual C++ 汇编语言输出

使用命令行通过 VC++ 编译此文件

cl -Fa -O1 t1.c

产生以下 (MASM) 汇编语言输出。

注意

此输出中每个汇编语言语句的具体含义暂时不重要——重要的是看到该列表与接下来章节中 Visual C++ 和 Gas 的语法之间的区别。


			; Listing generated by Microsoft (R) Optimizing
; Compiler Version 19.00.24234.1
; This listing is manually annotated for readability.

include listing.inc

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

PUBLIC  __local_stdio_printf_options
PUBLIC  _vfprintf_l
PUBLIC  printf
PUBLIC  main
PUBLIC  ??_C@_0M@MJLDLLNK@i?$DN?$CFd?0?5j?$DN?$CFd?6?$AA@ ; `string'
EXTRN   __acrt_iob_func:PROC
EXTRN   __stdio_common_vfprintf:PROC
_DATA   SEGMENT
COMM    ?_OptionsStorage@?1??__local_stdio_printf_options@@9@9:QWORD                                                    ; `__local_stdio_printf_options'::`2'::_OptionsStorage
_DATA   ENDS
;       COMDAT pdata
pdata   SEGMENT
    .
    .
    .
;       COMDAT main
_TEXT   SEGMENT
argc$ = 48
argv$ = 56
main    PROC                                            ; COMDAT

$LN6:
        sub     rsp, 40                                 ; 00000028H

; if( i == 2 )
;{
;    ++j;
;}
;else
;{
;    --j
;}

        mov     rax, QWORD PTR [rdx]   ; rax (i) = *argc
        cmp     ecx, 2
        movsx   edx, BYTE PTR [rax]    ; rdx(j) = **argv

        lea     eax, DWORD PTR [rdx-1] ; rax = ++j
        lea     r8d, DWORD PTR [rdx+1] ; r8d = --j;

        mov     edx, ecx               ; edx = argc (argc was passed in rcx)
        cmovne  r8d, eax               ; eax = --j if i != 2

; printf( "i=%d, j+5d\n", i, j ); (i in edx, j in eax)

        lea     rcx, OFFSET FLAT:??_C@_0M@MJLDLLNK@i?$DN?$CFd?0?5j?$DN?$CFd?6?$AA@
        call    printf

; return 0;

        xor     eax, eax

        add     rsp, 40                                 ; 00000028H
        ret     0
main    ENDP
_TEXT   ENDS
; Function compile flags: /Ogtpy
; File c:\program files (x86)\windows kits\10\include\10.0.17134.0\ucrt\stdio.h
;       COMDAT printf
_TEXT   SEGMENT
    .
    .
    .
    END
5.2.3.2 GCC 汇编语言输出 (PowerPC)

与 Visual C++ 类似,GCC 并不会将 C 源代码插入汇编输出文件。在 GCC 的情况下,这有一定的理解空间:生成汇编输出是它总是做的事情(而不是用户请求后才做的事情)。通过不将 C 源代码插入输出文件,GCC 可以稍微减少编译时间(因为编译器不需要写入 C 源代码数据,汇编器也不需要读取这些数据)。以下是使用命令行 gcc -O1 -S t1.c 时,GCC 为 PowerPC 处理器生成的输出:


			gcc -O1 -S t1.c

.data
.cstring
        .align 2
LC0:
        .ascii "i=%d, j=%d\12\0"
.text
        .align 2
        .globl _main
_main:
LFB1:
        mflr r0
        stw r31,-4(r1)
LCFI0:
        stw r0,8(r1)
LCFI1:
        stwu r1,-80(r1)
LCFI2:
        bcl 20,31,L1$pb
L1$pb:
        mflr r31
        mr r11,r3
        lwz r9,0(r4)
        lbz r0,0(r9)
        extsb r5,r0
        cmpwi cr0,r3,2
        bne+ cr0,L2
        addi r5,r5,1
        b L3
L2:
        addi r5,r5,-1
L3:
        addis r3,r31,ha16(LC0-L1$pb)
        la r3,lo16(LC0-L1$pb)(r3)
        mr r4,r11
        bl L_printf$stub
        li r3,0
        lwz r0,88(r1)
        addi r1,r1,80
        mtlr r0
        lwz r31,-4(r1)
        blr
LFE1:
.data
.picsymbol_stub
L_printf$stub:
        .indirect_symbol _printf
        mflr r0
        bcl 20,31,L0$_printf
L0$_printf:
        mflr r11
        addis r11,r11,ha16(L_printf$lazy_ptr-L0$_printf)
        mtlr r0
        lwz r12,lo16(L_printf$lazy_ptr-L0$_printf)(r11)
        mtctr r12
        addi r11,r11,lo16(L_printf$lazy_ptr-L0$_printf)
        bctr
.data
.lazy_symbol_pointer
L_printf$lazy_ptr:
        .indirect_symbol _printf
        .long dyld_stub_binding_helper
.data
.constructor
.data
.destructor
        .align 1

如你所见,GCC 的输出相当简洁。当然,因为这是 PowerPC 汇编语言,与 Visual C++ 编译器的 80x86 输出进行比较并不实际。

5.2.3.3 GCC 汇编语言输出 (80x86)

以下代码提供了 GCC 编译到 x86-64 汇编代码的 t1.c 源文件:


			      .section    __TEXT,__text,regular,pure_instructions
    .macosx_version_min 10, 13
    .globl  _main                   ## -- Begin function main
    .p2align    4, 0x90
_main:                              ## @main
    .cfi_startproc
## BB#0:
    pushq   %rbp
Lcfi0:
    .cfi_def_cfa_offset 16
Lcfi1:
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
Lcfi2:
    .cfi_def_cfa_register %rbp
    movl    %edi, %ecx
    movq    (%rsi), %rax
    movsbl  (%rax), %eax
    cmpl    $2, %ecx
    movl    $1, %esi
    movl    $-1, %edx
    cmovel  %esi, %edx
    addl    %eax, %edx
    leaq    L_.str(%rip), %rdi
    xorl    %eax, %eax
    movl    %ecx, %esi
    callq   _printf
    xorl    %eax, %eax
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function
    .section    __TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
    .asciz  "i=%d, j=%d\n"

.subsections_via_symbols

这个例子应该能帮助展示,GCC 为 PowerPC 生成的大量代码更多的是机器架构的作用,而不是编译器本身的原因。如果将其与其他编译器生成的代码进行比较,你会发现它大致相当。

5.2.3.4 GCC 汇编语言输出 (ARMv7)

以下代码提供了 GCC 编译到 ARMv6 汇编代码的 t1.c 源文件,编译环境为 Raspberry Pi(运行 32 位 Raspian):


			..arch armv6
    .eabi_attribute 27, 3
    .eabi_attribute 28, 1
    .fpu vfp
    .eabi_attribute 20, 1
    .eabi_attribute 21, 1
    .eabi_attribute 23, 3
    .eabi_attribute 24, 1
    .eabi_attribute 25, 1
    .eabi_attribute 26, 2
    .eabi_attribute 30, 2
    .eabi_attribute 34, 1
    .eabi_attribute 18, 4
    .file   "t1.c"
    .section    .text.startup,"ax",%progbits
    .align  2
    .global main
    .type   main, %function
main:
    @ args = 0, pretend = 0, frame = 0
    @ frame_needed = 0, uses_anonymous_args = 0
    stmfd   sp!, {r3, lr}
    cmp r0, #2
    ldr r3, [r1]
    mov r1, r0
    ldr r0, .L5
    ldrb    r2, [r3]    @ zero_extendqisi2
    addeq   r2, r2, #1
    subne   r2, r2, #1
    bl  printf
    mov r0, #0
    ldmfd   sp!, {r3, pc}
.L6:
    .align  2
.L5:
    .word   .LC0
    .size   main, .-main
    .section    .rodata.str1.4,"aMS",%progbits,1
    .align  2
.LC0:
    .ascii  "i=%d, j=%d\012\000"
    .ident  "GCC: (Raspbian 4.9.2-10) 4.9.2"
    .section    .note.GNU-stack,"",%progbits

请注意,@ 表示此源代码中的注释;Gas 会忽略从 @ 到行末的所有内容。

5.2.3.5 Swift 汇编语言输出 (x86-64)

给定一个 Swift 源文件 main.swift,你可以使用以下命令请求来自 macOS Swift 编译器的汇编语言输出文件:

swiftc -O -emit-assembly main.swift -o result.asm

这将生成 result.asm 输出的汇编语言文件。考虑以下 Swift 源代码:


			import Foundation

var i:Int = 0;
var j:Int = 1;

    if( i == 2 )
    {
        i = i + 1
    }
    else
    {
        i = i - 1
    }

    print( "i=\(i), j=\(j)" )

使用之前的命令行编译此文件会生成一个相当长的汇编语言输出文件;以下是该代码中的主要过程:


			_main:
.cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    pushq   %r15
    pushq   %r14
    pushq   %r13
    pushq   %r12
    pushq   %rbx
    pushq   %rax
    .cfi_offset %rbx, -56
    .cfi_offset %r12, -48
    .cfi_offset %r13, -40
    .cfi_offset %r14, -32
    .cfi_offset %r15, -24
    movq    $1, _$S6result1jSivp(%rip)
    movq    $-1, _$S6result1iSivp(%rip)
    movq    _$Ss23_ContiguousArrayStorageCyypGML(%rip), %rdi
    testq   %rdi, %rdi
    jne LBB0_3
    movq    _$SypN@GOTPCREL(%rip), %rsi
    addq    $8, %rsi
    xorl    %edi, %edi
    callq   _$Ss23_ContiguousArrayStorageCMa
    movq    %rax, %rdi
    testq   %rdx, %rdx
    jne LBB0_3
    movq    %rdi, _$Ss23_ContiguousArrayStorageCyypGML(%rip)
LBB0_3:
    movabsq $8589934584, %r12
    movl    48(%rdi), %esi
    movzwl  52(%rdi), %edx
    addq    $7, %rsi
    andq    %r12, %rsi
    addq    $32, %rsi
    orq $7, %rdx
    callq   _swift_allocObject
    movq    %rax, %r14
    movq    _$Ss27_ContiguousArrayStorageBaseC16countAndCapacitys01_B4BodyVvpWvd@GOTPCREL(%rip), %rbx
    movq    (%rbx), %r15
    movaps  LCPI0_0(%rip), %xmm0
    movups  %xmm0, (%r14,%r15)
    movq    _$SSSN@GOTPCREL(%rip), %rax
    movq    %rax, 56(%r14)
    movq    _$Ss23_ContiguousArrayStorageCySSGML(%rip), %rdi
    testq   %rdi, %rdi
    jne LBB0_6
    movq    _$SSSN@GOTPCREL(%rip), %rsi
    xorl    %edi, %edi
    callq   _$Ss23_ContiguousArrayStorageCMa
    movq    %rax, %rdi
    testq   %rdx, %rdx
    jne LBB0_6
    movq    %rdi, _$Ss23_ContiguousArrayStorageCySSGML(%rip)
    movq    (%rbx), %r15
LBB0_6:
    movl    48(%rdi), %esi
    movzwl  52(%rdi), %edx
    addq    $7, %rsi
    andq    %r12, %rsi
    addq    $80, %rsi
    orq $7, %rdx
    callq   _swift_allocObject
    movq    %rax, %rbx
    movaps  LCPI0_1(%rip), %xmm0
    movups  %xmm0, (%rbx,%r15)
    movabsq $-2161727821137838080, %r15
    movq    %r15, %rdi
    callq   _swift_bridgeObjectRetain
    movl    $15721, %esi
    movq    %r15, %rdi
    callq   _$Ss27_toStringReadOnlyStreamableySSxs010TextOutputE0RzlFSS_Tg5Tf4x_n
    movq    %rax, %r12
    movq    %rdx, %r13
    movq    %r15, %rdi
    callq   _swift_bridgeObjectRelease
    movq    %r12, 32(%rbx)
    movq    %r13, 40(%rbx)
    movq    _$S6result1iSivp(%rip), %rdi
    callq   _$Ss26_toStringReadOnlyPrintableySSxs06CustomB11ConvertibleRzlFSi_Tg5
    movq    %rax, 48(%rbx)
    movq    %rdx, 56(%rbx)
    movabsq $-2017612633061982208, %r15
    movq    %r15, %rdi
    callq   _swift_bridgeObjectRetain
    movl    $1030365228, %esi
    movq    %r15, %rdi
    callq   _$Ss27_toStringReadOnlyStreamableySSxs010TextOutputE0RzlFSS_Tg5Tf4x_n
    movq    %rax, %r12
    movq    %rdx, %r13
    movq    %r15, %rdi
    callq   _swift_bridgeObjectRelease
    movq    %r12, 64(%rbx)
    movq    %r13, 72(%rbx)
    movq    _$S6result1jSivp(%rip), %rdi
    callq   _$Ss26_toStringReadOnlyPrintableySSxs06CustomB11ConvertibleRzlFSi_Tg5
    movq    %rax, 80(%rbx)
    movq    %rdx, 88(%rbx)
    movabsq $-2305843009213693952, %r15
    movq    %r15, %rdi
    callq   _swift_bridgeObjectRetain
    xorl    %esi, %esi
    movq    %r15, %rdi
    callq   _$Ss27_toStringReadOnlyStreamableySSxs010TextOutputE0RzlFSS_Tg5Tf4x_n
    movq    %rax, %r12
    movq    %rdx, %r13
    movq    %r15, %rdi
    callq   _swift_bridgeObjectRelease
    movq    %r12, 96(%rbx)
    movq    %r13, 104(%rbx)
    movq    %rbx, %rdi
    callq   _$SSS19stringInterpolationS2Sd_tcfCTf4nd_n
    movq    %rax, 32(%r14)
    movq    %rdx, 40(%r14)
    callq   _$Ss5print_9separator10terminatoryypd_S2StFfA0_
    movq    %rax, %r12
    movq    %rdx, %r15
    callq   _$Ss5print_9separator10terminatoryypd_S2StFfA1_
    movq    %rax, %rbx
    movq    %rdx, %rax
    movq    %r14, %rdi
    movq    %r12, %rsi
    movq    %r15, %rdx
    movq    %rbx, %rcx
    movq    %rax, %r8
    callq   _$Ss5print_9separator10terminatoryypd_S2StF
    movq    %r14, %rdi
    callq   _swift_release
    movq    %r12, %rdi
    callq   _swift_bridgeObjectRelease
    movq    %rbx, %rdi
    callq   _swift_bridgeObjectRelease
    xorl    %eax, %eax
    addq    $8, %rsp
    popq    %rbx
    popq    %r12
    popq    %r13
    popq    %r14
    popq    %r15
    popq    %rbp
    retq
    .cfi_endproc

如你所见,与 C++ 相比,Swift 生成的代码并不特别优化。实际上,为了节省空间,这里省略了数百行额外的代码。

5.2.4 汇编输出分析

除非你熟练掌握汇编语言编程,否则分析汇编输出可能会很棘手。如果你不是汇编语言程序员,最好的做法是数一数指令,并假设如果某个编译器选项(或者对你的高级语言源代码进行重组)产生了更少的指令,结果应该是更好的。但实际上,这个假设并不总是正确的。一些机器指令(特别是在 CISC 处理器上,如 80x86)执行时间比其他指令要长得多。在像 80x86 这样的处理器上,三条或更多指令的序列可能比执行相同操作的单条指令更快。幸运的是,编译器通常不会基于你高级源代码的重组而生成这两种序列。因此,检查汇编输出时,你通常不需要担心这些问题。

请注意,如果你更改优化级别,一些编译器会生成两种不同的指令序列。这是因为某些优化设置会告诉编译器偏向于生成更短的程序,而其他优化设置则会告诉编译器偏向于生成更快的执行速度。偏向于生成更小可执行文件的优化设置可能会选择单条指令,而不是执行相同操作的三条指令(假设这三条指令编译成更多的代码);偏向于速度的优化设置可能会选择执行速度更快的指令序列。

本节使用了各种 C/C++编译器作为示例,但你应当记住,其他语言的编译器也提供了生成汇编代码的功能。你需要查看编译器的文档,了解是否支持此功能,以及可以使用哪些选项生成汇编输出。一些编译器(例如 Visual C++)提供了集成开发环境(IDE),你可以使用它替代命令行工具。尽管大多数通过 IDE 工作的编译器也能通过命令行工作,但你通常可以在 IDE 内以及通过命令行指定汇编输出。再次提醒,查看编译器厂商的文档获取详细信息。

5.3 使用目标代码工具分析编译器输出

尽管许多编译器提供了发出汇编语言而不是目标代码的选项,但许多编译器并不提供;它们只能将二进制机器代码输出到目标代码文件。分析这种编译器输出会稍微复杂一些,并且需要一些专用的工具。如果你的编译器生成目标代码文件(如 PE/COFF 或 ELF 文件)以供链接器使用,你可能会找到一个“目标代码转储”工具,这对分析编译器的输出非常有用。例如,微软提供了dumpbin.exe程序,FSF/GNU 的dumpobj程序也具有类似的功能,用于 Linux 及其他操作系统下的 ELF 文件。在接下来的子节中,我们将了解在分析编译器输出时如何使用这两个工具。

使用目标文件的一个优点是它们通常包含符号信息。也就是说,除了二进制机器代码外,目标文件还包含指定标识符名称的字符串,这些名称出现在源文件中(这些信息通常不会出现在可执行文件中)。目标代码工具通常可以显示这些符号名称,在引用与这些符号关联的内存位置的机器指令中。尽管这些目标代码工具不能自动将高级语言源代码与机器代码相关联,但有符号信息可用时,当你研究它们的输出时会更加有帮助,因为像JumpTable这样的名称比像$401_1000这样的内存地址更容易理解。

5.3.1 Microsoft dumpbin.exe 工具

微软的dumpbin命令行工具允许你检查微软 PE/COFF 文件的内容。^(2)你可以按照以下方式运行该程序:


			dumpbin options filename

文件名参数是你希望检查的.obj文件的名称,选项参数是一组可选的命令行参数,用于指定你希望显示的信息类型。这些选项每个都以斜杠(/)开头。接下来我们将查看每个可能的选项。首先,这是通过/?命令行选项获得的可能对象的列表:


			Microsoft (R) COFF/PE Dumper Version 14.00.24234.1
Copyright (C) Microsoft Corporation.  All rights reserved.

usage: dumpbin options files

   options:

      /ALL
      /ARCHIVEMEMBERS
      /CLRHEADER
      /DEPENDENTS
      /DIRECTIVES
      /DISASM[:{BYTES|NOBYTES}]
      /ERRORREPORT:{NONE|PROMPT|QUEUE|SEND}
      /EXPORTS
      /FPO
      /HEADERS
      /IMPORTS[:filename]
      /LINENUMBERS
      /LINKERMEMBER[:{1|2}]
      /LOADCONFIG
      /NOLOGO
      /OUT:filename
      /PDATA
      /PDBPATH[:VERBOSE]
      /RANGE:vaMin[,vaMax]
      /RAWDATA[:{NONE|1|2|4|8}[,#]]
      /RELOCATIONS
      /SECTION:name
      /SUMMARY
      /SYMBOLS
      /TLS
      /UNWINDINFO

尽管dumpbin的主要用途是查看编译器生成的目标代码,但它也会显示关于 PE/COFF 文件的相当多的有趣信息。关于许多dumpbin命令行选项的含义,更多信息请参阅第 71 页的“目标文件格式”或第 80 页的“可执行文件格式”。

以下子节描述了几种可能的dumpbin命令行选项,并为用 C 语言编写的简单“Hello World”程序提供了示例输出。


			#include <stdio.h>

int main( int argc, char **argv)
{
    printf( "Hello World\n" );
}
5.3.1.1 /all

/all 命令行选项指示 dumpbin 显示它能提供的所有信息,除了对象文件中的代码反汇编。这个方法的问题在于,.exe 文件包含了链接器将语言标准库(例如 C 标准库)合并到应用程序中的所有例程。当分析编译器输出以改善应用程序的代码时,浏览这些关于程序外部代码的额外信息可能会很繁琐。幸运的是,有一个简单的方法可以减少不必要的信息——对对象文件(.obj)而非可执行文件(.exe)运行 dumpbin。以下是 dumpbin 对“Hello World”示例产生的(简化)输出:


			G:\>dumpbin /all hw.obj
Microsoft (R) COFF/PE Dumper Version 14.00.24234.1
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file hw.obj

File Type: COFF OBJECT

FILE HEADER VALUES
            8664 machine (x64)
               D number of sections
        5B2C175F time date stamp Thu Jun 21 14:23:43 2018
             466 file pointer to symbol table
              2D number of symbols
               0 size of optional header
               0 characteristics

SECTION HEADER #1
.drectve name
       0 physical address
       0 virtual address
      2F size of raw data
     21C file pointer to raw data (0000021C to 0000024A)
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
  100A00 flags
         Info
         Remove
         1 byte align

Hundreds of lines deleted...

  Summary

           D .data
          70 .debug$S
          2F .drectve
          24 .pdata
          C2 .text$mn
          18 .xdata

这个示例删除了大部分输出内容(以避免你阅读多达十几页的额外信息)。你可以尝试自己执行 /all 命令,看看你会得到多少输出。然而,总的来说,使用这个选项时要小心。

5.3.1.2 /disasm

/disasm 命令行选项是我们最感兴趣的选项。它会生成对象文件的反汇编清单。与 /all 选项一样,你不应该尝试使用 dumpbin 反汇编 .exe 文件。你得到的反汇编清单可能会非常长,而且大多数代码可能只是你的应用程序调用的所有库例程的列表。例如,简单的“Hello World”应用程序会生成超过 5000 行的反汇编代码。除了极少数语句之外,所有这些语句都对应于库例程。浏览这么多代码对大多数人来说是压倒性的。

然而,如果你对 hw.obj 文件进行反汇编,而不是可执行文件,通常会得到以下输出:


			Microsoft (R) COFF/PE Dumper Version 14.00.24234.1
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file hw.obj

File Type: COFF OBJECT

main:
  0000000000000000: 48 89 54 24 10     mov         qword ptr [rsp+10h],rdx
  0000000000000005: 89 4C 24 08        mov         dword ptr [rsp+8],ecx
  0000000000000009: 48 83 EC 28        sub         rsp,28h
  000000000000000D: 48 8D 0D 00 00 00  lea         rcx,[$SG4247]
                    00
  0000000000000014: E8 00 00 00 00     call        printf
  0000000000000019: 33 C0              xor         eax,eax
  000000000000001B: 48 83 C4 28        add         rsp,28h
  000000000000001F: C3                 ret

// Uninterested code emitted by dumpbin.exe left out...

  Summary

           D .data
          70 .debug$S
          2F .drectve
          24 .pdata
          C2 .text$mn
          28 .xdata

如果你仔细查看这个反汇编代码,你会发现反汇编对象文件而不是可执行文件的主要问题——代码中的大多数地址都是可重定位地址,在对象代码列表中显示为 $00000000。因此,你可能很难弄清楚各个汇编语句的作用。例如,在 hw.obj 的反汇编清单中,你会看到以下两条语句:


			000000000000000D:  48 8D 0D 00 00 00  lea         rcx,[$SG4247]
                   00
0000000000000014:  E8 00 00 00 00     call        printf

lea 指令操作码是 3 字节序列 48 8D 0D(其中包括一个 REX 操作码前缀字节)。"Hello World" 字符串的地址不是 00 00 00 00(操作码后面的 4 字节);而是一个可重定位地址,链接器/系统稍后会填充这个地址。如果你在 hw.obj 上运行带有 /all 命令行选项的 dumpbin,你会注意到这个文件有两个重定位条目:


			RELOCATIONS #4
                                                Symbol    Symbol
 Offset    Type              Applied To         Index     Name
 --------  ----------------  -----------------  --------  ------
 00000010  REL32                      00000000         8  $SG4247
 00000015  REL32                      00000000        15  printf

偏移列告诉你将在文件中应用重定位的字节偏移量。在前面的反汇编中,注意到lea指令从偏移量$d开始,因此实际的位移在偏移量$10处。类似地,call指令从偏移量$14开始,因此需要修补的实际例程的地址在 1 字节之后,位于偏移量$15。通过dumpbin输出的重定位信息,你可以辨认出与这些重定位相关的符号。($SG4247是 C 编译器为"Hello World"字符串生成的内部符号。而printf显然是与 C 语言的printf()函数相关联的名称。)

交叉引用每个调用和内存引用与重定位列表可能看起来很麻烦,但至少你在这样做时会获得符号名称。

考虑在对hw.exe文件应用/disasm选项时,反汇编代码的前几行:


			0000000140001009: 48 83 EC 28        sub         rsp,28h
000000014000100D: 48 8D 0D EC DF 01  lea         rcx,[000000014001F000h]
                  00
0000000140001014: E8 67 00 00 00     call        0000000140001080
0000000140001019: 33 C0              xor         eax,eax
000000014000101B: 48 83 C4 28        add         rsp,28h
000000014000101F: C3                 ret
                              .
                              .
                              .

请注意,链接器已经填充了偏移量$SG4247print标签的地址(相对于文件的加载地址)。这看起来可能有点方便;然而,注意到这些标签(特别是printf标签)不再出现在文件中。当你阅读反汇编输出时,缺少这些标签可能会使得很难搞清楚哪些机器指令对应 HLL 语句。这是你在运行dumpbin时应该使用目标文件而非可执行文件的又一个原因。

如果你觉得阅读dumpbin工具的反汇编输出会非常麻烦,不必担心:为了优化目的,你通常更关注的是 HLL 程序两个版本之间的代码差异,而不是搞清楚每条机器指令的作用。因此,你可以通过对两个版本的目标文件(一个是在 HLL 代码更改之前,另一个是在更改之后生成的)运行dumpbin,轻松确定哪些机器指令受到代码更改的影响。例如,考虑对“Hello World”程序的以下修改:


			#include <stdio.h>

int main( int argc, char **argv)
{
        char *hwstr = "Hello World\n";

        printf( hwstr );
}

这是dumpbin生成的反汇编输出:


			Microsoft (R) COFF Binary File Dumper Version 6.00.8168
  0000000140001000: 48 89 54 24 10     mov         qword ptr [rsp+10h],rdx
  0000000140001005: 89 4C 24 08        mov         dword ptr [rsp+8],ecx
  0000000140001009: 48 83 EC 28        sub         rsp,28h
  000000014000100D: 48 8D 0D EC DF 01  lea         rcx,[000000014001F000h]
                    00
  0000000140001014: E8 67 00 00 00     call        0000000140001080
  0000000140001019: 33 C0              xor         eax,eax
  000000014000101B: 48 83 C4 28        add         rsp,28h
  000000014000101F: C3                 ret

通过将此输出与之前的汇编输出进行比较(无论是手动比较还是使用基于 Unix 的diff工具运行程序),你可以看到对 HLL 源代码的更改对生成的机器代码的影响。

注意

第 137 页的“比较两个编译输出”部分讨论了两种比较方法(手动比较和基于diff的方法)的优缺点。

5.3.1.3 /headers

/headers选项指示dumpbin显示 COFF 头文件和节头文件。/all选项也会打印这些信息,但/header仅显示头部信息,而不包括所有其他输出。以下是“Hello World”可执行文件的示例输出:


			G:\WGC>dumpbin /headers hw.exe
Microsoft (R) COFF/PE Dumper Version 14.00.24234.1
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file hw.exe

PE signature found

File Type: EXECUTABLE IMAGE

FILE HEADER VALUES
            8664 machine (x64)
               6 number of sections
        5B2C1A9F time date stamp Thu Jun 21 14:37:35 2018
               0 file pointer to symbol table
               0 number of symbols
              F0 size of optional header
              22 characteristics
                   Executable
                   Application can handle large (>2GB) addresses

OPTIONAL HEADER VALUES
             20B magic # (PE32+)
           14.00 linker version
           13400 size of code
            D600 size of initialized data
               0 size of uninitialized data
            1348 entry point (0000000140001348)
            1000 base of code
       140000000 image base (0000000140000000 to 0000000140024FFF)
            1000 section alignment
             200 file alignment
            6.00 operating system version
            0.00 image version
            6.00 subsystem version
               0 Win32 version
           25000 size of image
             400 size of headers
               0 checksum
               3 subsystem (Windows CUI)
            8160 DLL characteristics
                   High Entropy Virtual Addresses
                   Dynamic base
                   NX compatible
                   Terminal Server Aware
          100000 size of stack reserve
            1000 size of stack commit
          100000 size of heap reserve
            1000 size of heap commit
               0 loader flags
              10 number of directories
               0 [       0] RVA [size] of Export Directory
           1E324 [      28] RVA [size] of Import Directory
               0 [       0] RVA [size] of Resource Directory
           21000 [    126C] RVA [size] of Exception Directory
               0 [       0] RVA [size] of Certificates Directory
           24000 [     620] RVA [size] of Base Relocation Directory
           1CDA0 [      1C] RVA [size] of Debug Directory
               0 [       0] RVA [size] of Architecture Directory
               0 [       0] RVA [size] of Global Pointer Directory
               0 [       0] RVA [size] of Thread Storage Directory
           1CDC0 [      94] RVA [size] of Load Configuration Directory
               0 [       0] RVA [size] of Bound Import Directory
           15000 [     230] RVA [size] of Import Address Table Directory
               0 [       0] RVA [size] of Delay Import Directory
               0 [       0] RVA [size] of COM Descriptor Directory
               0 [       0] RVA [size] of Reserved Directory

SECTION HEADER #1
   .text name
   1329A virtual size
    1000 virtual address (0000000140001000 to 0000000140014299)
   13400 size of raw data
     400 file pointer to raw data (00000400 to 000137FF)
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
60000020 flags
         Code
         Execute Read

SECTION HEADER #2
  .rdata name
    9A9A virtual size
   15000 virtual address (0000000140015000 to 000000014001EA99)
    9C00 size of raw data
   13800 file pointer to raw data (00013800 to 0001D3FF)
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         Read Only

  Debug Directories
        Time Type        Size      RVA  Pointer
    -------- ------- -------- -------- --------
    5B2C1A9F coffgrp      2CC 0001CFC4    1B7C4

SECTION HEADER #3
   .data name
    1BA8 virtual size
   1F000 virtual address (000000014001F000 to 0000000140020BA7)
     A00 size of raw data
   1D400 file pointer to raw data (0001D400 to 0001DDFF)
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
C0000040 flags
         Initialized Data
         Read Write

SECTION HEADER #4
  .pdata name
    126C virtual size
   21000 virtual address (0000000140021000 to 000000014002226B)
    1400 size of raw data
   1DE00 file pointer to raw data (0001DE00 to 0001F1FF)
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         Read Only

SECTION HEADER #5
  .gfids name
      D4 virtual size
   23000 virtual address (0000000140023000 to 00000001400230D3)
     200 size of raw data
   1F200 file pointer to raw data (0001F200 to 0001F3FF)
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         Read Only

SECTION HEADER #6
  .reloc name
     620 virtual size
   24000 virtual address (0000000140024000 to 000000014002461F)
     800 size of raw data
   1F400 file pointer to raw data (0001F400 to 0001FBFF)
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
42000040 flags
         Initialized Data
         Discardable
         Read Only

  Summary

        2000 .data
        1000 .gfids
        2000 .pdata
        A000 .rdata
        1000 .reloc
       14000 .text

请回顾第四章中关于目标文件格式的讨论(参见第 71 页中的“目标文件格式”),以理解当你指定/headers选项时,dumpbin输出的信息。

5.3.1.4 /imports

/imports选项列出了操作系统在程序加载到内存时必须提供的所有动态链接符号。由于这些信息对于分析 HLL 语句生成的代码并不特别有用,因此本章不会进一步提及此选项。

5.3.1.5 /relocations

/relocations选项显示文件中的所有重定位对象。此命令非常有用,因为它提供了程序中所有符号及其在反汇编列表中的使用偏移的列表。当然,/all选项也会显示这些信息,但/relocations仅显示这些信息,而不包含其他内容。

5.3.1.6 其他 dumpbin.exe 命令行选项

dumpbin工具支持许多本章未涉及的命令行选项。如前所述,你可以通过在命令行中指定/?来获取所有可能的选项列表。当运行dumpbin时,你还可以在线阅读更多内容,地址是docs.microsoft.com/en-us/cpp/build/reference/dumpbin-reference?view=vs-2019/

5.3.2 FSF/GNU objdump 工具

如果你在操作系统上运行 GNU 工具集(例如,在 Linux、Mac 或 BSD 下),那么你可以使用 FSF/GNU 的objdump工具来检查由 GCC 和其他符合 GNU 标准的工具生成的目标文件。以下是它支持的命令行选项:


			Usage: objdump <option(s)> <file(s)>
Usage: objdump <option(s)> <file(s)>
Display information from object <file(s)>.
At least one of the following switches must be given:
  -a, --archive-headers    Display archive header information
  -f, --file-headers       Display the contents of the overall file header
  -p, --private-headers    Display object format specific file header contents
  -P, --private=OPT,OPT... Display object format specific contents
  -h, --[section-]headers  Display the contents of the section headers
  -x, --all-headers        Display the contents of all headers
  -d, --disassemble        Display assembler contents of executable sections
  -D, --disassemble-all    Display assembler contents of all sections
  -S, --source             Intermix source code with disassembly
  -s, --full-contents      Display the full contents of all sections requested
  -g, --debugging          Display debug information in object file
  -e, --debugging-tags     Display debug information using ctags style
  -G, --stabs              Display (in raw form) any STABS info in the file
  -W[lLiaprmfFsoRt] or
  --dwarf[=rawline,=decodedline,=info,=abbrev,=pubnames,=aranges,=macro,=frames,
          =frames-interp,=str,=loc,=Ranges,=pubtypes,
          =gdb_index,=trace_info,=trace_abbrev,=trace_aranges,
          =addr,=cu_index]
                           Display DWARF info in the file
  -t, --syms               Display the contents of the symbol table(s)
  -T, --dynamic-syms       Display the contents of the dynamic symbol table
  -r, --reloc              Display the relocation entries in the file
  -R, --dynamic-reloc      Display the dynamic relocation entries in the file
  @<file>                  Read options from <file>
  -v, --version            Display this program's version number
  -i, --info               List object formats and architectures supported
  -H, --help               Display this information

 The following switches are optional:
  -b, --target=BFDNAME           Specify the target object format as BFDNAME
  -m, --architecture=MACHINE     Specify the target architecture as MACHINE
  -j, --section=NAME             Only display information for section NAME
  -M, --disassembler-options=OPT Pass text OPT on to the disassembler
  -EB --endian=big               Assume big endian format when disassembling
  -EL --endian=little            Assume little endian format when disassembling
      --file-start-context       Include context from start of file (with -S)
  -I, --include=DIR              Add DIR to search list for source files
  -l, --line-numbers             Include line numbers and filenames in output
  -F, --file-offsets             Include file offsets when displaying information
  -C, --demangle[=STYLE]         Decode mangled/processed symbol names
                                 The STYLE, if specified, can be `auto', `gnu',
                                  `lucid', `arm', `hp', `edg', `gnu-v3', `java'
                                  or `gnat'
  -w, --wide                     Format output for more than 80 columns
  -z, --disassemble-zeroes       Do not skip blocks of zeroes when disassembling
      --start-address=ADDR       Only process data whose address is >= ADDR
      --stop-address=ADDR        Only process data whose address is <= ADDR
      --prefix-addresses         Print complete address alongside disassembly
      --[no-]show-raw-insn       Display hex alongside symbolic disassembly
      --insn-width=WIDTH         Display WIDTH bytes on a single line for -d
      --adjust-vma=OFFSET        Add OFFSET to all displayed section addresses
      --special-syms             Include special symbols in symbol dumps
      --prefix=PREFIX            Add PREFIX to absolute paths for -S
      --prefix-strip=LEVEL       Strip initial directory names for -S
      --dwarf-depth=N            Do not display DIEs at depth N or greater
      --dwarf-start=N            Display DIEs starting with N, at the same depth
                                 or deeper
      --dwarf-check              Make additional dwarf internal consistency checks.

objdump: supported targets: elf64-x86-64 elf32-i386 elf32-iamcu elf32-x86-64 a.out-i386-linux pei-i386 pei-x86-64 elf64-l1om elf64-k1om elf64-little elf64-big elf32-little elf32-big pe-x86-64 pe-bigobj-x86-64 pe-i386 plugin srec symbolsrec verilog tekhex binary ihex
objdump: supported architectures: i386 i386:x86-64 i386:x64-32 i8086 i386:intel i386:x86-64:intel i386:x64-32:intel i386:nacl i386:x86-64:nacl i386:x64-32:nacl iamcu iamcu:intel l1om l1om:intel k1om k1om:intel plugin
The following i386/x86-64 specific disassembler options are supported for use
with the -M switch (multiple options should be separated by commas):
  x86-64          Disassemble in 64bit mode
  i386            Disassemble in 32bit mode
  i8086           Disassemble in 16bit mode
  att             Display instruction in AT&T syntax
  intel           Display instruction in Intel syntax
  att-mnemonic    Display instruction in AT&T mnemonic
  intel-mnemonic  Display instruction in Intel mnemonic
  addr64          Assume 64bit address size
  addr32          Assume 32bit address size
  addr16          Assume 16bit address size
  data32          Assume 32bit data size
  data16          Assume 16bit data size
  suffix          Always display instruction suffix in AT&T syntax
  amd64           Display instruction in AMD64 ISA
  intel64         Display instruction in Intel64 ISA
Report bugs to <http://www.sourceware.org/bugzilla/>.

给定以下m.hla源代码片段:


			begin t;

        // test mem.alloc and mem.free:

        for( mov( 0, ebx ); ebx < 16; inc( ebx )) do

                // Allocate lots of storage:

                for( mov( 0, ecx ); ecx < 65536; inc( ecx )) do

                        rand.range( 1, 256 );
                        malloc( eax );
                        mov( eax, ptrs[ ecx*4 ] );

                endfor;
                   .
                   .
                   .

这是在 80x86 上生成的一些示例输出,通过 Linux 命令行objdump -S m创建:


			        objdump -S m

 0804807e <_HLAMain>:
 804807e:   89 e0                   mov    %esp,%eax

        .
        . // Some deleted code here,
        . // that HLA automatically generated.
        .

 80480ae:   bb 00 00 00 00          mov    $0x0,%ebx
 80480b3:   eb 2a                   jmp    80480df <StartFor__hla_2124>

080480b5 <for__hla_2124>:
 80480b5:   b9 00 00 00 00          mov    $0x0,%ecx
 80480ba:   eb 1a                   jmp    80480d6 <StartFor__hla_2125>

080480bc <for__hla_2125>:
 80480bc:   6a 01                   push   $0x1
 80480be:   68 00 01 00 00          push   $0x100
 80480c3:   e8 64 13 00 00          call   804942c <RAND_RANGE>
 80480c8:   50                      push   %eax
 80480c9:   e8 6f 00 00 00          call   804813d <MEM_ALLOC1>
 80480ce:   89 04 8d 68 c9 04 08    mov    %eax,0x804c968(,%ecx,4)

080480d5 <continue__hla_2125>:
 80480d5:   41                      inc    %ecx

080480d6 <StartFor__hla_2125>:
 80480d6:   81 f9 00 00 01 00       cmp    $0x10000,%ecx
 80480dc:   72 de                   jb     80480bc <for__hla_2125>

080480de <continue__hla_2124>:
 80480de:   43                      inc    %ebx

080480df <StartFor__hla_2124>:
 80480df:   83 fb 10                cmp    $0x10,%ebx
 80480e2:   72 d1                   jb     80480b5 <for__hla_2124>

080480e4 <QuitMain__hla_>:
 80480e4:   b8 01 00 00 00          mov    $0x1,%eax
 80480e9:   31 db                   xor    %ebx,%ebx
 80480eb:   cd 80                   int    $0x80
 8048274:   bb 00 00 00 00          mov    $0x0,%ebx
 8048279:   e9 d5 00 00 00          jmp    8048353 <L1021_StartFor__hla_>
 0804827e <L1021_for__hla_>:
 804827e:   b9 00 00 00 00          mov    $0x0,%ecx
 8048283:   eb 1a                   jmp    804829f <L1022_StartFor__hla_>

08048285 <L1022_for__hla_>:
 8048285:   6a 01                   push   $0x1
 8048287:   68 00 01 00 00          push   $0x100
 804828c:   e8 db 15 00 00          call   804986c <RAND_RANGE>
 8048291:   50                      push   %eax
 8048292:   e8 63 0f 00 00          call   80491fa <MEM_ALLOC>
 8048297:   89 04 8d 60 ae 04 08    mov    %eax,0x804ae60(,%ecx,4)

0804829e <L1022_continue__hla_>:
 804829e:   41                      inc    %ecx

0804829f <L1022_StartFor__hla_>:
 804829f:   81 f9 00 00 01 00       cmp    $0x10000,%ecx
 80482a5:   72 de                   jb     8048285 <L1022_for__hla_>

080482a7 <L1022_exitloop__hla_>:
 80482a7:   b9 00 00 00 00          mov    $0x0,%ecx
 80482ac:   eb 0d                   jmp    80482bb <L1023_StartFor__hla_>

这些列出的内容只是总代码的一个片段(这就是为什么某些标签缺失的原因)。不过,你可以看到objdump工具如何通过允许你反汇编特定代码片段的目标代码来分析编译器输出。

dumpbin一样,objdump也可以显示除机器代码反汇编外的其他信息,这在分析编译器输出时可能会有所帮助。然而,对于大多数用途而言,GCC 的-S(汇编输出)选项是最有用的。以下是使用objdump工具对一些 C 代码进行反汇编的示例。首先是原始的 C 代码:


			// Original C code:

#include <stdio.h>
int main( int argc, char **argv )
{
    int i,j,k;

    j = **argv;
    k = argc;
    i = j && k;
    printf( "%d\n", i );
    return 0;
}

下面是 GCC 为 C 代码生成的 Gas 输出(x86-64):


			    .file   "t.c"
    .section    .rodata
.LC0:
    .string "%d\n"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $32, %rsp
    movl    %edi, -20(%rbp)
    movq    %rsi, -32(%rbp)
    movq    -32(%rbp), %rax
    movq    (%rax), %rax
    movzbl  (%rax), %eax
    movsbl  %al, %eax
    movl    %eax, -12(%rbp)
    movl    -20(%rbp), %eax
    movl    %eax, -8(%rbp)
    cmpl    $0, -12(%rbp)
    je  .L2
    cmpl    $0, -8(%rbp)
    je  .L2
    movl    $1, %eax
    jmp .L3
.L2:
    movl    $0, %eax
.L3:
    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    movl    %eax, %esi
    movl    $.LC0, %edi
    movl    $0, %eax
    call    printf
    movl    $0, %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
    .section    .note.GNU-stack,"",@progbits

现在,这是objdump反汇编的主函数:


			.file   "t.c"

0000000000400526 <main>:
  400526:   55                      push   %rbp
  400527:   48 89 e5                mov    %rsp,%rbp
  40052a:   48 83 ec 20             sub    $0x20,%rsp
  40052e:   89 7d ec                mov    %edi,-0x14(%rbp)
  400531:   48 89 75 e0             mov    %rsi,-0x20(%rbp)
  400535:   48 8b 45 e0             mov    -0x20(%rbp),%rax
  400539:   48 8b 00                mov    (%rax),%rax
  40053c:   0f b6 00                movzbl (%rax),%eax
  40053f:   0f be c0                movsbl %al,%eax
  400542:   89 45 f4                mov    %eax,-0xc(%rbp)
  400545:   8b 45 ec                mov    -0x14(%rbp),%eax
  400548:   89 45 f8                mov    %eax,-0x8(%rbp)
  40054b:   83 7d f4 00             cmpl   $0x0,-0xc(%rbp)
  40054f:   74 0d                   je     40055e <main+0x38>
  400551:   83 7d f8 00             cmpl   $0x0,-0x8(%rbp)
  400555:   74 07                   je     40055e <main+0x38>
  400557:   b8 01 00 00 00          mov    $0x1,%eax
  40055c:   eb 05                   jmp    400563 <main+0x3d>
  40055e:   b8 00 00 00 00          mov    $0x0,%eax
  400563:   89 45 fc                mov    %eax,-0x4(%rbp)
  400566:   8b 45 fc                mov    -0x4(%rbp),%eax
  400569:   89 c6                   mov    %eax,%esi
  40056b:   bf 14 06 40 00          mov    $0x400614,%edi
  400570:   b8 00 00 00 00          mov    $0x0,%eax
  400575:   e8 86 fe ff ff          callq  400400 <printf@plt>
  40057a:   b8 00 00 00 00          mov    $0x0,%eax
  40057f:   c9                      leaveq
  400580:   c3                      retq

如你所见,汇编代码输出比objdump的输出更容易阅读。

5.4 使用反汇编器分析编译器输出

尽管使用目标代码“转储”工具是一种分析编译器输出的方法,另一个可能的解决方案是对可执行文件运行反汇编器。反汇编器是一种工具,它将二进制机器码翻译成人类可读的汇编语言语句(“人类可读”这个说法有待商榷,但大体就是这个意思)。因此,它是另一个可以用来分析编译器输出的工具。

目标代码转储工具(它包含一个简单的反汇编器)和复杂的反汇编程序之间有一个微妙但重要的区别。目标代码转储工具是自动的,但如果目标代码包含复杂的构造(例如指令流中的隐藏数据),它们很容易被弄错。自动反汇编器使用起来非常方便,用户不需要太多专业知识,但很少能正确反汇编机器码。另一方面,完全功能的交互式反汇编器需要更多的训练才能正确使用,但它能够在用户的帮助下反汇编复杂的机器码序列。因此,优秀的反汇编器能够在简单的目标代码转储工具无法处理的情况下工作。幸运的是,大多数编译器不会总是生成那些让目标代码转储工具困惑的复杂代码,因此有时你可以不用学会如何使用完全功能的反汇编程序。不过,在简单方法无法奏效的情况下,拥有一个反汇编器会非常有用。

有几款“免费”的反汇编器可供使用。本章将介绍的工具是 IDA7。IDA 是 IDA Pro 的免费版本,是一个功能强大且能力卓越的商业反汇编系统(www.hex-rays.com/products/ida/)。

当你首次运行 IDA 时,它会打开图 5-1 中显示的窗口。

Image

图 5-1:IDA 启动窗口

点击新建按钮并输入你希望反汇编的.exe.obj文件名。输入可执行文件名后,IDA 会弹出图 5-2 中显示的格式对话框。在这个对话框中,你可以选择二进制文件类型(例如,PE/COFF、PE64 可执行文件或纯二进制)以及反汇编文件时使用的选项。IDA 在选择这些选项的默认值时做得很好,所以大多数情况下你只需接受默认设置,除非你在处理一些特殊的二进制文件。

Image

图 5-2:IDA 可执行文件格式对话框

一般来说,IDA 会找出适当的文件类型信息进行标准的反汇编,然后对目标代码文件进行“自动”反汇编。要生成汇编语言输出文件,请点击确定。以下是“示例汇编语言输出”中给出的t1.c文件反汇编的前几行,参见 第 102 页:


			; int __cdecl main(int argc, const char **argv, const char **envp)
main    proc    near
        sub     rsp, 28h
        mov     rax, [rdx]
        cmp     ecx, 2
        movsx   edx, byte ptr [rax]
        lea     eax, [rdx-1]
        lea     r8d, [rdx+1]
        mov     edx, ecx
        cmovnz  r8d, eax
        lea     rcx, aIDJD      ; "i=%d, j=%d\n"
        call    sub_140001040
        xor     eax, eax
        add     rsp, 28h
        retn
main endp

IDA 是一个 交互式 反汇编器。这意味着它提供了许多复杂的功能,你可以利用这些功能引导反汇编过程,以生成更合理的汇编语言输出文件。然而,它的“自动”模式通常就是你需要的,用来检查编译器输出文件并评估其质量。有关 IDA(免费版)或 IDA Pro 的更多详细信息,请参见其文档 (www.hex-rays.com/products/ida/support/).

5.5 使用 Java 字节码反汇编器分析 Java 输出

大多数 Java 编译器(特别是来自 Oracle 公司)并不会直接生成机器码。相反,它们生成 Java 字节码(JBC),计算机系统随后通过 JBC 解释器执行它。为了提高性能,一些 Java 解释器会运行即时编译器(JIT),在解释过程中将 JBC 转换为本地机器码以提高性能(尽管结果通常不如优化编译器生成的机器码)。不幸的是,由于 Java 解释器在运行时进行此转换,因此很难分析 Java 编译器生成的机器码输出。然而,可以分析它生成的 JBC。这可以让你比单纯猜测更好地了解编译器是如何处理你的 Java 代码的。考虑以下(相对简单的)Java 程序:


			public class Welcome
{
    public static void main( String[] args )
    {
        switch(5)
        {
            case 0:
                System.out.println("0");
                break;
            case 1:
                System.out.println("1");
                break;
            case 2:
            case 5:
                System.out.println("5");
                break;
            default:
                System.out.println("default" );
        }
        System.out.println( "Hello World" );
    }
}

通常,你可以使用类似下面的命令行来编译这个程序(Welcome.java):

javac Welcome.java

此命令生成 Welcome.class JBC 文件。你可以使用以下命令来反汇编此文件(输出到标准输出):

javap -c Welcome

请注意,在命令行中不需要包含 .class 文件扩展名;javap 命令会自动添加它。

javap 命令会生成类似以下的字节码反汇编清单:


			Compiled from "Welcome.java"
public class Welcome extends java.lang.Object{
public Welcome();
  Code:
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return

public static void main(java.lang.String[]);
  Code:
   0:   iconst_5
   1:   tableswitch{ //0 to 5
        0: 40;
        1: 51;
        2: 62;
        3: 73;
        4: 73;
        5: 62;
        default: 73 }
   40:  getstatic   #2;     //Field java/lang/System.out:Ljava/io/PrintStream;
   43:  ldc #3;             //String 0
   45:  invokevirtual   #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   48:  goto    81
   51:  getstatic   #2;     //Field java/lang/System.out:Ljava/io/PrintStream;
   54:  ldc #5;             //String 1
   56:  invokevirtual   #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   59:  goto    81
   62:  getstatic   #2;     //Field java/lang/System.out:Ljava/io/PrintStream;
   65:  ldc #6;             //String 5
   67:  invokevirtual   #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   70:  goto    81
   73:  getstatic   #2;     //Field java/lang/System.out:Ljava/io/PrintStream;
   76:  ldc #7;             //String default
   78:  invokevirtual   #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   81:  getstatic   #2;     //Field java/lang/System.out:Ljava/io/PrintStream;
   84:  ldc #8;             //String Hello World
   86:  invokevirtual   #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   89:  return
}

你可以在 Oracle.com 上找到 JBC 助记符和 javap Java 类文件反汇编器的文档(在网站中搜索javap和“Java 字节码反汇编器”)。此外,本书附带的在线章节(特别是附录 D)讨论了 Java VM 字节码汇编语言。

5.6 使用 IL 反汇编器分析微软 C# 和 Visual Basic 输出

微软的 .NET 语言编译器不会直接生成本地机器码。相反,它们会生成一种特殊的 IL(中间语言)代码。原理上,这与 Java 字节码或 UCSD p-machine 代码非常相似。 .NET 运行时系统会编译 IL 可执行文件,并通过 JIT 编译器运行它们。

微软的 C# 编译器是一个很好的例子,它属于以这种方式工作的 .NET 语言。编译一个简单的 C# 程序将生成一个微软的 .exe 文件,你可以使用 dumpbin 来查看。遗憾的是,你不能使用 dumpbin 来查看目标代码(无论是 IL 还是其他格式)。幸运的是,微软提供了一个工具 ildasm.exe,你可以用它来反汇编 IL 字节码/汇编代码。

考虑以下小型 C# 示例程序(Class1.cs,对常见的“Hello World!”程序稍作调整):


			using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Hello_World
{
    class program
    {
        static void Main( string[] args)
        {
            int i = 5;
            int j = 6;
            int k = i + j;
            Console.WriteLine("Hello World! k={0}", k);
        }
    }
}

在命令提示符下键入ildasm class1.exe会弹出图 5-3 所示的窗口。

Image

图 5-3:IL 反汇编窗口

要查看代码反汇编,双击 S 图标(位于 Main 条目旁)。这会打开一个窗口,其中包含以下文本(为清晰起见添加了注释):


			.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       25 (0x19)
  .maxstack  2
  .locals init (int32 V_0,
           int32 V_1,
           int32 V_2)
; push constant 5 on stack

  IL_0000:  ldc.i4.5

; pop stack and store into i

  IL_0001:  stloc.0

; push constant 6 on stack

  IL_0002:  ldc.i4.6

; pop stack and store in j

  IL_0003:  stloc.1

; Push i and j onto stack:

  IL_0004:  ldloc.0
  IL_0005:  ldloc.1

; Add two items on stack, leave result on stack

  IL_0006:  add

; Store sum into k

  IL_0007:  stloc.2

; Load string onto stack (pointer to string)

  IL_0008:  ldstr      "Hello World! k={0}"

; Push k's value onto stack:

  IL_000d:  ldloc.2
  IL_000e:  box        [mscorlib]System.Int32

; call writeline routine:

  IL_0013:  call       void [mscorlib]System.Console::WriteLine(string,
                                                                object)
  IL_0018:  ret
} // end of method program::Main

你可以使用 IL 反汇编程序来处理任何 .NET 语言(如 Visual Basic 和 F#)。有关微软 IL 汇编语言的详细信息,请参阅在线的附录 E。

5.7 使用调试器分析编译器输出

另一个分析编译器输出的选项是使用调试程序,通常调试程序内置有反汇编器,可以用来查看机器指令。根据所使用的调试器,查看编译器输出的过程可能会让你头疼,也可能是轻松愉快的。通常,如果你使用独立的调试器,你会发现分析编译器输出的工作要比使用集成在编译器 IDE 中的调试器更加繁琐。本节将介绍这两种方法。

5.7.1 使用 IDE 的调试器

微软的 Visual C++ 环境提供了出色的工具来分析编译过程中生成的代码(当然,编译器也会生成汇编输出,但在这里我们暂不讨论)。要使用 Visual Studio 调试器查看输出,首先将你的 C/C++ 程序编译为可执行文件,然后从 Visual Studio 调试菜单中选择 调试逐步进入。当程序暂停执行时,从调试菜单中选择 调试窗口反汇编。对于 t1.c 程序(参见第 102 页的“示例汇编语言输出”),你应该会看到如下反汇编内容(假设你正在生成 32 位代码):


			--- c:\users\rhyde\test\t\t\t.cpp ----------------------------------------------
#include "stdafx.h"
#include <stdio.h>
int main(int argc, char **argv)
{
00F61000  push        ebp
00F61001  mov         ebp,esp
00F61003  sub         esp,8
    int i;
    int j;

    i = argc;
00F61006  mov         eax,dword ptr [argc]
00F61009  mov         dword ptr [i],eax
    j = **argv;
00F6100C  mov         ecx,dword ptr [argv]
00F6100F  mov         edx,dword ptr [ecx]
00F61011  movsx       eax,byte ptr [edx]
00F61014  mov         dword ptr [j],eax

    if (i == 2)
00F61017  cmp         dword ptr [i],2
00F6101B  jne         main+28h (0F61028h)
    {
        ++j;
00F6101D  mov         ecx,dword ptr [j]
00F61020  add         ecx,1
00F61023  mov         dword ptr [j],ecx
    }
    else
00F61026  jmp         main+31h (0F61031h)
    {
        --j;
00F61028  mov         edx,dword ptr [j]
00F6102B  sub         edx,1
00F6102E  mov         dword ptr [j],edx
    }

    printf("i=%d, j=%d\n", i, j);
00F61031  mov         eax,dword ptr [j]
00F61034  push        eax
00F61035  mov         ecx,dword ptr [i]
00F61038  push        ecx
00F61039  push        0F620F8h
00F6103E  call        printf (0F61090h)
00F61043  add         esp,0Ch
    return 0;
00F61046  xor         eax,eax
}
00F61048  mov         esp,ebp
00F6104A  pop         ebp
00F6104B  ret

当然,因为微软的 Visual C++ 包已经能够在编译过程中输出汇编语言文件,所以以这种方式使用 Visual Studio 集成调试器并非必要。^(3) 然而,有些编译器不提供汇编输出,调试器的输出可能是查看编译器生成的机器代码最简单的方式。例如,Embarcadero 的 Delphi 编译器不提供生成汇编语言输出的选项。考虑到 Delphi 在应用程序中链接了大量的类库代码,尝试通过反汇编程序查看程序小部分代码就像是在大海捞针。更好的解决方案是使用 Delphi 环境内置的调试器。

5.7.2 使用独立调试器

如果你的编译器没有将自己的调试器作为 IDE 的一部分提供,另一种选择是使用像 OllyDbg、DDD 或 GDB 等独立调试器来反汇编编译器的输出。只需将可执行文件加载到调试器中,进行常规调试操作即可。

大多数与特定编程语言无关的调试器是机器级调试器,它们将二进制机器代码反汇编成机器指令,以便在调试过程中查看。使用机器级调试器的一大问题是定位要反汇编的特定代码段可能会很困难。记住,当你将整个可执行文件加载到调试器时,你同时也加载了所有静态链接的库例程和其他在应用程序源文件中通常不会出现的运行时支持代码。要在这些多余的代码中查找编译器如何将特定的语句序列转换为机器代码,可能会耗费大量时间。有时可能需要一些艰苦的代码侦探工作。幸运的是,大多数链接器会将所有的库例程集中在一起,并将它们放在可执行文件的开头或结尾。因此,通常你也会在这些位置找到与应用程序相关的代码。

调试器有三种不同的类型:纯机器级调试器、符号调试器和源代码级调试器。符号调试器和源代码级调试器要求可执行文件包含特殊的调试信息,因此编译器必须专门包含这些额外的信息。

纯机器级调试器无法访问应用程序中的原始源代码或符号。纯机器级调试器仅仅是反汇编应用程序的机器代码,并使用字面数值常量和机器地址显示反汇编列表。阅读这样的代码是困难的,但如果你理解编译器如何为高级语言(HLL)语句生成代码(正如本书将教给你的那样),那么定位机器代码就容易得多。尽管如此,由于缺乏符号信息提供“根点”,分析可能会变得困难。

符号调试器使用在可执行文件中找到的特殊符号表信息(或者在某些情况下是单独的调试文件)将标签与函数关联,并可能将变量名与源文件中的函数关联。这一功能使得在反汇编列表中定位代码段变得更加容易。当符号标签识别出函数调用时,更容易看到反汇编代码与原始高级语言(HLL)源代码之间的对应关系。然而,需要记住的是,符号信息仅在应用程序启用了调试模式编译时可用。请查看编译器的文档,了解如何为调试器启用此功能。

源代码级调试器实际上会显示与调试器正在处理的文件相关的原始源代码。为了查看编译器生成的机器代码,你通常需要激活程序的特殊机器级视图。与符号调试器一样,编译器必须生成包含调试信息的特殊可执行文件(或辅助文件),这些文件供源代码级调试器使用。显然,源代码级调试器更易于使用,因为它们显示了原始高级语言源代码与反汇编机器代码之间的对应关系。

5.8 比较两次编译的输出

如果你是一个资深的汇编语言程序员,并且精通编译器设计,那么你应该能够很容易地判断出需要对你的高级语言(HLL)源代码做哪些修改,以改善输出机器代码的质量。然而,大多数程序员(尤其是那些没有大量经验研究编译器输出的程序员)不能仅凭编译器的汇编语言输出进行判断。他们必须比较两组输出(修改前和修改后的输出),以确定哪一段代码更好。毕竟,并不是每一次修改高级语言源代码都会生成更好的代码。有些修改可能不会影响机器代码(在这种情况下,你应该使用更具可读性和可维护性的高级语言源代码版本)。在其他情况下,你甚至可能会让输出的机器代码变得更差。因此,除非你确切知道编译器在你修改高级语言源文件时会做什么,否则在接受任何修改之前,你应该对编译器的输出机器代码进行修改前后的对比。

5.8.1 使用 diff 进行修改前后的比较

当然,任何有经验的软件开发人员的第一反应是:“嗯,如果我们需要比较文件,我们就使用diff!”事实证明,典型的diff(计算文件差异)程序在某些情况下会很有用,但在比较编译器输出的两个不同文件时,它并不总是适用。像diff这样的程序的问题在于,当两个文件之间只有少量差异时,它能很好地工作,但当文件差异巨大时,它就不太有用了。例如,考虑以下的 C 程序(t.c)和由 Microsoft VC++ 编译器产生的两个不同输出:


			extern void f( void );
int main( int argc, char **argv )
{
    int boolResult;

    switch( argc )
    {
        case 1:
            f();
            break;

        case 10:
            f();
            break;

        case 100:
            f();
            break;

        case 1000:
            f();
            break;

        case 10000:
            f();
            break;

        case 100000:
            f();
            break;

        case 1000000:
            f();
            break;

        case 10000000:
            f();
            break;

        case 100000000:
            f();
            break;
 case 1000000000:
            f();
            break;

    }
    return 0;
}

这是使用命令行 cl /Fa t.c(即在没有优化的情况下编译)时 MSVC++ 产生的汇编语言输出:


			  ; Listing generated by Microsoft (R) Optimizing Compiler Version 19.00.24234.1

include listing.inc

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

PUBLIC  main
EXTRN   f:PROC
pdata   SEGMENT
$pdata$main DD  imagerel $LN16
        DD      imagerel $LN16+201
        DD      imagerel $unwind$main
pdata   ENDS
xdata   SEGMENT
$unwind$main DD 010d01H
        DD      0620dH
xdata   ENDS
; Function compile flags: /Odtp
_TEXT   SEGMENT
tv64 = 32
argc$ = 64
argv$ = 72
main    PROC
; File c:\users\rhyde\test\t\t\t.cpp
; Line 4
$LN16:
        mov     QWORD PTR [rsp+16], rdx
        mov     DWORD PTR [rsp+8], ecx
        sub     rsp, 56                                 ; 00000038H
; Line 7
        mov     eax, DWORD PTR argc$[rsp]
        mov     DWORD PTR tv64[rsp], eax
        cmp     DWORD PTR tv64[rsp], 100000             ; 000186a0H
        jg      SHORT $LN15@main
        cmp     DWORD PTR tv64[rsp], 100000             ; 000186a0H
        je      SHORT $LN9@main
        cmp     DWORD PTR tv64[rsp], 1
        je      SHORT $LN4@main
        cmp     DWORD PTR tv64[rsp], 10
        je      SHORT $LN5@main
        cmp     DWORD PTR tv64[rsp], 100                ; 00000064H
        je      SHORT $LN6@main
        cmp     DWORD PTR tv64[rsp], 1000               ; 000003e8H
        je      SHORT $LN7@main
        cmp     DWORD PTR tv64[rsp], 10000              ; 00002710H
        je      SHORT $LN8@main
        jmp     SHORT $LN2@main
$LN15@main:
        cmp     DWORD PTR tv64[rsp], 1000000            ; 000f4240H
        je      SHORT $LN10@main
        cmp     DWORD PTR tv64[rsp], 10000000           ; 00989680H
        je      SHORT $LN11@main
        cmp     DWORD PTR tv64[rsp], 100000000          ; 05f5e100H
        je      SHORT $LN12@main
        cmp     DWORD PTR tv64[rsp], 1000000000         ; 3b9aca00H
        je      SHORT $LN13@main
        jmp     SHORT $LN2@main
$LN4@main:
; Line 10
        call    f
; Line 11
        jmp     SHORT $LN2@main
$LN5@main:
; Line 14
        call    f
; Line 15
        jmp     SHORT $LN2@main
$LN6@main:
; Line 18
        call    f
; Line 19
        jmp     SHORT $LN2@main
$LN7@main:
; Line 22
        call    f
; Line 23
        jmp     SHORT $LN2@main
$LN8@main:
; Line 26
        call    f
; Line 27
        jmp     SHORT $LN2@main
$LN9@main:
; Line 30
        call    f
; Line 31
        jmp     SHORT $LN2@main
$LN10@main:
; Line 34
        call    f
; Line 35
        jmp     SHORT $LN2@main
$LN11@main:
; Line 38
        call    f
; Line 39
        jmp     SHORT $LN2@main

$LN12@main:
; Line 42
        call    f
; Line 43
        jmp     SHORT $LN2@main
$LN13@main:
; Line 46
        call    f
$LN2@main:
; Line 50
        xor     eax, eax
; Line 51
        add     rsp, 56                                 ; 00000038H
        ret     0
main    ENDP
_TEXT   ENDS
END

这是我们使用命令行 cl /Ox /Fa t.c 编译 C 程序时获得的汇编清单(/Ox 在 Visual C++ 中启用最大优化以提高速度):


			    ; Listing generated by Microsoft (R) Optimizing Compiler Version 19.00.24234.1

include listing.inc

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

PUBLIC  main
EXTRN   f:PROC
pdata   SEGMENT
$pdata$main DD  imagerel $LN18
        DD      imagerel $LN18+89
        DD      imagerel $unwind$main
pdata   ENDS
xdata   SEGMENT
$unwind$main DD 010401H
        DD      04204H
xdata   ENDS
; Function compile flags: /Ogtpy
_TEXT   SEGMENT
argc$ = 48
argv$ = 56
main    PROC
; File c:\users\rhyde\test\t\t\t.cpp
; Line 4
$LN18:
        sub     rsp, 40                                 ; 00000028H
; Line 7
        cmp     ecx, 100000                             ; 000186a0H
        jg      SHORT $LN15@main
        je      SHORT $LN10@main
        sub     ecx, 1
        je      SHORT $LN10@main
        sub     ecx, 9
        je      SHORT $LN10@main
        sub     ecx, 90                                 ; 0000005aH
        je      SHORT $LN10@main
        sub     ecx, 900                                ; 00000384H
        je      SHORT $LN10@main
        cmp     ecx, 9000                               ; 00002328H
; Line 27
        jmp     SHORT $LN16@main
$LN15@main:
; Line 7
        cmp     ecx, 1000000                            ; 000f4240H
        je      SHORT $LN10@main
        cmp     ecx, 10000000                           ; 00989680H
        je      SHORT $LN10@main
        cmp     ecx, 100000000                          ; 05f5e100H
        je      SHORT $LN10@main
        cmp     ecx, 1000000000                         ; 3b9aca00H
$LN16@main:
        jne     SHORT $LN2@main
$LN10@main:
; Line 34
        call    f
$LN2@main:
; Line 50
        xor     eax, eax
; Line 51
        add     rsp, 40                                 ; 00000028H
        ret     0
main    ENDP
_TEXT   ENDS
        END

不需要非常敏锐的眼力就能注意到这两个汇编语言输出文件有很大的不同。将这两个文件通过 diff 处理,只会产生大量噪声;diff 的输出比手动比较这两个汇编语言输出文件更难以理解。

diff 这样的差异比较程序(或者更好的是,许多高级编程编辑器中内置的差异比较功能)最适合用于比较给定 HLL 源文件的两个不同输出,其中你做了一个小的修改。在当前的示例中,如果我们将语句 case 1000: 改为 case 1001:,那么对比结果汇编文件与原始文件的 diff 输出如下:


			50c50
< cmp eax, 1000

---
> cmp eax, 1001

只要你能看懂 diff 输出,这并不算太难。然而,更好的解决方案是使用一些商业文件比较程序。两个优秀的选择是 Beyond Compare (www.scootersoftware.com/ ) 和 Araxis Merge (www.araxis.com/merge/ )。

当然,比较编译器输出的另一种方式是手动比较。将两个列表并排放置(无论是在纸上还是在显示器上),然后开始分析它们。在当前的 C 示例中,如果我们比较 C 编译器的两种不同输出(无优化和使用 /Ox 优化选项),我们会发现两个版本都使用二分查找算法来将 switch 值与一组变化较大的常量进行比较。优化版和未优化版的主要区别在于代码重复。

为了正确比较编译器生成的两个汇编列表,你需要学会如何解读编译器的机器语言输出,并将某些汇编语言序列与 HLL 代码中的语句对应起来。这也是接下来许多章节的目的。

5.9 更多信息

当你试图弄清楚如何查看编译器生成的机器码时,首先查看编译器手册是最好的地方。许多编译器提供汇编语言输出选项,这是查看代码输出的最佳方式。如果你的编译器没有提供这个选项,编译器 IDE 中内置的调试工具(如果有)是另一个不错的选择。有关详细信息,请查看你的 IDE 或编译器的文档。

objdumpdumpbin 等工具对于检查编译器输出也非常有用。有关使用这些程序的详细信息,请查阅 Microsoft、FSF/GNU 或 Apple LLVM 的文档。如果你决定使用外部调试器,如 OllyDbg 或 GDB,请查看软件的用户文档,或访问作者的支持网页(例如,* www.ollydbg.de/* 用于 OllyDbg 调试器)。

第六章:常量与高级语言

image

一些程序员可能没有意识到,但许多 CPU 在机器代码级别并不会将常量和变量数据视为相同。大多数 CPU 提供一种特殊的立即寻址模式,允许语言翻译器将常量值直接嵌入机器指令中,而不是将其存储在内存位置并作为变量进行访问。然而,CPU 表示常量数据的能力在不同 CPU 之间有所不同,事实上,甚至不同类型的数据也有差异。通过了解 CPU 如何在机器代码级别处理常量数据,你可以选择合适的方式在高级语言(HLL)源代码中表示常量,从而生成更小、更快的可执行程序。为此,本章将讨论以下主题:

  • 如何正确使用字面常量来提高程序的效率

  • 字面常量和显式常量之间的区别

  • 编译器如何处理编译时常量表达式,以减少程序大小并避免运行时计算

  • 编译时常量与存储在内存中的只读数据之间的区别

  • 编译器如何表示非整数常量,如枚举数据类型、布尔数据类型、浮点常量和字符串常量

  • 编译器如何表示复合数据类型常量,如数组常量和记录/结构常量

当你读完本章时,你应该清楚了解各种常量如何影响编译器生成的机器代码的效率。

注意

如果你已经读过 WGC1,你可能只想浏览一下本章,完整性考虑它重复了一些第六章和第七章中的信息。

6.1 字面常量与程序效率

高级编程语言和大多数现代 CPU 允许你在几乎任何可以合法读取内存变量值的地方指定常量值。考虑以下 Visual Basic 和 HLA 语句,它们将常量 1000 赋值给变量 i


			i = 1000

mov( 1000, i );

80x86 与大多数 CPU 一样,实际上将常量值 1,000 直接编码到机器指令中。这提供了一种紧凑且高效的方式在机器级别处理常量。因此,以这种方式使用字面常量的语句通常比那些将常量值赋给变量再在代码中引用该变量的语句更高效。考虑以下 Visual Basic 代码序列:


			oneThousand = 1000
    .
    .
    .
x = x + oneThousand 'Using "oneThousand" rather than
                    ' a literal constant.
y = y + 1000        'Using a literal constant.

现在考虑你可能为这两个语句编写的 80x86 汇编代码。对于第一个语句,我们必须使用两条指令,因为我们不能将一个内存位置的值直接加到另一个内存位置上:


			mov( oneThousand, eax ); // x = x + oneThousand
add( eax, x );

但我们可以将常量添加到内存位置,因此第二条 Visual Basic 语句会转化为一个单独的机器指令:

add( 1000, y ); // y = y + 1000

如你所见,使用字面常量而不是变量更为高效。然而,这并不是说每个处理器在使用字面常量时都更高效,或者每个 CPU 在无论常量值如何时都更高效。一些非常老旧的 CPU 根本不支持将字面常量嵌入到机器指令中;而许多 RISC 处理器,如 ARM,只有在较小的 8 位、12 位或 16 位常量的情况下才支持此操作。^(1) 即便是那些允许加载任何整数常量的 CPU,也可能不支持字面浮点常量——例如广泛使用的 80x86 处理器就是一个例子。很少有 CPU 提供将大数据结构(如数组、记录或字符串)编码为机器指令一部分的能力。例如,考虑以下 C 代码:


			#include <stdlib.h>
#include <stdio.h>
int main( int argc, char **argv, char **envp )
{
  int i,j,k;

  i = 1;
  j = 16000;
  k = 100000;
  printf( "%d, %d, %d\n", i, j, k );

}

通过 GCC 编译器将其编译为 PowerPC 汇编代码的过程如下所示(已编辑以去除不相关的代码):


			L1$pb:
    mflr r31
    stw r3,120(r30)
    stw r4,124(r30)
    stw r5,128(r30)

; The following two instructions copy the value 1 into the variable "i"

    li r0,1
    stw r0,64(r30)

; The following two instructions copy the value 16,000 into the variable "j"

    li r0,16000
    stw r0,68(r30)

; It takes three instructions to copy the value 100,000 into variable "k"

    lis r0,0x1
    ori r0,r0,34464
    stw r0,72(r30)

; The following code sets up and calls the printf function:

    addis r3,r31,ha16(LC0-L1$pb)
    la r3,lo16(LC0-L1$pb)(r3)
    lwz r4,64(r30)
    lwz r5,68(r30)
    lwz r6,72(r30)
    bl L_printf$stub
    mr r3,r0
    lwz r1,0(r1)
    lwz r0,8(r1)
    mtlr r0
    lmw r30,-8(r1)
    blr

PowerPC CPU 在单个指令中仅允许 16 位立即数常量。为了将更大的值加载到寄存器中,程序必须首先使用lis指令将 32 位寄存器的高 16 位(HO)加载,然后使用ori指令将低 16 位(LO)合并进来。这些指令的具体操作并不太重要。值得注意的是,编译器对于大常量发出三条指令,而对于较小的常量只发出两条指令。因此,在 PowerPC 上使用 16 位常量值会生成更短且更快速的机器代码。

通过 GCC 编译器将此 C 代码编译为 ARMv7 汇编代码的过程如下所示(已编辑以去除不相关的代码):


			.LC0:
    .ascii  "i=%d, j=%d, k=%d\012\000"
    .text
    .align  2
    .global main
    .type   main, %function
main:
    @ args = 0, pretend = 0, frame = 24
    @ frame_needed = 1, uses_anonymous_args = 0
    stmfd   sp!, {fp, lr}
    add fp, sp, #4
    sub sp, sp, #24
    str r0, [fp, #-24]
    str r1, [fp, #-28]

; Store 1 into 'i' variable:

    mov r3, #1
    str r3, [fp, #-8]
@ Store 16000 into 'j' variable:

    mov r3, #16000
    str r3, [fp, #-12]

@ Store 100,000 (constant appears in memory) into 'k' variable:

    ldr r3, .L3
    str r3, [fp, #-16]

@ Fetch the values and print them:

    ldr r0, .L3+4
    ldr r1, [fp, #-8]
    ldr r2, [fp, #-12]
    ldr r3, [fp, #-16]
    bl  printf
    mov r3, #0
    mov r0, r3
    sub sp, fp, #4
    @ sp needed
    ldmfd   sp!, {fp, pc}
.L4:

@ constant value for k appears in memory:

    .align  2
.L3:
    .word    100000
    .word   .LC0

ARM CPU 在单个指令中仅允许 16 位立即数常量。为了将更大的值加载到寄存器中,编译器将常量放置到内存位置,并从内存中加载常量。

即便像 80x86 这样的 CISC 处理器通常可以在单条指令中编码任何整数常量(最多 32 位),这并不意味着程序的效率与程序中使用的常量大小无关。CISC 处理器通常对具有大或小立即操作数的机器指令使用不同的编码方式,从而使程序在处理较小常量时能使用更少的内存。例如,考虑以下两个 80x86/HLA 机器指令:


			add( 5, ebx );
add( 500_000, ebx );

在 80x86 上,汇编器可以用 3 个字节来编码第一条指令:2 个字节用于操作码和寻址模式信息,1 个字节用于保存小的立即常量5。另一方面,第二条指令需要 6 个字节来编码:2 个字节用于操作码和寻址模式信息,4 个字节用于保存常量500_000。显然,第二条指令更大,在某些情况下,它甚至可能运行得稍微慢一些。

6.2 绑定时间

常量究竟是什么?显然,从高级语言(HLL)的角度来看,常量是某种值不变的实体(即保持恒定)。然而,定义中还有更多内容。例如,考虑以下 Pascal 常量声明:


			const someConstant:integer = 5;

在此声明后的代码中,^(2) 你可以用名字 someConstant 替代值 5。但在此声明之前呢?在这个声明所属的作用域之外呢?显然,someConstant 的值会随着编译器处理这个声明而变化。所以,常量的“值不变”这一概念在这里并不完全适用。

这里真正关注的不是程序将值与 someConstant 关联的位置,而是时间绑定 是创建某个对象的属性(如名字、值和作用域)之间关联的技术术语。例如,之前的 Pascal 示例将值 5 绑定到名字 someConstant。绑定时间——即绑定(关联)发生的时间——可以在多个不同的时刻发生:

  • 在语言定义时。 这指的是语言设计者定义语言的时间。许多语言中的常量 truefalse 就是很好的例子。

  • 在编译期间。 本节中的 Pascal someConstant 声明就是一个很好的例子。

  • 在链接阶段。 一个示例可能是指定程序中对象代码(机器指令)大小的常量。程序不能在链接阶段之前计算这个大小,因为链接器会将所有对象代码模块提取并合并在一起。

  • 程序加载(到内存)时。 一个好的加载时绑定示例是将内存中某个对象的地址(例如变量或机器指令)与某个指针常量关联起来。在许多系统中,操作系统在加载代码到内存时会进行重定位,因此程序只能在加载后确定绝对内存地址。

  • 程序执行期间。 有些绑定只能在程序运行时发生。例如,当你将某个(计算出的)算术表达式的值赋给一个变量时,值与变量的绑定发生在执行期间。

动态绑定 是指在程序执行期间发生的绑定。静态绑定 是指在其他任何时间发生的绑定。第七章将再次讨论绑定(参见《什么是变量?》在第 180 页)。

6.3 字面常量与显式常量

显式常量 是与符号名称相关联(即绑定到符号名称)的常量值。语言翻译器可以在源代码中每次出现该名称时直接替换该值,从而生成易于阅读和维护的程序。正确使用显式常量是专业编写代码的良好标志。

在许多编程语言中,声明显式常量非常简单:

  • Pascal 程序员使用 const 区域。

  • HLA 程序员可以使用 constval 声明区块。

  • C/C++ 程序员可以使用 #define 宏功能。

这个 Pascal 代码片段展示了在程序中正确使用显式常量的例子:


			const
    maxIndex = 9;

var
    a :array[0..maxIndex] of integer;
        .
        .
        .
    for i := 0 to maxIndex do
        a[i] := 0;

这段代码比使用文字常量的代码更易读和维护。通过更改此程序中的单一语句(maxIndex 常量声明)并重新编译源文件,你可以轻松设置元素的数量,并且程序将继续正常运行。

由于编译器将文字常量替换为显式常量的符号名称,因此使用显式常量不会带来性能损失。鉴于它们能够在不损失效率的情况下提高程序的可读性,显式常量是优秀代码的重要组成部分。请使用它们。

6.4 常量表达式

许多编译器支持使用 常量表达式,即那些可以在编译时计算的表达式。常量表达式的组成值在编译时就已知,因此编译器可以在编译时计算表达式并替代其值,而不是在运行时计算它。像显式常量一样,常量表达式使你能够编写更易于阅读和维护的代码,而不会带来任何运行时效率损失。

例如,考虑以下 C 代码:


			#define smArraySize 128
#define bigArraySize (smArraySize*8)
      .
      .
      .
char name[ smArraySize ];
int  values[ bigArraySize ];

这两个数组声明扩展为以下内容:


			char name[ 128 ];
int  values[ (smArraySize * 8) ];

C 预处理器进一步将其扩展为:


			char name[ 128 ];
int  values[ (128 * 8) ];

尽管 C 语言定义支持常量表达式,但并非所有语言都支持此特性,因此你需要查看特定编译器的语言参考手册。例如,Pascal 语言定义并未提及常量表达式。一些 Pascal 实现支持它们,但其他一些则不支持。

现代优化编译器能够在编译时计算算术表达式中的常量子表达式(称为 常量折叠;详见 第 63 页的“常见编译器优化”),从而节省了在运行时计算固定值的开销。考虑以下 Pascal 代码:


			var
    i   :integer;
            .
            .
            .
    i := j + (5*2-3);

任何一个合格的 Pascal 实现都能够识别子表达式 5*2–3 是一个常量表达式,在编译期间计算这个表达式的值(7),并在编译时用该值替代。换句话说,一个优秀的 Pascal 编译器通常会生成等同于以下语句的机器代码:

i := j + 7;

如果你的编译器完全支持常量表达式,你可以利用这个特性来编写更好的源代码。可能看起来有些矛盾,但在程序的某些地方写出完整的表达式,有时会使那部分代码更容易阅读和理解;阅读代码的人可以准确看到你如何计算一个值,而不必弄清楚你是如何得出某个“魔法”数字的。例如,在发票或工时单的计算中,表达式5*2–3可能比字面常量7更能描述“两个工人工作五小时,减去三小时工时”的计算过程。

以下示例 C 代码及 GCC 编译器生成的 PowerPC 输出展示了常量表达式优化的实际情况:


			#include <stdio.h>
int main( int argc, char **argv, char **envp )
{
  int j;

  j = argc+2*5+1;
  printf( "%d %d\n", j, argc );
}

以下是 GCC 的输出(PowerPC 汇编语言):


			_main:
    mflr r0
    mr r4,r3            // Register r3 holds the ARGC value upon entry
    bcl 20,31,L1$pb
L1$pb:
    mr r5,r4            // R5 now contains the ARGC value.
    mflr r10
    addi r4,r4,11       // R4 contains argc+ 2*5+1
                        // (i.e., argc+11)
    mtlr r0             // Code that calls the printf function.
    addis r3,r10,ha16(LC0-L1$pb)
    la r3,lo16(LC0-L1$pb)(r3)
    b L_printf$stub

如你所见,GCC 将常量表达式2*5+1替换为了常量11

使你的代码更具可读性无疑是做得很好的事情,也是编写优秀代码的一个重要部分。然而,请记住,一些编译器可能不支持常量表达式的使用,而是会生成代码,在运行时计算常量值。显然,这会影响你程序的大小和执行速度。了解你的编译器能够做什么,将帮助你决定是否使用常量表达式,或者为了提高效率而以牺牲可读性为代价预计算表达式。

6.5 显式常量与只读内存对象

C/C++程序员可能注意到,上一节没有讨论 C/C++ const声明的使用。这是因为你在 C/C++ const语句中声明的符号名(以下简称符号)不一定是显式常量。也就是说,C/C++并不总是在源文件中每次出现符号时都替换它的值。相反,C/C++编译器可能将该const值存储在内存中,然后像引用静态(只读)变量一样引用该const对象。这样,const对象和静态变量之间唯一的区别是,C/C++编译器不允许你在运行时给const赋值。

C/C++有时会将你在const语句中声明的常量当作静态变量处理,这是有充分理由的——它允许你在函数内创建局部常量,且这些常量的值可以在每次函数执行时变化(尽管在函数执行过程中,值保持不变)。这就是为什么你不能总是在 C/C++的const中使用这样的“常量”,并期望 C/C++编译器预计算它的值。

大多数 C++编译器会接受这个:


			const int arraySize = 128;
      .
      .
      .
int anArray[ arraySize ];

然而,他们不会接受这个序列:


			const int arraySizes[2] = {128,256}; // This is legal
const int arraySize = arraySizes[0]; // This is also legal

int array[ arraySize ]; // This is not legal

arraySizearraySizes 都是常量。然而,C++ 编译器不允许你使用 arraySizes 常量,或者基于它的任何内容作为数组边界。这是因为 arraySizes[0] 实际上是一个运行时内存位置,因此 arraySize 也必须是一个运行时内存位置。理论上,你可能认为编译器会足够智能,能够推断出 arraySize 在编译时是可以计算的,并将其值(128)直接替代。然而,C++ 语言并不允许这么做。

6.6 Swift let 语句

在 Swift 编程语言中,你可以使用 let 语句来创建常量。例如:


			let someConstant = 5

然而,该值在运行时绑定到常量的名称(也就是说,这是一个动态绑定)。赋值运算符(=)右侧的表达式不一定是常量表达式;它可以是包含变量和其他非常量组件的任意表达式。每次程序执行此语句时(例如在循环中),程序可能会为 someConstant 绑定一个不同的值。

Swift 的 let 语句并不真正定义常量,至少不像传统意义上的常量那样;它允许你创建“只写”变量。换句话说,在你使用 let 语句定义的符号的作用域内,你只能初始化该名称一次。请注意,如果你离开并重新进入该名称的作用域,值会被销毁(在退出作用域时),并且你可以在重新进入作用域时为该名称绑定一个新的(可能不同的)值。与 C++ 中的 const int 声明不同,let 语句不允许你在只读内存中为对象分配存储空间。

6.7 枚举类型

编写良好的程序通常使用一组名称来表示没有明确数值表示的现实世界量。例如,这样一组名称可能是各种显示技术,如 crtlcdledplasma。尽管现实世界并未将数值与这些概念关联,但如果你希望在计算机系统中高效地表示它们,你必须将这些值编码为数字。每个符号的内部表示通常是任意的,只要我们分配的值是唯一的。许多计算机语言提供了 枚举数据类型,它自动将唯一的值与列表中的每个名称关联起来。通过在程序中使用枚举数据类型,你可以为数据分配有意义的名称,而不是使用类似 0、1、2 等“魔法数字”。

例如,在 C 语言的早期版本中,你会按照以下方式创建一系列唯一值的标识符:


			/*
   Define a set of symbols representing the
   different display technologies
*/

#define crt 0
#define lcd (crt+1)
#define led (lcd+1)
#define plasma (led+1)

通过分配连续的值,你可以确保每个值都是唯一的。这个方法的另一个优点是它对值进行了排序。也就是说,crt < lcd < led < plasma。不幸的是,这种方式创建显式常量既繁琐又容易出错。

幸运的是,大多数语言的枚举常量可以解决这个问题。 “枚举”意味着编号,这正是编译器所做的——它为每个常量编号,从而处理分配值给枚举常量的记录细节。

大多数现代编程语言都提供了声明枚举类型和常量的支持。以下是来自 C/C++、Pascal、Swift 和 HLA 的一些示例:


			enum displays {crt, lcd, led, plasma, oled };       // C++
type displays = (crt, lcd, led, plasma, oled );     // Pascal
type displays :enum{crt, lcd, led, plasma, oled };  // HLA
// Swift example:
enum Displays
{
    case crt
    case lcd
    case led
    case plasma
    case oled
}

这四个示例内部将 0crt 关联,1lcd2led3plasma4oled。同样,确切的内部表示无关紧要(只要每个值都是唯一的),因为该值的唯一目的是区分枚举对象。

大多数语言会为枚举列表中的符号分配单调递增的值(即每个后续值都大于所有前面的值)。因此,这些示例具有以下关系:

crt < lcd < led < plasma < oled

不要让这个给你留下这样一种印象:单个程序中出现的所有枚举常量都有唯一的内部表示。大多数编译器会将枚举列表中的第一个项分配值 0,第二个项分配值 1,以此类推。例如,考虑以下 Pascal 类型声明:


			type
    colors = (red, green, blue);
    fasteners = (bolt, nut, screw, rivet );

大多数 Pascal 编译器会将 0 作为 redbolt 的内部表示;将 1 用于 greennut;以此类推。在一些强制进行类型检查的语言(如 Pascal 和 Swift)中,通常不能在同一表达式中使用类型为 colorsfasteners 的符号。因此,这些符号共享相同的内部表示并不是问题,因为编译器的类型检查机制会防止任何可能的混淆。然而,一些语言(如 C/C++ 和汇编语言)并不提供强类型检查,因此可能会发生这种混淆。在这些语言中,避免混用不同类型的枚举常量是程序员的责任。

大多数编译器会分配 CPU 可以高效访问的最小内存单元来表示枚举类型。由于大多数枚举类型声明定义的符号少于 256 个,因此在能够高效访问字节数据的机器上,编译器通常会为任何具有枚举数据类型的变量分配一个字节。许多 RISC 机器上的编译器可以分配一个 32 位字(或更多),因为访问这些数据块更快。确切的表示方法依赖于语言和编译器/实现,因此请查阅编译器的参考手册以获取详细信息。

6.8 布尔常量

许多高级编程语言提供布尔逻辑常量来表示truefalse的值。因为布尔值只有两个可能的值,所以它们的表示只需要一个位。然而,由于大多数 CPU 不允许你分配单个位的存储空间,大多数编程语言使用整个字节甚至更大的对象来表示布尔值。那么,布尔对象中剩余的位会怎样呢?不幸的是,答案因语言而异。

许多语言将布尔数据类型视为枚举类型。例如,在 Pascal 中,布尔类型定义如下:


			type
    boolean = (false, true);

这种声明将内部值0false关联,将1true关联。这个关联具有一些理想的属性:

  • 大多数布尔函数和运算符按预期工作——例如,(truetrue) = true,(truefalse) = false,等等。

  • 当你比较这两个值时,false小于true——这是一个直观的结果。

不幸的是,将0false1true关联并不总是最佳的解决方案。以下是一些原因:

  • 某些布尔运算应用于位串时,不会产生预期的结果。例如,你可能期望(not false)等于true。然而,如果你将布尔变量存储在 8 位对象中,那么(not false)的结果是$FF,这不等于true1)。

  • 许多 CPU 提供指令,可以在操作后轻松检测0或非零;很少有 CPU 提供隐式的1检测。

许多语言,如 C、C++、C#和 Java,将0视为false,其他任何值视为true。这样做有几个优点:

  • 提供简便的0和非零检查的 CPU 可以轻松测试布尔结果。

  • 0/非零表示法无论布尔变量存储对象的大小如何,都有效。

不幸的是,这种方案也有一些缺点:

  • 许多按位逻辑运算在应用于0和非零布尔值时会产生不正确的结果。例如,$A5true/非零)与$5Atrue/非零)进行与运算结果为0false)。按逻辑与运算,truetrue不应该产生false。类似地,(NOT $A5)结果是$5A。通常,你会期望(NOT true)应该产生false而不是true$5A)。

  • 当位串被当作二进制补码有符号整数值处理时,某些true的值可能小于零(例如,8 位值$FF等于-1)。因此,在某些情况下,false小于true的直观结果可能不正确。

除非你在汇编语言中工作(在这种情况下,你可以定义truefalse的值),否则你必须接受高级语言(HLL)中表示布尔值的方案,正如它在语言参考手册中所解释的那样。

了解你的编程语言如何表示truefalse,可以帮助你编写出生成更好机器代码的高级源代码。例如,假设你正在编写 C/C++代码。在这些语言中,false0true是其他任何值。考虑下面的 C 语言语句:


			int i, j, k;
      .
      .
      .
    i = j && k;

许多编译器为这个赋值语句生成的机器代码是非常糟糕的。它通常看起来像下面这样(Visual C++输出):


			; Line 8
        cmp     DWORD PTR j$[rsp], 0
        je      SHORT $LN3@main
        cmp     DWORD PTR k$[rsp], 0
        je      SHORT $LN3@main
        mov     DWORD PTR tv74[rsp], 1
        jmp     SHORT $LN4@main
$LN3@main:
        mov     DWORD PTR tv74[rsp], 0
$LN4@main:
        mov     eax, DWORD PTR tv74[rsp]
        mov     DWORD PTR i$[rsp], eax
;

现在,假设你始终确保使用0表示false,使用1表示true(且不允许使用其他值)。在这种条件下,你可以将之前的语句写成这样:

i = j & k;  /* Notice the bitwise AND operator */

这是 Visual C++为前述语句生成的代码:


			; Line 8
        mov     eax, DWORD PTR k$[rsp]
        mov     ecx, DWORD PTR j$[rsp]
        and     ecx, eax
        mov     DWORD PTR i$[rsp], ecx

如你所见,这段代码显著更好。只要你始终使用1表示true0表示false,你就可以使用按位与(&)和按位或(|)操作符代替逻辑运算符。^(3) 如前所述,使用按位取反操作符无法得到一致的结果;但是,你可以通过以下方式实现正确的逻辑非操作:

i = ~j & 1; /* "~" is C's bitwise not operator */

这个简短的代码片段会反转j中的所有位,然后清除除第 0 位以外的所有位。

关键是,你应该非常清楚你的编译器如何表示布尔常量。如果你有选择权(例如任何非零值),那么你可以为truefalse选择适当的值,以帮助你的编译器生成更好的代码。

6.9 浮点常量

浮点常量在大多数计算机架构中是特殊情况。因为浮点表示可能会消耗大量位数,很少有 CPU 提供立即寻址模式来将任意常量加载到浮点寄存器中。即使是小的(32 位)浮点常量也是如此。即使是在许多 CISC 处理器上,如 80x86,也是如此。因此,编译器通常需要将浮点常量放置在内存中,然后让程序从内存中读取它们,就像它们是变量一样。例如,考虑以下 C 程序:


			#include <stdlib.h>
#include <stdio.h>
int main( int argc, char **argv, char **envp )
{
  static int j;
  static double i = 1.0;
  static double a[8] = {0,1,2,3,4,5,6,7};
  j = 0;
  a[j] = i+1.0;

}

现在考虑 GCC 为这个程序使用-O2选项生成的 PowerPC 代码:


			.lcomm _j.0,4,2
.data
// This is the variable i.
// As it is a static object, GCC emits the data directly
// for the variable in memory. Note that "1072693248" is
// the HO 32-bits of the double-precision floating-point
// value 1.0, 0 is the LO 32-bits of this value (in integer
// form).

    .align 3
_i.1:
    .long       1072693248
    .long       0

// Here is the "a" array. Each pair of double words below
// holds one element of the array. The funny integer values
// are the integer (bitwise) representation of the values
// 0.0, 1.0, 2.0, 3.0, ..., 7.0.

    .align 3
_a.2:
    .long       0
    .long       0
    .long       1072693248
    .long       0
    .long       1073741824
    .long       0
    .long       1074266112
    .long       0
    .long       1074790400
    .long       0
    .long       1075052544
    .long       0
    .long       1075314688
    .long       0
    .long       1075576832
    .long       0

// The following is a memory location that GCC uses to represent
// the literal constant 1.0\. Note that these 64 bits match the
// same value as a[1] in the _a.2 array. GCC uses this memory
// location whenever it needs the constant 1.0 in the program.

.literal8
    .align 3
LC0:
    .long       1072693248
    .long       0

// Here's the start of the main program:

.text
    .align 2
    .globl _main
_main:

// This code sets up the static pointer register (R10), used to
// access the static variables in this program.

    mflr r0
    bcl 20,31,L1$pb
L1$pb:
    mflr r10
    mtlr r0

    // Load floating-point register F13 with the value
    // in variable "i":

    addis r9,r10,ha16(_i.1-L1$pb)  // Point R9 at i
    li r0,0
    lfd f13,lo16(_i.1-L1$pb)(r9)   // Load F13 with i's value.

    // Load floating-point register F0 with the constant 1.0
    // (which is held in "variable" LC0:

    addis r9,r10,ha16(LC0-L1$pb)   // Load R9 with the
                                   //  address of LC0
    lfd f0,lo16(LC0-L1$pb)(r9)     // Load F0 with the value
                                   //  of LC0 (1.0).

    addis r9,r10,ha16(_j.0-L1$pb)  // Load R9 with j's address
    stw r0,lo16(_j.0-L1$pb)(r9)    // Store a zero into j.

    addis r9,r10,ha16(_a.2-L1$pb)  // Load a[j]'s address into R9

    fadd f13,f13,f0                // Compute i+1.0

    stfd f13,lo16(_a.2-L1$pb)(r9)  // Store sum into a[j]
    blr                            // Return to caller

由于 PowerPC 处理器是一个 RISC CPU,GCC 为这个简单的代码序列生成的代码相当复杂。为了与 CISC 等效代码进行对比,请看下面的 80x86 的 HLA 代码;它是 C 代码逐行翻译的结果:


			program main;
static
    j:int32;
    i:real64 := 1.0;
    a:real64[8] := [0,1,2,3,4,5,6,7];

readonly
    OnePointZero : real64 := 1.0;

begin main;

    mov( 0, j );  // j=0;

    // push i onto the floating-point stack
 fld( i );

    // push the value 1.0 onto the floating-point stack

    fld( OnePointZero );

    // pop i and 1.0, add them, push sum onto the FP stack

    fadd();

    // use j as an index

    mov( j, ebx );

    // Pop item off FP stack and store into a[j].

    fstp( a[ ebx*8 ] );

end main;

这段代码比 PowerPC 代码更容易理解(这是 CISC 代码优于 RISC 代码的一个优势)。注意,和 PowerPC 一样,80x86 不支持大多数浮点操作数的立即寻址模式。因此,和 PowerPC 一样,你必须将常量1.0的副本放置在某个内存位置,并在需要使用1.0的值时访问该内存位置。^(4)

因为大多数现代 CPU 不支持对所有浮点常量使用立即寻址模式,所以在程序中使用这些常量等同于访问用这些常量初始化的变量。别忘了,如果你访问的内存位置不在数据缓存中,访问内存可能会非常慢。因此,使用浮点常量可能比访问适合寄存器的整数或其他常量值慢得多。

请注意,一些 CPU 确实允许将某些浮点立即常量编码为指令的操作码的一部分。例如,80x86 处理器有一个特殊的“加载零”指令,它将0.0加载到浮点栈中。ARM 处理器也提供了一条指令,允许将某些浮点常量加载到 CPU 浮点寄存器中(请参阅附录 C 在线中的“vmov指令”)。

在 32 位处理器上,CPU 通常可以使用整数寄存器和立即寻址模式执行简单的 32 位浮点运算。例如,你可以通过加载一个 32 位整数寄存器,将该数值的比特模式加载进去,然后将整数寄存器存储到浮点变量中,从而轻松地将一个 32 位单精度浮点值赋给变量。考虑以下代码:


			#include <stdlib.h>
#include <stdio.h>
int main( int argc, char **argv, char **envp )
{

  static float i;

  i = 1.0;

}

下面是 GCC 为此序列生成的 PowerPC 代码:


			.lcomm _i.0,4,2 // Allocate storage for float variable i

.text
    .align 2
    .globl _main
_main:

    // Set up the static data pointer in R10:

    mflr r0
    bcl 20,31,L1$pb
L1$pb:
    mflr r10
    mtlr r0

    // Load the address of i into R9:

    addis r9,r10,ha16(_i.0-L1$pb)

    // Load R0 with the floating-point representation of 1.0
    // (note that 1.0 is equal to 0x3f800000):

    lis r0,0x3f80 // Puts 0x3f80 in HO 16 bits, 0 in LO bits

    // Store 1.0 into variable i:

    stw r0,lo16(_i.0-L1$pb)(r9)

    // Return to whomever called this code:

    blr

作为 CISC 处理器,80x86 使得在汇编语言中执行此任务变得非常简单。下面是实现相同功能的 HLA 代码:


			program main;
static
    i:real32;
begin main;

    mov( $3f800_0000, i ); // i = 1.0;

end main;

将单精度浮点常量简单地赋值给浮点变量通常可以利用 CPU 的立即寻址模式,从而节省访问内存的开销(因为内存中的数据可能不在缓存中)。不幸的是,编译器并不总是利用这种技巧将浮点常量赋值给双精度变量。例如,PowerPC 或 ARM 上的 GCC 会退回到将常量保存在内存中,并在将常量赋值给浮点变量时复制该内存位置的值。

大多数优化编译器足够智能,能够在内存中维护它们创建的常量表。因此,如果你在源文件中多次引用常量2.0(或任何其他浮点常量),编译器只会为该常量分配一个内存对象。然而,请记住,这种优化仅在同一个源文件内有效。如果你在不同的源文件中引用相同的常量值,编译器可能会为该常量创建多个副本。

的确,拥有多个数据副本会浪费存储空间,但考虑到大多数现代系统的内存容量,这只是一个小问题。更大的问题是,程序通常以随机方式访问这些常量,因此它们很少驻留在缓存中,实际上,它们往往会将其他更常用的数据从缓存中逐出。

解决这个问题的一个方法是自己管理浮动点“常量”。因为就程序而言,这些常量实际上是变量,你可以负责这个过程,并将需要的浮动点常量放入已初始化的静态变量中。例如:


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

static double OnePointZero_c = 1.0;

int main( int argc, char **argv, char **envp )
{
  static double i;

  i = OnePointZero_c;
}

当然,在这个例子中,通过将浮动点常量处理为静态变量,你根本不会获得任何好处。然而,在更复杂的情况下,当你有多个浮动点常量时,你可以分析程序,确定哪些常量经常被访问,并将这些常量的变量放置在相邻的内存位置。由于大多数 CPU 处理引用的空间局部性的方式(参见WGC1),当你访问其中一个常量对象时,缓存行将被填充相邻对象的值。因此,当你在短时间内访问其他对象时,它们的值很可能已经在缓存中。自己管理这些常量的另一个优点是,你可以创建一个全局常量集合,可以在不同的编译单元(源文件)中引用,这样程序在访问某个常量时只会访问一个内存对象,而不是多个内存对象(每个编译单元一个)。编译器通常没有足够的智能来做出有关数据的这种决策。

6.10 字符串常量

像浮动点常量一样,字符串常量也无法被大多数编译器高效处理(即使它们是字面值常量或显式常量)。理解何时应该使用显式常量,何时应将其替换为内存引用,可以帮助你指导编译器生成更好的机器代码。例如,大多数 CPU 无法将字符串常量编码为指令的一部分。使用显式字符串常量实际上可能使程序的效率降低。考虑以下 C 代码:


			#define strConst "A string constant"
        .
        .
        .
    printf( "string: %s\n", strConst );
        .
        .
        .
    sptr = strConst;
        .
        .
        .
    result = strcmp( s, strConst );
        .
        .
        .

编译器(实际上是 C 预处理器)将宏strConst展开为字符串字面值"A string constant",每当标识符strConst出现在源文件中时,所以这段代码实际上等价于:


			    .
    .
    .
printf( "string: %s\n", "A string constant" );
    .
    .
    .
sptr = "A string constant";
    .
    .
    .
result = strcmp( s, "A string constant" );

这段代码的问题在于相同的字符串常量在程序的不同位置出现。在 C/C++中,编译器将字符串常量放入内存并替换为指向该字符串的指针。一个没有优化的编译器可能会在内存中创建三份相同的字符串副本,这会浪费空间,因为这三份数据是完全相同的。(记住,我们这里说的是常量字符串。)

编译器开发者几十年前发现了这个问题,并修改了编译器以跟踪给定源文件中的字符串。如果一个程序多次使用相同的字符串字面常量,编译器不会为第二个副本的字符串分配存储空间,而是直接使用第一个字符串的地址。这种优化(常量折叠)可以减少代码的大小,特别是当相同的字符串出现在源文件的多个地方时。

不幸的是,常量折叠并不总是正常工作。一个问题是,许多旧的 C 程序将字符串字面常量分配给字符指针变量,然后继续修改该字面字符串中的字符。例如:


			sptr = "A String Constant";
    .
    .
    .
*(sptr+2) = 's';
    .
    .
    .
/* The following displays "string: 'a string Constant'" */

printf( "string: '%s'\n", sptr );
    .
    .
    .
/* This prints "a string Constant"! */

printf( "A String Constant" );

重用相同字符串常量的编译器会失败,如果用户将数据存储到字符串对象中,就像这段代码演示的那样。虽然这是不良的编程实践,但在旧的 C 程序中,这种情况足够频繁,以至于编译器供应商无法为多个副本的相同字符串字面量使用相同的存储空间。即使编译器供应商将字符串字面常量放入只读内存以防止这个问题,仍然会出现其他语义问题。这引出了如下的 C/C++ 代码:


			sptr1 = "A String Constant";
sptr2 = "A String Constant";
s1EQs2 = sptr1 == sptr2;

执行完这段指令序列后,s1EQs2 会包含 true1)还是 false0)?在 C 编译器没有良好优化器的早期程序中,这段语句会让 s1EQs2false。这是因为编译器创建了两个不同的字符串副本,并将这些字符串放置在内存的不同地址(因此程序分配给 sptr1sptr2 的地址会不同)。在一个后来的编译器中,如果编译器仅保留字符串数据的单一副本,这段代码序列会使 s1EQs2true,因为 sptr1sptr2 会指向相同的内存地址。无论字符串数据是否出现在受保护的内存中,这种差异都存在。

为了解决这个难题,许多编译器供应商提供了一个编译器选项,允许程序员决定编译器是应生成每个字符串的单一副本,还是为每个字符串的出现生成单独的副本。如果你不向字符串字面常量写入数据或比较它们的地址,可以选择这个选项来减少程序的大小。如果你有旧代码需要单独的字符串数据副本(希望你不会再写需要这种方式的新代码),你可以启用此选项。

不幸的是,许多程序员完全没有意识到这个选项,且一些编译器的默认条件通常是创建字符串数据的多个副本。如果你正在使用 C/C++ 或其他通过字符数据指针操作字符串的语言,检查编译器是否提供合并相同字符串的选项,如果有的话,启用该功能。

如果你的 C/C++ 编译器没有提供这个字符串合并优化,你可以手动实现它。为此,只需在程序中创建一个char数组变量,并用字符串的地址进行初始化。然后,像使用常量一样在整个程序中使用该数组变量的名称。例如:


			char strconst[] = "A String Constant";
        .
        .
        .
    sptr = strconst;
        .
        .
        .
    printf( strconst );
        .
        .
        .
    if( strcmp( string, strconst ) == 0 )
    {
        .
        .
        .
    }

这段代码将只在内存中保持一个字符串字面量常量的副本,即使编译器并不直接支持该优化。事实上,即使你的编译器直接支持此优化,仍然有几个很好的理由让你使用这个技巧,而不是依赖编译器为你完成这个工作。

  • 将来你可能需要将代码移植到一个不支持此优化的不同编译器。

  • 通过手动处理优化,你就不必担心这个问题了。

  • 通过使用指针变量而非字符串字面量常量,你可以在程序控制下轻松更改该指针所指向的字符串。

  • 将来你可能需要修改程序,以便在程序控制下切换(自然)语言。

  • 你可以在多个文件之间轻松共享字符串。

这个字符串优化讨论假设你的编程语言通过引用操作字符串(即,通过使用指向实际字符串数据的指针)。虽然对于 C/C++ 程序来说这确实是事实,但并非所有语言都如此。支持字符串的 Pascal 实现(如 Free Pascal)通常是通过值而非通过引用来操作字符串。每当你将一个字符串值赋给一个字符串变量时,编译器会复制字符串数据,并将该副本放入为字符串变量保留的存储空间中。这个复制过程可能会很耗费资源,如果你的程序从不修改字符串变量中的数据,那么这种复制就是不必要的。更糟糕的是,如果(Pascal)程序将字符串字面量赋给字符串变量,程序将会有两个字符串副本在内存中(一个是字符串字面量常量,另一个是程序为字符串变量所做的副本)。如果程序以后再也不修改这个字符串(这并不罕见),它将浪费内存,通过保留两个字符串副本来维护一个本可以只保留一个副本的字符串。这些原因(空间和速度)可能就是 Borland 在创建 Delphi 4.0 时采用了更复杂的字符串格式,而放弃了早期版本 Delphi 中的字符串格式的原因。^(5)

Swift 也将字符串视为值对象。这意味着,在最坏的情况下,每当你将一个字符串字面量赋值给字符串变量时,Swift 会复制该字符串字面量。然而,Swift 实现了一种名为按需复制的优化。每当你将一个字符串对象赋给另一个,Swift 只会复制一个指针。因此,如果多个字符串被赋值相同的值,Swift 会为所有副本在内存中使用相同的字符串数据。当你修改字符串的某个部分时,Swift 会在修改之前先复制字符串(因此称为“按需复制”),以确保引用原始字符串数据的其他字符串对象不会受到该修改的影响。

6.11 复合数据类型常量

许多语言除了字符串外,还支持其他复合常量类型(如数组、结构体/记录和集合)。通常,这些语言使用这些常量在程序执行前静态初始化变量。例如,考虑以下 C/C++代码:

static int arrayOfInts[8] = {1,2,3,4,5,6,7,8};

注意,arrayOfInts 不是一个常量。相反,它是构成数组常量的初始化器——即{1,2,3,4,5,6,7,8}。在可执行文件中,大多数 C 编译器只是在与arrayOfInts关联的地址上叠加这八个整数值。

例如,下面是 GCC 为这个变量输出的内容:


			LC0:          // LC0 is the internal label associated
              //  with arrayOfInts
    .long       1
    .long       2
    .long       3
    .long       4
    .long       5
    .long       6
    .long       7
    .long       8

假设arrayOfInts是 C 中的静态对象,那么存储常量数据不会占用额外的空间。

然而,如果你正在初始化的变量不是静态分配的对象,规则就会发生变化。考虑以下简短的 C 代码序列:


			int f()
{
  int arrayOfInts[8] = {1,2,3,4,5,6,7,8};
    .
    .
    .
} // end f

在这个例子中,arrayOfInts是一个自动变量,这意味着每次程序调用函数f()时,程序都会在栈上为该变量分配存储空间。因此,编译器不能仅仅在程序加载到内存时使用常量数据来初始化数组。arrayOfInts对象实际上可能在每次激活函数时位于不同的地址。为了遵循 C 编程语言的语义,编译器必须复制数组常量,并在程序调用该函数时将该常量数据物理复制到arrayOfInts变量中。以这种方式使用数组常量会消耗额外的空间(用于存储数组常量的副本)和额外的时间(用于复制数据)。有时,算法的语义要求每次新激活函数f()时都要获取数据的新副本。然而,你需要认识到什么时候这是必要的(以及什么时候额外的空间和时间是值得的),而不是无谓地浪费内存和 CPU 周期。

如果你的程序没有修改数组的数据,你可以使用一个静态对象,编译器可以在加载程序到内存时初始化该对象一次:


			int f()
{
  static int arrayOfInts[8] = {1,2,3,4,5,6,7,8};
    .
    .
    .
} // end f

C/C++语言也支持结构体常量。当初始化自动变量时,我们看到的数组的空间和速度问题,同样适用于结构体常量。

Embarcadero 的 Delphi 编程语言也支持结构化常量,尽管这里的“常量”一词有些误导。Embarcadero 称它们为 类型化常量,你可以在 Delphi 的 const 部分这样声明:


			const
    ary: array[0..7] of integer = (1,2,3,4,5,6,7,8);

尽管声明出现在 Delphi 的 const 部分,Delphi 实际上将其视为变量声明。这是一个不太理想的设计选择,但对于想要创建结构化常量的程序员来说,这种机制是可行的。与本节中的 C/C++ 示例一样,重要的是要记住,示例中的常量实际上是 (1,2,3,4,5,6,7,8) 对象,而不是 ary 变量。

Delphi(以及大多数现代 Pascal,如 Free Pascal)也支持其他几种复合常量类型。例如,集合常量就是一个很好的例子。每当你创建一个对象集合时,Pascal 编译器通常会用集合数据的幂集(位图)表示来初始化某个内存位置。每当你在程序中引用该集合常量时,Pascal 编译器会生成一个指向集合常量数据的内存引用。

Swift 也支持复合数据类型常量,如数组、元组、字典、结构体/类以及其他数据类型。例如,以下 let 语句创建了一个包含八个元素的数组常量:


			let someArray = [1,2,3,4,11,12,13,14]

6.12 常量不变

理论上,绑定到常量的值是不会改变的(Swift 中的 let 语句是一个明显的例外)。在现代系统中,将常量放入内存的编译器通常会将它们放置在写保护内存区域中,以便在发生意外写入时强制引发异常。当然,很少有程序仅使用只读(或一次写入)对象来编写。大多数程序都需要能够改变它们操作的对象(变量)的值。这是下一章的内容。

6.13 更多信息

Duntemann, Jeff. 逐步学习汇编语言. 第 3 版. 印第安纳波利斯:Wiley,2009 年。

Hyde, Randall. 汇编语言的艺术. 第 2 版. 旧金山:No Starch Press,2010 年。

——. 编写高质量代码,第 1 卷:理解机器. 第 2 版. 旧金山:No Starch Press,2020 年。

第七章:高级语言中的变量

image

本章探讨了高级语言中变量的低级实现。尽管汇编语言程序员通常能较好地理解变量与内存位置之间的联系,但高级语言通过足够的抽象来掩盖这种关系。我们将讨论以下主题:

  • 大多数编译器的典型运行时内存组织

  • 编译器如何将内存分割成不同的区域,并将变量放入每个区域

  • 区分变量与其他对象的属性

  • 静态变量、自动变量和动态变量之间的区别

  • 编译器如何在堆栈帧中组织自动变量

  • 硬件为变量提供的基本数据类型

  • 机器指令如何编码变量的地址

当你读完本章后,应该能够清楚地了解如何在程序中声明变量,以使用最少的内存并生成运行速度较快的代码。

7.1 运行时内存组织

正如第四章所讨论的,操作系统(如 macOS、Linux 或 Windows)将不同类型的数据放入主内存的不同区域(或)。虽然通过运行链接器并指定各种命令行参数可以控制内存组织,但默认情况下,Windows 会使用图 7-1 中所示的内存组织将典型程序加载到内存中(macOS 和 Linux 类似,尽管它们重新排列了一些区域)。

Image

图 7-1:Windows 的典型运行时内存组织

操系统保留最低的内存地址。通常,您的应用程序无法访问内存中最低地址的数据(或执行指令)。操作系统保留这一空间的原因之一是帮助检测NULL指针引用。程序员通常使用NULL0)来初始化指针,表示该指针无效。如果在这样的操作系统下尝试访问内存地址0,操作系统将生成一般保护故障,以表示这是一个无效的内存位置。

剩下的七个内存区域包含与程序相关的不同类型的数据:堆栈、堆、代码、常量、只读数据、静态(已初始化)变量和存储(未初始化)变量。

大多数情况下,给定的应用程序可以使用编译器和链接器/加载器为这些段选择的默认布局。然而,在某些情况下,了解内存布局可以帮助你开发更简短的程序。例如,由于代码段通常是只读的,你可能可以将代码段、常量段和只读数据段合并为一个单独的段,从而节省编译器/链接器可能在这些段之间插入的任何填充空间。尽管对于大型应用程序来说,这可能并不显著,但对于小型程序而言,这可能对可执行文件的大小产生较大影响。

接下来我们将详细讨论这些段。

7.1.1 代码段、常量段和只读段

内存中的代码(或文本)段包含程序的机器指令。你的编译器将你写的每个语句转换为一个或多个字节值(机器指令操作码)序列。在程序执行过程中,CPU 会解释这些操作码值。

大多数编译器还将程序的只读数据和常量池(常量表)段附加到代码段,因为与代码指令一样,只有只读数据已经是写保护的。然而,在 Windows、macOS、Linux 和许多其他操作系统下,完全可以在可执行文件中创建一个独立的段并标记为只读。因此,一些编译器确实支持独立的只读数据段,甚至有些编译器为编译器生成的常量创建了一个不同的段(常量池)。这些段包含初始化数据、表格和程序在执行过程中不应修改的其他对象。

许多编译器生成多个代码段,并将其交由链接器在执行之前合并为一个单一的代码段。为了理解为什么,考虑以下简短的 Pascal 代码片段:


			if( SomeBooleanExpression ) then begin

    << Some code that executes 99.9% of the time >>

end
else begin

    << Some code that executes 0.01% of the time >>

end;

假设编译器可以搞清楚,这个if语句的then部分比else部分执行得要频繁得多,而无需关心其具体实现。一个汇编程序员为了编写尽可能快的代码,可能会将这个序列编码如下:


			    << evaluate Boolean expression, leave true/false in EAX >>
    test( eax, eax );
    jz exprWasFalse;
    << Some code that executes 99.9% of the time >>
rtnLabel:
    << Code normally following the last END in the
               Pascal example >>
        .
        .
        .
// somewhere else in the code, not in the direct execution path
// of the above:

exprWasFalse:
    << Some code that executes 0.1% of the time >>

    jmp rtnLabel;

这段汇编代码可能看起来有些复杂,但请记住,任何控制转移指令可能会因为现代 CPU 上的流水线操作而消耗大量时间(有关详细信息,请参见WGC1的第九章)。没有分支(或者直接执行)的代码执行速度最快。在前面的例子中,常见情况是 99.9%的时间都直接执行。罕见的情况则执行两个分支(一个跳转到else段,另一个返回正常控制流)。但因为这段代码很少执行,所以可以容忍执行时间较长。

许多编译器使用一个小技巧,在它们生成的机器代码中像这样移动代码段——它们顺序地发出代码,但将else代码放置在一个独立的段中。以下 MASM 代码演示了这种技术:


			    << evaluate Boolean expression, leave true/false in EAX >>
    test eax, eax
    jz exprWasFalse
    << Some code that executes 99.9% of the time >>
alternateCode segment

exprWasFalse:
    << Some code that executes 0.1% of the time >>

    jmp rtnLabel;
alternateCode ends

rtnLabel:
    << Code normally following the last END in the Pascal example >>

尽管else部分的代码似乎紧接着then部分的代码,但将其放置在不同的段落中会告诉汇编器/链接器将这段代码移动并与其他代码合并到alternateCode段中。这个小技巧依赖于汇编器或链接器来移动代码,因此可以简化高级语言编译器(例如,GCC 就采用这种方法来移动它发出的汇编语言文件中的代码)。因此,你会偶尔看到这个技巧被使用,并且可以预期某些编译器会生成多个代码段。

7.1.2 静态变量部分

许多语言在编译阶段提供初始化全局变量的能力。例如,在 C/C++ 中,你可以使用如下语句为这些静态对象提供初始值:


			static int i = 10;
static char ch[] = { 'a', 'b', 'c', 'd' };

在 C/C++ 和其他语言中,编译器将这些初始值放置在可执行文件中。当你执行应用程序时,操作系统会将包含这些静态变量的可执行文件部分加载到内存中,以便这些值出现在与这些变量相关联的地址上。因此,当本示例中的程序第一次开始执行时,ich将会绑定这些初始值。

静态部分通常在大多数编译器生成的汇编清单中被称为DATA_DATA段。以下是一个 C 代码片段的示例:


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

static char *c = "";
static int i = 2;
static int j = 1;
static double array[4] = {0.0, 1.0, 2.0, 3.0};

int main( void )
{

      .
      .
      .

以下是 Visual C++ 编译器为这些声明生成的 MASM 汇编代码:


			_DATA   SEGMENT
?c@@3PEADEA   DQ  FLAT:$SG6912                 ; c
?i@@3HA       DD  02H                          ; i
?j@@3HA       DD  01H                          ; j
?array@@3PANA DQ  00000000000000000r      ; 0  ; array
              DQ  03ff0000000000000r      ; 1
              DQ  04000000000000000r      ; 2
              DQ  04008000000000000r      ; 3
_DATA   ENDS

如你所见,Visual C++ 编译器将这些变量放置在_DATA段中。

7.1.3 存储变量部分

大多数操作系统会在程序执行前将内存清零。因此,如果初始值为0是合适的,你就不需要浪费磁盘空间存储静态对象的初始值。然而,通常情况下,编译器会将静态部分中的未初始化变量当作已初始化为0来处理,这会占用磁盘空间。一些操作系统提供了另一种段类型,存储变量部分(也称为BSS 段),以避免浪费磁盘空间。

该部分是编译器通常存储没有显式初始值的静态对象的地方。如第四章中所述,BSS 代表“由符号开始的块”(block started by a symbol),这是一个旧的汇编语言术语,用来描述一个伪操作码,用来为未初始化的静态数组分配存储空间。在像 Windows 和 Linux 这样的现代操作系统中,编译器/链接器会将所有未初始化的变量放入 BSS 区,这个区只告诉操作系统为该区域预留多少字节。当操作系统将程序加载到内存中时,它会为 BSS 区中的所有对象保留足够的内存,并将这一内存区域填充为零。请注意,可执行文件中的 BSS 区并不包含实际数据,因此,在 BSS 区中声明大型未初始化静态数组的程序会占用较少的磁盘空间。以下是前一部分的 C/C++ 示例,修改后去除了初始化程序,使得编译器会将变量放入 BSS 区:


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

static char *c;
static int i;
static int j;
static double array[4];

int main( void )
{
      .
      .
      .

这是 Visual C++ 的输出:


			_BSS    SEGMENT
?c@@3PEADEA   DQ  01H DUP (?)                             ; c
?i@@3HA       DD  01H DUP (?)                             ; i
?j@@3HA       DD  01H DUP (?)                             ; j
?array@@3PANA DQ  04H DUP (?)                             ; array
_BSS    ENDS

不是所有的编译器都会使用 BSS 区。例如,许多微软的语言和链接器会将未初始化的对象与静态/只读数据区合并,并明确地给它们一个初始值 0。尽管微软声称这种方案更快,但如果你的代码中有大型的未初始化数组,它会导致可执行文件变得更大(因为数组的每个字节都会出现在可执行文件中——如果编译器将数组放入 BSS 区,这种情况是不会发生的)。不过,请注意,这是默认设置,你可以通过设置适当的链接器标志来更改它。

7.1.4 栈区

栈是一种数据结构,它会根据过程调用和返回等操作进行扩展和收缩。在运行时,系统将所有自动变量(非静态局部变量)、子例程参数、临时值以及其他对象放入内存的栈区,并使用一种叫做激活记录的特殊数据结构(这个名字非常贴切,因为系统会在子例程开始执行时创建它,并在子例程返回时将其释放)。因此,内存中的栈区非常繁忙。

许多 CPU 使用一种特殊用途的寄存器,称为栈指针,来实现栈操作。其他一些 CPU(特别是一些 RISC CPU)不提供明确的栈指针,而是使用一个通用寄存器来实现栈。如果 CPU 提供栈指针,我们称该 CPU 支持硬件栈;如果它使用通用寄存器,则我们称其使用软件实现的栈。80x86 就是一个支持硬件栈的 CPU 的良好例子,而 PowerPC 家族则是使用软件实现栈的 CPU 家族的典型例子(大多数 PowerPC 程序使用 R1 作为栈指针寄存器)。ARM CPU 支持伪硬件栈;它将一个通用寄存器指定为硬件栈指针,但仍然要求应用程序显式地维护栈。提供硬件栈的系统通常可以使用更少的指令来操作栈上的数据,而使用软件实现栈的系统则相对较多。另一方面,选择使用软件栈实现的 RISC CPU 设计师认为,硬件栈的存在实际上会减慢 CPU 执行的所有指令。理论上,你可以说 RISC 设计师是对的;但实际上,80x86 家族包含一些最快的 CPU,充分证明了拥有硬件栈并不意味着你会得到一款慢速 CPU。

7.1.5 堆区和动态内存分配

尽管简单的程序可能只需要静态和自动变量,但复杂的程序需要能够在程序控制下动态地分配和释放存储空间。在 C 和 HLA 语言中,您可以使用malloc()free()函数来完成此任务。C++提供了newdelete(以及std::unique_ptr)操作符。Pascal 使用newdispose。Java 和 Swift 使用new(这些语言中的内存释放是自动的)。其他语言也提供了类似的例程。这些内存分配例程有一些共同点:

  • 它们允许程序员请求分配多少字节的存储空间(可以通过显式指定要分配的字节数或指定已知大小的数据类型来实现)。

  • 它们返回一个指向新分配存储区的指针(即该存储区的地址)。

  • 它们提供了一种机制,当存储空间不再需要时,将其归还给系统,以便系统可以在未来的分配调用中重新使用它。

动态内存分配发生在称为堆区的内存区域。通常,应用程序通过指针变量隐式或显式地引用堆中的数据;一些语言,如 Java 和 Swift,在幕后隐式使用指针。因此,这些堆内存中的对象通常被称为匿名变量,因为它们是通过其内存地址(通过指针)而不是名称来引用的。

操作系统和应用程序在程序开始执行后创建堆内存部分;堆内存永远不是可执行文件的一部分。通常,操作系统和语言运行时库维护着应用程序的堆内存。尽管内存管理的实现方式有所不同,但了解堆内存分配和释放的基本原理是个好主意,因为不当使用它们会对应用程序性能产生非常负面的影响。

7.2 变量是什么?

如果你考虑一下“变量”这个词,很明显它描述的是某些变化的事物。但究竟是什么在变化呢?大多数程序员会说是程序执行过程中可能变化的值。实际上,有几件事是可以变化的,因此,在明确地定义一个变量之前,我们先来讨论一下变量(以及其他对象)可能具备的一些特征。

7.2.1 属性

一个属性是与某个对象相关联的某个特性。例如,变量的常见属性包括其名称、内存地址、大小(以字节为单位)、运行时值以及与该值相关联的数据类型。不同的对象可能拥有不同的属性集。例如,数据类型是一个对象,具有如名称和大小等属性,但通常没有与之关联的值或内存位置。常量可能具有如值和数据类型等属性,但它没有内存位置,且可能没有名称(例如,如果它是一个字面常量)。一个变量可能拥有所有这些属性。实际上,属性列表通常决定了一个对象是常量、数据类型、变量还是其他什么。

7.2.2 绑定

绑定,在第六章中介绍,是将一个属性与一个对象关联的过程。例如,当一个值被赋给一个变量时,这个值在赋值时被绑定到该变量。这个绑定将持续存在,直到某个其他值被绑定到该变量(通过另一次赋值操作)。同样,如果你在程序运行时为变量分配内存,变量就会在那个时刻与内存地址绑定。变量和地址会一直绑定,直到你为变量关联一个不同的地址。绑定不一定发生在运行时。例如,值在编译时就会被绑定到常量对象,这些绑定在程序运行时无法更改。同样,地址在编译时会与某些变量绑定,这些内存地址在程序执行过程中不能更改(有关详细信息,请参见第 150 页中的“绑定时间”)。

7.2.3 静态对象

静态对象在应用程序执行之前绑定了某个属性。常量是静态对象的典型例子;它们在整个程序执行过程中绑定了相同的值。^(1) 在 Pascal、C/C++和 Ada 等编程语言中,程序级别的全局变量也是静态对象的例子,因为它们在程序的生命周期中始终绑定相同的内存地址。系统在程序开始执行之前将属性绑定到静态对象(通常是在编译、链接,甚至加载期间,尽管也可以在更早的阶段绑定值)。

7.2.4 动态对象

动态对象在程序执行期间绑定某些属性。在程序运行时,程序可能会选择动态地更改该属性(动态地)。动态属性通常无法在编译时确定。动态属性的例子包括在运行时绑定到变量的值,以及在运行时绑定到某些变量的内存地址(例如,通过malloc()或其他内存分配函数调用)。

7.2.5 作用域

标识符的作用域是程序中标识符名称与对象绑定的部分。由于大多数编译语言中的名称仅在编译期间存在,因此作用域通常是一个静态属性(尽管在某些语言中它可以是动态的,稍后我会解释)。通过控制名称与对象绑定的位置,你可以在程序的其他地方重用该名称。

大多数现代编程语言(如 C/C++/C#、Java、Pascal、Swift 和 Ada)支持局部全局变量的概念。局部变量的名称仅在程序的某个特定部分内绑定到特定对象(例如,在某个特定函数内)。在该对象的作用域之外,名称可以绑定到不同的对象。这允许全局对象和局部对象共享相同的名称,而不会产生歧义。这看起来可能会引起混淆,但能够在整个项目中重用像ij这样的变量名称,可以避免为循环索引和程序中的其他用途发明同样无意义的唯一变量名称。对象声明的作用域决定了名称在哪个地方应用于给定对象。

在解释型语言中,解释器在程序执行期间维护标识符名称,因此作用域可以是动态属性。例如,在 BASIC 编程语言的不同版本中,dim是一个可执行语句。在执行dim之前,你定义的名称可能与执行dim之后完全不同。SNOBOL4 是另一种支持动态作用域的语言。然而,大多数编程语言避免使用动态作用域,因为使用它可能导致程序难以理解。

从技术上讲,作用域可以应用于任何属性,而不仅仅是名称,但本书将在名称绑定到给定变量的上下文中使用该术语。

7.2.6 生命周期

一个属性的lifetime从你第一次将该属性绑定到一个对象时开始,到你断开这个绑定的时刻为止,可能是通过将另一个属性绑定到该对象来实现。如果程序将某个属性与对象关联,并且从未断开该关联,那么该属性的生命周期从关联时开始,到程序终止时结束。例如,变量的生命周期从你第一次为该变量分配内存开始,到你释放该变量的存储时结束。因为程序在执行之前就绑定了静态对象(并且静态属性在程序执行期间不会改变),所以静态对象的生命周期从程序开始执行时起,直到程序终止。

7.2.7 变量定义

回到本节开头的问题,我们现在可以将变量定义为一个可以动态绑定值的对象。也就是说,程序可以在运行时改变变量的值属性。请注意,“可以”这个关键词。程序能够在运行时改变变量的值才是必要的;但它并不必须这么做,才能将对象视为一个变量。

虽然将一个值动态绑定到一个对象是变量的定义特征,但其他属性可能是动态的或静态的。例如,变量的内存地址可以在编译时静态绑定到变量,或者在运行时动态绑定。同样,某些语言中的变量具有动态类型,类型会在程序执行期间变化,而其他变量则具有静态类型,在应用程序执行过程中保持固定。只有值的绑定决定了对象是变量还是其他类型(例如常量)。

7.3 变量存储

值必须存储在内存中,并从内存中检索。^(2) 为了实现这一点,编译器必须将一个变量绑定到一个或多个内存位置。变量的类型决定了它需要多少存储空间。字符变量可能只需要一个字节的存储,而大型数组或记录可能需要成千上万、甚至更多的存储空间。为了将变量与某块内存关联,编译器(或运行时系统)会将该内存位置的地址绑定到该变量上。当一个变量需要两个或更多内存位置时,系统通常将第一个内存位置的地址绑定到该变量上,并假设在运行时该地址后面的连续位置也会与该变量绑定。

变量和内存位置之间可能存在三种类型的绑定:静态绑定、伪静态(自动)绑定和动态绑定。根据它们如何与内存位置绑定,变量通常被分类为静态、自动或动态。

7.3.1 静态绑定和静态变量

静态绑定发生在运行时之前,可能在四个时间点之一:语言设计时、编译时、链接时,或者当系统将应用程序加载到内存时(但在执行之前)。语言设计时绑定并不常见,但在一些语言中(尤其是汇编语言)会出现。编译时绑定在直接生成可执行代码的汇编器和编译器中很常见。链接时绑定也相当常见(例如,一些 Windows 编译器就是这样做的)。加载时绑定,当操作系统将可执行文件复制到内存中时,可能是静态变量最常见的绑定方式。我们将依次查看每一种可能性。

7.3.1.1 语言设计时绑定

地址可以在语言设计时分配,当语言设计者将语言定义的变量与特定的硬件地址(例如,I/O 设备或特殊类型的内存)关联时,这个地址在任何程序中都不会改变。这类对象在嵌入式系统中很常见,但在通用计算机系统的应用程序中很少见。例如,在 8051 微控制器上,许多 C 编译器和汇编器会自动将某些名称与 CPU 的 128 字节数据空间中的固定位置关联。汇编语言中的 CPU 寄存器引用是绑定到语言设计时某个位置的变量的典型例子。

7.3.1.2 编译时绑定

地址可以在编译时分配,当编译器知道它可以在运行时将静态变量放置到内存中的哪个区域时。通常,这种编译器会生成绝对机器代码,该代码必须在执行前加载到内存中的特定地址。大多数现代编译器生成可重定位代码,因此不属于这一类。然而,低端编译器、高速学生编译器以及嵌入式系统的编译器通常使用这种绑定技术。

7.3.1.3 链接时绑定

某些链接器和相关工具可以将应用程序的各种可重定位目标模块链接在一起,创建一个绝对加载模块。因此,虽然编译器生成可重定位代码,但链接器会将内存地址绑定到变量(以及机器指令)上。通常,程序员会通过命令行参数或链接脚本文件指定程序中所有静态变量的基地址;链接器将把静态变量绑定到从基地址开始的连续地址上。将应用程序放置在只读存储器(ROM)中的程序员(例如,PC 的 BIOS(基本输入/输出系统)ROM)通常会采用这种方案。

7.3.1.4 加载时绑定

静态绑定最常见的形式发生在加载时。微软的 PE/COFF 和 Linux 的 ELF 等可执行格式通常将重定位信息嵌入可执行文件中。当操作系统将应用程序加载到内存中时,它决定将静态变量对象的块放置在哪个位置,然后修补所有引用这些静态对象的指令中的地址。这使得加载器(例如操作系统)在每次加载静态对象到内存时都能为其分配一个不同的地址。

7.3.1.5 静态变量绑定

静态变量在程序执行之前就已经绑定了内存地址,并且相较于其他变量类型,它享有几个优势。由于编译器在运行时之前就知道静态变量的地址,因此它通常可以使用绝对寻址模式或其他简单的寻址模式来访问该变量。静态变量的访问通常比其他变量的访问更高效,因为它不需要任何额外的设置。^(3)

静态变量的另一个优点是它们保留与之绑定的任何值,直到你显式地绑定另一个值,或者程序终止。这意味着静态变量在其他事件(例如过程激活和停用)发生时仍然保留其值。在多线程应用程序中,不同线程也可以使用静态变量共享数据。

静态变量也有一些值得注意的缺点。首先,由于静态变量的生命周期与程序相同,它在程序运行期间一直占用内存。即使程序不再需要静态对象所持有的值,这一点依然成立。

静态变量的另一个缺点(尤其是在使用绝对寻址模式时)是整个绝对地址通常必须作为指令的一部分进行编码,这使得指令变得更大。事实上,在大多数 RISC 处理器上,甚至没有绝对寻址模式,因为你无法在单个指令中编码绝对地址。

最后,使用静态对象的代码不是可重入的(即两个线程或进程不能并发执行相同的代码序列);这意味着在多线程环境中使用这些代码时需要更多的努力(在多线程环境中,可能有两份相同的代码同时执行,并且都访问同一个静态对象)。然而,多线程操作引入了很多复杂性,超出了本章的范围,因此我们暂时忽略这个问题。

注意

有关静态对象使用的更多细节,请参阅任何一本好的操作系统设计或并发编程教科书。 《多线程、并行与分布式编程基础*》由 Gregory R. Andrews(Addison-Wesley,1999)出版,是一个很好的起点。

以下示例演示了在 C 程序中使用静态变量,并展示了 Visual C++ 编译器生成的用于访问它们的 80x86 代码:


			#include <stdio.h>

static int i = 5;
static int j = 6;

int main( int argc, char **argv)
{

    i = j + 3;
    j = i + 2;
    printf( "%d %d\n", i, j );
    return 0;
}

; The following are the memory declarations
; for the 'i' and 'j' variables. Note that
; these are declared in the global '_DATA'
; section.

_DATA   SEGMENT
i       DD      05H
j       DD      06H
$SG6835 DB      '%d %d', 0aH, 00H
_DATA   ENDS
main    PROC
; File c:\users\rhyde\test\t\t\t.cpp
; Line 8
;
;    int main( int argc, char **argv)
;    {
$LN3:
        mov     QWORD PTR [rsp+16], rdx
        mov     DWORD PTR [rsp+8], ecx
        sub     rsp, 40                                 ; 00000028H
; Line 10
;
;            i = j + 3;
;
; Load the EAX register with the
; current value of the global j
; variable using the displacement-only
; addressing mode, add three to the
; value, and store back into 'i':

        mov     eax, DWORD PTR j
        add     eax, 3
        mov     DWORD PTR i, eax

; Line 11
;
;            j = i + 2;
;
        mov     eax, DWORD PTR i
        add     eax, 2
        mov     DWORD PTR j, eax

; Line 12
; Load i, j, and format string into appropriate registers
; and call printf:

        mov     r8d, DWORD PTR j
        mov     edx, DWORD PTR i
        lea     rcx, OFFSET FLAT:$SG6835
        call    printf
; Line 13
;
; RETURN 0

        xor     eax, eax
; Line 14
        add     rsp, 40                                 ; 00000028H
        ret     0
main    ENDP
_TEXT   ENDS

如注释所示,编译器生成的汇编语言代码使用仅有位移的寻址模式来访问所有静态变量。

7.3.2 伪静态绑定与自动变量

自动变量在一个过程或其他代码块开始执行时会绑定一个地址。当该代码块或过程执行完毕时,程序会释放这些存储空间。我们称这些对象为自动变量,因为运行时代码会根据需要自动分配和释放它们的存储空间。

在大多数编程语言中,自动变量使用一种结合了静态绑定和动态绑定的方式,称为伪静态绑定。编译器在编译期间将一个偏移量分配给变量名,偏移量相对于基地址。在运行时,偏移量始终保持固定,但基地址可能会变化。例如,一个过程或函数为一块局部变量(即前面章节中提到的激活记录)分配存储空间,然后以固定的偏移量访问该块存储中的局部变量。虽然程序在运行时才能确定变量的最终内存地址,但编译器可以选择一个在程序执行期间始终不变的偏移量,因此称之为伪静态

一些编程语言用局部变量来代替自动变量。局部变量的名称静态绑定到某个给定的过程或代码块(即该名称的作用域仅限于该过程或代码块)。因此,局部在这个上下文中是一个静态属性。可以很容易理解为什么局部变量自动变量这两个术语经常被混淆。在某些编程语言中,如 Pascal,局部变量总是自动变量,反之亦然。尽管如此,始终要记住,局部是静态属性,而自动是动态属性。^(4)

自动变量有一些重要的优势。首先,它们仅在包含它们的过程或块执行时占用存储空间。这使得多个块和过程可以共享同一内存池中的自动变量需求。尽管需要执行一些额外的代码来管理自动变量(在激活记录中),但在大多数 CPU 上,这只需要几条机器指令,并且每个过程/块的进入和退出时仅需要执行一次。虽然在某些情况下,成本可能会比较高,但设置和销毁激活记录所需的额外时间和空间通常是微不足道的。自动变量的另一个优势是它们通常使用基址加偏移量寻址模式,其中激活记录的基址保存在寄存器中,而激活记录中的偏移量较小——通常是 256 字节或更少。因此,CPU 无需将完整的 32 位(例如)地址编码为机器指令的一部分——只需编码一个 8 位(或其他小)偏移量,从而生成较短的指令。还值得注意的是,自动变量是“线程安全”的,使用自动变量的代码可以重新进入。这是因为每个线程都有自己的栈空间(或类似的数据结构),编译器在其中维护自动变量;因此,每个线程将拥有程序使用的任何自动变量的副本。

然而,自动变量也有一些缺点。如果你想初始化一个自动变量,你必须使用机器指令来完成。你不能像静态变量那样,在程序加载到内存时初始化自动变量。此外,任何保存在自动变量中的值,在你退出包含它们的块或过程时都会丢失。如前所述,自动变量需要少量的开销;必须执行一些机器指令来构建和销毁包含这些变量的激活记录。

下面是一个简短的 C 示例,使用自动变量以及 Microsoft Visual C++编译器为其生成的 80x86 汇编代码:


			#include <stdio.h>

int main( int argc, char **argv)
{

    int i;
    int j;

    j = 1;
    i = j + 3;
    j = i + 2;
    printf( "%d %d\n", i, j );
    return 0;
}

; Data emitted for the string constant
; in the printf function call:

CONST   SEGMENT
$SG6917 DB      '%d %d', 0aH, 00H
CONST   ENDS

PUBLIC  _main
EXTRN   _printf:NEAR
; Function compile flags: /Ods

_TEXT   SEGMENT
j$ = 32
i$ = 36
argc$ = 64
argv$ = 72
main    PROC
; File c:\users\rhyde\test\t\t\t.cpp
; Line 5
$LN3:
        mov     QWORD PTR [rsp+16], rdx
        mov     DWORD PTR [rsp+8], ecx
        sub     rsp, 56                                 ; 00000038H
; Line 10
        mov     DWORD PTR j$[rsp], 1
; Line 11
        mov     eax, DWORD PTR j$[rsp]
        add     eax, 3
        mov     DWORD PTR i$[rsp], eax
; Line 12
        mov     eax, DWORD PTR i$[rsp]
        add     eax, 2
        mov     DWORD PTR j$[rsp], eax
; Line 13
        mov     r8d, DWORD PTR j$[rsp]
        mov     edx, DWORD PTR i$[rsp]
        lea     rcx, OFFSET FLAT:$SG6917
        call    printf
; Line 14
        xor     eax, eax
; Line 15
        add     rsp, 56                                 ; 00000038H
        ret     0
main    ENDP
_TEXT   ENDS

注意,在访问自动变量时,汇编代码使用基址加位移寻址模式(例如,j$[rsp])。这种寻址模式通常比静态变量使用的仅位移或 RIP 相对寻址模式更短(当然,前提是自动对象的偏移量在基址地址 RSP 的 127 字节以内)。^(5)

7.3.3 动态绑定和动态变量

动态变量在运行时绑定存储。在一些语言中,应用程序员完全负责将地址绑定到动态对象;在其他语言中,运行时系统自动为动态变量分配和释放存储。

动态变量通常通过堆中的内存分配函数,如malloc()new()(或std::unique_ptr)进行分配。编译器无法确定动态对象的运行时地址,因此程序必须始终通过间接引用动态对象——即通过使用指针。

动态变量的一个主要优点是应用程序可以控制它们的生命周期。动态变量只在必要时消耗存储空间,当变量不再需要时,运行时系统可以回收该存储空间。与自动变量不同,动态变量的生命周期与其他对象(如过程或代码块的入口和出口)无关。内存分配给动态变量的时机是在该变量首次需要内存时,且当变量不再需要时可以释放这部分内存。因此,对于需要大量存储的变量,动态分配可以有效利用内存。

动态变量的另一个优点是,大多数代码通过指针引用动态对象。如果该指针值已经存储在 CPU 寄存器中,程序通常可以使用简短的机器指令来引用该数据,无需额外的位数来编码偏移量或地址。

动态变量也有几个缺点。首先,通常需要一些存储开销来维护它们。静态和自动对象通常不需要额外的存储;而运行时系统则通常需要一定数量的字节来跟踪系统中的每个动态变量。这个开销范围从 4 或 8 字节到几十字节(在极端情况下),它跟踪如对象的当前内存地址、对象的大小及其类型等信息。如果你正在分配小对象,如整数或字符,用于记录的存储开销可能会超过实际数据所需的存储空间。另外,由于大多数语言通过指针变量引用动态对象,这些指针需要额外的存储空间,超过实际存储动态数据的空间。

动态变量的另一个问题是性能。由于动态数据通常存储在内存中,CPU 必须访问内存(这比缓存内存慢)来访问几乎每个动态变量。更糟糕的是,访问动态数据通常需要两次内存访问——一次是获取指针的值,另一次是通过指针间接获取动态数据。管理堆,运行时系统存储动态数据的地方,也可能影响性能。每当应用程序请求动态对象的存储时,运行时系统必须寻找一个足够大的连续空闲内存块来满足请求。这种搜索操作可能在计算上是昂贵的,这取决于堆的组织方式(这会影响与每个动态变量相关的额外存储开销)。此外,在释放动态对象时,运行时系统可能需要执行一些代码,以便为其他动态对象释放存储空间。这些运行时堆分配和释放操作通常比在过程入口/退出时分配和释放自动变量的开销要大得多。

动态变量的另一个考虑因素是,有些语言(如 Pascal 和 C/C++^(6))要求应用程序员显式地分配和释放动态变量的存储空间。如果没有自动分配和释放,因人为错误而导致的缺陷可能会渗入代码中。这就是为什么像 C#、Java 和 Swift 这样的语言试图自动处理动态分配,尽管这个过程可能会比较慢。

下面是一个 C 语言的简短示例,演示了 Microsoft Visual C++ 编译器为了访问通过 malloc() 分配的动态对象所生成的代码。


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

int main( int argc, char **argv)
{

    int *i;
    int *j;

    i = (int *) malloc( sizeof( int ) );
    j = (int *) malloc( sizeof( int ) );
    *i = 1;
    *j = 2;
    printf( "%d %d\n", *i, *j );
    free( i );
    free( j );
    return 0;
}

下面是编译器生成的机器代码,包括(手动插入的)注释,描述了访问动态分配对象所需的额外工作:


			_DATA   SEGMENT
$SG6837 DB      '%d %d', 0aH, 00H
_DATA   ENDS
PUBLIC  _main
_TEXT   SEGMENT
i$ = 32
j$ = 40
argc$ = 64
argv$ = 72
main    PROC
; File c:\users\rhyde\test\t\t\t.cpp
; Line 7 // Construct the activation record
$LN3:

        mov     QWORD PTR [rsp+16], rdx
        mov     DWORD PTR [rsp+8], ecx
        sub     rsp, 56                                 ; 00000038H

; Line 13
; Call malloc and store the returned
; pointer value into the i variable:

        mov     ecx, 4
        call    malloc
        mov     QWORD PTR i$[rsp], rax

; Line 14
; Call malloc and store the returned
; pointer value into the j variable:

        mov     ecx, 4
        call    malloc
        mov     QWORD PTR j$[rsp], rax

; Line 15
; Store 1 into the dynamic variable pointed
; at by i. Note that this requires two
; instructions.

        mov     rax, QWORD PTR i$[rsp]
        mov     DWORD PTR [rax], 1

; Line 16
; Store 2 into the dynamic variable pointed
; at by j. This also requires two instructions.

        mov     rax, QWORD PTR j$[rsp]
        mov     DWORD PTR [rax], 2

; Line 17
; Call printf to print the dynamic variables'
; values:

        mov     rax, QWORD PTR j$[rsp]
        mov     r8d, DWORD PTR [rax]
        mov     rax, QWORD PTR i$[rsp]
        mov     edx, DWORD PTR [rax]
        lea     rcx, OFFSET FLAT:$SG6837
        call    printf

; Line 18
; Free the two variables
;
        mov     rcx, QWORD PTR i$[rsp]
        call    free
; Line 19
        mov     rcx, QWORD PTR j$[rsp]
        call    free

; Line 20
; Return a function result of zero:
        xor     eax, eax
; Line 21
        add     rsp, 56                                 ; 00000038H
        ret     0
main    ENDP
_TEXT   ENDS
END

正如你所看到的,通过指针访问动态分配的变量需要额外的工作。

7.4 常见的原始数据类型

计算机数据总是具有一个数据类型属性,用于描述程序如何解释该数据。数据类型还决定了数据在内存中的大小(以字节为单位)。数据类型可以分为两类:原始数据类型,这些数据类型可以被 CPU 存储在 CPU 寄存器中并直接操作;以及复合数据类型,它们由较小的原始数据类型组成。在接下来的章节中,我们将回顾(来自 WGC1)大多数现代 CPU 中的原始数据类型,而在下一章中,我将开始讨论复合数据类型。

7.4.1 整型变量

大多数编程语言都提供某种机制,用于在内存变量中存储整数值。通常,编程语言使用无符号二进制表示法、二补码表示法或二进制编码十进制表示法(或这些的组合)来表示整数值。

也许编程语言中整数变量最基本的属性是用于表示该整数值的位数。在大多数现代编程语言中,用于表示整数值的位数通常是 8、16、32、64 或其他某个二的幂。许多语言只提供一种表示整数的大小,但一些语言允许你从几种不同的大小中进行选择。你根据想要表示的值的范围、希望变量消耗的内存量以及涉及该值的算术运算的性能来选择大小。表 7-1 列出了各种有符号、无符号和十进制整数变量的一些常见大小和范围。

不是所有的编程语言都支持这些不同的大小(实际上,要在同一个程序中支持所有这些大小,可能需要使用汇编语言)。如前所述,一些编程语言只提供一种大小,通常是处理器的本地整数大小(即 CPU 通用整数寄存器的大小)。

提供多种整数大小的编程语言通常并不会明确提供可以选择的大小。例如,C 语言提供最多五种不同的整数大小:char(始终为 1 字节)、shortintlonglong long。除了 char 类型外,C 语言并没有指定这些整数类型的具体大小,只是指出 short 整数的大小小于或等于 int 对象,int 对象的大小小于或等于 long 整数,而 long 整数的大小小于或等于 long long 整数。(实际上,这四种类型的大小可能是相同的。)依赖于整数具有特定大小的 C 程序,可能会在使用不同编译器编译时失败,因为这些编译器使用的整数大小与原编译器不同。

注意

C99 和 C++11 包括了确切大小的类型:int8_t, int16_t, int32_t, int64_t,依此类推。

表 7-1: 常见整数大小及其范围

大小(位数) 表示 无符号范围
8 无符号 0..255
有符号 -128..+127
十进制 0..99
16 无符号 0..65,536
有符号 -32768..+32,767
十进制 0..9999
32 无符号 0..4,294,967,295
有符号 -2,147,483,648..+2,147,483,647
十进制 0..99999999
64 无符号 0..18,466,744,073,709,551,615
有符号 -9,223,372,036,854,775,808.. +9,223,372,036,854,775,807
十进制 0..9999999999999999
128 无符号 0..340,282,366,920,938,463,563,374,607,431,768,211,455
有符号 -170,141,183,460,469,231,731,687,303,715,884,105,728.. +170,141,183,460,469,231,731,687,303,715,884,105,727
十进制 0..99999999999999999999999999999999

尽管各编程语言没有明确指定整数变量的精确大小可能会显得不便,但请记住,这种模糊性是故意的。当你在某编程语言中声明一个“整数”变量时,语言会将选择该整数最佳大小的任务交给编译器的实现者,基于性能和其他考虑因素来决定。“最佳”定义可能会根据编译器为其生成代码的 CPU 而变化。例如,针对 16 位处理器的编译器可能会选择实现 16 位整数,因为该 CPU 处理它们最有效。然而,针对 32 位处理器的编译器可能会选择实现 32 位整数(出于同样的原因)。那些明确指定各种整数格式精确大小的语言(如 Java)可能会随着处理器技术的发展而受到影响,因为随着大数据对象处理变得更加高效,编译器可能需要进行调整。例如,当世界从 16 位处理器过渡到 32 位处理器时,在大多数新的处理器上执行 32 位算术运算实际上更快。因此,编译器编写者重新定义了整数为“32 位整数”,以最大化使用整数算术的程序的性能。

一些编程语言提供对无符号整数变量和有符号整数的支持。乍一看,似乎支持无符号整数的唯一目的是在不需要负值的情况下提供两倍的正数值。实际上,当编写高效代码时,很多优秀的程序员可能会选择无符号整数而非有符号整数,这背后有许多其他原因。

Swift 编程语言让你对整数的大小有明确的控制。Swift 提供了 8 位(有符号)整数(Int8)、16 位整数(Int16)、32 位整数(Int32)和 64 位整数(Int64)。Swift 还提供了一个Int类型,它的大小为 32 位或 64 位,具体取决于底层 CPU 的原生(最有效)整数格式。Swift 进一步提供了 8 位无符号整数(UInt8)、16 位无符号整数(UInt16)、32 位无符号整数(UInt32)、64 位无符号整数(UInt64),以及一个通用的UInt类型,其大小由原生 CPU 大小决定。

在一些 CPU 上,无符号整数的乘法和除法比有符号整数更快。你可以更高效地比较0..n范围内的值,使用无符号整数而不是有符号整数(无符号情况只需要对 n 进行一次比较);这一点在检查数组索引的边界时尤其重要,因为数组的元素索引从0开始。

许多编程语言允许在同一个算术表达式中包含不同大小的变量。编译器会根据需要自动将操作数符号扩展或零扩展到表达式中的较大大小,以计算最终结果。这个自动转换的问题在于,它隐藏了处理表达式时需要额外工作这一事实,而表达式本身并没有明确显示这一点。像这样的赋值语句:

x = y + z - t;

如果操作数的大小相同,则可以是一个简短的机器指令序列,或者如果操作数的大小不同,可能需要一些额外的指令。例如,考虑以下 C 代码:


			#include <stdio.h>

static char c;
static short s;
static long l;

static long a;
static long b;
static long d;

int main( int argc, char **argv)
{

    l = l + s + c;
    printf( "%ld %hd %hhd\n", l, s, c );

    a = a + b + d;
    printf( "%ld %ld %ld\n", a, b, d );

    return 0;
}

使用 Visual C++编译器编译它时,给出了以下两个赋值语句的汇编语言序列:


			;            l = l + s + c;
;
        movsx   eax, WORD PTR s
        mov     ecx, DWORD PTR l
        add     ecx, eax
        mov     eax, ecx
        movsx   ecx, BYTE PTR c
        add     eax, ecx
        mov     DWORD PTR l, eax
;
;            a = a + b + d;
;
        mov     eax, DWORD PTR b
        mov     ecx, DWORD PTR a
        add     ecx, eax
        mov     eax, ecx
        add     eax, DWORD PTR d
        mov     DWORD PTR a, eax

如你所见,操作在大小相同的变量上的语句比操作混合操作数大小的表达式使用的指令少。

在表达式中使用不同大小的整数时,还需要注意,并非所有的 CPU 对所有操作数大小的支持效率相同。虽然使用大于 CPU 通用寄存器整数大小的整数会产生低效代码是有道理的,但使用更小的整数值也可能低效,这一点可能不那么显而易见。许多 RISC CPU 只处理与通用寄存器大小完全相同的操作数。更小的操作数必须首先通过零扩展或符号扩展到通用寄存器的大小,然后才能进行任何涉及这些值的计算。即使在有硬件支持不同大小整数的 CISC 处理器上,如 80x86,使用某些大小的整数也可能更昂贵。例如,在 32 位操作系统下,操作 16 位操作数的指令需要额外的操作码前缀字节,因此比操作 8 位或 32 位操作数的指令要大。

7.4.2 浮点/实数变量

像整数一样,许多高级编程语言(HLL)提供多种浮点变量大小。大多数语言至少提供两种不同的大小:基于 IEEE 754 浮点标准的 32 位单精度浮点格式和 64 位双精度浮点格式。少数语言提供 80 位浮点变量(Swift 就是一个很好的例子),它基于 Intel 的 80 位扩展精度浮点格式,但这种用法越来越少。后来的 ARM 处理器支持四倍精度浮点运算(128 位);一些 GCC 变体支持使用四倍精度运算的_float128类型。

不同的浮点格式在精度、空间和性能之间做出权衡。涉及较小浮点格式的计算通常比涉及较大格式的计算更快。然而,为了提高性能和节省空间,你需要牺牲精度(有关详细信息,请参见WGC1的第四章)。

与涉及整数运算的表达式一样,你应该避免在一个表达式中混合不同大小的浮点操作数。CPU(或 FPU)必须在使用之前将所有浮点值转换为相同的格式。这可能涉及额外的指令(消耗更多内存)和额外的时间。因此,你应该尽量在整个表达式中使用相同的浮点类型。

整数与浮点格式之间的转换是另一项昂贵的操作,你应该避免。现代高级语言尽量将变量的值保存在寄存器中。不幸的是,在一些现代 CPU 上,无法在整数和浮点寄存器之间移动数据,而不先将数据复制到内存中(这很昂贵,因为内存比较慢)。此外,整数与浮点数之间的转换通常涉及几条专门的指令,这些指令都会消耗时间和内存。尽可能避免这些转换。

7.4.3 字符变量

大多数现代高级语言中的标准字符数据每个字符消耗 1 字节。在支持字节寻址的 CPU 上,例如 Intel 80x86 处理器,编译器可以为每个字符变量保留一个字节的存储空间,并高效地在内存中访问该字符变量。然而,一些 RISC CPU 不能访问内存中的数据,除非是 32 位块(或者其他非 8 位的大小)。

对于无法在内存中单独寻址字节的 CPU,高级语言编译器通常为字符变量保留 32 位,并仅使用该双字变量的低字节存储字符数据。由于很少有程序需要大量的标量字符变量^(7),因此大多数系统中的空间浪费问题并不严重。然而,如果你有一个未打包的字符数组,那么浪费的空间可能会变得显著。我们将在第八章中回到这个问题。

现代编程语言支持 Unicode 字符集。Unicode 字符可能需要 1 到 4 字节的内存来存储字符的数据值(具体取决于底层编码,例如 UTF-8、UTF-16 或 UTF-32)。随着时间的推移,Unicode 可能会取代 ASCII 字符集,成为大多数面向字符和字符串操作的程序的标准,除了那些需要高效随机访问字符串中字符的程序(在这些程序中,Unicode 的性能较差)。

7.4.4 布尔变量

布尔变量只需要一个位来表示truefalse两个值。高级语言通常为这些变量保留最小的内存空间(在支持字节寻址的机器上为 1 字节,而在只能寻址 16 位、32 位或 64 位内存值的 CPU 上,则保留更大的内存空间)。然而,这并不总是如此。有些语言(如 FORTRAN)允许你创建多字节布尔变量(例如,FORTRAN 中的LOGICAL*4数据类型)。

一些语言(例如 C/C++的早期版本)不支持显式的布尔数据类型。相反,它们使用整数数据类型来表示布尔值。这些 C/C++实现使用0和非零值分别表示falsetrue。在这些语言中,你可以通过选择用于存储布尔值的整数大小来决定布尔变量的大小。例如,在一个典型的旧版 32 位 C/C++实现中,你可以定义 1 字节、2 字节或 4 字节的布尔值,如表 7-2 所示。^(8)

表 7-2: 定义布尔值大小

C 整型数据类型 布尔对象的大小
char 1 字节
short int 2 字节
long int 4 字节

有些语言在特定情况下,当布尔变量是记录的字段或数组的元素时,仅使用一个存储位。我们将在第八章–第十一章中讨论复合数据结构时再回到这个话题。

7.5 变量地址和高级语言

程序中变量的组织、类别和类型会影响编译器生成的代码效率。此外,声明顺序、对象大小以及对象在内存中的位置等问题,会对程序的运行时间产生巨大影响。本节将介绍如何组织变量声明以生成高效的代码。

至于机器指令中编码的立即数,许多 CPU 提供了专门的寻址模式,比其他更通用的寻址模式更高效地访问内存。就像通过仔细选择常量来减少程序的大小并提高速度一样,通过仔细选择变量的声明方式,也可以使程序更高效。对于常量,你主要关心它们的值;而对于变量,你必须考虑编译器将它们放置在内存中的地址。

80x86 是一个典型的 CISC 处理器,提供多种地址大小。在现代 32 位或 64 位操作系统上运行时,如 macOS、Linux 或 Windows,80x86 CPU 支持三种地址大小:0 位、8 位和 32 位。80x86 使用 0 位位移来进行寄存器间接寻址模式。我们暂时忽略 0 位位移寻址模式,因为 80x86 编译器通常不使用它来访问你在代码中显式声明的变量。8 位和 32 位位移寻址模式是当前讨论中更有趣的部分。

7.5.1 为全局和静态变量分配存储空间

32 位位移也许是最容易理解的。你在程序中声明的变量,编译器会将它们分配到内存中,而不是寄存器中,这些变量必须出现在内存的某个地方。在大多数 32 位处理器中,地址总线宽度为 32 位,因此需要一个 32 位地址来访问内存中任意位置的变量。编码此 32 位地址的指令可以访问任何内存变量。80x86 提供了 仅位移 寻址模式,其有效地址就是嵌入指令中的 32 位常量。

32 位地址的一个问题(随着我们转向使用 64 位处理器和 64 位地址,这个问题会变得更加严重)是,地址最终会占用指令编码中最大的一部分。例如,80x86 上某些形式的仅位移寻址模式,拥有 1 字节的操作码和 4 字节的地址。因此,地址占用了指令大小的 80%。如果 80x86 的 64 位变体(x86-64)真的将一个 64 位的绝对地址作为指令的一部分进行编码,那么该指令将是 9 字节长,并且几乎占用了指令字节的 90%。为了避免这种情况,x86-64 修改了仅位移寻址模式。它不再将绝对地址编码到指令中;相反,它将一个带符号的 32 位偏移量(±20 亿字节)编码到指令中。

在典型的 RISC 处理器上,情况甚至更糟。由于典型的 RISC CPU 上的指令都是 32 位长,因此无法将 32 位地址作为指令的一部分进行编码。为了访问内存中任意的 32 位或 64 位地址的变量,你需要将该变量的 32 位或 64 位地址加载到寄存器中,然后使用寄存器间接寻址模式来访问它。对于 32 位地址,这可能需要三条 32 位指令,正如 图 7-2 所示;这在速度和空间上都是昂贵的。对于 64 位地址,情况则更加昂贵。

由于 RISC CPU 的运行速度并不会比 CISC 处理器慢得离谱,编译器很少生成如此糟糕的代码。实际上,运行在 RISC CPU 上的程序通常将对象块的基地址保存在寄存器中,这样可以通过基寄存器的短偏移量高效地访问这些块中的变量。但编译器如何处理内存中的任意地址呢?

Image

图 7-2:RISC CPU 访问绝对地址

7.5.2 使用自动变量来减少偏移量大小

避免使用大位移的一个方法是使用较小位移的寻址模式。例如,80x86(以及 x86-64)提供了一种 8 位位移形式,用于基址加索引寻址模式。该形式允许你在基地址(存储在寄存器中)附近,以–128 到+127 字节的偏移量访问数据。RISC 处理器也有类似的特性,尽管位移位数通常较大,从而允许更大的地址范围。

通过将寄存器指向内存中的某个基地址,并将变量放置在该基地址附近,你可以使用这些指令的简短形式,从而使程序更小且运行更快。如果你在汇编语言中工作并能直接访问 CPU 的寄存器,这并不难。然而,如果你在高级语言(HLL)中工作,你可能无法直接访问 CPU 的寄存器,即使能访问,你也可能无法说服编译器将变量分配到方便的地址。那么,如何在 HLL 程序中利用这种小位移寻址模式呢?答案是,你并不需要显式指定使用这种寻址模式;编译器会自动为你处理。

考虑以下在 Pascal 中的简单函数:


			function trivial( i:integer; j:integer ):integer;
var
    k:integer;
begin

    k := i + j;
    trivial := k;

end;

进入这个函数时,编译后的代码构造了一个激活记录(有时称为堆栈帧)。如你在本章之前看到的,激活记录是内存中的一种数据结构,系统在其中保存与函数或过程相关的局部数据。激活记录包括参数数据、自动变量、返回地址、编译器分配的临时变量和机器状态信息(例如,保存的寄存器值)。运行时系统动态分配存储空间用于激活记录,实际上,对同一过程或函数的两次调用可能会将激活记录放置在内存中的不同地址。为了访问激活记录中的数据,大多数高级语言(HLL)会将一个寄存器(通常称为帧指针)指向激活记录,然后过程或函数在该帧指针的某个偏移量处引用自动变量和参数。除非你有许多自动变量和参数,或者你的自动变量和参数非常大,否则这些变量通常会出现在内存中接近基地址的偏移量处。这意味着 CPU 在引用接近帧指针持有的基地址的变量时,可以使用较小的偏移量。在前面给出的 Pascal 示例中,参数ij以及局部变量k最有可能位于离帧指针地址几字节的地方,因此编译器可以使用小的位移编码这些指令,而不是大的位移。如果你的编译器在激活记录中分配局部变量和参数,你需要做的就是将你的变量安排在激活记录中,使它们出现在其基地址附近。但你该如何做到这一点呢?

激活记录的构建从调用过程的代码开始。调用者将参数数据(如果有的话)放入激活记录中。然后,执行一个汇编语言的call(或等效)指令将返回地址添加到激活记录中。此时,激活记录的构建继续在过程内部进行。过程复制寄存器值和其他重要的状态信息,然后为局部变量在激活记录中腾出空间。过程还必须更新帧指针寄存器(例如,在 80x86 中是 EBP,或在 x86-64 中是 RBP),使其指向激活记录的基地址。

要查看典型的激活记录是什么样的,考虑以下 HLA 过程声明:


			procedure ARDemo( i:uns32; j:int32; k:dword ); @nodisplay;
var
    a:int32;
    r:real32;
    c:char;
    b:boolean;
    w:word;
begin ARDemo;
    .
    .
    .
end ARDemo;

每当 HLA 程序调用这个 ARDemo 过程时,它会通过按照参数列表中的顺序将参数数据从左到右依次推送到栈上来构建激活记录。因此,调用代码首先将 i 参数的值推送到栈上,然后是 j 参数的值,最后是 k 参数的值。在推送参数之后,程序调用 ARDemo 过程。进入该过程时,栈中包含这四个项,按照 图 7-3 所示的方式排列,假设栈是从高地址向低地址增长的(如大多数处理器一样)。

Image

图 7-3:进入 ARDemo 时的栈组织

ARDemo 中的前几条指令将当前的框架指针寄存器值(例如,32 位 80x86 上的 EBP,或 x86-64 上的 RBP)推送到栈上,然后将栈指针(80x86/x86-64 上的 ESP/RSP)的值复制到框架指针中。接下来,代码将栈指针向下移动到内存中,以便为局部变量腾出空间。这就产生了如 图 7-4 所示的栈组织(在 80x86 CPU 上)。

要访问激活记录中的对象,必须使用框架指针寄存器(如 图 7-4 中的 EBP)到目标对象的偏移量。

Image

图 7-4:ARDemo 的激活记录(32 位 80x86)

目前最关心的两个项目是参数和局部变量。如 图 7-5 所示,您可以通过框架指针寄存器的正偏移量访问参数,通过框架指针寄存器的负偏移量访问局部变量。

Image

图 7-5:32 位 80x86 中 ARDemo 激活记录中对象的偏移量

英特尔专门保留了 EBP/RBP(基指针寄存器)用于指向激活记录的基址。因此,编译器通常会使用这个寄存器作为框架指针寄存器,在栈上分配激活记录。有些编译器则尝试使用 80x86 的 ESP/RSP(栈指针)寄存器指向激活记录,因为这样可以减少程序中的指令数量。无论编译器使用 EBP/RBP、ESP/RSP 还是其他寄存器作为框架指针,最终的结论是编译器通常会将某个寄存器指向激活记录,而大多数局部变量和参数都位于激活记录基址附近。

正如你在图 7-5 中看到的,ARDemo过程中的所有局部变量和参数都在帧指针寄存器(EBP)127 字节范围内。这意味着,在 80x86 CPU 上,引用这些变量或参数的指令将能够使用单字节编码 EBP 的偏移量。如前所述,由于程序构建激活记录的方式,参数出现在帧指针寄存器的正偏移量处,而局部变量则出现在帧指针寄存器的负偏移量处。

对于只有少数参数和局部变量的过程,CPU 能够使用较小的偏移量(即 80x86 上的 8 位,某些 RISC 处理器上可能更大)访问所有参数和局部变量。然而,请考虑以下 C/C++函数:

int BigLocals( int i, int j )
{
    int array[256];
    int k;
        .
        .
        .
}

该函数在 32 位 80x86 上的激活记录如图 7-6 所示。

Image

图 7-6:BigLocals()函数的激活记录

注意

这个激活记录与 Pascal 和 HLA 函数的激活记录之间的一个区别是,C 语言将参数逆序压入栈中(即先压入最后一个参数,再压入第一个参数)。然而,这一差异对我们的讨论没有任何影响。

在图 7-6 中需要注意的重要一点是,局部变量arrayk有较大的负偏移量。由于偏移量为–1,024 和–1,028,从 EBP 到arrayk的位移远超编译器能够在 80x86 上用单字节编码的范围。因此,编译器别无选择,只能使用 32 位值来编码这些位移。当然,这使得在函数中访问这些局部变量变得更加昂贵。

对于这个例子中的数组变量,无法做任何处理(无论你把它放在哪里,数组基地址的偏移量至少会比激活记录的基地址远 1,024 字节)。然而,请考虑图 7-7 中的激活记录。

Image

图 7-7:BigLocals()函数的另一种可能的激活记录布局

编译器已重新排列了此激活记录中的局部变量。尽管访问array变量仍然需要一个 32 位的位移,但访问k时现在使用 8 位位移(在 32 位 80x86 上),因为k的偏移量为–4。你可以使用以下代码生成这些偏移量:


			int BigLocals( int i, int j );
{
    int k;
    int array[256];
        .
        .
        .
}

理论上,重新排列激活记录中变量的顺序对编译器来说并不十分困难,因此你可以预期编译器会进行此修改,以便通过小的偏移量尽可能访问更多的局部变量。但实际上,并非所有编译器都会进行此优化,原因有很多,包括技术性和实际性的原因(具体来说,它可能会破坏一些对激活记录中变量位置做假设的写得不好的代码)。

如果你想确保在你的过程中的局部变量有尽可能小的偏移量,解决方案很简单:首先声明所有的 1 字节变量,其次是 2 字节变量,再然后是 4 字节变量,依此类推,直到函数中最大的局部变量。一般来说,然而,你可能更关心的是减少函数中最大数量的指令的大小,而不是减少函数中最大数量的变量所需的偏移量的大小。例如,如果你有 128 个 1 字节变量,并且首先声明这些变量,那么访问它们时只需要 1 字节的偏移量。然而,如果你从未访问这些变量,那么它们有 1 字节偏移量而不是 4 字节偏移量对你并没有任何帮助。你唯一节省空间的时刻是当你通过某个使用 1 字节而不是 4 字节偏移量的机器指令实际访问该变量的值时。因此,为了减少函数的目标代码大小,你希望最大化使用小偏移量的指令数量。如果你在函数中比任何其他变量更频繁地引用一个 100 字节的数组,那么你可能更好地首先声明这个数组,即使这会导致(在 80x86 上)只为其他将使用较短偏移量的变量留下 28 字节的存储空间。

RISC 处理器通常使用有符号的 12 位或 16 位偏移量来访问激活记录的字段。因此,使用 RISC 芯片时,你在声明时有更多的灵活性(这很好,因为当你超出 12 位或 16 位的限制时,访问局部变量会变得非常昂贵)。除非你声明一个或多个数组,它们消耗超过 2,048 字节(12 位)或 32,768 字节(合计),否则典型的 RISC 编译器会生成高效的代码。

同样的理由也适用于参数和局部变量。然而,代码中传递大型数据结构(按值传递)给函数的情况很少,因为这样做的开销较大。

7.5.3 分配中间变量的存储

中间变量对一个过程/函数来说是局部的,但对另一个过程/函数来说是全局的。你会在支持嵌套过程的块结构语言中看到它们——比如 Free Pascal、Delphi、Ada、Modula-2、Swift 和 HLA。考虑一下下面这个用 Swift 写的示例程序:


			import Cocoa
import Foundation

var globalVariable = 2

func procOne()
{
    var intermediateVariable = 2;

    func procTwo()
    {
        let localVariable =
            intermediateVariable + globalVariable
        print( localVariable )
    }
    procTwo()
}

procOne()

请注意,嵌套过程可以访问在主程序中找到的变量(即全局变量),以及在包含嵌套过程的过程中找到的变量(即中间变量)。正如你所看到的,与全局变量访问相比,本地变量的访问成本较低(因为你总是需要使用较大的偏移量来访问过程中的全局对象)。如procTwo过程中所做的,中间变量访问是昂贵的。本地和全局变量访问的区别在于指令中编码的偏移量/位移的大小,本地变量通常使用比全局对象更短的偏移量。另一方面,中间变量访问通常需要多个机器指令。这使得访问一个中间变量的指令序列比访问本地(甚至全局)变量要慢几倍,且指令的体积也要大几倍。

使用中间变量的问题在于,编译器必须维护一个激活记录的链表,或者维护一个指向激活记录的指针表(显示表),以便引用中间对象。为了访问一个中间变量,procTwo过程必须跟随一条链(在这个例子中只有一条链),或者进行表查找,以便获取指向procOne激活记录的指针。更糟糕的是,维护这个指针链表的显示表并不便宜。维护这些对象的工作必须在每次过程/函数的入口和出口时完成,即使该过程或函数在某次调用中并不访问任何中间变量。尽管使用中间变量(与全局变量相比,涉及信息隐藏)在某些软件工程方面可能有好处,但请记住,访问中间对象是昂贵的。

7.5.4 动态变量和指针的存储分配

高级语言中的指针访问提供了代码优化的另一个机会。指针的使用可能非常昂贵,但在某些情况下,它们实际上可以通过减少位移大小来提高程序的效率。

指针只是一个内存变量,其值是某个其他内存对象的地址(因此,指针的大小与机器上的地址相同)。因为大多数现代 CPU 只通过机器寄存器支持间接访问,所以间接访问一个对象通常是一个两步过程:首先,代码必须将指针变量的值加载到寄存器中,然后通过该寄存器间接引用对象。

请考虑以下 C/C++代码片段:


			int *pi;
    .
    .
    .
i = *pi;    // Assume pi is initialized with a
            // reasonable address at this point.

以下是相应的 80x86/HLA 汇编代码:


			pi: pointer to int32;
    .
    .
    .
mov( pi, ebx );     // Again, assume pi has
mov( [ebx], eax );  // been properly initialized
mov( eax, i );

如果pi是一个普通变量而不是指针对象,这段代码本可以省略mov([ebx], eax);指令,直接将pi移动到eax中。因此,使用这个指针变量既增加了程序的大小,又通过在编译器生成的代码序列中插入了额外的指令,降低了执行速度。

然而,如果你多次间接引用一个对象,编译器可能会重新利用它已加载到寄存器中的指针值,从而将额外指令的开销分摊到几条不同的指令上。考虑以下的 C/C++代码序列:


			int *pi;
    .
    .   // Assume code in this area
    .   // initializes pi appropriately.
    .
*pi = i;
*pi = *pi + 2;
*pi = *pi + *pi;
printf( "pi = %d\n", *pi );

这是相应的 80x86/HLA 代码:


			pi: pointer to int32;
    .
    . // Assume code in this area
    . // initializes pi appropriately.
    .
// Extra instruction that we need to initialize EBX

mov( pi, ebx );

mov( i, eax );
mov( eax, [ebx] );  //  This code can clearly be optimized,
mov( [ebx], eax );  //  but we'll ignore that fact for the
add( 2, eax );      //  sake of the discussion here.
mov( eax, [ebx] );
mov( [ebx], eax );
add( [ebx], eax );
mov( eax [ebx] );
stdout.put( "pi = ", (type int32 [ebx]), nl );

这段代码仅在一次操作中将实际指针值加载到 EBX 寄存器。从那时起,代码将仅使用 EBX 中包含的指针值来引用pi所指向的对象。当然,任何能够进行这种优化的编译器可能会从这段汇编语言序列中去除五次冗余的内存加载和存储操作,但我们暂时假设它们并非冗余。因为代码在每次需要访问pi所指向的对象时无需重新加载 EBX 中的pi值,所以只有一条开销指令(mov(pi, ebx);)被分摊到六条指令中。这样看起来并不算太差。

事实上,你完全可以提出一个很好的论点,认为这段代码比直接访问本地或全局变量更加优化。一条形式为

mov([ebx],eax);

编码的是一个 0 位偏移量。因此,这条移动指令只有 2 个字节长,而不是 3、5 甚至 6 个字节长。如果pi是一个局部变量,那么原始将pi复制到 EBX 的指令很可能只有 3 个字节长(2 字节操作码和 1 字节偏移量)。因为形式为mov([ebx], eax);的指令只有 2 个字节长,所以使用间接寻址而不是 8 位偏移量时,只有三条指令“达到平衡”。在第三条引用pi指向的内容的指令之后,涉及指针的代码实际上变得更短了。

你甚至可以使用间接寻址来高效地访问一块全局变量。如前所述,编译器通常无法在编译程序时确定全局对象的地址。因此,它必须假设最坏的情况,并在生成机器代码以访问全局变量时允许最大的位移/偏移量。当然,你刚刚看到,通过使用指向对象的指针而不是直接访问对象,你可以将位移值的大小从 32 位减少到 0 位。因此,你可以获取全局对象的地址(例如,使用 C/C++中的&运算符),然后使用间接寻址来访问变量。这种方法的问题是,它需要一个寄存器(寄存器在任何处理器中都是宝贵资源,尤其是在 32 位的 80x86 中,它只有六个通用寄存器可供使用)。如果你在快速连续的代码中多次访问同一变量,这个 0 位位移技巧可以提高代码效率。然而,在短序列代码中反复访问同一变量而不需要同时访问其他多个变量的情况相对较少。这意味着编译器可能需要将指针从寄存器中刷新出来,稍后重新加载指针值,从而降低这种方法的效率。如果你在 RISC 芯片或具有多个寄存器的 x86-64 处理器上工作,你可能能利用这个技巧来提高效率。然而,在寄存器数量有限的处理器上,你就无法频繁使用它了。

7.5.5 使用记录/结构体来减少指令偏移量大小

你还可以使用一个技巧,通过单一指针访问多个变量:将这些变量放入一个结构体中,然后使用结构体的地址。通过指针访问结构体的字段,你可以使用更小的指令来访问对象。这几乎与激活记录的方式完全相同(实际上,激活记录就是程序通过帧指针寄存器间接引用的记录)。在用户定义的记录/结构体中间接访问对象和在激活记录中访问对象之间的唯一区别是,大多数编译器不允许你使用负偏移量来引用用户结构体/记录中的字段。因此,你只能访问激活记录中通常可以访问的字节数量的一半。例如,在 80x86 架构中,你可以使用 0 位位移从指针访问偏移量为 0 的对象,使用单字节位移访问偏移量为 1 到+127 的对象。考虑以下使用此技巧的 C/C++示例:


			typedef struct
{
    int i;
    int j;
    char *s;
    char name[20];
    short t;
} vars;

static vars v;
vars *pv = &v;  // Initialize pv with the address of v.
        .
        .
        .
    pv->i = 0;
    pv->j = 5;
    pv->s = pv->name;
    pv->t = 0;
    strcpy( pv->name, "Write Great Code!" );
        .
        .
        .

一个设计良好的编译器会在这段代码中将pv的值加载到寄存器中,只会加载一次。因为vars结构体的所有字段都位于结构体基地址内存的 127 字节以内,80x86 编译器可以发出一系列仅需 1 字节偏移的指令,即使v变量本身是静态/全局对象。顺便提一下,vars结构体中的第一个字段是特殊的。由于这个字段位于结构体的 0 偏移位置,因此在访问这个字段时,你可以使用 0 位偏移。因此,如果你打算间接访问某个结构体,最好将最常访问的字段放在结构体的第一个位置。

在代码中使用间接寻址确实是有代价的。对于像 32 位 80x86 这样的有限寄存器 CPU,使用这种方法会占用一个寄存器一段时间,这可能会导致编译器生成较差的代码。如果编译器必须不断地重新加载寄存器,以获取结构体在内存中的地址,那么这种技巧带来的节省就会很快消失。此类技巧在不同处理器(以及同一处理器的不同编译器)上效果不同,因此一定要查看编译器生成的代码,确认一个技巧是否真正节省了资源,而不是让你付出了更多的代价。

7.5.6 将变量存储在机器寄存器中

说到寄存器,值得指出的是,另一种通过 0 位偏移来访问程序中变量的方式:将它们保存在机器寄存器中。机器寄存器始终是存储变量和参数的最有效地方。不幸的是,只有在汇编语言中,以及在 C/C++中有限地,你才可以控制编译器是否将变量或参数保存在寄存器中。在某些方面,这并不坏。优秀的编译器在寄存器分配方面比随便的程序员做得更好。然而,专家程序员可以比编译器做得更好,因为专家程序员理解程序将要处理的数据以及对特定内存位置的访问频率。(当然,专家程序员可以先查看编译器的做法,而编译器无法看到程序员的处理方式。)

一些语言,如 Delphi,提供了对程序员控制寄存器分配的有限支持。特别是,Delphi 编译器允许你指示它将函数或过程的前三个(顺序)参数传递到 EAX、EDX 和 ECX 寄存器中。这个选项被称为fastcall 调用约定,多个 C/C++编译器也支持它。

在 Delphi 和某些其他语言中,选择 fastcall 参数传递约定是你唯一能控制的方式。然而,C/C++语言提供了register关键字,它是一个存储说明符(类似于conststaticauto关键字),告诉编译器程序员期望频繁使用该变量,因此编译器应尝试将其保留在寄存器中。请注意,编译器也可以选择忽略register关键字(此时,它会使用自动分配来保留变量存储)。许多编译器完全忽略register关键字,因为编译器的作者有点傲慢地认为,他们可以比任何程序员做得更好,进行寄存器分配。当然,在一些寄存器稀缺的机器上,比如 32 位的 80x86,寄存器数量极少,可能甚至无法在某些函数的执行过程中将一个变量分配到寄存器中。然而,一些编译器确实会尊重程序员的要求,并确实会将一些变量分配到寄存器中,如果你要求它们这么做。

大多数 RISC 编译器会为传递参数保留几个寄存器,为局部变量保留几个寄存器。因此,最好(如果可能的话)将你最常访问的参数放在参数声明的最前面,因为它们很可能是编译器分配到寄存器中的参数。^(9) 局部变量声明也是如此。总是先声明经常使用的局部变量,因为许多编译器可能会将这些(顺序)变量分配到寄存器中。

编译器寄存器分配的一个问题是它是静态的。也就是说,编译器在编译过程中根据对源代码的分析来决定将哪些变量放入寄存器,而不是在运行时决定。编译器通常会做出一些假设(这些假设通常是正确的),比如“这个函数比任何其他变量更频繁地引用变量xyz,因此它是寄存器变量的良好候选。”的确,通过将变量放入寄存器,编译器肯定会减少程序的大小。然而,也有可能是所有这些对xyz的引用出现在一些很少执行甚至从不执行的代码中。虽然编译器可能通过发出较小的指令来访问寄存器而不是内存,从而节省一些空间,但代码不会显著变得更快。毕竟,如果代码很少或从不执行,那么让这段代码更快运行对程序的执行时间没有太大贡献。另一方面,也很有可能将对某个变量的单次引用埋藏在一个深度嵌套的循环中,而这个循环会执行多次。整个函数中只有一次引用时,编译器的优化器可能会忽视程序执行过程中频繁引用该变量的事实。虽然编译器在处理循环中的变量时已经变得更加智能,但事实上,没有任何编译器能够预测任意循环在运行时会执行多少次。人类在预测这种行为(或者至少通过分析工具进行测量)方面要比编译器强得多,因此在寄存器中进行变量分配时,人类通常能够做出更好的决策。

7.6 内存中变量的对齐

在许多处理器(特别是 RISC 处理器)中,你必须考虑另一个效率问题。许多现代处理器不允许你在内存中的任意地址访问数据。相反,所有访问都必须在 CPU 支持的某个原生边界上进行(通常是 4 字节)。^(10) 即使 CISC 处理器允许在任意字节边界上访问内存,通常将原始对象(字节、字和双字)在对象大小的倍数边界上进行访问,效率更高(见图 7-8)。

Image

图 7-8:内存中的变量对齐

如果 CPU 支持不对齐访问——也就是说,如果 CPU 允许你在不是对象原始大小的倍数的边界上访问内存对象——那么你应该能够将变量打包到激活记录中。这样,你就可以获得最大数量的变量,并且这些变量的偏移量较小。然而,由于不对齐访问有时比对齐访问更慢,许多优化编译器会在激活记录中插入填充字节,以确保所有变量都按照合理的边界对齐,以适应它们的原生大小(见图 7-9)。这种做法以稍微更大的程序换取了略微更好的性能。

Image

图 7-9:激活记录中的填充字节

然而,如果你将所有的双字声明放在前面,字声明放在第二,字节声明放在第三,数组/结构声明放在最后,你可以提高代码的速度和大小。编译器通常会确保你声明的第一个局部变量出现在一个合理的边界上(通常是双字边界)。通过先声明所有的双字变量,你可以确保它们都出现在一个地址,该地址是 4 的倍数(因为编译器通常将相邻的变量按声明顺序分配到内存中的相邻位置)。你声明的第一个字大小的对象也会出现在一个 4 的倍数地址上——这意味着它的地址也是 2 的倍数(这对于字访问是最佳的)。通过将所有的字变量一起声明,你可以确保每个变量都出现在一个 2 的倍数地址上。在允许字节访问内存的处理器上,字节变量的放置(相对于高效访问字节数据)并不重要。通过将所有的局部字节变量放在过程或函数的最后,你通常可以确保这些声明不会影响你在函数中使用的双字和字变量的性能。图 7-10 展示了如果你按照以下函数中的方式声明变量,典型的激活记录会是什么样子:


			int someFunction( void )
{
    int d1;   // Assume ints are 32-bit objects
    int d2;
    int d3;
    short w1; // Assume shorts are 16-bit objects
    short w2;
    char b1;  // Assume chars are 8-bit objects
    char b2;
    char b3;
        .
        .
        .
} // end someFunction

Image

图 7-10:激活记录中的对齐变量(32 位 80x86)

请注意,所有的双字变量(d1d2d3)的地址都是 4 的倍数(–4、–8 和 –12)。同时,注意到所有的字大小变量(w1w2)的地址是 2 的倍数(–14 和 –16)。字节变量(b1b2b3)的地址是内存中的任意地址(包括偶数和奇数地址)。

现在考虑以下函数,它具有任意(无序的)变量声明,以及在图 7-11 中显示的相应激活记录:


			int someFunction2( void )
{
    char  b1; // Assume chars are 8-bit objects
    int   d1; // Assume ints are 32-bit objects
    short w1; // Assume shorts are 16-bit objects
    int   d2;
    short w2;
    char  b2;
    int   d3;
    char  b3;
        .
        .
        .
} // end someFunction2

Image

图 7-11:激活记录中的未对齐变量(32 位 80x86)

如您所见,除了字节变量外,其他所有变量都出现在不适合该对象的地址上。在允许在任意地址访问内存的处理器上,访问未对齐到合适地址边界的变量可能需要更长的时间。

一些处理器不允许程序在未对齐的地址访问对象。例如,大多数 RISC 处理器不能在非 32 位地址边界访问内存。要访问短整数或字节值,一些 RISC 处理器要求软件读取 32 位值并提取 16 位或 8 位值(也就是说,CPU 强制软件将字节和字作为打包数据处理)。为了打包和解包这些数据,所需的额外指令和内存访问会显著降低内存访问的速度(通常需要两条或更多指令才能从内存中获取一个字节或一个字)。将数据写入内存的情况更糟,因为 CPU 必须先从内存中提取数据,将新数据与旧数据合并,然后将结果写回内存。因此,大多数 RISC 编译器不会创建类似于图 7-11 中的激活记录。相反,它们会添加填充字节,使得每个内存对象都以一个 4 字节的倍数地址边界开始(见图 7-12)。

Image

图 7-12:RISC 编译器通过添加填充字节来强制对齐访问。

请注意,在图 7-12 中,所有变量都位于 32 位的倍数地址处。因此,RISC 处理器在访问这些变量时不会遇到问题。当然,代价是激活记录要大得多(局部变量占用 32 字节,而不是 19 字节)。

虽然图 7-12 中的例子是典型的 32 位 RISC 编译器,但这并不意味着 CISC CPU 的编译器也不会这样做。例如,许多 80x86 的编译器也会构建这种激活记录,以提高编译器生成代码的性能。尽管在 CISC CPU 上不对齐声明变量可能不会降低代码的运行速度,但它可能会使用更多的内存。

当然,如果你使用汇编语言工作,通常由你决定如何声明变量,使其适合或高效地适应特定处理器。例如,在 HLA(在 80x86 上)中,以下两个过程声明会产生在图 7-10、图 7-11 和图 7-12 中所示的激活记录。


			procedure someFunction; @nodisplay; @noalignstack;
var
    d1  :dword;
    d2  :dword;
    d3  :dword;
    w1  :word;
    w2  :word;
    b1  :byte;
    b2  :byte;
    b3  :byte;
begin someFunction;
        .
        .
        .
end someFunction;

procedure someFunction2; @nodisplay; @noalignstack;
var
    b1  :byte;
    d1  :dword;
    w1  :word;
    d2  :dword;
    w2  :word;
    b2  :byte;
    d3  :dword;
    b3  :byte;
begin someFunction2;
        .
        .
        .
end someFunction2;

procedure someFunction3; @nodisplay; @noalignstack;
var
    // HLA align directive forces alignment of the next declaration.

    align(4);
    b1  :byte;
    align(4);
    d1  :dword;
    align(4);
    w1  :word;
    align(4);
    d2  :dword;
    align(4);
    w2  :word;
    align(4);
    b2  :byte;
    align(4);
    d3  :dword;
    align(4);
    b3  :byte;
begin someFunction3;
        .
        .
        .
end someFunction3;

HLA 的 someFunction 和 someFunction3 过程将在任何 80x86 处理器上产生最快的代码,因为所有变量都对齐到合适的边界;HLA 的 someFunction 和 someFunction2 过程将在 80x86 CPU 上产生最紧凑的激活记录,因为激活记录中的变量之间没有填充。如果你在 RISC CPU 上使用汇编语言工作,你可能希望选择 someFunction 或 someFunction3 的等效方法,这样可以更容易地访问内存中的变量。

7.6.1 记录和对齐

高级语言(HLL)中的记录/结构也存在对齐问题,这些问题是你应该关注的。最近,CPU 制造商提倡 应用二进制接口(ABI) 标准,以促进不同编程语言及其实现之间的互操作性。虽然并非所有语言和编译器都遵循这些建议,但许多新型编译器是遵循的。其中,ABI 规范描述了编译器应如何在内存中组织记录或结构对象中的字段。虽然规则因 CPU 而异,但适用于大多数 ABI 的一条规则是:编译器应该将记录/结构字段对齐到一个是该对象大小倍数的偏移量。如果记录或结构中的两个相邻字段具有不同的大小,并且第一个字段的放置会导致第二个字段出现在一个不是该第二个字段原生大小倍数的偏移量位置,那么编译器将插入一些填充字节,将第二个字段推到一个适合该第二个对象大小的更高偏移量。

在实际应用中,不同 CPU 和操作系统的 ABI 存在细微的差异,这些差异主要取决于 CPU 访问内存中不同地址的对象的能力。例如,英特尔建议编译器将字节对齐到任何偏移量,将字对齐到偶数偏移量,并将其他所有内容对齐到 4 的倍数的偏移量。一些 ABI 建议将 64 位对象放置在记录中的 8 字节边界上。x86-64 SSE 和 AVX 指令要求对 128 位和 256 位数据值进行 16 字节和 32 字节对齐。一些 CPU 在访问小于 32 位的对象时会遇到困难,可能会建议对记录/结构中的所有对象使用最小 32 位的对齐。规则会根据 CPU 的不同以及制造商是希望优化代码执行速度(通常情况)还是减小数据结构的大小而有所不同。

如果你正在为单一 CPU(例如基于英特尔的 PC)编写代码,并使用单一编译器,请了解该编译器的填充字段规则,并根据最大性能和最小浪费调整声明。然而,如果你需要使用多个不同的编译器进行编译,特别是针对不同 CPU 的编译器,遵循一套规则可能在一台机器上工作得很好,但在其他几台机器上却会生成低效的代码。幸运的是,有一些规则可以帮助减少由于重新编译不同 ABI 所产生的低效问题。

从性能/内存使用的角度来看,最佳解决方案与我们之前看到的激活记录规则相同:在声明记录字段时,将所有相同大小的对象放在一起,并将所有较大的(标量)对象放在前面,较小的对象放在记录/结构体的最后。这个方案产生的浪费(填充字节)最少,并且在大多数现有的 ABI 中提供了最高的性能。唯一的缺点是你必须根据字段的原生大小而非它们之间的逻辑关系来组织字段。然而,由于记录/结构体的所有字段在逻辑上都与该记录/结构体相关联,因此这个问题并不像为某个特定函数的所有局部变量采用这种组织方式那么严重。

许多程序员尝试自己为结构体添加填充字段。例如,以下类型的代码在 Linux 内核及其他被过度修改的软件中非常常见:


			typedef struct IveAligned
{
    char byteValue;
    char padding0[3];
    int  dwordValue;
    short wordValue;
    char padding1[2];
    unsigned long dwordValue2;
        .
        .
        .
};

这个结构体中的padding0padding1字段是为了手动对齐dwordValuedwordValue2字段,使它们的偏移量成为 4 的偶数倍。

尽管这种填充并不不合理,但如果你使用的编译器没有自动对齐字段,请记住,在不同的机器上尝试编译这段代码可能会产生意外的结果。例如,如果一个编译器将所有字段对齐到 32 位边界,不管字段的大小,那么这个结构体声明将需要两个额外的双字来存放两个paddingX数组。这样就会无缘无故浪费空间。如果你决定手动添加填充字段,请记住这一点。

许多自动对齐结构体字段的编译器提供关闭此功能的选项。对于生成 CPU 代码的编译器尤其如此,其中字段对齐是可选的,编译器只会在轻微提升性能的情况下进行对齐。如果你打算手动为记录/结构体添加填充字段,你需要指定这个选项,以防编译器在你手动对齐后重新对齐字段。

从理论上讲,编译器可以自由地重新排列激活记录中局部变量的偏移量。然而,编译器重新排列用户定义的记录或结构体的字段是极为罕见的。太多外部程序和数据结构依赖于记录的字段按声明的顺序出现。尤其是在两个不同语言编写的代码之间传递记录/结构体数据时(例如,在调用汇编语言编写的函数时)或直接将记录数据转储到磁盘文件时,情况尤为如此。

在汇编语言中,字段对齐所需的工作量从纯手工劳动到具有自动处理几乎任何 ABI 的丰富功能集不等。有些(低端)汇编器甚至不提供记录或结构数据类型。在这种系统中,汇编程序员必须手动指定记录结构中的偏移量(通常通过声明常量,表示结构中的数值偏移量)。其他汇编器(例如 NASM)提供宏,可以自动为你生成等式。在这些系统中,程序员必须手动提供填充字段,以将某些字段对齐到给定边界。一些汇编器,如 MASM,提供简单的对齐功能。你可以在 MASM 中声明 struct 时指定值 124,汇编器将自动为结构添加填充字节,以使所有字段对齐到你指定的对齐值,或者到对象大小的倍数,以较小的为准。同时,请注意,MASM 会在结构体的末尾添加足够的填充字节,以确保整个结构体的长度是对齐大小的倍数。考虑下面这个 MASM 中的 struct 声明:


			Student  struct  2
score    word    ?   ; offset:0
id       byte    ?   ; offset 2 + 1 byte of padding
year     dword   ?   ; offset 4
id2      byte    ?   ; offset:8
Student  ends

在这个例子中,MASM 会在结构体的末尾添加一个额外的填充字节,使其长度成为 2 字节的倍数。

MASM 还允许你通过使用 align 指令控制结构中单个字段的对齐。以下结构声明与当前示例等效(请注意 struct 操作数字段中没有对齐值操作数):


			Student  struct
score    word    ?   ; offset:0
id       byte    ?   ; offset 2
         align   2   ; Injects 1 byte of padding.
year     dword   ?   ; offset 4
id2      byte    ?   ; offset:8
         align   2   ; Adds 1 byte of padding to the end of the struct.
Student  ends

MASM 结构的默认字段对齐是未对齐的。也就是说,字段从结构中下一个可用的偏移量开始,无论字段的大小(以及前一个字段的大小)如何。

高级汇编语言(HLA)可能提供对记录字段对齐的最大控制(既有自动也有手动)。与 MASM 一样,默认的记录对齐是未对齐的。同样如同 MASM,你可以使用 HLA 的 align 指令手动对齐 HLA 记录中的字段。下面是之前 MASM 示例的 HLA 版本:


			type
    Student :record
        score :word;
        id    :byte;
        align(2);
        year  :dword;
        id2   :byte;
        align(2);
    endrecord;

HLA 还允许你为记录中的所有字段指定自动对齐。例如:


			type
    Student :record[2]  // This tells HLA to align all
                        // fields on a word boundary
        score :word;
        id    :byte;
        year  :dword;
        id2   :byte;
    endrecord;

这个 HLA 记录和之前的 MASM 结构(具有自动对齐)之间有一个微妙的区别。记住,当你指定 Student struct 2 形式的指令时,MASM 会将所有字段对齐到 2 的倍数或对象大小的倍数,以较小者为准。而 HLA 会始终使用此声明将所有字段对齐到 2 字节的边界,即使该字段只有一个字节。

强制字段对齐到最小大小的能力是一个不错的功能,如果你正在处理在不同机器(或编译器)上生成的数据结构,而该机器(或编译器)强制这种对齐。然而,如果你只希望字段按自然边界对齐(这正是 MASM 所做的),这种对齐方式可能会不必要地浪费记录中的空间。幸运的是,HLA 提供了另一种记录声明语法,允许你指定 HLA 应用到字段的最大和最小对齐方式:


			recordID: record[ maxAlign : minAlign ]
    <<fields>>
endrecord;

maxAlign 项指定了 HLA 在记录中使用的最大对齐方式。HLA 会将任何原生大小大于 maxAlign 的对象对齐到 maxAlign 字节的边界。同样,HLA 会将任何原生大小小于 minAlign 的对象对齐到至少 minAlign 字节的边界。HLA 会将原生大小介于 minAlignmaxAlign 之间的对象对齐到该对象大小的倍数字节边界。以下是等效的 HLA 和 MASM 记录/结构声明:

这是 MASM 代码:


			Student  struct  4
score    word    ?   ; offset:0
id       byte    ?   ; offset 2

    ; 1 byte of padding appears here

year     dword   ?   ; offset 4
id2      byte    ?   ; offset:8

    ; 3 padding bytes appear here

courses  dword   ?   ; offset:12
Student  ends

这是 HLA 代码:


			type
    //  Align on 4-byte offset, or object's size, whichever
    //  is the smaller of the two. Also, make sure that the
    //  entire record is a multiple of 4 bytes long.

    Student  :record[4:1]
        score   :word;
        id      :byte;
        year    :dword;
        id2     :byte;
      courses   :dword;
    endrecord;

尽管很少有高级语言(HLL)在语言设计中提供控制记录(或其他数据结构)字段对齐的功能,但许多编译器通过扩展这些语言,形式为编译器指令,允许程序员指定默认的变量和字段对齐方式。由于很少有语言有这种标准,因此你需要查看特定编译器的参考手册(注意,C++11 是少数几种提供对齐支持的语言之一)。尽管这些扩展是非标准的,但它们通常非常有用,特别是当你在链接不同语言编译的代码,或试图从系统中挤压最后一滴性能时。

7.7 更多信息

Aho, Alfred V.,Monica S. Lam,Ravi Sethi 和 Jeffrey D. Ullman. 编译原理:技术与工具。第 2 版。英国埃塞克斯:Pearson Education Limited,1986 年。

Barrett, William 和 John Couch. 编译器构造:理论与实践。芝加哥:SRA,1986 年。

Dershem, Herbert 和 Michael Jipping. 编程语言、结构与模型。加利福尼亚州贝尔蒙特:Wadsworth,1990 年。

Duntemann, Jeff. 汇编语言逐步学习。第 3 版。印第安纳波利斯:Wiley,2009 年。

Fraser, Christopher 和 David Hansen. 可重定向的 C 编译器:设计与实现。波士顿:Addison-Wesley Professional,1995 年。

Ghezzi, Carlo 和 Jehdi Jazayeri. 编程语言概念。第 3 版。纽约:Wiley,2008 年。

Hoxey, Steve,Faraydon Karim,Bill Hay 和 Hank Warren,编。PowerPC 编译器写作指南。加利福尼亚州帕洛阿尔托:Warthman Associates for IBM,1996 年。

Hyde, Randall. 汇编语言艺术。第 2 版。旧金山:No Starch Press,2010 年。

英特尔。“Intel 64 和 IA-32 架构软件开发手册。”更新于 2019 年 11 月 11 日。software.intel.com/en-us/articles/intel-sdm

Ledgard, Henry, 和 Michael Marcotty. The Programming Language Landscape. 芝加哥: SRA, 1986.

Louden, Kenneth C. Compiler Construction: Principles and Practice. 波士顿: Cengage, 1997.

Louden, Kenneth C., 和 Kenneth A. Lambert. Programming Languages: Principles and Practice. 第 3 版. 波士顿: Course Technology, 2012.

Parsons, Thomas W. Introduction to Compiler Construction. 纽约: W. H. Freeman, 1992.

Pratt, Terrence W., 和 Marvin V. Zelkowitz. Programming Languages, Design and Implementation. 第 4 版. Upper Saddle River, NJ: Prentice Hall, 2001.

Sebesta, Robert. Concepts of Programming Languages. 第 11 版. 波士顿: Pearson, 2016.

第八章:数组数据类型

image

高级语言的抽象隐藏了机器如何处理复合数据类型(由较小的数据对象组成的复杂数据类型)。虽然这些抽象通常很方便,但如果你不了解它们背后的细节,你可能会不经意地使用某些构造,导致生成不必要的代码或运行速度比需要的慢。本章将介绍一个最重要的复合数据类型:数组。我们将考虑以下主题:

  • 数组的定义

  • 如何在不同的语言中声明数组

  • 数组在内存中的表示方式

  • 访问数组元素

  • 声明、表示和访问多维数组

  • 行优先和列优先的多维数组访问

  • 动态数组与静态数组

  • 使用数组如何影响应用程序的性能和大小

数组在现代应用程序中非常常见,因此你应该深入理解程序如何在内存中实现和使用数组,这样才能编写出优秀的代码。本章将为你提供在程序中更高效使用数组所需的基础。

8.1 数组

数组是最常见的复合(或聚合)数据类型之一,但很少有程序员能完全理解它们是如何工作的。一旦他们理解了数组在机器级别的运作方式,程序员通常会从完全不同的角度看待它们。

从抽象的角度来看,数组是一种聚合数据类型,其成员(元素)都是相同类型的。要选择数组中的成员,你需要使用整数(或某些其底层表示为整数的值,例如字符、枚举和布尔类型)来指定该成员的数组索引。在本章中,我们假设数组的所有整数索引都是数值上连续的。也就是说,如果xy都是数组的有效索引,并且如果x < y,那么所有满足x < i < yi也是有效的索引。我们还通常假设数组元素在内存中占据连续的位置,尽管这不是数组的一般定义所要求的。一个包含五个元素的数组在内存中的表现如图 8-1 所示。

Image

图 8-1:数组在内存中的布局

数组的基地址是其第一个元素的地址,该元素占据最低的内存位置。第二个数组元素直接跟在第一个元素后面,第三个元素跟在第二个元素后面,依此类推。请注意,索引不必从0开始;它们可以从任何数字开始,只要它们是连续的。然而,讨论数组访问时,如果第一个索引是0会更容易,因此除非另有说明,本章中的数组从索引0开始。

每当你对数组应用索引操作符时,结果就是由该索引指定的唯一数组元素。例如,A[i]选择数组A中的第i个元素。

8.1 数组声明

数组声明在许多高级语言(HLL)中非常相似。本节提供了几种语言的示例。

8.1.1.1 在 C、C++和 Java 中声明数组

C、C++和 Java 都允许你通过指定元素的总数来声明数组。这些语言中的数组声明语法如下:

data_type  array_name [ number_of_elements ];

这里是一些 C/C++数组声明的示例:


			char CharArray[ 128 ];
int intArray[ 8 ];
unsigned char ByteArray[ 10 ];
int *PtrArray[ 4 ];

如果你将这些数组声明为自动变量,C/C++会使用内存中现有的位模式初始化它们。另一方面,如果你将这些数组声明为静态对象,C/C++会将每个数组元素初始化为0。如果你想自行初始化数组,可以使用以下 C/C++语法:

data_type array_name[ number_of_elements ] = {element_list};

这是一个典型的例子:

int intArray[8] = {0,1,2,3,4,5,6,7};

C/C++编译器将这些初始数组值存储在目标代码文件中,当操作系统将程序加载到内存时,会将这些值加载到与intArray相关联的内存位置。为了查看这一过程是如何工作的,考虑以下简短的 C/C++程序:


			#include <stdio.h>
static int intArray[8] = {1,2,3,4,5,6,7,8};
static int array2[8];

int main( int argc, char **argv )
{
    int i;
    for( i=0; i<8; ++i )
    {
        array2[i] = intArray[i];
    }
    for( i=7; i>= 0; --i )
    {
        printf( "%d\n", array2[i] );
    }
    return 0;
}

Microsoft 的 Visual C++编译器为这两个数组声明生成以下 80x86 汇编代码:


			_DATA    SEGMENT
intArray DD      01H
         DD      02H
         DD      03H
         DD      04H
         DD      05H
         DD      06H
         DD      07H
         DD      08H
$SG6842  DB      '%d', 0aH, 00H
_DATA    ENDS
_BSS     SEGMENT
_array2  DD      08H DUP (?)
_BSS     ENDS

每个DD(“定义双字”)语句预留 4 字节的存储空间,操作数指定它们在操作系统将程序加载到内存时的初始值。intArray声明出现在_DATA段中,该段在 Microsoft 内存模型中可以包含初始化数据。另一方面,array2变量是在_BSS段内声明的,MSVC++将未初始化的变量放置在该段中(操作数字段中的?字符告诉汇编器数据未初始化;8 dup (?)操作数告诉汇编器将声明复制八次)。当操作系统将_BSS段加载到内存时,它会将与该段关联的所有内存清零。在初始化和未初始化的情况下,编译器都会在连续的内存位置上分配这两个数组的所有八个元素。

8.1.1.2 在 HLA 中声明数组

HLA 的数组声明语法如下,它在语义上等同于 C/C++声明:

array_name : data_type [ number_of_elements ];

以下是一些 HLA 数组声明的例子,它们为未初始化的数组分配存储空间(第二个例子假设你已经在 HLA 程序的type部分定义了integer数据类型):


			static

 // Character array with elements 0..127.

 CharArray: char[128];

 // "integer" array with elements 0..7.

 IntArray: integer[ 8 ];

// Byte array with elements 0..9.

 ByteArray: byte[10];

 // Double word array with elements 0..3.

 PtrArray: dword[4];

你也可以使用以下声明初始化数组元素:


			RealArray: real32[8] :=
    [ 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0 ];

IntegerAry: integer[8] :=
    [ 8, 9, 10, 11, 12, 13, 14, 15 ];

这两种定义都创建了包含八个元素的数组。第一个定义将每个 4 字节的real32数组元素初始化为0.0..7.0范围内的某个值。第二个声明将每个integer数组元素初始化为8..15范围内的某个值。

8.1.1.3 在 Pascal/Delphi 中声明数组

Pascal/Delphi 使用以下语法声明数组:

array_name : array[ lower_bound..upper_bound ] of data_type;

与之前的示例一样,array_name 是标识符,data_type 是该数组中每个元素的类型。在 Pascal/Delphi 中(不同于 C/C++、Java 和 HLA),你指定数组的上下边界,而不是数组的大小。以下是 Pascal 中的典型数组声明:


			type
    ptrToChar = ^char;
var
    CharArray: array[ 0..127 ] of char;   // 128 elements
    IntArray: array[ 0..7 ] of integer;   // 8 elements
    ByteArray: array[ 0..9 ] of char;     // 10 elements
    PtrArray: array[ 0..3 ] of ptrToChar; // 4 elements

尽管这些 Pascal 示例的索引从 0 开始,但 Pascal 并不要求这样做。以下是 Pascal 中一个完全有效的数组声明:


			var
   ProfitsByYear : array[ 1998..2028 ] of real; // 31 elements

声明此数组的程序在访问该数组元素时会使用索引 1998..2028,而不是 0..30

许多 Pascal 编译器提供了一个非常有用的功能,帮助你在程序中找到缺陷。每当你访问数组的元素时,这些编译器会自动插入代码,验证数组索引是否在声明时指定的范围内。如果索引超出范围,这段额外的代码会停止程序。例如,如果 ProfitsByYear 的索引超出了 1998..2028 范围,程序将因错误而中止。^(1)

8.1.1.4 在 Swift 中声明数组

Swift 中的数组声明与其他基于 C 的语言有所不同。Swift 的数组声明有以下两种(等效的)形式:


			var data_type array_name = Array<element_type>()
var data_type array_name = [element_type]()

与其他语言不同,Swift 中的数组是完全动态的。你通常不会在第一次创建数组时指定元素的数量;相反,你可以根据需要使用 append() 等函数向数组添加元素。如果你想预声明一个包含一定数量元素的数组,你可以使用特殊的数组构造形式,如下所示:


			var data_type array_name = Array<element_type>( repeating: initial_value, count: elements )

在这个例子中,initial_value 是元素类型的值,elements 是要在数组中创建的元素数量。例如,以下 Swift 代码创建了两个包含 100 个 Int 值的数组,每个数组都初始化为 0


			var intArray = Array<Int>( repeating: 0, count: 100 )
var intArray2 = Int

请注意,你仍然可以扩展此数组的大小(例如,通过使用 append() 函数);由于 Swift 数组是动态的,它们的大小可以在运行时增长或缩小。

也可以使用初始值来创建一个 Swift 数组:


			var intArray = [1, 2, 3]
var strArray = ["str1", "str2", "str3"]

Swift 和 Pascal 一样,会在运行时检查数组索引的有效性。如果你尝试访问一个不存在的数组元素,Swift 会抛出异常。

8.1.1.5 声明非整数索引值的数组

通常,数组索引是整数值,尽管一些语言允许其他序数类型(使用基础整数表示的类型)。例如,Pascal 允许使用 charboolean 类型作为数组索引。在 Pascal 中,声明数组如下是完全合理且有用的:

alphaCnt : array[ 'A'..'Z' ] of integer;

你可以使用字符表达式作为数组索引来访问 alphaCnt 的元素。例如,考虑下面的 Pascal 代码,它将 alphaCnt 中的每个元素初始化为 0


			for ch := 'A' to 'Z' do
    alphaCnt[ ch ] := 0;

汇编语言和 C/C++ 将大多数顺序值视为整数值的特殊实例,因此它们是合法的数组索引。大多数 BASIC 的实现允许将浮点数作为数组索引,尽管 BASIC 在使用它作为索引之前总是将值截断为整数。

注意

BASIC 允许你使用浮点值作为数组索引,因为原始语言不支持整数表达式;它只提供了实数和字符串值。

8.1.2 数组在内存中的表示

抽象地说,数组是一组变量,你可以通过索引来访问它们。在语义上,你可以按任何方式定义数组,只要它将不同的索引映射到内存中的不同对象,并且始终将相同的索引映射到相同的对象。然而,实际上,大多数语言使用一些常见的算法来提供对数组数据的高效访问。

数组最常见的实现方式是将元素存储在连续的内存位置。如前所述,大多数编程语言将数组的第一个元素存储在较低的内存地址,然后将后续元素存储在逐渐更高的内存位置。

考虑以下 C 程序:


			#include <stdio.h>

static char array[8] = {0,1,2,3,4,5,6,7};

int main( void )
{

    printf( "%d\n", array[0] );
}

这是 GCC 为它生成的相应 PowerPC 汇编代码:


			        .align 2
_array:
        .byte   0   ; Note that the assembler stores the byte
        .byte   1   ; values on successive lines into
        .byte   2   ; contiguous memory locations.
        .byte   3
        .byte   4
        .byte   5
        .byte   6
        .byte   7

数组占用的字节数是元素个数与每个元素占用字节数的乘积。在前面的例子中,每个数组元素占用一个字节,因此数组占用的字节数与元素个数相同。然而,对于更大元素的数组,整个数组的大小(以字节为单位)相应地更大。考虑以下 C 代码:


			#include <stdio.h>

static int array[8] = {0,0,0,0,0,0,0,1};

int main( void )
{
    printf( "%d\n", array[0] );
}

这是相应的 GCC 汇编语言输出:


			        .align 2
_array:
        .long   0
        .long   0
        .long   0
        .long   0
        .long   0
        .long   0
        .long   0
        .long   1

许多语言还会在数组的末尾添加一些填充字节,以使数组的总长度成为像 2 或 4 这样的方便值的倍数(这使得使用位移计算数组索引变得容易,或者为内存中紧跟数组后的下一个对象添加额外的填充字节;详细信息请参见 WGC1 的第三章)。然而,程序不能依赖这些额外的填充字节,因为它们可能存在也可能不存在。一些编译器总是将它们加入,另一些则从不加入,还有一些编译器根据内存中紧跟数组的对象类型决定是否加入。

许多优化编译器会尝试将数组放置在内存地址为常见大小(如 2、4 或 8 字节)倍数的地址上。实际上,这会在数组的开始前添加填充字节,或者,如果你更愿意这样理解的话,在内存中紧跟数组的前一个对象之后添加填充字节(参见图 8-2)。

Image

图 8-2:在数组前添加填充字节

在不支持字节寻址内存的机器上,尝试将数组的第一个元素放置在容易访问的边界上的编译器,将根据机器支持的边界分配数组的存储。在前面的示例中,注意到.align 2指令位于_array声明之前。在 Gas 语法中,.align指令告诉汇编器调整源文件中下一个声明对象的内存地址,使其开始于某个边界地址,而这个边界地址是 2 的某个幂(由.align的操作数指定)的倍数。在这个示例中,.align 2告诉汇编器将_array的第一个元素对齐到一个是 4 的倍数的地址边界(即 2²)。

如果每个数组元素的大小小于 CPU 支持的最小内存对象大小,编译器的实现者有两个选择:

  1. 为数组的每个元素分配最小的可访问内存对象。

  2. 将多个数组元素打包到一个内存单元中。

选项 1 的优点是速度快,但它浪费了内存,因为每个数组元素都携带一些它不需要的额外存储。以下 C 语言示例为一个元素大小为 5 字节的数组分配存储空间,其中每个元素是一个结构体对象,由一个 4 字节的long对象和一个 1 字节的char对象组成(我们将在第十一章中讨论 C 语言结构体)。


			#include <stdio.h>

typedef struct
{
    long a;
    char b;
} FiveBytes;

static FiveBytes shortArray[2] = {{2,3}, {4,5}};
int main( void )
{
    printf( "%ld\n", shortArray[0].a );
}

当 GCC 将此代码编译为在 PowerPC 处理器上运行时,该处理器需要对long对象进行双字对齐,编译器会自动在每个元素之间插入 3 个字节的填充:


			.data
        .align 2   ; Ensure that _shortArray begins on an
                   ; address boundary that is a multiple
                   ; of 4.
_shortArray:
        .long   2  ; shortArray[0].a
        .byte   3  ; shortArray[0].b
        .space  3  ; Padding, to align next element to 4 bytes
        .long   4  ; shortArray[1].a
        .byte   5  ; shortArray[1].b
        .space  3  ; Padding, at end of array.

选项 2 比较紧凑,但速度较慢,因为它在访问数组元素时需要额外的指令来打包和解包数据。在这种机器上的编译器通常提供一个选项,让你指定是否希望数据被打包或解包,从而在空间和速度之间做出选择。

请记住,如果你在使用字节寻址的机器(如 80x86)上工作,那么你可能不需要担心这个问题。然而,如果你正在使用高级语言(HLL),并且你的代码将来可能会在不同的机器上运行,那么你应该选择一种在所有机器上都高效的数组组织方式(即,采用填充每个数组元素的额外字节的组织方式)。

8.1.3 Swift 数组实现

尽管到目前为止的示例都涉及 Swift 中的数组,但 Swift 数组有不同的实现方式。首先,Swift 数组是基于 struct 对象的一个不透明类型^(2),而不仅仅是内存中元素的集合。Swift 不保证数组元素在连续的内存位置出现;因此,你不能假设在 Swift 数组中的对象元素和某些其他元素类型是连续存储的。作为解决方法,Swift 提供了 ContiguousArray 类型说明。为了保证数组元素出现在连续的内存位置,你可以在声明 Swift 数组变量时指定 ContiguousArray 而非 Array,如下所示:


			var data_type array_name = ContiguousArray<element_type>()

Swift 数组的内部实现是一个包含计数(当前数组元素数量)、容量(当前已分配的数组元素数量)和指向存储数组元素的指针的结构体。由于 Swift 数组是一个不透明类型,这种实现可能随时发生变化;然而,在结构体中的某个位置会有指向实际数组数据的内存指针。

Swift 动态地分配数组存储,这意味着你永远看不到 Swift 编译器生成的目标代码文件中嵌入的数组存储(除非 Swift 语言规范发生变化,支持静态分配数组)。你可以通过向数组追加元素来增加数组的大小,但如果你尝试将其扩展超过当前容量,Swift 运行时系统可能需要动态地重新定位数组对象。为了提高性能,Swift 使用指数分配方案:每当你向数组追加一个会超过其容量的值时,Swift 运行时系统会分配当前容量的两倍(或其他常数)存储空间,将当前数组缓冲区的数据复制到新缓冲区中,然后将数组的内部指针指向新的内存块。这个过程的一个重要方面是,你永远不能假设指向数组数据的指针是静态的,也不能假设数组数据始终保存在内存中的同一缓冲区位置——在不同的时间点,数组可能出现在内存中的不同位置。

8.1.4 访问数组元素

如果你为数组分配了所有存储空间,并且数组的第一个索引是 0,那么访问一维数组的元素就变得很简单。你可以使用以下公式计算数组中任何给定元素的地址:

Element_Address = Base_Address + index * Element_Size

Element_Size 是每个数组元素所占的字节数。因此,如果数组包含 byte 类型的元素,则 Element_Size 字段为 1,计算就非常简单。如果数组的每个元素是 word(或其他 2 字节类型),则 Element_Size 为 2,依此类推。考虑以下 Pascal 数组声明:

var  SixteenInts : array[0..15] of integer;

要在字节可寻址的机器上访问 SixteenInts 数组的元素,假设使用的是 4 字节整数,你需要使用以下计算:

Element_Address = AddressOf( SixteenInts ) + index * 4

在 HLA 汇编语言中(在这里你必须手动进行这个计算,而不是让编译器为你完成),你可以使用类似这样的代码来访问数组元素 SixteenInts[index]


			mov( index, ebx );
mov( SixteenInts[ ebx*4 ], eax );

要看到这个过程的实际操作,请考虑以下 Pascal/Delphi 程序和生成的 32 位 80x86 代码(我通过反汇编 Delphi 编译器输出的 .exe 文件并将结果粘贴回原始 Pascal 代码中获得):


			program x(input,output);
var
    i :integer;
    sixteenInts :array[0..15] of integer;

    function changei(i:integer):integer;
    begin
        changei := 15 - i;
    end;

    // changei         proc    near
    //                 mov     edx, 0Fh
    //                 sub     edx, eax
    //                 mov     eax, edx
    //                 retn
    // changei         endp

begin
    for i:= 0 to 15 do
        sixteenInts[ changei(i) ] := i;

    //                 xor     ebx, ebx
    //
    // loc_403AA7:
    //                 mov     eax, ebx
    //                 call    changei
    //
    // Note the use of the scaled-index addressing mode
    // to multiply the array index by 4 prior to accessing
    // elements of the array:
    //
    //                 mov     ds:sixteenInts[eax*4], ebx
    //                 inc     ebx
    //                 cmp     ebx, 10h
    //                 jnz     short loc_403AA7

end.

如同 HLA 示例中所示,Delphi 编译器使用 80x86 缩放索引寻址模式,将索引乘以元素大小(4 字节)。80x86 提供了四种不同的缩放值用于缩放索引寻址模式:1、2、4 或 8 字节。如果数组的元素大小不是这四个值中的任何一个,机器代码必须显式地将索引乘以数组元素的大小。以下 Delphi/Pascal 代码(及来自反汇编的相应 80x86 代码)演示了这个过程,其中使用了一个有 9 字节活动数据的记录(Delphi 将其四舍五入到下一个 4 字节的倍数,因此实际上为每个记录数组元素分配了 12 字节):


			program x(input,output);
type
    NineBytes=
        record
            FourBytes       :integer;
            FourMoreBytes   :integer;
            OneByte         :char;
        end;

var
    i               :integer;
    NineByteArray   :array[0..15] of NineBytes;

    function changei(i:integer):integer;
    begin
        changei := 15 - i;
    end;

    // changei         proc    near
    //                 mov     edx, 0Fh
    //                 sub     edx, eax
    //                 mov     eax, edx
    //                 retn
    // changei         endp

begin

    for i:= 0 to 15 do
        NineByteArray[ changei(i) ].FourBytes := i;

//                  xor     ebx, ebx
//
//  loc_403AA7:
//                  mov     eax, ebx
//                  call    changei
//
//            // Compute EAX = EAX * 3
//
//                  lea     eax, [eax+eax*2]
//
//            // Actual index used is index*12 ((EAX*3) * 4)
//
//                  mov     ds:NineByteArray[eax*4], ebx
//                  inc     ebx
//                  cmp     ebx, 10h
//                  jnz     short loc_403AA7

end.

微软的 C/C++ 编译器生成可比的代码(也为每个记录数组元素分配 12 字节)。

8.1.5 填充与打包

这些 Pascal 示例重申了一个重要的观点:编译器通常会将每个数组元素填充到 4 字节的倍数,或者根据机器架构的要求填充到最合适的大小,以提高访问数组元素(和记录字段)的效率,确保它们始终在合理的内存边界上对齐。一些编译器提供了选项,可以消除每个数组元素末尾的填充,使得连续的数组元素在内存中紧接在前一个元素之后。例如,在 Pascal/Delphi 中,你可以通过使用 packed 关键字来实现这一点:


			program x(input,output);

// Note the use of the "packed" keyword.
// This tells Delphi to pack each record
// into 9 consecutive bytes, without
// any padding at the end of the record.

type
    NineBytes=
        packed record
            FourBytes       :integer;
            FourMoreBytes   :integer;
            OneByte         :char;
        end;

var
    i               :integer;
    NineByteArray   :array[0..15] of NineBytes;

    function changei(i:integer):integer;
    begin
        changei := 15 - i;
    end;

    // changei         proc near
    //                 mov     edx, 0Fh
    //                 sub     edx, eax
    //                 mov     eax, edx
    //                 retn
    // changei         endp

begin

    for i:= 0 to 15 do
        NineByteArray[ changei(i) ].FourBytes := i;
//                 xor     ebx, ebx
//
// loc_403AA7:
//                 mov     eax, ebx
//                 call    changei
//
//      // Compute index (eax) = index * 9
//      // (computed as index = index + index*8):
//
//                 lea     eax, [eax+eax*8]
//
//                 mov     ds:NineBytes[eax], ebx
//                 inc     ebx
//                 cmp     ebx, 10h
//                 jnz     short loc_403AA7

end.

packed 保留字仅仅是给 Pascal 编译器的一个提示。通用的 Pascal 编译器可以选择忽略它;Pascal 标准并没有明确说明它对编译器代码生成的影响。Delphi 使用 packed 关键字来告诉编译器将数组(和记录)元素打包到字节边界,而不是 4 字节边界。其他 Pascal 编译器实际上使用这个关键字将对象对齐到比特边界。

注意

有关 packed 关键字的更多信息,请参阅编译器文档。

很少有其他语言在通用语言定义中提供将数据打包到给定边界的方式。例如,在 C/C++ 语言中,许多编译器提供了 pragma 或命令行开关来控制数组元素的填充,但这些功能几乎总是特定于某个编译器。

通常,在选择打包和填充数组元素(当你有选择时)时,通常是在速度和空间之间进行权衡。打包可以为每个数组元素节省少量空间,但代价是访问速度变慢(例如,当访问内存中位于奇数地址的dword对象时)。此外,计算数组中元素的索引(当元素大小不是 2 的倍数时,或者更好的是 2 的幂次)可能需要更多的指令,这也会减慢访问此类数组元素的程序的速度。

当然,一些机器架构不允许未对齐的数据访问,因此,如果你正在编写必须在不同 CPU 上编译和运行的可移植代码,你不应指望数组元素可以紧密地打包到内存中。一些编译器可能不提供这个选项。

在结束这段讨论之前,值得强调的是,最佳的数组元素大小是 2 的幂次。通常,只需一条指令就可以将任何数组索引乘以 2 的幂(这条指令是左移指令)。考虑以下 C 程序和 Borland C++编译器生成的汇编输出,它使用具有 32 字节元素的数组:


			typedef struct
{
    double EightBytes;
    double EightMoreBytes;
    float  SixteenBytes[4];
} PowerOfTwoBytes;

int i;
PowerOfTwoBytes ThirtyTwoBytes[16];

int changei(int i)
{
    return 15 - i;
}

int main( int argc, char **argv )
{
    for( i=0; i<16; ++i )
    {
        ThirtyTwoBytes[ changei(i) ].EightBytes = 0.0;
    }

    // @5:
    //  push      ebx
    //  call      _changei
    //  pop       ecx           // Remove parameter
    //
    // Multiply index (in EAX) by 32.
    // Note that (eax << 5) = eax * 32
    //
    //  shl       eax,5
    //
    // 8 bytes of zeros are the coding for
    // (double) 0.0:
    //
    //  xor       edx,edx
    //  mov       dword ptr [eax+_ThirtyTwoBytes],edx
    //  mov       dword ptr [eax+_ThirtyTwoBytes+4],edx
    //
    // Finish up the for loop here:
    //
    //  inc       dword ptr [esi]   ;ESI points at i.
    // @6:
    //  mov       ebx,dword ptr [esi]
    //  cmp       ebx,16
    //  jl        short @5

    return 0;
}

如你所见,Borland C++编译器发出shl指令来将索引乘以 32。

8.1.6 多维数组

多维数组是指允许使用两个或更多独立的索引值来选择数组元素的数组。一个经典的例子是二维数据结构(矩阵),它跟踪按日期的产品销售情况。表中的一个索引可能是日期,另一个索引可能是产品的标识(某个整数值)。通过这两个索引选择的数组元素将是该产品在某个特定日期的总销售额。这个例子的三维扩展可以是按日期和国家销售的产品。再次,产品值、日期值和国家值的组合将用来定位数组中的元素,从而得到该产品在该国家指定日期的销售情况。

大多数 CPU 可以轻松处理使用索引寻址模式的一维数组。不幸的是,没有一种神奇的寻址模式可以让你轻松访问多维数组的元素。这将需要一些工作和几条机器指令。

8.1.6.1 声明多维数组

一个“mn”的数组有m × n个元素,需要m × n × 元素大小字节的存储空间。对于一维数组,高级语言的语法非常相似。然而,它们的语法在多维数组中开始有所不同。

在 C、C++和 Java 中,你可以使用以下语法声明一个多维数组:

data_type array_name [dim1][dim2]...[dimn];

例如,这是一个 C/C++中的三维数组声明:

int threeDInts[ 4 ][ 2 ][ 8 ];

这个示例创建了一个包含 64 个元素的数组,按 4 深度、2 行、8 列的结构组织。假设每个 int 对象需要 4 字节,那么这个数组消耗了 256 字节的存储空间。

Pascal 的语法支持两种等效的声明多维数组的方式。以下示例演示了这两种方式:


			var
    threeDInts:
        array[0..3] of array[0..1] of array[0..7] of integer;

    threeDInts2: array[0..3, 0..1, 0..7] of integer;

第一个 Pascal 声明在技术上是一个数组的数组,而第二个声明是一个标准的多维数组。

从语义上讲,不同语言处理多维数组的方式有两个主要差异:是否数组声明指定了每个数组维度的总体大小或上下边界;以及起始索引是否为01或用户指定的值。

8.1.6.2 声明 Swift 多维数组

Swift 不支持原生的多维数组,而是使用数组的数组。对于大多数编程语言来说,数组对象严格来说是内存中数组元素的序列,因此数组的数组和多维数组是相同的(参见前面的 Pascal 示例)。然而,Swift 使用描述符(基于 struct)对象来指定数组。与字符串描述符类似,Swift 数组由一个包含各种字段的数据结构组成(例如容量、当前大小和指向数据的指针;有关更多细节,请参见 第 234 页的“Swift 数组实现”)。当你创建一个数组的数组时,实际上是在创建一个包含这些描述符的数组,每个描述符都指向一个子数组。考虑以下(等效的)Swift 数组的数组声明和示例程序:


			import Foundation

var a1 = [[Int]]()
var a2 = ContiguousArray<Array<Int>>()
a1.append( [1,2,3] )
a1.append( [4,5,6] )
a2.append( [1,2,3] )
a2.append( [4,5,6] )

print( a1 )
print( a2 )
print( a1[0] )
print( a1[0][1] )

运行此程序会产生以下输出:


			[[1, 2, 3], [4, 5, 6]]
[[1, 2, 3], [4, 5, 6]]
[1, 2, 3]
2

这是合理的——对于二维数组,你会期望看到这种类型的输出。然而,从内部来看,a1a2 是具有两个元素的单维数组。这两个元素是数组描述符,它们分别指向数组(每个数组在本示例中包含三个元素)。尽管 a2 是一个连续的数组类型,但与 a2 相关的六个数组元素不太可能出现在连续的内存位置。a2 中持有的两个数组描述符可能出现在连续的内存位置,但这不一定会延续到它们共同指向的六个数据元素上。

由于 Swift 动态分配数组存储,二维数组中的行可以具有不同的元素数量。考虑对之前的 Swift 程序进行以下修改:


			import Foundation

var a2 = ContiguousArray<Array<Int>>()
a2.append( [1,2,3] )
a2.append( [4,5] )

print( a2 )
print( a2[0] )
print( a2[0][1] )

运行这个程序会产生以下输出:


			[[1, 2, 3], [4, 5]]
[1, 2, 3]
2

请注意,a2 数组中的两行具有不同的大小。这可能是有用的,或者根据你要实现的目标可能是缺陷的来源。

在 Swift 中获取标准的多维数组存储的一种方法是声明一个一维的 ContiguousArray,该数组的元素数足够容纳所有多维数组的元素。然后使用行主序(或列主序)功能计算数组中的索引(参见第 244 页的“实现行主序”以及第 247 页的“实现列主序”)。

8.1.6.3 将多维数组元素映射到内存

现在您已经了解了如何声明数组,接下来需要知道如何在内存中实现它们。第一个挑战是将多维对象存储到一维内存空间中。

考虑一个如下形式的 Pascal 数组:

A:array[0..3,0..3] of char;

这个数组包含 16 字节,组织为四行四列的字符。您需要将该数组中的每个 16 字节映射到主内存中每个连续的 16 字节。图 8-3 展示了其中一种方法。

您可以通过不同的方式将数组网格中的位置映射到内存地址,只要遵循两个规则:

  • 数组中的任何两个元素不能占用相同的内存位置。

  • 数组中的每个元素始终映射到相同的内存位置。

因此,您真正需要的是一个具有两个输入参数的函数(一个用于行值,一个用于列值),它会生成一个偏移量,指向一个连续的 16 个内存位置的块。

Image

图 8-3:将 4×4 数组映射到连续内存位置

现在,任何满足这两个约束的旧函数都能正常工作。然而,您真正需要的是一个映射函数,它能在运行时高效地计算,并且适用于具有任意维数和维度边界的数组。虽然有许多选项符合这一要求,但大多数高级编程语言使用两种组织方式之一:行主序列主序

8.1.6.4 实现行主序

行主序通过跨越行并向下移动列来将数组元素分配到连续的内存位置。图 8-4 演示了A[col,row]的这种映射。

Image

图 8-4:4×4 数组的行主序

行主序是大多数高级编程语言使用的方法,包括 Pascal、C/C++/C#、Java、Ada 和 Modula-2。它非常容易实现,并且在机器语言中也很容易使用。从二维结构到线性序列的转换非常直观。图 8-5 提供了一个 4×4 数组的行主序的另一种视图。

Image

图 8-5:4×4 数组的行主序的另一种视图

将多维数组索引集转换为单一偏移量的函数,是计算一维数组元素地址公式的一个轻微修改。给定形式的二维行优先顺序数组访问时,计算偏移量的通用公式如下:

array[ colindex ][ rowindex ]

如下所示:

Element_Address =
    Base_Address +
        (colindex * row_size + rowindex) * Element_Size

和往常一样,Base_Address 是数组第一个元素的地址(在本例中为 A[0][0]),Element_Size 是数组中单个元素的大小(以字节为单位)。Row_size 是数组每行元素的数量(在本例中是 4,因为每行有四个元素)。假设 Element_Size 为 1 且 row_size 为 4,则该公式计算出从基地址开始的偏移量,如 表 8-1 所示。

对于三维数组,计算偏移量的公式仅稍微复杂一些。考虑以下给定的 C/C++ 数组声明:

someType array[depth_size][col_size][row_size];

表 8-1: 二维行优先顺序数组的偏移量

列索引 行索引 数组偏移量
0 0 0
0 1 1
0 2 2
0 3 3
1 0 4
1 1 5
1 2 6
1 3 7
2 0 8
2 1 9
2 2 10
2 3 11
3 0 12
3 1 13
3 2 14
3 3 15

如果你有类似 array[depth_index] [col_index] [row_index] 的数组访问,则产生内存偏移量的计算公式为:

Address =
    Base +
        ((depth_index * col_size + col_index) *
            row_size + row_index) * Element_Size

再次强调,Element_Size 是单个数组元素的大小(以字节为单位)。

对于四维数组,声明为 C/C++ 如下:

type A[bounds0] [bounds1] [bounds2] [bounds3];

访问元素 A[i][j][k][m] 时,计算数组元素地址的公式为:

Address =
    Base +
        (((i * bounds1 + j) * bounds2 + k) * bounds3 + m) *
            Element_Size

如果你在 C/C++ 中声明了一个 n 维数组,如下所示:

dataType array[bn-1][bn-2]...[b0];

并且你希望访问该数组的以下元素:

array[an-1][an-2]...[a1][a0]

然后你可以使用以下算法计算特定数组元素的地址:

Address := an-1
for i := n-2 downto 0 do
    Address := Address * bi + ai
Address := Base_Address + Address * Element_Size

编译器实际执行这样的循环来计算数组索引是非常罕见的。通常维度较小,编译器会展开循环,从而避免了循环控制指令的开销。

8.1.6.5 实现列优先顺序

列优先顺序,另一种常见的数组元素地址函数,被 FORTRAN、OpenGL 和各种 BASIC 方言(如早期版本的 Microsoft BASIC)用于索引数组。列优先顺序的数组(访问形式为 A[col,row])的组织方式如 图 8-6 所示。

Image

图 8-6:列优先顺序

使用列主序(column-major)排序时,计算数组元素地址的公式与行主序(row-major)排序时非常相似。不同之处在于,你需要反转计算中的索引和大小变量的顺序。也就是说,计算时不是从最左边的索引开始,而是从最右边的索引开始操作。

对于二维列主序数组:

Element_Address =
    Base_Address +
        (rowindex * col_size + colindex) *
            Element_Size

对于三维列主序数组:

Element_Address =
    Base_Address +
        ((rowindex * col_size + colindex) *
            depth_size + depthindex) *
                Element_Size

依此类推。除了使用这些新公式之外,使用列主序访问数组元素与使用行主序访问数组元素是相同的。

8.1.6.6 访问多维数组的元素

在高级语言中访问多维数组的元素非常容易,以至于许多程序员在没有考虑相关成本的情况下就这么做了。在本节中,为了让你更清楚地了解这些成本,我们将查看编译器常常生成的一些汇编语言序列,用于访问多维数组的元素。由于数组是现代应用程序中最常见的数据结构之一,而多维数组也非常常见,因此编译器设计师投入了大量的工作,以确保它们尽可能高效地计算数组索引。给定如下声明:

int ThreeDInts[ 8 ][ 2 ][ 4 ];

以及像下面这样的数组引用:

ThreeDInts[i][j][k] = n;

访问数组元素(使用行主序)需要计算以下内容:

Element_Address =
    Base_Address +
        ((i * col_size + j) * // col_size = 2
            row_size + k) *   // row_size = 4
                Element_Size

在暴力汇编代码中,这可能是:


			intmul( 2, i, ebx );    // EBX = 2*i
add( j, ebx );          // EBX = 2*i + j
intmul( 4, ebx );       // EBX = (2*i + j)*4
add( k, ebx );          // EBX = (2*i + j)*4 + k
mov( n, eax );
mov( eax, ThreeDInts[ebx*4] );  // ThreeDInts[i][j][k] = n; assumes 4-byte ints

然而,在实践中,编译器作者避免使用 80x86 的intmulimul)指令,因为它很慢。许多不同的机器惯用法可以用来模拟乘法,利用短小的加法、移位和“加载有效地址”指令序列来实现。大多数优化编译器使用计算数组元素地址的指令序列,而不是使用乘法指令的暴力代码。

考虑以下 C 程序,它初始化了一个 4×4 数组的 16 个元素:


			int i, j;
int TwoByTwo[4][4];

int main( int argc, char **argv )
{
    for( j=0; j<4; ++j )
    {
        for( i=0; i<4; ++i )
        {
            TwoByTwo[i][j] = i+j;
        }
    }
    return 0;
}

现在考虑 Borland C++ v5.0 编译器(一个老旧的编译器)为此示例中的for循环生成的汇编代码:


			    mov       ecx,offset _i
    mov       ebx,offset _j
   ;
   ;    {
   ;        for( j=0; j<4; ++j )
   ;
?live1@16: ; ECX = &i, EBX = &j
    xor       eax,eax
    mov       dword ptr [ebx],eax ;i = 0
    jmp       short @3
   ;
   ;        {
   ;            for( i=0; i<4; ++i )
   ;
@2:
    xor       edx,edx
    mov       dword ptr [ecx],edx ; j = 0

; Compute the index to the start of the
; current column of the array as
; base( TwoByTwo ) + eax*4\. Leave this
; "column base address" in EDX:

    mov       eax,dword ptr [ebx]
    lea       edx,dword ptr [_TwoByTwo+4*eax]
    jmp       short @5
   ;
   ;            {
   ;                TwoByTwo[i][j] = i+j;
   ;
?live1@48: ; EAX = @temp0, EDX = @temp1, ECX = &i, EBX = &j
@4:

;
    mov       esi,eax                  ; Compute i+j
    add       esi,dword ptr [ebx]      ; EBX points at j's value

    shl       eax,4                    ; Multiply row index by 16

; Store the sum (held in ESI) into the specified array element.
; Note that EDX contains the base address plus the column
; offset into the array. EAX contains the row offset into the
; array. Their sum produces the address of the desired array
; element.

    mov       dword ptr [edx+eax],esi  ; Store sum into element

    inc       dword ptr [ecx]          ; increment i by 1
@5:
    mov       eax,dword ptr [ecx]      ; Fetch i's value
    cmp       eax,4                    ; Is i less than 4?
    jl        short @4                 ; If so, repeat inner loop
    inc       dword ptr [ebx]          ; Increment j by 1
@3:
    cmp       dword ptr [ebx],4        ; Is j less than 4?
    jl        short @2                 ; If so, repeat outer loop.
   ;

       .
       .
       .
; Storage for the 4x4 (x4 bytes) two-dimensional array:
; Total = 4*4*4 = 64 bytes:

    align   4
_TwoByTwo   label   dword
    db  64  dup(?)

在这个例子中,计算 rowIndex * 4 + columnIndex 通过以下四条指令来处理(这些指令也存储了数组元素):


			; EDX = base address + columnIndex * 4

    mov       eax,dword ptr [ebx]
    lea       edx,dword ptr [_TwoByTwo+4*eax]
      .
      .
      .
; EAX = rowIndex, ESI = i+j

    shl       eax,4                    ; Multiply row index by 16
    mov       dword ptr [edx+eax],esi  ; Store sum into element

注意,这段代码使用了缩放索引寻址模式(以及lea指令)和shl指令来完成必要的乘法运算。由于乘法操作通常是昂贵的操作,大多数编译器避免在计算多维数组的索引时使用它。尽管如此,通过将这段代码与用于一维数组访问的示例进行比较,你可以看到在计算数组索引时,二维数组访问在机器指令数量上稍微更为昂贵。

三维数组的访问比二维数组的访问更昂贵。这里有一个 C/C++ 程序,它初始化了一个三维数组的元素:


			#include <stdlib.h>
int i, j, k;
int ThreeByThree[3][3][3];

int main( int argc, char **argv )
{
    for( j=0; j<3; ++j )
    {
        for( i=0; i<3; ++i )
        {
            for( k=0; k<3; ++k )
            {
                // Initialize the 27 array elements
                // with a set of random values:

                ThreeByThree[i][j][k] = rand();
            }
        }
    }
    return 0;
}

这是 Microsoft Visual C++编译器生成的 32 位 80x86 汇编语言输出:


			; Line 9
        mov     DWORD PTR j, 0     // for( j = 0;...;... )
        jmp     SHORT $LN4@main

$LN2@main:
        mov     eax, DWORD PTR j   // for( ...;...;++j )
        inc     eax
        mov     DWORD PTR j, eax

$LN4@main:
        cmp     DWORD PTR j, 4     // for( ...;j<4;... )
        jge     $LN3@main

; Line 11
        mov     DWORD PTR i, 0     // for( i=0;...;... )
        jmp     SHORT $LN7@main

$LN5@main:
        mov     eax, DWORD PTR i   // for( ...;...;++i )
        inc     eax
        mov     DWORD PTR i, eax

$LN7@main:
        cmp     DWORD PTR i, 4     // for( ...;i<4;... )
        jge     SHORT $LN6@main

; Line 13
        mov     DWORD PTR k, 0     // for( k=0;...;... )
        jmp     SHORT $LN10@main

$LN8@main:
        mov     eax, DWORD PTR k   // for( ...;...;++k )
        inc     eax
        mov     DWORD PTR k, eax

$LN10@main:
        cmp     DWORD PTR k, 3     // for( ...; k<3;... )
        jge     SHORT $LN9@main

; Line 18
        call    rand
        movsxd  rcx, DWORD PTR i   // Index =( (( ( i*3 + j ) * 3 + k ) * 4 )
        imul    rcx, rcx, 36       // 00000024H
        lea     rdx, OFFSET FLAT:ThreeByThree
        add     rdx, rcx
        mov     rcx, rdx
        movsxd  rdx, DWORD PTR j
        imul    rdx, rdx, 12
        add     rcx, rdx
        movsxd  rdx, DWORD PTR k
//  ThreeByThree[i][j][k] = rand();

        mov     DWORD PTR [rcx+rdx*4], eax

; Line 19
        jmp     SHORT $LN8@main // End of for( k = 0; k<3; ++k )
$LN9@main:
; Line 20
        jmp     SHORT $LN5@main // End of for( i = 0; i<4; ++i )
$LN6@main:
; Line 21
        jmp     $LN2@main       // End of for( j = 0; j<4; ++j )
$LN3@main:

如果你感兴趣,你可以编写自己的短小 HLL 程序,并分析为n维数组(n大于或等于 4)生成的汇编代码。

列主序或行主序数组的选择通常由编译器决定,如果没有,则由编程语言的定义决定。我所知道的没有任何编译器允许你按数组逐个选择你偏好的数组排序方式(或者甚至是跨整个程序)。然而,实际上没有必要这样做,因为你可以通过简单地改变程序中“行”和“列”的定义来轻松模拟这两种存储机制。

请考虑以下 C/C++数组声明:


			int array[ NumRows ][ NumCols ];

通常,你会通过如下引用访问此数组的元素:

element = array[ rowIndex ][ colIndex ]

如果你按每行的列索引值递增(你也会递增行索引值),你将在访问此数组的元素时访问连续的内存位置。也就是说,以下 C for循环用0初始化内存中的连续位置:


			for( row=0; row<NumRows; ++row )
{
    for( col=0; col<NumCols; ++col )
    {
        array[ row ][ col ] = 0;
    }
}

如果 NumRow 和 NumCols 的值相同,那么以列主序而不是行主序访问数组元素是微不足道的。只需交换前面代码片段中的索引即可得到:


			for( row=0; row<NumRows; ++row )
{
    for( col=0; col<NumCols; ++col )
    {
        array[ col ][ row ] = 0;
    }
}

如果 NumCols 和 NumRows 的值不相同,你将需要手动计算列主序数组中的索引,并像下面这样分配一个一维数组的存储:


			int columnMajor[ NumCols * NumRows ]; // Allocate storage
    .
    .
    .
for( row=0; row<NumRows; ++row)
{
    for( col=0; col<NumCols; ++col )
    {
        columnMajor[ col*NumRows + row ] = 0;
    }
}

希望实现真正的多维数组(而不是数组的数组实现)的 Swift 用户需要为整个数组分配存储空间,作为一个单一的ContiguousArray类型,然后手动计算数组中的索引:


			import Foundation

// Create a 3-D array[4][4][4]:

var a1 = ContiguousArray<Int>( repeating:0, count:4*4*4 )

for var i in 0...3
{
    for var j in 0...3
    {
        for var k in 0...3
        {
            a1[ (i*4+j)*4 + k ] = (i*4+j)*4 + k
        }
    }
}
print( a1 )

这是该程序的输出:


			[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]

尽管如果应用程序需要,使用列主序方式访问数组是可能的,但在以非语言默认方案的方式访问数组时,应该非常小心。许多优化编译器足够聪明,能够识别你是否以默认方式访问数组,并且在这种情况下,它们会生成更好的代码。事实上,迄今为止呈现的示例显式地以不常见的方式访问数组,以阻止编译器的优化器。请考虑以下 C 代码和启用优化后的 Visual C++输出:


			#include <stdlib.h>
int i, j, k;
int ThreeByThreeByThree[3][3][3];

int main( int argc, char **argv )
{
    // The important difference to note here is how
    // the loops are arranged with the indices i, j, and k
    // used so that i changes the slowest and k changes
    // most rapidly (corresponding to row-major ordering).

    for( i=0; i<3; ++i )
    {
        for( j=0; j<3; ++j )
        {
            for( k=0; k<3; ++k )
            {
                ThreeByThreeByThree[i][j][k] = 0;
            }
        }
    }
    return 0;
}

这是前面代码中for循环的 Visual C++汇编语言输出。特别地,注意编译器如何将三个循环替换为一个 80x86 的stosd指令:


			    push    edi
;
; The following code zeros out the 27 (3*3*3) elements
; of the ThreeByThreeByThree array.

    mov ecx, 27                 ; 0000001bH
    xor eax, eax
    mov edi, OFFSET FLAT:_ThreeByThreeByThree
    rep stosd

如果你重新排列索引,使得不再将零存储到连续的内存位置,Visual C++ 将不会编译成 stosd 指令。即使最终结果是将整个数组置零,编译器仍然认为 stosd 的语义不同。(想象一下程序中有两个线程,它们同时读写 ThreeByThreeByThree 数组元素;程序的行为可能会根据写入数组的顺序不同而有所不同。)

除了编译器语义之外,还有一些硬件方面的原因不建议更改默认的数组顺序。现代 CPU 的性能高度依赖于 CPU 缓存的有效性。由于缓存性能取决于缓存中数据的时间局部性和空间局部性,因此必须小心避免以破坏局部性的方式访问数据。特别是,以与存储顺序不一致的方式访问数组元素,会显著影响空间局部性,从而降低性能。这个故事的寓意是:除非你真的知道自己在做什么,否则应该采用编译器的数组组织方式。

8.1.6.7 在应用程序中提高数组访问效率

在你的应用程序中使用数组时,遵循以下规则:

  • 当一维数组能够满足需求时,切勿使用多维数组。这并不是建议你通过手动计算行优先(或列优先)索引将多维数组模拟成一维数组,而是如果你可以使用一维数组来表达算法而不是多维数组,应该选择一维数组。

  • 当必须在应用程序中使用多维数组时,尽量使用 2 的幂次方或至少是 4 的倍数作为数组的边界值。编译器可以比使用任意边界值的数组更有效地计算索引。

  • 在访问多维数组的元素时,尽量以支持顺序内存访问的方式进行。对于行优先顺序的数组,这意味着应从最右侧的索引开始最快地进行访问,而从最左侧的索引开始最慢地进行访问(对于列优先顺序的数组则相反)。

  • 如果你的编程语言支持对整个行或列(或其他大型数组片段)进行单次操作,那么应该使用这些功能,而不是通过嵌套循环访问单个元素。通常,循环的开销在每个访问的数组元素上摊销后,往往大于索引计算和元素访问的成本。当数组操作是循环中唯一的操作时,这一点尤为重要。

  • 在访问数组元素时,始终牢记空间局部性和时间局部性的问题。以随机(或非缓存友好)方式访问大量数组元素可能会导致缓存和虚拟内存子系统的抖动。^(3)

最后一条尤其重要。考虑以下 HLA 程序:


			program slow;
#include ( "stdlib.hhf" )
begin slow;

    // A dynamically allocated array accessed as follows:
    // array [12][1000][1000]

    malloc( 12_000_000 ); // Allocate 12,000,000 bytes
    mov( eax, esi );

    // Initialize each byte of the array to 0:

    for( mov( 0, ecx ); ecx < 1000; inc( ecx ) ) do

        for( mov( 0, edx ); edx < 1000; inc( edx ) ) do

            for( mov( 0, ebx ); ebx < 12; inc( ebx ) ) do

                // Compute the index into the array
                // as EBX*1_000_000 + EDX*1_000 + ECX

                intmul( 1_000_000, ebx, eax );
                intmul( 1_000, edx, edi );
                add( edi, eax );
                add( ecx, eax );
                mov( 0, (type byte [esi+eax]) );

            endfor;

        endfor;

    endfor;

end slow;

仅仅交换循环的顺序——使得 EBX 循环成为最外层循环,ECX 循环成为最内层循环——就可以使这个程序运行速度提高最多 10 倍。原因在于,当前写法下,程序以行主序的方式非顺序访问数组。频繁改变最右侧的索引(ECX)而最不频繁改变最左侧的索引(EBX),这意味着该程序将顺序访问内存。这使得缓存能够更好地工作,从而显著提升程序性能。

8.1.7 动态数组与静态数组

一些语言允许你声明在程序运行之前其大小未知的数组。这些数组非常有用,因为许多程序在接收到用户输入之前无法预测数据结构需要多少空间。例如,考虑一个程序,它将从磁盘逐行读取文本文件,并将内容存入字符串数组中。在程序实际读取文件并计算行数之前,它并不知道需要多少元素来存储字符串数组。在编写程序时,程序员无法知道数组需要多大。

支持这种数组的语言通常称其为动态数组。本节探讨与动态数组及其对应物静态数组相关的问题。一个好的起点是一些定义:

静态数组(或纯静态数组)

一个数组,其大小在编译期间程序就已知道。这意味着编译器/链接器/操作系统可以在程序执行之前为该数组分配存储空间。

伪静态数组

一个数组,其大小编译器知道,但程序直到运行时才实际分配存储空间。自动变量(即,函数或过程中的非静态局部变量)就是伪静态对象的一个很好的例子。编译器在编译程序时就知道它们的确切大小,但程序直到包含声明的函数或过程执行时,才会为它们分配内存存储空间。

伪动态数组

一个数组,其大小编译器无法在程序执行前确定。通常,程序会在运行时根据用户输入或其他计算来确定数组的大小。然而,一旦程序为伪动态数组分配了存储空间,该数组的大小将保持不变,直到程序终止或释放该数组的存储空间。特别地,不能在不释放整个数组存储空间的情况下更改伪动态数组的大小以添加或删除特定元素。

动态数组(或纯动态数组)

一个数组,其大小编译器无法在程序运行前确定,实际上,即使创建了数组,编译器也不能确保它的大小。程序可以随时改变动态数组的大小,添加或删除元素,而不会影响数组中已有的值(当然,如果删除了一些数组元素,它们的值会丢失)。

注意

静态数组和伪静态数组是本书之前讨论过的静态和自动对象的例子。请参阅第七章进行复习。

8.1.7.1 一维伪动态数组

大多数声称支持动态数组的语言实际上只支持伪动态数组。也就是说,当你首次创建数组时,可以指定数组的大小,但一旦指定了大小,就无法轻松更改数组的大小,除非先释放原始的数组存储空间。考虑以下 Visual Basic 语句:

dim dynamicArray[ i * 2 ]

假设 i 是你在执行此语句之前已赋值的整数变量,当 Visual Basic 遇到此语句时,它将创建一个包含 i×2 个元素的数组。在支持(伪)动态数组的语言中,数组声明通常是可执行语句,而在不支持动态数组的语言(如 C 和 Pascal)中,数组声明则不是可执行的。它们只是声明,编译器出于记账目的进行处理,但编译器不会为此生成机器代码。

虽然标准 C/C++ 不支持伪动态数组,但 GNU C/C++ 实现支持。因此,在 GNU C/C++ 中编写如下函数是合法的:


			void usesPDArray( int aSize )
{
    int array[ aSize ];
        .
        .
        .
} /* end of function usesPDArray */

当然,如果你在 GCC 中使用这个功能,你将只能使用 GCC 编译你的程序。^(4) 这就是为什么你不会看到很多 C/C++ 程序员在他们的程序中使用这种类型的代码。

如果你使用的是像 C/C++ 这样的语言,且该语言不支持伪动态数组,但提供了通用的内存分配函数,那么你可以轻松创建像一维伪动态数组一样的数组。特别是在像 C/C++ 这种不检查数组索引范围的语言中,这尤其容易实现。考虑以下代码:


			void usesPDArray( int aSize )
{
    int *array;

    array = (int *) malloc( aSize * sizeof( int ) );
        .
        .
        .
    free( array );

} /* end of function usesPDArray */

使用像 malloc() 这样的内存分配函数的一个问题是,必须记得在函数返回之前显式释放存储(如本例中的 free() 调用)。某些版本的 C 标准库包括一个 talloc() 函数,该函数在堆栈上分配动态存储。调用 talloc() 比调用 malloc()free() 更快,并且 talloc() 会在你返回时自动释放存储。

8.1.7.2 多维伪动态数组

如果你想创建多维伪动态数组,那是完全不同的问题了。对于一维伪动态数组,程序实际上不需要跟踪数组的边界,除非是为了验证数组索引是否合法。而对于多维数组,程序必须维护每个维度的上下边界的额外信息;程序需要这些大小信息来计算数组元素相对于数组索引列表的偏移量,正如你在本章之前看到的那样。因此,除了保持一个指针来保存数组基元素的地址外,使用伪动态数组的程序还必须跟踪数组的边界。^(5) 这一信息集合——基地址、维度数和每个维度的边界——被称为多维向量。在像 HLA、C/C++ 或 Pascal 这样的语言中,你通常会创建一个 structrecord 来维护多维向量(有关 struct 和记录的更多信息,请参见第十一章)。以下是你可能为一个二维整数数组创建的多维向量的示例,使用 HLA:


			type
    dopeVector2D :
        record
            ptrToArray :pointer to int32;
            bounds :uns32[2];
        endrecord;

以下是你将用来从用户读取二维数组的边界并使用此多维向量为伪动态数组分配存储的 HLA 代码:


			var
    pdArray :dopeVector2D;
        .
        .
        .
stdout.put( "Enter array dimension #1:" );
stdin.get( pdArray.bounds[0] );
stdout.put( "Enter array dimension #2:" );
stdin.get( pdArray.bounds[4] );  //Remember, '4' is a
                                 // byte offset into bounds.
// To allocate storage for the array, we must
// allocate bounds[0]*bounds[4]*4 bytes:

mov( pdArray.bounds[0], eax );

// bounds[0]*bounds[4] -> EAX

intmul( pdArray.bounds[4], eax );

// EAX := EAX * 4 (4=size of int32).

shl( 2, eax );

// Allocate the bytes for the array.

malloc( eax );

// Save away base address.

mov( eax, pdArray.ptrToArray );

这个示例强调了程序必须将数组的大小计算为数组维度的乘积与元素大小的乘积。在处理静态数组时,编译器可以在编译时计算这个乘积。然而,在处理动态数组时,编译器必须在运行时生成机器指令来计算这个乘积,这意味着你的程序会比使用静态数组时稍大且稍慢。

如果某种语言不直接支持伪动态数组,你将不得不使用行主序函数(或类似的函数)将索引列表转换为单一偏移量。这在高级语言和汇编语言中都是如此。考虑以下 C++ 示例,它使用行主序方式访问伪动态数组的一个元素:


			typedef struct
{
    int *ptrtoArray;
    int bounds[2];
} dopeVector2D;

dopeVector2D pdArray;
        .
        .
        .
    // Allocate storage for the pseudo-dynamic array:

    cout << "Enter array dimension #1:";
    cin >> pdArray.bounds[0];
    cout << "Enter array dimension #2:" ;
    cin >> pdArray.bounds[1];
    pdArray.ptrtoArray =
        new int[ pdArray.bounds[0] * pdArray.bounds[1] ];
        .
        .
        .
    // Set all the elements of this dynamic array to
    // successive integer values:

    k = 0;
    for( i=0; i < pdArray.bounds[0]; ++i )
    {
        for( j=0; j < pdArray.bounds[1]; ++j )
        {
            // Use row-major ordering to access
            // element [i][j]:

            *(pdArray.ptrtoArray + i*pdArray.bounds[1] + j) = k;
            ++k;
        }
    }

至于一维伪动态数组,内存分配和回收可能比实际的数组访问更昂贵——尤其是当你分配和回收许多小数组时。

多维动态数组的一个大问题是编译器在编译时无法知道数组的边界,因此它无法生成像伪静态数组和静态数组那样高效的数组访问代码。举个例子,考虑以下的 C 代码:


			#include <stdlib.h>

int main( int argc, char **argv )
{

    // Allocate storage for a 3x3x3 dynamic array:

    int *iptr = (int*) malloc( 3*3*3 *4 );
    int depthIndex;
    int rowIndex;
    int colIndex;

    // A pseudo-static 3x3x3 array for comparison:

    int ssArray[3][3][3];

    // The following nested for loops initialize all
    // the elements of the dynamic 3x3x3 array with
    // zeros:

    for( depthIndex=0; depthIndex<3; ++depthIndex )
    {
        for( rowIndex=0; rowIndex<3; ++rowIndex )
        {
            for( colIndex=0; colIndex<3; ++colIndex )
            {
                iptr
                [
                    // Row-major order computation:

                      ((depthIndex*3) + rowIndex)*3
                    + colIndex

                ] = 0;
            }
        }
    }

    // The following three nested loops are comparable
    // to the above, but they initialize the elements
    // of a pseudo-static array. Because the compiler
    // knows the array bounds at compile time, it can
    // generate better code for this sequence.

    for( depthIndex=0; depthIndex<3; ++depthIndex )
    {
        for( rowIndex=0; rowIndex<3; ++rowIndex )
        {
            for( colIndex=0; colIndex<3; ++colIndex )
            {
                ssArray[depthIndex][rowIndex][colIndex] = 0;
            }
        }
    }

    return 0;
}

以下是 GCC 为该 C 程序生成的 PowerPC 代码的相关部分(手动注释)。需要注意的重要一点是,动态数组代码被迫使用昂贵的乘法指令,而伪静态数组代码则不需要这条指令。


			    .section __TEXT,__text,regular,pure_instructions

_main:

// Allocate storage for local variables
// (192 bytes, includes the ssArray,
// loop control variables, other stuff,
// and padding to 64 bytes):

    mflr r0
    stw r0,8(r1)
    stwu r1,-192(r1)

// Allocate 108 bytes of storage for
// the 3x3x3 array of 4-byte ints.
// This call to  malloc leaves the
// pointer to the array in R3.
    li r3,108
    bl L_malloc$stub

    li r8,0     // R8= depthIndex
    li r0,0

    // R10 counts off the number of
    // elements in rows we've processed:

    li r10,0

// Top of the outermost for loop

L16:
    // Compute the number of bytes
    // from the beginning of the
    // array to the start of the
    // row we are about to process.
    // Each row contains 12 bytes and
    // R10 contains the number of rows
    // processed so far. The product
    // of 12 by R10 gives us the number
    // of bytes to the start of the
    // current row. This value is put
    // into R9:

    mulli r9,r10,12

    li r11,0    // R11 = rowIndex

// Top of the middle for loop

L15:
    li r6,3     // R6/CTR = colIndex

    // R3 is the base address of the array.
    // R9 is the index to the start of the
    // current row, computed by the MULLI
    // instruction, above. R2 will now
    // contain the base address of the
    // current row in the array.

    add r2,r9,r3

    // CTR = 3

    mtctr r6

    // Repeat the following loop
    // once for each element in
    // the current row of the array:

L45:
    stw r0,0(r2)    // Zero out current element
    addi r2,r2,4    // Move on to next element
    bdnz L45        // Repeat loop CTR times

    addi r11,r11,1  // Bump up RowIndex by 1
    addi r9,r9,12   // Index of next row in array
    cmpwi cr7,r11,2 // Repeat for RowIndex=0..2
    ble+ cr7,L15

    addi r8,r8,1    // Bump up depthIndex by 1
    addi r10,r10,3  // Bump up element cnt by 3
    cmpwi cr7,r8,2  // Repeat for depthIndex=0..2
    ble+ cr7,L16

/////////////////////////////////////////////////////
//
// Here's the code that initializes the pseudo-static
// array:

    li r8,0         // DepthIndex = 0
    addi r10,r1,64  // Compute base address of ssArray
    li r0,0
    li r7,0         // R7 is index to current row
L31:
    li r11,0        // RowIndex = 0
    slwi r9,r7,2    // Convert row/int index to
                    // row/byte index (int_index*4)
L30:
    li r6,3         // # iterations for colIndex
    add r2,r9,r10   // Base+row_index = row address
    mtctr r6        // CTR = 3

// Repeat innermost loop three times:

L44:
    stw r0,0(r2)    // Zero out current element
    addi r2,r2,4    // Bump up to next element
    bdnz L44        // Repeat CTR times

    addi r11,r11,1  // Bump up RowIndex by 1
    addi r9,r9,12   // R9=Adrs of start of next row
    cmpwi cr7,r11,2 // Repeat until RowIndex >=3
    ble+ cr7,L30

    addi r8,r8,1    // Bump up depthIndex by 1
    addi r7,r7,9    // Index of next depth in array
    cmpwi cr7,r8,2
    ble+ cr7,L31

    lwz r0,200(r1)
    li r3,0
    addi r1,r1,192
    mtlr r0
    blr

不同的编译器和不同的优化级别以不同的方式处理动态数组和伪静态数组的访问。有些编译器为这两种序列生成相同的代码,但很多编译器并不如此。底线是,多维动态数组访问从来不比伪静态多维数组访问更快,有时甚至更慢。

8.1.7.3 纯动态数组

纯动态数组的管理更加困难。你很少能在像 APL、SNOBOL4、LISP 和 Prolog 这样的高级语言之外看到它们。唯一的显著例外是 Swift,它的数组就是纯动态数组。大多数支持纯动态数组的语言不会强制要求你显式声明或分配数组的存储空间。相反,你只需使用数组中的元素,如果某个元素当前不在数组中,语言会自动为你创建它。那么,如果你当前有一个包含元素 09 的数组,决定使用元素 100 会发生什么呢?嗯,结果依赖于语言。一些支持纯动态数组的语言会自动创建数组元素 10..100,并将元素 10..99 初始化为 0(或其他默认值)。其他语言可能只分配元素 100,并跟踪其他元素尚未出现在数组中的事实。不管怎样,每次访问数组时所需的额外记录是相当昂贵的。这就是为什么支持纯动态数组的语言不太流行——它们通常执行程序较慢。

如果你使用的是支持动态数组的语言,请记住该语言中与数组访问相关的开销。如果你使用的语言不支持动态数组,但支持内存分配/释放(例如,C/C++、Java 或汇编语言),你可以自行实现动态数组。你将深刻意识到使用此类数组的开销,因为你可能需要编写所有操作其元素的代码,尽管这并不完全是坏事。如果你使用的是 C++,你甚至可以重载数组索引运算符([ ])来隐藏动态数组元素访问的复杂性。不过,一般来说,真正需要动态数组语义的程序员通常会选择直接支持动态数组的语言。同样,如果你选择了这种方式,记得留意其开销。

8.2 更多信息

Duntemann, Jeff. 汇编语言一步一步. 第 3 版。印第安纳波利斯:Wiley,2009 年。

Hyde, Randall. 汇编语言艺术. 第 2 版。旧金山:No Starch Press,2010 年。

Knuth, Donald. 计算机程序设计艺术,第一卷:基础算法,第三版。波士顿:Addison-Wesley Professional,1997 年。

第九章:指针数据类型

image

指针是 goto 语句的数据类型等价物。若使用不当,它们会将一个稳健高效的程序变成一个充满 bug 和效率低下的垃圾堆。然而,与 goto 语句不同,指针在许多常见编程语言中难以避免。像 Dijkstra 的《Go To 语句不良影响》一文那样的“指针有害”文章,在学术期刊中是不存在的。[1] 许多语言,如 Java 和 Swift,试图限制指针,但一些流行语言仍然使用它们,因此优秀的程序员需要能够处理它们。因此,本章将讨论:

  • 指针的内存表示

  • 高级语言如何实现指针

  • 动态内存分配及其与指针的关系

  • 指针运算

  • 内存分配器如何工作

  • 垃圾回收

  • 常见的指针问题

通过理解指针的低级实现和使用,你将能够编写更高效、更安全、更易读的高级代码。本章将提供你需要的所有信息,帮助你正确使用指针,并避免通常与指针相关的问题。

9.1 指针的定义

指针只是一个变量,其值指向其他对象。像 Pascal 和 C/C++ 这样的高级语言通过抽象层隐藏了指针的简单性。HLL 程序员通常依赖于语言提供的高度抽象,因为他们不想了解幕后发生了什么。他们只需要一个“黑箱”,能够生成可预测的结果。然而,在指针的情况下,这种抽象可能过于有效;许多程序员觉得指针既令人畏惧又晦涩难懂。好吧,别怕!指针其实很容易处理。

为了理解指针是如何工作的,我将使用数组数据类型作为例子。考虑以下 Pascal 中的数组声明:

M: array [0..1023] of integer;

即使你不知道 Pascal,这里的概念也很容易理解。M 是一个包含 1,024 个整数的数组,索引从 M[0]M[1023]。每个数组元素都可以存储一个独立的整数值。换句话说,这个数组给你提供了 1,024 个不同的整数变量,你可以通过数组索引(变量在数组中的顺序位置)来访问它们,而不是通过名称。

语句 M[0] := 100; 将值 100 存储到数组 M 的第一个元素中。现在考虑以下两个语句:


			i := 0; (* assume "i" is an integer variable *)
M [i] := 100;

这两个语句的作用与 M[0] := 100; 相同。事实上,你可以使用任何产生 0..1023 范围内的整数表达式作为该数组的索引。以下语句仍然执行与之前语句相同的操作:


			i := 5;         (* assume all variables are integers*)
j := 10;
k := 50;
m [i*j-k] := 100;

但现在看看以下语句:


			M [1] := 0;
M [ M [1] ] := 100;

初看之下,这些语句可能会让人困惑;然而,它们执行的操作与前面的例子相同。第一条语句将 0 存储到数组元素 M[1] 中。第二条语句取出 M[1] 的值,即 0,并利用该值来决定将 100 存储到哪里。

如果你认为这个例子是合理的——或许有点奇怪,但仍然可用——那么你就不会对指针感到困惑,因为M[1]是一个指针!好吧,严格来说不是,但如果你将 M 改为“内存”,并将该数组的每个元素视为单独的内存位置,那么它就符合指针的定义——即,一个内存变量,其值是某个其他内存对象的地址。

9.2 高级语言中的指针实现

尽管大多数语言使用内存地址实现指针,但指针实际上是内存地址的抽象。因此,一种语言可以通过任何机制来定义指针,这种机制将指针的值映射到内存中某个对象的地址。例如,一些 Pascal 的实现使用相对于固定内存地址的偏移量作为指针值。一些语言(包括像 Lisp 这样的动态语言)可能实际上使用双级间接寻址来实现指针;也就是说,指针对象包含某个内存变量的地址,而该内存变量的值是要访问对象的地址。这种方法看起来有点复杂,但它在复杂的内存管理系统中提供了某些优势,使得重用内存块变得更容易和高效。然而,为了简化起见,我们假设,如前所定义,指针是一个其值为内存中某个对象地址的变量。这对于许多你可能遇到的高性能高级语言(如 C、C++ 和 Delphi)来说是一个安全的假设。

你可以通过两条 80x86 机器指令间接访问对象,具体如下:


			mov( PointerVariable, ebx ); // Load pointer variable into a register.
mov( [ebx], eax );           // Use register-indirect mode to access data.

现在考虑前面描述的双级间接指针实现。通过双级间接访问数据比直接指针实现效率低,因为它需要额外的一条机器指令来从内存中取出数据。这在像 C/C++ 或 Pascal 这样的高级语言中并不明显,在这些语言中使用双级间接是显式的:


			i = **cDblPtr;
i := pDblPtr^^;

这在语法上类似于单级间接寻址。然而,在汇编语言中,你将看到额外的工作:


			mov( hDblPtr, ebx );  // Get the pointer to a pointer
mov( [ebx], ebx );    // Get the pointer to the value
mov( [ebx], eax );    // Get the value

与之前的两条汇编指令使用单级间接寻址访问对象相比,双级间接寻址需要比单级间接多 50% 的代码(并且需要两倍的内存访问速度),你可以理解为什么许多语言采用单级间接来实现指针。为了验证这一点,考虑以下 C 代码在不同编译器下生成的机器代码:


			static int i;
static int j;
static int *cSnglPtr;
static int **cDblPtr;

int main( void )
{
        .
        .
        .
    j = *cSnglPtr;
    i = **cDblPtr;
        .
        .
        .

下面是 PowerPC 处理器的 GCC 输出:


			; j = *cSnglPtr;

        addis r11,r31,ha16(_j-L1$pb)
        la r11,lo16(_j-L1$pb)(r11)
        addis r9,r31,ha16(_cSnglPtr-L1$pb)
        la r9,lo16(_cSnglPtr-L1$pb)(r9)
        lwz r9,0(r9)  // Get the ptr into register R9
        lwz r0,0(r9)  // Get the data at the pointer
        stw r0,0(r11) // Store into j

; i = **cDblPtr;
;
; Begin by getting the address of cDblPtr into R9:

        addis r11,r31,ha16(_i-L1$pb)
        la r11,lo16(_i-L1$pb)(r11)
        addis r9,r31,ha16(_cDblPtr-L1$pb)
        la r9,lo16(_cDblPtr-L1$pb)(r9)

        lwz r9,0(r9)  // Get the dbl ptr into R9
        lwz r9,0(r9)  // Get the ptr into R9
        lwz r0,0(r9)  // Get the value into R9
        stw r0,0(r11) // Store value into i

如你在这个 PowerPC 示例中看到的,使用双重间接寻址获取值比使用单重间接寻址多一条指令。当然,这里的总指令数相当大,因此这条额外的指令对执行时间的影响没有 80x86 中那么大,因为 80x86 中涉及的指令较少。考虑以下为 32 位 80x86 生成的 GCC 代码输出:


			; j = *cSnglPtr;

        movl    cSnglPtr, %eax
        movl    (%eax), %eax
        movl    %eax, j

; i = **cDblPtr;

        movl    cDblPtr, %eax
        movl    (%eax), %eax
        movl    (%eax), %eax
        movl    %eax, i

正如我们在 PowerPC 代码中看到的,双重间接寻址需要额外的机器指令,因此使用双重间接寻址的程序将变得更大且更慢。

请注意,PowerPC 指令序列的长度是 80x86 指令序列的两倍。^(2) 一种积极的看法是,双重间接寻址对 PowerPC 代码的执行时间的影响比对 80x86 代码的影响小。也就是说,额外的指令在 PowerPC 代码中只占总指令的 13%,而在 80x86 代码中则占总指令的 25%。^(3) 这个简短的例子应该能表明,执行时间和代码空间不是处理器独立的。糟糕的编码实践(例如在不需要时使用双重间接寻址)可能对某些处理器的影响更大。

9.3 指针和动态内存分配

指针通常引用匿名变量,这些匿名变量是通过堆上的内存分配/释放函数(如 malloc()/free()new()/dispose()、以及 new()/delete()(C++17 中的 std::make_unique))进行分配的。你在堆上分配的对象被称为匿名变量,因为你通过它们的地址而不是名称来引用它们。虽然指针变量可能有一个名称,但该名称适用于指针的数据(一个地址),而不是该地址所引用的对象。

注意

正如第七章所解释的,堆是内存中为动态存储分配保留的区域。

动态语言以透明、自动的方式处理内存分配和释放操作。应用程序仅使用动态数据,并将内存分配的任务交给运行时系统,根据需要分配内存,并在内存不再需要时将存储空间重新用于其他目的。由于无需显式地为指针变量分配和释放内存,用动态语言(如 AWK 或 Perl)编写的应用程序通常更容易编写,而且往往包含更少的错误。但这也带来了效率上的代价,因为它们通常比用其他语言编写的程序运行得要慢。相反,传统语言(如 C/C++)要求程序员显式地管理内存,通常能够生成更高效的应用程序,尽管由于内存管理代码的额外复杂性,它更容易出现缺陷。

9.4 指针操作和指针算术

大多数提供指针数据类型的高级语言(HLL)允许你将地址分配给指针变量,比较指针值的相等性或不等性,并通过指针间接引用对象。一些语言还允许进行其他操作,正如你在本节中将看到的那样。

许多编程语言允许你对指针进行有限的算术操作。至少,这些语言允许你向指针添加一个整数常量,或从指针中减去一个整数。为了理解这两种算术操作的目的,请回顾一下 C 标准库中 malloc() 函数的语法:


			ptrVar = malloc( bytes_to_allocate );

你传递给 malloc() 的参数指定了要分配的存储字节数。一个好的 C 程序员通常会提供像 sizeof(int) 这样的表达式作为此参数。sizeof() 函数返回其单一参数所需的字节数。因此,sizeof(int) 告诉 malloc() 至少分配足够存储一个 int 变量的空间。现在考虑以下对 malloc() 的调用:

ptrVar = malloc( sizeof( int ) * 8 ); // An array of 8 integers

如果一个整数的大小是 4 字节,那么对 malloc() 的调用将分配 32 字节的存储空间,位于内存中的连续地址(参见图 9-1)。

Image

图 9-1:通过 malloc(sizeof(int) * 8) 分配内存

malloc() 返回的指针包含此集合中第一个整数的地址,因此 C 程序只能直接访问这八个整数中的第一个。要访问其他七个整数的单独地址,你需要在该 基准 地址上加上一个整数偏移量。在支持字节可寻址内存的机器(如 80x86)上,内存中每个连续整数的地址是前一个整数地址加上整数大小。例如,如果对 C 标准库中的 malloc() 函数的调用返回内存地址 $0300_1000,那么 malloc() 分配的八个整数将位于表 9-1 中显示的内存地址。

表 9-1:为基地址 $0300_1000 分配的整数地址

整数 内存地址
第一 $0300_1000..$0300_1003
第二 $0300_1004..$0300..1007
第三 $0300_1008..$0300_100b
第四 $0300_100c..$0300_100f
第五 $0300_1010..$0300_1013
第六 $0300_1014..$0300..1017
第七 $0300_1018..$0300_101b
第八 $0300_101c..$0300_101f

9.4.1 将整数添加到指针

由于前一部分中的八个整数相隔恰好 4 字节,你可以通过将 4 加到第一个整数的地址来获得第二个整数的地址。同样,第三个整数的地址是第二个整数的地址加上 4 字节,依此类推。在汇编语言中,你可以使用如下代码访问这八个整数:


			// malloc returns storage for eight
//  int32 objects in EAX.

malloc( @size( int32 ) * 8 );

mov( 0, ecx );
mov( ecx, [eax] );     // Zero out the 32 bytes
mov( ecx, [eax+4] );   // (4 bytes at a time).
mov( ecx, [eax+8] );
mov( ecx, [eax+12] );
mov( ecx, [eax+16] );
mov( ecx, [eax+20] );
mov( ecx, [eax+24] );
mov( ecx, [eax+28] );

请注意使用 80x86 索引寻址模式来访问 malloc() 分配的八个整数。EAX 寄存器保持了这段代码分配的八个整数的基地址(第一个地址),而 mov() 指令的寻址模式中的常数表示相对于这个基地址的特定整数的偏移量。

大多数 CPU 使用字节地址来表示内存对象。因此,当一个程序在内存中分配多个 n 字节对象时,这些对象不会从连续的内存地址开始;相反,它们会出现在相隔 n 字节的内存地址上。然而,某些机器不允许程序访问任意地址的内存;它们要求程序只能在对齐边界上访问数据,这些边界是字、双字甚至四字的倍数。任何试图在其他边界访问内存的行为都将引发异常,可能会终止应用程序。如果高级语言(HLL)支持指针运算,它必须考虑到这一点,并提供一个跨不同 CPU 架构可移植的通用指针运算方案。当 HLL 在指针上加上一个整数偏移时,最常见的解决方案是将该偏移量乘以指针所引用对象的大小。也就是说,如果你有一个指向内存中 16 字节对象的指针 p,那么 p + 1 指向比 p 指向的位置多 16 字节。同样,p + 2 指向比 p 指向的地址多 32 字节。只要数据对象的大小是所需对齐大小的倍数(编译器可以通过添加填充字节来强制执行,如果需要),这个方案就能避免在需要对齐数据访问的架构上出现问题。举个例子,考虑以下 C/C++ 代码:


			int *intPtr;
        .
        .
        .
    // Allocate storage for eight integers:

    intPtr = malloc( sizeof( int ) * 8 );

    // Initialize each of these integer values:

    *(intPtr+0) = 0;
    *(intPtr+1) = 1;
    *(intPtr+2) = 2;
    *(intPtr+3) = 3;
    *(intPtr+4) = 4;
    *(intPtr+5) = 5;
    *(intPtr+6) = 6;
    *(intPtr+7) = 7;

这个例子演示了 C/C++ 如何使用指针运算来指定相对于基指针地址的整数大小偏移。

需要注意的是,加法运算符只在指针和整数值之间才有意义。例如,在 C/C++ 中,你可以使用像 *(p + i) 这样的表达式间接访问内存中的对象(其中 p 是指向某个对象的指针,i 是整数值)。将两个指针相加是没有意义的。同样,将其他数据类型与指针相加也是不合理的——例如,将一个浮动点值加到指针上。(参考某个基地址加上 1.5612 是什么意思呢?)涉及字符串、字符和其他数据类型的指针运算也没有多大意义。整数(有符号和无符号)是唯一合理的可以加到指针上的值。

另一方面,你不仅可以将整数加到指针上,还可以将指针加到整数上,结果仍然是一个指针(p + ii + p 都是合法的)。这是因为加法是 可交换的——操作数的顺序不会影响结果。

9.4.2 从指针中减去一个整数

从指针中减去一个整数会引用紧接在指针所指向的基地址之前的内存位置。然而,减法不是交换律的,且将指针从整数中减去并不是一个合法操作(p - i 是合法的,但 i - p 不是)。

在 C/C++ 中,*(p - i) 访问的是紧接在 p 所指向的对象之前的第 i 个对象。在 80x86 汇编语言中,和许多处理器上的汇编语言一样,你还可以在使用索引寻址模式时指定一个负常量偏移量。例如:

mov( [ebx-4], eax );

请记住,80x86 汇编语言使用的是字节偏移量,而不是对象偏移量(如同 C/C++ 中使用的那样)。因此,这条语句将内存中紧接着 EBX 所指向的内存地址前面的双字加载到 EAX 中。

9.4.3 从指针中减去另一个指针

与加法不同,减去一个指针变量的值是有意义的。考虑以下 C/C++ 代码,它通过一个字符字符串查找紧随第一个 a 字符之后的第一个 e 字符(例如,你可以使用这种计算的结果来提取子字符串):


			int distance;
char *aPtr;
char *ePtr;
    .
    .
    .
aPtr = someString;  // Get ptr to start of string in aPtr.
// While we're not at the end of the string
// and the current char isn't 'a':

while( *aPtr != '\0' && *aPtr != 'a' )
{
    // Move on to the next character pointed at by aPtr.

    aPtr = aPtr + 1;
}

// while we're not at the end of the string
// and the current character isn't 'e'
//
// Start at the 'a' char (or end of string if no 'a').

ePtr = aPtr;
while( *ePtr != '\0' && *ePtr != 'e' )
{
    // Move on to the next character pointed at by aPtr.
    ePtr = ePtr + 1;
}

// Now compute the number of characters between
// the 'a' and the 'e' (counting the 'a' but not
// counting the 'e'):

distance = (ePtr - aPtr);

将一个指针从另一个指针中减去,得到的是它们之间存在的数据对象的数量(在本例中,ePtraPtr 指向的是字符,因此这次减法得到的是两个指针之间的字符数,或者如果是 1 字节字符的话,则是字节数)。

只有当两个指针都引用内存中相同的数据结构(例如数组、字符串或记录)时,指针值的减法才是有意义的。虽然汇编语言允许你减去指向内存中完全不同对象的两个指针,但它们的差值可能几乎没有什么意义。

在 C/C++ 中进行指针减法时,两个指针的基类型必须相同(即,两个指针必须包含类型相同的两个对象的地址)。这个限制存在的原因是,在 C/C++ 中,指针减法计算的是两个指针之间的对象数量,而不是字节数。计算内存中的字节与双字之间的对象数量是没有意义的。结果既不是字节数也不是双字数。

如果左侧指针操作数的内存地址低于右侧指针操作数的内存地址,则两个指针的减法可能返回一个负数。根据你使用的语言及其实现,如果你只关心两个指针之间的距离,而不在乎哪个指针包含较大的地址,可能需要取结果的绝对值。

9.4.4 比较指针

比较是另一类对指针有意义的操作。几乎所有支持指针的语言都允许你比较两个指针,以查看它们是否相等。指针比较告诉你这些指针是否引用了内存中的同一对象。一些语言(如汇编和 C/C++)还允许你比较两个指针,查看一个指针是否小于或大于另一个指针。像减去两个指针一样,比较两个指针只有在它们具有相同的基类型并指向相同的数据结构时才有意义。如果一个指针小于另一个指针,这意味着该指针引用的数据结构中的某个对象出现在第二个指针包含的对象之前。大于比较的反之亦然。以下是一个 C 语言示例,演示了指针比较:


			#include <stdio.h>

int iArray[256];
int *ltPtr;
int *gtPtr;

int main( int argc, char **argv )
{
    int lt;
    int gt;

    // Put the address of the "argc" element
    // of iArray into ltPtr. This is done
    // so that the optimizer doesn't completely
    // eliminate the following code (as would
    // happen if we just specified a constant
    // index):

    ltPtr = &iArray[argc];

    // Put the address of the eighth array
    // element into gtPtr.

    gtPtr = &iArray[7];

    // Assuming you don't type seven or more
    // command-line parameters when running
    // this program, the following two
    // assignments should set lt and gt to 1.

    lt = ltPtr < gtPtr;
    gt = gtPtr > ltPtr;
    printf( "lt:%d, gt:%d\n", lt, gt );
    return 0;
}

在 (x86-64) 机器语言层面,地址仅仅是 64 位的量,因此机器代码可以将这些指针当作 64 位整数值进行比较。以下是 Visual C++ 为此示例生成的 x86-64 汇编代码:


			;
; Grab ARGC (passed to the program in rcx), use
; it as an index into iArray (4 bytes per element,
; hence the "*4" in the scaled-index addressing mode),
; compute the address of this array element (using the
; LEA -- load effective address -- instruction), and
; store the resulting address into ltPtr:
; Line 24
        movsxd  rax, ecx ; rax=rcx
; Line 37
        xor     edx, edx ;edx = 0
        mov     r8d, edx ;Initialize boolean result w/false
        lea     rcx, OFFSET FLAT:iArray ;rcx = base address of iArray
        lea     rcx, QWORD PTR [rcx+rax*4] ;rcx = &iArray[argc]

        lea     rax, OFFSET FLAT:iArray+28 ;rax=&iArray[7] (7*4 = 28)
        mov     QWORD PTR ltPtr, rcx ;ltPtr = &iArray[argc]
        cmp     rax, rcx ;carry flag = !(ltPtr < gtPtr)
        mov     QWORD PTR gtPtr, rax ;gtPtr = &iArray[7]
        seta    r8b ;r8b = ltPtr < gtPtr (which is !gtPtr > ltPtr)
        cmp     rcx, rax ;Carry flag = !(gtPtr > ltPtr)
; Line 38
        lea     rcx, OFFSET FLAT:??_C@_0O@KJKFINNE@lt?3?$CFd?0?5gt?3?$CFd?6?$AA@
        setb    dl ;dl = !(ltPtr < gtPtr ) (which is !(gtPtr > ltPtr)
        call    printf
;

除了比较两个地址后计算 true1)或 false0)的技巧之外,这段代码是一个非常直接的机器代码编译。

9.4.5 使用逻辑与/或操作与指针

在字节可寻址的机器上,使用与(AND)操作符对地址与位串值进行逻辑与运算是有意义的,因为掩码低位(LO)位是将地址对齐到 2 的幂次方边界的一种简单方法。例如,如果 32 位的 80x86 EBX 寄存器包含一个任意地址,以下汇编语言语句将指针 EBX 向下舍入到一个 4 字节对齐的地址:

and( $FFFF_FFFC, ebx );

这种操作在确保内存以良好的内存边界进行访问时非常有用。例如,假设你有一个内存分配函数,它返回一个指向在任意字节边界开始的内存块的指针。为了确保指针所指向的数据结构从双字(dword)边界开始,你可以使用类似下面的汇编代码:


			// # of bytes to allocate

mov( nBytes, eax );

// Provide a "cushion" for rounding.

add( 3, eax );

// Allocate the memory (returns pointer in EAX).

malloc( eax );

// Round up to the next higher dword, if not dword-aligned.

add( 3, eax );

// Make the address a multiple of 4.

and( $ffff_fffc, eax );

这段代码在调用 malloc() 时额外分配了 3 个字节,以便它可以将 0、1、2 或 3 加到 malloc() 返回的地址上,从而将对象对齐到一个 dword 地址上。从 malloc() 返回时,代码将地址加 3,如果它本来不是 4 的倍数,那么地址将跨越到下一个 dword 边界。使用 AND 指令可以将地址还原到之前的 dword 边界(无论是下一个 dword 边界,还是如果它已经是 dword 对齐的原始地址)。

9.4.6 使用其他操作与指针

除了加法、减法、比较操作,以及可能的与操作(AND)或或操作(OR)外,极少有算术操作对于指针操作数是有意义的。将指针乘以某个整数值(或另一个指针)是什么意思?指针的除法是什么意思?将指针左移一位是什么意思?你可以为这些操作编造一些定义,但考虑到原始的算术定义,这些操作对于指针来说根本不合理。

一些语言(包括 C/C++ 和 Pascal)限制了其他指针操作。有几个好的理由限制程序员对指针的操作,例如:

  • 涉及指针的代码通常很难优化。通过限制指针操作的数量,编译器可以对代码做出一些假设,这是在没有限制的情况下无法做到的。这使得编译器(理论上)能够生成更好的机器代码。

  • 包含指针操作的代码更容易出现缺陷。限制程序员在这一领域的选择有助于防止指针滥用,从而导致更健壮的代码。

注意

“常见指针问题”一节在第 286 页中描述了这些错误中最严重的,并提供了避免它们的方法。

  • 一些指针操作——尤其是某些算术操作——在不同的 CPU 架构之间并不具有可移植性。例如,在一些分段架构(如原始的 16 位 80x86 架构)上,减去两个指针的值可能无法产生预期的结果。

  • 正确使用指针有助于创建高效的程序,但反过来也成立:不当使用指针会破坏程序的效率。通过限制语言支持的指针操作数量,可以防止由于过度使用指针而导致的代码低效。

限制指针操作的这些理由的主要问题在于,大多数理由是为了保护程序员不犯错误,事实上,许多程序员(尤其是初学者)从这些限制所强制执行的纪律中受益。然而,对于那些谨慎使用指针、不滥用指针的程序员来说,这些限制可能会消除一些编写优秀代码的机会。因此,像 C/C++ 和汇编语言这样提供丰富指针操作集的语言,深受那些更喜欢对指针使用拥有完全控制的高级程序员欢迎。

9.5 一个简单的内存分配器示例

为了演示使用动态分配的内存和指向它的指针的性能和内存成本,本节展示了一个简单的内存分配/释放系统。通过考虑与内存分配和释放相关的操作,你将更加意识到它们的成本,并更好地了解如何以合适的方式使用它们。

一个极其简单(且快速)的内存分配方案是维护一个指向堆内存区域的单一变量。每当有内存分配请求时,系统就会复制这个堆指针并返回给应用程序。堆管理例程会将内存请求的大小加到指针变量中的地址上,并验证内存请求是否会尝试使用超过堆可用内存的空间。(一些内存管理器在内存请求过大时会返回一个错误指示,如NULL指针;其他则会抛出异常。)这个简单的内存管理方案的问题在于它浪费内存,因为没有垃圾回收机制让应用程序能够释放内存以便后续重用。垃圾回收是堆管理系统的一个主要目的。

唯一的问题是,支持垃圾回收需要一些额外开销。内存管理代码将需要更复杂,执行时间更长,并且需要一些额外的内存来维护堆管理系统使用的内部数据结构。考虑一个支持垃圾回收的堆管理器的简单实现,该实现适用于 32 位系统。这个简单的系统维护一个(链式)空闲内存块的列表。列表中的每个空闲内存块需要两个dword值:一个指定空闲块的大小,另一个包含下一个空闲块的地址(即链接);参见图 9-2。

Image

图 9-2:使用空闲内存块列表的堆管理

系统用一个NULL链接指针初始化堆,并且大小字段包含堆中所有空闲空间的大小。当有内存分配请求时,堆管理器会在列表中搜索,找到一个足够大的空闲块来满足请求。这个搜索过程是堆管理器的一个特征。一些常见的搜索算法包括首次适应搜索和最佳适应搜索。首次适应搜索顾名思义,会扫描块列表,直到找到第一个足够大的内存块来满足分配请求。最佳适应搜索则扫描整个列表,找到一个最小的足够大的块来满足请求。最佳适应算法的优点是,它比首次适应算法更能有效保存较大的块,因此在后续有更大的分配请求时,系统仍然能够满足这些请求。而首次适应算法则是抓住第一个合适大小的块,哪怕有一个较小的块也能满足需求,这可能会限制系统处理未来大内存请求的能力。

话虽如此,首次适配算法确实比最佳适配算法有一些优势。最明显的一点是,它通常更快。最佳适配算法必须扫描空闲块列表中的每个块,以找到一个足够大的最小块来满足分配请求(当然,如果途中找到一个完全合适的块,它就会停止)。而首次适配算法则可以在找到一个足够大的块满足请求后立即停止。

另一个首次适配算法的优点是,它往往较少遭受一种叫做外部碎片的退化状况。碎片化发生在一系列分配和释放请求之后。

请记住,当堆管理器满足内存分配请求时,它通常会创建两个内存块:一个是用于请求的在用块,另一个是包含原始块剩余字节的空闲块(假设请求的大小与块大小不完全匹配)。经过一段时间后,最佳适配算法可能会产生许多剩余的内存块,这些块太小,无法满足一般的内存请求,从而变得实际上不可用。随着这些小碎片在堆中积累,它们可能会消耗相当多的内存。这可能导致即使堆中有足够的空闲内存(分布在堆的各个位置),堆也没有足够大的块来满足内存分配请求。请参见图 9-3 中的示例。

Image

图 9-3:内存碎片化

除了首次适配(first-fit)和最佳适配(best-fit)搜索算法,还有其他内存分配策略。它们中的一些执行速度更快,一些内存开销更少,一些容易理解(也有一些非常复杂),一些产生更少的碎片,而有些则可以组合并使用不连续的空闲内存块。内存/堆管理是计算机科学中研究较多的课题之一,关于不同方案的优缺点有大量文献解释。如需了解更多内存分配策略的信息,请参考一本关于操作系统设计的好书。

9.6 垃圾回收

内存分配只是故事的一半。如前所述,堆管理器还需要提供一个调用,允许应用程序释放其不再需要的内存,以供将来重用——这一过程称为垃圾回收。例如,在 C 和 HLA 中,应用程序通过调用free()函数来完成这一操作。乍一看,free()似乎是一个非常简单的函数。它要做的只是将先前分配的、现在未使用的内存块附加到空闲列表的末尾,对吧?然而,这种简单实现的free()存在一个问题,那就是它几乎可以确保堆会在短时间内变得碎片化并不可用。考虑图 9-4 中的情况。

如果free()仅仅将要释放的内存块添加到空闲列表中,则在图 9-4 所示的内存组织方式下会生成三个空闲块。然而,由于这三个块是连续的,堆管理器实际上应该将它们合并为一个单一的空闲块,以便能够满足更大的请求。不幸的是,这个操作需要扫描空闲块列表,以确定是否有任何空闲块与系统正在释放的块相邻。虽然你可以设计一种数据结构,使得合并相邻空闲块更加容易,但这种方案通常会为堆中的每个块增加 8 个或更多字节的开销。是否这是一个合理的权衡,取决于内存分配的平均大小。如果使用堆管理器的应用程序倾向于分配小对象,则每个内存块的额外开销可能会占用堆空间的很大一部分。然而,如果大多数分配是大的,少量的开销就不会有什么影响。

图片

图 9-4:释放内存块

9.7 操作系统与内存分配

堆管理器所使用的算法和数据结构的性能只是性能难题的一部分。堆管理器最终需要向操作系统请求内存块。在一种极端情况下,操作系统直接处理所有的内存分配请求。在另一种极端情况下,堆管理器是一个与应用程序链接的运行时库例程,首先向操作系统请求大量的内存块,然后根据应用程序发出的分配请求将其拆分成小块。

向操作系统直接请求内存分配的问题在于操作系统的 API 调用通常非常慢。这是因为它们通常涉及在 CPU 上切换内核模式和用户模式(这并不快)。因此,如果操作系统直接实现堆管理器,而应用程序频繁调用内存分配和释放例程,堆管理器的性能将不会很好。

由于操作系统调用的高开销,大多数语言在它们的运行时库中实现了自己的malloc()free()函数版本。在第一次内存分配时,malloc()例程向操作系统请求一个大块内存,而应用程序的malloc()free()例程自行管理这块内存。如果出现了一个malloc()函数无法在它最初创建的内存块中满足的分配请求,malloc()将向操作系统请求另一个大块内存(通常比请求的要大得多),并将该块添加到其空闲列表的末尾。因为应用程序的malloc()free()例程仅偶尔调用操作系统,所以应用程序不会遭遇频繁操作系统调用带来的性能损失。

然而,请记住,这个过程是非常依赖实现和语言的;在编写需要高性能组件的软件时,假设 malloc()free() 是相对高效的做法是危险的。确保高性能堆管理器的唯一便携方法是开发自己特定应用程序的分配/释放例程。编写这些例程超出了本书的范围(大多数标准堆管理函数对于典型程序的表现良好),但你应该知道你有这个选择。

9.8 堆内存开销

堆管理器通常会表现出两种类型的开销:性能(速度)和内存(空间)。到目前为止,这个讨论主要处理了性能方面的问题,但现在我们将把注意力转向内存。

系统分配的每个块都需要一定的额外开销,这些开销超出了应用程序请求的存储空间;至少,这些开销是一些字节,用于跟踪块的大小。更复杂(高性能)的方案可能需要额外的字节,但通常开销在 8 到 64 字节之间。堆管理器可以将这些信息保存在一个单独的内部表中,也可以将块的大小和其他内存管理信息直接附加到它分配的块上。

将这些信息保存在内部表中有几个优点。首先,应用程序很难意外覆盖存储在其中的信息;将数据附加到堆内存块本身并不能提供足够的保护,防止这种情况发生。其次,将内存管理信息放入内部数据结构中,使得内存管理器可以轻松地确定给定的指针是否有效(也就是说,指向堆管理器认为已分配的某个内存块)。

将控制信息直接附加到堆管理器分配的每个块的优点在于,定位这些信息非常容易,而将信息存储在内部表中可能需要进行搜索操作。

另一个影响堆管理器开销的问题是分配粒度——即堆管理器支持的最小字节数。尽管大多数堆管理器允许你请求最小为 1 字节的分配,但实际上它们可能分配大于 1 字节的最小字节数。通常,设计内存分配函数的工程师会选择一个粒度,确保堆上分配的任何对象都能以该对象合理对齐的内存地址开始。因此,大多数堆管理器会在 4 字节、8 字节或 16 字节边界上分配内存块。出于性能考虑,许多堆管理器会在缓存行边界上开始每次分配,通常是 16、32 或 64 字节。不管粒度是多少,如果应用程序请求的字节数少于堆管理器粒度或不是粒度的倍数,堆管理器就会分配额外的存储字节(参见图 9-5)。这个数值因堆管理器而异(甚至可能因特定堆管理器的版本而异),因此程序员不应该假设应用程序有比请求的更多内存可用;如果他们有这种想法,应该提前请求更多的内存。

堆管理器分配的额外内存导致了另一种形式的碎片化,称为内部碎片化(在图 9-5 中也有显示)。与外部碎片化类似,内部碎片化会在系统中产生少量无法满足未来分配请求的剩余内存。假设是随机大小的内存分配,每次分配时发生的平均内部碎片化量是粒度大小的一半。幸运的是,对于大多数内存管理器来说,粒度大小通常非常小(通常为 16 字节或更少),因此经过成千上万次的内存分配后,你失去的内部碎片也只会是几十个字节。

Image

图 9-5:分配粒度和内部碎片化

在分配粒度的成本和内存控制信息之间,典型的内存请求可能需要 8 到 64 字节,再加上应用程序请求的内存。如果你进行大规模内存分配(几百或几千字节),则开销字节不会占用堆内存的大部分比例。然而,如果你分配许多小对象,内部碎片和内存控制信息所消耗的内存可能占用堆区域的相当大一部分。例如,考虑一个简单的内存管理器,它总是在 4 字节对齐的边界上分配数据块,并且为每个内存存储分配请求附加一个 4 字节的长度值。这意味着堆管理器为每次分配所需的最小存储量是 8 字节。如果你进行一系列 malloc() 调用来分配一个字节,应用程序几乎无法使用它分配的 88% 内存。即使你在每次分配请求时分配 4 字节的值,堆管理器也会消耗三分之二的内存作为开销。然而,如果你的平均分配是 256 字节的块,开销仅占总内存分配的约 2%。简而言之,你的分配请求越大,控制信息和内部碎片对堆的影响就越小。

许多计算机科学期刊中的软件工程研究发现,内存分配/释放请求会导致性能的显著损失。在这些研究中,作者通过简单实现自己特定应用的简化内存管理算法,而不是调用标准运行时库或操作系统内核的内存分配代码,通常能获得 100% 或更好的性能提升。希望这一节已经让你意识到自己代码中可能存在的这个问题。

9.9 常见的指针问题

程序员在使用指针时会犯六个常见的错误。其中一些错误会立即通过诊断信息停止程序。其他错误则较为微妙,会导致错误结果,而不报告其他错误。还有一些错误会直接影响程序的性能。优秀的程序员始终意识到使用指针的风险,并避免这些错误:

  • 使用未初始化的指针

  • 使用包含非法值的指针,例如 NULL

  • 在存储已释放后继续使用

  • 程序使用完存储后未释放

  • 使用错误的数据类型访问间接数据

  • 执行无效的指针操作

9.9.1 使用未初始化的指针

在为指针分配有效的内存地址之前使用指针变量是一个非常常见的错误。初学的程序员往往没有意识到,声明一个指针变量只会为指针本身保留存储空间,而不是为指针所引用的数据分配存储空间。以下是一个简短的 C/C++程序,演示了这个问题:


			int main()
{
    static int *pointer;

    *pointer = 0;
}

尽管你声明的静态变量从技术上讲是以0(即NULL)初始化的,但静态初始化并不会将指针初始化为有效的地址。因此,当该程序执行时,变量指针将不包含有效地址,程序将会失败。为避免这个问题,确保所有指针变量在解引用之前包含有效地址。例如:


			int main()
{
     static int i;

     static int *pointer = &i;

    *pointer = 0;
}

当然,在大多数 CPU 上并没有真正的未初始化变量。变量的初始化有两种方式:

  • 程序员显式地为它们赋予初始值。

  • 它们继承了系统为其绑定存储时内存中的任何位模式。

大多数时候,内存中残留的垃圾位模式并不对应有效的内存地址。尝试解引用这样的无效指针(即访问它所指向的内存中的数据)会引发内存访问违规异常,前提是你的操作系统能够捕捉到这个异常。

然而,有时候内存中的这些随机位恰好对应于一个你可以访问的有效内存位置。在这种情况下,CPU 会访问指定的内存位置而不会中止程序。一个初学者可能会认为,访问随机内存比中止程序更可取。然而,忽视错误要糟糕得多,因为你的有缺陷的程序会继续运行,而没有提醒你。如果你使用未初始化的指针存储数据,很可能会覆盖内存中其他重要变量的值。这会产生一些非常难以定位的问题。

9.9.2 使用包含非法值的指针

程序员在使用指针时常犯的第二个错误是将无效值赋给它们(“无效”是指指针不包含内存中实际对象的地址)。这可以看作是第一个问题的一个更一般的情况;如果没有初始化,内存中的垃圾位就会提供无效的地址。其效果是一样的。如果你尝试解引用一个包含无效地址的指针,你将会遇到内存访问违规异常,或者访问一个意外的内存位置。解引用指针变量时要小心,确保在使用它之前已经为指针分配了有效地址。

9.9.3 在释放存储后继续使用它

第三个错误被称为悬空指针问题。为了理解它,考虑以下 Pascal 代码片段:


			(* Allocate storage for a new object of type p  *)

new( p );

(* Use the pointer *)

p^ := 0;
    .
    . (* Code that uses the storage associated with p *)
    .
(* free the storage associated with pointer p *)

dispose( p );

    .
    . (* Code that doesn't reference p *)
    .
(* Dangling pointer                             *)

p^ := 5;

该程序分配了一些存储空间,并将该存储空间的地址保存在p变量中。代码使用这块存储空间一段时间后,再通过dispose()释放它,将内存归还给系统供其他用途。请注意,调用dispose()并不会改变分配的内存中的任何数据。它不会以任何方式改变p的值;p仍然指向之前由new()分配的内存块。然而,调用dispose()确实通知系统该程序不再需要这块内存,因此系统可以将该内存用于其他目的。dispose()函数无法强制确保你再也不会访问这些数据,你只是承诺不会再访问。当然,这段代码打破了这个承诺:最后一条语句将值5存储到p指向的内存地址中。

悬挂指针的最大问题在于,有时候你可能会侥幸使用它们,因此你不会立刻发现问题。只要系统没有重用你已经释放的存储空间,使用悬挂指针对你的程序没有直接影响。然而,随着每次调用new(),系统可能会决定重用之前调用dispose()释放的内存。当系统重用内存时,任何后续对悬挂指针的解引用操作可能会产生一些意想不到的后果。这些问题包括读取已被覆盖的数据、覆盖新的数据,或者(在最坏的情况下)覆盖系统堆管理指针(这可能导致程序崩溃)。解决方案很明确:一旦释放了与指针关联的存储空间,就绝不再使用该指针的值。

9.9.4 使用后未释放存储空间

在所有这些错误中,未能释放已分配的存储空间可能对程序的正常运行影响最小。以下 C 语言代码片段展示了这个问题:


			// Pointer to storage in "ptr" variable.

ptr = malloc( 256 );
    .
    . // Code that doesn't free "ptr"
    .
ptr = malloc( 512 );

// At this point, there is no way to reference the
// original block of 256 bytes allocated by malloc.

在这个示例中,程序分配了 256 字节的存储空间,并使用ptr变量引用该存储空间。随后,程序分配了另一块 512 字节的存储空间,并将ptr中的值覆盖为新块的地址。原来在ptr中的地址值丢失了。由于程序已经覆盖了这个值,所以没有办法将最初的 256 字节的地址传递给free()函数。因此,这 256 字节的内存就无法再被程序使用。

尽管让程序无法访问 256 字节的内存看起来似乎没什么大不了的,但想象一下这段代码在循环中执行的情况。每次循环迭代,程序都会丧失另外 256 字节的内存。经过足够多次的重复后,程序就会耗尽堆上的可用内存。这个问题通常被称为内存泄漏,因为其效果就像是内存数据在程序执行过程中从计算机中“泄漏”出去。

内存泄漏不如悬空指针问题严重。实际上,内存泄漏只有两个问题:

  • 堆空间耗尽的危险(这最终可能导致程序中止,尽管这种情况比较罕见)

  • 由于虚拟内存页面交换导致的性能问题(换页

尽管如此,释放你分配的所有存储空间是一个值得培养的好习惯。

注意

当你的程序退出时,操作系统将回收所有存储空间,包括通过内存泄漏丢失的数据。因此,泄漏的内存只是对你的程序丢失,而不是整个系统。

9.9.5 使用错误的数据类型访问间接数据

指针的另一个问题是,它们缺乏类型安全的访问,使得容易不小心使用错误的数据类型。一些语言,比如汇编语言,不能也不强制进行指针类型检查。其他语言,比如 C/C++,则非常容易覆盖指针引用对象的类型。例如,考虑以下 C/C++程序片段:


			char *pc;
    .
    .
    .
pc = malloc( sizeof( char ));
    .
    .
    .
// Typecast pc to be a pointer to an integer
// rather than a pointer to a character:

*((int *) pc) = 5000;

通常,如果你试图将值5000赋给pc指向的对象,编译器会强烈抱怨。值5000无法适应与字符(char)对象相关联的存储空间,即 1 字节。然而,本文使用了类型转换(或强制转换)来告诉编译器,pc实际上包含的是指向一个整数的指针,而不是指向字符的指针。因此,编译器会认为这个赋值是合法的。

然而,如果pc实际上没有指向一个整数对象,这个序列中的最后一条语句可能会造成灾难。字符是 1 字节长,而整数通常更大。如果整数大于 1 字节,这个赋值会覆盖malloc()分配的 1 字节存储空间之外的若干字节。是否灾难性取决于字符对象后面在内存中紧跟的数据是什么。

9.9.6 对指针执行非法操作

常见的指针错误的最后一类与指针本身的操作有关。任意的指针运算可能导致指针指向原本分配的数据范围之外的地方。通过一些疯狂的运算,甚至可以修改指针,使其不再指向正确的对象。考虑以下(非常糟糕的)C 代码:


			int  i [4] = {1,2,3,4};
int *p     = &i[0];
      .
      .
      .
    p = (int *)((char *)p + 1);
    *p = 5;

这个示例将p转换为指向char的指针。然后,它将1加到p中值的基础上。由于编译器认为p指向一个字符(因为有类型转换),它实际上将值1加到p中保存的地址上。这个序列中的最后一条指令将值5存储到p指向的内存地址中,这个地址现在距离i[0]元素的 4 字节区间只有 1 字节。在一些机器上,这将导致故障;而在其他机器上,它将把一个奇怪的值存储到i[0]i[1]中。

比较两个指针是否小于或大于当这两个指针不指向同一个对象(通常是数组或结构体)时,就是另一个非法的指针操作例子,类似地,将指针强制转换为整数并赋予该指针一个整数值,也可能产生意想不到的结果。

9.10 现代语言中的指针

由于上一节中描述的问题,现代高级语言(如 Java、C#、Swift 和 C++11/C++14)尝试消除手动内存分配和释放。这些语言允许你在堆上创建新的对象(通常使用new()函数),但不提供显式释放存储的设施。相反,语言的运行时系统会追踪内存使用情况,并在程序不再使用内存时通过垃圾回收自动回收存储。这消除了大多数(但不是所有)与未初始化和悬空指针相关的问题,也降低了内存泄漏的可能性。这些新语言大大减少了与错误指针使用相关的问题数量。

当然,放弃对内存分配和释放的控制也会带来一些问题。特别是,你失去了控制内存分配生命周期的能力。现在,运行时系统决定何时进行垃圾回收以回收未使用的数据,因此,即使你已经不再使用某些数据,这些数据可能仍然会保留一段时间。

9.11 托管指针

一些编程语言提供非常有限的指针功能。例如,标准 Pascal 仅允许对指针进行少数几种操作:赋值(复制)、比较(用于相等/不相等)和解引用。它不支持指针算术,这意味着许多指针相关的错误是不可能发生的^(4)。另一方面,C/C++允许对指针进行不同的算术运算,使得该语言非常强大,但也引入了代码缺陷的可能性。

现代语言系统(例如,C#和微软通用语言运行时系统)引入了托管指针,它允许对指针进行各种算术运算,比标准的 Pascal 语言提供了更大的灵活性,但也有一些限制,帮助避免许多常见的指针问题。例如,在这些语言中,你不能将任意整数加到任意指针上(这在 C/C++中是可能的)。如果你想将一个整数加到指针上并获得合法的结果,指针必须包含一个数组对象的地址(或内存中其他类似元素的集合)。此外,整数的值必须限制在不超过数据类型大小的范围内(即,运行时系统会强制执行数组边界检查)。

虽然使用智能指针不能消除所有指针问题,但它确实可以防止指针引用的数据对象范围外的数据被擦除。它还帮助防止软件中的安全问题,例如通过在指针运算中提供非法偏移量来尝试攻击系统。

9.12 更多信息

Duntemann, Jeff. 汇编语言一步步. 第 3 版。印第安纳波利斯:Wiley,2009 年。

Hyde, Randall. 汇编语言艺术。第 2 版。旧金山:No Starch Press,2010 年。

Oualline, Steve. 如何避免在 C++中编程错误。旧金山:No Starch Press,2003 年。

第十章:字符串数据类型

image

在整数之后,字符字符串可能是现代程序中最常用的数据类型;仅次于数组,它们是第二常用的复合数据类型。字符串是对象的序列。通常,字符串这一术语描述的是字符值的序列,但也有可能存在由整数、实数、布尔值等组成的字符串(例如,我已经在本书和WGC1中讨论过位字符串)。不过在本章中,我们将专注于字符字符串。

一般来说,字符字符串具有两个主要属性:长度和一些字符数据。字符字符串还可以具有其他属性,例如该特定变量的最大长度引用计数,指示有多少不同的字符串变量引用相同的字符字符串。我们将研究这些属性及其程序如何使用它们,以及各种字符串格式和可能的字符串操作。具体而言,本章讨论以下主题:

  • 字符串格式,包括零终止字符串、长度前缀字符串、HLA 字符串和 7 位字符串

  • 何时使用(以及何时不使用)标准库字符串处理函数

  • 静态、伪动态和动态字符串

  • 引用计数与字符串

  • 字符串中的 Unicode 和 UTF-8/UTF-16/UTF-32 字符数据

字符串操作在当今的应用程序中消耗了相当多的 CPU 时间。因此,如果你想编写高效操作字符串的代码,理解编程语言如何表示和操作字符字符串是非常重要的。本章提供了你所需要的基本信息。

10.1 字符串格式

不同的编程语言使用不同的数据结构来表示字符串。一些字符串格式使用较少的内存,其他格式允许更快的处理,一些格式更方便使用,一些对编译器开发者来说更易于实现,而一些则为程序员和操作系统提供了额外的功能。

尽管它们的内部表示方式各不相同,但所有字符串格式有一个共同点:字符数据。这是一系列 0 个或多个字节(序列一词意味着字符的顺序是重要的)。程序如何引用这组字符的方式因格式而异。在某些字符串格式中,字符序列存储在数组中;而在其他字符串格式中,程序保持指向内存中其他位置字符序列的指针。

所有字符字符串格式都共享长度属性;然而,它们使用多种不同的方式来表示字符串的长度。有些字符串格式使用一个特殊的哨兵字符来标记字符串的结尾。其他格式则在字符数据之前加上一个指定字符序列长度的数值。还有一些将长度编码为一个与字符序列无关的变量中的数值。某些字符字符串格式使用一个特殊的位(设置或清除)来标记字符串的结尾。最后,某些字符串格式结合了这些方法。一个特定字符串格式如何确定字符串的长度,可能会对操作这些字符串的函数性能产生较大影响,也会影响表示字符串数据所需的额外存储空间。

一些字符串格式提供附加的属性,如最大长度和引用计数值,这些属性可以让某些字符串函数更高效地操作字符串数据。这些附加属性是可选的,因为它们并不是定义字符串值所必须的。然而,它们确实允许字符串操作函数进行正确性检查或比其他方法更高效地工作。

为了帮助你更好地理解字符字符串设计背后的原因,我们来看一下由各种语言推广的几种常见字符串表示形式。

10.1.1 零终止字符串

毫无疑问,零终止字符串(见图 10-1)可能是目前使用最广泛的字符串表示形式,因为这是 C、C++及其他几种语言的本地字符串格式。此外,你还会发现零终止字符串用于一些没有特定本地字符串格式的语言编写的程序中,例如汇编语言。

Image

图 10-1:零终止字符串格式

零终止的 ASCII 字符串,也称为ASCIIz字符串或zstring,是包含零个或多个 8 位字符编码,并以包含0的字节结尾的序列——或者在 Unicode(UTF-16)的情况下,是一个包含零个或多个 16 位字符编码并以一个包含0的 16 位字结尾的序列。对于 UTF-32 字符串,字符串中的每个项都是 32 位(4 字节)宽,并以 32 位0值结尾。例如,在 C/C++中,ASCIIz 字符串"abc"需要 4 个字节:每个字符abc各占 1 字节,后跟一个0字节。

零终止字符串相较于其他字符串格式有一些优势:

  • 零终止字符串可以表示任何实际长度的字符串,且仅需 1 字节的开销(在 UTF-16 中为 2 字节,在 UTF-32 中为 4 字节)。

  • 由于 C/C++编程语言的流行,现有许多高性能的字符串处理库,它们能够很好地处理零终止字符串。

  • 零终止字符串易于实现。实际上,除了处理字符串字面常量外,C/C++ 编程语言并不提供本地的字符串支持。就这些语言而言,字符串只是字符数组。这也可能是 C 的设计者最初选择这种格式的原因——这样他们就不需要用字符串操作符来混乱语言的设计。

  • 你可以在任何提供创建字符数组能力的语言中轻松表示零终止字符串。

然而,零终止字符串也有一些缺点,这意味着它们并不总是表示字符字符串数据的最佳选择:

  • 当操作零终止字符串时,字符串函数通常效率较低。许多字符串操作需要在处理字符串数据之前知道字符串的长度。计算零终止字符串的长度的唯一合理方法是从头到尾扫描字符串。你的字符串越长,这个函数运行得就越慢,因此,如果你需要处理长字符串,零终止字符串格式不是最佳选择。

  • 尽管这是一个小问题,但你无法轻松地使用零终止字符串格式表示字符代码0(例如 ASCII 和 Unicode 中的 NUL 字符)。

  • 零终止字符串不包含任何信息,无法告诉你字符串可以扩展到终止 0 字节之后的长度。因此,一些字符串函数(如拼接)只能扩展现有字符串变量的长度,并且只有在调用者显式传递最大长度时才会检查溢出。

如前所述,零终止字符串的一个优点是你可以通过使用指针和字符数组轻松实现它们。考虑以下 C/C++ 语句:

someCharPtrVar = "Hello World";

以下是 Borland C++ v5.0 编译器为此语句生成的代码:


			;       char *someCharPtrVar;
   ;        someCharPtrVar = "Hello World";
   ;
@1:
; "offset" means "take the address of" and "s@" is
; the compiler-generated label where the string
; "Hello World" can be found.

    mov       eax,offset s@
        .
        .
        .
_DATA   segment dword public use32 'DATA'
;       s@+0:
        ; Zero-terminated sequence of characters
        ; emitted for the literal string "Hello World":

s@      label   byte
        db      "Hello World",0

        ;       s@+12:
        db      "%s",0
        align   4
_DATA   ends

Borland C++ 编译器将字面字符串 "Hello World" 直接输出到内存的全局数据段,然后将 someCharPtrVar 变量加载为该字符串字面量在数据段中的第一个字符的地址。从那时起,程序可以通过这个指针间接引用字符串数据。从编译器编写者的角度来看,这是一种非常方便的方案。

在使用像 C、C++、Python 或其他采用 C 字符串格式的语言时,你可以通过记住以下几点来提高字符串处理代码序列的性能:

  • 尝试使用语言的运行时库函数,而不是尝试自己编写类似的函数。大多数编译器供应商提供了高度优化的字符串函数版本,这些函数的运行速度可能比你自己编写的代码快得多。

  • 一旦通过扫描整个字符串计算了字符串的长度,应该将该长度保存以供将来使用(而不是每次需要时重新计算它)。

  • 避免将字符串数据从一个字符串变量复制到另一个。这样做是使用零终止字符串的应用程序中最昂贵的操作之一(仅次于长度计算)。

以下小节将依次讨论每个要点。

10.1.1.1 何时使用 C 标准库字符串函数

一些程序员怀疑别人能写出更快或更高质量的代码。但当涉及到标准库函数时,你应该避免用自己选择的代码替代它们。除非你考虑的库代码特别差,否则你很难接近它的效率。这一点在处理像 C 和 C++ 中的零终止字符串的字符串函数时尤其如此。标准库通常比你自己编写的代码表现得更好,有三个主要原因:经验、成熟度和内联替换。

编写编译器运行时库的典型程序员在字符串处理函数方面有着丰富的经验。尽管过去新编译器往往伴随着效率低下的库,但随着时间的推移,编译器程序员积累了相当多的经验,编写出了高效的字符串处理函数。除非你花费了大量时间编写相同类型的例程,否则你的代码不太可能与他们的代码表现得一样好。许多编译器供应商会从专门编写库代码的第三方购买标准库代码,所以即使你使用的编译器相对较新,它也可能有一个不错的库。如今,几乎没有商业编译器包含效率极低的库代码。大多数情况下,只有研究型或“爱好者”编译器才会包含那么糟糕的库代码,以至于你可以轻松编写出更好的代码。考虑一个简单的例子——C 标准库中的 strlen()(字符串长度)函数。以下是一个没有经验的程序员可能会编写的典型 strlen() 实现:


			#include <stdlib.h>
#include <stdio.h>
int myStrlen( char *s )
{
    char *start;

    start = s;
    while( *s != 0 )
    {
        ++s;
    }
    return s - start;
}

int main( int argc, char **argv )
{

    printf( "myStrlen = %d", myStrlen( "Hello World" ));
    return 0;
}

微软 Visual C++ 编译器为 myStrlen() 生成的 80x86 机器代码可能是任何汇编程序员所预期的样子:


			myStrlen PROC                                           ; COMDAT
; File c:\users\rhyde\test\t\t\t.cpp
; Line 10                           // Pointer to string (s) is passed in RCX register.
        cmp     BYTE PTR [rcx], 0   // Is *s = 0?
        mov     rax, rcx            // Save ptr to start of string to compute length
        je      SHORT $LN3@myStrlen // Bail if we hit the end of the string
$LL2@myStrlen:
; Line 12
        inc     rcx                 // Move on to next char in string
        cmp     BYTE PTR [rcx], 0   // Hit the 0 byte yet?
        jne     SHORT $LL2@myStrlen // If not, repeat loop
$LN3@myStrlen:
; Line 14
        sub     rcx, rax            // Compute length of string.
        mov     eax, ecx            // Return function result in EAX.
; Line 15
        ret     0
myStrlen ENDP

毫无疑问,一位经验丰富的汇编语言程序员可以重新排列这些特定的指令,稍微加速它。事实上,即使是一个普通的 80x86 汇编语言程序员,也能指出 80x86 的scasb指令在这段代码序列中完成了大部分工作。尽管这段代码相当简短且易于理解,但它并不会以最快的速度运行。一位专家级的汇编语言程序员可能会注意到,这个循环每次迭代处理字符串中的一个字符,并且一次访问一个字节的字符,它可能会通过展开^(1)循环并在每次迭代中处理多个字符来改进它。例如,考虑以下 HLA 标准库的zstr.len()函数,它通过一次处理四个字符来计算零终止字符串的长度:


			unit stringUnit;

#include( "strings.hhf" );

/*******************************************************************/
/*                                                                 */
/* zlen-                                                           */
/*                                                                 */
/* Returns the current length of the z-string passed as a parm.    */
/*                                                                 */
/*******************************************************************/

procedure zstr.len( zstr:zstring ); @noframe;
const
    zstrp   :text := "[esp+8]";

begin len;

    push( esi );
    mov( zstrp, esi );

    // We need to get ESI dword-aligned before proceeding.
    // If the LO 2 bits of ESI contain 0s, then
    // the address in ESI is a multiple of 4\. If they
    // are not both 0, then we need to check the 1,
    // 2, or 3 bytes starting at ESI to see if they
    // contain a zero-terminator byte.

    test( 3, esi );
    jz ESIisAligned;

    cmp( (type char [esi]), #0 );
    je SetESI;
    inc( esi );
    test( 3, esi );
    jz ESIisAligned;

    cmp( (type char [esi]), #0 );
    je SetESI;
    inc( esi );
    test( 3, esi );
    jz ESIisAligned;

    cmp( (type char [esi]), #0 );
    je SetESI;
    inc( esi );                 // After this, ESI is aligned.

    ESIisAligned:
        sub( 32, esi );         // To counteract add immediately below.
    ZeroLoop:
        add( 32, esi );         // Skip chars this loop just processed.
    ZeroLoop2:
        mov( [esi], eax );      // Get next four chars into EAX.
        and( $7f7f7f7f, eax );  // Clear HO bit (note:$80->$00!)
        sub( $01010101, eax );  // $00 and $80->$FF, all others have pos val.
        and( $80808080, eax );  // Test all HO bits.  If any are set, then
        jnz MightBeZero0;       // we've got a $00 or $80 byte.

        mov( [esi+4], eax );    // The following are all inline expansions
        and( $7f7f7f7f, eax );  // of the above (we'll process 32 bytes on
        sub( $01010101, eax );  // each iteration of this loop).
        and( $80808080, eax );
        jnz MightBeZero4;

        mov( [esi+8], eax );
        and( $7f7f7f7f, eax );
        sub( $01010101, eax );
        and( $80808080, eax );
        jnz MightBeZero8;

        mov( [esi+12], eax );
        and( $7f7f7f7f, eax );
        sub( $01010101, eax );
        and( $80808080, eax );
        jnz MightBeZero12;

        mov( [esi+16], eax );
        and( $7f7f7f7f, eax );
        sub( $01010101, eax );
        and( $80808080, eax );
        jnz MightBeZero16;

        mov( [esi+20], eax );
        and( $7f7f7f7f, eax );
        sub( $01010101, eax );
        and( $80808080, eax );
        jnz MightBeZero20;

        mov( [esi+24], eax );
        and( $7f7f7f7f, eax );
        sub( $01010101, eax );
        and( $80808080, eax );
        jnz MightBeZero24;

        mov( [esi+28], eax );
        and( $7f7f7f7f, eax );
        sub( $01010101, eax );
        and( $80808080, eax );
        jz ZeroLoop;

    // The following code handles the case where we found a $80
    // or a $00 byte. We need to determine whether it was a 0
    // byte and the exact position of the 0 byte. If it was a
    // $80 byte, then we've got to continue processing characters
    // in the string.

    // Okay, we've found a $00 or $80 byte in positions
    // 28..31\. Check for the location of the 0 byte, if any.

        add( 28, esi );
        jmp MightBeZero0;

    // If we get to this point, we've found a 0 byte in
    // positions 4..7:

    MightBeZero4:
        add( 4, esi );
        jmp MightBeZero0;

    // If we get to this point, we've found a 0 byte in
    // positions 8..11:

    MightBeZero8:
        add( 8, esi );
        jmp MightBeZero0;

    // If we get to this point, we've found a 0 byte in
    // positions 12..15:

    MightBeZero12:
        add( 12, esi );
        jmp MightBeZero0;

    // If we get to this point, we've found a 0 byte in
    // positions 16..19:

    MightBeZero16:
        add( 16, esi );
        jmp MightBeZero0;

    // If we get to this point, we've found a 0 byte in
    // positions 20..23:

    MightBeZero20:
        add( 20, esi );
        jmp MightBeZero0;

    // If we get to this point, we've found a 0 byte in
    // positions 24..27:

    MightBeZero24:
        add( 24, esi );

    // If we get to this point, we've found a 0 byte in
    // positions 0..3 or we've branched here from one of the
    // above conditions

    MightBeZero0:
        mov( [esi], eax );          // Get the original 4 bytes.
        cmp( al, 0 );               // See if the first byte contained 0.
        je SetESI;
        cmp( ah, 0 );               // See if the second byte contained 0.
        je SetESI1;
        test( $FF_0000, eax );      // See if byte #2 contained a 0.
        je SetESI2;
        test( $FF00_0000, eax );    // See if the HO byte contained 0.
        je SetESI3;

    // Well, it must have been a $80 byte we encountered.
    // (Fortunately, they are rare in ASCII strings, so all this
    // extra computation rarely occurs). Jump back into the 0
    // loop and continue processing.

        add( 4, esi );              // Skip bytes we just processed.
        jmp ZeroLoop2;              // Don't bother adding 32 in the ZeroLoop!

    // The following computes the length of the string by subtracting
    // the current ESI value from the original value and then adding
    // 0, 1, 2, or 3, depending on where we branched out
    // of the MightBeZero0 sequence above.

    SetESI3:
        sub( zstrp, esi );          // Compute length
        lea( eax, [esi+3] );        // +3 since it was in the HO byte.
        pop( esi );
        ret(4);

    SetESI2:
        sub( zstrp, esi );          // Compute length
        lea( eax, [esi+2] );        // +2 since zero was in byte #2
        pop( esi );
        ret(4);

    SetESI1:
        sub( zstrp, esi );          // Compute length
        lea( eax, [esi+1] );        // +1 since zero was in byte #1
        pop( esi );
        ret(4);

    SetESI:
        mov( esi, eax );
        sub( zstrp, eax );          // Compute length. No extra addition since
        pop( esi );                 // 0 was in LO byte.
        ret( _parms_ );

end len;
end stringUnit;

即使这个函数比之前给出的简单示例更长且更复杂,但它运行得更快,因为它每次循环迭代处理四个字符,而不是一个,这意味着它执行的循环迭代次数大大减少。此外,这段代码通过展开八个循环副本(即将循环体的八个副本内联展开)来减少循环开销,这节省了 87%的循环控制指令的执行。因此,这段代码的运行速度比之前的代码快两到六倍;具体的节省取决于字符串的长度。^(2)

避免自己编写库函数的第二个原因是代码的成熟度。现今大多数流行的优化编译器已经存在了一段时间。在此期间,编译器厂商已经使用了他们的例程,找出了瓶颈所在,并对他们的代码进行了优化。当你编写自己的标准库字符串处理函数时,你可能不会有足够的时间来进行优化——你还得处理整个应用程序。由于项目的时间限制,你很可能永远不会回过头来重写那个字符串函数以提高性能。即使你现在的程序有些微的性能优势,编译器厂商将来可能会更新他们的库,而你只需将更新后的代码与项目重新链接,就能利用这些改进。然而,如果你自己编写库代码,除非你明确更新它,否则它永远不会改进。大多数人都忙于处理新项目,没有时间回去清理旧代码,所以未来改进自己编写的字符串函数的可能性非常低。

在像 C 或 C++ 这样的语言中使用标准库字符串函数的第三个原因是最重要的:内联扩展。许多编译器能够识别某些标准库函数名,并将它们内联展开为高效的机器代码,取代函数调用。这个内联扩展通常比显式的函数调用快得多,尤其是在函数调用包含多个参数的情况下。举个简单的例子,考虑以下(几乎微不足道的)C 程序:


			#include <string.h>
#include <stdio.h>

int main( int argc, char **argv )
{
    char localStr[256];

    strcpy( localStr, "Hello World" );
    printf( localStr );
    return 0;
}

Visual C++ 生成的相应的 64 位 x86-64 汇编代码相当有趣:


			; Storage for the literal string appearing in the
; strcpy invocation:

_DATA   SEGMENT
$SG6874 DB  'Hello World', 00H
_DATA   ENDS

_TEXT   SEGMENT
localStr$ = 32
__$ArrayPad$ = 288
argc$ = 320
argv$ = 328
main    PROC
; File c:\users\rhyde\test\t\t\t.cpp
; Line 6
$LN4:
    sub rsp, 312                ; 00000138H
    mov rax, QWORD PTR __security_cookie
    xor rax, rsp
    mov QWORD PTR __$ArrayPad$[rsp], rax
; Line 9
    movsd   xmm0, QWORD PTR $SG6874
; Line 10
    lea rcx, QWORD PTR localStr$[rsp]
    mov eax, DWORD PTR $SG6874+8
    movsd   QWORD PTR localStr$[rsp], xmm0
    mov DWORD PTR localStr$[rsp+8], eax
    call    printf
; Line 11
    xor eax, eax
; Line 12
    mov rcx, QWORD PTR __$ArrayPad$[rsp]
    xor rcx, rsp
    call    __security_check_cookie
    add rsp, 312                ; 00000138H
    ret 0
main    ENDP
_TEXT   ENDS

编译器能够识别正在发生的事情,并替换为四条内联指令,将字符串的 12 个字节从内存中的字面常量复制到localStr变量中(具体来说,它使用 XMM0 寄存器复制 8 个字节,使用 EAX 寄存器复制 4 个字节;注意,这段代码使用 RCX 将localStr的地址传递给printf()函数)。调用和返回一个实际的strcpy()函数的开销将比这个更昂贵(更不用说复制字符串数据所需的工作)。这个例子很好地演示了为什么你通常应该调用标准库函数,而不是编写你自己的“优化”函数来完成相同的工作。

10.1.1.2 何时不使用标准库函数

尽管如你所见,通常调用标准库例程要比编写你自己的版本更好,但在某些特殊情况下,你不应该依赖标准库中的一个或多个库函数。

当库函数完全执行你需要的功能时,它们非常有效——既不多也不少。程序员容易出问题的一个地方是,他们错误地使用库函数,调用它来做一些它并不真正打算做的事,或者他们只需要它提供功能的一部分。例如,考虑 C 标准库中的strcspn()函数:


			size_t strcspn( char *source, char *cset );

这个函数返回源字符串中字符数,直到它找到一个在cset字符串中也出现的第一个字符为止。看到像这样的函数调用并不罕见:


			len = strcspn( SomeString, "a" );

这里的目的是返回 SomeString 中第一次出现字符a之前的字符数。也就是说,它尝试做类似以下的事情:


			len = 0;
while
(
        SomeString[ len ] != '\0'
    &&  SomeString[ len ] != 'a'
){
    ++len;
}

不幸的是,调用strcspn()函数可能比这个简单的while循环实现要慢得多。这是因为strcspn()实际上做了比仅仅在字符串中查找单个字符更多的工作。它会在源字符串中查找来自一组字符的任何字符。这个函数的通用实现可能像这样:


			len = 0;
for(;;) // Infinite loop
{
    ch = SomeString[ len ];
    if( ch == '\0' ) break;
    for( i=0; i<strlen( cset ); ++i )
    {
        if( ch == cset[i] ) break;
    }
    if( ch == cset[i] ) break;
    ++len;
}

通过稍微分析(并注意到我们有一对嵌套循环),很明显这段代码比前面的代码慢,即使你传入一个只包含单个字符的 cset 字符串。这是一个经典的例子,展示了调用一个比实际需要更通用的函数,因为它会搜索多个终止字符,而不是单一终止字符的特殊情况。当一个函数完全符合你的需求时,使用标准库的版本是一个好主意。然而,当它做的事情超出你的需求时,使用标准库函数可能会非常昂贵,这时自己写一个版本会更好。

10.1.1.3 为什么要避免重新计算字符串长度

前一节的最后一个例子演示了一个常见的 C 编程错误。考虑以下代码片段:


			for( i=0; i<strlen( cset ); ++i )
{
    if( ch == cset[i] ) break;
}

在每次循环迭代中,代码都会测试循环索引,看它是否小于 cset 字符串的长度。但因为循环体没有修改 cset 字符串(并且假设这不是一个多线程应用程序,另一个线程没有修改 cset 字符串),实际上没有必要在每次循环迭代中重新计算字符串长度。看一下微软 Visual C++ 32 位编译器为这段代码生成的机器代码:


			; Line 10
        mov     DWORD PTR i$1[rsp], 0 ;for(i = 0;...;...)
        jmp     SHORT $LN4@main

$LN2@main:
        mov     eax, DWORD PTR i$1[rsp] ;for(...;...;++i)
        inc     eax
        mov     DWORD PTR i$1[rsp], eax

$LN4@main: ;for(...; i < strlen(localStr);...)
        movsxd  rax, DWORD PTR i$1[rsp]
        mov     QWORD PTR tv65[rsp], rax
        lea     rcx, QWORD PTR localStr$[rsp]
        call    strlen
        mov     rcx, QWORD PTR tv65[rsp]
        cmp     rcx, rax
        jae     SHORT $LN3@main
; Line 12
        movsx   eax, BYTE PTR ch$[rsp]
        movsxd  rcx, DWORD PTR i$1[rsp]
        movsx   ecx, BYTE PTR localStr$[rsp+rcx]
        cmp     eax, ecx
        jne     SHORT $LN5@main
        jmp     SHORT $LN3@main
$LN5@main:
; Line 13
        jmp     SHORT $LN2@main
$LN3@main:

同样,机器代码在每次内层 for 循环的迭代中重新计算字符串的长度,但由于 cset 字符串的长度始终不变,这完全是不必要的。我们可以通过将代码片段重写成这样,轻松地解决这个问题:


			slen = strlen( cset );
len = 0;
for(;;) // Infinite loop
{
    ch = SomeString[ len ];
    if( ch == '\0' ) break;
    for( i=0; i<slen; ++i )
    {
        if( ch == cset[i] ) break;
    }
    if( ch == cset[i] ) break;
    ++len;
}

另一方面,如果你启用了优化,微软 VC++ 编译器的最新版本会识别这种情况。因为 VC++ 确定字符串长度是一个循环不变的计算(即它的值在每次循环迭代中都不会改变),VC++ 会把对 strlen() 的调用移出循环。不幸的是,VC++ 并不能在所有情况下都捕捉到这一点。例如,如果你调用一个 VC++ 不知道的函数,并将 localStr 的地址作为一个(非 const)参数传递给它,VC++ 将不得不假设字符串的长度可能会改变(即使实际上没有改变),因此它无法将 strlen() 的调用移出循环。

很多字符串操作在执行之前需要知道字符串的长度。考虑 strdup() 函数,它在许多 C 库中常见。^(3) 以下代码是这个函数的常见实现:


			char *strdup( char *src )
{
    char *result;

    result = malloc( strlen( src ) + 1 );
    assert( result != NULL ); // Check malloc check
    strcpy( result, src );
    return result;
}

从根本上讲,这个 strdup() 的实现没有错。如果你对传递的字符串对象一无所知,那么你必须计算字符串的长度,以便知道为该字符串的副本分配多少内存。然而,考虑以下调用 strdup() 的代码序列:


			len = strlen( someStr );
if( len == 0 )
{
    newStr = NULL;
}
else
{
    newStr = strdup( someStr );
}

这里的问题是你会调用strlen()两次:一次是显式调用strlen(),另一次是在strdup()函数内部调用。更糟糕的是,这并不明显,因此你甚至无法察觉到你在这段代码中浪费了 CPU 周期。这是另一个例子,说明调用了一个比你实际需要的更通用的函数,导致程序重新计算字符串的长度(这是一种低效的过程)。一种解决方案是提供一个更不通用的版本,比如strduplen(),它允许你传递已经计算过的字符串长度。你可以按如下方式实现strduplen()


			char *strduplen( char *src, size_t len)
{
    char *result;

    // Allocate storage for new string:

    result = malloc( len + 1 );
    assert( result != NULL );

    // Copy the source string and
    // 0 byte to the new string:

    memcpy( result, src, len+1 );
    return result;
}

注意使用memcpy()而不是strcpy()(或者更好的是strncpy())。同样,我们已经知道字符串的长度,因此没有必要执行任何代码来查找终止字节0(这正是strcpy()strncpy()所做的)。当然,这个函数实现假设调用者传递了正确的长度,但对于大多数字符串和数组操作来说,这是标准 C 语言的假设。

10.1.1.4 为什么要避免复制数据

复制字符串,尤其是长字符串,可能是一个耗时的过程。大多数程序在内存中维护字符串数据,而内存比 CPU 慢得多(通常慢一个数量级或更多)。尽管缓存内存可以帮助缓解这个问题,但处理大量字符串数据可能会从缓存中移除其他数据,并且如果你不经常重用所有通过缓存移动的字符串数据,就会导致缓存抖动问题。虽然并非总能避免在字符串数据之间移动,但许多程序不必要地复制数据,这会影响程序性能。

更好的解决方案是传递指向零终止字符串的指针,而不是将这些字符串从一个字符串变量复制到另一个字符串变量。指向零终止字符串的指针可以存储在寄存器中,并且在使用内存变量来存储它们时不会占用太多内存。因此,传递指针对缓存和 CPU 性能的影响远远小于在字符串变量之间复制字符串数据。

正如你在本节中所看到的,零终止字符串函数通常比操作其他类型字符串的函数效率低。此外,使用零终止字符串的程序往往会出错,比如多次调用strlen()函数,或者滥用通用函数来实现特定的目标。幸运的是,在以零终止字符串为本地字符串格式的编程语言中,设计和使用更高效的字符串格式是相对简单的。

10.1.2 长度前缀字符串

第二种常见的字符串格式,长度前缀字符串,克服了零终止字符串的一些问题。长度前缀字符串在像 Pascal 这样的语言中很常见;它们通常由一个字节组成,指定字符串的长度,后面跟着零个或多个 8 位字符代码(见 图 10-2)。在长度前缀方案中,字符串 "String" 由 4 个字节组成:长度字节(6),后面是字符 String

Image

图 10-2:长度前缀字符串格式

长度前缀字符串解决了与零终止字符串相关的两个问题:

  • NUL 字符可以在长度前缀字符串中表示。

  • 字符串操作更高效。

长度前缀字符串的另一个优点是,长度通常位于字符串中的位置 0(如果我们将字符串视为字符数组),因此字符串的第一个字符在数组表示中从索引 1 开始。对于许多字符串函数,使用基于 1 的索引比基于 0 的索引(零终止字符串使用的索引)要方便得多。

长度前缀字符串确实有其自身的缺点,主要是它们的长度限制为最多 255 个字符(假设使用 1 字节长度前缀)。你可以通过使用 2 字节或 4 字节的长度值来去除这个限制,但这样会将额外的开销数据从 1 字节增加到 2 字节或 4 字节。而且,它还会将字符串的起始索引从 1 改为 2 或 4,消除了 1 基索引的特点。虽然有方法可以克服这个问题,但它们会带来更多的开销。

许多字符串函数在长度前缀字符串上更高效。显然,计算字符串的长度是一个简单的操作——它只是一次内存访问——但其他最终需要字符串长度的字符串函数(如连接和赋值)通常比零终止字符串的类似函数更高效。此外,每次调用语言标准库中内置的字符串函数时,你不必担心重新计算字符串的长度。

尽管有这些优点,但不要产生这样的印象:使用长度前缀字符串函数的程序总是高效的。你仍然可以通过不必要地复制数据而浪费许多 CPU 周期。和零终止字符串一样,如果你只使用字符串函数的一部分功能,仍然会浪费大量 CPU 周期来执行不必要的任务。

使用长度前缀字符串函数时,请记住以下几点:

  • 尽量使用语言的运行时库函数,而不是自己编写类似的函数。大多数编译器供应商提供的字符串函数经过高度优化,运行速度可能比你自己编写的代码快得多。

  • 尽管在使用带长度前缀的字符串格式时计算字符串长度相对简单,但许多(Pascal)编译器实际上会调用一个函数来从字符串数据中提取长度值。函数调用和返回比从变量中获取长度值要昂贵得多。因此,一旦计算出字符串的长度,如果打算再次使用该值,考虑将该长度保存在局部变量中。当然,如果编译器足够智能,能够将对长度函数的调用替换为从字符串数据结构中简单地获取数据,那么这种“优化”对你帮助不大。

  • 避免将字符串数据从一个字符串变量复制到另一个。这样做是使用带长度前缀的字符串的程序中较为昂贵的操作之一。传递字符串的指针与零终止字符串的好处相同。

10.1.3 七位字符串

7 位字符串格式是一个有趣的选择,适用于像 ASCII 这样的 7 位编码。它使用字符串中字符的(通常未使用的)高阶位来表示字符串的结束。字符串中的每个字符代码,除了最后一个字符,其高阶位都是清除的,最后一个字符的高阶位是设置的(见图 10-3)。

Image

图 10-3:七位字符串格式

这种 7 位字符串格式有几个缺点:

  • 你必须扫描整个字符串才能确定字符串的长度。

  • 这种格式中不能有零长度的字符串。

  • 很少有语言提供用于 7 位字符串的字面量字符串常量。

  • 你最多只能有 128 个字符代码,尽管在使用普通 ASCII 时这并不成问题。

然而,7 位字符串的最大优势是它们不需要任何额外的字节来编码长度。当处理 7 位字符串时,汇编语言(使用宏来创建字面量字符串常量)可能是最适合的语言。因为 7 位字符串的优点是它们紧凑,而汇编语言程序员通常最关心紧凑性,因此这是一个很好的匹配。以下是一个 HLA 宏,它将字面量字符串常量转换为 7 位字符串:


			#macro sbs( s );

    // Grab all but the last character of the string:

    (@substr( s, 0, @length(s) - 1) +

        // Concatenate the last character
        // with its HO bit set:

        char
        (
            uns8
            (
               char( @substr( s, @length(s) - 1, 1))
            ) | $80
        )
    )
#endmacro
    .
    .
    .
byte sbs( "Hello World" );

由于很少有语言支持 7 位字符串,第一个适用于零终止和带长度前缀字符串的建议并不适用于 7 位字符串:你可能必须编写自己的字符串处理函数。然而,即使是 7 位字符串,计算长度和复制数据也是昂贵的操作,所以这两条建议仍然适用:

  • 一旦你通过扫描整个字符串计算出字符串的长度,就可以保存该长度以备将来使用(而不是每次需要时都重新计算它)。

  • 避免将字符串数据从一个字符串变量复制到另一个。这样做是使用 7 位字符串的程序中成本较高的操作之一。

10.1.4 HLA 字符串

只要你不太关心每个字符串多出的几个字节开销,你就可以创建一种结合了长度前缀和零终止字符串优点的字符串格式,而没有它们各自的缺点。高级汇编语言已经通过其原生字符串格式实现了这一点。^(4)

HLA 字符串格式的最大缺点是每个字符串所需的开销(如果你在内存受限的环境中,并且处理许多小字符串,这个开销可能会非常显著)。HLA 字符串包含一个长度前缀和一个零终止字节,以及一些其他信息,每个字符串总共有 9 个字节的开销。^(5)

HLA 字符串格式使用一个 4 字节的长度前缀,使得字符字符串的长度可以达到超过 40 亿个字符(远远超出任何实际应用的需求)。HLA 还会在字符字符串数据后附加一个 0 字节,因此 HLA 字符串与引用(但不更改长度)零终止字符串的字符串函数兼容。HLA 字符串中的剩余 4 字节开销包含该字符串的最大合法长度(加上一个 0 终止字节)。这个额外的字段允许 HLA 字符串函数在必要时检查字符串溢出。在内存中,HLA 字符串的形式如图 10-4 所示。

Image

图 10-4:HLA 字符串格式

字符串第一个字符之前的 4 个字节包含当前字符串长度。当前字符串长度之前的 4 个字节包含最大字符串长度。紧随字符数据后面的是一个 0 字节。最后,HLA 始终确保字符串数据结构的长度是 4 字节的倍数,以提高性能,因此在内存中对象的末尾可能会有最多 3 个额外的填充字节。(注意,图 10-4 中显示的字符串只需要 1 个填充字节,以确保数据结构的长度是 4 字节的倍数。)

HLA 字符串变量实际上是指针,包含字符串第一个字符的字节地址。要访问长度字段,你需要将字符串指针的值加载到一个 32 位寄存器中,然后从该寄存器偏移 -4 处访问长度字段,从寄存器偏移 -8 处访问最大长度字段。以下是一个示例:


			static
    s :string := "Hello World";
        .
        .
        .
// Move the address of 'H' in
// "Hello World" into esi.

mov( s, esi );

// Puts length of string
// (11 for "Hello World") into ECX.

mov( [esi-4], ecx );
        .
        .
        .
mov( s, esi );

// See if value in ECX exceeds the
// maximum string length.

cmp( ecx, [esi-8] );
jae StringOverflow;

如前所述,HLA 字符串的字符数据(包括 0 字节)所保留的内存量始终是 4 字节的倍数。因此,总是可以通过复制双字而不是单个字节来将数据从一个 HLA 字符串移动到另一个 HLA 字符串。这使得字符串复制例程运行速度提高最多四倍,因为复制双字字符串的循环迭代次数是逐字节复制字符串的四分之一。例如,这是 HLA str.cpy() 函数中复制一个字符串到另一个字符串的高度修改版代码:


			// Get the source string pointer into ESI,
// and the destination pointer into EDI.
    mov( dest, edi );
    mov( src, esi );

    // Get the length of the source string
    // and make sure that the source string
    // will fit in the destination string.

    mov( [esi-4], ecx );

    // Save as the length of the destination string.

    mov( ecx, [edi-4] );

    // Add 1 byte to the length so we will
    // copy the 0 byte. Also compute the
    // number of dwords to copy (rather than bytes).
    // Then copy the data.

    add( 4, ecx );  // Adds one, after division by 4.
    shr( 2, ecx );  // Divides length by 4
    rep.movsd();    // Moves length/4 dwords

HLA str.cpy() 函数还会检查字符串溢出和 NULL 指针引用(为了清晰起见,这段代码在本示例中没有出现)。然而,重点是 HLA 以双字的形式复制字符串,以提高性能。

HLA 字符串变量的一个优点是(作为只读对象)HLA 字符串与零终止字符串兼容。例如,如果你有一个用 C 或其他语言编写的函数,要求你传递一个零终止的字符串,你可以调用该函数并传递一个 HLA 字符串变量,如下所示:

someCFunc( hlaStringVar );

唯一需要注意的是,C 函数不得对字符串进行任何会影响其长度的修改(因为 C 代码不会更新 HLA 字符串的 Length 字段)。当然,你可以在返回时调用 C 的 strlen() 函数来更新长度字段,但通常最好不要将 HLA 字符串传递给会修改零终止字符串的函数。

关于长度前缀字符串的评论通常适用于 HLA 字符串,具体而言:

  • 尝试使用 HLA 标准库函数,而不是自己编写相应的函数。尽管你可能想查看库函数的源代码(HLA 中提供了源代码),但大多数字符串函数对于通用字符串数据的处理都很不错。

  • 虽然理论上你不应该依赖 HLA 字符串数据格式中显式的长度字段,但大多数程序会从字符串数据之前的 4 个字节获取长度,因此通常不需要保存长度。细心的 HLA 程序员实际上会调用 HLA 标准库中的 str.len() 函数,并将此值保存在局部变量中以供将来使用。然而,直接访问长度通常是安全的。

  • 避免将字符串数据从一个字符串变量复制到另一个字符串变量中。在使用 HLA 字符串的程序中,这种操作是相对昂贵的。

10.1.5 基于描述符的字符串

迄今为止我们讨论的字符串格式将属性信息(即长度和终止字节)与字符数据一起保存在内存中。一种稍微灵活一些的方案是将这些信息保存在一个记录结构中,该结构称为描述符,它还包含指向字符数据的指针。考虑以下 Pascal/Delphi 数据结构(参见图 10-5):


			type
    dString = record
        curLength  :integer;
        strData    :^char;
    end;

图片

图 10-5:字符串描述符

请注意,这个数据结构并不保存实际的字符数据。相反,strData指针包含字符串第一个字符的地址。curLength字段指定字符串的当前长度。您可以根据需要向该记录添加其他字段,比如最大长度字段,尽管通常不需要最大长度,因为大多数采用描述符的字符串格式都是动态的(如下一节将讨论的那样)。

基于描述符的字符串系统的一个有趣特性是,与字符串相关的实际字符数据可能是一个更大字符串的一部分。由于实际字符数据中没有长度或终止字节,因此两个字符串的字符数据可以重叠(参见图 10-6)。

图片

图 10-6:使用描述符的重叠字符串

这个例子展示了两个重叠的字符串——"Hello World""World"。这样可以节省内存,并使某些函数,如substring(),非常高效。当然,当字符串像这样重叠时,您不能修改字符串数据,因为那样可能会破坏其他字符串的一部分。

针对其他字符串格式的建议并不完全适用于基于描述符的字符串。确实,如果有标准库可用,您应该调用那些函数,因为它们可能比您自己编写的函数更高效。不需要保存长度,因为从字符串的描述符中提取长度字段通常是一个小任务。此外,许多基于描述符的字符串系统使用写时复制(参见WGC1和第 317 页的“动态字符串”部分)来减少字符串复制的开销。在字符串描述符系统中,您应避免修改字符串,因为写时复制语义通常要求系统在您更改单个字符时对整个字符串进行完整复制(与其他字符串格式不同,这种操作通常是不必要的)。

10.2 静态、伪动态和动态字符串

在介绍了各种字符串数据格式之后,接下来需要考虑的是在哪里存储字符串数据。字符串可以根据系统何时以及何地分配存储来分类。它们有三种类别:静态字符串、伪动态字符串和动态字符串。

10.2.1 静态字符串

静态字符串是程序员在编写程序时选择其最大大小的字符串。Pascal 字符串和 Delphi短字符串属于这一类别。你在 C/C++中用来保存零终止字符串的字符数组也属于这一类别,固定长度的字符数组也在此类之中。考虑以下在 Pascal 中的声明:


			(* Pascal static string example *)

var
    //Max length will always be 255 characters.

    pascalString :string[255];

这里有一个 C/C++的例子:


			// C/C++ static string example:

//Max length will always be 255 characters (plus 0 byte).

char cString[256];

在程序运行时,无法增加这些静态字符串的最大大小,也无法减少它们所使用的存储空间;这些字符串对象在运行时将消耗 256 字节的存储空间,且不可改变。纯静态字符串的一个优点是编译器可以在编译时确定它们的最大长度,并隐式地将此信息传递给字符串函数,以便在运行时检查边界溢出。

10.2.2 伪动态字符串

伪动态字符串是系统通过调用像malloc()这样的内存管理函数在运行时为其分配存储空间的字符串。然而,一旦系统为该字符串分配了存储空间,字符串的最大长度就固定了。HLA 字符串通常属于这一类别。^(6) HLA 程序员通常会调用stralloc()函数为字符串变量分配存储空间,之后该字符串对象的长度将固定,无法更改。^(7)

10.2.3 动态字符串

动态字符串系统通常使用基于描述符的格式,每当创建新字符串或对现有字符串进行影响时,系统会自动为字符串对象分配足够的存储空间。在动态字符串系统中,像字符串赋值和子字符串提取这样的操作相对简单——通常它们只复制字符串描述符数据,因此这些操作很快。然而,正如在第 315 页“基于描述符的字符串”一节中所提到的,使用这种方式的字符串时,无法将数据存储回字符串对象中,因为这可能会修改系统中其他字符串对象的数据。

解决这个问题的方法是使用写时复制技术。每当一个字符串函数需要修改动态字符串中的字符时,该函数首先会复制一份字符串,然后对那份副本进行必要的修改。研究表明,写时复制语义可以提高许多典型应用程序的性能,因为像字符串赋值和子字符串提取(其实只是部分字符串赋值)这样的操作,比修改字符串内的字符数据要常见得多。这种方法唯一的缺点是,在内存中对字符串数据进行多次修改后,字符串堆区域可能会包含一些不再使用的字符数据。为了避免内存泄漏,采用写时复制的动态字符串系统通常会提供垃圾回收代码,扫描字符串堆区域,寻找过时的字符数据,以便将这些内存回收用于其他目的。不幸的是,根据使用的算法不同,垃圾回收可能非常缓慢。

注意

有关内存泄漏和垃圾回收的更多信息,请参见第九章。

10.3 字符串的引用计数

假设有两个字符串描述符(或指针)指向内存中相同的字符串数据。显然,在程序仍然使用另一个指针访问相同数据时,你不能释放与其中一个指针关联的存储空间。一个常见的解决方案是让程序员负责跟踪这些细节。不幸的是,随着应用程序变得更加复杂,这种方法往往会导致悬空指针、内存泄漏和其他与指针相关的问题。一个更好的解决方案是允许程序员释放字符串中字符数据的存储空间,并且让实际的释放过程推迟,直到程序员释放掉最后一个引用该数据的指针。为了实现这一点,字符串系统可以使用引用计数器,它们跟踪指针及其关联的数据。

引用计数器是一个整数,用来统计内存中引用字符串字符数据的指针数量。每次将字符串的地址赋值给某个指针时,引用计数器加 1。同样,当你想要释放与字符串字符数据关联的存储空间时,引用计数器减 1。直到引用计数器减到 0,才会真正释放字符数据的存储空间。

引用计数在语言自动处理字符串赋值的细节时效果很好。如果你尝试手动实现引用计数,必须确保每次将字符串指针赋值给其他指针变量时,都要始终递增引用计数器。最好的做法是不要直接赋值指针,而是通过某个函数(或宏)调用来处理所有字符串赋值,这样不仅能复制指针数据,还能更新引用计数器。如果你的代码未能正确更新引用计数器,最终可能会出现悬空指针或内存泄漏。

10.4 Delphi 字符串

尽管 Delphi 提供了与早期版本 Delphi 和 Turbo Pascal 中的长度前缀字符串兼容的“短字符串”格式,但从 Delphi v4.0 版本开始,后续版本使用动态字符串作为其本地字符串格式。虽然这种字符串格式未公开(因此可能会发生变化),但有迹象表明,Delphi 的字符串格式与 HLA 非常相似。Delphi 使用一个零终止的字符序列,前面有字符串长度和引用计数器(而不是像 HLA 使用的最大长度)。图 10-7 显示了 Delphi 字符串在内存中的布局。

Image

图 10-7:Delphi 字符串数据格式

与 HLA 一样,Delphi 字符串变量是指针,指向实际字符串数据的第一个字符地址。为了访问长度和引用计数字段,Delphi 字符串例程使用从字符数据基地址偏移的负值 -4 和 -8。然而,由于这种字符串格式并未公开,应用程序不应直接访问长度或引用计数字段(例如,这些字段将来可能会变为 64 位值)。Delphi 提供了一个长度函数来提取字符串长度,而你的应用程序实际上无需访问引用计数字段,因为 Delphi 字符串函数会自动维护它。

10.5 在高级语言中使用字符串

字符串是高级编程语言中非常常见的数据类型。由于应用程序经常大量使用字符串数据,许多高级编程语言提供了包含大量复杂字符串操作例程的库,这些操作例程将相当复杂的细节隐藏在程序员的视野之外。不幸的是,当你执行像下面这样的语句时,很容易忽视典型字符串操作所涉及的工作量:

aLengthPrefixedString := 'Hello World';

在典型的 Pascal 实现中,这个赋值语句会调用一个函数,将字符串字面量的每个字符复制到为 aLengthPrefixedString 变量保留的存储空间中。也就是说,这个语句大致展开为如下:


			(* Copy the characters in the string *)

    for i:= 1 to length( HelloWorldLiteralString ) do begin

        aLengthPrefixedString[ i ] :=
            HelloWorldLiteralString[ i ];

    end;

    (* Set the string's length *)

    aLengthPrefixedString[0] :=
        char( length( HelloWorldLiteralString ));

这段代码甚至不包括过程调用、返回和参数传递的开销。正如本章各处提到的,复制字符串数据是程序中常见的昂贵操作之一。这就是为什么许多高级语言(HLL)已转向动态字符串和写时复制语义——当你仅复制指针而不是所有字符数据时,字符串赋值要高效得多。这并不是说写时复制总是更好,但对于许多字符串操作——比如赋值、子字符串操作和其他不会改变字符串字符数据的操作——它可以非常高效。

尽管很少有编程语言允许你选择想要使用的字符串格式,但许多语言允许你创建指向字符串的指针,因此你可以手动支持写时复制。如果你愿意编写自己的字符串处理函数,通过避免使用语言内置的字符串处理功能,你可以创建一些非常高效的程序。例如,C 语言中的子字符串操作通常由strncpy()函数处理,通常像这样实现:^(8)


			char *
strncpy( char* dest, char *src, int max )
{
    char *result = dest;
    while( max > 0 )
    {
        *dest = *src++;
        if( *dest++ == '\0') break;
        --max;
    }
    return result;
}

一个典型的“子字符串”操作可能会像下面这样使用strncpy()


			strncpy( substring, fullString + start, length );
substring[ length ] = '\0';

其中 substring 是目标字符串对象,fullString 是源字符串,start 是要复制的子字符串的起始索引,length 是要复制的子字符串的长度。

如果你使用 C 语言中的struct创建一个基于描述符的字符串格式,类似于《基于描述符的字符串》一章中“描述符式字符串”部分提到的 HLA 记录,在 C 语言中,你可以通过以下两个语句进行子字符串操作:


			// Assumption: ".strData" field is char*

    substring.strData = fullString.strData + start;
    substring.curLength = length;

这段代码的执行速度比strncpy()版本要快得多。

有时,某些编程语言不会提供访问其支持的底层字符串数据表示的功能,你将不得不忍受性能损失、切换语言,或者编写自己的汇编语言字符串处理代码。然而,通常来说,在应用程序中有替代方法来避免复制字符串数据,比如使用字符串描述符,就像刚才的示例所示。

10.6 Unicode 字符数据在字符串中的应用

到目前为止,我们假设字符串中的每个字符都占用精确的 1 字节存储空间。我们还假设在讨论字符串中的字符数据时使用的是 7 位 ASCII 字符集。传统上,这一直是编程语言表示字符串字符数据的方式。然而,今天,ASCII 字符集对于全球使用来说太有限了,许多新的字符集已经逐渐流行起来,其中包括 Unicode 的变体:UTF-8、UTF-16、UTF-32 和 UTF-7。因为这些字符格式可能对操作它们的字符串函数的效率产生重大影响,我们将花一些时间讨论它们。

10.6.1 Unicode 字符集

几十年前,Aldus、NeXT、Sun、Apple Computer、IBM、Microsoft、研究图书馆集团和 Xerox 的工程师意识到,他们的新计算机系统支持位图和用户可选择字体,可以同时显示比 256 个字符更多的字符。当时,双字节字符集(DBCSs)是最常见的解决方案。然而,DBCSs 有几个问题。首先,作为典型的可变长度编码,DBCSs 需要特殊的库代码;依赖固定长度字符编码的常见字符/字符串算法在它们上面无法正常工作。其次,没有统一的标准——不同的 DBCSs 对不同的字符使用相同的编码。因此,为了避免这些兼容性问题,工程师们寻找了另一种解决方案。

他们想出的解决方案是 Unicode 字符集。最初开发 Unicode 的工程师选择了 2 字节字符大小。像 DBCSs 一样,这种方法仍然需要特殊的库代码(现有的单字节字符串函数并不总是能与 2 字节字符兼容),但除了改变字符的大小外,大多数现有的字符串算法仍然可以与 2 字节字符一起使用。Unicode 的定义包括了当时所有的(已知/在用的)字符集,为每个字符分配了唯一的编码,以避免不同 DBCSs 所困扰的一致性问题。

原始的 Unicode 标准使用 16 位字表示每个字符。因此,Unicode 支持最多 65,536 个不同的字符编码——相比 8 位字节能够表示的 256 个编码,这是一个巨大的进步。此外,Unicode 向下兼容 ASCII。如果 Unicode 字符的高 9 位^(9)的二进制表示中包含0,则低 7 位使用标准 ASCII 码。如果高 9 位包含非零值,则 16 位形成扩展字符编码(即从 ASCII 扩展过来)。如果你在想为什么需要这么多不同的字符编码,值得注意的是,当时某些亚洲字符集包含了 4,096 个字符。Unicode 字符集甚至提供了一组编码,可以用来创建自定义的字符集。65,536 个可能的字符编码中,大约一半已经被定义,剩余的字符编码保留用于未来扩展。

今天,Unicode 已经成为一个通用字符集,长期取代了 ASCII 和旧版的双字节字符集(DBCS)。所有现代操作系统(包括 macOS、Windows、Linux、iOS、Android 和 Unix)、网页浏览器以及大多数现代应用程序都支持 Unicode。Unicode 联盟是一个非营利机构,负责维护 Unicode 标准。通过维护该标准,Unicode 公司(*home.unicode.org)帮助确保你在一个系统上写下的字符能够在不同的系统或应用程序中按预期显示。

10.6.2 Unicode 代码点

可惜的是,尽管最初的 Unicode 标准设计得非常周到,但它无法预见到字符的激增。表情符号、星座符号、箭头、指针以及为互联网、移动设备和网页浏览器引入的各种符号大大扩展了 Unicode 符号的范围(同时也带来了对历史、过时和稀有文字的支持需求)。1996 年,系统工程师发现 65,536 个符号已经不够用了。为了避免每个 Unicode 字符需要 3 或 4 个字节,负责 Unicode 定义的人员放弃了创建固定大小字符表示的想法,转而允许 Unicode 字符使用不透明的(且可变的)编码。如今,Unicode 定义了 1,112,064 个代码点,远超最初为 Unicode 字符设置的 2 字节容量。

Unicode 代码点 只是一个整数值,Unicode 将其与特定字符符号关联;你可以将其视为字符的 Unicode 等价物,类似于 ASCII 代码。Unicode 代码点的约定是以十六进制表示,并以 U+ 前缀指定;例如,U+0041 是字母 A 的 Unicode 代码点。

注意

查看更多关于代码点的详细信息,请访问 en.wikipedia.org/wiki/Unicode#General_Category_property

10.6.3 Unicode 编码平面

由于历史原因,Unicode 中的 65,536 个字符块具有特殊意义——它们被称为多语言平面。第一个多语言平面,U+000000U+00FFFF,大致对应于最初的 16 位 Unicode 定义;Unicode 标准将其称为基本多语言平面(BMP)。平面 1(U+010000U+01FFFF)、平面 2(U+020000U+02FFFF)和平面 14(U+0E0000U+0EFFFF)是补充平面。Unicode 保留平面 3 到 13 供未来扩展,平面 15 和 16 用于用户自定义字符集。

Unicode 标准定义了范围为 U+000000U+10FFFF 的代码点。请注意,0x10FFFF 是 1,114,111,这是 Unicode 字符集中的大部分 1,112,064 个字符的来源;其余的 2,048 个代码点被保留用于作为替代符,即 Unicode 扩展。你可能还会听到另一个术语——Unicode 标量;这是指来自所有 Unicode 代码点集合的值,但不包括那 2,048 个替代符代码点。六位数的代码点值中的前两位十六进制数字指定了多语言平面。为什么有 17 个平面?原因是,正如你将看到的,Unicode 使用特殊的多字条目来编码 U+FFFF 以外的代码点。每个扩展编码 10 位,共 20 位;20 位可以表示 16 个多语言平面,加上原始的 BMP,总共 17 个多语言平面。这也是为什么代码点范围是 U+000000U+10FFFF:编码这 16 个多语言平面加上 BMP 需要 21 位。

10.6.4 代理代码点

如前所述,Unicode 最初是一个 16 位(2 字节)字符集编码。当 16 位的编码容量显然不足以处理当时所有可能的字符时,便需要扩展。从 Unicode v2.0 开始,Unicode, Inc. 组织扩展了 Unicode 的定义,以包括多字字符。现在 Unicode 使用代理代码点(U+D800U+DFFF)来编码大于 U+FFFF 的值。图 10-8 显示了编码方式。

Image

图 10-8:Unicode 平面 1 到 16 的代理代码点编码

请注意,两个单词(单元 1/高代理和单元 2/低代理)总是一起出现。单元 1 的值(带有 HO 位 %110110)指定 Unicode 标量的上 10 位(b[10]..b[19]),而单元 2 的值(带有 HO 位 %110111)指定 Unicode 标量的下 10 位(b[0]..b[9])。因此,位 b[16]..b[19] 加 1 的值指定 Unicode 平面 1 到 16。位 b[0]..b[15] 指定平面内的 Unicode 标量值。

注意,代理代码仅出现在基本多文种平面(BMP)中。其他多语言平面不包含代理代码。位 b[0]..b[19] 从单元 1 和 2 提取的值始终指定一个 Unicode 标量值(即使该值落在 U+D800U+DFFF 范围内)。

10.6.5 字形、字符和字形簇

每个 Unicode 码点都有一个唯一的名称。例如,U+0045 的名称是 “LATIN CAPITAL LETTER A”。请注意,符号 A 不是字符的名称。A 是一个 字形——设备通过绘制一系列笔画(一个水平笔画和两个倾斜笔画)来表示该字符。

单一的 Unicode 字符 “LATIN CAPITAL LETTER A” 有许多不同的字形。例如,Times Roman A 和 Times Roman Italic A 有不同的字形,但 Unicode 不区分它们(也不区分 A 字符在任何两种不同字体中的差异)。字符 “LATIN CAPITAL LETTER A” 始终是 U+0045,无论你使用何种字体或样式来绘制它。

顺便提一句,如果你有使用 Swift 编程语言的权限,你可以通过以下代码打印任何 Unicode 字符的名称:


			import Foundation
let charToPrintName  :String = "A"      // Print name of this character

let unicodeName =
    String(charToPrintName).applyingTransform(
        StringTransform(rawValue: "Any-Name"),
        reverse: false
    )! // Forced unwrapping is legit here because it always succeeds.
print( unicodeName )

Output from program:
\N{LATIN CAPITAL LETTER A}

那么,Unicode 中的字符究竟是什么?Unicode 标量是 Unicode 字符,但通常所说的字符和 Unicode 字符(标量)的定义之间是有区别的。例如,é 是一个字符还是两个字符?考虑以下 Swift 代码:


			import Foundation
let eAccent  :String = "e\u{301}"
print( eAccent )
print( "eAccent.count=\(eAccent.count)" )
print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

"\u{301}" 是 Swift 语法中指定字符串内 Unicode 标量值的方式;在这个特定的例子中,301组合尖音符 字符的十六进制代码。

第一个 print 语句:

print( eAccent )

打印该字符(输出 é,正如我们所期望的)。

第二个 print 语句打印出 Swift 确定字符串中包含的字符数量:

print( "eAccent.count=\(eAccent.count)" )

这会在标准输出中打印 1

第三个print语句打印字符串中的元素数量(UTF-16 元素^(10)):

print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

这在标准输出中打印 2,因为字符串包含 2 个 UTF-16 数据单元。

那么,这到底是一个字符还是两个字符呢?在内部(假设使用 UTF-16 编码),计算机为这个单一字符分配了 4 字节的内存(两个 16 位 Unicode 标量值)。^(11) 然而,在屏幕上,输出仅占用一个字符位置,并且看起来就像是一个字符。当这个字符出现在文本编辑器中,并且光标紧挨字符的右侧时,用户期望按下退格键时能够删除它。从用户的角度来看,这确实是一个字符(就像当你打印字符串的 count 属性时 Swift 所报告的那样)。

然而,在 Unicode 中,字符大体上等同于一个代码点。这并不是人们通常所认为的字符。在 Unicode 术语中,字素簇 是人们通常所称为字符的东西——它是一个或多个 Unicode 代码点的序列,组合成一个单一的语言元素(即单一字符)。因此,当我们谈论应用程序向终端用户展示的符号时,实际上是在谈论字素簇。

字素簇(Grapheme clusters)可能会让软件开发者感到头痛。考虑以下的 Swift 代码(对之前示例的修改):


			import Foundation
let eAccent  :String = "e\u{301}\u{301}"
print( eAccent )
print( "eAccent.count=\(eAccent.count)" )
print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

这段代码从前两个 print 语句中产生相同的 é1 输出。以下代码输出 é

print( eAccent )

而这个 print 语句输出的是 1

print( "eAccent.count=\(eAccent.count)" )

然而,第三个 print 语句:

print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

显示 3 而不是原示例中的 2

这个字符串中无疑有三个 Unicode 标量值(U+0065U+0301U+0301)。在打印时,操作系统将字符 e 和两个急音符组合字符组合成单一字符 é,然后将该字符输出到标准输出设备。Swift 足够聪明,知道这个组合会在显示器上生成一个单一输出符号,因此打印 count 属性的结果仍然输出 1。然而,这个字符串中确实包含(不可否认的)三个 Unicode 代码点,因此打印 utf16.count 时输出的是 3

10.6.6 Unicode 标准与规范等价性

Unicode 字符 é 实际上早在 Unicode 出现之前就已经存在于个人电脑中。它是原始 IBM PC 字符集的一部分,也是 Latin-1 字符集的一部分(例如在旧款 DEC 终端上使用)。事实证明,Unicode 使用 Latin-1 字符集来表示从 U+00A0U+00FF 范围内的代码点,而 U+00E9 正好对应于 é 字符。因此,我们可以像之前那样修改程序:


			import Foundation
let eAccent  :String = "\u{E9}"
print( eAccent )
print( "eAccent.count=\(eAccent.count)" )
print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

以下是该程序的输出:


			é
1
1

哎呀!三个不同的字符串都产生 é,但包含不同数量的代码点。想象一下,这将如何使得包含 Unicode 字符的编程字符串更加复杂。例如,如果你有以下三个字符串(Swift 语法),并且尝试比较它们,结果会是什么?


			let eAccent1 :String = "\u{E9}"
let eAccent2 :String = "e\u{301}"
let eAccent3 :String = "e\u{301}\u{301}"

对用户来说,这三种字符串在屏幕上看起来是相同的。然而,它们明显包含不同的值。如果你比较它们看看是否相等,结果是true还是false

最终,这取决于你使用的字符串库。大多数当前的字符串库在比较这些字符串是否相等时会返回 false。有趣的是,Swift 会声称 eAccent1 等于 eAccent2,但它不够聪明,无法报告 eAccent1 等于 eAccent3,或者 eAccent2 等于 eAccent3——尽管它为所有三个字符串显示相同的符号。许多语言的字符串库简单地报告这三个字符串都不相等。

这三个 Unicode/Swift 字符串 "\u{E9}""e\u{301}""e\u{301}\u{301}" 在显示上都产生相同的输出。因此,根据 Unicode 标准,它们是规范等价的。一些字符串库不会报告这些字符串中的任何一个是相等的。像 Swift 附带的字符串库会处理小的规范等价性(例如,"\u{E9}" == "e\u{301}"),但不会处理那些应该等价的任意序列(可能是正确性与效率之间的一个良好平衡;处理所有那些通常不会发生的奇怪情况(如 "e\u{301}\u{301}")可能在计算上代价较高)。

Unicode 定义了正常形式用于 Unicode 字符串。正常形式的一个方面是将规范等价的序列替换为等价序列——例如,将 "e\u{309}" 替换为 "\u{E9}" 或将 "\u{E9}" 替换为 "e\u{309}"(通常,较短的形式更为优选)。一些 Unicode 序列允许多个组合字符。通常,组合字符的顺序对于产生期望的字形聚集体是无关紧要的。然而,如果组合字符按指定顺序排列,比较两个这样的字符串会更容易。规范化 Unicode 字符串还可能产生其组合字符总是按固定顺序出现的结果(从而提高字符串比较的效率)。

10.6.7 Unicode 编码

从 Unicode v2.0 起,标准支持一个 21 位的字符空间,能够处理超过百万个字符(尽管大多数代码点仍保留供未来使用)。为了允许更大的字符集,Unicode, Inc. 允许不同的编码——UTF-32、UTF-16 和 UTF-8——每种编码都有自己的优缺点。^(12)

UTF-32 使用 32 位整数来存储 Unicode 标量值。这种方案的优点在于,32 位整数可以表示每个 Unicode 标量值(只需要 21 位)。需要随机访问字符串中的字符的程序——无需查找代理对——以及其他常数时间操作,在使用 UTF-32 时(大多)是可能的。UTF-32 的明显缺点是,每个 Unicode 标量值需要 4 个字节的存储空间——是原始 Unicode 定义的两倍,ASCII 字符的四倍。看起来使用两倍或四倍的存储空间(相比 ASCII 和原始 Unicode)似乎是一个小代价。毕竟,现代计算机的存储容量比 Unicode 刚出现时大了几个数量级。然而,这额外的存储空间对性能有巨大影响,因为这些额外的字节会迅速占用缓存存储空间。此外,现代字符串处理库通常每次操作 8 字节(在 64 位机器上)。对于 ASCII 字符,这意味着给定的字符串函数可以并发处理最多 8 个字符;而对于 UTF-32,相同的字符串函数只能并发处理 2 个字符。因此,UTF-32 版本的运行速度会比 ASCII 版本慢四倍。最终,甚至 Unicode 标量值也不足以表示所有 Unicode 字符(即,许多 Unicode 字符需要一系列 Unicode 标量),所以使用 UTF-32 并不能解决这个问题。

Unicode 支持的第二种编码格式是 UTF-16。顾名思义,UTF-16 使用 16 位(无符号)整数来表示 Unicode 值。为了处理大于 0xFFFF 的标量值,UTF-16 使用代理对方案来表示范围从 0x0100000x10FFFF 的值(请参见 第 323 页的“代理代码点”)。因为绝大多数有用字符适合 16 位表示,所以大多数 UTF-16 字符只需要 2 个字节。对于那些需要代理的少数情况,UTF-16 需要 2 个字(32 位)来表示该字符。

最后一种编码方式,毫无疑问是最流行的,是 UTF-8。UTF-8 编码与 ASCII 字符集向前兼容。特别是,所有 ASCII 字符都有单字节表示(它们原始的 ASCII 码,其中包含字符的字节的 HO 位为 0)。如果 UTF-8 的 HO 位为 1,那么 UTF-8 需要 1 到 3 个附加字节来表示 Unicode 代码点。表格 10-1 提供了 UTF-8 编码方案。

表格 10-1: UTF 编码

字节数 代码点的位数 第一个代码点 最后一个代码点 字节 1 字节 2 字节 3 字节 4
1 7 U+00 U+7F 0xxxxxxx
2 11 U+80 U+7FF 110xxxxx 10xxxxxx
3 16 U+800 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
4 21 U+10000 U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

“xxx . . . ”位是 Unicode 代码点位。对于多字节序列,第 1 字节包含高位位,第 2 字节包含下一个高位位(与字节 1 的低位相比),依此类推。例如,2 字节序列(%11011111%10000001)对应的 Unicode 标量是%0000_0111_1100_0001U+07C1)。

UTF-8 编码可能是最常用的编码。大多数网页使用它。大多数 C 标准库字符串函数将处理 UTF-8 文本而无需修改(尽管一些 C 标准库函数可能会在程序员不小心的情况下生成格式错误的 UTF-8 字符串)。

不同的语言和操作系统使用不同的编码作为默认编码。例如,macOS 和 Windows 通常使用 UTF-16 编码,而大多数 Unix 系统使用 UTF-8。Python 的一些变种使用 UTF-32 作为其本地字符格式。不过,总体而言,大多数编程语言使用 UTF-8,因为它们可以继续使用基于 ASCII 的旧字符处理库来处理 UTF-8 字符。Apple 的 Swift 是最早尝试正确处理 Unicode 的编程语言之一(尽管这样做会带来巨大的性能损失)。

10.6.8 Unicode 组合字符

尽管 UTF-8 和 UTF-16 编码比 UTF-32 更加紧凑,但处理多字节(或多字)字符集的 CPU 开销和算法复杂性使得它们的使用变得复杂(可能引入错误和性能问题)。尽管浪费内存(特别是在缓存中)是个问题,为什么不直接将字符定义为 32 位实体,然后就此了事呢?这看起来能简化字符串处理算法,提高性能,并减少代码中的缺陷概率。

这个理论的问题在于,仅用 21 位(甚至 32 位)的存储空间无法表示所有可能的字形簇。许多字形簇由几个连接在一起的 Unicode 代码点组成。以下是 Chris Eidhof 和 Ole Begemann 的高级 Swift(CreateSpace,2017 年)中的一个示例:


			let chars: [Character] = [
    "\u{1ECD}\u{300}",
    "\u{F2}\u{323}",
    "\u{6F}\u{323}\u{300}",
    "\u{6F}\u{300}\u{323}"
]

每个 Unicode 字形簇都会产生一个相同的字符:带下点的ó字符(这是来自约鲁巴字符集的字符)。字符序列(U+1ECDU+300)是带下点的o,后面跟着一个组合尖音符。字符序列(U+F2U+323)是ó后跟一个组合点。字符序列(U+6FU+323U+300)是o,后跟一个组合点,再后跟一个组合尖音符。最后,字符序列(U+6FU+300U+323)是o,后跟一个组合尖音符,再后跟一个组合点。所有四个字符串都会产生相同的输出。事实上,Swift 字符串比较将这四个字符串视为相等:


			print("\u{1ECD} + \u{300} = \u{1ECD}\u{300}")
print("\u{F2} + \u{323} = \u{F2}\u{323}")
print("\u{6F} + \u{323} + \u{300} = \u{6F}\u{323}\u{300}")
print("\u{6F} + \u{300} + \u{323} = \u{6F}\u{300}\u{323}")
print( chars[0] == chars[1] ) // Outputs true
print( chars[0] == chars[2] ) // Outputs true
print( chars[0] == chars[3] ) // Outputs true
print( chars[1] == chars[2] ) // Outputs true
print( chars[1] == chars[3] ) // Outputs true
print( chars[2] == chars[3] ) // Outputs true

请注意,并没有单一的 Unicode 标量值可以产生这个字符。你必须至少结合两个 Unicode 标量(或者最多三个)才能在输出设备上显示这个字形簇。即使使用 UTF-32 编码,仍然需要两个(32 位)标量来产生这个特定的输出。

表情符号提出了另一个挑战,无法通过 UTF-32 解决。考虑 Unicode 标量 U+1F471。它显示的是一个金发的人的表情符号。如果我们在此基础上添加皮肤颜色修饰符,则得到(U+1F471U+1F3FF),显示的是一个深色肤色(并且有金发)的人。在这两种情况下,屏幕上显示的是一个单一字符。第一个例子使用一个 Unicode 标量值,而第二个例子则需要两个。没有办法用单个 UTF-32 值来编码这个字符。

最终的结论是,某些 Unicode 字形簇将需要多个标量,不管我们为标量分配多少位(例如,可能将 30 或 40 个标量组合成一个字形簇)。这意味着我们不得不处理多个单词序列来表示一个单一的“字符”,无论我们如何努力避免这种情况。这就是为什么 UTF-32 从未真正流行的原因。它并没有解决随机访问 Unicode 字符串的问题。如果你必须处理 Unicode 标量的归一化和组合,使用 UTF-8 或 UTF-16 编码会更高效。

再次强调,如今大多数编程语言和操作系统都以某种形式支持 Unicode(通常使用 UTF-8 或 UTF-16 编码)。尽管处理多字节字符集存在明显问题,但现代程序需要处理 Unicode 字符串,而不是简单的 ASCII 字符串。几乎“纯 Unicode”的 Swift 甚至没有提供多少标准 ASCII 字符支持。

10.7 Unicode 字符串函数与性能

Unicode 字符串有一个根本问题:由于 Unicode 是一种多字节字符集,字符字符串中的字节数并不等于字符串中的字符数(或者更重要的,字形数)。不幸的是,确定字符串长度的唯一方法是扫描字符串中的所有字节(从开始到结束),并计算这些字符。在这方面,Unicode 字符串长度函数的性能将与字符串的大小成正比,就像零终止字符串一样。

更糟糕的是,计算字符串中字符位置的索引(即,从字符串开始处的字节偏移量)的唯一方法是从字符串的开头扫描,并计算所需的字符数。即使是零终止(ASCII)字符串也不会遇到这个问题。在 Unicode 中,像子字符串或插入/删除字符这样的函数可能非常昂贵。

Swift 标准库的字符串函数性能受限于语言的 Unicode 纯净性。Swift 程序员在处理字符串时必须小心,因为在 C/C++ 或其他语言中通常很快的操作,在 Swift 的 Unicode 环境下可能会成为性能问题的根源。

10.8 更多信息

Hyde, Randall. 汇编语言艺术. 第二版. 旧金山:No Starch Press, 2010。

———. 编写高效代码,第 1 卷:理解计算机. 第二版. 旧金山:No Starch Press, 2020。

第十一章:记录、联合体和类数据类型

image

记录、联合体和类是许多现代编程语言中常见的复合数据类型。使用不当时,这些数据类型可能对软件性能产生非常负面的影响。然而,正确使用时,它们实际上可以提高应用程序的性能(与使用替代数据结构相比)。在本章中,我们将探讨如何充分利用这些数据类型,以最大化程序的效率。本章涵盖的主题包括:

  • 记录、联合体和类数据类型的定义

  • 各种语言中记录、联合体和类的声明语法

  • 记录变量和实例化

  • 记录的编译时初始化

  • 记录、联合体和类数据的内存表示

  • 使用记录来提高运行时内存性能

  • 动态记录类型

  • 命名空间

  • 变体数据类型及其作为联合体的实现

  • 类的虚方法表及其实现

  • 类中的继承与多态

  • 类和对象相关的性能开销

在我们深入探讨如何实现这些数据类型以生成更高效、更易读和更易维护的代码之前,先从一些定义开始。

11.1 记录

Pascal 中的记录和 C/C++中的结构是用于描述可比的复合数据结构的术语。语言设计教材有时将这些类型称为笛卡尔积元组。Pascal 的术语可能是最好的,因为它避免了与数据结构一词的混淆,因此我们在这里使用记录。无论你称其为何种名称,记录都是组织应用程序数据的一个极好工具,对语言如何实现它们有良好的理解将帮助你编写更高效的代码。

数组是同质的,意味着其元素都是相同类型的。另一方面,记录是异质的——其元素可以具有不同类型。记录的目的是将逻辑相关的值封装为一个对象。

数组让你通过整数索引选择特定元素。使用记录时,你必须通过字段的名称选择一个元素,该元素称为字段。记录中的每个字段名称必须唯一;也就是说,不能在同一个记录中使用相同的名称多次。然而,所有字段名称仅限于其记录,因此你可以在程序中的其他地方重复使用这些名称。^(1)

11.1.1 在各种语言中声明记录

在讨论各种语言如何实现记录数据类型之前,我们将快速查看其中一些语言的声明语法,包括 Pascal、C/C++/C#、Swift 和 HLA。

11.1.1.1 Pascal/Delphi 中的记录声明

这是 Pascal/Delphi 中student数据类型的典型记录声明:


			type
    student =
        record
            Name:     string [64];
            Major:    smallint;    // 2-byte integer in Delphi
            SSN:      string[11];
            Mid1:     smallint;
            Mid2:     smallint;
            Final:    smallint;
            Homework: smallint;
            Projects: smallint;
        end;

记录声明由关键字 record 开始,后跟一系列 字段声明,并以关键字 end 结束。字段声明的语法与 Pascal 语言中的变量声明完全相同。

许多 Pascal 编译器将所有字段分配到连续的内存位置。这意味着 Pascal 会为名字预留前 65 字节,接下来的 2 字节存储主代码,接下来的 12 字节存储社会安全号码,依此类推。

11.1.1.2 C/C++ 中的记录声明

这是 C/C++ 中相同的声明:


			typedef
    struct
    {
        // Room for a 64-character zero-terminated string:

        char Name[65];

        // Typically a 2-byte integer in C/C++:

        short Major;

        // Room for an 11-character zero-terminated string:

        char SSN[12];

        short Mid1;
        short Mid2;
        short Final;
        short Homework;
        short Projects;

    } student;

C/C++ 中的记录(结构体)声明以关键字 typedef 开头,接着是 struct 关键字,一对大括号括起来的 字段声明,以及结构体名称。与 Pascal 类似,大多数 C/C++ 编译器按照字段在记录中的声明顺序为字段分配内存偏移量。

11.1.1.3 C# 中的记录声明

C# 结构体声明与 C/C++ 非常相似:


			struct student
 {
    // Room for a 64-character zero-terminated string:

    public char[] Name;

    // Typically a 2-byte integer in C/C++:

    public short Major;

    // Room for an 11-character zero-terminated string:

    public char[] SSN;

    public short Mid1;
    public short Mid2;
    public short Final;
    public short Homework;
    public short Projects;
 };

C# 中的记录(结构体)声明以关键字 struct 开头,结构体名称,后跟一对大括号括起来的 字段声明。与 Pascal 类似,大多数 C# 编译器按照字段在记录中的声明顺序为字段分配内存偏移量。

这个例子将 NameSSN 字段定义为字符数组,以匹配本章中的其他记录声明示例。在实际的 C# 程序中,你可能会想要使用 string 数据类型,而不是字符数组来表示这些字段。然而,请记住,C# 使用动态分配的数组;因此,C# 结构体的内存布局与 C/C++、Pascal 和 HLA 的布局有所不同。

11.1.1.4 Java 中的记录声明

Java 不支持纯粹的记录类型,但仅包含数据成员的类声明具有相同的作用(请参见 第 366 页 中的“C# 和 Java 中的类声明”部分)。

11.1.1.5 HLA 中的记录声明

在 HLA 中,你可以使用 record/endrecord 声明来创建记录类型。你可以像下面这样编码前面章节中的记录:


			type
    student:
        record
            sName:    char[65];
            Major:    int16;
            SSN:      char[12];
            Mid1:     int16;
            Mid2:     int16;
            Final:    int16;
            Homework: int16;
            Projects: int16;
        endrecord;

如你所见,HLA 声明与 Pascal 声明非常相似。请注意,为了与 Pascal 声明保持一致,这个例子使用字符数组,而不是字符串,来表示 sNameSSN(社会安全号码)字段。在典型的 HLA 记录声明中,你可能会使用 string 类型,至少对于 sName 字段(记住,字符串变量只是一个 4 字节的指针)。

11.1.1.6 Swift 中的记录(元组)声明

虽然 Swift 不支持记录的概念,但你可以使用 Swift 的 元组 来模拟记录。元组是一种创建复合/聚合数据类型的有用结构,不需要像类那样的开销。(不过请注意,Swift 不以与其他编程语言相同的方式在内存中存储记录/元组元素。)

Swift 元组仅仅是一个值的列表。从语法上讲,元组具有以下形式:


			( value[1], value[2], ..., value[n] )

元组中的值类型不必完全相同。

Swift 通常使用元组从函数返回多个值。考虑以下简短的 Swift 代码片段:


			func returns3Ints()->(Int, Int, Int )
{
    return(1, 2, 3)
}
var (r1, r2, r3) = returns3Ints();
print( r1, r2, r3 )

returns3Ints函数返回三个值(12,和3)。该语句

var (r1, r2, r3) = returns3Ints();

将这三个整数值分别存储到r1r2r3中。

你还可以将元组赋值给一个变量,并使用整数索引作为字段名称来访问元组的“字段”:


			let rTuple = ( "a", "b", "c" )
print( rTuple.0, rTuple.1, rTuple.2 ) // Prints "a b c"

使用像.0这样的字段名称是不推荐的,因为它会导致难以维护的代码。你可以通过元组创建记录,但在现实世界的程序中,使用整数索引引用字段通常不合适。

幸运的是,Swift 允许你为元组字段分配标签,并通过标签名称而不是整数索引来引用这些字段,可以通过typealias关键字实现:


			typealias record = ( field1:Int, field2:Int, field3:Float64 )

var r = record(1, 2, 3.0 )
print( r.field1, r.field2, r.field3 )  // prints "1 2 3.0"

请记住,元组在内存中的存储方式可能与其他语言中的记录或结构布局不完全相同。就像 Swift 中的数组一样,元组是一个不透明类型,没有保证 Swift 如何在内存中存储它们的定义。

11.1.2 实例化记录

通常,记录声明并不会为记录对象保留存储空间;相反,它指定了一个数据类型,作为声明记录变量时的模板。实例化指的是使用记录模板或类型来创建记录变量的过程。

考虑前一节中student的 HLA 类型声明。这个类型声明并没有为记录变量分配任何存储空间;它仅仅提供了记录对象使用的结构。要创建一个实际的student变量,你必须为记录变量预留一些存储空间,无论是在编译时还是运行时。在 HLA 中,你可以通过使用如下的变量声明在编译时为student对象预留存储空间:


			var
    automaticStudent :student;

static
    staticStudent :student;

var声明告诉 HLA 在程序进入当前过程时,为当前激活记录中的student对象保留足够的存储空间。static语句告诉 HLA 在静态数据区保留足够的存储空间给student对象;这在编译时完成。

你还可以使用内存分配函数动态分配记录对象的存储空间。例如,在 C 语言中,你可以使用malloc()像这样为student对象分配存储空间:


			student *ptrToStudent;
        .
        .
        .
    ptrToStudent = malloc( sizeof( student ));

记录只是(否则)不相关变量的集合。那么为什么不创建单独的变量呢?例如,在 C 语言中,为什么不直接写:


			// Room for a 64-character zero-terminated string:

char someStudent_Name[65];

// Typically a 2-byte integer in C/C++:

short someStudent_Major;

// Room for an 11-character zero-terminated string:

char someStudent_SSN[12];

short someStudent_Mid1;
short someStudent_Mid2;
short someStudent_Final;
short someStudent_Homework;
short someStudent_Projects;

这种方法并不理想的原因有几个。在软件工程方面,需要考虑维护问题。例如,如果你创建了多个student变量集,然后决定要添加一个字段,怎么办?现在你得回去编辑你创建的每一组声明——这可不是什么好事。然而,使用结构/记录声明时,你只需对类型声明进行一次更改,所有变量声明都会自动获得新字段。此外,考虑一下如果你想创建一个student对象的数组会发生什么。

撇开软件工程问题不谈,将不同的字段收集到一个记录中对于提高效率是一个好主意。许多编译器允许你将整个记录视为一个单一对象,用于赋值、参数传递等。例如,在 Pascal 中,如果你有两个student类型的变量s1s2,你可以使用一个赋值语句将一个student对象的所有值赋给另一个对象,像这样:

s2 := s1;

这不仅比分配单个字段更方便,而且编译器通常可以通过使用块移动操作生成更好的代码。考虑以下 C++代码及其相关的 x86 汇编语言输出:


			#include <stdio.h>

// A good-sized but otherwise arbitrary structure that
// demonstrates how a C++ compiler can handle structure
// assignments.

typedef struct
{
    int x;
    int y;
    char *z;
    int a[16];
}aStruct;

int main( int argc, char **argv )
{
    static aStruct s1;
    aStruct s2;
    int i;

    // Give s1 some nonzero values so
    // that the optimizer doesn't simply
    // substitute zeros everywhere fields
    // of s1 are referenced:

    s1.x = 5;
    s1.y = argc;
    s1.z = *argv;

    // Do a whole structure assignment
    // (legal in C++!)

    s2 = s1;

    // Make an arbitrary change to S2
    // so that the compiler's optimizer
    // won't eliminate the code to build
    // s2 and just use s1 because s1 and
    // s2 have the same values.

    s2.a[2] = 2;

    // The following loop exists, once again,
    // to thwart the optimizer from eliminating
    // s2 from the code:

    for( i=0; i<16; ++i)
    {
        printf( "%d\n", s2.a[i] );
    }

    // Now demonstrate a field-by-field assignment
    // so we can see the code the compiler generates:

    s1.y = s2.y;
    s1.x = s2.x;
    s1.z = s2.z;
    for( i=0; i<16; ++i )
    {
        s1.a[i] = s2.a[i];
    }
    for( i=0; i<16; ++i)
    {
        printf( "%d\n", s2.a[i] );
    }
    return 0;
}

下面是微软 Visual C++编译器生成的 x86-64 汇编代码的相关部分(使用/O2优化选项):


			; Storage for the s1 array in the BSS segment:

_BSS    SEGMENT
?s1@?1??main@@9@9 DB 050H DUP (?)                       ; `main'::`2'::s1
_BSS    ENDS
;
s2$1$ = 32
s2$2$ = 48
s2$3$ = 64
s2$ = 80
__$ArrayPad$ = 160
argc$ = 192
argv$ = 200

; Note: on entry to main, rcx = argc, rdx = argv

main    PROC                                            ; COMDAT
; File c:\users\rhyde\test\t\t\t.cpp
; Line 20
$LN27:
        mov     r11, rsp
        mov     QWORD PTR [r11+24], rbx
        push    rdi
;
; Allocate storage for the local variables
; (including s2):

        sub     rsp, 176                                ; 000000b0H
        mov     rax, QWORD PTR __security_cookie
        xor     rax, rsp
        mov     QWORD PTR __$ArrayPad$[rsp], rax

        xor     ebx, ebx   ; ebx = 0
        mov     edi, ebx  ; edi = 0

     ; s1.z = *argv
        mov     rax, QWORD PTR [rdx] ;rax = *argv
        mov     QWORD PTR ?s1@?1??main@@9@9+8, rax

     ; s1.x = 5
        mov     DWORD PTR ?s1@?1??main@@9@9, 5

     ;s1.y = argc
        mov     DWORD PTR ?s1@?1??main@@9@9+4, ecx
;     s2 = s1;
;
;       xmm1=s1.a[0..1]
        movaps  xmm1, XMMWORD PTR ?s1@?1??main@@9@9+16
        movaps  XMMWORD PTR s2$[rsp+16], xmm1 ;s2.a[0..1] = xmm1
        movaps  xmm0, XMMWORD PTR ?s1@?1??main@@9@9
        movaps  XMMWORD PTR s2$[rsp], xmm0
        movaps  xmm0, XMMWORD PTR ?s1@?1??main@@9@9+32
        movaps  XMMWORD PTR s2$[rsp+32], xmm0
        movups  XMMWORD PTR s2$1$[rsp], xmm0
        movaps  xmm0, XMMWORD PTR ?s1@?1??main@@9@9+48
        movaps  XMMWORD PTR [r11-56], xmm0
        movups  XMMWORD PTR s2$2$[rsp], xmm0
        movaps  xmm0, XMMWORD PTR ?s1@?1??main@@9@9+64
        movaps  XMMWORD PTR [r11-40], xmm0
        movups  XMMWORD PTR s2$3$[rsp], xmm0

    ; s2.a[2] = 2

        mov     DWORD PTR s2$[rsp+24], 2
        npad    14

;    for (i = 0; i<16; ++i)
;    {

$LL4@main:
; Line 53
        mov     edx, DWORD PTR s2$[rsp+rdi*4+16]
        lea     rcx, OFFSET FLAT:??_C@_03PMGGPEJJ@?$CFd?6?$AA@
        call    printf
        inc     rdi
        cmp     rdi, 16
        jl      SHORT $LL4@main

.;     } //endfor

; Line 59 // s1.y = s2.y
        mov     eax, DWORD PTR s2$[rsp+4]
        mov     DWORD PTR ?s1@?1??main@@9@9+4, eax

       ;s1.x = s2.x
        mov     eax, DWORD PTR s2$[rsp]
        mov     DWORD PTR ?s1@?1??main@@9@9, eax

       ; s1.z = s2.z
        mov     rax, QWORD PTR s2$[rsp+8]
        mov     QWORD PTR ?s1@?1??main@@9@9+8, rax

;    for (i = 0; i<16; ++i)
;    {
;        printf("%d\n", s2.a[i]);
;    }
; Line 64
        movups  xmm1, XMMWORD PTR s2$1$[rsp]
        movaps  xmm0, XMMWORD PTR s2$[rsp+16]
        movups  XMMWORD PTR ?s1@?1??main@@9@9+32, xmm1
        movups  xmm1, XMMWORD PTR s2$3$[rsp]
        movups  XMMWORD PTR ?s1@?1??main@@9@9+16, xmm0
        movups  xmm0, XMMWORD PTR s2$2$[rsp]
        movups  XMMWORD PTR ?s1@?1??main@@9@9+64, xmm1
        movups  XMMWORD PTR ?s1@?1??main@@9@9+48, xmm0
        npad    7

$LL10@main:
; Line 68
        mov     edx, DWORD PTR s2$[rsp+rbx*4+16]
        lea     rcx, OFFSET FLAT:??_C@_03PMGGPEJJ@?$CFd?6?$AA@
        call    printf
        inc     rbx
        cmp     rbx, 16
        jl      SHORT $LL10@main

; Return 0
; Line 70
        xor     eax, eax
; Line 71
        mov     rcx, QWORD PTR __$ArrayPad$[rsp]
        xor     rcx, rsp
        call    __security_check_cookie
        mov     rbx, QWORD PTR [rsp+208]
        add     rsp, 176                                ; 000000b0H
        pop     rdi
        ret     0
main    ENDP

这个例子中需要注意的一个重要点是,Visual C++编译器在你分配整个结构时会发出一系列movapsmovups指令。然而,当你进行逐字段分配两个结构时,它可能会退化为针对每个字段的单独mov指令序列。同样,如果你没有将所有字段封装到一个结构中,那么通过块复制操作分配与你的“结构”关联的变量就不可能了。

将多个字段组合成一个记录有许多优点,包括:

  • 维护记录结构要容易得多(即添加、删除、重命名和更改字段)。

  • 编译器可以对记录进行额外的类型和语义检查,从而帮助在你错误使用记录时捕捉程序中的逻辑错误。

  • 编译器可以将记录视为一个整体对象,从而生成比处理单个字段变量时更高效的代码(例如,movsdmovaps指令)。

  • 大多数编译器会遵循记录中声明的顺序,将连续的字段分配到连续的内存位置。这在从两种不同语言接口数据结构时尤为重要。在大多数语言中,无法保证单独变量在内存中的组织方式。

  • 你可以使用记录来提高缓存内存性能并减少虚拟内存抖动(正如你很快就会看到的那样)。

  • 记录可以包含指针字段,这些字段存储其他(相同类型)记录对象的地址。而当你使用内存中的大规模变量时,这是不可能的。

在接下来的章节中,你将看到记录的一些其他优势。

11.1.3 在编译时初始化记录数据

一些语言——例如 C/C++和 HLA——允许你在编译时初始化记录变量。对于静态对象,这可以节省应用程序手动初始化每个记录字段所需的代码和时间。例如,考虑以下 C 代码,它为静态和自动结构变量提供了初始化器:


			#include <stdlib.h>

// Arbitrary structure that consumes a nontrival
// amount of space:

typedef struct
{
    int x;
    int y;
    char *z;
    int a[4];
}initStruct;

// The following exists just to thwart
// the optimizer and make it think that
// all the fields of the structure are
// needed.

extern void thwartOpt( initStruct *i );

int main( int argc, char **argv )
{
    static initStruct staticStruct = {1,2,"Hello", {3,4,5,6}};
    initStruct autoStruct = {7,8,"World", {9,10,11,12}};

    thwartOpt( &staticStruct );
    thwartOpt( &autoStruct );
    return 0;

}

当使用 Visual C++并使用/O2/Fa命令行选项编译时,此示例会生成以下 x86-64 机器代码(手动编辑以去除不相关的输出):


			; Static structure declaration.
; Note how each of the fields is
; initialized with the initial values
; specified in the C source file:

; String used in static initStruct:

CONST   SEGMENT
??_C@_05COLMCDPH@Hello?$AA@ DB 'Hello', 00H       ; `string'
CONST   ENDS

_DATA   SEGMENT
; `main'::`2'::staticStruct
?staticStruct@?1??main@@9@9 DD 01H ;x field
        DD      02H ;y field
        DQ      FLAT:??_C@_05COLMCDPH@Hello?$AA@  ; z field
        DD      03H ;a[0] field
        DD      04H ;a[1] field
        DD      05H ;a[2] field
        DD      06H ;a[3] field
_DATA   ENDS

; String used to initialize autoStruct:

CONST   SEGMENT
??_C@_05MFLOHCHP@World?$AA@ DB 'World', 00H       ; `string'
CONST   ENDS
;
_TEXT   SEGMENT
autoStruct$ = 32
__$ArrayPad$ = 64
argc$ = 96
argv$ = 104
main    PROC                                      ; COMDAT
; File c:\users\rhyde\test\t\t\t.cpp
; Line 26
$LN9: ;Main program startup code:
        sub     rsp, 88                           ; 00000058H
        mov     rax, QWORD PTR __security_cookie
        xor     rax, rsp
        mov     QWORD PTR __$ArrayPad$[rsp], rax

; Line 28
;
; Initialize autoStruct:

        lea     rax, OFFSET FLAT:??_C@_05MFLOHCHP@World?$AA@
        mov     DWORD PTR autoStruct$[rsp], 7 ;autoStruct.x
        mov     QWORD PTR autoStruct$[rsp+8], rax
        mov     DWORD PTR autoStruct$[rsp+4], 8 ;autoStruct.y
        lea     rcx, QWORD PTR autoStruct$[rsp+16] ;autoStruct.a
        mov     eax, 9
        lea     edx, QWORD PTR [rax-5] ;edx = 4
$LL3@main:
; autoStruct.a[0] = 9, 10, 11, 12 (this is a loop)
        mov     DWORD PTR [rcx], eax
        inc     eax

; point RCX at next element of autoStruct.a
        lea     rcx, QWORD PTR [rcx+4]
        sub     rdx, 1
        jne     SHORT $LL3@main

; Line 30
; thwartOpt(&staticStruct );

        lea     rcx, OFFSET FLAT:?staticStruct@?1??main@@9@9
        call    thwartOpt

; Line 31
; thwartOpt( &autoStruct );

        lea     rcx, QWORD PTR autoStruct$[rsp]
        call    thwartOpt
; Line 32
; Return 0
        xor     eax, eax ;EAX = 0
; Line 34
        mov     rcx, QWORD PTR __$ArrayPad$[rsp]
        xor     rcx, rsp
        call    __security_check_cookie
        add     rsp, 88                                 ; 00000058H
        ret     0
main    ENDP
_TEXT   ENDS
        END

仔细观察编译器为初始化autoStruct变量所生成的机器代码。与静态初始化不同,编译器无法在编译时初始化内存,因为它不知道系统在运行时为自动记录分配的各个字段的地址。不幸的是,这个特定的编译器生成了逐字段赋值的初始化序列来初始化结构的字段。虽然这样做相对较快,但可能会消耗相当多的内存,尤其是当结构很大时。如果你希望减少自动结构变量初始化的大小,一种可能的做法是创建一个初始化的静态结构,并在每次进入声明了自动变量的函数时将其赋值给自动变量。考虑以下的 C++和 80x86 汇编代码:


			#include <stdlib.h>
typedef struct
{
    int x;
    int y;
    char *z;
    int a[4];
}initStruct;

// The following exists just to thwart
// the optimizer and make it think that
// all the fields of the structure are
// needed.

extern void thwartOpt( initStruct *i );

int main( int argc, char **argv )
{
    static initStruct staticStruct = {1,2,"Hello", {3,4,5,6}};

    // initAuto is a "readonly" structure used to initialize
    // autoStruct upon entry into this function:

    static initStruct initAuto = {7,8,"World", {9,10,11,12}};

    // Allocate autoStruct on the stack and assign the initial
    // values kept in initAuto to this new structure:

    initStruct autoStruct = initAuto;

    thwartOpt( &staticStruct );
    thwartOpt( &autoStruct );
    return 0;

}

这是 Visual C++生成的相应 x86-64 汇编代码:


			; Static initialized data for the staticStruct structure:

_DATA   SEGMENT

; Initialized data for staticStruct:

?staticStruct@?1??main@@9@9 DD 01H                      ; `main'::`2'::staticStruct
        DD      02H
        DQ      FLAT:??_C@_05COLMCDPH@Hello?$AA@
        DD      03H
        DD      04H
        DD      05H
        DD      06H

; Initialization data to be copied to autoStruct:

?initAuto@?1??main@@9@9 DD 07H                          ; `main'::`2'::initAuto
        DD      08H
        DQ      FLAT:??_C@_05MFLOHCHP@World?$AA@
        DD      09H
        DD      0aH
        DD      0bH
        DD      0cH
_DATA   ENDS

_TEXT   SEGMENT
autoStruct$ = 32
__$ArrayPad$ = 64
argc$ = 96
argv$ = 104
main    PROC                                    ; COMDAT
; File c:\users\rhyde\test\t\t\t.cpp
; Line 23
$LN4:
; Main startup code:

        sub     rsp, 88                         ; 00000058H
        mov     rax, QWORD PTR __security_cookie
        xor     rax, rsp
        mov     QWORD PTR __$ArrayPad$[rsp], rax
; Line 34
; Initialize autoStruct by copying the data from the static
; initializer to the automatic variable:

        movups  xmm0, XMMWORD PTR ?initAuto@?1??main@@9@9
        movups  xmm1, XMMWORD PTR ?initAuto@?1??main@@9@9+16
        movups  XMMWORD PTR autoStruct$[rsp], xmm0
        movups  XMMWORD PTR autoStruct$[rsp+16], xmm1

; thwartOpt( &staticStruct );

        lea     rcx, OFFSET FLAT:?staticStruct@?1??main@@9@9
        call    thwartOpt  ; Arg is passed in RCX.

; thwartOpt( &autoStruct );

        lea     rcx, QWORD PTR autoStruct$[rsp]
        call    thwartOpt

; Return 0;
        xor     eax, eax
; Line 40
        mov     rcx, QWORD PTR __$ArrayPad$[rsp]
        xor     rcx, rsp
        call    __security_check_cookie
        add     rsp, 88                         ; 00000058H
        ret     0
main    ENDP
_TEXT   ENDS
        END

如你在这段汇编代码中看到的,将静态初始化的记录数据复制到自动分配的记录中只需要四条指令。这个代码简洁很多。然而,值得注意的是,它不一定更快。从一个结构复制数据到另一个结构涉及内存到内存的移动,如果所有的内存位置都没有被缓存,可能会非常慢。将常数直接移动到各个字段通常更快,尽管可能需要多条指令来完成。

这个示例应该提醒你,如果你给一个自动变量附加了一个初始化器,编译器将不得不生成一些代码,以便在运行时处理该初始化。除非你的变量需要在每次进入函数时重新初始化,否则考虑使用静态记录对象。

11.1.4 在内存中存储记录

以下 Pascal 示例演示了一个典型的student记录变量声明:


			var
    John: student;

根据之前对 Pascal student 数据类型的声明,这会分配 81 字节的存储,并按 图 11-1 所示的方式在内存中布局。如果标签 John 对应于该记录的基地址,则 Name 字段位于偏移量 John+0Major 字段位于偏移量 John+65SSN 字段位于偏移量 John+67,依此类推。

Image

图 11-1:学生数据结构在内存中的存储

大多数编程语言允许你通过字段名称而非记录中字段的数字偏移量来引用记录字段(实际上,只有少数低端汇编器要求你通过数字偏移量来引用字段;可以说,这样的汇编器并不真正支持记录)。字段访问的典型语法使用点操作符从记录变量中选择字段。假设变量 John 来自前面的示例,以下是如何访问该记录中的不同字段:


			John.Mid1 = 80;           // C/C++ example
John.Final := 93;         (* Pascal example *)
mov( 75, John.Projects ); // HLA example

图 11-1 表明记录的所有字段在内存中按声明顺序排列,通常情况也是如此(尽管从理论上讲,编译器可以自由地将字段放置在内存中的任何位置)。第一个字段通常位于记录中的最低地址,第二个字段位于下一个较高地址,第三个字段跟随第二个字段依次排列,依此类推。

图 11-1 还表明编译器将字段打包到相邻的内存位置中,字段之间没有空隙。虽然这对许多语言来说是正确的,但这肯定不是记录的最常见内存组织方式。出于性能考虑,大多数编译器将记录的字段对齐到适当的内存边界。具体细节因语言、编译器实现和 CPU 而异,但典型的编译器会将字段放置在记录存储区域内一个对该特定字段数据类型“自然”的偏移量上。例如,在 80x86 上,遵循 Intel ABI(应用程序二进制接口)的编译器将单字节对象分配到记录中的任何偏移量,字(word)仅分配在偶数偏移量上,而双字或更大的对象则放置在双字边界上。虽然并非所有 80x86 编译器都支持 Intel ABI,但大多数都支持,这使得记录能够在用不同语言编写的函数和过程之间共享。其他 CPU 制造商为其处理器提供了自己的 ABI,遵循 ABI 的程序可以在运行时与其他遵循相同 ABI 的程序共享二进制数据。

除了将记录的字段对齐到合理的偏移量边界,大多数编译器还确保整个记录的长度是 2、4、8 或 16 字节的倍数。正如你在前几章中看到的,它们通过在记录末尾添加填充字节来实现这一点,从而填满记录的大小。这确保了记录的长度是记录中最大标量(非数组/非记录)对象的倍数。例如,如果一个记录的字段长度分别为 1、2、4 和 8 字节,那么 80x86 编译器通常会填充记录的长度,使其成为 8 的倍数。这样,你可以创建一个记录数组,并确保数组中的每个记录都从内存中的合理地址开始。

尽管一些 CPU 不允许访问内存中不对齐地址的对象,许多编译器仍然允许你禁用记录字段的自动对齐。通常,编译器会提供一个选项,允许你全局禁用此功能。这些编译器中的许多还提供pragmaalignaspacked关键字,让你可以在每个记录的基础上禁用字段对齐。禁用自动字段对齐功能可能会通过消除字段之间(以及记录末尾)的填充字节来节省一些内存——当然,前提是你的 CPU 可以接受字段的不对齐。代价当然是,当程序需要访问内存中不对齐的值时,运行速度可能会稍微变慢。

使用打包记录的一个原因是为了手动控制记录字段的对齐。例如,假设你有几个用两种不同语言编写的函数,并且这两个函数都需要访问记录中的某些数据。进一步假设这两个函数的编译器使用不同的字段对齐算法。像以下这样的记录声明(在 Pascal 中)可能与这两个函数访问记录数据的方式不兼容:


			type
    aRecord = record

        (* assume Pascal compiler supports a
        ** byte, word, and dword type
        *)

        bField : byte;
        wField : word;
        dField : dword;

    end; (* record *)

这里的问题是,第一个编译器可能使用偏移量 0、2 和 4 分别为bFieldwFielddField字段,而第二个编译器可能使用偏移量 0、4 和 8。

然而,假设第一个编译器允许你在record关键字之前指定packed关键字,这将导致编译器将每个字段紧接着前一个字段存储。尽管使用packed关键字并不能使记录与两种函数兼容,但它确实允许你手动向记录声明中添加填充字段,具体如下:


			type
    aRecord = packed record
        bField   :byte;  (* Offset 0 *)

        (* add padding to dword align wField *)

        padding0 :array[0..2] of byte;

        wField   :word; (* offset 4 *)

        (* add padding to dword align dField *)

        padding1 :word;

        dField   :dword;  (* offset 8 *)

    end; (* record *)

手动添加填充可能会使代码维护变得非常繁琐。然而,如果不兼容的编译器需要共享数据,这是一个值得了解的技巧。有关打包记录的具体细节,请参考你所使用语言的参考手册。

11.1.5 使用记录来提高内存性能

对于那些想写出高质量代码的人来说,记录提供了一个重要的好处:能够控制变量在内存中的位置。这一能力让你可以更好地控制这些变量的缓存使用,从而帮助你编写执行速度更快的代码。

想一想以下的 C 全局/静态变量声明:


			int i;
int j = 5;
int cnt = 0;
char a = 'a';
char b;

你可能会认为编译器会将这些变量存储在连续的内存位置。然而,很少(如果有的话)有编程语言能保证这一点。C 语言显然没有,并且事实上,像 Microsoft 的 Visual C++ 编译器这样的 C 编译器并不会将这些变量分配在连续的内存位置。考虑一下 Visual C++ 汇编语言输出的前述变量声明:


			PUBLIC  j
PUBLIC  cnt
PUBLIC  a
_DATA   SEGMENT
COMM    i:DWORD
_DATA   ENDS
_BSS    SEGMENT
cnt     DD      01H DUP (?)
_BSS    ENDS
_DATA   SEGMENT
COMM    b:BYTE
_DATA   ENDS
_DATA   SEGMENT
j       DD      05H
a       DB      061H
_DATA   ENDS

即使你不理解这里所有指令的用途,也可以明显看出 Visual C++ 已经重新排列了内存中的所有变量声明。因此,你不能指望源文件中的相邻声明会在内存中产生相邻的存储单元。实际上,没有任何东西能够阻止编译器将一个或多个变量分配到机器寄存器中。

你可能会想,为什么你需要关注变量在内存中的位置。毕竟,使用命名变量作为内存抽象的主要原因之一,就是避免考虑低级的内存分配策略。然而,有时候,能够控制变量在内存中的位置是很重要的。例如,如果你想最大化程序的性能,你应该尽量将一起访问的变量放置在相邻的内存位置。这样,这些变量会倾向于位于同一缓存行中,访问不在缓存中的变量时,你就不会付出过高的延迟代价。此外,通过将一起使用的变量放置在相邻的内存位置,你将使用更少的缓存行,从而减少缓存抖动。

通常情况下,支持传统记录概念的编程语言会将记录的字段存储在相邻的内存位置;因此,如果你有某种理由将不同的变量放置在相邻的内存位置(以便它们尽可能共享缓存行),将变量放入记录中是一种合理的方法。然而,这里关键的词是传统——如果你的语言使用动态记录类型,你就需要采用不同的方法。

11.1.6 使用动态记录类型和数据库

一些动态语言采用动态类型系统,且对象类型可以在运行时发生变化。我们将在本章稍后讨论动态类型,但可以简单地说,如果你的语言使用动态类型的记录结构,那么关于字段在内存中的位置就无法确定了。很有可能这些字段不会位于相邻的内存位置。再者,如果你使用的是动态语言,因未能最大化缓存的利用率而牺牲了些许性能,反倒是你最不需要担心的问题。

动态记录的经典例子是你从数据库引擎中读取的数据。引擎本身没有任何预设(即编译时)的观念来定义数据库记录的结构。相反,数据库本身提供元数据,告诉数据库记录的结构。数据库引擎从数据库中读取这些元数据,利用它来将字段数据组织成一个单一的记录,然后将这些数据返回给数据库应用程序。在动态语言中,实际的字段数据通常分布在内存的不同位置,数据库应用程序间接引用这些数据。

当然,如果你使用的是动态语言,那么你对性能的关注远远大于记录字段在内存中的位置或组织方式。动态语言,如数据库引擎,会执行许多指令来处理元数据(或以其他方式确定数据操作数的类型),因此,在这里丢失几个周期导致缓存抖动不会产生太大影响。有关与动态类型系统相关的开销的更多信息,请参见第 356 页的“变体类型”。

11.2 判别联合

判别联合(或简称联合)与记录非常相似。判别是用来区分或分隔数量中各项的东西。在判别联合的情况下,它意味着使用不同的字段名来区分给定内存位置的数据类型的不同解释方式。

像记录一样,支持联合的典型语言中,联合也有字段,你可以使用点符号来访问。事实上,在许多语言中,记录和联合之间唯一的语法差异就是使用关键字union而不是recordstruct。然而,在语义上,记录和联合之间有很大的区别。在记录中,每个字段相对于记录的基地址都有自己的偏移量,且字段不会重叠。然而,在联合中,所有字段都共享相同的偏移量 0,所有联合的字段都重叠。因此,记录的大小是所有字段大小的总和(可能还包括一些填充字节),而联合的大小是其最大字段的大小(可能还包括一些末尾的填充字节)。

由于联合的字段是重叠的,修改一个字段的值会改变其他所有字段的值。这通常意味着联合字段的使用是互斥的——也就是说,你只能在任何给定时间使用一个字段。因此,联合不像记录那样具有广泛的适用性,但它仍然有许多用途。正如你在本章后面将看到的,你可以使用联合通过重用内存来节省内存,强制数据类型转换,以及创建变体数据类型。大多数情况下,程序使用联合在不同的变量对象之间共享内存,而这些对象的使用永远不会重叠(也就是说,变量的使用是互斥的)。

例如,假设你有一个 32 位的双字变量,并且你发现自己不断地提取出 LO 或 HO 的 16 位字。在大多数高级语言中,这将需要先进行一次 32 位读取,然后执行与操作以屏蔽掉不需要的字。如果这还不够,如果你需要 HO 字,你还需要将结果右移 16 位。使用联合时,你可以将 32 位双字与一个包含两个元素的 16 位字数组叠加,并直接访问这些字。你将在《以其他方式使用联合》一节中看到如何操作,请参阅第 355 页。

11.2.1 在各种语言中声明联合

C/C++、Pascal 和 HLA 语言提供了判别联合类型声明。Java 语言则没有提供等效的联合类型。Swift 有一种特殊版本的 Enum 声明,提供变体记录功能,但它并不会将这些声明的成员存储在内存的同一地址上。因此,在本讨论中,我们假设 Swift 不提供联合声明。

11.2.1.1 C/C++ 中的联合声明

这是 C/C++ 中联合声明的示例:


			typedef union
{
    unsigned int  i;
    float         r;
    unsigned char c[4];

} unionType;

假设正在使用的 C/C++ 编译器为无符号整数分配了 4 个字节,那么 unionType 对象的大小将为 4 个字节(因为所有三个字段都是 4 字节对象)。

11.2.1.2 Pascal/Delphi 中的联合声明

Pascal 和 Delphi 使用 case-variant 记录 来创建判别联合。case-variant 记录的语法如下:


			type
    typeName =
        record

            <<nonvariant/union record fields go here>>

            case tag of
                const1:( field_declaration );
                const2:( field_declaration );
                    .
                    .
                    .
                constn:( field_declaration )

        end;

标签项可以是一个类型标识符(例如 booleanchar 或某个用户定义的类型),也可以是形式为 identifier:type 的字段声明。如果它采用后者形式,则标识符将成为记录的另一个字段(而不是变体部分的成员),并具有指定的类型。此外,Pascal 编译器可以生成代码,在应用程序尝试访问任何变体字段(除了标签字段值允许的字段)时引发异常。实际上,几乎没有 Pascal 编译器会进行此检查。尽管如此,请记住,Pascal 语言标准建议编译器 应该 执行此检查,因此某些编译器可能会执行。

这是 Pascal 中两种不同 case-variant 记录声明的示例:


			type
    noTagRecord=
        record
            someField: integer;
            case boolean of
                true:( i:integer );
                false:( b:array[0..3] of char)
        end; (* record *)

    hasTagRecord=
        record
            case which:0..2 of
                0:( i:integer );
                1:( r:real );
                2:( c:array[0..3] of char )
        end; (* record *)

正如你在hasTagRecord联合体中看到的,Pascal 风格的变体记录不需要任何常规记录字段。即使没有标签字段,这也是成立的。

11.2.1.3 HLA 中的联合体声明

HLA 同样支持联合体。以下是 HLA 中的典型联合体声明:


			type
    unionType:
        union
            i: int32;
            r: real32;
            c: char[4];
        endunion;

11.2.2 将联合体存储在内存中

记住,联合体和记录体之间的一个重要区别是:记录体为每个字段分配不同偏移位置的存储,而联合体则将每个字段叠加在内存中的相同偏移位置。例如,考虑以下 HLA 的记录和联合体声明:


			type
    numericRec:
        record
            i: int32;
            u: uns32;
            r: real64;
        endrecord;

    numericUnion:
        union
            i: int32;
            u: uns32;
            r: real64;
        endunion;

如果你声明一个变量,例如n,类型为numericRec,你可以像访问numericUnion类型一样,访问字段n.in.un.r。然而,numericRec对象的大小为 16 字节,因为记录包含两个双字字段和一个四字字段(real64)。然而,numericUnion变量的大小只有 8 字节。图 11-2 显示了记录和联合体中iur字段的内存排列。

Image

图 11-2:联合体与记录变量的布局

11.2.3 以其他方式使用联合体

除了节省内存,程序员还经常使用联合体在代码中创建别名。别名是同一内存对象的不同名称。尽管别名往往会引起程序中的困惑,应当谨慎使用,但有时使用它们是方便的。例如,在程序的某些部分,你可能需要不断地使用类型强制转换来引用某个特定对象。为了避免这种情况,你可以使用一个联合体变量,其中每个字段代表你想要为该对象使用的不同类型。考虑以下 HLA 代码片段:


			type
    CharOrUns:
        union
            c:char;
            u:uns32;
        endunion;

static
    v:CharOrUns;

使用这样的声明,你可以通过访问v.u来操作uns32对象。如果在某个时刻,你需要将此uns32变量的低字节视为字符,只需访问v.c变量,如下所示:


			mov( eax, v.u );
stdout.put( "v, as a character, is '", v.c, "'" nl );

另一个常见的做法是使用联合体将一个较大的对象拆解为其组成字节。考虑以下 C/C++代码片段:


			typedef union
{
    unsigned int u;
    unsigned char bytes[4];
} asBytes;

asBytes composite;
        .
        .
        .
    composite.u = 1234567890;
    printf
    (
        "HO byte of composite.u is %u, LO byte is %u\n",
        composite.bytes[3],
        composite.bytes[0]
    );

尽管这样组合和拆解数据类型是一个有时非常有用的技巧,但请记住,这段代码并不具有可移植性。多字节对象的高字节(HO)和低字节(LO)在大端和小端机器上的地址是不同的。因此,这段代码在小端机器上可以正常工作,但在大端 CPU 上则无法正确显示字节。每次使用联合体来拆解更大的对象时,你都应该意识到这个限制。不过,这个技巧通常比使用左移、右移和与操作要高效得多,所以你会经常看到它的使用。

11.3 变体类型

变体对象具有动态类型——即,对象的类型可以在运行时变化。这使得程序员在设计程序时不必决定数据类型,并且允许最终用户在程序运行时输入任何他们喜欢的数据。用动态类型语言编写的程序通常比用传统静态类型语言编写的程序更加紧凑。这使得动态类型语言在快速原型开发、解释型语言和高级语言中非常受欢迎。一些主流语言(包括 Visual Basic 和 Delphi)也支持变体类型。在本节中,我们将讨论编译器如何实现变体类型,并讨论与之相关的效率成本。

为了实现一个变体类型,大多数语言使用联合体来为变体对象支持的所有不同类型预留存储空间。这意味着变体对象将至少消耗它所支持的最大原始数据类型的空间。除了保存其值所需的存储空间外,变体对象还需要存储空间来跟踪其当前类型。如果语言允许变体假定数组类型,可能还需要更多存储空间来指定数组中有多少个元素(或者如果语言支持多维变体数组,则指定每个维度的边界)。最重要的是,变体消耗了相当多的内存,即使实际数据只占一个字节。

也许最好的方式来说明变体数据类型是如何工作的,就是手动实现一个。考虑以下 Delphi 的 case-variant 记录声明:


			type
    dataTypes =
           (
               vBoolean, paBoolean, vChar, paChar,
               vInteger, paInteger, vReal, paReal,
               vString, paString
           );

       varType =
           record
               elements : integer;
               case theType: dataTypes of
                   vBoolean:  ( b:boolean );
                   paBoolean: ( pb:array[0..0] of ^boolean );
                   vChar:     ( c:char );
                   paChar:    ( pc:array [0..0] of ^char );
                   vInteger:  ( i:integer );
                   paInteger: ( pi:array[0..0] of ^integer );
                   vReal:     ( r:real );
                   paReal:    ( pr:array[0..0] of ^real );
                   vString:   ( s:string[255] );
                   paString:  ( ps:array[0..0] of ^string[255] )
          end;

在这个记录中,当对象是一个一维数组时,元素将包含数组中元素的数量(此数据结构不支持多维数组)。另一方面,如果对象是标量变量,则元素值将不相关。theType字段指定对象的当前类型。如果该字段包含枚举常量之一vBooleanvCharvIntegervRealvString,则对象是标量变量;如果它包含常量paBooleanpaCharpaIntegerpaRealpaString之一,则对象是指定类型的一维数组。

Pascal 记录中的 case-variant 部分的字段保存变体的值(如果它是标量对象),或者如果变体是数组对象,则保存指向对象数组的指针。从技术上讲,Pascal 要求你在声明中指定数组的边界。但幸运的是,Delphi 允许你关闭边界检查(并且还允许你为任意大小的数组分配内存),因此在这个例子中有虚拟的数组边界。

操作两个具有相同类型的变体对象是很容易的。例如,假设你想将两个变体值相加。首先,你需要确定这两个对象的当前类型,以及加法操作是否对这些数据类型有意义。^(4) 一旦你决定加法操作是合理的,就可以很容易地使用基于两个变体类型标签字段的case(或switch)语句:


			// Handle the addition operation:

// Load variable theType with either left.theType
// or right.theType (which, presumably, contain
// the same value at this point).

case( theType ) of

    vBoolean: writeln( "Cannot add two Boolean values!" );
    vChar: writeln( "Cannot add two character values!" );
    vString: writeln( "Cannot add two string values!" );
    vInteger: intResult := left.vInteger + right.vInteger;
    vReal: realResult := left.vReal + right.vReal;
    paBoolean: writeln( "Cannot add two Boolean arrays!" );
    paChar: writeln( "Cannot add two character arrays!" );
    paInteger: writeln( "Cannot add two integer arrays!" );
    paReal: writeln( "Cannot add two real arrays!" );
    paString: writeln( "Cannot add two Boolean arrays!" );

end;

如果左右操作数类型不同,那么操作会变得稍微复杂一些。一些混合类型操作是合法的。例如,将整数操作数和实数操作数相加是合理的(在大多数语言中,它会产生实数类型的结果)。其他操作可能只有在操作数的值可以相加时才是合法的。例如,如果字符串恰好包含一串可以在加法之前转换为整数的数字字符,那么将字符串和整数相加是合理的(对于字符串和实数操作数也一样)。这里需要的是一个二维的case/switch语句。不幸的是,除了汇编语言外,你不会找到这种结构。^(5) 但是,你可以通过嵌套case/switch语句来轻松模拟这种结构:


			case( left.theType ) of

    vInteger:
        case( right.theType ) of
            vInteger:
                (* code to handle integer + integer operands *)
            vReal:
                (* code to handle integer + real operands *)
            vBoolean:
                (* code to handle integer + boolean operands *)
            vChar:
                (* code to handle integer + char operands *)
            vString:
                (* code to handle integer + string operands *)
            paInteger:
                (* code to handle integer + intArray operands *)
            paReal:
                (* code to handle integer + realArray operands *)
            paBoolean:
                (* code to handle integer + booleanArray operands *)
            paChar:
                (* code to handle integer + charArray operands *)
            paString:
                (* code to handle integer + stringArray operands *)
        end;

    vReal:
        case( right.theType ) of
            (* cases for each of the right operand types
                REAL + type *)
        end;

    Boolean:
        case( right.theType ) of
            (* cases for each of the right operand types:
                BOOLEAN + type *)
        end;

    vChar:
        case( right.theType ) of
            (* cases for each of the right operand types:
                CHAR + type *)
        end;

    vString:
        case( right.theType ) of
            (* cases for each of the right operand types:
                STRING + type *)
        end;

    paInteger:
        case( right.theType ) of
            (* cases for each of the right operand types:
                intArray + type *)
        end;

    paReal:
        case( right.theType ) of
            (* cases for each of the right operand types:
                realArray + type *)
        end;

    paBoolean:
        case( right.theType ) of
            (* cases for each of the right operand types:
                booleanArray + type *)
        end;

    paChar:
        case( right.theType ) of
            (* cases for each of the right operand types:
                charArray + type *)
        end;

    paString:
        case( right.theType ) of
            (* cases for each of the right operand types:
                stringArray + type *)
        end;

end;

一旦你扩展了这些注释中提到的所有代码,你将会有相当多的语句。这只是为了一个运算符!显然,实施所有基本的算术、字符串、字符和布尔操作需要相当多的工作——并且在每次需要将两个变体值相加时,在线展开这段代码是不可行的。通常,你会写一个像vAdd()这样的函数,接受两个变体参数并返回一个变体结果(如果操作数相加不合法,则引发某种异常)。

这里的要点不是变体加法的代码很长——真正的问题是性能。期望一个变体加法操作需要几十甚至上百条机器指令来完成,这一点一点也不不合理。相比之下,两个整数或浮点数值相加只需要两到三条机器指令。因此,你可以预计涉及变体对象的操作将比标准操作慢大约一个到两个数量级。事实上,这正是“无类型”语言(通常是非常高级的语言)运行缓慢的主要原因之一。当你真正需要变体类型时,它的性能往往与你为避免使用它所写的替代代码一样好(甚至更好)。然而,如果你使用变体对象来存储那些在编写程序时就知道类型的值,那么由于没有使用类型化对象,你将付出沉重的性能代价。

在面向对象的语言如 C++、Java、Swift 和 Delphi(Object Pascal)中,有一个更好的变体计算解决方案:继承和多态。使用 union/switch 语句版本的一个大问题是,扩展变体类型时需要添加新类型,这可能非常麻烦。例如,假设你想添加一个新的复合数据类型来支持复数。你必须找到你写的每个函数(通常每个运算符一个),并在 switch 语句中添加一个新的 case。这可能会导致维护上的噩梦(尤其是当你无法访问原始源代码时)。然而,使用对象时,你可以创建一个新的类(比如 ComplexNumber),它重写现有的基类(可能是 Numeric),而无需修改任何现有的代码(对于其他数字类型和运算)。有关此方法的更多信息,请参见 Write Great Code, Volume 4: Designing Great Code

11.4 命名空间

随着你的程序变得越来越大,特别是当这些大型程序使用第三方软件库来减少开发时间时,源文件中出现名称冲突的可能性也会越来越高。名称冲突发生在你想在程序的某一部分使用一个特定的标识符时,但该名称已经在其他地方被使用(例如,在你使用的一个库中)。在一个非常大的项目中,你可能会想出一个新的名称来解决命名冲突,结果却发现新名称也已经被使用。软件工程师称这种情况为 命名空间污染。就像环境污染一样,当污染小而局部时,问题容易忽视。然而,随着程序变得越来越大,处理“所有好的标识符都已经被占用”的问题变得越来越具有挑战性。

初看起来,这个问题可能显得有些夸大;毕竟,程序员总能想到一个不同的名称。然而,编写优质代码的程序员往往会遵循某些命名约定,以确保他们的源代码一致且易于阅读(我将在 Write Great Code, Volume 5: Great Coding 中回到这个话题)。不断想出新的名称,即使它们并不那么糟糕,往往会导致源代码中的不一致性,使程序更难阅读。如果你能够随心所欲地为标识符选择名称,而不必担心与其他代码或库冲突,那将是多么美好。命名空间应运而生。

命名空间 是一种机制,允许你将一组标识符与命名空间标识符关联。在许多方面,命名空间就像是记录声明。事实上,在不直接支持命名空间的语言中,你可以使用 record(或 struct)声明作为一种简单的命名空间(有一些主要的限制)。例如,考虑以下 Pascal 变量声明:


			var
    myNameSpace:
        record
            i: integer;
            j: integer;
            name: string[64];
            date: string[10];
            grayCode: integer;
        end;

    yourNameSpace:
        record
            i: integer;
            j: integer;
            profits: real;
            weekday: integer;
        end;

如你所见,这两个记录中的 ij 字段是不同的变量。由于程序必须使用记录变量名来限定这两个字段名称,因此永远不会发生命名冲突。也就是说,你使用以下名称来引用这些变量:


			myNameSpace.i, myNameSpace.j,
yourNameSpace.i, yourNameSpace.j

作为字段前缀的记录变量唯一标识了这些字段名称中的每一个。对于曾经编写过使用记录或结构体代码的人来说,这是显而易见的。因此,在不支持命名空间的语言中,你可以使用记录(或类)来代替它们。

然而,使用记录或结构体创建命名空间有一个主要问题:许多语言只允许在记录中声明变量。命名空间声明(如 C++ 和 HLA 中可用的那种)专门允许你包含其他类型的对象。例如,在 HLA 中,命名空间声明具有以下形式:


			namespace nsIdentifier;

    << constant, type, variable, procedure,
            and other declarations >>

end nsIdentifier;

类声明(如果在你选择的语言中可用)可以克服一些这些问题。至少,大多数语言允许在类中声明过程或函数,但许多语言也允许常量和类型声明。

命名空间本身就是一个声明部分。特别地,它们不必放在 varstatic(或任何其他)部分中。你可以在命名空间内创建常量、类型、变量、静态对象、过程等。

在 HLA 中访问命名空间对象时,使用记录、类和联合体所使用的熟悉的点符号。如果要访问 C++ 命名空间中的名称,则使用 :: 操作符。

只要命名空间标识符是唯一的,并且命名空间内的所有字段都是该命名空间唯一的,你就不会遇到任何问题。通过仔细将项目划分为不同的命名空间,你可以轻松避免由于命名空间污染而发生的大部分问题。

命名空间的另一个有趣的方面是它们是可扩展的。例如,考虑以下 C++ 中的声明:


			namespace aNS
{
    int i;
    int j;
}

int i;  // Outside the namespace, so this is unique.
int j;  // ditto.
namespace aNS
{
    int k;
}

这个示例代码是完全合法的。aNS 的第二个声明与第一个声明不冲突:它扩展了 aNS 命名空间,包含了标识符 aNS::k 以及 aNS::iaNS::j。这个特性在你想扩展一组库例程和头文件时非常有用,而无需修改原始库的头文件(假设库的名称都出现在命名空间中)。

从实现的角度来看,命名空间和出现在命名空间外的声明集之间真的没有区别。编译器通常以几乎相同的方式处理这两种类型的声明,唯一的区别是程序会使用命名空间的标识符来为命名空间内的所有对象加上前缀。

11.5 类和对象

数据类型是现代面向对象编程(OOP)的基石。在大多数 OOP 语言中,类与记录或结构密切相关。然而,与记录(在大多数语言中具有惊人一致的实现)不同,类的实现往往有所不同。然而,许多现代 OOP 语言通过类似的方式实现其结果,因此本节展示了来自 C++、Java、Swift、HLA 和 Delphi(Object Pascal)的一些具体示例。其他语言的用户将发现他们的语言也有类似的工作方式。

11.5.1 类与对象

许多程序员混淆了对象这两个术语。类是一种数据类型;它是编译器根据类的字段组织内存的模板。对象是类的实例化——也就是说,对象是某个类类型的变量,内存已分配用于存储与类字段相关的数据。对于给定的类,只有一个类定义。然而,您可以有该类类型的多个对象(变量)。

11.5.2 C++ 中的简单类声明

在 C++ 中,类和结构在语法和语义上是相似的。实际上,它们之间只有一个语法上的区别:使用 class 关键字和使用 struct 关键字。考虑以下两个在 C++ 中有效的类型声明:


			struct student
{
        // Room for a 64-character zero-terminated string:

        char Name[65];

        // Typically a 2-byte integer in C/C++:

        short Major;

        // Room for an 11-character zero-terminated string:

        char SSN[12];

        // Each of the following is typically a 2-byte integer

        short Mid1;
        short Mid2;
        short Final;
        short Homework;
        short Projects;
};

class myClass
{
public:

// Room for a 64-character zero-terminated string:

        char Name[65];

        // Typically a 2-byte integer in C/C++:

        short Major;

        // Room for an 11-character zero-terminated string:

        char SSN[12];

        // Each of the following is typically a 2-byte integer

        short Mid1;
        short Mid2;
        short Final;
        short Homework;
        short Projects;
};

尽管这两种数据结构包含相同的字段,且你将以相同的方式访问这些字段,但它们的内存实现略有不同。结构体的典型内存布局出现在 图 11-3 中,可以与类的内存布局(见 图 11-4)进行比较。(图 11-3 与 图 11-1 相同,但此处为了方便与 图 11-4 进行比较而再次出现。)

Image

图 11-3:学生结构体在内存中的存储

Image

图 11-4:学生类在内存中的存储

VMT 指针 是一个字段,如果类包含任何类成员函数(即 方法),该字段会出现。某些 C++ 编译器如果没有成员函数,则不会生成 VMT 指针字段,这种情况下,classstruct 对象在内存中的布局是相同的。

注意

VMT 代表 虚拟方法表,将在“虚拟方法表”一节中进一步讨论,见 第 367 页。

虽然 C++ 类声明可能仅包含数据字段,但类通常还包含成员函数定义以及数据成员。在 myClass 示例中,可能有以下成员函数:


			class myClass
{
public:

// Room for a 64-character zero-terminated string:

        char Name[65];

        // Typically a 2-byte integer in C/C++:

        short Major;

        // Room for an 11-character zero-terminated string:

        char SSN[12];

        // Each of the following is typically a 2-byte integer

        short Mid1;
        short Mid2;
        short Final;
        short Homework;
        short Projects;

        // Member functions:

        double computeGrade( void );
        double testAverage( void );
};

computeGrade() 函数可能计算课程的总成绩(基于期中考试、期末考试、作业和项目分数的相对权重)。testAverage() 函数可能返回所有测试分数的平均值。

11.5.3 C# 和 Java 中的类声明

C# 和 Java 类与 C/C++ 类声明非常相似。以下是一个 C# 类声明示例(也适用于 Java):


			class student
{
        // Room for a 64-character zero-terminated string:

        public char[] Name;

        // Typically a 2-byte integer in C/C++:

        public short Major;

        // Room for an 11-character zero terminated string:

        public char[] SSN;

        public short Mid1;
        public short Mid2;
        public short Final;
        public short Homework;
        public short Projects;

        public double computeGrade()
        {
            return Mid1 * 0.15 + Mid2 * 0.15 + Final *
                   0.2 + Homework * 0.25 + Projects * 0.25;
        }
        public double testAverage()
        {
            return (Mid1 + Mid2 + Final) / 3.0;
        }
    };

11.5.4 Delphi (Object Pascal) 中的类声明

Delphi (Object Pascal) 类看起来与 Pascal 记录非常相似。类使用 class 关键字,而不是 record,并且你可以在类中包含函数原型声明。


			type
  student =
    class
      Name:     string [64];
      Major:    smallint;    // 2-byte integer in Delphi
      SSN:      string[11];
      Mid1:     smallint;
      Mid2:     smallint;
      Final:    smallint;
      Homework: smallint;
      Projects: smallint;

      function computeGrade:real;
      function testAverage:real;
  end;

11.5.5 HLA 中的类声明

HLA 类看起来与 HLA 记录非常相似。类使用 class 关键字,而不是 record,并且你可以在类中包含函数(方法)原型声明。


			type
    student:
        class
          var
            sName:    char[65];
            Major:    int16;
            SSN:      char[12];
            Mid1:     int16;
            Mid2:     int16;
            Final:    int16;
            Homework: int16;
            Projects: int16;

            method computeGrade;
            method testAverage;

        endclass;

11.5.6 虚拟方法表

如你在图 11-3 和 11-4 中看到的,类定义与结构定义的区别在于前者包含了 VMT 字段。VMT,代表虚拟方法表,是指向对象类中所有成员函数或方法的指针数组。虚拟方法(在 C++ 中是虚拟成员函数)是你在类中声明为字段的特殊类相关函数。在当前的学生示例中,类实际上没有任何虚拟方法,因此大多数 C++ 编译器会去除 VMT 字段,但一些面向对象编程语言仍会在类中为 VMT 指针分配存储。

这是一个实际具有虚拟成员函数的小型 C++ 类,因此它也具有 VMT:


			class myclass
{
    public:
        int a;
        int b;
        virtual int f( void );
};

当 C++ 调用标准函数时,它直接调用该函数。虚拟成员函数则另当别论,正如你在图 11-5 中看到的那样。

Image

图 11-5:C++ 中的虚拟方法表

调用虚拟成员函数需要两个间接访问。首先,程序需要从类对象中获取 VMT 指针,并利用该指针间接获取 VMT 中特定虚拟函数的地址。然后,程序必须通过从 VMT 检索到的指针,间接调用虚拟成员函数。作为例子,请考虑以下 C++ 函数:


			#include <stdlib.h>

// A C++ class with two trivial
// member functions (so the VMT
// will have two entries).

class myclass
{
    public:
        int a;
        int b;
        virtual int f( void );
        virtual int g( void );
};

// Some trivial member functions.
// We're really only interested
// in looking at the calls, so
// these functions will suffice
// for now.

int myclass::f( void )
{
    return b;
}

int myclass::g( void )
{
    return a;
}

// A main function that creates
// a new instance of myclass and
// then calls the two member functions

int main( int argc, char **argv )
{
    myclass *c;

    // Create a new object:

    c = new myclass;

    // Call both member functions:

    c->a = c->f() + c->g();
    return 0;

}

这是 Visual C++ 生成的相应 x86-64 汇编代码:


			; Here is the VMT for myclass. It contains
; three entries:
; a pointer to the constructor for myclass,
; a pointer to the myclass::f member function,
; and a pointer to the myclass::g member function.

CONST   SEGMENT
??_7myclass@@6B@ DQ FLAT:??_R4myclass@@6B@ ; myclass::`vftable'
        DQ      FLAT:?f@myclass@@UEAAHXZ
        DQ      FLAT:?g@myclass@@UEAAHXZ
CONST   ENDS
;
    .
    .
    .
;
; Allocate storage for a new instance of myclass:
; 16 = two 4-byte ints plus 8-byte VMT pointer
        mov     ecx, 16

        call    ??2@YAPEAX_K@Z             ; operator new
        mov     rdi, rax                   ; Save pointer to allocated object
        test    rax, rax                   ; Did NEW FAIL (returning NULL)?
        je      SHORT $LN3@main

; Initialize VMT field with the address of the VMT:

        lea     rax, OFFSET FLAT:??_7myclass@@6B@
        mov     QWORD PTR [rdi], rax
        jmp     SHORT $LN4@main
$LN3@main:
        xor     edi, edi                   ; For failure, put NULL in EDI

; At this point, RDI contains the "THIS" pointer
; that refers to the object in question. In this
; particular code sequence, "THIS" is the address
; of the object whose storage we allocated above.

; Get the VMT into RAX (first indirect access
; needed to make a virtual member function call)

        mov     rax, QWORD PTR [rdi]

        mov     rcx, rdi                   ; Pass THIS in RCX
        call    QWORD PTR [rax+8]          ; Call c->f()
        mov     ebx, eax                   ; Save function result

        mov     rdx, QWORD PTR [rdi]       ; Load VMT into RDX
        mov     rcx, rdi                   ; Pass THIS in RCX
        call    QWORD PTR [rdx]            ; Call c->g()

; Compute sum of function results:

        add     ebx, eax
        mov     DWORD PTR [rdi+8], ebx     ; Save sum in c->a

这个例子充分展示了为什么面向对象程序通常比标准的过程式程序运行稍慢:调用虚拟方法时的额外间接访问。C++ 尝试通过提供静态成员函数来解决这一效率问题,但它们失去了虚拟成员函数带来的许多面向对象编程的好处。

11.5.7 抽象方法

一些语言(例如 C++)允许在类中声明抽象方法。抽象方法声明告诉编译器,你不会提供该方法的实际代码。相反,你承诺某个派生类会提供该方法的实现。以下是具有抽象方法的myclass版本:


			class myclass
{
public:
    int a;
    int b;
    virtual int f(void);
    virtual int g(void);
    virtual int h(void) = 0;
};

为什么会有这种奇怪的语法?将 0 赋值给虚函数似乎没有太大意义。为什么不直接使用像大多数其他语言那样的 abstract 关键字(而不是 virtual)呢?这些问题很有价值。答案可能与 0NULL 指针)被放置在抽象函数的 VMT 条目中有很大关系。在现代版本的 C++ 中,编译器实现者通常将某个函数的地址放在这里,该函数会生成一个合适的运行时消息(例如 不能调用抽象方法),而不是放置 NULL 指针。以下代码片段展示了 Visual C++ 中该版本 myclass 的 VMT:


			CONST   SEGMENT
??_7myclass@@6B@ DQ FLAT:??_R4myclass@@6B@              ; myclass::`vftable'
        DQ      FLAT:?f@myclass@@UEAAHXZ
        DQ      FLAT:?g@myclass@@UEAAHXZ
        DQ      FLAT:_purecall
CONST   ENDS

_purecall 条目对应于抽象函数 h()。这是处理非法调用抽象函数的子程序的名称。当你重写一个抽象函数时,C++ 编译器会将 VMT 中指向 _purecall 函数的指针替换为重写函数的地址(就像它会替换任何被重写函数的地址一样)。

11.5.8 共享 VMT

对于一个给定的类,内存中只有一份 VMT 副本。它是一个静态对象,因此所有该类类型的对象共享相同的 VMT。这是合理的,因为所有同类类型的对象具有完全相同的成员函数(参见 图 11-6)。

Image

图 11-6:共享相同 VMT 的对象(注意对象都是相同的类类型)

由于 VMT 中的地址在程序执行过程中永远不会改变,大多数语言将 VMT 放置在内存中的常量(只读保护)部分。在前面的例子中,编译器将 myclass 的 VMT 放置在 CONST 段。

11.5.9 类中的继承

继承是面向对象编程的基本概念之一。基本思想是一个类继承或复制某个已有类的所有字段,然后可能会扩展新类数据类型中的字段数量。例如,假设你创建了一个描述平面(二维)空间中点的数据类型 point。该点的类可能如下所示:


			class point
{
    public:
        float x;
        float y;

        virtual float distance( void );
};

distance() 成员函数可能会计算从原点(0,0)到由对象的 (x,y) 字段指定的坐标的距离。

这是该成员函数的典型实现:


			float point::distance( void )
{
    return sqrt( x*x + y*y );
}

继承允许你通过添加新的字段或替换现有字段来扩展一个已有的类。例如,假设你想将二维点的定义扩展到第三个空间维度。你可以通过以下 C++ 类定义轻松实现这一点:


			class point3D :public point
{
    public:
        float z;

        virtual void rotate( float angle1, float angle2 );
};

point3D 类继承了 xy 字段,以及 distance() 成员函数。(当然,distance() 并没有计算三维空间中点的正确结果,不过稍后我会讨论这个问题。)我所说的“继承”是指,point3D 对象将它们的 xy 字段定位在与 point 对象相同的偏移位置(见 图 11-7)。

Image

图 11-7:类中的继承

正如你可能注意到的,point3D 类实际上添加了两个项——一个新的数据字段 z 和一个新的成员函数 rotate()。在 图 11-7 中,你可以看到添加 rotate() 虚拟成员函数并没有对 point3D 对象的布局产生任何影响。这是因为虚拟成员函数的地址出现在 VMT 中,而不是对象本身中。虽然 pointpoint3D 都包含一个名为 VMT 的字段,但这些字段并不指向内存中的同一表格。每个类都有其独特的 VMT,该 VMT 如前所述,由指向类的所有成员函数(包括继承或显式声明的)的指针数组组成(见 图 11-8)。

Image

图 11-8:继承类的 VMT(假设为 32 位指针)

对于给定的类,所有对象共享相同的 VMT,但对于不同类的对象则不成立。由于 pointpoint3D 是不同的类,它们的对象的 VMT 字段将在内存中指向不同的 VMT。(见 图 11-9)。

Image

图 11-9:VMT 访问

到目前为止,point3D 定义的一个问题是,它从 point 类继承了 distance() 函数。默认情况下,如果一个类从另一个类继承了成员函数,则对应于这些继承函数的 VMT 条目将指向基类相关函数的地址。如果你有一个 point3D 类型的对象指针变量,比如 p3D,并调用成员函数 p3D->distance(),你将得不到正确的结果。因为 point3Dpoint 类继承了 distance() 函数,p3->distance() 会计算到 (x,y,z) 在二维平面上的投影的距离,而不是正确的三维平面上的值。在 C++ 中,你可以通过重载继承的函数,并编写一个新的、特定于 point3D 的成员函数来解决这个问题,代码如下:


			class point3D :public point
{
    public:
        float z;

        virtual float distance( void );
        virtual void rotate( float angle1, float angle2 );
};
float point3D::distance( void )
{
    return sqrt( x*x + y*y + z*z );
}

创建一个重载的成员函数不会改变类的数据布局或point3D VMT 的布局。这个函数引发的唯一变化是,C++ 编译器将 distance() 条目的地址初始化为 point3D::distance() 函数的地址,而不是 point::distance() 函数的地址。

11.5.10 类中的多态性

除了继承和重载外,多态性是面向对象编程的另一个基石。多态性,字面意思是“多面性”(或者翻译得更好一点是“多种形式”或“多种形状”),描述的是在你的程序中,像x->distance()这样的单一函数调用如何最终调用不同的函数(在上一节的例子中,这可能是point::distance()或者point3D::distance()函数)。之所以能够实现这一点,是因为 C++在处理派生类(继承类)时,会在一定程度上放宽类型检查。

我们来看一个例子。通常情况下,如果你尝试执行以下操作,C++编译器会报错:


			float f;
int *i;
    .
    .
    .
i = &f; // C++ isn't going to allow this.

C++不允许将某个对象的地址赋值给基类型与对象类型完全不匹配的指针——但有一个主要的例外。C++放宽了这一限制,只要指针的基类型与对象的类型匹配或是对象类型的祖先(祖先类是通过继承直接或间接派生出其他类类型的类),那么就可以将某个对象的地址赋值给指针。这意味着以下代码是合法的:


			point *p;
point3D *t;
point *generic;

    p = new point;
    t = new point3D;
        .
        .
        .
    generic = t;

如果你在想这如何可能是合法的,可以再看看图 11-7。如果generic的基类型是point,那么 C++编译器将允许访问对象中偏移量为 0 的 VMT,偏移量为 4(在 64 位机器上为 8)的x字段,以及偏移量为 8(16)的y字段。同样,任何尝试调用distance()成员函数的操作都会访问指向对象 VMT 字段的 VMT 中的函数指针。如果generic指向point类型的对象,那么所有这些要求都得到满足。如果generic指向point的任何派生类(即,任何继承自point字段的类),也是如此。派生类中(point3D)的额外字段无法通过generic指针访问,但这是预期的,因为generic的基类是point

然而,需要注意的一个关键点是,当你调用distance()成员函数时,实际上是调用了由point3D VMT 指向的函数,而不是由point VMT 指向的函数。这一事实是 C++等面向对象编程语言多态性的基础。编译器生成的代码与generic包含point类型对象地址时生成的代码完全相同。所有的“魔法”发生的原因是,编译器允许程序员将point3D对象的地址加载到generic中。

11.5.11 多重继承(在 C++中)

C++是为数不多的支持多重继承的现代编程语言之一,允许一个类从多个类继承数据和成员函数。考虑以下 C++代码片段:


			class a
{
    public:
        int i;
        virtual void setI(int i) { this->i = i; }
};

class b
{
    public:
        int j;
        virtual void setJ(int j) { this->j = j; }
};

class c : public a, public b
{
    public:
        int k;
        virtual void setK(int k) { this->k = k; }
};

在这个例子中,c 类继承了 ab 类的所有信息。在内存中,典型的 C++ 编译器会创建如图 11-10 所示的对象。

Image

图 11-10:多重继承内存布局

VMT 指针条目指向一个典型的 VMT,包含了 setI()setJ()setK() 方法的地址,如图 11-11 所示。如果你调用 setI() 方法,编译器将生成代码,将 this 指针加载为对象中 VMT 指针条目的地址(如图 11-10 所示的 c 对象的基地址)。进入 setI() 方法时,系统认为 this 指向的是一个类型为 a 的对象。特别地,this.VMT 字段指向一个 VMT,其第一个(对于类型 a 来说是唯一的)条目是 setI() 方法的地址。同样地,在内存中偏移量为 (this+8) 的位置(由于 VMT 指针为 8 字节,假设使用 64 位指针),setI() 方法将找到 i 数据值。对于 setI() 来说,this 指向的是一个类型为 a 的类对象(尽管它实际上指向的是一个类型为 c 的对象)。

Image

图 11-11:多重继承中的 this

当你调用 setK() 方法时,系统也会传递 c 对象的基地址。当然,setK() 方法期望的是一个类型为 c 的对象,并且 this 正指向一个类型为 c 的对象,因此对象中的所有偏移量都正如 setK() 所期望的那样。请注意,c 类型的对象(以及 c 类中的方法)通常会忽略 c 对象中的 VMT2 指针字段。

问题出现在程序尝试调用setJ()方法时。因为setJ()属于类b,它期望this指针指向一个 VMT 指针,该指针指向类b的 VMT。它还期望在偏移量(this+8)处找到数据字段j。如果我们将c对象的this指针传递给setJ(),访问(this+8)将引用数据字段i,而不是j。此外,如果类b的方法调用了类b中的另一个方法(例如setJ()递归调用自身),那么 VMT 指针将不正确——它指向一个 VMT,其中setI()的指针在偏移量 0,而类b期望它指向一个 VMT,其中setJ()的指针在偏移量 0。为了解决这个问题,典型的 C++编译器会在c对象中的j数据字段之前插入一个额外的 VMT 指针。它会初始化这个第二个 VMT 字段,指向c VMT 中类b方法指针开始的位置(见图 11-11)。当调用类b中的方法时,编译器会生成代码,用这个第二个 VMT 指针的地址初始化this指针(而不是指向内存中c类型对象的开头)。现在,当进入类b的方法——比如setJ()时——this将指向类b的一个合法 VMT 指针,而j数据字段将在偏移量(this+8)处出现,这是类b方法所期望的。

11.6 协议和接口

Java 和 Swift 不支持多重继承,因为它存在一些逻辑问题。经典的例子是“钻石格”数据结构。这种情况发生在两个类(比如bc)都从同一个类(比如a)继承信息,然后第四个类(比如d)从bc继承。结果,da继承了两次数据——一次通过b,一次通过c。这可能会导致一些一致性问题。

尽管多重继承可能会导致像这样的一些奇怪问题,但毫无疑问,从多个位置继承通常是非常有用的。因此,像 Java 和 Swift 这样的语言的解决方案是允许类从多个父类继承方法/函数,但只允许从一个祖先类继承。这避免了大多数多重继承的问题(特别是继承数据字段的模糊选择),同时允许程序员从不同来源包含方法。Java 称这种扩展为接口,而 Swift 称之为协议

这里是几个 Swift 协议声明的示例,以及一个支持该协议的类:


			protocol someProtocol
{
    func doSomething()->Void;
    func doSomethingElse() ->Void;
}
protocol anotherProtocol
{
    func doThis()->Void;
    func doThat() ->Void;
}

class supportsProtocols: someProtocol, anotherProtocol
{
    var i:Int = 0;
    func doSomething()->Void
    {        // appropriate function body
    }
    func doSomethingElse()->Void
    {        // appropriate function body
    }
    func doThis()->Void
    {        // appropriate function body
    }
    func doThat()->Void
    {        // appropriate function body
    }

}

Swift 协议不提供任何函数。相反,一个支持协议的类承诺提供协议中指定的函数的实现。在前面的示例中,supportsProtocols类负责提供它所支持的协议所要求的所有函数。实际上,协议就像只包含抽象方法的抽象类——继承的类必须为所有抽象方法提供实际的实现。

下面是前面的示例,用 Java 编写并演示其相应机制,即接口:


			interface someInterface
{
    void doSomething();
    void doSomethingElse();
}
interface anotherInterface
{
    void doThis();
    void doThat();
}

class supportsInterfaces  implements someInterface, anotherInterface
{
    int i;
    public void doSomething()
    {
        // appropriate function body
    }
    public void doSomethingElse()
    {
        // appropriate function body
    }
    public void doThis()
    {
        // appropriate function body
    }
    public void doThat()
    {
        // appropriate function body
    }
}

接口/协议在行为上有点类似于 Java 和 Swift 中的基类类型。如果你实例化一个类对象并将该实例赋值给一个接口/协议类型的变量,那么你可以执行该接口/协议所支持的成员函数。考虑以下 Java 示例:


			someInterface some = new supportsInterfaces();

// We can call the member functions defined for someInterface:

some.doSomething();
some.doSomethingElse();

// Note that it is illegal to try and call doThis or doThat
// (or access the i data field)
// using the "some" variable.

下面是一个 Swift 中的相似示例:


			import Foundation

protocol a
{
    func b()->Void;
    func c()->Void;
}

protocol d
{
    func e()->Void;
    func f()->Void;
}
class g : a, d
{
    var i:Int = 0;

    func b()->Void {print("b")}
    func c()->Void {print("c")}
    func e()->Void {print("e")}
    func f()->Void {print("f")}

    func local()->Void {print( "local to g" )}
}

var x:a = g()
x.b()
x.c()

协议或接口的实现非常简单——它只是一个指向 VMT 的指针,VMT 包含在该协议/接口中声明的函数地址。因此,前面示例中 Swift g类的数据结构将包含三个 VMT 指针:一个指向协议a,一个指向协议d,一个指向类g(其中包含指向local()函数的指针)。图 11-12 显示了类和 VMT 布局。

Image

图 11-12:多重继承内存布局

在图 11-12 中,类g的 VMT 指针包含整个 VMT 的地址。该类中有两个条目,分别包含指向协议a和协议d的 VMT 的指针。由于类g的 VMT 也包含指向这些协议所属函数的指针,因此无需为这两个协议单独创建 VMT;相反,aPtrdPtr字段可以指向类g的 VMT 中的相应条目。

当在前面的示例中发生赋值var x:a = g()时,Swift 代码会用g对象中持有的aPtr指针加载变量x。因此,对x.b()x.c()的调用就像正常的函数调用一样——系统使用x中持有的指针引用 VMT,然后通过索引适当的位置来调用bc。如果x的类型是d而不是a,那么赋值var x:d = g()将会加载xd协议 VMT 的地址(由dPtr指向)。对de的调用将发生在d VMT 的偏移量 0 和 8(64 位指针)处。

11.7 类、对象和性能

正如你在本章中看到的,与面向对象编程相关的直接成本并不特别显著。调用成员函数(方法)因为有双重间接引用,会稍微贵一些;然而,对于 OOP 所带来的灵活性来说,这只是一个小代价。额外的指令和内存访问大约只会占应用程序总性能的 10%左右。一些语言,例如 C++和 HLA,支持静态成员函数的概念,当多态性不必要时,允许直接调用成员函数。

面向对象程序员有时面临的一个大问题是过度应用面向对象原则。与其直接访问对象的字段,他们编写访问器函数来读取和写入这些字段值。除非编译器非常出色地内联这些访问器函数,否则访问对象字段的成本大约会增加一个数量级。换句话说,当过度使用 OOP 范式时,应用程序的性能可能会受到影响。采用“面向对象方式”做事(比如使用访问器函数访问对象的所有字段)可能有合理的原因,但要记住,这些成本会迅速累积。除非你绝对需要 OOP 技术提供的功能,否则你的程序可能会比必要时运行得更慢(并占用更多空间)。

Swift 是面向对象编程极致应用的一个很好的例子。任何将编译后的 Swift 代码与等效的 C++程序性能进行比较的人都知道,Swift 要慢得多。主要原因是 Swift 将一切都视作对象(并在运行时不断检查它们的类型和边界)。结果是,在 Swift 中执行同一任务可能需要数百条机器指令,而优化后的 C++编译器只需要半打机器指令。

许多面向对象程序的另一个常见问题是过度泛化。这种情况发生在程序员使用大量类库,通过继承扩展类以尽量减少编程工作量时。虽然节省编程工作通常是个好主意,但扩展类库可能会导致你需要完成一个小任务时,却调用了一个执行所有功能的库例程。问题在于,在面向对象系统中,库例程往往是高度层次化的。也就是说,你需要完成一些工作时,就调用你继承的某个类中的成员函数。该函数可能会对你传递的数据做一些处理,然后调用它继承的类中的成员函数。接着该函数再对数据进行一些处理,然后调用它继承的成员函数,以此类推。没过多久,CPU 花在调用和返回函数上的时间比实际做有用工作的时间还要多。虽然在标准(非面向对象)库中也可能发生这种情况,但在面向对象应用中,这种情况更加常见。

精心设计的面向对象程序的运行速度不必比同等的过程式程序显著慢。只需小心不要为了做一些琐碎的任务而调用过多的昂贵函数。

11.8 了解更多信息

Dershem, Herbert 和 Michael Jipping. 程序设计语言,结构与模型. 加利福尼亚州贝尔蒙特: Wadsworth 出版社, 1990 年。

Duntemann, Jeff. 汇编语言一步步学习. 第 3 版. 印第安纳波利斯: Wiley 出版社, 2009 年。

Ghezzi, Carlo 和 Jehdi Jazayeri. 程序设计语言的概念. 第 3 版. 纽约: Wiley 出版社, 2008 年。

Hyde, Randall. 汇编语言的艺术. 第 2 版. 旧金山: No Starch Press 出版社, 2010 年。

Knuth, Donald. 计算机程序设计的艺术,第一卷:基本算法. 第 3 版. 波士顿: Addison-Wesley Professional 出版社, 1997 年。

Ledgard, Henry 和 Michael Marcotty. 编程语言的全景. 芝加哥: SRA 出版社, 1986 年。

Louden, Kenneth C. 和 Kenneth A. Lambert. 程序设计语言,原理与实践. 第 3 版. 波士顿: Course Technology 出版社, 2012 年。

Pratt, Terrence W. 和 Marvin V. Zelkowitz. 程序设计语言,设计与实现. 第 4 版. 新泽西州上萨德尔河: 普伦蒂斯霍尔出版社, 2001 年。

Sebesta, Robert. 程序设计语言的概念. 第 11 版. 波士顿: Pearson 出版社, 2016 年。

第十二章:算术与逻辑表达式

image

高级语言相较于低级语言的一个主要优势是使用代数算术和逻辑表达式(以下简称“算术表达式”)。高级语言的算术表达式在可读性上比编译器生成的机器指令序列高一个数量级。然而,将算术表达式转换为机器代码的过程也是最难以高效完成的转换之一,典型编译器的优化阶段有相当一部分时间用于处理这一过程。由于翻译的难度,这是你可以帮助编译器的一个领域。本章将描述:

  • 计算机架构如何影响算术表达式的计算

  • 算术表达式的优化

  • 算术表达式的副作用

  • 算术表达式中的序列点

  • 算术表达式中的求值顺序

  • 算术表达式的短路与完全求值

  • 算术表达式的计算成本

有了这些信息,你将能够编写更高效、更强大的应用程序。

12.1 算术表达式与计算机架构

就算术表达式而言,我们可以将传统的计算机架构分为三种基本类型:基于栈的机器、基于寄存器的机器和基于累加器的机器。这些架构类型之间的主要区别在于 CPU 将算术操作的操作数存放在哪里。一旦 CPU 从这些操作数中获取数据,数据将传递给算术和逻辑单元,实际的算术或逻辑计算将在这里发生。^(1)我们将在接下来的章节中探讨这些架构。

12.1.1 基于栈的机器

基于栈的机器在大多数计算中使用内存,采用一种名为的数据结构来存储所有操作数和结果。具有栈架构的计算机系统在某些方面相较于其他架构有一些重要的优势:

  • 在栈架构中,指令通常较小,因为这些指令通常不需要指定任何操作数。

  • 编写栈架构的编译器通常比编写其他机器的编译器更容易,因为将算术表达式转换为一系列栈操作非常简单。

  • 在栈架构中,临时变量很少需要,因为栈本身就可以完成这个任务。

不幸的是,栈机器也有一些严重的缺点:

  • 几乎每条指令都引用内存(在现代机器上内存较慢)。尽管缓存可以帮助缓解这个问题,但内存性能仍然是栈机器上的一个主要问题。

  • 尽管从高级语言转换到栈机器非常容易,但与其他架构相比,优化的机会较少。

  • 由于栈机器不断访问相同的数据元素(即栈顶的数据),因此实现流水线和指令并行性是困难的。

注意

参见 WGC1 了解流水线和指令并行性的详细信息。

使用栈时,通常会执行以下三种操作之一:将新数据压入栈、从栈中弹出数据,或操作当前位于栈顶的数据(并可能操作栈顶下方的数据,或栈中的下一个数据)。

12.1.1.1 基本栈机器组织

典型的栈机器在 CPU 内部维护几个寄存器(见图 12-1)。特别地,你可以找到一个程序计数器寄存器(如 80x86 的 RIP 寄存器)和一个栈指针寄存器(如 80x86 的 RSP 寄存器)。

Image

图 12-1:典型的栈机器架构

栈指针寄存器包含当前栈顶元素(TOS)在内存中的地址。每当程序将数据压入栈中或从栈中移除数据时,CPU 都会递增或递减栈指针寄存器。在某些架构中,栈从高地址向低地址扩展;在其他架构中,栈从低地址向高地址增长。从根本上讲,栈增长的方向并不重要;它真正决定的是,机器在将数据压入栈时是递减栈指针寄存器(如果栈向低地址增长)还是递增栈指针寄存器(如果栈向高地址增长)。

12.1.1.2 push 指令

要将数据压入栈中,通常使用机器指令push。该指令通常包含一个操作数,用于指定要压入栈的数据值,例如:


			push memory or constant operand

这里有几个具体的例子:


			push 10  ; Pushes the constant 10 onto the stack
push mem ; Pushes the contents of memory location mem

push操作通常会将栈指针寄存器的值增加操作数大小的字节数,然后将该操作数复制到栈指针现在指定的内存位置。例如,图 12-2 和图 12-3 展示了push 10操作前后的栈情况。

Image

图 12-2:执行 push 10 操作前

Image

图 12-3:执行 push 10 操作后

12.1.1.3 pop 指令

要从栈顶移除数据项,使用poppull指令。(本书将使用pop;不过请注意,有些架构使用pull代替。)典型的pop指令可能如下所示:


			pop memory location

注意

你不能将数据弹出到常量中。pop操作数必须是一个内存位置。

pop 指令将堆栈指针指向的数据复制并存储到目标内存位置。然后,它递减(或递增)堆栈指针寄存器,以指向堆栈上的下一个较低项或下一个堆栈项(NOS);见 图 12-4 和 12-5。

图片

图 12-4:执行 pop mem 操作之前

图片

图 12-5:执行 pop mem 操作之后

请注意,pop 指令从堆栈中移除的堆栈内存中的值仍然物理存在于新 TOS 上方的内存中。然而,下次程序将数据推送到堆栈时,它会用新值覆盖此值。

12.1.1.4 堆栈机器上的算术操作

堆栈机器上的算术和逻辑指令通常不允许任何操作数。这就是为什么堆栈机器通常被称为 零地址机器;算术指令本身并不编码任何操作数地址。例如,考虑一个典型堆栈机器上的 add 指令。此指令将从堆栈中弹出两个值(TOS 和 NOS),计算它们的和,并将结果推送回堆栈(见 图 12-6 和 12-7)。

图片

图 12-6:执行加法操作之前

图片

图 12-7:执行加法操作之后

由于算术表达式本质上是递归的,而递归需要堆栈才能正确实现,因此将算术表达式转换为堆栈机器指令序列相对简单也不足为奇。常见编程语言中的算术表达式使用 中缀表示法,即操作符位于两个操作数之间。例如,a + bc - d 是中缀表示法的例子,因为操作符(+-)出现在操作数([a, b] 和 [c, d])之间。在你能够进行堆栈机器指令的转换之前,你必须将这些中缀表达式转换为 后缀表示法(也称为 逆波兰表示法),在后缀表示法中,操作符紧随其后作用的操作数。例如,中缀表达式 a + bc - d 对应的后缀表达式分别为 a b +c d -

一旦你拥有一个后缀表达式,将其转换为堆栈机器指令序列非常简单。你只需为每个操作数发出一个 push 指令,并为操作符发出相应的算术指令。例如,a b + 转换为:


			push a
push b
add

c d - 转换为:


			push c
push d
sub

当然,假设 add 操作将堆栈顶部的两个项相加,sub 操作将 TOS 与其下方的值相减。

12.1.1.5 现实世界中的堆栈机器

栈架构的一个巨大优势是,它很容易为这种机器编写编译器。为基于栈的机器编写仿真器也非常简单。正因为如此,栈架构在虚拟机 (VM) 中非常流行,例如 Java 虚拟机、UCSD Pascal p-machine,以及微软的 Visual Basic、C# 和 F# CIL。尽管确实存在一些现实世界中的基于栈的 CPU,例如 Java VM 的硬件实现,但由于内存访问的性能限制,它们并不太受欢迎。尽管如此,理解栈架构的基本原理仍然很重要,因为许多编译器会在生成实际机器代码之前将高级语言源代码转换为基于栈的形式。事实上,在最糟糕的情况下(尽管这种情况很少见),编译器在编译复杂的算术表达式时被迫生成模拟基于栈的机器的代码。

12.1.2 基于累加器的机器

栈机器指令序列的简单性掩盖了巨大的复杂性。考虑上一节中的以下基于栈的指令:

add

这条指令看起来很简单,但实际上它指定了大量的操作:

  • 从栈指针指向的内存位置获取一个操作数。

  • 将栈指针的值发送到ALU (算术/逻辑单元)

  • 指示 ALU 减少刚刚发送给它的栈指针的值。

  • 将 ALU 的值路由回栈指针。

  • 从栈指针指向的内存位置获取操作数。

  • 将上一步和第一步的值发送到 ALU。

  • 指示 ALU 对这些值进行加法运算。

  • 将和存储在栈指针指向的内存位置。

一个典型栈机器的组织方式会阻止许多通过流水线实现的并行操作(有关流水线的更多细节,请参见WGC1)。因此,栈架构面临两次挑战:典型的指令需要多个步骤才能完成,而且这些步骤很难与其他操作并行执行。

栈架构的一个大问题是它几乎所有的操作都需要访问内存。例如,如果你只是想计算两个变量的和并将结果存储到第三个变量,你必须先从内存中取出两个变量并将它们写入栈中(四次内存操作);然后你必须从栈中取出这两个值,进行加法运算,并将它们的和写回栈中(三次内存操作);最后,你必须从栈中弹出该项,并将结果存储到目标内存位置(两次内存操作)。总共是九次内存操作。当内存访问速度较慢时,这是一种计算两个数字和的高成本方式。

避免大量内存访问的一种方法是提供一个通用算术寄存器。累加器机器的理念就是提供一个单一的累加器寄存器,CPU 在此寄存器中计算临时结果,而不是在内存(栈上)中计算临时值。累加器机器也被称为单地址单一地址机器,因为大多数操作两个操作数的指令都将累加器作为默认的目标操作数,并且需要一个内存或常数操作数作为源操作数。累加器机器的典型例子是 6502,它包含以下指令:


			LDA constant or memory ; Load accumulator register
STA memory             ; Store accumulator register
ADD constant or memory ; Add operand to accumulator
SUB constant or memory ; Subtract operand from accumulator

由于单地址指令需要一个在许多零地址指令中不存在的操作数,因此,累加器机器上的单个指令通常比典型的栈机器上的指令更大(因为你必须将操作数地址作为指令的一部分进行编码;详情见WGC1)。然而,程序通常更小,因为完成相同操作所需的指令更少。例如,假设你要计算x = y + z。在栈机器上,你可能会使用如下的指令序列:


			push y
push z
add
pop x

在累加器机器上,你可能会使用如下的指令序列:


			lda y
add z
sta x

假设pushpop指令的大小大致与累加器机器的ldaaddsta指令相同(这是一个安全的假设),可以明确看到栈机器的指令序列实际上更长,因为它需要更多的指令。即便忽略栈机器中的额外指令,累加器机器也可能执行代码更快,因为它只需要三次内存访问(用于获取yz,以及存储x),相比之下栈机器需要九次内存访问。此外,累加器机器在计算过程中不会浪费时间操作栈指针寄存器。

尽管基于累加器的机器通常比基于栈的机器具有更高的性能(正如你刚才所看到的原因),但它们也并非没有问题。由于仅有一个通用寄存器可用于算术操作,这在系统中形成了瓶颈,导致了数据冒险。许多计算会产生临时结果,应用程序必须将这些结果写入内存,以便计算表达式的其他部分。这导致了额外的内存访问,如果 CPU 提供额外的累加器寄存器,这些访问本可以避免。因此,大多数现代通用 CPU 不使用基于累加器的架构,而是提供大量的通用寄存器。

注意

参见 WGC1 讨论数据冒险问题。

基于累加器的架构在早期计算机系统中非常流行,当时制造工艺限制了 CPU 内的功能数量,但今天除了低成本嵌入式微控制器外,几乎看不到它们。

12.1.3 基于寄存器的机器

在本章讨论的三种架构中,基于寄存器的机器是目前最常见的,因为它们提供了最高的性能。通过提供相当数量的 CPU 内寄存器,这种架构在计算复杂表达式时能够避免 CPU 进行昂贵的内存访问。

理论上,一个基于寄存器的机器可以只有两个通用(算术能力)寄存器。在实践中,唯一符合这一类别的机器是摩托罗拉 680x 处理器,大多数人认为它是带有两个独立累加器的累加器架构的一个特例。寄存器机器通常至少包含八个“通用”寄存器(这个数字并非随意的;它是 80x86 CPU、8080 CPU 和 Z80 CPU 中发现的通用寄存器数量,这些可能是计算机架构师所称的“基于寄存器”的机器的最简化示例)。

虽然一些基于寄存器的机器(如 32 位的 80x86)有较少的可用寄存器,但一个普遍的原则是“越多越好”。典型的 RISC 机器,如 PowerPC 和 ARM,至少有 16 个通用寄存器,并且通常至少有 32 个寄存器。例如,英特尔的 Itanium 处理器提供 128 个通用整数寄存器。IBM 的 CELL 处理器在每个处理单元中提供 128 个寄存器(每个处理单元是一个能够执行特定操作的微型 CPU);典型的 CELL 处理器包含八个这样的处理单元,并且配有一个 PowerPC CPU 核心。

拥有尽可能多的通用寄存器的主要原因是为了避免内存访问。在基于累加器的机器中,累加器是一个用于计算的瞬态寄存器,但你不能将一个变量的值长时间保存在那里,因为你还需要使用累加器执行其他任务。在一个具有大量寄存器的寄存器机器中,可以将某些(经常使用的)变量保存在寄存器中,这样在使用这些变量时就不必进行内存访问。考虑赋值语句 x := y+z;在一个基于寄存器的机器(如 80x86)上,我们可以使用以下 HLA 代码来计算这个结果:


			// Note: Assume x is held in EBX, y is held in ECX,
// and z is held in EDX:

mov( ecx, ebx );
add( edx, ebx );

这里只需要两条指令,并且不需要进行内存访问(对于变量)。这比基于累加器或堆栈的架构效率要高得多。从这个例子中,您可以看到为什么基于寄存器的架构在现代计算机系统中变得如此普遍。

正如您将在接下来的章节中看到的那样,寄存器机器通常被描述为两地址机器或三地址机器,具体取决于特定 CPU 的架构。

12.1.4 算术表达式的典型形式

计算机架构师已经深入研究了典型的源文件,他们发现的一个事实是,大部分赋值语句采用以下几种形式之一:


			var = var2;
var = constant;
var = op var2;
var = var op var2;
var = var2 op var3;

尽管其他类型的赋值语句也存在,但程序中采用这些形式的语句通常比其他任何赋值语句形式都要多。因此,计算机架构师通常会优化他们的 CPU,以高效地处理这些形式。

12.1.5 三地址架构

许多机器使用三地址架构。这意味着一个算术语句支持三个操作数:两个源操作数和一个目标操作数。例如,大多数 RISC CPU 提供add指令,将两个操作数的值相加并将结果存储到第三个操作数中:


			add source1, source2, dest

在这种架构中,操作数通常是机器寄存器(或小常数),所以通常你会按如下方式编写此指令(假设你使用R0、R1、……、Rn来表示寄存器):

add r0, r1, r2   ; computes r2 := r0 + r1

由于 RISC 编译器尝试将变量保存在寄存器中,因此这条单指令处理了上一节中给出的最后一个赋值语句:

var = var2 op var3;

处理如下形式的赋值:

var = var op var2;

也相对简单——只需将目标寄存器作为其中一个源操作数,如下所示:

add r0, r1, r0  ; computes r0 := r0 + r1

三地址架构的缺点是,你必须将所有三个操作数编码到每个支持三个操作数的指令中。这就是为什么三操作数指令通常只对寄存器操作数进行操作的原因。编码三个单独的内存地址可能相当昂贵——问问任何 VAX 程序员就知道了。DEC VAX 计算机系统是一个很好的三地址 CISC 机器示例。

12.1.6 二地址架构

80x86 架构被称为二地址机器。在这种架构中,一个源操作数也是目标操作数。考虑以下 80x86/HLA add指令:

add( ebx, eax );  ; computes eax := eax + ebx;

二地址机器,如 80x86,可以用单个指令处理前面给出的赋值语句的前四种形式。然而,最后一种形式需要两个或更多指令和一个临时寄存器。例如,要计算:

var1 = var2 + var3;

你需要使用以下代码(假设var2var3是内存变量,且编译器将var1保存在 EAX 寄存器中):


			mov( var2, eax );
add( var3, eax );  //Result (var1) is in EAX.

12.1.7 架构差异与你的代码

一地址、二地址和三地址架构具有以下层次结构:

1 地址2 地址3 地址

也就是说,二地址机器能够做任何一地址机器能做的事情,而三地址机器能够做任何一地址或二地址机器能做的事情。证明非常简单:^(2)

  • 为了证明二地址机器可以做任何单地址机器能做的事情,只需选择二地址机器上的一个寄存器,并在模拟单地址架构时将其用作“累加器”。

  • 为了证明三地址机器可以完成任何二地址机器能够做的事情,只需将同一个寄存器同时用作一个源操作数和目标操作数,从而使所有操作仅限于两个寄存器(操作数/地址)。

根据这个层次结构,你可能会认为如果你限制编写的代码,使其在单地址机器上运行良好,那么它在所有机器上都会获得良好的结果。实际上,今天大多数通用 CPU 都是二地址或三地址机器,因此将代码写成偏向单地址机器可能会限制二地址或三地址机器上可能的优化。此外,优化质量在不同编译器之间差异如此之大,以至于很难为这样的说法提供支持。如果你希望编译器生成最佳的代码,最好还是尝试创建符合之前给出的五种形式的表达式(在《典型的算术表达式形式》部分的第 394 页)。因为大多数现代程序都运行在二地址或三地址机器上,本章其余部分假设使用的是这种环境。

12.1.8 复杂表达式

一旦你的表达式比之前给出的五种形式更复杂,编译器将必须生成两条或更多指令的序列来计算它们。在编译代码时,大多数编译器会将复杂的表达式内部转换为一系列与其语义等价的“三地址语句”,如以下示例所示:


			// complex = ( a + b ) * ( c - d ) - e/f;

temp1 = a + b;
temp2 = c - d;
temp1 = temp1 * temp2;
temp2 = e / f;
complex = temp1 - temp2;

如你所见,这五条语句在语义上等同于注释中出现的复杂表达式。计算中的主要区别是引入了两个临时值(temp1temp2)。大多数编译器会尝试使用机器寄存器来维护这些临时值。

由于编译器内部将复杂指令转换为三地址语句的序列,你可能会想,是否可以通过自己将复杂的表达式转换为三地址语句来帮助编译器。嗯,这取决于你的编译器。对于许多(优秀的)编译器,将复杂的计算分解成更小的部分,实际上可能会妨碍编译器优化某些语句的能力。因此,在处理算术表达式时,大多数情况下你应该做好自己的工作(尽可能清晰地编写代码),让编译器做好它的工作(优化结果)。然而,如果你能使用一种自然转化为二地址或三地址形式的方式来指定计算,毫不犹豫地去做。至少,它对编译器生成的代码没有任何影响。在某些特殊情况下,它甚至可能帮助编译器生成更好的代码。不管怎样,结果代码可能会更易于阅读和维护,如果它不那么复杂的话。

12.2 算术语句的优化

因为高级语言编译器最初是为了让程序员在源代码中使用类似代数的表达式而设计的,所以这是计算机科学中一个已经得到充分研究的领域。大多数现代编译器都提供了合理的优化器,能够很好地将算术表达式转化为机器码。你通常可以假设你使用的编译器在优化算术表达式时不需要太多帮助(如果它确实需要,你可能考虑换一个更好的编译器,而不是尝试手动优化代码)。

为了帮助你理解编译器为你做的工作,本节讨论了一些你可以从现代优化编译器中预期的典型优化。通过了解一个(优秀的)编译器的工作,你可以避免手动优化那些它能够处理的部分。

12.2.1 常量折叠

常量折叠是一种优化技术,它在编译时计算常量表达式或子表达式的值,而不是在运行时生成代码来计算其结果。例如,支持这种优化的 Pascal 编译器会将类似i := 5 + 6;的语句在生成机器码之前转化为i := 11;。这样可以避免在运行时执行add指令。再举一个例子,假设你想分配一个包含 16MB 存储的数组。可以通过以下方式来实现:

char bigArray[ 16777216 ]; // 16 MB of storage

这个方法唯一的问题是 16,777,216 是一个魔法数字。它代表的是 2²⁴的值,而不是其他任意值。现在考虑以下 C/C++声明:

char bigArray[ 16*1024*1024 ]; // 16 MB of storage

大多数程序员知道 1,024 乘以 1,024 是二进制的百万,而这个值的 16 倍对应 16 兆什么的。是的,你需要知道子表达式 16*1024*1024 等同于 16,777,216。但这个模式更容易被识别为 16MB(至少在字符数组中使用时),而不是 16777216(或者是 16777214?)。在这两种情况下,编译器分配的存储量完全相同,但第二种情况可以说更具可读性。因此,这是更好的代码。^(3)

变量声明并不是唯一可以使用此优化的地方。任何包含常量操作数的算术表达式(或子表达式)都可以作为常量折叠的候选。因此,如果你能够通过使用常量表达式而不是手动计算结果,使算术表达式更清晰,应该选择更具可读性的版本,并让编译器在编译时处理常量计算。如果你的编译器不支持常量折叠,你当然可以通过手动执行所有常量计算来模拟它。然而,这应该仅作为最后的手段。寻找一个更好的编译器通常是更好的选择。

一些优秀的优化编译器在折叠常量时可能会采取极端手段。例如,一些开启了足够高优化级别的编译器会将某些带有常量参数的函数调用替换为相应的常量值。例如,编译器可能会将 C/C++ 语句 sineR = sin(0); 转换为 sineR = 0;(因为零弧度的正弦值为 0)。然而,这种类型的常量折叠并不常见,通常需要启用特别的编译器模式才能实现。

如果你对自己的编译器是否支持常量折叠有任何疑问,可以让编译器生成汇编清单并查看其输出(或使用调试器查看反汇编输出)。下面是一个用 C/C++ 编写的简单示例(使用 Visual C++ 编译):


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

// Assembly output for sequence above (optimizations turned off!)

        mov     DWORD PTR i$[rsp], 16777216             ; 01000000H

        mov     edx, DWORD PTR i$[rsp]
        lea     rcx, OFFSET FLAT:$SG7883
        call    printf

这是一个用 Java 编写的相应程序:


			public class Welcome
{
      public static void main( String[] args )
      {
            int i = 16 * 1024 * 1024;
            System.out.println( i );
      }
}

// JBC generated by the compiler:

javap -c Welcome
Compiled from "Welcome.java"
public class Welcome extends java.lang.Object{
public Welcome();
  Code:
   0:   aload_0

        ; //Method java/lang/Object."<init>":()V
   1:   invokespecial   #1

   4:   return

public static void main(java.lang.String[]);
  Code:
   0:   ldc #2; //int 16777216
   2:   istore_1

        ; //Field java/lang/System.out:Ljava/io/PrintStream;
   3:   getstatic   #3

   6:   iload_1

        ; //Method java/io/PrintStream.println:(I)V
   7:   invokevirtual   #4   10:  return

}

请注意,ldc #2 指令将常量池中的常量推送到栈上。附加在这条字节码指令上的注释解释了 Java 编译器将 16*1024*1024 转换为一个单一常量 16777216。Java 在编译时进行常量折叠,而不是在运行时计算这个乘积。

这是相应的 Swift 程序,并附带了相关部分^(4)的汇编代码:


			import Foundation

var i:Int = 16*1024*1024
print( "i=\(i)" )

// code produced by
// "xcrun -sdk macosx
//       swiftc -O -emit-assembly main.swift -o result.asm"

       movq    $16777216, _$S6result1iSivp(%rip)

正如你所看到的,Swift 也支持常量折叠优化。

12.2.2 常量传播

常量传播是一种优化技术,编译器使用它来将变量访问替换为常量值,如果编译器确定这样做是可能的。例如,支持常量传播的编译器将进行如下优化:


			// original code:

    variable = 1234;
    result = f( variable );

// code after constant propagation optimization

    variable = 1234;
    result = f( 1234 );

在目标代码中,操作立即常量通常比操作变量更有效;因此,常量传播通常会产生更好的代码。在某些情况下,常量传播还可以使编译器完全消除某些变量和语句(在这个例子中,如果源代码中没有后续对变量对象的引用,编译器可以删除 variable = 1234;)。

在某些情况下,编写良好的编译器可以进行一些令人惊讶的优化,涉及常量折叠。考虑以下 C 代码:


			#include <stdio.h>
static int rtn3( void )
{
    return 3;
}
int main( void )
{
    printf( "%d", rtn3() + 2 );
    return( 0 );
}

这是 GCC 在启用 -O3(最大)优化选项后生成的 80x86 输出:


			.LC0:
        .string "%d"
        .text
        .p2align 2,,3
.globl main
        .type   main,@function
main:
        ; Build main's activation record:

        pushl   %ebp
        movl    %esp, %ebp
        subl    $8, %esp
        andl    $-16, %esp
        subl    $8, %esp

        ; Print the result of "rtn3()+5":

        pushl   $5      ; Via constant propagation/folding!
        pushl   $.LC0
        call    printf
        xorl    %eax, %eax
        leave
        ret

快速查看表明,rtn3() 函数在哪里都找不到。启用了 -O3 命令行选项后,GCC 发现 rtn3() 仅仅返回一个常量,因此它将常量返回结果传播到你调用 rtn3() 的每个地方。在 printf() 函数调用的情况下,常量传播和常量折叠的结合生成了一个单一的常量 5,该常量被传递给 printf() 函数。

和常量折叠一样,如果你的编译器不支持常量传播,你可以手动模拟它,但这只是最后的手段。再次强调,找到一个更好的编译器几乎总是更好的选择。

你可以开启编译器的汇编语言输出,来判断你的编译器是否支持常量传播。例如,以下是 Visual C++ 的输出(启用了 /O2 优化级别):


			#include <stdio.h>

int f(int a)
{
      return a + 1;
}

int main(int argc, char **argv)
{
      int i = 16 * 1024 * 1024;
      int j = f(i);
      printf( "%d\n", j);
}

// Assembly language output for the above code:

main    PROC                                            ; COMDAT

$LN6:
        sub     rsp, 40                                 ; 00000028H

        mov     edx, 16777217                           ; 01000001H
        lea     rcx, OFFSET FLAT:??_C@_02DPKJAMEF@?$CFd?$AA@
        call    printf

        xor     eax, eax
        add     rsp, 40                                 ; 00000028H
        ret     0
main    ENDP

正如你所看到的,Visual C++ 也消除了 f() 函数以及 ij 变量。它在编译时计算了函数结果(i+1),并将常量 1677721716*1024*1024 + 1)替换到所有的计算中。

这是一个使用 Java 的示例:


			public class Welcome
{
      public static int f( int a ) { return a+1;}
      public static void main( String[] args )
      {
            int i = 16 * 1024 * 1024;
            int j = f(i);
            int k = i+1;
            System.out.println( j );
            System.out.println( k );
       }
}

// JBC emitted for this Java source code:

javap -c Welcome
Compiled from "Welcome.java"
public class Welcome extends java.lang.Object{
public Welcome();
  Code:
   0:   aload_0

        ; //Method java/lang/Object."<init>":()V
   1:   invokespecial   #1
   4:   return

public static int f(int);
  Code:
   0:   iload_0
   1:   iconst_1
   2:   iadd
   3:   ireturn

public static void main(java.lang.String[]);
  Code:
   0:   ldc #2; //int 16777216
   2:   istore_1
   3:   iload_1
   4:   invokestatic    #3; //Method f:(I)I
   7:   istore_2
   8:   iload_1
   9:   iconst_1
   10:  iadd
   11:  istore_3

        ; //Field java/lang/System.out:Ljava/io/PrintStream;
   12:  getstatic   #4
   15:  iload_2

        ; //Method java/io/PrintStream.println:(I)V
   16:  invokevirtual   #5

        ; //Field java/lang/System.out:Ljava/io/PrintStream;
   19:  getstatic   #4
   22:  iload_3

        ; //Method java/io/PrintStream.println:(I)V
   23:  invokevirtual   #5
   26:  return

}

快速查看这个 Java 字节码显示,Java 编译器(java version "1.6.0_65")不支持常量传播优化。它不仅没有消除 f() 函数,而且也没有消除变量 ij,并且它将 i 的值传递给 f() 函数,而不是传递合适的常量。有人可能会说,Java 的字节码解释会显著影响性能,因此像常量传播这样的简单优化不会对性能产生太大影响。

这是用 Swift 编写的类似程序,以及编译器的汇编输出:


			import Foundation

func f( _ a:Int ) -> Int
{
    return a + 1
}
let i:Int = 16*1024*1024
let j = f(i)
print( "i=\(i), j=\(j)" )

// Assembly output via the command:
// xcrun -sdk macosx swiftc -O -emit-assembly main.swift -o result.asm

    movq    $16777216, _$S6result1iSivp(%rip)
    movq    $16777217, _$S6result1jSivp(%rip)
    .
    .   // Lots of code that has nothing to do with the Swift source
    .
    movl    $16777216, %edi
    callq   _$Ss26_toStringReadOnlyPrintableySSxs06CustomB11ConvertibleRzlFSi_Tg5
    .
    .
    .
    movl    $16777217, %edi
    callq   _$Ss26_toStringReadOnlyPrintableySSxs06CustomB11ConvertibleRzlFSi_Tg5

Swift 编译器生成了大量代码来支持其运行时系统,因此你几乎不能称 Swift 为一个 优化 编译器。话虽如此,它生成的汇编代码表明,Swift 支持常量传播优化。它消除了 f() 函数,并将计算结果的常量传播到打印 ij 值的调用中。它没有消除 ij(可能是因为与运行时系统有关的一些一致性问题),但它确实通过编译代码传播了常量。

鉴于 Swift 编译器生成的代码量过大,是否值得进行这种优化值得怀疑。然而,即使有了所有这些额外的代码(这里太多了,无法全部打印出来,欢迎自己查看),输出仍然比解释执行的 Java 代码运行得更快。

12.2.3 死代码消除

死代码消除是指删除与特定源代码语句相关的目标代码,如果程序再也不使用该语句的结果。这通常是编程错误的结果。(毕竟,为什么有人会计算一个值却不使用它呢?)如果编译器在源文件中遇到死代码,它可能会警告你检查代码逻辑。然而,在某些情况下,早期的优化可能会产生死代码。例如,前面示例中值变量的常量传播可能会导致语句variable = 1234;变成死代码。支持死代码消除的编译器会悄悄地从目标文件中删除这条语句的目标代码。

作为死代码消除的一个示例,考虑以下 C 程序及其相应的汇编代码:


			static int rtn3( void )
{
    return 3;
}

int main( void )
{
    int i = rtn3() + 2;

    // Note that this program
    // never again uses the value of i.

    return( 0 );
}

这是 GCC 在提供-O3命令行选项时生成的 32 位 80x86 代码:


			.file   "t.c"
        .text
        .p2align 2,,3
.globl main
        .type   main,@function
main:
        ; Build main's activation record:

        pushl   %ebp
        movl    %esp, %ebp
        subl    $8, %esp
        andl    $-16, %esp

        ; Notice that there is no
        ; assignment to i here.

        ; Return 0 as main's function result.

        xorl    %eax, %eax
        leave
        ret

现在考虑 GCC 在未启用优化时的 80x86 输出:


			.file   "t.c"
        .text
        .type   rtn3,@function
rtn3:
        pushl   %ebp
        movl    %esp, %ebp
        movl    $3, %eax
        leave
        ret
.Lfe1:
        .size   rtn3,.Lfe1-rtn3
.globl main
        .type   main,@function
main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $8, %esp
        andl    $-16, %esp
        movl    $0, %eax
        subl    %eax, %esp

        ; Note the call and computation:

        call    rtn3
        addl    $2, %eax
        movl    %eax, -4(%ebp)

        ; Return 0 as the function result.

        movl    $0, %eax
        leave
        ret

实际上,本书中大多数程序示例调用像printf()这样的函数来显示各种值,主要是为了显式地使用这些值,以防止死代码消除将我们正在检查的代码从汇编输出文件中删除。如果你从这些示例中的 C 程序中移除最后一个printf(),大部分汇编代码将因为死代码消除而消失。

这是前面 Visual C++代码的输出:


			; Listing generated by Microsoft (R) Optimizing Compiler Version 19.00.24234.1

include listing.inc

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

PUBLIC  main
; Function compile flags: /Ogtpy
; File c:\users\rhyde\test\t\t\t.cpp
_TEXT   SEGMENT
main    PROC

        xor     eax, eax

        ret     0
main    ENDP
_TEXT   ENDS
; Function compile flags: /Ogtpy
; File c:\users\rhyde\test\t\t\t.cpp
_TEXT   SEGMENT
rtn3    PROC

        mov     eax, 3

        ret     0
rtn3    ENDP
_TEXT   ENDS
END

与 GCC 不同,Visual C++没有删除rtn3()函数。然而,它确实删除了对i的赋值以及对rtn3()的调用——这些都在主程序中被移除。

下面是等效的 Java 程序和 JBC 输出:


			public class Welcome
{
    public static int rtn3() { return 3;}
    public static void main( String[] args )
    {
        int i = rtn3();
    }
}

// JBC output:

public class Welcome extends java.lang.Object{
public Welcome();
  Code:
   0:   aload_0

        ; //Method java/lang/Object."<init>":()V
   1:   invokespecial   #1
   4:   return

public static int rtn3();
  Code:
   0:   iconst_3
   1:   ireturn

public static void main(java.lang.String[]);
  Code:
   0:   invokestatic    #2; //Method rtn3:()I
   3:   istore_1
   4:   return
}

初看之下,似乎 Java 不支持死代码消除。然而,问题可能是我们的示例代码没有触发编译器的这个优化。让我们尝试一些编译器更容易识别的代码:


			public class Welcome
{
    public static int rtn3() { return 3;}
    public static void main( String[] args )
    {
        if( false )
        {   int i = rtn3();
        }
    }
}

// Here's the output bytecode:

Compiled from "Welcome.java"
public class Welcome extends java.lang.Object{
public Welcome();
  Code:
   0:   aload_0

        ; //Method java/lang/Object."<init>":()V
   1:   invokespecial   #1
   4:   return

public static int rtn3();
  Code:
   0:   iconst_3
   1:   ireturn

public static void main(java.lang.String[]);
  Code:
   0:   return
}

现在我们给 Java 编译器提供了一些它可以处理的内容。主程序消除了对rtn3()的调用和对i的赋值。这个优化不如 GCC 或 Visual C++的优化聪明,但(至少)在某些情况下,它是有效的。不幸的是,没有常量传播,Java 错过了很多死代码消除的机会。

下面是前一个示例的等效 Swift 代码:


			import Foundation

func rtn3() -> Int
{
    return 3
}
let i:Int = rtn3()
// Assembly language output:

_main:
    pushq   %rbp
    movq    %rsp, %rbp
    movq    $3, _$S6result1iSivp(%rip)
    xorl    %eax, %eax
    popq    %rbp
    retq

    .private_extern _$S6result4rtn3SiyF
    .globl  _$S6result4rtn3SiyF
    .p2align    4, 0x90
_$S6result4rtn3SiyF:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $3, %eax
    popq    %rbp
    retq

请注意,Swift(至少对于这个示例)不支持死代码消除。然而,让我们尝试像在 Java 中做的那样。考虑以下代码:


			import Foundation

func rtn3() -> Int
{
    return 3
}
if false
{
    let i:Int = rtn3()
}

// Assembly output

_main:
    pushq   %rbp
    movq    %rsp, %rbp
    xorl    %eax, %eax
    popq    %rbp
    retq

    .private_extern _$S6result4rtn3SiyF
    .globl  _$S6result4rtn3SiyF
    .p2align    4, 0x90
_$S6result4rtn3SiyF:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $3, %eax
    popq    %rbp
    retq

编译这段代码会生成关于死代码的警告列表,但输出结果证明了 Swift 确实支持死代码消除。此外,由于 Swift 也支持常量传播,它不会像 Java 那样错过那么多死代码消除的机会(尽管 Swift 在追赶 GCC 或 Visual C++之前可能还需要一些时间)。

12.2.4 公共子表达式消除

通常,一些表达式的部分—一个子表达式—可能在当前函数的其他地方出现。如果在子表达式中出现的变量的值没有发生变化,那么程序就不需要两次计算它的值。相反,它可以在第一次计算时保存子表达式的值,然后在子表达式再次出现时使用该值。例如,考虑以下 Pascal 代码:


			complex := ( a + b ) * ( c - d ) - ( e div f );
lessSo  := ( a + b ) - ( e div f );
quotient := e div f;

一个合格的编译器可能会将这些转换为以下三地址语句序列:


			temp1 := a + b;
temp2 := c - d;
temp3 := e div f;
complex := temp1 * temp2;
complex := complex - temp3;
lessSo := temp1 - temp3;
quotient := temp3;

尽管前面的语句分别使用了子表达式(a + b)两次和子表达式(e div f)三次,但三地址代码序列只计算这些子表达式一次,并在后续出现公共子表达式时使用它们的值。

作为另一个例子,考虑下面的 C/C++代码:


			#include <stdio.h>

static int i, j, k, m, n;
static int expr1, expr2, expr3;

extern int someFunc( void );

int main( void )
{
    // The following is a trick to
    // confuse the optimizer. When we call
    // an external function, the optimizer
    // knows nothing about the value this
    // function returns, so it cannot optimize
    // the values away. This is done to demonstrate
    // the optimizations that this example is
    // trying to show (that is, the compiler
    // would normally optimize away everything
    // and we wouldn't see the code the optimizer
    // would produce in a real-world example without
    // the following trick).

    i = someFunc();
    j = someFunc();
    k = someFunc();
    m = someFunc();
    n = someFunc();

    expr1 = (i+j) * (k*m+n);
    expr2 = (i+j);
    expr3 = (k*m+n);

    printf( "%d %d %d", expr1, expr2, expr3 );
    return( 0 );
}

这是 GCC 为上述 C 代码生成的 32 位 80x86 汇编文件(使用-O3选项):


			.file   "t.c"
        .section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d %d %d"
        .text
        .p2align 2,,3
.globl main
        .type   main,@function
main:
        ; Build the activation record:

        pushl   %ebp
        movl    %esp, %ebp
        subl    $8, %esp
        andl    $-16, %esp

        ; Initialize i, j, k, m, and n:

        call    someFunc
        movl    %eax, i
        call    someFunc
        movl    %eax, j
        call    someFunc
        movl    %eax, k
        call    someFunc
        movl    %eax, m
        call    someFunc ;n's value is in EAX.

        ; Compute EDX = k*m+n
        ; and ECX = i+j
        movl    m, %edx
        movl    j, %ecx
        imull   k, %edx
        addl    %eax, %edx
        addl    i, %ecx

        ; EDX is expr3, so push it
        ; on the stack for printf

        pushl   %edx

        ; Save away n's value:

        movl    %eax, n
        movl    %ecx, %eax

        ; ECX is expr2, so push it onto
        ; the stack for printf:

        pushl   %ecx

        ; expr1 is the product of the
        ; two subexpressions (currently
        ; held in EDX and EAX), so compute
        ; their product and push the result
        ; for printf.

        imull   %edx, %eax
        pushl   %eax

        ; Push the address of the format string
        ; for printf:

        pushl   $.LC0

        ; Save the variable's values and then
        ; call printf to print the values

        movl    %eax, expr1
        movl    %ecx, expr2
        movl    %edx, expr3
        call    printf

        ; Return 0 as the main function's result:

        xorl    %eax, %eax
        leave
        ret

注意编译器如何在不同的寄存器中保持公共子表达式的结果(请参阅汇编输出中的注释以了解详细信息)。

这是 Visual C++的(64 位)输出:


			_TEXT   SEGMENT
main    PROC

$LN4:
        sub     rsp, 40                                 ; 00000028H

        call    someFunc
        mov     DWORD PTR i, eax

        call    someFunc
        mov     DWORD PTR j, eax

        call    someFunc
        mov     DWORD PTR k, eax

        call    someFunc
        mov     DWORD PTR m, eax

        call    someFunc

        mov     r9d, DWORD PTR m

        lea     rcx, OFFSET FLAT:$SG7892
        imul    r9d, DWORD PTR k
        mov     r8d, DWORD PTR j
        add     r8d, DWORD PTR i
        mov     edx, r8d
        mov     DWORD PTR n, eax
        mov     DWORD PTR expr2, r8d
        add     r9d, eax
        imul    edx, r9d
        mov     DWORD PTR expr3, r9d
        mov     DWORD PTR expr1, edx
        call    printf

        xor     eax, eax

        add     rsp, 40                                 ; 00000028H
        ret     0
main    ENDP
_TEXT   ENDS

由于 x86-64 架构上额外的寄存器,Visual C++能够将所有临时变量保留在寄存器中,并且在重用公共子表达式的预计算值时做得更好。

如果你使用的编译器不支持公共子表达式优化(你可以通过检查汇编输出来判断),那么它的优化器可能比较差,你应该考虑使用不同的编译器。然而,在此期间,你可以始终手动实现这种优化。考虑以下版本的前述 C 代码,它手动计算了公共子表达式:


			#include <stdio.h>

static int i, j, k, m, n;
static int expr1, expr2, expr3;
static int ijExpr, kmnExpr;

extern int someFunc( void );

int main( void )
{
    // The following is a trick to
    // confuse the optimizer. By calling
    // an external function, the optimizer
    // knows nothing about the value this
    // function returns, so it cannot optimize
    // the values away because of constant propagation.

    i = someFunc();
    j = someFunc();
    k = someFunc();
    m = someFunc();
    n = someFunc();

    ijExpr = i+j;
    kmnExpr = (k*m+n);
    expr1 = ijExpr * kmnExpr;
    expr2 = ijExpr;
    expr3 = kmnExpr;

    printf( "%d %d %d", expr1, expr2, expr3 );
    return( 0 );
}

当然,没有必要创建ijExprkmnExpr变量,因为我们本可以直接使用expr2expr3变量来达到目的。然而,这段代码的编写目的是让对原程序的更改尽可能明显。

下面是类似的 Java 代码:


			public class Welcome
{
    public static int someFunc() { return 1;}
    public static void main( String[] args )
    {
        int i = someFunc();
        int j = someFunc();
        int k = someFunc();
        int m = someFunc();
        int n = someFunc();
        int expr1 = (i + j) * (k*m + n);
        int expr2 = (i + j);
        int expr3 = (k*m + n);
    }
}

// JBC output

public class Welcome extends java.lang.Object{
public Welcome();
  Code:
   0:   aload_0

        ; //Method java/lang/Object."<init>":()V
   1:   invokespecial   #1
   4:   return

public static int someFunc();
  Code:
   0:   iconst_1
   1:   ireturn

public static void main(java.lang.String[]);
  Code:
   0:   invokestatic    #2; //Method someFunc:()I
   3:   istore_1
   4:   invokestatic    #2; //Method someFunc:()I
   7:   istore_2
   8:   invokestatic    #2; //Method someFunc:()I
   11:  istore_3
   12:  invokestatic    #2; //Method someFunc:()I
   15:  istore  4
   17:  invokestatic    #2; //Method someFunc:()I
   20:  istore  5
; iexpr1 = (i + j) * (k*m + n);
   22:  iload_1
   23:  iload_2
   24:  iadd
   25:  iload_3
   26:  iload   4
   28:  imul
   29:  iload   5
   31:  iadd
   32:  imul
   33:  istore  6
; iexpr2 = (i+j)
   35:  iload_1
   36:  iload_2
   37:  iadd
   38:  istore  7
; iexpr3 = (k*m + n)
   40:  iload_3
   41:  iload   4
   43:  imul
   44:  iload   5
   46:  iadd
   47:  istore  8
   49:  return
}

请注意,Java 不会优化公共子表达式;相反,它每次遇到子表达式时都会重新计算它。因此,在编写 Java 代码时,你应该手动计算公共子表达式的值。

这是当前示例在 Swift 中的变体(以及汇编输出):


			import Foundation

func someFunc() -> UInt32
{
    return arc4random_uniform(100)
}
let i = someFunc()
let j = someFunc()
let k = someFunc()
let m = someFunc()
let n = someFunc()

let expr1 = (i+j) * (k*m+n)
let expr2 = (i+j)
let expr3 = (k*m+n)
print( "\(expr1), \(expr2), \(expr3)" )

// Assembly output for the above expressions:

; Code for the function calls:

    movl    $0x64, %edi
    callq   arc4random_uniform
    movl    %eax, %ebx  ; EBX = i
    movl    %ebx, _$S6result1is6UInt32Vvp(%rip)
    callq   _arc4random
    movl    %eax, %r12d ; R12d = j
    movl    %r12d, _$S6result1js6UInt32Vvp(%rip)
    callq   _arc4random
    movl    %eax, %r14d ; R14d = k
    movl    %r14d, _$S6result1ks6UInt32Vvp(%rip)
    callq   _arc4random
    movl    %eax, %r15d ; R15d = m
    movl    %r15d, _$S6result1ms6UInt32Vvp(%rip)
    callq   _arc4random
    movl    %eax, %esi  ; ESI = n
    movl    %esi, _$S6result1ns6UInt32Vvp(%rip)

; Code for the expressions:

    addl    %r12d, %ebx ; R12d = i + j (which is expr2)
    jb  LBB0_11         ; Branch if overflow occurs

    movl    %r14d, %eax ;
    mull    %r15d
    movl    %eax, %ecx  ; ECX = k*m
    jo  LBB0_12         ; Bail if overflow
    addl    %esi, %ecx  ; ECX = k*m + n (which is expr3)
    jb  LBB0_13         ; Bail if overflow

    movl    %ebx, %eax
    mull    %ecx        ; expr1 = (i+j) * (k*m+n)
    jo  LBB0_14         ; Bail if overflow
    movl    %eax, _$S6result5expr1s6UInt32Vvp(%rip)
    movl    %ebx, _$S6result5expr2s6UInt32Vvp(%rip)
    movl    %ecx, _$S6result5expr3s6UInt32Vvp(%rip)

如果你仔细阅读这段代码,你可以看到 Swift 编译器正确地优化了公共子表达式,并且每个子表达式只计算一次。

12.2.5 强度减少

通常,CPU 可以使用不同的操作符直接计算某个值,从而用更简单的指令替代更复杂(或更强大)的指令。例如,shift操作可以实现乘以或除以 2 的幂的常数,而某些取模(余数)操作可以使用按位and指令来完成(shiftand指令通常比multiplydivide指令执行得更快)。大多数编译器优化器擅长识别此类操作,并将更昂贵的计算替换为更便宜的一系列机器指令。要看到强度化简的实际效果,可以考虑以下 C 代码及其后面的 80x86 GCC 输出:


			#include <stdio.h>

unsigned i, j, k, m, n;

extern unsigned someFunc( void );
extern void preventOptimization( unsigned arg1, ... );

int main( void )
{
    // The following is a trick to
    // confuse the optimizer. By calling
    // an external function, the optimizer
    // knows nothing about the value this
    // function returns, so it cannot optimize
    // the values away.

    i = someFunc();
    j = i * 2;
    k = i % 32;
    m = i / 4;
    n = i * 8;

    // The following call to "preventOptimization" is done
    // to trick the compiler into believing the above results
    // are used somewhere (GCC will eliminate all the code
    // above if you don't actually use the computed result,
    // and that would defeat the purpose of this example).

    preventOptimization( i,j,k,m,n);
    return( 0 );
}

这是 GCC 生成的最终 80x86 代码:


			.file   "t.c"
        .text
        .p2align 2,,3
.globl main
        .type   main,@function
main:
        ; Build main's activation record:

        pushl   %ebp
        movl    %esp, %ebp
        pushl   %esi
        pushl   %ebx
        andl    $-16, %esp

        ; Get i's value into EAX:

        call    someFunc

        ; compute i*8 using the scaled-
        ; indexed addressing mode and
        ; the LEA instruction (leave
        ; n's value in EDX):

        leal    0(,%eax,8), %edx

        ; Adjust stack for call to
        ; preventOptimization:

        subl    $12, %esp

        movl    %eax, %ecx      ; ECX = i
        pushl   %edx            ; Push n for call
        movl    %eax, %ebx      ; Save i in k
        shrl    $2, %ecx        ; ECX = i/4 (m)
        pushl   %ecx            ; Push m for call

        andl    $31, %ebx       ; EBX = i % 32
        leal    (%eax,%eax), %esi ;j=i*2
        pushl   %ebx            ; Push k for call
        pushl   %esi            ; Push j for call
        pushl   %eax            ; Push i for call
        movl    %eax, i         ; Save values in memory
        movl    %esi, j         ; variables.
        movl    %ebx, k
        movl    %ecx, m
        movl    %edx, n
        call    preventOptimization

        ; Clean up the stack and return
        ; 0 as main's result:

        leal    -8(%ebp), %esp
        popl    %ebx
        xorl    %eax, %eax
        popl    %esi
        leave
        ret
.Lfe1:
        .size   main,.Lfe1-main
        .comm   i,4,4
        .comm   j,4,4
        .comm   k,4,4
        .comm   m,4,4
        .comm   n,4,4

在这段 80x86 代码中,请注意,GCC 从未生成乘法或除法指令,即使 C 代码中大量使用了这两个操作符。GCC 将每个(开销大的)操作替换为较便宜的地址计算、移位和逻辑与操作。

这个 C 示例将变量声明为unsigned而不是int。这样做有一个很好的理由:强度化简对于某些无符号操作数比有符号操作数生成更高效的代码。这是一个非常重要的点:如果你有选择使用有符号或无符号整数操作数的机会,总是尝试使用无符号值,因为编译器在处理无符号操作数时通常能够生成更好的代码。为了看到差异,以下是之前的 C 代码使用有符号整数重写后的版本,并附上 GCC 的 80x86 输出:


			#include <stdio.h>

int i, j, k, m, n;

extern int someFunc( void );
extern void preventOptimization( int arg1, ... );

int main( void )
{
    // The following is a trick to
    // confuse the optimizer. By calling
    // an external function, the optimizer
    // knows nothing about the value this
    // function returns, so it cannot optimize
    // the values away. That is, this prevents
    // constant propagation from computing all
    // the following values at compile time.

    i = someFunc();
    j = i * 2;
    k = i % 32;
    m = i / 4;
    n = i * 8;

    // The following call to "preventOptimization"
    // prevents dead code elimination of all the
    // preceding statements.

    preventOptimization( i,j,k,m,n);
    return( 0 );
}

这是 GCC(32 位)为此 C 代码生成的 80x86 汇编输出:


			.file   "t.c"
        .text
        .p2align 2,,3
        .globl main
        .type   main,@function
main:
        ; Build main's activation record:

        pushl   %ebp
        movl    %esp, %ebp
        pushl   %esi
        pushl   %ebx
        andl    $-16, %esp

        ; Call someFunc to get i's value:

        call    someFunc
        leal    (%eax,%eax), %esi ; j = i * 2
        testl   %eax, %eax        ; Test i's sign
        movl    %eax, %ecx
        movl    %eax, i
        movl    %esi, j
        js      .L4

; Here's the code we execute if i is non-negative:

.L2:
        andl    $-32, %eax       ; MOD operation
        movl    %ecx, %ebx
        subl    %eax, %ebx
        testl   %ecx, %ecx       ; Test i's sign
        movl    %ebx, k
        movl    %ecx, %eax
        js      .L5
.L3:
        subl    $12, %esp
        movl    %eax, %edx
        leal    0(,%ecx,8), %eax ; i*8
        pushl   %eax
        sarl    $2, %edx         ; Signed div by 4
        pushl   %edx
        pushl   %ebx
        pushl   %esi
        pushl   %ecx
        movl    %eax, n
        movl    %edx, m
        call    preventOptimization
        leal    -8(%ebp), %esp
        popl    %ebx
        xorl    %eax, %eax
        popl    %esi
        leave
        ret
        .p2align 2,,3

; For signed division by 4,
; using a sarl operation, we need
; to add 3 to i's value if i was
; negative.

.L5:
        leal    3(%ecx), %eax
        jmp     .L3
        .p2align 2,,3

; For signed % operation, we need to
; first add 31 to i's value if it was
; negative to begin with:

.L4:
        leal    31(%eax), %eax
        jmp     .L2

这两个编码示例之间的差异展示了为什么在不需要处理负数时,你应该选择无符号整数(而不是有符号整数)。

手动进行强度化简是有风险的。虽然某些操作(如除法)在大多数 CPU 上通常比其他操作(如右移)慢,但许多强度化简优化在不同 CPU 之间并不具有可移植性。也就是说,用左移操作替代乘法操作,可能在你为不同 CPU 编译时并不会产生更快的代码。一些较旧的 C 程序包含了当初为了提高性能而手动加入的强度化简。今天,这些强度化简反而可能导致程序的运行速度比预期更慢。在将强度化简直接加入 HLL 代码时要非常小心——这是一个应该让编译器完成工作的领域。

12.2.6 归纳

在许多表达式中,特别是在循环中,表达式中某个变量的值完全依赖于另一个变量。例如,考虑以下 Pascal 中的for循环:


			for i := 0 to 15 do begin

    j := i * 2;
    vector[ j ] := j;
    vector[ j+1 ] := j + 1;

end;

编译器的优化器可能会识别到 j 完全依赖于 i 的值,并将此代码重写如下:


			ij := 0;  {ij is the combination of i and j from
           the previous code}
while( ij < 32 ) do begin

    vector[ ij ] := ij;
    vector[ ij+1 ] := ij + 1;
    ij := ij + 2;

end;

这个优化减少了循环中的一些工作量(具体来说,是 j := i * 2 的计算)。

另一个示例,考虑以下 C 代码以及 Microsoft 的 Visual C++ 编译器生成的 MASM 输出:


			extern unsigned vector[32];

extern void someFunc( unsigned v[] );
extern void preventOptimization( int arg1, ... );

int main( void )
{

    unsigned i, j;

    //  "Initialize" vector (or, at least,
    //  make the compiler believe this is
    //  what's going on):

    someFunc( vector );

    // For loop to demonstrate induction:

    for( i=0; i<16; ++i )
    {
        j = i * 2;
        vector[ j ] = j;
        vector[ j+1 ] = j+1;
    }

    // The following prevents dead code elimination
    // of the former calculations:

    preventOptimization( vector[0], vector[15] );
    return( 0 );
}

这是 Visual C++ 生成的 MASM(32 位 80x86)输出:


			_main   PROC

        push    OFFSET _vector
        call    _someFunc
        add     esp, 4
        xor     edx, edx

        xor     eax, eax
$LL4@main:

        lea     ecx, DWORD PTR [edx+1]      ; ECX = j+1
        mov     DWORD PTR _vector[eax], edx ; EDX = j
        mov     DWORD PTR _vector[eax+4], ecx

; Each time through the loop, bump j up by 2 (i*2)

        add     edx, 2

; Add 8 to index into vector, as we are filling two elements
; on each loop.

        add     eax, 8

; Repeat until we reach the end of the array.

        cmp     eax, 128                                ; 00000080H
        jb      SHORT $LL4@main

        push    DWORD PTR _vector+60
        push    DWORD PTR _vector
        call    _preventOptimization
        add     esp, 8

        xor     eax, eax

        ret     0
_main   ENDP
_TEXT   ENDS

正如你在这个 MASM 输出中看到的,Visual C++ 编译器识别到 i 在这个循环中没有被使用。没有涉及 i 的计算,它被完全优化掉了。此外,也没有 j = i * 2 的计算。相反,编译器使用归纳法来确定 j 在每次迭代中增加 2,并生成执行这一操作的代码,而不是从 i 计算 j 的值。最后,注意到编译器并没有索引到向量数组,而是通过每次迭代循环中的指针来遍历数组——再次使用归纳法生成比没有这个优化时更快、更短的代码序列。

至于常见的子表达式,你可以手动将归纳优化融入到你的程序中。结果几乎总是更难以阅读和理解,但如果编译器的优化器未能在程序的某个部分生成好的机器代码,手动优化始终是一个可行的选项。

这是这个示例的 Java 变种和 JBC 输出:


			public class Welcome
{
    public static void main( String[] args )
    {
          int[] vector = new int[32];
          int j;
          for (int i = 0; i<16; ++i)
          {
            j = i * 2;
            vector[j] = j;
            vector[j + 1] = j + 1;
          }
    }
}

// JBC:

Compiled from "Welcome.java"
public class Welcome extends java.lang.Object{
public Welcome();
  Code:
   0:   aload_0

        ; //Method java/lang/Object."<init>":()V
   1:   invokespecial   #1
   4:   return

public static void main(java.lang.String[]);
  Code:
; Create vector array:

   0:   bipush  16
   2:   newarray int
   4:   astore_1

; i = 0   -- for( int i=0;...;...)
   5:   iconst_0
   6:   istore_3

; If i >= 16, exit loop  -- for(...;i<16;...)

   7:   iload_3
   8:   bipush  16
   10:  if_icmpge   35

; j = i * 2

   13:  iload_3
   14:  iconst_2
   15:  imul
   16:  istore_2

; vector[j] = j

   17:  aload_1
   18:  iload_2
   19:  iload_2
   20:  iastore

; vector[j+1] = j + 1

   21:  aload_1
   22:  iload_2
   23:  iconst_1
   24:  iadd
   25:  iload_2
   26:  iconst_1
   27:  iadd
   28:  iastore

; Next iteration of loop -- for(...;...; ++i )

   29:  iinc    3, 1
   32:  goto    7

; exit program here.

   35:  return
}

很明显,Java 完全没有优化这段代码。如果你希望获得更好的代码,就必须手动优化它:


			for ( j = 0; j < 32; j = j + 2 )
{
    vector[j] = j;
    vector[j + 1] = j + 1;
}
  Code:
; Create array:

   0:   bipush  16
   2:   newarray int
   4:   astore_1

; for( int j = 0;...;...)

   5:   iconst_0
   6:   istore_2

; if j >= 32, bail -- for(...;j<32;...)

   7:   iload_2
   8:   bipush  32
   10:  if_icmpge   32

; vector[j] = j

   13:  aload_1
   14:  iload_2
   15:  iload_2
   16:  iastore

; vector[j + 1] = j + 1

   17:  aload_1
   18:  iload_2
   19:  iconst_1
   20:  iadd
   21:  iload_2
   22:  iconst_1
   23:  iadd
   24:  iastore

; j += 2  -- for(...;...; j += 2 )

   25:  iload_2
   26:  iconst_2
   27:  iadd
   28:  istore_2
   29:  goto    7

   32:  return

正如你所看到的,如果你希望生成优化后的运行时代码,Java 可能不是最佳的语言选择。也许 Java 的作者认为由于字节码的解释执行,没有必要尝试优化编译器的输出,或者他们认为优化是 JIT 编译器的责任。

12.2.7 循环不变式

目前为止展示的优化方法都是编译器可以用来改进已经编写得很好的代码的技巧。相比之下,处理循环不变式是编译器用来修复坏代码的优化方法。循环不变式是指在某个循环的每次迭代中都不会改变的表达式。下面的 Visual Basic 代码演示了一个简单的循环不变式计算:


			i = 5
for j = 1 to 10
    k = i*2
next j

k 的值在循环执行过程中不会改变。一旦循环执行完毕,k 的值与将 k 的计算移到循环前后时的值完全相同。例如:


			i = 5
k = i * 2
for j = 1 to 10
next j
rem At this point, k will contain the same
rem value as in the previous example

这两个代码片段的区别显然是,第二个示例只计算一次 k = i * 2,而不是每次循环迭代时都计算。

许多编译器的优化器会发现循环不变式计算,并使用代码移动将其移出循环。作为这种操作的示例,考虑以下 C 程序及其相应的输出:


			extern unsigned someFunc( void );
extern void preventOptimization( unsigned arg1, ... );

int main( void )
{
    unsigned i, j, k, m;

    k = someFunc();
    m = k;
    for( i = 0; i < k; ++i )
    {
        j = k + 2;    // Loop-invariant calculation
        m += j + i;
    }
    preventOptimization( m, j, k, i );
    return( 0 );
}

这是 Visual C++ 生成的 80x86 MASM 代码:


			_main   PROC NEAR ; COMDAT
; File t.c
; Line 5
        push    ecx
        push    esi
; Line 8
        call    _someFunc
; Line 10
        xor     ecx, ecx  ; i = 0
        test    eax, eax  ; see if k == 0
        mov     edx, eax  ; m = k
        jbe     SHORT $L108
        push    edi

; Line 12
; Compute j = k + 2, but only execute this
; once (code was moved out of the loop):

        lea     esi, DWORD PTR [eax+2] ; j = k + 2

; Here's the loop the above code was moved
; out of:

$L99:
; Line 13
        ; m(edi) = j(esi) + i(ecx)

        lea     edi, DWORD PTR [esi+ecx]
        add     edx, edi

        ; ++i
        inc     ecx

        ; While i < k, repeat:

        cmp     ecx, eax
        jb      SHORT $L99

        pop     edi
; Line 15
;
; This is the code after the loop body:

        push    ecx
        push    eax
        push    esi
        push    edx
        call    _preventOptimization
        add     esp, 16                                 ; 00000010H
; Line 16
        xor     eax, eax
        pop     esi
; Line 17
        pop     ecx
        ret     0
$L108:
; Line 10
        mov     esi, DWORD PTR _j$[esp+8]
; Line 15
        push    ecx
        push    eax
        push    esi
        push    edx
        call    _preventOptimization
        add     esp, 16                                 ; 00000010H
; Line 16
        xor     eax, eax
        pop     esi
; Line 17
        pop     ecx
        ret     0
_main   ENDP

正如你从汇编代码中的注释中看到的那样,循环不变的表达式 j = k + 2 被移出了循环,并在循环代码开始之前执行,从而节省了每次循环迭代的执行时间。

与大多数优化不同,尽可能将循环不变的计算移出循环,除非有充分的理由将它们保留在循环内。循环不变的计算会让阅读代码的人产生疑问(“这不是应该在循环中变化吗?”),因为它们的存在实际上使代码更难以阅读和理解。如果你出于某种原因想将不变的代码保留在循环内,请务必在代码中添加注释,解释你的理由,方便以后查看代码的人理解。

12.2.8 优化器与程序员

HLL 程序员根据对编译器优化的理解分为三类:

  • 第一组程序员不了解编译器优化的原理,他们编写代码时没有考虑代码组织对优化器的影响。

  • 第二组程序员理解编译器优化的工作原理,因此他们编写更易于阅读的代码。他们假设优化器会处理诸如将乘法和除法转换为位移(适用时)和预处理常量表达式等问题。这第二组程序员对编译器能够正确优化代码抱有相当的信心。

  • 第三组程序员也了解编译器可以进行的优化类型,但他们不信任编译器来为他们执行优化。相反,他们手动将这些优化融入到自己的代码中。

有趣的是,编译器优化器实际上是为第一组程序员设计的,那些不了解编译器如何工作的程序员。因此,一个好的编译器通常会为这三类程序员生成大致相同质量的代码(至少在算术表达式方面)。当你在不同的编译器中编译同一程序时,这一点尤其如此。然而,记住,这一断言仅适用于那些具有良好优化能力的编译器。如果你不得不在大量编译器上编译代码,并且不能确保所有编译器都有良好的优化器,那么手动优化可能是实现跨所有编译器一致良好性能的一种方式。

当然,真正的问题是:“哪些编译器好,哪些不好?”如果本书能够提供一个表格或图表,描述你可能遇到的所有不同编译器的优化能力,那就太好了。但不幸的是,随着编译器厂商不断改进产品,排名会发生变化,因此这里印刷的任何内容都会迅速过时。^(5) 幸运的是,有一些网站会尽力保持编译器的最新对比。

12.3 算术表达式中的副作用

你一定要为编译器提供关于表达式中可能发生的副作用的指导。如果你不理解编译器如何处理算术表达式中的副作用,你可能会写出不总是产生正确结果的代码,尤其是在不同编译器之间迁移源代码时。希望编写最快或最小的代码是可以理解的,但如果它没有产生正确的答案,那么你对代码的任何优化都将毫无意义。

副作用是对程序全局状态的任何修改,超出了代码段所产生的即时结果。算术表达式的主要目的是产生表达式的结果。任何在表达式中对系统状态的其他更改都是副作用。C、C++、C#、Java、Swift 及其他基于 C 的语言尤其容易在算术表达式中允许副作用。例如,考虑以下 C 语言代码片段:

i = i + *pi++ + (j = 2) * --k

这个表达式表现出四个独立的副作用:

  • 表达式结束时k的递减。

  • 在使用j的值之前对j的赋值。

  • 在解引用pi之后对指针pi的递增。

  • i的赋值^(6)。

尽管很少有非基于 C 的语言提供像 C 语言那样多的方式在算术表达式中创建副作用,但大多数语言确实允许通过函数调用在表达式中创建副作用。例如,在不直接支持此功能的语言中,当你需要返回多个值作为函数结果时,函数中的副作用非常有用。考虑以下 Pascal 代码片段:


			var
   k:integer;
   m:integer;
   n:integer;

function hasSideEffect( i:integer; var j:integer ):integer;
begin

    k := k + 1;
    hasSideEffect := i + j;
    j := i;

end;
        .
        .
        .
    m := hasSideEffect( 5, n );

在这个例子中,调用hasSideEffect()函数会产生两个不同的副作用:

  • 对全局变量k的修改。

  • 对通过引用传递的参数j的修改(在这段代码中,实际参数是n)。

该函数的真实目的是计算其返回结果。任何修改全局值或引用参数的行为都构成了该函数的副作用;因此,在表达式中调用该函数会产生副作用。任何允许你从函数中修改全局值(无论是直接修改还是通过参数)的语言,都能够在表达式中产生副作用;这一概念并不限于 Pascal 程序。

算术表达式中的副作用问题在于,大多数语言并不保证构成表达式的各个组件的求值顺序。许多初学者程序员错误地假设,当他们写下如下表达式时:

i := f(x) + g(x);

编译器会生成首先调用函数f(),然后调用函数g()的代码。然而,很少有编程语言要求这种执行顺序。也就是说,一些编译器确实会先调用f(),然后调用g(),并将它们的返回结果相加。然而,其他编译器则会先调用g(),再调用f(),并计算函数返回结果的和。也就是说,编译器在生成本地机器代码之前,可能会将此表达式转换为以下任一简化的代码序列:


			{ Conversion #1 for "i := f(x) + g(x);" }

    temp1 := f(x);
    temp2 := g(x);
    i := temp1 + temp2;

{ Conversion #2 for "i := f(x) + g(x);" }

    temp1 := g(x);
    temp2 := f(x);
    i := temp2 + temp1;

如果f()g()产生副作用,那么这两种不同的函数调用顺序可能会产生完全不同的结果。例如,如果函数f()修改了你传递给它的x参数,那么前面的顺序可能会产生不同的结果。

请注意,优先级、结合性和交换性等问题并不影响编译器是否在某个子表达式之前评估另一个子表达式。

例如,考虑以下算术表达式及其几种可能的中间形式:


			    j := f(x) - g(x) * h(x);

{ Conversion #1 for this expression: }

    temp1 := f(x);
    temp2 := g(x);
    temp3 := h(x);
    temp4 := temp2 * temp3
    j := temp1 - temp4;

{ Conversion #2 for this expression: }

    temp2 := g(x);
    temp3 := h(x);
    temp1 := f(x);
    temp4 := temp2 * temp3
    j := temp1 - temp4;

{ Conversion #3 for this expression: }

    temp3 := h(x);
    temp1 := f(x);
    temp2 := g(x);
    temp4 := temp2 * temp3
    j := temp1 - temp4;

其他组合也是可能的。

大多数编程语言的规范明确规定了求值顺序是未定义的。这看起来可能有些奇怪,但这样做有一个充分的理由:有时候,编译器通过重新排列表达式中某些子表达式的求值顺序,可以生成更高效的机器代码。因此,语言设计者若试图强制编译器实现特定的求值顺序,可能会限制优化的范围。

当然,也有一些规则是大多数语言都会强制执行的。最常见的规则可能是表达式中的所有副作用将在该语句执行完毕之前发生。例如,如果函数f()修改了全局变量x,那么以下语句将始终打印出xf()修改之后的值:


			i := f(x);
writeln( "x=", x );

你可以依赖的另一个规则是,赋值语句左侧的变量赋值操作不会在表达式右侧同一变量被使用之前发生。也就是说,以下代码不会将表达式的结果存储到变量n中,直到它在表达式中使用了n的先前值:

n := f(x) + g(x) - n;

因为在大多数语言中,表达式中副作用的产生顺序是未定义的,所以以下代码的结果通常是未定义的(在 Pascal 中):


			function incN:integer;
begin
    incN := n;
    n := n + 1;
end;
        .
        .
        .
    n := 2;
    writeln( incN + n*2 );

编译器可以先调用incN()函数(这样n将在执行子表达式n * 2之前包含3),也可以先计算n * 2再调用incN()函数。因此,这条语句的某一次编译可能会产生输出8,而另一种编译则可能产生6。在这两种情况下,n在执行writeln语句后会包含3,但writeln语句中表达式的计算顺序可能会有所不同。

不要误以为你可以通过做一些实验来确定求值顺序。最好的情况是,这些实验只能告诉你特定编译器使用的顺序。不同的编译器可能会以不同的顺序计算子表达式。事实上,即使是同一个编译器,也可能根据子表达式的上下文以不同的方式计算子表达式的各个组成部分。这意味着编译器可能在程序的某个位置使用一种顺序来计算结果,而在程序中的其他地方使用另一种顺序。这就是为什么“确定”你的特定编译器使用的顺序并依赖于该顺序是危险的原因。即使编译器在计算副作用时使用一致的顺序,编译器供应商也可能会在后续版本中改变这个顺序。如果你必须依赖于求值顺序,首先将表达式分解为一系列更简单的语句,从而能够控制它们的计算顺序。例如,如果你确实需要在这个语句中确保程序先调用f()再调用g()

i := f(x) + g(x);

那么你应该这样编写代码:

temp1 := f(x);
temp2 := g(x);
i := temp1 + temp2;

如果你必须控制表达式内的求值顺序,请特别注意确保所有副作用在适当的时机被计算。为了做到这一点,你需要了解序列点。

12.4 包含副作用:序列点

如前所述,大多数编程语言都保证在程序执行的某些时刻,副作用的计算会在被称为序列点的特定位置完成。例如,几乎所有的语言都保证在包含表达式的语句执行完成时,所有的副作用都会被计算完。语句结束就是一个序列点的例子。

C 编程语言在表达式中提供了几个重要的序列点,除了语句结尾的分号外。C 定义了在以下每个操作符之间的序列点:

expression1, expression2                 (comma operator in an expression)
expression1 && expression2               (logical AND operator)
expression1 || expression2               (logical OR operator)
expression1 ? expression2 : expression3  (conditional expression operator)

在这些示例中,C^(7)保证在计算表达式 2 或表达式 3 之前,表达式 1 中的所有副作用都已完成。请注意,对于条件表达式,C 只会计算表达式 2 或表达式 3 中的一个,因此,只有其中一个子表达式的副作用会在每次执行条件表达式时发生。同样,短路求值可能只会导致&&||操作中表达式 1 的求值。因此,在使用最后三种形式时,请小心。

为了理解副作用和顺序点如何影响程序的操作,考虑以下 C 语言中的示例:


			int array[6] = {0, 0, 0, 0, 0, 0};
int i;
    .
    .
    .
i = 0;
array[i] = i++;

请注意,C 语言并没有定义赋值运算符之间的顺序点。因此,语言并不保证它使用的作为索引的表达式i的值。编译器可以选择在索引数组之前或之后使用i的值。++运算符是后置递增运算符,仅意味着i++返回递增前的i值;它并不保证编译器在表达式的其他地方使用递增前的i值。关键在于,这个示例中的最后一条语句在语义上可能等同于以下任一语句:


			      array[0] = i++;
-or-
      array[1] = i++;

C 语言定义允许两种形式;它并不要求使用第一种形式,仅仅因为数组索引出现在表达式中并且在后置递增运算符之前。

在此示例中,要控制对array的赋值,你必须确保表达式的任何部分都不依赖于表达式中其他部分的副作用。也就是说,除非两个使用之间有顺序点,否则你不能在表达式的某个地方使用i的值,并在另一个地方对i应用后置递增运算符。由于此语句中没有这样的顺序点,结果根据 C 语言标准是未定义的。

为了保证副作用在合适的时刻发生,必须确保在两个子表达式之间有一个顺序点。例如,如果你希望在递增之前使用i的值作为数组的索引,你可以编写如下代码:


			array [i] = i; //<-semicolon marks a sequence point.
++i;

要使用递增操作后的i值作为数组索引,你可以使用如下代码:


			++i;               //<-semicolon marks a sequence point.
array[ i ] = i-1;

顺便提一下,一个合格的编译器不会先递增i然后再计算i - 1。它会识别这里的对称性,在递增之前获取i的值,并将该值作为数组索引。这是一个例子,说明熟悉典型编译器优化的人如何利用这些知识编写更具可读性的代码。一个天生不信任编译器及其优化能力的程序员可能会写出如下代码:


			j=i++;           //<-semicolon marks a sequence point.
array[ i ] = j;

一个重要的区别是,序列点并未指定确切的计算时机,只是规定计算会发生在跨越序列点之前。副作用的计算可能早在代码的某个时刻就已完成,处于前一个序列点与当前序列点之间的任何位置。另一个关键点是,序列点并不强制编译器在一对序列点之间完成某些计算,尤其是当该计算没有产生副作用时。例如,消除公共子表达式就不是一个有用的优化,如果编译器只能在序列点之间使用公共子表达式的计算结果。只要该子表达式不产生副作用,编译器可以提前尽可能远地计算子表达式的结果。同样,编译器也可以推迟计算子表达式的结果,直到它想要计算,只要该结果不成为副作用的一部分。

由于语句结束符(即分号)在大多数语言中是序列点,控制副作用计算的一种方法是将复杂的表达式手动拆解成一系列类似三地址码的语句。例如,不必依赖 Pascal 编译器将前面的示例翻译成三地址代码,你可以使用自己选择的语义集显式编写代码:


			{ Statement with an undefined result in Pascal }

    i := f(x) + g(x);

{ Corresponding statement with well-defined semantics }

    temp1 := f(x);
    temp2 := g(x);
    i := temp1 + temp2;

{ Another version, also with well-defined but different semantics }

    temp1 := g(x);
    temp2 := f(x);
    i := temp2 + temp1;

再次强调,运算符的优先级和结合性并不控制表达式中计算的时机。即使加法是左结合的,编译器也可能在计算加法运算符的左操作数之前,先计算加法运算符的右操作数的值。优先级和结合性控制的是编译器如何安排计算以生成结果,而不控制程序何时计算表达式的子组件。只要最终的计算结果符合优先级和结合性的预期,编译器可以在任何顺序和任何时机计算子组件。

到目前为止,本节内容暗示编译器总是在遇到语句末尾的分号时计算赋值语句的值,并完成该赋值(以及其他副作用)。严格来说,这并不完全正确。许多编译器的做法是确保所有副作用发生在一个序列点与下一个引用被副作用改变的对象之间。例如,考虑以下两个语句:


			j = i++;
k = m*n + 2;

尽管这段代码中的第一个语句有副作用,但一些编译器可能在完成第一个语句的执行之前就计算第二个语句的值(或其中的一部分)。许多编译器会重新排列各种机器指令,以避免数据风险和代码中可能影响性能的其他执行依赖关系(有关数据风险的详细信息,请参见WGC1)。这两个语句之间的分号并不能保证第一个语句的所有计算在 CPU 开始任何新计算之前都已经完成;它只保证程序在执行任何依赖于它们的代码之前,先计算任何先于分号的副作用。由于第二个语句不依赖于ji的值,编译器可以在完成第一个语句之前开始计算第二个赋值。

序列点充当屏障。一个代码序列必须在任何受副作用影响的后续代码执行之前完成其执行。编译器不能在执行程序中所有到达先前序列点的代码之前计算副作用的值。考虑以下两个代码片段:


			// Code fragment #1:

    i = j + k;
    m = ++k;

// Code fragment #2:

    i = j + k;
    m = ++n;

在代码片段 1 中,编译器不能重新排列代码,使得副作用++k在使用k之前发生。语句末尾的序列点保证了本例中的第一个语句在任何后续语句产生副作用之前使用k的值。然而,在代码片段 2 中,++n产生的副作用结果并不影响i = j + k;语句中的任何内容,因此编译器可以将++n操作移到计算i值的代码中,如果这样做更方便或更高效的话。

12.5 避免副作用引起的问题

因为通常很难看到代码中副作用的影响,所以尽量限制程序暴露于副作用问题中是个好主意。当然,最好的方法是完全消除程序中的副作用。不幸的是,这并不是一个现实的选择。许多算法依赖副作用才能正常运行(通过引用参数或甚至全局变量返回多个结果的函数就是一个很好的例子)。然而,您可以通过遵循一些简单的规则来减少副作用带来的不良后果:

  • 避免在程序流程控制语句(如ifwhiledo..until)中的布尔表达式中引入副作用。

  • 如果赋值运算符右侧存在副作用,尽量将副作用移入它自己的语句中,放在赋值语句之前或之后(取决于赋值语句是在应用副作用之前还是之后使用该对象的值)。

  • 避免在同一语句中进行多次赋值;将它们分解为单独的语句。

  • 避免在同一个表达式中调用多个可能产生副作用的函数。

  • 编写函数时,避免修改全局对象(例如副作用)。

  • 始终详细记录副作用。对于函数,应在函数文档中注明副作用,并在每次调用该函数时做出记录。

12.6 强制特定的求值顺序

如前所述,运算符优先级和结合性并不控制编译器何时计算子表达式。例如,如果XYZ都是子表达式(它们可以是从单一常量或变量引用到复杂的表达式本身),那么形如X / Y * Z的表达式并不意味着编译器会先计算X的值,再计算YZ的值。实际上,编译器可以自由选择先计算Z的值,然后是Y,最后是X。运算符优先级和结合性仅要求编译器必须在计算X/Y之前,先计算XY的值(顺序可以任意),并且必须在计算(X / Y) * Z之前,先计算子表达式X/Y的值。当然,编译器可以通过适用的代数变换来改变表达式,但它们通常会小心操作,因为并非所有标准的代数变换在有限精度的算术运算中都适用。

虽然编译器可以按照自己选择的顺序计算子表达式(这也是为什么副作用可能会导致难以察觉的问题),但它们通常避免重新排列实际计算的顺序。例如,在数学上,以下两个表达式在遵循标准代数规则(而非有限精度计算机算术)的情况下是等价的:


			X / Y * Z
Z * X / Y

在标准数学中,这个恒等式成立是因为乘法运算符是交换律的;即,A × B 等于 B × A。实际上,只要按以下方式计算,这两个表达式通常会产生相同的结果:


			(X / Y) * Z
Z * (X / Y)

这里使用括号并不是为了表示优先级,而是为了将计算分组为 CPU 必须作为一个整体执行的单元。也就是说,这些语句等价于:


			A = X / Y;
B = Z
C = A * B
D = B * A

在大多数代数系统中,CD应该有相同的值。为了理解为什么CD可能不等价,可以考虑当XYZ都是整数对象,且其值分别为523时会发生什么:


			    X / Y * Z
=   5 / 2 * 3
=   2 * 3
=   6

    Z * X / Y
=   3 * 5 / 2
=   15 / 2
=   7

再次强调,这也是编译器在代数变换表达式时非常小心的原因。大多数程序员都意识到,X * (Y / Z)(X * Y) / Z并不相同。大多数编译器也意识到了这一点。从理论上讲,编译器应该将X * Y / Z这样的表达式翻译为(X * Y) / Z,因为乘法和除法操作符具有相同的优先级并且是左结合的。然而,好的程序员从不依赖结合性规则来保证这一点。尽管大多数编译器会按照预期正确地翻译这个表达式,但下一个工程师可能并不明白发生了什么。因此,明确地包含括号以澄清预期的求值顺序是个好主意。更好的做法是,将整数截断视为副作用,并将表达式分解为其组成计算(使用类似三地址的表达式),以确保正确的求值顺序。

整数运算显然遵循其自身的规则,而实数代数的规则并不总是适用。然而,不要假设浮点运算不受相同问题的影响。每当进行有限精度的运算,涉及到舍入、截断、溢出或下溢的可能性时——就像浮点运算一样——标准的实数代数变换可能不合法。换句话说,对浮点表达式应用任意的实数代数变换可能会引入计算的不准确性。因此,一个好的编译器不会对实数表达式进行这些类型的变换。不幸的是,一些编译器确实会将实数算术规则应用于浮点运算。大多数情况下,它们产生的结果是相对正确的(在浮点表示的限制范围内);然而,在一些特殊情况下,它们的结果特别糟糕。

一般来说,如果你必须控制求值顺序以及程序何时计算表达式的子组件,你唯一的选择是使用汇编语言。尽管存在一些小问题,例如指令执行顺序不一致,但在实现汇编代码时,你可以精确地指定软件在何时计算表达式的各个组件。对于非常精确的计算,当求值顺序会影响你获得的结果时,汇编语言可能是最安全的方法。尽管并不是所有程序员都能读懂和理解汇编语言,但毫无疑问,它允许你准确地指定算术表达式的语义——你所看到的就是你得到的,不会被汇编器修改。这在大多数高级语言系统中并不成立。

12.7 短路求值

对于某些算术和逻辑运算符,如果表达式的一个组成部分具有某个值,则无论表达式的其他部分值如何,整个表达式的值都会自动确定。一个经典的例子是乘法运算符。如果你有一个表达式A * B,并且知道AB之一是0,那么就无需计算另一个组成部分,因为结果已经是0。如果计算子表达式的代价相对于比较的代价较高,那么程序可以通过测试第一个组件来节省一些时间,以确定是否需要继续计算第二个组件。这个优化被称为短路求值,因为程序跳过了(在电子学术语中是“短路”)计算表达式的剩余部分。

尽管一些算术运算可以使用短路求值,但检查优化的代价通常比完成计算的代价要高。例如,乘法可以使用短路求值来避免乘以零,如前所述。然而,在实际程序中,乘以零的情况很少发生,因此在所有其他情况下进行零值比较的成本通常超过了通过避免乘以零所带来的节省。因此,你很少会看到支持算术运算短路求值的语言系统。

12.7.1 使用短路求值与布尔表达式

一种可以受益于短路求值的表达式是布尔/逻辑表达式。布尔表达式有三个原因使它们成为短路求值的好候选者:

  • 布尔表达式只有两种结果,truefalse;因此,出现短路“触发”值的概率很高(50/50 的机会,假设随机分布)。

  • 布尔表达式往往很复杂。

  • 布尔表达式在程序中经常出现。

由于这些特性,你会发现许多编译器在处理布尔表达式时使用短路求值。

请考虑以下两个 C 语言语句:


			A = B && C;
D = E || F;

请注意,如果Bfalse,那么无论C的值如何,A都会是false。类似地,如果Etrue,那么无论F的值如何,D都会是true。因此,我们可以如下计算AD的值:


			A = B;
if( A )
{
    A = C;
}

D = E;
if( !D )
{
    D = F;
}

现在,这看起来可能是很多额外的工作(确实是更多的输入!),但如果CF代表复杂的布尔表达式,那么如果B通常为false,而E通常为true,那么这段代码可能会运行得更快。当然,如果你的编译器完全支持短路求值,你根本不需要输入这段代码;编译器会为你生成等效的代码。

顺便提一下,短路运算的对立面是完整布尔运算。在完整布尔运算中,编译器生成的代码始终计算布尔表达式的每个子组件。一些语言(如 C、C++、C#、Swift 和 Java)规定使用短路运算。一些语言(如 Ada)允许程序员指定是否使用短路或完整布尔运算。大多数语言(如 Pascal)没有定义表达式是否使用短路或完整布尔运算——该语言将选择权留给实现者。实际上,同一个编译器可能在同一程序中,对于某个表达式的一个实例使用完整布尔运算,而对于该表达式的另一个实例使用短路运算。除非你使用的是严格定义布尔运算类型的语言,否则你需要查看你所使用的编译器文档,了解它是如何处理布尔表达式的。(如果将来可能需要使用不同的编译器编译代码,记得避免使用特定于编译器的机制。)

再看看之前布尔表达式的展开。应该很清楚,如果Afalse并且Dtrue,程序将不会计算CF。因此,连接运算符(&&)或析取运算符(||)的左侧可以充当门控,防止执行表达式右侧的部分。这是一个重要的点,实际上,许多算法依赖于这个特性来保证正确运行。考虑下面这个(非常常见的)C 语句:


			if( ptr != NULL && *ptr != '\0' )
{
    << process current character pointed at by ptr >>
}

如果使用完整的布尔运算,可能会导致这个示例失败。考虑ptr变量包含NULL的情况。使用短路运算时,程序不会计算子表达式*ptr != '\0';因为它意识到结果总是false。因此,控制立即转移到这个if语句中结束括号后的第一条语句。然而,假设该编译器使用的是完整的布尔运算,会发生什么情况呢?在确定ptr包含NULL之后,程序仍然会尝试解引用ptr。不幸的是,这个尝试可能会产生运行时错误。因此,完整的布尔运算会导致该程序失败,尽管它认真地检查了通过指针访问是否合法。

完全布尔求值和短路布尔求值之间的另一个语义差异与副作用有关。特别是,如果由于短路求值某个子表达式没有被执行,那么该子表达式就不会产生任何副作用。这种行为非常有用,但本质上是危险的。它之所以有用,是因为某些算法绝对依赖于短路求值。它之所以危险,是因为某些算法也期望所有副作用都能发生,即使在某个时刻表达式的值为false。例如,考虑以下奇特(但完全合法)的 C 语句,它将一个“游标”指针推进到字符串的下一个 8 字节边界,或者字符串的末尾(以先到者为准):

*++ptr && *++ptr && *++ptr && *++ptr && *++ptr && *++ptr && *++ptr && *++ptr;

这个语句首先递增指针,然后从内存中获取一个字节(由ptr指向)。如果获取的字节是0,则此表达式/语句的执行会立即停止,因为此时整个表达式的值为false。如果获取的字符不是0,则该过程会重复最多七次。在这一序列的末尾,ptr要么指向0字节,要么指向比原始位置前进 8 个字节的位置。这里的技巧是,在到达字符串的末尾时,表达式会立即终止,而不是盲目地跳过该点。

当然,也有一些互补的例子,展示了当在布尔表达式中发生副作用时,完全布尔求值会带来期望的行为。需要注意的重要一点是,没有一种方案是绝对正确或错误的;这一切取决于具体的上下文。在不同的情况下,给定的算法可能需要使用短路布尔求值或完全布尔求值才能得到正确的结果。如果你使用的语言的定义没有明确指定使用哪种方案,或者你想使用另一种方案(例如 C 语言中的完全布尔求值),那么你就需要编写代码以强制使用你偏好的求值方案。

12.7.2 强制使用短路或完全布尔求值

在使用(或可能使用)短路求值的语言中,强制执行完全布尔求值相对容易。你只需要将表达式拆分成各个独立的语句,将每个子表达式的结果存入一个变量中,然后对这些临时变量应用合取和析取运算符。例如,考虑以下转换:


			// Complex expression:

if( (a < f(x)) && (b != g(y)) || predicate( a + b ))
{
    <<stmts to execute if this expression is true>>
}

// Translation to a form that uses complete Boolean evaluation:

temp1 = a < f(x);
temp2 = b != g(y);
temp3 = predicate( a + b );
if( temp1 && temp2 || temp3 )
{
    <<stmts to execute if this expression is true>>
}

if语句中的布尔表达式仍然使用短路求值。然而,由于这段代码在if语句之前就对子表达式进行了求值,这段代码确保了f()g()predicate()函数产生的所有副作用都会发生。

假设你想反过来做。也就是说,如果你的语言只支持完整布尔求值(或没有指定求值类型),你想强制短路求值该怎么办?这种方向的工作量比反向略多,但仍然不难。

考虑以下 Pascal 代码:^(8)


			if( ((a < f(x)) and (b <> g(y))) or predicate( a + b )) then begin

    <<stmts to execute if the expression is true>>

end; (*if*)

为了强制短路布尔求值,你需要测试第一个子表达式的值,只有当它求值为true时,才评估第二个子表达式(以及这两个表达式的连接)。你可以使用以下代码实现:


			boolResult := a < f(x);
if( boolResult ) then
    boolResult := b <> g(y);

if( not boolResult ) then
    boolResult := predicate( a+b );
 if( boolResult ) then begin

    <<stmts to execute if the if's expression is true>>

end; (*if*)

这段代码通过使用if语句来模拟短路求值,基于布尔表达式的当前状态(保存在boolResult变量中),阻止(或强制)执行g()predicate()函数。

将一个表达式转换为强制短路求值或完整布尔求值,似乎需要比原始形式更多的代码。如果你担心这种转换的效率,请放心。内部,编译器将这些布尔表达式转换为类似你手动翻译的三地址代码。

12.7.3 比较短路和完整布尔求值效率

虽然你可能从前面的讨论中推测出完整的布尔求值和短路求值具有相同的效率,但事实并非如此。如果你正在处理复杂的布尔表达式,或者某些子表达式的成本较高,那么短路求值通常比完整布尔求值更快。至于哪种形式生成的目标代码更少,它们大致相同,具体差异将完全依赖于你正在求值的表达式。

为了理解完整与短路布尔求值周围的效率问题,查看以下 HLA 代码,该代码同时使用两种形式实现了这个布尔表达式:^(9)


			// Complex expression:

 //  if( (a < f(x)) && (b != g(y)) || predicate( a+b ))
 //  {
 //      <<stmts to execute if the if's expression is true>>
 //  }
 //
 // Translation to a form that uses complete
 //  Boolean evaluation:
 //
 //  temp1 = a < f(x);
 //  temp2 = b != g(y);
 //  temp3 = predicate( a + b );
 //  if( temp1 && temp2 || temp3 )
 //  {
 //      <<stmts to execute if the expression evaluates true>>
 //  }
 //
 //
// Translation into 80x86 assembly language code,
 // assuming all variables and return results are
 // unsigned 32-bit integers:

     f(x);            // Assume f returns its result in EAX
     cmp( a, eax );   // Compare a with f(x)'s return result.
     setb( bl );      // bl = a < f(x)
     g(y);            // Assume g returns its result in EAX
     cmp( b, eax );   // Compare b with g(y)'s return result
     setne( bh );     // bh = b != g(y)
     mov( a, eax );   // Compute a + b to pass along to the
     add( b, eax );   // predicate function.
     predicate( eax );// al holds predicate's result (0/1)
     and( bh, bl );   // bl = temp1 && temp2
     or( bl, al );    // al = (temp1 && temp2) || temp3
     jz skipStmts;    // 0 if false, not 0 if true.

       <<stmts to execute if the condition is true>>

skipStmts:

以下是使用短路布尔求值的相同表达式:


			    //  if( (a < f(x)) && (b != g(y)) || predicate( a+b ))
    //  {
    //      <<stmts to execute if the if's expression evaluates true>>
    //  }

        f(x);
        cmp( a, eax );
        jnb TryOR;          // If a is not less than f(x), try the OR clause
        g(y);
        cmp( b, eax );
        jne DoStmts         // If b is not equal g(y) (and a < f(x)), then do the body.

TryOR:
        mov( a, eax );
        add( b, eax );
        predicate( eax );
        test( eax, eax );   // EAX = 0?
        jz SkipStmts;

DoStmts:
        <<stmts to execute if the condition is true>>
SkipStmts:

如你所见,通过简单地计数语句,使用短路求值的版本稍微短一些(11 条指令对 12 条指令)。然而,短路版本可能会运行得更快,因为在一半的时间里,代码只会求值三个表达式中的两个。只有当第一个子表达式a < f(x)求值为true,且第二个表达式b != g(y)求值为false时,这段代码才会评估所有三个子表达式。如果这些布尔表达式的结果是等概率的,那么这段代码将有 25%的时间测试所有三个子表达式。其余时间它只需要测试两个子表达式(50%的时间它将测试a < f(x)predicate(a + b),25%的时间它将测试a < f(x)b != g(y),剩下的 25%时间它将需要测试所有三个条件)。

需要注意的有趣之处在于这两个汇编语言序列,完全的布尔运算评估倾向于在实际变量中维持表达式的状态(truefalse),而短路评估则通过程序在代码中的位置维持当前的表达式状态。再看一遍短路示例。请注意,它并不会在代码中的其他位置保存每个子表达式的布尔结果,只有在代码中的位置会保存。例如,如果你到达了这段代码中的 TryOR 标签,你就知道涉及结合(逻辑与)的子表达式是 false。同样,如果程序执行了对 g(y) 的调用,你就知道示例中的第一个子表达式 a < f(x) 已经被评估为 true。当你到达 DoStmts 标签时,你就知道整个表达式已经评估为 true

如果在当前示例中,f()g()predicate() 函数的执行时间大致相同,你可以通过几乎微不足道的修改大大提升代码的性能:


			    //  if( predicate( a + b ) || (a < f(x)) && (b != g(y)))
    //  {
    //      <<stmts to execute if the expression evaluates true>>
    //  }

        mov( a, eax );
        add( b, eax );
        predicate( eax );
        test( eax, eax );   // EAX = true (not zero)?
        jnz DoStmts;

        f(x);
        cmp( a, eax );
        jnb SkipStmts;      // If a >= f(x), try the OR clause
        g(y);
        cmp( b, eax );
        je SkipStmts;       // If b != g(y) then do the body.

DoStmts:
        <<stmts to execute if the condition is true>>
SkipStmts:

再次假设每个子表达式的结果是随机且均匀分布的(也就是说,每个子表达式产生 true 的概率为 50/50),那么这个代码的执行速度平均会比之前的版本快约 50%。为什么?将 predicate() 的测试移到代码段的开始意味着代码现在可以通过一次测试来确定是否需要执行代码体。因为 predicate() 有 50% 的时间会返回 true,你可以通过一次测试来确定是否会执行循环体。在之前的示例中,至少需要两次测试才能确定是否执行循环体。

这里有两个假设(布尔表达式同样可能产生 truefalse,以及计算每个子表达式的成本相等)在实际中很少成立。然而,这意味着你有更多的机会来优化代码,而不是减少机会。例如,如果调用 predicate() 函数的成本很高(相对于计算表达式其余部分的成本),那么你会希望安排表达式,使得只有在绝对必要时才调用 predicate()。相反,如果调用 predicate() 的成本相对较低,比较于计算其他子表达式的成本,那么你会希望优先调用它。对于 f()g() 函数的情况也是类似的。因为逻辑与操作是交换律的,以下两个表达式在语义上是等价的(没有副作用的情况下):


			a < f(x) && b != g(y)
b != g(y) && a < f(x)

当编译器使用短路评估时,如果调用函数 f() 的成本低于调用函数 g() 的成本,第一个表达式会比第二个表达式执行得更快。相反,如果调用 f() 的成本高于调用 g(),那么第二个表达式通常执行得更快。

影响短路布尔表达式求值性能的另一个因素是给定布尔表达式在每次调用时返回相同值的可能性。考虑以下两个模板:

expr1 && expr2
expr3 || expr4

在处理连接操作时,尽量将更可能返回true的表达式放在连接操作符(&&)的右侧。记住,对于逻辑与操作,如果第一个操作数是false,采用短路求值的布尔系统将不会评估第二个操作数。出于性能考虑,您希望将最可能返回false的操作数放在表达式的左侧。这将避免更多地计算第二个操作数,相较于将操作数交换位置时。

对于析取(||),情况正好相反。在这种情况下,您应将操作数排列,使得 expr3 更可能返回true而不是expr4。通过这种方式组织析取操作,您将比交换操作数时更频繁地跳过执行右侧表达式。

如果布尔表达式会产生副作用,则不能随意重新排列操作数,因为副作用的正确计算可能依赖于子表达式的精确顺序。重新排列子表达式可能导致发生某个本不该发生的副作用。在您尝试通过重新排列布尔表达式中的操作数来提高性能时,请记住这一点。

12.8 算术操作的相对成本

大多数算法分析方法都使用简化假设,即所有操作所需时间相同。^(10) 这一假设很少是正确的,因为有些算术操作比其他计算慢两个数量级。例如,一个简单的整数加法操作通常比整数乘法要快得多。类似地,整数操作通常比相应的浮点操作要快。对于算法分析来说,忽略某个操作可能比其他操作快 n 倍这一事实是可以接受的。然而,对于那些希望编写高效代码的人来说,了解哪些操作符最有效是很重要的,尤其是当你有选择的余地时。

不幸的是,我们无法创建一个列出操作符相对速度的表格。给定算术操作符的性能会因 CPU 而异。即使在同一 CPU 系列中,相同的算术操作性能也会有很大差异。例如,移位和旋转操作在 Pentium III 上相对较快(相对于加法操作)。然而,在 Pentium 4 上,它们要慢得多。这些操作在后来的 Intel CPU 上更快。因此,像 C/C++ 中的<<>>操作符可能相对加法操作来说,执行速度快或慢,这取决于它执行的 CPU。

话虽如此,我可以提供一些通用的指导。例如,在大多数 CPU 上,加法操作是最有效的算术和逻辑操作之一;很少有 CPU 支持比加法更快的算术或逻辑操作。因此,根据与加法等操作的性能相对关系,将不同的操作分组是很有用的(可以参考表 12-1 中的示例)。

表 12-1: 算术操作的相对性能(指导原则)

相对性能 操作
最快 整数加法、减法、取反、逻辑与、逻辑或、逻辑异或、逻辑非和比较
逻辑移位
逻辑旋转
乘法
除法
浮点比较和取反
浮点加法和减法
浮点乘法
最慢 浮点除法

表 12-1 中的估算值并不适用于所有 CPU,但它们提供了一个“初步估算”,可以在你对特定处理器获得更多经验之前作为参考。在许多处理器中,你会发现最慢和最快操作之间的性能差距在两个到三个数量级之间。特别是,除法在大多数处理器上通常相当慢(浮点除法甚至更慢)。乘法通常比加法慢,但具体的差异在不同处理器之间差异较大。

如果你绝对需要进行浮点除法,使用其他操作对提高应用程序的性能几乎没有帮助(虽然在某些情况下,通过乘以倒数来实现浮点除法会更快)。然而,值得注意的是,你可以使用不同的算法来计算许多整数算术。举例来说,左移操作通常比乘以 2 便宜。虽然大多数编译器会自动为你处理这种“操作符转换”,但是编译器并非全知全能,并不是每次都能找出最佳的计算方法。然而,如果你自己手动进行“操作符转换”,就不必依赖编译器来正确处理它。

12.9 获取更多信息

Aho, Alfred V.,Monica S. Lam,Ravi Sethi,和 Jeffrey D. Ullman. 编译原理:技术与工具。第 2 版。英国埃塞克斯:Pearson Education Limited,1986 年。

Barrett, William,和 John Couch. 编译器构造:理论与实践。芝加哥:SRA,1986 年。

Fraser, Christopher,和 David Hansen. 可重定向 C 编译器:设计与实现。波士顿:Addison-Wesley Professional,1995 年。

Duntemann, Jeff. 汇编语言逐步教程。第 3 版。印第安纳波利斯:Wiley,2009 年。

Hyde, Randall. 汇编语言的艺术。第 2 版。旧金山:No Starch Press,2010 年。

Louden, Kenneth C. 编译器构造:原理与实践。波士顿:Cengage,1997 年。

Parsons, Thomas W. 编译器构造导论。纽约:W. H. Freeman,1992 年。

Willus.com. “Willus.com 的 2011 Win32/64 C 编译器基准测试。”最后更新于 2012 年 4 月 8 日。www.willus.com/ccomp_benchmark2.shtml

第十三章:控制结构和程序决策

image

控制结构是高级语言(HLL)编程的核心。根据给定条件的评估做出决策的能力,对于计算机提供的几乎所有自动化任务都是基础。高级语言控制结构转化为机器码的过程,可能对程序的性能和大小产生最大的影响。正如你将在本章中看到的,知道在特定情况下使用哪种控制结构是编写出色代码的关键。特别是,本章描述了与决策和无条件流相关的控制结构的机器实现,包括:

  • if 语句

  • switchcase 语句

  • goto 及相关语句

接下来的两章将扩展讨论循环控制结构及过程/函数调用与返回。

13.1 控制结构如何影响程序效率

程序中相当一部分机器指令控制着程序的执行路径。由于控制转移指令通常会清空指令流水线(参见 WGC1),它们往往比执行简单计算的指令要慢。为了生成高效的程序,你应该减少控制转移指令的数量,或者如果无法避免,选择最快的指令。

CPUs 用于控制程序流的指令集在不同处理器之间有所不同。然而,许多 CPU(包括本书所涵盖的五个家族)使用“比较与跳转”范式来控制程序流。也就是说,在进行比较或其他修改 CPU 标志的指令后,条件跳转指令会根据 CPU 标志设置将控制权转移到另一个位置。有些 CPU 可以用单条指令完成所有这些操作,而其他 CPU 可能需要两条、三条或更多指令。有些 CPU 允许你对两个值进行广泛的不同条件比较,而其他 CPU 仅允许进行少数几种测试。不论使用何种机制,映射到某一 CPU 上给定指令序列的高级语言(HLL)语句,在第二个 CPU 上也会映射到一个相似的序列。因此,如果你理解了某个 CPU 上的基本转换方式,就能较好地理解编译器如何在所有 CPU 上工作。

13.2 低级控制结构简介

大多数 CPU 使用两步过程来做程序决策。首先,程序比较两个值,并将比较结果保存在机器寄存器或标志中。然后,程序测试该结果,并根据结果将控制权转移到两个位置之一。通过这种比较与条件分支序列,几乎可以合成大多数主要的高级语言控制结构。

即使在比较和条件分支范式中,CPU 通常也使用两种不同的方法来实现条件代码序列。一个技术,尤其在基于堆栈的架构(如 UCSD p-machine、Java 虚拟机和 Microsoft CLR)中常见,是使用不同形式的比较指令来测试特定条件。例如,你可能会有 相等比较不相等比较小于比较大于比较 等等。每个比较的结果是一个布尔值。然后,一对条件分支指令,条件成立时分支条件不成立时分支,可以测试比较的结果,并将控制转移到适当的位置。这些虚拟机可能实际上将比较和分支指令合并为“比较和分支”指令(每个条件一个)。尽管使用了更少的指令,但最终结果完全相同。

第二种,也是历史上更为流行的方法,是让 CPU 的指令集包含一个单一的比较指令,该指令设置(或清除)CPU 的 程序状态标志 寄存器中的多个位。然后,程序使用几种更具体的条件分支指令之一,将控制转移到其他位置。这些条件分支指令可能有如 相等跳转不相等跳转小于跳转大于跳转 等名称。由于这种“比较和跳转”技术是 80x86、ARM 和 PowerPC 所使用的,因此本章的示例也采用了这种方法;然而,将其转换为多重比较/跳转真/跳转假范式是很容易的。

ARM 处理器的 32 位变体引入了第三种技术:条件执行。大多数指令(不仅仅是分支指令)在 32 位 ARM 上都提供了这个选项。例如,addeq 指令仅在先前的比较(或其他操作)结果设置了零标志时,才会执行两个值的加法。更多详细信息,请参见附录 C 中的《指令的条件后缀》。

条件分支通常是双向分支。也就是说,如果它们测试的条件为 true,则将控制转移到程序中的一个位置;如果条件为 false,则转移到另一个位置。为了减少指令的大小,大多数 CPU 上的条件分支仅编码两个可能分支位置中的一个地址,并且对于相反条件使用隐式地址。具体来说,大多数条件分支如果条件为 true,则将控制转移到目标位置,如果条件为 false,则跳过到下一条指令。例如,考虑以下 80x86 的 je(相等跳转)指令序列:


			// Compare the value in EAX to the value in EBX

        cmp( eax, ebx );

// Branch to label EAXequalsEBX if EAX==EBX

        je EAXequalsEBX;

        mov( 4, ebx );      // Drop down here if EAX != EBX
            .
            .
            .
EAXequalsEBX:

该指令序列首先通过比较 EAX 寄存器中的值与 EBX 寄存器中的值(cmp指令);这会设置 80x86 EFLAGS 寄存器中的条件码位。特别地,如果 EAX 中的值等于 EBX 中的值,则该指令将 80x86 零标志设置为1je指令测试零标志是否被设置,如果是,它将控制转移到紧随EAXequalsEBX标签之后的机器指令。如果 EAX 中的值不等于 EBX,则cmp指令会清除零标志,je指令会直接跳过到mov指令,而不会转移控制到目标标签。

某些访问数据的机器指令,如果所访问的内存位置靠近包含该变量的激活记录的基址,则可能会更小(且更快)。此规则同样适用于条件跳转指令。80x86 提供了两种形式的条件跳转指令。一种形式仅为 2 字节长(1 字节用于操作码,1 字节用于范围从-128 到+127 的有符号位移)。另一种形式为 6 字节长(2 字节用于操作码,4 字节用于范围从-20 亿到+20 亿的有符号位移)。位移值指定程序需要跳转多少字节才能到达目标位置。为了将控制转移到附近的位置,程序可以使用短形式的跳转。由于 80x86 指令的长度在 1 到 15 字节之间(通常约为 3 或 4 字节),条件跳转指令的短形式通常可以跳过大约 32 到 40 条机器指令。一旦目标位置超出了±127 字节的范围,这些条件跳转指令的 6 字节版本将扩展跳转范围到当前指令的±20 亿字节。如果你有兴趣编写最有效的代码,那么你会尽量多使用 2 字节形式。

分支在现代(流水线)CPU 中是一个昂贵的操作,因为分支可能要求 CPU 刷新流水线并重新加载(更多细节见WGC1)。条件分支只有在分支被执行时才会产生这种成本;如果条件分支指令跳过到下一条指令,CPU 将继续使用流水线中找到的指令,而不会刷新它们。因此,在许多系统中,跳过到下一条指令的分支比执行跳转的分支更快。然而,请注意,一些 CPU(如 80x86、PowerPC 和 ARM)支持分支预测功能,这可以告诉 CPU 从分支目标位置开始获取指令进入流水线,而不是从紧随其后的条件跳转指令后获取。不幸的是,分支预测算法因处理器而异(甚至在 80x86 系列 CPU 中也有所不同),因此通常难以预测分支预测会如何影响你的高级语言代码。除非你正在为特定的处理器编写代码,否则最安全的假设是,跳过到下一条指令比执行跳转更有效。

虽然比较和条件分支范式是机器代码程序中最常见的控制结构,但也有其他方法可以基于某些计算结果将控制权转移到内存中的另一个位置。毫无疑问,间接跳转(尤其是通过地址表)是最常见的替代形式。考虑以下 32 位 80x86 的jmp指令:


			readonly
    jmpTable: dword[4] := [&label1, &label2, &label3, &label4];
            .
            .
            .
        jmp( jmpTable[ ebx*4 ] );

这个jmp指令从jmpTable数组中,由 EBX 中的值指定的索引处获取双字值。也就是说,该指令根据 EBX 中的值(0..3)将控制权转移到四个不同的位置。例如,如果 EBX 的值为0,那么jmp指令将从jmpTable的索引0处获取双字(即由label1前缀的指令的地址)。同样地,如果 EBX 的值为2,那么该jmp指令将从该表中获取第三个双字(即程序中label3的地址)。这大致等同于,但通常比以下指令序列更简短:


			cmp( ebx, 0 );
je label1;
cmp( ebx, 1 );
je label2;
cmp( ebx, 2 );
je label3;
cmp( ebx, 3 );
je label4;

// Results are undefined if EBX <> 0, 1, 2, or 3

一些其他的条件控制转移机制在不同的 CPU 上也可以使用,但这两种机制(比较和条件分支以及间接跳转)是大多数高级语言编译器用来实现标准控制结构的方式。

13.3 goto 语句

goto语句也许是最基本的低级控制结构。自从 1960 年代末和 1970 年代“结构化编程”浪潮以来,它在高级语言代码中的使用逐渐减少。事实上,一些现代高级编程语言(例如 Java 和 Swift)甚至不提供非结构化的goto语句。即使在提供goto语句的语言中,编程风格指南通常也会限制其仅在特殊情况下使用。再加上自 1970 年代中期以来,学生程序员被严格教导在程序中避免使用goto,因此在现代程序中很少能找到goto语句。从可读性的角度来看,这是件好事(可以查看一些 1960 年代的 FORTRAN 程序,了解当代码中充斥着goto语句时,代码有多难以阅读)。尽管如此,仍有一些程序员认为,通过在代码中使用goto语句,他们可以实现更高的效率。虽然有时这种说法是对的,但效率的提升往往不值得牺牲可读性。

goto的一个重要效率论点是它有助于避免重复代码。考虑以下简单的 C/C++示例:


			if( a == b || c < d )
{
    << execute some number of statements >>

    if( x == y )
    {
        << execute some statements if x == y >>
    }
    else
    {
        << execute some statements if x != y >>
    }
}
else
{
    << execute the same sequence of statements
       that the code executes if x!= y in the
       previous else section >>
}

寻找提高程序效率方法的程序员会立即注意到所有重复的代码,并可能会被诱使将示例重写如下:


			if( a == b || c < d )
{
    << execute some number of statements >>

    if( x != y ) goto DuplicatedCode;

    << execute some statements if x == y >>
}
else
{
DuplicatedCode:
    << execute the same sequence of statements
       if x != y or the original
       Boolean expression is false >>
}

当然,这段代码存在一些软件工程问题,包括它比原始示例稍难阅读、修改和维护。(你可以辩称它实际上更容易维护,因为你不再有重复的代码,只需要在一个地方修复公共代码中的缺陷。)然而,不能否认的是,这个示例中的代码量确实较少。或者说,真的少吗?

许多现代编译器中的优化器实际上会寻找像第一个示例中的代码序列,并生成与第二个示例预期完全相同的代码。因此,一个优秀的编译器即使源文件中存在重复,也会避免生成重复的机器代码,正如第一个示例中的情况。

考虑以下 C/C++示例:


			#include <stdio.h>

static int a;
static int b;

extern int x;
extern int y;
extern int f( int );
extern int g( int );
int main( void )
{
    if( a==f(x))
    {
        if( b==g(y))
        {
            a=0;
        }
        else
        {
            printf( "%d %d\n", a, b );
            a=1;
            b=0;
        }
    }
    else
    {
        printf( "%d %d\n", a, b );
        a=1;
        b=0;
    }

    return( 0 );
}

这是 GCC 将if语句序列编译成 PowerPC 代码的过程:


			        ; f(x):

        lwz r3,0(r9)
        bl L_f$stub

        ; Compute a==f(x), jump to L2 if false

        lwz r4,0(r30)
        cmpw cr0,r4,r3
        bne+ cr0,L2

        ; g(y):

        addis r9,r31,ha16(L_y$non_lazy_ptr-L1$pb)
        addis r29,r31,ha16(_b-L1$pb)
        lwz r9,lo16(L_y$non_lazy_ptr-L1$pb)(r9)
        la r29,lo16(_b-L1$pb)(r29)
        lwz r3,0(r9)
        bl L_g$stub

        ; Compute b==g(y), jump to L3 if false:

        lwz r5,0(r29)
        cmpw cr0,r5,r3
        bne- cr0,L3

        ; a=0
        li r0,0
        stw r0,0(r30)
        b L5

        ; Set up a and b parameters if
        ; a==f(x) but b!=g(y):

L3:
        lwz r4,0(r30)
        addis r3,r31,ha16(LC0-L1$pb)
        b L6

        ; Set up parameters if a!=f(x):
L2:
        addis r29,r31,ha16(_b-L1$pb)
        addis r3,r31,ha16(LC0-L1$pb)
        la r29,lo16(_b-L1$pb)(r29)
        lwz r5,0(r29)

        ; Common code shared by both
        ; ELSE sections:
L6:
        la r3,lo16(LC0-L1$pb)(r3) ; Call printf
        bl L_printf$stub
        li r9,1                 ; a=1
        li r0,0                 ; b=0
        stw r9,0(r30)           ; Store a
        stw r0,0(r29)           ; Store b
L5:

当然,并非每个编译器都有优化器能够识别重复代码。因此,如果你想编写一个无论编译器如何都能编译成高效机器代码的程序,你可能会倾向于使用带有goto语句的代码版本。实际上,你可以提出一个强有力的软件工程论点,即源代码中的重复代码使得程序更难以阅读和维护。(如果你在某个代码副本中修复了一个缺陷,可能会忘记在其他副本中修复相同的缺陷。)虽然这确实是事实,但如果你在目标标签处对代码进行修改,那么是否每个跳转到该目标标签的代码段都适合这种更改并不立刻显现。而且,在阅读源代码时,也不容易立刻看出有多少个goto语句将控制转移到同一个目标标签。

传统的软件工程方法是将公共代码放入一个过程或函数中,然后简单地调用该函数。然而,函数调用和返回的开销可能相当大(尤其是在没有太多重复代码的情况下),因此从性能角度来看,这种方法可能不尽如人意。对于短小的公共代码序列,创建宏或内联函数可能是最佳解决方案。更复杂的是,你可能需要对仅影响一个实例的重复代码进行更改(也就是说,它不再是重复的)。总的来说,以这种方式使用goto语句来提高效率应当是你的最后手段。

另一个常见的goto语句用法是处理异常情况。当你发现自己深深地嵌套在多个语句中,并且遇到一种需要退出所有这些语句的情况时,普遍共识是,如果重构代码不会使其更加可读,那么使用goto是可以接受的。然而,从嵌套块跳出可能会妨碍优化器为整个过程或函数生成良好代码的能力。goto语句的使用可能会在它直接影响的代码中节省一些字节或处理器周期,但它也可能对函数的其余部分产生不利影响,导致整体代码效率降低。因此,在代码中插入goto语句时要小心——它们可能会使源代码更难以阅读,并且可能最终使其效率降低。

如果你觉得有用,下面是一个可以用来解决原始问题的编程技巧。考虑对代码进行如下修改:


			switch( a == b || c < d )
{
    case 1:
        << execute some number of statements >>

        if( x == y )
        {
            << execute some statements if x == y >>
            break;
        }
        // Fall through if x != y

    case 0:

        << execute some statements if x!= y or
            if !( a == b || c < d )  >>
}

当然,这段代码比较棘手,而棘手的代码通常不是优秀的代码。然而,它确实有一个好处,就是避免了程序中源代码的重复。

13.3.1 限制形式的 goto 语句

为了支持结构化的“无 goto”编程,许多编程语言添加了受限形式的 goto 语句,允许程序员立即退出控制结构,如循环或过程/函数。典型的语句包括 breakexit,它们会跳出包围的循环;continuecyclenext,它们会重新启动包围的循环;以及 returnexit,它们会立即从包围的过程或函数中返回。这些语句比标准的 goto 更具结构性,因为程序员并不选择跳转目标;相反,控制会转移到一个固定的位置,这个位置取决于包含该语句的控制语句(或函数或过程)。

几乎所有这些语句都会编译为单个 jmp 指令。那些跳出循环的语句(如 break)会编译为一个 jmp 指令,将控制权转移到循环底部之后的第一个语句。那些重新启动循环的语句(例如,continuenextcycle)会编译为一个 jmp 指令,将控制权转移到循环终止测试(对于 whilerepeat..until/do..while)或循环顶部(对于其他大多数循环)。

然而,仅仅因为这些语句通常会编译为单个 jmp 指令,并不意味着它们使用起来高效。即使忽略了 jmp 可能比较昂贵的事实(因为它会迫使 CPU 清空指令流水线),从循环中分支出来的语句可能会对编译器的优化器产生严重影响,显著降低生成高质量代码的机会。因此,您应该尽可能节省使用这些语句。

13.4 if 语句

也许最基本的高级控制结构就是 if 语句。事实上,仅凭 ifgoto 语句,您就可以(语义上)实现所有其他控制结构。^(1) 我们将在讨论其他控制结构时再次提到这一点,但现在我们先来看一下典型编译器是如何将 if 语句转换为机器代码的。

为了实现一个简单的 if 语句,它比较两个值并在条件为 true 时执行主体,您可以使用单个比较和条件跳转指令。考虑下面的 Pascal if 语句:


			if( EAX = EBX ) then begin

    writeln( 'EAX is equal to EBX' );
    i := i + 1;

end;

下面是转换为 80x86/HLA 代码的示例:


			    cmp( EAX, EBX );
    jne skipIfBody;
    stdout.put( "EAX is equal to EBX", nl );
    inc( i );
skipIfBody:

在 Pascal 源代码中,if 语句的主体会在 EAX 的值等于 EBX 时执行。在生成的汇编代码中,程序会比较 EAX 和 EBX,然后,如果 EAX 不等于 EBX,跳过对应于 if 语句主体的语句。这是将高层语言 if 语句转换为机器代码的“模板”:测试某个条件,如果条件为 false,则跳过 if 语句的主体。

if..then..else 语句的实现比基本的 if 语句稍微复杂一些。if..then..else 语句通常采用如下的语法和语义:


			if( some_boolean_expression ) then

    << Statements to execute if the expression is true >>

else

    << Statements to execute if the expression is false >>

endif

在机器代码中实现这一代码序列,只需比简单的 if 语句多一个机器指令。考虑以下 C/C++ 示例代码:


			if( EAX == EBX )
{
    printf( "EAX is equal to EBX\n" );
    ++i;
}
else
{
    printf( "EAX is not equal to EBX\n" );
}

下面是转换成 80x86 汇编语言代码:


			    cmp( EAX, EBX );        // See if EAX == EBX
    jne doElse;             // Branch around "then" code
    stdout.put( "EAX is equal to EBX", nl );
    inc( i );
    jmp skipElseBody;        // Skip over "else" section.

// if they are not equal.

doElse:
    stdout.put( "EAX is not equal to EBX", nl );

skipElseBody:

这段代码有两点需要注意。首先,如果条件计算结果为 false,代码将跳转到 else 块的第一条语句,而不是跳转到(整个)if 语句后的第一条语句。第二点需要注意的是,在 true 条件语句末尾的 jmp 指令跳过了 else 块。

一些语言,包括 HLA,支持 if 语句中的 elseif 子句,当第一个条件失败时,它会评估第二个条件。这是对我所展示的 if 语句代码生成的一个直接扩展。考虑以下 HLA if..elseif..else..endif 语句:


			if( EAX = EBX ) then

    stdout.put( "EAX is equal to EBX" nl );
    inc( i );

elseif( EAX = ECX ) then

    stdout.put( "EAX is equal to ECX" nl );

else

    stdout.put( "EAX is not equal to EBX or ECX" nl);

endif;

下面是转换成纯 80x86/HLA 汇编语言代码:


			// Test to see if EAX = EBX

    cmp( eax, ebx );
    jne tryElseif;    // Skip "then" section if equal

    // Start of the "then" section

    stdout.put( "EAX is equal to EBX", nl );
    inc( i );
    jmp skipElseBody  // End of "then" section, skip
                      // over the elseif clause.
tryElseif:
    cmp( eax, ecx );  // ELSEIF test for EAX = ECX
    jne doElse;       // Skip "then" clause if not equal

    // elseif "then" clause

    stdout.put( "EAX is equal to ECX", nl );
    jmp skipElseBody; // Skip over the "else" section

doElse: // else clause begins here
    stdout.put( "EAX is not equal to EBX or ECX", nl );

skipElseBody:

elseif 子句的翻译非常直接;它的机器码与 if 语句是相同的。这里值得注意的是,编译器如何在 if..then 子句末尾发出 jmp 指令,以跳过为 elseif 子句发出的布尔测试。

13.4.1 提高某些 if/else 语句的效率

从效率角度来看,需要注意的是,if..else 语句中没有路径是不会涉及控制转移的(与简单的 if 语句不同,如果条件表达式为 true,它会直接跳过)。正如本章所指出的,分支是有问题的,因为它们通常会刷新 CPU 的指令流水线,重新填充需要几个 CPU 周期。如果布尔表达式的两个结果(truefalse)的可能性相等,那么通过重新安排 if..else 语句来提高代码的性能几乎没有什么可做的。然而,对于大多数 if 语句来说,一个结果往往比另一个更可能——甚至可能大大更可能——出现。理解一种比较比另一种比较更可能的汇编程序员通常会按如下方式编码他们的 if..else 语句:


			// if( eax == ebx ) then
//    //<likely case>
//    stdout.put( "EAX is equal to EBX", nl );
// else
//    // unlikely case
//    stdout.put( "EAX is not equal to EBX" nl );
// endif;

    cmp( EAX, EBX );
    jne goDoElse;
    stdout.put( "EAX is equal to EBX", nl );
backFromElse:
        .
        .
        .
// Somewhere else in the code (not in the direct path of the above):

goDoElse:
    stdout.put( "EAX is not equal to EBX", nl );
    jmp backFromElse

请注意,在最常见的情况下(即表达式求值为true时),代码会直接跳转到then部分,然后继续执行if语句后面的代码。因此,如果布尔表达式(eax == ebx)大部分时间为true,这段代码会毫不分支地直接执行。在极少数情况下,当 EAX 不等于 EBX 时,程序实际上需要执行两次分支:一次转移控制到处理else子句的代码段,另一次将控制权返回到if后面的第一条语句。只要这种情况发生的频率不到一半,软件的整体性能就会得到提升。你可以在像 C 这样的高级语言中通过goto语句实现相同的结果。例如:


			if( eax != ebx ) goto doElseStuff;

    // << body of the if statement goes here>>
    // (statements between then and else)
endOfIF:
// << statements following the if..endif statement >>
    .
    .
    .
// Somewhere outside the direct execution path of the above

doElseStuff:
    << Code to do if the expression is false >>
    goto endOfIF;

当然,这种方案的缺点是它产生了意大利面条代码,一旦你加入多个这种权宜之计,它就变得难以阅读。汇编语言程序员可以使用这种方式,因为大多数汇编语言代码本质上就是意大利面条代码。^(2) 然而,对于高级语言(HLL)代码而言,这种编程风格通常是不可接受的,只有在必要时才应使用它。(请参见“goto语句”在第 455 页的内容。)

以下是一个在高级语言(如 C)中常见的通用if语句:


			if( eax == ebx )
{
    // Set i to some value along this execution path.

    i = j+5;
}
else
{
    // Set i to a different value along this path

    i = 0;
}

这是将此 C 代码转换为 80x86/HLA 汇编代码的形式:


			        cmp( eax, ebx );
        jne doElse;
        mov( j, edx );
        add( 5, edx );
        mov( edx, i );
        jmp ifDone;

doElse:
        mov( 0, i );
ifDone:

正如你在前面的示例中看到的,if..then..else语句的汇编语言转换需要两条控制转移指令:

  • 测试 EAX 和 EBX 之间比较的jne指令

  • 无条件的jmp指令跳过if语句中的else部分

无论程序采取哪条路径(通过then部分还是else部分),CPU 都会执行一个慢速的分支指令,最终导致指令流水线被刷新。考虑以下代码,它没有这个问题:


			i = 0;
if( eax == ebx )
{
    i = j + 5;
}

这是它转换为纯 80x86/HLA 汇编代码的形式:


			        mov( 0, i );
        cmp( eax, ebx );
        jne skipIf;
        mov( j, edx );
        add( 5, edx );
        mov( edx, i );
skipIf:

正如你所见,如果表达式求值为true,CPU 根本不会执行任何控制转移指令。是的,CPU 执行了一条额外的mov指令,其结果会立即被覆盖(因此第一次mov指令的执行是浪费的);然而,这条额外的mov指令的执行速度远快于jmp指令的执行。这个技巧是一个典型例子,说明了为什么了解一些汇编语言代码(以及了解编译器如何从高级语言代码生成机器代码)是一个好主意。第二个序列比第一个更优这一点并不明显。事实上,初学者可能会认为它更差,因为当表达式求值为true时,程序“浪费”了对i的赋值(而第一个版本没有进行这样的赋值)。这也是本章存在的原因之一——确保你理解使用高级控制结构所涉及的成本。

13.4.2 强制在 if 语句中进行完全布尔求值

因为完全布尔求值和短路布尔求值可能会产生不同的结果(参见 第 441 页的“短路求值”),因此在计算布尔表达式的结果时,有时需要强制代码使用其中一种形式。

强制完全布尔求值的一般方法是评估表达式的每个子组件,并将子结果存储到临时变量中。然后,你可以在计算完这些临时结果后,将它们组合成完整的结果。例如,考虑以下 Pascal 代码片段:


			if( (i < g(y)) and (k > f(x)) ) then begin

    i := 0;

end;

因为 Pascal 不保证完全布尔求值,函数 f() 可能不会在此表达式中被调用——如果 i 小于 g(y)——因此,调用 f() 可能产生的副作用可能不会发生。(参见 第 430 页的“算术表达式中的副作用”)。如果应用程序的逻辑依赖于 f()g() 的调用产生的副作用,则必须确保应用程序调用这两个函数。请注意,简单地交换 AND 运算符两边的子表达式不足以解决这个问题;通过这种修改,应用程序可能不会调用 g()

解决这个问题的一种方法是使用单独的赋值语句计算两个子表达式的布尔结果,然后在 if 表达式中计算这两个结果的逻辑与:


			lexpr := i < g(y);
rexpr := k > f(x);
if( lexpr AND rexpr ) then begin

    i := 0;

end;

不必过于担心使用这些临时变量可能导致的效率损失。任何提供优化功能的编译器都会将这些值放入寄存器,而不会使用实际的内存位置。考虑以下用 C 语言编写并通过 Visual C++ 编译器编译的 Pascal 程序变体:


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    int lExpr;
    int rExpr;

    lExpr = i < g(y);
    rExpr = k > f(x);
    if( lExpr && rExpr )
    {
        printf( "Hello" );
    }

    return( 0 );
}

以下是 Visual C++ 编译器转换为 32 位 MASM 代码的结果(为了更清楚地表达意图,几条指令已经重新排列):


			main    PROC

$LN7:
        mov     QWORD PTR [rsp+8], rbx
        push    rdi
        sub     rsp, 32                                 ; 00000020H

; eax = g(y)
        mov     ecx, DWORD PTR y
        call    g
; ebx (lExpr) = i < g(y)
        xor     edi, edi
        cmp     DWORD PTR i, eax
        mov     ebx, edi ; ebx = 0
        setl    bl ;if i < g(y), set EBX to 1.

; eax = f(x)
        mov     ecx, DWORD PTR x
        call    f

; EDI = k > f(x)

        cmp     DWORD PTR k, eax
        setg    dil ; Sets EDI to 1 if k > f(x)

; See if lExpr is false:

        test    ebx, ebx
        je      SHORT $LN4@main

; See if rExpr is false:

        test    edi, edi
        je      SHORT $LN4@main

; "then" section of the if statement:

        lea     rcx, OFFSET FLAT:$SG7893
        call    printf
 $LN4@main:

; return(0);
        xor     eax, eax

        mov     rbx, QWORD PTR [rsp+48]
        add     rsp, 32                                 ; 00000020H
        pop     rdi
        ret     0
main    ENDP

如果你查看汇编代码,你会发现这段代码片段始终执行对 f()g() 的调用。与以下 C 代码和汇编输出对比:


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    if( i < g(y) && k > f(x) )
    {
        printf( "Hello" );
    }

    return( 0 );
}

以下是 MASM 汇编输出:


			main    PROC

$LN7:
        sub     rsp, 40                                 ; 00000028H

; if (!(i < g(y))) then bail on the rest of the code:

        mov     ecx, DWORD PTR y
        call    g
        cmp     DWORD PTR i, eax
        jge     SHORT $LN4@main

; if (!(k > f(x))) then skip printf:

        mov     ecx, DWORD PTR x
        call    f
        cmp     DWORD PTR k, eax
        jle     SHORT $LN4@main

; Here's the body of the if statement.

        lea     rcx, OFFSET FLAT:$SG7891
        call    printf
$LN4@main:

; return 0
        xor     eax, eax

        add     rsp, 40                                 ; 00000028H
        ret     0
main    ENDP

在 C 语言中,你可以使用另一种技巧来强制在任何布尔表达式中进行完全的布尔求值。C 语言的位运算符不支持短路布尔求值。如果你的布尔表达式中的子表达式始终产生 01,那么位运算布尔与(&)和布尔或(|)运算符的结果与逻辑布尔运算符(&&||)产生的结果是相同的。考虑以下 C 代码和 Visual C++ 编译器生成的 MASM 代码:


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    if( i < g(y) & k > f(x) )
    {
        printf( "Hello" );
    }
    return( 0 );
}

以下是 Visual C++ 生成的 MASM 代码:


			main    PROC

$LN6:
        mov     QWORD PTR [rsp+8], rbx
        push    rdi
        sub     rsp, 32                                 ; 00000020H

        mov     ecx, DWORD PTR x
        call    f
        mov     ecx, DWORD PTR y
        xor     edi, edi
        cmp     DWORD PTR k, eax
        mov     ebx, edi
        setg    bl
        call    g
        cmp     DWORD PTR i, eax
        setl    dil
        test    edi, ebx
        je      SHORT $LN4@main

        lea     rcx, OFFSET FLAT:$SG7891
        call    printf
$LN4@main:

        xor     eax, eax

        mov     rbx, QWORD PTR [rsp+48]
        add     rsp, 32                                 ; 00000020H
        pop     rdi
        ret     0
main    ENDP

注意位运算符的使用如何生成与早期使用临时变量的代码段相似的代码。这会减少原始 C 源文件中的杂乱。

然而,值得记住的是,C 的按位运算符只有在操作数为01时,才会产生与逻辑运算符相同的结果。幸运的是,你可以在这里使用一个小技巧:只需写!!(expr),如果表达式的值是零或非零,C 将把结果转换为01。为了演示这一点,考虑以下 C/C++代码片段:


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

int main( int argc, char **argv )
{
    int boolResult;

    boolResult = !!argc;
    printf( "!!(argc) = %d\n", boolResult );
    return 0;
}

这是微软 Visual C++编译器为此短程序生成的 80x86 汇编代码:


			main    PROC
$LN4:
        sub     rsp, 40      ; 00000028H

        xor     edx, edx     ; EDX = 0
        test    ecx, ecx     ; System passes ARGC in ECX register
        setne   dl           ; If ECX==0, sets EDX=1, else EDX=0

        lea     rcx, OFFSET FLAT:$SG7886 ; Zero flag unchanged!
        call    printf       ; printf parm1 in RCX, parm2 in EDX

; Return 0;
        xor     eax, eax

        add     rsp, 40                   ; 00000028H
        ret     0
main    ENDP

正如你在 80x86 汇编输出中所看到的,只需要三条机器指令(不涉及昂贵的分支操作)就能将零/非零转换为0/1

13.4.3 在 if 语句中强制短路求值

虽然偶尔强制完全布尔求值是有用的,但强制短路求值的需求可能更为常见。考虑以下 Pascal 语句:


			if( (ptrVar <> NIL) AND (ptrVar^ < 0) ) then begin

    ptrVar^ := 0;

end;

Pascal 语言的定义将是否使用完全的布尔求值还是短路求值留给编译器编写者决定。实际上,编写者可以根据需要自由选择两种方案。因此,完全有可能同一个编译器在代码的一个部分使用完全布尔求值,而在另一个部分使用短路求值。

你可以看到,如果ptrVar包含 NIL 指针值,并且编译器使用完全布尔求值,这个布尔表达式将失败。要使此语句正确工作,唯一的方法就是使用短路布尔求值。

使用 AND 运算符模拟短路布尔求值实际上非常简单。你所需要做的就是创建一对嵌套的if语句,并将每个子表达式分别放入其中。例如,你可以通过以下方式在当前 Pascal 示例中保证短路布尔求值:


			if( ptrVar <> NIL ) then begin

    if( ptrVar^ < 0 ) then begin

        ptrVar^ := 0;
 end;

end;

这条语句在语义上与前一个相同。应该很清楚,如果第一个表达式求值为false,第二个子表达式将不会执行。尽管这种方法会让源文件稍显冗杂,但它确实能保证无论编译器是否支持该方案,都会进行短路求值。

处理逻辑“或”操作要稍微复杂一些。如果左操作数求值为true,则需要额外的测试来保证右操作数不执行。考虑以下 C 代码(记住,C 默认支持短路求值):


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    if( i < g(y) || k > f(x) )
    {
        printf( "Hello" );
    }

    return( 0 );
}

这是微软 Visual C++编译器生成的机器代码:


			main    PROC

$LN8:
        sub     rsp, 40             ; 00000028H

        mov     ecx, DWORD PTR y
        call    g
        cmp     DWORD PTR i, eax
        jl      SHORT $LN3@main
        mov     ecx, DWORD PTR x
        call    f
        cmp     DWORD PTR k, eax
        jle     SHORT $LN6@main
$LN3@main:

        lea     rcx, OFFSET FLAT:$SG6880
        call    printf
$LN6@main:

        xor     eax, eax

        add     rsp, 40              ; 00000028H
        ret     0
main    ENDP
_TEXT   ENDS

这是一个 C 程序版本,它实现了短路求值,而不依赖 C 编译器的实现(值得注意的是,C 的语言定义保证了短路求值,但你可以在任何语言中使用这种方法):


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    int temp;

        // Compute left subexpression and
        // save.

    temp = i < g(y);

        // If the left subexpression
        // evaluates to false, then try
        // the right subexpression.

    if( !temp )
    {
        temp = k > f(x);
    }

        // If either subexpression evaluates
        // to true, then print "Hello"

    if( temp )
    {
        printf( "Hello" );
    }

    return( 0 );
}

这是微软 Visual C++编译器为此短程序生成的相应 MASM 代码:


			main    PROC

$LN9:
        sub     rsp, 40         ; 00000028H

        mov     ecx, DWORD PTR y
        call    g
        xor     ecx, ecx
        cmp     DWORD PTR i, eax
        setl    cl
        test    ecx, ecx

        jne     SHORT $LN7@main

        mov     ecx, DWORD PTR x
        call    f
        xor     ecx, ecx
        cmp     DWORD PTR k, eax
        setg    cl
        test    ecx, ecx

        je      SHORT $LN5@main
$LN7@main:

        lea     rcx, OFFSET FLAT:$SG6881
        call    printf
$LN5@main:

        xor     eax, eax

        add     rsp, 40            ; 00000028H
        ret     0
main    ENDP

如你所见,编译器为第二版例程(手动强制短路求值)生成的代码不如 C 编译器为第一个示例生成的代码那样好。然而,如果你需要短路求值的语义以确保程序正确执行,那么你只能接受比编译器直接支持这种方案时生成的代码效率低的情况。

如果速度、最小化大小和短路求值都是必要的,并且你愿意牺牲代码的可读性和可维护性来实现这些目标,那么你可以解构代码,生成类似于 C 编译器通过短路求值生成的代码。请看以下 C 代码及其生成的输出:


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    if( i < g(y)) goto IntoIF;
    if( k > f(x) )
    {
      IntoIF:

        printf( "Hello" );
    }

    return( 0 );
}

这是 Visual C++的 MASM 输出:


			main    PROC

$LN8:
        sub     rsp, 40         ; 00000028H

        mov     ecx, DWORD PTR y
        call    g
        cmp     DWORD PTR i, eax
        jl      SHORT $IntoIF$9

        mov     ecx, DWORD PTR x
        call    f
        cmp     DWORD PTR k, eax
        jle     SHORT $LN6@main
$IntoIF$9:

        lea     rcx, OFFSET FLAT:$SG6881
        call    printf
$LN6@main:

        xor     eax, eax

        add     rsp, 40         ; 00000028H
        ret     0
main    ENDP

如果将这段代码与原始 C 示例的 MASM 输出进行比较(原始 C 示例依赖于短路求值),你会发现这段代码同样高效。这是为什么在 1970 年代,一些程序员对结构化编程存在相当大抵制的经典例子——有时候它会导致不那么高效的代码。当然,代码的可读性和可维护性通常比几个字节或机器周期更重要。但永远不要忘记,如果性能对某段小代码至关重要,解构这段代码在某些特殊情况下可以提高效率。

13.5 switch/case语句

switch(或case)高阶控制语句是 HLL(高级语言)中另一种条件语句。如你所见,if语句测试布尔表达式,并根据表达式的结果执行代码中的两条不同路径。另一方面,switch/case语句可以根据序数(整数)表达式的结果跳转到代码中的多个不同位置。以下示例展示了 C/C++、Pascal 和 HLA 中的switchcase语句。首先是 C/C++中的switch语句:


			switch( expression )
{
  case 0:
    << statements to execute if the
       expression evaluates to 0 >>
    break;

  case 1:
    << statements to execute if the
       expression evaluates to 1 >>
    break;

  case 2:
    << statements to execute if the
       expression evaluates to 2>>
    break;

  <<etc>>

  default:
    << statements to execute if the expression is
       not equal to any of these cases >>
}

Java 和 Swift 为switch语句提供了类似 C/C++的语法,尽管 Swift 的版本有许多附加特性。我们将在“Swift switch语句”章节中探讨一些附加特性,具体见第 500 页。

这是一个 Pascal case语句的示例:


			case ( expression ) of
  0: begin
    << statements to execute if the
       expression evaluates to 0 >>
    end;

  1: begin
    << statements to execute if the
       expression evaluates to 1 >>
    end;

  2: begin
    << statements to execute if the
       expression evaluates to 2>>
    end;

  <<etc>>

  else
    << statements to execute if
       REG32 is not equal to any of these cases >>

end; (* case *)

最后,以下是 HLA 中的switch语句:


			switch( REG32 )

  case( 0 )
    << statements to execute if
       REG32 contains 0 >>

  case( 1 )
    << statements to execute
       REG32 contains 1 >>

  case( 2 )
    << statements to execute if
       REG32 contains 2>>

  <<etc>>

  default
    << statements to execute if
       REG32 is not equal to any of these cases >>

endswitch;

从这些例子中你可以看出,这些语句的语法都非常相似。

13.5.1 switch/case语句的语义

大多数初学编程的课程和教材通过将switch/case语句与一系列if..else..if语句进行比较,来讲解switch/case语句的语义;这种方式用学生已经理解的概念介绍switch/case语句。不幸的是,这种方法可能具有误导性。为了解释原因,请看以下代码,一本入门级 Pascal 编程书籍可能会声称它等同于我们的 Pascal case语句:


			if( expression = 0 ) then begin

  << statements to execute if expression is 0 >>

end
else if( expression = 1 ) then begin

  << statements to execute if expression is 1 >>

end
else if( expression = 2 ) then begin

  << statements to execute if expression is 2 >>

end
else
  << statements to execute if expression is not 1 or 2 >>

end;

尽管这个特定的序列会与 case 语句达到相同的效果,但 if..then..elseif 序列和 Pascal case 实现之间有几个根本性的区别。首先,case 语句中的 case 标签必须都是常量,但在 if..then..elseif 链中,你实际上可以将变量和其他非常量值与控制变量进行比较。switch/case 语句的另一个限制是,你只能将单一表达式的值与常量集合进行比较;而在 if..then..elseif 链中,你可以将一个表达式与一个常量进行比较,并将另一个表达式与第二个常量进行比较。稍后会解释这些限制的原因,但这里要记住的是,if..then..elseif 链在语义上与 switch/case 语句不同——并且比其功能更强大。

13.5.2 跳转表与链式比较

尽管 switch/case 语句在可读性和便利性上可能比 if..then..elseif 链更好,但它最初添加到高级语言中是为了效率,而非可读性或便利性。考虑一个包含 10 个独立表达式的 if..then..elseif 链。如果所有的 case 是互斥的并且同样可能,那么程序平均需要执行五次比较,才能遇到一个计算结果为 true 的表达式。在汇编语言中,使用表查找和间接跳转,可以在固定时间内将控制转移到多个不同的目标地址,而不受 case 数量的影响。实际上,这段代码利用 switch/case 表达式的值作为索引,查找地址表中的一个地址,然后(间接地)跳转到表项指定的语句。当 case 数量超过三四个时,这种方案通常比相应的 if..then..elseif 链更快,且占用更少的内存。考虑以下汇编语言中 switch/case 语句的简单实现:


			// Conversion of
//    switch(i)
//    { case 0:...case 1:...case 2:...case 3:...}
// into assembly

static
  jmpTable: dword[4] :=
    [ &label0, &label1, &label2, &label3 ];
      .
      .
      .
    // jmps to address specified by jmpTable[i]

    mov( i, eax );
    jmp( jmpTable[ eax*4 ] );

label0:
    << code to execute if i = 0 >>
    jmp switchDone;

label1:
    << code to execute if i = 1 >>
    jmp switchDone;

label2:
    << code to execute if i = 2 >>
    jmp switchDone;

label3:
    << code to execute if i = 3 >>

switchDone:
  << Code that follows the switch statement >>

为了查看这段代码如何运作,我们将逐条指令进行分析。jmpTable 声明定义了一个包含四个双字指针的数组,每个指针对应 switch 语句模拟中的一个 case。数组中的第 0 项保存了 switch 表达式计算结果为 0 时需要跳转到的语句地址,第 1 项保存了 switch 表达式计算结果为 1 时需要执行的语句地址,依此类推。请注意,数组必须包含一个元素,其索引与 switch 语句中每个可能的 case 匹配(在此例中为 03)。

这个例子中的第一条机器指令将switch表达式的值(变量i的值)加载到 EAX 寄存器中。因为这段代码使用switch表达式的值作为索引来访问jmpTable数组,所以这个值必须是一个整数(整型)值,存储在 80x86 的 32 位寄存器中。接下来的指令(jmp)执行了switch语句仿真的实际工作:它跳转到由jmpTable数组中由 EAX 索引的条目所指定的地址。如果 EAX 在执行这条jmp指令时的值为0,程序将从jmpTable[0]获取双字,并将控制转移到该地址;这是程序代码中label0标签后面的第一条指令的地址。如果 EAX 的值为1,则jmp指令从内存地址jmpTable + 4获取双字(注意,这段代码使用了*4的缩放索引寻址模式;有关更多详细信息,请参阅第 34 页)。同样,如果 EAX 的值为23,则jmp指令将控制转移到存储在jmpTable + 8jmpTable + 12(分别)的双字地址。因为jmpTable数组已经初始化了label0label1label2label3的地址,分别位于偏移量 0、4、8 和 12,因此这个特定的间接jmp指令将把控制转移到与i的值相对应的标签语句(label0label1label2label3)。

这个switch语句仿真最有趣的第一点是,它只需要两条机器指令(和一个跳转表)就能将控制转移到四个可能的案例中的任何一个。与此相比,if..then..elseif的实现,每个案例至少需要两条机器指令。实际上,随着你向if..then..elseif实现中添加更多的案例,比较和条件分支指令的数量会增加,而跳转表实现的机器指令数始终固定为两条(尽管跳转表的大小会因每个案例增加一个条目而增大)。因此,随着案例的增加,if..then..elseif实现会逐渐变慢,而跳转表实现则保持恒定的执行时间(无论案例的数量如何)。假设你的 HLL 编译器为switch语句使用了跳转表实现,那么如果有大量案例,switch语句通常会比if..then..elseif序列更快。

然而,switch语句的跳转表实现也有几个缺点。首先,由于跳转表是内存中的一个数组,而访问(非缓存的)内存可能较慢,因此访问跳转表数组可能会影响系统性能。

另一个缺点是,你必须在表格中为每一个可能的情况(从最大值到最小值之间的所有情况)创建一个条目,包括那些你并未明确提供的情况。在目前为止的示例中,这并不是一个问题,因为情况值从0开始,并且是连续的,直到3。然而,考虑以下的 Pascal case语句:


			case( i ) of

  0: begin
      << statements to execute if i = 0 >>
     end;

  1: begin
      << statements to execute if i = 1 >>
     end;
  5: begin
      << statements to execute if i = 5 >>
     end;

  8: begin
      << statements to execute if i = 8 >>
     end;

end; (* case *)

我们无法通过一个包含四个条目的跳转表来实现这个case语句。如果i的值是01,则会获取正确的地址。然而,对于情况 5,跳转表的索引将是20(5 × 4),而不是跳转表中的第三个(2 × 4 = 8)条目。如果跳转表只包含四个条目(16 字节),那么使用值20进行索引将会获取到表格末尾之后的地址,并可能导致应用崩溃。这正是为什么在 Pascal 的原始定义中,如果程序提供了一个在特定case语句的标签集合中不存在的情况值,结果将是未定义的原因。

为了解决汇编语言中的这个问题,你必须确保每一个可能的情况标签都有相应的条目,并且所有这些标签之间的值也要包含在内。在当前示例中,跳转表需要九个条目来处理所有可能的情况值,从08


			// Conversion of
//    switch(i)
//    { case 0:...case 1:...case 5:...case 8:}
// into assembly

static
  jmpTable: dword[9] :=
          [
            &label0, &label1, &switchDone,
            &switchDone, &switchDone,
            &label5, &switchDone, &switchDone,
            &label8
          ];
      .
      .
      .
    // jumps to address specified by jmpTable[i]

    mov( i, eax );
    jmp( jmpTable[ eax*4 ] );

label0:
    << code to execute if i = 0 >>
    jmp switchDone;

label1:
    << code to execute if i = 1 >>
    jmp switchDone;
label5:
    << code to execute if i = 5 >>
    jmp switchDone;

label8:
    << code to execute if i = 8 >>

switchDone:
  << Code that follows the switch statement >>

注意,如果i的值为23467,那么这段代码会将控制转移到switch语句之后的第一个语句(这是 C 语言的switch语句和大多数现代 Pascal 变种中的case语句的标准语义)。当然,如果switch/case表达式的值大于最大情况值,C 语言也会将控制转移到这段代码中的这一点。大多数编译器通过在间接跳转之前立即进行比较和条件分支来实现此功能。例如:


			// Conversion of
//    switch(i)
//    { case 0:...case 1:...case 5:...case 8:}
// into assembly, that automatically
// handles values greater than 8.

static
  jmpTable: dword[9] :=
          [
            &label0, &label1, &switchDone,
            &switchDone, &switchDone,
            &label5, &switchDone, &switchDone,
            &label8
          ];
      .
      .
      .
    // Check to see if the value is outside the range
    // of values allowed by this switch/case stmt.

    mov( i, eax );
    cmp( eax, 8 );
    ja switchDone;

    // jmps to address specified by jmpTable[i]

    jmp( jmpTable[ eax*4 ] );

      .
      .
      .

switchDone:
  << Code that follows the switch statement >>

你可能已经注意到这段代码做出了另一个假设——即情况值从0开始。修改代码以处理任意范围的情况值是很简单的。考虑以下示例:


			// Conversion of
//    switch(i)
//    { case 10:...case 11:...case 12:...case 15:...case 16:}
// into assembly, that automatically handles values
// greater than 16 and values less than 10.

static
  jmpTable: dword[7] :=
          [
            &label10, &label11, &label12,
            &switchDone, &switchDone,
            &label15, &label16
          ];
      .
      .
      .
    // Check to see if the value is outside the
    // range 10..16.

    mov( i, eax );
    cmp( eax, 10 );
    jb switchDone;
    cmp( eax, 16 );
    ja switchDone;

    // The "- 10*4" part of the following expression
    // adjusts for the fact that EAX starts at 10
    // rather than 0, but we still need a zero-based
    // index into our array.

    jmp( jmpTable[ eax*4 - 10*4] );

      .
      .
      .

switchDone:
  << Code that follows the switch statement >>

这个例子和之前的例子有两个区别。首先,这个例子将 EAX 中的值与范围10..16进行比较,如果值超出此范围,则跳转到switchDone标签(换句话说,EAX 中的值没有对应的 case 标签)。其次,jmpTable的索引被修改为[eax*4 - 10*4]。在机器级别,数组总是从索引0开始;该表达式中的“- 10*4”部分调整了 EAX 实际上包含的是从10开始的值,而不是从0开始。实际上,这个表达式使得jmpTable在内存中的起始位置比声明中所示提前了 40 个字节。因为 EAX 的值总是大于等于 10(由于eax*4的作用,实际上是 40 字节或更大),所以这段代码从jmpTable声明的起始位置开始访问该表。需要注意的是,HLA 从jmpTable的地址中减去这个偏移量;CPU 在运行时并不会实际执行这次减法操作。因此,创建这个基于零的索引不会导致额外的效率损失。

请注意,完全通用的switch/case语句实际上需要六条指令来实现:原始的两条指令加上四条用于测试范围的指令。^(3) 这一点,再加上间接跳转的执行成本略高于条件分支的事实,解释了为什么switch/case语句(相对于if..then..elseif链)的盈亏平衡点大约在三到四个分支之间。

如前所述,switch/case语句的跳转表实现有一个严重的缺点,即你必须为从最小 case 到最大 case 之间的每一个可能的值准备一个表项。考虑以下 C/C++ switch语句:


			switch( i )
{
  case 0:
      << statements to execute if i == 0 >>
      break;

  case 1:
      << statements to execute if i == 1 >>
      break;

  case 10:
      << statements to execute if i == 10 >>
      break;

  case 100:
      << statements to execute if i == 100 >>
      break;

  case 1000:
      << statements to execute if i == 1000 >>
      break;

  case 10000:
      << statements to execute if i == 10000 >>
      break;
}

如果 C/C++编译器使用跳转表来实现这个switch语句,那么该表将需要 10,001 个条目(也就是说,在 32 位处理器上需要 40,004 字节的内存)。对于这样一个简单的语句来说,这可是相当大的一块内存!虽然各个 case 之间的宽大间隔对内存使用有很大的影响,但它对switch语句的执行速度影响却微乎其微。程序执行的仍然是与值是连续的情况相同的四条指令(只需要四条指令,因为 case 值从0开始,所以无需检查switch表达式是否符合下界)。实际上,唯一导致性能差异的原因是表的大小对缓存的影响(当表非常大时,查找某个特定的表项时,缓存命中率较低)。抛开速度问题不谈,跳转表的内存使用对于大多数应用来说是难以证明其合理性的。因此,如果你的编译器为所有switch/case语句生成了跳转表(你可以通过查看它生成的代码来确定),那么你应该小心创建那些 case 分布较远的switch/case语句。

13.5.3 switch/case 的其他实现

由于跳转表大小的问题,一些高级语言编译器并未使用跳转表实现switch/case语句。有些编译器会将switch/case语句简单地转换为相应的if..then..elseif链(Swift 就是这种情况)。显然,这种编译器在跳转表合适的情况下会生成低质量的代码(从速度角度来看)。许多现代编译器在代码生成方面相对智能。它们会根据switch/case语句中的案例数量以及案例值的分布来选择使用跳转表还是if..then..elseif实现,这取决于一些阈值标准(代码大小与速度的平衡)。有些编译器甚至可能使用这些技术的组合。例如,考虑以下 Pascal 的case语句:


			case( i ) of
  0: begin
      << statements to execute if i = 0 >>
     end;

  1: begin
      << statements to execute if i = 1 >>
     end;

  2: begin
      << statements to execute if i = 2 >>
     end;

  3: begin
      << statements to execute if i = 3 >>
     end;

  4: begin
      << statements to execute if i = 4 >>
     end;

  1000: begin
      << statements to execute if i = 1000 >>
        end;
end; (* case *)

一个好的编译器会识别出大多数案例适合使用跳转表,只有少数(一个或几个)案例不适合。它会将代码转换为一系列结合了if..then和跳转表实现的指令。例如:


			    mov( i, eax );
    cmp( eax, 4 );
    ja try1000;
    jmp( jmpTable[ eax*4 ] );
      .
      .
      .
try1000:
    cmp( eax, 1000 );
    jne switchDone;
    << code to do if i = 1000 >>
switchDone:

尽管switch/case语句最初是为了在高级语言中使用高效的跳转表传输机制而创建的,但很少有语言定义要求特定的控制结构实现。因此,除非你坚持使用某个特定的编译器,并且知道该编译器在所有情况下如何生成代码,否则完全无法保证你的switch/case语句会编译成跳转表、if..then..elseif链、两者的组合,或者完全不同的东西。例如,考虑以下简短的 C 程序及其生成的汇编输出:


			extern void f( void );
extern void g( void );
extern void h( void );
int main( int argc, char **argv )
{
    int boolResult;

    switch( argc )
    {
        case 1:
            f();
            break;

        case 2:
            g();
            break;

        case 10:
            h();
            break;

        case 11:
            f();
            break;
    }
    return 0;
}

这是(较旧版本的)Borland C++ v5.0 编译器的 80x86 输出:


			_main   proc    near
?live1@0:
   ;
   ;    int main( int argc, char **argv )
   ;
@1:
    push      ebp
    mov       ebp,esp
   ;
   ;    {
   ;        int boolResult;
   ;
   ;        switch( argc )
   ;

; Is argc == 1?

    mov       eax,dword ptr [ebp+8]
    dec       eax
    je        short @7

; Is argc == 2?

    dec       eax
    je        short @6

; Is argc == 10?

    sub       eax,8
    je        short @5

; Is argc == 11?

    dec       eax
    je        short @4

; If none of the above

    jmp       short @2
   ;
   ;        {
   ;            case 1:
   ;                f();
   ;
@7:
    call      _f
   ;
   ;                break;
   ;
    jmp       short @8
   ;
   ;
   ;            case 2:
   ;                g();
   ;
@6:
    call      _g
   ;
   ;                break;
   ;
    jmp       short @8
   ;
   ;
   ;            case 10:
   ;                h();
   ;
@5:
    call      _h
   ;
   ;                break;
   ;
    jmp       short @8
   ;
   ;
   ;            case 11:
   ;                f();
   ;
@4:
    call      _f
   ;
   ;                break;
   ;
   ;        }
   ;        return 0;
   ;
@2:
@8:
    xor       eax,eax
   ;
   ;    }
   ;
@10:
@9:
    pop       ebp
    ret
_main   endp

如你所见,在主程序开始部分,代码将argc中的值依次与四个值(121011)进行比较。对于这样一个小的switch语句,这并不是一个糟糕的实现。

当有相当多的案例且跳转表会太大时,许多现代优化编译器会生成二叉搜索树来测试各个案例。例如,考虑以下 C 程序及其相应的输出:


			#include <stdio.h>

extern void f( void );
int main( int argc, char **argv )
{
    int boolResult;

    switch( argc )
    {
        case 1:
            f();
            break;

        case 10:
            f();
            break;

        case 100:
            f();
            break;

        case 1000:
            f();
            break;

        case 10000:
            f();
            break;

        case 100000:
            f();
            break;

        case 1000000:
            f();
            break;

        case 10000000:
            f();
            break;

        case 100000000:
            f();
            break;

        case 1000000000:
            f();
            break;
    }
    return 0;
}

这是来自 Visual C++编译器的 64 位 MASM 输出。注意,微软的编译器如何通过对每个 10 个案例进行部分二叉搜索:


			main    PROC

$LN18:
        sub     rsp, 40                                 ; 00000028H

; >+ 100,000?
        cmp     ecx, 100000                             ; 000186a0H
        jg      SHORT $LN15@main
        je      SHORT $LN10@main

; handle cases where argc is less than 100,000
;
; Check for argc = 1

        sub     ecx, 1
        je      SHORT $LN10@main

; check for argc = 10

        sub     ecx, 9
        je      SHORT $LN10@main

;check for argc = 100

        sub     ecx, 90                                 ; 0000005aH
        je      SHORT $LN10@main

; check for argc = 1000

        sub     ecx, 900                                ; 00000384H
        je      SHORT $LN10@main

; check for argc = 1000
        cmp     ecx, 9000                               ; 00002328H

        jmp     SHORT $LN16@main
$LN15@main:

; Check for argc = 100,000

      cmp     ecx, 1000000                              ; 000f4240H
        je      SHORT $LN10@main

; check for argc = 1,000,000
        cmp     ecx, 10000000                           ; 00989680H
        je      SHORT $LN10@main

; check for argc = 10,000,000
        cmp     ecx, 100000000                          ; 05f5e100H
        je      SHORT $LN10@main

; check for argc = 100,000,000

        cmp     ecx, 1000000000                         ; 3b9aca00H
$LN16@main:
        jne     SHORT $LN2@main
$LN10@main:

        call    f
$LN2@main:

        xor     eax, eax

        add     rsp, 40                                 ; 00000028H
        ret     0
main    ENDP

有趣的是,在编译成 32 位代码时,Visual C++会生成真正的二叉搜索。以下是 Visual C++ 32 位版本的 MASM32 输出:


			_main   PROC

        mov     eax, DWORD PTR _argc$[esp-4] ; argc is passed on stack in 32-bit code

; Start with >100,000, = 100,000, or < 100,000

        cmp     eax, 100000                  ; 000186a0H
        jg      SHORT $LN15@main             ; Go if >100,000
        je      SHORT $LN4@main              ; Match if equal

; Handle cases where argc < 100,000
;
; Divide it into >100 and < 100

        cmp     eax, 100                     ; 00000064H
        jg      SHORT $LN16@main             ; Branch if > 100
        je      SHORT $LN4@main              ; = 100

; Down here if < 100

        sub     eax, 1
        je      SHORT $LN4@main              ; branch if it was 1

        sub     eax, 9                       ; Test for 10
        jmp     SHORT $LN18@main

; Come down here if >100 and <100,000
$LN16@main:

        cmp     eax, 1000                    ; 000003e8H
        je      SHORT $LN4@main              ; Branch if 1000
        cmp     eax, 10000                   ; 00002710H
        jmp     SHORT $LN18@main             ; Handle =10,000 or not in range

; Handle > 100,000 here.

$LN15@main:
        cmp     eax, 100000000               ; 05f5e100H
        jg      SHORT $LN17@main             ; > 100,000,000
        je      SHORT $LN4@main              ; = 100,000

; Handle < 100,000,000 and > 100,000 here:

        cmp     eax, 1000000                 ; 000f4240H
        je      SHORT $LN4@main              ; =1,000,000
        cmp     eax, 10000000                ; 00989680H

        jmp     SHORT $LN18@main             ; Handle 10,000,000 or not in range

; Handle > 100,000,000 here
$LN17@main:
; check for 1,000,000,000
        cmp     eax, 1000000000              ; 3b9aca00H
$LN18@main:
        jne     SHORT $LN2@main
$LN4@main:

        call    _f
$LN2@main:

        xor     eax, eax

        ret     0
_main   ENDP

一些编译器,特别是某些微控制器设备的编译器,会生成一个 2 元组 表(成对的记录/结构),元组的一个元素是 case 值,第二个元素是如果值匹配时跳转到的地址。然后编译器会生成一个循环,扫描这个小表,寻找当前 switch/case 表达式的值。如果这是线性搜索,这种实现比 if..then..elseif 链还慢。如果编译器生成的是二分查找,代码可能会比 if..then..elseif 链更快,但可能不如跳转表实现快。

这是一个 Java switch 语句的例子,以及编译器生成的 Java 字节码:


			public class Welcome
{
    public static void f(){}
    public static void main( String[] args )
    {
        int i = 10;
        switch (i)
        {
            case 1:
                f();
                break;

            case 10:
                f();
                break;

            case 100:
                f();
                break;

            case 1000:
                f();
                break;

            case 10000:
                f();
                break;

            case 100000:
                f();
                break;

            case 1000000:
                f();
                break;

            case 10000000:
                f();
                break;

            case 100000000:
                f();
                break;

            case 1000000000:
                f();
                break;

        }
    }
}

// JBC output:

Compiled from "Welcome.java"
public class Welcome extends java.lang.Object{
public Welcome();
  Code:
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return

public static void f();
  Code:
   0:   return

public static void main(java.lang.String[]);
  Code:
   0:   bipush  10
   2:   istore_1
   3:   iload_1
   4:   lookupswitch{ //10
        1: 96;
        10: 102;
        100: 108;
        1000: 114;
        10000: 120;
        100000: 126;
        1000000: 132;
        10000000: 138;
        100000000: 144;
        1000000000: 150;
        default: 153 }
   96:  invokestatic    #2; //Method f:()V
   99:  goto    153
   102: invokestatic    #2; //Method f:()V
   105: goto    153
   108: invokestatic    #2; //Method f:()V
   111: goto    153
   114: invokestatic    #2; //Method f:()V
   117: goto    153
   120: invokestatic    #2; //Method f:()V
   123: goto    153
   126: invokestatic    #2; //Method f:()V
   129: goto    153
   132: invokestatic    #2; //Method f:()V
   135: goto    153
   138: invokestatic    #2; //Method f:()V
   141: goto    153
   144: invokestatic    #2; //Method f:()V
   147: goto    153
   150: invokestatic    #2; //Method f:()V
   153: return
}

lookupswitch 字节码指令包含一个由 2 元组组成的表。如前所述,元组的第一个值是 case 值,第二个值是匹配时代码跳转的目标地址。可以推测,字节码解释器对这些值执行二分查找,而不是线性查找(希望是这样!)。注意,Java 编译器为每个 case 生成了单独的 f() 方法调用;它并没有像 GCC 和 Visual C++ 那样将它们优化为单个调用。

注意

Java 还具有一个 tableswitch 虚拟机指令,用于执行基于表驱动的 switch 操作。Java 编译器会根据 case 值的密度选择使用 tableswitch 还是 lookupswitch 指令。

有时候,编译器会采用一些代码技巧,在特定情况下生成略微更好的代码。再看看导致 Borland 编译器生成线性查找的简短 switch 语句:


			switch( argc )
    {
        case 1:
            f();
            break;

        case 2:
            g();
            break;

        case 10:
            h();
            break;

        case 11:
            f();
            break;

    }

这是微软 Visual C++ 32 位编译器为这个 switch 语句生成的代码:


			; File t.c
; Line 13
;
; Use ARGC as an index into the $L1240 table,
; which returns an offset into the $L1241 table:

    mov eax, DWORD PTR _argc$[esp-4]
    dec eax         ; --argc, 1=0, 2=1, 10=9, 11=10
    cmp eax, 10     ; Out of range of cases?
    ja  SHORT $L1229
    xor ecx, ecx
    mov cl, BYTE PTR $L1240[eax]
    jmp DWORD PTR $L1241[ecx*4]

    npad    3
$L1241:
    DD  $L1232  ; cases that call f
    DD  $L1233  ; cases that call g
    DD  $L1234  ; cases that call h
    DD  $L1229  ; Default case

$L1240:
    DB  0   ; case 1 calls f
    DB  1   ; case 2 calls g
    DB  3   ; default
    DB  3   ; default
    DB  3   ; default
    DB  3   ; default
    DB  3   ; default
    DB  3   ; default
    DB  3   ; default
    DB  2   ; case 10 calls h
    DB  0   ; case 11 calls f

; Here is the code for the various cases:

$L1233:
; Line 19
    call    _g
; Line 31
    xor eax, eax
; Line 32
    ret 0

$L1234:
; Line 23
    call    _h
; Line 31
    xor eax, eax
; Line 32
    ret 0

$L1232:
; Line 27
    call    _f
$L1229:
; Line 31
    xor eax, eax
; Line 32
    ret 0

这个 80x86 代码的技巧在于,Visual C++ 首先通过表查找将 argc 值范围 1..11 映射到值范围 0..3(对应于出现的三个不同的代码体,以及一个默认 case)。这段代码比跳转表要短,双字条目对应于默认 case,尽管它比跳转表稍慢,因为它需要访问内存中的两个不同表。(至于这段代码的速度与二分查找或线性查找相比如何,这个研究留给你自己;答案可能因处理器而异。)然而需要注意的是,当生成 64 位代码时,Visual C++ 会回退到线性查找:


			main    PROC

$LN12:
        sub     rsp, 40                                 ; 00000028H

; ARGC is passed in ECX

        sub     ecx, 1
        je      SHORT $LN4@main  ; case 1
        sub     ecx, 1
        je      SHORT $LN5@main  ; case 2
        sub     ecx, 8
        je      SHORT $LN6@main  ; case 10
        cmp     ecx, 1
        jne     SHORT $LN10@main ; case 11
$LN4@main:

        call    f
$LN10@main:

        xor     eax, eax

        add     rsp, 40                                 ; 00000028H
        ret     0
$LN6@main:

        call    h

        xor     eax, eax

        add     rsp, 40                                 ; 00000028H
        ret     0
$LN5@main:

        call    g

        xor     eax, eax

        add     rsp, 40                                 ; 00000028H
        ret     0
main    ENDP

很少有编译器允许你显式指定编译器如何转换特定的switch/case语句。例如,如果你真的希望之前提到的包含 0、1、10、100、1000 和 10000 这几个 case 的switch语句生成跳转表,你必须使用汇编语言编写代码,或者使用你理解其代码生成特性的特定编译器。然而,任何依赖于编译器生成跳转表的 HLL 代码都无法在其他编译器中移植,因为很少有语言指定高级控制结构的实际机器代码实现。

当然,你不必完全依赖编译器为switch/case语句生成高效的代码。假设你的编译器对所有switch/case语句使用跳转表实现,当对你的 HLL 源代码做出修改时,可能会生成一个巨大的跳转表,你可以帮助编译器生成更好的代码。例如,考虑之前给出的switch语句,包含 0、1、2、3、4 和 1000 这几个 case。如果你的编译器生成一个包含 1001 个条目的跳转表(占用超过 4KB 的内存),你可以通过编写以下 Pascal 代码来改善编译器的输出:


			if( i = 1000 ) then begin

  << statements to execute if i = 1000 >>

end
else begin

  case( i ) of
    0: begin
        << statements to execute if i = 0 >>
       end;

    1: begin
        << statements to execute if i = 1 >>
       end;

    2: begin
        << statements to execute if i = 2 >>
       end;

    3: begin
        << statements to execute if i = 3 >>
       end;

    4: begin
        << statements to execute if i = 4 >>
       end;
  end; (* case *)
end; (* if *)

通过将 case 值1000放在switch语句之外,编译器可以为主要的、连续的 case 生成一个简短的跳转表。

另一个可能性(可以说更容易阅读)是以下 C/C++代码:


			switch( i )
{
  case 0:
      << statements to execute if i == 0 >>
      break;

  case 1:
      << statements to execute if i == 1 >>
      break;

  case 2:
      << statements to execute if i == 2 >>
      break;

  case 3:
      << statements to execute if i == 3 >>
      break;

  case 4:
      << statements to execute if i == 4 >>
     break;

  default:
    if( i == 1000 )
    {
      << statements to execute if i == 1000 >>
    }
    else
    {
      << Statements to execute if none of the cases match >>
    }
}

使这个例子稍微容易阅读的原因是,当i等于1000时的代码已经被移入了switch语句中(得益于默认子句),因此它看起来不会与switch中进行的所有测试分开。

一些编译器根本不会为switch/case语句生成跳转表。如果你使用的是这样的编译器,并且希望生成跳转表,那么除了使用汇编语言或非标准的 C 扩展之外,你几乎无能为力。

尽管switch/case语句的跳转表实现通常在有大量 case 且它们的可能性相等时效率较高,但请记住,如果其中一两个 case 的可能性远高于其他 case,使用if..then..elseif链可能更高效。例如,如果一个变量的值为15的时间超过一半,20的时间大约四分之一,而其余 25%的时间则是其他几种不同的值,那么使用if..then..elseif链(或if..then..elseifswitch/case语句的组合)来实现多路选择可能更为高效。通过先测试最常见的 case,你通常可以减少多路选择语句执行所需的平均时间。例如:


			if( i == 15 )
{
  // If i = 15 better than 50% of the time,
  // then we only execute a single test
  // better than 50% of the time:
}
else if( i == 20 )
{
  // if i == 20 better than 25% of the time,
  // then we only execute one or
  // two comparisons 75% of the time.
}
else if etc....

如果 i 的值为 15 的情况出现得更多,那么大多数时候这段代码会在执行仅两条指令后,执行第一个 if 语句的主体。即使是在最好的 switch 语句实现中,你仍然需要比这更多的指令。

13.5.4 Swift switch 语句

Swift 的 switch 语句在语义上与大多数其他语言不同。Swift 的 switch 和典型的 C/C++ switch 或 Pascal case 语句之间有四个主要区别:

  • Swift 的 switch 提供了一个特殊的 where 子句,允许你对 switch 应用条件。

  • Swift 的 switch 允许你在多个 case 语句中使用相同的值(通过 where 子句区分)。

  • Swift 的 switch 允许使用非整数/序数数据类型,如元组、字符串和集合,作为选择值(并配有适当类型的 case 值)。

  • Swift 的 switch 语句支持对 case 值进行模式匹配。

请查阅 Swift 语言参考手册以获取更多细节。本节的目的是讨论 Swift 设计如何影响其实现,而不是提供 Swift switch 的语法和语义。

由于它允许任意类型作为 switch 选择值,因此 Swift 无法使用跳转表来实现 switch 语句。跳转表实现需要一个序数值(可以表示为整数),编译器可以将其用作跳转表的索引。例如,字符串选择值不能用作数组的索引。此外,Swift 允许你指定相同的 case 值两次^(4),这就导致了一个一致性问题,因为同一个跳转表条目会映射到代码的两个不同部分(这对于跳转表来说是不可能的)。

鉴于 Swift switch 语句的设计,唯一的解决方案是线性搜索(实际上,switch 语句等同于一系列 if..else if..else if..etc 语句)。最重要的是,使用 switch 语句并不会比使用一组 if 语句带来性能上的好处。

13.5.5 switch 语句的编译器输出

在你去帮助你的编译器生成更好的 switch 语句代码之前,你可能想检查一下它实际生成的代码。本章已经描述了各种编译器在机器码层面实现 switch/case 语句时使用的几种技术,但还有一些额外的实现本书未能覆盖。尽管你不能假设编译器总是为 switch/case 语句生成相同的代码,观察它的输出有助于你了解编译器作者使用的不同实现方式。

13.6 更多信息

Aho, Alfred V., Monica S. Lam, Ravi Sethi 和 Jeffrey D. Ullman. 编译器:原理、技术与工具(第 2 版)。英国埃塞克斯:皮尔逊教育有限公司,1986 年。

Barrett, William, 和 John Couch. 编译器构造:理论与实践. 芝加哥:SRA, 1986.

Dershem, Herbert, 和 Michael Jipping. 编程语言、结构与模型. 贝尔蒙特,加利福尼亚州:Wadsworth, 1990.

Duntemann, Jeff. 汇编语言一步步学. 第 3 版. 印第安纳波利斯:Wiley, 2009.

Fraser, Christopher, 和 David Hansen. 可重定向的 C 编译器:设计与实现. 波士顿:Addison-Wesley Professional, 1995.

Ghezzi, Carlo, 和 Jehdi Jazayeri. 编程语言概念. 第 3 版. 纽约:Wiley, 2008.

Hoxey, Steve, Faraydon Karim, Bill Hay, 和 Hank Warren, 编辑. PowerPC 编译器编写者指南. 帕洛阿尔托,加利福尼亚州:Warthman Associates 为 IBM 出版, 1996.

Hyde, Randall. 汇编语言艺术. 第 2 版. 旧金山:No Starch Press, 2010.

Intel. “Intel 64 和 IA-32 架构软件开发者手册。”更新于 2019 年 11 月 11 日。software.intel.com/en-us/articles/intel-sdm.

Ledgard, Henry, 和 Michael Marcotty. 编程语言的全景图. 芝加哥:SRA, 1986.

Louden, Kenneth C. 编译器构造:原理与实践. 波士顿:Cengage, 1997.

Louden, Kenneth C., 和 Kenneth A. Lambert. 编程语言:原理与实践. 第 3 版. 波士顿:Course Technology, 2012.

Parsons, Thomas W. 编译器构造导论. 纽约:W. H. Freeman, 1992.

Pratt, Terrence W., 和 Marvin V. Zelkowitz. 编程语言:设计与实现. 第 4 版. 上萨德尔河,新泽西州:Prentice Hall, 2001.

Sebesta, Robert. 编程语言概念. 第 11 版. 波士顿:Pearson, 2016.

第十四章:迭代控制结构

image

大多数程序的大部分时间都在循环中执行机器指令。因此,如果你想提高应用程序的执行速度,首先应该看看能否提高代码中循环的性能。本章将介绍以下几种循环形式:

  • while 循环

  • repeat..until/do..while 循环

  • forever(无限)循环

  • for(确定性)循环

14.1 while 循环

while 循环可能是高级语言(HLL)提供的最通用的迭代语句,因此编译器通常会努力生成最优的代码。while 循环在循环体的顶部测试一个布尔表达式,如果表达式计算结果为 true,则执行循环体。当循环体执行完毕后,控制权返回到测试语句,过程重复。当布尔控制表达式计算结果为 false 时,程序将控制转移到循环体之外的第一个语句。这意味着如果程序首次遇到 while 语句时布尔表达式计算结果为 false,程序将跳过循环体中的所有语句而不执行它们。以下示例演示了一个 Pascal 的 while 循环:


			while( a < b ) do begin

  << Statements to execute if a is less than b.
     Presumably, these statements modify the value
     of either a or b so that this loop ultimately
     terminates. >>

end; (* while *)
<< statements that execute when a is not less than b >>

你可以通过使用 if 语句和 goto 语句在高级语言中轻松模拟 while 循环。考虑以下 C/C++ 的 while 循环以及使用 ifgoto 的语义等效代码:


			// while loop:

while( x < y )
{
  arr[x] = y;
  ++x;
}

// Conversion to an if and a goto:

whlLabel:
if( x < y )
{
  arr[x] = y;
  ++x;
  goto whlLabel;
}

假设为了这个示例,当 if/goto 组合首次执行时,x 小于 y。既然这一条件为 true,那么循环体(if 语句的 then 部分)就会执行。在循环体的底部,goto 语句将控制转移回到 if 语句之前的位置。这意味着代码会再次测试该表达式,就像 while 循环那样。每当 if 表达式计算结果为 false 时,控制将转移到 if 后的第一个语句(这将控制转移到 goto 语句之后的部分)。

尽管 if/goto 组合在语义上与 while 循环相同,但这并不意味着这里呈现的 if/goto 方案比典型编译器生成的代码更高效。并不是的。以下汇编代码展示了你从一个平庸的编译器中获得的 while 循环的代码:


			  // while( x < y )

whlLabel:
    mov( x, eax );
    cmp( eax, y );
    jnl exitWhile;  // jump to exitWhile label if
                    // x is not less than y

    mov( y, edx );
    mov( edx, arr[ eax*4 ] );
    inc( x );
    jmp whlLabel;
exitWhile:

一个优秀的编译器会通过使用一种叫做代码移动(或表达式旋转)的技术稍微改进这一点。考虑一下这个比之前的 while 循环更高效的实现:


			// while( x < y )

    // Skip over the while loop's body.

    jmp testExpr;

whlLabel:
    // This is the body of the while loop (same as
    // before, except moved up a few instructions).

    mov( y, edx );
    mov( edx, arr[ eax*4 ] );
    inc( x );

// Here is where we test the expression to
// determine if we should repeat the loop body.

testExpr:
    mov( x, eax );
    cmp( eax, y );
    jl whlLabel;    // Transfer control to loop body if x < y.

这个示例与前一个示例的机器指令数量完全相同,但循环终止的测试已移至循环的底部。为了保留while循环的语义(以便我们在第一次遇到循环时,如果表达式求值为false,就不执行循环体),该序列中的第一条语句是一个jmp语句,将控制转移到测试循环终止表达式的代码。如果该测试求值为true,程序将控制转移到while循环体(紧跟在whlLabel之后)。

尽管此代码与前一个示例有相同数量的语句,但两者之间有一个微妙的区别。在这个后者的示例中,初始的jmp指令仅执行一次——也就是循环执行的第一次。此后的每次迭代,代码都会跳过该语句的执行。在原始示例中,相应的jmp语句位于循环体的底部,并且在每次循环迭代时都会执行。因此,如果循环体执行多于一次,第二个版本会运行得更快(另一方面,如果while循环即使一次也很少执行循环体,那么第一个版本会稍微更高效)。如果你的编译器没有为while语句生成最佳代码,考虑使用不同的编译器。正如第十三章所讨论的那样,尝试通过使用ifgoto语句在高级语言中编写优化代码会产生难以阅读的意大利面条代码,而且通常情况下,代码中的goto语句实际上会损害编译器生成良好输出的能力。

注意

当本章讨论 repeat..until/do..while 循环时,你会看到一个替代方案,它不同于 if..goto 方案,并且会产生更结构化的代码,编译器可能更容易处理。尽管如此,如果你的编译器无法进行像这样的简单转换,那么编译后的while循环的效率可能是你最小的问题之一。

做得不错的编译器通常会对while循环进行优化,并假设该循环有一个入口点和一个出口点。许多语言提供语句允许提前退出循环(例如,break,如“goto语句的受限形式”一节中在第 459 页讨论过的那样)。当然,许多语言也提供某种形式的goto语句,允许你在任意点进入或退出循环。然而,记住,尽管使用这样的语句可能是合法的,但它们可能会严重影响编译器优化代码的能力。所以要小心使用它们。^(1) while循环是一个你应该让编译器处理优化,而不是自己试图优化代码的地方(实际上,这适用于所有循环,因为编译器通常在优化循环时做得很好)。

14.1.1 强制在 while 循环中完全布尔求值

while语句的执行依赖于布尔表达式求值的语义。与if语句类似,有时while循环的正确执行取决于布尔表达式是否使用完全求值或短路求值。本节介绍了如何强制while循环使用完全布尔求值,下一节将展示如何强制短路求值。

起初,你可能会猜测在while循环中强制完全布尔求值的方法与在if语句中一样。然而,如果你回顾一下针对if语句给出的解决方案(请参见第 465 页的“强制在if语句中进行完全布尔求值”),你会意识到我们在if语句中使用的方法(嵌套if和临时计算)对于while语句是行不通的。我们需要一种不同的方法。

14.1.1.1 以简单但低效的方式使用函数

强制完全布尔求值的一个简单方法是写一个函数来计算布尔表达式的结果,并在该函数内使用完全布尔求值。以下 C 代码实现了这个思路:


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

/*
** Complete Boolean evaluation
** for the expression:
** i < g(y) || k > f(x)
*/

int func( void )
{
    int temp;
    int temp2;

    temp = i < g(y);
    temp2 = k > f(x);
    return temp || temp2;
}

int main( void )
{
    /*
    ** The following while loop
    ** uses complete Boolean evaluation
    */

    while( func() )
    {
      IntoIF:

        printf( "Hello" );
    }

    return( 0 );
}

这是 GCC(x86)为这段 C 代码生成的代码(经过一些清理,去除了多余的行):


			func:
.LFB0:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $16, %rsp
        movl    y(%rip), %eax
        movl    %eax, %edi
        call    g
        movl    %eax, %edx
        movl    i(%rip), %eax
        cmpl    %eax, %edx
        setg    %al
        movzbl  %al, %eax
        movl    %eax, -8(%rbp)
        movl    x(%rip), %eax
        movl    %eax, %edi
        call    f
        movl    %eax, %edx
        movl    k(%rip), %eax
        cmpl    %eax, %edx
        setl    %al
        movzbl  %al, %eax
        movl    %eax, -4(%rbp)
        cmpl    $0, -8(%rbp)
        jne     .L2
        cmpl    $0, -4(%rbp)
        je      .L3
.L2:
        movl    $1, %eax
        jmp     .L4
.L3:
        movl    $0, %eax
.L4:
        leave
        ret
.LFE0:
        .size   func, .-func
        .section        .rodata
.LC0:
        .string "Hello"
        .text
        .globl  main
        .type   main, @function
main:
.LFB1:
        pushq   %rbp
        movq    %rsp, %rbp
        jmp     .L7
.L8:
        movl    $.LC0, %edi
        movl    $0, %eax
        call    printf
.L7:
        call    func
        testl   %eax, %eax
        jne     .L8
        movl    $0, %eax
        popq    %rbp
        ret

正如汇编代码所示,这种方法的问题在于,代码必须进行函数调用和返回(这两者都是较慢的操作),才能计算表达式的值。对于许多表达式来说,调用和返回的开销比实际计算表达式值的成本更高。

14.1.1.2 使用内联函数

前一种方法显然并不是你能够得到的最优代码,无论是在空间还是速度上。如果你的编译器支持内联函数,你可以通过在这个例子中将func()内联,从而生成一个更好的结果:


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

inline int func( void )
{
    int temp;
    int temp2;

    temp = i < g(y);
    temp2 = k > f(x);
    return temp || temp2;
}

int main( void )
{
    while( func() )
    {
      IntoIF:

        printf( "Hello" );
    }

    return( 0 );
}

这是 GCC 编译器将其转换为(32 位)x86 Gas 汇编的代码:


			main:
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %ebx
        pushl   %ecx
        andl    $-16, %esp
        .p2align 2,,3
.L2:
        subl    $12, %esp

; while( i < g(y) || k > f(x) )
;
; Compute g(y) into %EAX:

        pushl   y
        call    g
        popl    %edx
        xorl    %ebx, %ebx
        pushl   x

; See if i < g(y) and leave Boolean result
; in %EBX:

        cmpl    %eax, i
        setl    %bl

; Compute f(x) and leave result in %EAX:

        call    f                ; Note that we call f, even if the
        addl    $16, %esp        ; above evaluates to true

; Compute k > f(x), leaving the result in %EAX.

        cmpl    %eax, k
        setg    %al

; Compute the logical OR of the above two expressions.

        xorl    %edx, %edx
        testl   %ebx, %ebx
        movzbl  %al, %eax
        jne     .L6
        testl   %eax, %eax
        je      .L7
.L6:
        movl    $1, %edx
.L7:
        testl   %edx, %edx
        je      .L10
.L8:

; Loop body:

        subl    $12, %esp
        pushl   $.LC0
        call    printf
        addl    $16, %esp
        jmp     .L2
.L10:
        xorl    %eax, %eax
        movl    -4(%ebp), %ebx
        leave
        ret

正如这个例子所示,GCC 将函数直接编译到while循环的测试中,避免了与函数调用和返回相关的开销。

14.1.1.3 使用按位逻辑运算

在 C 语言中,支持对位进行布尔操作(也称为按位逻辑操作),你可以使用与if语句相同的技巧来强制进行完全的布尔求值——只需使用按位运算符。在&&||运算符的左右操作数始终为01的特殊情况下,你可以像下面这样写代码,强制进行完全的布尔求值:


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    // Use "|" rather than "||"
    // to force complete Boolean
    // evaluation here.

    while( i < g(y) | k > f(x) )
    {
        printf( "Hello" );
    }

    return( 0 );
}

这是 Borland C++为这段 C 源代码生成的汇编代码:


			_main   proc    near
?live1@0:
   ;
   ;    int main( void )
   ;
@1:
        push      ebx
        jmp       short @3 ; Skip to expr test.
   ;
   ;    {
   ;            while( i < g(y) | k > f(x) )
   ;            {
   ;                    printf( "Hello" );
   ;
@2:
        ; Loop body.

        push      offset s@
        call      _printf
        pop       ecx

; Here's where the test of the expression
; begins:

@3:
        ; Compute "i < g(y)" into ebx:

        mov       eax,dword ptr [_y]
        push      eax
        call      _g
        pop       ecx
        cmp       eax,dword ptr [_i]
        setg      bl
        and       ebx,1

        ;  Compute "k > f(x)" into EDX:

        mov       eax,dword ptr [_x]
        push      eax
        call      _f
        pop       ecx
        cmp       eax,dword ptr [_k]
        setl      dl
        and       edx,1
        ; Compute the logical OR of
        ; the two results above:

        or        ebx,edx

        ; Repeat loop body if true:

        jne       short @2
   ;
   ;            }
   ;
   ;            return( 0 );
   ;
        xor       eax,eax
   ;
   ;    }
   ;
@5:
@4:
        pop       ebx
        ret
_main   endp

正如你在这段 80x86 输出中看到的,使用位运算逻辑运算符时,编译器生成的是语义等效的代码。只需记住,这段代码只有在你使用01分别表示布尔值falsetrue时才有效。

14.1.1.4 使用非结构化代码

如果你没有内联函数的能力,或者位运算逻辑运算符不可用,你可以使用非结构化代码强制进行完全的布尔运算,作为最后的手段。基本思路是创建一个无限循环,然后编写代码在条件失败时显式退出循环。通常,你会使用goto语句(或类似 C 的breakcontinue语句的有限形式)来控制循环终止。请看下面的 C 语言示例:


			#include <stdio.h>

static int i;
static int k;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    int temp;
    int temp2;
 for( ;; )                 //Infinite loop in C/C++
    {
        temp = i < g(y);
        temp2 = k > f(x);
        if( !temp && !temp2 ) break;
        printf( "Hello" );
    }

    return( 0 );
}

通过使用无限循环并显式地中断,我们能够通过独立的 C 语句计算布尔表达式的两个部分(因此,强制编译器执行两个子表达式)。这是 MSVC++ 编译器生成的代码:


			main    PROC
; File c:\users\rhyde\test\t\t\t.cpp
; Line 16
$LN9:
        sub     rsp, 56                                 ; 00000038H

; Infinite loop jumps here:

$LN2@main:
; Line 21
;
; temp = i < g(y);
;
        mov     ecx, DWORD PTR ?y@@3HA                  ; y
        call    ?g@@YAHH@Z                              ; g

; compute i < g(y) and leave result in eax:

        cmp     DWORD PTR ?i@@3HA, eax
        jge     SHORT $LN5@main
        mov     DWORD PTR tv67[rsp], 1
        jmp     SHORT $LN6@main
$LN5@main:
        mov     DWORD PTR tv67[rsp], 0

$LN6@main:

; temp2 = k > f(x);

        mov     ecx, DWORD PTR ?x@@3HA                  ; x
        call    ?f@@YAHH@Z                              ; f

; compute k > f(x) and leave result in eax:

        cmp     DWORD PTR ?k@@3HA, eax
        jle     SHORT $LN7@main
        mov     DWORD PTR tv71[rsp], 1
        jmp     SHORT $LN8@main
$LN7@main:
        mov     DWORD PTR tv71[rsp], 0
$LN8@main:

; if( !temp && !temp2 ) break;

        or      ecx, eax
        mov     eax, ecx
        test    eax, eax
        je      SHORT $LN3@main
; Line 23
        lea     rcx, OFFSET FLAT:$SG6924
        call    printf

; Jump back to beginning of for(;;) loop.
;
; Line 24
        jmp     SHORT $LN2@main

$LN3@main:
; Line 26
        xor     eax, eax
; Line 27
        add     rsp, 56                                 ; 00000038H
        ret     0
main    ENDP

如你所见,这个程序总是会评估原始布尔表达式的两个部分(也就是说,你得到了完整的布尔运算)。

你在以这种方式使用非结构化代码时应该小心。不仅结果更难以阅读,而且很难迫使编译器生成你想要的代码。此外,在一个编译器上生成的有效代码序列,在其他编译器上可能无法生成相同的代码。

如果你的编程语言不支持像break这样的语句,你可以始终使用goto语句跳出循环并实现相同的效果。尽管将goto语句注入到代码中并不是一个好主意,但在某些情况下,它是你唯一的选择。

14.1.2 强制在 while 循环中进行短路布尔运算

有时候,即使语言(如 BASIC 或 Pascal)没有实现该方案,你也需要确保在while语句中强制进行短路运算。对于if语句,你可以通过重新安排程序中计算循环控制表达式的方式来强制进行短路运算。与if语句不同的是,你不能使用嵌套的while语句或在while循环前加上其他语句来强制短路运算,但在大多数编程语言中,依然可以做到这一点。

请考虑以下 C 代码片段:


			while( ptr != NULL && ptr->data != 0 )
{
    << loop body >>
    ptr = ptr->Next; // Step through a linked list.
}

如果 C 没有保证布尔表达式的短路运算,这段代码可能会失败。

和强制进行完全布尔运算一样,在像 Pascal 这样的语言中,最简单的方法是编写一个函数,使用短路布尔运算计算并返回布尔结果。然而,由于函数调用的高开销,这种方法相对较慢。请看下面的 Pascal 示例:^(2)


			program shortcircuit;
{$APPTYPE CONSOLE}
uses SysUtils;
var
    ptr     :Pchar;

    function shortCir( thePtr:Pchar ):boolean;
    begin

        shortCir := false;
        if( thePtr <> NIL ) then begin

            shortCir := thePtr^ <> #0;

        end; //if

    end;  // shortCircuit

begin

    ptr := 'Hello world';
    while( shortCir( ptr )) do begin

        write( ptr^ );
        inc( ptr );

    end; // while
    writeln;

end.

现在考虑一下这个由 Borland 的 Delphi 编译器生成的 80x86 汇编代码(并通过 IDAPro 反汇编):


			; function shortCir( thePtr:Pchar ):boolean
;
; Note: thePtr is passed into this function in
; the EAX register.

sub_408570  proc near

            ; EDX holds function return
            ; result (assume false).
            ;
            ; shortCir := false;

            xor     edx, edx

            ; if( thePtr <> NIL ) then begin

            test    eax, eax
            jz      short loc_40857C    ; branch if NIL

            ; shortCir := thePtr^ <> #0;

            cmp     byte ptr [eax], 0
            setnz   dl  ; DL = 1 if not #0

loc_40857C:

            ; Return result in EAX:

            mov     eax, edx
            retn
sub_408570  endp

; Main program (pertinent section):
;
; Load EBX with the address of the global "ptr" variable and
; then enter the "while" loop (Delphi moves the test for the
; while loop to the physical end of the loop's body):

                mov     ebx, offset loc_408628
                jmp     short loc_408617
; --------------------------------------------------------

loc_408600:
                ; Print the current character whose address
                ; "ptr" contains:

                mov     eax, ds:off_4092EC  ; ptr pointer
                mov     dl, [ebx]           ; fetch char
                call    sub_404523          ; print char
                call    sub_404391
                call    sub_402600

                inc     ebx                 ; inc( ptr )

; while( shortCir( ptr )) do ...

loc_408617:
                mov     eax, ebx         ; Pass ptr in EAX
                call    sub_408570       ; shortCir
                test    al, al           ; Returns true/false
                jnz     short loc_408600 ; branch if true

sub_408570过程包含计算类似于早期 C 代码中表达式的短路布尔求值的函数。正如你所看到的,如果thePtr包含 NIL(0),则解引用thePtr的代码永远不会执行。

如果函数调用不可行,那么唯一合理的解决方案就是使用非结构化的方法。以下是早期 C 代码中while循环的 Pascal 版本,强制短路布尔求值:


			    while( true ) do begin

        if( ptr = NIL ) then goto 2;
        if( ptr^.data = 0 ) then goto 2;
        << loop body >>
        ptr := ptr^.Next;

    end;
2:

再次强调,像本例中的非结构化代码,应该仅作为最后的手段来编写。但是,如果你使用的语言(或编译器)不保证短路求值,并且你需要这些语义,那么非结构化代码或低效代码(使用函数调用)可能是唯一的解决方案。

14.2 repeat..until(do..until/do..while)循环

另一个在大多数现代编程语言中常见的循环是repeat..until。这个循环在循环底部测试终止条件。这意味着循环体至少会执行一次,即使布尔控制表达式在循环的第一次迭代中评估为false。尽管repeat..until循环的适用范围比while循环小,你也不会像使用while循环那样频繁使用它,但在很多情况下,repeat..until循环是最适合的控制结构。经典的例子可能是读取用户输入,直到用户输入某个特定值。以下是一个典型的 Pascal 代码片段:


			repeat

      write( 'Enter a value (negative quits): ');
      readln( i );
      // do something with i's value

until( i < 0 );

这个循环总是执行一次循环体。当然,这是必要的,因为你必须执行循环体来读取用户输入的值,程序会检查这个值来判断循环何时结束。

repeat..until循环在布尔控制表达式评估为true时终止(而不是false,像while循环那样),正如词语until所暗示的那样。然而,值得注意的是,这是一个小的语法问题;C/C++/Java/Swift 语言(以及许多继承了 C 语言的语言)提供了do..while循环,它会在循环条件评估为true时重复执行循环体。从效率的角度来看,这两种循环完全没有区别,你可以通过使用语言的逻辑非操作符轻松将一种循环终止条件转换为另一种。以下示例演示了 Pascal、HLA 和 C/C++中的repeat..untildo..while循环的语法。以下是 Pascal 的repeat..until循环示例:


			repeat

    (* Read a raw character from the "input" file, which in this case is the keyboard *)

    ch := rawInput( input );

    (* Save the character away. *)

    inputArray[ i ] := ch;
    i := i + 1;

    (* Repeat until the user hits the enter key *)

until( ch = chr( 13 ));

现在,这里是相同循环的 C/C++ do..while版本:


			do
{
    /* Read a raw character from the "input" file, which in this case is the keyboard */

    ch = getKbd();

    /* Save the character away. */
 inputArray[ i++ ] = ch;

    /* Repeat until the user hits the enter key */
}
while( ch != '\r' );

这里是 HLA 的repeat..until循环:


			repeat

    // Read a character from the standard input device.

    stdin.getc();

    // Save the character away.

    mov( al, inputArray[ ebx ] );
    inc( ebx );

    // Repeat until the user hits the enter key.

until( al = stdio.cr );

repeat..until(或do..while)循环转换为汇编语言相对简单直接。编译器只需要为布尔循环控制表达式替换代码,并在表达式为真时(对于repeat..untilfalse,对于do..whiletrue)跳转回循环体的开头。下面是早期 HLA repeat..until循环的直接纯汇编实现(C/C++和 Pascal 编译器对于其他示例会生成几乎相同的代码):


			rptLoop:

    // Read a character from the standard input.

    call stdin.getc;

    // Store away the character.

    mov( al, inputArray[ ebx ] );
    inc( ebx );

    // Repeat the loop if the user did not hit
    // the enter key.

    cmp( al, stdio.cr );
    jne rptLoop;

正如你所看到的,典型编译器为repeat..until(或do..while)循环生成的代码通常比常规的while循环生成的代码更高效。因此,如果语义上可行,你应该考虑使用repeat..until/do..while形式。在许多程序中,布尔控制表达式在某些循环构造的第一次迭代时总是评估为true。例如,在应用程序中,遇到如下循环并不罕见:


			i = 0;
while( i < 100 )
{
      printf( "i: %d\n", i );
      i = i * 2 + 1;
      if( i < 50 )
      {
            i += j;
      }
}

这个while循环可以轻松地转换为如下的do..while循环:


			i = 0;
do
{
      printf( "i: %d\n", i );
      i = i * 2 + 1;
      if( i < 50 )
      {
            i += j;
      }
} while( i < 100 );

这种转换之所以可行,是因为我们知道i的初始值(0)小于100,因此循环体总是至少执行一次。

如你所见,通过使用更合适的repeat..until/do..while循环,而非常规的while循环,你可以帮助编译器生成更好的代码。然而,请记住,效率的提升很小,因此确保你不会因此牺牲可读性或可维护性。始终使用最合乎逻辑的循环结构。如果循环体总是至少执行一次,你应该使用repeat..until/do..while循环,即使while循环也同样有效。

14.2.1 强制repeat..until循环中的完全布尔求值

由于在repeat..until(或do..while)循环中,测试循环终止发生在循环的底部,因此你可以像在if语句中一样强制完全布尔求值。考虑以下 C/C++代码:


			extern int x;
extern int y;
extern int f( int );
extern int g( int );
extern int a;
extern int b;
int main( void )
{

    do
        {
            ++a;
            --b;
        }while( a < f(x) && b > g(y));

    return( 0 );
}

下面是 GCC 为 PowerPC(使用短路求值,这是 C 的标准)生成的do..while循环的输出:


			L2:
        // ++a
        // --b

        lwz r9,0(r30)  ; get a
        lwz r11,0(r29) ; get b
        addi r9,r9,-1  ; --a
        lwz r3,0(r27)  ; Set up x parm for f
        stw r9,0(r30)  ; store back into a
        addi r11,r11,1 ; ++b
        stw r11,0(r29) ; store back into b

        ; compute f(x)

        bl L_f$stub    ; call f, result to R3

        ; is a >= f(x)? If so, quit loop

        lwz r0,0(r29)  ; get a
        cmpw cr0,r0,r3 ; Compare a with f's value
        bge- cr0,L3

        lwz r3,0(r28)  ; Set up y parm for g
        bl L_g$stub    ; call g

        lwz r0,0(r30)  ; get b
        cmpw cr0,r0,r3 ; Compare b with g's value
        bgt+ cr0,L2    ; Repeat if b > g's value
L3:

如果表达式a < f(x)false(即a >= f(x)),这个程序会跳过b > g(y)的测试,直接跳转到标签L3

为了强制完全布尔求值,我们的 C 源代码需要在while子句之前计算布尔表达式的子组件(将子表达式的结果保存在临时变量中),然后仅测试while子句中的结果:


			static int a;
static int b;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    int temp1;
    int temp2;

    do
        {
            ++a;
            --b;
            temp1 = a < f(x);
            temp2 = b > g(y);
        }while( temp1 && temp2 );

    return( 0 );
}

下面是 GCC 将代码转换为 PowerPC 的结果:


			L2:
        lwz r9,0(r30)    ; r9 = b
        li r28,1         ; temp1 = true
        lwz r11,0(r29)   ; r11 = a
        addi r9,r9,-1    ; --b
        lwz r3,0(r26)    ; r3 = x (set up f's parm)
        stw r9,0(r30)    ; Save b
        addi r11,r11,1   ; ++a
        stw r11,0(r29)   ; Save a
        bl L_f$stub      ; Call f
        lwz r0,0(r29)    ; Fetch a
        cmpw cr0,r0,r3   ; Compute temp1 = a < f(x)
        blt- cr0,L5      ; Leave temp1 true if a < f(x)
        li r28,0         ; temp1 = false
L5:
        lwz r3,0(r27)    ; r3 = y, set up g's parm
        bl L_g$stub      ; Call g
        li r9,1          ; temp2 = true
        lwz r0,0(r30)    ; Fetch b
        cmpw cr0,r0,r3   ; Compute b > g(y)
        bgt- cr0,L4      ; Leave temp2 true if b > g(y)
        li r9,0          ; Else set temp2 false
L4:
        ; Here's the actual termination test in
        ; the while clause:

        cmpwi cr0,r28,0
        beq- cr0,L3
        cmpwi cr0,r9,0
        bne+ cr0,L2
L3:

当然,实际的布尔表达式(temp1 && temp2)仍然使用短路求值,但仅针对所创建的临时变量。无论第一个子表达式的结果如何,循环都会计算两个原始子表达式。

14.2.2 强制repeat..until循环中的短路布尔求值

如果你的编程语言提供了一种能够跳出repeat..until循环的功能,例如 C 语言的break语句,那么强制短路运算就非常简单。考虑前一部分中强制进行完全布尔运算的 C 语言do..while循环:


			do
{
    ++a;
    --b;
    temp1 = a < f(x);
    temp2 = b > g(y);

}while( temp1 && temp2 );

以下展示了一种转换代码的方式,使其使用短路布尔运算来评估终止表达式:


			static int a;
static int b;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    do
    {
        ++a;
        --b;

        if( !( a < f(x) )) break;
    } while( b > g(y) );

    return( 0 );
}

下面是 GCC 为 PowerPC 生成的do..while循环代码:


			L2:
        lwz r9,0(r30)   ; r9 = b
        lwz r11,0(r29)  ; r11 = a
        addi r9,r9,-1   ; --b
        lwz r3,0(r27)   ; Set up f(x) parm
        stw r9,0(r30)   ; Save b
        addi r11,r11,1  ; ++a
        stw r11,0(r29)  ; Save a
        bl L_f$stub     ; Call f

        ; break if !(a < f(x)):

        lwz r0,0(r29)
        cmpw cr0,r0,r3
        bge- cr0,L3

        ; while( b > g(y) ):

        lwz r3,0(r28)   ; Set up y parm
        bl L_g$stub     ; Call g
        lwz r0,0(r30)   ; Compute b > g(y)
        cmpw cr0,r0,r3
        bgt+ cr0,L2     ; Branch if true
L3:

如果a大于或等于f(x)返回的值,则该代码会立即跳出循环(在标签L3处),而不需要测试b是否大于g(y)返回的值。因此,这段代码模拟了 C/C++表达式a < f(x) && b > g(y)的短路布尔运算。

如果你使用的编译器不支持类似于 C/C++的break语句的语句,你将需要使用稍微复杂一点的逻辑。这里有一种方法:


			static int a;
static int b;

extern int x;
extern int y;
extern int f( int );
extern int g( int );

int main( void )
{
    int temp;

    do
    {
        ++a;
        --b;
        temp = a < f(x);
        if( temp )
        {
            temp = b > g(y);
        };
    }while( temp );

    return( 0 );
}

这是 GCC 为此示例生成的 PowerPC 代码:


			L2:
        lwz r9,0(r30)   ; r9 = b
        lwz r11,0(r29)  ; r11 = a
        addi r9,r9,-1   ; --b
        lwz r3,0(r27)   ; Set up f(x) parm
        stw r9,0(r30)   ; Save b
        addi r11,r11,1  ; ++a
        stw r11,0(r29)  ; Save a
        bl L_f$stub     ; Call f
        li r9,1         ; Assume temp is true
        lwz r0,0(r29)   ; Set temp false if
        cmpw cr0,r0,r3  ; a < f(x)
        blt- cr0,L5
        li r9,0
L5:
        cmpwi cr0,r9,0  ; If !(a < f(x)) then bail
        beq- cr0,L10    ; on the do..while loop
        lwz r3,0(r28)   ; Compute temp = b > f(y)
        bl L_g$stub     ; using a code sequence
        li r9,1         ; that is comparable to
        lwz r0,0(r30)   ; the above.
        cmpw cr0,r0,r3
        bgt- cr0,L9
        li r9,0
L9:
        ; Test the while termination expression:

        cmpwi cr0,r9,0
        bne+ cr0,L2
L10:

虽然这些示例使用了与运算符(逻辑与),但使用或运算符(逻辑或)同样简单。为了结束这一部分,考虑这个 Pascal 序列及其转换:


			repeat

      a := a + 1;
      b := b - 1;

until( (a < f(x)) OR (b > g(y)) );

这是强制进行完全布尔运算的转换:


			repeat

      a := a + 1;
      b := b - 1;
      temp := a < f(x);
      if( not temp ) then begin

            temp := b > g(y);

    end;
until( temp );

下面是 Borland Delphi 为这两个循环生成的代码(假设在编译器选项中选择了完全布尔运算):


			;    repeat
;
;        a := a + 1;
;        b := b - 1;
;
;    until( (a < f(x)) or (b > g(y)));

loc_4085F8:
                inc     ebx                  ; a := a + 1;
                dec     esi                  ; b := b - 1;
                mov     eax, [edi]           ; EDI points at x
                call    locret_408570
                cmp     ebx, eax             ; Set AL to 1 if
                setl    al                   ; a < f(x)
                push    eax                  ; Save Boolean result.

                mov     eax, ds:dword_409288 ; y
                call    locret_408574        ; g(6)

                cmp     esi, eax             ; Set AL to 1 if
                setnle  al                   ; b > g(y)
                pop     edx                  ; Retrieve last value.
                or      dl, al               ; Compute their OR
                jz      short loc_4085F8     ; Repeat if false.

;    repeat
;
;        a := a + 1;
;        b := b - 1;
;        temp := a < f(x);
;        if( not temp ) then begin
;
;            temp := b > g(y);
;
;        end;
;
;    until( temp );
loc_40861B:
                inc     ebx                  ; a := a + 1;
                dec     esi                  ; b := b - 1;
                mov     eax, [edi]           ; Fetch x
                call    locret_408570        ; call f
                cmp     ebx, eax             ; is a < f(x)?
                setl    al                   ; Set AL to 1 if so.

            ; If the result of the above calculation is
            ; true, then don't bother with the second
            ; test (that is, short-circuit evaluation)

                test    al, al
                jnz     short loc_40863C

            ; Now check to see if b > g(y)

                mov     eax, ds:dword_409288
                call    locret_408574

            ; Set AL = 1 if b > g(y):

                cmp     esi, eax
                setnle  al

; Repeat loop if both conditions were false:

loc_40863C:
                test    al, al
                jz      short loc_40861B

Delphi 编译器为这种强制短路运算生成的代码,远不如它为你做这项工作的代码效果好。下面是未选中完全布尔运算选项的 Delphi 代码(即指示 Delphi 使用短路运算):


			loc_4085F8:
                inc     ebx
                dec     esi
                mov     eax, [edi]
                call    nullsub_1 ;f
                cmp     ebx, eax
                jl      short loc_408613
                mov     eax, ds:dword_409288
                call    nullsub_2 ;g
                cmp     esi, eax
                jle     short loc_4085F8

尽管这种技巧在编译器不支持时很有用,可以强制短路运算,但这个 Delphi 示例再次强调,如果可能的话,应该使用编译器提供的功能——通常这样会生成更好的机器代码。

14.3 forever..endfor 循环

while循环在循环开始(顶部)时测试是否结束。repeat..until循环在循环结束(底部)时测试是否结束。唯一可以在循环体的中间测试循环终止的位置是循环体的某个位置。forever..endfor循环以及一些特殊的循环终止语句处理这种情况。

大多数现代编程语言提供了while循环和repeat..until循环(或它们的等效形式)。有趣的是,只有少数现代命令式编程语言提供了显式的forever..endfor循环。^(3) 这尤其令人惊讶,因为forever..endfor循环(以及一个循环终止测试)实际上是三种形式中最通用的一种。你可以轻松地从单个forever..endfor循环合成while循环或repeat..until循环。

幸运的是,在任何提供while循环或repeat..until/do..while循环的语言中,创建一个简单的forever..endfor循环都很容易。你只需要提供一个布尔控制表达式,对于repeat..until,它始终评估为false,对于do..while,它始终评估为true。例如,在 Pascal 中,你可以使用如下代码:


			const
    forever = true;
        .
        .
        .
    while( forever ) do begin

        << code to execute in an infinite loop >>

    end;

标准 Pascal 的一个大问题是,它没有提供一种机制(除了通用的goto语句)来显式地跳出循环。幸运的是,许多现代 Pascal 语言,如 Delphi 和 Free Pascal,提供了类似break的语句,可以立即退出当前的循环。

尽管 C/C++语言没有提供显式的语句来创建forever循环,但语法上奇怪的for(;;)语句自从第一个 C 编译器编写以来,就一直用于此目的。因此,C/C++程序员可以按如下方式创建forever..endfor循环:


			for(;;)
{
    << code to execute in an infinite loop >>
}

C/C++程序员可以使用 C 语言的break语句(与if语句一起)在循环中间设置一个循环终止条件,如下所示:


			for(;;)
{
    << Code to execute (at least once)
       prior to the termination test >>

    if( termination_expression ) break;

    << Code to execute after the loop termination test >>
}

HLA 语言提供了一个显式的(高级)forever..endfor语句(以及breakbreakif语句),允许你在循环中途终止循环。这个 HLA 的forever..endfor循环在循环中间测试是否终止循环:


			forever

    << Code to execute (at least once) prior to
       the termination test >>

    breakif( termination_expression );

    << Code to execute after the loop termination test >>

endfor;

forever..endfor循环转换为纯汇编语言是很简单的——你只需要一个jmp指令,它可以将控制从循环底部转移回循环顶部。break语句的实现也同样简单:它只是一个跳转(或条件跳转)到循环之后的第一条语句。以下两个代码片段展示了一个 HLA 的forever..endfor循环(以及一个breakif)和相应的“纯”汇编代码:


			// High-level forever statement in HLA:

forever

    stdout.put
    (
     "Enter an unsigned integer less than five:"
    );
    stdin.get( u );
    breakif( u < 5);
    stdout.put
    (
      "Error: the value must be between zero and five" nl
    );
endfor;

// Low-level coding of the forever loop in HLA:

foreverLabel:
    stdout.put
    (
      "Enter an unsigned integer less than five:"
    );
    stdin.get( u );
    cmp( u, 5 );
    jbe endForeverLabel;
    stdout.put
    (
      "Error: the value must be between zero and five" nl
    );
    jmp foreverLabel;

endForeverLabel:

当然,你也可以调整这段代码,创建一个稍微更高效的版本:


			// Low-level coding of the forever loop in HLA
// using code rotation:

jmp foreverEnter;
foreverLabel:
        stdout.put
        (
          "Error: the value must be between zero and five"
          nl
        );
    foreverEnter:
        stdout.put
        (
          "Enter an unsigned integer less "
          "than five:"
        );
        stdin.get( u );
        cmp( u, 5 );
        ja foreverLabel;

如果你使用的语言不支持forever..endfor循环,任何一款不错的编译器都会将while(true)语句转换为一个单一的跳转指令。如果你的编译器没有这么做,那它在优化方面做得很差,任何尝试手动优化代码的努力都是徒劳的。出于你很快就会明白的原因,你不应该尝试使用goto语句来创建forever..endfor循环。

14.3.1 强制在forever循环中进行完整的布尔评估

因为你是通过if语句退出forever循环的,所以强制在退出forever循环时进行完整的布尔评估的技巧与在if语句中相同。有关详细信息,请参见第 465 页的《强制在if语句中进行完整布尔评估》。

14.3.2 强制在forever循环中进行短路布尔评估

同样地,由于你是通过 if 语句退出 forever 循环,因此在退出 forever 循环时强制短路布尔运算的技巧与在 repeat..until 语句中的技巧相同。详细内容请参见 第 524 页 中的“在 repeat..until 循环中强制短路布尔运算”。

14.4 确定性循环(for 循环)

forever..endfor 循环是一个 无限 循环(假设你没有通过 break 语句跳出它)。whilerepeat..until 循环是 不确定 循环的例子,因为通常情况下,程序无法在首次遇到它们时确定它们将执行多少次迭代。另一方面,对于 确定 循环,程序可以在执行循环体的第一条语句之前准确地确定循环将执行多少次迭代。传统高级语言中的一个确定性循环的好例子是 Pascal 的 for 循环,它使用以下语法:


			for variable := expr1 to expr2 do
        statement

expr1 小于或等于 expr2 时,它会遍历 expr1..expr2 范围,或者


			for variable := expr1 downto expr2 do
        statement

expr1 大于或等于 expr2 时,它会遍历 expr1..expr2 范围。以下是一个典型的 Pascal for 循环示例:


			for i := 1 to 10 do
    writeln( 'hello world');

这个循环总是精确执行 10 次;因此,它是一个确定性循环。然而,这并不意味着编译器必须能够在编译时确定循环迭代次数。确定性循环也允许使用表达式,强制程序在运行时确定迭代次数。例如:


			write( 'Enter an integer:');
readln( cnt );
for i := 1 to cnt do
    writeln( 'Hello World');

Pascal 编译器无法确定此循环将执行的迭代次数。事实上,由于迭代次数依赖于用户输入,它在一次程序执行中每次执行时都可能有所不同。然而,每当程序遇到此循环时,它可以确定循环将执行多少次迭代,这由 cnt 变量中的值指示。请注意,Pascal(像大多数支持确定性循环的语言一样)明确禁止如下代码:


			for i := 1 to j do begin

    << some statements >>
    i := <<some value>>;
    << some other statements >>

end;

在循环体执行过程中不允许更改循环控制变量的值。在这个例子中,如果你试图更改 for 循环的控制变量,一个高质量的 Pascal 编译器会检测到这个尝试并报告错误。此外,确定性循环只计算起始值和结束值一次。因此,如果 for 循环的体内修改了作为第二个表达式的变量,它不会在每次循环迭代时重新计算该表达式。例如,如果前面示例中的 for 循环体修改了 j 的值,这不会影响循环迭代次数。^(4)

确定性循环具有某些特殊属性,允许(好的)编译器生成更好的机器代码。特别是,因为编译器可以在执行循环体的第一条语句之前确定循环将执行多少次,它通常可以不需要复杂的循环终止测试,而直接将一个寄存器递减至0来控制循环的迭代次数。编译器还可以使用归纳法优化在确定性循环中对循环控制变量的访问(有关归纳法的描述请参见第 397 页的“算术语句优化”部分)。

C/C++/Java 用户应注意,这些语言中的for循环并不是一个真正的确定性循环;它只是一个不确定的while循环的特例。大多数优秀的 C/C++编译器会尝试确定一个for循环是否是确定性循环,如果是,它们会生成高效的代码。你可以通过遵循以下指导原则来帮助编译器:

  • 你的 C/C++ for循环应使用与像 Pascal 这样的语言中的确定性(for)循环相同的语义。也就是说,for循环应初始化一个单一的循环控制变量,当该值小于或大于某个结束值时进行终止条件测试,并且将循环控制变量增减 1。

  • 你的 C/C++ for循环不应在循环内修改循环控制变量的值。

  • 循环终止条件的测试在循环体执行过程中保持静态。也就是说,循环体不应能够改变终止条件(这将使得循环变为不确定循环)。例如,如果循环终止条件是i < j,则循环体不应修改ij的值。

  • 循环体不会通过引用将循环控制变量或出现在循环终止条件中的任何变量传递给函数,如果该函数会修改实际参数。

14.5 更多信息

在第 501 页的“更多信息”部分同样适用于本章节。请参阅该部分以获取更多详细信息。

第十五章:函数和过程

image

自从 1970 年代结构化编程革命开始以来,子程序(过程和函数)一直是软件工程师用来组织、模块化和构建程序的主要工具之一。由于过程和函数调用在代码中使用频繁,CPU 制造商尝试使它们尽可能高效。然而,这些调用及其相关的返回操作存在成本,许多程序员在创建函数时未考虑到这些成本,不恰当地使用它们可能会大幅增加程序的大小和执行时间。本章将讨论这些成本及如何避免它们,涵盖以下主题:

  • 函数和过程调用

  • 宏和内联函数

  • 参数传递和调用约定

  • 激活记录和局部变量

  • 参数传递机制

  • 函数返回结果

通过理解这些主题,你可以避免现代程序中常见的效率陷阱,这些程序大量使用过程和函数。

15.1 简单的函数和过程调用

我们从一些定义开始。函数是一个计算并返回某个值的代码块——即函数结果。过程(或在 C/C++/Java/Swift 术语中称为无返回函数)仅仅执行某些操作。函数调用通常出现在算术或逻辑表达式中,而过程调用则像编程语言中的语句。为了讨论的目的,你可以假设过程调用和函数调用是相同的,可以互换使用函数过程这两个术语。大多数情况下,编译器对过程和函数调用的实现是一样的。

注意

函数和过程确实存在一些差异。然而,函数结果相关的效率问题是其中之一,我们将在“函数返回值”一节中讨论,详见第 590 页。

在大多数 CPU 上,你通过类似于 80x86 的call指令(ARM 和 PowerPC 上的branchlink)调用过程,并使用ret(返回)指令返回调用者。call指令执行三项离散操作:

  1. 它决定了从过程返回时要执行的指令地址(通常是紧随call指令后的那条指令)。

  2. 它将此地址(通常称为返回地址链接地址)保存到一个已知位置。

  3. 它通过跳转机制将控制权转移到过程的第一条指令。

执行从过程的第一条指令开始,直到 CPU 遇到ret指令,这时会获取返回地址并将控制权转移到该地址处的机器指令。考虑以下 C 语言函数:


			#include <stdio.h>

void func( void )
{
    return;
}
int main( void )
{
    func();
    return( 0 );
}

这是 GCC 将其转换为 PowerPC 代码后的结果:


			_func:
        ; Set up activation record for function.
        ; Note R1 is used as the stack pointer by
        ; the PowerPC ABI (application binary
        ; interface, defined by IBM).

        stmw r30,-8(r1)
        stwu r1,-48(r1)
        mr r30,r1

        ; Clean up activation record prior to the return

        lwz r1,0(r1)
        lmw r30,-8(r1)

        ; Return to caller (branch to address
        ; in the link register):

        blr

_main:
        ; Save return address from
        ; main program (so we can
        ; return to the OS):

        mflr r0
        stmw r30,-8(r1) ; Preserve r30/31
        stw r0,8(r1)    ; Save rtn adrs
        stwu r1,-80(r1) ; Update stack for func()
        mr r30,r1       ; Set up frame pointer

        ; Call func:

        bl _func

        ; Return 0 as the main
        ; function result:

        li r0,0
        mr r3,r0
        lwz r1,0(r1)
        lwz r0,8(r1)
        mtlr r0
        lmw r30,-8(r1)
        blr

这是由 GCC 编译的 32 位 ARM 版本源代码:


			func:
    @ args = 0, pretend = 0, frame = 0
    @ frame_needed = 1, uses_anonymous_args = 0
    @ link register save eliminated.

    str fp, [sp, #-4]!  @ Save frame pointer on stack
    add fp, sp, #0
    nop
    add sp, fp, #0
    @ sp needed
    ldr fp, [sp], #4    @ Load FP from stack.
    bx  lr              @ Return from subroutine

main:
    @ args = 0, pretend = 0, frame = 0
    @ frame_needed = 1, uses_anonymous_args = 0

    push    {fp, lr}    @ Save FP and return address

    add fp, sp, #4      @ Set up FP
    bl  func            @ Call func
    mov r3, #0          @ main return value = 0
    mov r0, r3

    @ Note that popping PC returns to Linux
    pop {fp, pc}

下面是 GCC 将相同源代码转换为 80x86 代码的结果:


			func:
.LFB0:
    pushq   %rbp
    movq    %rsp, %rbp
    nop
    popq    %rbp
    ret

main:
.LFB1:
    pushq   %rbp
    movq    %rsp, %rbp
    call    func
    movl    $0, %eax
    popq    %rbp
    ret

如你所见,80x86、ARM 和 PowerPC 都花费了相当大的努力来构建和管理激活记录(请参阅第 179 页的“栈部分”)。在这两段汇编语言序列中,重要的是要注意 PowerPC 代码中的bl _funcblr指令;ARM 代码中的bl funcbx lr指令;以及 80x86 代码中的call funcret指令。这些是调用函数和从函数返回的指令。

15.1.1 返回地址存储

那么,CPU 到底将返回地址存储在哪里呢?在没有递归和某些其他程序控制结构的情况下,CPU 可以将返回地址存储在任何足够大的位置,只要这个位置在过程返回调用者时仍然包含该地址。例如,程序可以选择将返回地址存储在机器寄存器中(在这种情况下,返回操作将是间接跳转到寄存器中包含的地址)。然而,使用寄存器有一个问题,那就是 CPU 通常只有有限数量的寄存器。这意味着每个保存返回地址的寄存器都不能用于其他目的。因此,在那些将返回地址保存在寄存器中的 CPU 上,应用程序通常会将返回地址移动到内存中,以便可以重用该寄存器。

考虑 PowerPC 和 ARM 的bl(分支和链接)指令。该指令将控制转移到其操作数指定的目标地址,并将bl后面的指令地址复制到 LINK 寄存器中。在一个过程内部,如果没有代码修改 LINK 寄存器的值,则该过程可以通过执行 PowerPC 的blr(分支到 LINK 寄存器)或 ARM 的bx(分支和交换)指令来返回到调用者。在我们简单的示例中,func()函数没有执行任何修改 LINK 寄存器值的代码,因此这正是func()返回到其调用者的方式。然而,如果该函数将 LINK 寄存器用于其他目的,那么过程就需要负责保存返回地址,以便在函数调用结束时通过blr指令恢复返回地址。

一个更常见的保存返回地址的地方是内存。尽管在大多数现代处理器上,访问内存的速度比访问 CPU 寄存器慢得多,但将返回地址保存在内存中允许程序有大量的嵌套过程调用。大多数 CPU 实际上使用来保存返回地址。例如,80x86 的call指令返回地址压入内存中的栈数据结构,而ret指令这个返回地址从栈中弹出。使用内存位置的栈来保存返回地址有几个优点:

  • 栈由于其后进先出 (LIFO) 的组织方式,完全支持嵌套过程调用和返回以及递归过程调用和返回。

  • 栈具有内存效率,因为它们重用相同的内存位置来存储不同过程的返回地址(而不是需要单独的内存位置来存储每个过程的返回地址)。

  • 即使栈访问比寄存器访问慢,CPU 通常也能比访问其他地方的返回地址更快地访问栈上的内存位置,因为 CPU 频繁访问栈,并且栈内容通常会保存在缓存中。

  • 如第七章所述,栈也是存储激活记录(如参数、局部变量和其他过程状态信息)的好地方。

然而,使用栈也会带来一些惩罚。最重要的是,维护栈通常需要分配一个 CPU 寄存器来跟踪栈在内存中的位置。这可能是 CPU 专门为此目的分配的寄存器(例如,x86-64 的 RSP 寄存器或 ARM 的 R14/SP 寄存器),或者是没有提供显式硬件栈支持的 CPU 上的通用寄存器(例如,运行在 PowerPC 处理器系列上的应用通常使用 R1 寄存器来完成此任务)。

在提供硬件栈实现和call/ret指令对的 CPU 上,进行过程调用非常简单。如前面 80x86 GCC 示例输出所示,程序只需执行call指令将控制转移到过程的开始,然后执行ret指令从过程返回。

PowerPC/ARM 的方式,使用“分支和链接”指令,可能看起来比call/ret机制效率低。虽然“分支和链接”方法确实需要稍微多一些代码,但并不明显比call/ret方法慢。call指令是一条复杂指令(通过一条指令完成多个独立任务),因此通常需要几个 CPU 时钟周期来执行。ret指令的执行类似。额外的开销是否比维护软件栈更昂贵,取决于 CPU 和编译器。然而,“分支和链接”指令以及通过链接地址的间接跳转,通常比相应的call/ret指令对更快,因为没有维护软件栈的开销。如果一个过程不调用其他过程,并且能够在机器寄存器中维护参数和局部变量,那么完全可以跳过软件栈维护指令。例如,前面示例中的func()调用在 PowerPC 和 ARM 上可能比在 80x86 上更高效,因为func()不需要将 LINK 寄存器的值保存到内存中——它只是将该值保持在 LINK 寄存器中,直到函数执行完成。

由于许多过程较短,且参数和局部变量较少,一个好的 RISC 编译器通常能够完全省去软件栈的维护。因此,对于许多常见的过程,这种 RISC 方法比 CISC(call/ret)方法更快;然而,这并不意味着它总是更好。本节中的简短示例是一个非常特殊的情况。在我们的简单演示程序中,这段代码调用的函数——通过bl指令——离bl指令很近。在一个完整的应用程序中,func()可能会非常远,而编译器将无法将目标地址编码为指令的一部分。这是因为 RISC 处理器(如 PowerPC 和 ARM)必须将整个指令编码在一个 32 位值内(该值必须同时包括操作码和到函数的偏移量)。如果func()的距离超出了剩余偏移位(在 PowerPC 和 ARM 的bl指令中为 24 位)所能编码的范围,编译器必须发出一系列指令来计算目标例程的地址,并通过该地址间接转移控制权。大多数时候,这不应该是个问题。毕竟,很少有程序的函数会超出这个范围(在 PowerPC 中为 64MB,ARM 为±32MB)。然而,有一个非常常见的情况是 GCC(以及其他编译器,可能也一样)必须生成这种类型的代码:当编译器不知道函数的目标地址时,因为它是一个外部符号,链接器必须在编译完成后将其合并进来。因为编译器不知道该例程将在内存中的哪个位置(而且大多数链接器仅处理 32 位地址,而不是 24 位的偏移量字段),编译器必须假设该函数的地址超出了范围,并生成长版本的函数调用。考虑一下对之前示例的轻微修改:


			#include <stdio.h>

extern void func( void );

int main( void )
{
    func();

    return( 0 );
}

这段代码将func()声明为外部函数。现在看看 GCC 生成的 PowerPC 代码,并与之前的代码进行比较:


			.text
        .align 2
        .globl _main
_main:
        ; Set up main's activation record:

        mflr r0
        stw r0,8(r1)
        stwu r1,-80(r1)

        ; Call a "stub" routine that will
        ; do the real call to func():

        bl L_func$stub

        ; Return 0 as Main's function
        ; result:

        lwz r0,88(r1)
        li r3,0
        addi r1,r1,80
        mtlr r0
        blr

; The following is a stub that calls the
; real func() function, wherever it is in
; memory.

        .data
        .picsymbol_stub
L_func$stub:
        .indirect_symbol _func

        ; Begin by saving the LINK register
        ; value in R0 so we can restore it
        ; later.

        mflr r0

        ; The following code sequence gets
        ; the address of the L_func$lazy_ptr
        ; pointer object into R12:

        bcl 20,31,L0$_func      ; R11<-adrs(L0$func)
L0$_func:
        mflr r11
        addis r11,r11,ha16(L_func$lazy_ptr-L0$_func)

        ; Restore the LINK register (used by the
        ; preceding code) from R0:

        mtlr r0

        ; Compute the address of func() and move it
        ; into the PowerPC COUNT register:

        lwz r12,lo16(L_func$lazy_ptr-L0$_func)(r11)
        mtctr r12

        ; Set up R11 with an environment pointer:

        addi r11,r11,lo16(L_func$lazy_ptr-L0$_func)

        ; Branch to address held in the COUNT
        ; register (that is, to func):

        bctr

; The linker will initialize the following
; dword (.long) value with the address of
; the actual func() function:

        .data
        .lazy_symbol_pointer
L_func$lazy_ptr:
        .indirect_symbol _func
        .long dyld_stub_binding_helper

这段代码实际上调用了两个函数来调用func()。首先,它调用一个存根函数(L_func$stub),然后将控制权转交给实际的func()函数。显然,这里有相当大的开销。如果不对 PowerPC 代码与 80x86 代码进行实际的基准测试,推测 80x86 解决方案可能会更高效一些。(即使在编译外部引用时,80x86 版本的 GCC 编译器也会生成与之前示例中相同的主程序代码。)很快你会发现,PowerPC 也会为除外部函数之外的其他情况生成存根函数。因此,CISC 解决方案通常比 RISC 解决方案更高效(大概 RISC 处理器在其他方面弥补了性能差距)。

Microsoft CLR 还提供了通用的调用和返回功能。考虑以下带有静态函数f()的 C# 程序:


			using System;

namespace Calls_f
{
     class program
      {
        static void f()
        {
            return;
        }
        static void Main( string[] args)
        {
            f();
        }
      }
}

以下是 Microsoft C# 编译器为函数f()Main()生成的 CIL 代码:


			.method private hidebysig static void  f() cil managed
{
  // Code size       4 (0x4)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  br.s       IL_0003
  IL_0003:  ret
} // end of method program::f

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       8 (0x8)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  call       void Calls_f.program::f()
  IL_0006:  nop
  IL_0007:  ret
} // end of method program::Main

作为最后一个例子,这是一个类似的 Java 程序:


			public class Calls_f
{
    public static void f()
    {
        return;
    }

    public static void main( String[] args )
    {
        f();
    }
}

以下是 Java 字节码(JBC)输出:


			Compiled from "Calls_f.java"
public class Calls_f extends java.lang.Object{
public Calls_f();
  Code:
   0:   aload_0
        //call Method java/lang/Object."<init>":()
   1:   invokespecial   #1;
   4:   return

public static void f();
  Code:
   0:   return

public static void main(java.lang.String[]);
  Code:
   0:   invokestatic    #2; //Method f:()
   3:   return

}

请注意,Microsoft CLR 和 Java VM 都有多种调用和调用指令变体。这些简单的例子展示了对静态方法的调用。

15.1.2 其他开销来源

当然,一个典型的过程调用和返回涉及的开销不仅仅是执行实际的过程callreturn指令。在调用过程之前,调用代码必须计算并传递任何参数给它。进入过程后,调用代码还可能需要完成激活记录的构建(即为局部变量分配空间)。这些操作的开销因 CPU 和编译器而异。例如,如果调用代码能够通过寄存器而非栈(或其他内存位置)传递参数,通常会更高效。同样,如果过程能够将所有局部变量保存在寄存器中,而不是在栈上的激活记录中,那么访问这些局部变量会更高效。这是 RISC 处理器相较于 CISC 处理器的一个重要优势。典型的 RISC 编译器可以为传递参数和局部变量保留多个寄存器。(RISC 处理器通常具有 16、32 个或更多通用寄存器,因此为此目的保留几个寄存器并不算过分。)对于那些不调用其他过程的过程(将在下一节讨论),无需保存这些寄存器的值,因此参数和局部变量的访问非常高效。即使在寄存器数量有限的 CPU(如 32 位 80x86)上,仍然可以在寄存器中传递少量参数,或者保持少量局部变量。例如,许多 80x86 编译器会将最多三个值(参数或局部变量)保存在寄存器中。显然,RISC 处理器在这方面具有优势。^(1)

拥有这些知识,并结合本书前面关于激活记录和栈帧的背景(参见 第 179 页中的《栈部分》),我们现在可以讨论如何编写高效的过程和函数。具体的规则高度依赖于您的 CPU 和使用的编译器,但一些概念足够通用,可以应用于所有程序。以下各节假设您是在为 80x86 或 ARM CPU 编写程序(因为世界上大多数软件运行在这两种 CPU 上之一)。

15.2 叶函数和过程

编译器通常能够为叶子过程和函数生成更好的代码——也就是那些不调用其他过程或函数的过程和函数。这个隐喻来源于一种叫做调用树的过程/函数调用图形表示。调用树由一组圆圈(节点)组成,这些圆圈表示程序中的函数和过程。从一个节点到另一个节点的箭头表示第一个节点包含对第二个节点的调用。图 15-1 展示了一个典型的调用树。

在这个例子中,主程序直接调用过程prc1()和函数fnc1()以及fnc2()。函数fnc1()直接调用过程prc2()。函数fnc2()直接调用过程prc2()prc3(),以及函数fnc3()。在这个调用树中,叶子过程和函数是prc1()prc2()fnc3()prc3(),它们没有调用任何其他过程或函数。

图片

图 15-1:调用树

处理叶子过程和函数有一个优势:它们不需要将传递给它们的参数保存在寄存器中,也不需要保持它们在寄存器中维护的局部变量的值。例如,如果main()将两个参数通过 EAX 和 EDX 寄存器传递给fnc1(),而fnc1()将一对不同的参数通过 EAX 和 EDX 传递给prc2(),那么fnc1()必须在调用prc2()之前先保存 EAX 和 EDX 中的值。另一方面,prc2()过程则不需要在调用某个过程或函数之前保存 EAX 和 EDX 中的值,因为它不会进行这样的调用。同样,如果fnc1()在寄存器中分配了任何局部变量,那么它在调用prc2()时需要保留这些寄存器的值,因为prc2()可以将这些寄存器用于它自己的目的。相反,如果prc2()使用寄存器来存储局部变量,它就不需要保留该变量的值,因为它从不调用任何子程序。因此,优秀的编译器通常会为叶子过程和函数生成更好的代码,因为它们不需要保存寄存器的值。

将调用树扁平化的一种方式是将内部节点中与过程和函数相关的代码内联到调用树中更高的函数中。例如,在图 15-1 中,如果将fnc1()的代码移到main()中是可行的,那么就不需要保存和恢复寄存器(以及其他操作)。然而,在扁平化调用树时,要确保你没有牺牲可读性和可维护性。你希望避免编写那些仅仅调用其他过程和函数而自己不做任何工作的过程和函数,但你也不希望通过在代码中扩展函数和过程调用来破坏应用程序设计的模块化。

你已经看到,当使用 RISC 处理器(如 PowerPC 或 ARM)时,具有叶子函数是很方便的,因为它们使用“分支和链接”指令来进行子程序调用。PowerPC 和 ARM 的 LINK 寄存器就是必须在过程调用中保留的寄存器的良好示例。因为叶子过程通常不修改 LINK 寄存器的值,所以在叶子过程中不需要额外的代码来保留该寄存器的值。要了解在 RISC CPU 上调用叶子函数的好处,可以参考以下 C 代码:


			void g( void )
{
    return;
}

void f( void )
{
    g();
    g();
    return;
}

int main( void )
{
    f();
    return( 0 );
}

GCC 生成以下 PowerPC 汇编代码:


			; g's function code:

_g:
        ; Set up g's environment
        ; (set up activation record):

        stmw r30,-8(r1)
        stwu r1,-48(r1)
        mr r30,r1

        ; Tear down the activation
        ; record.

        lwz r1,0(r1)
        lmw r30,-8(r1)

        ; Return to caller via LINK:

        blr

; f's function code:

_f:
        ; Set up activation record,
        ; including saving the value
        ; of the LINK register:

        mflr r0         ; R0 = LINK
        stmw r30,-8(r1)
        stw r0,8(r1)    ; Save LINK
        stwu r1,-80(r1)
        mr r30,r1

        ; Call g (twice):

        bl _g
        bl _g

        ; Restore LINK from the
        ; activation record and
        ; then clean up activation
        ; record:

        lwz r1,0(r1)
        lwz r0,8(r1)    ; R0 = saved adrs
        mtlr r0         ; LINK = RO
        lmw r30,-8(r1)

        ; Return to main function:

        blr

; Main function code:

_main:
        ; Save main's return
        ; address into main's
        ; activation record:

        mflr r0
        stmw r30,-8(r1)
        stw r0,8(r1)
        stwu r1,-80(r1)
        mr r30,r1

        ; Call the f function:

        bl _f

        ; Return 0 to whomever
        ; called main:

        li r0,0
        mr r3,r0
        lwz r1,0(r1)
        lwz r0,8(r1)    ; Move saved return
        mtlr r0         ; address to LINK
        lmw r30,-8(r1)
        ; Return to caller:

        blr

在这段 PowerPC 代码中,f()g()函数的实现有一个重要区别——f()必须保留 LINK 寄存器的值,而g()则不需要。这样不仅涉及额外的指令,而且还涉及访问内存,这可能会比较慢。

使用叶子过程的另一个优点是,从调用树上看不出来的,就是构建它们的激活记录需要更少的工作。例如,在 80x86 架构上,一个优秀的编译器不需要保留 EBP 寄存器的值,不需要将 EBP 加载为激活记录的地址,然后通过栈指针寄存器(ESP)访问局部对象来恢复原始值。而在手动管理栈的 RISC 处理器上,这种节省可能是显著的。对于这些过程,过程调用和返回以及激活记录维护的开销大于过程实际执行的工作。因此,消除激活记录维护代码几乎可以使过程的速度加倍。基于这些原因,你应该尽量保持调用树尽可能浅。程序中使用的叶子过程越多,使用合适编译器编译时程序的效率可能越高。

15.3 宏和内联函数

结构化编程革命的一个结果是,计算机程序员被教导编写小型、模块化且逻辑连贯的函数^(2)。一个逻辑连贯的函数能做好一件事。该过程或函数中的所有语句都专注于完成手头的任务,不进行任何副计算或无关操作。多年的软件工程研究表明,将问题分解为小组件,然后实现这些组件,能生成更容易阅读、维护和修改的程序。不幸的是,很容易在这个过程中过度设计,产生像下面这个 Pascal 示例这样的函数:


			function sum( a:integer; b:integer ):integer;
begin

       (* returns sum of a & b as function result *)

        sum := a + b;

end;
      .
      .
      .
sum( aParam, bParam );

在 80x86 上,计算两个值的和并将其存储到内存变量中大约需要三条指令。例如:


			mov( aParam, eax );
add( bParam, eax );
mov( eax, destVariable );

将其与简单调用函数sum()所需的代码进行对比:


			push( aParam );
push( bParam );
call sum;

sum过程内(假设使用一个普通编译器),你可能会看到如下 HLA 代码:


			// Construct the activation record

push( ebp );
mov( esp, ebp );

// Get aParam's value

mov( [ebp+12], eax );

// Compute their sum and return in EAX

add( [ebp+8], eax );

// Restore EBP's value

pop( ebp );

// Return to caller, cleaning up
// the activation record.

ret( 8 );

如你所见,使用一个函数来计算这两个对象的和,比直接的(没有函数调用的)代码需要更多三倍的指令。更糟糕的是,这九条指令通常比构成内联代码的三条指令要慢。内联代码的运行速度可能比带有函数调用的代码快 5 到 10 倍。

关于函数或过程调用的开销,唯一值得肯定的优点是它是固定的。不管过程或函数体包含 1 条还是 1,000 条机器指令,设置参数和激活记录所需的指令数是相同的。尽管当过程体较小时,过程调用的开销很大,但当过程体较大时,这种开销几乎可以忽略不计。因此,为了减少程序中过程/函数调用的开销影响,尽量将较大的过程和函数放置为内联代码,并编写较短的序列。

在模块化结构的好处与过于频繁的过程调用成本之间找到最佳平衡可能很困难。不幸的是,良好的程序设计通常会阻止我们将过程和函数的大小增加到足以使调用和返回的开销变得微不足道的程度。确实,我们可以将多个函数和过程调用合并为一个过程或函数,但这会违反几个编程风格的规则,而且优秀的代码通常避免使用这种策略。(这样产生的程序的一个问题是,很少有人能够弄清楚它们是如何工作的,从而优化它们。)然而,如果通过增加过程的大小不能足够降低过程体的开销,你仍然可以通过其他方式减少开销,从而提高整体性能。正如你所看到的,一个选项是使用叶子过程和函数。优秀的编译器对调用树中的叶节点生成更少的指令,从而减少调用/返回的开销。然而,如果过程体很短,你需要一种方法来完全消除过程调用/返回的开销。有些语言通过实现这一点。

宏会将过程或函数的主体直接扩展为调用位置。由于没有程序中其他地方的调用/返回,宏扩展避免了与这些指令相关的开销。此外,宏还通过使用文本替换参数而不是将参数数据推入栈或将其移动到寄存器中,节省了相当大的开销。宏的缺点是编译器会为每次调用宏时扩展宏的主体。如果宏的主体很大,并且你在多个地方调用它,那么可执行程序的体积可能会增加相当多。宏代表了经典的时间/空间权衡:通过增加大小来换取更快的代码。因此,除非在某些对速度要求极高的罕见情况下,否则应只使用宏来替代语句较少的过程和函数(比如说,一到五个语句)。

一些语言(如 C/C++)提供了 内联 函数和过程,它们是函数(或过程)和纯宏之间的结合体。大多数支持内联函数和过程的语言并不保证编译器会进行内联扩展。内联扩展,或调用内存中的实际函数,是由编译器决定的。如果函数体太大,或者有过多的参数,大多数编译器不会将内联函数扩展出来。此外,与纯宏不同,纯宏没有任何关联的过程调用开销,而内联函数可能仍然需要构建一个激活记录,以处理局部变量、临时变量以及其他需求。因此,即使编译器确实进行了内联扩展,可能仍然会有一些额外开销,这些是使用纯宏时不会遇到的。

要查看函数内联的结果,请考虑以下由 Microsoft Visual C++ 准备进行编译的 C 源文件:


			#include <stdio.h>

// Make geti and getj external functions
// to thwart constant propagation so we
// can see the effects of the following
// code.

extern int geti( void );
extern int getj( void );

// Inline function demonstration. Note
// that "_inline" is the legacy MSVC++ "C" way
// of specifying an inline function (the
// actual "inline" keyword was a C++/C99 feature,
// which this code avoids in order to make
// the assembly output a little more readable).
//
//
// "inlineFunc" is a simple inline function
// that demonstrates how the C/C++ compiler
// does a simple inline macro expansion of
// the function:

_inline int inlineFunc( int a, int b )
{
    return a + b;
}

_inline int ilf2( int a, int b )
{
    // Declare some variable that will require
    // an activation record to be built (that is,
    // register allocation won't be sufficient):

    int m;
    int c[4];
    int d;

    // Make sure we use the "c" array so that
    // the optimizer doesn't ignore its
    // declaration:

    for( m = 0; m < 4; ++m )
    {
        c[m] = geti();
    }
    d = getj();
    for( m = 0; m < 4; ++m )
    {
        d += c[m];
    }
    // Return a result to the calling program:

    return (a + d) - b;
}

int main( int argc, char **argv )
{
    int i;
    int j;
    int sum;
    int result;

    i = geti();
    j = getj();
    sum = inlineFunc( i, j );
    result = ilf2( i, j );
    printf( "i+j=%d, result=%d\n", sum, result );
    return 0;
}

这是 MSVC 在指定 C 编译时(与 C++ 编译时不同,后者会产生更为混乱的输出)生成的与 MASM 兼容的汇编语言代码:


			_main      PROC NEAR
main    PROC
;
; Create the activation record:
;
$LN6:
        mov     QWORD PTR [rsp+8], rbx
        push    rdi
        sub     rsp, 32        ; 00000020H
; Line 66
;
; i = geti();
;
        call    ?geti@@YAHXZ   ; geti -- returns result in EAX
        mov     edi, eax       ; Save i in edi

; Line 67
;
; j = getj();
;
        call    ?getj@@YAHXZ   ; getj -- Returns result in EAX
; Line 69
;
; Inline expansion of inlineFunc()
;
        mov     edx, eax       ; Pass j in EDX
        mov     ecx, edi       ; Pass i in ECX
        mov     ebx, eax       ; Use EBX as "sum" local
        call    ?ilf2@@YAHHH@Z ; ilf2

;       Computes sum = i+j (inline)

        lea     edx, DWORD PTR [rbx+rdi]

; Line 70
;
; Call to printf function:

        mov     r8d, eax
        lea     rcx, OFFSET FLAT:??_C@_0BD@INCDFJPK@i?$CLj?$DN?$CFd?0?5result?$DN?$CFd?6?$AA@
        call    printf
; Line 72
;
; Return from main function
;
        mov     rbx, QWORD PTR [rsp+48]
        xor     eax, eax
        add     rsp, 32        ; 00000020H
        pop     rdi
        ret     0
main    ENDP

?ilf2@@YAHHH@Z PROC            ; ilf2, COMDAT
; File v:\t.cpp
; Line 30
$LN24:
        mov     QWORD PTR [rsp+8], rbx
        mov     QWORD PTR [rsp+16], rsi
        push    rdi
        sub     rsp, 64        ; 00000040H
;
; Extra code to help prevent hacks from messing with
; stack data (clears array data to prevent observing old
; memory data).

        mov     rax, QWORD PTR __security_cookie
        xor     rax, rsp
        mov     QWORD PTR __$ArrayPad$[rsp], rax
        mov     edi, edx
        mov     esi, ecx
; Line 43
; Loop to fill "v" array:
;
        xor     ebx, ebx
$LL4@ilf2:
; Line 45
        call    ?geti@@YAHXZ   ; geti
        mov     DWORD PTR c$[rsp+rbx*4], eax
        inc     rbx
        cmp     rbx, 4
        jl      SHORT $LL4@ilf2

; Line 47
;
; d = getj();
;
        call    ?getj@@YAHXZ   ; getj
; Line 50
;
; Second for loop is unrolled and expanded inline:
;
; d += c[m];

        mov     r8d, DWORD PTR c$[rsp+8]
        add     r8d, DWORD PTR c$[rsp+12]
        add     r8d, DWORD PTR c$[rsp]
        add     r8d, DWORD PTR c$[rsp+4]
;
; return (a+d) - b
;
        add     eax, r8d
; Line 55
        sub     eax, edi
        add     eax, esi
; Line 56
;
; Verify code did not mess with stack before leaving
; (array overflow):
;
        mov     rcx, QWORD PTR __$ArrayPad$[rsp]
        xor     rcx, rsp
        call    __security_check_cookie
        mov     rbx, QWORD PTR [rsp+80]
        mov     rsi, QWORD PTR [rsp+88]
        add     rsp, 64        ; 00000040H
        pop     rdi
        ret     0
?ilf2@@YAHHH@Z ENDP            ; ilf2

?inlineFunc@@YAHHH@Z PROC      ; inlineFunc, COMDAT
; File v:\t.cpp
; Line 26
        lea     eax, DWORD PTR [rcx+rdx]
; Line 27
        ret     0
?inlineFunc@@YAHHH@Z ENDP      ; inlineFunc

正如您在这个汇编输出中看到的那样,inlineFunc() 函数没有被调用。相反,编译器在 main() 函数中扩展了这个函数,扩展的位置正是主程序调用它的地方。虽然 ilf2() 函数也被声明为内联,编译器拒绝将其内联扩展,并将其视为普通函数(可能是因为它的大小)。

15.4 将参数传递给函数或过程

参数的数量和类型也会对编译器为你的过程和函数生成的代码效率产生重大影响。简而言之,传递的参数数据越多,过程或函数调用的开销就越大。通常,程序员会调用(或设计)需要传递多个可选参数的通用函数,而这些参数的值函数可能并不会使用。这种方案可以让函数在不同的应用程序中更具通用性,但正如你将在本节中看到的那样,这种通用性是有代价的,因此,如果空间或速度是问题,你可能需要考虑使用针对特定应用程序的函数版本。

参数传递机制(例如,传值或传引用)也会影响过程调用和返回时的开销。一些语言允许你通过值传递大数据对象。(例如,Pascal 允许传递字符串、数组和记录作为值,C/C++ 允许传递结构体作为值;其他语言的设计有所不同。)每当你通过值传递大数据对象时,编译器必须生成机器代码来将该数据复制到过程的激活记录中。这可能是一个耗时的过程(特别是当复制大型数组或结构时)。此外,大对象可能无法适应 CPU 的寄存器集,因此在过程或函数内访问这些数据会变得很昂贵。通常,通过引用传递像数组和结构体这样的大型数据对象比通过值传递更高效。间接访问数据的额外开销通常通过避免将数据复制到激活记录中得以节省,通常是几倍地节省。考虑以下 C 代码,其中将一个大型结构体通过值传递给 C 函数:


			#include <stdio.h>

typedef struct
{
    int array[256];
} a_t;

void f( a_t a )
{
    a.array[0] = 0;
    return;
}

int main( void )
{
    a_t b;

    f( b );
    return( 0 );
}

这是 GCC 发出的 PowerPC 代码:


			_f:
        li r0,0 ; To set a.array[0] = 0

        ; Note: the PowerPC ABI passes the
        ; first eight dwords of data in
        ; R3..R10\. We need to put that
        ; data back into the memory array
        ; here:

        stw r4,28(r1)
        stw r5,32(r1)
        stw r6,36(r1)
        stw r7,40(r1)
        stw r8,44(r1)
        stw r9,48(r1)
        stw r10,52(r1)

        ; Okay, store 0 into a.array[0]:

        stw r0,24(r1)

        ; Return to caller:

        blr

; main function:

_main:

        ; Set up main's activation record:

        mflr r0
        li r5,992
        stw r0,8(r1)

        ; Allocate storage for a:

        stwu r1,-2096(r1)

        ; Copy all but the first
        ; eight dwords to the
        ; activation record for f:

        addi r3,r1,56
        addi r4,r1,1088
        bl L_memcpy$stub

        ; Load the first eight dwords
        ; into registers (as per the
        ; PowerPC ABI):

        lwz r9,1080(r1)
        lwz r3,1056(r1)
        lwz r10,1084(r1)
        lwz r4,1060(r1)
        lwz r5,1064(r1)
        lwz r6,1068(r1)
        lwz r7,1072(r1)
        lwz r8,1076(r1)

        ; Call the f function:

        bl _f

        ; Clean up the activation record
        ; and return 0 to main's caller:

        lwz r0,2104(r1)
        li r3,0
        addi r1,r1,2096
        mtlr r0
        blr

; Stub function that copies the structure
; data to the activation record for the
; main function (this calls the C standard
; library memcpy function to do the actual copy):

        .data
        .picsymbol_stub
L_memcpy$stub:
        .indirect_symbol _memcpy
        mflr r0
        bcl 20,31,L0$_memcpy
L0$_memcpy:
        mflr r11
        addis r11,r11,ha16(L_memcpy$lazy_ptr-L0$_memcpy)
        mtlr r0
        lwz r12,lo16(L_memcpy$lazy_ptr-L0$_memcpy)(r11)
        mtctr r12
        addi r11,r11,lo16(L_memcpy$lazy_ptr-L0$_memcpy)
        bctr
.data
.lazy_symbol_pointer
L_memcpy$lazy_ptr:
        .indirect_symbol _memcpy
        .long dyld_stub_binding_helper

如你所见,对函数 f() 的调用通过 memcpy 将数据从 main() 函数的局部数组复制到 f() 函数的激活记录中。再次强调,复制内存是一个慢过程,这段代码充分展示了你应该避免通过值传递大型对象。考虑在通过引用传递结构体时的相同代码:


			#include <stdio.h>

typedef struct
{
    int array[256];
} a_t;

void f( a_t *a )
{
    a->array[0] = 0;
    return;
}

int main( void )
{
    a_t b;
    f( &b );
    return( 0 );
}

这是 GCC 将该 C 源代码转换为 32 位 ARM 汇编的代码:


			f:
    @ Build activation record:

    str fp, [sp, #-4]!  @ Push old FP on stack
    add fp, sp, #0      @ FP = SP
    sub sp, sp, #12     @ Reserve storage for locals
    str r0, [fp, #-8]   @ Save pointer to 'a'
    ldr r3, [fp, #-8]   @ r3 = a

    @ a->array[0] = 0;

    mov r2, #0
    str r2, [r3]
    nop

    @ Remove locals from stack.

    add sp, fp, #0

    @ Pop FP from stack:

    ldr fp, [sp], #4

    @ Return to main function:

    bx  lr

main:
    @ Save Linux return address and FP:

    push    {fp, lr}
 @ Set up activation record:

    add fp, sp, #4
    sub sp, sp, #1024   @ Reserve storage for b
    sub r3, fp, #1024   @ R3 = &b
    sub r3, r3, #4

    mov r0, r3          @ Pass &b to f in R0
    bl  f               @ Call f

    @ Return 0 result to Linux:

    mov r3, #0
    mov r0, r3
    sub sp, fp, #4      @ Clean up stack frame
    pop {fp, pc}        @ Returns to Linux

根据你的 CPU 和编译器,将小(标量)数据对象通过值传递可能比通过引用传递更高效。例如,如果你使用的是 80x86 编译器并且参数通过栈传递,那么传递一个内存对象时通过引用需要两条指令,而通过值传递只需要一条指令。因此,尽管尝试通过引用传递大对象是一个好主意,但对于小对象而言,通常相反。不过,这并不是一条死板的规则;它的有效性取决于你使用的 CPU 和编译器。

一些程序员可能会认为通过全局变量将数据传递给过程或函数更加高效。毕竟,如果数据已经存储在一个全局变量中,且该全局变量对过程或函数是可访问的,那么调用该过程或函数时就不需要额外的指令来传递数据给子程序,从而减少了调用开销。尽管这看起来是一个巨大的优势,但要记住,编译器在优化大量使用全局变量的程序时会遇到困难。虽然使用全局变量可能减少函数/过程调用的开销,但也可能会妨碍编译器处理其他本可以实现的优化。下面是一个简单的 Microsoft Visual C++示例,展示了这个问题:


			#include <stdio.h>

// Make geti an external function
// to thwart constant propagation so we
// can see the effects of the following
// code.

extern int geti( void );

// globalValue is a global variable that
// we use to pass data to the "usesGlobal"
// function:

int globalValue = 0;
 // Inline function demonstration. Note
// that "_inline" is the legacy MSVC++ "C" way
// of specifying an inline function (the
// actual "inline" keyword is a C99/C++ feature,
// which this code avoids in order to make
// the assembly output a little more readable).

_inline int usesGlobal( int plusThis )
{
    return globalValue+plusThis;
}

_inline int usesParm( int plusThis, int globalValue )
{
    return globalValue+plusThis;
}

int main( int argc, char **argv )
{
    int i;
    int sumLocal;
    int sumGlobal;

    // Note: the call to geti in between setting globalValue
    // and calling usesGlobal is intentional. The compiler
    // doesn't know that geti doesn't modify the value of
    // globalValue (and neither do we, frankly), and so
    // the compiler cannot use constant propagation here.

    globalValue = 1;
    i = geti();
    sumGlobal = usesGlobal( 5 );

    // If we pass the "globalValue" as a parameter rather
    // than setting a global variable, then the compiler
    // can optimize the code away:

    sumLocal = usesParm( 5, 1 );
    printf( "sumGlobal=%d, sumLocal=%d\n", sumGlobal, sumLocal );
    return 0;
}

下面是 32 位 MASM 源代码(带手动注释),这是 MSVC++编译器为此代码生成的:


			_main      PROC NEAR
;   globalValue = 1;

    mov    DWORD PTR _globalValue, 1

;   i = geti();
;
; Note that because of dead code elimination,
; MSVC++ doesn't actually store the result
; away into i, but it must still call geti()
; because geti() could produce side effects
; (such as modifying globalValue's value).

    call   _geti

;   sumGlobal = usesGlobal( 5 );
;
; Expanded inline to:
;
; globalValue+plusThis

    mov    eax, DWORD PTR _globalValue
    add    eax, 5          ; plusThis = 5

; The compiler uses constant propagation
; to compute:
;   sumLocal = usesParm( 5, 1 );
; at compile time. The result is 6, which
; the compiler directly passes to print here:

    push   6

; Here's the result for the usesGlobal expansion,
; computed above:

    push   eax
    push   OFFSET FLAT:formatString ; 'string'
    call   _printf
    add    esp, 12                  ; Remove printf parameters

; return 0;

    xor    eax, eax
    ret    0
_main      ENDP
_TEXT      ENDS
           END

如你所见,编译器在优化全局变量时的能力很容易被一些看似无关的代码所破坏。在这个例子中,编译器无法确定对外部geti()函数的调用不会修改globalValue变量的值。因此,编译器不能假设在计算usesGlobal()的内联函数结果时,globalValue仍然保持值1。在使用全局变量在过程或函数与调用者之间传递信息时,要格外小心。与当前任务无关的代码(如对geti()的调用,可能并不影响globalValue的值)会阻止编译器优化使用全局变量的代码。

15.5 激活记录与堆栈

由于堆栈的工作方式,软件创建的最后一个过程激活记录将是系统首先回收的激活记录。由于激活记录保存过程参数和局部变量,因此后进先出(LIFO)的组织方式是实现激活记录的非常直观的方法。为了理解它是如何工作的,考虑下面这个(简单的)Pascal 程序:


			program ActivationRecordDemo;

    procedure C;
    begin

        (* Stack Snapshot here *)

    end;

    procedure B;
    begin

        C;

    end;

    procedure A;
    begin

        B;

    end;

begin (* Main program *)

    A;

end.

图 15-2 显示了程序执行时堆栈的布局。

当程序开始执行时,首先为主程序创建一个激活记录。主程序调用A过程(①)。进入A过程时,代码完成A的激活记录的构建,并有效地将其压入堆栈中。一旦进入A过程,代码调用B过程(②)。注意,A在代码调用B时仍然处于活动状态,因此A的激活记录仍然保留在堆栈上。进入B后,系统为B构建激活记录,并将其压入堆栈的顶部(③)。一旦进入B过程,代码调用C过程,C在堆栈上构建其激活记录,并到达注释(* Stack snapshot here *)(④)。

Image

图 15-2:三层嵌套过程调用后的堆栈布局

由于程序会将它们的局部变量和参数值保存在激活记录中,因此这些变量的生命周期从系统首次创建激活记录时开始,直到程序返回调用者时系统释放激活记录为止。在图 15-2 中,注意到A的激活记录在执行BC过程时仍然保留在栈上。因此,A的参数和局部变量的生命周期完全覆盖了BC激活记录的生命周期。

现在考虑以下带有递归函数的 C/C++代码:


			void recursive( int cnt )
{
    if( cnt != 0 )
    {
        recursive( cnt - 1 );
    }
}

int main( int argc, char **argv )
{
    recursive( 2 );
}

这个程序在开始返回之前调用了recursive()函数三次(主程序调用recursive()一次,参数值为2,而recursive()自身调用了两次,参数值分别为10)。由于每次递归调用recursive()时都会在当前调用返回之前再压入一个激活记录,因此,当程序最终在代码中的if语句处,cnt等于0时,栈的情况大致如下图 15-3 所示。

图片

图 15-3:三次递归过程调用后的栈布局

由于每个过程调用都有一个单独的激活记录,每次激活该过程时都会有其自身的参数和局部变量副本。程序或函数代码执行时,仅会访问它最新创建的激活记录中的局部变量和参数^(3),从而保留了先前调用的值。

15.5.1 激活记录的分解

现在你已经了解了程序如何在栈上操作激活记录,接下来我们来看看典型的激活记录的内部组成。在这一节中,我们将使用一个典型的激活记录布局,这是你在执行 80x86 代码时会看到的布局。虽然不同的编程语言、不同的编译器和不同的 CPU 对激活记录的布局有所不同,但这些差异(如果有的话)通常是微小的。

80x86 使用两个寄存器来管理栈和激活记录:ESP/RSP(栈指针)和 EBP/RBP(帧指针,Intel 称其为基址指针)。ESP/RSP 寄存器指向当前栈顶,EBP 寄存器指向激活记录的基地址^(4)。过程可以通过使用索引寻址模式(见第 34 页的“索引寻址模式”)并提供从 EBP/RBP 寄存器值偏移的正负偏移量来访问激活记录中的对象。通常,过程为局部变量分配的内存存储会位于 EBP/RBP 值的负偏移量处,而为参数分配的内存存储则位于 EBP/RBP 的正偏移量处。考虑以下的 Pascal 过程,它同时包含了参数和局部变量:


			procedure HasBoth( i:integer; j:integer; k:integer );
var
    a  :integer;
    r  :integer;
    c  :char;
    b  :char;
    w  :smallint;  (* smallints are 16 bits *)
begin
        .
        .
        .
end;

图 15-4 显示了该 Pascal 过程的典型激活记录(请记住,在 32 位 80x86 上,栈是向低地址生长的)。

Image

图 15-4:典型的激活记录

当你看到与内存对象相关的术语 base 时,你可能会认为基地址是该对象在内存中最低的地址。然而,实际上并没有这样的要求。基地址只是内存中你用来基准偏移量以访问该对象特定字段的地址。如这个激活记录所示,80x86 激活记录的基地址实际上位于记录的中间。

激活记录的构建分为两个阶段。第一阶段始于调用过程的代码将调用的参数压入栈中。例如,考虑下面在之前示例中对 HasBoth() 的调用:

HasBoth( 5, x, y + 2 );

下面是可能对应于此调用的 HLA/x86 汇编代码:


			pushd( 5 );
push( x );
mov( y, eax );
add( 2, eax );
push( eax );
call HasBoth;

这段代码中的三个 push 指令构建了激活记录的前三个双字,而 call 指令将 返回地址 压入栈中,创建了激活记录的第四个双字。调用后,执行继续进入 HasBoth() 过程本身,程序在该过程中继续构建激活记录。

HasBoth() 过程的前几条指令负责完成激活记录的构建。进入 HasBoth() 后,栈呈现出 图 15-5 中所示的形式。^(5)

Image

图 15-5:进入 HasBoth() 时的激活记录

该过程代码的第一件事应该是保留 80x86 EBP 寄存器中的值。进入时,EBP 可能指向调用者激活记录的基址。退出 HasBoth() 时,EBP 需要包含其原始值。因此,在进入时,HasBoth() 需要将当前的 EBP 值压入栈中,以保留 EBP 的值。接下来,HasBoth() 过程需要更改 EBP,使其指向 HasBoth() 激活记录的基址。以下 HLA/x86 代码处理了这两个操作:


			// Preserve caller's base address.

        push( ebp );

        // ESP points at the value we just saved. Use its address
        // as the activation record's base address.

        mov( esp, ebp );

最后,HasBoth() 开头的代码需要为其局部(自动)变量分配存储空间。如 图 15-4 所示,这些变量位于激活记录中的帧指针下方。为了防止未来的压栈操作覆盖这些局部变量的值,代码必须将 ESP 设置为激活记录中最后一个局部变量双字的地址。为了实现这一点,它通过以下机器指令将局部变量的字节数从 ESP 中减去:

sub( 12, esp );

对于像HasBoth()这样的过程,标准入口序列包括刚才提到的三条机器指令——push(ebp);mov(esp, ebp);sub(12, esp);——它们完成了过程内激活记录的构建。在返回之前,Pascal 过程负责释放与激活记录相关的存储空间。标准退出序列通常在 Pascal 过程中以以下形式出现(在 HLA 中):


			// Deallocates the local variables
// by copying EBP to ESP.

mov( ebp, esp );

// Restore original EBP value.

pop( ebp );

// Pops return address and
//  12 parameter bytes (3 dwords)

ret( 12 );

第一条指令为图 15-4 中显示的局部变量释放存储空间。请注意,EBP 指向 EBP 的旧值;该值存储在所有局部变量上方的内存地址中。通过将 EBP 中的值复制到 ESP,我们将堆栈指针移动过所有局部变量,从而有效地释放它们。现在,堆栈指针指向堆栈上 EBP 的旧值;因此,该序列中的pop指令恢复 EBP 的原始值,并将 ESP 指向堆栈上的返回地址。标准退出序列中的ret指令执行两项操作:它从堆栈中弹出返回地址(并将控制权转移到该地址),并从堆栈中移除 12 个字节的参数。因为HasBoth()有三个双字参数,从堆栈中弹出 12 个字节会移除这些参数。

15.5.2 分配局部变量的偏移量

这个HasBoth()示例按照编译器遇到局部变量的顺序分配局部(自动)变量。典型的编译器维护一个当前偏移量(初始值为 0),该偏移量用于局部变量的激活记录。每当编译器遇到一个局部变量时,它会从当前偏移量中减去该变量的大小,然后使用结果作为局部变量在激活记录中的偏移量(相对于 EBP/RBP)。例如,遇到变量a的声明时,编译器将a的大小(假设a是 32 位整数,占用 4 字节)从当前偏移量(0)中减去,并将结果(–4)作为a的偏移量。接下来,编译器遇到变量r(同样占用 4 字节),将当前偏移量设置为–8,并将此偏移量分配给r。这个过程对程序中的每个局部变量都会重复进行。

尽管这是编译器分配局部变量偏移量的典型方式,但大多数语言允许编译器实现者根据需要自由分配局部对象。如果这样做更为方便,编译器可以重新排列激活记录中的对象。这意味着你应该避免设计依赖于上述分配方案的算法,因为某些编译器的做法可能不同。

许多编译器尝试确保你声明的所有局部变量的偏移量都是对象大小的倍数。例如,假设你在 C 函数中有以下两个声明:


			char c;
int  i;

通常,你会期望编译器为c变量分配偏移量–1,为(4 字节)int变量i分配偏移量–5。然而,一些 CPU(例如 RISC CPU)要求编译器将双字对象分配在双字边界上。即使是在不要求这样做的 CPU 上(例如 80x86),如果编译器将双字变量对齐到双字边界上,访问该变量可能会更快。正因为如此(如前面章节所述),许多编译器会自动在局部变量之间添加填充字节,使得每个变量位于激活记录中的自然偏移量。一般来说,字节可以出现在任何偏移量上,字最好位于偶数地址边界上,而双字则应该位于地址是 4 的倍数的内存地址上。

尽管一个优化编译器可能会自动为你处理这种对齐,但这样做是有代价的——那些额外的填充字节。如前所述,编译器通常可以自由地重新排列激活记录中的变量,但它们并不总是这样做。因此,如果你在局部变量声明中交织了多个字节、字、双字和其他大小的对象,编译器最终可能会在激活记录中插入几字节的填充字节。你可以通过尽可能合理地将相同大小的对象分组在你的过程和函数中,从而最小化这个问题。考虑以下在 32 位机器上的 C/C++代码:


			char c0;
int  i0;
char c1;
int  i1;
char c2;
int  i2;
char c3;
int  i3;

一个优化编译器可能会选择在每个字符变量和紧随其后的(4 字节)整数变量之间插入 3 字节的填充字节。这意味着前述代码将浪费大约 12 字节的空间(每个字符变量占 3 字节)。现在考虑以下相同 C 代码中的声明:


			char c0;
char c1;
char c2;
char c3;
int  i0;
int  i1;
int  i2;
int  i3;

在这个例子中,编译器不会为代码插入任何额外的填充字节。为什么?因为字符(每个字符为 1 字节)可以在内存中的任何地址开始。^(6) 因此,编译器可以将这些字符变量放置在激活记录中的偏移量–1、–2、–3 和–4 处。由于最后一个字符变量出现在一个是 4 的倍数的地址上,编译器不需要在c3i0之间插入任何填充字节(i0会自然地出现在前述声明中的偏移量–8 处)。

正如你所看到的,将你的声明安排得使得所有相同大小的对象彼此相邻,可以帮助编译器生成更好的代码。然而,不要把这个建议极端化。如果重新排列会使你的程序更难以阅读或维护,那么你应该仔细考虑在程序中是否值得这样做。

15.5.3 将偏移量与参数关联

如前所述,编译器在为过程中的局部(自动)变量分配偏移量时,拥有相当大的自由度。只要编译器一致地使用这些偏移量,它所采用的具体分配算法几乎是无关紧要的;实际上,它可以在同一程序的不同过程使用不同的分配方案。然而,编译器不能在为参数分配偏移量时完全自由。因为过程外部的其他代码也会访问这些参数。具体来说,过程和调用代码必须就激活记录中参数的布局达成一致,以便调用代码可以构建参数列表。请注意,调用代码可能不在同一个源文件中,甚至可能不在同一种编程语言中。为了确保过程和调用该过程的任何代码之间的互操作性,编译器必须遵循某些调用约定。本节将探讨 Pascal/Delphi 和 C/C++ 的三种常见调用约定。

15.5.3.1 Pascal 调用约定

在 Pascal(包括 Delphi)中,标准的参数传递约定是将参数按照在参数列表中的出现顺序推送到堆栈中。考虑以下对早期示例中 HasBoth() 过程的调用:

HasBoth( 5, x, y + 2 );

以下汇编代码实现了这一调用:


			// Push the value for parameter i:

pushd( 5 );

// Push x's value for parameter j:

push( x );

// Compute y + 2 in EAX and push this as the value
// for parameter k:

mov( y, eax );
add( 2, eax );
push( eax );

// Call the HasBoth procedure with these
// three parameter values:

call HasBoth;

在为过程的正式参数分配偏移量时,编译器将第一个参数分配给最高的偏移量,而将最后一个参数分配给最低的偏移量。由于 EBP 的旧值在激活记录中的偏移量为 0,返回地址的偏移量为 4,因此在激活记录中(当使用 Pascal 调用约定并且在 80x86 CPU 上时)最后一个参数将位于 EBP 偏移量 +8 处。回顾 图 15-4,你可以看到参数 k 位于偏移量 +8,参数 j 位于偏移量 +12,而参数 i(第一个参数)位于偏移量 +16。

Pascal 调用约定还规定,当过程返回到其调用者时,程序必须负责清除调用者推送的参数。如前所述,80x86 CPU 提供了一种 ret 指令的变种,允许你指定在返回时从堆栈中移除多少字节的参数。因此,使用 Pascal 调用约定的过程通常会在返回到其调用者时,将参数字节数作为操作数传递给 ret 指令。

15.5.3.2 C 调用约定

C/C++/Java 语言采用了另一种非常流行的调用约定,通常称为cdecl 调用约定(或简称C 调用约定)。C 调用约定与 Pascal 调用约定有两个主要区别。首先,C 语言中的函数调用必须按反向顺序将参数压入栈中。也就是说,第一个参数必须位于栈上的最低地址(假设栈向下增长),最后一个参数必须位于内存中的最高地址。第二个区别是 C 语言要求调用者而非函数来从栈中移除所有参数。

请考虑以下使用 C 语言编写的HasBoth()版本,而不是 Pascal:


			void HasBoth( int i, int j, int k )
{
    int a;
    int r;
    char c;
    char b;
    short w;  /* assumption: short ints are 16 bits */
        .
        .
        .
}

图 15-6 展示了典型的HasBoth激活记录的布局(在 32 位 80x86 处理器上使用 C 语言编写)。

Image

图 15-6:C 语言中的 HasBoth()激活记录

仔细观察,你会看到这个图与图 15-4 之间的区别。ik变量的位置在这个激活记录中被颠倒了(巧合的是,j在两个图中的偏移量相同)。

由于 C 调用约定会颠倒参数顺序,并且由调用者负责从栈中移除所有参数值,因此HasBoth()在 C 中的调用顺序与 Pascal 有所不同。请考虑以下对HasBoth()的调用:

HasBoth( 5, x, y + 2 );

下面是这次调用的 HLA 汇编代码:


			// Compute y + 2 in EAX and push this
// as the value for parameter k

mov( y, eax );
add( 2, eax );
push( eax );

// Push x's value for parameter j

push( x );

// Push the value for parameter i

pushd( 5 );

// Call the HasBoth procedure with
// these three parameter values

call HasBoth;

// Remove parameters from the stack.

add( 12, esp );

由于使用了 C 调用约定,这段代码与 Pascal 实现的汇编代码有两个不同之处。首先,这个例子以与 Pascal 代码相反的顺序压入实际参数的值;也就是说,它先计算y+2并将该值压入栈中,然后再压入x,最后压入值5。第二个不同之处是在调用之后立即包含了add(12,esp);指令。这条指令在返回时从栈中移除 12 个字节的参数。HasBoth()的返回只会使用ret指令,而不是ret n指令。

15.5.3.3 在寄存器中传递参数的约定

正如你在这些例子中看到的那样,在两个过程或函数之间通过栈传递参数需要相当多的代码。优秀的汇编语言程序员早就知道,最好通过寄存器传递参数。因此,遵循 Intel ABI(应用二进制接口)规则的多个 80x86 编译器可能尝试将最多三个参数通过 EAX、EDX 和 ECX 寄存器传递。^(7) 大多数 RISC 处理器专门为函数和过程之间传递参数预留了一组寄存器。有关更多信息,请参见第 585 页的“寄存器的拯救”。

大多数 CPU 要求堆栈指针保持在某个合理的边界上(例如,双字边界),即使是那些不绝对要求这一点的 CPU,仍然可能从中受益。此外,许多 CPU(包括 80x86)无法轻松地将某些小尺寸的对象(如字节)推入堆栈。因此,大多数编译器为一个参数保留最少的字节数(通常是 4 字节),无论其实际大小如何。作为一个例子,考虑下面的 HLA 过程片段:


			procedure OneByteParm( b:byte ); @nodisplay;
    // local variable declarations
begin OneByteParm;
    .
    .
    .
end OneByteParm;

该过程的激活记录显示在图 15-7 中。

Image

图 15-7:OneByteParm() 激活记录

如你所见,HLA 编译器为 b 参数保留了 4 字节,尽管 b 只是一个单字节变量。这个额外的填充确保了 ESP 寄存器将保持在双字边界对齐。^(8) 我们可以通过 4 字节的 push 指令轻松地将 b 的值推入调用 OneByteParm() 的堆栈中。^(9)

即使你的程序可以访问与b参数相关的额外填充字节,这样做也从来不是一个好主意。除非你明确地将参数推入堆栈(例如,使用汇编语言代码),否则不能保证填充字节中的数据值。特别地,它们可能不包含 0。你的代码也不应假设填充字节存在,或者编译器将这些变量填充到 4 字节。一些 16 位处理器可能只需要 1 字节的填充。一些 64 位处理器可能需要 7 字节的填充。一些 80x86 上的编译器可能使用 1 字节的填充,而其他编译器使用 3 字节的填充。除非你愿意接受只有一个编译器能够编译的代码(并且代码可能在下一个编译器版本发布时无法工作),否则最好忽略这些填充字节。

15.5.4 访问参数和局部变量

一旦子程序设置了激活记录,访问局部(自动)变量和参数就变得非常容易。机器代码只需要使用索引寻址模式来访问这些对象。再考虑一下图 15-4 中的激活记录。HasBoth()过程中的变量在表 15-1 中列出了其偏移量。

表 15-1: HasBoth() 中局部变量和参数的偏移量(Pascal 版本)

变量 偏移量 寻址模式示例
i +16 mov( [ebp+16], eax );
j +12 mov( [ebp+12], eax );
k +8 mov( [ebp+8], eax );
a –4 mov( [ebp-4], eax );
r –8 mov( [ebp-8], eax );
c –9 mov( [ebp-9], al );
b –10 mov( [ebp-10], al );
w –12 mov( [ebp-12], ax );

编译器将静态局部变量分配在函数的固定内存地址。静态变量不会出现在激活记录中,因此 CPU 使用直接寻址模式访问静态对象。^(10) 如第三章所讨论,在 80x86 汇编语言中,使用直接寻址模式的指令需要将完整的 32 位地址编码为机器指令的一部分。因此,使用直接寻址模式的指令通常至少为 5 个字节长(而且通常更长)。在 80x86 上,如果 EBP 的偏移量在 –128 到 +127 之间,那么编译器可以将形式为 [ebp + constant] 的指令编码为 2 或 3 个字节。这样的指令比编码完整 32 位地址的指令更高效。这个原则适用于其他处理器,即使这些 CPU 提供不同的寻址模式、地址大小等。具体来说,访问偏移量较小的局部变量通常比访问静态变量或偏移量较大的变量更高效。

由于大多数编译器在遇到局部(自动)变量时会为其分配偏移量,局部变量的前 128 个字节将具有最短的偏移量(至少在 80x86 上是这样;对于其他处理器,这个值可能不同)。

考虑以下两组局部变量声明(假设它们出现在某个 C 函数中):


			// Declaration set #1:

char string[256];
int i;
int j;
char c;

这是第二个版本的声明:


			// Declaration set #2

int i;
int j;
char c;
char string[256];

虽然这两个声明部分在语义上是相同的,但编译器为 32 位 80x86 生成的访问这些变量的代码有很大差异。在第一个声明中,变量 string 在激活记录中的偏移量为 –256,i 在 –260,j 在 –264,c 在 –265。由于这些偏移量超出了 –128 到 +127 的范围,编译器将不得不生成编码 4 字节偏移常量的机器指令,而不是 1 字节常量。因此,关联的代码会更大,并且可能运行得更慢。

现在考虑第二个声明。在这个例子中,程序员首先声明标量(非数组)对象。因此,变量的偏移量如下:i 为 –4,j 为 –8,c 为 –9,string 为 –265。这被证明是这些变量的最优配置(ijc 将使用 1 字节偏移量;string 将需要 4 字节偏移量)。

这个例子演示了你在声明局部(自动)变量时应该遵循的另一条规则:在一个过程内,首先声明较小的标量对象,然后声明所有数组、结构体/记录和其他大对象。

正如在《将偏移量与参数关联》一节中所解释的,参见第 570 页,如果你声明了几个不同大小的局部对象并将它们相邻放置,编译器可能需要插入填充字节,以确保较大的对象在合适的内存地址处对齐。虽然在拥有千兆字节(或更多)内存的机器上为几字节的浪费而烦恼看起来很荒谬,但这些填充字节可能恰好足以将某些局部变量的偏移量推得超过–128,从而导致编译器为这些变量发出 4 字节的偏移量,而不是 1 字节的偏移量。这也是你应该尽量将大小相似的局部变量放在一起声明的另一个原因。

在 RISC 处理器(如 PowerPC 或 ARM)上,可能的偏移量范围通常比±128 大得多。这是件好事,因为一旦你超出了 RISC CPU 可以直接在指令中编码的激活记录偏移量范围,参数和局部变量的访问将变得非常昂贵。考虑以下的 C 程序:


			#include <stdio.h>
int main( int argc, char **argv )
{
    int a;
    int b[256];
    int c;
    int d[16*1024*1024];
    int e;
    int f;

    a = argc;
    b[0] = argc + argc;
    b[255] = a + b[0];
    c = argc + b[1];
    d[0] = argc + a;
    d[4095] = argc + b[255];
    e = a + c;
    printf
    (
        "%d %d %d %d %d ",
        a,
        b[0],
        c,
        d[0],
        e
    );
    return( 0 );
}

这是来自 GCC 的 PowerPC 汇编输出:


			.data
        .cstring
        .align 2
        LC0:
        .ascii "%d %d %d %d %d \0"
        .text

; main function:

        .align 2
        .globl _main
_main:
        ; Set up main's activation record:

        mflr r0
        stmw r30,-8(r1)
        stw r0,8(r1)
        lis r0,0xfbff
        ori r0,r0,64384
        stwux r1,r1,r0
        mr r30,r1
        bcl 20,31,L1$pb
L1$pb:
        mflr r31

        ; The following allocates
        ; 16MB of storage on the
        ; stack (R30 is the stack
        ; pointer here).

        addis r9,r30,0x400
        stw r3,1176(r9)

        ; Fetch the value of argc
        ; into the R0 register:

        addis r11,r30,0x400
        lwz r0,1176(r11)
        stw r0,64(r30)      ; a = argc

        ; Fetch the value of argc
        ; into r9

        addis r11,r30,0x400
        lwz r9,1176(r11)

        ; Fetch the value of argc
        ; into R0:

        addis r11,r30,0x400
        lwz r0,1176(r11)

        ; Compute argc + argc and
        ; store it into b[0]:

        add r0,r9,r0
        stw r0,80(r30)

        ; Add a + b[0] and
        ; store into c:

        lwz r9,64(r30)
        lwz r0,80(r30)
        add r0,r9,r0
        stw r0,1100(r30)
        ; Get argc's value, add in
        ; b[1], and store into c:

        addis r11,r30,0x400
        lwz r9,1176(r11)
        lwz r0,84(r30)
        add r0,r9,r0
        stw r0,1104(r30)

        ; Compute argc + a and
        ; store into d[0]:

        addis r11,r30,0x400
        lwz r9,1176(r11)
        lwz r0,64(r30)
        add r0,r9,r0
        stw r0,1120(r30)

        ; Compute argc + b[255] and
        ; store into d[4095]:

        addis r11,r30,0x400
        lwz r9,1176(r11)
        lwz r0,1100(r30)
        add r0,r9,r0
        stw r0,17500(r30)

        ; Compute argc + b[255]:

        lwz r9,64(r30)
        lwz r0,1104(r30)
        add r9,r9,r0

; ************************************************
        ; Okay, here's where it starts
        ; to get ugly. We need to compute
        ; the address of e so we can store
        ; the result currently held in r9
        ; into e. But e's offset exceeds
        ; what we can encode into a single
        ; instruction, so we have to use
        ; the following sequence rather
        ; than a single instruction.

        lis r0,0x400
        ori r0,r0,1120
        stwx r9,r30,r0

; ************************************************
        ; The following sets up the
        ; call to printf and calls printf:

        addis r3,r31,ha16(LC0-L1$pb)
        la r3,lo16(LC0-L1$pb)(r3)
        lwz r4,64(r30)
        lwz r5,80(r30)
        lwz r6,1104(r30)
        lwz r7,1120(r30)
        lis r0,0x400
        ori r0,r0,1120
        lwzx r8,r30,r0
        bl L_printf$stub
        li r0,0
        mr r3,r0
        lwz r1,0(r1)
        lwz r0,8(r1)
        mtlr r0
        lmw r30,-8(r1)
        blr

; Stub, to call the external printf function:

        .data
        .picsymbol_stub
L_printf$stub:
        .indirect_symbol _printf
        mflr r0
        bcl 20,31,L0$_printf
L0$_printf:
        mflr r11
        addis r11,r11,ha16(L_printf$lazy_ptr-L0$_printf)
        mtlr r0
        lwz r12,lo16(L_printf$lazy_ptr-L0$_printf)(r11)
        mtctr r12
        addi r11,r11,lo16(L_printf$lazy_ptr-L0$_printf)
        bctr
.data
.lazy_symbol_pointer
L_printf$lazy_ptr:
        .indirect_symbol _printf
        .long dyld_stub_binding_helper

这个编译是在没有优化的情况下通过 GCC 完成的,目的是展示当激活记录增长到你无法再将激活记录偏移量编码到指令中时会发生什么。

要编码偏移量过大的e的地址,我们需要这三条指令:


			lis r0,0x400
ori r0,r0,1120
stwx r9,r30,r0

而不是一条将R0存储到a变量中的指令,如:

stw r0,64(r30)      ; a = argc

尽管在这个大小的程序中,增加两条额外指令看起来似乎微不足道,但请记住,编译器会为每次这样的访问生成这些额外的指令。如果你频繁访问一个偏移量巨大的局部变量,编译器可能会在你的函数或过程内生成大量的额外指令。

当然,在标准的 RISC 应用程序中,这个问题很少出现,因为我们很少会分配超出单条指令能够编码的激活记录偏移量的局部存储。此外,RISC 编译器通常将标量(非数组/非结构)对象分配到寄存器中,而不是盲目地将它们分配到激活记录中的下一个内存地址。例如,如果你在命令行中使用-O2开关开启 GCC 优化,你会得到以下 PowerPC 输出:


			.globl _main
_main:

; Build main's activation record:

        mflr r0
        stw r31,-4(r1)
        stw r0,8(r1)
        bcl 20,31,L1$pb
L1$pb:
        ; Compute values, set up parameters,
        ; and call printf:

        lis r0,0xfbff
        slwi r9,r3,1
        ori r0,r0,64432
        mflr r31
        stwux r1,r1,r0
        add r11,r3,r9
        mr r4,r3
        mr r0,r3
        lwz r6,68(r1)
        add r0,r0,r11 ;c = argc + b[1]
        stw r0,17468(r1)
        mr r5,r9
        add r6,r3,r6
        stw r9,64(r1)
        addis r3,r31,ha16(LC0-L1$pb)
        stw r11,1084(r1)
        stw r9,1088(r1)
        la r3,lo16(LC0-L1$pb)(r3)
        mr r7,r9
        add r8,r4,r6
        bl L_printf$stub

; Clean up main's activation
; record and return 0:

        lwz r1,0(r1)
        li r3,0
        lwz r0,8(r1)
        lwz r31,-4(r1)
        mtlr r0
        blr

在这个启用了优化的版本中,你会注意到一个现象,那就是 GCC 没有按照遇到变量的顺序分配变量到激活记录中。而是将大部分对象放入了寄存器中(即使是数组元素)。请记住,一个优化的编译器很可能会重新排列你声明的所有局部变量。

ARM 处理器有类似的限制,这些限制基于指令操作码的大小(32 位)。这是来自 GCC 的(未优化的)ARM 输出:


			.LC0:
    .ascii  "%d %d %d %d %d \000"

main:

    @ Set up activation record

    push    {fp, lr}
    add fp, sp, #4

    @ Reserve storage for locals.
    @ (2 instructions due to instruction
    @ size limitations).

    add sp, sp, #-67108864
    sub sp, sp, #1056

    @ Store argc (passed in R0)
    @ into a. Three additions
    @ (-67108864, 4, and -1044)
    @ are needed because of ARM
    @ 32-bit instruction encoding
    @ limitations

    add r3, fp, #-67108864
    sub r3, r3, #4
    str r0, [r3, #-1044]

    @ a = argc

    add r3, fp, #-67108864
    sub r3, r3, #4
    ldr r3, [r3, #-1044]    @ r3 = argc
    str r3, [fp, #-8]       @ a = argc

    @ b[0] = argc + argc

    add r3, fp, #-67108864
    sub r3, r3, #4
    ldr r2, [r3, #-1044]    @ R2 = argc
    ldr r3, [r3, #-1044]    @ R3 = argc
    add r3, r2, r3          @ R3 = argc + argc
    str r3, [fp, #-1040]    @ b[0] = argc+argc

    ldr r2, [fp, #-1040]    @ R2 = b[0]
    ldr r3, [fp, #-8]       @ R3 = a
    add r3, r2, r3          @ a + b[0]
    str r3, [fp, #-20]      @ b[255] = a  +b[0]

    ldr r2, [fp, #-1036]    @ R2 = b[1]
    add r3, fp, #-67108864
    sub r3, r3, #4
    ldr r3, [r3, #-1044]    @ R3 = argc
    add r3, r2, r3          @ argc + b[1]
    str r3, [fp, #-12]      @ c = argc + b[1]

    add r3, fp, #-67108864
    sub r3, r3, #4
    ldr r2, [r3, #-1044]    @ R2 = argc
    ldr r3, [fp, #-8]       @ R3 = a
    add r3, r2, r3          @ R3 = argc + a
    add r2, fp, #-67108864
    sub r2, r2, #4
    str r3, [r2, #-1036]    @ d[0] = argc + a

    ldr r2, [fp, #-20]      @ R2 = b[255]
    add r3, fp, #-67108864
    sub r3, r3, #4
    ldr r3, [r3, #-1044]    @ R3 = argc
    add r3, r2, r3          @ R3 = argc + b[255]
    add r2, fp, #-67108864
    sub r2, r2, #4
    add r2, r2, #12288
    str r3, [r2, #3056]     @ d[4095] = argc + b[255]

    ldr r2, [fp, #-8]       @ R2 = a
    ldr r3, [fp, #-12]      @ R3 = c
    add r3, r2, r3          @ R3 = a + c
    str r3, [fp, #-16]      @ e = a + c

    @ printf function call:

    ldr r1, [fp, #-1040]
    add r3, fp, #-67108864
    sub r3, r3, #4
    ldr r3, [r3, #-1036]
    ldr r2, [fp, #-16]
    str r2, [sp, #4]
    str r3, [sp]
    ldr r3, [fp, #-12]
    mov r2, r1
    ldr r1, [fp, #-8]
    ldr r0, .L3
    bl  printf
    @ return to Linux from function
    mov r3, #0
    mov r0, r3
    sub sp, fp, #4

    pop {fp, pc}

.L3:
    .word   .LC0

尽管这比 PowerPC 代码要好,但由于 ARM CPU 不能将 32 位常量编码为指令操作码的一部分,地址计算中仍然存在相当大的不美观。要理解为什么 GCC 会发出如此奇怪的常量来计算激活记录中的偏移量,请参阅在线附录 C 中“立即寻址模式”一节中对 ARM 立即数操作数的讨论。

如果你发现优化后的 PowerPC 或 ARM 代码有些难以理解,可以考虑以下同一 C 程序的 80x86 GCC 输出:


			.file   "t.c"
        .section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d %d %d %d %d "
        .text
        .p2align 2,,3
        .globl main
        .type   main,@function
main:
        ; Build main's activation record:

        pushl   %ebp
        movl    %esp, %ebp
        pushl   %ebx
        subl    $67109892, %esp

        ; Fetch ARGC into ECX:

        movl    8(%ebp), %ecx

        ; EDX = 2*argc:

        leal    (%ecx,%ecx), %edx

        ; EAX = a (ECX) + b[0] (EDX):

        leal    (%edx,%ecx), %eax

        ; c (ebx) = argc (ecx) + b[1]:

        movl    %ecx, %ebx
        addl    -1028(%ebp), %ebx
        movl    %eax, -12(%ebp)
 ; Align stack for printf call:

        andl    $-16, %esp

        ;d[0] (eax) = argc (ecx) + a (eax);

        leal    (%eax,%ecx), %eax

        ; Make room for printf parameters:

        subl    $8, %esp
        movl    %eax, -67093516(%ebp)

        ; e = a + c

        leal    (%ebx,%ecx), %eax

        pushl   %eax    ;e
        pushl   %edx    ;d[0]
        pushl   %ebx    ;c
        pushl   %edx    ;b[0]
        pushl   %ecx    ;a
        pushl   $.LC0
        movl    %edx, -1032(%ebp)
        movl    %edx, -67109896(%ebp)
        call    printf
        xorl    %eax, %eax
        movl    -4(%ebp), %ebx
        leave
        ret

当然,80x86 没有足够多的寄存器用于传递参数和存储局部变量,因此 80x86 代码必须在激活记录中分配更多的局部变量。此外,80x86 在 EBP 寄存器周围只提供–128 到+127 字节的偏移范围,因此更多的指令必须使用 4 字节的偏移而不是 1 字节的偏移。幸运的是,80x86 确实允许你将一个完整的 32 位地址编码为访问内存的指令的一部分,因此你不必执行多条指令来访问存储在 EBP 指向的堆栈帧远距离位置的变量。

15.5.5 寄存器的救援

正如上一节中的示例所示,当 RISC 代码需要处理那些偏移量不容易在指令操作码的范围内表示的参数和局部变量时,它会遭遇很大的困难。然而,在实际代码中,情况并没有那么糟糕。编译器足够聪明,能够使用机器寄存器传递参数并存储局部变量,从而实现对这些值的即时访问。这大大减少了典型函数中的指令数量。

考虑一下(寄存器匮乏的)32 位 80x86 CPU。由于只有八个通用寄存器,其中两个(ESP 和 EBP)有特殊用途,限制了它们的使用,因此可用来传递参数或存储局部变量的寄存器并不多。典型的 C 编译器使用 EAX、ECX 和 EDX 将最多三个参数传递给函数。函数通过 EAX 寄存器返回结果。函数必须保存它使用的任何其他寄存器(EBX、ESI、EDI 和 EBP)的值。幸运的是,访问函数内的局部变量和参数非常高效——鉴于寄存器集的限制,32 位 80x86 通常需要频繁使用内存来实现这一目的。

对于大多数应用程序,64 位 x86-64 相对于 32 位 80x86 的最大架构改进并不是 64 位寄存器(甚至不是地址),而是 x86-64 新增了八个通用寄存器和八个新的 XMM 寄存器,编译器可以使用它们来传递参数和存储局部变量。Intel/AMD 的 x86-64 ABI 允许编译器通过寄存器将最多六个不同的参数传递给函数(无需调用者在使用之前显式保存这些寄存器的值)。表 15-2 列出了可用的寄存器。

表 15-2: Ix86-64 通过寄存器传递参数

寄存器 用途
RDI 第一个参数
RSI 第二个参数
RDX 第三个参数
RCX 第四个参数
R8 第五个参数
R9 第六个参数
XMM0–XMM7 用于传递浮点参数
R10 可用于传递静态链指针
RAX 如果参数数量可变,则用于传递参数个数

32 位 ARM (A32) ABI 定义最多四个参数出现在 R0 到 R3 寄存器中。由于 A64 架构有两倍数量的寄存器(32 个),因此 A64 ABI 比较宽松,允许最多将八个 64 位整数/指针参数传递给 R0 到 R7 寄存器,并且允许最多将八个额外的浮点参数传递给 V0 到 V7 寄存器。

PowerPC ABI 拥有 32 个通用寄存器,将 R3 到 R10 寄存器留给传递最多八个函数参数。它还将 F1 到 F8 浮点寄存器留给传递浮点函数参数。

除了分配寄存器用于保存函数参数外,各种 ABI 通常还定义了各种寄存器,供函数用于存储局部变量或临时值(在进入函数时,不必显式保存这些寄存器中的值)。例如,Windows ABI 为临时/局部使用预留了 R11、XMM8 到 XMM15、MMX0 到 MMX7、FPU 寄存器和 RAX。ARM A32 ABI 为局部变量预留了 R4 到 R8 和 R10 到 R11 寄存器。A64 ABI 为局部变量和临时值预留了 R9 到 R15 寄存器。PowerPC 为局部变量预留了 R14 到 R30 和 F14 到 F31 寄存器。一旦编译器用尽了 ABI 定义的用于传递参数的寄存器,大多数 ABI 期望调用代码通过栈传递额外的参数。类似地,一旦函数用完了为局部变量保留的所有寄存器,额外的局部变量分配将发生在栈上。

当然,编译器可以使用其他寄存器来存储局部和临时值,包括那些由 CPU 或操作系统的 ABI 预留的寄存器。然而,编译器有责任在函数调用之间保持这些寄存器的值。

注意

ABI 是一种 约定,并非底层操作系统或硬件的要求。遵循某一 ABI 的编译器作者(以及汇编语言程序员)可以期待他们的目标代码模块能够与遵循该 ABI 的其他语言编写的代码进行交互。但是,没有什么可以阻止编译器作者使用他们选择的任何机制。

15.5.6 Java VM 和 Microsoft CLR 参数与局部变量

由于 Java 虚拟机和 Microsoft CLR 都是虚拟栈机器,编译为这两种架构的程序总是将函数参数推入栈中。除此之外,这两种虚拟机架构存在差异。差异的原因在于,Java 虚拟机的设计支持通过 JIT 编译高效地解释 Java 字节码,从而在需要时提高性能。而 Microsoft CLR 则不支持解释执行;相反,CLR 代码(CIL)的设计支持将其高效地 JIT 编译为优化后的机器码。

Java 虚拟机是传统的栈架构,参数、局部变量和临时变量都位于栈上。除了没有可供此类对象使用的寄存器外,Java 的内存组织与 80x86/x86-64、PowerPC 和 ARM CPU 非常相似。在 JIT 编译期间,可能很难弄清楚栈上的哪些值可以移到寄存器中,Java 编译器分配到栈上的哪些局部变量可以分配到寄存器中。优化这种栈分配以使用寄存器可能非常耗时,因此怀疑 Java JIT 编译器会在应用程序运行时执行此操作(因为这样做会大大降低应用程序的运行时性能)。

Microsoft 的 CLR 采用不同的哲学。CIL 总是 JIT 编译成本地机器码。此外,Microsoft 的目标是让 JIT 编译器生成优化过的本地机器码。尽管 JIT 编译器的表现通常不如传统的 C/C++ 编译器,但它通常比 Java JIT 编译器做得更好。这是因为 Microsoft CLR 的定义明确区分了参数传递和局部变量内存访问。当 JIT 编译器遇到这些特殊指令时,它可以将这些变量分配到寄存器而不是内存位置。因此,CLR JIT 编译的代码通常比 Java 虚拟机 JIT 编译的代码更短且更快(尤其在 RISC 架构上)。

15.6 参数传递机制

大多数高级语言提供至少两种机制来将实际参数数据传递给子例程:按值传递和按引用传递。^(11) 在像 Visual Basic、Pascal 和 C++ 这样的语言中,声明和使用这两种类型的参数非常简单,以至于程序员可能会得出结论,认为这两种机制在效率上几乎没有区别。这是一个误区,本节旨在澄清这一点。

注意

除了按值传递(pass-by-value)和按引用传递(pass-by-reference)之外,还有其他的参数传递机制。例如,FORTRAN 和 HLA 支持一种被称为按值/结果传递(pass-by-value/result,或称按值/返回传递)的机制。Ada 和 HLA 支持按结果传递(pass-by-result)。HLA 和 Algol 支持按名称传递(pass-by-name)。本书不会进一步讨论这些替代的参数传递机制,因为你可能很少遇到它们。如果你想了解更多信息,可以查阅一本关于编程语言设计的好书或 HLA 文档。

15.6.1 按值传递

按值传递是最容易理解的参数传递机制。调用过程的代码会复制参数的数据,并将这个副本传递给过程。对于小型值,按值传递通常只需要一个 push 指令(或者,在寄存器中传递参数时,需要一个将值移动到寄存器的指令)。因此,这种机制通常非常高效。

按值传递参数的一个大优点是,CPU 将它们视为激活记录中的局部变量。因为你通常不会传递超过 120 字节的参数数据,所以支持索引寻址模式的 CPU 将能够使用更短(因此更高效)的指令访问大多数参数值。

按值传递在某些情况下可能效率较低,尤其是当你需要传递一个大型数据结构时,如数组或记录。调用代码需要将实际参数逐字节复制到过程的激活记录中,正如你在之前的示例中所看到的那样。这可能是一个非常慢的过程,例如,如果你决定通过值传递一个包含百万个元素的数组给子程序。因此,除非绝对必要,否则你应该避免按值传递大型对象。

15.6.2 按引用传递

按引用传递机制传递的是对象的地址,而不是它的值。这相比于按值传递有几个明显的优点。首先,无论参数的大小如何,按引用传递的参数始终消耗相同的内存——指针的大小(通常可以适配到机器寄存器中)。其次,按引用传递的参数允许你修改实际参数的值——这是按值传递无法做到的。

然而,按引用传递的参数也并非没有缺点。通常,访问过程中的引用参数比访问值参数更昂贵,因为子程序在每次访问对象时都需要解引用该地址。这通常涉及将指针加载到寄存器中,以便使用寄存器间接寻址模式解引用该指针。

例如,考虑以下 Pascal 代码:


			procedure RefValue
 (
    var dest:integer;
    var passedByRef:integer;
        passedByValue:integer
);
begin

    dest := passedByRef + passedByValue;

end;

这是等效的 HLA/x86 汇编代码:


			procedure RefValue
(
var     dest:int32;
var     passedByRef:int32;
            passedByValue:int32
); @noframe;
begin RefValue;

    // Standard entry sequence (needed because of @noframe).
    // Set up base pointer.
    // Note: don't need SUB(nn,esp) because
    // we don't have any local variables.

    push( ebp );
    mov( esp, ebp );

    // Get pointer to actual value.

    mov( passedByRef, edx );

    // Fetch value pointed at by passedByRef.
 mov( [edx], eax );

    // Add in the value parameter.

    add( passedByValue, eax );

    // Get address of destination reference parameter.

    mov( dest, edx );

    // Store sum away into dest.

    mov( eax, [edx] );

    // Exit sequence doesn't need to deallocate any local
    // variables because there are none.

    pop( ebp );
    ret( 12 );

end RefValue;

请注意,这段代码比使用值传递的版本多了两条指令——特别是,将destpassedByRef的地址加载到 EDX 寄存器中的两条指令。通常,访问值传递参数的值只需要一条指令。然而,在通过引用传递参数时,需要两条指令来操作参数的值(第一条指令用于获取地址,第二条指令用于操作该地址中的数据)。因此,除非你需要引用传递的语义,否则尽量使用值传递。

当你的 CPU 有大量可用寄存器来存储指针值时,引用传递的问题往往会减少。在这种情况下,CPU 可以使用一条指令通过寄存器中的指针来获取或存储一个值。

15.7 函数返回值

大多数高级语言(HLL)会通过一个或多个 CPU 寄存器返回函数结果。具体使用哪个寄存器取决于数据类型、CPU 和编译器。然而,大多数情况下,函数会将结果返回到寄存器中(假设返回的数据适合存放在机器寄存器中)。

在 32 位的 80x86 中,大多数返回整数值的函数会将其结果返回到 AL、AX 或 EAX 寄存器。返回 64 位值(long long int)的函数通常会将结果返回到 EDX:EAX 寄存器对中(其中 EDX 包含 64 位值的高双字)。在 80x86 家族的 64 位版本中,64 位编译器会将 64 位结果返回到 RAX 寄存器。在 PowerPC 上,大多数编译器遵循 IBM ABI,并将 8 位、16 位和 32 位值返回到 R3 寄存器中。32 位版本的 PowerPC 编译器将 64 位整数值返回到 R4:R3 寄存器对中(其中 R4 包含函数结果的高字)。可以推测,运行在 64 位 PowerPC 上的编译器可以直接在 R3 寄存器中返回 64 位整数结果。

通常,编译器会将浮点数结果返回到 CPU(或 FPU)的浮点寄存器中。在 32 位的 80x86 CPU 家族中,大多数编译器将浮点结果返回到 80 位的 ST0 浮点寄存器中。虽然 80x86 家族的 64 位版本也提供与 32 位版本相同的 FPU 寄存器,但一些操作系统,如 Windows64,通常使用 SSE 寄存器中的一个(XMM0)来返回浮点值。PowerPC 系统通常将浮点函数结果返回到 F1 浮点寄存器中。其他 CPU 则将浮点结果返回到相应的位置。

一些语言允许函数返回非标量(聚合)值。编译器用于返回大函数结果的具体机制因编译器而异。然而,一种典型的解决方案是传递函数一个存储地址,函数可以将返回结果放在该存储区域。例如,考虑以下短小的 C++ 程序,其中 func() 函数返回一个结构体对象:


			#include <stdio.h>

typedef struct
{
    int a;
    char b;
    short c;
    char d;
} s_t;

s_t func( void )
{
    s_t s;

    s.a = 0;
    s.b = 1;
    s.c = 2;
    s.d = 3;
    return s;
}

int main( void )
{
    s_t t;

    t = func();
    printf( "%d %d", t.a, func().a );
    return( 0 );
}

这是 GCC 为这个 C++ 程序生成的 PowerPC 代码:


			.text
        .align 2
        .globl _func

; func() -- Note: upon entry, this
;           code assumes that R3
;           points at the storage
;           to hold the return result.

_func:
        li r0,1
        li r9,2
        stb r0,-28(r1) ; s.b = 1
        li r0,3
        stb r0,-24(r1) ; s.d = 3
        sth r9,-26(r1) ; s.c = 2
        li r9,0        ; s.a = 0

        ; Okay, set up the return
        ; result.

        lwz r0,-24(r1) ; r0 = d::c
        stw r9,0(r3)   ; result.a = s.a
        stw r0,8(r3)   ; result.d/c = s.d/c
        lwz r9,-28(r1)
        stw r9,4(r3)   ; result.b = s.b

        blr
        .data
        .cstring
        .align 2
LC0:
        .ascii "%d %d\0"
        .text
        .align 2
        .globl _main
_main:
        mflr r0
        stw r31,-4(r1)
        stw r0,8(r1)
        bcl 20,31,L1$pb
L1$pb:
        ; Allocate storage for t and
        ; temporary storage for second
        ; call to func:

        stwu r1,-112(r1)

        ; Restore LINK from above:

        mflr r31
        ; Get pointer to destination
        ; storage (t) into R3 and call func:

        addi r3,r1,64
        bl _func

        ; Compute "func().a"

        addi r3,r1,80
        bl _func

        ; Get t.a and func().a values
        ; and print them:

        lwz r4,64(r1)
        lwz r5,80(r1)
        addis r3,r31,ha16(LC0-L1$pb)
        la r3,lo16(LC0-L1$pb)(r3)
        bl L_printf$stub
        lwz r0,120(r1)
        addi r1,r1,112
        li r3,0
        mtlr r0
        lwz r31,-4(r1)
        blr

; stub for printf function:

        .data
        .picsymbol_stub
L_printf$stub:
        .indirect_symbol _printf
        mflr r0
        bcl 20,31,L0$_printf
L0$_printf:
        mflr r11
        addis r11,r11,ha16(L_printf$lazy_ptr-L0$_printf)
        mtlr r0
        lwz r12,lo16(L_printf$lazy_ptr-L0$_printf)(r11)
        mtctr r12
        addi r11,r11,lo16(L_printf$lazy_ptr-L0$_printf)
        bctr
        .data
        .lazy_symbol_pointer
L_printf$lazy_ptr:
        .indirect_symbol _printf
        .long dyld_stub_binding_helper

这是 GCC 为相同函数生成的 32 位 80x86 代码:


			.file   "t.c"
        .text
        .p2align 2,,3
        .globl func
        .type   func,@function

; On entry, assume that the address
; of the storage that will hold the
; function's return result is passed
; on the stack immediately above the
; return address.

func:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $24, %esp       ; Allocate storage for s.

        movl    8(%ebp), %eax   ; Get address of result
        movb    $1, -20(%ebp)   ; s.b = 1
        movw    $2, -18(%ebp)   ; s.c = 2
        movb    $3, -16(%ebp)   ; s.d = 3
        movl    $0, (%eax)      ; result.a = 0;
        movl    -20(%ebp), %edx ; Copy the rest of s
        movl    %edx, 4(%eax)   ; to the storage for
        movl    -16(%ebp), %edx ; the return result.
        movl    %edx, 8(%eax)
        leave
        ret     $4
.Lfe1:
        .size   func,.Lfe1-func
        .section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d %d"

        .text
        .p2align 2,,3
        .globl main
        .type   main,@function
main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $40, %esp       ; Allocate storage for
        andl    $-16, %esp      ; t and temp result.

        ; Pass the address of t to func:

        leal    -24(%ebp), %eax
        subl    $12, %esp
        pushl   %eax
        call    func

        ; Pass the address of some temporary storage
        ; to func:

        leal    -40(%ebp), %eax
        pushl   %eax
        call    func
        ; Remove junk from stack:

        popl    %eax
        popl    %edx

        ; Call printf to print the two values:

        pushl   -40(%ebp)
        pushl   -24(%ebp)
        pushl   $.LC0
        call    printf
        xorl    %eax, %eax
        leave
        ret

从这些 80x86 和 PowerPC 示例中可以得出结论:返回大对象的函数通常会在返回之前复制函数结果数据。这种额外的复制可能会花费相当多的时间,尤其是在返回结果很大的时候。与其像这里展示的那样返回一个大的结构体作为函数结果,通常更好的做法是明确地将指向某个目标存储区的指针传递给一个返回大结果的函数,然后让该函数进行必要的复制操作。这通常可以节省一些时间和代码。考虑以下实现此策略的 C 代码:


			#include <stdio.h>

typedef struct
{
    int a;
    char b;
    short c;
    char d;
} s_t;

void func( s_t *s )
{
    s->a = 0;
    s->b = 1;
    s->c = 2;
    s->d = 3;
    return;
}

int main( void )
{
    s_t s,t;
    func( &s );
    func( &t );
    printf( "%d %d", s.a, t.a );
    return( 0 );
}

这是 GCC 转换成 80x86 代码的结果:


			        .file   "t.c"
        .text
        .p2align 2,,3
        .globl func
        .type   func,@function
func:
        pushl   %ebp
        movl    %esp, %ebp
        movl    8(%ebp), %eax
        movl    $0, (%eax)      ; s->a = 0
        movb    $1, 4(%eax)     ; s->b = 1
        movw    $2, 6(%eax)     ; s->c = 2
        movb    $3, 8(%eax)     ; s->d = 3
        leave
        ret
.Lfe1:
        .size   func,.Lfe1-func
        .section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d %d"
        .text
        .p2align 2,,3
        .globl main
        .type   main,@function
main:
        ; Build activation record and allocate
        ; storage for s and t:

        pushl   %ebp
        movl    %esp, %ebp
        subl    $40, %esp
        andl    $-16, %esp
        subl    $12, %esp

        ; Pass address of s to func and
        ; call func:

        leal    -24(%ebp), %eax
        pushl   %eax
        call    func

        ; Pass address of t to func and
        ; call func:

        leal    -40(%ebp), %eax
        movl    %eax, (%esp)
        call    func

        ; Remove junk from stack:

        addl    $12, %esp
        ; Print the results:

        pushl   -40(%ebp)
        pushl   -24(%ebp)
        pushl   $.LC0
        call    printf
        xorl    %eax, %eax
        leave
        ret

如你所见,这种方法更高效,因为代码不必复制数据两次,一次是复制到数据的本地副本,再一次复制到最终的目标变量。

15.8 更多信息

Aho, Alfred V., Monica S. Lam, Ravi Sethi 和 Jeffrey D. Ullman。《编译原理:技术与工具》。第 2 版。英国埃塞克斯:Pearson Education Limited,1986。

Barrett, William 和 John Couch。《编译器构建:理论与实践》。芝加哥:SRA,1986。

Dershem, Herbert 和 Michael Jipping。《编程语言、结构与模型》。加利福尼亚州贝尔蒙特:Wadsworth,1990。

Duntemann, Jeff. 《汇编语言逐步解析》。第 3 版。印第安纳波利斯:Wiley,2009。

Fraser, Christopher 和 David Hansen。《可重定向的 C 编译器:设计与实现》。波士顿:Addison-Wesley Professional,1995。

Ghezzi, Carlo 和 Jehdi Jazayeri。《编程语言概念》。第 3 版。纽约:Wiley,2008。

Hoxey, Steve, Faraydon Karim, Bill Hay, 和 Hank Warren, 主编。《PowerPC 编译器编写指南》。加利福尼亚州帕洛阿尔托:Warthman Associates for IBM,1996。

Hyde, Randall. 《汇编语言的艺术》。第 2 版。旧金山:No Starch Press,2010。

———. “Webster:互联网上学习汇编语言的地方。” plantation-productions.com/Webster/index.html

英特尔。“英特尔 64 和 IA-32 架构软件开发者手册。” 更新于 2019 年 11 月 11 日。 software.intel.com/en-us/articles/intel-sdm

Ledgard, Henry 和 Michael Marcotty。《编程语言景观》。芝加哥:SRA,1986。

Louden, Kenneth C. 《编译器构建:原理与实践》。波士顿:Cengage,1997。

Louden, Kenneth C. 和 Kenneth A. Lambert. 编程语言:原理与实践. 第 3 版. 波士顿: 课程技术出版社, 2012 年。

Parsons, Thomas W. 编译原理导论. 纽约: W. H. Freeman 出版社, 1992 年。

Pratt, Terrence W. 和 Marvin V. Zelkowitz. 编程语言:设计与实现. 第 4 版. 新泽西州上萨德尔河: 普伦蒂斯霍尔出版社, 2001 年。

Sebesta, Robert. 编程语言概念. 第 11 版. 波士顿: Pearson 出版社, 2016 年。

第十六章:后记:软件工程

image

本卷的目标是帮助你考虑高层次编程技术对编译器生成的机器代码的影响。除非你理解高阶语言(HLL)程序中语句和数据结构的成本权衡,否则你将无法持续编写高效的程序。如果你想编写出色的代码,就不能编写低效的程序。为此,本系列的前两本书,理解机器从低层思考,写高层代码,已经解决了现代程序员面临的效率问题。然而,正如在第一章中提到的,效率并不是优秀代码的唯一特征。因此,接下来的卷《软件工程》将改变方向,讨论一些其他的特征。

具体来说,第三卷开始讨论编程的个人软件工程方面。软件工程领域主要关注大规模软件系统的管理。而个人软件工程则涉及与编写优秀代码相关的个人层面话题——工艺、艺术和对自己工作的自豪感。因此,在《软件工程》一书中,我们将通过讨论软件开发隐喻、软件开发者隐喻和系统文档等话题,考虑这些方面。

恭喜你在编写出色代码的路上取得了进展。第三卷见!


  1. 1 ↩︎

posted @ 2025-12-01 09:44  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报