gitbook/编译原理之美/docs/165078.md
2022-09-03 22:05:03 +08:00

168 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 35 | 案例总结与热点问题答疑:后端部分真的比前端部分难吗?
本节课,我会继续剖析一些,你们提出的,有代表性的问题(以后端问题为主),主要包括以下几个方面:
* 后端技术部分真的比前端技术部分难吗?
* 怎样更好地理解栈和栈桢(有几个同学提出的问题很好,有必要在这里探究一下)?这样,你对栈桢的理解会更加扎实。
* 有关数据流分析框架。数据流分析是后端技术的几个重点之一,需要再细化一下。
* 关于Java的两个知识点泛型和反射。我会从编译技术的角度讲一讲。
接下来,进入第一个问题:后端技术真的难吗?正确的学习路径是什么?
## 后端技术真的难吗?该怎么学?
有同学觉得,一进到后端,难度马上加大了,你是不是也有这样的感觉?我承认,前端部分和后端部分确实不太相同。
**前端部分**偏纯逻辑,你只要把算法琢磨透就行了。**而后端部分,**开始用到计算机组成原理的知识要考虑CPU、寄存器、内存和指令集甚至还要深入到CPU内部去看它的流水线结构以便理解指令排序。当然我们还要说清楚与操作系统的关系操作系统是如何加载代码并运行的如何帮你管理内存等等。另外还涉及ABI和调用约定NP完全的算法等等。**看上去复杂了很多。**
虽然比较复杂,但我认为,这并不意味着后端更难,只意味着知识点更多。可这些知识,往往你熟悉了就不难了。
比如,**@风**同学见到了汇编代码说总算遇到了自己熟悉的内容了不用天天看Java代码了。
我觉得,从算法的角度出发,后端部分的算法,至少没比前端的语法分析算法难。而且有些知识点,别的课程里应该讲过,如果你从以下三个方面多多积累,会更容易掌握后端内容:
* 计算机组成原理CPU的运行原理、汇编指令等等。
* 数据结构和算法特别是与树和图有关的算法如果你之前了解过与图有关的算法了解旅行商问题那么会发现指令选择等算法似曾相识。自然会理解我提到某些算法是NP完全的是什么意思。
* 操作系统:大部分情况下,程序是在操作系统中运行的,所以,要搞清楚我们编译的程序是如何跟操作系统互动的。
**@沉淀的梦想**就对这些内容,发表过感触:感觉学编译原理,真的能够帮助我们贯通整个计算机科学,涉及到的东西好多。
确实如他所说,那么我也希望《编译原理之美》这门课,能促使你去学习另外几门基础课,把基础夯实。
**后端技术的另一个特点,**是它比较偏工程性,不像前端部分有很强的理论性,对于每个问题有清晰的答案。而后端技术部分,往往对同一个问题有多种解决思路和算法,不一定有统一的答案,甚至算法和术语的名称都不统一。
后端技术的工程性特点还体现在它会涉及很多技术细节这些细节信息往往在教科书上是找不到的必须去查厂商比如Intel的手册有时要到社区里问有时要看论文甚至有时候要看源代码。
总的来说,如何学好后端,**我的建议主要有三个方面:**
* 学习关联的基础课程,比如《数据结构与算法》,互相印证;
* 理解编译原理工程性的特点,接受术语、算法等信息的不一致,并从多渠道获得前沿信息,比如源代码、厂商的手册等等。
* 注重实操亲自动手。比如你在学优化算法时即使没时间写算法也要尽可能用LLVM的算法做做实验。
按照上面三条建议,你应该可以充分掌握后端技术了。当然,如果你只是想做一个概要的了解,那么阅读文稿也会有不错的收获,因为我已经把主线梳理出来了,能避免你摸不着头脑,不知如何入手。
接下来,我们进入第二个问题:再次审视一下栈桢。
## 再次认识栈桢
**@刘强**同学问:操作系统在栈的管理中到底起不起作用?
这是操作系统方面的知识点,但可以跟编译技术中栈的管理联系在一起看。
我们应用程序能够访问很大的地址空间,但操作系统不会慷慨地,一下子分配很多真实的物理内存。操作系统会把内存分成很多页,一页一页地按需分配给应用程序。**那么什么时候分配呢?**
当应用访问自己内存空间中的一个地址但实际上没有对应的物理内存时就会导致CPU产生一个PageFault在Intel手册中可以查到这是一种异常Exception
对异常的处理跟中断的处理很相似会调用注册好的一个操作系统的例程在内核态运行来处理这个异常。这时候操作系统就会实际分配物理内存。之后回到用户态继续执行你的程序比如一个push指令等等。整个过程对应用程序是透明的其实背后有CPU和操作系统的参与。
**@风**提出了关于栈桢的第二个问题看到汇编代码里管理栈桢的时候用了rbp和rsp两个寄存器。是不是有点儿浪费一个寄存器就够了啊。
确实是这样,用这种写法是习惯形成的,其实可以省略。而我在[34讲](https://time.geekbang.org/column/article/164017)里用到的那个foo函数根本没有使用栈仅仅用寄存器就完成了工作。这时可以把下面三行指令全部省掉
```
pushq %rbp
movq %rsp, %rbp
popq %rbp
```
从而让产生的机器码少5个字节。最重要的是还省掉两次内存读写操作相比对寄存器的操作对内存的操作是很费时间的
实际上如果你用GCC编译的话可以使用-fomit-frame-pointer参数来优化会产生同样的效果也就是不再使用rbp。在访问栈中的地址时会采用4(%rsp)、8(%rsp)的方式在rsp的基础上加某个值来访问内存。
**@沉淀的梦想**提出了第三个问题栈顶也就是rsp的值为什么要16字节对齐
这其实是一个调用约定。是在GCC发展的过程中形成的一个事实上的标准。不过它也有一些好处比如内存对齐后某些指令读取数据的速度会更快这会让你产生一个清晰的印象每次用到栈桢至少要占16个字节也就是4个32位的整数的空间。那么如果把一些尾递归转化为循环来执行确实会降低系统的开销包括内存开销和保存前一个桢的bsp、返回地址、寄存器的运行时间开销。
而**@不的**问了第四个问题: 为什么要设计成区分调用者、被调用者保护的寄存器,统一由被调用者或者调用者保护,有什么问题么?
这个问题是关于保护寄存器的,我没有仔细去研究它的根源。**不过我想,这种策略是最经济的。**
如果全部都是调用者保护,那么你调用的对象不会破坏你的寄存器的话,你也要保护起来,那就增加了成本;如果全部都是被调用者保护,也是一样的逻辑。如果调用者用了很少几个寄存器,被调用者却要保护很多,也不划算。
所以最优的方法,其实是比较中庸主义的,两边各负责保护一部分,不过,我觉得这可以用概率的方法做比较严谨的证明。
**关于栈桢,我最后再补充一点。**有的教材用活动记录这个术语,有的教材叫做栈桢。你要知道这两个概念的联系和区别。活动记录是比较抽象的概念,它可以表现为多种实际的实现方式。在我们的课程中,栈桢加上函数调用中所使用的寄存器,就相当于一个活动记录。
讲完栈桢之后,再来说说与数据流分析框架有关的问题。
## 细化数据流分析框架
数据流分析本身,理解起来并不难,就算不引入半格这个数学工具,你也完全可以理解。
对于数据流分析方法不同的文献也有不同的描述有的说是3个要素有的说是4个要素。而我在文稿里说的是5个要素方向D、值V、转换函数F、相遇运算meet operation, Λ和初始值I。你只要把这几个问题弄清楚就可以了。
引入半格理论,主要是进一步规范相遇运算,这也是近些年研究界的一个倾向。用数学做形式化地描述虽然简洁清晰,但会不小心提升学习门槛。如果你只是为了写算法,完全可以不理半格理论,但如果为了方便看这方面算法的论文,了解半格理论会更好。
**首先,半格是一种偏序集。**偏序集里,某些元素是可以比较大小的。但怎么比较大小呢?其实,有时是人为定的,比如,{a, b}和{a, b, c}的大小,就是人为定的。
那么既然能比较大小就有上界Upper Bound和下界Lower Bound的说法。给定偏序集P的一个子集A如果A中的每个元素a都小于等于一个值xx属于P那么x就是A的一个上界。反过来你也知道什么是下界。
**半格是偏序集中,一种特殊的类型,**它要求偏序集中每个非空有限的子集要么有最小上界并半格join-semilattice要么有最大下界交半格meet-semilattice
其实如果你把一个偏序集排序的含义反过来它就会从交半格转换成并半格或者并半格转换成交半格。我们还定义了两个特殊值Top、Bottom。在不同的文献里Top和Bottom有时刚好是反着的那是因为排序的方向是反着的。
因为交半格和并半格是可以相互转化的,所以有的研究者采用的框架,就只用交半格。交半格中,集合{x, y}的最大下界就记做x Λ y。在做活跃性分析的时候我们就规定{a, b} > {a, b, c}就行了,这样就是个交半格。如果按照这个规矩,我在[28讲](https://time.geekbang.org/column/article/156878)中举的那个常数传播的例子,应该把大小反过来,也做成个交半格。文稿中的写法,实际是个并半格,不过也不影响写算法。
这样讲,你更容易理解了吧?现在你再看到不同文献里,关于数据流分析中的偏序集、半格的时候,应该可以明白是怎么回事了。
最后我再讲讲关于Java的两个知识点泛型和反射。这也是一些同学关注的问题。
## Java的两个知识点泛型和反射
泛型机制大大方便了我们编写某些程序不用一次次做强制类型转换和检查了。比如我们要用一个String类型的List就声明为
```
List<String> myList
```
这样你从myList中访问一个元素获取的自然就是一个String对象而不是基类Object对象。
**而增加泛型这个机制其实很简单。**它只是在编译期增加了类型检查的机制运行期没有任何改变。List和List运行的字节码都是完全相同的。
那么反射机制呢?它使我们能够在运行期,通过字符串形式的类名和方法名,来创建类,并调用方法。这其实绕过了编译期的检查机制,而是在运行期操纵对象:
```
//获取Class
Class<?> clazz = Class.forName("MyClass");
//动态创建实例
Object obj = clazz.newInstance();
//获取add方法的引用
Method method = clazz.getMethod("add",int.class,int.class);
//调用add方法
Object result = method.invoke(obj,1,4);
```
这样能带来很多灵活性方便你写一些框架或者写IDE。
从编译技术的角度看,实现反射很容易。因为在[32讲](https://time.geekbang.org/column/article/161944)中你已经了解了字节码的结构。当时我比较侧重讲指令其实你还会看到它前面的完整的符号表也就是记录了类名、方法名等信息。正因为有这些信息所以反编译工具能够从字节码重新生成Java的源文件。
所以虽然在运行时Java类已经编译成字节码了但我们仍然可以列出它所有的方法可以实例化它可以执行它的方法因为可以查到方法的入口地址。**所以你看,**一旦你掌握了底层机制,理解上层的一些特性就很容易了。
## 课程小结
编译器的后端技术部分也告一段落了。我们用16讲的篇幅涵盖了运行时机制、汇编语言基础知识、中间代码、优化算法、目标代码生成、垃圾收集、即时编译等知识点还针对内存计算和Java的字节生码成做了两个练习中间还一直穿插介绍LLVM这个工具。我之前就提到实现一个编译器后端的工作量会很大现在你应该有所体会。
在这里,我也想强调,后端技术的工程性比较强,每本书所采用的术语和算法等信息,都不尽相同。在我们的课程中,我给你梳理了一条,比较清晰的脉络,你可以沿着这条脉络,逐步深化,不断获得自己的感悟,早日修炼成后端技术的高手!
在答疑篇的最后,我总结了一些案例,供你参考。
## 案例总结
**第一批示例程序,**与汇编代码有关包括手写的汇编代码以及从playscript生成汇编代码的程序。这部分内容主要是打破你对汇编代码的畏惧心知道它虽然细节很多但并不难。在讲解后端技术部分时我总是在提汇编代码在34讲我甚至写了一个黑客级的小程序直接操作机器码。我希望经历了这些过程之后你能对汇编代码亲切起来产生可以掌握它的信心。
**第二批示例程序,**是基于LLVM工具生成IR的示例代码。掌握LLVM的IR熟悉调用LLVM的API编程能让你在写完前端以后以最短的时间拥有所有后端的功能。通过LLVM你也会更加具体的体会代码优化等功能。
**第三批示例程序,**是内存计算和字节码生成,这两个应用题目。通过这两个应用题目,你会体会到两点:
* 编译器后端技术对于从事一些基础软件的开发很有用;
* 虽然课程没有过多讲解Java技术只通过一个应用篇去使用Java的字节码但你会发现我们对后端技术的基本知识比如对中间代码的理解都可以马上应用到Java语言上得到举一反三的感觉。
## 一课一思
如果你在工作中真的接到了一个任务,要实现某编译器的后端,你觉得学过本课程以后,你敢接手这个任务吗?还有哪些地方是需要你再去补足的?你完成这个任务比较可靠的路径是什么?欢迎在留言区分享你的观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。