C Part II · Module 23 · Calling SystemVerilog from C
Scope Control: Setting and Getting the Context
A callback has to land somewhere, and the measurement here is sharper than the usual warning. Called from inside a task in one instance, the lookup of a bare name did not land in the wrong scope — it failed outright, for a variable the SystemVerilog at that exact point can see perfectly well. The scope a call reports is the innermost one, the task rather than the module, and a scope handle names one scope without searching the ones enclosing it. Walking outward a single level found the right object in each instance, from identical C and an identical name.
Expert24 min readCPart IIVerificationDPI-C
Part II · Module 23 · Chapter 23.3 · Scope Control: Setting and Getting the Context
1. A Callback Has to Land Somewhere
Chapter 23.2 settled what kind of subroutine is being called. This chapter is about which one — because in a testbench with two instances of the same component, the name of an exported subroutine is not unique, and neither is the name of anything it touches.
From the LRM, that is what a DPI context is: a function declared context can find out which SystemVerilog scope it was called from, and can set the scope that its own calls back into SystemVerilog will use. The two operations are get where I am and make this the current one.
The labelling rule from Chapter 22.1 §2 applies: the export "DPI-C" declaration, context functions and the scope-setting calls are from the LRM; everything measured below is a real lookup in a running simulation through VPI, where asking "what scope was I called from" and "find this name from here" are directly available.
The setup for every measurement: a top module tb with a variable counter, and two instances u1 and u2 of a module that also has a counter, each with a task that calls into C passing the bare name "counter".
2. Full Hierarchical Names Work
The simplest thing first. Measured, looking up complete paths from the top:
[C] found "tb.flag", writing 42
[SV] after the call, tb.flag = 42
[C] vpi_handle_by_name("tb.nosuchthing", NULL) -> NOT FOUND
[SV] return -1
[C] found "tb.u1.counter", writing 100
[C] found "tb.u2.counter", writing 200
[SV] u1.counter = 100, u2.counter = 200Everything behaves. A real path resolves, a bogus one returns a null handle rather than misbehaving, and the two instances are reached distinctly.
Note the one genuinely good property in that block: a failed lookup is a null handle, not a crash and not a wrong object. That is Chapter 20.10's discipline arriving from the other side — the handle is checked, never assumed — and §5 is about making sure your code actually checks it.
3. The Bare Name, From Three Places
Now the realistic case: the C does not want to know which instance it is serving. It passes "counter" and expects the context to resolve it.
Measured:
(1) called from tb itself:
[C] called from scope "tb"
[C] found "tb.counter" relative to that scope, writing 11
[SV] tb.counter=11 u1.counter=0 u2.counter=0
(2) called from inside u1:
[C] called from scope "tb.u1.poke_me"
[C] vpi_handle_by_name("counter", <that scope>) -> NOT FOUND
(3) called from inside u2:
[C] called from scope "tb.u2.poke_me"
[C] vpi_handle_by_name("counter", <that scope>) -> NOT FOUND
-- and the same bare name looked up ABSOLUTELY (no scope) --
[C] vpi_handle_by_name("counter", NULL) -> NOT FOUNDTwo surprises, and the second is the chapter.
The scope reported is the task, not the module. tb.u1.poke_me — the innermost enclosing scope at the point of the call. Not tb.u1, which is what a reader of the SystemVerilog would name if asked "where is this call?"
And the lookup failed. Not landed somewhere wrong — failed, returning a null handle, for a variable that the SystemVerilog statement three characters away can read and write without qualification.
4. The Finding: Two Different Name Resolutions
A SystemVerilog statement inside poke_me resolves counter by looking in the task, then the module, then outward — the ordinary scoping rules of the language.
A lookup given a scope handle searches that scope. One. It does not walk outward.
So:
"Visible at the call site" and "findable from the scope that call reports" are different predicates, and nothing in either language tells you so. The gap shows up as a null handle, which is a value, which means your code decides what happens next.
Walking outward closes it. Measured, stepping to the enclosing scope until the name resolves:
(called from inside u1's task:)
[C] looking for "counter" in "tb.u1.poke_me"
[C] looking for "counter" in "tb.u1"
[C] found "tb.u1.counter" after 1 hop(s) outward, writing 22
[SV] u1.counter=22 u2.counter=0
(called from inside u2's task:)
[C] looking for "counter" in "tb.u2.poke_me"
[C] looking for "counter" in "tb.u2"
[C] found "tb.u2.counter" after 1 hop(s) outward, writing 33
[SV] u1.counter=22 u2.counter=33The same C code and the same bare name reached two different objects, each the right one. That is what context is for, and it took one hop and an explicit loop.
5. Writing C That Lands Where You Meant
Four rules, each from a measurement.
Check every handle. A failed lookup is a null handle, measured three times above. It is not an error, not an exception and not a crash — it is a value, and if your code dereferences it without asking, Chapter 20.10 §7's situation applies exactly.
vpiHandle target = lookup(name, scope);
if (!target) { report("scope: could not resolve \"%s\"", name); return -1; }Report the scope you were actually given, not the one you assumed. Every measurement above prints the scope's full name, and that is why the surprise in §3 was a two-line discovery rather than an afternoon. A callback that cannot find something should say where it looked.
Resolve once, not per call. A lookup is a search; the handle it returns is reusable. Resolve at set-up, store the handle alongside the per-instance state Chapter 22.7 established, and the per-call path becomes a use rather than a search — which also means a failure surfaces at set-up time instead of in the middle of a run.
And decide deliberately between a bare name and a full path. §6 is that decision.
6. The Misconception: Pass the Full Path and the Problem Goes Away
"All of this is avoidable. Pass
tb.u1.counterinstead ofcounterand there is no scope question at all."
Measured true, narrowly: every full-path lookup in §2 resolved and wrote correctly, including into both instances distinctly.
And it is the wrong design, for three reasons that only appear once the model is used twice.
The path hard-codes the instance. "tb.u1.counter" serves u1 and nothing else. The second instance needs a second string, from somewhere, chosen by someone — and §4's walking version served both from identical C with an identical name.
The path hard-codes the hierarchy. Rename the instance, move the component under another level, instantiate it inside a generate block, and every string is wrong. They are wrong silently, because a failed lookup is a null handle: the symptom is a callback that quietly stops doing anything, not a build error.
And it puts testbench structure inside the C. The C model now contains knowledge of where in the hierarchy it is used, which is exactly the coupling the reverse direction was supposed to avoid — Chapter 20.10 §5's argument about opacity, pointed at the testbench instead of at a data structure.
The accurate position:
Use the context to resolve a bare name relative to where you were called from, check the handle, and resolve once at set-up. A full path is reasonable for something genuinely singular — a top-level control block, a single global scoreboard — and even then the string belongs in one place, not scattered through the model.
7. What You Should Be Able to Say Now
- A callback has to land somewhere, and with two instances the name of everything it touches is ambiguous.
- From the LRM, a
contextfunction can learn the scope it was called from and set the scope its callbacks use. - Measured: full hierarchical paths all resolve —
tb.flag,tb.u1.counter,tb.u2.counter— and a bogus one returns a null handle, not a crash. - The scope a call reports is the innermost one — measured,
tb.u1.poke_me, the task, not the module. - The finding: a bare name failed to resolve from that scope, for a variable the SystemVerilog at the call site reads without qualification.
- C's name resolution is not SystemVerilog's — a scope handle names one scope and the lookup does not search outward.
- Walking outward closes the gap — measured, one hop, finding
tb.u1.counterandtb.u2.counterfrom identical C and an identical name. - Check every handle, report the scope you were given, and resolve once at set-up rather than per call.
- A full path works and hard-codes the instance and the hierarchy — and when it breaks, it breaks silently into a null handle.
- Two instances is the test; one instance makes every one of these designs look correct.
8. Exercises
- Look up a full hierarchical path from C and write through it. Then look up a path that does not exist. What exactly does the second one return?
- Call into C from inside a task in an instance and print the scope's full name. Is it the module or the task? Was that what you expected?
- Try to resolve a bare name from that scope. Does it resolve? Can the SystemVerilog statement next to the call read the same name?
- Implement the outward walk. How many hops does it take, and what does it find in each of two instances?
- §5 says to resolve once at set-up. What changes about when a failure is discovered?
- Rename an instance in your testbench and re-run a model that uses full paths. What is the symptom, and how long would it take to attribute?
- Build a test with two instances of a component that calls back into C. What would a single-instance test have failed to show?
- §6 says a full path puts testbench structure inside the C. Name another place in this track where the same coupling was argued against.
9. Summary
- Two instances make every name ambiguous — that is what context is for.
- Measured: full paths resolve (
tb.flag,tb.u1.counter,tb.u2.counter); a bogus path gives a null handle. - The scope reported is the innermost — measured
tb.u1.poke_me, the task. - The finding: the bare name failed there, for a variable visible to the SystemVerilog beside the call.
- C's lookup searches one scope; SystemVerilog searches outward.
- Walking outward, one hop, found the right
counterin each instance from identical C. - Check every handle · report where you looked · resolve once at set-up.
- A full path hard-codes the instance and the hierarchy and fails silently into a null handle.
- Two instances is the test — the fifth time in this track that one of something made a broken design look correct.
Scope answers where a callback lands. It says nothing about when, and the two questions have very different answers: by the time a callback has landed, control has crossed the boundary twice and the simulator has been waiting throughout. Following that path precisely is what makes the next chapter's constraints inevitable rather than arbitrary.
Standards & specifications
- Governing standard
- IEEE Std 1800 (SystemVerilog)(opens IEEE in a new tab)
Defines SystemVerilog language semantics — syntax, data types, scheduling and the behaviour a conforming simulator must produce. It does not define tool-specific synthesis support or vendor methodology.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
