链接的套路

Introduction

Linux系统的二进制兼容性一向是万恶之源,作为用户极度讨厌在试图运行预编译的二进制的时候弹出来个version GLIBC_2.11 not found之类的报错。事实上,有些软件会在发行的时候注意到这一点,通过一些构建特别是链接的技巧来尽量做到令二进制文件适配各类Linux发行版,即做到所谓cross-distribution compatible。对于我们的内部软件产品,显然更应该做到尽可能适配多发行版,这篇文章主要总结一下相关技术。

Portable Binary

Executable

对可执行文件,gcc支持-static参数,它保证对可执行的产出能够彻底关掉动态链接功能,使得链接出来的可执行文件不含有任何对动态链接库的引用。但此时你就需要保证编译器能够找到静态的libc.a之类的东西,从而能够正确进行链接,不然就会报错"-lc"找不到之类的。这种可执行文件的好处是放到任何一台Linux系统的机器上都(可能)正确执行,不会触发任何缺少依赖库的报错。因此,一些工具类的程序很适合以这种方式来进行链接。

Shared Library

对于编译出的.so来说,以下这些库基本上是不太方便或者说也没有必要做到静态链接的:

linux-vdso.so.1 =>  (0x00007ffff53ff000)
librt.so.1 => /lib64/librt.so.1 (0x00007ffee0209000)
libm.so.6 => /lib64/libm.so.6 (0x00007ffedff84000)
libc.so.6 => /lib64/libc.so.6 (0x00007ffedfbf0000)
/lib64/ld-linux-x86-64.so.2 (0x00007ffee7020000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007ffedf9d3000)

vdso是内核注入地址空间的,所以不用管它;ld-linux是动态链接器肯定也免不了;pthread具有较强的platform specific属性,可以理解为不适合与系统解耦,所以最好动态载入;glibc也是同理,不然的话可能换个地方跑起来就core了。

需要注意的是,libmlibrt其实是可以进行静态链接的,glibc-static包中也提供了相应的.a,只不过这些文件并没有加-fPIC参数编译,因此无法用来链接shared library,只能用来链接可执行文件。但你只要自己手动编译一遍对应版本的glibc,把它的编译产中名为libm_pic.a这样的临时文件拷出来,作为你要链接的库即可。注意这些文件只是临时产出,所以只能通过这样的手段来获取。

那么为什么glibc不提供以-fPIC编译的.a呢?这是因为动态链接库机制本质上就是为了减小库文件大小,让通用的功能函数能够在系统各个组件之间共享,所以肯定是不鼓励你这样强行把系统默认库里面的函数二进制包含在自身里面的,这样做完全违背了动态链接库的设计目标。但很多时候我就是需要一个尽可能通用的so,可以让我拿到哪里都能用,此时就需要本文所介绍的技术了。

Example

最后提供一段完整的链接参数示例。假设你要将libxxx.a里面的所有内容,以及xxx.o链接到一起形成一个.so,并且希望它非常的portable,也就是只能依赖低版本的glibc。那么可以用下述参数来进行链接:

g++ -L. -shared -nodefaultlibs -o libxxx.so \
-specs=linker.specs \
-Wl,--wrap=memcpy \
-Wl,--wrap=__stack_chk_fail \
-Wl,--whole-archive \
libxxx.a xxx.o \
-Wl,--no-whole-archive \
-Wl,--start-group \
xxx.a .... \
$(gcc --print-file-name libgcc.a) \
$(gcc --print-file-name libgcc_eh.a) \
$(gcc --print-file-name libstdc++.a) \
libm.a librt.a libgomp.a xxx.a ... \
-Wl,--end-group \
-Wl,--as-needed -lc

上述所有.a都需要是-fPIC的,也就意味着libm.alibrt.a需要你手动从glibc的编译产出里面拷出来,libgomp.a需要你从gcc的编译产出中搞出来。另外,此处之所以不用-static-libgcc-static-libstdc++而是手动这样指定,是因为librt.a会和这两者发生符号冲突,所以最好把他们一起放到-Wl,--start-group -Wl,--end-group里面安排一下顺序,让librt.a在后

libgomp.a with -fPIC

首先全量编译gcc,配置编译参数如下:

../configure --enable-checking=release \
--enable-languages=c,c++  \
--disable-multilib \
--enable-shared \
--enable-host-shared \
--disable-tls

注意需要手动关掉TLS功能,阻止libgomp在代码中使用Thread Local Storage,否则静态链接libgomp的话虽然可以成功,但用起来就会报错,无法成功dlopen,这里涉及到glibc在装载使用了TLS机制的动态链接库上面的一些实现细节。

然后进入<build-dir>/x86_64-unknown-linux-gnu/libgomp/,这里我们会看到好多.o文件,注意这里的object file是不含-fPIC的,随便打开一个.lo描述文件,我们可以看到这样的内容:

# Name of the PIC object.
pic_object='.libs/error.o'
# Name of the non-PIC object
non_pic_object='error.o'

所以也就意味着用-fPIC选项编译出来的.o文件实际上放在<build-dir>/x86_64-unknown-linux-gnu/libgomp/.libs/下面,我们只需要把它们压缩成一个新的.a,就是我们想要的支持-fPIClibgomp.a了。

搜索顺序

链接时对动态库的搜索顺序主要参考man ld中的描述:https://linux.die.net/man/1/ld

运行时的搜索顺序参考man ld.so中的描述:https://linux.die.net/man/8/ld.so

使用readelf -d <exe>可以检查某个可执行文件的.dynamic段。其中的NEEDED表明那些so是运行所需的。如果so名称中不包含绝对路径,则动态链接器会按照某种顺序搜索来试图找到他。这个搜索顺序存放在RPATH条目中,其中的$ORIGIN变量是用来表示当前二进制文件在被装载时的那个路径位置。可以通过链接器参数-rpath来设置这个顺序。

对于已经编译好的二进制文件,可以通过patchelf这类工具来修改dynamic linker以及RPATH——这并不是一个非常简单的事情,考虑到新的路径可能更长,需要对ELF文件做额外的处理。常见的用法如下:

patchelf --set-interpreter /lib64/ld-2.17.so  ~/.dropbox-dist/dropbox-lnx.x86_64-59.4.93/dropbox
patchelf --set-rpath /lib64/ ~/.dropbox-dist/dropbox-lnx.x86_64-59.4.93/dropbox

指定符号版本

GLIBC不支持通过链接选项链接到某个版本,只能通过symver配合linkerwrap参数来实现。首先在项目中加入如下代码:

:::C
#include <string.h>

void *__memcpy_glibc_2_2_5(void *, const void *, size_t);

asm(".symver __memcpy_glibc_2_2_5, memcpy@GLIBC_2.2.5");

void *__wrap_memcpy(void *dest, const void *src, size_t n) {
    return __memcpy_glibc_2_2_5(dest, src, n);
}

然后在链接器(不是编译器)参数中加入:-Wl,--wrap=memcpy。这样所有未决议的memcpy都被解析到__wrap_memcpy这个包装函数上来。

使用objdump -p <so-name>或者strings <so-name> | grep GLIBC可以检查某个二进制所依赖的所有动态库版本。使用objdump -T <so-name> | grep GLIBC_2.14来检查二进制中具体引用了该版本GLIBC的哪个符号。

GCC specs

gcc程序本质上是一个编译驱动器,它按照某些规则,自动地调用预处理、编译、汇编、链接等等过程。这个规则被存储在称为specs的文件中,随着编译器的发行,会携带对应的默认specs文件。specs文件中的参数会优先于所有后续命令行补充的参数,因此如果编译器自带的规则中有不符合预期的条目,我们需要将其覆盖掉。gcc支持命令行-specs来指定规则文件,并且可以指定多个,后面的规则文件可以覆盖掉前面载入的

