Сборка 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 :)