41 共享库基础
操作静态库
静态库可以使用 ar
命令创建和管理(尽管一般不需要我们自己调用 ar
命令)。
创建归档:
ar r libdemo.a mod1.o mod2.o mod3.o
列出归档:
$ ar tv libdemo.a
rw-r--r-- 1000/100 1001016 Nov 15 12:26 2009 mod1.o
rw-r--r-- 1000/100 406668 Nov 15 12:21 2009 mod2.o
rw-r--r-- 1000/100 46672 Nov 15 12:21 2009 mod3.o
从归档中删除内容:
ar d libdemo.a mod3.o
共享库基本介绍
- 虽然共享库的代码是由多个进程共享的,但其中的变量却不是。每个使用库的进程会拥有自己的在库中定义的全局和静态变量的副本。
- 共享库通常需要使用额外的寄存器来支持位置独立编码(PIC),在运行时也需要符号解析(重定位)。
DSO 指 dynamic shared object。
创建共享库
在 gcc 中使用 -fPIC
来生成位置无关编码的程序,用 -shared
来生成共享库而不是可执行文件。
位置独立编码(PIC)
生成位置独立编码会让程序每次对全局数据(包括变量和函数地址)的访问都相对于一个偏移进行(这个偏移其实就是 GOT 表的起始位置)。在一些平台上要生成共享库必须使用 PIC。
Linux/x86-32 环境中不用 PIC 也能生成共享库,只是这样创建出来的共享库代码不能被多个进程复用内存,而是在每个进程中都有一份修改后的副本。(外存共享、内存不共享。)
确定文件是否使用了 -fPIC
选项编译
1. 检查目标文件的符号表
如果目标文件(*.o
)在编译时使用了 -fPIC
选项,则 nm
或 objdump
命令会有输出:
# 1
nm mod1.o | grep _GLOBAL_OFFSET_TABLE_
# 如果 nm 的输入是静态库,则会对静态库中的每个目标文件都进行搜索
# 2
readelf -s mod1.o | grep _GLOBAL_OFFSET_TABLE_
以上两个命令都是读取目标文件的符号表。
2. 检查共享库的符号表
如果以下任何命令产生了输出,说明共享库中至少有一个目标文件在编译时没有使用 -fPIC
选项。举例:
# 获取 libc6 的路径
$ realpath "$(gcc --print-file-name=libc.so.6)"
/usr/lib/x86_64-linux-gnu/libc.so.6
# 测试 1
$ readelf -d /usr/lib/x86_64-linux-gnu/libc.so.6 | grep TEXTREL
# 测试 2
$ objdump --all-headers /usr/lib/x86_64-linux-gnu/libc.so.6 | grep TEXTREL
字符串 TEXTREL 表示存在一个目标模块,其文本段包含需要运行时重定位的引用。(意思是要么依靠运行时重定位,要么依靠虚拟地址并在访存时加上偏移?)
说明:
- 对共享库文件使用
nm
会提示 no symbol。书上的例子是用nm
对目标文件提取符号表,不要用错对象。 - /usr/lib/x86_64-linux-gnu/libc.so 是一个链接脚本,不是 ELF 格式的动态链接库。可以用
file
命令来查看文件的类型。 - gcc 还有一个 /usr/lib/x86_64-linux-gnu/libc.a 文件。
动态链接器(Dynamic Linker)
动态链接器(Dynamic linker)又被称为动态链接加载器(dynamic linking loader)或运行时加载器(run-time loader)。
要区分 linker 和 loader(动态链接器):前者通常为 ld,是可执行程序;后者通常为 ld.so,是动态链接库。Linker 也被叫做链接编辑器(link editor),在静态链接阶段起作用(这个时候还没形成可执行文件),包括使用共享库在内的所有程序都会经过静态链接阶段。
一个程序中依赖的所有共享库的列表被称为程序的动态依赖列表(dynamic dependency list)。链接到共享库的程序至少都会依赖动态链接器,也常可能会依赖 C 语言标准库(libc.so.6)。
在 wsl 和服务器上测试,发现可执行文件还依赖了 linux-vdso.so.1,但是书上没有列出。
动态链接器路径一般是 /lib/ld-linux.so.2,常常为指向真正动态链接器的符号链接。在我测试的服务器上,这个文件指向 /usr/lib/i386-linux-gnu/ld-2.31.so。在 wsl 中,这个文件不存在,取而代之的是 /usr/lib64/ld-linux-x86-64.so.2,它是指向 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 的符号链接。
LD_LIBRARY_PATH
环境变量
如果共享库在非标准的位置,可以将路径添加到 LD_LIBRARY_PATH
中使动态链接加载器在加载程序时找到共享库。LD_LIBRARY_PATH
中的路径可以用 :
来分割,如果 LD_LIBRARY_PATH
不为空,但是有一个路径分项为空,则等价于这个路径是当前路径(.
)。
soname
没有 soname 时,嵌入到可执行文件的名称,还有动态链接器在运行时搜索的名称都是共享库文件的真实名称(real name)。有 soname 时就是 soname 了。soname 提供了一层间接机制,使得可执行文件运行时可以加载相互兼容(soname 相同、实现兼容)的共享库。
给共享库执行 soname 相当于是在说这个共享库支持了某种服务。感觉有点像 Java 的服务提供者框架(Service Provider Interface, SPI)协议。
在生成动态链接库时指定 soname(例子中虽然共享库本身的名字是 libfoo.so 但 soname 是 libbar.so):
gcc -g -shared -Wl,-soname,libbar.so -o libfoo.so mod1.o mod2.o
不指定 soname 时,生成的共享库中是没有 SONAME 这一项记录的,并不是会和输出文件名相同。
soname 放在 ELF 的 DT_SONAME
标签中,可以用以下方式搜索到:
$ objdump -p libfoo.so | grep SONAME
SONAME libbar.so
$ readelf -d libfoo.so | grep SONAME
0x0000000e (SONAME) Library soname: [libbar.so]
如果 soname 和文件本身的名字不同,那么还必须做一件事:创建一个和 soname 同名的符号链接,指向文件本身。否则,由于动态链接器查找的是 soname,在 LD_LIBRARY_PATH
下是找不到的。
实用工具
ldd a.out
可以显示运行程序所需的依赖。
objdump
和 readelf
可以显示可执行文件、目标文件和共享库中的各种信息。objdump
甚至还能显示汇编代码。objdump
也可以用来显示 ELF 的头部信息,这时功能近似于 readelf
。
nm 命令列举可执行文件或目标库中的符号。
共享库命名规范
libname.so.major-id.minor-id
如果次要版本中包含了补丁号 / 修订号,那么版本名就不是 x.y,而是 x.y.z(相当于 y.z 是原来的 y)。
只要主要版本相同,共享库就应该是可以兼容的。因此 soname 只包含共享库的主要版本号,通常作为一个指向以完整版本号为名的共享库的符号链接。比如:
libdemo.so.1 -> libdemo.so.1.0.2
libdemo.so.2 -> libdemo.so.2.0.0
libreadline.so.5 -> libreadline.so.5.0
除了真实名称和 soname,共享库还存在第三个名称:链接器名称。
名称 | 命名 | 用途 | 文件类型 |
---|---|---|---|
真实名称(real name) | libname.so.major.minor\[.patch\] | 共享库本身 | 共享库 |
soname | libname.so.major | 链接时把 soname 存入可执行文件的动态依赖列表;加载时用 soname 来查找共享库 | 指向真实名称的符号链接 |
链接器名称(linker name) | libname.so | 链接时用 linker name 查找共享库 | 指向 soname 文件的符号链接 |
在生成可执行文件时,将目标文件链接到链接器名称,可以使得动态链接器(loader)能在运行时自动查找最新的共享库。但如果需要使用特定版本的共享库,则应该链接到 soname(带有主要版本号)。
安装共享库
生产环境中不应该使用依赖 LD_LIBRARY_PATH
查找共享库的技术,应该把共享库安装到标准的位置。
标准的库目录包括:
- /usr/lib:大多数标准程序的安装目录(debian 中用 apt 安装共享库就会安装到这里)
- /lib:系统启动需要的库需要放在这里,因为这个时候 /usr/lib 可能还没有被挂载
- /etc/ld.so.conf 中指定的目录(包括 /usr/local/lib)
通常 soname 和链接器名称都是和“以真实名称命名的共享库文件”在同一个目录下的符号链接,符号链接的形式也是相对寻址:
mv libdemo.so.1.0.1 /usr/lib
cd /usr/lib
ln -s libdemo.so.1.0.1 libdemo.so.1
ln -s libdemo.so.1 libdemo.so
用 ldconfig
管理加载器缓存
ldconfig(8) 维护 /etc/ld.so.cache 缓存,以方便动态链接器快速查找需要的共享库。每次更新缓存时,ldconfig 会从 /lib、/usr/lib 和 /etc/ld.so.conf 中指定的路径来查找共享库。它检查每个库的各个主要版本的最新次要版本(即具有最大的次要版本号的版本)以找出嵌入的 soname,然后在同一目录中为每个 soname 创建(或更新)相对符号链接。
命令选项:
-N
会禁用缓存的重建-n
会禁用对标准库路径的处理(只对命令行提供的路径处理),同时也带有-N
的含义,即禁用缓存重建-X
选项会阻止 soname 符号链接的创建-v
选项会让 ldconfig 给出更多的信息-p
选项会直接输出当前缓存的内容、不执行缓存构建操作
ldconfig
会更新 soname 符号链接,但不会更新链接器名称
ldconfig
会通过真实共享库名称更新 soname 符号链接,但是不会更新链接器名称(以下例子中没有输出 libdemo.so 符号链接),因此,如果需要链接器名称,我们需要手动更新。
# mv libdemo.so.1.0.1 libdemo.so.2.0.0 /usr/lib
# ldconfig -v | grep libdemo
libdemo.so.1 -> libdemo.so.1.0.1 (changed)
libdemo.so.2 -> libdemo.so.2.0.0 (changed)
如果又安装了 libdemo.so.2.0.1,ldconfig 还能将 libdemo.so.2 修改为指向 libdemo.so.2.0.1 的符号链接,因为 2.0.1 版本是最新的。
ldconfig
是从 ELF 文件的 DT_SONAME 获取 soname 信息的
ldconfig 用来更新符号链接的 soname 是从共享库文件本身的 DT_SONAME 信息中提取的:
eric@debian:~/soname$ gcc -fPIC -shared -Wl,-soname,libbar.so.1 -o libfoo.so.1.0.0 foo.c
eric@debian:~/soname$ ls
foo.c libfoo.so.1.0.0
eric@debian:~/soname$ ldconfig -nv .
.: (from <cmdline>:0)
libbar.so.1 -> libfoo.so.1.0.0
eric@debian:~/soname$ ls
foo.c libbar.so.1 libfoo.so.1.0.0
此时,由于没有链接器名称,是无法使用 gcc -fPIC main.c -lbar -L.
(假设 main.c 也在当前文件夹)来编译代码的,一定要创建一个链接器名才能成功。(或者直接把共享库作为参数,而不是通过 -lbar
指定共享库。)
eric@debian:~/soname$ gcc -fPIC main.c -lbar -L.
/usr/bin/ld: cannot find -lbar: No such file or directory
collect2: error: ld returned 1 exit status
eric@debian:~/soname$ ln -s libbar.so.1 libbar.so
eric@debian:~/soname$ gcc -fPIC main.c -lbar -L.
eric@debian:~/soname$
注意:符号链接对 gcc(和任何用默认参数调用 open()
的程序一样)来说是透明的,gcc 看到 -lbar
就会去找 libbar.so
,并不关心它本身是共享库、还是一个符号链接。因此,人为创建“链接器名”符号链接是为了帮助 gcc 正确链接可执行程序,可执行程序依赖的还是 soname 而不是 linker name。一旦找到共享库,生成的可执行文件中记录的动态依赖是 soname,而不是真实名称,这样方便共享库的次要版本更新。几种共享库名称的对比见上面的表格。
在目标文件中指定搜索目录(-rpath
)
链接器的 -rpath
选项
静态编辑阶段可以向可执行文件中插入一个运行时搜索共享库的目录列表,方法是使用链接器(linker)的 -rpath
选项向 ELF 中添加 RUNPATH(这里没有说是 ELF 的哪个标签,标签过几个小节就会提到,请留意)。书上的例子:
gcc -g -Wall -Wl,-rpath,/home/mtk/pdir -o prog prog.c libdemo.so
如果要加入多个条目到 RUNPATH 中,需要指定多次 -rpath
。
什么时候要用到 RUNPATH?
用 cmake 编译出来的可执行文件 A 如果链接到了同期用 cmake 编译出来的共享库 B 上,那么它的 RUNPATH 中就会包含 B 生成位置的绝对路径,形如 /workspace/build/,这样就可以在不将共享库安装到系统、也不设置 LD_LIBRARY_PATH
环境变量的情况下测试程序。
一个共享库也可能会包含 RUNPATH,以方便动态链接器能找到它依赖列表中的共享库。
LD_RUN_PATH
也可以使用 LD_RUN_PATH
环境变量,在构建可执行文件的时候,链接器会将其视为从命令行提供的 -rpath
选项使用。如果显式提供了 -rpath
选项,该环境变量会被忽略。
ELF 的 DT_RPATH
和 DT_RUNPATH
条目
使用 -rpath
向 ELF 写入的标签默认为 DT_RPATH
,如果链接器同时收到了 --enable-new-dtags
选项,则是向 ELF 写入 DT_RUNPATH
。
这两个标签的区别是:如果程序运行时存在 LD_LIBRARY_PATH
环境变量,在标签为 DT_RPATH
的情况下,LD_LIBRARY_PATH
环境变量优先级更低;在标签为 DT_RUNPATH
的情况下,LD_LIBRARY_PATH
的优先级更高。
一个 ELF 中可以同时存在 DT_RPATH
和 DT_RUNPATH
条目。这两个条目同时存在时,如果动态链接器能够理解 DT_RUNPATH
(过老的动态链接器不能理解),则会把 DT_RPATH
忽略;否则,动态链接器只会认出 DT_RPATH
。
在 -rpath
中使用相对于应用程序的路径
可以用 ${ORIGIN}
或者 $ORIGIN
表示相当于 ELF 本身的路径。为了防止 shell 转义,需要用单引号将 $ORIGIN
包裹:
gcc -Wl,-rpath,'$ORIGIN'/lib ...
相当于 ELF 本身的路径 != 相对路径,因为相当路径是相当于工作目录。
在应用程序依赖了自己的共享库,而应用(及它的共享库)又可能被安装到任意位置时,$ORIGIN
就能派上用场。
共享库的搜索顺序(重要)
对于 ELF 文件中的每个共享库依赖:
- 如果依赖中有
/
,说明这是绝对或相对路径,将直接检查该路径,终止后续搜索。 - 如果 ELF 中有
DT_RPATH
且没有DT_RUNPATH
,则前者发挥作用,按顺序搜索该列表中的路径。 - 如果有环境变量
LD_LIBRARY_PATH
且可执行文件不是 set-user/group-ID 程序(为了安全考虑、防止用户诱骗加载同名库),则搜索LD_LIBRARY_PATH
表示的路径列表。 - 如果 ELF 中有
DT_RUNPATH
,按顺序搜索该列表中的路径。 - 在 /etc/ld.so.cache 文件中搜索共享库。
- 搜索 /lib,如果找不到再搜索 /usr/lib。原文:The directories /lib and /usr/lib are searched (in that order).
运行时符号解析(重要)
书上给了个例子:
按理来说,我们希望 libfoo.so 中定义的 func()
能去调用 libfoo.so 中的 xyz()
,但实际上却调用了 prog 中的 xyz()
。这是因为程序需要解析符号时,总是在第一次看到符号时进行链接,不管它在哪个库(多个库中可能会有同名的符号),链接后符号就变成“不需要解析”的了。在这个例子中,prog 已经包含了 xyz()
的定义,无需解析;但还缺少 func() 的定义。在运行时加载了 libfoo.so 后,func()
也成功解析了,func()
中调用的 xyz()
因为早就有了,所以没有从 libfoo.so 中取。
为了改变这种情况,我们在创建 libfoo.so 共享库的时候可以向 ld 指定 -Bsymbolic
选项,要求在编译共享库的时候就尽可能提前绑定内部定义的全局符号,这样生成的程序就不会意外地调用 prog 中的 xyz()
了。ld
命令帮助中写道:
-Bno-symbolic Don't bind global references locally
-Bsymbolic Bind global references locally
-Bsymbolic-functions Bind global function references locally
区分 -Bsymbolic
和 -Bsymbolic-functions
让我们意识到主程序中引用的全局变量也是可以在共享库中定义的!
给 xyz()
函数加上 static 限定符能否避免这种情况?实测可以。
复现书中例子
eric@debian:~/resolve$ cat main.c
#include <stdio.h>
void foo(void) {
printf("main::foo()\n");
}
void call_foo();
int main() {
call_foo();
}
eric@debian:~/resolve$ cat foo.c
#include <stdio.h>
/* static */
void foo(void) {
printf("libfoo.so::foo()\n");
}
void call_foo(void) {
foo();
}
eric@debian:~/resolve$ gcc -fPIC -shared -Wl,-Bsymbolic -o libfoo.so foo.c
eric@debian:~/resolve$ gcc -fPIC main.c libfoo.so
eric@debian:~/resolve$ env LD_LIBRARY_PATH=. ./a.out
libfoo.so::foo()
eric@debian:~/resolve$ gcc -fPIC -shared -o libfoo.so foo.c
eric@debian:~/resolve$ gcc -fPIC main.c libfoo.so
eric@debian:~/resolve$ env LD_LIBRARY_PATH=. ./a.out
main::foo()
eric@debian:~/resolve$ vim foo.c # 取消对 /* static */ 的注释
eric@debian:~/resolve$ gcc -fPIC -shared -o libfoo.so foo.c
eric@debian:~/resolve$ gcc -fPIC main.c libfoo.so
eric@debian:~/resolve$ env LD_LIBRARY_PATH=. ./a.out
libfoo.so::foo()
再来一个例子,这次引用的是全局变量,而不是函数:
eric@debian:~/resolve$ cat main.c
#include <stdio.h>
int bar = 4;
int get_bar(void);
int main() {
printf("bar=%d\n", get_bar());
}
eric@debian:~/resolve$ cat bar.c
int bar = 31;
int get_bar(void) {
return bar;
}
eric@debian:~/resolve$ gcc -fPIC -shared -o libbar.so bar.c
eric@debian:~/resolve$ gcc -fPIC main.c libbar.so
eric@debian:~/resolve$ env LD_LIBRARY_PATH=. ./a.out
bar=4
eric@debian:~/resolve$ gcc -fPIC -shared -Wl,-Bsymbolic -o libbar.so bar.c
eric@debian:~/resolve$ env LD_LIBRARY_PATH=. ./a.out # 动态链接的优势:修改共享库之后不需要再次链接主程序
bar=31
个人回忆
在某个 C++ 项目中我见到过这样的代码(假设 A
类型不是 POD):
A *getInstance() {
static A a;
return &a;
}
与使用全局变量相比有以下好处:
- 使用函数而不是变量有更多灵活性(可以加一些副作用或者判断过程)。
- 由语言规范保证了全局实例
a
在第一次使用前被初始化。在 C++ 中,这有助于编排静态初始化的顺序,另外一个在静态初始化阶段调用此函数进行初始化的全局对象b
一定是比a
后初始化的。没有编排静态初始化顺序的例子见 要小心 C++ 静态初始化顺序。 - 根据实现来看(可以阅读 汇编代码),函数内的静态变量有 lazy-init 的效果。写成全局变量则是在
main()
函数进入前就会初始化了。注意:类的方法(无论是静态还是成员方法)也有这种效果,但是类的静态属性就跟在类外定义的一样,会在程序的静态初始化阶段就进行初始化: https://godbolt.org/z/5b36e9c57 (尝试将一些调用分别修改成 0 次、1 次和 2 次,看看效果)。
如果把 A a;
写到函数外面,在 getInstance()
里面返回它的指针,则同名 symbol 的风险很大。如果在外面写 static A a;
,也会丢掉前面做法 lazy-init 的优势。
控制 linker 链接到动态库还是静态库
在静态库和动态库都能找到时,ld 会优先使用动态库。如果要强制选择静态库 / 动态库,可以:
- 在 gcc 命令行给出路径名,如 libdemo.a。路径名可以决定性地确定文件,用动态库还是静态库当然也就确定了。
- 在 gcc 命令行给出
-static
选项(这是 gcc 的选项,不是 ld 的)。 - 给 ld 传入
-Bstatic
选项或者-Bdynamic
选项(如果在 gcc 上使用则需要写成-Wl,-Bstatic
或者-Wl,-Bdynamic
)。同一条命令行可以使用多次这样的选项,每次使用都会切换链接类型。
举例:libc 同时有静态库和动态库版本:
$ ls -lh "$(dirname "$(gcc --print-file-name libc.a)")"|grep 'libc\.'
-rw-r--r-- 1 root root 5.2M May 1 05:07 libc.a
-rw-r--r-- 1 root root 283 May 1 05:07 libc.so
-rwxr-xr-x 1 root root 1.9M May 1 05:07 libc.so.6
其中 libc.a 是静态库,libc.so.6 是动态库,libc.so 是一个链接器脚本(GNU ld script)。
即使不真正依赖也强制链接
可以给 gcc 传递 -Wl,--no-as-needed
保证一个共享库即使没有提供任何需要链接到的符号,也将其加入到目标文件的依赖列表中。
相反的选项是 -Wl,--as-needed
,这也是默认行为(ld
的 man 手册中如是说)。