C Essentials
C is a small, low-level procedural language that maps closely onto how the machine actually works: flat memory, explicit pointers, and manual resource management. There's no garbage collector, no classes, no exceptions — every byte of memory you use, you asked for, and every byte you're done with, you have to say so. That directness is C's whole appeal (and its whole danger).
Pointers & Pointer Arithmetic
A pointer is just a variable holding a memory address. The & operator takes the address of a variable; * dereferences a pointer to get (or set) the value it points to. Pointer arithmetic is scaled by the pointed-to type's size, not by raw bytes — ptr + 1 moves forward by sizeof(*ptr) bytes, which is what makes pointers and arrays interchangeable in most expressions.
#include <stdio.h>
void demo(void) {
int values[5] = {10, 20, 30, 40, 50};
int *p = values; // array decays to a pointer to its first element
printf("%d\n", *p); // 10
printf("%d\n", *(p + 2)); // 30 -- same as values[2]
printf("%d\n", p[2]); // 30 -- array subscript is pointer arithmetic in disguise
for (int *cur = values; cur < values + 5; cur++) {
printf("%d ", *cur);
}
printf("\n");
int x = 7;
int *xp = &x;
*xp = 42; // writes through the pointer
printf("x is now %d\n", x);
int **pp = &xp; // pointer to a pointer
printf("%d\n", **pp); // 42
}A NULL pointer (0) points to nothing valid; dereferencing it is undefined behavior, usually a crash. Always check a pointer returned from a function that can fail (malloc, fopen) against NULL before using it.
Manual Memory Management
Local variables live on the stack and are freed automatically when their scope ends. Anything that needs to outlive its scope, or whose size is only known at runtime, goes on the heap via malloc/calloc/realloc — and you are responsible for calling free() exactly once when you're done, no more, no less.
#include <stdlib.h>
#include <string.h>
typedef struct {
int *data;
size_t length;
size_t capacity;
} IntArray;
IntArray *array_create(size_t initial_capacity) {
IntArray *arr = malloc(sizeof(IntArray));
if (arr == NULL) return NULL; // allocation can fail -- check it
arr->data = malloc(initial_capacity * sizeof(int));
if (arr->data == NULL) {
free(arr); // don't leak the struct if data fails
return NULL;
}
arr->length = 0;
arr->capacity = initial_capacity;
return arr;
}
int array_push(IntArray *arr, int value) {
if (arr->length == arr->capacity) {
size_t new_capacity = arr->capacity * 2;
int *bigger = realloc(arr->data, new_capacity * sizeof(int));
if (bigger == NULL) return -1; // original arr->data is still valid on failure
arr->data = bigger;
arr->capacity = new_capacity;
}
arr->data[arr->length++] = value;
return 0;
}
void array_destroy(IntArray *arr) {
if (arr == NULL) return;
free(arr->data);
free(arr);
}
int main(void) {
IntArray *nums = array_create(4);
for (int i = 0; i < 10; i++) array_push(nums, i * i);
for (size_t i = 0; i < nums->length; i++) printf("%d ", nums->data[i]);
array_destroy(nums); // every malloc needs exactly one matching free
return 0;
}realloc can move the block to a new address, so you must always reassign the pointer from its return value — and never assign directly back into the original pointer variable (arr->data = realloc(arr->data, ...)), because if realloc fails and returns NULL, you'll have overwritten your only reference to the original, still-valid memory, leaking it.
Structs & Arrays vs Pointers
A struct groups related fields into one value with a fixed memory layout — no methods, no inheritance, just data. Arrays and pointers look interchangeable in expressions, but they are not the same thing: an array is a block of memory whose size the compiler knows; a pointer is just an address, with no idea how much memory it refers to.
typedef struct {
char name[32];
int age;
float balance;
} Account;
void print_account(const Account *acc) { // pass structs by pointer to avoid a copy
printf("%s (%d): $%.2f\n", acc->name, acc->age, acc->balance); // -> derefs and accesses in one step
}
void array_vs_pointer(void) {
int stack_array[10];
printf("%zu\n", sizeof(stack_array)); // 40 -- sizeof knows the full array size
int *heap_ptr = malloc(10 * sizeof(int));
printf("%zu\n", sizeof(heap_ptr)); // 8 -- sizeof a pointer, NOT the buffer it points to
free(heap_ptr);
}
void needs_length(int *arr, size_t len) { // a pointer alone can't tell you how many
for (size_t i = 0; i < len; i++) { // elements it points to -- always pass the length too
arr[i] *= 2;
}
}Header Files & the Compilation Model
C compiles one translation unit (.c file) at a time. A header (.h) declares functions and types so other .c files can call into a file they don't have the source of; the preprocessor literally pastes the header's text in via #include before compilation starts. The linker then stitches separately compiled .o object files together, resolving each declared-but-not-defined symbol to its actual definition.
// math_utils.h
#ifndef MATH_UTILS_H // include guard: prevents double inclusion in one TU
#define MATH_UTILS_H
int gcd(int a, int b); // declaration only -- no body
extern const double PI; // extern: this global is defined in another file
#endif
// math_utils.c
#include "math_utils.h"
const double PI = 3.14159265;
int gcd(int a, int b) {
while (b != 0) {
int t = b;
b = a % b;
a = t;
}
return a;
}
// main.c
#include <stdio.h>
#include "math_utils.h"
int main(void) {
printf("gcd(48, 18) = %d\n", gcd(48, 18));
return 0;
}
// Build: compile each .c to a .o, then link them together
// gcc -c math_utils.c -o math_utils.o
// gcc -c main.c -o main.o
// gcc math_utils.o main.o -o appUndefined Behavior Gotchas
C trusts the programmer completely — the compiler does very little to stop you from doing something the language spec calls undefined behavior (UB): the result could be a crash, silently wrong output, or code that happens to work today and breaks the moment you change compiler flags. These are the classic sources.
void buffer_overflow(void) {
char buf[8];
strcpy(buf, "this string is way too long for buf"); // UB: writes past the
// end of buf, corrupting
// adjacent stack memory
}
int *dangling_pointer(void) {
int local = 42;
return &local; // UB: local's stack frame is gone once the function returns
} // the caller now holds a pointer to reclaimed memory
void use_after_free(void) {
int *p = malloc(sizeof(int));
*p = 10;
free(p);
*p = 20; // UB: writing through a pointer to already-freed memory
free(p); // UB: double free -- can corrupt the heap allocator's state
}
void uninitialized_read(void) {
int x; // no default value in C -- x holds garbage
if (x > 0) { /* ... */ } // UB: branching on an indeterminate value
}None of these reliably crash — that's what makes them dangerous. A buffer overflow might silently corrupt an unrelated variable; a dangling pointer read might return the old value by luck until an unrelated function call reuses that stack slot. Tools like AddressSanitizer (-fsanitize=address) and Valgrind catch most of these at runtime during testing, which is why running your test suite under one of them regularly is worth the slowdown.
Practical Tips & Pitfalls
Always check the return value of malloc/calloc/realloc for NULL before dereferencing — allocation failure is rare but not impossible, especially for large requests.
Set a pointer to NULL immediately after free()'ing it; dereferencing a NULL pointer crashes predictably, while dereferencing a dangling one corrupts memory unpredictably.
Prefer strncpy/snprintf over strcpy/sprintf, and always pass buffer sizes explicitly — bounds-unaware string functions are the single most common source of buffer overflows in C.
A pointer carries no length information — whenever you pass a raw pointer across a function boundary, pass its length (or a sentinel like the NUL terminator) alongside it.
Use include guards (or #pragma once) in every header to prevent redefinition errors when a header gets included transitively more than once.
Compile with -Wall -Wextra (and treat warnings as errors in CI) — the compiler can catch a surprising number of UB-adjacent bugs, like uninitialized reads and format-string mismatches, if you let it warn.
Global mutable state and manual memory management interact badly in multi-threaded code — protect shared data with a mutex, and never assume malloc/free are free of data races.
Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free