이 프로젝트의 심장. 4-way 캐시 컨트롤러 = 2단 파이프라인 + 6상태 FSM +
forwarding. 면접에서 가장 깊게 파고드는 파일이라, 블록별로 "무엇을·왜"를 다 잡는다.
컨트롤러는 크게 7개 블록으로 나뉜다. 순서대로 읽으면 데이터 흐름이 보인다.
| # | 블록 | 종류 | 역할 |
|---|---|---|---|
| 1 | S1→S2 파이프라인 레지스터 | always_ff | 요청을 한 사이클 붙잡아 S2로 넘김 |
| 2 | 라인 상태 (valid/dirty/PLRU) | 플롭 배열 | 캐시 메타데이터, 리셋 1사이클 flush |
| 3 | 배열 read 주소 mux | always_comb | S1/S2/victim 중 어느 주소를 SRAM에 줄지 |
| 4 | forwarding | always_ff+always_comb | read-before-write hazard 우회 |
| 5 | hit 판정 | always_comb | 4-way 병렬 태그 비교 |
| 6 | victim 선택 | always_comb | 교체할 way 결정 (invalid 우선, 아니면 PLRU) |
| 7 | FSM | always_ff+always_comb | miss 처리(evict/fill) 시퀀싱 |
핵심 원칙: hit는 파이프라인이 조합적으로 처리(FSM은 ST_IDLE에 머무름),
miss만 FSM이 여러 사이클에 걸쳐 처리(그동안 stall).
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
!pipeline_stall 일 때만 갱신 = stall이면 S2가 그 요청을 붙잡고 있음 (miss 처리 동안 요청이 날아가지 않게).s1_* 는 입력 주소에서 조합적으로 뽑은 필드, s2_* 는 레지스터된 주소에서 뽑은 필드. (get_set/get_word/get_tag는 l1_cache_pkg.sv)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
===를 써야 한다.동기 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_READ | dirty victim 읽어낼 때 | S2 set + wb_cnt word | victim 라인을 워드별로 순회 |
| ③ stall/idle | 그 외 | S2의 {set,word} | 파이프라인 홀드 중이니 S2 유지. idle 시 X 인덱싱 방지 |
왜 ③에서 S2로 폴백? stall 중엔 S1에 새 주소가 와도 무시해야 하고, 요청이 없을 땐
드라이버가 버스에 X를 실으므로 그 X로 배열을 인덱싱하면 안 된다.
동기 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개 (면접에서 여기 파고듦):
비교 대상이 s2가 아니라 prev_*_raddr — 직전 사이클에 실제로 인가한
read 주소. ST_WB_READ에선 read 주소가 라인을 순회(§3 ②)하므로 s2_word가 아님.
그래서 "직전에 준 read 주소"를 플롭으로 잡아 비교.
바이트 단위 — partial write(be != 4'b1111)면 쓴 레인만 포워딩, 나머지는 배열 값.
워드 통째로 포워딩하면 안 건드린 바이트가 오염됨.
1사이클 깊이면 충분 — write 다음다음 사이클엔 배열 자체가 새 값을 가짐.
stale인 건 딱 한 사이클.
태그(
safe_tag_rdata)도 같은 원리인데 바이트 분할이 없다(태그는 통째 갱신).
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인지
set_valid[w] 조건 필수 — valid 안 된 way의 쓰레기 태그가 우연히 맞는 걸 배제.safe_tag_rdata(포워딩된 값)를 쓰는 게 중요 — 방금 쓴 태그도 반영.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를 블록 밖에 선언하고 안에서 대입. (실제로 다른 곳에서 이 함정에 물렸음)
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];
읽기 응답이 나가는 두 순간:
safe_data_rdata[hit_way], 포워딩 반영)fill_datawrite는 응답 없음(!s2_rw 조건). fill_data는 FSM이 fetch 중 요청 워드를 잡아둔 값(§8 ST_FILL_RCV).
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로 스냅샷.
맨 위에 기본값 전부 비활성(모든 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.
ST_COMPLETE에서 stall을 푼다 (428~440행)ST_COMPLETE: begin
pipeline_stall = 1'b0; // ← 여기서 해제
...
end
여기서 stall을 유지하면 다음 사이클 ST_IDLE에서 S2가 같은 요청을 그대로 들고 있어
응답을 두 번 내보낸다. stall을 풀어야 파이프라인이 진행되어 S2가 넘어감.
tag_raddr가 pipeline_stall로 선택되지만, tag_rdata는 플롭 출력(동기 SRAM)이라
stall→raddr→rdata→hit→stall 피드백이 끊긴다. 비동기 SRAM이면 이 mux가 루프를 닫는다
(이전 버전의 675 버그).
always_comb 정적 변수 함정 (§6)절차 블록 선언 초기화는 static. 루프/조합 블록에선 선언과 대입 분리.
메모리 latency=2 가정. victim이 clean이라 write-back 없음.
| cycle | state | 하는 일 | stall |
|---|---|---|---|
| N | ST_IDLE | S2에 read 도착, miss 판정, victim 래치 | 1 |
| N+1 | ST_FILL_REQ | mem_req(rw=0) 발행, req_ready 대기 | 1 |
| N+2 | ST_FILL_RCV | (latency 대기) | 1 |
| N+3 | ST_FILL_RCV | beat0 수신 → data[set,0] 기록, 요청워드면 fill_data | 1 |
| N+4~6 | ST_FILL_RCV | beat1,2,3 수신·기록; last에서 → COMPLETE | 1 |
| N+7 | ST_COMPLETE | valid=1,dirty=0,PLRU갱신; rsp_valid+fill_data; stall=0 | 0 |
| N+8 | ST_IDLE | 다음 요청 진행 | - |
dirty miss면 N+1 앞에 WB_READ(≈5) + WB_SEND(req+4beat) 가 추가된다.
| 질문 | 답 |
|---|---|
| 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은 캐시가 자기 배열을 채우는 것 → 항상 수용 가능 |
pipeline_stall이 1이면 S1→S2 레지스터는 어떻게 되나? 왜 그래야 하나?ST_WB_READ에서 wb_buf[wb_cnt-1]로 한 사이클 뒤에 캡처하는 이유는?prev_data_raddr(직전 read 주소)로 비교하는 이유는? s2면 왜 안 되나?ST_COMPLETE에서 pipeline_stall = 0으로 푸는 이유는?