1. 모듈 정의
지금까지의 예제는 모두 io 묶음 하나를 가진 모듈 하나였습니다. 실제 하드웨어 설계는 최상위 모듈이
하위 모듈을 인스턴스화해 포트를 연결하고, 하위 모듈도 다시 다른 모듈을 포함하는 트리 구조가
됩니다. 이 장에서는 Chisel 모듈을 정의하는 방법, 하위 모듈을 배치하고 연결하는 방법, 매개변수
하나로 여러 모듈 정의를 만드는 방법, Scala 함수가 호출될 때마다 하드웨어를 만드는 방식, 그리고
모듈을 함수처럼 호출하는 방법을 배웁니다. Bundle과 Vec 장의
Bundle, Vec 포트 선언, 폭·SInt·산술 장의 폭
규칙과 +&, Mux·when·Wire 장의 Mux와 "모든 출력을
빠짐없이 구동해야 한다"는 미연결 검사를 그대로 사용합니다.
이 장에서 다루는 내용은 모두 elaboration 단계에서 결정됩니다. Scala 코드가 실행되면서 모듈
트리와 연결이 만들어지고, 시뮬레이션은 그렇게 만든 연결이 의도한 대로인지 확인하는 데만 사용합니다.
1.1 모듈 클래스
Chisel 모듈은 Module을 상속한 Scala 클래스입니다. 클래스 본문, 곧 생성자가 실행되면서 포트를
선언하고 내부 하드웨어를 만들고 :=로 신호를 연결합니다.
형식
class 모듈_이름 extends Module {
val io = IO(new Bundle {
val 입력_이름 = Input(타입)
val 출력_이름 = Output(타입)
})
// 회로 기술
}다음은 1비트 입력 두 개의 AND를 출력하는 가장 작은 모듈입니다.
class And2 extends Module {
val io = IO(new Bundle {
val a = Input(Bool())
val b = Input(Bool())
val y = Output(Bool())
})
io.y := io.a && io.b
}| 코드 | 의미 |
|---|---|
class And2 extends Module | And2라는 모듈 클래스를 정의합니다 |
IO(new Bundle { ... }) | Bundle의 각 필드를 모듈 포트로 만듭니다 |
Input(Bool()), Output(Bool()) | 포트의 방향과 타입을 정합니다 |
io.y := io.a && io.b | 출력 y를 두 입력의 AND로 구동합니다 |
포트 묶음의 이름은 관례적으로 io를 씁니다. 생성된 Verilog에서는 필드 이름이 평탄화되어 io_a,
io_y 같은 포트 이름이 됩니다.
1.2 암묵적인 클록과 리셋
Module을 상속한 모든 모듈에는 포트 목록에 적지 않은 clock과 reset 입력이 하나씩 있습니다.
하위 모듈을 인스턴스화하면 이 두 입력은 상위 모듈의 clock, reset에 자동으로 연결됩니다. 그래서
이 장의 예제에는 클록과 리셋이 한 번도 나오지 않습니다.
레지스터·RegInit·클록 진행 장의 레지스터가 이 두 신호를
사용합니다.
참고. 암묵적인 클록과 리셋이 없는 모듈은 RawModule로 정의합니다. 클록·리셋 포트의 이름이나
극성을 외부 규약에 맞춰야 할 때 사용하며, 이 과정에서는 쓰지 않습니다.
1.3 클래스, 인스턴스, 모듈 정의
모듈을 다룰 때는 비슷해 보이는 세 가지를 구분해야 합니다.
| 대상 | 존재하는 시점 | 의미 |
|---|---|---|
class Mux2 extends Module | Scala 소스 | 회로를 만드는 절차입니다. 아직 하드웨어는 없습니다 |
Module(new Mux2) | elaboration 중, 실행될 때마다 | 상위 모듈 안의 인스턴스 하나입니다. 인스턴스마다 포트가 따로 있습니다 |
| CHIRRTL의 모듈 정의 | elaboration 이후 | 출력된 회로 텍스트입니다. elaboration한 인스턴스마다 하나씩 출력되고, 이름이 겹치면 _1 같은 suffix가 붙습니다. firtool은 Verilog를 출력하기 전에 같은 정의를 하나로 합칩니다 |
Module(...)을 호출할 때마다 새 인스턴스가 생깁니다. 두 번 호출하면 같은 하드웨어가 두 벌
만들어지며, 인스턴스끼리 하드웨어를 암묵적으로 공유하지 않습니다. 기판에 칩을 하나 더 얹는 것과
같습니다.
2. 하위 모듈 인스턴스화와 포트 연결
2.1 Module(new ...)
상위 모듈 안에서 하위 모듈을 만들 때는 new로 만든 객체를 반드시 Module(...)로 감쌉니다.
Module(...)이 그 객체를 현재 구성 중인 모듈의 자식으로 등록하고, 클록과 리셋을 연결하고, 인스턴스
이름을 부여합니다.
형식
val 인스턴스_이름 = Module(new 모듈_클래스(생성자_인자))다음 And3은 And2 인스턴스 두 개로 세 입력의 AND를 만듭니다.
class And3 extends Module {
val io = IO(new Bundle {
val a = Input(Bool())
val b = Input(Bool())
val c = Input(Bool())
val y = Output(Bool())
})
val first = Module(new And2)
val second = Module(new And2)
first.io.a := io.a
first.io.b := io.b
second.io.a := first.io.y
second.io.b := io.c
io.y := second.io.y
}| 코드 | 의미 |
|---|---|
val first = Module(new And2) | first라는 이름의 And2 인스턴스를 만듭니다 |
first.io.a := io.a | 상위 모듈의 입력으로 하위 모듈의 입력을 구동합니다 |
second.io.a := first.io.y | 한 하위 모듈의 출력을 다른 하위 모듈의 입력에 연결합니다 |
io.y := second.io.y | 하위 모듈의 출력으로 상위 모듈의 출력을 구동합니다 |
주의. Module(...) 없이 val m = new And2만 쓰면 분리된 객체가 조용히 만들어지는 것이
아닙니다. elaboration이
ChiselException: Error: attempted to instantiate a Module without wrapping it in Module().
오류로 중단됩니다.
2.2 상위 모듈에서 본 포트 방향
상위 모듈은 하위 모듈의 내부를 볼 수 없고 child.io.field 형식으로 포트에만 접근합니다. 이때 포트
방향은 상위 모듈의 시점에서 해석합니다.
| 하위 모듈 포트 | 상위 모듈에서의 역할 |
|---|---|
Input | 상위 모듈이 반드시 구동해야 하는 대상(sink)입니다 |
Output | 상위 모듈이 읽을 수 있는 값(source)입니다 |
주의. when 장의 미연결 검사는 하위 모듈의 입력에도 적용됩니다. 어느 인스턴스의 입력 하나라도
구동하지 않으면 시뮬레이션 전에 FIRRTL 검사가 not fully initialized로 보고하며 실행을 멈춥니다.
상위 모듈의 포트는 Vec일 수 있습니다. 아래 예제의 io.in은 Vec(4, UInt(4.W)), 즉 4비트 값 넷을
담은 포트 하나이고, Scala 인덱스로 쓴 io.in(0)은 원소 0으로 가는 배선이며 elaboration 중에
결정됩니다. Scala 인덱스와
하드웨어 인덱스에서 설명한 규칙입니다.
3. 예제: 2입력 멀티플렉서 세 개로 4입력 멀티플렉서 만들기
Mux4는 Mux2 인스턴스 세 개를 2단으로 연결합니다. 선택 신호의 0번 비트는 각 쌍 안에서 고르고,
1번 비트는 두 쌍의 결과 가운데 하나를 고릅니다. 코드를 읽을 때는 다음 표의 연결을 확인하세요.
| 인스턴스 | sel 입력 | 데이터 입력 |
|---|---|---|
lo | io.sel(0) | in(0), in(1) |
hi | io.sel(0) | in(2), in(3) |
top | io.sel(1) | lo와 hi의 출력 |
Mux2 생성자 안의 println은 생성자가 실행될 때마다 한 번 출력되므로 만들어진 인스턴스 수를
셉니다.
import chisel3._
class Mux2 extends Module {
val io = IO(new Bundle {
val sel = Input(Bool())
val in0 = Input(UInt(4.W))
val in1 = Input(UInt(4.W))
val out = Output(UInt(4.W))
})
println("ELABORATE Mux2")
io.out := Mux(io.sel, io.in1, io.in0)
}
class Mux4 extends Module {
val io = IO(new Bundle {
val sel = Input(UInt(2.W))
val in = Input(Vec(4, UInt(4.W)))
val out = Output(UInt(4.W))
})
// 선택 신호의 하위 비트가 각 쌍 안에서 고른다.
val lo = Module(new Mux2)
lo.io.sel := io.sel(0)
lo.io.in0 := io.in(0)
lo.io.in1 := io.in(1)
val hi = Module(new Mux2)
hi.io.sel := io.sel(0)
hi.io.in0 := io.in(2)
hi.io.in1 := io.in(3)
// 선택 신호의 상위 비트가 두 쌍의 결과 중 하나를 고른다.
val top = Module(new Mux2)
top.io.sel := io.sel(1)
top.io.in0 := lo.io.out
top.io.in1 := hi.io.out
io.out := top.io.out
}테스트벤치는 in(0)부터 in(3)에 서로 다른 값 3, 5, 9, 12를 넣고, 선택 값 0–3을 차례로 인가하면서
expect로 출력을 검사합니다. expect는 값이 다르면 그 자리에서 테스트를 실패시킵니다.
import chisel3._
import chiseltest._
import chiseltest.RawTester.test
object Main extends App {
test(new Mux4) { dut =>
// 입력값이 모두 다르면 경로 오류가 모두 드러난다.
val data = Seq(3, 5, 9, 12)
for ((v, i) <- data.zipWithIndex) dut.io.in(i).poke(v.U)
for (sel <- 0 until 4) {
dut.io.sel.poke(sel.U)
dut.io.out.expect(data(sel).U)
println(s"SIMULATE sel=$sel out=${dut.io.out.peek().litValue}")
}
println("CHECK all 4 selections matched")
}
}이 개념 실습하기
다음 단계로 짧은 Chisel 문제에 이 개념을 적용해 보세요.
실행하기 전에 예측해 보세요.
ELABORATE Mux2줄은 몇 번 출력될까요?SIMULATE줄보다 먼저 나올까요, 나중에 나올까요?sel = 2(2진수10)일 때lo,hi,top은 각각 어느 입력을 통과시킬까요?- 네 입력값을 모두 다르게 고른 이유는 무엇일까요?
확인 문제
첫 SIMULATE 줄 앞에 ELABORATE Mux2 줄은 몇 개 나옵니까?
확인 문제
sel = 2, 이진수 10일 때 out은 무엇입니까?
결과 확인. 실행을 누른 뒤 다음 내용을 확인하세요.
| 출력 줄 | 확인할 내용 |
|---|---|
ELABORATE Mux2 | Module(new Mux2) 호출이 세 번이므로 정확히 세 줄이며, 모두 시뮬레이션보다 먼저 출력됩니다 |
SIMULATE sel=0–3 | 3, 5, 9, 12가 차례로 출력됩니다. sel = 2에서는 0번 비트가 0이라 hi가 in(2)를, 1번 비트가 1이라 top이 hi를 골라 9가 됩니다 |
CHECK | 네 선택 값의 expect 검사가 모두 통과했다는 표시입니다 |
입력값이 모두 다르므로 경로가 하나라도 틀리면 다른 숫자가 나타나고 expect가 실패합니다. 선택 값
네 개를 모두 검사하므로 이 경로 구성 전체를 빠짐없이 확인합니다.
직접 바꿔 보세요. 실행 패널에서 hi.io.sel := io.sel(0)을 io.sel(1)로 바꾸고 다시
실행하세요.
확인 문제
hi가 sel(1)로 선택하면 무슨 일이 생깁니까?
| 바꿀 곳 | 예상 결과 |
|---|---|
hi.io.sel := io.sel(0)을 io.sel(1)로 | sel이 0과 1일 때는 top이 lo를 고르므로 통과합니다. sel = 2에서 hi가 in(3)을 골라 chiseltest.FailedExpectException과 io_out=12 (0xc) did not equal expected=9 (0x9)로 멈춥니다 |
hi.io.in1 := io.in(3) 줄 삭제 | elaboration은 끝나지만 시뮬레이션 전에 FIRRTL 검사가 RefNotInitializedException으로 실행을 멈춥니다. 메시지는 Reference hi is not fully initialized이고 상세 줄에 hi.io.in1 <= VOID가 보입니다 |
두 변형은 검출되는 단계가 다릅니다. 첫 번째는 모든 포트가 연결되어 있지만 하나가 잘못된 신호에 연결된 경우라서 테스트벤치만 찾아낼 수 있습니다. 두 번째는 하위 모듈의 입력을 비워 둔 경우라서 상위 모듈의 출력을 비워 둔 것과 같은 미연결 오류가 됩니다.
4. 매개변수화된 모듈
4.1 생성자 매개변수
모듈 클래스는 일반 Scala 생성자 매개변수를 받을 수 있습니다. 매개변수는 elaboration 중에 생성자가
실행될 때 읽히므로 포트 폭, 내부 구조, 허용 범위를 정하는 데 쓸 수 있습니다.
형식
class 모듈_이름(매개변수: 타입, ...) extends Module {
// 매개변수로 포트와 회로를 구성합니다
}
val 인스턴스_이름 = Module(new 모듈_이름(인자, ...))다음 Inc는 width비트 값에 1을 더합니다.
class Inc(width: Int) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(width.W))
val out = Output(UInt(width.W))
})
io.out := io.in + 1.U
}
val inc4 = Module(new Inc(4))
val inc8 = Module(new Inc(8))Inc(4)와 Inc(8)은 포트 폭이 다른 하드웨어이므로 출력된 회로에는 별도의 모듈 정의 두 개가
생깁니다. 이름을 따로 정하지 않으면 두 정의는 Inc와 Inc_1이 됩니다.
4.2 require로 매개변수 검사하기
require(조건, 메시지)는 일반 Scala 문장입니다. 조건이 거짓이면 IllegalArgumentException을
발생시키므로 하드웨어가 만들어지기 전에 잘못된 구성을 막습니다.
형식
require(조건, "오류 메시지")class Inc(width: Int) extends Module {
require(width >= 1, s"Inc width must be positive, got $width")
// ...
}4.3 desiredName으로 모듈 정의 이름 정하기
desiredName을 재정의하면 출력되는 모듈 정의의 이름을 바꿀 수 있습니다. 매개변수를 이름에 넣으면
생성된 Verilog에서 어느 정의가 어떤 구성인지 바로 알 수 있습니다.
형식
override def desiredName = s"이름$매개변수"| 구문 | 역할 |
|---|---|
override def | Module이 제공하는 desiredName을 재정의합니다 |
s"..." | Scala 문자열 보간입니다. $width가 매개변수 값으로 바뀝니다 |
5. 예제: 폭이 다른 포화 덧셈기
SatAdd(width)는 결과가 최댓값을 넘으면 최댓값으로 고정하는 포화 덧셈기입니다. 코드를 읽을 때 다음
네 줄에 주목하세요.
| 코드 | 역할 |
|---|---|
require(width >= 2, ...) | 매개변수가 범위를 벗어나면 하드웨어 생성 전에 예외를 발생시킵니다 |
io.a +& io.b | width + 1비트 합을 만듭니다. 최상위 비트 full(width)가 자리올림입니다 |
((1 << width) - 1).U(width.W) | 최댓값을 Scala가 계산합니다 |
override def desiredName | 모듈 정의 이름을 SatAdd4, SatAdd8처럼 정합니다 |
Top은 SatAdd(4)와 SatAdd(8)을 인스턴스화하고, 4비트 인스턴스에는 각 입력의 하위 4비트만
전달합니다.
import chisel3._
class SatAdd(width: Int) extends Module {
// Scala가 하드웨어를 만들기 전에 매개변수를 검사한다.
require(width >= 2, s"SatAdd width must be at least 2, got $width")
val io = IO(new Bundle {
val a = Input(UInt(width.W))
val b = Input(UInt(width.W))
val sum = Output(UInt(width.W))
})
override def desiredName = s"SatAdd$width"
// +&는 올림 비트를 유지하므로 오버플로를 확인할 수 있다.
val full = io.a +& io.b
val max = ((1 << width) - 1).U(width.W)
io.sum := Mux(full(width), max, full(width - 1, 0))
}
class Top extends Module {
val io = IO(new Bundle {
val a = Input(UInt(8.W))
val b = Input(UInt(8.W))
val narrow = Output(UInt(4.W))
val wide = Output(UInt(8.W))
})
val add4 = Module(new SatAdd(4))
val add8 = Module(new SatAdd(8))
// 4비트 인스턴스는 각 입력의 하위 4비트만 받는다.
add4.io.a := io.a(3, 0)
add4.io.b := io.b(3, 0)
add8.io.a := io.a
add8.io.b := io.b
io.narrow := add4.io.sum
io.wide := add8.io.sum
}테스트벤치는 먼저 Top을 CHIRRTL로 출력해 모듈 정의 이름을 MODULE 줄로 표시하고, 이어서 입력 세
쌍을 시뮬레이션합니다.
import chisel3._
import chiseltest._
import chiseltest.RawTester.test
object Main extends App {
// 생성기가 만든 모듈 정의 목록을 출력한다.
val chirrtl = circt.stage.ChiselStage.emitCHIRRTL(new Top)
val ModuleLine = """\s*(?:public )?module (\w+) :.*""".r
for (ModuleLine(name) <- chirrtl.split("\n")) println(s"MODULE $name")
test(new Top) { dut =>
for ((a, b) <- Seq((3, 4), (9, 8), (200, 100))) {
dut.io.a.poke(a.U)
dut.io.b.poke(b.U)
val n = dut.io.narrow.peek().litValue
val w = dut.io.wide.peek().litValue
println(s"SIMULATE a=$a b=$b narrow=$n wide=$w")
}
}
}실행하기 전에 예측해 보세요.
MODULE줄에는 어떤 이름이 몇 개 출력될까요?- 다음 표의 빈칸을 채워 보세요.
a, b | 4비트 인스턴스가 보는 값 | narrow | wide |
|---|---|---|---|
| 3, 4 | 3, 4 | ? | ? |
| 9, 8 | 9, 8 | ? | ? |
| 200, 100 | 하위 4비트 8, 4 | ? | ? |
확인 문제
a = 9, b = 8일 때 narrow와 wide는 무엇을 보입니까?
결과 확인.
| 출력 줄 | 확인할 내용 |
|---|---|
MODULE | SatAdd4, SatAdd8, Top 세 이름이 출력됩니다. Scala 클래스는 하나지만 생성자가 서로 다른 하드웨어를 만들었으므로 정의가 둘입니다 |
a=3 b=4 | 두 인스턴스 모두 넘치지 않으므로 7과 7입니다 |
a=9 b=8 | 합 17은 4비트 최댓값 15를 넘으므로 narrow는 15, wide는 17입니다 |
a=200 b=100 | 4비트 인스턴스는 8 + 4 = 12를 계산합니다. 8비트 인스턴스는 300이 255를 넘으므로 255입니다 |
직접 바꿔 보세요.
| 바꿀 곳 | 예상 결과 |
|---|---|
desiredName 줄 삭제 | 정의 이름이 SatAdd, SatAdd_1이 됩니다. Chisel은 두 정의를 숫자 suffix로만 구분합니다 |
Top에 Module(new SatAdd(8)) 인스턴스를 하나 더 추가하고 입력 연결 | 목록에 SatAdd8과 SatAdd8_1이 나타납니다. CHIRRTL에는 elaboration한 인스턴스마다 정의가 하나씩 출력되고 겹치는 이름은 고유하게 바뀝니다 |
new SatAdd(4)를 new SatAdd(1)로 | 아무것도 출력되지 않고 java.lang.IllegalArgumentException: requirement failed: SatAdd width must be at least 2, got 1이 발생합니다. 스택 추적은 Top의 Module(new SatAdd(1)) 줄을 가리킵니다 |
require는 그 인스턴스의 포트가 만들어지기 전에 실행되므로 잘못된 구성은 회로 검사 단계까지 가지
않습니다. 매개변수가 들어오는 곳에서 검사하면, 원인에서 멀리 떨어진 곳에서 폭 오류를 추적하는 일을
막을 수 있습니다.
6. 하드웨어를 반환하는 함수
6.1 def는 호출될 때마다 하드웨어를 만듭니다
본문에서 Chisel 연산자를 사용하는 Scala 함수는 호출할 때마다 하드웨어를 만듭니다. 새 모듈도 포트도 필요 없는 가장 가벼운 재사용 방법입니다.
형식
def 이름(인자): 반환_타입 = {
// Chisel 식
}def max2(a: UInt, b: UInt): UInt = Mux(a > b, a, b)
io.maxv := max2(io.x, 50.U) // 비교기 하나와 Mux 하나
io.maxw := max2(io.y, io.z) // 별도의 비교기와 Mux가 하나 더| 구성 요소 | 의미 |
|---|---|
def max2(a: UInt, b: UInt): UInt | 하드웨어를 받아 하드웨어를 반환하는 Scala 함수 |
본문 Mux(a > b, a, b) | elaboration 중에 실행되어 비교기와 Mux를 추가합니다 |
| 각 호출 | 하드웨어 사본을 새로 추가합니다. 호출 사이에 공유하지 않습니다. firtool과 합성 도구가 뒤에 같은 논리를 합칠 수는 있습니다 |
x = 197이면 max2(io.x, 50.U)는 197이고, x = 3이면 50입니다. 7절의 예제처럼 Scala 튜플을
사용하면 함수가 노드 여러 개를 한 번에 반환할 수도 있습니다.
참고. 하드웨어 함수는 소프트웨어 서브루틴처럼 호출할 때 그 코드로 이동하는 방식이 아닙니다. 열
번 호출하면 게이트 사본이 열 벌 생깁니다. 생성된 설계에서 따로 보이는 이름 있는 블록이 필요하면
8절에서 다루는 Module을 사용하세요.
6.2 생성기 읽기: for와 def
첫 장에서 for (i <- 1 to stages)를 보았습니다. Scala
범위 1 to 3은 1, 2, 3이고 0 until 3은 0, 1, 2입니다. Scala 함수는
def 이름(인자: 타입, ...): 반환타입 = { 본문 }으로 선언합니다. 본문은 호출될 때 실행되고, 본문이
Chisel 연산자를 쓰면 호출마다 그 하드웨어의 새 복사본이 추가됩니다.
형식
def addTimes(in: UInt, terms: Int): UInt = {
var x: UInt = in
for (i <- 1 to terms) {
x = x + in // 반복마다 덧셈기 하나
}
x // in * (terms + 1)
}반복문과 함수 안의 코드는 시뮬레이션 중에 실행되지 않습니다. 생성기를 읽는 방법은 만들어진 것을
세는 것입니다. 실습 문제 gain-stages는 덧셈기마다 ELABORATE 줄을 하나 출력하는데, 반복문의
상한이 잘못된 매개변수를 읽습니다.
확인 문제
시작 코드의 반복문은 for (i <- 1 to bias)입니다. gain(io.in, 2, 3)은 doubling 줄을 몇 개 출력합니까?
7. 예제: 함수로 만든 리플 캐리 덧셈기
Ripple.scala는 리플 캐리 덧셈기를 만듭니다. fullAdd는 Bool 세 개를 받아 합과 자리올림 Bool
노드를 Scala 튜플로 반환하며, 호출할 때마다 println을 실행합니다. Ripple은 Scala for로
비트마다 fullAdd를 한 번씩 호출하고, Scala var로 자리올림을 다음 비트에 넘긴 뒤, Cat으로 최종
자리올림과 합 비트를 연결합니다.
import chisel3._
import chisel3.util.Cat
object Logic {
// 호출할 때마다 구성 중인 모듈에 전가산기가 하나씩 추가된다.
def fullAdd(a: Bool, b: Bool, cin: Bool): (Bool, Bool) = {
println("ELABORATE fullAdd")
val sum = a ^ b ^ cin
val cout = (a && b) || (a && cin) || (b && cin)
(sum, cout)
}
}
class Ripple(width: Int) extends Module {
val io = IO(new Bundle {
val a = Input(UInt(width.W))
val b = Input(UInt(width.W))
val sum = Output(UInt((width + 1).W))
})
// carry는 가장 최근 자리올림 노드를 가리키는 Scala var다.
var carry = false.B
val bits = for (i <- 0 until width) yield {
val (s, c) = Logic.fullAdd(io.a(i), io.b(i), carry)
carry = c
s
}
// 뒤집으면 비트 0이 마지막에 오므로 Cat이 최하위 비트에 배치한다.
io.sum := Cat(carry +: bits.reverse)
}import chisel3._
import chiseltest._
import chiseltest.RawTester.test
object Main extends App {
test(new Ripple(4)) { dut =>
for ((a, b) <- Seq((3, 5), (9, 7), (15, 15))) {
dut.io.a.poke(a.U)
dut.io.b.poke(b.U)
println(s"SIMULATE a=$a b=$b sum=${dut.io.sum.peek().litValue}")
}
}
}실행하기 전에 예측해 보세요.
Ripple(4)는ELABORATE줄을 몇 개 출력할까요?- 그 가운데
SIMULATE줄 사이에 나오는 줄이 있을까요? 9 + 7의sum은 무엇일까요?
확인 문제
Ripple(4)는 ELABORATE 줄을 몇 개, 어디에 출력합니까?
결과 확인. fullAdd는 elaboration 중에 네 번 실행되므로 ELABORATE 네 줄이 먼저 나오고
이후에는 나오지 않습니다. 모든 poke가 같은 전가산기 네 개를 사용합니다. 9 + 7, 즉
1001 + 0111을 비트별로 추적하세요.
| 비트 | a, b, 입력 자리올림 | 합 | 출력 자리올림 |
|---|---|---|---|
| 0 | 1, 1, 0 | 0 | 1 |
| 1 | 0, 1, 1 | 0 | 1 |
| 2 | 0, 1, 1 | 0 | 1 |
| 3 | 1, 0, 1 | 0 | 1 |
Cat이 최종 자리올림을 최상위에 두므로 sum은 10000, 10진수로 16입니다. 나머지 줄은
3 + 5 = 8과 15 + 15 = 30이며, 5비트 출력은 가장 큰 경우도 담을 수 있습니다.
직접 바꿔 보세요.
fullAdd에carry대신false.B를 전달하세요. 호출은 여전히 네 번이지만 어느 전가산기도 이전 자리올림을 받지 못합니다. 합 비트는 각각a ^ b가 되고 비트 3의 자리올림만 출력에 남으므로3 + 5는 6,9 + 7은 14,15 + 15는 16이 됩니다. 함수 본문은 그대로이고 호출을 연결하는 방식만 회로를 바꿨습니다.- 테스트벤치를
new Ripple(8)로 바꾸세요.ELABORATE줄이 여덟 개가 되고 합은 그대로입니다. 폭이 Scala 매개변수이므로 같은 소스가 다른 회로를 만듭니다.
8. companion apply로 모듈 생성하기
8.1 함수처럼 호출하는 모듈
인스턴스를 만들 때마다 m.io.a := ...; m.io.b := ...를 쓰면 코드가 길어집니다. 모듈이 사실상
입력의 함수라면, 같은 이름의 companion object에 apply를 정의해 인스턴스화와 입력 연결을 맡기고
출력 포트를 반환할 수 있습니다.
형식
object 모듈_이름 {
def apply(인자: 타입, ...): 반환_타입 = {
val m = Module(new 모듈_이름)
m.io.입력 := 인자
m.io.출력
}
}object And2 {
def apply(a: Bool, b: Bool): Bool = {
val m = Module(new And2)
m.io.a := a
m.io.b := b
m.io.y
}
}
io.y := And2(And2(io.a, io.b), io.c) // And2 인스턴스 두 개| 코드 | 의미 |
|---|---|
object And2 | 클래스 And2와 이름이 같은 companion object입니다 |
def apply(...) | And2(x, y)처럼 객체 이름 뒤에 괄호를 쓰면 호출되는 메서드입니다 |
val m = Module(new And2) | 호출할 때마다 인스턴스 하나를 만듭니다 |
m.io.y | 출력 포트를 반환하므로 호출 결과를 식 안에서 바로 쓸 수 있습니다 |
주의. And2(x, y)는 식처럼 보이지만 호출할 때마다 Module(new And2)가 실행됩니다. 같은 식을
두 번 쓰면 인스턴스도 두 개 생깁니다.
8.2 예제: 최댓값 트리
Max2는 두 8비트 값 가운데 큰 값을 출력하고, companion object의 apply가 인스턴스화와 입력 연결을
맡습니다. Max4는 Max2 호출 세 개를 중첩해 2단 트리를 한 줄로 기술합니다.
import chisel3._
class Max2 extends Module {
val io = IO(new Bundle {
val a = Input(UInt(8.W))
val b = Input(UInt(8.W))
val out = Output(UInt(8.W))
})
println("ELABORATE Max2")
io.out := Mux(io.a > io.b, io.a, io.b)
}
// companion apply가 포트 연결을 함수 호출 안에 감춘다.
object Max2 {
def apply(a: UInt, b: UInt): UInt = {
val m = Module(new Max2)
m.io.a := a
m.io.b := b
m.io.out
}
}
class Max4 extends Module {
val io = IO(new Bundle {
val in = Input(Vec(4, UInt(8.W)))
val out = Output(UInt(8.W))
})
// Max2(...)를 호출할 때마다 모듈이 하나씩 인스턴스화된다.
io.out := Max2(Max2(io.in(0), io.in(1)), Max2(io.in(2), io.in(3)))
}테스트벤치는 입력 벡터 세 개를 인가합니다. 세 번째 벡터 50, 50, 10, 10에는 같은 값이 있습니다.
import chisel3._
import chiseltest._
import chiseltest.RawTester.test
object Main extends App {
test(new Max4) { dut =>
for (vs <- Seq(Seq(4, 9, 2, 7), Seq(1, 2, 3, 200), Seq(50, 50, 10, 10))) {
for ((v, i) <- vs.zipWithIndex) dut.io.in(i).poke(v.U)
println(s"SIMULATE in=${vs.mkString(",")} out=${dut.io.out.peek().litValue}")
}
}
}실행하기 전에 예측해 보세요.
ELABORATE Max2는 몇 줄 출력될까요? 코드 줄 수가 아니라 호출 횟수로 세어 보세요.- 세 입력 벡터의 출력은 각각 무엇일까요?
50과50을 비교하면Mux는a와b가운데 어느 쪽을 고를까요?
확인 문제
Max4는 ELABORATE Max2 줄을 몇 개 출력합니까?
결과 확인.
| 출력 줄 | 확인할 내용 |
|---|---|
ELABORATE Max2 | 호출이 세 번이므로 세 줄입니다. Scala는 안쪽 호출 두 개를 먼저 평가하고, 바깥 인스턴스는 두 출력 포트를 인자로 받습니다 |
SIMULATE 세 줄 | 출력은 9, 200, 50입니다 |
| 같은 값 비교 | >가 거짓이므로 Mux가 b를 고릅니다. 두 값이 같으므로 결과도 같습니다 |
직접 바꿔 보세요.
| 바꿀 곳 | 예상 결과 |
|---|---|
식을 io.in.reduce(Max2(_, _))로 | 출력값과 인스턴스 수 3은 그대로입니다. 구조는 Max2(Max2(Max2(in0, in1), in2), in3)인 3단 체인이 됩니다 |
위 식에서 포트를 Vec(8, UInt(8.W))로 바꾸고 테스트벤치가 값 8개를 인가 | reduce 줄은 그대로인데 ELABORATE Max2가 일곱 줄 출력됩니다. Scala 컬렉션 메서드가 elaboration 중에 Vec 길이로 인스턴스 수를 결정합니다 |
8.3 하위 모듈, Scala 함수, companion apply
companion apply는 인스턴스화를 기술하는 방식을 바꿀 뿐 생성되는 회로는 바꾸지 않습니다. 모듈
경계는 생성된 계층에 그대로 남습니다. 반면 Mux를 반환하는 일반 Scala def는 모듈 경계를 만들지
않고 호출한 모듈 안에 논리를 직접 추가합니다.
| 선택 | 적합한 경우 |
|---|---|
| 하위 모듈 직접 인스턴스화 | 여러 곳에서 재사용하는 블록, 따로 검증할 단위, 상태를 가진 블록, 출력이 여러 개인 블록 |
| 하드웨어를 반환하는 Scala 함수 | 작은 식. 모듈 경계가 이름만 늘리는 경우 |
companion apply | 모듈 경계는 유지하면서 식 형태로 호출하려는 경우. 출력이 하나인 모듈에 적합합니다 |
생성된 계층은 이후 도구에 모듈 경계와 파형의 scope를 제공합니다. 다만 최적화나 평탄화 과정에서 경계가 사라질 수 있으며, Chisel 모듈이 자동으로 물리 설계의 배치 단위가 되는 것은 아닙니다.
9. 자주 하는 실수
9.1 Module(...) 없이 new만 쓰기
// 잘못된 코드: 모듈 객체를 Module로 감싸지 않음
val m = new And2
// 바른 코드
val m = Module(new And2)하위 모듈 객체는 Module(...)이 등록해야 계층에 들어갑니다. new만 쓰면 elaboration이
attempted to instantiate a Module without wrapping it in Module() 오류로 중단됩니다.
9.2 하위 모듈의 입력 하나를 구동하지 않기
// 잘못된 코드: second.io.b를 연결하지 않음
second.io.a := first.io.y
io.y := second.io.y
// 바른 코드
second.io.a := first.io.y
second.io.b := io.c
io.y := second.io.y하위 모듈의 입력은 상위 모듈 안에서 구동해야 하는 대상입니다. 하나라도 비워 두면 FIRRTL 검사가
RefNotInitializedException과 Reference second is not fully initialized를 보고합니다. 상세 줄에
<= VOID로 표시된 포트가 빠진 연결입니다.
9.3 같은 apply 호출을 두 번 쓰고 하나로 공유된다고 생각하기
// 잘못된 코드: Max2 인스턴스가 두 개 생김
io.out1 := Max2(io.a, io.b)
io.out2 := Max2(io.a, io.b)
// 바른 코드: 한 번 호출한 결과를 재사용
val m = Max2(io.a, io.b)
io.out1 := m
io.out2 := mapply는 호출할 때마다 Module(...)을 실행합니다. 같은 결과를 여러 곳에서 쓰려면 호출 결과를
val에 저장하고 그 값을 연결하세요.
9.4 매개변수가 다른 인스턴스의 이름을 정하지 않기
// 잘못된 코드: 정의 이름이 Inc, Inc_1이 됨
class Inc(width: Int) extends Module { /* ... */ }
// 바른 코드: 정의 이름이 Inc4, Inc8이 됨
class Inc(width: Int) extends Module {
override def desiredName = s"Inc$width"
/* ... */
}오류는 아니지만 생성된 Verilog와 파형에서 Inc_1이 어느 구성인지 알 수 없습니다. 매개변수가 구조를
바꾸는 모듈에는 매개변수를 넣은 desiredName을 두세요.
10. 적용: 정렬 네트워크 완성하기
아래 채점 실습 문제는 Sort2 하위 모듈로 세 값 정렬기를 만듭니다. 인스턴스 둘이 이미 있고, 그 뒤에
최댓값은 정해지지만 남은 두 후보는 서로 다른 하위 모듈에서 나와 한 번도 비교되지 않았습니다.
Sort2를 하나 더 놓고 두 입력을 모두 구동한 뒤 그 출력에서 min과 mid를 받으세요. 완성된 설계에
들어 있는 Sort2 인스턴스 수를 3절에서 ELABORATE 줄을 센 방식으로 세어 보세요.
hierarchy 예제와 연결된 두 번째 문제에서는 네 레인의 절댓값 차이가 모두 같은 값으로 출력되는
원인을 찾아 각 레인이 자기 값을 출력하게 합니다. 두 문제 모두 예측한 ELABORATE 줄 수와 실제
출력을 비교하면서 풀어 보세요. 6절의 gain-stages 문제는 생성기 반복문에 같은 질문을 던집니다.
doubling 줄을 세고, 반복문이 무시하는 매개변수를 찾으세요.
11. 정리
| 주제 | 핵심 |
|---|---|
| 모듈 정의 | Module을 상속한 클래스이며 생성자가 포트와 회로를 기술합니다. 암묵적인 clock과 reset이 있습니다 |
| 인스턴스화 | Module(new ...) 호출 하나가 인스턴스 하나를 만듭니다. new만 쓰면 오류입니다 |
| 포트 연결 | 상위 모듈은 child.io.field로 접근하며 하위 모듈의 모든 입력을 구동해야 합니다 |
| 매개변수 | Scala 값이며 elaboration 중 구조를 결정합니다. 구조가 다른 인스턴스는 다른 모듈 정의가 됩니다 |
require, desiredName | 잘못된 매개변수를 하드웨어 생성 전에 막고, 모듈 정의 이름을 매개변수로 정합니다 |
| 함수 | for (i <- a to b)와 def f(...): T = { ... }는 elaboration 시점의 반복과 재사용입니다. 하드웨어를 만드는 함수는 호출할 때마다 사본을 새로 추가하므로 만든 것을 셉니다 |
companion apply | 인스턴스화와 입력 연결을 함수 호출로 묶습니다. 호출할 때마다 인스턴스가 하나씩 생깁니다 |
12. 확인 문제
-
테스트벤치가 몇 사이클을 시뮬레이션하든
ELABORATE Mux2가 정확히 세 번 출력되는 이유는 무엇일까요?정답 보기
println은 생성자 안에 있고, 생성자는elaboration중Module(new Mux2)가 실행될 때만 실행됩니다.Mux4에는 그 호출이 세 번 있습니다. 시뮬레이션은 이미 만들어진 회로를 실행할 뿐 생성자를 다시 실행하지 않습니다. -
잘못된 신호에 연결된 하위 모듈 입력과 아무 데도 연결되지 않은 하위 모듈 입력은 각각 어느 단계에서 검출될까요?
정답 보기
잘못 연결된 입력은 구조상 문제가 없으므로 시뮬레이션에서 테스트벤치의
expect가 검출합니다. 연결되지 않은 입력은 시뮬레이션 전 FIRRTL 검사에서RefNotInitializedException으로 검출됩니다. -
SatAdd(4)와SatAdd(8)이 별도 모듈 정의가 되는 이유는 무엇이며,desiredName이 없으면 이름은 무엇이 될까요?정답 보기
두 생성자 실행이 포트 폭이 다른 하드웨어를 만들었기 때문에 정의가 둘이 됩니다.
desiredName이 없으면 클래스 이름을 기준으로SatAdd와SatAdd_1이 됩니다. -
Ripple(4)의elaboration이 끝나면 전가산기는 몇 개 있을까요? 테스트벤치가 입력 쌍 열 개를 인가한 뒤에는 몇 개일까요?정답 보기
두 경우 모두 네 개입니다. 함수는
elaboration중에만 실행되고, 시뮬레이션 중의 입력 인가는 하드웨어를 만들지 않습니다. -
길이
n인Vec에io.in.reduce(Max2(_, _))를 적용하면 인스턴스는 몇 개이며, 그 수는 언제 결정될까요?정답 보기
reduce는 원소n개를n - 1번 결합하므로 인스턴스는n - 1개입니다. 이 수는elaboration중 Scala가reduce를 실행할 때 결정됩니다. -
Max2(io.a, io.b)를 두 출력에 각각 한 번씩 쓰면 인스턴스는 몇 개 생길까요?정답 보기
두 개입니다.
apply는 호출할 때마다Module(new Max2)를 실행합니다. 인스턴스 하나를 공유하려면 결과를val에 저장해 두 출력에 연결합니다.