Win32汇编源程序的结构
任何种类的语言,总是有基本的源程序结构规范。
下面以经典的Hello World程序为例,展示一个C语言、DOS汇编、Win32汇编三种写法。同学位好好体会一下。
如果没有汇编基础,建议看一下
C语言中的HelloWorld程序:
#include <stdio.h>
main()
{
printf(“Hello, world\n”);
}
像这样的一个程序,就说明了C语言中最基本的格式,main()中的括号和下面的花括号说明了一个函数的定义方法,printf语句说明了一个函数的调用方法,调用函数语句后面的分号也是基本的格式。C是一种高级语言,在C源程序中,不必为堆栈段、数据段和代码段的定义而担心,编译器会把程序中的字符串和语句代码分别放到它们该去的地方,程序开始执行的时候也会自己找到main()函数。而汇编是低级语言,必须为所有的东西找到它们该去的地方,所以在DOS的汇编中,Hello World又长成了这样一副模板:
;分号后面是注释
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 堆栈段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
stack segment
db 100 dup (?) ;定义100个字节的内存存储单元空间,默认值为?
stack ends
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 数据段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
data segment
szHello db ‘Hello, world’,0dh,0ah,’$’
;szHello为数据标号,它标记了存储数据的单元的地址和长度。
;天哪,这太像高级语言中的变量了!!!
data ends
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 代码段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
code segment
assume cs:code,ds:data,ss:stack
start:
mov ax,data
mov ds,ax
mov ah,9
mov dx,offset szHello
int 21h
mov ah,4ch
int 21h
code ends
end start
在这个源程序中,stack段为堆栈找了个家,hello world字符串则跑到数据段中去了,代码则放在代码段中,程序的开始语句必须由最后一句end start来说明应该从start这个标号开始执行,整个程序在使用过DOS汇编的程序员眼里是非常的熟悉。(一个月前我不熟悉,现在我熟悉了。感
到了Win32汇编的时候,程序的基本结构还是如此,先来看一看这个看起来很新鲜的Win32的Hello world程序。
.386
.model flat,stdcall
option casemap:none
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Include 文件定义
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
include windows.inc
include user32.inc
includelib user32.lib
include kernel32.inc
includelib kernel32.lib
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 数据段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.data
szCaption db ‘A MessageBox!’,0
szText db ‘Hello, World!’,0
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 代码段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.code
start:
invoke MessageBox, NULL, offset szText, offset szCaption, MB_OK
invoke ExitProcess, NULL
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
end start
怎么样,看来和上面的C以及DOS汇编又不同了吧!但从include, .data和.code等语句,顾名思义,也能看出一点苗头来,include应该就是包含别的文件,.data想必是数据段,.code应该就是代码段了吧!接下来通过这个例子程序逐段介绍Win32汇编程序的结构。
模式定义
程序的第一部分是模式和源程序格式的定义语句:
.386
.model flat,stdcall
option casemap:none
这些指令定义了程序使用的指令集、工作模式和格式。
1)指定使用的指令集
.386语句是汇编语句的伪指令,它在低版本的宏汇编中就已经存在,类似的指令还有:.8086、.186、.286、.386/.386p、.486/..486p和.586/.586p等,用于告诉编译器在本程序中使用的指令集。在DOS的汇编中默认使用的是8086指令集,那时候如果在源程序中写入80386所特有的指令或使用32位的寄存器就会报错,为了在DOS环境下进行保护模式编程或仅为了使用32位寄存器,常在DOS的汇编中使用.386来定义。Win32环境工作在80386及以上的处理器中,所以这一句.386是必不可少的。
后面带p的伪指令则表示程序中可以使用特权指令,如:
mov cr0,eax
这一类指令必须在特权级0上运行,如果只指定.386,那么使用普通的指令是可以的,编译时到这一句就会报错,如果我们要写的程序是VxD等驱动程序,中间要用到特权指令,那么必须定义.386p,在应用程序级别的Win32编程中,程序都是运行在优先级3上,不会用到特权指令,只需定义.386就够了。80486和Pentium处理器指令是80386处理器指令的超集,同样道理,如果程序中要用80486处理器或Pentium处理器的指令,则必须定义.486或.586。
另外,Intel公司的80x86系列处理器从Pentium MMX开始增加了MMX指令集,为了使用MMX指令,除了定义.586之外,还要加上一句.mmx伪指令:
.386
.mmx
2)model语句
.model语句在低版本的宏汇编中已经存在,用来定义程序工作的模式,它的使用方法是:
.model 内存模式 [,语言模式] [,其他模式]
内存模式的定义影响最后生成的可执行文件,可执行文件的规模从小到大,可以有很多种类型,在DOS的可执行程序中,有只用到64KB的.com文件,也有大大小小的.exe文件。到了Win32环境下,又有了可以用4GB内存的PE格式可执行文件,编写不同类型的可执行文件要用.model语句定义不同的参数,具体如下 表所示。
内存模式
模式 |
内存使用方式 |
tiny |
用来建立.com文件,所有的代码、数据和堆栈都在同一个64KB段内 |
small |
建立代码和数据分别用一个64KB段的.exe文件 |
medium |
代码段可以有多个64KB段,数据段只有一个64KB段 |
compact |
代码段只有一个64KB,数据段可以有多个64KB段 |
large |
代码段和数据段都可以有多个64KB段 |
huge |
同large,并且数据段中的一个数组也可以超过64KB |
float |
Win32程序使用的模式,代码和数据使用同一个4GB段 |
Windows 程序运行在保护模式下,系统把每一个Win32应用程序都放到分开的虚拟地址空间中去运行,也就是说,每一个应用程序都拥有其相互独立的4GB地址空间,对Win32程序来说,只有一种内存模式,即flat(平坦)模式,意思是内存是很平坦地从0延伸到4GB,再没有64KB段大小限制。对比一下DOS的Hello World和Win32的Hello World开始部分的不同,DOS程序中有这样语句:
mov ax,data
mov ds,ax
意思是把数据段寄存器DS指向data数据段,data数据段在前面已经用data segment语句定义,只要DS不重新设置,那么从此以后指令中涉及的数据默认将从data数据段中取得,所以下面的语句是从data数据段取出szHello字符串的地址后再显示:
mov ah,9
mov dx,offset szHello
int 21h
纵观Win32汇编的源程序,没有一处可以找到ds或es等段寄存器的使用,因为所有的4GB空间用32位的寄存器全部都能访问到了,不必在头脑中随时记着当前使用的是哪个数据段,这就是平坦内存模式带来的好处。
如果定义了.model flat,MASM自动为各种段寄存器做了如下定义:
ASSUME cs:FLAT,ds:FLAT,ss:FLAT,es:FLAT,fs:ERROR,gs:ERROR
也就是说,CS,DS,SS和ES段全部使用平坦模式,FS和GS寄存默认不使用,这时若在源程序中使用FS或GS,在编译时会报错。如果有必要使用它们,只需在使用前用下面的语句声明一下就可以了:
assume fs:nothing,gs:nothing 或者 assume fs:flat,gs:flat
在Win32汇编中,.model语句中还应该指定语言模式,即子程序和调用方式,例子中用的是stdcall,它指出了调用子程序或Win32 API时参数传递的次序和堆栈平衡的方法,相对于stdcall,不同的语言类型还有C,SysCall,BASIC,FORTRAN和PASCALL,虽然各种高级语言在调用子程序时都是使用堆栈来传递参数。Windows的API调用使用是的stdcall格式,所以在Win32汇编中没有选择,必须在.model中加上stdcall参数。
3)option语句
option casemap:none
用option语句定义的选项有很多,如option language定义和option segment定义等,在Win32汇编程序中,需要的只是定义option casemap:none,这个语句定义了程序中的变量和子程序名是否对大小写每感,由于Win32 API中的API名称是区分大小写的,所以必须指定这个选项,否则在调用API的时候会有问题。
段的定义
段的概念
把上面的Win32的Hello World源程序中的语句归纳精简一下,再列在下面:
.386
.model flat,stdcall
option casemap:none
<一些include语句>
.data
<一些字符串、变量定义>
.code
<代码>
<开始标号>
<其他语句>
end 开始标号
模式定义中的模式、选项等定义并不会在编译好的可执行程序中产生什么东西,它们只是说明,而真正的数据和代码是定义在各个段中的,如上面的.data段和.code段,考虑到不同的数据类型,还可以有其他种类的数据段,下面是包含全部段的源程序结构:
.386
.model flat,stdcall
option casemap:none
<一些include语句>
.stack [堆栈段的大小]
.data
<一些初始化过的变量定义>
.data?
<一些没有初始化过的变量定义>
.const
<一些常量定义>
.code
<代码>
<开始标号>
<其他语句>
end 开始标号
.stack、.data、.data?、.const和.code是分段伪指令,Win32中实际上只有代码和数据之分,.data,.data?和.const是数据段,.code是代码段,和DOS汇编不同,Win32汇编不必考虑堆栈,系统会为程序分配一个向下扩展的、足够大的段作为堆栈段,所以.stack段定义常常被忽略。
注意,前面不是说过Win32环境下不用段了吗?是的,这些“段”,实际上并不是DOS汇编中那种意义的段,而是内存的“分段”。上一个段的结束就是下一个段的开始,所有的分段,合起来,包括系统使用的地址空间,就组成了整个可以寻址的4GB空间。Win32汇编的内存管理使用了80386处理器的分页机制,每个页(4KB大小)可以自由指定属性,所以上一个4KB可能是代码,属性是可执行但不可写,下一个4KB就有可能是既可读也可写但不可执行的数据,再下面呢?有可能是可读不可写也不可执行的数据。Win32汇编源程序中“分段”的概念实际上是把不同类型的数据或代码归类,再放到不同属性的内存页(也就是不同的“分段”)中,这中间不涉及使用不同的段选择器。虽然使用和DOS汇编同样的.code和.data语句来定义,意思可是完全不同了!
数据段
.data、.data?和.const定义的是数据段,分别对应不同方式的数据定义,在最后生成的可执行文件中也分别放在不同的节区(Section)中。程序中的数据定义一段可以归纳为3类:
1)第一类是可读可写的已定义变量。这些数据在源程序中已经被定义了初始值,而且在程序的执行中有可能被更改,如一些标志等,这些数据必须定义在.data段中,.data段是已初始化数据段,其中定义的数据是可读可写的,在程序装入完成的时候,这些值就已经在内存中了,.data段存放在可执行文件的_DATA节区内。
2)第二类是可读可写的未定义变量。这些变量一般是当做缓冲区或者在程序执行后才开始使用的,这些数据可以定义在.data段中,也可以定义在.data?段中,但一般把它放到.data?段中。虽然定义在这两种段中都可以正常使用,但定义在.data?段中不会增大.exe文件的大小。举例说明,如果要用到一个100KB的缓冲区,可以在数据段中定义:
szBuffer db 100 * 1024 dup (?)
如果放在.data段中,编译器认为这些数据在程序装入时就必须有效,所以它在生成可执行文件的时候保留了所有的100KB的内容,即使它们是全零!如果程序其他部分的大小是50KB,那么最后的.exe文件就会是150KB大小,如果缓冲区定义为1MB,那么.exe文件会增大到1050KB。.data?段则不同,其中的内容编译器会认为程序在开始执行后才会用到,所以在生成可执行文件的时候只保留了大小信息,不会为它浪费磁盘空间。和上面同样的情况下,即使缓冲区定义为1MB,可执行文件同样只有50KB!总之,.data?段是未初始化数据段,其中的数据也是可读可写的,但在可执行文件中不占空间,.data?段在可执行文件中存放在_BSS节区中。
3)第三类数据是一些常量。如一些要显示的字符串信息,它们在程序装入的时候也已经有效,但在整个执行过程中不需要修改,这些数据可以放在.const段中,.const段是常量段,它是可读不可写的。一般为了方便起见,在小程序中常常把常量一起定义到.data段中,而不另外定义一个.const段。在程序中如果不小心写了对.const段中的数据做写操作的指令,会引起保护错误,Windows会显示一个提示框并结束程序。
Hello.exe – 应用程序错误
“0x
要终止程序,请单击”确定”。
要调试程序,请单击”取消”。
如果不怕程序可读性不佳的话,把.const段中定义的东西混到.code段中去也可以正常使用,因为.code段也是可以读的。
代码段
.code段是代码段,所有的指令都必须写在代码段中,在可执行文件中,代码段是放在_TEXT节区中的。Win32环境中的数据段是不可执行的,只有代码段有可执行的属性。对于工作在特权级3的应用程序来说,.code段是不可写的,在编写DOS汇编程序的时候,好事的程序员往往有个习惯,就是靠改动代码段中的代码来做一些反跟踪的事情,如果企图在Win32汇编下做同样的事情,结果就是和上面同样 “非法操作”。
当然事物总有两面性,在Windows95下,在特权级0下运行的程序对所有的段都有读写的权利,包括代码段。另外,在优先级3下运行的程序也不是一定不能写代码段,代码段的属性是由可执行文件PE头部中的属性位决定的,通过编辑磁盘上的.exe文件,把代码段属性位改成可写,那么在程序中就允许修改自己的代码段。一个典型的应用就是一些针对可执行文件的压缩软件和加壳软件,如Upx和PeCompact等,这些软件靠把代码段进行变换来达到解压缩和解密的目的,被处理过的可执行文件在执行时需要由解压代码来将代码段解压缩,这就需要写代码段,所以这些软件对可执行文件代码段的属性预先做修改。
程序结束和程序入口
在C语言源程序中,程序不必显式地指定程序由哪里开始执行,编译器已经约定好从main()函数开始执行了。而在汇编程序中,并没有一个main函数,程序员可以指定从代码段的任何一个地方开始执行,这个地方由程序最后一句的end语句来指定:
end [开始地址]
这句语句同时表示源程序结束,所有的代码必须在end语句之前。
end start
上述语句指定程序从start这个标号开始执行。当然,start标号必须在程序的代码段中有所定义。
但是,一个源程序不必非要指定入口标号,这时候可以把开始地址忽略不写,这种情况发生在编写多模块程序的单个模块的时候。当分开写多个程序模块时,每个模块的源程序中也可以包括.data、.data?、.const和.code段,结构就和上面的Win32 Hello World一样,只是其他模块最后的end语句必须不带开始地址。当最后把多个模块链接在一起的时候,只能有一个主模块指定入口地址,在多个模块中指定入口地址或者没有一个模块指定了入口地址,链接程序都会报错。
注释和换行
注释是源程序中不可忽略的一部分,汇编源程序的注释以分号(;)开始,注释既可以在一行的头部,也可以在一行的中间,一行中所有在分号之后的字符全部当做注释处理,但在字符串的字义中包含的引号内的分号不当做是注释的开始。
;这里是注释
call _PrintChar ;这里是注释
szChar db ‘Hello, world; ’,0dh,0ah ;world后面的分号不是注释,后面的才是
当源程序的某一行过长,不利于阅读的时候,可以分行书写,分行的办法是在一行的最后用反斜杠(\)做换行符,如:
invoke MessageBox, NULL, offset szText, offset szCaption, MB_OK
可以写为:
invoke MessageBox, \
NULL, \ ;父窗口句柄
offset szText, \ ;消息框中的文字
offset szCaption, \ ;标题文字
MB_OK
一行的最后,指的是最后一个有用的字符,反斜杠后面多几个空格或加上注释并不影响换行符的使用,如上例所示,这一点和makefile文件中换行符的规定有所不同。
调用API
API是什么?
Win32程序是构筑在Win32 API基础上的。在Win32 API中,包括了大量的函数、结构和消息等,它不仅为应用程序所调用,也是Windows自身的一部分,Windows自身的运行也调用这些API函数。
在DOS下,操作系统的功能是通过各种软中断来实现的,如大家都知道int 21h是DOS中断,int 13h和int 10h是BIOS中的磁盘中断和视频中断。当应用程序要引用系统功能时,要把相应的参数放在各个寄存器中再调用相应的中断,程序控制权转到中断中去执行,完成以后会通过iret中断返回指令回到应用程序中。如DOS汇编下的Hello World程序中有下列语句:
mov ah,9
mov dx,offset szHello
int 21h
这3条语句调用DOS系统模块中的屏幕显示功能,功能号放在ah中,9号功能表示屏幕显示,要输出到屏幕上的内容的地址放在dx中,然后去调用int 21h,字符串就会显示到屏幕上。
这个例子说明了应用程序调用系统功能的一般过程。首先,系统提供功能模块并约定参数的定义方法,同时约定调用的方式,同时约定调用的方式,应用程序按照这个约定来调用系统功能。在这里,ah中放功能号9,dx中放字符串地址就是约定的参数,int 21h是约定的调用方式。
下面来看看这种方法的不便这处。首先,所有的功能号定义是冷冰冰的数字,int 21h的说明文档是这样的:
Int 21 Functions:
00 Programe termination
01 Keyboard input
02 Display output
03 AUX input
04 AUX output
05 Printer output
06 Direct console I/O
07 Direct STDIN input, no echo
08 Keyboard input, no echo
09 Print string
0B Check standard input status
再进入09号功能看使用方法:
Print string (Func 09)
AH = 09h
DS:DX -> string terminated by “$”
这就是DOS时代汇编程序员都有一厚本《中断大全》的原因,因为所有的功能编号包括使用的参数定义仅从字面上看,是看不出一点头绪来的。
另外,80x86系列处理器能处理的中断最多只能有256个,不同的系统服务程序使用了不同的中断号,这少得可怜的中断数量就显得太少了,结果到最后是中断挂中断,大家抢来抢去的,把好好的一个系统搞得像接力赛跑一样。
对于这些弱点,程序员们都有个愿望:系统功能如果能以功能名作为子程序名直接调用就好了,参数也最好定义的有意义一点,这样一来写程序就会方便得多,编系统扩展模块也就不必老是担心往哪个中断上面挂了,最好能把上面int 21h/ah=9的调用写成下面这副样子:
call PrintString, addr szHello
终于,好消息出来了,Win32环境中的编程接口就是这个样子,这就是API,它实际上是以一种新的方法代替了DOS中用软中断的方式。和DOS的结构相比,Win32的系统功能模块放在Windows的动态链接库(DLL)中,DLL是一种Windows的可执行文件,采用的是和.exe文件同样的PE格式,在PE格式文件头的导出表中,以字符串形式指出了这个DLL能提供的函数列表。应用程序使用字符串类型的函数名指定要调用的函数。
应用程序在使用的时候由Windows自动载入DLL程序并调用相应的函数。
实际上,Win32的基础就是由DLL组成的。Win32 API的核心由3个DLL提供,它们是:
KERNEL32.DLL——系统服务功能。包括内存管理、任务管理和动态链接等。
GDI32.DLL——图形设备接口。利用VGA与DRV之类的显示设备驱动程序完成显示文本和矩形等功能。
USER32.DLL——用户接口服务。建立窗口和传送消息等。
当然,Win32 API还包括其他很多函数,这些也是由DLL提供的,不同的DLL提供了不同的系统功能。如使用TCP/IP协议进行网络通信的DLL是Wsock32.dll,它所提供的API称为Socket API;专用于电话服务方面的API称为TAPI(Telephony API),包含在Tapi32.dll中,所有的这些DLL提供的函数组成了现在使用的Win32编程环境。
调用API
和在DOS中用中断方式调用系统功能一样,用API方式调用存放在DLL中的函数必须同样约定一个规范,用来定义函数的调用方法、参数的传递方法和参数的定义,洋洋洒洒几百MB的Windows系统比起才几百KB规模的DOS,其系统函数的规模和复杂程度都上了一个数量级,所在使用一个API时,带的参数数量多达十几个是常有的事,在DOS下用寄存来传递参数的方法显然已经不能胜任了。
Win32 API是用堆栈来传递参数的,调用者把参数一个个压入堆栈,DLL中的函数程序再从堆栈中取出参数处理,并在返回之前将堆栈中已经无用的参数丢弃。在Microsoft发布的《Microsoft Win32 Programmer’s Reference》中定义了常用API的参数和函数声明,先来看消息框函数的声明:
int MessageBox(
HWND hWnd, //handle to owner window
LPCTSTR lpText, //text in message box
LPCTSTR lpCaption, //message box title
UINT uType //message box style
);
最后还有一句说明:
Library: Use User32.lib。
上述函数声明说明了MessageBox有4个参数,它们分别是HWND类型的窗口句柄(hWnd),LPCTSTR类型的要显示的字符串地址(lpText)和标题字符串地址(lpCaption),还有UINT类型的消息框类型(uType)。这些数据类型看起来很复杂,但有一点是很重要的,对于汇编语言来说,Win32环境中的参数实际上只有一种类型,那就是一个32位的整数,所以这些HWND,LPCTSTR和UINT实际上就是汇编中的dword(double word,双字型,4个字节,两个字,32位),之所以定义为不同的模样,是用来说明了用途。由于Windows是用C写成的,世界上的程序员好像也是用C语言的最多,所以Windows所有编程资料发布的格式也是C格式。
上面的声明用汇编的格式来表达就是:
MessageBox Proto hWnd:dword,lpText:dword,lpCaption:dword,uType:dword
上面最后一句Library:Use User32.lib则说明了这个函数包括在User32.dll中。
有了函数原型的定义后,就是调用的问题了,Win32 API调用中要把参数放入堆栈,顺序是最后一个参数最先进栈,在汇编中调用MessageBox函数的方法是:
push uType
push lpCaption
push lpText
push hWnd
call MessageBox
在源程序编译链接成可执行文件后,call MessageBox语句中的MessageBox会被换成一个地址,指向可执行文件中的导入表,导入表中指向MessageBox函数的实际地址会在程序装入内存的时候,根据User32.dll在内存中的位置由Windows系统动态填入。
使用invoke语句
API是可以调用了,另一个烦人的问题又出现了,Win32的API动辄就是十几个参数,整个源程序一眼看上去基本上都是把参数压堆栈的push指令,参数的个数和顺序很容易搞错,由此引起的莫名其妙的错误源源不断,源程序的可读性看上去也很差。如果写的时候少写了一句push指令,程序在编译和链接的时候都不会报错,但在执行的时候必定会崩溃,原因是堆栈对不齐了。
有不有解决的办法呢?最好是像C语言一样,能在同一句中打入所有的参数,并在参数使用错误的时候能够提示。
好消息又来了,Microsoft终于做了一件好事,在MASM中提供了一个伪指令实现了这个功能,那就是invoke伪指令,它的格式是:
invoke 函数名 [,参数1][,参数2]…[,参数n]
对MessageBox的调用在MASM中可以写成:
invoke MessageBox, NULL, offset szText, offset szCaption, MB_OK
注意,invoke并不是80386处理器的指令,而是一个MASM编译器的伪指令,在编译的时候它把上面的指令展开成我们需要的4个push指令和一个call指令,同时,进行参数数量的检查工作,如果带的参数数量和声明时的数量不符,编译器报错:
error A2137: too few arguments to INVOKE
编译时看到这样的错误报告,首先要检查的是有没有少写一个参数。对于不带参数的API调用,invoke伪指令的参数检查功能可有可无,所以既可以用call API_Name这样的语法,也可以用invoke API_Name这样的语法。
API函数的返回值
有的API函数有返回值,如MessageBox定义的返回值是int类型的数,返回值的类型对汇编程序来说也只有dword一种类型,它永远放在eax中。如果要返回的内容不是一个eax所能容纳的,Win32 API采用的方法一般是返回一个指针,或者在调用参数中提供一个缓冲区地址,干脆把数据直接返回到缓冲区中去。
函数的声明
在调用API函数的时候,函数原型也必须预先声明,否则,编译器会不认这个函数。invoke伪指令也无法检查参数个数。声明函数的格式是:
函数名 proto [距离] [语言] [参数1]:数据类型, [参数2]:数据类型,
句中的proto是函数声明的伪指令,距离可以是NEAR,FAR,NEAR16,NEAR32,FAR16或FAR32,Win32中只有一个平坦的段,无所谓距离,所以在定义时是忽略的;语言类型就是.model那些类型,如果忽略,则使用.model定义的默认值。
后面就是参数的列表了,对Win32汇编来说只存在dword类型的参数,所以所有参数的数据类型永远是dword,另外对于编译器来说,它只关心参数的数量,参数的名称在这里是无用的,仅是为了可读性而设置的,可以省略掉,所以下面两句消息框函数的定义实际上是一样的:
MessageBox Proto hWnd:dword, lpText:dword, lpCaption:dword, uType:dword
MessageBox Proto :dword, :dword, :dword, :dword
在Win32环境中,和字符串相关的API共有两类,分别对应两个字符集:一类是处理ANSI字符集的,另一类是处理Unicode字符集的。前一类函数名字的尾部带一个A字符,处理Unicode的则带一个W字符。
我们比较熟悉的ANSI字符串是以NULL结尾的一串字符数组,每一个ANSI字符占一个字节宽。对于欧洲语言体系,ANSI字符集已足够了,但对于有成千上万个不同字符的几种东方语言体系来说,Unicode字符集更有用。每一个Unicode字符占两个字节的宽度,这样一来就可以在一个字符串中使用65536个不同的字符了。
MessageBox和显示字符串有关,同样它有两个版本,严格地说,系统中有两个定义:
MessageBoxA Proto hWnd:dword, lpText:dword, lpCaption:dword, uType:dword
MessageBoxB Proto hWnd:dword, lpText:dword, lpCaption:dword, uType:dword
虽然《Microsoft Win32 Programmer’s Reference》中只有一个MessageBox定义,但User32.dll中确确实实没有MessageBox,而只有MessageBoxA和MessageBoxW,那么为什么还是可以使用MessageBox呢?实际上在程序的头文件user32.inc中有一句:
MessageBox equ <MessageBoxA>
它把MessageBox偷梁换柱变成了MessageBoxA。在源程序中继续沿用MessageBox是为了程序的可读性以及保持和手册的一致性,但对于编译器来说,实际是在使用MessageBoxA。
由于并不是每个Win32系统都支持W系统的API,在Windows 9x系列中,对Unicode是不支持的,很多的API只有ANSI版本,只有Windows NT系列才对Unicode完全支持。为了编写在几个平台中通用的程序,一般应用程序都使用ANSI版本的API函数集。
为了使程序更有移植性,在源程序中一般不直接指明使用Unicode还是ANSI版本,而是使用宏汇编中的条件汇编功能来统一替换,如在源程序中使用MessageBox,但在头文件中定义:
if UNICODE
MessageBox equ <MessageBoxW>
else
MessageBox equ <MessageBoxA>
endif
所有涉及版本问题的API都可以按此方法定义,然后在源程序的头指定UNICODE=1或UNICODE=0,重新编译后就能产生不同的版本。
include语句
对于所有要用到的API函数,在程序的开始部分都必须预先声明,但这一个步骤显然是比较麻烦的,为了简化操作,可以采用各种语言通用的解决办法,就是把所有的声明预先放在一个文件中,在用到的时候再用include语句包含进来。现在回到Win32 Hello World程序,这个程序用到了两个API函数:MessageBox和ExitProcess,它们分别在User32.dll和Kernel32.dll中,在MASM32工具包中已经包括了所有DLL的API函数声明列表,每个DLL对应<DLL名.inc>文件,在源程序中只要使用include语句包含进来就可以了:
include user32.inc
include kernel32.inc
当用到其他的API函数时,只需相应增加对应的include语句。
include语句还用来在源程序中包含别的文件,当多个源程序用到相同的函数定义、常量定义、甚至源代码时,可以把相同的部分写成一个文件,然后在不同的源程序中用include语句包含进来。
编译器对include语句的处理仅是简单地把这一行用指定的文件内容替换掉而而已。
include语句的语法是:
include 文件名
或 include <文件名>
当遇到要包括的文件名和MASM的关键字同名等可能会引起编译器混淆的情况时,可以用<>将文件名括起来。
includelib语句
在DOS汇编中,使用中断调用系统功能是不必声明的,处理器自己知道到中断向量表中去取中断地址。在Win32汇编中使用API函数,程序必须知道调用的API函数存在于哪个DLL中,否则,操作系统必须搜索系统中存在的所有DLL,并且无法处理不同DLL中的同名函数,这显然是不现实的,所以,必须有个文件包括DLL库正确的定位信息,这个任务是由导入库来实现的。
在使用外部函数的时候,DOS下有函数库的概念,那时的函数库实际上是静态库,静态库是一组已经编写好的代码模块,在程序中可以自由引用,在源程序编译成目标文件,最后要链接可执行文件的时候,由link程序从库中找出相应的函数代码,一起链接到最后的可执行文件中。DOS下C语言的函数库就是典型的静态库。库的出现为程序员节省了大量的开发时间,缺点就是每个可执行文件中都包括了要用到的相同函数的代码,占用了大量的磁盘空间,在执行的时候,这些代码同样重复占用了宝贵的内存。
Win32环境中,程序链接的时候仍然要使用函数库来定位函数信息,只不过由于函数代码放在DLL文件中,库文件中只留有函数的定位信息和参数数目等简单信息,这种库文件叫做导入库,一个DLL文件对应一个导入库,如User32.dll文件用于编程的导入库是User32.lib,MASM32工具包中包含了所有DLL的导入库。
为了告诉链接程序使用哪个导入库,使用的语句是:
includelib 库文件名
或 includelib <库文件名>
和include的用法一样,在要包括让编译器混淆的文件名时加括号。
Win32 Hello World程序用到的两个API函数MessageBox和ExitProcess分别在User32.dll和Kernel32.dll中,那么在源程序使用的相应语句为:
includelib user32.lib
includelib kernel32.lib
和include语句的处理不同,includelib不会把.lib文件插入到源程序中,它只是告诉链接器在链接的时候到指定的库文件中去找而已。
API参数中的等值定义
再回过头来看显示消息框的语句:
invoke MessageBox, NULL, offset szText, offset szCaption, MB_OK
在uType这个参数中使用了MB_OK,这个MB_OK是什么意思?
在《Microsoft Win32 Programmer’s Reference》中的说明:
uType——定义对话框的类型,这个参数可以是以下标志的合集:
要定义消息框上显示按钮,用下面的某一个标志:
MB_ABORTRETRYIGNORE——消息框有三个按钮:终止,重试和忽略
MB_HELP——消息框上显示一个帮助按钮,按下后发送WM_HELP消息
MB_OK——消息框上显示一个确定按钮,这是默认值
……
要在消息框中显示图标,用下面的某一个标志:
MB_ICONWARNING——显示惊叹号图标
MB_ICONINFORMATION——显示消息图标
……
这些是uType参数说明中的一小半,可以看出,参数可以用的值有很多种。
MB_ICONWARING和MB_YESNO等参数究竟是什么意思呢?
在Visual C++的目录下中,可以找到头文件WinUser.h,里面定义了如下一段内容:
/* MessageBox() Flags */
#define MB_OK 0x
#define MB_OKCANCEL 0x
#define MB_ABORTRETRYIGNORE 0x
#define MB_YESNOCANCEL 0x
#define MB_YESNO 0x
#define MB_RETRYCANCEL 0x
#define MB_ICONHAND 0x
#define MB_ICONQUESTION 0X
……
显然,MB_YESNO就是4,MB——ICONWARNING就是30h,默认的MB_OK就是0,Win32 API的参数使用这样的定义方法显然是为了免除程序员死记数值定义的麻烦。在编写Win32汇编程序的时候,MASM32工具包中的Windows.inc也包括了所有这些参数的定义,只要在程序的开头包含这个定义文件:
include windows.inc
就可以方便地完全按照API手册来使用Win32函数。
打开\masm32\include 目录下的Windows.inc查看一下,可以发现整个文件总共有两万六千多行,包括了几乎所有的Win32 API参数中的常量和数据结构定义。
标号、变量和数据结构
当程序中要跳转到另一位置时,需要有一个标识来指示新的位置,这就是标号,通过在目的地址的前面放上一个标号,可以在指令中使用标号来代替直接使用地址。
使用变量是任何编程语言都要遇到的工作,Win32汇编也不例外,在MASM中使用变量也有需要注意的几个问题,错误地使用变量定义或用错误的方法初始化变量会带来难以定位的错误。
变量是计算机内存中已命名的存储位置,在C语言中有很多种类的变量,如整数型、浮点型和字符型等,不同的变量有不同的用途和尺寸,比如说虽然长整数和单精度浮点数都是32位长,但它们的用途不同。
顾名思义,变量的值在程序运行中是需要改变的,所以它必须定义在可写的段内,如.data和.data?,或者在堆栈内。按照定义的位置不同,MASM中的变量也分为全局变量和局部变量两种。
在MASM中标号和变量的命名规范是相同的,它们是:
1)可以用字母、数字、下划级及符号@、$和?。
2)第一个符号不能是数字。
3)长度不能超过240个字符。
4)不能使用指令名等关键字。
5)在作用域内必须是唯一的。
标号
标号的定义
当在程序中使用一条跳转指令的时候,可以用标号来表示跳转的目的地,编译器在编译的时候会把它替换成地址,标号既可以定义在目的指令同一行的头部,也可以在目的指令前一行单独用一行定义,标号定义的格式是:
标号名: 目的指令
标号的作用域是当前的子程序,在单个子程序中的标号不能同名,否则编译器不知该用哪个地址,但在不同的子程序中可以有相同名称的标号,这意味着不能从一个子程序中用跳转指令跳到另一个子程序中。
在低版本的MASM中,标号在整个程序中是唯一的,子程序中的标号也可以从整个程序的任何地方转入。但Win32汇编使用的高版本MASM中不允许这样,这是为了提供对局部变量和参数的支持,由于在子程序入口有对堆栈的初始化指令,所以一个子程序不允许有多个入口,其结果主是标号的作用域变成了单个子程序范围。
MASM中的@@
在DOS时代,为标号起名是个麻烦的事情,因为汇编指令用到跳转指令特别多,任何比较和测试等都要涉及跳转,所以在程序中会有很多标号,在整个程序范围内起个不重名的标号要费一番功夫,结果常常用addr1和addr2之类的标号一直延续下去,如果后来要在中间插一个标号,那么就常常出现addr1_1和loop10_5之类奇怪的标号。
实际上,很多标号会使用一到两次,而且不一定非要起个有意义的名称,如汇编程序中下列代码结构很多:
mov cx,1234h
cmp flag,1
je loc1
mov cx,1000h
loc1:
loop loc1
loc1在别的地方就再也用不到了,对于这种情况,高版本的MASM用@@标号去代替它:
mov cx,1234h
cmp flag,1
je @F
mov cx,1000h
@@:
loop @B
当用@@做标号时,可以用@F和@B来引用它,@F表示本条指令后的第一个@@标号,@B表示本条指令前的第一个@@标号,程序中可以有多个@@标号,@B和@F只寻找匹配最近的一个。
不要在间隔太远的代码中使用@@标号,因为在以后的修改中@@和@B,@F中间可能会被无意中插入一个新的@@,这样一来,@B或@F就会引用到错误的地方去,源程序中@@标号和跳转指令之间的距离最好限制在编辑器能够显示的同一屏幕的范围内。
全局变量
全局变量的定义
全局变量的作用域是整个程序,Win32汇编的全局变量定义在.data或.data?段内,可以同时定义变量的类型和长度,格式是:
变量名 类型 初始值1, 初始值2,…
变量名 类型 重复数量 dup (初始值1,初始值2,…)
MASM中可以定义的变量类型相当多。
名称 |
表示方式 |
缩写 |
长度(字节) |
字节 |
byte |
db |
1 |
字 |
word |
dw |
2 |
双字(double word) |
dword |
dd |
4 |
三字(far word) |
fword |
df |
6 |
四字(quad word) |
qword |
dq |
8 |
十字节BCD码(ten byte) |
tbyte |
dt |
10 |
有符号字节(sign byte) |
sbyte |
|
1 |
有符号字(sign word) |
sword |
|
2 |
有符号双字(sign dword) |
sdword |
|
4 |
单精度浮点数 |
real4 |
|
4 |
双精度浮点数 |
real8 |
|
8 |
10字节浮点数 |
real10 |
|
10 |
所有使用到变量类型的情况中,只有定义全局变量的时候类型才可以用缩写,现在先来看全局变量定义的几个例子:
.data
wHour dw ? ;例1
wMinute dw 10 ;例2
_hWnd dd ? ;例3
word_Buffer dw 100 dup (1,2) ;例4
szBuffer byte 1024 dup (?) ;例5
szText db ‘Hello,world!’ ;例6
例1定义了一个未初始化的word类型变量,名称为wHour。
例2定义了一个名为wMinute的word类型变量。
例3定义了一个双字类型的变量_hWnd。
例4定义了一组字,以0001,0002,0001,0002,的顺序在内存中重复100遍,一共是200个字节。
例5定义了一个1024字节的缓冲区。
例6定义了一个字符串,总共占用了12个字节。两头的单引号是定界的符号,并不属于字符串中真正的内容。
在byte类型变量的定义中,可以用引号定义字符串和数值定义的方法混用,假设要定义两个字符串Hello,World!和Hello again,每个字符串后面中回车和换行符,最后以一个0字符结尾,可以定义如下:
szText db ‘Hello,World!’,0dh,0ah,’Hello again’,0dh,0ah,0
全局变量的初始化值
全局变量在定义中既可以指定初值,也可以只用问题预留究竟,在.data?段中,只能用问号预留究竟,因为.data?段中不能指定初始值,这里就有一个问题:既然可以用问号预留空间,那么在实际运行的时候,这个未初始化的值是随机的还是确定的呢?在全局变量中,这个值就是0,所以用问号指定的全局变量如果要以0为初始值的话,在程序中可以不必为它赋值。
局部变量
局部变量这个名称最早源于高级语言,主要是为了定义一些仅在单个函数里面有用的变量而提出的,使用局部变量能带来一些额外的好处,它使程序的模块化封装变得可能,试想一下,如果要用到的变量必须定义在程序的数据段里面,假设在一个子程序中要用到一些变量,当把这个子程序移植到别的程序时,除了把代码移过去以外,还必须把变量定义移过去。而即使把变量定义移过去了,由于这些变量定义在大家都可以用的数据段中,就无法对别的代码保持透明,别的代码有可能有意无意地修改它们。还有,在一个大的工程项目中,存在很多的子程序,所有的子程序要用到的变量全部定义在数据段中,会使数据段变得很大,混在一起的变量也使维护变得非常不方便。
局部变量这个概念出现以后,两个以上子程序都要用到的数据才被定义为全局变量统一放在数据段中,仅在子程序内部使用的变量则放在堆栈中,这样子程序可以编成黑匣子的模样,使程序的模块结构更加分明。
局部变量的作用域是单个子程序,在进入子程序的时候,通过修改堆栈指针esp来预留出需要的空间,在用ret指令返回主程序之前,同样通过恢复esp丢弃这些空间,这些变量就随之无效了。它的缺点就是因为空间是临时分配的,所以无法定义含有初始化值的变量,对局部变量的初始化一般在子程序中由指令完成。
在DOS时代,低版本的宏汇编本来无所谓全局变量和局部变量,所有的变量都是定义在数据段里面的,能让被所有的子程序或主程序存取,就相当于现在所说的全局变量,用汇编语言在堆栈中定义局部变量是很麻烦的一件事情。要和高级语言做混合编程的时候,程序员往往很痛苦地在边上准备一张表,表上的内容是局部变量名和ebp指针的位置关系。
局部变量的定义
MASM用local伪指令提供了对局部变量的支持。定义的格式是:
local 变量名1 [[重复数量]] [:类型], 变量名2 [[重复数量]] [:类型] ……
local伪指令必须紧接在子程序定义的伪指令proc后、其他指令开始前,这是因为局部变量的数目必须在子程序开始的时候就确定下来,在一个local语句定义不下的时候,可以有多个local语句,语法中的数据类型不能用缩写,如果要定义数据结构,可以用数据结构的名称当做类型。Win32汇编默认的类型是dword,如果定义dword类型的局部变量,则类型可以省略。当定义数组的时候,可以[]括号起来。不能使用定义全局变量的dup伪指令。局部变量不能和已定义的全局变量同名。局部变量的作用域是当前子程序,所以在不同的子程序中可以有同名的局部变量。
定义局部变量的例子:
local local[1024]:byte ;例1
local loc2 ;例2
local loc3:WNDCLASS ;例3
例1定义了一个1024字节长的局部变量loc1。
例2定义了一个名为loc2的局部变量,类型是默认值dword。
例3定义了一个WNDCLASS数据结构,名为loc3。
下面是局部变量使用的一个典型的例子:
TestProc proc
local @loc1:dword, @loc2:word
local @loc3:byte
mov eax,@loc1
mov ax,@loc2
mov al,@loc3
ret
TestProc endp
这是一个名为TestProc的子程序,用local语句定义了3个变量,@loc1是dword类型,@loc2是word类型,@loc3是byte类型,在程序中分别有3句存取3个局部变量的指令,然后就返回了,编译成可执行文件后,再把它反汇编就得到以下指令:
:00401000 55 push ebp
:00401001 8BEC mov ebp, esp
:00401003
:00401006 8B45FC mov eax, dword ptr [ebp-04]
:00401009 668B45FA mov ax, word ptr [ebp-06]
:0040100D
:
:
可以看到,反汇编后的指令比源程序多了前后两段指令,它们是:
:00401000 55 push ebp
:00401001 8BEC mov ebp, esp
:00401003
:
这些就是使用局部变量所必需的指令,分别用于局部变量的准备工作和扫尾工作。执行了call指令后,CPU把返回的地址压入堆栈,再转移到子程序执行,esp在程序的执行过程中可能随时用到,不可能用esp来随时存取局部变量,ebp寄存器是以堆栈段为默认数据段的,所以,可以用ebp做指针,于是,在初始化前,先用一句push ebp指令把原来的dbp保存起来,然后把esp的值放到ebp中,供存取局部变量做指针用,再后面就是堆栈中预留空间了,由于堆栈是向下增长的,所以要在esp中加一个负值,FFFFFFF8就是-8,慢着!一个dword加一个word加一个字节不是7吗,为什么是8呢?这是因为在80386处理器中,以dword为界对齐时存取内存速度最快,所以MASM宁可浪费一个字节,执行了这3句指令后,初始化完成,就可以进行正常的操作了,从指令中可以看出局部变量在堆栈中的位置排列。
在程序退出的时候,必须把正确的esp设置回去,否则,ret指令会从堆栈中取出错误的地址返回,看程序可以发现,ebp就是正确的esp值,因为子程序开始的时候已经有一句mov ebp,esp,所以要返回的时候只要先mov esp,ebp,然后再pop ebp,堆栈就是正确的了。
在80386指令集中有一条指令可以在一句中实现这些功能,就是leave指令,所以,编译器在ret指令之前只使用了一句leave指令。
明白了局部变量使用的原理,就很容易理解使用时的注意点:ebp寄存器是关键,它起到保存原始esp的作用,并随时用做存取局部变量的指针基址,所以在任何时刻,不要尝试把ebp用于别的用途,否则会带来意想不到的后果。
Win32汇编中局部变量的使用方法可以解释一个很有趣的现象:在DOS汇编的时候,如果在子程序中的push指令和pop指令不配对,那么返回的时候ret指令从堆栈里得到的肯定是错误的返回地址,程序也就死掉了。但在Win32汇编中,push指令和pop指令不配对可能在逻辑上产生错误,却不会影响子程序正常返回,原因就是在返回的时候esp不是靠相同数量的push和pop指令来保持一致的,而是靠leave指令从保存在ebp中的原始值中取回来的,也就是说,即使把esp改得一塌糊涂也不会影响到子程序的返回,当然,窍门就在ebp,把ebp改掉,程序就玩完了!
局部变量的初始化值
显然,局部变量是无法在定义的时候指定初始化值的,因为local伪指令只是简单地把空间给留出来,那么开始使用时它里面是什么值呢?和全局变量不一样,局部变量的初始值是随机的,是其他子程序执行后在堆栈里留下的垃圾,所以,对局部变量的值一定要初始化,特别是定义为结构后当参数传递给API函数的时候。
在API函数使用的大量数据结构中,往往用0做默认值,如果用局部变量定义数据结构,初始化时只定义了其中的一些字段,那么其余字段的当前值可以是编程者预想不到的数值,传给API函数后,执行的结果可能是意想不到的,这是初学者很容易忽略的一个问题。所以最好的办法是:在赋值前首先将整个数据结构填0,然后再初始化要用的字段,这样其余的字段就不必一个个地去填0了,RtlZeroMemory这个API函数就是实现填0的功能的。
数据结构
数据结构实际上是由多个字段组成的数据样板,相当于一种自定义的数据类型,数据结构中间的每一个字段可以是字节、字、双字、字符串或所有可能的数据类型。
比如在API函数RegisterClass中要使用到一个叫做WNDCLASS的数据结构,Microsoft的手册上是如下定义的
typeof struct _WNDCLASS {
UINT style;
WNDPROC lpfnWndProc;
Int cbClsExtra;
Int cbWndExtra;
HINSTANCE hInstance;
HICON hIcon;
HCURSOR hCursor;
HBRUSH hbrBackground;
LPCTSTR lpszMenuName;
LPCTSTR lpszClassName;
}WNDCLASS, *PWNDCLASS;
注意,这是C语言格式的,这个数据结构包含了10个字段,字段的名称是style,lpfnWndProc和cbClsExtra等,前面的UINT和WNDPROC等是这些字段的类型,在汇编中,数据结构的写法如下:
结构名 struct
字段1 类型 ?
字段2 类型 ?
……
结构名 ends
上面的WNDCLASS结构定义用汇编的格式来表示就是:
WNDCLASS struct
Style DWORD ?
LpfnWndProc DWORD ?
cbClsExtra DWORD ?
cbWndExtra DWORD ?
hInstance DWORD ?
hIcon DWORD ?
hCursor DWORD ?
hbrBackground DWORD ?
lpszMenuName DWORD ?
lpszClassName DWORD ?
WNDCLASS ends
和大部分的常量一样,几乎所有API所涉及的数据结构在Windows.inc文件中都已经有定义了。要注意的是,定义了数据结构实际上只是定义了一个样板,上面的定义语句并不会在哪个段中产生数据,和Word中使用各种信纸与文书等模板类似,定义了数据结构以后就可以多次在源程序中用这个样板当做数据类型来定义数据,使用数据结构在数据段中定义数据的方法如下:
.data?
stWndClass WNDCLASS <>
……
.data
stWndClass WNDCLASS <1,1,1,1,1,1,1,1,1,1,>
……
这个例子定义了一个以WNDCLASS为结构的变量stWndClass,第一段的定义方法是未初始化的定义方法,第二段是在定义的同时指定结构中各字段的初始化值,各字段的初始值用逗号隔开,在这个例子中10个字段的初始值都指定为1。
在汇编中,数据结构的引用方法有好几种,以上面的定义为例,如果要使用stWndClass中的lpfnWndProc字段,最直接的办法是:
mov eax,stWndClass.lpfnWndProc
它表示把lpfnWndProc字段的值放入eax中去,假设stWndClass在内存中的地址是403000h,这句指令会被编译成mov eax,[403004h],因为lpfnWndProc是stWndClass中的第二个字段,第一个字段是dword,已经占用了4字节的空间。
在实际使用中,常常有使用指令存取数据结构的情况,如果使用esi寄存器做指针寻址,可以使用下列语句完成同样的功能:
mov esi,offset stWndClass
move ax,[esi + WNDCLASS.lpfnWndProc]
注意:第二句是[esi + WNDCLASS.lpfnWndProc]而不是[esi + stWndClass.lpfnWndProc],因为前者被编译成mov eax,[esi + 4],而后者被编译成mov eax,[esi + 403004h],后者的结果显然是错误的!如果要对一个数据结构中的大量字段进行了操作,这种写法显然比较烦琐,MASM还有一个用法,可以用assume伪指令把寄存器预先定义为结构指针,再进行操作:
mov esi,offset stWndClass
assume esi:ptr WNDCLASS
move ax,[esi].lpfnWndProc
……
assume esi:nothing
这样,使用寄存器也可以用逗号引用字段名,程序的可读性比较好。这样的写法在最后编译成可执行程序的时候产生同样的代码。注意:在不再使用esi寄存器做指针的时候要用assume esi:nothing取消定义。
结构的定义也可以嵌套,如果要定义一个新的NEW_WNDCLASS结构,里面包含一个老的WNDCLASS结构和一个新的dwOption字段,那么可以如下定义:
NEW_WNDCLASS struct
DwOption dword ?
OldWndClass WNDCLASS <>
NEW_WNDCLASS ends
假设现在esi是指向一个NEW_WNDCLASS的指针,那么引用里面嵌套的oldWndClass中的lpfnWndProc字段时,就可以用下面的语句:
move ax,[esi].oldWndClass.lpfnWndProc
结构的嵌套在Windows的数据定义中也常有,熟练掌握数据结构的使用对Win32汇编编程是很重要的!
变量的使用
以不同的类型访问变量
这个话题有点像C语言中的数据类型强制转换,C语言中的类型转换指的是把一个变量的内容转换成另外一种类型,转换过程中,数据的内容已经发生了变化,如把浮点数转换成整数后,小数点后的内容就丢失了。在MASM中以不同的类型访问不会对变量造成影响。
例如,以db方式定义一个缓冲区:
szBuffer db 1024 dup (?)
然后从其他地方取得了数据,但数据的格式是字方式组织的,要处理数据,最有效的方法是两个字节两个字节处理,但如果在程序中把szBuffer的值放入ax:
mov ax,szBuffer
编译器会报一个错:
error A2070: invalid instruction operands
意思是无效的指令操作,为什么呢?因为szBuffer是用db定义的,而ax的尺寸是一个word,等于两个字节,尺寸不符合。MASM中,如果要用指定类型之外的长度访问变量,必须显式地指出要访问的长度,这样,编译器忽略语法上的长度检验,仅使用变量的地址。使用的方法是:
类型 ptr 变量名
类型可以是byte, word, dword, fword, qword, real8和real10。如:
mov ax,word ptr szBuffer
mov eax,dword ptr szBuffer
DOS汇编中也有这种用法。
上述语句能通过编译,当然,类型必须和操作的寄存器长度匹配。在这里要注意的是,指定类型的参数访问并不会去检测长度是否溢出,看下面一段代码:
.data
bTest1 db 12h
wTest2 dw 1234h
dwTest3 dd 12345678h
……
.code
mov al,bTest1
mov ax,word ptr bTest1
mov eax,dword ptr bTest1
……
上面的程序片断,每一句执行后寄存器中的值是什么呢,mov al,bTest1这一句很显然使al等12h,下面的两句呢,ax和eax难道等于0012h和00000012h吗?实际运行结果是3412h和78123412h,为什么呢?(DOS汇编基础不错的同学,应该能理解)先来看反汇编的内容:
: .data段中的变量
:00403000 12 34 12 78 56 34 12 …
: .code段中的代码
:
:00401005
:0040100B A100304000 mov eax, dword ptr [00403000]
.data段中的变量是按顺序从低地址往高地址排列的,对于超过一个字节的数据,80386处理器的数据排列方式是低位数据在低地址,所以wTest2的1234h在内存中的排列是34h 12h,因为34h是低位。同样,dwTest3在内存中以78h 56h 34h 12h从低地址往高地址存放,在执行指令mov ax,word ptr bTest1的时候,是从bTest1的地址403000h处取一个字,其长度已经超过了bTest1的范围并落到了wTest2中,从内存中看,是取了bTest1的数据12h和wTest2的低位34h,在这两个字节中,12h位于低地址,所以ax中的数值是3412h。同理,看另一条指令:
move ax,dword ptr bTest1
这条指令取了bTest1,wTest2的全部和dwTest3的最低位78h,在内存中的排列是12h 34h 12h 78h,所以eax等于78123412h。
这个例子说明了汇编中用ptr强制覆盖变量长度的时候,实质上是只用了变量的地址而禁止编译器进行检验,编译器并不会考虑定界的问题,程序员在使用的时候必须对内存中的数据排列有个全局概念,以免越界存取到意料之外的数据。
如果程序员的本意是类似于C语言的强制类型转换,想把bTest1的一个字节扩展到一个字或一个双字再放到ax或eax中,高位保持0而不是越界存取到其他的变量,可以用80386的扩展指令来实现。80386处理器提供的movzx指令可以实现这个功能,例如:
movzx ax,bTest1 ;例1
movzx eax,bTest1 ;例2
movzx eax,cl ;例3
movzx eax,ax ;例4
例1把单字节变量bTest1的值扩展到16位放入ax中。
例2把单字节变量bTest1的值扩展到32位放入eax中。
例3把cl中的8位值扩展到32位放入eax中。
例4把ax中的16位值扩展到32位放入eax中。
用movzx指令进行数据长度扩展是Win32汇编中经常用到的技巧。
变量的尺寸和数量
在源程序中用到变量的尺寸和数量的时候,可以用sizeof和lengthof伪指令来实现,格式是:
sizeof 变量名、数据类型或数据结构名
lengthof 变量名
sizeof伪指令可以取得变量、数据类型或数据结构以字节为单位的长度,lengthof可以取得变量中数据的项数。例如定义了以下数据:
stWndClass WNDCLASS <>
szHello db ‘Hello,world!’,0
dwTest dd 1,2,3,4
……
.code
……
mov eax, sizeof stWndClass
mov ebx, sizeof WNDCLASS
mov ecx, sizeof szHello
mov edx, sizeof dword
mov esi, sizeof dwTest
执行后eax的值是stWndClass结构的长度40,ebx同样是40,ecx的值是13,就是Hello,world!字符串的长度加上一个字节的0结束符,edx的值是一个双字的长度:4,而esi则等于4个双字的长度16。
如果把所有的sizeof换成lengthof,那么eax会等于1,因为只定义了1项WNDCLASS,而ecx同样等于13,esi则等于4,而lenghof WNDCLASST和lengthof dword是非法的用法,编译程序会报错。
要注意的是,sizeof和lengthof的数值是编译时产生的,由编译器传递到指令中去,上边的指令最后产生的代码就是:
mov eax,40
mov ebx,40
mov ecx,13
mov edx,4
mov esi,16
如果为了把Hello和World分两行定义,szHello是这样定义的:
szHello db ‘Hello’,odh,oah
db ‘World’,0
那么sizeof szHello是多少呢?注意!是7而不是13,MASM中的变量定义只认一行,后一行db ‘World’,0实际上是另一个没有名称的数据定义,编译器认为sizeof szHello是第一行字符的数量。虽然把szHello的地址当参数传给MessageBox等函数显示时会把两行都显示出来,但严格地说这是越界使用变量。虽然在实际的应用中这样定义长字符串的用法很普遍,因为如果要显示一屏幕帮助,一行是不够的,但要注意的是:要用到这种字符串的长度时,千万不要用sizeof去表示,最好是在程序中用lstrlen函数去计算。
获取变量地址
获取变量地址的操作对于全局变量和局部变量是不同的。
对于全局变量,它的地址在编译的时候已经由编译器确定了,它的用法大家都不陌生:
mov 寄存器, offset 变量名
其中offset是取变量地址的伪操作符,和sizeof伪操作符一样,它仅把变量的地址带到指令中去,这个操作是在编译时而不是在运行时完成的。
对于局部变量,它是用ebp来做指针操作的,假设ebp的值是40100h,那么局部变量l的地址是ebp-4即400FCh,由于ebp的值随着程序的执行环境不同可能是不同的,所以局部变量的地址值在编译的时候也是不确定的,不可能用offset伪操作符来获取它的地址。
80386处理器中有一条指令用来取指针的地址,就是lea指令,如:
lea eax,[ebp-4]
该指令可以在运行时按照ebp的值实际计算出地址放到eax中。
如果要在invoke伪指令的参数中用到一个局部变量的地址,该怎么办呢?参数中是不可能写入lea指令的,用offset又是不对的。MASM对此有一个专用的伪操作符addr,其格式为:
addr 局部变量名和全局变量名
当addr后跟全局变量名的时候,用法和offset是相同的;当addr后面跟局部变量名的时候,编译器自动用lea指令先把地址取到eax中,然后用eax来代替变量地址使用。注意addr伪操作符只能在invoke的参数中使用,不能用在类似于下列的场合:
move ax, addr 局部变量名 ;注意:错误用法
假设在一个子程序中有如下invoke指令:
invoke Test,eax, addr szHello
其中Test是一个需要两个参数的子程序,szHello是一个局部变量,会发生什么结果呢?编译器会把invoke伪指令和addr翻译成下面这个模样:
lea eax,[ebp-4]
push eax ;参数2:addr szHello
push eax ;参数1:eax
call Test
发现了什么?到push第一个参数eax之前,eax的值已经被lea eax,[ebp-4]指令覆盖了!也就是说,要用到的eax的值不再有效,所以,当在invoke中使用addr伪操作符时,注意在它的前面不能用eax,否则eax的值会被覆盖掉,当然eax在addr的后面的参数中用是可以的。幸亏MASM编译器对这种情况有如下错误提示:
error A2133:register value overwritten by INVOKE
否则,不知道又会引出多少莫名其妙的错误!
使用子程序
当程序中相同功能的一段代码用得比较频繁时,可以将它分离出来写成一个子程序,在主程序中用call指令来调用它。这样可以不用重复写相同的代码,而用call指令就可以完成多次同样的工作了。Win32汇编中的子程序也采用堆栈来传递参数,这样就可以用invoke伪指令来进行调用和语法检查工作。
子程序的定义
子程序的定义方式如下所示:
子程序名 proc [距离] [语言类型] [可视区域] [USES寄存器列表] [,参数:类型]…[VARARG]
local 局部变量列表
指令
子程序名 endp
proc和endp伪指令定义了子程序开始和结束的位置,proc后面跟的参数是子程序的属性和输入参数。子程序的属性有:
距离。可以是NEAR,FAR,NEAR16,NEAR32,FAR16或FAR32,Win32中只有一个平坦的段,无所谓距离,所以对距离的定义往往忽略。
语言类型表示参数的使用方式和堆栈平衡的方式,可以是StdCall,C,SysCall,BASIC,FORTRAN和PASCAL,如果忽略,则使用程序头部.model定义的值。
可视区域,可以是PRIVATE,PUBLIC和EXPORT。PRIVATE表示子程序只对本模块可见;PUBLIC表示对所有的模块可见(在最后编译链接完成的.exe文件中);EXPORT表示是导出的函数,当编写DLL的时候要将某个函数导出的时候可以这样使用。默认的设置是PUBLIC。
USES寄存器列表,表示由编译器在子程序指令开始前自动安排push这些寄存器的指令,并且在ret前自动安排pop指令,用于保存执行环境,但笔者认为不如自己在开头和结尾用pushad和popad指令一次保存和恢复所有寄存器来得方便。
参数和类型。参数指参数的名称,在定义参数名的时候不能跟全局变量和子程序中的局部变量重名。对于类型,由于Win32中的参数类型只有32位(dword)一种类型,所以可以省略。在参数定义的最后还可以跟VARARG,表示在已确定的参数后还可以跟多个数量不确定的参数,在Win32汇编中唯一使用VARARG的API就是wsprintf,类似于C语言中的printf,其参数的个数取决于要显示的字符串中指定的变量个数。
完成了定义之后,可以用invoke伪指令来调用子程序,当invoke伪指令位于子程序代码之前的时候,处理到invoke语句的时候编译器还没有扫描到子程序定义信息的记录,所以会有以下错误的信息:
error A2006: undefined symbol: _ProcWinMain
这并不是说子程序的编写有错误,而是invoke伪指令无法得知子程序的定义情况,所以无法进行参数的检测。在这种情况下,为了让invoke指令能正常使用,必须在程序的头部用proto伪操作定义子程序的信息,提前告诉invoke语句关于子程序的信息,当然,如果子程序定义在前的话,用proto的定义就可以省略了。
由于程序的调试过程中可能常常对一些子程序的参数个数进行调整,为了使它们保持一致,就需要同时修改proc语句和proto语句。在写源程序的时候有意识地把子程序的位置提到invoke语句的前面,省略掉proto语句,可以简化程序和避免出错。
参数传递和堆栈平衡
了解了子程序的定义方法后,让我们继续深入了解了程序的使用细节。在调用子程序时,参数的传递是通过堆栈进行的,也就是说,调用者把要传递给子程序的参数压入堆栈,子程序在堆栈中取出相应的值再使用,比如,如果要调用:
SubRouting(Var1, Var2, Var3)
经过编译后的最终代码可能是(注意只是可能):
push Var3
push Var2
push Var1
call SubRouting
add esp,12
也就是说,调用者首先把参数压入堆栈,然后调用子程序,在完成后,由于堆栈中先前压入的数不再有用,调用者或者被调用者必须有一方把堆栈指针修正到调用前的状态,即堆栈的平衡。参数是最右边的先入堆栈还是最左边的先入堆栈、还有由调用者还是被调用者来修正堆栈都必须有个约定,不然就会产生错误的结果,这就是在上述文字中使用“可能”这两个字的原因。各种语言中调用子程序的约定是不同的,所以在proc以及proto语句的语言属性中确定语言类型后,编译器才可能将invoke伪指令翻译成正确的样子,不同语言的不同点如下:
|
C |
SysCall |
StdCall |
BASIC |
FORTRAN |
PASCAL |
最先入栈参数 |
右 |
右 |
右 |
左 |
左 |
左 |
清除堆栈者 |
调用者 |
子程序 |
子程序 |
子程序 |
子程序 |
子程序 |
允许使用VARARG |
是 |
是 |
是 |
否 |
否 |
否 |
注:VARARG表示参数的个数可以是不确定的,如wsprintf函数,本表中特殊的地方是StdCall的堆栈清除平时是由子程序完成的,但使用VARARG时是由调用者清除的。
为了了解编译器对不同类型子程序的处理方式,先来看一段源程序:
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Sub1 proc C _Var1,_Var2
mov eax, _Var1
mov ebx,_Var2
ret
Sub1 endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Sub2 proc PASCAL _Var1, _Var2
mov eax, _Var1
mov ebx, _Var2
ret
Sub2 endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Sub3 proc _Var1, _Var2
mov eax,_Var1
mov ebx,_Var2
ret
Sub3 endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
……
invoke Sub1,1,2
invoke Sub2,1,2
invoke Sub3,1,2
编译后再进行反汇编,看编译器是如何转换处理不同类型的子程序的:
;这里是Sub1 – C类型
:00401000 55 push ebp
:00401001 8BEC mov ebp,esp
:00401003 8B4508 mov eax, dword ptr [ebp+08]
:00401006 8B5D
:
:
;这里是Sub2 – PASCAL类型
:0040100B 55 push ebp
:
:0040100E 8B
:00401011 8B5D08 mov ebx, dword ptr [ebp+08]
:
:
;这里是Sub3 – StdCall类型
:00401018 55 push ebp
:00401019 8BEC mov ebp,esp
:0040101B 8B4508 mov eax, dword ptr [ebp+08]
:0040101E 8B5D
:
:
……
;这里是invoke Sub1,1,2 – C类型
:00401025
:00401027
:00401029 E8D2FFFFFF call 00401000
:0040102E
;这里是invoke Sub2,1,2 -- PASCAL类型
:00401031
:00401033
:00401035 E8D1FFFFFF call 0040100B
;这里是invoke Sub3,1,2 – StdCall类型
:
:
:0040103E E8D5FFFFFF call 00401018
可以清楚地看到,在参数入栈顺序上,C类型和StdCall类型是先把右边的参数先压入堆栈,而PASCAL类型是先把左边的参数压入堆栈。在堆栈平衡上,C类型是在调用者在使用call指令完成后,自行用add esp,8指令把8个字节的参数空间清除,而PASCAL和StdCall的调用者则不管这个事情,堆栈平衡的事情是由子程序用ret 8来实现的,ret指令后面加一个操作数表示在ret后把堆栈指针esp加上操作数,完成的是同样的功能。
Win32约定的类型是StdCall,所以在程序中调用子程序或系统API后,不必自己来平衡堆栈,免去了很多麻烦。
存取参数和局部变量都是通过堆栈来定义的,所以参数的存取也是通过ebp做指针来完成的。在探讨局部变量的时候,已经就没有参数的情况下ebp指针和局部变量的对应关系做了分析,现在来分析一下ebp指针和参数之间的对应关系,注意,这里是以Win32中的StdCall为例,不同的语言类型,指针的顺序可能是不同的。
假定在一个子程序中有两个参数,主程序调用时在push第一个参数前的堆栈指针esp为X,那么压入两个参数后的esp为X-8,程序开始执行call指令,call指令把返回地址压入堆栈,这时候esp为X-C,接下去是子程序中用push ebp来保存ebp的值,esp变为X-10,再执行一句mov ebp,esp,就可以开始用ebp存取参数和局部变量了。
在源程序中,由于参数、局部变量和ebp的关系是由编译器自动维护的,所以读者不必关心它们的具体关系,但到了用Soft-ICE等工具来分析其他软件的时候,遇到调用子程序的时候一定要先看清楚它们之间的类型差别。
在子程序中使用参数,可以使用与存取局部变量同样的方法,因为这两者的构造原理几乎一模一样,所以,在子程序中有invoke语句时,如果要用到输入参数的地址当做invoke的参数,同样要遵循局部变量的使用方式,不能用offset伪操作符,只能用addr来完成。同样,所有对局部变量使用的限制几乎都可以适用于参数。
高级语法
以前高级语言和汇编的最大差别就是条件测试、分支和循环等高级语法。
高级语言中,程序员可以方便地用类似于if,case,loop和while等语句来构成程序的结构流程,不仅条理清楚、一目了然,而且维护性相当好。而汇编程序员呢?只能在cmp指令后面绞尽脑汁地想究竟用几十种跳转语句中的哪一种,这里就能列出近三十个条件跳转指令来:ja,jae,jb,jeb,jc,je,jg,jge,jl,jle.jna,jnb,jnbe,jnc,jng,jnge,jnl,jno,jnp,jns,jnz,jo,jp,jpe,jpo以及jz等。虽然其中的很多指令我们一辈子也不会用到,但就是这些指令和一些loop,loopnz以及被loop涉及的ecx等寄存器纠缠在一起,使在汇编中书写结构清晰、可读性好的代码变得相当困难,这也是很多人视汇编为畏途的一个原因。
现在好了,MASM中新引入了一系列的伪指令,涉及条件测试、分支和循环语句,利用它们,汇编语言有了和高级语言一样的结构,配合对局部变量和调用参数等高级语言中觉元素的支持,为使用Win32汇编编写大规模的应用程序奠定了基础。
条件测试语句
在高级语言中,所有的分支和循环语句首先要涉及条件测试,也就是涉及一个表达式的结果是真还是假的问题,表达式中往往有用来做比较和计算的操作符,MASM也不例外,这就是条件测试语句。
MASM条件测试的基本表达式是:
寄存器或变量 操作符 操作数
两个以上的表达式可以用逻辑运算符连接:
(表达式1) 逻辑运算符 (表达式2) 逻辑运算符 (表达式3) …
允许的操作符和逻辑运算符如下所示:
条件溑或的操作符
操作符和逻辑运算符 |
操作 |
用途 |
== |
等于 |
变量和操作数之间的比较 |
!= |
不等于 |
变量和操作数之间的比较 |
> |
大于 |
变量和操作数之间的比较 |
>= |
大于等于 |
变量和操作数之间的比较 |
< |
小于 |
变量和操作数之间的比较 |
<= |
小于等于 |
变量和操作数之间的比较 |
& |
位测试 |
将变量和操作数做与操作 |
! |
逻辑取反 |
对变量取反或对表达式的结果取反 |
&& |
逻辑与 |
对两个表达式的结果进行逻辑与操作 |
|| |
逻辑或 |
对两个表达式的结果进行逻辑或操作 |
举例,左边为表达式,右边是表达式为真的条件:
x == 3 ;x等于3
eax != 3 ;eax不等于3
(y>=3) && ebx ;y大于等于3且ebx为非零值
(z&1) ||!eax ;z和1进行“与”操作后非零或eax取反后非零
;也就是说z的位0等于1或eax为零
细心的读者一定会发现,MASM的条件测试采用的是和C语言相同的语法。如!和&是对变量的操作符(取反和与操作),||和&&是表达式结果之间的逻辑与和逻辑或,而==、!=、>、<等是比较符。同样,对于不含比较符的单个变量或寄存器,MASM也是将所有非零认为是真,零值认为是假。
MASM的条件测试语句有几个限制,首先是表达式的左边只能是变量或寄存器,不能为常数;其次表达的两边不能同时为变量,但可以同时是寄存器。这些限制来自于80x86的指令,因为条件测试伪操作符只是简单地把每个表达式翻译成cmp或test指令,80x86的指令集中没有cmp 0,eax之类的指令,同时也不允许直接操作两个内存中的数,所以对这两个限制是很好理解的。
除了这些和高级语言类似的条件测试伪操作,汇编语言还有特殊的要求,就是程序中常常要根据系统标志寄存器中的各种标志位来做条件跳转,这些在高级语言中是用不到的,所以又增加了以下一些标志位的状态指示,它们本身相当于一个表达式:
CARRY? 表示Carry位是否置位
OVERFLOW? 表示Overflow位是否置位
PARITY? 表示Parity位是否置位
SIGN? 表示Sign位是否置位
ZERO? 表示Zero位是否置位
要测试eax等于ebx同时Zero位置位,条件表达式可以写为:
(eax == ebx) && ZERO?
要测试eax等ebx同时Zero位清零,条件表达式可以写为:
(eax == ebx) && !ZERO?
和C语言的条件测试同样,MASM的条件测试伪指令并不会改变被测试的变量或寄存器的值,只是进行测试而已,到最后它会被编译器翻译成类似于cmp或test之类的比较或位测试指令。
分支语句
分支语句用来根据条件表达式测试的真假执行不同的代码模块,MASM中的分支语句的语法如下:
.if 条件表达式1
表达式1为“真”时执行的指令
[.elseif 条件表达式2]
表达式2为“真”时执行的指令
[.elseif 条件表达式3]
表达式3为“真”时执行的指令
……
[.else]
所有表达式为“否”时执行的指令
.endif
注意:关键字if/elseif/else/endif的前面有个小数点,如果不加小数点,就变成宏汇编中的条件汇编伪操作了,结果可是天差地别。
这些伪指令把汇编程序的可读性基本上提高到了高级语言的水平。
注意:使用.if/.else/.endif构成分支伪指令的时候,不要漏写前面的小数点,if/else/endif是宏汇编中条件汇编宏操作的伪操作指令,作用是根据条件决定在最后的可执行文件中包不包括某一段代码。这和.if/.else/.endif构成分支的伪指令完全是两回事情。
循环语句
循环是重复执行的一组指令,MASM的循环伪指令可以根据条件表达式的真假来控制循环是否继续,也可以在循环体中直接退出,使用循环的语法是:
.while 条件测试表达式
指令
[.break [.if 退出条件]]
[.continue]
.endw
或
.repeat
指令
[.break [.if 退出条件]]
[.continue]
.until 条件测试表达式 (或.untilcxz [条件测试表达式])
.while/.endw循环首先判断条件测试表达式,如果结果是真,则执行循环体内的指令,结束后再回到.while处判断表达式,如此往复,一直到表达式结果为假为止。.while/.endw指令有可能一遍也不会执行到循环体内的指令,因为如果第一次判断表达式时就遇到结果为假的情况,那么就直接退出循环。
.repeat/.until循环首先执行一遍循环体内的指令,然后再判断条件测试表达式,如果结果为真的话,就退出循环,如果为假,则返回.repeat处继续循环,可以看出,.repeat/.until不管表达式的值如何,至少会执行一遍循环体内的指令。
也可中以把条件表达式直接设置为固定值,这样就可以构建一个无限循环,对于.while/.end直接使用TRUE,对于.repeat/until直接使用FALSE来当表达式就是如此,这种情况下,可以使用.break伪指令强制退出循环,如果.break伪指令后面跟一个.if测试伪指令的话,那么当退出条件为真时才执行.break伪指令。
在循环体中也可以用.continue伪指令忽略以后的指令,遇到.continue伪指令时,不管下面还有没有其他循环体中的指令,都会直接回到循环头部开始执行。
代码风格
随着程序功能的增加和版本的提高,程序越来越复杂,源文件也越来越多,风格规范的源程序会对软件的升级、修改和维护带来极大的方便,要想开发一个成熟的软件产品,必须在编写源程序的时候就有条不紊,细致严谨。
在编程中,在程序排版、注释、命名和可读性等问题上都有一定的规范,虽然编写可读性良好的代码并不是必然的要求,但好的代码风格实际上是为自己将来维护和使用这些代码节省时间。
下面是对汇编语言代码风格的建议。
变量和函数的命名
匈牙利表示法
匈牙利表示法主要用在变量和子程序的命名,这是现在大部分程序员都在使用的命名约
定。匈牙利表示法这个奇怪的名字是为了纪念匈牙利籍的Microsoft 程序员Charles
Simonyi,他首先使用了这种命名方法。
匈牙利表示法用连在一起的几个部分来命名一个变量,格式是类型前缀加上变量说明,类型用小写字母表示,如用h表示句柄,用dw表示double word,用sz表示以0结尾的字符串等,说明则用首字母大写的几个英文单词组成,如TimeCounter,NextPoint等,可以令人一眼看出变量的含义来,在汇编语言中常用字的类型前缀有:
b 表示byte
w 表示word
dw 表示dword
h 表示句柄
lp 表示指针
sz 表示以0结尾的字符串
lpsz 表示指向以0结尾的字符串的指针
f 表示浮点数
st 表示一个数据结构
这样一来,变量的意思就很好理解:
hWinMain 主窗口的句柄
dwTimeCount 时间计数器,以双字定义
szWelcome 欢迎信息字符串,以0结尾
lpBuffer 指向缓冲区的指针
很明显,这些变量名比count1,abc,commandlinebuffer和FILEFLAG之类的命名要易于理解。由于匈牙利表示法既描述了变量的类型,又描述了变量的作用,所以能帮助程序员及早发现变量的使用错误,如把一个数值当指针来使用引发的内存页错误等。
对于函数名,由于不会返回多种类型的数值,所以命名时一般不再用类型开头,但名称还是用表示用途的单词组成,每个单词的首字母大写。Windows API是这种命名方式的绝好例子,当人们看到ShowWindow,GetWindowText,DeleteFile和GetCommandLine之类的API函数名称时,恐怕不用查手册,就能知道它们是做什么用的。比起int 21h/09h和int 13h/02h之类的中断调用,好处是不必多讲的。
对匈牙利表示法的补充
使用匈牙利表示法已经基本上解决了命名的可读性问题,但相对于其他高级语言,汇编语言有语法上的特殊性,考虑下面这些汇编语言特有的问题:
·对局部变量的地址引用要用lea指令或用addr伪操作,全局变量要用offset;对局部变量的使用要特别注意初始化问题。如何在定义中区分全局变量、局部变量和参数?
·汇编的源代码占用的行数比较多,代码行数很容易膨胀,程序规模大了如何分清一个函数是系统的API还是本程序内部的子程序?
实际上上面的这些问题可以归纳为区分作用域的问题。为了分清变量的作用域,命名中对全局变量、局部变量和参数应该有所区别,所以我们需要对匈牙利表示法做一些补充,以适应Win32汇编的特殊情况,下面的补充方法仅供参考:
·全局变量的定义使用标准的匈牙利表示法,在参数的前面加下划线,在局部变量的前面加@符号,这样引用的时候就能随时注意到变量的作用域。
·在内部子程序的名称前面加下划线,以便和系统API区别。
如下面是一个求复数模的子程序,子程序名前面加下划线表示这是本程序内部模块,两个参数——复数的实部和虚部用_dwX和_dwY表示,中间用到的局部变量@dwResult则用@号开头:
_Calc proc _dwX, _dwY
local @dwResult
finit
fild _dwX
fld st(0)
fmul ;i * i
fild _dwY
fld st(0)
fmul ; j * j
fadd ; i * I + j * j
fsqrt ;sqrt(i * i + j * j)
fistp @dwResult ;put result
mov eax,@dwResult
ret
_Calc endp
(说实话,上面这段Win32汇编子程序,我只能看懂20%。看了一个月的汇编了,痛哉!痛哉!)
代码的书写格式
排版方式
程序的排版风格应该遵循以下规则。
首先是大小写的问题,汇编程序中对于指令和寄存器的书写是不分大小写的,但小写代码比大写代码便于阅读,所以程序中的指令和寄存器等要采用小写字母,而用equ伪操作符定义的常量则使用大写,变量和标号使用匈牙利表示法,大小写混合。
其次是使用Tab的问题。汇编源程序中Tab的宽度一般设置为8个字符。在语法上,指令和操作数之间至少有一个空格就可以了,但指令的助记符长度是不等长的,用Tab隔开指令和操作数可以使格式对齐,便于阅读。如:
xor eax,eax
fistp dwNumber
xchg eax,ebx
上述代码的写法就不如下面的写法整齐:
xor eax,eax
fistp dwNumber
xchg eax,ebx
还有就是缩进格式的问题。程序中的各部分采用不同的缩进,一般变量和标号的定义不缩进,指令用两个Tab缩进,遇到分支或循环伪指令再缩进一格,如:
.data
dwFlag dd ?
.code
start:
mov eax,dwFlag
.if dwFlag == 1
call _Function1
.else
call _Function2
.endif
合适的缩进格式可以明显地表现出程序的流程结构,也很容易发现嵌套错误,当缩进过多的时候,可以意识到嵌套过深,该改进程序结构了。
注释和空行
没有注释的程序是很难维护的,但注释的方法也很有讲究,写注释要遵循以下的规则:
·不要写无意义的注释,如:将1放到eax中,跳转到 exit标号处。
·修改代码同时修改相应的注释,以保证注释与代码的一致性。
·注释以描写一组指令实现的功能为主,不要解释单个指令的用法,那是应该由指令手册来完成的,不要假设看程序的人连指令都不熟悉。
·对于子程序,要在头部加注释说明参数和返回值,子程序可以实现的功能,以及调用时应该注意的事项。
由于汇编语言是以一条指令为一行的,实现一个小功能就需要好几行,没有分段的程序很难看出功能模块来,所以要合理利用空行来隔开不同的功能块,一般以在高级语言中可以用一句语句来完成的一段汇编指令为单位插入一个空行。
避免使用宏
在MASM的宏功能中最好只使用条件汇编,用来选择编译不同的代码块来构建不同的版本,其他如宏定义和宏调用只会破坏程序的可读性,能够不用就尽量不用,虽然展开后只有一两句的宏定义不在此列,但既然展开后也只有一两句,那么和直接使用指令也就没有什么区别了。
在汇编中避免使用宏定义的理由是:汇编中随时要用到各个寄存器,宏定义不同于子程序,可以有选择地保护现场,在使用中很容易忽略里面用了哪个寄存器,从而对程序结构构成威胁。高级语言的宏定义则不会有这个问题。
最极端的使用宏定义的程序是MicroMedia的Director SDK,100行左右的例子中几乎有90%都是宏定义,虽然例子很容易改成其他功能的程序,但要在里面加新的功能则几乎是不可能的,因为程序中连C语言函数开始和结束的花括号都被改成了宏定义,这样一来,如果要真正使用这个开发包,则必须把宏定义“翻译”回原来的样子才能真正理解程序的流程。
代码的组织
程序中要注意变量的组织和模块的组织方式。
过多的全局变量会影响程序的模块化结构,所以不要设置没必要的全局变量,尽量把变量定义成局部变量。
把仅在子程序中使用的变量设置为局部变量可以使子程序更容易封装成一个黑匣子,如果无法把全部变量设置为局部变量,则尽量把这些数据改为参数输入输出,如果无法改为参数,那么意味着这个子程序不能不经修改地直接放到别的程序中使用。
在主程序中使用比较频繁的部分,以及便于封装成黑匣子在别的程序上用的代码,都应该写上子程序,但一个子程序的规模不应该太大,行数尽量限制在几百行之内,功能则限于完成单个功能。对于子程序,定义参数的时候要尽可能精简,对可能引起程序崩溃的参数,如指针等,要进行合法性检测。
子程序中在使用完申请的资源的时候,注意在退出前要释放所用资源,包括申请的内存和其他句柄等,对于打开的文件则要关闭。
对于程序员来说,开发每一个软件都是要从头做起是很浪费时间的,一般的做是从自己以前做的程序中拷贝相似的代码,但修改还是要花一定时间,最好的办法就是尽量把子程序做成一个黑匣子,可以不经修改地直接拿过来用,这样,每次编程相当于只是编写新增的部分,随着代码的积累,开发任何程序都将是很快的事情。