玩转Linux动态链接库

Last Updated: 2023-07-24 09:57:31 Monday

-- TOC --

当你在Linux中运行一个程序,系统提示找不到某个动态链接库,或者某个符号在库中找不到,这可能是我们常有的经历。比较痛苦的是,如果没有思路,这个问题有时还很不好解决,有时甚至会导致系统重装。这篇笔记,尝试总结我个人在动态链接库方面的积累。

理解动态链接

可以这么说,动态链接已经是软件系统中必不可少的一个重要系统机制。它带来的好处,远远大于它引入的影响,几乎所有OS都支持动态链接机制。gcc编译器默认就采用动态链接,除非使用-static参数。

动态链接带来的好处:

计算机系统包含一些长期发展迭代很多次后沉淀下来的几乎不再变化的设计思想和数学原理,这些可以看做是计算机系统的基石设计,动态链接应该可以算一个。

下面这小段代码,什么都不干,动态链接和静态链接的输出size差异巨大:

int main() {
    return 9;
}

动态链接和静态链接的输出size:

$ gcc test.c -o test
$ ls -l test
-rwxr-xr-x. 1 xinlin xinlin 24752 Jul  7 09:22 test
$ gcc -static test.c -o test
$ ls -l test
-rwxr-xr-x. 1 xinlin xinlin 1928000 Jul  7 09:22 test
$ echo $((1928000/24752))
77

静态链接后,程序文件size暴增77倍!

动态链接基本流程

程序代码在(动态)链接的时候,如果某个外部符号属于动态链接库,此时链接器无法确定符号地址,因而推迟到加载的时候再确定。ELF文件中通过.got中转动态链接库中的全局变量,通过.got.plt中转动态链接库中的函数接口。

OS在启动某个进程时,首先判断此进程是否是动态链接的。如果是,先将控制权交给动态链接器,由动态链接器加载(映射)程序所需要的所有动态链接库,这个过程会涉及某些全局符号的重定位。当所有动态链接库在进程的虚拟地址空间中准备就绪,动态链接器再将控制权交给程序入口,此时程序才开始执行。

如下两篇笔记,更深入细致的分析了上述流程:

动态链接器

程序代码经过编译链接之后,动态链接器的位置就已经存放在了ELF文件的名叫.interp的section中了。查看程序的动态链接器:

$ gcc test.c -o test  # dynamic link
$ readelf -p .interp test

String dump of section '.interp':
  [     0]  /lib64/ld-linux-x86-64.so.2

同一系统下的几乎所有程序,都用这个相同的动态链接器/lib64/ld-linux-x86-64.so.2。它的重要性不言而喻,没有它,所有程序都无法启动。

$ /lib64/ld-linux-x86-64.so.2 --version
ld.so (GNU libc) stable release version 2.35.
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.

$ ldd /lib64/ld-linux-x86-64.so.2
        statically linked

$ file /lib64/ld-linux-x86-64.so.2
/lib64/ld-linux-x86-64.so.2: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), static-pie linked, BuildID[sha1]=321b87738a8ee7220b3c045c0d4d93150be54470, with debug_info, not stripped

动态链接器本身是一个可以直接运行的静态链接的PIE,它是个特殊的存在,因为没有其它程序能够为它加载器依赖的动态链接库,它只能自己干。

查看程序依赖的动态链接库

有好几种方法,可以查看程序运行依赖的动态链接库。

ldd命令

ldd命令其实是一个shell脚本,行数不多,值得一读。

$ ldd /usr/bin/python
        linux-vdso.so.1 (0x00007ffc5a43c000)
        libpython3.10.so.1.0 => /lib64/libpython3.10.so.1.0 (0x00007f07c9c00000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f07c9800000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f07c9f6a000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f07ca061000)
$ ldd /lib64/libpython3.10.so.1.0
        linux-vdso.so.1 (0x00007ffd8adfb000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f88a9522000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f88a9200000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f88a998e000)

