程序的机器级表示

程序的机器级表示

  • CPU 的组成
  • 指令集
  • 数据格式
  • 计算机的各种操作
    • 访问信息的方式
    • 操作数据
    • 判断和循环如何实现
    • 运行时栈

所有的高级语言,都会被计算机翻译为机器代码,然后再根据汇编代码生成可执行的机器代码。二进制的机器代码我们人类肯定是读不懂了,但是汇编代码还是可以简单了解一下的。CPU 的 PC、寄存器、缓存都是怎么工作的,计算机是如何寻址的等等问题,都可以通过汇编代码去了解

CPU 的组成

我们先来说一说 CPU 的组成框架,从结构上讲 CPU 分为以下部分:运算器、控制器、寄存器和缓存

其中运算器包含以下两类:

  • 算术逻辑运算单元 ALU:ALU 主要完成对二进制数据的定点算术运算(加减乘除)、逻辑运算(与或非异或)以及移位操作。在某些 CPU 中还有专门用于处理移位操作的移位器。我们通常所说的 CPU 是32位或者64位的就是指 ALU 所能处理的数据的位数
  • 浮点运算单元 FPU:FPU 主要负责浮点运算和高精度整数运算(对,整数与浮点数是分开计算的)。有些 FPU 还具有向量运算的功能,另外一些则有专门的向量处理单元

寄存器也包含以下两类:

  • 通用寄存器组(整数寄存器或者浮点寄存器):通用寄存器组是一组最快的存储器,用来保存参加运算的操作数和中间结果
  • 专用寄存器:专用寄存器通常是一些状态寄存器,不能通过程序改变,由 CPU 自己控制,表明某种状态

运算器只能完成运算,而控制器用于控制着整个 CPU 的工作

  • 指令控制器(PC 程序计数器):又称指令指针,指令控制器是控制器中相当重要的部分,它要完成取指令、分析指令等操作,然后交给执行单元(ALU 或 FPU)来执行,同时还要获取下一条指令的地址
  • 时序控制器:时序控制器的作用是为每条指令按时间顺序提供控制信号。时序控制器包括时钟发生器和倍频定义单元,其中时钟发生器由石英晶体振荡器发出非常稳定的脉冲信号,就是 CPU 的主频;而倍频定义单元则定义了 CPU 主频是存储器频率(总线频率)的几倍
  • 总线控制器:总线控制器主要用于控制 CPU 的内外部总线,包括地址总线、数据总线、控制总线等等
  • 中断控制器:中断控制器用于控制各种各样的中断请求,并根据优先级的高低对中断请求进行排队,逐个交给 CPU 处理

缓存用于加快寄存器与内存的数据访问速度,又分一级缓存和二级缓存(Cache)甚至是三级缓存

一级缓存和二级缓存是为了缓解较快的 CPU 与较慢的存储器之间的矛盾而产生的,以及缓存通常集成在 CPU 内核,而二级缓存则是以 OnDie 或 OnBoard 的方式以较快于存储器的速度运行(比如一个四核 CPU,每个核都有自己的一级缓存,但是四个核公用一个二级缓存)

指令集

将高级语言编译成机器语言,计算机才可以根据自身的指令集来执行命令

机器执行的程序只是一个字节序列,是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。在 Linux 上,汇编代码文件的后缀名是 .s 文件,二进制的目标代码文件后缀为 .o。对汇编文件会调用汇编器以产生目标代码文件,之后再对目标代码文件调用链接器,为其中的函数调用找到匹配的函数的可执行代码的位置

为了读取上面的 .o 文件,计算机对于使用机器级代码的机器级编程而言,有两种重要抽象:

  • ISA:指令集体系结构或指令集架构,来定义机器级程序的格式和行为。它定义了处理器状态、指令的格式、以及每条指令对状态的影响
  • 虚拟地址:机器级程序使用的内存地址是虚拟地址,所提供的内存模型看起来像是一个非常大的字节数组

ISA 中,指令由操作码和操作数两部分组成。CPU 在设计好之后,其指令集就确定了(硬件厂商处理),CPU 对每条指令都规定了相应的机器码,不同寻址方式的指令,不同运算的指令,它们的机器码都不相同。CPU 刚开始读取指令时并不知道将会执行什么指令,它将指令地址发到地址总线,然后指令将逐字节地通过数据总线传送到 CPU 中,当 CPU 读取到指令中的操作码(前几个字节)时,就知道了当前指令的长度,于是就知道接下来应该读取多少字节的数据作为一条指令和下一条指令的位置

