Linux動態庫(一)之同名符號

萬事皆有緣由,還是先從我遇到的這個問題說起~~~

問:有一個主執行程序main,其中實現了函數foo(),同時調用動態庫liba.so中的函數bar(),而動態庫liba.so中也實現了foo()函數,那麼在執行的時候如果在bar()中調用foo()會調用到哪一個?在main()中調用呢?

直 接給答案:如果是在Linux上,liba.so中的foo()函數是一個導出的(extern)”可見”函數,那麼調用會落入主程序裏,這對於 liba.so的作者來說實在是個災難,自己辛辛苦苦的工作竟然被自己人視而不見,偏偏誤入歧途,偏偏那個歧途“看起來”(declaration)和自 己完全一樣,但表裏(definition)不一的後果就是程序出錯或者直接crash~~~

到這裏故事講完了,只想知道結論的可以離開了,覺得不爽的別忙着扔臭雞蛋,下面待我從頭慢慢敘來。

先貼代碼:

main.cpp

#include <stdio.h>
#include <stdlib.h>
#include "mylib.h"

extern "C" void foo() {
        printf
("foo in main/n");
}

int main() {
        bar
();
       
return 0;
}

mylib.cpp

#include "mylib.h"
#include <stdio.h>
#include <stdlib.h>
#include "mylib_i.h"

void bar() {
        foo
();
}

mylib_i.cpp

#include "mylib_i.h"
#include <stdio.h>
#include <stdlib.h>

extern "C" void foo() {
        printf
("foo in lib/n");
}

Makefile

all:
        g
++ -c -o mylib_i.o mylib_i.cpp
        g
++ -c -o mylib.o mylib.cpp
        g
++ -shared -o libmylib.so mylib.o mylib_i.o
        g
++ -c -o main.o main.cpp
        g
++ -o main main.o -L. -lmylib

代碼很簡單(沒有貼header文件),結果也很簡單,正如前面所說,會輸出foo in main。

那麼,爲什麼會這樣呢?

看一下libmylib.so裏的東西:

[root@zouf testlib]# objdump -t libmylib.so |grep "foo|bar"
[root@zouf testlib]# objdump -t libmylib.so |egrep "foo|bar"
00000614 g     F .text  00000034              foo
000005f4 g     F .text  0000001e              bar
[root@zouf testlib]# nm libmylib.so |egrep "foo|bar"
000005f4 T bar
00000614 T foo

我們看到libmylib.so中導出了兩個全局符號(global)foo和bar,而Linux中動態運行庫的符號是在運行時進行重定位的,我們可以用objdump -R看到libmylib.so的重定位表,中間有foo符號,爲什麼沒有bar符號呢?

這裏有點複雜了,先扯開談一下另一個問題,即地址重定位發生的時機,而在這之前先看無處不在的符號的問題,事實上我們的C/C++程序從可讀的代碼 變成計算機可執行的進程,中間經過了很多步驟:編譯、鏈接、加載、最後纔是真正運行我們的代碼。C/C++代碼的變量函數等符號最後怎麼變成正確的內存中 地址,這個和符號相關的問題牽涉到很多:這裏和我們相關的主要是兩個話題:符號的查找(resolve,或者叫決議、綁定、解析)和符號的重定位 (relocation)

符號的查找/綁定在每一個步驟中都可能會發生(嚴格說編譯時並不是符號的綁定,它只會牽涉到局部符號(static)的綁定,它是基於token的語法和語義層面的“綁定”)。

鏈接時的符號綁定做了很多工作,這也是我們經常看到ld會返回"undefined reference to …"或"unresolved symbol …",這就是ld找不到符號,完成不了綁定工作,只能向我們抱怨老,但並不是所有的鏈接時都會把符號綁定進行到底的,只要在生成最終的執行程序之前把所有 的符號全部綁定完成就可以了。這樣我們可以理解爲什麼鏈接動態庫或靜態庫是即使有符號找不到也不會報錯(但如果是局部符號找不到那時一定會報錯的,因爲鏈 接器知道已經沒有機會再找到了)。當然靜態鏈接(ar)和動態鏈接(ld)在這方面有着衆多生理以及心理、外在以及本源的差別,比如後面系列會提到的弱符 號解析、地址無關代碼等等,這裏按下不表。

