Современный CMake: 10 советов по улучшению скриптов сборки

CMake — это система сборки для C/C++, которая с каждым годом становится всё популярнее. Он практически стал решением по умолчанию для новых проектов. Однако, множество примеров выполнения какой-либо задачи на CMake содержат архаичные, ненадёжные, раздутые действия. Мы выясним, как писать скрипты сборки на CMake лаконичнее.


Если вы хотите опробовать советы в деле, возьмите пример на github и исследуйте его по мере чтения статьи: https://github.com/sergey-shambir/modern-cmake-sample

Совет №1: указывайте высокую минимальную версию CMake


Совет не относится к тем, кто пишет публичные библиотеки, поскольку для них важна совместимость со старым окружением разработки. А если вы пишете проект с закрытым кодом либо узкоспециальное опенсорсное ПО, то можно потребовать от всех разработчиков поставить последнюю версию CMake. Без этого многие советы статьи работать не будут! На момент написания статьи мы имеем CMake 3.8.


cmake_minimum_required(VERSION 3.8 FATAL_ERROR)

Совет №2: не вызывайте ни make, ни make install


Современный CMake умеет сам вызывать систему сборки. В документации CMake такой режим называется Build Tool Mode.


# Переходим из каталога myproj в myproj-build
mkdir ../myproj-build && cd ../myproj-build

# Конфигурируем для сборки из исходников в ../myproj
cmake -DCMAKE_BUILD_TYPE=Release ../myproj

# Запускаем сборку в текущем каталоге
cmake --build .

# Запускаем сборку, передаём ключ '-j4' низлежащей системе сборки.
cmake --build . -- -j4

Если вы генерируете проект Visual Studio, вы также можете собрать его из командной строки, в том числе можно собрать конкретный проект в конкретной конфигурации:


cmake --build . \
    --target myapp \
    --config Release \
    --clean-first

На Linux не используйте make install, иначе вы засорите свою систему. Об этом есть отдельная статья Хочется взять и расстрелять, или ликбез о том, почему не стоит использовать make install

Совет №3: используйте несколько CMakeLists.txt


Вложенность CMakeLists.txt — это нормально. Если ваш проект разделён на 3 библиотеки, 3 набора тестов и 2 приложения, то почему бы не добавить CMakeLists.txt для каждого из них? Тогда вам потребуется создать ещё один центральный CMakeLists.txt, и в нём выполнить add_subdirectory. Так может выглядеть центральный CMakeLists:


cmake_minimum_required(VERSION 3.8 FATAL_ERROR)

project(opengl-samples)

# Лайфхак: объявленные в старшем CMakeLists функции
#  будут видны в подпроектах, включённых через add_subdirectory
include(scripts/functions.cmake)

add_subdirectory(libs/libmath)
add_subdirectory(libs/libplatform)
add_subdirectory(libs/libshade)

# Инструкция enable_testing неявно объявляет опцию BUILD_TESTING,
#  по умолчанию BUILD_TESTING=ON.
# Вызывайте `cmake -DBUILD_TESTING=OFF projectdir` из командной строки,
#  если не хотите собирать тесты.
enable_testing()

if(BUILD_TESTING)
    add_subdirectory(tests)
endif()

# ..остальные цели..

Совет №4: не засоряйте глобальную область видимости


Не заводите глобальных переменных без крайней необходимости. Не используйте link_directories(), include_directories(), add_definitions(), add_compile_options() и другие подобные инструкции.


  • Используйте target_link_libraries для добавления статических и динамических, внутренних и внешних библиотек, от которых зависит цель
  • Используйте target_include_directories вместо include_directories для добавления путей поиска заголовков, от которых зависит цель
  • Используйте target_compile_definitions вместо add_definitions для добавления макросов, с которыми собирается цель
  • Используйте target_compile_options для добавления специфичных флагов компилятора, с которыми собирается цель

# Добавляем цель-библиотеку
add_library(mylibrary \
    ColorDialog.h ColorDialog.cpp \
    ColorPanel.h ColorPanel.cpp)

