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
  count::Bits{8} = 0
  @out pwm::Bool
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.
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(Bits{8}(02h), true, (duty = Bits{8}(80h),))

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

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

64 of 256, as promised. step is all you need for a unit test — we will see a nicer way to drive a longer run next.

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 = "*")
Simulation(Pwm, 4 nets, 4 watched, t = 0s)

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
Capture(4 signals, 2.0ms)

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[0.5ms], out.pwm[1.5ms]
(false, 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

And with Plots loaded, plot it:

using Plots
plot(out, "pwm"; xlims = (0.9ms, 1.1ms))

The duty steps from 25% to 75% at 1 ms, which is what we asked for. 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

Now the part that makes all of this trustworthy. cosim 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 — and that is the kind of bug that gets fixed first.

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 [7:0] count;
  reg pwm;

  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.