【CO】P3课下——单周期CPU(Logisim实现)

本文为P3课下单周期CPU设计思路与具体细节,仅供参考

P3课下–单周期CPU的Logisim实现

总体设计方案

指令集合

课下提交要求实现的指令包括add(u),sub(u),ori,lui,beq,lw,sw,nop,具体实现过程中在此基础上又添加了j,jal,jr等跳转指令和移位指令sll。根据机器码类别将其分类:

R型指令 I型指令 J型指令
add(u),sub(u),sll(nop),jr ori,lui,beq,lw,sw j,jal

模块设计

根据功能划分需要设计以下模块:

  • 取指令——IFU,包括PC(程序计数器),NPC(Next PC),IM(指令存储器)。其中要求PC用寄存器实现并且具有异步复位功能,起始地址为0x00003000。地址范围要求为0x00003000~0x00006FFF。

    由地址范围可知,地址宽度为0x00004000,即为2^14,但每条指令大小为32bit,每个地址对应的容量为1个字节(8bit),因此相邻两条指令的地址差异为4,我们引入指令字的概念,利用ROM作为IM,每个位置储存一条指令,那么我们仅需要2^12个指令字,即12位ROM便可以满足我们的需要。

  • 寄存器读写——GRF,由32个寄存器组成的寄存器堆,具有异步复位功能
  • 内存读写——DM,数据存储器,可以理解为内存,要求使用RAM实现,具有异步复位功能,地址范围为0x00000000~0x00002FFF。

    地址宽度为0x00003000,即为3*2^12,根据和指令字相同的原理,12位RAM即可满足我们的需要。注意!这样做我们仅能以字为单位对内存进行操作。但是转念再去想,如果不指定内存的对齐方式,如何去存储都会产生问题:按字对齐那么对于半字、字节操作很麻烦,按字节对齐那么会多占用两位地址,同时如果储存字或半字需要对多个地址的内存进行操作。因此仍然选择按字存储,选择地址位数为12,数据位数为32的RAM作为DM。

  • 数学运算——ALU,算数逻辑单元,实现的运算包括加、减、或、逻辑左移、lui(高位存储)。
  • 位数扩展——EXT,位扩展单元,根据指令的不同进行0扩展(ori,lui)或符号扩展(beq,lw,sw)
  • 控制器——CTRL,根据输入指令的操作码和功能码输出各个模块和数据通路的控制信号。

整体架构

整体架构

具体模块设计

PC

端口说明

示意图

端口 功能
next_pc 接收来自NPC模块指示的下一条指令地址,并在时钟上升沿更新
clk 时钟信号
reset 异步复位信号,为1’b1时将当前指令设置为起始位置

实现电路

电路图
顶层接入
输出的pc为符合题目要求的指令地址值,next_pc返回的是未经+0x00003000的地址位置,这样设计是为了能利用寄存器自带的异步复位功能,这里对0x00003000加加减减纯属是为了满足题目输出pc地址范围的要求。

NPC

端口说明

示意图

端口 功能
pc 接收当前指向的指令地址
pc+4 输出当前pc+4的值,便于jal指令跳转时将pc+4的值存入$ra中
IMM 接收当前J型指令的26位转移地址
beq_offset 接收beq指令中16位偏移量
RA 接收读出的jr指令转移至$31指向的地址(仅设计了jr $ra)
zero 接收ALU返回的rs rt寄存器值是否相等的信号,用于beq
NPCOp 接收CTRL返回的下一个PC所指向的位置类型控制信号
npc 向PC传递经NPC模块得到的下一条执行指令的位置

其中,控制信号NPCOp的功能具体如下:

信号 功能
2’b00 npc为pc+4
2’b01 npc为j/jal指向的指令
2’b10 npc为$ra指向的指令
2’b11 npc为pc+4/beq分支的指令,取决于zero的输入

实现电路

电路图

注意:此处pc接收的应未减0x00003000,并且输出npc前减去0x00003000以保持与pc的配合

IM

结构相对简单,输入为希望读取的地址,输出为读到的指令机器码。

GRF

端口说明

示意图

端口 功能
A1 读取的第一个寄存器编号(rs)
A2 读取的第二个寄存器编号(rt)
WR 写入数据的目标寄存器(rd/rt/$ra)
WD 写入目标寄存器的数据
RD1 A1中的数据
RD2 A2中的数据
clk 时钟信号
reset 异步复位信号
WE 写入信号

实现电路

电路图
实现思路很简单,注意对应关系不要混乱即可,推荐自动化对.circ文件内容进行修改。

ALU

端口说明

示意图

端口 功能
A 进行运算的第一个数
B 进行运算的第二个数
sll 进行sll操作需要左移的位数
ALUOp 控制信号,选择ALU进行的操作
zero 输出两个寄存器中的值是否相等,为beq指令时的NPC提供是否分支的依据
ans 输出运算结果

其中,控制信号ALUOp的功能具体如下:

信号 功能
3’b000 A+B
3’b001 A-B
3’b010 A
3’b011 B<<sll
3’b100 B<<16

实现电路

电路图
利用Logisim自带的元件进行算术操作,实现较为简单。ALU关键在于数据通路,在下面分析。

DM

电路图
想法也较为简单,使用之前提到的RAM即可,其中DMOp作为读写控制信号,其功能具体如下:

信号 功能
1’b0 写入数据
1’b1 读出数据

EXT

电路图
选择0扩展或符号扩展

信号 功能
1’b0 0扩展
1’b1 符号扩展

CTRL

端口说明

示意图

端口 功能
OpCode 获取指令的操作码
FuncCode 获取指令的功能码
NPCOp 模块控制信号,控制NPC操作
ALUOp 模块控制信号,控制ALU操作
WE 模块控制信号,控制GRF读写
DMOp 模块控制信号,控制DM读写
WRSlt 数据通路控制信号,控制写入的寄存器来源
WDSlt 数据通路控制信号,控制写入的数据来源
ALUSlt 数据通路控制信号,控制进入ALU的第二个数来源
EXTOp 模块控制信号,控制EXT扩展方式

CTRL输出的控制信号可以分为模块控制信号和数据通路控制信号,模块控制信号使模块进行不同的操作,数据通路控制信号控制进入模块的数据来源。根据指令码和操作码的不同,CTRL输出不同的控制信号。具体的指令和控制信号的控制关系我们在数据通路分析部分给出。

实现电路

实现电路可分为两部分:与逻辑和或逻辑,与逻辑负责识别指令,或逻辑负责提供指令对应的控制信号,将这两部分分开有助于添加指令,在连线时可以先把对应的操作码和功能码输入,然后根据颜色与门电路连接,可以减小犯错误的概率。
与逻辑
或逻辑

数据通路分析

数据通路控制着每条指令从读入到执行数据在各个模块间的流动轨迹,根据不同的指令控制数据的流向是实现指令的关键,利用多路选择器和其选择信号对数据的流向进行控制。
在电路设计中,我在三处设置了选择器。
数据通路
WRSlt对应的选择器选择进行写入操作的寄存器编号:

WRSlt 来源
2’b00 rd
2’b01 rt
2’b10 $ra

WDSlt对应的选择器选择写入寄存器的数据:

WDSlt 来源
2’b00 pc+4
2’b01 ALU计算得到的ans
2’b10 DM读取的数据

ALUSlt对应的选择器选择进入ALU模块的第二个数:

ALUSlt 来源
1’b0 RD2
1’b1 EXT扩展后的结果

综上,我们给出每条指令对应的各个控制信号,这也是CTRL连线的重要依据:

指令 NPCOp WE ALUOp DMOp WRSlt WDSlt ALUSlt EXTOp
add(u) 2’b00 1’b1 3’b000 1’b1 2’b00 2’b01 1’b0 1’b0
sub(u) 2’b00 1’b1 3’b001 1’b1 2’b00 2’b01 1’b0 1’b0
sll 2’b00 1’b1 3’b011 1’b1 2’b00 2’b01 1’b0 1’b0
ori 2’b00 1’b1 3’b010 1’b1 2’b01 2’b01 1’b1 1’b0
lui 2’b00 1’b1 3’b100 1’b1 2’b01 2’b01 1’b1 1’b0
beq 2’b11 1’b0 3’b001 1’b1 2’b00 2’b00 1’b0 1’b0
lw 2’b00 1’b1 3’b000 1’b1 2’b01 2’b10 1’b1 1’b1
sw 2’b00 1’b0 3’b000 1’b0 2’b00 2’b00 1’b1 1’b1
j 2’b01 1’b0 3’b000 1’b1 2’b10 2’b00 1’b0 1’b0
jal 2’b01 1’b1 3’b000 1’b1 2’b00 2’b00 1’b0 1’b0
jr 2’b10 1’b0 3’b000 1’b1 2’b10 2’b00 1’b0 1’b0

有了这张表,我们便可以在CTRL中进行连线为每条指令规划数据通路。当我们添加指令时,按该表的逻辑分析数据通路再进行CTRL中的连线即可。

思考题

  1. 上面我们介绍了通过 FSM 理解单周期 CPU 的基本方法。请大家指出单周期 CPU 所用到的模块中,哪些发挥状态存储功能,哪些发挥状态转移功能。

    答: 状态存储功能:PC、GRF、DM
    状态转移功能:NPC、CTRL、GRF、DM

  2. 现在我们的模块中 IM 使用 ROM, DM 使用 RAM, GRF 使用 Register,这种做法合理吗? 请给出分析,若有改进意见也请一并给出。

    答:我认为在理论上是合理的,在实践中有些不合理。IM对使用过程来说只需要只读即可,所以采用一个ROM是合理的。DM和GRF都是读取和写入都需要。DM每次仅进行读或写,所以可以采用一个读写分离的RAM实现,并且每个周期仅进行一次读或写,对读写速度要求不高,所以没必要使用寄存器构建,那样会占用大量的寄存器。GRF读和写可以认为是相互不干预的,并且需要高速读写,所以用寄存器搭建是很好的选择。以上说明了本次作业模块实现器件是合理的,但是仍然可以优化,比如用一个RAM来作为存储器,既存储指令,又存储数据,在实践中可以更节省成本。

  3. 在上述提示的模块之外,你是否在实际实现时设计了其他的模块?如果是的话,请给出介绍和设计的思路。

    答:设计了NPC(Next Program Counter),PC的指向可以简单分为三种情况:PC+4,分支指向,跳转指向,这三种不同的情况如何转移,如果全在PC模块中实现有些臃肿复杂,并且参考状态机的状态转移,可以将此部分抽离出来作为一个状态转移电路,这是该模块的设计的初衷。具体设计中,根据指令的不同要求,对指令的不同部分进行不同的操作,这些数学操作并不引入到ALU中完成,而是在模块内部完成,一是这些数学操作的结果对于其他非IFU模块来说没有用,无法合并数据通路,导致走线混乱,二是这些数学操作特殊,并不仅是简单的加减乘除,更适合在模块内部单独考虑完成。需要哪些数据,便从外部引入,通过控制信号和内部计算,将下一条指令的地址返回PC即可。

  4. 事实上,实现 nop 空指令,我们并不需要将它加入控制信号真值表,为什么?

    答:从语义上来看,nop指令不进行任何操作,不会对数据或者状态进行修改,因此保持控制信号的默认状态或者保持上一条指令的状态即可。从机器码上来看,nop即为sll $0,$0,0,而GRF中$zero不会改变,并且该操作的数据通路不会影响其他模块,所以控制信号也就无所谓了。在我的设计中,由于添加了sll指令,nop也就顺带可以作为sll $0,$0,0执行了,但是控制信号是为sll设计的,nop不需要控制信号,自然可以被兼容至设计的控制信号中

  5. 阅读 Pre 的 “MIPS 指令集及汇编语言” 一节中给出的测试样例,评价其强度(可从各个指令的覆盖情况,单一指令各种行为的覆盖情况等方面分析),并指出具体的不足之处。

    答:从指令覆盖情况来看,没有涉及sub(u)指令,而这是很容易出现溢出的操作,也是设计的易错点。同时swlw指令在涉及偏移量时,仅考虑了正整数,没有测试出现负数的情况,从而无法测试设计时扩展方式的正误。综上,测试数据较弱,仅测试了容易想到的地方,容易出现Bug的地方没有进行相应测试。

