Study guide + Summary

Seungyun Lee·2026년 7월 30일

Pipelined L1 Cache Controller UVM

이 문서 하나로 공부할 수 있게 방법 + 내용을 모두 담았다.

  • Part 0~1 — 어떻게, 어떤 순서로 공부할지
  • Part 2 — 실제 학습 내용 (핵심)
  • Part 3~7 — 확인·연습·면접 대비

Part 0. 공부 3원칙

① 캐시는 "구조"보다 "타이밍"이다.
4-way, PLRU, write-back 같은 용어는 하루면 외운다. 이 프로젝트에서 진짜 배울 건
어느 사이클에 무엇이 유효한가 다. 배열 read가 registered라는 사실 하나가
파이프라인 구조·포워딩·FSM을 전부 결정한다. 사이클을 못 세면 아무것도 이해 못 한 것이다.

② 파일 순서가 아니라 "요청 하나의 일생"을 따라가라.
ls 순서로 읽으면 죽는다. CPU 요청 하나가
태어나서(seq) → 핀을 흔들고(driver) → S1/S2를 통과하고(DUT) → 관측되고(monitor) → 검사받는(scoreboard)
경로를 따라가면 30개 파일이 하나의 이야기로 연결된다.

③ "PASS"를 믿지 마라 — 이 프로젝트의 핵심 교훈.
이 저장소의 역사는 통과하는데 아무것도 검증하지 않던 테스트벤치를 고친 기록이다
(Part 4). 그러니 공부할 때도 같은 태도로: "이 체크가 실제로 발화한 적이 있나?"를
계속 물어라. 이게 이 프로젝트에서 얻어갈 가장 값비싼 습관이다.


Part 1. 학습 경로 (3개 레이어)

🟦 Layer A — 큰 그림 + 캐시 기본

순서무엇집중
1OVERVIEW.svg손으로 따라 그리기. 백지에 4블록(TB / if / DUT / 주소분할) 재현되면 통과
2rtl/l1_cache_pkg.sv60줄짜리 파일 하나에 geometry·FSM·PLRU가 다 있다. 여기부터 읽어라
3Part 2.1~2.2 + 연습문제주소 분해를 손으로 3번
4run.bat l1_cache_smoke_test 로그코드 전에 살아있는 걸 먼저. hit rate·way 분포·커버리지

🟩 Layer B — DUT 타이밍 ⭐ (질문의 절반)

순서파일집중
5rtl/sram_macro.sv (63줄)rdata <= mem[raddr]. 왜 non-blocking인가, write와의 순서
6rtl/l1_cache_core.sv:63~230파이프라인 레지스터 → 배열 주소 mux → 포워딩 → hit 판정
7rtl/l1_cache_core.sv:262~444응답 조건식, FSM 순차/조합 두 블록
8Part 2.5 사이클 표dirty miss 사이클을 직접 세어보기

🟨 Layer C — UVM 배선 + 검증 장치

순서파일집중
9tb/l1_cache_if.svclocking block 4개, SVA 15개, assert_reset()
10tb/agent/l1_cache_cpu_driver.svtry_next_item 으로 만드는 진짜 back-to-back
11tb/agent/l1_cache_cpu_monitor.sv왜 analysis port가 2개인가
12tb/env/l1_cache_scoreboard.sv ⭐⭐가장 중요. 골든 캐시 모델 = 이 프로젝트의 심장
13tb/agent/l1_cache_mem_driver.svreactive responder, disable fork 를 안 쓴 이유
14tb/agent/l1_cache_reset_agent.sv + SCB write_rst()리셋 동기화, poisoning
15tb/env/l1_cache_coverage.sv, l1_cache_fsm_cov.sv블랙박스 vs 화이트박스(bind)
16tb/tests/l1_cache_base_test.sv + l1_cache_config.svconfig 객체로 11개 시나리오 만드는 법

Part 2. 학습 내용 정리 ⭐

2.1 캐시 기본기 — 이 DUT 기준으로

항목이 프로젝트대안과 트레이드오프
구조4-way set associativedirect-mapped: 싸지만 같은 index 두 주소가 영구 thrash / full-assoc: 비교기 N개, 면적·타이밍 폭발
크기64 set × 4 way × 16 B = 4 KB
쓰기 정책write-backwrite-through: 매 store마다 메모리 트래픽. write-back은 dirty bit + eviction 로직이 필요한 대신 대역폭을 아낌
할당 정책write-allocateno-write-allocate: write miss 시 라인을 안 가져옴. 이 DUT는 store도 miss면 라인을 fetch한 뒤 쓴다
교체 정책tree-PLRU (3 bit/set)true LRU 4-way는 순서 정보에 log2(4!)≈5 bit. PLRU는 3 bit로 근사
쓰기 단위byte enable be[3:0]

3C miss 분류 — 스코어보드가 실제로 이 분류를 만들어 커버리지로 보낸다
(l1_cache_item::victim_e):

bin의미언제
VICTIM_INVALIDCompulsory(cold)그 way를 한 번도 안 썼다
VICTIM_CLEANConflict/Capacity, write-back 없음victim이 valid하지만 clean
VICTIM_DIRTYConflict/Capacity + write-backvictim이 dirty → 메모리로 4 beat 내보냄
VICTIM_NONEhit아무것도 교체 안 함

이걸 핀에서는 못 본다. 그래서 커버리지를 모니터가 아니라 스코어보드에서 샘플한다.


2.2 주소 분할 — 반드시 손으로 계산

addr[31:10] = TAG   (22b)   → 4-way 비교 대상
addr[ 9: 4] = SET   ( 6b)   → 64 set 선택
addr[ 3: 2] = WORD  ( 2b)   → 라인 안 4워드 중 선택
addr[ 1: 0] = BYTE  ( 2b)   → 미사용 (be[3:0] 가 바이트 선택)

정의는 rtl/l1_cache_pkg.sv:74~97 한 곳뿐이고 RTL과 TB가 같은 함수를 쓴다.
스코어보드가 get_set() 을 직접 호출한다(l1_cache_scoreboard.sv:123) — 주소 분할이
어긋날 수 없는 구조다.

1. 먼저 "왜" 쪼개는지 — 사물함 비유

캐시를 사물함이라고 생각하세요.

캐시 = 사물함 64칸  (칸 = set)
칸 하나 = 서랍 4개   (서랍 = way)
서랍 하나 = 물건 4개  (물건 = word)
CPU가 "0x1234 주소 줘"라고 하면 캐시는 이렇게 움직입니다:
순서하는 일필요한 정보
1몇 번 칸으로 갈지 정한다set
2그 칸의 서랍 4개를 열고 이름표가 맞는지 본다tag
3맞는 서랍에서 몇 번째 물건을 꺼낸다word

주소 하나에 이 세 정보가 다 들어있어야 하죠. 그래서 32비트를 잘라 쓰는 겁니다.

addr[31:10] = TAG   ← 이름표 (서랍이 진짜 내 것 맞나?)
addr[ 9: 4] = SET   ← 몇 번 칸?
addr[ 3: 2] = WORD  ← 서랍 안 몇 번째 물건?
addr[ 1: 0] = BYTE  ← 안 씀

2. 계산 3단계

십진수 변환 필요 없습니다. hex 마지막 한 글자가 열쇠예요.

┌─────────────────────────────────────────────┐
│ ① 마지막 hex 글자 → ÷4 하면 word 번호            │
│                                              │
│ ② 마지막 글자를 뗀 나머지 → 64로 나눈다             │
│      몫    = tag                             │
│      나머지 = set                             │
│                                             │
│ ③ 마지막 글자를 0으로 → line base 주소           │
└─────────────────────────────────────────────┘

왜 이렇게 되나 (한 번만 이해하면 됨)
hex 한 글자 = 4비트입니다. 그런데 word(2비트) + byte(2비트) = 4비트
→ 마지막 hex 글자 하나가 딱 word+byte예요. 그래서 떼어내면 됩니다.
마지막 글자를 떼면 남는 건 {tag, set}이 붙어있는 수입니다.
set이 6비트 = 64가지니까, 64로 나누면 나머지가 set, 몫이 tag입니다.

3. 0x0000_1234 직접 해보기

① word 구하기

0x1234  →  마지막 글자 = 4
           4 ÷ 4 = 1
                        → word 1

② tag / set 구하기

0x1234  →  마지막 글자 떼면  0x123

0x123 을 십진수로:  1×256 + 2×16 + 3 = 291

291 ÷ 64 = 4 … 나머지 35
           ↑         ↑
          tag       set

③ line base 주소

0x1234  →  마지막 글자를 0으로  →  0x1230

정리해서 읽으면
35번 칸으로 가서, 서랍의 이름표가 4인지 확인하고,
맞으면 그 서랍의 1번 물건을 꺼낸다.
없으면(miss) 메모리에 0x1230부터 4개를 달라고 한다.

4. 연습 2 — "같은 라인인가?"

이건 훨씬 쉬워요. hex 마지막 한 글자만 빼고 같으면 같은 라인입니다.

0x1230  →  앞부분 "123"
0x1234  →  앞부분 "123"   ← 같다! → 같은 라인 ✅
0x1238  →  앞부분 "123"   ← 같다!
0x123C  →  앞부분 "123"   ← 같다!

0x1240  →  앞부분 "124"   ← 다르다 → 다른 라인 ❌

한 라인 = 주소 16개(4워드)

0x1230  word 0  ┐
0x1234  word 1  │  이 4개가 한 덩어리 (16 B)
0x1238  word 2  │  메모리에서 한 번에 4 beat로 가져옴
0x123C  word 3  ┘

그래서 l1_cache_line_seq가 하는 일:

0x1234 읽기  →  miss!  →  0x1230,34,38,3C 4개 전부 가져옴
0x1230 읽기  →  hit 여야 함  ✅
0x1238 읽기  →  hit 여야 함  ✅
0x123C 읽기  →  hit 여야 함  ✅

hit: 찾는게 캐시에 있었다
miss: 찾는게 캐시에 없었다 -> 메모리 가서 가져옴
miss는 에러가 아니라 그냥 "아직 없음" 상태예요. 처음 접근하는 주소는 당연히 miss입니다 (아무것도 없는 빈 캐시니까). 캐시는 "두 번째부터 빨라지는" 장치입니다.

miss 났을 때 캐시가 "요청한 워드 1개"만 가져온다고 생각하고 계실 거예요.
실제로는 4개를 통째로 가져옵니다.

한 번 miss 났으면 나머지 3개는 무조건 hit여야 합니다. 여기서 miss가 나면 → burst로 가져온 4개가 서랍 안 제자리에 안 들어갔다는 뜻이에요.

핵심: 가지러 갈 때 4개를 들고 온다
사물함 비유로 다시 갈게요.