数据格式

数据格式方面,由于是 16bit 体系结构扩展至 32bit,Intel 用术语表示 16bit 数据类型。进而 32bit 称为双字,64bit 称为四字,字节、字、双字、四字分别对应的汇编代码后缀为 b,w,l,q,特别的 float 类型对应的是 s,而 double 对应的则是 l。他们的大小我们在学习 c 语言或者 java 时已经了解过了,就不再叙述了

大多数汇编代码都有这样一个后缀,表面操作数的大小,例如数据传输指令有四个变种,movb(传输字节)、movw(传输字)、movl(传输双字)、movq(传输四字),计算机知道了后面数字的大小,就知道什么位置是下一个指令的开头

我们可能想到一些常见的计算机处理文件大小的方法,例如 TCP 报文用差错校验、特殊的字符作为报文的开头和结尾,并且还有数字表示该报文有多长。CPU 没有采取这些方法,是因为 CPU 读取的指令多而且小,用特殊的字符以此来提高性能

计算机的各种操作

访问信息的方式

一个 x86_64 的中央处理器单元 CPU 包含一组16个存储64位值的通用目的寄存器,以用来存储整数数据和指针。16个寄存器的命名有历史原因,其中有一重要的寄存器为%rsp(用作栈指针,用来指明运行时栈的结束位置),%rax(返回值寄存器,ret 指令返回的即是%rax 寄存器中的值)

此外对于寄存器,有一组标准的变成规范控制着如何使用寄存器来管理栈、传递返回值,以及存储局部和临时数据。以下表示的是16个存储寄存器分别的类别:
程序的机器级表示_第1张图片

对于操作数指示符,也即是目的操作数的概念。其作用是指出执行一个操作中要使用的源数据值,以及放置结果的目的位置。各种不同的操作数可以分为四种:

  • 立即数:用来表示常数值
  • 寄存器数:表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为一个操作数。访问寄存器比访问内存要快得多
  • 内存引用:根据计算得到的地址(通常是有效的物理内存地址而不是虚拟内存地址)访问某个内存位置
  • 以上混合使用:我们可以使用立即数当作寄存器的指针,因此获取寄存器中的数据,也可以使用寄存器中的数据或者立即数当作指针,来访问内存中对应地址的数据

数组是特殊的数据形式,一般指很多相同类型的数据集合。数组的存在将标量数据聚集在一起形成更大数据类型。C 语言中,可以产生执行数组中元素的指针,即生成数组的索引,并对这些指针做加减乘除等运算,在机器代码中,这些指针会翻译成地址计算。其中,优化编译器非常善于简化数组索引所使用的地址计算

C 语言中,单操作数操作符 & 和 * 可以产生指针和间接引用指针。也即数组引用 A[i] 等价于表达式 *(A+i),它计算第i个数组元素的地址,然后访问这个内存位置

对于嵌套数组 int A[5][3],可以被看成一个5行3列的二维数组,用 A[0][0] 到 A[4][2] 来引用。数组元素在内存中按照行优先的顺序排列,意味着第0行的所有元素,可以写作 A[0]…,则将 A 看作一个有5个元素的数组,每个元素都是3个 int 的数组

操作数据

CPU 已经拿到了信息与存放该信息的位置,那么接下来会对其进行什么操作呢?我们可以对其进行的操作大致分为以下几类:

  • 对数据进行计算,获取更多数据
  • 传输数据,比如将一个数据从内存放到寄存器

一般地,计算操作指令用法为:指令 源操作数 目的操作数,可以对获取到的数据进行自增、自减、加减乘除等计算。常见的操作指令如下(大多数操作都分成了指令类,这些指令类有各种带不同大小操作数的变种,leaq 除外)

程序的机器级表示_第2张图片
leaq 指令能执行加法和有限形式的乘法。相应的,以上运算操作指令也有针对字节 b、字 w、双字 l 和四字 q 的操作变种,除了 leaq 指令

传输数据需要使用数据传输指令,这些指令可以将数据从一个位置复制到另一个位置,一般有以下几种通用类(所有的数据传输指令都分单字、双字、四字与比特来做区分):

  • mov 一般数据传送指令。比如 movb S D 所代表的含义就是将数据 S 复制到 D 的位置上
  • push 进栈指令,还记得上面的栈指针吗,调用 push S 指令即可将 %rsp 所指向的位置减去一到八,并且新的栈指向 S。堆栈的最大容量为 64K
  • pop 填出数据
  • xchg 数据交换指令,有的地方也叫 exchange 指令或者 swap 指令,可以原子性的将寄存器中的值与内存中的值互换。这个指令就是乐观锁在汇编语言层面的实现

