Rust 1.99 C Variadic Functions: A Safe FFI Boundary

Rust 1.99 can define C variadic functions on stable Rust. A function implemented in Rust may now expose an extern "C" or extern "C-unwind" entry point with a trailing ..., read its arguments through VaList, and satisfy a legacy C callback or exported API without a C shim.
The feature closes an important FFI gap. It does not make variable arguments type-safe. The callee still depends on a protocol that says how many values exist, what their promoted C types are, and what ends the list. Reading one value with the wrong type, or reading past the final argument, can produce undefined behavior.
This guide builds a count-based integer function, explains C's default argument promotions, wraps the unsafe decoder in a typed internal API, covers panic and unwind boundaries, and lays out an ABI test matrix for production use.
The short answer
Use Rust 1.99 C variadic definitions only at a boundary that must preserve an existing C calling convention. Keep the exported function small:
- Validate every fixed argument before reading
VaList. - Treat the function as
unsafeand document the caller's obligations. - Read only promoted C-compatible types.
- Copy data into ordinary Rust values as soon as possible.
- Call a safe, typed internal function for the real work.
- Convert every failure into a C-compatible return code or output parameter.
- Prevent Rust panics from crossing an
extern "C"boundary.
Rust's 1.99 release announcement stabilizes definitions for the "C" and "C-unwind" ABIs and stabilizes core::ffi::VaList. Code that only declares and calls a C variadic function was already possible. The new capability is implementing that entry point in Rust itself.
What Rust 1.99 stabilizes
Rust can own the function body
Before 1.99, a Rust program could declare printf or another variadic C function in an unsafe extern block and call it. Defining a compatible function still required nightly Rust or a small C wrapper that received va_list and forwarded typed data.
Rust 1.99 lets stable code write the body directly:
use core::ffi::{c_int, c_uint};
/// # Safety
/// The caller must pass exactly `count` additional arguments, and every
/// argument must be compatible with C `int` after default promotions.
unsafe extern "C" fn sum_i32(count: c_uint, mut args: ...) -> i64 {
let mut total = 0i64;
for _ in 0..count {
// SAFETY: guaranteed by the function's caller contract.
let value = unsafe { args.next_arg::<c_int>() };
total += i64::from(value);
}
total
}
The syntax exposes the central fact honestly. Rust cannot infer the values behind ...; each next_arg is unsafe because the caller supplies the proof.
VaList follows the platform ABI
The Rust Reference states that VaList is ABI-compatible with C's va_list. That compatibility matters because va_list is not represented the same way on every platform. It may behave like a pointer on one target and carry register-save state on another.
Do not replace it with a guessed pointer type or inspect its fields. Use VaList and its methods so Rust follows the target's calling convention.
The feature is stable on a defined target set
The Reference lists support for x86, x86-64, ARM, AArch64, Arm64EC, most RISC-V 32- and 64-bit targets, LoongArch, s390x, PowerPC, AMDGPU, NVPTX, WebAssembly, C-SKY, Xtensa, Hexagon, SPARC64, and MIPS. Some targets, including BPF, do not support C variadic definitions and produce a compiler error.
Treat that list as a build constraint. A crate compiling on one supported workstation does not prove a downstream embedded or kernel target accepts the definition.
Variadics are a protocol without a schema
A normal Rust function carries its parameter types in the signature. A C variadic function carries only the fixed prefix. The remaining values are interpreted by convention.
Common protocols include:
- a count followed by that many values;
- a format string that determines later types;
- a tag followed by a value, repeated until a sentinel;
- a fixed header whose flags select optional trailing values.
The ABI transports values. It does not transport a portable runtime schema for those values. The callee cannot ask how many arguments were passed or recover their original source types.
Choose the simplest protocol the existing API permits. Count-based homogeneous values are easier to validate than a format language. A tagged sequence is easier to audit than positional arguments whose meaning changes with flags.
C default argument promotions control what Rust reads
Small integers arrive as int or unsigned int
For a C variadic call, integer types narrower than int undergo integer promotion. A C caller may write:
signed char a = -4;
short b = 12;
long result = sum_i32(2, a, b);
The variadic values are passed using their promoted types, normally C int. Rust must read them as c_int, not i8 or i16.
Rust's VaArgSafe machinery limits next_arg to primitive types that have a valid variable-argument ABI on supported platforms. The standard FFI documentation explains that types affected by default promotion are not valid direct reads. This compiler check prevents one class of mistake. It does not verify that the caller actually passed the type you request.
float arrives as double
A C float passed through ... is promoted to double. Read it as c_double, which corresponds to Rust f64 for the C ABI:
use core::ffi::{c_double, c_uint};
/// # Safety
/// The caller must pass `count` values compatible with C `double`.
unsafe extern "C" fn sum_f64(count: c_uint, mut args: ...) -> c_double {
let mut total = 0.0;
for _ in 0..count {
total += unsafe { args.next_arg::<c_double>() };
}
total
}
Reading f32 would describe the source expression rather than the value's variadic ABI. The distinction is small in source code and severe at runtime.
Pointers retain a pointer-shaped ABI
Raw pointers can be read through VaList, but their validity remains a separate obligation. A non-null pointer may still be dangling, misaligned, point to the wrong object type, or lack a terminating nul byte.
Read the pointer, validate what can be validated, and delay dereferencing until the smallest possible unsafe block. A variadic boundary does not weaken ordinary pointer rules.
Build a narrow count-based boundary
Assume an existing C header requires this symbol:
#include <stdint.h>
// Returns 0 on success and a negative error code on failure.
// On success, writes the sum to `out`.
int rust_sum_i32(int64_t *out, uint32_t count, ...);
A production implementation should cap the count before reading anything and move the arithmetic into safe Rust:
use core::ffi::{c_int, c_uint};
const MAX_VALUES: usize = 1_024;
fn sum_checked(values: &[c_int]) -> Option<i64> {
values.iter().try_fold(0i64, |acc, &value| {
acc.checked_add(i64::from(value))
})
}
/// # Safety
///
/// - `out` must be valid and aligned for one writable `i64`.
/// - The caller must pass exactly `count` trailing arguments.
/// - Every trailing argument must be compatible with C `int`.
/// - `count` must describe the arguments supplied by the caller.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_sum_i32(
out: *mut i64,
count: c_uint,
mut args: ...,
) -> c_int {
if out.is_null() {
return -1;
}
let Ok(count) = usize::try_from(count) else {
return -2;
};
if count > MAX_VALUES {
return -2;
}
let mut values = Vec::with_capacity(count);
for _ in 0..count {
values.push(unsafe { args.next_arg::<c_int>() });
}
let Some(total) = sum_checked(&values) else {
return -3;
};
unsafe { out.write(total) };
0
}
The fixed arguments can be checked before the first variadic read. After reading begins, a false count cannot be repaired because asking for a missing value is already outside the function's safety contract.
The example allocates a vector to create a clean boundary between decoding and business logic. For a proven hot path, process values incrementally or use bounded stack storage, then benchmark. Safety review comes before removing one allocation.
Write the safety contract for the caller
An unsafe fn is incomplete without a precise # Safety section. Avoid language such as “arguments must be valid.” State the protocol in terms a C or Rust caller can follow:
- the exact number of trailing arguments;
- the compatible C type of each argument;
- whether signed and unsigned values may be mixed;
- what values a count, tag, or sentinel may contain;
- pointer validity, alignment, mutability, and lifetime requirements;
- whether pointers may be null;
- whether strings must be nul-terminated;
- whether arguments may alias output storage;
- whether callbacks may re-enter the function;
- whether unwinding is permitted.
The contract defines what fuzz harnesses, integration tests, code review, and downstream headers need to enforce.
Keep real work out of the unsafe function
The exported function should decode, validate, and delegate. This creates a small unit for ABI review and a normal Rust function for the rest of the system.
#[derive(Debug)]
enum SumError {
Overflow,
TooManyValues,
}
fn calculate_sum(values: &[i32]) -> Result<i64, SumError> {
if values.len() > MAX_VALUES {
return Err(SumError::TooManyValues);
}
values.iter().try_fold(0i64, |acc, &value| {
acc.checked_add(i64::from(value))
}).ok_or(SumError::Overflow)
}
This typed layer can use iterators, enums, domain validation, property tests, and ordinary error handling. Only the adapter needs to understand promotions and VaList.
The same principle applies when Rust calls Python through PyO3 or exposes native modules. Our Rust and PyO3 extension guide keeps conversion at the edge and moves computation into typed Rust. Variadic C functions deserve an even thinner edge because a protocol mismatch can be undefined behavior.
Forward a VaList to a C v function
Many C libraries pair a variadic function with a v form such as printf and vprintf. Rust's VaList can cross that boundary with the correct declaration:
use core::ffi::{c_char, c_int, VaList};
unsafe extern "C" {
unsafe fn vprintf(format: *const c_char, args: VaList<'_>) -> c_int;
}
/// # Safety
/// `format` and the trailing arguments must satisfy `vprintf`'s contract.
unsafe extern "C" fn rust_printf(
format: *const c_char,
args: ...,
) -> c_int {
unsafe { vprintf(format, args) }
}
This is useful for callbacks and gradual migration, but it preserves the original format-string hazards. If untrusted input controls the format, Rust has not made the interface safe.
Clone when two consumers need independent cursors
Reading advances a variable argument list. The VaList documentation states that cloning performs the equivalent of C va_copy, producing an independent cursor, and dropping performs the equivalent of va_end.
Do not copy VaList with raw memory operations or assume assignment has C's desired semantics. Use its supported clone behavior when the same logical sequence must be traversed independently. Prefer a single decode into typed Rust data when ownership and performance permit it.
Choose C or C-unwind deliberately
extern "C" describes a non-unwinding boundary. A Rust panic or foreign exception must not escape through it. Rust's Reference documents that reaching a non-unwinding ABI with an unwind causes the process to terminate safely.
That outcome avoids unwinding through an incompatible stack, but it is rarely a good error policy for a library. Remove panic sources from the adapter, use checked operations, avoid indexing that can fail, and map fallible work to return codes.
extern "C-unwind" permits unwinding in ABI terms. It does not make cross-language exceptions automatically safe or portable. Both sides must agree on the unwinding contract, target support, runtime behavior, cleanup, and which exceptions may cross. Use it for an established integration that requires unwinding, not as a substitute for error handling.
C variadic functions also cannot be async or const, according to the Reference. Decode the call synchronously, copy owned data, and hand work to an async subsystem only after the FFI boundary has returned or through a separately designed ownership protocol.
Export the symbol correctly
In Rust 2024, no_mangle is an unsafe attribute because exporting a chosen symbol can violate global linkage assumptions. The example uses:
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_sum_i32(/* ... */) -> c_int {
// ...
}
Build the crate as the artifact the C consumer expects:
[lib]
crate-type = ["cdylib", "staticlib"]
Choose only the formats you distribute. Generate or maintain a matching C header, verify integer widths, and inspect the exported symbol in CI with platform-appropriate tools such as nm, objdump, or dumpbin.
Do not expose Rust-owned layout types in the C header. Use core::ffi aliases, fixed-width integers where the C contract specifies them, raw pointers, and #[repr(C)] structs for shared records.
Test from the C side
A Rust unit test proves Rust can call its own symbol. It does not fully prove a C compiler passes arguments the way the header promises.
Add a small C integration fixture:
#include <assert.h>
#include <stdint.h>
int rust_sum_i32(int64_t *out, uint32_t count, ...);
int main(void) {
int64_t out = 0;
assert(rust_sum_i32(&out, 0) == 0);
assert(out == 0);
signed char a = 7;
short b = -2;
assert(rust_sum_i32(&out, 3, a, b, 11) == 0);
assert(out == 16);
assert(rust_sum_i32(NULL, 0) == -1);
return 0;
}
Compile the caller with each supported compiler family and optimization level. Link it to the release-mode Rust library as well as any debug artifact used during development. Run it on the target machine or a trustworthy emulator that implements the target ABI.
Test values that expose ABI errors
Use negative integers, values around signed boundaries, floating values that are not exactly representable as f32, null and non-null pointers, zero arguments, one argument, the maximum accepted count, and counts just beyond the maximum.
Malformed inputs that would require reading a nonexistent argument cannot be tested as ordinary expected failures because the call itself violates the contract. Use sanitizers and isolated negative-test binaries if the platform can report the fault, but do not claim the function can safely detect missing arguments.
Run sanitizers on the combined program
AddressSanitizer and UndefinedBehaviorSanitizer are most valuable when they instrument the C caller and native dependencies as well as the Rust library where supported. They may catch invalid pointer use, stack damage, and alignment mistakes. A clean sanitizer run increases confidence; it does not prove all variadic type sequences are correct.
Build an ABI test matrix
At minimum, record these dimensions:
| Dimension | Representative coverage |
|---|---|
| Architecture | x86-64 and AArch64 used in production |
| Operating system | Linux, macOS, and Windows where supported |
| C compiler | Clang, GCC, or MSVC used by consumers |
| Rust profile | Debug checks and optimized release build |
| Link mode | Static or dynamic format actually shipped |
| Panic strategy | unwind and abort if both are distributed |
| Argument protocol | Zero, boundary, maximum, promoted, pointer cases |
Expand it for 32-bit targets because integer widths and calling conventions may differ. c_long, usize, and pointer widths are not interchangeable across the matrix.
Toolchain patch versions also matter. Our analysis of the Rust 1.98.1 vtable miscompilation fix is a reminder that production native code should pin a compiler, track compiler advisories, and retest the release artifact after an upgrade.
Fuzz the typed decoder, not arbitrary ABI calls
Fuzzers excel at byte sequences and structured Rust values. Generating arbitrary machine-level variadic calls with random type sequences is target-specific and can enter undefined behavior before the function has a chance to reject anything.
Split the problem:
- Test a finite set of valid ABI call shapes from C.
- Decode those shapes into an owned Rust representation.
- Fuzz the safe parser, validator, and business logic with structured inputs.
- Add property tests comparing the Rust implementation with a known C reference where one exists.
For a format-driven interface, parse the format into a typed plan before consuming arguments when the protocol permits it. Limit width, precision, nesting, and output size before allocation.
Prefer typed APIs for new designs
Variadics exist because C needs flexible interfaces without Rust-style slices, enums, traits, and builders. A new Rust-facing API should use those stronger tools:
pub fn sum(values: &[i32]) -> Option<i64> {
values.iter().try_fold(0i64, |acc, &value| {
acc.checked_add(i64::from(value))
})
}
If C callers also control their code, a pointer-and-length function is easier to validate than ...:
int rust_sum_i32_slice(
int64_t *out,
const int32_t *values,
size_t len
);
The slice form exposes the count in a fixed argument, preserves element type, supports bulk validation, and is easier to bind from other languages. Keep the variadic symbol as a compatibility adapter and document the typed replacement.
This is often a better migration plan than rewriting an entire native library around its oldest entry points. Our guide to rewriting in Rust when it makes sense recommends choosing bounded components with measurable ownership and safety benefits. Rust 1.99 makes one more legacy boundary available for that incremental approach.
Common failure modes
Reading the source type instead of the promoted type
C source code passes a float, char, or short; the variadic ABI passes a promoted value. Read c_double or c_int as required by C's promotion rules.
Trusting a count before capping it
A huge count can trigger excessive allocation before the first argument is read. Convert and cap fixed metadata first.
Assuming a wrong count is recoverable
The function cannot discover that the caller supplied fewer values. Reading beyond the list violates the safety contract.
Letting format strings become instructions
A format-driven interface can perform unexpected reads or writes when the format is untrusted. Restrict the language, use a literal format at the call site, or replace the interface.
Passing borrowed Rust references through ...
The C ABI does not preserve Rust reference validity or lifetime semantics. Pass raw pointers only under an explicit ownership and lifetime contract.
Allowing a panic to reach extern "C"
Avoid panicking operations inside the adapter. Map errors to documented return values before control leaves Rust.
Testing on one desktop ABI
va_list and register classification differ across targets. Run C-to-Rust integration tests on every supported architecture and operating system.
A production review checklist
- The API exists to preserve a real C ABI, callback, or migration path.
- A typed pointer-and-length alternative is offered for new callers where possible.
- The function is
unsafe extern "C"or deliberately"C-unwind". - Its
# Safetysection states count, type, pointer, lifetime, aliasing, and unwind rules. - Fixed metadata is validated before
VaListis read. - Every
next_arguses the C-promoted type. - The maximum number of values and output size are bounded.
- Data crosses into an owned or typed Rust representation promptly.
- Business logic runs in safe Rust outside the adapter.
- Errors map to a stable C-compatible result contract.
- No Rust panic can escape a non-unwinding boundary.
- Symbol export and the generated header are verified in CI.
- C integration tests run against release artifacts.
- The matrix covers each production architecture and compiler family.
- Sanitizers and fuzzing target the layers they can evaluate meaningfully.
- The minimum supported Rust version is at least 1.99.
Rust 1.99 removes the need for a C trampoline when a Rust library must implement a variadic callback or legacy symbol. That is a practical improvement for incremental migrations, native plugins, logging adapters, and systems code that cannot change its public ABI overnight.
The unsafe work remains real. VaList handles platform representation, and VaArgSafe prevents unsupported Rust read types, but the caller still owns the argument sequence. Keep the decoder tiny, promote types exactly as C does, move immediately into typed Rust, and prove the boundary from an actual C caller on every target you ship.
FAQs
What changed for C variadic functions in Rust 1.99?
Rust 1.99 stabilizes definitions of C-ABI variadic functions for extern C and extern C-unwind. Rust could already declare and call external variadic functions; it can now implement functions with a trailing ... directly in stable Rust.
Is Rust's VaList compatible with C va_list?
Yes. Rust documents core::ffi::VaList as ABI-compatible with C va_list across supported targets. It is initialized for a Rust-defined variadic function, can read arguments with next_arg, and can be passed to a compatible v-style C function.
Can Rust verify the number and runtime types of variadic arguments?
No. VaArgSafe limits which Rust types may be requested from VaList, but a variadic call carries no universal runtime type list or count. The caller must follow a documented protocol, and reading a missing or incompatible argument is undefined behavior.
Which argument types should Rust read after C default promotions?
C promotes float to double and integer types narrower than int to int or unsigned int where applicable. Rust code should normally read promoted values such as c_double and c_int, not f32, i8, i16, or their C aliases.
Should new Rust APIs use C variadics?
Usually no. Prefer slices, iterators, enums, structs, or typed builders for Rust callers. Define a variadic function when an existing C ABI, callback, plugin interface, logging hook, or incremental migration requires that exact calling convention.
Can a Rust C variadic function panic?
A panic must not escape an extern C boundary. Design the body to avoid panics and translate failures into a C-compatible return value. Use extern C-unwind only when the cross-language unwinding contract is deliberate and validated on every participating toolchain.
How should a C variadic Rust function be tested?
Test it from C as well as Rust, cover zero and maximum counts, promoted integer and floating types, null pointers, malformed protocols where they can be rejected before reading, symbol export, link behavior, sanitizer runs, and every supported target ABI.
Work with us
Let's build something together
We build fast, modern websites and applications using Next.js, React, WordPress, Rust, and more. If you have a project in mind or just want to talk through an idea, we'd love to hear from you.
Related Articles
Engineering • 21 min
Rust 1.98.1
Rust 1.98.1 fixes a critical vtable miscompilation. Here is what failed, why safe Rust could still crash, and how teams should upgrade and verify their builds.
9/5/2026
Engineering • 10 min
Architecting High-Performance Rust APIs for Headless WordPress
Learn to build high-performance headless WordPress APIs using Rust, Axum, and SQLX. Replace PHP bottlenecks with native code for predictable latency and scalable architecture.
5/5/2026
Engineering • 20 min
Getting Started with Rust: A Systems Programming Primer for Web Developers
Learn Rust from a web developer's perspective. Explore ownership, borrowing, memory safety without GC, and how to build high-performance async web APIs. This guide covers syntax, tooling, and the Actix-web ecosystem.
4/14/2026