[1] CPU: "0x1234 줘"
    35번 칸 열어봄 → 이름표 4인 서랍 없음 → MISS ❌
    창고(메모리)로 감
      ↓
    ⚠️ 여기가 포인트: 물건 1개만 안 가져온다
       "0x1230부터 4개 다 줘"  ← 4-beat burst
      ↓
    beat 0 → 0x1230 의 값
    beat 1 → 0x1234 의 값
    beat 2 → 0x1238 의 값
    beat 3 → 0x123C 의 값
      ↓
    서랍에 4개 다 넣고 이름표 "4" 붙임
    서랍 상태:  [ 0x1230 | 0x1234 | 0x1238 | 0x123C ]
                  word0    word1    word2    word3
    CPU한테는 0x1234 값만 돌려줌

이제 그 다음:

[2] CPU: "0x1230 줘"  →  35번 칸, 이름표 4, word0 → 이미 있음 → HIT ✅
[3] CPU: "0x1238 줘"  →                    word2 → 이미 있음 → HIT ✅
[4] CPU: "0x123C 줘"  →                    word3 → 이미 있음 → HIT ✅
한 번 창고에 갔을 때 4개를 다 들고 왔으니까, 나머지 3개는 갈 필요가 없는 겁니다.

0x1240은?

0x1240  →  마지막 글자 떼면  0x124
           0x124 = 1×256 + 2×16 + 4 = 292
           292 ÷ 64 = 4 … 나머지 36
                              ↑
                          set 36  (0x1234는 set 35)
→ 다른 칸입니다. 서로 아무 상관 없는 라인이에요.

5. 직접 풀어보기

문제 1. 0x0000_2008 을 분해하라

정답
① 마지막 글자 8  →  8 ÷ 4 = 2        →  word 2
② 떼면 0x200 = 2×256 = 512
   512 ÷ 64 = 8 … 나머지 0           →  tag 8, set 0
③ line base = 0x2000
→ 0번 칸에서 이름표 8인 서랍의 2번 물건

문제 2. 0x0000_00FC 를 분해하라

정답

① 마지막 글자 C(=12) → 12 ÷ 4 = 3 → word 3
② 떼면 0x00F = 15
15 ÷ 64 = 0 … 나머지 15 → tag 0, set 15
③ line base = 0x00F0

문제 3. ⭐ 0x0000_1234 와 0x0000_5234 는 같은 칸(set)인가?

정답

0x1234 → 0x123 = 291 → 291 ÷ 64 = 4 … 35 → tag 4, set 35
0x5234 → 0x523 = 1315 → 1315 ÷ 64 = 20 … 35 → tag 20, set 35
→ 같은 set(35), 다른 tag!

이게 바로 conflict 상황입니다. 같은 칸에 서로 다른 두 이름표가 들어가려는 거죠.

direct-mapped(서랍 1개)라면 → 둘이 번갈아 오면 매번 서로를 쫓아냄 (영구 thrash 💀)
4-way(서랍 4개)라면 → 서랍 2개를 나눠 쓰면 됨 → 둘 다 hit ✅
"4-way set-associative를 왜 쓰나"의 답이 바로 이 문제입니다.

  1. 한 장 요약 (외울 것)
addr = 0x . . . . . . . X
                        ↑
                 마지막 hex 한 글자
                 
  X ÷ 4        = word 번호  (0,4,8,C → 0,1,2,3)
  X를 0으로     = line base 주소
  X를 뗀 값 ÷64 = 몫이 tag, 나머지가 set
  앞부분이 같으면 → 같은 라인
  set이 같고 tag가 다르면 → conflict (way를 나눠 씀)

2.3 registered SRAM + 2-stage 파이프라인 ⭐

이 설계의 모든 것이 여기서 출발한다.

// rtl/sram_macro.sv:30
always_ff @(posedge clk) begin
    rdata <= mem[raddr];      // 이 엣지의 write보다 "먼저" 샘플됨
    if (we) mem[waddr] <= wdata;
end

읽기 포트가 registered = N 사이클에 주소를 주면 N+1 사이클에 데이터가 나온다.
→ 파이프라인이 자연스럽게 2단이 된다:

단계하는 일
S1CPU 요청 접수. set index 를 4개 way의 tag/data 배열에 동시 인가
S2배열 데이터 도착 → 4-way tag 비교 → hit/way 결정 → way mux → rdata

read hit 연속 2건 타이밍

cycleS1S2출력
NA 접수, 배열 주소 인가
N+1B 접수A 판정 (hit)rsp_valid, A 데이터
N+2C 접수B 판정 (hit)rsp_valid, B 데이터

hit throughput = 1 요청/사이클. latency는 2 사이클이지만 파이프라이닝되어 있다.

왜 조합형 read면 안 되나 (⭐ 단골 질문)

assign rdata = mem[raddr] 로 하면 배열 주소 mux가 pipeline_stall 로 선택되므로:

pipeline_stall → tag_raddr → tag_rdata → cache_hit → pipeline_stall

닫힌 조합 루프가 된다. registered read면 tag_rdata 가 플롭 출력이라 루프가 끊긴다.
(README "History" 의 첫 번째 항목이 바로 이 버그다.)

배열 주소 mux (l1_cache_core.sv:136~149)

세 가지 경우를 구분한다:

if (!pipeline_stall && cpu_req_valid)  // 정상: S1 주소
else if (state == ST_WB_READ)          // victim 라인을 워드 단위로 훑는다
else                                    // stall 중 or S1 idle: S2 주소를 유지

세 번째 분기가 중요하다. 드라이버는 idle일 때 버스에 X를 park하므로
(l1_cache_cpu_driver.sv::drive_idle), 그대로 인덱싱하면 배열이 X로 접근된다.


2.4 read-during-write 포워딩 ⭐

registered read의 대가. 배열 read 포트는 같은 엣지의 write를 못 본다.

사이클 N: write(addr=A, data=D)read(addr=A) 가 동시에 걸림
→ 사이클 N+1 에 나오는 rdataD 이전의 옛날 값

