ABI series | Part 4 of 9 | Jun 3, 2026
Structure returns, C++ member functions, JNI native methods, runtime context, and the invisible fields behind source-level calls.
In effect, this address becomes a "hidden" first argument.

The most important argument in a call is often the one you did not write.
You wrote:
struct Big make_big(long long seed);
The ABI may hear:
make_big(out, seed)
You wrote:
counter.add(5);
The ABI may hear:
Counter_add(this, 5)
You wrote a Java native method:
native double f(int i, String s);
The native side receives runtime context before the ordinary Java parameters: a JNIEnv * first, and then either the object or the class depending on whether the method is nonstatic or static.1
These hidden fields are not hacks. They are how source-level abstractions become callable protocols.
A function signature is a public API. A hidden argument is a private wire-format field.
Post 1 used the word “lie” carefully. The source signature is not false. It is faithful at its layer.
The source signature tells a programmer what expression is legal:
struct Big b = make_big(100);
It does not promise that the binary call has exactly one incoming field and one outgoing register. The compiler and ABI still need an operational plan. Where does the result storage live? How does the callee find the object? Which runtime owns the current thread? Is the function entry point expecting a base-subobject pointer or a most-derived-object pointer?
Those questions often become hidden arguments.
A useful debugging move is to rewrite the call as a wire signature:
source: make_big(seed)
wire: make_big(result_storage, seed)
source: counter.add(delta)
wire: Counter_add(object, delta)
source: Java_p_q_A_f(i, s)
wire: Java_p_q_A_f(env, object_or_class, i, s)
The rewrite is conceptual. Do not paste it into a header. The exact registers, stack slots, pointer adjustments, and result channels are target-specific. The point is to stop assuming that the visible source parameter list is the ABI parameter list.
Result storage: the return value arrives first
Large structure returns are the cleanest hidden-argument example because the source says “return value” while the ABI may say “incoming pointer.”
Part 1 used a simple one-argument shape first:
struct Big make_big(long long seed);
The hosted lab uses a two-argument fixture so the visible-argument shift is easier to inspect:
struct SretRecord make_sret_record(unsigned long long seed,
unsigned long long tag);
Open the hidden result-storage trace. The receipts make the hidden result channel visible:
; Windows x64
make_sret_record:
movq %rcx, %rax
movq %rdx, (%rcx)
movq %r8, 8(%rcx)
; System V AMD64
make_sret_record:
mov rax, rdi
mov qword ptr [rdi], rsi
mov qword ptr [rdi + 8], rdx
; AArch64 Linux
make_sret_record:
stp x0, x1, [x8]
For this fixture, Windows x64 uses caller-allocated return storage passed as the first argument, shifts the visible arguments right, and returns the same pointer in RAX.2 System V AMD64 MEMORY-class returns use caller-provided storage passed in RDI as if it were the first argument, and return that address in RAX.3 AAPCS64 gives r8 / x8 the role of indirect result location register, so the visible seed and tag can remain in x0 and x1 while the result storage lives in x8.4
That last detail matters. Hidden arguments are not all inserted the same way.
On Windows x64 and System V AMD64, the hidden result pointer consumes the first integer/pointer argument position in this example. On AAPCS64, the indirect result location uses a dedicated role for x8.
Same source signature. Different hidden-field placement.
The receipt shows one selected fixture, not a universal size threshold or a reproduced bad call. The rule to remember:
A return value can become an incoming argument.
That is the sort of sentence that sounds wrong until you have debugged a call stub that forgot it.
Receiver: the object outside the parentheses
C++ member syntax makes the receiver look special:
counter.add(5);
The object is visually outside the argument list. The callee still needs it.
The example stays intentionally boring:
struct Counter {
int value;
int add(int delta);
};
int Counter::add(int delta) {
value += delta;
return value;
}
The source call has one visible value, delta. At the ABI level, the callee still needs two inputs: an object pointer and the visible integer. A simple compiler listing can make that visible, but the mechanism is the important part.
; System V AMD64
_ZN7Counter3addEi:
mov eax, esi
add eax, dword ptr [rdi]
mov dword ptr [rdi], eax
; Windows x64
?add@Counter@@QEAAHH@Z:
movl %edx, %eax
addl (%rcx), %eax
movl %eax, (%rcx)
; AArch64 Linux
_ZN7Counter3addEi:
ldr w9, [x0]
mov x8, x0
add w0, w9, w1
str w0, [x8]
In this simple case:
| Target | Hidden object pointer | Visible delta |
|---|---|---|
| System V AMD64 | rdi | esi |
| Windows x64 | rcx | edx |
| AArch64 Linux | x0 | w1 |
A listing is evidence for one target and one example, not the general C++ object model. But the pattern is exactly what a call protocol needs: a non-static member function must know which object it is operating on.
Microsoft’s old x86 __thiscall convention makes the pattern explicit: the this pointer is passed in ECX, while ordinary arguments are pushed on the stack from right to left.5 Modern 64-bit ABIs use their own register conventions, but the hidden-receiver idea remains the same.
This is the first receiver lesson:
Method syntax hides an object pointer. ABI calls cannot.
Runtime context: the VM enters before your parameters
Some hidden arguments are not produced by the C or C++ calling convention alone. They are inserted by a runtime boundary.
JNI is a good example because the specification says the quiet part out loud. Native methods receive the JNI interface pointer as the first argument. The second argument is a reference to the object for nonstatic native methods, or a reference to the class for static native methods. The remaining arguments correspond to the Java method arguments.1
So this Java declaration:
package p.q.r;
class A {
native double f(int i, String s);
}
does not become a native entry that receives only i and s.
Conceptually, the native side receives something closer to:
JNIEXPORT jdouble JNICALL
Java_p_q_r_A_f(JNIEnv *env, jobject self, jint i, jstring s);
For a static native method, the second field changes:
JNIEXPORT jdouble JNICALL
Java_p_q_r_A_f(JNIEnv *env, jclass cls, jint i, jstring s);
The first two fields are not accidents. They are the runtime context that lets native code talk back to the VM and know which object or class the call is associated with.
This is the same pattern at a different layer.
Source-level call:
a.f(i, s)
Native wire format:
env, receiver-or-class, i, s
Post 8 will cover modern language and runtime boundaries more broadly. For now, JNI gives us one sharp rule:
Runtime calls often prepend runtime context before user arguments.
That rule should influence API design. A native binding that pretends user parameters are always the first ABI-visible fields will fail as soon as a runtime inserts state ahead of them.
A simple member call can be approximated as:
Counter_add(&counter, delta)
That approximation is useful. It is not the whole C++ story.
With inheritance and virtual dispatch, the pointer passed to the eventual implementation may be a pointer to a base subobject. The Itanium C++ ABI describes virtual-call entry points that expect this to point to the class where the function is defined, plus adjusting entry points that convert a base-subobject pointer into the pointer expected by the non-adjusting implementation before transferring control.6
This is where thunks enter the series.
A thunk is often explained as “a little function that adjusts this and jumps.” That is true enough, but the deeper point is more interesting: the hidden argument may require adaptation before it reaches the final implementation.
Conceptually:
source: p->f()
wire step 1: choose base-subobject view
wire step 2: pass adjusted pointer as hidden this
wire step 3: maybe enter thunk
wire step 4: final implementation receives expected object pointer
This post is not a C++ ABI tutorial. The point is not to memorize vtable layout. The point is to stop treating the source receiver as a single obvious address.
The visible call expression can hide object selection, pointer adjustment, and entry-point adaptation.
That is a lot of protocol for a pair of parentheses.
Hidden arguments are powerful inside a compiler/runtime ecosystem. They are dangerous at poorly specified boundaries.
That is why stable plugin and FFI APIs often retreat to boring C-shaped contracts:
struct plugin_api {
int (*open)(void *ctx, const char *path);
int (*read)(void *ctx, void *buf, unsigned long len);
void (*close)(void *ctx);
};
This is not aesthetically exciting. That is the feature.
The object pointer is explicit. The context is explicit. The functions have fixed signatures. The ABI boundary no longer depends on a C++ member-call convention, a vtable layout, an exception model, or a runtime’s implicit state injection.
The lesson is not “never use C++” or “never use JNI.” The lesson is to be honest about where the boundary is. Inside one compiler’s world, hidden fields are implementation machinery. Across independently built components, hidden fields become interoperability hazards unless both sides agree on them.
When you design a boundary, ask:
What result channel exists?
What receiver or context channel exists?
What runtime state is prepended?
Can a wrapper make these fields explicit?
Is this call fixed-arity, or does varargs create another hidden protocol?
That last question belongs to the next post.
What to remember
Hidden arguments are everywhere because source languages are richer than raw calls.
A return value needs storage. A method needs an object. A runtime needs thread-local interface state. A virtual call may need an adjusted subobject pointer. A binding layer may need a context pointer. A callback often needs user data.
The source signature is the API the programmer sees.
The hidden fields are the protocol the callee needs.
So when a call crosses a real boundary, do not ask only:
What are the parameters?
Ask:
What are the ABI-visible fields?
That shift catches the bugs that source review misses. It catches the missing structure-return pointer. It catches the forgotten receiver. It catches the runtime context that arrives before user data. It catches the thunk that adjusted a pointer you thought was stable.
A hidden argument is not magic.
It is a field in the packet.
-
Oracle, “Java Native Interface Specification: Design Overview,” says native methods receive the JNI interface pointer as the first argument; the second argument is the object for nonstatic native methods or the class for static native methods; remaining arguments correspond to Java method arguments: https://docs.oracle.com/en/java/javase/24/docs/specs/jni/design.html ↩ ↩2
-
Microsoft, “x64 calling convention,” documents user-defined return-by-pointer behavior: the caller allocates storage, passes its pointer as the first argument, shifts remaining arguments right, and the callee returns the same pointer in
RAX: https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170 ↩ -
The x86-64 System V psABI source says MEMORY-class return values use caller-provided storage passed in
RDIas if it were the first argument, and return that address inRAX: https://gitlab.com/x86-psABIs/x86-64-ABI/-/raw/e1ce098331da5dbd66e1ffc74162380bcc213236/x86-64-ABI/low-level-sys-info.tex ↩ -
Arm’s AAPCS64 lists
r8/x8as the indirect result location register andr0-r7/x0-x7as parameter/result registers: https://raw.githubusercontent.com/ARM-software/abi-aa/087483cc79262fb5caf7734b69fa59a6637c4692/aapcs64/aapcs64.rst ↩ -
Microsoft, “
__thiscall,” documents that on x86 C++ member functions pass thethispointer inECX, with ordinary arguments pushed right-to-left on the stack: https://learn.microsoft.com/en-us/cpp/cpp/thiscall?view=msvc-170 ↩ -
The Itanium C++ ABI describes non-adjusting virtual function entry points, adjusting entry points that convert base-subobject pointers before transferring control, and caller behavior that selects a subobject and passes it as the
thispointer: https://itanium-cxx-abi.github.io/cxx-abi/abi.html ↩
Newsletter
Get the next essay
Systems, statistics, and institutional lessons, one useful essay at a time.
Adjacent boundary notes
- writingThe Function Signature Is a LieHidden result storage shows why the source signature is not the whole call.
- writingThe ABI Is the BoundaryNative boundaries stay hard even when high-level languages make them look ergonomic.
- notesCommon x86 Calling ConventionsA practical guide to cdecl, stdcall, and fastcall on x86, with stack layouts and NASM examples.
- writingC++ Crash CourseA reflection on modern C++, technical books, and the work of teaching systems programming clearly.