Что такое CMake
CMake - это набор кроссплатформенных инструментов с открытым исходным кодом, используемых для сборки и распространения программного обеспечения. В последние годы он стал стандартом де-факто для разработки приложений на C и C++, поэтому пришло время для легкой вводной статьи на эту тему. В следующих параграфах мы разберемся, что представляет собой CMake, какова его философия и как с его помощью создать демо-приложение с нуля.
CMake известен как система мета-сборки. На самом деле она не собирает
ваш исходный код: вместо этого она генерирует файлы проекта для целевой платформы. Например, CMake под Windows создаст проект для Visual Studio; CMake под Linux создаст Makefile; CMake под macOS - проект для XCode и так далее. Вот что означает слово meta: CMake создает системы сборки.
Проект, основанный на CMake, всегда содержит файл CMakeLists.txt.
Этот специальный текстовый файл описывает структуру проекта, список
исходных файлов для компиляции, что CMake должен сгенерировать из них и так далее. CMake читает содержащиеся в нем инструкции и выдает нужный результат. Этим занимаются так называемые генераторы - компоненты CMake, отвечающие за создание файлов для системы сборки под конкретную платформу.
Еще одна приятная возможность CMake - это так называемая сборка вне
исходных текстов. Любой файл, необходимый для окончательной сборки,
включая исполняемые файлы, будет храниться в отдельном от исходного кода каталоге сборки (обычно он называется build). Это позволяет не
загромождать каталог исходного кода и легко начинать работу заново:
просто удалите каталог сборки, и все готово.
Демо-проект для работы
Для этого введения я буду использовать фиктивный проект на C++, состоящий из нескольких исходных файлов:
Чтобы было интереснее, позже я добавлю в проект внешнюю зависимость и некоторые параметры, которые будут передаваться на этапе сборки для
выполнения условной компиляции. Но сначала давайте добавим файл
CMakeLists.txt и напишем в нем что-нибудь осмысленное.
Понимание файла CMakeLists.txt
Современный файл CMakeLists.txt представляет собой коллекцию целей и
свойств. Цель - это задание процесса сборки или, другими словами,
желаемый результат. В нашем примере мы хотим собрать исходный код в
бинарный исполняемый файл: это цель. Цели имеют свойства: например,
исходные файлы, необходимые для компиляции исполняемого файла, параметры компилятора, зависимости и так далее. В CMake вы определяете цели, а затем добавляете к ним необходимые свойства.
Начнем с создания файла CMakeLists.txt в каталоге проекта, вне каталога src. Папка будет выглядеть следующим образом:
Затем откройте файл CMakeLists.txt в выбранном вами редакторе и начните его редактировать.
Определите версию CMake
Файл CMakeLists.txt всегда начинается с команды
cmake_minimum_required(), которая определяет версию CMake, необходимую для текущего проекта. Она должна быть первой в файле CMakeLists.txt и выглядит следующим образом:
где <version-number> - нужная версия CMake, с которой вы хотите
работать. Современный CMake начинается с версии 3.31 и далее: общее
правило - использовать версию CMake, вышедшую после вашего компилятора, поскольку для этой версии необходимо знать флаги компилятора и т. д. Генерация проекта с CMake более старой версии, чем требуемая, приведет к появлению сообщения об ошибке.
Задайте имя проекта
Второе указание, которое должен содержать файл CMakeLists.txt, - это
имя проекта, определяемое командой project(). Эта команда может
принимать множество параметров, таких как номер версии, описание, URL
домашней страницы и многое другое. Полный список доступен на странице документации CMake. Довольно полезным является язык программирования, на котором написан проект, задаваемый с помощью флага LANGUAGES. Так в нашем примере:
где CXX означает C++.
Определение цели исполняемого файла
Сейчас мы добавим нашу первую цель CMake: исполняемый файл.
Определяется командой add_executable() и указывает CMake на создание
исполняемого файла из списка исходных файлов. Предположим, мы хотим
назвать его myApp, команда будет выглядеть следующим образом:
CMake достаточно умен, чтобы построить имя файла в соответствии с
соглашениями целевой платформы: myApp.exe в Windows, myApp в macOS и Linux и так далее.
Установите некоторые свойства цели
Как уже говорилось, у целей есть свойства. Они задаются с помощью
ряда команд, начинающихся с префикса target_. Эти команды также требуют от вас определения области применения: как свойства должны
распространяться при включении проекта в другие родительские проекты,
основанные на CMake. Поскольку мы работаем над бинарным исполняемым файлом (а не библиотекой), никто не будет включать его куда-либо, поэтому мы можем придерживаться стандартной области видимости под названием PRIVATE. В будущем я, вероятно, напишу еще одну статью о библиотеках CMake, чтобы полностью раскрыть эту тему.
Установите используемый стандарт C++
Предположим, что наш фиктивный проект написан на C++20: нам нужно
указать компилятору действовать соответствующим образом. Например, в
Linux это означает передачу в GCC флага -std=c++20. CMake позаботится об
этом, установив свойство для цели myApp с помощью команды
target_compile_features(), как показано ниже:
Полный список доступных функций компилятора C++ можно найти здесь.
Установка некоторых жестко заданных флагов препроцессора
Предположим, что наш проект хочет, чтобы некоторые дополнительные
флаги препроцессора были определены заранее, например
USE_NEW_AUDIO_ENGINE. Для этого нужно задать еще одно свойство цели с
помощью команды target_compile_definitions():
Команда сама позаботится о добавлении части -D, если это потребуется, так что в этом нет необходимости.
Установка параметров компилятора
Включение предупреждений компилятора считается хорошей практикой. В GCC я обычно использую распространенную тройку -Wall -Wextra -Wpedantic. Такие флаги компилятора устанавливаются в CMake с помощью команды target_compile_options():
Однако это не на 100% переносимо. Например, в Visual Studio от
Microsoft флаги компилятора передаются совершенно по-другому. Текущая
настройка отлично работает в Linux, но она все еще не
кроссплатформенная: мы рассмотрим, как улучшить ее в нескольких
параграфах.
Запуск CMake для сборки проекта
На данный момент проект уже готов к сборке. Самый простой способ
сделать это - вызвать исполняемый файл cmake из командной строки: именно этим я и займусь в оставшейся части этой статьи. Также доступен
графический мастер, который обычно является предпочтительным способом в Windows: официальная документация подробно описывает его. На Unix также доступен ccmake: то же самое, что и в Windows, но с текстовым интерфейсом.
Как уже говорилось, CMake поддерживает сборку вне исходных текстов,
поэтому первым делом создайте каталог - иногда его называют build, но
название вы выбираете сами - и войдите в него. Теперь папка проекта
будет выглядеть следующим образом:
Из новой папки вызовите CMake следующим образом:
Это даст указание CMake прочитать файл CMakeLists.txt, расположенный в
родительском каталоге, обработать его и вывести результат в текущий
каталог, т.е. build. После завершения работы вы найдете сгенерированные
файлы проекта. Например, если я запускаю CMake в Linux, то в каталоге
build будет находиться Makefile, готовый к запуску.
Добавьте поддержку мультиплатформы: Linux, Windows и macOS
CMake дает вам возможность определить, на какой платформе вы
работаете, и действовать соответствующим образом. Это делается путем
проверки CMAKE_SYSTEM_NAME, одной из многих переменных, которые CMake определяет внутренне. CMake также поддерживает условные конструкции, то есть обычные комбинации if-then-else. С этими инструментами задача становится довольно простой. Например, предположим, что мы хотим исправить проблему переносимости, которая была раньше с опциями компилятора:
Обратите внимание, что STREQUAL - это способ CMake для сравнения строк. Список возможных значений CMAKE_SYSTEM_NAME доступен здесь.
Вы также можете проверить наличие дополнительной информации, такой как версия операционной системы, имя процессора и так далее: полный список здесь.
Передача переменных в CMake при вызове в командной строке
В нашей текущей конфигурации мы имеем жестко закодированное
определение препроцессора: USE_NEW_AUDIO_ENGINE. Почему бы не дать
пользователям возможность включать его опционально при вызове CMake? Это можно сделать, добавив команду option() в любое место файла
CMakeLists.txt. Синтаксис следующий:
Необязательный параметр [value] может быть ON или OFF. Если значение
опущено, используется значение OFF. Вот как это будет выглядеть в нашем
фиктивном проекте:
Чтобы воспользоваться этим, просто запустите CMake следующим образом:
или
Таким же образом передаются внутренние переменные CMake и другие параметры. В более общем случае:
Отладочные и релизные сборки
Иногда для тестирования требуется собрать исполняемый файл с
отладочной информацией и отключенными оптимизациями. В других случаях вполне достаточно оптимизированной сборки, готовой к выпуску. CMake поддерживает следующие типы сборок:
- Debug - отладочная информация включена, без оптимизации;
- Release - без отладочной информации и с полной оптимизацией;
- RelWithDebInfo - то же, что и Release, но с отладочной информацией;
- MinSizeRel - специальная сборка Release, оптимизированная по размеру.
То, как обрабатываются типы сборок, зависит от используемого
генератора. Некоторые генераторы являются многоконфигурационными
(например, Visual Studio Generators): они включают все конфигурации
сразу, и вы можете выбрать их из вашей IDE.
Некоторые генераторы являются одноконфигурационными (например,
Makefile Generators): они генерируют один выходной файл (например, один Makefile) для каждого типа сборки. Таким образом, вы должны указать CMake генерировать определенную конфигурацию, передав переменную CMAKE_BUILD_TYPE. Например:
В этом случае полезно иметь несколько директорий сборки, по одной для
каждой конфигурации: build/debug/, build/release/ и так далее.
Управление зависимостями
Реальные программы часто зависят от внешних библиотек, но C++ все еще
не хватает хорошего менеджера пакетов. К счастью, CMake может помочь во многих отношениях. Ниже приведен краткий обзор команд, доступных в CMake для управления зависимостями, при условии, что наш проект зависит от SDL (кроссплатформенной мультимедийной библиотеки).
команда find_library()
Идея заключается в том, чтобы заставить CMake искать в системе нужную
библиотеку и, если она найдена, связать ее с исполняемым файлом. Поиск
осуществляется командой find_library(): она принимает имя библиотеки,
которую нужно искать, и переменную, которая будет заполнена путем к
библиотеке, если она найдена. Например:
Затем вы проверяете корректность LIBRARY_SDL и передаете ее в
target_link_libraries(). Эта команда используется для указания библиотек
или флагов, которые нужно использовать при компоновке конечного
исполняемого файла. Примерно так:
Обратите внимание на использование синтаксиса ${...} для захвата
содержимого переменной и использования его в качестве параметра команды.
команда find_package()
Команда find_package() - это как find_library() на стероидах. С
помощью этой команды вы используете специальные модули CMake, которые помогают в поиске различных известных библиотек и пакетов. Такие модули предоставляются авторами библиотек или самим CMake (а также вы можете написать свои собственные). Список доступных модулей на вашей машине можно посмотреть, выполнив команду cmake --help-module-list. Модули, начинающиеся с префикса Find, используются командой find_package() для своей работы. Например, версия CMake, на которую мы ориентируемся, поставляется с модулем FindSDL, поэтому его нужно вызвать следующим образом:
Где SDL - переменная, определяющая вызываемый модуль FindSDL. Если
библиотека найдена, модуль определит дополнительные переменные SDL_FOUND и SDL_LIBRARIES (специфичные для каждого модуля), которые будут использоваться в вашем сценарии CMake.
Если есть возможность, всегда отдавайте предпочтение этому методу, а не find_library().
Модуль ExternalProject
Две предыдущие команды предполагают, что библиотека уже доступна и
скомпилирована где-то в вашей системе. Модуль ExternalProject использует другой подход: он загружает, собирает и подготавливает библиотеку для использования в вашем проекте CMake. ExternalProject также может взаимодействовать с популярными системами контроля версий, такими как Git, Mercurial и так далее. По умолчанию он предполагает, что зависимость является проектом CMake, но при необходимости вы можете легко передать пользовательские инструкции по сборке. Использование этого модуля заключается в вызове команды
примерно так:
Это позволит загрузить исходный код SDL из репозитория GitHub,
запустить на нем CMake (SDL поддерживается CMake) и затем собрать его в
библиотеку, готовую к линковке. По умолчанию артефакты будут храниться в каталоге сборки.
Следует иметь в виду, что шаг загрузки выполняется на этапе сборки
проекта (например, при вызове make в Linux), поэтому CMake не знает о
наличии библиотеки при генерации проекта (т.е. при вызове команды
cmake). Следствием этого является то, что вы не можете получить
правильный путь и флаги для этой библиотеки с помощью таких команд, как find_library() или find_package(). Один из способов решения -
предположить, что зависимость уже существует, потому что рано или поздно она будет загружена и собрана, поэтому вы просто передаете ее полный путь в target_link_libraries() вместо переменной, как мы делали раньше.
Модуль FetchContent (CMake 3.14+)
Этот модуль основан на предыдущем. Разница в том, что FetchContent
загружает исходный код заранее при генерации проекта (т.е. при вызове
команды cmake). Это позволяет CMake узнать, что зависимость существует, и рассматривать ее как дочерний проект. Типичное использование выглядит следующим образом:
Другими словами: сначала вы объявляете, что хотите загрузить, с
помощью FetchContent_Declare, а затем включаете зависимость с помощью FetchContent_MakeAvailable, чтобы настроить ваш проект на нужную библиотеку. Зависимость будет автоматически сконфигурирована и скомпилирована при сборке конечного исполняемого файла, перед линковкой.
Модуль FetchContent предполагает что включаемая зависимость
поддерживается CMake, и если это так, то включить ее в проект так же
просто, как показано выше. В противном случае вам нужно явно указать
CMake, как ее компилировать, например, с помощью команды
add_custom_target(). Подробнее об этом в следующих выпусках.
Как видите, в CMake существует несколько способов работы с внешними
зависимостями. Выбор правильного зависит от вашего вкуса и требований к проекту. CMake также может напрямую взаимодействовать с внешними
менеджерами пакетов, такими как Vcpkg, Conan и git-submodules.
Заключение
В этой статье я затронул лишь поверхность огромной вселенной CMake,
но есть масса интересных возможностей, которые заслуживают упоминания: макросы и функции для написания многократно используемых блоков кода CMake; переменные и списки, полезные для хранения и манипулирования данными; генераторные выражения для определения сложных свойств, специфичных для каждого генератора; поддержка тестов непрерывной интеграции с помощью ctest и многое другое ...