在前面的章节中,我们已经了解了寄存器的基本使用方法。而正如结尾提到的那样,仅仅使用寄存器做一点运算是没有什么太大意义的,毕竟它们不能保存太多的数据,因此,对编程人员而言,他肯定迫切地希望访问内存,以保存更多的数据。
我将分别介绍如何在保护模式和实模式操作内存,然而在此之前,我们先熟悉一下这两种模式中内存的结构。
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,这样,它的代码段、数据段都被装入同样的数值(即,初始状态下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],3E8h jge 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的寄存器不是32-bit的,那么它——简直可以肯定地说——的字长要比32-bit还要多。新的个人机上已经开始逐步采用64-bit的CPU了),换言之,实际上段/偏移量这一格局已经不再需要了。尽管如此,在继续看保护模式内存结构时,仍请记住段/偏移量的概念。不妨把段寄存器看作对于保护模式中的选择器的一个模拟。选择器是全局描述符表(Global Descriptor Table, GDT)或本地描述符表(Local Descriptor Table, LDT)的一个指针。
如图所示,GDT和LDT的每一个项目都描述一块内存。例如,一个项目中包含了某块被描述的内存的物理的基地址、长度,以及其他一些相关信息。
保护模式是一个非常重要的概念,同时也是目前撰写应用程序时,最常用的CPU模式(运行在新的计算机上的操作系统很少有在实模式下运行的)。
为什么叫保护模式呢?它“保护”了什么?答案是进程的内存。保护模式的主要目的在于允许多个进程同时运行,并保护它们的内存不受其他进程的侵犯。这有点类似于C++中的机制,然而它的强制力要大得多。如果你的进程在保护模式下以不恰当的方式访问了内存(例如,写了“只读”内存,或读了不可读的内存,等等),那么CPU就会产生一个异常。这个异常将交给操作系统处理,而这种处理,假如你的程序没有特别说明操作系统该如何处理的话,一般就是杀掉做错了事情的进程。
我像这样的对话框大家一定非常熟悉(临时写了一个程序故意造成的错误):
好的,只是一个程序崩溃了,而操作系统的其他进程照常运行(同样的程序在DOS中几乎是板上钉钉的死机,因为NULL指针的位置恰好是中断向量表),你甚至还可以调试它。
保护模式还有其他很多好处,在此就不一一赘述了。实模式和保护模式之间的切换问题我打算放在后面的“高级技巧”一章来讲,因为多数程序并不涉及这个。
了解了内存的格局,我们就可以进入下一节——操作内存了。
3.3 操作内存
前两节中,我们介绍了实模式和保护模式中使用的不同的内存格局。现在开始解释如何使用这些知识。
回忆一下前面我们说过的,寄存器可以用作内存指针。现在,是他们发挥作用的时候了。
可以将内存想象为一个顺序的字节流。使用指针,可以任意地操作(读写)内存。
现在我们需要一些其他的指令格式来描述对于内存的操作。操作内存时,首先需要的就是它的地址。
让我们来看看下面的代码:
方括号表示,里面的表达式指定的不是立即数,而是偏移量。在实模式中,DS:0中的那个字(16-bit长)将被装入AX。
然而0是一个常数,如果需要在运行的时候加以改变,就需要一些特殊的技巧,比如程序自修改。汇编支持这个特性,然而我个人并不推荐这种方法——自修改大大降低程序的可读性,并且还降低稳定性,性能还不一定好。我们需要另外的技术。
看起来舒服了一些,不是吗?BX寄存器的内容可以随时更改,而不需要用冗长的代码去修改自身,更不用担心由此带来的不稳定问题。
同样的,mov指令也可以把数据保存到内存中:
在存储器与寄存器之间交换数据应该足够清楚了。
有些时候我们会需要操作符来描述内存数据的宽度:
操作符 |
意义 |
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,[bx+10] mov ax,[bx+si] mov ax,es:[di+bp] |
等等。我们看到,对于内存的操作,即使使用MOV指令,也有许多种可能的方式。下一节中,我们将介绍如何操作串。
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,之后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.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 |
之后,堆栈的状态为
之后,再执行
堆栈的状态成为
当然,dx、cx中的内容将分别是000ah和1234h。
注意,最后这张图中,我没有抹去1234h和000ah,因为POP指令并不从内存中抹去数值。不过尽管如此,我个人仍然非常反对继续使用这两个数(你可以通过修改SP来再次POP它们),然而这很容易导致错误。
一定要保证堆栈段有足够的空间来执行中断,以及其他一些隐式的堆栈操作。仅仅统计PUSH的数量并据此计算堆栈所需的大小很可能造成问题。
CALL指令将返回地址放到堆栈中。绝大多数C/C++编译器提供了“堆栈检查”这个编译选项,其作用在于保证C程序段中没有忘记对堆栈中多余的数据进行清理,从而保证返回地址有效。
本章小结
本章中介绍了内存的操作的一些入门知识。限于篇幅,我不打算展开细讲指令,如cmps*,lods*,stos*,等等。这些指令的用法和前面介绍的movs*基本一样,只是有不同的作用而已。