Why C

Dennis Ritchie created C in 1972 at Bell Labs to rewrite Unix. Before C, operating systems were written in assembly — thousands of lines of instructions tied to a specific processor. Ritchie wanted a language that was close enough to the hardware to write an operating system, but abstract enough to be portable across machines. He succeeded. Unix was rewritten in C, and both language and operating system began to spread.

Fifty years later, C is the foundation of modern computing. The Linux kernel is C. The Windows kernel is C. macOS is C. The Python interpreter is C. The JVM is C and C++. The TCP/IP stack that connects every device on the internet is C. Your phone, your car’s engine controller, the Mars rovers, the MRI machine — all C.

“C is not a big language, and it is not well served by a big book.”

— Brian Kernighan & Dennis Ritchie, preface to The C Programming Language

Why C Is Still Relevant

Every operating system, every database engine (PostgreSQL, SQLite, MySQL), every language runtime (CPython, Ruby MRI, PHP), every embedded system, and most network infrastructure is written in C. Learning C means learning how the machine actually works: how memory is laid out, how pointers work, why buffer overflows are dangerous, and what your higher-level language is doing under the hood.

I. Hello, World

#include <stdio.h> int main(void) { printf("Hello, World\n"); return 0; }

#include <stdio.h> is a preprocessor directive: it literally pastes the contents of the standard I/O header file into your source code before compilation. main is the entry point. It returns int: 0 means success, nonzero means failure. printf writes formatted text to standard output. \n is a newline. The semicolons and braces come from C, and every language that borrowed C’s syntax (Java, JavaScript, Go, Rust, C++, C#) inherited them.

The Compilation Pipeline

# Compile and run $ gcc hello.c -o hello $ ./hello Hello, World # The four stages: $ gcc -E hello.c # 1. Preprocessing: expand #include, #define $ gcc -S hello.c # 2. Compilation: C → assembly (.s) $ gcc -c hello.c # 3. Assembly: assembly → object file (.o) $ gcc hello.o -o hello # 4. Linking: object files → executable

Understanding these stages matters. When you get a “undefined reference” error, that is a linker error (stage 4). When you get a “undeclared identifier” error, that is a compiler error (stage 2). Knowing which stage failed tells you where to look.

II. Types and Variables

int x = 42; /* integer (typically 32 bits) */ char c = 'A'; /* character (8 bits) */ float f = 3.14f; /* single-precision float (32 bits) */ double d = 3.14159265358979; /* double-precision float (64 bits) */ long big = 1000000000L; /* at least 32 bits */ long long huge = 9000000000000000000LL; /* at least 64 bits */ short small = 32767; /* at least 16 bits */ unsigned int u = 42; /* non-negative only */ /* sizeof tells you the size in bytes */ printf("int: %zu bytes\n", sizeof(int)); /* typically 4 */ printf("char: %zu bytes\n", sizeof(char)); /* always 1 */ printf("double: %zu bytes\n", sizeof(double)); /* typically 8 */

C’s type sizes are not fully specified. An int is “at least 16 bits.” On modern 64-bit systems it is 32 bits, but the standard does not guarantee this. For portable code, use the fixed-width types from <stdint.h>:

#include <stdint.h> int8_t a; /* exactly 8 bits, signed */ uint8_t b; /* exactly 8 bits, unsigned */ int32_t c; /* exactly 32 bits, signed */ uint64_t d; /* exactly 64 bits, unsigned */ size_t n; /* unsigned, big enough for any object size */

III. Operators and Control Flow

/* Bitwise operators (C gives you direct access to bits) */ unsigned flags = 0; flags |= (1 << 3); /* set bit 3 */ flags &= ~(1 << 3); /* clear bit 3 */ if (flags & (1 << 3)) /* test bit 3 */ /* Switch (falls through by default — unlike Go!) */ switch (c) { case 'a': case 'e': case 'i': case 'o': case 'u': printf("vowel\n"); break; /* without break, execution falls through! */ default: printf("consonant\n"); } /* Loops */ for (int i = 0; i < 10; i++) { } /* classic for */ while (n > 0) { n--; } /* while */ do { n++; } while (n < 10); /* do-while (body runs at least once) */ /* Ternary operator */ int abs_x = (x >= 0) ? x : -x;

