C++:从一行代码到能运行的程序
0. 前言
由于本科教学安排的问题,我至今不了解C++程序是如何被构建的,对于一些常识也一无所知。最近不少工具需要从源码构建软件,借此机会在DeepSeek的辅助下厘清了一些关键概念,对Windows x64平台的C++程序编译流程有了个大概了解。想必不记录的话不用几天我就会全部忘掉,干脆和AI一起整理了一下总结成博文好了。
1. 术语表
0.1 关于"代码"的三个词
| 词 | 通俗解释 | 细节 |
|---|---|---|
源文件 / 实现文件(.cpp) |
真正写逻辑的地方 | 每个 .cpp 会被单独编译,互相不知道对方存在 |
头文件(.h / .hpp) |
说明书:告诉别人"有哪些函数可用、长什么样" | 头文件不单独编译,它被 #include 贴进 .cpp 里一起编译 |
| 翻译单元 TU(translation unit) | 一个 .cpp 加上它 #include 进来的一堆头文件,拼成的完整一份待编译材料 |
这是编译器真正面对的东西。也是"改一个头文件要重编一堆文件"的原因 |
0.2 关于"产物"的六个词
| 词 | 通俗解释 | 细节 |
|---|---|---|
目标文件(.obj) |
一块预制件:一个 .cpp 编译出来的机器码半成品 |
有机器码,但里面有些"这里要调用别人的函数"的空缺还没填上(那些叫"未解析符号") |
| 符号(symbol) | 函数或变量的链接期名字 | 编译后名字会变形(叫"修饰名"),例如 ?Seed@random@utility@open3d@@YAXH@Z。链接器靠它对账 |
静态库(.lib,archive) |
一箱预制件:若干 .obj 打包成一个文件 |
它不是能运行的东西,只是一只箱子。谁用它,谁就把里面的预制件复制进自己的成品 |
导入库(.lib,import library) |
一张取货单:不是零件,只写着"某某函数在某个 dll 里" | 配合 DLL 使用。链接时用它把符号对上,运行时真正的代码由 DLL 提供 |
映像(image:.dll / .exe) |
能开门营业的成品:操作系统能直接加载的东西 | .exe 和 .dll 是同一种文件格式(PE),区别在标志位、入口点、有没有"对外供货清单"(导出表) |
PDB(.pdb) |
调试符号表:把机器码地址翻译回"哪个源文件第几行、变量叫什么"的字典 | 只用于调试,不参与编译和链接。缺了它程序照跑,只是调试器看不进源码 |
0.3 关于"工具和流程"的七个词
| 词 | 通俗解释 | 细节 |
|---|---|---|
| CMakeLists.txt | 总编排脚本:说明"这个项目由哪些部分组成、产出什么、依赖谁" | 用 CMake 自己的语言写。改它必须重新"编排"(configure) |
| CMake 程序 | 编排员:读 CMakeLists.txt,算出"要产出哪些成品、各自需要哪些材料",然后把它写成施工队能读的工单 |
CMake 自己不编译任何代码 |
| configure(配置) | 编排阶段:跑一遍所有 CMakeLists.txt,探测编译器/SDK/依赖,把结果写进缓存 |
产物是 CMakeCache.txt。耗时几秒到几分钟 |
| generate(生成) | 出图阶段:把编排结果写成具体格式的工单 | 生成什么格式由 -G(生成器)决定 |
| 生成器 generator | 决定"工单用哪种格式" | -G "Visual Studio 18 2026" → 出 .slnx + .vcxproj;-G Ninja → 出 build.ninja |
| MSBuild | 工地主管:读工单(.vcxproj),决定"哪些活要干、按什么顺序干",然后叫工人开工 |
真正的编译/链接命令是它发出去的 |
0.4 关于"第二次为什么快"
| 词 | 通俗解释 | 细节 | 实例 |
|---|---|---|---|
| 增量构建 | 只重做受影响的那部分,而不是从头再来 | 判定依据是"时间戳比较 + 台账",见第 3 章 | — |
| tlog(跟踪日志) | 工地台账:记录"上次每块预制件用了哪些材料、用了什么工艺、产出了什么" | MSBuild 每次开工前读台账 + 比时间戳,决定哪些活要重干 | utility.dir\Debug\utility.tlog\CL.read.1.tlog 等 |
0.5 三类容易混的"库"
| 类型 | 比喻 | 编译期怎么用 | 运行时要不要带 |
|---|---|---|---|
| 静态库 / OBJECT 库 | 一箱预制件 / 一堆散装零件 | 预制件被复制进成品 | 不用带 |
| 导入库 + DLL | 取货单 + 共享仓库 | 只用取货单对符号 | 必须带着 DLL |
| 可执行文件 | 能开门营业的楼 | 不能被别人拼进自己的成品 | 自己就是入口 |
2. 全过程:五步流水线
① 源文件 ② 编排 ③ 出工单
src/*.cpp/h → cmake 程序 → Open3D.slnx + 113 个 .vcxproj
(图纸) (读 CMakeLists) (施工队能读的工单)
④ 编译 ⑤ 总装
cl.exe → link.exe → Open3D.dll / tests.exe
(图纸→预制件) (预制件→成品) (能跑的东西)
一句话总结每一步:
| 步骤 | 负责人 | 输入 | 输出 | 实例 |
|---|---|---|---|---|
| ① 写源码 | 程序员 | — | .cpp / .h |
cpp\open3d\utility\Random.cpp |
| ② 编排 configure | cmake 程序 | 所有 CMakeLists.txt + -D 选项 |
CMakeCache.txt(内存里还有一张"要产出什么"的清单) |
build-debug\CMakeCache.txt |
| ③ 出工单 generate | cmake 程序 + 生成器 | 上面那张清单 | 解决方案 + 每个成品一份 .vcxproj |
build-debug\Open3D.slnx、...\utility\utility.vcxproj |
| ④ 编译 | MSBuild 叫 cl.exe |
一份 TU | 一块 .obj |
utility.dir\Debug\Random.obj |
| ⑤ 总装 | MSBuild 叫 link.exe |
一堆 .obj + 第三方 .lib |
.dll / .exe(+ 导入库 + .pdb) |
Open3D.dll、Open3D.lib、Open3D.pdb |
关键理解:CMake 只负责 ②③(编排和出工单),它从不编译。真正编译的是 ④⑤,由 VS 里的 MSBuild 驱动。
3. 每一步的细节
第 1 步:源文件
磁盘上就是一堆文本文件,按目录摆放:
cpp\open3d\utility\Random.cpp ← 库的实现
cpp\open3d\utility\Random.h ← 库的说明书
cpp\tests\utility\Random.cpp ← 库的测试(另一个 .cpp,同名但无关)
第 2 步:编排(configure)
你敲的命令:
1 | cmake -S . -B build-debug -G "Visual Studio 18 2026" -A x64 -T v142 ` |
cmake 会:
-
从头到尾执行所有
CMakeLists.txt; -
探测环境(用哪个编译器、哪个 Windows SDK、有没有 Python);
-
把结果写进
build-debug\CMakeCache.txt; -
在内存里构造出一张清单:要产出哪些成品、每个成品用哪些源文件、依赖哪些第三方。
第 3 步:出工单(generate)
生成器把那张清单写成磁盘文件:
build-debug\Open3D.slnx ← 解决方案(113 个项目 + 35 个分组)
build-debug\cpp\open3d\utility\utility.vcxproj ← "utility 这个成品"的工单
build-debug\cpp\open3d\Open3D.vcxproj ← "Open3D.dll 这个成品"的工单
build-debug\cpp\tests\tests.vcxproj ← "tests.exe 这个成品"的工单
工单(.vcxproj)是纯文本 XML,可以直接读。里面写着三类关键信息:
1 | <!-- 1. 这个成品是什么类型(决定产出 exe 还是 dll) --> |
为什么头文件在工单里一个都没有?**因为 CMake 只把"你明确写进 CMakeLists.txt 的文件"记进工单。Open3D 只写了 .cpp,没写 .h。实测:utility 工单里 16 个 .cpp / 0 个 .h;geometry 工单里 35 个 .cpp / 0 个 .h。这不影响编译——编译器靠 #include 自己找头文件。工单里列头文件只是为了方便你在 VS 里点开看。
第 4 步:编译(compile)
MSBuild 针对每一份 TU,发出一条编译命令(真实命令有 177 个参数):
1 | cl.exe /c /I... /W4 /WX /O2 /std:c++17 /MD /D... |
产物:
1 | build-debug\cpp\open3d\utility\utility.dir\Debug\Random.obj |
第 5 步:总装(link)
MSBuild 把该成品的所有零件交给链接器:
1 | link.exe /DLL /OUT:...\Open3D.dll /IMPLIB:...\Open3D.lib /DEBUG ... |
产出四个文件:
| 文件 | 是什么 |
|---|---|
Open3D.dll |
成品(215.78 MB,Debug) |
Open3D.lib |
导入库(47.88 MB):给别的项目链接用的"取货单" |
Open3D.exp |
导出过程的中间文件 |
Open3D.pdb |
调试字典(556 MB) |
可执行文件同理,只是没有 /DLL、没有导入库。
第 6 步(附加):总装之后的小动作(POST_BUILD)
有些工单在"成品做出来后"还要干点事。例如 cpp\tests\CMakeLists.txt 里写了:tests.exe 做完后,把 TBB 的 DLL 复制到旁边:
cmake -E copy_if_different <tbb12_debug.dll> build-debug\bin\Debug\
注意:这个动作只在 tests.exe 重新链接过之后才执行。
第 7 步(附加):安装(install)
cmake --install build-debug --config Debug
这一步不经过 MSBuild,是 CMake 自己按工单里的 install() 规则复制文件到安装目录。
一个容易困惑的点:安装目录里没有 .pdb。因为 Open3D 的 CMakeLists.txt 里从头到尾没有写"安装 pdb"这句话(CMake 不会自动帮你装)。同理,tests.exe 也没有被安装——它不是交付物。
4. “只改一个 .cpp,为什么只重编一点点”
这就是增量构建。它不是 VS"猜"出来的,而是三本台账 + 比时间戳。
台账在哪
build-debug\cpp\open3d\utility\utility.dir\Debug\utility.tlog\
CL.read.1.tlog ← 每个 TU 读过哪些文件(最重要的台账)
CL.command.1.tlog ← 每个 TU 用的完整命令行(工艺参数)
CL.10880.write.1.tlog ← 这次编译写出了哪些文件
Lib.command.1.tlog ← 打包 .lib 用的命令
Lib-link.read.1.tlog ← 打包 .lib 读了哪些文件
utility.lastbuildstate ← 上次开工时用的工具/SDK/配置
CL.read.1.tlog 的真实内容长这样(^ 开头的是这台机器的"预制件"用了哪些材料):
1 | ^D:\LIBS\OPEN3D\CPP\OPEN3D\UTILITY\HELPER.CPP |
也就是说:每个 .cpp 到底读过哪些头文件,是被逐条记下来的。
判定规则
开工前,对每一块预制件(.obj)问一句:
“这块预制件(
.obj),和它用过的所有材料(.cpp加那些头文件),谁的时间更新?”
-
材料更新 → 重做这一块预制件;
-
材料都更旧 → 跳过。
再加两条:
-
工艺参数(命令行)变了 → 也要重做(命令记在
CL.command.1.tlog); -
工具/SDK/配置变了 → 整个工地全部重做(记在
.lastbuildstate,例如里面写着PlatformToolSet=v142:VCToolsVersion=14.29.30133:...)。
所以实际会发生什么
| 你改了什么 | 会重做哪些预制件 |
|---|---|
某个 .cpp 里的函数实现 |
只有那一块(就这一个是它的材料) |
某个 .h 里的内容 |
所有读过它的 .cpp(台账里记着谁读过),以及读过"读过它的头文件"的那些 .cpp——连锁反应 |
编译选项(-D 开关、/W4 之类) |
受影响的全部(因为命令行变了) |
| 换了编译器版本 / SDK | 全部 |
只改了 CMakeLists.txt |
先重新"编排"(configure)+ 重新"出工单",再按上面规则扩散 |
还有一步:总装必须重来
只要任何一块零件变了,成品就必须重新拼一次。这就是为什么"只改一个 .cpp"时,你还看到它重新链接了 Open3D.dll 和 tests.exe——不是它重编了整个项目,而是总装这一步无法增量(要把所有零件重新拼在一起)。
完整时序(只改一个 .cpp 后按 F5)
-
检查有没有改过
CMakeLists.txt→ 没改,跳过重新编排。 -
看
tests(启动项目)和它依赖的成品。 -
utility:只有Random.obj过期 → 重编 1 个文件。 -
Open3D.dll:零件变了 → 重新链接(这一步要几秒到几十秒)。 -
tests.exe:导入库变了 → 重新链接;然后执行 POST_BUILD(复制 TBB 的 DLL)。 -
启动
tests.exe,挂上调试器。
没变的东西一律不重做。 这就是"第二次构建快"的全部原因。
5. 为什么有时产出 .exe,有时产出 .dll
谁说了算
是你在 CMakeLists.txt 里写的那一句话说了算。
| CMake 里怎么写 | 产出什么 |
|---|---|
add_executable(tests ...) |
tests.exe |
add_library(Open3D) + BUILD_SHARED_LIBS=ON |
Open3D.dll + 导入库 Open3D.lib |
add_library(x STATIC ...) |
x.lib(一箱预制件) |
open3d_ispc_add_library(utility OBJECT) |
一组 .obj,被别人直接拿去总装 |
实测本仓库:
build-debug\CMakeCache.txt : BUILD_SHARED_LIBS:BOOL=ON
cpp\open3d\CMakeLists.txt:71 : add_library(Open3D) → 共享库
cpp\open3d\utility\CMakeLists.txt:1 : open3d_ispc_add_library(utility OBJECT)
三者到底差在哪
| 问题 | .exe |
.dll |
静态库 / OBJECT 库 |
|---|---|---|---|
| 能双击运行吗? | 能 | 不能(要别人调用它) | 不能 |
| 能被别的成品"拼进去"吗? | 不能(没有取货单) | 能(靠导入库) | 能(直接拼) |
| 运行时要不要单独带文件? | 不用 | 要带着 DLL | 不用(代码已被复制进去) |
| 多个程序能共用一份吗? | 不能(每个进程一份) | 能(同一份物理内存共享) | 不能 |
| 有没有"对外供货清单"(导出表)? | 一般没有 | 必须有,否则别人链接不了 | 不适用 |
DLL 那张"对外供货清单"由代码里的宏控制,本仓库在 cpp\open3d\Macro.h:
#define OPEN3D_DLL_EXPORT __declspec(dllexport) // 造 DLL 时:把符号放上货架
#define OPEN3D_DLL_IMPORT __declspec(dllimport) // 用 DLL 时:声明"这货在仓库里"
#define OPEN3D_API OPEN3D_DLL_EXPORT // 造 Open3D.dll 时展开成这个
本仓库具体是怎么选的
| 成品 | 类型 | 数量 | 为什么这么选 |
|---|---|---|---|
Open3D.dll |
共享库 | 1 | 对外只开一个门,方便交付;所有第三方代码被塞进这一个文件里(所以 98 MB / 215 MB 这么大) |
utility、geometry、io、core、t/*、visualization … |
OBJECT 库 | 18 | 它们是内部实现,不需要对外。拆开纯粹是为了编译快(改一个模块只重编那个模块)。实测主库工单里用 320 个 <Object> 直接引用它们的 .obj,一个 .lib 都没引用 |
tests.exe + 8 个工具 exe |
可执行文件 | 9 | 它们是"要手动运行的程序"(跑测试、看几何、转格式) |
tbb12*.dll、gtest.dll、gmock.dll |
第三方共享库 | 4 | 被多个成品共同使用,做成共享避免重复 |
assimp、embree、fmt、zlib … |
第三方静态库 | 多数 | 免去运行时部署一堆 DLL 的麻烦 |
一句话:需要"独立运行"→ .exe;需要"被别人共用且独立发布"→ .dll;只是内部零件、跟着主成品走 → 静态库或 OBJECT 库。
6. 速查卡
| 我想知道 | 去哪儿看 |
|---|---|
| 这次用了哪些编译选项 | build-debug\CMakeCache.txt |
| 有哪些成品、各是什么类型 | build-debug\Open3D.slnx;或各 .vcxproj 里的 <ConfigurationType> |
某个成品要编译哪些 .cpp |
对应 .vcxproj 里的 <ClCompile Include=...> |
某个 .cpp 读过头文件 |
...\<模块>.tlog\CL.read.1.tlog |
| 上次用了什么编译器/SDK | ...\<模块>.tlog\<模块>.lastbuildstate |
| 编译命令全文 | ...\<模块>.tlog\CL.command.1.tlog |
| 某块预制件产出了没有 | ...\<模块>.dir\<Config>\<名字>.obj |
| 为什么这次又链接了一遍 | 因为至少有一块零件变了;总装不能增量 |
| 我想干 | 怎么做 |
|---|---|
| 只改实现、快速验证 | 直接改 .cpp → F5(只重编那一个文件) |
| 新增一个测试文件 | 改 cpp\tests\<模块>\CMakeLists.txt 登记它 → 重新 configure → 再编 |
| 彻底重来 | 删掉整个 build-debug\(保留 3rdparty_downloads\),重新 configure,约 20 分钟 |
| 看某个成品是什么类型 | 打开它的 .vcxproj,搜 <ConfigurationType> |
7. 三个常见误解
**误解 1:解决方案里有源码。**解决方案(.slnx)只是一张"有哪些成品"的清单,一个源码文件名都没有。源码的路径写在各个 .vcxproj 里。
**误解 2:头文件应该在解决方案资源管理器里能找到。**不会。CMake 只把 .cpp 写进工单,头文件的 <ClInclude> 数量是 0(实测)。头文件只能靠搜索或文件资源管理器找到。
误解 3:改了 .cpp,VS 会"全部重编"。不会。它只重编那一个 TU,然后重新总装。真正代价大的是改头文件——因为所有读过它的 TU 都要重编(连锁反应)。