引言

我们已经编写了单周期处理器, 并了解了设备如何工作

但处理器如何与其他模块通信?

 

本次课内容

  • 总线
  • 系统总线
  • AXI-Lite

总线的来源 - 数据交换

模块间需要通信

  • 不同的计算机
    • 通过互联网通信
  • 在计算机内部
    • CPU和内存控制器通过一套协议通信
    • 内存控制器和内存颗粒通过另一套协议通信
    +-----+      +-------------------+      +---------------+
    | CPU | <==> | Memory Controller | <==> | Memory Device |
    +-----+      +-------------------+      +---------------+
  • 在CPU内部
    • IFU需要和IDU通过信号通信
    • IDU需要和EXU通过信号通信
  • 软件模块也有类似的需求
    • DiffTest中, NEMU需要和Spike通信, NPC需要和NEMU通信

广义的总线 = 通信系统

Wikipedia中总线的定义:

In computer architecture, a bus is a communication system that transfers data between
components inside a computer, or between computers. This expression covers all
related hardware components (wire, optical fiber, etc.) and software, including
communication protocols.

 

没错, 以下这些都属于广义的总线概念:

  • TCP/IP, 以太网, 网线, RTL信号, 系统调用, …
  • 华为鸿蒙OS的一大卖点 “分布式软总线”, 好像也没那么高端了 😂
    • 就是一套分布式通信协议

 

这次课我们学习狭义的总线

  • 但理解其本质需求是很重要的

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

最简单的总线

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

大家在单周期处理器里面就是这样做的

  • 简单到你几乎不会去往总线的方向深入思考

 

主动发起通信的模块叫master(主设备), 响应通信的模块叫slave(从设备)

其实背后藏着一套通信协议

  • master(IFU)往slave(IDU)发送消息(当前指令inst)
  • 双方约定, 只要master发送, slave立即收到
  • 上述发送行为每周期都发生
    • 即每周期master都往slave发送有效的指令
    • 在单周期处理器中确实是这样

更真实的处理器

IFU并非每周期都能取到指令

  • IDU需要等待IFU完成取指后, 才能进行译码
+-----+ inst  ---> +-----+
| IFU | valid ---> | IDU |
+-----+            +-----+

需要添加valid(有效)信号, 指示何时发送有效的指令, 通信协议如下

  • master(IFU)往slave(IDU)发送消息(当前指令inst)
  • 双方约定, 只要master发送, slave立即收到
  • 上述发送行为仅在valid有效时发生

 

Q: 如何避免处理器执行了无效指令?

A: 处理器是个状态机!

  • valid无效时, 只需要不修改处理器的状态即可
    • 状态 = 时序逻辑元件, 将其写使能无效即可

更真实的处理器(2)

如果IDU并非每周期都能译码指令

  • IFU需要等待IDU完成当前的译码工作后, 才能发送下一条指令
+-----+ inst  ---> +-----+
| IFU | valid ---> | IDU |
+-----+ <--- ready +-----+

需要添加ready(就绪)信号, 通信协议如下

  • master(IFU)往slave(IDU)发送消息(当前指令inst)
  • 双方约定, 若master发送, 则ready有效时, 才认为slave收到
  • 上述发送行为仅在valid有效时发生

 

这就是异步总线

  • 通信发生的时刻无法提前预知, 在valid & ready时才发生, 称 “握手”
  • valid & !ready时, master需要暂存消息, 避免丢失

异步总线的RTL实现 - 接口信号

  • Chisel提供了Decoupled模板, 通过元编程轻松实现异步总线接口
    • Decoupled模板自带valid和ready
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 = Filpped(Decoupled(new Message)) })
  // ...
}

加一个信号

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

异步总线的RTL实现 - 模块逻辑

master和slave需要根据握手信号的情况来实现约定的总线协议

  • 不同情况做不同的事情 -> 状态机!
  • 总线的RTL实现 = 接口信号 + 状态机
