外行也来看看热闹
这些年RISCV红火, 打算研究一下RV, 我一个同学兼同事写了著名的tinyriscv的项目, 而我对这一块刚刚入门,在学习RV之前, 我打算自己实现一个自己设计的RISC处理器, 看看能够领悟到什么。
先定义一下指令集和寄存器, 其实处理器和DDS发生器的架构有些类似, 只不过内存里面的数据从正弦查找表变成指令, 同时输出的数据会变成ALU的变量,为了方便处理,通常RISC的指令都是32bit的, 即四个字节 如0xff000000
我们的指令定义成下面的格式
// instru format : 32bit // 0x-xx----xx---xx---xxxx // | | | | // op r1 r2 data
op就是操作码, 如加减乘除 , r1是第一个寄存器的索引, r2是第二个寄存器的索引, 剩下的16位是立即数。
而寄存器的索引如下:
// 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寄存器里面的。
接着说一下指令,直接列出
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不外乎干几件事情:取指令、运行指令、取内存数据、写内存数据、等待、 错误, 那么对应的就是:
parameter INIT = 0; parameter FETCH = 1; parameter EXEC = 2; parameter LOAD = 3; parameter STORE = 4; parameter IDLE = 5; parameter ERROR = 6;
这里额外加了一个初始化状态。
让CPU干活, 可以利用Verilog的case语句, 如下面这样
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系统的乘法器, 比自己写快很多
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。
RAM ram( .address(ram_data_addr[9:0]), .clock(clk_low), .data(ram_data_in), .wren(ram_wr), .q(ram_data_out));
注意我这里设计的CPU是冯诺依曼架构的, 也就是说内存既装指令也装数据, 这个其实是个大坑, 调试取数据存数据的指令时, 会有一些时序的影响。这也让代码写得很垃圾。
给这个内存加入一个mif文件,也就是初始的程序, 内容如下:
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的值
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
VID_20240623_223854037.mp4 点击下载
[修改于 5个月13天前 - 2024/06/24 08:50:24]
正好最近再研究简单CPU,这种能徒手现编的CPU有个分类叫做 极简CPU。
这里面有个非常优秀的开源设计 J1,我在高云开发板 1k资源就能跑的开,还能添加几个串口。
唯一比较麻烦的是J1是面向forth语言开发的,是个堆栈型CPU,这是历史上存在的CPU流派,没有寄存器,操作数是若干个硬件堆栈。
forth语言是个很烧脑的东西,抽象等级比汇编高一点点。学起来容易但是用起来很难,很考验智力。用得好的高手,能在1k资源fpga里面,跑起来串口交互和forth编译器。
XXXXXXXXXXXXXXXXXX/jamesbowman/j1
时段 | 个数 |
---|---|
{{f.startingTime}}点 - {{f.endTime}}点 | {{f.fileCount}} |
200字以内,仅用于支线交流,主线讨论请采用回复功能。