1. 从一份“能跑就行”的CMakeLists说起
很多人第一次接触CMake,都是在接手一个别人写好的C++项目时。打开根目录,看到一个叫CMakeLists.txt的文件,里面密密麻麻写了几十行,有project()、add_executable()、target_link_libraries(),还有一堆if()判断和option()开关。你心里想的是“我就想加一个源文件,怎么这么麻烦”,然后硬着头皮改了一行,结果编译直接报错,报错信息还看不懂。
我自己最开始也是这样。那会儿写一个图像处理的小工具,源文件从3个涨到11个,每次新增文件都要手动往add_executable里加一行,加漏了就报“undefined reference”,查半天才发现是文件没编进去。后来被逼着把CMakeLists从头理了一遍,才发现这东西其实有一套非常清晰的逻辑,只是大部分教程上来就讲语法,不讲“为什么这么写”。
这篇内容就是把我这些年写CMakeLists的经验整理出来。不追求覆盖CMake的全部特性,而是聚焦在“一个简单项目到底该怎么写”这件事上。适合刚接触CMake、或者一直在复制粘贴别人配置、想真正搞明白每一行在干什么的人。读完之后,你应该能独立为一个中小型C++项目写出结构清晰、可维护的构建脚本,而不是每次加文件都靠猜。
2. 先搞清楚CMake到底在解决什么问题
2.1 没有CMake的世界是什么样的
在CMake出现之前,Linux下写C++项目基本靠手写Makefile。一个最简单的Makefile大概长这样:
main: main.o utils.o g++ -o main main.o utils.o main.o: main.cpp utils.h g++ -c main.cpp utils.o: utils.cpp utils.h g++ -c utils.cpp三个文件的时候还能忍,一旦项目变成三十个文件、跨三个目录、依赖两个第三方库,Makefile就会变成一场灾难。你需要手动维护每个.o文件和.cpp文件的依赖关系,改一个头文件路径,可能要在十几个地方同步修改。更麻烦的是,Makefile是平台相关的,Linux上写的拿到Windows上基本不能用。
CMake的核心价值就在这里:它让你用一种平台无关的方式描述“这个项目由哪些源文件组成、依赖哪些库、生成什么目标”,然后由CMake根据当前平台生成对应的构建文件。在Linux上它生成Makefile,在Windows上它生成Visual Studio的工程文件,在macOS上它生成Xcode工程。你写一份配置,三个平台都能用。
2.2 CMake的“两阶段”工作模式
理解CMake最关键的一点,是它分两个阶段工作。
第一个阶段叫配置阶段(configure)。你运行cmake -S . -B build,CMake会读取CMakeLists.txt,检查编译器是否可用、依赖库是否存在、用户传了哪些选项,然后生成构建系统文件到build目录。这个阶段只执行一次,除非你改了CMakeLists或者想换配置。
第二个阶段叫构建阶段(build)。你运行cmake --build build,CMake调用底层的构建工具(make、ninja、MSBuild等)去实际编译和链接。这个阶段可以反复执行,每次只重新编译改动的文件。
很多人搞混这两个阶段,比如在CMakeLists里写execute_process去跑一个需要在构建阶段才能跑的命令,结果配置阶段就报错了。记住这个区分,后面很多问题都能自己想明白。
2.3 为什么是CMake而不是别的
构建工具不止CMake一个。Meson、Bazel、Ninja(严格说Ninja是构建执行器不是配置工具)各有拥趸。但CMake在C++生态里的地位短期内很难被撼动,原因很实际:绝大多数第三方库都提供CMake支持。你找一个库,它的文档里大概率写着“使用find_package即可集成”。这意味着你不需要为每个依赖手写编译规则,CMake帮你处理了头文件路径、库文件路径、编译选项的传递。
另一个原因是IDE支持。主流的C++ IDE和编辑器都能直接读取CMake配置,自动补全、跳转定义、调试配置都能从中推导出来。你写一份CMakeLists,等于同时给编译器和编辑器提供了项目结构信息。
3. 一个最小可用的CMakeLists应该包含什么
3.1 三行代码就能跑起来
先看一个绝对最小但完整的CMakeLists:
cmake_minimum_required(VERSION 3.16) project(my_app) add_executable(my_app main.cpp)就这三行。第一行声明CMake的最低版本要求,第二行声明项目名称,第三行声明要生成一个叫my_app的可执行文件,源文件是main.cpp。
把这三行保存到CMakeLists.txt,然后在同目录下执行:
cmake -S . -B build cmake --build build你就能在build目录下看到编译出来的my_app。这是验证环境是否正常的最快方式。
3.2 每一行背后的逻辑
cmake_minimum_required(VERSION 3.16)这行经常被当成形式主义,但它其实很重要。CMake的不同版本之间行为有差异,有些策略(policy)在新版本里默认值变了。声明最低版本相当于告诉CMake“请用这个版本的行为模式来解析我的脚本”,避免因为版本升级导致构建行为突变。3.16是一个比较稳妥的选择,它支持了现代CMake的大部分核心特性,同时兼容性足够好。
project(my_app)不只是起个名字。它会自动定义几个变量,比如PROJECT_NAME、PROJECT_SOURCE_DIR、PROJECT_BINARY_DIR。这些变量在后面写路径的时候会用到。另外,project()还可以指定语言和版本:
project(my_app VERSION 1.0.0 LANGUAGES CXX)指定LANGUAGES CXX可以跳过对C编译器的检查,在纯C++项目里能稍微加快配置速度。
add_executable(my_app main.cpp)定义了一个构建目标。目标(target)是CMake里最核心的概念,后面所有的属性设置、依赖关系、编译选项,都是围绕目标来组织的。你可以把目标理解成“一个需要被构建出来的东西”,它可以是可执行文件、静态库、动态库,甚至是自定义的命令。
3.3 源文件多了怎么办
当源文件从1个变成10个,最直接的做法是全部列在add_executable里:
add_executable(my_app main.cpp utils.cpp parser.cpp renderer.cpp network.cpp )这样写没问题,但每次新增文件都要手动加一行,容易漏。另一种做法是用file(GLOB ...)自动收集:
file(GLOB SOURCES "src/*.cpp") add_executable(my_app ${SOURCES})看起来很方便,但CMake官方明确不推荐这种做法。原因是file(GLOB)只在配置阶段执行一次,新增文件后如果不重新运行cmake命令,构建系统不会知道有新文件。而手动列文件虽然麻烦,但每次改动CMakeLists都会触发重新配置,行为是可预测的。
我的建议是:项目初期文件少,手动列;文件多到手动维护成本太高时,可以考虑用GLOB配合CONFIGURE_DEPENDS选项:
file(GLOB SOURCES CONFIGURE_DEPENDS "src/*.cpp")CONFIGURE_DEPENDS会让构建系统在每次构建时检查文件列表是否变化,有变化就自动重新配置。这个选项在CMake 3.12之后可用,能解决大部分场景下的痛点。
4. 头文件路径和库依赖怎么处理
4.1 include目录的正确设置方式
当项目有了目录结构,比如头文件放在include/下,源文件放在src/下,编译器默认是找不到头文件的。你需要告诉它去哪里找:
target_include_directories(my_app PRIVATE include)这行代码的意思是:在编译my_app这个目标时,把include目录加入头文件搜索路径。PRIVATE关键字表示这个路径只用于编译my_app自身,不会传递给依赖my_app的其他目标。
target_include_directories比老式的include_directories好在哪里?后者是全局的,会影响之后定义的所有目标,容易造成路径污染。前者是目标级别的,精确控制每个目标能看到哪些路径。现代CMake的核心理念就是“一切围绕目标”,尽量不用全局命令。
三个关键字PRIVATE、PUBLIC、INTERFACE的区别值得说清楚:
PRIVATE:这个路径我自己编译时用,别人链接我时不需要知道。PUBLIC:我自己编译时用,别人链接我时也需要知道。INTERFACE:我自己编译时不用,但别人链接我时需要知道。
举个例子,如果你在写一个库,库的公开头文件里#include了某个第三方库的头文件,那这个第三方库的include路径就应该是PUBLIC或INTERFACE,因为使用你库的人也需要能找到那个头文件。
4.2 链接第三方库的两种场景
链接库分两种情况:一种是系统库或者已经安装到标准位置的库,用find_package找;另一种是项目自带的或者放在非标准位置的库,直接用路径链接。
先看find_package的用法:
find_package(Threads REQUIRED) target_link_libraries(my_app PRIVATE Threads::Threads)find_package(Threads REQUIRED)会去找系统里的线程库。REQUIRED表示找不到就报错停止配置。找到之后,它会提供一个叫Threads::Threads的导入目标,直接链接这个目标就行。这种“导入目标”是现代CMake推荐的方式,因为它会自动携带include路径、编译定义、链接选项等所有信息。
再看直接链接库文件的情况:
target_link_libraries(my_app PRIVATE ${CMAKE_SOURCE_DIR}/libs/libfoo.a)这种方式简单直接,但缺点是库文件路径写死了,换台机器可能就不对了。更好的做法是用find_library:
find_library(FOO_LIB foo PATHS ${CMAKE_SOURCE_DIR}/libs REQUIRED) target_link_libraries(my_app PRIVATE ${FOO_LIB})find_library会在指定路径下搜索名为libfoo.a或libfoo.so的文件,找到后把完整路径赋给FOO_LIB变量。这样路径的拼接逻辑由CMake处理,跨平台时更稳妥。
4.3 编译选项和目标属性
C++项目经常需要设置编译标准,比如用C++17:
target_compile_features(my_app PRIVATE cxx_std_17)这行代码会让CMake自动选择合适的编译选项(比如-std=c++17或/std:c++17),不需要你手动写。比老式的set(CMAKE_CXX_STANDARD 17)更精确,因为它是针对具体目标的。
如果需要添加自定义编译选项,比如开启警告:
target_compile_options(my_app PRIVATE -Wall -Wextra)注意这些选项是直接传给编译器的,不同编译器支持的选项不同。如果项目需要跨平台,最好用生成器表达式区分:
target_compile_options(my_app PRIVATE $<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra> $<$<CXX_COMPILER_ID:MSVC>:/W4> )$<...>是生成器表达式,它在生成构建文件时才求值,可以根据编译器类型、构建类型等条件动态决定选项内容。这是CMake里比较高级但非常实用的特性。
5. 多目录项目的组织方式
5.1 用add_subdirectory拆分模块
项目一大,把所有内容塞进一个CMakeLists就不合适了。常见的做法是按模块拆分成子目录,每个子目录有自己的CMakeLists,根目录用add_subdirectory引入:
cmake_minimum_required(VERSION 3.16) project(my_app LANGUAGES CXX) add_subdirectory(src) add_subdirectory(libs/parser) add_subdirectory(libs/renderer)src/CMakeLists.txt里定义可执行文件:
add_executable(my_app main.cpp app.cpp) target_link_libraries(my_app PRIVATE parser renderer) target_include_directories(my_app PRIVATE ${CMAKE_SOURCE_DIR}/include)libs/parser/CMakeLists.txt里定义库:
add_library(parser STATIC parser.cpp tokenizer.cpp) target_include_directories(parser PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)这种结构的好处是每个模块的构建逻辑独立,修改一个模块的配置不会影响其他模块。add_subdirectory会按顺序处理子目录,子目录里定义的目标在父目录里可以直接引用。
5.2 静态库还是动态库
add_library不指定类型时,默认根据BUILD_SHARED_LIBS变量决定是静态还是动态。可以显式指定:
add_library(parser STATIC parser.cpp) # 静态库 add_library(renderer SHARED renderer.cpp) # 动态库静态库在链接时被完整复制进可执行文件,生成的文件大但部署简单,不依赖外部库文件。动态库在运行时加载,可执行文件小,多个程序可以共享同一份库,但部署时需要确保库文件在正确的位置。
对于中小型项目,我通常建议先用静态库,省去运行时库路径的麻烦。等确实有共享需求或者库体积很大时,再考虑动态库。
5.3 条件编译和可选功能
项目经常需要根据平台或用户选项决定编译哪些代码。CMake提供了option命令:
option(ENABLE_DEBUG_LOG "Enable debug logging" OFF) if(ENABLE_DEBUG_LOG) target_compile_definitions(my_app PRIVATE ENABLE_DEBUG_LOG) endif()用户在配置时可以通过-DENABLE_DEBUG_LOG=ON来开启这个选项。target_compile_definitions会添加-DENABLE_DEBUG_LOG编译选项,代码里就可以用#ifdef ENABLE_DEBUG_LOG来判断。
平台判断也很常用:
if(WIN32) target_link_libraries(my_app PRIVATE ws2_32) elseif(UNIX) target_link_libraries(my_app PRIVATE pthread) endif()WIN32、UNIX、APPLE这些变量由CMake自动设置,不需要自己检测。
6. 那些年我踩过的CMake坑
6.1 路径问题:相对路径的基准在哪里
CMake里相对路径的基准目录经常让人困惑。add_subdirectory里的相对路径是相对于当前CMakeLists所在目录,但target_include_directories里的相对路径是相对于CMAKE_CURRENT_SOURCE_DIR,也就是当前处理的CMakeLists所在目录。
我遇到过的情况是:在子目录的CMakeLists里写target_include_directories(foo PRIVATE include),以为include是相对于项目根目录,结果CMake把它解析成了子目录下的include。解决办法是统一用${CMAKE_CURRENT_SOURCE_DIR}或${CMAKE_SOURCE_DIR}拼绝对路径,虽然啰嗦但不会出错。
target_include_directories(foo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)6.2 变量作用域:父目录看不到子目录的变量
CMake的变量作用域是向下传递的。父目录定义的变量在子目录里可见,但子目录里set的变量默认不会影响父目录。如果确实需要向上传递,要用PARENT_SCOPE:
set(MY_VAR "value" PARENT_SCOPE)这个机制设计是为了避免子目录意外修改父目录的状态。但有时候确实需要收集子目录的信息,比如把所有源文件路径汇总到根目录,这时候PARENT_SCOPE就派上用场了。
另一个容易踩的坑是add_subdirectory的顺序。如果子目录B依赖子目录A定义的目标,那add_subdirectory(A)必须在add_subdirectory(B)之前。CMake不会自动分析依赖关系来调整顺序,它严格按你写的顺序执行。
6.3 重新配置的触发条件
CMake只在以下情况重新运行配置阶段:CMakeLists.txt文件本身被修改、通过-D传入的缓存变量改变、或者手动删除了build/CMakeCache.txt。如果你改了源文件但没改CMakeLists,构建系统只会重新编译改动的源文件,不会重新配置。
但有一种情况例外:如果你用file(GLOB)收集源文件,新增文件后CMake不会自动感知。这就是前面提到的CONFIGURE_DEPENDS要解决的问题。没有这个选项时,新增文件后必须手动重新运行cmake -S . -B build。
6.4 清理构建目录的正确姿势
很多人习惯rm -rf build来清理,这当然可以,但有个更优雅的方式:
cmake --build build --target clean这会调用底层构建工具的clean目标,删除编译产物但保留配置文件。下次构建时不需要重新配置,速度更快。
如果确实需要完全重置,删除build目录是最彻底的。但注意不要误删了源码目录里的文件。我一般会在项目根目录的.gitignore里加上build/,避免把构建产物提交到版本控制。
7. 让CMakeLists更好维护的几个习惯
7.1 把版本号集中管理
项目版本号经常需要在多个地方使用,比如project()里、安装规则里、生成的配置头文件里。与其到处硬编码,不如定义一个变量:
set(MY_APP_VERSION_MAJOR 1) set(MY_APP_VERSION_MINOR 2) set(MY_APP_VERSION_PATCH 3) project(my_app VERSION ${MY_APP_VERSION_MAJOR}.${MY_APP_VERSION_MINOR}.${MY_APP_VERSION_PATCH})这样改版本号只需要改一处。还可以用configure_file生成一个版本头文件:
configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h )version.h.in里写#define MY_APP_VERSION "@PROJECT_VERSION@",CMake会把@PROJECT_VERSION@替换成实际版本号。代码里#include "version.h"就能拿到版本信息。
7.2 用函数封装重复逻辑
如果多个目标需要相同的编译选项和链接库,可以定义一个函数:
function(configure_my_target target_name) target_compile_features(${target_name} PRIVATE cxx_std_17) target_compile_options(${target_name} PRIVATE -Wall -Wextra) target_link_libraries(${target_name} PRIVATE common_lib) endfunction()然后对每个目标调用configure_my_target(my_app)。这样修改公共配置时只需要改函数体,所有目标自动生效。
注意函数和宏的区别:函数有自己的作用域,宏是文本替换。在函数里set的变量不会影响调用者,宏则会。大多数情况下用函数更安全。
7.3 构建类型和默认值
CMake支持几种构建类型:Debug、Release、RelWithDebInfo、MinSizeRel。不指定时,单配置生成器(如Makefile)默认是空构建类型,多配置生成器(如Visual Studio)默认是Debug。
我一般会在CMakeLists里设置一个合理的默认值:
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) endif()这样用户不指定时默认用Release,需要调试时显式传-DCMAKE_BUILD_TYPE=Debug。CACHE关键字让这个变量出现在CMakeCache里,用户可以通过cmake-gui或命令行修改。
不同构建类型的区别主要在于优化级别和调试信息:Debug是-O0 -g,Release是-O3 -DNDEBUG,RelWithDebInfo是-O2 -g -DNDEBUG。根据实际需求选择,开发阶段用Debug方便调试,发布用Release追求性能。
7.4 安装规则和导出配置
如果项目需要被其他项目使用,安装规则就很重要:
install(TARGETS my_app RUNTIME DESTINATION bin LIBRARY DESTINATION lib ARCHIVE DESTINATION lib ) install(DIRECTORY include/ DESTINATION include)install命令定义了cmake --install build时把哪些文件复制到哪里。RUNTIME对应可执行文件,LIBRARY对应动态库,ARCHIVE对应静态库。
对于库项目,还需要导出CMake配置,让find_package能找到:
install(EXPORT my_lib_targets FILE my_lib_targets.cmake NAMESPACE my_lib:: DESTINATION lib/cmake/my_lib )这样其他项目find_package(my_lib)之后就能用my_lib::my_lib这个目标来链接。导出配置涉及的内容比较多,中小型项目如果只是内部使用,可以先不折腾这部分,等确实需要对外提供库时再补。
8. 从零写一个完整示例
8.1 项目结构
假设我们要写一个简单的命令行工具,功能是读取一个文本文件并统计词频。项目结构如下:
wordcount/ ├── CMakeLists.txt ├── include/ │ └── wordcount.h ├── src/ │ ├── main.cpp │ ├── counter.cpp │ └── CMakeLists.txt └── tests/ ├── test_counter.cpp └── CMakeLists.txt8.2 根目录CMakeLists
cmake_minimum_required(VERSION 3.16) project(wordcount VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) endif() option(BUILD_TESTS "Build unit tests" OFF) add_subdirectory(src) if(BUILD_TESTS) enable_testing() add_subdirectory(tests) endif()8.3 src目录的CMakeLists
add_library(wordcount_lib STATIC counter.cpp ) target_include_directories(wordcount_lib PUBLIC ${CMAKE_SOURCE_DIR}/include ) add_executable(wordcount main.cpp) target_link_libraries(wordcount PRIVATE wordcount_lib)这里把核心逻辑放在静态库里,可执行文件只包含main.cpp。这样测试代码可以直接链接wordcount_lib,不需要重复编译counter.cpp。
8.4 tests目录的CMakeLists
add_executable(test_counter test_counter.cpp) target_link_libraries(test_counter PRIVATE wordcount_lib) add_test(NAME counter_test COMMAND test_counter)add_test注册一个测试,运行ctest时会执行test_counter这个可执行文件。如果测试程序返回非零值,测试就被认为失败。
8.5 构建和测试
cmake -S . -B build -DBUILD_TESTS=ON cmake --build build cd build && ctest --output-on-failure--output-on-failure让测试失败时输出程序的完整输出,方便定位问题。
这个示例虽然简单,但涵盖了CMakeLists的核心要素:项目声明、构建类型、选项、子目录、库目标、可执行目标、测试注册。把这个骨架记住,大部分中小型项目的构建脚本都能在此基础上扩展出来。
9. 关于CMakeLists编写的一点个人体会
写了这么多年CMakeLists,我最大的感受是:不要追求一次写完美。CMake的语法和最佳实践也在演进,几年前推荐的写法现在可能已经过时了。重要的是理解每个命令在做什么,而不是死记硬背模板。
另一个体会是尽量用target级别的命令。include_directories、link_libraries、add_definitions这些全局命令虽然写起来短,但会污染整个目录的作用域,项目一大就容易出问题。target_include_directories、target_link_libraries、target_compile_definitions虽然多打几个字,但精确可控,长期来看省心得多。
最后,多看官方文档。CMake的官方文档质量很高,每个命令都有详细的参数说明和示例。遇到不确定的地方,直接查文档比在网上搜零散的教程靠谱。特别是cmake-buildsystem(7)和cmake-language(7)这两个手册页,值得反复读几遍。