Лабораторная работа 0.4 для студентов курса “Основы программирования” 1 курса кафедры ИУ5 МГТУ им Н.Э. Баумана.
Познакомиться с системами сборки для приложений на C++. Научиться базово собирать приложение с использованием CMake.
В данной лабораторной работе будут покрыты следующие темы:
В текущий момент, при выполнении лабораторных работ сборка самого приложения осуществляется через запуск компилятора вручную, с подачей ему соответствующих флагов и прочих аргументов.
В реальности проекты могут состоять из сотен и тысяч файлов, организованных в сложную иерархию директорий. Сами эти файлы могут компилироваться не в один исполняемый файл (т.н. цель), а в множество разных. В частности, это могут быть не только исполняемые файлы, но и библиотеки. Каждая из целей может требовать собственный уникальный (или частично уникальный) набор файлов, флагов, предварительный запуск каких-то скриптов (например, для кодогенерации) и т.п.
Помимо этого, абсолютное большинство крупных проектов полагаются на большое количество разнообразных зависимостей - разного вида библиотек, реализующих тот или иной функционал, которые разрабатываются другими людьми и командами.
Также, в средних и больших проектах стоит проблема времени компиляции. Разработка приложений, обычно, осуществляется инкрементально. Т.е. за раз изменяется/добавляется небольшое количество файлов с кодом. Таким образом, нет необходимости заново компилировать те файлы, которые не были затронуты изменениями. Однако, при запуске компилятора вручную, все файлы компилируются заново, независимо от того, менялись они или нет. Это может привести к длительной компиляции, которая может исчисляться десятками минут и даже часами.
Чтобы решить эти и другие проблемы существуют системы сборки.
Исторически сложилось, что C++ не имеет какой-то дефолтной инфраструктуры: одного конкретного компилятора, системы сборки, менеджера пакетов (зависимостей), линтеров и т.п. Поэтому существует множество разных решений.
Системы сборки делятся на низкоуровневые и высокоуровневые. Низкоуровневые (такие как Make, Ninja) зависят от конкретной операционной системы и конфигурируются по-разному в зависимости от компиляторов и прочих инструментов. Высокоуровневые же предоставляют дополнительный уровень абстракции, который позволяет упростить написание конфигов для кроссплатформенной сборки, и используют низкоуровневые системы сборки для своей работы.
На данном курсе используется система сборки CMake - одна из наиболее распространенных высокоуровневых систем сборки для C++. Конфиг файлы для CMake пишутся на собственном скриптовом языке.
CMake использует регистронезависимый скриптовый язык. Это значит, что команды можно писать как в нижнем, так и в верхнем регистре.
Проекты, использующие CMake, содержат один или несколько конфиг файлов, которые всегда имеют одно и то же название: CMakeLists.txt. Внутри одной директории находится только один конфиг файл. Самый основной находится прямо в корне проекта. При необходимости (когда нужно делить проект на отдельные модули, где описывается своя логика сборки) заводятся дополнительные конфиг файлы во вложенных директориях.
Прежде чем продолжать выполнение работы, убедитесь, что у вас установлен CMake как минимум версии 3.20. Также рекомендуется установить расширения CMake и CMake Tools для VS Code.
Для примера создадим проект Cmake-Example в VS Code со следующим содержимым:
Cmake-Example/
├── CMakeLists.txt
└── main.cpp
Содержимое файла main.cpp не имеет значение. Предположим, это будет обычный “Hello, World!”.
Вставьте в CMakeLists.txt следующее (с # начинаются комментарии):
# указывает минимальную версию CMake
cmake_minimum_required(VERSION 3.20)
# название проекта
project(Example)
# указание на то, что надо создать исполняемый файл с названием Example (название цели), на вход компилятору будет подан файл main.cpp
add_executable(Example main.cpp)
# для цели Example будет использован двадцатый стандарт
set_property(TARGET Example PROPERTY CXX_STANDARD 20)
Для сборки проекта, как правило, заводят отдельную директорию. Обычно ее называют build (или схожим образом).
mkdir build
cd build
Прежде чем собрать сам проект непосредственно, нужно сгенерировать файлы, необходимые для сборки. Файлы обычно генерируются один раз. Но если в конфиг файл были внесены изменения, то их необходимо сгенерировать заново. Для этого в директории build:
cmake ..
Данная команда сгенерирует файлы, нужные для сборки проекта, используя конфиг файл, находящийся в директории выше (в данном примере в директории Cmake-Example).
Чтобы собрать сам проект, необходимо ввести следующую команду:
cmake --build .
Команда соберет все цели, указанные во всех конфигах в текущую директорию. Цели, указанные в конфиг файлах во вложенных директориях, будут собраны с сохранением исходной иерархии.
В данном примере будет собран исполняемый файл Example. Данную команду нужно вводить каждый раз, если нужно заново собрать цель с учетом изменений в исходном коде. При этом перекомпилируются только те файлы, которые были изменены (или были затронуты изменениями, например, внесенными в заголовочные файлы).
Исполняемый файл Example, находящийся в директории build, можно запустить как обычно.
Документация - https://cmake.org/
При написании конфиг файлов следует руководствоваться теми же соображениями, что и при написании кода. Это активное использование переменных, разделение на семантические блоки и на отдельные модули в разные конфиги, использование новых команд (по отношению к старым), указание минимально возможного скоупа для команд (опция PRIVATE в соответствующих командах) и т.д.
CMake имеет множество встроенных переменных, значения которых зависят от окружения (операционная система, установленные инструменты и т.п.) и конфигурации сборки. Также можно заводить собственные переменные (строковые, булевые и т.п.).
Значения встроенных переменных можно менять вручную, но по возможности это не рекомендуется. Вместо этого следует использовать отдельные команды, которые сами поменяют/дополнят содержимое этих переменных.
Например, вместо следующей команды:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -Werror -Wpedantic")
которая дополнит переменную, содержащую флаги для компилятора новыми флагами, следует использовать другую команду:
target_compile_options(Example PRIVATE -Wall -Wextra -Werror -Wpedantic)
Данная команда добавит эти же флаги компиляции только для указанной цели Example с приватным скоупом.
Для указания файлов с исходным кодом рекомендуется использовать отдельные переменные, в которых перечисляются эти файлы. Часто их называют SOURCES и HEADERS для .cpp и заголовочных файлов соответственно. При наличии множества целей используются более специфичные названия.
Категорически не рекомендуется использовать команду file (https://cmake.org/cmake/help/latest/command/file.html) для формирования списка исходных файлов, т.к. это затрудняет разделение файлов по модулям между разными целями для больших проектов, скрывает конкретный список исходных файлов, может привести к проблемам с работой в тех IDE, которые полагаются на CMake для умной подсветки синтаксиса (например, CLion).
Пример простого конфига с учетом best practices:
cmake_minimum_required(VERSION 3.20)
project(Example)
# файлы перечисляются либо через пустую строку, либо через пробел
set(SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/main.cpp
${CMAKE_CURRENT_SOURCE_DIR}/File2.cpp
${CMAKE_CURRENT_SOURCE_DIR}/File3.cpp
)
set(HEADERS
${CMAKE_CURRENT_SOURCE_DIR}/Header1.hpp
${CMAKE_CURRENT_SOURCE_DIR}/Header2.hpp
)
# новая цель с названием аналогичным названию проекта
add_executable(${CMAKE_PROJECT_NAME} ${SOURCES} ${HEADERS})
set_property(TARGET ${CMAKE_PROJECT_NAME} PROPERTY CXX_STANDARD 20)
target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE -Wall -Wextra -Werror -Wpedantic)
CMake предоставляет встроенную поддержку clang-tidy. Для автоматического запуска при каждой сборке в конфиг нужно добавить следующую команду:
set(CMAKE_CXX_CLANG_TIDY "clang-tidy")
https://cmake.org/cmake/help/latest/variable/CMAKE_LANG_CLANG_TIDY.html
Clang-tidy будет запускаться каждый раз при сборке приложения только на измененных файлах, используя конфиг в корне проекта. Учтите, что это может увеличить время сборки.
CMake позволяет добавлять собственные цели, допускающие любые команды, запускающиеся в консоли. Для этого используется команда add_custom_target.
Так можно добавить кастомную цель для запуска clang-format (имя цели тоже будет clang-format):
add_custom_target(
clang-format
COMMAND clang-format
--dry-run
-Werror
${SOURCES}
${HEADERS}
)
Чтобы запустить кастомную цель, необходимо в консоли явно указать ее при сборке. Для данного примера:
cmake --build . --target clang-format
Для работы дебаггера необходимы дебажные символы, добавляемые компилятором при указании флага -g (для gcc и clang). Добавлять этот флаг без намерения дебажить приложение нецелесообразно. Поэтому можно использовать CMake для настраиваемой сборки: в случае необходимости, флаг будет добавлен, по умолчанию же он будет отключен.
Для этого в CMakeLists.txt добавим следующее:
if(ENABLE_DEBUG)
target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE -g)
endif()
Тут используется кастомный флаг ENABLE_DEBUG (название может быть почти любым). По умолчанию он не определен, а значит его значение - False.
Теперь при генерации файлов сборки необходимо подать этот флаг. Для удобства (чтобы не генерировать файлы сборки каждый раз заново при необходимости дебажить приложение) можно завести отдельную директорию, где будет собираться версия приложения для дебага, например build-debug.
Находясь в этой директории, нужно ввести следующую команду в консоль:
cmake -DENABLE_DEBUG=True ..
Где после -D идет название флага (или любой другой нужной переменной) и устанавливается ее значение в True (если бы это была строковая переменная, то передали бы строку и т.п.).
После этого можно собрать приложение как обычно. В него компилятором будут добавлены дебажные символы.
При использовании дебаггера в VS Code, не забудьте указать правильный путь к приложению в соответствующем конфиге в launch.json.
cmake_minimum_required(VERSION 3.20)
project(Example)
set(CMAKE_CXX_CLANG_TIDY "clang-tidy")
set(SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/main.cpp
${CMAKE_CURRENT_SOURCE_DIR}/File2.cpp
${CMAKE_CURRENT_SOURCE_DIR}/File3.cpp
)
set(HEADERS
${CMAKE_CURRENT_SOURCE_DIR}/Header1.hpp
${CMAKE_CURRENT_SOURCE_DIR}/Header2.hpp
)
add_executable(${CMAKE_PROJECT_NAME} ${SOURCES} ${HEADERS})
set_property(TARGET ${CMAKE_PROJECT_NAME} PROPERTY CXX_STANDARD 20)
target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE -Wall -Wextra -Werror -Wpedantic)
if(ENABLE_DEBUG)
target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE -g)
endif()
add_custom_target(
clang-format
COMMAND clang-format
--dry-run
-Werror
${SOURCES}
${HEADERS}
)