吳章金: 如何創建一個*可執行*的共享庫

  • 2019 年 11 月 10 日
  • 筆記

license: "cc-by-nc-nd-4.0" description: "本文手把手指導如何創建一個可以執行的共享目標文件"

前言

前段時間,有多位同學在「泰曉原創團隊」微信群聊到 C 語言相關的兩個問題:

  • 如何讓共享庫文件也可以直接執行
  • 如何在可執行文件中用 dlopen 解析自身的函數

這兩個需求匯總起來,可以大體理解為如何讓一個程式既可以作為共享庫,又能夠直接運行。

這類需求在 Linux 下面其實很常見,比如 ld-linux.so 和 libc.so:

$ file /lib/i386-linux-gnu/ld-linux.so.2  /lib/i386-linux-gnu/ld-linux.so.2: symbolic link to ld-2.23.so  $ file /lib/i386-linux-gnu/ld-2.23.so  /lib/i386-linux-gnu/ld-2.23.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), dynamically linked  $ /lib/i386-linux-gnu/ld-2.23.so  Usage: ld.so [OPTION]... EXECUTABLE-FILE [ARGS-FOR-PROGRAM...]    $ file /lib/i386-linux-gnu/libc.so.6  /lib/i386-linux-gnu/libc.so.6: symbolic link to libc-2.23.so  $ file /lib/i386-linux-gnu/libc-2.23.so  /lib/i386-linux-gnu/libc-2.23.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (GNU/Linux), dynamically linked    $ /lib/i386-linux-gnu/libc.so.6  GNU C Library (Ubuntu GLIBC 2.23-0ubuntu11) stable release version 2.23, by Roland McGrath et al.

那如何做到的呢?

先來看看兩類文件的區別

當前 Linux 下面的二進位程式標準格式是 ELF,這類格式可以用來表示 4 種不同類型的文件:

  • 可重定位目標文件(.o),用於靜態鏈接
  • 可執行文件格式,用於運行時創建進程映像
  • 共享目標文件(.so,共享庫),協同可執行文件創建進程映像
  • Core dump(core),運行過程中崩潰時自動生成,用於調試

我們來看中間兩類:

  • 可執行文件
    • 如果不引用外部庫函數,那麼所有符號地址是確定的,執行載入後可直接運行
  • 共享庫
    • 如果可執行文件用到外部庫函數,那麼需要通過動態鏈接器載入引用到的共享庫並在運行時解析用到的相應符號

所以,前者和後者通常情況下是獨立存在的,是聯合行動的,兩者差異明顯:

  • 可執行文件有標準的 C 語言程式執行入口 main,而共享庫則並沒有這類強制要求
  • 後者為了確保可以靈活被多個可執行文件共享,所以,符號地址在鏈接時是相對的,在裝載時動態分配和計算符號地址

接下來做個實驗具體看看兩者的區別,準備一個「爛大街」的 hello.c 先:

#include <stdio.h>    int main(void)  {      printf("hellon");        return 0;  }

先來編譯為可執行文件(-m32 用來生成採用 i386 指令集的程式碼):

$ gcc -m32 -o hello hello.c  $ file hello  hello: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-  $ ./hello  hello

再來編譯為共享目標文件,並嘗試直接執行它:

$ gcc -m32 -shared -fpic -o libhello.so hello.c  $ file libhello.so  libhello.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), dynamically linked  $ ./libhello.so  Segmentation fault (core dumped)

直接執行失敗,再試試如何生成一個可執行文件來載入運行它,這個是引用共享庫的通常做法:

$ gcc -m32 -o hello.noc -L./ -lhello  $ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.noc  hello

通過實驗,可以確認「正常」創建出來的共享庫並不能夠直接運行,而是需要鏈接到其他可執行文件中。

上述編譯選項簡介:

-shared Create a shared library. -fpic Generate position-independent code (PIC) suitable for use in a shared library

讓可執行文件可共享

接下來,好好研究一番。

先來看一個 gcc 直接支援的方式:

$ gcc -m32 -pie -fpie -rdynamic -o libhello.so hello.c  $ file libhello.so  libhello.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), dynamically linked    $ ./libhello.so  hello    $ gcc -m32 -o hello.noc -L./ -lhello  $ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.noc  hello

確實可以執行,而且可以作為共享庫鏈接到其他可執行文件中。

上述編譯選項簡介:

-pie Produce a position independent executable on targets that support it. -fpie These options are similar to -fpic and -fPIC, but generated position independent code can be only linked into executables. -rdynamic Pass the flag -export-dynamic to the ELF linker, on targets that support it. This instructs the linker to add all symbols, not only used ones, to the dynamic symbol table.

-rdynamic 等價於 -Wl,-E / -Wl,--export-dynamic,確保所有「庫」中的符號都 export 到動態符號表,包括當前未用到的那些符號。

舉個例子,如果 hello.c 有一個獨立的 hello() 函數,沒有別的函數(這裡是指 main)調用到,但是其他用到該庫的可執行文件希望用到它,那麼 -rdynamic 就是必須的。

$ cat hello.c  #include <stdio.h>    void hello(void)  {      printf("hello...n");  }    int main(void)  {      printf("hellon");        return 0;  }  $ cat main.c    $ gcc -m32 -pie -fpie -rdynamic -o libhello.so hello.c  $ readelf --dyn-syms libhello.so  | grep hello      19: 00000662    43 FUNC    GLOBAL DEFAULT   14 hello    $ gcc -m32 -o hello.main main.c -L./ -lhello  $ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.main  hello...

如果沒有 -rdynamic,鏈接時就沒法使用。

$ gcc -m32 -o hello.main main.c -L./ -lhello  main.c:(.text+0x7): undefined reference to `hello'

同理,dlopen 自解析時也需要 -rdynamic

#include <stdio.h>  #include <stdlib.h>  #define _GNU_SOURCE  #include <dlfcn.h>    void hello(void)  {      printf("hello...n");  }    int main(void)  {      void *handle;          void (*func)(void);          char *error;            handle = dlopen(NULL, RTLD_LAZY);          if (!handle) {              fprintf(stderr, "%sn", dlerror());              return EXIT_FAILURE;          }            dlerror();    /* Clear any existing error */            func = (void (*)(void)) dlsym(handle, "hello");            error = dlerror();          if (error != NULL) {              fprintf(stderr, "%sn", error);              return EXIT_FAILURE;          }        func();          dlclose(handle);        return 0;  }

實測效果:

$ gcc -m32 -pie -fpie -o libhello.so hello.c -ldl  $ ./libhello.so  ./libhello.so: undefined symbol: hello    $ gcc -m32 -pie -fpie -rdynamic -o libhello.so hello.c -ldl  $ ./libhello.so  hello...

讓共享庫可執行

下面來探討另外一種方式,在生成共享庫的基礎上,來研究怎麼讓它可以執行。

先來回顧一下共享庫,在本文第 2 節直接執行的時候馬上出段錯誤,基本原因是共享庫沒有強制提供一個標準的 C 程式入口。

即使是我們提供了 main()(把標準 hello.c 編譯為 libhello.so),程式的入口並沒有指向它。

$ readelf -h libhello.so | grep "Entry point"    Entry point address:               0x3d0  $ objdump -d libhello.so | grep 3d0 | head -2   380:    e8 4b 00 00 00          call   3d0 <__x86.get_pc_thunk.bx>  000003d0 <__x86.get_pc_thunk.bx>:

那麼,先解決入口的問題並運行,同樣出錯了:

$ gcc -m32 -shared -fpic -o libhello.so hello.c -Wl,-emain  $ readelf -h libhello.so | grep "Entry point"    Entry point address:               0x4b9  $ objdump -d libhello.so | grep 4b9 | head -2  000004b9 <main>:   4b9:    8d 4c 24 04             lea    0x4(%esp),%ecx    $ ./libhello.so  Segmentation fault

加上 -g 編譯用 gdb 來看看原因:

$ gcc -m32 -g -shared -fpic -o libhello.so hello.c -Wl,-emain  $ objdump -d libhello.so | grep 4b9 | head -2  000004b9 <main>:   4b9:    8d 4c 24 04             lea    0x4(%esp),%ecx    $ ulimit -c unlimited  $ ./libhello.so  Segmentation fault (core dumped)    $ gdb ./libhello.so core  Core was generated by `./libhello.so'.  Program terminated with signal SIGSEGV, Segmentation fault.  #0  0x000003a6 in ?? ()  (gdb) bt  #0  0x000003a6 in ?? ()  #1  0xf77344e3 in main () at hello.c:5  (gdb) l hello.c:5  1    #include <stdio.h>  2  3    int main(void)  4    {  5        printf("hellon");  6  7        return 0;  8    }  (gdb)

可以看到是執行 printf 的時候出錯,說明庫函數的解析出了問題,主動用動態連接器跑一下看看:

$ /lib/i386-linux-gnu/ld-2.23.so ./libhello.so  hello  Segmentation fault (core dumped)

哇哦,可以解析符號並列印了,不過最後還是崩潰了?

如果去分析 glibc 的 __libc_start_main 不難發現,我們還少調用一個標準退出函數,改造過後:

$ cat hello.c  #include <stdio.h>  #include <unistd.h>    void main(void)  {      printf("hellon");        _exit(0);  }

再編譯運行就沒段錯誤了。再進一步,同樣是分析 glibc,發現實際的入口函數並非 main(),而是 _start

$ cat hello.c  #include <stdio.h>  #include <unistd.h>    int main(void)  {      printf("hellon");        return 0;  }    void _start(void)  {      int ret;        ret = main();      _exit(ret);  }

編譯時連入口都不用指定了:

$ gcc -m32 -g -shared -fpic -o libhello.so hello.c  $ /lib/i386-linux-gnu/ld-2.23.so ./libhello.so  hello

也可以當共享庫使用:

$ gcc -m32 -o hello.noc -L./ -lhello  $ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.noc  hello

最後還有一點遺憾,怎麼樣才能「動態」鏈接,而不是手動指定動態鏈接器呢?我們在程式中主動加入一個 .interp 節區來指定動態鏈接器吧。

$ cat hello.c  #include <stdio.h>  #include <unistd.h>    asm(".pushsection .interp,"a"n"      "        .string "/lib/i386-linux-gnu/ld-linux.so.2"n"      ".popsection");    int main(void)  {      printf("hellon");        return 0;  }    void _start(void)  {      int ret;        ret = main();      _exit(ret);  }

再試試,完美運行:

$ gcc -m32 -shared -fpic -o libhello.so hello.c  $ ./libhello.so  hello

最後,稍後整理一下:

$ cat hello.c  #include <stdio.h>    #ifdef EXEC_SHARED  #include <unistd.h>    asm(".pushsection .interp,"a"n"      "        .string "/lib/i386-linux-gnu/ld-linux.so.2"n"      ".popsection");    int entry(void)  {      printf("%s %d: %s(): the real entry of shared library here.n", __FILE__, __LINE__, __func__);        /* do whatever */        return 0;  }    int main(void)  {      return entry();        return 0;  }    void _start(void)  {      int ret;        ret = main();      _exit(ret);  }  #endif    void hello(void)  {      printf("hello...n");  }

當普通共享庫使用,默認編譯即可,要能夠執行的話,實現一下 entry(),編譯時打開 EXEC_SHARED 即可:

$ gcc -m32 -shared -fpic -o libhello.so hello.c -DEXEC_SHARED  $ ./libhello.so  hello.c 12: entry(): the real entry of shared library here.    $ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.main  hello...

小結

本文詳細講解了如何像 libc.so 和 ld-linux.so 一樣,既可以當共享庫使用,還能直接執行,並且講述了兩種方法。

兩種方法都可以達成目標,第一種方法用起來簡單方便,第二種方法揭示了很多背後的工作邏輯。