GNU LD符号版本控制与C++二进制向后兼容性

3

我正在了解如何使用GCC的ld版本脚本在ELF共享库中版本化符号,我知道可以使用以下指令导出相同符号的不同版本:

__asm__(".symver original_foo,foo@VERS_1.1");

如果函数的语义发生变化,但仍希望库导出旧版本,以便使用该库的旧应用程序仍能与新版本一起工作,则这很有用。

但对于C++库,符号MyClass的vtable将被导出。如果稍后通过添加更多虚拟函数来更改类,如何导出包含原始vtable符号和新版本vtable的原始类?

编辑:我创建了一个测试用例,似乎通过将一个类的所有符号重命名为另一个类的符号可以实现我的目标。这似乎像我所希望的那样工作,但是这种方法是否保证可行或者只是我走了运?以下是代码:

编辑2:我更改了类的名称(希望)更少地引起混淆,并将定义分成了2个文件。

编辑3:它似乎在clang++上也能正常工作。我将澄清我要问的总体问题:

是否确保此技术在Linux上对C++共享库中的类进行二进制向后兼容性,而不考虑虚拟函数的差异?如果不能,为什么?(提供反例会很好)。

libtest.h:

struct Test {
    virtual void f1();
    virtual void doNewThing();
    virtual void f2();
    virtual void doThing();
    virtual void f3();
    virtual ~Test();
};

libtest_old.h:

// This header would have been libtest.h when test0 was theoretically developed.

struct Test {
    virtual void f3();
    virtual void f1();
    virtual void doThing();
    virtual void f2();
    virtual ~Test();
};

libtest.cpp:

#include "libtest.h"
#include <cstdio>

struct OldTest {
    virtual void f3();
    virtual void f1();
    virtual void doThing();
    virtual void f2();
    virtual ~OldTest();
};

__asm__(".symver _ZN7OldTestD1Ev,_ZN4TestD1Ev@LIB_0");
__asm__(".symver _ZN7OldTestD0Ev,_ZN4TestD0Ev@LIB_0");
__asm__(".symver _ZN7OldTest7doThingEv,_ZN4Test7doThingEv@LIB_0");
__asm__(".symver _ZN7OldTestD2Ev,_ZN4TestD2Ev@LIB_0");
__asm__(".symver _ZTI7OldTest,_ZTI4Test@LIB_0");
__asm__(".symver _ZTV7OldTest,_ZTV4Test@LIB_0");
__asm__(".symver _ZN7OldTest2f1Ev,_ZN4Test2f1Ev@LIB_0");
__asm__(".symver _ZN7OldTest2f2Ev,_ZN4Test2f2Ev@LIB_0");
__asm__(".symver _ZN7OldTest2f3Ev,_ZN4Test2f3Ev@LIB_0");

void OldTest::doThing(){
    puts("OldTest doThing");
}
void OldTest::f1(){
    puts("OldTest f1");
}
void OldTest::f2(){
    puts("OldTest f2");
}
void OldTest::f3(){
    puts("OldTest f3");
}
OldTest::~OldTest(){

}

void Test::doThing(){
    puts("New Test doThing from Lib1");
}
void Test::f1(){
    puts("New f1");
}
void Test::f2(){
    puts("New f2");
}
void Test::f3(){
    puts("New f3");
}
void Test::doNewThing(){
    puts("Test doNewThing, this wasn't in LIB0!");
}
Test::~Test(){

}

libtest.map:

LIB_0 {
global:
    extern "C++" {
        Test::doThing*;
        Test::f*;
        Test::Test*;
        Test::?Test*;
        typeinfo?for?Test*;
        vtable?for?Test*
    };
local:
    extern "C++" {
        *OldTest*;
        OldTest::*;
    };
};

LIB_1 {
global:
    extern "C++" {
        Test::doThing*;
        Test::doNewThing*;
        Test::f*;
        Test::Test*;
        Test::?Test*;
        typeinfo?for?Test*;
        vtable?for?Test*
    };
} LIB_0;

Makefile:

all: libtest.so.0 test0 test1

libtest.so.0: libtest.cpp libtest.h libtest.map
    g++ -fPIC -Wl,-s -Wl,--version-script=libtest.map libtest.cpp -shared -Wl,-soname,libtest.so.0 -o libtest.so.0

test0: test0.cpp libtest.so.0
    g++ test0.cpp -o test0 ./libtest.so.0

test1: test1.cpp libtest.so.0
    g++ test1.cpp -o test1 ./libtest.so.0

test0.cpp:

#include "libtest_old.h"
#include <cstdio>

// in a real-world scenario, these symvers would not be present and this file
// would include libtest.h which would be what libtest_old.h is now.

