Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Start Here4
  • UVM Foundations14
  • UVM Phases & Simulation Lifecycle26
  • Factory & Configuration15
  • Transaction-Level Modeling (TLM)13
  • UVM Sequences & Stimulus21
  • UVM Agents & Protocol VIP21
    • Agent Architecture
    • Reusable VIP: Active, Passive and Protocol Agents
    • Error and Fault Injection
    • Agent Patterns: Slaves, Reset, Interrupts and Pipelines
      • Reactive Slave Agents
      • Reset on the Fly
      • Interrupt Agents
      • Pipelined and Out-of-Order Drivers
  • Environment & Test Classes11
  • Checking & Coverage in UVM21
  • UVM Register Abstraction Layer (RAL)22
  • Advanced UVM in Practice13
  • Debug & Regression10
  • Coding Practice47
  • Verification in Industry Practice10
  • Senior Prep: Architecture, Leadership and Interviews32
Loading lesson

‹Agent Patterns: Slaves, Reset, Interrupts and Pipelines
  1. Topics
  2. UVM Verification
  3. UVM Agents & Protocol VIP
  4. Agent Patterns: Slaves, Reset, Interrupts and Pipelines
  5. Interrupt Agents

Part 6 · Agents & Protocol IP · Intermediate

Interrupt Agents

A passive interrupt agent that publishes every change of the line and triggers a global event, so tests and sequences react without a virtual interface.

