5 组合电路基本单元 (Combinational Building Blocks)
在这一章中,我们将探讨不同的组合电路——这些是构建复杂系统的基本单元。原则上,所有的组合电路都可以用布尔表达式来描述,但在实际设计中,使用表格形式(如 查找表)更加高效。综合工具通常会自动提取并优化布尔表达式。
本章将介绍两类重要的组合电路:
- 译码器(Decoder)
- 编码器(Encoder)
5.1 组合电路 (Combinational Circuits)
在介绍具体的组合电路之前,先简要说明如何使用 Chisel 表达组合逻辑。最简单的形式是布尔表达式,例如:
val e = (a & b) | c
这里 e
是布尔表达式,通过将其赋值给 Scala 变量定义了一个名称。你可以将这个表达式重用于其他表达式:
val f = ~e
这种表达式是固定的(即只读)。尝试重新赋值给 e
会导致编译器报错。 如果需要可更新的信号,可以使用 Wire 类型并结合 :=
运算符进行赋值。例如:
val e = Wire(UInt())
e := c & b
条件赋值:when
和 elsewhen
Chisel 提供了 when 语句来描述条件逻辑,类似于硬件中的多路复用器(Multiplexer)。 以下示例声明了一个类型为 UInt
的 Wire w
,并根据条件进行赋值:
val w = Wire(UInt())
w := 0.U
when (cond) {
w := 3.U
}
上面的逻辑实现了一个 2:1 选择器,其中输入为 0
和 3
,条件 cond
作为选择信号。 多级条件赋值 可以使用 .elsewhen 和 .otherwise 进行扩展,如下所示:
val w = Wire(UInt())
when (cond) {
w := 1.U
} .elsewhen (cond2) {
w := 2.U
} .otherwise {
w := 3.U
}
这里,条件 cond 的优先级最高,其次是 cond2,最后执行 otherwise。
when
:条件为 true 时执行。elsewhen
:作为补充条件,优先级低于when
。otherwise
:所有条件都不满足时的默认执行。
注:
- 该链式结构在硬件中构建为 一系列多路复用器(Multiplexers),如 图 5.1 所示。
默认值赋给 Wire
在更复杂的条件赋值场景中,最好先为 Wire 设置一个默认值,再进行条件赋值,避免在某些条件下出现未定义行为。Chisel 提供了 WireDefault 方法:
val w = WireDefault(0.U)
when (cond) {
w := 3.U
}
// ... 其他更复杂的条件赋值
为什么不用 Scala 的 if-else?
你可能会问,为什么使用 when、elsewhen 和 otherwise,而不是直接使用 Scala 的 if
、else if
和 else
?
- Scala 的
if
语句是 程序执行 的控制流,不会生成硬件。 - Chisel 的 when 等语句是为描述硬件而设计的,它们会被映射为 组合逻辑(如多路复用器)。
因此,使用 when 能够明确表达组合逻辑,而非程序的条件执行。
示例:链式多路复用器
下面的代码实现了一个优先级多路复用器链:
val w = Wire(UInt())
when (cond) {
w := 1.U
} .elsewhen (cond2) {
w := 2.U
} .otherwise {
w := 3.U
}
在这个示例中:
- 如果
cond
为 true,w
被赋值为1.U
。 - 如果
cond2
为 true(且cond
为 false),w
被赋值为2.U
。 - 如果都不满足,
w
被赋值为3.U
。
这种链式结构的硬件实现等价于一系列多路复用器的串联。
组合逻辑的复杂条件控制
使用 switch
和 is
在某些场景下,如果条件链仅依赖于单一信号,使用 switch/is 语句会更简单高效。 下面的代码展示了一个典型的 ALU 选择器,它根据输入信号 fn
选择不同的操作:
val y = WireDefault(0.U)
switch(fn) {
is(0.U) { y := a + b } // 加法
is(1.U) { y := a - b } // 减法
is(2.U) { y := a | b } // 按位或
is(3.U) { y := a & b } // 按位与
}
switch/is
特点:
switch
指定要检查的单一信号。is
语句指定信号的具体值及其对应的操作。- 生成的硬件是 一个多路复用器,选择输出
y
的值。
选择 when/elsewhen
还是 switch/is
?
when/elsewhen
适用于条件复杂、独立的情况。switch/is
更适合条件依赖于单一信号的多路选择场景。
通过这一节的内容,你已经学会了如何在 Chisel 中高效地描述组合逻辑电路,包括条件选择、链式多路复用器和 switch
控制语句。这些技巧是构建复杂硬件系统的基础模块。
5.2 译码器 (Decoder)
译码器 将一个二进制数 (由 n 位表示) 转换成一个 m 位信号,其中1
,其余位为 0
。
例如,图 5.2 显示了一个 2 位输入到 4 位输出的译码器。该译码器的功能可以使用 真值表 来表示,如下所示:
输入 | 输出 |
---|---|
00 | 0001 |
01 | 0010 |
10 | 0100 |
11 | 1000 |
实现译码器:使用 switch
语句
Chisel 提供了 switch 语句,可以非常清晰地描述译码器的逻辑,类似于一张真值表。要使用 switch
语句,首先需要导入 chisel3.util
包:
import chisel3.util._
val result = WireDefault(0.U) // 初始化为 0
switch(sel) {
is(0.U) { result := 1.U }
is(1.U) { result := 2.U }
is(2.U) { result := 4.U }
is(3.U) { result := 8.U }
}
在上述代码中:
sel
信号 是输入的 2 位二进制选择信号。result
信号 是译码器的输出,使用WireDefault
将其初始化为0.U
,避免出现不完全赋值的情况。- 使用 switch/is 语句枚举了所有可能的输入值,并为每个输入分配了相应的输出。
注意:Chisel 要求所有组合逻辑电路必须有默认赋值,否则会推断出锁存器(Latches)。在上面的例子中,通过 WireDefault(0.U)
设置默认值,避免了锁存器的生成。
使用二进制字符串表示输入
在前面的例子中,我们使用了无符号整数 (如 0.U
、1.U
) 作为 sel
信号的输入值。在某些情况下,使用 二进制字符串 可以更清晰地表示输入,例如:
switch(sel) {
is("b00".U) { result := "b0001".U }
is("b01".U) { result := "b0010".U }
is("b10".U) { result := "b0100".U }
is("b11".U) { result := "b1000".U }
}
在这种表示方法中:
b
前缀表示 二进制 数值(如b00
、b01
)。- 输出值同样用二进制字符串表示,更直观地反映了 one-hot 编码的特点。
更高效的实现:使用位移操作 (Shift Operation)
观察译码器的真值表可以发现一个规律:对于输入信号 sel
,输出可以通过将 1
左移 sel
位 来实现。因此,译码器的逻辑可以简化为:
val result = 1.U << sel
解释:
1.U
表示初始值0001
。<<
是 左移操作,将1
向左移动sel
位。- 结果与真值表中的输出一致,例如:
- 当
sel = 0
,输出为0001
。 - 当
sel = 1
,输出为0010
。 - 当
sel = 2
,输出为0100
。 - 当
sel = 3
,输出为1000
。
- 当
译码器的应用场景
- 选择信号生成:译码器常用于产生 one-hot 信号,作为控制逻辑的输入信号。例如,作为多路复用器的选择信号或使能信号。
- 地址解码:在微处理器设计中,译码器可以用于将地址总线的一部分解码,生成存储器的选择信号。
- 控制信号生成:在复杂的控制单元中,译码器可以帮助生成不同的控制信号,驱动不同的操作模块。
Chisel 提供的内置 Mux 模块可以替代手动构建的多路复用器,而译码器的输出通常作为 Mux 的 使能输入 或 选择信号。
通过上面的内容,我们详细了解了如何在 Chisel 中设计 译码器:
- 使用 switch 和 is 描述真值表。
- 采用 位移操作 提高代码效率。
- 探讨了译码器在硬件设计中的实际应用场景,如地址解码和控制信号生成。
这些技巧对于构建高效的硬件模块尤为重要,是后续复杂系统设计的基础。
5.3 编码器 (Encoder)
编码器 是 译码器 (Decoder) 的反向操作,它将 one-hot 编码 输入信号转换成一个 二进制编码 输出信号。例如,对于一个 4 位的 one-hot 编码输入,它可以生成一个 2 位的二进制输出。
图 5.3 展示了一个 4 位输入到 2 位输出的编码器。表 5.2 列出了输入和输出的真值表。
输入 (a) | 输出 (b) |
---|---|
0001 | 00 |
0010 | 01 |
0100 | 10 |
1000 | 11 |
???? | ?? |
需要注意的是,编码器只在输入信号是 one-hot 编码时才能正常工作。如果输入信号不符合 one-hot 编码的格式(例如多个位为 1
),输出将是未定义的。因此,通常我们会为编码器的输出提供一个默认赋值,来捕获所有未定义的输入模式。
Chisel 实现一个简单的编码器
我们可以使用 switch
和 is
语句实现编码器的逻辑,如下所示:
val b = WireDefault("b00".U) // 默认输出为 00
switch(a) {
is("b0001".U) { b := "b00".U } // 输入 a0,输出 00
is("b0010".U) { b := "b01".U } // 输入 a1,输出 01
is("b0100".U) { b := "b10".U } // 输入 a2,输出 10
is("b1000".U) { b := "b11".U } // 输入 a3,输出 11
}
代码说明
a
是输入信号,表示 4 位 one-hot 编码。b
是输出信号,初始化为00
,使用WireDefault
避免不完全赋值。switch
语句用于匹配输入
a
的合法值:- 如果输入是
0001
,输出为00
。 - 如果输入是
0010
,输出为01
。 - 如果输入是
0100
,输出为10
。 - 如果输入是
1000
,输出为11
。
- 如果输入是
扩展到更大的编码器
对于更大的编码器(如 16 位 one-hot 输入),手动编写 switch
语句会非常繁琐。为了自动化这个过程,我们可以借助 Scala 循环 来生成编码器的逻辑。
使用 Vec
和 Mux
进行生成
在编码器中,Vec
可以用于存储输出的索引值。我们通过循环遍历所有输入位,并使用 多路复用器 (Mux) 来检查每个位是否被激活:
val v = Wire(Vec(16, UInt(4.W))) // 16 位输入,4 位输出
v(0) := 0.U // 默认值为 0
for (i <- 1 until 16) {
v(i) := Mux(hotIn(i), i.U, 0.U) | v(i - 1)
}
val encOut = v(15) // 输出结果
代码逻辑
hotIn
信号 是输入的 one-hot 编码信号,16 位宽。v
是一个向量,包含了 16 个元素,每个元素的值对应输入位的位置索引:
- 如果输入
hotIn(i)
被激活 (即为1
),则输出为当前索引i.U
。 - 否则,输出为
0.U
。
- 如果输入
使用 OR 归约:通过
Mux
和|
操作将所有元素合并成一个输出值。最终输出
encOut
表示编码器的结果。
编码器的工作原理
编码器的实现基于以下原则:
- one-hot 输入:假设输入是 one-hot 编码,即只有一个位被激活。
- 位置索引:激活的位的索引值即为编码器的输出结果。
- OR 归约:对于多位输入,我们通过 OR 归约将激活的索引合并为单个输出。
总结与应用场景
编码器 的主要功能是将 one-hot 编码 转换成 二进制编码,广泛应用于以下场景:
- 优先编码器 (Priority Encoder):在多个输入中选择最高优先级的激活信号。
- 地址编码:将存储器的多位使能信号转换为地址信号。
- 中断处理:在中断控制器中识别最高优先级的中断请求。
通过使用 Scala 循环 和 Vec,我们可以高效地生成大规模的编码器逻辑,而无需手动编写繁琐的 switch
语句。这种方法使得 Chisel 在硬件描述方面更加灵活和强大。
5.4 仲裁器(Arbiter)
仲裁器 (Arbiter) 的作用是仲裁多个客户端对单一共享资源的请求,确保每次只授予一个请求。这种电路的典型应用场景包括多个处理器核心共享一个串行端口(UART)或总线访问。
图 5.4 展示了一个 4 位仲裁器的示意图,包含四个请求线 (r0 ~ r3) 和四个授予线 (g0 ~ g3)。仲裁器根据请求的优先级授予访问权。
优先级仲裁器(Priority Arbiter)
优先级仲裁器遵循固定的优先级规则,优先级越低的输入信号越优先。例如,输入 r0
拥有最高优先级,接下来依次是 r1
、r2
和 r3
。
Chisel 实现 3 位仲裁器
以下代码实现了一个 3 位的优先级仲裁器,使用了 Vec
和布尔逻辑来描述请求和授予信号:
val grant = VecInit(false.B, false.B, false.B) // 授予信号向量
val notGranted = VecInit(false.B, false.B, false.B) // 未授予信号向量
// 仲裁逻辑
grant(0) := request(0) // 授予 r0 的请求
notGranted(0) := !grant(0) // r0 没有授予
grant(1) := request(1) && notGranted(0) // 授予 r1 的请求(如果 r0 未授予)
notGranted(1) := !grant(1) && notGranted(0) // r1 未授予
grant(2) := request(2) && notGranted(1) // 授予 r2 的请求(如果 r0 和 r1 未授予)
代码说明
请求优先级顺序:
- 第一个请求
r0
直接被授予 (grant(0)
)。 - 第二个请求
r1
只有在r0
未被授予时才会被考虑 (notGranted(0)
)。 - 第三个请求
r2
依赖于r0
和r1
都未被授予 (notGranted(1)
)。
- 第一个请求
向量表示:
grant
是授予信号向量,用于表示仲裁结果。notGranted
是未授予信号向量,用于标记哪些请求未被授予。
图 5.5 显示了 4 位仲裁器的逻辑电路结构,使用了 与非门 和 与门 实现逻辑:
- 每个请求与前一个请求的未授予信号进行逻辑 AND,保证了优先级顺序。
g0
依赖于r0
,g1
依赖于r1
和notGranted(0)
,以此类推。
逻辑表描述小规模仲裁器
对于较小的仲裁器,我们可以直接用逻辑表实现:
val grant = WireDefault("b000".U(3.W)) // 默认输出全为 0
// 使用 switch 表述请求到授予信号的映射关系
switch (request) {
is ("b000".U) { grant := "b000".U } // 无请求
is ("b001".U) { grant := "b001".U } // 请求 0
is ("b010".U) { grant := "b010".U } // 请求 1
is ("b011".U) { grant := "b001".U } // 优先级较高的请求 0
is ("b100".U) { grant := "b100".U } // 请求 2
is ("b101".U) { grant := "b001".U } // 请求 0 优先
is ("b110".U) { grant := "b010".U } // 请求 1 优先
is ("b111".U) { grant := "b001".U } // 请求 0 优先
}
代码说明
- 输入信号
request
是请求向量,表示多个客户端的请求状态。 - 输出信号
grant
表示被授予的请求线索,每次只授予一个请求。
可扩展的仲裁器生成器
对于大规模仲裁器,我们可以使用 Scala 循环 生成逻辑,从而避免手动编写大量代码:
val n = 8 // 参数化:请求信号的数量
val grant = VecInit.fill(n)(false.B) // 初始化仲裁结果向量
val notGranted = VecInit.fill(n)(false.B) // 初始化未授予向量
// 初始赋值:处理第一个请求
grant(0) := request(0)
notGranted(0) := !grant(0)
// 通过 for 循环生成剩余的仲裁逻辑
for (i <- 1 until n) {
grant(i) := request(i) && notGranted(i-1) // 授予信号:当前请求且前一个未授予
notGranted(i) := !grant(i) && notGranted(i-1) // 未授予信号:当前未授予且前一个未授予
}
代码说明
n
表示仲裁器的规模(例如,4 位)。- 循环生成逻辑:通过
for
循环自动为每一位生成逻辑,避免手动重复代码。 - 未授予信号:
notGranted(i)
依赖于之前的未授予状态,确保请求按优先级顺序被仲裁。
总结与应用场景
仲裁器的主要功能是 仲裁多个请求,确保每次只授予一个请求。优先级仲裁器适用于以下场景:
- 总线仲裁:多个设备竞争访问共享总线时的仲裁逻辑。
- 中断请求仲裁:在中断控制器中,仲裁多个中断请求的优先级。
- 资源分配:例如处理器核心之间共享串行端口或内存。
通过 Chisel 的灵活性,我们可以轻松构建固定优先级仲裁器或扩展到动态仲裁器。对于大规模仲裁器,使用 Scala 循环 和向量生成器可以大大简化代码,提高设计的可扩展性和可读性。
5.5 优先编码器 (Priority Encoder)
在之前的编码器设计中,我们假设输入信号是 one-hot 编码 的,即只有一个位被置为 1
。如果多个输入位同时为 1
,传统编码器将导致未定义行为。这种情况在实际硬件设计中显然是不允许的。
为了解决这一问题,我们将编码器与仲裁电路(Arbiter)结合,确保只选择优先级最高的置位信号进行编码。这种组合生成了 优先编码器,其作用是输出最高优先级的信号位置。
图 5.6 展示了一个优先编码器的结构:
- 仲裁器 (Arbiter) 选择最高优先级的请求信号。
- 编码器 (Encoder) 将仲裁器输出的 one-hot 信号转换为二进制编码。
实现优先编码器的 Chisel 代码
class PriorityEncoder extends Module {
val io = IO(new Bundle {
val request = Input(UInt(4.W)) // 4位请求信号
val grant = Output(UInt(4.W)) // 仲裁结果,优先级最高的位
val encoded = Output(UInt(2.W)) // 编码结果
})
// 仲裁器逻辑:选择优先级最高的请求
val arbiter = WireDefault(0.U(4.W))
when(io.request(0)) { arbiter := "b0001".U }
.elsewhen(io.request(1)) { arbiter := "b0010".U }
.elsewhen(io.request(2)) { arbiter := "b0100".U }
.elsewhen(io.request(3)) { arbiter := "b1000".U }
io.grant := arbiter // 输出仲裁结果
// 编码器逻辑:对仲裁器输出进行编码
io.encoded := 0.U
switch(arbiter) {
is("b0001".U) { io.encoded := "b00".U }
is("b0010".U) { io.encoded := "b01".U }
is("b0100".U) { io.encoded := "b10".U }
is("b1000".U) { io.encoded := "b11".U }
}
}
代码解析
- 输入与输出:
request
:4 位输入请求信号。grant
:仲裁器的输出,指示哪个请求获得了优先级。encoded
:编码器的输出,表示优先级最高的请求的二进制位置。
- 仲裁器:
- 使用
when...elsewhen
控制语句,按照 优先级顺序 检查输入请求,选中第一个有效的请求。 - 输出信号
arbiter
具有独热编码格式(例如:0001
表示请求 0,0010
表示请求 1)。
- 使用
- 编码器:
- 通过
switch
语句对仲裁器输出的独热编码结果进行二进制编码。 b00
表示请求 0,b01
表示请求 1,以此类推。
- 通过
5.6 比较器 (Comparator)
比较器是一种简单的组合逻辑电路,用于比较两个多位输入信号,并输出比较结果。
图 5.7 展示了比较器的结构:
输入信号:
a
和b
是两个需要比较的信号。输出信号:
equ
表示a == b
。gt
表示a > b
。
Chisel 实现比较器
实现一个比较器在 Chisel 中非常简单,仅需两行代码:
val equ = a === b // 检查 a 和 b 是否相等
val gt = a > b // 检查 a 是否大于 b
说明
===
操作符:用于比较两个信号是否相等。>
操作符:用于判断左侧信号是否大于右侧信号。- 扩展:通过组合
equ
和gt
信号,可以实现所有的比较逻辑,例如a <= b
可以通过!gt
来表示。
5.7 练习
任务:设计一个 7 段显示器驱动电路
设计一个组合逻辑电路,将 4 位二进制输入 转换为 7 段显示器编码,以显示十进制数字或十六进制数字。
功能要求
- 输入:4 位二进制信号,用于表示十进制数字 (0-9) 或十六进制数字 (0-F)。
- 输出:7 段显示器的控制信号,每段对应于一个 LED 灯。
- 显示内容:可以定义为十进制数的编码,或者进一步扩展为显示十六进制数字 (0-9, A-F)。
硬件连接
- 输入:4 位开关或按键,连接到电路的输入端。
- 输出:7 个 LED 控制引脚,连接到 7 段显示器的输入端。
扩展目标
如果使用 FPGA 板,编写 Chisel 代码生成 Verilog,并将其加载到 FPGA 板上,使用实际硬件测试显示功能。