泰晓科技 -- 聚焦 Linux - 追本溯源,见微知著!
网站地址:https://tinylab.org

儿童Linux系统,可打字编程学数理化
请稍侯

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

Wu Zhangjin 创作于 2019/11/07

By Falcon of TinyLab.org Nov 02, 2019

前言

前段时间,有多位同学在“泰晓原创团队”微信群聊到 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("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-
$ ./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 usedones, 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("hello\n");

	return 0;
}
$ cat main.c
#include <stdio.h>

extern void hello(void);

int main(void)
{
	hello();
	return 0;
}

$ 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...

让共享库可执行

下面来探讨另外一种方式,在生成共享库的基础上,来研究怎么让它可以执行。

先来回顾一下共享库,在本文第 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("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 不难发现,我们还少调用一个标准退出函数,改造过后:

$ cat hello.c
#include <stdio.h>
#include <unistd.h>

void main(void)
{
	printf("hello\n");

	_exit(0);
}

再编译运行就没段错误了。再进一步,同样是分析 glibc,发现实际的入口函数并非 main(),而是 _start

$ cat hello.c
#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

稍微补充,完整的 _start 还需要处理参数、实现 init/fini 逻辑,这里限于篇幅不做展开。

最后还有一点遗憾,怎么样才能“动态”链接,而不是手动指定动态链接器呢?我们在程序中主动加入一个 .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("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

最后,稍微整理一下:

$ 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 一样,既可以当共享库使用,还能直接执行,并且讲述了两种方法。

两种方法都可以达成目标,第一种方法用起来简单方便,第二种方法揭示了很多背后的工作逻辑。

如果想与本文作者更深入地探讨共享库、程序执行、动态链接、内联汇编、程序真正入口等本文涉及的内容以及它们背后的程序链接、装载和运行原理,欢迎订阅吴老师的 10 小时 C 语言进阶视频课:《360° 剖析 Linux ELF》



Read Related:

Read Latest: