
이 문서 하나로 공부할 수 있게 방법 + 내용을 모두 담았다.
① 캐시는 "구조"보다 "타이밍"이다.
4-way, PLRU, write-back 같은 용어는 하루면 외운다. 이 프로젝트에서 진짜 배울 건
어느 사이클에 무엇이 유효한가 다. 배열 read가 registered라는 사실 하나가
파이프라인 구조·포워딩·FSM을 전부 결정한다. 사이클을 못 세면 아무것도 이해 못 한 것이다.
② 파일 순서가 아니라 "요청 하나의 일생"을 따라가라.
ls 순서로 읽으면 죽는다. CPU 요청 하나가
태어나서(seq) → 핀을 흔들고(driver) → S1/S2를 통과하고(DUT) → 관측되고(monitor) → 검사받는(scoreboard)
경로를 따라가면 30개 파일이 하나의 이야기로 연결된다.
③ "PASS"를 믿지 마라 — 이 프로젝트의 핵심 교훈.
이 저장소의 역사는 통과하는데 아무것도 검증하지 않던 테스트벤치를 고친 기록이다
(Part 4). 그러니 공부할 때도 같은 태도로: "이 체크가 실제로 발화한 적이 있나?"를
계속 물어라. 이게 이 프로젝트에서 얻어갈 가장 값비싼 습관이다.
| 순서 | 무엇 | 집중 |
|---|---|---|
| 1 | OVERVIEW.svg | 손으로 따라 그리기. 백지에 4블록(TB / if / DUT / 주소분할) 재현되면 통과 |
| 2 | rtl/l1_cache_pkg.sv | 60줄짜리 파일 하나에 geometry·FSM·PLRU가 다 있다. 여기부터 읽어라 |
| 3 | Part 2.1~2.2 + 연습문제 | 주소 분해를 손으로 3번 |
| 4 | run.bat l1_cache_smoke_test 로그 | 코드 전에 살아있는 걸 먼저. hit rate·way 분포·커버리지 |
| 순서 | 파일 | 집중 |
|---|---|---|
| 5 | rtl/sram_macro.sv (63줄) | rdata <= mem[raddr]. 왜 non-blocking인가, write와의 순서 |
| 6 | rtl/l1_cache_core.sv:63~230 | 파이프라인 레지스터 → 배열 주소 mux → 포워딩 → hit 판정 |
| 7 | rtl/l1_cache_core.sv:262~444 | 응답 조건식, FSM 순차/조합 두 블록 |
| 8 | Part 2.5 사이클 표 | dirty miss 사이클을 직접 세어보기 |
| 순서 | 파일 | 집중 |
|---|---|---|
| 9 | tb/l1_cache_if.sv | clocking block 4개, SVA 15개, assert_reset() |
| 10 | tb/agent/l1_cache_cpu_driver.sv | try_next_item 으로 만드는 진짜 back-to-back |
| 11 | tb/agent/l1_cache_cpu_monitor.sv ⭐ | 왜 analysis port가 2개인가 |
| 12 | tb/env/l1_cache_scoreboard.sv ⭐⭐ | 가장 중요. 골든 캐시 모델 = 이 프로젝트의 심장 |
| 13 | tb/agent/l1_cache_mem_driver.sv | reactive responder, disable fork 를 안 쓴 이유 |
| 14 | tb/agent/l1_cache_reset_agent.sv + SCB write_rst() | 리셋 동기화, poisoning |
| 15 | tb/env/l1_cache_coverage.sv, l1_cache_fsm_cov.sv | 블랙박스 vs 화이트박스(bind) |
| 16 | tb/tests/l1_cache_base_test.sv + l1_cache_config.sv | config 객체로 11개 시나리오 만드는 법 |
| 항목 | 이 프로젝트 | 대안과 트레이드오프 |
|---|---|---|
| 구조 | 4-way set associative | direct-mapped: 싸지만 같은 index 두 주소가 영구 thrash / full-assoc: 비교기 N개, 면적·타이밍 폭발 |
| 크기 | 64 set × 4 way × 16 B = 4 KB | |
| 쓰기 정책 | write-back | write-through: 매 store마다 메모리 트래픽. write-back은 dirty bit + eviction 로직이 필요한 대신 대역폭을 아낌 |
| 할당 정책 | write-allocate | no-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_INVALID | Compulsory(cold) | 그 way를 한 번도 안 썼다 |
VICTIM_CLEAN | Conflict/Capacity, write-back 없음 | victim이 valid하지만 clean |
VICTIM_DIRTY | Conflict/Capacity + write-back | victim이 dirty → 메모리로 4 beat 내보냄 |
VICTIM_NONE | hit | 아무것도 교체 안 함 |
이걸 핀에서는 못 본다. 그래서 커버리지를 모니터가 아니라 스코어보드에서 샘플한다.
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) — 주소 분할이
어긋날 수 없는 구조다.
캐시를 사물함이라고 생각하세요.
캐시 = 사물함 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 ← 안 씀
십진수 변환 필요 없습니다. 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입니다.
① 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개를 달라고 한다.
이건 훨씬 쉬워요. 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)
→ 다른 칸입니다. 서로 아무 상관 없는 라인이에요.
문제 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를 왜 쓰나"의 답이 바로 이 문제입니다.
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를 나눠 씀)
이 설계의 모든 것이 여기서 출발한다.
// 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단이 된다:
| 단계 | 하는 일 |
|---|---|
| S1 | CPU 요청 접수. set index 를 4개 way의 tag/data 배열에 동시 인가 |
| S2 | 배열 데이터 도착 → 4-way tag 비교 → hit/way 결정 → way mux → rdata |
read hit 연속 2건 타이밍
| cycle | S1 | S2 | 출력 |
|---|---|---|---|
| N | A 접수, 배열 주소 인가 | — | — |
| N+1 | B 접수 | A 판정 (hit) | rsp_valid, A 데이터 |
| N+2 | C 접수 | B 판정 (hit) | rsp_valid, B 데이터 |
→ hit throughput = 1 요청/사이클. latency는 2 사이클이지만 파이프라이닝되어 있다.
assign rdata = mem[raddr] 로 하면 배열 주소 mux가 pipeline_stall 로 선택되므로:
pipeline_stall → tag_raddr → tag_rdata → cache_hit → pipeline_stall
닫힌 조합 루프가 된다. registered read면 tag_rdata 가 플롭 출력이라 루프가 끊긴다.
(README "History" 의 첫 번째 항목이 바로 이 버그다.)
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로 접근된다.
registered read의 대가. 배열 read 포트는 같은 엣지의 write를 못 본다.
사이클 N:
write(addr=A, data=D)와read(addr=A)가 동시에 걸림
→ 사이클 N+1 에 나오는rdata는 D 이전의 옛날 값
그래서 코어가 배열 바깥에서 우회시킨다 (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];
꼭 짚어야 할 두 가지:
s2_* 가 아니라 prev_*_raddr — ST_WB_READ 중에는 read 주소가s2_word 가 아니다. "지난 사이클에 실제로 뭘 읽으라고 했나"가 정답.l1_cache_be_seq + l1_cache_be_test0x100 에 be=4'b0011 로 0xAAAA_BBBB 를 쓰고, 바로 다음 사이클 같은 주소를 읽는다.
원래 값이 0x1111_2222 였다면 정답은? 포워딩이 워드 단위였다면 뭐가 나오나?
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_IDLE | 1 | miss 감지 + victim latch (victim_way, victim_tag) |
ST_WB_READ | 5 | wb_cnt 0→4. 주소는 N에, 데이터는 N+1에 오므로 캡처가 한 박자 늦다 |
ST_WB_SEND | 1 + 4 | req 수락 1 + 4 beat (backpressure 있으면 더) |
ST_FILL_REQ | ≥1 | mem_req_ready 대기 |
ST_FILL_RCV | latency + 4 | responder가 랜덤 latency 후 4 beat |
ST_COMPLETE | 1 | tag/valid/dirty/PLRU 커밋 + CPU 응답 |
ST_WB_READ가 왜 4가 아니라 5인가 — l1_cache_core.sv:307~312:
| 사이클 | wb_cnt | 인가하는 주소 | 캡처하는 데이터 |
|---|---|---|---|
| 1 | 0 | word 0 | — |
| 2 | 1 | word 1 | wb_buf[0] |
| 3 | 2 | word 2 | wb_buf[1] |
| 4 | 3 | word 3 | wb_buf[2] |
| 5 | 4 | — (전이) | wb_buf[3] |
registered read의 한 박자 지연이 정확히 한 사이클의 오버헤드로 드러난 자리다.
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개:
rsp_valid 를 받는다 → 모니터도 read만 큐에 넣는다.fill_data 에서 나온다 (fill 중에 요청 워드를 따로 잡아둠,l1_cache_core.sv:325).ST_COMPLETE 에는 cache_hit 조건이 없다 — 방금 채운 라인이니 당연히 맞다.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가 어긋난다.
| plru | victim | 갱신 후 |
|---|---|---|
3'b000 | way 0 | 3'b011 |
3'b011 | way 2 | 3'b110 |
3'b110 | way 1 | 3'b101 |
3'b101 | way 3 | 3'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%에서 멈춘다.
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 에 실제로 있던 버그).
이 프로젝트의 심장. 메모리만 모델링하는 흔한 스코어보드와 다르다.
// 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.svh 의 L1_MEM_DEFAULT(a) 매크로로 공유하는데,
주소 의존적인 값(32'hDEAD_0000 | addr[15:0])인 게 포인트다. 상수였다면 fill burst의
4 beat가 전부 똑같아서 워드 선택 버그가 안 보인다.
check_phase (:308~317) — 조용한 PASS 방지장치:
트래픽 0건 / 응답 못 받은 read / 예측했는데 안 일어난 버스트 / mismatch 누적 → 전부 실패.
증상이었던 것: DUT가 파이프라인이라 read miss가 retire 되는 바로 그 사이클에
다음 요청이 수락될 수 있다. 모니터를 "write는 accept 때, read는 응답 때" 한 포트로
보내면, 이 겹침에서 write가 스코어보드에 먼저 도착해 이미 전진해버린 모델과
read를 비교한다 → 매 런마다 가짜 실패 2건.
해결:
| 포트 | 언제 | 무엇 |
|---|---|---|
req_ap | valid && ready 인 사이클 | 모든 수락된 요청 (read + write), 발행 순서 그대로 |
rsp_ap | rsp_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 안 했나"에 이렇게 답하면 만점이다.
l1_cache_mem_driver 는 stimulus를 만드는 게 아니라 DUT의 요청에 반응한다.
시퀀스(l1_cache_mem_rsp_seq)는 데이터가 아니라 타이밍을 공급한다:
| 노브 | 의미 |
|---|---|
latency | 요청 수락 → 첫 fill beat 까지 |
ready_stall | request 채널 backpressure (몇 % 의 버스트에, 최대 몇 사이클) |
beat_gap | beat 사이 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 쌍은 항상 균형을 유지한다.
리셋 에이전트가 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에 찍힌다.
"검사를 껐다"가 아니라 "모델이 원리적으로 알 수 없는 부분만, 범위를 한정해서,
개수를 보고하면서 껐다" — 이 구분을 설명할 수 있으면 검증 엔지니어답게 들린다.
cache_ops | cache_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%다.
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 data | write·read 데이터 hold, X·Z 금지 |
| burst 회계 | 카운터를 돌려서 모든 버스트가 정확히 len+1 beat 를 내고 마지막에 LAST |
역할 분담:
cover property 4개도 있지만 xsim은 무시한다(XSIM 43-4127). 문법은 유효하고
Questa에서는 수집된다.
cd sim\vivado
run.bat l1_cache_eviction_test 42
run.bat 이 마지막에 뽑아주는 줄들을 하나씩 해석할 수 있어야 한다:
| 확인할 것 | 어디서 | 무엇을 봐야 하나 |
|---|---|---|
✅ UVM_ERROR : 0 / UVM_FATAL : 0 | 로그 끝 | 0이 아니면 무조건 실패 |
✅ SCOREBOARD SUMMARY: MATCH=… MISMATCH=0 | SCB report | MATCH가 충분히 큰가 (0에 가까우면 검사가 안 돈 것) |
✅ Cache hits / misses … (hit rate xx%) | SCB report | 시퀀스 의도와 맞나? thrash는 hit rate가 바닥, mix는 높아야 함 |
✅ Dirty evictions : N | SCB report | eviction 테스트인데 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.sv 의 plru_update 호출을 지워보면 → Way utilisation 이 한쪽으로 쏠리고
cp_way 커버리지가 25% 근처에서 멈춘다. 어느 체크가 먼저 우는지 관찰하면
각 장치의 담당 범위가 몸으로 이해된다. (실험 후 반드시 되돌릴 것.)
면접관은 문법보다 검증적 사고를 본다. 각각 30초 안에 설명할 수 있어야 한다.
배열을
assign rdata = mem[raddr]로 모델링해놨는데, 파이프라인은 S1에서 주소를 주고
S2에서 쓰는 구조였다. allocate→hit 전환 시점에 코어가 다음 요청의 index를 읽어
엉뚱한 라인 데이터를 돌려줬다 → read mismatch 675건.
게다가stall → raddr → rdata → hit → stall이 닫힌 조합 루프였다.
읽기 포트를 registered로 바꿔 둘 다 해결하고, 그 대가로 생긴 read-during-write 해저드는
byte 단위 포워딩 경로를 추가해 막았다.교훈: 메모리 모델은 "동작하는 코드"가 아니라 타이밍 계약이다. 계약이 틀리면 RTL 전체가 틀린다.
어서션이
$error를 쓰고 있었는데, 이건UVM_ERROR를 증가시키지 않는다.
어서션이 줄줄이 깨지는 런도UVM_ERROR : 0으로 PASS 로 보고됐다.
uvm_report_error로 바꾸고, 일부러 깨지는 어서션을 넣어 685개 에러가 찍히고
런이 FAIL로 분류되는 걸 확인해서 증명했다.교훈: 어서션이 "통과"하는 것과 어서션이 "존재"하는 것은 다르다. 체커는 발화시켜서 증명해야 한다.
두 가지가 겹쳐 있었다.
(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 내부 동작을
아는지 보여주는 소재. 여유 있으면 네 번째로 준비.
0xDEAD_BEEF 추천)l1_cache_core.sv:196~210) 을 백지에 써보기.plru_update 제거 / byte enable 무시 / 포워딩 제거,| 일차 | 목표 |
|---|---|
| 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 질문 소리 내어 답하기 |
s2_* 가 아니라 prev_*_raddr 인가?ST_COMPLETE 는 왜 있나? 한 사이클 손해 아닌가? 여기서 stall을 푸는 이유는?ST_WB_READ 가 왜 4가 아니라 5 사이클인가?bind 는 뭐고 왜 썼나?illegal_bins 와 ignore_bins 의 차이는? 후자를 남용하면?@(posedge clk); #1ps; 와 뭐가 다른가?uvm_analysis_imp_decl 은 왜 필요한가?soft constraint 는 언제 쓰나?disable fork 가 왜 위험한가? 이 프로젝트에서 뭘 깨뜨렸나?이 프로젝트의 진짜 가치는 "동작하는 캐시 컨트롤러"가 아니라
"통과하는데 아무것도 검증하지 않던 테스트벤치"를 찾아내고 고친 과정이다.
코드 100줄 외우는 것보다 Part 4의 세 문장을 자기 말로 설명하는 게 훨씬 강력하고,
그 앞에 Part 2.3~2.5의 사이클 계산을 손으로 할 수 있으면 "AI가 만들어준 프로젝트"와
"내가 이해한 프로젝트"의 차이가 명확해진다.