rtl/l1_cache_core.sv

Seungyun Lee·2026년 8월 9일

이 프로젝트의 심장. 4-way 캐시 컨트롤러 = 2단 파이프라인 + 6상태 FSM +
forwarding. 면접에서 가장 깊게 파고드는 파일이라, 블록별로 "무엇을·왜"를 다 잡는다.


0. 큰 그림 — 이 모듈의 구성

컨트롤러는 크게 7개 블록으로 나뉜다. 순서대로 읽으면 데이터 흐름이 보인다.

#블록종류역할
1S1→S2 파이프라인 레지스터always_ff요청을 한 사이클 붙잡아 S2로 넘김
2라인 상태 (valid/dirty/PLRU)플롭 배열캐시 메타데이터, 리셋 1사이클 flush
3배열 read 주소 muxalways_combS1/S2/victim 중 어느 주소를 SRAM에 줄지
4forwardingalways_ff+always_combread-before-write hazard 우회
5hit 판정always_comb4-way 병렬 태그 비교
6victim 선택always_comb교체할 way 결정 (invalid 우선, 아니면 PLRU)
7FSMalways_ff+always_combmiss 처리(evict/fill) 시퀀싱

핵심 원칙: hit는 파이프라인이 조합적으로 처리(FSM은 ST_IDLE에 머무름),
miss만 FSM이 여러 사이클에 걸쳐 처리(그동안 stall).


1. S1 → S2 파이프라인 레지스터 (66~96행)

always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin ... 리셋 ... end
    else if (!pipeline_stall) begin       // ← stall이면 값 유지(홀드)
        s2_valid <= cpu_req_valid;
        s2_rw    <= cpu_req_rw;
        s2_addr  <= cpu_req_addr;
        s2_be    <= cpu_req_be;
        s2_wdata <= cpu_req_wdata;
    end
end
  • S1(입력 핀) → S2(레지스터)로 요청을 1사이클 지연. 이 레지스터가 파이프라인 단 경계.
  • !pipeline_stall 일 때만 갱신 = stall이면 S2가 그 요청을 붙잡고 있음 (miss 처리 동안 요청이 날아가지 않게).
  • s1_* 는 입력 주소에서 조합적으로 뽑은 필드, s2_* 는 레지스터된 주소에서 뽑은 필드. (get_set/get_word/get_tagl1_cache_pkg.sv)

2. 라인 상태 — 플롭에 둔 이유 (98~108행)

logic [WAYS-1:0] valid_bits [SETS];   // set당 4비트 (way별 valid)
logic [WAYS-1:0] dirty_bits [SETS];
plru_t           plru_bits  [SETS];   // set당 3비트 PLRU
  • valid/dirty/PLRU를 SRAM이 아니라 플롭에 뒀다.
  • 이유: 리셋 한 사이클에 캐시 전체가 flush된다 (FSM 리셋 블록 280~284행에서 전 set을 0으로).
    SRAM에 두면 전원 인가 후 전 라인을 순회 무효화하는 상태가 필요하고, 그 전까진
    태그가 X라 hit 판정에 합성 불가 ===를 써야 한다.
  • 용량: 64 set × 4 way × 2bit(valid+dirty) + 64 × 3(plru) = 감당 가능한 플롭 수.

3. 배열 read 주소 mux (136~149행) ★

동기 SRAM이라 N에 준 주소가 N+1에 데이터로 나온다. 그래서 "지금 어느 주소를
줘야 다음 사이클 S2가 원하는 데이터가 오는가"를 3경우로 나눈다.

always_comb begin
    if (!pipeline_stall && cpu_req_valid) begin        // ① 정상
        tag_raddr  = s1_set;
        data_raddr = make_data_addr(s1_set, s1_word);
    end
    else if (state == ST_WB_READ) begin                // ② victim 읽어내는 중
        data_raddr = make_data_addr(s2_set, wb_cnt[1:0]);
    end
    else begin                                          // ③ stall/idle
        tag_raddr  = s2_set;
        data_raddr = make_data_addr(s2_set, s2_word);
    end
end
경우언제주소이유
① 정상진행 중 & 유효 요청S1의 {set,word}다음 사이클 S2에 이 요청 데이터가 도착
② WB_READdirty victim 읽어낼 때S2 set + wb_cnt wordvictim 라인을 워드별로 순회
③ stall/idle그 외S2의 {set,word}파이프라인 홀드 중이니 S2 유지. idle 시 X 인덱싱 방지

왜 ③에서 S2로 폴백? stall 중엔 S1에 새 주소가 와도 무시해야 하고, 요청이 없을 땐
드라이버가 버스에 X를 실으므로 그 X로 배열을 인덱싱하면 안 된다.


4. Forwarding — read-before-write 우회 (166~210행) ★★

동기 SRAM은 "쓰기 직전 값"을 반환한다(STUDY_sram.md §4).
그래서 직전 사이클에 쓴 위치를 지금 읽으면 stale. 1단 포워딩으로 우회.

// 직전 사이클의 write/read 정보를 플롭에 저장
prev_data_we, prev_data_wbe, prev_data_waddr, prev_data_wdata, prev_data_raddr <= ...

// 바이트 단위로 우회
safe_data_rdata[w][b*8 +: 8] =
    (prev_data_we[w] && (prev_data_waddr == prev_data_raddr) && prev_data_wbe[b])
    ? prev_data_wdata[b*8 +: 8]     // 직전에 쓴 값
    : data_rdata[w][b*8 +: 8];      // 배열이 준 값

포인트 3개 (면접에서 여기 파고듦):

  1. 비교 대상이 s2가 아니라 prev_*_raddr — 직전 사이클에 실제로 인가한
    read 주소
    . ST_WB_READ에선 read 주소가 라인을 순회(§3 ②)하므로 s2_word가 아님.
    그래서 "직전에 준 read 주소"를 플롭으로 잡아 비교.

  2. 바이트 단위 — partial write(be != 4'b1111)면 쓴 레인만 포워딩, 나머지는 배열 값.
    워드 통째로 포워딩하면 안 건드린 바이트가 오염됨.

  3. 1사이클 깊이면 충분 — write 다음다음 사이클엔 배열 자체가 새 값을 가짐.
    stale인 건 딱 한 사이클.

태그(safe_tag_rdata)도 같은 원리인데 바이트 분할이 없다(태그는 통째 갱신).


5. Hit 판정 — 4-way 병렬 비교 (215~230행)

for (int w = 0; w < WAYS; w++)
    way_hit[w] = set_valid[w] && (safe_tag_rdata[w] == s2_tag);

assign cache_hit = s2_valid && (|way_hit);   // |way_hit = OR 리덕션

for (int w = 0; w < WAYS; w++)
    if (way_hit[w]) hit_way = way_t'(w);      // 어느 way가 hit인지
  • 4개 way의 태그를 동시에 s2_tag와 비교 (direct-mapped는 1개만, 4-way는 4개 병렬).
  • set_valid[w] 조건 필수 — valid 안 된 way의 쓰레기 태그가 우연히 맞는 걸 배제.
  • 정상 캐시라면 set당 같은 태그는 최대 1개 way에만 있음 → hit_way 유일.
  • 비교에 safe_tag_rdata(포워딩된 값)를 쓰는 게 중요 — 방금 쓴 태그도 반영.

6. Victim 선택 (236~257행)

for (int w = WAYS-1; w >= 0; w--)           // 높은 way→낮은 way
    if (!set_valid[w]) begin
        first_invalid = way_t'(w); has_invalid = 1'b1;
    end
victim_sel = has_invalid ? first_invalid : plru_victim(plru_bits[s2_set]);

우선순위: ① invalid(빈) way가 있으면 그걸 먼저 채움(cold fill) → ② 없으면 PLRU가 지목.

  • 루프가 높은→낮은 순회 + 덮어쓰기라, first_invalid가장 낮은 번호의 invalid way가 남음.
  • plru_victim()은 3비트 트리를 따라 내려가 victim 결정 (l1_cache_pkg.sv).
  • victim_dirty = victim이 valid && dirty → write-back 필요 여부.

⚠️ 여기 주석의 교훈: always_comb 안에서 logic x = 0; 같은 선언 초기화 금지.
절차 블록 변수는 static이라 time 0에 한 번만 초기화됨. 그래서 first_invalid/
has_invalid를 블록 밖에 선언하고 안에서 대입. (실제로 다른 곳에서 이 함정에 물렸음)


7. CPU 응답 (262~265행)

assign cpu_rsp_valid = ((state == ST_IDLE)     && s2_valid && cache_hit && !s2_rw)
                    || ((state == ST_COMPLETE) && s2_valid &&              !s2_rw);

assign cpu_rsp_rdata = (state == ST_COMPLETE) ? fill_data : safe_data_rdata[hit_way];

읽기 응답이 나가는 두 순간:

  • ST_IDLE의 read hit → 데이터는 배열에서(safe_data_rdata[hit_way], 포워딩 반영)
  • ST_COMPLETE의 read (miss 처리 끝) → 데이터는 fill에서 잡아둔 fill_data

write는 응답 없음(!s2_rw 조건). fill_data는 FSM이 fetch 중 요청 워드를 잡아둔 값(§8 ST_FILL_RCV).


8. FSM (I) — 순차 블록: 무엇을 래치하나 (270~339행)

state <= next_state 외에, 상태별로 레지스터에 무엇을 저장하는지.

ST_IDLE:
    hit  → PLRU 갱신, write면 dirty 세팅
    miss → victim_way/victim_tag 래치, 카운터 리셋 (miss 시퀀스 준비)

ST_WB_READ:
    if (wb_cnt > 0) wb_buf[wb_cnt-1] <= safe_data_rdata[victim_way];  // 1사이클 뒤 캡처
    wb_cnt <= wb_cnt + 1;

ST_FILL_RCV:
    if (mem_rd_valid) begin
        if (fill_beat == s2_word) fill_data <= mem_rd_data;  // 요청 워드만 잡아둠
        fill_beat <= fill_beat + 1;
    end

ST_COMPLETE:
    valid_bits[s2_set][victim_way] <= 1'b1;      // 라인 유효화
    dirty_bits[s2_set][victim_way] <= s2_rw;     // write miss면 dirty
    plru_bits[s2_set] <= plru_update(..., victim_way);

wb_cnt-1 캡처의 의미: 동기 read라 주소를 준 다음 사이클에 데이터가 옴.
wb_cnt=N일 때 word N 주소를 인가(§3 ②) → wb_cnt=N+1이 됐을 때 word N 데이터 도착
wb_buf[N]에 저장. 그래서 "한 사이클 뒤(wb_cnt-1)"에 캡처.

왜 victim을 버퍼에 복사? fill(ST_FILL_RCV)이 같은 물리 위치
{s2_set, victim_way}에 새 라인을 덮어쓴다. 그러니 덮이기 전에 victim을 먼저 읽어내야
한다 → WB_READ에서 wb_buf로 스냅샷.


9. FSM (II) — 조합 블록: next_state + 출력 (344~444행)

맨 위에 기본값 전부 비활성(모든 we=0, mem_req=0, stall=0…)을 깔고, 상태별로 덮어씀.
이게 latch 방지 + 안전한 기본 상태의 정석.

ST_IDLE:
    hit & write → data_we[hit_way]=1, data_wbe=s2_be   (제자리 갱신)
    miss        → stall=1, next = victim_dirty ? WB_READ : FILL_REQ

ST_WB_READ:  stall=1;  wb_cnt==4 → next=WB_SEND
ST_WB_SEND:  stall=1
    !req_sent → mem_req(rw=1, victim 주소) 발행
    req_sent  → mem_wr_data=wb_buf[wb_ptr], last=마지막워드; ready&last → next=FILL_REQ
ST_FILL_REQ: stall=1;  mem_req(rw=0, 새 라인 주소);  req_ready → next=FILL_RCV
ST_FILL_RCV: stall=1
    mem_rd_valid → data_we[victim_way]=1, wbe=all, {set,fill_beat}에 기록; last → next=COMPLETE
ST_COMPLETE: stall=0(!);  tag_we[victim_way]=1(태그 커밋);
             write면 data_we[victim_way]=1 + s2_be (스토어 반영);  next=IDLE

miss 처리 흐름 (dirty): IDLE → WB_READ(victim 버퍼링) → WB_SEND(메모리로 스트리밍)
→ FILL_REQ(새 라인 요청) → FILL_RCV(4비트 수신) → COMPLETE(커밋+응답) → IDLE.
clean victim이면 WB 두 단계 건너뛰고 바로 FILL_REQ.


10. ★ 반드시 외울 설계 포인트 3개

(a) ST_COMPLETE에서 stall을 푼다 (428~440행)

ST_COMPLETE: begin
    pipeline_stall = 1'b0;   // ← 여기서 해제
    ...
end

여기서 stall을 유지하면 다음 사이클 ST_IDLE에서 S2가 같은 요청을 그대로 들고 있어
응답을 두 번
내보낸다. stall을 풀어야 파이프라인이 진행되어 S2가 넘어감.

(b) 왜 조합 루프가 없나

tag_raddrpipeline_stall로 선택되지만, tag_rdata플롭 출력(동기 SRAM)이라
stall→raddr→rdata→hit→stall 피드백이 끊긴다. 비동기 SRAM이면 이 mux가 루프를 닫는다
(이전 버전의 675 버그).

(c) always_comb 정적 변수 함정 (§6)

절차 블록 선언 초기화는 static. 루프/조합 블록에선 선언과 대입 분리.


11. Cycle-by-cycle — clean read miss (가장 단순)

메모리 latency=2 가정. victim이 clean이라 write-back 없음.

cyclestate하는 일stall
NST_IDLES2에 read 도착, miss 판정, victim 래치1
N+1ST_FILL_REQmem_req(rw=0) 발행, req_ready 대기1
N+2ST_FILL_RCV(latency 대기)1
N+3ST_FILL_RCVbeat0 수신 → data[set,0] 기록, 요청워드면 fill_data1
N+4~6ST_FILL_RCVbeat1,2,3 수신·기록; last에서 → COMPLETE1
N+7ST_COMPLETEvalid=1,dirty=0,PLRU갱신; rsp_valid+fill_data; stall=00
N+8ST_IDLE다음 요청 진행-

dirty miss면 N+1 앞에 WB_READ(≈5) + WB_SEND(req+4beat) 가 추가된다.


12. 면접 예상 질문

질문
hit는 왜 FSM을 안 거치나?hit는 조합적으로 응답/write 가능. FSM은 miss(여러 사이클)만 처리. 성능 위해 hit path를 짧게
4-way에서 hit_way를 어떻게?4개 태그를 병렬 비교, valid까지 AND, OR 리덕션으로 hit, 인코딩으로 way
victim은 어떻게 고르나?invalid way 우선, 없으면 tree-PLRU
write-back 데이터를 왜 버퍼링?fill이 같은 {set,victim_way} 위치를 덮으므로, 덮기 전에 victim을 읽어내야 함
ST_COMPLETE가 없으면?fill 마지막 beat와 store write가 같은 사이클에 충돌 + 응답 이중 발생
stall 중 파이프라인 레지스터는?홀드(!pipeline_stall일 때만 갱신). 요청이 날아가지 않게
forwarding 비교를 s2로 하면 안 되는 이유?WB_READ에서 read 주소가 라인을 순회해 s2_word와 다름. 실제 인가한 prev_raddr로 비교해야
조합 루프가 없는 이유?rdata가 플롭 출력이라 stall로의 피드백이 끊김 (동기 SRAM 덕분)
read 채널에 ready가 없는 이유?fill은 캐시가 자기 배열을 채우는 것 → 항상 수용 가능

13. 셀프 체크

  1. pipeline_stall이 1이면 S1→S2 레지스터는 어떻게 되나? 왜 그래야 하나?
  2. clean miss와 dirty miss의 상태 경로 차이는?
  3. ST_WB_READ에서 wb_buf[wb_cnt-1]로 한 사이클 뒤에 캡처하는 이유는?
  4. forwarding에서 prev_data_raddr(직전 read 주소)로 비교하는 이유는? s2면 왜 안 되나?
  5. ST_COMPLETE에서 pipeline_stall = 0으로 푸는 이유는?
  6. victim으로 dirty way가 뽑혔는데 그걸 버퍼에 안 읽고 바로 fill하면 무슨 일이?
  1. 홀드(값 유지). miss 처리 동안 S2가 그 요청을 붙잡고 있어야 요청이 사라지지 않음.
  2. clean: IDLE→FILL_REQ→FILL_RCV→COMPLETE. dirty: 앞에 WB_READ→WB_SEND 추가.
  3. 동기 read라 주소 준 다음 사이클에 데이터 도착. word N 주소를 wb_cnt=N에 인가 →
    wb_cnt=N+1에 데이터 도착 → wb_buf[N]에 저장.
  4. WB_READ에서 read 주소가 라인을 순회(word 0,1,2,3)해 s2_word와 다름.
    "직전에 실제로 인가한 read 주소"와 비교해야 그 위치의 stale을 정확히 잡음.
  5. 안 풀면 다음 사이클 IDLE에서 S2가 같은 요청을 들고 있어 응답을 두 번 냄
    • fill 마지막 write와 store write 충돌.
  6. fill이 같은 {set,victim_way} 위치를 덮어써서 victim(수정된 값)이 메모리에 못 나가고
    사라짐 → 데이터 유실. 그래서 WB_READ로 먼저 스냅샷.
profile
Design Verification engineer

0개의 댓글