Calling C Variadic Functions From Rust: What Rust 1.99 Stabilized

Calling C Variadic Functions From Rust: What Rust 1.99 Stabilized

pr0h0•
rustffivariadic-functionssystems-programmingabi
AI Usage (88%)

Introduction: Calling vs. Defining C Variadics in Rust

Rust 1.99 shipped, and the one item that caught my eye in the Linuxiac coverage was C variadic function support. The post ran on 2026-10-01 with the headline "Rust 1.99 Released With C Variadic Functions, New Stable APIs." The shorthand "Rust adds C variadics" is a little off in a way that matters if you are planning an upgrade. Calling printf-shaped C functions from Rust has been stable for years. What changed is the other direction: writing one in Rust.

This post isolates the half of the problem that actually moved, walks through the ABI contract behind variadic FFI, and gives you a minimal Rust-to-C example you can reproduce. I also want to be blunt about where I would not use the feature at all, and clear about what I verified versus what you should check on your own toolchain.

I went down this path because variadic FFI looks like a footnote in a changelog and turns into real work when you are gluing Rust into a C ABI.

One honesty note before we start: I did not have a 1.99 toolchain available while writing this. The mechanics below come from the C ABI and the documented c_variadic surface. Every output block is labeled expected, not captured, and the final section lists exactly what I did and did not verify.

What Rust 1.99 Actually Stabilized

Declaring and calling extern "C" variadic functions was already stable

This was never the problem. printf-style bindings have compiled on stable for years:

extern "C" {
    fn printf(fmt: *const c_char, ...) -> c_int;
}

That ... inside an extern block is just a declaration of a variadic signature, and stable Rust has accepted it for years. Consumer-side FFI never needed nightly.

What was nightly-only was defining a variadic function in Rust

The other direction required #![feature(c_variadic)]: a trailing ... in a Rust fn signature, the core::ffi::VaList / VaListImpl types, and the va_arg! machinery behind them. That is the gate this release removes. It is the entire substance behind the "C variadic functions" line.

Why this flips the audience

Consumers massively outnumber producers, and consumers never needed nightly. The people who benefit from 1.99 are the small set of crate authors exposing C-shaped APIs: libc-adjacent crates, callback glue, loggers, allocator shims, and anyone maintaining a Rust replacement for a C library whose public header already had a ....

How C Variadics Work at the ABI Level

Default argument promotions

The callee never sees the types the caller wrote. C applies default argument promotions before the call: float becomes double, and anything narrower than int (char, short, _Bool) becomes int. A caller writing f(1, (short)7) is passing an int. A Rust callee that reads i16 is reading two bytes of an int the platform placed on the stack, plus whatever follows it. This is why variadic arguments are only "typed" by convention.

va_start / va_arg / va_end and the platform-specific va_list

On System V x86-64, variadic arguments land in register save areas and overflow areas, and va_list is an array type, so passing it to another function decays to a pointer. On AArch64 Darwin, va_list is a struct holding a pointer and offsets. On 32-bit x86 it is something else entirely. va_start seeds the cursor, va_arg walks it with a caller-supplied width and alignment, and va_end closes out whatever bookkeeping that ABI requires.

Why a portable stable abstraction took years

A stable API cannot leak a target-specific va_list layout, and it cannot let safe code hold a cursor past the lifetime of the frame that owns it. The stabilized shape therefore has to be a wrapper whose invariants the type system enforces, not a transparent alias for whatever the target does. Add the usual stabilization process — feature gate, design review, the rule that stably wrong is worse than late — and years of latency is the expected outcome, not a surprise.

Defining a C-Variadic Function in Rust

Minimal C-variadic function defined in Rust

Here is the shape the documented c_variadic surface expects, with a C caller in the same directory. Check the exact spelling against your toolchain's docs. This is the syntax as implemented, and stabilization sometimes renames things.

vsum.rs
// Remove the feature gate on 1.99+.
#![feature(c_variadic)]

use std::os::raw::c_int;

#[no_mangle]
pub unsafe extern "C" fn sum_i32(n: c_int, mut ap: ...) -> c_int {
  let mut total: c_int = 0;
  for _ in 0..n {
      // Desugars to va_arg!(ap, c_int)
      total += ap.arg::<c_int>();
  }
  total
}
main.c
#include <stdio.h>
#include <stdint.h>

