Ch 01 第一個 Win32 組合語言程式

在這一章裏將向各位介紹 ( 註一 ) 在 Win32 作業系統中,最簡單的組合語言程式,同時練習如何以 MASM32 v7.0 組譯連結,以及利用 Soft-ICE 載入程式除錯 ( 或者用 OllyDebug 除錯 ) 這三項工作。


Win32 作業系統中最簡單的程式

依慣例,程式教學的第一個程式,僅在螢幕上顯示一個字串,小木偶也不想例外,所以第一個用組合語言所撰寫的 Win32 程式也是如此。它僅僅顯示一個視窗,標題寫『最簡單的程式』,視窗內文寫『這是在 Win32 作業系統,用組合語言寫的程式。』,並且有一個『確定』按鈕,當使用者按下這個按鈕可以結束程式。它沒有牽涉太多 Win32 複雜的系統,甚至連最基本的訊息傳遞也都沒有,最適合作為 Win32 組合語言入門的程式。這個程式,小木偶名之為 message.asm,其執行結果如下:

於 Win32 作業系統裏,最簡單的程式

上面是在 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 )

呼叫協定是指要決定在最右邊還是最左邊的參數先推入堆疊、是由主程式還是副程式清除堆疊、是否允許不固定個數的參數等等。底下幾種是常見的呼叫協定:

呼叫協定 STDCALLCBASIC
最先推入堆疊的參數
由誰負責清除堆疊副程式 主程式副程式
是否允許不定個數的參數

其他還有一些呼叫協定,如 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,這是一個假指令,它所代表的意義是呼叫副程式,先看看它的語法:

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

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,就知道這是指向字串所在位址的指標。底下是這四個參數所表示的意義:

  1. hWnd:這是父程式的視窗代碼。假如是 NULL 的話,表示 MessageBox 所建立的視窗無父視窗。NULL 是在 WINDOWS.INC 裏定義的一個常數,就是零。

  2. lpText:表示要顯示在視窗內的字串起始位址,該字串必須以 0 為結尾。

  3. lpCaption:表示要顯示於視窗標題的字串起始位址,該字串必須以 0 為結尾。

  4. uType:表示顯示於視窗的按鈕形式,可以查 Win32 API 得知,MB_OK 就是只顯示一個『確定』按鈕,它也是定義在 WINDOWS.INC 裏,其數值為零。底下列出常用的 uType:
    uType 數值意義
    底下的 uType 會顯示不同的按鈕
    MB_OK0h 只顯示『確定』按鈕
    MB_OKCANCEL1h 顯示『確定』與『取消』兩個按鈕
    MB_ABORTRETRYIGNORE2h 顯示『終止』、『重試』、『略過』三個按鈕
    MB_YESNOCANCEL3h 顯示『是』、『否』、『取消』三個按鈕
    MB_YESNO4h 顯示『是』、『否』兩個按鈕
    MB_RETRYCANCEL5h 顯示『重試』、『取消』兩個按鈕
    MB_CANCELTRYCONTINUE6h 顯示『取消』、『重試』、『繼續』三個按鈕
    MB_HELP4000h 顯示『確定』、『說明』兩個按鈕,如果使用者按下『說明』按鈕,系統會發出 WM_HELP 訊息給父程式。有關訊息,參考第二章。
    底下的 uType 會在視窗的左邊顯不同的圖示
    MB_ICONSTOP10h 會顯示圖示
    MB_ICONERROR10h 同 MB_ICONSTOP
    MB_ICONHAND10h 同 MB_ICONSTOP
    MB_ICONQUESTION20h 會顯示圖示,但微軟建議儘量不要使用
    MB_ICONEXCLAMATION30h 會顯示圖示
    MB_ICONWARNING30h 同 MB_ICONEXCLAMATION
    MB_ICONINFORMATION40h 會顯示圖示
    MB_ICONASTERISK40h 同 MB_ICONINFORMATION
    底下的 uType 決定哪一個是內定的按鈕
    MB_DEFBUTTON10h 第一個按鈕為內定按鈕,內定按鈕邊邊會以虛線框框圍住,當使用者按下鍵盤上的『Enter』鍵,就相當使用者以滑鼠點選內定按鈕一樣,具有相同的效果
    MB_DEFBUTTON2100h 第二個按鈕為內定按鈕
    MB_DEFBUTTON3200h 第三個按鈕為內定按鈕
    MB_DEFBUTTON4300h 第四個按鈕為內定按鈕
    底下的 uType 決定 MessageBox 視窗出現後,使用者能否繼續工作
    MB_APPLMODAL0h 如果 MessageBox 是某個程式的子視窗,那麼使用者一定要按下 MessageBox 所產生的視窗中的任一按鈕,才能切換到父視窗;但可以切換到其他視窗繼續工作。如果沒有指定 MB_SYSTEMMODAL,也沒有指定 MB_TASKMOOAL,則 MB_APPLMODAL 為預設值
    MB_SYSTEMMODAL1000h 此旗標會使對話盒出現在最前面,即使以滑鼠點選其他視窗,也仍在最前面。通常用來通知很嚴重的錯誤。
    MB_TASKMODAL2000h
    其他
    MB_SETFOREGROUND10000h 系統呼叫 SetForegroundWindow,使 MessageBox 產生的視窗在最前面。
    MB_DEFAULT_DESKTOP_ONLY20000h
    MB_TOPMOST40000h 產生的視窗具有 WS_EX_TOPMOST 延伸風格
    MB_RIGHT80000h 標題、視窗內文字靠右對齊
    MB_RTLREADING100000h 標題、視窗內文字由右至左排列,用在阿拉伯文或希伯來文
    MB_SERVICE_NOTIFICATION200000h

  5. 傳回值:如果呼叫過程出錯,MessageBox 會把 0 存於 EAX,再返回父程式 ( 父程式就是呼叫 MessageBox 的程式 );如果成功,MessageBox 把傳回值存於 EAX 暫存器裏,表示使用者按下了什麼按鈕,如下表:
    符號 數值意義
    IDOK1h 按下『確定』按鈕
    IDCANCEL2h 按下『取消』按鈕。如果視窗中有『取消』按鈕,按下鍵盤的 Esc 鍵,也能關閉視窗,並返回 IDCANCEL;如果沒有『取消』按鈕,Esc 鍵就沒有作用。
    IDABORT3h 按下『終止』按鈕
    IDRETRY4h 按下『重試』按鈕
    IDIGNORE5h 按下『忽略』按鈕
    IDYES6h 按下『是』按鈕
    IDNO7h 按下『否』按鈕
    IDCONTINUE0Bh 按下『繼續』按鈕