由於編譯和鏈接都不能保證所有符號都已經解析,因此我們通過nm查看.o或者.a或者.so文件時會看到U符號,即undefined符號,那都是待字閨中的代表(無視剩女的後果是當你生成最終的執行程序時報告unresolved symbol…)

加載時的綁定,其實加載時對於符號的綁定和鏈接對應的執行程序做的事情基本類似,需要重複勞動的原因是OS無法保證加載程序時當初的原配是否還在,通過綁定來找到符號在那個模塊的那個地址。

既然加載時都已經綁定了,那爲什麼運行時還要?唉,懶惰的OS總是抱着僥倖的心理想可能有些符號不需要綁定,加載時就不理它們了,直到運行時不能不用時火燒眉頭再來綁定,此所謂延遲綁定,在Linux裏通過PLT實現。

另一個符號的概念是重定位(relocation),似乎這個概念和符號的綁定是很相關、甚至有點重疊的。我的理解,符號的綁定一般包含了重定位這 樣一個操作(但也不絕對,比如局部符號就沒有重定位的發生),而要完成重定位則需要符號的查找先。一般而言,我們更多地提重定位是在加載和運行的時候。嚴 格的區分,嗯,我也說不清,哪位大大解釋一下?

所以類似地,重定位也可以分爲

  • 鏈接時重定位
  • 加載時重定位
  • 運行時重定位

所有重定位都要依賴與重定位表(relocation table),可以通過objdump –r/-R看到,就是前文中提到objdump –R libmylib.so會看到foo這個符號需要重定位,爲什麼呢?因爲鏈接器發現libmylib.so中的bar()函數調用了foo()函數,而 foo()也可能會被外面的函數調用到(extern函數),所以鏈接器得讓它在加載或運行時再次重定位以完成綁定。那爲什麼沒有bar()函數在重定位 表中呢?ld在鏈接libmylib.so時還沒看到任何它需要加載/運行時重定位的跡象,那就放過吧,但是,main…我要用啊,試試objdum –R main?OK,果然ld在這時候把它加上了。

所以,foo/bar這兩個函數都需要在加載/運行時進行重定位。那究竟是加載還是運行時呢?前面已經提過,就是PLT的差別,具體到編譯選項,是 -fPIC,也就是地址無關代碼,如果編譯.o文件時有-fPIC,那就會是PLT代碼,即運行時重定位,如果只在鏈接時指定-shared,那就是加載 時運行咯。搞這麼複雜的目的有兩個:一是杜絕浪費,二是爲了COW。Windows上採用的就是加載時重定位,並且引入了所謂基地址重置 (rebasing)來解決共享問題。糟糕,劇透了,打住~~~

這篇我們編譯的時候沒有帶-fPIC參數,所以重定位會發生在程序加載的時候。

OK,說了這麼多,其實只是想說明:對於用到的動態庫中的extern符號,在加載/運行時會發生重定位(如果我們注意前面的結果其實會看到中間還有printf,也就是用到的libc.so中的符號,同樣它也會在加載/運行時被重定位)。

可是,這個和最初提到的問題有啥關係?然,正是重定位導致了問題的出現!

這個就源於加載器對於重定位的實現邏輯了(重定位這個操作是由加載器完成,也就是我們在ldd main是會看到的/lib/ld-linux.so.2,它裏面實現了一個重要的函數_dl_runtime_resolve,會真正完成重定位的邏輯)

加載器在進行符號解析和重定位時,會把找到的符號及對應的模塊和地址放入一個全局符號表(Global Symbol Table),但GST中是不能放多個同名的符號的,否則使用的人就要抓狂了,所以加載器在往全局符號表中加入item時會解決同名符號衝突的問題,即引入符號優先級,原則其實很簡單:當一個符號需要被加入全局符號表時,如果相同的符號名已經存在,則後加入的符號被忽略。所以在Linux中,發生全局符號衝突真是太正常了~~~,主執行程序中的符號可能會覆蓋動態庫中的符號(因爲主程序肯定是第一個加載的),甚至動態庫a中符號也會覆蓋動態庫b中的符號。這又被稱爲共享對象全局符號介入(Global Symbol Interpose)

當然,對於動態庫和動態庫中的符號衝突,又牽涉到另一個問題,即動態庫的加載順序問題,這可是生死攸關的大問題,畢竟誰在前面誰就說話算數啊。加載 器的原則是廣度優先(BFS),即首先加載主程序,然後加載所有主程序顯式依賴所有動態庫,然後加載動態庫們依賴的動態庫,直到全部加載完畢。

當然,這邊還有個動態加載動態庫(dlopen)的問題,原則也是一樣,真正的邏輯同樣發生在_dl_runtime_resolve中。

好,到這裏這個問題的來龍去脈已經搞清楚了。應該可以清晰地回答同名函數的執行究竟是怎樣這樣一個問題了。至於http://wf.xplore.cn/post/test_lib.php中提到的動態庫和靜態庫同名衝突的問題,可以看成是這裏的一個特例,原則並不是靜態庫優先,而是主程序優先,因爲靜態庫被靜態鏈接進了主程序,所以“看起來”被優先了。

那麼,這個行爲是好是壞呢?在我看來很痛苦~~~因爲這個衝突gdb半天可不是鬧着玩的~~~C/C++中的全局命名空間污染問題由來已久,也糾結 很多很多人,《C++大規模程序開發》中也給出很多實務的方法解決這個問題。那這個行爲就一點好處沒有?也未必,比如可以很方便地hook,通過控制動態 庫的加載循序可以用自己的代碼替換掉其他人庫裏的代碼,應該可以實現很酷的功能(比如LD_PRELOAD),不清楚valgrind是不是就是通過這種 方法實現?hook住malloc/free/new/delete應該可以~~~

但總的看起來這個行爲還是很危險的,特別是如果你鴕鳥了,那它就會在最不經意間刺出一刀,然後讓你話費巨大的精力查找、修改(對於大型項目查找很難,修改也很難)

那難道就沒法避免了?畢竟在大規模團隊開發中,每個人在寫自己的代碼,怎樣保證自己的就一定和別人的不衝突呢?

C++的namespace(C怎麼辦?),合理的命名規範(所有人都能很好地遵守嗎?),儘可能地避免全局變量和函數(能夠static的儘量 static)(static只能在同一個c/cpp中使用,不同c/cpp文件中的變量或函數怎麼辦?),這些都是很好的解決方法,但都是着眼在避免同 名衝突,但如果真的無法避免同名衝突,有什麼方面能夠解決嗎?比如動態庫中就必須有個函數也叫foo(),而且動態庫中必須調用這樣一個foo(),而不 是可被別人覆蓋的foo()(static或許可以解決問題,呵呵)

這裏再多介紹一個新學習到的方法:GCC的visibility屬性http://gcc.gnu.org/onlinedocs/gcc/Function-Attributes.html#Function-Attributes

