Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← C++ 编程 / C++ Programming

基础语法与编译模型 / Language & Compilation

1. C++ 语言基础完整学习手册 / Complete C++ Language Basics Guide

2. C++ 语言基础与程序运行 / C++ Language Basics and Program Execution

3. C++ 程序是怎样做出来的:从源码到可执行文件 / From Source to Executable

4. 值、类型与对象:C++ 程序中的第一层现实 / Values, Types, and Objects

5. 表达式、运算符与控制流 / Expressions, Operators, and Control Flow

6. 函数、作用域与重载 / Functions, Scope, and Overloading

7. 数组、指针与字符串:从地址到借用范围 / Arrays, Pointers, and Strings

8. 引用、const 与类型推导:从复制到借用 / References, const, and Type Deduction

9. 让数据说清楚自己:枚举、结构体、联合体与类型别名 / Enums, Structs, Unions, and Aliases

10. 一个头文件怎样影响整个程序:预处理、头文件与命名空间 / Preprocessor, Headers, and Namespaces

11. 基础 I/O、错误与调试:让程序会观察自己的失败 / Basic I/O, Errors, and Debugging

本页目录

C++ 程序是怎样做出来的:从源码到可执行文件 / From Source to Executable ​

本文属于 C++ 语言基础模块的“程序翻译”部分。

本文不假设你已经熟悉 g++、终端、目标文件或链接器。

我们会先做出一个能运行的小程序,再一次只改变一个步骤,观察程序是怎样被做出来的。

1. 我们先做一个很小的程序 ​

先创建一个文件,名字叫 hello.cpp:

cpp
#include <iostream>

int main() {
    std::cout << "hello\n";
    return 0;
}
1
2
3
4
5
6

这段代码做的事很简单:

text
程序开始
  |
  v
进入 main()
  |
  v
把 hello 输出到终端
  |
  v
返回 0,表示正常结束
1
2
3
4
5
6
7
8
9
10

但是,CPU 不能直接执行 hello.cpp。

CPU 能直接执行的是机器指令,也就是经过编码的二进制指令。

所以中间需要一个翻译过程:

text
hello.cpp
  |
  v
C++ 编译器
  |
  v
可执行文件
  |
  v
CPU 执行机器指令
1
2
3
4
5
6
7
8
9
10

这里的“编译器”不是一个神秘的黑盒。

它只是一个负责把人写的 C++ 代码转换成机器可以执行的形式的工具。

2. 先认识终端和编译器命令 ​

2.1 什么是终端 ​

终端是一个让你用文字输入命令的程序。

你输入命令,操作系统启动某个工具,工具输出结果。

text
你输入命令
  |
  v
终端把命令交给操作系统
  |
  v
操作系统启动编译器
  |
  v
编译器读取文件并输出结果
1
2
3
4
5
6
7
8
9
10

Windows 上可以使用 PowerShell、Windows Terminal 或其他终端。

Linux 和 macOS 上通常使用 Terminal。

终端不是 C++ 的一部分。

它只是我们和编译器交流的窗口。

2.2 什么是 g++ ​

g++ 是 GCC 工具链中常见的 C++ 编译器驱动程序。

你可以把它暂时理解成:

text
g++
  +-- 找到 C++ 编译器
  +-- 调用预处理、编译、汇编和链接工具
  +-- 帮你处理 C++ 运行库
1
2
3
4

不同系统可能使用不同命令:

text
g++       GCC 的常见 C++ 命令
clang++   Clang 的常见 C++ 命令
c++       系统提供的通用 C++ 命令名
1
2
3

它们都可以编译 C++,但版本、警告文字和平台行为可能不同。

本文主要使用 g++。

如果你的环境只有 c++,可以把示例中的 g++ 换成 c++。

2.3 先运行最简单的编译命令 ​

在 hello.cpp 所在目录打开终端,输入:

bash
g++ hello.cpp
1

这条命令由两部分组成:

text
g++
  +-- 调用 C++ 编译器

hello.cpp
  +-- 告诉编译器要读取哪个源码文件
1
2
3
4
5

如果编译成功,GCC 在类 Unix 环境中通常会生成一个叫 a.out 的文件。

Windows 环境可能生成 a.exe。

运行它:

bash
./a.out
1

如果是 Windows,也可能需要:

powershell
.\a.exe
1

你应该看到:

text
hello
1

到这里,我们只知道:

