指令与指令集

到目前为止, 虽然 CPU 已经拥有了算术逻辑单元 ALU 进行算术逻辑运算和使用寄存器存储数据, 但它仍然缺少一些正常工作时需要的信息. 就像建筑工人无法仅仅依靠材料和工具凭空造出一栋高楼大厦, 除非建筑师能为其提供详细的建筑图纸. CPU 也是一样的, 开发者需要通过某种方式告诉 CPU 下一步的工作内容, CPU 才能有序的展开工作.

什么是指令

类似人与人之间的交流所使用的语言, 开发者与 CPU 进行交流使用的语言被称为"机器语言". 现实世界的英语由各种单词构成, 并有其固定的语法规则, 机器语言也是一样的, 机器语言所限定的可以使用的"单词"及其语法规则被称为"指令集".

顾名思义, 指令集是一组指令的集合, 指令则是 CPU 进行操作的最小单元. 指令集是计算机体系结构中与程序设计有关的部分, 包含了基本数据类型, 指令集, 寄存器, 寻址模式, 存储体系, 中断, 异常处理以及外部 I/O. 指令集包含一系列的操作码(Operation Code, 缩写 OPCode), 以及由特定处理器执行的基本命令. 指令集是 CPU 具体硬件实现与其上层运行的软件之间的一层抽象, 它连接应用层与底层硬件, 使得应用层软件无需做任何修改便能运行在拥有相同指令集架构的不同处理器上. 指令集架构是区分不同 CPU 的主要依据, 就像 Intel 和 AMD 虽然分别推出了很多不同型号和性能的 CPU, 但它们仍然被统一称为 x86 架构.

push h
mov a,d
inx h
add h

上面这段代码摘抄自 Game Boy CPU 的测试用例, 它虽然内容不多, 但可以很好对指令进行阐释.

push h

将寄存器 H 中的值入栈. 栈(Stack)又称为堆栈或堆叠, 是计算机科学中的一种抽象数据类型, 只允许在有序的线性数据集合的一端(Top, 栈顶)进行加入数据(Push)和移除数据(Pop)的运算, 按照后进先出(LIFO, Last In First Out)的原理运作. 就 LR35902 CPU 本身来说, 它本身并没有"栈"这样的真实硬件结构, 它的栈是内存中的一块特殊的连续区域, 同时有一个编号为 SP 的寄存器记录当前的栈顶地址. 如下所示, 现在有一块初始化的栈结构, 栈深度为 10, 同时 SP 寄存器保存的值是 0x4000.

+-------------+
| 0x4009 0x00 |
| ...    0x00 |
| ...    0x00 |
| 0x4001 0x00 |
| 0x4000 0x00 | <--- SP
+-------------+

如果 CPU 此时执行到 push 0xff 操作, 则操作过后的栈结构为:

+-------------+
| 0x4009 0x00 |
| ...    0x00 |
| ...    0x00 |
| 0x4001 0x00 | <--- SP (SP 加 1)
| 0x4000 0xff |         (0x4000 现在保存为 0xff)
+-------------+

注意的是, 栈虽然特殊, 但它仍然是内存中的一块区域, 某些情况下仍然允许开发者无视栈的规则任意操作栈内任意元素的值或位置. 至于为什么要特意在指令集中加入栈指令, 有一些说法是称早期的 CPU 的寄存器数量十分有限(比如 LR35902 只有 8 个寄存器), 同时当时的编译器不够智能, 为程序中众多的变量自动最优的分配寄存器并不容易, 但在栈结构上却并不会有这个问题. 因此, 躲开寄存器分配的难题而使用栈结构就成了一个可行的选择. 同时有说法认为 JVM 采用栈结构的最主要原因之一就是不信任编译器的寄存器分配能力. 但是使用栈相关的指令并非没有任何代价, 从物理结构上来说, 寄存器与 CPU 是物理上直接连接的, 读取数据延时最短, 而栈却存在内存中, 因此现代编译器为了性能着想, 除了在函数调用情况下通常很少使用栈指令.

mov a,d

第二条指令非常容易理解: 将寄存器 D 中的值赋值(Move)到寄存器 A.

inx h

inx(Increment X index)将指定寄存器中的值加一. 此处对寄存器 H 内的值做加一操作, 并将结果回写到寄存器 H.

add h

最后一条汇编代码将寄存器 H 的值与寄存器 A 的值相加, 并将结果回写到寄存器 A. 与前几条汇编代码不同, 该行代码涉及算术逻辑运算, 因此需要 ALU 的介入. 寄存器 A 是一个特殊的寄存器, 也被称为累加器(Accumulator), 通常默认作为 ALU 的第一个操作数, 同时 ALU 的计算结果也默认保存到寄存器 A 中.

通过指令, 开发者便可以控制 CPU 的运行过程.

指令类型

