第八章 簡單的視窗(二)

接著上一章,解說 SPLWND.ASM 剩下的部分。


解說:視窗函式(Window Procedure)

視窗函式是一種很特別的副程式。大部分的副程式,都是由我們自己撰寫的程式去呼叫;但是呼叫視窗函式的方式卻有點不同。我們自己撰寫的應用程式包含視窗函式,而此應用程式卻是先呼叫 Windows 的 USER32.DLL 中的一個函式,DispatchMessage API,再由它去呼叫視窗函式。為何要這麼麻煩,不直接呼叫視窗函式,請看第七章

像這種包含在某個應用程式的函式,是經由該應用程式去呼叫其他程式的副程式,而後再由此副程式呼叫原先應用程式的函式,我們就說該函式為呼回函式(callback function)。見右圖。

因為視窗函式是被 Windows 作業系統所呼叫的,因此有一定的格式,如下:

視窗函式名稱    PROC    hWnd:HWND, uMsg:UINT, wParam:WPARAM, lParam:LPARAM
.switch uMsg
  .case 訊息識別碼1
                處理訊息識別碼1的程式碼
  
  .case 訊息識別碼2
                處理訊息識別碼2的程式碼
                ⁝
  .default
                invoke  DefWindowProc,hWnd,uMsg,wParam,lParam
                ret
.endsw

                xor     rax,rax
                ret
視窗函式名稱    ENDP

視窗函式名稱可以任意取,在組合語言中,這只不過是個位址。

視窗函式有四個參數,而且不多不少正好四個,這四個參數和訊息迴圈中的 MSG 結構體前四的欄位相同。說明如下:

視窗函式的目的是該根據訊息識別碼處理訊息,而視窗所收到的訊息五花八門,因此應該會有一大段程式碼是用比較指令來比對是否為什麼訊息,如果是就跳至某處執行,意即處理該訊息;如果不是就再次比對是否為另一個訊息。但如果真的這樣寫,閱讀性很差且難以理解。幸好組合語言的先進們模仿高階語言已寫好一個巨集,「.switch/.case/.default/.endsw」,專門做這種事情。

.switch/.case/.default/.endsw 巨集

「.switch/.case/.default/.endsw」巨集是諸位先進所撰寫的,非 ML64 的保留字,故有區分大小寫(亦即都必須照底下的大小寫來寫,否則會發生錯誤)。它的語法是:

.switch 運算元
  .case 常數一
        程式片段一
  
  .case 常數二
        程式片段二
        ⁝
  .default
        程式片段三
.endsw

電腦會根據 .sitch 後面接著的運算元判斷執行哪一段程式:如果運算元等於常數一,就執行程式片段一,待執行完程式片段一就跳至 .endsw 之後的指令;如果運算元等於常數二,就執行程式片段二……;如果運算元都不是所列的常數,就執行 .default 後的程式片段三,待執行完程式片段三就跳至 .endsw 之後的指令。

把「.switch/.case/.default/.endsw」巨集應用在 SPLWND 的視窗函式上,就變成:

WndProc         PROC    hWnd:HWND, uMsg:UINT, wParam:WPARAM, lParam:LPARAM
.switch uMsg
  .case WM_PAINT
            ┌── invoke  BeginPaint,hWnd,ADDR ps
               invoke  GetClientRect,hWnd,ADDR rect
              invoke  DrawText,ps.hdc,ADDR sYouPress,SIZEOF sYouPress,ADDR rect,\
                       DT_SINGLELINE or DT_CENTER or DT_VCENTER
            └── invoke  EndPaint,hWnd,ADDR ps

  .case WM_CHAR
            ┌── mov     rax,wParam
              mov     sYouPress[8],al
            └── invoke  InvalidateRect,hWnd,0,1

  .case WM_DESTROY
           ── invoke  PostQuitMessage,0
  .default
           ── invoke  DefWindowProc,hWnd,uMsg,wParam,lParam
            └── ret
.endsw

           ── xor     rax,rax
            └── ret
WndProc         ENDP

上面的視窗函式中,如果 uMsg 等於 WM_PAINT,會執行處的程式;如果 uMsg 等於 WM_CHAR,會執行處的程式;如果 uMsg 等於 WM_DESTROY,會執行處的程式;如果 uMsg 都不是上面三種訊息識別碼中的任一種,就會執行處的程式。前三種情形,不管是哪一種,處理完該訊息後,就會到處,將零當做回傳值,返回主程式,也就是 USER32.DLL 裡面的 DispatchMessage API。

Windows 作業系統傳給應用程式的訊息成千上萬,這麼多的訊息中大部分都有一定的處理方式或是程式不感興趣的,例如移動視窗、改變視窗大小等等都有一定的處理方式。其他程式處理這些訊息,也是使用一樣的方式。如果每個程式都自行處理,程式碼會變得很大而且都一樣既麻煩又不經濟。因此都交由一個 API 負責處理,那就是 DefWindowProc。視窗函式只需要針對感興趣的訊息加以處理即可。

在 SPLWND 中,我們要把使用者按下的鍵顯示在視窗中,因此要處理 WM_PAINT、WM_CHAR 這兩則訊息,看它們的名稱,大概也能猜出分別代表繪製視窗畫面與接收字元的訊息。另外還有一條訊息是 WM_DESTROY,是銷毀視窗的訊息。這些經由視窗函式處理過的訊息,必須把零當做回傳值,傳回給作業系統,告知這訊息已處理完畢。

