CMake入门
CMake学习记录
1. 摘要与目标
CMake 是 C/C++ 工程中常见的构建配置工具。它本身不负责把源码编译成机器码,而是读取 CMakeLists.txt,根据项目配置生成 Makefile、Ninja 文件或 IDE 工程文件,再由 make、ninja 或 cmake --build 调用编译器完成构建。
这篇文章按 CMake 入门教程常见的讲解顺序展开:先说明 CMake 的作用,再讲 CMakeLists.txt 的基础命令,然后逐步介绍注释、构建目录、变量、搜索源文件、包含头文件、查找和链接库文件、日志、变量操作、宏定义和预定义变量。
阅读这篇文章需要了解 C/C++ 的基本概念,例如源文件、头文件、编译、链接和可执行文件。文章重点是 CMake 入门阶段最常见的命令和工程组织方式,不展开复杂的现代 CMake 重构、交叉编译工具链文件或包管理系统。
2. CMake 概述
CMake 可以理解成“生成构建说明书”的工具。开发者在 CMakeLists.txt 中描述项目需要哪些源文件、头文件目录、编译选项和链接库,CMake 再根据当前平台生成对应的构建文件。
在 Linux 下,最常见的流程是:
CMakeLists.txt ↓ cmake 配置 / 生成Makefile 或 Ninja 文件 ↓ make / ninja / cmake --build调用 g++ / clang++ ↓生成可执行程序或库文件所以,CMake 和 make 的职责不同:
- CMake 负责读取项目描述,生成构建系统文件。
make或ninja负责按照生成的规则真正调用编译器。g++、clang++等编译器负责把源码编译、链接成最终产物。
对于只有一个 main.cpp 的程序,直接写一条 g++ main.cpp -o app 也能工作。但真实工程通常会包含多个源码目录、第三方库、编译宏、安装规则和测试入口。示例项目 MultiProjectSystem 就包含 common、frameworks、projects 多个模块,还依赖 OpenCV、onnxruntime、JPEG、FFTW、wiringPi、pigpio、u8g2、i2c、pthread、rt 等库。这样的项目更适合交给 CMake 管理。
3. CMake 的使用
CMake 的核心配置文件叫 CMakeLists.txt。文件名大小写要准确,通常放在项目根目录。CMake 命令本身大小写不敏感,但工程中一般保持小写命令风格,例如 project、set、add_executable。
3.1 注释
CMake 支持行注释和块注释。行注释使用 #,从 # 开始到当前行结束都不会被执行:
# 设置 C++ 标准set(CMAKE_CXX_STANDARD 17)块注释使用 #[[ ... ]],适合临时屏蔽多行内容:
#[[message(STATUS "This message is disabled")set(SOME_OPTION ON)]]示例项目里大量使用行注释把配置分区,例如“项目选择”“OpenCV”“包含目录”“源文件”“链接库”等。这类注释不会影响构建,但能让 CMakeLists.txt 更容易维护。
3.2 最小 CMake 项目与构建方式
最小的 CMake 项目通常只需要三个命令:指定最低版本、声明项目、生成可执行程序。
3.2.1 在源码目录中构建
最直接的方式是在源码目录中执行 CMake。此时源码文件、CMakeLists.txt 和构建产物都在同一个目录里。假设目录里只有一个 main.cpp,最小配置可以写成:
cmake_minimum_required(VERSION 3.16)project(HelloCMake)
add_executable(hello main.cpp)cmake_minimum_required 指定项目需要的最低 CMake 版本,project 定义项目名,add_executable 表示用 main.cpp 生成名为 hello 的可执行文件。
这种方式适合理解 CMake 的最小工作流程,但不适合真实项目。因为如果直接在源码目录执行 cmake . 和 make,Makefile、CMakeCache.txt、CMakeFiles/、目标文件等构建产物会和源码混在一起,目录很快变乱。
3.2.2 使用独立 build 目录
更推荐的方式是 out-of-source build,也就是把源码目录和构建目录分开。常见命令如下:
mkdir buildcd buildcmake ..make -j4这里在 build 目录中执行 cmake ..,其中 .. 表示源码目录。CMake 会读取上一级目录的 CMakeLists.txt,然后把 Makefile、缓存和中间文件生成到 build 目录里。
现代 CMake 也可以写成:
cmake -S . -B buildcmake --build build -j4-S . 指定源码目录,-B build 指定构建目录。这种写法不需要手动 cd build,更适合脚本和 CI。
示例项目也应该使用这种方式构建。因为它包含多个源码目录和第三方依赖,构建过程中会生成大量中间文件,放到单独的 build 目录更清晰。
3.3 项目构建配置
CMake 不只是“把某几个 .cpp 编译成程序”。它还可以设置变量、指定 C++ 标准、控制模块开关、配置依赖路径。这些内容可以理解成项目构建的“私人订制”。
3.3.1 定义变量
CMake 用 set 定义变量:
set(SOURCES main.cpp app.cpp)使用变量时,用 ${变量名} 取值:
add_executable(app ${SOURCES})示例项目中,set 用来整理最终参与编译的源文件列表:
set(SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp ${COMMON_SOURCES} ${FRAMEWORK_SOURCES} ${PROJECT_SOURCES})这里 SOURCES 是一个源文件列表,后面会交给 add_executable。这种写法比把所有 .cpp 直接塞进 add_executable 更清楚。
3.3.2 指定使用的 C++ 标准
项目如果使用了 C++17 特性,就需要告诉 CMake 编译时使用 C++17:
set(CMAKE_CXX_STANDARD 17)set(CMAKE_CXX_STANDARD_REQUIRED ON)CMAKE_CXX_STANDARD 指定 C++ 标准版本,CMAKE_CXX_STANDARD_REQUIRED ON 表示这个标准是硬性要求。如果编译器不支持 C++17,CMake 不应该悄悄降级到旧标准。
示例项目的开头还开启了编译数据库导出:
cmake_minimum_required(VERSION 3.16)project(MultiProjectSystem VERSION 2.0.0)
set(CMAKE_CXX_STANDARD 17)set(CMAKE_CXX_STANDARD_REQUIRED ON)set(CMAKE_EXPORT_COMPILE_COMMANDS ON)CMAKE_EXPORT_COMPILE_COMMANDS ON 会在构建目录生成 compile_commands.json。这个文件记录每个源文件的真实编译命令,对 clangd、VS Code 和静态分析工具很有用。
3.3.3 用 option 配置功能开关
option 用来定义用户可以配置的开关,值通常是 ON 或 OFF。它适合控制是否启用某个模块、功能或可选依赖。
option(ENABLE_FEATURE "Enable some feature" ON)配置时可以通过 -D 覆盖:
cmake -S . -B build -DENABLE_FEATURE=OFF示例项目用 option 控制两个业务模块:
option(FIRE_DETECTION_PROJECT "Build Fire Detection Project" OFF)option(BEARING_VIBRATION_PROJECT "Build Bearing Vibration Project" ON)
if(NOT FIRE_DETECTION_PROJECT AND NOT BEARING_VIBRATION_PROJECT) set(BEARING_VIBRATION_PROJECT ON)endif()这段配置让轴承振动检测模块默认开启。如果两个模块都被关闭,CMake 会重新启用轴承振动检测模块,避免没有业务源码被选择。
切换模块时可以这样写:
cmake -S . -B build \ -DFIRE_DETECTION_PROJECT=ON \ -DBEARING_VIBRATION_PROJECT=OFF3.3.4 配置第三方包路径
有些第三方库不在系统默认路径下,CMake 可能找不到对应的 package config 文件。此时可以设置形如 <PackageName>_DIR 的变量。
示例项目为 onnxruntime 设置了默认路径:
if(NOT DEFINED onnxruntime_DIR OR onnxruntime_DIR STREQUAL "") set(onnxruntime_DIR "/usr/local/lib/cmake/onnxruntime" CACHE PATH "Path to onnxruntime CMake config directory" FORCE)endif()onnxruntime_DIR 用来告诉 CMake 去哪里找 onnxruntime 的 CMake config。CACHE PATH 表示写入 CMake cache,FORCE 表示强制更新缓存值。
3.4 搜索文件
当项目源文件很多时,手动列出每个 .cpp 会很麻烦。CMake 提供了搜索文件的方式。
常见写法之一是 file(GLOB ...) 或 file(GLOB_RECURSE ...):
file(GLOB SOURCES "*.cpp")file(GLOB_RECURSE ALL_SOURCES "src/*.cpp")GLOB 只匹配当前指定目录,GLOB_RECURSE 会递归进入子目录。
示例项目用 GLOB_RECURSE 收集公共模块和框架模块源码:
file(GLOB_RECURSE COMMON_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/common/*.cpp")file(GLOB_RECURSE FRAMEWORK_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/frameworks/*.cpp")这种写法可以减少手动维护源文件列表的工作量。不过新增 .cpp 后,构建系统不一定总能自动感知,必要时需要重新执行 CMake 配置:
cmake -S . -B buildcmake --build build -j4业务源码则根据模块开关选择:
if(FIRE_DETECTION_PROJECT) set(PROJECT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/projects/FireDetectionProject.cpp)elseif(BEARING_VIBRATION_PROJECT) set(PROJECT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/projects/BearingVibrationProject.cpp)endif()3.5 包含头文件
编译 .cpp 时,编译器需要知道 #include "xxx.h" 或 #include <xxx.h> 应该去哪些目录查找。CMake 中可以用 include_directories 添加头文件搜索路径:
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)示例项目的头文件目录更多:
include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include/common ${CMAKE_CURRENT_SOURCE_DIR}/include/common/oled ${CMAKE_CURRENT_SOURCE_DIR}/include/frameworks ${CMAKE_CURRENT_SOURCE_DIR}/include/projects
${OpenCV_INCLUDE_DIRS} ${JPEG_INCLUDE_DIRS} ${WIRINGPI_INCLUDE_DIR} ${PIGPIO_INCLUDE_DIR} ${FFTW_INCLUDE_DIRS}
${CMAKE_CURRENT_SOURCE_DIR}/src/common/oled/u8g2/csrc ${CMAKE_CURRENT_SOURCE_DIR}/src/common/oled/u8g2/sys/linux-i2c/common)这里要区分两个概念:
- 头文件路径解决“声明在哪里”。
- 链接库解决“函数实现在哪里”。
如果头文件路径缺失,常见错误是:
fatal error: wiringPi.h: No such file or directory这类错误发生在编译阶段,不是链接阶段。即使头文件找到了,如果库文件没有链接上,后面仍然可能出现 undefined reference。
include_directories 是目录级写法。现代 CMake 更推荐 target_include_directories,作用范围更清楚。不过入门阶段先理解“把头文件目录加入编译器搜索路径”这个核心作用即可。
3.6 查找与链接第三方库
参考教程通常会先讲如何用 add_library 制作静态库或动态库,再讲如何链接库文件。示例项目主要不是在顶层 CMakeLists.txt 中制作库,而是链接 OpenCV、onnxruntime、wiringPi、pigpio、u8g2 等已有库。因此这一节重点放在“如何查找并链接库”。
3.6.1 查找规范安装的库:find_package
find_package 适合查找安装规范、带 CMake config 或 Find 模块的库:
find_package(OpenCV REQUIRED)REQUIRED 表示找不到就让 CMake 配置失败。示例项目中使用了:
find_package(OpenCV REQUIRED)find_package(onnxruntime REQUIRED CONFIG)find_package(JPEG REQUIRED)find_package(PkgConfig REQUIRED)pkg_check_modules(FFTW REQUIRED fftw3)find_package(OpenCV REQUIRED) 成功后,通常会提供 ${OpenCV_INCLUDE_DIRS}、${OpenCV_LIBS}、${OpenCV_VERSION} 等变量。find_package(onnxruntime REQUIRED CONFIG) 找到的是 onnxruntime 的 CMake config,后面链接时使用 onnxruntime::onnxruntime 这个导入目标。
3.6.2 查找头文件和库文件:find_path / find_library
有些库没有标准 CMake config,需要手动查找头文件和库文件。
find_path 查找头文件所在目录:
find_path(WIRINGPI_INCLUDE_DIR wiringPi.h PATHS /usr/include /usr/local/include)find_library 查找库文件,实际可能找到 .so 或 .a:
find_library(PIGPIO_LIBRARY pigpio PATHS /usr/lib /usr/local/lib /usr/lib/aarch64-linux-gnu /usr/lib/arm-linux-gnueabihf)示例项目还指定了 u8g2 的库目录:
find_library(U8G2_LIB u8g2 PATHS ${CMAKE_CURRENT_SOURCE_DIR}/src/common/oled/u8g2/build NO_DEFAULT_PATH)NO_DEFAULT_PATH 表示只在指定目录查找,不去系统默认路径查找。这里说明项目希望链接源码树中已经构建好的 u8g2 库。
查找完成后应尽早检查结果:
if(NOT PIGPIO_INCLUDE_DIR OR NOT PIGPIO_LIBRARY) message(FATAL_ERROR "pigpio not found.\n" "Try: sudo apt install pigpio libpigpio-dev" )endif()这样可以把缺少依赖的问题暴露在 CMake 配置阶段,而不是拖到编译或链接阶段。
3.6.3 链接库文件:target_link_libraries
target_link_libraries 用来给某个 target 链接库。头文件解决声明,库文件解决实现。如果声明找到了但实现没链接上,常见错误是:
undefined reference to `gpioInitialise'示例项目最终把多个库链接到 multi_project_system:
target_link_libraries(multi_project_system ${OpenCV_LIBS} onnxruntime::onnxruntime ${WIRINGPI_LIBRARY} ${PIGPIO_LIBRARY} ${U8G2_LIB} i2c pthread ${JPEG_LIBRARIES} ${RT_LIB} ${FFTW_LIBRARIES})这里可以看到三种常见形式:
${OpenCV_LIBS}、${PIGPIO_LIBRARY}这类变量,来自前面的查找结果。onnxruntime::onnxruntime这类导入目标,通常携带更多使用信息。i2c、pthread这类系统库名,由链接器到系统库路径中查找。
3.7 日志
CMake 配置脚本也需要调试。message 可以在配置阶段输出信息:
message(STATUS "Configuring project")示例项目会输出依赖版本和路径:
message(STATUS "OpenCV version: ${OpenCV_VERSION}")message(STATUS "OpenCV include: ${OpenCV_INCLUDE_DIRS}")message(STATUS "Found pigpio lib: ${PIGPIO_LIBRARY}")message(STATUS ...) 出现在执行 cmake -S . -B build 时,不是程序运行时日志。它适合确认 CMake 到底找到了哪个版本的库、哪个头文件目录、哪个 .so 文件。
如果使用 message(FATAL_ERROR ...),CMake 会终止配置。这适合关键依赖缺失时尽早失败。
3.8 变量操作
CMake 中的列表变量本质上是一组字符串。常见操作包括追加、过滤和重新赋值。
3.8.1 追加
可以用 list(APPEND ...) 给列表追加元素:
list(APPEND SOURCES src/app.cpp)示例项目先收集 src/common,再手动追加自己的 OLED 封装:
list(APPEND COMMON_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/common/oled/oled.cpp")3.8.2 过滤列表元素
可以用 list(FILTER ... EXCLUDE REGEX ...) 从列表中排除匹配项:
list(FILTER COMMON_SOURCES EXCLUDE REGEX ".*/src/common/oled/u8g2/.*")这里排除了 u8g2 第三方源码目录,避免把 u8g2 内部源码直接作为主程序源码重复编译。后续链接的是已经构建好的 ${U8G2_LIB}。
3.9 宏定义
CMake 可以向 C++ 编译器传递宏定义。这样 C++ 代码就能通过 #ifdef 判断当前构建选项。
示例项目根据模块选择添加宏:
if(FIRE_DETECTION_PROJECT) add_definitions(-DFIRE_DETECTION_PROJECT)elseif(BEARING_VIBRATION_PROJECT) add_definitions(-DBEARING_VIBRATION_PROJECT)endif()对应的 C++ 代码可以写成:
#ifdef BEARING_VIBRATION_PROJECT // 轴承振动检测相关代码#endifadd_definitions 是目录级写法,会影响当前目录及其子目录。现代 CMake 更推荐 target_compile_definitions,但理解 -D宏名 如何传给编译器,是入门阶段更重要的部分。
可选 Python 集成也使用了同样思路:
option(USE_PYTHON_INTEGRATION "Enable Python integration for AI models" OFF)if(USE_PYTHON_INTEGRATION) add_definitions(-DUSE_PYTHON_INTEGRATION) find_package(Python3 COMPONENTS Development Interpreter REQUIRED)endif()普通构建不需要 Python 开发组件;只有显式开启 USE_PYTHON_INTEGRATION 时,才查找 Python3 并定义对应宏。
4. 预定义变量
CMake 提供了一些预定义变量,用来表示源码目录、构建目录、项目目录等。入门阶段最常见的是:
${CMAKE_CURRENT_SOURCE_DIR}${CMAKE_CURRENT_BINARY_DIR}CMAKE_CURRENT_SOURCE_DIR 表示当前正在处理的 CMakeLists.txt 所在源码目录。CMAKE_CURRENT_BINARY_DIR 表示当前 CMakeLists.txt 对应的构建目录。
示例项目用它们生成 systemd service 文件:
configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/scripts/multi_project.service.in ${CMAKE_CURRENT_BINARY_DIR}/multi_project.service @ONLY)模板文件来自源码目录,生成文件放到构建目录。这样不会污染源码树。@ONLY 表示只替换 @VAR@ 形式的变量。
还可以简单区分:
PROJECT_SOURCE_DIR:当前项目的源码根目录。PROJECT_BINARY_DIR:当前项目的构建根目录。CMAKE_CURRENT_SOURCE_DIR:当前正在处理的CMakeLists.txt所在源码目录。CMAKE_CURRENT_BINARY_DIR:当前CMakeLists.txt对应的构建目录。
当项目只有一个顶层 CMakeLists.txt 时,它们看起来可能差不多。项目使用 add_subdirectory 后,CURRENT 变量会随着当前处理的子目录变化。
5. 编译、安装、测试与运行时配置
参考入门教程的主体通常到宏定义和预定义变量就基本结束。真实 Linux 工程还经常需要安装规则、测试入口和运行时动态库路径。
5.1 编译选项
target_compile_options 可以给某个 target 添加编译选项:
target_compile_options(multi_project_system PRIVATE -Wall -Wextra -O2 -march=native)-Wall、-Wextra 开启更多警告,-O2 开启常用优化,-march=native 会根据当前机器 CPU 生成更针对性的指令。PRIVATE 表示这些选项只作用于当前 target。
5.2 安装规则
install 描述安装阶段要复制哪些文件:
install(TARGETS multi_project_system DESTINATION bin)install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/config DESTINATION etc/multi_project)install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/models DESTINATION var/lib/multi_project)install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/scripts DESTINATION usr/share/multi_project)普通构建命令只负责编译。执行安装时,CMake 才会按照这些规则把可执行文件、配置、模型和脚本放到对应目录:
cmake --install build示例项目还安装了生成后的 systemd service 文件:
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/multi_project.service DESTINATION /lib/systemd/system)5.3 测试入口
如果项目有测试子目录,可以用 enable_testing 和 add_subdirectory 接入:
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt") enable_testing() add_subdirectory(tests)else() message(STATUS "No tests directory found, skipping tests.")endif()这个写法先检查 tests/CMakeLists.txt 是否存在。存在就启用测试并加入测试子目录,不存在就打印提示,不影响主程序构建。
5.4 RPATH
编译和链接成功,不代表程序运行时一定能找到所有动态库。RPATH 会影响程序启动时查找 .so 的路径:
set_target_properties(multi_project_system PROPERTIES BUILD_RPATH "/usr/local/gcc-11/lib64" INSTALL_RPATH "/usr/local/gcc-11/lib64")BUILD_RPATH 影响构建目录中运行程序时的库搜索路径,INSTALL_RPATH 影响安装后的程序运行时库搜索路径。示例中设置这两个属性,说明程序可能依赖 /usr/local/gcc-11/lib64 下的运行时库。
6. 总结
按照入门学习顺序看,CMake 可以从几个层次理解:
- CMake 不是编译器,而是读取
CMakeLists.txt并生成构建文件的工具。 - 最小项目通常由
cmake_minimum_required、project、add_executable组成。 - 推荐使用 out-of-source build,把源码目录和构建目录分开。
set、option、file(GLOB_RECURSE)、list可以组织变量、开关和源文件列表。include_directories解决头文件声明查找,target_link_libraries解决库文件实现链接。message、宏定义和预定义变量让 CMake 配置更容易调试和维护。
回顾:CMake 的核心作用是读取
CMakeLists.txt并生成本地构建系统文件。一个真实 Linux C++ 项目通常会通过option控制功能模块,通过宏定义把配置传给 C++ 条件编译,通过find_package、find_path、find_library查找依赖,通过include_directories配置头文件搜索路径,通过file、list、set整理源文件,最后用add_executable创建可执行目标,并用target_link_libraries链接第三方库和系统库。