CMake出现之前,C++项目是如何构建呢?
本节以一个简单的计算程序来演示

1. CPP项目结构

01_Make/
├── main.cpp      # 主程序
├── calc.cpp      # 计算函数实现
├── calc.h        # 计算函数声明
└── Makefile      # make的构建脚本

1.1 calc.h

#ifndef CALC_H
#define CALC_H

// 加法函数
int add(int a, int b);

// 减法函数
int subtract(int a, int b);

#endif

1.2 calc.cpp

#include "calc.h"

int add(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

1.3 main.cpp

#include <iostream>
#include "calc.h"

int main() {

    int x = 10, y = 5;
    
    std::cout << x << " + " << y << " = " << add(x, y) << std::endl;
    std::cout << x << " - " << y << " = " << subtract(x, y) << std::endl;
    
    return 0;
}

2. 直接g++编译

上一节,使用task来编译项目,实际上是手动写g++命令,指定参数,来编译出可执行文件。
这种方式是最原始的,效率也是低下的。

2.1 一次性编译所有文件

缺点:只修改一个文件也需要重新编译所有文件

# 一次性编译链接
g++ -o calculator main.cpp calc.cpp -I.

# 运行
./calculator

2.2 分步编译

每个源文件单独编译出目标文件,再将所有的目标文件链接成可执行文件:

# 编译单个源文件,不链接,生成目标文件
g++ -c calc.cpp -o calc.o -I.
g++ -c main.cpp -o main.o -I.

# 链接所有目标文件,生成可执行文件
g++ main.o calc.o -o calculator

# 运行
./calculator

假设只修改了main.cpp

# 只编译被修改的文件
g++ -c main.cpp -o main.o -I.

# 链接所有目标文件,生成可执行文件
g++ main.o calc.o -o calculator

# 运行
./calculator

3. 使用make构建

3.1 Makefile实操

关于 Makefile,有非常多的语法规则,不是本课重点,不再展开~

直接手敲g++命令,无法自动管理依赖,也不方便增量编译(只编译修改的文件)
可以把编译/链接/安装步骤写入到名为Makefile的脚本中
有了Makefile,你不用每次手动敲一堆g++命令,就能高效、可维护地构建项目

创建了一个 Makefile文件,内容如下:

# 内置变量:表示 C++ 编译器
# 将变量 CXX 赋值为字符串 "g++"
# 如果要切换到clang++,只需改为 CXX = clang++
CXX = g++     

# 内置变量:C++编译标志
# -I.:包含当前目录作为头文件搜索路径
# -Wall:启用所有警告
# -std=c++11:使用C++11标准
CXXFLAGS = -I. -Wall -std=c++11

# 自定义变量:存储最终生成的可执行文件名
TARGET = calculator

# 自定义变量:列出所有C++源文件
SRCS = main.cpp calc.cpp

# 自定义变量:目标文件列表
# 模式替换,语法:$(VAR:pattern=replacement)
# 展开过程:
#   1. SRCS = "main.cpp calc.cpp"
#   2. 将 .cpp 替换为 .o
#   3. 结果:OBJS = "main.o calc.o"
OBJS = $(SRCS:.cpp=.o)

# 最后一行中 all 被.PHONY 声明为伪目标
# 它是默认目标,因为在 Makefile 中,第一个出现的非特殊目标就是默认目标
# 当只输入 make 而不指定目标时,make 会自动选择第一个目标进行构建
# :冒号是依赖关系分隔符,all 这个目标依赖 calculator
all: $(TARGET)

# 自动推导规则:.cpp -> .o
# %:通配符,匹配任意字符串
# 匹配任何.o文件依赖于同名的.cpp文件

# $(CXX):展开为g++
# $(CXXFLAGS):展开为-I. -Wall -std=c++11
# -c:编译但不链接

# $<:第一个依赖文件,对于main.o: main.cpp,$< = main.cpp
# $@:目标文件,对于main.o: main.cpp,$@ = main.o
# 完整展开示例:
# main.o: main.cpp
#	g++ -I. -Wall -std=c++11 -c main.cpp -o main.o
%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@

# 链接
# 展开:
# calculator: main.o calc.o
#       g++ main.o calc.o -o calculator
$(TARGET): $(OBJS)
	$(CXX) $(OBJS) -o $(TARGET)

# 指定目标文件依赖的头文件
# 作用:当calc.h修改时,main.o和calc.o都需要重新编译
main.o: calc.h
calc.o: calc.h

# 清理
# 最后一行中 clean 被.PHONY 声明为伪目标
# make clean 会执行该目标
# 展开:rm -f main.o calc.o calculator
clean:
	rm -f $(OBJS) $(TARGET)

# .PHONY:声明伪目标
# 伪目标不是真实文件,只是一个标签,用于防止与同名文件冲突
# 示例:
#   如果有文件叫clean,执行make clean会失败(除非声明为.PHONY)
#   声明后,make知道clean是动作,不是文件
.PHONY: all clean

执行 make 时的流程:

1. 默认执行第一个目标:all
2. 检查all的依赖:需要calculator
3. 检查calculator的依赖:需要main.o和calc.o
4. 检查main.o的依赖:
   匹配%.o: %.cpp模式规则 → main.o: main.cpp
   检查是否有main.cpp(有)
   检查显式依赖:main.o: calc.h
   如果main.cpp或calc.h比main.o新,则执行编译
5. 检查calc.o的依赖:类似过程
6. 所有.o文件就绪后:执行链接命令生成calculator

cmake

3.2 Makefile 的优点

使用 Makefile,有以下优点:

  • 自动化与可复现
    把编译命令、编译选项、链接、生成文件、安装、清理等写成规则,任何人运行make 都能得到相同结果。
  • 增量构建
    make根据时间戳和依赖只重新编译发生变化的源文件,极大节省时间(而不是每次全量编译)。
  • 依赖管理
    规则里指定哪些文件依赖哪些头文件,make能正确处理编译顺序与重建条件。
  • 并行构建
    使用make -jN并行执行多个独立编译任务,显著加速构建。
  • 封装复杂流程
    支持自定义目标(如test, install, distclean),可以把preprocesscodegen、测试、打包等流程集成进来。

3. CMake

Makefile存在以下问题:

  • 需要学习Makefile语法
  • 跨平台问题(需要条件判断)
  • 大型项目Makefile复杂
  • 依赖检测不够智能
  • 不支持安装、打包等高级功能

有没有更简单的方法呢?
答:CMake