最後整理如下:

  1. 應用程式感興趣的訊息:必須以「.case WM_XXX」檢查並放在 .switch/.endsw 裡面的 .case 區塊裡。該訊息處理完畢,在 .switch/.endsw 區塊外把零當成回傳值傳給 Windows,Windows 接收到回傳值 0,就知道該訊息已經處理完畢。
  2. 應用程式不感興趣的訊息:必須放在「.default」之後的區塊,用 DefWindowProc 處理,等它處理完畢,傳回 Windows 的回傳值就是 DefWindowProc 的回傳值。

DefWindowProc API

在視窗函式中,不感興趣的訊息都交給 DefWindowProc 處理。經過 DefWindowProc 處理後的訊息,最後也要返回系統,這時候的回傳值只要保持 DefWindowProc 的回傳值,讓其原封不動傳給作業系統即可。DefWindowProc 的用法是:

invoke  DefWindowProc,\
        hWnd,\          ; handle to window
        Msg,\           ; message identifier
        wParam,\        ; first message parameter
        lParam          ; second message parameter

很明顯,DefWindowProc 的四個參數其實就是 Windows 系統給視窗函式的四個參數,一模一樣。而其回傳值則與訊息識別碼有關。

這裡小木偶舉兩個例子說明 DefWindowProc 如何處理訊息:①DefWindowProc 處理 WM_CLOSE 訊息,就只是呼叫 DestroyWindow 銷毀視窗;②DefWindowProc 處理 WM_PAINT 訊息,就只是先後呼叫 BeginPaint 與 EndPaint(見後面)。

兩個與結束程式有關的 API:PostQuitMessage 與 DestroyWindow

PostQuitMessage 會把 WM_QUIT 訊息插入自己的程式訊息佇列中,等訊息迴圈中的 GetMessage 提取。一旦 GetMessage 取得的是 WM_QUIT,GetMessage 的回傳值為零,會立即退出訊息迴圈,執行 ExitProcess 結束程式。PostQuitMessage 的用法是

invoke  PostQuitMessage,\
        nExitCode       ; exit code

nExitCode 是退出碼,此數值是 WM_QUIT 的 wParam 參數,也是要傳給 Windows 的退出碼,一般為零。PostQuitMessage 沒有回傳值。

DestroyWindow 是用來銷毀視窗的 API,其用法是

invoke  DestroyWindow,\
        hWnd            ; handle to window to destroy

hWnd 就是要銷毀的視窗。除了銷毀視窗外,DestroyWindow 還會銷毀與該視窗相關的物件,例如選單、計數器、子視窗等等,另外 DestroyWindow 還會對要銷毀的視窗發出 WM_DESTROY 和 WM_NCDESTROY 訊息。如果要銷毀的視窗擁有子視窗,那麼 DestroyWindow 會優先銷毀所有子視窗及子視窗的相關物件,然後再銷毀父視窗及父視窗相關物件。如果成功銷毀視窗,回傳值為非零;如果失敗,回傳值為零。

三則訊息:WM_CLOSE、WM_DESTROY、WM_QUIT

底下來談談結束程式時發生的事情,這些事情和三則訊息:WM_CLOSE、WM_DESTROY、WM_QUIT 有關,初學者很容易搞混,因此小木偶在這裡說說他們之間的區別。

使用者要結束程式,通常有三種操作方式:①按下視窗標題欄右邊的「關閉」按鈕。②或是按視窗標題欄左邊圖示,然後選擇「系統選單」中的關閉選項。③按下「Alt-F4」快捷鍵。這三種操作,都會使 Windows 把 WM_CLOSE 訊息放進該視窗的程式訊息佇列中。視窗函式取得此訊息後,可以選擇不處理此訊息,也可以選擇處理。

如果選擇不處理 WM_CLOSE 訊息,那麼就會由 DefWindowProc 處理,DefWindowProc 處理的方式就是呼叫 DestroyWindow 銷毀視窗。DestroyWindow 除了銷毀視窗外,還會對視窗發出 WM_DESTROY 訊息。因為 DefWindowProc 不會處理 WM_DESTROY,所以視窗函式必須處理 WM_DESTROY,在處理 WM_DESTROY 訊息時呼叫 PostQuitMessage 以退出訊息迴圈。

如果選擇要處理 WM_COLSE 訊息,那麼程式可以在這裡提醒使用者是否真的要退出程式,還是有其他原因而反悔不退出,比如尚未存檔。如果使用者選擇要退出程式,那可以在這時候做一些結束工作,像釋放記憶體、關閉檔案,最後呼叫 DestroyWindow 銷毀視窗。DestroyWindow 還會對視窗發出 WM_DESTROY 訊息,視窗函式必須處理 WM_DESTORY,於此呼叫 PostQuitMessage 摧毀訊息迴圈。如果使用者選擇還不要退出,那就使回傳值為零傳給 Windows 並返回 Windows,就好像什麼事都沒發生一樣。

從上面來看,結束程式似乎有兩種寫法:一是使用者還是有機會反悔,讓程式繼續工作;另一種是不讓使用者反悔,直接結束。這兩種結束程式的寫法如下表:

使用者還是有機會反悔使用者沒有機會反悔
WndProc PROC    hWnd:HWND,uMsg:UINT,wParam:WPARAM,lParam:LPARAM
.switch uMsg
  ⁝
  .case WM_CLOSE
        invoke  MessageBox,hWnd,"您要退出嗎?","詢問",MB_YESNO
        cmp     rax,IDNO
        je      exit
        invoke  DestroyWindow,hWnd

  .case WM_DESTROY
        invoke  PostQuitMessage,0

  .default
        invoke  DefWindowProc,hWnd,uMsg,wParam,lParam
        ret
