Skip to main content
Version: release

Designing an inverter with open source tools

In this tutorial, we'll design and lay out an inverter in the Skywater 130nm process. Substrate will call into open source tools (ngspice, magic, and Netgen) to run simulations, DRC, LVS, and extraction.

Setup

Protocol Buffer Compiler

Ensure that you have the protocol buffer compiler (protoc) installed.

Rust

Ensure that you have a recent version of Rust installed.

Add the Substrate registry to your Cargo config:
~/.cargo/config.toml
[registries]
substrate = { index = "https://github.com/substrate-labs/crates-index" }
You only need to do this the first time you set up Substrate.

Project Setup

Next, create a new Rust project:

cargo new --lib sky130_inverter && cd sky130_inverter

In your project's Cargo.toml, start with the following dependencies:

Cargo.toml
[dependencies]
substrate = { version = "0.10.2", registry = "substrate" }
sky130 = { version = "0.10.2", registry = "substrate" }
layir = { version = "0.2.1", registry = "substrate" }
gdsconv = { version = "0.2.1", registry = "substrate" }
gds = { version = "0.4.1", registry = "substrate" }
scir = { version = "0.9.1", registry = "substrate" }
spice = { version = "0.9.2", registry = "substrate" }

arcstr = "1"
rust_decimal = "1"
rust_decimal_macros = "1"

To pull in the plugins for the necessary tools, add these depependencies as well:

Cargo.toml
ngspice = { version = "0.5.2", registry = "substrate" }
magic_netgen = { version = "0.1.3", registry = "substrate" }
magic = { version = "0.2.1", registry = "substrate" }

Let's now add some imports that we'll use later on. Replace the content of src/lib.rs with the following:

src/lib.rs
use sky130::mos::{Nfet01v8, Pfet01v8};
use sky130::Sky130;
use substrate::block::Block;
use substrate::context::Context;
use substrate::error::Result;
use substrate::schematic::{CellBuilder, Schematic};
use substrate::types::schematic::IoNodeBundle;
use substrate::types::{InOut, Input, Io, Output, Signal};

Also, add the following constants:

src/lib.rs
pub const SKY130_MAGIC_TECH_FILE: &str =
concat!(env!("OPEN_PDKS_ROOT"), "/sky130/magic/sky130.tech");
pub const SKY130_NETGEN_SETUP_FILE: &str =
concat!(env!("OPEN_PDKS_ROOT"), "/sky130/netgen/sky130_setup.tcl");

EDA Tools

This tutorial will demonstrate how to invoke ngspice from Substrate to run transient simulations. Make sure to install an ngspice version of at least 41 before running your Rust code.

You will also need to have magic and netgen installed for verification and extraction.

SKY130 PDK

You will need to install the open source PDK by cloning the skywater-pdk repo and pulling the relevant libraries:

git clone https://github.com/ucb-substrate/skywater-pdk.git && cd skywater-pdk
git submodule update --init libraries/sky130_fd_pr/latest

Set the SKY130_OPEN_PDK_ROOT environment variable to point to the location of the repo you just cloned.

You will also need to install Tim Edwards' Open-PDKs and point to your installation with the OPEN_PDKS_ROOT environment variable.

Interface

We'll first define the interface (also referred to as IO) exposed by our inverter.

The inverter should have four ports:

  • vdd and vss are inout ports.
  • din is an input.
  • dout is the inverted output.

This is how that description translates to Substrate:

src/lib.rs
#[derive(Io, Clone, Default, Debug)]
pub struct InverterIo {
pub vdd: InOut<Signal>,
pub vss: InOut<Signal>,
pub din: Input<Signal>,
pub dout: Output<Signal>,
}

Each Signal is a single wire. The Input, Output, and InOut wrappers provide directions for the Signals they enclose.

The #[derive(Io)] attribute tells Substrate that our InverterIo struct should be made into a Substrate IO.

Inverter parameters

Now that we've defined an IO, we can define a block. Substrate blocks are analogous to modules or cells in other generator frameworks.

While Substrate does not require you to structure your blocks in any particular way, it is common to define a struct for your block that contains all of its parameters.

We'll make our inverter generator have two parameters:

  • An NMOS width.
  • A PMOS width.

We're assuming here that the NMOS and PMOS will have a length of 150 nanometers to simplify layout.

In this tutorial, we store all dimensions as integers in layout database units. In the SKY130 process, the database unit is a nanometer, so supplying an NMOS width of 1,200 will produce a transistor with a width of 1.2 microns.

We'll now define the struct representing our inverter:

src/lib.rs
#[derive(Block, Debug, Copy, Clone, Hash, PartialEq, Eq)]
#[substrate(io = "InverterIo")]
pub struct Inverter {
/// NMOS width.
pub nw: i64,
/// PMOS width.
pub pw: i64,
}

There are a handful of #[derive] attributes that give our struct properties that Substrate requires. For example, blocks must implement Eq so that Substrate can tell if two blocks are equivalent. It is important that Eq is implemented in a way that makes sense as Substrate uses it to determine if a block can be reused or needs to be regenerated.

Schematic Generator

We can now generate a schematic for our inverter.

Describing a schematic in Substrate requires implementing the Schematic trait, which specifies a block's schematic in a particular schema. A schema is essentially just a format for representing a schematic. In this case, we want to use the Sky130 schema as our inverter should be usable in any block generated in SKY130.

Here's how our schematic generator looks:

src/lib.rs
impl Schematic for Inverter {
type Schema = Sky130;
type NestedData = ();
fn schematic(
&self,
io: &IoNodeBundle<Self>,
cell: &mut CellBuilder<<Self as Schematic>::Schema>,
) -> Result<Self::NestedData> {
let nmos = cell.instantiate(Nfet01v8::new((self.nw, 150)));
cell.connect(io.dout, nmos.io().d);
cell.connect(io.din, nmos.io().g);
cell.connect(io.vss, nmos.io().s);
cell.connect(io.vss, nmos.io().b);

let pmos = cell.instantiate(Pfet01v8::new((self.pw, 150)));
cell.connect(io.dout, pmos.io().d);
cell.connect(io.din, pmos.io().g);
cell.connect(io.vdd, pmos.io().s);
cell.connect(io.vdd, pmos.io().b);

Ok(())
}
}

The calls to cell.instantiate(...) create two sub-blocks: an NMOS and a PMOS. Note how we pass transistor dimensions to the SKY130-specific Nfet01v8 and Pfet01v8 blocks.

The calls to cell.connect(...) connect the instantiated transistors to the ports of our inverter. For example, we connect the drain of the NMOS (nmos.io().d) to the inverter output (io.dout).

Testbench

Let's now simulate our inverter and measure the rise and fall times using ngspice.

Start by creating a new file, src/tb.rs. Add a reference to this module in src/lib.rs:

src/lib.rs
pub mod tb;

Add the following imports to src/tb.rs:

src/tb.rs
use crate::Inverter;
use crate::InverterIoKind;
use crate::SKY130_MAGIC_TECH_FILE;
use crate::SKY130_NETGEN_SETUP_FILE;

use magic_netgen::Pex;
use ngspice::blocks::{Pulse, Vsource};
use ngspice::Ngspice;
use rust_decimal::prelude::ToPrimitive;
use rust_decimal_macros::dec;
use sky130::corner::Sky130Corner;
use sky130::layout::to_gds;
use sky130::Sky130OpenSchema;
use spice::Spice;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use substrate::block::Block;
use substrate::context::Context;
use substrate::error::Result;
use substrate::schematic::{CellBuilder, ConvertSchema, Schematic};
use substrate::simulation::waveform::{EdgeDir, TimeWaveform};
use substrate::simulation::Pvt;
use substrate::types::schematic::{IoNodeBundle, Node};
use substrate::types::{Signal, TestbenchIo};

All Substrate testbenches are blocks that have schematics. The schematic specifies the simulation structure (i.e. input sources, the device being tested, etc.). As a result, creating a testbench is the same as creating a regular block except that we don't have to define an IO. All testbenches must declare their IO to be TestbenchIo, which has one port, vss, that allows simulators to identify a global ground (which they often assign to node 0).

Just like regular blocks, testbenches are usually structs containing their parameters. We'll make our testbench take two parameters:

  • A PVT corner.
  • An Inverter instance to simulate.

Here's how that looks in Rust code:

src/tb.rs
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Block)]
#[substrate(io = "TestbenchIo")]
pub struct InverterTb {
pvt: Pvt<Sky130Corner>,
dut: Inverter,
}

impl InverterTb {
#[inline]
pub fn new(pvt: Pvt<Sky130Corner>, dut: Inverter) -> Self {
Self { pvt, dut }
}
}

The Pvt<Sky130Corner> in our testbench is essentially a 3-tuple of a process corner, voltage, and temperature. The process corner here is an instance of Sky130Corner, which is defined in the sky130 plugin for Substrate.

Let's now create the schematic for our testbench. We will do this in the Ngspice schema so that the ngspice simulator plugin knows how to netlist and simulate our testbench. This should have three components:

  • A pulse input source driving the inverter input.
  • A DC voltage source supplying power to the inverter.
  • The instance of the inverter itself.

Recall that schematic generators can return data for later use. Here, we'd like to probe the output node of our inverter, so we'll set Data in HasSchematicData to be of type Node.

Here's our testbench setup:

src/tb.rs
impl Schematic for InverterTb {
type Schema = Ngspice;
type NestedData = Node;
fn schematic(
&self,
io: &IoNodeBundle<Self>,
cell: &mut CellBuilder<<Self as Schematic>::Schema>,
) -> Result<Self::NestedData> {
let inv = cell
.sub_builder::<Sky130OpenSchema>()
.instantiate(ConvertSchema::new(self.dut));

let vdd = cell.signal("vdd", Signal);
let dout = cell.signal("dout", Signal);
let vddsrc = cell.instantiate(Vsource::dc(self.pvt.voltage));
cell.connect(vddsrc.io().p, vdd);
cell.connect(vddsrc.io().n, io.vss);

let vin = cell.instantiate(Vsource::pulse(Pulse {
val0: 0.into(),
val1: self.pvt.voltage,
delay: Some(dec!(0.1e-9)),
width: Some(dec!(1e-9)),
fall: Some(dec!(1e-12)),
rise: Some(dec!(1e-12)),
period: None,
num_pulses: Some(dec!(1)),
}));
cell.connect(inv.io().din, vin.io().p);
cell.connect(vin.io().n, io.vss);

cell.connect(inv.io().vdd, vdd);
cell.connect(inv.io().vss, io.vss);
cell.connect(inv.io().dout, dout);

Ok(dout)
}
}

We create two ngspice-specific Vsources (one for VDD, the other as an input stimulus). We also instantiate our inverter and connect everything up. The cell.signal(...) calls create intermediate nodes. Creating them isn't strictly necessary (we could connect inv.io().vdd directly to vddsrc.io().p, for example), but they can sometimes improve readability of your code and of generated schematics. Finally, we return the node that we want to probe.

Design

Writing a design script

Let's use the code we've written to write a script that automatically sizes our inverter for equal rise and fall times.

We'll assume that we have a fixed NMOS width and channel length and a set of possible PMOS widths to sweep over.

Here's our implementation:

src/tb.rs
/// Designs an inverter for balanced pull-up and pull-down times.
///
/// The NMOS width is kept constant; the PMOS width is swept over
/// the given range.
pub struct InverterDesign {
/// The fixed NMOS width.
pub nw: i64,
/// The set of PMOS widths to sweep.
pub pw: Vec<i64>,
}

impl InverterDesign {
pub fn run(&self, ctx: &mut Context, work_dir: impl AsRef<Path>) -> Inverter {
let work_dir = work_dir.as_ref();
let pvt = Pvt::new(Sky130Corner::Tt, dec!(1.8), dec!(25));

let mut opt = None;
for pw in self.pw.iter().copied() {
let dut = Inverter { nw: self.nw, pw };
let tb = InverterTb::new(pvt, dut);
let sim_dir = work_dir.join(format!("pw{pw}"));
let sim = ctx
.get_sim_controller(tb, sim_dir)
.expect("failed to create sim controller");
let mut opts = ngspice::Options::default();
sim.set_option(pvt.corner, &mut opts);
let output = sim
.simulate(
opts,
ngspice::tran::Tran {
stop: dec!(2e-9),
step: dec!(1e-11),
..Default::default()
},
)
.expect("failed to run simulation");

let vout = output.as_ref();
let mut trans = vout.transitions(
0.2 * pvt.voltage.to_f64().unwrap(),
0.8 * pvt.voltage.to_f64().unwrap(),
);
// The input waveform has a low -> high, then a high -> low transition.
// So the first transition of the inverter output is high -> low.
// The duration of this transition is the inverter fall time.
let falling_transition = trans.next().unwrap();
assert_eq!(falling_transition.dir(), EdgeDir::Falling);
let tf = falling_transition.duration();
let rising_transition = trans.next().unwrap();
assert_eq!(rising_transition.dir(), EdgeDir::Rising);
let tr = rising_transition.duration();

println!("Simulating with pw = {pw} gave tf = {}, tr = {}", tf, tr);
let diff = (tr - tf).abs();
if let Some((pdiff, _)) = opt {
if diff < pdiff {
opt = Some((diff, dut));
}
} else {
opt = Some((diff, dut));
}
}

opt.unwrap().1
}
}

We sweep over possible PMOS widths. For each width, we create a new testbench instance and tell Substrate to simulate it. We use the WaveformRef API to look for 20-80% transitions, and capture their duration. Finally, we keep track of (and eventually return) the inverter instance that minimizes the absolute difference between the rise and fall times.

Running the script

Let's now run the script we wrote. We must first create a Substrate context that stores all information relevant to Substrate. This includes the tools and PDKs you've set up, all blocks that have been generated, cached computations, and more. We will put this in src/lib.rs.

src/lib.rs
/// Create a new Substrate context for the SKY130 open PDK.
///
/// Sets the PDK root to the value of the `SKY130_OPEN_PDK_ROOT`
/// environment variable and installs ngspice with default configuration.
///
/// # Panics
///
/// Panics if the `SKY130_OPEN_PDK_ROOT` environment variable is not set,
/// or if the value of that variable is not a valid UTF-8 string.
pub fn sky130_open_ctx() -> Context {
let pdk_root = std::env::var("SKY130_OPEN_PDK_ROOT")
.expect("the SKY130_OPEN_PDK_ROOT environment variable must be set");
Context::builder()
.install(ngspice::Ngspice::default())
.install(Sky130::open(pdk_root))
.build()
}

We can then write a Rust unit test to run our design script:

src/tb.rs
#[cfg(test)]
mod tests {
use crate::sky130_open_ctx;

use super::*;

#[test]
pub fn design_inverter_open() {
let work_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/design_inverter_open");
let mut ctx = sky130_open_ctx();
let script = InverterDesign {
nw: 1_200,
pw: (3_000..=5_000).step_by(400).collect(),
};
let inv = script.run(&mut ctx, work_dir);
println!("Designed inverter:\n{:#?}", inv);
}
}

To run the test, run

cargo test design_inverter -- --show-output

If all goes well, the test above should print the inverter dimensions with the minimum rise/fall time difference.

Layout

Generator

The next step is to generate an inverter layout.

Start by creating a new file, src/layout.rs. Add a reference to this module in src/lib.rs:

src/lib.rs
pub mod layout;

In this file, add the following imports:

src/layout.rs
use layir::Shape;
use sky130::{
layers::Sky130Layer,
layout::{NtapTile, PtapTile},
mos::{GateDir, MosLength, NmosTile, PmosTile},
Sky130,
};
use substrate::{
error::Result,
geometry::{
align::{AlignMode, AlignRectMut},
bbox::Bbox,
prelude::Transformation,
rect::Rect,
span::Span,
transform::TransformMut,
union::BoundingUnion,
},
layout::{CellBuilder, CellBundle, Layout},
types::{
codegen::{PortGeometryBundle, View},
layout::PortGeometry,
},
};

use crate::{Inverter, InverterIo};

Describing a layout in Substrate requires implementing the Layout trait. Let's start implementing it now:

src/layout.rs
impl Layout for Inverter {
type Schema = Sky130;
type Bundle = View<InverterIo, PortGeometryBundle<Sky130>>;
type Data = ();
fn layout(&self, cell: &mut CellBuilder<Self::Schema>) -> Result<(Self::Bundle, Self::Data)> {
// TODO: Implement layout generator.
}
}

Substrate layouts are specified in a particular schema. In the context of layout, a schema is essentially just a layer stack. We also have to specify Bundle, which describes the geometry associated with the inverter's IO, and Data, which describes any nested geometry that we might want to pass up to parent cells.

In this case, we want to use the Sky130 schema as our inverter uses the SKY130 layer stack. We also choose to use the layout bundle that Substrate autogenerates in its #[derive(Io)] macro, though more advanced users can choose to implement a custom layout bundle that more accurately describes the IO geometry. Substrate's default layout bundle consists of PortGeometrys, which are essentially just an arbitrary collection of shapes. We don't have any data we want to propagate, so we specify type Data = ();.

In fn layout, let's start writing our generator. We can begin by generating our inverter's NMOS and PMOS using generators provided by Substrate's SKY130 plugin:

src/layout.rs
let mut nmos =
cell.generate(NmosTile::new(self.nw, MosLength::L150, 1).with_gate_dir(GateDir::Left));
let mut pmos =
cell.generate(PmosTile::new(self.pw, MosLength::L150, 1).with_gate_dir(GateDir::Left));

Using Substrate's transformation API, we can flip the NMOS and place the PMOS above it:

src/layout.rs
nmos.transform_mut(Transformation::reflect_vert());
pmos.align_mut(AlignMode::Above, pmos.bbox_rect(), nmos.bbox_rect(), 600);

The drains and gates of the two transistors should now be aligned, so we can simply compute the bounding box of the two drains, then the two gates, and draw the resulting rectangles on li1:

src/layout.rs
let dout = Shape::new(
Sky130Layer::Li1,
nmos.io().sd[1]
.primary
.bounding_union(&pmos.io().sd[1].primary),
);
cell.draw(dout.clone())?;

let din = Shape::new(
Sky130Layer::Li1,
nmos.io().g[0]
.primary
.bounding_union(&pmos.io().g[0].primary),
);
cell.draw(din.clone())?;

To get our layout LVS clean, we will need to add taps. We can add an n-well tap above the PMOS and a substrate tap below the NMOS. We can then use li1 rectangles to connect the taps to the sources of the NMOS and PMOS.

src/layout.rs
let mut ntap = cell.generate(NtapTile::new(2, 2));
ntap.align_mut(AlignMode::Above, ntap.bbox_rect(), pmos.bbox_rect(), 0);
ntap.align_mut(
AlignMode::CenterHorizontal,
ntap.bbox_rect(),
pmos.bbox_rect(),
0,
);

let mut ptap = cell.generate(PtapTile::new(2, 2));
ptap.align_mut(AlignMode::Beneath, ptap.bbox_rect(), nmos.bbox_rect(), -20);
ptap.align_mut(
AlignMode::CenterHorizontal,
ptap.bbox_rect(),
nmos.bbox_rect(),
0,
);

let vdd = ntap.io().vpb.primary.clone();
let vss = ptap.io().vnb.primary.clone();

let nmos_s = nmos.io().sd[0].bbox_rect();
let vss_conn = Rect::from_spans(
nmos_s.hspan(),
Span::new(vss.bbox_rect().bot(), nmos_s.top()),
);
cell.draw(Shape::new(Sky130Layer::Li1, vss_conn))?;

let pmos_s = pmos.io().sd[0].bbox_rect();
let vdd_conn = Rect::from_spans(
pmos_s.hspan(),
Span::new(pmos_s.bot(), vdd.bbox_rect().top()),
);
cell.draw(Shape::new(Sky130Layer::Li1, vdd_conn))?;

Now that all the connections have been finalized, we can draw all of our instances and return the appropriate geometry to specify where our inverter's pins are located.

src/layout.rs
cell.draw(ntap)?;
cell.draw(ptap)?;
cell.draw(nmos)?;
cell.draw(pmos)?;

Ok((
CellBundle::<Inverter> {
vdd: PortGeometry::new(vdd),
vss: PortGeometry::new(vss),
din: PortGeometry::new(din),
dout: PortGeometry::new(dout),
},
(),
))

The final layout generator should look like this:

src/layout.rs
impl Layout for Inverter {
type Schema = Sky130;
type Bundle = View<InverterIo, PortGeometryBundle<Sky130>>;
type Data = ();
fn layout(&self, cell: &mut CellBuilder<Self::Schema>) -> Result<(Self::Bundle, Self::Data)> {
let mut nmos =
cell.generate(NmosTile::new(self.nw, MosLength::L150, 1).with_gate_dir(GateDir::Left));
let mut pmos =
cell.generate(PmosTile::new(self.pw, MosLength::L150, 1).with_gate_dir(GateDir::Left));

nmos.transform_mut(Transformation::reflect_vert());
pmos.align_mut(AlignMode::Above, pmos.bbox_rect(), nmos.bbox_rect(), 600);

let dout = Shape::new(
Sky130Layer::Li1,
nmos.io().sd[1]
.primary
.bounding_union(&pmos.io().sd[1].primary),
);
cell.draw(dout.clone())?;

let din = Shape::new(
Sky130Layer::Li1,
nmos.io().g[0]
.primary
.bounding_union(&pmos.io().g[0].primary),
);
cell.draw(din.clone())?;

let mut ntap = cell.generate(NtapTile::new(2, 2));
ntap.align_mut(AlignMode::Above, ntap.bbox_rect(), pmos.bbox_rect(), 0);
ntap.align_mut(
AlignMode::CenterHorizontal,
ntap.bbox_rect(),
pmos.bbox_rect(),
0,
);

let mut ptap = cell.generate(PtapTile::new(2, 2));
ptap.align_mut(AlignMode::Beneath, ptap.bbox_rect(), nmos.bbox_rect(), -20);
ptap.align_mut(
AlignMode::CenterHorizontal,
ptap.bbox_rect(),
nmos.bbox_rect(),
0,
);

let vdd = ntap.io().vpb.primary.clone();
let vss = ptap.io().vnb.primary.clone();

let nmos_s = nmos.io().sd[0].bbox_rect();
let vss_conn = Rect::from_spans(
nmos_s.hspan(),
Span::new(vss.bbox_rect().bot(), nmos_s.top()),
);
cell.draw(Shape::new(Sky130Layer::Li1, vss_conn))?;

let pmos_s = pmos.io().sd[0].bbox_rect();
let vdd_conn = Rect::from_spans(
pmos_s.hspan(),
Span::new(pmos_s.bot(), vdd.bbox_rect().top()),
);
cell.draw(Shape::new(Sky130Layer::Li1, vdd_conn))?;

cell.draw(ntap)?;
cell.draw(ptap)?;
cell.draw(nmos)?;
cell.draw(pmos)?;

Ok((
CellBundle::<Inverter> {
vdd: PortGeometry::new(vdd),
vss: PortGeometry::new(vss),
din: PortGeometry::new(din),
dout: PortGeometry::new(dout),
},
(),
))
}
}

Verification

We can now run DRC and LVS using magic and netgen by writing a cargo test in src/layout.rs:

src/layout.rs
#[cfg(test)]
mod tests {
use std::{path::PathBuf, sync::Arc};

use magic::drc::{run_drc, DrcParams};
use sky130::{layout::to_gds, Sky130OpenSchema};
use substrate::{block::Block, schematic::ConvertSchema};

use crate::{sky130_open_ctx, Inverter, SKY130_MAGIC_TECH_FILE, SKY130_NETGEN_SETUP_FILE};

#[test]
fn inverter_layout_open() {
use magic_netgen::LvsParams;
let work_dir = PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/inverter_layout_open"
));
let layout_path = work_dir.join("layout.gds");
let ctx = sky130_open_ctx();

let dut = Inverter {
nw: 1_200,
pw: 2_400,
};

ctx.write_layout(dut, to_gds, &layout_path).unwrap();

// Run DRC.
let drc_dir = work_dir.join("drc");
let drc_report_path = drc_dir.join("drc_results.rpt");
let data = run_drc(&DrcParams {
work_dir: &drc_dir,
gds_path: &layout_path,
cell_name: &dut.name(),
tech_file_path: &PathBuf::from(SKY130_MAGIC_TECH_FILE),
drc_report_path: &drc_report_path,
})
.expect("failed to run drc");

assert_eq!(data.rule_checks.len(), 0, "layout was not DRC clean");

// Run LVS.
let lvs_dir = work_dir.join("lvs");
let output = magic_netgen::run_lvs(LvsParams {
schematic: Arc::new(ConvertSchema::new(
ConvertSchema::<_, Sky130OpenSchema>::new(dut),
)),
ctx: ctx.clone(),
gds_path: layout_path,
work_dir: lvs_dir,
layout_cell_name: dut.name(),
magic_tech_file_path: PathBuf::from(SKY130_MAGIC_TECH_FILE),
netgen_setup_file_path: PathBuf::from(SKY130_NETGEN_SETUP_FILE),
})
.expect("failed to run lvs");

assert!(output.matches, "layout does not match netlist");
}
}

To run the test, run

cargo test inverter_layout -- --show-output

Post-extraction design

Now that we have an LVS-clean layout and schematic, we can run our design script using post-extraction simulations.

First, let's update our earlier testbench to use either the pre-extraction or post-extraction netlist.

src/tb.rs
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Block)]
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub enum InverterDut {
Schematic(Inverter),
Extracted(Pex<ConvertSchema<ConvertSchema<Inverter, Sky130OpenSchema>, Spice>>),
}

#[derive(Clone, Debug, Eq, PartialEq, Hash, Block)]
#[substrate(io = "TestbenchIo")]
pub struct InverterTb {
pvt: Pvt<Sky130Corner>,
dut: Inverter,
dut: InverterDut,
}

