外行也来看看热闹
这些年RISCV红火, 打算研究一下RV, 我一个同学兼同事写了著名的tinyriscv的项目, 而我对这一块刚刚入门,在学习RV之前, 我打算自己实现一个自己设计的RISC处理器, 看看能够领悟到什么。
先定义一下指令集和寄存器, 其实处理器和DDS发生器的架构有些类似, 只不过内存里面的数据从正弦查找表变成指令, 同时输出的数据会变成ALU的变量,为了方便处理,通常RISC的指令都是32bit的, 即四个字节 如0xff000000
我们的指令定义成下面的格式
C++// instru format : 32bit
// 0x-xx----xx---xx---xxxx
// | | | |
// op r1 r2 data
op就是操作码, 如加减乘除 , r1是第一个寄存器的索引, r2是第二个寄存器的索引, 剩下的16位是立即数。
而寄存器的索引如下:
C++// reg index
// zo : 0 always 0
// ra : 1 alu parameter 1
// rb : 2 alu parameter 2
// rc : 3 alu parameter 3
// rd : 4 alu output 1
// re : 5 alu output 2
// rf : 6 temp 1
// rg : 7 temp 2
// rh :8 temp 3
// ri : 9 temp 4
// rj : 10 temp 5
// rk : 11 temp 6
// rl : 12 temp 7
// rm : 13 temp 8
// rn : 14 temp 9
// ro : 15 temp 10
// mo : 16 mode and status
// the op is as blow
如果看其他的指令集, 如ARM和MIPS, 通常指令是有三个寄存器索引的, 这里我偷懒,把输出的寄存器永远定义成了rd,即第四个寄存器, 如执行add rb, ra 命令, 那么, 其实rb + ra的结果是在rd寄存器里面的。
接着说一下指令,直接列出
C++parameter NOP = 8'hff; // nop
parameter HALT = 8'h00; // halt
parameter LDH = 8'h01; // ldh rn, xxxx
parameter LDL = 8'h02; // ldl rn, xxxx
parameter MOV = 8'h05; // mov rn, rm
parameter CLR = 8'h13; // clr rn
parameter INC = 8'h14; // inc rn
parameter DEC = 8'h15; // dec rn
parameter ADD = 8'h16; // add rn, rm
parameter SUB = 8'h17; // dec rn, rm
parameter MUL = 8'h18; // mul rn, rm
parameter DIV = 8'h19; // div rn, rm
parameter LSF = 8'h1a; // lsf rn, rm
parameter RSF = 8'h1b; // rsf rn, rm
parameter AND = 8'h20; // and rn, rm
parameter OR = 8'h21; // or rn, rm
parameter XOR = 8'h22; // xor rn, rm
parameter NOT = 8'h23; // not rn
parameter STACK = 8'h40; // stack rn
parameter POP = 8'h62; // pop rm
parameter PUSH = 8'h51; // push rn
parameter STR = 8'h53; // str rn, [rm]
parameter STD = 8'h54; // std rn, [0000]
parameter IFEQ = 8'h33; // ifeq rn, rm
parameter IFNQ = 8'h34; // ifnq rn, rm
parameter IFLG = 8'h35; // iflg rn, rm
parameter IFSM = 8'h36; // ifsm rn, rm
parameter JPR = 8'h37; // jpr rn
parameter JPD = 8'h38; // jpd xxxx
parameter LDR = 8'h63; // ldr rn, [rm]
我们的指令主要分成几类, 一类是寄存器存取指令, 如LDH 、LDL、MOV这些, 一类是算数指令, 如ADD、DEC、MUL、DIV这些, 一类是跳转指令, 即ifeq、 ifnq, jpr(跳到寄存器的地址), jpd等等。还有以内是内存操作指令, 如LDR、STR、STD, 这些指令都非常消耗时间。
从上面的指令和寄存器来看, 我们的指令集定义得非常的简单易懂, 甚至可以用手直接写代码
比如想把mul rb, ra 这条指令翻译成机器码, 那我们只要查上面的定义:
mul是0x18
rb是2
ra是1
立即数全是空的, 也就是0000
那么吧上面的数字拼起来, 这个指令就是0x18210000, 非常简单吧
有了指令了,接着还要定义处理器的运行状态, 通常CPU不外乎干几件事情:取指令、运行指令、取内存数据、写内存数据、等待、 错误, 那么对应的就是:
C++parameter INIT = 0;
parameter FETCH = 1;
parameter EXEC = 2;
parameter LOAD = 3;
parameter STORE = 4;
parameter IDLE = 5;
parameter ERROR = 6;
这里额外加了一个初始化状态。
让CPU干活, 可以利用Verilog的case语句, 如下面这样
C++ case (op)
HALT : begin
state <= ERROR;
end
NOP : begin
exec_done <= 1'b1;
end
LDH : begin
rn[r1][31:16] <= da;
exec_done <= 1'b1;
end
LDL : begin
rn[r1][15:0] <= da;
exec_done <= 1'b1;
end
CLR : begin
rn[r1] <= 0;
exec_done <= 1'b1;
。。。。。。。。
其中加减乘除,虽然有不少FPGA开发的书建议手写乘法器和除法器,但实际上系统已经有很好的乘法器给我们用来, 直接写 *和/ 综合是可以用altera或者Xilinx系统的乘法器, 比自己写快很多
C++ ADD : begin
rn[4] <= rn[r1] + rn[r2];
if( clk_cont > 2 )
exec_done <= 1'b1;
else
exec_done <= 1'b0;
clk_cont <= clk_cont + 1;
end
SUB : begin
rn[4] <= rn[r1] - rn[r2];
if( clk_cont > 2 )
exec_done <= 1'b1;
else
exec_done <= 1'b0;
clk_cont <= clk_cont + 1;
end
MUL : begin
rn[4] <= rn[r1] * rn[r2];
if( clk_cont > 10 )
exec_done <= 1'b1;
else
exec_done <= 1'b0;
clk_cont <= clk_cont + 1;
end
CPU有了,还得有内存让它才能加载数据跑起来, 这里定义了一个ram。
C++ RAM ram(
.address(ram_data_addr[9:0]),
.clock(clk_low),
.data(ram_data_in),
.wren(ram_wr),
.q(ram_data_out));
注意我这里设计的CPU是冯诺依曼架构的, 也就是说内存既装指令也装数据, 这个其实是个大坑, 调试取数据存数据的指令时, 会有一些时序的影响。这也让代码写得很垃圾。
给这个内存加入一个mif文件,也就是初始的程序, 内容如下:
C++DEPTH = 256;
WIDTH = 32;
ADDRESS _RADIX = HEX;
DATA_RADIX = HEX;
-- Specify initial data values for memory, format is address : data
CONTENT BEGIN
[00..FF] : 00000000;
-- Initialize from course website data
00 : ff000000; --nop
04 : ff000000; --nop
08 : 02100005; --ldl ra, 0005 //ra = 0005
0C : 02200001; --ldl rb, 0001
10 : 01201000; --ldh, rb, 1000 //rd = 1000 0001
14 : 16210000; --add rb, ra //rd = 1000 0006
18 : 17210000; --sub rb, ra // rd = 0fff fffc
1C : 18210000; --mul rb, ra // rd = 5000 0005
20 : 19210000; -- div rb, ra // rd = 0333 3333
24 : 13100000; --clr ra
28 : 02100000; --ldl ra 0000
2C : 544000f0; --sta rd ,00f0
30 : 13400000; --clr rd
34 : 13400000; -- clr rd
38 : 634000f0; -- ldr rd ,00f0
3C : 38000000;
END;
这段程序对CPU的加减乘除进行了测试, 结果存入00f0地址,再取出来到rd,在跳到0000首地址
为了观察结果, 我用LED数码管和LCD把寄存器打出来, 还有通过流水灯显示PC的值
C++LCD_Display lcd_driver(KEY[0], CLOCK_50, ins_out, rd_out, LCD_RS, LCD_EN, LCD_RW, LCD_DATA)
SEG7_LUT_8 uled ( .oSEG0(HEX0),
.oSEG1(HEX1),
.oSEG2(HEX2),
.oSEG3(HEX3),
.oSEG4(HEX4),
.oSEG5(HEX5),
.oSEG6(HEX6),
.oSEG7(HEX7),
.iDIG(rd_out) );
基本上就是这些, 但实际中还有不少恶心的调试,特别对我这种不喜欢仿真的人来说。
最终的代码如下:
综合的结果如下:
占用的资源并不多
运行的视频结果如下:
实际上可以运行得更快,频率可上100M,但为了看到运行, LCD数字和数码管的变化, 这里把时钟设置成了1M
[修改于 10个月1天前 - 2024/06/24 08:50:24]
正好最近再研究简单CPU,这种能徒手现编的CPU有个分类叫做 极简CPU。
这里面有个非常优秀的开源设计 J1,我在高云开发板 1k资源就能跑的开,还能添加几个串口。
唯一比较麻烦的是J1是面向forth语言开发的,是个堆栈型CPU,这是历史上存在的CPU流派,没有寄存器,操作数是若干个硬件堆栈。
forth语言是个很烧脑的东西,抽象等级比汇编高一点点。学起来容易但是用起来很难,很考验智力。用得好的高手,能在1k资源fpga里面,跑起来串口交互和forth编译器。
https://github.com/jamesbowman/j1
200字以内,仅用于支线交流,主线讨论请采用回复功能。