吴章金: 如何创建一个*可执行*的共享库
- 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 一样,既可以当共享库使用,还能直接执行,并且讲述了两种方法。
两种方法都可以达成目标,第一种方法用起来简单方便,第二种方法揭示了很多背后的工作逻辑。