Linux 创建一个可执行的共享库

本文讨论两个需求:

  • 如何让共享库文件也可以直接执行;
  • 如何在可执行文件中用 dlopen 解析自身的函数。

这两个需求汇总起来,可以理解为:如何让一个程序既可以作为共享库,又能够直接运行。这类需求在 Linux 下很常见,ld-linux.solibc.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.sold-linux.so 一样,让一个文件既可以当共享库使用,还能直接执行,并介绍了两种方法:

  • 方法一:用 -pie -fpie -rdynamic 编译,让可执行文件同时具备共享库的能力,简单方便;
  • 方法二:基于共享库补齐 _start 入口与 .interp 节区,让它能够直接执行,揭示了更多背后的工作原理。

两种方法都能达成目标,按需选用即可。


   转载规则


《Linux 创建一个可执行的共享库》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
C++ 内存碎片化:成因与解法 C++ 内存碎片化:成因与解法
内存碎片化是 C++ 在高并发服务中的一个关键性能顽疾。碎片分成两类:外部碎片——空闲的内存块零散、不连续,总的空闲内存充足,却无法满足大块内存的分配;内部碎片——分配出去的内存大于实际需求,剩下的空间被闲置。 高并发场景下,高频次的小内存
2020-08-02
下一篇 
C++ 双缓冲:读写分离 C++ 双缓冲:读写分离
1. 引言:为什么我们需要双缓冲?在现代软件开发中,尤其是在高并发、实时性要求极高的系统中,数据的读写分离是一个永恒的话题。最近在处理一个实时数据系统时,我遇到了数据库(MySQL)在高并发读写下的性能瓶颈。传统的加锁机制虽然能保证数据一致
2020-07-21
  目录