Skip to content

14 Design of a Processor

本章介绍了一个中型项目的设计、模拟和测试过程,该项目的目标是设计一个微处理器。为了使项目具有可操作性,我们选择设计一个简单的累加器机器。这款处理器名为 Leros,其开源代码可通过 GitHub 获取。这是一个高级示例,要求读者具备一定的计算机体系结构知识,以便理解代码示例。

14.1 The Instruction Set Architecture

指令集的定义是指所谓的指令集架构(Instruction Set Architecture, ISA)。ISA 是计算机体系结构中最重要的抽象之一,它充当编译器(或汇编程序员)与具体处理器实现之间的契约。ISA 是与具体实现无关的,可以通过不同的微处理器实现(也称微架构)来执行相同的 ISA。

Leros 的设计目标是简单,但它同时也是一个适合 C 编译器的良好目标。Leros 的指令集结构紧凑,仅需一页即可描述所有指令,详见下表 14.1。

累加器机器的特性

Leros 是一种所谓的 累加器机器(Accumulator Machine)。在这种架构中,所有操作都以累加器(Accumulator, A)作为源操作数之一,计算结果通常也会存储在累加器中。算术或逻辑操作的第二操作数可以是以下之一:

  • 立即数(Immediate):即常量;
  • 片上寄存器:Leros 支持 256 个片上寄存器。

此外,内存的加载和存储操作也通过累加器完成:

  • 加载(Load):将内存中的值加载到累加器中;
  • 存储(Store):将累加器的值存储到内存中。

用于内存访问的地址存储在 地址寄存器(Address Register, AR) 中。

程序计数器(Program Counter, PC)

程序计数器指向当前正在执行的指令的地址。通常情况下,程序计数器会递增以执行下一条指令。但在需要改变程序控制流的情况下,可以通过跳转(Branch 或 Jump)指令操控程序计数器的值。

Leros 支持以下两种分支指令:

  • 无条件跳转:直接修改程序计数器的值;
  • 条件跳转:跳转的执行依赖累加器内容。例如:
    • 指令 brz 表示当累加器的值为 0 时进行跳转。

Leros 指令集详解

下表 14.1 列出了 Leros 的完整指令集,包括操作码(Opcode)、功能描述(Function)及其具体作用(Description)。

OpcodeFunctionDescription
addA = A + Rn将寄存器 Rn 的值加到累加器 A
addiA = A + i将立即数 i 加到累加器 A
subA = A - Rn用累加器 A 减去寄存器 Rn 的值
subiA = A - i从累加器 A 中减去立即数 i
shrA = A >>> 1将累加器 A 的值逻辑右移一位
loadA = Rn将寄存器 Rn 的值加载到累加器 A
loadiA = i将立即数 i 加载到累加器 A
andA = A and Rn对累加器 A 和寄存器 Rn 的值执行按位与操作
andiA = A and i对累加器 A 和立即数 i 执行按位与操作
orA = A or Rn对累加器 A 和寄存器 Rn 的值执行按位或操作
oriA = A or i对累加器 A 和立即数 i 执行按位或操作
xorA = A xor Rn对累加器 A 和寄存器 Rn 的值执行按位异或操作
xoriA = A xor i对累加器 A 和立即数 i 执行按位异或操作
loadhiA₁₅₋₈ = i将立即数 i 加载到累加器的第二字节
loadh2iA₂₃₋₁₆ = i将立即数 i 加载到累加器的第三字节
loadh3iA₃₁₋₂₄ = i将立即数 i 加载到累加器的第四字节
storeRn = A将累加器 A 的值存储到寄存器 Rn
jalPC = A, Rn = PC + 2跳转到累加器 A 指向的地址,并将返回地址存储在寄存器 Rn
ldaddrAR = A将累加器 A 的值加载到地址寄存器 AR
loadindA = mem[AR + (i << 2)]从内存加载一个字到累加器 A
loadindbA = mem[AR + i][7:0]从内存加载一个字节到累加器 A
loadindhA = mem[AR + (i << 1)][15:0]从内存加载半字到累加器 A
storeindmem[AR + (i << 2)] = A将累加器 A 的值存储到内存
storeindbmem[AR + i] = A₇₋₀将累加器 A 的字节存储到内存
storeindhmem[AR + (i << 1)] = A₁₅₋₀将累加器 A 的半字存储到内存
brPC = PC + o跳转到偏移地址 o
brzif A == 0 PC = PC + o若累加器 A 为 0,则跳转到偏移地址 o
brnzif A != 0 PC = PC + o若累加器 A 不为 0,则跳转到偏移地址 o
brpif A >= 0 PC = PC + o若累加器 A 为正,则跳转到偏移地址 o
brnif A < 0 PC = PC + o若累加器 A 为负,则跳转到偏移地址 o
scallSystem call (simulation hook)系统调用,用于模拟环境

Leros 的分支指令是 相对分支,其目标地址是相对于当前指令的偏移量,允许前后跳转大约 2000 条指令。对于更大的控制流改变(如函数调用和返回),Leros 提供了 跳转并链接(jump-and-link, jal) 指令。该指令跳转到累加器中存储的地址,并将下一条指令的地址存储到寄存器中,从而可以通过该地址返回函数。

