DeepDataFlow
RISC-V SoC 实战 · 13 集

RISC-V SoC 实战:从模块到芯片

把一个已经签核的 RISC-V 核心,变成一颗真正的 sky130 芯片:双端口 RAM macro、带安全 bypass 的 PLL、58 个真实 pad,以及作为硬核 macro 的核心。综合、布局布线与签核,全部在 G2 里用开源 EDA 完成。

发布日期 2026-10-21 · 13 集 · DeepDataFlow
RISC-V SoC 实战:从模块到芯片

模块不是芯片。在上一个系列 RISC-V 实战:从 RTL 到签核 里,我们把一个 RISC-V 核心——lowRISC 的 Ibex——当作单一模块,从 RTL 做到签核。这个系列,我们围绕这个核心做出一颗芯片:加上存储器和外设、一个 PLL、真正的 IO pad,核心本身则作为硬核 macro 放进去。然后在 G2 里完成整颗芯片的综合、布局布线与签核。

这篇文章是 13 集视频的文字版。每一节包含该集视频、背后的决定,以及跑出来的数据。所有文件都在 G2_demo_sky130 项目里。

设计lowRISC Ibex Demo System:总线、RAM、GPIO、UART、SPI、PWM、定时器、JTAG 调试
工艺SkyWater 130 nm(sky130 HD 标准单元)
工具Yosys、OpenROAD、OpenRCX、OpenSTA,全部在 G2 里运行
芯片1.8 × 1.8 mm die、58 个 pad、6 个 macro(核心、4 个 RAM、PLL),加入 filler 后共 115,854 个实例
结果hold 满足;慢速 corner 的 setup 差 1.445 ns,即 24.1 MHz,目标是 25 MHz

第一部分:芯片的组成

#1从模块到 SoC

2:07

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

这颗 SoC 是 lowRISC 的 Ibex Demo System:总线、RAM、GPIO、UART、SPI、PWM、定时器和 JTAG 调试模块,围绕一个 Ibex 核心。它原本是为 FPGA 写的。其中有两样会改:u_top 是核心,u_ram 是 128 KiB 的双端口存储器。

在 G2 里,SoC 是一个新模块 ibex_demo_system。名字用下划线,因为 G2 把模块名当作综合和布局布线的顶层模块名,而 Verilog 的名字不能有减号。一次点击就建好 spec 页面,以及这个模块在实现、脚本和设计数据管理器(DDM)里的目录。层级上,SoC 是第 0 层,系列一做好的核心是它里面的第 1 层。

第一个 spec 栏位是速度:25 MHz。核心在慢速 corner 签核到 25.56 MHz,SoC 不可能比它的核心更快。标准单元栏位会打开模块的 stdcell.cfg,流程读的就是这个文件,综合时会再用到这一点。

#2双端口 RAM macro

2:23

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

SoC 的 RAM 是 128 KiB、双端口:一个端口给数据,一个给取指。在 FPGA 上那是 block RAM;在芯片上它是一个存储器 macro,而我们的库里只有核心指令缓存用的单端口 RAM。

我们做了两个决定。第一,16 KiB 对这个 demo 的软件已经足够,128 KiB 在 sky130 上会非常大。第二,生成器的双端口 RAM 没有字节写掩码,所以 32 位存储器由四个 8 位宽的 macro 组成,一个字节通道一个。

FakeRAM2.0 为一个并不存在的存储器生成时序和版图视图,非常适合做流程。同样的 32 Kbit 可以有完全不同的形状:

同样 32 Kbit 在四种 column mux 与 bank 设定下的形状。mux 8、2 个 bank 得到 209 × 159 µm。
同样 32 Kbit 在四种 column mux 与 bank 设定下的形状。mux 8、2 个 bank 得到 209 × 159 µm。

一定要看生成的模型。这个模型先寄存地址再读阵列,数据晚两个周期才出来。liberty 和我们的 RAM wrapper 都说是一个周期,所以换成一个周期的模型。liberty 只有一个典型 corner,项目有四个 corner,所以我们写四份,表头放对应的电压和温度;每份的时序都一样,和系列一的 RAM 做法相同。

