斯坦福-CS143-编译原理中文笔记-三-

128 阅读1小时+

斯坦福 CS143 编译原理中文笔记(三)

P58:p58 11-03-_Activation_Recor - 加加zero - BV1Mb42177J7

上期视频中,我们讨论了激活,但从未说过需要保留什么信息,这是本视频的主题。

激活记录是管理过程激活所需的所有信息,这通常也称为帧,与激活记录完全相同,这只是同一事物的两个名称,现在,关于过程激活的一个有趣事实是它们包含的信息比你预期的要多,特别是,当过程f调用过程g时。

g的激活记录实际上不仅包含关于g的信息,而且经常也包含调用函数f的信息,通常,过程的激活记录将包含关于该过程的信息,以及关于调用它的过程的信息。

到目前为止,我们还没有说过为什么要保留关于激活的信息,原因是每个过程都有一个与之相关的状态,激活,需要正确执行该过程,我们必须在某处跟踪它,这就是激活记录的作用,它将用于存储正确执行过程所需的信息。

所以让我们更详细地看一下,考虑过程f调用过程g的情况,概念上,当f调用g时,f被暂停,f将在g运行时停止执行,所以g将使用处理器和机器的所有资源,但当g完成时,我们希望再次执行f,f将恢复,所以在中间。

当g运行时,我们必须将过程f的状态,激活保存在某个地方,以便我们正确地恢复它,这又是激活记录的作用,因此,g的激活记录将必须包含信息,这将帮助我们完成g的执行,所以会有一些关于g的信息。

我们只需要运行g,但g的激活记录还必须存储,我们需要能够恢复过程f执行的任何东西。

所以让我们通过一个例子,这是我们在上一期视频中看到的一个程序,这是过程f的具体激活记录设计,我们将有一个位置用于f的结果,这将持有f执行完成后返回的值,这里有一个位置用于f的参数,因此f只接受一个参数。

所以只需要一个字来存储函数的参数,将有一个控制链接,指向前一个或调用者的激活,我们还将有一个用于返回地址的插槽,所以是内存中的地址,或我们应在f执行完成后跳转到的指令的地址。

所以现在让我们手动执行这个程序,并计算出栈上的激活记录将是什么样子,所以当程序首次被调用时,它将调用main,将有一个main的激活记录,好的,但我们不会担心那个,我们将专注于f。

所以有一些关于main的东西,但我们不会谈论那个,然后main将调用f好吧,所以当main调用f时,一个激活记录将被推入栈中,你将有四个插槽或四个字段,用于值,那么什么将进入那些呢,第一个插槽用于结果。

嗯,它刚刚开始运行,如果它刚刚开始执行,所以目前那里没有什么可填写的,将在f返回时填充,当f返回时,第二个位置将持有f的参数,所以那将是数字三,第三个插槽将持有控制,所以那将指向main的激活。

第四个位置将持有返回地址,这实际上并不完全简单,因为f在多个地方被调用,所以如果你看程序,main中有对f的调用,f内部也有对f的调用,所以根据函数被调用的位置,在该函数完成后,我们希望返回不同的地址。

在main的情况下,当这个调用f完成时,我们希望返回,调用f后的任何指令,这将是程序执行的结束,因为它是main的退出点,因为在f内部,它将是条件语句的结论,所以这里的双星号将是条件剩下的部分。

然后从f返回,所以根据f被调用的位置,好的,所以在这种情况下,f从main被调用,因此我们将单星地址,I放入激活记录的该位置,好的,然后f被第二次调用,f的主体执行,参数3不是0。

因此我们最终会再次调用f,但这意味着另一个激活记录将被推入堆栈,这也会有四个插槽,它是f的激活记录,如果我应该标记这些,所以这是f的激活,这也是f的激活,这个里面有什么呢?再次,结果,呃。

最初里面没有任何东西,在这种情况下,参数将是2,控制链接在这种情况下将指向f的先前激活,在这种情况下,返回地址将是双星,在两次调用f之后,这就是堆栈将看起来的样子,具有这种特定的激活记录设计。

所以这是同一张图片,只是画得更整洁,还有一件事我想指出,即这个激活记录堆栈,让我来区分激活记录,这里不是像你可能在数据结构课上学到的那种抽象堆栈,如果你上过这样的课,所以堆栈上有明确的激活记录。

我们如此对待它们,运行时系统也将如此对待它们,但这也像一个巨大的数组,所有这些数据都只是连续地排列在内存中,这些都是连续的地址,这里有一个激活记录,紧接着在先前激活记录的下一个地址立即之后,编译器。

编译器作者将经常玩技巧来利用,这些激活在内存中相邻的事实,我们将在片刻后看到这样一个潜在技巧,总结一下这个例子的亮点。

所以到目前为止我想重复,main并不有趣,所以它没有参数或局部变量,并且它的结果从未被使用,因此,虽然它确实有一个激活记录,我们并没有关注那个,我们并不关心,激活记录里面有什么,与什么内容相关。

我们只关注f的激活记录,我只是要确认一下,这很清楚,我在示例中使用的星号和双星号,这些都是内存地址,这些都是实际的内存地址,它们指向代码的地址,这些是调用f后指令的地址,这是f将返回的地方。

最后我想强调这真的只是许多可能激活记录设计中的一种,你可以为f设计不同的激活记录,其中包含不同的信息,这也会很好用,取决于其余代码生成器和运行系统的结构,特别是许多编译器不使用控制链接。

因为它们不需要明确的链接就能找到调用者,调用程序的激活记录,事实上,在你的项目里,酷编译器你不会使用控制链接,大多数激活记录不会在激活记录中包含返回值,因为将其返回在寄存器中会更高效方便,好吧。

所以这只是一种可能的设计,它将会,你可以设计其他激活记录也能正常工作,关于激活记录的重要一点是它只需要包含足够的信息,以使生成的代码能够正确执行,呃,被调用的过程,以及恢复调用程序的执行。

到目前为止我们只看了这个激活记录的程序调用,我们还没有谈论激活返回时会发生什么,所以让我们考虑在我们的例子中会发生什么,在第二次调用f之后,就是这个,这个下面的激活返回,所以会发生什么。

是我们将使调用者,成为当前激活,这实际上将成为栈的顶部,所以我在这里有一个大而粗的绿色箭头,来表示这现在是当前激活,这个上面,好的,所以这是调用,这是谁是调用者,它现在将继续执行,有趣的是要注意。

就像我之前说的,这并不像数据结构课程中的栈那么抽象,这只是一种抽象,虽然已恢复为当前过程,此数据在此处,这个,运行的激活仍在内存中,实际上我们可以查看,我设置这个例子,实际上我们需要。

因为调用过程的结果存储在这里,好的,所以当f再次执行时,它需要查找该结果,以了解被调用过程的结果。

将返回值放在帧首位的优点是,调用者可以从自己的帧固定偏移处找到它。

让我们后退并看看,当第二次调用f返回时,第一个调用已恢复执行,这个调用,该调用的代码将知道,此激活记录的大小为4,此激活记录中有4个单词,因此它可以找到被调用过程的结果,在4加1位置和5个单词后。

特别是他们可以找到这个单词,即使这个已从栈中弹出,我之前说过,该数据仍在,至少直到另一个过程被调用,因此,如果我们立即读取函数调用结果,我们可以获取该结果,然后在调用过程继续执行中使用它。

再次强调,我知道我已经说过几次,但组织方式绝对没有魔力,我们可以重新排列帧中元素的顺序,我们可以不同地分配调用者和同事的责任,实际上,唯一的衡量标准是,如果一个组织比另一个更好。

如果它导致更快的代码或更简单的代码生成器,我之前也提到过,但在生产编译器中也是一个重要点,我们将尽可能多地将在帧内容放入寄存器中,特别是,将方法结果和方法参数传递到寄存器中。

因为这些被频繁访问,最后。

总结我们对激活和激活记录的讨论,问题是编译器必须在编译时确定,好的,这发生在静态布局的激活记录,并且还需要生成正确访问该激活记录位置的代码,这意味着激活记录布局和代码生成器必须一起设计,好的。

所以你不能只设计你的代码生成器,然后后来再决定你的激活记录布局将会是什么,反之亦然,这两件事需要一起设计,因为它们相互依赖。

P59:p59 11-04-_Globals_and_Heap - 加加zero - BV1Mb42177J7

本视频中,我们将继续讨论运行时组织,通过讨论编译器如何处理全局变量和堆数据结构。

让我们从讨论全局变量开始,全局变量的基本属性是所有引用都指向同一个对象,这就是全球的含义,因此我们不能将全局变量存储在激活记录中,因为激活记录,当然,在激活完成时会释放,那么这将分配我们的全局变量。

因此全局变量的实现方式是所有全局变量都被分配一个固定的地址一次,这些具有固定地址的变量被称为静态分配,因为它们本质上在编译时分配,因此编译器决定它们将生活在何处,然后它们将在程序的所有执行中生活在那里。

取决于语言,可能还有其他静态分配的值,我们实际上稍后会看到一些,但它们的行为与全局变量完全相同。

因此添加全局变量稍微改变了我们的运行时组织图,我们之前有代码,然后紧接着是所有的静态数据,所以这些都是全局变量和其他静态对象,在程序执行期间具有固定地址的东西,然后栈在后面。

因此栈将从静态数据区的末尾开始,并朝着程序分配内存的末尾增长。

现在转向堆,任何比创建它的过程生存时间更长的值也不能存储在激活记录中,让我们看看这个例子,所以这里我们有一个foo过程,让我们看看foo的激活记录或帧,假设foo分配了一个bar对象。

并且我们打算将该对象存储在foo的激活中,现在当这个方法返回时,当然,激活记录将被释放,因此bar对象也会消失,但这在这里不起作用,因为请注意动态分配的对象,我们在foo执行期间分配的对象。

也是foo的结果,因此这必须,这必须对foo的调用者和foo退出后可用,这意味着这个bar对象,嗯,以及所有动态分配的数据都必须存储在激活记录之外的地方,具有动态分配数据的语言通常使用堆来实现这一点。

此时,语言实现需处理不同数据,首先是代码,许多语言,非多数,嗯,许多语言代码固定,只读,编译器创建程序执行所需所有代码,可一次分配,许多语言非如此,运行时可动态创建代码,这些如全局变量,通常为固定大小。

但可能可读写,而非代码,我通常不想能写,栈用于存储每个当前活跃过程的激活记录,激活记录通常为固定大小,每种过程的每个激活记录都有固定大小,将包含所有局部信息,执行特定激活所需的局部变量和临时变量。

堆用于其他所有内容,那么,堆就是存储所有不,属于这些其他类别的数据,如果你熟悉C语言,堆和C由程序员使用,malloc和free管理,Java中有new用于动态,分配数据,然后垃圾回收。

实际上负责回收堆中,这里有点问题,因为堆和栈都在增长,所以我们必须确保它们不会相互增长,不要踩到对方的数据,有一个很好的简单解决方案,从内存的两端开始堆栈,让他们向对方增长。

让我们再次看看运行时组织图并回顾一下,首先我们有代码,然后我们有静态数据,然后我们有堆,它向高地址增长,注意堆不一定会随着过程返回而增长,堆也会缩小,因此,程序运行时,堆会变大变小。

取决于当前运行的过程数,现在堆将从内存的另一端开始向低地址增长,因此,当我们分配对象时将从内存的末尾分配,或从程序分配的内存末尾向上堆栈顶部,如果这两个指针相等,哪两个指针?我们有栈分配指针。

下一个栈帧将分配在哪里,我们有堆分配指针,下一个对象将分配在哪里,如果有另一个动态分配的对象,只要这两个指针不交叉,只要它们永远不会相等,程序就有内存添加另一个栈帧或另一个动态分配的对象。

程序可以继续运行,如果这些指针变得相等,那么程序实际上已经用完内存,在这一点上,运行时系统将终止程序,或尝试从操作系统获取更多内存,或采取其他措施来处理没有更多内存的事实,没有更多的内存。

但只要这两个指针不交叉,注意这种设计允许堆和栈以最适合程序的方式共享这个,数据区域,因此,这种相同的设计,无需任何更改,将适用于需要大量堆和少量栈的程序,以及需要大量栈和少量堆的程序。

以及堆和栈大致平衡的东西,只要它们不超过分配给程序的总内存。

P6:p06 02-03-_Cool_Example_III - 加加zero - BV1Mb42177J7

再次问候,在这段视频中,我们将结束对酷的概述,以一个编写酷程序的更多示例结束。

对于我们的最后一个示例,让我们看看一个实际上操作有趣数据结构的程序,因此我们将从这里开始打开一个文件,让我们这次将程序命名为list。cl。

和往常一样,我将开始编写我们的main例程和方法,再一次,嗯,让我们,嗯,让我们让它继承自io,这样我们可以在这里做io,例程,让我们从非常简单的事情开始,一如既往。

让我们只是打印出hello world,但以一种不太寻常的方式,让我们最终编写一个列表,列表抽象,让我们首先手动构建一个列表,或者至少手动构建列表的元素,然后,我们将实际构建列表抽象并将它们放入列表中。

所以嗯,让我们有一些字符串,所以我们将有我们的字符串,Hello,这还将说明如何同时进行多个let绑定,我应该说同时,如何在单个let表达式中进行多个let绑定,你只需列出它们。

并注意这使用逗号作为分隔符,而不是分号作为终止符,因此,此let绑定将定义三个名称,嗯hello world和new line,所有这些都是字符串,然后,嗯,我们将要,嗯,现在将这些打印到屏幕上,因此。

我们将需要能够执行out string,并且由于main继承自self,我们可以在没有对象的情况下这样做,因为再次只是将调度到self对象,并且我们希望以正确的顺序将这些字符串连接在一起。

因此我们将做hello,点,这是hello字符串,可连接到世界和字符串,因此可连接到新行,应该完成工作,再谈一点关于这个,让这些let绑定,逗号是分隔符,意味着不在列表末尾,仅分隔列表项,不是终止符。

现在关闭主过程,关闭类定义,保存,现在看看是否编译。

哦,第一次尝试惊人,在print中运行,Hello world,呃,如预期,现在,呃,而不是分别引入3个字符串,然后连接它们,写一个抽象,可构建字符串列表,该抽象将有一个函数,执行连接,执行连接。

将有一个名为list的类,每个列表需要,我认为有两个组件,首先将有一个列表项,它将是字符串,然后当你有一个指针,指向列表的尾部,指向其余部分,所以有一个next字段指向,或另一个列表,另一个字符串列表。

现在需要一些方法,为了使用这个列表,需要以某种方式初始化列表,初始化函数将接受一个项和其余列表,列表的下一部分,它将做什么?需要设置对象的字段,这必须作为一系列赋值语句完成,因此需要一个语句块。

并将item设置为i参数,并将item设置为i参数,设置下一个属性为n参数,现在,嗯,实际上我们想要,嗯,这个初始化对象,这里这个方法返回对象本身,嗯,因此,这样便于连接init的调用。

所以我们将让它返回self,它将返回self对象,这是语句的结束,块,然后这是方法的结束,我上面犯了个错误,我们需要声明返回类型,嗯,Vignette及其将返回的内容,当然它返回一个列表类型的对象。

我们需要在那里声明一个列表声明,好的,这样就解决了knit问题,现在我们可以使用这个来构建,所以我在这里构建一个列表,嗯,我们应该做什么,嗯,让我们实际上有一个新的变量叫做list,嗯。

这将在这里引入在这个let中,嗯,这一系列的let绑定,让我们只构建一个由这些三个对象组成的列表,所以我们将说我们将有一个新的列表,然后我们将初始化它以包含字符串,Hello和。

列表的其余部分应该是什么?那应该是一个另一个初始化为包含字符串world的列表,那列表里面应该是什么?好吧,必须有一个另一个新的列表对象,我们将初始化它以包含新行,现在这里我们应该放什么?

实际上这里有点问题,不是吗?我们需要在这里放一个列表对象,但我们不想分配一个新的列表对象,我们希望那实际上相当于一个空指针,在Cool中并没有这样的名称,实际上你不能写下空指针的名称。

它叫做void在Cool中,没有,没有特殊的符号表示那个,因此需创建未初始化变量,实际上为空的无初始化列表变量,将是一个空指针,我们称之为nil,为带类型列表且无初始化器的nil,将指向无或,呃。

空指针,然后可用nil终止列表,最后需关闭所有嵌套的父元素,就这样吧,这就是我们的列表,好的,我们有三个字符串的列表,现在我们要做的是打印它,所以我们想要有一个叫to_list的列表。

然后有一个函数将该列表展平,然后我们将打印它,所以这就是,呃,主程序应做什么,现在,必须编写flatten函数,flatten不接受参数,将返回字符串,将返回单个字符串,flatten是一个简单的函数。

我们该做什么,我认为有两种情况,一是字符串已结束,二是尚未到达字符串末尾,那么让我们测试一下,我们如何知道,是否已到字符串末尾,嗯,若下一个指针为空,则字符串中再无内容,实际上有一个特殊测试。

在Cool中,它称为is void函数,所以如果下一个为空,好的,所以下一个字段是,所以下一个字段为空,那么我们返回什么,那么结果就只是,嗯,这个项,列表中最后一个元素的项,否则,我们想做好什么,嗯。

否则我们想取项目并连接到它,其余列表展平的结果,这就是我们的展平方法,让我们看看是否有效,让我们编译这个,我们得到了几个语法错误,让我们回去看看发生了什么,我们有一个语法错误,嗯,在末尾,嗯,展平方法。

我们看到我们遗漏了关闭条件的关键字,所以条件必须以with fee结束,让我们看看现在是否有效,我们仍然有语法错误,嗯,在第二十九行,这里的错误是我们忘了声明这个变量的类型,它是一个列表。

然后它被初始化为这个,嗯,到这个大的表达式我们写出的,然后我们把缩进做得更漂亮一点,注意实际上有一些值得注意的事情,这个定义,这个列表变量的定义依赖于之前在let中定义的变量。

所以每当一个let绑定被创建时,绑定的变量的名称实际上在随后的let表达式中是可用的,所以在这种情况下,这个列表变量使用了hello world和new line,这些都是在之前定义的。

在同一let构造中。

让我们保存这个并来这里编译它,我们看到代码中还有另一个错误,所以如果我们上来,我们看到我们在这里犯了一个错误,我在这里使用了函数式表示法,调用flatten的next。

但实际上我想做的是在next上调度方法flatten,所以应该像这样写。

好吧,可能快接近了,让我们看看是否有效,啊哈,它编译了,现在让我们看看它是否运行,确实如此,它打印出了hello world。

正如我们所期望的那样,回到我们的程序,让我们以某种方式泛化列表抽象,假设我们可以有一个任意对象的列表,不仅仅是字符串,这将需要我们改变一些东西,现在可以用对象初始化,现在到了展开列表的时候。

嗯,我们想产生一个字符串,我们想展示,产生一个打印表示,但列表中不一定是字符串,我们需要一种遍历列表的方法,并对列表中可能存在的不同类型的事物做不同的事情,对列表中可能存在的不同类型的事物做不同的事情。

有一个很酷的构造,用于在运行时恢复对象的类型,这被称为case构造,所以让我先引入一个let表达式,我们要构造的字符串,类型为字符串,将被初始化为某些东西,现在它将是一个case,我们要case什么。

将取决于事物的类型,列表中的项可能是,它可以是不同的类型,我们想对不同的操作,取决于实际项是什么,所以我们将做case item,然后关键字是of,现在case表达式有不同的分支。

对于列表中可能存在的不同类型,所以假设它是int,好的,所以这做的是这个,这表示如果项是int,那么我们将将其重命名为i,我们将i绑定到该整数,然后我们可以对i做些什么,我们可能想对i做什么。

我们可能想将其转换,转换为字符串。

所以我会做i to a of i,如果实际上,该项恰好是类型string,列表中的项必须是类型string,那么我们就可以直接使用项本身作为字符串表示,我们可以为其他类型的类型做同样的事情。

如果我们系统中还有其他类型的类型,我们可以在这里继续列出其他情况以及如何将它们转换为字符串表示,但让我们在这里有一个默认情况,我们将说如果它是任何其他类型,若有分支,则覆盖,若类型为对象,则应中断。

称为o,好的,应调用中断函数并退出,这是我们的情况,嗯,需以issac结束,case的反向,使用构造函数中的字符串,若下一个字段为void,则返回字符串,否则返回连接字符串,与列表其余部分的展开,好的。

有几个问题需要修复,这里使用i到a方法,列表需要继承,从转换类a到i,还有另一个问题,我明白了,嗯,就在这里,如果你注意到,case语句需要产生字符串,好的,结果板不返回字符串,实际上板终止程序。

但类型是返回对象,因此我们需要说服类型检查器,接受这段代码,需要将此分支类型化为字符串,我们可以做的是,这很丑陋,但这是必须做的,我们将它放入一个块和一个语句块,嗯,首先调用中断,再次。

那将只是终止程序,现在我们可以在后面放置任何字符串表达式,那将给整个块类型字符串,因此我们可以在这里放置空字符串,例如,并以分号结束,因为这是在一个块中,我们可以用大括号关闭它,好的。

这只是为了让类型检查器高兴,这可能是我们需要做的全部,所以让我们尝试编译这个,我们需要包含转换库,好的,目前有一个语法错误,因为我们忘了在on后面加分号终止符,我们每个,每个的,每个的,嗯。

我们在let中引入的变量,好的,我们得保存那个,让我们再试一次,哎呀,实际上我没能修复语法错误,那是因为我把分号放错了地方,嗯实际上,我忘了在let中绑定的变量之间要用逗号分隔。

但case的分支必须用分号终止,我之前关于使用分号终止let绑定的说法是错误的,只是在case分支中需要,在这个例子中,好吧无论如何,回到这个,让我们看看它是否编译并成功了,现在让我们运行它。

它现在工作了,当然,我们实际上还没有利用列表中不同类型对象的能力,所以让我们,嗯,让我们做那个,嗯,让我们添加,嗯,一个整数在这里,类型为int,让我们给它数字42,我们可以把它插在这里。

现在我们可以传递任何对象,嗯,给init在第一个位置,所以我们会直接把42放那里,当我们编译并运行这个,它应该打印hello world 42,如果一切按预期进行,它做到了。

这结束了我们关于酷的简短之旅,还有一些特性我们没有在这些例子中展示,但你可以在示例目录中查看更多程序,更多程序将,嗯展示你所有不同的语言特性的里里外外和细节,以及我们在此涵盖的,作为结束。

P60:p60 11-05-_Alignment - 加加zero - BV1Mb42177J7

在这段视频中,我们将讨论对齐,一个非常低的级别,但机器架构中非常重要的细节。

首先,让我们回顾一下现代机器的一些属性,嗯,目前大多数现代机器都是32位或64位,意味着它们要么是32位要么是64位的字,字实际上被分成更小的单位,我们说一个字节有8位,然后4或8个字节是一个字。

取决于它是32位还是64位机器,另一个重要属性是机器可以是字节或字寻址,意味着在机器的本地语言中,在机器码中,可能只能命名整个字,或者可能以单个字节的粒度引用内存。

我们说数据是字对齐的,如果它从字边界开始,所以如果我们考虑内存中的数据或内存的组织,它被分成字节,假设这是一个32位机器,所以4个字节是一个字,一个字从这里开始,下一个字从这里开始。

那么如果数据分配在字边界上,比如说在这4个字节中,那么这将是一个字对齐的数据块,如果一块数据开始于字的中间,所以比如说,例如,它从这里开始,是的,我们有一些分配在这里的数据,这个数据不是字对齐的。

因为它的开始不在字边界上,重要的是机器有一些对齐限制,这些限制有两种形式,所以有些机器,如果数据没有正确对齐,意味着你尝试引用机器要求的方式没有对齐的数据,那么机器可能无法执行该指令,程序可能会挂起。

