ABI‑разрыв

—

от автора

Привет, Хабр! Ни для кого не секрет, что в языке C существует множество красивых способов положить вашу программу со всеми вытекающими последствиями, но большинство из них обходятся банальными средствами гигиены, санитайзерами, статическими анализаторами, линтерами, да и компиляторы в целом не плохо помогают решать большинство проблем до их обнаружения в продакшене. Но существуют проблемы, которые возникают только во время исполнения и понять, в чем причина, не так просто, хотя сама проблема может оказаться очень даже понятной после установления причинно-следственной связи.

Останавливаться на том, что такое ABI, я не буду, дабы не разводить лишнюю полемику, на Хабре достаточно отличных статей на эту тему. Поговорим предметно о том что такое ABI‑разрыв или ABI‑несовметимость. ABI‑разрыв — это ситуация, когда два и более бинарных файла представляют работу с одними данными по-разному. Представим ситуацию в кофейне, вы заказали большой кофе, бариста готовит нужный объем, но стажер подает ему стакан меньшего объема, как результат вы либо получите меньше кофе, либо произойдет потеря ценных миллилитров. Это и есть ABI‑разрыв, один компонент системы представляет себе работу не так как другой и на стыке их взаимодействия получатся поведение, которое не закладывалось изначально.

Из кофейни к коду

Команда из нескольких разработчиков трудится над одним большим продуктом, компоненты системы разнесены на множество репозиториев. Во время выпуска новой версии продукта билд-система собирает все репозитории, проверяет зависимости и сливает всё в одну рабочую систему. Для ускорения разработки, любой модуль можно собрать отдельно без пересборки всего остального.

Оперировать будем только с 4-мя репозиториями:

  1. Абстрактная кофейня, описанная на языке на С.

  2. Профессиональный бариста, подгружаемый в виде плагина к движку.

  3. Плагин стажера.

  4. Библиотека для работы со стаканчиками.

> tree├── build_run.sh├── coffee_shop│   └── coffee_shop.c├── cuplib│   └── cup.h├── plugin_barista│   ├── api.h│   └── plugin_barista.c└── plugin_intern    └── plugin_intern.c

Плагины к основному движку подгружаются с помощью библиотеки libdl.

plugin_barista — предоставляет API, в виде заполненной структуры с указателями на функции для управления кофейным цехом. Другие плагины экспортируют эту структуру и используют для своих нужд.

Моделирование ABI‑разрыва

Библиотека для работы со стаканчиками описывает только одну структуру — стаканчик.

// cuplib/cup.h#pragma oncetypedef struct cup_s {  //...} cup_t;

Объявление API баристы.

// plugin_barista/api.h#pragma once#include <stdbool.h>#include <cuplib/cup.h>// Структура APItypedef struct barista_plugin_api_s {    void        (*make_coffee)(void); // Приготовить кофе    void        (*pour_coffee)(cup_t* cup); // Налить кофе    bool        (*is_coffee_ready)(void); // Возвращает true, если кофе готово} barista_plugin_api_t;extern const barista_plugin_api_t *get_barista_plugin_api(void);

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

// plugin_barista/plugin_barista.c#include <stdio.h>#include <plugin_barista/api.h>// Флаг готовности кофеstatic bool g_is_coffee_ready = false;void make_coffee_fn(void) {     g_is_coffee_ready = false;     printf("Called make_coffee_fn\n"); };static void pour_coffee_fn(cup_t* cup) {     printf("Called pour_coffee_fn, g_is_coffee_ready = %s\n", g_is_coffee_ready ? "true" : "false"); };static bool is_coffee_ready_fn(void) {     // Для простоты воспроизведения на третий раз вызова функции, кофе готово    static unsigned char call_counter = 0;    if (++call_counter == 3) {        call_counter = 0;        g_is_coffee_ready = true;    }    printf("Called is_coffee_ready_fn, g_is_coffee_ready = %s\n", g_is_coffee_ready ? "true" : "false");    return g_is_coffee_ready; };// Заполнение структуры APIstatic barista_plugin_api_t barista_plugin_api = {    .make_coffee = make_coffee_fn,    .pour_coffee = pour_coffee_fn,    .is_coffee_ready = is_coffee_ready_fn,};// Реализация функции экспорта APIconst barista_plugin_api_t *get_barista_plugin_api(void) {    return &barista_plugin_api;}

