所有的高级语言,都会被计算机翻译为机器代码,然后再根据汇编代码生成可执行的机器代码。二进制的机器代码我们人类肯定是读不懂了,但是汇编代码还是可以简单了解一下的。CPU 的 PC、寄存器、缓存都是怎么工作的,计算机是如何寻址的等等问题,都可以通过汇编代码去了解
我们先来说一说 CPU 的组成框架,从结构上讲 CPU 分为以下部分:运算器、控制器、寄存器和缓存
其中运算器包含以下两类:
寄存器也包含以下两类:
运算器只能完成运算,而控制器用于控制着整个 CPU 的工作
缓存用于加快寄存器与内存的数据访问速度,又分一级缓存和二级缓存(Cache)甚至是三级缓存
一级缓存和二级缓存是为了缓解较快的 CPU 与较慢的存储器之间的矛盾而产生的,以及缓存通常集成在 CPU 内核,而二级缓存则是以 OnDie 或 OnBoard 的方式以较快于存储器的速度运行(比如一个四核 CPU,每个核都有自己的一级缓存,但是四个核公用一个二级缓存)
将高级语言编译成机器语言,计算机才可以根据自身的指令集来执行命令
机器执行的程序只是一个字节序列,是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。在 Linux 上,汇编代码文件的后缀名是 .s 文件,二进制的目标代码文件后缀为 .o。对汇编文件会调用汇编器以产生目标代码文件,之后再对目标代码文件调用链接器,为其中的函数调用找到匹配的函数的可执行代码的位置
为了读取上面的 .o 文件,计算机对于使用机器级代码的机器级编程而言,有两种重要抽象:
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个存储寄存器分别的类别:
对于操作数指示符,也即是目的操作数的概念。其作用是指出执行一个操作中要使用的源数据值,以及放置结果的目的位置。各种不同的操作数可以分为四种:
数组是特殊的数据形式,一般指很多相同类型的数据集合。数组的存在将标量数据聚集在一起形成更大数据类型。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 除外)
leaq 指令能执行加法和有限形式的乘法。相应的,以上运算操作指令也有针对字节 b、字 w、双字 l 和四字 q 的操作变种,除了 leaq 指令
传输数据需要使用数据传输指令,这些指令可以将数据从一个位置复制到另一个位置,一般有以下几种通用类(所有的数据传输指令都分单字、双字、四字与比特来做区分):
我们现在已经可以线性的操作数据了,对数据进行简单的加减乘除操作不成问题,那如何实现 if、while 这些判断与循环语句呢
我们在 AMD 指令集中使用条件码来控制。CPU 在整数寄存器之外,还维护一组单个位二字的条件码寄存器,描述了最近的算术或逻辑操作的属性。可通过检测条件寄存器来执行条件分支指令
重点看前4位[31:28]的NZCV,也就是:Negative, Zero, Carry, oVerflow。它们的值是由ALU(逻辑运算单元)在每一次运算后根据自己的运算结果更新的:
算术与逻辑运算当中的操作指令除了 leaq 指令不会改变任何条件码外(因为其用来进行地址计算),其余指令都会根据结果设置条件码
对于条件码的访问,一般不会直接读取,常用的使用方法有三种:
但是个人觉得条件码似乎不是必要的东西,原因是其占据指令集编码的开销与计算条件码的代价不足以 cover 简单分支预测性能的提升幅度,因此似乎最新的指令集已经废弃了这个东西
即使没有了条件码,控制指令执行顺序的思路还是不变的,根据某个 boolean 值来判断程序需要跳到内存中的哪个地方
栈是计算机运行过程中在内存里存在的一种数据,它物理意义上是真实存在的,并且它是连续的。栈后进先出的机构主要用于完成函数的调用这种功能,当某个函数运行时,它往上追溯的所有调用链中的过程,都是被挂起的
函数的实现有下面两个重要指令:
完成了调用返回,函数还有两个重要的属性,一是入参,二是返回值,那么这两个值到底是怎么处理的呢
x86-64中大部分过程间的数据传送时通过寄存器实现的,但是寄存器最多只能传递6个整数型(整数和指针)参数,寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递数据类型的大小,同时传输的顺序也会决定寄存器的不同,如下图所示:
如果一个函数有6个以上参数的话(说明你代码写的不好),超出6个的部分就要通过栈来传递。这就引出了一个存储数据有关的概念:栈帧