@quartz struct Acc
@in x::Bits{8}
@in rst::Bool = false
@in en::Bool = true
@out y::Bits{16}
sum::Bits{16} = 0
end
@on Acc posedge(clk) begin
@reset(rst)
@only_when(en)
sum ← sum + x
y ← sum
endClocked logic
The @on block: how registers behave at a clock edge
The block
The module type comes first, then the clock: posedge(name) or negedge(name). Any name will do — it becomes a clock port of the module. A parametric module names the parameters its body uses: @on Ctr{N} posedge(clk).
Inside the body a bare name is the field of that name, or the input of that name. Nothing is listed: the block reads whatever @in ports it mentions. A name that is none of those — not a field, not an input, not a local, not something defined in the enclosing Julia module — is an error, so a typo cannot quietly become a port that nothing drives.
A block with one statement needs no begin ... end:
@on Acc posedge(clk) y ← sumThe clauses
Three statements may open a body, before anything else:
@reset(cond)puts every register the block owns back to its default whencondholds. A register with no default keeps its value; so does astaticone. AStepneeds no default: it always goes back toSTART. A keyword gives one register a different reset value:@reset(rst; sum=1).@only_when(cond)runs the block only whencondholds — a clock enable.@clockout(pin; invert, gate)forwards the block’s clock to an output pin, optionally inverted or gated.
A string before the clauses is the block’s summary, kept for documentation.
Writing a register
x ← v and x <= v mean the same thing: a write that lands at the end of the cycle. Every read in the block sees the value the register had when the edge arrived, so the order of statements does not matter for reads, and the last write to a register wins:
@quartz struct Swap
a::Bits{8} = 1
b::Bits{8} = 2
end
@on Swap posedge(clk) begin
a ← b
b ← a
end
step(Swap())Swap(Bits{8}(0x02), Bits{8}(0x01), NamedTuple())
One register has one writer: two @on blocks may not write the same field.
← is the non-blocking assignment <=, and the only kind of register write there is. You may write <= if you prefer; ← is there because <= also means “less than or equal”, and the two would otherwise have to be told apart by context. Type it as \leftarrow at the Julia REPL or in an editor with Julia support.
Locals are wires
= makes a local variable, and a local is a wire: a name for part of an expression, with no storage.
@on Acc posedge(clk) begin
total = sum + x # a wire
sum ← total # a register
y ← total >> 1
endA local that shadows a field or an input is an error rather than a silent shadow.
Choosing
if, elseif and else work as in Julia, on any condition the hardware can compute:
@quartz struct Sat
@in x::Bits{8}
@in limit::Bits{8} = 200
y::Bits{8} = 0
end
@on Sat posedge(clk) begin
if x > limit
y ← limit
elseif x == 0
y ← 1
else
y ← x
end
endA local is assigned at the top level of the block, never inside an if. Compute it above the if and use it under the condition: it is a wire either way, and a wire that a branch does not use costs nothing. A value that must differ by condition is an ifelse, which is a mux:
big = ifelse(a > b, a, b)&& and || on hardware values are gates, the same as & and |: both sides are always computed, as hardware computes them, and nothing on either side has a side effect for the short-circuit to skip. That rewrite happens inside a block; a plain helper function is ordinary Julia, where && short-circuits, so a helper uses & and |.
One-line conditionals
A single write under a condition can be written the way Julia writes it:
count == limit && (count ← 0) # if count == limit
tx_rd || (tx_rq ← true) # unless tx_rd
a && b && (x ← v) # both conditionsThis is the if spelled differently. Only a statement may sit on the right — a write, a log macro (cond && @info "seen" x), or a @check: something that computes a value and throws it away is still a mistake, and still warns.
Case
A chain of if/elseif that tests one register against distinct constants is emitted as a Verilog case. You write the chain, or a state machine, and the emitter recognises the shape.
Writing part of a register
w[8:15] ← byte # bits 8 to 15
w[part(i, Bits{8})] ← byte # byte number i, counting from zero
w[base .+ (0:7)] ← byte # eight bits from a computed basepart(i, Bits{8}) is the one to reach for when a word is a row of equal fields: the index is widened before the multiply, so the product cannot wrap and no width appears in the design. base .+ (0:7) is for a base that is not a multiple of the width. Both are Verilog’s x[base +: 8]; a base that would run off the end is an error.
Splitting is a write with ⊞ on the left, by the widths of the targets, and _ skips a piece:
word ← id ⊞ tag ⊞ payload # join
id ⊞ tag ⊞ payload ← word # split
hi ⊞ _ ⊞ lo ← word # the middle bits go nowhereShifts and widths
Shifting left by the value’s own width or more leaves nothing behind, which is a statement about a width that is wrong rather than a shift worth making, so it is an error. Widen first if that is what you mean:
wide = Bits{16}(narrow) << 8Helper functions are ordinary Julia
Anything that computes a value can be an ordinary function. It runs in the simulator, and it is traced into the emitted Verilog — no annotation, no special types. A helper is a pure function: it takes values and returns one, and it cannot write a register — only a block can do that.
parity(x) = count_ones(x)[0]
saturate(x, hi) = ifelse(x > hi, hi, x)
@quartz struct Uses
@in x::Bits{8}
p::Bool = false
s::Bits{8} = 0
end
@on Uses posedge(clk) begin
p ← parity(x)
s ← saturate(x, Bits{8}(100))
end
write(stdout, Uses, Verilog());module Uses (
input wire clk_i,
input wire [7:0] x_i
);
wire clk = clk_i;
wire [7:0] x = x_i;
reg p;
reg [7:0] s;
wire [3:0] w4 = x[0] + x[1] + x[2] + x[3] + x[4] + x[5] + x[6] + x[7];
wire w5 = w4[0];
wire w7 = x > 8'h64;
wire [7:0] w9 = w7 ? 8'h64 : x;
always @(posedge clk_i) begin
begin
p <= w5;
s <= w9;
end
end
endmodule
This is how an algorithm moves from Julia to hardware a step at a time: the reference implementation and the hardware share helper functions, and each helper is checked in both worlds.
A few bit-level operations are common in hardware. Some are Julia functions that work on Bits as they do on any integer; the rest the package supplies:
| function | gives |
|---|---|
firstset(x) |
the lowest set bit, as a one-hot |
onehot(Bits{N}, i) |
an N-bit word with only bit i set |
popcount(x) |
the number of set bits (count_ones works too) |
leading_zeros(x), trailing_zeros(x) |
as in Julia |
bitwidth(x) |
the width of a value or type |
bits(a, b, ...), a ⊞ b, split(x, w...) |
join and take apart |
Methods
A helper computes a value, and that is all it can do. Take a module that sends a byte when it is free to:
@quartz struct Sender
@in busy::Bool
tx_tag::Bits{4} = 0
tx_data::Bits{8} = 0
tx_rq::Bool = false
endThe helpers we would like to write are these:
ready() = !tx_rq && !busy
function send(tag, payload)
tx_tag ← tag
tx_data ← payload
tx_rq ← true
endNeither is possible as a plain function. ready names fields and an input, which a function outside the block knows nothing about; and send writes registers, which a pure function cannot. The closest a helper can get is to be handed the state — inside a block it is reachable as this, with the registers and the inputs as its properties — and read through it; send cannot be a helper at all, so its writes stay in the block:
ready(m) = !m.tx_rq & !m.busy
@on Sender posedge(clk) begin
if ready(this)
tx_tag ← 3
tx_data ← 0x55
tx_rq ← true
end
endA method is the helper we wanted, as written — bare field names, inputs, register writes, nothing passed in:
@method ready() = !tx_rq && !busy
@method function send(tag, payload)
tx_tag ← tag
tx_data ← payload
tx_rq ← true
end
@on Sender posedge(clk) begin
ready() && send(3, 0x55)
endA method belongs to no one module: the same method serves every module whose fields it names. It is inlined where it is called, so it must be defined before the block that calls it. A method that writes is a statement, and cond && f() guards it as it would a write; one that only computes a value may be used as one.
Logging from inside a block
@info, @debug, @warn and @error work inside a block, with the simulation time and the module’s name added; @check is an assertion the simulation stops on. None of it reaches the Verilog unless asked for. Simulating has the details.
Next
Wires and pads: combinational outputs, and pins that go both ways.