Instruction Decode モジュールの作成


1. はじめに

今回はプロセッサのうち,instruction decode 部を設計記述する。 ここでは、命令の解釈(decode)に必要な考え方と、 データハザードを回避するためのインターロックの考え方を 修得する。

2. instruction Decode に必要な機能

instruction decode 部の主な機能は、命令の解釈、即値の切り出し、 およびレジスタ読み出しである。 まず、instruction fetch ステージから 送られてきた命令を解釈して実行ステージで使いやすい形に変形し、 同時に命令中に含まれた即値を切り出す。 また、本プロセッサでは instruction decode ステージが 実行ステージの直前に位置するため、 汎用レジスタ(architectural register)の読み出しもここで行う。 さらに、演算に必要なレジスタの値が確定していない場合は、 パイプラインをストールさせる。

2.1 命令解釈

命令解釈の主な目的は次のとおりである。 まず、細かい演算の種類は、 実行ステージ中のモジュールで判別しても良いが、 少なくともどの演算器で処理したらよいかぐらいは、 前の方のステージで調べておいた方が良い。 さらに、このステージで必要なオペランドを揃えるために、 各々の演算がどのようなオペランドを必要としているかを調べる必要がある。
本プロセッサは、単純な 32 bit 固定長命令を採っており、 そのうち命令を判別する opecode は 7 bit 固定長である。 したがって、単純にこの opecode を解析することによって、必要な情報が求まる。

2.2 即値の切り出し

本プロセッサでは命令の 25 bit 目の 1 bit (命令表中の immf) で 即値を使用するか否かを示している。 この immf に従って instruction fetch ステージから送られてきた命令に 含まれている即値を切り出し、実行ステージで使用可能な形にする。
命令によって即値の符号拡張を行う場合と行わない場合があるので、 気をつける必要がある。 符号拡張する場合は即値の最上位ビットを複製して 32 bit に足りない部分へ充填する。 この複製・充填(結合)を verilog で表現するには、 連結・繰り返し演算子を使用すると良い。

2.3 汎用レジスタ読みだし

命令表中 rd, rs で示された汎用レジスタを読み出す。 本プロセッサは 2 オペランド形式であるから、 最大 2 つの汎用レジスタを読み出す。 汎用レジスタファイルは二つの読み出しポートと 一つの書き込みポートを持っており、 レジスタ番号を指定すると読み出しポートから値が出力されると仮定する。 レジスタファイルの詳細な構造については後述する。

2.4 レジスタの書き込み予約と結果待ち

命令の実行順序によっては、先行する命令が終了しておらず、 読み出すべきレジスタの値が確定していない場合がある。 これを正確に検出し、結果が確定するまでパイプラインを ストールさせなければならない。 いくつかの方法が考えられるが、 最も単純な方法は今解釈している命令が書き込むレジスタに、 印を付ける事である。 このために、各々のレジスタに 1 bit の記憶素子を付加し、 命令を解釈する度にこのビットをセットする事にする。 実行ステージで演算が終了し、レジスタに書き戻す度に このビットをリセットすると、正確に結果が確定したことが 検出できるはずである。
ここで、読み込むべきレジスタが書き込み予約されている場合には パイプラインをストールさせることにすれば、 連続する命令にデータ依存関係が有っても正しくプログラムが実行される。 これはインターロックの一種である。

3. デコーダ

opecode を解釈するには、各々の opecode を分類し どのカテゴリに属するかを判別する論理回路が必要である。 このように、ある入力に対して定められた条件が成り立つときに、 必要な信号が真になる論理回路をデコーダ(decoder)と呼ぶ。 例えば加減算器を使用する命令が入力された場合に 加減算を示す bit が真になるといった具合である。
これを verilog-HDL で実現するには、リスト 1 のように case 文を用い opecode によって出力を決定する function を 記述すればよい。
リスト1 デコーダの例
    function check_int;
      input [2:0] opecode;
      case (opecode) // synopsys parallel_case
        2'b00: check_int = 1'b1;
        2'b01: check_int = 1'b0;
        2'b10: check_int = 1'b0;
        2'b11: check_int = 1'b0;
      endcase
    endfunction
