我们以姜咏江老师的书《自己设计制作CPU与单片机》中一个简单CPU为例子,说明CPU的工作原理和设计过程。
该CPU的程序在设计之初是固定的,并没有设计外部程序输入接口,所以这是一个专用CPU。
jdcpu完整代码

//简单计算机核设计 2009-4-29  解释权姜咏江 Email:accsys@126.com
//参考书:姜咏江.PMC计算机设计与应用.清华大学出版社.2008-5
//说明:这里给出的简单计算机核设计,是初学计算机设计的最好实例。
 
//基本输入时钟clock
//复位控制:reset_n,低电位有效
//基本输出:o
//程序存储器iram,16位,高5位是类指令代码,用imem16_1.mif初始化
//数据存储器dram,16位,不用数据文件初始化
//用lpm存储器地址数据信号要稳定1拍,才可以读写数据
 
//指令格式:高5位指令代码,11位地址码,16位立即数(分高低8位)
 
module jdcpu
    (
        clock,   //系统输入时钟
        reset_n, //复位信号,低电平有效
        o,       //数据输出端口
        //调试输出以观察CPU内部变化,设计完成后删除:
        opc,     //程序计数器观察变量
        omar,    //数据地址寄存器观察变量
        ojp,     //CPU节拍观察变量
        oqw,     //程序存储器输出观察变量
        oda,     //累加器变化观察
        ozf,     //累加器为零标志观察
        osp      //堆栈指针变化观察
    );
    input           clock;
    input           reset_n;
    output [15:0]   o;
 
    output [15:0]   oqw,oda;
    output [10:0]   opc,omar,osp;
    output [2:0]    ojp;
    output          ozf;

CPU内部一般都包含运算器、各种标志寄存器、通用寄存器、累计器、存储器、指令寄存器、程序计数器、通用指针、堆栈指针、节拍器、地址寄存器、输出寄存器,还包括连接这些部件的各种导线,既有单条线,也有成组的线,也称总线。这些内部部件和导线的定义,直接反映了CPU的组成。
存储类型的设备一般用关键字“reg”来定义,属于导线类型的用“wire”关键字来定义。

//CPU内部器件和导线    
    //定义16位的程序存储器输出连接导线和数据存储器输入导线
    wire [15:0] q_w,q_data; 
    //定义保持写数据存储器和写堆栈存储器控制信号的寄存器
    reg         dwren,swren;
    //定义16位用于保持取出指令进行分析的指令寄存器
    reg  [15:0] ir;
    //定义16位的运算器前端寄存器a、b,累加器da,输出寄存器oo和暂存输入数据寄存器ddata
    reg  [15:0] b,a,da,oo,ddata;
    //定义11位的程序计数器pc,地址残存寄存器pc_back,数据存储地址寄存器mar,堆栈指针sp和堆栈输出寄存器q_s
    reg  [10:0] pc,pc_back,mar,sp,q_s;
    //定义3位的CPU节拍寄存器
    reg  [2:0]  jp;

现在的CPU内部都包含有Cache存储器,为了剖析CPU的内部结构,我们将运算器和控制器组成的部分称为执行单元,用PU(Performance Unit)来表示,将PU以外包括存储器和寄存器一类暂时存放数据的设备,统称为存储单元,用MU(Memory Unit)表示。所以一个CPU可以认为由PU和MU构成。 在这个简易CPU中,我们采用程序存储器和数据存储器分开的设计架构,也就是哈佛结构,而且我们将堆栈存储器单独出来,这样该设计中一共有3个存储器,我们通过FPGA厂商提供的Memory IP来生成这些存储器。

//指令存储器:   
    lpm_rom iram(.address(pc),.inclock(clock),.q(q_w));  //程序存储器
    defparam iram.lpm_width = 16;
    defparam iram.lpm_widthad = 11;
    defparam iram.lpm_outdata = "UNREGISTERED";
    defparam iram.lpm_indata = "REGISTERED";
    defparam iram.lpm_address_control = "REGISTERED";
    defparam iram.lpm_file = "imem16_2013.mif";  //初始化文件,放置程序
//数据存储器:  
    lpm_ram_dq dram(.data(ddata),.address(mar),.we(dwren),.inclock(clock),.q(q_data)); //数据存储器
    defparam dram.lpm_width = 16;
    defparam dram.lpm_widthad = 10;
    defparam dram.lpm_outdata = "UNREGISTERED";
    defparam dram.lpm_indata = "REGISTERED";
    defparam dram.lpm_address_control = "REGISTERED";
 
    lpm_ram_dq sram(.data(pc_back),.address(sp),.we(swren),.inclock(clock),.q(q_s)); //堆栈
    defparam sram.lpm_width = 11;
    defparam sram.lpm_widthad = 10;
    defparam sram.lpm_outdata = "UNREGISTERED";
    defparam sram.lpm_indata = "REGISTERED";
    defparam sram.lpm_address_control = "REGISTERED";

CPU的行为描述也就是描述CPU随时间变化的状态,其中包括初始状态和正常运行态。

初始状态的描述

该CPU的初始状态用初始化信号变量resetn来驱动,resetn信号下降沿有效,在always语句体中敏感信号列表中用negedge来申明。

    always @(posedge clock or negedge reset_n)
    begin
    if (!reset_n)
    begin
        pc      <= 0;
        sp      <= 0;
        lda     <= 0;   
        add     <= 0;   
        out     <= 0;   
        sdal    <= 0;   
        sdah    <= 0;   
        str     <= 0;
        sub     <= 0;
        jmp     <= 0;
        jz      <= 0;
        jn      <= 0;
        call    <= 0;
        ret     <= 0;
        mult    <= 0;       
        divi    <= 0;
        jp      <= 0;
    end

这一段初始化程序描述了CPU复位后的初始状态,如果reset_n从1变为0,那么begin…end块中的语句被执行。被复位的除了指令标志之外,还有程序计数器pc、堆栈指针sp和节拍jp。sp的初值设为0,说明堆栈开口向下,数据入栈后,sp加1,而数据出栈前,sp要减1;程序计数器pc初始化为0,说明CPU开始运行,从程序存储器的0地址取指,节拍jp被赋值0,表示CPU指令的动作从0节拍开始;各条指令标志都为0,表示开始时没有确定是哪一条指令执行。
该设计中的全部指令有:

//指令:
    reg         lda,    //取数:从数据单元取数到da
                add,    //加:da与数据单元相加,结果放入da
                out,    //输出:将数据单元内容输出到输出寄存器
                sdal,   //低8位立即数:将8位立即数扩充为16位送da
                sdah,   //高8位立即数:将8位立即数作为高8位,与原da低8位连接成16位放在da中
                str,    //da送数据存储单元:
                sub,    //减:da与数据单元相减,结果放入da
                jmp,    //跳转
                jz,     //da为0跳转
                jn,     //da为负跳转
                call,   //调用子程序
                ret,    //返回
                mult,   //
                divi,   //
                stp;    //停止

取指令周期的描述

CPU的正常运行状态分为取指周期和执行周期,这主要由时钟节拍和指令标志两部分变量确定。节拍jp表明指令执行动作的顺序,而指令标志是用来指示正在执行的指令。
从节拍取指为0开始描述。

//  节拍jp指出的状态: 
        case (jp)
        0:  begin           //空拍,稳定地址寄存器数据需要
                jp <= 1;    //转到1拍 
            end

由于jp=0节拍被用于程序计数器pc将值传递到程序存储器的前端地址寄存器(并不是所有的存储器都要求这样),所以这一拍在外被设定为空操作。在这一拍中,将节拍变量赋值1,从而使CPU运行转到下一个节拍为1的状态。

指令分析的描述