甚至机器可能会挂起,但重要的是程序不会正确执行,所以没有正确对齐数据是不正确的,现在有一些机器实际上允许你将数据放在任何你喜欢的地方,但代价是巨大的,所以可能是。

访问字边界对齐的数据比访问非字边界对齐的数据更便宜,而这些性能惩罚往往是巨大的,这些性能惩罚往往是巨大的,访问错位数据可能慢十倍,访问机器偏爱对齐的数据。

让我们看一个数据对齐问题常出现的情况,最常见需要担心对齐的情况之一,是在字符串分配中,假设我们有这个字符串,嗯,字符串,Hello,我们想,嗯,存入内存,所以让我把我们的记忆画成字节序列,好的。

所以我会标记一些字节,假设这是32位机器,让我标出单词边界,嗯,更粗的边界,1,2,3,四,二三四,好的,所以这是单词边界,现在假设我们,我们尝试有对齐的数据,单词对齐的数据。

所以我们将字符串分配在单词边界开始,所以h字符将进入第一个字节,然后e和l,然后 l then o,现在可能有一个终止空,取决于字符串如何实现,但让我们假设我们有,这是一个很好的,呃,字符串的放置。

字符串从单词边界开始,这应该会满足机器的任何对齐限制,现在的问题是,下一个数据项放哪,我们可以在下一个可用字节开始下一个数据项,那很好,如果我们非常关心不浪费内存,但我注意到那个数据项将不会按字对齐。

我们可能会遇到正确性或性能问题,嗯,如果机器有对齐限制,简单的解决方案是简单地跳转到下一个字边界,并分配下一个数据项,下一个单词是什么,从下一个单词边界开始,这两个字节会发生什么,嗯,这些字节只是垃圾。

它们,它们,它们根本不会被使用,程序永远不会引用它们,它们的值无关紧要,因为程序不应该引用它们,这只是未使用的内存,注意如果没有终止零,那么那里会有终止空字符,那么字符串后面会有三个未使用的字节。

所以总结一下,这是处理,嗯,对齐,当你有对齐限制时,数据从边界开始,通常是要求的字边界,而你分配的具体数据长度不是整数,意味着它没有直接结束在下一个要求的边界上,那么你就跳过中间的任何字节以获取数据。

P61:p61 11-06-_Stack_Machines - 加加zero - BV1Mb42177J7

本视频中,我们将讨论运行时组织之外的内容,开始讨论代码生成,在这第一部分,将是一系列关于代码生成的长视频。

我们将讨论最简单的代码生成模型,称为堆栈机。

所以在一个堆栈机中,你可能会猜到主要存储是一种堆栈,你猜对了,事实上,堆栈机唯一的存储就是一个堆栈,堆栈机的工作方式是执行指令,所有指令都有这种形式,有一些函数的某些参数,它们产生一个结果。

它所做的就是,它会从堆栈中弹出操作数,因此,参数a1到an存储在堆栈的顶部,然后它将使用这些操作数计算函数f,并将结果r推回到堆栈的顶部。

好的,让我们看一个简单的例子,让我们看看如何使用堆栈机计算七加五,所以我们会有一个堆栈,堆栈最初可能已经有一些东西在上面,但我们不在乎那些东西是什么,所以执行七加五,我们首先,必须将七和五放在堆栈上。

所以当它们被推入堆栈时,我们稍后会详细讨论这一点,但假设七和五都在堆栈上,所以现在我们要计算七和五的加法,加法需要两个参数,所以我们会弹出两个参数从堆栈,我们最终会得到五和七,嗯,从堆栈弹出。

我们会执行操作,加,然后结果会被推回到堆栈上,所以这将等于十二,然后十二会被推回到我们的堆栈上,好的,现在注意我,嗯,指出堆栈上可能已经有一些其他东西,让我给这些东西起个名字。

让我们谈谈堆栈机的一个非常重要的属性,请注意,当我们评估七加五时,我们最终处于一种情况,即该操作的结果在堆栈的顶部,好的,初始堆栈内容保持不变,这个堆栈,下面的东西,我们感兴趣的参数没变,好的,所以。

它经所有操作未变,这是堆栈机的关键属性,一般而言,属性是,评估表达式时,结果在栈顶,表达式开始评估前的栈内容保留。

现在思考如何编程堆栈机,让我们有一种只有两个指令的语言,可以推整数到栈上,然后有操作加,将栈顶两个整数相加,现在看看这个程序,推七然后推五,然后不加,思考程序如何工作,好的,我们有栈内容。

现在第一条指令是推七,结果栈上有七,它加到栈上,现在推五,好的,下一步栈顶有五和七,然后执行加,弹出这两个元素相加并推回,结果栈上有十二,原始栈内容保留,现在。

堆栈机代码有趣属性,指令中操作数和结果位置未明确,因为这些指令总是指栈顶,与寄存器机或寄存器指令相反,明确命名操作数来源和结果位置,例如,你可能熟悉过去的机器码或汇编代码。

一个加指令可能通常取三个寄存器,两个为参数的寄存器,两个为参数的寄存器相加,一个为结果的寄存器,而在堆栈机中,我们只有一个字加,无明确参数命名,因为参数来源固定,参数总是从栈弹出,参数来源总是固定。

参数总是从栈弹出,结果总放栈顶,有趣性质是更紧凑,指令中需说更少,程序实际小得多,Java字节码用栈评估模型原因之一,导致更紧凑程序,特别是Java早期,通过互联网分发昂贵,小紧凑代码是好属性。

为何偏好寄存器机,答案通常是寄存器机码更快,因为我们能准确放置数据,通常会有更少的中间操作和栈操作,推入弹出以获取所需数据,和,介于纯栈机和纯寄存器机之间有一种中间状态,这很有趣,这称为n寄存器栈机。

概念上,背后的想法是,寄存器堆栈机仅用于保持堆栈顶和位置,我们特别关注的特定管理堆栈机器变体,单寄存器栈机器是否,原来有一个注册也有很大好处,献给堆栈的顶端,这个寄存器称为累加器。

这里专用的寄存器称为累加器,因其直观地累积操作结果。

其他数据都存于栈上。

好的,让我们思考加法指令及其在纯栈机中的工作方式,所以在纯栈机中,加法指令将做什么?它将从栈中弹出两个参数,比如五和七,并将它们相加,然后将结果放回栈中,让我们称其余的栈内容,这需要三个内存操作:

加载两个参数,然后存储一个结果,嗯,但在单寄存器堆栈机中,加法操作实际上做了很多工作,嗯,一个寄存器,因此,一个参数已经存储在寄存器中,因为那是概念上的堆栈顶部,结果将推回到堆栈顶部。

这再次是累加器寄存器,因此,一个参数和右值都是从寄存器中获取的,仅有一个内存引用,获取栈中存储的第二参数。

总的来说,思考如何评估任意表达式,使用栈机,现在这不是,应该说,现在,仅是栈机代码,如之前所看,这不是字节码操作序列,实际上是一个完整表达式,正如你在酷中可能找到的,因此。

某些操作内部嵌套了其他复杂表达式,因此,如果我们有一个操作需要n个参数,并且这些参数本身是需要被评估的表达式,这里有一个使用栈机的一般策略,因此,对于每个子表达式,按顺序的每个参数。

我们将递归地使用相同的栈机策略进行评估,最终结果将存储,评估时,递归地,结果在累加器中,好的,结果确实在累加器中,然后将结果推入内存栈,我们将取那个结果,我们将,将累加器清空并保存到栈上,栈的部分。

它在内存中,好的,因此我们评估前n-1个参数的子表达式,除了最后一个,好的,对于最后一个参数,我们将使用相同的策略,我们只是评估,我们不将结果推入栈中,这意味着结果留在累加器中,好的。

现在累加器中有op的一个参数,我们最后评估的一个,其他n减一个在栈顶,在内存中,所以我们要做的就是,从栈中弹出n减一个值并组合,然后使用n减一个值和累加器中的值计算,并将结果存回累加器,好的。

评估表达式的总体策略。

现在让我们举个简单例子,用我们一直在用的例子,计算七加五的表达式,我们如何做到这一点呢,我们正在评估加法表达式,它需要两个参数,两个表达式,因此我们不得不评估每一个,首先我们评估表达式七。

实际上让我画一下我们的堆栈,好的,这是堆栈的初始内容,这是初始累加器,所以现在我们在评估七,好的,当然常数将直接评估为其本身,结果存储在累加器中,好的,评估七后的第一步,现在因为那是加法的第一个参数。

它必须被推入堆栈,主内存中的堆栈部分,所以,现在我们的情况看起来像这样,好吧,当然七仍然在累加器中,但我们即将覆盖它,我们不会再使用那个值,因为接下来我们要做的是评估加法的第二个参数。

碰巧在这种情况下也是一个常数表达式,五,因此它将得到评估并存储在累加器中,好的,我将覆盖七,好的,所以那里会有一个五,好吧,现在我们评估了所有参数,好的,记住,对于只有两个参数的情况。

第一个参数被评估并保存在堆栈上,所以它不会丢失,当我们评估第二个参数时,由于它是最后一个,我们可以将其留在累加器中,现在我们可以实际评估加法,好的,所以我们将执行累加器,获取累加器加上内存栈的顶部。

所以在这种情况下,结果就是加7和5,然后我们结束,然后我们,当然我们从内存栈弹出参数,好的,所以我们有,仅仅,呃,原始内容在那里,现在累加器中有值12。

所以正如你可以从例子中看到的,我们将在栈机上维护的不变量是,在我们评估表达式e之后,累加器持有值v,所以评估e的结果最终在累加器中,栈保持不变,好的,所以栈,栈的内存部分在我们开始评估e之前是什么。

这是一个非常,非常重要的表达式评估保持栈不变的性质。

所以现在让我们看一个更复杂的例子,只是稍微更复杂一点3加7加5,关于这个例子有趣的是现在外加的其中一个参数本身,是一个复合表达式,所以我们必须,那将必须被递归地评估,作为评估整个表达式的部分。

所以让我们看看这是如何工作的,所以首先将要发生的是,我们正在评估外加的,我们要评估那个加的第一个参数,那只是常数3,所以那将被加载到累加器中,好的,这是评估3的结果,而现在因为它是加的第一个参数。

我们必须保存它,在我们开始评估加法本身的时候,所以那个结果,呃,被推送到栈上,现在我们要评估外加的第二个参数,它本身有两个参数,那个内加的第一个参数,是7,所以那最终被存储在模拟器中,这是评估7的结果。

然后因为内加有两个参数,我们必须评估第二个,评估内加的第二参数,七必须保存到栈上,所以现在栈上有七,三和之前开始前的任何内容,接下来将评估内加的第二个参数,因此评估常数五将导致五被加载到累加器中。

现在我们已评估了内加的所有参数,好的,因此我们知道从我们的堆栈纪律来看,最后一个参数在累加器中,第一个参数将在栈的顶部,所以接下来会发生的是我们将从栈中弹出第二个参数,将其添加到累加器中并存储回累加器。

所以现在累加器中有加法的结果,我们还需要从栈中弹出七,好的,最后,我们现在已评估了外加的第二个参数,所以现在我们可以执行外加了,那涉及什么,它取栈的内容,并将其添加到当前栈顶的值,即值为三。

这是我们很久以前保存的,以记住它,当我们想要执行外加时,当我们弹出栈后,最终在累加器中有十五,这是整个表达式的结果,并且使用的栈与开始时相同,好的,评估整个表达式导致结果在累加器中,并且栈保持不变。

如果你看子表达式,你可以看到同样的事情发生了,所以让我们看看七加五的评估,嗯,那发生在哪,嗯,那从这里开始,好的,开始,嗯,在这个指令,并且一直持续到这里,你可以看到七加五的评估,涵盖了这五个表达式。

导致十二被放在栈的顶部,这是七加五的结果,它没有影响内容,对不起,它们导致十二被放置在累加器中,并且让栈保持不变,从它所在的地方,评估七加五开始,这是开始和保存的值,栈顶是三,评估七加五完成,确实再次。

值为三,所有其他先前的东西仍在栈上。

P62:p62 12-01-Introduction_to - 加加zero - BV1Mb42177J7

多视频后,运行时,组织与栈机,终于可讨论代码生成。

如前视频所述,将聚焦栈机代码生成,这可能是最简单的策略,通常不产生高效代码,但策略有趣,并非完全不现实,对目的足够复杂,要在真机上运行结果代码,我们将使用mips处理器,特别是使用mips的模拟器。

它几乎可以在任何硬件上运行,这对课程项目将非常方便,基本思想,基本策略将是使用mips模拟栈机。

指令和寄存器,在设计我们的模拟时第一个决定是决定累加器放在哪里,我们将它保存在寄存器中,零,任何寄存器都行,但我们只用零,始终为累加器,栈将保存在内存中,在此指出,当我们谈论一个单寄存器栈机时。

名义上那个寄存器,在这种情况下,零是栈机逻辑栈的顶部,但为了避免术语上的混淆,我将把零称为累加器,和堆栈,所有内存栈上的数据,所以考虑零,累加器与内存中的堆栈区分,MIPS上的堆栈向低地址增长。

这是MIPS的标准惯例,MIPS中堆栈下一个位置的,地址将保存在寄存器sp中,这个寄存器,实际上有一个助记符名称,代表栈指针,通常在MIPS机上,编译器用sp指向栈,栈顶始终在地址p+4处。

记住栈向低地址增长,栈指针中的地址是栈上未分配的下一个位置,栈指针实际上指向未使用的内存,因此栈顶在下一个更高的字地址,即p+4。

MIPS架构相当古老,它于20世纪80年代设计,它是或曾是典型的精简指令集计算机或RISC机,风险机器的理念是使用相对简单的指令集,大多数操作使用寄存器作为操作数和结果。

然后使用加载和存储指令将值移入和移出内存,因此,所有计算主要在寄存器中进行,内存操作主要是加载和存储数据,有三十二个通用寄存器,这是32位机器,我们只会使用其中三个寄存器,我们已经讨论过,SP栈指针。

A零,累加器,我们还需要一个寄存器来存储临时值,因此,像加法和乘法这样的操作,需要两个寄存器来存储操作数,因此,我们将使用累加器来存储其中一个操作数,使用临时寄存器来存储另一个。

在MIPS架构的SPIN文档中,有更多详细信息,SPIN是我们将使用的执行MIPS的模拟器,代码,现在,当然。

为了为MIPS生成代码,也需要一些MIPS指令,我们只需要非常少的指令,五个,实际上,对于我们的第一个例子,它们在这里,我们需要的第一条指令是加载或加载字,它的工作方式是,它取寄存器二中的值。

取寄存器二中的内容,添加一个固定偏移量,这是一个直接嵌入在代码中的数字,作为固定偏移量添加到寄存器二的内容,这是一个内存地址,它将该内存地址的值加载到寄存器一中,加法指令将寄存器二和寄存器三的内容相加。

并将结果存储在寄存器一中,存储操作或存储字操作将寄存器一中的值存储到内存中,因此,它存储在内存地址,内存地址是什么?它是寄存器二中的内容,加上代码中的固定偏移量,以及立即加无符号,将其视为无符号加。

它取寄存器二中的值和一个立即值,因此,这只是一个直接嵌入在代码中的常数,它将该值添加到寄存器二中,并将结果存储在寄存器一中,这里的无符号方面意味着溢出不被检查,我们不会,我们不会检查。

是否生成了一个超出,嗯,范围的数字,嗯,嗯,超出我们能表示的范围,如果有符号数,最后立即加载,只需一个代码中的常数并将其放入,嗯,名为第一个参数的寄存器中,好吧,所以这是我们需要的五个指令。

嗯 做一个非常简单的事例。

所以现在我们可以做我们的第一个程序了,并不意外,它是我们在之前的视频中看过的同一个程序,当我们谈论堆栈机代码时,让我们看看,这里是用我们小小的抽象堆栈机语言编写的加七加五的程序。

现在我们的目标是使用mips指令实现这个程序 所以在这里,在右边,我将列出我们将使用的指令来模拟这个程序,或在mips机器上实现这个程序 好吧,第一条指令是将七加载到累加器中,我们可以使用立即加载来做。

我们将立即加载,值七 a零是我们的累加器寄存器,所以这条指令将七放入累加器,下一个指令我们想将累加器的值推入栈中,我们怎么做呢 嗯,我们必须将值存储到栈上,并记住栈指针指向下一个未使用的内存位置。

所以我们只是直接存储在栈指针指向的位置,就是这样,相对于栈指针的零偏移,累加器的值将值推入栈中,现在为了恢复栈指针指向,下一个未使用位置的不变性,我们必须从栈指针中减去四,好的。

所以这两条指令一起实现了一个推,它们将贝塔值推入栈中,并将栈指针移动到下一个未使用的地址,好吧 现在我们可以做下一个指令了,将五加载到累加器中 嗯。

我们已经知道如何做了 将是立即加载到累加器寄存器a零中,立即值五,现在准备做加法,那怎么工作呢 嗯,首先,我们必须加载栈顶的值,因为第二个参数是从栈顶取的,并且因为mips只能对寄存器中的操作进行操作。

该值需存入寄存器,我们使用临时寄存器,现值距栈指针偏移4,因从栈指针减4,并载入寄存器t1,然后可执行加法,累加器加寄存器t1值,结果存回累加器,最后弹出栈,栈上值处理完毕,如何弹出?仅加4至栈指针。

栈指针回移。

P63:p63 12-02-Code_Generation - 加加zero - BV1Mb42177J7

接下来两视频,将研究代码生成,比之前讨论的简单,栈机语言更高级的语言。

这是有整数和整数运算的语言,这是语法,程序由声明列表组成,声明是函数定义,它有函数名,函数接受参数列表,参数只是标识符,函数有一个表达式,这是函数的主体,函数体内看像什么,看起来表达式可以是整数。

标识符,如果-那么-否则,我们只允许整数相等测试,然后是表达式的和,表达式的差和函数调用。

列表中的第一个函数定义是入口点,这是主例程或程序启动时运行的函数,这种语言足够写斐波那契函数,这就是它,只是一个标准定义,如果x是1,结果为0,如果x是2,结果为1。

否则为fib(x-1)和fib(x-2)的和。

现在为这种语言做代码生成,需要为每个表达式e生成代码,需要为每个表达式e产生MIPS代码,完成两件事,首先代码将计算e的值,并将其留在累加器a0中,所以当e的代码完成后,e的值将存储在累加器中,此外。

e的代码,生成的代码将保留栈指针,和栈的内容,这意味着无论栈是什么,当我们开始执行e或e的代码时,栈将在执行完e的代码后完全相同,我们将编写一个代码生成函数,一个c gen of e产生代码,好的。

所以c gen of e是产生程序的东西。

它将完成这两件事,现在我们的代码生成函数,将通过案例工作,我们将会有不同类型的代码,或为每种语言表达式生成的特定代码,因此,要评估一个整数常量表达式,我们只需将该常数加载到累加器中,因此。

对于常量i的代码生成,i是立即加载到累加器的指令,i的值,显然,这保留了所需的栈,因此,这不会修改栈指针,或栈中的内容,因此,栈在指令执行前和执行后完全相同,我还想指出,或我想强调。

这里我将遵循一个惯例,红色表示编译时执行的事情,蓝色表示将在运行时执行的事情,因此,在这种情况下,编译时我们执行函数,C gen of i,这会产生将在运行时运行的代码,好的。

Cn of i是我们在编译时执行的,它产生将在运行时执行的程序,这有助于您在脑海中分离并牢牢掌握,这些程序中真正的时间划分概念,编译器内部发生的事情,然后有推迟的计算,直到我们正在生产的程序实际执行。

好吧现在,让我们看另一个例子,让我们,嗯,让我们考虑两个表达式的相加并思考为它生成的代码,我们要做什么?当我们执行e一加e二时,首先发生的事情是,我们必须计算子表达式的值,我们必须知道要添加的整数。

所以我们最好为e一生成代码,这将在编译时发生,我们肯定会编译时生成该代码,然后一旦我们有了e一的值,嗯,记住我们只有一台单寄存器栈机器,所以我们必须将该值,保存在某个地方,直到我们也知道b二的值。

我们将把它放在哪里?我们将做我们总是做的事情,我们将它放在栈上,因此,e一是,e一的代码,保证e一的代码将e一的值留在累加器中,因此,我们将做的是,在我们评估完一之后,我们将累加器的值存储到栈上。

我们知道如何做到,将恢复的零压入栈,然后必须调整栈指针,然后我们可以为e two生成代码,好的,再说一次,蓝色部分,这是运行时执行的程序部分,这是编译时发生的代码生成调用,好的。

因此我们为e two生成代码,它放在这里,在这段为e one推值的代码之后,一旦我们有了e two的值,现在我们可以执行加法,我们如何做到这一点呢?首先,我们获取e one的值。

因此我们加载栈上的e one的值,注意这是因为e two的代码保证,e two的代码生成保证会保留栈,你知道这里的e two代码,让我暂时离题一下,e two的代码可以非常复杂,这可以是一个完整的程序。

它可以调用函数,你可以分配数据结构,它可以打印东西,它可以做各种复杂的事情,但由于我们拥有这个不变量,所有表达式的代码生成都将保留栈,我们知道无论这个有多复杂,执行多长时间,完成时。

执行栈将处于相同的状态,这就是使我们能够知道,我们存储的e one的值在哪里,它将在栈的顶部,好的,因此我们将e one的值加载回临时寄存器,现在我们可以做加法,好的。

因此我们将t one和a zero相加并存储回累加器,现在我们必须弹出栈,现在注意,这里所有代码都是为e one加e two,当我们完成时,我们建立了两个不变量,e one加e two的值在累加器中。

由这条指令建立,这里的弹出恢复了栈的状态,这里的栈状态与,我们进入这里的代码块时完全一样,为了完全精确,我实际上应该以稍微不同的方式写出这个代码生成函数,它应该是这样的。

所以我们在做的是为e one生成代码,然后我们在文件或其他类似的东西中打印出,执行推的代码,好的,然后生成两个的代码,现在这些代码生成调用也打印到同一文件,好的,所以你知道它们打印出了指令。

无论指令是什么来执行一个,这是打印退出代码,做推送,我们打印做e二的代码,然后打印做ad和pop的代码,是的,ad和pop,好的,这只是为了,你知道这边更冗长,所以我会省略打印,只用蓝色。

指示被推迟的指令,但我希望你能理解这意味着什么,这里所有红色,当然是在编译时完成的,所以你知道,我们正在调用这些编译时代码生成函数,打印语句在编译时执行,然后我们在某个地方,在某种数据结构或文件中。

累积所有将在运行时执行的指令。

所以让我们考虑对这个代码的可能优化,而不是将e一的执行结果推入栈,如果我们将e一的执行结果存储在临时寄存器t一,那代码会是什么样子呢?在那种世界里,为e一加e二生成代码,我们会做什么。

我们为e一生成代码,接下来将是,而不是将结果推入栈,我们会取e一的执行结果,当然它在累加器a零,并将其存储在临时寄存器,然后我们会为e二生成代码,接下来将是e二的代码,然后我们就可以做加法。

我们会取e二的执行结果,它在累加器a零,将其加到t一的值中,并将结果存储回累加器a零,当然这里没有从栈中推入和弹出,所以这段代码保持了栈,而且看起来,它实际上将e一加e二的值放入累加器。

不幸的是这段代码是错误的,所以这实际上是错的,你不应该这样做,要明白为什么,让我们考虑如果,如果e二本身是,实际上让我们举个具体例子,我们做例子,一加二加三,像这样括号,好的,那么会发生什么。

所以e一这里,我们正在做一加二加三,这将是一个立即加载,e一的代码将是立即加载,到数字一的零中,好的,然后我们将有移动,我们将尝试保存该值,我在临时寄存器t一中,现在我们将为e二生成代码,e二是什么?

e二是自身的一个加表达式,因此我们将递归调用代码生成器来生成二的代码,加三,因此我们为新的第一个表达式生成代码,这将是一个立即加载,I到数字二的零中,现在你应该能看到将要出错的地方。