IV. Functions

/* Declaration (prototype) — tells the compiler what to expect */ double distance(double x1, double y1, double x2, double y2); /* Definition — the actual implementation */ double distance(double x1, double y1, double x2, double y2) { double dx = x2 - x1; double dy = y2 - y1; return sqrt(dx*dx + dy*dy); } /* C is pass-by-value ONLY */ void double_it(int x) { x *= 2; /* modifies local copy, caller's variable unchanged */ } /* To modify the caller's variable, pass a pointer */ void double_it(int *x) { *x *= 2; /* dereference the pointer to modify the original */ } int n = 5; double_it(&n); /* pass the address of n */ /* n is now 10 */ /* static functions are visible only within this file */ static int helper(int x) { return x + 1; }

V. Pointers

Pointers are what makes C C. A pointer is a variable that holds the memory address of another variable. That is all it is. But from this single concept flows almost everything that distinguishes C from higher-level languages.

int x = 42; int *p = &x; /* p holds the ADDRESS of x */ printf("%d\n", *p); /* 42 — dereference: read the value at that address */ printf("%p\n", p); /* 0x7ffc... — the address itself */ *p = 100; /* modify x through the pointer */ printf("%d\n", x); /* 100 */ /* Pointer arithmetic */ int arr[5] = {10, 20, 30, 40, 50}; int *q = arr; /* array name decays to pointer to first element */ printf("%d\n", *q); /* 10 */ printf("%d\n", *(q+1)); /* 20 — pointer + 1 moves by sizeof(int) bytes */ printf("%d\n", q[2]); /* 30 — q[i] is exactly *(q+i) */ /* NULL pointer */ int *null_ptr = NULL; /* points nowhere — dereferencing is undefined behavior */ /* void pointer — generic pointer, can point to any type */ void *generic = &x; int *back = (int *)generic; /* must cast back to use it */

Function Pointers

/* Declare a function pointer */ int (*operation)(int, int); int add(int a, int b) { return a + b; } int mul(int a, int b) { return a * b; } operation = add; printf("%d\n", operation(3, 4)); /* 7 */ operation = mul; printf("%d\n", operation(3, 4)); /* 12 */ /* qsort uses a function pointer for the comparison */ int compare_ints(const void *a, const void *b) { return (*(int *)a - *(int *)b); } int nums[] = {5, 2, 8, 1, 9}; qsort(nums, 5, sizeof(int), compare_ints);

Function pointers are how C does callbacks, polymorphism, and plugin architectures. The standard library’s qsort and bsearch use function pointers to work with any type. This is C’s version of generics — crude but effective.

VI. Arrays and Strings

/* Stack-allocated array */ int arr[5] = {1, 2, 3, 4, 5}; int zeros[100] = {0}; /* all zeros */ /* sizeof pitfall */ printf("%zu\n", sizeof(arr)); /* 20 (5 * 4 bytes) */ printf("%zu\n", sizeof(arr) / sizeof(arr[0])); /* 5 — number of elements */ /* But this breaks when you pass an array to a function: */ void foo(int arr[]) { /* sizeof(arr) is sizeof(int*), NOT the array size! */ /* Arrays decay to pointers when passed to functions. */ }

C Strings

C has no string type. A “string” is an array of char terminated by a null byte ('\0'). This is the source of a disproportionate number of bugs and security vulnerabilities.