# master的状态转移图
   +-+ valid = 0
   | v         valid = 1
1. idle ----------------> 2. wait_ready <-+
   ^                          |      |    | ready = 0
   +--------------------------+      +----+
              ready = 1
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 := 有指令需要发送
  // ...
}

总线视角下的处理器设计

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

一个观察: 不同微结构的处理器, 只是模块间的通信协议不同

  • 单周期 - 每周期上游发送的消息均有效, 下游均就绪接收新消息
  • 多周期 - 模块空闲时消息无效, 模块忙碌时不接收新消息, IFU收到WBU的完成信号后再取下一条指令
    • 基于消息控制的分布式多周期处理器
      • 分布式 = 两个模块能否通信只取决于二者的状态, 和其他模块无关
    • 和课本上用一个大状态机控制的集中式多周期处理器不同
  • 流水线 - IFU一直取指, 各模块每个周期都尝试往下游发送消息
  • 乱序执行 - 下游模块有一个队列, 上游只需要把消息发到队列, 即可继续处理新消息

分布式控制 vs. 集中式控制

                   +--------------+
   +-------------> |  Controller  | <--------------+
   |               +--------------+                |
   |                ^            ^                 |
   v                v            v                 v
+-----+  inst   +-----+   ...   +-----+   ...   +-----+
| IFU | ------> | IDU | ------> | EXU | ------> | WBU |
+-----+         +-----+         +-----+         +-----+
  • 集中式控制 - 控制器需收集所有模块状态, 并决定如何控制各模块工作
    • 可扩展性较低, 随着模块数量和复杂度提升, 控制器越来越难设计
      • 各模块可能会工作多个周期(访存延迟, 除法器)/冲刷(抛异常)/阻塞
  • 分布式控制 - 各模块的行为仅取决于自身状态和下游模块状态
    • 各模块可独立工作, 直到下游无法接收消息
    • 容易插入新模块, 只需修改上下游模块的接口实现

采用基于握手的分布式控制可以统一不同微结构的处理器设计!

  • 乱序执行天生就是分布式控制的

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

轻松对处理器微结构进行 “升级”

开放讨论 - 先做单周期, 后面真的要推倒重写吗?

大部分同学的回答都说 “是”, 因为大部分同学很可能被教科书所约束

  • 教科书只介绍处理器设计的基本原理, 不考虑如何平滑地 “升级”
    • 这属于工程实践的范畴, 并不是教科书中的知识点

我们的实践经验(Chisel):

  • 单周期 -> 多周期: 改动30/800=3.75%的RTL代码
  • 多周期 -> 流水线(不含转发): 改动50/1000=5.00%的RTL代码
  • 流水线(不含转发) -> 流水线(含转发): 改动20/1100=1.82%的RTL代码

 

这体现了设计模式的优势

  • 处理器设计的对象是硬件, 但设计模式是一个软件问题
  • 设计模式和RTL实现是不同的层面
    • 但强大的编程语言可以使能更灵活的设计模式

对初学者的启发: 大家能学习的比教科书中的内容多得多

系统总线

最简单的系统总线

系统总线 = 连接处理器和存储器以及设备之间的总线

读操作是最基本的需求

  • 假设存储器的规格是固定的 - 32 bits x N words
  • 存储器读数据的延迟固定为1周期 - 同步存储器(如SRAM)
    • NPC仿真环境提供的pmem_read()没有读延迟, 实际上不存在这样的存储器器件
+-----+ raddr[log2(N)-1:0] ---> +-----+
| CPU | <---        rdata[31:0] | MEM |
+-----+                         +-----+

这就是只读存储器(ROM, Read-Only Memory), 其通信协议如下

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

新需求: 如何支持写操作?

可读可写的系统总线

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

需要添加新信号:

  • 写地址waddr, 写数据wdata
  • 并非每周期都需要写, 因此需要写使能wen
    • 为什么可以没有读使能?
      • 状态机视角: 读操作不改变电路的状态
      • 实际中一般还是有读使能, 不用读的时候节省能耗
  • 允许只写入部分字节, 因此需要写掩码wmask
    • 用于支持CPU的sb, sh等指令

可读可写的系统总线(2)

通信协议 - wen有效时, M[waddr]更新为

// wmask_full为wmask按比特展开
M[waddr] <= (wdata & wmask_full) | (M[waddr] & ~wmask_full);

若同时读写同一地址, 读出结果需要RTFM(有可能undefined)

            +---+   +---+   +---+   +---+   
            |   |   |   |   |   |   |   |   
        ----+   +---+   +---+   +---+   +---+
                    /------\
waddr   ------------  addr  -----------------
                    \------/
                    +------+
wen                 |      |
        ------------+      +-----------------
                    /------\
wmask   ------------  1111  -----------------
                    \------/
            /----------------------\
raddr   ----           addr         ---------
            \----------------------/
                    /------\/------\/------\
rdata   ------------  old    XXXXXX   new   -
                    \------/\------/\------/

SRAM和FPGA中的Block RAM都是类似上述特性

常用的存储器读延迟更大

读延迟为1周期的前提: 能够使用与处理器制造相同的工艺进行生产

  • 通常只有SRAM能满足要求, 但其存储密度低, 价格昂贵

更多成本更低的存储器通常采用存储密度更大的工艺来制造(如DRAM)

  • 由于电气特性, 其读延迟大于CPU的1周期
  • 这下不能一直发送读请求了, 否则MEM将一直被无用请求占用
    • 请求速率 > 服务速率, 整个系统效率很低

 

新需求

  • slave需要识别master何时发送有效请求
  • master也需要识别slave何时可以接收请求

这就需要握手信号!

  • 握手 = 双方对请求的发送和接收达成共识
    • 不会遗漏/重复

异步的系统总线

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

添加rvalid(也充当了ren的作用)和rready, 实现读请求raddr的握手

 

新问题

  • slave读出rdata的时刻无法提前确定
    • 例如, DRAM会定时对存储单元的电容进行充电刷新, 此时需要等待
  • master也不一定总是准备好接收slave读出的数据
    • 例如上一次读出的数据还没用完, 取决于状态机的状态

异步的系统总线(2)

+-----+ 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]          ---> |     |
+-----+                          +-----+

读出的数据rdata也需要握手

  • 为了避免重名, 给地址相关的信号添加前缀a

 

在一次读数据过程中, master和slave都需要等待两次握手

  • master先等arready, 确保slave接收读地址后, 再等rvalid接收读数据
  • slave先等arvalid接收读地址, 再等rready, 确保master接收读数据
  • 当然在RTL实现层面, 这些都是状态机

异步的系统总线(3)

+-----+ 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何时读出数据受很多因素影响
    • 充电的时机/控制器的请求调度/颗粒中的row buffer是否命中/颗粒的电气特性
  • CPU何时发请求并接收读数据, 同样受很多因素影响
    • 程序何时执行访存指令/流水线的堵塞情况/缓存的状态

 

通信的一方无法得知另一方处于什么状态

  • 因此也不可能得知另一方的处理延迟

 

有了握手信号, 双方均无需关心上述细节, 只要等待握手即可

  • 只要模块遵循同一套通信协议, 即可替换/接入, 各模块可顺利工作

错误处理

读写请求可能会出错, 例如超过存储区间的边界

+-----+ 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              ---> +-----+

通过rresp和bresp(b表示backward)向master回复读写操作是否成功

  • 若失败, CPU可抛出异常, 通知软件处理
    • RISC-V中可抛出3种Access Fault异常
  • 为了让master成功收到写回复信号, 也需要握手

得到手册上的AXI-Lite总线规范

  1. 将写地址和写数据分开, 写地址通过单独握手
  2. 分组, 并将wmask改名为wstrb
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  ---> -+

实际使用中的若干问题

1. 将NPC升级为多周期处理器

总线协议要求读数据在读地址握手后至少1周期后返回

  • 因此处理器访问存储器的延迟至少是1周期
  • 不满足单周期处理器的特性

 

因此需要将NPC升级成多周期处理器后, 才能正确地接入总线

  • 不过这个多周期处理器不必100%与教科书相同
  • 只需要在IFU和LSU中添加总线相关的状态机即可

 

如果想获得更高的主频, 还需要在多个模块之间添加寄存器暂存信号

  • 如果采用正确的设计模式, 这是很容易实现的
  • 体会IPC和主频的权衡

2. 避免两种情况

死锁(deadlock): 系统卡死

master和slave都在等待对方先将握手信号置1

  • master: 我等slave将ready置1后, 再将valid置1
  • slave: 我等master将valid置1后, 再将ready置1

 

活锁(livelock): 局部看没卡死, 全局看没进展

master和slave都在试探性地握手, 但试探失败后又都取消握手

            T0      T1      T2      T3
        +---+   +---+   +---+   +---+
 clk    |   |   |   |   |   |   |   |
        +   +---+   +---+   +---+   +---+
        +-------+       +-------+
valid           |       |       |
                +-------+       +--------
                +-------+       +--------
ready           |       |       |
        +-------+       +-------+

解决方法: 对握手信号的行为添加额外的约束, 具体RTFM

3. 总线的仲裁

CPU中IFU需要从内存取指, LSU需要读写内存中的数据

  • 这里的 “内存”是在物理上是同一个存储器
    • 多个master访问同一个slave

 

需要一个仲裁器(Arbiter)来决定当前谁可以访问

  • 获得访问权的master将得到放行, 可以成功访问slave
  • 其他master的请求将阻塞, 等待获得访问权的master访问结束后, 参与下一轮仲裁

 

+-----+       +---------+
| IFU | ----> |         |
+-----+       |         |       +-----+
              | Arbiter | ----> | MEM |
+-----+       |         |       +-----+
| LSU | ----> |         |
+-----+       +---------+

仲裁器的实现 = 状态机

+-----+       +---------+
| IFU | ----> |         |
+-----+       |         |       +-----+
              | Arbiter | ----> | MEM |
+-----+       |         |       +-----+
| LSU | ----> |         |
+-----+       +---------+
  • 调度: 选择一个正在发送有效请求的master
  • 阻塞: 阻塞其他master的访问
  • 转发: 将获得访问权的master的请求转发给slave, 并在slave的请求到达时, 将其转发给之前的master

 

在复杂系统中, 调度策略还需要考虑

  • 避免饥饿: 任一个master都能在有限次仲裁后获得访问权
  • 避免死锁: 造成的阻塞不应使整个系统出现循环等待的现象

多周期处理器还很简单, 随着系统的复杂度上升, 大家就知道厉害了

4. 支持多个设备

真实的计算机系统中并不仅仅只有存储器, 还有其他设备

  • 回顾 - 内存映射I/O, 通过不同的内存地址来指示不同的设备
    • 在仿真环境中, 通过pmem_read()pmem_write()实现
    • 在真实硬件中, 通过crossbar(有时也写作Xbar)实现
+-----+       +---------+       +------+       +-----+
| IFU | ----> |         |       |      | ----> | UART|   [0x1000_0000, 0x1000_0fff)
+-----+       |         |       |      |       +-----+
              | Arbiter | ----> | Xbar |
+-----+       |         |       |      |       +-----+
| LSU | ----> |         |       |      | ----> | SRAM|   [0x8000_0000, 0x80ff_ffff)
+-----+       +---------+       +------+       +-----+

Xbar根据请求地址将请求转发给不同的下游(设备或另一个Xbar)

  • Xbar发现目标地址无设备时, resp信号返回decerr错误(地址译码错)
  • 地址译码 = 将请求的地址转换为下游的编号, 是Xbar的核心功能

 

Arbiter和Xbar可合并成多进多出的Xbar(也称Interconnect, 总线桥等)

多个设备带来的问题

+-----+       +---------+       +------+       +-----+
| IFU | ----> |         |       |      | ----> | UART|   [0x1000_0000, 0x1000_0fff)
+-----+       |         |       |      |       +-----+
              | Arbiter | ----> | Xbar |
+-----+       |         |       |      |       +-----+
| LSU | ----> |         |       |      | ----> | SRAM|   [0x8000_0000, 0x80ff_ffff)
+-----+       +---------+       +------+       +-----+
  • 根据拓扑关系, IFU也可以从UART中取指
    • 但显然UART不能存放程序和指令

 

  • 如果程序错误地跳转到0x1000_0000, UART当作是读操作来处理, 返回一个数据
    • 可能是表示UART的内部状态的编码, 或者是UART接收到的字符
    • CPU错误地将这个数据解释成指令来执行, 造成严重的错误
    • 对UART的读也会改变其状态, 导致UART的状态不可预测

RISC-V的内存访问检查机制

  • 为每段地址空间添加若干权限属性(RWX等)
  • 在IFU发出取指请求前, 先检查请求的地址所属的地址空间是否可执行
    • 若否, 则抛出Access Fault异常

 

RISC-V提供两种物理内存检查机制

  • PMA(Physical Memory Attribute): 地址空间在系统中固定
    • 通过RTL实现权限表, 在RTL设计时写入
  • PMP(Physical Memory Protection): 地址空间动态分配(如PCI-e等)
    • 通过CSR实现权限表, 在系统初始化时由软件写入

 

  • 如果支持虚拟内存, 则能实现更细粒度的权限检查功能
  • 如果上述机制都不支持, 开发裸机程序的时候就要特别小心
    • 如果程序跑飞了, 后果可能难以想象 😂

5. RTFM了解总线规范

完整的AXI总线规范包含更多的信号和特性

  • 突发读写(burst): arburst, arlen, arsize, rlast, 介绍缓存时讲解
  • 多个请求并发访问(可选): arid
  • 窄传输: 介绍SoC设备时讲解
  • 非对齐传输(无需实现)
    • 目前运行的程序也不包含不对齐访存
    • 就算有, CPU也应该先抛出不对齐异常, 不会发起总线请求
  • 更多属性(这些信号我们不使用) - arlock, arcache, arqos, aruser

 

大家一定要RTFM

  • 很多细节只有仔细RTFM才会发现
  • 只需了解AXI4即可

总结

总线 = 通信协议

  • 通过握手信号屏蔽模块内部细节
    • 握手时master和slave对数据传输达成一致
  • 总线的RTL实现 = 接口信号 + 状态机
    • 不同的状态下控制不同的接口信号
  • 从只读同步存储器的访问总线到AXI-Lite
    • 理解每一个信号的意义
  • 使用总线的真实问题
    • CPU需要升级为多周期
    • 避免死锁和活锁
    • 通过仲裁器支持多个master
    • 通过Xbar的地址译码支持多个slave

 

  • 一定要RTFM了解具体细节

学习建议: 首先端正心态

总线是传统课本上一个相对抽象的概念

  • 不像处理器设计那样可以看框图写代码
  • 只介绍协议, 没有明确说明总线的RTL实现是什么

AXI的细节对初学者来说并不少

  • 初学者很难在首次接触AXI协议时就理解到位

 

大家要认识到学习的规律

  • 不要想着一次把总线代码写完, 以后就不用改
    • 如果水平没到, 这种一步登天的幻想是不现实的
  • 真相 - 在迭代开发和调试中逐渐理解总线的所有细节
    • 强烈建议大家在第一版代码能工作之前, 不要阅读那些充满示例代码的资料和书籍
    • 否则只会错过锻炼的机会