GNU ld #
链接器 ld 是 binutil 包提供的命令,是架构相关的,如 x86_64-linux-gnu-ld
只能链接 x86_64 格式的 ELF 文件。
链接器将多个 ELF 格式的 obj 文件中的 segments(readelf -S
显示) 根据链接器脚本(ld --verbose
显示)的定义合并到最终的可执行文件的 Program Header 中(readelf -l
显示),期间涉及符号解析和冲突检测。
gcc 在链接主程序时,除了程序自身的 object 文件外,还链接了 gcc、libc 库, 这 3 个库协作才能形成一个可执行二进制:
- C 程序源码的入口是 main 函数,但是生成的 ELF 文件的执行入口是 _start,它是编译器提供的符号和代码
- 链接了 gcc 库 -lgcc, -lgcc_s,crtbeginS.o,crtendS.o 等,并将 GCC 的库添加到搜索路径的最前面
- crt 是 c runtime 的简称,其中的 _start 才是程序的真正执行入口,编译器为 C main 函数前后插入了 gcc 和 libc 库提供的代码。
- 链接了 -lc 库
- 开启了 –eh-frame-hdr
- 使用动态链接器 -dynamic-linker /lib/ld-linux-aarch64.so.1
gcc 通过包装器 collect2 来调用 ld:
-
构造函数和析构函数的处理:
collect2
的主要作用是确保 C++ 程序中的全局构造函数(constructors)和析构函数(destructors)能够正确执行- 它会收集所有的全局构造函数和析构函数,并生成必要的代码来确保它们在程序启动和结束时被正确调用
-
链接时的特殊处理(特别是 gcc 和启动、退出相关的代码库链接):
collect2
能够处理一些特殊的链接时任务- 它可以添加必要的启动代码(startup code)
- 处理一些平台特定的链接需求
在编译链接时,可以使用 gcc 的 -v 和 -Wl,-v 参数来打印 collect2 调用 ld 的参数详情。
alizj@ubuntu:/Users/alizj/docs/lang/c$ gcc -g -v -Wp,-v -Wa,-v -Wl,-v array.c
...
# collect 2 调用的 ld 命令
/usr/bin/ld -plugin /usr/libexec/gcc/aarch64-linux-gnu/13/liblto_plugin.so -plugin-opt=/usr/libexec/gcc/aarch64-linux-gnu/13/lto-wrapper -plugin-opt=-fresolution=/tmp/ccZXDhqQ.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr --hash-style=gnu --as-needed -dynamic-linker /lib/ld-linux-aarch64.so.1 -X -EL -maarch64linux --fix-cortex-a53-843419 -pie -z now -z relro /usr/lib/gcc/aarch64-linux-gnu/13/../../../aarch64-linux-gnu/Scrt1.o /usr/lib/gcc/aarch64-linux-gnu/13/../../../aarch64-linux-gnu/crti.o /usr/lib/gcc/aarch64-linux-gnu/13/crtbeginS.o -L/usr/lib/gcc/aarch64-linux-gnu/13 -L/usr/lib/gcc/aarch64-linux-gnu/13/../../../aarch64-linux-gnu -L/usr/lib/gcc/aarch64-linux-gnu/13/../../../../lib -L/lib/aarch64-linux-gnu -L/lib/../lib -L/usr/lib/aarch64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/aarch64-linux-gnu/13/../../.. -v /tmp/cc0XD2tu.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/aarch64-linux-gnu/13/crtendS.o /usr/lib/gcc/aarch64-linux-gnu/13/../../../aarch64-linux-gnu/crtn.o
GNU ld (GNU Binutils for Ubuntu) 2.42
COLLECT_GCC_OPTIONS='-g' '-v' '-mlittle-endian' '-mabi=lp64' '-dumpdir' 'a.'
编译和链接 #
每个 C 源文件可以单独编译, 生成一个 obj 文件, 然后再链接这些 obj 文件(其中有一个包含 main 函数)生成可执行程序:
- 使用 -c 来编译为 obj 文件;
- 如果不加 -c 则表示编译并链接为可执行程序 ,对于没有 main 函数定义的源文件会报错。
- -o 指定生成的 obj 或可执行程序文件名称。未指定时,保存到当前目录,可执行程序名为 a.out。
gcc -c foo.c # produces foo.o
gcc -c bar.c # produces bar.o
gcc -o foo foo.o bar.o # 链接为可执行程序
gcc main.c file1.o file2.o -o myprogram # c 源文件和 obj 文件混合
# 或者一步生成可执行程序
gcc -o foo foo.c bar.c
每次编译,生成 obj 文件是比较耗时的, 但是链接是很快的。分别生成 obj 文件的好处是可以按需只重新编译源文件发生变化的代码, 没变化的代码的 obj 文件可以复用, 从而缩短了整体编译的时间。
这种在命令行上指定所有的所有 obj 文件,不管主程序是否使用,都会被链接到主程序中,但是如果使用静态库则只会链接静态库中使用的符号。
链接顺序 #
链接顺序由库的依赖关系决定:链接顺序应该从具体到抽象,即先链接项目自己的库,再链接外部依赖库,后面的库需要满足前的库的符号需求。
链接顺序会影响符号解析 ,因为链接器会在遇到第一个满足未解析符号的库或对象文件时停止搜索。因此,库文件的顺序非常重要,尤其是在处理相互依赖的多个库时。
- 命令行顺序:链接器按照命令行中库文件和对象文件的顺序进行处理。
- 符号解析:链接器遇到一个对象文件或库文件时,它会尝试解析该文件中引用的所有符号。
- 库文件顺序:如果链接器在当前库文件中找不到某个符号,它会继续在后续的库文件中查找。如果后续的库文件中包含该符号,那么符号就会被解析。链接器不会反过来再在前面的 lib 文件中查找未解析的符号,所以越是特殊的库越应该放前面。
在链接生成可执行程序时,ld 先后进行符号解析(Symbol Resolution)和重定位(Relocation), 需要将最通用/基础的库放到后面, 例如 libA 和 libB 都依赖 libC, 则链接时指定的顺序:
- 正确: -lA -lB -lC;
- 错误: -lC -lA -lB;
这是因为连接器是根据 -l 指定的 obj 或文件顺序来依次解析使用到的符号, 如果前一个 lib 中的符号没有被后续 lib 来引用,则前一个 lib 在处理完后, 它就会被忽略。
如果在命令行上指定多个 c 源文件来编程生成可执行程序,各源文件的顺序也是如上考虑:
# 包含 main 函数的放到最前面,被依赖的库放后面
gcc -o awesomegame ui.c characters.c npc.c items.c
目前还没有自动化的方式来自动排序这些有相互交叉依赖的 obj 文件,但可以通过如下两种方式来解决:
- 重复多次列出库文件;
- 使用 ld 的 –start-group archives –end-group
# 多次列出库文件
gcc -o myprogram main.o -lA -lB -lA
# 使用 --start-group 和 --end-group
gcc -o myprogram main.o --start-group -lA -lB --end-group
还可以使用 Makefile 来显示指定它们的链接顺序:假设你有三个对象文件 main.o、a.o 和 b.o,其中 main.o依赖于 a.o,a.o 依赖于 b.o;
# Compiler and flags
CC = gcc
CFLAGS = -Wall -Iinclude
# Directories
SRC_DIR = src
LIB_DIR = lib
EXT_LIB_DIR = ext_lib
# Source files
SRC_FILES = $(wildcard $(SRC_DIR)/*.c)
LIB_FILES = $(wildcard $(LIB_DIR)/*.c)
# Object files
OBJ_FILES = $(SRC_FILES:.c=.o) $(LIB_FILES:.c=.o)
# External libraries
EXT_LIBS = -L$(EXT_LIB_DIR) -lotherlib
# Output executable
OUTPUT = myprogram
# Default target
all: $(OUTPUT)
# Link object files with specified order
$(OUTPUT): main.o a.o b.o
$(CC) -o $@ $^ $(EXT_LIBS) # $^ 会根据顺序包含所有的 prerequisite
# Compile source files
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
# Clean up
clean:
rm -f $(SRC_DIR)/*.o $(LIB_DIR)/*.o $(OUTPUT)
.PHONY: all clean
参考:https://akaedu.github.io/book/ch20s01.html
创建静态和动态库 ar、ranlib #
编译多个库文件和主程序通常涉及以下几个步骤:编译源文件、创建静态库或动态库、以及链接库文件和主程序。
使用 gcc 编译多个库文件的具体步骤如下:
- 编译源文件:使用 -c 参数将每个源文件编译成目标文件(.o 文件):
$ gcc -c file1.c -o file1.o # 编译单个源文件的顺序是无关紧要的,但是链接它们时要考虑链接顺序
$ gcc -c file2.c -o file2.o # 如果未指定 -o,默认都保存到当前工作目录
# 一次性编译多个源文件成目标文件
$ gcc -I./include -c lib/file1.c lib/file2.c
# 创建动态库时,可以使用 `-Wl,-soname` 指定它的 soname(见后文)
$ gcc -shared -Wl,-soname,libstack.so.1 -o libstack.so.1.0 stack.o push.o pop.o is_empty.o
- 创建静态库:使用 ar 工具将多个目标文件打包成一个静态库(.a 表示 archive),静态和动态库文件都以 lib 开头,静态库以 .a 结尾。
gcc -I./include -c lib/file1.c lib/file2.c
# r: 将 .o 文件添加或更新到 .a 中,如果 .a 不存在则创建
# s: 为 .a 创建索引,后续被链接器使用
ar rs lib/libmylib.a file1.o file2.o
# 也可以使用 ranlib 命令为静态库创建索引
ranlib libmylib.a
- 创建动态库: 使用 gcc 将目标文件打包成一个动态库(.so 文件)
- 编译目标文件必须指定 -fPIC 参数,用于生成可重定位的位置无关代码;
- 创建动态库时必须加 -shared;
// 先将各源文件编译为 PIC 的独立 obj 文件
gcc -fPIC -c file1.c file2.c file3.c
// 再将个 obj 文件打包为动态库, 需要指定 -shared 参数
gcc -shared -o libmylib.so file1.o file2.o file3.o
// 或者,一次性编译并创建动态库 libmylib.so
gcc -shared -o libmylib.so file1.c file2.c
链接静态库或动态库 #
ld 优先链接动态库,通过指定 -static 来只链接静态库。
- 链接静态库: 在编译主程序时指定库路径和库名称(不需要加前缀 lib 和扩展名 .a,也就是后文提到的 linker name),链接器只会将主程序 使用的 函数或定义链接到主程序中。
# 链接静态库时,只提取主程序使用的部分
$ gcc main.c -L. -lmylib -o myprogram
# 不使用静态库,而指定多个 obj 文件是,则主程序没用到的函数也会链接进来,增加了文件体积
$ gcc main.c stack.o push.o pop.o is_empty.o -Istack -o main
- 链接动态库: 当链接动态库时,除了指定库路径和库名称外,还需要确保运行时能够找到动态库。
- -Wl,-rpath, 将指定的目录写入 ELF 文件中,在运行时链接器会到该目录下查找动态库文件。
gcc main.c -L. -lmylib -o myprogram -Wl,-rpath,/tmp/mylib
对于动态链接的程序,它的 ELF 文件中包含了动态链接器路径(由 glibc 提供)和依赖的动态库名称(SO-NAME),但是并没有真的做链接,而是在运行时由动态链接器查找和动态链接。
ldd 模拟动态链接器,将依赖的动态库 SO-NAME 如 libgcc_s.so.1 绑定到找到的动态库 /lib/aarch64-linux-gnu/libgcc_s.so.1:
root@ubuntu:/home/alizj# ldd /root/.cargo/bin/rustc
linux-vdso.so.1 (0x0000ffff985cc000)
# SO-NAME 及绑定到的动态路路径
libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ffff97c00000)
libpthread.so.0 => /lib/aarch64-linux-gnu/libpthread.so.0 (0x0000ffff97bd0000)
libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000ffff97b20000)
libdl.so.2 => /lib/aarch64-linux-gnu/libdl.so.2 (0x0000ffff97af0000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff97930000)
/lib/ld-linux-aarch64.so.1 (0x0000ffff98580000) # 动态链接器二进制路径, 由 glibc 提供
库文件搜索路径 #
除了 -L 指定库搜索路径外,还可以使用 LIBRARY_PATH 环境变量来指定动态库搜索路径。
注意:
- 不是 LD_LIBRARY_PATH,后者是运行动态链接的二进制时查找动态库路径;
- /etc/ld.so.conf 配置的路径不会在编译链接阶段搜索,它是运行时搜索;
除了自定义库搜索路径外,还会搜索链接器脚本中配置的默认搜索路径:
alizj@ubuntu:~$ ld --verbose | grep SEARCH_DIR | tr -s ' ;' \\012
SEARCH_DIR("=/usr/local/lib/aarch64-linux-gnu")
SEARCH_DIR("=/lib/aarch64-linux-gnu")
SEARCH_DIR("=/usr/lib/aarch64-linux-gnu")
SEARCH_DIR("=/usr/local/lib")
SEARCH_DIR("=/lib")
SEARCH_DIR("=/usr/lib")
SEARCH_DIR("=/usr/aarch64-linux-gnu/lib")
可以使用 gcc -print-search-dirs
打印库文件默认搜索路径(来源于默认链接脚本),或者使用 echo 'main(){}' | gcc -E -v -
或者 gcc -v -xc /dev/null -fsyntax-only
查看编译时生效的头文件和库文件搜索路径。具体参考: 20250124-gcc-cross-compiling-toolchain.md
链接时库文件搜索优先级顺序为:
- 命令行 -L 参数指定的路径;
- 环境变量 LIBRARY_PATH 指定的路径。
- 链接器 linker script 配置的默认搜索路径。
运行时动态链接&查找 #
在可执行文件执行过程中,动态库的查找发生在程序装载阶段,由操作系统内核调用 ELF 中记录的动态链接器,如 /lib64/ld-linux-x86-64.so.2,来执行动态库的查找和链接操作:
- 动态链接器是 glibc 库提供的,而且是架构相关的(如 ld-linux-aarch64.so.1, ld-linux-x86-64.so.2)
- 动态链接器的路径被写入到 ELF 文件中。
在编译链接阶段,主程序 ELF 只是记录了动态库的 SO-NAME,并没有实际链接,在运行时,由动态链接器进行链接:
ldd 模拟动态链接器,将依赖的动态库 SO-NAME 如 libgcc_s.so.1 绑定到找到的动态库 /lib/aarch64-linux-gnu/libgcc_s.so.1:
root@ubuntu:/home/alizj# ldd /root/.cargo/bin/rustc
linux-vdso.so.1 (0x0000ffff985cc000)
# SO-NAME 及绑定到的动态路路径
libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ffff97c00000)
libpthread.so.0 => /lib/aarch64-linux-gnu/libpthread.so.0 (0x0000ffff97bd0000)
libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000ffff97b20000)
libdl.so.2 => /lib/aarch64-linux-gnu/libdl.so.2 (0x0000ffff97af0000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff97930000)
/lib/ld-linux-aarch64.so.1 (0x0000ffff98580000) # 动态链接器二进制路径, 由 glibc 提供
gcc (交叉)编译工具链使用对应架构的 glibc 库的动态链接器,gcc 的 ld wrapper 命令 collect2 的 -dynamic-linker 参数指定它的路径:
$ echo 'main(){}' | gcc -E -v -
...
/usr/libexec/gcc/aarch64-linux-gnu/13/collect2 -plugin /usr/libexec/gcc/aarch64-linux-gnu/13/liblto_plugin.so -plugin-opt=/usr/libexec/gcc/aarch64-linux-gnu/13/lto-wrapper -plugin-opt=-fresolution=/tmp/ccZXDhqQ.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr --hash-style=gnu --as-needed -dynamic-linker /lib/ld-linux-aarch64.so.1 -X -EL -maarch64linux --fix-cortex-a53-843419 -pie -z now -z relro
动态链接器 ld.so 使用 /etc/ld.so.conf 配置的动态库搜索路径:
- 包含系统 Host 架构的标准库目录,如 /usr/local/lib/aarch64-linux-gnu,/usr/lib/aarch64-linux-gnu
- 也包含多架构的标准库目录,如 /usr/local/lib/x86_64-linux-gnu,/usr/lib/x86_64-linux-gnu
root@ubuntu:/home/alizj# cat /etc/ld.so.conf
include /etc/ld.so.conf.d/*.conf
root@ubuntu:/home/alizj# head /etc/ld.so.conf.d/*.conf
==> /etc/ld.so.conf.d/aarch64-linux-gnu.conf <==
# Multiarch support
/usr/local/lib/aarch64-linux-gnu
/lib/aarch64-linux-gnu
/usr/lib/aarch64-linux-gnu
==> /etc/ld.so.conf.d/fakeroot-aarch64-linux-gnu.conf <==
/usr/lib/aarch64-linux-gnu/libfakeroot
==> /etc/ld.so.conf.d/libc.conf <==
# libc default configuration
/usr/local/lib
==> /etc/ld.so.conf.d/x86_64-linux-gnu.conf <==
# Multiarch support
/usr/local/lib/x86_64-linux-gnu
/lib/x86_64-linux-gnu
/usr/lib/x86_64-linux-gnu
修改 /etc/ld.so.conf 后,执行 sudo ldconfig -v
来更新缓存 /etc/ld.so.cache
,从而来实现修改系统的运行动态库搜索路径。
root@ubuntu:/home/alizj# ldconfig -v # 更新缓存, 同时扫描动态库文件,生成对应的 SO-NAME 软链接文件
root@ubuntu:/home/alizj# ldconfig -p | head # 打印缓存
451 libs found in cache `/etc/ld.so.cache'
libz3.so.4 (libc6,AArch64) => /lib/aarch64-linux-gnu/libz3.so.4
libz3.so (libc6,AArch64) => /lib/aarch64-linux-gnu/libz3.so
libzstd.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libzstd.so.1
libzstd.so (libc6,AArch64) => /lib/aarch64-linux-gnu/libzstd.so
libz.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libz.so.1
libz.so (libc6,AArch64) => /lib/aarch64-linux-gnu/libz.so
libyaml-0.so.2 (libc6,AArch64) => /lib/aarch64-linux-gnu/libyaml-0.so.2
libxxhash.so.0 (libc6,AArch64) => /lib/aarch64-linux-gnu/libxxhash.so.0
libxtables.so.12 (libc6,AArch64) => /lib/aarch64-linux-gnu/libxtables.so.12
除了上述方式外,还可以通过如下方式添加动态库搜索路径:
- LD_LIBRARY_PATH 环境变量。
- 在链接时,通过 -rpath 参数指定运行时动态库搜索路径并写入到可执行文件 ELF .dynamic 段的 DT_RPATH 或 DT_RUNPATH,通过 readelf -d 可查询。
如上配置的动态库搜索优先级顺序为:
- 环境变量 LD_LIBRARY_PATH 指定的路径。
- 可执行文件中 ELF .dynamic 段 DT_RPATH 或 DT_RUNPATH 指定的路径。
- /etc/ld.so.conf 配置的路径。
# -Wl, 是给 linker 传递参数
$ gcc main.c -g -L. -lstack -Istack -o main -Wl,-rpath,/home/akaedu/somedir
# readelf 的结果的 .dynamic 段中多了一条rpath记录:
$ readelf -a main
...
Dynamic section at offset 0xf10 contains 23 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libstack.so]
0x00000001 (NEEDED) Shared library: [libc.so.6]
0x0000000f (RPATH) Library rpath: [/home/akaedu/somedir]
参考:
动态库的语义化版本 #
系统的共享库通常带有符号链接:
root@ubuntu:/home/alizj# ls -l /usr/lib/aarch64-linux-gnu/libc.*
-rw-r--r-- 1 root root 5247070 Aug 8 22:47 /usr/lib/aarch64-linux-gnu/libc.a
-rw-r--r-- 1 root root 291 Aug 8 22:47 /usr/lib/aarch64-linux-gnu/libc.so
-rwxr-xr-x 1 root root 1722920 Aug 8 22:47 /usr/lib/aarch64-linux-gnu/libc.so.6
root@ubuntu:/home/alizj# ls -l /usr/lib/aarch64-linux-gnu/libcur*
lrwxrwxrwx 1 root root 19 Dec 12 00:44 /usr/lib/aarch64-linux-gnu/libcurl-gnutls.so.3 -> libcurl-gnutls.so.4
lrwxrwxrwx 1 root root 23 Dec 12 00:44 /usr/lib/aarch64-linux-gnu/libcurl-gnutls.so.4 -> libcurl-gnutls.so.4.8.0
-rw-r--r-- 1 root root 732568 Dec 12 00:44 /usr/lib/aarch64-linux-gnu/libcurl-gnutls.so.4.8.0
lrwxrwxrwx 1 root root 16 Dec 12 00:44 /usr/lib/aarch64-linux-gnu/libcurl.so.4 -> libcurl.so.4.8.0
-rw-r--r-- 1 root root 798104 Dec 12 00:44 /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0
lrwxrwxrwx 1 root root 12 Apr 9 2024 /usr/lib/aarch64-linux-gnu/libcurses.a -> libncurses.a
lrwxrwxrwx 1 root root 13 Apr 9 2024 /usr/lib/aarch64-linux-gnu/libcurses.so -> libncurses.so
按照共享库的命名惯例,每个共享库有三个文件名:real name、soname 和 linker name。
- libxxx.so.x (soname) -> libxxx.so.x.y.z (real name)
- libxxx.so (linker name) -> libxxx.so.x.y.z (real name)
真正的库文件(而不是符号链接)的名字是 real name,包含完整的版本号,例如上面的 libcurl-gnutls.so.4.8.0 等。
soname 是一个符号链接的名字,只包含共享库的主版本号,主版本号一致即可保证库函数的接口一致,因此 ELF 的 .dynamic 段只记录共享库的 soname,只要 soname一致,这个共享库就可以用。
例如上面的 libcurl-gnutls.so.3 和 libcurl-gnutls.so.4 是两个主版本号不同的 libcurl-gnutls,有些应用程序依赖于 libcurl-gnutls.so.3,有些应用程序依赖于 libcurl-gnutls.so.4,但对于依赖 libcurl-gnutls.so.3 的应用程序来说,真正的库文件不管是 libcurl-gnutls.so.3.1 还是 libcurl-gnutls.so.3.11 都可以用,所以使用共享库可以很方便地升级库文件而不需要重新编译应用程序,这是静态库所没有的优点。
在使用 gcc 创建动态库时,可以使用 -Wl,-soname
指定它的 soname:
#$ gcc -shared -o libstack.so stack.o push.o pop.o is_empty.o
$ gcc -shared -Wl,-soname,libstack.so.1 -o libstack.so.1.0 stack.o push.o pop.o is_empty.o
这样编译生成的库文件 real name 是 libstack.so.1.0,但 ELF 中也记录了它的 SO-NAME 是 libstack.so.1:
$ readelf -a libstack.so.1.0
...
Dynamic section at offset 0xf10 contains 22 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libc.so.6]
0x0000000e (SONAME) Library soname: [libstack.so.1]
...
如果把 libstack.so.1.0 所在的目录加入 /etc/ld.so.conf,然后运行 ldconfig 命令,ldconfig 会扫描 real name 的共享文件,根据共享库文件中记录的 SONAME,自动创建 soname 的符号链接文件:
$ sudo ldconfig
$ ls -l libstack*
lrwxrwxrwx 1 root root 15 2009-01-21 17:52 libstack.so.1 -> libstack.so.1.0
-rwxr-xr-x 1 akaedu akaedu 10142 2009-01-21 17:49 libstack.so.1.0
linker name 仅在编译链接时使用,gcc 的 -L 选项应该指定 linker name 所在的目录。有的 linker name 是库文件的一个符号链接,有的 linker name 是一段链接脚本。例如上面的 libc.so 就是一个 linker name,它是一段链接脚本:
root@ubuntu:/home/alizj# cat /usr/lib/aarch64-linux-gnu/libc.so
/* GNU ld script
Use the shared library, but some functions are only in
the static library, so try that secondarily. */
OUTPUT_FORMAT(elf64-littleaarch64)
GROUP ( /lib/aarch64-linux-gnu/libc.so.6 /usr/lib/aarch64-linux-gnu/libc_nonshared.a AS_NEEDED ( /lib/ld-linux-aarch64.so.1 ) )
gcc 编译一个可执行文件时,使用 -L 和 -l 指定依赖一个动态库时,需要使用 linker name,如果该 name 文件不存在则报错。
# 链接器只认 linker name
$ gcc main.c -L. -lstack -Istack -o main
/usr/bin/ld: cannot find -lstack
collect2: ld returned 1 exit status
# 创建一个 linker name 的符号链接
$ ln -s libstack.so.1.0 libstack.so
$ gcc main.c -L. -lstack -Istack -o main
linker name 一般是指向 real name 的链接文件,如果该 real name 动态库 ELF 包含 SO-NAME 符号,将 SO-NAME 作为该动态库的运行时查找库的名字,而非 linker name 或 real name 文件名。
如:gcc ... -L ... -l foo
在构建(链接阶段)时,解析到 libfoo.so 链接到的 libfoo.so.1.2 文件包含 SO-NAME 为 libfoo.so.1,则在执行文件中使用 libfoo.so.1
作为运行时查找的名字而非 libfoo.so 或 libfoo.so.1.2。
执行 ELF 程序时,ELF 中指定的动态链接器(ld.so) 会根据写入的 SO-NAME 查找系统的动态链接库:
# libcurl.so.4 是 ELF 记录的 soname,被动态链接器绑定到 /lib/aarch64-linux-gnu/libcurl.so.4 动态库
root@ubuntu:/home/alizj# ldd /usr/bin/curl |grep -E '(libc|libcurl).so'
libcurl.so.4 => /lib/aarch64-linux-gnu/libcurl.so.4 (0x0000ffff90aa0000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff908a0000)
可以看出,在 SO-NAME 机制下:
- 如果可执行文件依赖了某个库,那么后续该库的次版本号的升级将不会破坏任何东西。
- 执行 ldconfig 会重新建立 SONAME 文件软连。
- 但是,如果可执行文件依赖了较新的次版本库中符号,那么后续运行时,如果不小心使用了同一个主版本号的较旧的次版本号,那么操作系统将不会拒绝这个程序的运行,而是运行到调用时才能发现这个符号不存,在运行时直接崩溃。要解决这个问题有如下几个办法:
- 库使用者:始终更新库到当前主版本号的最新的次版本。
- 库开发者:使用 Linux 提供的符号版本机制,将报错提前到加载这个程序的阶段。
参考:
- https://akaedu.github.io/book/ch20s04.html
- https://www.rectcircle.cn/posts/linux-dylib-detail-2-version/
符号版本 ld version script #
SO-NAME 实现了可执行文件依赖某个主版本号的库,如果该库的主版本号不匹配则将在 程序加载阶段报错。
但是,版本管理对于次版本的规定:只保证向后兼容(使用旧版本的库编译,可以在新版本的库运行),不保证向前兼容(不保证使用新版本的库编译,使用旧版本的库可以运行)。而只有 SO-NAME 情况下,在使用新版本的库编译,运行时使用旧版本的库的情况下,操作系统无法再程序加载阶段报错,而是 在运行依赖这个符号时直接崩溃 。
为了解决这个问题,Linux 提供了 ld version script
机制,编写一个脚本,这个脚本声明版本,每个版本中包含了在这个版本引入的符号。然后:
-
在使用 gcc 编译库时,通过
-Wl,--version-script,xxx.map
指定这个脚本,脚本中的信息将编译到库中: -
每个符号的版本,如
print_bar_d@@BAR_1.1
(在 elf 的.dynsym
段,通过readelf --dyn-syms
查看)。 -
脚本中声明的所有版本,如
BAR_1.1、BAR_1.0
(在 elf 的.gnu.version_d
段,通过readelf --version-info
查看)。 -
在使用 gcc 构建(链接阶段)可执行文件时,收集调用该库中所有符号的版本列表,去重编译到库中,如,仅调用了 print_bar_d 函数,则将获取到依赖的版本列表为
BAR_1.1
(在 elf 的.gnu.version_r
段,通过readelf --version-info
查看)。 -
在运行该可执行文件的加载阶段,会使用可执行文件中的
.gnu.version_r
和库中的.gnu.version_d
,进行匹配,如果发现找不到的符号,则直接加载失败。
示例:
1.0.0 版本的 libbar.map 如下:
BAR_1.0 {
...
};
BARprivate {
...
};
1.1.0 版本的 libbar.map 如下(… 为省略):
BAR_1.0 {
...
};
BARprivate {
...
};
BAR_1.1 {
global:
print_bar_d;
} BAR_1.0;
编译 main_d.c 使用了 1.1.0 版本的库,运行时使用 1.0.0 的库时将报错:
./build/bin/main_d: ./build/lib/libbar.so.1: version `BAR_1.1’ not found (required by ./build/bin/main_d)
因此:依赖了只在 1.1.0 有而 1.0.0 中没有的符号的库的可执行文件源码,在加载阶段直接报错,从而解决了该问题。
简单介绍一下 ld version scripts 的语法:
- 有多个 NAME_X.Y {}; 或 NAMEprivate {}; 块组成,声明多个版本,其中 NAMEprivate 表示这些符号时私有的,不保证未来是否会被删除,外部不应该依赖。
- 对于新的版本一般要继承上一个版本如
BAR_1.1 {} BAR_1.0
;,表示BAR_1.1
继承了BAR_1.0
中的所有符号。 - 每个版本块 {} 内可以声明导出的符号:
- global: 表示导出的全局符号列表。
- local: 表述局部符号。
- : 后面用来声明符号,每个符号使用 ; 结尾。
- 可以使用 * 通配符声明表示所有的符号。
符号重载
假设一个库函数 print_bar_b 在 1.0.0 版本有一个实现,但是 1.1.0 版本,库开发者想改变这个函数的语义,导致不兼容,按照语义化版本,这种场景需要升级大版本好到 2.0.0,但是如果仅仅为了这个小小的改动就升级大版本,有点小题大做。因此希望能做如下场景:
- 在 1.0.0 版本的库中,存在一个实现
print_bar_b
。 - 在 1.1.0 版本的库中,存在一个新的实现
__print_bar_b_1_1
,同时 1.0.0 的实现print_bar_b
变为__print_bar_b_1_0
仍然存在。 - 可执行文件调用了 print_bar_b 函数。
- 可执行文件是使用 1.0.0 版本的库编译的,在运行时:
- 使用的 1.0.0 版本的库时,实际调用的是 print_bar_b。
- 使用的 1.1.0 版本的库时,实际调用的是
__print_bar_b_1_0
(即 1.0.0 的实现)。
- 可执行文件是使用 1.1.0 版本的库编译的,在运行时:
- 使用的 1.0.0 版本的库时,将报错,因为没有 1.1.0 的实现。
- 使用的 1.1.0 版本的库时,将调用
__print_bar_b_1_1
(即 1.1.0 的实现)。
能实现如上场景的机制被称为 符号多版本重载 ,Linux 通过 asm 指定实现了该机制,示例如下:
在使用该特性的情况下,必须要声明 ld version scripts,如下(… 为省略):
`` text BAR_1.0 { global: print_bar_a; print_bar_b; print_bar_c; };
BARprivate { global: __print_bar_a_1_0; __print_bar_a_1_1; __print_bar_b_1_0; __print_bar_b_1_1; local: *; };
BAR_1.1 { global: print_bar_d; } BAR_1.0; …
在 1.0.0 版本的库中,相关代码如下:
```_C
void print_bar_b() {
printf("libbar1.0.0 b\n");
}
完成编译后,通过 readelf --dyn-syms
查看,可以看到 print_bar_b@@BAR_1.0
。
在 1.1.0 版本的库中,相关代码如下:
asm(".symver __print_bar_b_1_0,print_bar_b@BAR_1.0");
void __print_bar_b_1_0() {
printf("libbar1.0.0 b\n");
}
asm(".symver __print_bar_b_1_1,print_bar_b@@BAR_1.1");
void __print_bar_b_1_1() {
printf("libbar1.0.0 b\n");
}
完成编译后,通过 readelf --dyn-syms
查看,可以看到 print_bar_b@@BAR_1.1
和 print_bar_b@BAR_1.0
。
asm(".symver 实现的符号名,导出的符号名@版本号") 导出一个带版本的符号,指向某个实现。
上述的 @@ 表示该导出符号的 默认的实现,在编译时将使用该版本(编译到可执行文件中,可通过 readelf –dyn-syms 查看)。
示例代码: https://github.com/rectcircle/linux-dylib-demo/blob/master/03-symbolversion/1.1.0/libbar.map
glibc 语义化版本 #
glibc 主要使用了上述符号版本机制,如果遇到可执行文件报各种关于 glibc 的错误,通过了解上述机制,应该可以快速的解决问题。
- glibc 的 ld version scripts 可以查看 glibc 符号的版本:
- 各目录下的 Versions 文件,如:https://github.com/bminor/glibc/blob/master/string/Versions
- 可以使用 ldd、 readelf –version-info、 readelf –dyn-syms 等参数查看可执行文件依赖的动态库,找到其中最大的版本 。则这个版本就是该可执行文件依赖的 glibc 的最小版本号。
- 这里重点介绍下 glibc 2.34 的一个重大变化,即:-lpthread, -ldl, -lutil, -lanl, -lresolv 等的符号,已经被移动到 libc.so.6 中。因此,在使用这些库函数的项目编译时,在 2.34 之后,通过 ldd 将只能看到 libc.so.6 的依赖。
优缺点和使用场景
动态链接库的本质是对通用逻辑的复用。因此,动态链接库有如下优点:
- 节省磁盘和内存资源:将可以复用的代码编译成动态链接库,那么这些代码在一台设备中的磁盘中只需要保存一份,在运行时只需要将这些代码加载一份到内存中,从而节省磁盘和内存资源。
- 可执行文件和库的发布解耦:如果采用静态编译的方式,当库存在问题需要更新时,需要通知所有可执行文件开发者重新编译可执行文件。而采用动态链接库的方式,库开发者只需在满足兼容性的条件下,更新库即可,而无需通知可执行文件开发者。
基于以上优势,动态链接库主要的应用场景如下:
- 操作系统系统调用封装的函数库:如 libc 库的 POSIX 部分。
- 通用函数库:如 openssl、libz 等。
软件工程没有银弹,所有技术都是有代价的,动态链接库也存在很多问题:
- 隐式依赖: 一个可执行文件的能否运行隐式的依赖了某些动态链接库,这带来了运行环境搭建的成本。
- 依赖地狱(Dependency Hell): 不同的应用程序可能需要同一个库的不同版本,某些极端场景无法协调这些版本,可能造成:
- 某些可执行文件只能安装旧版本的而无法升级。
- 需要维护多个版本的动态库,运行环境的维护会变得异常复杂。
- 故障半径大: 为某个可执行文件升级动态链接库可能导致其他可执行文件的崩溃。
为了解决如上问题,业界又引入很多复杂的技术,如:
- 容器化: 将可执行文件和其依赖的动态链接库打包到镜像中,将依赖固化下来,实现可重现的运行。
- Nix: 采用可寻址的包管理机制,支持一个操作系统系统中安装多个版本的动态链接库而互不干扰,可通过声明式的方式安装各个版本的包。
参考:https://www.rectcircle.cn/posts/linux-dylib-detail-2-version/
链接脚本 #
ld 在链接时主要执行两个工作:符号解析和重定位(Relocation),而 Relocation 使用链接脚本来决定生成的 ELF 文件中 Section 布局:
- ENTRY(_start): 程序入口;
- SEARCH_DIR: 链接库搜索路径;
- SECTIONS: ELF 文件中的 section 定义, 用于链接;
- SEGMENTS: ELF 文件中的 segment 定义, 用于执行;
使用 –verbose 选项查看 ld 命令的默认链接脚本:
zhangjun@lima-ebpf-dev:/usr/include$ ld --verbose
...
OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64", "elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start)
SEARCH_DIR("=/usr/local/lib/x86_64-linux-gnu");
SEARCH_DIR("=/lib/x86_64-linux-gnu");
SEARCH_DIR("=/usr/lib/x86_64-linux-gnu");
SEARCH_DIR("=/usr/lib/x86_64-linux-gnu64");
SEARCH_DIR("=/usr/local/lib64");
SEARCH_DIR("=/lib64");
SEARCH_DIR("=/usr/lib64");
SEARCH_DIR("=/usr/local/lib");
SEARCH_DIR("=/lib");
SEARCH_DIR("=/usr/lib");
SEARCH_DIR("=/usr/x86_64-linux-gnu/lib64");
SEARCH_DIR("=/usr/x86_64-linux-gnu/lib");
SECTIONS
{
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
.interp : { *(.interp) }
.note.gnu.build-id : { *(.note.gnu.build-id) }
.hash : { *(.hash) }
.gnu.hash : { *(.gnu.hash) }
.dynsym : { *(.dynsym) }
.dynstr : { *(.dynstr) }
.gnu.version : { *(.gnu.version) }
.gnu.version_d : { *(.gnu.version_d) }
...
.rodata : { *(.rodata .rodata.* .gnu.linkonce.r.*) }
.rodata1 : { *(.rodata1) }
.eh_frame_hdr : { *(.eh_frame_hdr) *(.eh_frame_entry .eh_frame_entry.*) }
.eh_frame : ONLY_IF_RO { KEEP (*(.eh_frame)) *(.eh_frame.*) }
...
.data.rel.ro : { *(.data.rel.ro.local* .gnu.linkonce.d.rel.ro.local.*) *(.data.rel.ro .data.rel.ro.* .gnu.linkonce.d.rel.ro.*) }
.dynamic : { *(.dynamic) }
.got : { *(.got) *(.igot) }
. = DATA_SEGMENT_RELRO_END (SIZEOF (.got.plt) >= 24 ? 24 : 0, .);
.got.plt : { *(.got.plt) *(.igot.plt) }
.data :
{
*(.data .data.* .gnu.linkonce.d.*)
SORT(CONSTRUCTORS)
}
.data1 : { *(.data1) }
_edata = .; PROVIDE (edata = .);
. = .;
__bss_start = .;
.bss :
{
*(.dynbss)
*(.bss .bss.* .gnu.linkonce.b.*)
*(COMMON)
/* Align here to ensure that the .bss section occupies space up to
_end. Align after .bss to ensure correct alignment even if the
.bss section disappears because there are no input sections.
FIXME: Why do we need it? When there is no .bss section, we do not
pad the .data section. */
. = ALIGN(. != 0 ? 64 / 8 : 1);
}
.lbss :
{
*(.dynlbss)
*(.lbss .lbss.* .gnu.linkonce.lb.*)
*(LARGE_COMMON)
}
...
/* SGI/MIPS DWARF 2 extensions. */
.debug_weaknames 0 : { *(.debug_weaknames) }
.debug_funcnames 0 : { *(.debug_funcnames) }
.debug_typenames 0 : { *(.debug_typenames) }
...
}
编译 glibc 和修改动态链接器路径 #
参考:https://www.rectcircle.cn/posts/linux-build-once-run-anywhere/
如果主程序依赖一个特定的 glibc 版本,则可以进行手动编译打包该 glibc,然后随主程序一起打包:
wget http://ftp.gnu.org/gnu/glibc/glibc-2.31.tar.gz
tar -zxvf glibc-2.31.tar.gz
glibc_prefix=$(pwd)/glibc-2.31-target
cd glibc-2.31/
rm -rf build && mkdir -p build && cd build
sudo apt update && sudo apt install -y gcc make gdb texinfo gawk bison sed python3-dev python3-pip
../configure --prefix=$glibc_prefix
make -j4
make install
cd ../../
cp /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.28 ./glibc-2.31-target/lib
ln -s libstdc++.so.6.0.28 ./glibc-2.31-target/lib/libstdc++.so.6
cp /lib/x86_64-linux-gnu/libgcc_s.so.1 ./glibc-2.31-target/lib
# 压缩
tar -czvf glibc-2.31-target.tar.gz glibc-2.31-target/
由于 动态链接器是 glibc 提供的并且路径被写入到 ELF 中 ,所以需要修改主程序 ELF 文件中的动态链接器路径,指定为上面打包编译的 glibc 中的版本。
可以使用 patchelf
工具来修改 ELF 文件的动态链接库加载器路径:
sudo apt install -y patchelf
patchelf --set-interpreter $(pwd)/glibc-2.31-target/lib/ld-linux-x86-64.so.2 ./node-v18.12.0-linux-x64/bin/node
链接过程中的强符号和弱符号 #
https://www.zhaixue.cc/c-arm/c-arm-weak-attribute.html
内联函数探究 : https://www.zhaixue.cc/c-arm/c-arm-inline.html 有一种函数,叫内建函数 : https://www.zhaixue.cc/c-arm/c-arm-builtin.html 有一种宏,叫可变参数宏 : https://www.zhaixue.cc/c-arm/c-arm-macro.html