当jp=1时,程序存储器的地址已经被确定好了,所以可以从程序存储器的输出端口得到要取出的指令。一般情况下,应将取出的指令放到指令寄存器ir中分析,目的是防止后面程序存储器的地址有变,从而使输出的指令发生变化。由于我们的设计没有变动存储单元的地址,因而就可以直接对端口输出导线值进行逻辑分析。
这样在jp=1的节拍就可以利用程序存储器的输出,识别出是什么指令,从而约束后面节拍执行的指令。在jp=1的描述如下:

        1:  begin   //依指令前5位编码来识别指令,并将指令标识置位
                case (q_w[15:11])
                5'b00001:   lda     <= 1;   //lda:00001
                5'b00010:   add     <= 1;   //add:00010
                5'b00011:   out     <= 1;   //out:00011
                5'b00100:   sdal    <= 1;   //低8位,扩充有符号16位
                5'b00101:   sdah    <= 1;   //高8位,与前面低8位输入合成16位
                5'b00110:   str     <= 1;   //da送数据单元
                5'b00111:   sub     <= 1;   
                5'b01000:   jmp     <= 1;
                5'b01001:   if (da==0)      jz  <= 1;   //累加器da是0,跳转
                5'b01010:   if (da[15]==1)  jn  <= 1;   //累加器da为负,跳转
                5'b01011:   call    <= 1;
                5'b01100:   ret     <= 1;
                5'b01101:   mult    <= 1;
                5'b01110:   divi    <= 1;
                5'b11111:   stp     <= 1;
                default:    jp <= 0;
                endcase     //节拍区分指令结束
                jp <= 2;    //转到jp=2的状态
            end

qw[15:0]是全部数据,qw[15:11]是指令代码,依据这5位的数值来确定是哪一条指令在执行,继而将相应的指令标志赋值1,指令标志指示该条指令是否处于执行状态。在1节拍中,指令jz和jn除了节拍之外还有累加器限制。
如果将节拍的0状态称为取指令,1状态称为分析指令,那从节拍2状态开始就进入了指令的执行过程。

指令执行周期的描述

指令执行周期的详细描述实际上是CPU设计最核心的部分。

2:  begin                               //CPU进入jp=2的状态        
                case (q_w[15:11])               //用指令编码确定指令
                5'b00001:   begin               //lda <= 1; 
                                mar<=q_w[10:0]; //数据地址给到数据地址寄存器
                                jp <= 3;        //转到jp=3的状态
                            end
                5'b00010:   begin               //add <= 1; 
                                mar<=q_w[10:0];
                                jp <= 3;
                            end
                5'b00011:   begin               //out <= 1;
                                mar<=q_w[10:0];
                                jp <= 3;
                            end
 
                5'b00100:   begin               //sdal <= 1;
                                da <= {{8{q_w[7]}},q_w[7:0]}; //将指令中写的8位立即数扩充成16位有符号数送到累加器da
                                sdal<= 0;                     //sdal指令执行完成
                                pc <= pc+1;                   //准备取下一条指令
                                jp<= 0;                       //节拍状态复位
                            end
 
                5'b00101:   begin               //sdah <= 1;
                                da[15:0] <= {q_w[7:0],da[7:0]}; //将指令中写的8位数放入累加器的高8位,累加器低8位数不变
                                sdah <= 0;                      //sdal指令执行完成
                                pc <= pc+1;                     //准备取下一条指令
                                jp<= 0;                         //节拍状态复位
                            end 
 
                5'b00110:   begin               //str <= 1;
                                mar<=q_w[10:0];
                                ddata <= da;    //累加器da送数据存储器
                                jp <= 3;        //指令str未执行完,转jp=3
                            end
                5'b00111:   begin               //sub <= 1; 
                                mar<=q_w[10:0]; 
                                jp <= 3;
                            end
 
                5'b01000:   begin               //jmp <= 1;
                                pc <= q_w[10:0];//将跳转程序地址送程序计数器
                                jmp <=0;        //跳转指令完成
                                jp <= 0;
                            end
                5'b01001:   begin               //jz <= 1;
                                if (jz) pc <= q_w[10:0];//如果da=0则跳转
                                else    pc <= pc+1;     //不然执行下一条指令
                                jz <=0;
                                jp <= 0;
                            end
 
                5'b01010:   begin               //jn <= 1;
                                if (jn) pc <= q_w[10:0];
                                else    pc <= pc+1;
                                jn<=0;
                                jp <= 0;
                            end
                5'b01011:   begin               //call <= 1;
                                pc_back <= pc+1;//保存下一条指令的地址
                                jp <= 3;
                            end
 
                5'b01100:   begin               //ret <= 1;
                                jp <= 3;
                            end
                5'b01101:   begin               //mult<= 1; 
                                mar<=q_w[10:0];
                                jp <= 3;
                            end
                5'b01110:   begin               //divi <= 1;    
                                mar<=q_w[10:0];
                                jp <= 3;
                            end
                5'b11111:   jp<=0;              //stp指令,返回jp=0状态
                default:    jp <= 0;            //其他情况一律节拍返回jp=0状态
                endcase
            end 

