likes
comments
collection
share

计算机底层1 如何从编程语言一步步到可执行程序

作者站长头像
站长
· 阅读数 20

前段时间更新完设计模式,去看了一些底层知识和算法相关,在学习计算机底层知识时,陆小风的 《计算机底层的秘密》 这本书深入浅出的讲解了现代计算机系统中非常重要的编译器工作原理,操作系统,内存,CPU,cache,I/O 等知识,尤其是以内容可视化的方式很大程度上帮助了理解计算机底层中很多抽象的概念。

计算机底层1 如何从编程语言一步步到可执行程序

本篇文章旨在记录学习完这本书后自己对计算机底层知识的一些理解,加深学习,也便于在日后如有部分知识忘记后的快速查找。

要了解计算机底层,第一步就是从身边的编程语言开始,了解如何从编程语言一步步到可执行程序。

1 从二进制到汇编再到高级编程语言

”编程语言只是程序员对计算机发号施令的一个工具而已“ ----《计算机底层的秘密》

CPU是个笨蛋,笨到只会把数据从一个地方搬到另外一个地方,进行简单的计算后,再把数据搬回去,相比于有着数以百亿神经元的超级复杂的智慧人脑,计算机简直是太笨了。

但是CPU有着一项人脑难以超越的无敌优势:

《三体:地球往事》 中,刘慈欣也用“人列计算机”的设定说明了这一点,在设定中,

“每个人在一秒钟内可以挥动黑白小旗十万次”

这里的“挥动黑白小旗”就指的是CPU 的运算,“一秒钟十万次”指的就是CPU 的运算速度,当然实际上,CPU 的运算速度可能远远不止“十万次”。这形象地说明了CPU 运算速度之快。

实际上,CPU 是由一个个晶体管组成的,不过有关CPU 更加详细的内容,之后在CPU 的文章中会详细说明。在本篇文章中,我们更加关注人类是怎么利用CPU 这个简单计算的功能。

1.1 CPU 运算与二进制与汇编语言

计算机底层1 如何从编程语言一步步到可执行程序

我们知道,计算机是以二进制方式工作的,计算机内部的所有数据和指令都以二进制形式表示。计算机使用二进制来表示数字、字符、图像、音频等所有信息。

而CPU通过执行一系列的二进制指令来处理这些数据。CPU通过解释和执行二进制指令来完成各种任务,从而实现了计算、数据处理和程序执行等功能。

但是,对于计算机而言适合运算和处理的二进制,对于人类而言就很难理解和编写,人们很难直接识别二进制代码中的指令和数据,这使得编程和调试变得困难。同时,维护也变得困难可移植性也差(二进制代码通常依赖于特定的计算机体系结构和硬件,这意味着在不同的计算机上运行相同的程序可能需要重新编写和调整代码),就此,产生了汇编语言

汇编语言是一种更高级的低级编程语言,它使用助记符和符号来表示二进制指令和内存地址,从而提高了代码的可读性和可维护性。它比纯粹的二进制编程更加友好和高效。

计算机底层1 如何从编程语言一步步到可执行程序

1.2 高级编程语言

我们前面提到,人类的大脑是高级,复杂的,人类使用的语言是抽象的。 比如,人类能够听懂“给我端杯水”这样抽象的语言,但是CPU 却不能,CPU 所进行的计算是具象的,要是想让CPU 听懂,就应该转化成:

  1. 迈出右腿
  2. 停住
  3. 迈出左腿
  4. 停住
  5. 重复上面步骤直到饮水机旁边
  6. 找到水杯
  7. 移动到出水口
  8. 伸出左手
  9. 打开开关
  10. 如果水没有接满
  11. 则继续等待
  12. 如果水接满了
  13. 就关闭开关
  14. ...

可以看出,人类如果想提高编程效率,降低编程门槛,就必须要再发明比汇编更加抽象的语言,最好是能够接近人类语言逻辑的编程语言,因此,高级语言诞生了,现在我们学习的C语言,Java,python,C++ 等等,就是高级编程语言。至于高级编程是如何再变成让CPU 理解的二进制,会在稍后说明。

