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
2
3
cmake -S . -B build-debug -G "Visual Studio 18 2026" -A x64 -T v142 `
-DCMAKE_INSTALL_PREFIX=D:/libs/Open3D/build-debug/install `
-DBUILD_GUI=OFF -DBUILD_PYTHON_MODULE=OFF ...

cmake 会:

  1. 从头到尾执行所有 CMakeLists.txt;

  2. 探测环境(用哪个编译器、哪个 Windows SDK、有没有 Python);

  3. 把结果写进 build-debug\CMakeCache.txt;

  4. 在内存里构造出一张清单:要产出哪些成品、每个成品用哪些源文件、依赖哪些第三方。

第 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
2
3
4
5
6
7
8
<!-- 1. 这个成品是什么类型(决定产出 exe 还是 dll) -->
<ConfigurationType>DynamicLibrary</ConfigurationType> <!-- → Open3D.dll -->
<ConfigurationType>Application</ConfigurationType> <!-- → tests.exe -->
<ConfigurationType>StaticLibrary</ConfigurationType> <!-- → 模块(见第 4 章) -->
<!-- 2. 要编译哪些源文件 -->
<ClCompile Include="D:\libs\Open3D\cpp\open3d\utility\Random.cpp" />
<!-- 3. 要链接哪些零件(对主库而言) -->
<Object Include="...\utility.dir\$(Configuration)\Random.obj" />

为什么头文件在工单里一个都没有?**因为 CMake 只把"你明确写进 CMakeLists.txt 的文件"记进工单。Open3D 只写了 .cpp,没写 .h。实测:utility 工单里 16 个 .cpp / 0 个 .h;geometry 工单里 35 个 .cpp / 0 个 .h。这不影响编译——编译器靠 #include 自己找头文件。工单里列头文件只是为了方便你在 VS 里点开看。

第 4 步:编译(compile)

MSBuild 针对每一份 TU,发出一条编译命令(真实命令有 177 个参数):

1
2
cl.exe /c /I... /W4 /WX /O2 /std:c++17 /MD /D... 
/Fo"...\Random.obj" /Fd"...\vc142.pdb" Random.cpp

产物:

1
build-debug\cpp\open3d\utility\utility.dir\Debug\Random.obj

第 5 步:总装(link)

MSBuild 把该成品的所有零件交给链接器:

1
2
link.exe /DLL /OUT:...\Open3D.dll /IMPLIB:...\Open3D.lib /DEBUG ...
320 个 <Object> + 第三方静态库 + 第三方导入库

产出四个文件:

文件 是什么
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
2
3
4
5
^D:\LIBS\OPEN3D\CPP\OPEN3D\UTILITY\HELPER.CPP
D:\LIBS\OPEN3D\CPP\OPEN3D\UTILITY\HELPER.H
D:\VISUALSTUDIO2026\APP\VC\TOOLS\MSVC\14.29.30133\INCLUDE\CMATH
D:\WINDOWS KITS\10\INCLUDE\10.0.26100.0\UCRT\CORECRT.H
...

也就是说:每个 .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)

  1. 检查有没有改过 CMakeLists.txt → 没改,跳过重新编排。

  2. 看 tests(启动项目)和它依赖的成品。

  3. utility:只有 Random.obj 过期 → 重编 1 个文件。

  4. Open3D.dll:零件变了 → 重新链接(这一步要几秒到几十秒)。

  5. tests.exe:导入库变了 → 重新链接;然后执行 POST_BUILD(复制 TBB 的 DLL)。

  6. 启动 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 都要重编(连锁反应)。