Clocked logic

The @on block: how registers behave at a clock edge

The block

@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
end

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  sum

The clauses

Three statements may open a body, before anything else:

  • @reset(cond) puts every register the block owns back to its default when cond holds. A register with no default keeps its value; so does a static one. A Step needs no default: it always goes back to START. A keyword gives one register a different reset value: @reset(rst; sum=1).
  • @only_when(cond) runs the block only when cond holds — 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.

TipIf you know Verilog

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
end

A 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
end

A 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 conditions

This 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 base

part(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 nowhere

Shifts 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) << 8

Helper 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
end

The 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
end

Neither 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
end

A 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)
end

A 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.