Pull to refresh
VK
Building the Internet

Как делать бинарно-совместимые API на компилируемых языках

Reading time10 min
Views2.9K


При разработке продукта на компилируемом языке (таком как C или Rust) рано или поздно может наступить момент, когда нужно разделить продукт на несколько компонентов, развивающихся независимо, или дать возможность расширять функциональность плагинами, разрабатываемыми отдельными коллективами или сообществом.

Здесь появляется проблема обеспечения прямой и обратной совместимости: что произойдет при обновлении одного из компонентов независимо от другого? Если бы компоненты были микросервисами, в качестве интерфейса выступал бы JSON поверх HTTP или другой высокоуровневый протокол RPC. Но в приоритете, как правило, возможность сочетать независимость развития компонентов с нативным вызовом функций и нативным представлением структур.

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

Немного теории, или в чем заключается проблематика


При создании бинарно-совместимых API на С главным источником правды является стандарт С — именно в нем описано, как представляются структуры в памяти, скалярные значения в памяти и что происходит «под капотом».

Итак, стандарт гарантирует, что: 

  • есть четкий порядок полей (кроме битовых полей);
  • адрес структуры равен адресу первого элемента;
  • паддинга в начале структуры нет.

Одновременно с этим в нем не оговаривается ничего по поводу:

  • паддинга между элементами;
  • паддинга в конце структуры;
  • выравнивания полей и самой структуры.

Таким образом, стандарт C ничего не говорит компиляторам о том, как должна выглядеть структура в бинарном представлении. Если опираться только на стандарт, то:

  • gcc и clang могли бы генерировать совершенно разные структуры (например, библиотеку — clang, а приложение — gcc);
  • разные дистрибутивы Linux могли бы включать разные опции компиляции, влияющие на бинарное представление;
  • версия компилятора на машине разработчика приложения или сборочной машине может сильно отличаться от версии компилятора на хосте, где собирается библиотека.



На практике совместимость обеспечивается за счет того, что компиляторы соответствуют стандартам ABI, таким как System V ABI. Эти стандарты специфичны для OS (платформы) и архитектуры. 

Примечательно, что у ARM64 свой стандарт с вариантами выбора для конкретных платформ.
Также есть стандарт System V ABI без указаний архитектуры. Он закрепляет общие параметры — например, формат исполняемого файла. Бинарное представление структур и конвенции вызовов функций находятся в стандартах System V ABI x86, AMD64 и других.

Рассмотрим, что определяет стандарт, на примере самой популярной архитектуры AMD64. 

  1. Размеры скалярных типов данных и выравнивание. Примечательно, что выравнивание в AMD64 натуральное — по кратности, которая соответствует размеру типа. В x86 это не всегда соблюдалось, что создавало трудности.

    P. S. Красным в таблице выделены значения, которые будут использованы далее в статье: int — 4 байта, pointer — 8 байтов.


  2. Размер составных типов данных. Базово размер структуры определяется суммой размеров элементов (полей) и паддинга, который добавляется, чтобы обеспечить выравнивание. В нашем случае это выравнивание по границе 8 байтов.



    Причем паддинг может появиться и в конце, потому что для аллокации  массивов второй элемент тоже должен быть выровнен. Например, поскольку выравнивание структуры равно выравниванию его максимального поля (в данном случае 8 байтов), в следующем примере нужно добавить 4 байта в конце, чтобы второй элемент был выровнен по границе 4 байта.




Подходы к ABI-совместимости


Рассмотрим несколько подходов к построению API. Сначала посмотрим на подход, который не обеспечивает бинарную совместимость. Потом — два подхода, обеспечивающих её. 

Наивный нерабочий подход


Например, есть библиотека .so с хедером и приложение со структурой из двух интов, которое использует хедер библиотеки. 



В случае обновления библиотеки и добавления новой функциональности в структуру добавляется новое поле. 

Рассмотрим вариант с добавлением поля в начало. Приложение продолжает использовать старую версию библиотеки.



Что может пойти не так? 

Внутри библиотеки все хорошо. А вот приложение без пересборки начнет работать некорректно. Причина в том, что появляется несогласованность в положении полей — то есть в нашем примере при обращении к полю «а» из приложения мы попадаем в поле «x» в библиотеке, что создает проблемы.

Более того, проблема может возникнуть и в случае добавления нового поля в конец. Так, в приложении, которое использует старую версию хедера, при копировании mylib.so будет считать, что структура длиннее на 4 байта той области памяти, которую выделило под нее приложение. В таком случае запись (или чтение) из библиотеки в/из поля «b» «потрогает» чужую память.

Для malloc() конкретно в нашем случае, когда выделяется 8 или 12 байтов, это не критично: он выделяет память с гранулярностью в 16 байтов.

Но при автоматическом storage это может привести к переписыванию соседней переменной.



Если наоборот, что-то может не скопироваться.



