0%

从源代码到可执行文件——初步理解编译、链接与软件构造体系

从源代码到可执行文件——初步理解编译、链接与软件构造体系

从一个问题出发

我之前主要使用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 100
int arr[MAX];
int arr[100];
条件编译 #ifdef DEBUG
printf("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
2
3
4
5
# 对 C 文件做预处理,输出到 .i 文件
gcc -E main.c -o main.i

# 对 C++ 文件做预处理
g++ -E main.cpp -o main.ii

打开生成的 .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
2
3
4
5
// 优化前
for (int i = 0; i < 100; i++) {
int x = a + b; // a+b 每次都重新算
}
// 编译器可能把 a+b 提到循环外面,因为值没变

目标代码生成:把经过优化后的中间代码,翻译成当前平台的汇编语言。

3.2 亲手试试

1
2
3
4
5
# 只编译不汇编,输出汇编代码
gcc -S main.c -o main.s

# 查看生成的汇编代码
cat main.s

你会看到类似这样的汇编输出:

1
2
3
4
5
6
7
8
9
main:
pushq %rbp
movq %rsp, %rbp
leaq .LC0(%rip), %rax
movq %rax, %rcx
call printf
movl $0, %eax
popq %rbp
ret

四、汇编(Assembly)

把汇编代码翻译成机器码

4.1 汇编器在做什么

汇编这一步相对简单——把人类可读的汇编助记符(mov, push, call 等)翻译成机器可以直接执行的机器码(0 和 1 组成的二进制指令)。

汇编器的输入是 .s 汇编文件,输出是 .o 目标文件。

4.2 目标文件里有什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌──────────────────────────────┐
│ main.o │
├──────────────────────────────┤
│ 代码段(.text) │
│ ┌──────────────────────┐ │
│ │ 机器指令(二进制) │ │ ← main 函数的机器码
│ │ 55 48 89 e5 48 8d... │ │
│ └──────────────────────┘ │
│ │
│ 数据段(.data) │
│ ┌──────────────────────┐ │
│ │ 全局变量的初始值 │ │
│ └──────────────────────┘ │
│ │
│ 符号表(symbol table) │
│ ┌──────────────────────┐ │
│ │ main → 代码段某位置 │ │ ← 我定义了这些东西,别人可以用
│ │ printf → ???(未定义) │ │ ← 我需要别人提供这个东西
│ └──────────────────────┘ │
│ │
│ 重定位表(relocation) │
│ ┌──────────────────────┐ │
│ │ 对 printf 的调用 │ │ ← 这些地址尚未确定
│ │ 位置:0x0024 │ │
│ └──────────────────────┘ │
└──────────────────────────────┘

**这里藏着最重要的概念——“未解析的符号”**。

你的 main.o 里调用了 printf,但 printf 的实现不在 main.o 里——它在系统的 C 库(libc)里。所以 main.o 的符号表里,对 printf 标记的是”未定义的,需要别人提供”。

同样,如果你写了一个多文件项目:

1
2
main.c → #include "add.h", 调用了 add(3,5)
add.c → 实现了 add(int m, int n) { return m+n; }

它们各自编译成 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
2
3
4
5
6
7
8
# 只编译+汇编,不链接,生成目标文件
gcc -c main.c -o main.o

# 查看目标文件的符号表
nm main.o

# 查看目标文件的详细结构
objdump -h main.o

五、链接(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
2
3
4
multi_file_project/
├── main.cpp ← 主程序,调用了 add()
├── add.cpp ← 实现了 add() 函数
└── add.h ← 声明了 add() 函数
add.h
1
2
3
4
#ifndef _ADD_H_
#define _ADD_H_
int add(int m, int n); // 函数声明(告诉编译器:有这么一个函数)
#endif
add.cpp
1
2
3
int add(int m, int n) {
return m + n; // 函数定义(函数体在这里)
}
main.cpp
1
2
3
4
5
6
7
8
#include <iostream>
#include "add.h" // 只 include 了头文件
using namespace std;

int main() {
cout << add(3, 8) << endl; // 调用了 add 函数
return 0;
}

手动编译这三件套:

1
2
3
4
5
6
7
# 方式一:一条命令全搞定(适合小项目)
g++ main.cpp add.cpp -o main

# 方式二:分步走(适合大项目,也是 CMake/Makefile 背后做的事)
g++ -c main.cpp -o main.o # 第 1 步:只编译 main.cpp,不链接
g++ -c add.cpp -o add.o # 第 2 步:只编译 add.cpp,不链接
g++ main.o add.o -o main # 第 3 步:把两个 .o 链接成可执行文件

这三条命令把四个阶段完整走了一遍,对应关系如下:

命令 对应阶段
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 的角色

CMakeLists.txt
1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 2.8)
project(SWAP)

# 相当于 g++ -I include —— 告诉编译器去哪儿找头文件
include_directories(include)

# 相当于 g++ main.cpp src/swap.cpp -o swap_cmake
add_executable(swap_cmake main.cpp src/swap.cpp)

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
2
3
4
5
6
7
8
9
10
11
12
你按 F5 调试
↓
VS Code 执行 tasks.json 里定义的"编译任务"
↓
gcc ${file} -o xxx.exe ← 只编译当前这一个文件!
↓
当前文件 (main.c) 里调用了 SeqTable.c 里的函数
但 SeqTable.c 根本没被编译 → SeqTable.o 不存在
↓
链接器找遍了所有 .o 找不到那个函数的定义
↓
undefined reference to '函数名'

修改方法就是让 ${file} 变成 ${fileDirname}\\*.c,把整个目录的源文件都纳入编译。


十、核心概念对照表

概念 一句话解释
预处理 展开 #include、替换 #define、删除注释
编译 把 C/C++ 翻译成汇编语言
汇编 把汇编翻译成机器码(.o 文件)
链接 把多个 .o + 库拼成一个可执行文件
.o 目标文件 编译和汇编的输出,包含机器码 + 符号表
符号表 记录”我提供了什么”和”我需要什么”
undefined reference 链接时发现需要的符号没有任何地方提供定义
静态库 .a 链接时把库代码拷进 exe
动态库 .so 运行时才加载库代码
CMake 帮你生成 Makefile 的构建工具