Linux C++ 应用二进制兼容实践

老子叫甜甜 提交于 2019-12-26 11:18:12

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

本文将介绍一些在开发多 Linux 平台 C++ 应用时可能遇到的兼容性问题和相关的解法。虽然是以 C++ 为讲述对象,但兼容性这个问题,在没有 VM 帮你做这些脏活累活的情况下,是所有 C-like 语言(比如 Go、Rust 等)都可能遇到的。

受个人经验所限,本文所讨论内容仅限于 x86 架构下,但相信相关的原理和规则在其他架构下也是相通的,可作借鉴参考。

Linux 二进制兼容

首先,我们来看看什么叫二进制兼容?

众所周知,不同的 Linux 发行版会携带不同的基础库版本,以最常用的 g++ 工具链为例,基于它们的应用会附带地依赖上 libc, libgcc, libstdc++ 等库。显然,当应用使用了高版本才具备的功能后,编译得到的二进制内容在低版本环境中运行时,将产生兼容问题,最常见的表现就是无法运行

简而言之,当所提供的应用 binary 在目标平台上无法正常运行(包括跑不起来这种最差的情况),我们就认为这是一种不兼容的情况。

多平台兼容的常用方法

为了让应用兼容多平台,从开发者的角度一般有以下三个方法 [1]

1. 为每个目标平台提供特定的 Binary

顾名思义,对于每个目标平台,这种方法都要提供相应的 binary。

这种方法的好处在于每个 binary 或是安装包都能够对目标平台进行针对性适配,在承诺支持的范围内基本不需要担心发生不兼容的情况。

但这种方式的缺点也很明显,维护代价较大。应用每新增一个目标平台,在发布流程中就要为之构建相应的编译打包环境,即便是借助一些手段(比如容器镜像)来实现流程自动化,维护诸多的编译环境本身也会带来不小的工作量。

2. 低版本环境编译

此方法要求开发者将编译环境设置在目标平台中版本最低的环境上,此处的版本主要指的编译工具链。比如我们期望提供 CentOS 5.x 到 7.x 都能运行的应用,那么可以将编译环境设置在 5.0 上。

这个方法源于对 Linux 向后兼容能力的信任,根据经验,在低版本上编译得到的 binary,在高版本上有很大概率能够正常运行。

此方法的缺陷是应用能够使用的功能受限于编译环境,包括所能够使用的语言特性和系统功能。比如:

  • 如果环境上的 gcc 工具链仍在 4.1.x 版本,我们显然无法使用 C++11 等特性。
  • 某些系统库(比如 journal)需要更高的内核版本支持,那么在低版本环境下将无法使用。

3. 静态链接

严格来说,这不算是一个独立解决多平台兼容的方法,因为它完全可以结合前两个方法一并使用,但考虑到这是一个非常常用的办法,在此我们简单地说两句。

此方法解决兼容问题的基本思路是将应用所依赖的各种库都进行静态链接,这样在发布应用时仅需要提供一个单独的 binary,而无需附带上一系列关联的动态库(so 文件),能够有效地降低不兼容问题出现的概率。

但静态链接并非万能,抛开体积膨胀以外,它还有这样两个问题。一方面,有些库的 license 中会限制静态链接,另一方面,即使我们可以对大部分库进行静态链接,但随系统发布的 libc.so [2] 是无法这样做的,它也会带来一些兼容问题 。

我们的多平台兼容思路

本节将简要介绍在开发 Logtail(SLS 采集 agent)的过程中,我们和多平台兼容「斗争」时做出的一些选择。

1. 不排斥高版本编译器(只要稳定)

最初,我们仅采用了方法 2 来做到尽可能地兼容多平台,效果很好。但随着 C++ 标准的不断演进,我们面临了一个直接问题:低版本环境「落后」的语法支持和日益了解的新特性之间的矛盾。在低版本环境下,由于仅支持 C++98,我们:

  • 没法在恰当的地方引入 move 语义,只能依靠注释。
  • 重复地敲打着 auto 就能替换的迭代器类型声明。
  • ...

但经过调研和实践后,我们发现,其实只需要借助静态链接标准库+手动构建编译工具,就能够在保证兼容性地情况下,开心地使用新特性。

2. 尽可能地静态链接(注意版权)

虽然静态链接会导致 binary 产生一定程度的体积膨胀,但相比它能够带来的兼容能力的提升,这些额外的空间开销我们认为是值得的。

对于版权,丰富的开源生态并没有让我们失望,暂未遇到任何这方面的限制。

3. 符号替换

细数我们所遇到的兼容性问题,大多数都是在运行环境中缺失所需符号或是符号版本不一致导致的,此时符号替换将是一个很好的解决思路,事实上,我们也是借此方法来解决 libc.so 带来的一些问题。

操作实践