在 G2 的库管理器(LIM)里,操作顺序很重要:先 Find、再 Build all、最后 Fill。Fill 靠建好的页面把每个 liberty 对应到 corner,先跑 Fill 就什么都对不上。结果是两个端口,各有 12 位地址、8 位数据,时钟到数据 0.218 ns。

#3让 SoC 配合硬核

2:04

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

SoC 的 RTL 以 rtl v01 放进 DDM,从 demo 仓库复制,原仓库保持不动。问题来了:我们在系列一硬化的核心是 CHERIoT 版本的 Ibex,端口和这颗 SoC 对不上。它多了 SoC 完全不认识的 CHERIoT 和 lockstep 端口,而 SoC 还传了做好的 macro 无法接受的参数。

我们保留 macro,改 SoC:

  • 关掉 CHERIoT,让核心跑成普通的 32 位 RISC-V。
  • 多出来的输入接常数,多出来的输出悬空。
  • 拿掉参数,SoC 得到的就是我们硬化时的配置。
  • RAM 缩小到 16 KiB,每一处修改都加了标记,方便以后找。

RAM wrapper 把 32 位存储器对应到四个字节宽的 macro,每个字节的写使能就是它的掩码位。综合之前,先做一次快速的 elaboration 检查,核心和 RAM 都当黑盒。少了一个选项,slang 就会停下来:调试模块在声明之前就用了一个信号。仿真器放过它,slang 不放。加上选项后链接成功:一个核心、四个 RAM,端口名称和宽度都和真正的 macro 吻合。

#4真实芯片的时钟

2:19

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

在 FPGA 板上,时钟从一个引脚进来。真正的芯片自己产生时钟:一个 10 MHz 的参考时钟、一个把它乘十的 PLL、一个降到 25 MHz 给核心用的除频器,还有一个 bypass,PLL 不工作时芯片照样能跑。

时钟架构:10 MHz 参考时钟 → PLL ×10 → ÷4 → 25 MHz,加上无毛刺 mux 与 bypass 路径。
时钟架构:10 MHz 参考时钟 → PLL ×10 → ÷4 → 25 MHz,加上无毛刺 mux 与 bypass 路径。

sky130 没有我们能用的 PLL,所以像假 RAM 一样做一个假 PLL:有看起来真实的版图和时序视图,里面没有电路。其中一个选择是刻意的:参考时钟到输出没有时序弧。PLL 的输出是一个新的时钟,约束会说明这一点。

时钟产生器 clkgen 是 rtl v02 里需要小心的部分。一个两位计数器对 PLL 时钟除频,最高位就是 25 MHz 时钟。每个时钟有自己的时钟门控,每个门控的使能在自己的时钟域里同步,而且要等另一个关掉才会打开。复位之后先跑参考时钟,让 PLL 锁定。最后由复位同步器让复位干净地释放。

时钟变慢,SPI host 就出问题:25 MHz 除以 12.5 MHz 的 SPI 时钟,toggle 计数只剩一,计数器变成零位。我们保持原来的比例,系统时钟的四分之一。还有三个要说清楚的地方:bypass 时 UART 的波特率不对;bypass 引脚只能在复位时改变;这个 PLL 只是替身。

#5真实的 pad

2:41

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

芯片通过 pad 和电路板沟通。skywater-pdk 里 sky130 的 IO 库有版图和 Verilog,却没有时序,没有时序就不能综合,也不能签核。open_pdks 会建出完整的一套,包括每一颗 Caravel 芯片都在用的 Efabless 包装 pad,每个 corner 都有时序。LibreLane 的 pad 设定告诉我们,一个验证过的 sky130 pad ring 用哪些单元:通用 IO pad、带 ESD clamp 的核心电源与 IO 电源 pad、一个 corner 单元,以及填充 slice。

一个小安装脚本把五个单元拷进 G2 的 PDK,每个单元都有我们四个 corner 的 liberty。在 LIM 里每个单元一个条目,因为一个条目每个 corner 只能放一个 liberty。二十个 liberty 文件全部对上。(这里发现 LIM 的两个小 bug:Fill 会跳过符号链接,以及页面标题错误。)

rtl v03 的芯片顶层保留 SoC 的端口名称,现在都是 bond pad:50 个信号 pad、8 个电源 pad。pad wrapper 的高压使能来自 pad 自己的 tie-high 和 tie-low 输出,和 Caravel 的做法一样。顺便处理一件事:三个 128 字节、用 flip-flop 做的 FIFO 几乎占了整颗芯片三分之二的 flip-flop,每个都缩成 16 个 entry。

58 个 pad 围绕 SoC 核心。每个 pad 约 80 µm,每边至少需要 1.6 mm:这颗芯片是 pad 受限的。
58 个 pad 围绕 SoC 核心。每个 pad 约 80 µm,每边至少需要 1.6 mm:这颗芯片是 pad 受限的。

第二部分:把核心变成硬核 macro

#6让模块成为合格的 macro

2:50

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

硬核模块有一条规则:顶层金属归上一层。 macro 的 abstract 视图会挡住模块用到的每一层金属。系列一的核心把 metal 5 用在电源网格和绕线上,所以整个 macro 在 metal 5 上都被封住了。芯片在 metal 5 上的电源 strap 到它边缘就停了,我们练习时,芯片层级的电源检查就报告核心的电源引脚没有连接。核心会一直没有电。

解决办法是一个惯例:模块只用到 metal 4,芯片直接从 metal 5 在模块的 metal 4 电源引脚上打 via。我们把核心重跑成 build v03,系列一的 build 保持不动,脚本有三处修改:

  1. 信号和时钟的绕线都只到 metal 4。
  2. 每个 stage 都读这些设定,不只是 floorplan。
  3. 电源网格去掉 metal 5 的 strap。floorplan 脚本现在也会找设计自己的电源脚本,以及芯片会用到的 pad ring 脚本。

绕线花了十四分钟,结果是 120 个违规,比之前少一点,还有我们要的那一行:metal 5 上的线长是 0 µm。post-route 和 finish 之后是 130 个违规,这是少一层金属的代价,对 demo 来说没问题。

#7从模块到硬核 macro

2:49

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

上一层设计需要硬核模块的三种视图:寄生参数、每个 corner 的时序模型,以及 abstract 版图。

  • 寄生参数:OpenRCX 在一分钟内完成新版图四个 corner 的提取。
  • 时序模型:OpenSTA 的 write_timing_model 把整个核心写成一个单元:输入上的 setup 和 hold 检查、包含它自己时钟树的时钟到输出延迟,还有时钟树延迟本身。原本的脚本让每个 corner 的模型同名,corner 之间会冲突,所以我们用 corner 来命名。要注意:模型里的面积是单元面积,不是 macro 的占地面积。
  • abstract LEF:绕线器当作电源的 tie net 要改回信号,不然会变成空的电源引脚。abstract 挡住模块用到的每一层,电源引脚只保留 metal 4。原始的 abstract 列出几千个内部电源形状,却没有把它们连起来的 via,到了上一层看起来就是浮空的。

结果是一个 850 × 850 µm 的 macro,930 个引脚,电源在 metal 4,metal 5 上什么都没有。各视图也一致:LEF、网表和时序模型都是 928 个信号位。核心以硬核 IP 放进 LIM。两个 corner 对上了,就是我们签核过的那两个;另外两个没有时序模型,不会用到。

第三部分:约束与综合

#8为真实芯片写约束

2:14

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

综合约束 sdc_syn v01 由一个数字驱动:系统时钟 40 ns。总共有四个时钟。参考时钟在它的引脚上。PLL 输出是定义在 PLL 输出引脚上的新时钟,这就是假 PLL 没有时序弧的原因。系统时钟从它生成,除以四:

create_clock -name clk_ref -period $ref_period [get_ports clk_ref_i]
create_clock -name clk_pll -period $pll_period [get_pins u_core/u_clkgen/u_pll/clk_o]
create_generated_clock -name clk_sys -divide_by 4 \
  -source [get_pins u_core/u_clkgen/u_pll/clk_o] \
  [get_pins -of_objects [get_nets u_core/u_clkgen/clk_div4] -filter "direction == output"]
create_clock -name clk_tck -period $tck_period [get_ports tck_i]