그래서 코어가 배열 바깥에서 우회시킨다 (l1_cache_core.sv:175~210):

// 직전 사이클의 write 주소 vs 직전 사이클에 "실제로 발행한" read 주소
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];

꼭 짚어야 할 두 가지:

  1. 비교 대상이 s2_* 가 아니라 prev_*_raddrST_WB_READ 중에는 read 주소가
    라인을 훑고 있어서 s2_word 가 아니다. "지난 사이클에 실제로 뭘 읽으라고 했나"가 정답.
  2. data는 byte 단위 포워딩 — store가 일부 lane만 썼을 수 있다. 워드 통째로 포워딩하면
    안 건드린 lane까지 덮어쓴다. → 이걸 잡는 게 l1_cache_be_seq + l1_cache_be_test
    (idle gap 0 으로 돌려서 부분 write 바로 다음 사이클에 read가 오게 만든다).
  3. 1 사이클 깊이면 충분 — 그 다음 사이클부터는 배열 자체가 새 값을 갖고 있다.

✍️ 연습 3

0x100be=4'b00110xAAAA_BBBB 를 쓰고, 바로 다음 사이클 같은 주소를 읽는다.
원래 값이 0x1111_2222 였다면 정답은? 포워딩이 워드 단위였다면 뭐가 나오나?

정답 정답 `0x1111_BBBB` (하위 2바이트만 교체). 워드 단위 포워딩이면 `0xAAAA_BBBB` 가 나와서 상위 2바이트가 오염된다. byte enable을 무시하는 데이터 배열도 같은 증상이라, `be_seq` 는 두 버그를 동시에 잡는다.

2.5 miss FSM — 사이클을 세어라

ST_IDLE ──miss, clean victim───────────────────────────▶ ST_FILL_REQ
   │                                                          │
   └─miss, dirty victim─▶ ST_WB_READ ─▶ ST_WB_SEND ───────────┘
                          (버퍼로 복사)   (메모리로 전송)        │
                                                               ▼
   ST_IDLE ◀──────────────────────── ST_COMPLETE ◀────── ST_FILL_RCV

각 상태는 정확히 하나의 일만 한다. 상태별 사이클 수:

상태사이클근거
ST_IDLE1miss 감지 + victim latch (victim_way, victim_tag)
ST_WB_READ5wb_cnt 0→4. 주소는 N에, 데이터는 N+1에 오므로 캡처가 한 박자 늦다
ST_WB_SEND1 + 4req 수락 1 + 4 beat (backpressure 있으면 더)
ST_FILL_REQ≥1mem_req_ready 대기
ST_FILL_RCVlatency + 4responder가 랜덤 latency 후 4 beat
ST_COMPLETE1tag/valid/dirty/PLRU 커밋 + CPU 응답

ST_WB_READ가 왜 4가 아니라 5인가l1_cache_core.sv:307~312:

사이클wb_cnt인가하는 주소캡처하는 데이터
10word 0
21word 1wb_buf[0]
32word 2wb_buf[1]
43word 3wb_buf[2]
54— (전이)wb_buf[3]

registered read의 한 박자 지연이 정확히 한 사이클의 오버헤드로 드러난 자리다.

✍️ 연습 4 — dirty miss 총 사이클

memory latency = 3, backpressure 없음일 때 miss 감지부터 CPU 응답까지 몇 사이클?

정답 1(IDLE) + 5(WB_READ) + 5(WB_SEND) + 1(FILL_REQ) + 7(FILL_RCV: 3+4) + 1(COMPLETE) = **20 사이클** clean victim이면 WB 두 상태(10사이클)가 빠져서 **10 사이클**. → "dirty eviction이 miss 비용을 2배로 만든다"를 숫자로 말할 수 있게 된다. 그래서 "Not done yet"에 **write buffer** 가 들어가 있는 것 (evict를 fill 앞에 직렬화하지 않기).

ST_COMPLETE 는 왜 있나 (⭐ 단골 질문)

miss 당 1 사이클 손해지만, 마지막 fill beat 의 배열 write대기 중이던 store의
배열 write
가 같은 사이클에 충돌하는 구조적 해저드를 없앤다. 그리고 tag/valid/dirty/PLRU
커밋 지점이 한 곳으로 모인다.

여기서 stall을 푸는 이유(l1_cache_core.sv:432~433)도 중요하다.
ST_COMPLETE 에서 pipeline_stall=0 이라야 이 사이클 끝에 파이프라인이 전진한다.
안 그러면 다음 사이클 ST_IDLE 에서 S2가 같은 요청을 그대로 들고 있어서 두 번 응답한다.

응답 조건식 (l1_cache_core.sv: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];

읽어낼 것 3개:

  • write는 응답이 없다. read만 rsp_valid 를 받는다 → 모니터도 read만 큐에 넣는다.
  • miss 응답은 배열이 아니라 fill_data 에서 나온다 (fill 중에 요청 워드를 따로 잡아둠,
    l1_cache_core.sv:325).
  • ST_COMPLETE 에는 cache_hit 조건이 없다 — 방금 채운 라인이니 당연히 맞다.

2.6 tree-PLRU

3 bit로 4-way를 근사한다 (l1_cache_pkg.sv:99~121).

bit[0] : root       0 → {way0,way1} 쪽이 오래됨,  1 → {way2,way3} 쪽
bit[1] : 왼쪽 leaf  (way0 vs way1)
bit[2] : 오른쪽 leaf (way2 vs way3)
victim = plru[0] ? (plru[2] ? 3 : 2)
                 : (plru[1] ? 1 : 0);

