Linux 編譯 shared library 的方法和注意事項

fcamel
fcamel的程式開發心得
20 min readJul 25, 2018

《Linux 執行時尋找 symbol 的流程以及 shared library 相關知識》著重在執行期 (Runtime) 的行為,這篇補充說明編譯和連結(Link) 的行為,以及用 gcc 產生 shared library 的相關指令。

static linker 和 dynamic (runtime) linker

  • static linker 負責 link 產生 shared library 和 executable,在 Linux 上預設是 ld (ld.bfd), Google 有另寫一套 gold 取代 ld,兩者的預設行為略有不同。gcc 是 compiler,但它會使用 static linker 產生 shared library / executable。有時需要透過 gcc 傳遞參數給 static linker (後述)。
  • dynamic (runtime) linker 負責在執行期間載入 shared library ,Linux 上是ld.so,這在前篇文章介紹過了。

這篇將重心放在 static linker。

基本例子

程式碼:

$ cat foo.h
#ifndef FOO_H
#define FOO_H
void foo();
#endif
$ cat foo.c
#include "foo.h"
#include <stdio.h>void foo() {
printf("call foo\n");
}
$ cat main.c
#include "foo.h"
int main(void) {
foo();
return 0;
}
  1. 編譯 shared library:
$ gcc -g -fPIC -c foo.c
$ gcc -shared foo.o -o libfoo.so

-fPIC 表示要編成 position-independent code,這樣不同 process 載入 shared library 時,library 的程式和資料才能放到記憶體不同位置。

2. 編譯 executable:

$ gcc -g -o main main.c libfoo.so
$ gcc -g -o main main.c -lfoo -L.

兩個指令產生一樣的結果,但第二個作法比較常見:

  • -lfoo: 表示要連結 libfoo.so
  • -L.: 表示搜尋 libfoo.so 時,除了預設目錄外,也要找目前的位置 ( . )。可以指定多次 -LDIR。

3. 執行 executable:

$ LD_LIBRARY_PATH=. ./main
call foo

LD_LIBRARY_PATH 是必要的,因為 libfoo.so 不在 ld.so 搜尋的路徑裡。後面會說明更好的作法。

4. 用 ldd 查看 main 用到的 shared library:

$ ldd main
linux-vdso.so.1 => (0x...)
libfoo.so => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x...)
/lib64/ld-linux-x86-64.so.2 (0x...)
$ LD_LIBRARY_PATH=. ldd main
linux-vdso.so.1 => (0x...)
libfoo.so => ./libfoo.so (0x...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x...)
/lib64/ld-linux-x86-64.so.2 (0x...)

預設 ldd 找不到 libfoo.so,加上 LD_LIBRARY_PATH 後就找到了。

參數順序的影響

需要多個 object file (*.o)、static library (*.a,只是一包 object file 的集合)、shared library 時,它們之間的順序會有影響。ld 每個檔案只會看一次,丟掉沒用到的 symbol 和記錄 undefined symbol,預期後面的檔案會提供 undefined symbol。看完全部檔案仍有 undefined symbol,就會 link error。

man gcc 對 -l 的說明如下:

It makes a difference where in the command you write this option; the linker searches and processes libraries and object files in the order they are specified. Thus, foo.o -lz bar.o searches library z after file foo.o but before bar.o. If bar.o refers to functions in z, those functions may not be loaded.

若出現 A.a 用到 B.a、B.a 用到 A.a 這種 circular dependency,需要多提一次檔名:

$ gcc -g -o main main.o A.a B.a A.a

或是用 ld 的參數--start-group--end-group 包住 A.a 和 B.a:

$ gcc -g -o main main.o -Wl,--start-group A.a B.a -Wl,--end-group

這樣 ld 會重覆查看 A.a 和 B.a,缺點是比較沒有效率。

安裝 shared library 到系統目錄

先前的文章提到執行期尋找 shared library 的順序是:

  1. 若 shared library 名稱內有 “/”,表示它是路徑,直接用這個路徑找。
  2. 若 executable 內有定義 DT_RPATH 沒定義 DT_RUNPATH,從 DT_RPATH 列的目錄裡找。
  3. LD_LIBRARY_PATH 列的目錄裡找。
  4. DT_RUNPATH 列的目錄裡找。
  5. ldconfig 產生的 cache 內找 (/etc/ld.so.cache)。
  6. 從 OS 的預設位置找: 先找 /lib 再找 /usr/lib

所以複製 shared library 到 /lib 或 /usr/lib 或是 ldconfig 掃瞄的位置,就不用 LD_LIBRARY_PATH 了。可以從 /etc/ld.so.conf 得知 ldconfig 會掃瞄的位置。

