做Scale-up互连的兄弟,应该都绕不开一个词:协议。物理层我们能靠SerDes、D2D PHY、先进封装硬扛,但真正决定系统能不能把“多个计算Die”顺畅地拧成一个逻辑单机的,往往是跑在比特线上的协议语义——缓存行什么时候该失效、请求走哪条路径、路由节点到底缓冲几个flit、一个状态机跨了哪些节点保持一致。扛不住协议层的复杂度,再高的带宽都白搭。
这篇文章我打算把Scale-up互连里最硬核的两块解剖开:一块是CHI协议里的“七态”缓存一致性状态机,另一块是互连网络里的PBR路由(Partially Buffered Routing,部分缓冲路由)。同时我会把六个常见的开源互连协议——CHI、TileLink、AXI4、AXI4-Stream、OCP、Wishbone——放在比特和状态机层面做一次横向对比。适合正在做NoC、缓存一致性互连、Chiplet/多Die系统、或者想从“会用总线”进阶到“设计互连”的人。不绕弯子,直接上手拆。
1. 先从 Scale-up 谈起:为什么协议层比物理层更决定生死
1.1 我们到底在对比什么
Scale-up 和 Scale-out 的区别,很多文章讲过,但落到芯片工程师眼里其实一句话:Scale-out 是加节点,Scale-up 是把一个节点的“核、缓存、内存一致性”变成可扩展的域。也就是说,你插进去一个新Die、一个新Chiplet,不能只是让它的核能跑指令,还要让它能正确地观察到同一份内存、同一份缓存数据——这要求整个互连网络必须维护一致性的闭环,任何一环的协议语义对不上,数据就脏了。
所以在做这一类系统时,最忌讳上来就看物理层的带宽和时延。物理层的带宽不够,是“慢一点”的问题;协议层的状态机不对,是“静默算错”的问题。后者远比前者可怕。一个请求在Root Complex和远端Die之间转了一圈,返回的数据到底对应哪个缓存状态,是Unique还是Shared,是Clean还是Dirty,状态编码错一位,结果就是整条链路上的所有核都在用同一块脏数据。
我一般会先问自己三个问题:协议里有多少个缓存状态?这些状态之间的迁移由谁触发?数据包在互连网络里经过每个节点时,节点用什么策略缓冲和转发?这三个问题分别对应的是“状态语义”“状态机转换”“路由与缓冲策略”,也就是标题里说的“CHI七态”和“PBR路由”这两个解剖点。
1.2 六个开源协议怎么选出来的
很多初学者把协议理解成一份文档,但工程师眼里的协议是RTL代码、验证环境、协议分析仪里的一撮信号。我选这六个协议,是因为它们在开源世界里的参考实现最丰富、生态最典型,而且正好覆盖了从“纯数据搬运”到“全缓存一致性”的完整光谱:
- CHI:ARM AMBA体系里的一致性互连协议,目前做Scale-up/多Die一致性互连绕不开的标杆。
- TileLink:RISC-V生态里自带一致性语义的片内互连协议,有TL-UL/TL-UH/TL-C三个等级。
- AXI4:通用内存映射总线,没有一致性状态机,简单直接。
- AXI4-Stream:面向数据流,不关心地址,更不关心缓存。
- OCP:Open Core Protocol,一度是点对点SoC互连的热门选择。
- Wishbone:轻量级、极简,适合小型系统。
一个成熟的Scale-up互连系统一般不会只用一套协议,通常是CHI或TileLink做一致性域、AXI/AXI-Stream做数据面、底层传输再套OCP或自定义包格式。这六套协议放在一起对比,能让你清楚地看到“哪一层缺了状态机”“哪一层缺了路由字段”,也就理解了为什么有的协议在一块芯片里只能当配角。
2. CHI 七态缓存一致性:从原子语义到状态机迁移
2.1 七态不是凭空多出来的
CHI的状态模型比经典的MESI多出一截。MESI是四态:Modified、Exclusive、Shared、Invalid。CHI在一致性缓存状态上定义了七态,而且这七个状态不是文档作者拍脑袋加的,它们对应的是多Die互连场景里“不得不区分”的几种真实情况。按我的习惯,先把这七个状态列出来:
| 状态 | 含义 | 关键点 |
|---|---|---|
| I | Invalid | 缓存行无效,最干净的状态 |
| UC | Unique Clean | 唯一且干净,只有本地持有,未修改 |
| UD | Unique Dirty | 唯一且脏,只有本地持有,已修改 |
| SC | Shared Clean | 共享且干净,多个节点可能持有 |
| SD | Shared Dirty | 共享且脏,多个节点持有但其中一份被改过 |
| UCE | Unique Clean Empty | 唯一、干净、但数据体为空 |
| UDP | Unique Dirty Partial | 唯一、脏、但只有部分字节有效 |
UC和UD好理解,就是“独占且未改”和“独占且改了”。SC和SD对应“多个节点都有”场景下的Clean与Dirty。麻烦的是UCE和UDP。UCE的意思是:这个缓存行在本地具有唯一权限,但是数据本身还没被真正填充——这在CHI的某些原子操作、DMA、或者远端起包场景中很常见。你拿到的不是一个完整的数据体,而是一个“准备好了、可以写回”的状态。UDP则对应部分写:别的节点持有了这个缓存行的一部分,但当前节点持有且修改了其中一部分字节,缓存系统必须知道哪些字节是有效的,否则写回时会把垃圾覆盖过去。
这七个状态反映到比特层面,至少需要3位编码。很多工程实现里会用4位甚至用独热码,原因是硬件里状态比较的扇出压力很大,多花一位能省组合逻辑。如果你在做RTL实现,我建议状态编码别照着文档的语义编号硬来,先确认状态转移条件下哪些状态对比较密集,再选编码方案——这方面独热码在FPGA上通常比二进制跳转码更快,但在ASIC上可能浪费寄存器资源。
2.2 状态迁移的状态机建模:一段式、两段式、三段式怎么选
有了状态,下一步就是迁移。CHI节点收到来自RN(请求节点)、HN(主节点)、SN(从节点)的消息时,缓存状态要按消息类型做条件跳转。比如本地缓存是UD时收到ReadShared请求,如果当前系统不允许共享脏行,就要先做写回,把UD变成UC或SC,再回应读取方。这个“先写回、再共享”的过程,在状态机里就是一条带条件的迁移弧。
用硬件实现这个状态机时,工程师圈里经常吵一段式、两段式、三段式哪个好。我的观点是:互连协议状态机,除非极小的控制块,否则一律别用一段式。一段式把状态跳转和输出逻辑糊在一个always块里,写起来爽,但综合后时序收敛难,后期加一个信号就要牵连一大片逻辑,而且可读性很差。
两段式把状态寄存器和次态逻辑分开,适合状态数少、输出简单的场景。三段式则把状态跳转、次态判断、输出逻辑彻底拆开,每条路径都能单独约束、单独看时序。CHI这种跨节点、多消息类型的状态机,我强烈建议三段式——因为输出逻辑通常会依赖消息类型和储能状态等多个输入,拆开之后,至少你能在综合报告里清晰地看到“哪一段组合逻辑成了关键路径”。
2.3 状态机的 SystemVerilog 落地写法
拿CHI节点状态机做一个最简示例,这是在实际工程里可落地的三段式骨架,不是玩具代码。假设我们只处理I、UC、UD、SC四个主要状态的简化模型:
// 状态编码 typedef enum logic [1:0] { ST_I = 2'b00, ST_UC = 2'b01, ST_UD = 2'b10, ST_SC = 2'b11 } cache_state_t; // 三段式:第一段,状态寄存器 always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) cur_state <= ST_I; else cur_state <= next_state; end // 三段式:第二段,次态组合逻辑 always_comb begin next_state = cur_state; // 默认保持 case (cur_state) ST_I: begin if (req_is_read) next_state = ST_UC; else if (req_is_read_shared) next_state = ST_SC; else if (req_is_write) next_state = ST_UD; end ST_UC: begin if (req_is_snoop_read_shared) next_state = ST_SC; else if (req_is_write) next_state = ST_UD; end ST_UD: begin // 必须先写回,不能直接共享 if (req_is_snoop_read_shared) next_state = ST_SC; else if (req_is_snoop_read) next_state = ST_UC; end ST_SC: begin if (req_is_upgrade) next_state = ST_UC; else if (req_is_write) next_state = ST_UD; end endcase end // 三段式:第三段,输出逻辑 always_comb begin data_permission = 0; cache_op = OP_NOP; case (cur_state) ST_I: cache_op = OP_FILL; ST_UC: data_permission = 1; ST_UD: begin data_permission = 1; cache_op = OP_WRITEBACK; end ST_SC: data_permission = 1; endcase end这只是个简化的骨架。实际CHI节点里,Snoop响应、Dataless响应、CompData里的缓存状态都会打进状态机,七态之间的迁移会用一张大状态迁移表描述。做验证的时候,你可以用SystemVerilog assertion把“UD状态不能在未写回的情况下迁移到SC”这类规则写成断言,跑随机测试时只要断言被击穿,立刻就能抓到协议违例——这比事后翻波形高效得多。
3. PBR 路由:在互连网络中到底在解决什么问题
3.1 头拍缓冲,数据拍穿透
先把名字说清楚:在Scale-up互连协议语境下,PBR是Partially Buffered Routing(部分缓冲路由),不是网络工程师常说的“PBR策略路由”。策略路由是查策略表决定下一跳,部分缓冲路由是决定“一个包经过路由节点时,哪些flit必须缓冲、哪些flit可以免缓冲直接转发”。因为热搜词里混了“pbr策略路由”,很多人一开始会进错方向,所以这里特意指出来。
部分缓冲路由解决的是一个成本问题:Scale-up互连里数据包的典型大小是64B到128B的缓存行,拆成多个flit后才能在高位宽互联线上传输。如果每个路由节点把所有flit都缓冲下来再做转发判决,缓存面积和功耗会随节点数激增——你在NoC里铺上一堆大缓冲,芯片面积和散热直接爆炸。PBR的思路是:只把“带头拍”(header flit)和少量关键元数据flit缓冲到路由节点的队列里,数据体flit则直接穿透,由下一级按流水线式顺序接收。这样既保证路由决策有依据,又不需要每个节点给整个包留缓冲区。
但省面积是有代价的。数据体穿透意味着“你还没决策完,数据已经在路上”。一旦仲裁结果和实际物理路径不匹配,数据就会飞错端口,所以在实现PBR路由节点时,你必须保证仲裁判决在数据体到达前完成——或者用更保守的背压信号把数据体挡在上一级。这个约束直接影响了状态机和流水线级数设计。
3.2 PBR 与 CHI 协同时的比特级结构
一个CHI数据包从RN发出,到HN处理,再返回RN,途中可能经过两到三级路由节点。每一级的PBR逻辑都在做“头拍进场—查路由表—分配虚通道—转发”这几件事。把CHI和PBR放在一起看,你会发现一个关键点:CHI负责“包里装的数据语义”,PBR负责“包怎么在网络里走”。两者的交汇点是包头的路由字段。
一个典型的CHI请求包,头拍里包含以下关键字段:
| 字段 | 位宽(常见参考) | 作用 |
|---|---|---|
| Opcode | 6位左右 | 区分ReadShared、ReadUnique、WriteBack等操作 |
| Target Address | 地址位宽 | 决定路由终点 |
| Node ID | 8~16位 | 请求节点/目标节点标识 |
| Cache State | 3~4位 | 当前缓存状态,请求携带的来源状态 |
| QoS | 2~4位 | 服务质量等级,决定仲裁优先级 |
| Transaction ID | 10~16位 | 区分多个在途事务 |
PBR路由节点拿到头拍后,会提取Target Address和Node ID,查一次路由表,决定从哪个输出端口走。CHI场景里通常还要多查一次“这是REQ类、RSP类还是DAT类”,因为不同协议类型走不同的虚通道。这一步不能省略:如果REQ和DAT混在一个通道里,数据请求可能绕到数据包后面,形成协议层死锁。
真正做RTL的时候,我经常提醒团队把“持拍”和“通过”两个状态分开。持拍是指头拍在路由节点停留,等待资源;通过是指数据体不需要缓冲直接往下一级走。这两个状态对应的信用(credit)管理逻辑完全不同。持拍占用一个条目,通过只占用线的一部分空闲窗口。写状态机时,如果只在头拍状态里做资源申请,数据体穿透状态的逻辑会非常简单;如果你试图“顺便”在数据体状态里再判断一次路由,那就违背了PBR的初衷,时序大概率收不紧。
4. 六个开源协议在比特和状态机层面的横向解剖
4.1 一张表看穿六个协议的核心差异
把这六个协议放在同一张表里,可以从宏观层面快速定位各自的“生态位”。这里的参数不是某个厂商的精确数据手册值,而是工程实现里的常见范围,具体RTL集成时还要看配置。
| 协议 | 一致性支持 | 缓存状态数 | 拓扑 | 路由机制 | 缓冲策略 | 典型使用场景 |
|---|---|---|---|---|---|---|
| CHI | 有 | 七态或可裁剪 | 多层互连/NoC | 基于Node ID、地址的路由 | 全缓冲或PBR | 多Die、一致性域扩展、高性能Scale-up |
| TileLink | TL-C有,TL-UL无 | TL-C约五态(类似MESI+) | 点对点/星型/分层 | 固定主从链路,无显式路由 | 多数全缓冲 | RISC-V SoC、一致性SoC集成 |
| AXI4 | 无 | 无 | 主从双向通道 | 地址译码器选择从机 | 通道FIFO | 内存映射外设、DMA |
| AXI4-Stream | 无 | 无 | 点对点流 | 无地址,纯流向 | 流FIFO | 数据流处理、通信IP |
| OCP | 无 | 无 | 点对点为主 | 配置映射 | 简单握手缓冲 | 定制SoC、IP核互联 |
| Wishbone | 无 | 无 | 共享总线/交叉开关 | 地址译码选择 | 简单寄存器缓冲 | 教学、轻量级MCU系统 |
这六个协议里,真正有身份感的其实是CHI和TileLink:一个面向大系统的全一致性互连,一个面向RISC-V核的片内一致性互连。AXI、AXI-Stream、OCP、Wishbone都没有缓存一致性状态机,它们更多扮演“搬数据”的角色。做Scale-up系统时,常见组合是用CHI或TileLink搭一致性骨架,用AXI接DDR/DMA控制器,用AXI-Stream接通信IP。
4.2 关键差异背后的设计取舍
先看状态机复杂度。CHI的七态意味着硬件里至少几十条状态迁移弧,每一条都要处理消息类型、缓存状态、Snoop结果三类输入的组合。TileLink的TL-C则做了个简化:它把一致性操作划分成A、B、C三个通道,状态大约五类(Invalid、Clean、Dirty三档再细分),基本是MESI的变种。对大多数单Die多核SoC来说,TL-C这个复杂度是合适的;但如果你想在互连网络上做多协议桥接——把TileLink的一致性请求转成CHI的SNP请求——你就会发现状态对齐非常痛苦,因为TL-C里“Dirty”不像CHI的UD/SD那样区分得那么细,桥接时不可避免要做状态映射的近似处理。
再看路由机制。AXI4、AXI-Stream、OCP、Wishbone本质上都不携带路由字段。AXI是靠地址译码器把一笔事务送到对应的从机,Wishbone是类似的总线选择逻辑,OCP走点对点互连,压根没有“经过中间节点”的概念。所以在这些协议里讨论PBR是没有意义的——没有路由表可查,也不存在头拍和数据拍之分。CHI和TileLink才是真正能承载“Scale-up互联拓扑”的协议,因为它们天然支持多节点、多跳、带地址路由的一致性事务。
缓存策略的差异也很关键。全缓冲状态机写起来最省心:每个包在整个节点上都被完整缓存,仲裁逻辑只需要在队列非空时做调度,不会出现“数据体已经穿过去了但仲裁结果还没出来”的时序险象。但代价是每个节点的面积和功耗上去了。PBR的实现复杂度比全缓冲高,但它能在路由节点上省下大量SRAM,面积收益在几十个节点的NoC上非常可观——这也是为什么我会把它和CHI放在一起讲,因为CHI场景里节点多、跳数多、一致性流量重,最适合发挥PBR的价值。
4.3 不同场景的选型建议
如果只做单Die内的多核SoC,TileLink是个性价比很高的选择,状态机简单,RISC-V生态配套成熟。如果要做多Die、多Chiplet甚至机柜级Scale-up,那就得用CHI,或者至少是带一致性扩展的NIU(Network Interface Unit)帮助你桥接。如果你只是在做外设接入,AXI4仍然是兼容性之王;做流式数据搬运,AXI4-Stream最轻快;做教学或超轻量控制器,Wishbone简单得让人感动,但它真不适合跟一致性系统搭伙。
我见过不少团队做了一个“用CHI做NoC、但底层数据通路还是AXI风格”的混合设计,最后在协议转换上浪费了几个月。协议选型不是选最好看的,而是选“状态机匹配你系统规模”的。用量化方式说:节点数少于4个,TileLink或AXI协同足够;节点数上到8个、16个,CHI的分层拓扑和PBR/虚通道优势才会真正体现出来。
5. 实战环节:如何用状态机视角验证一个 Scale-up 互连
5.1 从状态机跑飞排查谈起
我调试互连状态机时,最常遇到的故障不是逻辑算错,而是“状态机卡死”。卡死的原因绝大多数不是协议文档写错,而是你漏了一条迁移弧:某个状态下收到某种消息,你忘了定义它的次态行为,于是默认保持原状态,结果后端队列一直等不到释放信号,整条链路的信用(credit)耗尽,事务就悬死了。
排查这个问题的第一工具不是波形,是断言。我在RTL里给每个协议节点都会写一组基础断言,比如“任何状态收到消息后,必须在N拍内产生回复或推进状态”,跑随机验证时只要断言超时,就能反推是哪个迁移弧漏了。第二工具是覆盖率统计。CHI七态之间满打满算有几十条迁移弧,如果覆盖率报告里某条弧永远没踩到,你要么没构造对应场景,要么就是状态机设计里有死路径。
还有一个非常容易被忽略的点:复位后的初始状态。CHI节点上电时,所有缓存状态必须是I,不能有任何“X态”或“UC态”。如果RTL里用了没有复位值的内存作为状态存储,综合后状态机上电可能是乱码,首个事务就会打到错误的状态上。我在验证环境里会专门跑一条“上电后立即发起ReadUnique”的用例,专查复位残留问题。
5.2 一致性广播风暴与 PBR 缓冲的联动问题
Scale-up互连里真正容易炸的,是广播类一致性请求。一个远端节点发起ReadShared,Home Agent需要向所有持有该缓存行的副本发Snoop请求。假如系统里有16个节点同时在做清理和共享操作,广播风暴能把路由节点和缓冲队列瞬间打满。这时候PBR的“只缓冲头拍”策略虽然省面积,但如果队列深度配置过小,头拍一样会排队,数据体的穿透通道被上游阻塞后,整个系统的有效带宽反而比全缓冲还差。
这类问题的最佳解法是“动静分离”:把一致性控制报文(REQ、SNP、RSP)和纯数据报文(DAT)拆到不同的虚通道里,并且给前者配置固定的小缓冲、后者配置PBR穿透路径。这样即使广播风暴来的时候,控制报文也有专用的存储出去的路,不会和数据报文互相抢占缓冲。这个设计模式算不上什么黑科技,但我在好几个项目里都看到因为偷懒把两类报文混在一个FIFO里,事后被死锁问题反复折磨。
排查缓冲联动问题时,我一般先看“信用计数”和“队列水位”。信用计数降为0但队列没满,说明有丢信用的逻辑Bug;队列满但信用计数正常,那大概率是上游把量打满了,需要查调度仲裁的公平性。这两个信号一起看,能快速定位是协议层还是微架构层的问题。
5.3 几个值得收藏的排查技巧
最后分享几个我调试Scale-up互连时比较管用的土办法:
第一个技巧是“单包追踪”。在验证环境里打只允许一个事务从发起端走完整条链路,所有节点的队列水位只应该有这个事务的包。此时抓波形看头拍和数据拍经过每个节点的时间戳,能准确算出每一跳的固定时延和调度时延。如果发现某一跳的时延远超预期,该节点大概率在等待某个资源——接下来查它的状态机条件即可。
第二个技巧是“制造乱序”。很多互连问题平时不出现,是因为事务顺序碰巧线性。你可以随机给不同事务配置不同优先级,打乱数据体到达顺序,再看目标节点的状态机是否还能正确响应。CHI场景里,乱序最容易暴露的是缓存状态回写竞态:一个ReadUnique还没处理完,另一个Snoop又插进来,如果状态机没有做中间态(intermediate state)缓存,最后一定丢数据。
第三个技巧是用形式化验证跑“无死锁证明”。对状态机规模不大的互连块,用形式化工具穷举所有状态组合,证明“不存在终态卡死且队列非空”的状态。这个方法对CHI节点这种中等规模状态机非常有效,对多节点整体互连则可能状态爆炸,所以建议对单节点做形式化,对全网做动态仿真。
第四个技巧是留意“超时计数器”的粒度。Scale-up互连里每个事务都有一个全局超时时间,从请求发出到最终接收数据。如果超时口径不同,可能引发误报:一个请求真的被阻塞时,有的节点超时触发重试,而另外的节点还在等待响应,最终造成状态机双活。做超时设计时,统一口径比调大数值重要得多。
6. 写在最后的一点实操体会
我在多个项目里被CHI的“七态”坑过,也被PBR的“数据体穿透”坑过,回头想想,坑都不在协议本身,而在“协议语义到状态机实现”的映射精度上。协议文档画的状态图只是纸上谈兵,真正决定成败的是你把哪条迁移弧写漏了、把哪类控制报文混进了数据队列、把哪个超时计数器配置错了粒度。所以我一直觉得:做Scale-up互连,最重要的能力不是会读协议文档,而是能把文档翻译成一群可验证的状态机、一组可量化的缓冲策略,再用断言和覆盖率把它们拴住。
如果你正打算在系统里引入CHI,或者准备把TileLink的一致性域扩展到多Die,建议先从小规模、低跳数的拓扑练手,把状态机的迁移覆盖率和PBR的缓冲水位调明白,再往大规模扩张。这种系统没法靠“试出来的稳定性”硬扛,只能靠一层层比特级、状态机级的较真堆出来。