Skip to main content

Schematic cell intermediate representation (SCIR)

SCIR is an intermediate representation used by Substrate to allow schematic portability between netlisters and simulators. This section will cover where SCIR is used in Substrate's API and how it interfaces with plugins for tools like ngspice and Spectre.

Overview

Basic objects

The table below provides high-level definitions of basic SCIR objects.

TermDefinition
InstanceAn instantiation of another SCIR object.
CellA collection of interconnected SCIR instances, corresponding to a SUBCKT in SPICE.
SignalA node or bus within a SCIR cell.
SliceA subset of a SCIR signal.
PortA SCIR signal that has been exposed to the instantiators of its parent SCIR cell.
ConnectionA SCIR signal from a parent cell connected to a port of child SCIR instance.
LibraryA set of cells, of which one may be designated as the "top" cell.

Take the following SPICE circuit as an example:

* CMOS buffer

.subckt buffer din dout vdd vss
X0 din dinb vdd vss inverter
X1 dinb dout vdd vss inverter
.ends

.subckt inverter din dout vdd vss
X0 dout din vss vss sky130_fd_pr__nfet_01v8 w=2 l=0.15
X1 dout din vdd vdd sky130_fd_pr__pfet_01v8 w=4 l=0.15
.ends

This circuit could conceptually be parsed to a SCIR library containing two cells named buffer and inverter. The buffer cell would contain 5 signals (din, dinb, dout, vdd, and vss), 4 of which are exposed as ports, as well as two instances of the inverter cell. The dinb signal is connected to the dout port of the first inverter instance and the din port of the second inverter instance.

Primitives

Since SCIR cells are simply collections of SCIR instances, SCIR instances must be able to instantiate more than just cells since we would otherwise only be able to represent an empty hierarchy. As such, SCIR allows users to define a set of arbitrary primitives that can be instantiated within SCIR cells. These primitives are opaque to SCIR and contain any data that the user sees fit.

In the above buffer example, the sky130_fd_pr__nfet_01v8 and sky130_fd_pr__pfet_01v8 are a type of primitive called a "raw instance" that allow a SCIR instance to reference an external SPICE model and provide its parameters. In Rust, the primitive definition looks like this:

enum Primitive {
// ...

/// A raw instance with an associated cell.
RawInstance {
/// The ordered ports of the instance.
ports: Vec<ArcStr>,
/// The associated cell.
cell: ArcStr,
/// Parameters associated with the raw instance.
params: HashMap<ArcStr, ParamValue>,
}

// ...
}

While SCIR cells and instances do not have parameters, parameters can be injected using SCIR primitives as shown above.

Schemas

SCIR schemas are simply sets of primitives that can be used to describe circuits. For example, the SPICE schema consists of MOSFET, resistor, capacitor, raw instance, and other primitives that can describe any circuit that can be netlisted to SPICE. Similarly, SKY130 is also a schema since it has its own set of primitive MOSFETs and resistors that can be fabricated in the SKY130 process.

When you write a schema, you can also specify which schemas it can be converted to. This allows you to elegantly encode portability in the type system. Since the SKY130 PDK supports simulations in ngspice and Spectre, we can declare that the SKY130 schema can be converted to both the ngspice and Spectre schemas. The specifics of this procedure will be detailed later on in this section.

Relationship to Substrate

Generators in Substrate produce cells that can be exported to SCIR. Substrate's APIs allow defining schematics in different schemas, which encodes generator compatibility in the Rust type system. For example, a Substrate block with a schematic in the Sky130Pdk schema can be included in a Spectre testbench's schematic in the Spectre schema, but cannot be included in an HSPICE testbench since the Sky130Pdk schema is not compatible with the HSPICE schema. Similarly, you cannot instantiate a SKY130 schematic in a schematic in a different process node (e.g. GF180).

While the user generally interfaces with Substrate's block API, simulator and netlister plugins interface with SCIR. This allows backend tools to abstract away Substrate's internal representation of cells. For simulation and netlisting, Substrate will export the appropriate cell to SCIR and pass the generated SCIR library to the necessary plugin for processing.

Technical Details

Schemas

Every SCIR library requires an underlying schema that implements the Schema trait.

pub trait Schema {
type Primitive: Primitive;
}

A SCIR schema has an associated primitive type that describes available primitives for representing objects that cannot be represented directly in SCIR. As an example, the most basic schema, NoSchema, has a primitive type of NoPrimitive that cannot be instantiated — as such, any SCIR library with this schema will have no primitives.

The relationship between schemas is encoded via the FromSchema trait, which describes how one schema is converted to another.

pub trait FromSchema<S: Schema>: Schema {
type Error;

// Required methods
fn convert_primitive(
primitive: <S as Schema>::Primitive
) -> Result<<Self as Schema>::Primitive, Self::Error>;
fn convert_instance(
instance: &mut Instance,
primitive: &<S as Schema>::Primitive
) -> Result<(), Self::Error>;
}

Schemas that are inter-convertible must have a 1-to-1 correspondence between their primitives, as shown by the signature of fn convert_primitive(...). The instance conversion function, fn convert_instance(...), allows you to modify the connections of a SCIR instance that is associated with a primitive to correctly connect to the ports of the primitive in the new schema.

The FromSchema trait is particularly important since it allows for schematics to be made simulator and netlist portable, and potentially even process portable, as we will see later.

Libraries

Once we have a schema, we can start creating a SCIR library by instantiating a LibraryBuilder. To create a library with the StringSchema schema, whose primitives are arbitrary ArcStrs, we write the following:

let mut lib = LibraryBuilder::<StringSchema>::new();

SCIR libraries are collections of SCIR cells and primitives. We can create a new cell and add it to our library:

let empty_cell = Cell::new("empty");
let empty_cell_id = lib.add_cell(empty_cell);

We can also add primitives to the library as follows (since we are using StringSchema, the value of the primitive must be an ArcStr):

let resistor_id = lib.add_primitive(arcstr::literal!("resistor"));

SCIR cells may contain signals that connect instances and/or serve as ports that interface with parent cells.

let mut vdivider = Cell::new("vdivider");

let vdd = vdivider.add_node("vdd");
let vout = vdivider.add_node("vout");
let vss = vdivider.add_node("vss");

vdivider.expose_port(vdd, Direction::InOut);
vdivider.expose_port(vout, Direction::Output);
vdivider.expose_port(vss, Direction::InOut);

SCIR cells may also contain instances of SCIR primitives and other cells. We can connect ports of each instance to signals in the parent cell. While connections to instances of SCIR cells must connect to ports declared in the underlying cell, connections to primitives are not checked by SCIR as primitives are opaque to SCIR.

We can first instantiate the resistor primitives we defined earlier and add our voltage divider cell to our SCIR library.

let mut r1 = Instance::new("r1", resistor_id);

r1.connect("p", vdd);
r1.connect("n", vout);

vdivider.add_instance(r1);

let mut r2 = Instance::new("r2", resistor_id);

r2.connect("p", vout);
r2.connect("n", vss);

vdivider.add_instance(r2);

let vdivider_id = lib.add_cell(vdivider);

We can then create a cell that instantiates two of our newly-defined voltage divider cell.

let mut stacked_vdivider = Cell::new("stacked_vdivider");

let vdd = stacked_vdivider.add_node("vdd");
let v1 = stacked_vdivider.add_node("v1");
let v2 = stacked_vdivider.add_node("v2");
let v3 = stacked_vdivider.add_node("v3");
let vss = stacked_vdivider.add_node("vss");

let mut vdiv1 = Instance::new("vdiv1", vdivider_id);

vdiv1.connect("vdd", vdd);
vdiv1.connect("vout", v1);
vdiv1.connect("vss", v2);

stacked_vdivider.add_instance(vdiv1);

let mut vdiv2 = Instance::new("vdiv2", vdivider_id);

vdiv2.connect("vdd", v2);
vdiv2.connect("vout", v3);
vdiv2.connect("vss", vss);

stacked_vdivider.add_instance(vdiv2);

let stacked_vdivider_id = lib.add_cell(stacked_vdivider);

Bindings

SCIR primitives and cells can be instantiated in Substrate generators using bindings. Suppose we have the following schema that supports instantiating resistor and capacitor primitives:

pub struct MySchema;

#[derive(Debug, Copy, Clone)]
pub enum MyPrimitive {
Resistor(i64),
Capacitor(i64),
}

impl Schema for MySchema {
type Primitive = MyPrimitive;
}

We can create a Substrate block whose schematic corresponds to a MyPrimitive::Resistor using a PrimitiveBinding. It can then be instantiated in other Substrate generators just like any other block.

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, Block)]
#[substrate(io = "TwoTerminalIo")]
pub struct Resistor(i64);

impl ExportsNestedData for Resistor {
type NestedData = ();
}

impl Schematic<MySchema> for Resistor {
fn schematic(
&self,
io: &Bundle<<Self as Block>::Io>,
cell: &mut CellBuilder<MySchema>,
) -> substrate::error::Result<Self::NestedData> {
let mut prim = PrimitiveBinding::new(MyPrimitive::Resistor(self.0));

prim.connect("p", io.p);
prim.connect("n", io.n);

cell.set_primitive(prim);
Ok(())
}
}

Similarly, we can bind to a SCIR cell using a ScirBinding:

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, Block)]
#[substrate(io = "TwoTerminalIo")]
pub struct ParallelResistors(i64, i64);

impl ExportsNestedData for ParallelResistors {
type NestedData = ();
}

impl Schematic<MySchema> for ParallelResistors {
fn schematic(
&self,
io: &Bundle<<Self as Block>::Io>,
cell: &mut CellBuilder<MySchema>,
) -> substrate::error::Result<Self::NestedData> {
// Creates a SCIR library containing the desired cell.
let mut lib = LibraryBuilder::<MySchema>::new();
let r1 = lib.add_primitive(MyPrimitive::Resistor(self.0));
let r2 = lib.add_primitive(MyPrimitive::Resistor(self.1));
let mut parallel_resistors = Cell::new("parallel_resistors");
let p = parallel_resistors.add_node("p");
let n = parallel_resistors.add_node("n");
parallel_resistors.expose_port(p, Direction::InOut);
parallel_resistors.expose_port(n, Direction::InOut);
let mut r1 = Instance::new("r1", r1);
r1.connect("p", p);
r1.connect("n", n);
parallel_resistors.add_instance(r1);
let mut r2 = Instance::new("r2", r2);
r2.connect("p", p);
r2.connect("n", n);
parallel_resistors.add_instance(r2);
let cell_id = lib.add_cell(parallel_resistors);

// Binds to the desired cell in the SCIR library.
let mut scir = ScirBinding::new(lib.build().unwrap(), cell_id);

scir.connect("p", io.p);
scir.connect("n", io.n);

cell.set_scir(scir);
Ok(())
}
}