2010年7月26日 星期一

(轉)決戰未解析的外部符號,C++實例教程,C++系列教程,C++

決戰未解析的外部符號,C++實例教程,C++系列教程,C++:
決戰「未解析的外部符號」
作者:Matt Pietrek

我用Delphi寫了一些32位代碼並把它編譯成了OBJ文件。我想 把這個Delphi代碼與由Visual C++ 編譯的C++代碼混合起來用。但是,我卻得到了類似下面的未解析符號的鏈接器錯 誤: D2.OBJ : error LNK2001: unresolved external symbol "MessageBeep"
有問題的函數看起來好像只有Windows API函數。然而,當我用Borland C++ 編譯時,一切工作正常。這到底是為什麼呢?


啊,鏈接器錯誤。這是我特別喜歡的方面。我花費了很多時間來解決這些非常耗時間的問題。現在,我已經有了一套行之有效的辦法來快速解決這些「未解析的外部符號」之類的問題。同時,如果你知道一些這方面的基本知識,那我解釋起來就不困難了。
可 能這只是我的感覺,但是我確實發現現在的程序員都使用非常好用的開發環境,很少有人知道它們的高級語言代碼是如何變成可執行的機器代碼的。像OBJ和 LIB之類的文件對大多數程序員來說都是黑盒。當一切工作正常時,你可能確實不需要知道從你的代碼被送到編譯器到在磁盤上產生一個可執行文件這段時間內到 底發生了什麼。但是,如果出現了一些什麼問題,這些黑盒很可能是就是你找到問題所在的惟一線索。
我要告訴你兩個關於C/C++ 編程的基本事實。我一直把問題歸結到這些事實上,並且總能找到答案(至少是在遇到「未解析的外部符號」這樣的鏈接器錯誤時是這樣)。第一個事實是,如果你跨編譯單元(一個文件就是一個編譯單元)引用符號,鏈接器看到的符號名必須完全匹配。
現 在給出一個具體的例子。假定你的一個源文件A.C中有函數Foo的實現代碼,並且,由A.C生成的OBJ文件中這個函數的名字還是Foo。用鏈接器的說法 就是,名字Foo是文件A.OBJ中的一個公共符號。現在,假定你在另外一個名字為B.CPP的文件中調用函數Foo。當你在B.CPP中調用函數Foo 時,編譯器並不知道函數Foo的實現代碼在哪裡。在這種情況下,編譯器在B.OBJ文件中生成一個記錄。這個記錄告訴鏈接器,它需要用函數Foo的真實地 址來修正對函數Foo的調用。這個記錄被稱為外部符號定義,因為函數Foo的位置是在調用它的源文件的外部。鏈接器的主要工作之一就是匹配,或者說是「解 析(resolve)」公共符號(像包含在文件A.OBJ中的公共符號)的外部定義(像文件B.OBJ中的符號Foo)。
在這個例子中,對鏈接器最重要的並不是在你的源文件中調用了什麼函數。相反,惟一重要的事就是公共符號的名字必須與外部名字完全匹配。如果它們並不完全匹配,你就會得到令人害怕的「未解析的外部符號」這樣的鏈接器錯誤。
第 二個基本事實是,編譯器背著你偷偷改變了符號的名字。例如,當生成OBJ文件時,C編譯器在符號的名稱前加一個下劃線再放入OBJ文件中。因此,在A.C 中的函數Foo在A.OBJ中的公共符號是_Foo。另外一個例子是當你使用C++時,編譯器把函數的參數信息也添加到了函數名上。在Visual C++ 中,函數「void Foo(int i)」變成了「?Foo@@YAXH@Z」。這種重命名方法被稱為名字粉碎(mangling或decorating),主要是為了讓鏈接器區分重載的函 數。(重載函數是名字相同,但參數不同的函數。記住這些,你就會理解鏈接器是怎樣處理重載的C++函數的。)
現在,我們的兩個事實說明公共符號與 外部符號的名字在鏈接階段必須匹配,還有就是,編譯器改變了符號名。當你遇到「未解析的外部符號」這樣的鏈接器消息時,要立即採取的行動再明顯不過了:找 出OBJ或LIB文件中的公共符號名,然後與鏈接器不能接受的符號名比較。它們幾乎總是不相同的,解決這個問題的方法就是讓這些符號名匹配。
回到前面的例子,假定函數Foo在B.CPP文件中的原型如下:
void Foo(int i);
如 果我鏈接A.OBJ與B.OBJ時,會有一個鏈接器錯誤,為什麼呢?因為在A.OBJ文件中,Foo的公共名字是_Foo,但是在B.OBJ文件(由 B.CPP生成)中被粉碎後的函數名字是?Foo@@YAXH@Z。這清楚地表明了那兩個事實:編譯器在兩個源文件中都改變了符號名,從而導致符號名不匹 配。

