Microsoft Windows的訊息迴圈

微軟視窗作業系統是以事件驅動做為程式設計的基礎。程式的線程會從作業系統取得訊息。應用程式會不斷迴圈呼叫GetMessage函數(或是PeekMessage函數)來接收這些訊息,這個迴圈稱之為「事件迴圈」。基本上事件迴圈的程式碼如下所示(C語言 / C++程式語言):

MSG msg; //用于存储一条消息
BOOL bRet;

//从UI线程消息队列中取出一条消息
while( (bRet = GetMessage( &msg, NULL, 0, 0 )) != 0)
{
   if (bRet == -1)
   {
       //错误处理代码,通常是直接退出程序
    }
    else
    {
       TranslateMessage(&msg); //按键消息转换为字符消息
       DispatchMessage(&msg); //分发消息给相应的窗体
     }
}

雖然在程式上並沒有很嚴格的規定與要求,但是一般來說,它的事件迴圈通常會呼叫TranslateMessage函數與DispatchMessage函數,這兩個函數會傳遞訊息給回呼函數,以及呼叫相應視窗的訊息處理常式。

現在的繪圖介面架構程式設計,例如Visual BasicQt基本上是不會要求應用程式直接擁有視窗程式的訊息迴圈,但是會以鍵盤與滑鼠的按鍵動作來作為事件的處理機制。在這些架構底下,訊息迴圈的痕跡還是可以被找到的。

注意:在上述的原始碼裏,尤其在while迴圈大於零的條件。即使GetMessage函數的傳回值型態是英文字大寫的BOOL,但是在Win32視窗程式裏,它是被定義成int整數型態,它有兩個值,TRUE是整數的1,FALSE是整數的0。整數 -1代表error(例如第二個參數為輸出的窗口控制代碼但取不到值的時候),整數0值當GetMessage取得到WM_QUIT訊息。假如有其他訊息,那麼非零值會當成傳回值(有訊息的傳回值通常是正值,但是有些程式設計的說明文件不一定會說明的很詳細[1][2])。

歷史

16位元Windows系統為非搶先單線程模式,應用程式沒有傳送訊息佇列,向窗口傳送一個訊息總是按同步方式執行,也即傳送程式要在接受訊息的窗口完全處理完訊息之後才能繼續執行。這通常是一個所期望的特性。但是,如果接收訊息的窗口花很長的時間來處理訊息或者出現掛起,則傳送程式就不能再執行。這意味着系統是不強壯的。[3]如果應用程式訊息佇列(只用於存放投寄的訊息)為空,由於沒有虛擬輸入訊息佇列,SendMessage或PeekMessage函數訪問系統事件佇列查取可用的滑鼠或鍵盤輸入訊息。如果系統佇列中沒有需要處理的事件,SendMessage或PeekMessage函數掃描所有窗口以處理需要修改重繪的區域。如果沒有需要重繪的區域,則交出CPU控制權。恢復CPU控制權時,檢視是否有定時器過期。至此如果沒有訊息可返回,SendMessage進入睡眠,直至被輸入事件喚醒;PeekMessage如果沒有設置PM_NOYIELD標記,則會讓出CPU控制權,但不會讓線程休眠,重新獲得CPU後PeekMessage將控制權返回到線程,並返回一個空值指出這個線程沒有要處理的訊息了。

本文主要關注Win32系統的訊息處理機制。

背景

UI線程

Windows系統規定,窗口和勾點(hook)這兩種User對象分別由建立窗口和安裝勾點的線程所擁有,一旦該線程結束,作業系統會自動刪除窗口或解除安裝勾點。而其他的User對象(圖示icon、游標cursor、窗口類WndClass、選單、加速鍵表等)則歸行程所有,行程結束時作業系統會自動刪除這些對象。

建立窗口的線程必須就是處理窗口所有訊息的線程,即UI線程(User Interface Thread)建立了表單及表單上的各種控制項,系統為UI線程分配一個訊息佇列用於窗口訊息的派送(dispatch)。為了使窗口處置這些訊息,線程必須有它自己的「訊息迴圈」。只有當一個線程呼叫Windows API中的GDI(Graphics Device Interface)和User函數時,作業系統才會將其看成是一個UI線程,並為它分配一些另外的資源,建立一套線程訊息佇列;否則,作業系統把非UI線程視作普通工作線程(Workhorse),不會為它建立訊息佇列。因此,呼叫PostThreadMessage前,這個線程必須是UI線程從而有投寄訊息的佇列,通常可在該線程中呼叫一次PeekMessage函數以達到這個目的。

如果一個UI線程結束執行,作業系統會自動回收它所建立的所有表單。

表單過程

表單過程(Window Procedure)是一個函數,每個表單有一個表單過程,負責處理該表單的所有訊息。

UI控制項也是獨立的「Window」,擁有自己的「表單過程」。

訊息佇列

Windows作業系統的內核空間中有一個系統訊息佇列(system message queue),在內核空間中還為每個UI線程分配各自的線程訊息佇列(Thread message queue)。在發生輸入事件之後,Windows作業系統的輸入裝置驅動程式將輸入事件轉換為一個「訊息」投寄到系統訊息佇列;作業系統的一個專門線程從系統訊息佇列取出訊息,分發到各個UI線程的輸入訊息佇列中。

每個UI線程的線程資訊塊TIB分配一個THREADINFO的結構,該結構包含一族成員變數,包括:[4]

  • 傳送訊息佇列(send-message queue)指標:其他發起線程通過SendMessage、SendMessageTimeout、SendMessageCallback、SendNotifyMessage、ReplyMessage等函數產生的訊息放入該佇列,發起的線程阻塞(掛起)在該佇列上(對於SendMessageCallback、SendNotifyMessage不被阻塞)直至訊息處理完或者逾時返回。
  • 投寄訊息佇列(posted-message queue)指標:其他線程通過PostMessage函數或PostThreadMessage函數投寄的訊息;
  • 虛擬輸入訊息佇列(virtualized-input queue)指標:鍵盤與滑鼠事件。該佇列最多只儲存一個鍵盤訊息,僅當應用程式處理完這個鍵盤訊息,Windows才會從作業系統訊息佇列取出下一個鍵盤訊息放入線程的虛擬輸入訊息佇列。這種方式至少有兩點用途:一是如果用戶的鍵盤輸入速度快於應用程式處理鍵盤訊息的速度,並且特定按鍵會使輸入焦點從一個窗口切換到另一個窗口,隨後的按鍵就應該是另一個窗口的輸入;二是Windows API函數TranslateMessage把按鍵訊息轉化為字元訊息,如WM_KEYDOWN轉化為WM_CHAR,然後放入線程的虛擬輸入訊息佇列中,成為下一個待處理的鍵盤訊息。
  • 回覆訊息佇列(reply-message queue)指標:呼叫SendMessage函數的線程在這個函數上阻塞後,實際上仍可能被系統使用該線程執行其他處理,因此SendMessage函數的目標線程把窗口函數的返回值登記到這個佇列作為SendMessage的返回值,以便SendMessage函數從阻塞狀態恢復時能取到該返回值(16位元Windows系統是單線程的,因此不可能存在這種需求)。另外一種使用情形是SendMessageCallback函數(給所有重疊(overlapped)窗口廣播)時,總是呼叫後立即返回並繼續執行,因此接收了此訊息的線程把窗口函數執行結果登記到發起線程的回覆訊息佇列,在發起線程下一次呼叫GetMessage、PeekMessage、WaitMessage或某個SendMessage掛起時從回覆訊息佇列中取出該msg並執行登記的ResultCallBack函數。
  • nExitCode:由PostQuitMessage函數設置該成員,作為線程的退出碼。
  • 喚醒標誌(wake flage)
  • 局部輸入狀態變數
    • QS_POSTMESSAGE位:投寄訊息佇列是否為空;
    • QS_QUIT位:由PostQuitMessage函數給該標誌置位。
    • QS_SENDMESSAGE位:傳送訊息佇列是否為空;
    • QS_KEY:有按鍵訊息
    • QS_MOUSE:有滑鼠訊息
    • QS_PAINT:有WM_PAINT
    • QS_TIMER:有WM_TIMER

應用程式的每個UI線程中有一段稱之為「訊息迴圈」的代碼,通過GetMessage系統呼叫(或是PeekMessage系統呼叫)訪問系統空間中的對應的UI線程的訊息佇列,並依照下述次序處理:

  • QS_SENDMESSAGE置位:則對傳送訊息佇列中的每個訊息,依次呼叫各個傳送訊息的窗口函數直接處理,GetMessage不返回;直至所有傳送訊息佇列中的訊息處理完畢。
  • QS_POSTMESSAGE置位:則填充GetMessage函數參數的MSG結構為相應的投寄訊息,GetMessage返回為真。該訊息通過DispatchMessage系統呼叫把訊息分發給相應窗口的訊息處理常式。
  • QS_QUIT置位:則填充GetMessage函數參數的MSG結構為WM_QUIT,QS_QUIT復位,GetMessage返回為假。
  • QS_INPUT置位:則填充GetMessage函數參數的MSG結構為相應的輸入訊息,GetMessage返回為真。該訊息通過DispatchMessage系統呼叫把訊息分發給相應窗口的訊息處理常式。
  • 再一次檢查QS_SENDMESSAGE置位,如是則處理髮送訊息佇列中的每個訊息。
  • QS_PAINT置位:則填充GetMessage函數參數的MSG結構為WM_PAINT,GetMessage返回為真。GetMessage不從佇列中刪除WM_PAINT訊息(即不對QS_PAINT復位)。
  • QS_TIMER置位:則填充GetMessage函數參數的MSG結構為WM_TIMER,QS_TIMER復位,GetMessage返回為真。如果QS_TIMER復位狀態,則當前線程掛起(hung)。

需要注意的是,GetMessage如果在應用程式訊息佇列未取得訊息,則GetMessage呼叫不返回,該線程掛起,CPU使用權交給作業系統。即GetMessage為阻塞呼叫。

由此可見,Windows的事件驅動模式,並不是作業系統把訊息主動分發給應用程式;而是由應用程式的每個UI線程通過「訊息迴圈」代碼從UI線程訊息佇列取得訊息。

Windows訊息類別

  • 鍵盤訊息:
    • 按鍵訊息:WM_SYSKEYDOWN、WM_SYSKEYUP、WM_KEYDOWN、WM_KEYUP等訊息。
      • wParam包含虛擬鍵碼(virtual-key code),表示按下或釋放的鍵
      • lParam包含按鍵6個欄位資訊:
        • 重複按鍵次數(Repeat Count,0~15 位):通常設為1。大於1說明按鍵速度大於程式處理能力。可以根據實際需要忽略或處理。
        • OEM掃描碼(scan code,16~23位):硬件產生的代碼。
        • 擴充鍵標誌(extended key,24位元):如果為擴充鍵(如右側的Alt鍵或Ctrl鍵)按下時為1,否則為0。
        • 保留位(25~28位元):保留位是系統預設保留的,一般不用。
        • 上下文代碼(context code,29位):如同時按下ALT,標誌為1;否則為0。WM_SYSKEYUP或WM_SYSKEYDOWN常為1。WM_KEYUP或WM_KEYDOWN常為0。當所有程式都最小化時,沒有窗口具有輸入焦點,Windows仍將傳送鍵盤訊息給活動窗口;所有的按鍵都會產生WM_SYSKEYUP與WM_SYSKEYDOWN訊息,此情況下如果沒按下ALT,該欄位為0,這樣使最小化的活動窗口不處理這些按鍵。對於一些非英文鍵盤,有些字元是shift等組合鍵產生的,這時內容代碼為1,但是其是非系統按鍵。
        • 鍵先前狀態(previous key state,位30):鍵此前是釋放的,則為0,還則為1。很明顯UP為1,DOWN可以為1或0,為1表示該鍵自動重複。
        • 轉換狀態(transition state,31位元):鍵被按下為0,鍵被鬆開時為1。如UP為1,DOWN為零。
      • SYSKEY:按下F10(將啟用選單條)或者按下Alt後再按下別的鍵,或者沒有窗口具有鍵盤輸入焦點(WM_SETFOCUS指示窗口得到輸入焦點,WM_KILLFOCUS指示窗口失去輸入焦點)時的按鍵,為SYSKEY。lParam的第29位為context code,如果為1表示Alt被按下,如果為0表示WM_SYSKEYDOWN發出時沒有窗口具有鍵盤輸入焦點。無論用戶處理與否,必須傳送給Windows預設窗口過程處理此類按鍵訊息。
      • 其他情形為普通按鍵。除Print鍵之外都有「按下」訊息;所有鍵都存在「彈起」訊息。
    • 字元訊息:按鍵訊息WM_SYSKEYDOWN、WM_KEYDOWN被Windows API函數TranslateMessage處理後該函數線上程訊息佇列投寄(post)相應的字元訊息,wParam參數是ASCII或Unicode的character code;這取決於RegisterClass函數是A或W版;IsWindowUnicode函數判斷窗口過程會接受哪種編碼。產生字元訊息的按鍵有:任何字元鍵、回退鍵(BACKSPACE)、確認鍵(carriage return)、ESC、SHIFT + ENTER(linefeed換行)、TAB。因為TranslateMessage函數從WM_KEYDOWN和WM_SYSKEYDOWN訊息產生了字元訊息,所以字元訊息是夾在按鍵訊息之間傳遞給窗口訊息處理程式的。如果用戶按住一個鍵不放,會自動重複產生一系列的WM_KEYDOWN訊息;對每條WM_KEYDOWN訊息,都會得到一條字元訊息。如果某些WM_KEYDOWN訊息的重複計數大於1,那麼相應的WM_CHAR訊息將具有同樣的重複計數。
      • WM_SYSCHAR:按下Alt後再按下別的鍵的WM_SYSKEYDOWN訊息被翻譯,
      • WM_CHAR:WM_KEYDOWN訊息被翻譯為WM_CHAR訊息。
      • WM_DEADCHAR:TranslateMessage函數處理「死鍵」(dead key)的WM_KEYUP訊息,向具有輸入焦點的窗口投寄(post)出WM_DEADCHAR訊息。死鍵是產生附加符號的按鍵。例如在德語鍵盤,銳音符被按下、釋放後,再按下A,將獲得字母á的WM_CHAR。如果在死鍵之後跟有不能帶此附件符號的字母(例如銳音符後跟「s」),那麼將接收到兩條WM_CHAR訊息:前一個訊息的wParam等於附加符號本身的ASCII碼(與傳遞到WM_DEADCHAR訊息的wParam值相同),第二個訊息的wParam等於字母的ASCII代碼。
      • WM_SYSDEADCHAR:按下Alt時又按下了「死鍵」的WM_SYSKEYUP訊息。
  • 滑鼠訊息:
    • 客戶區滑鼠訊息:WM_MOUSEMOVE及滑鼠按鍵的DOWN、UP、DBLCLK訊息。雙擊事件的處理只有窗口類別定義接收(CS_DBLCLKS)時,才起作用,這時接收到的滑鼠訊息順序為:DOWN、UP、DBLCLK、UP。滑鼠訊息傳送給被單擊的窗口或滑鼠經過的窗口,即使該窗口處於非活動或不帶輸入焦點;例外情況有「擷取滑鼠」時或模式對話方塊處於活動狀態時。訊息參數wParam(指出那個滑鼠按鈕、Shift鍵、Ctrl鍵被按下;lParam的低位表示x坐標,高位表示y坐標的滑鼠位置。
    • 非客戶區滑鼠訊息:為WM_NC*形式。
      • 擊中測試訊息:WM_NCHITTEST。Windows利用此訊息來產生其他所有的滑鼠訊息。
    • WM_MOUSEWHEEL傳送給具有焦點的窗口(注意不一定是滑鼠下面的窗口)
  • 定時器訊息
  • 控制項訊息
  • 跨行程傳送數據的訊息:WM_SETTEXT、WM_GETTEXT、WM_COPYDATA,系統自動分配使用可在行程間共用的主記憶體對映檔案來傳遞數據。

鍵盤輸入時需要明確插入符位置,相關API函數為:CreateCaret、SetCaretPos、ShowCaret、HideCaret、DestroyCaret、GetCaretPos、GetCaretBlinkTime、SetCaretBlinkTime。

3個獲得鍵狀態的函數:GetKeyState、GetAsyncKeyState、GetKeyboardState。

對於自訂的控制項,當單擊子窗口時,父窗口會得到焦點。但對於標準子窗口控制項,單擊時會自動獲得焦點(子窗口過程在WM_LBUTTONDOWN中實現了SetFocus(hwnd))。如果一個子窗口擁有輸入焦點,滑鼠單擊另一個兄弟子窗口,則兄弟子窗口獲得輸入焦點。

同步與阻塞

Windows API函數SendMessage是個同步呼叫,即它發出的Windows訊息沒被處理完之前這個函數就不返回。但這個函數不是阻塞的。分兩種情形:[5]

  • 如果被指定的窗口是發起呼叫SendMessage的線程建立的,則該線程立即執行窗口過程(window procedure);
  • 如果被指定的窗口不是發起呼叫SendMessage的線程建立的,作業系統切換到該窗口所屬的線程執行相應的窗口過程。訊息處理完之前,傳送線程不從呼叫SendMessage處返回,但傳送線程這期間可以處理非佇列訊息(nonqueued message)。這是「同步非阻塞」。

非同步傳送或投寄訊息的函數,如PostMessage、SendMessageCallback、SendNotifyMessage,訊息參數中不能使用指標,否則函數呼叫失敗。

GetMessage偽演算法

BOOL GetMessage(MSG *lpMsg, HWND hWnd , UINT wMsgFilterMin, UINT wMsgFilterMax)
{
         //查看QS_SENDMESSAGE标志,如果有的话循环处理,直到没有消息位置
         DWORD dwRetVal = 0;
         ThreadInfo threadInfo;
 
FLAG_SENDPROCLOOP:
         GetThreadInfo(GetCurrentThreadId(), &threadInfo);
         while (threadInfo.QS_SENDMESSAGE == QS_SIGNALSET) {
                   //从发送消息队列中获取消息
                   dwReturnVal = GetMsgFromQueue(QUEUE_SEND, lpMsg, hWnd,wMsgFilterMin, wMsgFilterMax);
                   //判断是否取到消息,有则调用窗口函数,无则复为QS_SENDMESSAGE标志
                   If (dwReturnVal == GETMESSAGE_HASMESSAGE) {
                            //调用指定窗口的窗口函数
                            CallWindowProc(hWnd, &threadInfo, lpMsg);
                   }
                   else {
                            QS_SENDMESSAGE = QS_SIGNALRESET;
                            break;
                   }
         }
         //在继续处理之前再次检查发送消息队列
         if (threadInfo.QS_SENDMESSAGE == QS_SIGNALSET) goto FLAG_SENDPROCLOOP;
         //检查发送消息队列, 如果有消息则取发送消息
         //判断是否还有发送消息,没有了则复位QS_POSTMESSAGE标志
         if (threadInfo.QS_POSTMESSAGE == QS_SIGNALSET) {
                   dwReturnVal = GetMsgFromQueue(QUEUE_POST, lpMsg, hWnd, wMsgFilterMin, wMsgFilterMax);
                   if (dwReturnVal == GETMESSAGE_LASTMESSAGE)
                            threadInfo.QS_POSTMESSAGE = QS_SIGNALRESET;
                  
                   return TRUE;
         }       
 
         //如果退出标志被置位
         if (threadInfo.QS_QUIT == QS_SIGNALSET) {
                   threadInfo.QS_QUIT = QS_SIGNALRESET;
                   FillMessage(lpMsg, MESSAGE_QUIT);
                   return FALSE;
         }
 
         //检查输入消息队列
         if (threadInfo.QS_INPUT == QS_SIGNALSET) {
                   DWORD dwRetVal = GetMessageFromQueue(QUEUE_INPUT, lpMsg, hWnd, wMsgFilterMin, wMsgFilterMax);
                   //检查是否有键盘,鼠标消息
                   if (Test(dwRetVal, QS_KEY) == QS_LASTMOUSEKEYMESSAGE)
                            threadInfo.QS_KEY = QS_SIGNALRESET;
                   if (Test(dwRetVal, QS_MOUSEBUTTON) == QS_LASTMOUSEMESSAGE)
                            threadInfo.QS_MOUSEBUTTON = QS_SIGNALRESET;
 
                   return TRUE;
         }
 
         //测试QS_PAINT
         if (threadInfo.QS_PAINT == QS_SIGNALSET) {
                   //填充MSG,如果没有窗口过程确认窗口,则复位QS_PAINT标志
                   //...
                   //返回TRUE
                   threadInfo.QS_PAINT = QS_SIGNALRESET;
                   return TRUE;
         }
 
         if (threadInfo.QS_TIMER == QS_SIGNALSET) {
                   //填充MSG,如果没有定时器报时,则复位QS_TIMER标志
                   //...
                   //返回TRUE
                   return TRUE;
         }
 
         //等待有消息到达
         dwRetVal = MsgWaitForMultipleObjectsEx(...);
         if (...)
                   goto FLAG_SENDPROCLOOP;
 
         //等待失败
         return FALSE;
}

參考資料

  1. ^ GetMessage function. [2009-11-01]. (原始內容存檔於2008-04-12). 
  2. ^ PeekMessage function. [2009-11-01]. (原始內容存檔於2008-04-08). 
  3. ^ Bob Gunderson:《GetMessage and PeekMessage Internals》,Microsoft Developer Network Technology Group,December 11, 1992
  4. ^ (美)Jeffrey Richter:Programming Applications for Microsoft Windows, Microsoft Press,2000,Fourth edition,「第26章 窗口訊息」,《Windows核心編程》中文版,機械工業出版社2008年5月1日。 ISBN:9787111237914.
  5. ^ MSDN:SendMessage function. [2017-11-29]. (原始內容存檔於2018-01-14). 

相關條目

外部連結