LoginSignup
9
10

More than 3 years have passed since last update.

Verilogでプロセッサを作ろう

Last updated at Posted at 2017-12-23

この記事について

どうも,最近研究とか就活とか学会とかで忙しすぎてメンヘラしてるむしさんです.
この記事は IQが1 Advent Calendar 2017 の24日目の記事です.明日はchakku_000君が華麗にまとめてくれるでしょう.
みなさんめりーくりすますいぶ!!!
Verilogしらんよってヒトはここを見るといいっぽい〜(o_o)
あと読み飛ばしたいヒトはプロセッサの設計から読むと心に優しい(かも).
ぼくはIQが1なのでアセンブリの気持ちを知りたくなってしまったのです...
そこでVerilogでめっちゃ簡単なプロセッサを作るよって話です...

使用するアセンブリ

プロセッサを作るといっても使用するアセンブリ言語と対応するbit列が決まらないとなにもできません.今回は,オレオレアセンブリを動かすようなプロセッサを設計していきたいと思います(初心者なので設計が下手な部分があると思いますがお許しを).

オレオレアセンブリの定義

今回は以下の命令を考えたいと思います.$rs $rt $rd はレジスタ番号が入ります.また,0番レジスタは常に0が格納されているものとします.

  • add命令: add $rs $rt $rd でレジスタrsとrtの中身の和をレジスタrdに格納します
  • addi命令: addi $rs $rt imm でレジスタrsの中身とimmの和をレジスタrtに格納します
  • bne命令: bne $rs $rt imm でレジスタrsとrtの中身が異なるときにプログラムカウンタをimmだけ加算します
  • lw命令: lw $rs $rt imm でレジスタrsの中身とimmの和が指し示すアドレスに格納されている値をレジスタrtにメモリからロードします
  • sw命令: sw $rs $rt imm でレジスタrsの中身とimmの和が指し示すアドレスにレジスタrtの値を格納します
  • nop命令: nop でなにもしない命令です
  • halt命令: halt でプロセッサを停止します

アセンブリの例

たとえば,長さ5の配列A(A[0]のアドレスは16とする)の総和を求めて,A[5]に格納するアセンブリは以下のようになります.

addi 0 1 16  //レジスタ1を16に初期化
addi 0 2 5   //レジスタ2を5に初期化
addi 0 0 3   //レジスタ3を0に初期化.これは和を表す変数
addi 0 0 4   //レジスタ4を0に初期化.これはループ用変数
lw   1 5 0   //レジスタ5にA[$1]をロード
add  3 5 3   //レジスタ3と5の和を3に格納
addi 4 4 1   //レジスタ4をインクリメント
addi 1 1 4   //アドレスは4bitごとに割り振るので,レジスタ1はA[$4]のアドレス
bne  4 2 -20 //レジスタ2と4を比較してlw命令にとぶ
sw   1 3 0   //レジスタ3の値をA[5]に格納
halt         //終わり

ここでbne命令で飛び先のアドレスを指定する際に,プログラムカウンタは次の値を基準とするので-20でlw命令にジャンプすることに注意してください.

アセンブリとbit列の対応

以上でアセンブリの書き方はわかっていただいたと思いますが,プロセッサでアセンブリ命令を解釈するためにはbit列になおす必要があります.ここではアセンブリ命令からbit列への直し方を定義していきます.
なお,以下では {5:rs} などと表記しますが,これはrsのレジスタ番号を5bitで書くということです.

  • add命令: {6:0}{5:rs}{5:rt}{5:rd}{11:0}
  • addi命令: {6:1}{5:rs}{5:rt}{16:imm}
  • bne命令: {6:2}{5:rs}{5:rt}{16:imm}
  • lw命令: {6:3}{5:rs}{5:rt}{16:imm}
  • sw命令: {6:4}{5:rs}{5:rt}{16:imm}
  • nop命令: {6:5}{26:0}
  • halt命令: {6:6}{26:0}

たとえば add 1 2 3000000 00001 00010 00011 00000000000 となります.

実行環境

ここで実行環境について説明しておきます.僕は,Icarus Verilogでコンパイルしてgtkwaveで波形をみます.詳しくはここここからどうぞ.

topモジュールの作成

前回の文法の記事では触れられなかったのですが,topモジュールの作成法をここで説明します.topモジュールとはC言語のmain文みたいなものです.
まず,ハードウェアには同期をとるためのクロックが必要ですよね.これは以下のように書きます.


module top();
    reg CLK;

    initial begin
        CLK = 0;
        forever #50 CLK = ~CLK;
    end
endmodule

これで100ns周期のクロックが作成できます.また,開始直後はすべての値が不定値をとるため,初期化用の信号を加えてみます.


module top();
    reg CLK, RST_X;

    initial begin
        CLK = 0;
        forever #50 CLK = ~CLK;
    end

    initial begin
        RST_X = 0;
        #300 RST_X = 1;
    end
endmodule

これで300nsまではRST_Xが0となり初期化用の時間が確保されました.

あとは,これから作るプロセッサモジュールをPROCESSORとして,以下のようにインスタンスを作成しておきましょう.

module top();
    reg CLK, RST_X;

    initial begin
        CLK = 0;
        forever #50 CLK = ~CLK;
    end

    initial begin
        RST_X = 0;
        #300 RST_X = 1;
    end
    PROCESSOR p(CLK, RST_X);
endmodule

これでひとまずはtopモジュールの作成終了です.

プロセッサの設計

次にプロセッサの設計に入りましょう.C言語などを書くときと同様,verilogを書くときにも作りたいハードウェアを設計してからコードを書きましょう.今回は命令が実行されるサイクルとして以下の5段階があると考え,それぞれのステージに必要なハードウェアを設計していきましょう.

  • Instruction Fetch (IF) ステージ:命令をメモリから取ってくるステージです
  • Instruction Decode (ID) ステージ:IFステージで取ってきた命令をデコードして,レジスタの読み込みを行うステージです
  • Execute (EX) ステージ:IDステージで読み込んだ値を使用して演算を行うステージです
  • Memory Access (MA) ステージ:データメモリへのアクセスを行うステージです
  • Write Back (WB) ステージ:データをレジスタに書き込むステージです

コーディング

Instruction Fetchステージ

命令をメモリから取ってくるには,まず命令を格納するメモリを作成しなくてはいけません.メモリはアドレスを受け取ってデータを読み取ります.また,今回はあとで使いまわすために,書き込み可能なメモリを作成するので書き込みモードの場合はデータを書き込むようにしておきましょう.


module MEM(CLK, addr, data_in, write, data_out);
    input         CLK;
    input  [31:0] addr, data_in;
    input write;
    output [31:0] data_out;    
    reg [31:0] mem[1024*8-1:0]; // 8K word memory
    assign data_out  = mem[addr[14:2]];
    always @(posedge CLK) if(write) mem[addr[14:2]] <= data_in;
endmodule

ここから1クロックごとに命令を取り出す処理は以下のように書きます.pcはプログラムカウンタ,irは取り出す命令です.


reg [31:0] pc;
wire [31:0] ir;

MEM imem(CLK, pc, 0, 0, ir); 
always @(posedge CLK) begin
   if(!RST_X) pc <= 0; //リセットのとき
   else pc <= (pc_src) ? tpc : npc; //条件分岐によって次のpcが変わる
end

Instruction Decodeステージ

データを保存するためのレジスタを定義しましょう.

module GPR(CLK, REGNUM0, REGNUM1, REGNUM2, DIN0, WE0, DOUT0, DOUT1);
    input             CLK;
    input       [4:0] REGNUM0, REGNUM1, REGNUM2; 
    // REGNUM0, 1は読み取りの番号で2は書き込み先のレジスタ番号
    input      [31:0] DIN0; //書き込みデータ
    input             WE0; //書き込みかどうか
    output     [31:0] DOUT0, DOUT1; //出力
    reg [31:0] r[0:31]; //レジスタ群
    assign DOUT0 = (REGNUM0==0) ? 0 : r[REGNUM0];
    assign DOUT1 = (REGNUM1==0) ? 0 : r[REGNUM1];
    always @(posedge CLK) if(WE0) r[REGNUM2] <= DIN0;
