Hidden Arguments Are Everywhere | Josh Lospinoso Skip to content ABI series | Part 4 of 9 | Jun 3, 2026<br>Hidden Arguments Are Everywhere<br>Structure returns, C++ member functions, JNI native methods, runtime context, and the invisible fields behind source-level calls.<br>ABI series | Part 4 of 9<br>Inside the ABI series<br>The function call you write is the public API. The ABI call is the private wire format.
View series
ABI labTrace hidden result storage. Open the focused ABI lab for this part.
Show all nine posts01The Function Signature Is a Lie<br>02Same Function, Three Realities<br>03The Bugs That Make ABI Rules Memorable<br>04Hidden Arguments Are Everywhere<br>05Varargs: The Function Call With Missing Type Information<br>06ARM64 Is Not Just x64 With More Registers<br>07It Works Until the Stack Walker Arrives<br>08The ABI Is the Boundary<br>09Calls Are Now a Security Surface<br>labHidden result-storage trace<br>The most important argument in a call is often the one you did not write.
You wrote:
struct Big make_big(long long seed);<br>The ABI may hear:
make_big(out, seed)<br>You wrote:
counter.add(5);<br>The ABI may hear:
Counter_add(this, 5)<br>You wrote a Java native method:
native double f(int i, String s);<br>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.
Hidden does not mean dishonest
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);<br>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)<br>wire: make_big(result_storage, seed)
source: counter.add(delta)<br>wire: Counter_add(object, delta)
source: Java_p_q_A_f(i, s)<br>wire: Java_p_q_A_f(env, object_or_class, i, s)<br>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);<br>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,<br>unsigned long long tag);<br>Open the hidden result-storage trace. The receipts make the hidden result channel visible:
; Windows x64<br>make_sret_record:<br>movq %rcx, %rax<br>movq %rdx, (%rcx)<br>movq %r8, 8(%rcx)
; System V AMD64<br>make_sret_record:<br>mov rax, rdi<br>mov qword ptr [rdi], rsi<br>mov qword ptr [rdi + 8], rdx
; AArch64 Linux<br>make_sret_record:<br>stp x0, x1, [x8]<br>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);<br>The object is visually outside the argument list. The callee still needs it.
The example stays intentionally boring:
struct Counter {<br>int value;<br>int add(int delta);<br>};
int Counter::add(int delta) {<br>value += delta;<br>return value;<br>The source call has one visible value, delta....