.endsw

exit:   xor     rax,rax
        ret
WndProc ENDP
WndProc PROC    hWnd:HWND,uMsg:UINT,wParam:WPARAM,lParam:LPARAM
.switch uMsg
  ⁝
  .case WM_DESTROY
        invoke  PostQuitMessage,0

  .default
        invoke  DefWindowProc,hWnd,uMsg,wParam,lParam
        ret
.endsw

        xor     rax,rax
        ret
WndProc ENDP

這兩種寫法不管哪一種,視窗函式似乎都要處理 WM_DESTROY 訊息,並於處理此訊息中呼叫 PostQuitMessage。PostQuitMessage 會發出 WM_QUIT 訊息,將其安插在自己的程式訊息佇列中,等 GetMessage 提取。當 GetMessage 提取的訊息是 WM_QUIT 時,回傳值是零,就會退出訊息迴圈,然後程式就結束。假如不處理 WM_DESTROY 訊息,視窗還是會從螢幕上消失時,但僅僅是銷毀視窗而已,訊息迴圈仍存在於記憶體中,還在消耗著電腦資源,也就是說程式並未完全從記憶體消除。

如果為了方便,還有另一種結束程式的寫法。其實在處理 WM_CLOSE 訊息時,也可以在呼叫 DestroyWindow 後,直接呼叫 PostQuitMessage,這樣也能發出 WM_QUIT,而不必處理 WM_DESTROY 訊息。這種結束程式的寫法,其程式碼跟上表左欄幾乎一樣,差別就只在刪除「.case WM_DESTROY」這一行。

把上面的觀念應用到 SPLWND 上,發現 SPLWND 只處理了 WM_DESTROY 訊息,所以使用者結束 SPLWND 的流程是:
 ①使用者按下視窗右上方的「關閉按鈕」或選擇視窗左上方的系統選單選擇「關閉」選項;
 ②視窗函式收到 WM_CLOSE,交給 DefWindowProc 處理;
 ③DefWindowProc 呼叫 DestoryWindow 銷毀視窗,並發出 WM_DESTORY;
 ④視窗函式收到 WM_DESTROY 訊息,在處理此訊息時呼叫 PostQuitMessage 發出 WM_QUIT 訊息;
 ⑤訊息迴圈取得 WM_QUIT 後退出迴圈,整個程式結束。

WM_CHAR

當使用者敲擊鍵盤上的按鍵往下時,會發出 WM_KEYDOWN 訊息,而 TranslateMessage 會將字元按鍵例如「A 鍵」、「B 鍵」等等,而不是像「F1 鍵」這種〝特殊鍵〞,轉換成 WM_CHAR 訊息,以方便程式使用。轉換後的 WM_CHAR 訊息中,wParam 的最低位元組是字元的 ASCII 碼,其他位元均為 0。lParam 是額外的一些資料,如下表:

位元說明
0∼15重複次數。當使用者按住鍵盤上的鍵不放時,相當於重複按該鍵很多次,按住時間越久,重複次數越多。
16∼23掃描碼。
24如果為一,表示按下擴充鍵;為零,表示沒按擴充鍵。擴充鍵是指 101 或 102 鍵盤右邊的數字鍵和其左邊的方向鍵等按鍵。
25∼28未使用。
29如果為一,表示按下 Alt 鍵;為零,表示沒按 Alt 鍵。
30如果為一,表示先前按鍵還未鬆開,就按下個按鍵;否則先前的按鍵已經鬆開。
31如果為一,表示按鍵還未鬆開;如果為零,表示按鍵已鬆開。

SPLWND.ASM 的第 27 行從 WM_CHAR 訊息中的 wParam 參數中取得使用者按鍵所代表的字元,此字元是 ASCII 碼,只有一個位元組長,因此儲存在 AL 暫存器中。

存取字串中的某個字元

第 28 行把 AL 內的字元存入 sYouPress 字串中。sYouPress 是一個字串的,經組譯器組譯後為字串起始的所在位址,再加上八並且以中括號括起來,表示是在 sTouPress 字串位址後的第八個位元組。所以

                mov     [sYouPress+8],al

的意思就是:把 AL 暫存器之值搬移到 sYouPress 字串起始位址之後第八個位元組的記憶體內(參考註一)。

此外要注意的是,如果沒有特別聲明,在組合語言中組譯,中文字是以「BIG 5」的編碼方式組譯,每個中文字需要兩個位元組表示,而英文字母、阿拉伯數字等都還是以一個位元組表示。所以使用者所按下的鍵,其代表的字元要放在第八個位元組(這第幾個位元組是從零開始算起)。


圖形裝置介面(GDI)與裝置內容(DC)

想要在視窗內顯示一段文字,首先要瞭解圖形裝置介面(GDI)與裝置內容(DC)。圖形裝置介面的英文原名是 graphics device interface,縮寫為 GDI,它是 Windows 作業系統中很重要不可或缺的部分。它主要負責三個部分:

首先,GDI 實現設計應用程式時,能做到與顯示卡、印表機、繪圖機等裝置無關的設計方式。當程式設計師設計應用程式時,不必考慮使用者的顯示卡、印表機、繪圖機等設備是哪一家廠商的何種晶片。雖然在市面上可以買得到的周邊設備有千百種,不論哪一種,使用者只要安裝好驅動程式,對應用程式而言,使用方式都相同。當然,這些廠商在開發各種周邊設備的驅動程式時,必須符合 GDI 的規範。

