UVM
Driver Architecture
Organizing a driver into clean layers — a run-loop skeleton (reset + pull-convert-complete) over a dispatch, per-operation conversion routines, and low-level primitives — separating the protocol logic from the loop.
Drivers · Module 12 · Page 12.5
The Engineering Problem
The previous chapters covered the driver's pieces — responsibilities (Module 12.1), conversion (12.2), signal driving (12.3), timing (12.4). Each is a real concern, and a real driver has all of them at once: a reset-aware loop, a pull-convert-complete cycle, per-operation conversion, wait-state and latency handling, idle driving. Crammed into one giant run_phase task, these concerns tangle — the reset handling interleaves with the conversion, which interleaves with the timing — and the result is unmaintainable: a small protocol change risks breaking the reset handling, a conversion bug is buried in a wall of code, and the protocol logic can't be reused. The problem this chapter solves is organizing the pieces so the driver is clean, maintainable, and reusable.
Driver architecture is the layered organization of the driver's concerns. A clean driver has layers: a run-loop skeleton (the reset-aware pull-convert-complete loop — Modules 12.1, 12.4) over a dispatch (branch by operation type) over per-operation conversion routines (the field-to-signal driving — 12.2) over low-level primitives (drive_idle, wait_for_ready, sample — 12.3, 12.4). And the protocol logic is separated from the loop skeleton — often via a reusable base driver providing the skeleton with overridable protocol hooks (the base-sequence pattern, Module 9.7, applied to drivers). This chapter is the architecture: the layers, the separation of protocol from skeleton, the decomposition into methods, and why a monolithic run_phase couples concerns so a change anywhere breaks something elsewhere.
How do you organize a driver into clean layers — a run-loop skeleton over a dispatch over per-operation conversion routines over low-level primitives — separating the protocol logic from the loop, so the driver is maintainable and reusable rather than a tangled monolith?
Motivation — why a driver needs architecture
A driver needs a deliberate architecture because it combines several distinct concerns, and conflating them in one task makes the driver unmaintainable:
- The concerns are distinct, so they should be separate. Reset handling, operation dispatch, field-to-signal conversion, and low-level timing are different problems with different logic. Keeping them in separate methods means each is understandable and changeable in isolation; tangling them in one task means understanding all of it to change any of it.
- Changes should be localized, so concerns must be decoupled. Adding an operation should touch only the dispatch and a new conversion routine — not the reset handling. If everything is coupled in one task, a change anywhere risks breaking everywhere (the DebugLab). Decoupling localizes change.
- The skeleton is reusable, so separate it from the protocol. The reset-aware pull-convert-complete loop is the same for every driver (it's protocol-agnostic). The protocol conversion is what differs. Separating the skeleton (reusable) from the protocol (specific) lets the skeleton be a base driver reused across protocols, with the protocol as overridable hooks.
- Bugs should be localizable, so the structure must be navigable. When a driver misbehaves, you should know which layer to look in — timing? conversion? reset? A layered driver makes the symptom point to a layer; a monolith makes every bug a search through one wall of code.
- Primitives are reused, so factor them out.
drive_idle,wait_for_ready,sampleare used by multiple operations; factoring them into small primitives avoids duplication and makes each correct once.
The motivation, in one line: a driver combines distinct concerns (reset, dispatch, conversion, timing) that, tangled in one task, make it unmaintainable — so a deliberate layered architecture (skeleton over dispatch over conversion over primitives, protocol separated from skeleton) keeps each concern isolated, changes localized, the skeleton reusable, and bugs navigable.
Mental Model
Hold driver architecture as organizing a kitchen into stations, not piling everything on one counter:
A well-architected driver is a kitchen organized into stations with a clean workflow; a monolithic driver is everything piled on one chaotic counter where touching anything disturbs everything. A good kitchen has a workflow (orders in, dishes out, handle emergencies — the run-loop skeleton: reset, pull, complete), a routing of each order to the right station (the dispatch: a grill order to the grill, a fry order to the fryer), the stations themselves (the conversion routines: how a grill order is cooked), and basic techniques every station uses (the primitives: chop, stir, plate —
drive_idle,wait_for_ready,sample). Each station works independently: you can change the grill recipe without touching the fryer, and the workflow (orders in, dishes out) is the same regardless of what's cooked — so the workflow is a reusable frame, and the recipes are the specifics. The monolithic kitchen is the opposite: one counter where everything — routing, cooking, plating, handling emergencies — is jumbled together, so changing one recipe might knock over the order queue, and finding where a dish went wrong means sifting the whole mess. The skill is organizing the kitchen: a clean workflow over routing over stations over techniques, with the recipes (protocol) separable from the workflow (skeleton).
So driver architecture is building a kitchen, not a pile: the run-loop skeleton is the workflow, the dispatch routes operations, the conversion routines are the stations, the primitives are the techniques — each separable, so changes are local and the skeleton is reusable. The monolith — everything in one task — is the pile to avoid.
Visual Explanation — the layered driver architecture
The defining picture is the layered stack: the run-loop skeleton on top, then dispatch, then per-operation conversion, then low-level primitives — each layer built on the one below.
The figure shows the layered stack that makes a driver maintainable. At the top, the run-loop skeleton handles reset and the pull-convert-complete cycle (Modules 12.1, 12.4) — and crucially, it's protocol-agnostic: the same loop works for any protocol, so it's reusable (a base driver). It dispatches each transaction by operation type — routing a read to drive_read, a write to drive_write. Each operation has a conversion routine that does the field-to-signal driving (Module 12.2) — this is the protocol-specific layer, the "flesh" that differs per protocol. And those routines use low-level primitives — drive_idle, wait_for_ready, sample (Modules 12.3, 12.4) — small helpers shared across operations, written correct once and reused everywhere. The crucial reading is that each layer is built on the one below and is changeable in isolation: you can add an operation (a new conversion routine + a dispatch entry) without touching the skeleton; fix a conversion bug in one routine without touching another; improve a primitive and all operations benefit. The separation is the architecture's value: the skeleton (reusable, protocol-agnostic) is distinct from the conversion (protocol-specific), and the primitives are factored out of the operations. This is the opposite of a monolith — where reset, dispatch, conversion, and timing are all in one task, so nothing is changeable in isolation. The diagram is the architecture's essence: layers, each separable, with the protocol logic (conversion) isolated from the reusable skeleton.
RTL / Simulation Perspective — the decomposed driver
The architecture is realized by decomposing the driver into methods — one per layer/concern — rather than one giant task. The code shows the decomposition.
class my_driver extends uvm_driver #(bus_item);
// ── LAYER 1: run-loop SKELETON (reset-aware pull-convert-complete) ──
task run_phase(uvm_phase phase);
forever begin
if (!vif.rst_n) begin drive_idle(); @(posedge vif.rst_n); end // reset (Module 12.4)
fork
begin : drv forever begin
seq_item_port.get_next_item(req);
drive_transaction(req); // → DISPATCH (layer 2)
seq_item_port.item_done();
end end
begin : rstw @(negedge vif.rst_n); end
join_any
disable fork;
end
endtask
// ── LAYER 2: DISPATCH (branch by operation) ──
virtual task drive_transaction(bus_item tr);
if (tr.is_read) drive_read(tr); // → CONVERSION (layer 3)
else drive_write(tr);
endtask
// ── LAYER 3: per-operation CONVERSION routines (protocol-specific) ──
virtual task drive_write(bus_item tr);
drive_request(tr); // uses PRIMITIVES (layer 4)
foreach (tr.data[i]) begin vif.drv_cb.wdata <= tr.data[i]; wait_for_ready(); end
drive_idle();
endtask
virtual task drive_read(bus_item tr);
drive_request(tr);
repeat (READ_LATENCY) @(vif.drv_cb);
sample_response(tr);
drive_idle();
endtask
// ── LAYER 4: low-level PRIMITIVES (shared, small) ──
task drive_request(bus_item tr); @(vif.drv_cb); vif.drv_cb.addr <= tr.addr; vif.drv_cb.valid <= 1; endtask
task wait_for_ready(); do @(vif.drv_cb); while (!vif.drv_cb.ready); endtask
task sample_response(bus_item tr);foreach (tr.rdata[i]) begin tr.rdata[i] = vif.drv_cb.rdata; @(vif.drv_cb); end endtask
task drive_idle(); vif.drv_cb.valid <= 0; endtask
endclass
// virtual methods = overridable PROTOCOL HOOKS: a derived/variant driver overrides drive_read/write,
// reusing the skeleton — the base-driver pattern (separating skeleton from protocol)The code shows the decomposition into layers, each a method. Layer 1 — the skeleton (run_phase): the reset-aware loop (Module 12.4) that pulls, calls drive_transaction, and completes — protocol-agnostic, the same for any driver. Layer 2 — dispatch (drive_transaction): a small method that branches by operation — read to drive_read, write to drive_write — nothing else. Layer 3 — conversion (drive_read/drive_write): the per-operation, protocol-specific driving (Module 12.2) — each small, focused on one operation, using primitives. Layer 4 — primitives (drive_request, wait_for_ready, sample_response, drive_idle): tiny low-level helpers (Modules 12.3, 12.4), shared across operations, each one clear job. The separation is visible: the skeleton knows nothing about read vs write (that's dispatch); the conversion knows nothing about reset (that's the skeleton); the primitives know nothing about operations (they're shared). And the virtual methods (the comment) are the protocol hooks: drive_transaction/drive_read/drive_write are virtual, so a derived/variant driver can override them — reusing the skeleton while replacing the protocol (the base-driver pattern, separating skeleton from protocol). The shape to carry: decompose the driver into methods by layer — skeleton (loop), dispatch (branch), conversion (per-op), primitives (helpers) — so each concern is a small, focused, changeable-in-isolation method, and make the protocol layers virtual so the skeleton is reusable.
Verification Perspective — separating the skeleton from the protocol
The architectural decision that most affects reuse is separating the reusable skeleton from the protocol-specific conversion — the base-driver pattern. Seeing this separation is seeing how a driver becomes reusable across protocols.
The skeleton/protocol separation is the base-driver pattern (the base-sequence pattern, Module 9.7, applied to drivers), and it's what makes drivers reusable across protocols. The base driver provides the skeleton: the reset-aware pull-convert-complete loop and the dispatch — with the conversion routines declared virtual (overridable hooks). This skeleton is protocol-agnostic and contains the hard-to-get-right logic — the reset handling (Module 12.4's abort-idle-resume), the loop structure, the completion — written once and correct once. A concrete driver (an AXI driver, an APB driver) extends the base and overrides only the conversion (drive_read, drive_write) for its protocol. So each protocol is a small derived driver supplying only its conversion, reusing the entire skeleton. The verification value is twofold. First, reuse: the complex skeleton (reset, loop) is not re-written per protocol — it's inherited, so a new protocol is a small derived class, and the reset handling (which is easy to get wrong, Module 12.4) is correct by inheritance. Second, correctness: the skeleton's tricky logic is centralized, so it's reviewed and fixed once, not duplicated (and mis-duplicated) across drivers. This is exactly the base-sequence argument (Module 9.7) — put the boilerplate/scaffolding in a base, the specifics in derived — and it applies to drivers because the skeleton (reset-aware loop) is the same for every driver while the conversion differs. The separation is the architecture's reuse payoff: the frame (skeleton) is reused, the fill (protocol) is supplied per driver — so the driver architecture, like the sequence architecture, scales across protocols rather than re-implementing the hard parts each time.
Runtime / Execution Flow — a transaction through the layers
At run time, a transaction flows down through the layers — skeleton to dispatch to conversion to primitives — and back. Tracing it shows how the layers cooperate without coupling.
The runtime flow shows the layers cooperating through calls — each layer calling only the one below, so they don't couple. Skeleton (step 1): the reset-aware loop pulls the transaction (get_next_item) and calls drive_transaction — it hands the transaction down to the dispatch without knowing the protocol (it just calls the next layer). Dispatch (step 2): drive_transaction branches by operation — read to drive_read, write to drive_write — its only job, routing to the conversion without doing the conversion. Conversion (step 3): the per-operation routine drives the protocol's signaling (Module 12.2), calling primitives for the low-level steps — it owns the protocol logic without knowing about reset (that's the skeleton's job, two layers up). Primitives (step 4): drive_request, wait_for_ready, sample_response do the signal-level work (Modules 12.3, 12.4), and control returns up — the conversion finishes, the skeleton completes (item_done) and loops. The crucial property is the one-directional calling: the skeleton calls the dispatch calls the conversion calls the primitives — never the reverse, and never skipping layers. This layering is what decouples the concerns: the skeleton knows nothing about read vs write (it just calls drive_transaction); the conversion knows nothing about reset (it just drives, and the skeleton aborts it if reset hits — Module 12.4's disable fork); the primitives know nothing about operations (they're called by conversion routines). So a transaction flows down through cooperating layers, each focused on its concern, calling the next — and this cooperation-without-coupling is exactly what makes the driver maintainable: a change to a layer affects only that layer and what calls it, not the whole driver. The flow is the architecture in operation: layers calling down, control returning up, concerns staying separate.
Waveform Perspective — the layers active over a transaction
The architecture's layers map to the execution of a transaction over time: the skeleton active throughout, the conversion active during driving, the primitives active for the low-level steps — a layered execution.
The driver's layers active over one transaction (skeleton → conversion → primitives)
12 cyclesThe waveform shows the architecture's layers nesting in time, which is the execution counterpart of the layered structure. skeleton_active is high across the whole transaction — the run-loop skeleton is always running (it's the outer loop, Module 12.4's reset-aware loop). When the skeleton dispatches and calls the conversion, conversion_active goes high — the per-operation conversion routine is now driving the protocol (Module 12.2). And within the conversion, prim_active pulses — the primitives (drive_request, wait_for_ready, sample) run for the low-level steps (Modules 12.3, 12.4). The actual pins (valid) follow the driving. So the layers nest in time: the skeleton contains the conversion, which contains the primitives — a temporal reflection of the structural layering (skeleton over conversion over primitives). The crucial reading is the nesting: each layer's activity is inside the layer that called it, and each could change without disturbing the others — because they're separate methods, nested by calls, not tangled in one task. If this were a monolith, there'd be no such structure — one run_phase's activity, with no separable layers, so a change anywhere is a change to the whole. The picture to carry is that the clean layered structure produces a clean nested execution: the skeleton's loop wraps the conversion's driving, which wraps the primitives' low-level work — separable in code and separable in time — which is the architecture's whole value: concerns that are distinct in structure are distinct in execution, so each is understandable, changeable, and debuggable in isolation.
DebugLab — the monolithic driver where one change broke another
A monolithic driver where adding an operation broke the reset handling
A driver was one giant run_phase task — reset handling, operation dispatch, read and write conversion, wait states, and latency all tangled together in a single block of code. When an engineer needed to add a new operation (a burst variant), they edited the giant task — and it broke the reset handling: after the change, a mid-traffic reset (which had worked before) no longer aborted the in-flight transaction correctly. The new operation worked, but an unrelated concern — reset — was broken by the change. The two had nothing to do with each other logically, yet editing one broke the other.
The driver was a monolith — all concerns coupled in one task — so a change to one concern (adding an operation) disturbed an unrelated one (reset handling) that was interleaved with it:
✗ MONOLITHIC driver (everything in one task):
task run_phase(...);
forever begin
... reset handling ... ┐
get_next_item(req); │ reset, dispatch, conversion, and timing
if (req.is_read) ... drive ... │ ALL TANGLED in one block — interleaved logic,
else ... drive ... │ shared local variables, intertwined control flow
... wait states, latency ... │
... idle ... ┘
item_done();
end
// adding a new operation here edits the SAME block as the reset handling
// → the change disturbs the interleaved reset logic → reset abort breaks
✓ LAYERED driver (concerns separated into methods):
task run_phase(...); ... reset-aware skeleton ONLY ... drive_transaction(req); ...
task drive_transaction(...); if (is_read) drive_read; else if (is_burst) drive_burst; ... // dispatch
task drive_read(...); ... task drive_burst(...); ... // per-op conversion (add here, isolated)
// adding drive_burst touches ONLY the dispatch + a new method — NOT the reset skeleton
fix — decompose the monolith into layers: skeleton (reset/loop), dispatch, conversion, primitives.
now adding an operation is a new conversion method + a dispatch entry — the reset skeleton is untouched.This is the monolithic-driver anti-pattern, the architectural failure that makes drivers fragile. The driver had no layering: reset handling, dispatch, conversion, and timing were all in one run_phase task, interleaved — shared local variables, intertwined control flow, no method boundaries. So when the engineer added an operation, they had to edit that one task, and the edit disturbed the interleaved reset logic (a misplaced statement, a shared variable, a control-flow change) — breaking the reset abort, an entirely unrelated concern. The root cause is the coupling: in a monolith, every concern shares the same code block, so a change to one risks any other — there are no boundaries to contain the change. The tell is exactly this: editing one concern (the operation) broke an unrelated one (reset), which only happens when they're coupled. The fix is architectural — decompose the monolith into layers: a skeleton method (reset-aware loop, only), a dispatch method (branch, only), per-operation conversion methods, and primitives. Now adding an operation is a new conversion method + a dispatch entry — touching only those, with the reset skeleton untouched (it's a separate method). The separation contains the change: a change to a layer affects only that layer and its callers, not unrelated concerns. The general lesson, and the chapter's thesis: a driver must be architected into layers — skeleton, dispatch, conversion, primitives — with concerns separated into methods, because a monolithic run_phase that tangles all concerns into one task couples them, so a change to one breaks another; the layering decouples the concerns, so changes are localized, the skeleton is reusable, and bugs are navigable — exactly the maintainability the architecture exists to provide.
The tell is a change to one concern breaking an unrelated one. Diagnose monolith problems:
- Check whether the driver is one giant task. A
run_phasewith reset, dispatch, conversion, and timing all inline is a monolith — the structural root of coupled changes. - Correlate the break with coupling. If editing the conversion broke the reset (or vice versa) — concerns that are logically unrelated — they're coupled in one block, the monolith signature.
- Look for missing method boundaries. A driver with no
drive_transaction/drive_read/wait_for_readydecomposition has no layers; everything is one scope. - Assess change locality. If a small change requires understanding the whole
run_phase, the concerns aren't separated.
Architect the driver into layers:
- Decompose into methods by concern. A skeleton (reset/loop), a dispatch (branch), per-operation conversion routines, and low-level primitives — each a separate, focused method.
- Keep the skeleton protocol-agnostic and reusable. The reset-aware loop should know nothing about operations; put it in a base driver with virtual conversion hooks (the base-driver pattern).
- Make conversion routines small and per-operation. One method per operation, using shared primitives — so adding an operation is a new method, not an edit to existing ones.
- Factor shared low-level steps into primitives.
drive_idle,wait_for_ready,sample— written once, reused, so each is correct in one place.
The one-sentence lesson: a monolithic run_phase that tangles reset, dispatch, conversion, and timing into one task couples unrelated concerns, so a change to one (adding an operation) breaks another (reset handling) — decompose the driver into layered methods (skeleton, dispatch, conversion, primitives), with the protocol-agnostic skeleton separated from the protocol-specific conversion, so changes are localized and the skeleton is reusable.
Common Mistakes
- A monolithic
run_phase. Tangling reset, dispatch, conversion, and timing in one task couples concerns, so a change to one breaks another; decompose into layered methods. - Mixing reset handling into the conversion. The skeleton (reset/loop) should be separate from the protocol conversion; tangling them couples unrelated concerns.
- Not separating skeleton from protocol. Re-writing the reset-aware loop per driver duplicates the hard-to-get-right logic; put the skeleton in a base driver with virtual conversion hooks.
- One giant conversion task for all operations. Per-operation conversion routines keep each small and isolated; one task for all couples them.
- Duplicating low-level steps. Inline
wait_for_ready/drive_idlein each operation duplicates and risks divergence; factor them into shared primitives. - Non-virtual conversion methods. Making conversion non-overridable blocks the base-driver reuse; declare protocol hooks virtual.
Senior Design Review Notes
Interview Insights
A driver should be organized into clean layers rather than crammed into one giant run_phase task. The layers, top to bottom, are: a run-loop skeleton that handles reset and the pull-convert-complete cycle; a dispatch that branches by operation type; per-operation conversion routines that do the field-to-signal driving; and low-level primitives like drive_idle, wait_for_ready, and sample that are shared across operations. The skeleton is the reset-aware loop and is protocol-agnostic — the same for every driver — so it's reusable. It pulls a transaction and calls the dispatch, which routes to the right conversion routine, which drives the protocol using the primitives. Each layer is a separate method, and each calls only the one below, so they cooperate without coupling: the skeleton doesn't know the protocol, the conversion doesn't know about reset, the primitives don't know about operations. This separation is the key, because it makes changes localized — adding an operation is a new conversion method plus a dispatch entry, touching nothing else — and it makes bugs navigable, because a symptom points to a layer: a timing bug is in the primitives, a conversion bug is in a conversion routine, a reset bug is in the skeleton. A further architectural move is to separate the skeleton from the protocol entirely, using a base driver that provides the reset-aware loop with the conversion routines as virtual hooks, so concrete drivers extend it and override only the conversion for their protocol — the base-driver pattern, analogous to the base-sequence pattern. The anti-pattern is the monolith, one giant task where reset, dispatch, conversion, and timing are all tangled, so a change to one concern breaks another. So good driver architecture is layered, decomposed into focused methods, with the reusable skeleton separated from the protocol-specific conversion.
Because it couples unrelated concerns, so a change to one breaks another, and bugs are hard to localize. A monolithic driver is one giant run_phase task where reset handling, operation dispatch, field-to-signal conversion, and timing are all interleaved in a single block — shared local variables, intertwined control flow, no method boundaries. The core problem is coupling: every concern shares the same code, so there are no boundaries to contain a change. When you edit one concern — say, add a new operation — you're editing the same block that contains the reset handling, and the edit can disturb the interleaved reset logic and break the reset abort, even though operations and reset are logically unrelated. That's the telltale symptom of a monolith: a change to one thing breaks an unrelated thing. It also makes the driver hard to understand and debug, because a small change requires understanding the whole task, and a bug could be anywhere in the wall of code, with no layer to point you at it. And it makes reuse impossible: you can't reuse the driver for a different protocol because the protocol logic isn't separable from the rest, and you can't reuse the reset-aware loop because it's tangled with the conversion. The fix is architectural — decompose the monolith into layered methods: a skeleton for the reset and loop, a dispatch for branching, per-operation conversion routines, and primitives. Then each concern is isolated in its own method, so a change to one is contained to that method and its callers, the reset skeleton stays untouched when you add an operation, bugs map to layers, and the skeleton can be reused via a base driver. So the monolith's problem is the absence of separation, and the cure is the layered architecture that provides it.
Exercises
- Name the layers. List the driver's four layers and what each is responsible for, from the run loop down to the primitives.
- Decompose the monolith. Given a giant
run_phasewith reset, dispatch, and conversion inline, sketch how you'd split it into layered methods. - Apply the base-driver pattern. Explain how a base driver and a derived AXI driver split the skeleton and the protocol, and what each contains.
- Trace the flow. Describe a read transaction's path through the layers, naming the method at each layer and the primitives called.
Summary
- Driver architecture is the layered organization of the driver's concerns: a run-loop skeleton (reset-aware pull-convert-complete) → a dispatch (branch by operation) → per-operation conversion routines (field-to-signal driving) → low-level primitives (
drive_idle,wait_for_ready,sample). - Each layer is a separate method calling only the one below, so the concerns cooperate without coupling — the skeleton doesn't know the protocol, the conversion doesn't know reset, the primitives don't know operations — and a change to a layer is localized.
- Separate the skeleton from the protocol: the reset-aware loop is protocol-agnostic and reusable — put it in a base driver with virtual conversion hooks, so each protocol is a small derived driver overriding only the conversion (the base-driver pattern, Module 9.7 applied to drivers).
- The monolith is the anti-pattern: one giant
run_phasetangling reset, dispatch, conversion, and timing couples concerns, so a change to one (adding an operation) breaks another (reset handling) — decompose to decouple. - The durable rule of thumb: architect the driver into layered methods — a protocol-agnostic skeleton (reset/loop, ideally a reusable base with virtual hooks), a dispatch, per-operation conversion routines, and shared primitives — because separating the concerns localizes changes, makes the skeleton reusable across protocols, and makes bugs navigable, whereas a monolithic
run_phasecouples everything so a change anywhere breaks something elsewhere.
Next — Driver Debugging: the driver's responsibilities, conversion, signal driving, timing, and architecture can each go wrong; the final chapter of the module collects the techniques for debugging drivers — diagnosing wrong values, races, timing bugs, and reset issues, and the tools that make the driver's behavior visible.