我记得三年前第一次在Rocket Chip上把两个RISC-V核通过TileLink接在一起时,根本没有意识到Scale-up互连协议会占掉后面大半年的调试时间。当时我以为只要总线带宽够、延迟够低,缓存一致性自然就能跑通,结果一次共享数据的原子操作让整个SoC挂死。从那以后,我养成了一个习惯:拿到任何互连协议,先不看它宣传的吞吐和延时,而是先把状态机图画出来,把每个事务的比特字段拆开,看它在极端边界下怎么走。这篇文章就是基于这个习惯做的六个开源协议/开源实现层面的对比,覆盖CHI七态、TileLink、AXI4-ACE、OCP、Wishbone还有NoC上常见的PBR路由机制,适合正在做多核SoC集成、缓存一致性IP选型、NoC调优或者干脆被状态机折磨到秃头的朋友。
1. 从一块“绕路的脏数据”说起:Scale-up互连为什么必须死磕协议层
很多做系统软件的朋友会把Scale-up和Scale-out搞混。Scale-out是加节点、加机器,靠网络把一堆独立系统串起来;Scale-up是指在同一个一致域里加核、加加速器、加内存控制器,让所有处理器看到同一份内存视图。后者对互连协议的依赖远高于前者,因为一旦同一份缓存行被多个核访问,协议必须回答三个问题:谁拥有这行数据?这份数据是干净还是脏的?当某个核想写的时候,怎么让其他人手上的副本全部失效或更新?这三个问题最终都落在状态机上。
所以Scale-up互连协议不是在物理链路上传数据这么简单,它本质上是分布式状态机协议。总线上的每个节点,无论是CPU核、LLC、内存控制器还是PCIe桥,都会为每个缓存行维护一个状态。事务之间互相耦合,比如你在总线上看到一个ReadUnique请求,它背后必须伴随着其他节点收到Snoop/Probe、返回响应、数据回家、完成确认这一整个闭环。任何一个环节的状态机写错了,轻则性能回退,重则死锁活锁。
这也是为什么我对比协议时不做那种“口号式评测”。六个开源协议实现,CHI、TileLink、AXI4-ACE、OCP、Wishbone,再加上NoC层常见的PBR策略路由,放到同一个框架里比,比的是以下四件事:
- 缓存一致性状态模型:状态集合有多大,每个状态代表什么权限。
- 状态迁移的触发事务:读、写、探测、回写、唯一化各自怎么走。
- 协议头的比特开销:每个事务需要多少控制字段,扩展性如何。
- 状态机实现成本:在RTL里用Verilog写出来,是能用一段式草草糊弄,还是必须上三段式加断言。
这个框架比单纯跑benchmark更接近工程本质。带宽不够可以加线、加通道,但状态机设计错是要返工的。下面的每一节,我都会沿这条线拆一个协议或一类机制。
2. CHI七态不是魔法,是一张带“空/脏”编码的状态机大图
ARM的AMBA CHI协议是这六个里一致性最强、也是状态机最复杂的一个。CHI的全称是Coherent Hub Interface,原本是为多核服务器、移动SoC里那些动辄十几个核加一堆一致性代理设计的。它在协议栈里把节点分成Request Node(RN,发起请求的核/加速器)、Home Node(HN,管理某段地址的节点,通常是LLC切片或内存控制器)、Subordinate Node(SN,最终内存或外设)。一个缓存行的状态并不只存在于RN侧,HN侧还要维护一个“监听过滤/目录”状态,两侧状态机互相配合。
2.1 七态到底怎么来的
很多文章直接说CHI七态,但没说这七个状态为什么长这样。CHI的缓存状态本质上还是从MOESI那套语义长出来的:M是Modified,O是Owned,E是Exclusive,S是Shared,I是Invalid。MOESI的问题在于没有表达“独占但数据无效/空”的情况,也没有表达“部分字节脏”的情况。CHI在MOESI基础上做了两件事:一是把Clean和Dirty进一步拆分,二是引入了Empty和Partial偏移状态。在我看过的几个开源CHI实现里,典型的七态集合可以归纳为:
| 状态 | 缩写 | 含义 |
|---|---|---|
| Invalid | I | 本节点没有有效数据副本 |
| Shared Clean | SC | 数据干净,且其他节点也可能有副本 |
| Unique Clean | UC | 本节点独占,数据干净,其他节点无副本 |
| Unique Clean Empty | UCE | 本节点独占地址,但数据内容无效,可被整块覆盖 |
| Unique Dirty | UD | 本节点独占,数据脏,最终要把数据写回内存 |
| Shared Dirty | SD | 数据脏但可以共享,本节点负责最终写回 |
| Unique Dirty Partial | UDP | 部分字节有效且脏,本节点独占 |
不同实现会对状态集做裁剪,有的把SD和UDP合到别的状态里,因此并不是所有文档都写七个。但七态的价值在于它把两个经常被忽略的场景显式化了:一个是“地址已经被我占住,但数据还没来/已经被作废”,典型场景是GPU要同一段地址反复做原子写;另一个是“数据部分脏”,典型场景是DMA只更新了缓存行中间的32字节。如果你不显式表示这两种情况,协议就得用额外的“无效事务”去覆盖,总线效率会难看很多。
2.2 一条状态转换事务的全过程
用一个例子说明七态怎么工作。假设RN-A对地址X发起一次ReadShared,HN收到请求后查目录,发现RN-B手里有Unique Dirty副本。这时候HN不会直接回答“内存里有数据”,因为内存里的数据是旧的,真正的新数据在RN-B手里。于是HN向RN-B发起Snoop,RN-B收到Snoop后把自己从UD状态变成SD或者SC,把脏数据写回到HN,再通过Data通道返回给HN。HN把数据转发给RN-A,同时更新自己的目录状态,RN-A从I变成SC。整个过程跨了REQ、SNP、RSP、DAT四条通道,任何一个通道上的状态迁移错位,数据就会在总线上“绕死”。
如果RN-A想写地址X,它必须先做“唯一化”。RN-A不知道别人有没有副本,所以发ReadUnique请求。HN收到后向所有可能持有副本的RN发Snoop,让它们把状态变回I或失效。RN-A收到Grant和数据后,直接从I跳到UD或UDP,之后就可以本地多次写而不必再发总线事务。这就是状态机的核心作用:它在数据路径上拍板,告诉每个节点“你能不能写,什么时候能写”。
2.3 为什么必须用“三段式状态机”来实现CHI节点
用Verilog写CHI节点状态机时,我一贯坚持三段式。第一段是时序逻辑,纯寄存器决定当前状态;第二段是组合逻辑做次态转移判断;第三段是输出逻辑,把当前状态翻译成对外的控制信号。CHI的状态转移条件有很多是跨通道的,比如要等REQ通道收到请求、同时SNP通道没有pending的snoop、还要满足credit计数非零。这些条件如果用一段式状态机一股脑写在一个always块里,代码会膨胀到根本没法review,状态转移和输出信号耦合在一起,出了问题都不知道是状态跳错了还是输出时序错了。
三段式对验证也友好。我在波形里看当前状态寄存器,再把组合逻辑里判断的转移条件拖出来,能够很快定位是“状态该跳没跳”还是“状态跳了但输出没跟上”。CHI这种协议,状态机代码写的规范程度直接决定了IP能不能集成进大系统。
3. TileLink TL-C在同一条总线上完成获取/释放/探测,状态机更瘦但边界要摸清
TileLink是Rocket Chip/Chipyard生态里最常见的SoC互连协议,最初由SiFive设计,和RISC-V社区绑定得很深。TL-C是TileLink的缓存一致性扩展,虽然很多使用默认配置的项目只用了TL-UL(uncached)或TL-UH(mostly cached但不涉及复杂监听),但只要涉及多核共享缓存,TL-C就绕不开。
3.1 TL-C的通道设计和状态机强相关
TL-C总共有A、B、C、D、E五条通道,五条通道的名字就暗示了它的状态机思路:
- A通道用于发起Primary操作,比如AcquireBlock、Get。
- B通道用于Manager主动发出的Probe,也就是监听/探测。
- C通道用于释放数据或者响应Probe,常见ReleaseData、ReleaseAck。
- D通道用于返回数据或应答,包括GrantData、AccessAck。
- E通道只有一个作用,客户端向Manager确认Grant,叫GrantAck。
这里和CHI的显著区别是,TL-C用“获取-释放”语义代替了CHI那种更细的“读共享/读唯一/回写/清理”语义。客户端要一块数据就发AcquireBlock,要释放数据或者收到Probe后要交出数据就发Release。状态机的核心状态大致收敛在Invalid、Clean、Dirty三类语义上,再带上一些过渡状态。相比CHI,TL-C的状态集合更小,实现起来更轻,因此Rocket Chip里到处都是它的身影。这种更瘦的状态机在多核小系统里是优点,因为验证棋盘小、逻辑简单。
3.2 一个容易踩的状态机边界:Release时数据归属
TL-C一个容易让人迷糊的地方是Probe和Release之间的握手。Manager发出B通道Probe以后,客户端必须在C通道上回Release,Release里要带上数据有没有被改写过的状态。如果客户端手上是Clean,它可以发ReleaseData不带Dirty标志;如果手上是Dirty,就必须带Dirty标志,Manager拿到Dirty标志后要把数据更新到内存,否则数据就等于丢了。
这个边界在真实RTL里会变成一个很刁钻的bug。我见过一个开源RISC-V核实现,客户端在Probe进来时正好处于一个“数据刚从A通道的响应里写进来、状态还没有刷成Clean”的时间窗口。它回了一个Clean的Release,但数据其实已经被改过了一部分。结果就是内存里的数据被旧版本覆盖,另一个核后读到的是一块被回滚的数据。幸好当时是FPGA验证,抓波形抓到凌晨三点。后来我们在TL-C状态机的每个转移边上都加了一条断言:Release中携带的Dirty标志必须和当前数据缓冲区的实际改写标记一致。
3.3 在比特层面看TL-C的开销
TL-C的通道字段包括Opcode、Param、Size、Source、Address、Data、Mask这些基本组成。和CHI类似,它也要在事务里带Source ID,因为一个请求从发出到最终收到响应之间可能有多个事务在飞行。Source ID的位宽直接决定了系统里能同时支持多少未完成事务。很多设计图省事把Source ID位宽设小,结果多核压力测试一跑,master因为拿不到新ID而stall,总线利用率掉一半。这种比特层的参数调整,比状态机本身更容易在实际项目里决定成败。
4. AXI4-ACE与OCP:一致性不是总线专利,扩展通道各有各的取舍
有些团队并不打算完全切换到CHI或TileLink,他们手上已经有大把AXI或OCP的IP核,想给旧总线加上缓存一致性能力。这时候AXI4-ACE和OCP的一致性扩展就成了实用选项。这两个方案不是从零设计的状态机协议,而是在现有总线上打补丁,所以它们的取舍很有意思。
4.1 ACE是给AXI4戴上的“一致性帽子”
AXI4-ACE的基本思想是保留AXI4的五个通道(AW、W、B、AR、R),再加上两个和缓存一致性相关的辅助通道。ACE把一致性相关操作分成“带一致性的访问”和“不带一致性的访问”,通过AR/AW通道上的信号位来区分。当某个master想要共享读或者唯一写时,它会在AR通道上发出带共享域属性的请求;总线上的一致性互连单元会解析这个请求,并通过辅助通道向持有副本的master发起监听。
ACE里的缓存状态机同样可以回溯到MESI/MOESI语义,但工程上很多开源实现通过一个“监听状态”寄存器来跟踪每个master的副本归属。因为ACE本身是建立在AXI的点对点流控上的,它需要确保监听请求和普通读写请求不会在同一个master里乱序完成。这个要求让ACE实现里加入了大量屏障(barrier)逻辑,比如一个master先读后写同一地址,ACE必须保证先读到旧值再写入新值,看不到旧值就绝不能执行写。
4.2 OCP:把“可裁剪信号”做到极致
OCP(Open Core Protocol)是OCP-IP组织推出的开放核接口标准。它和AXI最大的不同在于信号列表不是固定的,而是按配置项生成。你不需要突发传输,就可以砍掉Burst相关信号;你需要一致性,就打开一致性扩展。这套思路对IP复用很友好,因为挂接IP核时可以精确控制接口面积。
但OCP的灵活性也会反噬。一致性扩展打开后,每个master和interconnect之间要协商一堆配置项,比如“是否支持Atomic”“是否支持Exclusive”“监听是同步还是异步”。配置项组合爆炸,验证时如果每个配置组合都测一遍,测试矩阵根本写不完。我后来对OCP项目的建议是:锁死一套固定配置,不要允许每个IP分别选不同的一致性选项,否则互连矩阵里的组合状态机会超出人脑能维护的范围。
4.3 用表格快速对比一致性扩展能力
| 维度 | CHI | TileLink TL-C | AXI4-ACE | OCP扩展 |
|---|---|---|---|---|
| 状态模型 | 七态/可裁剪 | 三态+过渡 | MOESI类 | 多数可配置 |
| 独立监听通道 | SNP | B | AC/CD | 可配置 |
| 死锁避免 | 协议内置 | 靠排序规则 | 屏障方式 | 配置相关 |
| 开源实现数量 | 中等 | 多 | 多 | 少 |
| 适用于Scale-up大规模 | 高 | 中 | 中低 | 中低 |
从这张表可以看出,CHI在设计目标上就是冲着大规模可扩展去的,TL-C在中小规模多核里最舒服,ACE只是为了兼容AXI生态的一致性补丁,OCP则更像是一个框架而不是一种固定协议。选型时如果目标核数超过八个且要求强一致模型,我不会首选ACE和OCP。
5. Wishbone当不了主角,PBR路由才是让NoC“长脑子”的地方
5.1 Wishbone:极简状态机,适合做外围通路
Wishbone是开源SoC里资格最老的总线标准之一,它没有缓存一致性扩展,也没有复杂事务重排序机制。Wishbone的经典状态机非常简单,Master发起周期,等Slave返回ACK或ERR,超时就报错。它非常适合做寄存器配置、启动ROM、调试接口这类低压力通路。
但在Scale-up互连的语境里,Wishbone只能当配角。如果把它当主角放进多核缓存一致域,设计者必须自己用Wishbone拼出一套“软件一致性”机制,比如给每个核加一层软件维护的目录表,用中断让核主动刷cache。这种方案协议层面看不到状态机复杂度,复杂度全被推给了软件,延迟和不确定性完全不可控。所以我通常的建议是:能用TileLink或CHI做主数据面,就别用Wishbone硬扛;Wishbone留给控制面足够体面了。
5.2 PBR路由不是转发,是让NoC节点做决策
很多人看到PBR第一反应是网络设备里的策略路由(Policy-Based Routing)或者CG渲染里的PBR材质。确实容易混,但在SoC NoC语境中,我讲的PBR是一类基于策略的互连路由机制:交换节点不只看目标地址的低位完成“一刀切”路由,还会读事务头里的QoS属性、缓存一致性状态、事务类型、虚拟通道ID,再根据配置好的策略表决定这个包该去哪个物理端口、走哪条虚拟通道、需不需要插入等待状态。开源NoC框架如OpenSoC Fabric、Chipyard自带的一些NoC互连里都能用类似思路做路由扩展。
为什么PBR对Scale-up尤为重要?因为互连网络里的流量不是一个齐刷刷的集合。一致性探测(Probe)可能要求固定延迟,如果被普通读写流量堵在队列里,可能导致请求超时;原子操作要求顺序性,如果被分配到不同路径上,可能把两个原子操作的相对顺序打乱。PBR策略表存在的意义,就是把这些“协议的软需求”翻译成“路由的硬决策”。
我在一个四路RISC-V芯粒互连项目里用过类似PBR的机制。当时有两类流量:一类是核之间的一致性探测,一类是PCIe传入的大块DMA。普通哈希路由经常让PCIe大包和一致性探测混到同一个虚拟通道里,导致探测延迟抖动。后来我们加了一条策略:凡是带Coherence属性的包默认映射到VC0,并在每个交换节点独占一个队列;大块DMA映射到VC1,容量大但延迟不敏感。这么一改,探测延迟的最大值从800周期降到了140周期左右,解决了一个线上同步性能问题。
5.3 PBR和协议状态机的边界怎么切
PBR本身也有一张状态机,但它和缓存一致性状态机职责不同。缓存一致性状态机管理的是“一份数据被谁持有,状态是I/C/D”,PBR状态机管理的是“一个事务当前在互连网络中哪个队列、下一步朝哪个方向走”。前者是目录/缓存状态,后者是流控/路由状态。
这两个状态机必须在接口处对齐,否则就会出现“事务已经完成,但路由节点还在等策略表授权”这种互相等待的死锁。我的做法是在协议节点和NoC节点之间留一个统一的credit接口,策略表授权必须在credit授权之前完成,不允许路由节点在策略未决的状态下继续接受新包。这样虽然会牺牲一点队列利用率,但换来了可证明的“无环等待”。
| 对比项 | 缓存一致性状态机 | PBR路由状态机 |
|---|---|---|
| 管理对象 | 缓存行/数据块 | 数据包/事务 |
| 状态代表 | 数据在哪个节点的什么状态 | 包位于哪个队列/走向哪个端口 |
| 核心操作 | Read/Write/Probe/Release | 查表、分配VC、仲裁 |
| 典型状态数 | 3~7 | 4~16 |
| 死锁风险 | 等待数据/响应导致 | 队列头阻塞/策略表未授权 |
6. 虐过RTL之后,说说状态机编码与验证的一点经验
6.1 三段式状态机在这些协议里的实际写法
不管用哪个协议,在RTL里落地时都可以套三段式状态机。区别在于是否把“状态转移”和“输出”彻底分开。CHI节点往往有多个状态变量,比如请求状态、监听状态、数据缓冲状态;有人会把它们合并成一个大状态,用one-hot编码,但defense-in-depth的做法是拆成多个小状态机,每个状态机用三段式实现,再用一个顶层状态机协调它们之间的握手。
我写这类模块时有一个固定套路:当前状态用两个寄存器数组表示,一个叫cs,一个叫ns。cs在时钟上升沿更新;ns由组合逻辑根据cs和输入信号算出;输出信号再根据cs算一次。每个可能进入的死锁状态都写异常分支,一旦ns变成不应出现的编码,直接拉error输出并进入stall。这套做法不需要UVM也能在FPGA上跑出比较明确的问题。
6.2 状态机覆盖率绝不能只看行覆盖率
缓存一致性协议的状态机bug很多出现在“状态组合”上,而不是单跳路径上。比如CHI中RN同时收到一个Data包和一个Snoop包,它到底是先处理数据还是先处理Snoop?这个顺序如果没有在状态机里显式建模,验证时就算行覆盖率100%也测不出来。所以我在验证阶段最依赖的是交叉覆盖率和序列覆盖率,要专门构造“读写和监听同时到达”“多个master同时对同一地址发起唯一化”这类恐怖用例。很多开源核的bug就是在这些用例下暴露的。
还有一点,状态编码本身要留出非法状态断言。如果你用3bit编码7个状态,还剩下1个非法编码。很多人不管这1个编码,结果综合后逻辑翻转出了非法状态,系统并不立刻挂,而是等这个非法状态被某个路径再次触发才爆炸。我现在的习惯是每个状态机模块都加一段assert property 禁止任何时刻ns等于非法编码。
6.3 选型时先画状态图再跑benchmark
如果让我给团队选型,我会先让架构师把以下图画出来:一跃状态机的节点角色图、二典型读共享/写唯一/监听失效的时序图、三跨节点死锁场景的示例图。三张图画完,协议间的差异会非常直观。CHI画出来复杂但全面;TileLink画出来清爽但对复杂拓扑的支撑力有限;AXI-ACE画出来会看到一堆屏障;OCP画出来你会发现很多空分支要填;Wishbone画出来基本就是三四个状态转圈。
我始终觉得,Scale-up互连协议的对比不应该停留在“谁带宽高、谁延迟低”这种营销语言上。对于那些跑不到边界情况的benchmark,CHI和TileLink的差距可能微乎其微,但一旦多核压力测试或一致性协议风暴上来,状态机设计是否严谨会立刻决定项目是平稳交付还是进入漫长的调试期。以我个人的经验,看协议先看状态机,写代码先画状态图,调试先抓非法状态,永远是这三件事优先级最高。