多平台兼容的常用方法——Linux二进制应用兼容

本文将介绍开发多Linux平台C++应用程序时可能遇到的一些兼容性问题及相关解决方案。虽然它是基于 C++ 的,但兼容性的问题是所有类 C 语言(如 Go、Rust 等)都可能在没有 VM 帮助你做脏活的情况下遇到它。

限于个人经验,本文讨论的内容仅限于x86架构,但相信相关原理和规则在其他架构中也是一样的,可以参考。

Linux 二进制兼容性

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

众所周知,不同的 Linux 发行版承载不同的基础库版本。以最常用的g++工具链为例,基于它们的应用程序会顺带依赖libc、libgcc、libstdc++等库。显然,当应用程序使用了只有高版本才有的功能时,编译后的二进制内容在低版本环境中运行时,就会出现兼容性问题,最常见的表现就是无法运行。

简而言之,当提供的应用程序二进制文件在目标平台上无法正常工作时(包括无法运行的最坏情况),我们将其视为不兼容的情况。

多平台兼容的常用方法

为了使应用程序兼容多平台,从开发者的角度来看,一般有以下三种方法[1]。

1. 为每个目标平台提供特定的二进制文件

顾名思义,此方法为每个目标平台提供了相应的二进制文件。

这种方式的好处是每个二进制或安装包都可以适配目标平台,在承诺支持的范围内基本不用担心不兼容。

但是这种方式的缺点也很明显,维护成本也比较高。应用程序每增加一个新的目标平台,就必须在发布过程中为其构建相应的编译打包环境。即使使用一些手段(比如容器镜像)来自动化这个过程,维护很多编译环境本身也会带来相当大的好处。工作量。

2. 低版本环境编译

这种方式需要开发者在目标平台中版本最低的环境中设置编译环境。这里的版本主要是指编译工具链。例如,我们期望提供可以从 CentOS 5.x 运行到 7.x 的应用程序,那么编译环境可以设置在 5.0 上。

这种方法源于对 Linux 向后兼容性的信任。根据经验,在低版本上编译的二进制文件在高版本上正常运行的概率很高。

这种方法的缺点是应用程序可以使用的功能受到编译环境的限制,包括可以使用的语言特性和系统能力。例如:

3. 静态链接

严格来说,这并不是多平台兼容的独立解决方案,因为可以和前两种方法结合使用,但考虑到这是一种很常用的方法,这里简单说几句。

这种方法解决兼容性问题的基本思路是静态链接应用程序所依赖的各个库,这样在应用程序发布时只需要提供一个二进制文件,不需要附加一个一系列关联的动态库(so文件),可以有效降低出现不兼容问题的概率。

但是静态链接并不是万能的。除了体积膨胀:after有兼容性问题吗,它还有两个问题。一方面,一些库的许可会限制静态链接,另一方面,即使我们可以静态链接大部分库,系统发布的 libc.so[2] 也不能这样做,它会带来一些兼容性问题。

我们的多平台兼容性理念

本节将简要介绍我们在 SLS 捕获代理 Logtail 开发过程中为多平台兼容性而做出的一些选择。

1. 不排除更高版本的编译器(只要它们是稳定的)

最初,我们只采用方法 2 尽可能多平台兼容,并且效果很好。但是随着 C++ 标准的不断发展,我们面临一个直接的问题:对低版本环境的“向后”语法支持与越来越多的新特性之间的不一致。在低版本环境下,由于只支持C++98,我们:

但是,经过研究和实践,我们发现,其实我们可以在静态链接标准库和手动构建编译工具的帮助下,在保证兼容性的同时,愉快地使用新特性。

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

虽然静态链接会导致二进制文件的体积有一定程度的膨胀,但我们认为这些额外的空间开销相对于它所能带来的兼容性的提升来说是值得的。

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

3. 符号替换

算上我们遇到的兼容性问题,大部分是由于运行环境中缺少必需的符号或符号版本不一致造成的。这时,符号替换将是一个很好的解决方案。其实我们也借用了这个方法解决了libc.so带来的一些问题。

操作实践

对于一篇实用的文章,单纯的用文字来介绍总是欠缺的,也不能清楚地描述实际的问题。因此,本节将举例补充上述内容。

示例应用程序代码

在示例应用程序中,我们使用了 C++11 的一些特性,包括统一初始化、lambda(带捕获)、for auto 等。

#include 
#include 
#include 
#include 
using namespace std;
int main()
{
  vector 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;
}

编译运行环境

以下是示例中使用的两个环境,我们将使用 g++ 4.8.5 在 CentOS 7 上编译应用程序,然后将生成的二进制文件放到 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个版本的symbols(用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, std::allocator >::operator=(std::basic_string, std::allocator >&&)
$ c++filt _ZNSsC1EOSs
std::basic_string, std::allocator >::basic_string(std::basic_string, std::allocator >&&)

可以发现,这是与字符串相关的两个方法,以右值引用为参数,所以在不支持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查看哪个符号需要GLIBC2.14,结果如下:

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

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

为此,可以使用内联汇编+符号指定来实现这一点。为了篇幅,这里直接给出对应的解决方案代码。具体分析工作请参考旧版glibc兼容之旅-CSDN博客。

#ifdef v3
extern "C"
{
#include 
  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++错位符号,本来我们静态链接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 唯一的全局符号类型,是 GNU 对 ELF 的扩展,它会影响动态链接的过程,也就是说,它会影响动态链接的处理。 ld 的链接过程。

因为 ld/nm 等命令也是基础环境之一,所以两个环境上的版本也不同。低版本2.17.50不支持这个扩展,所以nm视图的结果显示为unknown(?):after有兼容性问题吗,ld做动态链接时会丢弃这个未知符号,所以有未定义符号的问题。

对于这个问题,像libc.so一样,我们没有办法更新ld,所以只能在编译环境下解决这个问题。解决方法是让gcc不生成这种扩展类型的符号,让运行环境中的ld能够识别和链接。

不生成唯一全局符号 (v4)

对于这个需求,从gcc邮件列表的回复可以看出,没有这个编译选项。唯一可行的方法是在编译 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

唯一需要注意的是选择安装目录,并将安装目录的内容导出到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 代表弱对象,该类型与 ld 的低版本兼容。

概括

从我个人的经验来看,研究二进制兼容性更多的是一个熟悉和理解编译工具和操作系统定义的规则的过程,远没有设计和实现它们困难。不过考虑到这个探索的过程比较折腾,我尽量把可以通过这篇文章总结出来的内容整理一下,希望能让读者以后做相关的事情时少踩点坑。

由于重点介绍方法和分析思路,所以本文使用的应用示例比较简单(仅考虑工具链依赖库的范围),以后有时间会添加兼容性改造过程,以供更完整的应用使用。未来,敬请期待。

请参阅创建可移植的 Linux 二进制文件。这里的 libc.so 来自 glibc,而不是 Linux 历史上的其他来源。对这段历史感兴趣的同学可以看看libc(7)老版本glibc兼容征程-CSDN博客SERVER -11641 undefined symbol:_ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE - MongoDBRe:--no-gnu-unique选项禁用STB_GNU_UNIQUE

© 版权声明
THE END
喜欢就支持一下吧
点赞1
分享
评论 抢沙发

请登录后发表评论