FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

FPGA開発日記 カテゴリ別インデックス

続きを読む

RISC-V OoO CPUを実装する (15. Commit slot expose: 実機コスト0のDIFFTEST hook)

前回でIQ age matrix入れて4命令が完走できる状態になった。 次のステップは「実行traceを外に出す」機構。retire (commit) 時の (pc, rd_addr, rd_data) をtop moduleのportとして拾えるようにして、reference ISS (spike / whisper) とco-simulationで状態一致を検証する下ごしらえ。 同時に「rd_dataは実機で不要なのにROBに持たせる」ジレンマを\ifdef DIFFTEST_ENABLE`のguardで解決した話。

DIFFTESTとは

XiangShanなどが呼んでいる名前で、要はcycle-by-cycleのtrace比較。retire時のarchitectural state (pc / rd write) をDUTとreference ISSで並行に流して、1 stepでも差分が出たら止める、という仕組み。in-house RTLでは極めて有効なbug早期発見手段。

必要なcommit trace情報:

情報 source
pc 実行した命令のPC (ROB entry)
rd_addr アーキreg番号 (ROB entry / arch RAT commit)
rd_data rdに書いた値 (wb bus / PRF)
valid このcycleにretireしたか

追加でstoreの (addr, data)、CSR writeの (addr, data)、trapの (cause, epc) も欲しくなるが、まずはGPR writeだけexposeして基礎を作る。

rd_dataをROBに持たせるか、PRFから読むか

Retire時にrd_dataを取得する方法は2択:

  • (a) PRFにretire時read portを追加、PRF[cmt_preg]を読む
  • (b) wb時にROB entryにrd_dataをlatchしておき、retireで読み出す

(a) は追加read port分のPRF cost (per-lane 1 port × DISP_SIZE + arbitration) が痛い。 (b) はROB entryにlogic [XLEN_W-1: 0] rd_data;を足すだけ。今回は (b) を採用。

ただしROB entryへのadditional fieldは面積コストがある: XLEN × ROB_ENTRIES × 1 (per entry) = 64 × 32 = 2048 FF。無視できない。

実機ではDIFFTEST不要なのでrd_dataは要らない。この不要なコストを避けるため、\ifdef DIFFTEST_ENABLE`でguard:

typedef struct packed {
  logic                  done;
  logic [VADDR_W-1: 0]   pc;               // trapのmepcに使うのでunconditional
  reg_type_t             rd_type;
  logic [GPR_W-1: 0]     rd_addr;
  logic [XPR_PRF_W-1: 0] prd;
`ifdef DIFFTEST_ENABLE
  logic [XLEN_W-1: 0]    rd_data;          // trace verification only
`endif
  logic                  excpt_valid;
  logic [ 3: 0]          excpt_cause;
} rob_entry_t;

これで実機合成時 (DIFFTEST_ENABLEをdefineしない) はrd_data fieldが消え、面積コストゼロ。sim時はMakefileのVFLAGS_BUILD+define+DIFFTEST_ENABLEを足して自動enable。

wb latchとretire outputの追加

wb側:

`ifdef DIFFTEST_ENABLE
logic [XLEN_W-1: 0] w_pr_ports_rd_data;
`endif

always_comb begin
  ...
`ifdef DIFFTEST_ENABLE
  w_pr_ports_rd_data = '0;
`endif
  for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
    if (w_rob_wr_valid[w] & (w_rob_wr_bank_idx[w] == b_idx[BW-1:0])) begin
      ...
`ifdef DIFFTEST_ENABLE
      w_pr_ports_rd_data = i_wb[w].result;
`endif
    end
  end
end

always_ff @(posedge i_clk) begin
  ...
  if (w_wr_ports_valid) begin
    r_rob[b_idx][w_wr_ports_entry_idx].done <= 1'b1;
`ifdef DIFFTEST_ENABLE
    r_rob[b_idx][w_wr_ports_entry_idx].rd_data <= w_pr_ports_rd_data;
`endif
    ...
  end
end

Retire output:

output logic [VADDR_W-1: 0]  o_cmt_pc      [DISP_SIZE],
`ifdef DIFFTEST_ENABLE
output logic [XLEN_W-1: 0]   o_cmt_rd_data [DISP_SIZE],
`endif

o_cmt_pcはpc fieldがtrap用に既にunconditionalで保持されているので、exposeもunconditional (追加コスト = wireだけ)。o_cmt_rd_dataだけがifdef guard。

Top module配線

同じifdef patternでtopも:

scariv_rob u_rob (
  ...
  .o_cmt_pc      ( w_cmt_pc      ),
`ifdef DIFFTEST_ENABLE
  .o_cmt_rd_data ( w_cmt_rd_data ),
`endif
  ...
);

assign o_cmt_pc = w_cmt_pc;
`ifdef DIFFTEST_ENABLE
assign o_cmt_rd_data = w_cmt_rd_data;
`endif

Top moduleのo_cmt_pcはunconditionalで、o_cmt_rd_dataはifdef guard。 内部wire宣言も同様。

LATCH warning: default init忘れ

Verilogのalways_combで 分岐の一部でしかassignしないと、latch推論されてlint warningが出る。今回:

`ifdef DIFFTEST_ENABLE
logic [XLEN_W-1: 0] w_pr_ports_rd_data;
`endif

always_comb begin
  w_wr_ports_valid = 'h0;
  ...
  // w_pr_ports_rd_dataのdefault init忘れ
  for (int w = 0; ...) begin
    if (...) begin
      ...
`ifdef DIFFTEST_ENABLE
      w_pr_ports_rd_data = i_wb[w].result;
`endif
    end
  end
end

for loopのif分岐でmatchしなかったcycleはw_pr_ports_rd_dataに何もassignされず、latch化される。修正はalways_comb冒頭でdefault 0:

always_comb begin
  ...
`ifdef DIFFTEST_ENABLE
  w_pr_ports_rd_data = '0;
`endif
  for (int w = 0; ...) ...
end

VerilatorがLATCH warningでcatchしてくれる。このpatternはexcpt_valid / excpt_causeでも同じ。

imm muxの追加 (実測で発覚)

configurationが済んでsmoke testをrunしたらpcは正しいのにrd_dataが全部0。ADDI x1..x4のはずがx1=0, x2=0, ...。

原因: top moduleのPRF-read glueが常にop2 = PRF[prs2]で構築していた。ADDIの場合rs2は使わずimmをop2に渡すべきなのに、PRF[0] (=0) が入って結果が0 + 0 = 0に。

修正: imm_validを見てmux:

always_comb begin
  w_issued[p]     = '0;
  w_issued[p].rn  = w_iss_uop[p];
  w_issued[p].op1 = w_prf_rd_data[2*p+0];
  w_issued[p].op2 = w_iss_uop[p].uop.imm_valid ? w_iss_uop[p].uop.imm
                                                 : w_prf_rd_data[2*p+1];
end

これでADDI x1..x4が想定通りrd_data = 1..4になった。

なおAUIPCはrd = pc + immでop1も特別 (PCを入れる必要)。現状は未対応 (rs1_type=REG_NONEでprs1=0 → op1=0になり、AUIPC実行時rd = 0 + imm、pc加算が抜ける)。backlogに追加。 現段階のsmoke test (ADDI × 4) では影響なし。

検証結果

Top smoke testのtrace validationを追加:

localparam int EXPECTED_TRACE_LEN = 4;
trace_t expected_trace [EXPECTED_TRACE_LEN];

initial begin
  expected_trace[0] = '{pc: 64'h8000_0000, rd_addr: 5'd1, rd_data: 64'h1};
  expected_trace[1] = '{pc: 64'h8000_0004, rd_addr: 5'd2, rd_data: 64'h2};
  expected_trace[2] = '{pc: 64'h8000_0008, rd_addr: 5'd3, rd_data: 64'h3};
  expected_trace[3] = '{pc: 64'h8000_000C, rd_addr: 5'd4, rd_data: 64'h4};
end

Trace出力と1対1比較 (retiredの順序 × pc / rd_addr / rd_dataの3 field):

[75000] commit lane[0]: pc=0x0000000080000000 rd=x1 data=0x0000000000000001
[85000] commit lane[0]: pc=0x0000000080000004 rd=x2 data=0x0000000000000002
[95000] commit lane[0]: pc=0x0000000080000008 rd=x3 data=0x0000000000000003
[105000] commit lane[0]: pc=0x000000008000000c rd=x4 data=0x0000000000000004
=== tb_scariv_top : 13 tests, 0 fail, 4 retired ===
PASS

13 tests = 4 retire count検証 + 4命令 × 3 field検証 = 12 + 1。

Regression全16 TB pass、累計196k+ tests / 0 fail。

RISC-V OoO CPUを実装する (14. IQ age matrix: 古い命令を絶対に追い越させない)

前回のtop moduleでpipelineが一通り繋がったが、smoke testの結果2命令retireで止まって「deadlock」していた。 原因はIQのpriorityが「physical entry indexのLSB」で決まっていて、free slotに埋まった若い命令が古い命令を追い越して発行され、in-order retireのROBで古い命令の完了待ちのまま止まる、というOoOの初歩的な設計不備。 今回はage matrixを入れてこれを解消し、ついでに絡み合った別のbugも一つ潰した話。

Age matrixの実装

古典的なOoO schedulerの1テクニックで、BOOMやXiangShanが使っているのと同じ形。

  • logic [IQ_ENTRIES-1: 0][IQ_ENTRIES-1: 0] r_age_matrix;
  • age[i][j] = 1 = entry iはjより古い、というbit matrix
  • Dispatchで新規に埋まるentryのrowをall-clear、逆に既存valid entryのcolumn (新規entryを表す) は1
  • Issueは「row iがall (ready & cat-matching) entryに対して1」ならoldest → 1個だけ1になるone-hot

update logicの構造:

generate for (genvar i_idx = 0; i_idx < IQ_ENTRY_NUM; i_idx++) begin
  for (genvar j_idx = 0; j_idx < IQ_ENTRY_NUM; j_idx++) begin
    always_ff @(posedge clk, negedge rst_n) begin
      if (!rst_n | flush_all) begin
        r_age_matrix[i_idx][j_idx] <= 1'b0;
      end else if (w_being_dispatched[i_idx] & w_being_dispatched[j_idx]) begin
        // 両方同時dispatch: 若いlane indexが古い命令
        r_age_matrix[i_idx][j_idx] <= (w_dispatch_lane[i_idx] < w_dispatch_lane[j_idx]);
      end else if (w_being_dispatched[i_idx]) begin
        r_age_matrix[i_idx][j_idx] <= 1'b0;   // i is new; not older than anyone
      end else if (w_being_dispatched[j_idx]) begin
        r_age_matrix[i_idx][j_idx] <= r_iq[i_idx].valid;   // existing i older than new j
      end
    end
  end
end endgenerate

Multi-lane dispatchでlane順序を「若い方が古い」に決める

最初lane 0 vs lane 1の比較をhardcodeで書いていたが、これだとDISP_SIZE=2前提で6-way構成 (最終形態) にscaleしない。 「entryごとにどのlaneがdispatchしたか」をsignal化して、そのlane indexの順序で古さを決める形にした:

logic [$clog2(DISP_SIZE)-1: 0] w_dispatch_lane [IQ_ENTRY_NUM];

always_comb begin
  for (int e = 0; e < IQ_ENTRY_NUM; e++) begin
    w_dispatch_lane[e] = '0;
    for (int d = 0; d < DISP_SIZE; d++) begin
      if (i_dis_valid[d] & w_iq_free_slot_oh[d][e]) begin
        w_dispatch_lane[e] = d[$clog2(DISP_SIZE)-1: 0];
      end
    end
  end
end

これでage matrix更新はlane index比較1本にまとまり、DISP_SIZEを大きくしても同じcodeが使える。

「lane 0がlane 1より古い」のinvariantはfront-endの構造から自動的に保たれる:

  • Fetch F3 output: lane 0のPCがlane 1のPCより4小さい (program order)
  • Rename: all-or-nothing dispatchなのでpartial dispatchが発生しない
  • ROB: 若いlane 0のrob_idがlane 1より1小さい

なのでdispatch時点で「lane indexが若い = 古い命令」が常に成立。

Issue selectorの書き換え

bit_extract_lsbのLSB priorityは削除して、age matrixでoldest-among-readyを選ぶ形に:

generate for (genvar p = 0; p < PRF_WRITE_PORTS; p++) begin
  logic [IQ_ENTRY_NUM-1: 0] w_oldest_ready;
  for (genvar i = 0; i < IQ_ENTRY_NUM; i++) begin
    logic w_older_than_all;
    always_comb begin
      w_older_than_all = 1'b1;
      for (int j = 0; j < IQ_ENTRY_NUM; j++) begin
        if (i != j) begin
          if (w_issue_ready[p][j] & ~r_age_matrix[i][j]) begin
            w_older_than_all = 1'b0;
          end
        end
      end
    end
    assign w_oldest_ready[i] = w_issue_ready[p][i] & w_older_than_all;
  end
  // w_oldest_ready is one-hot (unique oldest)
  assign w_iss_slot_oh[p] = w_oldest_ready;
  ...
end endgenerate

「entry iのage matrix rowが『同portの候補でreadyなentry全部』をdominateする」ならiは該当portのoldest ready。1個だけ1が立つone-hotになるので、そのままselectorとして使える。

埋め込まれていたBRU wb typo

age matrixを入れてrunしたら... 2命令retireから増えない。deadlock状況は同じ。

debug printsを追加して、issueはrob 2 (ADDI x3) がoldestとして選ばれて発火しているのに、ROBのr_rob[0][1].doneが1に立たない、という現象を突き止めた。

原因はage matrixとは無関係で、top moduleのBRU writeback構築のtypo:

always_comb begin
  w_wb_result[1] = 'h0;
  w_wb_result[1].valid  = w_iss_valid[0];   // ← [0] はtypo、[1] が正
  w_wb_result[1].rob_id = w_iss_uop[1].rob_id;
  ...
end

BRUのwb portはindex 1なのに、validだけport 0の値を追随していた。結果、BRUが全くissueしていないcycleでもwb.valid[1]=1が立ち、rob_idはw_iss_uop[1] (IQ port 1の出力 = stale値) から取られて、bank 0 / row 2のような「たまたま」ヒットするROB entryをwb扱いする形になっていた。

ROBはper-bankのwb collectorで「該当bankにwbしているport」の情報を集めるが、複数portが同じbankを触った場合は最後にヒットしたportがw_wr_ports_entry_idxを上書きする:

for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
  if (w_rob_wr_valid[w] & (w_rob_wr_bank_idx[w] == b_idx[BW-1:0])) begin
    w_wr_ports_valid = 1'b1;
    w_wr_ports_entry_idx = w_rob_wr_entry_idx[w];   // 上書き
  end
end

port 0 (ALU) がrob 2 (bank 0 row 1) をwbしていても、port 1 (BRU) がspuriousにwb.valid=1かつbank_idx=0 (別のrow) を報告すると、w_wr_ports_entry_idxがその別rowで上書きされて、rob 2のdoneがsetされない。

Typo 1文字 ([0][1]) 直しただけで、pipelineが4命令完走した。

Age matrixのdebugに時間を取られてBRU wb typoに気付くのに時間がかかったが、教訓としては:

  • 統合debugでは「一番怪しい所」以外にも複数bugが同居している可能性を常に念頭に
  • wb busのように複数producerで共有するpathは、片方のspurious出力が他方のkeyに影響を及ぼす形になりがち。valid gatingを厳密にする

検証結果

Top module smoke testでADDI × 4が全部retire:

[75000] commit lane[0]: rd=x1 ...
[85000] commit lane[0]: rd=x2 ...
[95000] commit lane[0]: rd=x3 ...
[105000] commit lane[0]: rd=x4 ...
=== tb_scariv_top : 1 tests, 0 fail, 4 retired ===
PASS

Regression全16 TB pass、累計196k+ tests / 0 fail。

RISC-V OoO CPUを実装する (13. Top module: 配線とIQ deadlockの発見)

CSRまで単体moduleが揃ったので、全部を1本のtop moduleに繋いで実命令を流すフェーズに入った。 Fetch → Decode → Rename → Dispatch → ROB → IQ → EX (ALU/BRU/LSU/CSR) → WB → Retireのloopを閉じるのに、interfaceの型合わせ・PRF read glue・redirect fan-in・wb bus構築などの細かい配線を一通り書いた話。 そして統合してsmoke testを回したところ、IQのdispatchとissueのpriorityがROBのin-order retireと組み合わさってdeadlockする、という設計問題を発見した。

統合前の下準備

Top moduleの実装に入る前に、以下の3点を先に片付けた。

(a) CSR opをuop typeに追加

Decoderのoutput uop_tには既にis_load / is_store / is_branch / is_jumpがあるが、CSR ops / ECALL / EBREAK / MRETのためのflagが無かった。以下追加:

// scariv_pkg.sv
logic is_csr;
logic is_ecall;
logic is_ebreak;
logic is_mret;

さらにop unionにcsr_op_tを追加してCSR addressとRW/RS/RC kindを載せる:

typedef union packed {
  logic [14: 0] raw;
  alu_op_t      alu_op;    // 5 bit
  br_op_t       br_op;     // 5 bit
  csr_op_t      csr_op;    // 15 bit (12b addr + 2b kind + 1b use_imm)
} op_u_t;

Packed unionは最大幅で確保されるので、alu_op / br_opは上位10bitがdon't care扱いになる。

(b) CSRのcat routing

DecoderのOP_SYSTEM branchを追加、catを分けてIQで振り分けられるように:

OP_SYSTEM: begin
  case (funct3)
    3'b000: cat = INST_CAT_SYSTEM;   // ECALL/EBREAK/MRET
    default: cat = INST_CAT_CSR;     // CSRRW/RS/RC(I)
  endcase
end

(c) IQ 4 port化

IQのissue portを3 (ALU/BRU/LSU) から4 (+CSR) に増やす。conf_pkgにCSR_INST_NUM=1を追加、PRF_WRITE_PORTS = ALU + BRU + LD + CSR = 4と連動。IQ側で4番目portのcat filterを追加:

w_issue_ready[3][i] = base & (cat == INST_CAT_CSR | cat == INST_CAT_SYSTEM);

Top moduleの構造

Sub-moduleが12個 (fetch / decoder / rat / free_list / rename / rob / iq / prf / alu / bru / lsu / csr) + memory 2個 (itcm / dtcm)。Memoryはplan docに従ってtop portとして外に出し、TB側でinstantiate。

主要signal chain (簡略):

Fetch → Decoder → Rename ↔ (RAT + FL) → ROB → IQ → PRF read → EX 4 unit → wb bus
                                                                              ↓
                                       ↓                                     ROB done/retire
                                    (rn_readyでfetch backpressure)

抜けているglue: PRF read + issued_t構築

一つ「無かった」のが、IQ output → EX inputの間の中間stage。IQが出すrenamed_uop_tはprs1 / prs2 / prdの「番号」だけ持っていて、実際のoperand値は持たない。ALU/BRU/LSU/CSRはoperand値を要求する。

Issueがcycle Tなら、cycle Tの中でPRF readを発火してT+1でEXに流す形が典型だが、現段階ではPRF read自体がcombinational (register fileの1-cycle latencyは無視) なので、issueと同cycleで:

generate for (genvar p = 0; p < PRF_WRITE_PORTS; p++) begin
  // PRF read address: 各issue portが2 operand要求
  assign w_prf_rd_addr[2*p+0] = w_iss_uop[p].prs1;
  assign w_prf_rd_addr[2*p+1] = w_iss_uop[p].prs2;

  // issued_t構築 (renamed_uop_t + operand値)
  always_comb begin
    w_issued[p].rn  = w_iss_uop[p];
    w_issued[p].op1 = w_prf_rd_data[2*p+0];
    w_issued[p].op2 = w_prf_rd_data[2*p+1];
  end
end endgenerate

こうして構築したw_issued[N]をLSU/CSRのi_iss_dataに、またALU/BRUのi_op1/i_op2のsourceとして使う。

PRF read port数はPRF_READ_PORTS = 2 * PRF_WRITE_PORTS (= 8) に定義変更した (以前は2*DISP_SIZE = 4だった)。

Redirect fan-in

Fetchのi_redirect_valid / i_redirect_pcに流れるsourceが複数ある:

  • CSR: trap起動 / MRET
  • BRU: branch mispredict

BRUはtaken / targetを出すだけなのでtopでredirectを組み立てる:

// 現段階はbranch prediction無し = sequential fetchなのでtakenなら常にmispredict
assign w_bru_redirect_valid = w_iss_valid[1] & w_bru_branch_taken;
assign w_bru_redirect_pc    = w_bru_branch_target;

CSRのredirectとORで束ねる (trap優先):

assign w_redirect_valid = w_csr_redirect_valid | w_bru_redirect_valid;
assign w_redirect_pc    = w_csr_redirect_valid ? w_csr_redirect_pc : w_bru_redirect_pc;

Flush出力も同じくfan-in:

assign w_flush_all = w_rob_flush_all | w_csr_flush_all;

実装で踏み抜いたbugの連鎖

Top moduleは「portを埋めるだけ」に見えて、実際は15回以上のreview往復になった。主要どころ:

  1. FetchのITCM port 3本 (o_itcm_ren/addr, i_itcm_rdata) 接続漏れ
  2. Fetchのi_dec_ready (array) をscalar w_rn_readyに繋いでいた → fan-out
  3. RATのwrite signal名w_rat_wr_*とrename側w_rn_wr_*のnaming zure
  4. renamed_uop_tのmemberをissued_tのようにaccess (.rn.rob_idは存在しない → .rob_id)
  5. ALU / BRU / LSU / CSRのoperand sourceをrenamed_uop_t直参照 → PRF-readで構築したw_issued[]に修正
  6. w_wb_valid[N].wb_en (scalarにfield access) → w_wb_result[N].wb_enのtypo
  7. Multi-driver: ROB o_flush_allw_flush_allに直繋ぎしつつ、別assignでw_rob_flush_all | w_csr_flush_allとしていた
  8. w_rn_dis_readyの未駆動 (= 0でpipelineが起動しない)
  9. Renameのo_rn_valid / o_rn_uop outputが空port
  10. scariv_decoder wrapperのo_dec_valid assign抜け (decoderがvalidを出さない)
  11. Decoderがi_rn_readyをAND gateに使っていて、renameのo_rn_readyと閉じたcombinational loopになりVerilatorが "Active region did not converge" でabort
  12. FLの全port (7本) とrenameのFL側3本が空
  13. RATのo_cmt_old_prd未接続 (FLのpushに流すsourceが消えていた)
  14. IQのi_wb empty (wb bus受け取り忘れ)
  15. Top port o_cmt_valid / o_cmt_pc / o_cmt_rd_*がROB出力に配線されていない (TBから見えない)

LintとVerilator elaborationが段階的にerrorを出してくれるので、順に潰していけば必ず動くところまで持って行けるが、量が量なので「配線量が支配的」というのがtop moduleのリアリティ。

Bug 11 (combinational loop) は特に厄介で、解消するために「decoderのvalid gatingを外してpass-throughにする」という設計変更を入れた。Backpressureはfetch → rename → 上流fetchの順で流れれば十分で、decoder自身はcombinational unitとしてgate不要という判断。

Smoke testの結果と発見したdeadlock

TBはITCMに4命令 (ADDI x1 / x2 / x3 / x4) + NOP paddingを読み込ませて、pipelineがretireに届くかを見た。

結果:

[75000] commit lane[0]: pc=... rd=x1 data=...
[85000] commit lane[0]: pc=... rd=x2 data=...
=== tb_scariv_top : 1 tests, 0 fail, 2 retired ===
PASS

2命令はcommitまで到達。ADDI x3 / x4は... 何百cycle待ってもretireしない。

Debug printで追ったところ、IQのdispatchとissueのpriorityに問題があった。

症状

  • IQ dispatch: bit_extract_lsbで「空いているentryのLSB」を選ぶ
  • IQ issue: 各issue portもbit_extract_lsbで「readyなentryのLSB」を選ぶ
  • ROB retire: in-order (rob_id順)

Rob 0 / 1 (ADDI x1, x2) がIQ entry 0 / 1にdispatch、issue、wb、retire、entry 0 / 1がfreeになる。

次cycle、fetchが既にadvancedした先の命令 (rob 4, 5 = NOP padding) がdispatchされると、空いたentry 0 / 1が最下位なのでそこに入る。このentry 0のrob 4がentry 2のrob 2 (ADDI x3) よりlower priorityでissueされてしまう。

Rob 4はwbするがROB.head = 2なのでretire待ち。Rob 2はIQでreadyな状態で待ち続ける。次cycleまたentry 0が新dispatchで埋まってissue... の繰り返しで、entry 2 (rob 2) が永遠に選ばれない。

500 cycle回しても2 commitのまま。ROBが満杯 (32 entry) になった時点でdispatch stallが入り、そこから漸くentry 2がissueされるが、その頃にはROB entryのrob_id encodingがwrap済みで、wbとretireのrob_id matchが壊れて (と思われる) retireできない。

根本原因

「IQがentry indexのLSBでpriorityを決める」と「ROBがin-order retire」の組み合わせが本質的に壊れている。若いuopがfreed slot経由で古いuopを追い越してissueされ、ROB側で「古いuopが終わるのを永遠に待つ」状態になる。

対策 (backlog)

  • Age-based priority: IQ entryにdispatch order tagを持たせて、tagの若い順にissue
  • またはmatrix scheduler (最終形態の本命): dispatch / wakeup / issueがentry indexに依存しない構造

現段階のsmoke testでは「2命令retire = pipelineが閉じている証拠」までを合格判定にして、上記deadlockはproject_backlogに追記して先送りとした。

RISC-V OoO CPUを実装する (12. CSR + Trap: mstatus struct、WARL、trap fsm)

LSUまで一通りEXユニットが揃ったので、次はtrap pathを閉じるためのCSR unitを組んだ。 現段階のスコープは「M-modeのみ、8 CSR、CSR read/write 6命令、ECALL/EBREAK/MRETによるtrap起動」の最小構成。 interrupt (mie/mip/mideleg) やU/S mode、CSR fieldの高度なsemanticsは全部後回し。

対象CSRの絞り込み

Fullなspec上CSRは4096個あるが、trap pathを回すのに必要なのは以下8個だけ:

CSR addr 役割
mstatus 0x300 MIE/MPIE/MPP (trap状態)
misa 0x301 ISA report (read-only)
mtvec 0x305 Trap vector base
mscratch 0x340 Handler用スクラッチ
mepc 0x341 Exception PC
mcause 0x342 Trap cause code
mtval 0x343 Trap value
mhartid 0xF14 Hart ID (read-only、0固定)

これでECALL/EBREAK/illegal instructionのtrap全部回せる。 Interruptを入れるとmtimer / MTIP / MEIPといった信号線が生えて実装量が跳ねるので、まずtrap pathを閉じることに絞った。

uop_tへのcsr_op追加

op unionにcsr_op_tを追加する形にした。既存のalu_op_t / br_op_t (5bit) より広い15bitだが、命令タイプごとに参照方法が排他 (ALU / BR / CSR opは同時に立たない) なのでunionで共存できる:

// scariv_pkg.sv
typedef enum logic [1: 0] {
  CSR_OP_NOP = 2'b00,   // ECALL/EBREAK/MRETなど、CSR read/writeなし
  CSR_OP_RW  = 2'b01,   // CSRRW / CSRRWI
  CSR_OP_RS  = 2'b10,   // CSRRS / CSRRSI
  CSR_OP_RC  = 2'b11    // CSRRC / CSRRCI
} csr_op_kind_t;

typedef struct packed {
  logic [11: 0]  addr;
  csr_op_kind_t  kind;
  logic          use_imm;
} csr_op_t;   // 15 bit

typedef union packed {
  logic [14: 0] raw;
  alu_op_t      alu_op;
  br_op_t       br_op;
  csr_op_t      csr_op;
} op_u_t;

Packed unionはwidth統一を要求されるので、最大幅を明示するlogic [14:0] rawを最初に入れておく。5bitのalu_op / br_opは上位10bitがdon't care扱いになる。

is_csr / is_ecall / is_ebreak / is_mretの4 flagもuop_tに追加。decoder側でOP_SYSTEMのfunct3 / funct12を見て分岐する:

OP_SYSTEM: begin
  case (w_funct3)
    3'b000: begin
      case (i_inst[31:20])  // funct12
        12'h000: is_ecall  = 1'b1;
        12'h001: is_ebreak = 1'b1;
        12'h302: is_mret   = 1'b1;
        default: illegal   = 1'b1;
      endcase
    end
    3'b001, 3'b010, 3'b011,       // CSRRW/RS/RC (rs1)
    3'b101, 3'b110, 3'b111: begin  // CSRRWI/RSI/RCI (imm)
      is_csr = 1'b1;
      op.csr_op.addr    = i_inst[31:20];
      op.csr_op.use_imm = w_funct3[2];
      op.csr_op.kind    = ...w_funct3[1:0]で分岐...;
    end
  endcase
end

mstatusのstruct化

mstatusは複数のbit fieldが混在するCSRで、事象ごとに触るbitが違う:

事象 更新
Trap entry MPIE ← MIE; MIE ← 0; MPP ← 2'b11
MRET MIE ← MPIE; MPIE ← 1'b1
CSRRW mstatus writableなbitだけ更新 (WARL)

これを64bitのbit vectorで扱うとbit位置に依存したmagic numberが散らばるので、riscv_pkgにstruct定義を置いた:

typedef struct packed {
  logic [50: 0] pad_hi;    // [63:13]
  logic [ 1: 0] mpp;       // [12:11]
  logic [ 2: 0] pad_10_8;
  logic         mpie;      // [7]
  logic [ 2: 0] pad_6_4;
  logic         mie;       // [3]
  logic [ 2: 0] pad_2_0;
} mstatus_t;

Trap entry / MRETの更新はfield直参照で読める:

if (i_trap_valid) begin
  r_mstatus.mpie <= r_mstatus.mie;
  r_mstatus.mie  <= 1'b0;
  r_mstatus.mpp  <= 2'b11;
end else if (i_iss_data.rn.uop.is_mret) begin
  r_mstatus.mie  <= r_mstatus.mpie;
  r_mstatus.mpie <= 1'b1;
end

CSR read側はstructを64bit vectorに自動flattenして返す (pad bitはreset値の0のまま):

12'h300: w_csr_rdata = r_mstatus;   // 自動flatten

将来SPP / SUM / MPRV / FS / VSなどのfieldを追加する時も、structに枝を足すだけで済む。

WARL: writable bitのみ反映

CSRRW mstatusで全bit書き込もうとしても、実装がsupportしないbitは無視するのがWARL (Write Any, Read Legal) semantics。 現段階のmstatusはMIE/MPIE/MPPしかsupportしないので、書き込み側で該当bitだけ拾って残りは触らない:

12'h300: begin
  r_mstatus.mie  <= w_csr_wnext[    3];
  r_mstatus.mpie <= w_csr_wnext[    7];
  r_mstatus.mpp  <= w_csr_wnext[12:11];
end

これでTBでCSRRW mstatus, 0xFFFFFFFF_FFFFFFFFを試すとreadbackで0x1888 (MIE=1, MPIE=1, MPP=11) だけ立って他bitは0のまま、というWARL挙動が取れる。

Trap起動とfetch redirect

Trapが起きた時、fetchはmtvec.baseに、MRETの時はmepcに飛ばす必要がある。fetch側は既にi_redirect_valid / i_redirect_pcを受けるinterfaceがあるので、CSR unitからそこに出力するだけ:

assign o_redirect_valid = i_trap_valid | (i_iss_valid & i_iss_data.rn.uop.is_mret);
assign o_redirect_pc    = i_trap_valid ? {r_mtvec[VADDR_W-1:2], 2'b00} : r_mepc;
assign o_flush_all      = o_redirect_valid;

mtvecの下位2bitはmode field (Direct=00 / Vectored=01)。M1はDirectのみsupport前提なのでbase部分を{[63:2], 2'b00}で切り出す。

o_flush_allはpipeline全squashトリガで、fetch / decode / rename / IQ / ROBの各段が受け取って自身のstateをclearする (実装は各moduleのi_flush_all port経由、既に他モジュールで支度は済んでいる)。

CSRRWの返り値: 旧CSR値をwriteback

CSR命令のsemanticsは「rdに旧CSR値を書く、CSRに新値を書く」。writeback pathで旧CSR値を返す:

if (i_iss_valid & i_iss_data.rn.uop.is_csr) begin
  o_wb_valid          = 1'b1;
  o_wb_data.rob_id    = i_iss_data.rn.rob_id;
  o_wb_data.prd       = i_iss_data.rn.prd;
  o_wb_data.reg_type  = REG_XPR;
  o_wb_data.result    = w_csr_rdata;   // 旧CSR値
  o_wb_data.wb_en     = 1'b1;
end

ECALL/EBREAK/MRETは書き込み先レジスタが無いのでwb_en=0でROBのmark doneだけ発火:

end else if (i_iss_valid & (is_ecall | is_ebreak | is_mret)) begin
  o_wb_valid         = 1'b1;
  o_wb_data.rob_id   = i_iss_data.rn.rob_id;
  o_wb_data.reg_type = REG_NONE;
  o_wb_data.wb_en    = 1'b0;
end

RISC-V OoO CPUを実装する (11. LSU: STQ + blocking Load、LDQを持たない理由)

Fetchまで一通り繋がったので、EX sideに残っていた最後のunit、LSUを組んだ。 今回はminimal構成 (cache無し、DTCM直付け、in-order commit、STQ 8-entry、blocking load、Store-to-Load forwarding無し) で、それでも「なぜLDQが要らないか」「STQのcommit orderingをどう扱うか」など、書いてみないと見えなかった判断がいくつかあった。

サブモジュール分割

3 module構成:

  • scariv_dtcm.sv: 1-cycle registered read/write SRAM stub (ITCMにwrite portを追加した形)
  • scariv_stq.sv: 8-entry FIFO Store Queue、commitまでstoreをhold
  • scariv_lsu.sv: AGU + alignment + DTCM/STQ接続wrapper

ALU/BRUがbare primitiveだった路線と同じで、DTCM (memory model) とSTQ (状態を持つFIFO) を切り出し、LSU本体は「addr計算 + align + STQ enq + load pending register」だけに絞った。

なぜLDQを持たないか

Plan docではLDQ / STQ各8-entryと書いていたのだが、実装に入って「M1 minimalではLDQ要らない」と気付いた。

理由:

  1. DTCMは1-cycle latencyのSRAM直付け ==> loadは必ず「発行後1 cycleで読み終わる」
  2. Blocking (次loadは前loadがwbするまで発行しない) ==> in-flightは常に1個
  3. Cache miss / non-blockingのケースが無い ==> queueで「複数のin-flight load」を管理する必要が無い

つまりLDQの実体はregister 1個 (r_ld_pending + rob_id + prd + addr_lo + funct3) で済む。 これは実質「1-entryのLDQ」。

将来必要になるのは:

  • D$ / L1D$ 導入時: cache missでloadがmulti-cycle停滞
  • Non-blocking load: miss中に次loadを発行してMSHR queueで管理
  • Store-to-load forwarding: STQ lookupとloadを並列に

これらが入る段階でLDQ moduleを切り出す。今はskip。

STQの構造

FIFOベースで、head / tailのsemanticsは慣例とswapして:

  • r_head: 次にenqueueする空きslot (write pointer)
  • r_tail: 次にdequeueする古いentry (read pointer)

top bit (STQ_W:0の最上位) でfull / emptyを判別する古典的なcircular buffer:

// full: top bits differ, bottom bits equal (wrap 1周した)
// empty: head == tail
assign o_enq_ready = ~((r_head[STQ_W] != r_tail[STQ_W]) &
                       (r_head[STQ_W-1: 0] == r_tail[STQ_W-1: 0]));

Enqueue on dispatch、dequeue on commit。

Load側の「same-line conflict」判定は8-byte line単位 (addr[VADDR_W-1:3]の完全一致 + STQ entry valid):

generate for (genvar e_idx = 0; e_idx < STQ_ENTRIES; e_idx++) begin
  assign w_ld_stq_hit[e_idx] = r_stq[e_idx].valid &
                                (r_stq[e_idx].addr[VADDR_W-1: 3] ==
                                 i_ld_addr[VADDR_W-1: 3]);
end endgenerate

assign o_ld_conflict = |w_ld_stq_hit;

8-byte line粒度は「安全側に振った」判定で、sub-word overlapを見逃す (例: STQにSB @0x100、load LB @0x107は同じlineでconflictになるが、byte単位では別領域)。 本来forwardingを入れる段階ではaddress + sizeでrange overlapを取るが、M1では「保守的にstall」で正しく動く。

LSU本体のpipeline

Load (2 cycle):

  • Cycle T: issueのaddr = op1 + immを計算、DTCM read発火 (o_dtcm_ren=1)、r_ld_pending <= 1とrob_id / prd / addr_lo / funct3をregister
  • Cycle T+1: i_dtcm_rdataが返ってきて、r_ld_addr_loでbyte offset shift + r_ld_funct3でsign/zero extend、o_wb_valid=1

Store (1 cycle):

  • Cycle T: issueのaddr / dataを作ってSTQ enqueue、同cycleでo_wb_valid=1 (side-effect無しでROBをmark done)

Commit (別always_ff、ROBから通知):

  • i_cmt_store=1のcycleにSTQ headをdequeue、DTCMにwrite発火 (o_dtcm_wen=1)

o_iss_readyは3要素AND:

assign o_iss_ready = !r_ld_pending &
                     (is_store ? w_stq_ready :          // STQ空きがあればOK
                      is_load  ? !w_ld_conflict :       // same-line conflictなし
                                 1'b1);

w_iss_is_load / w_iss_is_storeは自分で& o_iss_readyを含むので、この式の右辺で使うとcombinational loopになる。 loop回避のため右辺はrawな.is_load / .is_storeを参照 (DTCM read発火の判定側ではw_iss_is_loadを使う、といった具合に用途で使い分けた)。

Load align / Store wmask生成

Load側はfunct3でsign/zero extendの7通り:

logic [63: 0] w_shifted;
assign w_shifted = i_dtcm_rdata >> (r_ld_addr_lo * 8);

always_comb begin
  case (r_ld_funct3)
    3'b000: w_ld_align = {{56{w_shifted[ 7]}}, w_shifted[ 7: 0]};  // LB
    3'b001: w_ld_align = {{48{w_shifted[15]}}, w_shifted[15: 0]};  // LH
    3'b010: w_ld_align = {{32{w_shifted[31]}}, w_shifted[31: 0]};  // LW
    3'b011: w_ld_align = w_shifted;                                 // LD
    3'b100: w_ld_align = {56'h0, w_shifted[ 7: 0]};                 // LBU
    3'b101: w_ld_align = {48'h0, w_shifted[15: 0]};                 // LHU
    3'b110: w_ld_align = {32'h0, w_shifted[31: 0]};                 // LWU
    default:w_ld_align = 'h0;
  endcase
