C / UNDEFINED BEHAVIOUR AND DEFENSIVE C
Uninitialised variables and indeterminate values
Identify which objects C zero-initialises, explain why reading an indeterminate value is undefined, and structure code so no path ever reads one.
What you will learn
- Say which objects C zeroes for you: static and thread storage duration, nothing else.
- Initialise at the point of declaration, or assign on every branch before the first read.
- Treat malloc'ed bytes as indeterminate; use calloc or write every byte you read back.
- Use a sentinel plus a status return so 'not written' is visible instead of undefined.
Understanding Uninitialised variables and indeterminate values
C promises a starting value only for objects with static or thread storage duration: file-scope variables and anything marked static are set to zero (null for pointers, +0.0 for floating types) before main runs, because those objects are part of the program image. Everything else starts with what the standard calls an indeterminate value, meaning either an unspecified value or a trap representation: plain locals inside a function, and every byte handed back by malloc or by the grown tail of realloc. The one shortcut worth memorising is partial aggregate initialisation: int a[4] = {5}; or struct point p = {.x = 7}; zero-fills every element or member you did not mention, so a single initialiser defines the whole object.
Indeterminate is not a value, it is the absence of a promise, and that is why it behaves worse than a random number. Reading an uninitialised automatic object whose address you never took is undefined behaviour outright, so the compiler may assume the read never happens: it can leave the variable in a register that already held something, produce different answers at two reads of the same variable, or decide a branch that depends on it is unreachable and delete it. That is the usual reason a program prints 0 at -O0 and prints something else, or nothing at all, at -O2 — the -O0 build read a stack slot on a page the operating system had handed over already zeroed, and no rule in the language made that repeatable.
The nasty property of this bug class is that it hides. Fresh stack pages and freshly mapped heap really are zero, so an uninitialised counter can read 0 for months until the same function runs deeper in the call stack, on top of a frame someone else already dirtied. Because you cannot test your way to confidence, the fix has to be structural: declare a variable where you know its value, and when the value comes from branches, either seed it with a sentinel or check that every branch, including switch defaults and error paths, assigns it. Padding bytes deserve the same suspicion, since storing into one struct member leaves the padding around it unspecified even after a memset.
<stdio.h>
int file_scope; /* static storage duration: zeroed before main runs */
static int next_id(void)
{
static int counter; /* zeroed once, not on every call */
counter += 1;
return counter;
}
int main(void)
{
int automatic = 0; /* 0 only because this initialiser puts it there */
int counts[4] = { 5 }; /* counts[1..3] are zero-filled by the language */
char text[6] = "ab"; /* text[2..5] become '\0' */
int first = next_id();
int second = next_id();
printf("file_scope = %d\n", file_scope);
printf("automatic = %d\n", automatic);
printf("counts = %d %d %d %d\n", counts[0], counts[1], counts[2], counts[3]);
printf("text tail = %d %d %d %d\n", text[2], text[3], text[4], text[5]);
printf("ids = %d %d\n", first, second);
return 0;
}
An uninitialised automatic object holds no value at all, only the absence of a promise, and reading it is undefined behaviour that the optimiser is allowed to build on.
Worked examples
Heap bytes are indeterminate too
malloc defines nothing about the bytes it returns, so a string buffer needs one store before any library function may scan it.
<stdio.h>
<stdlib.h>
<string.h>
int main(void)
{
unsigned char *zeroed = calloc(4, 1); /* every byte is 0 by contract */
char *raw = malloc(8); /* 8 indeterminate bytes */
if (zeroed == NULL || raw == NULL)
return 1;
printf("calloc bytes: %d %d %d %d\n", zeroed[0], zeroed[1], zeroed[2], zeroed[3]);
raw[0] = '\0'; /* one store makes it a valid empty string */
printf("empty length: %zu\n", strlen(raw));
strcat(raw, "ab");
printf("after strcat: \"%s\" (%zu)\n", raw, strlen(raw));
free(zeroed);
free(raw);
return 0;
}
Example explained
Line 1calloc(4, 1) is the only allocator here that defines its result, so reading zeroed[0..3] is a promise from the library, not luck.
Line 2raw[0] = '\0' is what makes strlen(raw) legal: without it, strlen scans indeterminate bytes looking for a terminator that need not be inside the block.
Line 3strcat appends at the terminator it finds, so on an uninitialised buffer it can begin writing past the end of the allocation.
Line 4Nothing about free and a later malloc restores definedness either: recycled blocks come back indeterminate.
Status return instead of a garbage value
A function that can fail returns a status and writes its result only on success, while the caller keeps a defined sentinel throughout.
<stdio.h>
<stdlib.h>
/* Returns 1 and writes *out on success; returns 0 and leaves *out alone. */
static int parse_port(const char *s, int *out)
{
char *end;
long v = strtol(s, &end, 10);
if (end == s || *end != '\0' || v < 1 || v > 65535)
return 0;
*out = (int)v;
return 1;
}
int main(void)
{
const char *inputs[] = { "8080", "", "70000", "80x" };
size_t n = sizeof inputs / sizeof inputs[0];
for (size_t i = 0; i < n; i++) {
int port = -1; /* defined before the call, on every iteration */
if (parse_port(inputs[i], &port))
printf("[%s] -> ok, port = %d\n", inputs[i], port);
else
printf("[%s] -> rejected, port still %d\n", inputs[i], port);
}
return 0;
}
Example explained
Line 1int port = -1; gives the caller a defined value before the call, so a forgotten store becomes a visible -1 rather than undefined behaviour.
Line 2parse_port stores through out only after all four checks pass, which is what makes the failure path safe to have at all.
Line 3The three rejected lines are printable evidence that nothing was written; with int port; and no initialiser, printing that line would itself be the bug.
Line 4Separating status from value removes the temptation to encode failure in the returned number and read it anyway.
Important notes
The one narrow escape, reading indeterminate bytes through an unsigned char lvalue of an object whose address was taken, yields an unspecified value that may still differ between two reads, so it is not a usable trick.
Options like -ftrivial-auto-var-init=zero or a zeroing debug allocator change your platform, not the language: they hide the bug from reviewers and from MemorySanitizer while the code stays undefined on every other build.
Common mistakes
Assuming a local int n; is zero because a file-scope int n; is: the first read is undefined behaviour, so the code can pass every test at -O0 and misbehave at -O2.
Calling strlen or strcat on char buf[64]; before storing a terminator: the scan runs off the end of the array and strcat then writes wherever it stopped.
Comparing two structs with memcmp after assigning their members one by one: the padding bytes are unspecified, so objects with identical members can compare unequal.
Try it yourself
Change, predict, then run
Write int max_of(const int *a, size_t n, int *out) that returns 0 and leaves *out untouched when n is 0, and call it twice from a caller whose variable is initialised to INT_MIN: once with {3, 9, 4} and once with an empty array. Print the status and the variable after each call and confirm the empty case still shows INT_MIN.
Open the C workspaceCheck your understanding
A function declares int total;, assigns it only inside if (n > 0), then returns it. Built with -O0 the program prints 0 every run; built with -O2 it prints a huge number. What is the most accurate reading of this?
- Both builds are conforming: the read is undefined behaviour, and -O0 happened to use a stack slot on a zeroed page while -O2 reused a register that already held something.
- The -O2 build is wrong, because the standard requires a local to keep whatever was last stored in its stack slot.
- -O0 zero-initialises locals as required for debug builds, so the code is only broken in release builds.
- The value is unspecified but fixed for the run, so the code is safe as long as it compares the variable against itself first.
Show answer
Reading an uninitialised automatic object whose address was never taken is undefined behaviour, so neither build owes you a value; the 0 at -O0 came from a freshly zeroed stack page, not from a language rule. Option 3 is the tempting one because 'indeterminate' sounds like a fixed unknown number, but nothing requires two reads to agree, and the optimiser may re-materialise the read differently or fold the self-comparison away entirely.