☰
从AXI到CHI:NoC流量控制中的Credit机制解析与实践
2026/9/29 19:11:12 网站建设 项目流程

做数字IC这几年,AXI、CHI、Credit、流量控制、NoC这几个词几乎天天都要碰到。搞过片上网络的人都知道,NoC的命根子就是流量控制,而CHI从这个角度看就是一次重量级升级,它把Credit机制变成了数据搬运的节拍器。以前用AXI做互连,最头疼的是远端Ready信号怎么传;后来切到CHI风格的Credit流控,才算真正理解了什么叫“把背压做成资源管理”。这篇文章想聊的,就是我从AXI到CHI的实践过程里,对基于Credit的流量控制是怎么优化NoC设计的理解和踩坑总结,适合正在做互连总线、NoC路由器,或者准备数字IC架构岗面试的同学参考。

1. 从AXI到CHI:NoC流量控制的前世今生

1.1 AXI生态下的总线瓶颈

AXI协议在SoC内部AMBA互连中占据主导地位,核心就是Valid/Ready握手:发送方把Valid拉高表示数据有效,接收方把Ready拉高表示可以接收,两者同时在上升沿为高时完成一拍传输。这个机制非常直观,设计师很容易上手,但到了多节点互连场景,AXI的短板就显现出来了——信号是“即时交互”的。

在普通总线结构里,主从两端离得近,组合逻辑延迟可控。但在一个复杂的NoC拓扑中,比如8x8 Mesh网络里,一个源节点到目标节点要经过七八跳,每一跳之间还有好几级流水寄存器。如果还指望接收端直接拉一个Ready信号传回发送端,这个信号就要穿透整个网络,组合逻辑路径会长到完全无法收敛。所以AXI时代的NoC方案,通常都是在路由器内部把Ready拆成逐级stall信号:每一级的输入Buffer快满时,就向前一级发送暂停请求。但这样做的代价是背压是“串联”的,一级堵,上游全堵,信号链路上每一级都要参与握手,频率和吞吐都很受限。

我之前在做某款多核SoC的NoC互连时,第一版就是用AXI风格的Valid/Ready和本地stall组合起来做的。在benchmark流量不重的时候表现还行,一旦多个主设备同时朝同一个目标灌数据,内部Buffer会很快填满,背压反向传播,整个网络的有效吞吐掉得很难看。调那些背压时序,是我那段时间最痛苦的记忆。

1.2 为什么CHI要把Credit扶正

CHI是AMBA5里的高性能缓存一致性协议,它面向超大规模多核系统和Data Center SoC,天然要支持多跳网络。CHI在协议设计上做了一件很关键的事:把“链路传输”和“协议消息”分成不同层,然后在链路层引入了基于Credit的流量控制。

Credit流控的核心思想,通俗地讲就是“先买票后上车”。接收端预先告诉发送端:我这边有N个空位,你可以发N笔数据。发送端每发一笔,本地计数器减1;接收端把它处理完,再返还一个Credit。发送端只有在本地Credit大于0时才能继续发送。这样一来,发送端不再需要实时查询接收端的Buffer状态,只需要维护一个本地计数器,关键路径从“跨长距离的Ready返回”变成了“本地计数器的比较”。这在实际实现中非常香:Credit机制可以把反压从组合逻辑里抽出来,变成可流水线化的资源管理,天然适合多跳的NoC结构。

CHI协议里还专门定义了不同消息类型,比如Requisition、Response、Snoop、Data,各自有对应的Credit资源和虚拟网络。这样不仅把背压解耦了,还把协议上的依赖关系一并考虑进去,保证REQ、RSP、SNP、DAT这些消息类之间有明确的资源隔离,不互相挤占造成协议死锁。这点是从AXI到CHI进化里最值钱的设计思想。

1.3 从AXI到CHI的现实迁移

很多人容易有一个误解:我换了CHI协议,就自动拥有了Credit流控。其实真正的NoC设计里,端到端不一定全是CHI接口。比如GPU、AI加速器、视频编解码IP很多仍是AXI接口,它们接入NoC时需要一个桥接层。

