接著上一章,解說 SPLWND.ASM 剩下的部分。
視窗函式是一種很特別的副程式。大部分的副程式,都是由我們自己撰寫的程式去呼叫;但是呼叫視窗函式的方式卻有點不同。我們自己撰寫的應用程式包含視窗函式,而此應用程式卻是先呼叫 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」巨集是諸位先進所撰寫的,非 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,是銷毀視窗的訊息。這些經由視窗函式處理過的訊息,必須把零當做回傳值,傳回給作業系統,告知這訊息已處理完畢。
最後整理如下:
在視窗函式中,不感興趣的訊息都交給 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(見後面)。
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 有關,初學者很容易搞混,因此小木偶在這裡說說他們之間的區別。
使用者要結束程式,通常有三種操作方式:①按下視窗標題欄右邊的「關閉」按鈕。②或是按視窗標題欄左邊圖示,然後選擇「系統選單」中的關閉選項。③按下「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_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)。圖形裝置介面的英文原名是 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 系統的經驗,螢幕上可能不只一個視窗,這些視窗會相互影響,所以每個視窗的內容並非固定不變。例如:
像上面幾個例子,需要重新繪製的區域稱為「無效矩形(invalid rectangles)」,也稱為無效區域,這會被 Windows 檢測並記錄起來。不須重繪的區域,就稱為有效區域(valid rectangles)。Windows 其實很聰明,它會自動將無效區域繪製出來,例如上面第 5. 點提到滑鼠游標移過的區域,和上圖視窗的邊框(綠色框住的區域)。
除了像上面的例子是使用者的操作,可以產生無效區域之外,應用程式也可以自己產生無效區域,呼叫 InvalidateRect API(註五)就能在所指定的視窗產生無效區域。
以上只是舉出幾個例子,可以說明視窗中的內容是會變動的,因此並不是僅僅在程式一開始繪製視窗內容就行了,某些時候也要。那麼如果是這樣,應用程式要什麼時候繪製視窗內容呢?很簡單,當 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 訊息時,要如何處理呢?一般是先呼叫 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 程式怎麼把字串,「你按下了╳鍵。」,印在工作區的正中央。而字串中的「了」與「鍵」之間的「╳」代表使用者按下了什麼鍵。例如當使用者按了「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 的參數是
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。
那麼,是否有辦法得知剪裁矩形的大小呢?可以的,如果 uFormat 包含了 DT_CALCRECT,那麼 DrawText 並不會把字串畫出來,而是計算所需要的剪裁矩形最小的大小,並填入 lpRect 所指的 RECT 結構體內。等這工作完成,再呼叫一次 DrawText 真正的繪出文字來,這算很常用的功能。
如果僅是一行字串,那就把 uFormat 設為「DT_CALCRECT or DT_SINGLELINE」,並設定好剪裁矩形的 left、top 兩欄位,然後呼叫 DrawText。DrawText 會以這兩欄位為基準,去計算 right、bottom;等計算好 right、bottom 欄位之後,再呼叫一次 DrawText 真正繪製出字串。如果要顯示多行字串,那就把 uFormat 指定為「DT_CALCRECT or DT_WORDBREAK」,並設定 left、top、right 欄位,然後呼叫 DrawText 計算 bottom 欄位;等計算好之後,再呼叫一次 DrawText 真正繪製出字串。如果字串內含有換行字元及歸位字元,並且依據這兩字元換行,那就把 uFormat 指定為「DT_CALCRECT」,並設定好 left、top,這樣 DrawText 會依據 RECT 結構體的 left、top 為基準,去計算 right、bottom;等計算好之後,再呼叫一次 DrawText 真正繪製出字串。
上面所提到以 DT_CALCRECT 計算剪裁矩形的大小,在事先如果把 left、top 設為零,那麼 right、bottom 分別就是剪裁矩形的寬度與高度。
DrawText 還會把「&」字元視為前置字元,意思是在此字元之後的字加上底線,而不會顯示「&」;如果要顯示「&」字元得使用「&&」。於是有底下三種用法:
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」:
完成上面兩步驟,再捲動捲軸至適當位置,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 中,最常用的大多包含在以下三個動態連結程式庫中:
利用 Windows SDK 開發程式,主要就是以呼叫這三個動態連結程式庫,以達成各種目的。
GDI32.DLL 裡面,專門用來繪製文字與圖形。例如文字、直線或曲線的繪製,調色盤的控制等等;但是,它不能用來直接繪製一些使用者介面,像視窗、清單等等,這些工作由位於 user32.dll 中的 API 完成。當 Windows 發送 WM_PAINT 給應用程式時,應用程式就應該要著手繪製工作區(client area,也翻譯成客戶區或工作區,是指視窗標題欄底下與外框之間的區域,顯示視窗內容的地方,見第七章的圖片)。
此圖片為東森新聞民國 111 年 12 月 2 日在 Youtube 上發佈的新聞。在 Youtube 上也有官方正式版,請按這裡欣賞。事情是在同年 11 月 13 日,愛樂匯輕音樂團上海團隊宮崎駿久石讓音樂會的現場,在加演環節時,隊長也是單簧管樂手李政翰問大家想聽什麼曲子?有些人說「起風了」,但有一位叫書洋的小朋友大喊「孤勇者」,引起現場一片笑聲。小木偶不曉得為何大家會笑,可能是孤勇者不是宮崎駿、久石讓動畫上的曲目。
後來,樂團演奏的仍然是事先準備好的曲子菊次郎的夏天,但演奏一段不長的時間後,隊長突然開始演奏「孤勇者」中的「愛你孤身走暗巷」兩次。此訊號一出,大、小提琴很有默契的響應,後來其他樂手加入,結果樂曲就從夏天變成孤勇者。小朋友純真的大聲說出自己的願望,而樂團默默的幫助他實現,不會因為是個小孩,就忽略他。這樣尊重弱勢的聲音,是社會進步的表現,令人感動。
小木偶在撰寫這一章時,一邊聽著不同演唱者唱的「孤勇者」,一邊寫下這一章。製作這種沒人讚賞,也不知道有沒有人閱讀的網頁,內心很是孤寂也很糾結。這孤勇者動人的旋律與感人的歌詞,使我獨自行走在這條道路上,有了一絲慰藉。
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 的用法:
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 作為 WM_PAINT 最後的收尾,它的原型是:
invoke EndPaint,\ hWnd,\ ; handle to window lpPaint ; paint data
hWnd 不必再說了,就是重新繪製的視窗代碼,與 BeginPaint 的第一個參數一樣。lpPaint 是 PAINTSTRUCT 結構體的位址,與 BeginPaint 的第二個參數一樣。換句話說,BeginPaint 與 EndPaint 不但成雙成對,連參數都一模一樣。EndPaint 的回傳值通常為非零,表示正確執行。