2023-05-26    2024-11-08    2565 字  6 分钟

i.e. Instructions & Programs

上集我们把 ALU, 控制单元,RAM, 时钟 结合在一起,做了个基本,但可用的"中央处理单元", 简称 CPU ,它是计算机的核心。

我们已经用电路做了很多组件,这次我们给 CPU 一些指令来运行! CPU 之所以强大,是因为它是可编程的(programmable)- 如果写入不同指令,就会执行不同任务。CPU 是一块硬件,可以被软件控制!

我们重新看一下上集的简单程序,内存里有这些值,每个地址可以存 8 位数据。因为我们的 CPU 是假设的,这里前 4 位是"操作码",后 4 位指定一个内存地址,或寄存器,内存地址 0 是 0010 1110 ,前 4 位代表 LOAD_A 指令 - 意思是:把后 4 位指定的内存地址的值,放入寄存器 A ,后 4 位是 1110,十进制的 14 ,我们来把 0010 1110 看成 “LOAD_A 14” 指令,这样更好理解!也更方便说清楚!可以对内存里剩下的数也这样转换,这里,我们的程序只有 4 个指令,还有数字 3 和 14 。

![[assets/Pasted image 20230526123909.png]]

现在一步步看:

  • “LOAD_A 14” 是从地址 14 中拿到数字 3,放入寄存器 A ;
  • “LOAD_B 15” 是从地址 15 中拿到数字 14,放入寄存器 B ;
  • 下一个是 ADD 指令 - “ADD B A” 告诉 ALU 把寄存器 B 和寄存器 A 里的数字加起来,(B 和 A 的)顺序很重要,因为结果会存在第二个寄存器 - 也就是寄存器 A ;
  • 最后一条指令是 “STORE_A 13” ,把寄存器 A 的值存入内存地址 13 。

好棒!我们把 2 个数加在了一起!毕竟只有 4 个指令,也只能做这个了 。

加多一些指令吧!

![[assets/Pasted image 20230526123915.png|450]]

> 更多的指令

SUB 是减法,和 ADD 一样也要 2 个寄存器来操作。

还有 JUMP(跳转)- 让程序跳转到新位置,如果想改变指令顺序,或跳过一些指令,这个很实用。举例,JUMP 0 可以跳回开头。 JUMP 在底层的实现方式 - 是把指令后 4 位代表的内存地址的值覆盖掉 “指令地址寄存器” 里的值 。 还有一个特别版的 JUMP 叫 JUMP_NEGATIVE,它只在 ALU 的 “负数标志” 为真时,进行 JUMP ,第 5 集讲过,算术结果为负,“负数标志"才是真,结果不是负数时,“负数标志"为假。如果是假,JUMP_NEGATIVE 就不会执行,程序照常进行。

我们之前的例子程序,其实应该是这样,才能正确工作。否则跑完 STORE_A 13 之后, CPU 会不停运行下去,处理后面的 0 ,因为 0 不是操作码,所以电脑会崩掉 !

我还想指出一点,指令和数据都是存在同一个内存里的,它们在根本层面上毫无区别 - 都是二进制数 。HALT 很重要,能区分指令和数据。

好,现在用 JUMP 让程序更有趣一些,我们还把内存中 3 和 14 两个数字,改成 1 和 1 ,现在来从 CPU 的视角走一遍程序。

首先 LOAD_A 14,把 1 存入寄存器 A (因为地址 14 里的值是 1);然后 LOAD_B 15,把 1 存入寄存器 B (因为地址 15 里的值也是 1);然后 ADD B A 把寄存器 B 和 A 相加 结果放到寄存器 A 里,现在寄存器 A 的值是 2 (当然是以二进制存的);然后 STORE_A 13 指令,把寄存器 A 的值存入内存地址 13。

现在遇到 JUMP 2 指令 ,CPU 会把"指令地址寄存器"的值,现在是 4,改成 2 ,因此下一步不再是 HALT ,而是读内存地址 2 里的指令,也就是 ADD B A 。

我们跳转了!

寄存器 A 里是 2,寄存器 B 里是 1 ,1+2=3,寄存器 A 变成 3 ,存入内存 ,又碰到 JUMP 2,又回到 ADD B A 。 1+3=4 ,现在寄存器 A 是 4 。

