Introduction

Write FPGA logic in Julia, run it in Julia, compile it to Verilog.

Why another HDL?

Algorithmic logic in an FPGA often is a port from an implementation in Julia or Python or MATLAB, rewritten as hardware. Porting and testing can be tricky, and debugging the Verilog implementation can be painful. Wouldn’t it be nice if we could just write the hardware description in Julia, pass it inputs and compare the outputs directly from Julia, and poke at various wires in the hardware description from a Julia REPL?

That’s what QuartzHDL allows you to do!

QuartzHDL keeps the design in the language the reference is written in. A module is a struct whose fields are the registers, with a block that says how those registers behave at every clock edge. The same source runs as ordinary Julia — one function call per clock, values you can inspect and test — and compiles to Verilog. A co-simulation drives both versions with the same stimulus and checks that they agree, cycle for cycle, so that the compiled Verilog is tested to be a faithful port of the Julia version.

NoteWhat QuartzHDL is not

QuartzHDL is not a Julia-to-hardware compiler and not a replacement for Verilog. It is Verilog’s subset of synchronous logic, written in a language with types, a REPL, a test framework and a plotting library, so that a design can be debugged like software, ported from a reference one step at a time, tested against models of the chips around it, and checked on every push. If you know how to write an always @(posedge clk) block, you already know most of what QuartzHDL lets you say.

It also provides higher level design patterns — finite state machines, metaguards, pipelined computations, multicycle logic and the like — that form a foundation for many designs, but are painful to implement by hand in Verilog.

Features at a glance

  • Hardware description as a Julia struct, with fields as interface wires and registers, and @on blocks as behavior.
  • Primitive data typesBool, Bits{N} and SBits{N}; any port may be declared active low and is read as asserted.
  • Board level I/OPad{N} with tri-state drive and release, on-chip and board pull-ups / pull-downs, and pin bindings with I/O standard and drive strength.
  • Hierarchical designs – constructed by wiring up submodules, clock domains, gated clock outputs, and vendor black boxes.
  • Hardware idioms as foundational building blocks – metaguards, self-clearing pulses, countdown timers, edge detectors, finite state machines, multi-step sequences, pipelines, and multicycle paths with their timing constraints.
  • Runs as plain Julia — one function call per clock edge, tested with @test and debugged at the REPL.
  • Simulation with peripheral logic — real clock rates, models of a USB FIFO, UART, SPI, I2C, PWM and RAM, stand-ins for black boxes, live waveforms in Surfer or Plots, and a sim> custom REPL.
  • Compiles to Verilog – co-simulated using Icarus Verilog to ensure that Julia and Verilog outputs match cycle for cycle.
  • Board to bitstream@board describes the pins, and the constraint file and a Lattice Diamond workspace, with a Makefile that builds the bitstream, are generated from it.

Installation

using Pkg
Pkg.add("QuartzHDL")           # Julia 1.11 or later
Pkg.Apps.add("QuartzHDL")      # the `quartz` command; Julia 1.12 or later

Some features need tools on your path, and the manual says so where it uses them: Icarus Verilog (iverilog, vvp) for co-simulation, and Surfer for live waveforms. On a Mac with Homebrew, brew install icarus-verilog surfer gets both.

An example

A UART transmitter: a byte in, a serial frame out with a parity bit. The frame is a sequence of steps, each one clock; the bit timing is a countdown register and the start strobe is an edge detector, each doing its own bookkeeping.

using QuartzHDL

const BIT_TIME = 9                   # clocks per bit, less one: 100 kbaud from 1 MHz

@quartz struct UartTx
  # interface wires
  @in  data::Bits{8}                 # 8-bit data to transmit
  @in  send::Bool                    # on rising edge of send
  @in  rst::Bool active=:low         # reset signal, asserted low
  @out tx::Bool = true               # TX pin of the UART
  @out busy::Bool active=:low        # busy signal, asserted low
  # internal state
  step::Step                         # state machine step
  send_e::Edge                       # edge detector for send input
  shift::Bits{8}                     # transmit shift register
  parity::Bool                       # parity bit
  baud_timer::Timeout{7}             # timer to control baud rate
end

@on UartTx posedge(clk) begin
  @reset(rst)                        # reset module when rst is asserted
  send_e  send
  @sequence Frame step begin
    @when rose(send_e)               # wait for a send strobe, then
    shift  data
    parity  isodd(popcount(data))   # a Julia function, computed in hardware
    tx  false                       # start bit
    baud_timer  BIT_TIME
    @repeat 8 begin
      @when expired(baud_timer)      # one bit time later
      tx  shift[0]                  # a data bit, LSB first
      shift  shift >> 1
      baud_timer  BIT_TIME
    end
    @then @when expired(baud_timer)
    tx  parity                      # transmit parity bit
    baud_timer  BIT_TIME
    @then @when expired(baud_timer)
    tx  true                        # stop bit
    baud_timer  BIT_TIME
    @then @when expired(baud_timer)  # hold it, then back to waiting for send
  end
  busy  step != Frame.START
end

The @quartz struct declares the ports and the registers. @on says what happens on each rising edge of clk: is a register write that lands at the end of the cycle, exactly like Verilog’s <=.

It is ordinary Julia, so it runs as such. A simulation clocks it at a real rate, records the pins you ask for, and hands back signals you can index by time, test, and plot:

using QuartzHDL.Units, Plots

sim = Simulation(UartTx(); clocks=(clk=1MHz,), watch="*")
out = @run sim begin
  sim.data = Bits{8}('A')
  advance_by(5µs)                   # a few clocks in, strobe send
  sim.send = true
  advance_by(5µs)
  sim.send = false
  advance_by(120µs)
end
plot(out, "clk", "send", "tx", "busy")