第二,Windows 系統本身也靠 GDI 來完成本身繪製圖形、文字的作業。例如桌面視窗管理員(Desktop Window Manager,縮寫是 DWM,)、繪製視窗的外框、標題欄等等,都靠 GDI 完成。

最後,GDI 提供一套與繪製圖形及文字有關的 API,讓應用程式呼叫使用。當應用程式要對顯示卡(或螢幕)、印表機、繪圖機等設備輸出圖形或文字時,都可以呼叫這些 API,這是個標準化的過程。應用程式無法再像以前 DOS 時代一樣,直接存取視訊記憶體(註二)來改變螢幕上的文字或圖形,而必須呼叫這些 API 才能達到目的,這些 API 都包含在「GDI32.DLL」動態連結程式庫裡(註三)。

GDI32.DLL 裡面的 API 又是如何在視窗內繪製文字與圖形的呢?這還牽涉到裝置內容。裝置內容(device context,英文縮寫為 DC)是一組資料結構,當 Windows 作業系統建立一個視窗,同時也會為這個視窗建立一個裝置內容,把視窗與輸出設備(例如螢幕、印表機等)連接起來。當程式要在視窗中繪製圖形、文字時,要先輸出至裝置內容,而後裝置內容再把資料輸往螢幕、印表機等輸出設備。

我們可以把裝置內容想像成裝有一組繪圖工具的工具箱,這個工具箱裡包含了畫紙、顏料、各式各樣的畫筆等等繪圖工具,甚至還能自己製作一個。而 GDI32.DLL 內的 API 則是操縱這些工具的手,程式可以藉由這些 API 操縱前述的繪圖工具,在視窗中任意揮灑。舉個例子,我們可以呼叫 SelectObject API 來選用某種顏色的畫筆,然後呼叫 DrawText API 以該畫筆繪製文字,這樣就會在視窗中見到,Windows 用你所選擇的顏色寫出來的文字。

有效區域和無效區域

到此,我們大致瞭解,可以呼叫 GDI 內的 API 幫我們在裝置內容上繪出圖形;但還有一件事要考慮,要在什麼時候繪製圖形呢?是應用程式一開始執行的時候嗎?還是其他時候呢?從操作電腦 Windows 系統的經驗,螢幕上可能不只一個視窗,這些視窗會相互影響,所以每個視窗的內容並非固定不變。例如:

  1. 如果把一個視窗放大,就會多出許多空白的區域,必須要把多出來的區域繪製出來。
  2. 如果把螢幕上的視窗縮小至螢幕最下方的工作列(task bar),那麼就要把原來在背後的視窗之內容繪製出來。
  3. 使用者把捲軸往下移動,那麼就要把在底下原本沒顯示的內容繪製出來。
  4. 如果把某個視窗疊在另一個視窗上,那麼後者就會缺了一角,這一角被遮住了,是重疊的部分。當前者移走之後,底下視窗被遮住的內容要重新繪製。例如下圖(註四),「ACD See32」在「記事本」上方,紅色框住的區域是重疊的部分;把「ACD See32」移走之後,「記事本」被遮住的文字就要重新繪製(下圖藍色與綠色框內,也就是白色的部分)。
  5. 使用者移動滑鼠游標時,在游標前後,會分別產生被遮住的區域與需要重新繪製的區域。
  6. ……

像上面幾個例子,需要重新繪製的區域稱為「無效矩形(invalid rectangles)」,也稱為無效區域,這會被 Windows 檢測並記錄起來。不須重繪的區域,就稱為有效區域(valid rectangles)。Windows 其實很聰明,它會自動將無效區域繪製出來,例如上面第 5. 點提到滑鼠游標移過的區域,和上圖視窗的邊框(綠色框住的區域)。

除了像上面的例子是使用者的操作,可以產生無效區域之外,應用程式也可以自己產生無效區域,呼叫 InvalidateRect API(註五)就能在所指定的視窗產生無效區域。

WM_PAINT 訊息

以上只是舉出幾個例子,可以說明視窗中的內容是會變動的,因此並不是僅僅在程式一開始繪製視窗內容就行了,某些時候也要。那麼如果是這樣,應用程式要什麼時候繪製視窗內容呢?很簡單,當 Windows 偵測到某個視窗內有無效區域時,會發出 WM_PAINT 訊息會通知應用程式該繪製視窗內容了,所以程式只要在視窗函式中處理 WM_PAINT 訊息即可。

WM_PAINT 是個優先權低的訊息,只有當程式訊息佇列中已經沒有其他訊息了,Windows 才會生成 WM_PAINT,並將它移到程式訊息佇列中。有個例外,就是如果程式訊息佇列中已經有一則 WM_PAINT,同時系統又偵測到新的無效區域,那麼 Windows 會計算出無效區域的聯集,而合併成一則新的 WM_PAINT。結論就是,當應用程式沒有其他事可做時,才會處理 WM_PAINT 訊息,同時讓 WM_PAINT 不會重複繪製。為何會這樣這其實很好理解,因為①一般的應用程式都應該是先把要顯示的資料計算好之後,才顯示在工作區(client area,也稱顯示區、客戶區,是在視窗標題欄底下與外框之間、顯示視窗內容的區域,見第四章的圖片);②要繪製工作區,需要花費較多時間,因此等無事可做時再進行。

