使用MASM
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 – 應用程序錯誤
“0x00401000”指令引用 的”0x00402010”內存。該內存不能為”written”。
要終止程序,請單擊”確定”。
要調試程序,請單擊”取消”。
如果不怕程序可讀性不佳的話,把.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
0A Buffered keyboard input
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語句包含進來?,F在回到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 0x00000000L
#define MB_OKCANCEL 0x00000001L
#define MB_ABORTRETRYIGNORE 0x00000002L
#define MB_YESNOCANCEL 0x00000003L
#define MB_YESNO 0x00000004L
#define MB_RETRYCANCEL 0x00000005L
#define MB_ICONHAND 0x00000010L
#define MB_ICONQUESTION 0X00000020L
……
顯然,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 83C4F8 add esp, FFFFFFF8
:00401006 8B45FC mov eax, dword ptr [ebp-04]
:00401009 668B45FA mov ax, word ptr [ebp-06]
:0040100D 8A45F9 mov al, byte ptr [ebp-07]
:00401010 C9 leave
:00401011 C3 ret
可以看到,反匯編后的指令比源程序多了前后兩段指令,它們是:
:00401000 55 push ebp
:00401001 8BEC mov ebp, esp
:00401003 83C4F8 add esp, FFFFFFF8
:00401010 C9 leave
這些就是使用局部變量所必需的指令,分別用于局部變量的準備工作和掃尾工作。執行了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段中的代碼
:00401000 A000304000 mov al, byte ptr [00403000]
:00401005 66A100304000 mov ax, word ptr [00403000]
: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 8B5D0C mov ebx, dword ptr [ebp+0C]
:00401009 C9 leave
:0040100A C3 ret
;這里是Sub2 – PASCAL類型
:0040100B 55 push ebp
:0040100C 8BEC mov ebp,esp
:0040100E 8B450C move ax, dword ptr [ebp+0C]
:00401011 8B5D08 mov ebx, dword ptr [ebp+08]
:00401014 C9 leave
:00401015 C20800 ret 0008
;這里是Sub3 – StdCall類型
:00401018 55 push ebp
:00401019 8BEC mov ebp,esp
:0040101B 8B4508 mov eax, dword ptr [ebp+08]
:0040101E 8B5D0C mov ebx, dword ptr [ebp+0C]
:00401021 C9 leave
:00401022 C20800 ret 0008
……
;這里是invoke Sub1,1,2 – C類型
:00401025 6A02 push 00000002
:00401027 6A01 push 00000001
:00401029 E8D2FFFFFF call 00401000
:0040102E 83C408 add esp,00000008
;這里是invoke Sub2,1,2 -- PASCAL類型
:00401031 6A01 push 00000001
:00401033 6A02 push 00000002
:00401035 E8D1FFFFFF call 0040100B
;這里是invoke Sub3,1,2 – StdCall類型
:0040103A 6A02 push 00000002
:0040103C 6A01 push 00000001
: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%??戳艘粋€月的匯編了,痛哉!痛哉?。?/span>
代碼的書寫格式
排版方式
程序的排版風格應該遵循以下規則。
首先是大小寫的問題,匯編程序中對于指令和寄存器的書寫是不分大小寫的,但小寫代碼比大寫代碼便于閱讀,所以程序中的指令和寄存器等要采用小寫字母,而用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語言函數開始和結束的花括號都被改成了宏定義,這樣一來,如果要真正使用這個開發包,則必須把宏定義“翻譯”回原來的樣子才能真正理解程序的流程。
代碼的組織
程序中要注意變量的組織和模塊的組織方式。
過多的全局變量會影響程序的模塊化結構,所以不要設置沒必要的全局變量,盡量把變量定義成局部變量。
把僅在子程序中使用的變量設置為局部變量可以使子程序更容易封裝成一個黑匣子,如果無法把全部變量設置為局部變量,則盡量把這些數據改為參數輸入輸出,如果無法改為參數,那么意味著這個子程序不能不經修改地直接放到別的程序中使用。
在主程序中使用比較頻繁的部分,以及便于封裝成黑匣子在別的程序上用的代碼,都應該寫上子程序,但一個子程序的規模不應該太大,行數盡量限制在幾百行之內,功能則限于完成單個功能。對于子程序,定義參數的時候要盡可能精簡,對可能引起程序崩潰的參數,如指針等,要進行合法性檢測。
子程序中在使用完申請的資源的時候,注意在退出前要釋放所用資源,包括申請的內存和其他句柄等,對于打開的文件則要關閉。
對于程序員來說,開發每一個軟件都是要從頭做起是很浪費時間的,一般的做是從自己以前做的程序中拷貝相似的代碼,但修改還是要花一定時間,最好的辦法就是盡量把子程序做成一個黑匣子,可以不經修改地直接拿過來用,這樣,每次編程相當于只是編寫新增的部分,隨著代碼的積累,開發任何程序都將是很快的事情。