测试方案

测试用例

给出的测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ori $a0, $0, 123 
ori $a1, $a0, 456
lui $a2, 123 # 符号位为 0
lui $a3, 0xffff # 符号位为 1
ori $a3, $a3, 0xffff # $a3 = -1
add $s0, $a0, $a2 # 正正
add $s1, $a0, $a3 # 正负
add $s2, $a3, $a3 # 负负
ori $t0, $0, 0x0000
sw $a0, 0($t0)
sw $a1, 4($t0)
sw $a2, 8($t0)
sw $a3, 12($t0)
sw $s0, 16($t0)
sw $s1, 20($t0)
sw $s2, 24($t0)
lw $a0, 0($t0)
lw $a1, 12($t0)
sw $a0, 28($t0)
sw $a1, 32($t0)
ori $a0, $0, 1
ori $a1, $0, 2
ori $a2, $0, 1
beq $a0, $a1, loop1 # 不相等
beq $a0, $a2, loop2 # 相等
loop1:sw $a0, 36($t0)
loop2:sw $a1, 40($t0)

在此基础上添加了sub指令的测试和swlw偏移量为负数的指令:

1
2
3
4
5
6
ori $t0,$0,3
ori $t1,$0,2
sub $t2,$t1,$t0
ori $s1,$0,4
sw $t2,-4($s1)
lw $t3,-4($s1)

自动化测试

最初的思路是把Mars源码进行修改,使其能输出所需格式的信息。但是在解压缩Jar包并反编译后对源码修改完成(我也不知道是否成功)后,再次打包回Jar包时遇到了麻烦,JDK报错类重复,在编译环节似乎就出现了问题,当时已经是凌晨四点左右了,我的精力已经达到了极限,实在是不想,也无力去寻找解决办法,于是回宿舍睡觉去了。
第二天醒来,我打算尝试新的方法,即不利用Mars,自行模拟CPU执行指令,并输出我想要的信息。而最关键的就是内存和寄存器实现。一开始我的想法是直接在Python脚本中完成模拟,但是本人Python水平实在有限,并且C对于指针、内存操作的便利性又让我不舍得放弃。于是打算在脚本中通过命令行编译、执行C程序,执行Logisim并处理其输出,最终将二者输出对比,进行评测。
但是实现的过程并不是一帆风顺,在脚本中通过gcc编译C程序的过程中出现了问题,这一步索性手动完成。于是,我的半自动评测机完成了。
需要手动完成的部分有:

  1. 在Mars中将16进制文件导出,命名为"tst_hex.txt"
  2. 复制一份”tst_hex.txt”取别名并在其第一行添加一行v2.0 raw
  3. 将副本导入p3_cpu.circ的ROM中
  4. 执行py脚本

需要的环境和工具有:

  1. JDK包
  2. GCC等C语言编译器

其中支持的指令包括上面已实现的所有指令。
受限于时间,需手动完成的部分实在是无法自动化(22:00前就要提交了,现在是21:15),第一次手写评测机,实在是仓促,也没有经验,让大家见笑了。不过我还是蛮开心自豪的,在这个过程中我也学到了很多。
文件结构如下:
文件结构

模拟CPU执行指令的C程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define NUM_REGS 32
#define MEM_SIZE 4096

unsigned char memory[MEM_SIZE];
int registers[NUM_REGS] = { 0 };
int pc = 0;

// 操作指令的枚举
enum OPCODES { R = 0, LW = 35, SW = 43, BEQ = 4, J = 2, ORI = 13, LUI = 15, JAL = 3 };

// 从文件中加载十六进制指令
int load_instructions(const char* filename, unsigned long long* instructions, int max_instructions) {
FILE* file = fopen(filename, "r");
if (!file) {
printf("file error!");
return -1;
}
char line[10];
int count = 0;
while (fgets(line, sizeof(line), file) && count < max_instructions) {
instructions[count++] = strtoull(line, NULL, 16);
}
fclose(file);
return count;
}

// 解码指令
void decode_instruction(unsigned long long instruction, int* opcode, int* rs, int* rt, int* rd, int* shamt, int* func, int* imm, int* addr) {
*opcode = (instruction >> 26) & 0x3F;
*rs = (instruction >> 21) & 0x1F;
*rt = (instruction >> 16) & 0x1F;
*rd = (instruction >> 11) & 0x1F;
*shamt = (instruction >> 6) & 0x1F;
*func = instruction & 0x3F;
*imm = instruction & 0xFFFF;
*addr = instruction & 0x3FFFFFF;
}

// 执行指令
void execute_instruction(int opcode, int rs, int rt, int rd, int shamt, int func, int imm, int addr) {
switch (opcode) {
case R: // R型指令
switch (func) {
case 32: // add(u)
registers[rd] = (unsigned int)registers[rs] + (unsigned int)registers[rt];
//fprintf(out, "@0x%08x : $%d <= %d\n", pc + 0x3000, rd, registers[rd]);
printf("@0x%08x : $%d <= %d\n", pc + 0x3000, rd, registers[rd]);
pc += 4;
break;
case 34: // sub(u)
registers[rd] = (unsigned int)registers[rs] - (unsigned int)registers[rt];
//fprintf(out, "@0x%08x : $%d <= %d\n", pc + 0x3000, rd, registers[rd]);
printf("@0x%08x : $%d <= %d\n", pc + 0x3000, rd, registers[rd]);
pc += 4;
break;
case 0: //sll
registers[rd] = registers[rt] << shamt;
//fprintf(out, "@0x%08x : $%d <= %d\n", pc + 0x3000, rd, registers[rd]);
printf("@0x%08x : $%d <= %d\n", pc + 0x3000, rd, registers[rd]);
pc += 4;
break;
case 8: //jr
pc = registers[rs];
break;
}
break;
case LW: // lw
registers[rt] = *(int*)&memory[registers[rs] + imm];
//fprintf(out, "@0x%08x : $%d <= %d\n", pc + 0x3000, rt, registers[rt]);
printf("@0x%08x : $%d <= %d\n", pc + 0x3000, rt, registers[rt]);
pc += 4;
break;
case SW: // sw
*(int*)&memory[registers[rs] + imm] = registers[rt];
//fprintf(out, "@0x%08x : *0x%08x <= %d\n", pc + 0x3000, registers[rs] + imm, registers[rt]);
printf("@0x%08x : *0x%08x <= %d\n", pc + 0x3000, registers[rs] + imm, registers[rt]);
pc += 4;
break;
case BEQ: // beq
if (registers[rs] == registers[rt]) {
pc += 4 + (imm << 2);
}
else
{
pc += 4;
}
break;
case ORI: //ori
registers[rt] = registers[rs] | ((unsigned int)imm);
//fprintf(out, "@0x%08x : $%d <= %d\n", pc + 0x3000, rt, registers[rt]);
printf("@0x%08x : $%d <= %d\n", pc + 0x3000, rt, registers[rt]);
pc = pc + 4;
break;
case LUI: //lui
registers[rt] = imm << 16;
//fprintf(out, "@0x%08x : $%d <= %d\n", pc + 0x3000, rt, registers[rt]);
printf("@0x%08x : $%d <= %d\n", pc + 0x3000, rt, registers[rt]);
break;
case JAL: //jal
registers[31] = pc + 4;
pc = ((pc >> 28) << 28) | (((unsigned int)((addr << 2) << 5)) >> 5);
break;
case J: // j
pc = ((pc >> 28) << 28) | (((unsigned int)((addr << 2) << 5)) >> 5);
break;
}
}


