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)。
Opcode | Function | Description |
---|---|---|
add | A = A + Rn | 将寄存器 Rn 的值加到累加器 A |
addi | A = A + i | 将立即数 i 加到累加器 A |
sub | A = A - Rn | 用累加器 A 减去寄存器 Rn 的值 |
subi | A = A - i | 从累加器 A 中减去立即数 i |
shr | A = A >>> 1 | 将累加器 A 的值逻辑右移一位 |
load | A = Rn | 将寄存器 Rn 的值加载到累加器 A |
loadi | A = i | 将立即数 i 加载到累加器 A |
and | A = A and Rn | 对累加器 A 和寄存器 Rn 的值执行按位与操作 |
andi | A = A and i | 对累加器 A 和立即数 i 执行按位与操作 |
or | A = A or Rn | 对累加器 A 和寄存器 Rn 的值执行按位或操作 |
ori | A = A or i | 对累加器 A 和立即数 i 执行按位或操作 |
xor | A = A xor Rn | 对累加器 A 和寄存器 Rn 的值执行按位异或操作 |
xori | A = A xor i | 对累加器 A 和立即数 i 执行按位异或操作 |
loadhi | A₁₅₋₈ = i | 将立即数 i 加载到累加器的第二字节 |
loadh2i | A₂₃₋₁₆ = i | 将立即数 i 加载到累加器的第三字节 |
loadh3i | A₃₁₋₂₄ = i | 将立即数 i 加载到累加器的第四字节 |
store | Rn = A | 将累加器 A 的值存储到寄存器 Rn |
jal | PC = A, Rn = PC + 2 | 跳转到累加器 A 指向的地址,并将返回地址存储在寄存器 Rn |
ldaddr | AR = A | 将累加器 A 的值加载到地址寄存器 AR |
loadind | A = mem[AR + (i << 2)] | 从内存加载一个字到累加器 A |
loadindb | A = mem[AR + i][7:0] | 从内存加载一个字节到累加器 A |
loadindh | A = mem[AR + (i << 1)][15:0] | 从内存加载半字到累加器 A |
storeind | mem[AR + (i << 2)] = A | 将累加器 A 的值存储到内存 |
storeindb | mem[AR + i] = A₇₋₀ | 将累加器 A 的字节存储到内存 |
storeindh | mem[AR + (i << 1)] = A₁₅₋₀ | 将累加器 A 的半字存储到内存 |
br | PC = PC + o | 跳转到偏移地址 o |
brz | if A == 0 PC = PC + o | 若累加器 A 为 0,则跳转到偏移地址 o |
brnz | if A != 0 PC = PC + o | 若累加器 A 不为 0,则跳转到偏移地址 o |
brp | if A >= 0 PC = PC + o | 若累加器 A 为正,则跳转到偏移地址 o |
brn | if A < 0 PC = PC + o | 若累加器 A 为负,则跳转到偏移地址 o |
scall | System 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 汇编语言中一些指令的示例:
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 位的部分位)。
以下是两个指令的编码示例:
00001001.00000010
:表示一条 add immediate(addi) 指令,将立即数 2 加到累加器中。00001000.00000011
:表示一条 add register(add) 指令,将寄存器 R3 的值加到累加器中。
对于分支指令,偏移量的部分位会使用高 8 位中的额外指令位。这种设计节省了编码空间,并为更大的偏移量提供支持。
指令编码表
下表展示了 Leros 指令的编码方案,未使用的指令位以 -
表示:
编码 | 指令 |
---|---|
00000--- | nop |
000010-0 | add |
000010-1 | addi |
000011-0 | sub |
000011-1 | subi |
00010--- | sra |
00100000 | load |
00100001 | loadi |
00100010 | and |
00100011 | andi |
00100100 | or |
00100101 | ori |
00100110 | xor |
00100111 | xori |
00101000 | loadhi |
00101001 | loadh2i |
00101010 | loadh3i |
00110--- | store |
001110-? | out |
000001-? | in |
010000--- | jal |
1000nnnn | br |
1001nnnn | brz |
1010nnnn | brnz |
1011nnnn | brp |
1100nnnn | brn |
11111111 | scall |
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)
在取指状态,指令从指令存储器中读取并被解码。解码组件决定下一状态中将执行的操作:
- 对于立即数操作指令(例如
addi
或loadhi
),解码器会生成操作数,并将其存储到寄存器中以备执行状态使用。
状态 2:执行(execute)
在执行状态:
- 第二块存储器充当通用数据存储器,同时也存储了 255 个寄存器的值。对于寄存器的读写,地址是指令的一部分。
- 对于内存加载或存储指令,地址寄存器(AR)的值来自累加器 A。加载操作的结果存入 A,而存储操作则由 A 提供数据写入内存。
- 算术和逻辑运算由 算术逻辑单元(ALU) 完成:
- 一个操作数来自累加器 A;
- 另一个操作数可以是立即数(由指令提供)或寄存器值(从存储器读取)。
通过这两种状态的简单切换,实现了对所有指令的支持。
图 14.1 Leros 数据通路
图 14.1 展示了 Leros 数据通路的基本组成部分,包括:
- 指令存储器:存储程序代码,从 PC 中获取当前指令。
- 数据存储器:存储寄存器值及程序运行时的数据。
- 地址寄存器(AR):为内存操作提供地址。
- 累加器(A):主要操作数寄存器,用于存储 ALU 的操作数和计算结果。
- 解码器(Decode):解析指令,生成控制信号,决定数据流的方向和 ALU 的操作类型。
- ALU(算术逻辑单元):执行算术和逻辑运算。
数据在各组件之间通过总线传递,ALU 的结果返回累加器,从而完成数据操作和存储。
14.3 Start with an ALU
处理器的核心组件是 算术逻辑单元(Arithmetic Logic Unit, ALU)。因此,开发 Leros 的第一步是设计 ALU 并编写测试用例。首先,需要定义用于表示 ALU 不同操作的常量,如下所示:
// 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 通常包含以下组件:
- 两个操作数输入(称为
a
和b
):用于输入参与运算的值。 - 操作码输入(
op
):选择 ALU 执行的具体操作。 - 输出(
y
):运算结果。
代码清单 14.2 展示了 ALU 的实现逻辑:
- 首先,为三个输入(
a
,b
, 和op
)定义较短的名称。 - 使用
switch
语句定义计算逻辑,生成结果res
。res
的初始值为 0。 switch
语句列举了所有操作,并根据操作码将对应表达式分配给res
。- 所有操作都直接映射到 Chisel 表达式,最终将结果
res
分配给 ALU 的输出y
。
ALU 的 Chisel 实现
以下代码展示了 Leros 的 ALU 及其与累加器寄存器的结合实现(见代码清单 14.2)。ALU 是一个模块类,使用 Chisel 编写,主要完成以下功能:
- 定义输入/输出接口。
- 实现基本算术和逻辑运算。
- 支持不同位宽的数据操作,包括字节和半字操作。
- 将运算结果存入累加器。
Chisel 实现中的主要组件
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 位半字的操作,具体逻辑如下:
提取字节或半字:
byte
和half
分别表示字节和半字。- 通过提取操作数的指定位段实现。
根据操作标志选择:
- 如果偏移量为
1.U
,结果使用字节。 - 否则,结果使用半字。
- 如果偏移量为
val byte = WireDefault(res(7, 0)) // 提取字节
val half = WireDefault(res(15, 0)) // 提取半字
when(io.off === 1.U) {
res := byte
}
- 子字赋值:
- 使用工作区间掩码
enMask
和res
的位段更新累加器。 - 例如,更新累加器的某个子段,而非全段覆盖。
- 使用工作区间掩码
更新累加器
最后,累加器寄存器根据启用标志和 ALU 运算结果进行更新:
when(io.enAlu || io.enEnable && io.enMask.andR()) {
accuReg := res
}
io.accu := accuReg
ALU 的最终结果存入累加器寄存器,并输出给其他模块。
ALU 的测试
为了验证 ALU 的功能性,还编写了测试代码,见代码清单 14.3。
Scala 实现的参考代码
在测试过程中,ALU 的硬件逻辑使用了一个简化的纯 Scala 实现,用于生成期望的运算结果。以下代码定义了 ALU 的运算逻辑:
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 // 错误操作码
}
}
测试用例设计
边界条件:
针对 ALU 的各种操作码测试(
0-7
),选择有代表性的边界值进行验证:scalaval interesting = Seq(1, 2, 4, 123, 0, -1, -2, 0x80000000, 0x7fffffff)
随机输入测试:
使用随机生成的 32 位整数对 ALU 的功能进行额外验证:
scalaval randoms = Seq.fill(100)(scala.util.Random.nextInt())
测试框架:
定义一个通用测试函数
testOne
:scaladef 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) }
对所有可能的操作码和测试数据执行迭代测试。
测试结果
运行测试后,结果如下:
[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
的对象中:
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,如下定义:
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
,提供默认解码输出的方法:
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 位指令,输出为解码后的信号:
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 实现:
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")
}
}
汇编器接受一条汇编指令,并根据操作码常量生成对应的机器码。
测试和验证
在测试过程中,汇编器的输出与指令解码器的行为进行了交叉验证:
- 汇编器将指令生成机器码。
- 解码器解析机器码并生成控制信号。
- 验证解码结果与期望的信号是否一致。
14.5 Assembling Instructions
为了给 Leros 处理器编写程序,需要使用 汇编器(Assembler) 将人类可读的汇编指令翻译为机器码。以下部分讨论了如何手动初始化指令存储器,以及用 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
以上程序:
- 将立即数
0x3
加到累加器。 - 从累加器减去
-1
和2
。 - 加载
0xab
到累加器,并进行逻辑与(and
)和逻辑或(or
)操作。 - 最后一条指令是
nop
,表示无操作。
缺点:硬编码的方式效率低下,仅适用于简单测试。因此,需要更灵活的汇编器来自动生成机器码。
用 Scala 实现汇编器
Leros 汇编器是一个简化的工具,能够将汇编语言代码翻译为机器码。以下是其核心功能和实现细节。
双遍解析(Two-Pass Parsing)
汇编器采用两遍解析的方式:
- 第一遍:收集符号表(Symbol Table),即标记(Label)和其对应的程序计数器(PC)地址。
- 第二遍:利用符号表解析标记并生成最终的机器码。
def assemble(prog: String): Array[Int] = {
assemble(prog, false) // 第一遍:生成符号表
assemble(prog, true) // 第二遍:生成机器码
}
符号表的生成
在第一遍中,使用一个可变 Map 来存储符号表:
val symbols = collection.mutable.Map[String, Int]()
如果行是一个标记(Label),例如 loop:
, 将其记录到符号表中:
case Pattern(l) =>
if (!pass2) symbols += (l.substring(0, l.length - 1) -> pc)
工具函数
为了解析汇编指令中的操作数,定义了以下两个工具函数:
toInt
:将立即数转换为整数,支持十进制和十六进制格式。regNumber
:将寄存器名称(如r3
)转换为数字。
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):
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
语句匹配指令,基于操作码常量生成对应的机器码。 - 对立即数操作数和寄存器编号使用工具函数解析。
- 注释和空行被忽略。
示例汇编代码
以下是一个示例程序的汇编代码:
addi 3 // 将立即数 3 加到累加器
addi -1 // 从累加器减去 1
subi 2 // 从累加器减去 2
ldi 0xab // 加载立即数 0xab
and 0x0f // 执行按位与
or 0xc3 // 执行按位或
在汇编器中,该代码会被翻译为以下机器码:
Array(
0x0903, // addi 3
0x09ff, // addi -1
0x0d02, // subi 2
0x21ab, // ldi 0xab
0x230f, // and 0x0f
0x25c3 // or 0xc3
)
小结
Leros 汇编器是一个高效的工具,用于将人类可读的汇编代码翻译为机器指令。其设计特点包括:
- 双遍解析:生成符号表并解析指令,支持标记和偏移操作。
- 工具函数:简化了立即数和寄存器的解析。
- 模块化实现:通过 Scala 的函数式特性,将解析逻辑和工具方法清晰分离。
这种汇编器设计为 Leros 程序开发提供了便利,同时通过共享的指令常量确保了汇编器与处理器解码器的一致性。
14.6 The Instruction Memory
指令存储器是 Leros 处理器的重要组件,用于存储程序的机器指令。在硬件设计中,这部分存储器通常作为片上存储器实现。以下部分描述了 Leros 的指令存储器模块,以及如何通过汇编器将程序加载到存储器中。
指令存储器的实现
功能概述
指令存储器的配置:
- 通过参数
memAddrWidth
指定地址位宽,决定了存储器的大小(地址空间为)。 - 接收程序文件路径
prog
,通过汇编器加载程序代码。
- 通过参数
汇编代码的加载:
- 使用汇编器生成的机器码初始化指令存储器。
- 使用 Chisel 的
VecInit
构造存储器,其中每条指令为 16 位宽。
地址寄存器:
- 使用
memReg
寄存器存储当前的指令地址,以支持片上存储器的设计。
- 使用
核心代码(Listing 14.5)
以下是指令存储器模块的完整代码:
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 位机器指令。
val io = IO(new Bundle {
val addr = Input(UInt(memAddrWidth.W))
val instr = Output(UInt(16.W))
})
2. 加载程序代码
通过 Assembler.getProgram(prog)
调用汇编器,将汇编代码文件解析为机器码数组 code
。
同时,使用断言检查程序的长度是否超过存储器的容量。
val code = Assembler.getProgram(prog)
assert(scala.math.pow(2, memAddrWidth) >= code.length,
"Program too large")
3. 初始化存储器
使用 Chisel 的 VecInit
方法,将机器码数组转换为 Vec
数据结构,以实现指令存储器的初始化。
val progMem = VecInit(code.toIndexedSeq.map(_.asUInt(16.W)))
VecInit
:用于初始化一个 Chisel 的Vec
,即向量型数据结构。asUInt(16.W)
:将每条机器码指令转换为 16 位无符号整数格式。
4. 地址寄存器
定义 memReg
寄存器,用于存储当前指令地址。通过 io.addr
更新 memReg
的值。
val memReg = RegInit(0.U(memAddrWidth.W))
memReg := io.addr
5. 指令读取
通过地址寄存器 memReg
读取指令存储器中的内容,并将其输出。
io.instr := progMem(memReg)
指令存储器的特点
硬件生成器:
- 模块化设计可以在硬件生成阶段将汇编器直接嵌入到硬件中。这种设计方式特别适合嵌入式处理器的硬件生成。
灵活性:
- 指令存储器的大小通过参数
memAddrWidth
配置,可以适配不同的程序规模。 - 汇编器的直接集成支持灵活加载程序代码。
- 指令存储器的大小通过参数
FPGA 实现:
- 模块包含地址寄存器,支持作为片上存储器在 FPGA 上的直接实现。
14.7 A State Machine with Data Path Implementation
Leros 的设计基于一个状态机与数据通路的组合。这种实现方式将所有指令的执行分为多个状态,从而使得每条指令的执行过程跨越多个时钟周期。本节描述了 Leros 的状态机与数据通路的具体实现,包括状态的切换、主要寄存器的管理以及数据存储器模块的集成。
设计概述
- 共享存储器架构:在当前实现中,数据存储器与寄存器文件共享同一存储器。256 个寄存器被表示为数据存储器中的一个数组。
- 两状态设计:
- 取指状态 (fetch):从指令存储器中获取指令并解码,同时启动从数据存储器读取操作。
- 执行状态 (execute):执行解码后的操作,包括更新累加器或将读取的数据存入累加器。
状态机实现
状态机只包含两个状态:fetch
和 execute
,通过切换实现指令的逐步执行。以下代码定义了状态的枚举类型及其寄存器:
object State extends ChiselEnum {
val fetch, execute = Value
}
import State._
val stateReg = RegInit(fetch)
- 状态寄存器
stateReg
:初始化为fetch
状态,用于存储当前状态。
状态切换逻辑如下:
switch(stateReg) {
is(fetch) {
stateReg := execute
}
is(execute) {
stateReg := fetch
}
}
- 当处于
fetch
状态时,切换到execute
状态以执行指令。 - 当处于
execute
状态时,切换回fetch
状态以获取下一条指令。
数据通路实现
主要模块与寄存器
ALU 与累加器:
- ALU:执行算术和逻辑运算。
- 累加器 (
accu
):存储 ALU 的计算结果。
scalaval alu = Module(new AluAcc(size)) val accu = alu.io.accu
程序计数器 (
pcReg
):- 用于存储当前指令的地址。
scalaval pcReg = RegInit(0.U(memAddrWidth.W))
地址寄存器 (
addrReg
):- 用于存储内存操作的地址。
scalaval addrReg = RegInit(0.U(memAddrWidth.W))
指令存储器
指令存储器实例化时,需要提供地址位宽和程序文件路径:
val mem = Module(new InstrMem(memAddrWidth, prog))
mem.io.addr := pcReg
val instr = mem.io.instr
- 输入 (
addr
):程序计数器pcReg
提供当前指令地址。 - 输出 (
instr
):当前地址对应的指令内容。
解码器
解码器从指令存储器获取指令,并生成对应的控制信号。解码输出寄存器 (decReg
) 用于存储解码信号:
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):
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
信号控制。
- 使用写掩码
数据通路的连接
以下代码展示了如何连接数据存储器与其他模块:
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,可以实现一个三阶段流水线,包括以下阶段:
- 指令取指(Instruction Fetch):从指令存储器中读取当前指令。
- 指令解码(Instruction Decode):解析指令并生成控制信号。
- 指令执行(Execute):执行操作,如算术运算、逻辑运算或内存访问。
在这种设计中,可以同时处理三条指令,每条指令处于流水线的不同阶段。这意味着每个时钟周期可以完成一条指令的执行。
相比本章介绍的两状态实现(取指和执行需要两个时钟周期),流水线架构能够将 Leros 的性能 提升约两倍。这种改进是通过增加硬件并发度实现的,但需要注意流水线中的数据相关性、控制相关性以及可能的流水线停顿等问题。
14.9 Exercise
本章的练习为读者提供了一个开放性任务,旨在巩固 Chisel 学习,并尝试 Leros 的设计和实现。以下是几种建议的练习方式:
1. 重读本章并研究 Leros 仓库中的代码
- 目标:熟悉 Leros 的实现细节,并理解设计背后的逻辑。
- 步骤:
- 仔细阅读本章内容。
- 前往 Leros 仓库,获取 Leros 的完整源码。
- 运行测试用例,分析其输出结果。
- 修改代码(如更改 ALU 操作或指令解码逻辑),观察测试是否失败,以及如何修复问题。
2. 编写自己的 Leros 实现
- 目标:根据本章的指导,用 Chisel 从零开始实现 Leros 处理器。
- 建议实现路径:
- 实现一个最小版本的 Leros,包括累加器、ALU、解码器、指令存储器和数据存储器。
- 可以从单周期实现开始,即每条指令在一个时钟周期内完成所有操作。
- 逐步扩展功能,例如增加流水线阶段或优化内存接口。
- 如果想挑战更高的复杂度,可以尝试实现超标量(Superscalar)流水线,以支持更高的时钟频率。
3. 从零设计一个全新的处理器
- 目标:设计一个全新架构的处理器,而不仅限于模仿 Leros。
- 可能的设计方向:
- 使用寄存器堆(Register File)而非累加器作为数据存储结构。
- 添加更复杂的指令集,如乘法、浮点运算或复杂的分支指令。
- 实现多周期操作,例如处理复杂的算术指令或支持访存的分步实现。
- 探索更加先进的架构,例如分支预测、乱序执行(Out-of-Order Execution)或多核心处理器。
任务的价值
- 巩固 Chisel 编程能力:通过 Leros 的代码和硬件模块实现,加深对 Chisel 工具的理解。
- 强化处理器设计基础:Leros 的设计展示了一个从简单指令集到硬件实现的完整流程,是一个非常适合学习的范例。
- 实践工程方法:通过调试代码和测试设计结果,理解硬件开发中的挑战与解决方法。
- 激发创造力:通过从零设计一个新处理器,拓展对计算机架构设计的理解和创新能力。 无论选择哪种练习方式,都可以通过研究 Leros 的源码和设计原则来深入学习处理器设计。同时,这也是探索 Chisel 工具的能力和硬件开发工程方法的良好机会。