热身结束, 进入真正的PA
如果说PA1只是编程复习的热身运动, 那么PA2就是正片的开始了. 在PA2中, 你会感觉新代码和工程细节如潮水般袭来, 你需要不断地读讲义, RTFM, RTFSC, 并整理其中的细节, 一开始效率会比较低, 一旦掌握了这些细节, 后面就是重复性的工程活了.
此外, 如果说PA1中提到的"不要纠缠于和当前进度无关的代码" 是为了保护你的幼小心灵, 在PA2中你就需要勇敢地去探索了: 讲义不再像PA1那样详细指出每一处你值得阅读的代码. 事实上, PA中的所有代码都值得你去阅读, 在PA2结束之后, 你需要做到可以理解NEMU中的每一处细节. 随着你对这些代码细节的了解变得深入, 就算是调bug你也会觉得多了几分轻松. 相反, 如果你仍然抱着"测试能过就行", "这里不算分, 关我P事"的心态, 很快你就会发现连bug都不知道怎么调了.
所以, 不要再偷懒了.
不停计算的机器
在PA1中, 我们已经见识到最简单的计算机TRM的工作方式:
while (1) {
从PC指示的存储器位置取出指令;
执行指令;
更新PC;
}
接下来我们就来谈谈这个过程, 也就是, CPU究竟是怎么执行一条指令的. 对于大部分指令来说, 执行它们都可以抽象成取指-译码-执行的指令周期. 为了使描述更加清晰, 我们借助指令周期中的一些概念来说明指令执行的过程.
取指(instruction fetch, IF)
要执行一条指令, 首先要拿到这条指令. 指令究竟在哪里呢? 还记得冯诺依曼体系结构的核心思想吗? 那就是"存储程序, 程序控制". 你以前听说这两句话的时候可能没有什么概念, 现在是实践的时候了. 这两句话告诉你, 指令在存储器中, 由PC指出当前指令的位置. 事实上, PC就是一个指针! 在计算机世界中, 指针的概念无处不在. 如果你觉得对指针的概念还不是很熟悉, 就要赶紧复习指针这门必修课啦. 取指令要做的事情自然就是将PC指向的指令从内存读入到CPU中.
译码(instruction decode, ID)
在取指阶段, 计算机拿到了将要执行的指令. 让我们也来目睹一下指令的风采, 睁大眼睛一看, 竟然是个0和1组成的比特串!
10111001 00110100 00010010 00000000 00000000
这究竟是什么鬼... 不过想想, 计算机也只是个巨大的数字电路, 它也只能理解0和1了. 但是, 这样的计算机又是如何理解这让人一头雾水的比特串的呢?
让我们先来回想一下指令是做什么的. 我们知道CPU是用来处理数据的, 指令则是用来指示CPU具体对什么数据进行什么样的处理. 也就是说, 我们只要让CPU从上面那串神秘的比特串中解读出处理的对象和处理的操作, CPU就知道我们想让它做什么了. 所以相应地, CPU需要从指令中解读出"操作码"和"操作数"两部分信息.
于是, 为了让计算机明白指令的含义, 先驱想到了一个办法, 那就是你在数字电路课上学习过的查找表! CPU拿到一条指令之后, 可以通过查表的方式得知这条指令的操作数和操作码. 这个过程叫译码.
当然, 译码逻辑实际上也并非只有一张查找表那么简单, 还需要根据不同的指令通过多路选择器选择不同的操作数. 回想一下, 计算机现在已经有存储器和寄存器了, 它们都可以存放操作数, 指令中也可以存放立即数. 也可能还有二次译码的处理... 不过无论再怎么复杂, 我们只需要知道, 这个过程终究也只是一些数字电路的事情, 毕竟所有需要的信息都在指令里面了, 没什么神秘的操作.
执行(execute, EX)
经过译码之后, CPU就知道当前指令具体要做什么了, 执行阶段就是真正完成指令的工作. 现在TRM只有加法器这一个执行部件, 必要的时候, 只需要往加法器输入两个源操作数, 就能得到执行的结果了. 之后还要把结果写回到目的操作数中, 可能是寄存器, 也可能是内存.
更新PC
执行完一条指令之后, CPU就要执行下一条指令. 在这之前, CPU需要更新PC的值, 让PC加上刚才执行完的指令的长度, 即可指向下一条指令的位置.
于是, 计算机不断地重复上述四个步骤, 不断地执行指令, 直到永远.
YEMU: 一个简单的CPU模拟器
我们还是以ICS课本的1.1.3小节中介绍的简单计算机为例, 来展示如何用C语言实现一条指令的执行. 这个计算机有4个8位的寄存器, 一个4位PC, 以及一段16字节的内存. 它支持R型和M型两种指令格式, 4条指令. 其指令手册如下:
4 2 0
| | | +----+--+--+
mov rt,rs | R[rt] <- R[rs] | R-type | |0000|rt|rs|
| | | +----+--+--+
| | | +----+--+--+
add rt,rs | R[rt] <- R[rs] + R[rt] | R-type | |0001|rt|rs|
| | | +----+--+--+
| | | +----+--+--+
load addr | R[0] <- M[addr] | M-type | |1110| addr|
| | | +----+--+--+
| | | +----+--+--+
store addr | M[addr] <- R[0] | M-type | |1111| addr|
| | | +----+--+--+
我们可以根据指令手册用C语言编写出这个简单计算机的模拟器YEMU(袁妈模拟器):
#include <stdint.h>
#include <stdio.h>
#define NREG 4
#define NMEM 16
// 定义指令格式
typedef union {
struct { uint8_t rs : 2, rt : 2, op : 4; } rtype;
struct { uint8_t addr : 4 , op : 4; } mtype;
uint8_t inst;
} inst_t;
#define DECODE_R(inst) uint8_t rt = (inst).rtype.rt, rs = (inst).rtype.rs
#define DECODE_M(inst) uint8_t addr = (inst).mtype.addr
uint8_t pc = 0; // PC, C语言中没有4位的数据类型, 我们采用8位类型来表示
uint8_t R[NREG] = {}; // 寄存器
uint8_t M[NMEM] = { // 内存, 其中包含一个计算z = x + y的程序
0b11100110, // load 6# | R[0] <- M[y]
0b00000100, // mov r1, r0 | R[1] <- R[0]
0b11100101, // load 5# | R[0] <- M[x]
0b00010001, // add r0, r1 | R[0] <- R[0] + R[1]
0b11110111, // store 7# | M[z] <- R[0]
0b00010000, // x = 16
0b00100001, // y = 33
0b00000000, // z = 0
};
int halt = 0; // 结束标志
// 执行一条指令
void exec_once() {
inst_t this;
this.inst = M[pc]; // 取指
switch (this.rtype.op) {
// 操作码译码 操作数译码 执行
case 0b0000: { DECODE_R(this); R[rt] = R[rs]; break; }
case 0b0001: { DECODE_R(this); R[rt] += R[rs]; break; }
case 0b1110: { DECODE_M(this); R[0] = M[addr]; break; }
case 0b1111: { DECODE_M(this); M[addr] = R[0]; break; }
default:
printf("Invalid instruction with opcode = %x, halting...\n", this.rtype.op);
halt = 1;
break;
}
pc ++; // 更新PC
}
int main() {
while (1) {
exec_once();
if (halt) break;
}
printf("The result of 16 + 33 is %d\n", M[7]);
return 0;
}
理解YEMU如何执行程序
YEMU可以看成是一个简化版的NEMU, 它们的原理是相通的, 因此你需要理解YEMU是如何执行程序的. 具体地, 你需要
- 画出在YEMU上执行的加法程序的状态机
- 通过RTFSC理解YEMU如何执行一条指令
思考一下, 以上两者有什么联系?
也许你会疑惑, 这个只能做加法的TRM, 究竟还能做些什么呢?
对于采用补码表示的计算机, 能做加法自然就能做减法.
如果再添加一条条件跳转指令jnz r, addr
:
当寄存器r
不为0
时, PC跳转到addr
处, TRM就大不一样了.
例如通过jnz
和dec
的组合可以实现循环,
循环执行inc
可以实现任意数的加法, 循环执行加法可以实现乘法,
函数调用可以看成一种特殊的跳转, 递归本质上就是函数调用...
这下可不得了了, 没想到这个弱不禁风的TRM竟然深藏着擎天撼地的威力!
不过, 虽然这个只有几条指令的TRM可以解决所有可计算的问题, 但却低效得让人无法忍受.
为此, 先驱决定往TRM中加入更多高效的指令.