构建系统指北
2025-04-0211 min
构建系统(讲义版)
引子
Q:同学们是怎么编译和运行自己的代码的呢?
- A: 诶🤓☝,点“运行”就可以了啊!
- B:俺用的是VSCode咋点击运行按钮一直用不了啊啊啊啊啊啊啊...😭
- A:我不到啊,我用的VS2022 :(🤪
- C:Linux服务器怎么没有开图形化界面啊啊啊啊啊怎么点运行啊🤡
- D:上课学了通过gcc在命令行执行编译命令,但是每次都要重复写命令。🧐
- E:太好了是 自动补全 和 命令历史记录 ,我们有救了... Bro,我的项目结构怎么这么复杂,只靠命令语句完全不够用啊!💀
- F:......
是的,同学们在大学课程中接触到的,通常就是图形化操作和使用编译器命令来构建自己的应用程序
图形化操作的缺陷
如果IDE没有提供一键运行的按钮,或者对应的平台没有提供图形化界面,就难受了
直接使用编译器命令
缺陷
如果你的项目具有复杂结构,如何优雅的构建项目这件事仍然是一件令人抓狂的事情
使用命令脚本(例如Shell)
优势:显示描述依赖关系,可持久复用。
缺点:编写脚本的灵活度较高的同时,难度也较高,没有特定的语法模式,新手不容易理解,并且在跨平台上存在一定的困难。
构建系统
构建系统(Build System)是一套自动化工具和规则的集合,用于将源代码转换为可执行程序、库文件或其他交付物。其核心任务包括:
- 编译:将源代码(如C++、Java)转换为目标代码(如.o、.class)
- 链接:将目标代码与依赖库合并为最终可执行文件或动态库
- 依赖管理:自动处理文件间的依赖关系(如头文件、库文件)
- 任务编排:按顺序执行编译、测试、打包等任务
为什么需要构建系统?
- 复杂性管理:现代项目可能包含成千的源文件和依赖项
- 跨平台支持:不同操作系统(Windows/Linux/macOS)的构建流程差异
- 团队协作:确保所有开发者使用一致的构建环境
- 效率提升:增量编译、并行构建加速开发流程
构建系统的特点
- 依赖解析(Dependency Resolution)
- 隐式依赖:编译器自动追踪头文件包含关系
- 显式依赖:开发者手动声明任务间的依赖(如Makefile规则)
- 任务编排:构建工具将任务组织为有向无环图(DAG),确保执行顺序正确,避免陷入依赖循环。
- 跨平台构建
- 缓存与增量构建
现代工具
现代的项目工具不仅仅是单纯的着眼于如何构建,还糅合了很多其他的任务
- 包管理工具:聚焦于依赖的获取与版本控制。
- 核心问题:“项目需要哪些第三方库?如何确保版本一致?”
- 构建工具:聚焦于代码到产物的转换流程。
- 核心问题:“如何将源代码编译、链接、测试并打包为最终交付物?”
例如Java开发中的maven,gradle,它既是包管理工具,又是构建工具


而makefile,ninja就属于比较纯粹的构建工具
而Python的pip,就是比较纯粹的包管理
构建工具预览
现在我们的以C/C++项目为例,来介绍三种经典的构建工具
- Make
- Make并没有编译链接的功能,它只是通过批处理的方式来 调用 人类在makefile中编写的指令,达到自动编译和链接。
- Make的规则很简单,你规定要构建哪个文件、它依赖哪些源文件,当那些文件有变动时,如何重新构建它。
- 各个平台中的规范,标准,基础命令等并不统一,所以有的平台会有自己的makefile。这样就会导致无法实现跨平台。于是出现了 CMake等其他跨平台的构建工具
- Ninja
- Ninja 舍弃了各种高级功能,所以虽然总体功能不如make强大,但是语法和用法非常简单
- 启动编译的速度非常快。根据实际测试:在超过30,000个源文件的情况下,也能够在1秒钟内开始进行真正的构建
- 有测试表明,在超过30,000个源文件的情况下,make的构建时间一般在10s-20s
- Ninja 本身是跨平台的,但是可搭配CMake和ninja构建项目
- CMake
- CMake制订了一个统一的标准,编写后能够读取CMakelist.txt根据目标平台生成相适应的生成器规则(例如makefile, ninja),从而迈出跨平台的第一步。再根据对应平台的生成器输入构建命令,来构建项目,最终实现跨平台。
本节课要求了解Make、Ninja,并能通过它们进行基础性的项目构建
对CMake则会更加深入,掌握比较现代化的构建系统的使用
三种构建工具的关系预览

Make
构建规则文件
默认为当前工作目录下的makefile
或者通过make -f <file_name>指定的 .mk后缀文件
基础知识
makefile文件结构
规则
- 对一个规则,Makefile文件默认只生成第一个目标就结束构建
- make会通过文件最后一次修改的时间戳来检查所依赖的文件是否发生变动,之后才重新生成目标文件。
- 规则里的命令在执行时会先在终端输出正在执行的命令语句,然后输出命令的执行结果。如果在命令语句前加上
@符号,就只会输出命令结果。
目标
伪目标
:::info 如果目录下已存在与目标重名的文件,会导致目标构建失效(make认为这是一个生成目标),可以通过在makefile中加入以下配置来显示声名哪些是伪目标以避免检测。
:::
常用的伪目标命名
- all
- clean
变量
自定义变量
特殊变量/自动变量
$^ 表示所有的依赖文件
$@ 表示生成的目标文件
$< 代表第一个依赖文件
$+与$^类似,但是不会包含重复项。
这些变量可以用在规则里,用于表示目标和依赖,例如
print_msg展开后,$@就成为了命令语句的一部分,然后$@再展开为具体的目标名字。当然你也可以直接用在命令语句里面。
工具函数
wildcard
使用 $(wildcard) 函数时要注意,它会立即扩展,这意味着在 Makefile 解析时就会生成文件列表。
文件目录相关
其他
模式匹配
在 Makefile 中,通配符 * 和 % 用于文件名模式匹配,它们在规则中非常有用,尤其是在你需要指定一组文件而不是单个文件时。
*(星号):
*匹配任意数量的字符(包括零个字符)。- 例如,
*.c会匹配当前目录下所有以.c结尾的文件。
%(百分号):
%在模式匹配中用作通配符,它可以匹配任意单个非斜线(/)字符的序列。- 例如,
%.c会匹配任何单个字符后面跟着.c的文件名。 %特别有用,因为它允许你在替换操作中保留文件名的一部分,例如
$(patsubst %.c,%.o, /src/file.c) 只会将 file.c 转换为 file.o。
?(英文问号):
?用于匹配文件名中的单个字符。
注意事项
Ninja
构建规则文件
默认./build.ninja
也可指定 .ninja后缀文件
基础知识
示例
变量
定义
使用
规则 (rule)
规则描述了一组数据的处理方式,可接受两个固定的参数:in 和 out(类似于一个函数的参数变量),其实你可以将规则从表面上理解为一个函数...
构建 (build)
输入文件(依赖文件)的类型及格式:
显示依赖
指定为某个规则的依赖,就如示例中的hello.c作为compile规则的显示依赖
隐式依赖
用|符号标记隐式依赖的开始
例如header.h, 没有直接输入到编译命令中,但是在hello.c中被引入后,就成为隐式依赖文件。如果 header.h 的时间戳比 hello.o 新,hello.o 也会重新构建。
仅顺序依赖(Order-Only Dependencies)
用||标记仅顺序依赖的开始
仅顺序依赖是指那些文件的存在性会影响构建顺序,必须在目标文件生成之前存在,但它们的内容变化不会触发重新构建。
假如 hello.c中有读取config.txt的代码,那么config.txt在构建hello.o前需要准备好,但是config.txt内容变化并不会影响程序的读取功能
校验脚本(选讲)
用|@标记校验脚本的开始
校验脚本是一种特殊的依赖关系,用于在构建过程中运行额外的校验命令。这些校验命令可以检查某些条件是否满足,如果不满足,则会导致构建失败。
关于目标(输出文件)
build语句中的输出文件,也可视为目标
例如刚才的 hello.o,你只需要ninja hello.o就可以构建指定的目标
可以定义default:默认的构建目标
定义后,你只需要输入ninja并运行,就可以使用default目标
构建系统中关于通过来减少非必要的重构的构建机制
别名
例如下面这个例子,all就依赖于两个构建目标,通过phony伪规则,对他们这个组合起了别名
ninja命令工具
ninja自带了一些有用的工具,通过ninja -t <tool_name>的格式来运行,这里仅介绍一些常用的命令工具
**clean**: 清理生成的中间文件commands:列出重新构建制定目标所需的所有命令graph:为指定目标生成 graphviz dot 文件,用于展示构建规则的关系图。- 用例:
ninja -t graph | dot -Tpng -o graph.png,需要安装 graphviz 环境来使用dot命令 - 效果图示例:
- 用例:

targets:通过构建关系的有向图,列出最终的一些targetbrowse:在浏览器中浏览依赖关系图。(默认会启动一个基于python的http服务)- 不能在Windows平台上运行,且需要python环境
池(选讲)
在 Ninja 构建系统中,pool 用于控制并行任务的资源分配,限制某些规则(rules)的并发执行数量。默认情况下,Ninja 会根据系统的 CPU 核心数并行执行任务,但某些任务(如内存密集型操作或需要独占资源的任务)可能需要通过 pool 限制并发量,避免资源争用或资源占用过高导致构建失败。
Ninja 默认使用ninja -j N(N 为并行任务数)控制全局并发量,例如-j 4 表示最多同时执行 4 个任务。
pool 可以覆盖这一全局设置,针对特定规则(rule)限制其并发量。
定义
使用示例
CMake
构建规则文件
配置文件名为CMakeLists.txt,放在 项目 以及 子项目 的 根目录
同时也有.cmake文件,但是与CMakeLists.txt不同的是,.cmake文件是用于复用CMake代码,例如函数、宏等,而CMakeLists.txt才是可执行的配置文件
后文会详细介绍其间的区别...
CMakeLists.txt文件基础结构
- 指定 CMake 的最低版本要求
- 设置项目名与源文件语言
- 设置目标文件名及其依赖文件
以下是一个最简单的示例结构
示例
基础知识
CMake使用机制
使用CMake完整的构建流程为:
- 创建构建目录:保持源代码目录整洁。
- 使用 CMake 生成构建文件:配置项目并生成适合平台的构建文件。
- 编译和构建:使用生成的构建文件执行编译和构建。
- 清理构建文件:通过生成的构建配置文件的命令,删除构建的中间文件和目标文件。
- 重新配置和构建:处理项目设置的更改。
其中,构建项目的主要部分可以被概括为两步:
- 预构建:根据指定的生成器类型(makefile, Ninja, msvc ...),将CMake配置转化为对应的生成器配置文件
- 构建:使用对应的生成器的命令(make, ninja,msbuild ...)生成目标文件
预构建
cmake需要选择generator(生成器)
我们在执行cmake命令时,可以指定:
- 使用哪个生成器,通过
-G <generator>指定 - 项目位置,默认为当前目录,可通过
-S <path>指定 - 生成器配置文件输出目录,默认为当前目录,可通过
-B <path>指定。因为生成的生成器配置文件较多,一般都建议设置一个输出目录
构建
- 接着运行
cmake --build <dir>,由于例子中-B的值为build,所以直接cmake --build build,CMake就可以自动执行你刚才指定的生成器类型的构建命令 - 进入
-B指定的目录(例子中dir的值为build),这个目录下就有你的生成器入口配置文件,手动执行ninja或者make即可
变量
预定义变量
CMAKE_SOURCE_DIR项目根目录(顶层 CMakeLists.txt 所在路径)CMAKE_BINARY_DIR构建目录(执行 cmake 命令时的工作路径)CMAKE_CURRENT_SOURCE_DIR当前处理的 CMakeLists.txt 所在目录PROJECT_SOURCE_DIR当前项目的源代码目录,即-S指定的目录CMAKE_CXX_COMPILER指定 C++ 编译器(如 g++/clang++)
变量操作
覆盖、追加、移除、※ 【 获取长度、索引访问、元素拼接、元素查找、查找、翻转、排序....
这里只介绍一些常用的重要内容
file
file是一个比较实用的函数,可以执行诸多文件和目录操作,包括对文件和目录的CRUD,还有对目录下的文件进行通配符匹配获取,以及路径计算和转化,远程文件下载等...
这里比较常用的就是通配符获取文件了,其他操作:
通配符匹配文件
日志消息
在构建过程中输出一些信息
关于message_type的取值:
- (无) :重要消息
- STATUS :非重要消息
- WARNING:警告, 会继续执行
- AUTHOR_WARNING:警告, 会继续执行
- SEND_ERROR:错误, 继续执行,但是会跳过生成的步骤
- FATAL_ERROR:致命错误, 终止所有处理过程
流程控制
分支语句
常用条件表达式
DEFINED var_name(变量是否存在)a STREQUAL b(字符串比较)EXISTS path(文件/目录是否存在)IS_DIRECTORY path是否是目录/文件夹
常用运算符
逻辑符
- AND
- OR
- NOT
数值比较
- LESS
- GREATER
- EQUAL
- LESS_EQUAL
- GREATER_EQUAL
字符串比较
- STRLESS
- STRGREATER
- STREQUAL
- STRLESS_EQUAL
- STRGREATER_EQUAL
对于表达式的值判定
如果是1, ON, YES, TRUE, Y, 非零值,非空字符串时,条件判断返回True
如果是 0, OFF, NO, FALSE, N, IGNORE, NOTFOUND,空字符串时,条件判断返回False
循环遍历
函数 & 宏
函数和宏都可以用于定义一段可复用的代码,不过使用时稍有区别
- 作用域
- Function: 有独立的作用域。函数内部定义的变量不会影响外部,且外部变量在函数内不可见,除非使用
PARENT_SCOPE显式传递。
- Macro: 没有独立作用域。宏内部的变量会直接影响外部,且外部变量在宏内可见。
- 参数传递
- Function: 参数按值传递,函数内对参数的修改不会影响外部。
- Macro: 参数按文本替换传递,宏内对参数的修改会影响外部。
- 返回值
- Function: 通过
set和PARENT_SCOPE返回值。 - Macro: 没有返回值机制,直接通过修改外部变量传递结果。
- 性能
- Function: 每次调用都会创建新作用域,可能稍慢。
- Macro: 通过文本替换实现,通常更快。
函数(Function)
宏(Macro)
文件生成与链接
add_executable
add_executable 是CMake构建可执行程序的核心指令,用于定义项目的最终可执行目标。
基本语法
set_target_properties
设置和修改目标的属性,如编译选项、链接选项等。
add_library
aux_source_directory
找到指定目录下的所有符合源文件形式的文件(非递归搜索),并储存为列表变量
include_directories
将这个目录作为所有目标的include目录
target_include_directories
对特定目标设置include目录
link_libraries
直接指定需要链接的库文件或库名称,影响后续所有目标的链接阶段。
target_link_libraries
直接指定需要链接的库文件或库名称,但是只影响后续指定目标的链接阶段。
- PUBLIC 在public后面的库会被Link到你的target中,并且里面的符号也会被导出,提供给第三方使用。
- PRIVATE 在private后面的库仅被link到你的target中,并且隐藏掉其中的符号,第三方不能感知你调了啥库
- INTERFACE 在interface后面引入的库不会被链接到你的target中,只会导出符号。
link_directories
指定链接器查找库文件的目录路径。
- 参数:一个或多个目录路径,可以是绝对路径或相对路径(相对路径相对于当前 CMakeLists.txt)。
- 影响范围:全局生效,对所有后续定义的
target(如通过add_executable或add_library生成的目标)生效。
target_link_directories
作用类似于link_directories,不过只作用在指定的target上
嵌套项目结构
在CMake中,include 和 add_subdirectory 是两个常用的命令,用于组织和管理项目代码。它们的功能和使用场景有所不同,下面分别进行解释:
include 命令
include 命令用于包含并执行指定的CMake脚本文件(通常是 .cmake 文件)。它的主要作用是复用CMake代码,比如定义函数、宏、变量等。
语法
<file|module>:指定要包含的文件或模块。OPTIONAL:如果文件不存在,也不要报错。RESULT_VARIABLE <var>:将包含操作的结果(成功或失败)存储在变量<var>中。
使用场景
- 复用CMake代码(如函数、宏、变量定义)。
- 加载预定义的CMake模块(如
FindPackage模块)。
add_subdirectory 命令
add_subdirectory 命令用于将指定的子目录添加到构建系统中。CMake会进入该子目录并处理其中的 CMakeLists.txt 文件,从而将子目录中的代码纳入当前项目的构建。
语法
source_dir:指定包含CMakeLists.txt文件的子目录路径。binary_dir:指定子目录的构建输出路径(可选,默认为source_dir)。EXCLUDE_FROM_ALL:如果指定,子目录中的目标不会包含在默认的构建目标中。
使用场景
- 将项目拆分为多个子目录,每个子目录有自己的
CMakeLists.txt文件。 - 管理模块化或分层的项目结构。
区别与联系
示例项目结构
假设项目结构如下:
- 在
project/CMakeLists.txt中:
- 在
project/src/CMakeLists.txt中:
通过这种方式,include 用于复用CMake代码,而 add_subdirectory 用于管理子目录的构建。
课后实操(选做)
Tips
构建系统的实际内容非常丰富,但是限于篇幅,本次授课仅仅是带大家以C/C++语言为例,入门了构建系统。想要更进一步
不要让你的项目路径包含中文路径
尽管你有时候感觉运行起来没问题。。。
规则命令
因为构建工具一般是根据规则名去匹配目标文件名的,而可执行文件在windows下带有.exe后缀。所以在windows平台下,对于如果最终生成目标为可执行文件,最好指定规则名为后缀为.exe,而在Linux下则不用。
时间戳的比较
以make为例,正常工作时,只需要比较目标文件和输入文件的时间戳,如果目标文件比输入文件新,则可以断定输入文件在目标文件首次构建后没发生变化。
Ninja会在构建完成后生成一个记录(.ninja_log),记录任务的输入/输出文件的时间戳,用于下次构建时快速比较。