静态网站开发的目的,WordPress用户页面,网站关键词是什么,wordpress用户爆破我不想夸大或者贬低汇编语言。但我想说#xff0c;汇编语言改变了20世纪的历史。与前辈相比#xff0c;我们这一代编程人员足够的幸福#xff0c;因为我们有各式各样的编程语言#xff0c;我们可以操作键盘、坐在显示器面前#xff0c;甚至使用鼠标、语音识别。我们可以使…我不想夸大或者贬低汇编语言。但我想说汇编语言改变了20世纪的历史。与前辈相比我们这一代编程人员足够的幸福因为我们有各式各样的编程语言我们可以操作键盘、坐在显示器面前甚至使用鼠标、语音识别。我们可以使用键盘、鼠标来驾驭“个人计算机”而不是和一群人共享一台使用笨重的继电器、开关去操作的巨型机。相比之下我们的前辈不得不使用机器语言编写程序他们甚至没有最简单的汇编程序来把助记符翻译成机器语言而我们可以从上千种计算机语言中选择我们喜欢的一种而汇编虽然不是一种“常用”的具有“快速原型开发”能力的语言却也是我们可以选择的语言中的一种。
每种计算机都有自己的汇编语言——没必要指望汇编语言的可移植性选择汇编意味着选择性能而不是可移植或便于调试。这份文档中讲述的是x86汇编语言此后的“汇编语言”一词如果不明示则表示ia32上的x86汇编语言。
汇编语言是一种易学却很难精通的语言。回想当年我从初学汇编到写出 第一个可运行的程序 只用了不到4个小时然而直到今天我仍然不敢说自己精通它。编写快速、高效、并且能够让处理器“很舒服地执行”的程序是一件很困难的事情如果利用业余时间学习通常需要2-3年的时间才能做到。这份教材并不期待能够教给你大量的汇编语言技巧。对于读者来说x86汇编语言就在这里。然而不要僵化地局限于这份教材讲述的内容因为它只能告诉你汇编语言是“这样一回事”。学好汇编语言更多的要靠一个人的创造力于悟性我可以告诉你我所知道的技巧但肯定这是不够的。一位对我的编程生涯产生过重要影响的人曾经对我说过这么一句话 写汇编语言程序 不是 汇编语言最难的部分创新才是。
我想愿意看这份文档的人恐怕不会问我“为什么要学习汇编语言”这样的问题不过我还是想说几句首先汇编语言非常有用我个人主张把它作为C语言的先修课程因为通过学习汇编语言你可以了解到如何有效地设计数据结构让计算机处理得更快并使用更少的存储空间同时学习汇编语言可以让你熟悉计算机内部运行机制并且有效地提高调试能力。就我个人的经验而言调试一个非结构化的程序的困难程度要比调试一个结构化的程序的难度高很多因为“结构化”是以牺牲运行效率来提高可读性与可调试性这对于完成一般软件工程的编码阶段是非常必要的。然而在一些地方比如硬件驱动程序、操作系统底层或者程序中经常需要执行的代码结构化程序设计的这些优点有时就会被它的低效率所抹煞。另外如果你想真正地控制自己的程序只知道源代码级的调试是远远不够的。
浮躁的人喜欢说用C写程序足够了甚至说他不仅仅掌握C而且精通STL、MFC。我不赞成这个观点掌握上面的那些是每一个编程人员都应该做到的然而C只是我们常用的一种语言它不是编程的全部。低层次的开发者喜欢说嘿C是多么的强大它可以做任何事情——这不是事实。便于维护、调试这些确实是我们的追求目标但是写程序不能仅仅追求这个目标因为我们最终的目的是满足设计需求而不是个人非理性的理想。
这份教材适合已经学习过某种结构化程序设计语言的读者。其内容基于我在1995年给别人讲述汇编语言时所写的讲义。当然如大家所希望的它包含了最新的处理器所支持的特性以及相应的内容。我假定读者已经知道了程序设计的一些基本概念因为没有这些是无法理解汇编语言程序设计的此外我希望读者已经有了比较良好的程序设计基础因为如果你缺乏对于结构化程序设计的认识编写汇编语言程序很可能很快就破坏了你的结构化编程习惯大大降低程序的可读性、可维护性最终让你的程序陷于不得不废弃的代码堆之中。
基本上这份文档撰写的目标是尽可能地便于自学。不过它对你也有一些要求尽管不是很高但我还是强调一下。 学习汇编语言你需要 胆量。不要害怕去接触那些计算机的内部工作机制。 知识。了解计算机常用的数制特别是二进制、十六进制、八进制以及计算机保存数据的方法。 开放。接受汇编语言与高级语言的差异而不是去指责它如何的不好读。 经验。要求你拥有任意其他编程语言的一点点编程经验。 头脑。
祝您编程愉快
第一章 汇编语言简介
先说一点和实际编程关系不太大的东西。当然如果你迫切的想看到更实质的内容完全可以先跳过这一章。
那么我想可能有一个问题对于初学汇编的人来说非常重要那就是
汇编语言到底是什么 汇编语言是一种最接近计算机核心的编码语言。不同于任何高级语言汇编语言几乎可以完全和机器语言一一对应。不错我们可以用机器语言写程序但现在除了没有汇编程序的那些电脑之外直接用机器语言写超过1000条以上指令的人大概只能算作那些被我们成为“圣人”的牺牲者一类了。毕竟记忆一些短小的助记符、由机器去考虑那些琐碎的配位过程和检查错误比记忆大量的随计算机而改变的十六进制代码、可能弄错而没有任何提示要强的多。熟练的汇编语言编码员甚至可以直接从十六进制代码中读出汇编语言的大致意思。当然我们有更好的工具——汇编器和反汇编器。
简单地说汇编语言就是机器语言的一种 可以被人读懂的形式 只不过它更容易记忆。至于宏汇编则是包含了宏支持的汇编语言这可以让你编程的时候更专注于程序本身而不是忙于计算和重写代码。
汇编语言除了机器语言之外最接近计算机硬件的编程语言。由于它如此的接近计算机硬件因此它可以最大限度地发挥计算机硬件的性能。用汇编语言编写的程序的速度通常要比高级语言和C/C快很多--几倍几十倍甚至成百上千倍。当然解释语言如解释型LISP没有采用JIT技术的Java虚机中运行的Java等等其程序速度更 无法 与汇编语言程序同日而语 。
永远不要忽视汇编语言的高速。实际的应用系统中我们往往会用汇编彻底重写某些经常调用的部分以期获得更高的性能。应用汇编也许不能提高你的程序的稳定性但至少如果你非常小心的话它也不会降低稳定性与此同时它可以大大地提高程序的运行速度。我强烈建议所有的软件产品在最后Release之前对整个代码进行Profile并适当地用汇编取代部分高级语言代码。至少汇编语言的知识可以告诉你一些有用的东西比如你有多少个寄存器可以用。有时手工的优化比编译器的优化更为有效而且你可以完全控制程序的实际行为。
我想我在罗嗦了。总之在我们结束这一章之前我想说不要在优化的时候把希望完全寄托在编译器上——现实一些再好的编译器也不可能总是产生最优的代码。 [dvnews_page简明x86汇编语言教程(2)]
第二章 认识处理器
中央处理器(CPU)在微机系统处于“领导核心”的地位。汇编语言被编译成机器语言之后将由处理器来执行。那么首先让我们来了解一下处理器的主要作用这将帮助你更好地驾驭它。
典型的处理器的主要任务包括 从内存中获取机器语言指令译码执行 根据指令代码管理它自己的寄存器 根据指令或自己的的需要修改内存的内容 响应其他硬件的中断请求
一般说来处理器拥有对整个系统的所有总线的控制权。对于Intel平台而言处理器拥有对数据、内存和控制总线的控制权根据指令控制整个计算机的运行。在以后的章节中我们还将讨论系统中同时存在多个处理器的情况。
处理器中有一些寄存器这些寄存器可以保存特定长度的数据。某些寄存器中保存的数据对于系统的运行有特殊的意义。
新的处理器往往拥有更多、具有更大字长的寄存器提供更灵活的取指、寻址方式。
寄存器
如前所述处理器中有一些可以保存数据的地方被称作寄存器。
寄存器可以被装入数据你也可以在不同的寄存器之间移动这些数据或者做类似的事情。基本上像四则运算、位运算等这些计算操作都主要是针对寄存器进行的。
首先让我来介绍一下80386上最常用的4个通用寄存器。先瞧瞧下面的图形试着理解一下 上图中数字表示的是位。我们可以看出EAX是一个32-bit寄存器。同时它的低16-bit又可以通过AX这个名字来访问AX又被分为高、低8bit两部分分别由AH和AL来表示。
对于EAX、AX、AH、AL的改变同时也会影响与被修改的那些寄存器的值。从而事实上只存在一个32-bit的寄存器EAX而它可以通过4种不同的途径访问。
也许通过名字能够更容易地理解这些寄存器之间的关系。EAX中的E的意思是“扩展的”整个EAX的意思是扩展的AX。X的意思Intel没有明示我个人认为表示它是一个可变的量 。而AH、AL中的H和L分别代表高和低 。
为什么要这么做呢主要由于历史原因。早期的计算机是8位的8086是第一个16位处理器其通用寄存器的名字是AXBX等等80386是Intel推出的第一款IA-32系列处理器所有的寄存器都被扩充为32位。为了能够兼容以前的16位应用程序80386不能将这些寄存器依旧命名为AX、BX并且简单地将他们扩充为32位——这将增加处理器在处理指令方面的成本。
Intel微处理器的寄存器列表在本章先只介绍80386的寄存器MMX寄存器以及其他新一代处理器的新寄存器将在以后的章节介绍
通用寄存器 下面介绍通用寄存器及其习惯用法。顾名思义通用寄存器是那些你可以根据自己的意愿使用的寄存器修改他们的值通常不会对计算机的运行造成很大的影响。通用寄存器最多的用途是计算。 EAX 32-bit宽 通用寄存器。相对其他寄存器在进行运算方面比较常用。在保护模式中也可以作为内存偏移指针此时DS作为段 寄存器或选择器 EBX 32-bit宽 通用寄存器。通常作为内存偏移指针使用相对于EAX、ECX、EDXDS是默认的段寄存器或选择器。在保护模式中同样可以起这个作用。 ECX 32-bit宽 通用寄存器。通常用于特定指令的计数。在保护模式中也可以作为内存偏移指针此时DS作为 寄存器或段选择器。 EDX 32-bit宽 通用寄存器。在某些运算中作为EAX的溢出寄存器例如乘、除。在保护模式中也可以作为内存偏移指针此时DS作为段 寄存器或选择器。
上述寄存器同EAX一样包括对应的16-bit和8-bit分组。
用作内存指针的特殊寄存器 ESI 32-bit宽 通常在内存操作指令中作为“源地址指针”使用。当然ESI可以被装入任意的数值但通常没有人把它当作通用寄存器来用。DS是默认段寄存器或选择器。 EDI 32-bit宽 通常在内存操作指令中作为“目的地址指针”使用。当然EDI也可以被装入任意的数值但通常没有人把它当作通用寄存器来用。DS是默认段寄存器或选择器。 EBP 32-bit宽 这也是一个作为指针的寄存器。通常它被高级语言编译器用以建造‘堆栈帧来保存函数或过程的局部变量不过还是那句话你可以在其中保存你希望的任何数据。SS是它的默认段寄存器或选择器。
注意这三个寄存器没有对应的8-bit分组。换言之你可以通过SI、DI、BP作为别名访问他们的低16位却没有办法直接访问他们的低8位。
段寄存器和选择器
实模式下的段寄存器到保护模式下摇身一变就成了选择器。不同的是实模式下的“段寄存器”是16-bit的而保护模式下的选择器是32-bit的。
CS 代码段或代码选择器。同IP寄存器(稍后介绍)一同指向当前正在执行的那个地址。处理器执行时从这个寄存器指向的段实模式或内存保护模式中获取指令。除了跳转或其他分支指令之外你无法修改这个寄存器的内容。 DS 数据段或数据选择器。这个寄存器的低16 bit连同ESI一同指向的指令将要处理的内存。同时所有的内存操作指令 默认情况下都用它指定操作段(实模式)或内存(作为选择器在保护模式。这个寄存器可以被装入任意数值然而在这么做的时候需要小心一些。方法是首先把数据送给AX然后再把它从AX传送给DS(当然也可以通过堆栈来做). ES 附加段或附加选择器。这个寄存器的低16 bit连同EDI一同指向的指令将要处理的内存。同样的这个寄存器可以被装入任意数值方法和DS类似。 FS F段或F选择器(推测F可能是Free?)。可以用这个寄存器作为默认段寄存器或选择器的一个替代品。它可以被装入任何数值方法和DS类似。 GS G段或G选择器(G的意义和F一样没有在Intel的文档中解释)。它和FS几乎完全一样。 SS 堆栈段或堆栈选择器。这个寄存器的低16 bit连同ESP一同指向下一次堆栈操作(push和pop)所要使用的堆栈地址。这个寄存器也可以被装入任意数值你可以通过入栈和出栈操作来给他赋值不过由于堆栈对于很多操作有很重要的意义因此不正确的修改有可能造成对堆栈的破坏。
* 注意 一定不要在初学汇编的阶段把这些寄存器弄混。他们非常重要而一旦你掌握了他们你就可以对他们做任意的操作了。段寄存器或选择器在没有指定的情况下都是使用默认的那个。这句话在现在看来可能有点稀里糊涂不过你很快就会在后面知道如何去做。
特殊寄存器(指向到特定段或内存的偏移量)
EIP 这个寄存器非常的重要。这是一个32位宽的寄存器 同CS一同指向即将执行的那条指令的地址。不能够直接修改这个寄存器的值修改它的唯一方法是跳转或分支指令。(CS是默认的段或选择器) ESP 这个32位寄存器指向堆栈中即将被操作的那个地址。尽管可以修改它的值然而并不提倡这样做因为如果你不是非常明白自己在做什么那么你可能造成堆栈的破坏。对于绝大多数情况而言这对程序是致命的。(SS是默认的段或选择器)
IP: Instruction Pointer, 指令指针 SP: Stack Pointer, 堆栈指针
好了上面是最基本的寄存器。下面是一些其他的寄存器你甚至可能没有听说过它们。(都是32位宽)
CR0, CR2, CR3(控制寄存器)。举一个例子CR0的作用是切换实模式和保护模式。
还有其他一些寄存器D0, D1, D2, D3, D6和D7(调试寄存器)。他们可以作为调试器的硬件支持来设置条件断点。
TR3, TR4, TR5, TR6 和 TR? 寄存器(测试寄存器)用于某些条件测试。
最后我们要说的是一个在程序设计中起着非常关键的作用的寄存器标志寄存器。 本节中部份表格来自David Jurgens的HelpPC 2.10快速参考手册。在此谨表谢意。 [dvnews_page简明x86汇编语言教程(3)]
2.2 使用寄存器
在前一节中的x86基本寄存器的介绍对于一个汇编语言编程人员来说是不可或缺的。现在你知道寄存器是处理器内部的一些保存数据的存储单元。仅仅了解这些是不足以写出一个可用的汇编语言程序的但你已经可以大致读懂一般汇编语言程序了不必惊讶因为汇编语言的祝记符和英文单词非常接近因为你已经了解了关于基本寄存器的绝大多数知识。
在正式引入第一个汇编语言程序之前我粗略地介绍一下汇编语言中不同进制整数的表示方法。如果你不了解十进制以外的其他进制请把鼠标移动到 这里 。 汇编语言中的整数常量表示 十进制整数 这是汇编器默认的数制。直接用我们熟悉的表示方式表示即可。例如1234表示十进制的1234。不过如果你指定了使用其他数制或者有凡事都进行完整定义的小爱好也可以写成[十进制数]d或[十进制数]D的形式。 十六进制数 这是汇编程序中最常用的数制我个人比较偏爱使用十六进制表示数据至于为什么以后我会作说明。十六进制数表示为0[十六进制数]h或0[十六进制数]H其中如果十六进制数的第一位是数字则开头的0可以省略。例如7fffh, 0ffffh等等。 二进制数 这也是一种常用的数制。二进制数表示为[二进制数]b或[二进制数]B。一般程序中用二进制数表示掩码mask code等数据非常的直观但需要些很长的数据4位二进制数相当于一位十六进制数。例如1010110b。 八进制数 八进制数现在已经不是很常用了确实还在用一个典型的例子是Unix的文件属性。八进制数的形式是[八进制数]q、[八进制数]Q、[八进制数]o、[八进制数]O。例如777Q。
需要说明的是这些方法是针对宏汇编器例如MASM、TASM、NASM说的调试器默认使用十六进制表示整数并且不需要特别的声明例如在调试器中直接用FFFF表示十进制的65535用10表示十进制的16。
现在我们来写一小段汇编程序修改EAX、EBX、ECX、EDX的数值。
我们假定程序执行之前寄存器中的数值是全0 ? X H L EAX 0000 00 00 EBX 0000 00 00 ECX 0000 00 00 EDX 0000 00 00
正如前面提到的EAX的高16bit是没有办法直接访问的而AX对应它的低16bitAH、AL分别对应AX的高、低8bit。
mov eax, 012345678h mov ebx, 0abcdeffeh mov ecx, 1 mov edx, 2 将012345678h送入eax 将0abcdeffeh送入ebx 将000000001h送入ecx 将000000002h送入edx
则执行上述程序段之后寄存器的内容变为 ? X H L EAX 1234 56 78 EBX abcd ef fe ECX 0000 00 01 EDX 0000 00 02
那么你已经了解了mov这个指令mov是move的缩写的一种用法。它可以将数送到寄存器中。我们来看看下面的代码
mov eax, ebx mov ecx, edx ebx内容送入eax edx内容送入ecx
则寄存器内容变为 ? X H L EAX abcd ef fe EBX abcd ef fe ECX 0000 00 02 EDX 0000 00 02
我们可以看到“move”之后数据依然保存在原来的寄存器中。不妨把mov指令理解为“送入”或“装入”。
练习题
把寄存器恢复成都为全0的状态然后执行下面的代码
mov eax, 0a1234h mov bx, ax mov ah, bl mov al, bh 将0a1234h送入eax 将ax的内容送入bx 将bl内容送入ah 将bh内容送入al
思考此时EAX的内容将是多少[ 答案 ]
下面我们将介绍一些指令。在介绍指令之前我们约定 使用Intel文档中的寄存器表示方式 reg32 32-bit寄存器表示EAX、EBX等 reg16 16-bit寄存器在32位处理器中这AX、BX等 reg8 8-bit寄存器表示AL、BH等 imm32 32-bit立即数可以理解为常数 imm16 16-bit立即数 imm8 8-bit立即数
在寄存器中载入另一寄存器或立即数的值 mov reg32, (reg32 | imm8 | imm16 | imm32) mov reg32, (reg16 | imm8 | imm16) mov reg8, (reg8 | imm8)
例如mov eax, 010h表示在eax中载入00000010h。需要注意的是如果你希望在寄存器中装入0则有一种更快的方法在后面我们将提到。
交换寄存器的内容
xchg reg32, reg32 xchg reg16, reg16 xchg reg8, reg8
例如xchg ebx, ecx则ebx与ecx的数值将被交换。由于系统提供了这个指令因此采用其他方法交换时速度将会较慢并需要占用更多的存储空间编程时要避免这种情况即尽量利用系统提供的指令因为多数情况下这意味着更小、更快的代码同时也杜绝了错误如果说Intel的CPU在交换寄存器内容的时候也会出错那么它就不用卖CPU了。而对于你来说检查一行代码的正确性也显然比检查更多代码的正确性要容易刚才的习题的程序用下面的代码将更有效
mov eax, 0a1234h mov bx, ax xchg ah, al 将0a1234h送入eax 将ax内容送入bx 交换ah, al的内容
递增或递减寄存器的值
inc reg(8,16,32) dec reg(8,16,32)
这两个指令往往用于循环中对指针的操作。需要说明的是某些时候我们有更好的方法来处理循环例如使用loop指令或rep前缀。这些将在后面的章节中介绍。
将寄存器的数值与另一寄存器或立即数的值相加并存回此寄存器 add reg32, reg32 / imm(8,16,32) add reg16, reg16 / imm(8,16) add reg8, reg8 / imm(8)
例如add eax, edx将eaxedx的值存入eax。减法指令和加法类似只是将add换成sub。
需要说明的是与高级语言不同汇编语言中如果要计算两数之和差、积、商或一般地说运算结果那么必然有一个寄存器被用来保存结果。在PASCAL中我们可以用nA : nB nC来让nA保存nBnC的结果然而汇编语言并不提供这种方法。如果你希望保持寄存器中的结果需要用另外的指令。这也从另一个侧面反映了“寄存器”这个名字的意义。数据只是“寄存”在那里。如果你需要保存数据那么需要将它放到内存或其他地方。
类似的指令还有and、or、xor与或异或等等。它们进行的是逻辑运算。
我们称add、mov、sub、and等称为为指令助记符这么叫是因为它比机器语言容易记忆而起作用就是方便人记忆某些资料中也称为指令、操作码、opcode[operation code]等后面的参数成为操作数一个指令可以没有操作数也可以有一两个操作数通常有一个操作数的指令这个操作数就是它的操作对象而两个参数的指令前一个操作数一般是保存操作结果的地方而后一个是附加的参数。
我不打算在这份教程中用大量的篇幅介绍指令——很多人做得比我更好而且指令本身并不是重点如果你学会了如何组织语句那么只要稍加学习就能轻易掌握其他指令。更多的指令可以参考 Intel 提供的资料。编写程序的时候也可以参考一些在线参考手册。Tech!Help和HelpPC 2.10尽管已经很旧但足以应付绝大多数需要。
聪明的读者也许已经发现使用sub eax, eax或者xor eax, eax可以得到与mov eax, 0类似的效果。在高级语言中你大概不会选择用aa-a来给a赋值因为测试会告诉你这么做更慢简直就是在自找麻烦然而在汇编语言中你会得到相反的结论多数情况下以由快到慢的速度排列这三条指令将是xor eax, eax、sub eax, eax和mov eax, 0。
为什么呢处理器在执行指令时需要经过几个不同的阶段取指、译码、取数、执行。
我们反复强调寄存器是CPU的一部分。从寄存器取数其速度很显然要比从内存中取数快。那么不难理解xor eax, eax要比mov eax, 0更快一些。
那么为什么aa-a通常要比a0慢一些呢这和编译器的优化有一定关系。多数编译器会把aa-a翻译成类似下面的代码(通常高级语言通过ebp和偏移量来访问局部变量程序中x为a相对于本地堆的偏移量在只包含一个32-bit整形变量的程序中这个值通常是4) mov eax, dword ptr [ebp-x] sub eax, dword ptr [ebp-x] mov dword ptr [ebp-x],eax
而把a0翻译成 mov dword ptr [ebp-x], 0
上面的翻译只是示意性的略去了很多必要的步骤如保护寄存器内容、恢复等等。如果你对与编译程序的实现过程感兴趣可以参考相应的书籍。多数编译器特别是C/C编译器如Microsoft Visual C都提供了从源代码到宏汇编语言程序的附加编译输出选项。这种情况下你可以很方便地了解编译程序执行的输出结果如果编译程序没有提供这样的功能也没有关系调试器会让你看到编译器的编译结果。
如果你明确地知道编译器编译出的结果不是最优的那就可以着手用汇编语言来重写那段代码了。怎么确认是否应该用汇编语言重写呢 使用汇编语言重写代码之前需要确认的几件事情 首先这种优化 最好 有 明显的效果 。比如一段循环中的计算等等。一条语句的执行时间是很短的现在新的CPU的指令周期都在0.000000001s以下Intel甚至已经做出了4GHz主频主频的倒数是时钟周期的CPU如果你的代码自始至终只执行一次并且你只是减少了几个时钟周期的执行时间那么改变将是无法让人察觉的很多情况下这种“优化”并不被提倡尽管它确实减少了执行时间但为此需要付出大量的时间、人力多数情况下得不偿失极端情况比如你的设备内存价格非常昂贵的时候这种优化也许会有意义。 其次确认你已经使用了 最好的算法 并且你优化的程序的实现是 正确 的。汇编语言能够提供同样算法的最快实现然而它并不是万金油更不是解决一切的灵丹妙药。用高级语言实现一种好的算法不一定会比汇编语言实现一种差的算法更慢。不过需要注意的是时间、空间复杂度最小的算法不一定就是解决某一特定问题的最佳算法。举例说快速排序在完全逆序的情况下等价于冒泡排序这时其他方法就比它快。同时用汇编语言优化一个不正确的算法实现将给调试带来很大的麻烦。 最后确认你 已经 将高级语言编译器的性能 发挥到极致 。Microsoft的编译器在RELEASE模式和DEBUG模式会有差异相当大的输出而对于GNU系列的编译器而言不同级别的优化也会生成几乎完全不同的代码。此外在编程时对于问题的严格定义可以极大地帮助编译器的优化过程。如何优化高级语言代码使其编译结果最优超出了本教程的范围但如果你不能确认已经发挥了编译器的最大效能用汇编语言往往是一种更为费力的方法。 还有一点非常重要那就是你明白自己做的是什么。 好的高级语言编译器有时会有一些让人难以理解的行为比如重新排列指令顺序等等。如果你发现这种情况那么优化的时候就应该小心——编译器很可能比你拥有更多的关于处理器的知识例如对于一个超标量处理器编译器会对指令序列进行“封包”使他们尽可能的并行执行此外宏汇编器有时会自动插入一些nop指令其作用是将指令凑成整数字长32-bit对于16-bit处理器是16-bit。这些都是提高代码性能的必要措施如果你不了解处理器那么最好不要改动编译器生成的代码因为这种情况下盲目的修改往往不会得到预期的效果。
曾经在一份杂志上看到过有人用纯机器语言编写程序。不清楚到底这是不是编辑的失误因为一个头脑正常的人恐怕不会这么做程序即使它不长、也不复杂。首先汇编器能够完成某些封包操作即使不行也可以用db伪指令来写指令用汇编语言写程序可以防止很多错误的发生同时它还减轻了人的负担很显然“完全用机器语言写程序”是完全没有必要的因为汇编语言可以做出完全一样的事情并且你可以依赖它因为计算机不会出错而人总有出错的时候。此外如前面所言如果用高级语言实现程序的代价不大例如这段代码在程序的整个执行过程中只执行一遍并且这一遍的执行时间也小于一秒那么为什么不用高级语言实现呢
一些比较狂热的编程爱好者可能不太喜欢我的这种观点。比方说他们可能希望精益求精地优化每一字节的代码。但多数情况下我们有更重要的事情例如你的算法是最优的吗你已经把程序在高级语言许可的范围内优化到尽头了吗并不是所有的人都有资格这样说。汇编语言是这样一件东西它足够的强大能够控制计算机完成它能够实现的任何功能同时因为它的强大也会提高开发成本并且难于维护。因此我个人的建议是如果在软件开发中使用汇编语言则应在软件接近完成的时候使用这样可以减少很多不必要的投入。
第二章中我介绍了x86系列处理器的基本寄存器。这些寄存器对于x86兼容处理器仍然是有效的如果你偏爱AMD的CPU那么使用这些寄存器的程序同样也可以正常运行。
不过现在说用汇编语言进行优化还为时尚早——不可能写程序而只操作这些寄存器因为这样只能完成非常简单的操作既然是简单的操作那可能就会让人觉得乏味甚至找一台足够快的机器穷举它的所有结果如果可以穷举的话并直接写程序调用因为这样通常会更快。但话说回来看完接下来的两章——内存和堆栈操作你就可以独立完成几乎所有的任务了配合第五章中断、第六章子程序的知识你将知道如何驾驭处理器并让它为你工作。 [dvnews_page简明x86汇编语言教程(4)[修订版]]
第三章 操作内存
在前面的章节中我们已经了解了寄存器的基本使用方法。而正如结尾提到的那样仅仅使用寄存器做一点运算是没有什么太大意义的毕竟它们不能保存太多的数据因此对编程人员而言他肯定迫切地希望访问内存以保存更多的数据。
我将分别介绍如何在保护模式和实模式操作内存然而在此之前我们先熟悉一下这两种模式中内存的结构。
3.1 实模式
事实上在实模式中内存比保护模式中的结构更令人困惑。内存被分割成段并且操作内存时需要指定段和偏移量。不过理解这些概念是非常容易的事情。请看下面的图 段-寄存器这种格局是早期硬件电路限制留下的一个伤疤。地址总线在当时有20-bit。
然而20-bit的地址不能放到16-bit的寄存器里这意味着有4-bit必须放到别的地方。因此为了访问所有的内存必须使用两个16-bit寄存器。
这一设计上的折衷方案导致了今天的段-偏移量格局。最初的设计中其中一个寄存器只有4-bit有效然而为了简化程序两个寄存器都是16-bit有效并在执行时求出加权和来标识20-bit地址。
偏移量是16-bit的因此一个段是64KB。下面的图可以帮助你理解20-bit地址是如何形成的 段-偏移量标识的地址通常记做 段:偏移量 的形式。
由于这样的结构一个内存有多个对应的地址。例如0000:0010和0001:0000指的是同一内存地址。又如
0000:1234 0123:0004 0120:0034 0100:0234 0001:1234 0124:0004 0120:0044 0100:0244
作为负面影响之一在段上加1相当于在偏移量上加16而不是一个“全新”的段。反之在偏移量上加16也和在段上加1等价。某些时候据此认为段的“粒度”是16字节。
练习题 尝试一下将下面的地址转化为20bit的地址
2EA8:D678 26CF:8D5F 453A:CFAD 2933:31A6 5924:DCCF 694E:175A 2B3C:D218 728F:6578 68E1:A7DC 57EC:AEEA
稍高一些的要求是写一个程序将段为AX、偏移量为BX的地址转换为20bit的地址并保存于EAX中。
[ 上面习题的答案 ]
我们现在可以写一个真正的程序了。
经典程序Hello, world
应该得到一个29字节的.com文件 .MODEL TINY .CODE CR equ 13 LF equ 10 TERMINATOR equ $ ORG 100h Main PROC mov dx,offset sMessage mov ah,9 int 21h mov ax,4c00h int 21h Main ENDP sMessage: DB Hello, World! DB CR,LF,TERMINATOR END Main .COM文件的内存模型是‘TINY 代码段开始 回车 换行 DOS字符串结束符 代码起始地址为CS:0100h 令DS:DX指向Message int 21h(DOS中断)功能9 - 显示字符串到标准输出设备 int 21h功能4ch - 终止程序并返回AL的错误代码 程序结束的同时指定入口点为Main
那么我们需要解释很多东西。
首先作为汇编语言的抽象C语言拥有“指针”这个数据类型。在汇编语言中几乎所有对内存的操作都是由对给定地址的内存进行访问来完成的。这样在汇编语言中绝大多数操作都要和指针产生或多或少的联系。
这里我想强调的是由于这一特性汇编语言中同样会出现C程序中常见的缓冲区溢出问题。如果你正在设计一个与安全有关的系统那么最好是仔细检查你用到的每一个串例如它们是否一定能够以你预期的方式结束以及如果使用的话你的缓冲区是否能保证实际可能输入的数据不被写入到它以外的地方。作为一个汇编语言程序员你有义务检查每一行代码的可用性。
程序中的equ伪指令是宏汇编特有的它的意思接近于C或Pascal中的const常量。多数情况下equ伪指令并不为符号分配空间。
此外汇编程序执行一项操作是非常繁琐的通常在对与效率要求不高的地方我们习惯使用系统提供的中断服务来完成任务。例如本例中的中断21h它是DOS时代的中断服务在Windows中它也被认为是Windows API的一部分这一点可以在Microsoft的文档中查到。中断可以被理解为高级语言中的子程序但又不完全一样——中断使用系统栈来保存当前的机器状态可以由硬件发起通过修改机器状态字来反馈信息等等。
那么最后一段通过DB存放的数据到底保存在哪里了呢答案是紧挨着代码存放。在汇编语言中DB和普通的指令的地位是相同的。如果你的汇编程序并不知道新的助记符例如新的处理器上的CPUID指令而你很清楚那么可以用DB 机器码的方式强行写下指令。这意味着你可以超越汇编器的能力撰写汇编程序然而直接用机器码编程是几乎肯定是一件费力不讨好的事——汇编器厂商会经常更新它所支持的指令集以适应市场需要而且你可以期待你的汇编其能够产生正确的代码因为机器查表是不会出错的。既然机器能够帮我们做将程序转换为代码这件事情那么为什么不让它来做呢
细心的读者不难发现在程序中我们没有对DS进行赋值。那么这是否意味着程序的结果将是不可预测的呢答案是否定的。DOS或Windows中的MS-DOS VM在加载.com文件的时候会对寄存器进行很多初始化。.com文件被限制为小于64KB这样它的代码段、数据段都被装入同样的数值即初始状态下DSCS。
也许会有人说“嘿这听起来不太好一个64KB的程序能做得了什么呢还有你吹得天花乱坠的堆栈段在什么地方”那么我们来看看下面这个新的Hello world程序它是一个EXE文件在DOS实模式下运行。
应该得到一个561 字节的EXE文件 .MODEL SMALL .STACK 200h CR equ 13 LF equ 10 TERMINATOR equ $ .DATA Message DB Hello, World ! DB CR,LF,TERMINATOR .CODE Main PROC mov ax, DGROUP mov ds, ax mov dx, offset Message mov ah, 9 int 21h mov ax, 4c00h int 21h Main ENDP END main 采用“SMALL”内存模型 堆栈段 回车 换行 DOS字符串结束符 定义数据段 定义显示串 定义代码段 将数据段 加载到DS寄存器 设置DX 显示 终止程序
561字节实现相同功能的程序大了这么多为什么呢我们看到程序拥有了完整的堆栈段、数据段、代码段其中堆栈段足足占掉了512字节其余的基本上没什么变化。
分成多个段有什么好处呢首先它让程序显得更加清晰——你肯定更愿意看一个结构清楚的程序代码中hard-coded的字符串、数据让人觉得费解。比如mov dx, 0152h肯定不如mov dx, offset Message来的亲切。此外通过分段你可以使用更多的内存比如代码段腾出的空间可以做更多的事情。exe文件另一个吸引人的地方是它能够实现“重定位”。现在你不需要指定程序入口点的地址了因为系统会找到你的程序入口点而不是死板的100h。
程序中的符号也会在系统加载的时候重新赋予新的地址。exe程序能够保证你的设计容易地被实现不需要考虑太多的细节。
当然我们的主要目的是将汇编语言作为高级语言的一个有用的补充。如我在开始提到的那样真正完全用汇编语言实现的程序不一定就好因为它不便于维护而且由于结构的原因你也不太容易确保它是正确的汇编语言是一种非结构化的语言调试一个精心设计的汇编语言程序即使对于一个老手来说也不啻是一场恶梦因为你很可能掉到别人预设的“陷阱”中——这些技巧确实提高了代码性能然而你很可能不理解它于是你把它改掉接着就发现程序彻底败掉了。使用汇编语言加强高级语言程序时你要做的通常只是使用汇编指令而不必搭建完整的汇编程序。绝大多数也是目前我遇到的全部C/C编译器都支持内嵌汇编即在程序中使用汇编语言而不必撰写单独的汇编语言程序——这可以节省你的不少精力因为前面讲述的那些伪指令如equ等都可以用你熟悉的高级语言方式来编写编译器会把它转换为适当的形式。
需要说明的是在高级语言中一定要注意编译结果。编译器会对你的汇编程序做一些修改这不一定符合你的要求附带说一句有时编译器会很聪明地调整指令顺序来提高性能这种情况下最好测试一下哪种写法的效果更好此时需要做一些更深入的修改或者用db来强制编码。
3.2 保护模式
实模式的东西说得太多了尽管我已经删掉了许多东西并把一些原则性的问题拿到了这一节讨论。这样做不是没有理由的——保护模式才是现在的程序除了操作系统的底层启动代码最常用的CPU模式。保护模式提供了很多令人耳目一新的功能包括内存保护这是保护模式这个名字的来源、进程支持、更大的内存支持等等。
对于一个编程人员来说能“偷懒”是一件令人愉快的事情。这里“偷懒”是说把“应该”由系统做的事情做的事情全都交给系统。为什么呢这出自一个基本思想——人总有犯错误的时候然而规则不会正确地了解规则之后你可以期待它像你所了解的那样执行。对于C程序来说你自己用C语言写的实现相同功能的函数通常没有系统提供的函数性能好除非你用了比函数库好很多的算法因为系统的函数往往使用了更好的优化甚至可能不是用C语言直接编写的。
当然“偷懒”的意思是说把那些应该让机器做的事情交给计算机来做因为它做得更好。我们应该把精力集中到设计算法而不是编写源代码本身上因为编译器几乎只能做等价优化而实现相同功能但使用更好算法的程序实现则几乎只能由人自己完成。
举个例子这样一个函数
int fun(){ int a0; register int i; for (i0; i1000; i) ai; return a; }
在某种编译模式[DEBUG]下被编译为
push ebp mov ebp,esp sub esp,48h push ebx push esi push edi lea edi,[ebp-48h] mov ecx,12h mov eax,0CCCCCCCCh rep stos dword ptr [edi] mov dword ptr [ebp-4],0 mov dword ptr [ebp-8],0 jmp fun31h mov eax,dword ptr [ebp-8] add eax,1 mov dword ptr [ebp-8],eax cmp dword ptr [ebp-8],3E8h jge fun45h mov ecx,dword ptr [ebp-4] add ecx,dword ptr [ebp-8] mov dword ptr [ebp-4],ecx jmp fun28h mov eax,dword ptr [ebp-4] pop edi pop esi pop ebx mov esp,ebp pop ebp ret 子程序入口 保护现场 初始化变量-调试版本特有。 本质是在堆中挖一块地儿存CCCCCCCC。 用串操作进行这将发挥Intel处理器优势 ‘a0 ‘i0 走着 i i1000? ai; return a; 恢复现场 返回
而在另一种模式[RELEASE/MINSIZE]下却被编译为
xor eax,eax xor ecx,ecx add eax,ecx inc ecx cmp ecx,3E8h jl fun4 ret a0; i0; ai; i; i1000? 是-继续继续 return a
如果让我来写多半会写成
mov eax, 079f2ch ret return 499500
为什么这样写呢我们看到i是一个外界不能影响、也无法获知的内部状态量。作为这段程序来说对它的计算对于结果并没有直接的影响——它的存在不过是方便算法描述而已。并且我们看到的这段程序实际上无论执行多少次其结果都不会发生变化因此直接返回计算结果就可以了计算是多余的如果说一定要算那么应该是编译器在编译过程中完成它。
更进一步我们甚至希望编译器能够直接把这个函数变成一个符号常量这样连操作堆栈的过程也省掉了。
第三种结果属于“等效”代码而不是“等价”代码。作为用户很多时候是希望编译器这样做的然而由于目前的技术尚不成熟有时这种做法会造成一些问题gcc和g的顶级优化可以造成编译出的FreeBSD内核行为异常这是我在FreeBSD上遇到的唯一一次软件原因的kernel panic因此并不是所有的编译器都这样做另一方面的原因是如果编译器在这方面做的太过火例如自动求解全部“固定”问题那么如果你的程序是解决固定的问题“很大”如求解迷宫那么在编译过程中你就会找锤子来砸计算机了。然而作为编译器制造商为了提高自己的产品的竞争力往往会使用第三种代码来做函数库。正如前面所提到的那样这种优化往往不是编译器本身的作用尽管现代编译程序拥有编译执行、循环代码外提、无用代码去除等诸多优化功能但它都不能保证程序最优。最后一种代码恐怕很少有编译器能够做到不信你可以用自己常用的编译器加上各种优化选项试试:)
发现什么了吗三种代码中对于内存的访问一个比一个少。这样做的理由是尽可能地利用寄存器并减少对内存的访问可以提高代码性能。在某些情况下使代码既小又快是可能的。
书归正传我们来说说保护模式的内存模型。保护模式的内存和实模式有很多共同之处。 毫无疑问以protected mode(保护模式), global descriptor table(全局描述符表), local descriptor table(本地描述符表)和selector(选择器)搜索你会得到完整介绍它们的大量信息。
保护模式与实模式的内存类似然而它们之间最大的区别就是保护模式的内存是“线性”的。
新的计算机上32-bit的寄存器已经不是什么新鲜事如果你哪天听说你的CPU的寄存器不是32-bit的那么它——简直可以肯定地说——的字长要比32-bit还要多。新的个人机上已经开始逐步采用64-bit的CPU了换言之实际上段/偏移量这一格局已经不再需要了。尽管如此在继续看保护模式内存结构时仍请记住段/偏移量的概念。不妨把段寄存器看作对于保护模式中的选择器的一个模拟。选择器是全局描述符表(Global Descriptor Table, GDT)或本地描述符表(Local Descriptor Table, LDT)的一个指针。
如图所示GDT和LDT的每一个项目都描述一块内存。例如一个项目中包含了某块被描述的内存的物理的基地址、长度以及其他一些相关信息。
保护模式是一个非常重要的概念同时也是目前撰写应用程序时最常用的CPU模式运行在新的计算机上的操作系统很少有在实模式下运行的。
为什么叫保护模式呢它“保护”了什么答案是进程的内存。保护模式的主要目的在于允许多个进程同时运行并保护它们的内存不受其他进程的侵犯。这有点类似于C中的机制然而它的强制力要大得多。如果你的进程在保护模式下以不恰当的方式访问了内存例如写了“只读”内存或读了不可读的内存等等那么CPU就会产生一个异常。这个异常将交给操作系统处理而这种处理假如你的程序没有特别说明操作系统该如何处理的话一般就是杀掉做错了事情的进程。
我像这样的对话框大家一定非常熟悉临时写了一个程序故意造成的错误 好的只是一个程序崩溃了而操作系统的其他进程照常运行同样的程序在DOS中几乎是板上钉钉的死机因为NULL指针的位置恰好是中断向量表你甚至还可以调试它。
保护模式还有其他很多好处在此就不一一赘述了。实模式和保护模式之间的切换问题我打算放在后面的“高级技巧”一章来讲因为多数程序并不涉及这个。
了解了内存的格局我们就可以进入下一节——操作内存了。
3.3 操作内存
前两节中我们介绍了实模式和保护模式中使用的不同的内存格局。现在开始解释如何使用这些知识。
回忆一下前面我们说过的寄存器可以用作内存指针。现在是他们发挥作用的时候了。
可以将内存想象为一个顺序的字节流。使用指针可以任意地操作读写内存。
现在我们需要一些其他的指令格式来描述对于内存的操作。操作内存时首先需要的就是它的地址。
让我们来看看下面的代码
mov ax,[0]
方括号表示里面的表达式指定的不是立即数而是偏移量。在实模式中DS:0中的那个字16-bit长将被装入AX。
然而0是一个常数如果需要在运行的时候加以改变就需要一些特殊的技巧比如程序自修改。汇编支持这个特性然而我个人并不推荐这种方法——自修改大大降低程序的可读性并且还降低稳定性性能还不一定好。我们需要另外的技术。
mov bx,0 mov ax,[bx]
看起来舒服了一些不是吗BX寄存器的内容可以随时更改而不需要用冗长的代码去修改自身更不用担心由此带来的不稳定问题。
同样的mov指令也可以把数据保存到内存中
mov [0],ax
在存储器与寄存器之间交换数据应该足够清楚了。
有些时候我们会需要操作符来描述内存数据的宽度
操作符 意义 byte ptr 一个字节(8-bit, 1 byte) word ptr 一个字(16-bit) dword ptr 一个双字(32-bit)
例如在DS:100h处保存1234h以字存放
mov word ptr [100h],01234h
于是我们将mov指令扩展为
mov reg(8,16,32), mem(8,16,32) mov mem(8,16,32), reg(8,16,32) mov mem(8,16,32), imm(8,16,32)
需要说明的是加减同样也可以在[]中使用例如
mov ax,[bx10] mov ax,[bxsi] mov ax,es:[dibp]
等等。我们看到对于内存的操作即使使用MOV指令也有许多种可能的方式。下一节中我们将介绍如何操作串。
感谢 网友 水杉 指出此答案中的一处错误。 感谢 Heallven 指出.COM程序实例编译失败的问题 [dvnews_page简明x86汇编语言教程(5)]
3.4 串操作
我们前面已经提到内存可以和寄存器交换数据也可以被赋予立即数。问题是如果我们需要把内存的某部分内容复制到另一个地址又怎么做呢
设想将DS:SI处的连续512字节内容复制到ES:DI先不考虑可能的重叠。也许会有人写出这样的代码
NextByte: mov cx,512 mov al,ds:[si] mov es:[di],al inc si inc di loop NextByte 循环次数
我不喜欢上面的代码。它的确能达到作用但是效率不好。如果你是在做优化那么写出这样的代码意味着赔了夫人又折兵。
Intel的CPU的强项是串操作。所谓串操作就是由CPU去完成某一数量的、重复的内存操作。需要说明的是我们常用的KMP算法用于匹配字符串中的模式的改进——Boyer算法由于没有利用串操作因此在Intel的CPU上的效率并非最优。好的编译器往往可以利用Intel CPU的这一特性优化代码然而并非所有的时候它都能产生最好的代码。
某些指令可以加上REP前缀repeat, 反复之意这些指令通常被叫做串操作指令。
举例来说STOSD指令将EAX的内容保存到ES:DI同时在DI上加或减四。类似的STOSB和STOSW分别作1字节或1字的上述操作在DI上加或减的数是1或2。
计算机语言通常是不允许二义性的。为什么我要说“加或减”呢没错孤立地看STOS?指令并不能知道到底是加还是减因为这取决于“方向”标志(DF, Direction Flag)。如果DF被复位则加反之则减。
置位、复位的指令分别是STD和CLD。
当然REP只是几种可用前缀之一。常用的还包括REPNE这个前缀通常被用来比较两个串或搜索某个特定字符字、双字。REPZ、REPE、REPNZ也是非常常用的指令前缀分别代表ZF(Zero Flag)在不同状态时重复执行。
下面说三个可以复制数据的指令
助记符 意义 movsb 将DS:SI的一字节复制到ES:DI之后SI、DI movsw 将DS:SI的一字节复制到ES:DI之后SI2、DI2 movsd 将DS:SI的一字节复制到ES:DI之后SI4、DI4
于是上面的程序改写为
cld mov cx, 128 rep movsd 复位DF 512/4 128共128个双字 行动
第一句cld很多时候是多余的因为实际写程序时很少会出现置DF的情况。不过在正式决定删掉它之前建议你仔细地调试自己的程序并确认每一个能够走到这里的路径中都不会将DF置位。
错误非预期的的DF是危险的。它很可能断送掉你的程序因为这直接造成 缓冲区溢出 问题。
什么是缓冲区溢出呢缓冲区溢出分为两类一类是写入缓冲区以外的内容一类是读取缓冲区以外的内容。后一种往往更隐蔽但随便哪一个都有可能断送掉你的程序。
缓冲区溢出对于一个网络服务来说很可能更加危险。怀有恶意的用户能够利用它执行自己希望的指令。服务通常拥有更高的特权而这很可能会造成特权提升即使不能提升攻击者拥有的特权他也可以利用这种问题使服务崩溃从而形成一次成功的DoS拒绝服务攻击。每年CERT的安全公告中都有6成左右的问题是由于缓冲区溢出造成的。
在使用汇编语言或C语言编写程序时很容易在无意中引入缓冲区溢出。然而并不是所有的语言都会引入缓冲区溢出问题Java和C#由于没有指针并且缓冲区采取动态分配的方式有效地消除了造成缓冲区溢出的土壤。
汇编语言中由于REP*前缀都用CX作为计数器因此情况会好一些当然有时也会更糟糕因为由于CX的限制很可能使原本可能改变程序行为的缓冲区溢出的范围缩小从而更为隐蔽。避免缓冲区溢出的一个主要方法就是仔细检查这包括两方面设置合理的缓冲区大小和根据大小编写程序。除此之外非常重要的一点就是在汇编语言这个级别写程序你肯定希望去掉所有的无用指令然而再去掉之前一定要进行严格的测试更进一步如果能加上注释并通过善用宏来做调试模式检查往往能够达到更好的效果。
3.5 关于保护模式中内存操作的一点说明
正如3.2节提到到的那样保护模式中你可以使用32位的线性地址这意味着直接访问4GB的内存。由于这个原因选择器不用像实模式中段寄存器那样频繁地修改。顺便提一句这份教程中所说的保护模式指的是386以上的保护模式或者Microsoft通常称为“增强模式”的那种。
在为选择器装入数值的时候一定要非常小心。错误的数值往往会导致无效页面错误(在Windows中经常出现:)。同时也不要忘记你的地址是32位的这也是保护模式的主要优势之一。
现在假设存在一个描述符描述从物理的0:0开始的全部内存并已经加载进DS(数据选择器)则我们可以通过下面的程序来操作VGA的VRAM
mov edi,0a0000h mov byte ptr [edi],0fh VGA显存的偏移量 将第一字节改为0fh
很明显这比实模式下的程序
mov ax,0a000h mov ds,ax mov di,0 mov [di],0fh AX - VGA段地址 将AX值载入DS DI清零 修改第一字节
看上去要舒服一些。
3.6 堆栈
到目前为止您已经了解了基本的寄存器以及内存的操作知识。事实上您现在已经可以写出很多的底层数据处理程序了。
下面我来说说堆栈。堆栈实在不是一个让人陌生的数据结构它是一个 先进后出 (FILO)的线性表能够帮助你完成很多很好的工作。 先进后出 (FILO)是这样一个概念 最后 放进表中 的数据在取出时 最先 出来。 先进后出 (FILO)和 先 进先出 (FIFO, 和先进后出的规则相反)以及 随 机存取 是最主要的三种存储器访问方式。
对于堆栈而言最后放入的数据在取出时最先出 现。对于子程序调用特别是递归调用来说这 是一个非常有用的特性。
一个铁杆的汇编语言程序员有时会发现系统提供的寄存器不够。很显然你可以使用普通的内存操作来完成这个工作就像C/C中所做的那样。
没错没错可是如果数据段数据选择器以及偏移量发生变化怎么办更进一步如果希望保存某些在这种操作中可能受到影响的寄存器的时候怎么办确实你可以把他们也存到自己的那片内存中自己实现堆栈。
太麻烦了……
既然系统提供了堆栈并且性能比自己写一份更好那么为什么不直接加以利用呢
系统堆栈不仅仅是一段内存。由于CPU对它实施管理因此你不需要考虑堆栈指针的修正问题。可以把寄存器内容甚至一个立即数直接放到堆栈里并在需要的时候将其取出。同时系统并不要求取出的数据仍然回到原来的位置。
除了显式地操作堆栈使用PUSH和POP指令之外很多指令也需要使用堆栈如INT、CALL、LEAVE、RET、RETF、IRET等等。配对使用上述指令并不会造成什么问题然而如果你打算使用LEAVE、RET、RETF、IRET这样的指令实现跳转(比JMP更为麻烦然而有时例如在加密软件中或者需要修改调用者状态时这是必要的)的话那么我的建议是先搞清楚它们做的到底是什么并且精确地了解自己要做什么。
正如前面所说的有两个显式地操作堆栈的指令
助记符 功能 PUSH 将操作数存入堆栈同时修正堆栈指针 POP 将栈顶内容取出并存到目的操作数中同时修正堆栈指针
我们现在来看看堆栈的操作。
执行之前 执行代码
mov ax,1234h mov bx,10 push ax push bx
之后堆栈的状态为 之后再执行
pop dx pop cx
堆栈的状态成为 当然dx、cx中的内容将分别是000ah和1234h。
注意最后这张图中我没有抹去1234h和000ah因为POP指令并不从内存中抹去数值。不过尽管如此我个人仍然非常反对继续使用这两个数你可以通过修改SP来再次POP它们然而这很容易导致错误。
一定要保证堆栈段有足够的空间来执行中断以及其他一些隐式的堆栈操作。仅仅统计PUSH的数量并据此计算堆栈所需的大小很可能造成问题。
CALL指令将返回地址放到堆栈中。绝大多数C/C编译器提供了“堆栈检查”这个编译选项其作用在于保证C程序段中没有忘记对堆栈中多余的数据进行清理从而保证返回地址有效。
本章小结
本章中介绍了内存的操作的一些入门知识。限于篇幅我不打算展开细讲指令如cmps*lods*stos*等等。这些指令的用法和前面介绍的movs*基本一样只是有不同的作用而已。 [dvnews_page简明x86汇编语言教程(6)]
4.0 利用子程序与中断
已经掌握了汇编语言没错你现在已经可以去破译别人代码中的秘密。然而我们还有一件重要的东西没有提到那就是自程序和中断。这两件东西是如此的重要以至于你的程序几乎不可能离开它们。
4.1 子程序
在高级语言中我们经常要用到子程序。高级语言中子程序是如此的神奇我们能够定义和主程序或其他子程序一样的变量名而访问不同的变量并且还不和程序的其他部分相冲突。
然而遗憾的是这种“优势”在汇编语言中是不存在的。
汇编语言并不注重如何减轻程序员的负担相反汇编语言依赖程序员的良好设计以期发挥CPU的最佳性能。汇编语言不是结构化的语言因此它不提供直接的“局部变量”。如果需要“局部变量”只能通过堆或栈自行实现。
从这个意义上讲汇编语言的子程序更像GWBASIC中的GOSUB调用的那些“子程序”。所有的“变量”(本质上属于进程的内存和寄存器)为整个程序所共享高级语言编译器所做的将局部变量放到堆或栈中的操作只能自行实现。
参数的传递是靠寄存器和堆栈来完成的。高级语言中子程序(函数、过程或类似概念的东西)依赖于堆和栈来传递。
让我们来简单地分析一下一般高级语言的子程序的执行过程。无论C、C、BASIC、Pascal这一部分基本都是一致的。 调用者将子程序执行完成时应返回的地址、参数压入堆栈 子程序使用BP指针偏移量对栈中的参数寻址并取出、完成操作 子程序使用RET或RETF指令返回。此时CPU将IP置为堆栈中保存的地址并继续予以执行
毋庸置疑堆栈在整个过程中发挥着非常重要的作用。不过本质上对子程序最重要的还是返回地址。如果子程序不知道这个地址那么系统将会崩溃。
调用子程序的指令是CALL对应的返回指令是RET。此外还有一组指令即ENTER和LEAVE它们可以帮助进行堆栈的维护。
CALL指令的参数是被调用子程序的地址。使用宏汇编的时候这通常是一个标号。CALL和RET以及ENTER和LEAVE配对可以实现对于堆栈的自动操作而不需要程序员进行PUSH/POP以及跳转的操作从而提高了效率。
作为一个编译器的实现实例我用Visual C编译了一段C程序代码这段汇编代码是使用特定的编译选项得到的结果正常的RELEASE代码会比它精简得多。包含源代码的部分反汇编结果如下(取自Visual C调试器的运行结果我删除了10条int 3指令并加上了一些注释除此之外没有做任何修改)
1: int myTransform( int nInput){ 00401000 push ebp ; 保护现场原先的EBP指针 00401001 mov ebp,esp 2: return (nInput*2 3) % 7; 00401003 mov eax,dword ptr [nInput] ; 取参数 00401006 lea eax,[eaxeax3] ; LEA比ADD加法更快 0040100A cdq ; DWORD-QWORD(扩展字长) 0040100B mov ecx,7 ; 除数 00401010 idiv eax,ecx ; 除 00401012 mov eax,edx ; 商-eax(eax中保存返回值) 3: } 00401014 pop ebp ; 恢复现场的ebp指针 00401015 ret ; 返回 此处删除10条int 3指令它们是方便调试用的并不影响程序行为。 4: 5: int main( int argc, char * argv[]) 6: { 00401020 push ebp ; 保护现场原先的EBP指针 00401021 mov ebp,esp 00401023 sub esp,10h ; 为取argc, argv修正堆栈指针。 7: int a[3]; 8: for ( register int i0; i3; i){ 00401026 mov dword ptr [i],0 ; 0-i 0040102D jmp main18h (00401038) ; 判断循环条件 0040102F mov eax,dword ptr [i] ; i-eax 00401032 add eax,1 ; eax 00401035 mov dword ptr [i],eax ; eax-i 00401038 cmp dword ptr [i],3 ; 循环条件: i与3比较 0040103C jge main33h (00401053) ; 如果不符合条件则应结束循环 9: a[i] myTransform(i); 0040103E mov ecx,dword ptr [i] ; i-ecx 00401041 push ecx ; ecx (i) - 堆栈 00401042 call myTransform (00401000) ; 调用myTransform 00401047 add esp,4 ; esp4: 在堆中的新单元 准备存放返回结果 0040104A mov edx,dword ptr [i] ; i-edx 0040104D mov dword ptr a[edx*4],eax ; 将eax(myTransform返回值) 放回a[i] 10: } 00401051 jmp main0Fh (0040102f) ; 计算i并继续循环 11: return 0; 00401053 xor eax,eax ; 返回值应该是0 12: } 00401055 mov esp,ebp ; 恢复堆栈指针 00401057 pop ebp ; 恢复BP 00401058 ret ; 返回调用者(C运行环境)
上述代码确实做了一些无用功当然这是因为编译器没有对这段代码进行优化。让我们来关注一下这段代码中是如何调用子程序的。不考虑myTransform这个函数实际进行的数值运算最让我感兴趣的是这一行代码
00401003 mov eax,dword ptr [nInput] ; 取参数
这里nInput是一个简简单单的变量符号吗Visual C的调试器显然不能告诉我们答案——它的设计目标是为了方便程序调试而不是向你揭示编译器生成的代码的实际构造。我用另外一个反汇编器得到的结果是
00401003 mov eax,dword ptr [ebp8] ; 取参数
这和我们在main()中看到的压栈顺序是完全吻合的(注意程序运行到这个地方的时候EBPESP)。main()最终将i的 值 通过堆栈传递给了myTransform()。
剖析上面的程序只是说明了我前面所提到的子程序的一部分用法。对于汇编语言来说完全没有必要拘泥于结构化程序设计的框架(在今天使用汇编的主要目的在于提高执行效率而不是方便程序的维护和调试因为汇编不可能在这一点上做得比C更好)。考虑下面的程序 void myTransform1( int nCount, char * sBytes){ for ( register int i1; isBytes[i] sBytes[i-1]; for (i0; isBytes[i] 1; } void myTransform2( int nCount, char * sBytes){ for ( register int i0; isBytes[i] 1; }
很容易看出这两个函数包含了公共部分即
for (i0; isBytes[i] 1;
目前还没有编译器能够做到将这两部分合并。依然沿用刚才的编译选项得到的反汇编结果是(同样地删除了int 3)
1: void myTransform1( int nCount, char * sBytes){ 00401000 push ebp 00401001 mov ebp,esp 00401003 push ecx 2: for ( register int i1; i00401004 mov dword ptr [i],1 0040100B jmp myTransform116h (00401016) 0040100D mov eax,dword ptr [i] 00401010 add eax,1 00401013 mov dword ptr [i],eax 00401016 mov ecx,dword ptr [i] 00401019 cmp ecx,dword ptr [nCount] 0040101C jge myTransform13Dh (0040103d) 3: sBytes[i] sBytes[i-1]; 0040101E mov edx,dword ptr [sBytes] 00401021 add edx,dword ptr [i] 00401024 movsx eax,byte ptr [edx-1] 00401028 mov ecx,dword ptr [sBytes] 0040102B add ecx,dword ptr [i] 0040102E movsx edx,byte ptr [ecx] 00401031 add edx,eax 00401033 mov eax,dword ptr [sBytes] 00401036 add eax,dword ptr [i] 00401039 mov byte ptr [eax],dl 0040103B jmp myTransform10Dh (0040100d) 4: for (i0; i0040103D mov dword ptr [i],0 00401044 jmp myTransform14Fh (0040104f) 00401046 mov ecx,dword ptr [i] 00401049 add ecx,1 0040104C mov dword ptr [i],ecx 0040104F mov edx,dword ptr [i] 00401052 cmp edx,dword ptr [nCount] 00401055 jge myTransform16Bh (0040106b) 5: sBytes[i] 1; 00401057 mov eax,dword ptr [sBytes] 0040105A add eax,dword ptr [i] 0040105D mov cl,byte ptr [eax] 0040105F shl cl,1 00401061 mov edx,dword ptr [sBytes] 00401064 add edx,dword ptr [i] 00401067 mov byte ptr [edx],cl 00401069 jmp myTransform146h (00401046) 6: } 0040106B mov esp,ebp 0040106D pop ebp 0040106E ret 7: 8: void myTransform2( int nCount, char * sBytes){ 00401070 push ebp 00401071 mov ebp,esp 00401073 push ecx 9: for ( register int i0; i00401074 mov dword ptr [i],0 0040107B jmp myTransform216h (00401086) 0040107D mov eax,dword ptr [i] 00401080 add eax,1 00401083 mov dword ptr [i],eax 00401086 mov ecx,dword ptr [i] 00401089 cmp ecx,dword ptr [nCount] 0040108C jge myTransform232h (004010a2) 10: sBytes[i] 1; 0040108E mov edx,dword ptr [sBytes] 00401091 add edx,dword ptr [i] 00401094 mov al,byte ptr [edx] 00401096 shl al,1 00401098 mov ecx,dword ptr [sBytes] 0040109B add ecx,dword ptr [i] 0040109E mov byte ptr [ecx],al 004010A0 jmp myTransform20Dh (0040107d) 11: } 004010A2 mov esp,ebp 004010A4 pop ebp 004010A5 ret 12: 13: int main( int argc, char * argv[]) 14: { 004010B0 push ebp 004010B1 mov ebp,esp 004010B3 sub esp,0CCh 15: char a[200]; 16: for ( register int i0; i200; i)a[i]i; 004010B9 mov dword ptr [i],0 004010C3 jmp main24h (004010d4) 004010C5 mov eax,dword ptr [i] 004010CB add eax,1 004010CE mov dword ptr [i],eax 004010D4 cmp dword ptr [i],0C8h 004010DE jge main45h (004010f5) 004010E0 mov ecx,dword ptr [i] 004010E6 mov dl,byte ptr [i] 004010EC mov byte ptr a[ecx],dl 004010F3 jmp main15h (004010c5) 17: myTransform1(200, a); 004010F5 lea eax,[a] 004010FB push eax 004010FC push 0C8h 00401101 call myTransform1 (00401000) 00401106 add esp,8 18: myTransform2(200, a); 00401109 lea ecx,[a] 0040110F push ecx 00401110 push 0C8h 00401115 call myTransform2 (00401070) 0040111A add esp,8 19: return 0; 0040111D xor eax,eax 20: } 0040111F mov esp,ebp 00401121 pop ebp 00401122 ret
非常明显地0040103d-0040106e和00401074-004010a5这两段代码存在少量的差别但很显然只是对寄存器的偏好不同(编译器在优化时这可能会减少堆栈操作从而提高性能但在这里只是使用了不同的寄存器而已)
对代码进行合并的好处是非常明显的。新的操作系统往往使用页式内存管理。当内存不足时程序往往会频繁引发页面失效(Page faults)从而引发操作系统从磁盘中读取一些东西。磁盘的速度赶不上内存的速度因此这一行为将导致性能的下降。通过合并一部分代码可以减少程序的大小这意味着减少页面失效的可能性从而软件的性能会有所提高?/p
当然这样做的代价也不算低——你的程序将变得难懂并且难于维护。因此再进行这样的优化之前一定要注意 优化前的程序 必须 是正确的。如果你不能确保这一点那么这种优化必将给你的调试带来极大的麻烦。 优化前的程序实现 最好 是最优的。仔细检查你的设计看看是否已经使用了最合适(即对于此程序而言最优)的算法并且已经在高级语言许可的范围内进行了最好的实现。 优化 最好 能够非常有效地减少程序大小(例如如果只是减少十几个字节恐怕就没什么必要了)或非常有效地提高程序的运行速度(如果代码只是运行一次并且只是节省几个时钟周期那么在多数场合都没有意义)。否则这种优化将得不偿失。
4.2 中断
中断应该说是一个陈旧的话题。在新的系统中它的作用正在逐渐被削弱而变成操作系统专用的东西。并不是所有的计算机系统都提供中断然而在x86系统中它的作用是不可替代的。
中断实际上是一类特殊的子程序。它通常由系统调用以响应突发事件。
例如进行磁盘操作时为了提高性能可能会使用DMA方式进行操作。CPU向DMA控制器发出指令要求外设和内存直接交换数据而不通过CPU。然后CPU转去进行起他的操作当数据交换结束时CPU可能需要进行一些后续操作但此时它如何才能知道DMA已经完成了操作呢
很显然不是依靠CPU去查询状态——这样DMA的优势就不明显了。为了尽可能地利用DMA的优势在完成DMA操作的时候DMA会告诉CPU“这事儿我办完了”然后CPU会根据需要进行处理。
这种处理可能很复杂需要若干条指令来完成。子程序是一个不错的主意不过CALL指令需要指定地址让外设强迫CPU执行一条CALL指令也违背了CPU作为核心控制单元的设计初衷。考虑到这些在x86系统中引入了中断向量的概念。
中断向量表是保存在系统数据区(实模式下是0:0开始的一段区域)的一组指针。这组指针指向每一个中断服务程序的地址。整个中断向量表的结构是一个线性表。
每一个中断服务有自己的唯一的编号我们通常称之为中断号。每一个中断号对应中断向量表中的一项也就是一个中断向量。外设向CPU发出中断请求而CPU自己将根据当前的程序状态决定是否中断当前程序并调用相应的中断服务。
不难根据造成中断的原因将中断分为两类硬件中断和软件中断。硬件中断有很多分类方法如根据是否可以屏蔽分类、根据优先级高低分类等等。考虑到这些分类并不一定科学并且对于我们介绍中断的使用没有太大的帮助因此我并不打算太详细地介绍它(在本教程的高级篇中关于加密解密的部分会提到某些硬件中断的利用但那是后话)。
在设计操作系统时中断向量的概念曾经带来过很大的便利。操作系统随时可能升级这样通过CALL来调用操作系统的服务(如果说每个程序都包含对于文件系统、进程表这些应该由操作系统管理的数据的直接操作的话不仅会造成程序的臃肿而且不利于系统的安全)就显得不太合适了——没人能知道以后的操作系统的服务程序入口点会不会是那儿。软件中断的存在为解决这个问题提供了方便。
对于一台包含了BIOS的计算机来说启动的时候系统已经提供了一部分服务例如显示服务。无论你的BIOS、显示卡有多么的“个性”只要他们和IBM PC兼容那么此时你肯定可以通过调用16(10h)号中断来使用显示服务。调用中断的指令是 int 中断号
这将引发CPU去调用一个中断。CPU将保存当前的程序状态字清除Trap和Interrupt两个标志将即将执行的指令地址压入堆栈并调用中断服务(根据中断向量表)。
编写中断服务程序不是一件容易的事情。很多时候中断服务程序必须写成 可重入代码 (或纯代码pure code)。所谓可重入代码是指程序的运行过程中可以被打断并由开始处再次执行并且在合理的范围内(多次重入而不造成堆栈溢出等其他问题)程序可以在被打断处继续执行并且执行结果不受影响。
由于在多线程环境中等其他一些地方进行程序设计时也需要考虑这个因素因此这里着重讲一下可重入代码的编写。
可重入代码最主要的要求就是程序不应使用某个指定的内存地址的内存(对于高级语言来说这通常是全局变量或对象的成员)。如果可能的话应使用寄存器或其他方式来解决。如果不能做到这一点则必须在开始、结束的时候分别禁止和启用中断并且运行时间不能太长。
下面用C语言分别举一个可重入函数和两个非可重入函数的例子(注. 这些例子应该是在某本多线程或操作系统的书上看到的遗憾的是我想不起来是哪本书了在这里先感谢那位作者提供的范例)
可重入函数
void strcpy( char * lpszDest, char * lpszSrc){ while (*dest*src); *dest0; }
非可重入函数
char cTemp; // 全局变量 void SwapChar( char * lpcX, char * lpcY){ cTemp *lpcX; *lpcX *lpcY; lpcY cTemp; // 引用了全局变量在分享内存的多个线程中可能造成问题 }
非可重入函数
void SwapChar2( char * lpcX, char * lpcY){ static char cTemp; // 静态变量 cTemp *lpcX; *lpcX *lpcY; lpcY cTemp; // 引用了静态变量在分享内存的多个线程中可能造成问题 }
中断利用的是系统的栈。栈操作是可重入的(因为栈可以保证“先进后出”)因此我们并不需要考虑栈操作的重入问题。使用宏汇编器写出可重入的汇编代码需要注意一些问题。简单地说干脆不要用标号作为变量是一个不错的主意。
使用高级语言编写可重入程序相对来讲轻松一些。把持住不访问那些全局(或当前对象的)变量不使用静态局部变量坚持只适用局部变量写出的程序就将是可重入的。
书归正传调用软件中断时通常都是通过寄存器传进、传出参数。这意味着你的int指令周围也许会存在一些“帮手”比如下面的代码
mov ax, 4c00h int 21h
就是通过调用DOS中断服务返回父进程并带回错误反馈码0。其中ax中的数据4c00h就是传递给DOS中断服务的参数。
到这里x86汇编语言的基础部分就基本上讲完了《简明x86汇编语言教程》的初级篇——汇编语言基础也就到此告一段落。当然目前为止我只是蜻蜓点水一般提到了一些学习x86汇编语言中我认为需要注意的重要概念。许多东西包括全部汇编语句的时序特性(指令执行周期数以及指令周期中各个阶段的节拍数等)、功能、参数等等限于个人水平和篇幅我都没有作详细介绍。如果您对这些内容感兴趣请参考Intel和AMD两大CPU供应商网站上提供的开发人员参考。
在以后的简明x86汇编语言教程中级篇和高级篇中我将着重介绍汇编语言的调试技术、优化以及一些具体的应用技巧包括反跟踪、反反跟踪、加密解密、病毒与反病毒等等。 [dvnews_page简明x86汇编语言教程(7)]
5.0 编译优化概述
优化是一件非常重要的事情。作为一个程序设计者你肯定希望自己的程序既小又快。DOS时代的许多书中都提到“某某编译器能够生成非常紧凑的代码”换言之编译器会为你把代码尽可能地缩减如果你能够正确地使用它提供的功能的话。目前Intel x86体系上流行的C/C编译器包括Intel C/C Compiler, GNU C/C Compiler以及最新的Microsoft和Borland编译器都能够提供非常紧凑的代码。正确地使用这些编译器则可以得到性能足够好的代码。
但是机器目前还不能像人那样做富于创造性的事情。因而有些时候我们可能会不得不手工来做一些事情。
使用汇编语言优化代码是一件困难而且技巧性很强的工作。很多编译器能够生成为处理器进行过特殊优化处理的代码一旦进行修改这些特殊优化可能就会被破坏而失效。因此在你决定使用自己的汇编代码之前一定要测试一下到底是编译器生成的那段代码更好还是你的更好。
本章中将讨论一些编译器在某些时候会做的事情(从某种意义上说本章内容更像是计算机专业的基础课中《编译程序设计原理》、《计算机组成原理》、《计算机体系结构》课程中的相关内容)。本章的许多内容和汇编语言程序设计本身关系并不是很紧密它们多数是在为使用汇编语言进行优化做准备。编译器确实做这些优化但它并不总是这么做此外就编译器的设计本质来说它确实没有义务这么做——编译器做的是等义变换而不是等效变换。考虑下面的代码
// 程序段1int gaussianSum(){ int i, j0; for(i0; i100; i) ji; return j;}
好的首先绝大多数编译器恐怕不会自作主张地把它“篡改”为
// 程序段1(改进1)int gaussianSum(){ int i, j0; for(i1; i100; i) ji; return j;}
多数但确实不是全部编译器也不会把它改为 // 程序段1(改进2)inline int gaussianSum(){ return 5050;}
这两个修改版本都不同于原先程序的语义。首先我们看到让i从0开始是没有必要的因为ji时i0不会做任何有用的事情然后是实际上没有必要每一次都计算1...100的和——它可以被预先计算并在需要的时候返回。
这个例子也许并不恰当(估计没人会写出最初版本那样的代码)但这种实践在程序设计中确实可能出现。我们把改进2称为编译时表达式预先计算而把改进1成为循环强度削减。
然而一些新的编译器的确会进行这两种优化。不过别慌看看下面的代码
// 程序段2int GetFactorial(int k){ int i, j1; if((k0) || (k10)) return -1; if((k1)) return 1 for(i1; ik; i) j*i; return j;}
程序采用的是一个时间复杂度为O(n)的算法不过我们可以把他轻易地改为O(1)的算法
// 程序段2 (非规范改进)int GetFactorial(int k){ int i, j1; static const int FractorialTable[]{1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800}; if((k0) || (k10)) return -1; return FractorialTable[k];}
这是一个典型的以空间换时间的做法。通用的编译器不会这么做——因为它没有办法在编译时确定你是不是要这么改。可以说如果编译器真的这样做的话那将是一件可怕的事情因为那时候你将很难知道编译器生成的代码和自己想的到底有多大的差距。
当然这类优化超出了本文的范围——基本上我把它们归入“算法优化”而不是“程序优化”一类。类似的优化过程需要程序设计人员对于程序逻辑非常深入地了解和全盘的掌握同时也需要有丰富的算法知识。
自然如果你希望自己的程序性能有大幅度的提升那么首先应该做的是算法优化。例如把一个O(n2)的算法替换为一个O(n)的算法则程序的性能提升将远远超过对于个别语句的修改。此外一个已经改写为汇编语言的程序如果要再在算法上作大幅度的修改其工作量将和重写相当。因此在决定使用汇编语言进行优化之前必须首先考虑算法优化。但假如已经是最优的算法程序运行速度还是不够快怎么办呢
好的现在假定你已经使用了已知最好的算法决定把它交给编译器让我们来看看编译器会为我们做什么以及我们是否有机会插手此事做得更好。
5.1 循环优化强度削减和代码外提
比较新的编译器在编译时会自动把下面的代码
for(i0; i10; i){ j i; k j i;}
至少变换为
for(i0; i10; i);ji; kji;
甚至
ji10; k20;
当然真正的编译器实际上是在中间代码层次作这件事情。
原理 如果数据项的某个中间值(程序执行过程中的计算结果)在使用之前被另一中间值覆盖则相关计算不必进行。
也许有人会问编译器不是都给咱们做了吗管它做什么注意这里说的只是编译系统中优化部分的基本设计。不仅在从源代码到中间代码的过程中存在优化问题而且编译器生成的最终的机器语言(汇编)代码同样存在类似的问题。目前几乎所有的编译器在最终生成代码的过程中都有或多或少的瑕疵这些瑕疵目前只能依靠手工修改代码来解决。
5.2 局部优化表达式预计算和子表达式提取
表达式预先计算非常简单就是在编译时尽可能地计算程序中需要计算的东西。例如你可以毫不犹豫地写出下面的代码
const unsigned long nGiga 1024L * 1024L * 1024L;
而不必担心程序每次执行这个语句时作两遍乘法因为编译器会自动地把它改为
const unsigned long nGiga 1073741824L;
而不是傻乎乎地让计算机在执行到这个初始化赋值语句的时候才计算。当然如果你愿意在上面的代码中掺上一些变量的话编译器同样会把常数部分先行计算并拿到结果。
表达式预计算并不会让程序性能有飞跃性的提升但确实减少了运行时的计算强度。除此之外绝大多数编译器会把下面的代码
// [假设此时b, c, d, e, f, g, h都有一个确定的非零整数值并且// a[]为一个包括5个整数元素的数组其下标为0到4] a[0] b*c;a[1] bc;a[2] d*e;a[3] b*d c*d;a[4] b*d*e c*d*e;
优化为(再次强调编译器实际上是在中间代码的层次而不是源代码层次做这件事情)
// [假设此时b, c, d, e, f, g, h都有一个确定的非零整数值并且// a[]为一个包括5个整数元素的数组其下标为0到4] a[0] b*c;a[1] bc;a[2] d*e;a[3] a[1] * d;a[4] a[3] * e;
更进一步在实际代码生成过程中一些编译器还会对上述语句的次序进行调整以使其运行效率更高。例如将语句调整为下面的次序
// [假设此时b, c, d, e, f, g, h都有一个确定的非零整数值并且// a[]为一个包括5个整数元素的数组其下标为0到4] a[0] b*c;a[1] bc;a[3] a[1] * d;a[4] a[3] * e;a[2] d*e;
在某些体系结构中刚刚计算完的a[1]可以放到寄存器中以提高实际的计算性能。上述5个计算任务之间只有1, 3, 4三个计算任务必须串行地执行因此在新的处理器上这样做甚至能够提高程序的并行度从而使程序效率变得更高。
5.3 全局寄存器优化
[待修订内容] 本章中从这一节开始的所有优化都是在微观层面上的优化了。换言之这些优化是不能使用高级语言中的对应设施进行解释的。这一部分内容将进行较大规模的修订。
通常此类优化是由编译器自动完成的。我个人并不推荐真的由人来完成这些工作——这些工作多半是枯燥而重复性的编译器通常会比人做得更好(没说的肯定也更快)。但话说回来使用汇编语言的程序设计人员有责任了解这些内容因为只有这样才能更好地驾驭处理器。
在前面的几章中我已经提到过寄存器的速度要比内存快。因此在使用寄存器方面编译器一般会做一种称为全局寄存器优化的优化。
例如在我们的程序中使用了4个变量i, j, k, l。它们都作为循环变量使用
for(i0; i1000; i){ for(j0; j1000; j){ for(k0; k1000; k){ for(l0; l1000; l) do_something(i, j, k, l); } }}
这段程序的优化就不那么简单了。显然按照通常的压栈方法i, j, k, l应该按照某个顺序被压进堆栈然后调用do_something()然后函数做了一些事情之后返回。问题在于无论如何压栈这些东西大概都得进内存(不可否认某些机器可以用CPU的Cache做这件事情但Cache是写通式的和回写式的又会造成一些性能上的差异)。
聪明的读者马上就会指出我们不是可以在定义do_something()的时候加上inline修饰符让它在本地展开吗没错本地展开以增加代码量为代价换取性能但这只是问题的一半。编译器尽管完成了本地展开但它仍然需要做许多额外的工作。因为寄存器只有那么有限的几个而我们却有这么多的循环变量。
把四个变量按照它们在循环中使用的频率排序并决定在do_something()块中的优先顺序(放入寄存器中的优先顺序)是一个解决方案。很明显我们可以按照l, k, j, i的顺序(从高到低因为l将被进行1000*1000*1000*1000次运算)来排列但在实际的问题中事情往往没有这么简单因为你不知道do_something()中做的到底是什么。而且凭什么就以for(l0; l1000; l)作为优化的分界点呢如果do_something()中还有循环怎么办
如此复杂的计算问题交给计算机来做通常会有比较满意的结果。一般说来编译器能够对程序中变量的使用进行更全面地估计因此它分配寄存器的结果有时虽然让人费解但却是最优的(因为计算机能够进行大量的重复计算并找到最好的方法而人做这件事相对来讲比较困难)。
编译器在许多时候能够作出相当让人满意的结果。考虑以下的代码
int a0; for(int i1; i10; i) for(int j1; j100; j){ a (i*j); }
让我们把它变为某种形式的中间代码
00: 0 - a01: 1 - i02: 1 - j03: i*j - t04: at - a05: j1 - j06: evaluate j 10007: TRUE? goto 0308: i1 - i09: evaluate i 1010: TRUE? goto 0211: [继续执行程序的其余部分]
程序中执行强度最大的无疑是03到05这一段涉及的需要写入的变量包括a, j需要读出的变量是i。不过最终的编译结果大大出乎我们的意料。下面是某种优化模式下Visual C 6.0编译器生成的代码(我做了一些修改)
xor eax, eax a0(eax: a)mov edx, 1 i1(edx: i)push esi 保存esi(最后要恢复esi作为代替j的那个循环变量)nexti:mov ecx, edx [ti]mov esi, 999 esi999: 此处修改了原程序的语义但仍为1000次循环。nextj:add eax, ecx [at]add ecx, edx [ti]dec esi j--jne SHORT nextj jne 等价于 jnz. [如果还需要则再次循环]inc edx icmp edx, 10 i与10比较jl SHORT nexti i 10, 再次循环pop esi 恢复esi
这段代码可能有些令人费解。主要是因为它不仅使用了大量寄存器而且还包括了5.2节中曾提到的子表达式提取技术。表面上看多引入的那个变量(t)增加了计算时间但要注意这个t不仅不会降低程序的执行效率相反还会让它变得更快因为同样得到了计算结果(本质上i*j即是第j次累加i的值)但这个结果不仅用到了上次运算的结果而且还省去了乘法(很显然计算机计算加法要比计算乘法快)。
这里可能会有人问为什么要从999循环到0而不是按照程序中写的那样从0循环到999呢这个问题和汇编语言中的取址有关。在下两节中我将提到这方面的内容。
5.4 x86体系结构上的并行最大化和指令封包
考虑这样的问题我和两个同伴现在在山里远处有一口井我们带着一口锅身边是树林身上的饮用水已经喝光了此处允许砍柴和使用明火(当然我们不想引起火灾:)需要烧一锅水应该怎么样呢
一种方案是三个人一起搭灶一起砍柴一起打水一起把水烧开。
另一种方案是一个人搭灶此时另一个人去砍柴第三个人打水然后把水烧开。
这两种方案画出图来是这样 仅仅这样很难说明两个方案孰优孰劣因为我们并不明确三个人一起打水、一起砍柴、一起搭灶的效率更高还是分别作效率更高(通常的想法一起做也许效率会更高)。但假如说三个人一个只会搭灶一个只会砍柴一个只会打水(当然是说这三件事情)那么方案2的效率就会搞一些了。
在现实生活中某个人拥有专长是比较普遍的情况在设计计算机硬件的时候则更是如此。你不可能指望加法器不做任何改动就能去做移位甚至整数乘法然而我们注意到串行执行的程序不可能在同一时刻同时用到处理器的所有功能因此我们(很自然地)会希望有一些指令并行地执行以充分利用CPU的计算资源。
CPU执行一条指令的过程基本上可以分为下面几个阶段取指令、取数据、计算、保存数据。假设这4个阶段各需要1个时钟周期那么只要资源够用并且4条指令之间不存在串行关系(换言之这些指令的执行先后次序不影响最终结果或者更严格地说没有任何一条指令依赖其他指令的运算结果)指令也可以像下面这样执行
指令1取指令取数据计 算存数据 指令2 取指令取数据计 算存数据 指令3 取指令取数据计 算存数据 指令4 取指令取数据计 算存数据
这样原本需要16个时钟周期才能够完成的任务就可以在7个时钟周期内完成时间缩短了一半还多。如果考虑灰色的那些方格(这些方格可以被4条指令以外的其他指令使用只要没有串行关系或冲突)那么如此执行对于性能的提升将是相当可观的(此时CPU的所有部件都得到了充分利用)。
当然作为程序来说真正做到这样是相当理想化的情况。实际的程序中很难做到彻底的并行化。假设CPU能够支持4条指令同时执行并且每条指令都是等周期长度的4周期指令那么程序需要保证同一时刻先后发射的4条指令都能够并行执行相互之间没有关联这通常是不太可能的。
最新的Intel Pentium 4-XEON处理器以及Intel Northwood Pentium 4都提供了一种被称为超线程(Hyper-Threading TM)的技术。该技术通过在一个处理器中封装两组执行机构来提高指令并行度并依靠操作系统的调度来进一步提升系统的整体效率。
由于线程机制是与操作系统密切相关的因此在本文的这一部分中不可能做更为深入地探讨。在后续的章节中我将介绍Win32、FreeBSD 5.x以及Linux中提供的内核级线程机制(这三种操作系统都支持SMP及超线程技术并且以线程作为调度单位)在汇编语言中的使用方法。
关于线程的讨论就此打住因为它更多地依赖于操作系统并且无论如何操作系统的线程调度需要更大的开销并且到目前为止真正使用支持超线程的CPU并且使用相应操作系统的人是非常少的。因此我们需要关心的实际上还是同一执行序列中的并发执行和指令封包。不过令人遗憾的是实际上在这方面编译器做的几乎是肯定要比人好因此你需要做的只是开启相应的优化如果你的编译器不支持这样的特性那么就把它扔掉……据我所知目前在Intel平台上指令封包方面做的最好的是Intel的C编译器经过Intel编译器编译的代码的性能令人惊异地高甚至在AMD公司推出的兼容处理器上也是如此。
5.5 存储优化
从前一节的图中我们不难看出方案2中如果谁的动作慢那么他就会成为性能的瓶颈。实际上CPU也不会像我描述的那样四平八稳地运行指令执行的不同阶段需要的时间(时钟周期数)是不同的因此缩短关键步骤(即造成瓶颈的那个步骤)是缩短执行时间的关键。
至少对于使用Intel系列的CPU来说取数据这个步骤需要消耗比较多的时间。此外假如数据跨越了某种边界(如4或8字节与CPU的字长有关)则CPU需要启动两次甚至更多次数的读内存操作这无疑对性能构成不利影响。
基于这样的原因我们可以得到下面的设计策略 程序设计中的内存数据访问策略 尽可能减少对于内存的访问。在不违背这一原则的前提下如果可能将数据一次处理完。 尽可能将数据按4或8字节对齐以利于CPU存取 尽可能一段时间内访问范围不大的一段内存而不同时访问大量远距离的分散数据以利于Cache缓存*
第一条规则比较简单。例如需要求一组数据中的最大值、最小值、平均数那么最好是在一次循环中做完。
“于是这家伙又攒了一段代码”……
int a[]{1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0};int i;int avg, max, min; avgmaxmina[0]; for(i1; i(sizeof(a)/sizeof(int)); i){ avga[i]; if(max a[i]) max a[i]; else if(min a[i]) min a[i];} avg / i;
Visual C编译器把最开始一段赋值语句翻译成了一段简直可以说是匪夷所思的代码
int a[]{1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0};mov edi, 2 此时edi没有意义mov esi, 3 esi也是临时变量而已。mov DWORD PTR _a$[esp92], edimov edx, 5 黑名单加上edxmov eax, 7 eax也别跑:)mov DWORD PTR _a$[esp132], edimov ecx, 9 就差你了ecxint i;int avg, max, min;avgmaxmina[0];mov edi, 1 edi摇身一变现在它是min了。mov DWORD PTR _a$[esp96], esimov DWORD PTR _a$[esp104], edxmov DWORD PTR _a$[esp112], eaxmov DWORD PTR _a$[esp136], esimov DWORD PTR _a$[esp144], edxmov DWORD PTR _a$[esp152], eaxmov DWORD PTR _a$[esp88], 1 编译器失误? 此处edi应更好mov DWORD PTR _a$[esp100], 4mov DWORD PTR _a$[esp108], 6mov DWORD PTR _a$[esp116], 8mov DWORD PTR _a$[esp120], ecxmov DWORD PTR _a$[esp124], 0mov DWORD PTR _a$[esp128], 1mov DWORD PTR _a$[esp140], 4mov DWORD PTR _a$[esp148], 6mov DWORD PTR _a$[esp156], 8mov DWORD PTR _a$[esp160], ecxmov DWORD PTR _a$[esp164], 0mov edx, edi ; edx是max。mov eax, edi ; 期待已久的avg, 它被指定为eax
这段代码是最优的吗我个人认为不是。因为编译器完全可以在编译过程中直接把它们作为常量数据放入内存。此外如果预先对a[0..9]10个元素赋值并利用串操作指令(rep movsdw)速度会更快一些。
当然犯不上因为这些问题责怪编译器。要求编译器知道a[0..9]和[10..19]的内容一样未免过于苛刻。我们看看下面的指令段
for(i1; ...mov esi, edifor_loop:avga[i];mov ecx, DWORD PTR _a$[espesi*488]add eax, ecxif(max a[i])cmp edx, ecxjge SHORT elseif_minmax a[i];mov edx, ecxelse if(min a[i])jmp SHORT elseif_minelseif_min:cmp edi, ecxjle SHORT elseif_endmin a[i];mov edi, ecxelseif_end:[for i1]; i20; i){inc esicmp esi, 20jl SHORT for_loop}avg / i;cdqidiv esiesi: iecx: 暂存变量, a[i]eax: avgedx: max有趣的代码...并不是所有的时候都有用但是也别随便删除edi: minii与20比较avg / i
上面的程序倒是没有什么惊人之处。唯一一个比较吓人的东西是那个jmp SHORT指令它是否有用取决于具体的问题。C/C编译器有时会产生这样的代码我过去曾经错误地把所有的此类指令当作没用的代码而删掉后来发现程序执行时间没有明显的变化。通过查阅文档才知道这类指令实际上是“占位指令”他们存在的意义在于占据那个地方一来使其他语句能够正确地按CPU觉得舒服的方式对齐二来它可以占据CPU的某些周期使得后续的指令能够更好地并发执行避免冲突。另一个比较常见的、实现类似功能的指令是NOP。
占位指令的去留主要是靠计时执行来判断。由于目前流行的操作系统基本上都是多任务的因此会对计时的精确性有一定影响。如果需要进行测试的话需要保证以下几点 计时测试需要注意的问题 测试必须在没有额外负荷的机器上完成。例如专门用于编写和调试程序的计算机 尽量终止计算机上运行的所有服务特别是杀毒程序 切断计算机的网络这样网络的影响会消失 将进程优先级调高。对于Windows系统来说把进程(线程)设置为Time-Critical; 对于*nix系统来说把进程设置为实时进程 将测试函数运行尽可能多次运行如10000000次这样能够减少由于进城切换而造成的偶然误差 最后如果可能的话把函数放到单进程的系统(例如FreeDOS)中运行。
对于绝大多数程序来说计时测试是一个非常重要的东西。我个人倾向于在进行优化后进行计时测试并比较结果。目前我基于经验进行的优化基本上都能够提高程序的执行性能但我还是不敢过于自信。优化确实会提高性能但人做的和编译器做的思路不同有时我们的确会做一些费力不讨好的事情。