计算机系统篇之链接(6):动态链接
Author: stormQ
Created: Wednesday, 15. April 2020 04:35PM
Last Modified: Sunday, 10. January 2021 03:43PM
本文描述了引入动态链接库的动机,并提供了 Linux 系统中生成和使用动态链接库的示例。
共享库,也被称为可共享目标文件,可以被动态链接器在加载期或运行期加载到任意的内存地址,并和一个内存中的程序链接起来。
引入共享库的目的是为了解决静态库的缺点。相比于静态库的三个缺点,共享库对应的优点为:1)可以在加载期或运行期进行链接过程。因此,共享库更新后不用像静态库那样,可执行目标文件必须显式地重新链接。2)节省磁盘空间。共享库的代码和数据被所有引用该共享库的可执行目标文件共享。3)节省内存空间。共享库的代码在内存中的一份拷贝被所有引用该共享库的进程共享。
区别 | 使用的链接器 | 作用对象 | 发生的时期 |
---|---|---|---|
静态链接 | 静态链接器(如:ld on Linux) | 静态库 | 编译期 |
动态链接 | 动态链接器(如:ld-linux-x86-64.so.2 on Linux) | 共享库 | 加载期或运行期 |
注:
Linux 中的静态链接器ld
是可执行目标文件,而动态链接器(如ld-linux-x86-64.so.2
)是可共享目标文件。
Linux 中的静态链接器ld
的输入文件可以是共享库,但只是拷贝了重定位信息和符号表并解析符号,但未重定位。也就是说,即便共享库可以作为Linux 中的静态链接器ld
的输入文件,但实际上没有完全链接。
1) 生成共享库(On x86-64 Linux)
$ g++ -o libtest.so test.cpp -shared -fPIC
注:
-shared
属于链接器选项,指示链接器生成共享库。
-fPIC
属于编译器选项,指示编译器生成位置无关代码。
-fno-pic
选项指示编译器不生成位置无关代码,但在 x86-64 Linux 中使用该选项生成上述共享库时链接器会报错:/usr/bin/x86_64-linux-gnu-ld: /tmp/ccdwqRF4.o: relocation R_X86_64_PC32 against symbol g_val_1 can not be used when making a shared object; recompile with -fPIC<br />/usr/bin/x86_64-linux-gnu-ld: final link failed: Bad value<br />collect2: error: ld returned 1 exit status
。
查看 test.cpp 和 main.cpp 的源码:
$ cat test.cpp
int g_val_1 = 1;
int g_val_2 = 2;
void func()
{
g_val_1 *= 2;
g_val_2 *= 2;
}
$ cat main.cpp
extern int g_val_1;
extern int g_val_2;
void func();
int main()
{
func();
return 0;
}
2) 使用共享库
$ g++ -o main main.cpp ./libtest.so
虽然生成的可执行目标文件引用了共享库中的函数或全局变量,但是共享库中的代码和数据不会拷贝到可执行目标文件中。实际上,只是将共享库中的重定位信息和符号表拷贝到了可执行目标文件中,以便于在加载期对共享库中的函数或全局变量的引用进行解析。
当加载器加载并运行可执行目标文件时,如果可执行目标文件中包含.interp
section(该 section 用于指定动态链接器的路径),那么加载器会加载并运行动态链接器,动态链接器完成链接过程后,动态链接器会将控制权交给应用程序,从而开始执行应用程序的入口点函数。
查看可执行目标文件 main 需要的共享库:
$ ldd main
linux-vdso.so.1 => (0x00007ffc8e793000)
./libtest.so (0x00007f21de5ed000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f21de223000)
/lib64/ld-linux-x86-64.so.2 (0x00007f21de7ef000)
查看可执行目标文件 main 的.interp
section:
$ readelf -p .interp main
String dump of section '.interp':
[ 0] /lib64/ld-linux-x86-64.so.2
共享库常用函数:
函数作用 | 函数原型 | 参数含义 | 返回值 |
---|---|---|---|
打开共享库 | void *dlopen(const char *filename, int flag); |
RTLD_LAZY 、RTLD_NOW 、RTLD_GLOBAL 、RTLD_LOCAL |
|
关闭共享库 | int dlclose(void *handle); |
||
查找共享库中的符号定义 | void *dlsym(void *handle, char *symbol); |
注:
dlopen()
等函数包含在dlfcn.h
头文件中。
RTLD_LAZY
标志表示延迟绑定,即只有在执行符号引用的代码时才解析符号。延迟绑定的作用对象为对共享库中函数的引用。对共享库中全局变量的引用不涉及延迟绑定,即在 dlopen() 函数返回之前就完成了对共享库中全局变量的解析。
RTLD_NOW
标志表示立即解析。也就是说,在 dlopen() 函数返回之前需要完成所有对共享库中函数引用和全局变量引用的解析。
RTLD_GLOBAL
标志表示该共享库中的符号定义可以用于随后加载的共享库的符号解析。
RTLD_LOCAL
标志表示的含义与RTLD_GLOBAL
相反。
RTLD_LAZY
和RTLD_NOW
只能二选其一。RTLD_GLOBAL
和RTLD_LOCAL
只能二选其一。这两组的标志可以取|
,比如:RTLD_LAZY | RTLD_GLOBAL
。
进一步理解共享库:
1) 问题 1:如果一个进程在关闭共享库后,正在执行或再次执行该共享库中的代码。那么,这两种情况分别会导致什么后果?
验证过程:
a)验证问题 1
进程关闭共享库(即执行 dlclose 函数)后,如果该进程对该共享库的引用计数减为 0,那么无论是正在执行还是再次执行该共享库中的代码,都会发生违规访问,这是因为进程已撤销对共享库中代码和数据的映射,即进程看不到共享库中的任何代码和数据了。通常情况下,会导致进程崩溃退出。
验证如下:
首先,生成共享库 libtest.so 和可执行目标文件 main:
$ g++ -o libtest.so test.cpp -shared -fpic -g
$ g++ -o main main.cpp shared_library.cpp -std=c++11 -ldl -g
接下来,使用 gdb 调试:
$ gdb -q --args ./main "./libtest.so" "func"
启动 main,并执行到第一次加载 libtest.so 完成的位置:
(gdb) start
Temporary breakpoint 1 at 0x400ddf: file main.cpp, line 12.
Starting program: /home/xuxiaoqiang/tx/dyn/t/main ./libtest.so func
Temporary breakpoint 1, main (argc=3, argv=0x7fffffffdb38) at main.cpp:12
12 {
(gdb) n
13 if (argc != 3)
(gdb)
20 std::string lib_name(argv[1]);
(gdb)
21 std::string symbol_name(argv[2]);
(gdb)
23 SharedLibrary loader;
(gdb)
25 SharedLibrary::LOAD_FLAG_LAZY);
(gdb)
27 SharedLibrary loader_2;
自动打印 libtest.so 中定义的全局变量 g_val_1
和g_val_2
,及它们的内存地址、函数func
的地址和函数指针lib_func
的值,观察它们的值变化情况:
(gdb) display/d g_val_1
1: /d g_val_1 = 1
(gdb) display/x &g_val_1
2: /x &g_val_1 = 0x7ffff6f5b020
(gdb) display/d g_val_2
3: /d g_val_2 = 2
(gdb) display/x &g_val_2
4: /x &g_val_2 = 0x7ffff6f5b024
(gdb) display func
5: func = {<text variable, no debug info>} 0x7ffff6d5a680 <func>
(gdb) display lib_func
6: lib_func = (void (*)(void)) 0x7ffff729f299 <__GI___cxa_atexit+25>
查看进程已加载 sections 的信息:
(gdb) info files
// 省略 ...
0x00007ffff6d5a1c8 - 0x00007ffff6d5a1ec is .note.gnu.build-id in ./libtest.so
0x00007ffff6d5a1f0 - 0x00007ffff6d5a234 is .gnu.hash in ./libtest.so
0x00007ffff6d5a238 - 0x00007ffff6d5a3a0 is .dynsym in ./libtest.so
0x00007ffff6d5a3a0 - 0x00007ffff6d5a442 is .dynstr in ./libtest.so
0x00007ffff6d5a448 - 0x00007ffff6d5a538 is .rela.dyn in ./libtest.so
0x00007ffff6d5a538 - 0x00007ffff6d5a552 is .init in ./libtest.so
0x00007ffff6d5a560 - 0x00007ffff6d5a570 is .plt in ./libtest.so
0x00007ffff6d5a570 - 0x00007ffff6d5a580 is .plt.got in ./libtest.so
0x00007ffff6d5a580 - 0x00007ffff6d5a6b1 is .text in ./libtest.so
0x00007ffff6d5a6b4 - 0x00007ffff6d5a6bd is .fini in ./libtest.so
0x00007ffff6d5a6c0 - 0x00007ffff6d5a6dc is .eh_frame_hdr in ./libtest.so
0x00007ffff6d5a6e0 - 0x00007ffff6d5a744 is .eh_frame in ./libtest.so
0x00007ffff6f5ae60 - 0x00007ffff6f5ae68 is .init_array in ./libtest.so
0x00007ffff6f5ae68 - 0x00007ffff6f5ae70 is .fini_array in ./libtest.so
0x00007ffff6f5ae70 - 0x00007ffff6f5ae78 is .jcr in ./libtest.so
0x00007ffff6f5ae78 - 0x00007ffff6f5afc8 is .dynamic in ./libtest.so
0x00007ffff6f5afc8 - 0x00007ffff6f5b000 is .got in ./libtest.so
0x00007ffff6f5b000 - 0x00007ffff6f5b018 is .got.plt in ./libtest.so
0x00007ffff6f5b018 - 0x00007ffff6f5b028 is .data in ./libtest.so
0x00007ffff6f5b028 - 0x00007ffff6f5b030 is .bss in ./libtest.so
可以看出,g_val_1
、g_val_2
和func
的内存地址都在 ./libtest.so 中。
继续执行至第一次执行lib_func
完成的位置:
(gdb) n
29 SharedLibrary::LOAD_FLAG_LAZY);
1: /d g_val_1 = 1
2: /x &g_val_1 = 0x7ffff6f5b020
3: /d g_val_2 = 2
4: /x &g_val_2 = 0x7ffff6f5b024
5: func = {<text variable, no debug info>} 0x7ffff6d5a680 <func>
6: lib_func = (void (*)(void)) 0x7ffff729f299 <__GI___cxa_atexit+25>
(gdb)
32 *(void **)&lib_func = loader.getSymbol(symbol_name.c_str());
1: /d g_val_1 = 1
2: /x &g_val_1 = 0x7ffff6f5b020
3: /d g_val_2 = 2
4: /x &g_val_2 = 0x7ffff6f5b024
5: func = {<text variable, no debug info>} 0x7ffff6d5a680 <func>
6: lib_func = (void (*)(void)) 0x7ffff729f299 <__GI___cxa_atexit+25>
(gdb)
34 lib_func();
1: /d g_val_1 = 1
2: /x &g_val_1 = 0x7ffff6f5b020
3: /d g_val_2 = 2
4: /x &g_val_2 = 0x7ffff6f5b024
5: func = {<text variable, no debug info>} 0x7ffff6d5a680 <func>
6: lib_func = (void (*)(void)) 0x7ffff6d5a680 <func>
(gdb)
35 loader.unload();
1: /d g_val_1 = 2
2: /x &g_val_1 = 0x7ffff6f5b020
3: /d g_val_2 = 4
4: /x &g_val_2 = 0x7ffff6f5b024
5: func = {<text variable, no debug info>} 0x7ffff6d5a680 <func>
6: lib_func = (void (*)(void)) 0x7ffff6d5a680 <func>
可以看出,第二次加载 libtest.so(dlopen 函数是执行成功了的),并未改变g_val_1
、g_val_2
和func
的内存地址。
继续执行至第一次关闭libtest.so
完成的位置:
(gdb)
36 lib_func();
1: /d g_val_1 = 2
2: /x &g_val_1 = 0x7ffff6f5b020
3: /d g_val_2 = 4
4: /x &g_val_2 = 0x7ffff6f5b024
5: func = {<text variable, no debug info>} 0x7ffff6d5a680 <func>
6: lib_func = (void (*)(void)) 0x7ffff6d5a680 <func>
(gdb)
37 loader_2.unload();
1: /d g_val_1 = 4
2: /x &g_val_1 = 0x7ffff6f5b020
3: /d g_val_2 = 8
4: /x &g_val_2 = 0x7ffff6f5b024
5: func = {<text variable, no debug info>} 0x7ffff6d5a680 <func>
6: lib_func = (void (*)(void)) 0x7ffff6d5a680 <func>
可以看出,第一次关闭libtest.so
(dlclose 函数是执行成功了的),并未改变g_val_1
、g_val_2
和func
的内存地址。
继续执行至第二次关闭libtest.so
完成的位置:
(gdb)
38 lib_func();
warning: Unable to display "g_val_1": No symbol "g_val_1" in current context.
warning: Unable to display "&g_val_1": No symbol "g_val_1" in current context.
warning: Unable to display "g_val_2": No symbol "g_val_2" in current context.
warning: Unable to display "&g_val_2": No symbol "g_val_2" in current context.
warning: Unable to display "func": No symbol "func" in current context.
6: lib_func = (void (*)(void)) 0x7ffff6d5a680
可以看出,第二次关闭libtest.so
(dlclose 函数也是执行成功了的),g_val_1
、g_val_2
和func
的内存地址变为不可访问。而此时lib_func
的值仍为func
的内存地址。
继续执行:
(gdb)
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff6d5a680 in ?? ()
(gdb) bt
#0 0x00007ffff6d5a680 in ?? ()
#1 0x0000000000400f85 in main (argc=3, argv=0x7fffffffdb38) at main.cpp:38
可以看出,第二次关闭libtest.so
后,进程对共享库的引用计数减为 0,进程会撤销对共享库中的代码和数据的映射。再次执行共享库中的代码,会发生越界访问,从而导致进程崩溃退出。
从上述验证过程中,可以得出如下结论:
如果同一个进程多次调用dlopen
函数加载同一个共享库,那么自从第一次将共享库中的代码和数据等映射到进程后,其余的加载操作只是将进程对该共享库的引用计数递增 1。
如果同一个进程调用dlclose
函数卸载同一个共享库,每成功调用dlclose
函数一次,进程对该共享库的引用计数递减 1,当引用计数减为 0 时,进程才会真正地卸载共享库,即撤销进程对该共享库中的代码和数据的映射。从此以后,进程对该共享库中的代码和数据的访问会导致进程崩溃退出。
同一个共享库可以被映射到不同的进程中,每个进程各自维护对该共享库的引用计数,互不影响。关于这一点很容易验证,有兴趣的可以自己验证一下。
注:dlopen、dlclose 的引用计数保存在link_map
结构体中的l_direct_opencount
字段。
// in glibc-2.19/include/link.h
struct link_map
{
// 省略...
unsigned int l_direct_opencount; /* Reference count for dlopen/dlclose. */
// 省略...
};
完整程序:
main.cpp:
// how to compile: g++ -o main main.cpp shared_library.cpp -std=c++11 -ldl -g
// how to debug: gdb -q --args ./main "./libtest.so" "func"
#include "shared_library.h"
#include <iostream>
#include <cstdio>
#include <string>
using common::SharedLibrary;
int main(int argc, char **argv)
{
if (argc != 3)
{
std::cerr << "Usage: <path of shared library> <symbol name>\n";
return 1;
}
{
std::string lib_name(argv[1]);
std::string symbol_name(argv[2]);
SharedLibrary loader;
loader.load(lib_name, SharedLibrary::LOAD_FLAG_GLOBAL |
SharedLibrary::LOAD_FLAG_LAZY);
SharedLibrary loader_2;
loader_2.load(lib_name, SharedLibrary::LOAD_FLAG_GLOBAL |
SharedLibrary::LOAD_FLAG_LAZY);
void (*lib_func)();
*(void **)&lib_func = loader.getSymbol(symbol_name.c_str());
lib_func();
loader.unload();
lib_func();
loader_2.unload();
lib_func();
}
return 0;
}
test.cpp:
// how to compile: g++ -o libtest.so test.cpp -shared -fpic -g
int g_val_1 = 1;
int g_val_2 = 2;
extern "C"
{
void func()
{
g_val_1 *= 2;
g_val_2 *= 2;
}
} // extern "C"
shared_library.h:
#pragma once
#include <mutex>
#include <string>
namespace common
{
class SharedLibrary
{
public:
enum LoadFlag
{
LOAD_FLAG_GLOBAL = 1,
LOAD_FLAG_LOCAL = 2,
LOAD_FLAG_NOW = 4,
LOAD_FLAG_LAZY = 8,
};
SharedLibrary() = default;
~SharedLibrary() = default;
SharedLibrary(const SharedLibrary &) = delete;
SharedLibrary& operator = (const SharedLibrary &) = delete;
void load(const std::string &file_name, int flag);
void unload();
void* getSymbol(const std::string &symbol_name);
private:
int realLoadFlag(int flag) const;
private:
void *handle_ = nullptr;
static std::mutex handle_mutex_;
};
} // namespace common
shared_library.cpp:
#include "shared_library.h"
#include <dlfcn.h>
#include <iostream>
#define CHECK_DLERROR(cond) if (cond) { std::cerr << dlerror(); }
namespace common
{
std::mutex SharedLibrary::handle_mutex_;
void SharedLibrary::load(const std::string &file_name, int flag)
{
const auto real_flag = realLoadFlag(flag);
std::lock_guard<std::mutex> lock(handle_mutex_);
if (nullptr == handle_)
{
handle_ = dlopen(file_name.c_str(), real_flag);
}
CHECK_DLERROR(!handle_);
}
int SharedLibrary::realLoadFlag(int flag) const
{
int real_flag = 0;
real_flag |= (flag & LOAD_FLAG_NOW ? RTLD_NOW : RTLD_LAZY);
real_flag |= (flag & LOAD_FLAG_GLOBAL ? RTLD_GLOBAL : RTLD_LOCAL);
return real_flag;
}
void SharedLibrary::unload()
{
std::lock_guard<std::mutex> lock(handle_mutex_);
if (handle_)
{
auto success = !dlclose(handle_);
if (success)
{
handle_ = nullptr;
}
else
{
handle_mutex_.unlock();
CHECK_DLERROR(!success);
}
}
}
void* SharedLibrary::getSymbol(const std::string &symbol_name)
{
std::lock_guard<std::mutex> lock(handle_mutex_);
if (handle_)
{
auto result = dlsym(handle_, symbol_name.c_str());
handle_mutex_.unlock();
CHECK_DLERROR(!result);
return result;
}
return nullptr;
}
} // namespace common
-fpic
和-fPIC
都是用于指示编译器生成地址无关代码的编译器选项。
《gcc_9.3_manual》中关于-fpic
和-fPIC
的解释如下:
-fpic
:
Generate position-independent code (PIC) suitable for use in a shared library,
if supported for the target machine. Such code accesses all constant addresses
through a global offset table (GOT). The dynamic loader resolves the GOT
entries when the program starts (the dynamic loader is not part of GCC; it
is part of the operating system). If the GOT size for the linked executable
exceeds a machine-specific maximum size, you get an error message from the
linker indicating that ‘-fpic’ does not work; in that case, recompile with ‘-fPIC’
instead. (These maximums are 8k on the SPARC, 28k on AArch64 and 32k on
the m68k and RS/6000. The x86 has no such limit.)
Position-independent code requires special support, and therefore works only on
certain machines. For the x86, GCC supports PIC for System V but not for the
Sun 386i. Code generated for the IBM RS/6000 is always position-independent.
When this flag is set, the macros __pic__ and __PIC__ are defined to 1.
-fPIC
:
If supported for the target machine, emit position-independent code, suitable
for dynamic linking and avoiding any limit on the size of the global offset table.
This option makes a difference on AArch64, m68k, PowerPC and SPARC.
Position-independent code requires special support, and therefore works only
on certain machines.
When this flag is set, the macros __pic__ and __PIC__ are defined to 2.
从上面的解释中可以看出,-fpic
和-fPIC
之间的区别为:前者生成的地址无关代码中,如果全局偏移表(GOT
)的大小超过了机器所允许的最大值,那么链接器会报错;而后者不受限制。