c++混合使用不同标准编译潜在的问题

最近项目使用的C++的版本到C++11了,但是由于有些静态库(.a)没有源码,因此链接时还在使用非C++11版本的库文件。目前跑了几天,似乎是没出什么问题,但是我还是想说一下这样做有哪些潜在的风险。

首先需要说明的是,升级到C++11之后,部分std的数据结构的内存布局有可能发生改变(待考究)。最开始,我认为只要静态库暴露出来的接口没有使用这些不兼容的数据结构即可。也就是说,如果静态库暴露的所有接口都是纯C风格的,没有使用任何C++ std的数据结构,则链接这种静态库应该是安全的。但是后来发现似乎并不是这样...

来看看C++消除重复代码的机制

假设有一个模板类MyClass<T>定义在头文件my_template.h中。源文件x.cpp和y.cpp都包含了这个头文件,并且用类型int实例化了这个模板类。由于在编译时这两个源文件是完全独立的,因此两个源文件生成的object file(x.o和y.o)中都会包含了MyClass<int>的代码。但是实际上对应一个可执行的程序来说,MyClass<int>的代码存在一份即可。因此在链接阶段会有一个重复代码消除的步骤,回到上述例子就是x.o和y.o里的MyClass<int>的代码会被合并,最后在可执行程序里仅存在一份。

Linux GCC通过ELF的COMDAT section来实现消除重复的模板代码。COMDAT是一种特殊的section(ELF和COFF都有COMDAT section的概念),通常它会关联一个字符串(也可能就是section的名字)。链接器在处理object file时,对遇到的同名的section会执行去重操作,保证在输出的output file中仅存在一份实例。

看看下面的代码

// my_template.h

template <typename T>
class MyClass
{
public:

    void func1()
    {
        i1 = 1;
        i2 = 2;
    }

public:

#ifdef TEST
    int i1;
    int i2;
#else
    int i2;
    int i1;
#endif
};

 

上述代码定义了模板类MyClass<T>,并且其内存布局依赖于一个TEST宏。然后我们这样去使用它:

// x.cpp

#include <stdio.h>

#include "my_template.h"

void func_in_x()
{
    MyClass<int> c;
    c.func1();

    printf("i1=%d, i2=%d\n", c.i1, c.i2);
}

// y.cpp

#include <stdio.h>

#define TEST
#include "my_template.h"

void func_in_y()
{
    MyClass<int> c;
    c.func1();

    printf("i1=%d, i2=%d\n", c.i1, c.i2);
}

 

可以看到,在x.cpp中没有定义宏TEST,而在y.cpp中定义了宏TEST。因此这两个编译单元看到的MyClass<int>的内存布局应该是不一样的。

objdump -S x.o 看到成员函数func1的代码是这样的:

0000000000000000 <_ZN7MyClassIiE5func1Ev>:
template <typename T>
class MyClass
{
public:

    void func1()
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 89 7d f8             mov    %rdi,-0x8(%rbp)
    {
        i1 = 1;
   8:   48 8b 45 f8             mov    -0x8(%rbp),%rax
   c:   c7 40 04 01 00 00 00    movl   $0x1,0x4(%rax)
        i2 = 2;
  13:   48 8b 45 f8             mov    -0x8(%rbp),%rax
  17:   c7 00 02 00 00 00       movl   $0x2,(%rax)
    }
  1d:   90                      nop
  1e:   5d                      pop    %rbp
  1f:   c3                      retq

objdump -S y.o 看到成员函数func1的代码是这样的:

0000000000000000 <_ZN7MyClassIiE5func1Ev>:
template <typename T>
class MyClass
{
public:

    void func1()
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 89 7d f8             mov    %rdi,-0x8(%rbp)
    {
        i1 = 1;
   8:   48 8b 45 f8             mov    -0x8(%rbp),%rax
   c:   c7 00 01 00 00 00       movl   $0x1,(%rax)
        i2 = 2;
  12:   48 8b 45 f8             mov    -0x8(%rbp),%rax
  16:   c7 40 04 02 00 00 00    movl   $0x2,0x4(%rax)
    }
  1d:   90                      nop
  1e:   5d                      pop    %rbp
  1f:   c3                      retq

看上述生成的汇编代码可知,MyClass<int>的内存布局确实不一样。在x.o中成员i1的起始地址在对象内存的第4个字节处(偏移是0x4),而在y.o中成员i1的起始地址就是对象的地址(偏移是0x0)。

主函数代码如下:

void func_in_x();
void func_in_y();

int main()
{
    func_in_x();
    func_in_y();
    return 0;
}

