現如今,提供穩定可靠且能滿足人民群眾日益增長的物質文化需要的服務是互聯網服務商的基本責任,所以服務端軟件一定要夠壯夠強夠靈活。服務程序一旦跑起來那就最好7X24小時地永遠別掛,而且多變的、不停增長的用戶需求也得盡快滿足??蓡栴}是,永遠也別指望程序員寫出沒有bug的程序,任何架構師也沒有水晶球可以預測將來的花花世界,無論
當時看起來多么完美的代碼,將來也會因為種種原因要被修改(或者被丟棄?)。既然如此,那么我們或許應該想辦法給程序加上點進化能力,讓它能永不停歇地任勞任怨地工作,而同時還不斷地反省自己、糾正自己并茁壯成長。
??? 本文是寫給C++程序員的,如果你的工具是Lisp、Erlang、Ruby這樣的動態語言,那么因為它們牛B的高級動態特性,你壓根就用不著象我們這樣,在二進制層干刀口舔血的勾當。
??? 簡單來說,熱更新就是程序邊運行邊更新。有人一定覺得我在故意(象專家那樣)裝B,把簡單的問題搞得異常復雜,因為動態鏈接庫本身就是可以動態加載和卸載的,只要在新的動態庫build和部署好后通知程序重新加載一下不就搞定了?
??? 在這里,我要語重心長地告訴你們:第一,我沒有裝B,因為不想遭雷劈;第二,這種簡單的方案在少數情況下是行得通的,但在大多數情況下卻不行,因為實際的程序是代碼與數據結構的正確結合,代碼幾乎總是要操作相應的數據結構。舉個例子,A庫的create函數創建了數據對象data,foo函數能正確地操作data,然后我們用B庫熱更新了A庫,這樣現在的create和foo函數都是B庫實現的,而且新的create產生的數據對象與data(二進制布局)格式不同,新的foo也只能正確地操作新的數據對象;假設此后應用程序又需要用(B庫的)foo操作由A庫創建的data(我們無法避免,因為數據的生存周期是和應用邏輯息息相關的),這個時候嚴重的錯誤是不是就極可能發生?所以一個模塊被熱替換掉后,由它創建的所有數據對象也要跟著進行格式轉換,轉換為與新版本兼容的(二進制布局)格式??墒沁@又帶來了新問題:如何才能找到所有由舊模塊創建的數據對象?我們就象蹩腳數學家一樣,把一個骯臟的問題轉化成了另外一個骯臟的問題。
??? 換一種思路,如果在編程時愿意遵循一定的規范(規范是一種約束,但合理的約束卻常常能提高總體的自由),而這規范使我們能避開找到所有舊版本數據對象這樣的棘手問題,那么就能實現安全的熱更新。
??? 本文建議的規范是采用類似COM、XPCOM這樣的組件對象模型:程序由一個個的組件對象組成,每種對象提供了若干功能,外部只能通過對象的接口來使用相應功能。接口通常都用C++抽象基類來構建,從ABI(Application Binary Interface)的角度來看,C++抽象基類最關鍵的是規定了子類的虛函數表的布局。也可以用其它方法來構建接口機制,但是要保證與C++的虛函數表模型(g++, vc++等主流編譯器在這方面的實現都是一樣的)在ABI層上兼容。模塊是物理上的對象容器,可以包含一個或多個組件對象。模塊最常見的形式就是動態鏈接庫(so或dll),本文探索的動態更新機制便是以模塊為最小單位。
??? 從ABI層來看,通過組件對象接口來調用相關功能實際就是調用該對象虛表中對應項所指向的虛函數實現,正因為調用虛函數需要一個查表才能找到真正函數地址的中間操作,所以才使得我們能夠hook住組件對象的調用,從而有機會把老版本的對象轉換為新版本。那么如何才能hook對象的虛函數調用呢?方法很簡單,修改虛表,讓虛表的每一項都指向我們
的hook代碼,這樣修改之后,無論何時何地外部模塊調用老版本對象都會首先執行hook代碼。有朋友一定覺得這太hack、太不安全了:你咋就能確定虛表的位置,虛表的項數呢?是的,你的質疑一點都沒錯,如果不遵循任何規范,那么對林林總總的詭異的C++編譯器抖這么點小機靈的確是一種非常危險的動作。但幸運的是,如果你采用XPCOM這樣的組件對象模型,那么各大C++編譯器在抽象類的虛表實現上難得的共識就可以保證我們找到虛表的正確位置,而且模型額外提供的(標準C++不具有的)運行期接口類型信息又可以保證我們安全地修改適當個數的虛表項。
??? 現在可以用個簡單的例子來試驗一下上述思路是否行得通。這里提供了代碼的zip包下載,目前僅支持跑在Intel IA32架構CPU上的windows和freebsd平臺(當然,其它unix平臺也應該沒問題,只是頭文件的包含路徑有可能需要調整)的實現,windows平臺需要安裝mingw。解壓后在相應目錄下運行
??? gmake PLAT=windows
或
??? gmake PLAT=freebsd
就會生成test程序。在freebsd平臺下記得先用
??? export LD_LIBRARY_PATH=./
將當前目錄加入到動態庫搜索路徑中后再運行。
??? 例子包括如下幾個源程序文件:nsIBase.h nsImp1.h nsImp1.cpp nsImp2.h nsImp2.cpp dynahook.cpp test.cpp 。
??? nsIBase.h定義了一個抽象基類nsIBase,它代表著一個接口,其中包含有2個接口方法,分別是Hello和Foo。
??? nsImp1.h和nsImp1.cpp共同組成了nsIBase接口的第一個版本的實現,它們會被build成一個名為libimp1.dll(unix下是libimp1.so,下同)的動態鏈接庫,這就是一個模塊;頭文件中定義了nsImp1具體類,它繼承自nsIBase抽象類。cpp文件中除了包括接口方法的具體實現,還有三個約定的導出函數:<1>create,相當于工廠方法,因為外部模塊不知道組件對象的具體實現,所以只能用它來創建對象實例; <2>on_swapping,當該模塊被新版本模塊熱替換時,該函數會被調用,并且傳遞給它新版本模塊的格式轉換函數指針作參數,該函數應當更改當前版本對象之虛表中的各項函數指針,指向特殊的hook代碼,使得此后外部模塊對這些對象的任何調用都會首先被hook截獲,然后新模塊的格式轉換函數被執行,最后才進行通常的接口函數調用;<3>converter,格式轉換函數,當該模塊熱替換別的模塊時,它用于把老版本的對象轉換為自己版本的對象,這就包括給對象設置新的虛表指針、轉換數據塊等等。因為nsImp1是第一個版本的實現,沒有比它更老的實現需要它替換,所以它的converter是個空函數。
??? nsImp2.h和nsImp2.cpp組成了nsIBase接口的第二個版本的實現,它們被build成libimp2.dll。nsImp2的組織結構和nsImp1差不多,只不過它的converter是真正要做實際的轉換工作的。
??? dynahook.cpp是最有趣的部分,它提供了一個函數:
void dynahook(void **p_old_vtbl, int method_count, converter_t cf),其中p_old_vtbl指向老版本對象的虛函數表,method_count指明表中有多少需要被監控的接口方法, cf是(新模塊提供的)用于轉換老版本對象格式的轉換函數指針,作用就是產生特殊的hook代碼來監控相應的接口方法調用。而這所謂特殊的hook代碼其實也很簡單,它的實現如下(80X86匯編,gnu assembler格式):
??? pushl %ebp????? ; 保存caller的stack frame base
??? movl %esp, %ebp ; 設置自己的stack frame base
??? pushl 8(%ebp)?? ; this指針進棧
??? call *converter ; 調用converter轉換函數,它應該為對象設置新的虛函數表指針
??? subl $4, %esp?? ; 調整棧頂
??? movl 8(%ebp), %eax ; this指針讀入eax
??? movl (%eax), %eax? ; (新版本的)虛函數表首地址讀入eax
??? addl $method_offset, %eax" ; 加上正確的接口函數偏移量
??? movl (%eax), %eax? ; (新版本的)虛函數入口地址讀入eax
??? leave????????????? ; 恢復ebp和esp,注意其后跟的并非通常的ret指令
??? jmp *%eax????????? ; 直接跳轉到(新版本的)接口函數的實現中
??? 上述代碼只是個模板,dynahook對每個需要hook的接口方法都會依照該模板產生一段幾乎一模一樣的機器碼,只是其中converter的地址和method_offset都會被重新設置,因為它們要在運行期才能確定,然后讓虛函數表中的函數指針指向這些動態生成的hook機器碼,這不就實現了動態監控嗎?特別要注意的是,hook的實現依賴于caller通過棧來傳遞接口方法的第一個(隱含)參數――this指針。g++能滿足這一點,可是Visual C++系列卻不這樣:即使沒有使用fastcall這樣的調用規范,它也會把this指針放在ecx寄存器中傳遞(以上結論來自對編譯器生成的匯編碼的觀察,如有錯誤請指正)。有興趣的讀者可以自己改一改這段hook代碼,使它也能適合Visual C++編譯器。
??? test.cpp演示了如何使用組件對象和熱替換,部分代碼如下:
??? nsIBase *o1 = (*create_v1)(); // 創建版本為1的組件對象
??? nsIBase *o2 = (*create_v1)(); // 創建版本為1的組件對象
??? printf("create objects of version 1: %p, %p\n",
?????????? o1,o2);
??? o1->Hello();? // 直接調用版本1的實現
??? o2->Foo();??? // 直接調用版本1的實現
??? // 熱替換
??? ......
??? o1->Hello(); // 將會先觸發轉換函數,再調用版本2的實現
??? o2->Foo();?? // 將會先觸發轉換函數,再調用版本2的實現
??? o1->Hello(); // 直接調用版本2的實現
??? o2->Hello(); // 直接調用版本2的實現
??? o1->Foo();?? // 直接調用版本2的實現
??? o2->Foo();?? // 直接調用版本2的實現
程序的輸出則是:
create objects of version 1: 00032BC8, 00032CE0
obj(00032BC8), version1, Hello(), m_data->a = 2008
obj(00032CE0), version1, Foo(), m_data->a = 2008
convert object(00032BC8) from version 1 to 2
obj(00032BC8) version 2, Hello(), m_data->a = 2008, m_data->b:77
convert object(00032CE0) from version 1 to 2
obj(00032CE0) version 2, Foo(), m_data->a:2008, m_data->b:77
obj(00032BC8) version 2, Hello(), m_data->a = 2008, m_data->b:77
obj(00032CE0) version 2, Hello(), m_data->a = 2008, m_data->b:77
obj(00032BC8) version 2, Foo(), m_data->a:2008, m_data->b:77
obj(00032CE0) version 2, Foo(), m_data->a:2008, m_data->b:77
??? 現在來談談這種方案的缺點和局限性。
??? 首先,方案要求新版本模塊能知道舊版本對象的二進制布局,這就意味著要有舊的組件對象的定義文件等。如果舊版本對象聚合了第三方的(非組件型)對象,而且對象的內部狀態又不提供接口復制,那么新版本的實現就沒有辦法替換掉第三方對象。這是一個極大的限制。
??? 其次,即使擁有舊的組件對象的定義,也需要保證每個模塊的關鍵編譯參數(比如結構體的字節對齊數)相同,否則轉換函數訪問舊版本對象的數據塊就是一件非常危險的事情。
??? 再者,為了能比較方便地做數據塊的格式轉換,要求每一個版本的實現不能直接內嵌數據成員,只能用一個指針指向一塊結構體,就象nsImp1這樣:
class nsImp1 : public nsIBase
{
public:
??? nsImp1();
??? virtual void Hello();
??? virtual void Foo();
protected:
??? struct data_t
??? {
??????? int a;
??? };
??? data_t *m_data;
};
這無疑使得代碼的編寫更繁瑣。
???
??? 最后,正確地更改虛表需要知道一些諸如接口包含多少方法之類的類型信息,而C++沒有標準的方法去取得,因此組件
框架提供的動態類型信息至關重要。
本文來自CSDN博客,轉載請標明出處:http://blog.csdn.net/soloist/archive/2008/06/23/2580396.aspx