【程序构建流程】以具体函数为例
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 前言
- 一、核心概念铺垫
- 二、完整流程拆解(以 Linux + g++ 为例)
- 阶段 1:预处理(Preprocessing)—— 处理“编译前的文本替换”
- 工具:预处理程序(cpp,g++ 内置)
- 作用:
- 命令:
- 对函数的影响:
- 输出:
- 阶段 2:编译(Compilation)—— 把 C++ 代码转换成汇编语言
- 工具:编译器(g++ 的编译模块)
- 作用:
- 命令:
- 对函数的影响:
- 输出:
- 阶段 3:汇编(Assembly)—— 把汇编代码转换成目标文件(机器码)
- 工具:汇编器(as,g++ 内置)
- 作用:
- 命令:
- 对函数的影响:
- 输出:
- 阶段 4:链接(Linking)—— 合并目标文件,生成可执行文件
- 工具:链接器(ld,g++ 内置,默认会调用 ld 并链接标准库)
- 核心问题:
- 作用:
- 命令:
- 对函数的影响:
- 输出:
- 三、关键补充:链接的两种方式
- 1. 静态链接(Static Linking)
- 2. 动态链接(Dynamic Linking,默认方式)
- 四、常见问题与链接错误
- 1. 未定义引用(Undefined reference to `_Z3addii`)
- 2. 多重定义(Multiple definition of `_Z3addii`)
- 五、总结:函数的“旅程”
- 总结
前言
结合之前 add.cpp 、main.cpp例子记录下程序构建经典的四个步骤
要理解 C++ 中函数从源代码到可执行文件的完整流程,核心是四个关键阶段:预处理 → 编译 → 汇编 → 链接。其中前三个阶段聚焦“单个源文件→目标文件”的转换(重点处理函数的代码转换和符号记录),最后一个阶段聚焦“多个目标文件+库文件→可执行文件”的合并(重点处理函数的地址解析和重定位)。
下面结合具体例子,分阶段拆解每个过程的细节、工具、输出结果,以及函数在各阶段的形态变化。
一、核心概念铺垫
在开始前,先明确几个关键术语,避免后续混淆:
- 目标文件(.o/.obj):单个源文件经过预处理、编译、汇编后生成的二进制文件,包含函数的机器码、符号表(记录函数/变量名与地址的映射)、重定位表(记录需要链接时修正的地址)。
- 符号(Symbol):函数名、全局变量名的“编译后标识”(C++ 会进行名字修饰,比如
add(int, int)会变成_Z3addii,用于区分重载函数)。 - 重定位(Relocation):目标文件中调用其他文件的函数时,地址是“占位符”,链接时需要替换为函数的真实地址,这个过程叫重定位。
- 可执行文件(.exe/ELF):链接后生成的文件,包含所有函数的机器码、完整的地址映射,可直接被操作系统加载执行。
二、完整流程拆解(以 Linux + g++ 为例)
我们用两个简单的源文件演示:
// add.cpp(定义一个加法函数)
int add(int a, int b) {return a + b; // 核心逻辑:两数相加
}// main.cpp(调用 add 函数)
#include <iostream>
int add(int a, int b); // 函数声明(告诉编译器存在这个函数)int main() {int x = 3, y = 5;std::cout << add(x, y) << std::endl; // 调用 add 函数return 0;
}
阶段 1:预处理(Preprocessing)—— 处理“编译前的文本替换”
工具:预处理程序(cpp,g++ 内置)
作用:
- 展开
#include头文件(比如#include <iostream>会替换为标准输入输出流的全部声明代码); - 替换
#define宏定义(如果有#define MAX 10,会把代码中所有MAX替换为 10); - 删除注释(
//或/* */中的内容); - 处理条件编译(
#if/#else/#endif等)。
命令:
g++ -E add.cpp -o add.i # 生成预处理后的文件 add.i
g++ -E main.cpp -o main.i # 生成预处理后的文件 main.i
对函数的影响:
- 函数的定义和声明结构不变(比如
add(int a, int b)的原型、main中的调用逻辑都保留); - 仅做“文本层面的替换”,不涉及语法分析或代码转换。
输出:
- 生成
.i后缀的文本文件(本质还是 C++ 代码,但体积会变大,因为展开了头文件)。
阶段 2:编译(Compilation)—— 把 C++ 代码转换成汇编语言
工具:编译器(g++ 的编译模块)
作用:
- 对
.i文件进行语法分析、语义分析、优化(比如删除无效代码、简化运算); - 将合法的 C++ 代码转换为汇编语言指令(汇编代码是机器码的“人类可读形式”);
- 关键操作:C++ 特有的名字修饰(Name Mangling) —— 因为 C++ 支持函数重载(比如
add(int,int)和add(double,double)),编译器会把函数名+参数类型编码成唯一的符号(避免链接时冲突)。
命令:
g++ -S add.i -o add.s # 生成汇编文件 add.s
g++ -S main.i -o main.s # 生成汇编文件 main.s
对函数的影响:
- 函数被转换为汇编指令序列。以
add(int a, int b)为例,add.s中的汇编代码(x86-64 架构)大概是这样:.file "add.cpp" .text .globl _Z3addii # 名字修饰后的符号:_Z3addii(3表示函数名长度,add是函数名,ii是int+int) .type _Z3addii, @function _Z3addii: .LFB0:.cfi_startprocpushq %rbp # 栈帧初始化.cfi_def_cfa_offset 16.cfi_offset 6, -16movq %rsp, %rbp.cfi_def_cfa_register 6movl %edi, -4(%rbp) # 把第一个参数 a 存入栈帧movl %esi, -8(%rbp) # 把第二个参数 b 存入栈帧movl -4(%rbp), %eax # 把 a 加载到 eax 寄存器addl -8(%rbp), %eax # eax = a + b(核心运算)popq %rbp # 栈帧恢复.cfi_def_cfa 7, 8ret # 返回结果(eax 寄存器存返回值).cfi_endproc .LFE0:.size _Z3addii, .-_Z3addii .ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0" .section .note.GNU-stack,"",@progbits main.s中会包含调用add的汇编指令,但此时add的真实地址未知,编译器会用“占位符”标记(比如callq _Z3addii),并记录该符号为“未定义符号”。
输出:
- 生成
.s后缀的汇编文件(文本文件,可直接用文本编辑器打开查看)。
阶段 3:汇编(Assembly)—— 把汇编代码转换成目标文件(机器码)
工具:汇编器(as,g++ 内置)
作用:
- 读取
.s文件中的汇编指令,将其翻译成机器码(二进制指令,CPU 可直接执行); - 将机器码存入目标文件的 .text 段(代码段,专门存放函数的执行指令);
- 生成 符号表(Symbol Table) 和 重定位表(Relocation Table):
- 符号表:记录目标文件中所有函数/全局变量的符号(比如
add.o的符号表中,_Z3addii是“已定义符号”,地址是相对于.text段的偏移量,比如0x0); - 重定位表:记录目标文件中“地址未确定”的位置(比如
main.o中调用_Z3addii的指令地址),告诉链接器“这里需要后续修正”。
- 符号表:记录目标文件中所有函数/全局变量的符号(比如
命令:
g++ -c add.s -o add.o # 生成目标文件 add.o(或用 as add.s -o add.o)
g++ -c main.s -o main.o # 生成目标文件 main.o
对函数的影响:
- 函数的汇编指令被转换成二进制机器码,存入
.text段(比如add的机器码是55 48 89 e5 89 7d fc 89 75 f8 8b 45 fc 03 45 f8 5d c3,对应上面的汇编指令); - 目标文件是二进制文件(无法直接用文本编辑器查看,需用
objdump工具分析,比如objdump -d add.o可查看.text段的机器码)。
输出:
- 生成
.o后缀的目标文件(ELF 格式,Linux 下的标准目标文件格式;Windows 下是.obj,PE 格式)。
阶段 4:链接(Linking)—— 合并目标文件,生成可执行文件
工具:链接器(ld,g++ 内置,默认会调用 ld 并链接标准库)
核心问题:
- 单个目标文件无法独立运行(比如
main.o依赖add.o中的add函数,但main.o中没有add的机器码,且add的地址是占位符); - 还需要依赖标准库(比如
std::cout来自 C++ 标准库libstdc++)。
作用:
- 合并段(Section Merging):将所有目标文件的
.text段(代码)、.data段(已初始化全局变量)、.bss段(未初始化全局变量)等分别合并成一个大的段(比如所有.text合并为可执行文件的.text段); - 解析符号(Symbol Resolution):查找所有未定义符号的定义(比如
main.o中的_Z3addii未定义,链接器在add.o的符号表中找到其定义); - 重定位(Relocation):根据合并后的段基地址,修正重定位表中的占位符地址(比如合并后
.text段的基地址是0x400520,add在add.o中的偏移是0x0,则add的真实地址是0x400520,链接器会把main.o中调用add的指令地址改成0x400520); - 链接库文件:如果代码依赖标准库(比如
std::cout),链接器会从系统库路径中找到libstdc++.so(动态库)或libstdc++.a(静态库),将所需代码合并到可执行文件(静态链接)或记录库依赖(动态链接)。
命令:
g++ main.o add.o -o main # 链接生成可执行文件 main(Linux 下)
# Windows 下:g++ main.obj add.obj -o main.exe
对函数的影响:
- 所有函数的机器码被合并到可执行文件的
.text段,且地址完全确定(CPU 可直接跳转执行); - 比如
main函数调用add时,指令中的地址已经是0x400520(合并后的真实地址),不再是占位符。
输出:
- 生成可执行文件(Linux 下是 ELF 格式,无后缀;Windows 下是 PE 格式,
.exe后缀)。
三、关键补充:链接的两种方式
链接器链接库文件时,有两种核心方式,影响可执行文件的体积和运行依赖:
1. 静态链接(Static Linking)
- 原理:将库文件(比如
libstdc++.a)中的所需代码直接复制到可执行文件中; - 优点:可执行文件不依赖外部库,单独运行即可;
- 缺点:可执行文件体积大,多个程序会重复包含相同库代码,浪费磁盘空间。
2. 动态链接(Dynamic Linking,默认方式)
- 原理:不复制库代码,仅在可执行文件中记录“依赖哪个动态库(.so/.dll)”,程序运行时由操作系统加载动态库并解析地址;
- 优点:可执行文件体积小,多个程序共享同一个动态库,节省磁盘空间;
- 缺点:运行时必须依赖对应的动态库,否则会报错“找不到库文件”。
四、常见问题与链接错误
1. 未定义引用(Undefined reference to _Z3addii)
- 原因:
main.o中调用了add函数,但链接时没找到add.o(比如忘记链接add.o),或add的声明与定义不一致(比如add(int a, int b)声明,add(double a, double b)定义,导致名字修饰后符号不同); - 解决:确保所有被调用的函数都有对应的目标文件,且声明与定义完全一致。
2. 多重定义(Multiple definition of _Z3addii)
- 原因:同一个函数在多个目标文件中被定义(比如
add函数在add1.o和add2.o中都有定义); - 解决:确保函数仅在一个源文件中定义,其他文件用
extern声明。
五、总结:函数的“旅程”
| 阶段 | 工具 | 输入文件 | 输出文件 | 函数的形态变化 |
|---|---|---|---|---|
| 预处理 | cpp | .cpp | .i | C++ 源代码(展开头文件、替换宏) |
| 编译 | g++ | .i | .s | 汇编语言指令(名字修饰后符号) |
| 汇编 | as | .s | .o | 机器码(存入 .text 段,符号表+重定位表) |
| 链接 | ld | .o + 库 | 可执行文件 | 确定地址的机器码(合并到可执行文件) |
整个流程的核心是:从“人类可读的源代码”逐步转换为“CPU 可执行的机器码”,并通过链接器解决跨文件的函数依赖,确定函数的最终地址。
总结
程序构建经典四步骤看过很多博客也问过很多次AI,这个以具体例子询问得到的结果是看过文章里比较好的,记录下来!
