Quick Intro to Pyrope¶
Pyrope is a hardware description language: every construct elaborates to wires, muxes, flops, and memories. This page is the working subset needed to read and write correct Pyrope, with pointers to the chapters that explain each topic in depth. If you read nothing else, read this and the pitfalls at the end.
Declarations and storage kinds¶
Every declaration starts with a kind keyword — data: const / mut / wire
/ reg; lambda: comb / pipe / mod / fluid — and every data
declaration needs = value:
comptime const SIZE = 16 // compile-time constant (explicit `comptime`)
const my_constant = 42 // immutable; this value is known at compile time
mut my_wire = 0 // combinational: no persistence across cycles
wire my_net = nil // single-driver comb net; readable before its driver
reg my_state = 0 // register: persists across cycles; '= 0' is the RESET value
constmeans immutable; it does not require compile-time evaluation. Thecomptimeprefix requires it (comptime mutis valid too), while.[`comptime`]checks whether the current value is known at compile time. Identifier casing carries no meaning, but the built-in type names (U8,Bool, ...) are reserved words.nilmeans "no value yet" — reading it is a compile error;reg x = nildeclares a register with no reset. Unknown bits (Verilogx) are written0sb?/0ub10??01. There is no bare?and no_default.- No variable shadowing, anywhere.
;is the same as a newline. wireis one combinational net with exactly one driver; unlikemutit may be read before its driver appears textually (Verilog net / continuous-assign model).wire x = nilforward-declares an undriven net. A second unconditional driver, a never-driven net, a self-feeding combinational loop, or a write to awireinside a loop body are compile errors (a loop body may read awire).
Details: Variables and types.
One integer type¶
Integers are unlimited-precision and signed; everything else is a range
constraint on that one type. U8 is the same as Signed(min=0, max=255),
S4 is Signed(min=-8, max=7), and Unsigned is Signed(min=0):
mut a:U8 = 100
mut b:Signed(min=0, max=300) = 0
wrap a = a + 200 // narrowing must be annotated: wrap drops bits, sat saturates
- Booleans and integers never mix:
if x != 0 {}. Convert explicitly:U1(flag)for bool->bit (true == 1),Bool(v#[3])orv#[3] == 1for bit->bool (Signed(true) == -1is a reinterpretation, not the idiom).and/or/not/impliesare boolean-only;& | ^ ~are bitwise integer ops. Boolis legal on every port, top level included (a 1-bit port). The no-mixing rule holds at the boundary too: bind withconst d = dut(go=Bool(v#[0])), then readU1(d.flag).- Precedence is shallow — parenthesize:
3 & (4*4), never3 & 4*4. - An unannotated narrowing assignment is a compile error; prefix it with
wraporsat. Argument binding follows the same rule: aU16intoa:U4is an error, so slice it (f(a=x#[0..<4])). - Built-in types are capitalized:
U<num>/S<num>(S32; there is noI32),Unsigned,Signed,Bool,String,Clock,Reset. A width from a comptime value isUnsigned(bits=N)/Signed(bits=N). The old lowercase spellings (u8,bool,signed, ...) are ordinary identifiers (s1,i0,u4are legal variable names, no backticks). Using one as a type or a cast (x:u8,u8(x)) when it is not a name you declared is a compile error saying it was renamed (u8was renamedU8). A variable spelled like a type word (U4,S2,Bool) must be backticked (`U4`).
Details: Basics, Variables and types, Attributes.
Tuples are the core data structure¶
mut p = (mut x:U8 = 0, mut y:U8 = 0) // named fields use a kind keyword
mut t = (1, 2, 3) // positional entries are bare values
mut arr = [1, 2, 3] // [] = array: all entries same type
cassert(p.x == 0) // named access (also p['x'])
cassert(t[0] == 1) // integer indices select positional entries ONLY
A tuple is either all named or all unnamed; mixing named and unnamed
entries in one tuple is a compile error. Named fields are unordered and
name-access only (p.x, never p[0]); unnamed entries are positional only
(t[0]). (...a, ...b) concatenates (splice): two unnamed tuples append, two
named tuples merge (a field on both sides is an error), and splicing a named
with an unnamed tuple is an error. A selector [...] takes one expression
(integer, string, range, or conditional).
Details: Tuples, Type system.
Lambdas (the only functions)¶
| kind | contract |
|---|---|
comb |
Pure combinational, zero cycles. No reg. |
pipe[N] |
Fixed latency N > 0; never a combinational input→output path. |
mod |
No constraints; every output declares its landing cycle: -> (x:U8@[2]). |
fluid |
Transactional valid/retry handshakes (TBD: not yet implemented). |
comb add(a:U8, b:U8) -> (r:U9) { r = a + b }
pipe mul(a:U16, b:U16) -> (c:U32) { c = a * b }
pipe acc(a:U32, b:U32) -> (c:U32) { wrap c = a + b }
mod mac(in1:U16, in2:U16) -> (out:U32@[4]) {
stage[3] tmp = mul(a=in1, b=in2) // pipe call: 3 stages
stage[3] in1_d = in1 // pure 3-cycle delay
stage[1] out@[4] = acc(a=tmp@[3], b=in1_d@[3]) // alignments typechecked
}
- Outputs are always declared by name in
-> (...); the body assigns them.returnis a terminator only —return Xis a syntax error. - Name your call arguments (
f(a=1, b=2)); parentheses always (noarg()). - UFCS
x.f(args)works only whenfdeclaresselffirst;ref selfneeds amutreceiver.refis written at declaration and call. Callable tuple fields are independent of external names and need not declareself. A callable field plus a matching externalselffunction makes the dotted call ambiguous and is an error. initis the only implicit hook (the constructor, acomb); there are no getter/setter hooks. Overload by gathering:const add = [add1, add2]— a call dispatches to the first gathered lambda that can accept it (same argument rules as a direct call), resolved at compile time.
Details: Lambdas, Pipelining, Fluid, Instantiation.
Registers and time¶
reg counter:U8 = 0 // '= 0' is the reset value (nil ⇒ no reset)
const q = counter // a bare name reads the current q value
wrap counter += 1 // write with =/+= (wrap: U8 narrows); lands at the cycle boundary
const old = past[2](counter) // pipelined 2 cycles (inserts 2 flops)
Clock and reset bind by type, not by name. A mod's clock is its single
Clock input and its reset its single Reset input, whatever they are
called, and registers bind to them implicitly. A module with registers and no
Clock (or Reset) input gets one minted, clock:Clock (or reset:Reset);
a non-Clock input already named clock (a non-Reset one named reset) is
then a compile error. With two or more Clock (or Reset) inputs, every
register names its own:
reg c:U8:[clock_pin=clk2, reset_pin=rst2, async=true] = 3
(a _pin takes the signal directly, no ref; async=true selects an
asynchronous reset). A reset is active-high unless negreset=true; the name
(rst_n) means nothing. A Clock is not data: it only drives clock pins,
Clock ports, or a Clock_cell (clock gating), is never bound to a constant,
and U1(clk) is an error; its simulation cycle count is readable only in
tests, puts, and assert/cassert. A Reset is Bool-like: it can be computed
(rst or soft_rst), and false means no reset. An instance's unbound
Clock/Reset input is wired to the caller's single one (two or more in the
caller: bind it explicitly). A comb has no Clock/Reset inputs. Memories
are register arrays: reg mem:[256]U32 = 0.
mod cnt(c:Clock, r:Reset, en:Bool) -> (q:U8@[0]) {
reg n:U8 = 0 // clocked by `c`, reset by `r`: bound by type
if en { wrap n += 1 }
q = n
}
Details: Statements, Attributes, Implicit clock and reset, Memories.
Control flow¶
if cond { y = 1 } elif other { y = 2 } else { y = 3 } // also an expression
match state { // exactly one arm runs; `else` may be omitted only if the arms are exhaustive
== State.Idle { if start { state = State.Run } }
else { state = State.Idle }
}
for i in 0..<N { acc += f(i) } // loops fully unroll: bounds must be comptime
unique ifasserts mutually exclusive conditions (one-hot mux; replaces tri-state).- There are no
when/unlesstrailing gates and no runtime-bounded loops. - Ranges:
0..=7(inclusive),0..<8(exclusive),2..+3(size-based).
Details: Statements, Assertions.
Bits vs elements vs cycles¶
v#[3] // bit 3 v#[1..=4] // bit slice
v#sext[0..=2] // sign-extended slice
v#|[..] // or-reduce (integer 0/1); also #& #^ #+ (popcount)
t[0] // tuple/array element
out@[4] // cycle typecheck (never inserts flops)
#[] is bits, [] is elements, @[] is cycles — never mix them.
Details: Variables and types, Internals.
A complete example¶
enum State = (Idle, Run, Done)
mod fsm(start:Bool, fin:Bool) -> (busy:Bool@[0]) {
reg state:State = State.Idle
busy = state == State.Run
match state {
== State.Idle { if start { state = State.Run } }
== State.Run { if fin { state = State.Done } }
else { state = State.Idle }
}
}
test fsm.start {
mut f = fsm // one persistent instance, reset on declaration
tick 1 { // one cycle; the tick mints `clock:Clock` for `f`
f.start = true
f.fin = false
assert(not f.busy) // q value: still Idle this cycle
step // the clock edge
assert(f.busy) // Run after one cycle
}
}
Details: Verification for test/step/temporal
operators, Running cycles for tick.
Coming from Verilog¶
| Verilog | Pyrope |
|---|---|
module m(...) |
mod m(...) -> (out:T@[N]) (or pipe[N]/comb) |
reg [7:0] x + reset |
reg x:U8 = 0 |
reg [7:0] x / procedural blocking = |
mut x:U8 = 0 |
wire [7:0] x / continuous assign |
wire x:U8 = ... (single-driver net) |
x <= y (non-blocking) |
x = y on a reg (registered write) |
parameter N = 8 |
comptime const N = 8 |
input clk, rst |
clk:Clock, rst:Reset inputs (bound by type) |
always @(posedge clk) / @(*) |
implicit — reg vs mut |
case ... endcase |
match x { == v {...} else {...} } |
x[6:3] |
x#[3..=6] |
{a, b} concat |
(b, a)#[..] — entry 0 lands in the LOW bits, so the order reverses |
4'b10x? |
0ub10?? |
| tri-state / one-hot mux | unique if |
testbench initial |
test name { ... step ... } |
More: Hardware design, vs other languages.
Common pitfalls¶
return Xis always wrong — assign the named output, then barereturn.- Outputs must be named in
-> (...); the clause is mandatory (-> ()for none; onlyselfmethods may omit it). - A
matchneeds a finalelsearm unless its arms cover every selector value; an omittedelseis an unreachable don't-care, not a hold. constrequires immutability;comptimerequires compile-time evaluation.- A
wirehas exactly one driver; read-before-driver is allowed but a second unconditional driver is a compile error. A loop body may read awirebut not write it. @[N]never inserts flops;stage[N]does (mod-only). Apipecall needsstage[N]at the call site.- No bool/integer mixing:
if 5 {}is a type error — writeif 5 != 0 {}. Bool->bit isU1(flag), notSigned(flag)(which is-1for true). - Narrowing assignments need
wrap/sat. - Loop bounds must be comptime (loops unroll). No runtime loops, no comprehensions.
0b1010is invalid — write0ub1010/0sb1010.- A packed tuple reads left-to-right as low-to-high bits:
(a, b)#[..]putsain the LOW bits, the reverse of Verilog's{a, b}. - A tuple is all named or all unnamed, never mixed. Integer
[]indexing selects entries of an unnamed tuple only; named fields are name-access only. - Enum comparisons use names (
State.Idle), never raw integers. - Built-in types are capitalized (
U8,S4,Bool,String);u8,bool, andi32are compile errors, also as names (mut s1is an error). - A clock is a
Clockinput and a reset aResetinput; an input namedclkorrstof another type is plain data. Reset polarity comes only fromnegreset=true, never from an_nname.
Where to go next¶
- Hardware design mindset — why HDLs differ from software
- Basics — literals, operators, comments
- Tuples — the core data structure, enums
- Variables and types / Attributes
- Assertions / Statements
- Lambdas / Instantiation / Pipelining / Fluid
- Type system / Struct types
- Memories — arrays, SRAMs,
__memory,regref - Verification — tests, temporal library
- Querying a simulation — inspect a run with
lhd sim --query - Internals / LNAST — compiler view
- Standard library (TBD)
- Implementation status — documented features not yet implemented in LiveHD (marked "TBD" throughout the chapters)