Learn C
The foundation: pointers, manual memory, structs, and the standard library.
Setup, the Compiler, and Your First Program
Install a C compiler, understand the compile-link cycle, and run hello world.
Setup, the Compiler, and Your First Program
C is a small, fast, statically (but weakly) typed procedural language created by Dennis Ritchie at Bell Labs in 1972 to write Unix. More than fifty years later it is still the lingua franca of operating systems, embedded firmware, language runtimes, and the C ABI that nearly every other language has to speak to. This track takes you from a first program through pointers, fully manual memory management, structs, and the standard library.
Getting a compiler
Unlike languages with one official toolchain, C has several mature compilers. The two you will meet most are GCC (the GNU Compiler Collection) and Clang (the LLVM C/C++ compiler). On Linux install GCC with your package manager; on macOS clang ships with the Xcode command-line tools; on Windows use MSVC, MinGW-w64, or WSL.
$ gcc --version
gcc (GCC) 14.2.0
$ clang --version
clang version 18.1.0
Both accept nearly the same flags, so the commands below work with either (swap gcc for clang).
Your first program
C source files end in .c; headers end in .h. Create hello.c:
#include <stdio.h>
int main(void) {
printf("Hello, C!\n");
return 0;
}
A few things to notice:
#include <stdio.h>is a preprocessor directive. Before the compiler proper runs, the preprocessor literally pastes in the contents of the standard I/O header so the compiler knows whatprintflooks like.- Every C program starts at
main. Returning0signals success to the operating system; any nonzero value signals failure. - Statements end with a semicolon, and
\nis a newline escape inside the string literal.
The compile-and-link cycle
C is compiled ahead of time to a native executable. Conceptually there are four stages, and gcc runs them all for you in one command:
- Preprocess - expand
#include,#define, and conditionals. - Compile - translate each
.cinto assembly, then an object file (.o). - Link - combine object files and libraries into one executable.
- Run - the OS loads the executable.
$ gcc -std=c17 -Wall -Wextra -o hello hello.c
$ ./hello
Hello, C!
The flags matter a lot in C:
| Flag | Meaning |
|---|---|
-std=c17 |
Compile to the C17 standard (also c11, c99, c23) |
-Wall -Wextra |
Turn on most warnings - always do this |
-o hello |
Name the output hello |
-g |
Include debug info (for gdb/lldb) |
-O2 |
Optimize the build |
C does not hold your hand, so warnings are your first line of defense. Treat them as errors during development with -Werror.
A note on standards
C is standardized by ISO working group WG14. The widely used versions are C99, C11, and C17; the newest is C23 (published 2024), which finally makes bool, true, false, nullptr, and static_assert first-class keywords. Most code in the wild targets C11 or C17, so this track uses those unless noted.
Where C sits among systems languages
C is the baseline that newer systems languages define themselves against. C++ keeps C's machine model and adds RAII; Zig and Hare aim to be a "better C" with explicit allocators and defer; Odin routes allocation through an implicit context; Forth strips even further down to raw stacks and a bump pointer; and HolyC is a JIT-compiled C dialect that is an operating system shell. Throughout this track we will compare C's manual model to those cousins.
Reference
- cppreference C language reference: https://en.cppreference.com/w/c
- ISO/IEC JTC1/SC22/WG14 (the C committee): https://www.open-std.org/jtc1/sc22/wg14/
Next we cover C's type system, variables, and control flow - the procedural core.
Types, Variables, Operators, and Control Flow
Scalar types, integer sizes, implicit conversions, and the procedural control structures.
Types, Variables, Operators, and Control Flow
C is statically typed - every variable has a fixed type known at compile time - but weakly typed: it will silently convert between many numeric types, and a wrong conversion is a bug the compiler may not catch. Understanding the type system is the difference between fast, correct C and a debugging session.
Scalar types
The built-in types are deliberately tied to the machine:
char c = 'A'; // a single byte; signedness is implementation-defined
int i = 42; // the "natural" integer for the platform
long big = 1000000L;
float f = 3.14f; // 32-bit floating point
double d = 3.14159; // 64-bit floating point
Integer sizes are not fixed by the standard - only minimums are guaranteed (int is at least 16 bits, long at least 32). When you need an exact width, include <stdint.h> and use the precise-width types:
#include <stdint.h>
int8_t a = -1; // exactly 8 bits, signed
uint32_t b = 4000; // exactly 32 bits, unsigned
int64_t c = 1LL; // exactly 64 bits, signed
size_t n = 100; // unsigned, big enough to index any object
size_t (from <stddef.h>) is the type of sizeof and the right type for sizes and array indices. Prefer it for lengths.
No real booleans (until you ask)
Classic C has no boolean type: zero is false, any nonzero value is true. Since C99 you can #include <stdbool.h> to get bool, true, and false (in C23 these are built in):
#include <stdbool.h>
bool ready = true;
if (ready && count > 0) { /* ... */ }
sizeof and the size of things
sizeof is a compile-time operator that yields the size in bytes of a type or expression. It is essential because, as the next lessons show, you must tell malloc exactly how many bytes you want:
printf("%zu\n", sizeof(int)); // e.g. 4
printf("%zu\n", sizeof(double)); // e.g. 8
int arr[10];
printf("%zu\n", sizeof(arr)); // 40 = 10 * sizeof(int)
Use %zu to print a size_t.
Weak typing and implicit conversions
C performs the usual arithmetic conversions automatically. This is convenient and dangerous:
int x = 5, y = 2;
double q = x / y; // q == 2.0, NOT 2.5! integer division happens first
double r = (double)x / y; // 2.5, because the cast forces double division
unsigned u = 1;
int s = -1;
if (s < u) { /* NOT taken: s is converted to a huge unsigned value */ }
Mixing signed and unsigned is a classic source of bugs. Compile with -Wall -Wextra -Wconversion to catch many of them, and cast explicitly when you mean it.
Operators
C has the operators you expect plus bitwise ones, which matter in systems code:
int flags = 0;
flags |= (1 << 3); // set bit 3
flags &= ~(1 << 3); // clear bit 3
bool isSet = flags & (1 << 3);
int n = 17;
int half = n >> 1; // 8 (arithmetic/logical shift)
int rem = n % 5; // 2 (modulo)
The ternary operator cond ? a : b and compound assignment (+=, *=, ...) are everywhere.
Control flow
The procedural control structures are familiar:
for (int i = 0; i < 5; i++) {
if (i % 2 == 0) continue;
printf("%d\n", i);
}
int n = 3;
while (n-- > 0) puts("tick");
switch (grade) {
case 'A': puts("excellent"); break;
case 'B': puts("good"); break;
default: puts("other"); break; // fall-through without break is intentional in C
}
Note the break after each case: C switch falls through to the next case if you omit it. That is occasionally useful but a frequent bug, so be deliberate.
undefined behavior: the thing that bites
C has a category called undefined behavior (UB): operations the standard gives no meaning, such as signed integer overflow, reading an uninitialized variable, or dividing by zero. UB is not a runtime error - the compiler is allowed to do anything, including optimizing your code into something you did not write. This is the price of C's speed, and it is why discipline and tools (the next-lesson sanitizers) matter so much.
int x; // uninitialized
printf("%d\n", x); // UB: prints garbage, or worse
Always initialize your variables.
Reference
- Integer types (cppreference): https://en.cppreference.com/w/c/types/integer
- Undefined behavior (cppreference): https://en.cppreference.com/w/c/language/behavior
Next: pointers and arrays - the heart of C.
Pointers and Arrays
Addresses, dereferencing, pointer arithmetic, array decay, and strings.
Pointers and Arrays
Pointers are what make C C. A pointer is a variable that holds the memory address of another value. Direct, unchecked access to memory through pointers is the source of both C's power and its danger - and it is the foundation for the manual memory management in the next lesson.
Addresses and dereferencing
Two operators do the core work: & ("address of") gives you the address of a variable, and * ("dereference") gives you the value at an address.
int x = 42;
int *p = &x; // p holds the address of x
printf("%d\n", x); // 42
printf("%p\n", (void *)p); // the address, e.g. 0x7ffe...
printf("%d\n", *p); // 42 (read through the pointer)
*p = 100; // write through the pointer
printf("%d\n", x); // 100 -- x changed!
The type int * means "pointer to int." The pointer itself is just a number (an address); its type tells the compiler how to interpret the bytes it points at and how far to move during arithmetic.
NULL and uninitialized pointers
A pointer that points to nothing should be set to NULL (from <stddef.h>; in C23 you can use nullptr). Dereferencing NULL or an uninitialized pointer is undefined behavior - typically a crash.
int *p = NULL;
if (p != NULL) {
*p = 1; // guarded
}
Always initialize pointers, and check pointers returned by functions like malloc before using them.
Pass-by-value, and how to "pass by reference"
C always passes arguments by value - the function gets a copy. To let a function modify the caller's variable, pass a pointer to it:
void increment(int *n) {
*n += 1; // modifies the caller's variable
}
int main(void) {
int count = 5;
increment(&count); // pass the address
printf("%d\n", count); // 6
return 0;
}
This is also how scanf works: scanf("%d", &n) passes the address so the function can store into your variable.
Arrays and pointer arithmetic
An array is a contiguous block of elements. Crucially, an array name decays to a pointer to its first element in almost every expression:
int a[5] = {10, 20, 30, 40, 50};
int *p = a; // p points at a[0]; the array decayed to a pointer
printf("%d\n", *p); // 10
printf("%d\n", *(p+1)); // 20 -- pointer arithmetic moves by sizeof(int)
printf("%d\n", a[2]); // 30
printf("%d\n", p[2]); // 30 -- a[i] is literally *(a + i)
Pointer arithmetic is scaled by the pointee's size: p + 1 advances by sizeof(int) bytes, not one byte. Because array indexing is just pointer arithmetic, a[i] and i[a] both compile - and there are no bounds checks. Reading or writing past the end is undefined behavior and one of the most common security bugs in all of computing.
Array decay loses the length
When you pass an array to a function, only the pointer is passed - the length is lost. So you must pass the length separately:
int sum(const int *xs, size_t n) { // const: we promise not to write through xs
int total = 0;
for (size_t i = 0; i < n; i++) total += xs[i];
return total;
}
int a[4] = {1, 2, 3, 4};
printf("%d\n", sum(a, 4)); // 10
This pointer+length pattern is so error-prone that newer languages baked it in: Hare's []u8 slices and Zig's slices bundle a pointer with a length so the size travels with the data. In C, it is on you to keep them in sync.
Strings are arrays of char
C has no dedicated string type. A string is just an array of char terminated by a null byte '\0':
char hello[] = "hi"; // 3 bytes: 'h', 'i', '\0'
char *world = "world"; // pointer to a read-only string literal
printf("%c\n", hello[0]); // h
printf("%zu\n", sizeof(hello)); // 3 (includes the terminator)
The standard library's string functions (strlen, strcpy, strcmp) all rely on that terminating '\0'. Forget the terminator and strlen walks off the end of your buffer. We cover the safe ways to handle strings in the standard-library lesson.
const and pointer types
const lets you express intent and catch mistakes. Read pointer declarations right-to-left:
const int *p; // pointer to const int (can't change *p)
int *const q = &x; // const pointer to int (can't change q itself)
const int *const r = &x; // both
Reference
- Pointers (cppreference): https://en.cppreference.com/w/c/language/pointer
- Array declaration (cppreference): https://en.cppreference.com/w/c/language/array
With pointers in hand, we can tackle the defining feature of C: manual memory management.
Manual Memory Management: malloc and free
Stack vs heap, malloc/calloc/realloc/free, and the bugs you own when you own memory.
Manual Memory Management: malloc and free
This is the lesson that defines C. There is no garbage collector, no destructors, and no built-in ownership. You ask the operating system for heap memory with malloc, and you give it back with free - and if you get the pairing wrong, the program is yours to debug. This manual model is the baseline that every other systems language reacts to.
Two kinds of memory: stack and heap
Local variables live on the stack: allocated automatically when a function is entered and freed automatically when it returns. They are fast, but their lifetime is tied to the scope, and the stack is small.
void f(void) {
int x = 5; // on the stack
int buf[100]; // on the stack
} // x and buf are reclaimed automatically here
The heap is a large pool of memory you manage by hand. Use it when you need memory that outlives the function that created it, or whose size you do not know until runtime. The four functions, all from <stdlib.h>, are:
| Function | What it does |
|---|---|
malloc(n) |
Allocate n uninitialized bytes; returns a pointer or NULL |
calloc(count, size) |
Allocate count*size bytes, zero-initialized |
realloc(p, n) |
Resize a previous allocation to n bytes |
free(p) |
Release a block previously returned by the above |
Allocating with malloc
malloc returns a void * (a generic pointer) to a block of uninitialized bytes, or NULL if it cannot satisfy the request. Always check for NULL.
#include <stdlib.h>
int *make_array(size_t n) {
int *a = malloc(n * sizeof(int)); // size in BYTES
if (a == NULL) { // allocation can fail
return NULL;
}
for (size_t i = 0; i < n; i++) {
a[i] = (int)i; // initialize before reading
}
return a; // caller now owns this memory
}
Note the idiom n * sizeof(int): malloc counts bytes, so you multiply the element count by the element size. Writing sizeof(*a) instead of sizeof(int) is even safer because it stays correct if the type changes.
Freeing - and the rule that governs everything
Every successful malloc/calloc/realloc must be matched by exactly one free:
int *a = make_array(10);
if (a != NULL) {
/* ... use a ... */
free(a); // give the memory back
a = NULL; // defensive: avoid accidental reuse
}
free(NULL) is explicitly safe and does nothing, which simplifies cleanup paths. Setting the pointer to NULL after freeing is a common defensive habit so a later accidental use crashes loudly instead of corrupting memory.
calloc and realloc
calloc zeroes the memory for you - useful when you want a clean slate. realloc grows or shrinks an existing block, possibly moving it, so you must assign its result back through a temporary (assigning directly would leak the old block if it returns NULL):
size_t cap = 4;
int *xs = calloc(cap, sizeof(int)); // 4 zeroed ints
// grow to 8
size_t newcap = 8;
int *tmp = realloc(xs, newcap * sizeof(int));
if (tmp == NULL) {
free(xs); // realloc failed; original block is still valid
return;
}
xs = tmp; // success: adopt the (possibly moved) block
The bugs you now own
Manual memory is powerful and unforgiving. The classic errors, all undefined behavior:
- Memory leak - you never
freea block, so it is lost until the process exits. - Double free - calling
freetwice on the same pointer corrupts the allocator. - Use-after-free - reading or writing through a pointer after
free(a dangling pointer). - Buffer overflow - writing past the end of an allocation.
- Uninitialized read - reading
malloc'd memory before writing it (it is garbage).
int *p = malloc(sizeof(int));
free(p);
*p = 5; // USE-AFTER-FREE: undefined behavior
free(p); // DOUBLE FREE: undefined behavior
Tools that find these bugs
Because the language gives no safety net, tooling substitutes for it. Compile with sanitizers during development:
$ gcc -g -fsanitize=address,undefined -o app app.c
$ ./app # AddressSanitizer reports leaks, overflows, use-after-free with stack traces
AddressSanitizer (-fsanitize=address) and UndefinedBehaviorSanitizer (-fsanitize=undefined) catch the majority of memory bugs at runtime. valgrind ./app is another classic leak/error detector. Make them part of your test runs.
How the other systems languages handle the same problem
C's fully manual model is one point on a spectrum. The seven systems languages in this collection all forgo a garbage collector but differ sharply in how you free:
- C - pair every
malloc/calloc/reallocwith a matchingfree; raw pointers, no help. - C++ -
new/deleteexist, but idiomatic code uses RAII: destructors run at scope exit, and smart pointers (std::unique_ptr,std::shared_ptr) automatedelete. - HolyC - the TempleOS dialect; manual
MAlloc()from a per-task heap andFree()(Free on NULL is allowed), running in ring 0 with no memory protection. - Zig - no hidden allocations: any function that allocates takes an explicit
Allocatorparameter, anddefer/errdeferschedule the free. - Hare - manual
alloc/freewithdeferfor cleanup, a tiny runtime, and no RAII. - Odin - manual
new/make/free/deleterouted through an implicit per-scopecontext.allocator, withdeferand swappable arena allocators. - Forth - rawest of all: bump-allocate static space with
HERE/ALLOT, read/write with@/!, and use the optionalALLOCATE/FREEword set for the heap.
Two recurring ideas separate the newer languages from C: defer (schedule the free next to the allocation so you cannot forget it) and explicit allocators (make every allocation visible and swappable). C has neither - which is exactly why the discipline in this lesson matters.
Reference
- malloc (cppreference): https://en.cppreference.com/w/c/memory/malloc
- Dynamic memory management (cppreference): https://en.cppreference.com/w/c/memory
Next we use heap memory to build real data with structs.
Structs, Unions, Enums, and typedef
Aggregate your own types, build linked structures on the heap, and model variants.
Structs, Unions, Enums, and typedef
C lets you build your own aggregate types out of the scalars and pointers you already know. Structs group related fields; unions overlap fields in the same memory; enums name integer constants; and typedef gives types convenient names. Combined with the heap, structs are how you build lists, trees, and every other data structure.
Structs: grouping fields
A struct is a record with named fields laid out (mostly) in order in memory:
struct Point {
int x;
int y;
};
struct Point p = {3, 4}; // positional initialization
struct Point q = {.x = 1, .y = 2}; // designated initializers (C99+)
printf("%d, %d\n", p.x, p.y); // access with the dot operator
You access fields of a struct value with . and fields through a pointer to a struct with ->:
struct Point *pp = &p;
pp->x = 10; // shorthand for (*pp).x = 10
typedef for ergonomics
Writing struct Point everywhere is tedious. typedef creates an alias so you can drop the struct keyword:
typedef struct {
double re;
double im;
} Complex;
Complex c = {.re = 1.0, .im = -2.0}; // no "struct" needed
Passing structs: value vs pointer
Passing a struct by value copies the whole thing. For anything larger than a couple of fields, pass a pointer - it is cheaper and lets the function modify the original:
double magnitude(const Complex *c) { // const pointer: read-only, no copy
return sqrt(c->re * c->re + c->im * c->im);
}
Structs on the heap: a growable vector
Combine structs with malloc to build dynamic data structures. Here is a minimal growable integer vector - the kind of thing other languages give you for free:
#include <stdlib.h>
typedef struct {
int *data;
size_t len;
size_t cap;
} Vec;
Vec vec_new(void) {
return (Vec){.data = NULL, .len = 0, .cap = 0};
}
int vec_push(Vec *v, int value) {
if (v->len == v->cap) {
size_t newcap = v->cap ? v->cap * 2 : 4;
int *tmp = realloc(v->data, newcap * sizeof(int));
if (!tmp) return -1; // out of memory; caller handles it
v->data = tmp;
v->cap = newcap;
}
v->data[v->len++] = value;
return 0;
}
void vec_free(Vec *v) { // YOU must call this - no destructor
free(v->data);
v->data = NULL;
v->len = v->cap = 0;
}
Because C has no destructors, there is nothing automatic about vec_free: you must remember to call it. This is the exact problem RAII solves in C++ and defer solves in Zig, Hare, and Odin. In C, a self-cleaning Vec is just a discipline you enforce by hand.
Self-referential structs: a linked list
A struct can contain a pointer to its own type, which is how you build linked structures:
typedef struct Node {
int value;
struct Node *next; // must use the tag name here
} Node;
Node *push_front(Node *head, int value) {
Node *n = malloc(sizeof(Node));
if (!n) return head;
n->value = value;
n->next = head;
return n; // new head
}
void list_free(Node *head) {
while (head) {
Node *next = head->next; // save before freeing
free(head);
head = next;
}
}
Note the order in list_free: you must read head->next before you free(head), or you have a use-after-free.
Unions: one memory, many interpretations
A union overlaps all its members in the same storage, so it is only ever holding one of them at a time. Its size is that of its largest member. Unions are used for low-level reinterpretation and, with a tag, for variant types:
typedef enum { TAG_INT, TAG_FLOAT } Tag;
typedef struct {
Tag tag;
union {
int i;
double d;
} as;
} Value;
Value v = {.tag = TAG_INT, .as.i = 42};
if (v.tag == TAG_INT) printf("%d\n", v.as.i);
This tagged union pattern is how C models "one of several types." It is entirely manual - you must keep the tag in sync with which union member is valid. Hare and Rust make this safe with first-class tagged unions; in C, a mismatched tag is just another bug.
Enums: named integer constants
enum defines a set of named integer constants, improving readability over magic numbers:
typedef enum {
RED, // 0
GREEN, // 1
BLUE = 5, // explicit value; next continues from here
PURPLE // 6
} Color;
Color c = GREEN;
Under the hood enums are just integers, so they offer no exhaustiveness checking - another place where stronger type systems improve on C.
Reference
- struct (cppreference): https://en.cppreference.com/w/c/language/struct
- union (cppreference): https://en.cppreference.com/w/c/language/union
Next we tour the standard library you will lean on every day.
The Standard Library, Headers, and the C ABI
stdio, string, the preprocessor, multi-file builds, and why everything speaks C.
The Standard Library, Headers, and the C ABI
C's standard library is deliberately small - there are no built-in collections, no networking, no JSON. What it does provide is I/O, strings, math, memory, and conversions, all through a handful of headers. This final lesson tours the essentials, shows how to structure a multi-file program, and explains C's most distinctive role: being the ABI that every other language speaks.
The headers you will use constantly
| Header | Provides |
|---|---|
<stdio.h> |
printf, scanf, fopen, fgets, file I/O |
<stdlib.h> |
malloc/free, atoi, qsort, exit, rand |
<string.h> |
strlen, strcpy, strcmp, memcpy, memset |
<math.h> |
sqrt, sin, pow (link with -lm) |
<stdint.h> |
fixed-width integer types |
<ctype.h> |
isdigit, toupper, character classification |
Formatted I/O
printf formats output; scanf reads input (remember the &). The format specifier must match the argument type, or you get undefined behavior:
int n = 7;
double pi = 3.14159;
char name[32];
printf("n=%d pi=%.2f\n", n, pi); // n=7 pi=3.14
printf("size_t needs %%zu: %zu\n", sizeof(int));
printf("Enter your name: ");
scanf("%31s", name); // %31s caps input to fit the buffer
Common specifiers: %d int, %u unsigned, %ld long, %zu size_t, %f double, %c char, %s string, %p pointer, %x hex.
Strings, safely
The classic string functions assume a null terminator and do no bounds checking, which is why strcpy/strcat are notorious overflow sources. Prefer the size-bounded variants and snprintf:
#include <string.h>
#include <stdio.h>
char dst[8];
strncpy(dst, "hello", sizeof(dst) - 1); // copy at most 7 chars
dst[sizeof(dst) - 1] = '\0'; // strncpy may not null-terminate!
// snprintf is the safe, general way to build strings
char buf[32];
snprintf(buf, sizeof(buf), "x=%d", 42); // never overflows buf
memcpy(dst, src, n) copies n raw bytes (the buffers must not overlap; use memmove if they do), and memset(p, 0, n) fills n bytes with a value.
Reading a file
#include <stdio.h>
int main(void) {
FILE *f = fopen("data.txt", "r");
if (!f) { perror("fopen"); return 1; } // perror prints the OS error
char line[256];
while (fgets(line, sizeof(line), f)) { // fgets is bounded - safe
fputs(line, stdout);
}
fclose(f); // close what you open
return 0;
}
Just like heap memory, an open FILE * is a resource you must release - fclose is to fopen what free is to malloc.
The preprocessor
Before compilation, the preprocessor handles #include, macros, and conditional compilation. Macros are pure text substitution, so they are powerful and easy to misuse:
#define PI 3.14159
#define SQUARE(x) ((x) * (x)) // parenthesize every argument and the whole body!
double area = PI * SQUARE(r);
#ifdef DEBUG
printf("debug: r=%f\n", r);
#endif
Always wrap macro parameters in parentheses - SQUARE(a + b) without them expands to a + b * a + b, a classic bug.
Multi-file programs and header guards
Real programs split into .c files (implementation) and .h files (declarations). The header declares the interface; the source defines it. An include guard stops a header from being pasted in twice:
/* vec.h */
#ifndef VEC_H
#define VEC_H
typedef struct { int *data; size_t len, cap; } Vec;
int vec_push(Vec *v, int value);
void vec_free(Vec *v);
#endif /* VEC_H */
/* main.c */
#include "vec.h" // quotes for your own headers, <> for system headers
Compile and link several files together:
$ gcc -Wall -Wextra -c vec.c # -> vec.o
$ gcc -Wall -Wextra -c main.c # -> main.o
$ gcc -o app vec.o main.o # link
For anything beyond a couple of files, drive this with a Makefile (or CMake), so a single make rebuilds only what changed.
qsort: generics via void pointers and function pointers
Without templates, the standard library does generics through void * and function pointers. qsort sorts any array given a comparison function:
#include <stdlib.h>
int cmp_int(const void *a, const void *b) {
int x = *(const int *)a, y = *(const int *)b;
return (x > y) - (x < y); // -1, 0, or 1 without overflow
}
int arr[] = {5, 2, 9, 1, 7};
qsort(arr, 5, sizeof(int), cmp_int); // arr is now sorted
The cast-from-void * dance is C's way of writing code that works for any type - powerful, but it sacrifices type safety, which is one reason later languages added generics and templates.
The distinctive feature: C is the universal ABI
C's most far-reaching legacy is not any single feature but its Application Binary Interface. The C calling convention and header model are the interop layer of computing: Python, Rust, Go, Zig, Java, and nearly every other language can call C functions, and they do so by speaking the C ABI. A library exposed with a C interface can be used from essentially everywhere.
/* A function exported for other languages to call via FFI */
int add(int a, int b) {
return a + b;
}
This is why "writing the bindings in C" is the default for cross-language work, and why understanding C - its memory model, its pointers, its calling convention - pays off no matter what language you ultimately write in. Every systems language in this collection, from C++ to Zig to Forth, defines itself in relation to the model you have now learned.
Where to go next
You have the full arc: the compiler and standards, types and control flow, pointers and arrays, manual memory with malloc/free, your own types with structs and unions, and the standard library and ABI. The best next step is to build something small - a calculator, a linked list, a tiny shell - and run it under AddressSanitizer so the tools teach you where the bodies are buried.
Reference
- C standard library (cppreference): https://en.cppreference.com/w/c/header
- C programming language standard (WG14): https://www.open-std.org/jtc1/sc22/wg14/