在這一章裏將向各位介紹 ( 註一 ) 在 Win32 作業系統中,最簡單的組合語言程式,同時練習如何以 MASM32 v7.0 組譯連結,以及利用 Soft-ICE 載入程式除錯 ( 或者用 OllyDebug 除錯 ) 這三項工作。
依慣例,程式教學的第一個程式,僅在螢幕上顯示一個字串,小木偶也不想例外,所以第一個用組合語言所撰寫的 Win32 程式也是如此。它僅僅顯示一個視窗,標題寫『最簡單的程式』,視窗內文寫『這是在 Win32 作業系統,用組合語言寫的程式。』,並且有一個『確定』按鈕,當使用者按下這個按鈕可以結束程式。它沒有牽涉太多 Win32 複雜的系統,甚至連最基本的訊息傳遞也都沒有,最適合作為 Win32 組合語言入門的程式。這個程式,小木偶名之為 message.asm,其執行結果如下:
上面是在 Win 98 SE 的執行畫面,至於 XP/Vista 也都類似,讀者可以試試。message.asm 的原始碼如下:
1 2 3 4
5 6 7 8
9
10 11 12 13
14 15 16 17
18 19 |
OPTION CASEMAP:NONE
.386
.MODEL FLAT,STDCALL
INCLUDE WINDOWS.INC
INCLUDE KERNEL32.INC
INCLUDE USER32.INC
INCLUDELIB KERNEL32.LIB
INCLUDELIB USER32.LIB
;***************************************************************************************************
.DATA
szTitle DB "最簡單的程式",0
szMessage DB "這是在 Win32 作業系統,用組合語言寫的程式。",0
;***************************************************************************************************
.CODE
start: INVOKE MessageBox,NULL,ADDR szMessage,ADDR szTitle,MB_OK
INVOKE ExitProcess,NULL
;***************************************************************************************************
END start |
看起來只有 19 行,似乎很簡單,那是因為有許多定義都已囊括在包含檔 ( *.INC 檔 ) 中了。底下小木偶將分析這些程式碼。
第一行是告訴組譯器,ML.EXE,使用我們所定義的變數名稱、標記名稱、Win32 API 名稱等等是區分大小寫的,也就是說 message 與 MESSAGE 代表兩個不同的字串名。假如您不在原始程式中寫這一行,在組譯時下 /Cp 參數也是一樣的。因為所有的 Win32 程式都會呼叫 Win32 API,而 Win 32 API 大小寫不同的字母視為不同的變數,所以必須有這一行,這一點和 DOS 中撰寫程式不分大小寫是不一樣的,初學者得小心。
第二行是宣告可以使用 80386 指令集,因為我們的程式將在 Win32 系統上執行,因為 Win32 系統必須在 80386 以上的 CPU 才能執行,而且要用 32 位元的定址方式 ( 即 flat mode ),所以『.386』這一行是必要的。
第三行是指定記憶體模式以及呼叫協定 ( calling convention )。先說記憶體模式。在 DOS 時代,記憶體是以 16 位元定址方式定址,分為區段與偏移位址,每個區段不得超過 64 KB,但在 Win32 作業系統裏,記憶體是以 32 位元方式定址,最多可達 4GB,如此大的記憶體不再需要,也沒有必要分成區段與偏移位址的定址方式,您可以把記憶體位址全都看成是一個很大的『區段』,由 00 到 4GB 成直線排列,稱之為『flat』模式 ( 平坦模式 ) ( 註二 )。在 Win32 作業系統中,您也只能用這種模式。在 Win32 系統的 FLAT 模式之下,當程式被載入執行時,作業系統會分配一個 4GB 的記憶體空間給程式使用,我們的程式只能在這 4GB 的空間執行,也沒有辦法看到其他程式的記憶體內容,記憶體的分配、管理全部都由作業系統調度指揮,所以當某個程式當掉,並不影響其他程式。我們再也沒有辦法,也沒有必要去關心其他程式的記憶體內容,只要專注於自己程式的 4GB 內即可。
呼叫協定是指主程式呼叫副程式時,要如何把參數傳給副程式,主程式與副程式必須一致,否則所得結果便沒有意義。在 DOS 裏呼叫中斷是使用暫存器傳遞參數並無所謂呼叫協定,但是在 Win32 裏呼叫 Win32 API,卻是使用堆疊傳遞參數,而在中、高階語言裏呼叫副程式常以一行完成,例如 C/C++ 中呼叫 MessageBox 語法如下:
MessageBox ( NULL,szMessage,szTitle,MB_OK )
呼叫協定是指要決定在最右邊還是最左邊的參數先推入堆疊、是由主程式還是副程式清除堆疊、是否允許不固定個數的參數等等。底下幾種是常見的呼叫協定:
呼叫協定 | STDCALL | C | BASIC |
最先推入堆疊的參數 | 右 | 右 | 左 |
由誰負責清除堆疊 | 副程式 | 主程式 | 副程式 |
是否允許不定個數的參數 | 是 | 是 | 否 |
其他還有一些呼叫協定,如 PASCAL、FORTRAN 等等,不過在 Win32 裏用不著,所以沒有介紹。在 Win32 組合語言中因為大量呼叫 Win32 API 程式,而 Win32 API 是用 STDCALL 方式傳遞參數,所以我們也得宣告以 STDCALL 方式傳遞參數,並且也只能以 STDCALL 方式呼叫 Win32 API。如上表所示,以 STDCALL 呼叫協定組譯時,是告訴組譯器最右邊的參數最先推入堆疊,然後是右邊第二個參數推入堆疊……第一個參數是最後推入堆疊。
這前三行,您可以看成是必須且不太會更動的程式碼。
第四行到第八行是把程式所呼叫 Win32 API 所需要的變數定義及程式庫含括進來。在 DOS 中,DOS 提供了許多中斷服務程式供使用者呼叫;相同地,在 Win 32 系統中,Windows 也提供了許多強大的副程式,也就是 Win32 API,供應用程式呼叫。與 DOS 不同的是 DOS 中斷服務程式放在記憶體中,而 Win32 API 是包含在動態程式庫內,這些動態程式庫是放在 C:\WINDOWS\SYSTEM 子目錄裏的 DDL 檔 ( 最重要的三個是 KERNEL32.DLL、GDI32.DLL 和 USER32.DLL ) ,當應用程式呼叫時,才去這個子目錄找到該檔嵌入執行,不像 DOS 的中斷服務程式只需要用 CPU 指令 INT 就可以了。
動態程式庫的函數定義都放在相對應同主檔名的包含檔內,意思是 KERNEL32.DLL 的函數定義放在 KERNEL32.INC 包含檔內,USER32.DLL 的函數定義放在 KUSER32.INC 包含檔內。程式的第 4 到第 8 行就是指定所用到的包含檔與程式庫。至於要加入那一個包含檔或程式庫必須視程式所用到的 Win32 API 而定,可以查 API 手冊。不過一般有個規則,如果您呼叫的 API 是在 KERNEL32.DLL 檔案內,那就就必須包含 KERNEL32.INC,以及程式庫 KERNEL32.LIB。程式庫的包含檔並沒有包含結構體、常數等定義,這些定義是包含在 WINDOWS.INC 檔案內,所以原始程式也要包含 WINDOWS.INC 檔。
第十行是註解,組合語言註解均以『;』開始,組譯器會忽略『;』以後的文字。有時註解太長,必須使用好幾行文字時,可以用 COMMENT 來當成註解。
第 11 行到第 13 行是定義資料『段』,我把這個『段』以引號括起來是因為它和在 DOS 模式下的區段意義不同,在 DOS 中一個段只有 64KB 的大小,而在 Win32 中一個區段就有 4GB 的長度,而這 4GB 的大小再分成資料段與程式碼段。在 MASM 6.0 以後的版本可以用簡單的方法去定義區段,它省略了區段名,而只告訴組譯器這是堆疊區段、資料區段或是程式碼區段,由組譯器內定區段名稱。
簡單定義區段的方法就是在一行的最前面寫『.』表示這是一個區段開始,區段的結束並無假指令,但是一個區段的開始就是前一區段的結束,因此不會搞混。常用的區段有下面四種:
.DATA → 資料區段 .CONST → 資料區段,但是只能儲存常數,也就是此區段內的資料不能更改 .DATA? → 變數沒有初始值的資料區段 .CODE → 程式碼區段
『.data』是資料區段且此區段內的變數、字串都是已經有初始值的。『.data?』所開始的區段是未有初始值的資料區段。『.code』開始的區段就是程式碼區段。此處小木偶定義了兩個字串,這兩個字串都有內容了,所以放在『.data』區段內。這些區段是組譯器內的保留字,並不是變數,因此大小寫不受『OPTION CASEMAP:NONE』的影響。此外,像假指令 ( 例如 INVOKE、addr、offset 等等 )、80x86 指令集 ( 例如 mov、push、div 等等 )…都不受『OPTION CASEMAP:NONE』的影響,亦即大小寫沒有區別。
在 Win32 組合語言裏,所有有關位址的暫存器,例如 ESI、EDI、EBP 等都是 32 位元長,而且 232=4GB,所以這些暫存器都能定址到 4GB 的大小,因此不需要再像 DOS 時代,去把位址表示成區段與偏移的方式了。那麼區段暫存器是不是真的沒用了呢?當然不是,保護模式裏的區段暫存器另有其意義,並非三言兩語可以介紹得完的,如果亂改這些暫存器的話,很容易就造成當機,Windows 會自動在程式載入執行時,就會把這些值設定好,使用者的程式不能去修改它。所以在 Win32 組合語言裏,我們可以不必去在意區段暫存器的數值,當然假如您想深入研究的話,就另當別論了。
第 15 行是程式碼區段開始。再下一行,有一個標記,start:,和在 DOS 時候一樣,在程式的最後一行有一個 END 假指令,表示整個原始擋到此結束,END 後面所接的標記表示這個程式的進入點,換句話說,這個程式是從第 16 行開始執行的。
第一個指令是 INVOKE,這是一個假指令,它所代表的意義是呼叫副程式,先看看它的語法:
INVOKE 副程式名, 參數1, 參數2, 參數3, ……
INVOKE 後面接上要呼叫的副程式名稱,而副程式所需要的參數則是接在副程式之後,副程式與參數之間以『,』分隔,參數與參數之間也用『,』分隔。假如參數太多而一行容納不下時,可以用『\』表示下一行是接著這一行之後。這裏有個問題產生了,以前在 DOS 呼叫中斷時,是利用暫存器傳遞參數,例如
mov dx,offset string ;→DX為傳遞的參數
mov ah,9
int 21h
而 INVOKE 所需的參數存在那裡呢?原來接在副程式名稱後面的參數1、參數2……都會被組譯成 push 指令,然後全部被推入堆疊內,而副程式則到堆疊去找到所需要的參數。這些動作全部由組譯器自動作好,程式設計師所需要做的就是查 Win32 API 參考手冊中,這個 API 用到那些參數,以及參數所代表的意義和順序。(註三)
ML.EXE 組譯時,所推入堆疊的順序是依據程式第 3 行,model,的定義,如果是 STDCALL 的話,是最右邊的變數最先被推入堆疊,最左邊的變數最後被推入堆疊。所以第 16 行其實是底下五行程式的簡寫。
push MB_OK push addr szTitle ;其實參數有 addr 假指令時,並非翻成 push addr szTitle 或 push addr szMessage ;push addr szTitle 這樣簡單,請參考註五 push NULL call MessageBox
MessgaeBox ( 註四) 這個 API 會自動到堆疊裡去找出所需參數。
此處 INVOKE 所呼叫的副程式,MessageBox,在整個原始程式中並未定義,那它到底在那裏呢?原來在 USER32.INC 裏已經有定義了,您如果開啟 USE32.INC,可以找到:
MessageBoxA PROTO :DWORD,:DWORD,:DWORD,:DWORD MessageBox equ <MessageBoxA>
這兩行,就是定義 MessageBox,詳細情形請看註三與註七。這也就是在 Win32 撰寫組合語言必須把包含檔囊括進來的原因。當組譯器組譯時,它可以知道這是『外部』副程式,而在目的檔中記錄起來,等連結器把目的檔與程式庫連結時,因為程式庫存有 *.DLL 的資料,所以把這些資料和目的檔比對就知道呼叫的 API 存於何處,應該在執行時怎樣連接。
MessageBox 顧名思義,是用來把字串印在視窗的 API,有關它參數的意義,可以查 Win32 API 手冊如下:
int MessageBox( HWND hWnd, // handle of owner window LPCTSTR lpText, // address of text in message box LPCTSTR lpCaption, // address of title of message box UINT uType // style of message box );
在小括弧內的表示 MessageBox API 所需的四個參數,其參數名稱是 hWnd、lpText、lpCaption、uType,而這四個參數的資料型態是 HWND、LPCTSTR、UINT,所謂資料型態就是像 DB、DW、DD 這樣定義變數的長度,它們和 DB、DW、DD 不同的是它們定義在 WINDOWS.INC 裏而組譯器不認得,這也就是要把 WINDOWS.INC 包含進來的原因。HWND、LPCTSTR、UINT 的長度均為雙字組 ( 4 個位元組,其實所有的 API 參數的長度都是雙字組 ),之所以定義這幾個新的資料型態是為了可讀性 ( 但有時我覺得增加了複雜性 ),例如看見 HWND 就知道這是視窗代碼,看到 LPCTSTR,就知道這是指向字串所在位址的指標。底下是這四個參數所表示的意義:
uType | 數值 | 意義 |
底下的 uType 會顯示不同的按鈕 | ||
MB_OK | 0h | 只顯示『確定』按鈕 |
MB_OKCANCEL | 1h | 顯示『確定』與『取消』兩個按鈕 |
MB_ABORTRETRYIGNORE | 2h | 顯示『終止』、『重試』、『略過』三個按鈕 |
MB_YESNOCANCEL | 3h | 顯示『是』、『否』、『取消』三個按鈕 |
MB_YESNO | 4h | 顯示『是』、『否』兩個按鈕 |
MB_RETRYCANCEL | 5h | 顯示『重試』、『取消』兩個按鈕 |
MB_CANCELTRYCONTINUE | 6h | 顯示『取消』、『重試』、『繼續』三個按鈕 |
MB_HELP | 4000h | 顯示『確定』、『說明』兩個按鈕,如果使用者按下『說明』按鈕,系統會發出 WM_HELP 訊息給父程式。有關訊息,參考第二章。 |
底下的 uType 會在視窗的左邊顯不同的圖示 | ||
MB_ICONSTOP | 10h | 會顯示![]() |
MB_ICONERROR | 10h | 同 MB_ICONSTOP |
MB_ICONHAND | 10h | 同 MB_ICONSTOP |
MB_ICONQUESTION | 20h | 會顯示![]() |
MB_ICONEXCLAMATION | 30h | 會顯示![]() |
MB_ICONWARNING | 30h | 同 MB_ICONEXCLAMATION |
MB_ICONINFORMATION | 40h | 會顯示![]() |
MB_ICONASTERISK | 40h | 同 MB_ICONINFORMATION |
底下的 uType 決定哪一個是內定的按鈕 | ||
MB_DEFBUTTON1 | 0h | 第一個按鈕為內定按鈕,內定按鈕邊邊會以虛線框框圍住,當使用者按下鍵盤上的『Enter』鍵,就相當使用者以滑鼠點選內定按鈕一樣,具有相同的效果 |
MB_DEFBUTTON2 | 100h | 第二個按鈕為內定按鈕 |
MB_DEFBUTTON3 | 200h | 第三個按鈕為內定按鈕 |
MB_DEFBUTTON4 | 300h | 第四個按鈕為內定按鈕 |
底下的 uType 決定 MessageBox 視窗出現後,使用者能否繼續工作 | ||
MB_APPLMODAL | 0h | 如果 MessageBox 是某個程式的子視窗,那麼使用者一定要按下 MessageBox 所產生的視窗中的任一按鈕,才能切換到父視窗;但可以切換到其他視窗繼續工作。如果沒有指定 MB_SYSTEMMODAL,也沒有指定 MB_TASKMOOAL,則 MB_APPLMODAL 為預設值 |
MB_SYSTEMMODAL | 1000h | 此旗標會使對話盒出現在最前面,即使以滑鼠點選其他視窗,也仍在最前面。通常用來通知很嚴重的錯誤。 |
MB_TASKMODAL | 2000h | |
其他 | ||
MB_SETFOREGROUND | 10000h | 系統呼叫 SetForegroundWindow,使 MessageBox 產生的視窗在最前面。 |
MB_DEFAULT_DESKTOP_ONLY | 20000h | |
MB_TOPMOST | 40000h | 產生的視窗具有 WS_EX_TOPMOST 延伸風格 |
MB_RIGHT | 80000h | 標題、視窗內文字靠右對齊 |
MB_RTLREADING | 100000h | 標題、視窗內文字由右至左排列,用在阿拉伯文或希伯來文 |
MB_SERVICE_NOTIFICATION | 200000h |
符號 | 數值 | 意義 |
IDOK | 1h | 按下『確定』按鈕 |
IDCANCEL | 2h | 按下『取消』按鈕。如果視窗中有『取消』按鈕,按下鍵盤的 Esc 鍵,也能關閉視窗,並返回 IDCANCEL;如果沒有『取消』按鈕,Esc 鍵就沒有作用。 |
IDABORT | 3h | 按下『終止』按鈕 |
IDRETRY | 4h | 按下『重試』按鈕 |
IDIGNORE | 5h | 按下『忽略』按鈕 |
IDYES | 6h | 按下『是』按鈕 |
IDNO | 7h | 按下『否』按鈕 |
IDCONTINUE | 0Bh | 按下『繼續』按鈕 |
Win32 API 的傳回值均存放在 EAX 暫存器裏。
為了要取得兩個字串的起始位址,在字串前加上 addr 假指令,表示取得位址之意。addr 和以前的 offset 很相似,差別在 addr 不能向前引用,意思是您必須先定義變數才能在程式後面取得該變數位址,不能在程式後面定義變數而在定義前使用 addr,而 offset 則可以。addr 不能把變數位址傳給其他變數或暫存器,例如
mov si,addr string
這樣寫是不合法的,但是 offset 卻可以。addr 一般都是配合 INVOKE 假指令用的。(註五)
了解上述之後,INVOKE 假指令就不難了解了,INVOKE 就好像呼叫一個副程式 (API),而這個副程式所需要的參數,就接在副程式的後面,這樣用 INVOKE 呼叫比用 call 呼叫至少有一點好處,那就是 INVOKE 會幫我們檢查推入堆疊裏的參數個數是否正確,假如參數數目或型態不正確的話,在組譯階段就會產生錯誤訊息。而使用 call 呼叫則不會產生錯誤,但是在執行時,很容易引起當機。
程式第 13 行也是 INVOKE,所呼叫的是 ExitProcess,這也是一個 Win32 API,顧名思義,其功能是結束程式的,查 Win32 API 手冊:
VOID ExitProcess( UINT uExitCode // exit code for all threads );
得知參數只有一個,把返回碼傳給作業系統。
把上述原始程式用文書處理軟體存成 MESSAGE.ASM 檔案後,按下『開始』→『程式集』→『MS-DOS 模式』的提示符號下輸入:
H:\HomePage\SOURCE>ml message.asm [Enter]
Microsoft (R) Macro Assembler Version 6.14.8444
Copyright (C) Microsoft Corp 1981-1997. All rights reserved.
Assembling: message.asm
Microsoft (R) Incremental Linker Version 5.12.8078
Copyright (C) Microsoft Corp 1992-1998. All rights reserved.
/SUBSYSTEM:WINDOWS
"message.obj"
"/OUT:message.exe"
H:\MASM32ASM>
就能組譯與連結了。假如沒有辦法正確得到 MESSAGE.EXE 的話,檢查一下您的環境是否正確,在 DOS 提示號輸入
H:\HomePage\SOURCE>set [Enter] TMP=C:\WINDOWS\TEMP TEMP=C:\WINDOWS\TEMP PROMPT=$p$g winbootdir=C:\WINDOWS COMSPEC=C:\WINDOWS\COMMAND.COM windir=C:\WINDOWS INCLUDE=C:\masm32\INCLUDE LIB=C:\masm32\LIB PATH=C:\MASM32\BIN;C:\WINDOWS;C:\WINDOWS\COMMAND ML=/coff /link /SUBSYSTEM:WINDOWS
假如沒有出現類似白色文字的話,那可能必須您自己設定環境了,方法也很簡單,直接在 DOS 提示號輸入
H:\HomePage\SOURCE>path C:\masm32\BIN;%path% [Enter] H:\HomePage\SOURCE>set include=C:\masm32\INCLUDE [Enter] H:\HomePage\SOURCE>set lib=C:\masm32\LIB [Enter] H:\HomePage\SOURCE>set ml=/coff /link /SUBSYSTEM:WINDOWS [Enter]
四行指令,此處是假設您安裝的 MASM32 在『C:\masm32』。再執行 ML.EXE 一次試試,假如一切正常,您可以得到一個 MESSAGE.EXE 檔案,它是一個不折不扣的 Win32 可執行檔,如要執行它,就在 DOS 提示符號下輸入 message 即可看到彈出一個視窗,上面的訊息就是我們所寫的程式內容!
以前用 DEBUG 或 SYMDEB 時,要載入除錯的程式直接接在 DEBUG 或 SYMDEB 之後,但是 Soft-ICE 無法這樣載入,那要如何觀察 MESSAGE.EXE 執行情形呢?請照下面方法試試:
選取由『開始』→『程式集』→『NuMega SoftICE』→『Symbol Loader』,執行 Symbol Loader,然後在其選單內選擇『File』→『Open Modules...』開啟選擇檔案的對話盒,如下圖:
========================= H:\HomePage\SOURCE\MESSAGE.exe - loaded successfully
然後由選單內,選擇『Module』→『Load』,Soft-ICE 會出現沒有符號檔 ( 註六 ) 的訊息,問您是否繼續載入,如下圖:
除錯畫面是一塊黑底白字的長方形區域,其中還被分割成好幾塊,最下面那一塊應該是『命令視窗』(command window),可以給使用者輸入命令。在命令視窗上面是『程式碼視窗』(code window),現在還沒有顯示要除錯程式的內容。好,我們按下 F8 鍵 ( F8 鍵的功用,您也可以在命令視窗輸入『t』指令再按 Enter 鍵,結果是相同的。這個 t 的意思就是單步追蹤,和在 DEBUG/SYMDEB 相同 ),您會發現程式碼視窗的內容改變了,變成 MESSAGE.EXE 的內容,並且高亮度光棒移到位址 00401002 處:
017F:00401000 6A00 PUSH 00 017F:00401002 6800304000 PUSH 00403000 017F:00401007 680D304000 PUSH 0040300D 017F:0040100C 6A00 PUSH 00 017F:0040100E E80D000000 CALL 00401020 017F:00401013 6A00 PUSH 00 017F:00401015 E800000000 CALL 0040101A 017F:0040101A FF2500204000 JMP [00402000] 017F:00401020 FF2508204000 JMP [00402008]
檢視這一段程式碼,由 017F:00401000 到 0040100C 連續四個 push 指令,就是把 MessageBox 所需的參數推到堆疊中,然後 MessageBox 再由堆疊中去取出。位址 0040100E 的 call 就是 call MessageBox,這四個 push 指令與 call 指令可以看作是 INVOKE 假指令運作的結果。00401013 到 00401015 這兩行指令和上面一樣,是把 ExitProcess 所需之參數推入堆疊,以及呼叫 ExitProcess API。
每當您每輸入一個 F8 鍵時,高亮度的光棒執行該行指令並移動到下一個將要執行的指令處,除了進入 Soft-ICE 的第一個 F8。
當您追蹤到 017F:0040100E 處,這是 Win32 API MessageBox,可以按 F10 或輸入『p+Enter 鍵』 表示不追蹤副程式內部,把副程式當成一個指令執行,就像 SYMDEB 裏的『p』指令一樣,但是假如您要追蹤 MessageBox 內的資料可以按 F8 鍵繼續追蹤。
小木偶為了更詳細了解 INVOKE 假指令,按下 F8 追蹤 MessageBox 裏的程式碼,當您按下 F8 鍵時,發現光棒跳到位址 00401020 處,將執行 JMP [00402008] 這一行指令,當您再按下 F8 鍵,程式會跳到,整個畫面會變成下圖:
017F:BFF541BA 55 PUSH EBP 017F:BFF541BB 8BEC MOV EBP,ESP 017F:BFF541BD 6A00 PUSH 00 017F:BFF541BF FF7514 PUSH DWORD PTR [EBP+14] 017F:BFF541C2 FF7510 PUSH DWORD PTR [EBP+10] 017F:BFF541C5 FF750C PUSH DWORD PTR [EBP+0C] 017F:BFF541C8 FF7508 PUSH DWORD PTR [EBP+08] 017F:BFF541CB E84CECFFFF CALL BFF52E1C 017F:BFF541D0 5D POP EBP 017F:BFF541D1 C21000 RET 0010
發現 MessgaeBox 首先把 EBP 暫存器存入堆疊,然後把 EBP 指向堆疊頂端,以 EBP 去堆疊找出參數值來。但是 MessageBox 副程式 ( 應該說 API 太複雜,不易觀察堆疊狀態,註七有另一個簡單的例子。 )
了解了 INVOKE 假指令後,我想以後大概不需要費事去追蹤 Win API 服務程式,看到是 Win API 直接按下 F10 鍵執行完畢就可以了。其實 Soft-ICE 可以把 API 名稱顯示出來,方法是修改 winice.dat 檔案後半段,把所有的『;EXP=……』前面的『;』去掉,重新開機,再以相同方法載入 MESSAGE.EXE 就會看到下面的程式碼:
017F:00401000 6A00 PUSH 00 017F:00401002 6800304000 PUSH 00403000 017F:00401007 680D304000 PUSH 0040300D 017F:0040100C 6A00 PUSH 00 017F:0040100E E80D000000 CALL USER32!MessageBoxA 017F:00401013 6A00 PUSH 00 017F:00401015 E800000000 CALL KERNEL32!ExitProcess
這樣對於除錯或追蹤都很方便,不是嗎?
如果您想返回 Win 9x 作業系統,可以按 Ctrl-D,Soft-ICE 會把控制權交還給Win 9x 作業系統,但是 Soft-ICE 仍然還在記憶體內,您可以隨時按下 Ctrl-D 叫出 Soft-ICE。至於 Soft-ICE 更詳細的用法請參考電腦上的說明。
當程式設計師想用 OllyDebug 除錯 MESSAGE.EXE 時,先執行 OLLYDBG.EXE 程式,然後在 OllyDebug 視窗的選單裏,選擇『File』→『Open』,然後在彈出的對話盒中,切換到 MESSAGE.EXE 所在的子目錄,再選擇 MESSAGE.EXE,就出現像下面的畫面:
您可以把滑鼠游標移到想修改的暫存器上,對這個暫存器快按兩次滑鼠左鍵。
您可以移動滑鼠游標或按鍵盤的向上、向下鍵移動位於反組譯區的灰色光棒 ( 上圖的灰色光棒在位址 00401000 處 ) 到您想設定的中斷點位址,然後按鍵盤的 F2 鍵一次,在反組譯區程式碼位址處會變成紅底黑字,表示此處已經設定好中斷點。
若要移除中斷點,則使灰色光棒移到要移除的中斷點處,再按一次 F2 鍵即可。
F7 和 F8 都是追蹤程式,不過 F7 遇到副程式或 LOOP、REP MOVS 等指令時仍會一步一步去追蹤;而 F8 遇到副程式或 Win32 API 則直接把整個副程式或 Win32 API 執行完畢,遇到 LOOP、REP WOVS 等指令也是直接執行完畢。
按下 F9,可使程式執行到結束,或到中斷點為止。
關閉程式會結束原除錯中的程式,若按下 Ctrl-F2,OllyDebug 會自硬碟重新載入剛結束的程式;若按下 Alt-F2,OllyDebug 不會再載入程式,於是工作區變成空白。事實上,在 OllyDebug 一開始執行後,尚未載入要除錯的程式前,若按下 Ctrl-F2,OllyDebug 會自動載入上次除錯的程式。
OllyDebug 支援原始碼除錯,是一個在 RING 3 很不錯的除錯器。要在 OllyDebug 觀察到原始碼,必須把原始碼的符號資料寫入 EXE 檔裏,在組譯時必須下達『/Zi』參數:
E:\HomePage\SOURCE>ml /Zi message.asm [Enter] Microsoft (R) Macro Assembler Version 6.14.8444 Copyright (C) Microsoft Corp 1981-1997. All rights reserved. Assembling: message.asm Microsoft (R) Incremental Linker Version 5.12.8078 Copyright (C) Microsoft Corp 1992-1998. All rights reserved. /SUBSYSTEM:WINDOWS "message.obj" /DEBUG "/OUT:message.exe" E:\HomePage\SOURCE>
然後再以正常方式執行 OllyDebug,載入 message.exe 即可看到原始碼了,就像下面的圖片:
註一:其實小木偶自己也正學習 Win32 組合語言,所以假如有不正確的地方,還請大家來信告知。
註二:如果您曾學過 C/C++,而且曾寫過 DOS 程式,可能曾看過 TINY、SMALL、COMPACK、MEDIUM、LARGE、HUGE 等模式。在小木偶所寫的 DOS 程式中常常把程式碼段、資料段、堆疊段集中於 64KB 內,然後製作成 *.COM 檔,像這種模式稱之為 TINY 模式(微小模式)。SMALL 模式是資料段和程式碼段分別在不同的區段。COMPACK 模式則是只有一個程式碼區段,而資料段可以有好幾個。MEDIUM 模式則是只有一個資料段,而程式碼區段可以有好幾個。LARGE 模式則是程式碼區段和資料段都可以有好幾個。HUGE 模式則是程式碼區段和資料段都可以有好幾個,並且有必要的話,其某個變數長度可以超過 64KB。
註三:撰寫 Win32 系統的程式,如果要呼叫副程式,不論是此副程式是在原始檔中或是外部副程式,原始程式必須先宣告副程式,這點和在 DOS 的情形不同 ( 如果副程式在 call/INVOKE 呼叫之前,就不須先宣告 )。原因是在 Win32 系統裏的程式會用到 Win32 API,而其所需參數是放在堆疊裏,所以必須知道副程式需要幾個參數,讓組譯器好在堆疊中空出一些空間,容納這些參數。宣告副程式需要的參數,稱為『宣告函數原型』。這點和 C/C++ 很像,在 C/C++ 裏,使用副程式 ( C/C++ 稱為函式或函數 ) 時,也都要事先宣告。
在 Win32 組合語言中宣告副程式原型的方法是用 PROTO ,一般 PROTO 都是放在原始程式的最前面,至少在呼叫副程式之前。PROTO 是一個 MASM 的假指令,其語法如下;
副程式名稱 PROTO [位移] [程式語言] [[參數一]:資料型態,] [[參數二]:資料型態,] ……
上式中的位移可以用 NEAR、FAR……等,表示副程式距離呼叫者多遠,是否是在同一個區段等等,但是在 Win32 裏,記憶體模式都是 FLAT 模式,所以在 Win32 組合語言裏,這個選項是無用的。程式語言可用 C、PASCAL、STDCALL,其意義和 .MODEL 相同,假如這個選項省略的話,就採取 .MODEL 所定義的方式,在 Win32 組合語言來說,應該要使用 STDCALL,而且在 .MODEL 定義即可。副程式所需的參數列在最後面,對 Win32 組合語言來說,參數的資料型態都是雙字組,這是因為要推入堆疊,堆疊中的每一筆資料都是 32 位元長,而參數名稱都可以省略,因為組譯器只需知道推入堆疊的參數個數,至於參數名稱是不重要的,如果不是為了可讀性的話是可以省略的。
在 message.asm 原始程式裏並沒有宣告 MessageBox 這個 Win32 API,但是在 USER32.INC 裏有一行就是定義 MessageBoxA 的函數原型;
MessageBoxA PROTO :DWORD,:DWORD,:DWORD,:DWORD
所以雖然原始程式並沒有宣告 MessageBox,但是只要把 USER32.INC 包含進來就可以了。至於 MessageBox 和 MessageBoxA 的關係請參考註四。
當然除了呼叫 Win32 API 之外,也可以呼叫自己的副程式,假如要呼叫自己的副程式,那麼除了一開始就要用 PROTO 宣告副程式之外,還要自己用 PROC/ENDP 來撰寫副程式,否則在 API 沒有而您又不自己撰寫,這樣無法製成 *.EXE 檔。在 MASM 6.x 之後, PROC 已經做了一些修正,它的語法如下;
副程式名 PROC [位移][程式語言][使用權限][USES 暫存器][參數一:資料型態,]…… 副程式名 ENDP
副程式名可以任意取,只要符合命名規則並且 PROC 和 ENDP 一致即可。位移、程式語言、參數和 PROTO 意義相同,而且在 PROTO 宣告時和在 PROC 假指令指定的選項也應該要一致。使用權限可以用 PUBLIC、PRIVATE、EXPORT 三種,PUBLIC 是表示所有的模組都可以使用,假如在 PROC 沒有指明時,組譯器會採用這種方式。PRIVATE 是表示只有本模組能夠使用。EXPORT 是指如果要編寫 *.DLL 檔時讓此副程式能夠擷取出來。USES 之後接上的暫存器是表示組譯器會在副程式譯開始自動安插 push 指令使暫存器推入堆疊,在 ENDP 指令前用 pop 取回暫存器,這是體貼程式設計師的一種設計,但有時不如自己用 pushad 和 popad 這兩個 80386 指令自己保存暫存器。ENDP 假指令是表示副程式結束。
在撰寫 Win32 組合語言時,如果要呼叫副程式,應該要在程式一開始使用 PROTO 宣告函式原型,並且把該副程式的所用到的參數在 PROTO 後面,並且用 PROC 表是副程式開始,同時也把所使用到的參數列在 PROC 後面,然後在呼叫時儘量用 INVOKE 而不要用 call。這樣的話,如果呼叫時參數的數目和宣告時不一樣時,組譯器會產生錯誤,至於推入多少個參數到堆疊以及結束副程式時捨棄多少的堆疊資料,都由組譯器自動計算,不用程式設計者操心。假如您想用 call,當然也可以,但是必須自行注意堆疊是否錯誤,否則很容易當機。註七有一個例子說明 PROTO、PROC、INVOKE 的關係,以及堆疊操作。
註四:假如您去查 Win32 API 手冊,可能會查到 MessageBoxA 或 MessageBoxW,但是就找不到 MessageBox,其實 MessgaeBoxA 是 ANSI 版本的,MessageBoxW 是 UNICODE 版本的,在 Win 9x 系統中只支援 ANSI 版本,NT/XP 支援 ANSI 及 UNICODE,所以在 user32.inc 裏偷偷把 MessgaeBox 定義成 MessgaeBoxA。您可以用任何文書處理程式開啟 user32.inc 檔案,可以找到這一行;
MessageBox equ <MessageBoxA>
註五:有關 addr 與 offset 的不同,其實是和區域變數與全域變數有關,請參考第三章有關 addr 與 offset 的說明。
ML /Zi /Cp /coff MESSAGE.ASM
那就會製做出含有符號的 MESSAGE.EXE,雖然這樣的做法會使得 MESSAGE.EXE 檔案變大一些,但是當 Soft-ICE 載入時不會出現這個錯誤訊息,而且可以看見原始碼,並且可以用原始碼來除錯,當程式較為複雜時比較方便。其他的 ML 參數可以用
ML /help | more
來觀察。
註七:在 message.asm 裏呼叫了 MessageBox API,但是我們不易觀察到其堆疊運作情形。為了詳細觀察觀察堆疊的運作以及 PROC、INVOKE 等假指令暗地裏所做的事,小木偶另外寫了一個簡單的程式來說明。這個程式會呼叫一個 addition 副程式,addition 副程式的功用是用來求三個參數的和後存於 EAX 暫存器,再返回主程式,原始程式如下:
.386 .MODEL FLAT,STDCALL OPTION CASEMAP:NONE INCLUDE WINDOWS.INC INCLUDE KERNEL32.INC INCLUDELIB KERNEL32.LIB .DATA a1 DWORD 20h a2 DWORD 100h a3 DWORD 40000h .DATA? sum DWORD ? .CODE addition PROC para1:DWORD,para2:DWORD,para3:DWORD mov eax,para1 add eax,para2 add eax,para3 ret addition ENDP start: INVOKE addition, a1, a2, a3 mov sum,eax INVOKE ExitProcess,0 END start
組譯、連結好後,用 Soft-ICE 載入,再按下一次 F8 鍵,觀察程式碼如下:
0187:00401000 55 PUSH EBP 0187:00401001 8BEC MOV EBP,ESP 0187:00401003 8B4508 MOV EAX,[EBP+08] 0187:00401006 03450C ADD EAX,[EBP+0C] 0187:00401009 034510 ADD EAX,[EBP+10] 0187:0040100C C9 LEAVE 0187:0040100D C20C00 RET 0C 0187:00401010 FF3508304000 PUSH DWPRD PTR [00403008] 0187:00401016 FF3504304000 PUSH DWPRD PTR [00403004] 0187:0040101C FF3500304000 PUSH DWPRD PTR [00403000] 0187:00401022 E809FFFFFF CALL 00401000 0187:00401027 A30C304000 MOV [0040300C],EAX 0187:0040102C 6A00 PUSH 00 0187:0040102E E801000000 CALL KERNEL32!ExitProcess
上述程式碼中橘色的部份是 addition 副程式,藍色的部份是 INVOKE 假指令被組譯後的結果。可以很明顯看出來,組譯器根據 INVOKE addition 後面的參數列,組譯成三個 push 指令 ( 注意最右邊的參數先被推入堆疊 )。在 addition 副程式中,組譯器會自動加上三行並修改 RET 指令;
PUSH EBP MOV EBP,ESP ………… LEAVE RET 0C
前兩行是為了,當需要參數時就利用 EBP 暫存器自堆疊中取出。EBP 和 BP 暫存器都是可以利用暫存器定址取出堆疊段 ( SS ) 的數值。這點和以前在 DOS 組合語言中用 BX、SI 定址取出資料段 ( DS ) 的數值,和用 DI 定址取出額外段 ( ES ) 的數值是類似的。最後兩行是為了自副程式返回主程式時,能拋棄參數資料以及恢復正確的 EBP。底下我們先來看看 Soft-ICE 的暫存器視窗:
EAX=00401010 EBX=00000000 ECX=8196BF28 EDX=8196BF68 ESI=8196BF08 EDI=00000000 EBP=0063FF78 ESP=0063FE38 EIP=00401016 CS=0187 DS=018F SS=018F ES=018F FS=43CF GS=0000
再在 Soft-ICE 最下面的視窗輸入『d ds:402FF0』,觀察 DATA 區段的資料:
0187:00402FF0 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 0187:00403000 20 00 00 00 00 01 00 00-00 00 04 00 00 00 00 00
橘色的是 a1 變數,位址在 018F:00403000,淡藍色的是 a2 變數,位址在018F:00403004,淡黃色的是 a3 變數,位址在018F:00403008,在位址018F:0040300C 的是 sum 變數;這些位址可以在上面以 Soft-ICE 反組譯時,PUSH 三個變數的位址看出來。這時候 Soft-ICE 正要執行
0187:00401016 FF3504304000 PUSH DWPRD PTR [00403004]
這一行,但尚未執行。這一行並不是程式的第一行,這是因為一開始我們曾經按下一次 F8 鍵,所以其實程式已經執行過 push a3 了,換句話說,a3 已被推入堆疊了,您可以見到堆疊的情形是下圖的圖一,40000h 已經在堆疊裏了。而最左邊的白色數字,0063FE24 到 0063FE3C,是堆疊位址,黃色的三角形箭號與 ESP 是現在 ESP 所指的位址,而最上面的一行用白色表示的指令是將要執行的指令。底下小木偶以圖形說明堆疊變化情形;
再連續按兩次 F8 後,堆疊情形變成圖三,這時候已經把 addition 所需的三個參數推入堆疊裏了。再按一次 F8 鍵,呼叫位於 00401000 的 addition 副程式,同時把主程式下一個將執行指令的位址,00401027,推入堆疊儲存,如圖四。再按下一次 F8 鍵,執行副程式的第一個指令,把原來的 EBP 推入堆疊儲存起來,如圖五。再按一次 F8 鍵,使 EBP 指向堆疊頂,一直到副程式結束前,EBP 之值都是 63FE28,如圖六。
由上面的例子可以看到,主程式先把參數由右至左推入堆疊,接著執行 call 指令時把返回位址推入堆疊,並以 EBP 當作指標存取參數;如果副程式需要用到區域變數時,也會在這塊堆疊中建立區域變數。這塊堆疊可說是副程式與主程式溝通的橋樑,也是副程式存取資料所在,這塊堆疊稱為『堆疊框』( stack frame )。上圖中的 63FE38 到 63FE28 即為堆疊框。在 Win32 裏面,副程式的第一個參數以 [EBP+08H] 表示、第二個參數以 [EBP+0CH] 表示、第三個參數以 [EBP+10H] 表示……;而返回位址則儲存在 [EBP+04H] 之處。
LEAVE 指令是 80X86 CPU 指令集的一個指令,它先使 ESP 暫存器設定為 EBP 之值,然後再彈出一個堆疊數值存於 EBP。常用於返回主程式時把 ESP 之值設定為正確值。在副程式中,ESP 之值不一定是指向您所預期的位址,因為常有中斷發生,會使 ESP 值不確定,但是如果在副程式一開始設定 EBP 值,那 EBP 不會改變。所以 Win32 系統常利用 LEAVE 指令在返回主程式時把 ESP 之值設定為正確值。在 Win32 系統中,一個堆疊數值長度為一個雙字組 ( 32 個位元 )。
在 16 位元的 DOS 系統中,也可以使用 LEAVE 指令,這時使 SP 暫存器之值設為 BP 之值,然後再彈出一個堆疊數值存於 BP。所不同的是,在 16 位元的系統裏,堆疊一個數值長度為一個字組 ( 16 個位元 )。
RET 指令也是一個 80X86 CPU 指令集的一個指令,假如 n 為零可以僅寫『RET』,那就是取出一個堆疊的資料,存於 IP 或 EIP,視 Win32 或 DOS 系統而定。假如有 n 值,那麼先取出一個堆疊的資料,存於 IP 或 EIP,然後再使 SP 或 ESP 之值加上 n,拋棄堆疊上 n 個位元組的資料,這些資料也就是呼叫副程式時存於堆疊的參數。一般而言,n 值通常由組譯器設定,寫程式時,可以忽略而只寫『RET』即可。