所以当你的面试官问起“你用过AXI4吗”的时候,他真正想确认的并不是你会不会调用Xilinx的AXI DMA IP核,而是当你面对一片没有现成IP的空FPGA,需要把自定义外设挂到ARM处理器总线上时,你能不能自己把这套协议的主机端或者从机端用Verilog写出来。这篇文章就是干这个的:基于Verilog实现AXI4协议接口的读写功能,从握手信号的本质逻辑讲起,到写通道和读通道的状态机具体怎么设计,再到仿真时容易被忽略的边界问题,最后聊聊性能上你能优化的空间到底在哪。我默认你想实现的是一个AXI4主设备(Master)接口,如果你需要的是从设备(Slave),同样能从通道拆分和状态机的对应关系里找到参照。
1. AXI4不是“复杂的协议”,而是“五个并行的FIFO”
先把心态放平。很多人一打开ARM的AMBA AXI4协议规范,看到一两百页的PDF直接劝退。实际上AXI4真正需要你来处理的,就是5个通道之间的握手关系,而每个通道的本质,就是一个带valid/ready握手的FIFO接口。你不需要把整本规范背下来,但下面这个思维模型必须建立。
1.1 为什么选择手写AXI4主设备接口
可能你会问:Vivado里拖一个AXI Master IP核不就行了,为什么要手写?答案分场景。如果你做的是纯PS端通过M_AXI_GP访问PL端的寄存器,那确实用Xilinx自带IP最省事。但一旦涉及到性能敏感的数据搬运、自定义DMA控制器、或者需要精确控制总线时序的协议转换逻辑,通用IP核反而成了黑盒,出了问题你连波形都看不懂。
我在实际项目中就吃过这个亏:用AXI DMA搬数据,带宽死活上不去,后来抓了很长时间的波形才发现是地址递增模式配置错了,导致每次都重新发送地址而不是burst传输。如果当初懂AXI4的burst地址规则,这个问题一眼就能定位。手写接口的另一个价值在于,当你的系统需要同时支持多个主设备、做总线仲裁或者插入流水线寄存器时,只有理解协议底层才能正确设计。
1.2 握手信号与valid/ready依赖关系
5个通道分别是:写地址通道(AW)、写数据通道(W)、写响应通道(B)、读地址通道(AR)、读数据通道(R)。每个通道都有独立的valid和ready信号,规则只有三条:
- valid拉高后,直到握手完成(valid和ready同时为高)之前,不能拉低。
- 如果主设备没有准备好,可以先不拉高valid,但一旦拉高就不能因为等待而取消。这是AXI协议里最基础也最容易犯错的一条:valid不能依赖ready。也就是说,你设计状态机时,不能写“if(ready) valid = 1”这样的逻辑,必须是“状态满足条件时直接拉高valid,ready什么时候给是对方的事”。
- 你可以等valid先拉高、再去等ready;也可以等ready为高后再拉高valid。两条路径都符合协议,只是时序组合逻辑深度不一样。
这三条规则决定了你设计接口的整个基调。写通道的数据和地址可以独立握手,这意味着你可以先把地址发出去,再慢慢给数据,也可以反过来。这个灵活性对性能的影响我会在第3章专门说。
这里有个常见的认知误区:很多人以为AXI4是“工作在多个时钟域的总线”。其实AXI4的5个通道必须都在同一个时钟域,协议允许不同通道之间无固定的时序关系,但主从设备必须同源时钟。如果你确实需要跨时钟域,正确做法是在主设备内部或者从设备内部用异步FIFO隔离,总线本身必须是同步的。
2. 写通道实现:把控制、数据、响应拆开处理
写操作涉及三个通道:AW(写地址)、W(写数据)、B(写响应)。通道之间互不依赖,除了一个特殊情况:写响应只会在写数据和写地址都完成之后才会产生,这是从设备的逻辑保证,在主设备端你只需要接收响应即可。
2.1 先设计写状态机还是先处理数据流
我的经验是:先画数据流,再画状态机。对于写主设备来说,数据流是这样的:
- 应用层给出写请求(地址,长度,数据)。
- 主设备在AW通道发起地址握手,同时在W通道发起数据握手。
- 从设备接收完成后,通过B通道返回写响应。
- 主设备收到B响应,确认本次写入完成。
基于这个数据流,写状态机的核心状态可以设计为:
IDLE:空闲,等待应用层写请求。AW_TRANS:发送写地址,握手完成后进入W_TRANS。W_TRANS:发送写数据。如果使用了FIFO缓冲,可以每拍发送一个数据,直到beat计数为零。B_WAIT:等待写响应。收到BVALID且BREADY拉高后完成。
这里有一个很重要的设计决策:地址发送和数据发送是否要并行。最简单的设计是串行:先发送地址,等地址握手完成后再发数据。这样状态机很好写,但性能差。如果你做的是纯寄存器配置接口,比如AXI-Lite风格的低速写操作,完全没有问题;但数据量大的场景就撑不住了——地址交换那一个cycle完全被浪费。
我的建议是直接在AW和W通道上,地址数据并行发起:
// 写请求到来时同时拉高AWVALID和WVALID assign awvalid = write_req & state_reg == IDLE; assign wvalid = write_req & state_reg == IDLE;注意上面的代码没有让AWVALID依赖AWREADY,也没有让WVALID依赖WREADY,这正好符合AXI协议中valid不能依赖ready的规则。只要应用层的write_req足够长,握手成功与否由总线决定,状态机只在后续转换时才判断握手是否完成。
2.2 多拍写入与wlast的生成逻辑
当一次写操作需要多个数据拍(beat)时,写数据通道就需要在最后一拍拉高WLAST信号。WLAST没有独立的通道,它只是W通道上的一根信号线,用来告诉从设备“这次burst的最后一个数据到了”。
确认WLAST的生成逻辑并不复杂,关键在于确定burst长度。AXI4的突发长度由AWLEN[7:0]指定,数值是实际的拍数减1。比如一次写入16个数据,AWLEN=15。
如果每拍传一个数据,那么WLAST应该在数据计数器等于AWLEN的时候拉高:
if (wvalid && wready) begin if (w_cnt == awlen) begin wlast <= 1'b1; w_cnt <= 0; end else begin w_cnt <= w_cnt + 1'b1; wlast <= 1'b0; end end正确时序下,WLAST必须是和数据一起传递的,也就是你发出最后一个有效数据的那一拍,WLAST必须同时为高,而且一旦拉高必须在有的那拍完成。这里容易踩的坑是:有些同学会把WLAST当作一个“拍后标志”,即传输完后再单独发一拍WLAST。这是错误的,会被从设备当成一个无效的额外数据拍。
另外建议在设计时把AWLEN、AWSIZE等信号做成可配置寄存器,而不是写死在模块里。我做过一个版本就是图省事写死了长度,后来换对接模块时不得不改RTL重新综合,非常被动。
2.3 数据位宽与窄传输的处理
AXI4的窄传输(narrow transfer)是指总线数据宽度大于单次访问的字节数。比如32位总线上做一次8位写操作,AWSIZE=2'b000,数据只出现在低8位或指定的strb位上。
窄传输在实际项目中大量出现——寄存器配置往往就是32位总线上写8位、16位控制字。对应的,WSTRB信号用来标注哪几个字节有效,4字节总线上WSTRB[3:0]每一位对应一个字节的写使能。
我刚接触AXI4时在这里被坑过一次:做窄传输时总是把整个数据放到低字节,然后WSTRB按地址变化。调试了很久才意识到,AXI4协议里面,WSTRB是根据地址的起始偏移和数据宽度共同决定的,不能简单地在低字节置1。例如在32位总线上,地址偏移为1、数据宽度为8位时,数据实际应该放在byte lane 1,WSTRB应该是4'b0010。
写这块逻辑时,最稳妥的办法是建立一个字节通路(byte lane)映射表,根据地址和访问宽度翻译WSTRB,而不是靠猜。这也是AXI4性能高于APB/AHB的一个代价——它对主设备更“挑剔”了。
3. 读通道实现:地址一次发出,数据分批回来
相比写通道的多路并行,读通道其实更简单,但不少人在理解上绕了弯子。读操作只有两个通道:AR(读地址)和R(读数据)。地址通道的一次握手只能对应R通道上的一串数据返回,所以读状态机的核心是“等待数据的过程”。
3.1 AR通道的发起与地址计算
读取的第一步就是发地址。对于单次访问,ARVALID拉高、ARREADY为高时,地址被从设备接收。ARM规范里,AR通道的握手不需要等待数据返回,也就是说读请求发出去之后,状态机可以从容地等待RVALID。
地址递增的逻辑和写一致:AXI4的burst是“递增式为主”,地址增量等于单次数据位宽对应的字节数,而不是固定4字节。这是新手最容易算错的地方。比如你想连续读8个16位数据,数据总线宽度是32位:
- 合理的做法是AWLEN=7,AWSIZE=2'b001(16位),每个beat的地址增量是2字节。
- 而不是AWLEN=3,AWSIZE=2'b010(32位),把两个数据拼到一个拍里。
两者的区别在于总线效率:前者是8个beat的递增burst,地址有规律,从设备可以预测并预取;后者每拍固定32位,如果应用层只需要低16位,那么高16位数据其实是被浪费的带宽。
我建议的读地址状态机设计:
localparam IDLE = 2'd0; localparam AR_SEND = 2'd1; localparam R_WAIT = 2'd2; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else begin case (state) IDLE: if (read_req) state <= AR_SEND; AR_SEND: if (arvalid && arready) state <= R_WAIT; R_WAIT: if (rvalid && rready && rlast) state <= IDLE; endcase end end读地址通道只工作一次,然后状态机就进入长时间等待R数据的阶段。不要试图在这个阶段继续发AR,除非你想设计的是多个outstanding的乱序传输,那需要独立的ID管理,我放到第5章聊。
3.2 读数据缓存与对齐问题
R通道上返回的数据不一定按你希望的方式对齐。读操作没有WSTRB,因为读出来的数据总是完整的字节通路宽度。但你作为主设备,需要从返回的数据中提取你真正需要的字段。
比如你需要从从设备读回一个64位的状态字,而数据总线是32位的,那么R通道会返回两拍:第一拍对应对齐地址的低32位,第二拍是高32位。读状态机需要把这两拍拼起来。
这里的拼接逻辑要注意字节序。ARM体系下AXI4总线默认小端模式,所以第一拍数据对应低地址,应该拼到结果的低字节位。
if (rvalid && rready) begin if (r_cnt == 0) data_low <= rdata; else data_high <= rdata; end更复杂的实现里,接收到的数据会先存进FIFO,由后级逻辑按需消费。这种情况下你不需要关心R通道的拍序,只需要把FIFO深度设置成不小于最大burst长度,防止溢出。这里有一个经验值:FIFO深度至少是突发长度的2倍。因为如果后级消费速率不稳定,写满一次就可能导致R通道ready拉低,而从设备那边可能已经准备好连续返回数据,此时如果不能在一个很短的时间内恢复ready,就会拖慢整个总线的读效率。
4. 仿真验证与踩坑:你看到的波形不一定对
写完RTL只算完成了一半,另一半是仿真验证。AXI4协议接口的仿真调试,很多问题不是功能错误,而是协议违例——功能看起来对,但协议时序不合法。这类问题最难查,因为一旦接入真实系统,表现出的症状往往是偶发死机、随机数据错误,而不是你眼前这个波形直接报错。
4.1 自己写testbench还是用SystemVerilog断言
如果你只是验证主设备接口,最简单的方案是用Verilog写一个扮演从设备角色的bus functional model(BFM)。BFM的关键不是验证逻辑,而是以协议允许的时序来响应主设备的请求。
我习惯在testbench里加断言(assertion),这样比肉眼盯波形高效得多。比如验证valid和ready的依赖关系:
// 断言:一旦valid拉高,就不能仅为等待ready而拉低 property p_valid_no_unwait; @(posedge clk) valid |=> valid throughout !ready[->1]; endproperty如果没有SystemVerilog环境,也可以退而求其次用始终块做采样检查。我遇到过这样一个经典问题:写通道里我用了组合逻辑把WVALID连接到状态机上,结果WVALID随WREADY变化产生了一个毛刺,导致仿真里从设备收到了错误的写使能。这类问题靠看功能波形是看不出来的,只有加断言检查采样窗口才能发现。
4.2 仿真中遇到的几个经典问题
按我自己的经验,手写AXI4接口仿真最容易出现三片雷区。
雷区一:握手信号和状态机之间的时序竞争。很多人在状态机输出用组合逻辑直接驱动VALID,又用同一个状态机状态去采样READY,导致VALID和READY的建立时间计算混乱。在实际时序约束分析时,整合逻辑很可能不满足时序要求。一个简单做法是把状态寄存器改成“预判下一拍状态”的方式,让VALID的生成和状态机的下一拍状态对齐,避免组合逻辑环。
雷区二:WLAST和WVALID的时序对齐。前面提到过WLAST要随最后一拍数据一起发送。但如果WVALID和WLAST的生成逻辑在不同的分支里,仿真环境下有可能因为阻塞赋值的顺序问题导致两者错拍。写RTL时建议在同一个always块中对WVALID、WLAST、WCNT统一赋值,保证同拍更新。
雷区三:读通道RVALID与RLAST不伴随。从设备返回最后一拍数据时,RVALID和RLAST必须同时有效,这本来是协议规定的。但如果你在状态机里判断的是‘接收完成“之后再去看RLAST,就会丢掉最后一拍数据。我建议在R通道接收逻辑里,把“接收数据”和“判断最后一拍”放在同一个条件里处理,而不是分成两个状态:
if (rvalid && rready) begin fifo_wr_en <= 1'b1; if (rlast) begin fifo_wr_en <= 1'b1; // 这里带上last标记,一起写入FIFO end end这个做法在数据通路后级需要“整包处理”时尤其有用。如果最后再额外判断一次rlast,就得多一个状态,也多一层判断延迟。
4.3 地址边界与burst跨越问题
地址对齐和边界跨越问题是仿真覆盖里容易漏掉的点。AXI4协议不允许一个burst跨越4KB边界(历史原因,与页表管理相关)。如果主设备发出跨越4KB的burst请求,从设备可以拒绝或者截断,这在实际SoC中意味着总线错误。
设计主设备时,有两种处理方式。第一种是在应用层保证:每次burst的长度设置不超过当前地址到4KB边界的剩余空间。第二种是在接口层自动拆分:如果应用层Access一次传入的地址长度跨越了4KB,接口自动把它拆成两个burst。第二种方式更通用,代价是控制逻辑复杂一些。我自己倾向第一种方式,至少在接口层要暴露一个“burst长度限制”的信号,由上层来保证合法性。否则你会陷入到底拆几个burst、burst间要不要插入空闲拍这类细节,工程量会明显上涨。
仿真的边界测试不建议只测满长度burst。我实际遇到过从地址偏移为0xFFC开始读16字节的情况,如果按固定burst发出,直接越界。这个Case如果你测试向量没覆盖到,合到SoC里跑系统很容易随机死机,还极难复现。
5. 实测极限:性能瓶颈与优化空间
接口已经能跑通了,波形也符合预期了,再往前一步是性能问题。AXI4在协议层面可以支持很高的并发,但真正决定带宽的是你怎么使用它。下面几项是我在实际项目中反复调优的经验。
5.1 关键路径与时序收敛
时序收敛是接口设计绕不开的话题。AXI4总线上最可能成为关键路径的,通常是握手信号和地址计算逻辑。比如产生ARADDR的加法器,如果是一个宽度的累加器,从地址寄存器的输出到下一个地址的组合逻辑会很长,尤其当数据总线宽度到128位甚至256位时。
我比较推荐的加法器处理方式是“先减后加”转为“预加”:在状态机处于IDLE状态时就把下一次burst的地址预计算好,状态机进入传输状态后直接用预计算结果,而不是等当前beat结束才做加法。这个优化看起来简单,但对时序的帮助很大,尤其当总线频率到200MHz以上,综合工具对组合逻辑深度的要求会非常苛刻。
5.2 提升吞吐率:outstanding传输与ID管理
AXI4协议允许主设备同时发出多个写事务(多个AW)或多个读事务(多个AR),而不必等第一个完成。这就是outstanding的能力。它能让总线的读写请求“排着队”被从设备处理,避免因为从设备处理慢导致的总线空闲。
但outstanding是一把双刃剑。多事务同时进行后,返回数据的顺序可能不再和请求一致。AXI4没有强制要求必须保序,这就得靠事务ID来区分响应属于哪个请求。
如果你在做一个简单的FIFO搬运DMA,我建议先从outstanding=1开始,把功能跑通再加深度。做高并发时,每多一级outstanding,你得维护一个请求队列,记录每个挂起事务的地址和长度,收到响应时再根据ID做匹配。这个逻辑如果一开始没有设计数据结构,后期重构成本会非常高。
5.3 与AXI4-Lite和AXI-Stream的对比
很多项目实际是AXI4、AXI4-Lite、AXI-Stream三者混合使用的。我的设计原则是:
- AXI4-Lite只用于寄存器配置的读写,不支持burst,每次读写一个数据,适合低带宽的控制面。不要拿它跑数据。
- AXI4用于通用内存读写,支持突发传输,适合CPU访问内存、DMA搬运这种场景。
- AXI-Stream严格来说没有地址线,只有数据流,适合连续的数据处理,比如视频流、FIFO输入输出。
做一个完整SoC时,我的习惯是把AXI4用作系统主总线,外设按功能挂到AXI-Lite或AXI-Stream上,再通过AXI Interconnect做转换。这个结构比全部用AXI4更清晰,也更容易做时序收敛。
5.4 优化边界条件:零长度传输与异常处理
AXI4协议不允许零长度传输。AWLEN为零时代表传输长度是1拍,至少传一个数据。所以接口设计里一定要对应用层的写请求做保护:如果请求长度为零,直接在本层丢弃,不向总线发起任何事务。很多系统的稳定性问题就出现在这种“看起来没人会发”的异常请求上。
另一个需要处理的异常是“未就绪状态下应用层撤销请求”。如果应用层给出的wr_req只在IDLE状态有效,那你得想清楚:如果IDLE时wr_req到来,下一拍进入AW_TRANS状态,而wr_req紧接着拉低,此时接口应该继续完成本次传输,而不是回到IDLE。正确做法是把wr_req锁存到一个内部寄存器,直到传输完成再清除。
5.5 一个精简的主设备写通道参考框架
把上面的讨论收拢到一起,一个精简但完整的写通道主设备框架大致是这样的:应用层给出写请求、地址、数据、长度;接口将请求锁存进写控制寄存器;AW通道和W通道并行发起握手;数据通路通过内部计数器和WSTRB进行字节选择;收到B响应后,产生写完成标志。
这个框架里最关键的并不是某个信号的赋值,而是状态划分和请求锁存机制。我的个人经验是:接口越往上层,状态可以越粗;越往下层,越要把“产生握手”和“等待握手”分开,这样代码的可读性会高出很多。
6. 排错经验:用签核清单替代肉眼找bug
写完了代码,跑到客户系统里崩了,这时候最能体现功底。以下是我这些年总结的一套AXI4主设备接口“签核清单”,每一条都是我实际踩过坑后沉淀下来的。
- 检查所有通道的valid信号是否在IDLE状态时都拉低。如果上电时AWVALID是高,会直接被从设备当成一次非法请求。
- 检查是否在握手的同一拍改变地址。地址必须在握手之前稳定,不能边握手边改地址。
- 检查窄传输时的WSTRB是否与地址偏移一致。这一点对寄存器配置的可靠性影响很大。
- 检查突发传输是否跨越4KB。用脚本把所有访问范围的地址算一遍,确认不会越界。
- 检查读数据通路FIFO是否有溢出保护。如果RVALID来时FIFO已经满了,必须保持RREADY拉低。如果RREADY不能及时拉高,要考虑是否是数据消费速率不足。
- 检查复位释放后总线是否处于已知状态。尤其是ARREADY/AWREADY这种从设备给出来的输入信号,在复位无效后一个周期内不要采样。
- 检查跨时钟域路径。即使AXI4协议本身是单时钟,但你的应用层逻辑很可能在另一个时钟域。建议所有跨时钟域信号都过异步FIFO,不要直接用两级同步器去同步多bit总线。
这套清单,我每次做AXI4接口都会过一遍。相比对着仿真波形一条条翻,清单式的自检更高效,不会漏查关键点。接口验证和功能验证不一样,功能错了容易看出来,协议违例往往在上板后才会暴露,那时候排查成本就很高了。
写在最后:接口设计最值钱的其实是边界思维
手写一套AXI4主设备接口,看起来是把协议规范翻译成RTL,实际上锻炼的是“边界思维”。协议规定了合法行为的最小集合,你的实现则是在这个集合里找到一个功能和时序的平衡点。我见过很多同行,能默写AXI4通道信号,但一到互联多个外设、处理outstanding乱序返回时,就暴露出对协议理解的浅层化——这不是背规范背出来的,是真刀真枪调波形调出来的。
做这套接口最让我印象深刻的一次经历,是调一个读通道数据错位的问题。波形看起来RVALID、RREADY握手完全正常,数据也是连续的,但拼出来的结果就是错4字节。查了两三天,最后发现是地址递增增量写错了:我把32位总线的每个beat的增量写成了4,而实际上访问32位数据时增量确实是4;但那次访问的是16位数据,AXI协议里burst地址递增按传输数据的实际字节数跳,不是按总线宽度跳。虽然代码注释里写了“32位总线”,但协议里的规则是看AWSIZE,不是看总线物理宽度。
这个坎过了之后,我再也没有在高位宽总线上犯过类似错误。在此也提醒所有正准备动手的同学:不要只看你想实现的功能本身,多想一想总线上的其他主设备、从设备会怎么响应你的行为,AXI4互联的网络里任何一个协议违例都会被放大成系统级故障。把接口做扎实了,你的DMA、你的SoC、你的整个系统才能站得住。