extern int32_t sum_i32(int n, ...);

int main(void) {
  printf("sum(3, 1, 2, 3) = %d
", sum_i32(3, 1, 2, 3));
  printf("sum(1, 'a')     = %d
", sum_i32(1, 'a'));
  return 0;
}

Reading variadic arguments inside the Rust body

ap.arg::<T>() is a typed cursor advance: it reads size_of::<T>() bytes at the current slot and moves the cursor. That is all it does. The same pattern works for echoing arguments back so behavior is observable — build an owned String, convert it to CString, and pass the pointer to printf. The lifetime point matters: a variadic function must not return a pointer into the va_list or into a local temporary. The C caller holds nothing that keeps your Rust stack frame alive, so return owned buffers, write into a caller-supplied out-parameter, or hand back something the caller must free with a matching function.

Disciplined teardown so va_end always runs

va_end has to run on every path. In the current implementation, cleanup is tied to the VaList value's destructor, which is why the mut ap: ... binding sits at the top of the body. If your version gives you an owned list, an early return before the binding's scope ends is still fine. A hand-rolled mem::forget or a ManuallyDrop wrapper around it is not. That is the kind of bug that only shows up under a specific ABI.

⚠️

The safest structure is to accept the VaList in the outermost function, then immediately pass the borrow into a small inner helper that has exactly one exit point. You lose nothing in readability and you stop depending on destructor ordering for correctness.

Reproducing the Rust and C build

Expected transcript. The commands are real; the output is not captured:

rustc --edition 2021 --crate-type=cdylib -O -o libvsum.so vsum.rs
cc -O2 -o main main.c -L. -lvsum -Wl,-rpath,'$ORIGIN'
./main
## expected:
## sum(3, 1, 2, 3) = 6
## sum(1, 'a')     = 97

nm -D --defined-only libvsum.so | grep sum_i32
## expected:
## 0000000000001130 T sum_i32

The T in the nm output is the point: the symbol is exported and unmangled, so a C linker can call it. If you see a mangled name, #[no_mangle] is missing. The C caller fails to link rather than failing at runtime, which is the good failure mode.

Expected output and argument promotion table

These are expected values, not captures. I marked the rows where the mechanism does something a reader might not predict.

C call sitePromoted type at the ABIRust readsExpected result
sum_i32(3, 1, 2, 3)three intc_int ×36, matches
sum_i32(1, 'a')int (not char)c_int97, matches — the promotion hides the narrowing
sum_i32(1, (short)7)intc_int7, matches; reading i16 here would be wrong
sum_i32(1, 1.5)doublec_intUndefined. Never tested, never will be

The third row trips people up: the caller's short is gone by the time Rust sees it. A Rust signature that reads i16 is misaligned with reality even though the call site looks narrow.

The Unsafe Contract You Inherit

Type mismatch is undefined behavior, not a compiler error

Nothing checks that caller and callee agree, because the C ABI never encoded the types. If a C caller passes a double and Rust reads c_int, you get whatever the register or stack slot contains, interpreted as an integer. That is UB, not a wrong number you can assert on in a test.

Target and calling-convention caveats

Treat "works on x86-64 Linux" as a starting point, not a result. va_list representation differs across Win64, 32-bit x86, AArch64, and embedded targets, and extern "C" is not the same convention as extern "stdcall" or the fastcall family. If you ship on more than one ABI, each one needs its own test binary.

Header discipline for variadic Rust exports

Any variadic Rust export needs a matching C prototype in a header the C side actually includes. Without it, old-style C callers can pass arguments that were never promoted the way your Rust code expects, and the failure is silent. In review, put the prototype next to the #[no_mangle] definition every time.

Calling Variadic C Functions From Rust

The extern block shape and c_char

Unchanged in 1.99, but worth restating because release coverage blurs the two directions: c_char is i8 on x86-64 Linux and u8 on aarch64 Linux, and CString/CStr handle the null terminator. Do not build *const c_char by slicing a String and hoping.

Where a caller-supplied format string is a footgun

Passing untrusted input as a format string is arbitrary read and write, not a parsing bug. %n writes through a pointer, %s reads until it hits a null byte, and the arguments you did not supply become whatever happens to be in the next slot. There is no version of this that is "mostly safe."

// Bug: user input is the format string.
unsafe { printf(user_input.as_ptr()) };

// Safe: format string is a literal in your own code.
unsafe { printf(b"[user] %s\n\0".as_ptr() as *const c_char, user_input.as_ptr()) };

I keep the rule blunt: a format string is a literal, always. If a caller needs parameterized output, format it on the Rust side and pass the result as %s.

Impact on Crates and Build Pipelines

Removing the feature gate and RUSTC_BOOTSTRAP

If your crate had #![feature(c_variadic)], drop it. Also drop any RUSTC_BOOTSTRAP=1 hack that existed only to let stable CI build a nightly-gated feature. Then audit CI: plenty of pipelines pin nightly for one gate and keep it forever. Remove the reason, then remove the pin.

New stable APIs in the same release

The seed headline says "New Stable APIs," and I could not verify which ones from the material I had. The source was a Google News redirect to a Linuxiac summary, not the release notes themselves. I will not fill that gap with plausible-sounding API names. Check the release announcement and RELEASES.md for your toolchain.

MSRV cost of adopting the stabilized signature

Adopting the new signature raises the minimum toolchain for every downstream crate, because the definition itself requires 1.99. For a library, that is a semver-relevant decision even though no public Rust API changed. If your C-facing export already exists behind a nightly gate, this is a straight win. If you are considering adding one, you are now spending your users' toolchain budget on a feature they may not need.

My Position: Narrow but Real Value

Who should adopt it now

Crates that already expose a C ABI with ... in the header: adopt immediately. Callback glue where the C side passes variadic log or trace arguments: yes. Anyone shipping RUSTC_BOOTSTRAP or a pinned nightly for this: yes, this is the day you stop. Everyone else: the feature is irrelevant to you, and that is fine.

A concrete recommendation for new C boundaries

Adopt it for existing C-facing surfaces. Do not introduce new variadic surfaces just because the feature stabilized. A variadic API is type-unsafe by construction: every call site is a place where the compiler cannot help you, and the failure mode is UB rather than a build error. If you are designing a new C boundary today, take an explicit count, take a struct of typed fields, or take a fixed-arity function. You give up nothing that matters and keep the compiler's checks.

What I Confirmed vs. What I Did Not Test

What I confirmed about the ABI and the stabilization

The mechanism: default argument promotions, the va_start/va_arg/va_end model, va_list representation differences across ABIs, the required feature gate, and the reproduction commands. The consumer-side extern block syntax is stable and always was. The Rust 1.99 release and its date come from the Linuxiac summary in the source material.

What I did not test

I did not compile or run the snippets above. No 1.99 toolchain was available. Every output block is labeled expected. Treat the symbol-export check and the promotion table as predictions to verify, not evidence. I did not test any ABI other than the one I reasoned about, did not measure downstream crate behavior, did not confirm which additional APIs stabilized in the same release, and did not verify the stabilization against the release notes directly. Check the exact VaList spelling and arg syntax against the docs for the toolchain you actually build with.

Further Reading

Rust 1.99 announcement, Rust Reference, API docs, and source coverage

  • Rust blog — release announcements live here; use the 1.99 post to confirm the full stable API list.
  • Rust RELEASES.md — the canonical per-release changelog, including language and library stabilizations.
  • The Rust Reference — external blocks, calling conventions, and the variadic function rules.
  • core::ffi module docs — the VaList/VaListImpl types, c_char, and the va_arg mechanism.
  • The Unstable Book: c_variadic — the feature-gate documentation for the surface that just stabilized.
  • Linuxiac — the source coverage referenced in the seed for this post.

Conclusion

Rust 1.99 removing the nightly gate on defining C variadic functions is a real win for a small, specific class of FFI code: crates that already had a ... in their public C header and were paying for it with a pinned toolchain. For everyone else, it is a changelog line. The half that did not change — calling variadic C functions — was always available. The ABI contract behind both directions is still the same unsafe one: types are a convention, mismatches are UB, and no compiler on either side is going to catch you.

Share this post

More posts

Comments