jp=2状态结束后,sdal、sdah、jmp、jz、jn等指令就已经执行完成了,这说明这几条指令的指令周期只有3个时钟节拍。指令执行完成后,指令标识和节拍变量切记归零,若不是转移指令,还要讲程序计数器pc加1,以便CPU去取下一条指令;如果没有执行完成,那么将节拍状态改为下一个。
节拍状态jp=2之后,没有完成的指令要进入jp=3的状态。如果在jp=2状态中,有向存储器地址寄存器传送了数据的动作,那么就要空操作一拍,即在jp=3的状态中,直接将jp的值设定为4,就此转到下一个节拍。

3:  begin 
                case (q_w[15:11])
                5'b00001:   begin           //lda <= 1; 
                                jp <= 4;
                            end
                5'b00010:   begin           //add <= 1; 
                                jp <= 4;
                            end
 
                5'b00011:   begin           //out <= 1;
                                jp <= 4;
                            end
 
                5'b00110:   begin           //str <= 1;
                                dwren <= 1;
                                jp <= 4;     
                            end
                5'b00111:   begin           //sub <= 1; 
                                jp <= 4;
                            end
 
                5'b01011:   begin           //call <= 1;
                                pc <= q_w[10:0];//pc接收子程序地址
                                swren <= 1;     //发出写堆栈信号
                                jp <= 4;
                            end
 
                5'b01100:   begin           //ret <= 1;
                                sp <= sp-1;
                                jp <= 4;
                            end
                5'b01101:   begin           //mult <= 1;    
                                jp <= 4;
                            end
                5'b01110:   begin           //divi <= 1;    
                                jp <= 4;
                            end
                default:    jp <= 0;
                endcase
            end

在这一段描述中,只有call指令有实质性动作,其他指令都是空操作,为什么凡是在上一节拍中向存储器传送地址的指令,在此都要空一拍呢,这是因为我们使用的存储器前端都有特殊寄存器,我们在设计时并不能对这个特殊寄存器进行直接操作,特殊寄存器得到地址数据,还要通过一个时钟节拍传递才行。特殊寄存器接收数据的过程是在存储器内部进行的,设计过程中需要空置一拍。

4:  begin
                case (q_w[15:11])
                5'b00001:   begin           //lda <= 1; 
                                da<=q_data; //存储单元数据送累加器
                                pc <= pc+1;
                                jp <= 0;
                                lda<= 0;    //lda指令执行完成
                            end
                5'b00010:   begin           //add <= 1; 
                                b<=q_data;  //存储单元数据送前端寄存器b
                                a<=da;      //累计器da内容送前端寄存器a
                                jp <= 5;
                            end
                5'b00011:   begin             //out <= 1;
                                oo <= q_data; //将数据存储单元输出
                                pc <= pc+1;
                                jp <= 0;
                                out<= 0;
                            end
 
                5'b00110:   begin           //str <= 1;
                                dwren <= 1; //发出写数据寄存器信号
                                jp <= 5;    
                            end
                5'b00111:   begin           //sub <= 1; 
                                b<=q_data;
                                a<=da;
                                jp <= 5;
                            end
 
                5'b01011:   begin           //call <= 1;
                                sp <= sp+1; //写完堆栈之后,堆栈指针前移一位
                                swren <= 0; //停止写堆栈信号
                                jp <= 5;
                            end
 
                5'b01100:   begin           //ret <= 1;
                                pc <= q_s;  //返回地址送到程序计数器
                                ret <= 0;
                                jp <= 0;
                            end
                5'b01101:   begin           //mult <= 1;    
                                b<=q_data;
                                a<=da;
                                jp <= 5;
                            end
                5'b01110:   begin           //divi <= 1;    
                                b<=q_data;
                                a<=da;
                                jp <= 5;
                            end                                                     
                default:    jp <= 0;
                endcase
            end

在这一节拍完成的指令有lda、out、ret,除了ret之外,在结束时都对pc进行了加1操作,目的是让CPU转到下一条指令取指执行。
执行到这里,jp=5时就只剩下6条指令了,由于它们执行时基本动作较多,因而占用的时钟节拍也多。

5:  begin
            case (q_w[15:11])
            5'b00010:   begin           //add <= 1; 
                            da <= a+b;  //相加结果送累加器da
                            pc <= pc+1;
                            add <=0;
                            jp <= 0;
                        end
 
            5'b00110:   begin           //str <= 1;
                            dwren <= 0; //结束写存储器信号
                            pc <= pc+1;
                            str <=0;
                            jp <= 0;     
                        end
            5'b00111:   begin           //sub <= 1; 
                            da <= a-b;  //将减法运算结果送累加器da
                            pc <= pc+1;
                            sub<=0;
                            jp <= 0;
                        end
            5'b01011:   begin           //call <= 1;
                            swren <= 0; //结束写堆栈信号
                            call<=0;
                            jp<=0;
                        end
 
            5'b01101:   begin           //mult <= 1;    
                            da <= a*b;  //将乘法运算结果送累加器
                            pc <= pc+1;
                            mult <=0;
                            jp <= 0;                        
                        end
            5'b01110:   begin           //divi <= 1;    
                            da <= a/b;  //将除法运算结果送累加器
                            pc <= pc+1;
                            divi <=0;
                            jp <= 0;                        
                        end                         
            default:    jp <= 0;
            endcase
        end

如何验证我们设计的CPU是成功的呢?
办法只有用CPU指令系统编写程序,运行在这个CPU上面,如果结果是正确的,那说明我们的设计是成功的。
这个CPU支持的指令有

//指令:
     lda,    //取数:从数据单元取数到da
     add,    //加:da与数据单元相加,结果放入da
     out,    //输出:将数据单元内容输出到输出寄存器
     sdal,   //低8位立即数:将8位立即数扩充为16位送da
     sdah,   //高8位立即数:将8位立即数作为高8位,与原da低8位连接成16位放在da中
     str,    //da送数据存储单元:
     sub,    //减:da与数据单元相减,结果放入da
     jmp,    //跳转
     jz,     //da为0跳转
     jn,     //da为负跳转
     call,   //调用子程序
     ret,    //返回
     mult,   //乘:da与数据单元相乘,结果放入da
     divi,   //除:da除以数据单元,结果放入da
     stp;    //停止

设计用于检验的汇编程序

我们用该CPU的指令系统编写一个能够求出8!(8的阶乘)的汇编程序

start:	sdal 1		  ;1送到累加器da的低8位
	str	one	  ;累加器内容送到数据存储器的one单元
	str	result	  ;1送到数据存储器的result单元
	sdal 8		  ;8送到累加器da的低8位
	str	x	  ;将累加器内容送数据存储器x单元
loop:	lda	x	  ;将x单元的数据送到累加器da
	jz	exit	  ;如果da=0则跳转到exit地址取指令执行
	mult    result    ;da的值乘以result的值,结果送到da
	str	result	  ;将da的值回送到result
	lda	x	  ;将x单元的值送到da
	sub	one	  ;da-1送到da
	str	x	  ;再将da值送回x
	jmp	loop	  ;转到loop地址取指令执行
exit:	out	result	  ;输出最终结果
	stp		  ;停止CPU运行			

用表来编译汇编程序

现在程序的编译一般都有专门的汇编器,这里我们以人工绘表的方式来编译,这种程序编译表格是最基本的编译工具。

左边“地址”一栏是程序存储器或数据存储器的地址编号,“标号”和“汇编程序”两栏是汇编程序,“二进制编码”一栏是二进制数的机器指令,“编译”一栏是十六进制的机器指令,“数据”一栏是数据变量的位置分配。最后两栏是该设计中的CPU指令和编码。
这个汇编程序使用了3个16位的数据变量 one、result、x。它们在存储器中的位置被安排在1,2,3号存储单元。
程序计数器pc的初始值是0,我们将标号start定位0号存储单元,依次往下排可以得到loop标注5号存储单元,exit是13号存储单元。
二进制编译一栏是对指令操作码和操作数的译码。左面5位是对应指令的编码,如sdal 编码是00100,右面的11位数是对应操作数的编码,其编码依据指令格式来确定。如果操作数是变量型操作数即one,result,x,则对应变量的存储单元地址1,2,3。如果操作数是立即数,则直接使用立即数本身。