综合会改 flop 的名字,从 RTL 复制来的 flop 名称在网表里不会存在。net 会保留名字,所以我们找驱动除频时钟 net 的那个引脚。JTAG 是自己的时钟;三组时钟彼此异步,因为 mux 的控制信号经过同步器。

功能模式下 PLL 是开的,而只对 bypass 引脚做 case analysis 是不够的。bypass 时钟门控的使能来自一个 flop,不是常数,所以参考时钟还是会漏到每个系统 flop 上:4,599 个引脚有两个时钟。关掉 bypass 门控的时钟弧之后,每个 flop 就只有一个时钟:

set_case_analysis 0 [get_ports pll_bypass_i]
set_disable_timing -from CLK -to GCLK [get_cells u_core/u_clkgen/u_icg_ref/icg]

现在每个端口都是 bond pad,是 inout,所以时序分析把每个 pad 同时看成输入和输出。pad 不用的方向给零延迟并排除,就不会意外留下没有约束的路径。输出 pad 驱动 5 pF 的板级负载。

#9综合整颗芯片

2:19

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

芯片有一个新的实现 build:rtl v03 加上第 8 集的约束。G2 从 spec 收集库,每个 corner 9 个:标准单元、RAM、核心的时序模型、PLL 和五个 pad 单元。它们来自 spec 的库文件,也就是编辑器写的那些,所以前面要用编辑器。原本的 Yosys 脚本在 sky130 上用错误的名字统计 flop;系列一已经修好了,我们沿用它的脚本,包括时序驱动的那个。

运行约 40 秒:没有错误、没有未映射的单元,11,692 个实例、2,126 个触发器,每个 macro 和 pad 都以黑盒出现在网表里。接着让约束对上网表。有 22 个没有约束的端点,每一个都在预期之中:输入 pad 不用的那一侧,以及电源 pad。每个时钟都到达它该到的 flop。

布局前的时序要如实解读。系统时钟的 setup 是 −10.4 ns、复位 recovery 是 −21.4 ns,因为复位同步器完全没有 buffer 就驱动了 1,316 个 flop,slew 是 40.9 ns。综合不会帮这条 net 加 buffer,placement 会。把这一条 net 排除,所有时钟都满足时序。

第四部分:芯片的布局布线

#10pad ring 与芯片电源

2:35

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

布局布线的约束和综合的一样,只改一处:板级信号用 transition 驱动输入 pad,不用标准单元,因为没有标准单元能驱动 pad。die 是 1.8 mm 见方,core 是 1.3 mm。58 个 pad 每边至少需要 1.6 mm,所以决定尺寸的是 pad,不是逻辑。

pad ring 脚本在 die 四周放 IO site,然后依序、等距放每一边的 pad,再加上 corner、填充 slice、abutment,以及每个端口的 bond pad 端子。有一个顺序很关键:先命名 pad 电源,再做 abutment。否则 abutment 会自己产生 ring net,电源 pad 永远接不到核心电源,之后也没有任何步骤能修正。

# 先连接 pad 电源(add_global_connection ...),然后:
global_connect
connect_by_abutment
place_io_terminals {*/PAD */VCCD_PAD */VSSD_PAD */VDDIO_PAD */VSSIO_PAD}

电源网格加上 pad 电源、接到电源 pad 的 core ring,以及延伸到 ring 的 stripe。少了 -extend_to_core_ring,stripe 会停在 ring 之前,整个核心网格都是浮空的。这两个问题都是我们练习时吃过亏才发现的。

add_pdn_stripe -grid {grid} -layer {met5} -width {1.600} -pitch {27.200} -offset {13.600} -extend_to_core_ring
add_pdn_ring -grid {grid} -layers {met4 met5} -widths {10.0 10.0} -spacings {2.0 2.0} \
  -core_offsets {10.0 10.0} -connect_to_pads
芯片 floorplan:58 个 pad 加上 corner 与 filler、core ring、右上方的 Ibex 核心,以及底部排成一行的四个 RAM。
芯片 floorplan:58 个 pad 加上 corner 与 filler、core ring、右上方的 Ibex 核心,以及底部排成一行的四个 RAM。

这就是第 6 集的回报:32 条 metal 5 电源 strap 跨过核心,900 个 via 打到它的 metal 4 电源引脚上。

#11Placement、CTS 与 post-CTS

2:05

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

placement 之后电源检查通过:电源上的每个形状都连上了。练习时,直到网格接到 ring、核心空出 metal 5 之前,这个检查一直失败,所以前面要先做那些修正。

CTS 建了十棵时钟树。系统时钟从无毛刺 mux 出来,到达九个 macro 时钟引脚和 1,892 个 flop;JTAG 有自己的时钟树。

接着是层级设计的部分。核心的时序模型说,它自己的时钟树在 macro 里面又加了五纳秒以上:到引脚 3.8 ns,里面再加 5.6 ns。核心里的 flop 比 RAM 晚很多才看到时钟,结果在核心的输入端出现 −3.3 ns 的 hold 违规。post-CTS 修复插入 317 个 hold buffer,修好所有 hold 违规,包括核心的。

剩下一个 setup 违规,在除频器。除频器自己的输出现在是一条时钟树 net,它经过这棵树以 100 MHz 反馈回自己的计数器。真正的设计会从另一个 flop 反馈;我们记下来,继续往前。

#12绕线与完成

0:54

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

整颗芯片绕线大约十分钟,结果只有一个违规,线长 1.07 m、106,115 个 via。单独的核心有 120 个:芯片的 core 有空间,而且 macro 上方的 metal 5 对芯片是开放的。post-route 清理和 finish(加入 filler,写出最终网表和版图)之后剩 2 个违规,没有短路。finish 打印的时序还是理想时钟,真正的数字要到签核才有。

绕线拥塞图:挤在核心、RAM 与 pad 之间的 SoC 逻辑。第二轮 floorplan 就要从这里开始。
绕线拥塞图:挤在核心、RAM 与 pad 之间的 SoC 逻辑。第二轮 floorplan 就要从这里开始。

第五部分:签核

#13芯片签核

2:56

在 YouTube 观看(英文) ↗在 Bilibili 观看(中文) ↗

签核从真实连线开始:OpenRCX 用 18 秒提取四个 corner。签核约束使用传播时钟,让真实的时钟树算进去,uncertainty 也更小,因为 skew 已经不用猜了。

有一节只有带真实 pad 的芯片才需要。pad 的时序取决于它的驱动模式。我们的 RTL 把那些引脚接成常数,但经过的是没有驱动的 net,时序分析看不到它们的值,就假设最慢的模式。没有 pad 模式时,输出 pad 要 120 ns,芯片差了 102 ns;加上之后只要 6.9 ns,还有 12.2 ns 的余量。

接着用 OpenSTA 分析整颗芯片:核心是它的时序模型,每个 pad、RAM 和 PLL 都来自 LIM。合并后的结果:

合并后的签核结果:慢速 corner 有 44 个端点 setup −1.445 ns;快速 corner 的 hold 满足。
合并后的签核结果:慢速 corner 有 44 个端点 setup −1.445 ns;快速 corner 的 hold 满足。

最差路径正是这个系列的一课。时钟在 7.8 ns 到达核心。核心的时序模型说,它的数据地址在 27.9 ns 之后才出来:它自己的时钟树加上它的逻辑。这样 SoC 只剩大约 12 ns 给总线和调试存储器,而它需要 13.5 ns。用模块搭芯片时,每个模块的输出时序都是芯片必须接受的预算。芯片跑在约 24.1 MHz,而不是 25 MHz。

我们做了什么,还剩下什么

从系列一的一个核心,到一颗芯片:双端口 RAM、带安全 bypass 的 PLL、58 个真实 pad、为芯片留出空间的硬核 macro、综合、布局布线与签核。全部在 G2 里完成,每一次交接都经过设计数据管理器和库管理器。

遗留问题,以及从哪里着手:

  • 核心边界 setup −1.45 ns。这是核心 clock-to-output 的预算;下一步是更快的核心,或在总线边界加寄存器。
  • 输出 pad 的 slew。pad 的 transition 表不区分模式,这是库建模的限制。
  • PLL 支路上偏小的时钟门控(max capacitance)。
  • 100 MHz 时钟域里除频器的反馈。
  • 两个 DRC 违规,而且这个系列不做 DRC、LVS 或 GDS 签核。

观看与动手