Basics
Ivy has special features for designing synchronous hardware. Ivy can model, specify and verify hierarchical RTL designs. Verified designs modeled in Ivy can be translated to a the Yosys intermediate language RTLIL. This makes it possible to simulate a design, check its equivalence with designs in other langauges, such as SystemVerilog, synthesize it to a gate level netlist and so forth.
Representing synchronous hardware in Ivy
A synchronous hardware design is a collection of registers (state that is updated on a clock edge) connected by combinational logic (acyclic networks of gates that settle between clock edges). In Ivy we model this with three ingredients:
- ordinary state variables (
var) for registers, - a special kind of defined symbol called a wire for combinational signals, and
- exported actions to represent clock edges, with
aftermonitors giving the register updates that happen on each edge.
A design is organized as a hierarchy of Ivy objects (isolates), each of which becomes a module in the generated netlist. The rest of this section describes each ingredient in turn.
Datatypes for hardware
Wires and state variables in a hardware design may have one of the following interpreted types:
bool, a single-bit truth value,bv[k], a bit vector of fixed widthk, andbv[j] -> bv[k], an array (memory) with abv[j]index andbv[k]values.
A bit-vector or array type is introduced by declaring an Ivy type and giving it an interpretation:
type word
interpret word -> bv[8]
Note that bool and bv[1] are distinct types in Ivy, even though both
become one-bit signals in the RTL.
The interpreted operators available on bit vectors are:
- the unsigned arithmetic operators
+,-,*and/, - the unsigned comparison operators
<,<=,>and>=, - bit-field extraction
bfe[i][j](w), which selects bitsjdown toiofw(so the result has widthj-i+1), and - the concatenation operator
concat, whose first argument becomes the high-order bits of the result.
For readers used to Verilog, the last two have infix sugar (in language version
1.8 and later): w<<j:i>> is bfe[i][j](w) (note the Verilog-style high-index
first, low-index second), and a1 :: a2 :: ... :: an is
concat(a1, a2, ..., an). So a nibble swap can be written x<<3:0>> ::
x<<7:4>>. The sugar is purely notational – it desugars to bfe/concat in
the parser – so, like concat, a concatenation still needs a sort annotation
where its width cannot otherwise be inferred.
Arrays are read by application, mem(a), and updated element-wise,
mem(a) := v. Using any other type in the implementation of a hardware
block is reported as an error during translation.
Combinational logic
A wire is a defined symbol that represents a combinational signal. Like an ordinary definition, a wire is a function of state variables and of other wires, and its definition may not be cyclic (we cannot model combinational loops). A wire may be declared and defined separately,
wire w : bool
definition w = <expr>
or in a single combined form,
wire w = <expr>
The separate form is useful when a wire should be visible in an isolate’s specification but its definition hidden in the implementation.
The inputs and outputs of a block are not declared explicitly. Instead, a
wire that is declared as an import is a top-level input of the design,
and a wire declared as an export is a top-level output. Here is a purely
combinational block — a two-input AND gate:
#lang ivy1.8
isolate andgate = {
import wire a : bool
import wire b : bool
export wire y : bool
definition y = a & b
}
Wires a and b are top-level inputs and y is a top-level output. This
block has no state and no clock; it is just combinational logic.
Clocks, registers and reset states
Each clock in the design is represented by an exported action with no parameters, called by the environment:
export action posedge
The execution of the action represents a clock edge. Registers are
ordinary state variables, and their updates on a clock edge are given by
an after monitor attached to the clock action. Reset logic is given by
after init, which runs when the fictional init action fires (the
global reset).
This is where the special semantics of wires matters: during the execution of an action a wire’s value is frozen at the value of its definition on entry to the action. So even though a register update mutates the state, the wires keep their pre-edge values throughout the update — exactly the behavior of combinational logic settling before a clock edge.
The following non-hierarchical design is an accumulator. It has an input
x, an output sum, and one register acc that adds x on every clock:
#lang ivy1.8
type word
interpret word -> bv[8]
export action posedge
isolate accum = {
import wire x : word # value to add each clock
export wire sum : word # the running total (output)
var acc : word
after init { acc := 0 }
after posedge { acc := acc + x }
definition sum = acc
}
On reset, acc becomes 0; on each clock edge it becomes acc + x. The
wire sum continuously exposes the register’s value as the output.
Hierarchical designs
The hierarchy of a design corresponds to the nesting of Ivy objects: if
object b contains sub-objects b.c and b.d, then b.c and b.d
become submodules of b in the elaborated netlist.
Inputs and outputs of a block are implicit in how its wires are used:
- a wire that is declared in a block but defined in its parent is an input of the block;
- a wire that is defined in a block and referenced in its parent is an output of the block;
- a wire that is neither is internal.
To keep this unambiguous, a block must obey two rules: a wire declared in a block may be assigned only inside the block or by its parent, and a reference inside a block may only be to the block’s own wires or to a wire of one of its children.
Here is a one-stage pipeline built from two submodules: inc, which is
combinational and adds one, and delay, which registers its input. The
top module wires them together, inp -> inc -> delay -> out:
#lang ivy1.8
type word
interpret word -> bv[8]
export action posedge
isolate top = {
import wire inp : word
export wire out : word
isolate inc = {
wire inp : word
wire out : word
definition out = inp + 1
}
isolate delay = {
wire inp : word
wire out : word
var val : word
after init { val := 0; }
after posedge { val := inp; }
definition out = val
}
definition inc.inp = inp
definition delay.inp = inc.out
definition out = delay.out
}
The definitions at the bottom are the interconnect: inc.inp (an input of
inc) is driven from the top-level input inp, delay.inp is driven from
inc.out (an output of inc), and the top-level output out is driven
from delay.out. This is doc/examples/hardware/test_to_rtl.ivy.
Specifying and verifying synchronous hardware
We can use all of the specification and proof methods available in Ivy for hardware designs.
Specifying and proving invariants
An invariant is a formula that is true in every reachable state. We
attach invariants to an isolate and check them with ivy_check. Here two
registers are toggled together and we assert they stay equal:
#lang ivy1.8
export action posedge
isolate sync = {
var x : bool
var y : bool
after init {
x := false;
y := false;
}
after posedge {
x := ~x;
y := ~y;
}
invariant x = y
}
Checking it:
$ ivy_check sync.ivy
...
OK
Ivy proves the invariant by induction: it holds initially (both registers
are false), and if x = y before a clock edge then ~x = ~y after, so
it is preserved.
Using abstract reference models
A powerful specification technique is to compare a design against an
abstract reference model: a simpler design that captures the intended
behavior. The reference model is written as a specification-only isolate
— placed inside a specification { ... } block — so it is used for
proof but is not emitted as hardware.
In doc/examples/hardware/refinement3.ivy, a two-bit counter is built by
cascading two one-bit counters. The abstract model abs is a plain two-bit
counter, and it is specification-only:
isolate abs = {
specification {
wire cout : bool
var val : uint[2]
after init {
val := 0;
}
after posedge {
if en {
val := val + 1;
}
}
definition cout = en & (val = 3)
}
}
The concrete bit c0 then states its correctness relative to the
reference model — its carry output should agree with bit 0 of the
abstract counter:
isolate c0 = {
wire en : bool
wire cout : bool
specification {
invariant [cout_spec] cout <-> en & (abs.val<<0:0>> = 1:uint[1])
}
implementation {
var val : uint[1]
after init { val := 0; }
after posedge { if en { val := val + 1; } }
definition cout = en & (val = 1)
invariant val = abs.val<<0:0>>
}
} with this
Because abs lives entirely in a specification block, the translator
ignores it: only c0 and c1 become hardware.
Compositional proofs
The two-bit counter is also a small compositional proof. Each isolate is
verified separately, using only the specifications (not the
implementations) of the other blocks. The with this clause on c0 and
c1 says the proof may use the surrounding (abstract) definitions.
Bit c1 establishes that the design’s carry output matches the abstract
carry, but its proof depends on the specification of c0:
isolate c1 = {
wire en : bool
wire cout : bool
specification {
invariant cout <-> abs.cout with c0.cout_spec
}
implementation {
var val : uint[1]
after init { val := 0; }
after posedge { if en { val := val + 1; } }
definition cout = en & (val = 1)
invariant val = abs.val<<1:1>>
}
} with this
The clause with c0.cout_spec records that the invariant of c1 has a
combinational (zero-delay) dependency on the invariant cout_spec of
c0: because the carry chain flows combinationally from c0 into c1,
proving c1’s output in the post-state may assume c0’s output holds in
the post-state, not just the pre-state. Such dependencies may not be
cyclic. This is Ivy’s form of circular compositional reasoning, and it
lets each one-bit slice be verified in isolation. The whole proof is
checked with:
$ ivy_check refinement3.ivy
...
OK
Translating Ivy designs to RTL
The ivy_to_rtl command translates an Ivy design to a Yosys RTLIL netlist:
$ ivy_to_rtl <file>.ivy
This writes the whole elaborated design to <file>.il in the current
directory, with one RTLIL module per Ivy object.
Examples
A simple, non-hierarchical design without memories. Translating the
accumulator above produces a single module. The wire definition and the
+ become an $add cell, the register becomes a $dff, and the after
init reset value is selected by a $mux on the flip-flop’s D input
(rst ? 0 : acc + x):
$ ivy_to_rtl accum.ivy
$ cat accum.il
module \accum
wire width 8 input 1 \x
wire width 8 output 2 \sum
wire input 3 \posedge
wire input 4 \rst
wire width 8 \acc
...
cell $add $add$1
...
connect \A \acc
connect \B \x
connect \Y $1
end
cell $mux $mux$accum.acc
parameter \WIDTH 8
connect \A $1
connect \B 8'00000000
connect \S \rst
connect \Y $2
end
cell $dff $dff$accum.acc
parameter \WIDTH 8
parameter \CLK_POLARITY 1
connect \CLK \posedge
connect \D $2
connect \Q \acc
end
connect \sum \acc
end
Each exported action becomes a clock input, and a global rst input is
routed to every block that has state.
A hierarchical design. Translating the one-stage pipeline top above
produces three modules — \top, \top.inc and \top.delay — with
top instantiating the two submodules as cells and connecting their
ports, introducing an auxiliary wire where inc’s output feeds delay’s
input:
$ ivy_to_rtl test_to_rtl.ivy
$ cat test_to_rtl.il
module \top
wire width 8 input 1 \inp
wire width 8 output 2 \out
wire input 3 \posedge
wire input 4 \rst
wire width 8 \delay.out
wire width 8 \inc.out
cell \top.delay \delay
connect \inp \inc.out
connect \out \delay.out
connect \posedge \posedge
connect \rst \rst
end
cell \top.inc \inc
connect \inp \inp
connect \out \inc.out
end
connect \out \delay.out
end
...
Memories and memory initializers. Array state variables become RTLIL
memories: reads become asynchronous $memrd ports and the clocked update
becomes a $memwr_v2 port. Assignments to an array in after init become
power-on memory contents ($meminit_v2 cells) — a broadcast fill when a
constant is assigned to the whole array, and one cell per point
assignment. The processor example pipe_cpu.ivy uses this to zero its
register file and load a program into its instruction ROM:
var rf(R:reg) : word # register file -> a memory
var imem(A:addr) : word # instruction ROM -> a memory
...
after init {
rf(R) := 0; # broadcast fill -> a $meminit covering all words
imem(0) := 0x6405; # point write -> one $meminit cell
imem(1) := 0x6801;
...
}
Translating it yields memory declarations and initializers:
$ ivy_to_rtl pipe_cpu.ivy
$ grep 'memory\|meminit' pipe_cpu.il
memory width 16 size 256 \imem
memory width 16 size 256 \mem
memory width 16 size 8 \rf
cell $meminit_v2 $meminit$imem$21
...
connect \ADDR 8'00000000
connect \DATA 16'0110010000000101 # 0x6405
...
See the pipelined CPU example for the full design.
Equivalence checking
Because the translated design is an ordinary RTLIL netlist, Yosys can check it for equivalence against a design written in another language, such as SystemVerilog. Here is the combinational nibble-swap written in Ivy,
#lang ivy1.8
type byte
interpret byte -> bv[8]
type nibble
interpret nibble -> bv[4]
isolate swap = {
import wire inp : byte
export wire out : byte
wire lo : nibble
wire hi : nibble
definition lo = inp<<3:0>>
definition hi = inp<<7:4>>
definition out = lo :: hi
}
and in SystemVerilog:
module swap(input [7:0] inp, output [7:0] out);
assign out = {inp[3:0], inp[7:4]};
endmodule
We translate the Ivy design, then have Yosys pair the two by port name and
prove each output cone equivalent with equiv_simple:
$ ivy_to_rtl swap.ivy
$ yosys -p "
read_verilog -sv swap.sv
hierarchy -top swap; rename swap gold; design -stash gold
read_rtlil swap.il
hierarchy -top swap; rename swap gate; design -stash gate
design -copy-from gold -as gold gold
design -copy-from gate -as gate gate
equiv_make gold gate equiv
hierarchy -top equiv
equiv_simple
equiv_status -assert
"
...
Equivalence successfully proven!
For a design with registers, matching the state-element names lets
equiv_make establish the correspondence, and equiv_induct (temporal
induction over the matched registers) proves sequential equivalence. See
README.md in this directory for that recipe.
Simulation
A translated design can be simulated with Yosys’s sim command to produce
a .vcd waveform trace. Since pipe_cpu.ivy loads its own program in
after init, the generated netlist is self-contained:
$ ivy_to_rtl pipe_cpu.ivy
$ yosys -p "read_rtlil pipe_cpu.il; hierarchy -top cpu; proc; memory_collect;
sim -clock posedge -reset rst -n 12 -vcd cpu.vcd"
Inspecting the pc_out output in cpu.vcd, the program-counter trace
after reset is 0, 1, 2, 3, 4, 2, 3, 4, ...: the processor fetches
sequentially and then loops back to the branch target 2. See the
pipelined CPU example for the details.
Synthesis
Finally, the netlist can be synthesized to a gate-level implementation. Here we synthesize the accumulator/pipeline example to a small set of generic gates plus flip-flops:
$ ivy_to_rtl test_to_rtl.ivy
$ yosys -p "read_rtlil test_to_rtl.il; synth -top top -flatten;
abc -g AND,OR,XOR; opt_clean; stat"
...
6 $_AND_
1 $_NOT_
7 $_XOR_
8 $_SDFF_PP0_
The eight-bit incrementer becomes AND/XOR/NOT gates feeding eight
synchronous-reset flip-flops. To map to a real standard-cell library
instead of generic gates, pass a Liberty file to abc with
abc -liberty <cells>.lib (and dfflibmap -liberty <cells>.lib for the
flip-flops).