Worked examples — Variadic functions — va_list, va_start, va_arg, va_end
This page is the exhaustive drill room for [[Variadic Functions — va_list, va_start, va_arg, va_end|the parent topic]]. The parent showed you the four macros and three examples. Here we enumerate every kind of situation a variadic function can face — then walk one worked example for each cell so you never meet an unrehearsed case.
Everything below assumes you already met the four cursor macros. If any feel fuzzy, re-read the parent first. New tools that enter here (the promotion rules, the stack layout) are re-built from scratch where they matter.
First: what the belt really is (the memory picture)
Before any example, let's make the "conveyor belt" concrete, because every case below is really a statement about memory laid out in address order. This is the stack-layout tool, built from zero.

Figure — alt text and caption. A left-to-right memory diagram. Far left, a filled peach box labelled "n = 3 (named)" is the named parameter. To its right sit three outlined boxes each labelled "int, 4 bytes" holding 10, 20, 30 — the variadic belt. A navy arrow beneath, pointing right, is labelled "increasing address". A magenta arrow drops onto the gap just after the named box: "va_start(ap, n) lands here". A violet arrow spans across the first box rightward: "va_arg(ap,int): read + advance". An orange arrow after the third box points up to "STOP: count or NULL". Beyond the orange stop, an italic magenta label reads "? garbage (UB)" marking undefined memory. The picture says: the cursor is born at the magenta arrow, walks box-by-box under the violet arrow, and must halt at the orange stop — everything past it is the ? region.
Read the figure left to right as increasing memory address:
- The peach box on the left is the named parameter
n = 3. The compiler named it, sova_startcan find its address. - The three outlined boxes are the variadic arguments, each a full
int(4 bytes) — note the width, it will matter for the promotion traps (cells D, E). - The magenta arrow is where
va_start(ap, n)drops the cursor: just past the named box. - The violet arrow shows one
va_arg(ap, int): read the box under the cursor, then slide the cursor forward bysizeof(int). - The orange arrow is the only thing that saves us: a count or a sentinel telling us to stop before the cursor slides into the
?region — the undefined memory we do not own.
Keep this picture in mind for all nine examples: every bug is the cursor landing in the wrong box, at the wrong width, or past the orange stop.
The scenario matrix
Every question we can ask reduces to: how many boxes, what's in each, and how do we know when to stop? The matrix below lists every distinct case-class. The rest of the page fills each cell with a fully worked example. (Plain-text form: A = count/int, B = zero args, C = sentinel/pointers, D = char promotion, E = float promotion, F = format-string types, G = re-scan, H = word problem, I = over-read UB.)
| # | Case class | The twist it tests | Example |
|---|---|---|---|
| A | Count method, all int |
The simplest: caller says "n follow" | Ex 1 |
| B | Zero variadic args (degenerate) | The belt is empty — does va_start/va_end still work? |
Ex 2 |
| C | Sentinel method, pointers | No count; a NULL marks the end |
Ex 3 |
| D | Promotion trap: char/short |
Small integers arrive widened to int |
Ex 4 |
| E | Promotion trap: float |
Floats arrive widened to double |
Ex 5 |
| F | Mixed types driven by a format string | Type of each box decided at runtime by a leading string | Ex 6 |
| G | Re-scan with va_copy |
Read the same belt twice | Ex 7 |
| H | Word problem (real-world logging) | Applying all of the above in context | Ex 8 |
| I | Exam twist: reading one too many | The [[Undefined Behavior in C | UB]] boundary |
Ex 1 — Cell A: count method, all int
Forecast: guess the total before reading on. (Add them: , i.e. 5 plus 15 plus 25 plus 55.)
int sum(int n, ...) {
va_list ap;
va_start(ap, n);
long total = 0;
for (int i = 0; i < n; i++)
total += va_arg(ap, int);
va_end(ap);
return (int)total;
}va_start(ap, n)— anchor the cursor just after the namedn(the magenta arrow in the memory picture). Why this step?nis the only thing the compiler gave a name; the rest of the belt is unnamed, so the cursor must be told where the unnamed part begins.nis that anchor.- Loop
i = 0 → 3,va_arg(ap, int)each time — pull box, advance (the violet arrow, repeated). Why this step?n == 4promises exactly four boxes. We read exactly four; not more (that would be cell I), not fewer. - Accumulate into a
long total. Why this step? Fourints could overflow a 32-bitintin general; alongaccumulator is a cheap safety margin. va_end(ap)then return.
Verify: (five plus fifteen plus twenty-five plus fifty-five equals one hundred). Cursor address after read is start + k*sizeof(int); after 4 reads it points one int past the last argument — exactly at the orange stop.
Ex 2 — Cell B: zero variadic arguments (degenerate)
Forecast: will it crash, return garbage, or return 0?
va_start(ap, n)withn == 0. Why this step?va_startonly needs the address aftern; it does not read any variadic arg. So it is perfectly legal even when the belt is empty — the magenta arrow lands right at the orange stop.- The loop runs
0times — condition0 < 0is false immediately. Why this step? The count itself guards us. We never callva_arg, so we never touch memory beyondn. This is the whole point of a count: the degenerate case handles itself. va_end(ap)— still required. Why this step? The rule "everyva_startmatched by ava_end" has no exception for empty lists.
Verify: empty sum (zero). Crucially: va_start+va_end with zero va_arg calls in between is well-defined — reading nothing is safe; only reading too much is UB.
Ex 3 — Cell C: sentinel method, pointers
Forecast: sum the string lengths (2 plus 3 plus 1) — but where does the count come from?
size_t total_len(const char *first, ...) {
va_list ap;
va_start(ap, first);
size_t len = strlen(first);
const char *s;
while ((s = va_arg(ap, const char*)) != NULL)
len += strlen(s);
va_end(ap);
return len;
}firstis the anchor and also counted (strlen("ab") = 2). Why this step? A list naturally has a first element; making it named kills two birds — anchor and first datum.- Loop reading
const char*untilNULL. Why this step? No count was passed, so the contents of the last box (a null pointer) is the orange stop signal. The caller must supply thatNULLor we run off the end (cell I). - Pointer type must match:
va_arg(ap, const char*). Why this step? We promised pointers when we designed the API; reading them asintwould misinterpret the bytes.
Verify: (two plus three plus one equals six). The NULL box is consumed by the failing while test and is not added — correct.
Ex 4 — Cell D: the char/short promotion trap
Forecast: guess whether reading as char gives (65 plus 66 plus 67) or garbage.
Here we must build the tool: default argument promotions.
- Caller passes
'A'(achar). Why it matters: by the rule above, a 4-byteintbox (value 65) is placed on the belt, not a 1-bytecharbox. In the memory picture, every belt box is at leastint-wide. va_arg(ap, char)is undefined — you told the cursor to read 1 byte and advance 1 byte, but the box is 4 bytes wide. Why this step? Size mismatch corrupts the cursor: after the first "read", the violet arrow lands in the middle of box 1, not the start of box 2.- Fix:
va_arg(ap, int). Why this step? Read the box at the width it was actually stored —int.
int sum_bytes(int n, ...) {
va_list ap; va_start(ap, n);
long t = 0;
for (int i = 0; i < n; i++) t += va_arg(ap, int); // int, not char!
va_end(ap);
return (int)t;
}Verify: sum_bytes(3, 'A','B','C') (65 plus 66 plus 67 equals 198) with the correct int version.
Ex 5 — Cell E: the float promotion trap
Forecast: what is the average of and (one-point-five and two-point-five), and which type must you read?
- Caller passes
1.5f,2.5f(bothfloat). Why it matters: by promotion, each becomes an 8-bytedoubleon the belt. va_arg(ap, float)is undefined — reads 4 bytes where 8 were stored. Why this step? Same width mismatch as Ex 4 — the cursor desyncs and you read half of adouble's bit pattern as afloat.- Fix:
va_arg(ap, double).
double avg(int n, ...) {
va_list ap; va_start(ap, n);
double s = 0.0;
for (int i = 0; i < n; i++) s += va_arg(ap, double); // double, not float!
va_end(ap);
return n ? s / n : 0.0;
}Verify: (one-point-five plus two-point-five, all over two, equals two). Read as double. The n ? ... : 0.0 guard reuses cell B (zero args → return 0.0, no division by zero).
Ex 6 — Cell F: mixed types driven by a format string
Forecast: guess (42 plus 3 plus the ASCII code of 'Z').
This is exactly how printf decides types at runtime — the format string is the type program. We deliberately reuse printf's conventions so there is no ambiguity: 'd' is a decimal int, 'f' is a floating-point value (which, by promotion, is a double on the belt), and 'c' is a character (promoted to int). This same power is the source of format string vulnerabilities.
long describe(const char *fmt, ...) {
va_list ap; va_start(ap, fmt);
long acc = 0;
for (const char *p = fmt; *p; p++) {
switch (*p) {
case 'd': acc += va_arg(ap, int); break; // int
case 'f': acc += (long)va_arg(ap, double); break; // double, truncated
case 'c': acc += va_arg(ap, int); break; // char promoted to int
}
}
va_end(ap);
return acc;
}- Walk the format string char by char. Why this step? The string is the count-and-type table: its length tells us how many boxes, each letter tells us the type. No separate count needed.
'd'→va_arg(ap, int),'f'→va_arg(ap, double),'c'→va_arg(ap, int)(char promoted!). Why this step? Each conversion must match the promoted box width, echoing cells D and E. Using the exactprintfletters removes any guesswork.- The truncation
(long)3.14 = 3is deliberate.
Verify: ASCII of 'Z' is 90. Checksum (42 plus 3 plus 90 equals 135).
Ex 7 — Cell G: re-scan with va_copy
Forecast: mean of (two, four, six, eight) is ; deviations are ; guess the variance.
A va_list is often single-use — you cannot rewind it and = may not copy it safely. To read twice, we need va_copy.
long stats(int n, ...) {
va_list ap, ap2;
va_start(ap, n);
va_copy(ap2, ap); // duplicate BEFORE consuming ap
long sum = 0;
for (int i = 0; i < n; i++) sum += va_arg(ap, int);
va_end(ap);
double mean = (double)sum / n;
long sq = 0; // second pass over the copy
for (int i = 0; i < n; i++) {
long d = va_arg(ap2, int) - (long)mean;
sq += d * d;
}
va_end(ap2);
return sq / n; // population variance
}va_copy(ap2, ap)before touchingap. Why this step? Onceva_argadvancesap, the copy must already exist at the start position (both cursors at the magenta arrow). Copy first, consume second.- First pass with
ap: loopntimes summing eachint, then computemean = sum / n. Why this step? We need the mean before we can measure how far each value sits from it; so the first walk of the belt exists purely to compute that centre. va_end(ap)immediately after the first pass. Why this step?apis now fully consumed and useless; end it as soon as its job is done so no dangling cursor lingers.- Second pass with the untouched
ap2: loopntimes, subtract the mean, square, accumulate. Why this step? This is the whole reasonva_copyexisted —ap2still sits at the magenta arrow, so we can re-walk the same belt a second time to compute squared deviations. va_end(ap2)then returnsq / n. Why this step? Two origins (va_startforap,va_copyforap2) demand twova_ends — each cursor is ended exactly once.
Verify: (two plus four plus six plus eight, over four, equals five). Deviations ; squares ; variance (twenty over four equals five).
Ex 8 — Cell H: real-world word problem (a logger)
Forecast: average of (ninety-nine, one-hundred-point-five, one-hundred-four-point-five), and is it over ?
double log_avg(const char *tag, int n, ...) {
va_list ap;
va_start(ap, n); // anchor at the LAST named param, n
double sum = 0.0;
for (int i = 0; i < n; i++)
sum += va_arg(ap, double); // temps are floating point → double
va_end(ap);
double avg = n ? sum / n : 0.0;
// if (avg > 100.0) raise_alarm(tag);
return avg;
}- Two named params,
tagandn. Why this step?va_startanchors on the last named parameter — heren, nottag. The magenta arrow must land aftern; get this wrong and the cursor starts in the wrong place. - Read each as
double(cell E promotion). Why this step? Temperatures may be passed asfloatliterals; on the belt they aredouble. - Zero-guard on
n(cell B) prevents divide-by-zero. Why this step? Reusing the degenerate-case protection from Ex 2 meanslog_avg("core", 0)returns0.0instead of crashing.
Verify: (ninety-nine plus one-hundred-point-five plus one-hundred-four-point-five, over three). Since , the alarm branch would fire. tag is used only for the message, never read via va_arg.
Ex 9 — Cell I: exam twist, reading one too many
Forecast: does the 4th read return 0, or is the whole result meaningless?
int sum(int n, ...) {
va_list ap; va_start(ap, n);
long total = 0;
for (int i = 0; i <= n; i++) // BUG: <= reads n+1 boxes
total += va_arg(ap, int);
va_end(ap);
return (int)total;
}- First three
va_arg(ap, int)read the three real boxes: (ten, twenty, thirty). Why this step? Those boxes genuinely exist on the belt (the three outlined boxes in the memory picture). - Fourth
va_arg(ap, int)slides the cursor past the orange stop, into the?region (the undefined memory defined earlier). Why this step? There is nothing tellingva_argwhere the belt ends. Beyond box 3 is whatever the stack or register save area happened to hold — a return address, saved registers, junk. - This is undefined behavior — not "returns zero", not "returns garbage-but-consistent". The program may return a wrong number, crash, or appear to work by luck. Never rely on it.
Verify: the correct three-box sum (loop i < n) is (ten plus twenty plus thirty equals sixty). The buggy fourth read has no defined value, so no checkable number exists for it — which is exactly the lesson: never let the cursor cross the orange stop.
Recall Which cell was which?
Count-all-int ::: Ex 1 (A)
Empty belt, n == 0 ::: Ex 2 (B)
Sentinel NULL stop ::: Ex 3 (C)
char promotes to int ::: Ex 4 (D)
float promotes to double ::: Ex 5 (E)
Format string picks types ::: Ex 6 (F)
Two passes via va_copy ::: Ex 7 (G)
va_start anchors on the last named param ::: Ex 8 (H)
Reading past the end is UB ::: Ex 9 (I)