int main() {
unsigned long long instructions[100];
int num_instructions = load_instructions("./tst_hex.txt", instructions, 100);
//FILE* out = fopen("tst_out.txt", "w");
if (num_instructions < 0) {
return 777;
}
pc = 0;
while (pc / 4 < num_instructions) {
int opcode, rs, rt, rd, shamt, func, imm, addr;
decode_instruction(instructions[pc / 4], &opcode, &rs, &rt, &rd, &shamt, &func, &imm, &addr);
execute_instruction(opcode, rs, rt, rd, shamt, func, imm, addr);
}
//fclose(out);
return 0;
}

python执行命令行操作获得自己的输出并对拍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import os
import subprocess
import difflib

script_dir = os.path.dirname(os.path.abspath(__file__))

def execute_c_program(c_program_path):
# gcc_path = "C:\\MinGW\\bin\\gcc" # 替换为实际路径
# compile_cmd = f"gcc {c_program_path} -o output_program"
execute_cmd = os.path.join(script_dir, "output_program.exe")

try:
# subprocess.run(compile_cmd, shell=True, check=True)
result = subprocess.run(execute_cmd, shell=True, check=True, capture_output=True, text=True, cwd=script_dir)
return result.stdout
except subprocess.CalledProcessError as e:
print("Error executing C program:", e)
return None def execute_logisim(logisim_path, circuit_file):
cmd = f"java -jar {logisim_path} {circuit_file} -tty table" result = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=script_dir)
return result.stdout

def format_logisim_output(output):
lines = output.strip().split('\n')
formatted_lines = []

for line in lines:
values = line.split('\t') # 根据制表符分隔
if len(values) > 1 and values[2] == '1':
hex_1 = hex(int(values[1].replace(' ', ''), 2))[2:].zfill(8)
hex_1 = '0x' + hex_1
hex_3 = int(values[3].replace(' ', ''), 2)
hex_4 = int(values[4].replace(' ', ''), 2)
formatted_lines.append(f"@{hex_1} : ${hex_3} <= {hex_4}") # 自定义格式
if len(values) > 1 and values[5] == '1':
hex_1 = hex(int(values[1].replace(' ', ''), 2))[2:].zfill(8)
hex_1 = '0x' + hex_1
hex_6 = hex(int(values[6].replace(' ', ''), 2))[2:].zfill(8)
hex_6 = '0x' + hex_6
hex_7 = int(values[7].replace(' ', ''), 2)
formatted_lines.append(f"@{hex_1} : *{hex_6} <= {hex_7}") # 自定义格式
return formatted_lines

def compare_files(file1, file2):
with open(file1, 'r') as f1, open(file2, 'r') as f2:
diff = difflib.unified_diff(
f1.readlines(),
f2.readlines(),
fromfile='file1',
tofile='file2'
)
return list(diff)

def count_lines(file_path):
with open(file_path, 'r', encoding='utf-8') as file:
line_count = sum(1 for line in file)
return line_count

# 路径
c_program_path = os.path.join(script_dir,"mips_cpu.c")
logisim_path = os.path.join(script_dir, "logisim.exe")
circuit_file = os.path.join(script_dir, "test_p3.circ")
output_log_c = os.path.join(script_dir, "output_c_program.log")
output_log_logisim = os.path.join(script_dir, "output_logisim.log")
hex_txt = os.path.join(script_dir, "tst_hex.txt")

# 运行C程序并记录结果
c_output = execute_c_program(c_program_path)
if c_output:
with open(output_log_c, 'w') as f:
f.write(c_output)

p = count_lines(hex_txt)

# 运行Logisim并获取输出
logisim_output = execute_logisim(logisim_path, circuit_file)

# 格式化Logisim输出
formatted_output = format_logisim_output(logisim_output)
with open(output_log_logisim, 'w') as f:
for line in formatted_output[:p]:
f.write(line + '\n')

# 比较两个输出日志的差异
differences = compare_files(output_log_c, output_log_logisim)
if differences:
print("Differences found between C program output and Logisim output:")
for line in differences:
print(line)
else:
print("No differences found.")


【CO】P3课下——单周期CPU(Logisim实现)
http://example.com/2024/10/24/【CO】P3课下--单周期CPU(Logisim实现)/
作者
mRNA
发布于
2024年10月24日
许可协议