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