VCL窗口函數註冊機制研究手記,兼與MFC比較 By 王捷 cheka@yeah.net (轉載請保留此信息) 這個名字起的有些聳人聽聞,無他意,只爲吸引眼球而已,若是您對下列關鍵詞有興趣,但願不要錯過本文: 1. VCL可視組件在內存中的分頁式管理; 2. 讓系統回調類的成員方法 3. Delphi 中彙編指令的使用 咱們知道Windows平臺上的GUI程序都必須遵循Windows的消息響應機制,能夠簡單歸納以下,全部的窗口控件都向系統註冊自身的窗口函數,運行期間消息可被指派至特定窗口控件的窗口函數處理。對消息相應機制作這樣的歸納有失嚴密,請各位見諒,我想趕忙轉向本文重點,即在利用Object Pascali或是C++這樣的面嚮對象語言編程中,如何把一個類的成員方法向系統註冊以供回調。 在註冊窗口類即調用RegisterClass函數時,咱們向系統傳遞的是一個WindowProc 類型的函數指針 WindowProc 的定義以下 LRESULT CALLBACK WindowProc( HWND hwnd, // handle to window UINT uMsg, // message identifier WPARAM wParam, // first message parameter LPARAM lParam // second message parameter ); 若是咱們有一個控件類,它擁有看似具備相同定義的成員方法TMyControl.WindowProc,但是卻不可以將它的首地址做爲lpfnWndProc參數傳給RegisterClass,道理很簡單,由於Delphi中全部類成員方法都有一個隱含的參數,也就是Self,所以沒法符合標準 WindowProc 的定義。 那麼,在VCL中,控件向系統註冊時究竟傳遞了一個什麼樣的窗口指針,同時經過這個指針又是如何調到各個類的事件響應方法呢?我先賣個關子,先看看MFC是怎麼作的。 在調查MFC代碼以前,我做過兩種猜測: 一,做註冊用的函數指針指向的是一個類的靜態方法,靜態方法一樣不須要隱含參數 this (對應 Delphi中的 Self ,不過Object Pascal不支持靜態方法) 二,做註冊用的函數指針指向的是一個全局函數,這固然最傳統,沒什麼好說的。 通過簡單的跟蹤,我發現MFC中,全局函數AfxWndProc是整個MFC程序處理消息的「根節點」,也就是說,全部的消息都由它指派給不一樣控件的消息響應函數,也就是說,全部的窗口控件向系統註冊的窗口函數極可能就是 AfxWndProc (抱歉沒作深刻研究,若是不對請指正)。而AfxWndProc 是如何調用各個窗口類的WndProc呢? 哈哈,MFC用了一種很樸素的機制,相比它那麼多稀奇古怪的宏來講,這種機制至關好理解:使用一個全局的Map數據結構來維護全部的窗口對象和Handle(其中Handle爲鍵值),而後AfxWndProc根據Handle來找出惟一對應的窗口對象 (使用靜態函數CWnd::FromHandlePermanent(HWND hWnd) ),而後調用其WndProc,注意WndProc但是虛擬方法,所以消息可以正確到達所指定窗口類的消息響應函數並被處理。因而咱們有理由猜測VCL也可能採用相同的機制,畢竟這種方式實現起來很簡單。我確實是這麼猜的,不過結論是我錯了...... 開場秀結束,好戲正式上演。 在Form1上放一個Button(缺省名爲Button1),在其OnClick事件中寫些代碼,加上斷點,F9運行,當停留在斷點上時,打開Call Stack窗口(View->Debug Window->Call Stack,或者按Ctrl-Alt-S )可看到調用順序以下(從底往上看,stack嘛) ( 若是你看到的 Stack 和這個不一致,請打開DCU 調試開關 Project->Options->Compiler->Use Debug DCUs, 這個開關若是不打開,是無法調試VCL 源碼的 ) TForm1.Button1Click(???) TControl.Click TButton.Click TButton.CNCommand ((48401, 3880, 0, 3880, 0)) TControl.WndProc ((48401, 3880, 3880, 0, 3880, 0, 3880, 0, 0, 0)) TWinControl.WndProc ((48401, 3880, 3880, 0, 3880, 0, 3880, 0, 0, 0)) TButtonControl.WndProc ((48401, 3880, 3880, 0, 3880, 0, 3880, 0, 0, 0)) TControl.Perform (48401,3880,3880) DoControlMsg (3880,(no value)) TWinControl.WMComman d((273, 3880, 0, 3880, 0)) TCustomForm.WMCommand ((273, 3880, 0, 3880, 0)) TControl.WndProc ((273, 3880, 3880, 0, 3880, 0, 3880, 0, 0, 0)) TWinControl.WndProc((273, 3880, 3880, 0, 3880, 0, 3880, 0, 0, 0)) TCustomForm.WndProc ((273, 3880, 3880, 0, 3880, 0, 3880, 0, 0, 0)) TWinControl.MainWndProc ((273, 3880, 3880, 0, 3880, 0, 3880, 0, 0, 0)) StdWndProc (3792,273,3880,3880) 可見 StdWndProc 看上去象是扮演了MFC中 AfxWndProc 的角色,不過咱們先不談它,若是你抑制不住好奇心,能夠提早去看它的源碼,在Forms.pas中,看到了麼? 是否是 特~~~~別有趣阿。 實際上,VCL在RegisterClass時傳遞的窗口函數指針並不是指向StdWndProc。那是什麼呢? 我跟,我跟,我跟跟跟,終於在Controls.pas的TWindowControl的實現代碼中(procedure TWinControl.CreateWnd;) 看到了RegisterClass的調用,hoho,終於找到組織了......別忙,發現了沒,這時候註冊的窗口函數是InitWndProc,看看它的定義,嗯,符合標準,再去瞧瞧代碼都幹了些什麼。 發現這句:SetWindowLong(HWindow, GWL_WNDPROC,Longint(CreationControl.FObjectInstance)); 我Faint,搞了半天InitWndProc初次被調用(對每個Wincontrol來講)就把自個兒給換了,新上崗的是FObjectInstance。下面還有一小段彙編,是緊接着調用 FObjectInstance的,調用的理由不奇怪,由於之後調用FObjectInstace都由系統CallBack了,但如今還得勞InitWndProc的大駕去call。調用的方式有些講究,不過留給您看完這篇文章後自個兒琢磨去吧。接下來只能繼續看FObjectInstance是什麼東東,它定義在 TWinControl 的 Private 段,是個Pointer也就是個普通指針,當什麼使都行,你跟Windows說它就是 WndProc 型指針 Windows 拿你也沒轍。 FObjectInstance究竟指向何處呢,鏡頭移向 TWincontrol 的構造函數,這是 FObjectInstance初次被賦值的地方。 多餘的代碼不用看,焦點放在這句上 FObjectInstance := MakeObjectInstance(MainWndProc); 能夠先告訴您,MakeObjectInstance是本主題最精彩之處,可是您如今只需知道FObjectInstance「指向了」MainWndProc,也就是說經過某種途徑VCL把每一個MainWndProc做爲窗口函數註冊了,先證實容易的,即 MainWndProc 具有窗口函數的功能,來看代碼:( 省去異常處理 ) procedure TWinControl.MainWndProc(var Message: TMessage); begin WindowProc(Message); FreeDeviceContexts; FreeMemoryContexts; end; FreeDeviceContexts; 和 FreeMemoryContexts 是保證VCL線程安全的,不在本文討論之列,只看WindowProc(Message); 原來 MainWndProc 把消息委託給了方法 WindowProc處理,注意到 MainWndProc 不是虛擬方法,而 WindowProc 則是虛擬的,瞭解 Design Pattern 的朋友應該點頭了,嗯,是個 Template Method , 很天然也很經典的用法,這樣一來全部的消息都能準確到達目的地,也就是說從功能上看 MainWndProc 確實能夠充做窗口函數。您如今能夠回顧一下MFC的 AfxWindowProc 的作法,一樣是利用對象的多態性,可是兩種方 式有所區別。 是否是有點亂了呢,讓咱們總結一下,VCL 註冊窗口函數分三步: 1. [ TWinControl.Create ] FObjectInstance 指向了 MainWndProc 2. [ TWinControl.CreateWnd ] WindowClass.lpfnWndProc 值爲 @InitWndProc; 調用Windows.RegisterClass(WindowClass)向系統註冊 3. [ InitWndProc 初次被Callback時 ] SetWindowLong(HWindow, GWL_WNDPROC, Longint(CreationControl.FObjectInstance)) 窗口函數被偷樑換柱,今後 InitWndProc 退隱江湖 (注意是對每一個TWinControl控件來講,InitWndProc 只被調用一次) 前面說過,非靜態的類方法是不能註冊成爲窗口函數的,特別是Delphi中 根本沒有靜態類方法,那麼MainWndProc 也不能有特權(固然寶蘭能夠爲此在編譯器上動點手腳,若是他們不怕成爲嘔像的話)。 那麼,那麼,您應該意識到了,在幕後操縱一切的,正是...... 背景打出字幕 超級巨星:麥克奧布吉特因斯坦斯 (MakeObjectInstance) 天空出現閃電,哦耶,主角纔剛剛亮相。 廢話不說,代碼伺候: ( 原始碼在 Form.pas 中,「{}」中是原始的註釋,而「 //」 後的是我所加,您能夠直 接就註釋代碼,也能夠先看我下面的評論,再回頭啃code ) // 共佔 13 Bytes,變體紀錄以最大值爲準 type PObjectInstance = ^TObjectInstance; TObjectInstance = packed record Code: Byte; // 1 Bytes Offset: Integer; // 4 Bytes case Integer of 0: (Next: PObjectInstance); // 4 Bytes 1: (Method: TWndMethod); // 8 Bytes // TWndMethod 是一個指向對象方法的指針, // 事實上是一個指針對,包含方法指針以 // 及一個對象的指針(即Self ) end; // 313是知足整個TInstanceBlock的大小不超過4096的最大值 InstanceCount = 313; // 共佔 4079 Bytes type PInstanceBlock = ^TInstanceBlock; TInstanceBlock = packed record Next: PInstanceBlock; // 4 Bytes Code: array[1..2] of Byte; // 2 Bytes WndProcPtr: Pointer; // 4 Bytes Instances: array[0..InstanceCount] of TObjectInstance; 313 * 13 = 4069 end; function CalcJmpOffset(Src, Dest: Pointer): Longint; begin Result := Longint(Dest) - (Longint(Src) + 5); end; function MakeObjectInstance(Method: TWndMethod): Pointer; const BlockCode: array[1..2] of Byte = ( $59, { POP ECX } $E9); { JMP StdWndProc } // 實際上只有一個JMP PageSize = 4096; var Block: PInstanceBlock; Instance: PObjectInstance; begin // InstFreeList = nil 代表一個Instance block已被佔滿,因而須要爲一個新 // Instance block分配空間,一個個Instance block經過PinstanceBlock中的 // Next 指針相連,造成一個鏈表,其頭指針爲InstBlockList if InstFreeList = nil then begin // 爲Instance block分配虛擬內存,並指定這塊內存爲可讀寫並可執行 // PageSize 爲4096。 Block := VirtualAlloc(nil, PageSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE); Block^.Next := InstBlockList; Move(BlockCode, Block^.Code, SizeOf(BlockCode)); Block^.WndProcPtr := Pointer(CalcJmpOffset(@Block^.Code[2], @StdWndProc)); // 如下代碼創建一個Instance的鏈表 Instance := @Block^.Instances; repeat Instance^.Code := $E8; { CALL NEAR PTR Offset } //算出相對 jmp StdWndProc指令的偏移量,放在$E8的後面 Instance^.Offset := CalcJmpOffset(Instance, @Block^.Code); Instance^.Next := InstFreeList; InstFreeList := Instance; // 必須有這步,讓Instance指針移至當前instance子塊的底部 Inc(Longint(Instance), SizeOf(TObjectInstance)); // 判斷一個Instance block是否已被構造完畢 until Longint(Instance) - Longint(Block) >= SizeOf(TInstanceBlock); InstBlockList := Block; end; Result := InstFreeList; Instance := InstFreeList; InstFreeList := Instance^.Next; Instance^.Method := Method; end; 不要小看這區區幾十行代碼的能量,就是它們對 VCL 的可視組件進行了分頁式管理,代碼中對兩個鏈表進行操做,InstanceBlock 中有 ObjectInstance 的鏈表,而一個個InstanceBlock 又構成一個鏈表 )一個 InstanceBlock 爲一頁,有4096 字節,雖然 InstanceBlock 實際使用的只有 4079 字節,不過爲了 Alignment ,就加了些 padding 湊滿 4096 。從代碼可見每一頁中可容納 313 個所謂的ObjectInstance,若是望文生義很容易將這個 ObjectInstance 誤解爲對象實例,其實否則,每一個ObjectInstance 實際上是一小段可執行代碼,而這些可執行代碼不是編譯期間生成的,也不是象虛擬函數那樣滯後聯編,而根本就是MakeObjectInstance 在運行期間「創做」的(天哪)! 也就是說, MakeObjectInstance 將全部的可視VCL組件 改形成了一頁頁的可執行代碼區域,是否是很了不得呢。 不明白ObjectInstance所對應的代碼是作什麼的麼?不要緊,一塊兒來看 call - - - - - - - - - - - > pop ECX // 在call 以前,下一個指令的地址會被壓棧 @MainWndProc // 緊接着執行pop ECX, 爲什麼這麼作呢? @Object(即Self) // 前面註釋中提過 答案在 StdWndProc 的代碼中,要命哦,全是彙編,但是無限風光在險峯,硬着頭皮闖一回吧。 果不其然,咱們發現其中用到了ECX function StdWndProc(Window: HWND; Message, WParam: Longint; LParam: Longint): Longint; stdcall; assembler; asm XOR EAX,EAX PUSH EAX PUSH LParam PUSH WParam PUSH Message MOV EDX,ESP MOV EAX,[ECX].Longint[4] // 至關於 MOV EAX, [ECX+4] ( [ECX+4] 是什麼?就是Self ) CALL [ECX].Pointer // 至關於 CALL [ECX] , 也就是調用 MainWndProc ADD ESP,12 POP EAX end; 這段彙編中在調用MainWndProc前做了些參數傳遞的工做,因爲MainWndProc 的定義如 下: procedure TwinControl..MainWndProc(var Message: TMessage); 根據Delphi 的約定,這種狀況下隱函數Self 做爲第一個參數,放入EAX 中, TMessage 結構的指針做爲第二個參數,放入EDX中,而Message的指針從哪兒來呢?咱們看到在連續幾個 Push 以後,程序已經在堆棧中構造了一個TMessage 結構,而這時的ESP 固然就是這個結構的指針,因而將它賦給EDX 。若是您不熟悉這方面的約定,能夠參考Delphi 的幫助Object Pascal Refrence -> Program Control。 如今真相大白,Windows 消息百轉千折終於傳進MainWndProc , 不過這一路也可謂至關精彩,MakeObject這一函數天然是居功至偉, StdWndProc 也一樣是幕後英雄,讓咱們把 MakeObjectInstance 做出的代碼和StdWndProc 鏈接起來,哦,堪稱鬼斧神工. ( 大富翁無法顯示圖像,能夠去 http://jp.njuct.edu.cn/crystal/article\vcl%20hardcore.htm 看完整全文,感謝房客支持) 就此在總結一下, FobjectInstance 被VCL 註冊爲窗口函數,而實際上 FObjectInstance 並不實際指向某個函數,而是指向一個ObjectInstance, 然後者咱們已 經知道是一系列相接的可執行代碼段當中的一塊,當系統須要將 FObjectInstance 當作窗口函數做爲回調時,實際進入了ObjectInstance 所在的代碼段,而後幾番跳躍騰挪(一個call 加一個 jump )來到StdWndProc ,StdWndProc 的主要功用在於將Self 指針壓棧,並把Windows的消息包裝成Delphi的TMessage 結構,如此才能成功調用到TWinControl類的成員方法 MainWndProc, 消息一旦進入MainWndProc 即可以輕車熟路一路高唱小曲來到各個對象轉屬的WndProc , 今後功德圓滿。 後記: 我的感受在這一技術上VCL 要比MFC 效率高出很多,後者每次根據窗口句柄來檢索相 對應的窗口對象指針頗爲費時,同時MakeObject 的代碼也至關具備參考價值,有沒有想 過讓你本身的程序在內存中再開一堆可執行代碼? 全部的代碼是基於Delphi5的,可能與其他版本有所出入,但相信不會很大。 整個星期六和星期天我都花在寫做此文上了(連調試帶寫字), 不過水平所限,不免 有所錯誤與表達不周,希望不至以己昏昏使人昏昏,歡迎來信探討指教