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
endQuickstart
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
Three things to notice:
- A field with
@inis an input: it has no storage, and the block reads it by name.= 128is its default, so we may leave it out when stepping. - A field with
@outis an output register, and a field with neither is an internal register.count::Bits{8} = 0is an 8-bit unsigned register that powers up at zero. count ← count + 1schedules the new value for the end of the cycle.count + 1wraps at 8 bits, as hardware does, so this counter runs from 0 to 255 and round again with no extra code.
←
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”.
@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))
mPwm(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
end64
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)
endWhat 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.oktrue
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.
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.