对于一篇实践类的文章,单纯使用文字来介绍总是匮乏的,也无法清楚地描述实际的问题。因此,本节将通过一个示例来对前述内容进行补充说明。

示例应用代码

在示例应用中,我们使用了 C++11 的一些特性,包括 uniform initialization, lambda (with capture), for auto 等。

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

int main()
{
  vector<string> vec = {"b", "a", "d"};
  auto printVec = [&vec]()
  {
    for (auto &s : vec)
    {
      std::cout << s << std::endl;
    }
  };

  for (int i = 0; i < 10; ++i)
  {
    vec.push_back(to_string(i));
  }

  std::cout << "===== Before =====" << std::endl;
  printVec();
  sort(vec.begin(), vec.end());
  std::cout << "===== After =====" << std::endl;
  printVec();

  return 0;
}

编译及运行环境

如下是示例所使用的两个环境,我们将在 CentOS 7 上使用 g++ 4.8.5 对应用进行编译,然后把得到的 binary 放到 CentOS 5 上运行。

# 在两个环境上分别运行此命令
$ cat /etc/redhat-release; uname -r; g++ --version | grep g++; ld --version | grep ld

# 编译环境(高版本)
CentOS Linux release 7.5.1804 (Core)
3.10.0-862.3.2.el7.x86_64
g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-28)
GNU ld version 2.27-27.base.el7

# 运行环境(低版本)
CentOS release 5.7 (Final)
2.6.18-274.el5
g++ (GCC) 4.1.2 20080704 (Red Hat 4.1.2-51)
GNU ld version 2.17.50.0.6-14.el5 20061020

原始版本(v1)

执行 g++ -o main_v1 -std=c++11 main.cpp 进行编译,将得到的结果拷贝到运行环境执行,结果如下:

./main_v1: /usr/lib64/libstdc++.so.6: version `GLIBCXX_3.4.14' not found (required by ./main_v1)

这个报错表示所链接的 libstdc++.so 无法满足版本要求。对此,分别查看一下 libstdc++.so 和 main_v1 中 GLIBCXX 的版本情况:

$ strings main_v1 | grep "GLIBCXX_"
GLIBCXX_3.4.5
GLIBCXX_3.4.14
GLIBCXX_3.4

$ strings /usr/lib64/libstdc++.so.6 | grep "GLIBCXX_"
GLIBCXX_3.4
GLIBCXX_3.4.1
...
GLIBCXX_3.4.8
GLIBCXX_FORCE_NEW

可以看到,main_v1 要求 3.4.14 而运行环境上的 libstdc++.so 仅支持到 3.4.8,所以产生了这个错误。

对于这个问题,由于运行环境的不可控,我们无法通过更新 libstdc++.so 来解决,只能通过修改自己的应用来进行兼容。

解决办法:静态链接 libstdc++.a。

此处我们使用 nm 来进一步分析 main_v1 究竟依赖了哪些 3.4.14 版本的符号(配合 c++filt 进行 demangle),结果如下:

$ nm main_v1 | grep "GLIBCXX_3.4.14"
                 U _ZNSsaSEOSs@@GLIBCXX_3.4.14
                 U _ZNSsC1EOSs@@GLIBCXX_3.4.14
$ c++filt _ZNSsaSEOSs
std::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator=(std::basic_string<char, std::char_traits<char>, std::allocator<char> >&&)
$ c++filt _ZNSsC1EOSs
std::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::basic_string<char, std::char_traits<char>, std::allocator<char> >&&)

可以发现,这是与 string 相关的两个以右值引用为参数的方法,所以在不支持 C++11 的低版本环境上,libstdc++.so 显然不可能有这些符号。

静态链接 libstdc++(v2)

一般来说,编译环境中是不会自带 libstdc++.a,需要做一些额外的安装,比如 CentOS 7 可以直接通过 yum 安装。

如下是做了静态链接后的运行结果:

# 安装 + 静态链接
$ sudo yum install -y libstdc++-static
$ g++ -o main_v2 -static-libstdc++ -std=c++11 main.cpp

# 运行
./main_v2: /lib64/libc.so.6: version `GLIBC_2.14' not found (required by ./main_v2)

和 v1 类似的错误,借助同样的方法可以发现,这次是 libc.so 的版本不支持导致的,main_v2 需要 2.14 而运行环境上仅支持到 2.5。

$ strings main_v2 | grep "GLIBC_"
GLIBC_2.3
GLIBC_2.14
GLIBC_2.3.2
GLIBC_2.2.5
$ strings /lib64/libc.so.6 | grep "GLIBC_"
GLIBC_2.2.5
GLIBC_2.2.6
GLIBC_2.3
GLIBC_2.3.2
GLIBC_2.3.3
GLIBC_2.3.4
GLIBC_2.4
GLIBC_2.5
GLIBC_PRIVATE

作为一个随系统释出的库,libc.so 带来的兼容性问题一般无法通过静态链接解决(理论上或许可行),我们只能寻求其他的方法。

符号替换(v3)

为了解决 v2 的问题,我们先用 nm 看看究竟是哪个符号需要 GLIBC 2.14,结果如下:

$ nm main_v2 | grep "GLIBC_2.14"
                 U memcpy@@GLIBC_2.14

可以看到,只有 memcpy 这一个符号,直觉上这个方法的实现不太可能跟着版本在不停更新。在查看 glibc 源码后可以发现,string/memcpy.c 在 2.2.5 -> 2.14 之间都没有任何变化。因此,低版本环境上的 libc.so 其实已经提供了我们需要的 memcpy 的实现,唯一需要解决的就是绕过版本的检查。

对于这一点,可以借助 内联汇编 + 符号指定 来实现。出于篇幅,此处我们直接给出相应地解决代码,具体分析工作可以参考旧版glibc兼容旅程 - CSDN博客

#ifdef v3
extern "C"
{
#include <string.h>
  asm(".symver memcpy, memcpy@GLIBC_2.2.5");
  void* __wrap_memcpy(void* dest, const void* src, size_t n)
  {
    return memcpy(dest, src, n);
  }
}
#endif

编译及运行结果:

$ g++ -o main_v3 -static-libstdc++ -Wl,--wrap=memcpy -Dv3 -std=c++11 main.cpp

$ ./main_v3: symbol lookup error: ./main_v3: undefined symbol: _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE

还是无法运行......我们来分析一下,显然,这是一个 C++ mangled 符号,按道理应该在我们静态链接 libstdc++ 时已经解决了,为什么依旧会出现呢?

搜了一番后发现了这样一个帖子:SERVER-11641 undefined symbol: _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE - MongoDB。有兴趣的同学可以细看一下帖子的内容,就基本能理解这个问题了,这里我简单地复述一遍。

我们把 main_v3 拷贝到两个环境中,然后使用 nm 来查看一下这个符号:

$ nm main_v3 | grep "_ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE"

# 上面的是编译环境,下面是运行环境
0000000000680cc0 u _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE
0000000000680cc0 ? _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE

可以发现,中间那个字符有所不同,在高版本的编译环境上,中间的符号是 u,而低版本的运行环境上则是 ?。

从 man nm 中可知,u 表示这个符号是 GNU unique global symbol 类型,这是 GNU 对 ELF 的一个扩展,它会影响到动态链接的过程,换句话说,它会影响到 ld 对动态链接过程的处理。

因为 ld/nm 等命令也是基础环境之一,两个环境上的版本也有不同,低版本的 2.17.50 并没有支持这个扩展,所以 nm 查看的结果显示为未知(?),而 ld 在做动态链接时会抛弃掉这种未知的符号,所以也就出现了未定义符号的问题。

对于这个问题,和 libc.so 一样,我们也没办法去更新 ld,所以还是只能在编译环境中解决此问题。解决的思路就是让 gcc 不要生成这种扩展类型的符号,让运行环境中的 ld 能够识别并链接它。

不生成 Unique Global Symbol(v4)

对于这个需求,从 gcc mail list 的回复中可以看到,并没有这样的编译选项,唯一可行的途径是在编译 gcc 的时候,指定一个 --disable-gnu-unique-object 参数,因此,解决办法就是重新编译一个 gcc...

$ wget http://ftp.tsukuba.wide.ad.jp/software/gcc/releases/gcc-4.8.5/gcc-4.8.5.tar.bz2
$ tar -xjvf gcc-4.8.5.tar.bz2
$ cd gcc-4.8.5 && ./contrib/download_prerequisites
$ mkdir build-result && cd build-result
$ ../configure --enable-checking=release --enable-languages=c,c++ --disable-multilib --disable-gnu-unique-object --prefix=/usr/local/gcc-4.8.5
$ make && sudo make install
$ export PATH=/usr/local/gcc-4.8.5:$PATH

唯一需要注意的一点是选择好安装的目录,并且将安装目录的内容 export 到 PATH 中。

使用编译得到的 g++,使用 v3 的编译命令得到 main_v4 后,在运行环境中成功执行。

最后,我们可以直接 nm 比较一下 v3, v4:

$ nm main_v3 | grep "_ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE"
0000000000680cc0 u _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE
$ nm main_v4 | grep "_ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE"
000000000067dcc0 V _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE

在 v4 中的符号类型发生了变化,V 代表的 weak object,这个类型可以兼容低版本的 ld。

小结

就我个人感受而言,钻研二进制兼容性更多是个熟悉和理解编译工具以及操作系统所定义规则的过程,远不及设计和实现它们时的难度。但考虑到这个探索的过程也算挺折腾的,所以尽量把能够总结的内容通过本文进行了整理,希望能让读者在后续做相关事情时少才踩些坑。

 

 

v

原文链接

本文为阿里云内容,未经允许不得转载。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!