动态链接库本身也可能会依赖其它动态链接库,一样可以用ldd命令查看。linux-vdso.so.1是个特殊的库

readelf命令

readelf命令可以查看程序所依赖的动态链接库,但是依赖的依赖不能自动查询出来。

$ readelf -d /usr/bin/ls

Dynamic section at offset 0x209f8 contains 29 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libselinux.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libcap.so.2]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x4000
 0x000000000000000d (FINI)               0x17d74
 0x0000000000000019 (INIT_ARRAY)         0x20ef0
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x20ef8
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x458
 0x0000000000000005 (STRTAB)             0x1080
 0x0000000000000006 (SYMTAB)             0x498
 0x000000000000000a (STRSZ)              1510 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x21c08
 0x0000000000000002 (PLTRELSZ)           2568 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x2d50
 0x0000000000000007 (RELA)               0x1838
 0x0000000000000008 (RELASZ)             5400 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE
 0x000000006ffffffe (VERNEED)            0x1768
 0x000000006fffffff (VERNEEDNUM)         2
 0x000000006ffffff0 (VERSYM)             0x1666
 0x000000006ffffff9 (RELACOUNT)          211
 0x0000000000000000 (NULL)               0x0

NEEDED行显示出来此程序所依赖的动态链接库。如果用ldd查看这个程序,会发现多出一个库文件,这个多出来的库文件,就是libselinux.so.1的依赖。

LD_TRACE_LOADED_OBJECTS环境变量

LD_TRACE_LOADED_OBJECTS环境变量不为空时,运行程序会自动打印出其所依赖的所有动态链接库,程序本身不会被执行,只是打印这个信息。其实,这些信息就是动态链接器打印出来的,当此环境变量不为空时,动态链接器遍历出程序所有的库之后,不会将控制权交给程序入口,而是直接退出了。

$ LD_TRACE_LOADED_OBJECTS=1 ls
        linux-vdso.so.1 (0x00007fff503b5000)
        libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f60d461a000)
        libcap.so.2 => /lib64/libcap.so.2 (0x00007f60d4610000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f60d4400000)
        libpcre2-8.so.0 => /lib64/libpcre2-8.so.0 (0x00007f60d4363000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f60d465a000)

使用/lib64/ld-linux-x86-64.so.2动态链接器

前面介绍过,动态链接器是可以直接运行的一个程序,其也具有一些参数。其中,--list参数就是用来显示所有依赖的库文件。这个机制是前面所有机制的最底层,除了readelf命令。

$ /lib64/ld-linux-x86-64.so.2 --list /usr/bin/ls
        linux-vdso.so.1 (0x00007fffda9a4000)
        libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f12c1fdb000)
        libcap.so.2 => /lib64/libcap.so.2 (0x00007f12c1fd1000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f12c1c00000)
        libpcre2-8.so.0 => /lib64/libpcre2-8.so.0 (0x00007f12c1f34000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f12c203f000)

查看进程加载的动态链接库

程序是静态概念,而程序运行起来之后,就是进程,进程是动态的概念。程序依赖的动态链接库,不一定与其运行起来的进程所加载的动态链接库完全对应,有可能程序会在运行时,使用explicit runtime linking技术加载一些库到进程空间,比如调用dlopen接口。

理解explicit runtime linking,想一想Python的import一个C语言写的库,用的就是这个技术。

pldd命令

ldd是shell脚本,pldd是可执行程序。

启动一个interactive python,然后用pldd看一下:

$ pldd 4323   #  4323 is pid
4323:   /usr/bin/python3.10
linux-vdso.so.1
/lib64/libpython3.10.so.1.0
/lib64/libc.so.6
/lib64/libm.so.6
/lib64/ld-linux-x86-64.so.2
/usr/lib64/python3.10/lib-dynload/readline.cpython-310-x86_64-linux-gnu.so
/lib64/libreadline.so.8
/lib64/libtinfo.so.6
/usr/lib64/python3.10/lib-dynload/_opcode.cpython-310-x86_64-linux-gnu.so

