Variables in C β Not Just 'A Box That Holds a Value'
Declaring a variable and defining one aren't the same thing. Where you put static changes what a variable remembers. And a GCC 10 default change broke multi-file C projects that had compiled fine for a decade.
Last Updated
March 2026
Read Time
13 min
Level
BeginnerβIntermediate
A Variable Is a Name Bound to Storage β That's the Whole Definition
The 'box that holds a value' explanation works for a first lecture and stops working the moment you write C across more than one file. A variable in C is really a name the compiler associates with a location in memory, plus a type that says how to interpret the bytes there. Where that memory lives β stack, static storage, or a CPU register β and how long the name stays valid depends entirely on where and how you declared it, which is the part most beginner material glosses over.
C also insists on a distinction that languages like Python don't force on you at all: declaration versus definition. A declaration tells the compiler a name exists and what type it is. A definition actually reserves the storage. Most of the time you do both in one line β int total = 0; is a definition β and the distinction stays invisible until you split code across files, where it becomes the difference between code that links and code that doesn't.
Identifier Rules β What's Legal and What Just Compiles by Accident
- βΆ
Allowed characters β letters, digits, and underscore only. No spaces, no hyphens; `interest-rate` is two tokens to the compiler, not one identifier, and fails immediately.
- βΆ
Can't start with a digit β `2ndValue` is rejected; `value2nd` or `secondValue` is fine.
- βΆ
Case-sensitive β `total`, `Total`, and `TOTAL` are three distinct identifiers, a fact that has caused more than one genuinely confusing bug report where someone declared `Count` and used `count` elsewhere by habit, silently creating two separate variables.
- βΆ
Names starting with underscore are technically reserved β `_temp` or `__buffer` at file scope are reserved for the implementation (compiler/standard library) per the standard; using them yourself is legal in practice on most compilers but is asking for a name collision with something libc defines internally.
- βΆ
C keywords can never be identifiers β trying `int int = 5;` fails with `error: expected identifier before 'int'`, since `int` is a reserved word, not just a commonly-used name.
- βΆ
No enforced length limit in practice, but the standard guarantees only 63 significant characters β for internal identifiers (identifiers without external linkage) per C99/C11; beyond that, conforming compilers are only required to distinguish the first 63 characters, though GCC and Clang in practice track far more.
Declaration vs Definition β The Split That Matters Across Files
This distinction is invisible in single-file programs and becomes the entire ballgame in multi-file C projects β which is most real C codebases. extern int requestCount; is a pure declaration: it tells this file 'a variable called requestCount exists somewhere, trust me, and here's its type' β it reserves no storage anywhere. int requestCount = 0;, written once in exactly one .c file, is the definition β the actual storage.
// counter.c β this file owns the storage
int requestCount = 0; // DEFINITION β reserves actual memory
void incrementCount(void) {
requestCount++;
}// logger.c β a different file that needs to read the same counter
#include <stdio.h>
extern int requestCount; // DECLARATION only β no storage reserved here,
// this just tells the compiler 'trust me, it exists'
void logCurrentCount(void) {
printf("Requests so far: %d\n", requestCount);
}Get this backwards β define the same variable in both files instead of declaring it extern in one β and you're relying on undefined, compiler-version-dependent linker behaviour, which is exactly the trap in the next section.
The GCC 10 Change That Broke Old Multi-File C Projects
This is a version-specific gotcha I'd rank as one of the most disruptive quiet changes in recent GCC history for legacy C codebases. Before GCC 10, if you accidentally wrote int requestCount; (no initializer, no extern) in two different .c files, the compiler defaulted to -fcommon, which merges these 'tentative definitions' into a single shared symbol at link time. Sloppy, but it worked, and plenty of decades-old C code relied on exactly this behaviour without anyone realising it was technically undefined by the C standard.
GCC 10 (released 2020, but still catching people upgrading toolchains in 2026 on projects that hadn't been rebuilt in years) flipped the default to -fno-common, matching how Clang had always behaved. The exact same source files that linked cleanly for years suddenly fail:
$ gcc counter.c legacy_status.c -o app
/usr/bin/ld: /tmp/ccXYZ123.o:(.bss+0x0): multiple definition of 'requestCount';
/tmp/ccABC456.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit statusI ran into exactly this while helping port a decade-old industrial monitoring codebase β probably 40-odd .c files β from a CentOS 7 box with GCC 4.8 to a fresh Ubuntu 24.04 build server. Roughly a dozen 'tentative definition' variables scattered across the codebase suddenly refused to link, all with the same multiple definition of error, on a codebase where nobody on the current team even remembered which file was supposed to 'own' each variable. The fix, done properly, was picking one owning file per variable, giving that file the real definition, and turning every other reference into extern. The fast, not-recommended fix is compiling with -fcommon to restore the old behaviour β it works, but it's papering over a bug the new default was specifically added to expose.
Uninitialized Local Variables β Garbage, Not Zero
A local variable with automatic storage duration that you don't explicitly initialize does not default to zero. It holds whatever bit pattern was already sitting in that stack location from whatever function ran there before β reading it before assigning it is undefined behaviour, and the value you see is a coincidence of your specific compiler, optimisation level, and call history, not a guarantee.
#include <stdio.h>
int computeDiscount(int purchaseAmount) {
int discount; // NOT initialized β holds garbage until assigned
if (purchaseAmount > 5000) {
discount = purchaseAmount / 10;
}
// BUG: missing 'else' branch β for purchaseAmount <= 5000,
// discount is returned uninitialized
return discount;
}I made almost this exact mistake in a retail billing prototype: the discount calculation worked correctly during testing because the stack slot for discount happened to contain zero from a previous call, every single time on my dev machine. It shipped, and on the client's production build β different compiler, different optimisation flags, different call stack shape β small purchases occasionally got a wild, meaningless 'discount' because that stack slot now held leftover data from an unrelated function. gcc -Wall does catch this one reliably: warning: 'discount' may be used uninitialized in this function [-Wmaybe-uninitialized] β the warning was there from day one; nobody was compiling with -Wall turned on.
The Four Storage Classes β Scope, Lifetime, and Default Value Compared
Storage class keywords don't change a variable's type β they change where it lives, how long it lives, and what other files can see it. This is the table worth bookmarking.
That last row surprises people: register was genuinely useful when compilers were bad at register allocation. In my experience with GCC and Clang today, the optimiser makes better register-allocation decisions than a human guess placed on individual variables almost every time β the one thing register still reliably does is forbid &variable, which occasionally gets used deliberately just to catch accidental address-taking during a code review, but that's a niche, defensive use, not the keyword's original purpose.
static Locals β The One Case Where a 'Local' Variable Doesn't Reset
A static local variable is declared inside a function like any other local, but its storage is allocated once, for the entire program's run, not re-created on every call. It's the standard way to build a simple ID generator or call counter without reaching for a global.
#include <stdio.h>
int nextInvoiceId(void) {
static int lastId = 100000; // initialized ONCE, on first call only
lastId++;
return lastId;
}
int main() {
printf("%d\n", nextInvoiceId()); // 100001
printf("%d\n", nextInvoiceId()); // 100002
printf("%d\n", nextInvoiceId()); // 100003 β lastId remembered across calls
return 0;
}One catch worth flagging explicitly: this pattern is not thread-safe without extra work. If nextInvoiceId() is called concurrently from two threads β which is a very real scenario in a multi-threaded billing service handling parallel checkout requests β two threads can both read lastId before either writes it back, and you hand out the same invoice ID twice. The fix is either a mutex around the increment or, on GCC/Clang, an atomic increment via <stdatomic.h>'s atomic_fetch_add. I've genuinely seen a duplicate-invoice-number bug in production trace back to precisely this β a static counter that was perfectly correct in a single-threaded test harness and silently wrong the moment the service went multi-threaded under real Diwali-sale traffic.
const and volatile β Two Qualifiers People Confuse With Each Other
const means read-only after initialization, as far as the compiler is concerned β it is not the same thing as a compile-time constant. const int rate = getInterestRate(); is completely legal; the value is only known at runtime, but the compiler will refuse to let you reassign rate afterward, catching accidental mutation as a compile error instead of a runtime bug.
volatile solves a different, almost opposite problem: it tells the compiler 'don't optimise away reads or writes to this variable β its value can change for reasons you can't see in this code.' This matters directly for memory-mapped hardware registers and variables shared with an interrupt service routine (ISR).
// Firmware for a UART receive handler on an STM32-class microcontroller.
// dataReady is set to 1 inside the interrupt handler when a byte arrives.
volatile int dataReady = 0; // WITHOUT volatile, -O2 assumes this never
// changes inside the loop below and optimises
// the whole while loop into an infinite spin
void UART_IRQHandler(void) {
dataReady = 1; // set by hardware interrupt, asynchronously
}
void waitForByte(void) {
while (dataReady == 0) {
// spin until the ISR sets the flag
}
dataReady = 0;
}Drop the volatile keyword here and the bug is genuinely nasty to find: it compiles cleanly, runs fine at -O0 during early bring-up testing, and then hangs forever the moment someone builds the release firmware with -O2 β because the optimiser sees dataReady never gets written anywhere inside waitForByte()'s visible code path and hoists the check into a genuinely infinite loop, since from its point of view the condition can never change. This is exactly the class of bug that only shows up after an optimisation-level change, which is precisely why it tends to appear right before a release build, not during daily debug-mode development.
How I Handle Variable Declarations in Practice
- βΆ
Initialize every local variable at the point of declaration β even `int result = 0;` instead of `int result;` costs nothing and removes an entire bug category outright.
- βΆ
Own each global variable in exactly one .c file β define it there, and use `extern` in every other file that needs it. This sidesteps the GCC 10 -fno-common trap entirely, regardless of which GCC version compiles the project.
- βΆ
Reach for static local variables over globals when state needs to persist within one function's job β an ID generator, a one-time-setup flag, a small internal cache β static locals keep that state invisible to the rest of the file instead of polluting global namespace.
- βΆ
Mark anything touched by an ISR or hardware register as volatile, without exception β the STM32-class bug above doesn't show up in early testing; treat volatile as mandatory the moment a variable crosses into interrupt or memory-mapped-hardware territory, not as an optional hardening step.
- βΆ
Use const liberally for parameters and locals that genuinely don't change β it documents intent for the next reader and turns an accidental reassignment into a compile-time error instead of a silent logic bug.
- βΆ
Compile with -Wall -Wextra from day one of a project β the uninitialized-variable warning that would have caught the discount bug above exists specifically for this; it's free and it's silent unless you enable it.
Mistakes That Come Up Again and Again
- βΆ
Assuming globals default to zero and locals do too β globals and static locals DO default to zero if uninitialized; ordinary automatic locals do NOT. Mixing these two rules up is one of the most common beginner assumptions that turns into a real bug.
- βΆ
Declaring the same global variable (no extern) in two .c files β worked under GCC's old -fcommon default, fails with `multiple definition of` under GCC 10+ and has always failed under Clang; the correct fix is one definition, extern everywhere else.
- βΆ
Treating const as 'this is a compile-time constant' β const only blocks reassignment after initialization; it says nothing about when the value was determined. A true compile-time constant in C uses `#define` or, in C23, `constexpr`.
- βΆ
Forgetting volatile on ISR-shared flags and only noticing at -O2 β the bug is invisible at -O0, which is exactly why it tends to survive all of development and only appear in the release build, often right before a deadline.
Variables β Interview Questions
Practice Questions
1. What is the output of three consecutive calls to this function? int nextId(void) { static int id = 0; id++; return id; }
Easy2. Will this compile cleanly, and if not, why? // file1.c int sharedTotal; // file2.c int sharedTotal;
Medium3. What's wrong with this code, assuming purchaseAmount can legitimately be 5000 or less? int computeDiscount(int purchaseAmount) { int discount; if (purchaseAmount > 5000) { discount = purchaseAmount / 10; } return discount; }
Medium4. What compile-time restriction does the register storage class add compared to a plain local variable?
Easy5. Why might this microcontroller loop hang forever only in a release (-O2) build but work fine in debug (-O0)? int flag = 0; void ISR(void) { flag = 1; } void wait(void) { while (flag == 0) {} }
Hard6. Is 'const int limit = readConfigValue();' valid C, given that readConfigValue() only returns its result at runtime?
MediumClosing Thoughts
Every trap in this article β the uninitialized discount bug, the GCC 10 linker error, the volatile-less firmware hang β compiled without a single error the first time. That's the pattern worth internalising about C variables specifically: the language trusts you completely, and the compiler only tells you about a mistake if you've asked it to, via -Wall, -Wextra, or a sanitizer.
My actual working rule after enough of these bugs: every variable declaration should answer three questions before you move on β who owns its storage, does it need to survive past this block, and could something outside this function's visible code change it. Get those three right and most of what's in this article stops being a risk.