Reactive agents
Build an agent that answers a bus master instead of driving one: a driver that reports each request to a long-lived responder sequence, which decides the reply.
- Lesson
- U6.2
- Time
- 30 min
- Runs on
- Verilator with UVM
- Check
- 5 questions
A DMA engine is the design under test, and the bench has to play the memory it reads from and writes to. The first version, a driver with a memory array inside, works until one test needs an error on one address. A second test then needs slow responses, and a third a page of preloaded data. Each one is a new driver edit, and the driver that was supposed to know only the pins now holds the test plan.
| Who decides | Where the request appears | Where the reply is chosen | To change the behaviour |
|---|---|---|---|
| Master agent (U4) | A sequence creates it | The design | Start a different sequence |
| Responder with the memory in the driver | The pins | The driver | Edit the driver |
| Reactive agent with a responder sequence | The pins, reported by the driver | A long-lived sequence | Start a different responder sequence |
A reactive agent keeps the reply logic in a sequence, where tests can swap it, and the driver stays a pin mover.
The direction of the conversation turns round
In every agent so far a sequence invents a transaction and the driver pushes it onto the pins. A reactive agent, also called a responder or slave agent, works the other way. Something on the design side starts a transfer; the agent must notice it and answer. This lesson's design under test is a small bus master, a plain SystemVerilog module with a fixed script of reads and writes. The UVM agent plays the memory it talks to.
The question is where the reply is decided. The driver could hold a memory array and answer alone, but then every variation (an error on one address, a slow reply, preloaded data) is a driver change. The reactive pattern keeps the driver as a pin mover and lets a responder sequence decide. Step through the four-part conversation below; it repeats for every bus request.
- Tell me what to do (0 ns)
The responder sends an empty request item with
start_itemandfinish_item. The driver takes it withget_next_itemand waits forreq.More context
At time 0 the request item is empty: the responder is asking, not telling. The driver now polls req at every clock edge.
Follow the calls, values and waits
- call get_next_item: Slave driver → Slave sequencer
- data request item: Responder sequence → Slave sequencer
- data request item: Slave sequencer → Slave driver
- Waiting: Responder sequence — in finish_item(request)
- Waiting: Slave driver — waiting for req
- This is what the master asked (35 ns)
Master 0 raised
reqfor its write to 0x10. The driver copieswe,addrandwdatainto the same object and callsitem_done; the responder'sfinish_itemreturns.More context
The driver sees req at the 35 ns edge. Because the driver filled the very object the responder created, the responder can read the request as soon as finish_item returns.
Follow the calls, values and waits
- data write 0x10 <= 0x5a: Bus master (DUT) → Slave driver
- call item_done: Slave driver → Slave sequencer
- data request item: Slave sequencer → Responder sequence
- Released: Responder sequence
- Released: Slave driver
- Here is the reply (35 ns)
The responder stores 0x5a, randomizes a reply item under its constraints and sends it. No simulation time passes.
More context
The log line "35 ns resp0 saw write 0x10 <= 0x5a; reply rdata=0x5a err=0 wait=0" is printed here, in the same time step the request was reported.
Follow the calls, values and waits
- data mem[0x10] = 0x5a: Responder sequence → Memory model
- data reply item: Responder sequence → Slave driver
- Waiting: Responder sequence — in finish_item(reply)
- Done (45 ns)
The driver drives
ack,rdataanderrfor one clock and callsitem_done. The master samplesackat 45 ns.More context
The driver set ack in the 35 ns time step with no wait cycles; master 0 samples it at the next edge and logs "45 ns master0 write 0x10 <= 0x5a err=0". The responder loops back to step 1.
Follow the calls, values and waits
- data ack, err=0: Slave driver → Bus master (DUT)
- Released: Bus master (DUT)
- Released: Responder sequence
Read the diagram as text
- Bus master (DUT): Plain module with a fixed script
- Slave driver: Moves fields between pins and items
- Slave sequencer: One responder per sequencer
- Responder sequence: forever loop, decides every reply
- Memory model: Associative array inside the sequence
- Responder sequence → Slave sequencer: 1. empty request item
- Slave driver → Slave sequencer: 1. get_next_item(request)
- Bus master (DUT) → Slave driver: 2. req, we, addr, wdata
- Slave sequencer → Responder sequence: 2. item_done: finish_item returns the filled request
- Responder sequence → Memory model: 3. store or look up
- Responder sequence → Slave driver: 3. reply item: rdata, err, wait_cycles
- Slave driver → Bus master (DUT): 4. ack, rdata, err
Swap the responder box and only the reply rule changes; the driver and item stay.
Step 2 relies on a fact from U4.1: the driver receives the very object the sequence created. Fields the driver writes are visible to the sequence as soon as finish_item returns. The pattern assumes one responder per sequencer; two responders on one sequencer would interleave their request and reply items and break the pairing.
rand and non-rand change places
Compare a master item with the responder's item for the same bus. On the master side addr, we and wdata are rand, because a test chooses them, and rdata and err are plain fields the driver fills in. On the responder side it is reversed. The request fields come from the pins and must not be randomized; the reply fields rdata, err and wait_cycles are rand, because the responder chooses them.
Master item (U4)
rand bit we;
rand bit [7:0] addr, wdata;
bit [7:0] rdata; // from the design
bit err;
The test randomizes what the master sends; the reply is observed.
Responder item (this lesson)
bit we; // from the pins
bit [7:0] addr, wdata;
rand bit [7:0] rdata; // chosen by the responder
rand bit err;
rand bit [1:0] wait_cycles;
constraint c_wait { wait_cycles <= 2; }
The responder randomizes what it answers; the request is observed.
The responder sequence narrows those constraints per reply. A memory responder constrains rdata to the stored value for a read, err to 0 and wait_cycles to 0. An error-injecting responder keeps the same item and driver and constrains differently. For addresses at or above 0x80 it sets err to 1, returns the poison value 0xee and adds one wait cycle. The constraint block on the item sets the legal range; the sequence decides what this test needs.
A long-lived sequence
A master sequence runs for a known number of items and returns. A responder cannot know when the next request will arrive, so its body() is a forever loop that lasts the whole test. The test starts it in a forked thread and does not wait for it. The responder raises no objection; the last section shows who ends the run.
task body();
forever begin
bit [7:0] data;
slave_item request = slave_item::type_id::create("request");
slave_item reply = slave_item::type_id::create("reply");
start_item(request);
finish_item(request); // returns once the driver has seen a bus request
if (request.we) mem[request.addr] = request.wdata;
reply.we = request.we; reply.addr = request.addr; reply.wdata = request.wdata;
data = mem.exists(request.addr) ? mem[request.addr] : 8'h00;
start_item(reply);
if (!reply.randomize() with { rdata == data; err == 0; wait_cycles == 0; })
`uvm_error("RAND", "reply randomize failed")
finish_item(reply);
end
endtaskThe forever is not decoration. A body that serves one request and returns leaves the driver blocked in get_next_item at the master's second access, and the test ends in a timeout. The quiz has that variant.
Swapping the responder is a one-line change in the test, or none at all with a factory override: err_responder_seq extends mem_responder_seq and overrides only the function that chooses the reply. The example runs both responders at once, on two copies of the master and agent, so the difference is visible in one log.
The driver as a pin mover
The driver's loop mirrors the four steps. It takes the request item, waits at the clock edge until req is high, fills the item from the pins and calls item_done. Then it takes the reply item, waits wait_cycles clocks, drives ack, rdata and err for one clock, and calls item_done again.
Nothing in the driver reads or stores data, and nothing in it knows about errors or delays beyond copying the reply's fields to the pins. That is what makes the agent reusable: a project with a different memory map, an ECC error model or a slow peripheral changes the sequence and keeps the driver.
The master raised req after the 55 ns edge. At 65 ns the driver reports it, the responder answers and the driver schedules ack, all in one time step; the master samples ack and rdata at 75 ns.
Steps 2 and 3 take no simulation time. At the 65 ns edge the driver reports the request, the sequence computes and sends the reply, and the driver schedules ack. The master sees the answer at the next edge. A reply that needs real time, from a slow peripheral for example, gets it from wait_cycles, which the driver spends before driving ack.
One item or two
The example uses one item class for both halves of the conversation, with the request fields and the reply fields side by side. Its advantage is simplicity: one class, one sequencer type, no casting.
The alternative uses two item classes on a shared base. The request item holds only the non-rand pin fields. The reply item holds the rand response fields plus the few request fields the driver needs again, such as we. The sequencer and driver are parameterized with the base class, and the driver casts each item it receives to the request or reply type. Choose this when the halves have little in common, or when a protocol has more than two phases.
| Single item | Two items on a base class | |
|---|---|---|
| Classes | One | Base, request, reply |
| Sequencer and driver type | The item | The base class |
| In the driver | Reads and writes fields directly | $cast to the request or reply type |
| Fits when | Request and reply fields are few and related | Phases differ, or there are more than two |
Both variants run the same four-step conversation; they differ in how the fields are packaged.
Who ends a responder
- Master 0 finishes (135 ns)
Master 0 samples its last
ackand setsdone. The test keeps waiting for master 1.More context
Master 0's last access, the read of 0x90, completes at 135 ns. Master 1 is still being slowed by its error responder's wait cycles.
Follow the calls, values and waits
- data done: Master 0 done → Test run_phase
- Waiting: Test run_phase — waiting for master 1
- Master 1 finishes (165 ns)
Master 1 samples its last
ackand setsdone; the test's wait completes.More context
Master 1's read of 0x90 returns 0xee with err=1 at 165 ns; both flags are now set.
Follow the calls, values and waits
- data done: Master 1 done → Test run_phase
- Released: Test run_phase
- End the phase (165 ns)
The test drops its objection; the run phase ends, and uvm-core kills every thread still running in it, including both forever loops.
More context
Both responders are idle in finish_item(request), waiting for a request that will never come, so stopping them cuts off nothing. A timer-based end could kill a responder between a request and its reply.
Follow the calls, values and waits
- call drop_objection: Test run_phase → Run phase ends
- call kill: Run phase ends → Responder and driver loops
Read the diagram as text
- Master 0 done: Last ack sampled at 135 ns
- Master 1 done: Last ack sampled at 165 ns
- Test run_phase: wait (both done flags)
- Run phase ends: Objection dropped at 165 ns
- Responder and driver loops: Killed while waiting for the next request
- Master 0 done → Test run_phase: done
- Master 1 done → Test run_phase: done
- Test run_phase → Run phase ends: drop_objection
- Run phase ends → Responder and driver loops: remaining threads killed
A responder idle in finish_item(request) is safe to stop. Ending on a timer instead could cut off an access between its request and its reply.
The end of the example run, event by event
| Event | What happens | In the example |
|---|---|---|
| A master finishes its script | It sets done after sampling its last ack | Master 0 at 135 ns, master 1 at 165 ns |
| The test sees both flags | It drops its objection | 165 ns |
| The run phase ends | Every thread still running in it is killed, including the responder and driver loops | Both responders are waiting for the next request |
The responder never returns. Objections decide when the run ends, and the test waits on the design side.
Nothing ends a responder from the inside. Its forever loop stops when the run phase ends: uvm-core kills every thread still running in a task phase once that phase completes. So the real question is what the test waits for before it drops its objection.
Wait for the design side, not the responder. Here the test waits for both masters' done flags. If it dropped its objection on a timer instead, a master could be mid-access, with its request reported and its reply still counting wait_cycles. That access would be cut off unanswered and never checked. A responder idle in finish_item(request) is safe to stop; one between the two halves is not.
Checking does not belong in the responder either. Its log is the agent's own account of what it answered. A monitor on the same pins, as in U7, gives the scoreboard a separate observation of what the master actually received.
Try it
Each master access produces two lines: the responder's decision at the edge where the driver reports the request, and the master's view one or two clocks later. Master 0 and resp0 run the memory responder; master 1 and resp1 run the error responder on an identical agent.
The two logs agree up to address 0x90. There resp1 answers the write with err=1 after one wait cycle and drops it. Master 1's read of 0x90 then returns the poison value 0xee with the error flag, while master 0 reads back 0x33. No driver code changed between the two agents; only the sequence did.
Try changing err_responder_seq to keep the write and error only the read, or constrain wait_cycles to inside {[0:2]} in mem_responder_seq and watch the master lines move while the data stays right.
35 ns resp0 saw write 0x10 <= 0x5a; reply rdata=0x5a err=0 wait=0 45 ns master0 write 0x10 <= 0x5a err=0 45 ns resp1 saw write 0x10 <= 0x5a; reply rdata=0x5a err=0 wait=0 55 ns master1 write 0x10 <= 0x5a err=0 65 ns resp0 saw read 0x10; reply rdata=0x5a err=0 wait=0 75 ns master0 read 0x10 -> 0x5a err=0 75 ns resp1 saw read 0x10; reply rdata=0x5a err=0 wait=0 85 ns master1 read 0x10 -> 0x5a err=0 95 ns resp0 saw write 0x90 <= 0x33; reply rdata=0x33 err=0 wait=0 105 ns master0 write 0x90 <= 0x33 err=0 105 ns resp1 saw write 0x90 <= 0x33; reply rdata=0xee err=1 wait=1 125 ns master1 write 0x90 <= 0x33 err=1 125 ns resp0 saw read 0x90; reply rdata=0x33 err=0 wait=0 135 ns master0 read 0x90 -> 0x33 err=0 145 ns resp1 saw read 0x90; reply rdata=0xee err=1 wait=1 165 ns master1 read 0x90 -> 0xee err=1 165 ns both masters finished UVM_ERROR : 0Check yourself
Remember
- A reactive agent answers requests that the design side starts; its driver reports each request to a sequence and drives the reply the sequence returns.
- The responder item reverses the master item: request fields non-rand, reply fields rand with constraints.
- The responder sequence is long-lived: a
foreverloop, started in a fork, holding no objection. - Reporting the request and choosing the reply take no simulation time; delay comes from a reply field the driver honours.
- Swapping the responder sequence changes the agent's behaviour without touching the driver; use two item classes on a base type when phases differ.
Practice
- Play the memory, including its protected page Implement · Medium