Привет, Хабр! Ни для кого не секрет, что в языке C существует множество красивых способов положить вашу программу со всеми вытекающими последствиями, но большинство из них обходятся банальными средствами гигиены, санитайзерами, статическими анализаторами, линтерами, да и компиляторы в целом не плохо помогают решать большинство проблем до их обнаружения в продакшене. Но существуют проблемы, которые возникают только во время исполнения и понять, в чем причина, не так просто, хотя сама проблема может оказаться очень даже понятной после установления причинно-следственной связи.
Останавливаться на том, что такое ABI, я не буду, дабы не разводить лишнюю полемику, на Хабре достаточно отличных статей на эту тему. Поговорим предметно о том что такое ABI‑разрыв или ABI‑несовметимость. ABI‑разрыв - это ситуация, когда два и более бинарных файла представляют работу с одними данными по-разному. Представим ситуацию в кофейне, вы заказали большой кофе, бариста готовит нужный объем, но стажер подает ему стакан меньшего объема, как результат вы либо получите меньше кофе, либо произойдет потеря ценных миллилитров. Это и есть ABI‑разрыв, один компонент системы представляет себе работу не так как другой и на стыке их взаимодействия получатся поведение, которое не закладывалось изначально.
Из кофейни к коду
Команда из нескольких разработчиков трудится над одним большим продуктом, компоненты системы разнесены на множество репозиториев. Во время выпуска новой версии продукта билд-система собирает все репозитории, проверяет зависимости и сливает всё в одну рабочую систему. Для ускорения разработки, любой модуль можно собрать отдельно без пересборки всего остального.
Оперировать будем только с 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 once typedef struct cup_s { //... } cup_t;
Объявление API баристы.
// plugin_barista/api.h #pragma once #include <stdbool.h> #include <cuplib/cup.h> // Структура API typedef 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; }; // Заполнение структуры API static barista_plugin_api_t barista_plugin_api = { .make_coffee = make_coffee_fn, .pour_coffee = pour_coffee_fn, .is_coffee_ready = is_coffee_ready_fn, }; // Реализация функции экспорта API const 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/bash if [ -e "build" ]; then rm -rf build fi # Сборочная директория 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.o rm 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.o rm 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> // Структура API typedef 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; }; // Заполнение структуры API static barista_plugin_api_t barista_plugin_api = { .make_coffee = make_coffee_fn, .pour_coffee = pour_coffee_fn, .is_coffee_ready = is_coffee_ready_fn, }; // Реализация функции экспорта API const 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 на соответствие со своей локальной копией полученой из заголовочного файла. В случае расхождения версий, программа аварийно завершается, так как поведение при обращении к данным структурам становится неопределенным.

