从 Python 到 C++:把编译、调试、struct/class、Make/CMake 串成一条线

写完一段 C++ 代码,到能自己跑、能查错、能跨平台构建,中间到底经历了什么。这条主线,是这节课要交付给「只写过 Python、C++ 写过作业但编译/调试/构建没系统学过」的同学的唯一东西。围绕它,我们把 g++ 内部真实跑的四阶段流水线、gdb 抓运行时错的七个命令、struct 与 class 仅剩两条默认值的差异、Makefile 依赖图与 CMake 生成器这四块内容,用 Python 习惯作对照锚串起来。

读这门课时,脑子里先绷着这一条:写完代码不等于能跑,能跑不等于对,跑对了不一定能换台机器跑。下面四块就是按这条线一节一节推进的。


Python 边跑边翻、C++ 先翻再跑——为什么 C++ 必须先「出译本」

你在 Python 里写 print("hello"),终端敲 python hello.py,结果直接出来了。整个过程没有「等编译」这一步,敲完命令立刻看见输出。C++ 不一样:你写一段 std::cout << "hello" << std::endl; 存成 hello.cpp,敲 g++ hello.cpp -o hello 之后还得等几十毫秒到几秒(取决于文件大小),才能跑 ./hello。这几十毫秒里发生的事情,就是 C++ 与 Python 最根本的差别。

Python 这边,源码进入进程后会被先解析成一种叫「字节码(bytecode)」的中间形式,再由一个叫「bytecode interpreter(字节码解释器)」的执行引擎在进程里一条一条跑起来——Python 语言参考的官方执行模型页第 4.4.2 节明确写了这一点,区分了「广义解释器」与「在每个线程里实际执行编译后 Python 代码的字节码解释器」这两个概念Python 执行模型。你不需要记住这两个术语,只需要知道:Python 不是真的「一行一行现场翻」,而是先把源码一次性翻成字节码,再由解释器在内存里跑。C++ 这边必须先做完整本翻译——把整段 C++ 源码直接翻成 CPU 能直接吃的机器码,生成一个可执行文件——之后 CPU 才能跑它。这两件事发生的时间点不同,就是「编译型 vs 解释型」三个字真正想说的差别。

这段翻译的发生时间点差异是整条主线的第一跳。看一下两边的执行流水线:

图注:Python 与 C++ 执行流水线的关键差异:Python 在进程内解释字节码,C++ 由外部工具链四步翻译为可执行文件

两边的形状不同:Python 的处理单元是一个解释器进程,源码和字节码都在那个进程里;C++ 的处理单元是一连串外部工具,先把源码翻成独立的可执行文件,再丢给 CPU。下节我们会把这四个外部工具拆开看。

再补一条对照。Python 进程跑起来后,内存怎么释放是解释器的事——它的标准库 gc 模块管的就是这件事,而它自己管的是「补充(supplements)」引用计数解决不了的那部分循环引用(Python 官方 gc 模块页面第一段原句:Since the collector supplements the reference counting already used in Python, you can disable the collector if you are sure your program does not create reference cycles)Python gc 模块。C++ 默认没有这层机制:你在堆上 new 出来的对象,要自己 delete;现代做法是交给 std::unique_ptr / std::shared_ptr 这种智能指针来兜底。这条对照是开场的「左半边事实」,后面收尾时还会再回来一次——现在只需要记住一件事:C++ 的内存归属在你手上。

记住结论:Python 是「边翻边跑」,C++ 是「先翻再跑」。C++ 这一翻就是 g++ 干的活,下一节拆开看。


敲一行 g++ 表面是「一步」,内部走了四步

敲 g++ hello.cpp -o hello 这一行,在终端上看只是一个动作。在 GCC 内部,这一行被拆成四个阶段按固定顺序串起来跑。GCC 官方手册 Overall Options 一节对这件事有一句精确表述:Compilation can involve up to four stages: preprocessing, compilation proper, assembly and linking, always in that order(编译过程最多包含四个阶段:预处理、编译本身、汇编、链接,顺序固定)GCC 手册。这四个阶段名字里有几个关键词是 C++ 学习者后续会反复听到的,先认识一下:

阶段工具干了什么中间产物后缀
预处理cpp把 #include 展开成被包含文件的全部内容、把宏替换为展开后的代码.i
编译cc1 / cc1plus把预处理后的源码翻译成汇编语言.s
汇编as把汇编代码翻译成机器码(目标文件).o
链接ld把多个 .o 和库拼成一个可执行文件(无,默认 a.out 或 -o 指定名)

这张表怎么记?每个停步选项 -E / -S / -c 对应一个阶段:

  • g++ -E hello.cpp 停在预处理结束,把预处理后源码送到标准输出。
  • g++ -S hello.cpp 停在编译结束,产物是 hello.s(汇编源码)。
  • g++ -c hello.cpp 停在汇编结束,产物是 hello.o(可重定位目标文件)。
  • g++ hello.cpp -o hello(不写选项)四阶段全跑完,产物是 hello(可执行文件)。

中间产物 .i / .s / .o 是 GCC 内部四阶段留下的「脚印」——默认情况下 GCC 链接完就把这些临时文件删掉。所以平时你只敲 g++ hello.cpp -o hello 时根本看不到它们,必须显式加 -E / -S / -c 才能让这些产物保留下来gcc 四阶段选项。这张四阶段流水线图(来自 GCC 翻译流水线示意图)把刚才讲的对应关系画在一张图里——hello.c 经 preprocessor → compiler → assembler → linker 四步,中间夹着 hello.i / hello.s / hello.o 三个文件图标,最右端就是最终可执行文件 hello:

图注:GCC 四阶段流水线图。hello.c 从最左侧进入 preprocessor(cpp),产物是 hello.i;再进入 compiler(cc1),产物是 hello.s;再进入 assembler(as),产物是 hello.o;最后 linker(ld)把 hello.o 与底部送入的 libc.a 拼成最右端的 hello 可执行文件。中间三个文件图标正好对应 -E / -S / -c 三个停步选项的产物后缀。

这张图来自 Wikipedia / Wikimedia Commons 的 GCC_Translation_Diagram.jpg,不是 GCC 官方提供的图——但图中的节点与 GCC 手册里写的四阶段完全一一对应,文件名是示意图占位,真实项目里你看到的会是 main.cpp / main.i 等等。

到这里你应该能回答一个常见问题:「g++ 和 gcc 是不是两个编译器?」答案是同一个。GCC 手册 Invoking G++ 一节明确说:g++ is a program that calls GCC and automatically specifies linking against the C++ library(g++ 是调用 GCC 的一个程序,自动加上 C++ 标准库的链接)g++ 与 gcc 关系。翻译过来:gcc 也能编译 .cpp 文件,但链接阶段不会自动挂 C++ 标准库,所以你写 gcc hello.cpp -o hello 时只要代码里用了 std::xxx,链接就会报 undefined reference。g++ 与 gcc 在前三个阶段(预处理/编译/汇编)行为完全一致,只在链接阶段不同——g++ 默认挂上 libstdc++,gcc 不挂。这件事值得专门记一下,因为装 Linux 发行版时这两个命令通常同时存在,学生很容易把它们当成两个独立工具。

再看一个常见误解。「在 VSCode 里装了 C++ 插件就能跑 C++ 代码」——不对。VSCode / CLion / Visual Studio 这些 IDE(Integrated Development Environment,集成开发环境)只是编辑器加上一个按钮面板,真正翻译代码的是背后挂的 g++ / clang / MSVC。装 VSCode 的 C++ 插件不会自动装编译器;不装编译器,你按 IDE 上的「运行」按钮会直接报错。验证方法很简单——敲 g++ -v hello.cpp,GCC 会把内部依次调用的子程序一个个打印出来,你会看到 cc1plus(真正干编译活的子程序)、as(汇编器)、ld(链接器)的执行命令。这一招出自 GCC 手册 -v 选项的官方说明:Print (on standard error output) the commands executed to run the stages of compilation. Also print the version number of the compiler driver program and of the preprocessor and the compiler proper(打印编译各阶段执行的命令,并打印编译器驱动程序以及预处理器和编译器本身的版本号)GCC -v 选项。下面这张辅助图来自 Wikipedia / Wikimedia Commons 的 Compiler_design.svg,把「gcc/g++ 只是 driver、真正干活的是 cc1」这件事画出来——左端 gcc → cc1 一对就是「driver 与真正子程序」关系的可视化:

图注:编译器典型三阶段结构图。左侧 gcc 这个 driver(驱动程序)调用 cc1(真正干编译活的子程序)处理 source code,再交给 assembler,最后 linker 把两个 libraries 拼成两个 executables。图中楔形箭头由窄变宽代表数据流向。这一对「driver → 真正子程序」结构直接说明 g++ 不是「翻译代码」的工具,而是「调度翻译工具」的入口。

顺带校准一下时态:Red Hat Developer 在 2024-05 的解读明确说 GCC 14.1 于 2024-05-07 发布,Fedora 40(2024-04-23 GA)已采用 GCC 14 作为系统编译器,RHEL 10 计划以 GCC 14 为系统编译器;该解读还指出 GCC 14.x 系列默认按 -std=gnu++17 翻译 C++ 代码,GCC 14 范围内 C++20/23/26 仍标 experimental,要用得显式写 -std=c++23(GCC 15 起的默认方言与 experimental 范围以该版本 release notes 为准)GCC 14 默认方言。这条事实帮你避免写出「g++ 默认 C++11」这种过时的句子。

记住结论:「敲一行 g++」表面是一步,内部是四步;记住 -E / -S / -c + .i / .s / .o 对应关系,下一节 gdb 接手的就是这一步之后的产物。


编译期抓不到的错,gdb 在你跑起来之后替你抓

编译器是「写代码时」抓错的工具——语法错(漏分号、括号不匹配)、类型错(int 当 std::string 用)、明显的链接错(找不到符号),这些 g++ 在编译阶段就能拒绝,并告诉你错在哪一行。但有几类错 g++ 抓不到:

  • 数组越界(访问了 arr[10],但 arr 只有 5 个元素)
  • 空指针解引用(对一个 nullptr 调方法)
  • 逻辑错(你以为答案是 5,但程序算出来是 7)
  • 死循环(程序卡住不动)

这些错的特点是:源码层面语法类型都对,编译器放行;运行时才暴露。这类错归 gdb 管。gdb 是「运行时」抓错的工具——它在你程序跑起来之后让你停下来看「当时发生了什么」。

gdb 的官方手册第 4.1 节 Compiling for Debugging 第一段就把这个分工点破了:In order to debug a program effectively, you need to generate debugging information when you compile it. To request debugging information, specify the -g option when you run the compiler(要有效调试程序,编译时必须生成调试信息。用 -g 选项请求调试信息)gdb -g 编译标志。所以用 gdb 之前第一步永远是先 g++ -g hello.cpp -o hello——没加 -g 编译,gdb 看到的就是「裸机器码」,无法告诉你变量名、行号。手册里还加了一句更狠的:We recommend that you always use -g whenever you compile a program. You may think your program is correct, but there is no sense in pushing your luck(建议每次编译都加 -g,哪怕你觉得自己程序是对的,也别赌运气)gdb 推荐 -g。

然后 gdb ./hello 进去。打交道的核心是七个命令,各自干一件事。gdb 手册把它们分散在第 4、5、8、10 章,下面按典型使用顺序串起来:

每个命令在 gdb 手册里都有对应的官方原句定义:

  1. break locspec(第 5.1.1 节):在某个函数名、行号或地址处设断点,让程序跑到那里停下来。原句:Breakpoints are set with the break command... break locspec — Set a breakpoint at all the code locations in your program that result from resolving the given locspec(断点用 break 命令设置,可在函数名、行号、指令地址等多种位置设)gdb break。
  2. run(第 4.2 节):真正执行程序。原句:Use the run command to start your program under GDB(用 run 在 gdb 控制下启动程序)gdb run。
  3. next(第 5.2 节):执行当前栈帧的下一行;遇到函数调用,把整个被调函数当成一步跑完,不跳进去。原句:This is similar to step, but function calls that appear within the line of code are executed without stopping(与 step 类似,但当前行里的函数调用会被执行完而不停下)gdb next。
  4. step(第 5.2 节):跟 next 一样执行下一行,但当前行里的函数调用会跳进去。原句:step steps inside any functions called within the line(step 会进入当前行里被调用的函数)gdb step。
  5. print expr(第 10 章 Examining Data):在程序停在某行时打印当时变量值。可以打局部变量、全局变量、指针指向的内容(print *p)、结构体成员(print s.x)。
  6. backtrace(第 8.2 节):把程序是怎么走到崩那一行的整条函数调用链打出来。原句:A backtrace is a summary of how your program got where it is. It shows one line per frame(backtrace 是程序如何走到当前位置的总结,每层栈一帧)gdb backtrace。
  7. watch expr(第 5.1.2 节):不用告诉 gdb 在哪一行停,只要告诉它「盯着 x 这个变量」,x 一被赋值程序就停。原句:You can use a watchpoint to stop execution whenever the value of an expression changes, without having to predict a particular place where this may happen(watchpoint 能在表达式值变化时停下,无需预测变化会发生在哪个位置)gdb watch。

七个命令中最容易卡的是 next 与 step 的区别:next 不进被调函数,step 进。再单独说一句:你按 step 没进 helper 函数,通常有两个原因——helper 没有用 -g 编译,或者你实际敲的是 n(next)而不是 s(step)。gdb 手册原文也警告:If you use the step command while control is within a function that was compiled without debugging information, execution proceeds until control reaches a function that does have debugging information(在没有调试信息的函数里用 step,gdb 会跳过它直到下一个有调试信息的函数)gdb step 警告。

用法串起来是这样的:先 break main 在入口设断点,再 run 让程序跑起来停在 main 第一行,然后 next / step 一行一行走,碰到可疑的地方 print x 看变量值,崩了就 backtrace 看调用链,找不到哪里把变量改坏了就 watch x。这是 gdb 调试的最朴素链路。

记住结论:g++ 编译能过 ≠ 程序正确。崩了之后用 break → run → print / bt 这一条最朴素链路就能看清崩在哪个函数、当时变量是几。


struct 与 class:差别只剩两条默认值,功能几乎一样

你可能听过这样的说法:「struct 只用来存数据,class 才是写逻辑的」。这在 Python 里大致成立——Python 的 dataclass 是个装饰器,跟 class 不算并列关键字,习惯上确实拿 class 写完整对象、用 namedtuple / dataclass 写纯数据。但搬到 C++ 里,这句话就错了。cppreference 的 Class declaration 页 §Syntax 那一节里 class-key 的描述一上来就把这件事钉死:The keywords class and struct are identical except for the default member access and the default base class access(class 和 struct 这两个关键字是相同的,差别只在默认成员访问与默认基类访问)cppreference struct/class 锚句。这句话是权威结论。

那么「默认成员访问」和「默认基类访问」具体指什么?同一组文档树里的 Access specifiers 页 In detail 一段直接给了两条默认值规则:

A class defined with the keyword class has private access for its members and its base classes by default. A class defined with the keyword struct has public access for its members and its base classes by default(用 class 定义时,成员与基类默认 private;用 struct 定义时,成员与基类默认 public)cppreference 访问控制。

派生类的姊妹页 Derived classes 又写了一次,专门讲 base-clause(继承列表):If access-specifier is omitted, it defaults to public for derived classes declared with class-key struct and to private for derived classes declared with class-key class(继承列表里省略访问说明符时,struct 派生类默认 public 继承,class 派生类默认 private 继承)cppreference 继承默认值。

四条规则落到一张 2×2 表里就是:

维度structclass
默认成员访问publicprivate
默认基类访问(省略访问说明符时)publicprivate

就这两条默认值。除此之外,C++ 允许 struct 和 class 都能装什么?同样的成员——数据成员、成员函数、构造函数、虚函数、继承、模板、嵌套类。所以下面这段代码完全合法、完全可以编译通过:

struct S {
    int x;
    S(int v) : x(v) {}      // 构造函数
    int get() const { return x; }  // 成员函数
    virtual void f() {}     // 虚函数
};

class C {
    int x;                  // 默认 private
public:
    C(int v) : x(v) {}
    int get() const { return x; }
    virtual void f() {}
};

