Skip to content

第2章:基础组件

数字设计的核心在于组合逻辑电路和触发器,这些基本组件是构建更大、更复杂电路的基础。数字系统通常使用二进制信号,即单个位或信号只能表示两个可能值:0 和 1。这些值也常用以下术语描述:低/高(low/high)、假/真(false/true)、未断言/断言(deasserted/asserted)。无论采用哪种术语,均表示二进制信号的两个可能状态。

2.1 Chisel 类型与常量

Chisel 提供了三种基本数据类型,用于描述硬件连接、组合逻辑和寄存器:

  1. Bits:表示位向量。
  2. UInt:无符号整数,扩展自 Bits。
  3. SInt:有符号整数,扩展自 Bits,使用二进制补码表示。

以下是这些类型的定义示例:

scala
Bits(8.W)   // 8 位的 Bits 类型
UInt(8.W)   // 8 位无符号整数
SInt(10.W)  // 10 位有符号整数

宽度定义

位向量的宽度通过 Chisel 的宽度类型(Width)定义。可以使用 n.W 将整数 n 转换为 Chisel 宽度类型。例如:

scala
Bits(8.W)   // 定义一个宽度为 8 的 Bits 向量

常量定义

Chisel 提供了简洁的语法定义常量,可以通过将 Scala 整数转换为 Chisel 类型:

scala
0.U        // 定义一个值为 0 的 UInt 常量
-3.S       // 定义一个值为 -3 的 SInt 常量
3.U(4.W)   // 定义一个宽度为 4 的 UInt 常量,值为 3

这些语法类似于 C、Java 或 Scala 中的长整数常量(如 3L 表示 long 类型整数)。

常见陷阱

定义常量宽度时,可能会遗漏 .W 后缀。例如:

scala
1.U(32) // 错误

上例中,(32) 被解释为从位 32 提取单个位的操作,结果是一个值为 0 的单个位常量,而非宽度为 32 的常量。正确写法为:

scala
1.U(32.W)

类型推导

Chisel 借助 Scala 的类型推导,在许多情况下可以省略显式的类型和宽度定义。例如,Chisel 可以自动推导常量的最小宽度,使硬件描述更加简洁易读。

进制表示

Chisel 支持通过字符串定义其他进制(如十六进制、八进制和二进制)的常量,前缀分别为 hob

scala
"hff".U      // 十六进制表示 255
"o377".U     // 八进制表示 255
"b1111_1111".U // 二进制表示 255,支持使用下划线分组以提高可读性

字符常量

可以直接使用 ASCII 编码的字符作为常量:

scala
val aChar = 'A'.U // 定义字符 'A' 对应的常量

布尔类型

Chisel 定义了类型 Bool,用于表示逻辑值 truefalse

scala
Bool()       // 定义 Bool 类型
true.B       // 布尔值 true
false.B      // 布尔值 false

2.2 组合逻辑电路

Chisel 使用布尔代数运算符来描述组合逻辑电路,这些运算符在 C、Java 和 Scala 等编程语言中广泛使用。以下是典型的逻辑操作示例:

scala
val logic = (a & b) | c

该代码描述了一个简单的组合逻辑电路:

  • a & b 表示两个信号通过 与门(AND gate)结合。
  • (a & b) | c 表示与门输出与信号 c 通过 或门(OR gate)结合。

图 2.1 展示了这一逻辑表达式的电路图。需要注意的是,这些信号可以是单个位,也可以是多位向量。

运算符与类型推导

在上述示例中,logic 的类型和宽度是从表达式中自动推导的,无需显式声明。这种类型推导简化了硬件描述。

标准逻辑运算

Chisel 提供了以下标准逻辑运算符:

  • 按位与:

    scala
    val and = a & b
  • 按位或:

    scala
    val or = a | b
  • 按位异或:

    scala
    val xor = a ^ b
  • 按位非:

    scala
    val not = ~a

算术运算