С plugin_intern всё просто. Экспоритруем API, далее проверяем, если кофе готово, наливаем.

// plugin_intern/plugin_intern.c#include <stdio.h>#include <plugin_barista/api.h>bool intern_work(cup_t* cup) {    const barista_plugin_api_t *api = get_barista_plugin_api();    if (api->is_coffee_ready()) {        api->pour_coffee(cup);        return true;    }    printf("Coffee not ready\n");    return false;}

Реализация движка.

// coffee_shop/coffee_shop.c#include <stdio.h>#include <unistd.h>#include <dlfcn.h>#include <plugin_barista/api.h>#include <cuplib/cup.h>int main(int agrc, char **argv) {    // Подгружаем plugin_barista, RTLD_GLOBAL нужен для использования     // get_barista_plugin_api в других подгружаемых плагинах    void *plugin_barista = dlopen("./plugin_barista.so", RTLD_NOW | RTLD_GLOBAL);    if (!plugin_barista) { fprintf(stderr, "dlopen plugin_barista: %s\n", dlerror()); return 1; }    // Подгружаем plugin_intern    void *plugin_intern = dlopen("./plugin_intern.so", RTLD_NOW);    if (!plugin_intern) { fprintf(stderr, "dlopen plugin_intern: %s\n", dlerror()); return 1; }    // Получаем все неободимые для работы функции    bool (*intern_work)(cup_t* cup) = dlsym(plugin_intern, "intern_work");    void (*make_coffee)(void) = dlsym(plugin_barista, "make_coffee_fn");    if (!intern_work) { fprintf(stderr, "dlopen: %s\n",  dlerror()); return 1; }    // Моделируем работу кофейни    // Заказ кофе    // Бариста начинает готовку    make_coffee();    // Проходит время, стажер 5 раз опрашивает о готовности и     // пытается налить кофе, если кофе налито цикл прекращается    cup_t cup;    for (size_t i = 0; i < 5; ++i) {         if (intern_work(&cup)) {            break;        }    }    // Отдаем кофе    return 0;}

В примере используется тулчейн GCC, но вы можете использовать любой удобный вам. Вся сборка и запуск объеденины в один bash скрипт. Флаги -g и -O0 добавлены для удобства отладки в дебагере.

# !/bin/bashif [ -e "build" ]; then    rm -rf buildfi# Сборочная директорияmkdir build# Собираем плагин баристыgcc -c -fPIC plugin_barista/plugin_barista.c -g -O0 -I . --warn-all -o build/plugin_barista.o && gcc -shared -o build/plugin_barista.so build/plugin_barista.orm build/plugin_barista.o# Собираем плагин стажераgcc -c -fPIC plugin_intern/plugin_intern.c -g -O0 -I . --warn-all -o build/plugin_intern.o && gcc -shared -o build/plugin_intern.so build/plugin_intern.orm build/plugin_intern.o# Собираем движекgcc coffee_shop/coffee_shop.c -g -O0 --warn-all -ldl -I . -o build/coffee_shop# Запускаемcd build/ && ./coffee_shop

Для запуска скрипта не забудьте сделать:

chmod +x build_run.sh

Ожидаемый результат в STDOUT во время выполнения и фактический совпадают:

>Called make_coffee_fn>Called is_coffee_ready_fn, g_is_coffee_ready = false>Coffee not ready>Called is_coffee_ready_fn, g_is_coffee_ready = false>Coffee not ready>Called is_coffee_ready_fn, g_is_coffee_ready = true>Called pour_coffee_fn, g_is_coffee_ready = true

Но, в кофейню нагрянули изменения, повилось несколько видов стаканов разного объема, API баристы меняется, но стажер об этом не уведомлен…

is_coffee_ready_fn теперь должно возвращать не bool, а размер стаканчика при готовности и 0, если кофе не готово.

Ниже измененное объявление API.