Windows 作業系統會為每個視窗建立「繪圖資訊結構(paint information structure)」,保存無效區域及其他資料,Windows 因此能知道某個視窗是否有無效區域,如果有就會對該視窗發出 WM_PAINT。也因為如此,在處理完 WM_PAINT 必須把繪圖資訊結構裡的無效區域變成有效區域,否則 Windows 會一直偵測到無效區域,便會一直發送 WM_PAINT,引起程式崩潰。

雖然 Windows 知道無效區域在哪裡,但是卻不是通過 WM_PAINT 訊息傳給應用程式,WM_PAINT 訊息中的 wParam 與 lParam 都不使用,都是零。應用程式得自己取得無效區域,一般是呼叫 BeginPaint API。

視窗函式如何處理 WM_PAINT 訊息

當視窗函式收到 WM_PAINT 訊息時,要如何處理呢?一般是先呼叫 BeginPaint,根據 BeginPaint 傳回來的資料,在「程式片段」中處理完要在工作區繪製的圖形後,再呼叫 EndPaint 作為結尾,最後把回傳值設為 0 傳回 Windows 系統,代表已處理完 WM_PAINT 訊息。BeginPaint 與 EndPaint(註六)是成對出現的,不能讓它們形單影隻。請參考下面程式。

WndProc PROC    hWnd:HWND, uMsg:UINT, wParam:WPARAM, lParam:LPARAM
        LOCAL   ps:PAINTSTRUCT
switch  uMsg
  .case WM_PAINT
        invoke  BeginPaint,hWnd,ADDR ps
        程式片段
        invoke  EndPaint,hWnd,ADDR ps

  .case WM_XXX
        ⁝
  .default
        invoke  DefWindowProc,hWnd,uMsg,wParam,lParam
        ret
.endsw
        xor     rax,rax
        ret
WndProc ENDP

表面上看起來,上面的視窗函式似乎沒有把無效區域設為有效,但是有呼叫 BeginPaint,BeginPaint 隱含著這個功能。如果視窗函式處理 WM_PAINT 訊息,但卻沒有呼叫 BeginPaint 或是能把無效區域設為有效的函式,這樣會引起程式崩潰。

如果程式不打算在工作區繪製圖形或文字,那就不必處理 WM_PAINT 訊息(這不是 SPLWND.ASM 的設計初衷),這樣的話,WM_PAINT 訊息就會交給 DefWindowProc 處理,請參考下面程式:

WndProc PROC    hWnd:HWND, uMsg:UINT, wParam:WPARAM, lParam:LPARAM
switch  uMsg
  .case WM_XXX
        ⁝
  .default
        invoke  DefWindowProc,hWnd,uMsg,wParam,lParam
        ret
.endsw
        xor     rax,rax
        ret
WndProc ENDP

如同上一章所說的,在視窗函式中不感興趣的訊息都會交給 DefWindowProc 去處理。事實上 DefWindowProc 處理 WM_PAINT 的方式就只是呼叫 BeginPaint 與 EndPaint 而已,其餘什麼事都沒做。下面的程式碼就是想像中 DefWindowProc 處理 WM_PAINT 的過程:

  .case WM_PAINT
        invoke  BeginPaint,hWnd,ADDR ps
        invoke  EndPaint,hWnd,ADDR ps
        xor     rax,rax
        ret

等處理完後就把 DefWindowProc 的回傳值原封不動地傳回 Windows 系統,表示經由 DefWindowProc 處理過了。

SPLWND.ASM 是怎麼印出字串的?

還記得小木偶解釋了這麼多,為的是什麼嗎?其實只是要解釋 SPLWND 程式怎麼把字串,「你按下了╳鍵。」,印在工作區的正中央。而字串中的「了」與「鍵」之間的「╳」代表使用者按下了什麼鍵。例如當使用者按了「a 鍵」,字串就會變成「你按下了a鍵。」;當使用者按下 Shift+b,字串變成「你按下了B鍵。」。

照著這樣的思緒,就能想像得到,當使用者按下鍵盤上的按鍵時,必定會觸發 SPLWND 收到 WM_PAINT 訊息,要 SPLWND 重新繪製上面的字串,而這個字串會包含按鍵所代表的字元。前一章提過的 WM_CHAR 就可以做到獲得按鍵所代表的字元。

我們看看前一章的 SPLWND.ASM 印出字串的程式片段為處理 WM_PAINT 與 WM_CHAR 兩則訊息:

.switch uMsg
  .case WM_PAINT
                invoke  BeginPaint,hWnd,ADDR ps
                invoke  GetClientRect,hWnd,ADDR rect
                invoke  DrawText,ps.hdc,ADDR sYouPress,SIZEOF sYouPress,ADDR rect,\
                        DT_SINGLELINE or DT_CENTER or DT_VCENTER
                invoke  EndPaint,hWnd,ADDR ps

  .case WM_CHAR
                mov     rax,wParam
                mov     [sYouPress+8],al
                invoke  InvalidateRect,hWnd,0,1
  
  .case WM_DESTROY
                ⁝

當使用者按下鍵盤上的一個按鍵後,TranslateMessage 會將這個按鍵的 WM_KEYDOWN、WM_KEYUP 訊息轉換成 WM_CHAR 訊息,再把 WM_CHAR 插入 SPLWND 程式訊息佇列中,等待 SPLWND 的訊息迴圈讀取此訊息。

SPLWND 讀取並收到 WM_CHAR 後,用「mov [sYouPress+8],al」把使用者按鍵所代表的字元存入字串中,接著呼叫 InvalidateRect 強制讓整個工作區都變成無效區域。系統便會察覺 SPLWND 視窗有無效區域,待其空閒時發出 WM_PAINT 給 SPLWND。

當 SPLWND 收到 WM_PAINT 訊息後,首先呼叫 BeginPaint;要結束時,呼叫 EndPaint。中間呼叫 GetClientRect、DrawText 兩個 API,就是印出字串。

說到這兒,不知道你有沒有發現,在撰寫要把字串顯示在工作區的程式碼很是奇特、很不直覺,被拆成兩處。一處是處理 WM_CHAR,決定何時要顯示字串;另一處是處理 WM_PAINT,真槍實彈的顯示字串。的確如此,對於熟悉設計 DOS 程式或控制臺程式的人,會不習慣這種風格的程式設計。

回歸正傳,繼續說真槍實彈地顯示字串。你可以想像,視窗的工作區說大不大,但說小也不小,DrawText 會把字串印在哪兒呢?一定會有規範。事實上,必須先設置一塊稱為剪裁矩形的矩形區域,DrawText 會把字串印在這個矩形內。而呼叫 GetClientRect 的目的,就是把整個工作區都當成剪裁矩形(矩形就是長方形)。底下先說明 DrawText、GetClientRect 兩個 API。

DrawText

DrawText 的參數是

invoke  DrawText,\
        hDC,\           ; handle to device context
        lpString,\      ; pointer to string to draw
        nCount,\        ; string length,\in characters
        lpRect,\        ; pointer to structure with formatting dimensions
        uFormat         ; text-drawing flags

底下是這些參數的說明:

預設的情形下,DrawText 會把字串顯示在剪裁矩形的第一行,如果字串含有換行字元(linefeed)及歸位字元(carriage return),那麼就會換到下一行繼續顯示,如果字串太長超出剪裁矩形範圍,字串末端會被截斷不顯示。我們可以用 DT_LEFT、DT_RIGHT、DT_CENTER 指定向左、右、中間對齊。當 DrawText 換行時,每行高度是 Height,不包含 ExternalLeading(參考右圖),如果要使行距包含外部間距(ExternalLeading),可以使用 DT_EXTERNALLEADING。

uFormat 參數可以用 DT_SINGLELINE 顯示一行字串,即使字串包含換行或歸位字元,DrawText 也會把這兩個字元當做一般文字顯示在螢幕上。在有 DT_SINGLELINE 的情形下,也可以搭配 DT_TOP、DT_BOTTOM、DT_VCENTER 來使字串位於剪裁矩形的頂端、底端或鉛垂方向的中間,內定值是 DT_TOP。如果 uFormat 要搭配兩種或兩種以上的格式,以 or 連接。例如 SPLWND 中

DT_SINGLELINE or DT_CENTER or DT_VCENTER

表示 DrawText 印出的文字只有一行,在剪裁矩形的中央位置。

為何格式之間以「or」連接呢?原因是這些格式其實都只是一個個位元,例如 DT_SINGLELINE、DT_CENTER、DT_VCENTER 之值分別是 20H、1、4,換算成二進位是「10 0000」、「00 0001」、「00 0100」。很明顯可以看出來,每一種格式都只佔一個位元,所以如果以多種格式印出字串,「or」運算可以達到此目的。但這裡的「or」並不是 CPU 指令,而是叫 ML64.EXE 運算的一種運算子。

不管是多行或單行字串,如果剪裁矩形的寬度太小或字串太長,都會使字串右邊被截掉,這時 uFormat 可使用 DT_NOCLIP,使字串不受剪裁矩形的限制而可以超出剪裁矩形;因為 DT_NOCLIP 不須計算剪裁矩形的範圍,所以顯示速度當然會加快。但如果想要在剪裁矩形的範圍內,把超出的部份一到下一行顯示,可以使用 DT_WORDBREAK。

DrawText 還會把「&」字元視為前置字元,意思是在此字元之後的字加上底線,而不會顯示「&」;如果要顯示「&」字元得使用「&&」。於是有底下三種用法:

  1. DT_NOPREFIX:忽略前置字元,把「&」字元當成普通字元看待。例如有一個字串是「A&BC&&D」,DrawText 會顯示為「ABC&D」;但是如果加上 DT_NOPREFIX,那麼 DrawText 會顯示為「A&BC&&D」。
  2. DT_HIDEPREFIX:隱藏前置字元,會把「A&BC&&D」顯示為「ABC&D」。
  3. DT_PREFIXONLY:只會在接著「&」後的字元畫出底線,而不會顯示出其他字元,例如會把「A&BC&&D」顯示為「    」。

如果 uFormat 使用了 DT_CALCRECT,那麼 DrawText 並不會把字串畫出來,而是計算所需要的剪裁矩形最小的大小,然後填入 lpRect 所指的 RECT 結構體內,這個功能可以在不知剪裁矩形的大小時使用,先使用 DT_CALCRECT 為參數呼叫 DrawText 計算好剪裁矩形之後,再呼叫一次 DrawText 真正的繪出文字來,這算很常用的功能。假如這個字串是一行的文字,那麼程式設計師應先設定好剪裁矩形的左上角座標,亦即要先填好 top 和 left 欄位,然後以 DT_CALCRECT or DT_SINGLELINE 呼叫 DrawText,DrawText 返回時會把剪裁矩形的 bottom 和 right 填好。假如這個字串是多行文字,那麼程式設計師除了 top 和 left 要先填好外,還得指定 right,作為每一列的寬度,這樣 DrawText 才能決定此字串佔有幾列文字,進而決定剪裁矩形的高度,而傳回的 bottom 數值是剪裁矩形高度加上原先設定的 top。