visibility ("visibility_type") This attribute affects the linkage of the declaration to which it is attached. There are four supported visibility_type values: default, hidden, protected or internal visibility.           void __attribute__ ((visibility ("protected")))           f () { /* Do something. */; }           int i __attribute__ ((visibility ("hidden")));       The possible values of visibility_type correspond to the visibility settings in the ELF gABI. default Default visibility is the normal case for the object file format. This value is available for the visibility attribute to override other options that may change the assumed visibility of entities. On ELF, default visibility means that the declaration is visible to other modules and, in shared libraries, means that the declared entity may be overridden. On Darwin, default visibility means that the declaration is visible to other modules. Default visibility corresponds to “external linkage” in the language.  hidden Hidden visibility indicates that the entity declared will have a new form of linkage, which we'll call “hidden linkage”. Two declarations of an object with hidden linkage refer to the same object if they are in the same shared object.  internal Internal visibility is like hidden visibility, but with additional processor specific semantics. Unless otherwise specified by the psABI, GCC defines internal visibility to mean that a function is never called from another module. Compare this with hidden functions which, while they cannot be referenced directly by other modules, can be referenced indirectly via function pointers. By indicating that a function cannot be called from outside the module, GCC may for instance omit the load of a PIC register since it is known that the calling function loaded the correct value.  protected Protected visibility is like default visibility except that it indicates that references within the defining module will bind to the definition in that module. That is, the declared entity cannot be overridden by another module. All visibilities are supported on many, but not all, ELF targets (supported when the assembler supports the `.visibility' pseudo-op). Default visibility is supported everywhere. Hidden visibility is supported on Darwin targets. The visibility attribute should be applied only to declarations which would otherwise have external linkage. The attribute should be applied consistently, so that the same entity should not be declared with different settings of the attribute. In C++, the visibility attribute applies to types as well as functions and objects, because in C++ types have linkage. A class must not have greater visibility than its non-static data member types and bases, and class members default to the visibility of their class. Also, a declaration without explicit visibility is limited to the visibility of its type. In C++, you can mark member functions and static member variables of a class with the visibility attribute. This is useful if you know a particular method or static member variable should only be used from one shared object; then you can mark it hidden while the rest of the class has default visibility. Care must be taken to avoid breaking the One Definition Rule; for example, it is usually not useful to mark an inline method as hidden without marking the whole class as hidden. A C++ namespace declaration can also have the visibility attribute. This attribute applies only to the particular namespace body, not to other definitions of the same namespace; it is equivalent to using `#pragma GCC visibility' before and after the namespace definition (see Visibility Pragmas). In C++, if a template argument has limited visibility, this restriction is implicitly propagated to the template instantiation. Otherwise, template instantiations and specializations default to the visibility of their template. If both the template and enclosing class have explicit visibility, the visibility from the template is used.

 

正如這段描述,通過visibility屬性可以讓鏈接器知道,這個符號是否是extern的,如果把一個變量或函數的visibility設爲 hidden或internal(我不清楚這兩者有什麼差別?)那其他模塊是調用不到這個變量或函數的,即只能內部使用。既然鏈接器知道它們只會被內部使 用,那應該就不需要重定位了吧?下面我們通過例子來看一下。

有兩種使用方法指定visibility屬性

  • 編譯時指定,指定編譯選項-fvisibility=hidden,則該編譯出的.o文件中的所有符號均爲外部不可訪問
  • 代碼中指定,即上面例子中的代碼,void __attribute__((visibility (“hidden”))) foo(); 則foo()就只能在模塊內部使用了。

我們先來簡單地改一下Makefile:

all:
        g
++ -c -fvisibility=hidden -o mylib_i.o mylib_i.cpp
        g
++ -c -o mylib.o mylib.cpp
        g
++ -shared -o libmylib.so mylib.o mylib_i.o
        g
++ -c -o main.o main.cpp
        g
++ -o main main.o -L. -lmylib

再運行,發現結果變成了"foo in lib”。

深入一下:

[root@zouf testlib]# objdump -t libmylib.so |egrep "foo|bar"
000005ec l     F .text  00000022              .hidden foo
000005dc g     F .text  0000000d              bar
[root@zouf testlib]# nm libmylib.so |egrep "foo|bar"
000005dc T bar
000005ec t foo

我們看到nm輸出中foo()函數變成了t,nm的man手冊中說:

The symbol type. At least the following types are used; others are, as well, depending on the object file format. If lowercase, the symbol is local; if uppercase, the symbol is global (external).

很清楚了,我們已經成功地把這個函數限定在動態庫內部。

再深入一點,看一下沒加visibility=hidden和加了visibility=hidden的bar()函數調用foo()函數的彙編碼有什麼不一樣:

沒加visibility=hidden

[root@zouf testlib]# objdump -d libmylib.so
000005fc :
 
5fc:   55                      push   %ebp
 
5fd:   89 e5                   mov    %esp,%ebp
 
5ff:   83 ec 08                sub    $0x8,%esp
 
602:   e8 fc ff ff ff          call   603 <bar  +0X7>
 
607:   c9                      leave
 
608:   c3                      ret
 
609:   90                      nop
 
60a:   90                      nop
 
60b:   90                      nop

0000060c :

 

加了visibility=hidden

[root@zouf testlib]# objdump -d libmylib.so
...
000005dc :
 
5dc:   55                      push   %ebp
 
5dd:   89 e5                   mov    %esp,%ebp
 
5df:   83 ec 08                sub    $0x8,%esp
 
5e2:   e8 05 00 00 00          call   5ec <foo>
 
5e7:   c9                      leave
 
5e8:   c3                      ret
 
5e9:   90                      nop
 
5ea:   90                      nop
 
5eb:   90                      nop

000005ec <foo>:
...

 

可以清楚地看到,沒加visibility=hidden的bar()函數,在調用foo()函數的地方,填入的是0xfffffffc,也就是call 603,那603是什麼?

[root@zouf testlib]# objdump -R libmylib.so |grep -r foo
00000603 R_386_PC32        foo

哈哈,原來603就是要重定位的foo啊。

而我們看加了visibility=hidden的bar()函數,其中調用foo()函數的地方被正確地填入相對偏移地址0x00000005,也就是foo()函數所在地,所以它已經不再需要依賴重定位來找到正確的地址啦。

再提一遍,沒加visibility=hidden的那個也沒有加-fPIC,否則看到的就不是這個結果了,預知詳情,下回分解~~~

這樣看起來多好啊,動態庫內部的變量或函數就內部使用,只有需要導出的符號才參與全局導入。事實上這樣的行爲也正是Windows上DLL的行爲, 在DLL中定義的變量或函數默認是不會被導出的,只有在.DEF文件加上EXPORTS,或者__declspec__(dllexport)纔會導出, 別的模塊才能使用,而對於未導出的符號,內部使用時是不經過全局導入的,從而保證了全局空間的“相對”清潔。

唉,Linux/GCC爲什麼不這樣設計呢?是不是有什麼不可告人的祕密?哪位高人指點一下?莫非又是歷史遺留問題?爲了兼容性而妥協?

不過GCC在wiki上對於visilibity的建議似乎也很清楚,就是儘量在Makefile中打開visilibity屬性,同時在明確需要導出的變量/函數/類加上修飾符http://gcc.gnu.org/wiki/Visibility

甚至GCC都給出了很清楚的代碼來展示:

// Generic helper definitions for shared library support
#if defined _WIN32 || defined __CYGWIN__
 
#define FOX_HELPER_DLL_IMPORT __declspec(dllimport)
 
#define FOX_HELPER_DLL_EXPORT __declspec(dllexport)
 
#define FOX_HELPER_DLL_LOCAL
#else
 
#if __GNUC__ >= 4
   
#define FOX_HELPER_DLL_IMPORT __attribute__ ((visibility("default")))
   
#define FOX_HELPER_DLL_EXPORT __attribute__ ((visibility("default")))
   
#define FOX_HELPER_DLL_LOCAL  __attribute__ ((visibility("hidden")))
 
#else
   
#define FOX_HELPER_DLL_IMPORT
   
#define FOX_HELPER_DLL_EXPORT
   
#define FOX_HELPER_DLL_LOCAL
 
#endif
#endif

// Now we use the generic helper definitions above to define FOX_API and FOX_LOCAL.
// FOX_API is used for the public API symbols. It either DLL imports or DLL exports (or does nothing for static build)
// FOX_LOCAL is used for non-api symbols.

#ifdef FOX_DLL // defined if FOX is compiled as a DLL
 
#ifdef FOX_DLL_EXPORTS // defined if we are building the FOX DLL (instead of using it)
   
#define FOX_API FOX_HELPER_DLL_EXPORT
 
#else
   
#define FOX_API FOX_HELPER_DLL_IMPORT
 
#endif // FOX_DLL_EXPORTS
 
#define FOX_LOCAL FOX_HELPER_DLL_LOCAL
#else // FOX_DLL is not defined: this means FOX is a static lib.
 
#define FX_API
 
#define FOX_LOCAL
#endif // FOX_DLL

 

就我理解,所有Linux上的.so都該這樣設計,除非…嗯,我沒想到什麼除非,哪位高人指點?

#TO BE CONTINUED#

寫在最後的話:這個topic實在有點大,很多概念似乎瞭解但深入想下去又似乎不盡然,中間很多理解都有自己的加工,很可能紕漏百出,希望大家細心指正,共同進步,把這些模糊的概念和過程具體化,致謝!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章