// plugin_barista/api.h#pragma once#include <stdbool.h>#include <cuplib/cup.h>// Структура APItypedef struct barista_plugin_api_s {    void        (*make_coffee)(void); // Приготовить кофе    void        (*pour_coffee)(cup_t* cup); // Налить кофе    unsigned short        (*is_coffee_ready)(void); // Возвращает объем если кофе готово, иначе 0} barista_plugin_api_t;extern const barista_plugin_api_t *get_barista_plugin_api(void);

Реализация нового API.

// plugin_barista/plugin_barista.c#include <stdio.h>#include <plugin_barista/api.h>// Флаг готовности кофеstatic bool g_is_coffee_ready = false;void make_coffee_fn(void) {     g_is_coffee_ready = false;     printf("Called make_coffee_fn\n"); };static void pour_coffee_fn(cup_t* cup) {     printf("Called pour_coffee_fn, g_is_coffee_ready = %s\n", g_is_coffee_ready ? "true" : "false"); };static unsigned short is_coffee_ready_fn(void) {     // Для простоты воспроизведения на третий раз вызова функции, кофе готово    static unsigned char call_counter = 0;    if (++call_counter == 3) {        call_counter = 0;        g_is_coffee_ready = true;    }    printf("Called is_coffee_ready_fn, g_is_coffee_ready = %s\n", g_is_coffee_ready ? "true" : "false");    return g_is_coffee_ready ? 330 /* Или любой другой объем */ : 0; };// Заполнение структуры APIstatic barista_plugin_api_t barista_plugin_api = {    .make_coffee = make_coffee_fn,    .pour_coffee = pour_coffee_fn,    .is_coffee_ready = is_coffee_ready_fn,};// Реализация функции экспорта APIconst barista_plugin_api_t *get_barista_plugin_api(void) {    return &barista_plugin_api;}

Собираем плагин баристы и запускаем.

gcc -c -fPIC plugin_barista/plugin_barista.c -g -O0 -I . --warn-all -o build/plugin_barista.o && gcc -shared -o build/plugin_barista.so build/plugin_barista.o && rm build/plugin_barista.o && cd build/ && ./coffee_shop

Вывод в STDOUT абсолютно корректный, только теперь стажер продолжает трактовать возвращаемое значение is_coffee_ready_fn как флаг готово/не готово, как следствие мы не дополучаем или теряем нужный объем напитка.
Описанный выше пример является достаточно мягкой формой ABI‑разрыва. В реальных же системах ошибки могут возникать гораздо серьезнее. Например, сильно подпортить ситуацию может порядок расположения полей в структуре.

// Былоstruct API { A_fn; B_fn; C_fn; };

Добавили новую функцию в начало.

// Сталоstruct API { D_fn; A_fn; B_fn; C_fn; };

Без пересборки всей системы, вызывающий API код, будет обращаться к функциям по старым смещениям в структуре, лучший исход — это падение программы. В худшем случае программа будет работать, выдавая непредсказуемое поведение в разных местах.

Ещё одним следствием ABI‑разрыва может стать повреждение кучи, когда из-за расхождения представления о размере стркутур, перезаписываются или затираются участки памяти со служебной информацией необходимой для корректной работы аллокатора:

/* engine_old.c — собран со старым api.h */#include <stdlib.h>#include <stdio.h>#include <api.h>extern void fill_api(struct plugin_api *out);     /* реализовано в новом API */int main(void) {    struct plugin_api *p = calloc(1, sizeof(*p)); /* старый размер допустим 24*/    fill_api(p);                                  /* новый размер 32 байта */    /* В этом месте куча уже повреждена */    char *s = malloc(32);    // Далее поведение не определено, всё зависит от реализации аллокатора или от погоды за окном    free(s); /* может упасть тут */    printf("Hello!\n"); /* или тут */    return 0;}

Примеры выше связанны только с разделяемым API, но аналогичная проблема может возникать и для структур, в которых хранятся данные.

Как избегать подобных ситуаций

Решением может быть добавление поле version в начало подобных разделяемых структур. Все функции, которые обращаются к данным структурам, проверяют поле version на соответствие со своей локальной копией полученой из заголовочного файла. В случае расхождения версий, программа аварийно завершается, так как поведение при обращении к данным структурам становится неопределенным.

ссылка на оригинал статьи https://habr.com/ru/articles/1090188/