Wishbone Master 实现
既然我们已经有了两个 Wishbone Slave 实现,分别是同学们实现的 SRAM 控制器和提供给同学们的串口控制器,那么我们接下来讲如何编写代码,通过 Wishbone 协议来对这两个 Wishbone Slave 操作。
简介
本文讲述的是 Wishbone 总线拓扑的构建和 Wishbone Master 的状态机实现方法,还针对非对齐访问的情况进行了补充。
Wishbone 总线拓扑
有了总线以后,最大的好处就是可以很方便地添加新的外设到总线上,不再需要去修改 CPU 本身的代码,也可以复用已有的 Wishbone 设备,例如前面编写的 Wishbone SRAM 控制器,还有提供给同学们的串口控制器。那么,我们就需要有一种方法,来把它们接起来,让 CPU 可以访问这些外设。
这时候,就有一个问题了:CPU 在访问的时候,怎么区分这次访问是要访问谁呢?回忆一下我们在 QEMU 中编写的汇编,当我们访问 0x10000000 到 0x10000005 范围内的地址的时候,就会访问外设;而如果访问的是 0x80000000 以上的地址的时候,就是访问了内存。
于是很自然地,我们可以根据地址来动态“连接”CPU 和外设。这个模块,其实在上一次实验的时候已经看到了,就是 wb_mux。它的功能就是判断地址属于哪个外设,然后进行“连接”:
wire wbs0_match = ~|((wbm_adr_i ^ wbs0_addr) & wbs0_addr_msk);
wire wbs1_match = ~|((wbm_adr_i ^ wbs1_addr) & wbs1_addr_msk);
wire wbs2_match = ~|((wbm_adr_i ^ wbs2_addr) & wbs2_addr_msk);
wire wbs0_sel = wbs0_match;
wire wbs1_sel = wbs1_match & ~(wbs0_match);
wire wbs2_sel = wbs2_match & ~(wbs0_match | wbs1_match);
// slave 0
assign wbs0_adr_o = wbm_adr_i;
assign wbs0_dat_o = wbm_dat_i;
assign wbs0_we_o = wbm_we_i & wbs0_sel;
assign wbs0_sel_o = wbm_sel_i;
assign wbs0_stb_o = wbm_stb_i & wbs0_sel;
assign wbs0_cyc_o = wbm_cyc_i & wbs0_sel;
// master
assign wbm_dat_o = wbs0_sel ? wbs0_dat_i :
wbs1_sel ? wbs1_dat_i :
wbs2_sel ? wbs2_dat_i :
{DATA_WIDTH{1'b0}};
这里的 wbs*_match 指的是访问的地址是否匹配了对应的外设的地址范围;然后 wbs*_sel 根据优先级,找到实际要访问的外设,最后把信号连接起来,并且与对应的 wbs*_sel 信号取 AND。这样,只有对应的外设会收到请求,其他的外设则不会收到请求。同理,wbm_dat_o 也是根据当前活跃的外设来选择返回的数据。
当然了,这种方法只适用于单个总线 Master 的场景,对于多 Master 和多 Slave 的场景,我们也提供了相应的模块。
有了这些模块以后,我们就可以很方便地搭建总线拓扑。只要拓扑搭建好了,我们就可以放心,所有的 Master 都可以访问到所有的 Slave。
在本实验中,你需要编写一个 Wishbone Master,它会通过总线接口访问内存和串口。实验框架已经搭建好了一个总线拓扑,同学只需要实现其中的 Wishbone Master 状态机就可以了:
flowchart LR
wb_master --> wb_mux;
wb_mux --> sram_controller_base;
wb_mux --> sram_controller_ext;
wb_mux --> uart_controller;
Wishbone Master 状态机
接下来,我们需要实现一个 Wishbone Master 状态机。为什么要实现它呢?实际上,我们后面编写 CPU 的时候,就会实现一个 Wishbone Master 状态机,这样就可以通过总线来访问内存和外设了。我们现在编写的代码,在之后也可以很快地应用到 CPU 的访存模块中去。
我们来回忆一下 Wishbone 总线的请求过程:
- CPU 准备地址、读写方向、字节使能(写操作还需准备写数据),设置
STB_O=1, CYC_O=1,发起请求。 - 外设或内存完成操作后拉高自己的
ACK_O,表示操作已完成。 - CPU 看到内存设置
ACK_I=1,得知之前发起的操作已经完成,于是设置STB_O=0, CYC_O=0,本次操作至此结束。 - CPU 下一次进行读写操作的时候,再从第一步开始。
根据这个过程,我们可以设计如下的状态机:
flowchart LR
IDLE -- 有新的访问任务 --> ACTION;
ACTION -- ACK_I == 0 --> ACTION;
ACTION -- CYC_O && STB_O && ACK_I --> DONE;
DONE --> IDLE;
- 在 IDLE 状态下,如果想要发送一次请求,就把请求的地址等信息保存在寄存器中,并设置
STB_O=1, CYC_O=1,转移到 ACTION 状态。 - 在 ACTION 状态下保持请求不变,等待应答;在时钟上升沿采样到
CYC_O && STB_O && ACK_I时,若是读请求就记录DAT_I,同时(无论读请求还是写请求)撤销CYC_O、STB_O并转移到 DONE 状态。 - 在 DONE 状态下保持
CYC_O=0, STB_O=0,根据实际需求处理上一拍记录的结果,然后转移到 IDLE 状态。此时总线上的DAT_I可能已经改变,不能再用它判断上一次请求的结果。
对应的波形图画出来:
图中 Master 发起一次对地址 0x04 的整字读取,并在 ACTION 中等待若干拍。直到 ACK_I 为 1 的周期结束处的上升沿,才记录数据 A,并在沿后进入 DONE、撤销请求。
在等待应答期间,必须保持 CYC_O、STB_O、ADR_O、WE_O、SEL_O 以及写请求的 DAT_O 不变。
注意
仅有 ACK_I=1 而本端没有有效请求时,也不能把它当成一次访问完成。
非对齐访问
实验中采用的 Wishbone 协议的地址是按照字节编址的,那么同学在编写的时候可能会有疑惑:假如我访问的地址是非对齐的(指没有对齐到 32 位边界),例如地址 0x10000005 的一个字节,要如何实现呢?
首先让我们来看看内存,假如要执行一条 sb 指令,向内存地址 0x05 写入一个字节 0x55。由于实际上内存的存储是四个字节为一组,所以最后在 SRAM 上,是通过 be_n 来控制的:
ram_addr=0x01: 每个 SRAM 地址对应四字节数据,所以是0x05/4=0x01ram_data=0x00005500: 因为要写的是地址为 0x5 的字节,所以要把最低的 8 位留出来ram_be_n=0b1101: 同理,跳过最低的字节,只把要写的字节设置为 0(写入)
很自然地,在 Wishbone 上也是用类似的方法来实现:
ADR_O=0x05: Wishbone 是按字节编址的,保留原样DAT_O=0x00005500: 和上面的ram_data对应,跳过最低的 8 位SEL_O=0b0010: 和上面的ram_be_n对应,也是跳过最低的字节,只不过这里用 1 表示写入
这样,我们就可以很简单地让 SRAM 控制器实现非对齐访问,不用做特殊的什么处理。
而处理非对齐的任务就交给了 CPU:当 CPU 看到 sb 指令的时候,要根据地址和数据来计算出 DAT_O 和 SEL_O。例如:
- 向 0x05 写入一个字节的时候,
DAT_O是实际写入数据左移 8 位,SEL_O是0b0010 - 向 0x06 写入两个字节的时候,
DAT_O是实际写入数据左移 16 位,SEL_O是0b1100
总结上面的规律,DAT_O 左移的位数,实际上就是地址模 4 的余数乘以 8;SEL_O 首先根据写入的字节数,得到 0b0001(单字节),0b0011(双字节),然后再左移,左移的位数就是地址模 4 的余数。
读数据的时候,就是类似地把 DAT_I 右移相应的位数,然后把结果写到寄存器里。
例如读取 UART 状态寄存器 0x1000_0005,应保持原始地址,并设置 SEL_O=4'b0010。握手完成时提取 DAT_I[15:8] 作为状态字节,然后检查该字节的 bit 0(接收就绪)或 bit 5(发送就绪);它们对应原始 DAT_I 的 bit 8 和 bit 13。读取数据寄存器 0x1000_0000 时则使用 SEL_O=4'b0001,提取 DAT_I[7:0]。
这样就实现了非对齐访问的读和写。
看到这里,你可能会有一个疑问:既然 SRAM 控制器全程忽略了地址的低 2 位,为什么我不直接在总线上发送对齐的地址,而仅仅根据 SEL_O 来判断我要写入的是哪一部分呢?
答案是:UART 控制器会根据地址的低 2 位来判断。例如,它的状态寄存器地址是 0x10000005,如果地址设置的是 0x10000004,它实际上访问的就是别的寄存器。
而非对齐访存在 CPU 上通常有另一种含义,例如从 0x2 地址读取 32 位数据,意味着需要读取地址 0x2 到 0x5 四个字节的数据,而到总线上就犯了难:Wishbone 总线不允许跨越 32 位数据的边界,这意味着如果必须要读取,首先要从 0x2 地址读取两个字节的数据,再从 0x4 地址读取两个字节的数据,再把结果拼接起来。另外还有一种情况是从 0x1 地址读取 16 位的数据,虽然没有跨越 32 位数据的边界,但是会增加 CPU 设计的复杂性,因为需要从 0x0 地址读取 32 位数据,再取中间的 16 位。
针对这个问题,在实际的系统中,有如下几种可能的解决方法:
- 完全不允许用户使用非对齐访存,一旦出现了,直接杀掉用户程序
- 允许用户使用非对齐访存,但是 CPU 遇到非对齐访存会抛异常,进入操作系统内核后,内核发现这是由于非对齐访存触发的异常,就按照上述的方法,拆成两次读取,再拼接起来,把结果写入到寄存器中,再回到用户程序,假装硬件支持了非对齐访存
- 允许用户使用非对齐访存,CPU 也支持非对齐访存,会在执行指令的时候,自动拆成多个总线请求,读取数据后再拼接
总结
本节介绍了如何连接多个 Wishbone Slave,如何用状态机发起并完成总线请求,以及如何根据字节地址选择数据在总线上的位置。
现在 Master 访问内存和串口的完整流程为:Master 给出地址、读写方向、字节使能和写数据,地址选择电路将请求送到对应的 SRAM 或 UART 控制器;控制器完成操作后返回 ACK,读操作同时返回数据。Master 在等待期间保持请求不变,在时钟上升沿采样到 CYC && STB && ACK 时确认完成,并记录读数据,然后结束请求、继续下一步。
flowchart LR
master["Wishbone Master 状态机"] -- Wishbone 请求 --> mux["地址选择电路"];
mux -- ACK 和读数据 --> master;
mux -- Wishbone 请求 --> sram_controller["SRAM 控制器(Slave)"];
sram_controller -- Wishbone 响应 --> mux;
sram_controller <--> sram["SRAM 芯片"];
mux -- Wishbone 请求 --> uart_controller["UART 控制器(Slave)"];
uart_controller -- Wishbone 响应 --> mux;
uart_controller <--> platform["云平台(串口对端)"];
学完这节内容我需要做什么?
根据实验要求设计 Wishbone Master 状态机,明确每个状态发起的请求、等待的完成条件,以及返回数据的使用方式。