__asm__(".symver _ZN4TestD1Ev,_ZN4TestD1Ev@LIB_0");
__asm__(".symver _ZN4TestD0Ev,_ZN4TestD0Ev@LIB_0");
__asm__(".symver _ZN4Test7doThingEv,_ZN4Test7doThingEv@LIB_0");
__asm__(".symver _ZN4Test2f1Ev,_ZN4Test2f1Ev@LIB_0");
__asm__(".symver _ZN4Test2f2Ev,_ZN4Test2f2Ev@LIB_0");
__asm__(".symver _ZN4Test2f3Ev,_ZN4Test2f3Ev@LIB_0");
__asm__(".symver _ZN4TestD2Ev,_ZN4TestD2Ev@LIB_0");
__asm__(".symver _ZTI4Test,_ZTI4Test@LIB_0");
__asm__(".symver _ZTV4Test,_ZTV4Test@LIB_0");

struct MyClass : public Test {
    virtual void test(){
        puts("Old Test func");
    }
    virtual void doThing(){
        Test::doThing();
        puts("Override of Old Test::doThing");
    }
};

int main(void){
    MyClass* mc = new MyClass();

    mc->f1();
    mc->f2();
    mc->f3();
    mc->doThing();
    mc->test();

    delete mc;

    return 0;
}

test1.cpp:

#include "libtest.h"
#include <cstdio>

struct MyClass : public Test {
    virtual void doThing(){
        Test::doThing();
        puts("Override of New Test::doThing");
    }
    virtual void test(){
        puts("New Test func");
    }
};

int main(void){
    MyClass* mc = new MyClass();

    mc->f1();
    mc->f2();
    mc->f3();
    mc->doThing();
    mc->doNewThing();
    mc->test();

    delete mc;

    return 0;
}
1个回答

4

vtable符号和/或版本对于API和ABI来说都不太重要。重要的是哪个vtable索引具有哪些语义。vtable的名称和/或版本并不重要。

您可以通过具有轻量级运行时机制来检索特定接口的特定版本来实现向后兼容性。假设您有:

class MyThing: public VersionedInterface {...}; // V1
class MyThingV1: public MyThing {...};
class MyThingV2: public MyThingV1 {...};

您可能有一些创建“我的物品”(MyThings)的功能:

VersionedInterface *createMyThing();

然后,您需要请求您想要的接口版本(您的代码理解的)VersionedInterface

// Old code will ask for MyThing:
VersionedInterface *vi = createMyThing();    
MyThing *myThing = static_cast<MyThing*>(vi->getInterface("MyThing"));

// New code may ask for MyThingV2:
VersionedInterface *vi = createMyThing();    
MyThingV2 *myThing = static_cast<MyThingV2*>(vi->getInterface("MyThingV2"));
// New code may or may not get the newer interface:
if (!myThing) 
{
    // We did not get the interface version we wanted.
    // We can either consciously fall back to an older version or simply fail.
    ...
}

VersionedInterface 仅提供 getInterface()函数:
class VersionedInterface
{
public:
    virtual ~VersionedInterface() {}
    virtual VersionedInterface *getInterface(const char *interfaceName) = 0;    
};

这种方法的优点在于它以一种干净且可移植的方式允许对vtable进行任意更改(重新排序函数、插入和删除函数、更改函数原型)。
你可以扩展getInterface()函数,使其也接受数字版本,并且实际上你还可以使用它来检索对象的其他接口。
你可以在不破坏现有二进制代码的情况下后期向对象添加接口。这是主要的优势。当然,获取接口的样板代码需要付出一定代价。维护相同接口的多个版本当然也有其自身的代价。是否值得这样做应该经过深思熟虑。

这看起来很有用,谢谢。但是我认为我已经通过创建一个新类并将与其关联的所有符号重命名为以前版本的另一个类来使符号版本控制按照我想要的方式工作。我不确定为什么这样做不起作用。 - Xeno
哇,这个.symver结构很有趣!感谢分享。但我猜你很幸运这个能够工作:我认为发生了这样的事情:C++编译器在构建MyClass的虚拟表时完全不关心symver语句。它只看头文件,而test0.cpp和test1.cpp的头文件是相同的,决定vtable布局的类始终是Orig。在两种情况下,test()函数都会得到相同的vtable索引,因此可以确定这个方法可行。在test0.cpp中,MyClass构造函数将调用...的基类构造函数。 - Johannes Overmann
由于符号映射到LIB_0,类Old的vtable指向二进制vtable。但在您的代码中,mc将使用C++编译器在头文件中看到的Orig的vtable。当您在两个类定义中以不同的顺序插入函数f1()、f2()和f3()时,您会发现无法将这些函数映射为Orig::f1()调用Old::f1(),除非它们恰好位于相同的vtable位置。 - Johannes Overmann
我刚刚根据你的建议更新了原帖,添加了3个不同顺序的新函数。经过这些更改,两个测试仍然能够正确运行,但这让我更加不确定是不是只是碰巧成功了。 - Xeno
是的,请忘记我的虚函数表不匹配的理论。你上面的例子总是有效且正确的。这就是你所做的:你有一个包含类A和虚函数表VA的库。后来,你想增强类A,因此你将带有虚函数表VB的类B添加到同一个库中。所有为类A编译的旧程序仍将工作。所有使用类B的新程序也将工作。这种增强C++库的方式对于所有编译器和所有操作系统都是可以接受的。类A和类B之间没有关系,除了相似的函数名称。你可以使用命名空间v0、v1等代替链接器映射。 - Johannes Overmann

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接