Skip to main content

IOs

In this section, we'll explore how to define and use the interfaces between generated schematics.

Defining an IO

The first step in creating a Substrate schematic generator is to define an interface that other generators can use to instantiate your generator. An interface, called an IO in Substrate, defines a set of ports and their directions.

#[derive(Io, Clone, Default, Debug)]
pub struct VdividerIo {
pub vdd: InOut<Signal>,
pub vss: InOut<Signal>,
pub dout: Output<Signal>,
}

An IO must implement the Io trait. Implementing this trait is most easily done by using #[derive(Io)].

Schematic types

The IO struct itself does not store any connectivity data, but rather is a template for what an instantiation of the IO should look like. In this sense, the IO struct describes the schematic type of an interface (i.e. what signals it contains and how wide each bus within it is).

VdividerIo::default(), for example, would describe an IO template where the vout port should be a single-bit output signal. We can also describe an IO containing two 5-bit buses as follows:

#[derive(Io, Clone, Debug)]
pub struct ArrayIo {
pub in_bus: Input<Array<Signal>>,
pub out_bus: Output<Array<Signal>>,
}

let io_type = ArrayIo {
in_bus: Input(Array::new(5, Signal::new())),
out_bus: Output(Array::new(5, Signal::new())),
};

This allows you to parametrize the contents of your interface at runtime in whatever way you like. For example, we can do some calculations in the constructor for ArrayIo:

impl ArrayIo {
pub fn new(in_size: usize, m: usize) -> Self {
Self {
in_bus: Input(Array::new(in_size, Signal::new())),
out_bus: Output(Array::new(in_size * m, Signal::new())),
}
}
}

Port directions

Since port direction rules are often broken in analog design, Substrate does not enforce any directionality checks when connecting two ports. However, Substrate does run a basic driver analysis that throws warnings if there are multiple drivers of a net or no drivers, which may be helpful for debugging purposes.

All IOs implement the Directed trait, which allows them to specify the direction of each of their constituent ports. By default, all signals are made to be InOut, but this can be overwritten by wrapping the signal with one of the Input, Output, or InOut wrapper types. While you are not required to specify directions, it is recommended to improve debuggability and readability of your generators.

Wrapping a composite type with a direction will overwrite the direction of all constituent signals. In the example below, all of the ports of SramObserverIo are inputs.

#[derive(Io, Clone, Debug)]
pub struct SramIo {
pub clk: Input<Signal>,
pub we: Input<Signal>,
pub addr: Input<Array<Signal>>,
pub din: Input<Array<Signal>>,
pub dout: Output<Array<Signal>>,
}

pub type SramObserverIo = Input<SramIo>;

Similarly, if we wanted to create an SramDriverIo that drives the input signals of an SRAM and reads the output, we can use the Flipped wrapper type, which flips the direction of each constituent port.

pub type SramDriverIo = Flipped<SramIo>;

Bundles

Since IO structs only define the properties of an interface, a separate struct is needed to store connectivity data for the signals and buses defined by the IO struct. This struct is called a bundle and is associated with an IO struct via the SchematicType trait that all IOs must implement.

A bundle essentially just stores what each port in the IO is connected to and is created when the schematic type described by an IO struct is instantiated. In the case of the VdividerIo given before, the #[derive(Io)] macro automatically generates an appropriate schematic type called VdividerIoSchematic:

pub struct VdividerIoSchematic {
pub vdd: Node,
pub vss: Node,
pub dout: Node,
}

While IO structs describe the type of an interface, bundles describe the data of an interface and represent the physical wires (Nodes) in a netlist. As such, bundles can be connected to one another and probed during simulation.

Connections

Substrate encodes whether two bundles can be connected using the Connect marker trait.

Connections are made between two bundles by flattening bundles into an array of constituent wires and connecting these wires in order. As such, only bundles of the same type or derived types can be connected by default since Substrate cannot make any assumptions on the ordering of wires in different bundle types.

Custom connections

While you are free to implement Connect on whichever types you like, this requires you to ensure that the behavior above achieves what you want. In general, you should prefer to implement From or some other conversion function in order to encode connections between similar IOs.

Suppose we have the following two IOs:

#[derive(Io, Clone, Default, Debug)]
pub struct ThreePortMosIo {
pub d: InOut<Signal>,
pub g: Input<Signal>,
pub s: InOut<Signal>,
}

#[derive(Io, Clone, Default, Debug)]
pub struct FourPortMosIo {
pub d: InOut<Signal>,
pub g: Input<Signal>,
pub s: InOut<Signal>,
pub b: InOut<Signal>,
}

We should not directly implement Connect on their associated bundles since the flattened bundles have different lengths, resulting in one wire being left floating after the connection is made. Instead, we can write the following to make it easy to convert a source ThreePortMosIoSchematic bundle to a FourPortMosIoSchematic bundle that can be connected to the destination FourPortMosIoSchematic bundle:

impl From<ThreePortMosIoSchematic> for FourPortMosIoSchematic {
fn from(value: ThreePortMosIoSchematic) -> Self {
Self {
d: value.d,
g: value.g,
s: value.s,
b: value.s,
}
}
}

However, sometimes, we might want to tie the body port to a separate node:

impl ThreePortMosIoSchematic {
fn with_body(&self, b: Node) -> FourPortMosIoSchematic {
FourPortMosIoSchematic {
d: self.d,
g: self.g,
s: self.s,
b,
}
}
}

With these functions, we could conceptually write things like this:

cell.connect(three_port_io.into(), four_port_io);
cell.connect(three_port_io.with_body(vdd), four_port_io);