l1_cache_pkg.sv

Seungyun Lee·2026년 8월 6일

L1 Cache Controller 프로젝트 시리즈 1편
캐시의 기하학적 정의, 주소 분해, FSM 상태, PLRU 함수


이 파일의 역할

l1_cache_pkg = 캐시의 "설계도 상수 + 도구 모음"

모든 캐시 파일이 이 패키지를 import해서
  - 캐시 크기/구조 (파라미터)
  - 주소를 어떻게 나누는지 (헬퍼 함수)
  - FSM 상태
  - PLRU 로직
을 공유한다. "단일 진실 공급원(single source of truth)".

목차

  1. Cache Geometry — 캐시 구조 파라미터
  2. 주소 분해 — tag/set/word/byte
  3. 타입 정의 (typedef)
  4. Controller FSM — 상태 정의
  5. Tag 배열 구조
  6. 주소 헬퍼 함수
  7. Tree-PLRU 함수

1. Cache Geometry — 캐시 구조

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

4KB 계산

캐시 라인 = WORDS_PER_LINE × 4바이트
          = 4 × 4 = 16바이트

전체 크기 = 라인 × SETS × WAYS
          = 16B × 64 × 4
          = 4096B = 4KB ✅
파라미터의미
WAYS4한 set에 4개 라인 (4-way)
SETS64set 개수
WORDS_PER_LINE4한 라인에 4워드
BE_WIDTH4byte enable (바이트별 쓰기)
OFFSET_BITS4라인 안 위치 (16B → 4비트)
TAG_BITS22태그 폭

$clog2 — 비트 폭 계산

$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비트

DATA_ADDR_BITS

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 ✅

2. 주소 분해 — 핵심 개념

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 안에서 구분

3. 타입 정의 (typedef)

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

4. Controller FSM — 상태 정의

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의 이유

// 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 이슈와 같은 맥락!
  (구조적 충돌을 상태 분리로 해결)

write-back / write-allocate와 연결

miss가 나면 (write-allocate):
  victim 있음 + dirty → WB_READ → WB_SEND (메모리에 씀)
  victim 없음 or clean → 바로 FILL_REQ

→ dirty victim만 write-back (깨끗하면 그냥 덮어씀)
→ write-back 정책: 캐시에만 쓰다가 쫓길 때 메모리로

5. Tag 배열 구조

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

6. 주소 헬퍼 함수

주소에서 각 필드를 뽑는 함수들. 단일 진실 공급원.

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] 처럼 못 씀 (숫자 고정)
          → +:/-: 로 "시작점 + 폭"으로 표현

⚠️ 주석의 XSim 함정 (매우 중요)

// 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 접근 주소)

7. Tree-PLRU 함수 (핵심)

앞서 개념으로 배운 tree-PLRU의 실제 코드.

localparam int PLRU_BITS = 3;                 // 4-way → 3비트
typedef logic [PLRU_BITS-1:0] plru_t;

3비트 트리 구조

        [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 선택

victim 선택

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

PLRU 갱신 (접근 시)

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 플롭 → 한 사이클 캐시 무효화

다음 편 예고

이 패키지의 정의들이 실제로 쓰이는 곳:

  • 캐시 컨트롤러 (FSM 구현, hit/miss 판정)
  • 데이터 배열 / 태그 배열 (SRAM)
  • UVM 검증 환경 (golden model이 이 주소 분해를 독립 구현)

특히 이력서의 combinational loop 버그는 컨트롤러의 stall 경로에서 나오는데, 그때 이 FSM과 주소 헬퍼가 어떻게 얽히는지가 핵심이 됩니다.

profile
Design Verification engineer

0개의 댓글