Skip to content

5 组合电路基本单元 (Combinational Building Blocks)

在这一章中,我们将探讨不同的组合电路——这些是构建复杂系统的基本单元。原则上,所有的组合电路都可以用布尔表达式来描述,但在实际设计中,使用表格形式(如 查找表)更加高效。综合工具通常会自动提取并优化布尔表达式。

本章将介绍两类重要的组合电路:

  1. 译码器(Decoder)
  2. 编码器(Encoder)

5.1 组合电路 (Combinational Circuits)

在介绍具体的组合电路之前,先简要说明如何使用 Chisel 表达组合逻辑。最简单的形式是布尔表达式,例如:

scala
val e = (a & b) | c

这里 e 是布尔表达式,通过将其赋值给 Scala 变量定义了一个名称。你可以将这个表达式重用于其他表达式:

scala
val f = ~e

这种表达式是固定的(即只读)。尝试重新赋值给 e 会导致编译器报错。 如果需要可更新的信号,可以使用 Wire 类型并结合 := 运算符进行赋值。例如:

scala
val e = Wire(UInt())
e := c & b

条件赋值:whenelsewhen

Chisel 提供了 when 语句来描述条件逻辑,类似于硬件中的多路复用器(Multiplexer)。 以下示例声明了一个类型为 UIntWire w,并根据条件进行赋值:

scala
val w = Wire(UInt())
w := 0.U

when (cond) {
  w := 3.U
}

上面的逻辑实现了一个 2:1 选择器,其中输入为 03,条件 cond 作为选择信号。 多级条件赋值 可以使用 .elsewhen.otherwise 进行扩展,如下所示:

scala
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:所有条件都不满足时的默认执行。

  1. 该链式结构在硬件中构建为 一系列多路复用器(Multiplexers),如 图 5.1 所示。

默认值赋给 Wire

在更复杂的条件赋值场景中,最好先为 Wire 设置一个默认值,再进行条件赋值,避免在某些条件下出现未定义行为。Chisel 提供了 WireDefault 方法:

scala
val w = WireDefault(0.U)

when (cond) {
  w := 3.U
}
// ... 其他更复杂的条件赋值

为什么不用 Scala 的 if-else?

你可能会问,为什么使用 whenelsewhenotherwise,而不是直接使用 Scala 的 ifelse ifelse

  • Scala 的 if 语句是 程序执行 的控制流,不会生成硬件。
  • Chisel 的 when 等语句是为描述硬件而设计的,它们会被映射为 组合逻辑(如多路复用器)。

因此,使用 when 能够明确表达组合逻辑,而非程序的条件执行。

示例:链式多路复用器

下面的代码实现了一个优先级多路复用器链:

scala
val w = Wire(UInt())

when (cond) {
  w := 1.U
} .elsewhen (cond2) {
  w := 2.U
} .otherwise {
  w := 3.U
}

在这个示例中:

  • 如果 condtruew 被赋值为 1.U
  • 如果 cond2true(且 condfalse),w 被赋值为 2.U
  • 如果都不满足,w 被赋值为 3.U

这种链式结构的硬件实现等价于一系列多路复用器的串联。

组合逻辑的复杂条件控制

使用 switchis

在某些场景下,如果条件链仅依赖于单一信号,使用 switch/is 语句会更简单高效。 下面的代码展示了一个典型的 ALU 选择器,它根据输入信号 fn 选择不同的操作:

scala
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 位信号,其中m2nm2n。译码器的输出是 独热编码(即 “one-hot” 编码),其中只有一位为 1,其余位为 0

例如,图 5.2 显示了一个 2 位输入到 4 位输出的译码器。该译码器的功能可以使用 真值表 来表示,如下所示:

输入输出
000001
010010
100100
111000

实现译码器:使用 switch 语句

Chisel 提供了 switch 语句,可以非常清晰地描述译码器的逻辑,类似于一张真值表。要使用 switch 语句,首先需要导入 chisel3.util 包:

scala
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 }
}

在上述代码中:

  1. sel 信号 是输入的 2 位二进制选择信号。
  2. result 信号 是译码器的输出,使用 WireDefault 将其初始化为 0.U,避免出现不完全赋值的情况。
  3. 使用 switch/is 语句枚举了所有可能的输入值,并为每个输入分配了相应的输出。

注意:Chisel 要求所有组合逻辑电路必须有默认赋值,否则会推断出锁存器(Latches)。在上面的例子中,通过 WireDefault(0.U) 设置默认值,避免了锁存器的生成。

使用二进制字符串表示输入

在前面的例子中,我们使用了无符号整数 (如 0.U1.U) 作为 sel 信号的输入值。在某些情况下,使用 二进制字符串 可以更清晰地表示输入,例如:

scala
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 前缀表示 二进制 数值(如 b00b01)。
  • 输出值同样用二进制字符串表示,更直观地反映了 one-hot 编码的特点。

更高效的实现:使用位移操作 (Shift Operation)

观察译码器的真值表可以发现一个规律:对于输入信号 sel,输出可以通过将 1 左移 sel 来实现。因此,译码器的逻辑可以简化为:

scala
val result = 1.U << sel

解释

  1. 1.U 表示初始值 0001
  2. <<左移操作,将 1 向左移动 sel 位。
  3. 结果与真值表中的输出一致,例如:
    • sel = 0,输出为 0001
    • sel = 1,输出为 0010
    • sel = 2,输出为 0100
    • sel = 3,输出为 1000

译码器的应用场景

  1. 选择信号生成:译码器常用于产生 one-hot 信号,作为控制逻辑的输入信号。例如,作为多路复用器的选择信号或使能信号。
  2. 地址解码:在微处理器设计中,译码器可以用于将地址总线的一部分解码,生成存储器的选择信号。
  3. 控制信号生成:在复杂的控制单元中,译码器可以帮助生成不同的控制信号,驱动不同的操作模块。

Chisel 提供的内置 Mux 模块可以替代手动构建的多路复用器,而译码器的输出通常作为 Mux 的 使能输入选择信号

通过上面的内容,我们详细了解了如何在 Chisel 中设计 译码器

  • 使用 switchis 描述真值表。
  • 采用 位移操作 提高代码效率。
  • 探讨了译码器在硬件设计中的实际应用场景,如地址解码和控制信号生成。

这些技巧对于构建高效的硬件模块尤为重要,是后续复杂系统设计的基础。

5.3 编码器 (Encoder)

编码器译码器 (Decoder) 的反向操作,它将 one-hot 编码 输入信号转换成一个 二进制编码 输出信号。例如,对于一个 4 位的 one-hot 编码输入,它可以生成一个 2 位的二进制输出。

图 5.3 展示了一个 4 位输入到 2 位输出的编码器。表 5.2 列出了输入和输出的真值表。

输入 (a)输出 (b)
000100
001001
010010
100011
??????

需要注意的是,编码器只在输入信号是 one-hot 编码时才能正常工作。如果输入信号不符合 one-hot 编码的格式(例如多个位为 1),输出将是未定义的。因此,通常我们会为编码器的输出提供一个默认赋值,来捕获所有未定义的输入模式。

Chisel 实现一个简单的编码器

我们可以使用 switchis 语句实现编码器的逻辑,如下所示:

scala
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
}

代码说明

  1. a 是输入信号,表示 4 位 one-hot 编码。

  2. b 是输出信号,初始化为 00,使用 WireDefault 避免不完全赋值。

  3. switch 语句

    用于匹配输入a的合法值:

    • 如果输入是 0001,输出为 00
    • 如果输入是 0010,输出为 01
    • 如果输入是 0100,输出为 10
    • 如果输入是 1000,输出为 11

扩展到更大的编码器

对于更大的编码器(如 16 位 one-hot 输入),手动编写 switch 语句会非常繁琐。为了自动化这个过程,我们可以借助 Scala 循环 来生成编码器的逻辑。

使用 VecMux 进行生成

在编码器中,Vec 可以用于存储输出的索引值。我们通过循环遍历所有输入位,并使用 多路复用器 (Mux) 来检查每个位是否被激活:

scala
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) // 输出结果

代码逻辑

  1. hotIn 信号 是输入的 one-hot 编码信号,16 位宽。

  2. v 是一个向量

    ,包含了 16 个元素,每个元素的值对应输入位的位置索引:

    • 如果输入 hotIn(i) 被激活 (即为 1),则输出为当前索引 i.U
    • 否则,输出为 0.U
  3. 使用 OR 归约:通过 Mux| 操作将所有元素合并成一个输出值。

  4. 最终输出 encOut 表示编码器的结果。

编码器的工作原理

编码器的实现基于以下原则:

  1. one-hot 输入:假设输入是 one-hot 编码,即只有一个位被激活。
  2. 位置索引:激活的位的索引值即为编码器的输出结果。
  3. OR 归约:对于多位输入,我们通过 OR 归约将激活的索引合并为单个输出。

总结与应用场景

编码器 的主要功能是将 one-hot 编码 转换成 二进制编码,广泛应用于以下场景:

  1. 优先编码器 (Priority Encoder):在多个输入中选择最高优先级的激活信号。
  2. 地址编码:将存储器的多位使能信号转换为地址信号。
  3. 中断处理:在中断控制器中识别最高优先级的中断请求。

通过使用 Scala 循环Vec,我们可以高效地生成大规模的编码器逻辑,而无需手动编写繁琐的 switch 语句。这种方法使得 Chisel 在硬件描述方面更加灵活和强大。

5.4 仲裁器(Arbiter)

仲裁器 (Arbiter) 的作用是仲裁多个客户端对单一共享资源的请求,确保每次只授予一个请求。这种电路的典型应用场景包括多个处理器核心共享一个串行端口(UART)或总线访问。

图 5.4 展示了一个 4 位仲裁器的示意图,包含四个请求线 (r0 ~ r3) 和四个授予线 (g0 ~ g3)。仲裁器根据请求的优先级授予访问权。

优先级仲裁器(Priority Arbiter)

优先级仲裁器遵循固定的优先级规则,优先级越低的输入信号越优先。例如,输入 r0 拥有最高优先级,接下来依次是 r1r2r3

Chisel 实现 3 位仲裁器

以下代码实现了一个 3 位的优先级仲裁器,使用了 Vec 和布尔逻辑来描述请求和授予信号:

scala
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 未授予)

代码说明

  1. 请求优先级顺序:

    • 第一个请求 r0 直接被授予 (grant(0))。
    • 第二个请求 r1 只有在 r0 未被授予时才会被考虑 (notGranted(0))。
    • 第三个请求 r2 依赖于 r0r1 都未被授予 (notGranted(1))。
  2. 向量表示:

    • grant 是授予信号向量,用于表示仲裁结果。
    • notGranted 是未授予信号向量,用于标记哪些请求未被授予。

图 5.5 显示了 4 位仲裁器的逻辑电路结构,使用了 与非门与门 实现逻辑:

  • 每个请求与前一个请求的未授予信号进行逻辑 AND,保证了优先级顺序。
  • g0 依赖于 r0g1 依赖于 r1notGranted(0),以此类推。

逻辑表描述小规模仲裁器

对于较小的仲裁器,我们可以直接用逻辑表实现:

scala
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 优先
}

代码说明

  1. 输入信号 request 是请求向量,表示多个客户端的请求状态。
  2. 输出信号 grant 表示被授予的请求线索,每次只授予一个请求。

可扩展的仲裁器生成器

对于大规模仲裁器,我们可以使用 Scala 循环 生成逻辑,从而避免手动编写大量代码:

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)  // 未授予信号:当前未授予且前一个未授予
}

代码说明

  1. n 表示仲裁器的规模(例如,4 位)。
  2. 循环生成逻辑:通过 for 循环自动为每一位生成逻辑,避免手动重复代码。
  3. 未授予信号notGranted(i) 依赖于之前的未授予状态,确保请求按优先级顺序被仲裁。

