A Python package for testing hardware (part of the magma ecosystem).
pip install fault
Check out the fault tutorial
Here is a simple ALU defined in magma.
import magma as m
class ConfigReg(m.Circuit):
io = m.IO(D=m.In(m.Bits[2]), Q=m.Out(m.Bits[2])) + \
m.ClockIO(has_ce=True)
reg = m.Register(m.Bits[2], has_enable=True)(name="conf_reg")
io.Q @= reg(io.D, CE=io.CE)
class SimpleALU(m.Circuit):
io = m.IO(
a=m.In(m.UInt[16]),
b=m.In(m.UInt[16]),
c=m.Out(m.UInt[16]),
config_data=m.In(m.Bits[2]),
config_en=m.In(m.Enable)
) + m.ClockIO()
opcode = ConfigReg(name="config_reg")(io.config_data, CE=io.config_en)
io.c @= m.mux(
[io.a + io.b, io.a - io.b, io.a * io.b, io.a ^ io.b], opcode)
Here's an example test in fault that uses the configuration interface, expects a value on the internal register, and checks the result of performing the expected operation.
import operator
import fault
ops = [operator.add, operator.sub, operator.mul, operator.floordiv]
tester = fault.Tester(SimpleALU, SimpleALU.CLK)
tester.circuit.CLK = 0
tester.circuit.config_en = 1
for i in range(0, 4):
tester.circuit.config_data = i
tester.step(2)
tester.circuit.a = 3
tester.circuit.b = 2
tester.eval()
tester.circuit.c.expect(ops[i](3, 2))
We can run this with three different simulators
tester.compile_and_run("verilator", flags=["-Wno-fatal"], directory="build")
tester.compile_and_run("system-verilog", simulator="ncsim", directory="build")
tester.compile_and_run("system-verilog", simulator="vcs", directory="build")
Fault supports peeking, expecting, and printing internal signals. For the
verilator
target, you should use the keyword argument magma_opts
with
"verilator_debug"
set to true. This will cause coreir to compile the verilog
with the required debug comments. Example:
tester.compile_and_run("verilator", flags=["-Wno-fatal"],
magma_opts={"verilator_debug": True}, directory="build")
If you're using mantle.Register
from the coreir
implementation, you can
also poke the internal register value directly using the value
field. Notice
that conf_reg
is defined in ConfigReg
to be an instance of
mantle.Register
and the test bench pokes it by setting confg_reg.value
equal to 1
.
tester = fault.Tester(SimpleALU, SimpleALU.CLK)
tester.circuit.CLK = 0
# Set config_en to 0 so stepping the clock doesn't clobber the poked value
tester.circuit.config_en = 0
# Initialize
tester.step(2)
for i in reversed(range(4)):
tester.circuit.config_reg.conf_reg.value = i
tester.step(2)
tester.circuit.config_reg.conf_reg.O.expect(i)
# You can also print these internal signals using the getattr interface
tester.print("O=%d\n", tester.circuit.config_reg.conf_reg.O)
A common pattern in testing is to only perform certain actions depending on the
state of the circuit. For example, one may only want to expect an output value
when a valid signal is high, ignoring it otherwise. Another pattern is to change
the expected value over time by using a looping structure. Finally, one may
want to expect a value that is a function of other runtime values. To support
these pattern, fault
provides support "peeking" values, performing expressions
on "peeked" values, if statements, and while loops.
Suppose we had a circuit as follows:
class BinaryOpCircuit(m.Circuit):
io = m.IO(I0=m.In(m.UInt[5]), I1=m.In(m.UInt[5]), O=m.Out(m.UInt[5]))
io.O @= io.I0 + io.I1 & (io.I1 - io.I0)
We can write a generic test that expects the output O
in terms
of the inputs I0
and I1
(rather than computing the expected value in
Python).
tester = fault.Tester(BinaryOpCircuit)
for _ in range(5):
tester.poke(tester._circuit.I0, hwtypes.BitVector.random(5))
tester.poke(tester._circuit.I1, hwtypes.BitVector.random(5))
tester.eval()
expected = tester.circuit.I0 + tester.circuit.I1
expected &= tester.circuit.I1 - tester.circuit.I0
tester.circuit.O.expect(expected)
This is a useful pattern for writing reuseable test components (e.g. composign the output checking logic with various input stimuli generators).
The tester._while(<test>)
action accepts a Peek value or expression as the test condition for a loop and returns a child tester that allows the user to add actions to the body of the loop. Here's a simple example that loops until a done signal is asserted, printing some debug information in the loop body:
# Wait for loop to complete
loop = tester._while(dut.n_done)
debug_print(loop, dut)
loop.step()
loop.step()
# check final state
tester.circuit.count.expect(expected_num_cycles - 1)
Notice that you can also add actions after the loop to check expected behavior after the loop has completed.
The tester._if(<test>)
action behaves similarly by accepting a test peek value or expression and conditionally executes actions depending on the
result of the expression. Here is a simple example:
if_tester = tester._if(tester.circuit.O == 0)
if_tester.circuit.I = 1
else_tester = if_tester._else()
else_tester.circuit.I = 0
tester.eval()
The tester._for(<num_iter>)
action provides a simple way to write a loop over
a fixed number of iterations. Use the attribute index
to get access to the
current iteration, for example:
loop = tester._for(8)
loop.poke(circ.I, loop.index)
loop.eval()
tester.expect(circ.O, loop.index)
Here are the supported Python values for poking the following port types:
m.Bit
- bool
(True
/False
) or int
(0
/1
) or hwtypes.Bit
m.Bits[N]
- hwtypes.BitVector[N]
, int
(where the number of bits used to
express it is equal to N
)m.SInt[N]
- hwtypes.SIntVector[N]
, int
(where the number of bits used to
express it is equal to N
)m.UInt[N]
- hwtypes.UIntVector[N]
, int
(where the number of bits used to
express it is equal to N
)m.Array[N, T]
- list
(where the length of the list is equal to N
and
the elements recursively conform to the supported types of values for T
).
For example, suppose I have a port I
of type m.Array[3, m.Bits[3]]
.
I can poke it as follows:
val = [random.randint(0, (1 << 4) - 1) for _ in range(3)]
tester.poke(circ.I, val)
You can also poke it by element as follows:
for i in range(3):
val = random.randint(0, (1 << 4) - 1)
tester.poke(circ.I[i], val)
tester.eval()
tester.expect(circ.O[i], val)
m.Tuple(a=m.Bits[4], b=m.Bits[4])
- tuple
(where the length of the tuple is equal to the number of fields), dict
(where there is a one-to-one mapping between key/value pairs to the tuple fields). Example:
tester.circuit.I = (4, 2)
tester.eval()
tester.circuit.O.expect((4, 2))
tester.circuit.I = {"a": 4, "b": 2}
tester.eval()
tester.circuit.O.expect({"a": 4, "b": 2})
Fault supports generating .vcd
dumps when using the verilator
and
system-verilog/ncsim
target.
For the verilator
target, use the flags
keyword argument to pass the
--trace
flag. For example,
tester.compile_and_run("verilator", flags=["-Wno-fatal", "--trace"])
The --trace
flag must be passed through to verilator so it generates code
that supports waveform dumping. The test harness generated by fault will
include the required logic for invoking tracer->dump(main_time)
for every
call to eval
and step
. main_time
is incremented for every call to step.
The output .vcd
file will be saved in the file logs/{circuit_name}
where
circuit_name
is the name of the ciruit passed to Tester
. The logs
directory will be placed in the same directory as the generated harness, which
is controlled by the directory
keyword argument (by default this is
"build/"
).
For the system-verilog
target, enable this feature using the
compile_and_run
parameter dump_waveform=True
. By default, the waveform
file will be named waveforms.vcd
for ncsim
and waveforms.vpd
for vcs
.
The name of the file can be changed using the parameter
waveform_file="<file_name>"
.
The vcs
simulator also supports dumping fsdb
by using the argument
waveform_type="fsdb"
. For this to work, you'll need to also use the flags
argument using the path defined in your verdi manual. For example,
$VERDI_HOME/doc/linking_dumping.pdf
.
Here is an example using an older version of verdi (using the VERDIHOME environment variable):
verdi_home = os.environ["VERDIHOME"]
# You may need to change the 'vcs_latest' and 'LINUX64' parts of the path
# depending on your verdi version, please consult
# $VERDI_HOME/doc/linking_dumping.pdf
flags = ['-P',
f' {verdi_home}/share/PLI/vcs_latest/LINUX64/novas.tab',
f' {verdi_home}/share/PLI/vcs_latest/LINUX64/pli.a']
tester.compile_and_run(target="system-verilog", simulator="vcs",
waveform_type="fsdb", dump_waveforms=True, flags=flags)
Here's an example for a newer version of verdi
verdi_home = os.environ["VERDI_HOME"]
flags = ['-P',
f' {verdi_home}/share/PLI/VCS/linux64/novas.tab',
f' {verdi_home}/share/PLI/VCS/linux64/pli.a']
tester.compile_and_run(target="system-verilog", simulator="vcs",
waveform_type="fsdb", dump_waveforms=True, flags=flags)
To configure fsdb dumping, use the fsdb_dumpvars_args
parameter of the
compile_and_run command to pass a string to the $fsdbDumpvars()
function.
For example:
tester.compile_and_run(target="system-verilog", simulator="vcs",
waveform_type="fsdb", dump_waveforms=True,
fsdb_dumpvars_args='0, "dut"')
will produce:
$fsdbDumpvars(0, "dut");
inside the generated test bench.
The verilator
and system-verilog
target support the parameter flags
which
accepts a list of flags (strings) that will be passed through to the simulator
command (verilator
for verilator, irun
for ncsim, vcs
for vcs, and
iverilog
for iverilog).
Use the msg
argument to the expect action. You can either pass a standalone
string, e.g.
tester.circuit.O.expect(0, msg="my error message")
or you can pass a printf/$display style message using a tuple. The first argument should be the format string, the subsequent arguments are the format values, e.g.
tester.circuit.O.expect(0, msg=("MY_MESSAGE: got %x, expected 0!",
tester.circuit.O))
Yes, you can use the tester.print
API which accepts a format string and a
variable number of arguments. Here's an example:
tester = fault.Tester(circ, circ.CLK)
tester.poke(circ.I, 0)
tester.eval()
tester.expect(circ.O, 0)
tester.poke(circ.CLK, 0)
tester.step()
tester.print("%08x\n", circ.O)
Yes, here's an example:
# compile the tester
tester.compile("verilator")
# generate the test bench file (returns the name of the file)
tb_file = tester.generate_test_bench("verilator")
or for system verilog
tester.compile("system-verilog", simulator="ncsim")
tb_file = tester.generate_test_bench("system-verilog")
Fault provides a ReadyValidTester
that provides a few convenient features for
unit testing ReadyValid
interfaces with sequences.
Consider the following circuit:
class Main2(m.Circuit):
io = m.IO(I=m.Consumer(m.ReadyValid[m.UInt[8]]),
O=m.Producer(m.ReadyValid[m.UInt[8]]),
inc=m.In(m.UInt[8]),
) + m.ClockIO()
count = m.Register(m.UInt[2])()
count.I @= count.O + 1
enable = io.I.valid & (count.O == 3) & io.O.ready
io.I.ready @= enable
io.O.data @= m.Register(m.UInt[8], has_enable=True)()(io.I.data + io.inc,
CE=enable)
io.O.valid @= enable
The output stream O
is the input stream I
incremented by the value of inc
and delayed by 4 cycles.
Here's a simple test that provides the input sequence I
and expected output
sequence O
def test_lifted_ready_valid_sequence_simple():
I = [BitVector.random(8) for _ in range(8)] + [0]
O = [0] + [i + 2 for i in I[:-1]]
tester = f.ReadyValidTester(Main2, {"I": I, "O": O})
tester.circuit.inc = 2
tester.finish_sequences()
tester.compile_and_run("verilator", disp_type="realtime")
Notice that we provide the sequences as a dictionary mapping port name to
sequence in the constructor. Afterwards, we are free to poke
values like in a normal tester, in this case provide 2
for inc
which will satisfy the provided stream.
NOTE: The user must explicitly use the tester.circuit
peek/poke
interface, or call tester.poke(tester._circuit, value)
since the user circuit
is wrapped internally (cannot call tester.poke(Main2, value)
).
The test finishes by calling tester.finish_sequences()
which is a convenience
API that waits for the provided sequences to finish.
Here's a different version of the above test that should fail (changing the inc
value mid test).
def test_lifted_ready_valid_sequence_simple_fail():
I = [BitVector.random(8) for _ in range(8)] + [0]
O = [0] + [i + 2 for i in I[:-1]]
tester = f.ReadyValidTester(Main2, {"I": I, "O": O})
tester.circuit.inc = 2
# Should work for a few cycles
for i in range(9):
tester.advance_cycle()
# Bad inc should fail
tester.circuit.inc = 3
tester.finish_sequences()
with pytest.raises(AssertionError):
tester.compile_and_run("verilator", disp_type="realtime")
Finally, here's a variant that changes the inc
value over time to match the expected sequence.
def test_lifted_ready_valid_sequence_changing_inc():
I = [BitVector.random(8) for _ in range(8)] + [0]
O = [0] + [I[i] + ((i + 1) % 2) for i in range(8)]
tester = f.ReadyValidTester(Main2, {"I": I, "O": O})
# Sequence expects inc to change over time
for i in range(8):
tester.circuit.inc = i % 2
tester.advance_cycle()
tester.wait_until_high(tester.circuit.O.ready & tester.circuit.O.valid)
# Advance one cycle to finish last handshake
tester.advance_cycle()
tester.expect_sequences_finished()
tester.compile_and_run("verilator", disp_type="realtime")
NOTE: At the end of the test, we call expect_sequences_finished()
to
assert that the sequences have all been processed, otherwise it's possible tha
the test could pass without completing the sequences.