不同的指令集包含不同的指令, 但大部分指令集的指令都可以分成如下几个大类:

  1. 数据与存储指令
    • 修改寄存器中的值, 如 inx. – 寄存器与寄存器, 寄存器与内存间的数据交换, 如 mov.
  2. 算术逻辑指令 – 算术指令, 如加减乘除. – 位运算指令. – 比较指令, 判断两个数据的相对大小关系. 不过通常该类指令可以用算术减法指令代替.
  3. 流程控制 – 分支, 跳跃至程序某地址并执行相应指令, 如 jump(跳跃) 指令. – 条件分支, 假设某一条件成立, 就跳到程序的另一个位置, 常见如 jumpiz(Jump If flag zero is 1, 如果 Zero 标志为 1, 则跳跃到指定地址). – 调用, 在跳到另一个位置之前, 将现在所运行的指令的下一个指令的位置存储起来, 作为子程序运行完返回的地址. 常见如 call 指令, 行为非常类似高级语言中的函数调用(从当前上下文进入函数, 执行完函数后, 返回刚才的上下文环境).

除此之外, 还有一些比较特殊的指令, 比如中断相关的指令, 硬件设备 Input/Output 相关的指令, 空指令(什么都不干的指令), 甚至是暂时关闭 CPU 的指令.

复杂指令集与精简指令集

指令集是 CPU 相关的, 不同指令集之间的差异非常明显. 比如 x86 指令集包含有上千个指令, 而 RISC-V 指令集最小可以只包含几十个指令. 这引出一个经典的问题: 指令集中的指令数量是越多越好还是越少越好?

  • x86 泛指一系列英特尔公司用于开发处理器的指令集架构, 这类处理器最早为 1978 年面市的 Intel 8086 CPU. 该系列较早期的处理器名称是以数字来表示, 例如 80x86. 由于以 86 作为结尾, 包括 Intel 8086, 80186, 80286, 80386 以及 80486, 因此其架构被称为 x86. 由于数字并不能作为注册商标, Intel 及其竞争者均在新一代处理器使用可注册的名称, 如 Pentium. 现时英特尔将其称为 IA-32, 全名为 Intel Architecture, 32-bit, 一般情形下指代 32 位的架构.
  • RISC-V(发音为"risk-five")是一个基于精简指令集(RISC)原则的开源指令集架构(ISA), 简易解释为开源软件运动相对应的一种"开源硬件". 该项目 2010 年始于加州大学柏克莱分校, 但许多贡献者是该大学以外的志愿者和行业工作者.

试想一下下面这个问题: 作为一个计算机使用者, 现需要计算机提供计算一个数的开根号的能力, 应该如何实现? 从 CPU 的角度来看的话, 有如下两条路可以走:

  1. 设计一个专门用于开根号的 ALU 电路, 并添加一个开根号指令到指令集中.
  2. 什么都不做.

第二个选项看起来有点奇怪, 但事实上是行得通的, 因为可以通过牛顿迭代法来实现开根号的逻辑.

double MySqrt(double n)
{
    double x = 1.0;             //设置初值
    double p = 1e-5;            //设置精度
    while(fabs(x*x - n) > p)
    {
        x = (x + n / x) / 2.0;
    }
    return x;
}

上面的代码片段是典型的使用牛顿迭代法求开根号的实现, 可以看出其只使用了一些基本算术逻辑运算(加, 减, 乘, 除, 比较). 区别于直接通过新增 ALU 和指令的硬件实现, 这种方式被称作为软件实现. 使用硬件实现的优点是性能好, 而缺点是使 CPU 与指令集变得复杂;使用软件实现的优缺点与前者正好相反. 基于这两种不同的理念, 现代有两种类型的指令集设计思维: CISC(Complex Instruction Set Computing, 复杂指令集运算)与 RISC(Reduced Instruction Set Computing, 精简指令集运算). CISC 典型的应用场景是微软的 Windows 与苹果的 OSX 操作系统, 而 RISC 却被广泛应用在移动端如 Android, IOS 甚至是已经退出历史舞台的 Windows Phone 上. 这两种 CPU 设计思路无关对错, 而在于对场景的取舍.

另外还有一个比较重要的话题, 这个话题就是浮点数. 绝大多数早期 CPU, 甚至是部分现代 CPU, 它都是不支持浮点数的. 如果要在这种 CPU 下进行浮点数运算, 通常也只能采用软件模拟的形式, 即所谓 soft-float. 以 gcc 编译器为例, 它提供了可选的配置项, 由开发者决定编译后的二进制代码是使用 hard-float 还是 soft-float. 常见的使用方式如下:

$ gcc -g -msoft-float -mno-sse -m64 -lsoft-fp

该命令可以强制在编译时使用 soft-float. 使用这种方式生成的二进制文件, 不会调用任何指令集中涉及浮点数的指令.