Exercises — Variadic functions — va_list, va_start, va_arg, va_end
Everything here rests on four ideas you already met: the four macros, the stop-signal (count or sentinel), the default argument promotions, and the fact that a wrong promise is undefined behavior. We build nothing new without naming it first.
Level 1 — Recognition
Goal: can you read variadic code and spot the pieces?
Exercise 1.1
In the function below, name each of the four <stdarg.h> roles that appear, and say what the anchor (the last named parameter) is.
int biggest(int count, ...) {
va_list ap;
va_start(ap, count);
int best = va_arg(ap, int);
for (int i = 1; i < count; i++) {
int x = va_arg(ap, int);
if (x > best) best = x;
}
va_end(ap);
return best;
}Recall Solution 1.1
va_list ap;— declares the cursor that walks the extra arguments.va_start(ap, count);— points the cursor just after the anchor.- The anchor (last named parameter) is
count. va_arg(ap, int)— reads the next argument asintand advances.va_end(ap);— mandatory cleanup before returning.
Note the code reads count values total: one before the loop, then count-1 inside it. For biggest(3, 7, 2, 9) the answer is 9.
Exercise 1.2
Which of these signatures are legal starts for a variadic function, and why?
(a) void log(const char *fmt, ...);
(b) void log(...);
(c) int add(int a, int b, ...);
(d) double f(...) ; // with no arguments ever passed
Recall Solution 1.2
- (a) legal — one named parameter
fmtbefore.... ✔ - (b) illegal — C requires at least one named parameter before
...;va_starthas nothing to anchor to. ✘ - (c) legal — two named parameters is fine;
va_start(ap, b)anchors on the last one,b. ✔ - (d) illegal — same reason as (b): no named parameter exists. ✘
Legal count: 2 (a and c).
Level 2 — Application
Goal: write correct variadic code and trace it.
Exercise 2.1
Write int product(int n, ...) that returns the product of n integers. Then trace product(4, 2, 3, 5, 1) and give the result.
Recall Solution 2.1
#include <stdarg.h>
int product(int n, ...) {
va_list ap;
va_start(ap, n);
long p = 1;
for (int i = 0; i < n; i++)
p *= va_arg(ap, int);
va_end(ap);
return (int)p;
}Trace of product(4, 2, 3, 5, 1): 1·2 = 2, ·3 = 6, ·5 = 30, ·1 = 30.
Result: 30.
Exercise 2.2
The following sums doubles, but it is broken. Find the bug and fix it. Then evaluate the fixed version on avg(3, 1.5, 2.5, 4.0).
double avg(int n, ...) {
va_list ap; va_start(ap, n);
double s = 0;
for (int i = 0; i < n; i++)
s += va_arg(ap, float); // <-- ?
va_end(ap);
return s / n;
}Recall Solution 2.2
Bug: va_arg(ap, float). A float passed to ... is promoted to double by the default argument promotions. Reading it back as float misinterprets the bytes → garbage (undefined behavior). Fix: use va_arg(ap, double).
s += va_arg(ap, double);Fixed evaluation avg(3, 1.5, 2.5, 4.0): sum , then .
Result: 2.6666... (≈ 2.667).
Level 3 — Analysis
Goal: reason about what goes wrong and why.
Exercise 3.1
A student calls sum(3, 10, 20) — passing a count of 3 but only 2 values. Explain precisely what happens on the third va_arg and why the compiler did not catch it.
Recall Solution 3.1
The loop runs three times. The third va_arg(ap, int) reads memory past the last real argument — see the figure below. That location holds whatever bytes happen to be there (leftover stack, another value, padding). This is undefined behavior: the returned "integer" is garbage; the program may print a random number or crash.
The compiler cannot catch it because the count 3 is an ordinary runtime value. The compiler only sees "some ints are passed"; it has no idea how many, since ... carries no type or count information. All safety is delegated to you.

Exercise 3.2
Consider the mixing bug: a function expects the sequence int, double, int but the loop does three va_arg(ap, int) calls. On a typical 64-bit ABI where int is 4 bytes and double is 8 bytes, describe conceptually why the second and third reads are wrong, using the cursor model.
Recall Solution 3.2
The cursor advances by the size of the type you claim, not the type actually there.
- Read #1
va_arg(ap, int)— correct; the first slot really is anint, cursor moves forward by an int's width. - Read #2
va_arg(ap, int)— the real value is adouble(wider). Readingintgrabs only part of the double's bytes → wrong value, and advances the cursor too little, so it now points into the middle of the double. - Read #3
va_arg(ap, int)— starts from a misaligned position, reading a blend of the leftover double bytes and the real third int → garbage.
Moral: one wrong type desynchronises every read that follows. The cursor arithmetic ([[Calling Conventions and the Stack|stack layout]] dependent) only works if each promised type matches the pushed, promoted type.
Level 4 — Synthesis
Goal: combine the pieces into a small real feature.
Exercise 4.1
Write int count_positive(int stop, ...) that reads int arguments until it sees the sentinel value stop, and returns how many of the values read before the sentinel were strictly greater than zero. Then evaluate count_positive(-1, 5, -2, 0, 7, -1).
Recall Solution 4.1
#include <stdarg.h>
int count_positive(int stop, ...) {
va_list ap; va_start(ap, stop);
int count = 0, x;
while ((x = va_arg(ap, int)) != stop) {
if (x > 0) count++;
}
va_end(ap);
return count;
}Here the anchor stop doubles as the sentinel definition. The caller must end the list with stop.
Trace of count_positive(-1, 5, -2, 0, 7, -1): read 5 (>0 ✔ count=1), -2 (no), 0 (not strictly >0, no), 7 (>0 ✔ count=2), -1 == stop → halt.
Result: 2.
Exercise 4.2
Using va_copy, write double range_ratio(int n, ...) over n doubles that returns (max) / (average). You must pass over the arguments twice (once for the average, once for the max). Evaluate range_ratio(3, 2.0, 4.0, 6.0).
Recall Solution 4.2
#include <stdarg.h>
double range_ratio(int n, ...) {
va_list ap, ap2;
va_start(ap, n);
va_copy(ap2, ap); // duplicate BEFORE consuming ap
double sum = 0;
for (int i = 0; i < n; i++) sum += va_arg(ap, double);
va_end(ap);
double avg = sum / n;
double mx = va_arg(ap2, double);
for (int i = 1; i < n; i++) {
double v = va_arg(ap2, double);
if (v > mx) mx = v;
}
va_end(ap2);
return mx / avg;
}Why va_copy: a va_list is (potentially) single-use and cannot be rewound or copied with =. va_copy(ap2, ap) makes an independent cursor so the second walk starts from the first argument again.
Trace range_ratio(3, 2.0, 4.0, 6.0): sum , avg , max , ratio .
Result: 1.5.
Level 5 — Mastery
Goal: design, reason about safety, and connect to the wider system.
Exercise 5.1
You are writing a tiny logger void logf(const char *fmt, ...) that supports only %d (int) and %s (string). Sketch the parsing loop, and explain the one line an attacker could exploit if fmt came from untrusted input. Name the vulnerability class.
Recall Solution 5.1
#include <stdarg.h>
#include <stdio.h>
void logf(const char *fmt, ...) {
va_list ap; va_start(ap, fmt);
for (const char *p = fmt; *p; p++) {
if (*p != '%') { putchar(*p); continue; }
p++; // skip '%'
if (*p == 'd') printf("%d", va_arg(ap, int));
else if (*p == 's') fputs(va_arg(ap, const char*), stdout);
else putchar(*p); // literal, e.g. "%%"
}
va_end(ap);
}The dangerous pattern is calling something like logf(user_string) where user_string contains conversion specifiers but no matching arguments were passed. Each %d/%s then does a va_arg past the real argument list → reads/prints stack memory or crashes. This is the format string vulnerability class (the real-world version is printf(user_input) instead of printf("%s", user_input)).
Safe call: logf("%s", user_string); — the format is a fixed literal you control; the user data is an argument, not the format.
Exercise 5.2
Given the toy macros from the parent note:
#define my_va_arg(ap, T) (*(T*)((ap) += sizeof(T), (ap) - sizeof(T)))On a 64-bit ABI (int = 4 bytes, double = 8 bytes, pointer = 8 bytes), a cursor ap starts at byte offset 0. Suppose the promoted arguments laid out contiguously are: int, double, char*. After reading them in order with the correct promoted types, what byte offset does ap hold? What subtle real-world detail does this naive model ignore?
Recall Solution 5.2
Advancing by sizeof(claimed type) each time:
- after
my_va_arg(ap, int): offset - after
my_va_arg(ap, double): offset - after
my_va_arg(ap, char*): offset
Final offset: 20.
What it ignores: alignment and register passing. Real ABIs may require the double to sit at an 8-byte-aligned offset (inserting padding, so the arithmetic isn't a plain sum), and many arguments arrive in registers, not on the stack at all. This is precisely why the real <stdarg.h> is a compiler-magic black box and not the toy macro — see calling conventions.
Recall One-line recap of every level
L1 spot the pieces & the anchor · L2 write & fix promotion bugs · L3 explain why over-reads and type-mismatches desynchronise · L4 build with sentinels and va_copy · L5 connect to security and ABI reality.
Related: C Standard Library · Function Pointers in C · Undefined Behavior in C · Format String Vulnerabilities.