简介:这份《AXI总线协议规范》文档面向SoC、FPGA及芯片设计领域的学习者与工程师,系统讲解ARM AMBA 3.0框架下AXI高性能、高带宽、低延迟片内总线的核心机制,可作为理解总线协议、开展IP互联设计的入门参考。文档围绕读地址与控制、读数据、写地址与控制、写数据、写响应五个独立通道展开,说明地址/控制与数据相位分离、突发传输仅需首地址、非对齐访问、乱序响应及outstanding操作等特性,并对比AXI与AHB在性能和带宽上的差异。同时,文中还介绍低功耗模式进出时的握手协议、CACTIVE/CSYSREQ/CSYSACK信号作用,以及处理器、存储器、DMA控制器等典型应用场景,便于读者建立从协议概念到系统集成的整体认识。资源包共1个doc文件,大小约77KB,轻量便携,适合快速查阅。目前已有430人学习,可供总线协议入门与复习使用。
1. AXI 总线的性能账:单笔传输其实更慢了
把 AHB 和 AXI 的同一笔读操作摊在波形上数拍子,AXI 往往更慢:地址要握手、数据要握手、写还要多一个响应握手。可一旦系统里挂上几个主设备、几块存储、几个 DMA 控制器,AXI 的吞吐能拉开一个量级。AXI 总线协议规范里那些看起来啰嗦的通道和信号,本质上是在做一件事——把「等」这件事从关键路径上拆下来,挪到别的地方去消化。搞懂这套拆分方式,比背信号名有用得多。你在做 IP 集成、看波形、调互联的时候,遇到的大部分怪现象,根子都在这套设计取舍里。
2. 五通道分离:地址和数据为什么要分家
2.1 通道切分不是分类,是把相位解耦
AXI 定义了五个通道:读地址、读数据、写地址、写数据、写响应。刚接触的时候很容易把它当成「把信号按功能分个组」,实际不是。真正的变化是相位关系被拆开了,地址和控制信息相对数据可以独立推进。主设备先把地址发出去,数据可以晚几个周期再来,中间这段空隙不是浪费,是可以塞进另一笔事务的。AHB 上地址相位和数据相位是绑死的,每一笔传输必须连续走完,一个等待周期就把整条总线钉在原地。AXI 把这个绑定解开之后,控制流的处理和数据的搬运就能各自流水起来。
这种解耦带来的第一个直接好处是时序收敛。地址通道逻辑浅、位宽窄,可以往高频推;数据通道位宽大、扇出多,走线和逻辑都更重,频率上不去也没关系,靠位宽补。两者分开做约束,工具不用在一条路径上同时满足两种互相打架的目标。
第二个好处是接口对称。AXI 的主从接口在读写两个方向上是镜像的,同一套握手规则复用在五个通道上。做互联的时候,你只需要写一份通道模板,实例化五遍,加不同的位宽和方向参数,这比给每种传输单独写一套状态机省太多事了。
2.2 单向通道:三态总线的坟头草
早期共享总线为了省引脚,数据线是双向的,多个主设备靠仲裁器决定谁驱动。片上系统里这么干代价很大:双向信号在综合时要推断出三态门,物理实现上要处理使能方向和竞争,跨时钟域的时候更麻烦——双向信号没法简单地打一拍,你还得同时处理方向翻转的时刻。AXI 的每个通道都是严格单向的,信息只朝一个方向流。多主多从的连接靠互联结构里的多路选择器完成,不再有双向驱动。
单向带来的连锁反应比想象中大。跨时钟域桥接变得极其简单:单向信号加一级同步器就能过去,不用管方向握手。做寄存器切片(register slice)也很自然,在通道中间插一级触发器,把长线打断,时序立刻好看。多层互联、片上网络这类结构能落地,前提就是通道单向、可以随便切段。
| 对比项 | AHB | AXI |
|---|---|---|
| 数据线方向 | 双向共享 | 每个通道单向 |
| 读写通道 | 共用一套 | 读写完全独立 |
| 握手方式 | 从设备单向拉低 HREADY | VALID / READY 双向 |
| 未完成事务 | 基本不支持 | 支持多笔 outstanding |
| 返回顺序 | 严格保序 | 允许乱序 |
| 非对齐访问 | 不支持 | 支持,靠字节选通 |
2.3 VALID/READY:双向流控替代单向 HREADY
AHB 的流控是单向的:从设备拉低 HREADY,主设备只能等。这套机制简单,但把主动权全给了从设备,主设备没法表达「我这笔数据还没准备好」。AXI 改成两边各有一个信号:发起方用 VALID 表示「我手里的地址或数据有效」,接收方用 READY 表示「我能接」。两个信号独立,谁也不许等谁。
规则里有两条是硬约束,违反了仿真不一定报错,但上板必炸。第一条:VALID 一旦拉高,在对应的 READY 到来之前不许撤销,也不能改变数据内容。第二条:VALID 不允许组合依赖 READY,否则两端互等就是死锁。第二条经常被写成「VALID 可以晚于 READY,但不能由 READY 组合产生」,本质是要求握手路径上不能有组合环。
反压就建立在这套双向握手上。下游忙,READY 拉低,上游的 VALID 挂在那里不动,数据原地待命,不丢也不重复。上游忙,VALID 不拉高,下游的 READY 拉高也没用,白等。任意一端慢,整条链路自动降速,不需要额外的流控协议。
2.4 从握手到 skid buffer:为什么要多一级寄存
VALID 不许撤销这条规则,在组合逻辑很深的设计里会变成麻烦。假设上游的 VALID 是由一堆条件算出来的,中途某个条件变化导致 VALID 掉了,而此时 READY 恰好没来,这笔事务就丢了。常见的解法是在中间插一个浅缓冲,把「已经决定要发」和「下游接不接」这两件事隔开。
// 一级 skid buffer:保证 VALID 一旦拉高,不因下游未 READY 而撤销 module skid_buffer #(parameter W = 32) ( input wire clk, input wire rst_n, // 上游侧 input wire s_valid, output wire s_ready, input wire [W-1:0] s_data, // 下游侧 output reg m_valid, input wire m_ready, output reg [W-1:0] m_data ); reg buf_valid; reg [W-1:0] buf_data; // buffer 空的时候才允许接新数据,否则会把未送走的数据覆盖 assign s_ready = m_ready | ~buf_valid; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin m_valid <= 1'b0; m_data <= {W{1'b0}}; buf_valid <= 1'b0; buf_data <= {W{1'b0}}; end else if (s_valid && s_ready) begin if (m_valid && !m_ready) begin // 下游还没接走旧数据,新数据先落 buffer buf_valid <= 1'b1; buf_data <= s_data; end else begin m_valid <= 1'b1; m_data <= s_data; end end else if (m_valid && m_ready) begin if (buf_valid) begin // 下游接走旧数据,buffer 里的顶上 m_data <= buf_data; buf_valid <= 1'b0; end else begin m_valid <= 1'b0; end end end endmodule这段代码的关键在s_ready = m_ready | ~buf_valid这一行。buffer 空的时候,上游可以直接穿过去;buffer 满了,上游必须停。注意它没有用m_ready去组合地影响m_valid,m_valid只在时钟沿更新,握手路径上不存在组合环。
为什么一级就够?因为 AXI 规定 VALID 不许撤销,最坏情况是「下游拒收一拍、上游又送来一拍」,此时只需要暂存这一拍。深度为 2 的存储(一拍在主路径、一拍在 buffer)足以吸收这种背靠背的冲突。再深就是性能优化而不是功能需求了,通常出现在跨时钟域或者长线延迟补偿的场景。
参数上,W要跟通道位宽对齐。数据通道是TDATA的宽度,地址通道就是地址加控制信号的打包宽度。实际工程里很少手写这个模块,互联 IP 里到处都有,但它的存在解释了一个常见现象:为什么你在某段链路上看到 VALID 拉高后,数据晚了两个周期才出现在下游——中间有切片。
3. 突发、outstanding 与乱序:ID 是唯一的解药
3.1 一次突发只发首地址,省下的是地址通道带宽
AXI 的突发传输只发首地址,后续每个数据 beats 的地址由从设备内部自己递增。这个设计把地址通道的带宽摊销掉了。做一块大块内存拷贝的时候,地址通道只在开头忙一拍,剩下的时间全是数据通道在跑,地址通道可以腾出来服务别的事务。
突发有三种类型。FIXED 是地址不变,适合 FIFO 这类固定端口的设备;INCR 是递增,最常用;WRAP 是回卷,地址涨到边界后绕回起点,专门服务缓存行填充这种场景——缓存行是固定长度的,从中间地址开始的填充需要绕回去。
| 突发类型 | 地址行为 | 典型用途 |
|---|---|---|
| FIXED | 每个 beat 地址不变 | FIFO、外设寄存器 |
| INCR | 每个 beat 递增 | 连续内存读写 |
| WRAP | 涨到边界回卷到起点 | 缓存行填充 |
代价是从设备必须自己维护地址计数器,而且要能处理边界。互联结构里通常会给突发加边界约束,超出边界的突发必须拆成两笔,否则从设备的地址解码逻辑会做得很复杂。这也是为什么有些主设备在发起长突发之前会先检查起始地址和长度,避免跨过解码边界被拆开,白白损失效率。
3.2 outstanding:把等待时间叠起来
outstanding 的意思是主设备发完地址不等数据回来,接着发下一笔。这跟 CPU 的乱序执行是同一个思路:等待时间不再串行累加,而是重叠起来。假设一次读操作往返要 100 个周期,串行发 8 笔就是 800 个周期;允许 8 笔 outstanding,理论上 100 多加 7 个周期就能全部发完,剩下的就是等数据陆续回来。
支持 outstanding 的前提是能区分每一笔事务。AXI 用 ID 来干这件事,每笔事务在地址通道上带一个 ID,数据返回时把同一个 ID 带回来。主设备靠这个 ID 把回来的数据对号入座。ID 的位宽决定了互联结构内部能并行处理多少条独立的事务流,位宽越宽,互联可以做的并行 bank 越多,但面积和路由复杂度也上去了。
读写通道独立,意味着读写可以同时进行。这一点在做 DMA 的时候特别明显:读通道在从内存搬数据,写通道同时在往外设送数据,两条流互不干扰,不需要像共享总线那样分时复用。
3.3 乱序返回与 ID 归位
从设备或者互联结构内部如果有多个可以并行的存储体,不同 ID 的事务可能以不同的速度完成,返回顺序就跟发出顺序不一致了。这就是乱序。乱序本身不是问题,问题是主设备得知道哪笔数据对应哪次请求。
协议给的约束是:同一个 ID 的事务必须保序,不同 ID 之间可以乱序。这条规则把复杂度分配得很清楚——从设备只要保证单个 ID 内部的顺序,主设备只要为不同的用途分配不同的 ID,两边都不用做全局排序。
import heapq from collections import defaultdict def simulate_returns(bursts, bank_of, latency_of): """ 模拟从设备侧的乱序返回,并检查「同 ID 保序」这条约束。 bursts: [(tid, addr, seq)],按主设备发起顺序排列 bank_of: 地址 -> bank 编号,不同 bank 可以并行服务 latency_of:bank 编号 -> 服务延迟 """ heap = [] for idx, (tid, addr, seq) in enumerate(bursts): bank = bank_of(addr) # 简化模型:完成时刻 = 发起时刻 + 该 bank 的服务延迟 finish = idx + latency_of(bank) heapq.heappush(heap, (finish, seq, tid)) last_seq = defaultdict(lambda: -1) order = [] while heap: finish, seq, tid = heapq.heappop(heap) order.append((tid, seq)) # 同 ID 的事务如果后发先至,就是协议违规 assert seq > last_seq[tid], ( f"ID {tid} 发生乱序:seq={seq} 早于 seq={last_seq[tid]} 返回" ) last_seq[tid] = seq return order # 三笔同 ID 走同一个 bank,两笔不同 ID 走另一个 bank bursts = [(0, 0x1000, 1), (1, 0x2000, 2), (0, 0x1004, 3), (1, 0x2004, 4)] bank = lambda a: 0 if a < 0x2000 else 1 lat = {0: 3, 1: 1} for item in simulate_returns(bursts, bank, lat): print(item)跑一遍会看到 ID 1 的两笔先回来,ID 0 的两笔后回来,但 ID 0 内部仍然是 1 在前 3 在后,断言全部通过。把bank函数改成让同 ID 的两笔落到不同 bank,断言立刻炸掉——这正是集成时最容易踩的坑:互联结构内部做了并行 bank,却没检查同一个 ID 的事务会不会被分到不同 bank 上去。
参数说明:finish是模拟的完成时刻,实际硬件里没有这个值,这里只是为了造出乱序效果;last_seq记录每个 ID 最后一次返回的事务序号,断言比较的就是这一项。真实环境里这条检查应该写成断言挂在仿真里,一旦违规立刻报出来,而不是等到波形上对着看。
3.4 非对齐传输与字节选通
非对齐指的是传输的起始地址不在数据宽度的整数倍上。AHB 时代这类访问要靠软件拆成两次对齐访问,或者由桥接逻辑代劳。AXI 直接支持非对齐,靠的是写数据通道上的字节选通信号:每个字节位置对应一个选通位,从设备只需要把选通为高的字节写进去,其余字节保持不变。
非对齐的第一拍最麻烦。假设总线宽度 32 位,起始地址是 0x1002,那么第一拍只有高两个字节有效,选通位是4'b1100,不是全 1。最后一拍如果长度不是整数倍,也会出现部分有效。中间各拍才是全选通。这个细节在写从设备的时候必须处理,尤其是那种把字节选通直接映射到寄存器写使能的实现,漏掉首拍的掩码就会把相邻字段冲掉。
好处很实在:拷贝一个小结构体的时候,不用为了对齐先搬一次再拼一次,软件侧的 memcpy 路径能省掉不少分支。代价是从设备的写逻辑要按字节而不是按字处理。
4. 低功耗握手:CACTIVE 才是真正的准入票
4.1 三个信号的职责划分
AXI 的低功耗接口是在数据传输协议之上加的一层扩展,只用到三个信号,针对有低功耗管理能力的设备和没有低功耗管理能力的设备都能用。
CACTIVE 是设备发给系统时钟控制模块的,意思是「我现在需要时钟」。这个信号的方向很关键——它由设备发起,不是系统轮询出来的。CSYSREQ 是系统发给设备的,表示「我请求你进入低功耗状态」。CSYSACK 是设备回给系统的应答,表示「我收到了这个请求并且状态已经迁移」。三者的角色不能混:CSYSACK 只负责说明状态迁移的完整性,设备到底能不能关时钟,由 CACTIVE 说了算。
很多初学的人会以为 CSYSACK 拉低就等于可以关时钟了,这个理解是错的。项目文档里说得很清楚:在握手中 CACTIVE 可以作为拒绝或者同意的标志,如果 CACTIVE 一直拉高,就表示当前不接受低功耗请求,而不是依赖 ACK 信号来表达这个意思。换句话说,ACK 走的是流程,CACTIVE 走的是许可。
4.2 一次完整的进入/退出时序
| 时刻 | CSYSREQ | CSYSACK | 含义 |
|---|---|---|---|
| T1 之前 | 高 | 高 | 设备处于正常工作状态 |
| T1 | 拉低 | 高 | 系统请求进入低功耗 |
| T2 | 低 | 拉低 | 设备应答,状态迁移完成 |
| T3 | 拉高 | 低 | 系统请求退出低功耗 |
| T4 | 高 | 拉高 | 设备应答,已退出低功耗 |
T2 和 T4 两个时刻都要注意先后关系:CSYSACK 的翻转必须晚于 CSYSREQ 的翻转,不能同时发生,否则系统侧无法判断这次握手是新的请求还是上一次的残留。实际实现里通常用状态机保证这个次序。
4.3 轮询与被动上报:两种系统策略的取舍
系统层面的低功耗管理有两条路。第一条是系统主动轮询设备,定期检查哪些设备空闲,空闲就拉低 CACTIVE,再走低功耗握手。这条路耦合性强,实现简单,但效率低——系统并不知道某个设备什么时候会空下来,只能按固定节奏查,查早了没用,查晚了错过窗口。
第二条路是设备主动上报。设备自己知道什么时候活干完了,把 CACTIVE 拉低,系统收到这个信号再决定要不要发起低功耗请求。这条路效率高得多,但系统可能因为预测到马上要用这个设备,收到信号也不发起请求。所以它是个建议而不是命令,最终决定权还在系统侧。
两种方式可以混合使用。常见的做法是关键路径上的设备用被动上报,边缘设备用轮询,系统侧维护一张策略表。文档里特意点出这一点,是因为很多设计一上来就只做一种,后面发现某些设备根本没法主动上报(比如没有内部状态机的纯组合逻辑外设),只能返工。
// 设备侧低功耗握手:CSYSACK 跟随 CSYSREQ,CACTIVE 表达真实意愿 module pm_handshake ( input wire clk, input wire rst_n, input wire csysreq, // 来自系统时钟控制器 input wire busy, // 设备内部仍有未完成的事务 output reg csysack, output wire cactive ); reg lowp; // 已经进入低功耗状态 // 只要设备还有活干,就持续拉高 CACTIVE 表示拒绝 assign cactive = busy | ~lowp; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin lowp <= 1'b0; csysack <= 1'b1; end else if (!csysreq && !lowp) begin lowp <= 1'b1; // T2:ACK 晚于 REQ 一拍拉低 csysack <= 1'b0; end else if (csysreq && lowp) begin lowp <= 1'b0; // T4:ACK 晚于 REQ 一拍拉高 csysack <= 1'b1; end end endmodule这里cactive = busy | ~lowp是整个模块的核心。busy为高时,无论系统怎么请求,CACTIVE 都是高的,时钟控制器看到这个信号就不该关时钟。~lowp保证设备在正常状态下始终主张自己需要时钟。系统侧关时钟的条件应该是!cactive && !csysack同时成立,只满足其中一个都不能关。
参数上,busy需要覆盖设备所有可能产生事务的路径,包括内部 DMA、缓冲刷新、状态机迁移。漏掉任何一条,都会出现「CACTIVE 已经拉低但设备还在发请求」的情况,时钟一关就挂死。
5. 集成时最容易翻车的五处
5.1 VALID 拉高后又撤销
现象:仿真里从设备偶发丢数据,重跑一遍又不复现,波形上看到 VALID 有毛刺。原因多半是发送侧的 VALID 由组合逻辑产生,某个上游条件在中途变化,VALID 跟着掉了,而此时 READY 还没来。协议明确禁止这种行为。解决办法是把 VALID 打一拍寄存,或者在中间插 skid buffer,让「决定发送」和「下游接收」彻底解耦。
5.2 VALID 与 READY 形成组合环
现象:仿真跑起来直接死锁,波形上一片 X,或者两个信号在同一个时刻互相等待。原因是 VALID 的组合逻辑里用到了 READY,而 READY 的组合逻辑里又用到了 VALID。跨通道的依赖更容易出问题,比如写数据通道的 VALID 依赖写地址通道的 READY。解决办法是把跨通道的依赖打拍,或者干脆拆成独立的状态机,让每个通道的 VALID 只由本通道的状态决定。
5.3 同 ID 事务被下游并行打散
现象:主设备收到的数据顺序和预期对不上,但每个 ID 单独看又没错。原因是互联结构内部做了多 bank 并行,同一个 ID 的多笔事务被分到了不同 bank 上,返回时自然就乱了。协议要求同 ID 保序,这个责任在从设备和互联侧。解决办法是在互联的 bank 分配逻辑里加约束,同一 ID 的事务必须落到同一个 bank,或者在上层用不同的 ID 区分。
5.4 非对齐首拍的字节选通算错
现象:写寄存器的时候,相邻字段被莫名其妙改掉。原因是非对齐传输的第一拍只有部分字节有效,如果从设备把字节选通直接当成寄存器写使能,而没有按地址低位做掩码,就会把不该写的字节写进去。解决办法是在从设备侧显式处理首拍:根据起始地址的低位算出有效字节范围,与选通信号做与运算再输出。末拍同理,长度不是整数倍时要截断。
5.5 低功耗握手只看 ACK 不看 CACTIVE
现象:设备在低功耗模式下被关掉时钟,恢复后状态全乱,或者干脆起不来。原因是系统侧把 CSYSACK 拉低当成了可以关时钟的条件,忽略了 CACTIVE 还拉着。解决办法是把关时钟的条件改成!cactive && !csysack,并且在设备侧确认所有可能产生事务的路径都汇总进了 CACTIVE 的生成逻辑。这条坑的隐蔽之处在于,大部分时候设备确实空闲,问题要等到某个特定负载下才暴露。
6. 复杂度换并行度:AXI 的取舍边界在哪
AXI 的设计哲学可以把账算得很直白:用五倍的通道数、翻倍的信号量、一套必须严格遵守的握手规则,换来的是把串行等待变成并行重叠的能力。这笔账在高端 SoC 上是划算的,因为那里瓶颈在带宽不在面积;但在一个只有几块慢速外设的控制器里,全套 AXI 就是杀鸡用牛刀。常见的做法是主干用完整 AXI,边缘挂 AXI4-Lite,再往外用 APB 接寄存器,三层各司其职。
验证上有一件事值得单独提。AXI 的规则大多是「不许做什么」而不是「必须做什么」,这类约束最容易在仿真里漏过去。我的习惯是把 VALID 不撤销、同 ID 保序、握手不出现组合环这三条写成断言挂在接口上,跑随机激励的时候让断言自己去抓。覆盖率报表好看不代表协议合规,断言才是那道兜底。
从那以后我每次接一个新 IP,不管它标称多符合规范,都会先把这三条断言挂上去跑一轮。翻车最惨的一次就是某个第三方模块的写数据通道 VALID 会掉,仿真跑了几十万个周期才撞上一次,波形上根本看不出来是它的问题,最后靠断言定位到具体那一拍。这套规范文档值得放在手边当字典翻,遇到波形对不上的时候,先去里面找那条被违反的规则,比盲猜快得多。希望帮到你。
本文还有配套的精品资源,点击获取