Ch 03 繪製字串與接受按鍵


這一章堙A小木偶將撰寫一個程式,DRAWTXT.ASM,執行此程式時會產生一個視窗,假如使用者按下一個鍵時,它會把使用者所按下鍵所代表的 ASCII 字元顯示在視窗堙C例如,您按下『P』鍵,那麼它會顯示:

按鍵與回應


原理

GDI 與 DC ( 設備內容 )

GDI 是 Windows 系統用來作為應用程式與螢幕、印表機之間的界面,稱為圖形裝置界面 ( graphics device interface,縮寫為 GDI )。這個界面一端連接螢幕、印表機等硬體周邊設備,另一端連接著我們所撰寫的應用程式。

雖然每一家廠商所生產的產品不同,其驅動程式也不一樣,不過每家廠商的驅動程式都能直接使這些產品正常工作。同時這些驅動程式也必須符合 GDI 所規定的規範。另一方面,應用程式使用 GDI 函式也必須遵守一定的規則,也就是指遵守各參數的規定及稍後提到的規則。

換句話說,只要我們所撰寫的程式遵守 GDI 的規則,就能很簡單的在螢幕或印表機顯示出圖形來。另一方面,GDI 同時也要求硬體廠商所提供的驅動程式也必須遵守一定的規範,這樣程式設計師不必去關心使用者是使用那一家廠商的螢幕、印表機。所以 GDI 是一個與硬體無關的程式界面。

Windows 系統是一個多工系統,同一時間堙A可能有好幾個程式共用一個螢幕或印表機,所以當程式向螢幕、印表機輸出時,GDI 還必須做協調工作否則必會產生大亂。GDI 的做法是使這些程式先輸出到一個虛擬裝置,這個虛擬裝置就是設備內容( device context,縮寫為 DC ),而每一個應用程式要輸出資料到螢幕或印表機時必須向 Windows 系統取得許可,假如成功的話系統便會傳回一個設備內容代碼。

設備內容是一個很大的資料結構,包含了一組圖形物件 ( graphic objects,圖形物件包含筆、筆刷等等,您可以把它們想像成是一組程式,負責畫出點線寫出字、填上顏色的工具等等 ) 以及相關屬性,換句話說,對顯示器來講,您可以把設備內容想像成儲存這個視窗堛犒洇峇漁e及畫出圖形的程式。

試想,一個 800*600 的視窗有多少資料,假如同時開啟好幾個視窗,那麼所消耗的資源必定很可觀。因為設備內容所佔用的空間及資源是如此巨大,所以應用程式收到要輸出資料到螢幕的訊息 ( 即 WM_PAINT,稍後說明 ) 後,開啟一個設備內容,當完成後,必須在回到訊息循環之前釋放這個設備內容。

在 Windows 作業系統下,顯示一個字串,並不像在 DOS 下這樣簡單。首先 DOS 是文字模式,程式是以類似打字機的方式,一個字一個字印出來的;而 Windows 系統是在圖形模式,每個字是由許多的點組成,是一點一點描繪。幸好這個細節是由系統幫我們完成,當然您如果想控制每一個點,也有 API 可以幫您達成目的。

第二,DOS 系統是一次只有一個程式執行,螢幕被這個程式獨佔,當要印出字串時不用考慮其他程式,但是 Windows 系統是多工作業系統,在螢幕上可能同時顯示許多視窗,這些視窗共用一個螢幕,所以當某一個程式要顯示字串時,必須考慮這個程式的視窗是否被其他視窗遮蓋,假如被其他視窗遮蓋是否僅遮蓋部份而只顯示部份字串等等。

WM_PAINT 訊息與無效區域 ( invalid rectangle )

關於第二個問題,GDI 是利用重繪的方式來解決。每當使用者把一個視窗移到另一個視窗上面,或者改變視窗的大小 ( 包括縮到最小或放到最大、或者改變視窗長度、寬度 ),或者按下工具列的選單,或者滑鼠游標移動穿過工作區,用捲軸捲動工作區時等等,這些情形發生時 Windows 系統都能察覺,並且發出一個 WM_PAINT 給該視窗,告訴該視窗需要重新繪製了。當然程式也可以對自己發出 WM_PAINT 訊息,強迫自己重繪,例如呼叫 InvalidateRect API。其他還有一些 API 也會產生 WM_PAINT,像 UpdateWindow,通常視窗的第一個 WM_PAINT 訊息是 UpdateWindow 發出的。

當視窗函式收到 WM_PAINT 時,必須重新繪製視窗,但有時我們並不希望重繪整個視窗,因為那樣太消耗資源又太浪費時間了。所以 Windows 只重新繪製部份被遮蓋或者需要重新繪製的部份就可以了,這個區域稱為『無效區域』。那麼無效區域有多大又位於工作區的那堜O?這個問題很複雜,因為有許多各種不同的情況,但是這些都交由系統解決了。當系統發出 WM_PAINT 後被視窗函式所處理時,一般而言是先呼叫 BeginPaint API 函數,此函數會傳回一個結構體,此結構體含有無效區域。或者也可以呼叫 GetUpdateRect API 直接取得此視窗的無效區域。

當程式呼叫 BeginPaint API 時,BeginPaint 會把整個工作區設為有效。假如您不想處理 WM_PAINT 訊息,那麼就交由 DefWindowProc 處理,千萬不能寫成

        cmp     uMsg,WM_PAINT
        jnz     next_WM1
        jmp     exit
        ………
next_WM1:                       ;其他訊息

exit:   xor     eax,eax
        ret

這樣表示你雖然處理 WM_PAINT,但實際上工作區仍有無效區域,所以系統會發現這個無效區域未處理,於是再送一次 WM_PAINT,但程式仍無法把無效區域清除,系統會一直對你的視窗發出 WM_PAINT 的訊息。

按鍵:WM_CHAR 訊息

在螢幕上同一時間堙A可能有好幾個視窗,可是一般電腦只有一個鍵盤,所以只有正在使用的視窗能夠接收到鍵盤的按鍵。所謂正在使用的視窗,其實是很容易分辨的,它的標題欄是高亮度的,或者您也可以說是螢幕最上層的視窗。當使用者按下鍵盤中的一個鍵時,系統會分辨使用者所按下鍵是一般按鍵還是系統按鍵,所謂系統按鍵是指像 F10 鍵、Alt 鍵等,此時系統會發出 WM_SYSKEYDOWN,放開系統鍵時,會發出 WM_SYSKEYUP 訊息。但除非有特別的目的,一般很少處理系統按鍵,而這個程式我們只處理一般按鍵。

假如使用者按下一般按鍵的話,系統會發出 WM_KEYDOWN 訊息給正在使用的視窗,當使用者放開該鍵時,系統會發出 WM_KEYUP 訊息,WM_KEYDOWN、WM_KEYUP 訊息中的 wParam 中存有虛擬鍵碼 ( Virtual-Key Codes )。所謂虛擬鍵碼是 Windows 系統內所定義的,鍵盤上的每個按鍵都有獨一無二的數值,即為虛擬鍵碼。換句話說,Windows 就是靠虛擬鍵碼來判斷使用者按下那一個鍵,或放開那一個鍵。英文字母按鍵的虛擬鍵碼其實就是英文大寫的 ASCII 碼,鍵盤上方的數字鍵的虛擬鍵碼就是阿拉伯數字的 ASCII 碼,至於其他按鍵可以參考 Win32 Programmer's Reference。

雖然 Windows 賦予每個鍵獨一無二的虛擬鍵碼,但是虛擬鍵碼並未考慮使用者輸入大小寫的情形。例如,如果使用者在 CapsLock 燈熄滅的情形下按下『A』鍵,表示輸入的是『a』字元,但是在 CapsLock 燈亮的情形下按下『A』鍵或是在 CapsLock 熄滅下,同時按下 Shift 鍵與『A』鍵,表示輸入的是『A』字元。這兩種情形,在系統內部都是表示使用者按下『A』鍵,不管是否同時按下 Shift 鍵,也不管 CapsLock 燈號是否亮著,WM_KEYDOWN 或 WM_KEYUP 的 wParam 都會傳來『A』鍵的虛擬鍵碼,所以直接處理 WM_KEYDOWN 和 WM_KEYUP 還得參考其他資料才能知道您按的是大寫或小寫。

但只在特殊情形時,我們才直接處理 WM_KEYDOWN 和 WM_KEYUP,一般我們可以經由 TranslateMessage API 把 WM_KEYDOWN 與 WM_KEYUP 訊息翻譯成 WM_CHAR 訊息,TranslateMesage 會自動配合 CapsLock 與 Shift 鍵的狀況去判斷按鍵是大寫或小寫,然後存在 WM_CHAR 堙A這樣在 WM_CHAR 堶悸 wParam 就是您按鍵時候的狀況是大寫或小寫。


原始程式

        .386
        .model  flat,stdcall
        option  casemap:none

include         windows.inc
include         user32.inc
include         kernel32.inc 
includelib      user32.lib
includelib      kernel32.lib

WndProc         proto   :HWND,:UINT,:WPARAM,:LPARAM
DrawStr         proto   :DWORD,:DWORD           ;12 宣告 DrawStr 函式原型

        .DATA
ClassName       db      'SimpleWinClass',0
AppName         db      '按鍵與回應',0          ;16 標題欄的字串
MyText          db      '您按下了   鍵。',0     ;17 將於工作區繪製的字串
hInstance       HINSTANCE       ?
hwnd            HWND            ?
CommandLine     LPSTR           ?
wc      WNDCLASSEX      <30h,?,?,0,0,?,?,?,?,0,offset ClassName,?>
msg     MSG             <?>

        .CODE
start:  invoke  GetModuleHandle,NULL
        mov     hInstance,eax
        invoke  GetCommandLine
        mov     wc.style,CS_HREDRAW or CS_VREDRAW
        mov     wc.lpfnWndProc,offset WndProc
        mov     eax,hInstance
        mov     wc.hInstance,eax
        mov     wc.hbrBackground,COLOR_WINDOW+1
        invoke  LoadIcon,NULL,IDI_APPLICATION
        mov     wc.hIcon,eax
        mov     wc.hIconSm,eax
        invoke  LoadCursor,NULL,IDC_ARROW
        mov     wc.hCursor,eax
        invoke  RegisterClassEx,offset wc
        invoke  CreateWindowEx,NULL,offset ClassName,offset AppName,\ 
                WS_OVERLAPPEDWINDOW,0,0,200,100,0,0,hInstance,NULL 
        mov     hwnd,eax
        invoke  ShowWindow,hwnd,SW_SHOWDEFAULT
        invoke  UpdateWindow,hwnd

gt_msg: invoke  GetMessage,offset msg,NULL,0,0
        or      eax,eax
        jz      wm_qut
        invoke  TranslateMessage,offset msg
        invoke  DispatchMessage,offset msg
        jmp     gt_msg
wm_qut: mov     eax,msg.wParam
        invoke  ExitProcess,eax

WndProc proc    hWnd:HWND,uMsg:UINT,wParam:WPARAM,lParam:LPARAM
        cmp     uMsg,WM_PAINT           ;55 檢查是否 WM_PAINT 訊息
        jne     not_paint               ;56 否,跳到第 61 行
        mov     eax,offset MyText
        invoke  DrawStr,eax,hWnd        ;58 呼叫副程式
        jmp     exit

not_paint:                              ;61
        cmp     uMsg,WM_CHAR            ;62 檢查是否按下鍵
        jne     not_keyinput            ;63 否,跳到第 70 行
        mov     eax,wParam              ;64 得到按鍵 ASCII 字元
        mov     edx,offset MyText+9     ;65 取得字串中按鍵字元的位址
        mov     [edx],al                ;66 填入 ASCII 字元
        invoke  InvalidateRect,hWnd,0,1 ;67 設定為無效區域
        jmp     exit

not_keyinput:                           ;70
        cmp     uMsg,WM_DESTROY
        jne     default
        invoke  PostQuitMessage,NULL
        jmp     exit                    ;74

default:
        invoke  DefWindowProc,hWnd,uMsg,wParam,lParam
        ret
exit:   xor     eax,eax
        ret                             ;80 回到主程式
WndProc endp

DrawStr proc    AddrSt:DWORD,hWin:DWORD ;83 DrawStr 副程式開始
        LOCAL   hDevCont:HDC            ;84 區域變數,存 DC 代碼
        LOCAL   PS:PAINTSTRUCT          ;85 區域變數,存
        LOCAL   rectagl:RECT            ;86
        invoke  BeginPaint,hWin,ADDR PS ;87
        mov     hDevCont,eax            ;88
        invoke  GetClientRect,hWin,ADDR rectagl ;89
        invoke  DrawText,hDevCont,AddrSt,-1,\   ;90
                ADDR rectagl,DT_SINGLELINE      ;91
        invoke  EndPaint,hWin,ADDR PS           ;92
        ret
DrawStr endp

end     start

程式說明

這個程式和上一章差不多,只是在視窗函式 ( window procedure ) 埵h了兩個訊息是這個程式較有興趣而要處理的,這兩個訊息是 WM_CHAR 和 WM_PAINT。第 55 行到第 59 行檢查是否需要重繪,若需要重繪則執行第 58 行 CALL DrawStr,DrawStr 是重新繪製字串的副程式,其副程式在第 83 行到第 94 行。

第 61 行到第 68 行是檢查是否按下鍵,若為是則處理之;第 70 行到第 74 行則是處理退出程式的訊息;第 76 行到第 80 行是內定處理訊息的 DefWindowProc 部份。因為大部分都和上一章相同,所以小木偶僅就新增或重要的部份說明。

程式第 62 行檢查訊息是否為 WM_CHAR,如果是的話,有兩項工作要完成。第一是取得按鍵的 ASCII 字元並填入字串堙A待下一次訊息迴圈時印出於螢幕,這工作由程式第 64 到 66 行完成。第二是把視窗設為無效區,由程式第 67 行的 InvalidateRect API 完成。

InvalidateRect API

這個 API 是用來設定無效區域的,其用法如下:

BOOL InvalidateRect(
    HWND        hWnd,       // handle of window with changed update region
    CONST RECT  *lpRect,    // address of rectangle coordinates
    BOOL        bErase      // erase-background flag
   );

hWnd 是指要設定無效區的那個視窗的視窗代碼,lpRect 是指該視窗中要設定那一塊區域為無效區域, lpRect 是一個位址指標,它指向一個稱為 RECT 的結構體,該結構體是指定一塊長方形區域,描述如下:

RECT    struc
left    DD      ?       ;長方形左上角的 x 座標
top     DD      ?       ;長方形左上角的 y 座標
right   DD      ?       ;長方形右下角的 x 座標
bottom  DD      ?       ;長方形右下角的 y 座標
RECT    ends

lpRect 也可以是零,假如是零的話,表示工作區的所有範圍都是無效區域。最後一個參數 bErase 是表示是否清除背景,零的話表示不清除,非零表示要清除背景,在呼叫 BeginPaint 時,背景就會被清除。InvalidateRect 有傳回值,如果設定成功傳回值為非零,失敗傳回值為零。

當程式設定一塊無效區域時,系統會察覺而發出一個 WM_PAINT 訊息填入該視窗的訊息佇列堙A等待該視窗的訊息迴圈提取。而後程式繼續執行,跳到第 79 行返回 Windows 系統,等到執行下一次的訊息循環時就能收到 WM_PAINT 訊息,然後經由回呼機制再次進入視窗函式,在程式第 59 行檢查到訊息是 WM_PAINT 時,就執行 DrawStr 副程式。

所以囉,在收到 WM_PAINT 之後不可再呼叫 InvalidateRect,這樣會不斷地產生 WM_PAINT 訊息,以致於跌入一個無窮迴圈。

DrawStr 副程式

當我們的程式收到 WM_PAINT 時,表示視窗要重新繪製,這段程式都集中在 DrawStr 副程式堙CDrawStr 的原型宣告在第 12 行,第一個參數是要印出的字串位址,第二個參數是視窗代碼。底下來分析 DrawStr 副程式。

前三行是定義三個區域變數,這三個區域變數是 hDevCont、PS、rectagl,它們是在 DrawStr 副程式中會使用到的變數,有關區域變數的觀念請看底下 LOCAL 假指令的說明。

LOCAL 假指令

LOCAL 是用來在副程式堜w義變數的假指令,所謂在副程式堜w義的變數是指這個變數僅在副程式中可以使用它,在副程式以外則不能使用,否則會出現

error A2006: undefined symbol : 變數名

的錯誤。像這種只有在副程式範圍內才可以使用的變數稱之為『區域變數』,與區域變數相對的稱為『全域變數』,全域變數是整個程式中都可以存取的。至於為何只能夠在副程式的範圍使用以及為何使用區域變數的理由,小木偶稍後再說明。因為在副程式外的區域變數是無效的,所以不同副程式的區域變數名稱可以是相同的。現在先來看它的語法:

LOCAL   變數名[重複次數]:資料型態

重複次數有點像 dup,只不過 dup 是用在定義全域變數,假如不重複的話,[重複次數]可省略。資料型態是只這個變數的長度,例如雙字組用 DWORD,字組用 WORD,位元組用 BYTE,也可以使用結構體。LOCAL 假指令只能放在 PROC 宣告之後,其他指令開始之前。底下來看看幾個例子,假如有個程式片段寫成

        .code
start:  invoke  MyProc
        invoke  ExitProcess,0
MyProc  proc
        local   p:dword
        local   q:dword
        local   r:word
        mov     p,01234567h
        mov     q,89abcdefh
        mov     r,400h
        ret
MyProc  endp

這樣,組譯後再用 Soft-ICE 載入後按下 F8 鍵,然後觀察程式碼的樣子如下:

00401000 E807000000     CALL   0040100C →呼叫 MyProc
00401005 6A00           PUSH   00
00401007 E81C000000     CALL   KERNEL32!ExitProcess
0040100C 55             PUSH   EBP      →MyProc 開始處
0040100D 8BEC           MOV    EBP,ESP
0040100F 83C4F4         ADD    ESP,-0C
00401012 C745FC67452301 MOV    DWORD PTR [EBP-04],01234567
00401019 C745F8EFCDAB89 MOV    DWORD PTR [EBP-08],89ABCDEF
00401020 66C745F60004   MOV    WORD PTR [EBP-0A],400
00401026 C9             LEAVE
00401027 C3             RET             →MyProc 結束處

我們先找到 MyProc 副程式從位址 40100C 到 401027,再比較原始程式與機械碼,發現組譯器會在組譯時把 LOCAL 假指令的敘述再額外加上

PUSH    EBP
MOV     EBP,ESP
ADD     ESP,-XXXX

這三條指令。前兩條指令是保存 EBP 暫存器,因為在副程式堙AEBP 要做為存取參數之用 ( 參考第一章的註七 )。第三條指令則是在堆疊中預留一些空間,給區域變數存放之用。ESP 指向堆疊最高位址的地方,之後有數值被推入堆疊,ESP 就減少。假如使 ESP 減去某一數值,就會使下一次的 PUSH 或 CALL 指令,存入某數到堆疊時,在較低位址存入此數。這樣一來,會使堆疊空下一些空間,這些空間就給區域變數使用。當要存取區域變數時,就靠 EBP 來當指標,此外,在 80386 CPU 是以 32 位元來作為存取單位,故雖然上述副程式只用去 10 個位元組 ( p、q 各佔 4 個位元組,r 佔兩個位元組 ) 的區域變數,但是堆疊還是保留了 12 個位元組的大小給區域變數,雖然浪費了但是卻能加快速度。有關 LOCAL 的詳細過程請及堆疊操作請參考下圖。

區域變數說明一
當進入 Soft-ICE 後按下第一次 F8 鍵後,就立刻執行第一個指令,CALL MyProc,所以堆疊堣w經有一個 CALL MyProc 指令下一個指令 ( PUSH 00 ) 的位址,00401005,而 ESP 之值變為 0063FE38,也就是指向返回位址,如上圖一。然後再執行兩個命令,先保存 EBP 之值,再來使 EBP 之值等於 ESP,於是 ESP、EBP 都等於 0063FE34,而該位址之值,0063FF78,是原來的 EBP,如圖三。接下來是執行,ADD ESP,-0C,這個指令,相當於使 ESP 減少 12 的意思,於是在堆疊堳O留了 12 個位元組給區域變數使用,當我們要存取這些區域變數時,便以 EBP 作為參考點,例如第一個變數,p,以 [EBP-4] 代替;第二個變數,q,以 [EBP-8],代替;第三個變數,r,則以 [EBP-0AH] 代替。前兩者因為 p、q 長度是雙字組,故隔 4 個位元組;但是 r 的長度為字組,故只減少兩個位元組,所以 [EBP-A] 存 r 之值,[EBP-8] 則不使用。見圖四。

接下來的三個指令是把 p、q、r 三個區域變數填入對應的堆疊區,如下圖五、六、七。接下來是 LEAVE 指令,LEAVE 先使 ESP 之值等於 EBP,於是 ESP 值等於 0063FE34,然後再從堆疊彈出一個雙字組存於 EBP,於是 EBP 恢復原值,而 ESP 也指向回到主程式將執行的指令位址,如下圖八,這時原來在堆疊的區域變數就被拋棄了。

區域變數說明二
接下來,是 RET 指令,它從堆疊取出主程式下一個要執行的位址,於是程式返回主程式,如上圖九。所以這樣看起來,區域變數和傳遞資料到函式的參數其實運作方式差不多 ( 參考第一章註七 ),它們都被儲存在堆疊堙A並且當副程式或函式返回時都利用 LEAVE 指令拋棄它們。綜合第一章註七及上述說明,我們可以知道,在 Win32 堶悸滌幭|框,是用來儲存參數、區域變數及返回位址之用。副程式的第一個參數以 [EBP+08H] 表示、第二個參數以 [EBP+0CH] 表示、第三個參數以 [EBP+10H] 表示……;而返回位址則儲存在 [EBP+04H] 之處;至於第一個區域變數以 [EBP-04H] 表示、第二個區域變數以 [EBP-08H] 表示……。

為何使用區域變數呢?區域變數有一些限制,它不能再副程式以外的地方使用,這樣不是給程式許多限制嗎?原因是這樣的:當程式小的時候,您應該可以看得出程式是如何運作的,即使日後要維護,也不算太困難。但是當成是變得龐大且複雜時,要很快看出程式脈絡就不容易了,這時有些程式設計師提出一個觀念,他們希望能夠把具有某些功能的程式片段寫成副程式的形式,不僅如此還進一步要求這個副程式最好能只有輸入參數把資料傳遞進來,然後經過副程式處理把結果傳回主程式就好,不要再有其他部份和主程式有牽連,也就是說,在這個副程式中如果必須用到變數,這個變數最好只能被這個副程式存取,假如此變數被副程式以外的程式存取會造成許多干擾,除錯也不易,這也就是為什麼我們使用區域變數的原因。這個觀念就是數年前 ( 約 1990 年代 ) 流行的結構化觀念。

底下看看第 87 行的 invoke BeginPaint。但在這之前,先看看 ADDR 假指令。

ADDR 假指令

ADDR 和 OFFSET 都是取得位址的假指令,兩者稍有不同。OFFSET 是用在組譯時已經確定位址的地方,例如取得在資料段中的變數位址。但是在堆疊堛滌炾嚃僂ヾA組譯時還不知道位址 ( 因為那時還不知道在此之前堆疊被使用了多少 ),這時就不能用 OFFSET 來取得位址,必須用 ADDR。那麼 ADDR 是怎麼取得區域變數的位址呢?

原來組譯器會把 ADDR 翻譯成

LEA     EAX,變數

所以 EAX 便存有該區域變數的位址,之後便用 EAX 去取代 『ADDR 變數』。例如程式第 87 行的

invoke  BeginPaint,hWin,ADDR PS

經過組譯後就變成:

LEA     EAX,[EBP-44]
PUSH    EAX
PUSH    DWORD PTR [EBP+0C]
CALL    USER32!BeginPaint

上面粉紅色的部份是 ADDR 組譯後的結果,組譯後 PS 結構體變數的位址便放在 EAX 堙A所以再把 EAX 推入堆疊堙A就完成取得 PS 變數的位址及為 BeginPaint API 傳遞參數。

BeginPaint API

BeginPaint API 是用來準備繪圖時使用,其語法如下:

HDC BeginPaint(
    HWND            hwnd,       // handle to window
    LPPAINTSTRUCT   lpPaint     // pointer to structure for paint information  
   );

BeginPaint 有兩個參數,第一個是視窗代碼,第二個是一雙字組大小的數值,此數指向結構體,PAINTSTRUCT,的位址。假如呼叫成功,在 eax 會傳回設備內容代碼,小木偶把它存於區域變數 hDevCont 堙C至於 PAINTSTRUCT 結構體的內容如下:

PAINTSTRUCT     STRUC
  hdc           dd      ?
  fErase        dd      ?
  rcPaint       RECT    <?>
  fRestore      dd      ?
  fIncUpdate    dd      ?
  rgbReserved   db      32 dup (?) 
PAINTSTRUCT     ENDS

hdc 是設備內容代碼與 BeginPaint 的傳回值相同。fErase 是用來表示是否對無效區域的背景上色,要上色的話用 TRUE,不上色用 FALSE。rcPaint 是另一個結構體,RECT,此結構體指定了一塊矩形範圍,在呼叫 BeginPaint 之後,系統會把無效區的範圍填入 rcPaint 結構體,再傳回給我們的程式。fRestore、fIncUpdate、rgbReserved 都保留給系統使用。

EndPaint API

BeginPaint 會開啟一個設備內容,當繪圖完畢,應該把它關閉,一般是使用 EndPaint 來處理,換句話說 BeginPaint 與 EndPaint 應該成對搭配。EndPaint 語法是

BOOL EndPaint(
    HWND                hWnd,       // handle to window
    CONST PAINTSTRUCT   *lpPaint    // pointer to structure for paint data
   );

EndPaint 的參數和 BeginPaint 相同。一般程式要繪圖時的做法有兩種,第一種是像下面在 WM_PAINT 訊息中使用:

        invoke  BeginPaint,hWin,ADDR PS
        ……    ……                    ;繪圖程式
        invoke  EndPaint,hWin,ADDR PS

BeginPaint 和 EndPaint 兩個 API 來開啟和釋放設備內容。也可以使用第二種方法,用 GetDC 和 ReleaseDC 來處理。

GetClientRect API

GetClientRect 是用來取得工作區的大小,其函式原型為

BOOL GetClientRect(
    HWND    hWnd,       // handle of window
    LPRECT  lpRect      // address of structure for client coordinates
   );

hWnd 是要取得工作區的視窗代碼,lpRect 是指向一個 RECT 結構體位址,當 GetClientRect 執行完後會傳回來左上角和右下角的座標,並存於 lpRect 所指位址的結構體內。一般而言,工作區的左上角座標是 (0,0),所以右下角座標其實就是工作區的長與寬。

DrawText API

這個 API 是用來把字串以指定的方式顯示在某個的區域內,其原型為:

