总线

你已经设计了单周期处理器NPC, 也了解了设备如何工作. 不过我们之前是让仿真环境来提供设备的功能, 现在是时候从硬件上实现NPC与其他设备通信的功能了.

计算机中每个模块并非独立工作, 不同的模块之间需要进行数据交换: CPU和内存控制器之间, 内存控制器和内存颗粒之间, 取指单元和译码单元之间等等,都需要通过一套约定的协议进行通信. 软件同样如此, 在我们使用的DiffTest中, NEMU需要与Spike通信, NPC也需要与NEMU通信才能实现相应的功能.

从广义上来说, 总线其实就是一个通信系统, 用来在不同的模块之间进行数据传输. 我们接下来将聚焦于硬件, 介绍狭义的总线, 即硬件模块间的通信协议.

总线 - 硬件模块间的通信协议

我们先从处理器内部的模块通信开始, 理解总线协议的一般构成.

最简单的总线

NPC中包含取指单元IFU和译码单元IDU, 两个单元间需要进行通信, 将指令由IFU传递给IDU.

+-----+           +-----+
| IFU | inst ---> | IDU |
+-----+           +-----+

在这个简单的场景其实就隐含了总线协议: 一般称主动发起通信的模块为master(主设备), 称响应通信的模块为slave(从设备). 在上述的简单交互场景中, 作为master的IFU向作为slave的IDU发送信息, 发送的信息为当前的指令. 作为单周期处理器, 显然, 二者的通信实际上隐含了如下约定:

  • 每个周期master都向slave发送有效信息
  • master一旦发送有效信息, slave能够立刻收到

异步总线

但如果IFU不能保证每个周期都能取到指令, 此时IDU需要等待IFU完成取指. 这种情况下, 就需要在通信内容中增加一个有效信号valid, 来指示IFU何时向IDU发送有效的指令. 通信协议也需要更新如下:

  • 只有在valid信号有效时才认为信息有效
  • master一旦发送有效信息, slave能够立刻收到
+-----+ inst  ---> +-----+
| IFU | valid ---> | IDU |
+-----+            +-----+

那么, 如何避免让IDU执行无效指令呢? 回想一下处理器的状态机模型, 我们只要在指令无效时让处理器的状态保持不变即可. 在电路层次, 状态就是时序逻辑元件, 因此, 只需在指令无效时将时序逻辑元件的写使能置为无效即可.

再进一步, 假设有些指令的译码操作过于复杂, IDU需要多个周期才能译码一条指令. 在这种假设下, 当IFU成功取到一条指令时, IDU可能还未完成上条指令的译码, 这时IFU应该先等待IDU完成当前的译码工作, 再向IDU发送下一条指令. 为了实现让IFU等待的功能, 就需要在通信内容中增加一个就绪信号ready, 来指示IDU何时能接收下一条指令. 通信协议也需要更新如下:

  • 只有在valid信号有效时才认为信息有效
  • master发送的信息, 只有在ready信号有效时才认为slave收到
+-----+ inst  ---> +-----+
| IFU | valid ---> | IDU |
+-----+ <--- ready +-----+

这实际上就是异步总线, 两个模块之间何时发生通信是无法提前预知的, 只有在validready均有效时才发生. validready均有效, 也称为"握手", 表示master和slave对信息的成功传递达成共识. 显然, 这种通信协议更加灵活, master和slave将根据自身的工作情况决定何时发送或接收消息. 在valid信号有效, 而ready信号无效时, IFU需要等待IDU就绪, 此时IFU应暂存将要发送的信息, 以防止信息丢失.

异步总线的RTL实现

异步总线通过握手协议确定何时通信, 在RTL层次, 它主要由接口信号和通信逻辑这两部分组成.

接口信号即需要在模块间传递的信号, 如上述例子中的inst, validready信号. Chisel提供了Decoupled模板, 自带validready, 通过元编程可以轻松实现异步总线接口:

class Message extends Bundle {
  val inst = Output(UInt(32.W))
}

class IFU extends Module {
  val io = IO(new Bundle { val out = Decoupled(new Message) })
  // ...
}
class IDU extends Module {
  val io = IO(new Bundle { val in = Flipped(Decoupled(new Message)) })
  // ...
}

需要传递更多信息时, 也能很方便地增加信号, 这就是抽象带来的好处!

 class Message extends Bundle {
   val inst = Output(UInt(32.W))
+  val pc = Output(UInt(32.W))
 }

接下来是通信逻辑, 也即如何用电路实现上述总线协议. 协议是master和slave双方之间的约定, 而在电路上真正需要实现的, 是双方在遵守这一约定之下的行为. 也即, 双方根据握手信号的不同情况, 进入不同的状态, 从而采取不同的操作. 这就是状态机! 对于master而言, 我们可以很容易画出它的状态转移图.

   +-+ valid = 0
   | v         valid = 1
1. idle ----------------> 2. wait_ready <-+
   ^                          |      |    | ready = 0
   +--------------------------+      +----+
              ready = 1

具体地:

  1. 一开始处于空闲状态idle, 将valid置为无效
    1. 如果不需要发送消息, 则一直处于idle状态
    2. 如果需要发送消息, 则将valid置为有效, 并进入wait_ready状态, 等待slave就绪
  2. wait_ready状态中, 同时检测slave的ready信号
    1. 如果ready信号有效, 则握手成功, 返回idle状态
    2. 如果ready信号无效, 则继续处于wait_ready状态等待

根据状态转移图, 我们就可以很容易写出相应的RTL代码了. 这里也给出了一段简单的Chisel代码供大家参考:

class IFU extends Module {
  val io = IO(new Bundle { val out = Decoupled(new Message) })

  val s_idle :: s_wait_ready :: Nil = Enum(2)
  val state = RegInit(s_idle)
  state := MuxLookup(state, s_idle)(List(
    s_idle       -> Mux(io.out.valid, s_wait_ready, s_idle),
    s_wait_ready -> Mux(io.out.ready, s_idle, s_wait_ready)
  ))

  io.out.valid := 有指令需要发送
  // ...
}

用RTL实现IDU的通信

理解IFU通信的实现过程之后, 尝试分析并画出IDU的状态转移图, 并写出IDU通信的RTL代码. 这只是一个总线的练习, 目前你不必修改NPC中IDU的代码.

当我们完整实现IFU和IDU的通信逻辑后, 上述总线协议也就实现好了.

总线视角下的处理器设计

我们可以从总线角度来重新审视处理器设计本身. 假设处理器包含4个模块, 各模块之间需要进行通信: IFU向IDU发送指令, IDU将译码结果发送给EXU, EXU将计算结果发送给WBU.

+-----+ inst  ---> +-----+  ...  ---> +-----+  ...  ---> +-----+
| IFU | valid ---> | IDU | valid ---> | EXU | valid ---> | WBU |
+-----+ <--- ready +-----+ <--- ready +-----+ <--- ready +-----+

在这个视角下, 不同微结构的处理器实际上只是模块间的通信协议不同:

  • 对于单周期处理器, 相当于每个周期上游发送的消息都有效, 同时下游处于就绪状态来接收新消息
  • 对于多周期处理器, 上游模块空闲时消息无效, 下游模块忙碌时不接收新消息; IFU收到WBU的完成信号后再取下一条指令
    • 和传统课本不同, 这是一个基于消息控制的分布式多周期处理器, 其中的"分布式"体现在: 两个模块能否互相通信只取决于二者的状态, 和其他模块无关

课本上的多周期处理器

在多周期处理器中, 一条指令被划分为不同的阶段, 并在不同的周期中执行, 因此每条指令都需要花费多个时钟周期:

  • 在第1个周期, IFU取指令并将指令传递到IDU
  • 在第2个周期, IDU将译码结果传递给EXU
  • 在第3个周期, EXU将计算结果传递给WBU
  • 在第4个周期, WBU将结果写回寄存器堆
  • 在下一个周期, IFU再取出下一条指令...
  • 对于流水线处理器, 则可以让IFU一直取指, 同时每个模块只要有消息处理完成, 就在每个周期都尝试向下游发送消息
  • 对于乱序执行处理器, 则可以看成在流水线的基础上进行很小的扩展: 每一个下游模块中都有一个队列, 上游模块只需把消息发送到队列中, 就可以不用关心下游队列的状态继续工作

通常, 集中式控制需要有一个全局的控制器, 负责收集所有模块的状态, 再根据这些状态来控制各个模块的下一步工作. 传统课本中介绍的多周期处理器, 通常是一个基于大状态机的集中式多周期处理器. 它通过全局的大状态机来收集每个模块的状态, 从而控制每个模块之间的通信, 并确定下一个状态应该是什么. 例如在当前周期IFU取指完成, 那下一个周期就应该进入译码状态, 并控制IDU进行译码.

                   +--------------+
   +-------------> |  Controller  | <--------------+
   |               +--------------+                |
   |                ^            ^                 |
   v                v            v                 v
+-----+  inst   +-----+   ...   +-----+   ...   +-----+
| IFU | ------> | IDU | ------> | EXU | ------> | WBU |
+-----+         +-----+         +-----+         +-----+

课本上通常只通过少数几条指令来介绍多周期状态机的基本原理, 这时问题并不大. 但集中式控制的可扩展性比较低, 随着模块的数量还有复杂度的提升, 控制器的设计会越来越复杂:

  • 随着指令数量增加, 指令的类别也会增加, 这个集中式状态机需要考虑每一类指令执行的所有阶段
  • 如果想在这个处理器中插入一个新阶段, 就需要重新设计控制器
  • 在真实的处理器中, 每个模块的工作时间可能各不相同
    • IFU取指可能存在延迟
    • 而IDU可能只需要一个周期就能完成译码
    • 在EXU中, 不同指令的执行时间可能各不相同
      • RVI中的整数运算指令通常只需要一个周期就能完成计算
      • 除法通常需要执行很久
      • 乘法可能更快一些, 但可能也需要数个周期
      • 访存指令需要等待的时间无法提前预知
  • 一些指令的执行可能会触发异常, 中断也可能会随时到来

在这种复杂场景中, 需要考虑每个模块不同状态的组合, 统一的决策是很困难的.

而上文提到的分布式控制中, 每个模块的行为只取决于自身和下游模块的状态, 每个模块可以独立工作. 例如在乱序执行处理器中, 上游模块可以一直工作, 直到下游模块中的队列满为止. 在分布式控制中, 能够非常容易插入一个新模块, 只需要修改该模块的上下游模块的接口实现即可. 因此, 分布式控制具有更好的可扩展性.

采用这种基于握手的分布式控制, 可以统一不同微结构的处理器的设计. 此外, 乱序执行处理器天生就是分布式控制的, 这是因为乱序执行处理器中的模块和每个模块的状态都非常多, 且随时可能到来一些不同的事件(如中断到来, 流水线阻塞等), 如果采用集中式控制, 我们几乎无法保证控制器能在每个模块发生不同事件时都做出正确的决策.

总线在系统设计中的好处

你应该可以体会到总线在系统设计中的好处之一: 通过划分模块并在模块之间进行通信, 降低整个系统的设计和维护的复杂度. 集中式控制器需要和每一个模块进行通信, 因此它的设计和维护是最困难的; 通过总线协议将全局通信分解到上下游模块之间, 就消除了集中式控制器, 从而降低了整个处理器的设计复杂度.

事实上, 计算机领域中的很多例子都是通过消息通信降低系统的复杂度, 例如操作系统中的微内核, 分布式系统中的MPI编程框架, 软件架构中的C/S模型(客户端-服务器模型), 甚至整个互联网都是通过网络包进行通信.

处理器设计的对象固然是硬件, 但设计模式并不仅仅是一个硬件问题, 我们还是可以从软件领域借鉴一些有用的经验帮助我们把处理器设计这件事做得更好.

利用Chisel中的函数抽象和元编程功能, 我们可以将不同微结构处理器的设计模式统一起来, 并且很容易对处理器微结构进行"升级".

class NPC extends Module {
  val io = // ...

  val ifu = Module(new IFU)
  val idu = Module(new IDU)
  val exu = Module(new EXU)
  val wbu = Module(new WBU)

  StageConnect(ifu.io.out, idu.io.in)
  StageConnect(idu.io.out, exu.io.in)
  StageConnect(exu.io.out, wbu.io.in)
  // ...
}

object StageConnect {
  def apply[T <: Data](left: DecoupledIO[T], right: DecoupledIO[T]) = {
    val arch = "single"
    // 为展示抽象的思想, 此处代码省略了若干细节
    if      (arch == "single")   { right.bits := left.bits }
    else if (arch == "multi")    { right <> left }
    else if (arch == "pipeline") { right <> RegEnable(left, left.fire) }
    else if (arch == "ooo")      { right <> Queue(left, 16) }
  }
}

重构NPC

尝试采用上面的总线思想, 重构NPC. 尽管这并不是强制性的, 但如果你使用Chisel开发, 我们强烈建议你进行重构, 这也是为接下来接入SoC, 以及将来实现流水线提前做好准备.

如果你使用Verilog开发, 你可能会感到重构工作有一些繁琐. 不过, 我们想强调的是, 设计模式和RTL实现是不同的层面, 尽管你可能会遇到一些困难, 但我们还是鼓励你思考如何通过Verilog实现上述正确的设计模式.

系统总线

除了上文介绍的处理器内各模块的连接之外, 处理器如何连接到存储器和设备也是至关重要的, 毕竟真实的处理器无法脱离存储器和外设进行工作. 连接处理器和存储器以及设备之间的总线通常称为系统总线. 下面我们以处理器和存储器之间的连接为例, 介绍系统总线应该如何设计.

访问只读存储器

由于从存储器中读出数据是最基本的需要, 我们首先考虑处理器如何通过系统总线完成读操作. 假设处理器规格固定为Nx32, 即存储器包含N个字, 每个字为32位. 同时假设该存储器的读延迟固定为1周期, 这实际上是一种同步存储器, 即从收到读请求到返回数据之间的延迟是固定的, SRAM的访问特性正是如此. 事实上, NPC仿真环境提供的pmem_read()没有读延迟, 收到读请求的当前周期就可以返回读数据, 但它只是用于方便我们实现单周期处理器, 实际上并不存在这样的存储器器件.

如果不考虑写操作, 我们只需要一个只读存储器(ROM, Read-Only Memory)即可. 为了从ROM中读出数据, 相应的总线只需要两个信号, 分别为地址和数据, 二者的位宽分别为log2(N)和32.

+-----+ raddr[log2(N)-1:0] ---> +-----+
| CPU | <---        rdata[31:0] | MEM |
+-----+                         +-----+

其通信协议为:

  • master(CPU)向slave(MEM)发送读地址raddr
  • 下个周期slave向master回复数据rdata
  • 上述行为每周期都发生

评估单周期NPC的主频和程序性能

在进一步修改NPC之前, 尝试通过你在预学习阶段中使用的yosys-sta项目来评估当前NPC的主频. 不过在评估之前, 你需要进行以下工作:

  1. 先运行microbench的train规模测试, 记录其运行结束所需的周期数
  2. 在RTL中注释通过DPI-C调用pmem_read()pmem_write()的代码, 然后为取指和访存各自实例化一个存储器. 为了保持单周期的特性, 我们需要实例化的存储器需要当前周期就能返回读数据, 因此我们可以像寄存器堆那样通过触发器实现它. 如果你使用Verilog, 可以直接实例化RegisterFile模块, 当然你需要把端口正确连上. 为了统一测试结果, 我们约定实例化的存储器大小为256x32b, 即1KB, 共实例化两个这样的存储器, 总大小为2KB.

我们之所以这样修改, 是因为单周期NPC要求每个周期都完成一条指令完整的生命周期, 因此无法连接任何现实中的存储器, 只能连带两个类似寄存器堆的存储器一同评估主频. 修改后, 你就可以评估单周期NPC的主频了.

根据评估的主频和刚才记录的microbench执行的周期数, 就可以估算出将来NPC运行microbench需要多久了. 注意这并非仿真的耗时, 而是假设NPC在上述主频下运行程序的时间. 例如, yzh某个版本的NPC在yosys-sta项目默认提供的nangate45工艺下主频为51.491MHz, 因此可以算出microbench需要运行3.870s, 但仿真花费了19.148s.

当然, 这个估算结果其实并不准确, 而且还可以说是非常乐观的:

  • 这个单周期NPC距离可流片的配置还差很远, 例如我们刚才修改存储器的时候, 其实把I/O相关的部分都忽略了
  • 上述主频是综合后的主频, 布局布线之后引入的线延迟会进一步把主频拉低
  • 取指单元对应的存储器因为没有写操作, 被yosys优化掉了
  • 访存单元对应的存储器其实也远远装不下microbench. 要成功把train规模的测试运行起来, 数据需要占用1MB内存. 这个大小都已经远远超过实际处理器芯片设计中可以容纳的触发器数量了, 先不考虑EDA工具的处理时间, 光是在芯片上摆满这么多触发器, 从占用面积来估算线延迟就已经大得不得了了.

所以, 这个评估结果的参考意义其实很小, 就当作是给后续的评估练练手吧.

将IFU访问的存储器改造成SRAM

  1. 用RTL为IFU编写一个SRAM模块, 地址位宽为32bit, 数据位宽为32bit
  2. 收到读请求后, SRAM模块通过DPI-C调用pmem_read()读出数据, 并延迟1周期返回读出的数据, 来实现一个更真实的存储器

不过由于这个SRAM需要经过1周期才能拿到读出的数据, 这时候NPC已经不是一个严格意义上的单周期处理器了, 而是一个简单的多周期处理器:

  1. 在第1个周期, IFU发出取指请求
  2. 在第2个周期, IFU拿到指令, 并交给后续的模块译码并执行

如果你按照前文的建议重构了NPC, 你会发现将NPC改造成多周期处理器并不难实现.

让DiffTest适配多周期处理器

修改成多周期处理器后, NPC就并非每个周期都执行一条指令了. 为了让DiffTest机制可以正确工作, 你需要对检查的时机稍作调整. 为此, 你可能需要从仿真环境中读取RTL的一些状态, 来帮助你判断应该什么时候进行DiffTest的检查.

访问可读可写存储器

如果要支持写操作, 就需要在总线中添加新的信号:

  • 首先自然需要写地址waddr和写数据wdata
  • 由于写操作并非每个周期都发生, 因此还需要添加写使能信号wen
    • 虽然读操作也并非每个周期都发生, 例如对于改成多周期处理器的NPC, 第2个周期并不需要取指, 但由于读操作不会改变电路状态, 因此理论上读使能并非必须
    • 不过实际中一般还是有读使能ren, 如果没有读请求, 存储器就无需进行读操作, 从而节省能耗
  • 写操作可能只写入一个字当中的若干字节(例如sb指令只写入1字节), 因此还需要添加写掩码信号wmask, 用于指定写入数据中的哪些字节
+-----+ raddr[log2(N)-1:0] ---> +-----+
|     | ren                ---> |     |
|     | <---        rdata[31:0] |     |
|     | waddr[log2(N)-1:0] ---> |     |
| CPU | wdata[31:0]        ---> | MEM |
|     | wen                ---> |     |
|     | wmask[3:0]         ---> |     |
+-----+                         +-----+

同时, 通信协议需要增加写操作行为的定义, 我们用伪代码来表示:

if (wen) {
  // wmask_full为wmask按比特展开的结果
  M[waddr] = (wdata & wmask_full) | M[waddr] & ~wmask_full;
}

将LSU(访存单元)访问的存储器改造成SRAM

  1. 用RTL为LSU编写一个SRAM模块, 地址位宽为32bit, 数据位宽为32bit
  2. 收到读写请求后, 通过DPI-C调用pmem_read()/pmem_write(), 并延迟1周期返回读出的数据
    • 此时可以保留pmem_read()/pmem_write()中的设备访问功能, 我们将在后续讲义中介绍如何通过总线访问外设

实现后, 对于load指令, NPC需要3个周期才能完成:

  1. 在第1个周期, IFU发出取指请求
  2. 在第2个周期, IFU拿到指令, 并交给后续的模块译码, 发现是load指令后, 则通过LSU发出访存请求
  3. 在第3个周期, LSU拿到数据, 并交给WBU写回寄存器

同时读写同一个地址 - RTFM

如果同时读写同一个地址, 存储器究竟读出哪个数据呢? 事实上, 具体的行为需要RTFM: 器件的手册规定了该操作的行为, 在部分器件中, 读结果是未定义的. SRAM和FPGA中的Block RAM大都是这种特性.

因此, master端尽量不要同时读写同一个地址. 如果实在无法避免, 可以在master端加入检测逻辑, 在检测到读写同相同地址时, 可以延迟写操作, 先读出旧数据; 或者延迟读操作, 先写入数据, 再读出写入后的新数据. 这样, 至少可以保证读结果是有定义的.

不过, 当前NPC的IFU只会发出读请求, 而LSU则不会同时发出读请求和写请求 (取决于正在执行的是load指令还是store指令), 因此当前你无需在NPC中考虑这个问题.

更普遍的存储器

事实上, 读延迟为1周期的特性通常只有SRAM能够满足, 因为它能够使用与处理器制造相同的工艺进行生产, 但SRAM的价格十分昂贵. 为了实现更低成本的存储器, 通常会采用其他存储密度更大的工艺来制造存储器, 例如DRAM. 但由于电气特性, 这些存储器的读延迟通常大于处理器的1周期.

这时处理器不能一直发送读请求, 否则由于处理器的请求速率大于存储器的服务速率, 导致存储器会一直被无用的请求占据, 严重降低整个系统的工作效率. 为了解决这个问题, master需要告诉slave何时发送有效的请求, 同时也需要识别slave何时能接收请求. 这可以通过之前介绍的握手实现: 只要在master和slave之间的读协议添加一对握手信号即可, 也即, 添加rvalid指示处理器发送的读请求raddr有效, 添加rready指示存储器可以接收读请求. 这里的rvalid实际上也充当了读使能ren的作用.

+-----+ raddr[log2(N)-1:0] ---> +-----+
|     | rvalid             ---> |     |
|     | <---             rready |     |
|     | <---        rdata[31:0] |     |
| CPU | waddr[log2(N)-1:0] ---> | MEM |
|     | wdata[31:0]        ---> |     |
|     | wen                ---> |     |
|     | wmask[3:0]         ---> |     |
+-----+                         +-----+

事实上, 存储器何时完成读操作也是无法提前得知的, 例如DRAM会周期性地对存储单元的电容进行充电刷新, 如果此时收到读请求, 将会在充电刷新结束之后才会真正读出数据. 另一方面, 处理器也可能因为上一次读出的数据还没用完, 而没有准备好接收存储器返回的数据. 这些问题也都需要通过握手信号来解决: 添加rvalid信号表示存储器已经读出有效的数据, 添加rready信号表示处理器可以接收存储器返回的数据. 不过这两个信号和上文读请求的握手信号重名了, 为了区分, 我们为读地址相关的握手信号添加前缀a表示地址, 即arvalidarready.

+-----+ araddr[log2(N)-1:0] ---> +-----+
|     | arvalid             ---> |     |
|     | <---             arready |     |
|     | <---         rdata[31:0] |     |
|     | <---              rvalid |     |
| CPU | rready              ---> | MEM |
|     | waddr[log2(N)-1:0]  ---> |     |
|     | wdata[31:0]         ---> |     |
|     | wen                 ---> |     |
|     | wmask[3:0]          ---> |     |
+-----+                          +-----+

因此, 在一次读事务的完成过程中, master和slave都需要经历两次握手: master先等arready, 确保slave接收读地址后, 再等rvalid接收读数据; 而slave则先等arvalid接收读地址, 再等rready, 确保master接收读数据. 当然, 在RTL实现层面, 这些都是状态机.

同样地, 写事务也需要握手, 因此需要添加wvalidwready信号:

+-----+ araddr[log2(N)-1:0] ---> +-----+
|     | arvalid             ---> |     |
|     | <---             arready |     |
|     | <---         rdata[31:0] |     |
|     | <---              rvalid |     |
| CPU | rready              ---> | MEM |
|     | waddr[log2(N)-1:0]  ---> |     |
|     | wdata[31:0]         ---> |     |
|     | wmask[3:0]          ---> |     |
|     | wvalid              ---> |     |
+-----+ <---              wready +-----+

握手信号的微结构设计意义 - 解耦

从微结构设计来看, 握手信号最重要的意义就是屏蔽通信双方的处理延迟. 例如, DRAM何时读出数据受很多因素的影响, 包括刷新时机, DRAM控制器的请求调度, DRAM颗粒中的row buffer是否命中, 甚至颗粒的电气特性等. 而CPU何时发送请求并接收数据, 同样也受很多因素的影响, 包括程序何时执行访存指令, 流水线的堵塞情况, 缓存的状态等. 如果通信的时候其中一方要考虑另一方的这些情况, 这样的设计是不可能实现的: 我们没有办法知道另一方处于什么状态.

握手信号成功地把双方的状态解耦开来, 有了握手信号之后, 双方都无需关心对方模块的状态, 只需要等待握手即可. 因此, 只要模块遵循同一套总线协议, 接入总线后就可以顺利工作.

错误处理和异常

在部分情况下, 存储器处理读写事务的时候可能会发生错误, 例如读写地址超过了存储区间的范围, 或者通过校验码发现读写的存储单元发生损坏. 在这些情况下, slave通常应该告诉master发生了错误, 让master决定如何处理. 因此, 我们需要在存储器返回读出数据时额外传输一个rresp信号, 来指示读操作是否成功, 若不成功, 读数据rdata是无效的. 同理, 存储器在处理写操作后也需要返回一个写回复信号bresp(这里的b表示backward), 当然, 这个信号也需要握手.

+-----+ araddr[log2(N)-1:0] ---> +-----+
|     | arvalid             ---> |     |
|     | <---             arready |     |
|     | <---         rdata[31:0] |     |
|     | <---          rresp[1:0] |     |
|     | <---              rvalid |     |
|     | rready              ---> |     |
| CPU | waddr[log2(N)-1:0]  ---> | MEM |
|     | wdata[31:0]         ---> |     |
|     | wmask[3:0]          ---> |     |
|     | wvalid              ---> |     |
|     | <---              wready |     |
|     | <---          bresp[1:0] |     |
|     | <---              bvalid |     |
+-----+ bready              ---> +-----+

在处理器中, 如果读写操作出错, 可以进一步抛出异常, 通知软件进行处理. 例如, RISC-V设置了3种Access Fault异常, 分别代表在存储器中取指令, 读数据, 写数据时出错. 这样, 存储器内部的错误就可以通过总线协议传递到处理器, 再通过处理器的异常处理机制告知软件了.

业界中广泛使用的总线 - AXI协议家族

我们对上文的总线协议稍作变换:

  1. 将写地址和写数据分开, 写地址采用aw前缀, 写数据采用w前缀
  2. wmask改名为wstrb, 并将信号按功能分成5组
araddr  --->               araddr  --->              araddr  ---> -+
arvalid --->               arvalid --->              arvalid --->  AR
<--- arready               <--- arready              <--- arready -+
<--- rdata                 <--- rdata
<--- rresp                 <--- rresp                <--- rdata   -+
<--- rvalid                <--- rvalid               <--- rresp    |
rready  --->       1       rready  --->      2       <--- rvalid   R
waddr   --->      ===>     awaddr  --->     ===>     rready  ---> -+
wdata   --->               awvalid ---> *
wmask   --->               <--- awready *            awaddr  ---> -+
wvalid  --->               wdata   --->              awvalid --->  AW
<--- wready                wmask   --->              <--- awready -+
<--- bresp                 wvalid  --->
<--- bvalid                <--- wready               wdata   ---> -+
bready  --->               <--- bresp                wstrb   --->  |
                           <--- bvalid               wvalid  --->  W
                           bready  --->              <--- wready  -+

                                                     <--- bresp   -+
                                                     <--- bvalid   B
                                                     bready  ---> -+

这样我们就得到了AMBA AXI手册中的的AXI-Lite总线规范了! AXI-Lite有5个事务通道, 分别是读地址(AR), 读数据(R), 写地址(AW), 写数据(W)和写回复(B), 它们的工作状态只取决与各自的握手信号, 因此它们可以独立工作, 例如读请求和写请求可以在同一个周期中同时发送.

在实际使用中, 我们还需要避免和握手信号相关的两种情况:

  1. master和slave互相都在等待对方先将握手信号置1: master在等slave将ready置1后, 才将valid置1; 而slave则在等master将valid置1后, 才将ready置1. 结果就是双方无限制地等待, 造成死锁(dead lock)在新窗口中打开.
  2. master和slave都在试探性地握手, 但试探失败后又都取消握手:
    • 在第1周期, master将valid置1, 但此时ready置0, 握手失败
    • 在第2周期, slave发现上一个周期master将valid置1, 因此这个周期将ready置1; 但因为上一个周期握手失败, master在这个周期将valid置0, 于是这个周期握手仍然失败
    • 在第3周期, master发现上一个周期slave将ready置1, 因此这个周期将valid置1; 但因为上一个周期握手失败, slave在这个周期将ready置0, 于是这个周期握手仍然失败
    • 结果双方仍然无限制地等待, 造成活锁(live lock)在新窗口中打开.
        +---+   +---+   +---+   +---+
 clk    |   |   |   |   |   |   |   |
        +   +---+   +---+   +---+   +---+
        +-------+       +-------+
valid           |       |       |
                +-------+       +--------
                +-------+       +--------
ready           |       |       |
        +-------+       +-------+

避免握手的死锁和活锁

为了避免上述问题, AXI标准规范对握手信号的行为添加了一些约束. 你需要RTFM找到这些约束, 并正确理解它们.

注意你务必要查阅官方手册, 如果你参考了一些来源不够正规的资料, 你将会在接入SoC的时候陷入痛苦的调试黑洞.

让NPC支持AXI-Lite

至此, 你已经知道了AXI-Lite总线中的每一个信号因何加入, 并从需求角度理解了该总线规范的设计思想. 现在就可以把AXI-Lite加入到NPC中, 让NPC通过AXI-Lite来访问存储器了.

将IFU和LSU的访存接口改造成AXI-Lite

  1. 将IFU和LSU的访存接口改造成AXI-Lite
  2. 将IFU和LSU访问的SRAM模块用AXI-Lite进行封装, 但内部还是通过DPI-C来读写数据

由于IFU只会对存储器进行读操作, 不会写入存储器, 因此可以将IFU的AW, WB三个通道的握手信号均置为0. 当然, 更好的做法是在握手信号的另一端通过assert()确保它们一直为0. 此外, 虽然目前SRAM模块的访问延迟还是固定的1周期, 但你需要在master和slave两端都正确地用握手来实现AXI-Lite总线协议.

思考后消化的知识才是你真正掌握的

传统课本上对总线介绍通常只停留在协议层次, 并没有明确介绍如何从RTL层次实现总线, 因此如果大家只阅读课本, 总线仍然是一个相对抽象的概念. "一生一芯"是一个侧重动手实践的学习项目, 我们在讲义中已经给大家介绍了如何在RTL层次实现总线了.

不过, 如果你发现在实现的过程中还是存在一些难以克服的困难, 那就要给自己敲响警钟了: 你很可能缺乏将需求一步步转换为代码的能力. 你很可能需要反思你过去的学习方式, 例如:

  • 是否过分依赖框图, 导致你缺乏微结构设计能力, 在没有框图的情况下感觉无从下手
  • 更糟糕地, 是否过分依赖那些充满示例代码的资料和书籍, 导致你不仅缺乏微结构设计能力, 也缺乏将设计转换为代码的能力. 我们强烈不推荐初学者在自己的第一版代码能工作之前阅读这类资料和书籍!

我们希望你可以把"独立思考"放在学习的首位, 而不是当代码的搬运工. 如果你发现从现在开始独立思考是一件很困难的事情, 很有可能是因为你在之前的学习中错过了太多, 使得你没有能力独自完成接下来的任务了. 如果真是这样, 我们建议你带着正确的心态重新开始学习"一生一芯".

事实上, 我们现在实现的AXI-Lite只是AXI的简化版, 完整的AXI总线规范有更多的信号和特性, 如果大家感兴趣, 也可以通过RTFM了解相关细节. 我们会在后续讲义中逐渐介绍更多我们需要使用的特性.

端正心态, 在迭代开发和调试中逐渐理解所有细节

不过, 作为一个业界中广泛使用的总线协议, AXI的细节还是不少, 导致大部分初学者其实很难在首次接触AXI的时候就可以理解得十分透彻. 因此, 大家也需要端正心态, "一次把总线代码全部写完, 以后就不用改"的想法是不现实的.

事实上, 所有人都是在迭代开发和调试中逐渐理解总线的所有细节. 资深工程师学习新知识的时候尚且如此, 初学者想一步登天的想法并不符合学习的客观规律.

测试总线的实现

在SRAM模块中添加随机延迟的功能, 来测试总线实现是否能在任意延迟下正确工作. 当然, 你可以按照从简单到复杂的顺序添加访存延迟:

  1. 将SRAM的访问延迟依次修改成5, 10, 20等
  2. 在SRAM模块中添加一个LSFR, 通过它来决定当前请求的延迟
  3. 在IFU和LSU中也添加LSFR, 通过它来决定AR/AW/W通道中valid信号的延迟, 以及R/B通道中ready信号的延迟

如果NPC在充满LSFR的随机延迟下仍然能正确启动RT-Thread, 就能大大增强你对代码的信心.

总线的仲裁

经过上述改动, 我们实例化了两个SRAM模块. 但在真实的硬件中, 这两个SRAM模块应该都对应到同一个存储器, 这一点目前是通过pmem_read()/pmem_write()访问仿真环境中唯一的存储器来实现的. 但在真实的硬件中不存在pmem_read()/pmem_write()这样的仿真环境功能, 因此需要考虑如何在硬件实现上让IFU和LSU访问同一个存储器.

这其实是一个多master的问题, 既然slave同时只能接收一个请求, 那就通过一个仲裁器(Arbiter)来决策: 当多个master同时访问同一个slave时, 获得访问权的master将得到放行, 可以成功访问slave; 其他master的请求将阻塞在仲裁器, 等待获得访问权的master访问结束后, 它们才能获得接下来的访问权.

简单整理一下, 仲裁器需要实现如下功能:

  1. 调度: 选择一个正在发送有效请求的master
  2. 阻塞: 阻塞其他master的访问
  3. 转发: 将获得访问权的master的请求转发给slave, 并在slave的请求到达时, 将其转发给之前的master

多个master同时访问时具体如何选择, 其实是一个调度策略的问题. 在复杂系统中, 调度策略还需要考虑更多问题: 首先要避免饥饿, 即任一个master都能在有限次仲裁后获得访问权; 其次还要避免死锁, 即仲裁器造成的阻塞不应使整个系统出现循环等待的现象. 不过目前NPC是一个多周期处理器, IFU和LSU的master不会同时发送请求, 因此你也不必考虑复杂的调度策略, 选择任意简单的调度策略即可.

实现AXI-Lite仲裁器

保留一个AXI-Lite的SRAM模块, 编写一个AXI-Lite仲裁器, 从IFU和LSU中选择一个master与SRAM模块通信.

Hint: 仲裁器本质上也是一个状态机, 而阻塞和转发的功能本质上是通过操作握手信号来实现的.

评估NPC的主频和程序性能

实现了AXI-Lite之后, NPC就可以外接实际的SRAM了, 我们将要评估的对象是带有一个AXI-Lite接口的NPC, 其中包含刚才实现的AXI-Lite仲裁器, 而通过DPI-C实现的AXI-Lite接口的SRAM模块则不在评估范围内.

按照同样的评估方式, yzh另一个版本的NPC在yosys-sta项目默认提供的nangate45工艺下主频为297.711MHz, 因此可以算出microbench需要运行1.394s, 但仿真花费了29.861s. 可以看到仿真时间增加了, 这是因为多周期NPC的IPC小于单周期NPC, 需要花费更多的周期数来执行程序. 虽然IPC下降了, 但因为主频大幅提升, 因此程序反而执行得更快了.

别忘了, 上面的单周期NPC评估结果是非常乐观的, 甚至是乐观到实际中不可行的程度. 但这个多周期NPC的评估结果就真实多了, 至少1MB SRAM是可以实现的. 不过这还是和我们将要流片的配置差别很大, 毕竟1MB SRAM的流片成本仍然很高. 接下来我们会接入SoC, 使得评估结果更接近流片场景.

为什么要先实现总线?

过去有很多同学都对这个问题感到十分困惑, 甚至有同学认为这是讲义给大家挖的坑, 最大的原因是他们认为将来实现流水线的时候所有代码都需要推翻重写. 另一种声音是, 如果先写单周期, 后面也是要推翻重写.

之所以会让这些同学觉得后面需要推翻重写, 一方面有来自传统课本的原因: 传统课本确实把处理器的设计原理介绍得很清楚, 但却几乎不会考虑如何在不同微结构的处理器之间平滑过渡的问题, 毕竟这属于工程实践的范畴, 传统课本并不会将它作为一个知识点来讲解. 所以, 另一方面的原因就是这部分同学过分依赖传统课本: 他们仅仅按照课本上的内容去实现, 没有思考过如何采用合适的设计模式实现上述过渡.

对于初学者来说, 这其实不算一个大问题, 毕竟设计模式需要在充分了解各种细节之后才能进行抽象总结, 我们不应该要求初学者第一次学习就能思考出一个很好的设计模式. 但如果你想学得更多, 就不应该把课本上的内容看作是处理器设计的全部, 不应该把推翻重写看作是浪费时间. 作为还没有深入思考的初学者, 推翻重写的背后其实蕴含着极大的成长机会: 为什么前后两版设计的差别这么大? 能不能从中抽象总结出一些共性特征? 如果要重新来做这件事, 怎么样才能做得更好?

事实上, 好的经验和创新的机会都是从问题中总结出来的, 大家将来走向工作岗位, 肯定还会遇到更多不同的问题, 这些问题都不再像教科书那样有标准答案. 课本中的知识是有限的, 仅仅靠课本, 我们能成长的高度也是有限的, 但能够进一步帮助我们解决将来未知问题的是我们的思维习惯: 你在多次思考的过程中所形成的思维习惯, 要比课本中的知识重要得多.

回到处理器设计模式这个问题, 我们在2017年5月就已经思考并总结出相关经验, 并且在2017年和2018年南京大学参加的两届龙芯杯比赛, 以及2019年中国科学院大学发起的第一期"一生一芯"计划中都实践过, 并没有造成过多的推翻重写. 不少同学并不了解其中的情况, 因此也还是会根据自己的经验来下判断.

具体地, 通过使用Chisel的合适特性, 从单周期修改成多周期, 只需要改动30行代码, 占800行总代码的3.75%; 从多周期改成不带转发功能的流水线, 只需要改动50行代码, 占1000行总代码的5.00%; 为流水线添加转发功能, 只需要改动20行代码, 占1100行总代码的1.82%. 即使你使用Verilog开发, 如果使用正确的设计模式, 改动代码的占比也应该大致相同. 从比例上来看, 还远远不到需要推翻重写的程度.

我们想说的是, 作为初学者, 你需要保持一颗好奇的心, 以及"绝知此事要躬行"的理念. 当别人跟你说这样你需要推翻重写时, 你不应该仅仅听从这些"忠告", 而是应该思考为什么, 并通过自己的实践去钻研, 去回答这个问题. 因为在学习当中, 你才是主角.

所以为什么要先实现总线?

这是为了践行"先完成, 后完美"的设计法则.

如果我们把单周期处理器作为起点, 那流水线就应该属于"后完美"的部分. 因此, 我们先实现总线, 其实是先将处理器往可流片的方向靠拢, 实现"先完成", 即去掉任何一个功能, 要么处理器不能运行RT-Thread, 要么存储器和外设与可流片设计差别很大.

在"先完成"的基础上, 我们再通过量化评估方法理解每一种优化措施带来的性能收益. 这也是希望初学者将来可以量化地理解流水线设计对系统带来的性能提升, 而不仅仅是像传统课本那样, 将流水线设计作为一个将框图翻译成RTL代码的作业.

多个设备的系统

到目前为止, 我们的系统中还只有存储器. 显然, 真实的计算机系统中并不仅仅只有存储器, 还有其他设备. 因此我们需要考虑, 如何让NPC访问其他设备.

之前学习设备的时候, 我们介绍过内存映射I/O, 这种机制通过不同的内存地址来指示CPU访问的设备. 我们之前在仿真环境中通过pmem_read()pmem_write(), 按照访存地址来选择需要访问的设备, 从而实现内存映射I/O的功能. 但真实的硬件并不存在pmem_read()pmem_write()这两个仿真环境的函数.

实际上, 硬件是通过crossbar(有时也写作Xbar)模块来实现内存映射I/O. Xbar是一个总线的多路开关模块, 可以根据输入端的总线请求的地址, 将请求转发到不同的输出端, 从而传递给不同的下游模块. 这些下游模块可能是设备, 也可能是另一个Xbar. 例如, 下图中的Arbiter用于从IFU和LSU中选择一个请求转发给下游, 下游的Xbar收到请求后, 根据请求中的地址将其转发给下游设备.

+-----+      +---------+      +------+      +-----+
| IFU | ---> |         |      |      | ---> | UART|  [0x1000_0000, 0x1000_0fff)
+-----+      |         |      |      |      +-----+
             | Arbiter | ---> | Xbar |
+-----+      |         |      |      |      +-----+
| LSU | ---> |         |      |      | ---> | SRAM|  [0x8000_0000, 0x80ff_ffff)
+-----+      +---------+      +------+      +-----+

如果请求的地址位于串口设备UART的地址空间范围, 那么Xbar会将请求转发给UART; 如果请求的地址位于SRAM的地址空间范围, 则将请求转发给SRAM. 如果Xbar发现请求的地址不属于任何一个下游的地址空间, 例如0x0400_0000, 就会通过AXI-Lite的resp信号返回一个错误decerr, 表示地址译码错. 这里的“地址译码”表示将请求的地址转换为下游总线通道的编号, 而Xbar中的地址译码器可以看作是内存映射I/O在硬件实现中的核心模块.

Arbiter和Xbar也可以合并成一个多进多出的Xbar, 视场合也称为Interconnect或总线桥. 例如, 下图即为一个2输入2输出的Xbar. 它可以连接多个master和多个slave, 首先通过Arbiter记录当前请求来自哪个master, 再依据该请求的地址决定将其转发给哪个slave.

+-----+      +------+      +-----+
| IFU | ---> |      | ---> | UART|  [0x1000_0000, 0x1000_0fff)
+-----+      |      |      +-----+
             | Xbar |
+-----+      |      |      +-----+
| LSU | ---> |      | ---> | SRAM|  [0x8000_0000, 0x80ff_ffff)
+-----+      +------+      +-----+

物理内存属性(PMA)和裸机程序开发

上面的拓扑连接方式关系也可能会引起一些特殊的问题. 由于IFU可以通过Xbar和UART相连, 说明CPU也可以从UART中取指, 但显然UART设备不能存放程序和指令.

因此, 如果程序错误地跳转到0x1000_0000, CPU将向UART发送读请求, UART依据设备的行为返回一个数据, 它可能表示UART的内部状态的编码, 或者是UART接收到的字符. 一方面, CPU会错误地将UART返回的结果当作指令来执行, 从而造成严重的错误; 另一方面, IFU对UART设备的访问可能会改变设备的状态, 导致UART进入不可预测的状态.

为了避免引发这种问题, 一般会在硬件中添加一些检查机制, 为每段地址空间添加若干权限属性, 如可读标志, 可写标志, 可执行标志等 在IFU发出取指请求前, 先检查请求的地址所属的地址空间是否可执行, 若不可执行, 则直接抛出异常. 在RISC-V中, 如果系统中设备的地址空间是固定的, 则可以通过PMA(Physical Memory Attribute)机制来实现权限检查; 而对于地址空间在操作系统初始化时动态分配的现代PCI-e设备, 则可以通过PMP(Physical Memory Protection)机制或虚拟内存机制来实现权限检查. 如果你对这两种机制感兴趣, 可以参考RISC-V手册中的相关内容.

当然, 如果CPU中没有实现这些检查机制, 在开发裸机程序的时候就要特别小心了: 如果裸机程序跑飞了, 后果可能是难以想象的.

有了Xbar后, 我们就可以把之前在仿真环境中实现的外设功能移动到RTL中了.

实现AXI-Lite接口的UART功能

编写一个AXI-Lite接口的slave模块, 其中包含一个设备寄存器. 当往这个设备寄存器发送写请求时, 则将写入数据的低8位作为字符, 通过$write()printf()输出. 为了方便测试, 这个设备寄存器的地址可以设置成与之前仿真环境中串口的地址相同. 实现后, 你还需要自己编写一个Xbar模块, 来将这个具备UART功能的模块接入系统中.

事实上, 我们并没有完整地用RTL来实现一个UART, 因为$write()printf()仍然需要依赖仿真环境来实现字符的输出. 但作为一个总线的练习, 这已经足够了, 毕竟UART的实现还需要考虑很多电气细节. 不过我们很快就会接入SoC, 其中包含一个真实的UART控制器. 现在通过这个练习来测试总线的实现, 将来接入SoC的时候也会更顺利.

实现AXI-Lite接口的CLINT

CLINT(Core Local INTerrupt controller)在新窗口中打开是RISC-V系统中较通用的中断控制器, 是一个用于维护时钟中断和软件中断的模块. 不过目前我们的系统还不需要中断功能, 因此我们先考虑时钟相关的功能即可.

你需要实现一个AXI-Lite接口的CLINT模块, 并将其接入系统. CLINT包含一个只读的设备寄存器mtime, 它会以一定的速率增长, 最简单的实现是每周期加1. 同样地, 为了方便测试, 其地址可以设置成与之前仿真环境中时钟的地址相同.

不过, mtime的流逝还不能直接反映时间的流逝, 它们之间相差一个系数, 需要由软件读出后进行处理. 在真实的处理器芯片中, 一般这个系数等于CLINT模块中的时钟频率, 从而可以让软件测量出真实的时间. 不过仿真环境中没有主频的概念, 如果这个系数等于仿真速率, 我们就可以在仿真环境中通过mtime的流逝计算出真实时间的流逝. 具体地, 你还需要修改IOE的相关代码, 让AM_TIMER_UPTIME返回的时间接近真实时间.

最后, 你还需要考虑mtime寄存器的位宽. 上述手册中定义的mtime是64位的, 这是为了避免在实际使用中发生溢出. 但目前NPC是32位的, 如果我们只读出mtime的低32位, 在一段时间之后, mtime将会发生溢出, 从而使系统的时间功能发生错误. 尽管你不太容易在仿真环境中运行到mtime溢出的时刻, 但如果NPC将来运行在500MHz的频率下, 将大概率会发生溢出. 因此, 运行在32位NPC上的软件需要依次读出mtime的低32位和高32位, 将其组合成一个64位的值, 供上层应用使用.

最近更新时间:
贡献者: Zihao Yu