Knowledge/C/Introduction
28 Chapters
C17 Baseline
Living Document
Free License
Engineering Publication · Syed Omar Ibn Feroj← Back to portfolio
C
Engineering Notes · Open Knowledge Repository

C — Engineering
Notes for the Machine

A working reference for C as it actually runs — the memory model, pointers without mystery, the undefined behaviour that bites in production, and the tooling that turns silent corruption into a loud failure.

28
Chapters
C17
Baseline
Living
Document
Free
License
Ch. 01
Compile Model
What the four stages of a C build actually do.
1.1 — Preprocess → compile → assemble → link

C source becomes a program in four stages. Knowing which stage an error comes from tells you where to look: a missing header is preprocess, a type error is compile, an "undefined reference" is link.

Shell
$ cc -E a.c       # 1. preprocess only
$ cc -S a.c       # 2. → assembly
$ cc -c a.c       # 3. → a.o object file
$ cc a.o b.o -o app  # 4. link → executable
Always build with -Wall -Wextra -Werror. C will happily compile code that is wrong; the warnings are the language telling you it noticed.
Ch. 02
Types & Sizes
Why int is not 32 bits, and how to get fixed widths.
2.1 — Sizes are implementation-defined
C
#include <stdint.h>
int32_t  a;   // exactly 32 bits, every platform
uint64_t b;   // exactly 64, unsigned
size_t   n;   // the type of sizeof / object sizes
ptrdiff_t d;  // the type of pointer differences
int, long etc. only have minimum guaranteed sizes. Code that assumes int is 32 bits or long is 64 breaks across platforms. Use <stdint.h> for anything size-sensitive (serialisation, hardware, hashing).
Ch. 03
Storage Classes
auto, static, extern, register — and lifetime vs linkage.
3.1 — static means two different things
C
static int counter;        // file scope: internal linkage