在Python Shell中执行import socket,然后再用pldd查看:

$ pldd 4323
4323:   /usr/bin/python3.10
linux-vdso.so.1
/lib64/libpython3.10.so.1.0
/lib64/libc.so.6
/lib64/libm.so.6
/lib64/ld-linux-x86-64.so.2
/usr/lib64/python3.10/lib-dynload/readline.cpython-310-x86_64-linux-gnu.so
/lib64/libreadline.so.8
/lib64/libtinfo.so.6
/usr/lib64/python3.10/lib-dynload/_opcode.cpython-310-x86_64-linux-gnu.so
/usr/lib64/python3.10/lib-dynload/_socket.cpython-310-x86_64-linux-gnu.so
/usr/lib64/python3.10/lib-dynload/math.cpython-310-x86_64-linux-gnu.so
/usr/lib64/python3.10/lib-dynload/select.cpython-310-x86_64-linux-gnu.so
/usr/lib64/python3.10/lib-dynload/array.cpython-310-x86_64-linux-gnu.so

import socket后,一大堆动态链接库就被加载了!

cat /proc/pid/maps命令

有可能pldd命令就是用这个方法,只是它做了去重。

这个命令直接查看进程的虚拟地址空间的映射,所有加载的动态链接库,都有映射。只是不同的segment会将库文件进行重复显示。

关于如何解读cat /proc/pid/maps命令的输出,请参考:环境变量和进程地址空间分布

定位动态链接库

SO-NAME

见下。

ldconfig命令

见下。

有新库加入系统,或者有新的路径添加到ld.so.conf,记得运行ldconfig命令。

LD_LIBRARY_PATH环境变量

以上两个子目录,以及本目录,都请参考:动态链接库的名称和路径(ldconfig命令)

rpath参数

rpath参数在ELF文件的.dynamic字段内,gcc编译的时候,可以传入这个参数。rpath参数的作用,增加动态链接器的搜索路径。

elf.h文件中对rpath参数字段有一个deprecated注释,不建议使用,因为很容易造成在某台电脑上编译链接的动态链接库,copy到另一台电脑后,就无法正常运行的情况,两台电脑的相关路径很容易不一致。只有trusted directories才是很固定的。

做个测试:

先准备一个动态链接库:

$ cat t.c
#include <stdio.h>

void f(){
    printf("i am in t.c\n");
}
$ gcc -fPIC -shared t.c -o libt.so

然后写一段测试代码:

void f();

int main() {
    f();
    return 0;
}
$ gcc test.c -o test -Xlinker -rpath ./ -L./ -lt
$ ./test
i am in t.c

查看test内的rpath参数:

$ readelf -d test | grep RUNPATH
 0x000000000000001d (RUNPATH)            Library runpath: [./]

如果不加-Xlinker -rpath ./,test运行时报错:

$ gcc test.c -o test -L./ -lt
$ ./test
./test: error while loading shared libraries: libt.so: cannot open shared object file: No such file or directory

因此,程序运行时找不到动态链接库,虽然这个库文件就在当前路径,但当前路径不在搜范围中。解决此问题,最好是在编译链接时,不要设置rpath参数,而是使用LD_LIBRARY_PATH,如下:

$ LD_LIBRARY_PATH=./ ./test
i am in t.c

介绍一个chrpath小工具,它可以直接修改ELF文件中的rpath参数,这个小工具要单独安装,一般系统内都没有。

$ sudo dnf install chrpath

这个小工具也能够直接将ELF文件中的rpath参数删除。

LD_PRELOAD环境变量

参考:用LD_PRELOAD劫持的原理和实践

LD_DEBUG环境变量

通过给这个环境变量设置不同的值,可以实现各种动态链接过程的调试功能。