// 방금 쓴 way의 "반대쪽"을 가리키게 갱신
nxt[0] = ~way[1];
if (way[1] == 0) nxt[1] = ~way[0];
else             nxt[2] = ~way[0];

victim 선택 순서: invalid way가 있으면 그쪽 먼저(l1_cache_core.sv:245~255),
전부 valid일 때만 PLRU 트리를 따른다. 스코어보드도 똑같은 순서로 예측한다
(l1_cache_scoreboard.sv:150~153) — 안 맞으면 매 miss마다 way가 어긋난다.

✍️ 연습 5 — set이 꽉 찬 상태에서 4번 연속 miss. victim 순서는?

정답
plruvictim갱신 후
3'b000way 03'b011
3'b011way 23'b110
3'b110way 13'b101
3'b101way 33'b000

0, 2, 1, 3 순환. 4개 way를 골고루 쓴다.
l1_cache_eviction_seq 는 한 set에 6개 tag(> 4 way)를 돌려서 이 순환을 계속 강제하고,
커버리지 cp_way / cp_victim_way 가 4개 way를 다 봤는지 확인한다.
PLRU가 고장나서 항상 같은 way만 고르면 여기서 커버리지가 25%에서 멈춘다.


2.7 요청 하나의 일생

seq          item.randomize()  →  start_item / finish_item
  ↓ (TLM)
sequencer    ← driver.seq_item_port.get_next_item()
  ↓
driver       cb_cpu 로 valid/rw/addr/be/wdata 구동 → ready 대기 → item_done()
  ↓ (핀)
DUT          S1 (배열 주소 인가) → S2 (hit 판정) → hit면 즉시 응답 / miss면 FSM
  ↓ (핀)
monitor      cb_cpu_mon 으로 preponed 샘플
             ├─ req_ap : valid && ready 인 순간 = 수락된 요청 (read+write, 발행 순서)
             └─ rsp_ap : rsp_valid 인 순간 = read 응답 + 실제 rdata
  ↓ (analysis port, 1→N)
scoreboard   write_req(): 골든 모델 갱신 + 기대값 스냅샷 + 기대 버스트 push
             write_rsp(): 스냅샷과 실제 rdata 비교
             write_mem(): 관측된 버스트를 기대 큐와 순서대로 대조
  ↓ cov_ap
coverage     hit/way/victim 이 채워진 item 을 샘플

드라이버의 진짜 back-to-back (l1_cache_cpu_driver.sv):
gap이 0이면 item_done() 직후 버스를 놓지 않고 try_next_item() 으로 다음 아이템을
받아 그 자리에서 payload만 교체한다. 그래서 cpu_req_valid 가 accept 사이에 안 떨어진다.
매 아이템마다 VALID를 내리면 "back-to-back 테스트"가 이름만 back-to-back이 된다
(README History 에 실제로 있던 버그).


2.8 스코어보드 = 골든 캐시 모델 ⭐⭐

이 프로젝트의 심장. 메모리만 모델링하는 흔한 스코어보드와 다르다.

// l1_cache_scoreboard.sv:55~62 — DUT 내부를 통째로 미러링
tag_t      m_tag   [SETS][WAYS];
bit        m_valid [SETS][WAYS];
bit        m_dirty [SETS][WAYS];
bit [31:0] m_data  [SETS][WAYS][WORDS_PER_LINE];
plru_t     m_plru  [SETS];
bit [31:0] main_mem [int];    // 예측된 DRAM

write_req() 하나가 요청 1건에 대해 전부 예측한다:

예측 항목코드
hit/miss + 어느 way:137~140
victim 종류 (cold/clean/dirty):155~157
dirty면 write-back 주소 + 4 beat:160~171
뒤따르는 fill burst + 그 데이터:174~181
read가 돌려줘야 할 값 (byte 정확):200~203
PLRU 갱신:207

그리고 write_mem() 이 관측된 버스트를 exp_mem_q순서대로 대조한다.
→ 없어야 할 버스트, 있어야 하는데 없는 버스트, 주소·방향·길이·데이터 오류가 전부 잡힌다.