Chisel 同样支持常见的算术运算:

  • 加法:

    scala
    val add = a + b
  • 减法:

    scala
    val sub = a - b
  • 取反:

    scala
    val neg = -a
  • 乘法:

    scala
    val mul = a * b
  • 除法:

    scala
    val div = a / b
  • 取模:

    scala
    val mod = a % b

运算结果的宽度依赖于操作数:

  • 加法和减法:结果宽度为操作数的最大宽度。
  • 乘法:结果宽度为两个操作数宽度之和。
  • 除法和取模:结果宽度通常与被除数(分子)的宽度一致。

信号定义与操作

定义与赋值

Chisel 中可以通过 Wire 定义一个信号,随后使用 := 运算符对信号赋值:

scala
val w = Wire(UInt())
w := a & b

位提取

可以从一个信号中提取特定位或位字段:

  • 提取单个位:

    scala
    val sign = x(31) // 提取第 31 位
  • 提取子字段:

    scala
    val lowByte = largeWord(7, 0) // 提取第 0 到第 7 位

位拼接

位字段可以通过 ## 运算符拼接:

scala
val word = highByte ## lowByte

或者使用等效的 Cat 函数:

scala
val word = Cat(highByte, lowByte)

运算符优先级

Chisel 的运算符优先级遵循 Scala 的规则,与 Java/C 相似,但与 Verilog 和 VHDL 存在差异:

  • Verilog 的运算符优先级与 C 相同。
  • VHDL 的逻辑运算符没有优先级,按从左到右的顺序计算。

建议在复杂表达式中使用括号确保运算顺序清晰。例如:

scala
val result = ((a & b) | c) ^ d

运算符与硬件函数

Chisel 提供了丰富的运算符和硬件函数来描述硬件行为。以下是两张表格总结的内容,帮助更好地理解和使用这些功能。

表 2.1:Chisel 定义的硬件运算符

运算符描述支持的数据类型
* / %乘法、除法、取模UInt, SInt
+ -加法、减法UInt, SInt
=== =/=判断相等、不等,返回 BoolUInt, SInt, 返回 Bool
> >= < <=比较运算符,返回 BoolUInt, SInt, 返回 Bool
<< >>左移、右移(SInt 类型右移时带符号扩展)UInt, SInt
~按位非(NOT)UInt, SInt, Bool
& | ^按位与(AND)、或(OR)、异或(XOR)UInt, SInt, Bool
!逻辑非(NOT)Bool
&& ||逻辑与(AND)、或(OR)Bool

这些运算符与软件语言(如 C、Java 或 Scala)中定义的运算符相似,可以直接用于描述硬件行为。

表 2.2:Chisel 定义的硬件函数

函数描述支持的数据类型
v.andR v.orR v.xorR按位与、或、异或的归约操作,返回单个布尔值UInt, SInt, 返回 Bool
v(n)提取信号的第 nUInt, SInt
v(end, start)提取从 start 位到 end 位的子字段UInt, SInt
Fill(n, v)将位字段 v 复制 n 次形成新的位向量UInt, SInt
a ## b按位拼接两个信号UInt, SInt
Cat(a, b, ...)按位拼接多个信号,功能等同于 ##UInt, SInt

示例与说明

  1. 按位归约操作

    • andR:对所有位取按位与,若所有位均为 1 则返回 true

    • 示例:

      scala
      val result = v.andR
  2. 位提取

    • 提取单个位:

      scala
      val bit = v(3) // 提取第 3 位
    • 提取子字段:

      scala
      val subField = v(7, 4) // 提取第 4 到第 7 位
  3. 位拼接与填充

    • 拼接两个信号:

      scala
      val combined = highByte ## lowByte
    • 使用 Fill进行位复制:

      scala
      val replicated = Fill(4, 1.U) // 将 1 复制 4 次,生成 4'b1111

运算符优先级

  • Chisel 运算符的优先级继承自 Scala,与 Java/C 相似,但与 Verilog 和 VHDL 存在差异。

  • 建议:在表达式中使用括号来确保逻辑的清晰性,例如:

    scala
    val result = ((a & b) | c) ^ d