end

Store側はwmask生成 + data shiftの2段:

case (w_iss_funct3)
  3'b000: w_iss_st_wmask = 8'b0000_0001 << w_iss_addr_lo;   // SB
  3'b001: w_iss_st_wmask = 8'b0000_0011 << w_iss_addr_lo;   // SH
  3'b010: w_iss_st_wmask = 8'b0000_1111 << w_iss_addr_lo;   // SW
  3'b011: w_iss_st_wmask = 8'b1111_1111;                    // SD
endcase

assign w_stq_enq_wdata = i_iss_data.op2 << (w_iss_addr_lo * 8);

op2 << (addr_lo * 8)でstore dataをline内の適切なbyte位置に配置してからSTQに入れる。DTCM側はwmaskで該当byte laneだけ更新するので、その他のbyteは温存される。

踏み抜いたbug: shift amountのwidth bug

最初、shift式を以下のように書いていた:

assign w_shifted = i_dtcm_rdata >> ({3'b000, r_ld_addr_lo} * 3'd8);   // buggy

SystemVerilogの*演算結果widthはoperandのmaxだが、{3'b000, r_ld_addr_lo} = 6-bit × 3'd8 = 3-bitの場合、contextによっては3-bit truncationが起きて実質& 0x07になる。 addr_lo = 1の時、1*8=8が3-bitで8'b000 (=0) にラウンドされ、shift量が0になっていた。

結果、byte 1をloadするとbyte 0の内容が返ってきて全laneがpayload[0] を返すという症状で、TB debugで判明。修正は超シンプル:

assign w_shifted = i_dtcm_rdata >> (r_ld_addr_lo * 8);

同じパターンがstore側のw_stq_enq_wdataにもあって、両方直して初めて全lane passした。 「Verilogの演算結果widthはoperand幅のmax」というルールにint literal (3'd8) を混ぜると罠になる、というのが教訓。単に* 8と書けばSystemVerilogは32-bit intとして扱うのでtruncationは起きない。

RISC-V OoO CPUを実装する (10. Fetch: 3-stage F1-F2-F3 + ITCM + 4B alignment)

Decoderが出来上がったので、実命令を供給する側のFetchを組んだ。

BTB / TAGEなどのbranch predictionは後段の拡張に回し、現段階ではsequential fetch + redirectだけに絞る。一方で4-byte alignment、特にmid-line redirectの扱いは最初から真面目に実装した。

これでFetch → Decode → Rename → Dispatch → ROB → IQ → EXの流れが片方向で繋がる状態になった。

Fetch stageの割り付け

将来的なXiangShan Kunminghu相当の6-way構成を目指す前提で、fetch stageは最終的にF1-F2-F3の3-stage構成にする。

  • F1: PC gen + BTB lookup
  • F2: L1 I$ read (1-cycle SRAM)
  • F3: Predecode + alignment + BTB update

現段階ではL1 I$ / BTBを持たないので、以下の縮約版で組んだ。

  • F1: PC gen。BTBは無し、redirectだけ受ける
  • F2: ITCM read (1-cycle registered SRAM)
  • F3: 64-bit read dataを2命令 (32-bit × 2) に分解してDecoderへ渡す

段数を最終形と揃えたのは、後でBTB / I$を挿入しても前後のvalid chainを大きく書き換えずに済むため。

最初は2-stageで書くつもりだったが、F2を明示的なregisterとして持った方が命名もtimingも素直なので、そのまま3-stageにした。

ITCMは1-cycle registered SRAM

まずFetchの前提となるITCMを書いた。

module scariv_itcm #(
  parameter int    ITCM_ENTRIES = 256,
  parameter string INIT_FILE    = ""
)(
  input  logic                 i_clk,
  input  logic                 i_rst_n,
  input  logic                 i_ren,
  input  logic [VADDR_W-1:0]   i_addr,
  output logic [63:0]          o_rdata
);

localparam int IDX_W = $clog2(ITCM_ENTRIES);

logic [IDX_W-1:0] r_idx;
logic [63:0] mem [ITCM_ENTRIES];

always_ff @(posedge i_clk, negedge i_rst_n) begin
  if (!i_rst_n)
    r_idx <= '0;
  else if (i_ren)
    r_idx <= i_addr[IDX_W+2:3];
end

assign o_rdata = mem[r_idx];

initial
  if (INIT_FILE != "")
    $readmemh(INIT_FILE, mem);

endmodule

64-bit幅、つまり1 entry = 2命令。入力はbyte addressなので下位3-bitを捨ててindexにする。

Write portは現段階では不要なので持たせていない。初期化は$readmemhでhex fileを指定でき、TBでは空文字列を渡してmemをhierarchical writeで埋めた。

Fetch pipeline

scariv_fetch.svのportはITCM、Decoder、Redirectの3 groupに分けた。

module scariv_fetch (
  input  logic                  i_clk,
  input  logic                  i_rst_n,
  input  logic [VADDR_W-1:0]    i_init_pc,

  // ITCM
  output logic                  o_itcm_ren,
  output logic [VADDR_W-1:0]    o_itcm_addr,
  input  logic [63:0]           i_itcm_rdata,

  // To decoder
  output logic                  o_f3_valid [DISP_SIZE],
  output logic [INST_W-1:0]     o_f3_inst  [DISP_SIZE],
  output logic [VADDR_W-1:0]    o_f3_pc    [DISP_SIZE],
  input  logic                  i_dec_ready[DISP_SIZE],

  // Redirect
  input  logic                  i_redirect_valid,
  input  logic [VADDR_W-1:0]    i_redirect_pc
);

i_init_pcはportで受ける。将来的にはreset vectorやCSR側の設定値から配線する想定で、現段階ではFetch自身に固定値を持たせない。

i_dec_readyはper-lane array。ただし現段階のRenameは全laneまとめてstallする粒度で組む予定なので、実質的には両laneに同じ値が来る。

F3のadvance条件は、現在保持している命令をDecoderがすべてacceptした時だけ進める方式にした。

assign w_f3_accept = !r_f3_valid |
                     (i_dec_ready[0] &
                      (r_f3_pc[2] | i_dec_ready[1]));

Mid-line fetch (pc[2] = 1) ではlane 1自体が無効なので、i_dec_ready[1]はdon't careになる。

4-byte alignmentを真面目にやる

ITCMは8B/entryなので、Fetchから見るとpc[2]が「lineの前半から始めるか、後半から始めるか」を決める。

JAL / JALR / BRANCHのtargetは4B境界なので、redirect先がpc[2] = 1になることは普通にある。

pc[2] inst[0]のsource inst[1]のsource 有効lane数
0 rdata[31:0] rdata[63:32] 2
1 rdata[63:32] 無効 1

次のPCは両ケース共通で、

assign w_next_pc = {r_f1_pc[VADDR_W-1:3] + 1'b1, 3'b000};

とした。

pc[2] = 0なら現在lineの先頭から次の8B lineへ進むので+8、pc[2] = 1なら現在lineの後半1命令だけ消費して次のlineへ進むので実質+4になる。

最初は単純にr_f1_pc + 8としていたが、mid-line redirectで例えば0x2004 → 0x200Cとなり、次の8B aligned lineを指さないbugになった。

F3ではPCに応じて64-bit dataを分解する。

always_comb begin
  o_f3_valid[0] = r_f3_valid;
  o_f3_valid[1] = r_f3_valid & !r_f3_pc[2];

  o_f3_inst[0] = r_f3_pc[2]
                ? r_f3_rdata[63:32]
                : r_f3_rdata[31:0];

  o_f3_inst[1] = r_f3_rdata[63:32];

  o_f3_pc[0] = r_f3_pc;
  o_f3_pc[1] = {r_f3_pc[VADDR_W-1:3], 3'b100};
end

Backpressureとredirectを共存させる

今回一番書き直したのは、3-stage pipelineでbackpressureとredirectをどう共存させるか。

最初は各registerのRHSにw_f3_acceptを掛けていた。

// buggy
r_f2_valid <= !i_redirect_valid & r_f1_valid & w_f3_accept;
r_f3_valid <= !i_redirect_valid & r_f2_valid & w_f3_accept;

これだとstallした瞬間にvalidが0へ落ち、F3で保持していた命令まで消える。

正しくは「stallならregisterを更新しない」。そこでupdate条件自体を分岐した。

end else if (i_redirect_valid) begin
  // Redirect: squash F2/F3, restart at redirect_pc
  r_f1_pc    <= i_redirect_pc;
  r_f1_valid <= 1'b1;
  r_f2_valid <= 1'b0;
  r_f3_valid <= 1'b0;

end else if (w_f3_accept) begin
  // Normal advance
  r_f1_valid <= 1'b1;

  if (r_f1_valid)
    r_f1_pc <= w_next_pc;

  r_f2_valid <= r_f1_valid;
  r_f2_pc    <= r_f1_pc;

  r_f3_valid <= r_f2_valid;
  r_f3_pc    <= r_f2_pc;
  r_f3_rdata <= i_itcm_rdata;
end

// else: stall, all registers hold

Redirectはstallより優先し、in-flightのF2 / F3を捨ててredirect先から再開する。stallは単純に全register hold。

またreset直後はr_f1_valid = 0なので、r_f1_pcのadvanceにもr_f1_validのguardが必要。これがないとi_init_pcを読み出す前に次lineへ進んでしまう。

3-stage + stallのcorner case

まだ1個、簡略化したままのcorner caseがある。

F3がfullでstallしているcycleにF2のITCM read responseが返ってくると、そのdataを退避する場所がない。現在のF2はPC / validだけを持ち、read dataはF3へ直接latchする構成だから。

真面目に対応するなら、

  • F2にr_f2_rdataを持たせてskid buffer化する
  • F2を潰して2-stage構成に折り畳む

あたりになる。

現段階ではDecoder / Renameが基本always readyで、backpressure testもF3 holdの確認を目的としているのでbacklog送りにした。

検証

TBは2本。

  • tb_scariv_itcm.sv: 270 tests
    • sequential read
    • byte-address alias
    • ren hold
    • random 200 reads
  • tb_scariv_fetch.sv: 75 tests
    • sequential fetch
    • mid-line redirect (pc[2] = 1)
    • aligned redirect
    • 5-cycle backpressure hold

ITCMの初期化は$readmemhではなく、TBからu_dut.mem[i] = ...でhierarchical writeした。

ここでもVerilatorで1個踏んだ。assign rdata = mem[r_idx]のようなcombinational readでは、TBからmemをhierarchical writeした直後にrdataが再評価されず、古い値が残る場合があった。r_idxが動けば再評価されるので、TB側ではその前提でsequenceを組んだ。

Stimulusは@(negedge clk)で入力を設定し、@(posedge clk)でDUTにsampleさせる形にするとscheduling raceが起きにくい。

Fetch側のTBではITCMに入れる命令を、

{16'hC0DE, pc[15:0]}

というPC依存のpatternにした。F3へ出てきたinstructionを見るだけで、どのPCからfetchされたものか分かる。

計ITCM 270 tests、Fetch 75 tests、全通過。

現時点の全モジュール状況

Module TB tests Status
PRF 40062 PASS
RAT 49896 PASS
Free list 43193 PASS
Rename 60 PASS
ROB 51 PASS
IQ 29 PASS
ALU 11500 PASS
BRU 50736 PASS
Decoder 73 PASS
ITCM 270 PASS
Fetch 75 PASS

これでbackendの主要moduleに加えて、frontendもFetch → Decodeまで揃った。