前置条件
- starter:
klink 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>)。
_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 里读出来即可。
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_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 会证明这三个尺寸都能用。
demobg PCell 在三组参数下的实例:dev10_8(范例点)、dev20_8(插值)、dev50_3(外推)。几何随参数正确缩放——参数化是拟合出来的,不是硬编码的。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 铁律)。
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 参数),不是裸报错。
104/0)走行间通道,橙(101/0)、灰(106/0)竖线跨行,四周是 PDN 电源环。
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 原生提取器,不是布线器自己给自己打分。
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/94 且 markers=0(全部布通且零 DRC 违例);LVS match=True(173 个器件的提取网表和声明网表一致)。任何一道不过,demo 以非零退出码失败——不存在“差不多就算完成”。
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。