解决不同版本共享库间的符号冲突

一、背景

Chromium 源码中使用了大量的第三方开源库,其中部分做了定制或裁减,部分对库的版本有明确的要求,而平台也常常会提供同名的共享库,当不同版本的库同时被使用的时候,就会发生冲突。

工作中遇到一个典型的例子是 boringssl。boringssl 是 google 从 openssl fork 的分支,与标准的 openssl 库之间存在着大量的同名函数。在某个嵌入式平台,我们的 SDK 会使用 chromium 里的 boringssl,同时又间接地依赖系统的 openssl,这就导致了运行时的符号冲突,发生 crash。

针对这种情况,之前的解决方案是将 boringssl 做成静态库,并隐藏符号。这样运行时,我们的模块就会使用 boringssl,平台的模块会使用系统提供的 openssl。这样看似解决了问题,但是存在一个严重的弊端,就是会导致 size 膨胀。在我们的 SDK 中,boringssl 会被 8 个动态模块(共享库或可执行程序)所依赖。如果做成静态库,意味着这 8 个动态模块中都存在着 boringssl 的实现,这会导致 SDK size 显著增大。如果遇到类似的共享库冲突问题都这样来解决,显然是不合理的。最近找到了一种解决类似问题的方案,并做成一套机制,下面简要做一下介绍。

二、原理

要让不同版本的共享库共存而不发生符号冲突,最根本的解决之道就是修改共享库里的符号。提到修改符号,很多人的第一反应是修改源码。如果修改源码的话,不仅需要修改定义符号的源文件,并且所有引用到相关符号的源文件同样要做修改,这种工作极其繁琐。以 boringssl 为例,里面定义了上千个函数,如果手工更改的话,显然是要出人命的;即便写脚本去替换,由于宏定义、跨行等各种写法的存在,难免会导致很多疏漏,难以覆盖所有符号,再者编写脚本的工作量也不小。

既然修改源码的方式举步维艰,何不尝试去修改二进制文件呢?首先想到的方案是直接替换 ELF 文件中符号,但由于 ELF 文件中的符号已经被分配地址,导致这种工作几乎无法完成。这样似乎要绝望了。但仔细一想,编译器从源码生成最终的 ELF 文件,中间不是还会生成目标文件嘛,既然修改源码和 ELF 文件都不可行,是否可以修改编译中间阶段的目标文件呢?答案是肯定的。

考虑一个 C/C++ 程序编译链接过程:编译阶段,每个源文件(.c/.cc)会被编译成目标文件(.o),相关的符号并不会被实际分配地址,而是存放于目标文件的符号表中;如果引用的符号未在该源文件中定义,会在符号表中标记为 UND。链接阶段,linker 接收输入的目标文件,为每个目标文件中的符号分配地址,最终生成 ELF 文件。如果我们在目标文件中将符号替换,那么最终链接生成的 ELF 中的符号也就是替换后的版本了。

如何去修改目标文件中的符号呢?这里使用到了 objdump 和 objcopy 这两个利器。其中,objdump 可以用来导出 ELF 文件中的符号表,objcopy 可用于重命名符号。

如果要替换多个符号,可以使用 objcopy 的 –redefine-syms 参数,后面跟一个文件,其每一行的第一列是旧的符号,第二列是新的符号。

三、实现

要实现编译时对目标文件的符号替换,先要梳理构建工具和构建流程。Chromium 的构建系统主要使用了 gyp 和 ninja 这两套工具,其中 gyp 生成 .ninja 文件,ninja 负责最终的构建工作。而衔接 gyp 和 ninja 的桥梁,就是 tools/gyp/pylib/gyp/generator/ninja.py 这个脚本,它也是这里的工作重点。

Chromium 的构建工作,是从 out/Release/build.ninja 开始的,它是 ninja 工具最早的输入,也被称为 master ninja,里面定义着构建过程中会使用到的工具、规则(rule),以及依赖的子模块(subninja)等类内容。而 ninja.py 这个工具就是用来产生 mater ninja 和 sub ninja 文件的。

ninja 的规则(rule),简单来说就是针对输入的一系列处理流程,过程被称 command。如果将 ninja 的整个构建视作一张图,每一个 target 都是一个包括 input、output 以及 rule 的子图,其中 input 和 output 是节点,rule 就是从 input 到 output 的有向边,这和 Makefile 的语法非常类似,但更加灵活和简洁。比如,我们在构建过程常常看到的 cc 和 cxx 就是两个典型的 rule,分别用来处理 .c 和 .cpp 源文件,另外一个 cc_s 则用来处理汇编源码,它们都定义在 out/Release/build.ninja 这个 master ninja 文件中:

如果要定义 rule 来处理符号替换,那么输入自然是每个目标文件,command 就是我们使用 objcopy 替换符号的命令,输出就是新的目标文件。仔细一想,如果针对每个目标文件,都附加这么一套 rule,代价肯定是巨大的,会显著地增加每个 ninja 文件的 size,进而影响构建速度。这里想到的一个解决方案是改造既有的 rule 来产生我们期望的 rule,因为 cc、cxx、cc_s 这三个 rule 的 output 都是目标文件,我们只需要在 command 后面直接追加 objcopy 替换符号的命令就行了。

改造后的形式如下:

新的 rule 里,objcopy 直接作用于编译生成的目标文件,redefine_symbols_in_files 这个变量就是上面提到的记录着所需替换符号的文件。为支持同时指定多个输入文件,这里使用了 list,通过 join 的方式转成 string 导入到 ninja 文件中:

然后,在对源码文件定义 rule 的地方,检查 target 的配置项中 $redefine_symbols_in_files 这个 list 是否为空,如果不为空,说明有符号需要替换,那么就使用我们改造后的 rule,否则就使用原来的 rule:

四、使用

通过以上方式建立的机制,适用于所有与一开始提到的 boringssl 符号冲突问题相类似的场景。一旦我们依赖的某个 third party 模块(共享库)与系统的 library 间存在冲突,就可以通过该机制来解决。下面以 boringssl 为例来介绍其使用方法。

首先,导出目标模块(boringssl)的动态符号到文件:

以上命令会将 boringssl 中所有的动态函数符号导出,输入到一个文件,其中每一行的第一列是原来的符号,第二列是附加前缀 “chromium_” 后产生的新符号。

然后,我们需要修改相关模块的 gyp 文件,在相应的 target 配置下添加如下配置项:

其中 redefine_symbols_in_files 这个列表里面,存放着上面导出符号生成的文件的路径,可以同时指定多个文件;direct_dependent_settings 这项配置也是必须的,它会作用于所有依赖于 boringssl 的模块,这就保证了所有引用到 boringssl 里面符号的模块,所引用的符号也都被正确替换。