三步教你用Verilog写一个CPU
第二步:渐入佳境
基础
课程要求:数字电路、计算机组成原理、程序设计
编程语言:Verilog
开发平台:xilinx ISE
FPGA开发板:Nexys3
教学大纲
第一步
指令集设计与五级流水线的实现
第二步
内存设计与CPU测试
第三步
指令冲突避免
实现目标
上一步的时候,我们已经把CPU的整体框架给写出来了,但是除了几百行代码之外还没看到有什么具体的成果。没错,我讲过设计CPU可以很快见到成果,这第二步就是教你怎样把代码转变为一个可运行的CPU,然后对CPU进行测试。
CPU测试有多种方法,我们从最简单粗暴的实时赋值开始,到内存模块的实现,从软件仿真到硬件的板级验证,从开关选择的信号LED显示到基于VGA的信号显示,这些都是这一步要完成的。
这一部分内容比较多,写得比较乱,不过所有测试方法的代码实现可以在文档末尾下载到。
CPU测试方法一:实时赋值(软件仿真)
在ISE里新建一个pcpu的测试文件,然后只要在合适的时候分别给i_datain、d_datain赋值就可以了,这是最简单粗暴的方法。注意到,在真正执行的指令之间插入了很多NOP指令,原因就在于指令之间的冲突,本来要从内存读取一个数据到寄存器r2,结果这一步还没完成,下一条指令又要用到r2的值,这样就会产生冲突,关于指令冲突的问题将会在第三步解决。
// pcpu_test.v `timescale 1ns / 1ps `include "def.v" module pcpu_test; // Inputs reg clock; reg enable; reg reset; reg start; reg [15:0] i_datain; reg [15:0] d_datain; // Outputs wire [7:0] i_addr; wire [7:0] d_addr; wire d_we; wire [15:0] d_dataout; // Instantiate the Unit Under Test (UUT) pcpu uut ( .clock(clock), .enable(enable), .reset(reset), .start(start), .i_datain(i_datain), .d_datain(d_datain), .i_addr(i_addr), .d_addr(d_addr), .d_we(d_we), .d_dataout(d_dataout), ); initial begin // Initialize Inputs clock = 0; enable = 0; reset = 1; start = 0; i_datain = 0; d_datain = 0; select_y = 0; // Wait 100 ns for global reset to finish #100; // Add stimulus here $display("pc : id_ir : reg_A : reg_B : reg_C : da : dd : w : reC1 : gr1 : gr2 : gr3 : zf : nf : cf"); $monitor("%h : %b : %h : %h : %h : %h : %h : %b : %h : %h : %h : %h: %b: %b: %b", uut.pc, uut.id_ir, uut.reg_A, uut.reg_B, uut.reg_C, d_addr, d_dataout, d_we, uut.reg_C1, uut.gr[1], uut.gr[2], uut.gr[3], uut.zf, uut.nf, uut.cf); enable <= 1; start <= 0; i_datain <= 0; d_datain <= 0; select_y <= 0; #10 reset <= 0; #10 reset <= 1; #10 enable <= 1; #10 start <=1; #10 start <= 0; i_datain <= {`LOAD, `gr1, 1'b0, `gr0, 4'b0000}; #10 i_datain <= {`LOAD, `gr2, 1'b0, `gr0, 4'b0001}; #10 i_datain <= {`NOP, 11'b000_0000_0000}; #10 i_datain <= {`NOP, 11'b000_0000_0000}; d_datain <=16'h00AB; // 3 clk later from LOAD #10 i_datain <= {`NOP, 11'b000_0000_0000}; d_datain <=16'h3C00; // 3 clk later from LOAD #10 i_datain <= {`ADD, `gr3, 1'b0, `gr1, 1'b0, `gr2}; #10 i_datain <= {`NOP, 11'b000_0000_0000}; #10 i_datain <= {`NOP, 11'b000_0000_0000}; #10 i_datain <= {`NOP, 11'b000_0000_0000}; #10 i_datain <= {`STORE, `gr3, 1'b0, `gr0, 4'b0010}; #10 i_datain <= {`BNZ, `gr1, 8'b00100001}; #10 i_datain <= {`NOP, 11'b000_0000_0000}; #10 i_datain <= {`NOP, 11'b000_0000_0000}; #10 i_datain <= {`NOP, 11'b000_0000_0000}; #10 i_datain <= {`CMP, 4'b0000, `gr1, 1'b0, `gr2}; // 4 clk later from BNZ #10 i_datain <= {`HALT, 11'b000_0000_0000}; end always #5 clock = ~clock; endmodule
软件仿真之后,可以看到如下的信号监控图:
CPU测试方法二:内存实现(软件仿真)
内存其实就是一个 reg 数组,读内存用组合逻辑电路,写内存用时序电路,如下图代码所示。对内存的初始化可以通过 reset 信号,或者软件仿真的时候可以直接在 test bench 文件的 initial 里面初始化。
这时候,pcpu的测试文件就要另外再通过memory.v实例化两个内存模块i_mem、d_mem分别用作指令内存和数据内存了,pcpu模块与内存有关的所有信号(i_datain、d_datain等等)都要连接到这两个模块。initial初始化内存的方式如下图:
另外一种reset信号初始化内存的方式需要分开定义指令内存i_memory.v和数据内存d_memory.v,然后在pcpu的测试文件里面实例化这两个文件的模块。d_memory.v大致如下:
CPU测试方法三:开关选择LED信号显示(板级验证)
在CPU设计第一步的时候,有讲到两个信号select_y和y,第一个是输入,第二个是输出,通过开关的控制可以在FPGA上面通过LED显示cpu内部的关键信号,比如reg_A或者reg_B的值。因为我用的FPGA是Nexys3开发板,上面只有8个LED,最多只可以同时显示寄存器的8位,因此我选择了只显示低8位。
在pcpu.v文件添加select_y和y这2个输入输出信号,然后添加下面这一段关键代码:
添加管脚约束文件的时候必须注意,CPU的时钟不能连接到FPGA的系统时钟,否则频率太高了根本没法调试,应该把CPU时钟连接到一个按钮,这样手按一下执行一条指令。综合、翻译、映射、布线之后,生成二进制文件,把文件下载到FPGA,这时候你就真的拥有了一个可以运行的CPU,你会看到指令一条条往下执行,LED灯随着PC寄存器的值步步加1在不停地变换。当然,这个过程耗费的时间也是挺多的,在我的电脑上,一次从综合到生成二进制文件的过程就要半小时到一个多小时的时间。
CPU测试方法四:VGA信号显示(板级验证)
如果你需要做出一个真正可靠能用的CPU的话,那一定不能在板级验证这一步有丝毫的疏忽,而基于上面的开关选择信号显示的方法效率实在是太低,最实际的方法就是一次过在显示器上面显示CPU内部所有关键信号的值。这种方法自然是可以,FPGA本来就可以实现VGA显示,我之前也写过相关的博客,有兴趣可以参照一下。
要实现VGA显示,首先pcpu模块就要增加许多输出信号,将内部关键信号作为输出交付给VGA_info_display 模块,以达到在显示器同时显示多组信号值的效果,方便测试。VGA_info_display 模块其实就是一组寄存器(或者说内存),保存了 VGA 特定显示区域每个像素的值,寄存器的值以字符为单位,通过RAM_set 模块进行设置。
要通过VGA显示两行字符,从代码量上来讲确实不是一件简单的事情,我也没有什么好的方法,只能一个字符实例化一个RAM_set模块,因此有接近两百个RAM_set模块的实例,总共两千多行十分相似的代码,我当然没有耐性一行一行敲出来,就是复制粘贴着修改也够累人的了,我是写了个一百多行的shell脚本文件,然后瞬间生成的两千多行代码。