淺談函式庫

比較shared/static library

程式在執行的時候,大部分都會需要引用函式庫(library),library有分shared和static,兩者代表不同的引用方式。

static library shared library
優點 不需要考慮執行環境的相依性問題 使用空間小(檔案和記憶體)、更換library不用重build
缺點 執行檔極大、更換library需重build 在異地執行可能會因為相依性無法執行

動態函式庫

在開始前,先確定幾個名詞

  • soname:代表特定library的名稱,如libmylib.so.1,最後面的1是version
  • real name:實際放有library程式的檔案名稱,名稱會包含三個版號,分別為version, minor和release,如libmylib.so.1.0.0
    • version代表原介面有移除或改變,與舊版本不相容
    • minor代表新增介面,舊介面沒改
    • release代表程式修正,介面沒改
  • linker name:用於連結時的名稱,不含版號的soname,如libmylib。通常會link到實際的real name。

動態函式庫 - 如何編譯

首先我們先把.c編譯成.o,這邊要加上-fPIC的參數

這個原因是要產生Position Independent code,確保code segment在動態連結時不用花時間重新定位,而且重新定位會造成無法和其他process共享.text區段。

事實上,如果不加-fPIC也是可以產生library,但是產生的執行檔就需要另外存有重新定位的資訊(.rel.dyn區段),而且會有上述的問題。

1
gcc -c -fPIC hello.c world.c

接下來就是產生shared library了,解釋一下參數的意思

  • -shared:代表要編成shared library

  • -Wl:是用來傳遞參數給linker,讓-soname和libmylib.so.1傳給linker處理

  • -soname:用來指名soname為libmylib.so.1

  • -o:最後library會被輸出成libmylib.so.1.0.0

    1
    gcc -shared -Wl,-soname,libmylib.so.1 -o libmylib.so.1.0.0 hello.o world.o

    soname很重要,就如同前面所提,可以讓開發者和應用程式表示兼容標準,可以用objdump確認soname

    1
    2
    $ objdump -p libmylib.so.1.0.0 | grep SONAME
    SONAME libmylib.so.1

    完成後再用ln建立soname和linker name兩個檔案

    1
    2
    ln -s libmylib.so.1.0.0 libmylib.so
    ln -s libmylib.so.1.0.0 libmylib.so.1

動態函式庫 - 如何使用

如果有人要使用的話,下列兩種方式都可以。不過要注意目錄下如果同時有static和shared會使用shared為主,如果要static就要加上-static編靜態函式庫

1
2
gcc main.c libmylib.so -o a.out
gcc main.c -L. -lmylib -o a.out

但是shared library執行的時候還是需要有library才能執行,所以要把.so安裝到系統中,有三種方法:

  1. 把libmylib.so.1 放到系統常見的library目錄,如/lib, /usr/lib
  2. 設定/etc/ld.so.conf ,加入一個新的library搜尋目錄,並執行ldconfig更新/etc/ld.so.cache
  3. 設定LD_LIBRARY_PATH 環境變數來搜尋library,如LD_LIBRARY_PATH=. ./a.out

這邊提一下一般而言找library的順序

  1. LD_LIBRARY_PATHLD_AOUT_LIBRARY_PATH環境變數所指的路徑
  2. ld.so.cache的記錄來找shared library。
  3. /lib,/usr/lib內的檔案

查看shared library的關係 - ldd

我們要怎麼知道某個執行檔有使用到哪些library呢?這時候就要用到ldd這個指令了。

ldd其實是一個shell script,它會把檔案所用到library一一列出,包括library會用到的library。

舉例來說,如果我們不用ldd,其實是可以從ELF的Dynamic Section獲得shared library資訊

1
2
3
4
5
6
7
8
$ readelf -d /bin/cat

Dynamic section at offset 0x7dd8 contains 26 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x15e8
0x000000000000000d (FINI) 0x5a4c
...

我們看到NEEDED就是需要的dynamic library,但是這個library可能也需要其他library。

1
2
3
4
5
6
7
8
$ readelf -d /lib/x86_64-linux-gnu/libc.so.6

Dynamic section at offset 0x198ba0 contains 26 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [ld-linux-x86-64.so.2]
0x000000000000000e (SONAME) Library soname: [libc.so.6]
0x000000000000000c (INIT) 0x20050
...

因此我們知道/bin/cat需要libc.so.6,而libc.so.6還需要ld-linux-x86-64.so.2。這樣尋找實在太麻煩了,其實我們可以直接用ldd

1
2
3
4
$ ldd /bin/cat
linux-vdso.so.1 (0x00007fff8613c000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f654a3bf000)
/lib64/ld-linux-x86-64.so.2 (0x00007f654a967000)

看,是不是很輕鬆呢?

靜態函式庫

會有static library的概念是,如果我有很多.o檔,那每次要引用其實都不是很方便,所以最好的方法還是可以打包起來,也就是使用ar指令。

靜態函式庫 - 如何編譯

static libary建立方式很簡單,一樣要先建立.o

1
gcc -c test1.c test2.c

接下來開始打包,參數意義如下

  • r:代表加入新檔案或取代現有檔案

  • c:.a檔不存在時不會跳錯誤訊息

  • u:根據timestamp保留檔案

  • s:建立索引,加快連結速度

    1
    ar rcs libtest.a test1.o test2.o

    如果要顯示函式庫 libstack.a 的內容

    1
    2
    3
    $ ar -tv libtest.a
    rw-r--r-- 0/0 1464 Jan 1 00:00 1970 test1.o
    rw-r--r-- 0/0 1464 Jan 1 00:00 1970 test2.o

    如果要從libtest.a中取出test1.o

    1
    ar -x libtest.a test1.o

靜態函式庫 - 如何使用

編譯方法一樣很簡單,有兩種

1
2
3
gcc main.c libtest.a
# 也可以使用gcc的-l,-L代表要搜尋的目錄位置,-l會捨去library的lib開頭
gcc main.c -L. -ltest

symbol衝突

假設我們在創建library時遇到symbol衝突會發生什麼事呢?這邊我們分三種情況探討

首先先創三個檔案

hello.c

1
2
3
4
void test()
{
printf("hello\n");
}

world.c

1
2
3
4
void test()
{
printf("world\n");
}

main.c

1
2
3
4
5
6
void test();
int main()
{
test();
return 0;
}

shared library連結時,object file有衝突

嘗試編譯與連結

1
2
3
4
5
6
$ gcc -c -fPIC hello.c world.c
$ gcc -shared -o libmylib.so hello.o world.o
world.o: In function `test':
world.c:(.text+0x0): multiple definition of `test'
hello.o:hello.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

會發現出現錯誤,原因是動態連結跟一般編譯一樣會檢查symbol是否重複

static library打包時,object file有衝突

那如果是用static library呢?

1
2
3
4
5
$ gcc -c hello.c world.c
$ ar crs libhello.a hello.o
$ ar crs libworld.a world.o
$ gcc -o main.out main.c libhello.a libworld.a
hello

發現居然沒事,這個原因是因為ar只有打包功能不負責檢查。可是問題來了,到底是執行哪個呢?答案是看順序。

1
2
3
4
5
6
$ gcc -o main.out main.c libhello.a libworld.a
$ ./main.out
hello
$ gcc -o main.out main.c libworld.a libhello.a
$ ./main.out
world

使用shared library時,不同library有衝突

那如果是兩個shared library彼此間有函數衝突的現象呢?

1
2
3
gcc -fPIC -shared -o libhello.so  hello.c
gcc -fPIC -shared -o libworld.so world.c
gcc -o main.out libhello.so libworld.so main.c

結果一樣沒有錯誤,原因是在動態連結時會使用最先看到的symbol,所以順序不同就有不同結果

1
2
3
4
5
6
$ gcc -o main.out libhello.so libworld.so main.c
$ LD_LIBRARY_PATH=. ./main.out
hello
$ gcc -o main.out libworld.so libhello.so main.c
$ LD_LIBRARY_PATH=. ./main.out
world

這個特性也跟LD_PRELOAD有關,我們可以用LD_PRELOAD來抽換shared library就是因為連結時會先使用先看到的symbol。當然這也曾經造成了一些危害,例如goahead的CVE-2017-17562

執行中載入library

除了執行開始時載入library外,我們也可以用程式來載入

1
2
3
4
5
6
7
8
9
10
// 動態載入所需的header
#include <dlfcn.h>
// 載入指定library
void *dlopen(const char *filename, int flag);
// 透過symbol name取得symbol在library的記憶體位址
void *dlsym(void *handle, const char *symbol);
// 關閉dlopen開啟的handler
int dlclose(void *handle);
// 傳回錯誤訊息。
char *dlerror(void);

範例:dltest.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int main() {
void *handle;
void (*f)();
char *error;
/* 開啟之前所撰寫的libmylib.so 程式庫 */
handle = dlopen("./libmylib.so", RTLD_LAZY);
if( !handle ) {
fputs( dlerror(), stderr);
exit(1);
}
/* 取得hello function 的address */
f = dlsym(handle, "hello");
if(( error=dlerror())!=NULL) {
fputs(error, stderr);
exit(1);
}
/* 呼叫function */
f();
/* 結束handler */
dlclose(handle);
return 0;
}

記得編譯時要連結dl library

1
2
gcc dltest.c -ldl
LD_LIBRARY_PATH=. ./a.out

library公開symbols管理

有時候我們並不希望所提供的library會把所有symbol都洩漏出去,這時候大部分的人都會使用static限制外部呼叫。但是當這個函式在library中其他檔案會引用到,那就沒辦法設為static了。

那該怎麼辦呢?這邊有兩個方法:

使用 version script

首先我們先創兩個檔案當範例

test.c

1
2
3
4
void test()
{
printf("test\n");
}

func.c

1
2
3
4
5
6
void test();
void func()
{
printf("func\n");
test();
}

然後我們編成shared library,並且看看symbol

1
2
3
4
5
6
$ gcc -fPIC -c test.c func.c
$ gcc -shared -o libmylib.so test.o func.o
$ nm -D libmylib.so | grep -v '_' # -D 代表顯示dynmaic部分,-v 代表反向選擇
00000000000005e8 T func
U puts
00000000000005d5 T test

可以看到test還是被暴露出來了,但是明明test應該只想要在library中被使用而已。

這時候我們可以試試GNU linker的version script。

libmylib.map

1
2
3
4
{
global: func;
local: *;
};

這個意思是只要顯示func,其他function都要隱藏。然後我們link的時候加上version script試看看:

1
2
3
4
$ gcc -shared -o libmylib.so test.o func.o -Wl,--version-script,libmylib.map
$ nm -D libmylib.so | grep -v '_'
00000000000004e8 T func
U puts

成功隱藏test了!

使用__attribute__語法

除了使用version script以外,也可以用gcc特有的語法,__attribute__((visibility("default")))

首先我們先改寫要公開的函式,代表我們只要暴露func()給外界看到

func.c

1
2
3
4
5
6
void test();
__attribute__((visibility("default"))) void func()
{
printf("func\n");
test();
}

然後在編譯成.o時要記得加上-fvisibility=hidden,把其他function都隱藏起來。

1
2
3
4
5
$ gcc -c -fPIC test.c func.c -fvisibility=hidden
$ gcc -shared -o libmylib.so test.o func.o
$ nm -D libmylib.so | grep -v '_'
00000000000005a8 T func
U puts

達到的效果和version script一樣!

用version script控制版本

這邊我們再多談談version script其他的用法,其實他除了管理要暴露出來的symbol外,我們也可以依照版本控制library要暴露出來的function。

首先我們先出第一版程式

libtest.c

1
2
3
4
5
#include <stdio.h>
void func(int num)
{
printf("num=%d\n", num);
}

libtest1.h

1
void func(int num);

version1.c

1
2
3
4
5
6
7
#include <stdio.h>
#include "libtest1.h"
int main()
{
func(1);
return 0;
}

然後正常編譯執行

1
2
3
4
5
$ gcc -fPIC -c libtest.c
$ gcc -shared -o libtest.so libtest.o
$ gcc -L. -ltest -o version1.out version1.c
$ LD_LIBRARY_PATH=. ./version1.out
num=1

很順利正常執行,那我們假設現在要出第二個版本可以怎麼做

libtest2.c

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
void func_1(int num)
{
printf("num=%d\n", num);
}

void func_2(int num1, int num2)
{
printf("num1=%d, num2=%d\n", num1, num2);
}
__asm__(".symver func_1,func@LIBTEST_1.0");
__asm__(".symver func_2,func@@LIBTEST_2.0");

稍微解釋一下,首先先實作兩個function,然後再用後面兩個__asm__symver來把同樣symbol加上版號,至於第二行@@的意思代表為預設版本。

接下來的部分就一樣撰寫新的程式

libtest2.h

1
void func(int num1, int num2);

version2.c

1
2
3
4
5
6
7
#include <stdio.h>
#include "libtest2.h"
int main()
{
func(1,2);
return 0;
}

然後這時候就要出動version script了

libtest2.map

1
2
3
4
5
6
7
LIBTEST_1.0 {
global: func;
local: *;
};
LIBTEST_2.0 {
global: func;
}LIBTEST_1.0;

然後我們編譯並執行看看

1
2
3
4
5
6
7
$ gcc -fPIC -c libtest2.c
$ gcc -shared -o libtest.so libtest2.o -Wl,--version-script,libtest2.map
$ gcc -L. -ltest -o version2.out version2.c
$ LD_LIBRARY_PATH=. ./version1.out
num=1
$ LD_LIBRARY_PATH=. ./version2.out
num1=1, num2=2

可以看到兩者執行結果不同,為什麼會這樣呢?我們先看一下他們連結到的symbol

1
2
3
4
5
6
7
8
$ readelf -a version1.out  | grep func
000000601018 000500000007 R_X86_64_JUMP_SLO 0000000000000000 func + 0
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND func
51: 0000000000000000 0 FUNC GLOBAL DEFAULT UND func
$ readelf -a version2.out | grep func
000000601018 000100000007 R_X86_64_JUMP_SLO 0000000000000000 func@LIBTEST_2.0 + 0
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND func@LIBTEST_2.0 (2)
46: 0000000000000000 0 FUNC GLOBAL DEFAULT UND func@@LIBTEST_2.0

可以看到version1.out是使用func,而version2.out的symbol就是func@@LIBTEST_2.0。那同樣是引用相同library,到底是怎麼知道要呼叫哪個func呢?在呼叫func的情況下,會自動找到最初的版本也就是func@LIBTEST_1.0。而之後的程式編譯時link library則會去找default的版本,也就是有兩個@的func@@LIBTEST_2.0,所以就不會有搞混的情況發生了。

這個方法在要維持兼容性的情況下非常好用,可以在不影響舊版的情況下改變函式規格。

參考