实际项目中,典型的做法是:NoC内部路由节点和链路层使用Credit流控,边缘节点用AXI/CHI桥接IP做协议转换。AXI侧的Valid/Ready握手,在桥接器里被翻译成Credit消耗;NoC内部每跳都用Credit管理Buffer,最后在目标端再把流量翻译成目标接口的握手信号。这个过程考验的并不是会不会写协议转换,而是能否把Credit的Buffer深度、返回路径、虚拟通道分配这些底层细节做对。所以我的观点是:决定NoC性能上限的,往往不是“用了AXI还是CHI”,而是有没有把这个Credit流控吃透。

2. Credit流量控制的核心原理:从“握手印”到“钱包余额”

2.1 Credit到底在数什么

Credit最容易用钱包余额来解释。把接收端的输入Buffer想象成一个存储柜,柜子有N个格子。发送端手里一开始有N张代金券,每往柜子里放进一个flit,就撕掉一张券;柜子里的flit被取走之后,柜台再送回来一张券。发送端什么时候能发货?看手里券还有没有,有券就发,没券就等着。这就是Credit计数。

从形式化角度看,Credit counter记录的是“接收端剩余可接收数量”。只要初始值等于接收端Buffer容量,并且每发一个flit减一、每归还一个credit加一,系统就永远不会出现“发送端发出了数据但接收端Buffer已经满了”的情况。这个不变量是Credit流控可靠性的根基,设计时所有断言和验证都要围绕它来做。

还有一个容易混淆的点:Credit返回的时机到底应该是“接收端收到数据后立刻返还”,还是“接收端从Buffer里取走数据后才返还”?两者在协议语义上是不同的。如果Credit代表“可用空位”,那么数据一写入Buffer,接收端就应该立刻返还Credit,因为发送端占用的那个空位已经补回来了。如果Credit代表“已处理能力”,那么要等数据被消费后才返还。工程上推荐使用前者——在数据被接收端存入Buffer的同一拍就返还Credit。这样可以把处理管线的延迟从Credit反馈中拆出去,避免“收到数据但迟迟不返Credit”导致链路空跑。

2.2 Link Credit 和 Protocol Credit 的差别

CHI里的Credit并不是一套,而是两层:链路层Credit和协议层Credit。这是很多人初学CHI时最绕的地方。

链路层Credit作用于每一个物理链路,目的是管理路由器输入端口的物理Buffer。每次数据flit在链路传输前,发送端都必须确认对应的Link Credit大于0,否则不能发。Link Credit在每个hop逐跳维护,链路A到B的Credit不会影响链路B到C,每个链路的Buffer和Credit都独立计算。

协议层Credit则作用于协议的端到端资源,目的不是控制物理Buffer,而是控制一个节点能向另一个节点发送多少个协议消息(Req、Rsp、Data等)。比如在Cache一致性场景里,接收端处理一个请求可能需要分配一个Miss状态寄存器,这个资源是有限的。Protocol Credit就是用来限制pending transaction数量,防止某个发起端把接收端的协议资源耗尽。

这两层Credit各管一摊,缺一不可。Link Credit管的是“路上不堵”,Protocol Credit管的是“终点容得下”。有一次我调试NoC吞吐上不去,链路层Credit值给得很大,但协议层Credit池很小,结果数据到了一个节点,因为Miss资源没释放而停住,整条链路全被堵死。所以设计时要同时平衡这两层参数,不能只看链路Buffer。

Credit类型作用对象消耗方式返回时机
Link Credit物理链路/路由器输入Buffer每发送一个flit接收端把flit存入Buffer后
Protocol Credit协议处理资源/接收端事务槽位每发送一个协议消息接收端完成协议处理后

2.3 Credit vs Valid/Ready:一场关于“距离”的取舍

拿过窄桥来比喻Valid/Ready:桥两头的人必须手拉手,前一个人迈一步,后一个人确认脚踩稳了才迈下一步,每一步都要实时对齐。这套机制在短距离、低延迟链路里非常好用,控制逻辑简单,而且天然避免Overflow。

Credit则更像游乐园的通行证模式:保安先把固定数量的券发给游客,游客进一个项目就交一张券,项目结束再把券还回去。游客不需要在入口一遍遍问保安“我现在能不能进”,只要手上还有券,到了入口就可以直接进。即使券返回的路上有几拍延迟,也不会导致数据出错,最多是临时没券要等一等。

所以Credit的核心优点是可以容忍“反馈延迟”。无论链路里有多少级流水,Credit计数器的增减都是本地的,唯一条件是Buffer深度要覆盖“在途flit数 + 反馈返回延迟”。Valid/Ready则很难容忍这种延迟,因为Ready信号本质上是一个组合反馈,一旦链路变深,Ready的组合路径就会变长,最终限制工作频率。

实践中我见过不少团队,内部接口仍然写成AXI-Stream的Valid/Ready形式,但路由器内部并没有实现真正的Credit池。这种方式一旦NoC深度增加,Ready信号需要跨多级流水才能回到源头,时序立刻恶化。如果改成Credit内部接口,发送端只需要看本地计数器,Ready信号的作用只是“本地Credit是否大于0”,完全可以在源头发送端本地生成,这样每一级流水就可以放心插寄存器,性能和复杂度都能改善。

3. 把Credit落到NoC设计里:关键参数与实现细节

3.1 Credit数量怎么算:Buffer、往返延迟和吞吐

很多人拿到一个NoC模块,第一反应是“Buffer开大一点,Credit给满”,这样当然不会出错,但成本和性能都不理想。Credit数量的本质是“在途数据量的上限”,它必须覆盖链路两端之间的往返延迟,否则链路带宽会被Credit返回速度卡住。

我一般用一个很粗略的公式来估算:

满速所需Credit数量 = 发送路径流水拍数 + 接收端写入并返回Credit的处理拍数 + Credit返回路径流水拍数

这里不要求非常精确,但概念要清楚:假设发送端到接收端的数据路径是3拍流水(包含寄存器),接收端把flit写入Buffer并在同一拍产生Credit返回信号,Credit返回路径又是3拍流水,那么一个数据包发出后,最快也要经过3+1+3=7拍才能让发送端拿到这个Credit。如果初始Credit只有1,发送端每发完一个flit就要傻等7拍,链路利用率只有大约1/7。如果初始Credit有8,发送端连续发出8个flit后,第8个flit刚发出,第一笔的Credit刚好或者即将回来,可以继续发,链路就能基本跑满。

工程实现上,我习惯把初始Credit直接设成接收端Buffer深度,也就是Credit计数器初始化值等于Buffer的slot数量。但Buffer深度不能拍脑袋定,要结合链路往返延迟和高水位目标来算。最简单的做法是在RTL仿真里构建一条满负荷连续发送的testcase,观察Credit是否在某个阶段降到0且持续很久。如果降到0的时间超过几个周期,说明Buffer深度不够,链路吞吐已经受限于流量控制,需要加大Buffer或缩短Credit返回延迟。

3.2 多VC和多类消息的Credit分配策略

CHI协议里有多个虚拟网络,比如REQ、RSP、SNP、DAT。NoC路由器通常要给每个虚拟网络分配独立的Credit池,因为不同消息类之间的依赖关系非常微妙。如果REQ消息把缓冲资源占满,RSP消息就可能因为拿不到Credit而无法返回,而REQ发送端又一直等待RSP,最后形成协议层面上的死锁。

我见过的做法有两种极端。一种是彻底隔离:每个VC独立Buffer,独立Credit,实现简单、死锁风险最低,但Buffer利用率不高。另一种是完全共享:所有消息类共用一个Buffer池,谁有Credit谁用,利用率高但风险大,一个消息类可以“饿死”另一个。

实际项目里我倾向于折中:核心协议VC(比如RSP和DAT)给独立的Credit池,保证一致性协议不会卡死;低优先级的几个数据类可以共享一个Credit池,但要在共享池里设置水位线。比如最多允许一个VC占用共享池的70%,超过就不能再占。这个水位线需要仔细调,调得太松起不到隔离作用,调得太紧又会浪费Buffer。我在一个项目里就是靠这个策略,把总Buffer大小压缩了25%,同时没再出现消息类互相饿死的问题。

3.3 Credit返回通道的设计:别让它成为定时炸弹

如果说数据通道是人的上半身,那Credit返回通道就是下半身,很多人容易忽略它。Credit返回信号占用独立带宽,通常和反向的数据通道走同一条物理链路,但不参与数据仲裁。比如A到B的方向上,Credit返回信号可能由B主动发给A,它是一个边带信号,方向与数据流相反。

一个非常隐蔽的坑是:Credit返回路径本身有没有背压?如果Credit返回信号也和数据一样走一套带握手的Buffer,那么当Credit返回通道被堵住时,数据通道也会慢慢停掉。这种“信用返回阻塞”比数据通道阻塞更难查,因为看起来是发送端没Credit了,实际上接收端一直在正常处理数据,只是返还的Credit卡在路上。

所以设计时要确保Credit返回信号要么不走数据Buffer,要么有最高的优先级。在CHI链路里,Credit返回是作为一种简单的sideband信号实现的,每拍可以发送若干Credit,不依赖数据握手。如果因为跨时钟域需要做同步,也要保证Credit不丢、不重、不乱序。我之前吃过一次亏,远程节点返回的Credit跨了两级异步FIFO,因为同步器采样问题丢了几个Credit,导致发送端每跑一段时间Credit就永久少一截。后来在所有Credit路径上加了完整性检查,一旦链路的Credit计数和模型预期不一致立刻打印错误。这个习惯救了很多次。

4. 实操记录:基于Credit的NoC路由节点设计与验证

4.1 顶层接口和模块划分

以最简单的NoC路由器为例,每个端口包含三个功能块:发送侧Credit计数器、接收侧Buffer、以及Credit返回生成逻辑。路由器的数据路径由输入Buffer、路由计算、VC仲裁、交叉开关和输出Stage组成。

发送侧的核心是一个模N计数器,N等于接收端Buffer深度。计数器受到两个事件驱动:发送了一个flit就减一,收到一个Credit返回就加一。这两个事件可以发生在同一拍,减去一再加上一,净效果不变。

接收侧每个输入端口都有一块小Buffer,flit到达后如果Buffer有空位就写入,同时生成一个Credit返回信号给上游。返回信号要带上VC编号,因为每个VC的Credit池是分开的,不能混用。这一层很基础,但特别容易因为位宽和编号写错而导致Credit乱飞。

4.2 关键RTL伪代码骨架

下面的伪代码展示一个最简单的发送侧Credit计数器逻辑,重点在于“发送减一”和“收到Credit加一”两条事件在同一拍发生时,计数值要保持不变:

module credit_link_sender #( parameter BUF_DEPTH = 4, parameter CREDIT_W = 3 )( input clk, rst_n, input flit_valid, input credit_return, output flit_ready, output reg [CREDIT_W-1:0] credit_cnt ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin credit_cnt <= BUF_DEPTH; end else begin if (flit_valid && credit_cnt > 0) begin if (credit_return) credit_cnt <= credit_cnt; // 同时发送和返还,净变化为0 else credit_cnt <= credit_cnt - 1; // 只发送,消耗一个Credit end else begin if (credit_return) credit_cnt <= credit_cnt + 1; // 只返还,增加一个Credit else credit_cnt <= credit_cnt; // 无变化 end end end assign flit_ready = (credit_cnt > 0); endmodule

注意这里flit_ready不是接收端实时给出的Ready,而是发送端本地根据Credit计数器做判断。只要计数器大于0,发送端就认为接收端有能力收数据。这是Credit流控和Valid/Ready流控最本质的区别。

接收侧的逻辑也很简单:分配一个slot_available信号,只要还有空位,就拉高Credit返回。有一个小技巧:在接收侧,如果允许每拍最多接收一个flit,Credit返回信号用单bit就够了;如果允许每拍返还多个Credit,就要用多bit的Credit计数去增加。

4.3 把AXI Stream Valid/Ready转成Credit:一个实战桥接点

很多实际IP的接口是AXI-Stream,不是CHI。比如DMA、硬件加速器、视频处理IP,它们和NoC互联时通常需要把AXI Stream转成内部Credit流控。

做法不复杂:在桥接模块里放一个Credit计数器,初始值为深度的总Buffer数。上游AXI-Stream的Valid信号进来时,如果计数器大于0,就接受这一拍数据,同时计数器减一;如果计数等于0,就把Ready拉低,告诉上游“现在不收”。可见这个Ready是本地生成的,并不需要看NoC内部远端状态。

这里有一个特别容易踩的协议坑:AXI Stream的Ready和Valid之间有时序约束,特别是Valid必须在没有Ready时保持稳定,否则算协议违规。当Credit为0时,如果你的Ready拉低和上游Valid在同一个组合环里,就会产生组合路径过长的问题。我个人的做法是让Ready输出用一拍寄存,提前一个周期根据Credit计数器判断,这样Valid到Ready的组合路径很短,并且不会违反AXI Stream规范。

4.4 验证场景与一个关于日志的经验

验证Credit流控,我最喜欢用的方式不是只看波形,而是在Scoreboard里做“Credit不变量检查”。也就是始终验证:

发送端已发flit数 - 接收端已收flit数 = 发送端初始Credit数 - 当前Credit Counter

只要这个等式在仿真任意时刻成立,Credit流控就是无损且不重复的。

另一个非常实用的经验是控制日志打印。在用商用VIP测AXI/CHI桥接时,有时整个NoC链路上事务很多,VIP默认会打印大量transaction信息,终端刷屏到根本找不到真正的问题。比如用Synopsys AXI VIP时,可以通过UVM的报告机制把transaction打印级别关掉,设置到UVM_NONE,只在断言失败或超时时抛ERROR。这样仿真输出立刻安静很多,只剩关键断言和错误信息,定位问题效率高很多。别为了Debug把所有打印都打开,第一件事应该先把全局打印拉到最低,然后再按信号选择性打开。

4.5 仿真中如何制造Credit压力

只测正常流量是测不出Credit问题的。我会在验证计划里安排几类“暴力”场景:

  • 连续发送模式:发送端一直接收来自测试激励的大量flit,每个Cycle都尝试发送,看Credit是否能撑住满带宽。
  • 随机返回模式:通过配置模型,让Credit返回信号不规律地延迟,比如连续几个Cycle不返,再一次性返回多个Credit,验证发送端不会因为突发返回而出错。
  • 多VC同时抢占:同时向不同VC注入流量,检查各VC的Credit池是否互相干扰。
  • 远端Buffer卡死模式:让接收端故意停止消费Buffer,但Credit返回还能持续,验证协议行为和死锁监测机制。

这些场景看起来粗暴,但很多Credit实现里的边界错误都是在随机返回模式下暴露出来的。我印象最深的一个Bug就是Credit位宽不够,在持续满负荷发送时Counter溢出回绕成很大的数,结果发送端误以为自己有海量Credit,疯狂发包后把接收端Buffer冲爆。从那以后我所有Credit Counter的位宽都留出额外裕量,并且强制做饱和处理,而不是简单模N。

5. 踩坑手册:Credit流控常见问题与排查技巧

5.1 Credit耗尽但链路空跑

现象很诡异:发送端明明一直有数据要发,Credit Counter却长期为0,但接收端那边Buffer明明是空的,所有数据都已经被消费掉了。

排查思路:先看接收端是否给出发送了Credit。如果接收端正常接收,但没有返Credit,说明Credit返回生成逻辑有问题;如果接收端返了Credit,但发送端Counter没动,问题就在返回路径或同步逻辑上。

实际中我碰到过一种情况:接收端每收到flit后,必须等到下一拍才返Credit;而发送端又因为Credit为0而停发。在这种“必然产生一拍气泡”的设计下,链路永远跑不满。解决办法就是让Credit在flit写入Buffer的同拍就生成,或者调整发送端提前量,把返回延迟纳入Buffer深度计算。

5.2 Credit泄漏:发也发不出,收也收不回

Credit泄漏的典型表现是:跑一段时间后,发送端Counter比初始值少了一个固定数量,而且无论链路空闲多久也不再恢复。这基本说明有一些Credit返回信号丢了,或者初始化逻辑写错了。

排查方法很直接:在Scoreboard里同时跟踪发送端口、接收端口、Credit返回端口三处信息。发送端每发一个flit,计数器减一;接收端每收到一个flit,计数器对应加一。如果两边数量对不上,就能确定在哪一拍丢了。

我遇到过一次特别隐蔽的问题:接收端按“每次接收flit返还1个Credit”设计,但数据路径上有2个slot的流水寄存器,造成接收端收到flit的时间比发送端预期晚了2拍,而Credit返回也是从接收端发出后晚了2拍才回到发送端。理论上只要初始Credit大于在途数量,这个延迟也没问题。但当时设计者把Credit返回信号和另一个VC的返回信号做了位压缩,低位覆盖低位,结果两个VC同时返回时,其中一个的Credit被“吃掉”了。后来把返回信号改成每VC独立通道,问题彻底消失。

5.3 延迟敏感场景下的吞吐下降

Credit数量满足满带宽,但某个业务路径的延迟还是很高。这里要区分是“链路链路延迟”还是“排队延迟”。Credit流控并不能消除排队,它只是让排队变得有序、可预测。

比如一个单通道上同时跑多个VC,某个VC的Credit池没有独立,结果被另一个突发流量抢占了大部分Buffer,延迟自然变大。解决办法是优先保证低延迟VC的独立Credit池,且这个池不参与共享。此外,Credit返回路径的优先级也很重要。如果Credit返回帧和数据帧共用一条反向链路,哪怕Credit被排到队尾一两个Cycle,累积起来也会让延迟明显增加。因为链路满速时,每个Cycle的排队延迟都会直接叠加到端到端延迟上。

5.4 死锁与Credit的关系

NoC死锁的原因有很多,但Credit相关的资源死锁,核心特征是:一部分Credit被消息A占用,消息A要等消息B释放资源,而消息B又被Credit限制无法进入系统。这在多VC共享资源时最容易发生。

一个典型的协议死锁场景是:REQ类流量占满了和DAT类共享的Credit池,导致DAT类无法发送数据,而REQ的发送端又在等待DAT数据返回,于是双方都在等对方释放Credit。这种死锁用看门狗定时器也不一定能很快发现,因为有时候只是表现为性能归零,不是立刻报错。

我解决这类问题的方法是给每类Virtual Network设定最小Credit保障。哪怕整个共享池Size再小,REQ、RSP、SNP、DAT每个VC都必须至少预留两个Credit额度,确保任意时刻该类消息都能有小流量通行,打破“全阻塞”的闭环。这算是NoC里一个简单又有效的土办法。

5.5 排查时用的几个工具习惯

抓Credit相关问题,波形里直接盯Counter是很低效的。我一般在设计里加一个可读的debug寄存器,把每个VC的Credit当前值、累计发送数、累计返回数都打包寄存。仿真时可以靠断言自动检查,上板时也可以通过寄存器回读判断链路是否健康。

如果某个NoC节点出现异常,先看该节点所有VC的Credit计数值。如果所有VC都等于0,说明节点被完全堵死;如果只有一个VC等于0,那大概率是那一类消息的返回路径出问题。这个方法比我早期一点点跟波形要快很多。

日志打印上也有一点小建议:针对Credit返回信号,只在“预期返回但没返回”的时候打警告,而不是每个Cycle都打。比如接收端Buffer空余数大于0,但没有产生Credit返回,这就一定有问题,直接报ERROR。把这类“不可能”事件变成断言,比事后看日志省力得多。

6. 写在最后:关于Credit的一点个人体会

做了一段时间NoC之后,我越来越觉得,AXI到CHI的演进,本质上不是换了一套更复杂的协议栈,而是把流量控制从一个局部握手问题变成了一个全局资源管理问题。Credit这套机制,让NoC设计者可以在物理上不存在“全局Ready信号线”的前提下,完成无损、有序、可控的数据搬运。Buffer深度、往返延迟、VC隔离、返回路径,这几个点只要有一个没想清楚,最终都会在性能和稳定性上还回来。

我个人在实际操作中的体会是:先算清链路在途流量,再画Buffer和Credit的账;把Credit返回通道当成和主数据通道同等重要的设计对象;在每一个计数器旁边留好调试接口。做这三件事,不敢说NoC一定多优秀,但至少不会让流量控制成为拖垮全片性能的那块短板。最后再分享一个小技巧:所有Credit逻辑,都带一个可回读的统计寄存器。仿真、上板都能看到当前Counter和累计值。这个习惯帮我抓过好几次看起来完全随机、复现不了的Bug,强烈建议你也试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询