On this page

    Every DMA test ends a job the same way: wait (irq_vif.irq === 1'b1). It works, but look at what it costs. Every test needs a virtual interface it should not care about. A virtual sequence that wants to react to the interrupt needs one too, and objects carrying pin handles do not survive the move to a subsystem with forty interrupt lines. Nothing records when interrupts happened, so nothing can cover them or check that none is left pending at the end. How do you make an interrupt something the testbench observes rather than something every test pokes at?

    The idea: a doorbell wired to a hub

    Treat the interrupt line like any other interface: give it a passive agent whose monitor watches the wire and reports what it sees. The monitor reports in two forms at once. Every change of the line becomes an irq_item on an analysis port, for scoreboards and coverage. Every rising edge also triggers a named event, for anything that wants to wait. Think of a doorbell wired to a smart home hub: the hub logs every ring, and anyone in the house who asked to be told gets a notification, without running a wire to the button.

    The event is a uvm_event taken from uvm_event_pool::get_global("dma_irq"). The global pool is a table of events keyed by name, shared by the whole simulation. The monitor and a test that never met each other call get_global with the same name and get the same event object, so the test can wait for the interrupt with no virtual interface and no handle passed through config_db.

    The picture

    Flow diagram: the interrupt wire feeds the irq monitor inside a passive irq agent; the monitor writes irq items to an analysis port feeding coverage and an end-of-test check, and triggers a global uvm_event that a test and a virtual sequence's interrupt handler wait on.
    One monitor, two outputs: transactions for checkers and coverage, and a global event for whoever needs to react.

    Build it

    The monitor and the agent

    The monitor samples the line on every clock and acts only on a change. It creates a fresh irq_item for each change, writes it, and triggers the event when the new level is 1. The agent is passive by nature: an interrupt is driven by the DUT, so there is nothing for a driver to do, and the agent builds only the monitor.

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    // snippet: from the course DMA bench
    class irq_item extends uvm_sequence_item;
      bit level;
      `uvm_object_utils(irq_item)
      function new(string name = "irq_item");
        super.new(name);
      endfunction
    endclass
    
    // A passive interrupt monitor: publishes every change of the line, and triggers
    // the global event "dma_irq" on each rising edge, so tests and sequences can wait
    // for the interrupt without holding a virtual interface.
    class irq_monitor extends uvm_monitor;
      `uvm_component_utils(irq_monitor)
      virtual irq_if vif;
      uvm_analysis_port #(irq_item) ap;
      uvm_event irq_ev;
    
      function new(string name, uvm_component parent);
        super.new(name, parent);
        ap = new("ap", this);
      endfunction
    
      function void build_phase(uvm_phase phase);
        super.build_phase(phase);
        if (!uvm_config_db#(virtual irq_if)::get(this, "", "vif", vif))
          `uvm_fatal("NOVIF", "irq_if not set for the interrupt monitor")
        irq_ev = uvm_event_pool::get_global("dma_irq");
      endfunction
    
      task run_phase(uvm_phase phase);
        bit last = 0;
        forever begin
          @(posedge vif.clk);
          if (vif.irq !== last) begin
            irq_item it = irq_item::type_id::create("it");
            it.level = vif.irq;
            last = vif.irq;
            ap.write(it);
            if (it.level) irq_ev.trigger();
            `uvm_info("IRQ_MON", $sformatf("irq -> %0b", it.level), UVM_MEDIUM)
          end
        end
      endtask
    endclass
    
    class irq_agent extends uvm_agent;
      `uvm_component_utils(irq_agent)
      irq_monitor mon;
      uvm_analysis_port #(irq_item) ap;
      function new(string name, uvm_component parent);
        super.new(name, parent);
      endfunction
      function void build_phase(uvm_phase phase);
        super.build_phase(phase);
        mon = irq_monitor::type_id::create("mon", this);
      endfunction
      function void connect_phase(uvm_phase phase);
        ap = mon.ap;
      endfunction
    endclass

    Adding it without editing the env

    dma_env_irq extends the block env and adds one child. A type override in the test swaps it in for dma_env, so the env source is untouched and every other test still builds the plain env. The top module already sets the interface for uvm_test_top.env.irq.*, which costs nothing when no irq agent exists.

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    // snippet: from the course DMA bench
    // The block env plus an interrupt agent, swapped in by a factory override.
    class dma_env_irq extends dma_env;
      `uvm_component_utils(dma_env_irq)
      irq_agent irq;
      function new(string name, uvm_component parent);
        super.new(name, parent);
      endfunction
      function void build_phase(uvm_phase phase);
        super.build_phase(phase);
        irq = irq_agent::type_id::create("irq", this);
      endfunction
    endclass
    
    // The test waits on the interrupt agent's event instead of a virtual interface.
    class dma_irq_agent_test extends dma_base_test;
      `uvm_component_utils(dma_irq_agent_test)
    
      function new(string name, uvm_component parent);
        super.new(name, parent);
      endfunction
    
      function void build_phase(uvm_phase phase);
        dma_env::type_id::set_type_override(dma_env_irq::get_type());
        super.build_phase(phase);
      endfunction
    
      task run_phase(uvm_phase phase);
        uvm_event    irq_ev = uvm_event_pool::get_global("dma_irq");
        axis_pkt_seq pkt    = axis_pkt_seq::type_id::create("pkt");
        bit [31:0] status;
        phase.raise_objection(this);
        reg_write(KEY, 32'h0F0F_0F0F);
        reg_write(LEN, 4);
        reg_write(CTRL, 32'h3);
        if (!pkt.randomize() with { n == 4; }) `uvm_fatal("RAND", "axis_pkt_seq randomize failed")
        fork
          pkt.start(env.in_agt.sqr);
        join_none
        irq_ev.wait_trigger();                   // no virtual interface needed here
        reg_read(STATUS, status);
        `uvm_info("IRQ", $sformatf("interrupt event seen, STATUS=%08h", status), UVM_LOW)
        reg_write(STATUS, 32'h6);
        #100ns;
        phase.drop_objection(this);
      endtask
    endclass

    The test has no irq_vif. It gets the event from the pool, starts the packet in the background, and blocks on wait_trigger() until the monitor reports the rising edge.

    How the interrupt handler sequence fits

    The bench's virtual sequence dma_vseq has an interrupt branch that waits on irq_vif.irq and then starts dma_irq_seq, which grabs the APB sequencer so its STATUS read and clear jump ahead of any queued polling. With the agent in place, that branch needs no virtual interface either:

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    // snippet: dma_vseq's interrupt branch, waiting on the agent's event instead of the wire
    begin
      uvm_event irq_ev = uvm_event_pool::get_global("dma_irq");
      irq_ev.wait_trigger();                 // the irq agent saw a rising edge
      isr.start(apb_sqr, this);              // grab, read STATUS, write 1 to clear, ungrab
      status    = isr.status;
      poll.stop = 1;
    end

    Why the handler grabs, and how grab differs from lock, is in Lock versus grab.

    Watch it run

    dma_irq_agent_test with +UVM_VERBOSITY=UVM_MEDIUM:

    Diagram
    UVM_INFO dma_tb.sv(1082) @ 375000: uvm_test_top.env.irq.mon [IRQ_MON] irq -> 1
    UVM_INFO dma_tb.sv(1326) @ 415000: uvm_test_top [IRQ] interrupt event seen, STATUS=00000002
    UVM_INFO dma_tb.sv(1082) @ 465000: uvm_test_top.env.irq.mon [IRQ_MON] irq -> 0
    UVM_INFO dma_tb.sv(411) @ 555000: uvm_test_top.env.sb [SB] 4 words matched, 0 mismatched
    UVM_ERROR :    0
    UVM_FATAL :    0
    • At 375 ns the monitor sees the line rise and triggers the event; the test wakes and reads STATUS, which takes one APB access (with the DUT's wait state), so the read returns at 415 ns with DONE set.

    • The test writes 0x6 to STATUS, and at 465 ns the monitor sees the line fall. The line falling is the proof that the clear worked.

    • The scoreboard's 4 matched words show the agent changed nothing about data checking.

    Level, edge, and the clear that must happen

    The DMA's interrupt is level-sensitive: irq = IRQ_EN & (DONE | ERR), and it stays high until software clears the cause. The cause bits are write-1-to-clear (W1C): writing 1 to STATUS bit 1 clears DONE, writing 1 to bit 2 clears ERR, and writing 0 leaves a bit alone. An edge (pulse) interrupt is high for a fixed short time and needs no clear. The two need different handling:

    Level interrupt (the DMA)

    Edge or pulse interrupt

    Monitor

    Report changes; the line stays high until cleared

    Must not miss a one-clock pulse; sample every clock or use the pulse's own edge

    Handler duty

    Read the cause, clear exactly those bits

    Read the cause; nothing to clear on the line

    If the handler forgets

    Line stays high, no new rising edge, the next interrupt is never seen

    Nothing on the line; the cause register may still need clearing

    Checks

    Line falls after the clear; low at end of test

    Pulse width; one pulse per event

    A good handler clears what it read, not a constant. Writing back the STATUS value it just read clears exactly the causes it is about to handle, and leaves alone a cause that arrived between the read and the write. The bench's dma_irq_seq writes the constant 0x6, which is fine for a block with one job at a time and is the first thing to change at subsystem level.

    Interrupt storms

    A storm is an interrupt that re-asserts as fast as it is handled, usually because the clear never reaches the cause (a wrong address, a read-only bit, a clear blocked by a still-active condition). A level-sensitive handler written as while (irq) handle(); then spins forever in zero or near-zero time. Production handlers bound their loop and report: count handler entries per job, and raise an error when the count passes a limit the spec allows. The irq monitor's analysis stream makes that check easy, because it records every rise with its time.

    Covering interrupt causes

    The wire alone says only that an interrupt happened. The cause comes from the STATUS read the handler makes, which the APB monitor observes. A coverage subscriber that listens to both streams can pair each rising edge with the next STATUS read and sample cause (DONE, ERR, both) crossed with context (IRQ_EN, job length, whether a second job was queued).

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // not-run: covergroups are not supported in the course's Verilator checks
    class irq_cause_cov extends uvm_subscriber #(apb_item);
      `uvm_component_utils(irq_cause_cov)
      bit irq_pending;                       // set by the irq stream, see write_irq below
      bit [1:0] cause;                       // {ERR, DONE} from the STATUS read
      covergroup cg;
        cp_cause: coverpoint cause { bins done = {2'b01}; bins err = {2'b10}; bins both = {2'b11}; }
      endgroup
      function new(string name, uvm_component parent);
        super.new(name, parent);
        cg = new();
      endfunction
      function void write(apb_item t);       // from the APB monitor
        if (irq_pending && !t.write && t.addr == STATUS) begin
          cause = t.rdata[2:1];
          cg.sample();
          irq_pending = 0;
        end
      endfunction
      function void write_irq(irq_item t);   // connected through a uvm_analysis_imp_decl(_irq) imp
        if (t.level) irq_pending = 1;
      endfunction
    endclass

    What breaks: the second interrupt that never came

    A team's interrupt handler read STATUS and cleared DONE with a constant write of 0x2. Their regression ran two jobs per test. The second job of one random seed ended with a short packet, set ERR instead of DONE, and the test hung until the timeout. The waveform showed the interrupt line high from the end of the first job onward.

    The first job in that seed had also ended in error, so the handler's 0x2 cleared nothing that mattered and ERR stayed set. The line never fell, so when the second job ended there was no new rising edge, the monitor triggered nothing, and wait_trigger() waited forever. The fixes were the two rules above: clear what you read (write the STATUS value back), and check after every handler that the line actually fell. An end-of-test check that the interrupt line is low turns the next such bug into a one-line error instead of a hang.

    Common pitfall

    wait_trigger() waits for the next trigger. If the interrupt can rise before the waiter arrives, for example a zero-length job that is done the moment GO is written, the waiter misses it. Use wait_on() (returns at once if the event is already on, until someone calls reset()), or check the line's state through the monitor first. wait_ptrigger() only covers a trigger in the same timestep.

    Interview

    "How do you handle interrupts in a UVM testbench?"

    A weak answer: "The test waits on the interrupt signal, then reads the status register." It ties every test to a pin, cannot be reused at SoC level, records nothing and checks nothing.

    A strong answer: "A passive interrupt agent: its monitor publishes every change of the line as a transaction and triggers a named uvm_event on each rising edge. Tests and virtual sequences wait on the event, so they need no virtual interface. The handler is a sequence that grabs the register sequencer, reads the cause and clears exactly what it read, because the DMA's interrupt is level-sensitive with W1C cause bits. A subscriber covers causes from the STATUS reads, a bounded handler loop catches storms, and check_phase fails if an interrupt is still pending."

    Follow-ups to expect: Level or edge, what changes? (who clears, what the monitor must not miss) and What if the interrupt can fire before you wait? (wait_on with reset, or check the state first).

    Check yourself A virtual sequence calls uvm_event_pool::get_global("dma_irq") before the irq agent's build_phase has run. Does it get the same event the monitor triggers?

    Yes. get_global creates the event the first time any caller asks for that name and returns the same object to every later caller, whatever the order. Who asks first does not matter, only that both use the same string, which is why the name belongs in a shared constant.

    Check yourself The handler reads STATUS = 0x6 (DONE and ERR both set) and writes 0x2. What does the interrupt line do?

    It stays high. Writing 1 to bit 1 clears DONE, but ERR is still set and irq = IRQ_EN & (DONE | ERR). The monitor sees no falling edge, and no new rising edge can come until ERR is cleared too. Writing back the value read (0x6) clears both.

    Check yourself Why does irq_monitor publish falling edges too, when tests only wait for rising ones?

    Because checkers need them. The fall is the evidence that a clear worked, the time between rise and fall is the handler latency, and an end-of-test check needs to know the last level. Publishing every change costs almost nothing and lets each subscriber decide what matters.

    Many interrupt lines: the subsystem and SoC view

    • One agent per line, one event per line. Name each event after its source (dma0_irq, dma1_irq) in the global event pool, so a test or virtual sequence waits on exactly the source it means. The two-DMA subsystem bench still hands the test raw irq0_vif and irq1_vif; at that size it works, at twenty lines it does not.

    • Shared or ORed lines need a cause read. When several sources share one wire, the edge only says someone wants service. The handler reads the cause register (STATUS here, an interrupt controller's pending register on a chip), and the check pairs each serviced cause with the event that predicted it.

    • Prove every source fired. Coverage per line (rose with its enable set, held until cleared, cleared by the right write) catches a line that was never exercised, and a check in check_phase that no line is still pending catches an interrupt nobody serviced.

    • Swapped wiring is invisible to a test that waits for all of them. If dma0 and dma1 interrupts are crossed at integration, a test that waits for both still passes; pairing each edge with its own cause read is what exposes it.

    Next: the agents so far handle one transaction at a time. Fast buses keep many in flight: Pipelined and out-of-order drivers splits the driver into threads.

    Related topics

    • Lock versus grab

    • Virtual sequences without a virtual sequencer

    • The DMA spec

    • Pipelined and out-of-order drivers

    Previous

    113 / 280

    Next: Pipelined and Out-of-Order Drivers

    Up next

    Up nextPipelined and Out-of-Order Drivers

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Reactive Slave Agents