1.3 抽象:计算机科学中非常重要的概念

我们刚刚在学习高级编程语言的时候,提到了人类的语言是抽象的,我们发明更加适合人类语言习惯的高级编程语言,就是因为抽象,抽象使得我们开发的效率变得更高。这是我们在学习计算机底层的时候第一次提到抽象这个概念。但是,这个概念在计算机科学发展中贯穿始终。

现代计算机是一个复杂而庞大的系统,它的结构实际上就是被层层抽象过的。 现在有很多程序员就算根本不知道自己编写的代码在计算机的底层是如何运行的,他编写的代码可能也能够成功运行在各种设备上,这就是抽象的威力,使得现代的程序员在编写代码时不需要关心底层的细节是如何实现的。

在这一方面上,开发的效率大大提高了,但是在另外一方面,使得我们在遇到一些问题的时候,甚至都不能理解问题本身,给我们解决问题带来了很大的麻烦。这也是要学习计算机底层的原因,去了解问题本身。

2 编译器Compiler 是如何工作的

计算机底层1 如何从编程语言一步步到可执行程序

计算机底层1 如何从编程语言一步步到可执行程序 在刚刚按照编程语言被发明的顺序介绍了从二进制到汇编,再到高级编程语言,但这只是我们站在人类的角度想问题,不断抽象,制造最适合人类的工具。

但是计算机最终还是以二进制方式工作的,也就是说,我们用高级编程语言编写的源代码,最终还是要转化为二进制的可执行程序,那么这个过程,就是由编译器完成的。

所以简单来说,编译器就是一个将高级语言翻译为低级语言的程序

计算机底层1 如何从编程语言一步步到可执行程序

2.1 编译器对源代码的分析

编译器对源代码的分析主要有词法分析,语法分析,语义分析这几个过程。

2.1.1 词法分析 Lexical Analysis

将源代码分解为一个个的词法单元(tokens),如变量名、关键字、运算符等。这个过程去除不必要的空格和注释,并将代码转化为一系列有意义的词法单元。

比如:

int a = 1;
while (a != 5) {
  a++;
}

上面这段代码中,a, =, 1, ; 这些都会被转化为token :

T_Keyword        int
T_identifier     a
T_Assign         =
T_Int            1
T_Semicolon      ;
...

2.1.2 语法分析 Parsing

只有token 是没有用的,我们需要把这些token 背后程序员想表达的意图表示出来。

我们知道,代码都是按照语法来编写的,那么编译器就要按照语法来处理token

比如还是上面的这段代码:

int a = 1;
while (a != 5) {
  a++;
}

在C语言中,while的语法规则是后面的token是左括号,如果不是,那么这个时候编译器就会开始报告语法错误,如果正确就会继续检查,在这个过程中,编译器根据语法解析出来的结构就叫做“语法树”。

计算机底层1 如何从编程语言一步步到可执行程序

2.1.3 语义分析 Semantic Analysis

编译器接下来进行语义分析,确保源代码的语法正确且符合语言规范。这一阶段包括类型检查(比如不能把一个Intchar相加)、作用域分析常量折叠等。编译器会捕捉并报告任何语义错误。

2.2 代码生成

编译器遍历语法树,并且用 中间代码(IR code) 来表示。随后,编译器将中间代码转化为汇编指令,最后,编译器将汇编指令转化成机器指令,就这样,编译器把更加适合人类抽象语言的源代码转化成CPU 可以执行的机器指令。

我们刚刚用C语言编写的代码所放置的文件一般以.c 结尾,称为源文件,而编译器最终生成的机器指令称为目标文件,以.o 结尾。

计算机底层1 如何从编程语言一步步到可执行程序

也就是说,每个源文件都会有一个对应的目标文件,那么假如一个项目里面有3个源文件,那么最后就会生成3个目标文件,可是我们都知道,最后只会有一个可执行的程序,那么是什么把这三个文件合并成一个可执行程序呢?

计算机底层1 如何从编程语言一步步到可执行程序

3 链接器 Linker

这个合并多个目标文件的工作叫:链接,负责链接的程序就是链接器(Linker)

链接器和编译器一样,也是一个程序,它负责把编译器产生的多个目标文件打包成最终的可执行文件。

计算机底层1 如何从编程语言一步步到可执行程序

3.1 符号决议

刚才我们提到,把多个目标文件链接成一个可执行文件,那么这个过程中可能会出现这种情况:我们写的源文件A依赖于源文件B的借口或者变量,或者依赖别的模块,那么链接器就是要确保这种模块间的依赖是成立的,也就是说,模块A依赖的模块B的接口,在模块B中,必须要有该接口的实现。

这个过程称为符号决议,意思就是我们引用的外部符号必须要在其他模块中找到唯一对应的实现。这里的“符号”指的就是变量名,包括全局变量名和函数名。当然,由于局部变量是模块私有的,不会被其他模块引用,所以不需要考虑。

int global = 0;  // 全局变量
extern int external;  // 引用的外部变量
int funcA(int x);  // 引用的外部函数
int funcB() {  // 自己实现的函数
    int num = 1;  // 局部变量
    return funcA(num);
}

在上面这段代码中,全局变量global和自己实现的函数funcB是这个模块能够提供给外部调用的,与之相对的,这个模块中也调用了外部的变量external和外部函数funcA

链接器必须要知道的就是这两个信息:

  1. 该文件可以向外部提供什么符号
  2. 该文件引用了外部什么符号

那么,到底是谁告诉链接器这些信息的呢?

答案是编译器通过符号表告诉链接器的。

事实上,在编译器的编译过程中,如果遇到外部定义的全局变量或者函数时,只要能找到相应的声明即可,编译器并不关心这个变量是不是真的有定义,

也就是说,在上面这个例子中,

extern int external;  // 引用的外部变量

即使在外部文件中根本没有external 的定义,编译也是通过的。

虽然编译器不关心这个变量是不是真的有定义,但是它会把每一个源文件中可以对外提供哪些符号,以及该文件引用了哪些符号都记录下来,记录在一张叫符号表的表中。

所以,整个符号表只表达两件事:

1. 可以供外部使用的符号

2. 自己引用了哪些符号

编译器生成符号表后,把符号表放在了目标文件中,后面就交给链接器处理。

符号决议就是要确保每个目标文件的外部符号能够在符号表中找到唯一定义。

那么我们回到刚刚引用的外部变量但是没有定义的代码:

int main() {
    extern int external;  // 引用的外部变量但是没有定义
    printf("%d", external);
    return 0;
}

这个时候,编译部分通过,但是在链接阶段就会报错误:

计算机底层1 如何从编程语言一步步到可执行程序

这就是链接器在告诉我们没有找到变量external的定义。

3.2 静态库 Static Library

通常,一个比较大的项目,需要构建独立、可移植的应用程序的情况下,比如基建团队的一些工具模块,业务团队需要使用这些工具模块来实现业务逻辑,那么我们就可以把这些项目单独打包成静态库

静态库在Windows 下是以.lib 为后缀的文件,在Linux 下是以.a 为后缀的文件

利用静态库,我们可以把一堆源文件提前单独编译链接成静态库,所以在生成可执行文件时,只需要编译自己的代码,并且在链接的过程中把需要的静态库复制到可执行文件中,这样能够加快项目编译的速度。

计算机底层1 如何从编程语言一步步到可执行程序

但是,静态库会将用到的库直接复制到可执行文件中,但是如果有些几乎所有的程序都会用到的标准库,比如C标准库,那么如果采用静态链接,所有的可执行文件中都会有一模一样的一份代码,假如一个静态库为2MB,那么500个可执行文件就有将近1GB的数据是重复的,那么这将会是对硬盘和内存极大的浪费

要解决这个问题,就要用到动态库。

3.3 动态库 Dynamic Library

动态库,也叫共享库(Shared Library)

动态库在Windows下就是DLL 文件,以.dll为后缀,在Linux 下,是同时以lib为前缀,以.so 为后缀的文件。

前面提到,静态库是把用到的库直接复制到可执行文件中,但是当使用动态库时,可执行文件中仅仅需要包含关于所引用的动态库的一些必要的信息,如:所引用动态库的名字符号表重定位信息等。这一点和静态库相比,大大减小了可执行文件的大小

计算机底层1 如何从编程语言一步步到可执行程序

计算机底层1 如何从编程语言一步步到可执行程序

这些信息会在动态链接的时候会被用到。

动态链接就是用于获取到动态库的完整内容的过程。动态链接有两种形式:

1. 在程序加载的时候进行

这里的加载指的是把可执行文件从磁盘搬运到内存,因为程序最终都是在内存中运行。系统中有一个特定负责程序加载的程序:加载器。加载器在加载可执行文件后能够检测到该可执行文件是否依赖动态库,如果依赖,那么加载器就会启动另外一个程序:动态链接器来完成链接工作。

iOS 开发中的动态链接器dyld

在iOS 开发中,动态链接通常是在程序加载的时候发生的,以便在应用程序运行期间访问所需的库和功能。这有助于提高应用程序的性能和减小其二进制文件的大小。 而在iOS 开发中,动态链接器是一个系统组件,通常被称为“dyld”(Dynamic Link Editor), 在启动的时候会加入到进程的地址空间中,主要有两个版本。

  • dyld 2

iOS 12 前,会将UIKit 等系统库合成一个大文件,提高加载性能

  • dyld 3

iOS 13 引入,启动闭包,闭包里面包含了所需要的缓存信息,能够提高启动速度 dyld 会装载APP 的Mach-O 文件,也就是可执行文件,同时会递归加载所有的动态库,当把可执行文件和动态库都装载完毕后,会通知Runtime 进行下一步处理。

2. 在程序运行期间进行动态链接

运行时指的是从程序开始被CPU 执行到程序执行完毕的这段时间。这种情况下,可执行文件在启动运行之前都不知道依赖哪些动态库,这样程序员可以在编写程序时使用特定的API 来根据需求动态加载指定的动态库。

动态库的优缺点:

前面说到,如果大量使用相同的静态库,对磁盘和内存都是极大的浪费,那么动态库就很好解决了这个问题,如果使用的是动态库,那么无论有多少程序依赖它,磁盘中都只需要保存一份,让所有的程序进程共享这一份代码,因此,极大节省了内存和磁盘资源。

而且,因为内存中只有一份动态库的代码,所以当需要修改动态库的代码需要修改时,只需要修改后重新编译动态库即可,而不需要重新编译依赖该库的程序。

当然,动态库也是有缺点的。由于动态库在程序加载或者运行时才进行链接,同静态链接相比,性能上要稍微弱一些。因为动态库在内存中只有一份,又可以被其他程序进程共享,所以动态库的代码不能依赖任何绝对地址,是地址无关(Position-Idpendent Code, PIC)的。地址无关就是无论在哪个进程中调用该库(每个进程中调用库的指令指向的地址是不同的),都能找到该库正确的运行时地址,这种设计比直接调用,会多一点“间接寻址”,会带来一点性能上的损失,但是相比动态库带来的好处,这点性能损失是值得的。

3.4 重定位

刚刚在介绍动态库的时候,我们提到,当使用动态库时,可执行文件中仅仅需要包含关于所引用的动态库的一些必要的信息,如:所引用动态库的名字,符号表,重定位信息等。

这里的“重定位”是什么?

我们知道,变量或者函数都是有内存地址的,在机器指令中,执行指令全部都是对内存地址的使用,比如调用一段函数编译后生成的指令可能是这样:

call 0x4004d6

这条指令的意思是跳转到内存地址 0x4004d6处开始执行。但是,编译器在生成这条指令时,根本不知道这条这个函数最终会被放在哪里,也就是编译器不能确定call 指令后面的地址是什么,因此,它只能简单将其写为0,比如:

call 0x00

但是,链接器在生成可执行文件的时候,又必须知道这台条指令的地址在哪里,所以,链接器要怎么能把这条0x00的地址修正为正确的地址0x4004d6呢?

原来,编译器在遇到不知道最终运行时的内存地址的变量时,就会把它记录到目标文件中,与指令相关的放到.relo.text中,与数据相关的,就放到.relo.data中,当然,原本源文件中的指令相关的放入的是代码区,数据相关的放入数据区,这样,我们的目标文件就是下面这个结构了:

计算机底层1 如何从编程语言一步步到可执行程序

接下来,就是我们刚刚讲的符号决议的阶段了,链接器在完成符号决议后就能确定不存在链接错误,下一步就是把所有的目标文件合并,接下来,链接器逐个扫描目标文件中的.relo.text段和.relo.data段,发现原来的0x00,修正为0x4004d6。

这个修正符号内存地址的过程就是重定位。

但是,为什么链接器可以确定变量或者指令在程序运行起来后的内存地址呢?明明变量或者指令的地址只有当程序运行起来才知道啊?

这里就要说到当今操作系统中一项绝妙的设计:虚拟内存

4 虚拟内存

我们都知道内存的布局应该是这样的:

计算机底层1 如何从编程语言一步步到可执行程序

可以看到,每个程序的代码区的起始位置都是从0x400000 开始的,那么如果有两个程序A 和B 都在运行,CPU 在0x400000 获取到的指令到底是哪个程序的呢?神奇的是,如果是CPU 在执行程序A时,在0x400000 获取到的指令就属于程序A,在执行程序B时,在0x400000 获取到的指令就属于程序B,但是两次获取到的数据是不一样的,实现这一效果的就是虚拟内存技术

虚拟内存就是物理上不存在的内存,虚拟内存让每个程序都有这样一种错觉:自己独占内存。如果是32位系统,每个程序进程都认为自己独占2^32B也就是4GB 内存,不管真实的物理内存有多大。

上面的内存布局图也只是一种假象,在真实的物理内存中是不存在的,也就是说,我们以为数据存储是连续的,但是实际上,数据可能散落在磁盘的各个角落。上面的程序布局是方便于程序员编写代码,也是链接器能以在生成可执行程序的阶段就能确定运行地址的原因,链接器基于这种内存布局,可以确定符号的运行时地址,尽管这个地址是假的,但是链接器根本不关心这些指令或者数据在程序运行起来后真正放到物理内存的哪个地址上

计算机底层1 如何从编程语言一步步到可执行程序

当CPU 执行程序时,再把可执行程序的代码区加载到物理内存中。在真实的操作系统中,会增加一个记录虚拟内存和物理内存之间映射关系的页表。每个进程中都有单独属于自己的页表。CPU 通过查询页表,能够知道真实的物理内存地址。

计算机底层1 如何从编程语言一步步到可执行程序

其实,虚拟内存就是物理内存的一种抽象。又是抽象这个重要的概念。程序员在编写程序时,可以假设自己程序独占内存,尽管真实的物理内存大小不一。

5 总结

从二进制语言到汇编到高级编程语言,通过编译器,把源文件转化为目标文件,再由链接器通过符号决议和重定向把多个目标文件和静态库或者动态库集合成可执行程序,这都离不开抽象,物理内存被抽象成虚拟内存,程序被抽象成进程,I/O 设备被抽象成文件......抽象使得程序员不需要关心底层细节,开发的效率变得越来越高,编程的门槛也越来越低,但是想要了解问题本身,就一定要了解底层。

6 下一篇文章

7 参考资料