New lib.io components

Summary

Building on RFC 2 and RFC 53, a new set of components is added to lib.io. The current contents of lib.io (Pin and its signature) become deprecated.

Motivation

Currently, all IO buffer and register logic is instantiated in one place by platform.request. Per amaranth-lang/amaranth#458, this has caused problems. Ideally, the act of requesting a specific I/O (user's responsibility) would be decoupled from instantiating the I/O buffer (peripherial library's responsibility).

Further, we currently have no standard I/O buffer components, other than the low-level IOBufferInstance.

Guide-level explanation

IOPort, introduced in RFC 53, is Amaranth's low-level IO port primitive. In lib.io, IOPorts are wrapped in two higher-level objects: SingleEndedPort and DifferentialPort. These objects contain the raw IOValues together with per-bit inversion flags. They are obtained from platform.request. If no platform is used, they can be constructed by the user directly, like this:

a = SingleEndedPort(IOPort(8, name="a")) # simple 8-bit IO port
b = SingleEndedPort(IOPort(8, name="b"), invert=True) # 8-bit IO port, all bits inverted
c = SingleEndedPort(IOPort(4, name="c"), invert=[False, True, False, True]) # 4-bit IO port, varying per-bit inversions
d = DifferentialPort(p=IOPort(4, name="dp"), n=IOPort(4, name="dn")) # differential 4-bit IO port

Once a *Port object is obtained, whether from platform.request or by direct creation, it most likely needs to be passed to an I/O buffer. Amaranth provides a set of cross-platform I/O buffer components in lib.io.

For a non-registered port, the lib.io.Buffer can be used:

port = platform.request(...) # or = SingleEndedPort(,,,)
m.submodules.iob = iob = lib.io.Buffer(lib.io.Direction.Bidir, port)
m.d.comb += [
    iob.o.eq(...),
    iob.oe.eq(...),
    (...).eq(iob.i),
]

For an SDR registered port, the lib.io.FFBuffer can be used:

m.submodules.iob = iob = lib.io.FFBuffer(lib.io.Direction.Bidir, port, i_domain="sync", o_domain="sync")
m.d.comb += [
    iob.o.eq(...),
    iob.oe.eq(...),
    (...).eq(iob.i),
]

For a DDR registered port (given a supported platform), the lib.io.DDRBuffer can be used:

m.submodules.iob = iob = lib.io.DDRBuffer(lib.io.Direction.Bidir, port, i_domain="sync", o_domain="sync")
m.d.comb += [
    iob.o[0].eq(...),
    iob.o[1].eq(...),
    iob.oe.eq(...),
    (...).eq(iob.i[0]),
    (...).eq(iob.i[1]),
]

All of the above primitives are components with corresponding signature types. When elaborated, the primitives call a platform hook, allowing it to provide a custom implementation using vendor-specific cells. If no special support is provided by the platform, Buffer and FFBuffer provide a simple vendor-agnostic default implementation, while DDRBuffer raises an error when elaborated.

Reference-level explanation

The following classes are added to lib.io:

  • class Direction(enum.Enum):
        Input  = "i"
        Output = "o"
        Bidir  = "io"
    

    Represents a port or buffer direction.

  • SingleEndedPort(io: IOValue, *, invert: bool | Iterable[bool]=False, direction: Direction=Direction.Bidir): represents a single ended port; the invert parameter is normalized to a tuple of bool before being stored as an attribute

    • __len__(self): returns len(io)
    • __getitem__(self, index: slice | int): allows slicing the object, returning another SingleEndedPort; requesting a single index is equivalent to requesting a one-element slice
    • __add__(self, other: SingleEndedPort): concatenates two ports together into a bigger SingleEndedPort
    • __invert__(self): returns a new SingleEndedPort derived from this one by having the opposite (every element of) invert
  • DifferentialPort(p: IOValue, n: IOValue, *, invert: bool | Iterable[bool]=False, direction: Direction=Direction.Bidir): represents a differential pair; both IOValues given as arguments must have equal width

    • __len__(self): returns len(p) (which is equal to len(n))
    • __getitem__(self, index: slice | int): allows slicing the object, returning another DifferentialPort
    • __add__(self, other: DifferentialPort): concatenates two ports together into a bigger DifferentialPort
    • __invert__(self): returns a new DifferentialPort derived from this one by having the opposite (every element of) invert
  • Buffer.Signature(direction: Direction | str, width: int): a signature for the Buffer; if direction is a string, it is converted to Direction

    • i: Out(width) if direction in (Direction.Input, Direction.Bidir)
    • o: In(width) if direction in (Direction.Output, Direction.Bidir)
    • oe: In(1, init=1) if direction is Direction.Output
    • oe: In(1, init=0) if direction is Direction.Bidir
  • Buffer(direction: Direction | str, port: SingleEndedPort | DifferentialPort | ...): non-registered buffer, derives from Component

    • when elaborated, tries to return platform.get_io_buffer(self); if such a function doesn't exist, lowers to IOBufferInstance plus optional inverters
  • FFBuffer.Signature(direction: Direction | str, width: int): a signature for the FFBuffer

    • i: Out(width) if direction in (Direction.Input, Direction.Bidir)
    • o: In(width) if direction in (Direction.Output, Direction.Bidir)
    • oe: In(1, init=1) if direction is Direction.Output
    • oe: In(1, init=0) if direction is Direction.Bidir
  • FFBuffer(direction: Direction | str, port: SingleEndedPort | DifferentialPort | ..., *, i_domain="sync", o_domain="sync"): SDR registered buffer, derives from Component

    • when elaborated, tries to return platform.get_io_buffer(self); if such a function doesn't exist, lowers to IOBufferInstance, plus reset-less FFs realized by m.d[*_domain] assignment, plus optional inverters
  • DDRBuffer.Signature(direction: Direction | str, width: int): a signature for the DDRBuffer

    • i: Out(ArrayLayout(width, 2)) if direction in (Direction.Input, Direction.Bidir)
    • o: In(ArrayLayout(width, 2)) if direction in (Direction.Output, Direction.Bidir)
    • oe: In(1, init=1) if direction is Direction.Output
    • oe: In(1, init=0) if direction is Direction.Bidir
  • DDRBuffer(direction: Direction | str, port: SingleEndedPort | DifferentialPort | ..., *, i_domain="sync", o_domain="sync"): DDR registered buffer, derives from Component

    • when elaborated, tries to return platform.get_io_buffer(self); if such a function doesn't exist, raises an error

All of the above classes are fully introspectable, and the constructor arguments are accessible as read-only attributes.

If a platform is not used, the port argument must be a SingleEndedPort or DifferentialPort. If a platform is used, the platform may define support for additional types. Such types must implement the same interface as *Port objects, that is:

  • __len__ must provide length in bits (so that *Buffer can know the proper signature)
  • __getitem__ which supports slices, and where plain indices return single-bit slices
  • __invert__ that returns another port-like
  • direction attribute that must be a Direction

If a platform is not used, and a DifferentialPort is used, a pseudo-differential port is effectively created.

The direction argument on *Port can be used to restrict valid allowed buffer directions as follows:

  • an Input buffer will not accept an Output port
  • an Output buffer will not accept an Input port
  • a Bidir buffer will only accept a Bidir port

This is validated by the *Buffer constructors. Custom buffer-like elaboratables that take *Port are likewise encouraged to perform similar checking.

The platform.request function with dir="-" returns SingleEndedPort when called on single-ended ports, DifferentialPort when called on differential pairs. Using platform.request with any other dir becomes deprecated, in favor of having the user (or peripherial library) code explicitly instantiate *Buffers. The lib.io.Pin interface and its signature likewise become deprecated.

Drawbacks

The proposed FFBuffer and DDRBuffer interfaces have a minor problem of not actually being currently implementable in many cases, as there is no way to obtain clock signal polarity at that stage of elaboration. A solution for that needs to be proposed, whether as a private hack for the current platforms, or as an RFC.

Using plain domains for DDRBuffer has the unprecedented property of triggering logic on the opposite of active edge of the domain.

Rationale and alternatives

The buffers have minimal functionality on purpose, to allow them to be widely supported. In particular:

  • clock enables are not supported
  • reset is not supported
  • initial values are not supported
  • xdr > 2 is not supported

Such functionality can be provided by vendor-specific primitives.

Prior art

None.

Unresolved questions

None.

Future possibilities

Vendor-specific versions of the proposed buffers can be added to the vendor module, allowing access to the full range of hardware functionality.