总线协议
功能
总线通常用于连接 CPU 和外设(包括内存),为了更好的兼容性和可复用性,能否设计一个统一的协议,其中 CPU 实现的是发起请求的一方(又称为 master),外设实现的是接收请求的一方(又称为 slave),那么如果要添加外设、或者替换 CPU 实现,都会变得比较简单,减少了许多适配的工作量。
一个总线协议需要包括哪些内容?对于 CPU 来说,程序会读写内存,读写内存就需要将以下几个信号传输到内存:
- 地址(
addr
):例如 32 位地址 - 数据(
w_data
和r_data
):分别是写数据和读数据,宽度通常为 32 位或 64 位,也就是一个时钟周期可以传输的数据量 - 读还是写(
we
):高表示写,低表示读 - 字节有效(
be
):例如为了实现单字节写,虽然w_data
可能是 32 位宽,但是实际写入的是其中的一个字节
除了请求的内容以外,为了表示 CPU 想要发送请求,还需要添加 valid
信号:高表示发送请求,低表示不发送请求。很多时候,外设的速度比较慢,可能无法保证每个周期都可以处理请求,因此外设可以提供一个 ready
信号:当 valid=1 && ready=1
的时候,发送并处理请求;当 valid=1 && ready=0
的时候,表示外设还没有准备好,此时 CPU 需要一直保持 valid=1
不变,等到外设准备好后,valid=1 && ready=1
请求生效。
简易总线协议
结合上文,可以设计一个简易总线协议,分别得到 master 和 slave 端的信号列表。约定在命名的时候用 _o
表示输出、_i
表示输入,那么 master 端(CPU 端)的信号:
clock_i
:时钟输入valid_o
:高表示 master 想要发送请求ready_i
:高表示 slave 准备好处理请求addr_o
:master 想要读写的地址we_o
:master 想要读还是写data_o
:master 想要写入的数据be_o
:master 读写的字节使能,用于实现单字节写等data_i
:slave 提供给 master 的读取的数据
除了时钟都是输入以外,把上面其余的信号输入、输出对称一下,就可以得到 slave 端(外设端)的信号:
clock_i
:时钟输入valid_i
:高表示 master 想要发送请求ready_o
:高表示 slave 准备好处理请求addr_i
:master 想要读写的地址we_i
:master 想要读还是写data_i
:master 想要写入的数据be_i
:master 读写的字节使能,用于实现单字节写等data_o
:slave 提供给 master 的读取的数据
以上面的总线协议为例,绘制出一个可能的波形图(以 master 的信号为例):
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
)
观察上面的波形,得到如下几条规律:
- master 想要发起请求的时候,就设置
valid_o=1
;当 slave 可以接受请求的时候,就设置ready_i=1
;在valid_o=1 && ready_i=1
时视为一次请求 - 如果 master 发起请求,同时 slave 不能接收请求,即
valid_o=1 && ready_i=0
,此时 master 要保持addr_o
we_o
data_o
和be_o
不变,直到请求结束,这一点非常重要 - 当 master 不发起请求的时候,即
valid_o=0
,此时总线上的信号都视为无效数据,不应该进行处理;对于读操作,只有在valid_o=1 && ready_i=1
时data_i
上的数据是有效的 - 可以连续多个周期发生请求,即
valid_o=1 && ready_i=1
连续多个周期等于一,此时是理想情况,可以达到总线最高的传输速度
这样就实现了一个简易的总线协议。
Wishbone
Wishbone 是一个在开源社区广泛使用的总线协议,其分为多个版本,下面介绍其中的 Wishbone Classic Standard 和 Wishbone Classic Pipelined。
Wishbone 的名字来源在官方的文档里有讲:
Webster’s dictionary defines a WISHBONE as “the forked clavicle in front of the
breastbone of most birds.” The term ‘WISHBONE interconnect’ was coined by Wade
Peterson of Silicore Corporation. During the initial definition of the scheme he
was attempting to find a name that was descriptive of a bi-directional data bus
that used either multiplexers or three-state logic. This was solved by forming
an interface with separate input and output paths. When these paths are
connected to three-state logic it forms a ‘Y’ shaped configuration that
resembles a wishbone. The actual name was conceived during a Thanksgiving Day
dinner that included roast turkey. Thanksgiving Day is a national holiday in the
United States, and is observed on the third Thursday in November. It is
generally celebrated with a traditional turkey dinner.
简单来说,在起名的时候,想到总线的样子像是一个 Y 字型,和许愿骨 Wishbone 的形状类似,所以就这样起了名字。
Wishbone Classic Standard
最简单的 Wishbone 版本叫做 Wishbone Classic Standard,其设计思路和上面的简易总线协议非常相似,下面将两者进行一个对比。Wishbone Classic Standard 协议的 master 端(CPU 端)的信号:
CLK_I
: 时钟输入,即简易总线协议中的clock_i
STB_O
:高表示 master 要发送请求,即简易总线协议中的valid_o
ACK_I
:高表示 slave 处理请求,即简易总线协议中的ready_i
ADR_O
:master 想要读写的地址,即简易总线协议中的addr_o
WE_O
:master 想要读还是写,即简易总线协议中的we_o
DAT_O
:master 想要写入的数据,即简易总线协议中的data_o
SEL_O
:master 读写的字节使能,即简易总线协议中的be_o
DAT_I
:master 从 slave 读取的数据,即简易总线协议中的data_i
CYC_O
:总线的使能信号,无对应的简易总线协议信号
此处忽略了一些可选信号。除了最后一个 CYC_O
,其他的信号其实就是上文的简易总线协议。CYC_O
的可以认为是 master 想要占用 slave 的总线接口,在常见的使用场景下,直接认为 CYC_O=STB_O
。它的用途是:
- 占用 slave 的总线接口,不允许其他 master 访问
- 简化 interconnect 的实现
把上面简易总线协议的波形图改成 Wishbone Classic Standard,就可以得到:
Wishbone Classic Pipelined
Wishbone Classic Standard 协议非常简单,但是会遇到一个问题:假设实现的是一个 SRAM 控制器,它的读操作有一个周期的延迟,也就是说,在这个周期给出地址,需要在下一个周期才可以得到结果。在 Wishbone Classic Standard 中,就会出现下面的波形:
a
周期:master 给出读地址 0x01,此时 SRAM 控制器开始读取,但是此时数据还没有读取回来,所以ACK_I=0
b
周期:此时 SRAM 完成了读取,把读取的数据 0x12 放在DAT_I
并设置ACK_I=1
c
周期:master 给出下一个读地址 0x02,SRAM 要重新开始读取d
周期:此时 SRAM 完成了第二次读取,把读取的数据 0x34 放在DAT_I
并设置ACK_I=1
从波形来看,功能没有问题,但是每两个周期才能进行一次读操作,发挥不了最高的性能。在 a
周期给出第一个地址,在 b
周期得到第一个数据,那么如果能在 b
周期的时候给出第二个地址,就可以在 c
周期得到第二个数据,这样就可以实现流水线式的每个周期进行一次读操作。但是,Wishbone Classic Standard 要求 b
周期时第一次请求还没有结束,因此需要修改协议,来实现流水线式的请求。
实现思路也很简单:既然 Wishbone Classic Standard 认为 b
周期时,第一次请求还没有结束,那就让第一次请求提前在 a
周期完成,只不过它的数据要等到 b
周期才能给出。实际上,这个时候的一次读操作,可以认为分成了两部分:首先是 master 向 slave 发送读请求,这个请求在 a
周期完成;然后是 slave 向 master 发送读的结果,这个结果在 b
周期完成。为了实现这个功能,进行如下修改:
- 添加
STALL_I
信号:CYC_O=1 && STB_O=1 && STALL_I=0
表示进行一次读请求 - 修改
ACK_I
信号含义:CYC_O=1 && STB_O=1 && ACK_I=1
表示一次读响应
进行如上修改,就得到了 Wishbone Classic Pipelined 总线协议。上面的两次连续读操作波形如下:
a
周期:master 请求读地址 0x01,slave 接收读请求(STALL_O=0
)b
周期:slave 返回读请求结果 0x12,并设置ACK_I=1
;同时 master 请求读地址 0x02,slave 接收读请求(STALL_O=0
)c
周期:slave 返回读请求结果 0x34,并设置ACK_I=1
;master 不再发起请求,设置STB_O=0
d
周期:所有请求完成,master 设置CYC_O=0
这样就利用 Wishbone Classic Pipelined 协议实现了一个每周期进行一次读操作的 slave。
AXI
AXI 总线协议是 ARM 公司提出的总线协议,在 ARM 处理器以及 Xilinx FPGA 内使用的比较广泛。
AXI 的信号分成五个 Channel:
- AW(Address Write):写请求的地址会通过 AW Channel 发送给 Slave
- W(Write):写请求的数据会通过 W Channel 发送给 Slave
- B:写响应会通过 B Channel 发送给 Master
- AR(Address Read):读请求的地址会通过 AR Channel 发送给 Slave
- R(Read):读响应会通过 R Channel 发送给 Slave
每个 Channel 都包括 valid-ready
式的握手信号,对于 AW、W 和 AR Channel,Master 是发送方,Slave 是接收方;对于 R 和 B Channel,Slave 是发送方,Master 是接收方。
可见 AXI 的设计把请求和响应的过程拆开,并且允许总线上同时有多个正在进行的请求:不用等待响应回来,就可以发送新的请求。因此 AXI 可以更好地利用内存的并行度,达到更高的性能,代价就是设计更加复杂。
为了完成一次写请求,Master 需要:
- 通过 AW Channel 发送要写入的地址、写入的长度等信息给 Slave
- 通过 W Channel 发送要写入的数据,支持 Burst,也就是使用多个周期完成一个写请求的数据传输
- 在 B Channel 上等待 Slave 回复写入完成的响应
为了完成一次读请求,Master 需要:
- 通过 AR Channel 发送要读取的地址、读取的长度等信息给 Slave
- 在 R Channel 上等待 Slave 回复读取的数据,支持 Burst,也就是使用多个周期完成一个读响应的数据传输
由于 AXI 上可以同时进行多个请求,为了让 Master 可以区分出 B 和 R Channel 上的响应与请求的对应关系,每个请求和响应都附带了一个 ID,那么 Master 在请求中附带了什么 ID,Slave 在响应的时候,也要附带相同的 ID。对于同一个 ID 的请求,其顺序是受保证的。
AXI 有支持缓存一致性协议的扩展:ACE,其内容在 一致性协议 中介绍。
TileLink
TileLink 总线协议是 SiFive 公司提出的总线协议,在 Rocket Chip 相关的项目中使用比较广泛。根据 TileLink Spec 1.8.0,TileLink 分为以下三种:
- TL-UL: 只支持读写,不支持 burst,类比 AXI-Lite
- TL-UH:支持读写,原子指令,预取,支持 burst,类比 AXI+ATOP(AXI5 引入的原子操作)
- TL-C:在 TL-UH 基础上支持缓存一致性协议,类比 AXI+ACE/CHI
本文主要讨论前两种,TL-C 的内容在 一致性协议 中介绍。
接口
TileLink Uncached(TL-UL 和 TL-UH) 包括了两个 channel:
- A channel: M->S 发送请求,类比 AXI 的 AR/AW/W
- D channel: S->M 发送响应,类比 AXI 的 R/W
因此 TileLink 每个周期只能发送读或者写的请求,而 AXI 可以同时在 AR 和 AW channel 上发送请求。
一些请求的例子:
- 读:M->S 在 A channel 上发送 Get,S->M 在 D channel 上发送 AccessAckData
- 写:M->S 在 A channel 上发送 PutFullData/PutPartialData,S->M 在 D channel 是发送 AccessAck
- 原子操作:M->S 在 A channel 上发送 ArithmeticData/LogicalData,S->M 在 D channel 上发送 AccessAckData
- 预取操作:M->S 在 A channel 上发送 Intent,S->M 在 D channel 上发送 AccessAck
和 AXI 的对比
和 AXI 对比,TileLink 把读和写进行了合并,所有的读写请求都通过 A channel 发送,所有的响应都通过 D channel 回复。这样做简化了硬件的实现,但如果 CPU 希望同时进行大量的读写,可能 AXI 可以实现更高的性能。
下面分析 TileLink 和 AXI 协议的桥接模块的实现方法。
首先针对 AXI4ToTL 模块的例子,来分析一下如何把一个 AXI4 Master 转换为 TileLink。
针对 AXI4 和 TileLink 的区别进行设计:一个是读写 channel 合并了,所以这里需要一个 Arbiter;其次 AXI4 中 AW 和 W 是分开的,这里也需要进行合并。这个模块并不考虑 Burst 的情况,而是由 AXI4Fragmenter 来进行拆分,即添加若干个 AW beat,和 W 进行配对。
具体到代码实现上,首先把 AR channel 对应到 到 A channel 上:
val r_out = Wire(out.a)
r_out.valid := in.ar.valid
r_out.bits :<= edgeOut.Get(r_id, r_addr, r_size)._2
然后 AW+W channel 也连接 到 A channel,由于不用考虑 burst 的情况,这里在 aw 和 w 同时 valid 的时候才认为有请求。
val w_out = Wire(out.a)
in.aw.ready := w_out.ready && in.w.valid && in.w.bits.last
in.w.ready := w_out.ready && in.aw.valid
w_out.valid := in.aw.valid && in.w.valid
w_out.bits :<= edgeOut.Put(w_id, w_addr, w_size, in.w.bits.data, in.w.bits.strb)._2
为了区分请求的类型,读写的 id 增加了若干位,最低位 0 表示读,1 表示写,剩下几位是请求编号,这样发出去的是不同 id 的多个请求。
然后,把读和写的 A channel 连接到 Arbiter 上:
其余的部分则是对 D channel 进行判断,有数据的转给 R channel,没有数据的转给 B channel:
out.d.ready := Mux(d_hasData, ok_r.ready, ok_b.ready)
ok_r.valid := out.d.valid && d_hasData
ok_b.valid := out.d.valid && !d_hasData
最后处理了一下 TileLink 和 AXI4 对写请求返回确认的区别:TileLink 中,可以在第一个 burst beat 就返回确认,而 AXI4 需要在最后一个 burst beat 之后返回确认。
再来看一下反过来的转换,即从 TileLink Master 到 AXI。由于 TileLink 同时只能进行读或者写,所以它首先做了一个虚构的 arw channel,可以理解为合并了 ar 和 aw channel 的 AXI4,这个设计在 SpinalHDL 的代码中也能看到。然后再根据是否是写入,分别连接到 ar 和 aw channel:
val queue_arw = Queue.irrevocable(out_arw, entries=depth, flow=combinational)
out.ar.bits := queue_arw.bits
out.aw.bits := queue_arw.bits
out.ar.valid := queue_arw.valid && !queue_arw.bits.wen
out.aw.valid := queue_arw.valid && queue_arw.bits.wen
queue_arw.ready := Mux(queue_arw.bits.wen, out.aw.ready, out.ar.ready)
这里处理了 aw 和 w 的 valid 信号:
in.a.ready := !stall && Mux(a_isPut, (doneAW || out_arw.ready) && out_w.ready, out_arw.ready)
out_arw.valid := !stall && in.a.valid && Mux(a_isPut, !doneAW && out_w.ready, Bool(true))
out_w.valid := !stall && in.a.valid && a_isPut && (doneAW || out_arw.ready)
这样做的原因是,在 TileLink 中,每个 burst 都是一个 a channel 上的请求,而 AXI4 中,只有第一个 burst 有 aw 请求,所有 burst 都有 w 请求,因此这里用 doneAW 信号来进行区分。
接着,要把 b 和 r channel 上的结果连接到 d channel,根据上面的经验,这里 又是一个 arbitration:
val r_wins = (out.r.valid && b_delay =/= UInt(7)) || r_holds_d
out.r.ready := in.d.ready && r_wins
out.b.ready := in.d.ready && !r_wins
in.d.valid := Mux(r_wins, out.r.valid, out.b.valid)
最后还处理了一下请求和结果顺序的问题。