在当前实现中,Leros 的 累加器寄存器文件 都是 32 位宽 的。这种可配置性可以支持扩展到 16 位或 64 位版本。

Leros 指令集的结构和用途

表 14.1 展示了 Leros 的指令集,其中:

  • A 表示累加器(Accumulator)。
  • PC 表示程序计数器(Program Counter)。
  • i 是立即数(范围为 0 到 255)。
  • Rn 表示第 n 个寄存器(范围为 0 到 255)。
  • o 是相对于程序计数器的分支偏移量。
  • AR 是用于内存访问的地址寄存器(Address Register)。

以下代码片段是 Leros 汇编语言中一些指令的示例:

assembly
loadi 1       ; 将立即数 1 加载到累加器 A 中
addi 2        ; 将立即数 2 加到累加器 A 中
ori 0x50      ; 对累加器 A 和立即数 0x50 执行按位或操作
andi 0x1f     ; 对累加器 A 和立即数 0x1f 执行按位与操作
subi 0x13     ; 从累加器 A 中减去立即数 0x13
loadi 0xab    ; 将立即数 0xab 加载到累加器 A 中
addi 0x01     ; 将立即数 1 加到累加器 A 中
subi 0xac     ; 从累加器 A 中减去立即数 0xac
scall 0       ; 系统调用,结束程序执行

该示例代码展示了 Leros 支持的 立即数版本 的加载、算术和逻辑指令。最后一条指令 scall 0 是一个系统调用,用于结束程序的模拟运行。这段代码是 Leros 测试套件的一部分,测试的约定是程序结束时累加器的值应为 0。

指令宽度和编码格式

Leros 的指令宽度为 16 位

  • 高 8 位用于编码指令名(操作码)。
  • 低 8 位包含立即数、寄存器编号,或分支偏移量(部分分支偏移量还会使用高 8 位的部分位)。

以下是两个指令的编码示例:

  1. 00001001.00000010:表示一条 add immediate(addi) 指令,将立即数 2 加到累加器中。
  2. 00001000.00000011:表示一条 add register(add) 指令,将寄存器 R3 的值加到累加器中。

对于分支指令,偏移量的部分位会使用高 8 位中的额外指令位。这种设计节省了编码空间,并为更大的偏移量提供支持。

指令编码表

下表展示了 Leros 指令的编码方案,未使用的指令位以 - 表示:

编码指令
00000---nop
000010-0add
000010-1addi
000011-0sub
000011-1subi
00010---sra
00100000load
00100001loadi
00100010and
00100011andi
00100100or
00100101ori
00100110xor
00100111xori
00101000loadhi
00101001loadh2i
00101010loadh3i
00110---store
001110-?out
000001-?in
010000---jal
1000nnnnbr
1001nnnnbrz
1010nnnnbrnz
1011nnnnbrp
1100nnnnbrn
11111111scall

14.2 The Datapath

在本节中,讨论了如何利用状态机和数据通路在硬件中实现算法。类似于第 9.2 节中的方法,这里使用相同的方式来初步实现 Leros 处理器。

为了支持所有指令的数据流,设计了一个数据通路。我们的目标是在两次时钟周期内完成每条指令的执行。因此,基本状态机仅包含两个状态:取指(fetch)执行(execute)

数据通路的工作原理

图 14.1 展示了 Leros 的数据通路(进行了简化)。数据流从左至右,程序计数器(Program Counter, PC)指向需要获取的指令。FPGA 的片上存储器通常有输入寄存器,但这些寄存器无法直接读取。因此,数据通路设计为同时向 PC 和指令存储器的输入寄存器提供相同的值,表示下一个 PC 值。

  • 非分支指令:PC 的值会递增 1(基于 16 位指令字而不是字节)。
  • 相对分支指令:解码组件将立即数符号扩展后加到 PC。
  • jal 指令:PC 的值可以直接从累加器 A 中加载。

状态 1:取指(fetch)

在取指状态,指令从指令存储器中读取并被解码。解码组件决定下一状态中将执行的操作:

  • 对于立即数操作指令(例如 addiloadhi),解码器会生成操作数,并将其存储到寄存器中以备执行状态使用。

状态 2:执行(execute)

在执行状态:

  • 第二块存储器充当通用数据存储器,同时也存储了 255 个寄存器的值。对于寄存器的读写,地址是指令的一部分。
  • 对于内存加载或存储指令,地址寄存器(AR)的值来自累加器 A。加载操作的结果存入 A,而存储操作则由 A 提供数据写入内存。
  • 算术和逻辑运算由 算术逻辑单元(ALU) 完成:
    • 一个操作数来自累加器 A;
    • 另一个操作数可以是立即数(由指令提供)或寄存器值(从存储器读取)。

通过这两种状态的简单切换,实现了对所有指令的支持。

图 14.1 Leros 数据通路

图 14.1 展示了 Leros 数据通路的基本组成部分,包括:

  1. 指令存储器:存储程序代码,从 PC 中获取当前指令。
  2. 数据存储器:存储寄存器值及程序运行时的数据。
  3. 地址寄存器(AR):为内存操作提供地址。
  4. 累加器(A):主要操作数寄存器,用于存储 ALU 的操作数和计算结果。
  5. 解码器(Decode):解析指令,生成控制信号,决定数据流的方向和 ALU 的操作类型。
  6. ALU(算术逻辑单元):执行算术和逻辑运算。

数据在各组件之间通过总线传递,ALU 的结果返回累加器,从而完成数据操作和存储。

14.3 Start with an ALU

处理器的核心组件是 算术逻辑单元(Arithmetic Logic Unit, ALU)。因此,开发 Leros 的第一步是设计 ALU 并编写测试用例。首先,需要定义用于表示 ALU 不同操作的常量,如下所示:

scala
// ALU 操作码
val nop = 0   // 无操作
val add = 1   // 加法
val sub = 2   // 减法
val and = 3   // 按位与
val or  = 4   // 按位或
val xor = 5   // 按位异或
val ld  = 6   // 加载
val shr = 7   // 右移

ALU 的结构

ALU 通常包含以下组件:

  1. 两个操作数输入(称为 ab):用于输入参与运算的值。
  2. 操作码输入(op:选择 ALU 执行的具体操作。
  3. 输出(y:运算结果。

代码清单 14.2 展示了 ALU 的实现逻辑:

  • 首先,为三个输入(a, b, 和 op)定义较短的名称。
  • 使用 switch 语句定义计算逻辑,生成结果 resres 的初始值为 0。
  • switch 语句列举了所有操作,并根据操作码将对应表达式分配给 res
  • 所有操作都直接映射到 Chisel 表达式,最终将结果 res 分配给 ALU 的输出 y

ALU 的 Chisel 实现

以下代码展示了 Leros 的 ALU 及其与累加器寄存器的结合实现(见代码清单 14.2)。ALU 是一个模块类,使用 Chisel 编写,主要完成以下功能:

  • 定义输入/输出接口。
  • 实现基本算术和逻辑运算。
  • 支持不同位宽的数据操作,包括字节和半字操作。
  • 将运算结果存入累加器。

Chisel 实现中的主要组件

scala
class AluAcc(size: Int) extends Module {
  val io = IO(new Bundle {
    val op = Input(UInt(3.W))          // 操作码 (op)
    val din = Input(UInt(size.W))      // 输入数据 (din)
    val enMask = Input(UInt(4.W))      // 子字掩码 (enMask)
    val enEnable = Input(Bool())       // 数据有效标志 (enable)
    val enAlu = Input(Bool())          // ALU 启用标志 (enable ALU)
    val accu = Output(UInt(size.W))    // 累加器输出 (accumulator output)
  })

  val accuReg = RegInit(0.U(size.W))   // 累加器寄存器 (accumulator register)

  // ALU 操作数
  val a = accuReg                      // 操作数 A (来自累加器)
  val b = io.din                       // 操作数 B (来自输入)
  val op = io.op                       // 操作码

  // ALU 运算结果
  val res = WireDefault(a)

  // 操作码对应的运算逻辑
  switch(op) {
    is(nop.U) { res := a }             // 无操作
    is(add.U) { res := a + b }         // 加法
    is(sub.U) { res := a - b }         // 减法
    is(and.U) { res := a & b }         // 按位与
    is(or.U)  { res := a | b }         // 按位或
    is(xor.U) { res := a ^ b }         // 按位异或
    is(shr.U) { res := a >> 1 }        // 右移
    is(ld.U)  { res := b }             // 加载数据
  }

支持子字操作

ALU 支持 8 位字节和 16 位半字的操作,具体逻辑如下:

  1. 提取字节或半字

    • bytehalf 分别表示字节和半字。
    • 通过提取操作数的指定位段实现。
  2. 根据操作标志选择

    • 如果偏移量为 1.U,结果使用字节。
    • 否则,结果使用半字。
scala
val byte = WireDefault(res(7, 0))       // 提取字节
val half = WireDefault(res(15, 0))      // 提取半字

when(io.off === 1.U) {
  res := byte
}
  1. 子字赋值
    • 使用工作区间掩码 enMaskres 的位段更新累加器。
    • 例如,更新累加器的某个子段,而非全段覆盖。

更新累加器

最后,累加器寄存器根据启用标志和 ALU 运算结果进行更新:

scala
when(io.enAlu || io.enEnable && io.enMask.andR()) {
  accuReg := res
}

io.accu := accuReg

ALU 的最终结果存入累加器寄存器,并输出给其他模块。

ALU 的测试

为了验证 ALU 的功能性,还编写了测试代码,见代码清单 14.3。

Scala 实现的参考代码

在测试过程中,ALU 的硬件逻辑使用了一个简化的纯 Scala 实现,用于生成期望的运算结果。以下代码定义了 ALU 的运算逻辑:

scala
def alu(a: Int, b: Int, op: Int): Int = {
  op match {
    case 0 => a          // 无操作
    case 1 => a + b      // 加法
    case 2 => a - b      // 减法
    case 3 => a & b      // 按位与
    case 4 => a | b      // 按位或
    case 5 => a ^ b      // 按位异或
    case 6 => b          // 加载
    case 7 => a >>> 1    // 无符号右移
    case _ => -123       // 错误操作码
  }
}

测试用例设计

  1. 边界条件

    • 针对 ALU 的各种操作码测试(0-7),选择有代表性的边界值进行验证:

      scala
      val interesting = Seq(1, 2, 4, 123, 0, -1, -2, 0x80000000, 0x7fffffff)
  2. 随机输入测试

    • 使用随机生成的 32 位整数对 ALU 的功能进行额外验证:

      scala
      val randoms = Seq.fill(100)(scala.util.Random.nextInt())
  3. 测试框架

    • 定义一个通用测试函数 testOne

      scala
      def testOne(a: Int, b: Int, fun: Int): Unit = {
        dut.io.op.poke(fun.U)
        dut.io.din.poke(b.U)
        dut.clock.step(1)
        dut.io.accu.expect(alu(a, b, fun).U)
      }
    • 对所有可能的操作码和测试数据执行迭代测试。

测试结果

运行测试后,结果如下:

plaintext
[info] Test run completed in 1 second, 794 milliseconds.
[info] Total number of tests run: 1
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.

测试结果表明 ALU 的实现是正确的。

14.4 Decoding Instructions

在完成 ALU 的实现后,下一步是实现 指令解码器(Instruction Decoder)。指令解码的核心任务是生成控制信号,这些信号驱动多路复用器和 ALU,使其在下一状态中执行对应的操作。

为了统一硬件实现、汇编器和指令集模拟器中的指令编码,首先在 Scala 中定义了一个共享的指令编码常量类。这样可以在多个模块中共享这些编码,确保指令行为的一致性。

指令编码常量

以下是指令的编码,定义在一个名为 Constants 的对象中:

scala
object Constants {
  val NOP   = 0x00    // 无操作
  val ADD   = 0x08    // 加法
  val ADDI  = 0x09    // 加法(立即数)
  val SUB   = 0x0C    // 减法
  val SUBI  = 0x0D    // 减法(立即数)
  val SHR   = 0x10    // 右移
  val LD    = 0x20    // 加载
  val LDI   = 0x21    // 加载(立即数)
  val AND   = 0x22    // 按位与
  val ANDI  = 0x23    // 按位与(立即数)
  val OR    = 0x24    // 按位或
  val ORI   = 0x25    // 按位或(立即数)
  val XOR   = 0x26    // 按位异或
  val XORI  = 0x27    // 按位异或(立即数)
  val LDHI  = 0x29    // 加载高位立即数
  val LDH2I = 0x2A    // 加载更高位立即数
  val LDH3I = 0x2B    // 加载最高位立即数
  val ST    = 0x30    // 存储
  // ...
}

这些指令编码直接对应硬件指令集中的操作码(Opcode),确保在硬件实现、软件模拟和汇编器中使用统一的符号。

解码器的实现

解码输出结构

解码器的主要输出是一个包含解码信息的 Bundle,如下定义:

scala
class DecodeOut extends Bundle {
  val operand = UInt(32.W)    // 操作数
  val enMask = UInt(4.W)      // 掩码,用于字节/半字操作
  val op = UInt(10.W)         // 操作码
  val off = SInt(10.W)        // 偏移值,用于间接加载/存储
  val isRegOpd = Bool()       // 是否是寄存器操作数
  val useDecOpd = Bool()      // 是否使用解码的操作数
  val isStore = Bool()        // 是否是存储操作
  // 其他字段
}

该结构包含了指令执行所需的所有信息,例如操作数、偏移值、是否启用存储等。

此外,定义了一个伴生对象 DecodeOut,提供默认解码输出的方法:

scala
object DecodeOut {
  val MaskNone = "b0000".U    // 默认掩码(无掩码)
  val MaskAll = "b1111".U     // 全掩码

  def default(): DecodeOut = {
    val v = Wire(new DecodeOut)
    v.operand := 0.U
    v.enMask := MaskNone
    v.op := NOP.U
    v.off := 0.S
    v.isRegOpd := false.B
    v.useDecOpd := false.B
    v.isStore := false.B
    // 初始化其他字段
    v
  }
}

通过 default() 方法,所有解码信号的默认值被设置为合理的状态,简化了解码器的设计。

解码器模块

解码器本身是一个 Chisel 模块,输入为 16 位指令,输出为解码后的信号:

scala
class Decode() extends Module {
  val io = IO(new Bundle {
    val din = Input(UInt(16.W))        // 输入的指令
    val dout = Output(new DecodeOut)  // 解码输出
  })

  import DecodeOut._

  val d = DecodeOut.default()         // 获取默认解码输出

  // 根据指令的操作码进行解码
  switch(io.din(15, 8)) {              // 高 8 位是操作码
    is(ADD.U) {                       // 加法指令
      d.op := ADD.U
      d.enMask := MaskAll
      d.useDecOpd := true.B
    }
    is(ADDI.U) {                      // 加法(立即数)
      d.op := ADDI.U
      d.enMask := MaskAll
      d.useDecOpd := true.B
    }
    is(SUB.U) {                       // 减法
      d.op := SUB.U
      d.enMask := MaskAll
      d.useDecOpd := true.B
    }
    is(SUBI.U) {                      // 减法(立即数)
      d.op := SUBI.U
      d.enMask := MaskAll
      d.useDecOpd := true.B
    }
    is(SHR.U) {                       // 右移
      d.op := SHR.U
      d.enMask := MaskAll
    }
    // ...
  }
  
  // 输出解码结果
  io.dout := d
}

上述代码展示了如何根据指令的操作码生成控制信号。对于大多数指令,高 8 位是操作码,其余位可能包含立即数或地址偏移。

此外,解码器还负责:

  • 符号扩展立即数:将指令中的常量进行符号扩展,以支持负值操作。
  • 计算偏移值:间接加载和存储指令中,解码器计算实际地址偏移。

14.5 Assembling Instructions

汇编器负责将人类可读的汇编语言指令转换为二进制机器指令。Leros 的汇编器与解码器共享相同的指令编码常量,从而确保一致性。

汇编器的实现

以下是汇编器中将指令转换为机器码的逻辑,使用 Scala 实现:

scala
def assemble(instr: String): UInt = {
  instr match {
    case "add"  => Constants.ADD.U
    case "addi" => Constants.ADDI.U
    case "sub"  => Constants.SUB.U
    case "subi" => Constants.SUBI.U
    case "shr"  => Constants.SHR.U
    case _      => throw new IllegalArgumentException("Unknown instruction")
  }
}

汇编器接受一条汇编指令,并根据操作码常量生成对应的机器码。

测试和验证

在测试过程中,汇编器的输出与指令解码器的行为进行了交叉验证:

  1. 汇编器将指令生成机器码。
  2. 解码器解析机器码并生成控制信号。
  3. 验证解码结果与期望的信号是否一致。

14.5 Assembling Instructions

为了给 Leros 处理器编写程序,需要使用 汇编器(Assembler) 将人类可读的汇编指令翻译为机器码。以下部分讨论了如何手动初始化指令存储器,以及用 Scala 编写一个简单的汇编器来实现自动化的指令翻译。

指令存储器的硬编码初始化

在最初的测试中,可以通过手动编写少量机器码并将其加载到指令存储器中进行测试。以下代码定义了一个硬编码的程序:

scala
val prog = Array[Int](
  0x0903,  // addi 0x3
  0x09ff,  // addi -1
  0x0d02,  // subi 2
  0x21ab,  // ldi 0xab
  0x230f,  // and 0x0f
  0x25c3,  // or 0xc3
  0x0000   // nop
)

def getProgramFix() = prog

以上程序:

  1. 将立即数 0x3 加到累加器。
  2. 从累加器减去 -12
  3. 加载 0xab 到累加器,并进行逻辑与(and)和逻辑或(or)操作。
  4. 最后一条指令是 nop,表示无操作。

缺点:硬编码的方式效率低下,仅适用于简单测试。因此,需要更灵活的汇编器来自动生成机器码。

用 Scala 实现汇编器

Leros 汇编器是一个简化的工具,能够将汇编语言代码翻译为机器码。以下是其核心功能和实现细节。

双遍解析(Two-Pass Parsing)

汇编器采用两遍解析的方式:

  1. 第一遍:收集符号表(Symbol Table),即标记(Label)和其对应的程序计数器(PC)地址。
  2. 第二遍:利用符号表解析标记并生成最终的机器码。
scala
def assemble(prog: String): Array[Int] = {
  assemble(prog, false)   // 第一遍:生成符号表
  assemble(prog, true)    // 第二遍:生成机器码
}

符号表的生成

在第一遍中,使用一个可变 Map 来存储符号表:

scala
val symbols = collection.mutable.Map[String, Int]()

如果行是一个标记(Label),例如 loop:, 将其记录到符号表中:

scala
case Pattern(l) => 
  if (!pass2) symbols += (l.substring(0, l.length - 1) -> pc)

工具函数

为了解析汇编指令中的操作数,定义了以下两个工具函数:

  1. toInt:将立即数转换为整数,支持十进制和十六进制格式。
  2. regNumber:将寄存器名称(如 r3)转换为数字。
scala
def toInt(s: String): Int = {
  if (s.startsWith("0x")) Integer.parseInt(s.substring(2), 16)
  else Integer.parseInt(s)
}

def regNumber(s: String): Int = {
  assert(s.startsWith("r"), "Register numbers shall start with 'r'")
  s.substring(1).toInt
}

汇编器的核心逻辑

汇编器的核心部分在于逐行解析汇编代码并生成机器码。以下代码展示了主要逻辑(代码清单 14.4):

scala
for (line <- source.getLines()) {
  val tokens = line.trim.split(" ")       // 拆分行内容为指令和操作数
  val instr = tokens(0)                   // 提取指令
  instr match {
    case "add"  => program ::= (ADD << 8) + regNumber(tokens(1))
    case "addi" => program ::= (ADDI << 8) + toInt(tokens(1))
    case "sub"  => program ::= (SUB << 8) + regNumber(tokens(1))
    case "subi" => program ::= (SUBI << 8) + toInt(tokens(1))
    case "and"  => program ::= (AND << 8) + regNumber(tokens(1))
    case "andi" => program ::= (ANDI << 8) + toInt(tokens(1))
    case "or"   => program ::= (OR << 8) + regNumber(tokens(1))
    case "ori"  => program ::= (ORI << 8) + toInt(tokens(1))
    case "xor"  => program ::= (XOR << 8) + regNumber(tokens(1))
    case "xori" => program ::= (XORI << 8) + toInt(tokens(1))
    case "shr"  => program ::= (SHR << 8)
    case "//"   => // 注释行,跳过
    case ""     => // 空行,跳过
    case _      => throw new Exception("Unknown instruction: " + instr)
  }
}
  • 逻辑说明
    • 每行代码拆分为指令和操作数。
    • 使用 match 语句匹配指令,基于操作码常量生成对应的机器码。
    • 对立即数操作数和寄存器编号使用工具函数解析。
    • 注释和空行被忽略。

示例汇编代码

以下是一个示例程序的汇编代码:

assembly
addi 3       // 将立即数 3 加到累加器
addi -1      // 从累加器减去 1
subi 2       // 从累加器减去 2
ldi 0xab     // 加载立即数 0xab
and 0x0f     // 执行按位与
or 0xc3      // 执行按位或

在汇编器中,该代码会被翻译为以下机器码:

scala
Array(
  0x0903,    // addi 3
  0x09ff,    // addi -1
  0x0d02,    // subi 2
  0x21ab,    // ldi 0xab
  0x230f,    // and 0x0f
  0x25c3     // or 0xc3
)

小结

Leros 汇编器是一个高效的工具,用于将人类可读的汇编代码翻译为机器指令。其设计特点包括:

  1. 双遍解析:生成符号表并解析指令,支持标记和偏移操作。
  2. 工具函数:简化了立即数和寄存器的解析。
  3. 模块化实现:通过 Scala 的函数式特性,将解析逻辑和工具方法清晰分离。

这种汇编器设计为 Leros 程序开发提供了便利,同时通过共享的指令常量确保了汇编器与处理器解码器的一致性。

14.6 The Instruction Memory

指令存储器是 Leros 处理器的重要组件,用于存储程序的机器指令。在硬件设计中,这部分存储器通常作为片上存储器实现。以下部分描述了 Leros 的指令存储器模块,以及如何通过汇编器将程序加载到存储器中。

指令存储器的实现

功能概述

  1. 指令存储器的配置

    • 通过参数 memAddrWidth 指定地址位宽,决定了存储器的大小(地址空间为 2memAddrWidth)。
    • 接收程序文件路径 prog,通过汇编器加载程序代码。
  2. 汇编代码的加载

    • 使用汇编器生成的机器码初始化指令存储器。
    • 使用 Chisel 的 VecInit 构造存储器,其中每条指令为 16 位宽。
  3. 地址寄存器

    • 使用 memReg 寄存器存储当前的指令地址,以支持片上存储器的设计。

核心代码(Listing 14.5)

以下是指令存储器模块的完整代码:

scala
class InstrMem(memAddrWidth: Int, prog: String) extends Module {
  val io = IO(new Bundle {
    val addr = Input(UInt(memAddrWidth.W))  // 输入地址
    val instr = Output(UInt(16.W))         // 输出指令
  })

  // 调用汇编器加载程序代码
  val code = Assembler.getProgram(prog)

  // 检查程序是否超出存储器大小
  assert(scala.math.pow(2, memAddrWidth) >= code.length, 
         "Program too large")

  // 初始化存储器
  val progMem = VecInit(code.toIndexedSeq.map(_.asUInt(16.W)))

  // 定义地址寄存器
  val memReg = RegInit(0.U(memAddrWidth.W))
  memReg := io.addr

  // 从存储器中读取指令
  io.instr := progMem(memReg)
}

代码解析

1. I/O 接口

模块的输入输出通过 io 定义:

  • 输入地址 (addr):表示当前需要读取的指令地址,宽度为 memAddrWidth
  • 输出指令 (instr):表示从存储器读取的 16 位机器指令。
scala
val io = IO(new Bundle {
  val addr = Input(UInt(memAddrWidth.W))
  val instr = Output(UInt(16.W))
})

2. 加载程序代码

通过 Assembler.getProgram(prog) 调用汇编器,将汇编代码文件解析为机器码数组 code
同时,使用断言检查程序的长度是否超过存储器的容量。

scala
val code = Assembler.getProgram(prog)
assert(scala.math.pow(2, memAddrWidth) >= code.length, 
       "Program too large")

3. 初始化存储器

使用 Chisel 的 VecInit 方法,将机器码数组转换为 Vec 数据结构,以实现指令存储器的初始化。

scala
val progMem = VecInit(code.toIndexedSeq.map(_.asUInt(16.W)))
  • VecInit:用于初始化一个 Chisel 的 Vec,即向量型数据结构。
  • asUInt(16.W):将每条机器码指令转换为 16 位无符号整数格式。

4. 地址寄存器

定义 memReg 寄存器,用于存储当前指令地址。通过 io.addr 更新 memReg 的值。

scala
val memReg = RegInit(0.U(memAddrWidth.W))
memReg := io.addr

5. 指令读取

通过地址寄存器 memReg 读取指令存储器中的内容,并将其输出。

scala
io.instr := progMem(memReg)

指令存储器的特点

  1. 硬件生成器

    • 模块化设计可以在硬件生成阶段将汇编器直接嵌入到硬件中。这种设计方式特别适合嵌入式处理器的硬件生成。
  2. 灵活性

    • 指令存储器的大小通过参数 memAddrWidth 配置,可以适配不同的程序规模。
    • 汇编器的直接集成支持灵活加载程序代码。
  3. FPGA 实现

    • 模块包含地址寄存器,支持作为片上存储器在 FPGA 上的直接实现。

14.7 A State Machine with Data Path Implementation

Leros 的设计基于一个状态机与数据通路的组合。这种实现方式将所有指令的执行分为多个状态,从而使得每条指令的执行过程跨越多个时钟周期。本节描述了 Leros 的状态机与数据通路的具体实现,包括状态的切换、主要寄存器的管理以及数据存储器模块的集成。

设计概述

  • 共享存储器架构:在当前实现中,数据存储器与寄存器文件共享同一存储器。256 个寄存器被表示为数据存储器中的一个数组。
  • 两状态设计
    • 取指状态 (fetch):从指令存储器中获取指令并解码,同时启动从数据存储器读取操作。
    • 执行状态 (execute):执行解码后的操作,包括更新累加器或将读取的数据存入累加器。

状态机实现

状态机只包含两个状态:fetchexecute,通过切换实现指令的逐步执行。以下代码定义了状态的枚举类型及其寄存器:

scala
object State extends ChiselEnum {
  val fetch, execute = Value
}
import State._

val stateReg = RegInit(fetch)
  • 状态寄存器 stateReg:初始化为 fetch 状态,用于存储当前状态。

状态切换逻辑如下:

scala
switch(stateReg) {
  is(fetch) {
    stateReg := execute
  }
  is(execute) {
    stateReg := fetch
  }
}
  • 当处于 fetch 状态时,切换到 execute 状态以执行指令。
  • 当处于 execute 状态时,切换回 fetch 状态以获取下一条指令。

数据通路实现

主要模块与寄存器

  1. ALU 与累加器

    • ALU:执行算术和逻辑运算。
    • 累加器 (accu):存储 ALU 的计算结果。
    scala
    val alu = Module(new AluAcc(size))
    val accu = alu.io.accu
  2. 程序计数器 (pcReg)

    • 用于存储当前指令的地址。
    scala
    val pcReg = RegInit(0.U(memAddrWidth.W))
  3. 地址寄存器 (addrReg)

    • 用于存储内存操作的地址。
    scala
    val addrReg = RegInit(0.U(memAddrWidth.W))

指令存储器

指令存储器实例化时,需要提供地址位宽和程序文件路径:

scala
val mem = Module(new InstrMem(memAddrWidth, prog))
mem.io.addr := pcReg
val instr = mem.io.instr
  • 输入 (addr):程序计数器 pcReg 提供当前指令地址。
  • 输出 (instr):当前地址对应的指令内容。

解码器

解码器从指令存储器获取指令,并生成对应的控制信号。解码输出寄存器 (decReg) 用于存储解码信号:

scala
val dec = Module(new Decode())
dec.io.din := instr
val decout = dec.io.dout

val decReg = RegInit(DecodeOut.default())
when (stateReg === fetch) {
  decReg := decout
}
  • decReg 的作用:在 fetch 状态中记录解码信号,用于 execute 状态的指令执行。

数据存储器

数据存储器既用于寄存器文件的实现,也存储程序运行过程中需要访问的内存数据。以下是数据存储器模块的实现(Listing 14.6):

scala
class DataMem(memAddrWidth: Int) extends Module {
  val io = IO(new Bundle {
    val rdAddr = Input(UInt(memAddrWidth.W)) // 读地址
    val rdData = Output(UInt(32.W))         // 读数据
    val wrAddr = Input(UInt(memAddrWidth.W)) // 写地址
    val wrData = Input(UInt(32.W))          // 写数据
    val wrMask = Input(UInt(4.W))           // 写掩码
    val wr = Input(Bool())                  // 写使能信号
  })

  // 定义存储器,大小为 2^memAddrWidth,按字节存储
  val mem = SyncReadMem(1 << memAddrWidth, Vec(4, UInt(8.W)))

  // 读取数据
  val rdVec = mem.read(io.rdAddr)
  io.rdData := rdVec(3) ## rdVec(2) ## rdVec(1) ## rdVec(0)

  // 写入数据
  when(io.wr) {
    for (i <- 0 until 4) {
      when(io.wrMask(i)) {
        mem.write(io.wrAddr, VecInit((io.wrData >> (i * 8))(7, 0)))
      }
    }
  }
}
  • 读取数据 (rdData)
    • rdAddr 指定的地址读取数据,拼接成 32 位输出。
  • 写入数据 (wrData)
    • 使用写掩码 wrMask 选择性写入 4 字节中的某些字节。
    • 写操作受 wr 信号控制。

数据通路的连接

以下代码展示了如何连接数据存储器与其他模块:

scala
val dataMem = Module(new DataMem(memAddrWidth))

// 地址选择:寄存器访问或立即数地址
val memAddr = Mux(decout.isDataAccess, effAddrWord, instr(7, 0))
val memAddrReg = RegNext(memAddr)

// 数据存储器的输入输出
dataMem.io.rdAddr := memAddr
val dataRead = dataMem.io.rdData
dataMem.io.wrAddr := memAddrReg
dataMem.io.wrData := accu
dataMem.io.wr := false.B
dataMem.io.wrMask := "b1111".U
  • 读操作
    • 地址由指令提供(立即数)或通过解码信号指定。
    • 读取结果存储在 dataRead 中,并在执行状态时写入累加器。
  • 写操作
    • 写入地址和数据分别由 memAddrReg 和累加器提供,写掩码启用所有字节。

14.8 Implementation Variations

现代处理器通常使用 指令流水线(Instruction Pipelining) 来提高性能。在流水线架构中,可以在同一时间段内执行多条指令的不同阶段。例如,对于 Leros,可以实现一个三阶段流水线,包括以下阶段:

  1. 指令取指(Instruction Fetch):从指令存储器中读取当前指令。
  2. 指令解码(Instruction Decode):解析指令并生成控制信号。
  3. 指令执行(Execute):执行操作,如算术运算、逻辑运算或内存访问。

在这种设计中,可以同时处理三条指令,每条指令处于流水线的不同阶段。这意味着每个时钟周期可以完成一条指令的执行。

相比本章介绍的两状态实现(取指和执行需要两个时钟周期),流水线架构能够将 Leros 的性能 提升约两倍。这种改进是通过增加硬件并发度实现的,但需要注意流水线中的数据相关性、控制相关性以及可能的流水线停顿等问题。

14.9 Exercise

本章的练习为读者提供了一个开放性任务,旨在巩固 Chisel 学习,并尝试 Leros 的设计和实现。以下是几种建议的练习方式:

1. 重读本章并研究 Leros 仓库中的代码

  • 目标:熟悉 Leros 的实现细节,并理解设计背后的逻辑。
  • 步骤
    1. 仔细阅读本章内容。
    2. 前往 Leros 仓库,获取 Leros 的完整源码。
    3. 运行测试用例,分析其输出结果。
    4. 修改代码(如更改 ALU 操作或指令解码逻辑),观察测试是否失败,以及如何修复问题。

2. 编写自己的 Leros 实现

  • 目标:根据本章的指导,用 Chisel 从零开始实现 Leros 处理器。
  • 建议实现路径
    1. 实现一个最小版本的 Leros,包括累加器、ALU、解码器、指令存储器和数据存储器。
    2. 可以从单周期实现开始,即每条指令在一个时钟周期内完成所有操作。
    3. 逐步扩展功能,例如增加流水线阶段或优化内存接口。
    4. 如果想挑战更高的复杂度,可以尝试实现超标量(Superscalar)流水线,以支持更高的时钟频率。

3. 从零设计一个全新的处理器

  • 目标:设计一个全新架构的处理器,而不仅限于模仿 Leros。
  • 可能的设计方向
    1. 使用寄存器堆(Register File)而非累加器作为数据存储结构。
    2. 添加更复杂的指令集,如乘法、浮点运算或复杂的分支指令。
    3. 实现多周期操作,例如处理复杂的算术指令或支持访存的分步实现。
    4. 探索更加先进的架构,例如分支预测、乱序执行(Out-of-Order Execution)或多核心处理器。

任务的价值

  1. 巩固 Chisel 编程能力:通过 Leros 的代码和硬件模块实现,加深对 Chisel 工具的理解。
  2. 强化处理器设计基础:Leros 的设计展示了一个从简单指令集到硬件实现的完整流程,是一个非常适合学习的范例。
  3. 实践工程方法:通过调试代码和测试设计结果,理解硬件开发中的挑战与解决方法。
  4. 激发创造力:通过从零设计一个新处理器,拓展对计算机架构设计的理解和创新能力。 无论选择哪种练习方式,都可以通过研究 Leros 的源码和设计原则来深入学习处理器设计。同时,这也是探索 Chisel 工具的能力和硬件开发工程方法的良好机会。