# ! Осторожно - непереносимый код !
# Добавляем к цели путь поиска заголовков /usr/include/wx-3.0
# Лучше использовать find_package для получения пути к заголовкам.
target_include_directories(mylibrary /usr/include/wx-3.0)

Стоит заметить, что target_link_libraries может добавить пути поиска заголовков библиотеки, если библиотека находится в вашем проекте и к ней были прикреплены пути поиска заголовков через конструкцию target_include_directories(libfoo PUBLIC ...).

Есть пример схемы зависимостей, взятый из презентации Modern CMake / an Introduction за авторством Tobias Becker:


Схема


Совет №5: включите, наконец, C++17 или C++14!


В последние годы стандарт C++ обновляется часто: мы получили потрясающие изменения в C++11, C++14, C++17. Старайтесь по возможности отказаться от старых компиляторов. Например, для Linux ничто не мешает установить последнюю версию Clang и libc++ и начать собирать все проекты со статической компоновкой C++ runtime.


Лучший способ включить C++17 без игры с флагами компиляции — явно сказать CMake, что он вам нужен.


# Способ первый: затребовать от компилятора фичу cxx_std_17
target_compile_features(${TARGET} PUBLIC cxx_std_17)

# Способ второй: указать компилятору на стандарт
set_target_properties(${TARGET} PROPERTIES
    CXX_STANDARD 17
    CXX_STANDARD_REQUIRED YES
    CXX_EXTENSIONS NO
)

С помощью target_compile_features вы можете требовать не C++17 или C++14, а определённых фич со стороны компилятора. Полный список известных CMake фич компиляторов можно посмотреть в документации.


Совет №6: используйте функции


В CMake можно объявлять свои функциональные макросы и свои функции. Есть лишь одно различие между ними: переменные, установленные внутри функции, являются локальными.


Удобно писать функции, чтобы решать текущие проблемы кастомизации сборки либо упрощать добавление множества целей сборки. Пример ниже был написан для более корректного включения C++17 из-за того, что


  • CMake 3.8 ещё не умеет передавать Visual Studio флаг /std:c++latest для включения C++17
  • при использовании std::experimental::filesystem в Clang/libc++ нужно указать компоновщику, что проект надо линковать с libc++experimental.a, поскольку в libc++.a модуля filesystem пока ещё нет; также нужно линковать с pthread, поскольку реализация thread/mutex и т.п. опирается на pthread

# В текущей версии CMake не может включить режим C++17 в некоторых компиляторах.
# Функция использует обходной манёвр.
function(custom_enable_cxx17 TARGET)
    # Включаем C++17 везде, где CMake может.
    target_compile_features(${TARGET} PUBLIC cxx_std_17)
    # Включаем режим C++latest в Visual Studio
    if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
        set_target_properties(${TARGET} PROPERTIES COMPILE_FLAGS "/std:c++latest")
    # Включаем компоновку с libc++, libc++experimental и pthread для Clang
    elseif (CMAKE_CXX_COMPILER_ID MATCHES "Clang")
        set_target_properties(${TARGET} PROPERTIES COMPILE_FLAGS "-stdlib=libc++ -pthread")
        target_link_libraries(${TARGET} c++experimental pthread)
    endif()
endfunction(custom_enable_cxx17)

Каждая функция — это по сути хак, созданный для переопределения языка CMake или его поведения. Для других разработчиков смысл этого хака неясен. Поэтому старайтесь к каждой инструкции в функции добавлять комментарий, объясняющий её цель и смысл.


В крупных открытых проектах, например в KDE, применение своих функций может быть дурным тоном. Вы можете рассмотреть иные варианты: писать скрипт сборки явно по принципу "Explicit is better then implicit", либо даже предложить добавить свою функцию в upstream проекта CMake.

Совет №7 (спорный): не перечисляйте исходные файлы по одному


Мой коллега разрабатывает вне работы маленький 3D движок для рендеринга сцены с моделями и анимациями через OpenGL, GLES, DirectX и Vulkan. Однажды мы с ним обсуждали этот проект, и оказалось, что для сборки под все платформы (Windows, Linux, Android) он использует Visual Studio! Он недоволен тем, что Microsoft редко обновляет Android NDK, но не хочет отказываться от сборки через MSBuild по одной простой причине.


