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=1
,表示操作已经完成 - CPU 看到内存设置
ACK_I=1
时,就知道操作已经完成,设置STB_O=0, CYC_O=0
- CPU 下一次进行读写操作的时候,再从第一步开始
根据这个过程,我们可以设计如下的状态机:
flowchart LR
IDLE --> ACTION;
ACTION --> DONE;
DONE --> IDLE;
- 在 IDLE 状态下,如果想要发送一次请求,就把请求的地址等信息保存在寄存器中,并设置
STB_O=1, CYC_O=1
,转移到 ACTION 状态 - 在 ACTION 状态下,等待
ACK_I=1
;如果发现ACK_I=1
,把返回的数据DAT_I
记录下来,转移到 DONE 状态 - 在 DONE 状态下,根据自己的需求,对返回的结果做一些处理,然后转移到 IDLE 状态
对应的波形图画出来:
实现的时候,需要保证请求的整个过程中,输出的信号不变,主要是 CYC_O
,STB_O
,ADR_O
,WE_O
等。如果变化了,可能会导致操作的行为不符合预期。
非对齐访问
实验中采用的 Wishbone 协议的地址是按照字节编址的,那么同学在编写的时候可能会有疑惑:假如我访问的地址是非对齐的(指没有对齐到 32 位边界),例如地址 0x10000005 的一个字节,要如何实现呢?
首先让我们来看看内存,假如要执行一条 sb
指令,向内存地址 0x05 写入一个字节 0x55。由于实际上内存的存储是四个字节为一组,所以最后在 SRAM 上,是通过 be_n
来控制的:
ram_addr=0x01
: 每个 SRAM 地址对应四字节数据,所以是0x05/4=0x01
ram_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
右移相应的位数,然后把结果写到寄存器里。
这样就实现了非对齐访问的读和写。
看到这里,你可能会有一个疑问:既然 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 也支持非对齐访存,会在执行指令的时候,自动拆成多个总线请求,读取数据后再拼接