5.1.29 · D3C Programming

Worked examples — Variadic functions — va_list, va_start, va_arg, va_end

3,874 words18 min readBack to topic

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 — Variadic functions — va_list, va_start, va_arg, va_end

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, so va_start can 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 by sizeof(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;
}
  1. va_start(ap, n) — anchor the cursor just after the named n (the magenta arrow in the memory picture). Why this step? n is 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. n is that anchor.
  2. Loop i = 0 → 3, va_arg(ap, int) each time — pull box, advance (the violet arrow, repeated). Why this step? n == 4 promises exactly four boxes. We read exactly four; not more (that would be cell I), not fewer.
  3. Accumulate into a long total. Why this step? Four ints could overflow a 32-bit int in general; a long accumulator is a cheap safety margin.
  4. 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?

  1. va_start(ap, n) with n == 0. Why this step? va_start only needs the address after n; 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.
  2. The loop runs 0 times — condition 0 < 0 is false immediately. Why this step? The count itself guards us. We never call va_arg, so we never touch memory beyond n. This is the whole point of a count: the degenerate case handles itself.
  3. va_end(ap) — still required. Why this step? The rule "every va_start matched by a va_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;
}
  1. first is 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.
  2. Loop reading const char* until NULL. 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 that NULL or we run off the end (cell I).
  3. Pointer type must match: va_arg(ap, const char*). Why this step? We promised pointers when we designed the API; reading them as int would 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.

  1. Caller passes 'A' (a char). Why it matters: by the rule above, a 4-byte int box (value 65) is placed on the belt, not a 1-byte char box. In the memory picture, every belt box is at least int-wide.
  2. 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.
  3. 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?

  1. Caller passes 1.5f, 2.5f (both float). Why it matters: by promotion, each becomes an 8-byte double on the belt.
  2. 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 a double's bit pattern as a float.
  3. 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;
}
  1. 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.
  2. '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 exact printf letters removes any guesswork.
  3. The truncation (long)3.14 = 3 is 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
}
  1. va_copy(ap2, ap) before touching ap. Why this step? Once va_arg advances ap, the copy must already exist at the start position (both cursors at the magenta arrow). Copy first, consume second.
  2. First pass with ap: loop n times summing each int, then compute mean = 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.
  3. va_end(ap) immediately after the first pass. Why this step? ap is now fully consumed and useless; end it as soon as its job is done so no dangling cursor lingers.
  4. Second pass with the untouched ap2: loop n times, subtract the mean, square, accumulate. Why this step? This is the whole reason va_copy existed — ap2 still sits at the magenta arrow, so we can re-walk the same belt a second time to compute squared deviations.
  5. va_end(ap2) then return sq / n. Why this step? Two origins (va_start for ap, va_copy for ap2) demand two va_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;
}
  1. Two named params, tag and n. Why this step? va_start anchors on the last named parameter — here n, not tag. The magenta arrow must land after n; get this wrong and the cursor starts in the wrong place.
  2. Read each as double (cell E promotion). Why this step? Temperatures may be passed as float literals; on the belt they are double.
  3. Zero-guard on n (cell B) prevents divide-by-zero. Why this step? Reusing the degenerate-case protection from Ex 2 means log_avg("core", 0) returns 0.0 instead 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;
}
  1. 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).
  2. 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 telling va_arg where the belt ends. Beyond box 3 is whatever the stack or register save area happened to hold — a return address, saved registers, junk.
  3. 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)

Active Recall