C++ 多重继承的内存布局和指针偏移

在 C++ 程序里,在有多重继承的类里面。指向派生类对象的基类指针,其实是指向了派生类对象里面,该基类对象的起始位置,该位置相对于派生类对象可能有偏移。偏移的大小,等于派生类的继承顺序表里面,排在该类前面的所有的类的数据成员(含虚表指针)所占的空间大小总和。

下面以一个简单的程序为例,揭示有多重继承关系的派生类对象的内存布局:

注意该程序用 #pragma pack 指令指示数据在内存中按 4 字节来对齐。在 x64 平台上编译执行结果:

我们首先来分析这四个类的大小。

A 只有一个普通函数成员 foo,没有任何数据成员,是一个空类,其大小为 1 字节。之所以空类大小不为零,是需要标识类对象在内存中的位置,这 1 字节空间仅作占位用,不代表任何意义。

B 有一个成员变量 int b 和一个虚函数成员 func,其中 b 的大小为 4 字节。 由于存在虚函数,因此 B 类起始位置有一个虚表指针(vptr),在 64 平台上指针的大小为 8 字节。因此 B 的大小为 4 + 8 = 12 字节。

C 仅有一个成员变量 int c,因此其大小也就为 4 字节。

D 继承自A, B, C,它的大小等于 A, B, C 的所有数据成员的大小,加上其自身的数据成员和虚表指针的总的大小:4(b) + 4(c) + 4(d) + 8(vptr) = 20 字节。

注意:在 D 的继承关系链里面,只有基类 B 有虚函数,因此对于 D 对象而言,总体只有一个虚表指针,也就是(B)基类对象中的虚表指针。如果派生类的多个基类都有虚函数,则对应每个有虚函数的基类,在派生类对象里都有一个虚表指针。

因此,对于分析派生类 D 的对象,其内存布局如下:

分析结果与程序运行结果一致。

 

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

一、背景

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 均按指定的位置被装载。