Ему не хочется сопровождать список файлов для сборки в двух системах сборки.


Когда-то я вёл портирование игры с iOS на Android, и мы поддерживали две системы сборки с помощью скрипта, который читал проект XCode и автоматически дополнял список файлов в Android.mk. Если вы используете CMake, то вам даже скрипт не нужно писать.


В CMake есть функция aux_source_directory, но она имеет недостаток: заголовки не добавляются в список и не появляются в любом сгенерированном проекте для IDE.


  • на выручку приходит file(GLOB ...), сканирующий файлы по маске
  • чтобы не замусорить новыми переменными глобальную область видимости, создадим функцию custom_add_executable_from_dir(name)
  • чтобы функция, размещённая в отдельном файле, использовала как точку отсчёта путь к текущему CMakeLists.txt, мы применим переменнуюCMAKE_CURRENT_SOURCE_DIR

function(custom_add_executable_from_dir TARGET)
    # Собираем файлы с текущего каталога
    file(GLOB TARGET_SRC "CMAKE_CURRENT_SOURCE_DIR/*.cpp" 
    # Добавляем исполняемый файл
    add_executable(${TARGET} ${TARGET_SRC})
endfunction()

Вы можете добавить функцию custom_add_library_from_dir для целей-библиотек аналогичным путём.


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


add_library(libfoo Foo.h Foo_common.cpp)
if(WIN32)
    target_sources(libfoo Foo_win32.cpp)
endif(WIN32)

Совет №8: не запускайте утилиты bash, запускайте cmake -E


Наверняка вам хотелось ради автоматизации вызвать из cmake команду Bash, чтобы создать каталог, распаковать архив или подсчитать md5 сумму. Но вызов утилит командной строки может лишить проект кроссплатформенности. Более переносимый метод — вызывать cmake -E команда, пользуясь Command-Line Tool Mode.


Совет №9: оставляйте больше гибкости пользователям своих библиотек


Совет относится к вам, если вы пишете публично доступные библиотеки. В этом случае вам стоит упрощать следующие сценарии:


  • программист хочет добавить вашу библиотеку как подмодуль и включить ваш CMakeLists.txt через add_subdirectory
  • программист хочет сделать кастомизации в сборке вашей библиотеки; наиболее популярные — полностью статическая или динамическая компоновка, сборка с тестами и примерами либо только библиотеки

Добавляя библиотеку, создавайте ещё и уникальный синоним:


# Добавляем цель-библиотеку
add_library(foo ${FOO_SRC})

# Добавляем синоним, содержащий имя выпускающей библиотеку организации
add_library(MyOrg::foo ALIAS foo)

Оставляйте пользователям библиотеки право использования опции BUILD_SHARED_LIBS для выбора между сборкой статической и динамической версий библиотеки.


При установке настроек компоновки, поиска заголовков и флагов компиляции для библиотек используйте ключевые слова PUBLIC, PRIVATE, INTERFACE, чтобы позволить целям, зависящим от вашей библиотеки, наследовать необходимые настройки:


  • PUBLIC означает, что зависящему от библиотеки проекту тоже нужны эти опции
  • PRIVATE означает, что опции нужны лишь для сборки библиотеки
  • INTERFACE означает, что опции не нужны для сборки, но нужны для использования библиотеки

target_link_libraries(foobarapp
    PUBLIC MyOrg::libfoo
    PRIVATE MyOrg::libbar
)

Совет №10: регистрируйте автотесты в CTest


Подсистема CTest не заставляет вас использовать какие-то особые библиотеки для тестирования вместо привычных Boost.Test, Catch или Google Tests. Она всего лишь регистрирует автотесты так, чтобы CMake мог запустить все тесты или выбранные тесты одной командой ctest.


Чтобы включить поддержку CTest по всему проекту, есть инструкция enable_testing


# Инструкция enable_testing неявно объявляет опцию BUILD_TESTING,
#  по умолчанию BUILD_TESTING=ON.
# Вызывайте `cmake -DBUILD_TESTING=OFF projectdir` из командной строки,
#  если не хотите собирать тесты.
enable_testing()

if(BUILD_TESTING)
    add_subdirectory(tests/libhellotest)
    add_subdirectory(tests/libgoodbyetest)
endif()

Чтобы исполняемый файл с тестом был зарегистирован в CTest, нужно вызвать инструкцию add_test.


# Исполняемый файл теста - это обычная исполняемая цель сборки
add_executable(${TARGET} ${TARGET_SRC})

# Регистрируем исполняемый файл в CMake как набор тестов.
#  можно назначить тесту особое имя, но проще использовать имя исполняемого файла теста.
add_test(${TARGET} ${TARGET})

Что ещё почитать?


Если для своего OpenSource проекта вы настроили CMake и CTest, дальше можете подключить непрерывную интеграцию: Непрерывная интеграция (CI) для GitHub проектов на С/C++ с CMake-сборкой


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



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

Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 27

    +3

    Что тут можно сказать. Я вообще на С++ особенно не пишу, но когда пишу, делаю сборку на CMake. И многое из того, что я нашёл в статье я очень рад узнать, спасибо. Такие вот простые вещи, как cmake --build, например.

      0
      Вот по cmake --build. В чем проблема вызова make руками?
        +1

        На винде нет make, при генерации проекта указываешь -G"Visual Studio 15 2017", а при вызове cmake --build делает чорную магию. Удобно, когда пишешь универсальные скрипты. Пример

          +4

          В том, что в одном случае это make, в другом mingw32-make, а в третьем вообще nmake :).

            +1

            + ninja, довольно популярная сейчас.

        +2

        Про make install я всё же поспорю. Безусловно, если собирается какая-то программа, которой нет в репозитории, её стоит паковать checkinstall'ом (при отсутствии поддержки правильной сборки в формат дистрибутива). Но нередка ситуация, когда нужно пересобрать уже имеющуюся в системе библиотеку с другими флагами или вообще нужна более новая версия. Я для себя выбрал два подхода:
        1) конфигурим с префиксом /opt/build/%%softwarename%% (например, /opt/build/ffmpeg), на весь /opt ставим владельцем основного юзера, тем более, этот каталог принадлежит корневой системе, которая на SSD. Ставим через make install, без sudo. Далее по необходимости можно использовать при сборке PKG_CONFIG_PATH, чтобы найти именно эту библиотеку, либо специфичные настройки для модулей CMake (типа OPENSSL_ROOT_DIR). Из плюсов — удаление сводится к удалению каталога, т.к. мы ставили из-под юзера, в какие-либо системные файлы собранное прописаться чисто физически не могло, из минусов — каждую такую библиотеку надо указывать отдельно;
        2) мне часто нужно собирать как можно более статичный бинарник, чтобы его можно было закинуть на целевой сервер или ПК коллеги, и он бы там просто работал без доустановок кучи библиотек, а размер бинаря значения обычно не имеет. Для этого конфигурим с префиксом /opt/build/static и точно так же make install'им из-под юзера. Получается такой второй рут со сборной солянкой из библиотек и софта, плюсы и минусы, соответственно, обратны первому варианту.


        Вообще, проблема сборки статических бинарников освещена довольно слабо, а хитростей там много — например, слишком старая версия libstdc++ (на Ubuntu 14.04), ненайденные приложенные библиотеки, которые не получается слинковать статически, из-за отсутствующего RPATH (это вообще странная тема для CMake, ставится абсолютно неинтуитивно), ненайденные символы из libdl и libpthread, последний вообще может быть слинкован и статически, и динамически одновременно, что и приводит к таким ошибкам и т.д. Конкретно CMake не очень любит библиотеки .a и предпочитает им .so, если есть оба варианта. В целом, форсировать .a можно, но не кроссплатформенно, т.к. расширение различное на WIn и Mac, а какой-то единой рукоятки «линкуй всё статически» почему-то или не завезли, или я её не нашёл, или она работает по принципу «всё или ничего», а это не всегда возможно, например, PulseAudio уж точно статически линковать не стоит, равно как и библиотеки из Xorg. Пока оптимальный вариант — задавать этот /opt/build/static/lib через link_directories, и если там не хватает нужной .a библиотеки, можно просто скопировать или слинковать её из основной системы.


        Ещё бы хотелось почитать про управление зависимостями уровня Maven, но я ничего живого кроме conan.io не нашёл, да и тот что-то не сильно упрощает сборку, если не наоборот. Сам CMake, вроде бы, позволяет подключать git-репозитории как зависимости, но я в эту тему не углублялся. Вообще, похоже, для C/C++ проектов у CMake сейчас альтернатив не видно, хотя до уже упомянутого Maven ещё идти и идти, к сожалению.

          +1
          Приходится использовать CMake, но очень уж убогий и неудобный у него синтаксис. В этом плане мне гораздо больше нравится SCons.
            0
            Я пробовал разобраться в SCons, но так мануалы и не осилил, оказалось быстрее на чистом питоне скрипт написать.
              +1
              Да, с качественной документацией у него действительно проблема. Но после того, как разобрался во всех необходимых тонкостях, для моих целей вполне неплохо.
                +1
                Тогда нужен пост на эту тему!
              +1
              Думаю, что было бы здорово, если бы кто-то написал подобную статью про SCons.
              Сам им не владею.

              P.S. Проекты собираю по старинке с помощью GNU Make/QMake.
              0
              Хороший доклад на руcском языке про cmake:
              https://www.youtube.com/watch?v=ckO98bRzL9Y
                0
                На самом деле, только основы послушать. Я пробежался по кадрам, cmake там используется именно тот, что они пишут в минимальной версии — 2.8 Это очень старо, начиная с 3 версии добавилось много киллер фич, поэтому их и нужно использовать. Все, о чем говорится в статье, кроме target_compile_features, добавилось с версии 3.0 (а target_compile_features добавилось в 3.1).

                А вообще, мой опыт использования открытых библиотек Яндекса говорит о том, что сборочные файлы cmake они пишут откровенно плохо.
                +2

                Не освещены такие крутые штуки CMake как ExternalProject, ExternalData, custom_target, CMakeConfig (find_package) и т. д. и т. п. CMake очень спорный инструмент, с одной стороны вызывает восхищение его возможности, а с другой отвращение его перегруженность, уродливость и нелогичность. Без документации я не могу запомнить как пользоваться его стандартными функциями set, list, string, и т. д. У каждой несколько сигнатур, и я каждый раз лезу в документацию, чтобы выяснить как писать "name value CACHE INTERNAL comment" или "name value comment INTERNAL CACHE".


                include(scripts/functions.cmake)

                Можно использовать CMAKE_MODULE_PATH и тогда можно не писать путь в include:


                set(CMAKE_MODULE_PATH scripts) 
                include(functions)

                Совет №6: используйте функции

                Там ещё есть парсер аргументов функции: модуль CMakeParseArguments :)


                Совет №7 (спорный): не перечисляйте исходные файлы по одному

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


                В крупных открытых проектах, например в KDE, применение своих функций может быть дурным тоном.

                Вот уж нет, любой крупный проект, использующий CMake, обрастает невероятным количеством функций, макросов и костылей. Посмотрите на Insight Toolkit. Это как раз тот проект для сборки которого CMake изначально и создавался. Там ад, в системе сборки разобраться сложнее чем в самом проекте.

                  +1
                  С вашего позволения, хотел бы добавить пару (возможно, спорных) советов от себя.

                  1) Откройте для себя наконец Ninja (cmake -G Ninja ..). Скорость сборки возрастает существенно.
                  2) По возможности распиливайте проект на библиотеки и тяните их через git submodules.
                  3) Если тест можно написать на Python, пишите на Python.
                  4) И вообще раз заговорили про C++: сразу прикручивайте clang-format, clang static analyzer, cppcheck и valgrind, мерьте степень покрытия кода с помощью lcov.

                  У себя в бложике я описывал эти моменты более подробно.
                    +1
                    1) Откройте для себя наконец Ninja (cmake -G Ninja ..). Скорость сборки возрастает существенно.

                    У нас несколько лет проекту, поэтому в CMakeLists.txt понаписано уже. Поэтому просто использовав "-G Ninja" получаем на выходе довольно невнятные ошибки об отсутствии правил. Так и живем с make -j 8 :)

                      0

                      UPD: Хм, ничего в скриптах не менял. Но на машине с Fedora 26, где последние cmake и ninja, всё вдруг само заработало.
                      Ninja действительно дает выигрыш, но не сильно большой. ~20 секунд на ~4 минутах сборки всего проекта. Но у нас там CPU — узкое место (шаблонов много...).

                      0

                      С git submodules одна загвоздка… (может, недорыл, а может так оно и есть) — всё получается уж больно независимо.
                      Есть прога foo версии 4, собирается с подмодулем библиотеки bar версии 3.
                      А при этом старая версия foo версии 2 требует bar версии 1 и с более свежими версиями не собирается.


                      • Как это связано в подмодулях? Если я зачекаутю foo на версию 2 — зачекаутится ли bar и другие подмодули автоматически до тех самых совместимых версий, или же нужно где-то хранить граф этих зависимостей и вручную по нему ходить?
                      0

                      При желании можно сократить заклинание конфигурирования и сборки до двух:
                      cmake -B%папка_для_сборки% -H%папка_с_исходниками% cmake --build %папка_для_сборки% --config %конфигурация_сборки%


                      , например:
                      cmake -Bbuild -H. cmake --build build --config Debug

                        0

                        Это две команды последовательно выполнять? Почему тогда не на разных строках?
                        И почему директория для сборки два раза?

                          0

                          Поехал перенос строк.


                          cmake -B %папка_для_сборки% -H%папка_с_исходниками%
                          cmake --build %папка_для_сборки% --config %конфигурация_сборки%

                          Пример:


                          cmake -B build/debug -H.
                          cmake --build build/debug --config Debug
                            0

                            Про перенос понятно. Вопрос зачем два раза указывать директорию для сборки всё ещё открыт :).

                              0

                              Так в первый раз указываешь куда сгенерировать всякие сборочные файлы и прочий мусор. А второй начинается акт сборки на основании сгенерированных файлов.
                              Можно так сделать:


                              cmake -B build/debug -H.
                              cmake -B build/release -H.
                              cmake --build build/debug --config Debug
                              cmake --build build/release --config Release
                                0

                                ну, возможно кому-то проще и cmake туда рулить.
                                Но лично мне кажется, что лучше создать целевую папку, перейти в неё — и дальше без всяких дополнительных ключей, там и собирать.


                                Я лучше оформлю это в одну единицу и вызову для debug и release. А если понадобится добавить ещё и superdebug — добавлю эту самую одну строчку.
                                А так, как вы привели в пример — это нужно "расчёску" добавлять, и ещё и нигде не ошибиться.

                                  0

                                  Это вопрос фломастеров. Лично я стараюсь избегать состояний. В моём восприятии смена текущей папки — состояние.

                        0
                        add_test — хороший совет. лайк.
                          +1

                          Для сборки в несколько потоков еще можно использовать cmake --build --parallel.


                          Мало где упоминается одна из важнейших возможностей CMake — INTERFACE targets, которые могут служить своеобразным контейнером для свойств сборки (путей поиска заголовочных файлов, флагов компиляции, и пр.):


                          add_library(super INTERFACE)
                          target_link_directories(super INTERFACE <dirs>)
                          target_compile_options(super INTERFACE <opts>)
                          
                          # $A inherits properties from $super
                          add_library(A <srcs>)
                          target_link_libraries(A PRIVATE super)

                          Что касается перечисления файлов с исходным кодом с помощью file(GLOB), я сталкивался с двумя проблемами:


                          1. Очевидна необходимость следить за актуальностью проекта вручную (эта проблема вроде решается с file(GLOB <pattern> CONFIGURE_DEPENDS) с CMake 3.12).
                          2. file(GLOB) не сортирует перечисляемые файлы, что может привести к невоспроизводимым сборкам (эта проблема вроде решена встроенной сортировкой файлов по именам с CMake 3.6), если не сортировать полученный список файлов вручную c list(SORT).

                          Only users with full accounts can post comments. Log in, please.