Как мы переводили проект на CMake

Просто так в мире ничего не происходит. Особенно в мире разработки ПО, где если что-то работает, то лучше это лишний раз не трогать.

Дано: живой проект, который активно развивается, и собирается при помощи рекурсивной сборочной системы на основе GNU Make. Те года, что проект существует, он успешно масштабировался и прорастал этой системой сборки все глубже. Так зачем от нее отказываться в пользу чего-то другого?

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

Так, например, разработчики писали код в самых разных редакторах (тут уж кому какой больше по вкусу), сборку компонентов производили в контейнере в терминале, оттуда же развёртывали компоненты на виртуальную машину через SSH. Понятное дело, что эти действия были так или иначе заскриптованы, однако такой подход все еще остаётся излишне рукописным.

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

Назрeла необходимость во внедрении решения, которое бы было менее сложным с точки зрения синтаксиса, и, при этом, предоставляло возможность использования IDE из состава нашего контейнера (Qt Creator). Так мы пришли к системе сборки CMake.

Исходное состояние

Давным давно проект представлял собой одно монолитное приложение, которое решало задачу одного конкретного заказчика. Затем, с течением времени, архитектура проекта стала усложняться, и он эволюционировал в полноценный картографический фреймворк для ЗОСРВ «Нейтрино», на основе которого наши заказчики могут решать свои задачи планирования, навигации, и так далее. По мере усложнения, в проекте появлялись open-source библиотеки, которые собирались с другими системами сборки, отличной от нашей. Позже, для демонстрации работы фреймворка нам потребовалось написать несколько приложений с графическим интерфейсом — стали использовать Qt, и пойдя по пути наименьшего сопротивления, в качестве системы сборки для них был выбран qmake.

Консольные приложения, библиотеки

Графические приложения

Open-source библиотеки

Make

qmake

CMake
Autotools
Make

Как можно видеть, в проекте присутствовало целое разнообразие разных систем сборки. Как же всё это добро собиралось? При помощи специальных wrapper-ов. Например, для qmake обёртка Make над .pro файлами системы qmake выглядит следующим образом:

ifndef QCONFIG
  QCONFIG=qconfig.mk
endif
include $(QCONFIG)
include $(MKFILES_ROOT)/qmacros.mk
include $(MKFILES_ROOT)/qmake.mk
include $(MKFILES_ROOT)/qtargets.mk

Ключевая строка здесь — под номером 7, именно тут происходит подключение модуля работы с qmake. Вся основная информация о сборке компонента лежит в .pro файле, как и обычно у всех Qt-проектов. Обёртки для CMake и Autotools выглядят аналогично, отличие лишь в той же строке.

include $(MKFILES_ROOT)/cmake.mk
или
include $(MKFILES_ROOT)/autotools.mk

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

make

Начинаем переход

Как мы решили организовать работу по решению такой большой задачи, как смена система сборки? В первую очередь, проанализировали open-source библиотеки, как не зависящий от нас фактор — убедились, что в каждой их них предусмотрена возможность сборки с CMake. Далее, декомпозировали задачу, разбив на три основных этапа:

Make → CMake

Проще всего было переводить на CMake консольные утилиты, которые написаны на Make. С них мы в первую очередь и начали, а конкретнее — с утилит, состоящих из одного файла исходного кода, и закончили ядром картографического сервиса как наиболее ответственным компонентом проекта.

Немного в базовом синтаксисе CMake. Вот так выглядит базовый CMakeLists.txt для проекта Hello, World!:

cmake_minimum_required( VERSION 3.23 FATAL_ERROR )

project( hello LANGUAGES C )

add_executable( hello hello.c )

install( TARGETS hello DESTINATION bin )

А теперь возьмем как пример простейшую утилиту, которая просто читает заголовок файла во внутреннем формате и выводит информацию в консоль. Утилита состоит всего из одного исходника. На картинке ниже можно видеть различие в синтаксисе сборочных файлов. Даже для такой простой программы видно, что файл common.mk рекурсивной сборки (справа) содержит в себе множество директив include, а базовые конструкции, такие как подключение библиотек и указание флагов компилятора содержат в себе сокращения, которые требуют некоторого времени на осознание (EXTRA_LIBVPATH, EXTRA_INCVPATH, CCFLAGS), особенно для начинающих разработчиков. Также для рекурсивной системы сборки необходимо указание EXTRA_LIBVPATH, в то время как для CMake такая конструкция является излишней. На наш субъективный взгляд, язык CMake предоставляет более человеко-читаемые конструкции, которые позволяют читать CMakeLists.txt как сочинение на английском языке, что намного приятнее глазу.

CMake (слева) и GNU Make (справа)

CMake (слева) и GNU Make (справа)

qmake → CMake

Завершив этап с консольными приложениями и сервисами, мы приступили к графическим приложениям.

Вот так выглядит базовый CMakeLists.txt для проекта с использованием Qt (отличия от простого примера с Hello, World! выделил комментариями):

cmake_minimum_required( VERSION 3.23 FATAL_ERROR )

project( hello LANGUAGES CXX )

set( CMAKE_AUTOMOC ON )  # Обработка мета-объектным компилятором (moc)
set( CMAKE_AUTOUIC ON )  # Обработка файлов интерфейса (.ui)
set( CMAKE_AUTORCC ON )  # Обработка файлов ресурсов (.qrc)

add_executable( hello hello.c )

find_package( Qt5
              COMPONENTS
              Core
              REQUIRED )  # Находим установленные библиотеки Qt

target_link_libraries( hello
                       Qt5::Core )  # И линкуем их к приложению

install( TARGETS hello DESTINATION bin )

Для Qt-приложений у нас достаточно лохматые и большие файлы сборки, поэтому приводить их тут будет излишне, лучше взамен приведем таблицу, содержащую в себе сравнение конструкций, используемых в qmake и CMake для достижения одинаковых целей, которые встречались в нашем проекте:

Задача

CMake

qmake

Подключение библиотек Qt

find_package (Qt5
ㅤㅤㅤCOMPONENTS
ㅤㅤㅤCore
ㅤㅤㅤGui
ㅤㅤㅤWidgets
ㅤㅤㅤREQUIRED)

target_link_libraries (
ㅤㅤㅤtarget
ㅤㅤㅤㅤㅤ
ㅤㅤㅤPUBLIC
ㅤㅤㅤQt5:: Core
ㅤㅤㅤQt5:: Gui
ㅤㅤㅤQt5:: Widgets)

QT += core gui widgets

Подключение ресурсов (на примере переводов Qt Linguist)

qt5_add_resources (
ㅤㅤㅤtarget
ㅤㅤㅤFILES
ㅤㅤㅤtranslations.qrc)

RESOURCES += translations.qrc

Инсталлирование config-файла

install (TARGETS
ㅤㅤㅤtarget
ㅤㅤㅤDESTINATION
ㅤㅤㅤbin)