struct S { int x; }; 里的 x 默认 public,外面可以直接 S s; s.x = 5;;class C { int x; }; 里的 x 默认 private,外面写 c.x = 5; 就编译失败。但 S 和 C 都能写构造函数、成员函数、虚函数——所以「struct 只能存数据」这句话在 C++ 里是错的。

写代码时的实际建议:成员都是公开的纯数据结构用 struct;成员有内部状态、需要封装就写 class 并显式标 public: / private:。这条只是惯例,不是 C++ 标准强制。两者在功能上等价。

记住结论:struct 与 class 在 C++ 里是一对同义词,差别只在「成员默认访问权限」和「默认继承方式」这两条默认值;「struct 只能存数据」是从 Python 习惯错位带过来的误解。


Makefile 是「依赖图 + 命令」,CMake 是「生成器」

前几节我们一直在敲单文件命令:g++ hello.cpp -o hello。但项目不会只有一个文件——通常有两个 .cpp 加一个 .h。如果还靠手敲命令,每次改一个 .cpp 就得把所有命令再敲一遍,文件多起来之后这根本不可持续。Makefile 就是把这件重复劳动自动化的工具。

GNU Make 官方手册的 An Introduction to Makefiles 给出了 Makefile 的本质——它是一份规则集合,每条规则描述「什么时候该重做某文件、用什么命令做」。手册第 4.2 节 Rule Syntax 把这件事浓缩成一句最该背下来的官方原句:A rule tells make two things: when the targets are out of date, and how to update them when necessary(一条规则告诉 make 两件事:何时目标是过期的,以及必要时如何更新它)Makefile 规则定义。

规则的三段式语法长这样:

target ... : prerequisites ...
        recipe
        ...

三段的含义:target 是要生成的文件(一般是 .o 或可执行文件);prerequisite 是 target 依赖的输入文件;recipe 是 shell 命令,make 调用 shell 执行。注意 recipe 行必须以 tab 缩进——GNU Make 手册原文直接说:you need to put a tab character at the beginning of every recipe line! This is an obscurity that catches the unwary(每行 recipe 前必须加 tab 字符,这是一个容易绊倒粗心人的「晦涩」要求)Makefile tab 缩进。这意味着你用空格编辑器写 Makefile 时第一件事是确认缩进是 tab 不是空格。

「何时该重做」由过期判断决定——手册原文:A target is out of date if it does not exist or if it is older than any of the prerequisites (by comparison of last-modification times)(目标过期当且仅当它不存在,或者它的修改时间比任意 prerequisite 旧)Makefile 过期判断。这条规则的后果是:make 沿依赖图自顶向下递归处理,只重编需要重编的部分。

举个最朴素的多文件例子。假设 main.cpp、utils.cpp、utils.h 三个文件。Makefile 写成这样:

all: main

main: main.o utils.o
        g++ -g main.o utils.o -o main

main.o: main.cpp utils.h
        g++ -g -c main.cpp

utils.o: utils.cpp utils.h
        g++ -g -c utils.cpp

.PHONY: clean
clean:
        -rm -f main *.o

跑 make 时发生的事:make 读这份 Makefile,从第一条规则的第一个 target all 开始(这是默认目标,make 手册 How Make Works 一节说 By default, make starts with the first target,默认目标是第一条规则的第一个 target)Makefile 默认目标;要生成 all,先看它的依赖 main 是否过期;要生成 main,先看 main.o 和 utils.o 是否过期;每个 .o 又依赖它对应的 .cpp 和 .h。最后 make 顺着这条依赖图往回跑,按需执行 recipe。如果只改了 main.cpp,下一次 make 只重编 main.o 并重链 main,utils.o 不动。这就是「依赖图 + mtime」协作的产出。

clean 那条规则用的是 phony target(伪目标)。GNU Make 手册第 4.6 节 Phony Targets 给出定义:A phony target is one that is not really the name of a file; rather it is just a name for a recipe to be executed when you make an explicit request(伪目标不是一个真实文件名,它只是一个「接到明确请求时执行 recipe」的名字)phony target。为什么需要它?因为如果当前目录里恰好有一个叫 clean 的文件,make 会把 clean 当真实目标处理,比较它的 mtime,发现「不需要重做」——你的 make clean 就不执行了。把 clean 放进 .PHONY: 列表里,make 就永远把它当过期,永远执行下面的 rm。rm 前加 -(写成 -rm)意思是忽略错误——即使没有 main 或没有 .o,这条命令也不让 make 停下来,这是 Cleanup 一节给出的稳健写法Makefile clean 写法。

