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

來源:互聯網
上載者:User

原作者:Ivan S Zapreev
譯者:陸其明

轉自http://tech.acnow.net/Html/Program/VC/DLL/2004-5/17/041435538.shtml
  
  概要
  
       本文將要介紹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,產生新的客戶程式。我們看到新增加的回呼函數被調用了!
 

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.