How to handle Interrupt in UVM?
In short: An interrupt is a sideband signal from a design block that says “something needs attention now”. In a UVM testbench you model interrupt handling by watching the interrupt lines and, when one fires, starting an interrupt service routine (ISR) sequence that uses grab() to take exclusive control of the sequencer. The ISR clears the interrupt, then calls ungrab() so the main traffic can continue. A top-level sequence runs the main sequence and the interrupt watcher together in a fork.
Most SoCs contain a CPU, a bus fabric, and many IP blocks. When an IP block hits a condition it needs the CPU to act on, it raises an interrupt. These lines are usually sideband signals, separate from the main data bus. An interrupt controller collects them, prioritizes them, and passes them to the CPU, which runs an interrupt service routine (ISR) to deal with the event.
A simple way to picture it
Think of a chef cooking a planned menu (the main sequence). A waiter can walk in at any moment with an urgent order (the interrupt). The chef pauses the planned menu, takes full control of the stove to cook the urgent dish (grab the sequencer), finishes it, then hands the stove back and resumes the menu (ungrab). The urgent order cannot itself be interrupted halfway, and the chef always returns to the planned menu afterward. That pause, handle, resume pattern is exactly how a UVM ISR sequence behaves.
How interrupt handling maps to UVM
The simplest, reliable way to model an ISR is to trigger a sequence that grabs the sequencer. Grabbing gives that sequence exclusive access, so the normal stimulus is paused while the ISR runs. This matches real hardware, where the CPU stops normal execution to service the interrupt. The key pieces are:
| Piece | Role |
|---|---|
| Top-level sequence | Runs the main sequence and the interrupt watcher together in a fork. |
| Main sequence | Generates normal bus traffic in a loop. |
| ISR sequence | Grabs the sequencer, reads the status register, clears the active IRQ bits, then ungrabs. |
| Config object | Holds the wait_for_IRQ tasks that block until a given interrupt line asserts. |
| grab / ungrab | Take and release exclusive control of the sequencer so the ISR is not disturbed. |
The top-level sequence
This sequence starts the main traffic and, in parallel, waits for any of four interrupt lines. When one fires, it disables the other waiters, runs the ISR, then loops back to watch again. When the main traffic finishes, the whole watcher is torn down.
class top_level_seq extends uvm_sequence #(transaction);
`uvm_object_utils(top_level_seq)
function new(string name = "top_level_seq");
super.new(name);
endfunction
task body();
main_seq MAIN_SEQ;
isr_seq ISR;
int_config INT_CONF;
MAIN_SEQ = main_seq::type_id::create("MAIN_SEQ");
ISR = isr_seq::type_id::create("ISR");
// Interrupt config holds the wait_for_IRQx hooks
if (!uvm_config_db #(int_config)::get(null, get_full_name(),
"int_config", INT_CONF))
`uvm_fatal("TOP_SEQ", "Failed to get int_config")
// Level 1: run main traffic alongside the interrupt watcher
fork
MAIN_SEQ.start(m_sequencer);
begin
forever begin
// Level 2: wait for any one of the four IRQ lines
fork
INT_CONF.wait_for_IRQ0();
INT_CONF.wait_for_IRQ1();
INT_CONF.wait_for_IRQ2();
INT_CONF.wait_for_IRQ3();
join_any
disable fork; // drop the other three waiters
ISR.start(m_sequencer);
end
end
join_any
disable fork; // main traffic done, stop watching
endtask
endclassThe two-level fork is the heart of this design. Level one runs the main sequence and the watcher block side by side. Inside the watcher, level two waits on all four IRQ tasks; join_any unblocks as soon as any one asserts, and disable fork cancels the other three. The ISR then runs, and the surrounding forever loop goes back to watching. When the main sequence ends, the outer join_any completes and the outer disable fork stops the watcher.
The main sequence
The main sequence is ordinary bus traffic. It sends a batch of write transactions to a small address window.
class main_seq extends uvm_sequence #(transaction);
`uvm_object_utils(main_seq)
function new(string name = "main_seq");
super.new(name);
endfunction
task body();
transaction req;
repeat (150) begin
req = transaction::type_id::create("req");
start_item(req);
if (!req.randomize() with {
addr inside {[32'h0010_0000 : 32'h0010_001C]};
read_not_write == 0;
})
`uvm_error("MAIN_SEQ", "req randomization failed")
finish_item(req);
end
endtask
endclassThe ISR sequence
The ISR grabs the sequencer, reads the status register to learn which lines are active, clears each active bit, then releases the sequencer. Grabbing guarantees no main traffic slips in while the ISR runs.
class isr_seq extends uvm_sequence #(transaction);
`uvm_object_utils(isr_seq)
localparam bit [31:0] STATUS_ADDR = 32'h0010_0000;
function new(string name = "isr_seq");
super.new(name);
endfunction
task body();
transaction req;
bit [31:0] status;
// Take exclusive control so main traffic is paused
m_sequencer.grab(this);
// 1) Read the status register to find the cause
req = transaction::type_id::create("req");
start_item(req);
if (!req.randomize() with { addr == STATUS_ADDR;
read_not_write == 1; })
`uvm_error("ISR_SEQ", "status read randomization failed")
finish_item(req);
status = req.read_data; // driver returns data on read
// 2) Clear each active IRQ bit with a write
for (int i = 0; i < 4; i++) begin
if (status[i]) begin
`uvm_info("ISR_SEQ", $sformatf("IRQ[%0d] detected", i), UVM_LOW)
req = transaction::type_id::create("req");
start_item(req);
if (!req.randomize() with { addr == STATUS_ADDR;
read_not_write == 0;
write_data == (32'h1 << i); })
`uvm_error("ISR_SEQ", "clear write randomization failed")
finish_item(req);
`uvm_info("ISR_SEQ", $sformatf("IRQ[%0d] cleared", i), UVM_LOW)
end
end
// 3) Release the sequencer for the main sequence
m_sequencer.ungrab(this);
endtask
endclassThis version replaces the four repeated if-blocks of the original with a single loop, so adding a fifth interrupt line means changing one constant. The priority order still holds: bit 0 is handled first, then bit 1, and so on.
Expected behaviour, in plain words
The main sequence keeps sending writes to the address window. Whenever one of the IRQ lines asserts, the watcher stops the main flow, the ISR grabs the sequencer, reads the status register, prints a “detected” line for each active bit, writes to clear it, prints a “cleared” line, then ungrabs and the main traffic resumes. When all 150 main transactions finish, the watcher shuts down and the test ends. I have reviewed this against the UVM guidelines rather than run it, so use your own simulator or EDA Playground to confirm the exact logs and timing on your setup.
Why grab and ungrab matter
The sequencer arbitrates between sequences that want to send items. A normal sequence waits its turn. An ISR must not wait, because an interrupt is urgent. Calling grab() puts the ISR at the front with exclusive rights, so no other sequence can slip an item in until the ISR calls ungrab(). This mirrors a CPU masking lower-priority work while it services an interrupt. Always pair every grab() with an ungrab(), or the main traffic will never resume.
Common mistakes to avoid
- Forgetting
ungrab(). If the ISR grabs and never releases, the main sequence is starved forever. - No
disable forkafterjoin_any. The other IRQ waiters keep running and can fire spuriously; cancel them once one wins. - Wrong config_db lookup string. A typo in the field name means get() fails and the ISR never sees an interrupt. Match the set() and get() names exactly.
- Creating the transaction outside the loop. Reuse can carry stale fields; create a fresh item per transfer, or reset it deliberately.
- Assuming a fixed priority you did not set. The order you check the bits is the priority; make it explicit.
Keep learning
Interrupt handling builds on sequences and the sequencer. Review how a UVM callback works for another way to alter behaviour, and see the UVM testbench architecture to place the sequencer and driver. For how a test ends cleanly, read terminating a UVM test with objections. More material is in our UVM guides and interview questions.
Frequently asked questions
How do you handle an interrupt in UVM?
Model the interrupt as a sequence that grabs the sequencer. A top-level sequence runs the main traffic and an interrupt watcher in a fork. When an IRQ line asserts, the watcher starts an ISR sequence that grabs the sequencer, clears the interrupt, then ungrabs so normal traffic resumes.
Why does the ISR sequence use grab and ungrab?
grab() gives the ISR exclusive access to the sequencer so no other sequence can send items while the interrupt is being serviced. ungrab() releases that control so the main sequence can continue. This matches how a CPU pauses normal work to run an interrupt service routine.
What is the role of the two-level fork?
The outer fork runs the main sequence beside the interrupt watcher. The inner fork waits on all IRQ lines and uses join_any so it unblocks when any one asserts, then disable fork cancels the rest. A forever loop repeats the watch after each ISR.
How is interrupt priority decided?
Priority comes from the order in which the ISR checks the status bits. The bit checked first is serviced first. Make this order explicit in code so the priority is clear and easy to change.
What happens if I forget to call ungrab?
The sequencer stays locked to the ISR, so the main sequence can never send another item. The test will appear to hang or time out. Always pair every grab with an ungrab, ideally on every exit path.
Where do the wait_for_IRQ tasks come from?
They live in a configuration object passed through uvm_config_db. Each task blocks until its interrupt line asserts on the interface, usually by sampling a virtual interface signal. The top-level sequence gets this object and calls the tasks inside the watcher fork.



