RISC-V RV32 Variadic Calls: Account for Skipped Registers Before Decoding a Logging Argument

A hand-written logging stub works for several integers but prints the wrong floating-point value when a double enters the variadic tail. A second failure appears only after many named parameters. On RV32, the tail’s register allocation can skip an integer register and later abandon the register area entirely.

This article uses the RISC-V ELF psABI at commit 3095deae, specifically its integer, hardware floating-point and variadic calling conventions. The worked scope is little-endian RV32 with the standard ilp32d ABI, not RV32E or a private assembly convention. No cross-compiler, variadic function or target was executed; the register maps are derived expectations for a proposed test.

Separate hardware floating point from the variadic tail

The psABI hardware floating-point convention supplies fa0 through fa7 for eligible named floating-point arguments. Its rules explicitly apply only to named arguments. Variadic arguments instead follow the integer calling convention, even when the selected ABI uses hardware floating-point registers elsewhere.

For comparison, consider these declarations:

void named_sample(unsigned tag, double value);
void variable_sample(unsigned tag, ...);

Under the stated ABI, the named double can use a floating-point argument register while tag uses a0. In a call to variable_sample(7u, 1.25), the double belongs to the variadic tail and must be placed through the integer convention. Looking only in fa0 because the processor has a double-precision FPU reads the wrong interface.

GCC 15.2’s RISC-V options distinguish -march, which selects permitted instructions, from -mabi, which selects the calling convention. They also document that ABI defaults depend on the toolchain. Preserve the actual ABI selection rather than inferring argument placement from floating-point instructions seen elsewhere in the image.

Align a two-word variadic argument to an even register pair

The integer convention provides a0 through a7. For a variadic argument with alignment equal to twice XLEN and size no larger than twice XLEN, the psABI requires an aligned register pair: its first register number must be even. A suitable pair being numerically adjacent is not enough.

On the selected RV32 ABI, double occupies eight bytes with eight-byte alignment. In the example, the named tag consumes a0. The next available register is a1, but a pair starting there is not aligned. The tail’s double therefore occupies a2 and a3, leaving a1 unused for that argument.

For the little-endian scope here, the lower-addressed 32-bit portion of the double occupies the lower-numbered register. A following ordinary unsigned integer can use a4. The expected map is:

variable_sample(7u, 1.25, 9u)
a0: named tag
a1: skipped by the aligned-pair rule
a2/a3: variadic double
a4: following variadic unsigned integer

The skipped slot is not an extra argument, and its contents are not a reliable zero or padding value. A decoder that advances one register for every source expression will become misaligned at the double and misinterpret every following value.

RV32 variadic argument diagram shows a0 holding the named tag, a1 skipped, a2 and a3 holding a double, and a4 holding the next unsigned value.

Original ABI register map. The values describe a hypothetical call, not captured compiler output.

Once a variadic argument goes to the stack, later ones follow

Now consider a variadic function with seven named unsigned parameters. Under the stated scope they occupy a0 through a6, leaving only a7 available. An eight-byte-aligned double in the tail has no available aligned pair, so it is passed on the stack by value.

The psABI then requires all following variadic arguments to use the stack too. A later four-byte unsigned value does not return to the apparently unused a7. That register can remain unused even though the caller still has arguments to pass.

This differs from the ordinary named two-XLEN scalar rule, which can split a scalar between the last available integer register and the stack. Do not reuse that named-argument rule for the aligned variadic case. A generic call-marshalling routine needs to know where the variadic tail begins, not merely each argument’s size.

Keep the stack’s required alignment and the specific argument alignment separate. The standard procedure-entry stack boundary is 16 bytes; it does not mean each tail item occupies 16 bytes. Conversely, rounding every argument to four bytes loses the double’s required alignment.

Let the language interface maintain the save area

The psABI describes a variadic save area for unused integer argument registers and a va_list that progresses through suitably aligned values before reaching arguments supplied on the stack. That mechanism explains why skipped slots and stack continuation matter even when a callee uses ordinary C macros.

Use the compiler-provided va_start, va_arg, va_copy and va_end interfaces in C. Do not replace them with a guessed pointer to a1 or a fixed array of eight words. Fetch the type actually passed by the caller, and preserve its sequence. An assembly wrapper that changes the calling boundary must correctly construct or forward the complete interface rather than only copying its first few registers.

Build a small boundary-focused verification set

Compile separate callers and callees with the product’s real optimization, ISA and ABI flags. Compare a named double, a variadic double after one integer, and a variadic double after seven named integers. Add a following unsigned value in the latter two cases so a misplaced argument cannot accidentally hide behind a correct first result.

Inspect the final argument-loading instructions and callee save-area logic with a target-capable disassembler. Then run the cases in the supported execution environment and compare decoded types and values. Retain the declarations as well as the binaries: a different prototype changes the calling contract even when the symbol name is identical.

No such test was performed here. The acceptance criterion is agreement on the named/variadic boundary, aligned-pair allocation and irreversible transition of the tail to stack passing. “The floating-point ABI matches” is necessary context, but it is not enough to qualify a low-level logging or foreign-function bridge.

Similar Posts