text
源码文件 -> 可执行文件
1

但中间到底发生了什么,还没有看到。

3. 用 -o 给程序起一个清楚的名字 ​

默认文件名不够直观。

我们可以使用 -o:

bash
g++ hello.cpp -o hello
1

拆开看:

text
-o
  +-- output 的缩写,表示指定输出文件

hello
  +-- 输出文件名
1
2
3
4
5

运行:

bash
./hello
1

完整过程:

text
hello.cpp
  |
  | g++ hello.cpp -o hello
  v
hello
  |
  | ./hello
  v
终端输出 hello
1
2
3
4
5
6
7
8
9

-o 不会改变 C++ 语言本身。

它只是告诉编译器工具链把产物保存成什么名字。

4. 用 -std=c++17 选择语言版本 ​

C++ 一直在发展。

不同版本增加了不同的语法和标准库能力。

text
C++11
C++14
C++17
C++20
C++23
1
2
3
4
5

为了让编译器知道你希望使用哪一套语言规则,可以写:

bash
g++ -std=c++17 hello.cpp -o hello
1

拆开看:

text
-std=c++17
  +-- std 表示 standard
  +-- c++17 表示使用 C++17 规则
1
2
3

这个选项的作用不是“让程序更快”。

它主要告诉编译器:

text
遇到语法和标准库问题时,按照 C++17 的规则处理
1

在学习阶段,固定标准版本有一个好处:

text
你的代码
  |
  v
每次用相对稳定的一套语言规则编译
1
2
3
4

5. 用 -Wall 和 -Wextra 打开警告 ​

编译器发现问题时,有时会直接报错。

有时它只能判断“这里可能有问题”,于是发出警告。

bash
g++ -std=c++17 -Wall -Wextra hello.cpp -o hello
1

这些选项的含义:

text
-Wall
  +-- 打开一组常见警告

-Wextra
  +-- 打开额外的常见警告
1
2
3
4
5

-Wall 的名字容易误解。

它并不意味着“打开所有警告”。

它只是 GCC 约定的一组警告集合。

警告不一定表示程序一定错误,但它值得你停下来检查。

例如:

cpp
int main() {
    int value;
    return 0;
}
1
2
3
4

某些编译器和选项可能提醒 value 没有被使用。

警告的价值在于把一些问题提前暴露:

text
潜在问题
  |
  v
编译器警告
  |
  v
程序员检查
  |
  v
错误还没有变成线上故障
1
2
3
4
5
6
7
8
9
10

6. 第一个阶段:-E,只做预处理 ​

现在开始拆开完整流程。

第一步使用:

bash
g++ -std=c++17 -E hello.cpp -o hello.i
1

-E 表示:

text
只运行预处理阶段
1

预处理发生在正式编译之前。

它主要处理:

text
#include
#define
#if / #ifdef
1
2
3

可以把流程画成:

text
hello.cpp
  |
  | -E
  v
hello.i
1
2
3
4
5

.i 是一个约定俗成的预处理结果文件扩展名。

你可以用文本编辑器打开 hello.i。

你会发现它可能比原来的 hello.cpp 大很多。

为什么?

因为:

cpp
#include <iostream>
1

不是把一个文件对象放进程序。

它通常会让预处理器把相关头文件内容展开到当前翻译单元中。

text
hello.cpp
  +-- 只有几行自己的代码

#include <iostream>
  |
  v
预处理后
  +-- 许多声明、类型和实现文本进入 hello.i
1
2
3
4
5
6
7
8

这就是为什么标准库头文件一旦展开,预处理结果可能很大。

6.1 -E 阶段解决什么问题 ​

当你怀疑问题来自宏或条件编译时,可以查看 .i 文件。

例如:

cpp
#define LIMIT 10
int value = LIMIT;
1
2

预处理后,可能变成:

cpp
int value = 10;
1

这说明宏不是变量。

它首先是文本替换机制。

7. 第二个阶段:-S,生成汇编文本 ​

第二步使用:

bash
g++ -std=c++17 -S hello.cpp -o hello.s
1

-S 表示让编译器停在汇编文本阶段。

text
hello.cpp
  |
  | 预处理和编译
  v
hello.s
1
2
3
4
5

.s 文件是汇编源文件。

它已经比 C++ 更接近 CPU,但还不是最终的机器码文件。

例如:

cpp
int add(int left, int right) {
    return left + right;
}
1
2
3

编译器可能生成一段类似下面的结构:

text
函数入口
  |
  v
读取参数
  |
  v
执行加法
  |
  v
把结果放到返回值位置
  |
  v
返回调用者
1
2
3
4
5
6
7
8
9
10
11
12
13

不同 CPU 会生成不同汇编。

text
x86-64
  +-- 一套寄存器和指令名称

RISC-V
  +-- 另一套寄存器和指令名称

ARM
  +-- 另一套寄存器和指令名称
1
2
3
4
5
6
7
8

所以学习 -S 时,第一目标不是背汇编,而是观察:

text
C++ 函数
  -> 参数如何进入
  -> 分支如何跳转
  -> 局部变量如何使用
  -> 返回值如何产生
1
2
3
4
5

8. 第三个阶段:-c,生成目标文件 ​

第三步使用:

bash
g++ -std=c++17 -c hello.cpp -o hello.o
1

-c 可以理解为 compile only:

text
完成编译和汇编
  |
  v
停在目标文件
1
2
3
4
text
hello.cpp
  |
  | -c
  v
hello.o
1
2
3
4
5

.o 是 object file 的缩写。

目标文件已经包含部分机器码,但它通常还不是可以直接运行的完整程序。

目标文件中可能包含:

text
.text
  +-- 函数机器码

.rodata
  +-- 只读字符串和常量

.data
  +-- 有初始值的可写数据

.bss
  +-- 启动时需要清零的数据

符号表
  +-- 当前文件提供或需要的函数/变量

重定位信息
  +-- 以后由链接器补齐的地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这里的“目标”不是目标用户。

它表示“目标机器代码”,也就是 object code。

9. 第四个阶段:链接 ​

如果只有一个非常简单的 .cpp 文件,编译器驱动可以把编译和链接连续完成。

但真实 C++ 项目通常有很多源文件:

text
main.cpp
math.cpp
io.cpp
parser.cpp
1
2
3
4

它们通常先分别变成目标文件:

text
main.cpp  -> main.o
math.cpp  -> math.o
io.cpp    -> io.o
parser.cpp -> parser.o
1
2
3
4

然后再链接:

bash
g++ main.o math.o io.o parser.o -o app
1

链接器要做的事情包括:

text
把多个目标文件放在一起
  |
  v
寻找跨文件使用的函数和变量
  |
  v
处理重定位
  |
  v
连接标准库和其他库
  |
  v
生成最终可执行文件
1
2
3
4
5
6
7
8
9
10
11
12
13

10. 用两个文件观察链接 ​

创建 math.cpp:

cpp
int add(int left, int right) {
    return left + right;
}
1
2
3

创建 main.cpp:

cpp
#include <iostream>

int add(int, int);

int main() {
    std::cout << add(20, 22) << '\n';
}
1
2
3
4
5
6
7

先分别生成目标文件:

bash
g++ -std=c++17 -Wall -Wextra -c math.cpp -o math.o
g++ -std=c++17 -Wall -Wextra -c main.cpp -o main.o
1
2

现在有:

text
math.o
  +-- 提供 add 的函数体

main.o
  +-- 使用 add,但需要其他目标文件提供它
1
2
3
4
5

故意只链接 main.o:

bash
g++ main.o -o app
1

你可能看到:

text
undefined reference to `add(int, int)'
1

这句话逐词解释:

text
undefined
  +-- 没有找到定义

reference
  +-- 程序中有一个引用/使用

add(int, int)
  +-- 被使用的函数
1
2
3
4
5
6
7
8

补上 math.o:

bash
g++ main.o math.o -o app
1

运行:

bash
./app
1

现在:

text
main.o 需要 add
math.o 提供 add
链接器把它们接起来
app 可以运行
1
2
3
4

11. 声明和定义为什么分开 ​

在 main.cpp 中:

cpp
int add(int, int);
1

这是声明。

它告诉编译器:

text
有一个叫 add 的函数
它需要两个 int
它返回一个 int
1
2
3

在 math.cpp 中:

cpp
int add(int left, int right) {
    return left + right;
}
1
2
3

这是定义。

它提供真正的代码。

text
声明
  +-- 让当前文件知道接口

定义
  +-- 提供真正的实现
1
2
3
4
5

声明和定义分开,才能让多个源文件通过接口合作。

12. nm:观察目标文件里的符号 ​

如果系统有 nm,可以执行:

bash
nm main.o
nm math.o
1
2

nm 是一个查看目标文件符号的工具。

你可能看到类似:

text
main.o
  U add
  T main

math.o
  T add
1
2
3
4
5
6

这里的字母含义依赖工具和平台,但通常可以这样初步理解:

text
U
  +-- 当前文件使用了它,但没有提供定义

T
  +-- 当前文件在代码段中提供了它
1
2
3
4
5

于是:

text
main.o: U add
math.o: T add
1
2

正好对应前面的链接关系。

13. 头文件和 #include ​

可以把声明放进 math.h:

cpp
#pragma once

int add(int, int);
1
2
3

然后:

cpp
// main.cpp
#include "math.h"
#include <iostream>

int main() {
    std::cout << add(20, 22) << '\n';
}
1
2
3
4
5
6
7

头文件的作用是共享接口声明。

但 #include 通常是文本展开:

text
main.cpp
  |
  +-- include math.h
  |
  v
预处理后的 main.cpp
  +-- 包含 int add(int, int);
1
2
3
4
5
6
7

所以头文件中的内容可能被许多翻译单元看到。

14. ODR:为什么定义不能随便复制 ​

ODR 是 One Definition Rule,单一定义规则。

它不是简单地说“一个名字只能出现一次”。

更准确地说,C++ 对不同实体规定了不同的定义要求。

初学阶段重点观察这个问题:

cpp
// value.h
int value() {
    return 42;
}
1
2
3
4

如果 first.cpp 和 second.cpp 都包含这个头文件:

text
first.cpp  -> 展开 value 的定义
second.cpp -> 展开 value 的定义
1
2

链接时可能出现重复定义。

常见解决方向:

text
把定义移到一个 .cpp 文件
  |
  v
头文件只保留声明
1
2
3
4

或者:

cpp
inline int value() {
    return 42;
}
1
2
3

inline 在这里首先影响定义规则。

它不保证编译器一定把函数展开到调用点。

15. 模板为什么经常放在头文件 ​

cpp
template <typename T>
T max_value(T left, T right) {
    return left > right ? left : right;
}
1
2
3
4

模板通常要在具体使用处生成:

text
max_value<int>
max_value<double>
1
2

调用点如果只看到声明,看不到定义,编译器可能无法实例化。

所以模板实现通常和声明一起放在头文件。

16. 错误发生在哪个阶段 ​

text
源代码写错
  +-- 语法、类型、名字错误
  +-- 编译阶段

源文件之间接不上
  +-- undefined reference、重复符号
  +-- 链接阶段

程序无法找到共享库
  +-- 装载或运行环境阶段

程序启动后崩溃
  +-- 运行时路径
1
2
3
4
5
6
7
8
9
10
11
12
13

诊断顺序:

text
先看错误发生阶段
  |
  v
再看最小错误消息
  |
  v
建立最小复现
  |
  v
只改变一个条件
  |
  v
观察解释是否成立
1
2
3
4
5
6
7
8
9
10
11
12
13

17. 一次完整实验清单 ​

bash
g++ -std=c++17 -E main.cpp -o main.i
g++ -std=c++17 -S main.cpp -o main.s
g++ -std=c++17 -c main.cpp -o main.o
g++ -std=c++17 -c math.cpp -o math.o
g++ main.o math.o -o app
./app
1
2
3
4
5
6

每一步都回答:

text
输入是什么?
输出是什么?
这一步停止在哪个阶段?
下一个阶段需要它吗?
1
2
3
4

18. 本篇总结 ​

text
源代码
  |
  | -E
  v
预处理结果
  |
  | -S
  v
汇编文本
  |
  | -c
  v
目标文件
  |
  | 链接
  v
可执行文件
  |
  | 运行
  v
进程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

现在你不需要一次背住所有参数。

先记住四个阶段:

text
-E:看预处理
-S:看汇编
-c:看目标文件
链接:把目标文件组成程序
1
2
3
4

下一篇会继续从:

cpp
int count{3};
1

解释名字、类型、对象、存储、初始化和生命周期。

本篇的“程序怎样被做出来”,会成为下一篇“对象怎样存在”的背景。

最后更新于:

Pager
上一篇2. C++ 语言基础与程序运行 / C++ Language Basics and Program Execution
下一篇4. 值、类型与对象:C++ 程序中的第一层现实 / Values, Types, and Objects

持续记录,持续成长

Copyright © Tidenflow