GetClientRect

GetClientRect 可以獲得整個工作區大小,其原型是

invoke  GetClientRect,\
        hWnd,           ; handle of window
        lpRect          ; address of structure for client coordinates

沒有懸念,hWnd 是要獲得工作區的視窗。lpRect 是結構體 RECT 的位址,在呼叫 GetClientRect 成功後,GetClientRect 會在這個 RECT 結構體內填上工作區的左上角與右下角座標,同時回傳值為 TRUE。若 GetClientRect 執行失敗,回傳值為 FALSE。

要注意的是,GetClientRect 成功執行後,其座標是相對於工作區左上角的座標,因此 RECT 的前兩個欄位 left、top 均為零;而 right、bottom 分別代表工作區的寬與高。


註釋

註一:

如果你用 x64dbg 載入 SPLWND.EXE,然後找到

                mov     [sYouPress+8],al

所在的位址,13FB63170,如下圖紅框所示。為了說明這一行指令,有兩件事要先準備:①修改資料顯示區(也就是最大的藍色框)的顯示位址變為「13FB63170」;②把橙色框所指的編碼方式由「ASCII」改成「Big5」:

①:把滑鼠游標移到「資料顯示區」內點擊一次,接下來有兩種做法:
⑴直接按 Ctrl-G 快捷鍵,在彈出的對話盒中輸入「13FB63170」。
⑵點一次滑鼠右鍵→前往(G)→表述式(E)→在彈出的對話盒中輸入「13FB63170」(如紫色箭頭所示)。
②:把滑鼠游標移到「資料顯示區」內點擊一次→點一次滑鼠右鍵→十六進位(H)→Big5(如藍色箭頭所示)。

完成上面兩步驟,再捲動捲軸至適當位置,x64dbg 視窗變成下圖:從上圖可以得知,組譯器會把「sYouPress」字串位址計算出來,就是 13FB63168,再加上 8 之後是 13FB63170,而在這數值外加一對中括號,[13FB63170],代表要存取這個位址的內容。這種方式有點像是第一章提過的直接定址,只是這一章的變數是字串變數,字串可以很長也可以很短,所佔用的位元組個數不固定,視情況而定。如果要存取字串中的某個字元,就可以用 SPLWND.ASM 中的第 28 行:

[字串名稱+n]

也可以用

字串名稱[n]

這兩種方式都是一樣的。第二種方法表現的方式是 C 語言的形式。C 語言中把字串當成陣列(array),有關組合語言陣列的介紹有許多內容值得介紹,請看「DOS 組合語言第十九章」。

註二:

視訊記憶體也叫顯示記憶體,是用來儲存顯示晶片處理過或者即將讀取的資料。在 DOS 時代,應用程式能夠把資料直接填入視訊記憶體內,就能在螢幕上顯示出來,請參閱「DOS 組合語言第十六章」。當然在 Windows 時代,是無法這麼做,必須透過 Windows API 才能在螢幕上顯示資料。

現代(民國 112 年,西元 2023 年),許多電腦已是電競等級,配備 3D 顯示卡,能夠玩像惡靈古堡 2、3 重製版、古墓奇兵三部曲等三維空間的遊戲,其所配備的視訊記憶體通常都是要 6∼12GB,才能儲存這麼大的素材,表現才夠流暢。

註三:

撰寫 Windows 程式有許多種方法:①SDK、②MFC、③.NET Framework、④Universal Windows Platform……。其中 SDK(software development kit)是直接呼叫 Windows API,其他方式則使用了程式框架和工具,通過封裝和簡化 Windows API,節省了應用程式的開發過程。兩者各有優缺點,但是如果用組合語言開發 Windows 程式,就只能使用 SDK 方法。

在組合語言中,所呼叫 Windows API 中,最常用的大多包含在以下三個動態連結程式庫中:

  1. KERNEL32.DLL:和記憶體管理、資料的輸入輸出操作和中斷處理等有關,例如配置記憶體等呼叫。
  2. GDI32.DLL:和繪製圖形與文字有關,例如顯示文字、畫出點線圓等呼叫。
  3. USER32.DLL:和使用者介面有關,例如建立視窗、發送訊息等呼叫。

利用 Windows SDK 開發程式,主要就是以呼叫這三個動態連結程式庫,以達成各種目的。

GDI32.DLL 裡面,專門用來繪製文字與圖形。例如文字、直線或曲線的繪製,調色盤的控制等等;但是,它不能用來直接繪製一些使用者介面,像視窗、清單等等,這些工作由位於 user32.dll 中的 API 完成。當 Windows 發送 WM_PAINT 給應用程式時,應用程式就應該要著手繪製工作區(client area,也翻譯成客戶區或工作區,是指視窗標題欄底下與外框之間的區域,顯示視窗內容的地方,見第七章的圖片)。

註四:圖片來源

此圖片為東森新聞民國 111 年 12 月 2 日在 Youtube 上發佈的新聞。在 Youtube 上也有官方正式版,請按這裡欣賞。事情是在同年 11 月 13 日,愛樂匯輕音樂團上海團隊宮崎駿久石讓音樂會的現場,在加演環節時,隊長也是單簧管樂手李政翰問大家想聽什麼曲子?有些人說「起風了」,但有一位叫書洋的小朋友大喊「孤勇者」,引起現場一片笑聲。小木偶不曉得為何大家會笑,可能是孤勇者不是宮崎駿、久石讓動畫上的曲目。