endmodule

次にirをデコードします.ワイアを配線することでデコードとします.ここでプログラムカウンタの制御もしておきます.


wire [5:0] op = ir[31:26]; //先頭6bitでオペランドを表す
wire [4:0] rs = ir[25:21]; //データの読み取り先アドレス
wire [4:0] rt = ir[20:16]; //データの書き込み先アドレス
wire [4:0] rd = ir[15:11]; //add命令のときだけつかうやつ
wire signed [15:0] imm = ir[15:0]; //定数
wire signed [31:0] eximm = {{16{imm[15]}}, imm}; //immの符号拡張
wire [31:0] shift_imm = (eximm << 2); //pcの移動量は4の倍数

assign pc_src = (op == `BNE) ? (rrs != rrt) : 0; //分岐するかどうか
assign npc = pc + 4; //次のプログラムカウンタの値の候補
assign tpc = pc + 4 + shift_imm; //次のプログラムカウンタの値の候補

これらを使ってレジスタからデータを読み取り,書き込みをしましょう.


wire [31:0] rrs, rrt; //読み取りデータの配線
wire [31:0] result; //書き込みデータ

assign reg_rd = (op) ? rt  : rd; //書き込み先はどこか
assign reg_write = (op == `ADD) || (op == `ADDI) || (op == `LW); //書き込みか

GPR regfile(CLK, rs, rt, reg_rd, result, reg_write, rrs, rrt); 

ちなみに定数の宣言はtopモジュールの上でできます.


`define ADD  6'h00
`define ADDI 6'h01

Executeステージ

まず演算を行うALUを作ります.

module ALU(CLK, NUM1, NUM2, ZERO, ALU_RES);
   input CLK;
   input [31:0] NUM1, NUM2; //入力
   output   ZERO;
   output [31:0]    ALU_RES; //計算結果

   assign ZERO = (ALU_RES == 0); //これはお約束で付けてるだけ
   assign ALU_RES = NUM1 + NUM2; //今回はaddしかしないから
endmodule

ALUに配線します.


assign alu_inp1 = rrs; //ALUの入力1
assign alu_src = (op == `ADDI) || (op == `LW) || (op == `SW); //足す相手はimmかレジスタの値か
assign alu_inp2 = (alu_src) ? eximm : rrt; //上の制御線に合わせてつなぎ替える

ALU alu(CLK, alu_inp1, alu_inp2, alu_zero, alu_result);

Memory Accessステージ

メモリはIFステージで定義したのでそれを使ってインスタンスを作成します.

wire [31:0] mem_result; //メモリの読み取りデータ
assign mem_write = (op == `SW); //メモリへの書き込みか

// 演算結果をアドレスとして,入力値はrrtで指定(読み取ったデータ)
MEM dmem(CLK, alu_result, rrt, mem_write, mem_result); //メモリの読み取り

Write Backステージ

ALUで計算された値や,メモリからロードされた値を書き込む.ロード命令のときのみはメモリから読み取った値を配線する.

assign mem_to_reg = (op == `LW);
assign result = (mem_to_reg) ? mem_result : alu_result;  

まとめ

今回作成したプロセッサは1クロックに1命令づつ実行するため,パイプライン処理も行われていないし,ロード命令が連続で来るとバグる(コンパイル時にNOPを入れることを要請してしまう)ため,結構頭のわるいプロセッサです(IQ1なので).
参考にgithubにこれらのコードとその拡張版をあげておきます.
あと,補足ですがコレを書いているヒトは普段Pythonでえーあいしたり,Javaとかでしゅっとしたりしてます.なのでプロの方から見ると拙いコードや説明になっていると思いますがご容赦ください.

9
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
10