当前位置:编程学习 > 汇编语言 >>

汇编语言教程学习之第六节

     能够掌握了汇编语言?没错,您现在能够可以去破译别人代码中的秘密。然而,我们还有一件重要的东西没有提到,那可以自程序和中断。这两件东西是当的重要,以至于您的程序几乎不可能离开它们。

    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,还有跳转的操作,根据而提高了效率。

     作为一个编译器的做的更好实例,我用VisualC++编译了一段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); 调用myTransform
00401047 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<nCount; i++)
sBytes[i] += sBytes[i-1];
for(i=0; i<nCount; i++)
sBytes[i] <<= 1;
}

void myTransform2(int nCount, char* sBytes){
for(register int i=0; i<nCount; i++)
sBytes[i] <<= 1;
}

     很简单看出,这两个函数包含了公共部分,即

for(i=0; i<nCount; i++)
sBytes[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 i=1; i<nCount; i++)
00401004 mov dword ptr [i],1
0040100B jmp myTransform1+16h (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 myTransform1+3Dh (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 myTransform1+0Dh (0040100d)
4: for(i=0; i<nCount; i++)
0040103D mov dword ptr [i],0
00401044 jmp myTransform1+4Fh (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 myTransform1+6Bh (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 myTransform1+46h (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 i=0; i<nCount; i++)
00401074 mov dword ptr [i],0
0040107B jmp myTransform2+16h (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 myTransform2+32h (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 myTransform2+0Dh (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 i=0; i<200; i++)a[i]=i;
004010B9 mov dword ptr [i],0
004010C3 jmp main+24h (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 main+45h (004010f5)
004010E0 mov ecx,dword ptr [i]
004010E6 mov dl,byte ptr [i]
004010EC mov byte ptr a[ecx],dl
004010F3 jmp main+15h (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这两段代码存在少量的差别,但很显然只是对寄存器的偏好不相同(编译器在优化时,这可能会减少堆栈操作,根据而提高能力,但在这种地方只是使用了不相同的寄存器而已)

    对代码进行合并的好处是非常明显的。新的操作系统往往使用页式内存管理。当内存不足时,程序往往会频繁引发页面失效(Pagefaults),根据而引发操作系统根据磁盘中读取多数东西。磁盘的速度赶不上内存的速度,所以,这一行为用导致能力的下降。通过合并一部分代码,可以减少程序的大小,这代表着减少页面失效的可能性,根据而软件的能力会有所提高

     当然,这种做的代价也不算低——您的程序用变得难懂,并且难于维护。所以,再进行这种的优化之前,必须要注意:


  • 优化前的程序必须是正确的。可能您不可以确保这一点,当这种优化必用给您的调试带着极大的麻烦。
  • 优化前的程序做的更好最好是最优的。仔细检查您的设计,看看是不可能能够使用了最合适(即,对于此程序而言最优)的算法,并且能够在高级语言许可的范围内进行了最好的做的更好。
  • 优化最好能够非常有效地减少程序大小(打个比方,可能只是减少十几个字节,恐怕就没什么就得了),或非常有效地提高程序的运行速度(可能代码只是运行一次,并且只是节省几个时钟周期,当在多数场合都没有意义)。不然的话,这种优化用得不偿失。

    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++);
*dest=0;
}

非可重入函数

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汇编语言教程中级篇和高级篇中,我用着重介绍汇编语言的调试技术、优化,还有多数具体的应用技巧,包括反跟踪、反反跟踪、加密解密、病毒与反病毒等等。


CopyRight © 2012 站长网 编程知识问答 www.zzzyk.com All Rights Reserved
部份技术文章来自网络,