因为既然使用相同的代码生成策略,它也将尝试使用t一持有临时值,所以它将累加器移动到t一,从而覆盖之前子表达式评估的值,数字一,好的,所以该值将被覆盖,然后我们将做加和,和哦,抱歉,我犯了一个错误。

我们不会做加和,让我擦掉忘了为三生成代码,所以现在我们加载三的值,I到累加器中,嗯,现在我们可以做加和,现在进行加和,所以我们做零,t一a零,当你执行这个,你得到什么,你得到二加三,即五,这很好,嗯。

但现在,现在我们有这个子表达式的值,在累加器中,现在我准备好做外层加和,因此产生另一条加法指令,这完全一样,但不幸的是t1的第一个值,我们试图存储的第一个临时变量已被覆盖,那么里面是什么。

此时t1中的值是2而不是1,我们得到1,1加2加3等于7,这不是我们想要的,所以这里的问题是,当然是在嵌套表达式的情况下,特别是同一种类的嵌套表达式,如果表达式试图使用固定的寄存器来存储临时值。

如果你试图为两个不同的表达式生成代码,抱歉,两个同类型的表达式嵌套在里面,它们会踩到彼此的临时中间结果,这就是为什么我们必须使用栈来存储中间值。

所以这个例子说明了代码生成的一些特征,我只想强调几点,首先注意加法的代码实际上是一个模板,其中有为评估e1和e2的代码留出的洞,有一些固定的指令我们会发出,然后有一些地方我们可以直接插入。

e1和e2的代码,好的,这就是我所说的模板,有一些固定的东西,这些是实际执行加法的指令,然后有一个地方我们可以直接插入任意代码,无论是什么,用于实现e1和e2,我们将在其他类型的表达式中看到相同的模式。

另一个重要的一点是栈机代码生成是递归的,也就是说,你知道e1加e2的代码,是e1和e2粘合在一起的代码,递归地我们为e1和e2生成代码,它们将有自己的模板,它们甚至可能是我们刚刚看到的同一种类的表达式。

和,这意味着,代码生成可以写成抽象语法树的递归下降。

至少,呃,对于表达式,好吧,让我们考虑另一个新指令,让我们添加减法指令,这就像加法指令,所以sub只是减去两个寄存器而不是添加它们,代码生成,然后对于减法表达式,正如你所想。

看起来很像加法表达式的代码生成,那么我们有什么,首先我们有一个地方插入e一的代码,然后我们要将e一的值存储在栈上,我们需要记住这个中间结果,然后我们可以去计算e二的值,这里是插入e二代码的地方。

然后最后我们将e一的值加载回一个临时寄存器,实际上执行操作,减法,然后弹出栈,关于这段代码要注意的是它与加法代码完全相同,除了这条指令。

就在这里我们做减法,而不是加法,接下来我们将讨论if then else表达式的代码生成,为此,我们需要一些控制流指令,实际上,我们需要两个,这里是分支相等指令,如果这个标签的两个寄存器内容相等就跳转。

然后我们还需要一个无条件跳转,这个只是做无条件跳转和分支,无条件跳转到特定的汇编指令,现在让我们看看表达式的代码生成。

如果e一等于e二,那么计算三,否则计算e四,首先我们得评估谓词,为了评估谓词,我们首先得评估e一,到现在这个二元操作的模式应该很熟悉了,所以我们评估第一个子表达式,我们将结果保存在栈上。

所以我们将它推入栈,这需要两个操作,一个是将累加器的结果保存在栈上,另一个是移动栈指针,然后我们评估e二,现在我们已经评估了谓词的这两个参数,e二的结果在累加器中,e一的结果在栈顶。

因为e二的评估会保留栈,呃,所以现在我们将e一的值加载回一个临时寄存器,然后我们弹出栈,然后我们可以实际进行比较,所以现在我们做分支相等,所以现在我们做分支相等,如果e1等于抱歉,实际是e2在0的值。

如果等于e1,则转到真分支,好的,否则将通过,如果不等,好的,因此我们称其为假分支,嗯,我们要做什么,如果我们通过,如果测试失败,那么我们要评估e4,这将把e4的值留在累加器中,那将是整个。

如果-那么-否则在谓词为假的情况下,所以当我们完成时,我们现在将转到一些代码,它将清理并结束,如果语句,我们稍后会看到它做了什么,否则,嗯,我们仍然需要实现真分支,因此我们在这里放置真分支的标签。

我们在真分支上做什么,嗯,我们只评估三,好的,然后结束,实际上没有清理要做,因为,e3和e4都保留堆栈,并将它们表达式的结果留在累加器中,我们从e3到达结束if,如果我们执行了真分支。

那么累加器中的值是e3的值,我们通过这条分支到达结束if,如果我们执行了假分支,那么累加器中的值是v4的值,因此这正确实现了和-如果-否则表达式。

P64:p64 12-03-Code_Generation - 加加zero - BV1Mb42177J7

本视频是前一个视频的续集,我们将完成简单语言的代码生成,处理函数调用,函数定义,和变量引用。

为了提醒你我们在做什么,这是简单语言,我们再次有不同种类的表达式,我们上次处理了所有这些,除了变量引用和函数调用,当然,我们还有一个函数定义,如我在介绍中所说。

这些是我们将在本视频中查看的三个结构,设计函数调用和函数定义的代码生成的主要问题是,这两个都将密切依赖于激活记录的布局,因此,函数调用的代码生成,函数定义的代码生成和激活记录的布局都需要现在一起设计。

对于这个特定语言,一个非常简单的激活记录将足够,因为我们使用栈机,我们在代码生成中模拟栈机,函数调用的结果将始终在累加器中,这意味着无需将函数调用的结果存储在激活记录中,此外,激活记录将持有实际参数。

因此,当我们计算带有参数x1到xn的函数调用时,我们将这些参数推入栈中,并且碰巧的是,这些是语言中的唯一变量,除了函数调用的参数外,没有其他局部或全局变量,因此,这些是激活记录中需要存储的唯一变量。

现在回忆一下栈机纪律保证,函数调用期间栈指针保持不变,因此,当我们从函数调用退出时,栈指针将与我们进入函数调用时完全相同,这意味着我们不需要激活记录中的控制链接,控制链接的目的是帮助我们找到前一个激活。

由于栈指针被保留,当我们从函数调用返回时,他们将没有困难地找到它,并且在函数调用期间我们永远不会需要查看另一个激活,因为语言中没有非局部变量,我们,然而,需要返回地址。

并且它需要存储在激活记录的某个地方,结果,指向当前激活的指针将是有用的,现在,这是指向当前激活的指针,而不是前一个激活,该指针将生活在寄存器fp中,它代表帧指针,这是一个,这是一个。

这是mips上的寄存器名,名字用来表示帧指针,按惯例,编译器将帧指针放在这里,帧指针的作用是什么,嗯,它指向当前帧,这就是名字的来源,但它的作用我们将在几分钟后看到。

好吧,所以,对这个语言总结一下,一个激活记录,包含调用者的帧指针,实际参数和返回地址就足够了,所以让我们考虑对函数f的调用,它有2个参数x和y,那么在调用执行之前,在我们开始执行函数的主体之前。

这就是激活记录的样子,所以我们将有旧的帧指针,这是指向调用者帧的帧指针,不是指向我们正在执行的函数的帧,原因是我们必须把它保存在某个地方,因为帧指针寄存器将被当前激活的帧指针覆盖,所以我们必须保存旧的。

以便我们从当前函数返回时可以重新启动调用者,然后是函数的参数,它们按逆序推入栈中,最后一个参数先推入,第一个参数在栈顶,这样做的原因是,这将使找到参数的索引更容易,简单一点,然后是栈指针,所以有一个。

这里什么都没有,我们将在调用lee,我们正在调用的函数将推入返回地址,所以这是返回地址将去的地方,这些元素,调用者的帧指针,函数的参数,以及被调用函数的返回地址将构成f的激活记录。

一些术语,调用序列是调用者和被调用者的指令序列,用于设置函数调用,好的,这在编译器术语中称为调用序列,我们将需要一个新的指令来显示这个的调用序列,对于函数调用,那将是跳转和链接指令,所以跳转和链接。

它跳转到作为参数给出的标签,并将跳转和链接后下一个指令的地址保存在ra寄存器中,代表回邮地址,跳转和链接指令会发生什么,如果有跳转和链接至标签l,然后有一个加法指令紧随其后,我不知道它是什么。

这是这条指令的地址,跳转和链接后的那条指令将被存入寄存器a,这条指令将跳转到l,它将这条加法指令的地址存入我们的a,并执行l处的任何代码,然后l处的代码可以执行跳转,回到这里执行返回。

嗯,给调用者,现在我们可以实际生成函数调用表达式的代码了,假设我们有一个调用f(e1, e2, 。 en),其中,当然e1到en是表达式,让我换个颜色,这些都是表达式,不是值,我们怎么做呢?首先。

我们将开始构建激活记录,我们保存当前帧指针,这是调用者的帧指针,好的,它指向调用者的帧,然后我们将它在栈指针处存储,嗯,我们得增加栈指针,然后我们为最后一个参数生成代码,嗯,对于n。

这段代码将被插入这里,然后我们把它推入栈中,所以我们存储结果,它将在累加器中,A零在栈上,然后我们,嗯,增加栈指针,好的,然后我们对所有参数这样做,以e1结束,所以我们为e1生成代码并将其推入栈中。

好的,所以现在所有参数都在栈上,好的,然后我们只需进行跳转和链接,我们已经完成了大部分工作,以及调用序列中我们可以在调用者侧完成的,这段代码在调用者的函数中执行,好的,这是调用序列的调用者侧。

它构建了尽可能多的激活记录,特别是,它评估实际参数并将它们推入栈中,成为调用函数激活记录的一部分,然后执行跳转链接,跳转到被调用函数的入口点,我们正在,这是对f的调用,跳转到f的入口点。

还有一些需要注意的事项,首先,如前一页幻灯片所讨论的,当我们执行跳转链接指令时,将返回地址保存在ra寄存器中,该地址为这里的地址,跳转链接指令后的,下一个指令的地址,同时注意。

我们构建的激活记录目前是4n,嗯,加4字节,所以这里的n是参数的数量,每个参数占用4字节,然后4字节用于旧帧指针。

现在我们可以讨论调用序列的被调用方,我们需要一个新的指令,jr指令代表跳转寄存器,它跳转到寄存器参数中的地址,现在被调用方是函数定义的代码,好的,这是实际执行函数主体的代码,我们如何为它生成代码呢?

现在让我们看一下,实际上,这里的第一件事是,嗯,被调用方侧的第一条指令是入口点,我们缺少标签,所以这将被标记为f_entry,好的,这是跳转链接指令的目标,然后我们设置帧指针。

将栈指针的当前值复制到帧指针中,该指针指向被调用函数帧的末尾,对于被调用的,正在执行的新函数,我们还在栈的当前位置保存返回地址,记得调用序列的调用方侧还有一件事要做,调用序列的调用方侧缺少的一件事。

即返回地址,我们直到跳转链接指令执行后才知道返回地址,所以ali是必须保存该值的指令,好的,在跳转链接之后,a寄存器包含返回地址,现在我们将它保存到帧中,然后我们推入栈指针,好的。

现在只需为函数体生成代码,所以现在激活记录已经完全设置好了,现在我们可以为函数体生成代码,函数体执行后,当然,栈指针将被保留,嗯,这意味着返回地址,呃将是,呃,栈指针偏移4处。

因此我们可以将返回地址加载回,呃,返回地址寄存器,好吧,然后我们可以弹出栈,所以这里我们将弹出,呃,当前帧从栈中,并且那将是某个大小z,我们,呃,还没告诉你它是什么,但我们将在一分钟内计算z的大小。

这将是一个立即值,所以那是一个我们插入的常数,然后加载旧帧指针,好的,一旦我们增加了栈指针,我们弹出了现有帧,所以现在我们指向帧指针,我们首先指向前一个栈帧之外的第一件事,那是什么?

那是我们在f的栈帧中保存的第一件事,那就是旧帧指针,所以现在我们恢复了旧帧指针,这样调用我们的函数将会有其帧指针恢复,然后现在我们准备好返回并继续调用函数的执行,我们只需通过跳转到返回地址的寄存器即可。

好吧,所以请注意,帧指针指向帧的顶部,不是帧的底部,好的,这实际上将在我们讨论如何使用帧指针时很重要,当我们谈论关于变量引用的下一部分时,调用者弹出返回地址和实际参数,以及栈中保存的旧帧指针的值。

调用者弹出整个激活记录,并恢复调用者的帧指针,那么z的值是多少呢?有n个参数,每个占用4个字节,因此激活记录大小为4倍,N加上另外两个值,嗯,在激活记录中,一个是返回地址,另一个是旧帧指针,好的。

为两个更多单词的空间是8字节,因此这是激活记录的大小,因此这是我们要添加到栈指针的量。

弹出f激活记录,调用前概览,嗯,调用者帧指针,和,当前栈指针值和函数条目,好的,调用后,嗯,调用函数后,调用序列一侧完成,栈上有什么,好的,有旧帧指针和两个参数,栈指针指向下一个未使用位置。

这是返回地址将去的地方,对吧,然后执行跳转链接,我们跳过去,返回地址被推入栈中,帧指针被移动以指向当前帧的值,好的,指向帧顶,好的,通话后发生了什么,弹出所有堆栈,弹出调用函数的整个激活记录。

现在注意我们回到相同状态,函数调用必须保持不变,堆栈在调用中保持不变,调用后堆栈应与调用前完全相同,就像进入调用时一样。

我们几乎完成了简单语言的代码生成,最后要讲的构造是如何为变量引用生成代码,函数的变量就是它的参数,即函数的参数,这种简单语言中没有其他类型的变量,这些变量都在激活记录中,实际上。

我们只需要能够生成代码来查找变量在其适当的位置,在激活记录中,但有一个问题,那就是栈会随着中间值的增减而增长和收缩,当你调用函数并执行其体,值将在栈上弹出和推入,激活记录除外,回想加号和减号的代码生成。

以及if then else,中间值在栈上弹出和推入,这意味着激活记录中的这些变量,不在固定的偏移量,嗯,从栈指针,因此我们很难使用栈指针来决定或找到这些变量。

所以解决方案是使用帧指针,嗯,帧指针始终指向激活记录中的返回地址,并且在函数体执行期间它不会移动,因此我们总能相对于帧指针找到相同位置的变量。

那么我们如何做到这一点呢,让我们考虑i,第i个参数x_i是函数的参数,第i个参数到函数,所以相对于帧指针它将在哪里,将在偏移处,Z从帧指针,而z只是4倍,I正确,实际是生成推入堆栈参数的原因。

按相反顺序,从函数的最后一个参数开始,因为这使索引计算简单,如果按其他顺序推入参数不会更复杂,在其他顺序,这使索引工作更容易理解,而且无论如何,这个索引在编译时计算,注意这个数字,4次。

I是编译器知道的东西,我们在代码中放入的只是一个固定偏移量,我们实际上并没有在运行时进行乘法运算,z在这里只是一个由编译器静态计算的数字,所以无论如何我们只加载偏移量z,即4次i,i为索引。

参数列表中位置,嗯,列表参数中的变量,距离帧指针偏移处,xi在激活记录中存储,我们将其加载到累加器,这是变量引用的完整代码生成。

这是一个小例子,所以对于函数,嗯,我们一直在看的两个参数函数,X和y,X是帧指针加4。

y是帧指针加8,总结一下要点,嗯,激活记录必须与代码生成一起设计,所以你必须同时做这些事情,不能只设计激活记录而不考虑要生成的代码,不能只考虑写代码,而不做关于数据存放位置的决定,因此。

代码和操作数据的代码必须同时设计,代码生成可通过抽象语法树的递归遍历来完成,就像类型检查一样,代码生成可表示为递归树遍历,这是思考代码生成的一种非常方便的方式,因为它允许你一次考虑一个情况,而不必混淆。

同时考虑所有不同的结构,最后,建议使用栈机编译器,若实现课程项目,栈机最简单,提供问题分解框架,因其简单,是学习编译器的好方法。

需意识到生产编译器不同,嗯,不如栈机代码生成简单,如前几视频所述,主要区别在于,生产编译器强调保持值和寄存器,从寄存器操作更高效,而非从栈中保存和加载值,特别是当前激活记录中的值,当前栈帧,生产编译器。

尝试将它们在寄存器中而非栈中保持,通常生产编译器,在激活记录中使用临时变量时,这些将直接布局在激活记录中,不从栈中推入,这意味着它们将在激活记录中分配预定义位置。

就像函数参数和之前看的简单语言在激活记录中分配固定位置一样,因此,这些临时值也将分配固定位置。

P65:p65 12-04-Code_Generation - 加加zero - BV1Mb42177J7

本视频中,将生成一个小程序的代码。

要查看的程序接受正整数x,并将从0到x的所有数字相加,若x为0,则结果为0,否则为x加上从0到x-1的所有数字之和,这不是一个有趣的程序,但它展示了之前视频中讨论的所有功能,所以,让我们深入探讨。

讨论如何为程序生成代码,首先给函数的入口点一个标签,即为sum_to_entry,现在,我们,需要为调用方生成代码,调用方序列,抱歉,刚才说了什么,首先,我们需要设置帧指针,其值即为栈指针。

这是此激活的帧指针,然后,我们需要存储返回地址,在当前栈指针的值处,然后移动栈指针,每当我们在栈上存储东西时,我们需要将栈指针移动到下一个未使用的位置,好的,好的,现在,我们需要为这个。

if-then-else生成代码,如果你回去看if-then-else的代码,首先需要为谓词的第一个子表达式生成代码,我们将为x生成代码,这很容易,就像为变量生成代码一样,只需在帧的当前位置查找变量。

并在正确的偏移量处,从帧指针开始,好的,好的,一旦我们完成这个,我们,正在为谓词生成代码,我们如何做到这一点呢?我们已经为这个表达式生成了代码,现在我们需要将该子表达式保存在某个地方。

因为我们要为另一个子表达式生成代码,即相等,这是一个二元运算符,因此,我们需要保存该值,我们在栈上计算过,好的,那么我们将这样做,因此我们将零值存储在栈上,这总是涉及移动栈指针,好的。

现在为谓词的第二个子表达式生成代码,好吧,这也容易,这只是一个立即加载立即值到累加器,好吧,现在我们要加载,嗯,我们为谓词的第一个参数保存的值回到临时寄存器,实际上进行比较,所以这是更多代码。

这实际上是条件的一部分,好吧,所以我们做加载字,到t1,我们之前保存的值,好的,现在我们需要,我们需要弹出栈,好的,所以我们将在这里这样做,好吧,因为我们用完那个值了,所以我们将,好的。

现在我们可以做分支,所以现在我们测试谓词的两个子表达式是否相等,如果它们相等,则跳转到真分支,我在这里给真分支一个唯一标签,因为这可能是更大程序的一部分,其中有很多,如果-那么-否则。

我在末尾附加一些标识数字,而不是写出真分支,我将只称这个为真一,好吧,好的,如果我们继续执行,那么我们在假分支,我们称之为假一,现在我们在为假分支生成代码,这是这里的求和,好吧,我们如何做到这一点呢?

整个东西是一个加表达式,这意味着我们必须首先生成代码,呃,对于第一个子表达式,这仅仅是x,好吧,那么我们做什么呢?我们加载以生成x的代码,我们呃,查看x当前偏移量,但它是帧内的适当偏移量,使用帧指针。

好的,它是唯一的参数,因此它在帧指针的4个位置,抱歉,过程的唯一参数,因此它存储在第一个参数位置,在方案中总是从帧指针的4个位置开始,现在我们已经加载了它,我们不得不保存它,因为它是二元操作的一部分。

所以我们要将该值保存到栈上,好的,现在我们将,呃,调整堆栈,接下来我们要做什么,现在我们,呃,我们已经计算了这个子表达式,这个x,我们还不能做加法,直到我们计算第二个子表达式,即函数调用。

所以现在我们必须为函数调用生成代码,我要向上移动,到屏幕的另一边,这里来显示其余的代码,好的,为函数调用生成代码的第一步,是开始设置我们的激活记录,这是为函数调用设置新的激活记录,我们即将进行。

那么我们要做什么,我们存储,呃,帧指针,好的,为了存储我们的旧帧指针,呃,在栈上,好的,在栈上,好的,现在,嗯,我们必须计算参数,好的,我们需要计算x减一,因此,这段代码将插入函数调用的模板中。

那么那里会发生什么,嗯,我们正在计算减法,减法的模板是先生成第一个子表达式的代码,然后生成第二个子表达式的代码,然后减去它们,对吧,那么让我们这样做,所以我们首先为x生成代码,好的。

由于它是二元运算的第一个参数,我们将它保存在栈上,好的,现在为减法的第二个参数生成代码,好的,现在执行减法,因此,我们必须将第一个参数加载回临时寄存器,我们必须实际执行减法,抱歉这里,好的。

现在我们可以从栈中弹出临时值,好的,所以现在我们已经完成了减法,让我看看,那就是全部,呃,从这里到这里是计算x减一,好的,这是计算x。

这是计算一,然后整个东西是计算减法,好的,所以现在我们计算参数,我们要做什么,嗯,我们将其保存在栈上,所以现在我们将结果保存在栈上,我们将其保存在正在构建的新激活记录中,好的,呃,然后。

我们必须推进或移动堆栈指针,总是如此,现在我们已经准备好实际执行函数调用,所以现在我们做跳转链接到sum2的入口点,好的,现在当这个返回时,它将返回什么,它将返回计算sum2的结果,在累加器中,对吧。

所以现在我们可以执行加法,因为现在我们计算了加法的第二个参数,我们如何做到这一点,嗯,回顾加法模板,接下来重载栈中保存的临时值,好的,现在可以实际执行加法,好的,然后弹出栈中的临时值,好的。

实际上结束了else分支,嗯,else分支,整个的假分支,嗯,如果那么否则,好的,所以现在我们在其余部分分支,如果那么否则代码,我们将称之为标签,如果一和现在来的是真分支的代码,我们要放什么那里。

并不很复杂,嗯,因为在真分支上我们只是在加载,或生成零的代码,这只是一个加载,立即加载,立即,好的,这就是整个真分支,所以现在我们在,嗯,那里不应该有调用,打扰一下,实际上我可以稍微擦掉一点,好吧。

现在我们在,实际上我看到我写错地方了,所以让我们修复它,这是假分支末端的分支,在else末端的末端,if的一部分,我们将绕过真分支的代码,它只有一条指令,所以下一个指令是标签,如果这样,接下来做什么。

我们已为整体生成代码,如果,那么,否则,所以,现在这里是函数定义的其余模板,所以现在我们只需生成返回给调用者的代码,我们如何做到这一点呢,我们必须从返回地址加载,呃,从堆栈,好的,现在弹出栈。

弹出整个激活记录,激活记录多大,嗯,记得总是两个字,一个是返回地址,一个是帧指针,然后是等于参数数量的字数,这里只有一个参数,所以有三个字,所以是12字节,栈指针增加12,对,然后加载,呃,旧帧指针。

存储帧指针,好,然后返回,再一条指令,跳转到返回地址,这就是简单函数的全部代码,求和2,有几点要指出,代码由许多模板拼接而成,我们一边走一边指出它是如何工作的,但最终你确实得到一个线性序列的代码。

如果你有任何困惑,值得回去看看那些模板和例子,理解代码如何组合和如何工作,另一件要指出的是这是非常低效的代码,比如这里我们生成代码检查x是否等于0,注意这里我们加载x,这是加载x。

然后我们立即将x存储回栈中,我们刚刚从帧中加载它,我们立即又把它存回内存,然后加载一个立即值,然后我们重新加载x的值,你知道,移动x的值,你知道,到处移动,我们加载它,我们存储它,我们再加载它。