後來,樂團演奏的仍然是事先準備好的曲子菊次郎的夏天,但演奏一段不長的時間後,隊長突然開始演奏「孤勇者」中的「愛你孤身走暗巷」兩次。此訊號一出,大、小提琴很有默契的響應,後來其他樂手加入,結果樂曲就從夏天變成孤勇者。小朋友純真的大聲說出自己的願望,而樂團默默的幫助他實現,不會因為是個小孩,就忽略他。這樣尊重弱勢的聲音,是社會進步的表現,令人感動。

小木偶在撰寫這一章時,一邊聽著不同演唱者唱的「孤勇者」,一邊寫下這一章。製作這種沒人讚賞,也不知道有沒有人閱讀的網頁,內心很是孤寂也很糾結。這孤勇者動人的旋律與感人的歌詞,使我獨自行走在這條道路上,有了一絲慰藉。

註五:InvalidateRect

InvalidateRect 能夠強制產生無效區域,其用法為

invoke  InvalidateRect,\
        hWnd,\      ; handle of window with changed update region
        lpRect,\    ; address of rectangle coordinates
        bErase      ; erase-background flag

底下是這三個參數的說明:

RECT 結構體中每個欄位的資料類型都是 LONG(長整數之意),其實就是 DWORD。而各欄位的意義如下:

RECT            STRUCT
  left          LONG        ?   ;左上角的 X 座標
  top           LONG        ?   ;左上角的 Y 座標
  right         LONG        ?   ;右下角的 X 座標
  bottom        LONG        ?   ;右下角的 Y 座標
RECT            ENDS

前兩個欄位代表矩形的左上角座標,後面兩個欄位代表右下角座標,如右圖所示,根據這兩個點就能決定一個矩形。雖然以平面上的任意兩點間的線段作為矩形的對角線,可以畫出無限多個矩形,但是如果這矩形的相鄰兩邊是水平線及鉛垂線的話,就只有一個。

呼叫 InvalidateRect 後,系統會在程式訊息佇列中沒有其他訊息時,將 WM_PAINT 訊息插入其中。然後被視窗函式收到,視窗函式處理 WM_PAINT 時會呼叫 BeginPaint 以獲得無效區域並將無效區域設為有效,同時依據 InvalidateRect 的最後一個參數,bErase,決定是否擦掉無效區域的背景。如果 bErase 設為非零(即 TRUE),BeginPaint 會擦掉;如果設為零(即 FALSE),就不擦掉。

InvalidateRect 的回傳值如果是 TRUE,表示成功執行;如果是 FALSE,表示失敗。

註六:BeginPaint 與 EndPaint

顧名思義,BeginPaint 用於開始繪製視窗內容時;而 EndPaint 則是結束繪製時。先說 BeginPaint 的用法:

invoke  BeginPaint,\
        hwnd,\         ; handle to window
        lpPaint        ; pointer to structure for paint information

hwnd 很明顯,是用來表示要繪製圖形的視窗的視窗代碼,其實就是視窗函式的第一個參數。

lpPaint 是位址,是一個稱為 PAINTSTRUCT 的結構體位址。當從 BeginPaint 返回前,BeginPaint 會把這個結構體內的各欄位都填上正確的資料才返回。PAINTSTRUCT 中的欄位分別是:

PAINTSTRUCT     STRUCT
  hdc           HDC         ?   ;裝置內容代碼
  fErase        BOOL        ?   ;系統是否已擦掉無效區域,若為非零,表示尚未擦掉;若為零,代表已擦掉
  rcPaint       RECT        <>  ;無效區域的範圍,rcPaint 的資料類型是 RECT,見下面的說明
  fRestore      BOOL        ?   ;底下三個欄位都是供系統使用
  fIncUpdate    BOOL        ?
  rgbReserved   BYTE        32 DUP(?)
PAINTSTRUCT     ENDS

PAINTSTRUCT 各欄位的說明如下:

大部分的情形下,BeginPaint 都會擦除無效區域,但是如果是應用程式呼叫 InvalidateRect 製造無效區域時,可以在 InvalidateRect 的第三個參數,bErase,指定為 0,不要 BeginPaint 擦去無效區域。這時候 BeginPaint 就會將 PAINTSTRUCT 的 fErase 設為非零,交由應用程式自己擦掉,同時 BeginPaint 還會對自己的視窗函式發出 WM_ERASEBKGND 訊息,應用程式可以在處理這個訊息時擦除。

BeginPaint 可以由作業系統獲得無效區域範圍、擦掉無效區域之外,還會把無效區域設為有效區域,這也就是為什麼處理 WM_PAINT 時要呼叫它的原因了。

BeginPaint 的回傳值如果為零,表示呼叫失敗亦即沒有可用的裝置內容;如果是非零,那就是裝置內容代碼,此值其實跟呼叫 BeginPaint 時,第二個參數,PAINTSTRUCT 結構體的第一個欄位 hdc 是一樣的。。

EndPaint

EndPaint 作為 WM_PAINT 最後的收尾,它的原型是:

invoke  EndPaint,\
        hWnd,\         ; handle to window
        lpPaint        ; paint data

hWnd 不必再說了,就是重新繪製的視窗代碼,與 BeginPaint 的第一個參數一樣。lpPaint 是 PAINTSTRUCT 結構體的位址,與 BeginPaint 的第二個參數一樣。換句話說,BeginPaint 與 EndPaint 不但成雙成對,連參數都一模一樣。EndPaint 的回傳值通常為非零,表示正確執行。