impl InverterTb {
#[inline]
pub fn new(pvt: Pvt<Sky130Corner>, dut: Inverter) -> Self {
Self { pvt, dut }
pub fn new(pvt: Pvt<Sky130Corner>, dut: impl Into<InverterDut>) -> Self {
Self {
pvt,
dut: dut.into(),
}
}
}

impl Schematic for InverterTb {
type Schema = Ngspice;
type NestedData = Node;
fn schematic(
&self,
io: &IoNodeBundle<Self>,
cell: &mut CellBuilder<<Self as Schematic>::Schema>,
) -> Result<Self::NestedData> {
let inv = cell
.sub_builder::<Sky130OpenSchema>()
.instantiate(ConvertSchema::new(self.dut));
let invio = cell.signal(
"dut",
InverterIoKind {
vdd: Signal,
vss: Signal,
din: Signal,
dout: Signal,
},
);

match self.dut.clone() {
InverterDut::Schematic(inv) => {
cell.sub_builder::<Sky130OpenSchema>()
.instantiate_connected_named(ConvertSchema::new(inv), &invio, "inverter");
}
InverterDut::Extracted(inv) => {
cell.sub_builder::<Spice>()
.instantiate_connected_named(inv, &invio, "inverter");
}
};

let vdd = cell.signal("vdd", Signal);
let dout = cell.signal("dout", Signal);
let vddsrc = cell.instantiate(Vsource::dc(self.pvt.voltage));
cell.connect(vddsrc.io().p, vdd);
cell.connect(vddsrc.io().n, io.vss);

let vin = cell.instantiate(Vsource::pulse(Pulse {
val0: 0.into(),
val1: self.pvt.voltage,
delay: Some(dec!(0.1e-9)),
width: Some(dec!(1e-9)),
fall: Some(dec!(1e-12)),
rise: Some(dec!(1e-12)),
period: None,
num_pulses: Some(dec!(1)),
}));
cell.connect(inv.io().din, vin.io().p);
cell.connect(invio.din, vin.io().p);
cell.connect(vin.io().n, io.vss);

cell.connect(inv.io().vdd, vdd);
cell.connect(inv.io().vss, io.vss);
cell.connect(inv.io().dout, dout);
cell.connect(invio.vdd, vdd);
cell.connect(invio.vss, io.vss);
cell.connect(invio.dout, dout);

Ok(dout)
}
}

Then, we can update our design script to run either version of the testbench:

src/tb.rs
/// Designs an inverter for balanced pull-up and pull-down times.
///
/// The NMOS width is kept constant; the PMOS width is swept over
/// the given range.
pub struct InverterDesign {
/// The fixed NMOS width.
pub nw: i64,
/// The set of PMOS widths to sweep.
pub pw: Vec<i64>,
/// Whether or not to run extracted simulations.
pub extracted: bool,
}

impl InverterDesign {
pub fn run(&self, ctx: &mut Context, work_dir: impl AsRef<Path>) -> Inverter {
let work_dir = work_dir.as_ref();
let pvt = Pvt::new(Sky130Corner::Tt, dec!(1.8), dec!(25));

let mut opt = None;
for pw in self.pw.iter().copied() {
let dut = Inverter { nw: self.nw, pw };
let tb = InverterTb::new(pvt, dut);
let inverter = if self.extracted {
let work_dir = work_dir.join(format!("pw{pw}"));
let layout_path = work_dir.join("layout.gds");
ctx.write_layout(dut, to_gds, &layout_path)
.expect("failed to write layout");
InverterDut::Extracted(Pex {
schematic: Arc::new(ConvertSchema::new(ConvertSchema::new(dut))),
gds_path: work_dir.join("layout.gds"),
layout_cell_name: dut.name(),
work_dir,
magic_tech_file_path: PathBuf::from(SKY130_MAGIC_TECH_FILE),
netgen_setup_file_path: PathBuf::from(SKY130_NETGEN_SETUP_FILE),
})
} else {
InverterDut::Schematic(dut)
};
let tb = InverterTb::new(pvt, inverter);
let sim_dir = work_dir.join(format!("pw{pw}"));
let sim = ctx
.get_sim_controller(tb, sim_dir)
.expect("failed to create sim controller");
let mut opts = ngspice::Options::default();
sim.set_option(pvt.corner, &mut opts);
let output = sim
.simulate(
opts,
ngspice::tran::Tran {
stop: dec!(2e-9),
step: dec!(1e-11),
..Default::default()
},
)
.expect("failed to run simulation");

let vout = output.as_ref();
let mut trans = vout.transitions(
0.2 * pvt.voltage.to_f64().unwrap(),
0.8 * pvt.voltage.to_f64().unwrap(),
);
// The input waveform has a low -> high, then a high -> low transition.
// So the first transition of the inverter output is high -> low.
// The duration of this transition is the inverter fall time.
let falling_transition = trans.next().unwrap();
assert_eq!(falling_transition.dir(), EdgeDir::Falling);
let tf = falling_transition.duration();
let rising_transition = trans.next().unwrap();
assert_eq!(rising_transition.dir(), EdgeDir::Rising);
let tr = rising_transition.duration();

println!("Simulating with pw = {pw} gave tf = {}, tr = {}", tf, tr);
let diff = (tr - tf).abs();
if let Some((pdiff, _)) = opt {
if diff < pdiff {
opt = Some((diff, dut));
}
} else {
opt = Some((diff, dut));
}
}

opt.unwrap().1
}
}

Finally, we can split our original test into two tests (one post-extraction and one pre-extraction):

src/tb.rs
#[cfg(test)]
mod tests {
use crate::sky130_open_ctx;

use super::*;

#[test]
pub fn design_inverter_open() {
let work_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/design_inverter_open");
pub fn design_inverter_extracted_open() {
let work_dir = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/design_inverter_extracted_open"
);
let mut ctx = sky130_open_ctx();
let script = InverterDesign {
nw: 1_200,
pw: (3_000..=5_000).step_by(400).collect(),
extracted: true,
};
let inv = script.run(&mut ctx, work_dir);
println!("Designed inverter:\n{:#?}", inv);
}

#[test]
pub fn design_inverter_schematic_open() {
let work_dir = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/design_inverter_schematic_open"
);
let mut ctx = sky130_open_ctx();
let script = InverterDesign {
nw: 1_200,
pw: (3_000..=5_000).step_by(400).collect(),
extracted: false,
};
let inv = script.run(&mut ctx, work_dir);
println!("Designed inverter:\n{:#?}", inv);
}
}

To run the test, run

cargo test design_inverter_extracted -- --show-output

If all goes well, the test above should print the inverter dimensions with the minimum rise/fall time difference.

Conclusion

You should now be well-equipped to start writing your own schematic generators in Substrate. A full, runnable example for this tutorial is available here.