При разработке продукта на компилируемом языке (таком как 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. Нет ничего криминального в том, чтобы отойти от этого простого подхода.

Комментарии (6)


  1. boldape
    31.05.2024 07:28

    Обратная совместимость (это когда новое приложение загружает старую библиотеку) вами хорошо описана и самый частый случай и не особо сложная. Расскажите как вы добиваетесь прямой совместимости (это когда старое приложение загружает новую библиотеку). Особо интересен случай когда поддержка прямой совместимости со всеми старыми версиями приложения не целесообразна и нужно остечь/установить минимальную версию приложения с которым библиотека совместима. Конечно версия приложения до этой минимальной не должна крашится при попытке загрузить несовместимую библиотеку, а корректно сообщать что библиотека не совместима. Ещё более интересен случай когда у вас много приложений и у каждого своя версия, а библиотека совместима с разными минимальными версиям каждого приложения. Ещё более интересный случай это когда список приложений открытый т.е. приложения с которыми нужна прямая совместимость написаны не вами и вы о них ничего не знаете. Т.е. вы разработчик плагина/библиотеки, а не приложений.


    1. tknme
      31.05.2024 07:28
      +2

      В норме — за счет того, что старая функциональность библиотеки работает и в старой, и в новой версии библиотеки. Если нужно расширить API несовместимым способом, то появляется новый API рядом. (Но стараемся, конечно, закладывать расширяемость изначально.)

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

      Еще интересный момент, когда старое поведение мы считаем неправильным (например, неочевидным для программиста и ведущим к ошибкам) и хотим поменять несовместимым образом целенаправленно. Это валидное изменения для новой мажорной версии.

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

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

      В следующей мажорной версии новое поведение становится дефолтным. Но все еще можно попросить библиотеку (в нашем случае — тарантул) работать по-старому.

      А очередная мажорная версия удаляет старое поведение.

      Таким образом, можно обновиться, оценить необходимые доработки и делать их постепенно. Новые мажорные версии у нас выходят примерно раз в два года. Соответственно, есть 4 года на обновление кода приложения с возможностью продолжать использовать актуальные версии тарантула.

      Я бы еще упомянул, что часто мы сами являемся пользователями своих API (в модулях расширения, в примерах для документации, в решениях для заказчиков), и за счет интеграционного тестирования отлавливаем часть несовместимостей, которые иначе могли бы пройти незамеченными.


      1. boldape
        31.05.2024 07:28

        Вот да интересен случай когда надо осознано внести несовместимые изменения, но при этом не ломать всех разом, но и когда надо все таки поломать но корректно. В моем случае это не библиотека, а база но суть тоже самое. Я делаю несовместимую миграцию которая исправляет проблемы, но в 2 шага/релиза. На первом шаге я меняю приложение и подготавливаю его для будущей миграции таким образом версия приложения Н+1 может работать и со старой и с новой базой, а версия Н только со старой. Затем в следующем релизе я сделаю миграцию и надо сделать так, что бы версия Н перестала загружать новую базу но не крашилась, а версия Н+1 и новее могла.

        Раньше у нас это решалось через проверку мажорной версии базы если она поднимается то старые приложения перестают ее грузить, но очевидно в таком подходе все приложения кроме самой новой версии не смогут работать с новой базой. Я поменял этот механизм и стал в самой базе хранить минимальную совместимую версию приложения и в следующем релизе я проставлю ее как Н+1 и подниму мажорную версию базы. Таким образом Н и более ранние версии перестанут работать, а Н+1 и новее будут работать.

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


        1. tknme
          31.05.2024 07:28

          В вашей постановке задачи мне видится разумным подход с версионированием схемы базы данных: одна версия на всю базу, на отдельные таблицы, а то и на конкретные записи — зависит от размера таблиц, SLA и необходимости делать миграции «на живую». В этом случае приложения могут иметь список поддерживаемых версий схемы (базы данных, таблицы, записей) и обрабатывать ситуацию совместимой и несовместимой версии.

          Здесь мы заменили хранение версии приложения на хранение версии схемы базы плюс (что важно) контракт с приложением о том, как проверяется версия схемы. За счет этого любое приложение, соблюдающее этот контракт, работает ожидаемым для нас образом, и список приложений открытый.

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

          Впрочем, я могу не знать какой-то специфики вашего проекта и не возьмусь утверждать, что мои рассуждения применимы к нему.

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

          Удобно, когда перед таблицей есть слой логики, где можно заниматься трансформацией данный в зависимости от версии схемы данных и версии API.

          Пожалуй, мы относительно далеко уже ушли от темы доклада/статьи :)


  1. lil_master
    31.05.2024 07:28

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

    ARM64?


    1. tknme
      31.05.2024 07:28

      Да, натуральное выравнивание. См. раздел 5 (Data types and alignment) стандарта Procedure Call Standard for the Arm 64-bit Architecture. Сборки документа выкладываются здесь: https://github.com/ARM-software/abi-aa/releases