所以有很多浪费的动作,这是这种非常简单的代码生成策略的结果,我们希望能够组合代码,但能够以正确的方式组合这些模板,代码不必如此低效,在后续讲座中讨论的许多技术中,我们将讨论更智能的代码生成技术。

P66:p66 12-05-_Temporaries - 加加zero - BV1Mb42177J7

前几视频中讨论了简单编程语言的代码生成,上期视频末提到,实际编译器处理方式略有不同,特别是更有效地将值保存在寄存器中。

以及管理激活记录中必须存储的临时变量,本期将讨论这两个问题,本视频仅讨论第二个问题,将讨论编译器更好地管理临时值的方法,基本思想是已见的,将临时值保存在激活记录中。

激活记录中保存临时变量的基本思想,现在这不如寄存器中临时变量高效,但那是未来视频的主题,今天不谈,我们要讨论的是,改进激活记录中临时变量的管理方式,无论原因,所以为什么在激活记录中不重要,但既然在那里。

我们能生成的最有效代码是什么,我们要做的改进是,让代码生成器为每个临时变量分配激活记录中的固定位置,我们将预分配内存或激活记录中的位置,然后我们可以保存和恢复临时变量,而无需使用堆栈。

指针操作,让我们看一下简单编程语言的典型程序,这是斐波那契函数再次,让我改变颜色以获得更多对比度,让我们思考需要多少临时变量,来评估此函数,因此,此函数体在执行时,若提前知临时数,可分配激活记录空间。

而非运行时堆栈推拉,让我们看看,若然后则将产生临时,因需进行谓词比较,需评估谓词首参数,并保存结果,同时评估谓词次参数,将涉及一个临时,为此谓词需一临时变量,类似地,为评估此谓词,因是二元操作比较。

同样需一临时变量,还有这边这个表达式,相当复杂,为此需多少临时变量,记住如何运作,先评估第一个表达式,然后将结果保存,这将需要一个临时变量存储调用fib的结果,在评估加法时必须保存该变量,现在。

在评估对fib的调用时,实际上,在评估对fib的调用之前,我们必须评估fib的参数,这涉及减法,因此,我们还需要一个临时变量来存储减法,那么,关于这个加法运算的另一边呢,嗯,这也涉及减法,好的,因此。

我们需要一个临时变量来存储x的值,在我们评估减法以计算参数值时,在调用fit之前,好的,那么总共需要多少个临时变量呢,我们需要一个用于谓词的临时变量,但请注意,一旦谓词被决定。

一旦我们知道这个谓词是真还是假,我们不再需要那个临时变量了,实际上,那个临时变量可以被回收,我们不需要,不再需要那个临时变量的空间了,当我们到达false分支时,同样,一旦这个谓词被评估。

我们不再需要那个临时变量的空间了,好的,所以现在我们处理加法,首先,我们评估第一次调用fib的参数,一旦评估完成,我们不再需要它的临时变量了,现在,fib的结果必须保存在某个地方,以便我们进行加法。

好的,然后,我们不得不评估第二次调用fib的参数,现在请注意,这发生在我们需要这个临时变量的时候,因此,事实上我们需要这两个临时变量同时存在,好的,因为在评估对fib的第二次调用的参数时。

我们仍然需要保留加法第一个参数,因此,实际上这个特定函数可以用两个临时变量来评估,这是计算这个函数体值所需的所有空间。

一般来说,我们可以定义一个函数nt(e),它计算评估e所需的临时变量数,让我们只讨论一个例子,让我们看看评估e1加e2所需的临时变量数,因此,它将至少需要与e1相同的临时变量数,好的,因此。

如果我们需要一些临时变量k来评估,k个临时变量来评估,至少需要k个临时变量,也需要至少同样多的临时变量,来计算2加1,因为要保留v2的值,抱歉,在计算e2时要保留v1的值,好的,将是这两个的最大值。

所以将是最大数量,在评估1和1所需的最大临时变量数,加上评估2所需的临时变量数,将是总临时变量数,评估e1加e2所需的最小临时变量数,原因是最大,而不是总和,一旦评估了1,不再需要评估1时使用的空间。

所有临时变量都完成了,只需要答案,不需要中间结果,评估1时使用的临时变量。

可以重复使用,来评估,E,二,所以从那个例子概括,这是描述所需临时变量数量的方程系统,让我们看看,我们已经讨论了e1加e2,只是最大值,评估1和1所需的临时变量数,加上评估2所需的临时变量数。

所以e1减e2完全一样,具有相同的结构,是不同计算操作,但为二元操作,在评估e2时需要保存b1的值,现在公式相同,对于,如果-那么-否则,我们需要什么,我们需要一,抱歉,我们需要,将再次是最大值。

将是一些不同数量的最大值,可能需要多少临时变量,可能需要评估1所需同样多的临时变量,我们肯定至少需要这么多,好的,若取一定临时数,整个if then else至少需这些临时数,当然一旦e一评估完。

不再需要其临时数,然后可评估e二,好的,评估e二时,需保留e一结果,这就是oneplus的来源,评估e二时,需e二临时数加一,以保存所有计算临时数,一旦谓词完成,不再需要任何临时数,将评估e三或e四。

只需,这些表达式所需临时数,每个所需,这四个量中最大值,是最小临时数,评估整个if then else所需,让我们看函数调用,嗯,函数调用所需空间为,评估任一参数所需临时数最大值,实际上这是个有趣情况。

公式中无e一至n结果空间,当然一旦评估e一,需保存某处,可能认为公式中有数,代表评估这些表达式的临时空间,我们没有这些的原因是,这些值确实保存,不在当前激活记录中,e一结果和所有参数,结果,包括e n。

保存在新建激活记录中,e一至e n结果空间,这些值保存在新激活记录,不在当前激活记录,我们计算当前激活中,所需临时数,然后对于整数,不占用任何空间,不需要临时数,就是说,整数临时数为零。

变量引用也不需要。

现在让我们通过示例,系统地使用方程计算所需临时数,好的,那么,这里是为了这个,如果,那么,否则,记住,它将是所需评估e的最大值,那是01加上要评估e2的数字,这是谓词中的第二个表达式,所以那将是1。

因为数字1需要0个临时变量,而1和我们必须有,我们有一个来保留x,好的,然后分支的最大值,所以评估0需要0个临时变量,现在我们必须计算这里需要的数字,好的,所以再一次,为了评估这个,如果,那么。

否则需要0个临时变量来评估第二个一个将需要11,加上所需的1加0,嗯,评估那个常数需要0个临时变量,现在对于最后一个表达式,嗯,这个将需要多少,这将需要,嗯,0个对于这个家伙,1个对于第二个参数。

所以评估fib将需要1个临时变量,好的,然后它将是一,加上在这里我们必须保留结果,那里,x减2的值,那么那将需要多少,那将需要0和1加0的最大值,好的,所以这将是一,好吧,所以在这里,我们有1加1是2。

好的,现在我们在取最大值,所以那是2,好的,这是外层,如果,那么,否则的最后表达式,如果,那么,否则,这里将需要2个临时变量,好的,这是所需评估谓词中任何一部分的最大值,然后分支和否则分支。

现在整个表达式需要2个临时变量,那将是外层,如果,那么,否则的四个组成部分的最大值,所以然后对于整个表达式,我们得到。

一旦我们计算出,函数体所需临时变量数,可向激活记录添加相应空间,现在激活记录需2加n加nt元素,其中两个,当然,是返回地址和帧指针,n是函数的n个参数,其余为临时变量所需空间。

现在可讨论激活记录布局,嗯,我们将第一部分保持不变,返回地址前的所有内容,按逆序排列的n个参数,然后是返回地址,返回地址后是结束位置,或nt抱歉,临时变量位置。

现在知道函数所需临时变量数,以及临时变量在激活记录中的存储位置,为生成代码还需知道,程序中每点使用的临时变量数,换个颜色,我们将这样做,为代码生成添加新参数,下一个可用临时变量的位置,临时变量用完后。

代码生成参数会改变,允许其他表达式安全保存值,不会覆盖其他表达式已保存的临时变量,稍后示例中可见,激活记录的临时区域,将用作小型固定栈,本质上我们拥有之前相同的栈纪律,所有关于栈指针的计算。

或所有关于偏移量的讨论,编译器已全部完成,我们之前通过堆栈推入弹出元素,在生成的代码中,大量计算已移至编译器,现在仅是固定偏移量的存取,从帧指针。

所以让我们看看如何运作,这是旧方案下e一加e二的代码,激活记录中无单独的临时变量区域,我们将为e一生成代码,然后将e一的计算结果保存到栈上,这通过将累加器值保存到栈上来实现,然后需要调整栈指针。

在评估完二之后,然后加载e一的返回结果到临时寄存器,我们可以做加法,然后弹出栈上的值。

栈上的中间值,现在在新方案下,代码生成将接受第二个参数,说明下一个可用临时寄存器的位置,激活记录中下一个未使用的临时寄存器的位置,所以现在为e一生成代码并传递参数,好的,因为e一可能自己有一些临时变量。

它需要存储,然后在你评估完成后,现在,我们直接将值存储到激活记录中的偏移量,从帧指针,所以我们必须执行存储,我们必须保存,嗯,e一在激活记录中,这样我们稍后就有它了,但我们不需要对栈进行任何操作。

所以我们用一条指令替换了这里的两条,然后为e二生成代码,但现在我们只需在偏移量处保存临时值,从帧指针,下一个可用的临时寄存器将在地址nt或偏移量,抱歉,nt加四,然后e二评估完成后。

我们必须将e一的值加载回临时寄存器,再次,那是从当前激活记录的帧指针偏移nt处,然后我们可以做加法,再一次,我们保存了对栈指针的操作,这里的代码序列比之前短两条,实际上效率更高,并且实际上更加高效。

P67:p67 12-06-_Object_Layout - 加加zero - BV1Mb42177J7

最近几段视频,我们讨论了简单编程语言的代码生成,在这段视频中,我们将看看更高级功能的代码生成。

对象。

幸运的是,对象的标准代码生成策略只是我们所学的一个扩展,之前学过的所有内容我们都会使用,然后会有一些专门针对对象做的事情,关于对象的重要事情,人们谈论面向对象编程时听到的口号是,如果b是a的子类。

那么类b的对象可以在类a对象期望的地方使用,所以有一个替代能力属性,如果我有一段代码可以工作在,那么也可以工作在b's和其他a的子类上,现在这意味着对于,对于代码生成的情况,我们为类a生成的代码。

即我们为类a的方法产生的代码,对于类b的对象也必须不作修改地工作,为了看到这一点,记住当我们编译a时,当我们编译类a时,我们可能甚至不知道a的所有子类,所以它们可能甚至还没有被定义,在未来。

某个程序员可能会出现,定义一个a的子类。

所以,我们只需要回答两个问题,以完整描述如何为对象生成代码,第一个问题是我们的对象如何在内存中表示,因此我们需要决定对象的布局和表示,第二个问题是动态调度是如何实现的,这是使用对象的特征性功能。

即我们可以在对象中调度到一个方法,我们需要这种实现的。

所以,具体来说,我们将使用这个小例子贯穿整个视频,嗯,这个视频,我在这里花一点时间指出一些特点,我们有三个类,类a,B和c注意a是基类,b和c都继承自a,所有三个类都定义了一些属性,一些字段。

以及一些方法,现在,这里有几个重要特征,注意因为b继承自a,并且c继承自a,它们都继承自两个类,从类a继承属性a和d,因此,在类a中定义的这两个属性在类b中可用,和在类c中。

所以即使类b的定义中没有提到a和d,例如,类b的方法仍然可以引用这些属性,它们是类b的属性的一部分,它们只是从a复制或继承而来,我想指出的这个例子的另一个特征是,所有这些方法都引用属性a。

所以在这个方法中,我们被引用,在这个方法中被引用两次,并且也在这个方法中,这意义就是我们几页前讨论的,为了使所有这些方法都能工作,属性a必须存在于某个地方,在某个地方,当它们生成的代码运行时。

它们都能找到它,特别是,让我们考虑方法f,所以方法f存在于所有三个类中,所有三个类在运行时,它将引用属性a,即使对象不同,在一个情况下,它可能在一个a对象上运行,另一个情况下在一个c对象上运行。

它需要能够找到属性a,因此,属性a必须在每个对象中处于相同的位置。

那么我们如何实现这一点呢?第一个原则是对象在连续的内存中排列,所以一个对象就是一块内存,好的,没有间隙,对象的所有数据都存储在该块内存的字中,每个属性在对象中都有一个固定的偏移量,例如。

这个对象中可能有一个地方,用于属性a,在这种情况下,它在对象中间,在第四个位置,无论对象是什么类型,无论是a,B或c对象,在我们的例子中,属性a将始终处于该位置,因此,任何引用a的代码。

任何引用a的方法都能找到,现在能找到a属性,理解另一重要事,这是与讨论稍偏,但它是,对象生成关键方面,方法被调用时,对象本身是self参数,所以self,当函数被调用,将指向整个对象。

所以把self看作,指向整个对象的指针,记住self像,变量,或Java中的名字,然后字段将指向,或对象的属性将指向,特定位置。

我们决定a属性,住在那里,这是COOL中使用的特定对象布局,COOL对象的前三个词包含头部信息,每个COOL对象总是有这些三个条目,第一个位置是类标记,偏移量为0,下一个字,偏移量为4,是对象的大小。

然后是一个称为分发指针的东西,然后所有属性,类标记是一个整数,只是标识对象的类,编译器将编号所有类,所以在我们例子中,我们有三个类a,B和c,编译器,例如,可能会分配它们数字1,2,和3。

这些数字是什么不重要,只要它们彼此不同,所以不必连续编号或类似,重要的是类标记是类的唯一标识符,每个类都有自己的独特位模式,告诉你对象是什么类型,这里的其他字段,对象大小也是一个整数。

只是对象的大小以单词为单位,分发指针是指向方法表的指针,所以方法存储在一边,分发指针是指向该表的指针,我们稍后会讨论更多,然后所有属性按编译器确定的顺序排列,在随后的插槽中。

所以编译器将为类中的属性固定和顺序,然后,该类所有对象将具有相同顺序的属性,这一切都安排在连续的内存块中。

现在我们可以讨论继承如何工作了,所以基本思想是,给定类a的布局,子类b的布局,因此,子类a的布局可以由扩展a的布局来定义,我们不需要移动a的任何属性,我们只需在a的布局末尾添加更多字段,因此。

a的布局将保持不变,这是一个很好的属性,因为这就是我,属性在对象a中的位置,对所有子类始终相同,本质上,一旦我们决定一个属性在类中的位置,我们将永远不会改变它,该对象子类的状态永不改变。

所以b只是布局a的扩展,让我们看看示例,了解如何运作,让我在这里简单写一下这些课程,我们有课A,它有2个属性,A和d,好的,类型或方法不重要,只看类名,和类中定义的属性名,然后有b继承自a和b。

添加了属性,小b,然后有c,也继承自a但与b无关,类c定义了一个属性,小c,好吧,所以这就是,示例的结构与,嗯,对象布局有关,好的,所以让我们谈谈类a的布局,位置零偏移零,会有一个标签,将是一个小整数。

编译器选择的i,将有一个大小,我们稍后回来,将有一个分发指针,好的,稍后讨论,然后是属性,它们按酷的C实现方式排列,它们在顺序中排列,它们在类中出现的文本顺序,所以在这种情况下,首先属性a。

然后是属性d,偏移量为12和16,现在由于对象,有两个属性和三个头部单词,这意味着对象的大小是五个单词,所以5在a对象的尺寸字段中输入,让我们看看b,好的,所以b将会有不同的标签。

B对象将会有不同的标签,因此,为了将它们与a对象区分开来,将有一个额外的字段,所以大小将更大,但现在布局保留了a的布局,a的属性出现在相同的位置,你可以认为实际上有一个a对象嵌入在b对象中。

如果我剥离这里的结尾,如果我只是,你知道,覆盖b的最后一部分,我会看到这里的对象具有相同的尺寸,和与a对象相同的属性,因此,任何可以处理a对象的代码也将有意义,现在在b对象上运行,当然标签不同。

因为它实际上是一个子类,你知道,并且有这个额外的字段,所以大小不同,但重点是任何引用这些字段的代码仍然会很好地工作,所以任何编译为引用a对象方法的a方法,嗯,仍然会在b对象中找到相同的属性。

当然这里还有一个额外的字段,这是b的新属性,它只是在这些字段之后排列,所以所有这些字段之后是所有这些字段,它们在类中出现的文本顺序,由于只有一个,只有一个新字段,现在看看类c,类c的故事非常相似。

所以c有自己的独特标签,它也比a多一个属性,所以它的大小是6,现在a属性仍然在相同的位置,现在c属性只是跟在a属性之后,所以请注意,a方法再次将在c对象上正常工作,因为属性在同一位置。

所以方法将找到它们期望的属性,但调用类b的方法在类c的对象上,好的,因为它们第三个位置的属性不同,这些可能有完全不同的类型,在类c的对象上调用类b的方法可能没有意义,但没关系。

因为如果我们看这里的继承层次,我们看到b和c实际上没有关系,它们都是a的子类,但它们彼此之间没有关系,b不是c的子类,c也不是b的子类,因此,除了它们与a共享的祖先之外,布局可以完全不同。

所以更一般地,如果我们有一个继承关系链,假设我们有一个基类,一个一和一个二,继承自一个一和一个三继承自一个二等等,在底部继承这个链的某个类an,经过一系列其他中间子类的长时间序列。

所有这些类的布局会是什么样子呢?会有一个头部,好的,三个字的标题,然后是一个一的属性,然后是一个二的属性,接着是一个三的属性等等,一直到an的属性在这里,好的,如果你再看一次,我们之前讨论过的事情。

这个标题的每个前缀本质上都是一个有效的对象,这些对象中的一个有效的一个,所以如果我看看第一个属性集,一直到a一属性的末尾,这形成了一个a一对象的有效布局,如果我停在a二属性,我有。

我有a二对象的有效布局,从头部一直到包括a一和a二对象,然后a三包括所有a一,a二和a三的属性,那么,好的,因此,每个前缀的uh,这个对象,这个an对象,Uh,有正确的布局对于一些,Uh。

对于n的一些超类。

现在处理了对象属性布局,我们可以转向讨论方法布局,及动态分派的实现,考虑一个分发调用,E,G,假设e是类b的实例,好的,我们希望发生什么?我们想调用类b中的g方法,好的,这似乎很简单。

现在考虑一个稍复杂的例子,如果我们调用e。f,如果我们调用f方法,如果有b对象,我们想要调用此方法,这个f方法,即b中定义的f方法,但如果我们有a对象,我们要确保调用此方法,好的,这个版本的f。

所以f在这里被覆盖,好的,我们重新定义了,嗯,类b中的方法f,此定义替换了b从a继承的方法定义,特别是类c,类c也有f方法,好的,如果我们调用f方法,如果e是类c的实例,那么应该调用哪个方法?

会是这一个,会是a中定义的,这三个类都有f方法,如果,如果对a、c或a对象动态分发,将执行类a中定义的,如果对b对象分发,将执行类b中定义的方法。

每个类都有固定方法集,包括继承的方法,所以如果你,如果你看,嗯,如果我告诉你一个类的名字,你就知道它有哪些方法,这些方法运行时不改变,好的,所以别在这混淆,因为覆盖是编译时的事,基本上是静态属性。

所以编译器可以确定,尽管你可以在子类中重定义方法,编译器可以确定编译时,特定类的所有方法在程序运行时不会改变,因此使用调度表或某种表格索引这些方法,这只是方法入口点的数组,本质上,对于类的每个方法。

数组中都有该方法的条目,就像属性一样,方法f将在类的分发表中,及其所有子类的固定偏移处存在,一旦我们确定了方法的位置,它在分发表中的位置,它将保持不变。

嗯,对于该类的某些类,让我们再次看看示例,并提醒您示例的结构,我们有类a,现在我们只关心方法,所以,类a查找f方法,然后我们有类b,它继承自a,并定义了g方法,然后有类C,也继承自A,定义了一个h方法。

好的,那么,这三个类和这三个方法,好的,所以类A的分派表只有一个方法,所以偏移量为0,存储指向A中定义的f方法的代码指针,好的,这实际上就是一个指向代码第一条指令的指针,将运行方法a。

这是调用方序列的指针,或指向标记指令的入口点,嗯,对于方法现在,那关于。让我们接下来看看,实际上在类c,好的,所以类c继承自a,将包含所有方法,它们将在相同偏移,特别是f方法将在类c偏移零出现。

指向与a中的一样的方法,因为它从a继承该方法,然后类c定义了自己的方法h,因此在表格的下一个位置放置h代码的指针,你知道,如果这些类中定义了更多方法,它们就会出现,你知道,按文本顺序排列,就像属性一样。

所以如果有两个方法在a中定义,这里将有两个条目,对于在a中定义的第一个方法和第二个方法,然后如果c定义了三个方法,然后表格中会有三个更多的条目等等,好的,现在有趣的情况是类b发生了什么,所以在类b中。

f方法被重新定义,我忘了指出这一点,所以让我在这里指出,所以f方法我们有了一个新的定义在类b,好的,所以重要的是要看到的是代码的指针,对于f方法位于相同的位置,仍然是表格中的第一个条目,好的。

类b的f方法在调度表中的位置完全相同,那永远不会改变,不同的是,只是那个位置的内容,表格中的第一个条目指向不同的函数,它指向在b中定义的方法而不是在a中定义的。

然后因为b定义了一些额外的方法或一个额外的方法被放置在。

呃,方法,呃,对于a,好的,你可能还记得我们之前谈论过对象头,我们提到了这个叫做调度指针的东西,所以让我们重温一下对象头中有什么,有一个标记,然后有一个大小,然后有一个调度指针,所以。

然后跟随调度指针是所有类的所有属性,现在,此派发指针仅指向该类的方法表,好的,这将是指向包含所有方法条目的表的指针,该类方法的所有入口点,使用这种间接级别的原因,好的,我们为什么有这个指向单独表的指针。

好的,为什么方法像这样布局,当所有属性都直接嵌入在类中时,如果我们想,可以直接将所有函数嵌入到对象中,你知道,只需将整个表放入对象中,并且不需要我们维护和跟随的额外指针,并且原因在于属性可以更新,好的。

对象的一个对象的属性可以独特于该对象,每个对象都可以有自己的属性集,好吧,但对象的方法永远不会改变,因此,给定类的相同对象表可以共享给所有对象,所以如果我有一百个a对象。

那么我可能有一百个不同的属性版本,因此,每个a对象都必须有自己的属性副本,但所有一百个对象将具有相同的方法,通过让它们共享一个常见的方法表,我可以节省大量空间,并且再次。

类的每个方法或任何类的每个方法都被分配了一个偏移量,我们将在编译时在调度表中称之为o_sub_f,因此,编译器的任务是找出类中的所有方法,然后为这些方法中的每一个,分配一个固定位置。

在那个调度表中的固定偏移量。

因此,如何实现动态调度,所以假设我们有一个对表达式e的调度,并且我们正在调用f方法,所以这是序列步骤的一个稍微简化的版本,所以首先我们评估表达式e,这将给我们返回一个对象x,好的。

然后我们将获取x的调度表,它来自哪里?它在x的头部,所以我们可以直接取对象x本身,并且我们知道在每一个对象中,在第三个单词中有一个调度指针,适合于x的类,所以我们取那个表,然后在调度表中查找f的入口点。

在f的偏移量处,好的,然后我们跳转到那个地址,好的,这是函数的入口点,当我们这样做时,我们将self绑定到x,因此f方法内的self参数将是x对象。

P68:p68 13-01-_Semantics_Overvi - 加加zero - BV1Mb42177J7

这是关于编程语言语义的系列视频之一,特别是关于cool的语义,在深入技术细节之前,尽管我想花几分钟谈谈什么是编程语言语义。

