程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> VC >> vc教程 >> 經典編程:DLL地獄及其解決方案

經典編程:DLL地獄及其解決方案

編輯:vc教程

  原作者:Ivan S Zapreev
  
  譯者:陸其明
  
  概要
  
  本文將要介紹DLL的向後兼容性問題,也就是著名的“DLL Hell”問題。首先我會列出自己的研究結果,其中包括其它一些研究者的成果。在本文的最後,我還將給出“DLL Hell”問題的一個解決方案。
  
  介紹
  
  我曾經接受過一個任務,去解決一個DLL版本更新的問題————某個公司給用戶提供了一套SDK,這個SDK是由一系列DLL組成的;DLL中導出了很多類,用戶使用這些類(直接使用或派生新的子類)來繼續他們的C++程序開發。用戶在使用這些DLL時沒有得到很詳細的使用說明(比如使用這些DLL中導出的類有什麼限制等)。當這些DLL更新為新的版本之後,他們發現他們開發的基於這些DLL的應用程序會經常崩潰(他們的應用程序從SDK的導出類派生了新的子類)。為了解決這個問題,用戶必須重新編譯他們的應用程序,重新連接新版本的SDK DLL。
  
  我將對這個問題給出我的研究結果,同時還有我從其它地方搜集過來的相關信息。最後,我將來解決這個“DLL Hell”問題。
  
  研究結果
  
