Готовим C++. Система сборки Bake

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

    Комментарии 17

      +5

      Всё вроде бы неплохо со своими велосипедными системами сборки… До тех пор, пока ты не захочешь использовать современные IDE или интегрировать что-то с другими сторонними инструментами. База данных компиляции конечно может помочь в некотором роде, но в этом случае придется её генерировать для каждой цели, что очень неудобно. Ну и есть ещё множество нюансов. С этим проблемы возникают даже в больших компаниях с ресурсами, что уж говорить о мелких.


      Но, если я буду писать свое следующие приложение на C/C++ для сборки я, наверное, все же буду использовать CMake. Ну потому что, это CMake :)

      Вот это хороший вывод :) За статью спасибо.

        0

        А хотя бы инклюды он ассоциирует с артефактами так чтобы изменение заголовочника пересобирало все бинари его использующие?
        Посмотрите в сторону qbs, тоже декларативный синтаксис, но более однородная среда и не прибита гвоздями к С++

          0
          А хотя бы инклюды он ассоциирует с артефактами так чтобы изменение заголовочника пересобирало все бинари его использующие?

          Да конечно, это же прям базовый функционал.

          Про Qbs, я почему-то сначала подумал про qmake, а нет, это что-то новое. Давно не заглядывал в мир Qt. Посмотрю на досуге, спасибо.
            0

            Qbs уже давно не поддерживается. Нет смысла смотреть.

              0

              Хотя у кутешников опять какое-то раздвоение личности. То говорили, что прекратят разработку в 2019 (https://www.qt.io/blog/2018/10/29/deprecation-of-qbs), теперь оказывается, что все-таки поддерживают до сих пор (https://download.qt.io/official_releases/qbs/1.17.0/). В общем непонятно и стремно.

                0
                Это уже не ку-тешники, а комьюнити. И развивается даже быстрее чем было при ку-тешниках.

                Кроме того комьюнити для Qbs пилит плагин для VSCode: github.com/denis-shienkov/vscode-qbs

                Скоро анонс на хабр постараюсь добавить (плагин в принципе уже готов). ;)
            0
            > Посмотрите в сторону qbs, тоже декларативный синтаксис, но более однородная среда и не прибита гвоздями к С++

            Неистово плюсую этого господина, тем более, что уже готовится к релизу плагин для VSCode.
            +1
            Этой весной решил поискать альтернативы CMake, много лет в основном ей пользовался, реже qmake, autoconf и непосредственно make. Попробовал сначала gradle, потом bazel, в итоге на последнем и остановился. Синтаксис довольно прост, кросскомпиляцию поддерживает нормально (в отличие от gradle, долго с ней мучался), интеграция с IDE тоже, скорость устраивает. Не без косяков конечно, но потихоньку все устраняется, проект развивается. Команды для тестирования, покрытия тестами, запуск через valgrind имеются из коробки. Если надо логику для компиляции прикрутить, используется в файлах сборки язык starlark, по синтаксису как python. Всякие гетерогенные проекты можно собирать. В общем меня устраивает, полгода пользуюсь, не нарадуюсь.
              0

              Когда мне надоел "птичий язык" Make я стал искать альтернативу и перешёл на Rake где используется Ruby. Теперь если надо добавить в сборку что-нибудь этакое (например компилировать определенный каталог с другими флагами или добавлять номер коммита в выходной файл в виде константы), я не гуглю как это сделать в моей системе сборки, а сразу пишу. Из-за этого на системы сборки использующие свой язык я смотрю с предубеждением. Правда у меня проекты относительно небольшие (прошивки для микроконтроллеров).

                +1

                Да, Rake мы тоже используем, но только уже для вызова Bake или для реализации каких-то сложных шагов пост(пре)-сборки.

                –1

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


                Это верно для небольших проектов. Для сложных систем используют свои системы сборок или базел.

                  0
                  А что вы предполагаете под сложными системами? Какой критерий для их определения? Я бы не был так категоричен.

                  У меня есть примеры использвования CMake при разработке автомобильного ПО всех уровней. В моем понимании они попадают под категорию сложных систем.

                  Ну или, например, проект LLVM. Еще можно привести много достаточно больших открытых проектов, использующих CMake. А большая кодовая база уже подразумевает некоторую сложность.
                    +1

                    Про критерии мне тоже было бы интересно узнать.


                    Порой наблюдаешь странный гибрид из менеджера пакетов, собственно системы сборки, скриптов CI и много чего другого, а когда пытаешь узнать почему так, в ответ получаешь "сложная система". Да, чёрт побери, она теперь сложная! Мы же не умеем декомпозировать, не имеем доступа к существующим решениям, не знаем как вносить свой вклад в проекты с открытым исходным кодом, да и вообще всё это долго, дорого и отстаньте...

                      0
                      Именно поэтому маленькая Qt-компания 6-ю версию своего маленького Qt-фреймворка будет прекомпилить именно с помощью CMake
                        0
                        Ага, и до сих пор они домучать этот CMake не могут.
                          0

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

                            0
                            Ну, размер проекта понятие относительное.
                            Кто-то считает LLVM и Qt большими проектами, а кто-то — маленькими=)
                            Просто когда у вас пара терабайт кода, там начинаются пляски с бубнами вокруг системы сборки/системы контроля версий и прочего — догадываться что изначально имелось ввиду под «большим проектом» — дело неблагодарное.
                            Вообще, интересно сравнить один и тот же проект на разных системах сборки, но, к сожалению, это редко возможно — никто не хочет поддерживать зоопарк билдсистем. Я сравнивал сборку QtCreator с qbs/cmake — full build быстрее у qbs (~2.5 минуты разницы), null build быстрее у cmake+ninja (аж в 2 раза — 1.1 секунды против 2). Конфигурация у qbs чуть медленнее (к сожалению, данные не сохранил).

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

                        Самое читаемое