以及为什么我们需要它们。

我们需要解决的问题是,当我们运行cool程序时,我们期望的行为是什么,因此,对于每种cool表达式,对于每个人,我们必须说明它在评估时会发生什么,我们可以将此视为表达式的含义,我们以某种方式给出规则。

指定特定,特定表达式进行何种计算,我认为回顾一下我们如何处理类似问题是有用的,在定义cool的其他部分,好的,我们在本课程中已经看过的早期内容,例如,对于词法分析,我们使用正则表达式定义了一组标记。

对于语言的语法,我们使用上下文无关文法来指定单词,如何组合成cool中有效的句子,然后,嗯,对于语义分析,我们给出了正式的类型规则,现在,呃,我们到了必须谈论程序实际运行的地方,因此。

我们必须给出一些评估规则,这些将指导我们如何做,代码生成和优化将决定程序应该做什么,以及我们可以对程序进行哪些转换以使其运行更快或使用更少的空间,或其他,任何其他我们想执行的优化。

到目前为止,我们一直在间接地指定评估规则,我们通过给出完整的编译策略一直到栈机代码来做到这一点,然后我们讨论了栈机的评估规则,实际上是将栈机代码翻译成汇编代码,这当然是一个完整的描述。

你可以取生成的汇编代码并在机器上运行它,看看程序做了什么,这将是一个关于程序行为的合法描述,然后问题是,你知道,为什么这还不够好,为什么仅仅有一个语言的代码生成器。

为什么这还不是关于如何执行代码的足够好的描述,答案可能有点难以理解,如果没有写过几个编译器的话,人们从经验中得知,汇编语言描述的语言实现,语言实现包含很多无关细节,当你得到如此完整的可执行描述时。

有很多事你不得不说,嗯,关于程序如何执行,这些并非必要,所以,例如,我们使用栈机的事实,并非特定编程语言实现的固有属性,我们本可用其他代码生成策略,你知道,无需栈机实现语言的事实,栈的增长方向。

是向高地址还是低地址增长,你可以两种方式实现,嗯,整数的具体表示,执行或实现特定语言结构的特定指令,所有这些都是实现语言的一种方式,但我们不想它们被,作为语言实现的唯一方式。

所以我们真正想要的是一个完整的描述,但不要过于限制,一个允许不同实现的方式,当人们没有这样做时,当人们没有尝试找到相对高级的方式来描述语言行为时,他们不可避免地陷入了一种情况,人们不得不去运行参考实现。

以决定它做什么等,这并不令人满意,一种情况,因为参考实现并不完全正确,会有漏洞,会有特定实现方式的痕迹,你并不想成为语言的一部分,但因为没有更好的定义,最终成为,嗯,固定,你知道,语言形成中的意外,嗯。

第一次实现。

有很多方法,嗯,实际指定适合任务的语义,结果这些同样强大。

但有些更适合某些任务,我们将使用的称为操作语义,操作语义通过抽象机器上的执行规则描述程序评估,我们给出一些规则,假设你知道特定表达式的,执行方式,可以将其视为非常高级的,代码生成。

这对于指定实现非常有用,也是我们将用来描述cool语义的,我想提及两种其他指定编程语言语义的方式。

因为它们,很重要,你可能在课程之外遇到它们,一种是谓词语义,程序的意义实际上被给定为一个数学函数,因此,程序文本被映射到一个从输入到输出的函数,这个函数是数学意义上的实际函数,这是一种非常优雅的方法。

但它在定义适当函数类时引入了复杂性,我们实际上不需要考虑这些复杂性,只是为了描述实现。

另一种重要的方法是公理语义,在这里,嗯,程序行为用某种逻辑描述,你在这个语言中写的基本陈述,或在这个公理语义中,是如果执行从满足x的状态开始,那么它将结束于满足y的状态,其中x和y是某种逻辑中的公式。

这是许多自动分析程序的系统的基础,试图证明程序的事实,要么证明它们是正确的。

P69:p69 13-02-_Operational_Sema - 加加zero - BV1Mb42177J7

在这段视频中,我们将开始讨论形式操作语义。

就像我们处理词法分析一样,解析和类型检查,定义形式操作语义的第一步是引入符号,结果我们发现,我们想使用的操作语义符号,与我们在类型夹克中使用的符号相同,或非常相似,我们将使用逻辑推理规则。

以类型检查为例,我们展示的推理规则类型,证明了一些东西,在某些上下文中我们可以显示某些表达式具有特定类型,类型C,对于评估,我们将做非常相似的事情,我们现在将在某种上下文中显示。

这将不同于我们在类型中拥有的上下文,因此,这将是评估上下文而不是类型上下文,因此,上下文中实际包含的内容将不同,但目前真正重要的是存在某种上下文,在那个上下文中,我们将能够显示某些表达式评估为特定值。

V,例如,让我们看看这个简单的表达式,E一加E二,让我们说,使用我们的规则,我还没有展示给你,但让我们说我们有一堆规则,并且我们可以显示,在初始上下文中,E一在同一上下文中,好的,因此。

这些上下文将是相同的,E一在该上下文中评估为值五,E二也在同一上下文中评估为值七,然后我们可以证明E一加E二评估为值十二,如果你考虑一下,这条规则说的是,如果E一评估为五,E二评估为七。

那么如果你评估表达式,E一加E二你将得到值十二,那么上下文在做什么呢?在这条特定规则中它并没有做很多,但记住类型检查中的上下文是做什么的,上下文是为表达式的自由变量赋予值的,因此。

我们需要对像E一加E二这样的表达式说些什么,关于可能出现在E一中的变量的值,你需要说,以便于说它们评估为什么,因此,可以说整个表达式E一加E二将评估为什么,现在让我们更精确地谈谈上下文中将包含什么。

因此,让我们考虑表达式或语句y等于x加一的评估,好的,因此,我们将把y的值设置为x加一。

为了评估这个表达式,我们需要知道两件事,首先,要知道变量在内存中的位置,所以,例如,变量x的值需要查找,然后加1,该值需存入y的内存位置,好的,变量与内存位置有映射,好的,在操作语义中称为环境,环境。

可能有点混淆,因为我们曾用环境指代其他事物,好的,现在忘掉其他环境用法,谈论操作语义时,环境指映射,变量与内存位置关联,此外,需要存储,存储将告诉我们内存中的内容,仅知道变量位置不够,若知道x的值。

若知道x的位置,例如,嗯,这很重要,这是获取x值的方法,还需知道确切存储的值,存储是内存位置到值的映射,这些是存储在内存中的值,所以是两级映射,为每个变量关联内存位置,然后每个内存位置有值。

现在谈谈使用的符号。

嗯,记录环境和存储,如前所述,变量环境映射变量到位置,我们将以如下方式书写,以变量和位置对列表形式,用冒号分隔,例如这个环境,说变量a在位置l1,变量b在位置l2,并且,环境的一个方面。

是跟踪在作用域内的变量,另一个方面,是跟踪在作用域内的变量,环境中提到的变量仅是当前范围内的。

在我们评估的表达式中,现在,如我们所说,存储映射内存位置到值,我们还将存储作为成对的列表写出,在这种情况下,存储s中的内存位置l1包含值为5,内存位置l2包含值为7,我们用箭头分隔这些对。

只是为了使存储看起来与环境不同,这样我们不会混淆两者,存储有一种操作,即替换值或更新值,在这种情况下,我们取存储s,并将位置l1的值更新为12,这定义了一个新的存储s',好的,请记住这里,存储只是函数。

至少在我们的模型中,我们可以通过取旧存储s的旧函数,并在一点上进行修改来定义新的存储s',这定义了一个新的存储s',使得如果我应用s'到新的位置l1,我得到新的值12,如果我应用s'到任何其他位置。

任何不同于l1的位置,我得到存储s中持有的值,对不起,我得到存储s中位置的值。

现在在Cool中,我们有更复杂的值和整数,特别是我们有了对象,并且所有对象,当然,都是某个类的实例,我们将需要一种表示对象的操作语义符号。

因此我们将使用以下方式写下对象,一个对象将以其类名开始,嗯,在这种情况下类名x,它将跟随属性的列表,好的,在这种情况下类x有n个属性a1到an,并且与每个属性相关联的是存储该属性的内存位置,所以看。

属性a1存储在位置l1,一直到属性an,存储在位置ln,这将是一个完整的对象描述,因为一旦我们知道对象在内存中的存储位置,我们可以使用存储查找每个属性的值。

酷中有无属性名的特殊类,我们将有一种特殊的书写方式,整数仅有一个值,和,将写成int和一个整数值,布尔值类似,它们只有一个值,真或假,字符串有两个属性。

字符串长度和字符串常量,还有一个特殊值为void的object类型,我们将使用void术语表示,简而言之,void特殊在于无法操作,除了测试是否为void,特别地,不能派发void。

即使类型为object也会报错,唯一能做的是测试是否为void,具体实现通常使用空指针表示void。

现在可以详细讨论,操作语义中的判断将如何,所以上下文将包含三部分,第一部分是当前self对象,第二部分是环境,又是,从变量到存储位置的映射,第三部分是内存,存储,从内存位置到值的映射,好的。

所以在一个上下文中表达式e将评估为两件事,首先你会产生一个值,所以,例如,我们之前看到7加5产生12,这是评估的一个结果,但第二件事是它将产生一个修改后的存储,表达式e可能是一段复杂的代码。

可能本身就是整个程序,它可能包含赋值语句更新内存内容,所以评估后,将有一个新的内存状态需要表示,所以s prime代表评估后的内存状态,所以现在注意几件事,首先当前self对象和环境不会改变。

它们不会被评估改变,所以哪个对象是self参数,和当前方法,以及变量和内存位置映射不会被运行表达式改变,这很合理,你不能在cool中更新self对象,你没有以任何形式访问变量存储位置的权利,因此。

这两件事是不变的,它们不会,它们在评估下是不变的,当你运行一段代码时,它们不会改变,然而,存储会改变,内存的内容可能会被修改,这就是为什么我们需要在评估前后存储的原因,还有一个细节。

这种形式的判断总是有一个限定,即判断仅在e终止时成立,因此,如果e进入无限循环,那么你将不会得到一个值,你也不会得到一个新存储,因此,这种判断应该总是被理解为说,如果e终止。

那么e产生一个值v和一个新存储s',总结一下评估的结果是一个值和一个新存储。

并且新的存储模型表达式的副作用,再次注意,一些事情在评估结果中不会改变。

这实际上对于编译很重要,因为我们将能够利用它们不变的事实来生成高效代码,因此,变量环境不会改变,self的值,我们谈论的对象不会改变,并且注意这里还有一个细节,self对象的内容。

self对象中的属性可能会改变,它们可能会被更新,但是,属性存储的位置不会改变,因此,对象存储的布局不会改变,这就是我们在这里所说的,实际对象的内容,即,当然,是存储映射的一部分。

这些可能会通过评估被更新,操作语义还允许非终止评估,这是这里的最后一点,因此,意义是,嗯,那些,那些判断仅在假设,嗯。

那些判断仅在假设。

P7:p07 03-01-_Lexical_Analysis - 加加zero - BV1Mb42177J7

欢迎回来,这是关于编译器实现的系列视频的第一集。

回忆上次,编译器有五个阶段,呃,我们将从词法分析开始讨论,这可能需要三到四个视频才能完成,至少,然后我们会按顺序继续其他阶段,让我们先看一个小代码片段,词法分析的目标是将这段代码,分成其词法单元。

如关键字,如果变量名i和j和关系运算符双等号,等等,作为一个人类,这,如我们上次讨论的,这是一件很容易的事,因为有各种各样的视觉线索关于单元的位置,不同单元之间的边界,但程序,一个电分析器没有那种奢侈。

事实上,什么是奢侈,词法分析器将看到的是更像这样的东西,所以这里我把代码写出来,仅作为一个包含所有空白符号的字符串,从这个表示,这是一个线性字符串,你可以认为这是文件中的字节,词法分析器必须工作。

它将通过放置不同单元之间的分隔符前进,所以它将识别那里有一个分隔符,在空白空间和关键字之间,然后在关键字之后有一个分隔符,因此更多的空白空间,左括号,i另一个空白空间,双等号和等等,它继续画这些线。

分割,嗯,字符串成其词法单元,我不会完成整个东西,但你应该明白。

它不仅仅在这些字符串中放置分隔符,然而它不仅仅识别这些子字符串,它还需要根据它们在字符串中的角色对不同的元素进行分类,我们称之为标记类,有时我也会简单地称之为标记的类,在英语中这些角色是,动词形容词。

好吧,还有很多或没有,还有一些,在编程语言中,嗯,The,类,标记类可能是标识符等,嗯,关键字,嗯我,然后是语法片段,像打开的,括号,或关闭的,这些将是独立的类,嗯,嗯,数字,同样,还有更多类。

但有一组固定类,每个这些,嗯,对应程序中可能出现的字符串集。

标记类对应字符串集,嗯,这些字符串集可以描述,嗯啊,相对直接,例如,大多数编程语言中的标识符标记类,可能是以字母开头的字母或数字字符串,例如,变量名或标识符可以是a1,或foo,或b十七。

所有这些都是有效标识符,并且通常允许有其他标识符字符,但这是基本概念,非常非常经常,标识符的主要限制,它们必须以字母开头,嗯,整数,整数典型定义为非空数字字符串,如零或十二,好的,一后跟二。

我应该说实际上是一个字符串,不是数字在这种情况下,你知道,这实际上会接受一些数字,你可能想不到像零这样的东西,零,一可以表示一个数字,甚至是零,零可以是有效的整数,根据这个定义,嗯。

关键字通常只是一组保留字,所以这里我列出了几个else,如果开始等等,然后嗯,空格本身是一个标记类,所以实际上我们不得不说明在那个字符串中,哪个是程序的表示,那个字符串中每个字符,什么标记或什么标记类。

它是属于的,什么子串是所属的,并且包括,嗯 空格,所以,例如,如果我们有一系列三个空格,如果我输入if然后一个开,我在这里有三个空格,这三个空格将被分组为空格。

词法分析的目标是按照它们在程序中的作用对子串进行分类,这就是标记类,好的,它是一个关键字吗,变量标识符,然后将这些标记传递给解析器,所以嗯,在这里画个图,让我们嗯,换颜色,词法分析器与解析器通信,好的。

这里的功能是词法分析器接收一个字符串,通常存储在文件中,所以只是一串字节,然后当它发送给解析器时,是一系列对,即标记类和子串,我刚刚说过的,字符串在这里,完美 哪个是一个,所以它发送一个字符串。

它是输入的一部分,所以发送一个字符串,它是输入的一部分,与班级一起,它在语言中的作用,嗯,在语言中,这对称为标记,好的,那么,例如,如果我的字符串是foo等于42,好的,然后它将通过词法分析器并输出。

嗯,我会写在这里,嗯,三个标记,这些将是,嗯,标识符,操作符说等于,嗯,整数,哦,打扰一下,四十二,我仅将这些作为字符串,强调这些是字符串,这不是数字四十二,此时,它是字符串四二,这是一个。

它在编程语言中扮演整数角色,以及解析器视为输入的,这是对吗?词法分析器本质上遍历输入字符串,并将其拆分为对序列,其中每对是一个标记类和原始输入的子串。

让我们回到示例,从视频开头,它被写为字符串,现在我们的目标是词法分析这段代码,我们想遍历并识别标记子串,以及它们的标记类,所以要做这个,我们需要一些标记类,所以让我们给自己,一些来工作的,我们需要空格。

所以这是,嗯,空白序列,新行,制表符,诸如此类的事,嗯,我们需要关键词,我们还需要变量,这些我们称为标识符,嗯,我们需要整数,这里我叫它们数字,然后我们会有些其他操作或类,像打开,括号闭合,括号和分号。

这些很有趣,这三个很有趣,因为它们是一个字符标记类,即它是一个字符串集,但集合中只有一个字符串,所以n的打开对应于恰好包含打开n的字符串,语言的标点符号都在单独的标记类中,我们在这里添加的另一个标点是。

嗯是赋值,将在单独的标记类中,因为它是一个非常重要的操作,但双等号将归类为关系操作符,我们只将其归类为,嗯和操作符放在这里好吧,所以现在我们要做的是,我们将遍历并标记化,嗯这个字符串。

嗯对于每个子字符串,嗯它属于哪个类,我将只使用类的第一个字母,嗯来表示它只是为了节省时间,嗯,所以我不必把所有东西都写出来,我们改变颜色,这样我们可以用不同的颜色做,所以第一个标记是空白标记,然后是。

嗯如果关键字所以k,然后有一个空白,这是另一个空白,然后嗯打开for,这是它自己的标记类,所以我会让它自己在那里标识,然后有一个标识符,好的嗯,空白和然后一个操作符,双等号,嗯,另一个空白。

所以那是空白,空间,跟随另一个标识符,跟随再次关闭括号,标记类中的一个标点符号,然后是三个空格字符,因此它们被分组为一个空格标记,嗯,跟随另一个标识符和更多空格,然后是另一个单字符标记,赋值运算符,呃。

空格和数字,然后是再次分号,标记类中的一个标点符号,两个空格字符组合在一起,接下来是一个关键字,因此它被分类,作为关键字标记类,另一组空格字符,然后是另一个标识符,实际上有一个空白。

我们几乎用标记覆盖了它,I赋值运算符本身,在一个标记类中,空格数字,最后分号本身,这就是我们的标记化,我们识别了输入中的子字符串,并且我们也用标记类标记了每一个。

总结词法分析实现必须做两件事,第一项工作是识别输入中对应标记的子字符串,这里是一些编译器术语,这些子字符串称为词素,因此程序中的单词称为词素,然后第二项工作是对于每个词素,我们必须识别它的标记类。

词法分析器的输出是一系列对,这些是标记类和词素,整个东西。

P70:p70 13-03-_Cool_Semantics_I - 加加zero - BV1Mb42177J7

在接下来的视频中,我们将研究酷操作语义的细节,逐个讲解每种表达式的语义,从简单开始,逐步深入复杂。

因此,最简单的规则是酷常数规则,所以,值true,表达式true,应该说它评估为布尔值true,且不修改存储,因此,存储保持不变,因为它显然不更新,对于False有相应规则,整数非常,非常相似。

所以如果整数表达式,整数字面量,将评估为整数对象,值为i,同样,存储不会因这种评估而修改,最后字符串,如果a,呃,若s为长度为n的字符串,则其将评估为具有n和字符串常量s的字符串对象。

标识符的评估非常直接,考虑到我们既有环境又有存储,因此,要评估一个标识符,这将是类似于x或y或foo的变量名,我们做什么,首先,我们在环境中查找该标识符的存储位置,这将返回给我们一个内存位置。

在这种情况下为L_sub_id,然后在商店查看内存位置的价值,所以这里使用相同的内存位置作为存储的参数,以获取回值,那个变量目前拥有的,注意,这只是一个引用,这是内存的读取,所以这是加载。

你可以认为它是加载变量的值,这不会影响存储,所以存储在这之前和之后是一样的,这只是在查找变量的值,不更新变量,self表达式,仅评估为self对象,这里我们仅利用,self对象是环境一部分的事实。

所以直接复制过来,嗯,作为表达式的结果,注意,store不受self评估的影响,现在看看稍复杂的表达式如何评估,特别是,赋值表达式,由两部分组成,一个将被更新的标识符和一个将给出新值的表达式,例如。

提醒一下,我们可能有类似 x 得到 1 加 1,1 加 1,这里是表达式 e,x 是标识符,对吧,为了评估赋值,首先我们必须做的是,我们要知道写入标识符的值,所以什么是,我们要进行的更新是什么。

所以首先要做的是评估e,注意这里e在同一环境中评估,所以它和这里的三个组件相同,好的,所以如它所说,我们首先运行e,这将给我们带回一个新的a值v,我们将带回一个值v,打扰一下,可能还有更新商店。

所以e可以是任意代码段,它本身可以有赋值语句,所以得到的故事可能完全不同,所以e产生值v和更新后的商店s1,现在真正要做赋值,我们该怎么做呢,我们必须知道要更新哪个内存位置,所以我们查找id的内存位置。

这样我们就有位置ID,然后修改商店为新值,在那个点修改商店为新值,所以我们替换位置ID,或更新位置ID的值等于e的值,值v,我们在商店s1中这样做得到新商店,S2,现在注意s2是评估e的结果,好的。

我们完成作业时,作业返回值v,即,当然运行e的值,并返回更新后的存储s two。

接下来谈谈加法操作规则,要评估e one加e two,我们要做什么呢,首先,我们将评估e one,注意,这是在整体表达式的上下文中完成的,好的,所以这些组件,评估一的上下文与整个表达式的上下文完全相同。

E一加E二,因此,当我们评估E一,它将给我们一个值V一,它还将给我们一个更新的存储S一,然后我们要评估E,注意这里的上下文不同,自对象和环境相同,但现在我们在新的存储S一中运行E二,S一。

这意味着如果E二中有赋值或变量引用,这些赋值和变量引用必须在运行E一的结果存储上进行,好的,这一点非常重要,我们必须理解,运行E一发生的任何副作用,或E二表达式看到的都是可见的,所以我们运行E二。

在这个环境中,我们将得到V二的值和更新的存储S二,整个表达式的结果将是V一加V二,结果存储将是S二,注意这里的存储告诉你必须评估表达式的顺序,因为E一在相同的存储中评估,作为整个表达式。

这告诉你E一必须首先评估,然后因为E二在存储中评估,由E一产生,这告诉你E一,E二抱歉必须在评估后评估,呃,E一,然后事实是,呃,S二是整个东西的结果,这告诉你E二也是最后评估的。

在执行这个特定表达式期间。

好的,让我们看看语句块,只是为了多样性,让我改变我的颜色,我们如何评估一个语句块中的语句,E一至E n,好的,嗯,这个语义的含义是我们应该按顺序运行它们,从E一开始,整个执行的结抱歉。

整个块的值将是最后一个表达式的值,这些,嗯,这条规则只是说,首先,我们评估e one,注意到它在与整体表达相同的商店完成,这就是告诉你它必须先来的原因,并产生新的商店,S one和一个值v one。

好的,然后e two在商店s one中评估,并产生商店s two等,然后表达式在商店sn减一中评估,并产生值v n和更新的商店s sub n,整个结果为vn的值,以及更新后的存储s_n,这告诉你。

此规则告诉你评估子表达式的顺序,这里的依赖,存储迫使你先评估e1,然后是e2,然后是e3等,因此你必须按此顺序进行以获得副作用,以正确顺序获得所有这些表达式的副作用,此外,它还告诉你,你只会保留。

Vn的值,注意,这里产生的其他值,都没有被使用,它们没有出现在。

任何其他地方规则中,让我们用所学做个小例子,我们想了解评估x赋值为7加5,时会发生什么,这是第一个语句,块中的第二个也是最后一个语句,只是表达式的值,首先我们要说,上下文包括三部分。

会有一个self对象,在这种情况下,self对象的内容并不重要,因为程序中没有引用self,因此它不会对评估产生任何影响,但我们仍然需要它,将会有,将会有一些self对象,它不会被使用。

现在我们需要一个环境,告诉我们程序中所有自由变量的位置,只需存储x的地方,x将存储在某位置,L,还需知道内存内容,我们的存储是什么,假设l最初值为0,好的,现在可用规则运行程序,评估此程序。

我将此行拉长,回忆你知道的,块评估包括所有语句的评估,好的,所以第一个将是x得到7加5,将在与整个表达式的相同环境中评估,所以我们将有,抱歉,相同的上下文,对不起,我应该说我经常滑倒。

我知道我意识到并说环境对于这些判断的整个左半部分,我将努力保持一致,并仅使用环境,对于上下文的第二个组件,在文献中,人们称左半边的整个东西为环境,这就是我犯错误的原因,但你知道对于这套笔记。

我正在努力保持一致,左半边的所有组件一起称为上下文,环境只是第二个组件,从变量到其位置的映射,回到例子中,块中的第一个语句是x得到7加5,然后我们将会有第二个语句,我们知道self对象和环境不会改变。

但我们不知道,存储器将会怎样,存储器可能不同,所以我们将存储器暂时留空,我们稍后会弄清楚,我们将评估表达式的okay,所以现在要取得进展,我们应该看看这个第一个语句,尝试在那个上取得一些进展。

所以为了评估赋值,我们必须做什么,我们首先要做的就是我们要评估右半边,所以我们将会有,呃,那个的上下文将和,我们一直在看的上下文相同,因为那是实际上将要发生的第一件事是评估,7加5,好的,现在留。

一些空间在这里为赋值规则的其余部分,我们不会马上填写,现在要评估加表达式,我们必须评估第一个表达式和第二个表达式,好的,那么,我们怎么做呢,我们终于知道了,我想知道怎么做,因为我们终于完成了。

那里会有一个整数,然后,我们已经有了一个规则,所以整数字面量评估为整数对象,好的,嗯,对象内部就是值,好的,存储没有改变,好的,然后类似地,嗯,这里的另一个,参数,所以五也会评估为整数对象,值为五。

好的,所以这是两个,这个版本的表达式,所以现在我们可以填写结果了,所以我们将取两个整数的值,我们相加它们,那也将是一个整数对象,所以我们将得到整数对象十二,存储没有改变,好的,所以这。

我们得到的存储恰好与输入的存储相同,只是因为表达式中没有赋值,好的,现在我们可以做赋值了,那么我们怎么做呢,我们必须形成一个新存储,所以我们将有一个新存储,l 变为零,嗯,嗯,l 处的值。

我不记得我的符号是怎么写的,我认为长度,数字先,我们将十二放入位置 l,当然那个存储只是等于 l 值为十二的存储,好的,所以现在下面会发生什么,当我们做赋值时,我们得到新的值,好的,右边的值是十二。

我们有了新店,位置l有12,好的,现在我们可以评估块中的第二个语句,那将在位置l有12的商店中进行,当然,这只是一个整数,因此,它将评估为整数常量,整数值,请原谅我,或包含整数对象的整数对象。

我们的商店,它只会适合,不太对,然后这是整个评估的结果,因此,此块将产生值为整数对象的整数和更新的商店,呃,位置l有值为12。

接下来我想看一下if then else表达式,并且要评估,如果then else,我们做什么,实际上这应该是,如果then else费,当然,所以要评估,如果then else,首先。

我们必须评估谓词,这在同一家商店中进行,与整个表达式的相同上下文中,如果结果为真,如果布尔谓词返回值为真,那么我们只想评估真分支而不是假分支,这就是为什么你只在这里看到,没有提到e二和e三的评估。

并且要知道,谓词可能具有副作用,e二在e一产生的任何商店中评估,整个表达式的结果为e二的值,好的,那是v,以及运行then分支产生的最终商店,对于如果谓词评估为假的情况有一个对称规则,在这种情况下。

你将评估e三而不是e二,接下来我们将看看while循环在cool中会发生什么,有两种情况,首先,如果while循环的谓词评估为假,好的,嗯,在这种情况下,循环体将不会执行,对吧,所以首先我们评估谓词。

这在同一家商店中进行,与评估整个表达式的相同上下文中,如果谓词为假,那么我们退出循环,接下来我们将看看循环体在cool中会发生什么,循环结果为空,值为空,评估谓词的结果。

另一种可能是谓词为真,因此,我们再次在同一上下文中评估谓词,如果谓词为真,我们将运行循环体一次,好的,我们将评估循环体并注意到这是在,评估谓词的结果中完成的。

评估循环体将给我们一个值v和一个新存储s two,接下来我们需要做的是,我们需要再次执行循环,我们如何能做到这一点呢,我们实际上只是在新的上下文中运行整个循环。

因此我们接下来要做的就是在新存储中评估整个循环,因此,在执行一次循环体后,然后我们再次执行循环,这可能会运行零次或更多次,当它最终终止时,如果终止,它将产生,它将产生新商店,当3评估while循环。

当然,总是产生void值,然后会产生什么,嗯,对于整个循环,整个表达式的值为void,更新的商店为s3。

下一个有趣的表达式是let表达式,我回忆这看起来如何,所以让cool有一个声明类型的变量,一个初始化器,这是可选的,这是标识符将被初始化的值,然后是在新变量可用中的表达式,我们如何评估这个。

首先我们将评估新变量的初始值,所以我们评估e一,和往常一样,初始存储完成,可能产生修改后的存储,现在的问题是,我们将要做什么,这里的上下文是什么,对于e two的评估,对于let的主体。

显然它将涉及s one,因为它有e one的所有更新,我们如何做到这一点。

我们想要一个新环境,E但id绑定到新位置,我们引入一个新变量,记住环境要跟踪所有自由变量,这是扩展环境的情况之一,E与新绑定,新变量的位置必须是新位置,我们不想与正在使用的任何其他内存位置冲突,好的。

我们将为变量分配新的内存位置,然后存储,新存储也将类似于s一,如我们所说,它必须包含s一的所有值,但还将有这个新变量的新位置,它将具有变量的初始值。

V一,为了表达我们需要新位置,我们将引入存储上的新操作,它将给我们一个新鲜位置,所以new lo应用于存储,它只是将给我们存储未使用的某个位置,所以存储有一个域,它是从位置到值的映射。

我们只需选择一些位置,该位置不在存储当前列表中的位置,那将是返回的,或那将是new look返回的,好的,所以new look你可以看作是运行时系统中内存分配函数的模型,所以然后我们可以写出规则。

这是迄今为止我们看到的最复杂的规则,所以我会花一点时间走一遍,好吧,所以首先我们评估,E一,新变量的初始化器,好的,所以就像以前一样,这将在整个表达式的相同上下文中进行。

这将给我们一个值free一和一个更新的存储,好吧,然后使用更新的存储在这里,我们找到一个未使用的位置l new,好的,然后我们将创建一个存储,其中该新位置有,其中它有e一的值。

所以我们将e一的值存储在该新位置,我们将更新存储s一以反映这一点,并且进一步扩展我们的环境与新标识符,它将存储在这个新位置,这是上下文,然后好的,更新环境与存储,评估let体。

将产生值v2和可能更新的存储s2,这些是整体表达式的结果。

P71:p71 13-04-_Cool_Semantics_I - 加加zero - BV1Mb42177J7

本视频中,我们将继续讨论Cole的操作语义,将查看Cool中最复杂的两个操作,新对象的分配和动态分发。

首先非正式讨论新对象分配时发生的事,首先必须为对象分配空间,本质上意味着为对象的属性留有足够空间,将分配位置,给类t对象的每个属性,如果我们分配的是新的t对象,将对象的属性设置为默认值,稍后说明默认值。

以及为何需要设置默认属性,然后评估初始化器,类声明中的每个属性都可以有初始化表达式,将评估这些并设置结果属性值,然后返回新分配的对象,这是设置新对象涉及的步骤,如您所见,不仅仅是分配一点内存。

实际上在进行相当多的计算,嗯,在Cool中分配新对象。

每个类都与该类关联有一个默认值,对于整数,默认值为零对于布尔值,默认值为布尔值false,对于字符串,默认值为空字符串,然后对于不是这三个基本类的任何其他类,对于任何其他类,默认值为void。

在操作规则中,我们需要一种方式引用类的属性,因此我们将定义一个名为class的函数,它接受类名,并返回该类的属性列表,这里是类A的所有属性,假设它们是a1到an,此外,此函数还将告诉我们每个属性。

声明的属性类型,初始化属性的表达式,此列表的另一个重要特征是它包括类A的所有属性,包括继承的,还有一个细节是这些属性出现的顺序,这实际上将变得重要,当我们定义属性初始化的语义时,规则是属性按最远祖先。

首先顺序列出,好的,我指的是什么,假设我们有三个类,嗯,A、B和C,以及A,抱歉,B继承自A,C继承,自B,好的,假设A定义了两个属性a1和a2,B定义了两个属性b1,B2,C定义了两个属性c1和c2。

那么C类的,我们将按以下顺序列出属性,首先将是a1,然后是a2,因为A是最早的祖先,好的,它是,嗯,对象层次结构中离根最近的,类A或任何类中的属性总是按,它们在文本中出现的顺序列出。

所以首先出现的是a1和a2,当然,这两个属性的类型和初始化器也在这里列出,但我们只关注信息出现的顺序,所以接下来将是类B,所以类B的属性将是下一个,当然,这些属性的类型和初始化器也将列出。

然后最后将是类C的属性,再次按它们在类定义中列出的顺序,好的,这定义了任何类的属性的顺序,它总是从最早的祖先,嗯,沿着继承链,嗯,到类。

嗯本身,它是类函数的参数,现在我们可以准备正式定义new t的语义了,让我换个颜色,所以我们要,为类型t分配一个新对象,它将在具有self对象s的上下文中,零环境e和存储s,我们首先要做的就是弄清楚。

我们实际上要分配的是什么类型的对象,唯一的问题是t是否为self类型,因为记住self类型不是实际类的名称,如果t不是self类型,接下来将分配的类实际上是,T实际上是一个类名,并且该对象将分配的类型。

如果t是self类型,将要分配的对象类型,将是self对象动态类型的,因此,我们将查看self对象的动态类型,称为x,那将是我们要创建的类,那将是我们要创建的对象类型,好吧,有两种可能性。

如果对象分配类型为t的对象,t实际上是一个类名,否则,它是与self对象相同动态类型的对象,好吧,所以现在我们要查找,嗯,T零是什么类型,我们获取属性列表,类型和初始化程序为t零。

这告诉我们如何构建这种类型的对象,好吧,接下来我们要为每个属性分配位置,因为它们属于属性,我们将为每个属性分配一个位置,然后,我们将创建一个具有类标记t零的对象,属性将绑定到这些新位置,因此。

第八个属性将绑定到我们刚刚分配的新位置,然后我们要更新存储,好的,因此,我们将取我们的初始存储,不,这与我们开始的存储相同,我们将取s并更新它,因此,在这些新位置,这些位置持有每种属性的默认值,好的。

这给了我们存储s一,现在,嗯,我们必须评估初始化程序来实际初始化属性,并且我们必须考虑在这些属性初始化时的环境,并记住规则是,在属性的初始化程序中,类的所有属性都在范围内,好吧,在这种情况下。

初始化程序的环境将仅包括初始化程序,属性,对不起自己,好的,这些都是这些是属性名称,并且ice属性绑定到新的内存位置,该位置持有该属性的默认值,最初,好吧,最后,为了评估初始化程序。

我们只需将它们作为块按它们在类函数中出现的顺序评估,这就是为什么在类函数中指定顺序很重要,因此,请记住,这些属性包括所有继承的属性,首先评估最祖先属性的初始化,然后向下工作到类本身声明的属性。

注意这里的环境,包含所有属性和范围,这是一个有趣的观点,这个环境与新t实际评估的环境无关,你知道,这些环境e和e'是完全独立的,好的,所以new,所以e'在范围内有类的属性的名称,E是一个,你知道。

是其他一些环境,有一些函数,某个地方正在调用new t,那里的变量与这里的完全不同,好的,但无论如何,评估这个初始化块将产生一些值,和一个新的存储,该值未用于任何目的,好的,但新的存储是最终存储。

这是分配对象时得到的存储,那么new t的结果是什么,它是新的对象本身。

V,总结new的语义,嗯,注意前三个步骤分配对象,这些都是实际分配对象内存的事情,然后剩余的步骤通过评估一系列赋值来初始化对象,关于初始化可能最重要的事情,或其中最重要的事情之一是初始化器评估的上下文。

或初始化器评估的状态,所以注意只有属性在范围内,我们强调这一点,这与类型检查中的规则相同,所以当你在类型检查类声明时,只有属性在类的初始化器的范围内,你知道,对于类的初始化器,然后那是相同的,自然地。

当我们实际上在运行时评估初始化器时使用的相同的东西,属性的初始值是默认值,我们需要默认值,因为确切地说,属性在其自己的初始化器内部范围内,所以可能是,嗯,例如,有一个初始化器是完全合理和酷的。

比如说像这样,我将省略所有类型,只是为了,呃,节省时间,但可以给属性a,赋值为a,这完全没问题,因为初始化器的右边,所有属性和范围都有,这有意义的前提是,A必须有某种默认值,在计算初始化器之前。

我可能已经读过属性,注意初始化时,或,在对象的初始化中,self是对象本身。

是self对象,我指的是什么,我忘了在上张幻灯片上提到,快速回到那张幻灯片,注意在初始化器的评估中,上下文是什么,self对象是v,self对象是v,这是我们刚构造的新对象,所以对于。

e1到e n的初始化表达式,如果它们使用self,它们将指向,正在初始化的对象。

好的,回到这个,呃,总结,嗯,你知道,可能会有点惊讶,呃,Cool中new的语义,有多复杂,不仅仅是Cool具有这种属性,实际上,每个面向对象的语言,对新对象的初始化,都有相当复杂的语义。

这是继承等特性。

以及初始化器能够引用属性,导致的这种复杂性,现在让我们谈谈动态分派的语义,动态分派评估概述,然后看正式操作规则,评估分派时首先发生的事,将参数e1至e_n评估,接下来评估目标对象,E_0。

评估该表达式以获取实际分派对象,下一步,查看目标对象的动态类型,评估E_0后,查看其类标签,然后使用该类型确定应使用哪个函数,应使用哪个函数f,因此将查看方法表,对于类x和f的方法。

然后创建新的位置和环境,为调用设置,设置新的参数位置,用实际参数初始化这些位置,将self设为目标对象。

然后评估f的函数体,为了在类中查找方法,需要在操作规则中表示出类中存在哪些方法,因此将找到一个称为impulse的函数,代表实现,类A中方法f的实现,首先是一系列形式参数。

它将告诉我们f的形式参数是什么,然后是f的函数体,即f的函数体是什么,现在可以讨论方法分派在Cool中的正式操作语义细节。

再次切换颜色以作对比,所以首先评估n个参数,因此前n行处理这些,注意每个评估的参数都可能产生副作用,因此它从某个存储开始,但可能产生不同的存储,所以完成所有这些后。

我们评估了n个参数和一些存储s_sub,接下来评估E_0,这是我们要分派的表达式,这将给我们一个对象,V_0和一些更新的存储s_sub_n加1,好的,现在我们必须检查V_0。

我们想了解V_0内部是什么或V_0由什么组成,特别是我们感兴趣的是V_0的类标签,V_0的类标签,V_0的类标签,V_0的类标签,V_0的类标签,我们还将关注其属性内容,与其属性相关的位置。

但首先让我们关注类标签,好吗,因为我们将使用该类,记住这是v0的动态类型,这是程序运行时v0的实际对象类型,我们将使用该类查找应运行的f的定义,我们在类x中查找方法f,我们想了解其实现。

特别是我们获取形式参数的名称,好的,x1到xn,我们获取函数的主体,嗯或方法,好吧,因此我们接下来要做的就是,我们必须在内存或存储中,为方法调用的实际参数分配空间,所以我们分配新的位置,好的。

每个实际参数一个,现在我们可以构建一个环境,来评估方法,好吧,那么这个环境将包含什么,我们需要考虑在方法内部,范围内的名称,嗯,类的所有属性都在范围内,好的,这是一个具有属性a1到am的类x。

因此环境将具有这些名称定义a1到am,那么这些属性的实际位置是什么,嗯,这些是v0的位置,这是我们正在调度的对象,那将是self对象,属性名称将指向self的属性,好的。

所以这里的位置是v0对象中属性的位置,此外,方法体内的形式参数也在范围内,所以我们在包含属性的环境中添加,所有形式参数,好的,它们位于新的位置,l_x1到l_xn,好的,注意这个定义方式的一个细微差别。

我们正在取一个初始环境,我将在这里显示,我将用蓝色标记这些括号,所以我们正在定义属性的初始环境,然后我们在那上面进行更新,好的,我们不是在简单地定义x1映射到l(x1)。

我们正在替换x1在这个环境中的定义,用映射x1到l(x1)的另一个定义替换,我们为什么那样做呢?问题是,一个方法可能有一个形式参数,它与属性名相同,例如,我可以有一个类A,它有一个属性,小a在里面。

它还有一个方法f,它接受一个,呃,一个名为a的形式参数,好的,如果我这样做,当然,我忽略了类型和其他许多东西,所以这里有一个名为a的属性被声明,然后有一个方法,它接受一个名为a的参数,现在的问题是。

当我在这个方法的体内引用a时,我得到哪个a,是这个a吗?这个a绑定到形式参数吗?它是绑定到属性吗?我们必须给出一个答案,一个或另一个,Cool的答案是,它绑定到隐藏外层名称的形式参数,好的。

这就是这些更新在这里强制执行的原因,因此,如果形式参数与属性之一具有相同名称,它将替换环境中的属性定义,好的,一旦我们设置了环境,我们需要设置我们的存储,存储有什么变化?嗯。

我们只需要在参数的位置存储每个参数的实际值,最后,我们准备好评估函数体,有趣的部分是执行该操作时的上下文,所以请注意,运行方法时的self对象,F是我们要调度的对象,好的,然后环境是e prime。

在新设置的环境中,再次注意,这是对上下文的完全改变,e prime,环境e prime与环境无关,E e prime完全从我们正在调用的方法的信息中构建,没有从方法的起源环境中借用任何东西。

从调用方法的地方,最后,所有这些都是在反映所有由评估参数执行的副作用,由评估e零执行的存储中完成的,完成的,反映所有由评估参数执行的副作用,由评估e零执行的存储,通过扩展商店与实际参数的位置。

因此我们评估方法的主体,我们得到一个值和一个更新的商店,这个值和商店是结果,嗯,整个的,嗯,动态分派的执行。

总结我们关于动态分派的讨论,方法的主体被调用,带有环境E,E定义了形式参数和self对象的属性,商店就像调用者的商店,除了它还有实际参数绑定到为形式参数分配的位置,注意规则中帧或激活记录的概念是隐式的。

我们实际上没有构建一个包含,你知道,所有值,所有参数和返回地址,所有这些东西在一起,这些信息没有收集在一个地方,它更抽象,我们实际上不必说,嗯,你知道,是否东西在栈上或在堆上分配,这是一个很好的特性。

允许我们可能有一个范围,可以实现正确语义的实现,现在我们没有做静态分派的语义,但非常相似,唯一的区别是,在查找将要分派的类的过程中,所以在静态分发中,你可能能够,你知道,你可以命名你想要分派的类。

所以有一条额外的线,嗯,来决定被调用的类的正式规则,你可以查看手册了解它是如何工作的。

值得指出的是,虽然操作规则非常详细,嗯,它们有意省略了一些你认为它们应该覆盖的案例,让我们再次看一下我们的分发示例,所以嗯,这里注意我们查找v零的类,所以v零是一个对象,我们检查它的类标签。

然后我们在那个类中,查找我们正在分派的方法的名称,我们得到了方法定义,或者,你已了解方法定义,现在可写其余规则,若类x无方法f,会发生什么,此规则假设方法f确实在类x中定义,规则未说明应如何处理。

若类x无方法f,实际上不会发生,类型检查已确保,查找类x中的方法f时,它一定存在,类型检查规则之一,即动态分发不能指向未定义的方法,类型检查已完成的事实,使我们能省略一些情况,有些检查我们不必做。

因为知道类型系统已有效完成它们,若没有类型检查,规则会更复杂,并需实际说明类型检查未覆盖的所有情况,即未类型化的情况,正确。

类型检查无法防止某些运行时错误,然而,在Cool中,嗯,有四种,嗯,分发空值,嗯,除以零,嗯,子字符串索引超出范围,或内存耗尽,尝试分配新对象,但没有足够空间,在这种情况下,执行必须优雅中断。

意味着带有错误消息,不仅仅是段错误,或其他硬性崩溃,手册中有一些指南,关于正确Cool实现应如何处理,这四个情况总结最后几段视频的内容。

操作语义规则非常精确和详细,如果你理解它们,那么你真正理解如何实现正确的Cool编译器,规则足够完整,细节足够,你完全可以按照规则去做而不会出错,只要你实现规则告诉你的,但你需要仔细阅读规则。

我会强调这一点,因为规则中实际上有很多内容,它们以某种方式编写,以达到某种效果,我指出了一些规则中的微妙之处,所以你知道,你真的需要研究规则才能理解它们的含义,并能正确地实施它们,这也是一种很好的方式。

深入了解这些规则实际上是一种很好的学习方式,关于编程语言设计中涉及的形式思维,以及编程语言具有语义意味着什么,以及实现某物正确性的含义,说了这么多,我应该说,大多数语言都没有明确的操作语义,有一些。

有一些实质性语言和相当现实的语言确实具有正式语义,但您熟悉的多数语言都没有,最后,呃,就作为一个评论,你知道,当可移植性很重要时,当您真正希望编写的软件在不同环境中表现完全相同时,所以你知道。

如果我取相同的程序并将其移动到不同的机器或不同的操作系统,并且我还想有一种保证,软件将表现得像它,像它一样,你知道,在旧机器上,或新旧环境中,那么我确实需要一些独立定义。

P72:p72 14-01-_Intermediate_Cod - 加加zero - BV1Mb42177J7

本视频中,将简要介绍中间代码及其编译器中的应用。

首先需要解答的问题是中间代码或中间语言是什么,和,顾名思义,中间语言就是如此,它是介于源语言和目标语言之间的语言,记住编译器的作用,编译器将用某种源语言编写的程序,翻译成某种目标语言,因此在这门课中。

例如,源语言常很酷,目标语言常是MIPS汇编代码,实际上,中间语言在这两者之间存在,使用中间语言的编译器首先将其源语言翻译成中间语言,然后稍后翻译中间,中间语言中的代码为目标语言,你可能会想。

为什么要让生活变得如此困难,为什么为什么为什么分两步做,如果可以一步完成,结果表明,对于许多目的,这个中间级别实际上非常有用,因为它提供了一个中间级别的抽象,特别是,中间语言可能比源语言包含更多的细节。

所以,例如,如果我们想要优化寄存器使用,嗯,你知道,像Cool这样的源语言,在源级别没有寄存器的概念,因此无法表达,你可能想用寄存器做的优化,所以一种中间语言,至少包含寄存器,将允许你谈论。

并编写算法尝试改进程序中寄存器的使用,另一方面,中间语言将比目标语言更少细节,例如,中间语言可能略高于特定机器特定指令集的级别,因此更容易重新定位,将中间层代码应用于多种机器,正因为没有,嗯。

特定机器的所有细节,经验表明,拥有中间语言实际上是个好主意,几乎所有编译器都有中间语言,实际上在实现中,一些编译器有不止一种,源语言与目标语言间,本课程余下部分仅考虑一种中间语言。

我们要看的中间语言将是高级汇编,如前一幻灯片所示,该语言将使用寄存器名,但数量不限,可以使用任意数量的寄存器,不必限于3个,2或64个寄存器,控制结构将很像汇编语言,特别是,将有明确的跳转和指令标签。

语言还将包含操作码,因此它们看起来像汇编语言级别的操作码,但其中一些操作码将是高级的,例如,我们可能有一个名为push和push的操作码,最终将翻译为特定目标机器的多个具体汇编语言指令。

在我们将看到的中间代码中,每条指令只有两种形式,它将要么是一个二元操作,要么是一个一元操作,并且始终在右侧的参数,在这种情况下,y和z将是寄存器或常数,它们也可以是立即值,嗯,这是一种非常。

非常常见的中间代码形式,广泛使用,它被广泛使用,实际上它有名字,它被称为三地址代码,因为每条指令最多有三个地址,最多两个参数,然后是一个目的地,现在要看到这段代码实际上很低级,注意到你知道。