自动为section生成的__start & __stop

如果你在代码里用__attribute__来为函数指定要把它放到哪个ELF段(section)里面,并且你选择的段名是一个合法的C标识符,那么链接器会自动为你生成相应的__start__stop符号,例如下面这样:

#include <stdio.h>
int i;
__attribute__((used, noinline, section("mysection")))
void test_func (void) {
    i++;
}
int main() {
    extern unsigned char __start_mysection[];
    extern unsigned char __stop_mysection[];
    printf ("Func len: %lu\n", __stop_mysection - __start_mysection);
    test_func ();
    return 0;
}

需要注意的是,你得加上usednoinline以保证编译器不把你的代码优化掉,以及你得在什么地方引用过__start__end,这样链接器发现符号缺失的时候才会为你自动生成它。此外,这个技巧并不是跨平台的,在macOS上面编译这段代码你就会得到这样的报错:

test.c:3:40: error: argument to 'section' attribute is not valid for this target: mach-o section
      specifier requires a segment and section separated by a comma

Symbol Strip

Useful Parameters

GCC参数大全:https://gcc.gnu.org/onlinedocs/gcc/Option-Summary.html.

Compiler

  • static: On systems that support dynamic linking, this overrides -pie and prevents linking with the shared libraries. On other systems, this option has no effect.
  • -nostdinc/--nostdinc++: Instructs the compiler to not search for header files in standard system directories. Tells the compiler to search for header files only in directories specified with the –I option, as well as in the directory of a current file, if appropriate.
  • -nostdlib: Do not use the standard system startup files or libraries when linking.
  • -nodefaultlibs: Do not use the standard system libraries when linking.
  • -fno-builtin: Do not recognize built-in functions that do not begin with __builtin_ as prefix.
  • -fno-asm: Do not recognize asm, inline or typeof as a keyword, so that code can use these words as identifiers.
  • -rdynamic: This instructs the linker to add all symbols, not only used ones, to the dynamic symbol table. This option is needed for some uses of dlopen or to allow obtaining backtraces from within a program.
  • -static-libgcc/-static-libstdc++: 尝试静态链接libgcclibstdc++
  • C_INCLUDE_PATH or CPLUS_INCLUDE_PATH or CPATH will set the include path for both C and C++.
  • -fno-stack-protector: 关栈保护。

Linker

  • --whole-archive/--no-whole-archive
    • For each archive mentioned on the command line after the --whole-archive option, include every object file in the archive in the link, rather than searching the archive for the required object files. This is normally used to turn an archive file into a shared library, forcing every object to be included in the resulting shared library.
  • --start-group/--end-group
    • The archives should be a list of archive files. They may be either explicit file names, or -l options. The specified archives are searched repeatedly until no new undefined references are created.
    • Normally, an archive is searched only once in the order that it is specified on the command line. If a symbol in that archive is needed to resolve an undefined symbol referred to by an object in an archive that appears later on the command line, the linker would not be able to resolve that reference.
    • By grouping the archives, they all be searched repeatedly until all possible references are resolved. Using this option has a significant performance cost. It is best to use it only when there are unavoidable circular references between two or more archives.
  • -Bstatic/-Bdynamic: 用于在命令行中切换静态链接/动态链接,中间是需要静态链接的库名。
  • --wrap=xxx
    • 为特定的符号选择wrapper函数,一般用于选择GLIBCmemcpy的版本。需要配合相应的实现。
  • --strip-all: Omit all symbol information from the output file.
  • --strip-debug: Omit debugger symbol information (but not all symbols) from the output file.

Reference

  • http://wiki.osdev.org/C%2B%2B_Exception_Support
  • https://www.airs.com/blog/archives/56
comments powered by Disqus
Published:
2017-12-04
分类:
Tag: