@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)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:
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 blocking2-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 sentIt 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.
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 runningRAM
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 backAnswer 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
endA monitor that only records:
seen = UInt8[]
on(u) do bytes
append!(seen, bytes)
nothing
endAn SPI device that answers every command byte with a status byte:
on(sl) do b
b .+ 0x40
endAn 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]
endon(nothing, c) removes the function, and what arrives is queued again. An SPI slave with nothing queued sends its idle byte, 0xff unless set.