多路复用器(Multiplexer)

多路复用器是一种在多个输入中选择一个输出的电路。在最基本的形式中,它在两种输入间进行选择,如图 2.2 所示,这是一个 2:1 多路复用器(简称 MUX)。

工作原理:

  • 根据选择信号 sel的值,输出y将对应输入信号 ab
  • sel 为 1 时,y 输出 a
  • sel 为 0 时,y 输出 b

在 Chisel 中使用多路复用器

虽然多路复用器可以用基本的逻辑操作(如 ANDOR)实现,但由于其广泛使用,Chisel 提供了内置的 Mux 函数来简化设计。以下是多路复用器的定义:

scala
val result = Mux(sel, a, b)

参数说明

  1. sel:选择信号,为 Bool 类型。如果 seltrue.B,选择输入 a,否则选择输入 b
  2. ab:两个输入信号,可以是任何 Chisel 数据类型(如 UIntSInt)或聚合类型(如 BundleVec)。两者必须具有相同的类型。

示例

scala
val a = Wire(UInt(8.W))
val b = Wire(UInt(8.W))
val sel = Wire(Bool())
val y = Mux(sel, a, b)
  • 如果 seltrue.By 的值为 a
  • 如果 selfalse.By 的值为 b

功能与应用

结合逻辑运算、算术运算和多路复用器,几乎可以描述所有的组合逻辑电路。多路复用器特别适合以下场景:

  1. 选择路径:在多个数据路径中选择一条作为输出。
  2. 实现条件判断:替代传统的条件语句(如 if-elseswitch)。

虽然 Mux 功能强大,但 Chisel 还提供了更高级的控制抽象,使得描述复杂的组合逻辑更加简洁优雅。这些高级功能将在第 5 章详细介绍。

2.3 寄存器

2.3.1 寄存器的定义与使用

Chisel 提供了寄存器的抽象,它是由多个 D 触发器(D flip-flops)组成的集合。寄存器可以存储信号的状态,并在时钟上升沿更新。寄存器的初始化值可以通过定义时指定,并与全局复位信号同步复位。

寄存器的基本定义

以下代码定义了一个 8 位宽的寄存器,初始值为 0:

scala
val reg = RegInit(0.U(8.W))
  • RegInit:初始化寄存器,并在复位时将其值设为指定的初始值。
  • 0.U(8.W):表示宽度为 8 位的无符号整数常量,值为 0。

连接输入与使用输出

寄存器的输入通过 := 运算符连接,输出可以直接通过寄存器名称使用:

scala
reg := d       // 将输入信号 d 写入寄存器
val q = reg    // 读取寄存器的输出信号

简化定义方式

Chisel 提供了 RegNext 方法,简化了寄存器与输入信号的连接:

scala
val nextReg = RegNext(d)

上述代码创建了一个寄存器,并将其输入设为 d,默认的复位值为 0。

初始化与连接

寄存器还可以通过 RegNext 同时指定输入信号和复位值:

scala
val bothReg = RegNext(d, 0.U)

上述代码定义了一个寄存器,输入信号为 d,复位时的初始值为 0。

2.3.2 图示分析

图 2.3 展示了寄存器的基本结构:

  • 时钟(clock):控制寄存器在上升沿更新。
  • 复位(reset):同步复位寄存器的值为 0。
  • 输入信号(d):寄存器的数据输入。
  • 输出信号(q):寄存器的当前存储值。

复位信号和时钟信号是全局的,Chisel 会为寄存器自动连接这些信号。

2.3.3 命名惯例

为增强代码可读性,推荐以下命名规则:

  1. 寄存器后缀:在寄存器名称后添加 Reg,例如 cntReg
  2. 驼峰命名法:采用小驼峰式命名变量和函数,例如 nextReg。类名(如模块名)以大写字母开头。

虽然 Chisel 的命名规则较为灵活,但建议使用简洁、描述性强的名称。同时注意避免使用保留字(保留字列表详见附录 A)。

