冯诺依曼式计算机CPU模拟器(单核版)
一、课程设计要求简介
模拟一个简易的冯诺依曼式计算机CPU的工作。
CPU字长为16位,共11个寄存器,其中3个系统寄存器,分别为程序计数器,指令寄存器,标志寄存器;8个通用寄存器,即寄存器1、2、3、4(数据寄存器),寄存器5、6、7、8(地址寄存器)。该CPU至多支持32KB内存。内存分两部分,一部分为代码段,从地址0开始。另一部分为数据段,从地址16384开始。
CPU所支持的指令集中,每条指令固定由32位(编号为0到31)二进制数组成,其中第0到7位为操作码,代表CPU要执行哪种操作;第8到15位为操作对象,如寄存器,内存地址等;第16到31位为立即数。该CPU有一个输入端口和一个输出端口。输入端口的数据由标准输入设备(键盘)输入,输出端口的数据输出到标准输出设备(显示器)上。
程序开始时要从指定文件中读入一段用给定指令集写的程序至内存(从地址0开始顺序保存),程序计数器初始值也为0。指令加载完成后程序就开始不断重复取指令、分析指令和执行指令的过程。程序每执行一条指令就要输出CPU当前的状态,如各寄存器的值等。当执行到停机指令时,程序按要求输出后就结束了。
-
取指令:读取程序计数器PC内的指令地址,根据这个地址将指令从内存中读入,并保存在指令寄存器中,同时程序计数器内容加4,指向下一个条指令。
-
分析指令:对指令寄存器中的指令进行解码,分析出指令的操作码,所需操作数的存放位置等信息等。
-
执行指令:完成相关计算并将结果写到相应位置。
*指令输入从文件 “dict.dic” 中获取,非手动输入,只有遇到指令集中的输入操作时才从键盘读入。
*输入输出样例见 冯诺依曼结构作业_提取码BUPT,也可移步 我的Github
二、指令集
指令 | 说明 | |
---|---|---|
停机 指令 | 00000000 00000000 0000000000000000 | 停止程序执行。 |
数据传送 指令 | 00000001 00010000 0000000000000000 | 将一个立即数传送至寄存器1。 |
00000001 00010101 0000000000000000 | 将寄存器5中地址所指向的内存单元(2个字节)的内容传送至寄存器1。 | |
00000001 01010001 0000000000000000 | 将寄存器1的内容传送至寄存器5中地址所指向的内存单元(2个字节)。 | |
算术运算 指令 | 00000010 00010000 0000000000000000 | 将寄存器1内的数与一个立即数相加,结果保存至寄存器1。 |
00000010 00010101 0000000000000000 | 将寄存器1内的数与寄存器5中地址所指向的内存单元(2个字节)里存的数相加,结果保存至寄存器1。 | |
00000011 00010000 0000000000000000 | 将寄存器1内的数减去一个立即数,结果保存至寄存器1。 | |
00000011 00010101 0000000000000000 | 将寄存器1内的数减去寄存器5中地址所指向的内存单元(2个字节)里存的数,结果保存至寄存器1。 | |
00000100 00010000 0000000000000000 | 将寄存器1内的数与一个立即数相乘,结果保存至寄存器1。 | |
00000100 00010101 0000000000000000 | 将寄存器1内的数与寄存器5中地址所指向的内存单元(2个字节)里存的数相乘,结果保存至寄存器1。 | |
00000101 00010000 0000000000000000 | 将寄存器1内的数除以(C语言的整数除法)一个立即数,结果保存至寄存器1。 | |
00000101 00010101 0000000000000000 | 将寄存器1内的数除以(C语言的整数除法)寄存器5中地址所指向的内存单元(2个字节)里存的数,结果保存至寄存器1。 | |
逻辑运算 指令 | 00000110 00010000 0000000000000000 | 将寄存器1内的数与一个立即数做逻辑与,结果保存至寄存器1。(如果结果为真则保存1,否则保存0) |
00000110 00010101 0000000000000000 | 将寄存器1内的数与寄存器5中地址所指向的内存单元(2个字节)里存的数做逻辑与,结果保存至寄存器1。(如果结果为真则保存1,否则保存0) | |
00000111 00010000 0000000000000000 | 将寄存器1内的数与一个立即数做逻辑或,结果保存至寄存器1。(如果结果为真则保存1,否则保存0) | |
00000111 00010101 0000000000000000 | 将寄存器1内的数与寄存器5中地址所指向的内存单元(2个字节)里存的数做逻辑或,结果保存至寄存器1。(如果结果为真则保存1,否则保存0) | |
00001000 00010000 0000000000000000 | 将寄存器1内的数做逻辑非,结果保存至寄存器1。(如果结果为真则保存1,否则保存0) | |
00001000 00000101 0000000000000000 | 将寄存器5中地址所指向的内存单元(2个字节)里存的数做逻辑非,结果仍保存至寄存器5中地址所指向的内存单元。(如果结果为真则保存1,否则保存0) | |
比较 指令 | 00001001 00010000 0000000000000000 | 将寄存器1内的数与一个立即数比较,如两数相等,则标志寄存器被修置为0,如寄存器1大,则标志寄存器被置为1,如寄存器1小,则标志寄存器被置为-1。 |
00001001 00010101 0000000000000000 | 将寄存器1内的数与寄存器5中地址所指向的内存单元(2个字节)里存的数比较,如两数相等,则标志寄存器被置为0,如寄存器1大,则标志寄存器被置为1,如寄存器1小,则标志寄存器被置为-1。 | |
跳转 指令 | 00001010 00000000 0000000000000000 | 无条件跳转指令,转移至程序计数器加一个立即数处执行。也就是说要修改程序计数器。 |
00001010 00000001 0000000000000000 | 如果标志寄存器内的值为0,则转移至程序计数器加一个立即数处执行。也就是说要修改程序计数器。 | |
00001010 00000010 0000000000000000 | 如果标志寄存器内的值为1,则转移至程序计数器加一个立即数处执行。也就是说要修改程序计数器。 | |
00001010 00000011 0000000000000000 | 如果标志寄存器内的值为-1,则转移至程序计数器加一个立即数处执行。也就是说要修改程序计数器。 | |
输入输出 指令 | 00001011 00010000 0000000000000000 | 从输入端口读入一个整数并保存在寄存器1中。也就是从键盘读一个整数到寄存器1中。 |
00001100 00010000 0000000000000000 | 将寄存器1中的数输出到输出端口。也就是将寄存器1中的数以整数的形式输出到显示器上,同时输出一个换行符。 |
三、思路与分析
主要问题
- 设计怎样的数据结构来模拟对应元件
- 如何编写函数来完成相关指令
选取适当的数据结构
内存和通用寄存器十分相似,都是字节数据的容器,模拟它们是实现后续操作的前提,
容易想到几种方法:
- 用int、char等一维数组,每个下标对应1位
- 同上,但用二维数组,1行8列,模拟1字节对应8位
- 创建结构体数组,结构体内部放置8个变量,模拟1字节对应8位
这些都是理论可行的方法,然而实际内存开销较大,并且可能出现不易操作的情况。
例如,对于二维char数组,究竟是否需要多加一列来记录终止符’\0’,以便使用字符串函数;记录数据的过程中,究竟使用字符’0’还是ASCII 0(即’\0’),都需要斟酌。
C++提供了按位存储的容器vector<bool>,但由于课程要求,可能无法使用。实际上,C语言中就允许我们使用特殊的结构体,以实现内存里真正的按位存储。
考虑这样的写法:
struct Byte {
unsigned char bit0 : 1, bit1 : 1, bit2 : 1, bit3 : 1,
bit4 : 1, bit5 : 1, bit6 : 1, bit7 : 1;
};
使用了8个unsigned char来模拟8个位,其后的 “: 1” 说明bit0等各只占1位,而不是1字节。
但即使实现了真正的按位存储,后续操作依然十分繁琐,因为无法使用循环来读写。(你想换成数组?)
为此,union发挥了巨大的作用。考虑如下写法:
union Memory {
unsigned char data;
struct Byte b;
};
union Register {
short data;
struct {
struct Byte b0;
struct Byte b1;
}b;
};
union在此实现了数据的统一。在Memory中,包含两种类型数据,一个是8位的Byte,另一个是1字节的data,任何对data的操作,都会同时作用于Byte,例如当data = 255时,Byte中就有"11111111",直接对data进行读写,就修改了对应的01序列。这为我们提供了极大的便利。
Register中同理,能够用一个2字节的short统辖16位。(其中又使用了一个结构体,是为了防止b0与b1共用同一字节,从而导致高字节无法体现)
当然,其他数据结构同样可行,但这种方法编程复杂度很低,且极大节省内存,模拟的32KB就是真实的32KB。
至于程序计数器、指令寄存器、标志寄存器等,使用基本数据类型即可。
数据读写
通常而言,操作都是以字节为单位的,有了适当的数据结构,可以方便地进行读写。
我们对元件的操作媒介就是结构体和联合中的data,在寄存器中,它是short(2字节),在内存中,是unsigned char(1字节),如果要修改数据,显然直接修改data即可。
- 至于为何在内存中选用unsigned char,是因为我们不太关心具体的十进制数,而是侧重存储01序列本身,即它应当能将0-255对应为00000000-11111111
即将执行的指令被程序计数器PC选中,存储于指令寄存器IR中,IR为2字节,在分析指令步骤需要得到指令类型cmd、前后操作对象from和to。指令类型位于高字节,操作对象位于低字节的高4位和低4位。
数据存储的本质还是二进制01序列,不要因为有了data这一便捷的接口就忘记二进制操作。
容易想到如下计算方法:
IR.data = (memory[PC].data << 8) | memory[PC + 1].data;
int cmd = IR.data >> 8;
int from = IR.data & 15, to = (IR.data & 240) >> 4;
IR由指令的前2字节组合而成,按位或换成相加也是可以的。
cmd由IR整体右移8位获取高字节得到。低字节中,from为低4位,和(00001111)B按位与即可;to为高4位,和(11110000)B按位与后右移4位即可。
其余读写也同理,基本都可利用二进制操作。如读写内存(2字节),只需:
//read
short num = (memory[pos].data << 8) | memory[pos + 1].data;
//write
memory[dest].data = immed >> 8;
memory[dest + 1].data = immed & 255;
其中,immed表示立即数,可以知道立即数是补码表示的,那为何读写时不需要考虑补码因素呢?
事实上,依托于先前建立的数据结构,在整个设计过程中,都不需要考虑补码的影响。
试考虑以下原则:
- 我操控的任何数,本质上都是01序列
- 我写入数据时,只负责把01序列填到对应位置
- 我读取数据时,才依据数据类型来决定按照原码还是补码翻译这个01序列,从而得到十进制数
当前获取的立即数,在程序中看起来是十进制数,实际上是一个01序列,因为规定了它的数据类型是short,故它能表示对应范围的十进制数,而在二进制下操作时,与数据类型是无关的。
因此,在读取2字节数据时,通过位运算可以获取完整的2字节序列,我们规定它是short,所以它按照short翻译,也就是按照补码翻译;在写入2字节数据时,如果是向内存中写入,我们拆开原有的2字节,分别通过位运算写入。
如果读写操作是在寄存器之间的,由short到short,就更为简单了,直接对data赋值即可。
对指令集的分析
指令集虽长,但总共只有12种指令类型,逐一分析就能找到规律。
- 对于一个给定的指令,开头8位为指令类型,可以作为判断依据
- 接下来8位可分为前4位和后4位,前4位称为to,后4位称为from,可知绝大多数情况下,to总是目标位置,from总是数据来源位置,即指令要么是将数据从源位置作用到目标位置,要么源位置缺省,目标位置与立即数进行对应操作
- 末尾16位为立即数,注意立即数为补码表示
- 指令集给出的初始情况,是可以扩展的,如给出寄存器1与寄存器5操作,则前者可以换为1~4,后者可以换为5~8,以此类推
- 鉴于指令集规定不甚明确,许多情况下会出现歧义,建议严格按照指令集给出的初始情况去执行。例如对于输入输出函数,初始情况为寄存器1,而当寄存器为5时,到底是访问寄存器5本身的值,还是其存储的内存地址对应的值,无法确定,建议就按访问寄存器本身值来编写。
指令实现
建立在先前构建的数据结构的基础上,对指令集中各种指令的实现已经十分简单。这里不再逐一给出编写过程,仅讨论一些可能出错的地方。
- 读入指令到内存:应当一次读入到内存指令段,不要逐条读入执行。读文件有freopen和fopen,不建议使用前者,在windows本地测试可行,但linux下可能无法切换回控制台,导致OJ上出现运行错误。fopen可以搭配fscanf使用,同时参数为%s,避免换行符干扰
- 函数传参:课程有规定,不能使用全局变量,但在我个人看来,正是因为引入了全局变量,才使得函数十分精简,没有繁杂的传参。我的代码使用了全局变量。(
因为我不是计科的,我不用交作业)
如果不使用全局变量,就必须在传参上多加注意,C++中可以传递引用,以修改值本身,在C下,只能够传指针或使用return,否则对函数内部变量的修改都只是作用于栈內的副本,不会修改真实的外部变量 - 数据转换:必须小心short和int等各种类型间的数据相互转换中可能出现的强制截断,寄存器存储2字节数据,当相乘或相加指令产生溢出时,若始终保证short运算,理论上来说会发生绕回,得到正确结果,若在int下运算,会得到超过short范围的数。同理还有输出时,若按照%d输出,对于short型的边界数据极有可能错误输出,应使用%hd输出short
- 程序计数器PC的处理:可以在取完指令后,立即使PC+4,也可以在执行完所取指令后,再令PC+4。二者的区别在于访问立即数时,可能要修改寻址算法。同时,对于跳转指令,正确的处理方式应该是:取指令后PC不变->判断是否跳转->若跳转,跳到所处指令,PC不再+4;若不跳转,PC+4。
由上,可以知道对PC更好的处理方式为,设置变量记录当前指令是否有跳转,若无,在指令结束时令PC+4,若有跳转,PC不再+4 - 逻辑运算:简单地说,就是immed && ax[pos].data之类的操作。若对内存操作,需要注意2字节数处理
四、参考代码
已通过OJ上所有测试样例
鼓励独立思考,独立解题,请勿直接复制,否则OJ查重无法通过
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
typedef struct Byte { //stored by bit
unsigned char bit0 : 1, bit1 : 1, bit2 : 1, bit3 : 1,
bit4 : 1, bit5 : 1, bit6 : 1, bit7 : 1;
}Byte;
typedef union Memory {
unsigned char data;
Byte b;
}Memory;
typedef union Register {
short data;
struct {
Byte b0;
Byte b1;
}b;
}Register;
const int START = 16384; //dataSegment starts from 16384
Memory memory[32 * 1024]; //32Kb Memory
unsigned int PC = 0; //ProcessCounter
Register IR; //InstructionRegister, stores only 2 bytes
Register ax[9]; //GeneralRegisters, store only 2 bytes (don't use ax[0])
int FR = 0; //FlagsRegister
void ReadToMemory();
void process();
void move1(int dest, int src);
void move2(int dest, int src);
void cal(int dest, int src, int mode);
void AND(int dest, int src);
void OR(int dest, int src);
void NOT(int dest, int mode);
void cmp(int dest, int src);
void show();
int main() {
//Load all data into memory.
ReadToMemory();
//Analyze and run instructions.
process();
return 0;
}
void move1(int dest, int src) { //immed/memory->ax
short data = (memory[src].data << 8) | memory[src + 1].data;
ax[dest].data = data;
}
void move2(int dest, int src) { //ax->memory
memory[dest].data = ax[src].data >> 8;
memory[dest + 1].data = ax[src].data & 255;
}
void cal(int dest, int src, int mode) { //calculate, mode: 0->add, 1->sub, 2->mul, 3->div
short data = (memory[src].data << 8) | memory[src + 1].data;
if (mode == 0) ax[dest].data += data;
else if (mode == 1) ax[dest].data -= data;
else if (mode == 2) ax[dest].data *= data;
else if (mode == 3) ax[dest].data /= data;
}
void AND(int dest, int src) { //logic and
short data = (memory[src].data << 8) | memory[src + 1].data;
(ax[dest].data & data) ? (ax[dest].data = 1) : (ax[dest].data = 0);
}
void OR(int dest, int src) { //logic or
short data = (memory[src].data << 8) | memory[src + 1].data;
(ax[dest].data | data) ? (ax[dest].data = 1) : (ax[dest].data = 0);
}
void NOT(int dest, int mode) { //logic not
if (mode == 0) ax[dest].data = !ax[dest].data;
else {
if (memory[dest].data || memory[dest + 1].data) memory[dest].data = memory[dest + 1].data = 0;
else memory[dest + 1].data = 1;
}
}
void cmp(int dest, int src) { //compare
short data = (memory[src].data << 8) | memory[src + 1].data;
if (ax[dest].data == data) FR = 0;
else if (ax[dest].data > data) FR = 1;
else FR = -1;
}
void show() { //show you all the information
printf("ip = %hd\nflag = %d\nir = %hd\n", PC, FR, IR.data);
printf("ax1 = %hd ax2 = %hd ax3 = %hd ax4 = %hd\n", ax[1].data, ax[2].data, ax[3].data, ax[4].data);
printf("ax5 = %hd ax6 = %hd ax7 = %hd ax8 = %hd\n", ax[5].data, ax[6].data, ax[7].data, ax[8].data);
}
void ReadToMemory() { //read data and store in memory
FILE* fp = fopen("dict.dic", "r");
unsigned int cnt = 0;
while (1) {
char line[33] = { 0 };
if (fscanf(fp, "%s", line) != EOF) {
if (line[0] < '0' || line[0] > '1') break; //unavailable data
for (int i = 0; i < 4; i++) {
unsigned char _data = 0;
for (int j = 0; j < 8; j++) _data = _data * 2 + line[i * 8 + j] - '0';
memory[cnt++].data = _data;
}
}
else break;
}
fclose(fp);
}
void process() { //main function, to judge and operate
while (1) {
IR.data = (memory[PC].data << 8) | memory[PC + 1].data;
int cmd = IR.data >> 8;
int from = IR.data & 15, to = (IR.data & 240) >> 4;
bool noJump = 1;
if (cmd == 0) { //shut down
PC += 4;
show();
printf("\ncodeSegment :\n");
for (int i = 0; i < 16; i++) {
for (int j = 0; j < 8; j++) {
if (j) putchar(' ');
printf("%d", (memory[i * 32 + j * 4].data << 24) | (memory[i * 32 + j * 4 + 1].data << 16) |
(memory[i * 32 + j * 4 + 2].data << 8) | memory[i * 32 + j * 4 + 3].data);
}
putchar('\n');
}
printf("\ndataSegment :\n");
for (int i = 0; i < 16; i++) {
for (int j = 0; j < 16; j++) {
if (j) putchar(' ');
printf("%hd", (memory[START + i * 32 + j * 2].data << 8) | memory[START + i * 32 + j * 2 + 1].data);
}
putchar('\n');
}
break;
}
if (cmd == 1) { //move
if (from == 0) move1(to, PC + 2); //immed->ax
else if (from >= 5) move1(to, ax[from].data); //memory->ax
else move2(ax[to].data, from); //ax->memory
}
else if (cmd == 2) { //add
from == 0 ? cal(to, PC + 2, 0) : cal(to, ax[from].data, 0);
}
else if (cmd == 3) { //subtract
from == 0 ? cal(to, PC + 2, 1) : cal(to, ax[from].data, 1);
}
else if (cmd == 4) { //multiply
from == 0 ? cal(to, PC + 2, 2) : cal(to, ax[from].data, 2);
}
else if (cmd == 5) { //divide
from == 0 ? cal(to, PC + 2, 3) : cal(to, ax[from].data, 3);
}
else if (cmd == 6) { //logic and
from == 0 ? AND(to, PC + 2) : AND(to, ax[from].data);
}
else if (cmd == 7) { //logic or
from == 0 ? OR(to, PC + 2) : OR(to, ax[from].data);
}
else if (cmd == 8) { //logic not
from == 0 ? NOT(to, 0) : NOT(ax[from].data, 1);
}
else if (cmd == 9) { //compare
from == 0 ? cmp(to, PC + 2) : cmp(to, ax[from].data);
}
else if (cmd == 10) { //jump
short data = (memory[PC + 2].data << 8) | memory[PC + 3].data;
if (from == 0 || from == 1 && FR == 0 || from == 2 && FR == 1 || from == 3 && FR == -1) {
PC += data;
noJump = 0;
show();
}
}
else if (cmd == 11) { //input
printf("in:\n");
scanf("%hd", &ax[to].data);
}
else if (cmd == 12) { //output
printf("out: %hd\n", ax[to].data);
}
if (noJump) {
PC += 4;
show();
}
}
}
五、实用工具
备注1:感谢19级学长制作的工具,为debug提供了很大帮助。生成代码时,请注意按照指令集的规定进行生成,例如操作对象处于高位还是低位,通常都会有影响。同时,其中所给的测试样例具有一些指令集未说明的情况,我写的代码只遵循指令集的要求,虽然OJ上可以通过,但不适应这些情况。
备注2:工具中的执行操作是无效的,因为链接似乎挂掉了。
六、扩展思考
关于底层和汇编
实际上,这个作业无形中为了解计算机底层原理和汇编语言起到了微妙的入门引领效果。
重新回顾设计过程中涉及的内容。
- 虽然寄存器的设计比较粗略,就是用高级语言进行模拟,没有接触更底层的内容,但如何保存整数数据,如何进行读写,寄存器与内存间的交互,都是比较原理性的内容。程序计数器、指令寄存器等如何工作,我们使用高级语言时也不关心,但在进行作业设计时,需要模拟其工作原理,这也属于计算机系统的重要原理
- 事实上,指令集中给出的很多功能,都是汇编中的指令,但相比之下已经简化很多,如mov指令并没有movz,movs和movl等之分,方便了解基本原理
- 指令集中的跳转jmp依旧是引起BUG和带来困扰的万恶之源,如果不能理解jmp的意义,就无法深入理解汇编。站在汇编角度上,代码总是“线性”运行的,并没有选择、循环等结构的概念,它们实质上是高级语言依托jmp构建出来的逻辑结构。试想,在汇编的角度上,当一轮循环结束时,要往回跳转,是否就需要令程序计数器取到先前的指令呢?而在选择结构中,当不满足if的条件时,本质上就是跳过了if所包含的指令,取到了后边else中的第一条指令。现在你应该能够更加坚定,为什么跳转后PC不需要+4,因为会跳过一条有效指令。
关于代码的多样性(查重问题)
事实上,如果掌握了足够牢固的基本技能,思路比较开阔,在写这个作业的时候,是完全不需要注意查重的,因为自然不会重复。但在此依旧给出一些防止查重的技巧(不要滥用)。
- C++中引入了完善的面向对象编程,既可以解决全局变量的问题,又可以方便地调用成员函数,对多核版设计也十分友好。同时,C++也提供之前提到的按位存储结构vector<bool>,并且可以传递引用等,但是由于课程原因,可能无法使用C++,这是一个遗憾
- 对于一个数乘 2n 的情形,可以考虑移位的做法,例如 a ∗ 2 a * 2 a∗2 等价于 a < < 1 a << 1 a<<1。但是要注意优先级问题,例如 a < < 1 + 2 a << 1 + 2 a<<1+2 实际上会变成 a < < 3 a << 3 a<<3,而不是 a ∗ 2 + 2 a * 2 + 2 a∗2+2,需要加括号
- 函数的调用是区别代码是否重复的重要依据,可以尝试把一些功能拆开分到多个函数中,设计复合调用的函数
- 函数指针是一个容易被遗忘的东西,可考虑使用函数指针来指向各函数,这样只需要一个函数指针数组就好,而不是长篇的switch或大量的if-else,但是一旦使用函数指针,传参就会成为一个麻烦的事情,这需要自己解决