 运行程序,发现结果如下:

$ ./a.out
i1=1, i2=2
i1=2, i2=1

虽然我们在x.cpp和y.cpp都是对i1赋值为1,对i2赋值为2。但是却出现了一个i1值为2,i2值为1的结果。

objdump -S a.out 发现func1的代码如下:

0000000000000712 <_ZN7MyClassIiE5func1Ev>:
template <typename T>
class MyClass
{
public:

    void func1()
 712:   55                      push   %rbp
 713:   48 89 e5                mov    %rsp,%rbp
 716:   48 89 7d f8             mov    %rdi,-0x8(%rbp)
    {
        i1 = 1;
 71a:   48 8b 45 f8             mov    -0x8(%rbp),%rax
 71e:   c7 40 04 01 00 00 00    movl   $0x1,0x4(%rax)
        i2 = 2;
 725:   48 8b 45 f8             mov    -0x8(%rbp),%rax
 729:   c7 00 02 00 00 00       movl   $0x2,(%rax)
    }
 72f:   90                      nop
 730:   5d                      pop    %rbp
 731:   c3                      retq

可以发现,到了可执行程序中确实仅有一份MyClass<int>的代码,并且是用了x.cpp的那一份。因此在x.cpp中输出的结果是对的,在y.cpp中输出的结果确是相反的。

改变一下链接顺序,这次我们让链接器先处理y.o再处理x.o。再次运行程序,结果如下:

$ g++ y.o x.o main.o
$ ./a.out
i1=2, i2=1
i1=1, i2=2

这次变成x.cpp中输出是错的,y.cpp中输出是对的了。

从上述例子可知:链接器在对COMDAT section去重时,并没有辨别section的内容是否一致。因此在不同的object file中内存布局不一致的C++模板被链接到一起是有潜在的风险的。静态库(.a)文件实际上是单纯的object file集合再加上一个符号表,因此链接静态库(.a)文件情况和链接object file是一样的。而动态库(.so)的情况可能稍有不同。

因此剩下的问题就是,使用不同C++标准去编译std的数据结构会生成同名COMDAT section吗?

以vector<int>::push_back函数为例:

$ g++ -std=c++98 x.cpp -c
$ readelf -g x.o | grep push_back
COMDAT group section [    4] `.group' [_ZNSt6vectorIiSaIiEE9push_backERKi] contains 2 sections:
   [   64]   .text._ZNSt6vectorIiSaIiEE9push_backERKi
   [   65]   .rela.text._ZNSt6vectorIiSaIiEE9push_backERKi

$ g++ -std=c++11 x.cpp -c
$ readelf -g x.o | grep push_back
COMDAT group section [    5] `.group' [_ZNSt6vectorIiSaIiEE9push_backEOi] contains 2 sections:
   [   72]   .text._ZNSt6vectorIiSaIiEE9push_backEOi
   [   73]   .rela.text._ZNSt6vectorIiSaIiEE9push_backEOi

发现为vector<int>::push_back生成的COMDAT section确实是不同名字的。但是在没有彻底弄清楚这个命名规则(mangling)之前,也仅能说明的是vector<int>::push_back这个函数是没有问题,不代表其它的情况。

 

还有一些问题尚未解决,这里记录一下:

(1)不同C++版本编译对mangling的影响?对生成COMDAT section的影响?

(2)动态库(.so)是否也存在这种问题?对动态库不同的使用方式会有影响?比如进程运行时链接和通过dlopen方式是否会不同?

 

参考资料:

(1)https://forum.osdev.org/viewtopic.php?f=13&t=28618

(2)https://www.airs.com/blog/archives/52

 

========================================================

2019/07/22 更新

经大神同事提醒,GCC保证了如果都使用同一版本的编译器编译是二进制兼容的:is-it-safe-to-link-c17-c14-and-c11-objects

posted @ 2019-07-10 01:22  adinosaur  阅读(227)  评论(0编辑  收藏