涉及多个操作的高层表达式,将不得不翻译成只执行一次操作的指令序列,例如,如果我有一个表达式x等于,抱歉,x加y乘z,让我在这里放入括号以显示关联,因此乘法比加法更紧密地绑定,我们将不得不。

这种形式的中级语言不能直接编写,相反,我们不得不写成如下所示,我们首先必须计算y乘z,并将其分配给一个新的寄存器或临时变量,或你知道一个新的寄存器t1来持有中间值,然后我们可以使用t1来计算x加t1。

整个表达式的值,将存储在另一个寄存器中,强制你一次只使用一个操作的影响,基本上可以一次执行一个基本操作,结果必须存储在寄存器中,这给程序的每个子表达式命名,所以如果我回头看这里的这个表达式,我看到。

你知道,y乘以z是匿名的,在这个表达式x加y乘以z中,表达式y乘以z本身没有名字,通过像这样重写,实际上命名了中间结果,所以再次,总结一下这一点,编写复合表达式为一系列指令的后果。

每次只执行一个操作是每个中间值都将有自己的名字。

生成中间代码与生成汇编代码非常相似,我们不会详细讨论这一点,因为它非常相似,但我会为你简要概述,生成汇编代码和生成中间代码的主要区别,是我们可以在中间语言中使用任意数量的寄存器来存储中间结果。

生成中间代码,我们可以编写一个名为ien的中间代码生成函数,它接受两个参数,它接受我们要生成代码的表达式,以及结果应存储的寄存器,为了给你一个例子,这是我将做的唯一例子。

让我们看看为加法表达式生成中间代码,所以我想为e一,加上e二,并将结果存储在寄存器,T中,所以首先我要做的是,我将为子表达式生成代码,我需要一个地方来存储子表达式的结果。

所以我将为这些结果制作新的寄存器名称,我将为e一生成代码并将其存储在某个寄存器t一,我将为e二生成代码,并将结果存储在某个寄存器t二,然后我们可以计算总和,所以t将等于t一和t二的和。

注意这是一个三地址指令,我们在这里遵守规则,仅在我们的中间代码生成器中使用三地址指令,并且还注意到由于我们有无限数量的寄存器,这实际上导致生成中间代码非常简单,实际上它比为堆栈机器生成代码还要简单一点。

回忆一下在堆栈机器中,这里需保存栈上e一的中间结果,这涉及,你知道,不止一条指令来实际推入结果并调整栈指针,诸如此类,嗯,这里我们可以直接保存在寄存器中,然后使用那个寄存器名。

呃,稍后,这就是关于中间代码的全部,对于这门课,你应该能使用中间代码,在我们将使用的水平上,在讲座中,在未来的视频中,实际上我们会经常查看中间代码,特别是用它来表达某些优化。

你也应该能够编写简单的中间代码程序,你应该能够编写在中间代码上工作的算法,但我不期望你懂如何生成中间代码,因为我们不会进一步讨论,而且坦率地说,它没有引入任何新思想,它实际上只是代码生成的一种变体。

我们已经详细讨论过的想法。

P73:p73 14-02-_Optimization_Ove - 加加zero - BV1Mb42177J7

我们现在准备开始下一个主要话题,程序优化,在这段视频中,我们将简要概述优化原因和权衡,以及编译器决定实施哪种优化的权衡,嗯,对于编译器决定实施哪种优化。

优化是编译器讨论的最后一个阶段,嗯,让我们非常简要地回顾一下,嗯,编译器阶段,首先是词法分析,然后是解析,然后是语义分析,在那之后,嗯,我们讨论了代码生成,现在我们要讨论优化,好的。

实际上优化在代码生成之前进行,因为我们想在提交到机器码之前改进程序,但当然是我们讨论的最后一个,但只是想指出这里,优化通常位于语义分析和代码生成之间,在现代编译器中,这是大多数动作发生的地方。

它通常拥有最多的代码,也是编译器中最复杂的部分。

现在,一个非常基本的问题是何时执行优化,我们实际上有一些选择可以在抽象语法树上进行,一个很大的优势是它是机器无关的,但对于许多优化我们想这样做,结果表明抽象语法树将太高。

我们实际上无法表达我们想执行的优化,因为这些优化依赖于机器的低级细节,或我们生成代码的机器类型。

这些细节在抽象语法树中不存在,另一种可能性是在汇编语言上执行优化,这里的优势是机器的所有细节都暴露了,我们可以看到机器正在做的一切,我们可以谈论机器的所有资源,因此原则上。

任何我们想执行的优化都可以在汇编语言级别上表达,现在,在汇编语言上执行优化的缺点是它们依赖于机器,然后我们可能需要为每种新的架构重新实现我们的优化,正如我们在上一个视频中提到的,嗯。

另一种选择是使用中间语言,可能,如果设计得好,仍然与机器无关,意味着它可以,它可以略高于非常具体的,特定架构的细节,我的意思是,它仍然可以代表一大类机器,但与此同时,暴露足够的优化机会。

编译器可以很好地提高程序的性能。

我们将研究对中间语言优化的,由该语法给出的操作,因此在这种情况下,程序是一系列语句,语句由赋值组成,可以是简单的复制或一元或二元操作,我们可以从栈中推入和弹出东西,然后我们有几种不同的跳转。

我们有比较和跳转,我们比较两个寄存器的值,然后根据条件跳转到标签,我们有无条件跳转,最后有标签,跳转的目标,这里是寄存器名称,我们也可以在操作符的右侧使用立即值而不是寄存器,我们假设典型的操作符。

一些典型的操作符家族,如加、减、乘,等等。

现在优化通常针对语句组,最重要的和最有用的语句分组是基本块,因此,基本块是一系列指令,我们通常希望它是最长可能的序列指令,因此我们希望它是最大的,这个序列有两个属性,首先,除了可能的第一条指令外。

没有标签,并且在指令序列中没有任何跳转,除了可能的基本块的最后一指令外,基本块背后的想法。

以及我们要求这些两个属性的原因是因为它是保证流动的,执行是保证从块的第一个语句,到最后一个语句的,因此,基本块内的控制流是完全可预测的,一旦我们进入块,一旦我们从块的第一个语句开始,它可能有一个标签。

将有一系列语句必须全部执行,在我们到达最后一个语句之前,它可能是跳转到代码的其他部分的跳转,一旦我们到达这里。

一旦我们到达这个第一个声明,我们保证执行整个块,不会跳出去,没有跳出的方法,也没有方法跳入块内,你不能从程序的其他随机部分开始执行,比如从第二或第三条指令开始,进入块的唯一方式是通过第一条指令。

离开块的唯一方式是通过最后一条指令,这是一个基本块示例,为了展示基本块的有用性,让我们观察我们可以优化这段代码,好的,因为3总是在2之后执行,这条指令总是在这条指令之后执行。

我们可以将第三条指令改为w等于3乘以x,好的,因为我们可以看到t是2x加x或2倍的x,这里我们又在添加一个x,所以w实际上总是等于3倍的x,嗯,一个问题,这当然是一个正确的优化,并且完全正确。

因为语句2总是保证在语句3之前执行,我们可能还会问是否可以消除这个语句,一旦我们将其替换为3倍的x,你知道,也许我们不再需要这个赋值了,如果这是t唯一被使用的地方,如果t是一个临时值。

只是为了计算w的值,然后我们可以删除这个语句,这取决于程序的其他部分,我们必须知道,嗯,t是否在程序的其他地方有其他用途,嗯,程序的其他地方。

我们仅通过查看单个基本块无法看到,下一个重要的语句分组是控制流图,控制流图只是一个基本块的图,如果执行可以从块a的最后一指令传递到块b的第一指令,那么从块a到块b有一条边,所以本质上。

控制流图只是显示了控制流如何在块之间传递,当然,在块内没有有趣的控制流,我们知道基本块将从第一条指令执行到最后一条指令,所以控制流图是总结程序中有趣决策点的一种方式,的一种方式,在过程或其他代码中。

显示有趣控制流决策点,这是一个简单的控制流图,由两个基本块组成,第一个基本块在循环外,包含初始化代码,然后我们在循环中有一个基本块,基本块包含底部三个指令,有一个分支,或测试分支,我们退出或去其他地方。

或循环并再次执行循环体。

好的,方法体可表示为控制流图,我们使用总有一个特殊入口节点的惯例,控制流图有一个特殊起始节点,通常很明显,将是顶部列出的那个,然后会有一些返回节点或你可以从中返回的节点,你知道,如果有返回语句在过程内。

返回节点或退出点,过程将总是终结的。

意味着那些块不会有出边,优化目的是提高程序资源利用率,本课程目的,当我们谈论优化时,在我们的示例和视频中将讨论执行时间,我们谈论的是,将讨论使程序运行更快,这是人们最关心的。

因此大多数编译器都花很大力气使程序运行更快,但重要的是要认识到,还有许多其他资源可以优化,实际上,对于你能想到的任何资源,那里,很可能有一个编译器在努力优化它,在特定应用领域,例如。

我们可能关心的编译器是代码大小,我们可能关心发送的网络消息数量,其他通常优化的内存使用,磁盘访问,所以,数据库,例如,尽量减少访问硬盘和电源的次数,嗯,对于电池供电设备。

优化的一个重要方面是它不应该改变程序计算的内容,答案仍然必须相同,好的,因此我们可以提高程序的资源利用率,但我们不能改变程序将产生的内容。

现在对于像C和Cool这样的语言,以及你可能熟悉的其他语言,人们通常谈论3种优化粒度,一种是局部优化,这些是对基本块孤立的优化,这些是在单个基本块内发生的优化,然后是所谓的全局优化,这实际上被误命名了。

因为它不是整个程序的全局,人们所说的全局优化,意味着控制流图,它是整个函数的全局,对,所以全局优化适用于单个函数,并优化该函数的所有基本块,最后有跨过程优化,这些是跨越方法边界的优化,它们取多个函数。

并移动东西以尝试优化整个函数集合,许多编译器做一种,事实上几乎所有编译器都做一种,嗯,许多编译器今天做两种,但实际做三种的并不多,好的,所以你看,随着粒度的增加,编译器的数量在减少,嗯。

部分原因是这些优化更难实现,所以实现跨过程优化需要更多的工作,嗯,但也是因为很多收益在于,更局部的优化,稍微扩展一下最后一点。

实际上,虽然我们知道如何做许多许多优化,经常有意识地决定,不实现研究文献中已知的最先进的优化,这对我来说有点不幸,作为一个真正喜欢编译器的人,花了很多时间思考优化。

也许对于专业的编译器研究人员来说有点难以接受,人们并不总是想要实现最新最好的优化,但值得理解为什么可能不是这种情况,这本质上归结为软件工程,其中一些优化真的很难实现,我的意思是它们实现起来很复杂。

其中一些优化在编译时间上很昂贵,所以即使编译是离线的,不是程序运行的部分,你知道,程序员仍然需要等待,直到优化编译器完成编译,这些优化中的一些在编译时间上很昂贵,所以即使编译是离线的。

不是程序运行的部分,你知道,程序员仍然需要等待,直到优化编译器完成编译,优化程序可能需要几小时甚至几天,你知道这不一定很好,其中一些优化收益低,它们可能会改善程序,但可能只提高一点点,不幸的是。

文献中许多最复杂的优化具有所有这些特性,它们很复杂,它们需要很长时间运行,而且它们效果不大,因此并不奇怪,并非所有都能在生产编译器中实现,实际上,你知道,指出了优化真正的目标是。

我们真正想要的是最大收益最小成本,我们实际上在谈论成本效益比,因此优化在代码中花费一定量,复杂性,我的意思是,等待编译器运行,以及其好处,改进程序的程度必须足够大以证明这些成本。

P74:p74 14-03-_Local_Optimizati - 加加zero - BV1Mb42177J7

现在准备讨论实际程序优化,我们从局部优化开始。

局部优化是最简单的程序优化形式,因为它只关注优化单个基本块,仅一个基本块,特别地,无需担心复杂的控制流,我们不会查看整个方法或过程体。

让我们深入研究,并查看几个简单的局部优化,如果x是整型变量,从今往后我们假设x是整型,让我写下来,我们将在本幻灯片的示例中假设x是整型,那么语句x等于x加零,不会改变x的值,零是加法单位元。

我们只是将x赋予它当前的值,因此该语句实际上是无用的,可以从程序中删除,类似地,对于x等于x乘以一,乘以一不会改变x的值,因此该语句也可以删除,在这种情况下,这些是非常棒的优化。

因为我们实际上节省了整个指令,有些语句不能被删除,但可以被简化,一个简单的例子是,如果我们有x等于x乘以零,那么可以替换为赋值,x等于零,同样我们仍然有,我们仍然需要执行一个语句。

但这个语句可能执行得更快,因为它不涉及实际运行乘法运算符,它不涉及引用x的值,假设x是寄存器,实际上不花费任何东西,但你知道,这条指令可能比这条指令执行得更快,在许多机器上这不是事实,实际上。

这个赋值和这个赋值在右边将花费相同的时间,与左边的乘法相同,但正如我们将看到的,嗯,将一个常数赋给一个变量实际上将启用其他优化,所以这仍然是一个非常值得进行的转换,一个几乎肯定优化的例子是。

是用显式乘法替换幂运算符,将一个值提升到二次方,所以这里我们正在计算y的平方,这里我们替换成y乘y,为何这是个好主意,嗯,这个指数运算符很可能不是内置机器指令,可能最终会出现在我们生成的代码中。

是一个调用内置数学库的函数,这会涉及函数调用开销,然后会有一个通用循环,做正确的乘法次数,取决于指数是多少,若指数为2的特例,直接替换更高效,用显式乘法替换幂运算,替换操作类型的一个例子。

若乘数为2的幂,可用左移替换,这里乘8,相当于将x的二进制表示右移3位,嗯,那将你知道,实际上计算相同的事,甚至不必是2的幂,嗯,如果我们有乘以其他数字,那不是2的幂,可以用移位和减法组合替换,好的。

因此,可以用移位和算术操作替换乘法,更简单的算术操作,现在,最后两个这里,我要指出,你知道,现代机器上的有趣变换,通常,这不会导致任何加速,因为在现代机器上,整数乘法操作与其他单指令一样快。

这些实际上是重要优化,所有这些指令都是代数简化的例子,利用数学运算符特性,替换更复杂,嗯,指令或操作。

最重要的,和有用的局部优化,是在编译时而非运行时,计算操作结果,例如,假设我们有3地址指令,y和z都是常数,这些都是立即值,这些都是,你知道,指令中的字面量,实际上可以在编译时计算右边的结果。

并用对常数的赋值替换,例如,如果有指令x等于2,加2可以替换为x等于4的赋值,另一个例子是一个非常常见和重要的,如果条件语句的谓词仅由立即值组成,那么我们可以预计算该条件的结果,并决定条件的目标。

编译时决定下一个指令,在这种情况下,我们有这样一个谓词,它将评估为假,因为2不大于0,因此我们不会跳转,如果这个指令是,如果2大于0,它说这是一个保证为真的谓词,那么我们可以用跳转替换这个条件,好的。

这将成为无条件跳转,这类优化称为常量折叠,如我所说。

这是编译器执行的最常见和最重要的优化之一,现在有一种情况你应该注意,在这种情况下,常量折叠可能非常危险,这种情况实际上非常具有启发性,虽然它并不常见,我想提到它,因为它真正说明了。

程序优化和编程语言语义的微妙之处,那么这种危险的情况是什么,让我们考虑这个场景,我们有两台机器,我们有机器x和机器y,现在编译器正在机器x上运行,编译器正在产生代码,生成的代码。

这是编译器在这里产生的输出代码,嗯,将在机器y上运行,所以这是一个交叉编译器,好的,你在一个机器上运行编译器,但你为不同的机器生成代码,你为什么要这样做呢?你想要这样做的常见情况,这台机器是否很弱。

运行很慢,内存有限,可能电力有限,开发程序有益,甚至在更强大的机器上编译,许多嵌入式系统代码就是这样开发的,代码在强大的工作站开发,但实际上是为小型嵌入设备编译,将执行代码,现在问题来了,嗯。

如果x和y不同,考虑x和y是不同机器的情况,不同架构,我暗示过它们是,但它们不必是,我的意思是,我的意思是,可以在一种架构上编译,并在同一架构上运行相同代码,但有趣的情况是x和y是不同的架构。

考虑机器x中的情况,假设指令a等于1。5加3。7,好的,您希望将其常数折叠为a等于5。2,现在,问题是,如果您简单地在架构x上执行此浮点操作,舍入,您知道浮点,语义和架构x可能与架构y略有不同。

如果在架构y上直接执行,您可能会得到类似a点5,i等于5。19,浮点结果可能略有不同,取决于您是在这里还是这里执行该指令,这在常数折叠和交叉编译中很重要,因为某些算法确实依赖于浮点数字被非常一致地处理。

所以如果您以某种方式在这里进行舍入操作,您需要每次执行该操作时都这样做,您执行该特定操作,通过将计算从运行时,当它将在架构y上执行时回到编译器,最终执行架构x,您可以更改程序的结果。

那么交叉编译器如何实际处理这个问题,因此,想要小心处理这种事情的编译器,他们会将浮点数字表示为编译器内的字符串,并直接在字符串上执行明显的长形式加法和乘法除法操作,直接在字符串上执行所有浮点操作。

在编译器内保持完全精度,然后在生成的代码中产生精确浮点数字面量,然后让架构y决定如何四舍五入,好的,这是对浮点数进行常数折叠的非常谨慎的方法,如果您担心跨编译。

继续进行局部优化,另一个重要的一点是消除不可到达的基本块,什么是不可到达的基本块,这是没有被任何跳转或顺延的目标的基本块,所以如果我有一段代码永远无法执行,它们可能永远不会执行。

因为没有任何跳转跳转到那段代码的开头,并且它不是紧随在可以顺延到它的另一条指令之后,那么在那段代码中那个基本块就是不会被使用的。

它是不可到达的,可以从程序中删除,这有使代码变小的优点,显然,由于基本块是不可到达的,它不会以指令计数的方式对程序的执行成本做出贡献,所以这段代码永远不会执行,所以它并没有真正减缓代码的速度。

因为你知道,多余的指令正在被执行,但是使程序变小,实际上可以使它运行得更快,因为缓存效应,指令必须适合内存,就像数据一样,如果你使程序变小,它更容易适应内存,并且你可能增加程序的空间局部性。

一起使用的指令现在可能更接近,这可以使程序运行得更快。

在继续之前,我想说一两句关于为什么不可到达的基本块会出现,所以为什么一个头脑清醒的程序员会写一个程序,其中包含不会被执行的代码,实际上有好几种方式不可达代码可以出现,而且实际上相当常见。

所以这是一个重要的优化,消除不可达代码实际上相当重要,也许最常见的情况是代码实际上是参数化的,只在某些情况下编译和使用,所以在C中,你可能会看到一些像这样代码,嗯,如果调试。

那么你知道执行一些调试是预定义的常量,所以在C中你可以为字面量定义名称,所以你说一些像这样的事情,比如说,你可能将调试定义为0,因此你可能看到这样的程序代码,这实际上意味着这段代码相当于,如果0。

那么巴拉巴拉,巴拉,好吧,所以,当你编译时没有调试,你将调试定义为0,当你编译时带有调试,你会改变这行代码将调试定义为非零常数,所以在这种情况下当你编译时没有调试,会发生什么。

我们会看到此谓词保证为0常数,折叠将处理它,这将导致然后分支上的不可达基本块,然后该代码可以被删除,因此本质上,编译器能够通过优化器,并删除所有不会被使用的调试代码,因为你编译时没有调试。

不可达代码出现的另一个情况是使用库,因此非常频繁地,嗯,程序被编写为使用通用库,但程序可能只使用接口的一小部分,因此库可能提供100个方法来涵盖各种程序员感兴趣的情况。

但对于你的程序你可能只使用其中的3个方法,其余那些方法可能从最终二进制文件中删除以减小代码大小,最后,不可达基本块出现的另一种方式是其他优化的结果,因此我们将看到优化经常导致更多的优化。

并且可能是通过编译器对代码的其他重新排列,嗯,使一些基本块冗余并能够被删除。

现在一些优化更容易表达,如果每个寄存器只在赋值语句的左侧出现一次,这意味着如果每个寄存器最多被赋值一次,那么这些优化更容易讨论,因此我们将重写我们的中间代码始终为,以便它处于单赋值形式。

所以这称为单赋值形式,这意味着如果我们看到一个寄存器被重用,如这里,我们有对寄存器x的两个赋值,我们只需为这些赋值中的一个引入另一个寄存器名,所以在这种情况下我只是将第一个,嗯,x的使用。

x的定义改为新的寄存器b,将x替换为b的名字,现在我有满足单一赋值形式的等效代码,每个寄存器最多被赋值一次。

让我们看看依赖于单一赋值形式的优化,因此我们将假设基本块处于单一赋值形式,如果它们是这样,那么,我们将知道一个寄存器的定义,是该块中该寄存器的第一次使用,因此特别地,我们也在排除类似这样的事情。

所以可能像这样,x是红色的,然后稍后x被使用,好的,抱歉,x是红色的,然后稍后x被定义,所以我们不会允许这个,嗯,这里的寄存器必须重命名为其他东西,比如y,然后x稍后的使用重命名为y好吧。

因此我们将坚持,每当我们在基本块中有一个寄存器的定义时,那是该块中该寄存器的第一次使用好吧,如果这是真的,如果我们意味着,如果我们以那种形式放置东西,而且这很容易做到,正如我们所见。

那么当两个赋值具有相同的右侧时,它们保证计算相同的值,所以看看这里的例子,所以假设我们有一个赋值x等于y加c,然后稍后我们有一个另一个赋值,W等于y加z,我们说过,在任何基本块中只能对x进行一次赋值。

所以所有这些对齐的指令,它们不能对x进行赋值,它们也不能对y和z进行赋值,y和z已经有了它们的定义,所以y和z不能改变,这意味着x和w实际上计算相同的值,因此我们可以替换第二个计算。

y加z为我们已经有的它的名字,x好吧,这节省了我们重新计算值的工作,所以这被称为公共子表达式消除公共,这是一个相当长的名字子表达式,这是另一个更重要的编译器优化。

这实际上是一个令人惊讶地经常出现并节省大量工作的事情,如果,如果你执行此优化。

单一赋值形式的另一个用途是,如果我们看到块中的赋值w等于x,所以寄存器w只是从寄存器x复制而来,然后w的所有后续使用都可以替换为使用x,例如,这里我们有一个对b的赋值,然后有一个对a的复制a等于b。

然后在这里我们有一个在最后指令中的a的使用,嗯,最后指令中的a的使用可以用b的使用替换,这称为复制传播,好的,我们正在通过代码传播复制,并且仅此而已,注意这并没有对代码做出任何改进。

实际上仅与其他一些优化结合使用才有用,所以如果,例如,在这种情况下,在我们进行复制传播之后,如果这个指令可以被删除,如果a在代码的其他任何地方都没有被使用,那么现在这个指令可以被移除。

让我们做一个更复杂的例子,并使用我们迄今为止讨论过的优化,在一个稍大的代码片段上,所以我们从左边的这段代码开始,最终将得到右边的这段代码,这是如何工作的呢,首先,我们有一个复制传播,所以a被赋予值为5。

因此我们可以向前传播它,并用5替换稍后对a的使用,我应该说,当值被传播时,是一个常数而不是寄存器名称为常数传播而不是复制传播,但这是完全相同的事情,我们有一个单一的值在右侧被分配。

无论是寄存器名还是一个常数,并且我们正在用该寄存器名或常数替换后续指令中的使用,好的,所以一旦我们将a替换为5,现在我们可以做常数折叠,现在对于这个指令我们有两个常数参数。

所以2乘以5可以被替换为常数10,现在注意我们又有了一个对常数的寄存器赋值,因此我们可以向前传播该常数,我们可以用数字10替换后续对x的使用,现在我们有更多的机会进行常数折叠。