install (FILES
ㅤㅤㅤconfig/*.conf
ㅤㅤㅤDESTINATION
ㅤㅤㅤdata/config)

INSTALLS += target config

config.path=$$INSTALLDIRECTORY/…/data/config

config.files=config/*.conf

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

Open Source → CMake

До этого момента мы затрагивали исключительно наши собственные разработки. Однако, мы еще собираем из исходников open-source библиотеки. Дело в том, что поставлять их в бинарном виде в составе ОС или в составе комплекта разработчика излишне: утяжелять комплект разработчика и Docker-контейнеры собранными под все архитектуры библиотеками для нужд прикладного ПО ни к чему.

С open-source библиотеками по удачному стечению обстоятельств сложилось так, что они все задействуют или могут задействовать CMake. К примеру, библиотека PROJ версии 7.1.0 может собираться как с помощью CMake, так и с Autotools. Выбрали CMake, исключив обёртку в виде рекурсивной сборочной подсистемы, подключили библиотеку как зависимость с помощью обычного target_link_libraries(). Так же поступили и с другими либами. Проблема возникла с библиотекой GDAL, а точнее с ее версией. У нас в проекте была задействована версия 3.1.2, которая поддерживала исключительно Autotools. Однако, в результате недолгих изысканий в Интернете мы выяснили, что поддержка CMake в этой библиотеке была реализована начиная с версии 3.5.0. Портировали самую последнюю на тот момент версию — 3.7.1.

Открытый проект в Qt Creator. Можно заметить, что у каждой директории виднеется логотип CMake

Открытый проект в Qt Creator. Можно заметить, что у каждой директории виднеется логотип CMake

Здесь следует отметить, что портирование новой версии обошлось не совсем гладко, ровно как и переключение с Autotools на CMake. Пришлось вносить какие-то микрофиксы в файлы сборки библиотек, вроде отключения сборки тестов и документации или добавления флагов компилятора с целю сократить количество мата, выпадающего в лог. Опустим эти моменты, поскольку они являются мелкими шероховатостями и рутинной работой.

Как итог, мы получили полноценную иерархию проекта! Проверить ее можно, открыв проект в IDE. Забегая вперед, скажу, что мы используем Qt Creator и проверяли «открываемость» проекта именно в нем. Подробнее об использовании Qt Creator будет дальше, пока что лишь приведу скриншот, демонстрирующий тот результат, который мы так долго ждали!

Выгода от перевода на CMake

Использование IDE

Переход на систему сборки CMake дал нам возможность использовать полноценную IDE, в частности мы пользуемся Qt Creator из состава контейнера для разработчиков нашей команды.

Выбираем головной CMakeLists.txt файл в меню Qt Creator и у нас открывается проект. Мы можем собирать его как целиком, так и по отдельным компонентам. Поскольку теперь многие действия выполняются разработчиками из одного окна IDE, стало намного удобнее развёртывать компоненты проекта на целевом устройстве, а также производить дебаг отдельных частей проекта, буквально по нажатию одной кнопки.

Отдельно очень важным считаю отметить, что в Qt Creator интегрирована наша внутренняя документация, которая позволяет по нажатию кнопки F1 при наведении курсора на функцию/тип/класс открыть прямо в IDE интересующую страницу документации. Почитать статью о нашем подходе к ведению документации можно по этой ссылке, а более подробный разбор интеграции справки в Qt Creator описан в этой статье.

Проект, открытый в Qt Creator с открытой страницей документации

Проект, открытый в Qt Creator с открытой страницей документации

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

Упрощен анализ зависимостей

Использование сборочной системы CMake позволяет нам анализировать зависимости проекта в виде графа при помощи инструмента Graphviz. Он работает как с зависимостями внутри проекта, так и со сторонними зависимостями. Так, вызвав команду следующую команду, мы получаем файл, содержащий в себе граф, описанный на языке DOT:

cmake --graphviz=graph.dot .

Содержимое файла можно отредактировать на свой вкус при помощи какого-нибудь парсера. Это особенно актуально, если не хочется видеть все зависимости между модулями какой-нибудь open-source библиотеки в составе вашего проекта, или например, не хочется видеть все зависимости от Qt.

Затем полученный файл можно визуализировать любым удобным способом и сохранить в виде изображения. Например, командой:

dot -Tsvg graph.dot -o graph.svg

Еще удобно пользоваться онлайн-визуализатором, выполняющим те же функции.

Максимально упрощённый и причёсанный граф, привожу его исключительно для демонстрации того, что примерно выдает инструмент на выходе. Реальный граф будет в разы крупнее и содержать сотни стрелок.

Максимально упрощённый и причёсанный граф, привожу его исключительно для демонстрации того, что примерно выдает инструмент на выходе. Реальный граф будет в разы крупнее и содержать сотни стрелок.

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

Оформленная схема зависимостей с разделением по типам компонентов (цвет, форма блоков, тип линий).

Оформленная схема зависимостей с разделением по типам компонентов (цвет, форма блоков, тип линий).

Время сборки

Несмотря на то, что CMake использует под капотом те же Makefile, время сборки существенно сократилось. Замер проводился на моей инструментальной машине в контейнере, сборка проводилась в 8 потоков только для аппаратной платформы x86.

Команда сборки для Make:

CPULIST=x86 make -C src install -j8

Команды конфигурации и сборки для СMake (генератор Unix Makefiles):

cmake --toolchain=$KPDA_HOST/mk/cmake/toolchain-nto-x86.cmake ..
cmake --build . -j8

Команды конфигурации и сборки для СMake (генератор Ninja):

cmake -G "Ninja" \
      -DCMAKE_MAKE_PROGRAM=/usr/bin/ninja \
      --toolchain=$KPDA_HOST/mk/cmake/toolchain-nto-x86.cmake ..
cmake --build . -j8

Результаты следующие:

Сборочная система

Время сборки (мин: сек)

Рекурсивный Make

05:22

CMake (генератор Unix Makefiles)

03:02

CMake (генератор Ninja)

03:01

Синтаксис

На наш субъективный взгляд, язык CMake легче читается. Также, по нашей практике, он еще и легче в освоении для стажеров-разработчиков, плюс многие студенты так или иначе сталкивались с CMake в решении своих академических задач. Иными словами, найти новые кадры в проект становится легче и меньше времени тратится на обучение специалиста «уже на месте». Вдобавок, при передаче исходного кода наших демонстрационных приложений нашим заказчикам, у них появляется меньше вопросов касаемо системы сборки, поскольку с CMake знакомы практически все.

Приведем пару примеров улучшения читаемости файлов сборки.

Пример: линковка статической библиотеки

Ниже представлены строки из common.mk для разделяемой библиотеки, которая статически линкует в себя еще одну библиотеку:

EXTRA_LIBVPATH += $(PROJECT_ROOT)/../my_static_lib/$(OS)/$(CPU)/a.shared$(subst so,,$(COMPOUND_VARIANT))
LDFLAGS += -Wl,--whole-archive -lmy_static_libS -Wl,--no-whole-archive

Тут можно видеть, что в переменную EXTRA_LIBVPATH мы помещаем некоторый путь, где, очевидно, лежит статическая библиотека. Поскольку статические библиотеки не инсталлируются, нам необходимо знать точный путь до нее в директории сборки. Приведу краткое объяснение, полное и более технически корректное можно прочитать в нашей официальной документации. Итак, простыми словами, в рекурсивной сборочной подсистеме директория сборки определяется следующим образом:

1) В директории компонента обязательно есть директория с фиксированным именем для целевой ОС (например, linux64, nto, win32, win64). В нашем случае разработка ведется под ЗОСРВ «Нейтрино», поэтому далее рассмотрим содержимое папки nto.

2) В директории целевой ОС располагаются директории по аппаратным платформам. Например, x86, arm, ppc, e2k (Эльбрус), mips.

3) В директории целевой аппаратной платформы мы выбираем вариант платформы, как правило, это означает порядок байтов (big-endian, little-enidan).

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

Теперь посмотрим как эта операция выглядит в CMake:

target_link_libraries( gishelper my_static_lib )

И всё! Правда, для полной картины необходимо привести CMakeLists.txt для my_static_lib:

add_library( my_static_lib STATIC ${SOURCES} )

Нет загадочных сложных путей, их построение мы делегировали CMake. Плюс, мы получили построение зависимости: перед сборкой разделяемой библиотеки у нас автоматически происходит сборка статической, без каких-либо дополнительных команд. А если некоторое приложение будет линковать эту разделяемую библиотеку, то CMake раскрутит эту связку зависимостей, и будет собирать сначала статическую библиотеку, затем разделяемую, а затем уже приложение.

Пример: линковка произвольных файлов в бибиотеку

Одной из нетривиальных задач при сборке одной из наших библиотек является запаковка многочисленных .csv файлов в один объектный файл, который затем будет прилинкован к самой библиотеке, внутри которой мы будем потом этот файл читать при помощи специальных символов _binary_*_start[] и _binary_*_end[]. Для превращения пачки .csv файлов в один объектник вызывается линковщик. На Make такая операция имела довольно страшный синтаксис:

RESOURCES:=*.csv
EXTRA_OBJS=$(addsuffix .o,$(RESOURCES))
%.csv.o:
	cp $(subst .o,,$(PROJECT_ROOT)/palettes/$@) .
	$(LDPREF) -nostdlib -Wl,-r -Wl,-b -Wl,binary -o palettes.csv.o $(subst .o,,$@)
	rm $(subst .o,,$@)

Аналогичная операция на CMake:

set( SRCPATH    ${CMAKE_CURRENT_SOURCE_DIR}/palettes )
set( DSTPATH    ${CMAKE_BINARY_DIR}/lib/gishelper )

add_custom_command( OUTPUT              ${DSTPATH}/palettes.csv.o
                    WORKING_DIRECTORY   ${SRCPATH}/palettes
                    COMMAND             ${LINKER} -nostdlib -r -b binary -o palettes.csv.o *.csv
                    COMMAND             ${CMAKE_COMMAND} -E copy palettes.csv.o ${DSTPATH}/palettes.csv.o
                    COMMAND             ${CMAKE_COMMAND} -E rm   palettes.csv.o )

add_custom_target( linking_extra_object_files
                   DEPENDS
                   ${CMAKE_BINARY_DIR}/lib/gishelper/palettes.csv.o )

add_dependencies( gishelper linking_extra_object_files )

Да, строк получилось больше, но зато код выглядит куда опрятнее и читабельнее.

С какими трудностями мы столкнулись

Потеря совместимости с Qt 4.8.7.

А именно отказ от его поддержки ввиду отсутствия необходимых .cmake файлов, необходимых для работы функции find_package() языка CMake. Вполне резонно можно сказать, что поддержка такой версии Qt в 2023 году это жуткий архаизм, однако, еще буквально недавно некоторые наши заказчики пользовались именно этой версией Qt. Теперь мы переползли на Qt 5.12.2.

Портирование новых версий open-source библиотек

Например, одна из основополагающих open-source библиотек проекта — GDAL — до перехода на CMake была версии 3.1.2 и собиралась исключительно при помощи Autotools, однако поддержка CMake внутри самого GDAL появилась лишь в версии 3.5.0. Пришлось портировать самую последнюю на тот момент версию — 3.7.1. Обошлось без каких-либо явных проблем, за исключением того, что для сборки GDAL под архитектуру Эльбрус (e2kle) было необходимо явно отключать включенные по умолчанию опции BUILD_APPS и BUILD_TESTS, поскольку компилятор lcc от МЦСТ выдает чрезмерно много ошибок и варнингов. Поскольку эти тесты и приложения не являются важными компонентами, да и в принципе являются опциональными, они для этой аппаратной платформы не собираются. Наша собственая система тестирования проекта никаких отклонений в работе непосредственно библиотеки не выявила.

Стадии сборки

Пришлось привыкнуть к тому, что инсталляция зависимостей таргета происходит только после окончания сборки самого таргета. Например, чтобы собрать и установить наш проект с помощью Make выполнялась комнада:
CPULIST=x86 make -C src install -j8
Каждый собранный компонент устанавливался сразу после своей сборки, не дожидаясь пока завершится сборка всех таргетов в проекте. На мой взгляд, это очень удобно, и для меня было удивлением, что подобного механизма CMake не предоставляет. CMake придерживается идеи трёх стадий сборки: конфигурация, сборка, установка. На этапе конфигурации генерируются необходимые для сборки файлы в ${CMAKE_BINARY_DIR} с расширением .cmake и .make или .ninja. Эти файлы подготавливаюстя для второго этапа — непосредственно компиляции и линковки компонентов проекта. Последний же этап — этап установки — не представляет собой ничего, кроме простого копирования всех файлов из ${CMAKE_BINARY_DIR} в папку инсталляции.

Новые варнинги

Появление новых варнингов при сборке. Большинство из них были действительно по делу, их причины были устранены. Те, причины которых показались нам ошибочными мы заглушили флагами компилятора, особенно если речь о сборке open-source библиотек. Приятно видеть лог, который состоит исключительно из текущего состояния сборки и собираемого объектника, без засоренности лога.

Чистый лог, по мере заполнения которого мы можем понимать, в каком состоянии сейчас сборка и сколько примерно осталось ожидать

Чистый лог, по мере заполнения которого мы можем понимать, в каком состоянии сейчас сборка и сколько примерно осталось ожидать

Что в планах

Переход на новую систему сборки дал нам не только те преимущества, что были описаны выше, но и потенциально принесет нам в будущем новые:

  • Усовершенствование системы тестирования.
    С CMake мы получаем возможность легкой интеграции CTest или GTest. Можно будет отказаться от самописной системы из собственных shell-скриптов и юнит-тестов, которые лежат в отдельном репозитории, как будто бы «обособленно» от проекта.

  • Возможность формирования разных конфигураций сборки — Debug/Release.

  • Упаковка с помощью CPack.

© Habrahabr.ru