就我個人的理解,這個問題是由SDK DLL中導出的基類改動之後引起的。我查看了一些文章後發現,DLL的向後兼容性問題其實早有人提出。但作為一個實在的研究者,我決定自己做一些試驗。結果,我發現如下的問題:
  
  1. 在DLL的導出類中增加一個新的虛函數將導致如下問題:
   (1)如果這個類以前就有一個虛函數B,此時在它之前增加一個新的虛函數A。這樣,我們改變了類的虛函數表。於是,表中的第一個函數指向了函數A(而不是原來的B)。此時,客戶程序(假設沒有在拿到新版本的DLL之後重新編譯、連接)調用函數B就會產生異常。因為此時調用函數B實際上轉向了調用函數A,而如果函數A和函數B的參數類型、返回值類型迥異的話問題就出來了!
   (2)如果這個類原本沒有虛函數(它的父類也沒有虛函數),那麼給這個類增加一個新的虛函數(或者在它的父類增加一個虛函數)將導致新增加一個類成員,這個成員是一個指針類型的,指向虛函數表。於是,這個類的尺寸將會被改變(因為增加了一個成員變量)。這種情況下,客戶程序如果創建了這個類的實例,並且需要直接或間接修改類成員的值的時候就會有問題了。因為虛函數表的指針是作為類的第一個成員加入的,也就是說,原本這個類定義的成員因為虛函數表指針的加入而都產生了地址的偏移。客戶程序對原成員的操作自然就出現異常了。
   (3)如果這個類原本就有虛函數(或者只要它的父類有虛函數),而且這個類被導出了,被客戶程序當作父類來用。那麼,我們不要給這個類增加虛函數!不僅在類聲明的開頭不能加,即使在末尾處也不能加。因為加入虛函數會導致虛函數表內的函數映射產生偏移;即使你將虛函數加在類聲明的末尾,這個類的派生類的虛函數表也會因此產生偏移。
  
  2. 在DLL的導出類中增加一個新的成員變量將導致如下問題:
   (1)給一個類增加一個成員變量將導致類尺寸的改變(給原本有虛函數表的類增加一個虛函數將不會改變類的尺寸)。假設這個成員增加在類聲明的最後。如果客戶程序為創建這個類的實例少分配了內存,那麼可能在訪問這個成員時導致內存越界。
   (2)如果在原有的類成員中間增加一個新的成員,情況會更糟糕。因為這樣會導致原有類成員的地址產生偏移。客戶程序操作的是一個錯誤的地址表,對於新成員後面的成員尤其是這樣(它們都因為新成員的加入而導致了自己在類中的偏移的變化)。
  
  (注:上述的客戶程序就是指使用SDK DLL的應用程序。)
  
  除了上面這些原因外,還有其它操作會導致DLL的向後兼容性問題。下面列出了解決(大部分)這些問題的方法。
  
  DLL編碼約定簡述
  
  下面是我搜集到的所有的解決方案,其中一些是從網上的文章中拿來的,一些是跟不同的開發者交流後得到的。
  
  下面的約定主要針對DLL開發,而且是為解決DLL的向後兼容性問題:

  1. 編碼約定:
   (1)DLL的每個導出類(或者它的父類)至少包含一個虛函數。這樣,這個類就會始終保存一個指向虛函數表的指針成員。這麼做可以方便後來新的虛函數的加入。
   (2)如果你要給一個類增加一個虛函數,那麼將它加在所有其它虛函數的後面。這樣就不會改變虛函數表中原有函數的地址映射順序。
   (3)如果你打算以後給一個類擴充類成員,那麼現在預留一個指向一個數據結構的指針。這樣的話,增加一個成員直接在這個數據結構中修改,而不是在類中修改。於是,新成員的加入不會導致類尺寸的改變。當然,為了訪問新成員,需要給這個類定義幾個操作函數。這種情況下,DLL必須是被客戶程序隱式(implicitly)連接的。
   (4)為了解決前一點的問題,也可以給所有的導出類設計一個純接口的類,但此時,客戶程序將無法從這些導出類繼續派生,DLL導出類的層次機構也將無法維持。
   (5)發布兩個版本的DLL和LIB文件(Debug版本和Release版本)。因為如果只發布Release版本,開發者將無法調試他們的程序,因為Release版與Debug版使用了不同的堆(Heap)管理器,因而當Debug版本的客戶程序釋放Release版本DLL申請的內存時,會導致運行時錯誤(Runtime failure)。有一種辦法可以解決這個問題,就是DLL同時提供申請和釋放內存的函數供客戶程序調用;DLL中也保證不釋放客戶程序申請的內容。通常遵守這個約定不是那麼簡單!
   (6)在編譯的時候,不要改變DLL導出類函數的默認參數,如果這些參數將被傳遞到客戶程序的話。
   (7)注意內聯(inline)函數的更改。
   (8)檢查所有的枚舉沒有默認的元素值。因為當增加/刪除一個新的枚舉成員,你可能移動舊枚舉成員的值。這就是為什麼每一個成員應該擁有一個唯一標識值。如果枚舉可以被擴展,也應該對其進行文檔說明。這樣,客戶程序開發者就會引起注意。
   (9)不要改變DLL提供的頭文件中定義的宏。
  
  2. 對DLL進行版本控制:如果主要的DLL發生了改變,最好同時將DLL文件的名字也改掉,就象微軟的MFC DLL一樣。例如,DLL文件可以按照如下格式命名:Dll_name_xx.dll,其中xx就是DLL的版本號。有時候DLL中做了很大的改動,使得向後兼容性問題無法解決。此時應該生成一個全新的DLL。將這個新DLL安裝到系統時,舊的DLL仍然保留。於是,舊的客戶程序仍然能夠使用舊的DLL,而新的客戶程序(使用新DLL編譯、連接)可以使用新的DLL,兩者互不干涉。
  
  3. DLL的向後兼容性測試:還有很多很多中可能會破壞DLL的向後兼容性,因此實施DLL的向後兼容性測試是非常必要的!
  
  接下去,我將來討論一個虛函數的問題,以及對應的一個解決方案。
  
  虛函數與繼承
  
  首先來看一下如下的虛函數和繼承結構:
  
  /**********DLL導出的類 **********/
  class EXPORT_DLL_PREFIX VirtFunctClass{
  public:
   VirtFunctClass(){}
   ~VirtFunctClass(){}
   virtual void DOSmth(){
   //this->DoAnything();
   // Uncomment of this line after the corresponding method
   //will be added to the class declaration
   }
   //virtual void DoAnything(){}
   // Adding of this virtual method will make shift in
   // table of virtual methods
  };
  
  /**********客戶程序,從DLL導出類派生一個新的子類**********/
  class VirtFunctClassChild : public VirtFunctClass {
  public:
   VirtFunctClassChild() : VirtFunctClass (){}
   ~VirtFunctClassChild(){};
   virtual void DOSomething(){}
  };

  假設上面的兩個類,VirtFunctClass在my.dll中實現,而VirtFunctClassChild在客戶程序中實現。接下去,我們做一些改變,將如下兩個注釋行放開:
  //virtual void DoAnything(){}
  和
  //this->DoAnything();
  
  也就是說,DLL導出的類作了改動!現在如果客戶程序沒有重新編譯,那麼客戶程序中的VirtFunctClassChild將不知道DLL中VirtFunctClass類已經改變了:增加了一個虛函數void DoAnything()。因此,VirtFunctClassChild類的虛函數表仍然包含兩個函數的映射:
  1. void DOSmth()
  2. void DOSomething()
  
  而事實上這已經不對了,正確的虛函數表應該是:
  1. void DOSmth()
  2. void DoAnything()
  3. void DOSomething()
  
  問題就在於,當實例化VirtFunctClassChild之後,如果調用它的void DoSmth()函數,DoSmth()函數轉而要調用void DoAnything()函數,但此時基類VirtFunctClass只知道要調用虛函數表中的第二個函數,而VirtFunctClassChild類的虛函數表中的第二個函數仍然是void DOSomething(),於是問題就出來了!
  
  另外,禁止在DLL的導出類的派生類(上例中的VirtFunctClassChild)中增加虛函數也是於事無補的。因為,如果VirtFunctClassChild類中沒有virtual void DOSomething()函數,基類中的void DoAnything()函數(虛函數表中的第二個函數)調用將會指向一個空的內存地址(因為VirtFunctClassChild類維持的虛函數表僅僅維持有一個函數地址)。
  現在可以看出,在DLL的導出類中增加虛函數是一個多麼嚴重的問題!不過,如果虛函數是用來處理回調事件的,我們有辦法來解決這個問題(下文有介紹)。
  
  COM及其它
  
  現在可以看出,DLL的向後兼容性問題是一個很出名的問題。解決這些問題,不僅可以借助於一些約定,而且可以通過其它一些先進的技術,比如COM技術。因此,如果你想擺脫“DLL Hell”問題,請使用COM技術或者其它一些合適的技術。
  
  讓我們回到我接受的那個任務(我在本文開頭的地方講到的那個任務)————解決一個使用DLL的產品的向後兼容性問題。
  
  我對COM有些了解,因此我的第一個建議是使用COM技術來克服那個項目中的所有問題。但這個建議因為如下原因最終被否決了:
  1. 那個產品已經在某個內部層中有一個COM服務器。
  2. 將一大堆接口類重寫到COM的形式,投入比較大。
  3. 因為那個產品是DLL庫,而且已經有很多應用程序在使用它了。因此,他們不想強制他們的客戶重寫他們的應用程序。
  
  換句話說,我被要求完成的任務是,以最小的代價來解決這個DLL向後兼容性問題。當然,我應該指出,這個項目最主要的問題在於增加新的成員和接口類上的虛回調函數。第一個問題可以簡單地通過在類聲明中增加一個指向一個數據結構的指針來解決(這樣可以任意增加新的成員)。這種方法我在上面已經提到過。但是第二個問題,虛回調函數的問題是新提出的。因此,我提出了下面的最小代價、最有效的解決方法。
  
  虛回調函數與繼承
  
  然我們想象一下,我們有一個DLL,它導出了幾個類;客戶應用程序會從這些導出類派生新的類,以實現虛函數來處理回調事件。我們想在DLL中做一個很小的改動。這個改動允許我們將來可以給導出類“無痛地”增加新的虛回調函數。同時,我們也不想影響使用當前版本DLL的應用程序。我們期望的就是,這些應用程序只有在不得已的時候才協同新版本的DLL進行一次重新編譯。因此,我給出了下面的解決方案:
  
  我們可以保留DLL導出類中的每個虛回調函數。我們只需記住,在任何一個類定義中增加一個新的虛函數,如果應用程序不協同新版本的DLL重新編譯,將導致嚴重的問題。我們所做的,就是想要避免這個問題。這裡我們可以一個“監聽”機制。如果在DLL導出類中定義並導出的虛函數被用作處理回調,我們可以將這些虛函數轉移到獨立的接口中去。
    讓我們來看下面的例子:
  
  // 如果想要測試改動過的DLL,請將下面的定義放開
  //#define DLL_EXAMPLE_MODIFIED
  
  #ifdef DLL_EXPORT
   #define DLL_PREFIX __declspec(dllexport)
  #else
   #define DLL_PREFIX __declspec(dllimport)
  #endif
  
  /********** DLL的導出類 **********/
  #define CLASS_UIID_DEF static short GetClassUIID(){return 0;}
  #define OBJECT_UIID_DEF virtual short
   GetObjectUIID(){return this->GetClassUIID();}
  
  // 所有回調處理的基本接口
  struct DLL_PREFIX ICallBack
  {
   CLASS_UIID_DEF
   OBJECT_UIID_DEF
  };
  
  #undef CLASS_UIID_DEF
  
  #define CLASS_UIID_DEF(X) public: static
   short GetClassUIID(){return X::GetClassUIID()+1;}
  
  // 僅當DLL_EXAMPLE_MODIFIED宏已經定義的時候,進行接口擴展
  #if defined(DLL_EXAMPLE_MODIFIED)
  // 新增加的接口擴展
  struct DLL_PREFIX ICallBack01 : public ICallBack
  {
   CLASS_UIID_DEF(ICallBack)
   OBJECT_UIID_DEF
   virtual void DoCallBack01(int event) = 0; // 新的回調函數
  };

  #endif // defined(DLL_EXAMPLE_MODIFIED)
  
  class DLL_PREFIX CExample{
  public:
   CExample(){mpHandler = 0;}
   virtual ~CExample(){}
   virtual void DoCallBack(int event) = 0;
   ICallBack * SetCallBackHandler(ICallBack *handler);
   void Run();
  private:
   ICallBack * mpHandler;
  };
  
  很顯然,為了給擴展DLL的導出類(增加新的虛函數)提供方便,我們必須做如下工作:
  1. 增加ICallBack * SetCallBackHandler(ICallBack *handler);函數;
  2. 在每個導出類的定義中增加相應的指針;
  3. 定義3個宏;
  4. 定義一個通用的ICallBack接口。
  
  為了演示給CExample類增加新的虛回調函數,我在這裡增加了一個ICallBack01接口的定義。很顯然,新的虛回調函數應該加在新的接口中。每次DLL更新都新增一個接口(當然,每次DLL更新時,我們也可以給一個類同時增加多個虛回調函數)。
  
  注意,每個新接口必須從上一個版本的接口繼承。在我的例子中,我只定義了一個擴展接口ICallBack01。如果DLL再下個版本還要增加新的虛回調函數,我們可以在定義一個ICallBack02接口,注意ICallBack02接口要從ICallBack01接口派生,就跟當初ICallBack01接口是從ICallBack接口派生的一樣。
  
  上面代碼中還定義了幾個宏,用於定義需要檢查接口版本的函數。例如我們要為新接口ICallBack01增加新函數DoCallBack01,如果我們要調用ICallBack * mpHandler; 成員的話,就應該在CExample類進行一下檢查。這個檢查應該如下實現:
  
  if(mpHandler != NULL && mpHandler->GetObjectUIID()>=ICallBack01::GetClassUIID()){
   ((ICallBack01 *) mpHandler)->DoCallBack01(2);
  }
  
  我們看到,新回調接口增加之後,在CExample類的實現中只需簡單地插入新的回調調用。
  
  現在你可以看出,我們上述對DLL的改動並不會影響客戶應用程序。唯一需要做的,只是在采用這種新設計後的第一個DLL版本(為DLL導出類增加了宏定義、回調基本接口ICallBack、設置回調處理的SetCallBackHandler函數,以及ICallBack接口的指針)發布後,應用程序進行一次重編譯。(以後擴展新的回調接口,應用程序的重新編譯不是必需的!)
  
  以後如果有人想要增加新的回調處理,他就可以通過增加新接口的方式來實現(向上例中我們增加ICallBack01一樣)。顯然,這種改動不會引起任何問題,因為虛函數的順序並沒有改變。因此應用程序仍然以以前的方式運行。唯一你要注意的是,除非你在應用程序中實現了新的接口,否則你就接收不到新增加的回調調用。
  
  我們應該注意到,DLL的用戶仍然能夠很容易與它協同工作。下面是客戶程序中的某個類的實現例子:
  
  // 如果DLL_EXAMPLE_MODIFIED沒有定義,使用以前版本的DLL
  #if !defined(DLL_EXAMPLE_MODIFIED)
  // 此時沒有使用擴展接口ICallBack01
  class CClIEnt : public CExample{
  public:
   CClIEnt();
   void DoCallBack(int event);
  };
  
  #else // !defined(DLL_EXAMPLE_MODIFIED)
  // 當DLL增加了新接口ICallBack01後,客戶程序可以修改自己的類
  // (但不是必須的,如果他不想處理新的回調事件的話)
  class CClIEnt : public CExample, public ICallBack01{
  public:
   CClIEnt();
   void DoCallBack(int event);
  
   // 聲明DoCallBack01函數(客戶程序要實現它,以處理新的回調事件)
   // (DoCallBack01是ICallBack01接口新增加的虛函數)
   void DoCallBack01(int event);
  };
  #endif // defined(DLL_EXAMPLE_MODIFIED)
  
  例程 ---> 代碼下載(6.26K)
  
  與本文的內容配套,我提供了演示程序Dll_Hell_Solution。
  
  1. Dll_example: DLL的實現項目;
  2. Dll_ClIEnt_example: DLL的客戶應用程序項目。
  
  注意:目前Dll_Hell_Solution/Dll_example/dll_example.h文件中的DLL_EXAMPLE_MODIFIED定義被注釋掉了。如果放開這個注釋,可以生成更新後的DLL版本;然後可以再次測試客戶應用程序。
  
  為了保證讀者能夠正常演示,請遵循如下步驟:
  1. 不要改動任何代碼(此時DLL_EXAMPLE_MODIFIED沒有定義)編譯Dll_example和Dll_ClIEnt_example兩個項目。運行客戶程序,體驗最初的情況。
  2. 放開DLL_EXAMPLE_MODIFIED的注釋,然後重新編譯Dll_example。重新運行客戶程序(此時使用了新版本的DLL),應該仍然運行正常。
  3. 重新編譯Dll_ClIEnt_example,生成新的客戶程序。我們看到新增加的回調函數被調用了!

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved