Аккуратнее с vtable, или как выстрелить себе в ногу обновлением библиотеки

в 21:16, , рубрики: c++, c++ библиотеки, vtable, ооп

Представьте, что вы разрабатываете приложение, использующее какую-то shared-библиотеку. Библиотека тщательно следует принципам обратной совместимости, не меняя старый интерфейс и лишь добавляя новый. Оказывается, даже с учетом этого обновление библиотеки без непосредственной перелинковки приложения может привести к неожиданным эффектам.

Аккуратнее с vtable, или как выстрелить себе в ногу обновлением библиотеки - 1

Дисклеймер. Весь код собирался при помощи clang 10.0.0 на Arch Linux, но, полагаю, то же самое может быть достигнуто при использовании gcc, MSVC и большинства других компиляторов любых версий на всех популярных ОС.

Описанная мной выше ситуация не взята с потолка. Я переписывал библиотеку, сохраняя внешний интерфейс, и лишь добавлял какие-то новые методы в классы. Библиотека тестировалась при помощи отдельного исполняемого файла, слинкованного с ее некоторой первоначальной версией (интерфейс с тех пор, напомню, лишь дополнялся). После добавления новых методов при тестировании все пошло наперекосяк, и при анализе логов стало понятно, что творится что-то неладное: приложение-клиент, не менявшееся долгое время и знавшее лишь о старом интерфейсе, вызывало новые, только что добавленные методы. Беглым поиском в интернете я не нашел описания обнаруженных мной эффектов, поэтому решил рассказать о них сам. Давайте рассмотрим ситуацию на простом синтетическом примере.

Входные данные: shared-библиотека, в заголовочном файле которой описан некий абстрактный класс, и приложение-клиент, которое линкуется с этой библиотекой, подключает этот header и пользуется благами полиморфизма. Скажем, код может быть примерно таким:

Shared-библиотека

  • CMakeLists.txt

cmake_minimum_required(VERSION 3.5)

project(shared_lib LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(SOURCES lib.cpp)
set(HEADERS lib.h)

add_library(${PROJECT_NAME} SHARED ${SOURCES} ${HEADERS})

  • lib.h

#ifndef LIB_H
#define LIB_H

namespace my
{

class Interface
{
public:
    virtual ~Interface() = default;

    virtual void a() = 0;
    virtual void c() = 0;
};

class Implementation : public Interface
{
public:
    void a() override;
    void c() override;
};

} // namespace my

#endif // LIB_H

  • lib.cpp

#include "lib.h"

#include <iostream>

namespace my
{

void Implementation::a()
{
    std::cout << "Implementation::a()" << std::endl;
}

void Implementation::c()
{
    std::cout << "Implementation::c()" << std::endl;
}

} // namespace my

Приложение-клиент

  • CMakeLists.txt

cmake_minimum_required(VERSION 3.5)

project(client LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(libshared_binary_dir "/path/to/libshared_lib.so")
set(libshared_source_dir "/path/to/shared_lib/source")

add_executable(${PROJECT_NAME} main.cpp)

add_library(shared_lib SHARED IMPORTED)
set_property(TARGET shared_lib PROPERTY IMPORTED_LOCATION ${libshared_binary_dir}/libshared_lib.so)

target_include_directories(${PROJECT_NAME} PRIVATE ${libshared_source_dir})
target_link_libraries(${PROJECT_NAME} PRIVATE shared_lib)

  • main.cpp

#include <lib.h>

#include <memory>

int main()
{
    std::unique_ptr<my::Interface> ptr = std::make_unique<my::Implementation>();
    ptr->a(); 
    ptr->c(); 
}

Если собрать библиотеку и приложение-клиент, после чего запустить клиент, выхлоп будет вполне ожидаемый:

Implementation::a()
Implementation::c()

Но время идет, и библиотека обновляется. Теперь ее исходники выглядят так:

  • lib.h

#ifndef LIB_H
#define LIB_H

namespace my
{

class Interface
{
public:
    virtual ~Interface() = default;

    virtual void a() = 0;
    virtual void b() = 0; // +
    virtual void c() = 0;
};

class Implementation : public Interface
{
public:
    void a() override;
    void b() override;    // +
    void c() override;
};

} // namespace my

#endif // LIB_H

  • lib.cpp

#include "lib.h"

#include <iostream>

namespace my
{

void Implementation::a()
{
    std::cout << "Implementation::a()" << std::endl;
}

void Implementation::b()                             // +
{                                                    // +
    std::cout << "Implementation::b()" << std::endl; // +
}                                                    // +

void Implementation::c()
{
    std::cout << "Implementation::c()" << std::endl;
}

} // namespace my

Мы просто добавили новую функциональность, не трогая старую. Может показаться логичным, что старое приложение-клиент, которое линковалось еще со старой библиотекой и знает только о старом интерфейсе, не включающем в себя функцию b(), должно корректно работать, если старый so-файл библиотеки подменить новым. Действительно, все, что было, осталось на месте, а новое мы просто не используем, значит, и выхлоп должен совпадать с предыдущим. Но на самом деле при запуске мы увидим иное:

Implementation::a()
Implementation::b()

Что-то пошло не так: хотели запустить c(), но вызвали b()! Причем используемые мной инструментальные средства по типу санитайзеров в этом случае бессильны, никакой ошибки в максимально дебажном режиме и с кучей проверок я не получил. И если в случае с синтетическим примером в этом ничего страшного нет, то в случае с разработкой реальной библиотеки такой эффект был действительно неожиданным.

Итак, что же произошло? Клиент знает о том, что в Interface есть две виртуальные функции: a() и c(). В таблице виртуальных функций компилятор сохраняет их адреса в порядке описания в header-е. Конечно, это зависит от деталей реализации, но, скорее всего, это регламентируется ABI, и на одной платформе реализация будет совпадать у разных компиляторов (это лишь мое предположение, эксперты по этой теме приглашаются в комментарии). Для компилятора вызвать функцию c() означает вызвать функцию по адресу, находящемуся во второй строке vtable (в первой находится адрес a()). Но ведь мы изменили библиотеку! Теперь во второй строке находится адрес функции b(), которая и оказывается вызванной, а адрес c() переезжает на третью строку, о существовании которой клиент вообще не знает.

Причем сигнатуры функций b() и c() не обязаны совпадать. В b() в качестве значений аргументов мы, скорее всего, увидим данные с регистров или со стэка (в зависимости от соглашения о вызовах). Возможно, из этого даже можно извлечь какую-то пользу. Мои эксперименты показали, что на код функции b() не накладывается никаких ограничений, скажем, при желании можно читать и изменять стэк вызывающей стороны, а там могут находиться важные данные. Тем не менее, представить, что это можно как-то использовать в реальности, трудно: если у вас есть возможность подменить библиотеку, чтобы повлиять на поведение клиента, проще переписать те функции, что есть в интерфейсе, а не городить новые. К тому же, этот финт ушами работает только при вызовах через vtable.

В качестве дополнения хочу сказать, что все то же самое работает и при динамической линковке через dlopen. Код клиента может быть примерно следующим:

  • CMakeLists.txt

cmake_minimum_required(VERSION 3.5)

project(dynamic_client LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(libshared_source_dir "SOURCE_DIR")

add_executable(${PROJECT_NAME} main.cpp)

target_include_directories(${PROJECT_NAME} PRIVATE ${libshared_source_dir})

  • main.cpp

#include <lib.h>

#include <dlfcn.h>

#include <cassert>

int main()
{
    void* handle = ::dlopen("/path/to/libshared_lib.so", RTLD_NOW);
    assert(handle != nullptr);
    using make_instance_t = my::Interface* ();
    make_instance_t* function = reinterpret_cast<make_instance_t*>(::dlsym(handle, "make_instance"));
    assert(function != nullptr);

    my::Interface* ptr = function();
    ptr->a(); // Implementation::a() with both old and new shared library
    ptr->c(); // Implementation::c() with old, Implementation::b() with new shared library

    delete ptr;
    ::dlclose(handle);
}

В код самой библиотеки нужно добавить функцию make_instance():

  • lib.h

#ifndef LIB_H
#define LIB_H

// ...

extern "C"
{
    my::Interface* make_instance();
}

#endif // LIB_H

  • lib.cpp

#include "lib.h"

// ...

my::Interface* make_instance()
{
    return new my::Implementation();
}

// ...

Из этого всего можно сделать вывод, что, если мы не контролируем сборку и линковку клиента, дополнять интерфейс абстрактного класса, который торчит из библиотеки, можно лишь добавлением новых функций после уже имеющихся: тогда старые функции останутся в vtable на своих местах, и собранный ранее клиент не сломается. Можно, конечно, вообще попытаться отказаться от абстрактных классов в публичном интерфейсе библиотек, но таких рекомендаций я не встречал. Словом, будьте аккуратнее с полиморфизмом!

P.S. Примечание по мотивам комментариев ilammy, aamonster и demp:

  1. Добавление новых методов в конец решает проблему не на всех ABI, так что и на это лучше не полагаться или использовать это с осторожностью.
  2. Проблему решает версионирование интерфейсов через наследование. demp скинул хорошую статью на эту тему https://accu.org/index.php/journals/1718.

Автор: Даниил Ковалев

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js