初探——【Linux】程序的翻译与动静态链接
我们所写的C/C++程序计算机是看不懂的,它只认识0101这样的机器码。所以我们就需要借助编译器对这些源代码进行翻译,使之成为计算机能够执行的二进制指令。这个过程通常分为几个关键步骤:预处理、编译、汇编和链接。
一.预处理(Preprocessing)
预处理是翻译的第一步,编译器会对代码进行修改整理,进行替换工作,或者删除一些无用代码。它会进行以下几个步骤:
- 宏替换:将源代码中定义的宏用其对应的值或者代码块进行替换,这只是简单的文本替换
- 头文件展开:将源代码中的头文件在包含位置进行展开。这个过程是递归式,即包含的头文件内可能还包含其他的头文件。
- 条件编译:根据条件编译有选择地编译满足条件的代码,不满足的代码直接被编译器删除
- 去注释:删除源代码中所有的注释信息
我们可以借助linux上的gcc编译器的各种选项来控制翻译的过程:
gcc -E test.c -o test.i
-E选项,让程序进行了预处理之后停下来,然后生成对应的.i文件
我们看下图,右侧是我们所写的源代码,左侧是经过预处理之后的代码,首先能感受到的就是预处理之后的代码量比我们的.c大的多,这主要就是因为头文件展开导致的。还有就是注释的删除以及条件编译。
二.编译(Compilation)
编译这个过程是将我们经过预处理之后的代码进行一系列操作将其翻译为汇编代码。这个过程编译器对源代码进行词法分析、语法分析和语义分析,以确保代码的正确性,并将其转换为等价的汇编代码。
- 词法分析:将源代码中的字符流分割成一系列记号(如关键字、标识符、字面量、特殊字符等)。
- 语法分析:根据编程语言的语法规则,将词法分析阶段产生的记号组织成语法树。
- 语义分析:对语法树进行语义检查,如类型匹配、类型转换等,并生成中间代码或汇编代码。
编译之后,会生成一个.s文件
Linux上的gcc编译器可以通过-S选项,使源代码只进行到编译过程,然后生成其对应的.s文件:
gcc -S test.i/test.c -o test.s
既可以从.c源代码进行编译,编译完后停止,也可以从预处理之后的文件.i直接开始编译
最左侧的代码就是经过编译之后的汇编代码:
汇编代码是一种非常底层的代码,那么为什么要将我们的源代码编译成汇编代码呢?
这就与计算机的发展历史有关了,计算机刚发展出来是通过开关给计算机输入01这样的二进制指令,使其进行计算。接着就有了打孔编程,根据01的位置关系进行打孔,再将该纸条放入计算机的输入端,通过一些特殊操作使计算机读取数据。
但是上面两种方式都太麻烦了,随着时间的推演,汇编语言诞生了,大家都开始使用汇编语言编写代码,与此同时,汇编代码的产生也就促进了编译器的诞生。因为汇编代码计算机不认识,只能借助编译器对其进行翻译。
但毕竟汇编是非常底层的代码,写起来很难,于是便有了基于汇编代码产生的C语言。但是C语言计算机也无法直接执行,所以就需要新的编译器将C语言代码进行翻译。那此时翻译还需要直接将C语言翻译成01的二进制指令嘛?当然不需要了,我们已经有了汇编->二进制的基础了,我们直接将C->汇编不就行了。
所以将源代码首先翻译成汇编指令是历史的原因,这样可以加快语言的发展,不必埋头解决翻译难题了。
三.汇编(Assembling)
汇编是将汇编代码转换为机器代码(二进制指令)的过程。汇编器读取汇编代码文件,并将其转换为计算机可以直接执行的机器指令。
汇编阶段的主要任务是:
- 指令翻译:根据汇编指令和机器指令的对照表,将汇编指令一一翻译为对应的机器指令。
- 生成目标文件:将翻译后的机器指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件中(在Windows下目标文件是.obj,在Linux下目标文件为.o)。
汇编完成后,会生成一个扩展名为“.o”的目标文件。
linux中可以借助gcc编译器的-c选项,使代码的翻译过程在完成汇编之后停下来,生成对应的.o文件。
gcc -c test.c/test.i/test.s -o test.o
gcc -c test.c
gcc会根据第二个指令,直接生成同名.o文件
如下,就是经过汇编之后的二进制文件
但是生成的二进制文件还不能直接被执行,因为里面缺少方法的实现,所以还需要最后一步——链接
四.链接(linking)
链接是将多个目标文件以及所需的库文件合并成一个可执行程序的过程。链接器负责解决目标文件之间的符号引用问题,并将它们组合成一个统一的可执行文件。
链接阶段的主要任务包括:
- 地址和空间分配:为每个目标文件中的代码和数据段分配内存地址。
- 符号决议:解决目标文件之间的符号引用问题,即将一个文件中引用的符号与其在另一个文件中的定义连接起来。
- 重定位:修改目标文件中的指令和数据,以便它们指向正确的内存地址。
链接完成后,会生成一个可执行程序文件(在Windows下通常为.exe文件,在Linux下默认为具有可执行权限的a.out文件,但也可以通过编译器选项自定义输出文件名)。
gcc test.c/test.i/test.s/test.o -o test
可执行程序名可以任意指定,如果没有指定的话默认是a.out
我们可以看到,经过链接之后产生的可执行程序的容量又大了很多,这就是链接过程将该程序所依赖的方法的实现引入到了文件中。 如果引入的是方法的地址——动态链接,如果将方法拷贝到了目标文件中——静态链接。
那么有个疑问,我们已经包含了对应方法的头文件,为什么还要进行链接操作呢?
因为头文件中只是包含了对应方法的声明,我们在使用该方法的话程序需要跳转到该方法的具体实现才可以。
五.动态链接和静态链接
链接的过程是将库中的方法的实现和我们的.o结合的过程。
那什么是库呢?
库是一套方法或者数据集,它可以提高我们的开发效率。之所以会出现库就是因为在开发过程中,大家都发现一些的方法函数会被频繁使用,但是没有库的话,就需要自己实现,而且1000个人就有1000种实现方法。
所以人们就设计出来一套库,里面包含了各种常见的方法容器等等。比如我们的c标准库。
那么是什么动态链接什么是静态链接呢?
其实很好理解链接动态库就是动态链接,链接静态库就是静态链接。在windows下,动静态库的后缀分别为.dll,.lib,在linux下,动静态库的后缀分别为.so,.a.
动态链接其实是将动态库中的方法的地址给到源代码中调用该方法的位置。当程序运行之后,就可以根据这个地址跳转到对应的方法,执行完方法之后,在跳回到调用方法的位置继续执行代码。所以在动态链接的过程中,需要全程依赖动态库,动态库丢失就会导致程序运行失败。
静态链接则是将静态库中该方法拷贝到源代码中,程序执行过程直接在源代码中执行,且连接之后程序不再需要静态库。
linux中可以借助ldd命令查看可执行程序依赖了那个共享库,还可以借助file命令查看该可执行程序是采取了哪种链接方式:
如下图我们可以到看,该test可执行程序依赖c标准库,且采取的是动态链接。
那么我们要是就想让程序进行静态链接呢?
gcc test.c -o test_static -static
我们在编译的时候可以加上-static选项,使其进行静态链接
因为是静态链接,所以不会依赖任何共享库
那么动态链接和静态链接的结果有什么不同嘛?两者有什么有缺点嘛?
我们从链接的方式就可以体现第一个差别:动态链接只给了地址,而静态链接需要拷贝方法,所以动态链接的可执行程序体积小,静态链接的可执行程序体积大
程序运行,需要将代码加载到内存中,静态链接会加载很多的重复代码到内存中,浪费资源。
静态链接的程序对静态库的依赖度低,动态链接则动态库不能丢失。
静态链接是拷贝静态库中的方法到源代码中,所以链接之后静态库就没用了;而动态链接则是通过地址寻址到动态库中的方法,所以动态库不可缺失。
对于动态库来说,如果多个源代码中包含了同一种方法,等到程序加载到内存中时,内存中只有一份方法 。
六.多文件编译
在大型项目中,我们会包含都源文件。有些是运行逻辑,有些是方法实现。那么要怎么编译这个项目呢?
我们可以直接编译所有的源文件,生成一个可执行程序
但是更常用的是,我们先分别将所有的源文件都编译成.o为后缀的目标文件,最后在进行动态链接的时候将所有的.o文件一起链接成一个可执行程序
那么为什么要先生成目标文件,最后一起进行链接呢?
在项目结束后,我们最终会公开我们的头文件,里面包含了方法的声明以及各种接口。但是用户只有声明是无法进行编译的,所以我们还得提过实现,但是如果我们直接提供.c源文件,那不就等于泄露了机密嘛。
所以解决办法就是将所有的.c源文件先进行编译,生成对应的.o文件,最后将所有的目标文件打包放在一个公共的文件里使之成为一个库,最终我们将头文件以及这个包含了目标文件的库交给用户,它们需要某个方法时可以直接链接该库,这下就不害怕泄密了。
其实我们用的c标准库也是如此,里面都是.o目标文件所组成的。
完~