他の命令カテゴリについても同様に記述すれば良いが、 個別に記述していると嵩張って収拾がつかなくなるし、 論理合成ツールが最適化を行うにも不都合である。 そこで、opecode によって一意に定まる条件については まとめてデコーダを作成する。 残念なことに verilog-HDL の function は多出力ができないので、 リスト2 のように、信号のベクタを作って function の出力を一つにまとめる。
    リスト2 ベクトルを出力するデコーダ
    wire [W_DOPC -1: 0]   dopc; // decoded opecode
 
    assign                dopc = decode_ins(inst_i[W_INST -2: P_RD]);
 
    assign                inte   = dopc[W_DOPC -1]; // integer
    assign                logic  = dopc[W_DOPC -2]; // logic
    assign                shift  = dopc[W_DOPC -3]; // shift
    assign                ld     = dopc[W_DOPC -4]; // load
    assign		  st	 = dopc[W_DOPC -5]; // store
    assign                br     = dopc[W_DOPC -6]; // branch
    assign                imme16 = dopc[W_DOPC -7]; // immediate 16
    assign                rsv_o  = dopc[W_DOPC -8]; // reserv
    assign                und    = dopc[W_DOPC -9]; // undefined

    // **************** decode opecode *****************
    // dopc = {inte, logic, shift, ls, br, imm16, rsv, und}
    // integer, logic, shift, load, store, branch, immediate, reserved, undefined
    function [W_DOPC -1: 0] decode_ins;
       input [6: 0] opcode;
       case (opecode) // synopsys parallel_case
         // integer reg - reg
         7'b0000_000: decode_ins = 11'b100000_0_1_0;
         7'b0000_001: decode_ins = 11'b100000_0_1_0;
         7'b0000_010: decode_ins = 11'b100000_0_1_0;
         7'b0000_011: decode_ins = 11'b100000_0_1_0;
         7'b0000_100: decode_ins = 11'b100000_0_0_0;
         7'b0000_101: decode_ins = 11'b100000_0_1_0;
         7'b0000_110: decode_ins = 11'b100000_0_1_0;
         7'b0000_111: decode_ins = 11'b100000_0_1_0;
 

4. 汎用レジスタファイル

本プロセッサでは instruction decode ステージで汎用レジスタを読み出す。 そこで、ここで汎用レジスタファイルについても考察する。 論理的にはレジスタファイルは記憶素子の配列であるが、 verilog-HDL で記述するに当たって幾つか注意点がある。
まず注意すべきは、多くの場合二次元配列が使えないと言うことである。 二次元配列はまともに論理合成できない事が有る。 また、for 文等も論理合成できないことがあるため、 二次元配列は初期化にも不都合が有る。 結局、必要なだけ一次元配列を並べて記述するのが最も確実である。
もう一つは各レジスタのセットリセットの記述が繁雑になりやすいことである。 各々のレジスタは同様の動作をするが、 これを一つのモジュール内に並べて書くと極めて繁雑になる。 そこで、リスト3 のように一本分のレジスタセルをサブモジュールとし、 その中で判定可能な動作を定義しておき、 リスト4 のようにレジスタファイル中でこのレジスタセルモジュールを結合する。
※ここでは、 4 bit の入力から 16 bit のうち 1 bit のみが 1 である信号を作成する回路 decode16.v と 16 個の入力から 1 つを選び出す回路 select16.v を、 `include で読み込んでいる。 params.v は必要な parameter 記述を並べたファイルである。 例えば W_OPR はオペランドの大きさを示し、32 に設定される。

(パラメータ定義の例) parameter W_OPR = 32;

同様に W_RD はレジスタ番号を示す値を入れる大きさなので 4、 REG_S はレジスタの数で 16、といった具合に記述したファイルを 別途用意し各々のモジュールの中で読み込ませる。 このようにすれば、プロジェクト全体で定数の定義をまとめて扱うことができる。

    リスト3 レジスタセル
    module register_cell(clk, rst,
                         data_i,
                         data_o,
                         w_reserve_i,
                         w_reserve_o,
                         wb_i
                         );
       input clk, rst;
       input  [W_OPR -1: 0] data_i; // data for write
       output [W_OPR -1: 0] data_o; // data output
       input                w_reserve_i; // write reserve
       output               w_reserve_o; // write reserve out
       input                wb_i; // write back
    
       reg [W_OPR-1: 0]     data_cell; // register cell
       reg                  w_res; // write reserve bit
    
    
       assign               data_o = data_cell;
       assign               w_reserved = w_res;
    
       always @(posedge clk or negedge rst)
         begin
            if (~rst)
              begin
                 w_res <= 1'b0;
                 data_cell <= 32'b0;
              end
            else
              begin
                 if (w_reserve_i) // write reserve
                   begin
                      w_res <= 1'b1;
                   end
                 else if (wb_i) // write back
                   begin
                      w_res <= 1'b0;
                   end
    
                 if (wb_i)
                   begin
                      data_cell <= data_i;
                   end
              end // else: !if(~rst)
         end // always @ (posedge clk or negedge rst)
    endmodule // register_cell
    リスト4 汎用レジスタファイルモジュール
    module g_register(clk, rst,
                      w_reserv_i,
                      r0_i,
                      r1_i,
                      r_opr0_o,
                      r_opr1_o,
                      reserved_o,
                      wb_i,
                      wb_r_i,
                      result_i);
    
    `include "../include/params.v"
    
       input                clk, rst;

       input                w_reserv_i;    
       input [W_RD -1: 0]   r0_i; // register No. as opr0 & write_reservation
       input [W_RD -1: 0]   r1_i; // register No. as opr1
       output [W_OPR -1: 0] r_opr0_o; // operand0
       output [W_OPR -1: 0] r_opr1_o; // operand1
       output               reserved_o; // destination is reserved
    
       input                wb_i; // write back
       input [W_RD -1: 0]   wb_r_i; // write back register
       input [W_OPR -1: 0]  result_i; // write back data
    
       wire [REG_S -1: 0]   w_reserve;
       wire [REG_S -1: 0]   w_reserved;
       wire [REG_S -1: 0]   wb_r;
    
       wire [W_OPR -1: 0]   data0;
       wire [W_OPR -1: 0]   data1;
       //   :
       //   :
       //   :
       wire [W_OPR -1: 0]   dataf;
    
    `include "select16.v"
       assign r_opr0_o =
              select16(r0_i, // register descriptor
                       data0, data1, data2, data3,
                       data4, data5, data6, data7,
                       data8, data9, dataa, datab,
                       datac, datad, datae, dataf
                       );
    
       assign r_opr1_o =
              select16(r1_i, // register descriptor
                       data0, data1, data2, data3,
                       data4, data5, data6, data7,
                       data8, data9, dataa, datab,
                       datac, datad, datae, dataf
                       );
    
       wire [REG_S -1: 0] opr_req0; // operand request vector0
       wire [REG_S -1: 0] opr_req1; // operand request vector1
    
    `include "decode16.v"

       assign             opr_req0 = decode16(r0_i);
       assign             opr_req1 = decode16(r1_i);

       assign w_reserve = opr_req0 & {16{w_reserv_i}};

       // if requested register is reserved
       assign             reserved_o = |((opr_req0 | opr_req1)
                                         & w_reserved);

       assign wb_r = decode16(wb_r_i) & {16{wb_i}};
    
       g_reg_cell r0(.clk(clk), .rst(rst),
                      .data_i(result_i),
                      .data_o(data0),
                      .w_reserve_i(w_reserve[0]),
                      .w_reserve_o(w_reserved[0]),
                      .wb_r(wb_r[0])
                      );

       g_reg_cell r1(.clk(clk), .rst(rst),
                      .data_i(result_i),
                      .data_o(data1),
                      .w_reserve_i(w_reserve[1]),
                      .w_reserve_o(w_reserved[1]),
                      .wb_r(wb_r[1])
                      );
       //   :
       //   :
       //   :

5. 論理回路の並列動作

このプロセッサは非常に単純な命令の符号化を採用している。 そこで、例えば即値とレジスタの演算であることが判明したら、 レジスタを 1 つだけ読みに行き即値を切り出す といった論理を組みたくなるであろう。
しかし、これは自然な論理回路の挙動ではない。 一般に論理回路は各々独立して動作しており、 各部が独自の判断で動くようになっている。 また、各々の動作を直列に繋いだのでは動作速度が遅くなってしまう。 命令を解釈してしまわないと動作が決定できないような回路が有る場合には、 この遅延が大きな問題になる。
そこで、各々の回路は勝手に自分の判断で動き、 パイプラインステージの後半で必要とするものを選択する回路を記述する。 例えば、レジスタは何が何でも 2 つ読み込むのである。 同様に即値も切り出して準備しておく。 読み込んだレジスタや切り出した即値が使われなくても気にしない。 最後に命令の解釈結果によって、正しいものを選択すれば良いのである。
このように、決定を後へ後へ延ばすことによって、 並列に動作できる部分を増やして クリティカルパス(最も時間のかかる経路)を短縮することができる。
※ただし、特別に消費電力を削減する必要が有る場合などはまた別である。 その場合は、多少遅くなることと引き替えに、 同時に動作する回路を減らすような設計もあり得る。

6. 繰り返し記述の問題

デコーダーやレジスタファイルのようなモジュールでは、 繰り返して似たような記述を行う場合が頻出する。 本質的ではないが、手間がかかり間違いの元となる という点では大きな問題である。
そこで、少しでも手間を省くため、perl のようなスクリプト言語を 使用することを勧める。 例えば decode16.v なら decode16case.pl select16.v なら select16case.pl のようなスクリプトを書いて case 文の中を記述する。 デコーダーのような場合は、最初にビット列を おおよそスクリプト書いてしまい、後からそれを修正する。 レジスタファイルのように繰り返しモジュールを呼び出す場合は、 全部をスクリプトで書く方が良い。
このような手順を踏めば、"どうもおかしいと思ったら 何故だか5番レジスタを使うと 正しくプログラムが実行されない"などといった厄介な バグが入り込む危険を少なくすることができる。 シミュレーション結果の整理や合成結果の確認等にも有効なので、 何でも良いから一般的なスクリプト言語を一つ習得しておきたい。
また、エディタの置換やマクロ等も活用すべきである。 参考までに emacs では "C-x (" でキーボードマクロを記録開始し、 "C-x )" で終了するまでの動作を憶えて "C-x e" で呼び出すことができる。 一行下がって一文字消し別の文字を挿入すると行った場合に便利で、 検索等も組み合わせれば、かなり複雑な操作もスクリプトを組まずに繰り返すことができる。

7. 課題

必ずしも全ての命令を実装しなくても良い。加減算命令と、load/store 命令、 および分岐命令が有れば、ある程度のプログラムが組め、動作確認ができる。 手際よく設計を進めるには、最初は加算命令のみ実装し実行ステージの設計をできるようにすると良い。 一応の動作確認ができたら、次第に実装する命令を増やしていく。 命令表中の優先度を参考にすると良い。