Linux 开发工具(2)
上一章我们了解了文件编辑器Vim,本章我们将深入理解将一个代码文件经过预处理——编译——汇编——链接最后变成可执行程序的编译器——gcc/g++。
一.gcc/g++工作流
1.可执行程序生成过程
由引言我们得出,可执行程序生成的过程大致为:
预处理:进⾏宏替换/去注释/条件编译/头⽂件展开等
编译:⽣成汇编
汇编:生成机器可识别的二进制码
链接:⽣成可执⾏⽂件或库⽂件
接着我们结合gcc/g++的使用,来说明整个流程。
2.预处理
• 预处理功能主要包括宏定义,⽂件包含,条件编译,去注释等。
• 预处理指令是以#号开头的代码⾏。
gcc –E hello.c –o hello.i
-E选项表示编译器不会直接一口气将程序翻译到底,而是仅做完预处理工作就停下来,生成如上的.i文件。
示例:首先准备好我们要进行一系列操作的文件tree.cpp
wujiahao@VM-12-14-ubuntu:~$ vim tree.cpp
wujiahao@VM-12-14-ubuntu:~$ cat tree.cpp
#include<iostream>
using namespace std;
int main()
{for(int i=5;i>=0;i--){cout<< "i love linux "<<endl;cout<< "i love linux "<<endl;cout<< "i love linux "<<endl;}return 0;
}
接着我们对tree.cpp进行预处理,并生成对应.i文件。
wujiahao@VM-12-14-ubuntu:~$ g++ -E tree.cpp -o tree.i
wujiahao@VM-12-14-ubuntu:~$ ll
-rw-rw-r-- 1 wujiahao wujiahao 217 Sep 14 16:04 tree.cpp
-rw-rw-r-- 1 wujiahao wujiahao 778585 Sep 14 16:05 tree.i
然后我们不妨打开这个.i文件
可以看到这个文件里面确实是做了一系列的预处理工作,尽管初始的文件只有几行代码,但是展开之后的.i文件可以看到已经达到了三万多行。需要注意的是,经过这个阶段,仍然是C++语言。
3.编译
• 在这个阶段中,gcc ⾸先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的⼯作,在检查⽆误后,gcc 把代码翻译成汇编语⾔。
• 用户可以使⽤“-S”选项来进⾏查看,该选项只进⾏编译⽽不进⾏汇编,⽣成汇编代码
gcc –S hello.i –o hello.s
示例:我们依然对上面的tree.i进行编译
wujiahao@VM-12-14-ubuntu:~$ g++ -S tree.i -o tree.s
wujiahao@VM-12-14-ubuntu:~$ ll
-rw-rw-r-- 1 wujiahao wujiahao 217 Sep 14 16:04 tree.cpp
-rw-rw-r-- 1 wujiahao wujiahao 778585 Sep 14 16:05 tree.i
-rw-rw-r-- 1 wujiahao wujiahao 2938 Sep 14 16:11 tree.s
我们不妨可以打开这个tree.s,其实这里已经是大部分人不太看得懂的汇编代码了。
4.汇编
• 汇编阶段是把编译阶段⽣成的“.s”⽂件转成⽬标⽂件
• 读者在此可使⽤选项“-c”就可看到汇编代码已转化为“.o”的⼆进制⽬标代码了
gcc –c hello.s –o hello.o
示例:我们将上面的代码进行汇编
wujiahao@VM-12-14-ubuntu:~$ g++ -C tree.s -o tree.o
wujiahao@VM-12-14-ubuntu:~$ ll
-rw-rw-r-- 1 wujiahao wujiahao 217 Sep 14 16:04 tree.cpp
-rw-rw-r-- 1 wujiahao wujiahao 778585 Sep 14 16:05 tree.i
-rwxrwxr-x 1 wujiahao wujiahao 16520 Sep 14 16:14 tree.o*
-rw-rw-r-- 1 wujiahao wujiahao 2938 Sep 14 16:11 tree.s
这里的.o文件已经是二进制代码,就不再展示。
5.链接
• 在成功编译之后,就进⼊了链接阶段。
示例:此时我们已经通过了编译汇编,直接对.o文件进行链接
gcc hello.o –o hello
wujiahao@VM-12-14-ubuntu:~$ g++ tree.o -o tree.exe
链接过程的具体工作
链接器(Linker,通常是 ld
或 g++
调用的 collect2
)主要完成四项至关重要的工作:
1. 符号解析 (Symbol Resolution)
是什么:在编译阶段,编译器遇到像
printf
,std::cout
或者你在其他文件中自己定义的函数和变量时,它并不知道这些符号的具体地址。它只是记下“这里需要一个叫foo
的函数”,并生成一个未解决的符号引用(Unresolved Symbol Reference)。链接器做什么:链接器会扫描所有提供的目标文件(
.o
文件)和库文件(.a
或.so
文件),像侦探一样为每个“未解决的引用”找到其定义(Definition)所在的位置。
2. 重定位 (Relocation)
是什么:编译器在生成目标文件时,会从地址0开始为代码和数据分配临时的内存地址。但很显然,多个目标文件最终不能都从0开始加载。
链接器做什么:链接器将所有目标文件的代码段(如
.text
)、数据段(如.data
、.bss
)等分别合并到一起,然后为它们分配最终的、统一的运行时内存地址。接着,它会回去修改所有之前临时分配的地址引用,让它们指向正确的最终地址。这个过程就是重定位。
3. 合并目标文件与库 (Merging Object Files and Libraries)
是什么:一个项目通常由多个源文件(
.cpp
)编译成多个目标文件(.o
)。链接器做什么:链接器就像装配工人,把所有目标文件“拼凑”在一起,形成一个完整的整体。同时,它还会从标准库(如
libstdc++
.so)和用户指定的第三方库中,只提取那些真正被用到的函数和数据,合并到最终的可执行文件中。
4. 生成可执行文件 (Generating the Executable)
完成上述所有步骤后,链接器将最终结果输出为一个格式(如Linux上的ELF格式)完整的、操作系统可以加载和运行的可执行文件(如这里的 tree.exe
)。
动态链接与静态链接
在我们的实际开发中,不可能将所有代码放在⼀个源⽂件中,所以会出现多个源⽂件,⽽且多个源⽂件之间不是独⽴的,⽽会存在多种依赖关系,如⼀个源⽂件可能要调⽤另⼀个源⽂件中定义的函数,但是每个源⽂件都是独⽴编译的,即每个*.c⽂件会形成⼀个*.o⽂件,为了满⾜前⾯说的依赖关系,则需要将这些源⽂件产⽣的⽬标⽂件进⾏链接,从⽽形成⼀个可以执⾏的程序。这个链接的过程就是静态链接。静态链接的缺点很明显
• 浪费空间:因为每个可执⾏程序中对所有需要的⽬标⽂件都要有⼀份副本,所以如果多个程序对
同⼀个⽬标⽂件都有依赖,如多个程序中都调⽤了printf()函数,则这多个程序中都含有
printf.o,所以同⼀个⽬标⽂件都在内存存在多个副本;
• 更新⽐较困难:因为每当库函数的代码修改了,这个时候就需要重新进⾏编译链接形成可执⾏程
序。但是静态链接的优点就是,在可执⾏程序中已经具备了所有执⾏程序所需要的任何东西,在
执⾏的时候运⾏速度快。
动态链接的出现解决了静态链接中提到问题。动态链接的基本思想是把程序按照模块拆分成各个相对独⽴部分,在程序运⾏时才将它们链接在⼀起形成⼀个完整的程序,⽽不是像静态链接⼀样把所有程序模块都链接成⼀个单独的可执⾏⽂件。
动态链接其实远⽐静态链接要常⽤得多。⽐如我们查看下 tree 这个可执⾏程序依赖的动态库,会发现它就⽤到了⼀个c动态链接库。
ldd [可执行文件]
wujiahao@VM-12-14-ubuntu:~$ g++ tree.cpp -o tree
wujiahao@VM-12-14-ubuntu:~$ ldd treelinux-vdso.so.1 (0x00007fff1d5f1000)libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007ff3cdc8f000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff3cda66000)libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007ff3cd97f000)/lib64/ld-linux-x86-64.so.2 (0x00007ff3cdec8000)libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007ff3cd95f000)
在这⾥涉及到⼀个重要的概念: 库
• 我们的C程序中,并没有定义“cout”的函数实现,且在预编译中包含的“iostream”中也只有该函数的声明,⽽没有定义函数的实现,那么,是在哪⾥实“cout”函数的呢?
• 最后的答案是:系统把这些函数实现都被做到名为 libc.so.6 的库⽂件中去了,在没有特别指时,gcc 会到系统默认的搜索路径“/usr/lib”下进⾏查找,也就是链接到 libc.so.6 库函数中去,这样就能实现函数“printf”了,⽽这也就是链接的作⽤
6.针对多个源文件的解释
1.gcc/g++处理多个源文件时,通常经历以下步骤:
预处理:处理宏定义、头文件包含等
编译:将源代码转换为汇编代码
汇编:将汇编代码转换为目标文件(.o)
链接:将多个目标文件和库文件合并为可执行文件
2.gcc/g++做的工作:
GCC 会分别编译每个源文件为对应的.o目标文件
然后链接器将所有.o文件合并成一个可执行程序
示例:假设有三个源文件:main.c
、utils.c
和 helper.c
分步编译方式:
# 分别编译每个源文件为目标文件
gcc -c main.c -o main.o
gcc -c utils.c -o utils.o
gcc -c helper.c -o helper.o# 链接所有目标文件为可执行程序
gcc main.o utils.o helper.o -o myprogram
一次性编译模式
# GCC 自动完成编译和链接所有步骤
gcc main.c utils.c helper.c -o myprogram
在第二种方式中,GCC 内部仍然会分别编译每个源文件,然后自动调用链接器将它们合并为最终的可执行程序,只是这些中间步骤对用户不可见。
3.该种处理方式的优点
1. 增量编译:当只修改一个文件时,只需重新编译该文件,然后重新链接,节省时间
2. 代码组织:大型项目通常分成多个模块,分别编译后链接
3. 库的使用:可以将常用功能编译为静态库或动态库,供多个程序使用
二.动态库与静态库
1. 静态库 (Static Library)
什么是静态库?
在编译链接期,编译器会将你的程序代码和所使用的静态库代码完全复制到最终的可执行文件中。这个过程称为静态链接。
Windows 上的后缀是
.lib
(注意:静态库和动态库的导入库都使用.lib
,但内容不同)Linux/Unix 上的后缀是
.a
(Archive)macOS 上也使用
.a
或框架 (Framework) 的形式
工作原理
编译:你的源代码 (
main.c
) 和库的源代码(已被打包为.a
或.lib
)被编译成目标文件 (.o
或.obj
)。链接:链接器将你的目标文件和静态库合并,将所有需要用到的代码从库中提取出来,复制到最终的可执行文件中。
执行:生成的可执行文件是独立的。运行时不再需要原始的静态库文件。
优点
兼容性好/部署简单:可执行文件是自包含的,所有依赖都打包在内。发布程序时只需要提供一个可执行文件即可,不用担心用户系统上缺少所需的库或库版本不对。
性能稍高:因为所有代码都在同一个可执行文件中,函数调用在运行时没有额外的寻址开销,速度可能略快。
缺点
空间浪费:
磁盘空间:如果多个程序都使用了同一个静态库,那么每个程序的可执行文件内部都有一份该库的完整拷贝,会导致磁盘上有大量重复代码。
内存空间:当这些程序同时运行时,相同的库代码会被多次加载到内存中。
难以升级:如果静态库发现了 bug 或需要更新功能,必须重新编译并发布整个程序。用户需要更新整个巨大的可执行文件,而不能只更新一个库。
2. 动态库 (Dynamic Library / Shared Library)
什么是动态库?
在编译链接期,链接器只会在可执行文件中记录它依赖了哪个动态库(符号引用),而不会复制库的代码。直到程序运行时,操作系统才会将所需的动态库加载到内存中,并与程序链接。这个过程称为动态链接。
Windows 上的后缀是
.dll
(Dynamic-Link Library)Linux/Unix 上的后缀是
.so
(Shared Object)macOS 上的后缀是
.dylib
(Dynamic Library) 或.framework
工作原理
编译链接:生成可执行文件时,只记录它需要依赖的动态库名称(如
libmath.so
)和其中的函数入口信息。运行:当程序启动时,操作系统的动态链接器会去寻找所需的动态库文件,并将其加载到内存的共享区域。
映射:操作系统将库中的函数映射到进程的地址空间,程序开始执行并调用库中的函数。
优点
节省空间:
磁盘空间:多个程序可以共享磁盘上的同一个动态库文件。
内存空间:内存中只需要加载一份动态库的代码,多个运行的程序可以共享它。这大大节省了内存资源。
便于更新和部署:
更新库时(如修复安全漏洞),只需要替换掉磁盘上的动态库文件(如将
libfoo.so.1.0
升级到libfoo.so.1.1
),所有依赖它的程序在下次运行时就会自动使用新版本。无需重新编译主程序。这在大型软件系统和操作系统中非常常见(如 Windows 的系统 DLL,Linux 的 glibc)。
缺点
依赖管理复杂(DLL Hell):程序无法运行的一个常见原因是“找不到
xxx.dll
”或“ incompatible library version ”。发布程序时,必须确保目标系统上有正确版本的依赖库。性能轻微损耗:运行时链接需要额外的开销,但现代操作系统对此优化得很好,损耗非常小。
链接时机 | 编译时 | 运行时 |
包含方式 | 代码被复制到可执行文件中 | 库文件独立于可执行文件 |
文件后缀 | Windows: .lib , Linux: .a | Windows: .dll , Linux: .so , macOS: .dylib |
空间占用 | 浪费空间(磁盘和内存) | 节省空间(磁盘和内存共享) |
部署难度 | 简单,所有东西都在一个文件里 | 复杂,需要确保目标系统有正确的库 |
更新/升级 | 困难,需重新编译整个程序 | 容易,只需替换库文件 |
运行性能 | 略高(无运行时链接开销) | 略低(有运行时链接开销) |
兼容性风险 | 低 | 高(DLL Hell) |
如何选择?
使用静态库的情况:
对程序部署的简便性要求极高,希望只有一个可执行文件。
担心用户环境复杂,缺少必要的运行库(例如,在一些嵌入式系统或极简环境中)。
使用的库不太会改变,或者库是你项目本身的一部分。
非常看重程序的启动性能。
使用动态库的情况:
开发大型软件或操作系统组件,需要节省内存和磁盘空间。
库需要被多个应用程序共享。
库需要频繁更新或打补丁(如公共功能模块、插件系统)。
希望实现应用程序的模块化,可以按需加载不同的功能模块。
在实际开发中,两种方式常常混合使用。例如,C 语言的标准库 libc
,在大多数 Linux 系统上默认是动态链接的(libc.so
),但你也可以选择静态链接(libc.a
)。