作者:Agner Fog。Technical University of Denmark
Copyright © 1996 – 2017。最后更新:2017-05-01
1. 介绍
这是五本手册系列中的第二本:
- 优化C++软件:对Windows,Linux及Mac平台的优化指引。
- 优化汇编例程:对x86平台的优化指引。
- Intel, AMD及VIA CPU的微架构:对汇编程序员及编译器作者的优化指引。
- 指令表:Intel, AMD及VIA指令时延,吞吐率及微操作分解的列表。
- 不同C++编译器及操作系统间的调用惯例。
可以从www.agner.org/optimize得到这些手册的最新版。版权声明如下(原文在第20章):
本系列手册的版权为Agner Fog所有。不允许公开发行与抄袭(mirroring)。但允许对有限受众,出于教育目的非公开发行。这些手册中的样例代码可以自由使用。在我死后,GNU Free Document License将自动生效。参考www.gun.org/copyleft/fdl.html。
目前的手册解释了如何结合汇编代码与高级编程语言,以及如何使用汇编代码优化CPU密集代码的速度。
本手册面向高级汇编程序员与编译器作者。它假定读者对汇编有良好的理解,有一定的汇编编程经验。在尝试这里描述的优化技术前,建议初学者在别处查找信息,获取一些编程经验。我可以建议各种指引、教程、论坛及网络上的新闻组(参考www.agner.org/optimize的链接),以及R. C. Detmer,2006年版的《Introduction to 80x86 Assembly Language and Computer Architecture》。
当前手册覆盖了所有使用x86与x86-64指令集的平台。来自Intel,AMD与VIA的大多数微处理器使用这个指令集。可以使用这个指令集的操作系统包括DOS,Windows,Linux,FreeBSD/OpenBSD,及基于Intel的Mac OS。本手册覆盖了最新的微处理器与最新的指令集。各个微处理器模型的细节,参考手册3与4。
在手册1《优化C++软件》中讨论的优化技术不特定于汇编语言。特定于特定微处理器的细节在手册3《Intel,AMD与VIA CPU的微架构》中讨论。在手册4《指令表:Intel、AMD与VIA CPU的指令时延、吞吐率与微操作分解》中提供指令时序的列表。用于不同操作系统与编译器的调用惯例的细节在手册5《用于不同C++编译器及操作系统的调用惯例》中讨论。
使用汇编语言编程远比高级语言困难。引入bug非常容易,而找出它们非常难。现在你已经得到警告了!请不要将你的编程问题发送给我。我不会回这样的邮件。你可以在互联网上有各种各样的论坛里,找到你编程问题的答案,如果你不能在相关的书籍与手册里找到的话。
祝你幸运地获得纳秒进步。
1.1. 使用汇编代码的理由
如今汇编使用已不如以前广泛。不过,仍然有理由学习、使用汇编代码。主要的原因是:
- 教学原因。知道微处理器与编译器在指令层面如何工作,对能够预测哪个编程技术最有效,能够理解高级语言中的各种结构如何工作,以及追踪难以发现错误,很重要。
- 调试与验证。查看编译器产生的汇编代码或调试器中的反汇编窗口,有助于查找错误,以及检查编译器对特定一段代码的优化程度。
- 制作编译器。理解汇编编程技术有助于制作编译器、调试器以及其他开发工具。
- 嵌入式系统。小的嵌入式系统的资源要少于PC及大型机。在小的嵌入式系统中,对优化代码的速度或大小,汇编编程是必要的。
- 硬件驱动与系统代码。使用高级语言访问硬件、系统控制寄存器等,有时会很困难甚至不可能。
- 访问高级语言不能访问的指令。某些汇编指令没有高级语言的对等物。
- 自修改代码。自修改代码通常是没有益处的,因为它干扰了代码缓存。不过,例如在含有一个会计算许多次的用户定义函数的数学程序中,包含一个小编译器, 可能是有利的。
- 优化代码尺寸。如今存储空间与内存如此便宜,不值得使用汇编语言减小代码大小。不过,缓存大小仍然是一个关键资源,因此在某些情形里,优化一段关键代码片段的大小,使它能放入代码缓存,是有用的。
- 优化代码速度。一般而言,现代C++编译器在大多数情形里代码优化相当好。但仍然存在编译器执行很差的情况,以及仔细汇编编程会取得速度剧增的情况。
- 函数库。在许多程序员使用的函数库中,优化代码的总体利益会更高。
- 使函数库与多个编译器及操作系统兼容。制作带有多个入口、与不同编译器及操作系统兼容的函数库是可能的。这要求汇编编程。
本手册中的主要关注点在于优化代码速度,虽然也会讨论其他一些题目。
1.2. 不使用汇编代码的理由
在汇编编程中有许多不利与问题,建议在确定对一个特定任务使用汇编代码前,考虑替代方案。不使用汇编编程最重要的原因有:
- 开发时间。用汇编语言编写代码花的时间比高级语言要多得多。
- 可靠性与安全。在汇编代码里很容易出错。汇编器不检查是否遵守了调用惯例与寄存器保存惯例。没入会帮你检查PUSH与POP指令的数目在所有可能的分支与路径中都相同。在汇编代码里隐藏错误有如此多的可能性,它影响了项目的可靠性与安全性,除非对测试与验证有一个非常系统化的做法。
- 调试与验证。汇编代码更难以调试与验证,因为有比高级代码多的错误可能性。
- 可维护性。汇编代码更难修改与维护,因为该语言允许非结构化的面条式代码,以及各种难以理解的肮脏的技巧。需要细致的文档与一个一贯的编程风格。
- 系统代码可以使用固有函数来替代汇编。现在最好的C++编译器提供访问系统控制寄存器以及其他系统指令的固有函数。对设备驱动及其他系统代码,在有固有函数可用时,不再需要汇编代码。
- 应用代码可以使用固有函数或向量类来代替汇编。现在最好的C++编译器提供支持向量操作与其他之前要求汇编编程的特殊指令的固有函数。不再需要使用旧式汇编代码来使用单指令多数据(SIMD)指令。参考第27页。
- 可移植性。汇编代码高度特定于平台。移植到不同的平台是困难的。使用固有函数而不是汇编的代码,对所有x86及x86-64平台都是可移植的。
- 近年来,编译器已经改进了许多。现在最好的编译器,在许多情形下,要超过汇编程序员的平均水平。
- 编译后的代码可能比汇编代码更快,因为编译器可以进行过程间优化以及全程序优化。为了使代码可测试且可验证,汇编程序员通常必须提供带有一个遵守所有调用惯例的、良好调用接口的定义良好的函数。这阻止了许多编译器使用的优化方法,比如函数内联、寄存器分配、常量传播、函数间公共子表达式线程、函数间调度等。使用具有固有函数的C++代码替代汇编代码,可以获得这些好处。
1.3. 本手册覆盖的操作系统
以下操作系统可以使用x86微处理器家族:
16位:DOS,Windows 3.x。
32位:Windows,Linux,FreeBSD,OpenBSD,NetBSD,基于Intel的Mac OS X。
64位:Windows,Linux,FreeBSD,OpenBSD,NetBSD,基于Intel的Mac OS X。
所有类UNIX系统(Linux,BSC,Mac OS)使用相同的调用惯例,有极少数例外。本手册中讨论的关于Linux的内容,也适用于其他类UNIX系统,可能包括没有在这里提及的系统。
1. 在你开始之前
2.1. 在你开始编程之前所要做的决定
在你开始写汇编程序之前,你必须想一下为什么你想使用汇编语言,你程序的哪个部分需要用汇编制作,以及使用什么编程方法。如果你没有搞清楚你的开发策略,那么你将很快发现你自己浪费时间在优化程序错误的部分,在本可以使用C++的地方使用了汇编,尝试优化不可能进一步优化的部分,做出了难以维护的面条样代码,以及使代码充满错误且难以调试。
下面是在你开始编程前,需要考虑的清单:
- 永远不要用汇编写整个程序。这是浪费时间。汇编代码仅应该用在速度是关键且可以获得显著速度提升的地方。程序的大部分应该使用C或C++。这些是最容易与汇编代码整合的编程语言。
- 如果使用汇编的目的是编写系统代码或使用标准C++中不可用的特殊指令,那么你应该在一个独立的函数或类中,以一个定义良好的功能,隔离出需要这些指令的程序部分。如果可能使用固有函数(参考第27页)。
- 如果使用汇编的目的是优化速度,那么你必须识别出消耗CPU时间最多的程序部分,可能需要使用分析器。检查瓶颈是否为文件访问、内存访问、CPU指令或别的什么,如手册1《优化C++软件》所述。将程序的关键部分隔离到一个具有良好功能的函数或类中。
- 如果使用汇编的目的是编写函数库,那么你应该清晰定义这个库的功能。确定是否一个函数库还是类库。确定是否使用静态链接(Windows中.lib,Linux中.a)或动态链接(Windows中.dll,Linux中.so)。静态链接通常更高效,但如果是从像C#及Visual Basic这样的语言调用这个库,动态链接可能是必须的。可以同时做该库的静态与动态链接版本。
- 如果使用汇编的目的是优化嵌入式应用的大小或速度,那么尽可能在C或C++中找一个同时支持C/C++及汇编的开发工具。
- 确定代码是否可重用或特定于应用。如果代码是可重用的,花时间在仔细优化上是值得的。可重用代码最合适的实现是一个函数库或类库。
- 确定代码是否支持多线程。一个多线程应用可以利用带有多核的微处理器。在每线程基础上,从一个函数调用到下一个必须保留的数据应该保存在由调用程序提供的一个C++类或一个每线程缓冲中。
- 确定对你的应用程序可移植性是否重要。应用程序需要工作在Windows,Linux以及基于Intel的Mac OS上?应用程序需要工作在32位与64位模式中?它需要工作在非x86平台吗?这对编译器、汇编器与编程方法的选择是重要的。
- 确定你的应用程序是否应该工作在旧的微处理器上。如果是,那么你可能需要对支持SSE2指令集的微处理器做一个版本,以及与旧微处理器兼容的另一个版本。你甚至可以做几个版本,每个都对一个特定CPU优化。建议进行自动CPU分派(参考第143页)。
- 有3个汇编编程方法可以选择:(1) 使用C++编译器中的固有函数与向量类。(2) 使用C++编译器中的内联汇编。(3) 使用汇编器。这3个方法与它们相关的优缺点分别在第5,6与7章里描述(分别是第27,29与39页)。
- 如果你准备使用一个汇编器,那么你必须在不同的方言语法间选择。应该优先使用与你的C++编译器可以产生的汇编代码兼容的汇编器。
- 首先用C++做代码,并尽可能优化,使用在手册1《优化C++软件》中描述的方法。让编译器将代码翻译为汇编。查看编译器生成的代码,看代码是否有可能改进。
- 高度优化的代码倾向于对他人,甚至你自己一段时间后,难以阅读与理解。为了使代码维护成为可能,把它组织为具有良好定义接口、调用惯例及合适注释的小逻辑单元(过程与宏)是重要的。确定代码注释与文档的一贯策略。
- 为日后的维护,将编译器、汇编器及所有其他开发工具与源代码及项目文件保存在一起。几年后在需要更新、修改代码时,兼容的工具可能就找不到了。
3. 制定测试策略
就像我已经提到的,汇编代码易错、难调试、难以一个清晰的结构化方式制作、难读且难维护。一个一贯的测试策略可以改善其中一些问题,节约你大量的时间。
我的建议是,把汇编代码做成一个孤立的模块、函数、类或库,具有对调用程序良好定义的接口。首先用C++编写它。然后编写一个可以测试你希望优化代码各方面的测试程序。使用一个测试程序,比在最终的应用程序里测试模块,要更容易与安全。
测试程序有两个目的。第一个目的是验证汇编代码在所有的情形下工作正确。第二个目的是,在没有可能使速度测试准确度与可重现性降低的用户接口调用、文件访问以及调用最终应用程序其他部分的情形下,测试汇编代码的速度。
在开发过程中的每一步以及代码的每次修改之后,都应该使用使用这个测试程序。
确保这个测试程序工作正常。当错误实际是在测试程序里时,花费许多时间在被测试代码里查找错误,相当常见。
要验证代码工作正常,有不同的测试方法可以使用。一个白盒测试提供一系列仔细选择不同的输入数据集,来确保代码中所有的分支、路径、特殊情形都测试到了。一个黑盒测试提供一系列随机输入数据集,验证输出是正确的。一个好的随机数生成器产生的非常长的随机数序列,有时可以找出白盒测试找不到的极少出现的错误。
测试程序可以将汇编代码的输出与C++实现的输出比较,来验证其正确性。这个测试应该覆盖所有的边界用例,最好还有非法输入数据,来检查代码是否产生了正确的错误响应。
速度测试应该提供一组合理的输入数据。在包含许多分支的代码中,CPU时间相当大的一部分可能花的分支误预测上。分支误预测的数量依赖输入数据中的随机程度。你可以试验输入数据中的随机程度,看它影响计算时间的程度,然后决定与典型真实应用匹配的、现实的随机程度。
提供一长串测试数据流的自动测试程序,通常会比测试最终应用程序中的代码,找出更多的错误,而且快得多。一个好的测试程序会找出大多数错误,但不能确保找出所有的错误。可能某些错误仅与最终应用程序结合时出现。
2.3. 常见的编程陷阱
以下列表指出了汇编代码中最常见的编程错误。
- 忘记保存寄存器。某些寄存器有被调用者保存的状态,例如EBX。这些寄存器必须在一个函数的prolog里保存,在epilog里恢复,如果在函数中修改了它们。记住POP指令的顺序必须与PUSH指令的顺序相反,被调用者保存的寄存器清单,参考第21页。
- 不匹配的PUSH与POP指令。在一个函数所有可能的路径中,PUSH与POP指令数必须相等。例如:
Example 2.1. Unmatched push/pop
push ebx
test ecx, ecx
jz Finished
...
pop ebx Finished: ; Wrong! Label should be before pop ebx
ret
这里,如果ECX为零,被压栈的EBX值没有被弹出。结果是RET指令将弹出EBX的前一个值,跳转到一个错误的地址。
- 使用一个为其他目的保留的寄存器。某些编译器保留EBP或EBX用于栈框指针或其他目的。在内联汇编中为别的目的使用这些处理器会导致错误。
- 在压栈后栈相关的取址。在对一个相对于栈指针的变量取址时,你必须考虑所有之前改变栈指针的指令。例如:
Example 2.2. Stack-relative addressing
mov [esp+4], edi
push ebp
push ebx
cmp esi, [esp+4] ; Probably wrong!
这里,程序员可能打算比较ESI与EDI,但用于取址的ESP值已经被两条PUSH指令改变了,因此ESI实际上与EBP比较。
- 混淆一个变量的值与地址。例如:
Example 2.3. Value versus address (MASM syntax)
.data
MyVariable DD 0 ; Define variable
.code
mov eax, MyVariable ; Gets value of MyVariable
mov eax, offset MyVariable ; Gets address of MyVariable
lea eax, MyVariable ; Gets address of MyVariable
mov ebx, [eax] ; Gets value of MyVariable through pointer
mov ebx, [100] ; Gets the constant 100 despite brackets
mov ebx, ds:[100] ; Gets value from address 100
- 忽略调用惯例。对函数遵守调用惯例是重要的,比如参数的次序、参数通过栈还是寄存器传递、栈由调用者还是被调用者清理。参考第21页。
- 函数名修饰(mangle)。调用一个汇编函数的C++代码应该使用extern “C”来避免名字修饰。某些系统要求在汇编代码中的名字前放置一个下划线(_)。参考第23页。
- 忘记返回。一个函数声明必须以RET及ENDP结束。使用其中一个是不够的。如果没有RET,执行将在该过程后的代码中继续。
- 忘记栈对齐。在任何调用语句前,栈指针必须指向一个可被16整除的地址,除了16位系统与32位Windows。参考第21页。
- 在64位Windows中忘记影子空间(shadow space)。在64位Windows中,在任何函数调用前要求保留32字节空的栈空间。参考第23页。
- 混用调用惯例。在64位Windows与64位Linux中的调用惯例是不同的。参考第15页。
- 忘记清理浮点寄存器栈。函数使用的所有浮点栈寄存器必须被清理,通常使用FSTP ST(0),在该函数返回前,除了ST(0),如果它被用于返回值。记录实际使用的浮点寄存器数是必要的。如果一个函数在浮点寄存器栈上压入的值超过它弹出的值,那么在每次调用这个函数时,寄存器栈将增长。在栈填满时,产生一个异常。这个异常可能出现在程序的别处。
- 忘记清理MMX状态。一个使用MMX寄存器的函数,在进行任何调用或返回前,必须使用EMMS指令清除这些寄存器。
- 忘记清理YMM状态。一个使用YMM寄存器的函数,在进行任何调用或返回前,必须YZEROUPPER或VZEROALL指令清理这些寄存器。
- 忘记清理方向标记。任何使用STD设置方向标记的方式,在进行任何调用或返回前,必须清理它。
- 混用有符号与无符号整数。无符号整数使用JB与JA指令比较。有符号整数使用JL与JG指令比较。混用有符号与无符号整数会有意外的后果。
- 忘记按比例伸缩数组索引。任何数组索引必须乘以一个数组元素的大小。例如mov eax, MyIntegerArray[ebx*4]。
- 超出数组边界。有n个元素的指令索引从0到n – 1,不是从1到n。一个有缺陷的、写出一个数组边界的循环,会导致在程序别处发生难以查找的错误。
- 使用ECX = 0进行循环。如果ECX是零,一个以LOOP指令结尾的循环将重复232次。确保在循环前检查ECX是否为零。
- 在INC或DEC后读进位标记。INC与DEC指令不会改变进位标记。不要在INC或DEC后,使用读进位标记的指令,比如ADC,SBB,JC,JBE,SETA等。