看懂编译原理:前端&后端编译器做了什么?

2023-12-04 10:23:50 浏览数 (1)

铺垫

先铺垫几个计算机的基础知识:

  • L1中的数据区和指令区:内存和cpu交互数据通过数据总线(地址通过地址总线),而因为物理距离离的远 cpu运行速度快内存给的指令和数据却慢几拍,解决方案就是在cpu的高速缓存l1中存放预读取的指令lL也保存数据 为了避免冲突因此在高速缓存中区分了指令区和数据区;需要注意的是L2,L3不保存指令,也没有必要哈哈)
  • *指令如何读取的? *:cpu把指令地址寄存器的值(下一个要执行的指令)通过地址总线告知内存准备好对应地址的数据,内存准备好后(查找对应内存地址存储的内容可能是指令也可能是数据)通过数据总线把内容给到cpu
  • 为什么在条件跳转语句后面要加上一个nop(代表让cpu空转一个时钟周期)?cpu有预执行指令的功能,如果在跳转指令处预执行了后面代码就不符合条件跳转的定义,因此nop空转是对cpu预读取指令执行的妥协

编译器后端的结果就是生成目标代码,如果目标是计算机那么目标代码就是汇编代码;如果目标是虚拟机,那么目标代码就是对应虚拟机的代码。

  • 不同ir的目的和用途不同。

ir就是中间代码形式,java字节码,llvm,ast都是ir

ast可以叫做前端ir,java字节码叫做虚拟机的ir。

IR的作用是什么?

ir的目的在于做成中间代码形式而不是最终汇编代码。对于后端来说意味着新出一个语言不需要关心编译器后端去适配不同机器平台的这部分工作量了。

也就不需要根据不同平台转换成汇编,只要转换成ir就行,有专门做ir转换的程序 只需要一次转换ir就好,ir转换其他不同目标平台的汇编机器码啥的是ir做的。

编译器后端将前端生成的ast转换为ir,然后转换为不同机器平台的汇编代码。

编译器前后端作用

编译器的后端是要把高级语言翻译成计算机理解的语言。

前端关注的是正确反映代码的静态结构(AST),后端关注让代码更好更快运作的动态结构(目标代码)

后端更加强调运行性能(比如公共表达式删除,寄存器优化,流水线,计算机并行能力,高速缓存等技术)

没有操作系统的时候,使用的内存地址就是物理内存要管理好自己使用的内存;但是操作系统出现后,操作系统会给程序分配一段虚拟的内存空间,64位的机器所能表示的所有内存地址叫做寻址空间(64位的寻址空间是2的六十四次方好几个t),当程序使用内存的时候操作系统会将虚拟地址映射到真实的物理地址上(可能一块物理地址被多个进程共享 共享资源真实物理内存保存一份即可),对于物理内存上不常用的内存数据操作系统会写到磁盘上腾出更多的物理空间当需要这块数据时再从磁盘写回

不同后端编译器的内存管理机制有什么不同?

不同的编译器对于内存管理机制模式也有不同,不过大多数语言会采用一些通用的内存管理模式:

  • 代码区:最低的地址区域,存放编译后的机器码

一般来说是只读的,不过现代语言越来越动态化,这块内存在运行时也可以将中间代码转换成机器码存放

  • 静态数据区:保存程序中全局的变量和常量

这些数据的地址在编译期就可以确定,生存期从程序开始到程序结束

  • 堆:存放生存期较长的数据,比如方法里面创建后返回的对象
  • 栈(高地址向低地址延伸):存放生存期短的数据,比如函数和方法里面的本地变量
  • 环境变量
  • 内核空间

栈的结构

先是存储返回值(占位符),然后是返回地址(执行完该函数返回原函数继续执行),最后才是函数参数。

为什么这样做:是因为这样先清除的就是函数参数而不是返回值,如果先把参数压栈再把返回值压栈,那么清除空间的时候先清除的就是返回值而返回值一会还要用,所以不能这样做。而是把参数返回值调换位置。

方法栈调用结束后会回收里面的所有数据吗?哪些需要回收哪些不需要?不需要回收的放在哪里?

方法栈调用完后按理来说操作系统应该回收这部分物理内存让其他程序使用,但是比如返回值这种数据 虽然栈帧结束了但是 程序依然要访问

所以调用约定规定程序的栈顶之外仍然会有一小部分内存(比如128kb)是程序可以继续访问的,这部分内存可以存放返回值,并且这部分内存操作系统不会回收

后端编译器生成栈调用的汇编码

如何让利用rbp,rsp实现函数入栈出栈的汇编码

背景信息:约定表示 rbp寄存存放的是 栈底 的值,rsp存放的是 栈顶的值。栈是高地址向低地址增长的,栈顶是低地址,栈底是高地址。

一句话总结:函数入栈出栈操作的就是rbp和rsp这两个寄存器的值,入栈rsp增长rbp设置为rsp,保存原rbp的值;出栈rsp设置为rbp,rbp恢复

函数入站汇编码思路

  1. 把原函数栈顶的地址放入新函数栈的栈底 返回地址入站(也就是保存调用处的地址放入新函数的栈底,因为新函数调用完毕后会 销毁栈帧 这个顺序从低地址往上销毁高地址,因此返回地址应该最后一个被销毁也就是高地址也就是栈底)

保存旧函数的rbp值(后续恢复原函数rbp)

2.rsp增长(这部分空间保存内存中新加入的栈帧)

函数入站后rbp,rsp寄存器的值变化:rsp的值增长向低地址空间扩展,rbp的值是上一个rsp的值

思路可能没有那么直观,其实很简单,就是保存调用处的地址因为你调用完方法之后还有恢复,然后申请新的内存空间保存新的栈帧,所以rsp会增长(这里增长是-多少个字节,高地址向低地址延伸)

函数出栈汇编码思路

  1. 将现在rsp的值设置为rbp的值(恢复原函数栈顶的值为新函数栈底的值)

2.之前保存的rbp恢复

3.pop把之前保存的调用处地址拿到然后跳转到对应地址执行。

函数出站rbp,rsp的值变化:

同样这个地方就更简单了,就是一个字 恢复,把原函数的rbp设置为之前保存的值,rsp设置为现在rbp的值。

关于参数传递在汇编码中的实现方式

默认情况下 参数传递是通过寄存器来传递,x86-64架构规定 六个以内的参数传递都是通过寄存器超过六个用栈来传递(超过的参数在栈中倒序存放,先入站参数8,再入站7这样)

注意点:不是在新栈的内存空间内而是在新栈和调用栈中间的内存部分

操作参数汇编码的方式:间接地址访问或者直接使用寄存器

上面的实现方式已经介绍过了,因此对于操作六个以外的数据需要利用栈来操作,而这些数据是有顺序的所以需要偏移量拿到这些数据。

操作参数的汇编码:操作六个以内的参数是通过%edi使用寄存器的语法,操作六个以外的参数是通过间接地址访问的,新栈的rbp地址加上数据类型字节x参数个数(不严谨,只代表其余参数是存储在rbp栈底的上面内存空间中)

******

后端工作流

后端编译器转换ast为汇编:识别ast语义信息(此处上下文信息越多,后面生成的汇编码效率越高,不需要额外推断)进行标签类型匹配,然后根据ast中对应语义信息携带的上下文生成汇编码。

编译器后端将高级语言转换成汇编代码,汇编器将汇编代码转换成二进制目标文件,链接器将汇编代码和二进制目标文件链接绑定到汇编代码中

典型的基于AST优化范例

  • 方法内部使用寄存器优化:识别方法参数转换为寄存器存储,使用六个以内的参数都是通过寄存器存取(计数参数使用的寄存器个数如果超过六个通过rbp偏移向上扩展存储)
  • 基于某个变量操作时,如果这个变量已经存在于寄存器中泽直接复用,而不是新申请寄存器空间存储。

链接器工作流程

将公用的逻辑和类库抽取成单独的二进制目标代码,在其他的上层语言代码中直接使用(只是定义用extern关键字代表使用的是外部函数,当前模块不知道是否有这个函数,得等到所有模块和编译时携带的目标文件都编译完后才能知道是否有这个方法

因此汇编器在编译汇编码到二进制文件时,得等到所有模块都编译完*通过链接器链接模块中使用的具体的外部函数地址。 *当前文件不知道是否有这个函数,得把参数上带的所有二进制文件全部编译玩才能知道是否有,才会给使用的外部函数分配地址,才会进行链接,使用方才能正常使用

最终用的都是地址,地址在前期是不可知的因为还没有编译不知道存放在哪个地址,只有都编译玩后才能知道再替换

java的链接过程也是一样,符号只是代表使用某个标签,等对应标签的地址分配好时要替换到符号处,符号使用的时候才能跳转到正确的地址执行

汇编中访问数据的方式

关于数据表示的几种方式

  1. 立即数(通过$数字表示)比如$40代表数字40
  2. 寄存器表示(通过%寄存器名字表示)比如%rbp,表示的是当前rbp寄存器中存储的值
  3. 直接内存访问(普通数字代表的就是内存地址,如果是数字是第一种表示方法)
  4. 间接内存访问

定义:带有括号的表示,比如(%rbp)代表的就是rbp寄存器中存放的值指向的地址(也就是说这个寄存器中存放的是内存地址)。间接访问就是基于这个寄存器中存放的值进行偏移。

比如4(%rbp)这个意思就是rbp中存放的地址加4个字节的地址的值。同样也是基于基址进行增长减小。

常见的表示公式:(基址,索引值,字节数)

最终的地址公式是这么计算的:基址 索引值*偏移量 字节数。

比如(%rbp,%rsp, 4)这个地址没有偏移量,因此是基于rbp的值(存放的是内存地址)增长4个rsp(这个存放的是索引值,123)的值。rbp rap*4。很明显这是一个数组中的值

我正在参与2023腾讯技术创作特训营第四期有奖征文,快来和我瓜分大奖!

0 人点赞