你在一家国内头部私募 quant 桌的第一天上午,负责低延迟策略代码的 senior 把一把 USB key 拍在你桌上,指着旁边那台跑 Ubuntu 的 Linux 工作站说:"中午之前把一个 Black-Scholes 定价器编出来跑通。你用什么编辑器我不管,但 build 必须能从干净的仓库一键复现。"过去三个 Python 子学科没有为你接下来这九十秒做过准备。CPython 里你敲 python script.py,解释器把剩下的事都办了。在 C++ 里一个 .cpp 文件并不是程序——它是一份程序的源代码,而那个程序在你把它依次走完三道工序之前并不存在:预处理、编译、链接。在你能从命令行驱动这个循环之前,任何编辑器和任何构建系统都救不了你。本课就是把这个循环搬到你眼前,用 g++ 与 CMake,在一个 hello.cpp 上跑通。本课结束时你将以两种方式构建同一个程序、在 debug 与 release 之间切换、并在 gdb 里单步执行一行。本模块后四课都默认你能不假思索地走完这套流程。
三道工序:预处理、编译、链接
当你把一个 .cpp 文件交给 g++,会按顺序发生三件事。第一,预处理器遍历源码,把每一条 #include 指令(把对应头文件的内容粘贴进来)与每一个宏定义按字面展开。在下面这个文件上跑一下 g++ -E hello.cpp 并把输出管道给 wc -l——你会看到仅仅一行 #include <iostream> 就能扩展出大约三万行文本。那是整个 iostream 头文件传递性地拖入了 <ios>、<streambuf>、<ostream>、<istream>,以及一连串 libstdc++ 内部实现头。预处理器为每个 .cpp 产出一个逻辑文本文件,称作翻译单元(translation unit)。
第二,编译器把这份翻译单元转成一个目标文件(object file),里面包含函数体的机器码,加上一张符号引用表——比如 std::cout 与 std::__ostream_insert 这种本翻译单元用到但并未定义的名字。跑 g++ -S hello.cpp 可以看到中间汇编(编译器输出的可读形式),跑 g++ -c hello.cpp 则得到二进制目标文件 hello.o。你今天不需要读懂这份汇编;你需要知道这个文件存在,并且编译器到这一步就停了。
第三,链接器把一个或多个 .o 文件与 C++ 标准库拼起来,解析掉上面那些符号引用,产出最终的可执行文件。当链接器找不到某个符号——通常是因为你忘了把某个翻译单元加进来、或者忘了链某个库——你就会看到那个著名的 undefined reference to ... 错误。第 5 课会在你把一个工程拆到多个文件时回到这个话题;现在,记住三道工序的画面就够了。
一行命令的构建
把三道工序打包成日常一行命令,就是一次 g++ 调用:
g++ -std=c++17 -Wall -Wextra -O2 hello.cpp -o hello
./hello
对应的源码是:
#include <iostream>
int main() {
std::cout << "hello, world\n";
return 0;
}
每一个 flag 都有其存在的理由。-std=c++17 锁定语言标准。本模块以 C++17 为目标,因为它是后续四个模块都假设的稳定公分母:GCC 7+、clang 5+,以及当下任何主流 Linux 发行版都把 -std=c++17 发得齐齐整整。-Wall 与 -Wextra 合起来打开了一个真正干活的 C++ 开发者会当作错误对待的那一族警告——未初始化变量、有符号与无符号比较、未使用参数、缺失 return 语句。-O2 是标准的 release 优化级别:激进的内联、循环展开、常量折叠、死代码消除,同时把编译时间与栈追踪的可读性控制在合理范围。-o hello 命名输出。不写它,g++ 会写到 a.out——那是 1970 年代 Unix 留下来的化石,绝不是你想要的。
值得一并报出名字的两个邻居。clang++ 对本模块涉及的一切都是 g++ 的即插即换替换,命令行字面一致。-Werror 把所有警告升级为错误,是生产 CI 一定会打开的开关。本模块为了让你看见警告把它关着;真实代码库会打开它并把每一条警告都当作 build 中断处理。
通过 CMake 构建同一个程序
单文件 g++ 调用撑不起第 5 课要做的多目标工程。你现在就在最小例子上引入 CMake,这样后四课都能直接靠它。在 hello.cpp 旁边写一份 CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(hello CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(hello hello.cpp)
target_compile_options(hello PRIVATE -Wall -Wextra)
加上 project 与第一个 set 之间那一行空行,刚好 12 行左右。cmake_minimum_required 声明这份脚本支持的最低 CMake 版本;锁 3.20 让你能用上现代的 target_* 命令族,同时不把任何 2021 年以后的 Linux 机器排除在外。project(hello CXX) 命名工程并声明它用到的语言。两行 set 锁定 C++17,并禁止编译器在无法满足请求时悄悄退回 C++14。add_executable(hello hello.cpp) 声明一个名为 hello 的目标,由一个源文件构成。target_compile_options(hello PRIVATE -Wall -Wextra) 把警告 flag 挂到这个目标上——PRIVATE 意味着这些 flag 只作用于本目标,不会传播给依赖它的别人。
用两条命令构建:
cmake -S . -B build -G Ninja
cmake --build build
-S . 与 -B build 是源外构建(out-of-source build)惯例:-S 是源目录(放 CMakeLists.txt 的那个),-B 是构建目录(CMake 会替你创建并填进生成文件的兄弟目录)。源外构建把构建产物挡在源代码树之外,让你可以 rm -rf build 一键清空构建而不碰代码。-G Ninja 选择 Ninja 作为底层构建工具,而不是默认的 Make。Ninja 增量构建更快,对学习者而言没有语义差异;本模块其余部分都默认使用它。镜像加速一句:清华、中科大、阿里云的 apt 镜像可以显著加快 build-essential 与 cmake 的安装。运行 ./build/hello,你应该看到 hello, world。
debug 与 release
debug 构建是你写代码时编出来的东西;release 构建是你交付出去的东西。它们的区别在三个 flag。debug:-g -O0 -fsanitize=address。-g 嵌入行号与变量信息,让调试器能把机器码映射回你的源代码;-O0 禁用优化,程序按你写的样子执行(变量留在内存而非寄存器,单步执行一行源代码对应一步机器指令是可靠的);-fsanitize=address 给内存访问加上插桩,在运行时捕获 use-after-free 与 heap-buffer-overflow——第 3 课会重度依赖它。release:-O2 -DNDEBUG。-O2 就是上面讲过的优化级;-DNDEBUG 定义那个让标准库里所有 assert() 失效的宏。
通过 CMake 切换二者,只是 configure 时多一个 flag:
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
把 Debug 换成 Release 再跑一遍就切回来了。你不需要改 CMakeLists.txt 来切换 build 类型——这是 configure 时刻的决定。真实工程会同时维护 build-debug/ 与 build-release/ 两个目录,让两边的缓存都热着。
在 gdb 里单步
最后一块是调试器。用 -DCMAKE_BUILD_TYPE=Debug 重建,然后在 gdb 里启动那个二进制:
$ gdb ./build/hello
(gdb) break main
Breakpoint 1 at 0x1149: file hello.cpp, line 4.
(gdb) run
Starting program: /tmp/hello
Breakpoint 1, main () at hello.cpp:4
4 std::cout << "hello, world\n";
(gdb) next
5 return 0;
(gdb) print x
No symbol "x" in current context.
(gdb) continue
Continuing.
hello, world
[Inferior 1 (process 1) exited normally]
(gdb) quit
七条命令足以覆盖本系列 90% 的调试动作:break <位置> 设置断点,run 启动程序,next 单步跨过一行源代码(step 则会跨进函数调用),print <表达式> 在当前栈帧里求值,continue 继续运行直到下一个断点或程序退出,quit 退出调试器。这里 print x 失败是因为 hello.cpp 里没有名为 x 的变量——第 2 课会给你可以 print 的东西。从第 2 课起,当某个测试挂掉,你就掉进调试器、设断点、单步、print、continue。从现在开始就让它变成肌肉记忆。
通往下一课
你已经有了能跑通的「编辑—构建—运行」循环,能用一个 flag 在 debug 与 release 之间切,也能驱动 gdb 确认代码跑到了 main。第 2 课介绍 C++17 类型系统——<cstdint> 里的固定宽度整数、价位用的 IEEE-754 double、const 正确性、enum class——并以一个自由函数 black_scholes_call 收尾,对一只 510300.SH 沪深 300 ETF 欧式看涨期权定价。第 2 课每一段代码都会用你刚才敲下的这套 CMake 构建。工具链不再是障碍;语言才是。
练习
Exercise
拿本课的 hello.cpp,(a) 用 g++ -std=c++17 -Wall -Wextra -O2 hello.cpp -o hello_release 构建一份;(b) 用 g++ -std=c++17 -Wall -Wextra -g -O0 hello.cpp -o hello_debug 构建另一份;(c) 用 ls -l hello_release hello_debug 比较两个二进制,报告哪个更大、大约几倍;(d) 跑 file hello_debug 报告输出中是否包含字符串 with debug_info;(e) 在 gdb 里启动 ./hello_release 并直接用 quit 退出,不要设任何断点。
提示
提示
gdb ./hello_release,在 (gdb) 提示符下输入 quit——gdb 会干净退出,因为你从未输入 run,程序根本没跑起来。