ABI‑разрыв

Привет, Хабр! Ни для кого не секрет, что в языке 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 на соответствие со своей локальной копией полученой из заголовочного файла. В случае расхождения версий, программа аварийно завершается, так как поведение при обращении к данным структурам становится неопределенным.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.