做版图的人基本都逃不过 GDS 这个格式。无论是最终流片、跑 DRC/LVS,还是交给后道封装厂,大家认的始终是 GDSII。以前我干这活儿全靠手动在版图编辑器里点点画画,直到有一次要在一个 die 上排几百个 testkey 阵列,手动放置放到崩溃,才开始认真思考:能不能用 Python 直接生成 GDS?
答案是不仅能,而且生成的效率、可控性、复用性远超手动操作。这篇文章就围绕“python 生成 gds”这件事,从 GDS 格式的基本原理讲到具体库的选型,再到完整脚本怎么写、常见的坑怎么避开。内容覆盖三个层次的读者:完全不懂版图格式的小白,能照着一遍跑通;已经在用 EDA 工具但想引入自动化的工程师,能看到完整的脚本思路;想把这套流程固化到团队流程里的同学,文章最后还给了参数化设计和批量生成的进阶方向。整个过程不依赖大型 EDA 工具,只需要一台装了 Python 的电脑,核心库就是 gdspy。
1. 先搞懂 GDS:它的结构决定了你该怎么写生成脚本
1.1 为什么版图工程师最终都绕不开 GDS
GDSII 是行业内几十年来最通用的版图数据交换格式。虽然现在已经有 OASIS 这种压缩率更高、能存更多信息的格式,但大多数晶圆厂、封测厂、掩膜厂接收的最终数据仍然以 GDS 为主。它的本质是一串二进制的流格式文件,里面记录的并不是“电路图”,而是一堆几何图形和它们的位置关系:矩形、多边形、路径、文字标注、结构引用。读懂这一点很重要:我们做版图时脑子里想的是“这是一个电阻”“这是一根走线”“这是 PIN”,但落到 GDS 文件里统统变成了“某个图层上的某个几何图形”。所以用 Python 生成 GDS,本质上就是做一件事:用代码描述几何图形,并把它们组织到正确的图层上。
1.2 坐标、图层、单元格:理解这三个概念就能上手
要理解并编写生成脚本,必须先抓住 GDS 的三个核心概念。
第一是坐标系统。GDS 文件内部存储的坐标是整数,它通过两个参数映射到实际物理尺寸:数据库单位(unit)和坐标精度(precision)。举个例子,一个常见的设置是 unit=1e-6、precision=1e-9,也就是数据库单位是 1 微米,坐标分辨率是 1 纳米。代码里写坐标 10,实际含义就是 10 纳米;想画一个 1 微米×2 微米的矩形,就需要写坐标 (0,0) 到 (1000,2000)。很多新手第一次画出来的图形尺寸完全不对,十有八九就是没搞懂这层换算关系。
第二是图层(layer)和数据类型(datatype)。GDS 里每个图形都要归属于一个 layer 和一个 datatype,这两者通常都是整数。它们在 PDK 里往往有明确的映射规则,比如 layer 65/20 可能代表 N 阱,layer 66/20 可能代表 P 阱。DRC、LVS 工具全靠 layer + datatype 的组合来识别版图含义,所以生成脚本时图层的定义不能拍脑袋,必须和 PDK 的设计规则保持一致。
第三是单元格(cell)。GDS 的组织方式和代码的模块化非常像:你可以先画一个小的功能模块“inv_1”,再画一个“inv_1_pin”,然后把这些 cell 通过引用的方式放进顶层 cell,最终形成一棵层次树。这种结构叫层次化(hierarchy)。GDS 的底层实体是“结构”(structure),cell 是对结构的一种人话称呼。层次化能让文件体积大幅缩小,也让大型版图的维护变得可行。
1.3 GDS 能表达什么,表达不了什么
很多人刚开始写脚本时,容易把 GDS 当成万能格式,但它的表达能力其实非常有限。GDS 能存储的就是边界(boundary)、路径(path)、文本(text)、结构引用(structure reference)和阵列引用(array reference)这几种记录。它不知道“这个多边形是电阻的有源区”,也不保存电路网表、元器件属性、颜色设置,更不能承载设计规则或工艺参数。这些信息要么通过图层组合去间接表达,要么用独立的文件(比如 LVS 用的网表)配合使用。
理解这个边界对脚本设计非常重要。比如你想生成一份可以直接拿去跑 LVS 的版图,就得额外准备对应的网表文件,光有 GDS 是远远不够的。反过来,如果你的目标只是输出一份用于光刻掩膜的纯几何图形,那么 GDS 完全够用。我的经验是:开始写生成脚本前,先明确这份 GDS 是给谁看的、要参与什么流程。这决定了你要不要做层次化、要不要加 label、图层怎么归置。
2. 工具选型:不只是 gdspy,对比之后你才知道为何选它
2.1 三个主流方案的快速对比
在 Python 生态里,生成 GDS 最常见的库有三个:gdspy、gdstk,以及 KLayout 自带的 Python API(pya)。它们都能创建图形、写入 GDS,但设计思路差异不小。
| 库 | 学习曲线 | 性能 | 依赖 | 适用场景 |
|---|---|---|---|---|
| gdspy | 平缓 | 中等 | numpy | 中小规模版图、教学、自动化脚本 |
| gdstk | 较陡 | 快 | numpy | 大规模版图、对写入速度要求高的场景 |
| pya(KLayout API) | 中等 | 中等 | KLayout 本体 | 需要 KLayout 可视化、DRC/LVS 全流程的人 |
我平时用得最多的是 gdspy,原因很朴素。第一,它的 API 设计非常直观,创建多边形、路径、布尔运算的调用方式和人类的几何直觉一致,新手上手成本低。第二,它不需要安装任何 EDA 工具就能完成任务,纯 Python 环境下直接 pip install 就能用,非常适合跑在服务器上做批处理。第三,它在读回 GDS、复制单元格、打平层次这些常用功能上都有成熟的实现,写脚本时的“心智负担”比较小。
2.2 安装与第一个可运行环境
安装 gdspy 非常简单,它只依赖 numpy。建议先创建虚拟环境,避免污染系统 Python:
python -m venv gds_env source gds_env/bin/activate # Windows 下为 gds_env\Scripts\activate pip install numpy gdspy装好之后,可以写一行最简单的代码验证环境是否正常:
import gdspy print(gdspy.__version__)这里要特别注意:gdspy 的写文件操作本身不需要图形界面,所以你完全可以在没有显示器的 Linux 服务器上运行生成脚本。这个特性在批量生成 testkey、自动摆放多个 die 的场景里特别好用。我早期在公司服务器上生成一整版 GDS,直接 ssh 上去跑脚本,几分钟后下载回来的就是几百 MB 的完整版图文件。
3. 从零画出版图:一个能落地的 GDS 生成流程
3.1 创建库与顶层单元
用 gdspy 生成 GDS 的第一件事是创建库(library)和单元(cell)。库可以理解为容纳所有 cell 的容器,最后 write 的时候把整个库写进一个文件。顶层单元是版图层次树的根,所有模块最终都以某种方式被顶层引用。
import gdspy # 创建库 lib = gdspy.GdsLibrary() # 创建顶层单元 top = lib.new_cell('TOP') # 创建子单元(模块) module_a = lib.new_cell('MODULE_A')在命名 cell 时我强烈建议遵循统一的命名规范,比如“BLOCK_XXX”或“MODULE_XXX”。GDS 文件里 cell 名是全局标识符,命名混乱在后续 DRC 报错时定位问题会非常痛苦。
3.2 添加图形:矩形、多边形、圆环、路径
gdspy 提供了非常丰富的图形构造函数。最基础的是矩形,常用的还有多边形、圆环、椭圆、路径。
# 在 MODULE_A 中绘制一个矩形:左下角(0,0),右上角(1000, 2000) # 这里整个文件使用 unit=1e-6, precision=1e-9,所以 # (0,0) -> (1000,2000) 实际是 1um x 2um rect = gdspy.Rectangle((0, 0), (1000, 2000), layer=65, datatype=20) module_a.add(rect) # 多边形:三角形 tri = gdspy.Polygon( [(0, 0), (500, 1000), (1000, 0)], layer=66 ) module_a.add(tri) # 圆环/椭圆 circle = gdspy.Round( (5000, 5000), # 圆心 2000, # 半径 number_of_points=64, layer=66, datatype=0 ) module_a.add(circle)这段代码里有两个容易忽视的细节。第一个是number_of_points参数:GDS 不能直接存储完美的圆形,它是通过很多个短线段逼近的,这个参数决定圆周被切成多少段。数值越大越接近真实圆,但文件体积和 DRC 计算量都会变大。第二个是layer和datatype:datatype如果不写,会根据具体情况有默认值,但在正式项目里我建议每个图形都显式指定,避免默认值和你 PDK 映射不一致。
路径是版图里最常画的东西,gdspy 的 Path 对象非常适合画走线、切割道、密封环。它跟手动画图里的“route”很像,先给一个线宽,然后从起点逐段延伸:
# 宽度为 500nm 的走线,从 (0,0) 开始 path = gdspy.Path(500, (0, 0)) path.segment(2000, '+x') # 向 +x 方向走 2000 path.segment(1000, '+y') # 向 +y 方向走 1000 path.turn(1500, 'r') # 以半径 1500 向右转弯 module_a.add(path)Path 的转弯会自动插入圆弧段,不需要你手动计算圆弧顶点,这对写带角度的自动化脚本太有用了。
3.3 布尔运算与图形修改
版图不是画完基础图形就结束了,大量结构需要做图形合并、挖空、取交集。gdspy 提供了boolean函数,相当于 GDS 图形层面的“集合运算”。
# 两个多边形 shape1 = gdspy.Rectangle((0, 0), (2000, 2000), layer=1) shape2 = gdspy.Rectangle((1000, 1000), (3000, 3000), layer=1) # 并集 union = gdspy.boolean(shape1, shape2, 'or', layer=1) # 交集 inter = gdspy.boolean(shape1, shape2, 'and', layer=1) # 差集:shape1 减去 shape2 diff = gdspy.boolean(shape1, shape2, 'not', layer=1)布尔运算返回的是一个 PolygonSet 对象,可以像普通图形一样直接 add 到 cell 里。用布尔运算可以很方便地做出带圆角的方孔、掏空的密封环这类形状。
3.4 写入文件与验证
将所有图形都加到 cell 之后,调用write_gds即可写出文件。我习惯在写文件前先看一下图形的边界框,确认坐标范围是否符合预期。
# 查看 TOP 单元边界框 bbox = top.get_bounding_box() print(bbox) # 写出 GDS lib.write_gds('output.gds', unit=1e-6, precision=1e-9)unit和precision这两个参数极其重要。unit是 GDS 文件里“数据库单位”对应的物理长度,unit=1e-6表示一个数据库单位是 1 微米;precision是坐标缩放精度,precision=1e-9表示内部整数坐标代表 1 纳米。最终 GDS 文件里存储的整数是“物理坐标除以 precision”的结果。所以如果你发现写出来的图形尺寸大了 1000 倍或小了 1000 倍,基本就是这两个参数的关系没捋清。
验证方式有三个层次。第一,用gdspy.LayoutViewer()直接弹出窗口查看版图,适合本地调试。第二,用 KLayout 打开生成的 GDS,这也是最接近 EDA 流程的验证方式。第三,在纯服务器环境下,可以用 gdspy 自己把文件读回来检查单元和图形数量:
lib2 = gdspy.GdsLibrary() lib2.read_gds('output.gds') print(lib2.cells.keys())4. 层次化设计:让代码和版图一起“可维护”
4.1 单元引用的本质
当版图复杂度上来之后,最忌讳的就是把几千个图形全塞进同一个 cell 里。正确做法是像写函数一样,把可复用的单元抽象出来,然后通过引用拼装。GDS 里的“引用”不会复制图形数据,只是记录“我在某个位置,以某个角度和倍率,放置了某个 cell”。这既减小了文件体积,也让版图结构更清晰。
我做过一次 testkey 自动化生成,里面所有 testkey 结构都是同一个 cell,只是放在不同位置。如果打平(flatten)以后再写文件,文件有 300 多 MB;保持层次化时只有 30 多 MB。DRC 工具跑起来速度差距也是数量级的。
4.2 SRef 与 ARef:单个引用和阵列引用
gdspy 提供了两种主要引用方式:CellReference(单个引用)和CellArray(阵列引用)。
# 创建一个可复用的 pad 单元 pad = lib.new_cell('PAD') pad.add(gdspy.Rectangle((0, 0), (10000, 10000), layer=49)) # 在 TOP 顶层单元中放置一个 PAD ref1 = gdspy.CellReference(pad, origin=(1000, 2000), rotation=45) top.add(ref1) # 以 3 行 4 列阵列形式放置 PAD,间距 20000 arr = gdspy.CellArray( pad, columns=4, rows=3, spacing=(20000, 20000), origin=(0, 0) ) top.add(arr)这里的rotation单位是度,不是弧度。CellArray在图形上是将同一个 cell 按行列间距阵列复制,非常适合放 bonding pad 阵列、bump 阵列、dummy fill 阵列。还要注意x_reflection参数,它决定是否做镜像翻转,某些对称性要求较高的版图会用到,但用的时候要格外小心几何方向,不然翻转后的结构放到流片规则里可能直接报错。
4.3 从脚本层面组织工艺流程
层次化不仅是 GDS 文件层面的结构,更应该是代码层面的设计习惯。我在写生成脚本时,会把“单元定义”和“单元摆放”分成两个阶段。第一阶段,所有基础单元只定义一次,放入一个列表;第二阶段,通过一组布局坐标数据来摆放引用。这种分层方式让代码很容易维护。
比如下面的简化思路:
define_units(lib) place_units(lib, top, placement_list)当后来需要换排列方式、调整位置、增删单元时,只需要改数据,不需要动图形定义逻辑。这样的脚本模式,本质上就是参数化单元(PCell)的雏形,对团队协作尤其友好。
5. 实战中一定会踩的坑
5.1 单位与倍率:画出来总是大 1000 倍
这是新手遇到最多的一个问题。现象是代码里写了一个 1000×2000 的矩形,明明想画 1um×2um,打开 KLayout 一看变成了 1mm×2mm。根因几乎都是对unit和precision的理解不到位。如果precision=1e-9,内部坐标的最小刻度是 1nm,那么(1000,2000)就是1000 * 1e-9 = 1e-6米,也就是 1um。此时如果 KLayout 里数据库单位设置成了 1um,它就会把 1000 解释为 1000um。解决方法是统一各工具之间的数据库设置,或者写一个小函数做坐标换算,把项目里所有“以纳米为单位的物理长度”统一转换成 GDS 内部整数。
5.2 坐标过大或过小,导致图形偏移或精度丢失
GDS 的坐标是 32 位整数,实际约 ±21 亿。如果版图尺寸很大,加上坐标可能变成 20 亿以上,就会出现精度问题甚至溢出。我之前遇到过一颗大型 SoC 的版图,坐标已经到亿级别,再叠加一些小数运算,结果某些多边形顶点被舍入到了错误位置。处理手段有三种:要么把图形的绝对坐标值控制在合理范围,尽量把它平移到原点附近;要么在做后期合并时重新计算坐标;要么使用支持更大坐标范围的后续格式(比如 OASIS),但那是另一套流程。总之,写脚本时尽量用相对坐标定义单元,摆放时再加偏移,别把所有东西都写成绝对大坐标。
5.3 多边形顶点数量上限
GDSII 格式规定单个 boundary 的顶点数不能超过 8191 个。听起来很大,但画高精度的圆环或者复杂布尔运算结果时非常容易触及。比如一个 10000 段的圆弧,就直接爆表了。解决方式是调小number_of_points,或者把图形拆成多个多边形,又或者在导出前做一次简化。这个坑在 print 图像转 GDS、字体转 GDS 这类场景里特别常见——字体轮廓经过布尔运算后可能产生巨大数量的顶点。我的习惯是每次布尔运算后,检查结果 PolygonSet 的顶点数,超限就做拆分。
5.4 Label 的编码和图层习惯
GDS 里的文本标签并不是用来画文字的,它在更多时候是给 LVS 做 Pin 识别的。gdspy 的Label使用 ASCII 编码,如果需要写中文工艺名或者特殊符号,可能会在你没有预期的地方出问题。即便英文标签,也要注意字符长度,某些老式工具链对字符串长度有历史限制。另一个常见问题是texttype没有设置对,导致 LVS 工具识别不到 Pin。建议做 LVS 时,先在一小块测试版图上验证 label 能否被正确识别,再批量铺开。
label = gdspy.Label( 'VDD', position=(500, 500), layer=50, texttype=1 ) top.add(label)5.5 布尔运算后图层信息丢失
gdspy.boolean有一个很容易忽略的参数:layer和datatype。如果调用时不指定,生成的 PolygonSet 可能会变成 layer=0,导致结果图形在版图里消失或者被归到错误的工艺层。这个坑出现得频繁,但报错往往不明显,最后靠 DRC 才能发现。我的建议是,所有布尔运算的调用都显式写出目标图层:
result = gdspy.boolean( a, b, 'not', layer=65, datatype=0 )5.6 大量重复小图形导致文件膨胀
如果脚本里用循环生成了成千上万个相同的小图形,并且全部放到同一个 cell 里,生成的 GDS 会异常庞大。正确做法是把重复图形抽象成 cell,然后用CellArray或CellReference来放置。这样不仅文件小,后续修改也只需要改一个地方。这点和代码的 DRY 原则完全一致。
6. 更进一步的自动化思路
6.1 参数化单元:用一个函数批量生成同类结构
把生成逻辑封装成函数,是自动化的第一步。举个例子,假设我要生成不同尺寸的电阻结构,可以这么写:
def make_resistor(lib, name, width, length, layer_od, layer_poly): cell = lib.new_cell(name) od = gdspy.Rectangle((0, 0), (width + 2000, length + 2000), layer=layer_od) po = gdspy.Rectangle((0, 0), (width, length), layer=layer_poly) cell.add(od) cell.add(po) return cell r1 = make_resistor(lib, 'R_M1', 1000, 4000, 65, 66) r2 = make_resistor(lib, 'R_M2', 1500, 8000, 65, 66)这个函数就是 PCell 的雏形。当团队里需要新增一种器件时,只需要扩展参数列表,不用从底层重新写图形。
6.2 与 CSV/JSON 数据结合,形成批量布局能力
更实用的场景是:有一张 Excel 或 CSV 记录了所有 testkey 的位置、尺寸、图层信息,脚本读取后自动生成完整的版图。
import csv with open('placements.csv') as f: reader = csv.DictReader(f) for row in reader: ref = gdspy.CellReference( lib.cells[row['cell']], origin=(float(row['x']), float(row['y'])), rotation=float(row.get('rot', 0)) ) top.add(ref)这种模式最大的价值是可审计、可复现。手工摆放几百个 testkey 时,谁也没法保证不放错位置;脚本加数据的方案,每次的布局结果都是确定的,数据变更是可控的。代码里还可以加断言检查,比如“所有坐标都在 die 范围内”,避免低级错误。
6.3 建立脚本化检查和试跑流程
自动化流程跑通以后,建议再加一个“演练脚本”,把生成后的 GDS 读回来,做几件检查:确认 cell 是否存在、图形数量是否在预期范围、是否有坐标为负的图形、是否触发了顶点数超限。所有这些检查都可以在写入最终文件之前完成,一旦失败就直接终止流程。这样效率会高很多,也不会把错误版图带进下一个环节。
我在实际开发中会这么做:先lib.write_gds到一个临时文件,然后马上用read_gds读回来做一轮自检。虽然多了一点 IO 时间,但换来的是更高的可靠性。等脚本稳定后,再把这个临时检查开关关掉,为大批量生成提速。
结尾
从手动画版图到用 Python 生成 GDS,最大的变化不是省了多少时间,而是整个流程开始变得可以被审查、被版本控制、被复用。每次写新脚本,我仍然会先写坐标换算的公共函数,再定义单元,最后才是摆放和导出。遇到古怪的图形问题,第一反应不是用工具去修,而是回去看生成脚本里的参数有没有越界。这种思维方式一旦建立起来,几百个 testkey、几十个 die 的布局对你来说就不再是负担,而是一份随时可以改、随时可以再生成的数据资产。希望这篇文章能帮你更快进入这个状态。