发现了吗?每次循环都+1 ,不断增多 ,酷! 但没法结束啊 ,永远不会碰到 HALT ,总是会碰到 JUMP ,这叫无限循环 - 这个程序会永远跑下去。.. 下去。.. 下去。.. 下去。.. 为了停下来,我们需要有条件的 JUMP , 只有特定条件满足了,才执行 JUMP 。比如 JUMP NEGATIVE 就是条件跳转的一个例子,还有其他类型的条件跳转,比如、 JUMP IF EQUAL(如果相等)、JUMP IF GREATER(如果更大)。

现在把代码弄花哨一点,再过一遍代码。

就像之前,程序先把内存值放入寄存器 A 和 B 。寄存器 A 是 11,寄存器 B 是 5 ;SUB B A,用 A 减 B,11-5=6 ,6 存入寄存器 A ;JUMP NEGATIVE 出场,上一次 ALU 运算的结果是 6 ,是正数,所以 “负数标志” 是假 ,因此处理器不会执行 JUMP 。

![[assets/Pasted image 20230526123933.png]]

继续下一条指令 - JUMP 2 , JUMP 2 没有条件,直接执行!又回到寄存器 A-B,6-5=1 ,A 变成 1 ;

下一条指令又是 JUMP NEGATIVE ,因为 1 还是正数,因此 JUMP NEGATIVE 不会执行 ;来到下一条指令,JUMP 2 ,又来减一次 ,这次就不一样了 1-5=-4 ,这次 ALU 的 “负数标志” 是真,现在下一条指令, JUMP NEGATIVE 5,CPU 的执行跳到内存地址 5 ,跳出了无限循环!

现在的指令是 ADD B A,-4+5=1,1 存入寄存器 A ;下一条指令 STORE_A 13,把 A 的值存入内存地址 13 ,最后碰到 HALT 指令,停下来。

虽然程序只有 7 个指令,但 CPU 执行了 13 个指令,因为在内部循环了 2 次。

这些代码其实是算余数的,11 除 5 余 1 。

如果加多几行指令,我们还可以跟踪循环了多少次,11 除 5,循环 2 次,余 1 。当然,我们可以用任意 2 个数,7 和 81,18 和 54,什么都行,这就是软件的强大之处。

软件还让我们做到硬件做不到的事,ALU 可没有除法功能,是程序给了我们这个功能。别的程序也可以用我们的除法程序,来做其他事情。

这意味着一层新抽象!


我们这里假设的 CPU 很基础,所有指令都是 8 位,操作码只占了前面 4 位,即便用尽 4 位,也只能代表 16 个指令,而且我们有几条指令,是用后 4 位来指定内存地址。因为 4 位最多只能表示 16 个值,所以我们只能操作 16 个地址,这可不多。我们甚至不能 JUMP 17 ,因为 4 位二进制无法表示数字 17 ,因此,真正的现代 CPU 用两种策略: 最直接的方法是用更多位来代表指令,比如 32 位或 64 位 - 这叫 “指令长度” ;第二个策略是 “可变指令长度” 。

举个例子,比如某个 CPU 用 8 位长度的操作码,如果看到 HALT 指令,HALT 不需要额外数据,那么会马上执行。如果看到 JUMP,它得知道位置值,这个值在 JUMP 的后面,这叫 “立即值”。这样设计,指令可以是任意长度,但会让读取阶段复杂一点点。要说明的是,我们拿来举例的 CPU 和指令集都是假设的,是为了展示核心原理。

![[assets/Pasted image 20230526123942.png]]

所以我们来看个真的 CPU 例子。1971 年,英特尔发布了 4004 处理器。这是第一次把 CPU 做成一个芯片,给后来的英特尔处理器打下了基础。它支持 46 个指令,足够做一台能用的电脑,它用了很多我们说过的指令,比如 JUMP ADD SUB LOAD ,它也用 8 位的"立即值"来执行 JUMP, 以表示更多内存地址。处理器从 1971 年到现在发展巨大,现代 CPU, 比如英特尔酷睿 i7, 有上千个指令和指令变种,长度从 1 到 15 个字节。

举例,光 ADD 指令就有很多变种!指令越来越多,是因为给 CPU 设计了越来越多功能。

下集我们会讲。

下周见。