在這種情況下,你可以用extern 「C」機制來解決這個問題。也就是說,把B.CPP中的函數原型改成
extern void Foo(int i);

extern "C"告訴編譯器不要粉碎函數Foo的名字,按C編譯器的做法來(在OBJ文件中,放一個「_」在函數名前使它變成_Foo)。這樣,兩個名字匹配,從而解決了錯誤。
怎 樣才能知道OBJ文件中的外部符號名稱,從而改好自己的代碼呢?Visual C++ 附帶了一個DUMPBIN程序,它可以顯示由Visual C++創建的OBJ文件和LIB文件的內容(還有其它東西)。如果你運行DUMPBIN,記得要帶上/symbols參數才能看到所有的符號名。 Borland編譯器附帶了一個程序叫TDUMP,它可以用於Borland生成的OBJ文件和LIB文件。要想更容易地解決問題而不使用DUMPBIN 或TDUMP,繼續往下讀。我在本月專欄後面的部分提供了自己的工具。
如果要使基於Delphi的代碼與Visual C++ 共同工作,又該如何呢?很明顯,幾乎所有的Win32函數都被定義成__stdcall類型。除了還指示參數傳遞習慣外,__stdcall類型的函數的 名字已經被Visual C++ 修改得Delphi和Borland C++ 都不認識了。準確地說,Visual C++ 在__stdcall類型的函數的名字前加了一個「_」,在名字的最後加上了「@xxx」。xxx是所有實際通過堆棧傳遞給函數的參數的大小。因 此,MessageBeep(UINT uType)變成了_MessageBeep@4。同樣,GetMessageA,它帶了四個參數,變成了_GetMessageA@16。一些程序員把 這種重命名方法叫做__stdcall名字粉碎,但它與C++名字粉碎是截然不同的。
雖然Visual C++ 認為__stdcall類型的函數的名字已經被粉碎了,但Borland編譯器並不這麼認為。因此,Delphi生成的OBJ引用 MessageBeep,而MessageBeep不在Visual C++ 使用的USER32.LIB導入庫中,導入庫中的公共符號是_MessageBeep@4。Mirosoft鏈接器認為這兩個名字不匹配,因此產生了一個 鏈接器錯誤。如果你混合Borland C++ 代碼與Microsoft Visual C++ 代碼,你會遇到同樣的問題。
使事情更複雜的 是,當__stdcall類型的函數的名字出現在DLL的導出表中時,Microsoft並不粉碎它。在內部,Visual C++在你的OBJ文件中把MessageBeep函數粉碎成_MessageBeep@4,但是USER32.DLL(MessageBeep函數的代 碼就在其中)導出的名字卻是MessageBeep。這允許Borland編譯的代碼(它不粉碎__stdcall類型的函數的名字)可以正確地鏈接 Win32 DLL的導出函數。也就是說,當把名字放入DLL的導出表中時,Visual C++ 去掉了前導的「_」和後續的 「@xxx」。
怎 樣才能混合使用這兩個廠商的代碼呢?不幸的是,沒有什麼我們能做的。你的第一反應可能是在Delphi代碼中調用函數_MessageBeep@4。同樣 不幸的是,在Delphi(或C++)中,字符「@」是不合法的,因此這樣的代碼不能編譯。直到編譯器廠商開始行動之前,我們只有忍耐。