int DrawText(
    HDC     hDC,        // handle to device context
    LPCTSTR lpString,   // pointer to string to draw
    int     nCount,     // string length, in characters
    LPRECT  lpRect,     // pointer to structure with formatting dimensions
    UINT    uFormat     // text-drawing flags
   );
  1. hDC:是設備內容代碼。

  2. lpString:是要顯示的字串位址。

  3. nCount:是字串長度,以位元組為單位。假如此數值為-1的話,那麼 DrawText 會自動的找到 NULL 作為字串的結尾。

  4. lpRect:是字串顯示在那一塊剪裁矩形區域,它指向一個 RECT 結構體位址。( 見第 17 章的說明 )

  5. uFormat:是顯示字串的格式,DT_SINGLELINE 是指把字串顯示在一行,它會忽略換行字元,至於其他的格式,請參考 Win32 Programmer's Reference。

其實這個程式,並沒有必要使用 GetClientRect,因為當呼叫 BeginPaint 時,系統就已經知道無效區的範圍,我們只需要重繪無效區就可以了。還有一點理由,假使每次都重繪整個工作區,那麼是很耗費資源的,這點在實際撰寫程式時應該時時記得。改變後的 DrawStr 副程式如下:

DrawStr proc    AddrSt:DWORD,hWin:DWORD
        LOCAL   hDevCont:HDC
        LOCAL   PS:PAINTSTRUCT

        invoke  BeginPaint,hWin,ADDR PS
        mov     hDevCont,eax
        invoke  DrawText,hDevCont,AddrSt,-1,ADDR PS.rcPaint,DT_SINGLELINE
        invoke  EndPaint,hWin,ADDR PS
        ret
DrawStr endp

用 Sofit-ICE 除錯

當我們實際撰寫程式時,除錯大概是不可避免的一環,小木偶將以這個 DRAWTXT.EXE 為例說明如何在 DRAWTXT.EXE 堛熊礸”蝳○]定中斷點。為何要找到視窗函式呢,這是因為主程式的寫法都是固定的,所以會產生錯誤的機會幾乎沒有,而視窗函式因每個程式不同,所以出錯的機會大,因此除錯的重點應該放在視窗函式。

我們所要關心的是訊息傳入視窗函式的時機以及視窗函式對訊息處理方式。要達到這個目的,一般是在視窗函式的進入點或是每個訊息進入點設定中斷點,這樣才能檢查訊息是否正確處理。底下說明用 Sofit-ICE 的 Symbol Loader 載入 DRAWTXT.EXE:

首先由系統左下角的『開始』按鈕,執行 Symbol Loader,選擇選單『File』→『Open Module…』,切換到 DRAWTXT.EXE 所在資料夾,開啟它。

第二,再選擇 Symbol Loader 選單上的『Module』→『Load』,按下『是』的按鈕,忽略找不到符號檔的警告,進入 Soft-ICE 除錯畫面。

第三,按下『F8』鍵單步追蹤到我們的第二行程式碼,然後輸入『u』指令,尋找 DRAWTXT.EXE 的視窗函式。那麼,要如何才能找到視窗函式進入點呢?您觀察一下原始程式中主程式的結尾是

        invoke  ExitProcess,eax

視窗函式的進入點就是下一行指令,應該不難找到。您可以輸入『u』指令去觀察,這個指令是 Sofit-ICE 的指令,意思是反組譯 ( unassemble ),用法和 DEBUG/SYMDEB 類似。

Sofit-ICE 指令:U ( 反組譯指令 )

其語法是

U [位址 L長度]

『位址』和『L長度』都可以省略,如果省略位址的話,那麼 Sofit-ICE 將從程式碼視窗最後一個指令的下一個指令開始反組譯,『L長度』如果省略的話,Sofit-ICE 將反組譯整個程式碼視窗。依照剛才所講的方法找 invoke ExitProcess,eax 下一行指令,就是視窗函式,應該會找到視窗函式在位址 004010E9 處:

004010E9  55                PUSH    EBP
004010EA  8BEC              MOV     EBP,ESP
004010EC  837D0C0F          CMP     DWORD PTR [EBP+0C],0F
004010F0  7510              JNZ     00401102
004010F2  B81A304000        MOV     EAX,0040301A  →處理 WM_PAINT
004010F7  FF7508            PUSH    DWORD PTR [EBP+08]
004010FA  50                PUSH    EAX
004010FB  E84D000000        CALL    0040114D
00401100  EB45              JMP     00401147
00401102  817D0C02010000    CMP     DWORD PTR [EBP+0C],00000102
00401109  7518              JNZ     00401123
0040110B  8B4510            MOV     EAX,[EBP+10]  →處理 WM_CHAR
0040110E  BA23304000        MOV     EDX,00403023
00401113  8802              MOV     [EDX],AL
00401115  6A01              PUSH    01
00401117  6A00              PUSH    00
00401119  FF7508            PUSH    DWORD PTR [EBP+08]
0040111C  E8A1000000        CALL    USER32!InvalidateRect
00401121  EB24              JMP     00401147
00401123  837D0C02          CMP     DWORD PTR [EBP+0C],02
00401127  7509              JNZ     00401132
00401129  6A00              PUSH    00            →處理WM_DESTROY
0040112B  E8A4000000        CALL    USER32!PostQuitMessage
00401130  EB15              JMP     00401147
00401132  FF7514            PUSH    DWORD PTR [EBP+14] →內定的處理訊息程式
00401135  FF7510            PUSH    DWORD PTR [EBP+10]
00401138  FF750C            PUSH    DWORD PTR [EBP+0C]
0040113B  FF7508            PUSH    DWORD PTR [EBP+08]
0040113E  E85B000000        CALL    USER32!DefWindowProcA
00401143  C9                LEAVE
00401144  C21000            RET     0010
00401147  33C0              XOR     EAX,EAX
00401149  C9                LEAVE
0040114A  C21000            RET     0010

找到視窗函式之後,下一步應該是在每一個處理訊息的地方設立中斷點。在 Sofit-ICE 有關中斷點的指令有一群,都是以『B』開始,包括設立中斷點、清除中斷點、列出中斷點等,而設定的中斷點又有因為執行到某一位址而停下來、存取某一位址而停止、存取某一輸出入埠而停止等等,停下來後 Soft-ICE 會顯示出當時後的暫存器、記憶體內容等情形以供程式設計師除錯的參考,可說功能強大。此處小木偶介紹 BPX。

Sofit-ICE 指令:BPX ( 設立執行中斷點 )

BPX 是指程式執行到某位址就會停下來並進入 Sofit-ICE 除錯畫面,其語法是

BPX [位址]

DRAWTXT.EXE 的視窗函式僅處理三個訊息,WM_CHAR、WM_PAINT、WM_DESTROY,而 WM_DESTROY 應該不需要除錯,假使小木偶在 004010F2 這個位址設立中斷點,在 Soft-ICE 的命令視窗下輸入

bpx 4010f2 [Enter]

此時您會看到在 Soft-ICE 程式碼視窗的

004010F2  B81A304000        MOV     EAX,0040301A

變成天藍色,表示中斷點已設立好了。接下來在 Soft-ICE 中輸入『g』指令,您會看見螢幕上退出 Soft-ICE 畫面,出現 DRAWTXT.EXE 視窗,但馬上又回到 Soft-ICE 畫面。這是因為 UpdateWindow 也會送出 WM_PAINT 訊息。

然後您再輸入『g』指令,又會退出 Soft-ICE,但這時並不會立即回到 Soft-ICE,但是當您按下任意鍵時,又會回到 Soft-ICE 畫面,這是因為在按下鍵之前,程式不會收到 WM_PAINT 訊息,所以不會中斷,但按下任意鍵,程式會自己送出一個 WM_PAINT 給自己,所以會進入 Soft-ICE 除錯畫面。

此後每次收到 WM_PAINT 訊息,都會進入 Soft-ICE 除錯畫面。例如,改變視窗大小、縮到最小後還原等,也會因為系統對 DRAWTXT.EXE 發出 WM_PAINT,所以也會進入 Soft-ICE 除錯畫面。

Sofit-ICE 指令:BC ( 清除中斷點 )

想要清除中斷點,可以執行 BC 指令。其語法是

BC 中斷點編號列表或『*』

每當我們建立一個中斷點時,Soft-ICE 會照建立先後給每個中斷點編號,此編號由 0 開始,當不需要此中斷點時,可以用『BC 中斷點編號』清除之。假如您用『BC *』,則會清除所有中斷點。例如,小木偶現在不要偵測處理 WM_PAINT 的程式,就下

bc 0

因為僅設立一個中斷點,所以該中斷點編號為零。假如已經建立的中斷點太多,可用 BL 查詢。

Sofit-ICE 指令:BL ( 列出中斷點資料 )

BL 是把所有的中斷點的編號中斷位址列出來。

Sofit-ICE 指令:G ( 執行指令 )

這個指令和 DEBUG/SYMDEB 相似,執行指令的意思,其語法是

G [=位址] [位址]

有『=』的位址是指從這個位址開始執行,假如此位址省略的話表示從現在的 EIP 位址開始執行。而後面沒有等於的位址是指執行到此處停止,如果省略的話就會到設定中斷點處停止,假如沒設定中斷點的話,就無法停止了。


到第二章回到首頁到第四章