int next(void) {
  static int n = 0;     // block scope: persists across calls
  return ++n;
}
At file scope, static means "not visible to other translation units". Inside a function, it means "lifetime is the whole program, value persists between calls". Same keyword, unrelated effects.
Ch. 04
Operators
Integer promotion, the comma operator, and evaluation order.
4.1 — The conversions that surprise people
C
unsigned u = 1;
if (-1 < u) puts("yes");   // NOT printed!
// -1 converts to a huge unsigned before the compare
Mixing signed and unsigned in a comparison converts the signed operand to unsigned. -1 < 1u is false. Keep loop counters and sizes consistently typed; turn on -Wsign-compare.
Ch. 05
Control Flow
switch fallthrough, the dangling else, goto for cleanup.
5.1 — goto is acceptable for error unwinding
C
int load(void) {
  FILE *f = fopen("x", "r");
  if (!f) goto err0;
  char *buf = malloc(1024);
  if (!buf) goto err1;
  // ... work ...
  free(buf);
  err1: fclose(f);
  err0: return -1;
}
The single-exit goto cleanup ladder is idiomatic C (it's all over the Linux kernel). It beats deeply nested ifs and prevents the leak-on-early-return bug.
Ch. 06
Functions
Prototypes, K&R vs modern, and why void matters.
6.1 — Always prototype; () is not 'no args'
C
int add(int, int);        // prototype in a header
int ticks(void);            // takes NO arguments
// int ticks();  ← old style: 'unspecified args', unsafe
An empty parameter list f() in C means "unspecified arguments", not "no arguments" — the compiler can't check the call. Always write (void) for zero-arg functions.
Ch. 07
Arrays
Arrays decay to pointers — the source of half of C's confusion.
7.1 — sizeof lies after a function call
C
int a[10];
sizeof a;            // 40 — here, a is an array

void f(int a[]) {
  sizeof a;          // 8 — a is a pointer here!
}
An array passed to a function decays to a pointer to its first element — the length is lost. You must pass the length separately. sizeof inside the callee gives the pointer size, not the array size.
Ch. 08
Strings
NUL-terminated char arrays, and the off-by-one everyone hits.
8.1 — The terminator is part of the length
C
char s[6] = "hello";  // 5 chars + '\0' = 6
strlen(s);            // 5 (does not count '\0')
sizeof s;             // 6 (does count it)
Forgetting the +1 for the NUL terminator overflows the buffer. strcpy/strcat never check bounds — prefer snprintf for anything that takes external input.
Ch. 09
Structs
Layout, padding, and designated initialisers.
9.1 — The compiler inserts padding
C
struct S { char c; int i; char d; };
sizeof(struct S);   // 12, not 6 — alignment padding

struct S x = { .i = 5, .c = 'a' };  // designated init
Never memcmp two structs to test equality — padding bytes are uninitialised garbage and may differ even when all fields match. Compare field by field.
Ch. 10
Unions & Bitfields
Overlapping storage and packed flags.
10.1 — A union holds one member at a time
C
union U { int i; float f; } u;
u.i = 1;
u.f;   // reading the other member: type-punning
Reading a union member other than the one last written is well-defined in C (unlike C++) for "type punning", but the result depends on representation. For portable bit reinterpretation, memcpy is the safe idiom.
Ch. 11
Enums & typedef
Named constants and type aliases.
11.1 — typedef a struct for clean APIs
C
typedef enum { RED, GREEN, BLUE } Color;
typedef struct { int x, y; } Point;
Point p = { .x = 1, .y = 2 };
Enum values are just ints with no type safety — nothing stops Color c = 99;. Treat them as named constants, not a closed set.
Ch. 12
Pointers
A pointer is an address with a type. That's the whole idea.
12.1 — Declare, take address, dereference
C
int x = 42;
int *p = &x;     // p holds x's address
*p = 7;          // write through p → x is now 7
int *q = NULL;   // points nowhere; deref = crash/UB
Dereferencing an uninitialised or freed pointer is undefined behaviour — sometimes a crash, sometimes silent corruption that surfaces hours later. Initialise pointers to NULL and set them back to NULL after free.
Ch. 13
Pointer Arithmetic
Adding 1 moves by sizeof(T), not by 1 byte.
13.1 — Arithmetic is in units of the pointed-to type
C
int a[4] = {10,20,30,40};
int *p = a;
*(p + 2);    // 30 — advanced by 2*sizeof(int)
p[2];        // identical — a[i] is *(a+i)
Forming a pointer more than one-past-the-end of an array is undefined, even if you never dereference it. Stay within [a, a+n].
Ch. 14
Pointers & Arrays
Related, not the same — the distinction that trips everyone.
14.1 — Array name is not a modifiable lvalue
C
int a[3], *p;
p = a;        // ok: array decays to &a[0]
// a = p;     ← error: 'a' is not assignable
// extern int a[];  declares an array
// extern int *a;   declares a pointer — NOT interchangeable
Declaring extern int *a; in one file when the definition is int a[]; in another is a classic, hard-to-find bug — the linker accepts it, the runtime corrupts. The declaration must match the definition exactly.
Ch. 15
Dynamic Memory
malloc/calloc/realloc/free — and every way they go wrong.
15.1 — Check, use, free exactly once
C
int *v = malloc(n * sizeof *v);  // sizeof *v: type-safe
if (!v) return -1;                // malloc can fail
// ... use v ...
free(v);
v = NULL;   // prevents use-after-free / double-free
The four classic heap bugs: forgetting to free (leak), freeing twice (corruption), using after free (UB), and writing past the end (overflow). A discipline of "set to NULL after free" + a sanitizer build (Ch. 27) catches most of them.
Ch. 16
Function Pointers
Code as data — callbacks, dispatch tables, plugins.
16.1 — Declaration syntax and a dispatch table
C
int (*op)(int, int);     // pointer to int(int,int)
op = add;
op(2, 3);              // call through it

int (*table[])(int,int) = { add, sub, mul };
table[i](2, 3);        // O(1) dispatch by index
Use typedef int (*BinOp)(int,int); to make function-pointer APIs readable. The raw declarator syntax is the single most unreadable corner of C.
Ch. 17
const & restrict
Promises to the compiler — and what they actually buy.
17.1 — const-correctness and restrict
C
void print(const char *s);   // 'I won't modify s'
char *const p = buf;        // p can't be reseated

void copy(int *restrict d, const int *restrict s, int n);
// restrict: 'd and s never alias' → lets the compiler vectorise
restrict is an unchecked promise. If the pointers do alias, the result is undefined and the bug only appears at high optimisation levels. Only use it when you can guarantee no overlap.
Ch. 18
Preprocessor
Macros are text substitution — treat them with suspicion.
18.1 — Parenthesise everything
C
#define SQR(x) ((x) * (x))   // every x parenthesised
SQR(a + 1);   // ((a+1)*(a+1)) — correct
// #define SQR(x) x*x  →  a+1*a+1  ← wrong

#define MAX(a,b) ((a) > (b) ? (a) : (b))
MAX(i++, j);   // i++ evaluated twice — side-effect bug
A function-like macro evaluates its arguments however many times they appear in the body. MAX(i++, j) increments i twice. Prefer static inline functions; reach for macros only when you genuinely need text substitution.
Ch. 19
Translation Units
One .c = one unit; headers declare, .c files define.
19.1 — Include guards and the declare/define split
C
// math.h
#ifndef MATH_H
#define MATH_H
int add(int, int);   // declaration only
#endif

// math.c
#include "math.h"
int add(int a, int b) { return a + b; }  // definition
A definition (variable or function body) in a header gets compiled into every translation unit that includes it → "multiple definition" at link. Headers declare; .c files define. Exceptions: static inline, macros, types.
Ch. 20
Linking
Reading 'undefined reference' and 'multiple definition'.
20.1 — The two linker errors, decoded

undefined reference to `f` — you declared and called f but never linked the object/library that defines it. Add the .o or -lfoo; library order matters (dependencies come after dependents). multiple definition of `g`g is defined (not just declared) in more than one unit, usually a non-static definition in a header.

Link order is left-to-right and single-pass with ld: put -lm and other libraries after the objects that use them, or the symbols won't resolve.
Ch. 21
File I/O
FILE* streams, binary vs text, and always checking returns.
21.1 — Open, check, use, close
C
FILE *f = fopen("data.bin", "rb");
if (!f) { perror("fopen"); return -1; }
size_t got = fread(buf, 1, n, f);
if (got < n && ferror(f)) { /* I/O error */ }
fclose(f);
A short fread/fwrite isn't necessarily an error — check feof vs ferror to tell EOF from failure. And on text mode (Windows) \n is translated; use "rb"/"wb" for binary.
Ch. 22
string.h
The standard string functions and their sharp edges.
22.1 — Sized variants are not all safe
C
strncpy(d, s, n);   // may NOT NUL-terminate if s is long
d[n-1] = '\0';     // you must do this yourself

snprintf(d, n, "%s", s);  // always NUL-terminates: prefer this
strncpy does not guarantee a terminator and pads with zeros — it's not "safe strcpy". For bounded string building, snprintf is the reliably-correct tool.
Ch. 23
Variadic Functions
va_list — powerful, type-unsafe, used sparingly.
23.1 — The va_ machinery
C
#include <stdarg.h>
int sum(int count, ...) {
  va_list ap; va_start(ap, count);
  int s = 0;
  for (int i = 0; i < count; i++) s += va_arg(ap, int);
  va_end(ap);
  return s;
}
There is zero type checking on variadic arguments. A wrong va_arg type or a printf format mismatch is undefined behaviour. Compile with -Wformat=2 so the compiler at least checks printf-family calls.
Ch. 24
Standard Library
The headers worth knowing by heart.
24.1 — The map
HeaderGives you
stdio.hprintf/scanf, FILE I/O
stdlib.hmalloc, qsort, atoi, exit, rand
string.hmem*/str* functions
stdint.hfixed-width integer types
stddef.hsize_t, ptrdiff_t, NULL, offsetof
errno.herrno + error codes
assert.hassert() (compile out with NDEBUG)
math.hfloating-point math (link with -lm)
Ch. 25
Undefined Behaviour
The thing that makes C fast and dangerous in equal measure.
25.1 — The greatest hits
  • Signed integer overflow (unsigned wraps; signed is UB)
  • Dereferencing NULL or a dangling/freed pointer
  • Out-of-bounds array access
  • Reading an uninitialised value
  • Data race on a shared object
  • Modifying an object twice between sequence points
  • Strict-aliasing violations (reinterpreting via the wrong pointer type)
UB doesn't mean "crashes" — it means the compiler may assume it never happens and optimise accordingly. Code that "worked" at -O0 can break at -O2 because the optimiser deleted a "can't happen" check. The fix is to not invoke UB, not to lower the optimisation level.
Ch. 26
Memory Bugs
The bug classes that define C's reputation.
26.1 — Recognise them by symptom
BugSymptom
Buffer overflowcorruption far from the cause; crash later
Use-after-freeworks until the allocator reuses the block
Double freeallocator metadata corruption / abort
LeakRSS grows without bound
Uninitialised readnon-deterministic behaviour
The defining trait of C memory bugs: the crash is rarely where the bug is. This is exactly why sanitizers (next chapter) are non-optional, not a nicety.
Ch. 27
Sanitizers & Tooling
Turn silent corruption into a loud, located failure.
27.1 — The toolbox
Shell
$ cc -fsanitize=address,undefined -g a.c   # ASan + UBSan
$ valgrind --leak-check=full ./app          # leaks + bad access
$ clang-tidy a.c; cppcheck a.c            # static analysis
AddressSanitizer turns a use-after-free from "mystery corruption next Tuesday" into an exact stack trace at the moment of the bad access, for ~2x slowdown. Run your test suite under ASan+UBSan in CI.
Ch. 28
Defensive C
The habits that keep a C codebase from rotting.
28.1 — Non-negotiables
  • Build with -Wall -Wextra -Werror; treat warnings as bugs
  • Check every malloc, fopen, read, write return
  • NULL a pointer right after free
  • Pass array lengths explicitly — never trust an in-band terminator from untrusted input
  • Prefer snprintf over sprintf/strcpy/strcat
  • Run tests under ASan + UBSan in CI
  • Keep functions single-exit with a goto cleanup ladder for resources
REF
C Cheatsheet
Declarations and the rules you forget under pressure.
Reading declarations (right-left rule)
C
int *p;              // p is a pointer to int
int *p[3];           // array of 3 pointers to int
int (*p)[3];         // pointer to array of 3 int
int (*f)(void);        // pointer to function returning int
int *f(void);          // function returning pointer to int
const int *p;        // pointer to const int (data is const)
int *const p;        // const pointer to int (pointer is const)