2.3.4 计数器的实现

计数器是数字系统中常见的操作,通常用于事件计数或时间间隔定义。通过计数时钟周期,可以触发特定的行为或操作。

基本循环计数器

以下代码实现了一个从 0 计数到 9 的计数器,到达上限后重置为 0:

scala
val cntReg = RegInit(0.U(8.W)) // 定义一个 8 位宽的寄存器,复位值为 0

cntReg := Mux(cntReg === 9.U, 0.U, cntReg + 1.U)
  • 计数逻辑:

    • cntReg 的值达到 9 时,Mux 选择 0.U,将计数器重置为 0。
    • 否则,选择 cntReg + 1.U,计数器递增 1。

代码解析

  1. RegInit

    • 定义一个初始值为 0 的寄存器。
  2. 条件判断:

    • 使用 cntReg === 9.U 判断是否达到上限值。
  3. 多路选择:

    • Mux(condition, trueValue, falseValue)

      是一个条件选择器:

      • 若条件为 true,选择 trueValue
      • 若条件为 false,选择 falseValue

计数器的应用场景

  • 事件计数:记录特定事件的发生次数。
  • 时间控制:通过计数器生成定时信号或触发周期性动作。
  • 状态机:用于状态计数和状态转换。

2.4 使用 BundleVec 组织结构化信号

Chisel 提供了两种工具来组织相关信号:

  1. Bundle:将不同类型的信号组合为命名字段。
  2. Vec:表示相同类型信号的可索引集合(类似于数组)。

这两种构造工具允许用户定义新的 Chisel 类型,并支持嵌套使用。

2.4.1 Bundle

定义 Bundle

Bundle 将多个信号组合成一个整体,类似于 C 和 SystemVerilog 中的 struct,或 VHDL 中的 record。 通过扩展 Bundle 类,可以定义一个信号集合:

scala
class Channel extends Bundle {
  val data = UInt(32.W)  // 32 位无符号整数信号
  val valid = Bool()     // 布尔信号,表示有效位
}

使用 Bundle

创建一个 Bundle 的实例并将其包装成 Wire

scala
val ch = Wire(new Channel())
ch.data := 123.U      // 为字段 data 赋值
ch.valid := true.B    // 为字段 valid 赋值

val b = ch.valid      // 读取字段 valid 的值
  • 点号表示法:通过 . 访问 Bundle 中的字段。这种语法类似于面向对象编程中的字段访问。

整体引用 Bundle

整个 Bundle 也可以作为一个整体被引用或传递:

scala
val channel = ch

适用场景

  1. 端口组织:将模块的输入输出信号组织成 Bundle,简化接口设计。
  2. 结构化信号:将逻辑相关的信号组合在一起,增强代码可读性。

2.4.2 Vec

定义 Vec

Vec 表示相同类型信号的集合,支持按索引访问元素。它类似于其他编程语言中的数组数据结构。例如:

scala
val vec = Wire(Vec(4, UInt(8.W))) // 定义一个包含 4 个 8 位信号的向量
vec(0) := 1.U                    // 为第一个元素赋值
vec(1) := 2.U                    // 为第二个元素赋值

Vec 的用途

Vec 在硬件设计中有多种用途:

  1. 动态寻址:用于硬件中的多路选择器(Multiplexer)。
  2. 寄存器文件:实现读写的多路复用和写入使能控制。
  3. 模块端口参数化:根据端口数量动态定义模块接口。

使用建议

对于其他集合类型(如生成器数据),如果不涉及硬件信号的生成,推荐使用 Scala 的 Seq 来替代 Vec,以便更好地利用 Scala 的集合工具。

BundleVec 的嵌套

BundleVec 可以相互嵌套,实现复杂结构的信号组织。例如:

scala
class NestedExample extends Bundle {
  val data = Vec(4, UInt(8.W))  // 包含 4 个 8 位信号的向量
  val meta = new Channel        // 嵌套的 Bundle 类型
}

上述代码创建了一个 Bundle

  • 包含一个名为 dataVec
  • 包含一个嵌套的 Channel 类型字段。

使用 Vec 实现组合逻辑

Vec 是 Chisel 提供的工具之一,用于组织同类型信号的集合。在组合逻辑中,Vec 常被用来实现多路复用器(multiplexer)或其他索引操作。以下是 Vec 的更多细节与应用。

定义 Vec

Vec 通过构造函数创建,指定两个参数:

  1. 元素的数量。
  2. 元素的类型。

例如,定义一个包含三个 4 位无符号整数的向量:

scala
val v = Wire(Vec(3, UInt(4.W)))

索引访问

可以通过索引访问 Vec 的单个元素,并为其赋值:

scala
v(0) := 1.U   // 第一个元素赋值为 1
v(1) := 3.U   // 第二个元素赋值为 3
v(2) := 5.U   // 第三个元素赋值为 5

使用索引值动态选择一个元素:

scala
val index = 1.U(2.W)  // 定义一个索引值,宽度为 2 位
val a = v(index)      // 根据索引选择向量中的一个元素

使用 Vec 实现多路复用器

Vec 是实现多路复用器的常见工具,如图 2.4 所示。以下代码展示了一个简单的多路复用器设计:

scala
val vec = Wire(Vec(3, UInt(8.W))) // 定义一个包含三个 8 位信号的向量
vec(0) := x                       // 第一个输入
vec(1) := y                       // 第二个输入
vec(2) := z                       // 第三个输入

val muxOut = vec(select)          // 根据 `select` 选择输出
  • select 是一个索引信号,用于选择输出。
  • muxOut 是根据 select 选择的向量中的一个元素。

使用 VecInit 设置默认值

VecInit 提供了更简洁的语法,用于初始化 Vec 的默认值或连接信号:

定义带默认值的 Vec

以下代码实现了一个带初始值的 3:1 多路复用器:

scala
val defVec = VecInit(1.U(3.W), 2.U, 3.U) // 定义默认值为 1, 2, 3 的向量
when (cond) {                           // 根据条件覆盖默认值
  defVec(0) := 4.U
  defVec(1) := 5.U
  defVec(2) := 6.U
}
val vecOut = defVec(sel)                // 根据 `sel` 输出向量中的一个值
  • 默认值初始化VecInit 中的常量直接赋值给向量的每个元素。
  • 动态覆盖:通过 when 语句,可以动态修改 Vec 中的值。

连接信号到 Vec

除了初始化常量,VecInit 也可以直接将信号连接到 Vec

scala
val defVecSig = VecInit(d, e, f)   // 将信号 d, e, f 连接到 Vec
val vecOutSig = defVecSig(sel)    // 根据 sel 选择输出信号

使用 Vec 创建寄存器文件

Vec 可以和 Reg 结合使用,用于创建一组寄存器(即寄存器文件)。寄存器文件是数字系统中常见的结构,广泛应用于处理器设计中。

创建寄存器向量

以下代码创建了一个包含三个 8 位宽寄存器的向量:

scala
val regVec = Reg(Vec(3, UInt(8.W))) // 定义一个包含 3 个 8 位寄存器的向量

val dout = regVec(rdIdx)            // 通过索引 rdIdx 读取寄存器
regVec(wrIdx) := din                // 通过索引 wrIdx 写入寄存器

工作原理

  1. 写操作:

    • 使用 wrIdx(写索引)选择要写入的寄存器,并将 din 的值写入对应寄存器。
  2. 读操作:

    • 使用 rdIdx(读索引)选择要读取的寄存器,输出值为 dout

电路图说明

图 2.5 展示了寄存器向量的电路结构:

  • 多路复用器:

    • 通过 rdIdx 选择从哪个寄存器输出数据。
  • 解码器:

    • 根据 wrIdx 激活对应寄存器的写入使能信号(en)。

创建完整的寄存器文件

寄存器文件是寄存器的更大集合,通常用于存储处理器的操作数。例如,以下代码定义了一个包含 32 个寄存器的寄存器文件,每个寄存器 32 位宽:

scala
val registerFile = Reg(Vec(32, UInt(32.W)))

寄存器文件的读写操作

  1. 写操作:

    scala
    registerFile(index) := dIn
    • 使用 index 选择要写入的寄存器,将数据 dIn 写入其中。
  2. 读操作:

    scala
    val dOut = registerFile(index)
    • 使用 index 选择要读取的寄存器,返回其存储的值。

初始化寄存器文件

通过 VecInit 初始化寄存器

寄存器文件可以通过 VecInit 设置复位值:

scala
val initReg = RegInit(VecInit(0.U(3.W), 1.U, 2.U)) // 初始化寄存器的复位值
val resetVal = initReg(sel)                        // 读取选中的寄存器值

initReg(0) := d                                    // 动态修改第一个寄存器的值
initReg(1) := e                                    // 动态修改第二个寄存器的值
initReg(2) := f                                    // 动态修改第三个寄存器的值
  • RegInit:初始化寄存器文件的复位值。
  • VecInit:通过传入值列表初始化寄存器。

统一初始化为相同值

如果所有寄存器的复位值相同,可以使用 Scala 的 Seq.fill

scala
val resetRegFile = RegInit(VecInit(Seq.fill(32)(0.U(32.W)))) // 所有寄存器复位值为 0
val rdRegFile = resetRegFile(sel)                           // 通过 sel 选择输出寄存器值
  • Seq.fill

    • 用于创建一个指定长度的序列,所有元素初始化为相同值。
    • 在上例中,生成了一个长度为 32 的序列,每个元素初始化为 0.U(32.W)

应用场景

  1. 处理器寄存器文件:

    • RISC-V 等架构中使用寄存器文件存储操作数。
    • 典型寄存器文件包含 32 个 32 位宽寄存器。
  2. 缓冲器设计:

    • 用于数据存储和传输的缓冲区。
  3. 多路复用器控制:

    • 利用 Vec 结合索引实现动态选择逻辑。

使用 BundleVec 的组合

Chisel 提供了强大的抽象能力,可以将 BundleVec 组合使用,构建复杂的数据结构。这种方式不仅可以提高代码的模块化和可读性,还能适应灵活多变的硬件设计需求。

Vec 中使用 Bundle

可以在 Vec 中存储 Bundle 类型的元素,创建一个包含多个 Bundle 的向量。例如:

scala
val vecBundle = Wire(Vec(8, new Channel())) // 包含 8 个 Channel 的向量

这里:

  • Channel 是一个 Bundle 类型。
  • Vec(8, new Channel()) 定义了一个包含 8 个 Channel 的向量。

Bundle 中使用 Vec

同样,可以在 Bundle 中嵌套一个 Vec。例如:

scala
class BundleVec extends Bundle {
  val field = UInt(8.W)           // 一个普通字段
  val vector = Vec(4, UInt(8.W)) // 一个包含 4 个 8 位整数的向量
}

这种组合方式非常适合描述包含数组或向量的复杂信号结构。

寄存器初始化带有 BundleVec

如果需要为 Bundle 类型的寄存器设置复位值,可以使用如下方式:

  1. 先创建一个 Bundle 类型的 Wire,初始化其字段。
  2. 使用 RegInit 将初始化值赋给寄存器。

示例

scala
val initVal = Wire(new Channel()) // 创建一个 Channel 类型的 Wire
initVal.data := 0.U               // 初始化字段 data
initVal.valid := false.B          // 初始化字段 valid

val channelReg = RegInit(initVal) // 定义寄存器并初始化为 initVal

注意事项:禁止部分赋值

在 Chisel 3 中,不允许对 UInt 类型的部分位进行赋值(Verilog 和 Chisel 2 支持此功能)。例如:

scala
val assignWord = Wire(UInt(16.W))

assignWord(7, 0) := lowByte    // 会报错
assignWord(15, 8) := highByte  // 会报错

解决方法:使用 Bundle

可以通过定义一个本地的 Bundle 类型,将数据拆分为字段,然后将 Bundle 转换为 UInt。例如:

scala
val assignWord = Wire(UInt(16.W))

class Split extends Bundle {
  val high = UInt(8.W)
  val low = UInt(8.W)
}

val split = Wire(new Split())
split.low := lowByte
split.high := highByte

assignWord := split.asUInt()  // 将 Bundle 转换为 UInt

这种方法要求知道字段的拼接顺序(如高位字段在前,低位字段在后)。

使用 Vec 进行逐位赋值

另一种解决方案是使用 Vec 来表示每个位的值,然后将其转换为 UInt。例如:

scala
val vecResult = Wire(Vec(4, Bool())) // 定义一个 Bool 类型的向量

// 为向量的每个位赋值
vecResult(0) := data(0)
vecResult(1) := data(1)
vecResult(2) := data(2)
vecResult(3) := data(3)

val uintResult = vecResult.asUInt // 将 Vec 转换为 UInt

这种方法可以避免使用自定义 Bundle,直接将位向量组合成整数。

2.5 Wire, RegIO

在 Chisel 中,基本类型 UIntSIntBits 本身并不直接代表硬件。只有将这些类型封装到 WireRegIO 中时,才会生成实际的硬件组件:

  • Wire:表示组合逻辑(如一个连线或中间信号)。
  • Reg:表示寄存器(由一组 D 触发器组成)。
  • IO:表示模块输入输出端口(如硬件上的引脚)。

2.5.1 定义和使用

通过将 WireReg 赋值给 Scala 的不可变变量(val),即可定义一个硬件信号。例如:

scala
val number = Wire(UInt())   // 定义一个无符号整数的 Wire
val reg = Reg(SInt())       // 定义一个有符号整数的寄存器

赋值

使用 := 运算符为 WireReg 赋值:

scala
number := 10.U          // 给 Wire 赋值为 10
reg := value - 3.U      // 给 Reg 赋值为 (value - 3)
  • 注意:Chisel 中的:=和 Scala 的=不同。

    • = 用于创建硬件对象时初始化信号。
    • := 用于重新赋值给已存在的硬件对象。

2.5.2 条件赋值与默认值

条件赋值的规则

组合逻辑信号(Wire)可以使用条件赋值,但每个分支必须进行赋值,否则会引入潜在的锁存器(latch)。例如:

scala
when(condition) {
  number := 5.U
}.otherwise {
  number := 0.U
}

最佳实践:设置默认值

为了避免忘记赋值或引入锁存器,推荐在定义 Wire 时设置默认值:

scala
val number = WireDefault(10.U(4.W))  // 设置默认值为 10,位宽为 4

2.5.3 定义寄存器的初始值

对于寄存器,建议在复位时为其设置一个初始值。这样可以简化测试和验证,并避免硬件在复位后进入未定义状态:

scala
val reg = RegInit(0.S(8.W))  // 定义一个 8 位宽的有符号寄存器,复位值为 0
  • RegInit

    • 初始化寄存器的复位值。
    • 如果省略复位值,寄存器的值在复位后可能不确定,这可能会导致硬件行为不可预测。

2.5.4 小结与建议

  1. WireReg 的区别
    • Wire:表示组合逻辑信号,信号值可以随时更新。
    • Reg:表示时序逻辑信号(寄存器),值仅在时钟上升沿更新。
  2. :== 的区别
    • =:用于创建硬件对象。
    • :=:用于为硬件对象赋值或重新赋值。
  3. 组合逻辑的默认值
    • 使用 WireDefault 设置组合信号的默认值,避免因漏掉条件赋值导致锁存器。
  4. 寄存器初始化
    • 使用 RegInit 明确寄存器的复位值,提高硬件设计的可预测性和测试的可靠性。

通过掌握 WireRegIO 的使用,可以在 Chisel 中准确描述组合逻辑、时序逻辑和模块接口,构建清晰且易维护的硬件设计。

