前段时间更新完设计方式,去看了一些底层常识和算法相关,在学习核算机底层常识时,陆小风的 《核算机底层的隐秘》 这本书深入浅出的讲解了现代核算机体系中非常重要的编译器作业原理,操作体系,内存,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. 就关闭开关

能够看出,人类假如想进步编程功率,下降编程门槛,就有必要要再创造比汇编愈加笼统的言语,最好是能够挨近人类言语逻辑的编程言语,因而,高档言语诞生了,现在咱们学习的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 参考资料

  • 陆小风. 核算机底层的隐秘. 电子工业出版社, 2023.
  • 核算机底层的隐秘 gitbook