标号、变量和数据结构
当程序中要跳转到另一位置时,需要有一个标识来指示新的位置,这就是标号,通过在目的地址的前面放上一个标号,可以在指令中使用标号来代替直接使用地址。
使用变量是任何编程语言都要遇到的工作,Win32汇编也不例外,在MASM中使用变量也有需要注意的几个问题,错误地使用变量定义或用错误的方法初始化变量会带来难以定位的错误。变量是计算机内存中已命名的存储位置,在C语言中有很多种类的变量,如整数型、浮点型和字符串等,不同的变量有不同的用途和尺寸,比如说虽然长整数和单精度浮点数都是32位长,但它们的用途不同。
顾名思义,变量的值在程序运行中是需要改变的,所以它必须定义在可写的段内,如 .data和 .data?,或者在堆栈内。按照定义的位置不同,MASM中的变量也分为全局变量和局部变量两种。
在MASM中标号和变量的命名规范是相同的,它们是:
1. 可以用字母、数字、下划线及符号@、$和?。
2. 第一个符号不能是数字。
3. 长度不能超过240个字符。
4. 不能使用指令名等关键字。
5. 在作用域内必须是惟一的。
1. 标号的定义
当在程序中使用一条跳转指令的时候,可以用标号来表示跳转的目的地,编译器在编译的时候会把它替换成地址,标号既可以定义在目的指令同一行的头部,也可以在目的指令前一行单独用一行定义,标号定义的格式是:
标号名: 目的指令
标号的作用域是当前的子程序,在单个子程序中的标号不能同名,否则编译器不知该用哪个地址,但在不同的子程序中可以有相同名称的标号,这意味着不能从一个子程序中用跳转指令跳到另一个子程序中。
在低版本的MASM中,标号在整个程序中是惟一的,子程序中的标号也可以从整个程序的任何地方转入。但Win32汇编使用的高版本MASM中不允许这样,这是为了提供对局部变量和参数的支持,由于在子程序入口处有对堆栈的初始化指令,所以一个子程序不允许有多个入口,其结果就是标号的作用域变成了单个子程序范围。
2. MASM中的@@
在DOS时代,为标号起名是个麻烦的事情,因为汇编指令用到跳转指令特别多,任何比较和测试等都要涉及跳转,所以在程序中会有很多标号,在整个程序范围内起个不重名的标号要费一番功夫,结果常常用addr1和addr2之类的标号一直延续下去,如果后来要在中间插一个标号,那么就常常出现addr1_1和loop10_5之类奇怪的标号。
实际上,很多标号只会使用一到两次,而且不一定非要起个有意义的名称,如汇编程序中下列代码结构很多:
mov cx,1234h
cmp flag,1
jz loc1
mov cx,1000h
loc1:
…
loop loc1
loc1在别的地方就再也用不到了,对于这种情况,高版本的MASM用@@标号去代替它:
mov cx,1234h
cmp flag,1
jz @F
mov cx,1000h
@@:
…
loop @B
当用@@做标号时,可以用@F和@B来引用它,@F表示本条指令后的第一个@@标号,@B表示本条指令前的第一个@@标号,程序中可以有多个@@标号,@B和@F只寻找匹配最近的一个。
1. 全局变量的定义
全局变量的作用域是整个程序,Win32汇编的全局变量定义在 .data或 .data?段内,可以同时定义变量的类型和长度,格式是:
变量名 类型 初始值1,初始值2,……
变量名 类型 重复数量 dup (初始值1,初始值2,……)
MASM中可以定义的变量类型相当多,具体如表3.2所示。
表3.2 变量的类型
名 称 表示方式 缩 写 长度(字节) | |||
字节 |
byte |
db |
1 |
字 |
word |
dw |
2 |
双字(doubleword) |
dword |
dd |
4 |
三字(farword) |
fword |
df |
6 |
四字(quadword) |
qword |
dq |
8 |
十字节BCD码(tenbyte) |
tbyte |
dt |
10 |
有符号字节(signbyte) |
sbyte |
|
1 |
有符号字(signword) |
sword |
|
2 |
有符号双字(signdword) |
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定义了一个1 024字节的缓冲区。
● 例6定义了一个字符串,总共占用了12字节。两头的单引号是定界的符号,并不属于字符串中真正的内容。
在byte类型变量的定义中,可以用引号定义字符串和数值定义的方法混用,假设要定义两个字符串“Hello,World! ”和“Hello again”,每个字符串后面跟回车和换行符,最后以一个0字符结尾,可以定义如下:
szText db 'Hello,world!',0dh,0ah,'Hello again',0dh,0ah,0
2. 全局变量的初始化值
全局变量在定义中既可以指定初值,也可以只用问号预留空间,在 .data?段中,只能用问号预留空间,因为 .data?不能指定初始值,这里就有一个问题:既然可以用问号预留空间,那么在实际运行的时候,这个未初始化的值是随机的还是确定的呢?在全局变量中,这个值就是0,所以用问号指定的全局变量如果要以0为初始值的话,在程序中可以不必为它赋值。
局部变量
局部变量这个名称最早源于高级语言,主要是为了定义一些仅在单个函数里面有用的变量而提出的,使用局部变量能带来一些额外的好处,它使程序的模块化封装变得可能,试想一下,如果要用到的变量必须定义在程序的数据段里面,假设在一个子程序中要用到一些变量,当把这个子程序移植到别的程序时,除了把代码移过去以外,还必须把变量定义移过去。而即使把变量定义移过去了,由于这些变量定义在大家都可以用的数据段中,就无法对别的代码保持透明,别的代码有可能有意无意地修改它们。还有,在一个大的工程项目中,存在很多的子程序,所有的子程序要用到的变量全部定义到数据段中,会使数据段变得很大,混在一起的变量也使维护变得非常不方便。
局部变量这个概念出现以后,两个以上子程序都要用到的数据才被定义为全局变量统一放在数据段中,仅在子程序内部使用的变量则放在堆栈中,这样子程序可以编成“黑匣子”的模样,使程序的模块结构更加分明。
局部变量的作用域是单个子程序,在进入子程序的时候,通过修改堆栈指针esp来预留出需要的空间,在用ret指令返回主程序之前,同样通过恢复esp丢弃这些空间,这些变量就随之无效了。它的缺点就是因为空间是临时分配的,所以无法定义含有初始化值的变量,对局部变量的初始化一般在子程序中由指令完成。
在DOS时代,低版本的宏汇编本来无所谓全局变量和局部变量,所有的变量都是定义在数据段里面的,能让被所有的子程序或主程序存取,就相当于现在所说的全局变量,用汇编语言在堆栈中定义局部变量是很麻烦的一件事情。要和高级语言做混合编程的时候,程序员往往很痛苦地在边上准备一张表,表上的内容是局部变量名和ebp指针的位置关系。
1. 局部变量的定义
MASM用local伪指令提供了对局部变量的支持。定义的格式是:
local 变量名1[[重复数量]][:类型],变量名2[[重复数量]][:类型]……
local伪指令必须紧接在子程序定义的伪指令proc后、其他指令开始前,这是因为局部变量的数目必须在子程序开始的时候就确定下来,在一个local语句定义不下的时候,可以有多个local语句,语法中的数据类型不能用表3.2中的缩写,如果要定义数据结构,可以用数据结构的名称当做类型。Win32汇编默认的类型是dword,如果定义dword类型的局部变量,则类型可以省略。当定义数组的时候,可以 [] 括号括起来。不能使用定义全局变量的dup伪指令。局部变量不能和已定义的全局变量同名。局部变量的作用域是当前的子程序,所以在不同的子程序中可以有同名的局部变量。
这里有几个定义局部变量的例子:
local loc1[1024]:byte ;例1
local loc2 ;例2
local loc3:WNDCLASS ;例3
● 例1定义了一个1 024字节长的局部变量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指令把原来的ebp保存起来,然后把esp的值放到ebp中,供存取局部变量做指针用,再后面就是在堆栈中预留空间了,由于堆栈是向下增长的,所以要在esp中加一个负值,FFFFFFF8就是–8。慢着!一个dword加一个word加一个字节不是7吗,为什么是8呢?这是因为在80386处理器中,以dword为界对齐时存取内存速度最快,所以MASM宁可浪费一个字节,执行了这3句指令后,初始化完成,就可以进行正常的操作了,从指令中可以看出局部变量在堆栈中的位置排列,如表3.3所示。
表3.3 上例中局部变量排列的顺序
ebp偏移 内 容 | |
ebp+4 |
由call推入的返回地址 |
Ebp |
push ebp指令推入的原ebp值,然后新的ebp=现在的esp |
ebp-4 |
第一个局部变量 |
ebp-6 |
第二个局部变量 |
ebp-7 |
第三个局部变量 |
在程序退出的时候,必须把正确的esp设置回去,否则,ret指令会从堆栈中取出错误的地址返回,看程序可以发现,ebp就是正确的esp值,因为子程序开始的时候已经有一句mov ebp,esp,所以要返回的时候只要先mov esp,ebp,然后再pop ebp,堆栈就是正确的了。
在80386指令集中有一条指令可以在一句中实现这个功能,就是leave指令,所以,编译器在ret指令之前只使用了一句leave指令。
明白了局部变量使用的原理,就很容易理解使用时的注意点:ebp寄存器是关键,它起到保存原始esp的作用,并随时用做存取局部变量的指针基址,所以在任何时刻,不要尝试把ebp用于别的用途,否则会带来意想不到的后果。
2. 局部变量的初始化值
显然,局部变量是无法在定义的时候指定初始化值的,因为local伪指令只是简单地把空间给留出来,那么开始使用时它里面是什么值呢?和全局变量不一样,局部变量的起始值是随机的,是其他子程序执行后在堆栈里留下的垃圾,所以,对局部变量的值一定要初始化,特别是定义为结构后当参数传递给API函数的时候。
数据结构
数据结构实际上是由多个字段组成的数据“样板”,相当于一种自定义的数据类型,数据结构中间的每一个字段可以是字节、字、双字、字符串或所有可能的数据类型。比如在API函数RegisterClass中要使用到一个叫做WNDCLASS的数据结构,Microsoft的手册上是如下定义的:
typedef 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
mov eax,[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
mov eax,[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字段时,就可以用下面的语句:
mov eax,[esi].oldWndClass.lpfnWndProc
结构的嵌套在Windows的数据定义中也常有,比如在第13章13.3节中使用的DEBUG_EVENT结构中竟然使用了4层数据结构的嵌套。 熟练掌握数据结构的使用对Win32汇编编程是很重要的!