Modules
Modules are the primary building blocks of Virdant designs. A module defines a hardware block with ports, internal components, and behavioral statements.
Module Body
The body of a module definition is a sequence of statements that declare ports, components, instances, and control flow.
ModDefStmtCore :=
ModDefStmtComponent
| ModDefStmtMemory
| ModDefStmtDriver
| ModDefStmtIndexedDriver
| ModDefStmtInstance
| ModDefStmtSocket
| ModDefStmtUnused
| ModDefStmtInitial
| ModDefStmtDependsOn
ModDefStmt :=
ModDefStmtCore
| If
| Match
| Let
The order of statements within a module body does not matter. Virdant processes the module body as a set of declarations, not as a sequence of imperative statements.
Ports
Ports are the interface between a module and the outside world. They are declared with a direction, a name, and a type.
ModDefStmtComponent :=
DocString? Annotations "incoming" Ident ":" Type OnClause? ItBlock?
| DocString? Annotations "outgoing" Ident ":" Type DefaultClause? OnClause? ItBlock?
| DocString? Annotations "bus" Ident ":" Type ItBlock?
| DocString? Annotations "net" Ident ":" Type ItBlock?
| DocString? Annotations "outgoing" "wire" Ident ":" Type DefaultClause? OnClause? ItBlock?
| DocString? Annotations "outgoing" "reg" Ident ":" Type DefaultClause? OnClause? ItBlock?
| DocString? Annotations "wire" Ident ":" Type OnClause? ItBlock?
| DocString? Annotations "reg" Ident ":" Type OnClause? ItBlock?
DefaultClause := "default" Expr
mod Passthrough {
incoming inp : Word[8]
outgoing out : Word[8]
out := inp
}
incoming ports receive values from outside the module. outgoing ports send values from the module to the outside.
A default clause on an outgoing port is only allowed in a platform
definition; in a module it is an error (DefaultOutsidePlatformPort).
See Platforms for how platform defaults synthesize ports a
module does not declare.
Wires
A wire declaration creates an internal combinational signal.
wire sum : Word[8]
sum := a + b
A wire may optionally have an on clause and a block, making it a clocked wire. Wires may also be given an initial value through an it block.
wire w : Word[8] {
w := a + b
}
Registers
A reg declaration creates a sequential (stateful) element. A register is associated with a clock via the on keyword; when the module declares exactly one component of type Clock, the on clause may be omitted and the module’s unique clock is used implicitly.
reg counter : Word[8] on clock
counter <= counter + 1
Registers update their value on the rising edge of the associated clock. The value assigned to a register uses the <= assignment operator, indicating a non-blocking (clocked) assignment.
A register may have an associated block for additional statements:
reg counter : Word[8] on clock {
it <= if {
when reset => 0
else => it + 1
}
}
Inside the register’s block, the special identifier it refers to the register itself.
Memories
A memory declaration creates a module-local, clocked, word-addressable memory (a behavioral RAM or ROM) with dynamic indexing and explicit write drivers. The depth is written in brackets after the component name — mirroring how the memory is read — and the element type follows the colon:
ModDefStmtMemory :=
DocString? Annotations "memory" Ident "[" Expr "]" ":" Type
OnClause? MemoryBlock?
MemoryBlock := "{" MemoryBlockStmt* "}"
MemoryBlockStmt :=
ModDefStmtDriver
| ModDefStmtIndexedDriver
| ModDefStmtUnused
| ModDefStmtInitial
| If
| Match
| Let
| ModDefStmtOnRegion
ModDefStmtOnRegion := "on" Expr ModDefStmtBlock
mod Ram {
incoming clock : Clock
incoming write_data : Word[32]
incoming addr : Word[10]
incoming write_enable : Bit
outgoing read_data : Word[32] dependson addr {
it := mem[dyn addr]
}
memory mem[1024] : Word[32] {
if {
when write_enable {
it[dyn addr] <= write_data
}
}
}
}
memory mem[1024] : Word[32] reads as “a memory mem with 1024 slots, each a Word[32]”.
Shape rules:
Both the depth and the element width must be literal powers of two; no generics are supported in these positions.
A dynamic index must be exactly Word[k] where depth == 2^k — a wider address must be narrowed explicitly, never truncated silently.
Static indices read and write one word (mem[3]); they must be in range.
A memory is readable only through an index: a bare mem or a slice of a memory is an error.
Clocking and writes:
A memory has one clock slot, resolved exactly like a reg’s: the on clause may be omitted when the module declares exactly one Clock.
A write is an indexed latched driver: mem[dyn idx] <= data (the first indexed left-hand side in the language; := is accepted with a warning).
Inside a memory body, writes live either in an on <CLOCK> { ... } region (for multi-clock modules) or at the top level of the body (the same single-clock inference as reg); only writes to the memory are allowed inside an on region. A blockless memory may be written from a guarded module-level write site.
A memory has exactly one write site, and sibling when clauses whose guards are not exclusive may not both write it — use when/else or match arms, whose arms are disjoint.
Reads are combinational and usable anywhere an expression is; multiple reads at different addresses in one cycle are legal (multiple asynchronous read ports). A write commits on the clock edge, and a read of an address written in the same cycle sees the old value (read-before-write). Words never written read as unspecified (pseudorandom in simulation). An outgoing port that combinationally reads a memory declares dependson naming the index’s incoming ports: the only combinational dependency a read introduces is on the index expression.
Memories lower to behavioral Verilog (inferred as block RAM by synthesis tools):
reg [31:0] mem [0:1023];
always @(posedge clock) begin
if (write_enable) begin
mem[addr] <= write_data;
end
end
assign read_data = mem[addr];
Initialization: the @image("path") annotation loads a raw binary file
word-packed in declaration order (most-significant byte first within each
word), and @image_hex("path") a text hex file with one word per line (à
la $readmemh). The @image_inline("bytes") annotation embeds the image
directly: the string literal’s C-style escapes (a b e f n r t v,
\ " ?, octal ooo, hex xNN) are decoded into the image’s bytes,
packed like a raw binary file — "Hello, world!rn0" is the same 15
bytes as "Hello, world!x0dx0ax00". Paths resolve relative to the
project root — the
directory where virdant.toml is found. An image shorter than the depth leaves the remaining words
unspecified; a longer image is an error. An image memory may also declare a
write site, in which case the image words are its initial contents and writes
commit on the clock edge (a pure ROM has no write site and needs no clock).
Whether image initialization with writes is supported is a toolchain property,
checked late-binding when a bitstream build resolves the FPGA family, not at
vir check time. On platforms annotated @noinitial, memory images are
disallowed, exactly like initial statements.
Tristate Nets
Tristate components model wires that more than one driver can drive, where undriven means floating (high impedance) rather than holding a value. There are two kinds:
bus declares a bidirectional port. It is visible to the parent module, and it appears as an inout port in generated Verilog.
net declares an internal tristate net. It is not a port; it is visible only within the module.
mod Participant {
incoming pull : Bit
bus sda : Bit {
if {
when pull {
it #= false
}
}
}
}
A bus or net may not have an on clause or a default clause. It may have an it block, which is the usual place to put its drivers.
Reading a tristate component is different from reading a wire or
register: because the value may be floating, a plain reference is a
compile error (NetReadWithoutTap). Reads must go through the tap
operator, which asserts that the net is driven at that point:
seen := tap sda
See Statements for the tristate driver operators (#=, #-, and #=#) and Expressions for tap.
Tristate components may have any number of drivers. When several drivers are active at once, a strong driver (#=) wins over weak ones (#-); if no driver is active, the net floats. An if or match guarding only tristate drivers does not need to be exhaustive: an undriven arm leaves the net floating rather than holding its previous value.
In generated Verilog, a bus port becomes an inout wire, a strong drive becomes a plain assign, a weak drive becomes assign (weak1, weak0), and #=# becomes a tran connecting the two sides.
Assignments
Virdant provides three assignment operators for driving signals.
- := (combinational assignment)
Drives a combinational signal. The target is continuously driven by the expression.
out := a + b // combinational: out always equals a + b
- <= (sequential assignment)
Drives a register. The target updates on the next clock edge.
reg r : Word[8] on clock r <= a + b // sequential: r updates on next clock edge
- :=: (bidirectional connection)
Connects two signals bidirectionally. Both sides drive each other.
memsrv.mem :=: core.mem
Module Instances
Module instances allow you to compose larger designs from smaller modules.
ModDefStmtInstance :=
DocString? Annotations "mod" Ident "of" Ofness GenericsParams? ItBlock?
mod ha1 of HalfAdder
ha1.a := a
ha1.b := b
sum := ha1.sum
carry := ha1.carry
The mod keyword introduces an instance, followed by a name for the instance, the of keyword, and the type (module name) being instantiated. When the module being instantiated is a generic module, the type arguments follow the module name in square brackets, as in mod r8 of Reg[Word[8]]. See Generic Modules below.
Ports on the instance are accessed using dot notation: <instance>.<port>.
An instance may optionally have an it block for grouping connections:
mod ha1 of HalfAdder {
it.a := a
it.b := b
}
sum := ha1.sum
carry := ha1.carry
Generic Modules
A module definition may declare generic parameters: a comma-separated list in square brackets after the module name, each parameter written name : kind, exactly like a generic type definition (see Types). The two kinds are Type (the parameter stands for a whole type) and Nat (a number literal, as in Word[n : Nat]).
mod Reg[T : Type] {
incoming clk : Clock
incoming d : T
reg slot : T on clk {
it <= d
}
outgoing q : T
q := slot
}
Generic parameters are in scope inside the body of the declaring module: port and component types may reference them by name (d : T and slot : T above). A generic module is a template: it is not emitted to Verilog on its own and has no concrete port widths until it is instantiated.
Instantiating a Generic Module
An instantiation of a generic module supplies type arguments after the module name, comma-separated and in the same order as the declared parameters:
pub mod Top {
incoming clk : Clock
incoming a : Word[8]
incoming wide : Word[16]
mod r8 of Reg[Word[8]] {
it.clk := clk
it.d := a
}
mod r16 of Reg[Word[16]] {
it.clk := clk
it.d := wide
}
outgoing out8 : Word[8]
outgoing out16 : Word[16]
out8 := r8.q
out16 := r16.q
}
Inside the it-block of an instantiation the parameters are substituted with the arguments: it.d of r8 has type Word[8], while it.d of r16 has type Word[16]. Connecting a Word[8] signal to r16 is therefore rejected:
mod r16 of Reg[Word[16]] {
it.clk := clk
it.d := a // a : Word[8] --- wrong width for T = Word[16]
}
reports Wrong type: Expected Word[16] but found Word[8].
The same generic module may be instantiated several times with different arguments in one design. Each distinct combination of a module definition and concrete type arguments is a mono module (a monomorphized instantiation); two instances with identical arguments share one mono module.
The number and the kinds of the type arguments are checked at the instantiation site, so diagnostics point at the arguments as written:
Wrong arity. mod f of Reg reports Type Reg takes 1 type
argument, but 0 were given.
Wrong kind. mod f of Reg[8] reports Type argument mismatch:
parameter T of Reg expects a type, but found a Nat literal.
Restrictions
Two hard rules restrict where generic parameters may appear.
An ext mod cannot have generic parameters: external modules
parameterize through @parameter annotations instead
(see Annotations).
The rule cuts both ways: an ext-mod instantiation cannot take type
arguments either, even when the ext mod declares none.
ext mod BadCell[T : Type] reports ext mod BadCell cannot have
generics: ext mods do not support type parameters (use @parameter
annotations instead).
A generic module cannot be a top: the top module named by [prog] top
in virdant.toml must be a concrete instantiation, and a generic
definition has none. (pub mod`s may be generic.)Building a project whose `[prog] top names a generic module reports
top module top::Fifo cannot have generic parameters: a top module must
be a concrete instantiation.
Monomorphized Verilog Generation
Verilog generation emits exactly one Verilog module per distinct mono module — one per (definition, type arguments) combination. Instantiations with identical arguments share the emitted module; a generic module that is never instantiated emits nothing.
Mono module names carry a hash of the type arguments, so instantiations
at different arguments never collide, and a // comment above each
emitted declaration records the readable origin.
For Top above, the generated Verilog contains:
// top::Reg[Word[8]]
module \top::Reg#bde2499a (
input wire clk,
input wire [7:0] d,
output wire [7:0] q
);
/* ... */
endmodule
// top::Reg[Word[16]]
module \top::Reg#e5a295e4 (
input wire clk,
input wire [15:0] d,
output wire [15:0] q
);
/* ... */
endmodule
and Top wires each instance up to its mono module by that name:
\top::Reg#bde2499a r8(
.clk(\r8.clk ),
.d (\r8.d ),
.q (\r8.q )
);
Socket Instances
Sockets provide a way to group related ports and connect them as a unit.
ModDefStmtSocket :=
DocString? Annotations "client" "socket" Ident "of" Ofness GenericsParams? ItBlock?
| DocString? Annotations "server" "socket" Ident "of" Ofness GenericsParams? ItBlock?
client socket mem of Mem {
it.addr := 0
unused it.data
}
A socket instance must specify its role: either client or server. The role determines the direction of the individual ports within the socket.
For more details on sockets, see Sockets.
Match Statements
Match statements enable pattern-based conditional logic in module bodies.
Match := "match" Expr "{" MatchArm* "}"
MatchArm :=
"with" Pat "=>" Expr
| "with" Pat ArmBlock
| "with" Pat If
| "with" Pat Match
| "with" Pat Let
| "else" "=>" Expr
| "else" ArmBlock
| "else" If
| "else" Match
| "else" Let
ArmBlock :=
"{" ModDefStmt* "}"
| "{" ArmBlockExpr "}"
enum type StateE {
Idle
Run
Halt
}
reg state : StateE on clock
reg r : Word[8] on clock
outgoing out : Word[8]
match state {
with #Idle {
r <= 0w8
out := 0w8
state <= #Run
}
with #Run {
r <= r + 1w8
out := r
}
else {
out := 0w8
}
}
The else arm catches any value not matched by the preceding with arms. If no else arm is present, the match must be exhaustive.
Unused Declarations
The unused keyword indicates that a signal is intentionally not used. This suppresses compiler warnings about unused signals.
ModDefStmtUnused :=
"unused" Path
unused mem.data
unused takes a path that may refer to a component, a port, or a field of an instance.
The it Identifier
Inside certain blocks (such as register blocks and instance blocks), the special identifier it refers to the enclosing declaration.
For a register it refers to the register itself. For an instance it refers to the instance.
reg counter : Word[8] on clock {
it <= if {
when reset => 0
else => it + 1
}
}
mod fifo of Fifo {
it.clock := clock
it.data := data
}
The it identifier is only available within the block of a declaration that provides it.
On Clause
The on clause associates a register with a clock signal.
reg r : Word[8] on clock
The clock signal referenced in the on clause must be of type Clock.
Every reg declaration must have an on clause, unless the module declares exactly one component of type Clock. In that case the on clause is optional on every reg declaration, and an omitted on clause is equivalent to writing on with that component (see Statements). If the module declares zero or more than one component of type Clock, every reg declaration needs an explicit on clause. When the module does declare exactly one component of type Clock, an explicit on clause that names that clock is redundant: it is equivalent to omitting it, and the compiler emits a warning encouraging the implicit form. An on clause that references any other clock (for example, a submodule’s clock output) is not redundant and never warns.
The register latches its input value on the rising edge of the named clock. All registers driven by the same clock form a single clock domain.
Driver Blocks
Driver blocks are groups additional module body statements under a declaration. The keyword it inside the block refers to the enclosing declaration.
reg r : Word[8] on clock {
it <= 0
out := it
}
Blocks are optional. When present, they provide a scoped grouping for related assignments and connections.
The dependson List
The dependson list is a modifier on an outgoing (or outgoing wire) component declaration: the module’s declaration of which incoming ports the port may combinationally depend on.
DependsonList :=
"dependson" Path ("," Path)*
pub mod Top {
incoming inp : Bit
outgoing out : Bit dependson inp {
it := inp
}
}
Multiple entries are comma separated:
pub mod Top {
incoming inp_0 : Bit
incoming inp_1 : Bit
outgoing out : Bit dependson inp_0, inp_1 {
it := inp_0 && inp_1
}
}
The list is part of the module’s public interface: it tells clients what the port may depend on without looking inside the module. A declaration is a statement of potential, not of actuality: dependson does not create a combinational dependency. It only promises the port may depend on the listed ports, and it must list every incoming port the port can combinationally depend on (directly, through internal signals, or through submodule declarations).
An entry must resolve to an incoming port of the module. Anything else (an outgoing port, a register, an internal signal, a path into an instance) is an error.
An undeclared dependency is an error: a port whose drivers (or whose submodules’ declared potential) reach an incoming port that is not in its dependson list is rejected.
A declared entry the port does not actually depend on is an unused dependson: a warning. (There is one exception: an extmod’s body is opaque, so its declarations cannot be checked and are never flagged as unused.)
Only outgoing and outgoing wire may carry the list. The grammar accepts it on any component kind so the checker can diagnose each invalid placement precisely:
outgoing reg: a registered output cannot change in the same cycle, so a declared dependency would be permanently false — an error.
incoming: an error.
bus: tristate components are not yet covered by dependson — an error.
wire, reg, net: internal signals may not declare dependson — an error.
Socket channels may declare dependson as well (see Sockets). A list on a cosi channel applies when the socket is instantiated as a client, and a list on a soci channel as a server; it places the same obligation on the containing module as if the dependson were declared on the resulting port. In the receiving role the list is inert, and socket-derived obligations are exempt from the unused warning (a protocol such as Wishbone legitimately varies its latency):
socket Wishbone {
cosi cyc : Bit
cosi stb : Bit
soci ack : Bit dependson cyc, stb
}
Combinational Cycle Detection
Virdant checks every module for combinational cycles over the module’s potentiality graph, whose edges are:
the module’s driver-derived combinational edges (:= targets depending on the signals their right-hand sides read, including the per-channel edges of :=: socket links);
the module’s own dependson lists and socket obligations;
the dependson lists and socket obligations declared by the module’s immediate submodule instances (the declarations are the boundary contract: Virdant never looks inside a child module).
Sequential (<=) edges are never traversed: a clock edge breaks the combinational path.
A cycle in this graph is an error even when no actual combinational path exists today. dependson declares potential: a designer must be free to make any declared dependency actual at any time without breaking clients, so the cycle check operates on the declared relation.
Each cycle is reported once, at the module whose own local edge closes it — a loop living entirely inside a submodule is reported there, and a loop spanning a parent and its children is reported at the parent:
mod Loop2 {
mod loop_a of LoopA
mod loop_b of LoopB
loop_a.inp := loop_b.out
loop_b.inp := loop_a.out
}
mod LoopA {
incoming inp : Bit
outgoing out : Bit dependson inp {
it := inp
}
}
Here loop_a.inp depends on loop_b.out, which (by LoopB’s declaration) may depend on loop_b.inp, which depends on loop_a.out, which may depend on loop_a.inp — a cycle in the dependson relation, reported at Loop2.