This guide covers the foundation everything else in this series sits on
top of - variables, constants, data types, operators, storage classes,
and scope - but goes past the "int, float, char, if, for" version most
courses teach. The goal is the version of these fundamentals a working C
programmer actually needs: what a type's size really depends on, why
unsigned overflow is defined but signed overflow isn't, why two
floats that "should" be equal often aren't, and why i++ + i++ isn't
just bad style - it's undefined behavior.
0. Why the deeper fundamentals matter, why industry cares, and why your syllabus stops at the easy version
Why they're needed at all
C was designed to be close to the machine - a char is a byte, an int
is (roughly) a machine word, arithmetic maps almost directly to CPU
instructions. That closeness is exactly why C's "basics" have sharp edges
that higher-level languages hide from you entirely: type sizes that vary
by platform, signed integer overflow that's undefined rather than
wrapping predictably, implicit conversions that silently change a value's
meaning, and expression evaluation orders the standard deliberately
leaves unspecified. None of this is optional trivia - it's the actual
behavior of the programs you write, whether or not anyone taught it to
you.
Why real projects care so much
- Undefined behavior is a real, exploitable category of bug, not an
academic footnote - signed integer overflow, unsequenced modifications
like
i = i++ + ++i, and uninitialized reads are all UB that compilers are legally allowed to "optimize" in ways that produce surprising, sometimes security-relevant, results. - Portability depends on knowing what's guaranteed vs. what isn't.
intbeing 4 bytes is true on most desktop platforms today but is not guaranteed by the standard - code that assumes it breaks on some embedded targets. This is exactly why<stdint.h>'s fixed-width types (section 3) exist and are standard practice in portable/systems code. - Floating-point bugs are notoriously subtle and common. Comparing
floats with
==, assuming decimal arithmetic is exact, or ignoring accumulated rounding error are recurring real-world bugs - from financial software to game physics - that all trace back to not understanding IEEE-754 basics (section 15). - Bit manipulation is everywhere in systems code. Flags, hardware registers, network protocol fields, and compact data structures all rely on masking, shifting, and understanding exactly how operators behave at the bit level (section 25).
- Storage class and linkage decisions shape how large programs are
organized.
static,extern, and scope rules (sections 12–14) are the actual mechanism behind "this variable is private to this file" or "this variable is shared across the whole program" - decisions every multi-file C project has to make deliberately.
Why your syllabus (probably) stopped at the easy version
- A first course needs students to succeed quickly, so it teaches "here are the types, here's how to declare a variable" and moves on to control flow and functions - the deep edge cases would just slow down a student who's still learning what a variable even is.
- Undefined behavior is intentionally scary to teach early - showing
a beginner that
i++ + i++might do three different things on three different compilers is more likely to cause panic than build understanding, so it's usually deferred (if covered at all). - Floating-point precision and IEEE-754 is genuinely a topic of its own - many courses assume "just don't compare floats with ==" is enough guidance and never explain why, which leaves you unequipped to reason about it in genuinely tricky cases.
volatile,restrict, and precise storage/linkage rules are systems programming concepts that simply don't come up in typical homework - you don't needvolatileuntil you're reading a hardware register or writing a signal handler, which an intro course never asks you to do.
Once you actually see why signed overflow is undefined (versus
unsigned, which wraps predictably by design), or watch two visually
identical float values fail an == check, C's fundamentals stop
feeling like arbitrary rules and start feeling like a coherent (if sharp-
edged) model of how the machine actually works.
1. Variables: declaration, definition, initialization
A variable is a named piece of storage with a type. Declaring a variable (in this context) reserves the storage; initializing it gives it a starting value at the same time - an uninitialized local variable holds garbage, not zero.
#include <stdio.h>
int main(void) {
int a; // declared, but UNINITIALIZED -- holds garbage, indeterminate value
int b = 5; // declared AND initialized
printf("%d\n", b); // fine: 5
// printf("%d\n", a); // technically undefined behavior -- reading an
// indeterminate value; may "work" by accident,
// but is not guaranteed to print anything sane
a = 10; // NOW a has a defined value
printf("%d\n", a); // fine: 10
return 0;
}
Global and static variables are automatically zero-initialized if you
don't give them an explicit value - only plain local (automatic) variables
are left as garbage. This distinction trips up a lot of beginners moving
between the two.
2. Data types and their (platform-dependent) sizes
C guarantees minimum sizes and relative orderings for its built-in
types, but not exact sizes - sizeof(int) is commonly 4 bytes today, but
the standard doesn't promise that on every platform.
#include <stdio.h>
int main(void) {
printf("char: %zu byte(s)\n", sizeof(char)); // guaranteed to be 1
printf("short: %zu byte(s)\n", sizeof(short)); // at least 2
printf("int: %zu byte(s)\n", sizeof(int)); // at least 2, commonly 4
printf("long: %zu byte(s)\n", sizeof(long)); // at least 4, commonly 4 or 8
printf("long long: %zu byte(s)\n", sizeof(long long)); // at least 8
printf("float: %zu byte(s)\n", sizeof(float)); // commonly 4 (IEEE-754 single)
printf("double: %zu byte(s)\n", sizeof(double)); // commonly 8 (IEEE-754 double)
printf("pointer: %zu byte(s)\n", sizeof(int *)); // commonly 4 (32-bit) or 8 (64-bit)
return 0;
}
The standard only guarantees sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long) - never hardcode an
assumed size for anything except char; use sizeof or the fixed-width
types from section 3 instead.
3. Fixed-width integer types (<stdint.h>)
When you need a type of a guaranteed exact size - for a file format, a
network protocol, or just to stop worrying about platform differences -
<stdint.h> provides exact-width types instead of the platform-dependent
built-ins.
#include <stdio.h>
#include <stdint.h>
int main(void) {
int8_t a = -128; // EXACTLY 1 byte, signed, guaranteed
uint8_t b = 255; // EXACTLY 1 byte, unsigned, guaranteed
int32_t c = -2000000000; // EXACTLY 4 bytes, signed, guaranteed
uint32_t d = 4000000000u; // EXACTLY 4 bytes, unsigned, guaranteed
int64_t e = -9000000000000000000LL; // EXACTLY 8 bytes, signed
printf("%d %u %d %u %lld\n", a, b, c, d, (long long)e);
printf("sizeof(int32_t) = %zu\n", sizeof(int32_t)); // always 4, on every conforming platform
return 0;
}
For real-world portable code - binary file formats, network protocols,
anything shared across compilers/platforms - <stdint.h> types are the
standard choice over plain int/long.
4. Signed vs. unsigned integers, and overflow behavior
unsigned integer overflow is defined: it wraps around using modular
arithmetic. signed integer overflow is undefined behavior - the
compiler is not required to make it wrap predictably, and optimizing
compilers can (and do) exploit that assumption in surprising ways.
#include <stdio.h>
#include <limits.h>
int main(void) {
unsigned int u = UINT_MAX; // largest possible unsigned int
u = u + 1; // DEFINED: wraps around to 0
printf("unsigned overflow: %u\n", u); // 0
int s = INT_MAX; // largest possible signed int
s = s + 1; // UNDEFINED BEHAVIOR -- not guaranteed to wrap to INT_MIN,
// even though it often appears to on common platforms
printf("signed overflow (UB!): %d\n", s); // don't rely on this value at all
return 0;
}
This is not a minor technicality: compilers are legally permitted to assume signed overflow never happens and optimize based on that assumption, which has caused real security bugs when an overflow check written after the overflow (rather than before it) got silently eliminated by the optimizer.
5. Integer promotion and the "usual arithmetic conversions"
Before most arithmetic operators run, C automatically promotes smaller
integer types (char, short) up to at least int - and when operands
of different types are combined, C converts them to a common type
following a fixed set of rules ("the usual arithmetic conversions"). This
happens silently, and it can produce surprising results.
#include <stdio.h>
int main(void) {
char a = 100;
char b = 100;
// Both 'a' and 'b' are PROMOTED to int before the addition happens --
// the arithmetic itself is done as int, then (if assigned back to a
// char) truncated again. This addition does NOT overflow a char
// during the computation, because it isn't actually done in char.
int result = a + b;
printf("%d\n", result); // 200 -- correct, because promotion happened first
unsigned int u = 10;
int i = -5;
// 'i' gets converted to unsigned int before the comparison --
// -5 becomes a huge positive number as unsigned, so this is TRUE,
// which is almost never what a beginner expects.
if (i < u) {
printf("this looks wrong but is correct: -5 < 10u\n");
} else {
printf("i is NOT less than u (surprising!)\n"); // this branch actually runs
}
return 0;
}
The signed/unsigned comparison gotcha in particular is a real, common bug source - mixing signed and unsigned types in the same comparison or arithmetic expression is worth double-checking every time.
6. Implicit vs. explicit type conversion (casting)
C converts between types implicitly in many situations (assignment, mixed-type arithmetic, function arguments) - sometimes losing information silently. An explicit cast does the same conversion but marks it as intentional, which is both clearer to a reader and easier to grep for when auditing code.
#include <stdio.h>
int main(void) {
double pi = 3.14159;
int truncated = pi; // IMPLICIT conversion -- loses the fractional part silently
int explicitTruncated = (int)pi; // EXPLICIT cast -- same result, but clearly intentional
printf("%d %d\n", truncated, explicitTruncated); // 3 3
long bigValue = 100000L;
short small = bigValue; // IMPLICIT, and on a 16-bit short this would truncate/wrap
// with an implementation-defined result -- risky and silent
printf("%hd\n", small);
// Explicit casts are also how you force floating-point division
// instead of getting truncating integer division:
int a = 7, b = 2;
double result = (double)a / b; // cast ONE operand -- forces the whole expression to double math
printf("%f\n", result); // 3.500000, not 3 (which plain a / b would give)
return 0;
}
Prefer explicit casts anywhere a conversion is intentional and might lose information - it documents the intent and makes the narrowing visible to anyone reading the code later (including future you).
7. Literals and suffixes
A literal's exact type depends on its form and any suffix attached - this matters more than it looks, especially for arithmetic involving large numbers or floating-point precision.
#include <stdio.h>
int main(void) {
int decimal = 42;
int octal = 052; // leading 0 = octal -- this is ALSO 42 in decimal
int hex = 0x2A; // leading 0x = hexadecimal -- also 42
int binary = 0b101010; // leading 0b = binary (C23, widely supported earlier as an extension) -- also 42
long bigNumber = 1000000000L; // 'L' suffix -- forces 'long' type
long long hugeNumber = 10000000000LL; // 'LL' suffix -- forces 'long long', needed since
// this value doesn't fit in a 32-bit int OR long
unsigned int positiveOnly = 4000000000u; // 'u' suffix -- forces unsigned
float singlePrecision = 3.14f; // 'f' suffix -- forces float instead of the default double
double doublePrecision = 3.14; // no suffix -- floating-point literals default to double
char newline = '\n'; // escape sequence -- a single character, the newline byte
char tab = '\t';
char nullChar = '\0'; // the null terminator byte, value 0
printf("%d %d %d %ld %lld %u %f %f\n",
decimal, octal, hex, bigNumber, hugeNumber, positiveOnly,
singlePrecision, doublePrecision);
return 0;
}
A common real-world bug: writing 10000000000 (no suffix) where a
long long is needed - on a platform where int/long are only 4
bytes, that literal doesn't fit and either fails to compile or silently
picks a different, larger type than you assumed, depending on the exact
value and compiler.
8. const - true read-only variables
const marks a variable as read-only after initialization - the
compiler enforces this, catching accidental modification at compile time
rather than leaving it as a bug you discover at runtime.
#include <stdio.h>
int main(void) {
const double PI = 3.14159;
// PI = 3.0; // COMPILE ERROR -- PI is const, cannot be reassigned
const int MAX_USERS = 100;
int scores[MAX_USERS]; // fine to use as an array size (a compile-time constant expression)
printf("%f %d\n", PI, MAX_USERS);
return 0;
}
const isn't the same as a #define constant (section 9) - a const
variable is a real, typed variable with an address, just one the compiler
won't let you write to.
9. #define, const, and enum - three ways to define a constant, and when to use each
C has three common ways to define a constant value, each with different tradeoffs - knowing which to reach for is a real, practical decision in day-to-day C code.
#include <stdio.h>
#define MAX_RETRIES 3 // preprocessor: pure text substitution, no type, no scope
const int MAX_ATTEMPTS = 5; // const variable: typed, scoped, has a real address, debugger-visible
enum { MAX_CONNECTIONS = 10 }; // enum constant: typed as int, no memory address at all
// (a true compile-time constant, unlike a const variable)
int main(void) {
printf("%d %d %d\n", MAX_RETRIES, MAX_ATTEMPTS, MAX_CONNECTIONS);
int buffer[MAX_RETRIES]; // fine -- #define expands before compilation
int buffer2[MAX_ATTEMPTS]; // fine in modern C -- const int can size an array
// (this specific case needs a "constant expression";
// support/behavior for const-sized arrays has evolved
// across C standard versions -- enum is the most
// universally safe choice for old/strict compilers)
int buffer3[MAX_CONNECTIONS]; // fine everywhere -- enum constants are always true
// compile-time constant expressions
return 0;
}
General real-world guidance: prefer const for typed, debuggable
constants where you want type-checking and scoping; prefer enum for a
small integer constant needed in a strict compile-time context (like
old-style array sizes or switch cases); reserve #define for cases
const/enum can't handle (conditional compilation, generating code,
constants that must work across type boundaries).
10. Enumerations in depth
enum creates a set of named integer constants - beyond the simple case,
you can control the underlying values explicitly, which is common for
things like error codes or bit flags.
#include <stdio.h>
// Simple case: values auto-assigned starting from 0
enum Weekday { SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY };
// Explicit values -- common for error codes that need to match an external spec
enum ErrorCode {
ERROR_NONE = 0,
ERROR_NOT_FOUND = 404,
ERROR_SERVER = 500
};
// Bit-flag pattern -- each value is a distinct bit, so they can be OR'd together
enum Permissions {
PERM_READ = 1 << 0, // 0001
PERM_WRITE = 1 << 1, // 0010
PERM_EXECUTE = 1 << 2 // 0100
};
int main(void) {
enum Weekday today = WEDNESDAY;
printf("today = %d\n", today); // 3
enum ErrorCode err = ERROR_NOT_FOUND;
printf("err = %d\n", err); // 404
int myPermissions = PERM_READ | PERM_WRITE; // combine flags with bitwise OR
printf("can read: %d\n", (myPermissions & PERM_READ) != 0); // 1
printf("can execute: %d\n", (myPermissions & PERM_EXECUTE) != 0); // 0
return 0;
}
An enum's underlying type is implementation-defined (commonly int),
and - unlike some other languages - plain C provides no automatic
bounds-checking; you can assign any int value to an enum variable,
valid member or not.
11. typedef for custom type names
typedef creates an alias for an existing type - used constantly for
struct types (see the structs guide), but equally useful for giving
meaningful names to primitive types, improving both readability and
portability.
#include <stdio.h>
typedef unsigned int UserId; // a meaningful name instead of a bare "unsigned int"
typedef double Meters; // documents the UNIT a value represents
typedef int (*CompareFn)(int, int); // typedef for a function pointer type -- makes the
// ugly function-pointer syntax dramatically more readable
int ascending(int a, int b) { return a - b; }
int main(void) {
UserId currentUser = 1001;
Meters distance = 5.2;
CompareFn cmp = ascending; // MUCH more readable than "int (*cmp)(int, int) = ascending;"
printf("%u %f %d\n", currentUser, distance, cmp(3, 7));
return 0;
}
typedef doesn't create a genuinely new, distinct type the way some
other languages' equivalents do - UserId is still exactly an
unsigned int as far as the type system is concerned, so it won't catch
you passing a plain unsigned int where a UserId was expected. It's a
readability tool, not a type-safety mechanism.
12. Storage classes: auto, static, extern, register
A variable's storage class controls its lifetime, default
initialization, and (for static/extern) its visibility across files.
#include <stdio.h>
int globalCounter; // implicitly 'extern' at file scope -- visible to other files too
void demo(void) {
auto int localVar = 1; // 'auto' is the (almost never written) default for
// ordinary local variables -- rarely used explicitly
static int callCount = 0; // persists across calls; initialized ONCE, ever
callCount++;
register int fastCounter = 0; // HINT to keep this in a CPU register if possible --
// modern compilers mostly ignore this and optimize
// better on their own; 'register' also disallows
// taking the variable's address with '&'
for (int i = 0; i < 1000; i++) {
fastCounter++;
}
printf("call #%d, localVar=%d, fastCounter=%d\n", callCount, localVar, fastCounter);
}
int main(void) {
demo(); // call #1
demo(); // call #2 -- callCount persisted, localVar did NOT
return 0;
}
register is largely a historical relic today - modern optimizing
compilers make better register-allocation decisions than a programmer's
hint typically does, so it's rarely used in new code.
13. Scope: block, function, file, and program scope
Scope determines where in the source code a name is visible - a separate concept from storage class (which determines how long the underlying storage lasts).
#include <stdio.h>
int fileScoped = 100; // FILE scope -- visible anywhere below this point in this file
void demonstrateScope(void) {
int functionScoped = 1; // FUNCTION scope -- visible anywhere in this function's body
if (functionScoped) {
int blockScoped = 2; // BLOCK scope -- visible ONLY within these { }
printf("%d %d %d\n", fileScoped, functionScoped, blockScoped);
}
// printf("%d\n", blockScoped); // COMPILE ERROR -- blockScoped is out of scope here
}
int main(void) {
demonstrateScope();
// printf("%d\n", functionScoped); // COMPILE ERROR -- not visible in main at all
printf("%d\n", fileScoped); // fine -- file scope reaches main() too
return 0;
}
A variable declared inside a { } block - including a for/if/while
condition's own scope in modern C - only exists and is only visible
within that block, disappearing (conceptually) the moment execution
leaves it.
14. Linkage: internal vs. external
Linkage determines whether a file-scope name can be seen from other
.c files after separate compilation - a different axis entirely from
scope (which is about visibility within this file's source text).
/* utils.c */
int sharedCounter = 0; // EXTERNAL linkage (the default) -- visible to other files
static int privateCounter = 0; // INTERNAL linkage -- only visible within utils.c,
// even though its SCOPE (file scope) looks identical
// to sharedCounter's
void increment(void) {
sharedCounter++;
privateCounter++;
}
/* main.c */
extern int sharedCounter; // "this variable is defined in ANOTHER file -- link to it"
// extern int privateCounter; // would COMPILE, but FAIL TO LINK -- privateCounter has
// no external linkage, so the linker can't find it
// outside utils.c no matter how it's declared here
void increment(void);
int main(void) {
increment();
increment();
printf("%d\n", sharedCounter); // 2 -- successfully shared across files
return 0;
}
This is exactly the same static mechanism covered for functions in the
functions guide (section 11 there) - applied here to variables instead,
but the underlying idea (internal vs. external linkage) is identical.
15. Floating-point representation and precision pitfalls
float/double use IEEE-754 binary floating-point, which cannot
represent most decimal fractions exactly - the same way 1/3 has no
exact finite decimal representation, values like 0.1 have no exact
finite binary representation. This causes real, recurring bugs.
#include <stdio.h>
int main(void) {
double a = 0.1;
double b = 0.2;
double sum = a + b;
printf("%.17f\n", sum); // NOT exactly 0.3 -- shows the actual stored value,
// something like 0.30000000000000004
if (sum == 0.3) { // almost always FALSE, due to accumulated rounding error
printf("equal\n");
} else {
printf("NOT equal (as expected)\n"); // this branch runs
}
// The correct way to compare floating-point values: check they're
// within a small tolerance ("epsilon") of each other, not exactly equal.
double epsilon = 1e-9;
if (sum - 0.3 < epsilon && sum - 0.3 > -epsilon) {
printf("equal within tolerance\n"); // this is the reliable check
}
return 0;
}
This isn't a C-specific quirk - it's a property of binary floating-point
itself, shared by essentially every language that uses IEEE-754 (which is
almost all of them) - but it's C's fundamentals course where most
programmers first need to understand why == on floats is unreliable.
16. Character types: char signedness, and char vs. int
Whether plain char is signed or unsigned is implementation-defined
- it varies by platform and compiler. This matters more than it sounds
like, especially when using
charto store small numeric values or when working with functions likegetchar().
#include <stdio.h>
#include <limits.h>
int main(void) {
printf("Is plain char signed? %s\n", (CHAR_MIN < 0) ? "yes" : "no");
char c = -1;
// Whether this prints -1 or 255 depends on whether 'char' is signed
// or unsigned on this platform -- portable code should use 'signed
// char' or 'unsigned char' explicitly when the sign actually matters.
printf("%d\n", c);
// getchar() returns an int, NOT a char, specifically so it has enough
// range to represent EOF (typically -1) as a value distinct from any
// real character -- a critical, commonly-missed detail:
int ch;
printf("Type a character: ");
ch = getchar(); // correctly declared as int
if (ch == EOF) {
printf("Got EOF\n");
} else {
printf("Got character: %c\n", ch);
}
// char ch2 = getchar(); // WRONG on some platforms: if char is
// unsigned, EOF (-1) can never compare equal to it, breaking EOF
// detection in exactly the input loops covered in the standard I/O guide
return 0;
}
This is exactly why the standard I/O guide's read loops (while ((c = fgetc(fp)) != EOF)) always declare the loop variable as int, never
char - using char there is a real, if subtle, portability bug.
17. Operators: arithmetic, relational, logical, and bitwise
C's operator families each serve a distinct purpose - logical operators work on truthiness (zero vs. non-zero), while bitwise operators work on the actual bit pattern, and mixing them up is a common beginner mistake.
#include <stdio.h>
int main(void) {
// Arithmetic
printf("%d %d %d %d %d\n", 7 + 3, 7 - 3, 7 * 3, 7 / 3, 7 % 3); // 10 4 21 2 1
// Relational -- produce 0 (false) or 1 (true)
printf("%d %d %d\n", 5 > 3, 5 == 5, 5 != 3); // 1 1 1
// Logical -- operate on TRUTHINESS (any nonzero is "true"), short-circuit (section 19)
printf("%d %d %d\n", (1 && 1), (0 || 1), !0); // 1 1 1
// Bitwise -- operate on the actual BITS, completely different from logical operators
printf("%d %d %d %d\n", 6 & 3, 6 | 3, 6 ^ 3, ~6); // AND, OR, XOR, NOT (bit-level)
printf("%d %d\n", 1 << 3, 16 >> 2); // left shift (x8), right shift (/4)
// Common beginner mistake: & vs && are NOT interchangeable
int a = 2, b = 4;
printf("%d\n", a && b); // logical AND: both nonzero -> 1
printf("%d\n", a & b); // bitwise AND: 2 (010) & 4 (100) -> 0 (completely different result!)
return 0;
}
&&/|| on non-Boolean operands ("truthiness") is a distinct concept
from &/| on the actual bit pattern - conflating them is a real,
common bug, especially since & and && look so similar.
18. Operator precedence and associativity
Like arithmetic in math class, C operators have a defined precedence (which runs first) and associativity (left-to-right or right-to-left for operators of equal precedence) - getting this wrong silently produces the wrong answer, not a compile error.
#include <stdio.h>
int main(void) {
// Precedence: * and / bind tighter than + and -, just like in math class
int result = 2 + 3 * 4; // 3*4 happens first: 2 + 12 = 14, NOT (2+3)*4 = 20
printf("%d\n", result);
// A classic real gotcha: bitwise operators have LOWER precedence
// than relational operators, opposite of what many people assume:
int flags = 5;
// if (flags & 1 == 1) ... // DANGEROUS: parses as flags & (1 == 1), NOT (flags & 1) == 1!
if ((flags & 1) == 1) { // explicit parentheses -- the CORRECT, unambiguous version
printf("odd\n");
}
// Associativity: assignment is RIGHT-to-left
int a, b, c;
a = b = c = 5; // evaluates as a = (b = (c = 5)) -- all three end up as 5
printf("%d %d %d\n", a, b, c);
return 0;
}
The safest practical rule: whenever precedence isn't immediately obvious to a reader (not just to you, right now), add explicit parentheses - it costs nothing and eliminates an entire class of bug.
19. Short-circuit evaluation
&& and || stop evaluating as soon as the result is already known -
the right-hand operand is never evaluated if the left-hand one already
determines the outcome. This isn't just an optimization; real code relies
on it for correctness.
#include <stdio.h>
int dangerousCheck(int *ptr) {
// This is SAFE specifically because of short-circuit evaluation:
// if ptr is NULL, the left side of && is false, so *ptr is NEVER
// evaluated -- if && didn't short-circuit, this would dereference
// a NULL pointer and crash.
if (ptr != NULL && *ptr > 0) {
return 1;
}
return 0;
}
int sideEffect(void) {
printf("sideEffect() was called\n");
return 1;
}
int main(void) {
printf("%d\n", dangerousCheck(NULL)); // 0, and safely so -- no crash
printf("Testing ||: ");
if (1 || sideEffect()) { // sideEffect() is NEVER called -- left side is already true
printf("done\n");
}
printf("Testing &&: ");
if (0 && sideEffect()) { // sideEffect() is NEVER called -- left side is already false
printf("done\n");
}
printf("(sideEffect was never printed above, by design)\n");
return 0;
}
This "null check before dereference" pattern (ptr != NULL && *ptr > 0)
is extremely common in real C code, and depends entirely on &&'s
short-circuit guarantee - this is defined, guaranteed behavior in the C
standard, not an implementation detail you're relying on by luck.
20. Sequence points and undefined behavior in expressions
The C standard does not guarantee a left-to-right evaluation order for most expressions, and it's undefined behavior to both modify a variable and read it (for a purpose other than determining the new value) more than once without an intervening "sequence point." This produces genuinely different results on different compilers.
#include <stdio.h>
int main(void) {
int i = 1;
// UNDEFINED BEHAVIOR: 'i' is modified twice (by each i++) with no
// sequence point between them, AND used to compute the result at
// the same time -- the compiler is free to do essentially anything.
// int result = i++ + i++; // don't write this -- ever
// Function call boundaries and && / || / comma / ?: ARE sequence
// points, so this is safe, well-defined, ordinary code:
int a = 1;
a = a + 1; // fine -- a single, clear modification
printf("%d\n", a); // 2, guaranteed
// Function arguments: the ORDER in which arguments are evaluated is
// ALSO unspecified (though each individual argument's own evaluation
// is safe) -- don't rely on argument evaluation order for side effects:
int x = 0;
// printf("%d %d\n", x++, x++); // unspecified which x++ runs first;
// avoid relying on argument order
return 0;
}
The practical takeaway: never modify the same variable more than once in a single expression without a clear sequence point (like the end of a full statement, or a function call boundary) between the modifications - if you're not sure whether an expression is safe, split it into separate statements instead.
21. The volatile qualifier
volatile tells the compiler "this variable's value can change outside
this program's normal control flow" (a hardware register, a value
modified by a signal handler or another thread) - so the compiler must
never optimize away a read or write to it, even if it looks
redundant.
#include <stdio.h>
// Imagine this represents a memory-mapped hardware status register that
// can change value due to hardware activity, independent of this program:
volatile int hardwareStatus;
void waitForReady(void) {
// WITHOUT volatile, an optimizing compiler might assume hardwareStatus
// never changes inside this loop (since nothing in the loop body
// itself writes to it) and "optimize" this into an infinite loop that
// reads the value only ONCE. volatile forbids that optimization,
// forcing a fresh read every single iteration.
while (hardwareStatus == 0) {
// busy-wait for hardware to signal readiness
}
}
int main(void) {
hardwareStatus = 0;
// In real code, an interrupt handler or another thread would set
// hardwareStatus to nonzero asynchronously; simulated here directly:
hardwareStatus = 1;
waitForReady();
printf("ready!\n");
return 0;
}
volatile is unrelated to thread-safety in the way many people initially
assume - it prevents a specific compiler optimization, but provides no
atomicity or memory-ordering guarantees; real multithreaded code needs
<stdatomic.h> or platform threading primitives, not just volatile.
22. The restrict qualifier (C99)
restrict on a pointer parameter is a promise to the compiler: "for the
lifetime of this pointer, no other pointer will be used to access the
same memory" - letting the compiler make optimizations it couldn't safely
make if two pointers might alias (overlap) the same memory.
#include <stdio.h>
// Promising the compiler that 'dest' and 'src' never overlap allows more
// aggressive optimization (e.g. vectorization) than it could safely do
// otherwise, since it doesn't need to worry about a write through 'dest'
// changing a value that's about to be read through 'src'.
void copyArray(int *restrict dest, const int *restrict src, int count) {
for (int i = 0; i < count; i++) {
dest[i] = src[i];
}
}
int main(void) {
int source[5] = {1, 2, 3, 4, 5};
int destination[5];
copyArray(destination, source, 5); // fine -- genuinely non-overlapping arrays
// Violating the promise is undefined behavior -- the compiler is
// trusting you, not checking you:
// copyArray(source + 1, source, 4); // OVERLAPPING memory passed to
// a restrict-qualified pair --
// undefined behavior if this
// actually happened
for (int i = 0; i < 5; i++) printf("%d ", destination[i]);
printf("\n");
return 0;
}
restrict is purely a compiler hint/promise with no runtime check
whatsoever - violating it doesn't produce an error, it produces
undefined behavior, which is exactly why it's used sparingly and only
where the non-aliasing guarantee is actually certain.
23. The sizeof operator in depth
sizeof is evaluated at compile time for most uses (the exception is
variable-length arrays), returns a size_t, and works on both types and
expressions - several subtleties here are frequent sources of bugs.
#include <stdio.h>
int main(void) {
int arr[10];
printf("%zu\n", sizeof(arr)); // 40 (10 * sizeof(int)) -- size of the WHOLE array
printf("%zu\n", sizeof(arr) / sizeof(arr[0])); // 10 -- the standard idiom for element COUNT
int *ptr = arr;
printf("%zu\n", sizeof(ptr)); // size of a POINTER (e.g. 8), NOT the array --
// this is the exact bug from the functions guide's
// "array parameters decay to pointers" section
// sizeof on an EXPRESSION does not actually evaluate it -- only its TYPE matters:
int x = 5;
printf("%zu\n", sizeof(x++)); // size of int, e.g. 4
printf("%d\n", x); // still 5 -- x++ was NEVER actually executed!
// sizeof returns an UNSIGNED type (size_t) -- mixing it with signed
// arithmetic can trigger the signed/unsigned comparison gotcha from
// section 5:
int negativeOne = -1;
if (negativeOne < sizeof(arr)) { // -1 gets converted to a huge unsigned value here!
printf("this looks right but for the wrong reason\n");
}
return 0;
}
The sizeof(arr) / sizeof(arr[0]) idiom for "number of elements in this
array" only works when arr is an actual array in the current scope -
the moment it's passed to a function and decays to a pointer, this idiom
silently computes the wrong thing (section 23 above, and section 6 of the
functions guide).
24. _Bool and <stdbool.h>
C99 added a real boolean type, _Bool - conventionally accessed through
the more readable bool/true/false macros from <stdbool.h>. Before
C99, C code used plain int for booleans, and a lot of real-world code
still does, out of habit or for pre-C99 compatibility.
#include <stdio.h>
#include <stdbool.h>
bool isEven(int n) {
return n % 2 == 0; // any nonzero value converts to true, zero converts to false
}
int main(void) {
bool flag = true;
printf("%d\n", flag); // prints 1 -- bool is really just _Bool, printed as an integer
printf("%d\n", isEven(4)); // 1 (true)
printf("%d\n", isEven(7)); // 0 (false)
// _Bool (and therefore bool) can only ever hold 0 or 1 -- ANY nonzero
// value assigned to it is converted to 1, not stored as-is:
bool b = 42;
printf("%d\n", b); // 1, not 42 -- this is _Bool's defined conversion behavior
return 0;
}
sizeof(bool) is implementation-defined but is typically 1 byte -
smaller than the int (typically 4 bytes) that pre-C99 code used to
represent booleans, which matters slightly for large arrays of flags.
25. Bit manipulation: masks, shifting, and flag patterns
Working directly with individual bits - setting, clearing, toggling, and
checking them - is a fundamental technique across embedded programming,
protocol parsing, and compact flag storage (and underlies the enum
bit-flag pattern from section 10).
#include <stdio.h>
int main(void) {
unsigned int flags = 0;
#define FLAG_A (1 << 0) // 0001
#define FLAG_B (1 << 1) // 0010
#define FLAG_C (1 << 2) // 0100
flags |= FLAG_A; // SET a bit: OR with the mask
flags |= FLAG_C;
printf("after setting A, C: %d\n", flags); // 5 (0101)
flags &= ~FLAG_A; // CLEAR a bit: AND with the INVERTED mask
printf("after clearing A: %d\n", flags); // 4 (0100)
flags ^= FLAG_B; // TOGGLE a bit: XOR with the mask
printf("after toggling B: %d\n", flags); // 6 (0110)
flags ^= FLAG_B; // toggling again flips it back
printf("after toggling B again: %d\n", flags); // 4 (0100)
int isSet = (flags & FLAG_C) != 0; // CHECK a bit: AND with the mask, compare to 0
printf("is C set? %d\n", isSet); // 1
// Extracting a multi-bit field, e.g. bits 4-7 of a byte:
unsigned char byte = 0b10110101;
unsigned char upperNibble = (byte >> 4) & 0x0F; // shift into position, then mask off the rest
printf("upper nibble: %d\n", upperNibble); // 11 (1011)
return 0;
}
This set/clear/toggle/check pattern using |=, &= ~, ^=, and & is
close to universal across real-world C - the exact same four operations
appear whether you're managing feature flags, hardware registers, or
network protocol fields.
26. Common pitfalls checklist
Reading an uninitialized local variable - holds garbage, not zero; only globals and
staticlocals are zero-initialized automatically (section 1).Assuming
sizeof(int)(or any type exceptchar) is a fixed, portable size - only relative orderings are guaranteed; use<stdint.h>types when an exact size actually matters (sections 2, 3).Relying on signed integer overflow wrapping predictably - it's undefined behavior, unlike unsigned overflow, which is well-defined (section 4).
Mixing signed and unsigned values in comparisons/arithmetic without realizing the signed value gets silently converted to unsigned first - a frequent, subtle bug source (section 5).
Comparing floating-point values with
==instead of a tolerance- based comparison - almost never reliable due to binary floating-point rounding (section 15).Declaring
getchar()'s result ascharinstead ofint- breaks EOF detection on platforms where plaincharis unsigned (section 16).Confusing
&/|(bitwise) with&&/||(logical) - they operate on completely different things and can silently produce very different results (section 17).Assuming
&binds tighter than==- it doesn't; always parenthesize bitwise operations mixed with comparisons (section 18).Modifying the same variable more than once in one expression without a sequence point (e.g.
i++ + i++) - undefined behavior, not just "compiler-dependent" in a safe sense (section 20).Using
sizeof(arr) / sizeof(arr[0])on a pointer instead of a real array - silently computes garbage once the array has decayed to a pointer, e.g. after being passed to a function (section 23).
Quick reference
| Concept | Key syntax/tool | Purpose | Section |
|---|---|---|---|
| Uninitialized locals | (implicit) | Garbage value, NOT zero, unlike globals/static | 1 |
| Fixed-width types | int32_t, uint8_t, etc. (<stdint.h>) | Guaranteed exact-size integers | 3 |
| Unsigned overflow | wraps (defined) | Modular arithmetic, safe to rely on | 4 |
| Signed overflow | undefined behavior | Never rely on any particular result | 4 |
| Integer promotion | automatic, before most arithmetic | Small types promoted to at least int first | 5 |
| Explicit cast | (Type)expression | Intentional, visible type conversion | 6 |
| Integer literal suffixes | L, LL, u, UL, etc. | Force a literal's exact type | 7 |
| Float literal suffix | f | Force float instead of default double | 7 |
const | const Type name = value; | Typed, scoped, compiler-enforced read-only variable | 8 |
enum | enum { A, B, C }; | Named integer constants, true compile-time constants | 10 |
typedef | typedef Type Alias; | Alias for an existing type, no new type safety | 11 |
static (variable) | static Type name; | Persists across calls (local) / internal linkage (file scope) | 12, 14 |
extern | extern Type name; | Refer to a variable defined in another file | 14 |
| Scope | block { }, function, file | Where a name is visible in the source text | 13 |
| Linkage | internal (static) / external (default) | Whether a file-scope name is visible to other files | 14 |
| Float comparison | tolerance/epsilon check | Reliable way to compare floating-point values | 15 |
volatile | volatile Type name; | Forbids the compiler from optimizing away reads/writes | 21 |
restrict | Type *restrict ptr | Promise of no pointer aliasing, enables optimization | 22 |
sizeof | sizeof(expr) or sizeof(Type) | Compile-time size in bytes; doesn't evaluate its operand | 23 |
bool/true/false | <stdbool.h> | Real boolean type (C99+), built on _Bool | 24 |
| Bit set/clear/toggle/check | |=, &= ~, ^=, & | The four fundamental bit-manipulation operations | 25 |
Coverage note
This guide covers the parts of "the basics" that a first course typically
skips or rushes: exact type sizes and portability, signed vs. unsigned
overflow, implicit conversion and promotion rules, floating-point
precision, storage class and linkage, volatile/restrict, sequence
points and undefined behavior, and bit manipulation. Combined with the
macros, structs/unions, functions, file handling, and standard I/O guides
in this "Realworld C" collection, this covers the complete practical
foundation for reading, writing, and debugging real-world C code.
Standard I/O in C
Hermeneutor - Naming the Space Between Compilation and Interpretation