Сборка Hello World с помощью Bake
Сборка Hello World с помощью Bake

Наверное, большинство из вас согласится, что на сегодняшний день наибольшую популярность среди систем сборки для проектов на C/C++ имеет CMake. Каково же было мое удивление увидеть в проекте на новой работе собственную систему сборки - Bake.

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

Bake - это кросс-платформенная система сборки для проектов написанных на С/С++, нацеленная в первую очередь на встраиваемые системы. Bake написан на Ruby, с открытым исходным кодом, который по-прежнему поддерживается (в разработке с 2012 г.)

Основные цели, к которым стремились разработчики при создании данного решения:

  1. Command-line утилита (при этом есть поддержка plugins для некоторых редакторов, включая VSCode);

  2. Которая должна решать только одну задачу - сборка программы;

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

  4. Никаких промежуточных этапов генерации, при вызове команды сразу запускается сборка проекта;

  5. Скорость работы должна быть высокой (поддержка параллельной сборки, кеширование результата обработки файлов сборки и т. п.).

Основы

Начнем с установки. 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
Пример структуры проекта для приложения my_app

Предположим, что у нас есть приложение 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 :)