Skip to main content

Designing an inverter

In this tutorial, we'll design and simulate a schematic-level inverter in the Skywater 130nm process to showcase some of the capabilities of Substrate's analog simulation interface. We'll also go into some more detail about what the code you're writing is actually doing.

Setup

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.

Next, create a new Rust project:

cargo new --lib sky130_inverter && cd sky130_inverter

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

Cargo.toml
[dependencies]
substrate = { version = "0.8.1", registry = "substrate" }
ngspice = { version = "0.3.1", registry = "substrate" }
sky130pdk = { version = "0.8.1", registry = "substrate" }

serde = { version = "1", features = ["derive"] }
rust_decimal = "1.30"
rust_decimal_macros = "1.30"

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 serde::{Deserialize, Serialize};
use sky130pdk::mos::{Nfet01v8, Pfet01v8};
use sky130pdk::Sky130Pdk;
use substrate::block::Block;
use substrate::io::{InOut, Input, Output, Signal};
use substrate::io::{Io, SchematicType};
use substrate::schematic::{CellBuilder, ExportsNestedData, Schematic};

Simulators

This tutorial will demonstrate how to invoke both ngspice and Spectre from Substrate to run transient simulations. You can choose to use whichever simulator you would like, but make sure to install the appropriate simulator before running your Rust code. We recommend an ngspice version of at least 41.

SKY130 PDK

If you would like to simulate using ngspice, 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

Also, ensure that the SKY130_OPEN_PDK_ROOT environment variable points to the location of the repo you just cloned.

If you would like to use Spectre, you will also need to ensure that the SKY130_COMMERCIAL_PDK_ROOT environment variable points to an installation of the commercial SKY130 PDK.

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 three parameters:

  • An NMOS width.
  • A PMOS width.
  • A channel length.

We're assuming here that the NMOS and PMOS will have the same length.

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(Serialize, Deserialize, Block, Debug, Copy, Clone, Hash, PartialEq, Eq)]
#[substrate(io = "InverterIo")]
pub struct Inverter {
/// NMOS width.
pub nw: i64,
/// PMOS width.
pub pw: i64,
/// Channel length.
pub lch: 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 two traits:

  • ExportsNestedData declares what nested data a block exposes, such as internal nodes or instances. For now, we don't want to expose anything, so we'll set the associated NestedData type to Rust's unit type, ().
  • Schematic specifies the actual schematic in a particular schema. A schema is essentially just a format for representing a schematic. In this case, we want to use the Sky130Pdk schema as our inverter should be usable in any block generated in SKY130.

Here's how our schematic generator looks:

src/lib.rs
impl ExportsNestedData for Inverter {
type NestedData = ();
}

impl Schematic<Sky130Pdk> for Inverter {
fn schematic(
&self,
io: &<<Self as Block>::Io as SchematicType>::Bundle,
cell: &mut CellBuilder<Sky130Pdk>,
) -> substrate::error::Result<Self::NestedData> {
let nmos = cell.instantiate(Nfet01v8::new((self.nw, self.lch)));
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, self.lch)));
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. For now, we'll use ngspice as our simulator. Later, we'll add support for Spectre.

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 super::Inverter;

use ngspice::tran::Tran;
use ngspice::Ngspice;
use rust_decimal::prelude::ToPrimitive;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use sky130pdk::corner::Sky130Corner;
use sky130pdk::Sky130Pdk;
use std::path::Path;
use substrate::block::Block;
use substrate::context::{Context, PdkContext};
use substrate::io::{Node, SchematicType, Signal, TestbenchIo};
use substrate::pdk::corner::Pvt;
use substrate::schematic::{Cell, CellBuilder, ExportsNestedData, Schematic};
use substrate::simulation::data::{tran, FromSaved, Save, SaveTb};
use substrate::simulation::waveform::{EdgeDir, TimeWaveform, WaveformRef};
use substrate::simulation::{SimulationContext, Simulator, Testbench};

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, Serialize, Deserialize, 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 sky130pdk 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 ExportsNestedData for InverterTb {
type NestedData = Node;
}

impl Schematic<Ngspice> for InverterTb {
fn schematic(
&self,
io: &<<Self as Block>::Io as SchematicType>::Bundle,
cell: &mut CellBuilder<Ngspice>,
) -> substrate::error::Result<Self::NestedData> {
let inv = cell.sub_builder::<Sky130Pdk>().instantiate(self.dut);

let vdd = cell.signal("vdd", Signal);
let dout = cell.signal("dout", Signal);

let vddsrc = cell.instantiate(ngspice::blocks::Vsource::dc(self.pvt.voltage));
cell.connect(vddsrc.io().p, vdd);
cell.connect(vddsrc.io().n, io.vss);

let vin = cell.instantiate(ngspice::blocks::Vsource::pulse(ngspice::blocks::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 Spectre-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.

The final thing we must do is describe the data produced by our testbench. Here, we want to measure 20-80% rise and fall times.

To make our testbench actually a testbench, we must implement the Testbench trait. The run method of this trait allows us to configure simulator options (eg. error tolerances) and set up analyses (AC, DC, transient, etc.).

This is how our testbench looks:

src/tb.rs
#[derive(Debug, Clone, Serialize, Deserialize, FromSaved)]
pub struct Vout {
t: tran::Time,
v: tran::Voltage,
}

impl SaveTb<Ngspice, ngspice::tran::Tran, Vout> for InverterTb {
fn save_tb(
ctx: &SimulationContext<Ngspice>,
cell: &Cell<Self>,
opts: &mut <Ngspice as Simulator>::Options,
) -> <Vout as FromSaved<Ngspice, Tran>>::SavedKey {
VoutSavedKey {
t: tran::Time::save(ctx, (), opts),
v: tran::Voltage::save(ctx, cell.data(), opts),
}
}
}

impl Testbench<Ngspice> for InverterTb {
type Output = Vout;
fn run(&self, sim: substrate::simulation::SimController<Ngspice, Self>) -> Self::Output {
let mut opts = ngspice::Options::default();
sim.set_option(self.pvt.corner, &mut opts);
sim.simulate(
opts,
ngspice::tran::Tran {
stop: dec!(2e-9),
step: dec!(1e-11),
..Default::default()
},
)
.expect("failed to run simulation")
}
}

We define Vout as a receiver for data saved during simulation. We then tell Substrate what data we want to save from our testbench by implementing the SaveTb trait.

Design

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>,
/// The transistor channel length.
pub lch: i64,
}

impl InverterDesign {
pub fn run<S: Simulator>(
&self,
ctx: &mut PdkContext<Sky130Pdk>,
work_dir: impl AsRef<Path>,
) -> Inverter
where
InverterTb: Testbench<S, Output = Vout>,
{
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,
lch: self.lch,
};
let tb = InverterTb::new(pvt, dut);
let output = ctx
.simulate(tb, work_dir.join(format!("pw{pw}")))
.expect("failed to run simulation");

let vout = WaveformRef::new(&output.t, &output.v);
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.

You may also notice that the run function is generic over the simulator S, requiring only that the InverterTb implements Testbench and yields Vout as an output. This allows to support additional simulators simply by implementing Testbench for each simulator we would like to support.

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 you've set up, the current PDK, all blocks that have been generated, cached computations, and more.

src/tb.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 Spectre 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() -> PdkContext<Sky130Pdk> {
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::default())
.install(Sky130Pdk::open(pdk_root))
.build()
.with_pdk()
}

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

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

#[test]
pub fn design_inverter_ngspice() {
let work_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/design_inverter_ngspice");
let mut ctx = sky130_open_ctx();
let script = InverterDesign {
nw: 1_200,
pw: (3_000..=5_000).step_by(200).collect(),
lch: 150,
};

let inv = script.run::<Ngspice>(&mut ctx, work_dir);
println!("Designed inverter:\n{:#?}", inv);
}
}

To run the test, run

cargo test design_inverter_ngspice -- --show-output

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

Adding Spectre support

Because we designed in multi-simulator support from the beginning, adding Spectre support is simply a matter of defining a Spectre-specific testbench schematic, running the appropriate Spectre simulation, and returning the data in the appropriate format.

To add Spectre support, we can simply add the following code:

src/tb.rs
#[cfg(feature = "spectre")]
pub mod spectre_support {
use super::*;
use spectre::Spectre;

impl Schematic<Spectre> for InverterTb {
fn schematic(
&self,
io: &<<Self as Block>::Io as SchematicType>::Bundle,
cell: &mut CellBuilder<Spectre>,
) -> substrate::error::Result<Self::NestedData> {
let inv = cell.sub_builder::<Sky130Pdk>().instantiate(self.dut);

let vdd = cell.signal("vdd", Signal);
let dout = cell.signal("dout", Signal);

let vddsrc = cell.instantiate(spectre::blocks::Vsource::dc(self.pvt.voltage));
cell.connect(vddsrc.io().p, vdd);
cell.connect(vddsrc.io().n, io.vss);

let vin = cell.instantiate(spectre::blocks::Vsource::pulse(spectre::blocks::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,
}));
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)
}
}

impl substrate::simulation::data::SaveTb<Spectre, spectre::tran::Tran, Vout> for InverterTb {
fn save_tb(
ctx: &SimulationContext<Spectre>,
cell: &Cell<Self>,
opts: &mut <Spectre as Simulator>::Options,
) -> <Vout as FromSaved<Spectre, spectre::tran::Tran>>::SavedKey {
VoutSavedKey {
t: tran::Time::save(ctx, (), opts),
v: tran::Voltage::save(ctx, cell.data(), opts),
}
}
}

impl Testbench<Spectre> for InverterTb {
type Output = Vout;
fn run(&self, sim: substrate::simulation::SimController<Spectre, Self>) -> Self::Output {
let mut opts = spectre::Options::default();
sim.set_option(self.pvt.corner, &mut opts);
sim.simulate(
opts,
spectre::tran::Tran {
stop: dec!(2e-9),
errpreset: Some(spectre::ErrPreset::Conservative),
..Default::default()
},
)
.expect("failed to run simulation")
}
}

/// Create a new Substrate context for the SKY130 commercial PDK.
///
/// Sets the PDK root to the value of the `SKY130_COMMERCIAL_PDK_ROOT`
/// environment variable and installs Spectre with default configuration.
///
/// # Panics
///
/// Panics if the `SKY130_COMMERCIAL_PDK_ROOT` environment variable is not set,
/// or if the value of that variable is not a valid UTF-8 string.
pub fn sky130_commercial_ctx() -> PdkContext<Sky130Pdk> {
let pdk_root = std::env::var("SKY130_COMMERCIAL_PDK_ROOT")
.expect("the SKY130_COMMERCIAL_PDK_ROOT environment variable must be set");
Context::builder()
.install(Spectre::default())
.install(Sky130Pdk::commercial(pdk_root))
.build()
.with_pdk()
}

#[cfg(test)]
mod tests {
use super::*;

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

Before running the new Spectre test, ensure that the SKY130_COMMERCIAL_PDK_ROOT environment variable points to your installation of the SKY130 commercial PDK. Also ensure that you have correctly set any environment variables needed by Spectre.

To run the test, run

cargo test design_inverter_spectre --features spectre -- --show-output

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.