Win32 API 的傳回值均存放在 EAX 暫存器裏。

ADDR 假指令

為了要取得兩個字串的起始位址,在字串前加上 addr 假指令,表示取得位址之意。addr 和以前的 offset 很相似,差別在 addr 不能向前引用,意思是您必須先定義變數才能在程式後面取得該變數位址,不能在程式後面定義變數而在定義前使用 addr,而 offset 則可以。addr 不能把變數位址傳給其他變數或暫存器,例如

mov     si,addr string

這樣寫是不合法的,但是 offset 卻可以。addr 一般都是配合 INVOKE 假指令用的。(註五)

了解上述之後,INVOKE 假指令就不難了解了,INVOKE 就好像呼叫一個副程式 (API),而這個副程式所需要的參數,就接在副程式的後面,這樣用 INVOKE 呼叫比用 call 呼叫至少有一點好處,那就是 INVOKE 會幫我們檢查推入堆疊裏的參數個數是否正確,假如參數數目或型態不正確的話,在組譯階段就會產生錯誤訊息。而使用 call 呼叫則不會產生錯誤,但是在執行時,很容易引起當機。

ExitProcess API

程式第 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 即可看到彈出一個視窗,上面的訊息就是我們所寫的程式內容!


用 Soft-ICE 觀察

以前用 DEBUG 或 SYMDEB 時,要載入除錯的程式直接接在 DEBUG 或 SYMDEB 之後,但是 Soft-ICE 無法這樣載入,那要如何觀察 MESSAGE.EXE 執行情形呢?請照下面方法試試:

選取由『開始』→『程式集』→『NuMega SoftICE』→『Symbol Loader』,執行 Symbol Loader,然後在其選單內選擇『File』→『Open Modules...』開啟選擇檔案的對話盒,如下圖:

用 Symbol Loader 載入執行檔除錯
注意到這個對話盒下面的『檔案類型』是可執行檔 (*.EXE) 及動態連結程式庫 (*.DLL)。然後切換到您要載入除錯的可執行檔所在子目錄選擇該檔。按『開啟舊檔』後會看到在 Symbol Loader 視窗內顯示

=========================
H:\HomePage\SOURCE\MESSAGE.exe - loaded successfully

然後由選單內,選擇『Module』→『Load』,Soft-ICE 會出現沒有符號檔 ( 註六 ) 的訊息,問您是否繼續載入,如下圖:

切換到除錯畫面
按下『是』就可以切換到除錯畫面。進入除錯畫面無法用其他軟體,連滑鼠都無法使用,因此無法抓下圖片給各位看。

Soft-ICE 指令:追蹤-t 或 F8