不知出於什麼原因,我不能在Microsoft和Borland的32位編譯器之間混合使用OBJ文件和LIB文件。然而,在16位編譯器上可以正常工作。這到底是為什麼呢?


讓 我們先把目光對準OBJ文件,然後再說LIB文件。從PC出現到第一個Microsoft Win32編程工具出現,幾乎所有編譯器生成的OBJ文件都是Intel OMF格式。與OMF格式的OBJ文件打交道並不是一件輕鬆的事,因此,我並沒有打算詳細描述它。最初的Windows NT開發小組使用的OBJ文件格式被稱為通用目標文件格式(Common Object File Format,COFF)而COFF格式是UNIX System V的正式機器代碼格式使用COFF相對容易。COFF格式的OBJ與可移植可執行(Portable Executable,PE)文件的格式非常接近,而可移植可執行文件格式又是Win32的可執行文件格式。COFF格式的鏈接器從COFF格式的文件創 建EXE或DLL需要做的工作比從Intel OMF格式的文件要少。
就像有OMF和COFF格式的OBJ文件一樣,LIB文件也有OMF格式與COFF格式之分。幸運的是,這兩種格式的LIB文件都是僅僅把相應格式的一些OBJ文件放在一起組成的單個文件。專用記錄中的附加信息可以讓鏈接器快速從LIB文件中找到所需的OBJ文件。
混 合使用不同編譯器廠商的OBJ文件和LIB文件的問題是,並非每個廠商都把它的32位編譯器轉換到了COFF格式。Borland和Symantec仍舊 使用OMF格式的OBJ文件和LIB文件,但是Microsoft的32位編譯器生成COFF格式的OBJ文件和LIB文件。MASM 6.11默認情況下生成OMF格式的文件令人感到困惑,但使用/coff開關可以生成COFF格式的OBJ文件。
當鏈接不同格式的文件時,每個人 可以猜猜鏈接器會做什麼。例如,如果需要,Visual C++ 鏈接器可以把OMF格式的OBJ文件轉換成COFF格式,但它遇到OMF格式的LIB文件時就拒絕工作。Borland的TLINK始終拒絕使用COFF 格式的OBJ文件和LIB文件,Symantec C++ 7.2也是如此。Watcom 10.5好像選擇的是COFF。結果混合不同編譯器生成的文件經常造成混亂。鏈接器產生的模糊的錯誤信息並幫不了什麼忙。
即使你不混合使用不同編 譯器生成的OBJ文件,你仍然會在混合使用由不同編譯器生成的EXE和DLL時遇到問題。問題來自不同的導入庫,這些導入庫是一些非常小的OBJ文件的集 合,能夠告訴鏈接器某個特定的函數在正在鏈接的EXE或DLL之外的哪個DLL中。如果你提供了一個DLL,但不知道使用這個DLL的用戶使用的是哪個編 譯器,這樣,不同的LIB文件格式就會導致問題。大多數情況下你都得提供兩種不同格式的導入庫,一種是COFF格式,另一種是OMF格式。問題是,你怎樣 才能創建這些導入庫呢?
如果你曾為Windows 3.x編過程序,你可能使用過編譯器附帶的一個叫做IMPLIB的工具。IMPLIB接受一個DLL作為輸入,生成一個OMF格式的導入庫。IMPLIB 是通過讀取它處理的DLL的導出節來達到上述效果的。因此,如果你使用像Borland C++ 或Symantec C++ 之類的編譯器,你可以在任何你想鏈接的DLL上運行IMPLIB,這樣就能得到合適格式的LIB文件。
可惜!32位版的Visual C++ 並沒有附帶像IMPLIB之類的工具。這是為什麼呢?一個很好的解釋就是由於文章前面提到的__stdcall類型的函數的名字粉碎。DLL導出的函數名 字並不包含任何有關此函數所帶參數個數的信息,因此,假定有這樣一個IMPLIB,它也不知道怎樣生成合適的__stdcall類型的名字(例 如,_MessageBeep@4)。
幸運的是,在有些情況下,你可以使用一些鮮為人知技巧。不過這有些亂,並且僅適用於_cdecl類型的函 數,不適用於__stdcall類型的函數。如果你想鏈接到某個DLL上,就創建一個相應的DEF文件。在這個DEF文件中,有一個EXPORTS節,所 有需要包含在生成的LIB文件中的函數的名字都要在這個節中。不要在名字前加一個「_」字符,因為它會被自動加上。創建完DEF文件後,運行 Microsoft的32位LIB工具,帶上/MACHINE和/DEF選項。例如,要為MYDLL.DLL創建一個導入庫,你可以先創建 MYDLL.DEF文件,然後運行
LIB /MACHINE:i386 /DEF:MYDLL.DEF
如果一切順利,這會創建一個名字叫MYDLL.LIB的COFF格式的導入庫,。

