Scan the QR code to follow Chip Dynamics, and say goodbye to “chip” congestion!

Search WeChat
Chip Dynamics
Have you ever wondered how the printf function can “consume” any number and type of parameters and output them accurately? For example, this line of code:
printf(“%d %s %.2f”, 100, “hello”, 3.14);
How does it know that the first parameter is an int, the second is a char*, and the third is a double?
Today, we will unveil the mystery of variable-length arguments in C, and even implement a similar variable-length argument function like printf!
Fixed Parameters vs Variable-Length ParametersFixed Parameters
First, let’s look at a normal function:
void fixed_args_func(int a, double b, char *c){ printf("a address: 0x%p", &a); // Can directly get the address of a printf("b address: 0x%p", &b); // Can directly get the address of b printf("c address: 0x%p", &c); // Can directly get the address of c}
The advantages of fixed parameters are obvious: the type, number, and order of parameters are completely determined at the function declaration. We can not only directly use the parameter values but also obtain their memory addresses using the address-of operator &, and even calculate the address offsets of adjacent parameters based on their types (for example, &b = &a + sizeof(int)).
Variable-Length Parameters
However, variable-length parameter functions are much more complicated. For example, the classic printf declaration:
int printf(const char *fmt, ...); // ... indicates variable-length parameters
Here, fmt is the only fixed parameter (the C standard requires that variable-length parameter functions must have at least one fixed parameter), while the number and types of parameters in … are completely unknown. The question arises: how do we find the positions of these “unknown parameters”?
Manually Implementing Variable-Length Parameters: “Digging” Parameters from the StackStorage of Function Parameters: The Secret of the Stack
To understand variable-length parameters, we must first recall the underlying logic of function calls: parameters are passed via the stack (there may be differences across platforms, but the principle is similar). When a function is called, parameters are pushed onto the stack in right-to-left order, and the stack grows from low addresses to high addresses (the first pushed parameter is at a high address, and the last pushed is at a low address).
For example, when calling fixed_args_func(17, 5.40, “hello”), the order of parameters pushed onto the stack is:
“hello” → 5.40 → 17 (note the order is reversed!).
By printing the addresses of each parameter, we can verify this (assuming a 32-bit system):
a address: 0x0022FF50 // 17 (int, 4 bytes)b address: 0x0022FF54 // 5.40 (double, 8 bytes)c address: 0x0022FF5C // "hello" (char*, 4 bytes)
We can see that the address of a is 0x0022FF50, the address of b is a + 4 (skipping 4 bytes for the int), and the address of c is b + 8 (skipping 8 bytes for the double). This indicates that parameters are stored contiguously on the stack, and the address difference between adjacent parameters equals the size of the previous parameter.
Using Fixed Parameters to Trace Variable-Length Parameters
The key to variable-length parameters: deducing the addresses of variable-length parameters from the addresses of fixed parameters.
For example, the fixed parameter of printf is fmt (the format string). Assuming the address of fmt is &fmt, then the address of the first variable-length parameter is &fmt + sizeof(fmt) (skipping the size of fmt itself). The address of the second variable-length parameter is the address of the first variable-length parameter plus its size, and so on.
Manually Implementing a “Simplified Version of printf”
Let’s try to write a variable-length parameter function that simulates part of printf’s functionality:
#include <stdio.h>void my_printf(const char *fmt, ...) { // 1. Define a pointer of type va_list (essentially a char*, pointing to the address of parameters) char *ap = (char *)&fmt; // Initialize to the address of the fixed parameter fmt ap += sizeof(fmt); // Skip fmt itself, pointing to the first variable-length parameter // 2. Traverse the format string fmt while (*fmt) { if (*fmt == '%') { // Encountering % indicates we need to process variable-length parameters fmt++; // Move to the format specifier (like %d, %s) switch (*fmt) { case 'd': { // Handle int type int val = *(int *)ap; // Convert the memory pointed to by ap to int printf("int: %d ", val); ap += sizeof(int); // Move ap to the next parameter break; } case 's': { // Handle char* type char *val = *(char **)ap; // ap points to the address of char*, need to dereference again printf("str: %s ", val); ap += sizeof(char *); // Move ap to the next parameter break; } default: putchar(*fmt); } } else { putchar(*fmt); // Output ordinary characters directly } fmt++; }}int main(){ my_printf("Integer: %d, String: %s", 100, "hello"); return 0;}
Running result:
Integer: 100, String: hello
The core logic of this code is:
-
Using char *ap to point to the address of the fixed parameter fmt;
-
By ap += sizeof(fmt) to skip fmt, pointing to the first variable-length parameter;
-
According to the control specifiers in the format string like %d, %s, convert the memory pointed to by ap to the corresponding type and move ap to the next parameter’s position.
Standard Library: stdarg.hWhy do we need stdarg.h?
The manual implementation above has a fatal flaw: it relies on specific platforms and compilers. For example, in a 64-bit system or with different compilers (like GCC, MSVC), the alignment of parameters (memory addresses must be multiples of the type size) may differ, and directly using sizeof to calculate offsets can lead to errors.
The C standard library stdarg.h was created to solve this problem. It defines a set of macros (va_list, va_start, va_arg, va_end) that allow us to safely manipulate variable-length parameters across platforms.
Breaking Down the Core Macros of stdarg.h
Let’s take the most common implementation (similar to GCC’s x86 version) as an example to see what these macros actually do:
1. va_list: A “pointer” to the parameters
va_list is essentially a char* (character pointer) used to move byte by byte, precisely controlling the address of parameters.
2. va_start(ap, last_param): Initialize the pointer
The purpose of va_start is to make ap point to the first variable-length parameter. Its implementation is similar to:
#define va_start(ap, last_param) \ (ap = (va_list)&last_param + _INTSIZEOF(last_param))
Here, _INTSIZEOF(last_param) is a macro used to calculate the “aligned size” of last_param (ensuring the address is aligned). For example, if last_param is int (4 bytes), then _INTSIZEOF(int) = 4; if it is char (1 byte), then _INTSIZEOF(char) = 4 (because x86 requires 4-byte alignment).
3. va_arg(ap, type): Get the current parameter and move the pointer
The purpose of va_arg is to retrieve the value of the current parameter and move ap to the next parameter’s position. Its implementation is similar to:
#define va_arg(ap, type) \ (*(type *)((ap += _INTSIZEOF(type)) - _INTSIZEOF(type)))
This macro does two steps:
-
First, it adds _INTSIZEOF(type) to ap (moving to the address of the next parameter);
-
Then, it subtracts _INTSIZEOF(type) (going back to the address of the current parameter), and converts that address to type* type, dereferencing to get the value.
4. va_end(ap): End parameter access
The purpose of va_end is to ensure that ap no longer points to the stack space (to avoid dangling pointers). A common implementation is:
#define va_end(ap) (ap = (va_list)0)
Rewriting my_printf with stdarg.h
Now let’s optimize the previous my_printf using the macros from the standard library, making the code simpler and cross-platform:
#include <stdarg.h>#include <stdio.h>void my_printf(const char *fmt, ...) { va_list ap; // Define the parameter pointer va_start(ap, fmt); // Initialize ap, pointing to the first variable-length parameter while (*fmt) { if (*fmt == '%') { fmt++; switch (*fmt) { case 'd': { int val = va_arg(ap, int); // Automatically get int type parameter printf("int: %d ", val); break; } case 's': { char *val = va_arg(ap, char*); // Automatically get char* type parameter printf("str: %s ", val); break; } default: putchar(*fmt); } } else { putchar(*fmt); } fmt++; } va_end(ap); // End parameter access}int main() { my_printf("Integer: %d, String: %s", 100, "hello"); return 0;}
The effect is exactly the same as before, but the code is more robust and can run on different platforms!
How does printf “smartly” identify parameters?
Returning to the initial question: how does printf correctly read different types of parameters based on the format string %d, %s, etc.?
The answer is simple: **printf itself “parses” the format string and then calls va_arg to get the corresponding type of parameter**.
For example, when printf encounters %d, it knows to call va_arg(ap, int); when it encounters %s, it calls va_arg(ap, char*). This is why printf can “intelligently” handle different types of parameters.
The essence of variable-length parameters is to read unknown parameters one by one by deducing their addresses from the addresses of fixed parameters, combined with the storage rules of the stack. The standard library stdarg.h provides a set of cross-platform macros that help us simplify this process.
Things to note when using:
-
There must be at least one fixed parameter;
-
Type safety must be ensured (through format strings or other means);
-
Try to use the macros from stdarg.h to avoid directly manipulating stack addresses.

If you find this article helpful, click “Like”, “Share”, and “Recommend”!