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

一、背景

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 里面符号的模块,所引用的符号也都被正确替换。

指定 ELF 的装载位置

在 Linux 下,可指定 ELF 的装载位置,包括可执行程序和共享库。这在一定程度上给了程序员控制进程空间地址分配的能力。

直接编译并运行,其内存地址映射如下:

编译时通过 linker 分别指定装载位置,观察运行时的内存地址映射:

可以看到,可执行程序 a.out 和 共享库 liba.so 均按指定的位置被装载。

使用 Clang 进行静态代码检测

一、Clang Static Analyzer 简介

Clang Static Analyzer 是一个工业级的静态源码检测工具,可以用来发现 C、C++ 和 Objective-C 程序中的 Bug。它既可以作为一个独立工具(scan-build)使用,也可以集成在 Xcode 中使用。

Clang Static Analyzer 建立在 Clang 和 LLVM 之上。严格地讲,它是 Clang 的一部分,因此它是完全开源的。Clang Static Analyzer 使用的静态分析引擎被实现为一个 C++ 库,可以在不同的客户端中重用,因此拥有很高的可扩展性。

二、scan-build 的使用

scan-build 就是 Clang Static Analyzer 的命令行工具。在 Clang 的二进制包中就可以直接找到它的身影,一般它与 Clang/Clang++ 同处于一个目录。

我们从一个示例开始:

scan-build 是在编译过程中检测代码的,因此必须和编译器一起协同工作,可以指定使用 gcc 或者 clang。注意,scan-build 会使用 clang/clang++ 做 analyze,即便指定编译器为  gcc/g++。

上述命令即对 memleak.c 进行检测,其中 -o 参数用于指定检测结果存放路径,检测结果会以 html 文件的形式保存:

可看到上述代码中的内存泄露问题被检测出来。以上是对于单个源码文件的检测,更好的方式是将 scan-build 直接与构建系统串接起来一起协同工作:

三、使用 scan-build 处理交叉编译

交叉编译的场景下,除了指定 toolchain 之外,还要指定 analyzer-target,另外还需要指定 C/C++ 的 include 路径:

其中 C_INCLUDE_PATH 和 CPLUS_INCLUDE_PATH 这两个变量都支持指定多个路径,中间使用冒号分隔。

对于大型程序,scan-build 会显著拖慢编译速度。另外,这类静态代码检测工具也有它的局限性,像内存访问越界类似的问题就不太可能检测得到,这时就要靠 Valgrind 和 Address Sanitizer 这些运行时的内存调试工具了。

参考:

  1. FAQ and How to Deal with Common False Positives
  2. Getting Started: Building and Running Clang