메모리는 주소 하나로 여러 워드 가운데 하나를 고르는 상태 저장 구조입니다. 레지스터 Vec으로도
만들 수 있지만, 실제 하드웨어에는 논리로 합성되는 상수 테이블, 플립플롭으로 만든 레지스터
뱅크, 공정이 제공하는 SRAM 매크로처럼 전용 구조가 있습니다. 세 구조는 읽기 데이터가 나타나는
시점이 다르고, 그 차이가 주변 논리를 작성하는 방법을 결정합니다.
먼저 알아 두면 좋은 것. Bundle과 Vec 장의 Vec와
VecInit, 레지스터·RegInit·클록 진행 장의 현재 값·다음 값
모델을 사용합니다. 메모리 쓰기는 레지스터의 다음 값처럼 클록 에지에서 반영됩니다.
ROM의 내용은 elaboration 중에 Scala가 계산해 고정합니다. 읽기·쓰기 메모리의 내용은
시뮬레이션 중 클록 에지에서만 바뀝니다.
1. 세 메모리의 차이는 읽기 지연입니다
| 메모리 | 쓰기 | 읽기 | 대응 구현 |
|---|---|---|---|
VecInit(...) ROM | 없음 | 조합 읽기. 같은 사이클에 데이터가 나타납니다 | 상수 논리 |
Mem | 클록 에지 | 조합 읽기. 같은 사이클에 데이터가 나타납니다 | 레지스터 뱅크(플립플롭) |
SyncReadMem | 클록 에지 | 동기 읽기. 에지에서 주소를 저장하고 다음 사이클에 데이터가 나타납니다 | 공정 SRAM |
두 읽기·쓰기 메모리의 쓰기 동작은 같습니다. 차이는 읽기에 있습니다. Mem의 읽기는 Vec
인덱싱과 같아서 이번 사이클에 준 주소의 데이터가 이번 사이클에 보입니다. SyncReadMem은
에지에서 주소를 레지스터에 저장하므로 지금 준 주소의 데이터가 한 사이클 뒤에 나타납니다.
참고. 최근 FPGA와 ASIC 공정의 SRAM은 대부분 동기 읽기만 지원합니다. 그래서 SRAM에
대응하는 메모리는 SyncReadMem이고, Mem은 보통 플립플롭으로 구현됩니다.
2. VecInit으로 만드는 ROM
2.1 리터럴 테이블
ROM은 리터럴로 이루어진 Vec입니다. VecInit은 리터럴을 인자로 나열하거나 Scala Seq로
받습니다. 따라서 내용은 목록, 수식, elaboration 중 읽은 파일처럼 어떤 Scala 계산의 결과여도
됩니다.
형식
val 이름 = VecInit(값1, 값2, ...)
val 이름 = VecInit(Scala_시퀀스.map(i => 식.U(폭.W)))val squares = VecInit((0 until 8).map(i => (i * i).U(6.W)))
io.out := squares(io.idx)| 코드 | 의미 |
|---|---|
(0 until 8).map(i => (i * i).U(6.W)) | Scala가 0, 1, 4, …, 49를 계산해 6비트 리터럴 여덟 개를 만듭니다 |
VecInit(...) | 리터럴 Seq를 상수 하드웨어 Vec으로 만듭니다 |
squares(io.idx) | 하드웨어 UInt로 인덱싱하면 상수들 가운데 하나를 고르는 멀티플렉서가 됩니다 |
주의. Vec의 원소 타입은 하나여야 합니다. 폭을 적지 않은 리터럴은 각자 최소 폭을
가지므로 VecInit은 모든 원소를 가장 넓은 원소의 폭으로 맞춥니다. 2.U는 2비트, 13.U는
4비트이므로 두 값을 함께 넣으면 원소 폭은 4비트가 됩니다.
2.2 Counter
chisel3.util.Counter(n)은 0부터 n - 1까지 센 뒤 0으로 순환하는 카운터를 라이브러리로
제공합니다. 레지스터·RegInit·클록 진행 장에서 직접 만든
카운터와 같은 동작입니다.
형식
val c = Counter(n)
val wrap = c.inc() // c.value를 증가시키는 논리를 추가합니다| 멤버 | 의미 |
|---|---|
c.value | 현재 카운트입니다. 폭은 n에 맞춰 Scala가 정합니다 |
c.inc() | 증가 논리를 추가하고, 카운트가 n - 1이라 다음 에지에서 0으로 순환하는 사이클에 참인 Bool을 반환합니다 |
inc()를 when 안에서 호출하면 그 조건이 참인 사이클에만 증가합니다.
참고. Counter(cond, n) 형식은 cond가 참인 사이클에만 증가하는 카운터를 만들고
(현재 값, wrap) 쌍을 반환합니다. val (value, wrap) = Counter(io.en, 5)처럼 씁니다.
2.3 예제: 소수 테이블 순회하기
PrimeRom은 Scala 목록 2, 3, 5, 7, 11, 13을 리터럴로 바꿔 VecInit 테이블을 만듭니다.
elaboration 중의 println은 테이블 길이와 원소 폭을 출력합니다. Counter(table.length)가
주소를 만들고, inc()를 조건 없이 한 번 호출하므로 카운터는 매 사이클 증가합니다.
import chisel3._
import chisel3.util.Counter
class PrimeRom extends Module {
val io = IO(new Bundle {
val addr = Output(UInt(3.W))
val data = Output(UInt(8.W))
val wrap = Output(Bool())
})
// Scala가 내용을 계산하고 VecInit이 상수 하드웨어로 만든다.
val primes = Seq(2, 3, 5, 7, 11, 13)
val table = VecInit(primes.map(_.U))
println(s"ELABORATE entries=${table.length} width=${table(0).getWidth}")
// Counter(n)은 0부터 n-1까지 센 뒤 순환하는 레지스터다.
val c = Counter(table.length)
val wrap = c.inc()
io.addr := c.value
io.data := table(c.value)
io.wrap := wrap
}테스트벤치는 여덟 사이클 동안 주소, 데이터, wrap 플래그를 출력합니다.
import chisel3._
import chiseltest._
import chiseltest.RawTester.test
object Main extends App {
test(new PrimeRom) { dut =>
for (cycle <- 0 until 8) {
val a = dut.io.addr.peek().litValue
val d = dut.io.data.peek().litValue
val w = dut.io.wrap.peek().litValue
println(s"cycle=$cycle addr=$a data=$d wrap=$w")
dut.clock.step()
}
}
}실행하기 전에 예측해 보세요.
ELABORATE줄은 원소 몇 개, 폭 몇 비트를 보고할까요?wrap이 1인 사이클은 언제일까요?- 사이클 6에는 어느 주소와 데이터가 출력될까요?
확인 문제
테이블에는 원소 여섯 개가 있고 카운터는 매 사이클 증가합니다. 출력에서 wrap이 1인 사이클은 언제이고, 사이클 6의 주소는 무엇입니까?
결과 확인.
| 출력 줄 | 확인할 내용 |
|---|---|
ELABORATE | 원소 6개, 폭 4입니다. 가장 넓은 13.U의 폭에 맞춰졌습니다 |
| 사이클 0–5 | 주소 0–5에서 소수 2, 3, 5, 7, 11, 13이 차례로 출력됩니다 |
| 사이클 5 | 마지막 주소이므로 wrap이 1입니다 |
| 사이클 6–7 | 주소 0과 데이터 2로 순환한 뒤 주소 1로 이어집니다 |
wrap 플래그는 카운터 현재 값에 대한 조합 신호이며, 카운터가 0으로 순환하기 직전 사이클을 표시합니다. 순환에 맞춰 동작해야 하는 후단 논리는 이 사이클에 반응해야 제때 동작합니다.
직접 바꿔 보세요. Scala 목록 끝에 17을 추가하고, 두 단계의 결과를 각각 예측해 보세요.
| 단계 | 예상 결과 |
|---|---|
elaboration | 원소 7개, 폭 5를 보고합니다. 17은 5비트가 필요합니다 |
| 시뮬레이션 | 카운터가 주소 6 다음에 순환합니다. 사이클 6에서 wrap이 1이고 데이터는 17이며, 사이클 7에 주소 0이 됩니다 |
카운터와 Vec 코드는 고치지 않았습니다. 테이블 길이와 폭이 모두 Scala 목록에서 결정됩니다.
3. 조합 읽기 메모리 Mem
3.1 선언, 쓰기, 읽기
Mem은 깊이와 원소 타입을 받아 읽기·쓰기 메모리를 만듭니다. 쓰기 포트는 write로,
읽기 포트는 read로 만듭니다.
형식
val 이름 = Mem(깊이, 원소_타입)
when(쓰기_조건) {
이름.write(주소, 데이터)
}
val 읽은_값 = 이름.read(주소)다음은 8비트 레지스터 네 개로 이루어진 작은 레지스터 파일입니다.
val regs = Mem(4, UInt(8.W))
when(io.wen) {
regs.write(io.waddr, io.wdata)
}
io.rdata := regs.read(io.raddr)| 코드 | 의미 |
|---|---|
Mem(4, UInt(8.W)) | 8비트 워드 네 개를 저장하는 메모리입니다 |
when(io.wen) { regs.write(...) } | wen이 1인 사이클의 끝 에지에서 wdata를 씁니다 |
regs.read(io.raddr) | 이번 사이클의 raddr에 저장된 값을 같은 사이클에 읽습니다 |
같은 사이클에 같은 주소를 쓰고 읽으면 읽기 결과는 쓰기 전의 값입니다. 쓰기는 에지에서 반영되므로, 새 값은 다음 사이클부터 보입니다.
참고. regs(io.raddr)처럼 괄호로 인덱싱해도 읽기 포트가 만들어집니다.
4. 동기 읽기 메모리 SyncReadMem
4.1 선언과 읽기 지연
SyncReadMem의 선언과 쓰기는 Mem과 같습니다. 읽기에는 enable 인자를 줄 수 있으며,
enable이 0인 사이클에는 읽기를 하지 않습니다.
형식
val 이름 = SyncReadMem(깊이, 원소_타입)
val 읽은_값 = 이름.read(주소)
val 읽은_값 = 이름.read(주소, enable)read가 반환하는 값은 직전 에지에서 저장한 주소의 데이터입니다. 사이클 t에 준 주소의
데이터는 사이클 t + 1에 나타납니다.
4.2 읽기 데이터가 정의되지 않는 경우
공식 문서는 SyncReadMem의 읽기 데이터를 다음 세 경우에 보장하지 않습니다.
| 조건 | 결과 |
|---|---|
| 같은 클록 에지에 같은 주소를 쓰고 읽음 | 읽기 데이터가 정의되지 않습니다 |
읽기 enable을 0으로 둠 | 다음 사이클의 읽기 데이터가 정의되지 않습니다 |
| 읽기 데이터가 나타난 다음 사이클 | 값이 유지된다고 보장하지 않습니다. 나중에 필요하면 직접 레지스터에 저장합니다 |
4.3 예제: 같은 주소를 두 메모리에서 읽기
MemCompare는 깊이와 타입이 같은 Mem과 SyncReadMem을 선언하고, 같은 쓰기 입력과 같은
읽기 주소 raddr를 두 메모리에 연결합니다. SyncReadMem의 읽기는 쓰기가 없는 사이클에만
활성화되어 같은 사이클 읽기·쓰기를 피합니다.
import chisel3._
class MemCompare extends Module {
val io = IO(new Bundle {
val wen = Input(Bool())
val waddr = Input(UInt(4.W))
val wdata = Input(UInt(8.W))
val raddr = Input(UInt(4.W))
val asyncOut = Output(UInt(8.W))
val syncOut = Output(UInt(8.W))
})
// 깊이와 원소 타입은 같고 읽기 타이밍만 다르다.
val comb = Mem(16, UInt(8.W))
val sync = SyncReadMem(16, UInt(8.W))
when(io.wen) {
comb.write(io.waddr, io.wdata)
sync.write(io.waddr, io.wdata)
}
// Mem: raddr의 데이터가 같은 사이클에 나타난다.
io.asyncOut := comb.read(io.raddr)
// SyncReadMem: 에지에서 raddr를 저장하고 다음 사이클에 데이터가 나타난다.
io.syncOut := sync.read(io.raddr, !io.wen)
}테스트벤치는 주소 0–3에 11, 22, 33, 44를 쓴 뒤, 주소 2, 0, 3, 1, 1을 한 사이클씩 주면서 두
출력을 표시합니다. 첫 읽기 사이클의 syncmem 값은 앞선 활성 읽기가 없어 정의되지 않으므로
--로 표시합니다.
import chisel3._
import chiseltest._
import chiseltest.RawTester.test
object Main extends App {
test(new MemCompare) { dut =>
// 주소 0–3에 11, 22, 33, 44를 쓴다.
dut.io.wen.poke(true.B)
for (a <- 0 until 4) {
dut.io.waddr.poke(a.U)
dut.io.wdata.poke(((a + 1) * 11).U)
dut.clock.step()
}
// 두 메모리에서 같은 주소 순서로 읽는다.
dut.io.wen.poke(false.B)
for ((a, cycle) <- Seq(2, 0, 3, 1, 1).zipWithIndex) {
dut.io.raddr.poke(a.U)
val m = dut.io.asyncOut.peek().litValue
// 사이클 0에는 앞선 활성 읽기가 없으므로 동기 읽기 값을 표시하지 않는다.
val s = if (cycle == 0) "--" else dut.io.syncOut.peek().litValue.toString
println(s"cycle=$cycle raddr=$a mem=$m syncmem=$s")
dut.clock.step()
}
}
}이 개념 실습하기
다음 단계로 짧은 Chisel 문제에 이 개념을 적용해 보세요.
실행하기 전에 예측해 보세요. 처음 두 사이클을 채워 두었습니다. 사이클 2–4를 이어서 채워 보세요.
| 읽기 사이클 | raddr | mem | syncmem |
|---|---|---|---|
| 0 | 2 | 33 | -- (직전 사이클은 읽기가 비활성인 쓰기 사이클) |
| 1 | 0 | 11 | 33 (사이클 0의 에지에서 저장한 주소 2의 데이터) |
syncmem열과mem열은 어떤 관계일까요?- 두 열이 같은 값을 보이는 사이클이 있을까요?
확인 문제
읽기 사이클 2의 raddr는 3입니다. mem 열과 syncmem 열에는 무엇이 출력됩니까?
결과 확인.
| 출력 열 | 확인할 내용 |
|---|---|
mem | 주소 열을 그대로 따라 33, 11, 44, 22, 22입니다. 같은 사이클 읽기입니다 |
syncmem | mem 열을 한 줄 아래로 옮긴 --, 33, 11, 44, 22입니다. 한 사이클 지연입니다 |
| 사이클 4 | 주소가 사이클 3과 같으므로 두 열이 모두 22입니다 |
이 예제에서는 주소가 반복되어 두 열이 같아집니다. 서로 다른 주소에 같은 데이터가 있어도 출력은 같을 수 있으므로, 출력값이 같다는 사실만으로 읽기 지연이 같다고 판단하면 안 됩니다.
직접 바꿔 보세요. Mem의 출력을 io.asyncOut := RegNext(comb.read(io.raddr))로 바꿔
조합 읽기 뒤에 레지스터를 두고, mem 열을 예측해 보세요.
| 사이클 | mem | 설명 |
|---|---|---|
| 0 | 11 | 마지막 쓰기 사이클의 raddr가 0이었으므로 mem[0]의 레지스터 사본입니다 |
| 1–4 | 33, 11, 44, 22 | syncmem과 같습니다 |
조합 읽기 뒤에 레지스터를 두면 동기 읽기 메모리와 관찰 타이밍이 같아집니다.
주의. 타이밍이 같아도 하드웨어는 다릅니다. 이 구성은 레지스터 뱅크와 출력 레지스터이며 SRAM이 아닙니다.
5. 포트 구성, 마스크, SRAM API
5.1 쓰기 마스크
원소 타입을 Vec으로 선언하면 write에 마스크를 주어 일부 lane만 쓸 수 있습니다. 바이트
단위 쓰기 enable이 대표적인 용도입니다.
형식
val 이름 = SyncReadMem(깊이, Vec(lane_수, 원소_타입))
이름.write(주소, 데이터, 마스크) // 마스크: Vec(lane_수, Bool())val mem = SyncReadMem(256, Vec(4, UInt(8.W)))
when(io.wen) {
mem.write(io.addr, io.wdata, io.mask)
}
io.rdata := mem.read(io.addr, !io.wen)mask(i)가 1인 lane i만 wdata(i)로 바뀌고, 나머지 lane은 이전 값을 유지합니다.
5.2 단일 포트 메모리와 SRAM
| 기능 | 기술 방법 | 효과 |
|---|---|---|
| 단일 포트 메모리 | mem.readWrite(주소, 쓰기_데이터, enable, isWrite) 또는 하나의 when에서 읽기와 쓰기를 배타적 분기로 기술 | 단일 포트 SRAM처럼 읽기·쓰기 포트 하나를 공유합니다 |
chisel3.util.SRAM | SRAM(깊이, 타입, 읽기_포트_수, 쓰기_포트_수, 읽기쓰기_포트_수) | 포트 수를 명시한 SyncReadMem입니다. 포트 bundle로 구동합니다 |
val mem = SyncReadMem(16, UInt(8.W))
io.rdata := mem.readWrite(io.addr, io.wdata, io.en, io.isWrite)참고. when 안에서만 읽는 단일 포트 메모리는 다른 사이클에 출력이 구동되지 않습니다.
공식 예제는 미연결 검사를 통과하도록 먼저 io.dataOut := DontCare를 연결합니다.
참고. 메모리를 binary 또는 hex 파일로 초기화하는 기능도 있습니다. 공식 문서는 이 기능을 experimental로 분류하며, 이 과정에서는 사용하지 않습니다.
6. 메모리 선택 기준
| 메모리 | 적합한 경우 | 비용 |
|---|---|---|
VecInit ROM | 작은 상수 테이블, 계수, microcode | 논리로 합성되므로 크기가 커지면 면적이 빠르게 늘어납니다 |
Mem | 같은 사이클 읽기로 주변 논리가 단순해지는 작은 메모리. 예: 작은 레지스터 파일 | 플립플롭 면적 |
SyncReadMem | SRAM이 필요한 큰 메모리 | 한 사이클 읽기 지연. 주소나 데이터를 쓰는 연산을 한 사이클 맞춰야 합니다 |
Mem 뒤에 RegNext를 둔 구성은 SyncReadMem과 타이밍이 같습니다. 그래서 테스트벤치를
그대로 둔 채 두 구현을 바꿔 볼 수 있고, 선택 기준은 합성 도구가 추론할 수 있는 구현으로
좁혀집니다.
7. 자주 하는 실수
7.1 쓰기를 when으로 감싸지 않기
// 잘못된 코드: 매 사이클 waddr에 wdata를 씀
regs.write(io.waddr, io.wdata)
// 바른 코드: wen이 1인 사이클에만 씀
when(io.wen) {
regs.write(io.waddr, io.wdata)
}write는 둘러싼 조건이 참인 사이클마다 동작합니다. 조건 없이 쓰면 wen 입력이 있어도 쓰기
포트와 연결되지 않으므로, wen을 0으로 둔 사이클에도 값이 바뀝니다.
7.2 SyncReadMem에서 같은 주소를 동시에 읽고 쓰기
// 잘못된 코드: 쓰는 사이클에도 읽기가 활성
when(io.wen) { sync.write(io.addr, io.wdata) }
io.rdata := sync.read(io.addr)
// 바른 코드: 쓰는 사이클에는 읽기를 끔
when(io.wen) { sync.write(io.addr, io.wdata) }
io.rdata := sync.read(io.addr, !io.wen)같은 에지에 같은 주소를 쓰고 읽으면 SyncReadMem의 읽기 데이터는 정의되지 않습니다.
시뮬레이션에서 우연히 맞는 값이 보여도 SRAM에서는 다를 수 있습니다.
7.3 ROM 값을 좁은 포트로 내보내기
// 잘못된 코드: 원소는 4비트인데 출력은 3비트
val out = Output(UInt(3.W))
io.out := table(io.idx) // 11은 3, 13은 5로 출력됩니다
// 바른 코드: 원소 폭에 맞춘 출력
val out = Output(UInt(4.W))
io.out := table(io.idx)넓은 값을 좁은 포트에 :=로 연결하면 상위 비트가 오류 없이 잘립니다. ROM 값의 상위 비트가
사라지면 원소 폭과 데이터가 지나는 포트의 폭을 함께 확인하세요.
7.4 큰 메모리를 Mem으로 선언하기
// 잘못된 선택: 조합 읽기는 SRAM에 대응하지 않음
val big = Mem(4096, UInt(32.W))
// 바른 선택: 동기 읽기 메모리를 쓰고 한 사이클 지연을 설계에 반영
val big = SyncReadMem(4096, UInt(32.W))오류는 발생하지 않지만, 합성하면 13만 개가 넘는 플립플롭과 큰 읽기 멀티플렉서가 생깁니다.
SRAM이 필요한 크기라면 SyncReadMem을 쓰고 주변 논리를 한 사이클 읽기 지연에 맞추세요.
8. 정리
| 주제 | 핵심 |
|---|---|
| ROM | VecInit으로 만든 리터럴 Vec입니다. 내용과 원소 폭은 elaboration 중 Scala 목록이 정합니다 |
Counter | value는 현재 카운트, inc()는 증가 논리를 추가하고 순환 직전 사이클에 참을 반환합니다 |
Mem | 쓰기는 에지에서, 읽기는 같은 사이클에 일어납니다. 보통 플립플롭으로 구현됩니다 |
SyncReadMem | 에지에서 주소를 저장하고 한 사이클 뒤에 데이터를 출력합니다. SRAM에 대응합니다 |
| 정의되지 않는 읽기 | 같은 주소 동시 읽기·쓰기, 비활성 읽기 다음 사이클, 데이터가 나타난 다음 사이클 |
| 마스크와 포트 | Vec 원소와 마스크로 lane 단위 쓰기, readWrite와 SRAM으로 포트 구성을 정합니다 |
9. 확인 문제
-
17을 추가하면 ROM 원소 폭이 4비트에서 5비트로 바뀌는 이유는 무엇이며, 그 폭은 어느 단계에서 결정될까요?
정답 보기
폭을 적지 않은 리터럴은 각자 최소 폭을 갖고,
VecInit은 모든 원소를 가장 넓은 원소의 폭에 맞춥니다. 17은 5비트가 필요하므로 원소 폭이 5가 됩니다. 이 폭은elaboration중에 결정됩니다. -
Counter의inc()는 순환을 기준으로 어느 사이클에 참을 반환할까요?정답 보기
카운트가
n - 1인 사이클, 곧 다음 에지에서 0으로 순환하기 직전 사이클에 참을 반환합니다.PrimeRom에서는 주소 5인 사이클입니다. -
MemCompare추적에서 첫syncmem값을 출력하지 않는 이유는 무엇일까요?정답 보기
첫 읽기 사이클에 보이는
SyncReadMem데이터는 직전 사이클의 읽기 결과입니다. 직전 사이클은wen이 1인 쓰기 사이클이라 읽기enable이 0이었으므로 그 데이터는 정의되지 않습니다. -
Mem에서 같은 사이클에 같은 주소를 쓰고 읽으면 읽기 결과는 무엇일까요?정답 보기
쓰기 전의 값입니다. 쓰기는 사이클 끝 에지에서 반영되고, 조합 읽기는 그 전의 내용을 읽습니다. 새 값은 다음 사이클부터 보입니다.
-
RegNext(comb.read(...))와SyncReadMem의 타이밍이 같음을 보이려면 어느 두 신호를 비교해야 하며, 어느 사이클부터 같아야 할까요?정답 보기
asyncOut과syncOut, 곧 출력의mem열과syncmem열을 비교합니다. 첫 읽기 사이클은syncmem이 정의되지 않으므로, 두 열은 읽기 사이클 1부터 같아야 합니다.
10. 실습
이 장의 아래, read-latency 예제와 연결된 실습 문제 두 개가 있습니다. 첫 번째 문제에서는
SyncReadMem을 쓰는 스크래치패드에 valid 플래그를 추가해, 클라이언트가 플래그만 보고 올바른
워드를 받을 수 있게 합니다. 지금은 플래그가 데이터와 다른 사이클에 올라갑니다. 두 번째 문제에서는
레지스터 파일의 두 피연산자 포트가 모두 이전 명령의 레지스터 값을 반환하는 원인을 찾아 고칩니다.
두 문제 모두 실행하기 전에 사이클별 읽기 표를 먼저 써 보세요.