4、第一个CMake项目:从零编写CMakeLists.txt,构建可执行文件,理解构建目录与源码目录分离
当我们准备踏入CMake的世界时,最迫切的需求往往不是背诵几十条命令,而是亲手搭建一个最小的、可运行的工程骨架。本章将带领你从零开始,在一个空目录中编写出第一份CMakeLists.txt,成功编译并运行一个可执行文件。更重要的是,我们将深入剖析CMake最核心的工程哲学——构建目录与源码目录分离。理解这一机制,是后续驾驭复杂项目、集成第三方库、管理多配置构建的基石。
上图清晰地展示了CMake的两阶段工作模型。第一阶段,我们使用cmake -S . -B build命令,让CMake读取源码目录中的CMakeLists.txt,并在build目录中生成构建系统文件(如Makefile或Visual Studio解决方案)。第二阶段,使用cmake --build build命令,驱动本地的编译器工具链,将源码编译并链接成最终的可执行文件。整个过程源码目录始终保持干净,所有临时文件和产物都被隔离在构建目录中。
4.1 核心原理:为什么CMake强制要求源码与构建分离
在CMake诞生之前,传统的Makefile或Autotools项目通常采用“源码内构建”(in-source build),即直接在源码根目录下执行make,生成的.o目标文件、可执行文件与源代码混杂在一起。这种模式在小型项目中尚可接受,但一旦项目规模扩大,就会暴露出严重问题:
- 污染源码树:大量中间文件(
.o、.a、.so)与源码混在一起,难以区分哪些是原始文件,哪些是生成文件。 - 多配置冲突:无法在同一份源码上同时维护Debug和Release两种构建,因为两次构建的产物会互相覆盖。
- 清理困难:手动删除所有生成文件非常繁琐,容易遗漏,导致“脏构建”引发的诡异编译错误。
- 版本控制混乱:如果不小心将构建产物提交到Git或SVN,会造成仓库臃肿,且不同平台上的二进制文件互相干扰。
CMake从设计之初就确立了“源码目录只读,构建目录可写”的黄金法则。源码目录中只存放.cpp、.hpp、CMakeLists.txt等人工维护的文件;而构建目录则存放CMakeCache.txt(缓存变量)、CMakeFiles/(依赖信息与临时文件)、Makefile或.sln(构建系统描述)、以及最终的.exe或.elf可执行文件。这种分离带来了三个直接好处:
- 支持多构建并行:你可以同时创建
build-debug和build-release两个目录,分别用不同的配置生成,互不干扰。 - 源码树安全:即使构建失败或需要彻底清理,只需删除整个构建目录即可,源码永远保持整洁。
- 便于自动化集成:CI/CD流水线可以每次创建全新的构建目录,确保从干净状态编译,避免缓存污染。
.gitignore文件中添加build/目录。这样即使有人误操作在源码目录下执行了构建,也不会将垃圾文件提交到版本库。
4.2 操作流程:手把手编写第一个CMakeLists.txt
下面我们通过一个具体的实战步骤,完成从空目录到可执行文件运行的完整闭环。假设我们正在开发一个简单的控制台程序,用于打印系统信息。
第一步:准备源码文件
在项目根目录(例如D:/demo)下创建main.cpp,内容如下:
#include <iostream>
#include <string>
int main(int argc, char* argv[]) {
std::string name = "CMake";
if (argc > 1) {
name = argv[1];
}
std::cout << "Hello, " << name << "! 构建成功!" << std::endl;
std::cout << "CMake 版本: " << CMAKE_PROJECT_NAME << std::endl;
return 0;
}
第二步:编写CMakeLists.txt
在相同目录下创建CMakeLists.txt,这是CMake的“剧本”文件。我们逐行解释每一句的含义:
# 指定CMake最低版本要求,低于此版本会报错
cmake_minimum_required(VERSION 3.16)
# 定义项目名称和使用的编程语言
project(HelloCMake LANGUAGES CXX)
# 生成可执行文件,目标名为hello_app,源文件为main.cpp
add_executable(hello_app main.cpp)
# 设置C++标准为C++17(可选,但推荐)
set_property(TARGET hello_app PROPERTY CXX_STANDARD 17)
set_property(TARGET hello_app PROPERTY CXX_STANDARD_REQUIRED ON)
这里需要特别说明project()命令中的LANGUAGES CXX参数。它明确告诉CMake我们只需要C++编译器,不需要C编译器。这可以加快配置速度,并避免在某些嵌入式环境中因找不到C编译器而报错。
第三步:执行配置与构建
打开终端(Windows下建议使用“x64 Native Tools Command Prompt for VS”或Git Bash),进入项目根目录,依次执行:
# 配置阶段:-S指定源码目录,-B指定构建目录
cmake -S . -B build
# 构建阶段:--build指定构建目录,--config Release指定配置(多配置生成器需要)
cmake --build build --config Release
如果一切顺利,你会在build目录下看到生成的hello_app.exe(Windows)或hello_app(Linux/macOS)。运行它:
# Windows
./build/Release/hello_app.exe
# Linux/macOS
./build/hello_app
输出结果应为:Hello, CMake! 构建成功!
4.3 案例拆解:一个带版本号与编译选项的完整示例
为了更贴近真实工程,我们扩展上面的例子。假设我们需要在程序中显示版本号,并且希望支持通过CMake选项控制是否打印调试信息。这涉及到target_compile_definitions和configure_file的配合使用。
首先,在源码目录下创建一个头文件模板version.h.in:
#pragma once
#define PROJECT_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define PROJECT_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define PROJECT_VERSION_PATCH @PROJECT_VERSION_PATCH@
然后修改CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(HelloCMake VERSION 2.1.0 LANGUAGES CXX)
# 添加可执行文件
add_executable(hello_app main.cpp)
# 配置版本头文件,将@变量@替换为实际值
configure_file(version.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/version.h)
# 指定头文件搜索路径,包含构建目录下的generated文件夹
target_include_directories(hello_app PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
# 添加编译选项:如果开启DEBUG_PRINT,则定义宏
option(ENABLE_DEBUG_PRINT "Enable debug print" OFF)
if(ENABLE_DEBUG_PRINT)
target_compile_definitions(hello_app PRIVATE DEBUG_PRINT=1)
endif()
# 设置C++标准
set_property(TARGET hello_app PROPERTY CXX_STANDARD 17)
在main.cpp中,我们引入版本头文件并使用条件编译:
#include <iostream>
#include "version.h"
int main() {
std::cout << "版本号: "
<< PROJECT_VERSION_MAJOR << "."
<< PROJECT_VERSION_MINOR << "."
<< PROJECT_VERSION_PATCH << std::endl;
#ifdef DEBUG_PRINT
std::cout << "[调试] 当前处于调试模式" << std::endl;
#endif
return 0;
}
此时,我们再次执行配置和构建:
cmake -S . -B build -DENABLE_DEBUG_PRINT=ON
cmake --build build
运行程序,输出将包含版本号和调试信息。这个案例展示了CMake的三个关键能力:变量替换(configure_file)、编译选项控制(option与target_compile_definitions)、构建目录中的生成文件(version.h被生成在build/generated下,而非源码目录)。
| CMake命令 | 作用 | 在本案例中的具体体现 |
|---|---|---|
cmake_minimum_required |
声明最低CMake版本 | 确保使用3.16以上版本,以支持-S/-B参数 |
project |
定义项目名称、版本、语言 | 设置版本号2.1.0,并指定CXX语言 |
add_executable |
声明可执行文件目标 | 将main.cpp编译为hello_app |
configure_file |
将模板文件中的变量替换为实际值 | 生成version.h到构建目录 |
target_include_directories |
为目标指定头文件搜索路径 | 添加build/generated目录 |
option |
定义开关型变量 | 定义ENABLE_DEBUG_PRINT,默认OFF |
target_compile_definitions |
为目标添加预处理器宏定义 | 当开关打开时定义DEBUG_PRINT |
4.4 常见误区与总结复盘
初学者在编写第一个CMake项目时,最容易陷入以下几个误区。我们逐一剖析并给出规避建议。
有些教程会教你
mkdir build && cd build && cmake ..。这种方式虽然也能实现分离,但不够直观,且容易混淆当前目录。推荐使用cmake -S . -B build,它明确指定了源码目录和构建目录,即使当前工作目录不在项目根目录也能正确执行。
如果不显式设置
CXX_STANDARD,CMake会使用编译器默认标准(例如GCC 11默认是GNU++17,但旧版本可能默认C++14)。这会导致使用了C++17特性的代码在旧编译器上编译失败。务必通过set_property或target_compile_features明确指定标准。
在
CMakeLists.txt中,${CMAKE_CURRENT_SOURCE_DIR}指向源码目录,${CMAKE_CURRENT_BINARY_DIR}指向构建目录。如果源码中需要引用生成的头文件,必须使用target_include_directories将构建目录加入搜索路径,否则编译器会报“找不到头文件”错误。
总结复盘:本章我们完成了从零编写CMakeLists.txt到成功运行可执行文件的完整过程。核心收获有三点:第一,掌握了cmake -S -B和cmake --build的标准构建流程;第二,理解了源码目录与构建目录分离的必要性——它保证了源码的纯净性和多配置构建的可行性;第三,通过扩展案例,学会了使用configure_file生成版本头文件、使用option控制编译选项。这些基础能力将直接应用于后续章节的库构建、多目录组织以及第三方依赖集成中。
CMakeLists.txt,将可执行文件输出名称改为my_tool,并添加一个option来控制是否打印系统时间。重新配置并构建,观察构建目录中生成的文件结构变化。这个练习能帮助你巩固对目标属性与构建目录的理解。