概要 & 序论
在上一篇文章中,我们学习了 Vim 编辑器,已经能够在 Linux 环境下编写代码。
但写完代码之后,还有一个问题:我们编写的 C/C++ 源代码,究竟是怎样变成可以执行的程序的?
这就需要编译器。
在 Linux 中,我们通常使用 gcc 和 g++ 完成 C/C++ 程序的编译工作。通过学习这两个工具,我们可以进一步理解程序从源代码到可执行文件的完整过程。
本篇主要围绕以下内容展开:
- 编译与链接的基本原理,以及 gcc/g++ 的常用用法。
- 深入理解编译原理与条件编译。
- 初步认识动静态库与链接机制。
- 通过 Linux 命令验证动态链接和静态链接。
- 进一步理解动态库的共享机制。
- 整理 gcc/g++ 的常用选项。
一、编译与链接的基本原理及 gcc/g++ 使用
1.1 程序编译的四个阶段
在 Linux 环境下,我们通常使用 gcc 编译 C 程序,使用 g++ 编译 C++ 程序。
编译器需要将人类能够理解的源代码转换为计算机能够执行的程序。这个过程并不是简单地把代码翻译成二进制,而是需要经过多个阶段。
完整的过程主要分为四个阶段:
预处理 → 编译 → 汇编 → 链接
可以先记住每个阶段的核心任务:
| 阶段 | 核心任务 | 典型输出 |
|---|---|---|
| 预处理 | 处理宏、头文件、条件编译等 | .i |
| 编译 | 将预处理后的代码转换为汇编语言 | .s |
| 汇编 | 将汇编指令转换为机器指令 | .o |
| 链接 | 将目标文件和所需的库等组合起来 | 可执行文件 |
接下来,我们逐个理解。
1.1.1 预处理
预处理是程序正式编译之前的准备阶段。
在 C/C++ 代码中,我们经常会看到以 # 开头的预处理指令,例如:
c
#include <stdio.h>
#define MAX 100
这些指令并不是普通的 C 语句,而是需要在预处理阶段处理的内容。
预处理主要包括以下工作:
- 宏替换: 将宏名称替换为对应的内容。
- 头文件展开: 将
#include指定的头文件内容展开,头文件中包含的其他头文件也可能继续展开。 - 去除注释: 删除源代码中的注释。
- 条件编译: 根据预处理条件,决定哪些代码保留、哪些代码不参与后续编译。
例如:
c
#define MAX 100
int main()
{
int arr[MAX];
return 0;
}
经过宏替换后,代码中的 MAX 会被替换为 100。
因此,预处理的本质可以理解为:在正式编译之前,先对源代码进行文本层面的处理。
常用选项是 -E。
bash
gcc -E code.c -o code.i
其中:
-E:只进行预处理,完成后停止。code.c:原始 C 源文件。-o code.i:指定输出文件的名称。.i:通常用于保存预处理后的 C 源代码。
这里需要注意,-o 后面跟的是输出文件名,而不是某个编译阶段。
执行完成后,可以查看生成的文件:
bash
ls
再使用 Vim 查看:
bash
vim code.i
你可能会发现,文件内容比原始的 code.c 长很多。这是因为 stdio.h 等头文件的内容已经被展开,其中包含了大量声明和其他预处理后的内容。
1.1.2 编译
完成预处理后,程序进入编译阶段。
编译阶段会将预处理后的 C/C++ 代码转换为汇编语言,同时进行语法和语义等方面的分析。
例如,编译器会检查:
- 语法是否符合语言规则;
- 变量和函数的使用是否存在问题;
- 类型使用是否合理。
如果发现错误,编译器就可能停止并给出错误信息。
编译阶段常用的选项是 -S。
bash
gcc -S code.i -o code.s
其中:
-S:完成编译,生成汇编代码,然后停止。code.i:预处理后的源文件。code.s:生成的汇编文件。
这里的 .s 文件已经不是我们熟悉的 C 语言代码,而是与目标处理器指令相关的汇编语言。
例如,C 语言中的一条赋值语句,经过编译后可能会被转换成多条汇编指令。
为什么不直接从 C 语言转换成机器指令,而要先生成汇编语言?
这个问题会在后面的"为什么要先变成汇编"中继续解释。
还有一个细节:
bash
gcc -S code.c -o code.s
这样写同样可以生成汇编文件。
因为输入的是原始源文件,GCC 会自动完成前面的预处理,再进行编译。我们不必每次都手动生成 .i 文件。
1.1.3 汇编
编译阶段生成了汇编代码,但汇编代码仍然不是最终的机器指令。
汇编阶段的任务,就是将汇编语言转换为机器能够识别的二进制指令,并生成目标文件。
常用选项是 -c。
bash
gcc -c code.s -o code.o
其中:
-c:完成编译和汇编,生成目标文件后停止链接。code.s:汇编源文件。code.o:生成的目标文件。
在 Linux 中,目标文件通常使用 .o 后缀;在 Windows 的 Visual Studio 开发环境中,常见的对应文件后缀是 .obj。
需要注意的是:
.o 文件已经包含机器指令,但通常还不能直接作为完整的可执行程序运行。
为什么?
因为一个源文件可能调用其他源文件中定义的函数,也可能调用标准库中的函数。当前生成的目标文件不一定已经完成这些依赖的最终链接。
因此,我们还需要最后一个阶段:链接。
同样,也可以直接对原始源文件进行处理:
bash
gcc -c code.c -o code.o
GCC 会自动完成预处理和编译,再进行汇编,最终生成 code.o。
1.1.4 链接
链接阶段会将目标文件与程序所需要的其他目标文件、库等进行链接,最终生成可执行程序。
例如:
bash
gcc code.o -o code
其中:
code.o:已经生成的目标文件。-o code:指定最终生成的可执行文件名称。
如果程序依赖其他源文件中的函数,就需要把相应目标文件一起提供给链接器。
例如,一个程序由 main.c 和 tool.c 两个源文件组成,main.c 调用了 tool.c 中实现的函数,那么最终生成可执行程序时,就需要将两部分代码的目标文件链接起来。
除了自己的目标文件,程序还可能依赖系统库和标准库。
这里要区分两个概念:
头文件与库文件不是一回事。
头文件通常提供函数声明、类型定义、宏定义等信息,让编译器知道某个函数如何使用。
库文件则包含相应功能的实现代码,供链接过程使用。
例如,stdio.h 中提供了 printf 的声明,但函数的实际实现并不是由这个头文件直接提供的。
因此,程序不仅需要知道函数如何使用,还需要在后续的链接过程中解决函数实现的依赖问题。
至此,程序从源代码到可执行文件的四个阶段就串联起来了:
text
code.c
│
▼
预处理:-E
│
▼
code.i
│
▼
编译:-S
│
▼
code.s
│
▼
汇编:-c
│
▼
code.o
│
▼
链接
│
▼
code
1.2 gcc/g++ 的常用用法
理解了编译流程之后,我们再来看日常开发中如何使用 GCC。
1.2.1 合并以上步骤,直接生成可执行程序
平时编写简单的 C 程序时,不需要手动执行四个阶段,可以直接使用:
bash
gcc code.c
默认情况下,GCC 会将最终生成的可执行文件命名为:
text
a.out
可以使用以下命令查看文件:
bash
ls
然后运行程序:
bash
./a.out
这里的 ./ 表示从当前目录执行这个程序。
如果希望自定义可执行文件的名称,可以使用 -o 选项:
bash
gcc code.c -o mycode
运行:
bash
./mycode
-o 的作用是指定输出文件名。
需要注意的是,-o 后面必须跟着目标文件名。
下面两种写法都可以:
bash
gcc code.c -o mycode
bash
gcc -o mycode code.c
也就是说,源文件和输出文件的位置可以调整,只要选项与参数的对应关系正确即可。
1.2.2 多个源文件生成可执行程序
当项目变大时,我们通常不会把所有代码都写在一个源文件中。
例如:
text
main.c
tool.c
其中,main.c 负责主流程,tool.c 实现部分功能。
对于多个源文件,可以采用两种方式编译。
方式一:简单一步到位
直接将多个源文件交给 GCC:
bash
gcc main.c tool.c -o myapp
GCC 会分别处理这些源文件,再将生成的目标文件链接起来,最终得到 myapp。
运行:
bash
./myapp
这种方式简单直接,适合规模较小的程序。
方式二:分步增量编译
在大型项目中,如果每次修改代码都重新编译全部源文件,就会浪费时间。
因此,我们可以先分别生成目标文件,再进行统一链接。
第一步:编译每个源文件,生成对应的 .o 文件。
bash
gcc -c main.c -o main.o
gcc -c tool.c -o tool.o
第二步:将目标文件链接成可执行程序。
bash
gcc main.o tool.o -o myapp
运行:
bash
./myapp
这种方式的优势在于:源文件的编译与最终的链接可以分开执行。
假设项目中有 1000 个源文件,现在只修改了 main.c,那么通常只需要重新生成 main.o,再重新链接整个程序,而不必重新编译其他 999 个没有变化的源文件。
这就是增量编译的基本思路,也是后续学习 Makefile 的重要基础。
1.2 gcc/g++ 的常用选项与快速记忆
前面介绍的三个核心选项分别是:
| 选项 | 作用 | 常见输出 |
|---|---|---|
-E |
预处理完成后停止 | .i |
-S |
编译成汇编代码后停止 | .s |
-c |
生成目标文件,不进行最终链接 | .o |
-o |
指定输出文件名 | 由参数决定 |
可以利用键盘左上角的 Esc 键帮助记忆选项顺序:
text
-E → -S → -c
也可以利用 ISO 镜像文件常见的 .iso 后缀,帮助记忆中间文件的后缀:
text
.i → .s → .o
这只是一个辅助记忆方法,真正需要理解的是每个选项所对应的编译阶段。
在实际开发中,我们还经常需要将多个源文件分别编译成目标文件,最后统一链接:
bash
# 分别编译生成目标文件
gcc -c code1.c -o code1.o
gcc -c code2.c -o code2.o
# 将目标文件链接成可执行程序
gcc code.o code1.o code2.o -o code
注意:上述最后一条命令假设 code.o 已经存在。如果实际项目只有 code1.c 和 code2.c,就应该根据真实的源文件和目标文件列表进行链接。
1.3 一些编译器常识(了解即可)
下面的内容主要帮助我们建立对编译器内部工作的初步认识,不要求此时深入掌握。
1.3.1 常见的编译优化技术
编译器不仅负责转换代码,还可能在编译过程中对代码进行优化。
1. 死代码删除(Dead Code Elimination)
如果某段代码无法影响程序的最终结果,编译器就可能将其删除。
例如:
c
int main()
{
int a = 10;
int b = 20;
return 0;
}
如果 a 和 b 没有被使用,那么在允许优化的情况下,编译器可能不再为这些无用操作生成对应的机器指令。
2. 函数内联(Function Inlining)
普通函数调用涉及控制流转移等开销。对于适合内联的函数,编译器可能将函数体直接展开到调用位置,减少函数调用带来的开销。
不过,内联并不是越多越好,因为它也可能增加生成代码的体积。
3. 寄存器调度
CPU 寄存器的访问速度通常比内存更快。
编译器可能将频繁使用的变量保存在寄存器中,并对指令执行顺序进行调整,从而减少不必要的内存访问。
4. 强度削弱(Strength Reduction)
对于某些计算,编译器可以使用成本更低的操作替代原有操作。
例如,在适用的情况下:
text
x * 2
可能被优化成:
text
x << 1
但具体是否进行这种转换,需要由编译器根据目标平台、语言语义和优化条件决定。
1.3.2 编译器的典型分析阶段
为了将高级语言转换成机器能够执行的指令,编译器需要理解源代码的结构与含义。
常见的分析阶段包括:
1. 词法分析(Lexical Analysis)
扫描源代码,将字符序列识别为具有意义的基本单元,例如关键字、标识符、运算符等,并形成 Token 序列。
例如:
c
int number = 10;
其中的 int、number、=、10、; 都可以被识别为不同的词法单元。
2. 语法分析(Syntax Analysis)
分析 Token 序列是否符合语言的语法规则,并构建相应的语法结构,例如语法分析树。
如果括号不匹配或语句结构不符合语法规则,编译器就可能在这个阶段发现问题。
3. 语义分析(Semantic Analysis)
在语法结构正确的基础上,进一步检查代码的语义是否合理。
例如:
- 变量是否已经声明;
- 类型使用是否符合规则;
- 函数调用是否符合相应的声明。
可以把这三个阶段简单理解为:
text
词法分析:识别代码中的基本单元
↓
语法分析:检查这些单元能否组成合法结构
↓
语义分析:检查结构在语言规则下是否合理
二、深入理解编译原理与条件编译
2.1 条件编译的深度解析
2.1.1 预处理的本质与命令行宏定义
前面提到,预处理会处理宏定义和条件编译。
它的一个重要作用是:在正式编译之前,根据指定条件修改或裁剪源代码。
除了直接在代码中使用 #define 定义宏,我们还可以通过 GCC 命令行传入宏定义。
例如:
bash
gcc code.c -o code -DVALUE=100
这里的 -D 用于在编译命令中定义宏。
在预处理阶段,VALUE 会被当作值为 100 的宏处理,效果相当于在源代码中加入:
c
#define VALUE 100
注意,命令行中应将宏名称和赋值内容按照正确格式传递给 -D,例如:
bash
-DVALUE=100
2.1.2 条件编译的实际意义
条件编译并不只是一个语法知识点,在实际的软件开发中也有重要用途。
1. 不同版本的软件使用同一套代码
例如,一款软件可能存在社区版和专业版。
如果两种版本完全使用两套独立的源代码,后续维护就会非常麻烦:修复一个问题,可能需要在两套代码中分别修改。
利用条件编译,可以将共同部分保留在同一套代码中,再通过宏控制不同版本的功能。
2. 不同操作系统或环境的适配
例如,同一个项目需要在 Linux 和 Windows 上运行。
不同操作系统可能提供不同的系统接口。通过条件编译,可以根据平台选择对应的代码。
3. 开发调试
开发阶段可以开启调试日志,帮助定位问题;发布正式版本时,则可以根据配置关闭相应的调试代码。
这样就能在同一套源代码中管理不同的编译配置。
2.1.3 条件编译示例
下面通过一个简单例子,理解条件编译的使用方法。
c
#include <stdio.h>
// 如果取消下面这行的注释,则编译为专业版;否则默认为家庭版
// #define PROFESSIONAL 1
int main()
{
#ifdef PROFESSIONAL
printf("这是软件的专业版\n");
#else
printf("这是软件的家庭版\n");
#endif
return 0;
}
这里使用了几个预处理指令:
#ifdef PROFESSIONAL:判断是否定义了PROFESSIONAL。#else:如果前面的条件不成立,则选择另一部分代码。#endif:结束这段条件编译。
如果不定义 PROFESSIONAL,程序就会保留家庭版的输出代码。
编译并运行:
bash
gcc code.c -o family_version
./family_version
输出为:
text
这是软件的家庭版
如果希望生成专业版,可以在编译时定义宏,而不必修改源文件:
bash
gcc code.c -o pro_version -DPROFESSIONAL
./pro_version
此时,预处理阶段会根据宏定义选择专业版对应的代码。
需要注意:条件编译发生在编译之前。它不是程序运行到一半再决定执行哪个版本,而是在生成可执行程序时就已经决定了哪些代码参与编译。
2.2 编程语言的发展历程
2.2.1 从打孔编程到高级语言
编程语言的发展,可以理解为一个不断提高抽象层次、让人类更容易表达计算任务的过程。
1. 二进制编程
早期计算机编程与机器指令直接相关。人们需要使用非常底层的指令或二进制表示来控制计算机。
这种方式非常不方便,因为人类很难直接阅读和编写大量二进制内容。
2. 汇编语言
后来,人们使用更容易理解的助记符来表示机器指令,这就是汇编语言。
与直接使用二进制相比,汇编语言更加直观,但仍然与具体的处理器架构紧密相关。
3. 高级语言
随着编程语言的发展,C、C++、Java、Python 等语言逐渐出现。
高级语言让程序员能够使用更接近人类思维的方式描述计算过程,减少对底层机器细节的直接依赖。
不过,不同语言的执行方式并不完全相同。例如,C/C++ 通常需要经过编译生成目标代码,而 Python 等语言的执行过程还涉及解释器或运行时环境。
2.2.2 为什么要先变成汇编?
我们已经知道,C/C++ 程序可以先生成汇编代码,再通过汇编阶段生成目标文件。
为什么要设计这样的分层过程?
一个重要原因是:分层可以降低编译器设计和开发的复杂度。
可以把整个过程理解为:
text
高级语言
↓
编译器前端与中间处理
↓
目标平台对应的汇编语言
↓
汇编器
↓
机器指令
高级语言的语法和语义相对复杂,而汇编语言与具体处理器指令之间的关系更加直接。
通过将高级语言的处理与底层机器指令的生成分开,编译器可以更清晰地组织各个部分。
例如,不同的处理器可能使用不同的指令集。编译器可以针对目标平台生成相应的汇编代码,再由对应的汇编器处理。
这样就不必把高级语言的每一种语法规则都直接对应到每一种处理器的二进制细节上。
2.3 编译器的自举
2.3.1 "先有鸡还是先有蛋"
这里有一个很有意思的问题:
如果 C 语言需要编译器才能运行,那么最初的 C 语言编译器又是怎么产生的?
如果编译器本身是用 C 语言编写的,那么它似乎也需要另一个 C 语言编译器来编译自己。
这就形成了一个看起来像"先有鸡还是先有蛋"的问题。
一种典型的解决思路是逐步建立编译工具链:
- 最初,使用机器指令或已有的低级语言,编写一个功能较简单的编译器。
- 使用已有的编译器,编译用目标语言编写的下一版编译器。
- 随着编译器能力不断增强,逐步让编译器能够编译用自身语言编写的代码。
这个过程叫作编译器自举(Compiler Bootstrapping)。
它的核心思想不是凭空产生一个编译器,而是利用已有的工具逐步构建出更强大的工具,最终让编译器能够编译自身的源代码。
三、初步认识动静态库与链接机制
3.1 什么是库?
在前面学习链接时,我们已经遇到了库文件。
那么,库究竟是什么?
在程序开发中,很多功能并不需要每个程序员都重新实现一遍。例如,输入输出、字符串处理、数学运算等功能,往往可以通过现成的库来使用。
库可以理解为经过组织和编译、能够被程序复用的代码集合。
在 Linux 中,常见的库文件包括:
- 动态库:通常以
.so结尾; - 静态库:通常以
.a结尾。
在 Windows 中,常见的对应形式包括:
- 动态链接库:
.dll; - 静态库:
.lib。
例如,C 标准库相关的文件名中经常能看到:
text
libc.so
libc.a
常见的库命名形式为:
text
lib + 库名称 + .so
lib + 库名称 + .a
其中,lib 是常见的库文件名前缀。
例如,一个名为 xyz 的库,可能使用类似下面的文件名:
text
libxyz.so
libxyz.a
在 Linux 的链接过程中,库文件的命名规则与搜索机制有关系。使用 -l 指定库名时,通常写库名本身,而不是直接写上完整文件名。
例如,libxyz.so 对应的库名通常是 xyz。
库的价值在于提供可复用的功能和接口,让开发者不必每次都从零开始实现所有功能。
以 printf 为例:
c
#include <stdio.h>
int main()
{
printf("Hello Linux\n");
return 0;
}
我们在程序中调用了 printf,但并没有在自己的源文件中编写 printf 的完整实现。
那么,这个函数的实现在哪里?
stdio.h 提供了函数声明,而 C 标准库提供了相应的实现。在常见的 Linux 动态链接环境中,可以通过 libc.so.6 等库文件找到相关实现。
这就是库和链接机制发挥作用的地方。
3.2 动态链接和静态链接
在实际开发中,项目通常由多个源文件组成。
例如:
text
main.c
tool.c
一个源文件可能调用另一个源文件中定义的函数。每个源文件可以独立编译为目标文件,但最终程序需要把这些依赖组织起来。
前面介绍的链接阶段,就是解决这些依赖关系的重要环节。
根据库代码与可执行程序之间的组织方式,常见的链接方式有动态链接和静态链接。
3.2.1 什么是动态链接?
先看一个简单的 C 程序:
c
#include <stdio.h>
int main()
{
printf("Hello Linux\n");
return 0;
}
当程序调用 printf 时,它需要找到这个函数的实际实现。
初学者容易产生一个误解:是不是编译器在编译时,就已经把 printf 的最终内存地址写死在程序里了?
在常见的动态链接方式下,并不是这样。
可以把整个过程分成几个步骤理解。
第一步:头文件提供声明。
c
#include <stdio.h>
头文件让编译器知道 printf 的声明及其使用方式,但它并不直接提供 printf 的完整函数实现。
第二步:编译和链接阶段建立依赖关系。
程序使用了外部库中的函数,因此生成的程序需要记录和处理相应的动态链接依赖。
可以把它理解为:程序知道自己需要 printf,但具体实现仍然需要通过动态链接机制找到。
第三步:程序启动时加载动态库。
运行时,系统的动态链接器会根据程序的依赖关系加载所需的共享库,并完成相应的符号解析和地址处理。
第四步:程序调用函数。
当程序执行到 printf 调用时,就可以通过动态链接机制转到相应的函数实现中执行。
因此,动态链接的核心是:
程序保留对外部库的依赖,运行时再通过动态链接机制找到相应的实现。
3.2.2 什么是静态链接?
理解了动态链接之后,静态链接就比较容易理解了。
静态链接的核心思路是:在生成可执行程序时,将程序所需要的静态库代码链接进最终的可执行文件中。
可以把它理解为,将需要的库代码提前组织进程序里,而不是让程序运行时再依赖外部的动态库实现。
例如:
text
源文件
↓
目标文件
↓
静态链接
↓
包含所需库代码的可执行程序
程序运行时,通常不再需要原来的静态库文件本身,因为需要的代码已经被链接进了可执行文件。
可以用一个生活化的比喻帮助理解:
- 动态链接像去网吧上网: 你记住网吧的位置,需要使用时再去那里。
- 静态链接像把电脑带回宿舍: 你拥有自己需要的设备,不必再依赖网吧。
这个比喻主要帮助理解依赖关系:动态链接依赖外部库,静态链接则把所需的库代码放进最终程序。
不过,静态链接并不意味着程序在任何操作系统或任何环境下都绝对能够运行;操作系统、CPU 架构和其他运行条件仍然可能影响程序能否执行。
3.2.1 静态链接的缺点
原文接下来讨论静态链接的缺点。
1. 可能浪费空间。
假设多个程序都需要使用同一个库。
如果它们都采用静态链接,那么每个可执行文件中都可能包含一份相应的库代码。
例如,多个程序都使用了 printf。如果相关库代码被分别链接进每个程序,那么这些程序的文件中就可能出现重复的代码内容。
这会增加磁盘空间占用;在运行时,也可能导致相同代码以多个独立副本存在。
2. 更新比较困难。
如果库中的实现发生变化,已经生成的静态链接程序不会自动获得新实现。
通常需要重新进行编译或链接,才能把更新后的库代码放进可执行文件。
因此,静态链接会带来程序更新和维护方面的成本。
3.2.2 静态链接的优点
静态链接的优点是:
所需的库代码已经被链接进可执行文件。
因此,程序运行时通常不再需要对应的静态库文件。
在一些特定场景下,这能够简化部署,减少运行时对外部库文件的依赖。
原文还提到了执行速度方面的优势。不过,不能简单地认为所有静态链接程序都一定比动态链接程序快;实际性能还取决于程序、平台和具体实现。
3.2.3 动态链接的优点
动态链接将程序与库拆分为相对独立的部分,在运行时通过动态链接机制建立联系,而不是把所有需要的库代码都直接复制进一个可执行文件。
它的主要优势包括:
- 减少重复: 多个程序可以使用同一个共享库。
- 减小程序文件体积: 不必把所有动态库实现都打包进每个程序。
- 便于更新: 在兼容性等条件满足时,更新共享库可以让多个程序共同使用新的库实现。
我们可以使用 ldd 命令查看程序依赖的共享库:
bash
ldd hello
输出可能类似于:
text
linux-vdso.so.1 => ...
libc.so.6 => /lib64/libc.so.6 (...)
/lib64/ld-linux-x86-64.so.2 (...)
其中,libc.so.6 是常见的 C 标准库动态库文件,而 ld-linux 相关文件与动态链接过程有关。
ldd 的用途是查看程序或库文件依赖的共享库。
需要注意,输出的具体内容和路径会因 Linux 发行版、CPU 架构以及编译环境而不同。
3.3 静态库和动态库
3.3.1 静态库和动态库的概念
前面介绍的是链接方式,现在把它们与库文件对应起来。
静态库
- Linux 下通常使用
.a后缀。 - 链接时,将所需的库代码纳入最终可执行文件。
- 最终文件通常更大。
- 运行时通常不需要原来的静态库文件本身。
动态库
- Linux 下通常使用
.so后缀。 - 最终可执行文件会保留相应的动态库依赖。
- 程序运行时需要动态链接器找到相应的共享库。
- 多个程序可以共享同一个动态库的代码。
例如:
text
libc.so.6
就是常见的 C 标准库动态库文件。
通常情况下,GCC 在默认链接设置下会使用系统提供的动态库,但具体行为仍取决于编译选项和系统环境。
链接目标文件生成可执行文件的命令,例如:
bash
gcc hello.o -o hello
生成程序之后,可以使用 file 命令检查它的文件属性:
bash
file hello
输出中可能出现:
text
dynamically linked
这表示该程序采用了动态链接方式。
3.3.2 静态库和动态库的形象演示
为了更直观地理解动态链接与静态链接,可以使用一个生活化的例子。
假设学校里有一位叫 List 的学生,他每天都想上网。
学长告诉他:
出学校东门左拐 400 米,有一家网吧。
于是,List 记住了网吧的位置,每天需要上网时就去那里。
后来,他的室友也知道了这个地方,于是大家都去同一家网吧上网。
有一天,网吧被关闭了。List 和室友们发现,自己原本依赖的上网地点已经不存在了。
我们可以将这个故事中的角色与程序概念对应起来:
| 故事中的角色 | 对应的程序概念 |
|---|---|
| 学长 | 头文件,提供函数声明等信息 |
| 网吧地址 | 对动态库的依赖及查找机制 |
| List 的课表 | 程序中需要执行的代码 |
| 上网 | 调用库中的函数 |
| 网吧 | 动态库 |
| 网吧中的 100 号机器 | 被调用的功能实现 |
| 学校 | 内存与程序运行的环境 |
| 网吧被关闭 | 依赖的库缺失,可能导致程序无法正常运行 |
这个故事想说明的是:动态链接程序依赖外部共享库。若运行环境中缺少必要的库,程序就可能无法正常启动或执行。
那么,静态链接又该怎么理解?
假设 List 的父亲把一台与网吧 100 号机器配置相同的电脑买回宿舍。
List 现在不需要再去网吧,就可以使用自己的电脑了。
对应到程序中,就是把所需要的库代码链接进可执行程序。程序运行时不再需要原来的静态库文件。
但是,如果每个学生都在宿舍里放一台相同的电脑,就会出现重复占用空间的问题。
这正是静态链接可能带来的代码重复和资源占用问题。
这个故事主要用于理解两者的区别:
- 动态链接:程序运行时依赖外部共享库。
- 静态链接:所需的库代码已经被链接进程序。
3.4 对比总结
| 对比维度 | 动态链接 | 静态链接 |
|---|---|---|
| 基本原理 | 通过动态链接机制使用共享库 | 将所需库代码链接进可执行文件 |
| 可执行文件体积 | 通常较小 | 通常较大 |
| 运行时依赖 | 需要找到兼容的动态库 | 通常不需要原来的静态库文件 |
| 代码复用 | 多个程序可以共享库代码 | 不同程序可能包含重复的库代码 |
| 更新方式 | 兼容条件满足时可更新共享库 | 通常需要重新链接程序 |
四、Linux 链接技术实战验证
前面已经从原理上理解了动静态链接,现在通过 Linux 命令进行验证。
4.1 实验环境准备与源代码
4.1.1 编写一个源文件 code.c
首先准备一段简单的 C 语言程序。
创建文件:
bash
vim code.c
输入以下代码:
c
#include <stdio.h>
int main()
{
printf("Hello, Linux\n");
return 0;
}
保存退出后,使用 GCC 编译:
bash
gcc code.c -o code
这里没有使用 -static 选项,因此在常见的 Linux 默认配置下,生成的程序通常采用动态链接。
接下来,通过 ldd 和 file 查看具体结果。
4.2 动态链接验证
4.2.1 使用 ldd 查看库依赖
执行:
bash
ldd code
在常见的动态链接环境中,输出会包含程序依赖的共享库,例如:
text
libc.so.6 => /lib64/libc.so.6 (...)
这说明程序依赖 C 标准库的动态库实现。
结合前面的 printf 例子,可以理解为:程序并没有把所有相关的动态库实现都复制到自己的可执行文件中,而是保留了对共享库的依赖。
程序运行时,需要动态链接器找到相应的库。
注意,/lib64 只是某些系统中的常见路径。Ubuntu 等其他环境可能显示不同路径,具体以自己的命令输出为准。
4.2.2 使用 file 查看文件属性
执行:
bash
file code
输出可能包含:
text
ELF 64-bit LSB executable, ... dynamically linked (uses shared libs)
其中:
ELF:Linux 中常见的可执行文件和目标文件格式。64-bit:说明这是 64 位格式的文件。dynamically linked:说明采用动态链接。uses shared libs:说明程序使用共享库。
原文中的示例程序大小约为 8480 字节。这只是特定编译环境下的示例数据,不同系统和编译选项会得到不同结果。
还可以查看系统中常见的库文件目录:
bash
ls /usr/lib64
不过,并不是所有 Linux 发行版都使用 /usr/lib64 作为库文件目录。Ubuntu 上还可能看到 /usr/lib、/lib 等路径,具体取决于系统架构和配置。
4.3 静态链接验证
4.3.1 强制静态链接编译
前面使用的命令是:
bash
gcc code.c -o code
现在增加 -static 选项,尝试生成静态链接程序:
bash
gcc code.c -o code-static -static
其中:
code.c:源文件。-o code-static:指定输出文件名。-static:要求链接器采用静态链接方式。
需要注意:系统必须安装相应的静态库文件,否则链接可能失败。
例如,在缺少 C 标准库静态库的情况下,可能看到类似下面的错误:
text
cannot find -lc
这通常意味着链接器无法找到所需的 C 标准库静态库。
原文使用的是 CentOS 环境,并通过包管理器安装相应静态库:
bash
sudo yum install -y glibc-static
如果使用 Ubuntu,则不能直接照搬 yum 命令。Ubuntu 使用 apt,需要根据自己的系统版本和仓库情况安装对应的静态库开发包。
例如:
bash
sudo apt update
sudo apt install -y build-essential libc6-dev
这条命令用于安装常见的构建工具和 C 标准库开发文件,但它不保证每个 Ubuntu 版本都已经具备所有静态链接所需的文件。如果仍然报错,需要进一步检查对应静态库是否存在。
4.3.2 静态链接的结果对比
成功生成静态链接程序后,可以比较两个文件的大小:
bash
ls -l code code-static
也可以使用:
bash
du -h code code-static
原文中的示例里,动态链接程序约为 8480 字节,而静态链接程序约为 861288 字节。
这说明,在该示例环境下,静态链接生成的程序明显更大。
需要强调,这些数字不是固定值。程序体积取决于系统、库实现、编译器版本、优化选项以及实际链接进去的代码等因素。
接下来检查静态链接程序:
bash
ldd code-static
对于正常生成的静态链接程序,ldd 可能提示:
text
not a dynamic executable
这表明该程序不属于需要 ldd 列出动态共享库依赖的普通动态链接程序。
再使用:
bash
file code-static
输出中可能出现:
text
statically linked
这样,我们就通过文件属性和库依赖两种方式,验证了动态链接与静态链接的区别。
4.4 周边问题探讨:C++ 与标准库
4.4.1 C++ 的动态链接特点
C++ 程序与 C 程序一样,也需要经过编译和链接。
例如:
cpp
#include <iostream>
int main()
{
std::cout << "Hello, C++" << std::endl;
return 0;
}
可以使用 g++ 编译:
bash
g++ code.cpp -o codecpp
检查程序依赖:
bash
ldd codecpp
与简单的 C 程序相比,C++ 程序可能会依赖更多的库。
其中,常见的 C++ 标准库动态库包括:
text
libstdc++.so.6
因此,C++ 程序可能同时依赖 C 标准库和 C++ 标准库。
具体的库路径会根据系统、编译器和工具链配置而不同。有些定制开发环境中,标准库也可能位于工具链自己的目录中。
4.4.2 C++ 静态链接同理
C++ 程序也可以尝试使用静态链接:
bash
g++ code.cpp -o codecpp-static -static
如果系统中缺少相应的 C++ 标准库静态库,例如 libstdc++.a,链接时就可能出现类似错误:
text
cannot find -lstdc++
这与 C 程序静态链接时缺少对应静态库的情况类似。
因此,静态链接不仅涉及编译器选项,还取决于当前环境是否具备所需的静态库文件。
五、补充------动态库的真相
5.1 核心本质:内存中只存在一份共享代码
前面我们提到,动态链接的程序会依赖外部动态库。
但动态库真正重要的优势,不只是让可执行文件变小。
动态库的核心价值之一,是让多个进程能够共享同一份库代码,从而减少重复的内存占用。
我们可以通过静态链接和动态链接进行对比。
静态链接的情况
假设有多个程序都使用同一个库。
静态链接时,所需的库代码会被分别链接进各自的可执行文件中。
因此:
- 每个程序文件都可能包含相同的库代码;
- 程序运行时,相同代码也可能以多个独立副本存在;
- 这会增加磁盘和内存方面的资源开销。
动态链接的情况
动态链接时,程序不必把共享库的完整实现分别复制到每个可执行文件中。
运行时,系统可以将共享库映射到多个进程的虚拟地址空间,并让它们共享相应的物理内存页面。
因此,多个进程能够复用共享库中的代码,而不必为每个进程都保存一份完全独立的代码副本。
这里有一个需要准确理解的细节:
"共享一份代码"并不意味着所有进程的整个内存空间都相同。每个进程仍然有自己的虚拟地址空间、栈、堆和其他运行数据。能够共享的是动态库中适合共享的内存页面,具体还取决于系统的内存管理机制。
原文强调的核心思想是:
动态库让公共代码能够被多个程序共享,减少重复存储和内存占用。
这也是为什么动态链接在 Linux 系统和日常应用程序中非常常见。
六、附录:gcc/g++ 常用选项参考
最后,把本篇涉及的常用选项集中整理,方便以后查询。
6.1 编译流程控制
| 选项 | 作用 |
|---|---|
-E |
仅进行预处理,完成后停止 |
-S |
生成汇编代码,不继续汇编和链接 |
-c |
生成目标文件,不进行最终链接 |
-o [file] |
指定输出文件名 |
常见使用方式:
bash
gcc -E code.c -o code.i
gcc -S code.i -o code.s
gcc -c code.s -o code.o
gcc code.o -o code
6.2 链接与库相关
| 选项 | 作用 |
|---|---|
-static |
要求采用静态链接方式 |
-shared |
用于生成共享库等场景,具体使用方式取决于编译与链接参数 |
注意:-shared 并不是简单地"让普通可执行文件尽量使用动态库"。它常用于构建共享库,通常还需要结合其他选项与适当的源文件。
6.3 调试与优化
-g:生成调试信息
例如:
bash
gcc code.c -o code -g
生成的调试信息可以供 GDB 等调试器使用,帮助我们查看源代码位置、变量和调用栈等信息。
优化级别
| 选项 | 常见含义 |
|---|---|
-O0 |
不进行常规优化,通常便于调试 |
-O1 |
开启一组优化 |
-O3 |
开启更积极的优化 |
例如:
bash
gcc code.c -o code -O0
需要注意,优化级别的具体行为与编译器版本有关。-O1 不是 GCC 默认的优化级别;如果没有指定优化选项,GCC 通常默认采用 -O0。
6.4 警告信息控制
-w:关闭警告信息
bash
gcc code.c -o code -w
-Wall:开启一组常用警告
bash
gcc code.c -o code -Wall
-Wall 并不意味着开启了编译器能够提供的所有警告,但它会启用一组常用的警告检查。
在实际开发中,建议关注编译器的警告信息。警告不一定意味着程序无法运行,但可能提示潜在的错误、类型问题或不规范的代码。
本篇总结
本篇从 GCC/G++ 的基本使用出发,逐步理解了程序从源代码到可执行文件的转换过程,并进一步认识了条件编译、编译器自举和动静态库。
最重要的知识点可以归纳为:
- 编译流程: 预处理、编译、汇编、链接。
- 关键选项:
-E、-S、-c、-o。 - 多文件编译: 可以一次性编译,也可以先生成
.o文件再统一链接。 - 条件编译: 通过宏和条件指令控制哪些代码参与编译。
- 编译器自举: 利用已有工具逐步构建能够编译自身的编译器。
- 动态链接: 程序在运行时通过动态链接机制使用共享库。
- 静态链接: 将所需库代码链接进可执行文件,通常会增加文件体积。
- 实战验证: 使用
ldd查看共享库依赖,使用file查看文件属性。 - 动态库的核心优势: 多个进程可以共享库代码,减少重复的内存占用。
理解这些内容之后,我们就不再只是知道如何使用 gcc 和 g++,而是开始理解程序是怎样被构建出来的,以及程序为什么需要链接库。
下一步,我们将继续学习 Linux 开发工具,逐步把单个源文件的编译、多个源文件的链接与后续的自动化构建联系起来。