C++ 项目工作机制全景:从源码到可执行程序 / How C++ Projects Work from Source Code to Executables
📅 创建时间:2026-07-13 🏷️ 标签:#C++ #编译 #链接 #项目结构 #入门基础 📚 前置知识:C/C++ 基础语法
📋 文档目标
本文档帮助你建立对 C++ 项目工作机制的完整心智模型。通过本文档,你将:
- 理解一段 C++ 代码如何从源码一步步变成可运行的程序
- 掌握编译的四个阶段:预处理 → 编译 → 汇编 → 链接
- 理解翻译单元(Translation Unit)、目标文件(.o/.obj)、库文件、可执行文件的本质区别
- 明白链接器的核心工作——符号解析和重定位
- 知道 "undefined reference" 这种经典错误为什么会发生
- 理解构建系统(CMake/Make/MSBuild)在整个流程中的角色
- 熟悉一个典型 C++ 项目的目录结构:
src/、include/、lib/、bin/、build/各自放什么
第1部分:一个程序是怎么"生"出来的?
1.1 从一段最简单的代码说起
假设你写了这样一段代码:
// main.cpp
#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}然后你在终端敲下:
g++ main.cpp -o hello
./hello
# 输出:Hello, World!这行命令看起来很简单,但背后发生了四个阶段的事情:
┌─────────────────────────────────────────────────────────────────────────────┐
│ C++ 编译的四个阶段 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ main.cpp │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 1. 预处理 │ g++ -E main.cpp -o main.i │
│ │ Preprocessing │ │
│ │ │ • 展开 #include(把头文件内容复制进来) │
│ │ │ • 处理 #define 宏替换 │
│ │ │ • 处理 #ifdef / #ifndef 条件编译 │
│ │ │ • 删除注释 │
│ │ │ • 输出:.i 文件(纯文本,但非常长) │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 2. 编译 │ g++ -S main.i -o main.s │
│ │ Compilation │ │
│ │ │ • 词法分析:把字符流转成 token 流 │
│ │ │ • 语法分析:把 token 组装成 AST(抽象语法树) │
│ │ │ • 语义分析:类型检查、重载决议、模板实例化 │
│ │ │ • 代码生成:AST → 汇编代码 │
│ │ │ • 输出:.s 文件(汇编代码) │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 3. 汇编 │ g++ -c main.s -o main.o │
│ │ Assembly │ │
│ │ │ • 把汇编指令翻译成机器码 │
│ │ │ • 生成符号表(哪些符号定义了、哪些需要外部提供) │
│ │ │ • 生成重定位表(哪些地址还不知道,需要链接器来填) │
│ │ │ • 输出:.o 文件(Linux)或 .obj 文件(Windows) │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 4. 链接 │ g++ main.o -o hello │
│ │ Linking │ │
│ │ │ • 符号解析:把"我需要 printf"和"printf 在这里"对上 │
│ │ │ • 重定位:填上所有之前不知道的实际地址 │
│ │ │ • 合并段:把多个 .o 的 .text 段拼在一起 │
│ │ │ • 输出:可执行文件(Linux ELF / Windows PE) │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘1.2 四个阶段的深入理解
预处理——不只是复制粘贴
预处理是整个流程中最被低估的一步。做一个实验:
// before.cpp
#define MAX 100
#define ADD(a, b) ((a) + (b))
int arr[MAX];
int x = ADD(3, 5);
#ifdef DEBUG
#include <debug_tools.h>
#endifg++ -E before.cpp -o after.i预处理后,MAX 变成 100,ADD(3, 5) 变成 ((3) + (5)),条件编译的块被决定保留或删除。一个 #include <iostream> 展开后可能有几万行——这就是为什么 iostream 比较"重"的原因之一。
编译——核心智力劳动
这是四个阶段中最复杂的一步。编译器做的事分解如下:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 编译阶段的内部流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 预处理后的 .i 文件 │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 词法分析 │ → │ 语法分析 │ → │ 语义分析 │ → │ 代码生成 │ │
│ │ Lexer │ │ Parser │ │ Semantic │ │ Code Gen │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ 字符流→token token→AST AST类型检查 AST→汇编代码 │
│ │
│ 例如:int x = a + 1; │
│ 词法:int / x / = / a / + / 1 / ; │
│ 语法:Declaration(Variable(x), Add(Identifier(a), Literal(1))) │
│ 语义:检查 a 是否声明过、int + int 是否合法 │
│ 代码:mov eax, [a]; add eax, 1; mov [x], eax │
│ │
└─────────────────────────────────────────────────────────────────────────────┘关键认知:编译阶段的输出是 .o 文件,但 .o 文件不等于可执行程序。 一个 .o 里的"函数调用"还不知道被调用方在哪里。
汇编——机械翻译
汇编阶段做的事情最纯粹:把汇编指令一对一翻译成机器码(操作码 + 操作数)。但这里还有一件重要的事——符号表和重定位表的生成。
# 查看 .o 文件的符号表
nm main.o
# 输出示例:
# 0000000000000000 T main ← T = 在本文件中定义的符号(Text段)
# U _Z4putsPKc ← U = 未定义,需要链接器来找
# U std::coutU 代表 "Undefined"——这意味着 "我用了这个符号,但我不知道它在哪里,麻烦链接器帮我找"。
链接——最终组装
链接器的工作分两步:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 链接器的工作 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 步骤1:符号解析 (Symbol Resolution) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ main.o │ │ foo.o │ │ libc.so │ │
│ │ │ │ │ │ │ │
│ │ 需要: │ │ 提供: │ │ 提供: │ │
│ │ foo() │ → │ foo() │ │ printf() │ │
│ │ printf() │ │ │ → │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 链接器遍历所有 .o 和 .a/.so,为每一个 "U(未定义)" 符号 │
│ 找到对应的 "T(已定义)" 符号。找不到 → "undefined reference" 错误。 │
│ │
│ 步骤2:重定位 (Relocation) │
│ │
│ 链接前:main.o 里的 call 指令目标是 0x00000000(占位符) │
│ 链接后:call 指令目标更新为 foo() 实际在可执行文件中的地址 0x401050 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第2部分:翻译单元——理解 C++ 编译的基本单位
2.1 什么是翻译单元?
这个概念极其重要但经常被忽略。
翻译单元 = 一个 .cpp 文件 + 它递归 #include 的所有头文件(预处理展开后)一个项目有多少个 .cpp 文件,就有多少个翻译单元,每个编译成独立的 .o 文件。
┌─────────────────────────────────────────────────────────────────────────────┐
│ 翻译单元与编译的关系 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ main.cpp math.cpp utils.cpp │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ #include │ │ #include │ │ #include │ │
│ │ "math.h" │ │ "math.h" │ │ "utils.h" │ │
│ │ #include │ │ │ │ #include │ │
│ │ "utils.h" │ │ int add( │ │ "math.h" │ │
│ │ │ │ int a, │ │ │ │
│ │ int main(){ │ │ int b){ │ │ void log( │ │
│ │ ... │ │ return │ │ ...){ │ │
│ │ } │ │ a+b; │ │ ... │ │
│ │ │ │ } │ │ } │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ 编译 │ 编译 │ 编译 │
│ ▼ ▼ ▼ │
│ main.o math.o utils.o │
│ │ │ │ │
│ └──────────────────────┼──────────────────────┘ │
│ │ 链接 │
│ ▼ │
│ my_program(可执行文件) │
│ │
│ 核心要点: │
│ • 每个 .cpp 独立编译——编译器一次只看一个翻译单元 │
│ • math.cpp 不知道 main.cpp 里写了什么 │
│ • 它们之间的"沟通"靠头文件(声明)和链接器(定义) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘2.2 头文件(.h)和源文件(.cpp)的分工
┌─────────────────────────────────────────────────────────────────────────────┐
│ 声明 vs 定义 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 头文件 (.h) = 接口说明书 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ // math.h │ │
│ │ #pragma once // 防止重复包含 │ │
│ │ │ │
│ │ int add(int a, int b); // 声明:我承诺存在这个函数 │ │
│ │ int multiply(int a, int b); │ │
│ │ │ │
│ │ const double PI = 3.14159; // 常量可以在头文件中定义 │ │
│ │ │ │
│ │ class Calculator { // 类也可以声明在头文件中 │ │
│ │ public: │ │
│ │ int compute(int a, int b); // 只声明方法 │ │
│ │ }; │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 源文件 (.cpp) = 具体实现 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ // math.cpp │ │
│ │ #include "math.h" │ │
│ │ │ │
│ │ int add(int a, int b) { // 定义:函数体的具体实现 │ │
│ │ return a + b; │ │
│ │ } │ │
│ │ │ │
│ │ int Calculator::compute(int a, int b) { │ │
│ │ return add(a, b) * 2; │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 为什么分开? │
│ • 修改 .cpp 只需要重新编译一个 .o,不需要重编所有文件 │
│ • 发布库时可以只提供 .h + 编译好的 .lib/.so(不暴露源码) │
│ • 头文件是合同,源文件是实现——两者可以独立演化 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘2.3 One Definition Rule(ODR 单一定义规则)
这是 C++ 里一条极其关键的规则,违反它的后果非常隐蔽:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ODR 规则 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 规则1(强 ODR):函数、全局变量——整个程序中只能有一份定义 │
│ │
│ ✅ 可以:在 .h 中声明,在 .cpp 中定义 │
│ ❌ 不可以:在 .h 中定义非 inline 函数(多个翻译单元包含会导致重复定义) │
│ │
│ 规则2(弱 ODR):类定义、模板、inline 函数、枚举——可以在多个翻译单元出现 │
│ 但所有出现必须"完全相同"(token-by-token identical) │
│ │
│ // 危险示例:如果不同翻译单元看到了不同版本的类定义 │
│ // 编译器不会报错,但运行时会崩溃(ODR violation, UB) │
│ │
│ 这也是为什么: │
│ • 永远用 #pragma once 或 include guards │
│ • 不要把函数实现写在头文件里(除非是 inline / template) │
│ • 编译选项(如 #define)要全局一致 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第3部分:构建系统的角色
3.1 为什么需要构建系统?
如果你只有一个 main.cpp,直接 g++ main.cpp -o hello 就够了。但真实项目可能有几百个 .cpp 文件、几十个第三方库、多种编译选项、多平台支持。
┌─────────────────────────────────────────────────────────────────────────────┐
│ 一个中型 C++ 项目的构建依赖 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ src/ external/ │
│ ├── main.cpp ────────────→ libA/include/ │
│ ├── core/ libA/src/... │
│ │ ├── engine.cpp ────────────→ libB/include/ │
│ │ └── parser.cpp libB/lib/libB.so │
│ ├── ui/ │
│ │ └── window.cpp ────────────→ Qt (系统路径) │
│ └── utils/ │
│ └── logger.cpp │
│ │
│ 问题: │
│ • main.cpp 改了 → 只需要重编 main.cpp → 重新链接即可 │
│ • math.h 改了 → 所有 #include "math.h" 的 .cpp 都要重编 │
│ • libB 更新到 v2.0 → 可能需要改编译选项 │
│ • Debug 和 Release 编译选项不同 │
│ • Windows 和 Linux 的编译方式完全不同 │
│ │
│ 构建系统的作用: │
│ 1. 追踪文件依赖——只重编需要重编的文件(增量编译) │
│ 2. 管理编译选项——Debug/Release、平台差异 │
│ 3. 管理第三方库——找到它们、链接它们 │
│ 4. 生成最终产物——可执行文件、动态库、静态库 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘3.2 常见构建系统一览
| 构建系统 | 平台 | 特点 | 配置文件 |
|---|---|---|---|
| Make | Linux/Unix 原生 | 古老但通用,手写规则很痛苦 | Makefile |
| Ninja | 跨平台 | 极快,通常由 CMake 生成 | build.ninja |
| MSBuild | Windows 原生 | Visual Studio 专用 | .vcxproj |
| CMake | 跨平台 | 现代 C++ 的事实标准 | CMakeLists.txt |
| Meson | 跨平台 | 新时代构建系统,速度快 | meson.build |
| Bazel | 跨平台 | Google 出品,适合巨型项目 | BUILD |
| xmake | 跨平台 | Lua 语法,简洁 | xmake.lua |
在我们的文档系列中,CMake 是一等公民。 因为它是目前 C++ 生态中最广泛使用的跨平台构建系统。
3.3 CMake 的宏观视角
CMake 不是编译器,它是"构建系统的生成器":
┌─────────────────────────────────────────────────────────────────────────────┐
│ CMake 的工作流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CMakeLists.txt(你写的) │
│ │ │
│ ▼ cmake -B build -G "Unix Makefiles" │
│ │
│ CMake 读取你的 CMakeLists.txt │
│ │ │
│ │ • 检测编译器(gcc?clang?msvc?) │
│ │ • 检测系统库(pthread 在哪?OpenGL 在哪?) │
│ │ • 查找第三方库(find_package) │
│ │ • 解析你的源文件列表 │
│ │ │
│ ▼ 生成 │
│ │
│ Makefile(或 Ninja build 文件、或 Visual Studio .sln) │
│ │ │
│ ▼ make -j$(nproc) │
│ │
│ 实际调用 g++/cl.exe 等编译器进行编译和链接 │
│ │
│ 简单的 CMakeLists.txt 示例: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ cmake_minimum_required(VERSION 3.20) │ │
│ │ project(MyProject VERSION 1.0 LANGUAGES CXX) │ │
│ │ │ │
│ │ # 生成可执行文件 │ │
│ │ add_executable(my_app main.cpp) │ │
│ │ │ │
│ │ # 添加头文件搜索路径 │ │
│ │ target_include_directories(my_app PRIVATE include/) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第4部分:C++ 项目的标准目录结构
4.1 解剖一个典型 C++ 项目
┌─────────────────────────────────────────────────────────────────────────────┐
│ 典型 C++ 项目目录结构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ my_project/ ← 项目根目录 │
│ ├── CMakeLists.txt ← 顶层 CMake 配置 │
│ ├── README.md ← 项目说明 │
│ ├── LICENSE ← 许可证 │
│ │ │
│ ├── src/ ← 自己的源代码 │
│ │ ├── main.cpp ← 入口文件 │
│ │ ├── core/ ← 核心逻辑 │
│ │ │ ├── engine.cpp │
│ │ │ └── engine.h ← .h 和 .cpp 放在一起也是常见做法 │
│ │ └── utils/ ← 工具函数 │
│ │ ├── logger.cpp │
│ │ └── logger.h │
│ │ │
│ ├── include/ ← 公开头文件(给外部使用者看的) │
│ │ └── my_project/ │
│ │ ├── api.h ← 公共 API │
│ │ └── types.h ← 公共类型定义 │
│ │ │
│ ├── lib/ ← 预编译好的第三方库文件 │
│ │ ├── libfoo.so ← Linux 动态库 │
│ │ ├── libbar.a ← Linux 静态库 │
│ │ ├── foo.lib ← Windows 导入库(或静态库) │
│ │ └── foo.dll ← Windows 动态库 │
│ │ │
│ ├── bin/ ← 编译输出目录 │
│ │ ├── my_app ← Linux 可执行文件 │
│ │ ├── my_app.exe ← Windows 可执行文件 │
│ │ └── *.dll / *.so ← 运行时需要的动态库 │
│ │ │
│ ├── build/ ← CMake 构建目录(不提交到 git) │
│ │ ├── Makefile │
│ │ └── CMakeCache.txt │
│ │ │
│ ├── external/ 或 third_party/ ← 第三方库的源码 │
│ │ ├── spdlog/ ← 第三方 logging 库 │
│ │ └── nlohmann_json/ ← 第三方 JSON 库 │
│ │ │
│ ├── test/ ← 测试代码 │
│ │ ├── CMakeLists.txt │
│ │ └── test_main.cpp │
│ │ │
│ ├── doc/ ← 文档 │
│ │ │
│ ├── cmake/ ← 自定义 CMake 模块 │
│ │ └── FindCustomLib.cmake │
│ │ │
│ └── scripts/ ← 构建/部署脚本 │
│ └── build.sh │
│ │
└─────────────────────────────────────────────────────────────────────────────┘4.2 各目录的职责
┌─────────────────────────────────────────────────────────────────────────────┐
│ 目录 │ 放什么 │ git 提交? │
│─────────────┼──────────────────────────────────┼──────────────────────────────│
│ src/ │ 项目的 .cpp 源文件 │ ✅ 提交 │
│ include/ │ 对外公开的 .h 头文件 │ ✅ 提交 │
│ lib/ │ 预编译的第三方库二进制 │ 看情况(大文件用 git-lfs) │
│ bin/ │ 编译产物(可执行文件等) │ ❌ 不提交 │
│ build/ │ CMake 生成的中间文件 │ ❌ 绝对不提交 │
│ external/ │ 第三方库源码 │ ✅ 提交(或 git submodule) │
│ test/ │ 测试代码 │ ✅ 提交 │
│ cmake/ │ 自定义 CMake 查找模块 │ ✅ 提交 │
│ doc/ │ 文档 │ ✅ 提交 │
└─────────────────────────────────────────────────────────────────────────────┘第5部分:四种文件身份——看清一个 C++ 项目中的所有文件类型
一个 C++ 项目完成后,你会面对的文件可以分成四大类:
┌─────────────────────────────────────────────────────────────────────────────┐
│ C++ 项目中的四种文件身份 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 头文件 (.h / .hpp / .hxx) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • 作用:声明接口——函数签名、类定义、类型别名 │ │
│ │ • 使用方式:被 .cpp 文件 #include │ │
│ │ • 特点:不直接参与编译,在预处理阶段被"融入".cpp │ │
│ │ • 发布库时:必须随库提供(没有头文件就没法用你的库) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 2. 源文件 (.cpp / .cc / .cxx) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • 作用:函数的实际实现 │ │
│ │ • 使用方式:被编译器编译成 .o / .obj │ │
│ │ • 特点:每个 .cpp 是一个独立的翻译单元 │ │
│ │ • 发布库时:不需要提供(编译成二进制库后源码就不需要了) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 3. 库文件 (.a / .lib / .so / .dll / .dylib) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • 作用:可复用的预编译代码集合 │ │
│ │ • 使用方式:被链接器链接到可执行文件,或被运行时加载 │ │
│ │ • 分为:静态库(.a / .lib_1)和动态库(.so / .dll / .dylib) │ │
│ │ • 发布时:静态库只需给 .a/.lib;动态库需要给 .so/.dll + 头文件 │ │
│ │ • lib/ 目录里放的就是这类文件 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 4. 可执行文件 (Linux 无后缀 / .exe) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • 作用:可以直接运行的程序 │ │
│ │ • 使用方式:双击(Windows)或 ./运行(Linux) │ │
│ │ • 特点:包含 main() 入口点 │ │
│ │ • bin/ 目录里放的就是这类文件 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ⚠️ 特别提醒:Windows 上 .lib 后缀有两种完全不同的含义! │
│ 这个我们将在 01 篇中详细拆解——这是 Windows C++ 开发最大的认知陷阱。 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第6部分:一个项目"跑起来"需要什么?
6.1 编译时 vs 运行时
┌─────────────────────────────────────────────────────────────────────────────┐
│ 编译时需要 vs 运行需要 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│【编译时(Build Time)需要】 │
│ ✅ 源码 — 自己的 .cpp 和 .h │
│ ✅ 第三方头文件 — 第三方库的 .h(告诉编译器函数签名是什么) │
│ ✅ 第三方库文件 — .lib(Windows导入库)或 .a(Linux静态库) │
│ — 让链接器能验证"这个符号确实存在" │
│ │
│【运行时(Runtime)需要】 │
│ ✅ 可执行文件本身 │
│ ✅ 动态库 — 如果用了动态链接,.dll / .so 必须在系统能找到的地方 │
│ ✅ 配置文件、资源文件、数据文件等 │
│ │
│ ⚠️ 常见错误:编译成功但运行时报 "cannot open shared object file" │
│ → 编译时只需要头文件 + .lib 确认符号存在 │
│ → 运行时才真正需要 .dll/.so 中的代码 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘6.2 为什么有些库给 .lib 不给 .cpp?
这是很多新手困惑的地方:甲方给你一个 SDK,只给了 .h + .lib + .dll,没有给 .cpp。
因为库的作者已经把 .cpp 编译成了 .lib/.dll。你有头文件就知道怎么调用,有库文件就有实现代码,不需要源码。这也是保护知识产权的方式。
但如果甲方只给了源码(一堆 .cpp 和 .h),那你就需要用 CMake 组织编译。有时给的是预编译二进制但缺少某个平台的版本,或者编译选项不对兼容不了你的编译器——这些痛点将在第 03 篇和第 05 篇详细展开。
核心总结
┌─────────────────────────────────────────────────────────────────────────────┐
│ 核心概念速查 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 编译四阶段: │
│ 预处理(展开头文件/宏) → 编译(源码→汇编) → 汇编(汇编→机器码.o) │
│ → 链接(合并 .o + 解析符号 + 填地址) │
│ │
│ 翻译单元:一个 .cpp + 它 include 的所有头文件 │
│ │
│ 头文件 = 声明(接口),源文件 = 定义(实现) │
│ │
│ 构建系统(CMake)的作用: │
│ • 管理哪些文件需要编译 │
│ • 管理编译选项(Debug/Release/平台差异) │
│ • 找到并链接第三方库 │
│ │
│ 四种文件身份:头文件(.h) / 源文件(.cpp) / 库文件(.a .lib .so .dll) / 可执行文件│
│ │
│ 编译时需要:头文件 + 导入库(告诉链接器符号存在) │
│ 运行需要:可执行文件 + 动态库(实际代码执行) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘章节测试
测试1:编译过程
请列出 C++ 源码变成可执行文件的四个阶段,并说明每个阶段的输入和输出。
测试2:翻译单元
什么是翻译单元(Translation Unit)?一个项目有 5 个 .cpp 文件,编译后会产生多少个 .o 文件?
测试3:头文件与源文件
// math.h
int add(int a, int b);这段代码在 .h 文件中是什么?在 .cpp 文件中应该写什么?为什么要这样分开?
测试4:undefined reference
出现 undefined reference to 'foo()' 这个错误时,问题出在编译的哪个阶段?通常是什么原因造成的?
测试5:项目结构
一个项目有 src/、include/、lib/、bin/、build/、external/ 六个目录。请说明:
- 哪些应该提交到 git?
- 编译后的 .exe 文件通常放在哪个目录?
- 第三方库的源码通常放在哪个目录?
测试6:编译时 vs 运行时
为什么有时候编译能通过,但运行时报错 "cannot open shared object file"?
参考答案
测试1答案
答案:
- 预处理:输入 .cpp/.h → 输出 .i(展开后的纯文本)
- 编译:输入 .i → 输出 .s(汇编代码)
- 汇编:输入 .s → 输出 .o/.obj(目标文件,机器码 + 符号表 + 重定位表)
- 链接:输入 .o/.obj + .a/.lib → 输出可执行文件或动态库
测试2答案
答案:翻译单元 = 一个 .cpp 文件 + 它递归 #include 的所有头文件(预处理展开后)。5 个 .cpp 编译后产生 5 个 .o 文件,每个翻译单元独立编译。
测试3答案
答案:
- 在 .h 文件中,
int add(int a, int b);是函数声明——告诉编译器"存在这样一个函数,它接受两个 int 参数,返回一个 int"。 - 在 .cpp 文件中应该写函数定义——
int add(int a, int b) { return a + b; },即函数的具体实现。 - 分开的原因是:
- 修改实现时只需重新编译一个 .cpp 而不是全部
- 发布库时只需给头文件,不需要暴露源代码
测试4答案
答案:问题出在链接阶段。链接器在合并所有 .o 文件时,发现某个符号被引用(U),但在所有 .o 和 .a/.lib 中都找不到它的定义(T)。常见原因:
- 忘了链接包含该函数的 .cpp 文件
- 忘了在 target_link_libraries 中添加对应的库
- 函数名拼写错误
- C++ name mangling 导致的符号不匹配(编译为 C++ 但链接为 C)
测试5答案
答案:
- 应该提交:
src/、include/、test/、cmake/、doc/,external/也可以提交(或用 git submodule) - 不应该提交:
bin/、build/(编译产物和构建中间文件) - .exe 放在:
bin/ - 第三方库源码放在:
external/或third_party/
测试6答案
答案:编译时链接器只需要导入库(.lib)或静态库(.a)来验证符号存在并记录依赖关系,不拷贝 DLL 的实际代码。运行程序时,操作系统才真正需要加载动态库(.dll / .so)中的代码到内存中。如果动态库不在系统搜索路径(如 PATH、LD_LIBRARY_PATH)中,运行时加载失败,就会报这个错误。
相关笔记
- windows build outputs - Windows 编译产物详解:exe、DLL、两种 lib 的辨析
- linux build outputs - Linux 编译产物详解:ELF、.a、.so
- project structure thirdparty cmake - 项目结构、第三方库集成与 CMake 实战
下一步学习
- [ ] 阅读 01 - Windows 编译产物详解 —— 理解 exe、dll、两种 lib 的区别
- [ ] 阅读 02 - Linux 编译产物详解 —— 理解 ELF、.a、.so、soname
学习状态:🟡 开始学习