本文假设读者会基本的verilog。

本文会实现一个CPU的原理装置,作者对几乎所有内容进行了大量的简化,以便于理解,但所制作的简易CPU能完成大部分功能。

设计思路

传统CPU的控制往往较为复杂,有多种多样的微操作,但本文设计的CPU只有下面几种“微操作”,且一定是按照这个顺序执行,即:

  • 根据PC寄存器的值直接从指令Cache取出指令并利用组合逻辑进行译码1
  • 将1到2个数据从某处连入ALU
  • 将ALU运算结果输出到某处

即:

design

用这个方式实现的CPU仍然有所有必须的功能,且非常容易理解。

最简单的计算

CPU能做的事情无非是计算和控制,其中计算比较容易实现。

CPU中主要的计算部件,学过计组的人都知道,是ALU。

ALU 不过是一个简单的组合逻辑电路,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module ALU(
input wire [7:0] input1, // 操作数1
input wire [7:0] input2, // 操作数2
input wire [2:0] func, // 功能选择
output reg [7:0] result = 8'b00000000 // 结果
);
parameter ADD = 3'b000,
SUB = 3'b001,
OR = 3'b010,
AND = 3'b011,
// 100和101保留
NOT = 3'b110,
HLT = 3'b111;
always @(input1 or input2 or func) begin
case(func)
ADD : result = input1 + input2;
SUB : result = input1 - input2;
OR : result = input1 | input2;
AND : result = input1 & input2;
NOT : result = ~input1;
HLT : result = input1;
endcase
end
endmodule

寄存器堆

要计算,当然要有用来计算的数据,还要存放运算结果的地方。

一般来说大部分计算数据的来源和运输结果的去向都是寄存器。

很多计算都有两个输入和一个输出,所以我们的寄存器堆要有两组输出,每组输出由(输出寄存器编号,输出数据线)和一组输入(输入寄存器编号,输入数据线)。

相应verilog代码如下:

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
module RegisterFile(
input wire read_clk, // 此时钟信号上升沿时从寄存器中读取
input wire write_clk, // 此时钟信号上升沿时向寄存器中写入
input wire [2:0] read_address1, // 要读取的地址1
input wire [2:0] read_address2, // 要读取的地址2
output reg [7:0] out_1, // 读取的输出
output reg [7:0] out_2, // 读取的输入
input wire [2:0] write_address, // 要写入的地址
input wire [7:0] in, // 要写入的值
input wire in_en // 写入使能
);
reg [7:0] data [7:0]; // 寄存器们
wire [7:0] reg0;
wire [7:0] reg1;
assign reg0 = data[0];
assign reg1 = data[1];
always @(posedge read_clk) begin
case (read_address1)
'h0: out_1 <= data[0];
'h1: out_1 <= data[1];
'h2: out_1 <= data[2];
'h3: out_1 <= data[3];
'h4: out_1 <= data[4];
'h5: out_1 <= data[5];
'h6: out_1 <= data[6];
'h7: out_1 <= data[7];
endcase
case (read_address2)
'h0: out_2 <= data[0];
'h1: out_2 <= data[1];
'h2: out_2 <= data[2];
'h3: out_2 <= data[3];
'h4: out_2 <= data[4];
'h5: out_2 <= data[5];
'h6: out_2 <= data[6];
'h7: out_2 <= data[7];
endcase
end
always @(posedge write_clk) begin
if (in_en) begin
case(write_address)
'h0: data[0] <= in;
'h1: data[1] <= in;
'h2: data[2] <= in;
'h3: data[3] <= in;
'h4: data[4] <= in;
'h5: data[5] <= in;
'h6: data[6] <= in;
'h7: data[7] <= in;
endcase
end
end
endmodule

最简单的控制与指令系统

我们来设计一个简单到不能再简单的指令系统:

指令格式

instruction

(你可能已经看出来了,寄存器到寄存器的MOV其实就是“运算为不运算”的运算指令)

程序计数器

为了方便起见,我们暂时不和RAM/ROM或是FLASH进行对接,而是手动实现一个简单的ROM,将程序代码放在里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module ROM(
input wire [7:0] read_address,
output reg [15:0] result
);
always @(read_address) begin
case (read_address)
'h00: result <= 'h1111; // 留空
'h01: result <= 'hB833; // R0 = 33
'h02: result <= 'hB911; // R1 = 11
'h03: result <= 'h0004; // R0 = R0 + R1
'h04: result <= 'hF803; // JMP 03
endcase
end
endmodule

而程序计数器就只不过是一个普通的reg [7:0],放入read_address就能得到要执行的指令。

译码与执行

在执行时我们要把一条指令的执行过程分为三步:

  1. 计算下一个PC值,同时获得要执行的指令。
  2. 对指令进行译码,若有必要的话从寄存器堆中选中操作数对应的寄存器
  3. 将结果写回寄存器堆

那么我们可以做出这样的实现:

首先是构造一个三分频的时钟信号,用于驱动上面三步:

1
2
3
4
5
6
7
8
9
10
module Clock(
input wire global_clk,
output wire [2:0] generated_clks
);
reg [2:0] clk_reg = 'b100;
always @(posedge global_clk) begin
{clk_reg[0],clk_reg} = clk_reg << 1;
end
assign generated_clks = clk_reg;
endmodule

然后是CPU的顶层接口:

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
`include "alu.v"
`include "clock.v"
`include "ROM.v"
`include "register_file.v"
module CPU(
input wire clk
);
wire next_pc_clk;
wire fetch_data_clk;
wire write_back_clk;
// 分频时钟
Clock clock(clk,{write_back_clk,fetch_data_clk,next_pc_clk});
reg [7:0] program_counter = 0;
wire [15:0] instruction_register;
ROM instruction_rom(program_counter,instruction_register);
// 操作数是立即数还是寄存器
wire imm_or_register;
// 是否是跳转
wire jmp;
// ALU操作
wire [2:0] alu_op;
// 选中输入/输出的寄存器编号
wire [2:0] out_reg;
wire [2:0] in_reg1;
wire [2:0] in_reg2;
// 立即数
wire [7:0] imm;
wire [1:0] _;
// 译码
assign {imm_or_register,jmp,alu_op,out_reg,in_reg2,in_reg1,_} = instruction_register;
assign imm = instruction_register[7:0];
// 寄存器堆的输出
wire [7:0] out_1;
wire [7:0] out_2;
// 寄存器堆的输入
wire [7:0] in;
// 寄存器堆是否接受输入
wire in_en;

wire [7:0] op1;
always @(posedge next_pc_clk) begin
// PC+1
program_counter <= program_counter + 1;
if (jmp) begin
// 跳转
program_counter <= imm;
end
end
// 跳转时,寄存器堆不接受输入
assign in_en = jmp?0:1;
RegisterFile register_file(fetch_data_clk,write_back_clk,in_reg1,in_reg2,out_1,out_2,out_reg,in,in_en);
// 第一个操作数根据译码得到的 操作数是立即数还是寄存器 的值,选择使用立即数还是寄存器堆输出1
assign op1 = imm_or_register ? imm : out_1;
ALU alu(op1,out_2,alu_op,in);
endmodule

这样以来这个CPU就能跑了。

Futher work

  1. 和内存对接
  2. 流水线
1. 因此甚至不需要一个IR寄存器。