上面这份 Makefile 解决了一个工程问题:项目只有 Linux + GNU Make 一种构建环境。但如果项目要交给用 Windows + Visual Studio 的同事,怎么让他也能一键构建?每台机器手写一份 Makefile?这就是 CMake 解决的问题。

CMake 官方教程 Step 1 给出了 CMake 项目最朴素的形态——一份三行 CMakeLists.txt 就够了:The most basic CMake project is an executable built from a single source code file. For simple projects like this, a CMakeLists.txt file with three commands is all that is required(最基本的 CMake 项目就是从单个源文件构建出一个可执行文件,对于这样简单的项目,三行命令的 CMakeLists.txt 就够了)CMake 三行模板。三行各自干什么:

cmake_minimum_required(VERSION 3.10)   # 最低 CMake 版本
project(Tutorial)                       # 项目入口(探测编译器)
add_executable(Tutorial tutorial.cxx)   # 声明可执行文件由哪些源文件生成

第一行的 VERSION 3.10 不是随便填的。CMake 官方命令参考 cmake_minimum_required 页说:If the running version of CMake is lower than the <min> required version it will stop processing the project and report an error(如果运行中的 CMake 版本低于 <min> 声明的版本,CMake 会停止处理并报错)cmake_minimum_required 语义。同时这一行还会隐式调用 cmake_policy(VERSION ...),把所有已发布的 CMPxxxx 策略回退到对应版本的新行为;CMake 4.0 起,policy version 必须至少是 3.5,低于 3.5 直接 error 而不是 warningCMake 4.0 policy 3.5 强制。那么现在到底该填多少?Modern CMake 社区文档给出了一条朴素的实践建议:Never select a minimum version older than the oldest compiler you support(永远不要选比你支持的最老编译器还旧的最低版本)Modern CMake 选最低版本——也就是按你目标用户机器上最老的编译器对应那个 CMake 版本起步。

但 CMake 这一行自己并不编译任何代码。它做的事是把这份 CMakeLists.txt 翻译成当前平台用的构建脚本:Linux 上默认产出 Makefile(给 GNU Make 跑),装了 Ninja 后可指定 -G Ninja 产出 build.ninja(给 Ninja 跑),装了 Visual Studio 后产出 .sln / .vcxproj(给 MSBuild 跑)。这三种「下一跳」分别是 GNU Make / Ninja / MSBuild/IDE,但最终都把编译/链接交到 g++/clang/MSVC。这是「CMake 是生成器不是编译器」的最朴素分层——

跑起来的标准两步(CMake 4.x 系列官方推荐入口):cmake -B build 让 CMake 在 build/ 目录生成对应平台的构建脚本,再 cmake --build build 让那个本地构建系统完成编译——后者是 build tool agnostic 的统一入口,不必关心底层是 GNU Make 还是 Ninja 还是 MSBuildcmake --build 入口。

记住结论:Makefile 是「谁依赖谁、用什么命令编译」的清单;CMakeLists.txt 是「跨平台的构建描述」;CMake 自己不编译,它生成给当前平台的构建脚本,真正干活的是背后的 GNU Make / Ninja / MSBuild + 编译器。


把四块串成一条线:多文件小项目走完全链路

这一节我们拿一个最小可运行的多文件小项目,把前五节的内容走一遍。假设目录里有 main.cpp、utils.cpp、utils.h 三个文件:

// utils.h
#pragma once
int add(int a, int b);

// utils.cpp
#include "utils.h"
int add(int a, int b) { return a + b; }

// main.cpp
#include "utils.h"
#include <iostream>
int main() {
    std::cout << add(2, 3) << std::endl;  // 假设输出错了,不是 5
    return 0;
}

第一步:用 g++ 编译并跑(接第二章)。敲:

g++ -g main.cpp utils.cpp -o main

这一步内部走完四阶段:预处理展开 #include "utils.h" → 编译成 main.s 和 utils.s → 汇编成 main.o 和 utils.o → 链接两个 .o 成 main。-g 是为下一步调试做准备。跑 ./main 输出错——例如不是 5,是别的数。

第二步:用 gdb 查错(接第三章)。敲:

gdb ./main
(gdb) break main
(gdb) run
(gdb) next
(gdb) print 2+3

print 2+3 让 gdb 直接算表达式、看到正确结果;接下来 step 进 add 函数,看是不是 a + b 写错,或者 print a print b 看传入的参数。这是最朴素的 gdb 排查链路。

第三步:把 g++ 命令搬进 Makefile(接第五章前半)。写一份 Makefile 描述「谁依赖谁、用什么命令编译」——刚才 g++ -g main.cpp utils.cpp -o main 那一行拆成三条 recipe:两条 .o 编译规则加一条链接规则。再敲一次 make,得到同样的 main。但好处是下一次只改 main.cpp 时,make 只会重编 main.o 并重链 main,utils.o 不动——「依赖图 + mtime」协作的产出。

第四步:抽象到 CMakeLists.txt(接第五章后半)。写一份三行 CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(Tutorial)
add_executable(Tutorial main.cpp utils.cpp)

然后 cmake -B build && cmake --build build——同一份源,在 Linux 上产出 Makefile 给 GNU Make 跑,在 Windows + VS 上产出 .sln 给 MSBuild 跑。无需手写两份 Makefile / 工程文件。CMake 这一层做的就是把构建描述从「具体工具」抽到「抽象规则」。

回锚开场对照(接第一章)。Python 这边,从源码到能跑是一句 python main.py——解释器在进程里跑字节码,gc 模块补循环引用,内存管理归解释器。C++ 这边,从源码到能跑要走 g++ / gdb / Make / CMake 四件套配合:g++ 负责把源码翻译成机器码,gdb 负责跑起来之后查运行时错,Makefile 让多文件增量构建自动化,CMake 让构建描述跨平台。前者是单条命令;后者是工具链协作。

这条链路上的每一节都是这条主线的递进——不是孤立四块。下次你拿到一个多文件 C++ 小项目,先 g++ -g 编译,再 gdb 查错,写 Makefile 让增量构建自动化,再写 CMakeLists.txt 让它跨平台。整条链路就这几件事。

记住结论:从「写完代码」到「能跑、能查错、能跨平台构建」这一整条链路,g++ / gdb / Make / CMake 各负责一截;上完这节课,你应该能自己用这四件套把一个多文件 C++ 小项目在 Linux / Windows 上各跑通一次。


参考文献

本专题引用的核心资料如下,建议深入学习时阅读原文:

  1. GCC Manual — Overall Options / Invoking GCC / Invoking G++ — https://gcc.gnu.org/onlinedocs/gcc/Overall-Options.html
  2. Debugging with GDB — GNU Project Debugger official manual — https://sourceware.org/gdb/current/onlinedocs/gdb.html
  3. Class declaration — cppreference.com — https://en.cppreference.com/w/cpp/language/class
  4. Access specifiers — cppreference.com — https://en.cppreference.com/w/cpp/language/access
  5. GNU Make Manual — An Introduction to Makefiles / Writing Rules — https://www.gnu.org/software/make/manual/html_node/Introduction.html
  6. CMake Tutorial — Step 1: A Basic Starting Point — https://cmake.org/cmake/help/v4.1/guide/tutorial/A%20Basic%20Starting%20Point.html
  7. Modern CMake — Do's and Don'ts: Selecting a minimum — https://cliutils.gitlab.io/modern-cmake/
  8. gc — Garbage Collector interface (Python 3.15.0 docs) — https://docs.python.org/3/library/gc.html
  9. CPython — Execution model (Python language reference §4) — https://docs.python.org/3/reference/executionmodel.html
  10. New C++ features in GCC 14 — Red Hat Developer blog — https://developers.redhat.com/articles/2024/05/15/new-c-features-gcc-14
  11. cmake_minimum_required — CMake 4.x official command reference — https://cmake.org/cmake/help/latest/command/cmake_minimum_required.html