Quickstart

A small design, end to end

Let’s build something small but real: a pulse-width modulator. It takes an 8-bit duty and produces a square wave whose high time is duty out of every 256 clocks. Along the way we will simulate it, look at its waveform, check the generated Verilog against the Julia model, and write the Verilog out.

Describe it

using QuartzHDL

@quartz struct Pwm
  @in  duty::Bits{8} = 128
  @out pwm::Bool
  count::Bits{8} = 0
end

@on Pwm posedge(clk) begin
  count  count + 1
  pwm  count < duty
end

Three things to notice:

  • A field with @in is an input: it has no storage, and the block reads it by name. = 128 is its default, so we may leave it out when stepping.
  • A field with @out is an output register, and a field with neither is an internal register. count::Bits{8} = 0 is an 8-bit unsigned register that powers up at zero.
  • count ← count + 1 schedules the new value for the end of the cycle. count + 1 wraps at 8 bits, as hardware does, so this counter runs from 0 to 255 and round again with no extra code.
NoteTyping

At the Julia REPL, and in editors with Julia support, type \leftarrow and press Tab to get . If you would rather not, count <= count + 1 means exactly the same thing; is the manual’s spelling because <= also reads as “less than or equal”.

TipIf you know Verilog

@on Pwm posedge(clk) begin ... end is always @(posedge clk) begin ... end, and is <=. Every write in the block sees the register values from before the edge; the writes land together afterwards. There is no blocking assignment.

Step it by hand

A module is a value. step takes one and returns the next, one clock edge later:

m = Pwm()
m = step(m; duty=Bits{8}(3))
m = step(m; duty=Bits{8}(3))
m
Pwm(true, Bits{8}(0x02), (duty = Bits{8}(0x03),))

Let’s run a full period and count the high cycles:

let m = Pwm(), highs = 0
  for _ in 1:256
    m = step(m; duty=Bits{8}(64))
    highs += m.pwm
  end
  highs
end
64

64 of 256. step is enough for a unit test; a Simulation, next, drives a longer run.

Simulate it

For anything longer than a few cycles, a Simulation wraps the module with a clock and a recorder. We give the clock a real rate, and say which nets to watch ("*" means all of them):

sim = Simulation(Pwm(); clocks=(clk=1MHz,), watch="*")

Then we drive it with a @run block. Inside, sim.duty = v drives an input, and advance_by lets time pass:

out = @run sim begin
  sim.duty = 64
  advance_by(1ms)
  sim.duty = 192
  advance_by(1ms)
end

What comes back is a capture of every watched net. Ask it what a net was at some time, or list its changes:

out.pwm[100µs]
false
changes(out.pwm)[1:6]
6-element Vector{Pair{Float64, Union{Missing, Bool}}}:
      0.0 => 0
   1.0e-6 => 1
   6.5e-5 => 0
 0.000257 => 1
 0.000321 => 0
 0.000513 => 1

pwm goes high at the start of each 256-clock period and low again duty clocks later. To check the duty cycle, take the signal as one value per clock — 1000 per millisecond at 1 MHz — and see how much of each millisecond it was high:

frames = sampled(out.pwm)
sum(frames[1:1000]) / 1000, sum(frames[1001:2000]) / 1000
(0.256, 0.768)

About 25% for the first millisecond and 75% for the second, which is what we asked for. And with Plots loaded, plot it — a couple of periods on either side of the change at 1 ms:

using Plots

plot(out, "pwm"; xlims=(0.5ms, 1.5ms))

The high pulses are narrow before 1 ms and wide after it. Simulation covers the rest of this: watching specific nets, waiting on conditions, running tasks alongside the stimulus, and a REPL prompt for interactive poking.

Check it against the Verilog

cosim checks that the two agree. It writes the Verilog for the module, runs it under Icarus Verilog with the same inputs as the Julia model, and compares every output after every clock:

stimulus = [(duty=Bits{8}(i < 300 ? 64 : 192),) for i in 1:600]
r = cosim(Pwm, stimulus)
r.ok
true

If r.ok were false, r.mismatches would list the first cycles where the two disagreed. Because the two came from one source, a mismatch means a bug in QuartzHDL rather than in your design.

Note

cosim needs iverilog and vvp on your path. On a Mac, brew install icarus-verilog.

Write the Verilog

write(stdout, Pwm, Verilog());
module Pwm (
  input wire clk_i,
  input wire [7:0] duty_i,
  output wire pwm_o
);

  wire clk = clk_i;
  wire [7:0] duty = duty_i;
  reg pwm;
  reg [7:0] count;

  wire [7:0] w13 = count + 8'h1;
  wire w14 = count < duty;

  always @(posedge clk_i) begin
    begin
      count <= w13;
      pwm <= w14;
    end
  end

  assign pwm_o = pwm;
endmodule

Passing a path instead of stdout writes a file. The ports carry direction suffixes (duty_i, pwm_o, clk_i) so the pin list reads clearly in the vendor tools; Verilog output shows how to change the naming, and how to generate the constraint file that puts each port on a pin.

There is also a command line, for when you want a .v file without writing a driver script:

julia -m QuartzHDL pwm.jl --top Pwm -o pwm.v

What next

You have now seen every part of the workflow. The chapters that follow take each part in turn, starting with modules and registers — the types, the ports, and what a default means.