到目前为止,我们已经完整的学习了APB、AHB、AXI这三组协议。希望之前的文章对大家能够有所帮助。本篇文章讲解一个简单的AXI Slave的设计,其它复杂的AXI Master或者Slave,就留给大家自己去探索了。因为这种代码动辄就是上千行,在网上讲也很难讲清楚。希望能够用一个简单的例子带大家巩固AXI的基本设计思路,话不多说,让我们进入正题。
1、AXI2MEM简介
本模块是ETH开源项目PULP的一个小模块,其完整代码链接如下:
该模块的作用,是将AXI协议转换为SRAM的读写时序,就比如在下图这个例子中,Native接口的SRAM是无法直接和AXI的接口直接相连的,我们必须借助这种协议转换模块,完成两种协议之间的通信。
我们设计的模块接口非常简单,一边是AXI矩阵传来的AXI接口,另外一边是SRAM的native接口,如下图所示。
2、AXI2MEM设计思路及代码讲解
我们设计的AHB2MEM规格说明如下:
- 不支持Outstanding,阻塞型读写
- 我们连接的SRAM是单端口SRAM,因此我们的AXI2MEM不支持也不必支持同时读写
- 支持突发读写、不跨4KB边界
- 对于wrapping burst,burst length必须是2、4、8、16
- 一旦突发读写开始,不允许中途停止
既然是阻塞性读写,再结合AXI的通道依赖关系,自然而然应该想到,可以用状态机去设计。
此外明确我们设计的是AXI的Slave,设计的时候应该去回顾AXI的握手协议和读写时序等。
我们先只考虑怎么去读,读的话有两个通道,分别是AR通道和R通道。且读的话AR通道必须在前、R通道在后。理论上给了读地址通道的控制信号,就可以跳到读状态,然后一直等数据回复、直到最后一笔数据回来即可。因此读的话应该是涉及两个状态,我们将其定义为IDLE、READ
状态 。什么时候可以从IDLE跳转到READ状态呢?那自然是来了读地址通道的CMD,什么时候从READ跳回到IDLE状态呢?应该是完成了一次突发传输,即收到了rlast信号。
我们看一下下面的代码,一开始为IDLE状态,意味着此时没有处理任何的读写。可以接收读请求或者写请求,当Master侧传来了ar_valid信号,相应的可以把CMD全部给采集起来,然后跳转到READ状态。此时我们可以直接把req_o和addr_o赋值,这样下一个时钟周期的上升沿,即跳转到READ状态的时候,已经开始采集数据了。相应的可以节约一个时钟周期。此外还需要用寄存器记录一下此时的地址和对应的cnt(有什么用呢?接下来就说)。
case (state_q) IDLE: begin // Wait for a read or write // ------------ // Read // ------------ if (slave.ar_valid) begin slave.ar_ready = 1'b1; // sample ax ax_req_d = {slave.ar_id, slave.ar_addr, slave.ar_len, slave.ar_size, slave.ar_burst}; state_d = READ; // we can request the first address, this saves us time req_o = 1'b1; addr_o = slave.ar_addr; // save the address req_addr_d = slave.ar_addr; // save the ar_len cnt_d = 1;
我们再看一下READ状态的代码,如果Master可以接收数据,那么就应该接着去读数据,突发一共有三种分类,我们根据此分类去计算相应的地址。当最后一笔读数据回来的时候,应该重新回到IDLE状态。
此外,对于ax_req_d、cnt、addr信号,都需要用寄存器去寄存。我们使用的时候用的是其q端,然后基于Q端用组合逻辑给其D端赋值,相应的用寄存器从D打到Q端。实际上这也是状态机。(和状态机的cs和ns相对应!)
READ: begin // keep request to memory high req_o = 1'b1; addr_o = req_addr_q; // send the response slave.r_valid = 1'b1; slave.r_data = data_i; slave.r_user = user_i; slave.r_id = ax_req_q.id; slave.r_last = (cnt_q == ax_req_q.len + 1); // check that the master is ready, the slave must not wait on this if (slave.r_ready) begin // ---------------------------- // Next address generation // ---------------------------- // handle the correct burst type case (ax_req_q.burst) FIXED, INCR: addr_o = cons_addr; WRAP: begin // check if the address reached warp boundary if (cons_addr == upper_wrap_boundary) begin addr_o = wrap_boundary; // address warped beyond boundary end else if (cons_addr > upper_wrap_boundary) begin addr_o = ax_req_q.addr + ((cnt_q - ax_req_q.len) << LOG_NR_BYTES); // we are still in the incremental regime end else begin addr_o = cons_addr; end end endcase // we need to change the address here for the upcoming request // we sent the last byte -> go back to idle if (slave.r_last) begin state_d = IDLE; // we already got everything req_o = 1'b0; end // save the request address for the next cycle req_addr_d = addr_o; // we can decrease the counter as the master has consumed the read data cnt_d = cnt_q + 1; // TODO: configure correct byte-lane end end // -------------- // Registers // -------------- always_ff @(posedge clk_i or negedge rst_ni) begin if (~rst_ni) begin state_q <= IDLE; ax_req_q <= '0; req_addr_q <= '0; cnt_q <= '0; end else begin state_q <= state_d; ax_req_q <= ax_req_d; req_addr_q <= req_addr_d; cnt_q <= cnt_d; end end
看完了读,我们再来看写。对于AXI的写,实际上写地址通道和写数据通道谁在前都可以,但很多时候为了简化设计,我们的Slave可以先看写地址通道,写地址通道没有握手,不允许写数据通道握手。对于AXI Master侧实际上是感知不到这件事的,它只会看到没有握手,然后一直拉高Valid。所有AW先拉高还是W先拉高都是没有关系的。我们看具体的代码实现:
可以看到,优先只看aw_valid,当写地址通道valid拉高以后,就可以拉高ready握手成功,然后采样相应的控制信号,并将地址先赋值。然后我们看w_valid是否拉高,如果拉高直接跳到写状态,并将写使能、req都拉高。如果我们只写这一笔数据即last拉高,就直接跳到等待Back的SEND_B状态,否则跳到WRITE状态。
end else if (slave.aw_valid) begin slave.aw_ready = 1'b1; slave.w_ready = 1'b1; addr_o = slave.aw_addr; // sample ax ax_req_d = {slave.aw_id, slave.aw_addr, slave.aw_len, slave.aw_size, slave.aw_burst}; // we've got our first w_valid so start the write process if (slave.w_valid) begin req_o = 1'b1; we_o = 1'b1; state_d = (slave.w_last) ? SEND_B : WRITE; cnt_d = 1; // we still have to wait for the first w_valid to arrive end else state_d = WAIT_WVALID; end end // ~> we are still missing a w_valid WAIT_WVALID: begin slave.w_ready = 1'b1; addr_o = ax_req_q.addr; // we can now make our first request if (slave.w_valid) begin req_o = 1'b1; we_o = 1'b1; state_d = (slave.w_last) ? SEND_B : WRITE; cnt_d = 1; end end
然后我们再看写状态和等待写响应的状态,WRITE状态和READ状态差不多。重点看一下SEND_B状态,这个状态下把b_valid拉高,等待Master侧bready握手即可。握手成功跳回IDLE态,完成整个写过程。
// ~> we already wrote the first word here WRITE: begin slave.w_ready = 1'b1; // consume a word here if (slave.w_valid) begin req_o = 1'b1; we_o = 1'b1; // ---------------------------- // Next address generation // ---------------------------- // handle the correct burst type case (ax_req_q.burst) FIXED, INCR: addr_o = cons_addr; WRAP: begin // check if the address reached warp boundary if (cons_addr == upper_wrap_boundary) begin addr_o = wrap_boundary; // address warped beyond boundary end else if (cons_addr > upper_wrap_boundary) begin addr_o = ax_req_q.addr + ((cnt_q - ax_req_q.len) << LOG_NR_BYTES); // we are still in the incremental regime end else begin addr_o = cons_addr; end end endcase // save the request address for the next cycle req_addr_d = addr_o; // we can decrease the counter as the master has consumed the read data cnt_d = cnt_q + 1; if (slave.w_last) state_d = SEND_B; end end // ~> send a write acknowledge back SEND_B: begin slave.b_valid = 1'b1; slave.b_id = ax_req_q.id; if (slave.b_ready) state_d = IDLE; end
综上整个设计完成,可以看到其足够应付基本的AXI读写功能请求,在连接单端口SRAM的时候,其浪费的Cycle很少。并且在读写过程中,其aw_ready和ar_ready都会拉低,用于反压上级。这个接口和SRAM整体看,就是握手型的SRAM,很多工业界代码也会使用这种SRAM,大家可以认真学习感受其设计思想。