2.6 Chisel 是如何生成硬件的?

尽管 Chisel 的代码风格类似于 Java 或 C 等经典编程语言,但它的本质完全不同。Chisel 是一种 硬件描述语言,其代码生成的是硬件电路,而非传统的软件程序。

硬件的并行执行

在软件程序中,代码是一行接一行顺序执行的。而在硬件设计中,所有逻辑是并行执行的:

  • 每个赋值语句会生成硬件组件(如门电路或触发器)。
  • 所有硬件节点彼此独立,在时钟驱动下并行工作。

在构建 Chisel 模块时,可以将其想象为绘制电路图中的模块和连线。例如:

scala
val logic = (a & b) | c

这段代码描述了一个组合逻辑电路,由 AND 和 OR 门组成。这不是顺序执行的,而是直接生成一组互连的硬件节点。

硬件生成的过程

  1. Scala 程序运行
    • Chisel 的代码运行在 Scala 的执行环境中。
    • 程序中的每个语句描述并生成硬件节点。
  2. 硬件描述的收集
    • Chisel 代码通过运行后收集所有硬件组件及其连接关系。
    • 这些硬件节点构成了一个电路图。
  3. 输出 Verilog
    • 收集到的硬件节点网络会被转换为 Verilog 文件,供 ASIC 或 FPGA 工具进一步综合。

对于软件工程师而言,这种硬件设计方式提供了极大的并行性,无需像多线程编程那样复杂地管理任务分配和通信同步。

2.7 练习:从简单硬件设计开始

在这一节中,您将扩展之前实现的 Blinking LED 设计,为硬件添加输入开关,并在 FPGA 上测试基础组合逻辑。

步骤 1:扩展 IO 接口

创建新的 Chisel 模块,扩展 IO 接口,使其包含输入开关和输出 LED。例如:

scala
val io = IO(new Bundle {
  val sw = Input(UInt(2.W))  // 2 位输入开关
  val led = Output(UInt(1.W)) // 1 位输出 LED
})

步骤 2:简单的硬件功能

将设计简化为直接将输入连接到输出。例如:

scala
io.led := io.sw(0) // 第一个开关控制 LED 的状态

这是一种简单的直通逻辑,验证开关和 LED 是否正常连接。

步骤 3:引脚分配

要将硬件映射到 FPGA 开发板上,需要为开关和 LED 分配物理引脚:

  • 查看 FPGA 的手册,找到输入和输出的引脚位置。
  • 在 Quartus 或 Vivado 工具中完成引脚分配。

步骤 4:加载到 FPGA

  1. 编译 Chisel 项目,生成 Verilog 文件。
  2. 使用 Quartus 或 Vivado,将 Verilog 转换为比特流文件。
  3. 加载比特流文件到 FPGA,并观察 LED 是否跟随开关的状态变化。

进阶练习:实现逻辑功能

  1. 实现 AND 门功能

    • 使用两个开关作为输入,计算逻辑与的结果并显示在 LED 上。
    scala
    io.led := io.sw(0) & io.sw(1) // 两个开关的逻辑与
  2. 实现多路复用器

    • 增加一个输入开关作为选择信号,另外两个开关作为数据输入,输出选择结果:
    scala
    io.led := Mux(io.sw(0), io.sw(1), io.sw(2)) // 实现 2:1 复用器
  3. 观察与调试

    • 检查 FPGA 上的 LED 和开关是否正确工作。
    • 如果 LED 不工作,验证引脚分配是否正确。

下一步

完成以上基础实验后,您已经掌握了在 FPGA 上实现简单逻辑的技能。接下来的重点是:

  • 深入学习:了解如何使用 Chisel 描述更复杂的逻辑功能。
  • 测试框架:探索 Chisel 提供的测试工具(如 ChiselTest),无需直接加载到 FPGA 上即可验证设计逻辑。

通过这些实践,您将逐步从简单设计过渡到复杂的数字系统开发。