10加6可以被替换为值16,好吧现在我们有另一个值,这是一个常数赋值,抱歉,另一条指令,仅是将常数赋给寄存器,因此我们可以向前传播该常数,最终我们得到10乘以16,我看到这里,在我的最终示例中。

这里我没有费心传播10到x,但我们可以这样做,因此我们可以执行此优化,即x乘以16,如果我们不做传播,将等同于x左移4,或用10乘16替换,那会更好,最终t赋值为160。

回到一个想法,几页前我提过,假设基本块中有赋值,某个寄存器w赋值为右边的计算结果,但假设w寄存器名在程序中其他地方未使用,它没出现,不仅在这个基本块,但在程序的其他部分,此语句已死,可直接删除。

这里指它对程序结果无贡献,因为我们写入w的值从未被引用,计算w是浪费时间,因此可以删除该计算,这是一个简单例子,假设寄存器a在程序其他地方未使用,我们首先要做的是,这是我们的初始代码,首先。

我们将其转换为单赋值形式,因此,我已将此寄存器x重命名为寄存器b,好的,一旦我们完成,让我做,因此我们说关于b等于z加y和a等于b,然后,我们向前传播此副本,好吧,因此,我们现在已用b替换了a的使用。

这使我们达到了这种状态,这段代码所在位置,现在可以看到我们有一个赋值,A在后续指令中未使用,我们已说过A在基本块外未使用,因此可以将A=B删除。

最终得到较短的基本块,每个局部优化本身效果很小,其中一些优化,我展示的一些转换实际上不会加快程序运行,但它们本身并不改进程序,但通常优化会相互作用,因此执行一个优化将使另一个成为可能。

我们在几页前的例子中看到了这一点。

所以关于优化编译器的思考方式是,它有一个装满技巧的大袋子,它知道很多单个程序转换,当面临一个程序时,为了优化它将翻遍它的袋子,寻找适用于代码某一部分的优化,如果它找到一个它将做,优化,将进行转换。

然后它将重复,它将回到程序中再看一遍,看看是否有其他适用的优化,它将一直这样做,直到达到一个点,它知道的任何授权都不能应用于该程序。

接下来,我们将查看一个更大的例子,并尝试应用我们讨论过的优化,看看我们能走多远,当然,这个例子是为了说明我们讨论过的许多优化而构建的,首先我们可以做,有一些代数简化的机会。

因此我们可以将这里的平方替换为乘法,在这里我们有一个乘以二,我们可以将其替换为左移一位,接下来我们可以观察到有一些副本和常数,因此我们有一个对b的常数赋值和对c的复制赋值。

这些可以向前传播到b和c的使用,一旦我们完成了这一点,我们可以做常数折叠,所以在这里对e的赋值,操作数,移位的参数都是常数,因此那可以替换为一个赋值,e得到值为6。

接下来我们可以观察到有一个公共子表达式,我们可以消除它,因为a和d都有x乘以x的值,对d的赋值可以替换为一个复制,d现在得到a的值,现在我们有两次机会再次进行复制和常数传播。

对d的赋值和对e的赋值可以向前传播,最后我们可以做大量的死代码消除,假设嗯,这些值,b,C,D或e,在程序的其他任何地方都没有使用,这四句都可删除,这里实际有性能提升,我们实际在这,你知道。

保存整个指令,这是最好的节省,最终形式如此,注意a赋值为xx,f赋值为a+a,然后g赋值为6f,这并不如能快,实际上可做更多代数优化,注意f实际等于2a,可重排发现g等于12f,抱歉,抱歉。

12*a,好,f赋值可能成死代码,可从程序中删除,我认为编译器会找到,但相信当前编译器,许多不会发现最后。

程序重排。

P75:p75 14-04-_Peephole_Optimiz - 加加zero - BV1Mb42177J7

在这段视频中,我将谈论局部优化的变体,直接应用于汇编代码的称为窥孔优化。

基本思想是,而不是优化中间代码,我们可以直接在汇编代码上优化。

窥孔优化是其中一种技术,窥孔代表一段通常连续的指令,所以我们的程序,我们可以,可以将其视为一系列指令,我们的窥孔是该程序的一个窗口,所以如果我们的窥孔大小为四,我们可以想象自己通过一个小孔看着程序。

我们所能看到的就是四个指令的短序列,然后我们可以优化该序列,然后我们可以滑动窥孔以优化程序的不同部分,优化器将做的是,它将,你知道,盯着这个短的指令序列,如果它知道一个更好的序列。

你将用另一个序列替换它,然后它将重复这个,如我所说,你知道,应用其他转换可能对相同的或其他汇编程序部分。

所以窥孔优化通常被编写为替换规则,所以我们将指令窗口放在左边,所以将有一些指令序列,我们将知道一些其他指令序列,我们更喜欢在右边,所以如果我们看到左边的指令序列,那么我们将用右边的替换它,例如。

如果我从寄存器b移动到寄存器a,然后从寄存器a移动回寄存器b,那么第二个移动是多余的,它可以简单地被删除,因此我们可以将这个两个指令序列替换为一个指令序列,这将在没有可能跳转目标的情况下工作。

所以如果代码永远不会跳转到这条指令,那么这条指令可以被删除,另一个例子,如果我向寄存器a添加i,然后随后向寄存器a添加j,我可以做一个常数折叠优化,并将这两个加法合并为一个加法。

其中我将i加j的和添加到寄存器a。

所以许多,但并非全部,我们在上一视频中讨论的基本块优化可以转换为,也如窥孔优化,例如,若将零加至寄存器,然后存储至另一寄存器,那可替换为寄存器移动,若将值从同一寄存器移至自身,这就像自我赋值。

该指令可删除,替换为空指令序列,综合这两条指令为这两优化,抱歉,能否消除向寄存器添加零,首先,这个翻译成从a到a的移动,然后,从a到a的移动将被删除,正如这个小例子所示,就像本地优化一样。

人员优化需要反复应用以获得最大效果。

我希望这个简单的讨论已经向您表明,许多优化可以直接应用于汇编代码,嗯,直接对汇编代码,优化中间代码并没有真正的魔力,若用任何语言编写的程序,中间语言汇编语言,谈论该语言程序的转换有意义,以改善程序行为。

此时提及程序优化很重要,程序优化实为糟糕术语,编译器不产生最优代码,纯属偶然,若编译器能生成给定程序的最佳代码,实际上编译器所做的,他们有一系列优化,可改善程序行为,他们会尽可能多地改进。

程序优化实质就是程序改进,我们试图让程序更好。

P76:p76 15-01-_Dataflow_Analysi - 加加zero - BV1Mb42177J7

本视频中,我们将开始讨论全局程序优化,结果发现要讨论全局优化,还有另一个话题要处理,最初称为数据流分析。

让我们先回顾简单的基本块优化,特别是常量传播和死锥消除,这是一小段代码,我们注意到给x赋了一个常数,我们从局部优化讲座中知道,该常量赋值可向前传播,若基本块为单赋值形式,这特别容易做到。

若此处x的值未使用,程序中的任何其他地方,该语句为死代码,可删除,这是一个基本块的简单示例,嗯,合并常量传播和死代码消除。

这些优化可扩展至整个控制流图,现在我们有一个非平凡的,我记得控制流图是基本块的图,节点是基本块,边显示基本块间的控制转移,所以这第一个基本块有一个测试,和一个if语句,如果测试为真,进入不同的基本块。

如果测试为假,现在在这个控制流图中,观察到x被赋常数,然后下面有x的使用,实际上,在这种情况下,用常数三替换x的使用是安全的,就像我们在单个基本块中传播常数一样,嗯,我们至少在某些情况下。

也可以在整个控制流图中传播常数。

但实际上有些情况并不安全,嗯,传播常数,所以这里再次,让我们观察,我们有x的赋值,常数赋给x,下面我们使用x,但不能将此处的x替换为3,为什么不能呢,因为在这里我们有另一个x的赋值,x取值于。

这个例子的有趣之处在于,注意x只被赋予常数,因此x被赋予一个常数,这里x也被赋予一个常数,但这里的x值是未知的,我们不知道哪个常数将被赋予,因为如果我们从这条执行路径来,那么x将是四。

如果我们从这条路径来,x的值将是三,因此我们不能用这些值中的任何一个替换这里的x,这种情况下,将常数传播到x的使用不安全,那么问题就是,我们如何知道何时可以全局传播常数。

现在关于常数传播,结果有一个简单的标准,嗯,用常数k替换x的使用,我们得知道以下事实:到x使用的每条路径上,所以每条通往x使用的路径,x的最后赋值是x等于k,所有通往x的路径,这很合理,直观上我认为。

嗯,我们一定在那条路径上赋值了常数x,那实际上必须是每条路径上x的最后一次赋值。

让我们再次看看我们的例子,这里我会换个颜色,嗯,所以这里我们有赋值x等于三,这里我们使用x,现在我们需要做的是检查,以将x替换为3,每条到达x的路径,它到达x,沿该路径,x是正弦3,只有两条路径。

这条路径和这条路径,显然,这个赋值在这两条路径上都适用,因此,最后一条赋值在这两条路径上,在所有路径上都是,嗯,X等于3,因此可替换此X为3。

与这种X用法相反,示例中,路径上X赋值为3,我们有一条路径到达这里,实际上让我画整个路径,沿此路径,最后对X的赋值为4,因此不能将任何常量值传播到此X使用。

总体上,变量赋相同常量锁定所有路径的条件,使用该变量的难度并不容易检查,因为所有路径包括绕过循环的路径,以及通过条件的路径,如我们在示例中看到的,通过一系列技术实现这些条件,称为全局数据流分析。

专门用于检查此类条件,本质上,全局数据流分析被称为全局,因为它需要对整个控制流图进行分析。

暂时退一步,有许多全局优化任务,我们希望编译器执行常量传播,全局常量传播只是其中之一,结果发现所有这些全局优化问题具有一些共同特征,首先,优化始终依赖于知道某些属性,X在程序中的特定点。

所以我们想要知道一些非常局部的信息,例如,在程序的特定点,x是否保证是常数,好的,那是,那是常量传播的性质,然而,即使我们想了解一些局部事实,一些你知道特定于程序中某个点的事实。

证明这个事实需要知道整个程序,所以或至少整个控制流图,正如我们在常量传播的情况下看到的,要确定x在程序的特定点是否为常数,需要推理所有通往该语句的路径,那是一个全局属性,我一直在阅读所有可能的路径。

你知道,从方法入口点的路径,一直,你知道,通过循环和跨条件,到特定语句,好吧,总的来说,这是一个很难解决的问题,对于某些问题,解决它真的非常昂贵,正是这一点使我们得以拯救,总是可以保守,因此。

对于这些优化,如果我们想要知道某些属性,X那么我们真正想要知道的是x是否肯定正确,所以如果我们说属性为真,那么我们必须正确,我们不能犯错误,这就是为什么我们可以保守的原因,但说不知道总是可以的。

放弃并说没关系,我们不知道属性是否成立,在最坏情况下,我们就不优化了,如果我们不能建立条件,那意味着优化肯定正确,然后我们要安全行事,不做优化,所以有近似技术或不一定给出正确答案的技术是可以的。

只要我们总是正确,当我们说属性成立时,否则我们只是说不知道属性是否成立,总之,全局数据流分析是一种标准技术或技术家族,用于解决我们刚刚讨论的问题,全局常量传播是要求全局数据流分析的一个优化示例。

在接下来的几个视频中,我们将更详细地研究全局常量传播和另一种数据流分析。

P77:p77 15-02-_Constant_Propaga - 加加zero - BV1Mb42177J7

本视频中,我们将继续讨论全球数据流分析,详细看看全局常量传播如何工作。

首先,让我们回顾一下进行全局常量传播的条件,因此,用常数k替换变量x的使用,我们需要知道以下属性,到x使用的每条路径上,x的最后赋值为x=k,好的,再次,对于x使用的每条路径,这必须为真。

现在,只要该属性成立,就可以在任何一点执行全局常量传播,本视频中,我们将看看,为所有程序点计算单个变量x属性的情况,我们将取一个,我们将专注于一个变量x,并计算它在每个程序点是否为常数。

很容易将算法扩展为计算所有变量的属性,一种非常简单但效率低下的方法是,只需为方法体中的每个变量重复计算一次。

我们将以以下方式关联变量x在每个程序点上的值,让我们从最后一个开始,嗯,我们将给x分配这个特殊值,发音为top,如果x不是常数,因此,如果在程序的特定点无法确定x是否为常数,那么在那个点。

我们将说x是top,这将是我们的安全情况,说我们不知道x的值总是可以的,当我们说x的值为top时,我们实际上在说,我们不知道x是否为常数,在程序的这个点,x可以有任何值,对吧,另一种可能是。

我们将说x是某个常数c,好的,这是一个特定的常数,如果在程序点上说x是常数c,这意味着,实际上在那个程序点,我们相信,或者我们已证明x始终是该常数,现在有一个第三种可能性,可能不是立即直观的。

但正如我们将看到的,在全局常量传播算法中扮演着非常重要的角色,实际上,在所有全局数据流分析中,那就是bottom,好的,就这样,这个值读作bottom,直观上,这个想法是,那是top的反义词。

bottom的解释是此语句永不执行,当我们不知道语句是否执行时,我们会说x在那个点上的值为bottom,意味着我们不知道程序中的那个点是否可达,在那个点上x的值无关紧要,因为那个语句从不执行。

我们将给x分配这三种值之一,bottom,某个常数,或top。

让我们通过一个手动的例子开始,我们的目标是对于每个程序点决定x是否可以是常数,肯定不是常数,或者我们认为该语句可能永远不会执行,好的,执行将从控制流图的顶部开始,这是入口点,在执行开始之前。

我们不知道x的值,我没有对之前的基本块中的代码做出任何假设,因此为了安全起见,我会说在这个点上x有一个未知值,我们不知道x的值,它可以是任何东西,所以x等于top。

这是我们希望第一个基本块的入口具有的性质,现在,在赋值x等于三之后,这些指示我们谈论的是哪个点,所以在赋值x等于三之后,我们肯定会知道x是常数三,现在有一些值得指出的事情,即我们的程序点。

我们附加这些知识到的点,或这些事实是在语句之间,所以当我说x等于三在这个程序点时,我的意思是,在执行这个赋值之后,x等于三,但在这个条件语句的谓词执行之前,我知道x等于三,好的,程序点在语句之间。

每个语句前和后都有一个程序点,接下来发生的事情是这个条件分支,注意分支没有更新,x甚至没有引用x,所以在分支执行后,我们肯定会知道x在两个分支上仍然等于三,好的现在,做右分支,下一步赋值给y。

不影响x的值,所以赋值给y后,仍知x等于三,现在,看左分支,这里先赋值给y,不影响x的值,所以赋值给y后,我们知道x仍等于3,现在给x赋值,好的,所以在这个程序点之后,我们将知道x的值不同。

现在我们知道x等于4,好的,现在这条语句后我们知道x等于4,这条语句后我们知道x等于3,好的现在,那么在这条语句之前发生了什么我们知道了什么,好的,a等于2x,这里指出,之前和之后有程序点。

所以这里的程序点,在a赋值之前不同,与x等于4和y等于0之后的程序点,直观上,x等于4后,我们仍在左侧路径,知道x等于4,这里y等于0后,我们仍知道我们在此路上,X等于3,但在我们到达a等于2倍之前。

X我们不再知道从哪条路来,这是这两条路径的合并点,都导致此声明,关于x的值我们能说些什么,嗯,没有常数可分配给x,因为在一条路径上x是3,在另一条路径上是x是4,因此我们在这里必须说。

在赋值执行之前a等于,X,抱歉,X等于顶部,我们不知道x的值,另一种说法是,我们不知道x是常数,所以赋值后执行,不影响x的值,我们也会有x等于顶部。

现在注意一旦我们有全局常量信息,一旦我们知道每个程序点,x的状态如何,优化将非常容易,我们只需查看与语句相关的信息,然后会告诉我们当该语句执行时x是否为常数,如果x在那时是常数。

我们可以用常数替换x的使用,关键问题,当然,我们如何计算这些属性,我们手动做了一个例子,如何在系统化方式下,对任意控制流图计算x属性。

现在可讨论数据流分析算法,所有算法中有一基本原则,值得立即提及,复杂程序分析可表达为,相邻语句间信息变化简单规则组合,仅关注局部规则,并且,构建全局数据流分析的方法实际上。

通过仅查看单个语句及其邻居的规则组合。

规则背后的想法是将信息从一个语句推送到下一个,因此对于每个语句s,我们将计算x在之前和之后的值信息,记住,那是我们想要附加信息的程序点,因此我们将有一个名为c的函数,代表常数信息,c将接受三个参数。

它接受变量x的名称,它接受我们正在谈论的语句,程序中我们正在查看的具体语句,然后或进或出,这是区分x在s执行前的值,与s执行后的值。

我们将定义,一组传递函数,从一条语句到另一条语句传递信息,在常量传播的规则中,我们需要谈论一个语句及其前驱,因此,我们将说每个语句s都有一些立即前驱,P1到Pn,好吧,所以这些是引导到语句的一步的语句。

S,我们先做第一条规则,我们有语句s和其前驱语句集,P一,P二,P三,P四,我们感兴趣的情况是,假设x在这些前驱语句后为top,在某个前驱语句后,无所谓哪个,嗯,若x在先前点后为顶。

则x在s执行前须为顶,好的,这就是这条规则说的,若x的任何先前点为顶,则s的x输入也为顶,对吧,这有道理,它说,若不知x在某路径到s是否为常数,我们不知道x在s是否为常数,因为我们所知道的是。

执行已下达,特定来自特定前驱,因此我们无法预测s执行前x是否为常数,s执行,现在让我们看另一种情况,假设在某个前驱执行后x为常数c,另一个进程或不同处理器后,x为不同常数d,即d≠c,在s执行前。

关于x我们知道什么,我们不知道x必须是顶部,因为我们不知道s会是哪个常数,因为在运行时我们不知道哪条路径会到达我们,这是我们手动做的例子中看到的情况,另一种可能是所有前驱都同意,嗯,x的值可能是什么。

所以假设我们有,你知道这里的前驱,执行后,X已知为常数c,X已知为常数c,在此前驱后,还有另一种可能,假设此前驱后,仅知X为底部,好的,规则说,若X有底部属性,或所有前驱对X的可能常数一致。

在程序点之前,在s执行之前,我们知道x将获得,保证是常数c,想想就明白,为什么正确显而易见,首先显然,如果沿着x已知为常数c的路径,因为它们都一致,当我们到达s,X必定取值为c,底部情况如何。

记住那意味着什么,这意味着此语句从未执行,所以这里有一个前驱p,它从未执行,这意味着如果p从未执行,那么沿着这条路径从p到s就无法到达,所以唯一能到达s的路径是那些x已知为常量的路径,好吧。

这就是为什么在这种情况下可以说,如果控制,如果执行到达s,那么可以保证它在x为常数c的状态下到达,最后一种可能是,假设x对所有前驱都是底部,好的,那意味着什么,嗯,这意味着s的所有前驱从未执行。

它们都是不可达的,因此,如果x的所有前驱从未执行,那么s本身也永远不会执行,因此可以推断,在进入x时x为底部。

我们刚刚查看的前四条规则,将一个语句的输出与下一个语句的输入相关联,我们还需要规则,将一个语句的输入与该语句的输出相关联,因此,我们必须将信息从语句的输入推送到该语句的输出。

所以再一次,有许多情况,让我们先看看一个简单的,如果x在进入s的程序点之前为底部,那说明s从未从未到达,s从未执行,因此,x在s之后,在s之后也将为底部,因此,如果s之前的程序点从未到达。

那么s之后的程序点肯定也无法到达,另一种可能是,我们在语句中为x赋值为常数c,在这种情况下,语句的输出将等于c,好吧,因此,x在语句执行前的状态并不重要,执行完语句后,x将是常数c。

我应该说与之前的规则有冲突,好的,可能是x在语句执行前为底部,因此,规则六的优先级低于规则五,所以我们,如果我们能说x在语句执行后为底部,我们更愿意这样说,所以规则五将首先应用,然后如果规则五不适用。

所以,如果x是其他常数d或x等于top,然后我们会应用这条规则,并且我们得出结论,x之后将是常数c,这很有意义,如果x是d或x是top,这意味着,就我们所知,控制可以到达这个语句,然后我们在此所说的是。

嗯,在执行完此语句后,执行后,控制可到达此语句,X必定为常数c,另一种可能是我们对x进行了赋值,但右侧比常数更复杂,因此,此情况适用于除常数赋值外的所有情况,好的,因此。

这里的f仅代表比简单常数更复杂的表达式,在这种情况下,我们不知道值是什么,我们不会尝试猜测该计算的结果,我们将说x等于top x,执行此语句后,我们不知道x的值,再次,规则五优先,因此,如果规则五适用。

那么我们会应用规则五,而不是规则七,但如果控制能到达此语句,即上面,x等于某个常数,C或x等于top,那么我们将应用规则七,并得出x在语句后为top的结论,最后,规则八,嗯。

另一种可能是我们赋值给的不是x,在这种情况下,如果x等于某个值,K语句之前,我们就保持这个值,好的,所以无论x在语句底部之前是什么,常数或顶部,如果赋值给的不是下一个变量。

下一个将在语句执行后具有相同的属性。

现在我们可以将规则整合成算法,嗯,对于每个入口点,对于程序的每个入口语句,我们将说在进入时我们不知道x的值,所以在那个入口点之前,我们将说x有一个未知值top,然后其他地方我们将说x的值是bottom。

好的,这实际上很重要,所以我们正在做的是直观上,它在说,好吧,就我们所知,除了程序的入口点,肯定可以执行,我们不知道控制流图中其他任何语句是否实际上被执行,因此,我们最初将假设它们不是。

我们只是说x的值是底部,除了入口点之外,现在我们要做的是一种约束满足算法,我们将选择一些语句,不满足1至8规则之一,然后使用适当规则更新,将在控制流图中查找,根据规则信息不一致的地方。

然后更新信息以符合规则。

再看下示例,开始时x等于入口处的top,然后有其他所有程序点,我在这里标出,好的,这些都是我们关心的其他程序点,再次,每个语句前和后都有程序点,嗯,我们将所有这些设为bottom,这意味着到目前为止。

控制未到达任何这些点,我们尚未证明这些语句可以执行,现在我们在程序中四处看看,并尝试找到信息与规则不一致的地方,然后更新信息,所以让我换个颜色,开始时,信息实际上到处一致,除了第一个语句。

因为如果x之前是top,而我们给x赋值为3,那么结果不应该是x等于bottom,实际上应该是x等于3,应该是这里适当的信息,一旦我们更新了它,然后我们可以看到下一个语句不一致。

因为现在我们知道这个语句可达,我们有一个语句,并得出结论点之后不可达,这与规则不符,我认为这是规则8的应用,这里有一个不引用x的语句,所以x之前的值在语句后仍然是x的值,所以这变成x等于3。

然后现在我们可以看到这条信息不一致,语句结束处的i与结束处的i不一致,在这种情况下只有一个前驱,所以这些值应该相同,所以x在这个点应该是3,实际上也应该是3,这里有一个对变量的赋值。

下一个的信息应该前后相同,同样的事情现在有一个赋值,X,该赋值前的点是可达的,因为这是常量赋值,嗯,应知x赋值后为常数,这里又有进出的问题,此语句输出与结束不一致,这需要更新,但现在这应是什么?

我们有不一致的先行者,因此这必须是top,然后是x的赋值,抱歉,对非x变量的赋值,信息应仅传播,同样更新如下,现在x已知为top之后,现在查看所有程序点,信息一致,所有规则,如果你,如果你,如果你检查。

语句前后的信息或跨语句,抱歉,或先行者和后继者之间是否正确,根据规则,到处都正确。