本文讨论两个需求:
- 如何让共享库文件也可以直接执行;
- 如何在可执行文件中用
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("hello\n");
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-linux.so.2
$ ./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 |
生成共享库 |
-fpic |
生成适合放入共享库的位置无关代码(PIC) |
方法一:让可执行文件可共享
先看一个 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 |
生成位置无关的可执行文件(在支持的目标上) |
-fpie |
类似 -fpic/-fPIC,但生成的位置无关代码只能链接进可执行文件 |
-rdynamic |
给 ELF 链接器传 -export-dynamic,把所有符号(而非仅用到的)加入动态符号表 |
-rdynamic 的作用
-rdynamic 等价于 -Wl,-E/-Wl,--export-dynamic,它确保”库”中所有符号都导出到动态符号表,包括当前未被用到的那些。
举个例子:如果 hello.c 里有一个独立的 hello() 函数,main 并没有调用它,但其他用到该库的可执行文件希望能调用它,那么 -rdynamic 就是必须的:
#include <stdio.h>
void hello(void)
{
printf("hello...\n");
}
int main()
{
printf("hello\n");
return 0;
}
假设另有一个调用 hello() 的程序 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, "%s\n", dlerror());
return EXIT_FAILURE;
}
dlerror(); /* Clear any existing error */
func = (void(*)(void))dlsym(handle, "hello");
error = dlerror();
if (error != NULL) {
fprintf(stderr, "%s\n", 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...
方法二:让共享库可执行
下面探讨另一种方式:在生成共享库的基础上,让它能够直接执行。
先回顾一下共享库。前面直接执行 libhello.so 时立刻段错误,根本原因是共享库没有强制提供一个标准的 C 程序入口——即使我们提供了 main(),程序的入口也没有指向它:
$ 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
000003d0:
那么先解决入口问题,用 -Wl,-emain 把入口指定为 main,结果运行还是出错:
$ 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:
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:
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
2
3 int main(void)
4 {
5 printf("hello\n");
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 不难发现,我们还少调用了一个标准的退出函数。改造一下,把 main 改为 void 返回、并用 _exit(0) 退出:
#include <stdio.h>
#include <unistd.h>
void main(void)
{
printf("hello\n");
_exit(0);
}
再编译运行就没有段错误了。进一步分析 glibc 会发现,实际的入口函数并非 main(),而是 _start:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("hello\n");
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 节区来指定动态链接器:
#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("hello\n");
return 0;
}
void _start(void)
{
int ret;
ret = main();
_exit(ret);
}
再试试,完美运行:
$ gcc -m32 -shared -fpic -o libhello.so hello.c
$ ./libhello.so
hello
最后整理一下,把”既当共享库、又能执行”的能力用一个宏统一控制:
#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();
}
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 一样,让一个文件既可以当共享库使用,还能直接执行,并介绍了两种方法:
- 方法一:用
-pie -fpie -rdynamic编译,让可执行文件同时具备共享库的能力,简单方便; - 方法二:基于共享库补齐
_start入口与.interp节区,让它能够直接执行,揭示了更多背后的工作原理。
两种方法都能达成目标,按需选用即可。