Library components

The chips around the design, ready to attach

What sits on the board beside the design — a USB controller, a UART, an SPI flash, an I2C sensor, a block RAM — comes from the library as a component: a Julia model of the chip that attaches to a Simulation and drives and reads the design’s nets as the chip would. A component is bound to the design’s nets by role, and then talked to the way a host would talk to the hardware.

A component needs no wiring and no entry in the clock plan: it reads and drives nets the way a stimulus does, from a task of its own or, for a bus it must watch every cycle, a hook run after every slot. Pins are asserted as true, as everywhere in the design; a pin declared active=:low is inverted at the pin, not here. close(c) takes a component off the bench.

UART

Let’s build a loopback and talk to it:

@quartz struct Loop
  @in  rx::Bool = true
  @out tx::Bool = true
end

@on Loop posedge(clk) tx  rx

sim = Simulation(Loop(); clocks=(clk=48MHz,))
u = UART(sim; rx="tx", tx="rx", baud=115200)

The roles are the UART’s own: its rx listens on the design’s tx. A UART is a stream, and a stream is talked to like a serial port:

write(u, "hello\n")          # blocks, in simulation time, until it has gone out
readline(u)                  # advances until a line has come in
"hello"
put!(u, [0x01, 0x02]); @run sim advance_by(1ms); take!(u)   # queue and collect, without blocking
2-element Vector{UInt8}:
 0x01
 0x02

A stream is an IO, so print, readuntil, read and the rest work on it. A blocking call suspends its task inside a @run, or runs the simulation itself at top level, and gives up after the link’s timeout. frame = 8 or frame = "\n" groups what arrives into units for take!, read(u) and on. UART(sim; ..., parity=:even, stop=2) sets the framing.

FT2232H

The FT2232H is a USB-to-parallel FIFO, driven on the design’s byte bus in FT245 asynchronous mode:

ft = FT2232H(sim; data="usb_data", rd="usb_rd", wr="usb_wr",
                  rxf="usb_rxf", txe="usb_txe", frame=8)
write(ft, bytes)
take!(ft)                     # the next 8-byte frame the design sent

It is a stream like the UART, and it watches the bus every slot, so the design’s read and write strobes are honoured whenever they come.

SPI

m  = SPIMaster(sim; sclk="sclk", mosi="mosi", miso="miso", cs="cs", rate=2MHz, mode=0)
sl = SPISlave(sim; sclk="sclk", mosi="mosi", miso="miso", cs="cs")

A master is a transaction link: transfer(m, bytes) selects the chip, clocks the bytes out and returns the bytes that came back. A slave collects what the master sends, or answers each byte through on (below). mode is the usual 0–3 (clock polarity and phase).

I2C

i   = I2CMaster(sim; scl="scl", sda="sda", rate=400kHz)
rtc = I2CSlave(sim; scl="scl", sda="sda", address=0x51)

The master’s verbs are write(i, address, bytes) and read(i, address, n). A slave collects each transaction the master writes, or answers it through on (below).

The bus is open-drain, so the design’s scl and sda are pads declared Pad{1}(:pullup). Clock stretching is not modelled.

WarningOne outside driver per net

A simulation keeps one drive per net from outside the design, so a pad can carry one library component: a slave on the bus the design’s master runs, or a master on the bus a design’s slave listens to. Two library components on the same pad — two slaves on one bus, or a library master and a library slave with no design between them — overwrite each other’s drive, and the transaction fails. A second device on a shared bus has to be a module of the design for now.

PWM

A pulse train on a net — a clock, a strobe, a gated burst:

p = PWM(sim; out="ext_clk", rate=1MHz, duty=0.5)             # a clock
p = PWM(sim; out="trigger", rate=10kHz, duty=0.1, count=3)   # three pulses
p.duty = 0.25                                                # changed while running

RAM

A memory that stands in for a vendor RAM block behind a black box. Its ports are named by role, in the lowercase names Julia sees; any number of each:

QuartzHDL.standin(::Type{DacRam}) =
  RAM(8192, 16; read=(clock=:rdclock, addr=:rdaddress, data=:q, en=:rdclocken),
              write=(clock=:wrclock, addr=:wraddress, data=:data, we=:we))
ram[0x10]                     # read a word from the harness
ram[0x10] = 0xff              # write one
fill!(ram, 0)

A read port holds the word its address named before the edge, and a read of the address being written sees the old word. The storage is changed in place, and reset! does not clear it.

Receiving: take! or on

What a component receives from the design — bytes on a UART, bytes on an SPI slave, a transaction on an I2C slave — can be collected in two ways.

Pull it. Nothing is done with it until you ask: take!(c) gives what has arrived so far, read(c, n) waits for n bytes, readline(c) waits for a line.

write(u, "AT\r\n")             # the design gets a command
readline(u)                   # ...and this is what it sent back

Answer it. on(c) do unit ... end registers a function that is called with each unit as it arrives, and what the function returns is queued to send back. A unit answered this way is not kept for take!. The unit is whatever the component frames: for a UART or the USB FIFO, each byte — or each frame, when frame is set; for an SPI slave, each byte; for an I2C slave, one transaction, the bytes the master wrote. The return value is bytes, a string, or nothing to send nothing.

An echo, to test a design’s receive path against its transmit path:

on(u) do bytes
  bytes                       # send back what came in
end

A monitor that only records:

seen = UInt8[]
on(u) do bytes
  append!(seen, bytes)
  nothing
end

An SPI device that answers every command byte with a status byte:

on(sl) do b
  b .+ 0x40
end

An I2C register file, where a write selects the register and the next read returns from there:

registers = collect(0x00:0x0f)
on(rtc) do bytes
  registers[bytes[1] + 1:end]
end

on(nothing, c) removes the function, and what arrives is queued again. An SPI slave with nothing queued sends its idle byte, 0xff unless set.

Next

Custom components.