除錯畫面是一塊黑底白字的長方形區域,其中還被分割成好幾塊,最下面那一塊應該是『命令視窗』(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。

Soft-ICE 指令:副程式追蹤-p 或 F10

當您追蹤到 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 觀察

當程式設計師想用 OllyDebug 除錯 MESSAGE.EXE 時,先執行 OLLYDBG.EXE 程式,然後在 OllyDebug 視窗的選單裏,選擇『File』→『Open』,然後在彈出的對話盒中,切換到 MESSAGE.EXE 所在的子目錄,再選擇 MESSAGE.EXE,就出現像下面的畫面:

OllyDebug除錯畫面
OllyDebug 最常用到的就是上面的 CPU 視窗,整個視窗分成可用滑鼠調整大小的五個區域,分別是反組譯區 ( Disassembler )、訊息區 ( Information )、記憶體顯示區 ( Dump )、暫存器區 ( Registers ) 和堆疊區 ( Stack )。我們主要工作是在反組譯區,此區又再細分成程式碼位址、機械碼、助記憶碼、註解四區。在機械碼有一左中括號,此左中括號所括起來的程式碼表示在同一副程式裏;註解區也有兩個左中括號,它們表示呼叫 Win32 API 所需之參數。在暫存器區裏,您可以按下滑鼠右鍵,選擇顯示 FPU、MMX、3DNow! 或除錯暫存器。其餘的功能還有很多,小木偶不一一介紹,請大家試試,小木偶僅介紹幾個常用的功能。

修改暫存器數值

您可以把滑鼠游標移到想修改的暫存器上,對這個暫存器快按兩次滑鼠左鍵。

設定或移除中斷點:F2 鍵

您可以移動滑鼠游標或按鍵盤的向上、向下鍵移動位於反組譯區的灰色光棒 ( 上圖的灰色光棒在位址 00401000 處 ) 到您想設定的中斷點位址,然後按鍵盤的 F2 鍵一次,在反組譯區程式碼位址處會變成紅底黑字,表示此處已經設定好中斷點。

若要移除中斷點,則使灰色光棒移到要移除的中斷點處,再按一次 F2 鍵即可。

追蹤:F7 或 F8

F7 和 F8 都是追蹤程式,不過 F7 遇到副程式或 LOOP、REP MOVS 等指令時仍會一步一步去追蹤;而 F8 遇到副程式或 Win32 API 則直接把整個副程式或 Win32 API 執行完畢,遇到 LOOP、REP WOVS 等指令也是直接執行完畢。

執行程式:F9

按下 F9,可使程式執行到結束,或到中斷點為止。

關閉程式:Ctrl-F2 或 Alt-F2

關閉程式會結束原除錯中的程式,若按下 Ctrl-F2,OllyDebug 會自硬碟重新載入剛結束的程式;若按下 Alt-F2,OllyDebug 不會再載入程式,於是工作區變成空白。事實上,在 OllyDebug 一開始執行後,尚未載入要除錯的程式前,若按下 Ctrl-F2,OllyDebug 會自動載入上次除錯的程式。

在 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 即可看到原始碼了,就像下面的圖片:

OllyDebug在原始碼階段的除錯畫面
在上圖中,MessageBox API 的兩個參數,標題及字串,都變成了原始碼中的變數名稱了,不再是以位址表示,這樣對於較大程式的除錯很有幫助。


註一:其實小木偶自己也正學習 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++ 稱為函式或函數 ) 時,也都要事先宣告。

PROTO 假指令

在 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 的關係請參考註四

PROC 和 ENDP 假指令

當然除了呼叫 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,如圖六。

說明堆疊運作情形
圖六上方的指令跟接下來的兩個指令,一共三個指令都與堆疊無關,很快的連續按三次 F8,結果如圖九,這時將執行 LEAVE 指令,這個指令是把 ESP 之值設為 EBP,然後再使 ESP 所指的堆疊頂數值彈出存於 EBP。按下 F8,就執行上述過程,即 EBP 恢復呼叫前原值,且 ESP 加四,指向 63FE2C,如圖十。再來是執行 RET 0C 指令,這個指令是從堆疊取出返回位址,然後再捨去 0CH 個位元組的資料,所以再按下一次 F8 鍵後,ESP 便指向 63FE3C,這個位址是未執行 INVOKE 之前 ESP 所指的位址。底下還有一個 INVOKE 指令,但是操作方法類似,所以小木偶不打算再說明了。

由上面的例子可以看到,主程式先把參數由右至左推入堆疊,接著執行 call 指令時把返回位址推入堆疊,並以 EBP 當作指標存取參數;如果副程式需要用到區域變數時,也會在這塊堆疊中建立區域變數。這塊堆疊可說是副程式與主程式溝通的橋樑,也是副程式存取資料所在,這塊堆疊稱為『堆疊框』( stack frame )。上圖中的 63FE38 到 63FE28 即為堆疊框。在 Win32 裏面,副程式的第一個參數以 [EBP+08H] 表示、第二個參數以 [EBP+0CH] 表示、第三個參數以 [EBP+10H] 表示……;而返回位址則儲存在 [EBP+04H] 之處。

LEAVE 指令

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 n 指令

RET 指令也是一個 80X86 CPU 指令集的一個指令,假如 n 為零可以僅寫『RET』,那就是取出一個堆疊的資料,存於 IP 或 EIP,視 Win32 或 DOS 系統而定。假如有 n 值,那麼先取出一個堆疊的資料,存於 IP 或 EIP,然後再使 SP 或 ESP 之值加上 n,拋棄堆疊上 n 個位元組的資料,這些資料也就是呼叫副程式時存於堆疊的參數。一般而言,n 值通常由組譯器設定,寫程式時,可以忽略而只寫『RET』即可。


到第零章回到首頁到第二章