A new engineer tidies dma_env by moving the three connect calls into build_phase, right after the create calls: "why spread it over two functions?" The first run dies at time 0 with a null-object access on apb.ap. The agent was created a line earlier, so how can its port be null? The answer is the whole reason UVM separates building from connecting.
Building a testbench is like wiring a house. You cannot run a cable to a socket that has not been installed yet, so the walls go up first, room by room, and only then does the electrician connect everything. UVM does the same in two phases. In build_phase, each component creates its children, starting from the test and working down, so a parent always exists before its children and can configure them. In connect_phase, which runs only after the whole tree is built, components connect ports, starting from the leaves and working up.
The bottom-up order matters for one common pattern: an agent copies its monitor's port into its own ap in its connect_phase, and the env connects that ap in its connect_phase. Because children connect before parents, the agent's ap is already assigned when the env uses it. In build_phase, by contrast, the env runs before its agents' own build_phase, so the monitor, and the port the agent forwards, do not exist yet. That is the hook's null.
The bench's env is short because it does only two jobs. Build creates every child through the factory with a stable name; connect wires each agent's public port to a scoreboard input.
// snippet: from the course DMA bench
class dma_env extends uvm_env;
`uvm_component_utils(dma_env)
apb_agent apb;
axis_agent in_agt, out_agt;
dma_scoreboard sb;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
// The output side is observed only: the DUT drives it.
uvm_config_db#(uvm_active_passive_enum)::set(this, "out_agt", "is_active", UVM_PASSIVE);
apb = apb_agent::type_id::create("apb", this);
in_agt = axis_agent::type_id::create("in_agt", this);
out_agt = axis_agent::type_id::create("out_agt", this);
sb = dma_scoreboard::type_id::create("sb", this);
endfunction
function void connect_phase(uvm_phase phase);
apb.ap.connect(sb.apb_export);
in_agt.ap.connect(sb.in_export);
out_agt.ap.connect(sb.out_export);
endfunction
endclassNames are stable and meaningful: apb, in_agt, out_agt, sb. Config paths, factory instance overrides and every log line depend on them.
Every child comes from `type_id::create`, so a test can override any of them. dma_drain_test replaces sb with a derived scoreboard this way, with no env edit.
Connect is unconditional. No if on a scenario knob decides whether a checker hears traffic.
A class can have only one method called write, but the scoreboard needs three inputs. The uvm_analysis_imp_decl macro creates a new imp class whose write call is forwarded to a method with a suffix, so one scoreboard can implement write_apb, write_in and write_out:
// snippet: from the bench, above dma_scoreboard
`uvm_analysis_imp_decl(_apb) // uvm_analysis_imp_apb calls write_apb()
`uvm_analysis_imp_decl(_in) // uvm_analysis_imp_in calls write_in()
`uvm_analysis_imp_decl(_out) // uvm_analysis_imp_out calls write_out()The alternative is a uvm_tlm_analysis_fifo per stream and a run_phase thread in the scoreboard that pulls from them. FIFOs decouple timing (the scoreboard can wait for a matching item), at the cost of a thread and a FIFO to drain at the end of the test. The imp style is simpler when every input can be processed the moment it arrives, as here.
An analysis port can connect to any number of imps, and each write reaches all of them. Adding a coverage collector to the APB stream is one more line in connect_phase, and the monitor never knows:
// snippet
apb.ap.connect(sb.apb_export);
apb.ap.connect(len_cov.analysis_export); // a uvm_subscriber names its imp analysis_exportTwo facts about fan-out are worth knowing cold. A second connection never replaces the first; every connected imp receives every transaction. And the order in which subscribers are called is not something to rely on (the details are in analysis broadcast). An analysis port with no subscribers at all is legal, which is why a forgotten connection is silent.
An env usually gives each agent a config object. It can put the object in config_db for the agent to fetch in its build_phase, or assign it straight to the agent's handle right after create. Both are correct, because the agent's build_phase has not run yet at that point. config_db can be traced and printed (+UVM_CONFIG_DB_TRACE) and works across hierarchy levels; a direct assignment is checked by the compiler and costs nothing at run time. Pick one per bench and use it everywhere.
The passing run proves all three connections are live: the scoreboard's 8 words matched needs the KEY from the APB stream, every input word, and every output word. Now delete the first line of connect_phase, so the APB monitor's traffic never reaches the scoreboard. Nothing complains at elaboration, because an unconnected analysis port is legal. The scoreboard's key stays 0, so it predicts each output as the plain input word, and every compare fails:
UVM_ERROR dma_tb.sv(396) @ ...: uvm_test_top.env.sb [SB_MISMATCH] expected <input word>, got <input word ^ a5a50f0f>
...
UVM_INFO dma_tb.sv(411) @ ...: uvm_test_top.env.sb [SB] 0 words matched, 8 mismatchedEvery mismatch differs from its expected value by exactly the KEY, a5a50f0f. That pattern is the clue: the data path is fine, and the scoreboard is missing the register stream. A symptom that is "wrong by a constant" usually means a missing input, not a broken DUT.
Back to the hook. With the connects moved into build_phase, the env executes apb.ap.connect(...) immediately after creating apb. But apb.ap is assigned in the agent's connect_phase from mon.ap, and at this moment the agent's build_phase has not even run, so mon does not exist. apb.ap is null, and calling connect on it is a null-object access that stops the simulation at time 0.
The engineer's next instinct is often to "fix" it by connecting to apb.mon.ap instead, which still fails for the same reason and also breaks the agent boundary. The real fix is to put connections back in connect_phase. The rule to remember: build creates, connect wires, and anything that needs a child's internals waits for connect.
0 words matched, 8 mismatched, and every actual word equals the expected word XOR a5a50f0f. Where do you look first?The APB-to-scoreboard path: a missing apb.ap.connect, or a write_apb that does not recognise the KEY write. The constant difference equal to the KEY means the prediction used KEY = 0 while the DUT used the real key. The stream paths are working, since every word was compared.
in_agt.cfg = cfg.in_cfg; right after in_agt = axis_agent::type_id::create("in_agt", this);?Yes. The agent's build_phase runs after the env's has finished, so the handle is in place when the agent reads it. It is a legitimate alternative to config_db, type-checked by the compiler, though it does not show up in config_db traces.
Next: the env is wired, but a DMA job still needs two agents to act in order: registers first, then data. Coordinating agents shows how.