L1 Cache Controller 프로젝트 시리즈 1편
캐시의 기하학적 정의, 주소 분해, FSM 상태, PLRU 함수
l1_cache_pkg = 캐시의 "설계도 상수 + 도구 모음"
모든 캐시 파일이 이 패키지를 import해서
- 캐시 크기/구조 (파라미터)
- 주소를 어떻게 나누는지 (헬퍼 함수)
- FSM 상태
- PLRU 로직
을 공유한다. "단일 진실 공급원(single source of truth)".
localparam int ADDR_WIDTH = 32;
localparam int DATA_WIDTH = 32;
localparam int BE_WIDTH = DATA_WIDTH/8; // 4 (byte enable)
localparam int WAYS = 4; // 4-way
localparam int WAY_BITS = 2; // $clog2(4)
localparam int SETS = 64; // 64 sets
localparam int SET_BITS = 6; // $clog2(64)
localparam int WORDS_PER_LINE = 4; // 라인당 4워드
localparam int WORD_BITS = 2; // $clog2(4)
localparam int BYTE_BITS = 2; // 워드 안 바이트
localparam int OFFSET_BITS = WORD_BITS + BYTE_BITS; // 4 → 16B 라인
localparam int TAG_BITS = ADDR_WIDTH - SET_BITS - OFFSET_BITS; // 22
캐시 라인 = WORDS_PER_LINE × 4바이트
= 4 × 4 = 16바이트
전체 크기 = 라인 × SETS × WAYS
= 16B × 64 × 4
= 4096B = 4KB ✅
| 파라미터 | 값 | 의미 |
|---|---|---|
| WAYS | 4 | 한 set에 4개 라인 (4-way) |
| SETS | 64 | set 개수 |
| WORDS_PER_LINE | 4 | 한 라인에 4워드 |
| BE_WIDTH | 4 | byte enable (바이트별 쓰기) |
| OFFSET_BITS | 4 | 라인 안 위치 (16B → 4비트) |
| TAG_BITS | 22 | 태그 폭 |
$clog2(N) = N을 표현하는 데 필요한 비트 수 (ceiling log2)
$clog2(4) = 2 (WAYS=4 → WAY_BITS=2)
$clog2(64) = 6 (SETS=64 → SET_BITS=6)
왜? 4개를 구분하려면 2비트 (00,01,10,11)
64개를 구분하려면 6비트
localparam int DATA_ADDR_BITS = SET_BITS + WORD_BITS; // 8 → 256 words/way
데이터 배열 주소 = {set, word}
= 6비트 + 2비트 = 8비트
= 2^8 = 256워드 per way
한 way에 256워드 = 64 set × 4 word ✅
addr[31:10] = tag (22b)
addr[ 9: 4] = set index (6b → 64 sets)
addr[ 3: 2] = word in line (2b → 4 words)
addr[ 1: 0] = byte in word (2b)
32비트 주소:
31 10 9 4 3 2 1 0
┌────────────────────────┬─────────┬──────┬──────┐
│ tag │ set │ word │ byte │
│ (22비트) │ (6비트) │(2비트)│(2비트)│
└────────────────────────┴─────────┴──────┴──────┘
│ │ │ │
"이 데이터 맞나" 64개 set 라인 안 워드 안
확인용 중 선택 4워드 4바이트
byte (addr[1:0]):
워드(4바이트) 안에서 어느 바이트
→ byte enable로 선택
word (addr[3:2]):
캐시 라인(16B=4워드) 안에서 어느 워드
→ 2^2 = 4워드
set (addr[9:4]):
64개 set 중 어디에 매핑되나
→ 2^6 = 64 set
tag (addr[31:10]):
같은 set에 여러 주소가 매핑되므로
→ 태그로 "진짜 이 주소 맞나" 확인
→ 4개 way의 태그와 비교
낮은 비트(byte/word) = offset
→ 인접 주소는 같은 라인 → 공간 지역성 활용
중간 비트(set) = index
→ 주소를 64개 set에 분산
높은 비트(tag) = 나머지
→ 같은 set 안에서 구분
typedef logic [SET_BITS-1:0] set_t; // set 번호 (6비트)
typedef logic [TAG_BITS-1:0] tag_t; // 태그 (22비트)
typedef logic [WAY_BITS-1:0] way_t; // way 번호 (2비트)
typedef logic [WORD_BITS-1:0] word_t; // word 번호 (2비트)
typedef logic [DATA_ADDR_BITS-1:0] data_addr_t; // 데이터 주소 (8비트)
typedef logic [BE_WIDTH-1:0] be_t; // byte enable (4비트)
typedef = 타입에 이름 붙이기
logic [5:0] 대신 set_t
→ 코드가 의미로 읽힘: set_t set = get_set(addr);
→ 폭이 바뀌어도 typedef만 고치면 됨
이름 규칙: _t 접미사 = type
typedef enum logic [2:0] {
ST_IDLE = 3'd0,
ST_WB_READ = 3'd1, // dirty victim을 데이터 배열에서 읽기
ST_WB_SEND = 3'd2, // victim을 메인 메모리로 전송
ST_FILL_REQ = 3'd3, // 라인 fetch 요청
ST_FILL_RCV = 3'd4, // 버스트 받아서 배열에 씀
ST_COMPLETE = 3'd5 // tag/valid/dirty/PLRU 갱신, CPU 응답
} cache_state_t;

ST_IDLE: CPU 요청 대기. hit이면 여기서 바로 처리
ST_WB_READ: 쫓겨날 victim이 dirty면 먼저 데이터 배열에서 읽음
ST_WB_SEND: 읽은 victim을 메인 메모리에 씀 (write-back)
ST_FILL_REQ: 새 라인을 메모리에서 가져오라고 요청
ST_FILL_RCV: 버스트로 오는 데이터를 받아서 배열에 저장
ST_COMPLETE: 태그/valid/dirty/PLRU 갱신하고 CPU에 응답
// ST_COMPLETE costs one extra cycle per miss but removes the structural
// conflict between the last fill beat and the pending store's write.
ST_COMPLETE를 따로 둔 이유:
마지막 fill beat(FILL_RCV)와
대기 중인 store의 쓰기가 같은 사이클에 충돌
→ 구조적 충돌 (structural conflict)
해결: 완료 사이클을 하나 더 둠 (ST_COMPLETE)
→ miss당 1사이클 손해
→ 대신 충돌 제거
이게 이력서의 combinational loop 이슈와 같은 맥락!
(구조적 충돌을 상태 분리로 해결)
miss가 나면 (write-allocate):
victim 있음 + dirty → WB_READ → WB_SEND (메모리에 씀)
victim 없음 or clean → 바로 FILL_REQ
→ dirty victim만 write-back (깨끗하면 그냥 덮어씀)
→ write-back 정책: 캐시에만 쓰다가 쫓길 때 메모리로
typedef struct packed {
tag_t tag;
} tag_data_t;
태그 배열의 한 엔트리 = 태그만 담음
주석: "valid/dirty live in flops in the core (so reset flushes the
whole cache in one cycle); only the tag needs an SRAM."
valid/dirty는 플립플롭에 (SRAM 아님)
→ 리셋 시 한 사이클에 전체 캐시 무효화 가능
(SRAM은 한 번에 리셋 못 함)
태그는 SRAM에
→ 용량이 크니까 (22비트 × 256엔트리)
struct packed = 구조체를 비트로 촘촘히 packing
왜 valid/dirty를 플롭에?
리셋하면 모든 valid=0 되어야 함 (캐시 비우기)
SRAM은 일괄 리셋 불가 → 플롭이면 한 사이클에 전부 0
태그는 valid=0이면 어차피 무시되므로 리셋 불필요 → SRAM OK
주소에서 각 필드를 뽑는 함수들. 단일 진실 공급원.
function automatic set_t get_set(input logic [ADDR_WIDTH-1:0] addr_in);
return addr_in[OFFSET_BITS +: SET_BITS]; // addr[4 +: 6] = addr[9:4]
endfunction
function automatic tag_t get_tag(input logic [ADDR_WIDTH-1:0] addr_in);
return addr_in[ADDR_WIDTH-1 -: TAG_BITS]; // addr[31 -: 22] = addr[31:10]
endfunction
function automatic word_t get_word(input logic [ADDR_WIDTH-1:0] addr_in);
return addr_in[BYTE_BITS +: WORD_BITS]; // addr[2 +: 2] = addr[3:2]
endfunction
+: 와 -: 문법+: = "시작 비트부터 위로 N비트"
addr[4 +: 6] = addr[4], addr[5], ... addr[9] = addr[9:4]
-: = "시작 비트부터 아래로 N비트"
addr[31 -: 22] = addr[31], addr[30], ... addr[10] = addr[31:10]
왜 이 문법? 폭(TAG_BITS 등)이 파라미터라
addr[31:10] 처럼 못 씀 (숫자 고정)
→ +:/-: 로 "시작점 + 폭"으로 표현
// the formal argument names are deliberately verbose. Short names
// like `a`, `t`, `i` collide with ordinary loop/local variables at call
// sites, and xsim was observed evaluating the actual argument expression
// in the callee's scope when the names matched — silently passing 0.
XSim 버그:
함수 인자 이름이 호출부의 변수 이름과 같으면
→ XSim이 엉뚱한 스코프에서 값을 읽음
→ 조용히 0이 전달됨! (에러 없이)
예: get_set(a) 호출 시 인자 이름도 a면 충돌
해결: addr_in처럼 긴 이름 사용
→ 짧은 이름(a, t, i)이 루프 변수와 겹치는 걸 회피
→ AXI SVA의 vacuous assertion과 같은 종류의 XSim 함정
→ "조용히 틀린 값" = 가장 위험한 버그
이건 면접에서 좋은 이야깃거리예요. "시뮬레이터의 조용한 버그를 어떻게 피했나".
// addr가 속한 라인의 시작(word 0) 주소
function automatic logic [ADDR_WIDTH-1:0] get_line_addr(input logic [ADDR_WIDTH-1:0] addr_in);
return {addr_in[ADDR_WIDTH-1 : OFFSET_BITS], {OFFSET_BITS{1'b0}}};
endfunction
offset 비트를 0으로 만들어 라인 시작 주소 계산
addr = 0x...1234 (offset=0100 = word 1, byte 0)
→ 상위 비트 유지 + 하위 4비트 0
→ 0x...1230 (라인 시작)
{상위, {4{1'b0}}} = 상위 비트 + 0000 이어붙이기
이건 AXI ref_model의 word_base = a - (a % 4)와 같은 원리예요 (경계 정렬). 여기선 16B 라인 정렬.
// 태그+set으로 라인 주소 재구성
function automatic logic [ADDR_WIDTH-1:0] make_line_addr(input tag_t tag_in, input set_t set_in);
return {tag_in, set_in, {OFFSET_BITS{1'b0}}};
endfunction
// set+word로 데이터 배열 주소
function automatic data_addr_t make_data_addr(input set_t set_in, input word_t word_in);
return {set_in, word_in};
endfunction
make_line_addr: {tag, set, 0000} → 라인 주소 조립
(victim을 메모리에 쓸 때 주소 계산 등)
make_data_addr: {set, word} → 데이터 배열 인덱스
(SRAM 접근 주소)
앞서 개념으로 배운 tree-PLRU의 실제 코드.
localparam int PLRU_BITS = 3; // 4-way → 3비트
typedef logic [PLRU_BITS-1:0] plru_t;
[bit0] ← root: 왼쪽{0,1} vs 오른쪽{2,3}
/ \
[bit1] [bit2]
0 vs 1 2 vs 3
bit0 = 0 → ways{0,1}이 더 오래됨 (victim 후보)
bit0 = 1 → ways{2,3}이 더 오래됨
bit1 → way 0 vs 1 선택
bit2 → way 2 vs 3 선택
function automatic way_t plru_victim(input plru_t plru_in);
return plru_in[0] ? (plru_in[2] ? way_t'(3) : way_t'(2))
: (plru_in[1] ? way_t'(1) : way_t'(0));
endfunction
루트부터 화살표 따라 내려가며 victim 결정:
plru[0]=0 → 왼쪽으로
plru[1]=0 → way 0
plru[1]=1 → way 1
plru[0]=1 → 오른쪽으로
plru[2]=0 → way 2
plru[2]=1 → way 3
way_t'(3) = 3을 way_t 타입으로 캐스트
plru = 3'b101 (bit0=1, bit1=0, bit2=1)
plru[0]=1 → 오른쪽
plru[2]=1 → way 3
→ victim = way 3
function automatic plru_t plru_update(input plru_t plru_in, input way_t way_in);
plru_t nxt = plru_in;
nxt[0] = ~way_in[1];
if (way_in[1] == 1'b0) nxt[1] = ~way_in[0];
else nxt[2] = ~way_in[0];
return nxt;
endfunction
"방금 접근한 way의 반대로 화살표를 돌림"
(그 way를 victim에서 멀어지게 = 최근 사용 표시)
way_in을 이진수로 보면:
way 0 = 00, way 1 = 01, way 2 = 10, way 3 = 11
way_in[1] = 상위 비트 (왼쪽/오른쪽)
way_in[0] = 하위 비트 (쌍 안에서)
nxt[0] = ~way_in[1]:
way 0,1 접근(way_in[1]=0) → nxt[0]=1 (오른쪽 가리킴, 0/1 보호)
way 2,3 접근(way_in[1]=1) → nxt[0]=0 (왼쪽 가리킴, 2/3 보호)
if 분기:
왼쪽 쌍(0,1) 접근 → nxt[1] 갱신
오른쪽 쌍(2,3) 접근 → nxt[2] 갱신
→ 해당 쌍 안에서도 접근한 것 반대로
초기 plru = 000, way 2에 접근
way 2 = 10 → way_in[1]=1, way_in[0]=0
nxt[0] = ~way_in[1] = ~1 = 0 (왼쪽 가리킴, way 2,3 보호)
way_in[1]=1이므로 else 분기:
nxt[2] = ~way_in[0] = ~0 = 1 (way 3 가리킴, way 2 보호)
결과 plru = 3'b100 (bit2=1, bit0=0)
이제 victim 찾으면:
plru[0]=0 → 왼쪽 → plru[1]=0 → way 0
→ way 2를 방금 썼으니 way 0이 victim (오래된 것) ✅
이 패키지 하나가 캐시 전체의 기반:
주소 분해 → 컨트롤러가 hit/miss 판정하는 근거
FSM 상태 → 컨트롤러 동작의 뼈대
PLRU 함수 → victim 선택 로직
헬퍼 함수 → 모든 파일이 공유하는 주소 계산
→ 여기가 정확해야 캐시 전체가 정확
→ "단일 진실 공급원"이라 버그가 여기 있으면 전체 영향
l1_cache_pkg = 캐시의 설계도 상수 + 도구 모음
① geometry: 4-way, 64 set, 16B line = 4KB
② 주소 분해: [tag 22 | set 6 | word 2 | byte 2]
③ typedef: set_t, tag_t, way_t 등 (의미 있는 타입)
④ FSM: IDLE→WB_READ→WB_SEND→FILL_REQ→FILL_RCV→COMPLETE
⑤ tag 배열: 태그만 SRAM, valid/dirty는 플롭(리셋용)
⑥ 헬퍼: get_set/tag/word, +:/-: part-select
⑦ tree-PLRU: 3비트로 4-way victim 선택/갱신
핵심 디테일:
- ST_COMPLETE 분리 → 구조적 충돌 해결 (combinational loop 맥락)
- 긴 인자 이름 → XSim의 조용한 0 전달 버그 회피
- valid/dirty 플롭 → 한 사이클 캐시 무효화
이 패키지의 정의들이 실제로 쓰이는 곳:
특히 이력서의 combinational loop 버그는 컨트롤러의 stall 경로에서 나오는데, 그때 이 FSM과 주소 헬퍼가 어떻게 얽히는지가 핵심이 됩니다.