C Part I · Module 9 · Memory and Object Lifetime
Uninitialized Memory and Indeterminate Values
You have been told that an uninitialized variable contains a random number — whatever happened to be in that memory. It is a comfortable explanation and it is wrong, in a way that matters. If it were right, reading such a variable would give you an unpredictable but ordinary value, and a program that handled every possible value correctly would be correct. That is not the deal C offers. This chapter shows, by inspecting the machine code a real compiler produced, a function whose entire body was replaced by a single trap instruction, and a second one whose array and loop were deleted outright so that it silently returns zero with no warning of any kind. Neither outcome is explicable as a random number, and the difference between the two is the difference between a bug you find in a minute and one that ships.
Intermediate21 min readCPart IFundamentalsMemory
Part I · Module 9 · Chapter 9.12 · Uninitialized Memory and Indeterminate Values
1. Where Uninitialized Storage Comes From
Two places, both already familiar.
int x; /* automatic — indeterminate */
int *p = malloc(4 * sizeof *p); /* allocated — indeterminate */An automatic object with no initialiser holds an indeterminate value from the moment its lifetime begins (Chapter 9.2), and a fresh malloc block holds indeterminate values in every byte (Chapter 9.6 §6). Chapter 2.3 said the first one "holds whatever was there" and promised a better answer later; this is the better answer.
Objects with static storage duration are the exception: they are zero-initialised before the program starts, guaranteed, which Chapter 9.3 §3 established. That is the only one of the three storage durations that starts out defined.
2. What the Standard Actually Says
There are two separate rules, and the second is the one nobody quotes.
Rule one — what an indeterminate value is. An indeterminate value is either an unspecified value or a trap representation.
- An unspecified value is a valid value of the type, but which one is not documented and need not be consistent. The standard does not promise that two reads of the same uninitialised object give the same answer.
- A trap representation is a bit pattern that is not a valid value of the type at all. Reading one is undefined. Not every type has them — for
unsigned char, every possible object representation is a valid value — butintand the floating types may.
Rule two — and this is the rule that bites. For an uninitialised object of automatic storage duration whose address is never taken — one which therefore could have been declared register — reading its value is undefined behaviour outright. No trap representation required, no unspecified-value reasoning. Just undefined.
So whether an uninitialised read is undefined or merely unspecified depends on whether you took the variable's address. A local you only ever read is undefined. A local you passed
&xto somewhere, or an array you indexed, falls under rule one, where the outcome depends on the type's trap representations.
That distinction is not trivia. It is exactly why the two measured functions in the next two sections behave as they do.
3. The Measurement: A Function That Returns Nothing
Consider a function whose two tests cover every possible value a random number could have.
/* If x held some number — any number — exactly one test must be true. */
int contradiction(void)
{
int x;
if (x == 0) { return 1; }
if (x != 0) { return 2; }
return 3; /* a random number cannot reach this line */
}If the random-number story were right, this function would return 1 or 2, always, and return 3; would be dead code. The compiler warns:
i1.c:6:9: warning: variable 'x' is uninitialized when used here [-Wuninitialized]Now compile it and look at what came out. At -O0, you get roughly the program you wrote — a stack slot loaded twice, three branches, one of three constants returned:
_contradiction:
sub sp, sp, #16
ldr w8, [sp, #8]
cbnz w8, LBB0_2
b LBB0_1
LBB0_1:
mov w8, #1
...
LBB0_4:
mov w8, #3
...At -O2, the same source produced this — and this is the entire function:
_contradiction:
brk #0x1brk is an AArch64 breakpoint instruction. It traps. That is all the function is.
It does not return 1. It does not return 2. It does not return 3. It does not return. No account of "whatever was in that memory" produces this, because the compiler never loaded any memory — it recognised that the only way to reach the function body is through behaviour the standard does not define, concluded that a correct program cannot reach it, and emitted a trap instead of code.
4. The Dangerous One Is the Quiet One
A trap is the good outcome: it is loud, immediate, and unmissable. The case that ships looks like this.
int sum_table(void)
{
int t[4]; /* no initialiser */
int s = 0;
for (int i = 0; i < 4; i++) {
s += t[i];
}
return s;
}$ cc -std=c17 -Wall -Wextra -Wpedantic -Wconditional-uninitialized -O2 -c sum.c
$Nothing. Not one warning, even with clang's extra uninitialised-value flag added on top. And the emitted code at -O2:
_sum_table:
mov w0, #0
retThe array is gone. The loop is gone. The function unconditionally returns 0.
Stop and consider how that behaves in a real program. The function returns a plausible number. It never crashes. At -O0 during development it returns something else — some sum of whatever was on the stack — and at -O2 in production it returns zero. There is no warning, no diagnostic, no trap, and the only symptom is that a total is quietly wrong.
Rank the outcomes by how much they cost you: a trap costs minutes. A warning costs seconds. A plausible wrong answer with no diagnostic costs however long it takes someone to notice the total is wrong, which may be never. Undefined behaviour is most dangerous when it is well-behaved, and this is the cleanest example of that in the whole module.
Why did the compiler warn about the first function and not the second? x in contradiction is a plain local that never has its address taken — rule two's case, visible in one statement. t in sum_table is an array, which is indexed, and tracking which elements of an array were written on which path is a dataflow problem the warning machinery does not attempt. This is the same boundary Chapter 9.11 §7 measured: the compiler sees clearly next to the declaration, and poorly a few steps away.
5. Why "Random Number" Fails, in Three Ways
| The claim | What is actually true |
|---|---|
| "It's a random number" | It is whatever was there — usually highly structured leftovers, not noise |
| "At least it's a stable value" | Two reads of the same object need not agree; the value is unspecified, not fixed |
| "If I handle every value, I'm correct" | The compiler may assume the read never happens, so no value is ever produced |
The third row is the one the measurements prove, and it is the one that makes this undefined behaviour rather than mere unpredictability. The first two are worth a moment each.
It is not noise. The bytes are whatever the last occupant of that storage left behind. A stack slot usually holds a previous call's local variables; a fresh malloc block usually holds a previously freed object's contents. Both are structured data, which means an uninitialised read can produce a value that looks entirely reasonable — a plausible length, a valid-looking pointer, last request's user ID.
It is not stable. Because the value is unspecified rather than fixed, nothing requires two reads to agree, and an optimiser that keeps the variable in a register on one path and reloads it on another can produce exactly that. This is what Chapter 9.11's closing paragraph was referring to.
6. Reading Uninitialized Bytes Through unsigned char
One narrow case comes up often enough to be worth stating correctly, because the usual blanket advice is too strong.
unsigned char has no trap representations — every one of its possible object representations is a valid value. So reading uninitialised bytes through an unsigned char * does not fall foul of rule one's trap-representation clause, and copying a block of bytes whose contents you do not know is a legitimate operation. That is why a byte-copy function can copy a partially initialised buffer without the copy itself being undefined.
What that does not buy you:
- it says nothing about the values you get — they are still unspecified, so no computation on them is meaningful;
- it does not rescue the automatic, address-never-taken case, which is undefined under rule two regardless of type;
- and it is a statement about the standard, not a licence. Code that reads bytes it never wrote is still a bug in every practical sense.
7. The Security Consequence
Chapter 9.10 §3 measured something that has a direct consequence here: after a block is freed, the next allocation of the same size received the identical address. And free does not erase the bytes — it has no reason to.
Put those two facts together and the conclusion is immediate: a fresh malloc block can contain the previous object's data. If the previous object held a password, a session token, a key, or another user's record, those bytes are now in your new buffer.
That is not a theoretical concern; it is the mechanism behind a well-known family of real vulnerabilities, in which a program allocated a buffer, filled only part of it, and sent the whole thing — handing the recipient whatever the rest of the buffer still held.
Two rules follow, and they are not the same rule:
Never send or store more of a buffer than you wrote. Track the length you filled and use it — Chapter 7.7's pointer-plus-length discipline, and Chapter 8.5's output-buffer rule.
For anything sensitive, clear the buffer. calloc for a new block, or an explicit clear before releasing one that held secrets. (The standard library has a function for clearing a block of bytes; it belongs to Chapter 12.9 with the rest of the mem family, so §8 uses a plain loop instead.)
8. The Cure
Every one of these is defined, verified, and cheap.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
unsigned char *z = calloc(8, 1); /* 1. calloc: every byte zero */
if (z == NULL) { return 1; }
printf("calloc :");
for (int i = 0; i < 8; i++) { printf(" %02x", z[i]); }
printf("\n");
free(z);
unsigned char *m = malloc(8); /* 2. malloc, then clear it */
if (m == NULL) { return 1; }
for (int i = 0; i < 8; i++) { m[i] = 0; }
printf("clear loop :");
for (int i = 0; i < 8; i++) { printf(" %02x", m[i]); }
printf("\n");
free(m);
int a[6] = {1, 2}; /* 3. a partial initialiser */
printf("int a[6] = {1, 2}:");
for (int i = 0; i < 6; i++) { printf(" %d", a[i]); }
printf("\n");
int b[6] = {0}; /* the whole array zeroed */
printf("int b[6] = {0} :");
for (int i = 0; i < 6; i++) { printf(" %d", b[i]); }
printf("\n");
return 0;
}Output:
calloc : 00 00 00 00 00 00 00 00
clear loop : 00 00 00 00 00 00 00 00
int a[6] = {1, 2}: 1 2 0 0 0 0
int b[6] = {0} : 0 0 0 0 0 0The last two lines are the ones people forget: Chapter 6.2 §4's rule that a partial initialiser zero-initialises the rest is a guarantee, not an implementation detail. That is why int b[6] = {0}; zeroes all six elements and is the cheapest way to zero a whole array — one initialiser, no loop, no library call. (A library function also exists for clearing a block of bytes; it belongs to Chapter 12.9 with the rest of the mem family, so the loop above stands in for it here.)
And the fourth way is the best one, because it removes the question instead of answering it:
/* 4. declare it where you can give it its real value */
int total = compute_total();Declare a variable at the point where you can initialise it properly. C has permitted declarations anywhere in a block since C99, so the habit of declaring everything at the top of a function — which forces you to write
int x;and fill it in later — is a convention inherited from a language restriction that no longer exists. Chapter 5.6 §7 made the scope argument for this; the lifetime argument is the same habit paying twice.
A note on tooling, so you know what is and is not available. The run-time tool for this specific bug class is MemorySanitizer, which tracks which bits of your program's storage have been written and reports reads of the rest. It is not available on every platform — on the arm64 macOS used for this chapter's measurements, -fsanitize=memory is rejected as an unsupported option. Where you can run it, it is the only tool that finds the sum_table case; where you cannot, your defences are the warning flags, a static analyser, and the initialise-at-declaration habit. Chapter 13.10 covers the tooling properly.
9. What You Should Be Able to Say Now
- Automatic objects and fresh
mallocblocks hold indeterminate values; only static storage is zero-initialised, guaranteed. - An indeterminate value is either an unspecified value or a trap representation.
- Separately, reading an uninitialised automatic object whose address was never taken is undefined outright — so it matters whether you took the address.
- Measured: a function with two exhaustive tests on an uninitialised local compiled at
-O2tobrk #0x1— the whole function. It does not return at all. - The same source at
-O0produced ordinary branching code. The optimisation level changes the program. - Measured: summing an uninitialised array drew no warning at any flag used here and compiled to
mov w0, #0; ret— array and loop deleted, a plausible wrong answer. - Undefined behaviour is most dangerous when it is well-behaved. Rank outcomes: trap < warning < plausible wrong answer.
- "Random number" fails three ways: the bytes are structured leftovers, the value need not be stable, and the compiler may assume the read never happens.
unsigned charhas no trap representations, which makes byte copies of unknown bytes legitimate — and buys nothing else.- A fresh block can hold a previous object's data (9.10 measured the reuse) — so never send more of a buffer than you wrote, and clear anything sensitive.
- The cure:
calloc, an explicit clear loop, a partial initialiser ({0}zeroes the rest, verified), or best, declare it where you can give it its real value.
10. Exercises
Exercise 1 — classify six reads. For each, say whether it is undefined outright (rule two), indeterminate under rule one, or fully defined.
int x; int y = x;
int a[4]; int s = a[0];
static int g; int h = g;
int b; int *p = &b; int c = *p;
unsigned char u[4]; unsigned char v = u[1];
int *m = malloc(4); int n = *m;Exercise 2 — refute the dismissal. A colleague says sum_table from §4 "is fine, it just adds up whatever junk is there." Reply in three sentences, using the emitted code.
Exercise 3 — predict the warning. For each, say whether -Wall -Wextra -Wpedantic is likely to warn, and justify your answer with §4's explanation rather than by guessing.
int f(void) { int x; return x; }
int g(void) { int a[2]; return a[0]; }
int h(int n) { int x; if (n) x = 1; return x; }
int k(void) { int x; int *p = &x; return *p; }Exercise 4 — fix four declarations. Rewrite each to start from a known state, using the cheapest of §8's four methods that fits.
int count;
char name[32];
int *table = malloc(10 * sizeof *table);
double totals[100];Exercise 5 — the partial initialiser. State what int a[6] = {1, 2}; guarantees about a[5], and whether int a[6] = {}; is valid C17. Then say which of the two you would write to zero an array and why.
Exercise 6 — the information leak. A function allocates a 256-byte buffer, writes a 12-byte message into it, and transmits all 256 bytes. Describe what the recipient may receive, which chapter's measurement makes that possible, and give the two independent fixes from §7.
Exercise 7 — the optimisation-level argument. Explain why "it works in the debug build" is not evidence of correctness here, referring to the two assembly listings in §3.
Exercise 8 — the address-taken rule. Explain why int x; return x; and int x; int *p = &x; return *p; fall under different rules, what each one's status is, and why the practical advice is the same for both anyway.
11. Summary
- Automatic objects and fresh
mallocblocks hold indeterminate values; static storage is zero-initialised, guaranteed. - Indeterminate = unspecified value or trap representation; and an uninitialised automatic, address-never-taken read is undefined outright.
- Measured at
-O2: a function with exhaustive tests becamebrk #0x1— the whole function, a trap. - Measured at
-O2: an uninitialised array sum becamemov w0, #0; retwith no warning — array and loop deleted. - The optimisation level changes the program. A correctness argument that depends on it is not one.
- "Random number" is wrong three ways: structured leftovers, not stable, and the read may be assumed never to happen.
unsigned charhas no trap representations — byte copies are legitimate and the values still mean nothing.- A fresh block can hold a previous object's data — never send more than you wrote; clear anything sensitive.
- Cure:
calloc· an explicit clear loop · partial initialiser · best of all, declare where you can initialise.
12. Closing Module 9
This module changed what a C program is, for you. Before it, every object in this curriculum came into existence and went out of existence on its own, at a point the language chose. Now you own a third storage duration whose beginning and end you control, and with it the whole class of problems that come from controlling something: the block nothing can reach any more, the pointer that outlived its object, the two pieces of code that disagreed about whose job the release was.
The thread through all twelve chapters is one sentence, and it is worth stating plainly now that you can see the whole module: every bug in Chapters 9.9 through 9.12 is a failure of the design in Chapter 9.8. A leak is an allocation with no owner. A double free is an allocation with two. A dangling pointer is a borrow that outlived its loan. An uninitialised read is storage used before its owner gave it a value. The functions take a paragraph to learn; the ownership discipline takes a career to keep, and it is the thing that separates C programmers who ship memory bugs from those who do not.
Carry three habits forward, in this order of importance. Decide and document who owns every allocation. Give every pointer and every object a value where you declare it. Check every allocation before you use it.
One limitation has been visible throughout and could not be fixed within this module. Every example here managed a block of identical elements — ints, chars, bytes — because that is all the types you have. Real programs allocate things with parts: a reading with a timestamp and a value, a packet with a header and a payload, a record with a name and a count. The next module builds types like that, and it will do two things to what you have just learned. It will make the ownership question sharper, because a structure can hold a pointer, and then releasing the structure and releasing what it points at become two different jobs. And it will introduce a fact about layout — that the size of a structure is not the sum of its members — which is a piece of language trivia in Part I and becomes, in Part II, the exact reason a structure describing a hardware register does or does not match the hardware.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
