Layered protocols
Stack a packet layer over a byte-wide link: a layering component with a sequencer per layer, a translator sequence that pulls packets and sends bytes, and a reconstruction monitor that rebuilds packets from observed bytes.
- Lesson
- U6.3
- Time
- 30 min
- Runs on
- Verilator with UVM
- Check
- 5 questions
A link controller ships packets as bytes, and the first bench splits them inside the packet driver; that works for a year. Then one test needs a corrupt frame with a wrong length byte. Another needs raw bytes with no packet at all, and a third project wants the same link agent under a different packet format. Each one means another branch inside a driver that now understands two protocols at once.
| Layer | Item | Sequencer | Who sends it on | Who observes it |
|---|---|---|---|---|
| Packet | pkt_item: dst, payload[] | pkt_sqr in the layering | The translator sequence, as bytes | The reconstruction monitor, from bytes |
| Byte | byte_item: data | link.sqr in the byte agent | The byte driver, on the pins | The byte monitor, from the pins |
Each layer keeps its own item, sequencer and monitor. One packet becomes several bytes going down and is rebuilt from several bytes coming back up.
One agent per layer
Protocols such as PCIe, USB and MIPI define a transaction layer over a link layer over a physical layer. Even a home-grown link has at least two: the frames a test reasons about and the symbols the pins carry. A layering keeps those levels apart in the bench. The byte agent stays a plain agent that knows nothing about packets, and a packet layer sits on top of it.
- Pull a packet (0 ns)
The translator calls
get_next_itemonpkt_sqr, exactly as a driver would; the packet sequence waits infinish_item.More context
Packet 1 (dst 0x0a, payload 11 22) is now outstanding. The packet sequence stays in finish_item until the translator calls item_done.
Follow the calls, values and waits
- data packet 1: Packet sequence → pkt_sqr
- call get_next_item: Translator → pkt_sqr
- data packet 1: pkt_sqr → Translator
- Waiting: Packet sequence — in finish_item(packet 1)
- First byte on the link (5 ns)
The translator sends the destination byte with its default
start_item; the byte driver presents it and the link takes it at 5 ns.More context
The log shows "5 ns byte drv byte 0x0a". The translator now waits in its own finish_item for each byte, one clock per byte.
Follow the calls, values and waits
- data 0x0a: Translator → link.sqr
- data 0x0a: link.sqr → Byte driver
- data 0x0a: Byte driver → byte_link (DUT)
- Waiting: Packet sequence — in finish_item(packet 1)
- Second byte, first observation (15 ns)
The length byte goes out, and the byte monitor sees the destination byte on the far side of the link.
More context
The link registers each byte, so the monitor sees it one clock after the driver: 0x0a appears at 15 ns. The reconstruction monitor records it as the destination.
Follow the calls, values and waits
- data 0x02: Translator → link.sqr
- data 0x02: link.sqr → Byte driver
- data 0x02: Byte driver → byte_link (DUT)
- data 0x0a: byte_link (DUT) → Byte monitor
- data 0x0a: Byte monitor → Reconstruction monitor
- Last byte, packet released (35 ns)
The fourth byte, 0x22, is taken at 35 ns. The translator calls
item_doneonpkt_sqrand the packet sequence'sfinish_itemreturns.More context
Payload byte 0x11 went out at 25 ns. After 0x22 at 35 ns the log shows "packet 1 sent as 4 bytes; item_done" and "finish_item returned for packet 1", both at 35 ns.
Follow the calls, values and waits
- data 0x22: Byte driver → byte_link (DUT)
- call item_done: Translator → pkt_sqr
- Released: Packet sequence
- Rebuild from observation (45 ns)
The byte monitor publishes the last byte; the reconstruction monitor completes packet 1 from the four observed bytes.
More context
The log shows "45 ns pkt mon rebuilt dst=0x0a payload 11 22 from 4 bytes". The packet was rebuilt from what the link carried, not from what the translator meant to send.
Follow the calls, values and waits
- data 0x22: byte_link (DUT) → Byte monitor
- data 0x22: Byte monitor → Reconstruction monitor
- Released: Reconstruction monitor
Read the diagram as text
- Packet sequence: Started by the test on pkt_sqr
- pkt_sqr: Packet-layer sequencer
- Translator: Runs on link.sqr, pulls from pkt_sqr
- link.sqr: Byte agent sequencer
- Byte driver: One byte per clock
- byte_link (DUT): Registers each byte
- Byte monitor: ap.write(byte)
- Reconstruction monitor: Rebuilds packets, ap.write(packet)
- Packet sequence → pkt_sqr: finish_item(packet)
- Translator → pkt_sqr: get_next_item / item_done
- Translator → link.sqr: start_item / finish_item(byte)
- Byte driver → link.sqr: get_next_item
- Byte driver → byte_link (DUT): valid, data
- byte_link (DUT) → Byte monitor: out_valid, out_data
- Byte monitor → Reconstruction monitor: write(byte)
One packet becomes four byte transfers going down and is rebuilt from four observed bytes coming up.
The layering here is one component, pkt_layering. It holds pkt_sqr, an ordinary uvm_sequencer #(pkt_item) where packet sequences run; the byte agent link; a translator sequence; and a reconstruction monitor. A real layering also exposes an analysis port per rebuilt level and carries one configuration object for its children.
The leaf agent may live inside the layering, as here, or elsewhere in the bench with the layering holding a handle. Either way the agent is unchanged, and a raw-byte test can run a byte sequence on link.sqr instead of the packet layer.
Not at the same time, though, in this design. Each byte the translator sends is arbitrated on its own, so a second byte sequence on link.sqr could slip bytes between a packet's destination, length and payload. The example has one translator and no other byte traffic. Mixing the two needs the translator to hold a lock on link.sqr for each packet (U5.2), or an intent to corrupt frames.
The translator is a sequence that behaves like a driver
The translator is started on the byte sequencer. Its start_item(it) calls name no sequencer, so its bytes go there by default; start_item also accepts a sequencer as a third argument, unused here. Towards the packet layer it plays the driver's part: it holds a handle to pkt_sqr and calls pkt_sqr.get_next_item(pkt) and pkt_sqr.item_done() on it directly. Those are the sequencer's own public tasks; a driver's seq_item_port is only a port that forwards to them. Nothing in the packet sequencer knows whether a driver or a sequence is pulling from it.
class pkt_to_bytes_seq extends uvm_sequence #(byte_item);
uvm_sequencer #(pkt_item) up_sqr; // the layer above
task body();
forever begin
pkt_item pkt;
up_sqr.get_next_item(pkt); // pull one packet; its sequence waits in finish_item
send_byte(pkt.dst); // header: destination
send_byte(pkt.payload.size()); // header: length
foreach (pkt.payload[i]) send_byte(pkt.payload[i]);
up_sqr.item_done(); // the packet is on the link: release its sequence
end
endtask
task send_byte(bit [7:0] b);
byte_item it = byte_item::type_id::create("it");
start_item(it);
it.data = b;
finish_item(it);
endtask
endclassThe layering's run_phase creates the translator, sets up_sqr, and starts it on link.sqr in a forked thread. Like the responder of U6.2 it is long-lived: a forever loop that lasts the whole run. The packet sequence that a test starts on pkt_sqr is ordinary and knows nothing about bytes.
The two upstream calls pair up exactly as in a driver. Forget item_done and the next get_next_item reports "called twice" and hands back the same packet, while the packet sequence waits in finish_item forever. Call item_done before sending the bytes and everything still arrives, but the packet sequence's finish_item no longer means "the packet is on the link".
What finish_item means one layer up
A packet with two payload bytes takes four clocks. The translator calls item_done after the fourth byte, so packet 1's finish_item returns at 35 ns. The packet sequence sees packet-level timing without knowing how many bytes each packet took.
The packet sequence waits for all four bytes. The monitor sees each byte one clock after the driver, so it rebuilds packet 1 at 45 ns.
This is the one-to-many mapping: one upstream item, several downstream items, and one upstream completion after the last of them. The upstream item stays outstanding the whole time, so its finish_item can mean "on the link".
Many-to-one: packing upstream items
Now reverse the ratio. A 16-bit link carries two bytes per transfer, so the translator must pull two upstream byte items for every downstream word. One rule of the pull side shapes the whole design: a sequencer hands out one item at a time. A second get_next_item before item_done reports an error and returns the same item again.
forever begin
up_sqr.get_next_item(hi);
up_sqr.item_done(); // must release hi before pulling lo
up_sqr.get_next_item(lo);
up_sqr.item_done();
w = word_item::type_id::create("w");
start_item(w);
w.data = {hi.data, lo.data};
finish_item(w); // the word transfer has completed
rsp = byte_item::type_id::create("rsp"); rsp.set_id_info(hi); up_sqr.put(rsp);
rsp = byte_item::type_id::create("rsp"); rsp.set_id_info(lo); up_sqr.put(rsp);
endMeasured on this runner. Handoff runs a full word ahead of completion, so a byte's finish_item no longer means it was sent.
So the translator calls item_done on the first byte before it pulls the second. That releases the first byte's finish_item before its word exists. Handoff and completion come apart, exactly as in the pipelined driver of U6.1. The same tool joins them again: when the word's transfer completes, the translator sends each byte a response, routed by set_id_info.
- Pull the high byte (0 ns)
The translator pulls 0x11 and must call
item_donebefore it may pull again, so the byte'sfinish_itemreturns now.More context
Handoff of 0x11 at 0 ns, before its word exists: the measured "handed off 0 0 10 10" begins here.
Follow the calls, values and waits
- call get_next_item: Packing translator → Upstream sequencer
- data byte 0x11: Upstream sequencer → Packing translator
- call item_done: Packing translator → Upstream sequencer
- Released: Byte sequence
- Pull the low byte (0 ns)
In the same time step the byte sequence offers 0x22, and the translator pulls and releases it too.
More context
Both bytes are handed off at 0 ns. The translator still holds both handles, so the byte sequence must not modify them.
Follow the calls, values and waits
- call get_next_item: Packing translator → Upstream sequencer
- data byte 0x22: Upstream sequencer → Packing translator
- call item_done: Packing translator → Upstream sequencer
- Released: Byte sequence
- Word transfer starts (0 ns)
The translator sends word 0x1122 and waits in
finish_item.More context
The word link takes 10 ns per word. Neither byte is complete yet.
Follow the calls, values and waits
- data word 0x1122: Packing translator → Word link
- Waiting: Packing translator — in finish_item(word)
- Waiting: Byte sequence — response thread waiting
- Word completes, bytes complete (10 ns)
The word transfer completes; the translator sends a response for each byte, routed by
set_id_info.More context
Both responses arrive at 10 ns: the measured "completed 10 10 20 20" begins here. The translator then pulls 0x33 and 0x44 in the same time step.
Follow the calls, values and waits
- return finish_item returns: Word link → Packing translator
- return rsp 0x11: Packing translator → Byte sequence
- return rsp 0x22: Packing translator → Byte sequence
- Released: Byte sequence
- Released: Packing translator
Read the diagram as text
- Byte sequence: Send thread and response thread
- Upstream sequencer: One outstanding item
- Packing translator: Holds hi and lo after releasing them
- Word link: Transfer 0-10 ns for word 0x1122
- Packing translator → Upstream sequencer: get_next_item(hi), item_done
- Packing translator → Upstream sequencer: get_next_item(lo), item_done
- Packing translator → Word link: finish_item(word)
- Packing translator → Byte sequence: put(rsp) for hi and lo at 10 ns
Handoff runs a full word ahead of completion. The byte sequence must not modify an item after handing it off: the translator still reads it until the word is sent.
The upstream sequence therefore takes the two-thread shape of U6.1, sending on one thread and collecting responses on the other. A sequence that only cares about handoff can skip responses, and then the translator should send none; otherwise they pile up in the upstream sequence's response queue.
Packing raises one decision the one-to-many case never meets. If the upstream stops after an odd byte, the translator holds half a word forever. It needs a flush rule: pad after a quiet period, or close the word on an end-of-stream marker.
Rebuilding packets in the analysis path
Stimulus goes down the layering; checking goes up. The byte monitor publishes every byte it sees on the far side of the link, which suits a byte-link scoreboard. A packet scoreboard needs packets, so the layering adds a reconstruction monitor.
The monitor publishes
The byte monitor owns a
uvm_analysis_port #(byte_item)namedapand callsap.write(b)for every byte. The call returns at once and takes no simulation time.Every connected subscriber receives it
writeloops over everything connected to the port and calls each one'swrite(b)with the same handle. Nothing is queued or copied.A subscriber implements write
uvm_subscriber #(byte_item)is a component with one input,analysis_export, and one function to fill in,write(byte_item t). The layering connects them withlink.mon.ap.connect(pkt_mon.analysis_export).
That is all this lesson needs from analysis ports; U7.2 covers them fully, including FIFOs for slower consumers. The reconstruction monitor's write runs a small state machine over the byte stream. Byte 1 is the destination and byte 2 the length. The next len bytes are the payload, and after the last one the monitor publishes a pkt_item on its own analysis port.
The monitor rebuilds from observed bytes, never from the translator's knowledge of what it sent. If the link dropped or duplicated a byte, the rebuilt packets would differ from the sent ones, and that is exactly what a packet-level comparison should catch. U8 builds the scoreboards that would consume the rebuilt packets. This parser trusts the length byte. A bench that injects malformed frames, as the hook's corrupt-frame test would, first needs a framing rule, such as resynchronising on a marker.
Byte splitting inside a packet driver
task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(pkt);
drive_byte(pkt.dst);
drive_byte(pkt.payload.size());
foreach (pkt.payload[i]) drive_byte(pkt.payload[i]);
seq_item_port.item_done();
end
endtask
Works, but the driver owns two protocols: raw-byte tests and a second packet format both need driver changes.
Translator over a byte agent
up_sqr.get_next_item(pkt);
send_byte(pkt.dst);
send_byte(pkt.payload.size());
foreach (pkt.payload[i]) send_byte(pkt.payload[i]);
up_sqr.item_done();
The byte agent is reused as is; the packet format lives in one sequence that a test can replace, or run raw-byte sequences instead.
Where each piece is created
Which phase creates, connects and starts each piece
| Phase | In pkt_layering | Why there |
|---|---|---|
build_phase | Create pkt_sqr, the byte agent and the reconstruction monitor | They are components; the factory builds them before connections |
connect_phase | Connect the byte monitor's analysis port to the reconstruction monitor | Ports connect after every component exists |
run_phase | Create the translator, set up_sqr, start it on link.sqr in a fork | Sequences are objects started at run time, and this one must never return |
Test run_phase | Start a packet sequence on layering.pkt_sqr | The test chooses the traffic; the layering only moves it |
Components in build and connect, the translator at run time.
Each extra layer adds one sequencer, one translator and one reconstruction monitor, each knowing only its neighbours. A change to the byte format then touches one translator and one monitor.
The translator ends the way the responder of U6.2 does: its forever loop is killed when the run phase ends. A packet half-sent at that moment is an interrupted transaction that nobody checks. So the test drops its objection only when the reconstruction monitor has rebuilt every packet it sent, as the example does with wait (layering.pkt_mon.packets == 2).
Try it
Read the three voices. The byte driver logs one byte per clock: destination, length, then the payload. The translator logs when it has sent a whole packet and calls item_done, and the packet sequence's finish_item returns in the same time step, at 35 ns and 85 ns. The packet sequence never mentions bytes.
The reconstruction monitor logs one clock after the last byte of each packet, because the link registers each byte before the byte monitor sees it. Its rebuilt packets match what was sent: four bytes for packet 1, five for packet 2.
Try adding a third packet with one payload byte and predict every line before running. Then make the translator call up_sqr.item_done() before the bytes and watch where the packet sequence's lines move.
5 ns byte drv byte 0x0a 15 ns byte drv byte 0x02 25 ns byte drv byte 0x11 35 ns byte drv byte 0x22 35 ns translator packet 1 sent as 4 bytes; item_done 35 ns packet seq finish_item returned for packet 1: dst=0x0a payload 11 22 45 ns pkt mon rebuilt dst=0x0a payload 11 22 from 4 bytes 45 ns byte drv byte 0x0b 55 ns byte drv byte 0x03 65 ns byte drv byte 0x33 75 ns byte drv byte 0x44 85 ns byte drv byte 0x55 85 ns translator packet 2 sent as 5 bytes; item_done 85 ns packet seq finish_item returned for packet 2: dst=0x0b payload 33 44 55 95 ns pkt mon rebuilt dst=0x0b payload 33 44 55 from 5 bytes 95 ns test two packets sent and rebuilt UVM_ERROR : 0Check yourself
Remember
- A layering has a sequencer for every non-leaf layer, a handle to the leaf agent, a translator per layer and, usually, a reconstruction monitor per layer.
- The translator runs on the lower sequencer and pulls from the upper one with the driver-side calls
get_next_itemanditem_done. - One-to-many: the upstream item stays outstanding until its last downstream item is sent, so its
finish_itemmeans it is on the link. - Many-to-one: a sequencer hands out one item at a time, so each upstream item is released at pull time and completed later with a response.
- The reconstruction monitor rebuilds upper-layer items from observed lower-layer items, and the test ends only after it has seen every packet.
Practice
- Pack bytes into words, and flush the odd one Implement · Hard