Оба случая нежелательны.

Примечательно, что даже при замене двух интов по 4 байта на один восьмибайтный не исключены ошибки выравнивания. Например, в v1 есть восьмибайтный инт, поэтому структура выравнивается по 8 байтам. В v2 размер полей ограничен 4 байтами, то есть паддинг не добавляется и бинарный layout начинает «ехать», например при аллокации массива.



Простой подход


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

Простой подход к обеспечению ABI-совместимости ориентирован на решение этой проблемы и заключается в том, чтобы не показывать приложению Data-Layout-структуры.



То есть внутри библиотеки есть полная декларация структуры, а снаружи — нет. 

Для этого достаточно определить конструктор, деструктор, функцию копирования, аксессоры на чтение и на запись. 



Аллокация структуры, копирование, доступ к данным на чтение и запись в таком случае идет через функции в mylib, и весь контроль остается в одних руках.

Подход снимает множество головной боли, поэтому множество библиотек построены именно таким образом (например, pthreads).

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

  • Во-первых, если нужно уметь собираться и против старого, и против нового хедера, то придется включить декларации функции (справа) в код приложения. При этом обычно оставляют некоторый baseline (минимальная версия библиотеки), на который можно рассчитывать безусловно (поля «a» и «b»).



  • Во-вторых, чтобы использовать новые декларации, надо уметь в рантайме понимать, с какой версией работает приложение — со старой или новой (есть в ней нужное поле или нет). Для этого загружать функции приходится в рантайме с помощью dlsym — так можно понять, есть функция или нет, и в зависимости от этого выполнять одну их двух веток кода.



    То есть есть baseline, с которым работаем как обычно («a» и «b»), а есть функции, наличие которых проверяем в динамике.

Простой подход самый распространенный. Но и он не лишен недостатков. 

Чтобы понять суть недостатков, достаточно рассмотреть пример статической инициализации внутри библиотеки и инициализации вне ее. Так, запись внутри библиотеки читается легко и быстро. Запись внутри приложения более сложная, многокомпонентная.



Аналогично и второй пример: простой читаемый инкремент в библиотеке в приложении превращается в более сложный код.



Таким образом, проблемы две. Первая — простой подход не позволяет аллоцировать на стеке. Чтобы нивелировать ограничение, надо выдать размер структуры наружу, чтобы ее можно было аллоцировать с помощью malloc(), или завести структуру storage, которая заведомо больше, чем нужная структура.

Вторая и основная проблема — отсутствие выразительности языка С, связанное с необходимостью работы через аксессоры. 

Сложный подход


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

  • заполнять и читать стандартными средствами;
  • использовать статические инициализаторы, в том числе в массивах;
  • аллоцировать на стеке.



Здесь важно определить, что размер структуры будет установленным и неизменяемым, для этого в конец записи добавляется паддинг, за который «никогда нельзя заходить».

Также нужно определять: 

  • функцию, которая из внешнего определения создает внутренний объект;
  • функцию, которая сериализует внутреннее представление во внешнее;
  • деструктор.



Теперь подробнее поговорим обо всех особенностях сложного подхода.

Размер структуры


Важно, чтобы в пределах одной архитектуры и OS размер структуры был неизменным. Надо завести паддинг и «обложиться» ассертами времени сборки. Причем размер паддинга, скорее всего, надо будет считать вручную под каждую из поддерживаемых архитектур. Более того, стоит рекомендовать плагинам добавить такие же ассерты в свой код.



К решению этой задачи есть и альтернативный подход. 

Так, чтобы не считать паддинг руками под каждую из поддерживаемых платформ (и пересчитывать при каждом добавлении поля), в union можно добавить паддинг размером 64 байта и анонимную структуру. Поскольку размер union всегда равен размеру максимального элемента, в таком случае можно быть уверенным, что размер union не будет меньше размера паддинга. Чтобы убедиться, что union не больше размера паддинга, можно использовать статический ассерт.



По бинарному Layout здесь все хорошо: бинарное представление идентично, обращаться к полям можно так, как если бы это была обычная структура. Но с точки зрения языка C это не совсем эквивалентные вещи, поэтому лучше не использовать такой подход.

Внутренний паддинг


Допустим, мы определили структуру и теперь в ней в каком-то порядке располагаем поля. При этом размер структуры строго фиксирован, поэтому желательно избегать появления паддинга между полями. Если не следовать этой рекомендации, память структуры будет расходоваться нерационально. 

Чтобы избежать этого, можно пересортировать структуру. Скажем, в примере ниже можно поставить 8-байтное поле первым, а 4-байтное вторым. 

Также можно использовать __attribute__((packed)), который проинструктирует компилятор выкинуть паддинг. Вместе с тем здесь поля выровнены не по стандарту ABI, то есть этим можно воспользоваться, но подход подойдет не под все архитектуры. Лучше сортировать поля в структуре.



Перечисляемые типы


С чем еще можно столкнуться? Внутри кода библиотеки мы работаем в том числе с перечислимыми типами. Поэтому возникает вопрос, как их представлять во внешние структуры.

В принципе, можно взять enum. Но с ними есть ряд проблем. 

  1. Если в enum добавляется поле, то старая версия хедера не содержит новых значений и их все равно придется вносить в использующий библиотеку код. Получается ничем особенно не лучше, чем просто int. Удобство enum нивелируется.
  2. Получается два не совпадающих пространства числовых идентификаторов (внутреннее и внешнее) для одной сущности. В них легко запутаться, а читать сложно.



Эти проблемы преодолимы, но в ряде случае рациональнее использовать строковые идентификаторы. Это удобно, особенно если задача не связана с большим количеством аллокаций/деаллокаций структур или если они хорошо кешируются. Также это удобно для публичной структуры, которая используется для вывода информации для пользователя.



Флаги


При работе с флагами теоретически можно просто использовать bool. Но в условиях ограниченного размера структуры это дорого по памяти — по байту на флаг. 

Также есть вариант с битовыми полями (int x:1; и т.п.). Расположение битовых полей декларируется ABI-стандартами, но они не подчиняются общему правилу «все по порядку». То есть битовый филд будет расположен не в соответствии с привычными правилами и просто прочитать структуру и понять, как он будет расположен в памяти, уже не получится. 

Соответственно, обновлять ее сложно: легко допустить ошибку. К тому же не у всех базовых типов битовых полей есть декларируемое ABI-стандартами представление в памяти.



Поэтому здесь лучше делать выбор в пользу целочисленной переменной с флагами и использования битовых масок.



Политика обновления


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

Как правило, подобные политики сводятся к определению некоторых условий:

  • поля/флаги добавляются в конец;
  • ненужные поля/флаги резервируются, но не удаляются;
  • если представляемые структурой дефолтные значения объекта не фиксированы, то нужны дополнительные функции (например, функция аллокации);
  • если дефолтные значения не 0, то нужна функция, через которую будет выполняться создание.

Ложка дегтя 


Несмотря на сложность реализации и возможность закрытия большинства болей, сложный подход к обеспечению ABI-совместимости тоже не лишен недостатков. 

Например, если и в приватную, и в новую структуру к существующим полям «a» и «b» добавляется новое поле «c», то для того, чтобы приложение работало и со старой, и с новой версией кода, придется вручную посчитать смещение и сделать аксессоры на это поле «с». 





В некотором смысле это костыль, но иногда без него не обойтись.

Проверка версий


С темой бинарной совместимости неразрывно связаны и задачи определения версии библиотеки. Так, в рантайме может быть загружена не та версия библиотеки, против которой мы собирались, — старше или новее. Более того, это может быть и не та версия библиотеки, на наличие функций которой мы рассчитываем. 

Для проверки версии библиотеки можно использовать несколько подходов:

  • сравнение версий библиотеки;
  • проверка фич-флагов;
  • рантайм-проверка функциональности при старте.

Сравнение версий


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



Более того, иногда непонятно, в какие версии войдет та или иная фича, находящаяся в разработке, даже если она уже используется как минимум на ветке разработчика проекта.

Проверка фич-флагов


Подход с проверкой фич-флагов хорош тем, что не зависит от искусственных номеров, а фич-флаг создается вместе с фичей. 



Фич-флаги бывают не только числовые, но и строковые. Они позволяют проверять фичи напрямую по названию, а не по искусственным идентификаторам. 



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

Рантайм-проверка фичи


Рантайм-проверка функциональности подразумевает, что еще на этапе инициализации приложения можно протестировать новую функциональность. 



Если опереться на ранее описанный сложный подход с открытой структурой, можно сделать так:

  • создать открытую структуру и присвоить одному из полей значение;
  • создать внутреннюю структуру и сдампить ее во внешнюю; 
  • проверить, есть ли наше поле во внешней структуре;
  • если null, функциональности нет, если получили назад наше поле, функциональность есть. 

Фактически главное преимущество подхода — в его универсальности и независимости от внешних обстоятельств.

Ключевые рекомендации по обеспечению бинарной совместимости API


Напоследок несколько общих рекомендаций из нашего опыта:

  1. Расширяемость надо закладывать с первой версии API/ABI, чтобы потом мучительно не перестраивать интерфейсы.
  2. Явные правила обновления API помогают избежать ошибок, приводящих к проблемам в старых версиях компонентов.
  3. При разработке ABI можно опираться на стандарт C и стандарты ABI, специфичные для OS-платформы.
  4. Есть простой и часто используемый подход с закрытой структурой, который подходит для большинства случаев.
  5. Нет ничего криминального в том, чтобы отойти от этого простого подхода.
Tags:
Hubs:
Total votes 20: ↑20 and ↓0+23
Comments8

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен