跳转至

Wishbone 总线协议

本章介绍 Wishbone 总线协议,这个协议是实验中很重要的一环,穿插了多个实验。

简介

本文从为什么需要总线协议讲起,再从设计者的角度,谈谈如何设计一个总线协议,最后发现Wishbone 协议就是刚刚自己设计的总线协议

为什么需要总线协议

在我们日常使用的电脑里,有各种各样的部件,例如键盘,鼠标,显示器,无线网卡等等,它们在操作系统里都是如何识别和管理的?同学们可能听说过一些概念,包括 USB、PCIe 等等,它们的用途就是给 CPU 一个通用的接口,例如:

  • USB 总线:把键盘、鼠标、移动硬盘等等插到电脑上的 USB 接口,操作系统就可以自动地检测到新的硬件,并且用同样的方法来操控这些 USB 设备
  • PCIe 总线:台式机里的独立显卡,通常就是插在主板上的 PCIe 卡槽,插上去以后,操作系统就会自动识别并运行显卡驱动

由此,我们总结一下,总线的功能就是:

  1. 提供一个统一的硬件接口,可以接入不同的硬件外设
  2. 提供一个统一的软件接口,操作系统可以用同样的方式,来操作这个总线下的所有外设

上面的 USB 总线和 PCIe 总线,都是属于 CPU 片外的总线,即同学们可以在主板上看到的。今天,我们要实现的是 CPU 片内的总线,它的目的则是给 CPU 核心一个统一的接口,来访问内存或者外设。

总线协议是什么

我们编写的代码和数据都存放在内存中,那么同学们有没有想过,CPU 是如何读写内存的?我们来回想一下 C 代码中的读和写:

int *ptr;
int read_data = *ptr; // read
*ptr = write_data; // write

对应到汇编指令,如果说 ptr 保存在寄存器 a0 中,那么上面的代码对应下面的 RISC-V 汇编:

# ptr: a0
# read_data: a1
# write_data: a2

# int read_data = *ptr;
lw a1, 0(a0)
# *ptr = write_data;
sw a2, 0(a0)

我们可以想象,执行 lw a1, 0(a0) 的时候,CPU 向内存发送地址 a0,并且进行一个操作,内存读取完以后,内存向 CPU 返回地址为 a0 的数据;执行 sw a2, 0(a0) 的时候,CPU 向内存发送地址 a0 和数据 a2,并且进行一个操作,内存写入以后,内存向 CPU 返回写入完成的信息。

总结一下上面的操作,从 CPU 到内存需要传输的信息:

  1. 地址(addr):例如 32 位处理器就是 32 位地址,或者按照内存的大小计算地址线的宽度,例如 4GB 的内存是 2^32 个字节,就需要 32 位的地址
  2. 写入的数据(w_data
  3. 读还是写(we, write enable):高表示写,低表示读

从内存到 CPU 需要传输的信息:

  1. 读/写操作完成
  2. 读取的数据(r_data

接下来,我们就要自己设计一个让 CPU 访问内存的总线协议。

如何设计一个总线协议

当 CPU 不访问内存的时候,可以让内存休息,减少能耗,因此我们需要设计一个控制信号,即 CPU 告诉内存,什么时候需要进行一次读或者写的操作,我们称之为 valid:高表示 CPU 请求一次读写操作,低表示不请求。

我们已经学过,内存的访问相对 CPU 来说是很慢的,那么就需要一个机制,让 CPU 去等待内存的访问过程。我们上面已经提到,当 CPU 要进行读或者写的操作的时候,会设置 valid=1,此时内存进行实际的内存操作,等待一段时间后,需要通知 CPU 操作已经完成,同时把结果返回给 CPU。于是,我们添加一个信号 ready:高表示内存完成一次读写操作,低表示还没完成或者 CPU 没有请求。当内存完成读写的时候,设置 ready=1,就标志着一次读写操作的完成。

我们总结一下,CPU 进行一次读写操作,需要经历的过程:

  1. CPU 设置 valid=1,内存开始读写操作
  2. 内存完成操作以后,设置 ready=1,表示操作已经完成
  3. CPU 看到内存设置 ready=1 时,就知道操作已经完成,设置 valid=0
  4. CPU 下一次进行读写操作的时候,再从第一步开始

实际上,这种操作方式也可以用于 CPU 访问外设,因此下面我们用 master 表示 CPU 端,也就是发起请求的一端;用 slave 表示设备端,包括内存、外设等,也就是处理请求的一端。

接下来,我们回到硬件,综合以上的分析,可以得到 master 端的信号,我们约定 _o 表示输出,_i 表示输入:

  1. clock_i:时钟输入
  2. valid_o:高表示 master 想要发送请求
  3. ready_i:高表示 slave 完成处理请求
  4. addr_o:master 想要读写的地址
  5. we_o:master 想要读还是写
  6. data_o:master 想要写入的数据
  7. be_o:master 读写的字节使能,用于实现单字节写等
  8. data_i:slave 提供给 master 的读取的数据

字节使能

通常情况下,为了性能考虑,总线上一个周期会传输多个字节,例如 data_o 是 32 位宽的话,一个周期就可以写入四个字节的数据,执行一条 sw 指令。但同时又引入了新的问题,假如只想写入一个字节怎么办,例如 sb 指令?答案是添加信号 be_o,如果数据是 32 位宽的话,那么字节使能 be_o 就是 4 位,每一位表示对应的字节是否需要写入。

例如向地址 0x00 写入一个字节的话,字节使能 be_o=0001;向地址 0x02 写入两个字节的话,字节使能 be_o=1100

这样,我们就自己研制了一个总线协议。根据我们设计的自研总线,可以绘制出下面的波形图(以 master 的信号为例):

clockvalid_oready_iaddr_o0x010x020x030x010x02we_odata_o0x120x560x9abe_o0x10x10x1data_i0x340x12abcdefgh
  • a 周期:此时 valid_o=1 && ready_i=1 说明有请求发生,此时 we_o=1 说明是一个写操作,并且写入地址是 addr_o=0x01,写入的数据是 data_o=0x12
  • b 周期:此时 valid_o=0 && ready_i=0 说明无事发生
  • c 周期:此时 valid_o=1 && ready_i=0 说明 master 想要从地址 0x02(addr_o=0x02)读取数据(we_o=0),但是 slave 没有完成(ready_i=0
  • d 周期:此时 valid_o=1 && ready_i=1 说明有请求发生,master 从地址 0x02(addr_o=0x02)读取数据(we_o=0),读取的数据为 0x34(data_i=0x34
  • e 周期:此时 valid_o=0 && ready_i=0 说明无事发生
  • f 周期:此时 valid_o=1 && ready_i=1 说明有请求发生,master 向地址 0x03(addr_o=0x03)写入数据(we_o=1),写入的数据为 0x56(data_i=0x56
  • g 周期:此时 valid_o=1 && ready_i=1 说明有请求发生,master 从地址 0x01(addr_o=0x01)读取数据(we_o=0),读取的数据为 0x12(data_i=0x12
  • h 周期:此时 valid_o=1 && ready_i=1 说明有请求发生,master 向地址 0x02(addr_o=0x02)写入数据(we_o=1),写入的数据为 0x9a(data_i=0x9a

从上面的波形中,可以有几点观察:

  1. master 想要发起请求的时候,就设置 valid_o=1;当 slave 可以完成请求的时候,就设置 ready_i=1;在 valid_o=1 && ready_i=1 时请求完成,可以进行下一个请求
  2. 如果 master 发起请求,同时 slave 不能接收请求,即 valid_o=1 && ready_i=0,此时 master 要保持 addr_o we_o data_obe_o 不变,直到请求结束
  3. 当 master 不发起请求的时候,即 valid_o=0,此时总线上的信号都视为无效数据,不应该进行处理;对于读操作,只有在 valid_o=1 && ready_i=1data_i 上的数据是有效的
  4. 可以连续多个周期发生请求,即 valid_o=1 && ready_i=1 连续多个周期等于一,此时是理想情况,可以达到总线最高的传输速度

Wishbone 总线协议

接下来我们来看一个实践中很常用的总线协议:Wishbone,其实它和我们在上面自研的总线十分类似,让我们来看看它的信号,以 master 端为例:

  1. CLK_I: 时钟输入,即自研总线中的 clock_i
  2. STB_O:高表示 master 要发送请求,即自研总线中的 valid_o
  3. ACK_I:高表示 slave 完成请求,即自研总线中的 ready_i
  4. ADR_O:master 想要读写的地址,即自研总线中的 addr_o
  5. WE_O:master 想要读还是写,即自研总线中的 we_o
  6. DAT_O:master 想要写入的数据,即自研总线中的 data_o
  7. SEL_O:master 读写的字节使能,即自研总线中的 be_o
  8. DAT_I:master 从 slave 读取的数据,即自研总线中的 data_i
  9. CYC_O:总线的使能信号,无对应的自研总线信号

可以看到,除了最后一个 CYC_O,其他的信号其实就是我们刚刚设计的自研总线,其定义和用途完全一致。CYC_O 的可以认为是 master 想要占用 slave 的总线接口,在常见的使用场景下,直接认为 CYC_O=STB_O。它的用途是:

  1. 占用 slave 的总线接口,不允许其他 master 访问
  2. 简化 interconnect 的实现

把上面自研总线的波形图改成 Wishbone,就可以得到:

CLK_ICYC_OSTB_OACK_IADR_O0x010x020x030x010x02WE_ODAT_O0x120x560x9aSEL_O0x10x10x1DAT_I0x340x12abcdefgh

在课程中,建议 Wishbone 协议每次请求结束时,master 都要拉低 CYC_OSTB_O,因此就不能像上面的 f-g-h 那样连续三个周期发生请求。这样做的好处是:

  1. slave 实现简单,例如状态机中拉高 ACK 后回到 IDLE 状态即可,一些简单的 slave 也会默认 master 会在每个请求结束后拉低 CYC_OSTB_O
  2. 防止一个 master 占用总线太长时间,使得其他 master“饿死”;
  3. 从波形图上看,每个请求都明确地区分开来,方便人来阅读。

最后得到如下的波形:

CLK_ICYC_OSTB_OACK_IADR_O0x010x020x030x010x02WE_ODAT_O0x120x560x9aSEL_O0x10x10x10x10x1DAT_I0x340x12

Wishbone 总线规范

一个规范的 Wishbone Master,需要保证:

  1. 不能打断正在进行的请求:如果上一个周期 CYC_O=1 && STB_O=1 && ACK_I=0,那么这个周期必须要维持 CYC_O=1 && STB_O=1
  2. 不能修改正在进行的请求:如果上一个周期 CYC_O=1 && STB_O=1 && ACK_I=0,那么这个周期的 ADR_O, WE_O, DAT_O, SEL_O 应该和上一个周期相同;
  3. 仅在 CYC_O=1 && STB_O=1 && ACK_I=1 时,Wishbone 总线上 Slave 提供的 DAT_I 信号是有效的,其他时候的取值,不应该影响 Wishbone Master 的行为。

一个规范的 Wishbone Slave,需要保证:

  1. 仅在 CYC_I=1 && STB_I=1 时,Wishbone 总线上 Master 提供的 ADR_I, WE_I, DAT_I, SEL_I 信号是有效的,其他时候的取值,不应该影响 Wishbone Slave 的行为。
我在网上查找的 Wishbone 协议还有其他的信号?

在实验中,我们采用的是 Wishbone 的基础版本 Wishbone Classic Standard,并且去掉了其他的可选信号。其他的版本,主要是为了优化性能,对教学来说是不必要的。


最后更新: 2024年9月8日
作者:Jiajie Chen