C++ 程序是怎样做出来的:从源码到可执行文件 / From Source to Executable
本文属于 C++ 语言基础模块的“程序翻译”部分。
本文不假设你已经熟悉
g++、终端、目标文件或链接器。我们会先做出一个能运行的小程序,再一次只改变一个步骤,观察程序是怎样被做出来的。
1. 我们先做一个很小的程序
先创建一个文件,名字叫 hello.cpp:
#include <iostream>
int main() {
std::cout << "hello\n";
return 0;
}这段代码做的事很简单:
程序开始
|
v
进入 main()
|
v
把 hello 输出到终端
|
v
返回 0,表示正常结束但是,CPU 不能直接执行 hello.cpp。
CPU 能直接执行的是机器指令,也就是经过编码的二进制指令。
所以中间需要一个翻译过程:
hello.cpp
|
v
C++ 编译器
|
v
可执行文件
|
v
CPU 执行机器指令这里的“编译器”不是一个神秘的黑盒。
它只是一个负责把人写的 C++ 代码转换成机器可以执行的形式的工具。
2. 先认识终端和编译器命令
2.1 什么是终端
终端是一个让你用文字输入命令的程序。
你输入命令,操作系统启动某个工具,工具输出结果。
你输入命令
|
v
终端把命令交给操作系统
|
v
操作系统启动编译器
|
v
编译器读取文件并输出结果Windows 上可以使用 PowerShell、Windows Terminal 或其他终端。
Linux 和 macOS 上通常使用 Terminal。
终端不是 C++ 的一部分。
它只是我们和编译器交流的窗口。
2.2 什么是 g++
g++ 是 GCC 工具链中常见的 C++ 编译器驱动程序。
你可以把它暂时理解成:
g++
+-- 找到 C++ 编译器
+-- 调用预处理、编译、汇编和链接工具
+-- 帮你处理 C++ 运行库不同系统可能使用不同命令:
g++ GCC 的常见 C++ 命令
clang++ Clang 的常见 C++ 命令
c++ 系统提供的通用 C++ 命令名它们都可以编译 C++,但版本、警告文字和平台行为可能不同。
本文主要使用 g++。
如果你的环境只有 c++,可以把示例中的 g++ 换成 c++。
2.3 先运行最简单的编译命令
在 hello.cpp 所在目录打开终端,输入:
g++ hello.cpp这条命令由两部分组成:
g++
+-- 调用 C++ 编译器
hello.cpp
+-- 告诉编译器要读取哪个源码文件如果编译成功,GCC 在类 Unix 环境中通常会生成一个叫 a.out 的文件。
Windows 环境可能生成 a.exe。
运行它:
./a.out如果是 Windows,也可能需要:
.\a.exe你应该看到:
hello到这里,我们只知道:
源码文件 -> 可执行文件但中间到底发生了什么,还没有看到。
3. 用 -o 给程序起一个清楚的名字
默认文件名不够直观。
我们可以使用 -o:
g++ hello.cpp -o hello拆开看:
-o
+-- output 的缩写,表示指定输出文件
hello
+-- 输出文件名运行:
./hello完整过程:
hello.cpp
|
| g++ hello.cpp -o hello
v
hello
|
| ./hello
v
终端输出 hello-o 不会改变 C++ 语言本身。
它只是告诉编译器工具链把产物保存成什么名字。
4. 用 -std=c++17 选择语言版本
C++ 一直在发展。
不同版本增加了不同的语法和标准库能力。
C++11
C++14
C++17
C++20
C++23为了让编译器知道你希望使用哪一套语言规则,可以写:
g++ -std=c++17 hello.cpp -o hello拆开看:
-std=c++17
+-- std 表示 standard
+-- c++17 表示使用 C++17 规则这个选项的作用不是“让程序更快”。
它主要告诉编译器:
遇到语法和标准库问题时,按照 C++17 的规则处理在学习阶段,固定标准版本有一个好处:
你的代码
|
v
每次用相对稳定的一套语言规则编译5. 用 -Wall 和 -Wextra 打开警告
编译器发现问题时,有时会直接报错。
有时它只能判断“这里可能有问题”,于是发出警告。
g++ -std=c++17 -Wall -Wextra hello.cpp -o hello这些选项的含义:
-Wall
+-- 打开一组常见警告
-Wextra
+-- 打开额外的常见警告-Wall 的名字容易误解。
它并不意味着“打开所有警告”。
它只是 GCC 约定的一组警告集合。
警告不一定表示程序一定错误,但它值得你停下来检查。
例如:
int main() {
int value;
return 0;
}某些编译器和选项可能提醒 value 没有被使用。
警告的价值在于把一些问题提前暴露:
潜在问题
|
v
编译器警告
|
v
程序员检查
|
v
错误还没有变成线上故障6. 第一个阶段:-E,只做预处理
现在开始拆开完整流程。
第一步使用:
g++ -std=c++17 -E hello.cpp -o hello.i-E 表示:
只运行预处理阶段预处理发生在正式编译之前。
它主要处理:
#include
#define
#if / #ifdef可以把流程画成:
hello.cpp
|
| -E
v
hello.i.i 是一个约定俗成的预处理结果文件扩展名。
你可以用文本编辑器打开 hello.i。
你会发现它可能比原来的 hello.cpp 大很多。
为什么?
因为:
#include <iostream>不是把一个文件对象放进程序。
它通常会让预处理器把相关头文件内容展开到当前翻译单元中。
hello.cpp
+-- 只有几行自己的代码
#include <iostream>
|
v
预处理后
+-- 许多声明、类型和实现文本进入 hello.i这就是为什么标准库头文件一旦展开,预处理结果可能很大。
6.1 -E 阶段解决什么问题
当你怀疑问题来自宏或条件编译时,可以查看 .i 文件。
例如:
#define LIMIT 10
int value = LIMIT;预处理后,可能变成:
int value = 10;这说明宏不是变量。
它首先是文本替换机制。
7. 第二个阶段:-S,生成汇编文本
第二步使用:
g++ -std=c++17 -S hello.cpp -o hello.s-S 表示让编译器停在汇编文本阶段。
hello.cpp
|
| 预处理和编译
v
hello.s.s 文件是汇编源文件。
它已经比 C++ 更接近 CPU,但还不是最终的机器码文件。
例如:
int add(int left, int right) {
return left + right;
}编译器可能生成一段类似下面的结构:
函数入口
|
v
读取参数
|
v
执行加法
|
v
把结果放到返回值位置
|
v
返回调用者不同 CPU 会生成不同汇编。
x86-64
+-- 一套寄存器和指令名称
RISC-V
+-- 另一套寄存器和指令名称
ARM
+-- 另一套寄存器和指令名称所以学习 -S 时,第一目标不是背汇编,而是观察:
C++ 函数
-> 参数如何进入
-> 分支如何跳转
-> 局部变量如何使用
-> 返回值如何产生8. 第三个阶段:-c,生成目标文件
第三步使用:
g++ -std=c++17 -c hello.cpp -o hello.o-c 可以理解为 compile only:
完成编译和汇编
|
v
停在目标文件hello.cpp
|
| -c
v
hello.o.o 是 object file 的缩写。
目标文件已经包含部分机器码,但它通常还不是可以直接运行的完整程序。
目标文件中可能包含:
.text
+-- 函数机器码
.rodata
+-- 只读字符串和常量
.data
+-- 有初始值的可写数据
.bss
+-- 启动时需要清零的数据
符号表
+-- 当前文件提供或需要的函数/变量
重定位信息
+-- 以后由链接器补齐的地址这里的“目标”不是目标用户。
它表示“目标机器代码”,也就是 object code。
9. 第四个阶段:链接
如果只有一个非常简单的 .cpp 文件,编译器驱动可以把编译和链接连续完成。
但真实 C++ 项目通常有很多源文件:
main.cpp
math.cpp
io.cpp
parser.cpp它们通常先分别变成目标文件:
main.cpp -> main.o
math.cpp -> math.o
io.cpp -> io.o
parser.cpp -> parser.o然后再链接:
g++ main.o math.o io.o parser.o -o app链接器要做的事情包括:
把多个目标文件放在一起
|
v
寻找跨文件使用的函数和变量
|
v
处理重定位
|
v
连接标准库和其他库
|
v
生成最终可执行文件10. 用两个文件观察链接
创建 math.cpp:
int add(int left, int right) {
return left + right;
}创建 main.cpp:
#include <iostream>
int add(int, int);
int main() {
std::cout << add(20, 22) << '\n';
}先分别生成目标文件:
g++ -std=c++17 -Wall -Wextra -c math.cpp -o math.o
g++ -std=c++17 -Wall -Wextra -c main.cpp -o main.o现在有:
math.o
+-- 提供 add 的函数体
main.o
+-- 使用 add,但需要其他目标文件提供它故意只链接 main.o:
g++ main.o -o app你可能看到:
undefined reference to `add(int, int)'这句话逐词解释:
undefined
+-- 没有找到定义
reference
+-- 程序中有一个引用/使用
add(int, int)
+-- 被使用的函数补上 math.o:
g++ main.o math.o -o app运行:
./app现在:
main.o 需要 add
math.o 提供 add
链接器把它们接起来
app 可以运行11. 声明和定义为什么分开
在 main.cpp 中:
int add(int, int);这是声明。
它告诉编译器:
有一个叫 add 的函数
它需要两个 int
它返回一个 int在 math.cpp 中:
int add(int left, int right) {
return left + right;
}这是定义。
它提供真正的代码。
声明
+-- 让当前文件知道接口
定义
+-- 提供真正的实现声明和定义分开,才能让多个源文件通过接口合作。
12. nm:观察目标文件里的符号
如果系统有 nm,可以执行:
nm main.o
nm math.onm 是一个查看目标文件符号的工具。
你可能看到类似:
main.o
U add
T main
math.o
T add这里的字母含义依赖工具和平台,但通常可以这样初步理解:
U
+-- 当前文件使用了它,但没有提供定义
T
+-- 当前文件在代码段中提供了它于是:
main.o: U add
math.o: T add正好对应前面的链接关系。
13. 头文件和 #include
可以把声明放进 math.h:
#pragma once
int add(int, int);然后:
// main.cpp
#include "math.h"
#include <iostream>
int main() {
std::cout << add(20, 22) << '\n';
}头文件的作用是共享接口声明。
但 #include 通常是文本展开:
main.cpp
|
+-- include math.h
|
v
预处理后的 main.cpp
+-- 包含 int add(int, int);所以头文件中的内容可能被许多翻译单元看到。
14. ODR:为什么定义不能随便复制
ODR 是 One Definition Rule,单一定义规则。
它不是简单地说“一个名字只能出现一次”。
更准确地说,C++ 对不同实体规定了不同的定义要求。
初学阶段重点观察这个问题:
// value.h
int value() {
return 42;
}如果 first.cpp 和 second.cpp 都包含这个头文件:
first.cpp -> 展开 value 的定义
second.cpp -> 展开 value 的定义链接时可能出现重复定义。
常见解决方向:
把定义移到一个 .cpp 文件
|
v
头文件只保留声明或者:
inline int value() {
return 42;
}inline 在这里首先影响定义规则。
它不保证编译器一定把函数展开到调用点。
15. 模板为什么经常放在头文件
template <typename T>
T max_value(T left, T right) {
return left > right ? left : right;
}模板通常要在具体使用处生成:
max_value<int>
max_value<double>调用点如果只看到声明,看不到定义,编译器可能无法实例化。
所以模板实现通常和声明一起放在头文件。
16. 错误发生在哪个阶段
源代码写错
+-- 语法、类型、名字错误
+-- 编译阶段
源文件之间接不上
+-- undefined reference、重复符号
+-- 链接阶段
程序无法找到共享库
+-- 装载或运行环境阶段
程序启动后崩溃
+-- 运行时路径诊断顺序:
先看错误发生阶段
|
v
再看最小错误消息
|
v
建立最小复现
|
v
只改变一个条件
|
v
观察解释是否成立17. 一次完整实验清单
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每一步都回答:
输入是什么?
输出是什么?
这一步停止在哪个阶段?
下一个阶段需要它吗?18. 本篇总结
源代码
|
| -E
v
预处理结果
|
| -S
v
汇编文本
|
| -c
v
目标文件
|
| 链接
v
可执行文件
|
| 运行
v
进程现在你不需要一次背住所有参数。
先记住四个阶段:
-E:看预处理
-S:看汇编
-c:看目标文件
链接:把目标文件组成程序下一篇会继续从:
int count{3};解释名字、类型、对象、存储、初始化和生命周期。
本篇的“程序怎样被做出来”,会成为下一篇“对象怎样存在”的背景。