C Programming Tutorial: Learn Systems Language from Scratch (2026)
C remains the language I reach for when I need unfettered control over memory and hardware. After years writing kernel modules and embedded firmware, I have learned that C's minimal runtime and direct hardware access are irreplaceable. Learning C is not about memorizing syntax; it is about understanding what your program actually does at the silicon level.
This tutorial draws from real production experience with network stacks, embedded controllers, and interpreter runtimes. Every concept here has practical, not academic, significance.
Pointers and Memory
A pointer is just an integer that holds a memory address. The real skill is reading pointer declarations from right to left: int *p means "p is a pointer to int" and int **pp means "pp is a pointer to a pointer to int." This right-left rule eliminates confusion when declarations nest multiple levels deep.
int x = 42;
int *p = &x;
printf("%d\n", *p);
int *heap = malloc(10 * sizeof(int));
if (!heap) return -1;
free(heap);
heap = NULL;
Dynamic Allocation
Every malloc must be paired with a free, and that pairing should be decided at the call site. I follow one rule: the function that allocates is responsible for freeing. For embedded systems with constrained RAM, I use fixed-size block allocators instead of malloc to prevent fragmentation.
typedef struct { int id; char name[64]; } User;
User *make(int id, const char *n) {
User *u = malloc(sizeof(User));
if (!u) return NULL;
u->id = id;
strncpy(u->name, n, 63)[63] = 0;
return u;
}
Structs and Unions
Structs group related fields; unions overlay different types in the same memory. I rely on unions heavily in protocol parsers where a single buffer may represent headers from different layers. The compiler may pad struct members to align with word boundaries.
struct Packet {
uint16_t len;
uint8_t type;
uint8_t flags;
} __attribute__((packed));
union Val { int i; float f; };
File I/O
Standard I/O provides buffered access through FILE pointers. fopen opens a stream; fread and fwrite read and write binary data. Always check fopen's return value — NULL means the file could not open. For high-performance paths, use raw file descriptors with read/write or mmap.
FILE *fp = fopen("data.bin", "rb");
if (!fp) { perror("fopen"); return; }
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)))
process(buf, n);
fclose(fp);
Preprocessor
Macros perform text substitution before compilation. Always parenthesize macro parameters: #define SQUARE(x) ((x)*(x)). Without them, SQUARE(1+2) expands to 1+2*1+2 which evaluates to 5, not 9. Use header guards with #ifndef or #pragma once.
#ifndef UTIL_H
#define UTIL_H
#define MAX(a,b) ((a)>(b)?(a):(b))
#define ARR_LEN(a) (sizeof(a)/sizeof((a)[0]))
#endif
Build Systems
The compiler translates each .c into an object file; the linker resolves symbols across objects into the final binary. Undefined reference errors usually mean a missing object on the link line or incorrect library order. Makefiles automate this pipeline; CMake handles cross-platform builds.
CC=gcc
CFLAGS=-Wall -Wextra -O2
OBJS=main.o parse.o
prog: $(OBJS)
$(CC) -o $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
Frequently Asked Questions
malloc vs calloc?
malloc leaves memory uninitialized; calloc zeros every byte. calloc is safer for structs with assumed-zero fields.
Linked list vs array?
Lists for frequent mid-sequence insertions; arrays for random access and cache locality.
Why segfault?
Dereferencing NULL, buffer overflow, use-after-free, or stack overflow. Use -fsanitize=address in debug builds.
What is volatile for?
Prevents the compiler from optimizing away accesses to variables changed outside normal flow, such as hardware registers.
Originally published on Ayodhyyya. Last updated June 1, 2026.