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
- C's Fundamental Types and Their Sizes
- Fixed-Width Types with stdint.h
- C23: Automatic Type Inference with auto
- The sizeof Operator
- Variable Scope and Storage Duration
- Endianness: How Multi-Byte Values are Stored
- Integer Overflow: The Silent Killer
- Constants: const vs #define vs constexpr
- Frequently Asked Questions
- Key Takeaway
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.
int age = 25;What actually happens here:
- The compiler reserves 4 bytes on the stack (on a typical 64-bit system).
- The binary representation of
25(0x00000019) is written to those 4 bytes. - The name
ageis 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:
| Type | Typical Size | Range |
|---|---|---|
char | 1 byte | -128 to 127 |
unsigned char | 1 byte | 0 to 255 |
short | 2 bytes | -32,768 to 32,767 |
int | 4 bytes | ~±2.1 billion |
long | 4 or 8 bytes* | Platform-dependent |
long long | 8 bytes | ~±9.2 × 10¹⸠|
float | 4 bytes | IEEE 754 single precision |
double | 8 bytes | IEEE 754 double precision |
[!WARNING] Platform Dependency is Real. On a 32-bit Linux,
longis 4 bytes. On a 64-bit Linux,longis 8 bytes. On Windows 64-bit,longis still 4 bytes (LLP64 vs LP64 data model). Code that assumeslongis a specific size will silently break when ported. This is whystdint.hexists.
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:
#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.
#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
autois not the same asvarin JavaScript. The type is resolved at compile time and is permanent. Once the compiler deducesscoreasint, it is anintforever. 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).
#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:
// 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 Class | Scope | Lifetime | Memory Location |
|---|---|---|---|
auto (default) | Block | Until block exits | Stack |
static (local) | Block | Entire program | BSS/Data |
static (global) | File | Entire program | BSS/Data |
register (hint) | Block | Until block exits | Register (maybe) |
extern | File/global | Entire program | Defined elsewhere |
#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:
Little-Endian memory: [78] [56] [34] [12] ↠x86/x64
Big-Endian memory: [12] [34] [56] [78] ↠Network byte orderThis 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.
#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:
#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.