程序的机器级表示_第3张图片

  • testAndSet 指令,简称 TS 指令,有些地方也称 TSL。用于对数据赋值,TSL 指令是用硬件实现的,执行过程不允许被中断,只能一气呵成,就是,简单来说就是先尝试将对应的字段上锁,然后返回原来的锁,如果用来上锁了,那不能对其进行操作,如果原来没上锁就可以进行操作

判断和循环如何实现

我们现在已经可以线性的操作数据了,对数据进行简单的加减乘除操作不成问题,那如何实现 if、while 这些判断与循环语句呢

我们在 AMD 指令集中使用条件码来控制。CPU 在整数寄存器之外,还维护一组单个位二字的条件码寄存器,描述了最近的算术或逻辑操作的属性。可通过检测条件寄存器来执行条件分支指令

在这里插入图片描述
重点看前4位[31:28]的NZCV,也就是:Negative, Zero, Carry, oVerflow。它们的值是由ALU(逻辑运算单元)在每一次运算后根据自己的运算结果更新的:

  • N = 1则上次运算结果为负,= 0则为正
  • Z = 1则上次运算结果为零,= 0则非零
  • C = 1则上次运算产生进位,= 0则没有进位
  • V = 1则上次运算产生溢出,= 0则没有溢出

算术与逻辑运算当中的操作指令除了 leaq 指令不会改变任何条件码外(因为其用来进行地址计算),其余指令都会根据结果设置条件码

对于条件码的访问,一般不会直接读取,常用的使用方法有三种:

  • 根据条件码的某种组合,将一个字节设置为0或1。将这一整类指令称为SET指令;它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。所设置的条件码字节作为跳转的依据
  • 可以条件跳转到程序的某个其它的部分。jump 指令会根据条件码的各种状态切换到程序中一个全新的位置,在汇编代码当中,跳转的全新位置通常用一个标号 label 指明。跳转又分为直接跳转和间接跳转;汇编当中的 if-else 语句的控制流与 C 语言当中的 goto 语句的实现相近
  • 可以有条件的传送数据。实现条件操作的传统方法是通过使用控制的条件转移,但是该方法在现代处理器上效率低下。该种方式计算一个条件操作的两种结果,再根据条件是否满足从中选取一个结果传送给返回值/目的寄存器。只有在受限制的情况下,该策略才可行

但是个人觉得条件码似乎不是必要的东西,原因是其占据指令集编码的开销与计算条件码的代价不足以 cover 简单分支预测性能的提升幅度,因此似乎最新的指令集已经废弃了这个东西

即使没有了条件码,控制指令执行顺序的思路还是不变的,根据某个 boolean 值来判断程序需要跳到内存中的哪个地方

运行时栈

栈是计算机运行过程中在内存里存在的一种数据,它物理意义上是真实存在的,并且它是连续的。栈后进先出的机构主要用于完成函数的调用这种功能,当某个函数运行时,它往上追溯的所有调用链中的过程,都是被挂起的

函数的实现有下面两个重要指令:

  • call:该指令用于调用一个函数。它主要完成了两部分工作,一是将 PC 程序计数器中的数据指向被调用函数的开头位置,二是将栈指针(16个存储寄存器之一的栈指针 %rsp)指向栈的下一字的位置,并且记录调用函数的位置,这么做让程序知道函数返回时需要跳到哪里
  • ret:返回指令与 call 相反,它也完成了两部分工作,一是将栈指针指向位置的值取出赋给程序计数器。二是将栈的位置向上指一字

完成了调用返回,函数还有两个重要的属性,一是入参,二是返回值,那么这两个值到底是怎么处理的呢

x86-64中大部分过程间的数据传送时通过寄存器实现的,但是寄存器最多只能传递6个整数型(整数和指针)参数,寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递数据类型的大小,同时传输的顺序也会决定寄存器的不同,如下图所示:
程序的机器级表示_第4张图片
如果一个函数有6个以上参数的话(说明你代码写的不好),超出6个的部分就要通过栈来传递。这就引出了一个存储数据有关的概念:栈帧

你可能感兴趣的:(计算机基础,java,开发语言)