使用前面的测试代码。当设置LD_DEBUG=files时,可以打印出加载动态链接库的细节:

$ LD_DEBUG=files ./test
     55925:
     55925:     file=libt.so [0];  needed by ./test [0]
     55925:     file=libt.so [0];  generating link map
     55925:       dynamic: 0x00007f7507b77e20  base: 0x00007f7507b74000   size: 0x0000000000004030
     55925:         entry: 0x00007f7507b74000  phdr: 0x00007f7507b74040  phnum:                 11
     55925:
     55925:
     55925:     file=libc.so.6 [0];  needed by ./test [0]
     55925:     file=libc.so.6 [0];  generating link map
     55925:       dynamic: 0x00007f75079f7ba0  base: 0x00007f7507800000   size: 0x0000000000201d90
     55925:         entry: 0x00007f75078296d0  phdr: 0x00007f7507800040  phnum:                 14
     55925:
     55925:
     55925:     calling init: /lib64/ld-linux-x86-64.so.2
     55925:
     55925:
     55925:     calling init: /lib64/libc.so.6
     55925:
     55925:
     55925:     calling init: ./libt.so
     55925:
     55925:
     55925:     initialize program: ./test
     55925:
     55925:
     55925:     transferring control: ./test
     55925:
i am in t.c
     55925:
     55925:     calling fini: ./test [0]
     55925:
     55925:
     55925:     calling fini: ./libt.so [0]
     55925:

LD_DEBUG的其它有效值,可以通过help显示出来:

$ LD_DEBUG=help ./test
Valid options for the LD_DEBUG environment variable are:

  libs        display library search paths
  reloc       display relocation processing
  files       display progress for input file
  symbols     display symbol table processing
  bindings    display information about symbol binding
  versions    display version dependencies
  scopes      display scope information
  all         all previous options combined
  statistics  display relocation statistics
  unused      determined unused DSOs
  help        display this help message and exit

To direct the debugging output into a file instead of standard output
a filename can be specified using the LD_DEBUG_OUTPUT environment variable.

LD_DEBUG=libs很有用,它可以打印出动态库的搜索路径,方便定位找不到库的问题!

更多rpath

通过上文介绍的LD_DEBUG环境变量,我们可以测试一下找不到动态链接库文件时的搜索路径:

$ LD_DEBUG=libs ./test
     56235:     find library=libt.so [0]; searching
     56235:      search cache=/etc/ld.so.cache
     56235:      search path=/lib64/glibc-hwcaps/x86-64-v2:/lib64/tls/x86_64/x86_64:/lib64/tls/x86_64:/lib64/tls/x86_64:/lib64/tls:/lib64/x86_64/x86_64:/lib64/x86_64:/lib64/x86_64:/lib64:/usr/lib64/glibc-hwcaps/x86-64-v2:/usr/lib64/tls/x86_64/x86_64:/usr/lib64/tls/x86_64:/usr/lib64/tls/x86_64:/usr/lib64/tls:/usr/lib64/x86_64/x86_64:/usr/lib64/x86_64:/usr/lib64/x86_64:/usr/lib64             (system search path)
     56235:       trying file=/lib64/glibc-hwcaps/x86-64-v2/libt.so
     56235:       trying file=/lib64/tls/x86_64/x86_64/libt.so
     56235:       trying file=/lib64/tls/x86_64/libt.so
     56235:       trying file=/lib64/tls/x86_64/libt.so
     56235:       trying file=/lib64/tls/libt.so
     56235:       trying file=/lib64/x86_64/x86_64/libt.so
     56235:       trying file=/lib64/x86_64/libt.so
     56235:       trying file=/lib64/x86_64/libt.so
     56235:       trying file=/lib64/libt.so
     56235:       trying file=/usr/lib64/glibc-hwcaps/x86-64-v2/libt.so
     56235:       trying file=/usr/lib64/tls/x86_64/x86_64/libt.so
     56235:       trying file=/usr/lib64/tls/x86_64/libt.so
     56235:       trying file=/usr/lib64/tls/x86_64/libt.so
     56235:       trying file=/usr/lib64/tls/libt.so
     56235:       trying file=/usr/lib64/x86_64/x86_64/libt.so
     56235:       trying file=/usr/lib64/x86_64/libt.so
     56235:       trying file=/usr/lib64/x86_64/libt.so
     56235:       trying file=/usr/lib64/libt.so
     56235:
./test: error while loading shared libraries: libt.so: cannot open shared object file: No such file or directory

可以清晰的看到,搜索的路径全在/etc/ld.so.cache中!

如果增加rpath参数,让它指向与可执行文件相同的路径,再看看动态链接的搜索路径:

$ gcc test.c -o test -Xlinker -rpath=./ -L./ -lt
$ LD_DEBUG=libs ./test
     56327:     find library=libt.so [0]; searching
     56327:      search path=./glibc-hwcaps/x86-64-v2:./tls/x86_64/x86_64:./tls/x86_64:./tls/x86_64:./tls:./x86_64/x86_64:./x86_64:./x86_64:.              (RUNPATH from file ./test)
     56327:       trying file=./glibc-hwcaps/x86-64-v2/libt.so
     56327:       trying file=./tls/x86_64/x86_64/libt.so
     56327:       trying file=./tls/x86_64/libt.so
     56327:       trying file=./tls/x86_64/libt.so
     56327:       trying file=./tls/libt.so
     56327:       trying file=./x86_64/x86_64/libt.so
     56327:       trying file=./x86_64/libt.so
     56327:       trying file=./x86_64/libt.so
     56327:       trying file=./libt.so
     56327:
     56327:     find library=libc.so.6 [0]; searching
     56327:      search path=./glibc-hwcaps/x86-64-v2:./tls/x86_64/x86_64:./tls/x86_64:./tls/x86_64:./tls:./x86_64/x86_64:./x86_64:./x86_64:.              (RUNPATH from file ./test)
     56327:       trying file=./glibc-hwcaps/x86-64-v2/libc.so.6
     56327:       trying file=./tls/x86_64/x86_64/libc.so.6
     56327:       trying file=./tls/x86_64/libc.so.6
     56327:       trying file=./tls/x86_64/libc.so.6
     56327:       trying file=./tls/libc.so.6
     56327:       trying file=./x86_64/x86_64/libc.so.6
     56327:       trying file=./x86_64/libc.so.6
     56327:       trying file=./x86_64/libc.so.6
     56327:       trying file=./libc.so.6
     56327:      search cache=/etc/ld.so.cache
     56327:       trying file=/lib64/libc.so.6
     56327:
     56327:
     56327:     calling init: /lib64/ld-linux-x86-64.so.2
     56327:
     56327:
     56327:     calling init: /lib64/libc.so.6
     56327:
     56327:
     56327:     calling init: ./libt.so
     56327:
     56327:
     56327:     initialize program: ./test
     56327:
     56327:
     56327:     transferring control: ./test
     56327:
i am in t.c
     56327:
     56327:     calling fini: ./test [0]
     56327:
     56327:
     56327:     calling fini: ./libt.so [0]
     56327:

可以明显的看到,rpath增加的不仅仅是一个路径,而是一组!.rpath指向的路径,是个basedir

LD_BIND_NOW环境变量

前文有涉及Lazy Binding的内容,这是动态链接器的默认配置,当然也可以改变,即让LD_BIND_NOW环境变量为1即可。

$ export LD_BIND_NOW=1
$ # or
$ LD_BIND_NOW=1 command...

此时,程序加载时所有外部接口的地址在进入入口前,都会全部确定。一般实时进程需要这样的设置!

本文链接:https://cs.pynote.net/sf/c/cdm/202307066/

-- EOF --

-- MORE --