char s1[] = "Hello"; /* array of 6 chars: H, e, l, l, o, \0 */ char *s2 = "Hello"; /* pointer to string literal (read-only!) */ char s3[20]; /* buffer for up to 19 chars + null */ /* string.h functions */ size_t len = strlen(s1); /* 5 (does NOT count the \0) */ strcpy(s3, s1); /* copy — DANGEROUS if dest too small */ strncpy(s3, s1, sizeof(s3) - 1); /* safer: bounded copy */ s3[sizeof(s3) - 1] = '\0'; /* always null-terminate manually */ int cmp = strcmp(s1, s2); /* 0 if equal, <0 or >0 otherwise */ strcat(s3, " World"); /* concatenate — watch buffer size! */ char *found = strstr(s3, "World"); /* find substring, or NULL */

Buffer Overflows

C does not check array bounds. Writing past the end of a buffer overwrites whatever is next in memory. This is the root cause of a vast number of security vulnerabilities: stack smashing, code injection, remote code execution. Always use bounded functions (strncpy, snprintf, fgets) and always check sizes. The gets() function was so dangerous it was removed from the C standard entirely.

VII. Structs

struct Point { double x; double y; }; /* typedef saves you from writing "struct" everywhere */ typedef struct { char name[64]; int age; double gpa; } Student; /* Initialization */ struct Point p1 = {3.0, 4.0}; Student s = {"Alice", 20, 3.8}; /* Access fields with . */ printf("%s is %d\n", s.name, s.age); /* Pointer to struct: use -> instead of . */ Student *sp = &s; printf("%s\n", sp->name); /* same as (*sp).name */ /* Structs are passed by value (copied). For large structs, pass a pointer. */ void print_student(const Student *s) { printf("%s, age %d, GPA %.1f\n", s->name, s->age, s->gpa); }

VIII. Dynamic Memory

The stack is for local variables with known sizes. The heap is for data whose size is determined at runtime. You manage the heap manually: allocate with malloc, release with free. If you forget to free, memory leaks. If you free twice, undefined behavior. If you use after free, undefined behavior.

#include <stdlib.h> /* Allocate memory for n integers */ int *arr = malloc(n * sizeof(int)); if (arr == NULL) { perror("malloc failed"); return 1; } /* Use it */ for (int i = 0; i < n; i++) { arr[i] = i * i; } /* Resize */ arr = realloc(arr, 2 * n * sizeof(int)); /* Free when done */ free(arr); arr = NULL; /* good practice: prevent use-after-free */ /* calloc: allocate and zero-initialize */ int *zeros = calloc(n, sizeof(int)); /* all bytes set to 0 */
FunctionPurpose
malloc(size)Allocate size bytes (uninitialized)
calloc(n, size)Allocate n * size bytes (zero-initialized)
realloc(ptr, size)Resize existing allocation
free(ptr)Release allocated memory

Every malloc must have a corresponding free. Every malloc must be checked for NULL. This is the discipline C requires. There is no garbage collector, no RAII, no defer. You track every allocation yourself. Get it wrong and you get memory leaks, crashes, or security vulnerabilities. Get it right and you have software that runs for decades without consuming more memory than it needs.

IX. The Preprocessor

/* Include headers */ #include <stdio.h> /* system header: search system paths */ #include "myheader.h" /* local header: search current directory first */ /* Constants */ #define MAX_SIZE 1024 #define PI 3.14159265358979 /* Macros (text substitution — use with caution) */ #define MAX(a, b) ((a) > (b) ? (a) : (b)) #define SQUARE(x) ((x) * (x)) /* The extra parentheses are critical. Without them: */ /* SQUARE(1+2) would expand to (1+2 * 1+2) = 5, not 9 */ /* Conditional compilation */ #ifdef DEBUG printf("Debug: x = %d\n", x); #endif /* Include guards (prevent double inclusion) */ #ifndef MYHEADER_H #define MYHEADER_H /* ... header contents ... */ #endif