总结与应用场景

仲裁器的主要功能是 仲裁多个请求,确保每次只授予一个请求。优先级仲裁器适用于以下场景:

  1. 总线仲裁:多个设备竞争访问共享总线时的仲裁逻辑。
  2. 中断请求仲裁:在中断控制器中,仲裁多个中断请求的优先级。
  3. 资源分配:例如处理器核心之间共享串行端口或内存。

通过 Chisel 的灵活性,我们可以轻松构建固定优先级仲裁器或扩展到动态仲裁器。对于大规模仲裁器,使用 Scala 循环 和向量生成器可以大大简化代码,提高设计的可扩展性和可读性。

5.5 优先编码器 (Priority Encoder)

在之前的编码器设计中,我们假设输入信号是 one-hot 编码 的,即只有一个位被置为 1。如果多个输入位同时为 1,传统编码器将导致未定义行为。这种情况在实际硬件设计中显然是不允许的。

为了解决这一问题,我们将编码器与仲裁电路(Arbiter)结合,确保只选择优先级最高的置位信号进行编码。这种组合生成了 优先编码器,其作用是输出最高优先级的信号位置。

图 5.6 展示了一个优先编码器的结构:

  1. 仲裁器 (Arbiter) 选择最高优先级的请求信号。
  2. 编码器 (Encoder) 将仲裁器输出的 one-hot 信号转换为二进制编码。

实现优先编码器的 Chisel 代码

scala
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 }
  }
}

代码解析

  1. 输入与输出
    • request:4 位输入请求信号。
    • grant:仲裁器的输出,指示哪个请求获得了优先级。
    • encoded:编码器的输出,表示优先级最高的请求的二进制位置。
  2. 仲裁器
    • 使用 when...elsewhen 控制语句,按照 优先级顺序 检查输入请求,选中第一个有效的请求。
    • 输出信号 arbiter 具有独热编码格式(例如:0001 表示请求 0,0010 表示请求 1)。
  3. 编码器
    • 通过 switch 语句对仲裁器输出的独热编码结果进行二进制编码。
    • b00 表示请求 0,b01 表示请求 1,以此类推。

5.6 比较器 (Comparator)

比较器是一种简单的组合逻辑电路,用于比较两个多位输入信号,并输出比较结果。

图 5.7 展示了比较器的结构:

  • 输入信号ab 是两个需要比较的信号。

  • 输出信号:

    • equ 表示 a == b
    • gt 表示 a > b

Chisel 实现比较器

实现一个比较器在 Chisel 中非常简单,仅需两行代码:

scala
val equ = a === b   // 检查 a 和 b 是否相等
val gt  = a > b     // 检查 a 是否大于 b

说明

  1. === 操作符:用于比较两个信号是否相等。
  2. > 操作符:用于判断左侧信号是否大于右侧信号。
  3. 扩展:通过组合 equgt 信号,可以实现所有的比较逻辑,例如 a <= b 可以通过 !gt 来表示。

5.7 练习

任务:设计一个 7 段显示器驱动电路

设计一个组合逻辑电路,将 4 位二进制输入 转换为 7 段显示器编码,以显示十进制数字或十六进制数字。

功能要求

  1. 输入:4 位二进制信号,用于表示十进制数字 (0-9) 或十六进制数字 (0-F)。
  2. 输出:7 段显示器的控制信号,每段对应于一个 LED 灯。
  3. 显示内容:可以定义为十进制数的编码,或者进一步扩展为显示十六进制数字 (0-9, A-F)。

硬件连接

  • 输入:4 位开关或按键,连接到电路的输入端。
  • 输出:7 个 LED 控制引脚,连接到 7 段显示器的输入端。

扩展目标

如果使用 FPGA 板,编写 Chisel 代码生成 Verilog,并将其加载到 FPGA 板上,使用实际硬件测试显示功能。