Skip to content
Block to Chiplet to SoC: A DMA and Ethernet Worked Example

Block to Chiplet to SoC: A DMA and Ethernet Worked Example

The first five articles in this series built the pieces in isolation: the allocator, the registry, the address-map reservation. This article covers what happens when four real IP blocks, three DMA engines and an Ethernet controller, get reused across three integration levels. The environments and sequences never decide an address or a region name themselves, anywhere, at any level. That decision belongs in exactly one place: the test.

Why this isn’t mam_registry again

A Singleton Wrapper for Block, Subsystem, and SoC Reuse’s registry is a singleton, and that’s not what changes here: mem_region_manager below is a singleton too, for the same reason. Exactly one instance may ever exist for the life of the simulation, and constructing a second one is a bug the class itself refuses to allow, the same guarantee that’s been the point of this series since The Case for a Shared Memory Manager. What’s different is what the singleton actually holds. mam_registry is a name-keyed table of separately-owned uvm_mem_mam instances: each environment level constructs its own allocator over whatever memory it owns and registers it under a name, so get_pool() can hand the right one back later. That works cleanly for one reusable pool shared down a hierarchy, but it doesn’t fully hold up once a single simulation has several sibling instances, three DMA engines and an Ethernet block, each needing its own region names. The registry can guarantee a name isn’t registered twice, but it has no visibility into address math across pools: dma0’s allocator and dma1’s allocator are two entirely separate uvm_mem_mam instances, each with its own free list, and nothing about the registry cross-checks whether their address ranges happen to overlap. That discipline is left entirely to whoever picked each pool’s start_offset/end_offset by hand, exactly the case its own closing section flagged as the registry’s limit. mem_region_manager fixes it by never letting more than one uvm_mem_mam exist in the first place: every name, block-level "src", chiplet-level "dma0_src", SoC-level "chiplet0_dma0_src", is a named sub-region carved from the same single allocator, so the overlap check that used to be scattered across N independently-constructed pools is now one allocator’s own free-list bookkeeping, and the one table of names living in the test is something a person can actually read end to end and verify by inspection.

One manager, and the test owns every address

The pattern this series has been building toward is simpler than it looks once stated plainly: there is one uvm_mem_mam for the entire simulation, its bounds are set in the base test, and every named region any sequence will ever ask for is declared in that same test, up front, before the environment is built. Not in a block environment’s build_phase. Not conditionally suppressed by a config flag threaded down from a parent. In the test.

 1class mem_region_manager;
 2  static local mem_region_manager m_inst;
 3  local uvm_mem_mam mam;
 4  local uvm_mem_region m_named[string];
 5  local bit m_configured;
 6
 7  local function new();
 8  endfunction
 9
10  // new() is private, so this is the only way to get an instance, and
11  // it's the same instance every time -- exactly one uvm_mem_mam exists
12  // for the life of the simulation, the property this whole series
13  // exists to protect
14  static function mem_region_manager get();
15    if (m_inst == null) m_inst = new();
16    return m_inst;
17  endfunction
18
19  function void configure(uvm_mem_mam_cfg cfg);
20    if (m_configured)
21      `uvm_fatal("MEM_MGR", "configure() called more than once -- one test owns this manager's bounds")
22    mam = new("mam", cfg, null);
23    m_configured = 1;
24  endfunction
25
26  // the only place a name and a fixed address ever meet:
27  // reserve_region() takes an explicit start_offset and returns a handle
28  // pinned there, unlike request_region(), which only takes a size and
29  // lets the allocator pick the address itself
30  function void declare_region(string name, uvm_reg_addr_t start, uvm_reg_addr_t end);
31    if (m_named.exists(name))
32      `uvm_fatal("MEM_MGR", $sformatf("region '%s' already declared", name))
33    m_named[name] = mam.reserve_region(.start_offset(start), .n_bytes(end - start + 1));
34  endfunction
35
36  function uvm_mem_region get_region(string name);
37    if (!m_named.exists(name))
38      `uvm_fatal("MEM_MGR", $sformatf("no region named '%s' was declared", name))
39    return m_named[name];
40  endfunction
41endclass

Block level: DMA declares its own names, Ethernet declares its own, independently

dma_base_test is the only place "src" and "dst" mean anything. It configures the one manager for this simulation, sized for whatever this standalone regression needs, and declares exactly the two regions the DMA sequences will ask for:

 1class dma_base_test extends uvm_test;
 2  function void build_phase(uvm_phase phase);
 3    mem_region_manager mgr = mem_region_manager::get();
 4    uvm_mem_mam_cfg cfg = new();
 5    cfg.start_offset = 'h0000_0000;
 6    cfg.end_offset   = 'h0001_FFFF;
 7    mgr.configure(cfg);
 8    mgr.declare_region("src", 'h0000_0000, 'h0000_FFFF);
 9    mgr.declare_region("dst", 'h0001_0000, 'h0001_FFFF);
10  endfunction
11endclass

eth_base_test does whatever Ethernet actually needs, in a separate test class, with no relationship to dma_base_test at all:

 1class eth_base_test extends uvm_test;
 2  function void build_phase(uvm_phase phase);
 3    mem_region_manager mgr = mem_region_manager::get();
 4    uvm_mem_mam_cfg cfg = new();
 5    cfg.start_offset = 'h0000_0000;
 6    cfg.end_offset   = 'h0002_FFFF;
 7    mgr.configure(cfg);
 8    mgr.declare_region("tx_ring", 'h0000_0000, 'h0001_FFFF);
 9    mgr.declare_region("rx_ring", 'h0002_0000, 'h0002_FFFF);
10  endfunction
11endclass

The sequences themselves never hardcode "src" or "tx_ring" inline. That literal exists in exactly one place, a config object’s class default, not repeated at every call site that needs it. A small config object carries each sequence’s own region names, defaulting to the block-level names and overridable by any test that needs to say otherwise:

 1class dma_seq_cfg extends uvm_object;
 2  `uvm_object_utils(dma_seq_cfg)
 3  string src_region_name = "src";
 4  string dst_region_name = "dst";
 5endclass
 6
 7task dma_transfer_seq::body();
 8  dma_seq_cfg cfg = dma_seq_cfg::type_id::create("cfg");   // class defaults, overridden below if a test declared one
 9  void'(uvm_config_db#(dma_seq_cfg)::get(null, get_full_name(), "cfg", cfg));
10
11  src_region = mem_region_manager::get().get_region(cfg.src_region_name);
12  dst_region = mem_region_manager::get().get_region(cfg.dst_region_name);
13  build_and_send_descriptor(src_region, dst_region);
14endtask

get_full_name() on a sequence resolves through the sequencer it’s running on, so the lookup is scoped to that specific sequencer’s path, not a wildcard match against every sequence in the simulation. At block level nothing ever calls set() for dma_seq_cfg, so the get() finds nothing, cfg keeps its class defaults ("src"/"dst"), and that’s exactly what dma_base_test declared. Nothing about the sequence changes when a level above it starts calling set() instead. mem_region_manager::get() needs no lookup at all, since there’s only ever one instance to find.

Chiplet level: one test, one manager, every block’s regions declared together

The chiplet instantiates three DMA engines and one Ethernet block. chiplet_base_test is the single place responsible for every region any of the four needs, because it’s the only test that can see all four at once:

 1class chiplet_base_test extends uvm_test;
 2  function void build_phase(uvm_phase phase);
 3    mem_region_manager mgr = mem_region_manager::get();
 4    uvm_mem_mam_cfg cfg = new();
 5    cfg.start_offset = 'h8000_0000;
 6    cfg.end_offset   = 'h8FFF_FFFF;   // the chiplet's real, bounded range
 7    mgr.configure(cfg);
 8
 9    mgr.declare_region("dma0_src", 'h8000_0000, 'h8000_FFFF);
10    mgr.declare_region("dma0_dst", 'h8001_0000, 'h8001_FFFF);
11    mgr.declare_region("dma1_src", 'h8002_0000, 'h8002_FFFF);
12    mgr.declare_region("dma1_dst", 'h8003_0000, 'h8003_FFFF);
13    mgr.declare_region("dma2_src", 'h8004_0000, 'h8004_FFFF);
14    mgr.declare_region("dma2_dst", 'h8005_0000, 'h8005_FFFF);
15    mgr.declare_region("eth0_tx_ring", 'h8006_0000, 'h8007_FFFF);
16    mgr.declare_region("eth0_rx_ring", 'h8008_0000, 'h8009_FFFF);
17
18    // each instance gets told which of the eight names are its own
19    set_dma_cfg("dma0", "dma0_src", "dma0_dst");
20    set_dma_cfg("dma1", "dma1_src", "dma1_dst");
21    set_dma_cfg("dma2", "dma2_src", "dma2_dst");
22  endfunction
23
24  // scoped to that one instance's own sequencer path, so dma0/dma1/dma2
25  // each resolve get_full_name() to a distinct cfg instead of sharing one
26  local function void set_dma_cfg(string inst, string src_name, string dst_name);
27    dma_seq_cfg cfg = dma_seq_cfg::type_id::create(inst);
28    cfg.src_region_name = src_name;
29    cfg.dst_region_name = dst_name;
30    uvm_config_db#(dma_seq_cfg)::set(null, {"*.", inst, "*"}, "cfg", cfg);
31  endfunction
32endclass

One manager, one declaration table, owned entirely by whichever test is running: block level declares two names, chiplet level declares eight in the same place, SoC level declares the full real map, the DMA and Ethernet sequences underneath never change

Nothing here is subtle or clever. Eight names, eight fixed ranges, all visible in one function, all owned by one person writing the chiplet test, whose job includes making sure none of the eight ranges overlap. That’s a much easier property to verify by inspection than four block environments each independently deciding whether they’re allowed to own a pool this time.

SoC level: the same discipline, the real memory map

An SoC test instantiating two chiplets is not a special case. It’s chiplet_base_test’s pattern applied once more, by a test that can see both chiplets and knows the package’s actual physical memory map, the numbers that exist nowhere below this level:

 1class soc_base_test extends uvm_test;
 2  function void build_phase(uvm_phase phase);
 3    mem_region_manager mgr = mem_region_manager::get();
 4    uvm_mem_mam_cfg cfg = new();
 5    cfg.start_offset = 'h1_0000_0000;
 6    cfg.end_offset   = 'h1_1FFF_FFFF;  // real package address space
 7    mgr.configure(cfg);
 8
 9    mgr.declare_region("chiplet0_dma0_src", 'h1_0000_0000, 'h1_0000_FFFF);
10    mgr.declare_region("chiplet0_dma0_dst", 'h1_0001_0000, 'h1_0001_FFFF);
11    // ... the remaining six chiplet0 regions, same shape
12    mgr.declare_region("chiplet1_dma0_src", 'h1_1000_0000, 'h1_1000_FFFF);
13    mgr.declare_region("chiplet1_dma0_dst", 'h1_1001_0000, 'h1_1001_FFFF);
14    // ... the remaining six chiplet1 regions
15  endfunction
16endclass

Every name in this table is longer and more specific than the chiplet-level version, "chiplet0_dma0_src" instead of "dma0_src", because the SoC test is the one place that has to keep two chiplets’ worth of names from colliding, the same way the chiplet test kept four blocks’ worth of names from colliding. Nothing forces that uniqueness automatically. It’s a spreadsheet-shaped table that one engineer owns and reviews, which is exactly the property that makes an address map trustworthy: a human can read the whole thing in one place and see whether it’s right.

What never changes underneath all of this

The DMA sequence’s body() task, the Ethernet sequence’s, every line of every block environment, are identical at all three levels. What changes, every time, is a table in a test, mapping names to addresses, owned by whoever can see the whole topology that specific test is exercising. Block level sees one DMA. Chiplet level sees four blocks. SoC level sees two chiplets. Each of those tests is the complete, sole authority for its own scope, and none of them ever have to ask a lower level’s permission to reassign an address, because the lower level was never the one holding that decision in the first place.


This example ties together the allocator from Inside uvm_mem_mam, the reservation mechanism from Keeping Allocations Out of the RAL Address Map, and the region-name discipline this article adds on top: one owner, one table, one level at a time.

Last updated on