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
    • Sequence Items and the Driver Handshake
    • Sequence Mechanics: Lifecycle, Sequencer, Responses and Control
    • Virtual Sequences and Coordination
      • The Coordination Problem
      • Virtual Sequencer Wiring
      • Declaring p_sequencer
      • Scenario Patterns and Placement
      • Sub-sequences and Sequence Libraries
      • Virtual Sequences Without a Virtual Sequencer
      • lock vs grab: Who Gets the Sequencer
      • Protocol Layering with Translator Sequences
      • Reactive (Slave) Sequences
  • UVM Agents & Protocol VIP21
  • 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

‹Virtual Sequences and Coordination
  1. Topics
  2. UVM Verification
  3. UVM Sequences & Stimulus
  4. Virtual Sequences and Coordination
  5. Protocol Layering with Translator Sequences

Part 5 · Sequences · Intermediate

Protocol Layering with Translator Sequences

An upper-layer DMA job item (key and payload) on its own sequencer, a translator sequence that pulls jobs and produces APB register writes and AXI-Stream beats, and an upward monitor that rebuilds jobs from the bus. Built and run on the DMA bench.

On this page

    Your test writer wants to say "run three DMA jobs: this key, these six words" and nothing else. Today that means knowing that KEY is at 0x0C, that LEN must be written before GO, that GO is bit 0 of CTRL, that each word is a stream beat with TLAST on the last, and that STATUS must be cleared afterwards. Every abstraction leak is a place a test can get the protocol wrong. How do you let tests speak in jobs while the bench still speaks APB and AXI-Stream?

    The idea: a translator between two layers

    Protocol layering stacks abstraction levels the way network stacks do. An upper-layer item describes an operation in the design's terms: a DMA job with a key and a payload. A lower layer is the real bus: APB accesses and stream beats. Between them sits a translator sequence: it pulls one upper item, produces the lower-layer items that implement it, and reports the upper item done when the lower work is complete.

    Think of a simultaneous interpreter at a conference. The speaker (the upper-layer sequence) talks in whole sentences and waits for each to land; the interpreter (the translator) turns each sentence into the listeners' language, word by word. The speaker never learns the second language, and the listeners never hear the first. On the way back, a second interpreter (an upward monitor) listens to the bus and rebuilds the sentences, so checkers can work at the job level too.

    The upper layer needs a sequencer of its own, because upper-layer sequences call start_item and finish_item like any other. That sequencer has no driver. The translator plays the driver's part: it calls get_next_item and item_done on the upper sequencer, so the rule that `item_done` is called exactly once per item applies to it too.

    The picture

    Flowchart: a job sequence on an upper sequencer feeds a translator sequence that starts APB and stream sequences; bus monitors feed a job monitor that rebuilds jobs.
    Downward, a translator sequence turns each job into register writes and stream beats. Upward, a job monitor rebuilds jobs from what the bus monitors saw.

    Build it on the DMA bench

    The upper-layer item and its sequencer

    A job is a key and a payload. The length is the payload's size, so it can never disagree with the data. The upper sequencer is a plain uvm_sequencer parameterized by the job item; the test builds it like any component.

    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
    // snippet
    class dma_job_item extends uvm_sequence_item;
      rand bit [31:0] key;
      rand bit [31:0] payload[];
      constraint len_c { payload.size() inside {[1:6]}; }
      `uvm_object_utils(dma_job_item)
      function new(string name = "dma_job_item");
        super.new(name);
      endfunction
      function string convert2string();
        return $sformatf("key=%08h len=%0d", key, payload.size());
      endfunction
    endclass
    
    typedef uvm_sequencer #(dma_job_item) dma_job_sequencer;
    
    class dma_job_seq extends uvm_sequence #(dma_job_item);
      `uvm_object_utils(dma_job_seq)
      rand int unsigned n_jobs;
      constraint n_c { n_jobs inside {[2:3]}; }
      function new(string name = "dma_job_seq");
        super.new(name);
      endfunction
      task body();
        repeat (n_jobs) begin
          dma_job_item job = dma_job_item::type_id::create("job");
          start_item(job);
          if (!job.randomize()) `uvm_fatal("RAND", "dma_job_item randomize failed")
          finish_item(job);                     // returns when the translator calls item_done
          `uvm_info("JOB", {"upper layer: done ", job.convert2string()}, UVM_LOW)
        end
      endtask
    endclass

    dma_job_seq is ordinary sequence code. Nothing in it mentions a register or a beat; a test writer can constrain keys and lengths without opening the DMA spec.

    The translator sequence

    The translator is a virtual-style sequence: it starts lower-layer sequences on two sequencers and holds a handle to the upper sequencer it pulls from. It runs forever, one job per loop. The payload goes out through a small sequence that sends given words, since the bench's axis_pkt_seq randomizes its data.

    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
    // snippet
    class dma_job_xlate_seq extends uvm_sequence;
      `uvm_object_utils(dma_job_xlate_seq)
      dma_job_sequencer up_sqr;
      apb_sequencer     apb_sqr;
      axis_sequencer    axis_sqr;
      virtual irq_if    irq_vif;
      function new(string name = "dma_job_xlate_seq");
        super.new(name);
      endfunction
    
      task reg_rw(bit [7:0] addr, bit wr, bit [31:0] data, output bit [31:0] rdata);
        apb_rw_seq s = apb_rw_seq::type_id::create("s");
        s.addr = addr; s.write = wr; s.data = data;
        s.start(apb_sqr, this);
        rdata = s.rdata;
      endtask
    
      task body();
        bit [31:0] status, unused;
        forever begin
          dma_job_item   job;
          axis_words_seq beats = axis_words_seq::type_id::create("beats");
          up_sqr.get_next_item(job);             // pull one upper-layer item
          reg_rw(KEY, 1, job.key, unused);
          reg_rw(LEN, 1, job.payload.size(), unused);
          reg_rw(CTRL, 1, 32'h3, unused);        // IRQ_EN | GO
          foreach (job.payload[i]) beats.words.push_back(job.payload[i]);
          beats.start(axis_sqr, this);
          wait (irq_vif.irq === 1'b1);
          reg_rw(STATUS, 0, 0, status);
          reg_rw(STATUS, 1, 32'h6, unused);      // write 1 to clear DONE and ERR
          wait (irq_vif.irq === 1'b0);
          `uvm_info("XLATE", $sformatf("%s -> 5 APB accesses + %0d beats, STATUS=%08h",
                    job.convert2string(), job.payload.size(), status), UVM_LOW)
          up_sqr.item_done();                    // the upper item is finished
        end
      endtask
    endclass
    
    class axis_words_seq extends uvm_sequence #(axis_item);
      `uvm_object_utils(axis_words_seq)
      bit [31:0] words[$];
      function new(string name = "axis_words_seq");
        super.new(name);
      endfunction
      task body();
        foreach (words[i]) begin
          axis_item it = axis_item::type_id::create("it");
          start_item(it);
          if (!it.randomize() with { data == words[i]; last == (i == words.size() - 1); })
            `uvm_fatal("RAND", "axis_item randomize failed")
          finish_item(it);
        end
      endtask
    endclass

    Three choices in it are deliberate. item_done comes last, after the interrupt is handled, so finish_item in the upper sequence returns only when the job has really finished; the upper layer sees end-to-end completion, not "the translator has started". Every lower-layer child is started with this as parent, so if the translator ever needs to lock the APB sequencer around a job, its children pass. And the translator waits for the interrupt to fall after clearing it, so the next job's wait cannot be satisfied by the last job's level.

    Common pitfall

    Both waits are unbounded. If the DUT never raises the interrupt, the translator never calls item_done, the upper sequence sits in finish_item forever, and the test dies only at the global timeout with a message that names nobody. Bound them the way scenario patterns bounds an interrupt wait: race the wait against a per-job limit inside a guarded fork, and on timeout report which job and which register state, then call item_done so the upper layer can end. The translator owns that limit, because only it knows what a job should cost.

    The handle to up_sqr is the simplest wiring. Larger benches often put a uvm_seq_item_pull_port on a lower-layer sequencer subclass, connect it to the upper sequencer's seq_item_export, and have the translator call p_sequencer.up_port.get_next_item(); packaged with the upper sequencer and the job monitor, that is a layering agent. The mechanics are the same: the translator is the upper layer's driver.

    The upward monitor

    Checking at the job level needs jobs rebuilt from the bus, never from the translator, for the same reason the bench's scoreboard predicts from monitors and not from sequences. dma_job_monitor subscribes to the APB and stream-in monitors, remembers KEY and LEN from observed writes, starts collecting at a GO write, and publishes a job when LEN words (or a TLAST) have gone in:

    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
    // snippet
    `uvm_analysis_imp_decl(_jreg)
    `uvm_analysis_imp_decl(_jdat)
    class dma_job_monitor extends uvm_component;
      `uvm_component_utils(dma_job_monitor)
      uvm_analysis_imp_jreg #(apb_item,  dma_job_monitor) apb_export;
      uvm_analysis_imp_jdat #(axis_item, dma_job_monitor) in_export;
      uvm_analysis_port #(dma_job_item) ap;
      bit [31:0] key, len;
      bit        collecting;
      bit [31:0] words[$];
      function new(string name, uvm_component parent);
        super.new(name, parent);
        apb_export = new("apb_export", this);
        in_export  = new("in_export", this);
        ap         = new("ap", this);
      endfunction
      virtual function void write_jreg(apb_item t);
        if (!t.write || t.slverr) return;
        if (t.addr == KEY) key = t.data;
        if (t.addr == LEN) len = t.data[15:0];
        if (t.addr == CTRL && t.data[0]) begin collecting = 1; words.delete(); end
      endfunction
      virtual function void write_jdat(axis_item t);
        dma_job_item job;
        if (!collecting) return;
        words.push_back(t.data);
        if (words.size() == len || t.last) begin
          job = dma_job_item::type_id::create("job");   // a fresh object per job
          job.key = key;
          job.payload = new[words.size()];
          foreach (words[i]) job.payload[i] = words[i];
          collecting = 0;
          `uvm_info("JOBMON", {"rebuilt ", job.convert2string()}, UVM_LOW)
          ap.write(job);
        end
      endfunction
    endclass

    The test builds job_sqr and job_mon in build_phase, connects env.apb.ap and env.in_agt.ap to the monitor in connect_phase, then in run_phase hands the translator its four handles, forks xlate.start(null) with join_none, and starts dma_job_seq on job_sqr. When the upper sequence returns, every job is done; the forever translator is simply left waiting on an empty upper sequencer when the objection drops.

    Watch it run

    A scratch run of the bench with these classes (dma_layer_test). The keys and lengths are random, so yours differ:

    Diagram
    UVM_INFO dma_tb_scratch.sv(1124) @ 225000: uvm_test_top.job_mon [JOBMON] rebuilt key=429dc9e5 len=1
    UVM_INFO dma_tb_scratch.sv(1084) @ 305000: reporter@@xlate [XLATE] key=429dc9e5 len=1 -> 5 APB accesses + 1 beats, STATUS=00000002
    UVM_INFO dma_tb_scratch.sv(1031) @ 305000: uvm_test_top.job_sqr@@jobs [JOB] upper layer: done key=429dc9e5 len=1
    UVM_INFO dma_tb_scratch.sv(1124) @ 515000: uvm_test_top.job_mon [JOBMON] rebuilt key=cf8ef02d len=2
    UVM_INFO dma_tb_scratch.sv(1084) @ 595000: reporter@@xlate [XLATE] key=cf8ef02d len=2 -> 5 APB accesses + 2 beats, STATUS=00000002
    UVM_INFO dma_tb_scratch.sv(1031) @ 595000: uvm_test_top.job_sqr@@jobs [JOB] upper layer: done key=cf8ef02d len=2
    UVM_INFO dma_tb.sv(411) @ 695000: uvm_test_top.env.sb [SB] 3 words matched, 0 mismatched
    UVM_ERROR :    0
    • JOBMON before XLATE: the monitor rebuilt each job as soon as its last input word went in; the translator finished later, after the interrupt and the STATUS clear.

    • XLATE and JOB at the same time: the upper sequence's finish_item returned in the same timestep the translator called item_done.

    • 3 words matched: the bench's scoreboard checked the data (1 + 2 words) without knowing that a layer above existed.

    What breaks: the translator that said done too early

    Move up_sqr.item_done() to just after beats.start(), "because the job has been sent", and nothing fails. That is the problem. The upper sequence's finish_item now returns as soon as the last word has entered the DMA, before the interrupt arrives and before STATUS is read. For the last job, the test's objection can drop while the translator is still waiting for the interrupt, and the run can end with that job's STATUS never read. An ERR on the final job would then pass silently, and the log would show JOB ... done lines that run ahead of the XLATE lines.

    The general rule: the translator decides what done means for an upper item, and it should mean what a test writer assumes, which is that the operation is over and its outcome is known. Put item_done after the last thing that can fail.

    Interview

    "How would you layer a high-level protocol on top of a bus agent?"

    Weak: "Write a sequence that calls the lower sequences." That is a virtual sequence, not layering.

    Strong: "Give the upper layer its own item and its own sequencer with no driver. A translator sequence runs on, or alongside, the lower sequencers: it calls get_next_item on the upper sequencer, starts lower-layer sequences with itself as parent to implement the item, and calls item_done exactly once when the operation is really complete, so upper-layer finish_item means done. Upward, a monitor rebuilds upper items from the lower monitors' analysis ports, so checkers can work at that level and never trust the translator. Package the upper sequencer, translator and monitor as a layering agent if it's reused."

    Follow-ups: Who calls item_done on the upper item? (the translator, once), and Why not rebuild jobs from the translator's own record? (it would check intent, not what reached the pins).

    Check yourself The upper sequence calls get_response() after each finish_item, but the translator calls item_done() with no argument. What happens?

    The upper sequence blocks in get_response() forever, because no response is ever put. Either the upper sequence should not ask for responses (read results back from the item after finish_item, as the bench does), or the translator should call item_done(rsp) or put_response.

    Check yourself A job with a short payload ends early with STATUS.ERR because the last beat carried TLAST. Does the job monitor still publish a job?

    Yes: it publishes when it has LEN words or sees TLAST, so a truncated job comes out with fewer words than LEN. A checker comparing it with the intended job sees the mismatch. A monitor that waited only for LEN words would never publish, and would merge the next job's words into it.

    Next: the translator pushes traffic into the DUT. Some interfaces work the other way round: the DUT asks, and the bench answers. Reactive (Slave) Sequences answers the DMA's output stream.

    Related topics

    • Analysis broadcast

    • The driver loop

    • Virtual sequences without a virtual sequencer

    Previous

    92 / 280

    Next: Reactive (Slave) Sequences

    Up next

    Up nextReactive (Slave) Sequences

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • The Coordination Problem
    • Virtual Sequencer Wiring
    • Declaring p_sequencer