Наверное, большинство из вас согласится, что на сегодняшний день наибольшую популярность среди систем сборки для проектов на C/C++ имеет CMake. Каково же было мое удивление увидеть в проекте на новой работе собственную систему сборки - Bake.
В этой статье я бы хотел описать основные возможности этой системы, чем она отличается от других подобных систем и показать как на ней можно решить различные задачи, которые возникают в процессе разработки программы.
Bake - это кросс-платформенная система сборки для проектов написанных на С/С++, нацеленная в первую очередь на встраиваемые системы. Bake написан на Ruby, с открытым исходным кодом, который по-прежнему поддерживается (в разработке с 2012 г.)
Основные цели, к которым стремились разработчики при создании данного решения:
Command-line утилита (при этом есть поддержка plugins для некоторых редакторов, включая VSCode);
Которая должна решать только одну задачу - сборка программы;
Простые файлы конфигурации - разработчик должен тратить свое время для написания кода, а не файлов сборки;
Никаких промежуточных этапов генерации, при вызове команды сразу запускается сборка проекта;
Скорость работы должна быть высокой (поддержка параллельной сборки, кеширование результата обработки файлов сборки и т. п.).
Основы
Начнем с установки. Bake это Ruby gem. Поэтому в первую очередь вам нужно установить Ruby (требуется версия не ниже 2.0). А затем установить gem bake-toolkit с сервера rubygems.org:
gem install bake-toolkit
Для создание пустого проекта достаточно выполнить команду одну простую команду, предварительно создав новую папку:
mkdir myapp && cd myapp bake --create exe
Теперь чтобы его собрать, выполним команду
bake -a black **** Building 1 of 1: bake (Main) **** Compiling bake (Main): src/main.cpp Linking bake (Main): build/Main/bake Building done.
Флаг -a опционален и определяет цветовую палитру, которую будет использовать bake для вывода символов в терминал (терминал должен поддерживать, управляющие цветом последовательности ANSI).
Результат работы этих команд и показан на начальном скриншоте статьи. При повторном вызове команды сборки, Bake будет использовать кэш и результат предыдущего вызова, конечно предварительно проверив, что исходные файлы и конфигурация сборки не поменялись. Здесь все, как положено.
Если мы захотим пересобрать приложение с нуля, мы можем очистить кэш и запустить сборку снова (похожий результат, можно также получить при запуске bake с флагом --rebuild):
bake -c Cleaning done. bake -v2 -a black **** Applying 1 of 2: bake (IncludeOnly) **** **** Building 2 of 2: bake (Main) **** g++ -c -MD -MF build/Main/src/main.d -Iinclude -o build/Main/src/main.o src/main.cpp g++ -o build/Main/bake build/Main/src/main.o Building done.
С флагом -v(0-3) можно добавить больше информации в output, например, при уровне 2, можно увидеть команды компилятора.
Теперь обратим внимание на структуру, полученного проекта:
my_app | |-- .bake `-- .gitignore |-- Default.Project.meta.cache |-- Project.meta.cache |-- build `-- Main `-- src | `-- main.cmdline | |-- main.d | |-- main.d.bake | |-- main.o |-- .gitignore |-- my_app |-- my_app.cmdline |-- Project.meta |-- include `-- src `-- main.cpp
Файл Project.meta содержит правила сборки, по аналогии с CMakeLists.txt в CMake, таких файлов в проекте может быть несколько. Каждый файл соответствует новому проекту в Bake, а имя проекта определяется названием директории, в которой он находится. Таким образом в папке может быть только один Project.meta файл.
В папке .bake содержится служебная мета-информация, например кэш, которую использует Bake для внутренних процессов. Она не представляет для нас особого интер��са. Отмечу лишь, что Bake автоматически создает .gitignore файл для Git.
Папка build содержит результат работы. Здесь находятся артефакты компиляции main.o и my_app, а файлы с расширением .cmdline содержат, использованные для их создания команды компилятору/линкеру. Дополнительно файл .d.bake содержит список всех подключенных header файлов. Структура build директории, зависит от содержания файла Project.meta, в частности Main в данном случае это название конфигурации в нем, поэтому давайте разберем его структуру более подробно.
Project default: Main { RequiredBakeVersion minimum: "2.66.0" Responsible { Person "mdanilov" } CustomConfig IncludeOnly { IncludeDir include, inherit: true } ExecutableConfig Main { Files "src/*/.cpp" Dependency config: IncludeOnly DefaultToolchain GCC } }
Bake использует собственный декларативный язык, достаточно простой для понимания. Интересный факт, для описания синтаксиса языка была использована другая наработка одного из сотрудников компании - RText. Полный синтаксис представлен в документации Bake здесь.
Если вы вдруг решили попробовать, вы можете установить VSCode extension для подсветки синтаксиса. Для остальных поддерживаемых IDE можно посмотреть тут.
Итак, любой файл обычно начинается с ключевого слова Project и как я уже писал выше ему автоматически присваивается имя папки, в которой он находится. Далее мы указываем имя конфигурации (далее просто Config) по-умолчанию (та, которая будет запускаться при вызове bake без параметров). Проект может содержать сколь-угодно Config’ов, но все они могут быть только 3 типов - LibraryConfig для создания библиотек, ExecutableConfig для исполняемых файлов, или например ELF файлов, в случае сборки для микроконтроллера, и CustomConfig для всех остальных. После ключевого слова следует его имя, в примере это IncludeOnly для CustomConfig и Main для ExecutableConfig, который default.
Так как в Bake нет специальных Config для определения include директорий (в CMake мы обычно используем конструкции include_directories или target_include_directories), для этих целей используется паттерн CustomConfig с именем IncludeOnly, но для Bake это обычный Config.
Итак, IncludeDir указывает относительный путь к папке include проекта, где подразумевается хранить все публичные header файлы для библиотек. В нашем случае у нас нет библиотек с публичным API, которым могли бы воспользоваться другие проекты, поэтому папка include пустая. Атрибут inherit определяет будет ли данная директория унаследована проектами, которые будут использовать данный проект в качестве зависимости с помощью указания Dependency.
Затем в ExecutableConfig указываем пути к исходным файлам, из которых состоит наше приложение, используя команду Files. C помощью Dependency мы можем указать зависимость на другой Config, в нашем случае это CustomConfig IncludeOnly. Таким образом, мы наследуем include директории (см. описание inherit: true выше).
Обязательным атрибутом также является указание DefaultToolchain, который будет использоваться Bake по-умолчанию для всех проектов. В данном случае это gcc.
Список всех поддерживаемых toolchain можно узнать командой:
bake --toolchain-names Available toolchains: * Diab * GCC * CLANG * CLANG_ANALYZE * CLANG_BITCODE * TI * GreenHills * Keil * IAR * MSVC * GCC_ENV * Tasking
Hello world это хорошо, но что насчет реальных проектов
Чтобы внести немного ясности в систему зависимостей приведу более приближенный к реальности пример с несколькими проектами в одном workspace.
Предположим, что у нас есть приложение my_app, которое состоит из трех библиотек libA, libB, libC. Причем libB зависит от libC, а libC поставляется в виде бинарного файла с заголовочными файлами интерфейсов. И мы также хотим иметь unit тесты для libB.
Для такого приложения на скриншоте приведен пример организации файлов и правил сборки Bake. У нас есть основной Project.meta с описанием toolchain в корне проекта, и для каждой библиотеки свой Project.meta для удобства (конечно можно было бы описать все и в одном Project.meta файле, но при большом количестве библиотек и правил его было бы невозможно поддерживать).
В корневом Project.meta я привел пример как можно добавить дополнительные флаги компиляции с помощью свойства Flags. В данном случае флаги передаются компилятору C++, возможно таким же образом отдельно указать флаги для линкера (Linker), компилятора языка C (Compiler C), ассемблера (Compiler ASM) и архиватора (Archiver). Для того чтобы узнать конфигурацию по-умолчанию для GCC toolchain можно использовать команду bake --toolchain-info GCC.
Bake также позволяет добавлять дополнительные этапы сборки для выполнения различных команд после или перед сборкой определенных Config. Поддерживаются команды для работы с файловой системой (создание директории, копирование файла и т. п.) или запуск внешних процессов с помощью команды CommandLine (в примере не используется). Воспользуемся этим, чтобы создать release пакет нашего приложения установив команды MakeDir и Copy в PostSteps.
Вы также можете увидеть здесь использование встроенных переменных, например ArtifactName содержит имя полученного бинарного файла, после сборки Main конфигурации.
Bake определяет 3 типа переменных: встроенные, определенные пользователем и переменные окружения.
Список встроенных переменных можно посмотреть тут;
Переменные пользователя устанавливаются командой Set как на скриншоте выше для InstallDir или передаются в bake в качестве параметра командной строки
--set MyVar="Hello world!";Переменные окружения определяются ОС
Теперь рассмотрим Project.meta файлы наших библиотек:
libA/Project.meta
Project default: Lib { CustomConfig IncludeOnly { IncludeDir include, inherit: true } LibraryConfig Lib { Files "src/*/.cpp" Dependency config: IncludeOnly Toolchain { Compiler CPP { Flags remove: "-O2 -march=native" } } } }
Bake также позволяет переопределить или задать дополнительные параметры toolchain для любого Config. В качестве примера для libA я удалил флаги компиляции, которые мы определили в основном DefaultToolchain, таким образом сборка библиотеки будет происходить без оптимизации.
libB/Project.meta
Project default: Lib { CustomConfig IncludeOnly { IncludeDir include, inherit: true } LibraryConfig Lib { Files "src//.cpp" Dependency config: IncludeOnly Dependency libC, config: IncludeOnly } ExecutableConfig UnitTest { Files "test/src//.cpp" Dependency config: Lib DefaultToolchain GCC } }
libB содержит пример того, как может быть организована сборка UnitTest. Мы просто создаем дополнительный исполняемый Config с исходными файлами тестов, указываем зависимость на тестируемую библиотеку и определяем для него DefaultToolchain (это необходимо, для того чтобы была возможность скомпилировать только UnitTest).
libC/Project.meta
Project default: Lib { CustomConfig IncludeOnly { IncludeDir include, inherit: true } LibraryConfig Lib { ExternalLibrary "libC.a", search: false Dependency config: IncludeOnly } }
libC не собирается из исходных файлов, а линкуется в пре собранном виде, поэтому здесь мы используем атрибут ExternalLibrary.
Имея такую конфигурация, мы также теперь можем выполнять компиляцию отдельных проектов с помощью команды bake -p <dir>, где dir это имя проекта (libA, libB, ..).
Список часто используемых и полезных команд
1) Параллельная сборка организована на уровне исходных файлов и проектов Bake, для указания количества используемых потоков, как и во многих похожих утилитах используется параметр командной строки -j с указанием числа. По умолчанию, используется число ядер ЦПУ. Команда для запуска в 1 поток: bake -j 1
2) Есть возможность генерации файла compile_commands.json. bake --compile-db compile_commands.json
3) Поддержка частичной сборки. Если запустить bake с параметром --prebuild, будет запущена сборка только тех Config, для которых есть правило исключения, для остальных Config будет использоваться результат предыдущей сборки. Исключения задаются в Project.meta с помощью паттерна:
Prebuild { Except <project>, config: <config> ... }
Данная функциональность, например может быть использована для создания SDK, или тогда, когда хотите предоставить возможность писать стороннему разработчику часть модулей вашего приложения с возможностью сборки, но при этом не открывая исходных кодов остальной части проекта.
Для создания такого SDK, вам нужно сначала собрать проект полностью, а затем удалить все исходные файлы, оставив только заголовочные файлы публичных API, и предварительно определив Except правила. Сборка из SDK будет осуществляться с помощью команды bake --prebuild.
4) Сборка нескольких проектов с помощью утилиты bakery. Если вы например хотите собрать все UnitTest вы можете сделать это с помощью команды bakery -b AllUnitTests, предварительно создав файл Collection.meta, используемый bakery для создания списка Config для сборки:
Collection AllUnitTests { Project "*", config: UnitTest }
5) Генерация дерева зависимостей. После выполнения команды bake --dot DependencyGraph.dot для примера из статьи получим следующий рисунок:
6) Генерация JSON файла со списком всех incudes и defines bake --incs-and-defs=json Приведу только часть файла для примера:
"myapp": { "includes": [ "libA/include", "libB/include", "libC/include" ], "cppdefines": [], "c_defines": [], "asm_defines": [], "dir": "/Users/mdanilov/Work/my_app" }
Adaptions как высший уровень работы с Bake
Adaptions наверное самая сложная для понимания часть устройства Bake. Как можно понять из названия, они позволяют модифицировать конфигурацию проекта, но делают с помощью отдельных директив, таким образом не меняя основную структуру проекта.
Их можно определить как в Project.meta, так и в отдельном Adapt.meta (предпочтительный вариант). Но синтаксис и в том и другом случае будет один и тот же. Удобство отдельного файла заключается в том, что его можно применить к проекту в качестве параметра командной строки --adapt.
Объяснить как они работают лучше на примере. Допустим, мы хотим собрать наш проект для gcc (как вы помните мы определили DefaultToolchain GCC) с помощью Clang компилятора, при этом не меняя Project.meta. Единственный способ сделать это в Bake, это использовать Adapt.meta:
Adapt { ExecutableConfig __MAIN__, project: __MAIN__, type: replace { DefaultToolchain CLANG } }
В данном случае, мы заменили (replace) DefaultToolchain для основного Config в основном проекте, используя ключевое слово __MAIN__ в качестве имен.
Важно: Adapt.meta файл должен находится в отдельной директории, именно по имени директории Bake будет осуществлять поиск (это сделано по аналогии с именем проекта в случае с Project.meta).
Теперь поместив файл в папку clang, мы можем запустить команду сборки bake --adapt clang и убедиться, что она происходит компилятором Clang.
В качестве ключевого слова для названии можно также использовать __ALL__, если мы хотим переопределить все. Или можно явно указывать конкретное имя. А для типов модификаций, кроме replace, remove, extend и push_front. Более подробную, но к сожалению не исчерпывающую, информацию можно найти в документации.
Вы также можно применять несколько модификаций за раз, передавая несколько --adapt параметров.
Или добавлять различные условия для их выполнения:
Adapt toolchain: GCC, os: Windows { … }
Данная модификация будет применена к конфигурациям только при использовании компилятора GCC на Windows. В данном случае ключевое слово Adapt можно заменить на If (или Unless, если логику нужно инвертировать) для лучшей читаемости.
Заключение
В заключении хотелось бы подчеркнуть, что данная статья написана исключительно в ознакомительных целях и просто показывает еще один способ того, как может быть организована сборка проектов на C/C++.
От себя могу добавить, что Bake действительно быстро работает, и я не тратил много времени на его изучение. Очень много проектов в компании было написано, исключительно с использованием Bake. И большие проекты для него, по крайне мере в рамках встраиваемых систем, не проблема.
Но, если я буду писать свое следующие приложение на C/C++ для сборки я, наверное, все же буду использовать CMake. Ну потому что, это CMake :)