典型的处理器的主要任务包括 从内存中获取机器语言指令,译码,执行 根据指令代码管理它自己的寄存器 根据指令或自己的的需要修改内存的内容 响应其他硬件的中断请求 |
一般说来,处理器拥有对整个系统的所有总线的控制权。对于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 |
通用寄存器。相对其他寄存器,在进行运算方面比较常用。在保护模式中,也可以作为内存偏移指针(此时,DS作为段 寄存器或选择器) |
EBX |
通用寄存器。通常作为内存偏移指针使用(相对于EAX、ECX、EDX),DS是默认的段寄存器或选择器。在保护模式中,同样可以起这个作用。 |
ECX |
通用寄存器。通常用于特定指令的计数。在保护模式中,也可以作为内存偏移指针(此时,DS作为 寄存器或段选择器)。 |
EDX |
通用寄存器。在某些运算中作为EAX的溢出寄存器(例如乘、除)。在保护模式中,也可以作为内存偏移指针(此时,DS作为段 寄存器或选择器)。 |
上述寄存器同EAX一样包括对应的16-bit和8-bit分组。
用作内存指针的特殊寄存器
ESI |
通常在内存操作指令中作为“源地址指针”使用。当然,ESI可以被装入任意的数值,但通常没有人把它当作通用寄存器来用。DS是默认段寄存器或选择器。 |
EDI |
通常在内存操作指令中作为“目的地址指针”使用。当然,EDI也可以被装入任意的数值,但通常没有人把它当作通用寄存器来用。DS是默认段寄存器或选择器。 |
EBP |
这也是一个作为指针的寄存器。通常,它被高级语言编译器用以建造‘堆栈帧'来保存函数或过程的局部变量,不过,还是那句话,你可以在其中保存你希望的任何数据。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快速参考手册。在此谨表谢意。
2.2 使用寄存器
在前一节中的x86基本寄存器的介绍,对于一个汇编语言编程人员来说是不可或缺的。现在你知道,寄存器是处理器内部的一些保存数据的存储单元。仅仅了解这些是不足以写出一个可用的汇编语言程序的,但你已经可以大致读懂一般汇编语言程序了(不必惊讶,因为汇编语言的祝记符和英文单词非常接近),因为你已经了解了关于基本寄存器的绝大多数知识。
在正式引入第一个汇编语言程序之前,我粗略地介绍一下汇编语言中不同进制整数的表示方法。如果你不了解十进制以外的其他进制,请把鼠标移动到 这里 。
十进制整数 十六进制数 二进制数 八进制数 |
需要说明的是,这些方法是针对宏汇编器(例如,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 |
; 将012345678h送入eax |
则执行上述程序段之后,寄存器的内容变为:
|
? |
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 |
; ebx内容送入eax |
则寄存器内容变为:
|
? |
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 |
; 将0a1234h送入eax |
思考:此时,EAX的内容将是多少?[ 答案 ]
下面我们将介绍一些指令。在介绍指令之前,我们约定:
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 eax, 010h表示,在eax中载入00000010h。需要注意的是,如果你希望在寄存器中装入0,则有一种更快的方法,在后面我们将提到。
交换寄存器的内容:
xchg reg32, reg32 |
例如,xchg ebx, ecx,则ebx与ecx的数值将被交换。由于系统提供了这个指令,因此,采用其他方法交换时,速度将会较慢,并需要占用更多的存储空间,编程时要避免这种情况,即,尽量利用系统提供的指令,因为多数情况下,这意味着更小、更快的代码,同时也杜绝了错误(如果说Intel的CPU在交换寄存器内容的时候也会出错,那么它就不用卖CPU了。而对于你来说,检查一行代码的正确性也显然比检查更多代码的正确性要容易)刚才的习题的程序用下面的代码将更有效:
mov eax, 0a1234h |
; 将0a1234h送入eax |
递增或递减寄存器的值:
inc reg(8,16,32) |
这两个指令往往用于循环中对指针的操作。需要说明的是,某些时候我们有更好的方法来处理循环,例如使用loop指令,或rep前缀。这些将在后面的章节中介绍。
将寄存器的数值与另一寄存器,或立即数的值相加,并存回此寄存器:
add reg32, reg32 / imm(8,16,32) |
例如,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]等);后面的参数成为操作数,一个指令可以没有操作数,也可以有一两个操作数,通常有一个操作数的指令,这个操作数就是它的操作对象;而两个参数的指令,前一个操作数一般是保存操作结果的地方,而后一个是附加的参数。
我不打算在这份教程中用大量的篇幅介绍指令——很多人做得比我更好,而且指令本身并不是重点,如果你学会了如何组织语句,那么只要稍加学习就能轻易掌握其他指令。更多的指令可以参考 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] |
而把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伪指令来写指令;用汇编语言写程序可以防止很多错误的发生,同时,它还减轻了人的负担,很显然,“完全用机器语言写程序”是完全没有必要的,因为汇编语言可以做出完全一样的事情,并且你可以依赖它,因为计算机不会出错,而人总有出错的时候。此外,如前面所言,如果用高级语言实现程序的代价不大(例如,这段代码在程序的整个执行过程中只执行一遍,并且,这一遍的执行时间也小于一秒),那么,为什么不用高级语言实现呢?
一些比较狂热的编程爱好者可能不太喜欢我的这种观点。比方说,他们可能希望精益求精地优化每一字节的代码。但多数情况下我们有更重要的事情,例如,你的算法是最优的吗?你已经把程序在高级语言许可的范围内优化到尽头了吗?并不是所有的人都有资格这样说。汇编语言是这样一件东西,它足够的强大,能够控制计算机,完成它能够实现的任何功能;同时,因为它的强大,也会提高开发成本,并且,难于维护。因此,我个人的建议是,如果在软件开发中使用汇编语言,则应在软件接近完成的时候使用,这样可以减少很多不必要的投入。