嵌入式 Linux

CMake入门

CMake学习记录

1. 摘要与目标

CMake 是 C/C++ 工程中常见的构建配置工具。它本身不负责把源码编译成机器码,而是读取 CMakeLists.txt,根据项目配置生成 Makefile、Ninja 文件或 IDE 工程文件,再由 makeninjacmake --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 负责读取项目描述,生成构建系统文件。
  • makeninja 负责按照生成的规则真正调用编译器。
  • g++clang++ 等编译器负责把源码编译、链接成最终产物。

对于只有一个 main.cpp 的程序,直接写一条 g++ main.cpp -o app 也能工作。但真实工程通常会包含多个源码目录、第三方库、编译宏、安装规则和测试入口。示例项目 MultiProjectSystem 就包含 commonframeworksprojects 多个模块,还依赖 OpenCV、onnxruntime、JPEG、FFTW、wiringPi、pigpio、u8g2、i2c、pthread、rt 等库。这样的项目更适合交给 CMake 管理。

3. CMake 的使用

CMake 的核心配置文件叫 CMakeLists.txt。文件名大小写要准确,通常放在项目根目录。CMake 命令本身大小写不敏感,但工程中一般保持小写命令风格,例如 projectsetadd_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.txtCMakeFiles/、目标文件等构建产物会和源码混在一起,目录很快变乱。

3.2.2 使用独立 build 目录

更推荐的方式是 out-of-source build,也就是把源码目录和构建目录分开。常见命令如下:

Terminal window
mkdir build
cd build
cmake ..
make -j4

这里在 build 目录中执行 cmake ..,其中 .. 表示源码目录。CMake 会读取上一级目录的 CMakeLists.txt,然后把 Makefile、缓存和中间文件生成到 build 目录里。

现代 CMake 也可以写成:

Terminal window
cmake -S . -B build
cmake --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 用来定义用户可以配置的开关,值通常是 ONOFF。它适合控制是否启用某个模块、功能或可选依赖。

option(ENABLE_FEATURE "Enable some feature" ON)

配置时可以通过 -D 覆盖:

Terminal window
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 会重新启用轴承振动检测模块,避免没有业务源码被选择。

切换模块时可以这样写:

Terminal window
cmake -S . -B build \
-DFIRE_DETECTION_PROJECT=ON \
-DBEARING_VIBRATION_PROJECT=OFF

3.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 配置:

Terminal window
cmake -S . -B build
cmake --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 配置阶段,而不是拖到编译或链接阶段。

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 这类导入目标,通常携带更多使用信息。
  • i2cpthread 这类系统库名,由链接器到系统库路径中查找。

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
// 轴承振动检测相关代码
#endif

add_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 才会按照这些规则把可执行文件、配置、模型和脚本放到对应目录:

Terminal window
cmake --install build

示例项目还安装了生成后的 systemd service 文件:

install(FILES ${CMAKE_CURRENT_BINARY_DIR}/multi_project.service
DESTINATION /lib/systemd/system)

5.3 测试入口

如果项目有测试子目录,可以用 enable_testingadd_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 可以从几个层次理解:

  1. CMake 不是编译器,而是读取 CMakeLists.txt 并生成构建文件的工具。
  2. 最小项目通常由 cmake_minimum_requiredprojectadd_executable 组成。
  3. 推荐使用 out-of-source build,把源码目录和构建目录分开。
  4. setoptionfile(GLOB_RECURSE)list 可以组织变量、开关和源文件列表。
  5. include_directories 解决头文件声明查找,target_link_libraries 解决库文件实现链接。
  6. message、宏定义和预定义变量让 CMake 配置更容易调试和维护。

回顾:CMake 的核心作用是读取 CMakeLists.txt 并生成本地构建系统文件。一个真实 Linux C++ 项目通常会通过 option 控制功能模块,通过宏定义把配置传给 C++ 条件编译,通过 find_packagefind_pathfind_library 查找依赖,通过 include_directories 配置头文件搜索路径,通过 filelistset 整理源文件,最后用 add_executable 创建可执行目标,并用 target_link_libraries 链接第三方库和系统库。