The preprocessor runs before the compiler. It does pure text substitution: #include pastes files, #define replaces tokens, #ifdef conditionally includes code. Macros are powerful but dangerous: they have no type checking, no scoping, and can produce surprising results. Prefer const variables and inline functions when possible.

X. File I/O

/* Reading a file line by line */ FILE *f = fopen("data.txt", "r"); if (f == NULL) { perror("fopen"); return 1; } char line[256]; while (fgets(line, sizeof(line), f) != NULL) { printf("%s", line); } fclose(f); /* Writing formatted output */ FILE *out = fopen("output.txt", "w"); fprintf(out, "Name: %s, Score: %d\n", "Alice", 95); fclose(out); /* Binary I/O */ int data[100]; FILE *bin = fopen("data.bin", "wb"); fwrite(data, sizeof(int), 100, bin); fclose(bin);
ModeDescription
"r"Read (file must exist)
"w"Write (creates or truncates)
"a"Append (creates if needed)
"r+"Read and write (file must exist)
"rb", "wb"Binary mode (no newline translation)

XI. The Compilation Model

Real C projects split code across multiple files. Each .c file compiles independently into an object file (.o). The linker combines them.

/* === vector.h === */ #ifndef VECTOR_H #define VECTOR_H typedef struct { double x, y; } Vector; Vector vector_add(Vector a, Vector b); double vector_magnitude(Vector v); #endif
/* === vector.c === */ #include "vector.h" #include <math.h> Vector vector_add(Vector a, Vector b) { return (Vector){a.x + b.x, a.y + b.y}; } double vector_magnitude(Vector v) { return sqrt(v.x * v.x + v.y * v.y); }
# Compile separately, then link $ gcc -c main.c -o main.o $ gcc -c vector.c -o vector.o $ gcc main.o vector.o -o program -lm # Or all at once $ gcc main.c vector.c -o program -lm # Useful flags $ gcc -Wall -Wextra -std=c11 -g program.c -o program # -Wall: enable most warnings # -Wextra: enable additional warnings # -std=c11: use the C11 standard # -g: include debug information (for gdb/lldb)

XII. Undefined Behavior

Undefined behavior (UB) is the most important concept in C that most programmers do not understand well enough. When the C standard says a program has undefined behavior, it means anything can happen. The program may crash. It may silently produce wrong output. The compiler may remove your code entirely.

/* Common sources of undefined behavior */ int x = INT_MAX; x = x + 1; /* UB: signed integer overflow */ int *p = NULL; *p = 42; /* UB: null pointer dereference */ int arr[5]; arr[10] = 99; /* UB: out-of-bounds access */ int *q = malloc(sizeof(int)); free(q); *q = 42; /* UB: use-after-free */ int y; printf("%d\n", y); /* UB: reading uninitialized variable */

The compiler assumes UB never happens and optimizes accordingly. If signed overflow is UB, the compiler can assume x + 1 > x is always true and optimize out your overflow check. This is not a bug in the compiler — it is the compiler following the rules. Use -fsanitize=undefined and -fsanitize=address to catch UB at runtime.

XIII. The Standard Library

HeaderWhat It Provides
<stdio.h>printf, scanf, fopen, fclose, fgets, fread, fwrite, sprintf, snprintf
<stdlib.h>malloc, calloc, realloc, free, exit, atoi, qsort, bsearch, rand
<string.h>strlen, strcpy, strncpy, strcmp, strcat, strstr, memcpy, memset
<math.h>sqrt, pow, sin, cos, log, exp, ceil, floor, fabs (link with -lm)
<ctype.h>isalpha, isdigit, isspace, toupper, tolower
<assert.h>assert macro (abort if false; disabled by NDEBUG)
<stdint.h>int8_t, uint32_t, int64_t, SIZE_MAX, INT32_MAX
<stdbool.h>bool, true, false
<errno.h>errno, ENOENT, ENOMEM — error codes for system calls
<limits.h>INT_MAX, INT_MIN, CHAR_BIT — implementation limits
<time.h>time, clock, difftime, strftime
<signal.h>signal, raise, SIGINT, SIGSEGV — signal handling

The standard library is small and minimal. Unlike Python or Go, C does not ship with an HTTP server or a JSON parser. The philosophy is: give you I/O, memory management, strings, math, and sorting. Everything else you build yourself or use a third-party library. This minimalism is why C runs on everything from spacecraft to toasters.

XIV. Putting It Together

A complete linked list implementation demonstrating structs, pointers, dynamic memory, and error handling.

#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct Node { int key; char value[64]; struct Node *next; } Node; typedef struct { Node *head; size_t size; } List; List *list_create(void) { List *list = malloc(sizeof(List)); if (!list) { perror("malloc"); return NULL; } list->head = NULL; list->size = 0; return list; } int list_insert(List *list, int key, const char *value) { Node *node = malloc(sizeof(Node)); if (!node) { perror("malloc"); return -1; } node->key = key; strncpy(node->value, value, sizeof(node->value) - 1); node->value[sizeof(node->value) - 1] = '\0'; node->next = list->head; list->head = node; list->size++; return 0; } Node *list_find(const List *list, int key) { for (Node *n = list->head; n; n = n->next) if (n->key == key) return n; return NULL; } int list_delete(List *list, int key) { Node **pp = &list->head; while (*pp) { if ((*pp)->key == key) { Node *doomed = *pp; *pp = doomed->next; free(doomed); list->size--; return 0; } pp = &(*pp)->next; } return -1; } void list_print(const List *list) { printf("List (%zu elements):\n", list->size); for (Node *n = list->head; n; n = n->next) printf(" [%d] = \"%s\"\n", n->key, n->value); } void list_destroy(List *list) { Node *cur = list->head; while (cur) { Node *next = cur->next; free(cur); cur = next; } free(list); } int main(void) { List *list = list_create(); if (!list) return 1; list_insert(list, 1, "hydrogen"); list_insert(list, 6, "carbon"); list_insert(list, 8, "oxygen"); list_insert(list, 26, "iron"); list_print(list); Node *found = list_find(list, 6); if (found) printf("\nFound: [%d] = \"%s\"\n", found->key, found->value); printf("\nDeleting key 8...\n"); list_delete(list, 8); list_print(list); list_destroy(list); return 0; }
List (4 elements): [26] = "iron" [8] = "oxygen" [6] = "carbon" [1] = "hydrogen" Found: [6] = "carbon" Deleting key 8... List (3 elements): [26] = "iron" [6] = "carbon" [1] = "hydrogen"

Notice the patterns: every malloc is checked, every allocation is freed in list_destroy, strings are bounded with strncpy, const marks read-only parameters, and the double-pointer trick in list_delete eliminates the need to special-case deleting the head node. This is idiomatic C: explicit, careful, and hiding nothing.

Summary

C gives you everything and protects you from nothing. There is no bounds checking, no garbage collection, no type safety beyond what the compiler checks at compile time. When your program has a bug, the consequence is not an exception with a stack trace — it is a segfault, corrupted memory, or a security vulnerability.

That is the bargain. In exchange you get:

  • Performance. Native machine code, no runtime, no GC pauses. As close to the hardware as you can get without assembly.
  • Portability. C compilers exist for every architecture on earth: x86, ARM, RISC-V, microcontrollers with 2 KB of RAM, supercomputers with terabytes.
  • Longevity. Code written in the 1980s still compiles and runs. The language changes slowly. The ABI is stable. Your investment holds for decades.
  • Universality. Every operating system, every language runtime, every serious library exposes a C API. If you know C, you can interface with anything.

Learn C not because it is easy, but because everything else is built on it. When you understand C, you understand what malloc actually does, why buffer overflows are dangerous, how a virtual function table works, why your Python program uses so much memory, and what the operating system does when you open a file. You understand the machine.