The same file compiles to Verilog from the command line — a case over the sequence’s steps, the state machine you would have written by hand:

$ quartz uart.jl --top UartTx -o uart.v

Or from Julia, writing the module type out:

write(stdout, UartTx, Verilog());
module UartTx (
  input wire clk_i,
  input wire send_i,
  input wire [7:0] data_i,
  input wire rst_ni,
  output wire tx_o,
  output wire busy_no
);

  localparam [3:0] Frame_START = 4'h0;
  localparam [3:0] Frame_step_1 = 4'h1;
  localparam [3:0] Frame_step_2 = 4'h2;
  localparam [3:0] Frame_step_3 = 4'h3;
  localparam [3:0] Frame_step_4 = 4'h4;
  localparam [3:0] Frame_step_5 = 4'h5;
  localparam [3:0] Frame_step_6 = 4'h6;
  localparam [3:0] Frame_step_7 = 4'h7;
  localparam [3:0] Frame_step_8 = 4'h8;
  localparam [3:0] Frame_step_9 = 4'h9;
  localparam [3:0] Frame_step_10 = 4'ha;
  localparam [3:0] Frame_step_11 = 4'hb;

  wire clk = clk_i;
  wire send = send_i;
  wire [7:0] data = data_i;
  wire rst = ~rst_ni;
  reg tx;
  reg busy;
  reg [3:0] step;
  reg send_e;
  reg send_e_prev;
  reg [7:0] shift;
  reg parity;
  reg [6:0] baud_timer;

  wire w11 = baud_timer == 7'h0;
  wire [6:0] w13 = baud_timer - 7'h1;
  wire [6:0] w14 = w11 ? baud_timer : w13;
  wire w16 = step == Frame_START;
  wire w19 = ~send_e_prev;
  wire w20 = send_e & w19;
  wire [3:0] w21 = data[0] + data[1] + data[2] + data[3] + data[4] + data[5] + data[6] + data[7];
  wire w22 = w21[0];
  wire w24 = step == Frame_step_1;
  wire w26 = baud_timer == 7'h0;
  wire w27 = shift[0];
  wire [7:0] w28 = shift >> 1;
  wire w30 = step == Frame_step_2;
  wire w32 = baud_timer == 7'h0;
  wire w33 = shift[0];
  wire [7:0] w34 = shift >> 1;
  wire w36 = step == Frame_step_3;
  wire w38 = baud_timer == 7'h0;
  wire w39 = shift[0];
  wire [7:0] w40 = shift >> 1;
  wire w42 = step == Frame_step_4;
  wire w44 = baud_timer == 7'h0;
  wire w45 = shift[0];
  wire [7:0] w46 = shift >> 1;
  wire w48 = step == Frame_step_5;
  wire w50 = baud_timer == 7'h0;
  wire w51 = shift[0];
  wire [7:0] w52 = shift >> 1;
  wire w54 = step == Frame_step_6;
  wire w56 = baud_timer == 7'h0;
  wire w57 = shift[0];
  wire [7:0] w58 = shift >> 1;
  wire w60 = step == Frame_step_7;
  wire w62 = baud_timer == 7'h0;
  wire w63 = shift[0];
  wire [7:0] w64 = shift >> 1;
  wire w66 = step == Frame_step_8;
  wire w68 = baud_timer == 7'h0;
  wire w69 = shift[0];
  wire [7:0] w70 = shift >> 1;
  wire w72 = step == Frame_step_9;
  wire w74 = baud_timer == 7'h0;
  wire w76 = step == Frame_step_10;
  wire w78 = baud_timer == 7'h0;
  wire w80 = step == Frame_step_11;
  wire w82 = baud_timer == 7'h0;
  wire w84 = step != Frame_START;

  always @(posedge clk_i) begin
    if (rst) begin
      tx <= 1'h1;
    end else begin
      send_e_prev <= send_e;
      baud_timer <= w14;
      send_e <= send;
      case (step)
        Frame_START: begin
          if (w20) begin
            step <= Frame_step_1;
            shift <= data;
            parity <= w22;
            tx <= 1'h0;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_1: begin
          if (w26) begin
            step <= Frame_step_2;
            tx <= w27;
            shift <= w28;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_2: begin
          if (w32) begin
            step <= Frame_step_3;
            tx <= w33;
            shift <= w34;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_3: begin
          if (w38) begin
            step <= Frame_step_4;
            tx <= w39;
            shift <= w40;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_4: begin
          if (w44) begin
            step <= Frame_step_5;
            tx <= w45;
            shift <= w46;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_5: begin
          if (w50) begin
            step <= Frame_step_6;
            tx <= w51;
            shift <= w52;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_6: begin
          if (w56) begin
            step <= Frame_step_7;
            tx <= w57;
            shift <= w58;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_7: begin
          if (w62) begin
            step <= Frame_step_8;
            tx <= w63;
            shift <= w64;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_8: begin
          if (w68) begin
            step <= Frame_step_9;
            tx <= w69;
            shift <= w70;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_9: begin
          if (w74) begin
            step <= Frame_step_10;
            tx <= parity;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_10: begin
          if (w78) begin
            step <= Frame_step_11;
            tx <= 1'h1;
            baud_timer <= 7'h9;
          end
        end
        Frame_step_11: begin
          if (w82) begin
            step <= Frame_START;
          end
        end
        default: begin
          step <= Frame_START;
        end
      endcase
      busy <= w84;
    end
  end

  assign tx_o = tx;
  assign busy_no = ~busy;
endmodule

That is the whole loop: describe, simulate, compile. The rest of this manual walks through it slowly.

Where to go next

TipIf you know Verilog

Boxes like this one appear throughout the manual to point out where QuartzHDL differs from what a Verilog author would expect.