首页 [整理][电脑书籍]汇编语言简介与入门

[整理][电脑书籍]汇编语言简介与入门

举报
开通vip

[整理][电脑书籍]汇编语言简介与入门[整理][电脑书籍]汇编语言简介与入门 我不想夸大或者贬低汇编语言。但我想说,汇编语言改变了20世纪的历史。与前辈相比,我们这一代编程人员足够的幸福,因为我们有各式各样的编程语言,我们可以操作键盘、坐在显示器面前,甚至使用鼠标、语音识别。我们可以使用键盘、鼠标来驾驭“个人计算机”,而不是和一群人共享一台使用笨重的继电器、开关去操作的巨型机。相比之下,我们的前辈不得不使用机器语言编写程序,他们甚至没有最简单的汇编程序来把助记符翻译成机器语言,而我们可以从上千种计算机语言中选择我们喜欢的一种,而汇编,虽然不是一种“常...

[整理][电脑书籍]汇编语言简介与入门
[整理][电脑书籍]汇编语言简介与入门 我不想夸大或者贬低汇编语言。但我想说,汇编语言改变了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,并适当地用汇编取代部分高级语言代码。至少,汇编语言的知识可以告诉你一些有用的东西,比如,你有多少个寄存器可以用。有时,手工的优化比编译器的优化更为有效,而且,你可以完全控制程序的实际行为。 我想我在罗嗦了。总之,在我们结束这一章之前,我想说,不要在优化的时候把希望完全寄托在编译器上——现实一些,再好的编译器也不可能总是产生最优的代码。 当时我学过BASIC, Fortran和Pascal,写的是一个 对一个包含100个32bit整数的数组进行快速排序,并且输出出来的小程序。实际上用汇编器写出的机器码与在调试器中用它附带的汇编程序写出的机器码还是有一些细微差别的,前者更大,然而却可能更高效,因为汇编器能够将代码放置到适合处理器的地方这句话假定两 个程序进行了同等程度的优化,一个写的不好的汇编程序和一个写的很好的C程序相比,汇编程序不一定更快。 中央处理器(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位处理器,其通用寄存器的名字是AX,BX等等;80386是Intel推出的第一款IA-32系列处理器,所有的寄存器都被扩充为32位。为了能够兼容以前的16位应用程序,80386不能将这些寄存器依旧命名为AX、BX,并且简单地将他们扩充为32位——这将增加处理器在处理指令方面的成本。 Intel微处理器的寄存器列表(在本章先只介绍80386的寄存器,MMX寄存器以及其他新一代处理器的新寄存器将在以后的章节介绍) 通用寄存器 下面介绍通用寄存器及其习惯用法。顾名思义,通用寄存器是那些你可以根据自己的意愿使用的寄存器,修改他们的值通常不会对计算机的运行造成很大的影响。通用寄存器最多的用途是计算。 EAX 32-bit宽 通用寄存器。相对其他寄存器,在进行运算方面比较常用。在保护模式中,也可以作为内存偏移指针(此时,DS作为段 寄存器或选择器) EBX 32-bit宽 通用寄存器。通常作为内存偏移指针使用(相对于EAX、ECX、EDX),DS是默认的段寄存器 或选择器。在保护模式中,同样可以起这个作用。 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快速参考手册。在此谨表谢意。先说一点和实际编程关系不太大的东西。当然,如果你迫切的想看到更实质的内容,完全可以先跳过这一章。 那么,我想可能有一个问题对于初学汇编的人来说非常重要,那就是: 汇编语言到底是什么, 汇编语言是一种最接近计算机核心的编码语言。不同于任何高级语言,汇编语言几乎可以完全和机器语言一一对应。不错,我们可以用机器语言写程序,但现在除了没有汇编程序的那些电脑之外,直接用机器语言写超过1000条以上指令的人大概只能算作那些被我们成为“圣人”的牺牲者一类了。毕竟,记忆一些短小的助记符、由机器去考虑那些琐碎的配位过程和检查错误,比记忆大量的随计算机而改变的十六进制代码、可能弄错而没有任何提示要强的 多。熟练的汇编语言编码员甚至可以直接从十六进制代码中读出汇编语言的大致意思。当然,我们有更好的工具——汇编器和反汇编器。 简单地说,汇编语言就是机器语言的一种可以被人读懂的形式,只不过它更容易记忆。至于宏汇编,则是包含了宏支持的汇编语言,这可以让你编程的时候更专注于程序本身,而不是忙于计算和重写代码。 汇编语言除了机器语言之外最接近计算机硬件的编程语言。由于它如此的接近计算机硬件,因此,它可以最大限度地发挥计算机硬件的性能。用汇编语言编写的程序的速度通常要比高级语言和C/C++快很多--几倍,几十倍,甚至成百上千倍。当然,解释语言,如解释型LISP,没有采用JIT技术的Java虚机中运行的Java等等,其程序速度更无法与汇编语言程序同日而语 。 永远不要忽视汇编语言的高速。实际的应用系统中,我们往往会用汇编彻底重写某些经常调用的部分以期获得更高的性能。应用汇编也许不能提高你的程序的稳定性,但至少,如果你非常小心的话,它也不会降低稳定性;与此同时,它可以大大地提高程序的运行速度。我强烈建议所有的软件产品在最后Release之前对整个代码进行Profile,并适当地用汇编取代部分高级语言代码。至少,汇编语言的知识可以告诉你一些有用的东西,比如,你有多少个寄存器可以用。有时,手工的优化比编译器的优化更为有效,而且,你可以完全控制程序的实际行为。 我想我在罗嗦了。总之,在我们结束这一章之前,我想说,不要在优化的时候把希望完全寄托在编译器上——现实一些,再好的编译器也不可能总是产生最优的代码。 当时我学过BASIC, Fortran和Pascal,写的是一个 对一个包含100个32bit整数的数组进行快速排序,并且输出出来的小程序。实际上用汇编器写出的机器码与在调试器中用它附带的汇编程序写出的机器码还是有一些细微差别的,前者更大,然而却可能更高效,因为汇编器能够将代码放置到适合处理器的地方这句话假定两个程序进行了同等程度的优化,一个写的不好的汇编程序和一个写的很好的C程序相比,汇编程序不一定更快。 在前一节中的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对应它的低16bit,AH、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,将eax+edx的值存入eax。减法指令和加法类似,只是将add换成sub。 需要说明的是,与高级语言不同,汇编语言中,如果要计算两数之和(差、积、商,或一般地说,运算结果),那么必然有一个寄存器被用来保存结果。在PASCAL中,我们可以用nA := nB + nC来让nA保存nB+nC的结果,然而,汇编语言并不提供这种方法。如果你希望保持寄存器中的结果,需要用另外的指令。这也从另一个侧面反映了“寄存器”这个名字的意义。数据只是“寄存”在那里。如果你需要保存数据,那么需要将它放到内存或其他地方。 类似的指令还有and、or、xor(与,或,异或)等等。它们进行的是逻辑运算。 我们称add、mov、sub、and等称为为指令助记符(这么叫是因为它比机器语言容易记忆,而起作用就是方便人记忆,某些资料中也称为指令、操作码、opcode[operation code]等);后面的参数成为操作数,一个指令可以没有操作数,也可以有一两个操作数,通常有一个操作数的指令,这个操作数就是它的操作对象;而两个参数的指令,前一个操作数一般是保存操作结果的地方,而后一个是附加的参数。 我不打算在这份 教程 人力资源管理pdf成真迷上我教程下载西门子数控教程protel99se入门教程fi6130z安装使用教程 中用大量的篇幅介绍指令——很多人做得比我更好,而且指令本身并不是重点,如果你学会了如何组织语句,那么只要稍加学习就能轻易掌握其他指令。更多的指令可以参考Intel提供的资料。编写程序的时候,也可以参考一些在线参考手册。Tech!Help和HelpPC 2.10尽管已经很旧,但足以应付绝大多数需要。 聪明的读者也许已经发现,使用sub eax, eax,或者xor eax, eax,可以得到与mov eax, 0类似的效果。在高级语言中,你大概不会选择用a=a-a来给a赋值,因为测试会告诉你这么做更慢,简直就是在自找麻烦,然而在汇编语言中,你会得到相反的结论,多数情况下,以由快到慢的速度排列,这三条指令将是xor eax, eax、sub eax, eax和mov eax, 0。 为什么呢,处理器在执行指令时,需要经过几个不同的阶段:取指、译码、取数、执行。 我们反复强调,寄存器是CPU的一部分。从寄存器取数,其速度很显然要比从内存中取数快。那么,不难理解,xor eax, eax要比mov eax, 0更快一些。 那么,为什么a=a-a通常要比a=0慢一些呢,这和编译器的优化有一定关系。多数编译器会把a=a-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 而把a=0翻译成 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,那么使用这些寄存器的程序同样也可以正常运行。 不过现在说用汇编语言进行优化还为时尚早——不可能写程序,而只操作这些寄存器,因为这样只能完成非常简单的操作,既然是简单的操作,那可能就会让人觉得乏味,甚至找一台足够快的机器穷举它的所有结果(如果可以穷举的话),并直接写程序调用,因为这样通常会更快。但话说回来,看完接下来的两章——内存和堆栈操作,你就可以独立完成几乎所有的任务了,配合第五章中断、第六章子程序的知识,你将知道如何驾驭处理器,并让它为你工作。 数字计算机内部只支持二进制数,因为这样计算机 只需要表示两种(某些情况是3种,这一内容超过了 这份教程的范围,如果您感兴趣,可以参考数字逻 辑电路的相关书籍)状态. 对于电路而言,这表现 为高、低电平,或者开、关,分别非常明显,因而 工作比较稳定;另一方面,由于只有两种状态,设 计起来也比较简单。这样,使用二进制意味着低成 本、稳定,多数情况下,这也意味着快速。 与十进制类似,我们可以用下面的式子来换算出一 个任意形如am-1„„a3a2a1a0 的m位r进制数对应的 数值n: 程序设计中常用十六进制和八进制数字代替二进制 数,其原因在于,16和8是2的整次方幂,这样,一 位十六或八进制数可以表示整数个二进制位。十六 使用字母A、B、C、D、E、F表示10-15, 进制中, 而十六进制或八进制数制表示的的数字比二进制数 更短一些。 EAX的内容为000A3412h. 在前面的章节中,我们已经了解了寄存器的基本使用方法。而正如结尾提到的那样,仅仅使用寄存器做一点运算是没有什么太大意义的,毕竟它们不能保存太多的数据,因此,对编程人员而言,他肯定迫切地希望访问内存,以保存更多的数据。 我将分别介绍如何在保护模式和实模式操作内存,然而在此之前,我们先熟悉一下这两种模式中内存的结构。 3.1 实模式 事实上,在实模式中,内存比保护模式中的结构更令人困惑。内存被分割成段,并且,操作内存时,需要指定段和偏移量。不过,理解这些概念是非常容易的事情。请看下面的图: 段寄存器这种格局是早期硬件电路限制留下的一个伤疤。地址总线在当时有20-bit。 然而20-bit的地址不能放到16-bit的寄存器里,这意味着有4-bit必须放到别的地方。因此,为了访问所有的内存,必须使用两个16-bit寄存器。 这一设计上的折衷 方案 气瓶 现场处置方案 .pdf气瓶 现场处置方案 .doc见习基地管理方案.doc关于群访事件的化解方案建筑工地扬尘治理专项方案下载 导致了今天的段-偏移量格局。最初的设计中,其中一个寄存器只有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 - ; 显示字符串到 标准 excel标准偏差excel标准偏差函数exl标准差函数国标检验抽样标准表免费下载红头文件格式标准下载 输出设备 ; 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,这样,它的代码段、数据段都被装入同样的数值(即,初始状态下DS=CS)。 也许会有人说,“嘿,这听起来不太好,一个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 a=0; register int i; for(i=0; i<1000; i++) a+=i; 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 fun+31h mov eax,dword ptr [ebp-8] add eax,1 mov dword ptr [ebp-8],eax cmp dword ptr [ebp-8],3E8hjge fun+45h mov ecx,dword ptr [ebp-4] add ecx,dword ptr [ebp-8] mov dword ptr [ebp-4],ecx jmp fun+28h mov eax,dword ptr [ebp-4] pop edi pop esi pop ebx mov esp,ebp pop ebp ret ; 子程序入口 ; 保护现场 ; 初始化变量-调试版本特有。 ; 本质是在堆中挖一块地儿,存CCCCCCCC。 ; 用串操作进行,这将发挥Intel处理器优势 ; ‘a=0’ ; ‘i=0’ ; 走着 ; i++ ; i<1000? ; a+=i; ; return a; ; 恢复现场 ; 返回 而在另一种模式[RELEASE/MINSIZE]下却被编译为 xor eax,eax xor ecx,ecx add eax,ecx inc ecx cmp ecx,3E8h jl fun+4 ret ; a=0; ; i=0; ; a+=i; ; i++; ; i<1000? ; 是->继续继续 ; 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的寄存器不 bit的,那么它——简直可以肯定地说——的字长要比32-bit还要多。新的个人机上是32- 已经开始逐步采用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) bit) dword ptr 一个双字(32- 例如,在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,[bx+10] mov ax,[bx+si] mov ax,es:[di+bp] 等等。我们看到,对于内存的操作,即使使用MOV指令,也有许多种可能的方式。下一节中,我们将介绍如何操作串。 感谢 网友 水杉 指出此答案中的一处错误。 感谢 Heallven 指出.COM程序实例编译失败的问题 我们前面已经提到,内存可以和寄存器交换数据,也可以被赋予立即数。问题是,如果我们需要把内存的某部分内容复制到另一个地址,又怎么做呢, 设想将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,之后SI+=2、DI+=2 movsd 将DS:SI的一字节复制到ES:DI,之后SI+=4、DI+=4 于是上面的程序改写为 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.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清零 ; 修改第一字节 看上去要舒服一些。 到目前为止,您已经了解了基本的寄存器以及内存的操作知识。事实上,您现在已经可以写出很多的底层数据处理程序了。 下面我来说说堆栈。堆栈实在不是一个让人陌生的数据结构,它是一个先进后出(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*基本一样,只是有不同的作用而已。 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,[eax+eax+3] ; 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 i=0; i<3; i++){ 00401026 mov dword ptr [i],0 ; 0->i 0040102D jmp main+18h (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 main+33h (00401053) ; 如果不符合条件,则应结束循环 9: a[i] = myTransform(i); 0040103E mov ecx,dword ptr [i] ; i->ecx 00401041 push ecx ; ecx (i) -> 堆栈 00401042 call myTransform (00401000); 调用myTransform00401047 add esp,4 ; esp+=4: 在堆中的新单元 ; 准备存放返回结果 0040104A mov edx,dword ptr [i] ; i->edx 0040104D mov dword ptr a[edx*4],eax ; 将eax(myTransform返回值) ; 放回a[i] 10: } 00401051 jmp main+0Fh (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 [ebp+8] ; 取参数 这和我们在main()中看到的压栈顺序是完全吻合的(注意,程序运行到这个地方的时候,EBP=ESP)。main()最终将i的值通过堆栈传递给了myTransform()。 剖析上面的程序只是说明了我前面所提到的子程序的一部分用法。对于汇编语言来说,完全没有必要拘泥于结构化程序设计的框架(在今天,使用汇编的主要目的在于提高执行效率,而不是方便程序的维护和调试,因为汇编不可能在这一点上做得比C++更好)。考虑下面的程序: void myTransform1(int nCount, char* sBytes){ for(register int i=1; i=10)) return -1; if((k<=1)) return 1 for(i=1; i 规范 编程规范下载gsp规范下载钢格栅规范下载警徽规范下载建设厅规范下载 改进) int GetFactorial(int k){ int i, j=1; static const int FractorialTable[]={1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800}; if((k<0) || (k>=10)) return -1; return FractorialTable[k]; } 这是一个典型的以空间换时间的做法。通用的编译器不会这么做——因为它没有办法在编译时确定你是不是要这么改。可以说,如果编译器真的这样做的话,那将是一件可怕的事情,因为那时候你将很难知道编译器生成的代码和自己想的到底有多大的差距。 当然,这类优化超出了本文的范围——基本上,我把它们归入“算法优化”,而不是“程序优化”一类。类似的优化过程需要程序设计人员对于程序逻辑非常深入地了解和全盘的掌握,同时,也需要有丰富的算法知识。 自然,如果你希望自己的程序性能有大幅度的提升,那么首先应该做的是算法优化。例如,把一个O(n2)的算法替换为一个O(n)的算法,则程序的性能提升将远远超过对于个别语句的修改。此外,一个已经改写为汇编语言的程序,如果要再在算法上作大幅度的修改,其工作量将和重写相当。因此,在决定使用汇编语言进行优化之前,必须首先考虑算法优化。但假如已经是最优的算法,程序运行速度还是不够快怎么办呢, 好的,现在,假定你已经使用了已知最好的算法,决定把它交给编译器,让我们来看看编译器会为我们做什么,以及我们是否有机会插手此事,做得更好。 5.1 循环优化:强度削减和代码外提 比较新的编译器在编译时会自动把下面的代码: for(i=0; i<10; i++){ j = i; k = j + i; } 至少变换为 for(i=0; i<10; i++); j=i; k=j+i; 甚至 j=i=10; k=20; 当然,真正的编译器实际上是在中间代码层次作这件事情。 原理 如果数据项的某个中间值(程序执行过程中的计算结果)在使用之前被另一中间值覆盖,则相关计算不必进行。 也许有人会问,编译器不是都给咱们做了吗,管它做什么,注意,这里说的只是编译系统中优化部分的基本设计。不仅在从源代码到中间代码的过程中存在优化问题,而且编译器生成的最终的机器语言(汇编)代码同样存在类似的问题。目前,几乎所有的编译器在最终生成代码的过程中都有或多或少的瑕疵,这些瑕疵目前只能依靠手工修改代码来解决。 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] = b+c; 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] = b+c; 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] = b+c; 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(i=0; i<1000; i++){ for(j=0; j<1000; j++){ for(k=0; k<1000; k++){ for(l=0; l<1000; 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(l=0; l<1000; l++)作为优化的分界点呢,如果do_something()中还有循环怎么办, 如此复杂的计算问题交给计算机来做通常会有比较满意的结果。一般说来,编译器能够对程序中变量的使用进行更全面地估计,因此,它分配寄存器的结果有时虽然让人费解,但却是最优的(因为计算机能够进行大量的重复计算,并找到最好的方法;而人做这件事相对来讲比较困难)。 编译器在许多时候能够作出相当让人满意的结果。考虑以下的代码: int a=0; for(int i=1; i<10; i++) for(int j=1; j<100; j++){ a += (i*j); } 让我们把它变为某种形式的中间代码: 00: 0 -> a 01: 1 -> i 02: 1 -> j 03: i*j -> t 04: a+t -> a 05: j+1 -> j 06: evaluate j < 100 07: TRUE? goto 03 08: i+1 -> i 09: evaluate i < 10 10: TRUE? goto 02 11: [继续执行程序的其余部分] 程序中执行强度最大的无疑是03到05这一段,涉及的需要写入的变量包括a, j;需要读出的变量是i。不过,最终的编译结果大大出乎我们的意料。下面是某种优化模式下Visual C++ 6.0编译器生成的代码(我做了一些修改): xor eax, eax ; a=0(eax: a) mov edx, 1 ; i=1(edx: i) push esi ; 保存esi(最后要恢复,esi作为代替j的那个循环变量) nexti: mov ecx, edx ; [t=i] mov esi, 999 ; esi=999: 此处修改了原程序的语义,但仍为1000次循环。 nextj: add eax, ecx ; [a+=t] add ecx, edx ; [t+=i] dec esi ; j-- jne SHORT nextj ; jne 等价于 jnz. [如果还需要,则再次循环] inc edx ; i++ cmp 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; avg=max=min=a[0]; for(i=1; i<(sizeof(a)/sizeof(int)); i++){ avg+=a[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$[esp+92], edi mov edx, 5 ; 黑名单加上edx mov eax, 7 ; eax也别跑:) mov DWORD PTR _a$[esp+132], edi mov ecx, 9 ; 就差你了,ecx ; int i; ; int avg, max, min; ; avg=max=min=a[0]; mov edi, 1 ; edi摇身一变,现在它是min了。 mov DWORD PTR _a$[esp+96], esi mov DWORD PTR _a$[esp+104], edx mov DWORD PTR _a$[esp+112], eax mov DWORD PTR _a$[esp+136], esi mov DWORD PTR _a$[esp+144], edx mov DWORD PTR _a$[esp+152], eax mov DWORD PTR _a$[esp+88], 1 ; 编译器失误? 此处edi应更好 mov DWORD PTR _a$[esp+100], 4 mov DWORD PTR _a$[esp+108], 6 mov DWORD PTR _a$[esp+116], 8 mov DWORD PTR _a$[esp+120], ecx mov DWORD PTR _a$[esp+124], 0 mov DWORD PTR _a$[esp+128], 1 mov DWORD PTR _a$[esp+140], 4 mov DWORD PTR _a$[esp+148], 6 mov DWORD PTR _a$[esp+156], 8 mov DWORD PTR _a$[esp+160], ecx mov DWORD PTR _a$[esp+164], 0 mov edx, edi ; edx是max。 mov eax, edi ; 期待已久的avg, 它被指定为eax 这段代码是最优的吗,我个人认为不是。因为编译器完全可以在编译过程中直接把它们作为 常量数据放入内存。此外,如果预先对a[0..9]10个元素赋值,并利用串操作指令(rep movsdw),速度会更快一些。 当然,犯不上因为这些问题责怪编译器。要求编译器知道a[0..9]和[10..19]的内容一样未 免过于苛刻。我们看看下面的指令段: ; for(i=1; ... mov esi, edi for_loop: ; avg+=a[i]; mov ecx, DWORD PTR _a$[esp+esi*4+88] add eax, ecx ; if(max < a[i]) cmp edx, ecx jge SHORT elseif_min ; max = a[i]; mov edx, ecx ; else if(min > a[i]) jmp SHORT elseif_min elseif_min: cmp edi, ecx jle SHORT elseif_end ; min = a[i]; mov edi, ecx elseif_end: ; [for i=1]; i<20; i++){ inc esi cmp esi, 20 jl SHORT for_loop ; } ; avg /= i; cdq idiv esi ; esi: i ; ecx: 暂存变量, =a[i] ; eax: avg ; edx: max ; 有趣的代码...并不是所有的时候都有用 ; 但是也别随便删除 ; edi: min ; i++ ; i与20比较 ; avg /= i 上面的程序倒是没有什么惊人之处。唯一一个比较吓人的东西是那个jmp SHORT指令,它是否有用取决于具体的问题。C/C++编译器有时会产生这样的代码,我过去曾经错误地把所有的此类指令当作没用的代码而删掉,后来发现程序执行时间没有明显的变化。通过查阅文档才知道,这类指令实际上是“占位指令”,他们存在的意义在于占据那个地方,一来使其他语句能够正确地按CPU觉得舒服的方式对齐,二来它可以占据CPU的某些周期,使得后续的指令能够更好地并发执行,避免冲突。另一个比较常见的、实现类似功能的指令是NOP。 占位指令的去留主要是靠计时执行来判断。由于目前流行的操作系统基本上都是多任务的,因此会对计时的精确性有一定影响。如果需要进行测试的话,需要保证以下几点: 计时测试需要注意的问题 测试必须在没有额外负荷的机器上完成。例如,专门用于编写和调试程序的计算机 尽量终止计算机上运行的所有服务,特别是杀毒程序 切断计算机的网络,这样网络的影响会消失 将进程优先级调高。对于Windows系统来说,把进程(线程)设置为Time-Critical; 对于*nix系统来说,把进程设置为实时进程 将测试函数运行尽可能多次运行,如10000000次,这样能够减少由于进城切换而造成的偶然误差 最后,如果可能的话,把函数放到单进程的系统(例如FreeDOS)中运行。 对于绝大多数程序来说,计时测试是一个非常重要的东西。我个人倾向于在进行优化后进行计时测试并比较结果。目前,我基于经验进行的优化基本上都能够提高程序的执行性能,但我还是不敢过于自信。优化确实会提高性能,但人做的和编译器做的思路不同,有时,我们的确会做一些费力不讨好的事情。
本文档为【[整理][电脑书籍]汇编语言简介与入门】,请使用软件OFFICE或WPS软件打开。作品中的文字与图均可以修改和编辑, 图片更改请在作品中右键图片并更换,文字修改请直接点击文字进行修改,也可以新增和删除文档中的内容。
该文档来自用户分享,如有侵权行为请发邮件ishare@vip.sina.com联系网站客服,我们会及时删除。
[版权声明] 本站所有资料为用户分享产生,若发现您的权利被侵害,请联系客服邮件isharekefu@iask.cn,我们尽快处理。
本作品所展示的图片、画像、字体、音乐的版权可能需版权方额外授权,请谨慎使用。
网站提供的党政主题相关内容(国旗、国徽、党徽..)目的在于配合国家政策宣传,仅限个人学习分享使用,禁止用于任何广告和商用目的。
下载需要: 免费 已有0 人下载
最新资料
资料动态
专题动态
is_697316
暂无简介~
格式:doc
大小:127KB
软件:Word
页数:69
分类:生产制造
上传时间:2017-11-15
浏览量:29