多核芯片里最容易被低估的部分,其实是“互联”。三年前我刚开始看 FlooNoC 这个开源项目的时候,满脑子都是“核多了直接拿总线连不就完事了”,直到自己动手做了一块四核 RISC-V 的 FPGA 原型,才被 Cache 一致性和带宽瓶颈狠狠教育了一顿。当时我查到 FlooNoC这个开源多核互联网络项目时,第一反应是“竟然有人把 NoC 做成了可配置的 IP 生成器,还开源了”。FlooNoC 就是这样一个项目:它不是一块现成的芯片,而是一套用 Chisel 语言编写、能为你生成自定义片上网络 RTL 代码的开源工具链,目标场景覆盖多核处理器、嵌入式 SoC、FPGA 原型验证,适合想深入了解 NoC 架构、在做多核互联设计、或者正在为“核多了怎么连”发愁的硬件工程师和体系结构方向的学生。
这篇文章我会从“为什么多核会卡在互联上”开始,把 FlooNoC 的核心架构、参数配置、生成流程、FPGA 集成方法,以及我实际踩过的坑都过一遍。
1. 为什么多核芯片不能靠总线一路连到底
1.1 总线的十字路口效应
单核时代,CPU、内存、外设之间用总线连接完全够用,因为同一时刻只有 CPU 在发起主要访问。进入多核之后,总线的问题立刻暴露:四个核同时去读内存,总线只有一条路,数据必须排队;核数量翻倍,总线的仲裁逻辑复杂度跟着翻倍,时序收敛越来越难。你在 FPGA 上做过大规模总线设计的话,应该体会过那种“加一个主设备,全局时序就崩一次”的酸爽。
总线方案的本质问题是全局共享:带宽上限固定,所有主设备抢同一份资源。这就像城市只有一个十字路口,车多了必然堵,而且无论你把这个路口修多宽,它仍然只有一个路口。
1.2 NoC 的解法:从十字路口到城市路网
片上网络(Network-on-Chip,NoC)的思路是把“单条共享总线”换成“多节点的交换网络”。每个处理器核通过一个网络接口(Network Interface,NI)接入网络,数据被拆成包,经过中间的路由节点(Router)一级一级转发,最终到达目标节点。城市里不可能只修一条大马路,而是遍地小路口、环路和主干道,让车辆分散通行——NoC 的本质就是这个。
具体到数据通路上,一个典型的 NoC 包含三类核心组件:终端节点(处理器核、缓存、DMA)、网络接口(负责协议包和流控单元的转换)、路由节点(负责转发和仲裁)。拓扑结构决定了节点之间怎么连接,常见的有二维网格(Mesh)、环状(Ring)、树状(Tree)、交叉开关(Crossbar)。其中 Mesh 因为布线规整、扩展性好,是研究和工业界最常用的拓扑,FlooNoC 的默认配置就是 4x4 Mesh,一共 16 个终端节点。
1.3 FlooNoC 到底是个什么东西
FlooNoC 最早来自多伦多大学的一个开源项目,2014 年在 GitHub 上发布,2015 年还拿了 MESC(微处理器嵌入式系统竞赛)的最佳演示奖。它用 Chisel 语言编写,Chisel 是构建硬件生成器的高级语言,所以 FlooNoC 本质上是一套“NoC 生成器”:你通过配置参数指定拓扑、端口数、缓冲深度、仲裁方式,它替你生成对应的 Verilog RTL。
它的设计目标很清晰:第一,让 NoC 像软件库一样可以被调用和定制,而不是每次从零手写;第二,生成代码可综合、可仿真,能直接进入 FPGA 或 ASIC 流程;第三,内置性能监测逻辑,帮助你量化分析不同拓扑、不同参数下的延迟和吞吐。对做多核研究的人来说,这个项目最大的价值在于提供了一个可复现、可修改的 NoC 基线,你不需要把时间浪费在写路由器上,而可以把精力投入在更高层的系统问题,比如 Cache 一致性协议和 NoC 的协同设计。
2. FlooNoC 的整体架构与关键设计思路
2.1 三大核心子系统
FlooNoC 一个很有辨识度的地方,是它把网络划分成了三套并行子系统:数据通路、控制和监测、以及协议消息传递。
数据通路负责搬运真正要读写的请求和数据包。每个终端节点通过一个 NI 接入网络,NI 拆包、打包,并通过流控机制与路由器对接。路由器内部实现了交叉开关和仲裁逻辑,FlooNoC 用的是基于向量优先级的仲裁策略,也就是每个输出端口配一个仲裁器,根据输入请求的优先级决定谁先走。
控制和监测子系统是 FlooNoC 比较有特色的部分。它实现了一个 VCaller 机制,可以用来广播全局命令、收集全局状态,我理解它有点像一个“内部控制网络”,专门跑那些不适合走数据通路的短消息和同步操作。再加上 PCN(Protocol and Performance Counter Network),一个轻量级的独立网络,专门用来传输 Cache 一致性协议消息和性能计数器数据。为什么要单独拉一个网络?因为一致性协议消息往往是很短的控制包,如果和数据请求混在一起,很容易被长数据包阻塞,延迟抖动大;单独建一条“快车道”,让控制类短消息畅通无阻,能显著降低一致性协议的平均延迟。
数据缓存单元 DCU 夹在处理器核心和网络之间,起到缓冲和速率匹配的作用。FlooNoC 的 DCU 设计了读缓冲和写缓冲两套路径,写缓冲(GlobalBuffer)负责缓存来自核心的写请求,即使网络拥堵核心也不用停下来等待;读缓冲则配合 VCaller 的响应路径,缓存从网络返回的读数据。这两个缓冲深度是 NoC 调参时最关键的旋钮之一,之后实操部分我会展开讲。
2.2 为什么偏偏用 Chisel 写硬件生成器
如果你习惯用 Verilog 写 RTL,第一次看 Chisel 可能会觉得“这不就是 Scala 套了一层壳?”但 FlooNoC 选择 Chisel 不是偶然。NoC 本身是一个高度参数化的组件,端口数、拓扑维度、缓冲深度、仲裁机制、流控粒度……任何一项改动,手写 Verilog 都意味着大面积的代码替换。Chisel 允许你用软件工程的方式描述硬件生成逻辑:循环、条件、类继承、函数式变换,全部可以在生成时执行。
用 Chisel 写 NoC 生成器的组合拳效果是,你可以写一个 Scala 对象,遍历不同的拓扑参数和缓冲配置,批量生成几十个不同的 NoC RTL,然后跑仿真做设计空间探索。这在传统 Verilog 流程里是难以想象的。FlooNoC 的生成器内部大量使用了 Chisel 的参数化模块,顶层的拓扑结构和各节点的模块例化都是在 Scala 层动态决定的,而不是写死的 Verilog 例化语句。
对我们使用方来说,这意味着不需要把生成的 Verilog 当“源码”去改——正确的姿势是改 Chisel 层的配置参数,重新生成。这个思维方式转变,是我觉得 FlooNoC 最值钱的部分之一。
2.3 关键参数与设计权衡
FlooNoC 的配置参数很多,我只挑影响最大、实际调参时最容易踩坑的几个列一下,后面实操部分还会给出完整示例。
| 参数名 | 作用 | 默认值 | 影响 |
|---|---|---|---|
| NoCTreeArbiter | 路由器仲裁器的树形仲裁深度,约等于同一端口竞争的输入数量上限 | 8 | 太小会导致路由器输入端口阻塞,太大则增加组合逻辑延迟 |
| EjectionBufferSize | NI 出口缓冲深度,单位是 flit | 2 | 影响吞吐率,缓冲区过小会限流 |
| KeepArbiter | 仲裁器保持同一请求者连续发送的优先级次数 | 2 | 影响公平性和连续传输效率 |
| GlobalBufferSize | 写缓冲深度 | 8 | 影响核心写操作的吸收能力 |
| VCallerBufferSize | 读缓冲/虚通道缓冲深度 | 4 | 影响读响应链路 |
| UseAsyncReset | 是否使用异步复位 | true | FPGA 上一般保持 true,可改善时序 |
这些参数之间是相互牵制的。举个例子,你把路由器的仲裁树深度 NoCTreeArbiter 调大,网络能同时容纳更多竞争的输入请求,但代价是仲裁逻辑的时序变差,主频可能往下掉;你把 GlobalBufferSize 调大,核心写操作不容易被阻塞,但 DCU 面积和功耗会上升。所以调参的目标不是把某个参数拉满,而是在“吞吐、延迟、面积、主频”之间找到工程可接受的平衡点。这一点和做 CPU 微架构设计时的思路是一模一样的。
3. 从零搭建:环境准备与代码生成实操
3.1 环境依赖与项目克隆
FlooNoC 是用 Chisel 2.x 写的,那时的 Chisel 还紧密绑定 sbt 和 Scala 2.11 生态。我试过在 Ubuntu 20.04 和 22.04 上重建环境,最稳的组合是 JDK 8 配合 sbt 0.13.x,太新的 JDK 版本有时候会在 sbt 启动阶段就报错。如果你用的是更新的系统,建议直接装一个 OpenJDK 8,然后单独用 sbt 0.13 的 launcher,避免和系统里其他 Scala 项目的版本冲突。
整个搭建过程不复杂,核心是四步。第一步,安装 JDK 8 和 sbt 0.13;第二步,克隆 FlooNoC 仓库到本地;第三步,进入项目根目录,确认 build.sbt 里的 Scala/Chisel 版本号;第四步,运行 sbt 进入交互环境。如果你之前没跑过 Chisel 2.x 项目,不要指望新版 sbt 能无缝兼容,我直接把当时跑通的版本组合写在下面:
$ java -version openjdk version "1.8.0_392" $ sbt --version sbt 0.13.18 $ git clone https://github.com/CTSRD-CHERI/FlooNoC.git $ cd FlooNoC $ sbt第一次 sbt 启动需要从 Maven 仓库拉一堆依赖,耗时取决于网络环境,可能需要 10 到 20 分钟。这里有个很实际的建议:如果你身处境内网络环境,sbt 拉依赖经常超时,可以把仓库源换成阿里云 Maven 镜像,具体做法是在~/.sbt/repositories里添加镜像地址。这是当时解决“sbt 卡死”最有效的操作,没有之一。
3.2 配置参数与拓扑生成
FlooNoC 的生成过程不是直接make,而是通过 sbt 运行 Scala 主类。我当时跑通的配置命令大致是:
$ sbt "run-main noc.synthesis.Adapter -td output"其中-td指定生成的 Verilog 输出目录。需要说明的是,FlooNoC 项目迭代过程中命令入口可能有变化,最准确的入口名称以当前仓库 README 或src/main/scala目录下的main对象为准。我第一次跑的时候就没有先看源码,直接照抄网上旧教程的命令,结果编译器告诉我找不到主类,才老老实实回去翻目录结构。建议你拿到项目后第一步就做这件事:
$ find src/main -name "*.scala" | xargs grep -l "object.*App\|def main" | head -20找到主类入口后,真正有意思的部分是配置参数的指定。FlooNoC 支持通过命令行参数覆盖默认配置,也可以直接在 Scala 顶层配置对象里改。我当时为了生成一个 4x4 的 Mesh、并加深两个主要缓冲,用的大致命令是:
$ sbt "run-main noc.synthesis.Adapter -td output --mesh 4 4 --global-buffer-size 16 --vcaller-buffer-size 8"注意,这里我还特意把 GlobalBufferSize 和 VCallerBufferSize 从默认的 8 和 4 提到了 16 和 8,因为我计划在系统里跑一个带宽要求比较高的并行测试,希望核心侧能吸收更多未完成的写请求,不至于让核心因为网络反压而频繁停顿。如果你只是做功能验证,默认参数就够用。
生成过程会输出一堆 Chisel 内部的编译信息,看到 "Generated verilog" 字样后,去output目录下查文件:
$ ls output Mesh_4x4.v NoCConfig.v ...这里的Mesh_4x4.v就是最终可综合的 NoC 顶层模块。你可能会惊讶这个文件怎么这么大——那是因为 Chisel 把整个拓扑例化后的所有子模块都展平到了一个文件里。好处是集成方便,坏处是如果你想单独看某个路由器的实现,得在这个大文件里搜索模块名。
3.3 解析生成的 RTL 代码
拿到生成的 Verilog 之后,别急着直接丢进 Vivado,先花十分钟理解它的端口结构。FlooNoC 生成的模块顶层一定会暴露两类端口:一类是逻辑上的终端节点接口,也就是 NI 的输入输出端口,另一类是全局控制信号,比如时钟、复位、以及一些测试相关的信号。
终端节点的端口协议是写好的,每个节点有一套固定的握手信号:请求方向(核心发往网络)有io_valid、io_bits、io_ready,响应方向(网络返回核心)有相应的反馈通路。理解这套握手协议非常重要,因为你的处理器核如果要接进 NoC,就是通过控制这些信号的时序来完成数据交换的。
我最初犯的错误是默认这套接口由某个标准总线协议(比如 AXI)封装好了,结果一翻代码发现它是自定义的握手协议,只好自己写了一个 AXI 转 FlooNoC 的桥接模块。如果你的处理器核已经有 AXI 接口(比如 Rocket Chip、好多 RISC-V 软核都是),那么你需要做的核心工作就是实现这个桥接,把 AXI 的读地址通道、读数据通道、写地址通道、写数据通道映射到 FlooNoC 的端口上。这个映射本身不复杂,但值得花心思把握手时序对清楚,尤其是在处理 AXI 的 outstanding 写事务时,要确保 FlooNoC 的 VCallerBuffer 不会溢出。
4. 拿生成的 NoC 搭建一个最小多核系统
4.1 硬件集成框架
生成 NoC 之后,最自然的验证方式是搭一个最小多核 SoC 原型。我当时用的是 Digilent 的 Artix-7 FPGA 开发板,处理器核选了开源的精简 RISC-V 软核,没有用自带缓存的复杂核,而是先用了最简单的无缓存核,便于观察 NoC 本身的性能特征。
系统框架是这样的:四个 RISC-V 软核,分别接到 FlooNoC 的 0、1、2、3 号终端节点;4 号节点接了一块片上 SRAM 当作共享内存;5 号节点接了一个 UART 控制器,用来打印调试信息。每个处理器核与节点之间走我前面说的“AXI 转 FlooNoC 桥接”。共享内存区域对于所有核都是统一编址的,处理器核发出读请求 → 桥接模块把请求打包成 NoC 包 → 经过 Mesh 路由到 4 号节点的 SRAM 控制器 → SRAM 返回数据 → 按原路径回到发起核,完整的往返路径就通了。
这个最小系统跑通之后,我在每个核上跑了一个简单的计数器程序:每个核向共享内存的某个地址连续累加,然后通过 UART 打印结果。第一次实验结果有一个非常明显的问题——四个核的最终计数值完全对不上。这当然不是 NoC 的锅,而是多核系统里最经典的数据一致性问题:每个核都有自己私有的写缓冲,写请求还在路上、缓冲没有排空之前,另一个核读到的就是旧值。
4.2 数据一致性与 Cache 的关系
这个实验让我对“多核数据一致性”的认知从书上的概念变成实际需要解决的问题。在 NoC 系统里做一致性,软件层最粗暴的做法是“每次读写都走 volatile/原子操作”,但代价是性能惨不忍睹,因为每一次原子操作都要等 NoC 往返。硬件上更优雅的路线是引入 Cache,并配套一致性协议。
FlooloNoC 的 PCN 网络设计其实是冲着这个来的:它天然适合承载监听协议(Snooping)的监听消息和目录协议(Directory)的目录查询/回复消息。因为 PCN 是独立于数据网络的轻量通道,一致性协议的控制消息不会和数据包抢带宽。比如典型的一致性请求,处理器发出读缺失 → Cache 控制器通过 PCN 广播监听消息 → 其他核的 Cache 检查自己的状态并响应 → 数据本身仍然走数据网络传输。这样控制平面和数据平面分离,是在 NoC 上构建一致性系统比较优雅的做法。
如果你想把 FlooNoC 用在一个带 Cache 的多核系统里,我个人建议第一步不要直接跑 MESI 这种复杂协议,而是先实现一个最原始的一致性机制:所有共享内存的读写都强制穿过 NoC 到达共享 SRAM,处理器核私有的写缓冲仅用于吸收写请求,但通过软件 fence 保证顺序。跑通之后,再引入 PCN 网络做监听式的失效(Invalidate)消息传递,一步步把协议复杂度加上去。一次到位往往意味着同时面对 NoC 调试和 Cache 一致性调试两座大山,问题定位会非常痛苦。
5. 常见问题与排查技巧实录
5.1 环境与工具链的坑
sbt 拉依赖失败是最常见的问题。Chisel 2.x 依赖的老版本库很多已经不更新了,sbt 默认从 Maven Central 拉取,如果你所在网络的连接不稳定,经常出现 “unresolved dependency” 错误。解决办法不是反复重试,而是干脆换源。配置好~/.sbt/repositories之后,基本一次通过。
JDK 版本不兼容是另一个高频问题。如果你系统默认是 JDK 17,跑 FlooNoC 大概率会在 sbt 启动阶段报 “Unsupported class file major version”。Chisel 2 时代生成的字节码是针对老 JVM 的,新 JDK 不一定认。直接装一个 JDK 8,然后确认JAVA_HOME指向它,比在 build.sbt 里改各种 flag 靠谱得多。
sbt 交互式 shell 里输入命令卡住,多半是 sbt 在后台下载依赖或者解析项目定义。不要以为是死机了,第一次运行耐心等十分二十分钟很正常。如果实在等太久,可以看~/.sbt/boot目录的增长情况来判断是否在下载。
5.2 拓扑与参数调整技巧
改完拓扑后生成的 Verilog 没有任何变化。这条要多说一句:FlooNoC 的配置参数有优先级,命令行参数会覆盖默认配置,但如果你改了 Scala 源码里的默认值,最好 clean 掉 sbt 的增量编译缓存再重跑,否则 sbt 可能用了旧的 class 文件。我当时就因为这个浪费了半天时间,明明改了参数,生成的拓扑一点没变,气得够呛。
Mesh 拓扑非对称导致的拥塞。Mesh 的边角节点天然比中心节点少两条链路,如果共享内存挂在 4 号节点而多个核都在对角线方向,热点就很容易出现。实践中有两种解决思路:一是把高频访问的设备(比如共享内存控制器)放在拓扑的几何中心附近,减少平均跳数;二是通过增加中心节点的缓冲深度来吸收突发流量。FlooNoC 允许你为每个节点单独配置参数吗?严格说顶层是统一参数的,但生成的 RTL 是开放的,你完全可以手工修改特定节点的例化参数,保存为定制版本。
5.3 性能优化实用心得
先测带宽,再调延迟。很多人拿到 NoC 第一件事就是看延迟,但延迟测出来往往不好看,因为包交换网络在没有负载时的延迟主要由路由跳数和路由器流水线决定。真正影响多核系统性能的是高负载下的吞吐。建议先跑一个“多核同时写共享内存”的压测,观察吞吐是否随着核数线性扩展。如果扩展性差,问题大概率出在共享内存节点的仲裁器上,而不是网络本身。
网络反压会逆传播。NoC 的数据流控机制是逐跳反压的,核心发出的写请求如果因为目的端缓冲满而停滞,反压信号会一级一级向回传,最终导致发起核的写缓冲填满、核心停摆。这在系统层面表现为“某个核突然跑得很慢”,在没有调试手段的情况下很容易误判为核本身的问题。我当时就是靠抓 NoC 内部信号,看到 VCallerBuffer 满置位,才定位到共享内存端带宽不够的问题。
调试时加一个全局计数器。FlooNoC 的 PCN 带了性能计数器能力,但我建议在调试阶段额外加一个简单的全局时钟周期计数器和每条链路上的 flit 计数器。很多时序问题上板之后,用逻辑分析仪抓波形非常低效,不如直接在 RTL 里埋计数器,内核跑完一段测试后把计数器值读出来,对比不同配置下的数据,问题往往一眼就能看出来。
生成的 Verilog 不要用手工改。这条可能有点反直觉,但我的经验是:如果你发现生成的 Verilog 里路由器逻辑有问题,不要直接在.v文件里改——测试的时候你会疯掉的,因为重新生成一次就会把你的手工修改全部覆盖。正确姿势永远是回到 Chisel 源码里找到对应模块,修改后重新生成。Chisel 代码的审查难度刚开始大于 Verilog,但只要熬过前两周的语法适应期,后面改参数、加功能、批量生成的速度是纯 Verilog 没法比的。
最后说点实在的
回头来看,这个项目给我最大的收获不是“多了一个 NoC 可以抄”,而是让我理解了硬件生成器(Hardware Generator)这套思维方式的真正意义。FlooNoC 本身的可配置性在当时那个年代是相当超前的,它把 NoC 从“一张画死的电路图”变成了“一堆可以组合的生成函数”,你输入参数,它吐出一个可以进综合工具的网络。这种思路在今天的开源硬件圈已经很常见了,但在当时能把这个理念落到代码里、跑出可用的 RTL,是不容易的。如果你想深入研究,我建议从它的路由器仲裁器代码开始读——那段代码看似不长,却浓缩了 NoC 设计中关于公平性、吞吐和时序三者的全部博弈。读懂了它,再看其他商业 NoC 的资料,会轻松很多。