CFoundations

C Variables, Types & Memory Layout: A Complete Guide (C23)

TT
TopicTrick Team
C Variables, Types & Memory Layout: A Complete Guide (C23)

C Variables, Types & Memory Layout: A Complete Guide (C23)


Table of Contents


Variables as Memory Regions

When you declare a variable in C, the compiler allocates a specific block of bytes in memory and associates a name with it. This is fundamentally different from dynamic languages.

c
int age = 25;

What actually happens here:

  1. The compiler reserves 4 bytes on the stack (on a typical 64-bit system).
  2. The binary representation of 25 (0x00000019) is written to those 4 bytes.
  3. The name age is a compile-time alias for the address of that memory region.

After compilation, the name age literally disappears; the generated machine code refers only to the memory address. This is why C compilers can generate code so efficiently — by the time the binary runs, there's no symbol table to look up, just raw memory addresses.


C's Fundamental Types and Their Sizes

C provides several built-in types, each with platform-dependent sizes. This is both a source of flexibility and a common source of bugs:

TypeTypical SizeRange
char1 byte-128 to 127
unsigned char1 byte0 to 255
short2 bytes-32,768 to 32,767
int4 bytes~±2.1 billion
long4 or 8 bytes*Platform-dependent
long long8 bytes~±9.2 × 10¹⁸
float4 bytesIEEE 754 single precision
double8 bytesIEEE 754 double precision

[!WARNING] Platform Dependency is Real. On a 32-bit Linux, long is 4 bytes. On a 64-bit Linux, long is 8 bytes. On Windows 64-bit, long is still 4 bytes (LLP64 vs LP64 data model). Code that assumes long is a specific size will silently break when ported. This is why stdint.h exists.

Why int is 4 Bytes

Modern CPUs are optimized to work with data that matches their register width or common word boundaries. On a 32-bit architecture, int was the "natural" register size. On 64-bit systems, int remains 4 bytes by convention — partly for historical ABI compatibility and partly because 32-bit integers are still the most common use case for counters and loop indices.


Fixed-Width Types with stdint.h

The <stdint.h> header defines types with guaranteed, explicit sizes on any platform. This is what you should use for all systems-level code:

c
#include <stdint.h>
#include <stdio.h>

int main(void) {
    int8_t   tiny   =  127;    // Exactly 8 bits, signed
    uint8_t  byte_val = 255;   // Exactly 8 bits, unsigned
    int16_t  word  =  32767;   // Exactly 16 bits, signed
    uint32_t count = 4000000U; // Exactly 32 bits, unsigned
    int64_t  bignum = -9223372036854775807LL; // Exactly 64 bits
    
    printf("uint32_t size: %zu bytes\n", sizeof(uint32_t)); // Always 4
    printf("int64_t size:  %zu bytes\n", sizeof(int64_t));  // Always 8
    
    return 0;
}

The naming convention: intN_t for signed, uintN_t for unsigned, where N is the bit count (8, 16, 32, 64).

Also useful from <stdint.h>:

  • intptr_t / uintptr_t: An integer guaranteed to be large enough to hold any pointer (4 bytes on 32-bit, 8 bytes on 64-bit).
  • int_fast32_t: The fastest available type at least 32 bits wide on the current platform.
  • int_least32_t: The smallest available type at least 32 bits wide.

C23: Automatic Type Inference with auto

One of the most significant additions to C23 is the auto keyword for type inference (similar to C++'s auto, introduced in C++11). The compiler deduces the type from the initializer expression at compile time — there is zero runtime cost and no loss of type safety.

c
#include <stdio.h>
#include <stdint.h>

int main(void) {
    auto score       = 100;       // Deduced as int
    auto temperature = 98.6;      // Deduced as double
    auto ratio       = 3.14f;     // Deduced as float
    auto count       = 100ULL;    // Deduced as unsigned long long
    
    // auto also works with pointers
    auto *message    = "Hello, C23!"; // Deduced as const char *
    
    printf("Score type size:    %zu\n", sizeof(score));       // 4
    printf("Temperature size:   %zu\n", sizeof(temperature)); // 8
    printf("Count size:         %zu\n", sizeof(count));       // 8
    
    return 0;
}

[!IMPORTANT] C23's auto is not the same as var in JavaScript. The type is resolved at compile time and is permanent. Once the compiler deduces score as int, it is an int forever. This is type inference, not dynamic typing.

Use auto when the type is obvious from context (e.g., iterator variables, literals). Use explicit types when the type is an important part of the semantics (e.g., uint32_t packet_len makes the constraint visible to the reader).


The sizeof Operator

sizeof is not a function — it is a compile-time operator that returns the size of a type or expression in bytes. The return type is size_t (an unsigned integer type, typically 8 bytes on 64-bit systems).

c
#include <stdio.h>
#include <stdint.h>

struct Point {
    int32_t x;
    int32_t y;
};

int main(void) {
    // sizeof on types
    printf("char:    %zu byte(s)\n",  sizeof(char));
    printf("int:     %zu byte(s)\n",  sizeof(int));
    printf("double:  %zu byte(s)\n",  sizeof(double));
    printf("Point:   %zu byte(s)\n",  sizeof(struct Point));
    
    // sizeof on variables (equivalent, but safer in some contexts)
    int32_t value = 42;
    int32_t array[10];
    printf("value:   %zu byte(s)\n",  sizeof(value));        // 4
    printf("array:   %zu byte(s)\n",  sizeof(array));        // 40
    printf("elements:%zu\n", sizeof(array) / sizeof(array[0])); // 10

    return 0;
}

A critical pattern — safe array sizing:

c
// WRONG: hardcoding 10 is a maintenance nightmare
for (int i = 0; i < 10; i++) { ... }

// RIGHT: compute element count from sizeof
size_t n = sizeof(array) / sizeof(array[0]);
for (size_t i = 0; i < n; i++) { ... }

Variable Scope and Storage Duration

C variables have two orthogonal properties: scope (where the name is visible) and storage duration (how long the memory exists):

Storage ClassScopeLifetimeMemory Location
auto (default)BlockUntil block exitsStack
static (local)BlockEntire programBSS/Data
static (global)FileEntire programBSS/Data
register (hint)BlockUntil block exitsRegister (maybe)
externFile/globalEntire programDefined elsewhere
c
#include <stdio.h>

// Global variable: lives for entire program, zero-initialized
int global_counter = 0;

void increment(void) {
    // Static local: persists between function calls, not re-initialized
    static int call_count = 0;
    call_count++;
    global_counter++;
    printf("Called %d times, global=%d\n", call_count, global_counter);
}

int main(void) {
    increment(); // Called 1 times, global=1
    increment(); // Called 2 times, global=2
    increment(); // Called 3 times, global=3
    return 0;
}

Understanding static is critical for writing reentrant-safe code in multi-threaded programs, which we cover in Module 12.


Endianness: How Multi-Byte Values are Stored

When a multi-byte integer (like int32_t) is stored in memory, the CPU must decide the order of bytes. This is called endianness:

  • Little-Endian (x86-64, ARM by default): The least significant byte is at the lowest memory address.
  • Big-Endian (network byte order, some embedded systems): The most significant byte is at the lowest address.

For the value 0x12345678:

text
Little-Endian memory:  [78] [56] [34] [12]   ← x86/x64
Big-Endian memory:     [12] [34] [56] [78]   ← Network byte order

This matters enormously when you write binary data to a file or network socket on one machine and read it on another. The <arpa/inet.h> functions htonl() and ntohl() (host-to-network long and network-to-host long) handle this conversion explicitly — we cover them in detail in Module 15 (Sockets & TCP/IP).


Integer Overflow: The Silent Killer

C integers do not automatically detect overflow. When you exceed the maximum value of a type, the value wraps around — silently and without any error.

c
#include <stdio.h>
#include <stdint.h>

int main(void) {
    int8_t  max8  = 127;
    max8++;                     // Undefined behavior! Wraps to -128
    printf("%d\n", max8);       // Prints: -128
    
    uint8_t umax = 255;
    umax++;                     // Well-defined for unsigned: wraps to 0
    printf("%u\n", umax);       // Prints: 0
    
    return 0;
}

[!CAUTION] Signed integer overflow is undefined behavior in C, meaning the compiler is free to do anything — optimize away the branch, crash the program, or silently corrupt data. Always check bounds before arithmetic on signed integers, or use the __builtin_add_overflow() GCC/Clang intrinsic for safety-critical code.


Constants: const vs #define vs constexpr

C23 gives us three ways to define constants, each with different semantics:

c
#include <stdio.h>

// Method 1: #define (preprocessor text substitution, no type safety)
#define MAX_SIZE 1024

// Method 2: const (typed constant, but stored in memory, can be pointed to)
const int BUFFER_SIZE = 512;

// Method 3: C23 constexpr (compile-time constant, guaranteed no memory)
constexpr int CACHE_LINE_SIZE = 64;

int main(void) {
    int buffer[MAX_SIZE];       // Works at compile time
    // int buf2[BUFFER_SIZE];   // May or may not work (VLA territory pre-C99)
    int buf3[CACHE_LINE_SIZE];  // Always works — constexpr is a true constant
    
    printf("Cache line: %d bytes\n", CACHE_LINE_SIZE);
    return 0;
}

Best practice in C23: Prefer constexpr for named compile-time constants and const for values that should not be modified at runtime. Use #define only for feature flags and conditional compilation.


Frequently Asked Questions

What is the difference between signed and unsigned integers? A signed integer uses one bit to represent the sign (positive or negative), giving it a range of approximately -2^(N-1) to 2^(N-1)-1. An unsigned integer uses all bits for magnitude, giving it a range of 0 to 2^N-1 — twice as large on the positive side. Arithmetic on unsigned values wraps modulo 2^N (well-defined), while signed overflow is undefined behavior.

Why does memory alignment matter for performance? Modern CPUs read data from memory in aligned chunks matching their bus width (typically 8 bytes on x86-64). If a 4-byte int starts at address 0x003 (not divisible by 4), the CPU must perform two separate memory reads and combine the result — which is 2× slower. The compiler handles alignment automatically for individual variables, but you must manage it carefully when designing struct layouts (covered in Module 8).

Can a pointer be larger than an int on 64-bit systems? Yes. On 64-bit systems (LP64/LLP64), a pointer is 8 bytes while int remains 4 bytes. This is why you should never cast a pointer to int — use uintptr_t from <stdint.h> to store a pointer value as an integer safely.

What does volatile do? volatile tells the compiler that a variable's value can change at any time — from an interrupt handler, DMA controller, or memory-mapped hardware register. The compiler must not optimize away reads/writes to volatile variables or cache them in registers. It is essential for embedded systems programming and multi-threaded signal handlers.

Is _Bool the same as bool? _Bool is the C native Boolean type (since C99). The <stdbool.h> header defines bool as a macro expanding to _Bool, and true/false as macros for 1/0. In C23, bool, true, and false are now keywords — you no longer need #include <stdbool.h>.


Key Takeaway

C variables are physical memory regions with a precise size, type, and address. By using <stdint.h> fixed-width types, you write code that behaves identically on any platform. By understanding auto, static, const, and constexpr, you control not just what a variable stores, but how long it lives and how much the compiler is allowed to optimize it.

This physical understanding of data is what separates systems programmers from scripting language users. It is the foundation upon which memory management, pointer arithmetic, and all of the advanced topics in this course are built.

Read next: Control Flow & Deterministic Logic in C →


This post is part of the C Mastery Course — 30 modules from fundamentals to production-grade systems engineering.