在 Ubuntu 16.04 上,/usr/local/lib 有在 ldconfig 掃瞄的位置裡。所以前面的例子可以這麼改:

$ gcc -fPIC -g -shared -o libfoo.so foo.c
$ sudo cp libfoo.so /usr/local/lib
$ gcc -g -o main main.c -lfoo # 註1

然後更新 ldconfig 的 cache,這樣執行 main 的時候才會找到新加人的 libfoo.so:

$ sudo ldconfig
$ ldd main
...
libfoo.so => /usr/local/lib/libfoo.so (0x...)
...
$ ./main
call foo

因為 /etc/ld.so.cache 有 libfoo 的記錄,就不需要 LD_LIBRARY_PATH 了。

[註 1] Link main 的時候不用加 -L 是因為 /usr/local/lib 已經在 ld 的預設搜尋路徑裡:

$ ld --verbose | grep SEARCH
SEARCH_DIR("=/usr/local/lib/x86_64-linux-gnu"); SEARCH_DIR("=/lib/x86_64-linux-gnu"); SEARCH_DIR("=/usr/lib/x86_64-linux-gnu"); SEARCH_DIR("=/usr/local/lib64"); SEARCH_DIR("=/lib64"); SEARCH_DIR("=/usr/lib64"); SEARCH_DIR("=/usr/local/lib"); SEARCH_DIR("=/lib"); SEARCH_DIR("=/usr/lib"); SEARCH_DIR("=/usr/x86_64-linux-gnu/lib64"); SEARCH_DIR("=/usr/x86_64-linux-gnu/lib");

linker name, real name 和 soname

有時會升級 shared library,所以要有方法辨識 shared library 的版本,還有可能需要同時並存同一套但不同版本的 shared library。

對於 library X,Linux 的命名慣例是:

  • libX.so.A.B.C: real name。表示版號 A.B.C,A 是 major number,慣例是 A 一樣的情況會向前相容 (library 的開發者會遵守此項慣例)。比方說原本用 libX 1.0.0 的程式,改用 libX 1.2.3 也不會有問題。但用 libX 2.0.0 可能會有問題。
  • libX.so.A: soname。供 dynamic linker 使用。
  • libX.so: linker name,供 static linker 使用。用 gcc -lX 的時候,會先找 libX.so,找不到再找 libX.a (static library) (見 man ld-l namespec )。

制作shared library 時,需要:

  • (real name) 產生 libX.A.B.C
  • (soname) 產生 soft link libX.so.A → libX.A.B.C
  • (linker name) 產生 soft link libX.so → libX.so.A

ldconfig 可以幫我們產生 soname,linker name 則要自己產生。

綜合上面所知,更正式的產生以及安裝 shared library 流程是:

$ gcc -fPIC -g -shared -Wl,-soname,libfoo.so.1 -o libfoo.so.1.0.0 foo.c
$ sudo cp libfoo.so.1.0.0 /usr/local/lib
# 在 /usr/local/lib 產生 soname libfoo.so.1 -> libfoo.so.1.0.0
$ sudo ldconfig
# 產生 linker name
$ cd /usr/local/lib && sudo ln -s libfoo.so.1 libfoo.so

gcc 用 -Wl,NAME,VALUE 的方式傳遞參數給 ld,所以 -Wl,-soname,libfoo.so.1 是 gcc 傳遞參數 -soname=libfoo.so.1 給 ld,這個參數的意思是寫入 soname “libfoo.so.1” 到產生的 shared library 裡。gcc 的參數說明見 man gcc,ld 的參數則是看 man ld

Link main 的方法一樣:

$ gcc -g -o main main.c -lfoo# ldd 的結果略有不同,變成剛才指定的 soname "libfoo.so.1"
$ ldd main
...
libfoo.so.1 => /usr/local/lib/libfoo.so.1 (0x...)
...

注意 executable main 裡是註明需要 shared library “libfoo.so.1”。不是 “libfoo.so.1.0.0”,也不是 “libfoo.so”。因為 ld 是從 libfoo.so.1.0.0 這個檔案裡讀出 soname “libfoo.so.1”,然後寫入 main。

日後需要更新 libfoo 的話,只要有向前相容,可以產生不同的 real name 但維持一樣的 soname,然後一樣複製到 /usr/local/lib,再重跑 ldconfigldconfig 會更新 soname 指向新版的 real name。

像是這樣:

$ gcc -fPIC -g -shared -Wl,-soname,libfoo.so.1 -o libfoo.so.1.2.3 foo.c
$ sudo cp libfoo.so.1.2.3 /usr/local/lib
# 在 /usr/local/lib 產生 soname libfoo.so.1 -> libfoo.so.1.2.3
$ sudo ldconfig

這樣舊的程式不用重新 link,它會直接使用新版的 libfoo.so.1.2.3。

若需要產生不向前相容的 library,需要用不同的 soname:

# soname 由 libfoo.so.1 改為 libfoo.so.2
$ gcc -fPIC -g -shared -Wl,-soname,libfoo.so.2 -o libfoo.so.2.0.0 foo.c
$ sudo cp libfoo.so.2.0.0 /usr/local/lib
# 在 /usr/local/lib 產生 soname libfoo.so.2 -> libfoo.so.2.0.0
$ sudo ldconfig
# 更新 linker name
$ cd /usr/local/lib && sudo ln -fs libfoo.so.2 libfoo.so

之後用 gcc -lfoo 時,會用到 libfoo.so.2.0.0。若還想要用舊版 1.x.x.,就得在 gcc 參數裡直接指定在 /usr/local/lib 下的檔案,而不能用 -lfoo 了。

另外,先前產生的 executable 內有註明是使用 libfoo.so.1,執行舊版程式時,dynamic linker 會找到 libfoo.so.1 → libfoo.so.1.2.3,所以不用擔心安裝 libfoo.so.2.x.x 後會用錯版本。

Recap: ld, ld.so, ldconfig 的關係

以 Ubuntu 16.04 上的 libfontconfig 為例說明。

  1. 安裝 libfontconfig1:amd64 會產生
# real name
/usr/lib/x86_64-linux-gnu/libfontconfig.so.1.9.0
# soname
/usr/lib/x86_64-linux-gnu/libfontconfig.so.1
  • libfontconfig.so.1.9.0 是目標 shared library。
  • ldconfig 產生 soft link libfontconfig.so.1 指向 libfontconfig.so.1.9.0。

2. 對於有使用 libfontconfig 的 executable 來說,ld.so 從 executable 的 DT_NEEDED 欄位讀到字串 “libfontconfig.so.1” (可用 objdump -p PROG | grep NEEDED 列出此欄位),用這個字串順著搜尋路徑找到 /usr/lib/x86_64-linux-gnu/libfontconfig.so.1,然後載入 /usr/lib/x86_64-linux-gnu/libfontconfig.so.1.9.0

3. 安裝 libfontconfig1-dev:amd64 會產生

# linker name
/usr/lib/x86_64-linux-gnu/libfontconfig.so

libfontconfig.so 是 soft link 指向 libfontconfig.1.9.0,供開發者使用,這樣 gcc -lfontconfig 才能找到它 (告知 ld 去找 libfontconfig.so)。

rpath

若不想裝到系統路徑 ( ldconfig 掃瞄的位置、/lib、/usr/lib ),也可以在 executable / shared library 內寫入 rpath:

$ gcc -fPIC -g -shared  -o libfoo.so foo.c
$ gcc -g -o main main.c -lfoo -L. -Wl,-rpath,`pwd`
$ ldd main
...
libfoo.so => /home/fcamel/test/libfoo.so
...
$ ./main
call foo

main 裡多了 DT_RPATH 的資訊:

$ objdump -p main | grep RPATH
RPATH /home/fcamel/test

rpath 和 shared library

假設 libfoo.so 會用到 libbar.so,程式更改如下 :

$ cat foo.c
#include "foo.h"
#include "util/bar.h"
#include <stdio.h>void foo() {
printf("call foo\n");
bar();
}
$ cat util/bar.h
#ifndef BAR_H
#define BAR_H
void bar();
#endif
$ cat util/bar.c
#include "bar.h"
#include <stdio.h>void bar() {
printf("call bar\n");
}

程式的關係是 main → libfoo.so → libbar.so。可以將 libbar.so 的位置用 rpath 寫到 libfoo.so 裡,這樣產生 main 的時候,不需再指定 libbar.so 的位置:

$ gcc -fPIC -g -shared -o util/libbar.so util/bar.c
$ gcc -fPIC -g -shared -o libfoo.so foo.c -lbar -Lutil -Wl,-rpath,`pwd`/util
$ gcc -g -o main main.c -lfoo -L. -Wl,-rpath,`pwd`
$ ./main
call foo
call bar

用 objdump 和 ldd 檢視產生的檔案:

$ objdump -p main | grep RPATH
RPATH /home/fcamel/test
$ objdump -p libfoo.so | grep RPATH
RPATH /home/fcamel/test/util
$ ldd main
linux-vdso.so.1 => (0x...)
libfoo.so => /home/fcamel/test/libfoo.so (0x...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x...)
libbar.so => /home/fcamel/test/util/libbar.so (0x...)
/lib64/ld-linux-x86-64.so.2 (0x...)

可看出 main 本身不知道 libbar.so 的位置,不過直接使用 libbar.so 的 libfoo.so 知道。

需要注意的是這裡 rpath 都用絕對路徑,目的是確保 main 移到別的位置後仍能找到 shared library。但使用絕對路徑沒那麼彈性,不方便複製到不同機器使用。

rpath 的 $ORIGIN

若能將 executable 和用到的 shared library 放在一個目錄裡,整個目錄傳到別台機器上就能使用的話,會很方便。也就是說,我們希望以 executable 所在的目錄為底,用相對路徑來找目錄下的 shared library。rpath 的 $ORIGIN 提供這個語意。

$ORIGIN 表示 executable 所在的目錄,所以可以將 shared library 放在 lib 下,然後指定 rpath 為 $ORIGIN/lib:

$ gcc -Wl,rpath,'$ORIGIN'/lib ...

這樣 executable / shared library 內會記錄:

RPATH                $ORIGIN/lib

只要維持 executable 和 shared library 的相對位置,整個目錄移到不同位置仍能正常執行。

Recap: 該用何種方法安裝 shared library?

  • 系統共用的 shared library,裝到 ldconfig 掃瞄的路徑,用 ldconfig 產生 soname 並自己產生 linker name。省硬碟空間省記憶體。
  • 開發時會很常重編,將各個模組編成獨立的 shared library 可以節省 link 時間。用 rpath=$ORIGIN,比 LD_LIBRARY_PATH 乾淨。
  • 除錯時可用 LD_LIBRARY_PATH 檢查。
  • 部署 executable 到使用者環境時,從安全性和除錯 (避免被替換程式) 以及效率來看,全部合在一個 executable 較佳。
  • 承上,有用到 LGPL 的 library 或是希望提供彈性讓使用者可以自行替換不同實作 (例如升級 shared library),可將相關程式另編成 shared library,然後在 executable 使用rpath=$ORIGIN
  • 部署 executable 到 server 環境時,有很多選擇。我偏向使用 rpath=$ORIGIN,這樣 server 環境和開發者環境一樣,減省環境差異造成不同的行為,還有開發者方便替換自己的除錯版本到 server 環境。

Symbol Interposition 和 Symbol Visibility

symbol interposition (function interposition) 是指替換 symbol 為不同的實作。在前篇提到的 LD_PRELOAD 是最容易使用的方法。預設編譯 shared library 後,shared library 內全部 symbol 都是 global symbol,都能被替換。

前篇所言,ld.so 會先從 executable 內找 symbol,找不到才會從 shared library 找。所以若 executable 有和 shared library 內一樣的 symbol,執行 shared library 內程式時,反而會用到 executable 內的 symbol,這違反直覺。

編譯 shared library 時可加參數 -Bsymbolic 讓外部無法替換 shared library 的 symbol (參數細節見 man ld) 。但這個作法是全部一起封殺,失去日後用 LD_PRELOAD 替換部份 public API 的彈性。

所以比較好的作法是將 shared library 的 public API 設為 global symbol,剩下的設為 local symbol,這樣外部無法使用也無法替換 local symbol,讓 shared library 有更好的封裝效果。開發時將各個模組編成 shared library 並設好 visibility,可以避免使用到 private API (會造成 link error)。symbol visibility 測試的例子見這裡

剛才的例子是用 Version Script 設定 global/local symbol。symbol visibility 獨立於程式,比較不好維護。比較好的作法是用 gcc 的屬性 visibility。基本用法是編譯全部程式時都加上 -fvisibility=hidden -fvisibility-inlines-hidden,預設改成產生 local symbol。然後在 public API 前面加上 __attribute__ ((visibility (“default”))),標示為 global symbol。

像是這樣 :

$ cat foo.c
#include <stdio.h>
void xyz() {
printf("foo-xyz\n");
}
void xyz2() {
printf("foo-xyz2\n");
}
__attribute__ ((visibility ("default"))) void func() {
xyz();
xyz2();
}
$ gcc -g -fPIC -c foo.c -fvisibility=hidden
$ gcc -shared foo.o -o libfoo.so
$ nm libfoo.so
...
00000000000006c6 T func
...
00000000000006a0 t xyz
00000000000006b3 t xyz2

xyz() 和 xyz2() 都變成 local symbol 了 (T/t 分別表示 global/local symbol)。

由於 visibility 是 gcc 擴充功能,在不同平台語法有些不同,參考官方文件 visibility 了解跨平台的寫法。或是參考 Chromium 的用法,像是 base/base_export.h 定義的 BASE_EXPORT 以及使用 BASE_EXPORT 的例子

參考資料

相關資料

--

--