OBJHELP程序
由 於本月的專欄中回答的兩個問題都牽涉到OBJ、LIB以及它們中的符號,所以我寫了一個叫OBJHELP的工具。不管是OBJ文件還是LIB文件,也不管 是COFF格式還是Intel-OMF格式,OBJHELP都能顯示出文件的類型。更重要的是,OBJHELP能顯示出文件中的公共符號和外部符號的準確 名字。這對你跟蹤解決「未解析的外部符號」之類的鏈接器錯誤是非常有幫助的。例如,你可以運行OBJHELP的兩個實例。一個檢查有未解析的外部符號的 OBJ文件。另一個顯示你認為所需代碼應該在其中的庫或OBJ文件。如果名字不匹配,你的鏈接器可能不合適。
圖1是一幅OBJHELP實際工作的 畫面。頂端左邊的編輯框中顯示當前正在被顯示的文件名稱。你可以以三種方式選擇文件:鍵入文件名,然後回車;使用「Browse」按鈕來瀏覽;或者你也可 以把OBJ或LIB文件拖到OBJHELP窗口。(哎,我不得不為此學習拖放操作!)

圖1 OBJHELP程序
文件信息被顯示在兩個列表框中。上面的列表框顯示文件中所有的公共符號,下面的列表框顯示所有的外部符號。如果一些符號名看起來特別彆扭,那很可能是C++的名字粉碎所致。我有意不把那些名字還原為它們的本來面目,因為我想讓你看一看鏈接器在解析符號時都看到了什麼。
關 於你看到的外部符號有一些重要的注意事項。首選,如果你顯示一個LIB文件,那麼相同的符號可能在「Extrens」列表框中顯示多次。當LIB文件中包 含多個OBJ文件,並且它們中好幾個引用相同的外部函數時會出現這種情況。第二,當抓取COFF格式的導入庫時,函數名可能有一個「__imp__」前綴 (例如,__imp__GetFocus@0)。這是在函數定義時使用__declspec(dllimport)編譯器指令產生的。我在1995年十二 月的專欄中講過__declspec(dllimport)的工作原理,因此,我不打算再重複。
當顯示OMF格式的導入庫時,OBJHELP把符 號所在的DLL放在符號名前面(例如,USER32.dll.GETFOCUS)。我之所以這麼做是因為Borland編譯器把許多系統DLL的導入信息 組合成單個的庫(IMPORT32.LIB)。相反,Microsoft和Symantec為每一個DLL生成一個導入庫 (KERNEL32.LIB,USER32.LIB等等)。
OBJHELP代碼(如圖2所示)可以分成兩部分。用戶界面代碼在 OBJHELP.CPP中。這是相當簡單的基於對話框的用戶界面。其餘的文件用於鑑別文件類型以及找出文件中的公共符號與外部符號。可能大多數程序員對 OBJ文件和LIB文件的格式不是很感興趣。所以我就不詳細描述了。誰要是感興趣可以讀一讀源代碼(加了詳細的註釋)。

沒有留言:

張貼留言