Fundamentals of C

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

Why your syllabus (probably) stopped at the easy version

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

#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


Quick reference

ConceptKey syntax/toolPurposeSection
Uninitialized locals(implicit)Garbage value, NOT zero, unlike globals/static1
Fixed-width typesint32_t, uint8_t, etc. (<stdint.h>)Guaranteed exact-size integers3
Unsigned overflowwraps (defined)Modular arithmetic, safe to rely on4
Signed overflowundefined behaviorNever rely on any particular result4
Integer promotionautomatic, before most arithmeticSmall types promoted to at least int first5
Explicit cast(Type)expressionIntentional, visible type conversion6
Integer literal suffixesL, LL, u, UL, etc.Force a literal's exact type7
Float literal suffixfForce float instead of default double7
constconst Type name = value;Typed, scoped, compiler-enforced read-only variable8
enumenum { A, B, C };Named integer constants, true compile-time constants10
typedeftypedef Type Alias;Alias for an existing type, no new type safety11
static (variable)static Type name;Persists across calls (local) / internal linkage (file scope)12, 14
externextern Type name;Refer to a variable defined in another file14
Scopeblock { }, function, fileWhere a name is visible in the source text13
Linkageinternal (static) / external (default)Whether a file-scope name is visible to other files14
Float comparisontolerance/epsilon checkReliable way to compare floating-point values15
volatilevolatile Type name;Forbids the compiler from optimizing away reads/writes21
restrictType *restrict ptrPromise of no pointer aliasing, enables optimization22
sizeofsizeof(expr) or sizeof(Type)Compile-time size in bytes; doesn't evaluate its operand23
bool/true/false<stdbool.h>Real boolean type (C99+), built on _Bool24
Bit set/clear/toggle/check|=, &= ~, ^=, &The four fundamental bit-manipulation operations25

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.

Previous:
Standard I/O in C
Next:
Hermeneutor - Naming the Space Between Compilation and Interpretation