메모리 모델이 두 벌인 이유: mem_driver.main_mem(실제 응답값)과
scoreboard.main_mem(예측값)이 따로 있다. 둘이 같은 소스를 공유하면 서로를 검증하지 못한다.
"아직 아무도 안 쓴 주소"의 기본값만 tb_classes.svhL1_MEM_DEFAULT(a) 매크로로 공유하는데,
주소 의존적인 값(32'hDEAD_0000 | addr[15:0])인 게 포인트다. 상수였다면 fill burst의
4 beat가 전부 똑같아서 워드 선택 버그가 안 보인다.

check_phase (:308~317) — 조용한 PASS 방지장치:
트래픽 0건 / 응답 못 받은 read / 예측했는데 안 일어난 버스트 / mismatch 누적 → 전부 실패.


2.9 순서 문제: analysis port가 왜 2개인가 ⭐

증상이었던 것: DUT가 파이프라인이라 read miss가 retire 되는 바로 그 사이클에
다음 요청이 수락
될 수 있다. 모니터를 "write는 accept 때, read는 응답 때" 한 포트로
보내면, 이 겹침에서 write가 스코어보드에 먼저 도착해 이미 전진해버린 모델
read를 비교한다 → 매 런마다 가짜 실패 2건.

해결:

포트언제무엇
req_apvalid && ready 인 사이클모든 수락된 요청 (read + write), 발행 순서 그대로
rsp_aprsp_valid 인 사이클read 응답 + DUT가 실제 낸 rdata

read의 기대값은 발행 시점에 스냅샷(rd_q 에 push)해두고, 응답이 오면 그 스냅샷과 비교한다.
모든 요청이 발행 순서로 req_ap 를 지나가므로, 시뮬레이터가 같은 타임스텝 안에서
두 모니터 스레드를 어떤 순서로 스케줄하든 프로그램 순서가 보존된다.

이 구조는 in-order 응답을 전제로 한다. 그래서 README "Not done yet" 의 MSHR
(hit-under-miss)이 "RTL만이 아니라 TB 재설계"라고 적혀 있는 것 — 응답이 순서를
벗어나면 큐 매칭 자체가 성립하지 않고 response ID가 필요해진다.
"왜 MSHR 안 했나"에 이렇게 답하면 만점이다.


2.10 메모리 responder — reactive agent

l1_cache_mem_driver 는 stimulus를 만드는 게 아니라 DUT의 요청에 반응한다.
시퀀스(l1_cache_mem_rsp_seq)는 데이터가 아니라 타이밍을 공급한다:

노브의미
latency요청 수락 → 첫 fill beat 까지
ready_stallrequest 채널 backpressure (몇 % 의 버스트에, 최대 몇 사이클)
beat_gapbeat 사이 idle 삽입

l1_cache_stress_test 는 latency 10~50, 60% 의 버스트에 최대 6사이클 backpressure,
양쪽 채널 모두에 gap — 이 조건에서 통과해야 handshake를 진짜로 지킨 것이다.

disable fork 를 안 쓴 이유 ⭐

리셋으로 전송을 끊어야 하는데, get_next_item()item_done() 사이에서
disable fork 를 쓰면 시퀀서가 내부적으로 spawn한 프로세스까지 죽는다.
→ 나중에 "Get_next_item called twice" / "item_done() with no outstanding requests" 로 터진다.

대신 모든 대기 지점을 tick() 한 곳으로 모으고 aborted 플래그를 검사한다:

virtual task tick();
    @(vif.cb_mem);
    if (vif.rst_n !== 1'b1) aborted = 1'b1;
endtask

이렇게 하면 리셋이 어느 지점에서 끊든 get/item_done 쌍은 항상 균형을 유지한다.


2.11 리셋 검증과 poisoning

리셋 에이전트가 rst_n소유한다 (tb_top의 initial 블록이 아니라).
그래야 테스트가 트래픽 중간에 리셋을 다시 걸 수 있다.

동기화 포인트: 리셋 드라이버는 vif.assert_reset() 을 부르기 직전
analysis port로 스코어보드에 알린다 (l1_cache_reset_agent.sv). 스코어보드가 모델을
DUT와 같은 순간에 flush 하도록.

플레이버동작검사
l1_cache_reset_test (quiesced)메모리 버스가 idle 될 때까지 기다렸다 인가전부 살아있음
l1_cache_reset_async_test버스트 중간이든 상관없이 인가write-back이 반쪽만 나간 그 라인만 데이터 검사 제외

poisoning (l1_cache_scoreboard.sv:101~102): 리셋 시점에 exp_mem_q 에 남아있던
write-back 예측의 주소를 poisoned[] 에 표시한다. 그 라인의 DRAM 내용은 모델이 재구성할
수 없기 때문. 데이터 비교만 건너뛰고 프로토콜·순서·burst 길이 검사는 전부 유지된다.
리셋당 최대 1라인, 개수는 report에 찍힌다.

"검사를 껐다"가 아니라 "모델이 원리적으로 알 수 없는 부분만, 범위를 한정해서,
개수를 보고하면서 껐다"
— 이 구분을 설명할 수 있으면 검증 엔지니어답게 들린다.


2.12 커버리지 2종

cache_opscache_fsm
어디tb/env/l1_cache_coverage.sv (subscriber)tb/env/l1_cache_fsm_cov.sv (bind into core)
소스스코어보드 의 annotated item코어 내부 신호
무엇rw · hit · evict · victim종류 · way · word · set · be + cross 6종state · 합법 전이 7종 · illegal_bins 12종 · stall 길이 · backpressure · fill beat · victim/hit way

왜 스코어보드에서 샘플하나 — hit/miss, 어느 way, victim이 dirty였는지는 핀에 없다.
골든 모델 안에만 존재한다. 모니터에서 샘플하려면 커버리지 코드가 캐시를 또 모델링해야 한다.

bind 인가 — FSM state, victim_way, pipeline_stall 도 핀에 없다.
bind 는 RTL을 손대지 않고 검증 모듈을 계층 안으로 주입한다 (tb_top.sv:87).
합성 대상 코드에 검증용 코드가 섞이지 않는다.

illegal_bins vs ignore_bins:

  • illegal_bins = "일어나면 에러" — FSM의 불법 전이 12종
  • ignore_bins = "도달 불가하니 분모에서 빼자" — 예: x_rw_evict 에서 no_evict

커버리지 100%의 의미: "그 시나리오를 자극했다"이지 "맞다"가 아니다.
정답 판정은 스코어보드와 SVA가 한다. 그리고 커버리지 모델 자체가 빠뜨린 구멍은
커버리지로 못 찾는다.

per-test 수치가 낮은 건 정상 — 각 테스트가 좁은 시나리오를 노린다.
regress.bat 가 11테스트 × 4시드를 돌려 xcrg머지한 값이 100%다.


2.13 SVA — 스코어보드와 역할이 다르다

tb/l1_cache_if.sv:83~241 에 어서션 15개 (파일에 1~13번으로 번호가 붙은 것 + burst 회계 2개).

그룹내용
CPU리셋 중 VALID 금지 / 수락 전 VALID hold / payload stable / X·Z 금지 / write는 be≠0 / 응답 X·Z 금지
MEM req수락 전 hold + payload stable / X·Z 금지 / 라인 정렬
MEM datawrite·read 데이터 hold, X·Z 금지
burst 회계카운터를 돌려서 모든 버스트가 정확히 len+1 beat 를 내고 마지막에 LAST

역할 분담:

  • SVA = 프로토콜. "언제/어떻게" 신호가 움직이는가. 매 사이클, 국소적.
  • 스코어보드 = 데이터·의미. "무엇이" 맞는가. 트랜잭션 단위, 전역 상태 필요.

cover property 4개도 있지만 xsim은 무시한다(XSIM 43-4127). 문법은 유효하고
Questa에서는 수집된다.


Part 3. 로그로 확인하는 체크리스트

cd sim\vivado
run.bat l1_cache_eviction_test 42

run.bat 이 마지막에 뽑아주는 줄들을 하나씩 해석할 수 있어야 한다:

확인할 것어디서무엇을 봐야 하나
UVM_ERROR : 0 / UVM_FATAL : 0로그 끝0이 아니면 무조건 실패
SCOREBOARD SUMMARY: MATCH=… MISMATCH=0SCB reportMATCH가 충분히 큰가 (0에 가까우면 검사가 안 돈 것)
Cache hits / misses … (hit rate xx%)SCB report시퀀스 의도와 맞나? thrash는 hit rate가 바닥, mix는 높아야 함
Dirty evictions : NSCB reporteviction 테스트인데 0이면 테스트가 죽은 것 (실제로 있던 버그!)
Way utilisation : w0=… w1=… w2=… w3=…SCB report네 개가 비슷해야 PLRU가 살아있다. 한쪽만 크면 교체정책 고장
Set reach : xx% (n/64)SCB report주소 분포
cache_ops covergroup : xx%COV report시나리오 도달률
[FSM COV] cache_fsm covergroup: xx%final 블록상태·전이 도달률
Resets / poisoned : …reset 테스트에서만poisoned 라인 수가 리셋 수 이하인가

직접 부숴보기 (가장 빠른 학습법):
l1_cache_core.svplru_update 호출을 지워보면 → Way utilisation 이 한쪽으로 쏠리고
cp_way 커버리지가 25% 근처에서 멈춘다. 어느 체크가 먼저 우는지 관찰하면
각 장치의 담당 범위가 몸으로 이해된다. (실험 후 반드시 되돌릴 것.)


Part 4. 반드시 외울 버그 스토리 3개 ⭐

면접관은 문법보다 검증적 사고를 본다. 각각 30초 안에 설명할 수 있어야 한다.

1. 조합형 SRAM read 모델이 파이프라인과 모순이었다 — 설계 감각

배열을 assign rdata = mem[raddr] 로 모델링해놨는데, 파이프라인은 S1에서 주소를 주고
S2에서 쓰는
구조였다. allocate→hit 전환 시점에 코어가 다음 요청의 index를 읽어
엉뚱한 라인 데이터를 돌려줬다 → read mismatch 675건.
게다가 stall → raddr → rdata → hit → stall닫힌 조합 루프였다.
읽기 포트를 registered로 바꿔 둘 다 해결하고, 그 대가로 생긴 read-during-write 해저드는
byte 단위 포워딩 경로를 추가해 막았다.

교훈: 메모리 모델은 "동작하는 코드"가 아니라 타이밍 계약이다. 계약이 틀리면 RTL 전체가 틀린다.

2. SVA 실패가 회귀에 안 잡히고 있었다 — 검증 환경도 검증 대상

어서션이 $error 를 쓰고 있었는데, 이건 UVM_ERROR 를 증가시키지 않는다.
어서션이 줄줄이 깨지는 런도 UVM_ERROR : 0 으로 PASS 로 보고됐다.
uvm_report_error 로 바꾸고, 일부러 깨지는 어서션을 넣어 685개 에러가 찍히고
런이 FAIL로 분류되는 걸 확인해서 증명했다.

교훈: 어서션이 "통과"하는 것과 어서션이 "존재"하는 것은 다르다. 체커는 발화시켜서 증명해야 한다.

3. 통과하는 테스트가 아무것도 검증하지 않고 있었다 — PASS는 증거가 아니다

두 가지가 겹쳐 있었다.
(a) for 루프 본문의 선언 초기화(bit [31:0] base = f(i);)를 xsim이 한 번만 평가해서,
24개의 서로 다른 캐시 라인이 조용히 1개로 붕괴했다 — passive 테스트가
191 hit / 1 miss 를 찍고 "PASS" 였다.
(b) 주소 헬퍼가 get_index(a) 처럼 한 글자 인자명을 써서, 호출부에 같은 이름의 지역변수가
있으면 xsim이 실인자를 callee 스코프에서 평가해 조용히 0을 넘겼다 —
eviction_test 가 eviction을 0건 만들면서 PASS 였다.
둘 다 로그의 Dirty evictions / Way utilisation 통계를 보고서야 발견했다.

교훈: 테스트가 통과했다는 건 자극이 도달했다는 뜻이 아니다. 커버리지와 통계로 "자극이 실제로
갔는가"를 따로 확인해야 한다.

보너스disable fork 가 시퀀서 핸드셰이크를 깨뜨린 건 (Part 2.10) UVM 내부 동작을
아는지 보여주는 소재. 여유 있으면 네 번째로 준비.


Part 5. 능동 학습 연습

  1. 백지 구조도OVERVIEW.svg 를 보지 않고 4블록 + 화살표 그리기
  2. 주소 분해 3회 — 연습 1·2를 다른 주소로 (0xDEAD_BEEF 추천)
  3. PLRU 손계산 — 연습 5의 표를 백지에서 재현
  4. 사이클 세기 — dirty miss / clean miss / read hit 각각 (연습 4)
  5. 코드 덮고 재현 — 포워딩 로직(l1_cache_core.sv:196~210) 을 백지에 써보기.
    막히는 곳이 정확히 약점이다
  6. 일부러 부수기 — Part 3의 실험. plru_update 제거 / byte enable 무시 / 포워딩 제거,
    각각 어느 체크가 먼저 우는지 기록
  7. 버그 3개 소리 내어 설명 — 각각 30초, 타이머 켜고
  8. README "History" 표 읽기 — 코드만 봐선 안 보이는 의사결정 기록이다.
    "왜 이렇게 했나" 질문의 답이 여기 다 있다

Part 6. 추천 일정 (하루 1~2시간)

일차목표
1일Layer A. l1_cache_pkg.sv 정독 + Part 2.1~2.2 + 연습 1·2. smoke 테스트 로그 해석
2일Part 2.3~2.4 (registered SRAM, 포워딩) + 연습 3. 여기가 제일 어렵다, 시간 더 써라
3일Part 2.5~2.6 (FSM, PLRU) + 연습 4·5. 사이클 표 백지 재현
4일Part 2.7~2.9 (요청의 일생, 스코어보드, 순서 문제). 스코어보드 write_req() 정독
5일Part 2.10~2.13 (mem responder, reset, coverage, SVA)
6일Part 3 체크리스트로 테스트 3개 돌려 로그 해석 + Part 5의 "일부러 부수기"
7일버그 3개 스토리 + Part 7 질문 소리 내어 답하기

Part 7. 예상 질문

캐시 기본

  • direct-mapped / set-assoc / full-assoc 차이와 트레이드오프는?
  • write-back vs write-through, write-allocate vs no-write-allocate?
  • 3C miss는? 이 프로젝트에서 각각 어떻게 구분해서 커버리지로 잡았나?
  • 라인 크기를 32B로 키우면 뭐가 좋아지고 뭐가 나빠지나?
  • true LRU 대신 tree-PLRU를 쓴 이유는? 4-way에서 몇 bit 차이인가?

이 설계 고유 ⭐

  • SRAM read를 왜 registered로 만들었나? 조합형이면 뭐가 터지나?
  • read-during-write 해저드를 어떻게 처리했나? 왜 byte 단위인가?
  • 포워딩 비교 대상이 왜 s2_* 가 아니라 prev_*_raddr 인가?
  • ST_COMPLETE 는 왜 있나? 한 사이클 손해 아닌가? 여기서 stall을 푸는 이유는?
  • ST_WB_READ 가 왜 4가 아니라 5 사이클인가?
  • valid/dirty/PLRU를 SRAM이 아니라 플롭에 둔 이유는?
  • hit throughput은? latency는? 둘의 차이는?
  • back-to-back에서 VALID를 안 내린다는 게 무슨 뜻인가? 어떻게 구현했나?

검증 방법론 ⭐

  • 스코어보드가 정답을 어떻게 아는가? 왜 메모리 모델이 아니라 캐시 모델인가?
  • 파이프라인 DUT에서 응답 순서 문제를 어떻게 풀었나? 왜 analysis port가 2개인가?
  • 커버리지를 왜 모니터가 아니라 스코어보드에서 샘플하나?
  • bind 는 뭐고 왜 썼나?
  • illegal_binsignore_bins 의 차이는? 후자를 남용하면?
  • 커버리지 100%면 검증이 끝난 건가?
  • 시드를 여러 개 도는 이유는? 커버리지는 어떻게 합치나?
  • 리셋 검증은 어떻게 했나? poisoning은 뭐고 왜 그 범위인가?
  • passive agent는 왜 만들었고 어떻게 검증했나?
  • RAL은 왜 안 썼나? (함정 — DUT에 memory-mapped register가 없다. CSR 블록을 먼저
    설계해야 RAL이 의미를 갖는다. 기법 과시용으로 레지스터를 만들어내지는 않았다)
  • SVA와 스코어보드의 역할 차이는?

SystemVerilog / UVM

  • clocking block을 왜 쓰나? @(posedge clk); #1ps; 와 뭐가 다른가?
  • uvm_analysis_imp_decl 은 왜 필요한가?
  • soft constraint 는 언제 쓰나?
  • objection과 drain time은?
  • disable fork 가 왜 위험한가? 이 프로젝트에서 뭘 깨뜨렸나?
  • procedural 블록 안의 선언 초기화가 왜 함정인가? (static — 시간 0에 한 번만)

약점 방어

  • non-blocking miss(MSHR)는 왜 없나? (TB 재설계가 필요 — Part 2.9 참고. 반쯤
    하다 마느니 미룬 것)
  • critical-word-first는? write buffer는? 파라미터화는?
  • code coverage / X-propagation / gate-level / formal 은?

가장 중요한 조언

이 프로젝트의 진짜 가치는 "동작하는 캐시 컨트롤러"가 아니라
"통과하는데 아무것도 검증하지 않던 테스트벤치"를 찾아내고 고친 과정이다.

코드 100줄 외우는 것보다 Part 4의 세 문장을 자기 말로 설명하는 게 훨씬 강력하고,
그 앞에 Part 2.3~2.5의 사이클 계산을 손으로 할 수 있으면 "AI가 만들어준 프로젝트"와
"내가 이해한 프로젝트"의 차이가 명확해진다.

profile
Design Verification engineer

0개의 댓글