什麼是符號和符號可見性
符號是談及對象文件、鏈接等內容時的基本術語之一。實際上,在 C/C++ 語言中,符號是很多用戶定義的變量、函數名稱以 及一些名稱空間、類/結構/名稱等的對應實體。例如,當我們定義非靜態全局變量或非靜態函數時,C/C++ 編譯器就會在對象文 件中生成符號,這些符號對於鏈接器(linker)確定不同模塊(對象文件、動態共享庫、可執行文件)是否會共享相同的數據或 代碼很有用。
盡管變量和函數都可能會在模塊之間共享,但是對象文件之間的變量共享更為常見。例如,程序員可能會在 a.c 中聲明一個 變量:
extern int shared_var;
卻在 b.c 中定義該變量:
int shared_var;
這樣,兩個 shared_var 符號會出現在已編譯的對象 a.o 和 b.o 中,最後在鏈接器解析之後,a.o 中的符號會共享 b.o 的 地址。但是,人們很少讓變量在共享庫和可執行文件之間共享。對於此類模塊,通常只會讓函數對其他模塊可見。有時,我們將 此類函數稱之為 API,因為我們覺得該模塊是為其他模塊提供調用的接口。我們也把這種符號稱為導出的 (exported),因為它對 其他模塊可見。注意,此可見性只在動態鏈接時有效,因為共享庫通常在程序運行時被加載為內存映像的一部分。因此,符號可 見性 (symbol visibility) 是所有全局符號的一個用於動態鏈接的屬性。
在不同的平台上,XL C/C++ 編譯器可能會選擇導出或者不導出模塊中的所有符號。例如,在 IBM PowerLinux 平台上創建 Executable and Linking Format (ELF) 共享庫時,默認情況下,所有的符號都會導出。在 POWER 平台上的 AIX 系統中創建 XCOFF 庫時,當前 XL C/C++ 編譯器在沒有工具的幫助下可能會選擇不導出任何符號。還有其他方式允許程序員逐個地決定符號 可見性(這是本系列下一部分要介紹的內容)。但是,一般不建議導出模塊中的所有符號。程序員可以根據需要導出符號。這不 僅對庫的安全有益,也對動態鏈接時間有益。
程序員選擇導出所有符號時,存在很高的風險,鏈接時可能會出現符號沖突,尤其是當模塊是由不同的開發人員開發的時。因 為符號是低級別的概念,所以它不涉及到作用域。只要有人鏈接一個跟您的庫具有相同符號名稱的庫,當進行鏈接器解析時,該 庫就可能會意外地覆蓋您自己的符號(但願會給出一些警告或錯誤信息)。大多數情況下,此類符號從來不會被從庫設計者的角 度去使用。因此,為符號創建有限制、有含義(經過深思熟慮)的名稱,對於避免此類問題有很大幫助。
對於 C++ 編程,現在越來越注重性能了。然而,由於對其他庫的依賴性以及使用特定的 C++ 特性(比如模板),編譯器/鏈 接器趨向於會使用和生成大量的符號。因此,導出所有符號會減慢程序速度,並耗用大量內存。導出有限數量的符號可以縮短動 態共享庫的加載和鏈接時間。此外,也支持編譯器角度的優化,這意味著會生成更有效的代碼。
以上關於導出所有符號的缺點解釋了為什麼一定要定義符號可見性。在本文中,我們將提供一些解決方案來控制動態共享對象 (DSO) 中的符號。用戶可以使用不同的方式解決相同的問題,我們將提議特定平台應該首選哪種解決方式。
控制符號可見性的方式
在後面的討論中,我們將用到下面的 C++ 代碼片段:
清單 1. a.C
int myintvar = 5;
int func0 () {
return ++myintvar;
}
int func1 (int i) {
return func0() * i;
}
在 a.C 中,我們定義了一個變量 myintvar,以及兩個函數 func0 和 func1。默認情況下,在 AIX 平台上創建共享庫時,編 譯器和鏈接器以及 CreateExportList 工具會讓所有三個符號都可見。我們可以利用 dump 二進制工具從 Loader Symbol Table Information 檢查這一情況:
$ xlC -qpic a.C -qmkshrobj -o libtest.a
$ dump -Tv libtest.a
***Loader Symbol Table Information***
[Index] Value Scn IMEX Sclass Type IMPid Name
[0] 0x20000280 .data EXP RW SECdef [noIMid] myintvar
[1] 0x20000284 .data EXP DS SECdef [noIMid] func0__Fv
[2] 0x20000290 .data EXP DS SECdef [noIMid] func1__Fi
這裡,“EXP”表示符號是導出的。函數名稱 func0 和 func1 被 C++ 重整規則(mangling rule)進行了重整( 但是,不難猜出名稱的意思)。dump 工具的 -T 選項顯示 Loader Symbol Table Information,動態鏈接器將用到此信息。在本 例中,a.C 中的所有符號都被導出。但是從庫編寫者的角度,本例中我們可能只想導出 func1。全局符號 myintvar 和函數 func0 被認為只保持/改變內部狀態,或者說只在局部使用。因此,對於庫編寫者來說,讓它們不可見至關重要。
我們至少有三種方式可以達此目的。包括:使用 static 關鍵字,定義 GNU visibility 屬性,以及使用導出列表。每種方式 都有各自不同的功用和缺點。下面就來看看這些方式。
1. 使用 static 關鍵字
C/C++ 中的 static 可能是一個最常用的關鍵字,因為它可以為變量指定作用域和存儲。對於作用域,可以說成它為文件中的 符號禁用了外部鏈接。這意味著,帶有關鍵字 static 的符號永遠不會是可鏈接的,因為編譯器不為鏈接器留下關於此符號的任 何信息。這是一種語言級別的控制,是最簡單的一種隱藏符號的方式。
我們來給上面的例子添加 static 關鍵字吧:
清單 2. b.C
static int myintvar = 5;
static int func0 () {
return ++myintvar;
}
int func1 (int i) {
return func0() * i;
}
生成共享庫並再次查看 Loader Symbol Table Information,可以看到預期的效果:
$ xlC -qpic a.C -qmkshrobj -o libtest.a
$ dump -Tv libtest.a
***Loader Symbol Table Information***
[Index] Value Scn IMEX Sclass Type IMPid Name
[0] 0x20000284 .data EXP DS SECdef [noIMid] func1__Fi
現在,如信息所示,只有 func1 被導出。然而,盡管 static 關鍵字可以隱藏符號,但是它還定義了一條額外的規則,即變 量或函數只可以在定義它們的文件范圍內使用。因此,如果我們定義:
extern int myintvar;
後面,在文件 b.C 中,您可能想要從 a.o 和 b.o 構建 libtest.a。您這樣做時,鏈接器將顯示一條錯誤消息,指出定義在 b.C 中的 myintvar 無法被鏈接,因為鏈接器沒有在其他地方找到定義。這中斷了相同模塊內的數據/代碼共享,而這種共享通常 是程序員所需要的。因此,這種方法更多地用作文件內變量/函數的可見性控制,而不用於低級別符號的可見性控制。實際上,大 多數變量/函數不會依賴於 static 關鍵字來控制符號可見性。因此,我們可以考慮第二種方法:
2. 定義 visibility 屬性(僅針對 GNU)
下一個控制符號可見性的備選方法是使用 visibility 屬性。ELF 應用程序二進制接口 (ABI) 定義符號的可見性。一般來說 ,它定義 4 個類,但是大多數情況下,最常用的只有其中兩個:
STV_DEFAULT - 用它定義的符號將被導出。換句話說,它聲明符號是到處可見的。
STV_HIDDEN - 用它定義的符號將不被導出,並且不能從其他對象進行使用。
注意,這只是 GNU C/C++ 的一個擴展。因此,目前 PowerLinux 客戶可以將它用作符號的 GNU 屬性。下面是針對本文示例情 況的一個例子:
int myintvar __attribute__ ((visibility ("hidden")));
int __attribute__ ((visibility ("hidden"))) func0 () {
return ++myintvar;
}
...
要定義 GNU 屬性,需要包含 __attribute__ 和用括號括住的內容。您可以將符號的可見性指定為 visibility (“hidden”)。在上面的示例中,我們可以將 myintvar 和 func0 標記為 hidden 可見性。這將不允許它們在庫中被 導出,但是可以在源文件之間共享。實際上,隱藏的符號將不會出現在動態符號表中,但是還被留在符號表中用於靜態鏈接。這 是一種良好定義的行為,完全可以達到我們的目的。它顯然優於 static 關鍵字方法。
注意,對於用 visibility 屬性指定的變量,將它聲明為 static 可能會讓編譯器感到混淆。因此,編譯器會顯示一條警告消 息。
ELF ABI 也定義其他可見性模式:
STV_PROTECTED:符號在當前可執行文件或共享對象之外可見,但是不會被覆蓋。換句話說,如果共享庫中的一個受保護符號 被該共享庫中的另一個代碼引用,那麼此代碼將總是引用共享庫中的此符號,即便可執行文件定義了相同名稱的符號。
STV_INTERNAL:符號在當前可執行文件或共享庫之外不可訪問。
注意,此方法目前不受 XL C/C++ 編譯器支持,即便在 PowerLinux 平台上亦是如此。但是,我們還有別的方法。
3. 使用導出列表
上面兩種解決方案可以在源代碼級別發揮作用,並且只需要編譯器就可以實現目的。然而,這要求用戶能夠告訴鏈接器去執行 類似的工作,因為主要是在動態鏈接中涉及到符號可見性。針對鏈接器的解決方案是導出列表。
導出列表由編譯器(或相關工具,如 CreateExportlist)在創建共享庫的時候自動生成。也可以由開發人員手工編寫。導出 列表由鏈接器選項傳入並當作鏈接器的輸入。然而,由於編譯器驅動程序會完成所有瑣碎的工作,所以程序員很少關注那些非常 詳細的選項。
導出列表的原理是,顯式地告訴鏈接器可以通過外部文件從對象文件導出的符號是哪些。GNU 用戶將此類外部文件稱作為 “導出映射”。我們可以為本文的示例編寫一個導出映射:
{
global: func1;
local: *;
};
上面的描述告訴鏈接器,只有 func1 符號將被導出,其他符號(由 * 匹配)是局部的。程序員也可以顯式地列出 func0 或 myintvar 為局部符號 (local:func0;myintvar;)。但是很明顯,全部匹配 (*) 更為方便。一般來說,高度推薦使用全部匹配 (*) 來將所有符號都標記為局部並只挑出需要導出的符號,因為這樣更安全。這樣可以避免用戶忘記保持一些符號為局部的,也 可以避免兩個列表中出現重復,重復可能會導致非預期的行為。
要用這一方法生成 DSO,程序員必須利用 --version-script 鏈接器選項傳遞導出映射文件:
$ gcc -shared -o libtest.so a.C -fPIC -Wl,--version-script=exportmap
利用 readelf 二進制實用工具加上 -s 選項讀取 ELF 對象文件:readelf -s mylib.so
它將顯示只有 func1 對該模塊是全局可見的(.dynsym 部分中的信息項),其他符號被隱藏為局部的。
對於 IBM AIX OS 鏈接器,提供了一個類似的導出列表。確切說,在 AIX 上,導出列表被稱作導出文件。
編寫導出文件很簡單。程序員只需將需要導出的符號放入導出文件中即可。在本示例中,就像如下所示這麼簡單:
func1__Fi // symbol name
因此,我們用一個鏈接器選項指定導出文件時,只有我們想要導出的符號被添加到 XCOFF 的“加載器符號表”中 ,其他符號都保持為非導出的。
對於 AIX 6.1 及以上版本,程序員可能還會附加一個 visibility 屬性來描述導出文件中符號的可見性。AIX 鏈接器現在接 受 4 個這樣的 visibility 屬性類型:
export:符號用全局導出屬性進行導出。
hidden:符號不導出。
protected:符號被導出,但是不能被重新綁定(被搶占),即便使用的是運行時鏈接。
internal:符號不導出。符號的地址不得提供給其他程序或共享對象,但是鏈接器不對此進行驗證。
export 和 hidden 之間的區別很明顯。然而,exported 和 protected 之間的區別則很微妙。下一節我們將更加詳細地討論 符號搶占。
總之,上面 4 個關鍵字可用於導出文件中。通過將它們附加到符號的末尾(帶有一個空格),將會提供不同粒度的符號可見 性控制。在本例中,我們也可以像如下所示一樣指定符號可見性(在 AIX 6.1 及更高版本上):
func1__Fi export
func0__Fv hidden
myintvar hidden
這通知鏈接器只有 func1__Fi(即 func1)將會導出,其他符號不會導出。
您可能注意到了,與 GNU 導出映射不同,導出文件中列出的符號都是重整後的名稱。重整後的名稱看起來不是那麼友好,因 為程序員可能會不了解重整規則。但是這確實有助於鏈接器快速地進行名稱解析。為了彌補這一缺陷,AIX OS 選擇利用一個工具 來幫助程序員。
簡而言之,如果程序員在調用 XL C/C++ 編譯器時指定 -qmkshrobj 選項,那麼在編譯器成功生成對象文件之後,編譯器驅動 程序將調用 CreateExportList 工具來自動生成導出文件,其中包含已重整符號的名稱。編譯器驅動程序然後將此導出文件傳遞 給鏈接器來處理符號可見性設置。考慮下面這個例子,如果我們調用:
$ xlC -qpic a.C -qmkshrobj -o libtest.a
這將生成 libtest.a 庫,並且所有符號都被導出(這是默認情況)。盡管這沒有達到我們的目的,但是至少整個過程對程序 員看起來是透明的。程序員也可以選擇使用 CreateExportList 實用工具來生成導出文件。如果選擇這種方式,您現在能夠手工 修改導出文件。例如,假設您想要的導出文件名稱是 exportfile,那麼 qexpfile=exportfile 就是您需要傳遞給 XL C/C++ 編 譯器驅動程序的選項。
$ xlC -qmkshrobj -o libtest.a a.o -qexpfile=exportfile
在本例中,您會發現所有符號如下所示:
func0__Fv
func1__Fi
myintvar
根據需要,我們可以簡單地刪除帶有 myintvar、func0 的行,或者在它們後面附加 hidden 可見性關鍵字,然後保存導出文 件,並使用鏈接器選項 -bE:exportfile 來傳回修改後的導出文件。
$ xlC -qmkshrobj -o libtest.a a.o -bE:exportfile
這將完成所有的步驟。現在,生成的 DSO 將不讓 func1__Fi(即 func1)導出:
$ dump -Tv libtest.a
***Loader Symbol Table Information***
[Index] Value Scn IMEX Sclass Type IMPid Name
[0] 0x20000284 .data EXP DS SECdef [noIMid] func1__Fi
另外,程序員也可以使用 CreateExportList 實用工具來顯式地生成導出文件,如下所示:
$ CreateExportList exportfile a.o
在本文示例中,效果跟上面的方法相同。
對於 AIX 6.1 及更高版本上的新格式,逐個地為符號可見性附加關鍵字可能需要較多的精力。然而,XL C/C++ 編譯器計劃進 行一些更改,以便讓程序員的工作更輕松(本系列下一部分中將介紹相關的信息)。
在導出列表解決方案中,所有的信息都保留在導出列表中,程序員不需要更改源文件。這將代碼開發和庫開發的工作分離開來 。然而,我們可能會面臨此過程的一個問題。因為我們保持源文件不修改,所以編譯器生成的二進制代碼可能會不是最優的。編 譯器失去了優化那些由於缺少信息而不被導出的符號的機會。這會增加所生成二進制文件的大小,或者減慢符號解析的處理速度 。然而,對於大多數應用程序來說,這並不是一個主要問題。
下表比較了以上所有解決方案,並且讓視圖更為集中。
符號搶占
正如前面所提到的,可見性關鍵字 export 和 protected 之間存在微妙的區別。此微妙區別就在於符號搶占(symbol preemption)上。符號搶占出現在當鏈接時解析的符號地址被另一個在運行時解析的符號地址取代時(注意,盡管在 AIX 上運行 時鏈接是可選的)。從概念上講,運行時鏈接會在程序執行開始之後解析共享模塊中未定義和非延遲的符號。符號搶占是一種提 供運行時定義(這些函數定義在鏈接時不可用)和符號重新綁定功能的機制。在 AIX 上,當主程序利用 -brtl 標志進行鏈接時 或者當預加載的庫利用 LDR_CNTRL 環境變量進行指定時,程序能夠使用運行時鏈接設施。利用 -brtl 進行編譯會向程序添加一 個對動態鏈接器的引用,當程序開始運行時,該引用會被程序的啟動代碼 (/lib/crt0.o) 調用。共享對象輸入文件按其在命令行 中指定的相同順序在程序加載器部分被列出為關聯項。當程序開始運行時,系統加載器加載這些共享對象,以便它們的定義對動 態鏈接器可用。
因此,在運行時重新定義共享對象中的條目是一種叫做符號搶占的功能。 符號搶占只有在 AIX 上使用運行時鏈接時才能發揮 作用。在鏈接時綁定到一個模塊的導入會在運行時重新綁定到另一個模塊。一個局部定義是否可以被導入的實例搶占,取決於模 塊的鏈接方式。然而,非導出符號永遠不會在運行時被搶占。運行時加載器加載組件時,該組件中所有具有默認可見性的符號都 會被已經加載的組件中相同名稱的符號搶占。注意,因為主程序映像總是最先加載的,所以其定義的任何符號都不會被搶占(重 新定義)。
受保護符號會被導出,但是不可以被搶占。相反,導出的符號可被導出並搶占(如果使用運行時鏈接的話)。
對於默認符號,Linux 和 AIX 之間存在差別。GNU 編譯器和 ELF 文件格式定義一種默認可見性,用於可被導出和搶占的符號 。這類似於 AIX 上定義的 exported 可見性。
下面的代碼以 AIX 平台為例:
清單 3. func.C
#include <stdio.h>
void func_DEFAULT(){
printf("func_DEFAULT in the shared library, Not preempted\n");
}
void func_PROC(){
printf("func_PROC in the shared library, Not preempted\n");
}
清單 4. invoke.C
extern void func_DEFAULT();
extern void func_PROC();
void invoke(){
func_DEFAULT();
func_PROC();
}
清單 5. main.C
#include <stdio.h>
extern void func_DEFAULT();
extern void func_PROC();
extern void invoke();
int main(){
invoke();
return 0;
}
void func_DEFAULT(){
printf("func_DEFAULT redefined in main program, Preempted ==> EXP\n");
}
void func_PROC(){
printf("func_PROC redefined in main program, Preempted ==> EXP\n");
}
在上面的描述中,我們在 func.C 和 main.C 中都定義了 func_DEFAULT 和 func_PROC。它們名稱相同,但是行為不同。來自 invoke.C 的函數 invoke 將依次調用 func_DEFAULT 和 func_PROC。我們將使用下面的 exportlist 代碼來看符號是否被導出, 以及是如何導出的。
清單 6. exportlist
func_DEFAULT__Fv export
func_PROC__Fv protected
invoke__Fv
如果使用的是 AIX 6.1 之前的鏈接器版本,可以使用空格代替 export,symbolic 關鍵字代替 protected 關鍵字。下面代碼 中列出了構建 libtest.so 庫和 main 可執行文件的命令:
/* generate position-independent code suitable for use in shared libraries. */
$ xlC -c func.C invoke.C -qpic
/* generate shared library, exportlist is used to control symbol visibility */
$ xlC -G -o libtest.so func.o invoke.o -bE:exportlist
$ xlC -c main.C
/* -brtl enable runtime linkage. */
$ xlC main.o -L. -ltest -brtl -bexpall -o main
本質上,我們是從 func.o 和 invoke.o 構建 libtest.so。我們使用 exportlist 來將 func.C 中的 func_DEFAULT 和 func.C 中的 func_PROC 設置為導出符號,但是仍然是受保護的。這樣,libtest.so 就有兩個導出符號和一個受保護符號。對於 主程序,我們從 main.C 導出所有符號,但是將它鏈接到 libtest.so。注意,我們使用 -brtl 標志來為 libtest.so 啟用動態 鏈接。
下一步是調用主程序。
$ ./main
func_DEFAULT redefined in main program, Preempted ==> EXP
func_PROC in the shared library, Not preempted
在這裡我們看到一些有趣的東西:func_DEFAULT是來自 main.C 的版本,而 func_PROC 是來自 libtest.so (func.C) 的版本 。func_DEFAULT 符號被搶占,因為來自 libtest.so 的局部版本(我們說它是局部的,是因為調用函數 invoke 來自於 invoke.C,後者本質上與來自 func.C 的 func_DEFAULT 位於同一模塊)被來自另一個模塊的 func_DEFAULT 符號所取代。然而 ,func_PROC 上確實出現了相同的條件,它在導出文件中被指定為 protected 可見性。
注意,可以搶占其他符號的符號應該總是導出符號。假設我們在構建可執行文件 main 時刪除了 -bexpall 選項,那麼輸出如 下所示:
$ xlC main.o -L. -ltest -brtl -o main; //-brtl enable runtime linkage.
$ ./main
func_DEFAULT in the shared library, Not preempted
func_PROC in the shared library, Not preempted
這裡沒有發生搶占。所有符號都保持模塊中的相同版本。
實際上,要在運行時檢查符號是否是導出符號或者受保護符號,我們可以使用 dump 實用工具:
$ dump -TRv libtest.so
libtest.so:
***Loader Section***
***Loader Symbol Table Information***
[Index] Value Scn IMEX Sclass Type IMPid Name
[0] 0x00000000 undef IMP DS EXTref libc.a (shr.o) printf
[1] 0x2000040c .data EXP DS SECdef [noIMid] func_DEFAULT__Fv
[2] 0x20000418 .data EXP DS SECdef [noIMid] func_PROC__Fv
[3] 0x20000424 .data EXP DS SECdef [noIMid] invoke__Fv
***Relocation Information***
Vaddr Symndx Type Relsect Name
0x2000040c 0x00000000 Pos_Rel 0x0002 .text
0x20000410 0x00000001 Pos_Rel 0x0002 .data
0x20000418 0x00000000 Pos_Rel 0x0002 .text
0x2000041c 0x00000001 Pos_Rel 0x0002 .data
0x20000424 0x00000000 Pos_Rel 0x0002 .text
0x20000428 0x00000001 Pos_Rel 0x0002 .data
0x20000430 0x00000000 Pos_Rel 0x0002 .text
0x20000434 0x00000003 Pos_Rel 0x0002 printf
0x20000438 0x00000004 Pos_Rel 0x0002 func_DEFAULT__Fv
0x2000043c 0x00000006 Pos_Rel 0x0002 invoke__Fv
這是來自 libtest.so 的輸出。我們可以發現,func_DEFAULT__Fv 和 func_PROC__Fv 都是導出符號。然而,func_PROC__Fv 不具有任何重新定位。這意味著,加載器可能找不到方法來替換 TOC 表中 func_PROC 的地址。TOC 表中 func_PROC 的地址是函 數調用要將控制轉移到的地方。因此,func_PROC 似乎不會被搶占。我們然後認識到,它是受保護的。
實際上,符號搶占使用得很少。然而,它讓我們可以在運行時動態地替換符號,但是也會留下一些安全漏洞。如果不想讓庫中 的關鍵符號被搶占(但是仍然需要導出),為安全起見,需要將它設置為受保護的。