Bitvis UVVM VHDL Verification Component Framework: Difference between revisions

From ift
Line 583: Line 583:


== Checking register write and read ==
== Checking register write and read ==
Now we're enabled to write to and read from the registers. The register addresses are defined in the IRQC package file irqc_pif_bkg.vhd. Notice that we also use the previously declared overloaded version of log().
Now we're enabled to write to and read from the registers. The register addresses are defined in the IRQC package file irqc_pif_bkg.vhd. Notice that we also use the previously declared overloaded version of log() and fit().


<pre>
<pre>
Line 602: Line 602:
     check(C_ADDR_IER, fit(x"00"), ERROR, "IER pure readback");
     check(C_ADDR_IER, fit(x"00"), ERROR, "IER pure readback");
</pre>
</pre>
This check will give us a nice log if everything turns out ok:
[[File:sim2.png|400px]]
However, if there's an error:
[[File:error2.png|400px]]

Revision as of 14:44, 28 January 2016

-- Copyright (c) 2016 by Bitvis AS.  All rights reserved.
-- You should have received a copy of the license file containing the MIT License (see LICENSE.TXT), if not, 
-- contact Bitvis AS <support@bitvis.no>.
-- UVVM AND ANY PART THEREOF ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
-- INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-- IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH UVVM.
--========================================================================================================================

This wiki page steals heavily from the Powerpoint-presentation found here.

Introduction

Bitvis UVVM VVC Framework is a complete framework for making VHDL testbenches for verification of FPGA and ASIC desing. You can download the complete code-base, examples and simulations scripts from the Bitvis web page.

What's in the folders?

The download includes severals folders:

  • bitvis_irqc - example VHDL design + testbench
  • bitvis_uart - example VHDL design + testbench
  • bitvis_vip_sbi - Verification IP(VIP) for simple bus interface(SBI)
  • bitvis_vip_uart - VIP for UART TX and RX
  • uvvm_util - UVVM utility library - sufficient for simple testbenches
  • uvvm_vvc_framework - Framework for more advanced tutorials

IRQC

The provided example VHDL design is a simple interrupt controller with several internal registers, a bus interface and some input and output signals.

Testbench creation

Copy the folders bitvis_irqc, bitvis_vip_sbi and uvvm_util to another location before editing the files.

Generate TB entity with DUT instantiated

Our TB entity can in many cases be generated from several tools. Notepad++ (among other) supports plugins that enables copying an entity and pasting it as an instantiation, and also as a complete testbench template. However, we will change some of our signals so that they fit the VIP SBI BFM. The signals to and from the CPU will be converted to t_sbi_if record, which is a type that includes all the SBI signals (cs, addr, rd, wr, wdata, ready and rdata).

--Standard libraries
library IEEE;
use IEEE.std_logic_1164.all;
use IEEE.numeric_std.all;

-- Libraries used for string handling in UVVM
library STD;
use std.env.all;

-- Obviously the UVVM library
library uvvm_util;
context uvvm_util.uvvm_util_context;

-- We will use this library later when implementing the Bus Functional Model
-- Includes among much else the record type t_sbi_if and many functions
-- If other buses are used, you will have to change this library
library bitvis_vip_sbi;
use bitvis_vip_sbi.sbi_bfm_pkg.all;

-- This file includes definitions of everything from registers to record types
use work.irqc_pif_pkg.all;


-- Test case entity
entity irqc_tb is
end entity;

-- Test case architecture
architecture func of irqc_tb is

  -- DSP interface and general control signals
  signal clk           : std_logic  := '0';
  signal arst          : std_logic  := '0';
  -- CPU interface
  -- t_sbi_if is from the verification IP SBI
  -- init_sbi_if_signals initialize the inputs to 0 and the outputs to Z
  signal sbi_if : t_sbi_if(addr(2 downto 0), wdata(7 downto 0), rdata(7 downto 0)) := init_sbi_if_signals(3, 8);
  
  -- Interrupt related signals
  signal irq_source    : std_logic_vector(C_NUM_SOURCES-1 downto 0) := (others => '0');
  signal irq2cpu       : std_logic := '0';
  signal irq2cpu_ack   : std_logic := '0';

begin

  -----------------------------------------------------------------------------
  -- Instantiate DUT
  -----------------------------------------------------------------------------
  i_irqc: entity work.irqc
    port map (
    -- DSP interface and general control signals
        clk             => clk,
        arst            => arst,
    -- CPU interface
        cs              => sbi_if.cs,             -- NOTICE THE SIGNALS ARE NOW SBI_IF
        addr            => sbi_if.addr,
        wr              => sbi_if.wr,
        rd              => sbi_if.rd,
        din             => sbi_if.wdata,
        dout            => sbi_if.rdata,
    -- Interrupt related signals
        irq_source      => irq_source,
        irq2cpu         => irq2cpu,
        irq2cpu_ack     => irq2cpu_ack
        );
 
end func;

Add support process for clock generation

We now have to add a support process that controls the clock. This has to allow enabling/disabling from the test sequencer. We add the following before "begin" in our architecture:

-- Added for clock generation
  signal clock_ena  : boolean := false;

  constant C_CLK_PERIOD : time := 10 ns;
  
  procedure clock_gen(
    signal   clock_signal  : inout std_logic;
    signal   clock_ena     : in    boolean;
    constant clock_period  : in    time
  ) is
    variable v_first_half_clk_period : time := C_CLK_PERIOD / 2;
  begin
    loop
      if not clock_ena then
        wait until clock_ena;
      end if;
      wait for v_first_half_clk_period;
      clock_signal <= not clock_signal;
      wait for (clock_period - v_first_half_clk_period);
      clock_signal <= not clock_signal;
    end loop;
  end;

Our clock can now be activated from the test sequencer (this will be added in the next step):

-- After begin in the architecture
clock_gen(clk, clock_ena, C_CLK_PERIOD);


-- Inside the test sequencer process
clock_ena <= true;

Add test sequencer process

The next step is to add the test sequencer process. This process controls everything from initialization to termination of the simulation.

 -- Set upt clock generator
  clock_gen(clk, clock_ena, C_CLK_PERIOD);      
 
  ------------------------------------------------
  -- PROCESS: p_main
  ------------------------------------------------
  p_main : process
  -- The scope tells you where log messages originates - C_SCOPE tells us they originate from the default test sequencer scope
  constant C_SCOPE     : string  := C_TB_SCOPE_DEFAULT;
  -- This is where we will add some procedures later to simplify the tests
  
  begin
  
  --Print the configuration to the log
  report_global_ctrl(VOID);
  report_msg_id_panel(VOID);
  
  enable_log_msg(ALL_MESSAGES);
  --disable_log_msg
  --enable_log_msg(ID_LOG_HDR);
  
  log(ID_LOG_HDR, "Start Simulation of TB for IRQC", C_SCOPE);
  ------------------------------------------------------------
  clock_ena <= true;   -- to start clock generator
  
  
  ------------------------------------------------------------
  -- End the simulation
  wait for 1000 ns;                       -- to allow some time for completion
  report_alert_counters(FINAL);            -- Report final counters and print conclusion for simulation (Success/Fail)
  log(ID_LOG_HDR, "SIMULATION COMPLETED", C_SCOPE);
  
  --Finish the simulation
  std.env.stop;
  wait; -- to stop completely
  end process p_main;

Simulation

We now have the skeleton of the testbench, which we will develop further. But now, let's see if everything works. Bitvis have created simulation scripts for the IRQC example that compiles everything we need, from the source files of the VHDL design, to the testbench (if you called the file irqc_tb.vhd and placed it in the tb-folder) and the SBI BFM and the UVVM library. Open up QuestaSim/ModelSim. Change directory to the script folder:

cd ~/phys321/bitviswiki/bitvis_irqc/script
do compile_and_sim_all.do

This will present our result in the transcript windows, but also write _Log.txt file which includes all the information we have asked for. We see that we get the results from the following code:

report_global_ctrl(VOID);
report_msg_id_panel(VOID);
enable_log_msg(ALL_MESSAGES);
log(ID_LOG_HDR, "Start Simulation of TB for IRQC", C_SCOPE);
report_alert_counters(FINAL);
log(ID_LOG_HDR, "SIMULATION COMPLETED", C_SCOPE);

Commenting these out will result in an empty log.

Verbosity control

We want to able to control the amount of information in our logs, and the framework enables us to prioritize messages based on ID. This makes it easy to turn on or off the information we want. To turn on a specific ID

enable_log_msg(IDNAME);

Turn off:

disable_log_msg(IDNAME);

For writing a message to a certain log ID:

log(IDNAME, "MESSAGE HERE", C_SCOPE);

Remember that C_SCOPE just tells us that the message originated from the default scope and will look like "TB seq." in the log file.

Exampled IDs:

  • ID_LOG_HDR, -- ONLY allowed in test sequencer, Log section headers
  • ID_SEQUENCER, -- ONLY allowed in test sequencer, Normal log (not log headers)
  • ID_BFM, -- Used inside a BFM (to log BFM access)
  • ID_CLOCK_GEN, -- Used for logging when clock generators are enabled or disabled
  • ALL_MESSAGES -- Applies to ALL message ID apart from ID_NEVER

You'll find all the different ID's in the UVVM Utility Library Quick Reference or defined in uvvm_util/adaptions_pkg.vhd. This also where C_TB_SCOPE_DEFAULT is defined.

Implement first tests

We want to check and verify that our testbench is up and running and to verify our first tests of the DUT. This means that we have to able to set all our signals passive, apply a reset signal and then check the default outputs of the DUT.

Instead of setting all our signals passive one-by-one in our test sequencer we declare a procedure in our p_main process(this is done before begin):

procedure set_inputs_passive(
      dummy   : t_void) is           --dummy variable is included only to allow calling the procedure with parenthesis for readability
    begin
      sbi_if.cs           <= '0';
      sbi_if.addr         <= (others => '0');
      sbi_if.wr           <= '0';
      sbi_if.rd           <= '0';
      sbi_if.wdata          <= (others => '0');
      irq_source   <= (others => '0');
      irq2cpu_ack  <= '0';
      log(ID_SEQUENCER_SUB, "All inputs set passive", C_SCOPE);
    end;

Note that the procedure declaration also includes a dummy variable parameter. This means that we will be able to call the procedure with the more readable:

set_inputs_passive(VOID);

Rather than:

set_inputs_passive;

which is more ambigious.

We may also would like to send pulses on different signals, f.ex. sending a pulse on our reset to see if it behaves like intended. We therefore can include a pulse procedure:

procedure pulse(
      signal   target        : inout  std_logic_vector;
      constant pulse_value   : in     std_logic_vector;
      signal   clock_signal  : in     std_logic;
      constant num_periods   : in     natural;
      constant msg           : in     string) is
    begin
      if num_periods > 0 then
        wait until falling_edge(clock_signal);
        target <= pulse_value;
        for i in 1 to num_periods loop
          wait until falling_edge(clock_signal);
        end loop;
      else
        target <= pulse_value;
        wait for 0 ns;  -- Delta cycle only
      end if;
      target(target'range) <= (others => '0');
      log(ID_SEQUENCER_SUB, "Pulsed to " & to_string(pulse_value, HEX, AS_IS, INCL_RADIX) & ". " & msg, C_SCOPE);
    end;

In the above example the test sequencer is required to inform the procedure of what value the pulse is to take. The call to the procedure would take the following form:

pulse(arst, 'Z', clk, 10, "Log message - Im pulsing the value 'Z'");

But a more specific overload can be created where pulse always takes value '1':

procedure pulse(
      signal   target          : inout std_logic;
      signal   clock_signal    : in    std_logic;
      constant num_periods     : in    natural;
      constant msg             : in    string
    ) is
    begin
      if num_periods > 0 then
        wait until falling_edge(clock_signal);
        target  <= '1';
        for i in 1 to num_periods loop
          wait until falling_edge(clock_signal);
        end loop;
      else
        target  <= '1';
        wait for 0 ns;  -- Delta cycle only
      end if;
      target  <= '0';
      log(ID_SEQUENCER_SUB, msg, C_SCOPE);
    end;

These procedures can now be called directly from our test sequence:

set_inputs_passive(VOID);
pulse(arst, clk, 10, "pulsed reset-signal - active for 10T");

To check signal values we can use the built-in check function check_value():

check_value(irq2cpu, 'X', ERROR, "Interrupt to CPU must be default inactive", C_SCOPE);

The above call checks if the signal irq2cpu is 'X', and obviously fail if everything works correctly and gives the following message:

If we want we can change the number of errors logged before the simulation stops:

set_alert_stop_limit(ERROR, 3);

We now have all the tools needed for the first tests in our sequencer:

set_inputs_passive(VOID);
pulse(arst, clk, 10, "Pulsed reset-signal - active for 10T");
  
check_value(C_NUM_SOURCES > 0, FAILURE, "Must be at least 1 interrupt source", C_SCOPE);
check_value(C_NUM_SOURCES <= 8, TB_WARNING, "This TB is only checking IRQC with up to 8 interrupt sources", C_SCOPE);
  
log(ID_LOG_HDR, "Check defaults on output ports", C_SCOPE);
------------------------------------------------------------
check_value(irq2cpu, '0', ERROR, "Interrupt to CPU must be default inactive", C_SCOPE);
check_value(sbi_if.rdata, x"00", ERROR, "Register data bus output must be default passive");

This will give us the following log:

This information may only interesting initially and for debug, and can be turned on or off by use of ID.

Subprograms

Some of our testbench code will be repeated several times and the testbench may therefore benefit from creating several subprograms. Obvious examples for our IRQC is: - Register access - Signal checkers - Interrupt source pulsing? - Interrupt acknowledge pulsing? - (Report/log method) - (Alert-handling) - (reset, set_passive, ...)

We've already created and declared set_passive and pulse procedures, but we could f.ex create overloads for UVVM procedures:

    -- Log overloads for simplification
    procedure log(
      msg   : string) is
    begin
      log(ID_SEQUENCER, msg, C_SCOPE);
    end;

Let's say that it is probable that we'll want to change the number of interrupt sources that the controller can handle. We will then want to able to easily change vectors to the appropriate size. One way is to declare procedures that can trim and fit vectors. This way we can simply change a constant to change the number of sources.

  subtype t_irq_source is std_logic_vector(C_NUM_SOURCES-1 downto 0);

  -- Trim (cut) a given vector to fit the number of irq sources (i.e. pot. reduce width)
  function trim(
    constant source   : std_logic_vector;
    constant num_bits : positive := C_NUM_SOURCES)
  return t_irq_source is
    variable v_result : std_logic_vector(source'length-1 downto 0) := source;
  begin
    return v_result(num_bits-1 downto 0);
  end;

  -- Fit a given vector to the number of irq sources by masking with zeros above irq width
  function fit(
    constant source   : std_logic_vector;
    constant num_bits : positive := C_NUM_SOURCES)
  return std_logic_vector is
    variable v_result : std_logic_vector(source'length-1 downto 0) := (others => '0');
    variable v_source : std_logic_vector(source'length-1 downto 0) := source;
  begin
    v_result(num_bits-1 downto 0) := v_source(num_bits-1 downto 0);
    return v_result;
  end;

All IRQC-dedicated subprograms should be declared locally, but more common (f.ex bus-specific) should be declared in common package that can be shared with other.

Register access

To access the IRQC's registers we need to go through the actual process of writing and reading data from them. Fortunately, Bitvis have already taken the responsibility of writing the BFM for the SBI. This doesn't mean that we doesn't have to understand what's going on, since we'll have to write our own BFM's for other buses that we use(Avalon, AXI, etc). So we should investigate the BFM procedures. We want to check register values:

procedure sbi_check (
    constant addr_value   : in    unsigned;
    constant data_exp     : in    std_logic_vector;
    constant alert_level  : in    t_alert_level     := error;
    constant msg          : in    string;
    signal   clk          : in    std_logic;
    signal   cs           : inout std_logic;
    signal   addr         : inout unsigned;
    signal   rd           : inout std_logic;
    signal   wr           : inout std_logic;
    signal   ready        : in    std_logic;
    signal   rdata        : in    std_logic_vector;
    constant scope        : in    string            := C_SCOPE;
    constant msg_id_panel : in    t_msg_id_panel    := shared_msg_id_panel;
    constant config       : in    t_sbi_bfm_config  := C_SBI_BFM_CONFIG_DEFAULT
  ) is
    constant proc_name    : string :=  "sbi_check";
    constant proc_call    : string :=  "sbi_check(A:" & to_string(addr_value, HEX, AS_IS, INCL_RADIX) &
                                       ", "  & to_string(data_exp, HEX, AS_IS, INCL_RADIX) & ")";
    -- Normalize to the DUT addr/data widths
    variable v_normalised_addr    : unsigned(addr'length-1 downto 0) :=
      normalize_and_check(addr_value, addr, ALLOW_WIDER_NARROWER, "addr_value", "sbi_core_in.addr", msg);
    -- Helper variables
    variable v_data_value         : std_logic_vector(rdata'length - 1 downto 0);
    variable v_check_ok           : boolean;
    variable v_clk_cycles_waited  : natural := 0;
  begin
    sbi_read(addr_value, v_data_value, msg, clk, cs, addr, rd, wr, ready, rdata, scope, msg_id_panel, config, proc_name);

    -- Compare values, but ignore any leading zero's if widths are different.
    -- Use ID_NEVER so that check_value method does not log when check is OK,
    -- log it here instead.
    v_check_ok := check_value(v_data_value, data_exp, alert_level, msg, scope, HEX_BIN_IF_INVALID, SKIP_LEADING_0, ID_NEVER, msg_id_panel, proc_call);
    if v_check_ok then
      log(config.id_for_bfm, proc_call & "=> OK, read data = " & to_string(v_data_value, HEX, SKIP_LEADING_0, INCL_RADIX) & ". " & msg, scope, msg_id_panel);
    end if;
  end procedure;

We see that sbi_check() calls sbi_read() before it checks if the read value is the expected value.

procedure sbi_read (
    constant addr_value    : in     unsigned;
    variable data_value    : out    std_logic_vector;
    constant msg           : in     string;
    signal   clk           : in     std_logic;
    signal   cs            : inout  std_logic;
    signal   addr          : inout  unsigned;
    signal   rd            : inout  std_logic;
    signal   wr            : inout  std_logic;
    signal   ready         : in     std_logic;
    signal   rdata         : in     std_logic_vector;
    constant scope         : in     string            := C_SCOPE;
    constant msg_id_panel  : in     t_msg_id_panel    := shared_msg_id_panel;
    constant config        : in     t_sbi_bfm_config  := C_SBI_BFM_CONFIG_DEFAULT;
    constant proc_name     : in     string            := "sbi_read"  -- overwrite if called from other procedure like sbi_check
  ) is
    constant proc_call            : string := "sbi_read(A:" & to_string(addr_value, HEX, AS_IS, INCL_RADIX) & ")";
    -- Normalize to the DUT addr/data widths
    variable v_normalised_addr    : unsigned(addr'length-1 downto 0) :=
        normalize_and_check(addr_value, addr, ALLOW_WIDER_NARROWER, "addr_value", "sbi_core_in.addr", msg);
    variable v_data_value         : std_logic_vector(data_value'range);
    variable v_clk_cycles_waited  : natural := 0;
  begin
    wait_until_given_time_after_rising_edge(clk, config.clock_period/4);
    cs   <= '1';
    wr   <= '0';
    rd   <= '1';
    addr <= v_normalised_addr;
    wait for config.clock_period;
    while (config.use_ready_signal and ready = '0') loop
      if v_clk_cycles_waited = 0 then
        log(config.id_for_bfm_wait, proc_call & " waiting for response (sbi ready=0)" & msg, scope, msg_id_panel);
      end if;
      wait for config.clock_period;
      v_clk_cycles_waited := v_clk_cycles_waited + 1;
      check_value(v_clk_cycles_waited <= config.max_wait_cycles, config.max_wait_cycles_severity,
                  ": Timeout while waiting for sbi ready", scope, ID_NEVER, msg_id_panel, proc_call);
    end loop;

    cs  <= '0';
    rd  <= '0';
    v_data_value  := rdata;
    data_value    := v_data_value;
    if proc_name = "sbi_read" then
      log(config.id_for_bfm, proc_call & "=> " & to_string(v_data_value, HEX, SKIP_LEADING_0, INCL_RADIX) & ". " & msg, scope, msg_id_panel);
    else
      -- Log will be handled by calling procedure (e.g. sbi_check)
    end if;
  end procedure;

We don't want to (and probably shouldnt) call the sbi_check and providing all the parameters each time. Some of this can be solved by the overloads with more standard parameters, and with our own check procedures declared locally in our testbench:

    procedure check(
      constant addr_value   : in natural;
      constant data_exp     : in std_logic_vector;
      constant alert_level  : in t_alert_level;
      constant msg          : in string) is
    begin
      sbi_check(to_unsigned(addr_value, sbi_if.addr'length), data_exp, alert_level, msg,
            clk, sbi_if, C_SCOPE);
    end;

The write procedure is also very handy and should be understood:

  procedure sbi_write (
    constant addr_value   : in    unsigned;
    constant data_value   : in    std_logic_vector;
    constant msg          : in    string;
    signal   clk          : in    std_logic;
    signal   cs           : inout std_logic;
    signal   addr         : inout unsigned;
    signal   rd           : inout std_logic;
    signal   wr           : inout std_logic;
    signal   ready        : in    std_logic;
    signal   wdata        : inout std_logic_vector;
    constant scope        : in    string            := C_SCOPE;
    constant msg_id_panel : in    t_msg_id_panel    := shared_msg_id_panel;
    constant config       : in    t_sbi_bfm_config  := C_SBI_BFM_CONFIG_DEFAULT
  ) is
    constant proc_name  : string :=  "sbi_write";
    constant proc_call  : string :=  "sbi_write(A:" & to_string(addr_value, HEX, AS_IS, INCL_RADIX) &
                                     ", " & to_string(data_value, HEX, AS_IS, INCL_RADIX) & ")";
    -- Normalise to the DUT addr/data widths
    variable v_normalised_addr    : unsigned(addr'length-1 downto 0) :=
        normalize_and_check(addr_value, addr, ALLOW_WIDER_NARROWER, "addr_value", "sbi_core_in.addr", msg);
    variable v_normalised_data    : std_logic_vector(wdata'length-1 downto 0) :=
        normalize_and_check(data_value, wdata, ALLOW_NARROWER, "data_value", "sbi_core_in.wdata", msg);
    variable v_clk_cycles_waited  : natural := 0;
  begin
    wait_until_given_time_after_rising_edge(clk, config.clock_period/4);
    cs    <= '1';
    wr    <= '1';
    rd    <= '0';
    addr  <= v_normalised_addr;
    wdata <= v_normalised_data;

    wait for config.clock_period;
    while (config.use_ready_signal and ready = '0') loop
      if v_clk_cycles_waited = 0 then
        log(config.id_for_bfm_wait, proc_call & " waiting for response (sbi ready=0)" & msg, scope, msg_id_panel);
      end if;
      wait for config.clock_period;
      v_clk_cycles_waited := v_clk_cycles_waited + 1;
      check_value(v_clk_cycles_waited <= config.max_wait_cycles, config.max_wait_cycles_severity,
                  ": Timeout while waiting for sbi ready", scope, ID_NEVER, msg_id_panel, proc_call);
    end loop;

    cs  <= '0';
    wr  <= '0';
    log(config.id_for_bfm, proc_call & " completed. " & msg, scope, msg_id_panel);
  end procedure;

We will create a local overload of this too:

procedure write(
      constant addr_value   : in natural;
      constant data_value   : in std_logic_vector;
      constant msg          : in string) is
    begin
      sbi_write(to_unsigned(addr_value, sbi_if.addr'length), data_value, msg,
            clk, sbi_if, C_SCOPE);
    end;

All this enables us to handle transactions at a high level. See Bitvis documentation for how to write your own BFM and what it should include(sanity checks, etc).

Checking register write and read

Now we're enabled to write to and read from the registers. The register addresses are defined in the IRQC package file irqc_pif_bkg.vhd. Notice that we also use the previously declared overloaded version of log() and fit().

log(ID_LOG_HDR, "Check register defaults and access (write + read)", C_SCOPE);
    ------------------------------------------------------------
    log("\nChecking Register defaults");
    check(C_ADDR_IRR, x"00", ERROR, "IRR default");
    check(C_ADDR_IER, x"00", ERROR, "IER default");
    check(C_ADDR_IPR, x"00", ERROR, "IPR default");
    check(C_ADDR_IRQ2CPU_ALLOWED, x"00", ERROR, "IRQ2CPU_ALLOWED default");

    log("\nChecking Register Write/Read");
    write(C_ADDR_IER, fit(x"55"), "IER");
    check(C_ADDR_IER, fit(x"55"), ERROR, "IER pure readback");
    write(C_ADDR_IER, fit(x"AA"), "IER");
    check(C_ADDR_IER, fit(x"AA"), ERROR, "IER pure readback");
    write(C_ADDR_IER, fit(x"00"), "IER");
    check(C_ADDR_IER, fit(x"00"), ERROR, "IER pure readback");

This check will give us a nice log if everything turns out ok:

However, if there's an error: