从源代码到可执行文件——初步理解编译、链接与软件构造体系
从一个问题出发
我之前主要使用vs2022,codeblocks,clion等调试运行c/c++程序。而昨天我第一次使用vscode 来调试c/c++程序,遇到了一个问题:
1 | undefined reference to '函数名' |
我在 main.c 里写了 #include "SeqTable.h",SeqTable.h 里有函数声明,SeqTable.c 里有函数实现——一切看起来都对。编译器却告诉我它”找不到函数的定义”。
要真正理解这个问题,需要先弄明白:从我们写的 .c 文件,到能双击运行的 .exe 文件,中间到底发生了什么?
于是我查阅了一些资料,和claude一起,尝试把这件事彻底弄清楚。
一、全景图:四步走
一个 C/C++ 程序从源码到可执行文件,要经历四个阶段:
flowchart LR
A["📄 源代码
main.c / hello.cpp"] -->|"预处理器 cpp"| B["📄 预处理后源文件
main.i / hello.ii"]
B -->|"编译器 gcc/g++"| C["📄 汇编代码
main.s / hello.s"]
C -->|"汇编器 as"| D["📦 目标文件
main.o / hello.o"]
D -->|"链接器 ld"| E["🚀 可执行文件
main.exe / a.out"]
日常中说的”编译”,通常指的是这四步的总和。但在技术上,这四步各有分工。
下面来逐一拆解。
二、预处理(Preprocessing)
把人类写的代码变成”纯机器能读的代码”
预处理是四步中最不被人注意的一步——因为大多数时候它都在幕后自动完成。但它做的事情非常重要。
2.1 预处理干了什么
预处理器的任务可以概括为四个字:文本替换。具体包括:
| 操作 | 原始代码 | 预处理后 |
|---|---|---|
| 头文件展开 | #include <stdio.h> |
把 stdio.h 的全部内容复制进来 |
| 宏替换 | #define MAX 100int arr[MAX]; |
int arr[100]; |
| 条件编译 | #ifdef DEBUGprintf("debug\n");#endif |
如果定义了 DEBUG 就保留,否则删掉 |
| 删除注释 | // 这是一行注释 |
注释被空白替代 |
2.2 为什么需要预处理
预处理解决的核心问题是代码复用。
你写的程序需要用 printf,但你的 .c 文件里并没有 printf 的代码——它只是在 stdio.h 里声明了一下。预处理阶段,预处理器会把 stdio.h(以及里面层层包含的所有头文件)全部在你当前文件里展开。展开后,你的源文件从几十行变成了几千行——里面有了 printf 的声明,编译器于是知道”有这么一个函数,参数是这样的,返回值是这样的”。
flowchart TB
subgraph 预处理前
A["main.c
#include <stdio.h>
int main() {
printf("hello");
}"]
end
subgraph 预处理后
B["main.i(几千行)
...stdio.h全部内容展开...
...里面又include的各种头文件...
int main() {
printf("hello");
}"]
end
A -->|"gcc -E"| B
style A fill:#e8f4fd,stroke:#333
style B fill:#f5e6d3,stroke:#333
2.3 亲手试试
你可以用 GCC 命令手动看到预处理的结果:
1 | # 对 C 文件做预处理,输出到 .i 文件 |
打开生成的 .i 文件,你会发现——原来短短几十行的程序,展开后竟然有上千行。
三、编译(Compilation)
把 C/C++ 代码翻译成汇编语言
3.1 编译器在做什么
编译是这四步中最复杂的一步。编译器拿到预处理后的源代码后,要做以下几件事:
flowchart TB
A["源代码
(C/C++ 文本)"] --> B["词法分析
把代码切成 Token"]
B --> C["语法分析
检查语法,生成语法树"]
C --> D["语义分析
检查类型、作用域等"]
D --> E["中间代码生成
与机器无关的中间表示"]
E --> F["代码优化
去掉冗余、循环展开等"]
F --> G["目标代码生成
输出汇编语言"]
每一层的解释:
词法分析:把代码文本切分成一个个”单词”(Token)。比如 int a = 5; 被切分为:int(关键字)、a(标识符)、=(操作符)、5(字面量)、;(分号)。
语法分析:检查这些 Token 能不能组成合法的句子。如果写 int a = ;,少了右边的值,这步就会报语法错误。
语义分析:检查语句的”含义”是否对。比如把 int 类型的变量赋给 char* 指针——语法上没错,但语义上不对,编译器会警告。
代码优化:在保证程序行为不变的前提下,让生成的机器码更高效。比如:
1 | // 优化前 |
目标代码生成:把经过优化后的中间代码,翻译成当前平台的汇编语言。
3.2 亲手试试
1 | # 只编译不汇编,输出汇编代码 |
你会看到类似这样的汇编输出:
1 | main: |
四、汇编(Assembly)
把汇编代码翻译成机器码
4.1 汇编器在做什么
汇编这一步相对简单——把人类可读的汇编助记符(mov, push, call 等)翻译成机器可以直接执行的机器码(0 和 1 组成的二进制指令)。
汇编器的输入是 .s 汇编文件,输出是 .o 目标文件。
4.2 目标文件里有什么
1 | ┌──────────────────────────────┐ |
**这里藏着最重要的概念——“未解析的符号”**。
你的 main.o 里调用了 printf,但 printf 的实现不在 main.o 里——它在系统的 C 库(libc)里。所以 main.o 的符号表里,对 printf 标记的是”未定义的,需要别人提供”。
同样,如果你写了一个多文件项目:
1 | main.c → #include "add.h", 调用了 add(3,5) |
它们各自编译成 main.o 和 add.o:
main.o的符号表里:add是”未定义的,需要别人提供”add.o的符号表里:add是”已定义的,提供给人用”
“undefined reference” 的本质就是:在链接时,有个符号在所有 .o 文件里都没找到定义。比如你忘了编译 add.c,那 add.o 根本不存在,链接器找不到 add,就报 undefined reference to 'add'。
4.3 亲手试试
1 | # 只编译+汇编,不链接,生成目标文件 |
五、链接(Linking)
把所有目标文件拼在一起,填上缺失的地址
链接是新手最容易困惑的一步,也是”undefined reference”这道常客的温床。
5.1 链接器做了什么
flowchart TB
subgraph 输入
A["main.o
代码 + 符号表
(引用了 printf, add)"]
B["add.o
代码 + 符号表
(提供了 add)"]
C["libc.a
系统库
(提供了 printf)"]
end
subgraph 链接器
D["1. 符号解析
把每个.o里的「未定义」和别的.o里的「已定义」对上"]
E["2. 重定位
把代码里的符号引用替换成实际地址"]
F["3. 段合并
把所有 .text 段拼一起, 所有 .data 段拼一起"]
end
subgraph 输出
G["🚀 可执行文件
main.exe"]
end
A --> D
B --> D
C --> D
D --> E --> F --> G
5.2 一个多文件项目的完整链接流程
用你课程里的例子来说明。下面是一个最简单的情况——main.cpp 和 add.cpp 两个源文件,通过头文件 add.h 连接:
1 | multi_file_project/ |
1 |
|
1 | int add(int m, int n) { |
1 |
|
手动编译这三件套:
1 | # 方式一:一条命令全搞定(适合小项目) |
这三条命令把四个阶段完整走了一遍,对应关系如下:
| 命令 | 对应阶段 |
|---|---|
g++ -c main.cpp -o main.o |
预处理 → 编译 → 汇编,但不链接 |
g++ -c add.cpp -o add.o |
同上 |
g++ main.o add.o -o main |
链接 |
回到最初的问题——为什么 F5 调试报 “undefined reference”?
VS Code 自动生成的 tasks.json 里,编译参数写的是 ${file}——只编译当前打开的那个文件。所以当你按 F5 调试 main.c 时,它等价于执行了:
1 | gcc main.c -o main |
add.c 没有被编译!add.o 不存在!链接器找不到 add 的定义——啪,undefined reference to 'add'。
修法很简单:把 ${file} 改成 ${fileDirname}\\*.c,让编译器把当前目录里所有 .c 文件都编译进同一个可执行文件。
六、编译命令速查表
| 目标 | GCC 命令 | 说明 |
|---|---|---|
| 一步到可执行文件 | gcc main.c add.c -o main |
四步自动走完 |
| 只预处理 | gcc -E main.c -o main.i |
看到头文件展开后的样子 |
| 只编译到汇编 | gcc -S main.c -o main.s |
看到汇编代码 |
| 只编译+汇编 | gcc -c main.c -o main.o |
生成 .o 目标文件 |
| 只看符号表 | nm main.o |
看”定义了啥””需要啥” |
| 查看目标文件结构 | objdump -h main.o |
看到代码段、数据段 |
七、静态库与动态库
你的资料里有两个文件夹——static/ 和 dynamic/。这就是库的两种形式。
7.1 静态库(.a / .lib)
本质:一堆 .o 文件打成的包。
链接时,链接器把库里的代码拷贝一份到你的可执行文件里。
1 | main.o + libadd.a(add.o) → 链接 → main.exe(add 的代码在里面了) |
| 优点 | 缺点 |
|---|---|
| 运行时不需要库文件 | 每个程序都有一份拷贝,占空间 |
| 不依赖外部环境 | 库更新后需要重新编译链接 |
| 部署简单 | 可执行文件体积大 |
7.2 动态库(.so / .dll)
本质:库代码留在.so文件里,可执行文件里只记录”我需要这个库”。
1 | 程序启动 → OS 加载 main.exe → OS 发现它需要 libadd.so → 加载 libadd.so → 把 add() 的地址填到 main 的调用处 |
| 优点 | 缺点 |
|---|---|
| 多个程序共享一份库 | 部署时需要带上或确保安装了库文件 |
| 库升级不需要重编译程序 | 运行时查找库失败程序就崩了 |
| 可执行文件体积小 | 不同版本的库可能不兼容 |
7.3 工作中的类比
静态库 (.a) |
动态库 (.so / .dll) |
|
|---|---|---|
| 类比 | 文章里把别人的文字全文引用进来 | 文章里给出参考文献,让读者自己去查 |
| 链接时机 | 编译时(生成 exe 的时候) | 运行时(双击 exe 之后) |
| 体积 | exe 变大 | exe 不变 |
| 更新 | 库改了,重新编译 exe | 库改了,换一个 .so 文件就行 |
八、CMake 的角色
1 | cmake_minimum_required(VERSION 2.8) |
CMake 本身不是编译器,它是构建系统生成器。它的工作流程是:
1 | CMakeLists.txt → CMake 读取 → 生成 Makefile → make 读取 → 调用 g++ 编译 |
所以当你写了复杂的多文件项目,不用手动敲 g++ main.cpp a.cpp b.cpp c.cpp d.cpp -I include -o myapp——交给 CMake,让它来跟踪依赖关系。
九、总结:回到你的 VS Code 问题
现在你应该能看明白 tasks.json 和”undefined reference”之间的关系了:
1 | 你按 F5 调试 |
修改方法就是让 ${file} 变成 ${fileDirname}\\*.c,把整个目录的源文件都纳入编译。
十、核心概念对照表
| 概念 | 一句话解释 |
|---|---|
| 预处理 | 展开 #include、替换 #define、删除注释 |
| 编译 | 把 C/C++ 翻译成汇编语言 |
| 汇编 | 把汇编翻译成机器码(.o 文件) |
| 链接 | 把多个 .o + 库拼成一个可执行文件 |
.o 目标文件 |
编译和汇编的输出,包含机器码 + 符号表 |
| 符号表 | 记录”我提供了什么”和”我需要什么” |
| undefined reference | 链接时发现需要的符号没有任何地方提供定义 |
静态库 .a |
链接时把库代码拷进 exe |
动态库 .so |
运行时才加载库代码 |
| CMake | 帮你生成 Makefile 的构建工具 |