Step-by-step tutorial · structdevice · 数字 P&R

分步教程:自定义器件——从范例几何拟合到 PCell 再到 P&R/LVS

对应公开 gallery 里的 fit-device demo。前四篇教程画的都是“一个器件”;这一篇回答下一个问题:你自己的器件怎么接入数字 place & route 流?整条链是:几个不同尺寸的范例几何(exemplar)→ 拟合出参数化边模型 → 注册成真 PCell → 按器件级网表摆放 173 个实例 → FlexDR 详细布线 → live LVS match=True。这也是 process purity 的活教材:器件形状、层号、线宽、间距全部写在这一个 example 文件里,klink 机制层零工艺常数——换成你自己的器件,就是换这份文件里的数字。

前置条件

  • starterklink init <proj> 脚手架里就有(example_template/digital/fit_device_pnr_lvs.py);需要一个运行中的 KLayout 会话(它做 live P&R+LVS)。
  • KLayout 正在运行,并已加载 klink 插件(把 --port 指向你的 live 会话)。
  • 完整流程就是一条命令:python example_template/digital/fit_device_pnr_lvs.py --port <session-port>(加 --draw-only 只画器件、跳过 P&R/LVS;仓库克隆里也可以用 python -m examples_klink.public.demos.digital.fit_device_pnr_lvs --port <session-port>)。
这篇教程把 demo 一次跑完的流程拆成 6 个可观察的阶段,每一步都在 live KLayout 里真的执行、截图。demo 里的器件是合成的(数字干净、明显非真实工艺),所以整个 demo 零机密数据就能跑;把 _device_boxes() 换成你从自己手画 cell 里 harvest 出来的盒子,流程一行不改。

1范例几何:单器件出发

一切从“你已经会画的那个器件”开始。范例(exemplar)就是同一个器件在几个不同参数点上的画法——每个范例是一组带角色名的盒子(role → 层 + bbox)。本例是一个背栅式合成器件,4 个角色、3 个工艺层:沟道(103/0)、源/漏(104/0,在 x 方向伸出沟道)、背栅板(101/0,盖住沟道并向左伸出作栅引出)。几何由两个参数驱动:w_um(沟道宽)和 l_um(源漏间隙):

SX, OUTER, CY, PL, PR = 2.5, 11.0, 2.0, 10.0, 8.0   # OUTER = 固定的源/漏外缘

def _device_boxes(W, L):
    return {
        "channel":    ("103/0", [-W/2, -(CY + L/2), W/2, CY + L/2]),
        "source":     ("104/0", [-(W/2 + SX), -OUTER, W/2 + SX, -L/2]),
        "drain":      ("104/0", [-(W/2 + SX), L/2, W/2 + SX, OUTER]),
        "gate_plate": ("101/0", [-(W/2 + PL), -(CY + L/2), W/2 + PR, CY + L/2]),
    }

EXEMPLAR_SIZES = [(10, 4), (50, 4), (10, 8), (50, 8)]   # 覆盖 W 和 L 两个轴

4 个范例点刻意覆盖两个参数轴(W 变、L 变、两者都变)——拟合器是做回归的,范例必须让每个参数独立变化过,否则它会明确报错(“参数共线”)。真实场景里这些盒子来自你已有的版图:用 shape.query 从几个不同尺寸的手画器件 cell 里读出来即可。

一行四个背栅器件范例,从左到右分别是 W=10 L=4、W=50 L=4、W=10 L=8、W=50 L=8,宽度和源漏间隙明显不同
Step 1 · 4 个范例几何(W=10/50 × L=4/8)。橙色 = 背栅板(101/0),绿色 = 源/漏(104/0),中间致密网格 = 沟道(103/0)。W 驱动横向尺寸,L 驱动源漏间隙。

2拟合:从范例到参数化模型

拟合器(klink.domains.structdevice.pcell_fitter)做的事是:对每个角色的每条盒边(x1/y1/x2/y2),把边坐标对参数做最小二乘回归,并分类——linear(被参数干净驱动)、constant(不随参数动)、unexplained(数据解释不了,需要你拍板:是新参数,还是钉成常数)。先 analyze 筛查、读 decisions_needed、确认模型,再 fit_table 出表:

from klink.domains.structdevice import pcell_fitter as fitter

exemplars = [{"params": {"w_um": W, "l_um": L},
              "roles": {r: {"layer": ly, "box_um": bx}
                        for r, (ly, bx) in _device_boxes(W, L).items()}}
             for W, L in EXEMPLAR_SIZES]
report = fitter.analyze(exemplars, ["w_um", "l_um"])
print(report.summary())
# parameters: ['w_um', 'l_um']
# edges: 14 linear, 2 constant, 0 unexplained

table = fitter.fit_table(
    report, style="default",
    sample_order=[{"w_um": W, "l_um": L} for W, L in EXEMPLAR_SIZES],
    param_units={"w_um": "um", "l_um": "um"})

本次实际筛查结果:16 条边里 14 条 linear、2 条 constant、0 条 unexplained。那 2 条常数边正是器件模型里“源/漏外缘固定在 ±11 µm”的约定——拟合器自己从数据里发现了它。产物是一张 klink_fitted_device_pcell_v2 拟合表:每条边一个 base + Σ coef[p]·param[p] 的线性法则(单位 dbu),比如沟道左边缘:

"channel": {"layer": "103/0", "edges": {
    "x1": {"kind": "parametric", "base": 0,      "coef": {"w_um": -500, "l_um": 0}},
    ...
}},
"source":  {"layer": "104/0", "edges": {
    "y1": {"kind": "parametric", "base": -11000, "coef": {"w_um": 0,    "l_um": 0}},  # 常数边
    ...
}}
W=10 L=4 范例的放大图,蓝色双向箭头标出沟道宽 w_um 和源漏间隙 l_um,橙色方框标出源极外缘是 coef=0 的常数边
Step 2 · 拟合结果落在几何上:蓝色箭头 = 参数驱动的边(沟道宽 w_um、源漏间隙 l_um),橙色框 = 拟合器判为常数(coef=0)的源极外缘。有了这张表,器件可以画在任意参数值上,不只 4 个范例点。

3注册成 PCell,画出变体

拟合表交给 KLayout 插件侧的 pcell.register_fitted,就注册出一个真 PCell(库名 klink_structdevice):参数进、几何出,拟合表在注册时绑定,画出的实例只携带几何参数。改参数画 3 个变体,验证参数化是真的:

c.call("pcell.register_fitted", {"name": "demobg", "fit_table": FIT})  # FIT = 拟合表 json 路径

items = [{"pcell": "demobg", "library": "klink_structdevice",
          "params": {"w_um": W, "l_um": L, "style": "default"},
          "position_um": [i * 180.0, 0.0]}
         for i, (W, L) in enumerate([(10, 8), (20, 8), (50, 3)])]
c.instance_insert_pcell_many("FIT_VARIANTS", items)

注意这 3 个尺寸里只有 (10, 8) 是范例点:W=20 是范例区间内的插值L=3 在范例区间 [4, 8] 之外、是外推——线性模型对超出样本区间的参数不背书(工艺规则未知),但几何法则照常成立,后面的 LVS 会证明这三个尺寸都能用。

三个由同一 PCell 画出的器件变体,分别标注 dev10_8 W=10 L=8、dev20_8 W=20 L=8、dev50_3 W=50 L=3,尺寸依次变宽、源漏间隙不同
Step 3 · 同一个 demobg PCell 在三组参数下的实例:dev10_8(范例点)、dev20_8(插值)、dev50_3(外推)。几何随参数正确缩放——参数化是拟合出来的,不是硬编码的。
器件库也是 example 数据。后面 P&R 用的 DEVICES 表把每个器件键映射到 {params, pcell, library, style, fit_table}——器件是 N 元的params 是任意 dict,没有 W/L 假设),klink 不预设任何一项;连每个器件的端子(G/S/D 的位置、朝向、层)都从拟合表算出、存进 example 自己的 device_geom.json

4按网表摆放多实例

现在把单器件放大成电路。输入是一份器件级网表(demo 内置 add4.devnet.json,一个 4-bit 加法器:62 个门、173 个器件实例、96 个网);工艺栈用 example 自有的 PUBLIC_PROCESS(3 层布线 + 2 组过孔)。行列数、行距都从需求推导,不是魔法数:

from dataclasses import replace
from klink.routing.grid.process_profile import ProcessProfile
from klink.routing.grid.floorplan import derive_grid, derive_row_pitch
from klink.domains.structdevice import layout_engine as eng

PUBLIC_PROCESS = ProcessProfile(          # 全部数字 example-owned;抄走改成你的工艺
    routing_layers=("101/0", "104/0", "106/0"),
    gate_layer="101/0", sd_layer="104/0", channel_layer="103/0",
    vias=(("101/0", "102/0", "104/0"), ("104/0", "105/0", "106/0")),
    layer_directions={"101/0": "V", "104/0": "H", "106/0": "V"},
    wire_width_um=5.0, wire_clear_um=2.0, prl_spacing_um=10.0, prl_length_um=15.0,
    via_pad_um=5.0, litho_tol_um=1.0, y_step_um=30.0, col_pitch_um=100.0, margin_um=60.0)

P = replace(PUBLIC_PROCESS, wire_clear_um=5.0, grid_pitch_um=10.0,
            col_pitch_um=100.0, y_step_um=35.0)     # 密度旋钮,同样 example-owned
nl = json.loads(Path("examples_klink/public/demos/add4.devnet.json").read_text())
raw = eng.load_device_geom(GEOM)          # 器件几何表(terms/pads/body),由拟合表生成
_, _, terms = eng._geom_tables(raw)
rows, cols = derive_grid(len(nl["groups"]))          # 62 门 -> 8 x 8
rp = derive_row_pitch(nl, rows, cols, terms, y_step=P.y_step_um,
                      width_um=P.wire_width_um, wire_clear_um=P.wire_clear_um,
                      via_pad_um=P.via_pad_um, n_horiz_layers=3)   # -> 170.0 um
placement = eng.place_grid(nl, rows, cols, profile=P, row_pitch=rp)   # 173 个实例

derive_row_pitch 把行距 = 器件叠高 + 布线通道,通道宽度按“峰值穿越网数 ÷ 布线层数”算出——层多通道就窄,版图就小。摆放本身就是一次 instance.insert_pcell_many 批量调用(见批量 RPC 铁律)。

8 行 8 列的器件阵列,每列是一叠 2 到 3 个拟合 PCell 器件,行与行之间留有布线通道,尚无任何连线
Step 4 · 173 个拟合 PCell 实例按 8×8 门阵列摆好(每门一列、器件竖叠,负载管在顶)。行间空白就是 derive_row_pitch 预留的布线通道,此时还没有任何连线。

5声明网络 + 详细布线

连通性的意图来自网表:每个网声明“哪些器件端子属于同一个电气节点”。这份声明既是布线器的输入,也是第 ⑥ 步 LVS 对账的基准:

declared = [{"net": n["net_id"], "terminals": n["terminals"]} for n in nl["nets"]]
# 例:{"net": "$abc$217$new_n15", "terminals": ["X1.S", "X1.G", "X2.D", "X102.G"]}

cut_layer = {tuple(sorted((lo, up))): P.cut_layer(lo, up) for (lo, _c, up) in P.vias}
ok, info, _ = eng.route_and_draw_flexdr(
    c, "DEMO_ADD4", nl, placement, profile=P, layers=list(P.routing_layers),
    vias=P.via_rules(), cut_layer=cut_layer, geom_path=GEOM,
    devices=DEVICES, use_rust=True)
# FlexDR 3.7s ok=True routed=94/94 markers=0

96 个网里,VDD/GND 走电源网格(PDN:104/0 轨 + 106/0 带,本次 166 个 PDN 过孔),其余 94 个信号网由 FlexDR 详细布线器在 3 层上走线(本次 200 个信号过孔),端子接入点、过孔规则全部来自 profile 和 harvest 出的器件几何。routed=94/94、markers=0(0 个 DRC 违例)才继续;布不通时返回的是指令(哪个网、为什么、怎么改 floorplan 参数),不是裸报错。

8x8 器件阵列全部布通的整体视图,行间通道里布满横向绿色走线和纵向橙色、灰色走线,四周有电源环
Step 5a · 94/94 信号网全部布通:绿色横线(104/0)走行间通道,橙(101/0)、灰(106/0)竖线跨行,四周是 PDN 电源环。
放大到两列器件:可见器件的栅板和源漏 pad、三个布线层的走线以及带十字网格的过孔叠层方块
Step 5b · 局部放大(第一行两列器件):走线从器件 pad 的接入点出发,跨层处是过孔叠层(上下两层落 pad + 中间 cut,小方块)。

6live LVS:声明 vs 提取

最后一步不是“看起来连上了”,而是让 KLayout 原生的连通性提取当裁判:lvs_check 从画好的真实几何里提取网络(金属层内连通 + 过孔跨层),和第 ⑤ 步声明的网逐一对账,再做器件级 netlist 比对:

from klink.domains.structdevice.orchestrators import lvs_check
from klink.domains.structdevice.recipes import geom_terminal_provider

# device_terms = 每个实例端子的绝对坐标(摆放偏移 + terms 里的端子中心;
# 完整的一行 dict 推导见 demo 源码)
res = lvs_check(c, "DEMO_ADD4", declared=declared, mode="lvsdb",
                connectivity=P.connectivity_spec(),      # 由同一份 profile 推导
                terminal_provider=geom_terminal_provider(raw),
                placement=placement, device_terms=device_terms)
# LVS ok=True match=True devices=173

mode="lvsdb" 还会写出一份几何联动的原生 .lvsdb——在 KLayout 的 Netlist Browser 里打开,可以在版图和网表之间双向交叉探查。注意 LVS 用的连通性规则(哪些层导电、哪些层是过孔)由 P.connectivity_spec()同一份 profile 推导——布线器和裁判读同一份工艺声明,但裁判是 KLayout 原生提取器,不是布线器自己给自己打分。

完成的 4-bit 加法器版图全景,左上角有 GND 标签、左下角有 VDD 标签,电源环包住整个 8x8 阵列
Step 6 · 成品全景(view.zoom_fit):LVS match=True 之后的 DEMO_ADD4。PDN 外环带 GND/VDD 文字标签——没有 pad 时,外环轨就是电源端口。

验证,不是截图

教程首页说的一样,截图只是给人看的。这次在 live KLayout 里实际运行的完整输出:

=== fitter screening ===
parameters: ['w_um', 'l_um']
edges: 14 linear, 2 constant, 0 unexplained
drawn DEMO_DEVICES (synthetic fitted device)
[public] FlexDR 3.7s ok=True routed=94/94 markers=0
[public] LVS ok=True match=True devices=173
RESULT: PASS (synthetic fitted device: fit -> P&R -> LVS match)

四道结构化质量门,缺一不可:拟合筛查 0 unexplained(每条边都有解释);布线 routed=94/94markers=0(全部布通且零 DRC 违例);LVS match=True(173 个器件的提取网表和声明网表一致)。任何一道不过,demo 以非零退出码失败——不存在“差不多就算完成”。

KLayout 的 Netlist Database Browser 打开 DEMO_ADD4.lvsdb,Cross Reference 页里 DEMO_ADD4 的 Nets 和 Devices 均显示为绿色(匹配)
Step 6 · match=True 的人眼印证:mode="lvsdb" 写出的 DEMO_ADD4.lvsdb 在 KLayout 的 Netlist Database Browser 里打开,Cross Reference 页的网络与器件全部对上(绿色)。这就是上面 LVS ok=True match=True 那行数字背后的原生提取结果——数字是判定依据,这张图只是同一结论给人看的样子。

下一步

把这条流程变成你自己的:复制 examples_klink/public/demos/fit_device_pnr_lvs.py,换掉三样东西——_device_boxes()(改成从你手画的器件 cell 里 shape.query 出来的盒子)、PUBLIC_PROCESS 的层号/线宽/间距、以及网表。klink 机制层一行不用改。如果你是从零开始,建议先读第一批的Hall bar 入门教程建立 geometry-first 的基本流程。下一篇教程讲 probe-card padframe demo:pad 环先固定、电路去迁就它的 P&R。