Book
Book
Version 0.4.0
Duc-Tam Nguyen
2025-10-26
Table of contents
Content 6
The Book 7
2
29. Deep vs Shallow Copies . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
30. Practice: Manual Memory Management . . . . . . . . . . . . . . . . . . . . 101
3
64. Pipes and Redirection . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 286
65. Signals and Signal Handlers . . . . . . . . . . . . . . . . . . . . . . . . . . . 294
66. Memory Mapping (mmap) . . . . . . . . . . . . . . . . . . . . . . . . . . . 301
67. Time and Clock APIs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 307
68. Environment Variables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 313
69. Error Handling and Return Codes . . . . . . . . . . . . . . . . . . . . . . . 318
70. Practice: Mini Shell in C . . . . . . . . . . . . . . . . . . . . . . . . . . . . 324
4
100. Practice: Build Your Own Mini Project . . . . . . . . . . . . . . . . . . . 494
Epilogue. The Spirit of C . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 499
The Path Beyond . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 500
A Note from the Author . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 500
Final Exercise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 501
5
Content
6
The Book
7
Chapter 1. Getting Started
C is the language that sits closest to the machine while still feeling human to write. It’s not
the newest or the easiest, but it’s one of the most powerful. Every modern operating system,
compiler, and database has a core written in C, from Linux and Git to Python’s interpreter
and even parts of your browser.
Learning C gives you something no other language can: an understanding of how computers
actually work. You’ll see how memory is managed, how data moves, how the CPU runs your
code, and how everything you write turns into tiny instructions that the machine understands.
C teaches discipline. There’s no garbage collector or safety net. You decide when to allocate
memory, when to free it, and what happens when you forget. You learn precision and control,
the same skills that make great programmers in any language.
Tiny Code
#include <stdio.h>
int main(void) {
printf("Hello, C World!\n");
return 0;
}
Run this and you’ve done what every C programmer starts with, printing your first line of text
to the screen. It’s small, but it carries the spirit of C: direct, explicit, and clear.
Why It Matters
C is the foundation of all systems programming. When you understand it, higher-level languages
make more sense. You’ll see why compilers work the way they do, why memory errors happen,
and how performance decisions ripple through an entire program.
8
Even if you never write production C code, the mindset it builds, careful reasoning, attention
to detail, respect for the machine, will shape how you write code in any language.
Try It Yourself
4. Run it:
./hello
5. Modify the message and try printing more lines. You’ve just built your first C program.
Before you can write and run C programs, you need a compiler. A compiler is a tool that
translates your human-readable code into the machine instructions that your CPU understands.
In C, this process is explicit, you see it, control it, and learn from it.
There are many compilers available, but three are most common:
• GCC (GNU Compiler Collection)**, The standard compiler on Linux and macOS, known
for reliability and wide support.
• Clang, A modern compiler built for speed, cleaner diagnostics, and integration with
tools like LLVM.
• TinyCC (tcc), A super-lightweight compiler that’s perfect for learning and quick testing.
Tiny Code
You can check if you already have a compiler installed by running one of these commands in
your terminal:
gcc --version
clang --version
tcc --version
If you see a version number, you’re ready. If not, you’ll need to install one.
9
Installing on Different Systems
Linux (Debian/Ubuntu):
This installs GCC along with other useful tools like make.
macOS (with Xcode Command Line Tools):
xcode-select --install
1. Go to Mingw-w64.
2. Download and install it.
3. Add the compiler’s bin folder to your system PATH.
4. Open cmd or PowerShell and run gcc --version to confirm.
Or, if you prefer an all-in-one environment, install WSL (Windows Subsystem for Linux)
and use the Linux commands above.
Why It Matters
Installing a compiler is your first step toward understanding how programs become executables.
In C, there’s no hidden build system or automatic runtime, everything that happens between
writing code and running it is visible. That clarity is part of what makes C such a powerful
learning tool.
When you install your compiler, you’re also installing the ability to explore how software really
works.
Try It Yourself
10
int main(void) { return 0; }
4. Compile it:
gcc test.c -o test
5. Run it:
./test
If it runs with no output, that’s perfect, your compiler is ready. You’ve just built your very
first executable program from source code.
Now that your compiler is ready, it’s time to write your first real C program. This is where the
magic happens, you’ll write plain text, compile it into machine instructions, and watch your
computer follow your commands exactly.
C doesn’t hide what’s happening under the hood. Every step, writing, compiling, linking,
running, is visible and under your control.
Tiny Code
#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
return 0;
}
Hello, world!
11
Breaking It Down
• #include <stdio.h> This tells the compiler to use the Standard Input/Output library,
which provides the printf function.
• int main(void) Every C program starts with a main function. It’s the entry point,
where execution begins.
• printf("Hello, world!\n"); This prints text to the screen. The \n means “newline,”
so the next output starts on a new line.
• return 0; When main returns 0, it tells the operating system that your program finished
successfully.
Why It Matters
Your “Hello, world” may look simple, but it represents an entire process:
1. The compiler translates your text (hello.c) into object code (hello.o).
2. The linker combines that code with standard libraries.
3. The executable (hello) is pure machine instructions.
4. The operating system loads and runs it.
Understanding this flow is what makes C special, it’s not just about writing code, but about
knowing how code becomes software.
Try It Yourself
3. Try leaving out the semicolon, what error does the compiler show?
4. Try removing #include <stdio.h>, what happens then?
5. Experiment and break things. Every error teaches you how C thinks.
You’ve just written and run your first C program, a direct conversation between you and the
machine. From here, every new piece of code builds on this simple moment of control and
understanding.
12
4. Anatomy of a C Program
Now that your first program runs, let’s open it up and look inside. Every C program follows
a clear structure, a set of rules that tells both you and the compiler what each part means.
Understanding this structure early will help you read, write, and debug code with confidence.
Tiny Code
// 2. Function definition
int main(void) { // main: entry point of every C program
printf("Hello, C!\n"); // 3. Statement: prints a message
return 0; // 4. Return statement: signals success
}
1. Preprocessor Directives Lines that begin with # are handled before the code is even
compiled. They include or define things that your program depends on. Example:
#include <stdio.h>
#define PI 3.14159
2. Functions Every C program is made of functions. The function main() is special, it’s
where your program starts. You can define more functions to organize your code.
3. Statements Each instruction inside a function ends with a semicolon. These are the
steps your program takes, one by one.
4. Comments Comments are ignored by the compiler but read by humans. Use them to
explain why your code does something, not just what it does.
// This is a single-line comment
/* This is a multi-line comment */
13
Why It Matters
C is a structured language. Every function, statement, and declaration lives inside a clear
boundary. Unlike scripting languages, there’s no automatic setup or hidden runtime, everything
you see is everything that runs.
Learning the anatomy of a C program gives you a mental map:
Once this map becomes natural, reading even large C programs starts to feel easy and logical.
Try It Yourself
void greet(void) {
printf("Welcome to C programming!\n");
}
int main(void) {
greet();
return 0;
}
3. Add another function, maybe void bye(void) that prints a goodbye message, and call
it after greet().
4. Try removing return 0;, notice how the program still runs, but adding it makes your
intent clear.
Every C program you’ll ever write follows this basic shape. Once you can recognize these parts,
you can start building programs that are longer, smarter, and closer to the system.
14
5. Using Headers and the Preprocessor
Every C program begins before it even starts running, with something called the preprocessor.
Before the compiler turns your code into machine instructions, the preprocessor prepares it: it
pulls in files, replaces macros, and sets up everything your program needs. This step is what
makes #include <stdio.h> work, and it’s key to understanding how larger C projects are
organized.
Tiny Code
int main(void) {
printf("PI is approximately %.2f\n", PI);
return 0;
}
When you compile this program, the preprocessor replaces PI with 3.14159 and includes the
contents of the file stdio.h before the compiler even starts.
You can see the preprocessed result by running:
gcc -E program.c
It will output a much longer version of your code, showing all the lines that stdio.h added
behind the scenes.
Headers are declaration files. They tell the compiler what exists, like functions, constants,
and types, without actually providing the code (the definitions). For example, stdio.h declares
the function printf() so the compiler knows how to call it.
There are two main ways to include headers:
• System headers:
#include <stdio.h>
15
• User-defined headers:
#include "myutils.h"
Why It Matters
The preprocessor is like the “setup crew” for your program. It doesn’t run your code, it prepares
it. By understanding headers and macros, you can:
When you write #include <stdio.h>, you’re tapping into decades of reliable, shared code,
one of the greatest strengths of the C ecosystem.
Try It Yourself
#endif
main.c
#include <stdio.h>
#include "mathutils.h"
int main(void) {
int n = 5;
printf("The square of %d is %d\n", n, SQUARE(n));
return 0;
}
16
2. Compile and run:
gcc main.c -o main
./main
3. Try editing the macro in mathutils.h to add a CUBE(x) function, and use it in main.c.
4. Then run:
gcc -E main.c | less
to explore the preprocessed code and see how includes and macros expand.
Once you grasp headers and preprocessing, you’ll understand how large C codebases stay
organized, and how a simple #include line can unlock an entire library of functionality.
When you press Enter to compile your C program, a lot happens behind the scenes. Your source
code goes through several stages before it becomes a runnable executable. Understanding these
steps is essential, it turns compilation errors from mysteries into simple, fixable clues.
Tiny Code
#include <stdio.h>
int main(void) {
printf("Learning the C build process!\n");
return 0;
}
17
Output:
1. Preprocessing The preprocessor handles all lines starting with #. It expands macros,
includes headers, and prepares code for compilation. Command to inspect:
gcc -E hello.c | less
2. Compilation The compiler translates the preprocessed code into assembly language, and
then into object code. Each source file (like hello.c) becomes an object file (hello.o).
Command:
gcc -c hello.c
3. Linking The linker combines all object files and libraries into one final executable. For
example, printf comes from the C standard library (libc), so the linker connects your
code to it. Command:
gcc hello.o -o hello
4. Execution Once linked, your binary (hello) is loaded by the operating system and
executed by the CPU. Command:
./hello
Why It Matters
C gives you control over every stage of this process. Most modern languages hide compilation
or linking, but in C, these steps are transparent and configurable. When something goes wrong,
a missing function, an undefined symbol, or a broken include, you’ll know exactly which stage
to look at.
Mastering the build process also opens the door to deeper skills:
Every system programmer eventually learns to think like a compiler, and this is where that
thinking begins.
18
Try It Yourself
int main(void) {
greet();
return 0;
}
greet.c
#include <stdio.h>
void greet(void) {
printf("Hello from another file!\n");
}
2. Try breaking it: Delete the void greet(void); line in main.c and recompile, see how
the compiler warns you about an implicit declaration.
3. Observe the stages: Add flags like -Wall -O2 -v to see detailed messages from the
compiler and linker.
Once you understand compilation and linking, you’ve unlocked one of the most powerful parts
of C, the ability to control exactly how your software is built, combined, and executed.
No C programmer avoids errors. In fact, the compiler’s messages are your best teachers. Each
warning or error is the compiler’s way of saying, “Something here doesn’t make sense yet.”
Learning to read and fix them early will save you hours later and make debugging a natural
part of your process.
19
Tiny Code
#include <stdio.h>
int main(void) {
int a = 5
printf("The value of a is %d\n", a);
return 0;
}
Output:
This means the compiler found a missing semicolon. The message even tells you where (line
4) and why (expected ';' before 'printf').
Fix it by adding the missing semicolon:
int a = 5;
1. Syntax Errors These are the easiest to fix. You’ve broken a grammar rule. Example:
missing ;, mismatched braces {}, or incorrect parentheses.
2. Type Errors You’re using variables or functions in a way that doesn’t match their type.
Example:
int x = "hello"; // error: assigning string to int
3. Undeclared Identifiers You’re using a variable or function that the compiler hasn’t
seen yet. Example:
20
printf("Value: %d\n", number); // error: ‘number' undeclared
4. Linker Errors Compilation succeeds, but linking fails because something is missing.
Example:
This means the compiler saw a declaration but couldn’t find the actual function definition.
5. Warnings Warnings don’t stop compilation, but they often point to potential bugs.
Example:
warning: variable ‘x' set but not used
Always pay attention to warnings, clean builds (no warnings) are a mark of quality
code.
Why It Matters
Every programmer makes mistakes. What matters is how fast you can understand what the
compiler is saying. In C, error messages are usually precise and honest, they tell you exactly
what broke. By learning to interpret them, you’re training yourself to debug with logic, not
luck.
Good habits:
Try It Yourself
Compile with:
gcc -Wall test.c
You’ll get:
21
2. Using an undeclared variable:
#include <stdio.h>
int main(void) {
printf("%d\n", x);
return 0;
}
3. Fix each one until your program compiles cleanly with no warnings.
Errors are not failures, they’re the compiler’s way of guiding you toward understanding. The
more errors you fix, the better you become at speaking the language of the machine.
C was born in the Unix world, and the command line is its natural home. If you can move
comfortably in the terminal, you’ll understand what your tools are doing, compiling, linking,
and running programs directly. This section gives you the essential commands you’ll need to
build and explore C projects like a real systems programmer.
Tiny Code
#include <stdio.h>
int main(void) {
printf("Hello from the terminal!\n");
return 0;
}
Output:
22
Hello from the terminal!
That’s the full cycle: write → compile → run. Now let’s look at the basic tools that make that
process smoother.
3. cd – Change directories
cd projects/c_programs
5. rm – Remove files
rm hello
Press q to exit.
8. echo – Print a message or variable
echo "Compiling C!"
23
Why It Matters
The command line isn’t just for building code, it teaches you how your tools actually work.
In C, there’s no hidden environment running behind a button click. Each command you type
does exactly one job, and understanding those jobs gives you full control.
This mindset, knowing what happens under the hood, is what makes C programmers comfortable
working close to the machine.
Try It Yourself
Add code with your favorite text editor (like nano hello.c), then compile and run.
2. Use compiler flags:
gcc -Wall -O2 hello.c -o hello
./hello
Read a few lines, knowing how to find help is as important as coding itself.
C and the command line grew up together. Once you get comfortable typing and compiling by
hand, you’ll start to feel how programs, files, and processes fit together. That’s the real start
of systems programming, not just writing code, but commanding the computer directly.
24
9. Setting Up a Minimal Project Structure
As your C programs grow, you’ll quickly outgrow the single-file “hello.c” style. Real projects
are made of multiple source files, headers, and sometimes libraries. A clear folder structure
keeps your work clean, easy to build, and easy to maintain. In this section, you’ll create a
small, organized layout, the same structure used by professionals.
Tiny Code
my_project/
��� include/
� ��� greet.h
��� src/
� ��� greet.c
��� main.c
��� Makefile
include/greet.h
#ifndef GREET_H
#define GREET_H
#endif
src/greet.c
#include <stdio.h>
#include "greet.h"
main.c
25
#include "greet.h"
int main(void) {
greet("C Learner");
return 0;
}
Makefile
CC = gcc
CFLAGS = -Wall -Iinclude
SRC = main.c src/greet.c
OUT = my_program
$(OUT): $(SRC)
$(CC) $(CFLAGS) $(SRC) -o $(OUT)
clean:
rm -f $(OUT)
Output:
Hello, C Learner!
1. include/ Holds header files (.h), declarations of functions, constants, and types. You
include these in .c files using quotes:
#include "greet.h"
2. src/ Contains source files (.c) that implement functions declared in headers.
3. main.c The entry point of your program, this file usually just calls functions from src/.
4. Makefile Defines how to build the program. You can run make instead of typing long
gcc commands.
5. Output binary The compiled executable (here my_program) stays in the project’s root
for convenience.
26
Why It Matters
Even small C utilities benefit from structure, you’ll thank yourself later when you revisit your
code.
Try It Yourself
then rebuild.
4. Add another pair of files, src/farewell.c and include/farewell.h, with a goodbye
function, and call it from main.c.
5. Run make clean to delete the binary and rebuild fresh.
This small structure is the seed of every serious C project. Once you can organize your files
this way, you’re ready to grow into larger systems, libraries, tools, and applications that others
can use and build upon.
27
Chapter 2. Language Basics
In C, everything begins with types. A type tells the compiler how much memory to reserve,
how to interpret the bits stored there, and what operations are allowed. Understanding types
is the foundation of writing safe and efficient C programs, it’s how you speak the computer’s
native language precisely.
Tiny Code
#include <stdio.h>
int main(void) {
int age = 25; // integer
float height = 1.75; // floating-point number
char initial = 'A'; // single character
double weight = 68.4; // double-precision number
return 0;
}
Output:
Age: 25
Height: 1.75
Initial: A
Weight: 68.4
28
Core Built-in Types
Sizes may vary depending on system and compiler, but the relationships remain
consistent.
Example:
A variable is simply a named piece of memory. You declare it by specifying its type and
name:
Or both together:
Multiple declarations:
29
int x = 1, y = 2, z = 3;
Variables must be declared before you use them, and their type cannot change.
Why It Matters
C is a statically typed language, meaning every variable’s type is known at compile time.
This makes programs faster and safer, because the compiler can:
When you understand data types, you understand how your code maps directly to the machine’s
memory.
C forces you to think carefully about what kind of data you’re working with, a skill that
improves every program you write, in any language.
Try It Yourself
3. Try using an unsigned int and print what happens if you assign a negative value.
4. Use sizeof() to inspect how big each type is on your system:
printf("Size of int: %zu bytes\n", sizeof(int));
Every number, character, and pointer in C starts here, in the precise world of types and
variables. Once you’re fluent in these, memory layout, structs, and pointers will make perfect
sense.
30
12. Constants, Literals, and Enumerations
C programs often rely on values that never change, numbers, characters, or named constants
used throughout your code. Instead of sprinkling magic numbers everywhere, you can give
them meaningful names and keep your program readable, safe, and easy to maintain.
Tiny Code
#include <stdio.h>
int main(void) {
printf("Pi: %.2f\n", PI);
printf("Days in a week: %d\n", DAYS_IN_WEEK);
printf("Direction EAST has value: %d\n", EAST);
return 0;
}
Output:
Pi: 3.14
Days in a week: 7
Direction EAST has value: 1
Constants in C
• No memory is used.
• No type checking, the compiler just replaces the text.
31
2. Constant variables (const) These are real variables stored in memory but cannot be
modified after initialization.
const double SPEED_OF_LIGHT = 299792458.0;
• Type safe.
• Preferred for constants in modern C code.
Enumerations
An enum (short for enumeration) defines a set of named integer constants. They make your
code self-documenting and prevent mistakes with raw numbers.
enum TrafficLight {
RED, // 0
YELLOW, // 1
GREEN // 2
};
int main(void) {
enum TrafficLight signal = GREEN;
if (signal == GREEN)
printf("Go!\n");
return 0;
}
enum Month {
JAN = 1, FEB, MAR, APR, MAY, JUN,
JUL, AUG, SEP, OCT, NOV, DEC
};
32
Why It Matters
Without them, large programs become fragile and full of unexplained numbers, a maintenance
nightmare.
Good C programmers use constants to express intent, not just values.
Try It Yourself
1. Replace every numeric literal in your old programs with a #define or const. Example:
#define MAX_SCORE 100
const float TAX_RATE = 0.08;
2. Create an enum for days of the week, and print MONDAY and FRIDAY.
3. Assign custom values in your enum (e.g. start with SUNDAY = 1).
4. Experiment: try changing const int x = 5; x = 10; , notice the compiler stops you
from modifying it.
5. Use printf to print literal values in different formats:
printf("%d %x %o\n", 255, 255, 255); // decimal, hex, octal
Constants are how you make your C programs speak clearly. They turn numbers into ideas,
and that’s what transforms code from working to understandable.
Operators are the building blocks of computation in C. They let you perform arithmetic,
compare values, manipulate bits, and combine logic, all in concise expressions. Once you
understand how operators work and how they interact through precedence and associativity,
you can write clear, efficient code that behaves exactly as you expect.
33
Tiny Code
#include <stdio.h>
int main(void) {
int a = 10, b = 3;
a += 5; // same as a = a + 5
printf("a after += 5: %d\n", a);
return 0;
}
Output:
a + b = 13
a - b = 7
a * b = 30
a / b = 3
a % b = 1
a after += 5: 15
Arithmetic Operators
Tip: If you use floating-point numbers (float, double), division produces decimals.
34
Relational and Logical Operators
Example:
int x = 5;
printf("%d\n", ++x); // prefix: increments, then uses value (6)
printf("%d\n", x++); // postfix: uses value, then increments (6)
printf("%d\n", x); // final value is 7
35
Bitwise Operators
C gives you direct access to bits, useful for systems, embedded, or optimization tasks.
Example:
When you write complex expressions, C follows operator precedence rules. For example:
Why It Matters
Operators are where logic meets the machine. They translate mathematical ideas and
control decisions into instructions the CPU executes directly. Understanding how expressions
are built and evaluated helps you:
In low-level work (like bitwise operations or embedded systems), operator mastery is essential.
36
Try It Yourself
1. Write a small program that takes two integers and prints their:
• Sum
• Difference
• Product
• Quotient
• Remainder
5. Experiment with parentheses and operator order until you can predict every result.
Operators are where C’s simplicity meets its power, a small set of symbols that give you total
control over computation, logic, and even raw bits.
Programs become powerful when they can decide, when they can choose one path or another
depending on data or conditions. In C, control flow statements give you that power. They
determine how your program moves through different parts of your code.
Tiny Code
#include <stdio.h>
int main(void) {
int temperature = 30;
37
printf("It's cool.\n");
}
return 0;
}
Output:
It's warm.
This is how you express logic in C: by checking conditions and executing only the code that
matches.
if (condition) {
// do something if true
} else if (another_condition) {
// do something else
} else {
// default action
}
Each if or else if checks a condition that must evaluate to true (non-zero) or false
(zero).
Example:
38
Comparison and Boolean Logic
C doesn’t have a built-in bool type in older standards, but since C99, you can include it:
#include <stdbool.h>
bool is_ready = true;
if (is_ready) printf("Let's go!\n");
Nested if Statements
if (x > 0) {
if (x % 2 == 0)
printf("Positive even number\n");
else
printf("Positive odd number\n");
}
Just be careful, too much nesting makes code harder to read. When logic gets complex, consider
reorganizing or using a switch statement.
switch is a clean way to test one variable against several fixed values.
#include <stdio.h>
int main(void) {
int day = 3;
switch (day) {
case 1:
printf("Monday\n");
break;
case 2:
printf("Tuesday\n");
break;
39
case 3:
printf("Wednesday\n");
break;
default:
printf("Another day\n");
}
return 0;
}
Output:
Wednesday
Each case label marks a potential branch. break stops the switch from “falling through” into
the next case.
You can group multiple cases:
switch (ch) {
case 'a':
case 'A':
printf("Letter A detected\n");
break;
}
40
Why It Matters
Control flow gives your programs intelligence. Instead of running straight through, your code
reacts to input, conditions, and data. C’s branching statements are simple but flexible, they’re
the building blocks of everything from sorting algorithms to operating system schedulers.
When you understand how to control execution, you can shape your program’s logic precisely.
Try It Yourself
2. Extend it:
4. Try replacing your if statements with a ternary operator where it makes sense.
Control flow is how you think in code, it’s how you teach your program to make decisions just
like you do.
Sometimes you need your program to repeat something, a calculation, a print statement, or a
check, again and again. Instead of copying the same line of code many times, you use loops.
Loops make your program efficient, compact, and able to handle dynamic data of any size.
41
The for Loop
Example:
Explanation:
int n = 5;
while (n > 0) {
printf("n = %d\n", n);
n--;
}
The do-while loop guarantees at least one execution, because the condition is checked after
the body.
42
int i = 0;
do {
printf("Running once! i = %d\n", i);
i++;
} while (i < 1);
It’s useful for input validation or repeating tasks until the user chooses to stop.
Output:
1 2 3 4 6 7
Nested Loops
You can place one loop inside another to handle grids, tables, or multiple dimensions.
Output:
i=1, j=1
i=1, j=2
i=2, j=1
i=2, j=2
i=3, j=1
i=3, j=2
43
Tiny Code
Here’s a complete program that demonstrates all three types of loops and control flow features:
#include <stdio.h>
int main(void) {
// for loop
printf("for loop:\n");
for (int i = 1; i <= 5; i++) {
printf("%d ", i);
}
printf("\n\n");
// while loop
printf("while loop:\n");
int n = 3;
while (n > 0) {
printf("n = %d\n", n);
n--;
}
printf("\n");
// do-while loop
printf("do-while loop:\n");
int x = 0;
do {
printf("x = %d\n", x);
x++;
} while (x < 1);
printf("\n");
// nested loop
printf("\nnested loops:\n");
44
for (int i = 1; i <= 2; i++) {
for (int j = 1; j <= 3; j++) {
printf("(%d,%d) ", i, j);
}
printf("\n");
}
return 0;
}
Why It Matters
Loops are the engine of repetition in every C program. They make it possible to:
In C, loops are close to how the CPU itself operates, each iteration is a direct cycle of logic and
computation. By mastering them, you control how your program moves, stops, and repeats,
the heartbeat of every algorithm.
Try It Yourself
45
while (1) {
printf("Press Ctrl+C to stop\n");
break; // or add a condition to exit
}
Once you’re comfortable with loops, you can build patterns, algorithms, and data processors,
all by controlling how many times code repeats and under what conditions.
Functions are how you break a program into smaller, reusable pieces. Each function performs
one specific task, you call it when needed, pass in data (parameters), and get something back
(a return value). Functions make your code organized, testable, and easier to understand.
return_type function_name(parameter_list) {
// body of the function
return value;
}
Example:
Here:
46
Declaring and Defining Functions
In C, you must declare a function before using it. The declaration tells the compiler what to
expect.
int main(void) {
int result = add(3, 4);
printf("Result: %d\n", result);
return 0;
}
Output:
Result: 7
Passing Parameters
When you call a function, the arguments are passed by value, a copy of each value is made.
Changing parameters inside the function does not affect the original variables.
void change(int x) {
x = 10;
}
int main(void) {
int a = 5;
change(a);
printf("%d\n", a); // still 5
return 0;
}
If you want to modify the original variable, use pointers (you’ll explore this in Chapter 3):
47
void change(int *x) {
*x = 10;
}
Return Values
A function can return a value using return. The type of the returned value must match the
function’s declared return type.
void greet(void) {
printf("Hello!\n");
}
Tiny Code
#include <stdio.h>
// function declarations
int add(int a, int b);
int subtract(int a, int b);
double divide(double a, double b);
void greet(const char *name);
// main function
int main(void) {
greet("C Learner");
48
printf("Difference: %d\n", diff);
printf("Quotient: %.2f\n", quotient);
return 0;
}
// function definitions
int add(int a, int b) {
return a + b;
}
Output:
Hello, C Learner!
Sum: 15
Difference: 5
Quotient: 2.00
49
Why It Matters
Functions are the building blocks of every program. They let you:
In C, you’ll use functions for everything, from arithmetic helpers to memory allocators, system
calls, and modular libraries.
Try It Yourself
Functions are how C programs grow. Each one is a small tool, and together, they become
complete systems.
Every variable in C exists within a specific scope (where it can be accessed) and has a lifetime
(how long it exists in memory). Understanding both is essential to avoid common bugs, from
name conflicts to mysterious “garbage values.” Once you know where and how variables live,
you’ll start thinking like the compiler.
Variable Scope
1. Block scope (local variables) Declared inside a function or block { ... }. Accessible
only within that block.
void example(void) {
int x = 10; // local to this function
printf("%d\n", x);
}
50
You can’t access x outside of example().
2. File scope (global variables) Declared outside of all functions. Accessible anywhere
in the file after declaration.
int counter = 0; // global variable
void increment(void) {
counter++;
}
3. Function parameter scope Parameters behave like local variables, visible only within
the function.
void greet(const char *name) {
printf("Hello, %s!\n", name);
}
Variable Lifetime
1. Automatic (default) Local variables are created when a function starts and destroyed
when it ends.
void demo(void) {
int temp = 42; // exists only while demo() runs
}
2. Static Declared with the static keyword, they keep their value between function calls.
void counter(void) {
static int count = 0; // initialized only once
count++;
printf("Count: %d\n", count);
}
51
3. Dynamic Created manually using malloc() or calloc(), they live until you free()
them. (You’ll learn this in Chapter 3.)
4. Global Exist for the entire duration of the program.
Storage Classes in C
Tiny Code
#include <stdio.h>
void demo_scope(void) {
int local_var = 5; // block scope
static int persistent = 0; // retains value between calls
int main(void) {
printf("First call:\n");
demo_scope();
printf("\nSecond call:\n");
demo_scope();
52
Compile and run:
Output:
First call:
Global: 10, Local: 5, Static: 0
Second call:
Global: 10, Local: 5, Static: 1
Why It Matters
Scope and lifetime are the invisible structure beneath your code. They define what data is
available where, and for how long. Without understanding them, you’ll face bugs like:
Once you know how the compiler manages variables, you can reason about memory, performance,
and correctness with confidence.
Try It Yourself
1. Write a function with a static counter and call it three times. Observe how the count
persists.
2. Add a global variable, modify it from two different functions, and print the result.
3. Create nested blocks with variables of the same name, see how shadowing behaves.
4. Move a variable outside a function and mark it static. Try accessing it from another
function, what happens?
5. Rewrite your earlier “calculator” example using global and local variables to see the
difference.
When you understand scope and lifetime, you gain control over how your program’s data moves,
lives, and dies, a skill every true C programmer needs.
53
18. Return Values and Function Signatures
Functions not only perform tasks but often communicate results back to the caller. They do
this through return values. Every C function has a signature, a declaration that defines its
return type, name, and parameters. Getting comfortable with signatures and return values
helps you write clean, predictable, and modular programs.
return_type function_name(parameter_list);
1. What kind of value the function returns (int, double, void, etc.)
2. What the function is called
3. What arguments it expects and their types
Example:
This says: “max is a function that takes two integers and returns an integer.”
Returning Values
You use the return keyword to send a value back from a function.
The type of the value you return must match the function’s declared return type.
If a function doesn’t need to return anything, declare it as void:
void greet(void) {
printf("Hello!\n");
}
A void function can still perform actions, it just doesn’t produce a result.
54
Multiple Return Points
_Bool is_even(int n) {
return n % 2 == 0;
}
For more complex data, you’ll later learn how to return pointers or structs.
Tiny Code
#include <stdio.h>
#include <stdbool.h>
// function declarations
55
int add(int x, int y);
double divide(double a, double b);
bool is_even(int n);
void greet(const char *name);
int main(void) {
greet("C Programmer");
return 0;
}
// function definitions
int add(int x, int y) {
return x + y;
}
bool is_even(int n) {
return n % 2 == 0;
}
56
gcc return_demo.c -o return_demo
./return_demo
Output:
Hello, C Programmer!
Sum: 10
Quotient: 2.50
Is sum even? Yes
Why It Matters
Return values are how functions communicate. By designing clear and meaningful signatures:
• You make your code predictable, every function has a defined purpose and output.
• The compiler can check correctness, mismatched types raise warnings.
• You can compose functions, one function’s return becomes another’s input.
In large systems, consistent signatures and meaningful return types form the backbone of good
API design.
Try It Yourself
Return values give your functions purpose, they turn simple actions into reusable building
blocks that make your programs expressive, modular, and alive.
When your program grows beyond a single file, you begin linking multiple code units together,
functions and data that live in different files. This linking step decides how your program
combines and shares code. There are two main ways to do it in C: static linking and dynamic
linking. Understanding both is essential for building real-world software.
57
The Big Picture
Static Linking
Static linking copies all the necessary library code directly into your program at build time.
Example command:
Cons:
Dynamic Linking
Dynamic linking (or shared linking) links your program to shared libraries (.so on Linux,
.dll on Windows) at runtime instead of embedding them.
Example:
58
Here, -lm tells the linker to use the shared math library ([Link]).
Your program keeps the library separate, loading it when executed.
Pros:
• Smaller executables
• Libraries can be updated independently
• Multiple programs share the same library in memory
Cons:
Tiny Code
#include <stdio.h>
mathutils.h
#ifndef MATHUTILS_H
#define MATHUTILS_H
#endif
main.c
59
#include <stdio.h>
#include "mathutils.h"
int main(void) {
int x = 4, y = 5;
printf("Add: %d\n", add(x, y));
printf("Multiply: %d\n", multiply(x, y));
return 0;
}
Output:
Add: 9
Multiply: 20
Output:
Add: 9
Multiply: 20
Now your executable depends on the shared [Link], the same library could be used
by many other programs.
60
Why It Matters
Linking determines how your software connects and shares code. It affects:
Static linking is great for small, standalone tools. Dynamic linking is better for large systems,
shared components, or when you rely on system libraries (like libc, libm, pthread).
Understanding linking makes you a systems thinker, you’ll know how the pieces of your program
fit together at the binary level.
Try It Yourself
Static vs dynamic linking is where your C programs move from “source code” to real-world
software, how your logic becomes part of an executable that lives, loads, and runs on any
machine.
Now that you’ve learned about functions, variables, loops, control flow, and linking, it’s time
to bring everything together. You’ll build a simple calculator that performs basic arithmetic
using clean modular code. This small project will reinforce everything from Chapters 11–19,
data types, operators, control flow, and reusable functions.
Project Overview
61
• Repeats until the user chooses to quit
Tiny Code
calculator.c
#include <stdio.h>
#include <stdbool.h>
// Function declarations
double add(double a, double b);
double subtract(double a, double b);
double multiply(double a, double b);
double divide(double a, double b);
void print_menu(void);
int main(void) {
double num1, num2, result;
char op;
bool running = true;
while (running) {
print_menu();
printf("Enter an operator (+, -, *, /) or q to quit: ");
scanf(" %c", &op);
switch (op) {
62
case '+':
result = add(num1, num2);
printf("Result: %.2f\n", result);
break;
case '-':
result = subtract(num1, num2);
printf("Result: %.2f\n", result);
break;
case '*':
result = multiply(num1, num2);
printf("Result: %.2f\n", result);
break;
case '/':
if (num2 == 0) {
printf("Error: Division by zero!\n");
} else {
result = divide(num1, num2);
printf("Result: %.2f\n", result);
}
break;
default:
printf("Unknown operator: %c\n", op);
}
printf("\n");
}
return 0;
}
// Function definitions
double add(double a, double b) {
return a + b;
}
63
double divide(double a, double b) {
return a / b;
}
void print_menu(void) {
printf("\nChoose an operation:\n");
printf(" + Addition\n");
printf(" - Subtraction\n");
printf(" * Multiplication\n");
printf(" / Division\n");
printf(" q Quit\n\n");
}
Example session:
64
Why It Matters
You’ve now moved beyond syntax, you’ve built a working, reusable C program that interacts
with real users.
Try It Yourself
3. Extend the calculator to remember the last result and reuse it if the user enters a single
operand.
4. Add input validation (e.g., check if scanf actually reads a number).
5. For a challenge, implement power (^) or square root (sqrt) using <math.h>.
This calculator marks the end of your Language Basics journey, from variables and control
flow to full, interactive programs. In the next chapter, you’ll dive into memory: how C stores
your data, manages it, and lets you control it directly.
65
Chapter 3. Working with Memory
Before you can master pointers or dynamic memory, you need to understand how memory is
organized in a running C program. C gives you a level of control that few languages allow,
but to use it safely, you must know where your data lives and how long it stays there.
When a program runs, its memory is divided into several key sections:
These segments are managed differently by the operating system, and each has a different
lifetime and scope.
Tiny Code
Here’s a small program that prints the memory addresses of different variables to show where
they live:
66
#include <stdio.h>
#include <stdlib.h>
void show_addresses(void) {
// Local variable (Stack)
int local_var = 10;
free(heap_var);
}
int main(void) {
show_addresses();
return 0;
}
67
Code (function) address: 0x561ce7348169
Global variable address: 0x561ce7546014
Uninitialized global address:0x561ce7546018
Static variable address: 0x561ce7546020
Stack variable address: 0x7ffc94b65a5c
Heap variable address: 0x561ce774b2a0
Notice how the stack address is much higher than the heap, the stack usually grows downward,
and the heap grows upward in memory.
1. Code segment
2. Data segment
4. Stack
5. Heap
68
Why It Matters
Every time you write a variable, you’re deciding, whether consciously or not, where in memory
it lives. Understanding this layout helps you:
Without this mental model, C memory bugs feel mysterious; with it, they become logical and
fixable.
Try It Yourself
1. Add more global, local, and static variables to the example and print their addresses.
2. Allocate two blocks with malloc() and compare their addresses, the heap grows upward.
3. Call show_addresses() multiple times and notice how the stack variable’s address
changes each call.
4. Move a variable from global to local and observe how its memory segment changes.
5. Draw a diagram showing stack, heap, data, and code regions for your system.
Understanding memory layout is your first real step into systems-level C, it’s how you begin
to see your code not just as text, but as structured bytes living inside memory.
Pointers are at the heart of C programming. They give you direct access to memory, the power
to read, write, and manipulate data stored anywhere in your program. Understanding pointers
transforms how you think about variables, functions, and data structures.
You can’t truly master C without mastering pointers.
What Is a Pointer?
A pointer is a variable that stores the address of another variable. Think of it as a reference
to a specific spot in memory.
69
• ptr holds that address.
• *ptr lets you access the value stored there (this is called dereferencing).
Tiny Code
#include <stdio.h>
int main(void) {
int number = 10;
int *p = &number; // pointer stores address of number
return 0;
}
Value of number: 10
Address of number: 0x7ffc8f4c9c4c
Pointer p holds address: 0x7ffc8f4c9c4c
Value through pointer: 10
New value of number: 20
70
Syntax Meaning
int *p; Pointer to an integer
char *c; Pointer to a character
float *f; Pointer to a float
p = &x; Assigns address of variable x to pointer p
*p Accesses (dereferences) the value stored at the address held by p
Null Pointers
Dereferencing a null pointer (*ptr when ptr == NULL) causes a segmentation fault, one of
the most common C errors.
Pointer to Pointer
int x = 5;
int *p = &x;
int **pp = &p;
71
Why It Matters
But they also demand precision. A single misused pointer can cause crashes or memory
corruption.
Mastering pointers means mastering both control and responsibility over memory.
Try It Yourself
1. Write a program that declares an integer and prints both its value and address.
2. Create a pointer to that integer and modify the variable’s value through the pointer.
3. Try declaring int *p = NULL; and check it before dereferencing.
4. Print a pointer to a pointer (int **) and see how the addresses relate.
5. For fun, declare two variables and make one pointer swap their values using dereferencing.
Once you truly understand pointers, the rest of C, arrays, structs, dynamic memory, even
function calls, begins to make sense. They are the bridge between your code and the machine’s
actual memory.
An array is a block of consecutive memory cells that hold elements of the same type. Arrays
and pointers are deeply connected in C, in fact, an array’s name often behaves like a pointer to
its first element. Understanding how arrays and pointer arithmetic work together is key to
writing fast, memory-efficient programs.
#include <stdio.h>
int main(void) {
int numbers[5] = {10, 20, 30, 40, 50};
72
}
return 0;
}
Output:
numbers[0] = 10
numbers[1] = 20
numbers[2] = 30
numbers[3] = 40
numbers[4] = 50
Here:
When you use an array’s name (without an index), it acts as a pointer to its first element.
Output:
1 2 3
Each time you add 1 to the pointer, it moves forward by one element, not one byte, but one
object of that type.
Tiny Code
73
#include <stdio.h>
int main(void) {
int arr[5] = {2, 4, 6, 8, 10};
int *ptr = arr; // arr decays to pointer to arr[0]
printf("\nAddresses in memory:\n");
for (int i = 0; i < 5; i++) {
printf("&arr[%d] = %p\n", i, (void *)&arr[i]);
}
return 0;
}
74
*(ptr + 2) = 6
*(ptr + 3) = 8
*(ptr + 4) = 10
Addresses in memory:
&arr[0] = 0x7ffcc73f9a60
&arr[1] = 0x7ffcc73f9a64
&arr[2] = 0x7ffcc73f9a68
&arr[3] = 0x7ffcc73f9a6c
&arr[4] = 0x7ffcc73f9a70
You can see that each element sits 4 bytes apart (typical size of int).
When you move pointers, C automatically scales by the size of the type they point to:
Expression Meaning
p + 1 Move to the next element
p - 1 Move to the previous element
*(p + i) Access the i-th element after the current one
p2 - p1 Returns the number of elements between two pointers
Example:
Common Pitfalls
2. Array decay Arrays “decay” to pointers when passed to functions, they lose size
information. You must pass the length manually.
75
void print_array(int *arr, int len);
3. Pointer confusion Remember that arr[i] and *(arr + i) mean the same thing.
Mixing them is fine, but be consistent for readability.
Why It Matters
Arrays and pointers form the foundation of C data structures. You’ll use them to build:
Once you’re comfortable thinking of arrays as contiguous memory blocks accessed through
pointers, you can start designing your own data structures like a real systems programmer.
Try It Yourself
1. Write a function that prints an array using only pointers (no [] syntax).
2. Create an array of char and print it as a string and as separate characters.
3. Declare an array of 10 numbers, then use pointers to sum them.
4. Print the address difference between two elements.
5. Create a two-dimensional array and print it with nested loops.
Arrays and pointers are two sides of the same coin in C. Once you understand their connection,
you’ll see how powerful, and elegant, direct memory access can be.
In C, a string is simply an array of characters ending with a special null character '\0'. Unlike
higher-level languages, C doesn’t have a built-in string type, just arrays and pointers. This
simplicity gives you full control over text data but also demands care: every string operation
must respect memory limits and null terminators.
76
How Strings Work
Character H e l l o \0
Index 0 1 2 3 4 5
The '\0' (ASCII 0) marks the end of the string, it’s how functions like printf or strlen
know where to stop.
Declaring Strings
greeting1 is a mutable array you can modify. greeting3 points to a read-only string literal
stored in memory, modifying it causes undefined behavior.
Tiny Code
Here’s a complete example that explores string declarations, iteration, and basic operations:
#include <stdio.h>
#include <string.h>
int main(void) {
char msg[] = "C language";
char *ptr = msg; // pointer to the first character
77
printf("\nAccess via pointer arithmetic:\n");
for (int i = 0; *(ptr + i) != '\0'; i++) {
printf("*(ptr + %d) = %c\n", i, *(ptr + i));
}
return 0;
}
Output:
String: C language
Length: 10
78
Function Description Example
strlen(s) Get string length (excluding \0) strlen("Hi") == 2
strcpy(dest, src) Copy string strcpy(name, "Bob");
strcat(dest, src) Concatenate strings strcat(full, last);
strcmp(a, b) Compare strings (0 if equal) strcmp("a","b")
strchr(s, c) Find first occurrence of character strchr(word, 'a')
strstr(s, sub) Find substring strstr(text, "find")
Example:
Because a string’s name decays into a pointer, you can pass strings directly to functions:
int main(void) {
greet("C Learner");
return 0;
}
Common Pitfalls
1. Forgetting '\0':
char word[4] = {'T','e','s','t'}; // missing terminator, unsafe
2. Buffer overflows: Copying more characters than fit in the destination buffer leads to
undefined behavior.
char dest[5];
strcpy(dest, "Too long!"); // dangerous
79
3. Modifying string literals:
char *s = "Hello";
s[0] = 'Y'; // crash or undefined behavior
Why It Matters
Strings are the foundation of text processing, file handling, and user interfaces in C.
Because they’re just arrays of characters, understanding strings forces you to think about:
• Memory layout
• Null termination
• Buffer size and safety
Once you internalize how C handles text at the byte level, you’ll be ready to build real parsers,
file readers, and command-line tools.
Try It Yourself
Strings in C are both elegant and dangerous, a true test of precision. Once you master them,
you’ll understand how text truly exists in memory, one byte at a time.
Static arrays have fixed size, but real programs often need flexible data that grows or shrinks
at runtime. Dynamic memory allocation lets you request, use, and release memory manually
while your program is running. It’s one of the most powerful and error-prone parts of C.
The Idea
C provides four key functions from <stdlib.h> for dynamic memory management:
Function Purpose
malloc(size) Allocates a block of memory
calloc(n, size) Allocates and clears memory for n elements
80
Function Purpose
realloc(ptr, size) Changes the size of a previously allocated block
free(ptr) Releases memory back to the system
Basic Example
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(int)); // allocate space for one int
if (p == NULL) {
printf("Memory allocation failed.\n");
return 1;
}
Output:
Value: 42
81
int n;
printf("Enter number of elements: ");
scanf("%d", &n);
free(arr);
Output example:
When you need to resize an allocated block, say, double an array’s capacity, use realloc().
82
}
arr = temp;
arr[3] = 4;
arr[4] = 5;
free(arr);
Output:
1 2 3 4 5
realloc() tries to expand the existing block if possible; if not, it allocates a new block, copies
the data, and frees the old one automatically.
Tiny Code
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int n = 3;
int *nums = calloc(n, sizeof(int));
if (nums == NULL) {
printf("Initial allocation failed.\n");
return 1;
}
// Fill array
for (int i = 0; i < n; i++) nums[i] = (i + 1) * 5;
83
// Resize
n = 5;
int *new_nums = realloc(nums, n * sizeof(int));
if (new_nums == NULL) {
printf("Reallocation failed.\n");
free(nums);
return 1;
}
nums = new_nums;
free(nums);
return 0;
}
Output:
Initial values: 5 10 15
After realloc: 5 10 15 20 25
Each call to malloc reserves space on the heap, which stays allocated until explicitly freed.
Why It Matters
Dynamic memory is the backbone of all real systems programming. Without it, you
can’t build:
84
• Variable-sized arrays
• Linked lists, trees, graphs
• Caches and databases
• File readers and parsers
It’s also where most C bugs happen, dangling pointers, leaks, double frees, and buffer overruns,
so disciplined management is crucial.
Try It Yourself
1. Allocate an array of 10 integers, fill it, print it, and free it.
2. Use calloc instead of malloc and observe the zero initialization.
3. Resize the array from 10 to 20 elements using realloc.
4. Forget to call free() and then run your program with Valgrind, see the memory leak
report.
5. Write a function int *make_array(int n) that allocates and returns a pointer to a new
array.
Dynamic allocation is where you start managing memory by hand. Done right, it gives you
incredible control and efficiency, done wrong, it’s chaos. Master it carefully: it’s the essence of
being a C programmer.
C gives you total control over memory, which means you can do anything you want, including
things that should never be done. Two of the biggest dangers are memory leaks (when memory
is never released) and undefined behavior (when the program does something unpredictable).
Learning to avoid these is the key to writing stable, safe, and correct C programs.
A memory leak happens when you allocate memory on the heap and never free it. The
memory stays reserved even though you can’t access it anymore.
Example:
#include <stdlib.h>
void leak(void) {
int *data = malloc(100 * sizeof(int)); // allocated
data[0] = 42;
85
// forgot to free(data); memory is now lost
}
If leak() runs many times, your program consumes more and more memory until it crashes or
slows down. In long-running programs (like servers), this is deadly.
Rule: Every malloc, calloc, or realloc must eventually be paired with a matching free().
Tiny Code
#include <stdio.h>
#include <stdlib.h>
void with_leak(void) {
int *arr = malloc(5 * sizeof(int));
for (int i = 0; i < 5; i++) arr[i] = i;
printf("with_leak: allocated 5 ints, but not freed.\n");
}
void without_leak(void) {
int *arr = malloc(5 * sizeof(int));
for (int i = 0; i < 5; i++) arr[i] = i;
printf("without_leak: freeing memory.\n");
free(arr);
}
int main(void) {
with_leak();
without_leak();
return 0;
}
86
==1234== HEAP SUMMARY:
==1234== definitely lost: 20 bytes in 1 blocks
==1234== indirectly lost: 0 bytes in 0 blocks
==1234== LEAK SUMMARY:
==1234== 1 blocks definitely lost
You can see the first function leaked memory, while the second freed it properly.
Dangling Pointers
A dangling pointer points to memory that has been freed or is otherwise invalid.
int *p = malloc(sizeof(int));
*p = 10;
free(p);
printf("%d\n", *p); // � undefined behavior
After free(p), the pointer p still holds the old address, but that memory no longer belongs to
you. Accessing it may crash, or appear to work, or corrupt data, you can’t rely on it.
Always nullify freed pointers:
free(p);
p = NULL;
Double Free
int *p = malloc(sizeof(int));
free(p);
free(p); // � double free error
Most modern OSes detect this and abort, but it’s still a critical bug.
Use-After-Free
This is one of the worst kinds of memory errors. It happens when you access memory after it’s
been freed.
87
int *arr = malloc(3 * sizeof(int));
arr[0] = 5;
free(arr);
arr[0] = 7; // � use-after-free
The compiler won’t catch this, but Valgrind will warn you.
Uninitialized Memory
malloc() does not zero out memory, use calloc() if you need cleared data.
Why It Matters
In C, correctness is your responsibility. You must know when memory is valid, who owns it,
and when to free it.
88
Defensive Techniques
Try It Yourself
1. Write a small program that intentionally leaks memory. Run it under Valgrind.
2. Fix the leak by calling free() properly.
3. Create a dangling pointer and observe what happens (on some systems it crashes, on
others not).
4. Experiment with calloc() to see how zero-initialized memory behaves.
5. Write a function that allocates memory and returns it, then ensure the caller frees it.
Final Thought
Memory errors are the hardest bugs to track because they may not appear right away. But once
you understand ownership, who allocates and who frees, memory in C becomes predictable,
even elegant. This discipline is what separates casual C users from real systems programmers.
C gives you fine-grained control over how variables are used through type qualifiers. Two
of the most important are const and volatile. They look simple but play a crucial role in
writing safe, predictable, and efficient code, especially in systems programming, embedded
systems, and multithreaded environments.
const means read-only: once a variable is initialized, you cannot modify it.
89
It’s a promise to the compiler, and to other programmers, that the value won’t change.
const can be applied to many things:
• Variables
• Function parameters
• Pointers
• Return types
const with pointers can be tricky but follows consistent rules. The position of const determines
what cannot change.
Declaration Meaning
const int *p; Pointer to constant data, data can’t change
int *const p; Constant pointer, pointer can’t change, data can
const int *const p; Both pointer and data are constant
Example:
Marking parameters as const helps prevent accidental modification and enables compiler
optimizations.
Here, msg is read-only; the function can’t modify the string it points to.
90
Tiny Code
#include <stdio.h>
int main(void) {
int num = 5;
const int *p = #
int *const q = #
Output:
num = 5
num after q change = 15
Value: 15
volatile tells the compiler that a variable can change at any time, even if your code
doesn’t modify it. It prevents the compiler from optimizing out reads or writes.
Use volatile when:
91
• A variable can be changed by hardware (e.g., memory-mapped I/O registers).
• A variable can be modified by another thread or signal handler.
• You need to force an actual memory read each time, not a cached value.
Example:
Here, sensor_value might be updated by hardware; volatile ensures each check re-reads
memory instead of reusing a cached register value.
Yes, you can use both together, a value that can change unexpectedly, but your code cannot
modify it.
Example:
This is common in embedded systems, where a hardware register’s bits may change due to
external events.
Why It Matters
• const improves safety and clarity: makes interfaces self-documenting and helps the
compiler catch mistakes.
• volatile preserves correctness in concurrent or hardware-driven systems.
• Together, they let you balance optimization with precision, critical in low-level C pro-
gramming.
92
Try It Yourself
1. Write a program that tries to modify a const int through a pointer, observe the compiler
error.
2. Declare a variable as volatile int counter and increment it in a loop.
• Then remove volatile and inspect the generated assembly with gcc -S.
3. Create a function with const char *msg and try to modify it, see why it’s prohibited.
4. Experiment with const int *p vs int *const p to understand their difference.
5. Combine both: const volatile int flag; and print it in a loop.
In C, const and volatile are more than just keywords, they’re contracts. They tell the
compiler exactly how memory can be used, which helps both humans and machines reason
safely about your code.
Functions in C are values too, they live in memory and have addresses just like variables. A
function pointer is a pointer that stores the address of a function, allowing you to call that
function indirectly. This idea powers callbacks, event systems, custom sorters, and plug-in
architectures in C.
return_type (*pointer_name)(parameter_types);
Example:
93
int result = func_ptr(2, 3); // same as add(2, 3)
Tiny Code
Here’s a complete example showing how to declare, assign, and call function pointers:
#include <stdio.h>
int main(void) {
int (*f)(int, int); // declaration
f = add;
printf("Add via pointer: %d\n", f(5, 3));
f = sub;
printf("Subtract via pointer: %d\n", f(5, 3));
return 0;
}
Output:
94
Using callback function:
Result: 24
You can also store multiple function pointers in an array, useful for building tables of opera-
tions.
Output:
ops[0](4, 2) = 6
ops[1](4, 2) = 2
ops[2](4, 2) = 8
This pattern underlies dispatch tables, interpreters, and virtual function systems in C.
Callbacks
A callback is a function you pass as an argument to another function, letting the callee “call
back” into user code. This pattern is essential in event-driven and modular designs.
Example: a simple iterator that accepts a callback
#include <stdio.h>
void print_square(int x) {
printf("%d^2 = %d\n", x, x * x);
}
int main(void) {
int nums[] = {1, 2, 3, 4, 5};
95
for_each(nums, 5, print_square); // pass callback
return 0;
}
Output:
1^2 = 1
2^2 = 4
3^2 = 9
4^2 = 16
5^2 = 25
Why It Matters
Try It Yourself
1. Write three arithmetic functions and store them in an array of function pointers.
2. Build a calculate(a, b, char op) function that picks the right function pointer based
on op.
3. Implement a callback-style loop that calls a user-defined function for each array element.
4. Pass a function pointer to qsort() from <stdlib.h> to sort integers in descending order.
5. Write a small menu system that calls the right function based on user choice.
Function pointers and callbacks give your programs flexibility and abstraction without sacrificing
speed. They’re how C achieves dynamic behavior, the bridge between data and executable
logic.
96
29. Deep vs Shallow Copies
When you assign one variable to another in C, you’re often copying addresses, not actual data.
This distinction between shallow copies and deep copies becomes critical when working
with pointers, arrays, and dynamically allocated structures. Understanding it helps you prevent
memory corruption, double frees, and mysterious bugs.
• A shallow copy duplicates only the pointer, both variables refer to the same memory.
• A deep copy duplicates the data itself, each variable owns its own independent memory.
Simple Analogy
• Shallow copy: You hand someone your house key. You both open the same door.
• Deep copy: You build a new house that looks identical, but is separate.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char *original = malloc(10);
strcpy(original, "Hello");
// Shallow copy
char *copy = original;
free(original);
// free(copy); // � would cause double free error!
97
return 0;
}
Output:
Explanation:
A deep copy allocates new memory and copies the data over.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char *original = malloc(10);
strcpy(original, "Hello");
// Deep copy
char *copy = malloc(strlen(original) + 1);
strcpy(copy, original);
copy[0] = 'J';
printf("After change: %s | %s\n", original, copy);
free(original);
free(copy); // � both safely freed
return 0;
}
Output:
98
Before change: Hello | Hello
After change: Hello | Jello
Now the two strings are completely independent, a true deep copy.
typedef struct {
char *name;
} Person;
Person a, b;
[Link] = malloc(20);
strcpy([Link], "Alice");
b = a; // shallow copy
[Link][0] = 'M'; // modifies [Link] too!
Tiny Code
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char *name;
int age;
99
} Person;
int main(void) {
Person p1;
[Link] = malloc(20);
strcpy([Link], "Alice");
[Link] = 25;
// Shallow copy
Person p2 = p1;
print_person("Before", p1);
[Link][0] = 'M'; // modifies same memory
print_person("After shallow copy", p1);
// Deep copy
Person p3;
[Link] = malloc(strlen([Link]) + 1);
strcpy([Link], [Link]);
[Link] = [Link];
free([Link]);
free([Link]); // � safe
return 0;
}
Output:
100
Why It Matters
• If two variables share the same pointer (shallow), freeing one invalidates the other.
• Deep copies isolate data, preventing interference but using more memory.
Try It Yourself
1. Create a struct with dynamically allocated fields (e.g., name, address) and write two
copy functions: copy_shallow() and copy_deep().
2. Modify one copy and observe the difference.
3. Call free() in the wrong order and note what happens.
4. Use Valgrind to verify that deep copies are properly freed.
5. Extend the concept to an array of structs, implement deep copy for each element.
When you understand deep vs shallow copies, you control how memory ownership moves
in your program, a foundation for safe, modular, and leak-free C design.
Now that you’ve learned how memory works, stack vs heap, allocation, freeing, leaks, deep
vs shallow copies, it’s time to practice controlling memory manually. This exercise ties
together malloc, free, pointers, and struct management in a real, runnable program.
You’ll build a small system that stores and manipulates dynamically allocated records, a tiny
simulation of how databases or object systems manage memory in C.
101
Goal
Tiny Code
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char *name;
int age;
float gpa;
} Student;
strcpy(s->name, name);
s->age = age;
s->gpa = gpa;
return s;
}
102
void print_student(const Student *s) {
printf("Name: %-10s | Age: %d | GPA: %.2f\n", s->name, s->age, s->gpa);
}
int main(void) {
printf("=== Manual Memory Management Demo ===\n");
// Free memory
free_student(a);
free_student(b);
free_student(c);
103
Output:
After update:
Name: Bobby | Age: 22 | GPA: 3.60
How It Works
1. Dynamic Allocation: Each Student and its name field are created on the heap with
malloc(). You control exactly when they exist and when to destroy them.
2. Ownership:
3. Memory Safety:
Iterate through them and print all details, then free each one.
2. Reallocation (grow list) Use realloc() to increase your array’s capacity when adding
more students dynamically.
104
3. Deep Copy Function Implement:
Student *copy_student(const Student *src);
which performs a deep copy by allocating new memory for both the struct and its name.
4. Leak Detection Run your program with valgrind ./manual_memory, confirm that all
memory is freed cleanly.
Why It Matters
Everything from operating systems to databases and compilers depends on this discipline. Once
you can manage small dynamic structures like this confidently, you’re ready to build larger
systems safely, from allocators to object pools to file caches.
Try It Yourself
You’ve now completed Chapter 3: Working with Memory. You understand how data
lives, moves, and disappears in C, and you’ve practiced taking full control over it. From here,
you’ll learn how to structure that data elegantly using struct, union, and real-world data
abstractions in Chapter 4.
105
Chapter 4. Structuring Data
Real-world programs often deal with groups of related data, not just single variables. For
example, a person has a name, an age, and an address. Instead of juggling separate variables,
you can combine them into a single structure using struct.
struct is one of the most powerful features in C, it lets you define your own data types that
group information logically and efficiently.
What Is a Structure?
A structure is a user-defined type that holds variables of different kinds under one name.
struct Person {
char name[50];
int age;
float height;
};
This declares a template for a Person object. It doesn’t create actual data yet, just the
blueprint.
#include <stdio.h>
struct Person {
char name[50];
int age;
float height;
};
106
int main(void) {
struct Person p1 = {"Alice", 25, 1.65f};
return 0;
}
Output:
Name: Alice
Age: 25
Height: 1.65 m
Accessing Members
[Link] = 26;
printf("Updated age: %d\n", [Link]);
Output:
107
struct Person copy = p2;
printf("Copy: %s (%d)\n", [Link], [Link]);
This performs a shallow copy, all fields are copied, but if any contain pointers, they’ll still
refer to the same memory (you’ll learn how to make deep copies later).
Nested Structures
Structures can contain other structures. This helps you organize complex data clearly.
Example:
#include <stdio.h>
struct Date {
int day;
int month;
int year;
};
struct Student {
char name[50];
int id;
struct Date birthdate; // nested structure
};
int main(void) {
struct Student s = {
.name = "Carol",
.id = 1234,
.birthdate = {15, 8, 2003}
};
Output:
108
Carol (ID 1234) was born on 15/08/2003
[Link] = 2004;
Example:
Tiny Code
#include <stdio.h>
struct Date {
int day;
int month;
int year;
};
struct Person {
char name[50];
int age;
float height;
struct Date birthdate;
};
109
void print_person(const struct Person *p) {
printf("%s, %d years old, born on %02d/%02d/%04d, height %.2fm\n",
p->name, p->age,
p->[Link], p->[Link], p->[Link],
p->height);
}
int main(void) {
struct Person person = {"Alice", 25, 1.68f, {1, 2, 1999}};
print_person(&person);
[Link]++;
[Link]++;
printf("After update:\n");
print_person(&person);
return 0;
}
Output:
Why It Matters
They’re the foundation of all complex C systems, files, network packets, kernel data, even
database rows are built on top of struct.
110
Try It Yourself
Structures are how C lets you model the world, compact, explicit, and fast. Next, you’ll learn
about unions and how C lets different data types share the same memory space efficiently.
Sometimes you need a variable that can hold different types of data at different times, but you
don’t want to waste memory keeping all of them active at once. That’s where unions come
in.
A union lets multiple fields share the same memory location. It’s a space-saving feature
and a powerful tool for implementing type flexibility, variant data, and even low-level binary
manipulation.
What Is a Union?
A union is like a structure, but instead of giving each member its own memory, all members
share the same memory block. Only one field is valid at any moment.
Syntax:
union Data {
int i;
float f;
char c;
};
Here, i, f, and c share the same storage. The size of the union is equal to the size of its largest
member.
Using a Union
111
#include <stdio.h>
union Data {
int i;
float f;
char c;
};
int main(void) {
union Data d;
d.i = 42;
printf("d.i = %d\n", d.i);
d.f = 3.14f;
printf("d.f = %.2f\n", d.f);
d.c = 'A';
printf("d.c = %c\n", d.c);
return 0;
}
Output:
d.i = 42
d.f = 3.14
d.c = A
After d.c = 'A', d.i = 1094795585
Notice how writing to one member affects the others, because they occupy the same memory.
+------------------+
| Shared Memory | <- same location for all fields
| (size = largest) |
+------------------+
112
| i: 4 bytes |
| f: 4 bytes |
| c: 1 byte |
+------------------+
Tiny Code
#include <stdio.h>
#include <string.h>
union Value {
int i;
float f;
char str[20];
};
int main(void) {
union Value v;
v.i = 42;
printf("As int: %d\n", v.i);
v.f = 3.14f;
printf("As float: %.2f\n", v.f);
strcpy([Link], "Hello");
printf("As string: %s\n", [Link]);
Output:
As int: 42
As float: 3.14
As string: Hello
Union size: 20 bytes
113
Even though it contains an int, a float, and a char[20], the total size is only 20 bytes, the
size of the largest member.
In practice, you often use a tag (an enum or integer) to remember which member is active,
this is known as a tagged union or discriminated union.
#include <stdio.h>
#include <string.h>
struct Variant {
enum Type type;
union {
int i;
float f;
char str[20];
} data;
};
int main(void) {
struct Variant v;
[Link] = STRING;
strcpy([Link], "C Language");
print_variant(&v);
[Link] = INT;
[Link].i = 123;
print_variant(&v);
[Link] = FLOAT;
114
[Link].f = 9.81f;
print_variant(&v);
return 0;
}
Output:
STRING: C Language
INT: 123
FLOAT: 9.81
This is how you combine the flexibility of unions with the safety of knowing which field is
currently valid.
Why It Matters
In low-level systems, they enable compact and flexible representations that C is famous for.
Try It Yourself
2. Implement a tagged union Message with types TEXT, BINARY, and COMMAND.
3. Create a struct with an enum tag and a union, simulate how file formats (like PNG
chunks) are parsed.
4. Write a function that prints the active union field using the tag.
5. Modify the previous example to store an array of tagged unions.
115
In C, unions give you memory control and flexibility that few languages allow. They’re
the foundation for advanced constructs like variant types, polymorphic structs, and even
message protocols, used everywhere from the Linux kernel to embedded firmware.
C gives you the power to create your own type names using the typedef keyword. It doesn’t
create new types at runtime, instead, it creates aliases that make your code cleaner, more
expressive, and easier to maintain.
If struct, enum, or pointer syntax ever feels cluttered, typedef is your best friend.
What Is typedef?
typedef gives an existing type a new name. It’s like a nickname for a complex or frequently
used declaration.
Syntax:
Example:
Now ulong can be used wherever you’d normally write unsigned long.
Basic Examples
#include <stdio.h>
int main(void) {
Score math = 95;
Letter grade = 'A';
printf("Math: %d, Grade: %c\n", math, grade);
return 0;
}
116
Output:
It doesn’t change how the compiler treats the variable, just makes the code more readable.
Pointer declarations can get messy. With typedef, you can simplify them.
int main(void) {
int x = 10;
IntPtr p = &x; // same as int *p = &x;
printf("Value: %d\n", *p);
return 0;
}
Output:
Value: 10
Tip: Be careful, IntPtr a, b; means both a and b are pointers, unlike plain int
*a, b;.
typedef shines with struct, union, and enum declarations. Without typedef:
struct Point {
int x;
int y;
};
With typedef:
117
typedef struct {
int x;
int y;
} Point;
instead of:
With typedef:
118
typedef int (*Operation)(int, int);
int main(void) {
Operation op = add;
printf("%d\n", op(2, 3));
}
Now Operation is a clean alias for a pointer to a function that takes two ints and returns an
int.
Tiny Code
Here’s a full example showing typedefs for structs, pointers, and function types:
#include <stdio.h>
#include <string.h>
typedef struct {
char name[50];
int age;
} Person;
int main(void) {
Person p = {"Alice", 25};
PersonPtr ptr = &p;
Printer print = print_person;
print(ptr);
return 0;
}
Output:
119
Alice (25 years old)
Why It Matters
typedef improves:
It’s especially useful in large projects and system APIs, where naming conventions define clean
boundaries.
Purpose Example
Standard aliases typedef unsigned int uint;
Struct abstraction typedef struct Node Node;
Function pointer type typedef void (*Handler)(int signal);
Handle-like pattern typedef struct File* FileHandle;
Platform types typedef long long int64; typedef unsigned int uint32;
Try It Yourself
typedef may look simple, but it’s one of the most powerful readability tools in C. It lets you
design your own vocabulary for your system, a small step toward writing clean, self-documenting
code that scales.
120
34. Bitfields and Memory Packing
C lets you control data layout down to the bit level using bitfields. They allow you to store small
values compactly inside a struct, perfect for flags, configuration registers, or communication
protocols. Combined with packing, you can squeeze data into minimal space while still keeping
it easy to manipulate symbolically.
What Is a Bitfield?
A bitfield lets you define the exact number of bits to allocate for a field inside a struct.
Example:
struct Flags {
unsigned int is_visible : 1;
unsigned int is_enabled : 1;
unsigned int has_error : 1;
};
Here, each field uses just 1 bit instead of a full 4-byte int. That means 8 such flags fit
comfortably in one byte.
#include <stdio.h>
struct Status {
unsigned int connected : 1;
unsigned int error : 1;
unsigned int active : 1;
unsigned int reserved : 5; // padding bits
};
int main(void) {
struct Status s = {1, 0, 1, 0};
printf("Connected: %u, Active: %u\n", [Link], [Link]);
[Link] = 1;
printf("Error now: %u\n", [Link]);
return 0;
}
121
Output:
Connected: 1, Active: 1
Error now: 1
Even though there are 4 fields, the entire struct typically occupies only 1 byte.
Tiny Code
Here’s a complete example demonstrating packed flags and printing bit values:
#include <stdio.h>
struct DeviceStatus {
unsigned int powered_on : 1;
unsigned int connected : 1;
unsigned int has_error : 1;
unsigned int battery_low: 1;
unsigned int reserved : 4;
};
int main(void) {
struct DeviceStatus d = {1, 1, 0, 0, 0};
printf("Size of DeviceStatus: %zu bytes\n", sizeof(d));
d.has_error = 1;
printf("Updated binary: ");
print_bits(*raw);
return 0;
}
122
Output (may vary by platform):
You can even use bitfields inside nested structs to create compact yet expressive data models:
struct Sensor {
unsigned id : 4; // 0–15
unsigned type : 3; // 0–7
unsigned active : 1; // boolean
};
struct Device {
struct Sensor sensors[2];
};
Memory Packing
By default, compilers may insert padding bytes to align fields for faster access. If you want
tighter packing, for example, when saving binary data to a file or sending over a network, you
can request packed structs.
Compiler directives differ by system:
#pragma pack(push, 1)
struct Packet {
char type;
unsigned int length;
short checksum;
};
#pragma pack(pop)
Now the struct is tightly packed without alignment padding between fields.
123
Bitfields in Real Systems
Limitations
For portable bit-level control (especially in networking), many engineers use explicit
bitwise operators instead.
This approach is more portable and explicit, but less readable for large sets of flags.
Why It Matters
Bitfields give you compact control over memory layout and binary representation. They’re
critical in:
• Embedded firmware
• Network stacks
• Kernel drivers
• Compression libraries
124
They make your code expressive and efficient, as long as you understand alignment and
portability issues.
Try It Yourself
1. Define a struct Permissions with 1-bit fields for read, write, and execute.
2. Print its size and check how compact it is.
3. Use a bitfield struct to represent a simplified TCP header (flags like SYN, ACK, FIN).
4. Use #pragma pack(1) and observe the size difference.
5. Write functions to set, clear, and toggle bits using bitwise operators.
Bitfields are where C meets the hardware. They let you talk to the machine not just in bytes,
but in bits, the true language of computers. Next, you’ll revisit enumerations and see how
they complement these compact structures by giving symbolic meaning to values.
You’ve seen enum briefly when learning about constants, but now it’s time to use it as a
first-class design tool. Enumerations give names to sets of integer values, making code easier
to read, maintain, and debug. They also pair beautifully with struct, union, and bitfield
patterns from the previous sections.
What Is an Enumeration?
An enumeration (enum) defines a type whose values are limited to a specific list of named
constants.
Example:
enum Color {
RED,
GREEN,
BLUE
};
Under the hood, enum Color is an integer type — RED = 0, GREEN = 1, BLUE = 2 by default.
Basic Usage
125
#include <stdio.h>
enum Direction {
NORTH,
EAST,
SOUTH,
WEST
};
int main(void) {
enum Direction d = EAST;
printf("Current direction: %d\n", d);
return 0;
}
Output:
Current direction: 1
Even though EAST prints as 1, using a named constant makes your code far more meaningful.
You can specify explicit integer values, useful for compatibility or mapping to real-world
codes.
enum ErrorCode {
OK = 0,
FILE_NOT_FOUND = 404,
SERVER_ERROR = 500
};
If you skip a value, enumeration continues counting from the last number:
enum Level {
LOW = 1,
MEDIUM,
HIGH
};
// HIGH = 3
126
Tiny Code
#include <stdio.h>
enum Status {
SUCCESS = 0,
WARNING = 1,
ERROR = 2
};
int main(void) {
enum Status s = WARNING;
printf("Status: %s (%d)\n", status_to_string(s), s);
return 0;
}
Output:
This pattern, enum + switch, is everywhere in C projects: error handling, state machines,
network protocols, and more.
127
#include <stdio.h>
enum ShapeType {
CIRCLE,
RECTANGLE
};
struct Shape {
enum ShapeType type;
union {
struct { float radius; };
struct { float width, height; };
};
};
int main(void) {
struct Shape c = {CIRCLE, .radius = 2.5f};
struct Shape r = {RECTANGLE, .width = 3.0f, .height = 4.0f};
print_shape(c);
print_shape(r);
return 0;
}
Output:
This pairing of enum + union is a common pattern in real-world systems, known as a tagged
union.
128
enum class Mode { READ, WRITE, APPEND };
This avoids name collisions and allows better type checking — similar to enum class in C++.
(Some compilers may not support this yet, but it’s worth knowing.)
Why It Matters
They also improve portability, your program logic is described by intent, not arbitrary
numbers.
Try It Yourself
1. Define an enum TrafficLight { RED, YELLOW, GREEN } and print messages based on
its value.
2. Create an enum FileType { TEXT, BINARY, UNKNOWN } and use it inside a struct
FileInfo.
3. Extend your tagged-union pattern: add TRIANGLE to the Shape enum.
4. Write a switch statement that maps enum ErrorCode to error messages.
5. Experiment with explicitly setting values and skipping a few, observe the auto-increment
behavior.
Enumerations make your programs speak in concepts, not numbers. They are the key to
clarity, readability, and robust design, the bridge between human meaning and machine
representation.
Now that you understand how to group data with struct, it’s time to make it dynamic. A
linked list is one of the most fundamental data structures in C, built entirely with pointers
and structs. It teaches you how memory, pointers, and iteration really work.
129
What Is a Linked List?
Unlike arrays, linked lists aren’t fixed in size, you can add or remove nodes anytime without
reallocating large blocks of memory.
struct Node {
int value;
struct Node *next;
};
This defines a “node” that holds an integer and a pointer to the next node in the list. If next
is NULL, it’s the end of the list.
#include <stdio.h>
#include <stdlib.h>
struct Node {
int value;
struct Node *next;
};
int main(void) {
// Create three nodes dynamically
struct Node *a = malloc(sizeof(struct Node));
struct Node *b = malloc(sizeof(struct Node));
struct Node *c = malloc(sizeof(struct Node));
130
c->value = 30; c->next = NULL;
// Free memory
free(a); free(b); free(c);
return 0;
}
Output:
10 20 30
#include <stdio.h>
#include <stdlib.h>
131
*head = new_node;
return;
}
Node *cur = *head;
while (cur->next) cur = cur->next;
cur->next = new_node;
}
int main(void) {
Node *head = NULL;
append(&head, 5);
append(&head, 10);
append(&head, 15);
delete_list(head);
return 0;
}
Output:
132
Why Use Linked Lists?
Type Description
Singly Linked List Each node points to the next one (like above).
Doubly Linked List Each node has prev and next pointers.
Circular Linked List The last node links back to the first.
Sentinel List Uses dummy head/tail nodes to simplify logic.
Why It Matters
Linked lists are a window into manual memory management, you handle creation, traversal,
and cleanup. They’re used in:
You’re not just learning a data structure, you’re learning how to think in pointers.
Try It Yourself
1. Implement a function int length(Node *head) that counts the number of nodes.
2. Write insert_front() and insert_after() functions.
3. Implement a find() function that returns a pointer to a node with a given value.
4. Modify the delete_list() function to print which node is being freed.
5. Extend the struct to include a char name[20] and print both the name and value.
You’ve now built one of the most essential dynamic structures in computer science, entirely
from scratch. Next, you’ll build on this foundation to create stacks and queues, two of the
most common and useful data abstractions in systems programming.
133
37. Stacks and Queues with Structs
You’ve learned how to build a linked list, now you’ll use that foundation to create two classic
data structures: Stacks (LIFO, Last In, First Out) and Queues (FIFO, First In, First Out).
Both are essential for real-world programs, from parsing expressions to managing tasks and
kernel scheduling.
1. The Stack
A stack is like a pile of plates. You add to the top (push), and remove from the top (pop).
Operations:
Each stack node holds data and a pointer to the next node.
#include <stdio.h>
#include <stdlib.h>
typedef struct {
Node *top;
} Stack;
Stack* create_stack(void) {
Stack *s = malloc(sizeof(Stack));
s->top = NULL;
return s;
}
134
n->next = s->top;
s->top = n;
}
int main(void) {
Stack *s = create_stack();
push(s, 10);
push(s, 20);
push(s, 30);
free_stack(s);
return 0;
}
Output:
135
Top: 30
Popped: 30
Popped: 20
Top now: 10
2. The Queue
A queue is like a line at a store. You add to the back (enqueue), and remove from the front
(dequeue).
Operations:
#include <stdio.h>
#include <stdlib.h>
typedef struct {
Node *front;
Node *rear;
} Queue;
Queue* create_queue(void) {
Queue *q = malloc(sizeof(Queue));
136
q->front = q->rear = NULL;
return q;
}
if (q->rear == NULL) {
q->front = q->rear = n;
return;
}
q->rear->next = n;
q->rear = n;
}
if (q->front == NULL)
q->rear = NULL;
free(temp);
return val;
}
137
}
int main(void) {
Queue *q = create_queue();
enqueue(q, 1);
enqueue(q, 2);
enqueue(q, 3);
printf("Queue: ");
print_queue(q);
printf("Remaining: ");
print_queue(q);
free_queue(q);
return 0;
}
Output:
Queue: 1 2 3
Dequeued: 1
Dequeued: 2
Remaining: 3
Here’s a minimal snippet that lets you switch between stack and queue mode:
138
typedef enum { STACK_MODE, QUEUE_MODE } Mode;
You could use the same linked list logic but change whether new nodes are added at the head
(stack) or tail (queue).
Why It Matters
• CPU scheduling
• IO buffering
• Event loops
• Expression parsing
• Recursive algorithms
Building them in raw C solidifies your understanding of pointer-based data structures and
memory ownership.
Try It Yourself
Stacks and queues are the control flow primitives of memory and time. Next, you’ll
combine them with hashing and function pointers to build your own hash table, the basis for
efficient lookups and symbol tables in C.
Hash tables are among the most important data structures in computing, fast, flexible, and
foundational. They give you average O(1) lookup, insertion, and deletion by mapping keys to
values through a hash function. In this section, you’ll build a simple hash table from scratch in
C using structs, arrays, and function pointers for hash and comparison operations.
139
What Is a Hash Table?
A hash table stores data as key–value pairs. When you insert a key:
If multiple keys map to the same slot, that’s called a collision, handled by chaining (linked
lists) or open addressing.
Simple Design
We’ll use chaining, each slot in the table is a linked list of key–value pairs that share the same
hash.
typedef struct {
Entry **buckets; // array of linked lists
size_t size;
} HashTable;
Hash Function
#include <stddef.h>
140
Tiny Code: Hash Table Implementation
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
Entry **buckets;
size_t size;
} HashTable;
141
Entry *new_entry = create_entry(key, value);
new_entry->next = t->buckets[index];
t->buckets[index] = new_entry;
}
int main(void) {
HashTable *table = create_table(8);
free_table(table);
return 0;
142
}
Output:
banana = 7
We can make the hash table generic by letting users provide custom hash and compare
functions:
typedef struct {
Entry **buckets;
size_t size;
HashFunc hash;
CompareFunc compare;
} GenericTable;
This lets you reuse the same table for strings, integers, or structs, just provide the right hash
and compare functions.
Example:
Why It Matters
They balance speed, simplicity, and control, the heart of efficient system design in C.
143
Common Pitfalls
A well-designed hash table grows dynamically (doubling capacity and rehashing when load
exceeds a threshold).
Try It Yourself
1. Modify the table to update existing keys instead of always inserting new ones.
2. Implement a delete(key) function that removes an entry.
3. Write a version with integer keys.
4. Implement rehash() that doubles table size when 75% full.
5. Replace function pointers with macros for performance comparison.
Hash tables are where C shows its full power: raw pointers, function indirection, and dynamic
memory, all working together for blazing-fast lookups. Next, you’ll take these ideas further
and explore how to simulate object-oriented design in C using structs, function pointers,
and encapsulation.
C doesn’t have classes or inheritance, but it gives you structs, function pointers, and
encapsulation through conventions. With these, you can build object-oriented style
systems that are simple, fast, and explicit. You’ll learn how to design data structures that
“own” both data and behavior, like lightweight objects.
In C, you can achieve this by placing function pointers inside structs, and treating them
as “methods.”
144
A Simple Example: A Counter Object
#include <stdio.h>
#include <stdlib.h>
struct Counter {
int value;
Counter* new_counter(void) {
Counter *c = malloc(sizeof(Counter));
c->value = 0;
c->inc = counter_inc;
c->reset = counter_reset;
c->print = counter_print;
return c;
}
int main(void) {
Counter *c = new_counter();
c->inc(c);
c->inc(c);
c->print(c);
c->reset(c);
c->print(c);
free_counter(c);
return 0;
}
145
Output:
Value: 2
Value: 0
Here, Counter behaves like a small class: it stores both the state (value) and its methods
(inc, reset, print).
How It Works
You can simulate polymorphism, the ability to call the same function name on different types,
using function pointers.
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
typedef struct {
Shape base;
double radius;
} Circle;
typedef struct {
146
Shape base;
double width, height;
} Rectangle;
Shape* new_circle(double r) {
Circle *c = malloc(sizeof(Circle));
c->radius = r;
c->[Link] = circle_area;
c->[Link] = circle_print;
return (Shape*)c;
}
147
int main(void) {
Shape *s1 = new_circle(2.5);
Shape *s2 = new_rectangle(3.0, 4.0);
s1->print(s1);
s2->print(s2);
free(s1);
free(s2);
return 0;
}
Output:
Both shapes share the same “interface” (area, print) but behave differently, classic polymor-
phism.
Every “object” stores pointers to its methods, so you can call them without knowing the exact
type. The first field (base) in derived structs allows casting between the parent (Shape*) and
child (Circle*, Rectangle*). This mimics inheritance by composition.
Benefits
Limitations
But these are also strengths: nothing is hidden, and everything is under your control.
148
Try It Yourself
With structs and function pointers, C becomes a minimal but powerful object system. You
now have everything needed to design reusable, modular code, without losing the clarity and
efficiency that make C timeless.
Next, you’ll finish this chapter by putting all these ideas together: building a small, real-world
system in C, your own Tiny Library System, with data structures, memory management,
and modular design.
You’ve now learned every building block, structs, pointers, dynamic memory, linked lists, enums,
and even object-style design with function pointers. It’s time to combine them into a real
mini-project: a Tiny Library System. This will be a full, runnable C program that manages
books, authors, and borrowing records using everything you’ve learned so far.
Goal
We’ll use:
149
Data Structures
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef enum {
AVAILABLE,
BORROWED
} BookStatus;
typedef struct {
Book *head;
} Library;
Each book is one node in a linked list. The library owns the head pointer.
Core Functions
150
lib->head = b;
}
151
b->title, b->author, b->year,
b->status == AVAILABLE ? "Available" : "Borrowed");
printf("------------------------\n\n");
}
int main(void) {
Library lib = {NULL};
list_books(&lib);
free_library(&lib);
return 0;
}
152
gcc library_system.c -o library_system
./library_system
Output:
Why It Matters
This “tiny library” is a microcosm of systems programming: you’re managing memory, defining
abstractions, and building a dynamic system with clear data ownership. From here, you can
scale to databases, caches, or in-memory key-value stores, all built on the same principles.
153
Try It Yourself
You’ve completed Chapter 4: Structuring Data, the heart of understanding how C organizes
the world. Next, you’ll move from in-memory structures to input, output, and files, learning
how to interact with the outside world through the standard I/O library in Chapter 5.
154
Chapter 5. Input, Output and Files
Input and output are how your programs talk to the outside world. In C, almost everything
goes through the Standard I/O library, defined in <stdio.h>. You’ve already met printf()
in “Hello, C World”, now you’ll learn how all these functions fit together, how they work, and
how to use them safely.
printf() formats and prints data to stdout. Its power lies in format specifiers, which
describe the type and layout of what to print.
155
Type Format Example
hexadecimal %x printf("%x", 255);
Output:
Price | 3.50
#include <stdio.h>
int main(void) {
int i = 42;
float f = 3.1415;
char c = 'C';
char *s = "Hello, C!";
Output:
Integer: 42
Float: 3.14
Char: C
String: Hello, C!
Pointer: 0x7ffeed001234
156
Reading with scanf()
scanf() reads formatted input from stdin. It’s like printf() in reverse, you tell it the format,
and it fills your variables.
int age;
float height;
printf("Enter age and height: ");
scanf("%d %f", &age, &height);
printf("You are %d years old and %.1f meters tall.\n", age, height);
Input:
25 1.75
Output:
Always use the & operator for non-array variables — it passes the memory
address where the value should be stored.
scanf() is risky for strings, it doesn’t prevent buffer overflow. Safer pattern: use fgets() to
read a full line, then parse it.
char buf[100];
printf("Enter your name: ");
fgets(buf, sizeof(buf), stdin);
buf[strcspn(buf, "\n")] = '\0'; // remove newline
printf("Hello, %s!\n", buf);
157
#include <stdio.h>
int main(void) {
char name[50];
int year;
printf("Hi %s! You are about %d years old.\n", name, 2025 - year);
return 0;
}
Output:
You can redirect formatted output anywhere, not just the screen.
char buffer[50];
sprintf(buffer, "Pi = %.3f", 3.14159); // print to string
puts(buffer);
Output:
Pi = 3.142
158
Why It Matters
printf() and scanf() are the workhorses of console I/O. They teach you:
Every C system, from tiny microcontrollers to full operating systems, uses these same founda-
tions.
Try It Yourself
1. Print a table of numbers with two columns: number and its square.
2. Read three integers using scanf and print their average.
3. Use fgets and sscanf to safely parse "42 3.14" into int and float.
4. Write a small quiz app: ask a question, read input, print “Correct” or “Try again”.
5. Experiment with printing to stderr, redirect errors to a file.
Mastering standard I/O is like mastering your program’s voice, it’s how your C code speaks
and listens. Next, you’ll move deeper into file handling, learning how to open, read, and write
files with file pointers in Section 42.
Files let your C programs remember things beyond runtime. Unlike standard input and output,
which disappear when the program ends, files provide persistent storage, you can read and
write data between runs.
This section introduces the key file-handling API in C: fopen(), fclose(), fprintf(),
fscanf(), and their relatives.
File I/O in C uses a FILE * pointer to represent an open file. You don’t manipulate the disk
directly, instead, you read and write through a buffered file stream managed by the runtime.
This returns a pointer to a FILE object if successful, or NULL if the file can’t be opened.
159
File Modes
When opening a file, you specify a mode, what you intend to do with it.
#include <stdio.h>
int main(void) {
FILE *fp = fopen("[Link]", "w");
if (!fp) {
perror("Failed to open file");
return 1;
}
fp = fopen("[Link]", "r");
if (!fp) {
perror("Failed to reopen file");
return 1;
}
char line[100];
printf("--- File Content ---\n");
while (fgets(line, sizeof(line), fp))
printf("%s", line);
fclose(fp);
160
return 0;
}
Output:
How It Works
Always check file operations for errors. Use if (!fp) after fopen(), and use perror() to
print the reason.
Example output:
You can parse text files using fscanf(), just like scanf():
161
#include <stdio.h>
int main(void) {
FILE *fp = fopen("[Link]", "r");
if (!fp) return 1;
int a, b;
while (fscanf(fp, "%d %d", &a, &b) == 2)
printf("%d + %d = %d\n", a, b, a + b);
fclose(fp);
return 0;
}
If [Link] contains:
2 3
10 15
7 9
Output:
2 + 3 = 5
10 + 15 = 25
7 + 9 = 16
162
Writing Binary Data
Text files are human-readable; binary files store raw bytes. You’ll use fwrite() and fread()
for that (covered more in the next section).
Example:
Why It Matters
File I/O is the bridge between your C program and the real world:
It teaches resource management, always fopen() and fclose() in pairs, check errors, and
handle failures gracefully.
Try It Yourself
1. Write a program that asks for your name and saves it to [Link].
2. Append a timestamp each time the program runs.
3. Read all lines and count how many times your program has been executed.
4. Modify the example to reverse all lines read from a file.
5. Handle missing files gracefully using perror().
You now know how to open, read, and write text files safely. Next, you’ll go deeper into binary
files, where data moves in raw bytes, perfect for storing structs and arrays efficiently.
Text files are easy to read but not always efficient. Binary files, on the other hand, store
raw bytes exactly as they exist in memory, no formatting, no conversions. They’re ideal for
saving arrays, structs, or any data that must be written and read back quickly without loss or
rounding.
163
Text vs Binary
When you open a file for binary I/O, add b to the mode:
#include <stdio.h>
int main(void) {
int numbers[] = {10, 20, 30, 40, 50};
size_t count = sizeof(numbers) / sizeof(numbers[0]);
This writes 5 integers (4 bytes each on most systems) directly to disk as raw bytes, no text
conversion.
164
Reading Binary Data
#include <stdio.h>
int main(void) {
int numbers[5];
FILE *fp = fopen("[Link]", "rb");
if (!fp) {
perror("Failed to open file");
return 1;
}
return 0;
}
Output:
You can store whole structures directly using the same pattern.
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int id;
float price;
165
char title[50];
} Book;
int main(void) {
Book b1 = {1, 9.99, "The C Book"};
Book b2 = {2, 15.49, "Algorithms in C"};
fp = fopen("[Link]", "rb");
if (!fp) return 1;
Book b;
while (fread(&b, sizeof(Book), 1, fp) == 1)
printf("%d | %s | %.2f\n", [Link], [Link], [Link]);
fclose(fp);
return 0;
}
Output:
Handling Endianness
Binary files depend on the CPU’s byte order (endianness). If you write on a little-endian
machine and read on a big-endian one, bytes may appear reversed.
For portable formats, you can:
166
unsigned int to_big_endian(unsigned int x) {
return ((x & 0xFF) << 24) |
((x & 0xFF00) << 8) |
((x & 0xFF0000) >> 8) |
((x >> 24) & 0xFF);
}
Function Purpose
fwrite(ptr, size, count, file) Write binary data
fread(ptr, size, count, file) Read binary data
fseek(file, offset, origin) Move position
ftell(file) Get current position
rewind(file) Go back to start
Why It Matters
It’s the foundation of serialization, transforming data in memory into bytes that can travel
or persist.
167
Try It Yourself
Binary I/O connects C’s low-level power to real-world storage efficiency. Next, you’ll expand
this further by understanding standard streams, how to use stdin, stdout, and stderr to
build flexible, composable command-line tools.
Every C program automatically starts with three open streams connected to your environment,
the keyboard, the terminal screen, and the error console. They are the standard I/O streams
that make your programs flexible and scriptable.
Understanding these three streams is crucial for writing tools that can interact with files, pipes,
and other programs, the essence of Unix-style design.
These are all of type FILE *. You can treat them like normal file pointers, reading, writing, or
redirecting them.
Basic Example
168
#include <stdio.h>
int main(void) {
char name[50];
printf("Enter your name: ");
fgets(name, sizeof(name), stdin);
Output:
This merges both output and error streams into one file.
You can build programs that process input dynamically, one line at a time:
169
#include <stdio.h>
int main(void) {
char line[100];
printf("Enter text (Ctrl+D to stop):\n");
while (fgets(line, sizeof(line), stdin))
printf("You said: %s", line);
return 0;
}
Now your program behaves like a Unix filter, it can read from a file, a pipe, or a keyboard
input interchangeably.
Example:
Output:
stdout is for normal program output, while stderr is for error messages or logs.
#include <stdio.h>
int main(void) {
fprintf(stdout, "Everything is fine.\n");
fprintf(stderr, "Warning: something might be wrong.\n");
return 0;
}
Output:
170
Tiny Code: Word Counter Using stdin/stdout
#include <stdio.h>
#include <ctype.h>
int main(void) {
int ch, words = 0, in_word = 0;
Try:
Output:
Word count: 4
Sometimes you need to log progress to stderr while outputting results to stdout. That way,
logs don’t pollute the actual data.
#include <stdio.h>
int main(void) {
for (int i = 0; i < 3; i++) {
fprintf(stderr, "Processing item %d...\n", i + 1);
171
fprintf(stdout, "Item %d processed\n", i + 1);
}
return 0;
}
Flushing Buffers
Output streams are buffered, data isn’t written until the buffer is full or flushed. To ensure
output appears immediately:
Why It Matters
They are the foundation of the Unix philosophy: small programs that do one thing well and
can be composed together.
Try It Yourself
1. Write a program that reads from stdin and prints only lines containing a given keyword.
2. Print errors to stderr if no keyword is provided.
3. Redirect input and output from files using < and >.
4. Add progress messages to stderr and redirect them to a separate log.
5. Combine everything into a small “filter” tool that processes text from pipelines.
With stdin, stdout, and stderr, your C programs become tools that fit seamlessly into real
workflows, able to interact with files, other programs, and users alike. Next, you’ll explore
buffered I/O, understanding how the C library optimizes performance through read and write
buffers using fgets, fputs, and more.
172
45. Buffered I/O with fgets and fputs
When your program reads and writes data, it doesn’t always go directly to disk or the terminal,
instead, it uses buffers. Buffers are small chunks of memory that temporarily hold data,
improving performance by reducing how often the system has to perform slow I/O operations.
C’s Standard I/O library (<stdio.h>) handles this automatically for you. In this section, you’ll
learn how buffering works and how to use fgets, fputs, and related functions to manage it
effectively.
When the buffer is full or flushed, data moves between your program and the file or terminal.
This is why sometimes printf() output doesn’t appear immediately, it’s waiting in a buffer
until a newline or flush occurs.
Input: fgets()
fgets() reads a full line from a stream (including spaces) and stores it in a string.
Example:
#include <stdio.h>
int main(void) {
char line[100];
printf("Enter a sentence: ");
if (fgets(line, sizeof(line), stdin))
printf("You said: %s", line);
return 0;
}
173
Input:
C is beautiful.
Output:
If the input exceeds the buffer, fgets stops reading after size - 1 characters to prevent
overflow, and automatically null-terminates the string.
Output: fputs()
Example:
#include <stdio.h>
int main(void) {
FILE *fp = fopen("[Link]", "w");
if (!fp) {
perror("Open failed");
return 1;
}
fclose(fp);
printf("Wrote to [Link]\n");
return 0;
}
Output file:
174
Why fgets Is Safer Than scanf("%s", …)
Avoid this:
Prefer this:
#include <stdio.h>
int main(void) {
FILE *fp = fopen("[Link]", "r");
if (!fp) {
perror("Failed to open file");
return 1;
}
char line[200];
while (fgets(line, sizeof(line), fp))
175
printf("%s", line);
fclose(fp);
return 0;
}
#include <stdio.h>
int main(void) {
FILE *in = fopen("[Link]", "r");
FILE *out = fopen("[Link]", "w");
if (!in || !out) {
perror("File error");
return 1;
}
char buf[256];
while (fgets(buf, sizeof(buf), in))
fputs(buf, out);
fclose(in);
fclose(out);
printf("Copied successfully.\n");
return 0;
}
Buffer Flushing
fflush(stdout);
This is useful for interactive programs that must show messages immediately.
To disable buffering entirely (e.g., for logging):
176
setbuf(stdout, NULL);
Why It Matters
It’s what makes C both low-level and performant without forcing you to manage every byte
yourself.
Try It Yourself
1. Write a program that reads lines from stdin and writes them to a new file.
2. Count how many lines you read before EOF.
3. Print each line with line numbers using fgets and printf.
4. Experiment with buffer sizes, try 16 vs 256 bytes and note the performance difference.
5. Flush output after every line for an interactive logging program.
fgets and fputs give you a safe, line-based foundation for file and console I/O. Next, you’ll
learn how to handle errors correctly using errno, perror, and strerror, essential tools
for writing reliable system programs in C.
Even the best-written C programs can encounter errors, missing files, permission issues, division
by zero, or failed memory allocations. Unlike some languages that throw exceptions, C reports
errors manually using return values and a global variable named errno.
Understanding how to use errno, perror(), and strerror() is essential for writing robust,
production-grade C programs that fail gracefully and informatively.
177
The Idea Behind errno
errno is a global integer (declared in <errno.h>) that stores an error code whenever a library
function fails.
Example:
#include <stdio.h>
#include <errno.h>
int main(void) {
FILE *fp = fopen("[Link]", "r");
if (!fp) {
printf("Error code: %d\n", errno);
perror("fopen failed");
}
return 0;
}
Output:
Error code: 2
fopen failed: No such file or directory
178
if (errno == ENOENT) printf("File missing.\n");
Using perror()
perror() prints a human-readable error message to stderr, based on the current value of
errno.
Example:
#include <stdio.h>
#include <errno.h>
int main(void) {
FILE *f = fopen("[Link]", "r");
if (!f)
perror("Unable to open file");
return 0;
}
Output:
Using strerror()
If you want to use the error message in your own formatted output, use strerror() from
<string.h>:
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main(void) {
FILE *f = fopen("/root/[Link]", "r");
if (!f)
printf("Error (%d): %s\n", errno, strerror(errno));
return 0;
}
179
Output:
Here’s a simple file reader that checks for errors at every step:
#include <stdio.h>
#include <string.h>
#include <errno.h>
char buf[128];
while (fgets(buf, sizeof(buf), fp))
printf("%s", buf);
if (ferror(fp)) {
fprintf(stderr, "Error reading file: %s\n", strerror(errno));
}
fclose(fp);
return 0;
}
Usage:
./readfile [Link]
180
Clearing and Resetting errno
Some functions may set errno even if they succeed later. To be safe, you can clear it before a
call:
#include <errno.h>
errno = 0;
FILE *f = fopen("[Link]", "r");
if (!f) perror("fopen");
This ensures you don’t read a leftover error from an earlier operation.
errno isn’t limited to file I/O, it applies to many system calls and library functions:
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int main(void) {
FILE *f = fopen("/dev/full", "w"); // special Linux device that fails on write
if (f) {
if (fputc('A', f) == EOF)
perror("Write failed");
fclose(f);
}
return 0;
}
Output:
181
Why It Matters
errno and its helpers (perror, strerror) make your programs explain themselves when things
go wrong. This is vital for:
Try It Yourself
1. Open a file that doesn’t exist, then create it and try again.
2. Simulate a read error by using ferror() after reading a closed file.
3. Try writing to a directory (fopen("/tmp", "w")) and inspect errno.
4. Print all known error codes and messages using a loop and strerror().
5. Write a small “safe_open” function that wraps fopen with error reporting.
With error handling mastered, you now know how to make C programs both informative
and reliable. Next, you’ll explore command-line arguments (argc, argv), the gateway to
building flexible, scriptable tools that process user input dynamically.
Every C program can receive input directly from the command line, no scanf, no fgets,
just arguments passed when you run the executable. This is how professional C tools (like gcc,
ls, and grep) receive filenames, options, and flags.
Parameter Meaning
argc Argument count (number of command-line arguments)
argv Argument vector (array of C strings, each argument)
argv[0] is the program name itself, and argv[1] onward are the user-provided arguments.
182
Example:
Then:
• argc == 3
• argv[0] = "./hello"
• argv[1] = "world"
• argv[2] = "test"
#include <stdio.h>
Run it:
Output:
Argument count: 4
argv[0] = ./args
argv[1] = foo
argv[2] = bar
argv[3] = 123
183
#include <stdio.h>
Run it:
./fileop
Output:
Run again:
./fileop [Link]
Output:
All command-line arguments are strings. To use them as numbers, convert using:
Example:
184
#include <stdio.h>
#include <stdlib.h>
int a = atoi(argv[1]);
int b = atoi(argv[2]);
printf("%d + %d = %d\n", a, b, a + b);
return 0;
}
Run:
./sum 10 25
Output:
10 + 25 = 35
You can build simple command-line tools that handle options manually:
#include <stdio.h>
#include <string.h>
if (verbose)
printf("Verbose mode on\n");
185
else
printf("Run quietly\n");
return 0;
}
Run:
./tool -v
Output:
Verbose mode on
#include <stdio.h>
char buf[128];
while (fgets(buf, sizeof(buf), fp))
printf("%s", buf);
fclose(fp);
return 0;
}
Run:
186
./echo [Link]
Why It Matters
Try It Yourself
1. Write a program that takes a list of integers and prints their sum.
2. Add a -r flag to reverse the order of printed arguments.
3. Build a “greet” tool:
./greet Alice Bob Charlie
With command-line arguments, your C programs evolve from static exercises to flexible,
real-world tools. Next, you’ll explore reading configuration files, a powerful way to let
your programs adapt automatically without recompilation.
As your C programs grow, hardcoding settings like file paths, thresholds, or user preferences
becomes limiting. Configuration files let your program read settings at runtime, a critical
capability for tools, servers, and embedded systems.
You’ll learn how to read and parse configuration files using standard I/O and string handling.
187
The Goal
port=8080
host=localhost
max_clients=100
log_file=[Link]
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int port;
char host[64];
int max_clients;
char log_file[64];
} Config;
188
void load_config(const char *filename, Config *cfg) {
FILE *fp = fopen(filename, "r");
if (!fp) {
perror("Cannot open config file");
exit(1);
}
char line[MAX_LINE];
while (fgets(line, sizeof(line), fp)) {
line[strcspn(line, "\n")] = '\0'; // remove newline
fclose(fp);
}
This function:
189
int main(void) {
Config cfg = {0};
load_config("[Link]", &cfg);
printf("Server settings:\n");
printf("Host: %s\n", [Link]);
printf("Port: %d\n", [Link]);
printf("Max clients: %d\n", cfg.max_clients);
printf("Log file: %s\n", cfg.log_file);
return 0;
}
host=[Link]
port=9090
max_clients=250
log_file=/tmp/[Link]
Output:
Server settings:
Host: [Link]
Port: 9090
Max clients: 250
Log file: /tmp/[Link]
Config cfg = {
.port = 8080,
.host = "localhost",
.max_clients = 100,
.log_file = "[Link]"
};
This ensures your program still works even if the file is missing some values.
190
Step 4. Optional: Handle Quoted Values
If you expect values with spaces (like name="My Server"), you can modify parsing logic:
For more flexible systems, you can use a hash table or array of key-value pairs instead of fixed
fields:
typedef struct {
char key[64];
char value[64];
} KVPair;
KVPair settings[100];
Why It Matters
They’re used everywhere, from .ini and .conf files to complex YAML/JSON formats in
modern systems.
Try It Yourself
1. Add support for # comments and empty lines (skip them safely).
2. Make the parser print a warning for unknown keys.
3. Add a function save_config() that writes the struct back to a file.
4. Add reload_config() to update settings at runtime.
5. Implement your own .ini format parser supporting [section] headers.
191
With configuration files, your C programs gain flexibility and real-world usability, they can
adapt, reload, and persist settings just like professional systems software. Next, you’ll learn
how to serialize and deserialize structs to disk, the next level of persistent data handling
in Section 49.
So far, you’ve worked with text files, configuration files, and basic binary data. Now it’s time
to combine those ideas into something more powerful, serialization: saving complete C structs
to disk and restoring them later, exactly as they were in memory.
This is the foundation for databases, caches, and persistent state in operating systems and
games.
What Is Serialization?
Serialization means converting in-memory data into a format that can be stored or transmitted
(like a file). Deserialization is the reverse: reconstructing that data from the file.
In C, this often means writing structs directly as binary data with fwrite() and reading them
back with fread().
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int id;
char name[50];
float price;
} Product;
Each field is fixed-size, which makes it safe to write directly to disk as binary.
192
void save_products(const char *filename, Product *arr, size_t count) {
FILE *fp = fopen(filename, "wb");
if (!fp) {
perror("Cannot open file for writing");
exit(1);
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int id;
char name[50];
float price;
} Product;
193
FILE *fp = fopen(filename, "wb");
if (!fp) {
perror("Cannot open file");
exit(1);
}
fwrite(arr, sizeof(Product), count, fp);
fclose(fp);
}
int main(void) {
Product products[3] = {
{1, "Notebook", 2.99},
{2, "Pencil", 0.49},
{3, "Backpack", 25.00}
};
Product loaded[3];
size_t n = load_products("[Link]", loaded, 3);
printf("Loaded %zu products:\n", n);
return 0;
}
Output:
194
Products saved.
Loaded 3 products:
1 | Notebook | $2.99
2 | Pencil | $0.49
3 | Backpack | $25.00
You can add more data without overwriting by using append mode "ab":
You can use fseek() to jump to a specific record (useful for updating or reading one record at
a time).
To make it portable:
195
Step 7. Text-Based Alternative (Human-Readable)
This produces:
1,Notebook,2.99
2,Pencil,0.49
3,Backpack,25.00
Why It Matters
Serialization makes your C programs stateful, they can save progress, store data, or recover
after restarts. It’s the basis for:
Try It Yourself
You now know how to persist structured data in binary or text form. Next, you’ll close Chapter
5 by combining all this knowledge, writing a log reader and writer system that records
events, rotates files, and safely replays logs on startup.
196
50. Practice: Build a Log Reader and Writer
You’ve explored text and binary I/O, buffering, error handling, and configuration. Now it’s
time to bring everything together in one real-world practice project, a Log Reader and
Writer in C.
This system will let you write structured logs to a file and later read them back, a foundation
for tools like servers, daemons, and debugging utilities.
Project Overview
1. Logger (Writer):
• Appends log messages to a file with timestamps and levels (INFO, WARN, ERROR).
• Handles file opening, writing, and safe closure.
2. Reader:
This project teaches structured file I/O, formatted output, parsing, and simple text search, all
in clean C.
• Timestamp
• Level (INFO/WARN/ERROR)
• Message
197
Step 2. Implement the Logger
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <stdarg.h>
#include <string.h>
typedef enum {
INFO,
WARN,
ERROR
} LogLevel;
// Timestamp
time_t t = time(NULL);
struct tm *tm_info = localtime(&t);
char timebuf[32];
strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", tm_info);
// Format message
va_list args;
va_start(args, fmt);
198
va_end(args);
}
int main(void) {
FILE *log = fopen("[Link]", "a");
if (!log) {
perror("Cannot open log file");
return 1;
}
fclose(log);
return 0;
}
Run it:
#include <stdio.h>
#include <string.h>
199
}
char line[256];
while (fgets(line, sizeof(line), fp)) {
if (filter == NULL || strstr(line, filter))
printf("%s", line);
}
fclose(fp);
}
Example usage:
int main(void) {
printf("All logs:\n");
read_logs("[Link]", NULL);
printf("\nOnly errors:\n");
read_logs("[Link]", "ERROR");
return 0;
}
Output:
All logs:
[2025-10-15 21:00:32] [INFO] System started
[2025-10-15 21:00:35] [WARN] Low disk space on /dev/sda1
[2025-10-15 21:00:38] [ERROR] Failed to connect to database
[2025-10-15 21:01:00] [INFO] Shutdown complete
Only errors:
[2025-10-15 21:00:38] [ERROR] Failed to connect to database
200
return 1;
}
if (strcmp(argv[1], "write") == 0) {
FILE *log = fopen("[Link]", "a");
if (!log) {
perror("open");
return 1;
}
write_log(log, INFO, "%s", argc > 2 ? argv[2] : "Generic log entry");
fclose(log);
} else if (strcmp(argv[1], "read") == 0) {
const char *filter = argc > 2 ? argv[2] : NULL;
read_logs("[Link]", filter);
} else {
fprintf(stderr, "Invalid command. Use write or read.\n");
}
return 0;
}
Usage:
201
Why It Matters
Every C-based system, from embedded devices to Linux daemons, relies on some form of
logging.
Try It Yourself
This completes Chapter 5: Input, Output, and Files, a milestone in your journey. You
can now handle text, binary, streams, and persistent data with safety and clarity. Next, you’ll
step into Chapter 6: Compilation and the Build Process, where source code transforms
into executable binaries through preprocessing, compilation, linking, and automation.
202
Chapter 6. Compilation and the build process
you’re launching a complex, multi-stage process that transforms human-readable C code into a
machine-executable binary. Understanding this compilation pipeline is the heart of becoming
a real systems programmer.
Let’s unpack what happens between your .c file and the final executable.
You can stop at any step with compiler flags to see what’s happening.
203
// hello.c
#include <stdio.h>
int main(void) {
printf("Hello, C!\n");
return 0;
}
# 1. Preprocessing
gcc -E hello.c -o hello.i
# 2. Compilation to Assembly
gcc -S hello.i -o hello.s
# 4. Linking
gcc hello.o -o hello
Run:
./hello
Output:
Hello, C!
The preprocessor handles all lines starting with #. It’s purely textual, no code execution yet.
204
gcc -E hello.c -o hello.i
Open hello.i and you’ll see thousands of lines from stdio.h inserted into your code. It also
replaces macros and removes comments.
This is the stage where your headers, macros, and conditional compilation come to life.
Next, the compiler translates your preprocessed C code into assembly language for your
target CPU.
These are CPU-specific, on x86, ARM, or RISC-V, they’ll differ. This stage also performs
optimization, type checking, and error detection.
The assembler converts the .s file into raw machine instructions and data structures, producing
a relocatable object file.
objdump -d hello.o
Step 5. Linking
The linker (ld) combines object files and libraries into a single executable.
205
gcc hello.o -o hello
If your program uses external functions (like printf), the linker locates them in system libraries
(e.g., /usr/lib/[Link]) and records their addresses.
The result: one self-contained executable ready to run.
file hello
nm hello | head
readelf -h hello
These reveal:
While it’s educational to run each step manually, most of the time you’ll rely on:
206
#include "greet.h"
int main(void) {
greet("C programmer");
return 0;
}
greet.c
#include <stdio.h>
void greet(const char *name) {
printf("Hello, %s!\n", name);
}
greet.h
gcc -c main.c
gcc -c greet.c
gcc main.o greet.o -o app
./app
Output:
Hello, C programmer!
Why It Matters
This is how source code becomes machine reality, step by step, precisely defined.
207
Try It Yourself
1. Generate all intermediate files (.i, .s, .o) for a few programs and inspect them.
2. Experiment with gcc -O0, -O2, and -O3 and observe how assembly changes.
3. Add -g and explore the binary with gdb.
4. Build a program that spans multiple .c files.
5. Use nm and objdump to trace how symbols move through the stages.
Next, you’ll explore the preprocessor and macros, the engine behind includes, constants,
and compile-time code generation.
Before your C code is ever compiled, it passes through a powerful text-handling stage called
the preprocessor. This is where headers are included, macros are expanded, and conditional
compilation happens.
The preprocessor doesn’t “understand” C, it performs text substitution and file inclusion,
preparing your code for the compiler.
Every line that starts with # is a preprocessor directive. Common ones include:
Directive Purpose
#include Insert contents of a header file
#define Define a macro or constant
#undef Remove a macro definition
#if, #ifdef, #ifndef Conditional compilation
#else, #elif, #endif Branch logic for the preprocessor
#error Stop compilation with a message
#pragma Compiler-specific instruction
Create macro.c:
208
#include <stdio.h>
#define PI 3.14159
#define CIRCLE_AREA(r) (PI * (r) * (r))
#define SQUARE(x) ((x) * (x))
int main(void) {
printf("PI = %.2f\n", PI);
printf("Area of circle (r=2): %.2f\n", CIRCLE_AREA(2));
printf("Square of 5: %d\n", SQUARE(5));
return 0;
}
Output:
PI = 3.14
Area of circle (r=2): 12.57
Square of 5: 25
Open macro.i, you’ll see all #include files expanded and macros replaced with their values.
This is a great way to debug macro behavior or check how large standard headers expand.
Macros can look like functions, but they are expanded inline, meaning no call overhead, but
also no type safety.
209
#define ADD(a, b) ((a) + (b))
Usage:
#define BAD_ADD(a, b) a + b
printf("%d\n", 2 * BAD_ADD(3, 4)); // expands to 2 * 3 + 4 → 10, not 14
Usage:
int x = 5, y = 10;
PRINT_EXPR(x + y); // prints: x + y = 15
210
Step 5. Conditional Compilation
#define DEBUG 1
#if DEBUG
#define LOG(msg) printf("DEBUG: %s\n", msg)
#else
#define LOG(msg)
#endif
Usage:
int main(void) {
LOG("Starting program");
printf("Running main logic\n");
return 0;
}
#ifdef DEBUG
#ifndef RELEASE
Prevent multiple inclusions of the same header file by using preprocessor guards:
#ifndef MY_HEADER_H
#define MY_HEADER_H
void greet(void);
#endif
If MY_HEADER_H is already defined, the contents are skipped. This prevents duplicate definitions
across multiple includes.
211
Step 7. Built-in Macros
Macro Expands To
__FILE__ current filename
__LINE__ current line number
__DATE__ compilation date
__TIME__ compilation time
__func__ current function name (C99+)
Example:
Output:
int main(void) {
int x = 10;
DEBUG_PRINT("x = %d", x);
return 0;
}
Output:
[macro.c:5] x = 10
212
Step 8. Undefining and Redefining
#undef PI
#define PI 3.14
This is often used in large projects to avoid macro name collisions between libraries.
Why It Matters
It’s also a double-edged sword, overusing macros can make code hard to debug and maintain.
Modern C favors inline functions for most use cases (see Section 54), but macros remain
indispensable for low-level systems work.
Try It Yourself
In the next section, you’ll go deeper into conditional compilation, controlling which parts of
your program are built based on platform, features, or debugging needs.
Conditional compilation lets you control which code gets compiled, not at runtime, but at
compile time. This is how C programs adapt to different operating systems, architectures, or
build configurations without changing source files manually.
Think of it as logic for the compiler’s eyes only.
213
Step 1. Why Conditional Compilation Exists
Instead of maintaining multiple versions of the same file, you can use conditional directives to
selectively include or exclude code.
Directive Purpose
#if <expr> Compile code if expression is true
#ifdef <macro> Compile if macro is defined
#ifndef <macro> Compile if macro is not defined
#else Alternate block
#elif <expr> Else-if for preprocessor
#endif Marks the end of a conditional block
#include <stdio.h>
int main(void) {
#ifdef _WIN32
printf("Running on Windows\n");
#elif __linux__
printf("Running on Linux\n");
#elif __APPLE__
printf("Running on macOS\n");
#else
printf("Unknown platform\n");
#endif
return 0;
}
214
Compile and run on your system. The output will depend on which predefined macros your
compiler sets automatically.
In your code:
#ifdef DEBUG
printf("Debug mode: extra checks enabled\n");
#endif
Then:
#ifndef CONFIG_H
#define CONFIG_H
#endif
It ensures that if config.h is included multiple times, it only gets processed once. Every
header in the C standard library uses this pattern.
215
Step 5. Excluding Experimental Code
#define ENABLE_EXPERIMENTAL 0
#if ENABLE_EXPERIMENTAL
void experimental_feature() {
printf("Running experimental feature\n");
}
#endif
#include <stdio.h>
#define DEBUG_MODE 1
void compute(int x) {
#if DEBUG_MODE
printf("[DEBUG] compute() called with x=%d\n", x);
#endif
printf("Result: %d\n", x * x);
}
int main(void) {
compute(5);
return 0;
}
216
Step 6. Using #elif and #else
#define OS 2
#if OS == 1
#define OS_NAME "Windows"
#elif OS == 2
#define OS_NAME "Linux"
#else
#define OS_NAME "Unknown"
#endif
int main(void) {
printf("OS: %s\n", OS_NAME);
return 0;
}
Output:
OS: Linux
217
#ifndef API_KEY
#error "API_KEY not defined! Please compile with -DAPI_KEY=your_key"
#endif
Macro Meaning
__GNUC__ Defined by GCC
__clang__ Defined by Clang
_MSC_VER Defined by MSVC
__x86_64__ 64-bit architecture
__arm__, __aarch64__ ARM architectures
__STDC__ Conforms to ANSI C standard
#ifdef __clang__
printf("Compiled with Clang\n");
#elif defined(__GNUC__)
printf("Compiled with GCC\n");
#endif
#include <stdio.h>
#ifdef _WIN32
#include <windows.h>
#define SLEEP(ms) Sleep(ms)
#else
#include <unistd.h>
#define SLEEP(ms) usleep((ms) * 1000)
#endif
218
int main(void) {
printf("Waiting...\n");
SLEEP(1000);
printf("Done!\n");
return 0;
}
This compiles cleanly on both Windows and Linux with no code changes.
Why It Matters
Try It Yourself
In the next section, you’ll take the next step toward clean, maintainable C code by learning
about inline functions and header hygiene, modern, safer replacements for many macro
patterns.
In early C, programmers often relied on macros for performance and reuse. But macros have
big drawbacks, no type checking, no debugging symbols, and messy error messages.
Inline functions were introduced to solve this problem. They combine the efficiency of macros
with the safety of real functions.
This section also covers header hygiene, or how to write clean, reusable .h files that scale
safely across large projects.
219
Step 1. What Does “Inline” Mean?
Normally, calling a function like add(a, b) incurs a small overhead, the CPU jumps to the
function and back. Inlining means the compiler replaces the call with the function’s code
directly, avoiding that jump.
You can suggest this with the inline keyword:
When used properly, it’s as fast as a macro but behaves like a real function.
Macro version:
Inline version:
Macro:
• No type checking
• May cause multiple evaluations (e.g., ADD(x++, y++))
• Harder to debug
Inline function:
• Type-checked
• Single evaluation
• Can be stepped through in a debugger
When defining inline functions in header files, add static to avoid multiple-definition errors:
220
// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
#endif
This ensures each .c file that includes the header gets its own copy, avoiding linker conflicts.
#include <stdio.h>
int main(void) {
int n = 3;
printf("cube(%d) = %d\n", n, cube(n));
return 0;
}
Output:
cube(3) = 27
The compiler will expand cube(3) directly into 3 * 3 * 3, no function call overhead.
221
Step 4. Inline and the Compiler
inline is a hint to the compiler, not a command. The compiler decides whether inlining
actually improves performance.
You can force inlining (non-portably) with attributes:
But it’s best to let the optimizer choose. Inlining too much can increase binary size (known as
“code bloat”).
Inline functions behave differently depending on whether they’re declared static, extern, or
plain inline.
Macro:
Inline:
222
inline void print(int x) {
printf("%d\n", x);
}
Headers define your program’s public interface. Poorly written headers cause multiple-definition
errors, redefinition warnings, and broken builds.
Follow these guidelines:
2. Keep headers minimal Only include what’s necessary, use forward declarations when
possible.
3. Don’t put function definitions unless they’re static inline.
4. Never use using namespace, global variables, or large macros in headers.
5. Group related declarations together:
typedef struct Point { int x, y; } Point;
void move(Point *p, int dx, int dy);
mathlib.h
223
#ifndef MATHLIB_H
#define MATHLIB_H
typedef struct {
int x, y;
} Point;
#endif
mathlib.c
#include <stdio.h>
#include "mathlib.h"
void print_point(Point p) {
printf("(%d, %d)\n", p.x, p.y);
}
main.c
#include "mathlib.h"
int main(void) {
Point p = {2, 3};
print_point(p);
printf("Sum = %d\n", add(2, 5));
return 0;
}
Build:
Output:
(2, 3)
Sum = 7
This structure mirrors real-world C libraries, headers for declarations, .c files for definitions,
and inline helpers where performance matters.
224
Step 8. Inline and Optimization Flags
Inline semantics were standardized in C99. Older compilers (pre-C99) treated inline inconsis-
tently. Always compile with -std=c99 or later for predictable behavior:
Why It Matters
They are a modern C programmer’s best tool for writing efficient yet maintainable code.
Try It Yourself
1. Replace three of your macros from previous exercises with inline functions.
2. Benchmark your program with and without -O2 to see the difference.
3. Write a header-only math library using static inline functions.
4. Add header guards and check with multiple includes.
5. Use objdump -d to confirm whether your inline code actually got expanded.
Next, you’ll automate your growing C projects with Makefiles and build systems, the tools
that manage compilation, linking, and dependencies efficiently.
225
55. Makefiles and Build Automation
Compiling one or two C files by hand is fine, but real projects quickly grow to dozens or
hundreds of files. Typing long gcc commands every time becomes tedious, error-prone, and
inconsistent across environments.
That’s where Makefiles come in. They automate the build process, track dependencies, and
rebuild only what changed.
Let’s build a complete understanding of how to use make and write simple but powerful
Makefiles.
make is a tool that reads a file called Makefile and executes the build rules it defines.
Each rule describes:
Basic syntax:
target: dependencies
<TAB>command
main.c
math.c
math.h
Makefile:
226
Build:
make
Output:
Run:
./app
Now if you run make again, nothing happens, because make sees that the output (app) is newer
than the sources. That’s the magic of dependency tracking.
main.c
#include <stdio.h>
#include "math.h"
int main(void) {
printf("2 + 3 = %d\n", add(2, 3));
return 0;
}
227
math.c
math.h
Makefile
clean:
rm -f *.o app
Run:
make
./app
make clean
CC = gcc
CFLAGS = -Wall -Wextra -std=c99
OBJ = main.o math.o
app: $(OBJ)
$(CC) $(OBJ) -o app
%.o: %.c
228
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJ) app
Here:
CC = gcc
CFLAGS = -Wall -std=c99
DEBUG_FLAGS = -g -O0
RELEASE_FLAGS = -O2
all: release
$(TARGET): $(OBJ)
$(CC) $(CFLAGS) $(OBJ) -o $(TARGET)
clean:
rm -f $(OBJ) $(TARGET)
make debug
229
make release
This creates main.d which tracks included headers. You can include these files in your Makefile
for automatic rebuilds:
-include $(OBJ:.o=.d)
This prevents file name collisions (e.g., if a file named clean exists).
src/
��� main.c
��� util.c
include/
��� util.h
230
SRC = src/main.c src/util.c
OBJ = $(SRC:.c=.o)
CFLAGS = -Iinclude -Wall
TARGET = app
$(TARGET): $(OBJ)
$(CC) $(OBJ) -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJ) $(TARGET)
make already knows how to build .o from .c. A minimalist Makefile can be:
CC = gcc
CFLAGS = -Wall -std=c99
OBJ = util.o io.o
LIB = libtools.a
$(LIB): $(OBJ)
ar rcs $(LIB) $(OBJ)
clean:
rm -f $(OBJ) $(LIB)
231
make
Why It Matters
Every serious C project, from the Linux kernel to tiny embedded tools, relies on make or its
descendants (like CMake, Ninja, Meson).
Try It Yourself
In the next section, you’ll learn how to link multiple files and libraries, understanding
object files, symbols, and how your code connects together during the build process.
When a program grows beyond one .c file, the compiler must combine them into a single
executable. This process, linking, is what joins all your functions, variables, and library
references into one binary.
You’ve already seen snippets of it with:
But now we’ll go deeper into how linking works and what happens when things go wrong.
232
Step 1. The Two Compilation Phases
Each .o file contains machine code and symbol tables (lists of what it defines and what it
needs).
2. Linking – the linker (ld) merges all .o files and libraries into an executable:
gcc main.o math.o -o app
Symbols are names the compiler uses to track functions and global variables. There are two
kinds:
Example:
math.c
main.c
#include <stdio.h>
int add(int, int);
int main(void) {
printf("%d\n", add(2, 3));
return 0;
}
233
gcc -c main.c
gcc -c math.c
gcc main.o math.o -o app
Run:
You’ll get:
#ifndef MATH_H
#define MATH_H
int add(int a, int b);
#endif
#include "math.h"
234
gcc main.o math.o util.o io.o -o app
(-lm links the math library that provides functions like sqrt, sin, etc.)
The -l flag searches /usr/lib and /lib by default.
Custom library example:
Here, -L. adds the current directory to the library search path, and -lmyutils links
libmyutils.a or [Link].
The linker reads from left to right. If a symbol is used before its definition appears, it might
fail.
Example:
Always list libraries after the object files that need them.
project/
��� main.c
��� math.c
��� io.c
��� math.h
��� io.h
��� Makefile
Makefile
235
CC = gcc
CFLAGS = -Wall -std=c99
OBJ = main.o math.o io.o
TARGET = app
$(TARGET): $(OBJ)
$(CC) $(OBJ) -o $(TARGET)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJ) $(TARGET)
make
./app
math.c
#include "math.h"
int square(int x) { return x * x; }
int cube(int x) { return x * x * x; }
math.h
#ifndef MATH_H
#define MATH_H
int square(int x);
int cube(int x);
#endif
main.c
236
#include <stdio.h>
#include "math.h"
int main(void) {
printf("square(3) = %d\n", square(3));
printf("cube(2) = %d\n", cube(2));
return 0;
}
Build manually:
Output:
square(3) = 9
cube(2) = 8
Static linking:
237
Step 8. Inspecting Linked Binaries
nm main.o | head
Inline functions defined as static inline in headers do not require linking, each .c file gets
its own copy. But normal functions in .c files must be linked exactly once.
Why It Matters
Understanding the linker is essential for building scalable, multi-file systems, from small utilities
to entire kernels.
238
Try It Yourself
Next, you’ll learn how to create and use static and shared libraries, the modular building
blocks that every serious C project relies on for reusability and scalability.
In large C projects, you often want to reuse code across multiple programs, without copying
the same .c files everywhere. That’s exactly what libraries are for.
A library is a collection of precompiled object files (.o) packaged together. There are two
main kinds:
math.c
string_utils.c
main.c
math.c
string_utils.c
239
#include <string.h>
math.h
string_utils.h
main.c
#include <stdio.h>
#include "math.h"
#include "string_utils.h"
int main(void) {
printf("3 + 4 = %d\n", add(3, 4));
printf("Equal? %d\n", str_eq("abc", "abc"));
return 0;
}
Link it:
240
gcc main.c -L. -lmylib -o app
Output:
3 + 4 = 7
Equal? 1
Here:
The .a file is copied into your executable, you can now delete it and your program will still
run.
ar -t libmylib.a
Output:
math.o
string_utils.o
Extract a file:
ar -x libmylib.a math.o
A shared library is loaded dynamically at runtime, not compiled into the executable. They’re
what you see in /usr/lib as .so (Linux) or .dll (Windows).
Build one:
241
gcc -fPIC -c math.c string_utils.c
gcc -shared -o [Link] math.o string_utils.o
[Link]
Run it:
./app
If you get:
error while loading shared libraries: [Link]: cannot open shared object file
You need to add the current directory to the runtime library path:
export LD_LIBRARY_PATH=.
./app
Output:
3 + 4 = 7
Equal? 1
242
ldd app
For static linking, libmylib.a won’t appear, it’s baked into the executable.
[Link].1.0.0
[Link] -> [Link].1.0.0 (symlink)
243
Tiny Code: Combined Example
Makefile
CC = gcc
CFLAGS = -Wall -fPIC
OBJS = math.o string_utils.o
TARGET_STATIC = libmylib.a
TARGET_SHARED = [Link]
$(TARGET_STATIC): $(OBJS)
ar rcs $@ $^
$(TARGET_SHARED): $(OBJS)
$(CC) -shared -o $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(TARGET_STATIC) $(TARGET_SHARED)
Build:
make
Link app:
Run:
LD_LIBRARY_PATH=. ./app
244
Feature Static (.a) Shared (.so)
Linking At compile time At runtime
File size Larger executable Smaller executable
Update Recompile required Replace .so file
Portability Fully self-contained Needs library present
Speed Slightly faster Slight load delay
For small tools or embedded systems, use static. For large or updatable software, prefer
shared.
Why It Matters
Libraries make your C code modular, maintainable, and reusable. They are the foundation of
every serious C ecosystem, from libc to OpenSSL to SDL.
Once you understand how to build and link your own .a and .so files, you can:
Try It Yourself
Next, you’ll explore how compiler flags and optimization levels affect performance, safety,
and debugging, learning how to tune gcc for both development and release builds.
245
58. Compiler Flags and Optimization Levels
Once your program compiles and links correctly, the next step is mastering compiler flags,
the switches that control warnings, debugging info, optimization, and performance.
Using the right flags can make your C code safer, faster, and easier to debug.
Let’s go through the essential gcc and clang options every C developer should know.
This compiles main.c into an executable called main using default settings, minimal warnings,
no optimization, and no debug info.
For serious development, you’ll want more control.
Warnings are the compiler’s early-warning system. They catch mistakes before they become
bugs.
Flag Meaning
-Wall Enable most common warnings
-Wextra Enable additional, stricter warnings
-Werror Treat warnings as errors
-Wpedantic Enforce strict ISO C compliance
-Wshadow Warn if a local variable hides another variable
-Wconversion Warn about implicit type conversions
-Wunused Warn about unused variables or functions
Example:
246
int x;
printf("%d\n", x);
You’ll get:
With -Werror, that warning becomes a build-stopping error, a good habit for clean codebases.
Debugging information allows tools like gdb or lldb to map machine code back to your C
source.
Flag Description
-g Include debug symbols (file names, line numbers)
-ggdb Include GNU-specific symbols for gdb
-O0 Disable optimization (makes debugging easier)
Example:
Now run:
gdb ./main
Optimization tells the compiler how aggressively to transform your code for speed or size.
Flag Description
-O0 No optimization (fast compile, easy to debug)
-O1 Basic optimization
-O2 General speed optimization (default for most builds)
247
Flag Description
-O3 Aggressive optimization (may increase size)
-Os Optimize for size
-Ofast Ignore strict standards for speed (dangerous)
Example:
Compare sizes:
fast will be smaller and run faster, the compiler reorders code, inlines functions, and removes
dead logic.
Profiling helps measure which parts of your program consume the most CPU time.
Flag Purpose
-pg Generate profiling data for gprof
-fprofile-generate / -fprofile-use Use profile-guided optimization (PGO)
-ftime-report Show how long each compilation phase took
Example:
248
Flag Meaning
-std=c89 ANSI C (1989)
-std=c99 Modern C with inline, bool, // comments
-std=c11 Adds _Generic, _Thread_local, safer atomics
-std=c17 Minor cleanup
-std=c23 Latest (adds typeof, safer macros, etc.)
Example:
Flag Description
-m32 / -m64 Compile for 32-bit or 64-bit architecture
-march=native Optimize for the host CPU
-fPIC Position-independent code (required for shared libraries)
-static Fully static linking
-DNAME=value Define a macro (same as #define in code)
Example:
Flag Description
-L<dir> Add library search path
-l<name> Link with library (e.g., -lm for math)
-static Force static linking
-shared Build a shared library
-rpath <dir> Add runtime library search path
Example:
249
gcc main.o -L. -lmylib -Wl,-rpath=. -o app
Makefile
CC = gcc
CFLAGS = -Wall -std=c99
DEBUG_FLAGS = -g -O0 -DDEBUG
RELEASE_FLAGS = -O2 -DNDEBUG
SRC = main.c util.c
OBJ = $(SRC:.c=.o)
TARGET = app
$(TARGET): $(OBJ)
$(CC) $(CFLAGS) $(OBJ) -o $(TARGET)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJ) $(TARGET)
Run:
make debug
make release
250
Step 9. Sanitizers (Runtime Safety Tools)
Modern compilers include built-in sanitizers to detect memory and thread errors:
Flag Detects
-fsanitize=address Memory leaks, buffer overflows
-fsanitize=undefined Undefined behavior
-fsanitize=thread Data races in multithreaded code
Example:
If your code writes past an array boundary, you’ll get an instant, readable report, no guessing.
Why It Matters
Mastering them gives you precise control over how your code behaves, builds, and performs,
essential for reliable systems programming.
251
Try It Yourself
1. Compile the same program with -O0, -O2, and -O3, time each run.
2. Add -fsanitize=address and find hidden memory bugs.
3. Compare binary sizes between -g and -s.
4. Add -Wall -Wextra -Werror to your Makefile and fix every warning.
5. Explore gcc --help=optimizers to see all available optimization passes.
Next, you’ll peek inside the object file itself, learning what’s stored inside .o binaries and
how the linker stitches them together to form a complete executable.
By now, you’ve seen .o files appear in every build step, the intermediate products between
source and executable. But what exactly is inside them?
Object files are the compiler’s way of packaging machine code, symbol tables, and metadata,
ready for the linker to assemble into a final program. Understanding object files helps you
debug linking errors, inspect performance, and even reverse-engineer compiled code.
252
OS Format Typical Extension
Run:
Section Contents
.text Compiled machine code (functions)
.data Global variables with initial values
.bss Global variables without initial values
.rodata Constants, const variables, string literals
.symtab Symbol table: function and variable metadata
.rel* Relocation info, how to connect this file to others
Every .o file contains symbols that describe its functions and variables. List them:
nm main.o
Output:
253
0000000000000000 T main
U printf
Symbol Meaning
T Defined in the text (code) section
U Undefined, must be provided by another file or library
D Defined in data section
B Defined in bss section
R Defined in read-only data
W Weak symbol (can be overridden)
Here, main is defined, printf is undefined, meaning the linker must find it in the C standard
library.
math.c
main.c
#include <stdio.h>
int add(int, int);
int mul(int, int);
int main(void) {
printf("%d\n", add(2, 3) * mul(1, 4));
return 0;
}
gcc -c main.c
gcc -c math.c
Inspect:
254
nm main.o
U add
U mul
U printf
T main
T add
T mul
0000000000401136 T add
By default, every function and global variable has external linkage, visible to the linker.
Use static to limit visibility to the current file:
Now nm will not list it as an exported symbol. This keeps your binary clean and prevents name
collisions across files.
255
Step 6. Inspecting Relocations
Object files can’t know final addresses yet, so they store relocation entries: placeholders for
addresses that the linker must fill later.
Check them:
readelf -r main.o
Output:
objdump -d main.o
Output snippet:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: b8 00 00 00 00 mov $0x0,%eax
Each instruction corresponds to compiled C code. This is how you verify optimizations, inspect
inlining, or study generated assembly.
Because .o files contain clear symbol metadata, you can mix object files from different languages,
for example, C and assembly.
sum.s
256
.globl sum
sum:
addq %rsi, %rdi
movq %rdi, %rax
ret
as sum.s -o sum.o
gcc main.c sum.o -o app
size main.o
Output:
You’ll get a complete low-level view of how your C code looks to the compiler.
257
Why It Matters
In systems programming, this insight separates code users from code engineers.
Try It Yourself
1. Create two .o files that depend on each other and inspect their undefined symbols.
2. Use readelf -S to compare .text, .data, and .bss for different programs.
3. Add a global variable and see how it appears in .data or .bss.
4. Mark a function as static and confirm it disappears from nm output.
5. Compile with -O2 and observe changes in disassembly with objdump -d.
Next, you’ll complete Chapter 6 by building your own Makefile-based compilation pipeline
from scratch, writing every stage explicitly to transform .c files into .o, .a, and .so artifacts
just like a real compiler toolchain.
Now that you understand how the C build process works, preprocessing, compiling, linking,
and libraries, it’s time to tie everything together with your own Makefile.
make is one of the oldest and most powerful automation tools in C development. It watches file
timestamps, builds only what has changed, and lets you define build rules in a concise way.
By writing your own Makefile, you’ll automate your entire compilation workflow like a profes-
sional.
258
project/
��� Makefile
��� main.c
��� math.c
��� math.h
��� string_utils.c
main.c
#include <stdio.h>
#include "math.h"
int main(void) {
printf("2 + 3 = %d\n", add(2, 3));
printf("2 * 3 = %d\n", mul(2, 3));
return 0;
}
math.c
#include "math.h"
math.h
#ifndef MATH_H
#define MATH_H
#endif
Makefile
259
main: main.c math.c
gcc main.c math.c -o main
Run:
make
./main
Output:
2 + 3 = 5
2 * 3 = 6
This works, but make will rebuild everything every time, even if only one file changed.
Let’s make it smarter.
CC = gcc
CFLAGS = -Wall -std=c99
clean:
rm -f *.o main
Now when you run make, it builds .o files only once, and recompiles only what changed.
Test it:
260
make
touch math.c
make
CC = gcc
CFLAGS = -Wall -std=c99
OBJS = main.o math.o string_utils.o
TARGET = app
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) $(OBJS) -o $(TARGET)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(TARGET)
$< means “the first dependency” (like main.c). $@ means “the target” (like main.o).
Now the Makefile works for any .c file automatically.
CC = gcc
CFLAGS = -Wall -std=c99
DEBUG_FLAGS = -g -O0 -DDEBUG
RELEASE_FLAGS = -O2 -DNDEBUG
OBJS = main.o math.o
TARGET = app
261
all: release
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) $(OBJS) -o $(TARGET)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(TARGET)
Run:
make debug
./app
Then:
make clean
make release
The debug build has symbols for gdb; the release build is optimized.
libmylib.a: math.o
ar rcs libmylib.a math.o
[Link]: math.o
$(CC) -shared -o [Link] math.o
262
make libmylib.a
make [Link]
install:
cp app /usr/local/bin/
help:
@echo "make [target]"
@echo "Targets: all, debug, release, clean, install, libmylib.a, [Link]"
make help
CC = gcc
SRC = $(wildcard *.c)
OBJ = $(SRC:.c=.o)
CFLAGS = -Wall -Wextra -std=c99
LDFLAGS = -lm
TARGET = app
$(TARGET): $(OBJ)
$(CC) $(CFLAGS) $(OBJ) -o $(TARGET) $(LDFLAGS)
clean:
rm -f $(OBJ) $(TARGET)
wildcard and patsubst let you automatically include new .c files as the project grows.
263
Tiny Code: Final Polished Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c99 -O2
LDFLAGS = -lm
SRC = $(wildcard *.c)
OBJ = $(SRC:.c=.o)
TARGET = app
all: $(TARGET)
$(TARGET): $(OBJ)
$(CC) $(CFLAGS) $(OBJ) -o $(TARGET) $(LDFLAGS)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJ) $(TARGET)
Run:
make
./app
This pattern is simple, robust, and scalable, the foundation of nearly all C build systems.
make VERBOSE=1
make -p
264
Step 10. Why It Matters
A well-crafted Makefile:
It’s your first step toward professional build systems like CMake, Meson, or Bazel, all of
which build on these principles.
Try It Yourself
265
Chapter 7. Working Close to the System
When you write C programs that touch files, processes, or devices, you’re talking to the
operating system, not directly to hardware. That communication happens through system
calls.
System calls are the lowest-level interface between user-space programs and the OS kernel. C’s
standard library (libc) is a thin layer of wrappers built on top of those calls, making them
easier to use and more portable.
Let’s explore how this works, and how to use system calls directly from your C code.
A system call (syscall) lets a program request a service from the OS kernel, like reading a file,
creating a process, or allocating memory.
Examples:
When you call a system function, control passes from user space to kernel space, then back
again.
The C standard library (glibc, musl, etc.) provides wrappers around these system calls.
Example:
266
#include <stdio.h>
int main(void) {
FILE *f = fopen("[Link]", "r");
if (!f) {
perror("fopen failed");
return 1;
}
fclose(f);
}
Under the hood, fopen() eventually calls open(), a system call defined in <fcntl.h>. You
can call it directly too.
int main(void) {
int fd = open("[Link]", O_RDONLY);
if (fd == -1) {
perror("open failed");
return 1;
}
char buf[128];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n >= 0) {
buf[n] = '\0';
write(STDOUT_FILENO, buf, n);
}
close(fd);
}
267
gcc sysread.c -o sysread
./sysread
This prints the first 128 bytes of [Link] directly using system calls, no fopen() or printf()
involved.
Let’s drop even the C library and use a raw syscall interface.
#include <unistd.h>
int main(void) {
const char msg[] = "Hello via system call\n";
write(1, msg, sizeof(msg) - 1); // 1 = STDOUT
_exit(0);
}
Compile:
Run:
You just executed a system call directly, bypassing the standard library entirely.
System calls like read() and write() work with file descriptors, small integer handles
managed by the OS.
Descriptor Meaning
0 Standard input (stdin)
1 Standard output (stdout)
2 Standard error (stderr)
268
Every open file, socket, or pipe has a unique descriptor.
Example:
strace ./sysread
Example output:
open("[Link]", O_RDONLY) = 3
read(3, "Hello World\n", 12) = 12
write(1, "Hello World\n", 12) = 12
close(3) = 0
This shows the real kernel-level operations, a great debugging and learning tool.
Example:
#include <errno.h>
#include <string.h>
269
Code Meaning
ENOENT File not found
EACCES Permission denied
EBADF Invalid descriptor
EINTR Interrupted system call
You can combine both layers safely, just don’t mix them on the same file descriptor.
Example (safe):
Example (unsafe):
The Linux kernel provides hundreds of system calls. You can call most through <unistd.h>,
but for rare ones, you can use syscall():
#include <sys/syscall.h>
#include <unistd.h>
int main(void) {
syscall(SYS_write, 1, "Hello syscall\n", 14);
return 0;
}
270
man 2 intro
or
Learning to use them directly is essential for understanding how higher-level abstractions (like
stdio, pthreads, or sockets) are built.
Try It Yourself
Next, you’ll take this a step further: learning how to create and manage processes with
fork() and exec(), the heart of Unix multitasking.
271
62. Process Creation (fork, exec, wait)
Every program in a Unix-like system runs inside a process, a running instance of a program
with its own memory, file descriptors, and environment. When you type ls or cat, the shell
doesn’t just “jump” into those programs. It creates a new process to run them.
In C, you can do exactly the same thing, create new processes, run other programs, and
synchronize them.
This section teaches you how fork(), exec(), and wait() work together, the three essential
building blocks of process control.
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("My PID is %d\n", getpid());
return 0;
}
Output:
My PID is 5231
272
Step 2. Creating a New Process with fork()
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
}
if (pid == 0) {
printf("Child process! PID = %d\n", getpid());
} else {
printf("Parent process! PID = %d, child PID = %d\n", getpid(), pid);
}
return 0;
}
Example output:
Each process gets a copy of the parent’s memory. Changing a variable in the child doesn’t
affect the parent.
273
#include <stdio.h>
#include <unistd.h>
int main(void) {
int counter = 0;
pid_t pid = fork();
if (pid == 0) {
counter += 10;
printf("Child counter: %d\n", counter);
} else {
counter += 1;
printf("Parent counter: %d\n", counter);
}
return 0;
}
Output:
Parent counter: 1
Child counter: 10
After fork(), the child can replace itself with a new program using exec().
There are multiple versions:
Example:
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("Before exec\n");
274
execlp("ls", "ls", "-l", NULL);
printf("This will not run if exec succeeds\n");
return 0;
}
Output:
Before exec
(total listing from `ls`)
After exec, the current process image is replaced, the PID stays the same, but the program
running inside changes.
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
execlp("echo", "echo", "Hello from child", NULL);
perror("exec failed");
} else {
printf("Parent is waiting...\n");
}
return 0;
}
Output:
275
Parent is waiting...
Hello from child
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
printf("Child running\n");
execlp("sleep", "sleep", "1", NULL);
} else {
printf("Parent waiting for child...\n");
wait(NULL);
printf("Child finished\n");
}
return 0;
}
Output:
276
Step 7. Checking Exit Status
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
_exit(42); // child exits with status 42
} else {
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status))
printf("Child exited with code %d\n", WEXITSTATUS(status));
}
}
Output:
You can spawn multiple processes and wait for them all:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("Child %d PID %d\n", i, getpid());
_exit(0);
}
}
277
for (int i = 0; i < 3; i++)
wait(NULL);
printf("All children done\n");
}
Output:
If the parent doesn’t call wait(), the child becomes a zombie (terminated, but still in process
table). If the parent terminates before the child, the child becomes an orphan and gets adopted
by init (PID 1).
Run this:
ps -l | grep Z
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
char *argv[] = {"ls", "-1", NULL};
pid_t pid = fork();
if (pid == 0) {
execvp(argv[0], argv);
perror("exec failed");
} else {
wait(NULL);
278
printf("Command finished\n");
}
}
Output:
(file listing)
Command finished
fork(), exec(), and wait() form the core process model of Unix. Every command-line
program, daemon, and service uses these under the hood.
They let you:
Once you understand these, you’re ready to dive into inter-process communication, making
your processes talk via pipes and redirection.
Try It Yourself
1. Write a program that forks two children, one runs date, one runs whoami.
2. Modify it to wait for both children to finish.
3. Create a program that forks a child, but the parent exits immediately (observe orphan
adoption).
4. Write your own run(command) function using fork(), execvp(), and waitpid().
5. Combine all this into a tiny shell that accepts commands and executes them interactively.
Next, you’ll learn how these processes can communicate and share data, using file de-
scriptors, pipes, and redirection in the next section.
279
63. File Descriptors and open/read/write
Now that you can create and manage processes, let’s explore how those processes communicate
with files, devices, and even each other, through file descriptors.
File descriptors (FDs) are one of the simplest yet most powerful abstractions in Unix and C.
Everything, files, pipes, sockets, terminals, is represented by a small integer handle. Once you
understand how to open, read, write, and close file descriptors, you can interact with any I/O
system on a Unix machine.
A file descriptor is an integer that identifies an open resource in your process. Every process
starts with three open descriptors by default:
Each time you open a file, socket, or pipe, the kernel gives you the lowest unused FD.
You can open files directly using the system call layer, instead of fopen() from stdio.
int main(void) {
int fd = open("[Link]", O_RDONLY);
if (fd == -1) {
perror("open failed");
return 1;
}
280
Compile and run:
Output example:
File descriptor: 3
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fd = open("[Link]", O_RDONLY);
if (fd == -1) return 1;
char buf[64];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Read %zd bytes: %s\n", n, buf);
}
close(fd);
}
Output:
write(fd, buffer, size) writes raw bytes from memory to a file descriptor.
281
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void) {
int fd = open("[Link]", O_WRONLY | O_CREAT | O_TRUNC, 0644);
const char msg[] = "Writing from C using write()\n";
write(fd, msg, strlen(msg));
close(fd);
}
• O_WRONLY → write-only
• O_CREAT → create if it doesn’t exist
• O_TRUNC → truncate (clear) existing contents
O_APPEND moves the file offset to the end before every write, ideal for logs.
You can also open files as non-blocking:
282
Step 6. Duplicating Descriptors
You can duplicate an FD using dup() or dup2(). This is how redirection works (> in shells).
Example:
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
int main(void) {
int fd = open("[Link]", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO); // redirect stdout to file
close(fd);
./redir_demo
cat [Link]
Output:
You can move around inside a file using lseek(fd, offset, whence).
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fd = open("[Link]", O_RDONLY);
lseek(fd, 5, SEEK_SET); // move to byte 5
char buf[16];
read(fd, buf, 10);
283
buf[10] = '\0';
printf("Chunk: %s\n", buf);
close(fd);
}
Example:
#include <errno.h>
#include <string.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main(void) {
int fd = open("[Link]", O_RDONLY);
if (fd == -1)
fprintf(stderr, "Error: %s\n", strerror(errno));
}
Output:
284
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int src = open("[Link]", O_RDONLY);
int dst = open("[Link]", O_WRONLY | O_CREAT | O_TRUNC, 0644);
char buf[256];
ssize_t n;
while ((n = read(src, buf, sizeof(buf))) > 0)
write(dst, buf, n);
close(src);
close(dst);
return 0;
}
You can use read(0, ...) and write(1, ...) directly for console I/O.
#include <unistd.h>
int main(void) {
char buf[64];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
write(STDOUT_FILENO, buf, n);
}
285
Run:
./echo_demo
hello world
Output:
hello world
• Regular files
• Pipes and sockets
• Devices and terminals
They let you control exactly how data flows in and out of your program, a foundation for
system tools, servers, and OS-level programming.
Once you understand these primitives, you can build your own versions of tools like cat, tee,
and even simple shells.
Try It Yourself
Next, you’ll use these file descriptors to make processes communicate, building pipes and
redirection, the same mechanisms shells use to connect commands like ls | grep.
Now that you can read and write with file descriptors, you can connect two processes so that
one’s output becomes the other’s input, just like ls | grep c in a shell.
This magic happens through pipes, one of Unix’s simplest and most elegant inter-process
communication (IPC) mechanisms.
286
Step 1. What Is a Pipe?
A pipe is a unidirectional data channel between two file descriptors, one for reading, one for
writing.
In the shell:
ls | grep main
is equivalent to:
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fds[2];
if (pipe(fds) == -1) {
perror("pipe failed");
return 1;
}
printf("Read end: %d, Write end: %d\n", fds[0], fds[1]);
return 0;
}
Output example:
287
You now have two connected file descriptors:
#include <unistd.h>
#include <string.h>
#include <stdio.h>
int main(void) {
int fds[2];
pipe(fds);
char buf[64];
ssize_t n = read(fds[0], buf, sizeof(buf) - 1);
buf[n] = '\0';
printf("Received: %s\n", buf);
close(fds[0]);
close(fds[1]);
}
Output:
You’ve just communicated through memory between two file descriptors, no files, no network.
288
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main(void) {
int fds[2];
pipe(fds);
Output:
Parent writes, child reads, a clean data channel between two processes.
#include <unistd.h>
#include <stdio.h>
int main(void) {
289
int fds[2];
pipe(fds);
if (pid == 0) {
dup2(fds[0], STDIN_FILENO);
close(fds[1]);
execlp("wc", "wc", "-w", NULL);
} else {
close(fds[0]);
write(fds[1], "Hello from parent\nThis is a pipe test\n", 39);
close(fds[1]);
}
}
Output:
This is exactly how the shell implements pipelines like echo "hi" | wc -w.
You can chain multiple commands by creating multiple pipes and connecting them in series.
Example concept:
Each process reads from the previous pipe and writes to the next, the shell’s fundamental
design.
You can implement the same concept in C by:
290
Step 7. Named Pipes (FIFOs)
Pipes normally exist only between related processes. To share data between unrelated programs,
you can use named pipes (FIFOs).
Create one:
mkfifo mypipe
And in another:
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void) {
mkfifo("mypipe", 0666);
int fd = open("mypipe", O_WRONLY);
write(fd, "Hello FIFO\n", 11);
close(fd);
}
If all write ends of a pipe are closed, read() returns 0, indicating EOF.
int fds[2];
pipe(fds);
close(fds[1]); // no writers
char buf[10];
ssize_t n = read(fds[0], buf, 10); // n == 0 => EOF
If you try to write after all readers are gone, you’ll get SIGPIPE.
291
Tiny Code: Minimal Shell Pipeline
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
int fds[2];
pipe(fds);
if (fork() == 0) {
dup2(fds[1], STDOUT_FILENO);
close(fds[0]);
execlp("ls", "ls", NULL);
}
if (fork() == 0) {
dup2(fds[0], STDIN_FILENO);
close(fds[1]);
execlp("wc", "wc", "-l", NULL);
}
close(fds[0]);
close(fds[1]);
wait(NULL);
wait(NULL);
}
Run:
Output:
292
Step 9. Combining Redirection and Files
Try It Yourself
Next, you’ll explore how processes signal and interrupt each other, using signals and signal
handlers, a crucial concept for handling interrupts, timeouts, and graceful termination.
293
65. Signals and Signal Handlers
When you press Ctrl+C and your program stops, that’s not magic, it’s a signal. Signals are
how the operating system tells your process that something important has happened.
They’re asynchronous, lightweight messages from the kernel or other processes. Your C program
can catch, ignore, or handle them, giving you full control over shutdowns, interrupts, and
errors.
kill -l
Output example:
Any process can send a signal to another using the kill() system call.
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
pid_t pid = getpid();
294
printf("My PID: %d\n", pid);
pause(); // wait for signal
}
./signal_wait
Then in another:
The program will wake up from pause() and terminate (default behavior for SIGUSR1).
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
signal(SIGINT, handle_sigint);
while (1) {
printf("Running... Press Ctrl+C to stop.\n");
sleep(1);
}
}
Output:
295
Running... Press Ctrl+C to stop.
Running... Press Ctrl+C to stop.
^C
Caught signal 2 (SIGINT). Exiting gracefully.
signal() is simple but inconsistent across systems. The recommended modern interface is
sigaction().
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
struct sigaction sa = {0};
sa.sa_handler = handler;
sigaction(SIGUSR1, &sa, NULL);
Send a signal:
Output:
Caught signal
Unlike signal(), this version is reliable and reentrant-safe (you can call only async-safe
functions like write() inside handlers).
296
Step 5. Ignoring and Resetting Signals
signal(SIGINT, SIG_IGN);
signal(SIGINT, SIG_DFL);
This can be useful if you don’t want Ctrl+C to interrupt certain sections of code.
int main(void) {
pid_t pid = fork();
if (pid == 0) {
signal(SIGUSR1, child_handler);
while (1) pause();
} else {
sleep(1);
printf("Parent sending SIGUSR1\n");
kill(pid, SIGUSR1);
sleep(1);
kill(pid, SIGTERM);
}
}
Output:
297
Step 7. Blocking and Unblocking Signals
Sometimes you want to delay signal handling. You can use sigprocmask() to block signals
temporarily.
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
Press Ctrl+C during the block, nothing happens. Once unblocked, it terminates normally.
#include <unistd.h>
#include <signal.h>
#include <stdio.h>
int main(void) {
signal(SIGALRM, handler);
alarm(3); // after 3 seconds, send SIGALRM
printf("Waiting...\n");
298
pause();
}
Output:
Waiting...
Timer expired!
Signals let you implement graceful cleanup (e.g., close files, delete temp files).
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
signal(SIGINT, cleanup);
open("[Link]", O_CREAT | O_WRONLY, 0644);
while (1) {
printf("Running... (Ctrl+C to exit)\n");
sleep(1);
}
}
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
299
volatile sig_atomic_t running = 1;
int main(void) {
signal(SIGINT, stop);
printf("Server running. Press Ctrl+C to stop.\n");
while (running) {
printf("Handling request...\n");
sleep(1);
}
Every real-world Unix program, from editors to web servers, depends on correct signal handling
for stability.
Try It Yourself
1. Write a program that ignores SIGINT for 5 seconds, then restores default behavior.
2. Catch SIGTERM and print “Termination requested”.
3. Make a parent send SIGUSR1 to its child every second.
4. Use alarm() to implement a timeout for user input.
5. Add a signal handler to your shell that cleans up child processes before exit.
Next, you’ll learn how programs share and map memory directly, using mmap(), a system
call that powers databases, shared memory, and file-backed data structures.
300
66. Memory Mapping (mmap)
In previous sections, you learned how to read and write files using read() and write(). Those
system calls move data between files and user-space buffers in RAM.
But what if you could map a file directly into memory, and then treat it as part of your
process’s address space?
That’s exactly what memory mapping (via mmap) does, it’s faster, more flexible, and forms
the backbone of databases, shared memory systems, and even virtual memory itself.
mmap() maps a file or device into memory so you can access it directly, as if it were an array in
RAM.
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
Parameter Description
addr Hint for mapping address (usually NULL)
length Number of bytes to map
prot Protection: PROT_READ, PROT_WRITE, etc.
flags Type: MAP_PRIVATE, MAP_SHARED, etc.
fd File descriptor to map
offset Start offset in file (must be multiple of page size)
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(void) {
int fd = open("[Link]", O_RDONLY);
301
if (fd == -1) { perror("open"); return 1; }
If you want to modify a file through memory, you must open it read-write and use PROT_WRITE.
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/stat.h>
int main(void) {
int fd = open("[Link]", O_RDWR | O_CREAT, 0666);
ftruncate(fd, 64); // ensure file has enough size
munmap(map, 64);
302
close(fd);
}
You can create memory that isn’t tied to any file, purely in RAM.
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
size_t len = 4096;
int *arr = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (arr == MAP_FAILED) return 1;
arr[0] = 1234;
printf("arr[0] = %d\n", arr[0]);
munmap(arr, len);
}
Output:
arr[0] = 1234
Anonymous mappings are commonly used for dynamic memory regions or shared memory
between processes.
You can use MAP_SHARED and fork() to let parent and child processes share the same mapped
memory.
303
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int *shared = mmap(NULL, sizeof(int),
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS,
-1, 0);
*shared = 0;
pid_t pid = fork();
if (pid == 0) {
(*shared)++;
printf("Child: shared = %d\n", *shared);
} else {
sleep(1);
printf("Parent: shared = %d\n", *shared);
}
munmap(shared, sizeof(int));
}
Output:
Child: shared = 1
Parent: shared = 1
304
You can change permissions later:
This helps you simulate “read-only” data regions or test segmentation faults intentionally.
Memory is mapped in units of pages (usually 4096 bytes). You can get your system’s page
size:
#include <unistd.h>
#include <stdio.h>
int main(void) {
printf("Page size: %ld bytes\n", sysconf(_SC_PAGESIZE));
}
munmap(addr, length);
Databases, editors, and browsers (like SQLite, Vim, Chrome) rely heavily on mmap for perfor-
mance.
305
Tiny Code: Count Lines in a Large File
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fd = open("[Link]", O_RDONLY);
struct stat st;
fstat(fd, &st);
It’s the bridge between files and memory, unifying two key abstractions in C and Unix.
Try It Yourself
306
2. Create shared memory between parent and child processes with MAP_SHARED.
3. Measure performance difference between read() and mmap.
4. Map only part of a file using an offset aligned to page size.
5. Implement a small in-memory key-value store backed by mmap.
Next, you’ll explore how to work with time and clocks in C, retrieving system timestamps,
measuring durations, and implementing timers with precision.
Time is one of the simplest things humans understand, and one of the trickiest things for
computers to handle correctly. In C, time is represented in seconds since the Unix epoch
(Jan 1, 1970), and you can work with it at various levels: wall-clock time, process time, and
high-precision timers.
Let’s explore how to get, format, and measure time in C.
The simplest way to get the current time is with the time() function.
#include <time.h>
#include <stdio.h>
int main(void) {
time_t now = time(NULL);
printf("Seconds since epoch: %ld\n", now);
}
Output:
You can convert time_t into a calendar date using localtime() or gmtime().
307
#include <time.h>
#include <stdio.h>
int main(void) {
time_t now = time(NULL);
struct tm *t = localtime(&now);
printf("Local time: %02d-%02d-%04d %02d:%02d:%02d\n",
t->tm_mday, t->tm_mon + 1, t->tm_year + 1900,
t->tm_hour, t->tm_min, t->tm_sec);
}
Output:
#include <time.h>
#include <stdio.h>
int main(void) {
char buf[100];
time_t now = time(NULL);
struct tm *t = localtime(&now);
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", t);
printf("Formatted: %s\n", buf);
}
Output:
308
#include <time.h>
#include <stdio.h>
int main(void) {
clock_t start = clock();
for (volatile long i = 0; i < 100000000; i++);
clock_t end = clock();
double seconds = (double)(end - start) / CLOCKS_PER_SEC;
printf("Elapsed time: %.3f seconds\n", seconds);
}
Output:
clock() measures CPU time, not real elapsed time, so it excludes time spent waiting for I/O
or sleeping.
#include <time.h>
#include <stdio.h>
int main(void) {
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
clock_gettime(CLOCK_MONOTONIC, &end);
Output:
309
Elapsed: 0.515421 seconds
#include <unistd.h>
#include <stdio.h>
int main(void) {
printf("Sleeping for 2 seconds...\n");
sleep(2);
printf("Awake!\n");
}
#include <time.h>
int main(void) {
struct timespec ts = {0, 500000000}; // 0.5 seconds
nanosleep(&ts, NULL);
}
gmtime() gives you UTC, while localtime() converts to your system’s timezone.
You can change timezone behavior via the TZ environment variable and tzset().
You can inspect how much CPU time your program used with getrusage().
310
#include <sys/resource.h>
#include <stdio.h>
int main(void) {
struct rusage usage;
getrusage(RUSAGE_SELF, &usage);
printf("User CPU time: %ld.%06lds\n",
usage.ru_utime.tv_sec, usage.ru_utime.tv_usec);
printf("System CPU time: %ld.%06lds\n",
usage.ru_stime.tv_sec, usage.ru_stime.tv_usec);
}
#include <time.h>
#include <stdio.h>
int main(void) {
time_t start = time(NULL);
sleep(2);
time_t end = time(NULL);
printf("Elapsed: %.0f seconds\n", difftime(end, start));
}
Output:
Elapsed: 2 seconds
#include <stdio.h>
#include <unistd.h>
int main(void) {
for (int i = 5; i > 0; i--) {
311
printf("%d...\n", i);
sleep(1);
}
printf("Time's up!\n");
}
Output:
5...
4...
3...
2...
1...
Time's up!
Why It Matters
Every systems program eventually needs accurate, reliable time measurement, and C
gives you all the low-level tools to do it efficiently.
Try It Yourself
Next, you’ll learn how to access and modify environment variables, another key part of
how Unix programs communicate with their runtime environment.
312
68. Environment Variables
Every program in Unix inherits a set of key–value pairs called environment variables.
They store information about your shell, system configuration, and runtime behavior, such as
your username, home directory, and compiler paths.
C gives you full control to read, modify, and define these variables directly.
KEY=VALUE
printenv
Common examples:
HOME=/home/user
PATH=/usr/local/bin:/usr/bin:/bin
USER=alice
LANG=en_US.UTF-8
SHELL=/bin/bash
These values are passed to every program when you run it.
You can use the standard library function getenv() to read a variable.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
const char *path = getenv("PATH");
if (path)
printf("PATH = %s\n", path);
else
printf("PATH not found.\n");
}
313
Output example:
PATH = /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
#include <stdlib.h>
#include <stdio.h>
int main(void) {
setenv("GREETING", "Hello from C!", 1);
printf("%s\n", getenv("GREETING"));
}
Output:
Hello from C!
#include <stdlib.h>
#include <stdio.h>
int main(void) {
setenv("TEMPVAR", "temporary", 1);
printf("Before unset: %s\n", getenv("TEMPVAR"));
unsetenv("TEMPVAR");
printf("After unset: %s\n", getenv("TEMPVAR"));
}
Output:
314
Before unset: temporary
After unset: (null)
The environ global variable gives you access to the entire environment list.
#include <stdio.h>
int main(void) {
for (char **env = environ; *env != NULL; env++) {
printf("%s\n", *env);
}
}
When you use fork() and exec(), environment variables are inherited by the child process
automatically.
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(void) {
setenv("HELLO", "world", 1);
execlp("printenv", "printenv", "HELLO", NULL);
perror("execlp");
}
Output:
world
You can also provide a custom environment list using execle() or execve().
315
Step 7. Custom Environment for a New Program
#include <unistd.h>
#include <stdio.h>
int main(void) {
char *newenv[] = { "MODE=debug", "VERSION=1.0", NULL };
execle("/usr/bin/env", "env", NULL, newenv);
perror("execle");
}
Output:
MODE=debug
VERSION=1.0
Only these two variables exist for the new process, everything else is discarded.
For example:
Environment variables are inherited automatically, so they can be a security risk if not handled
carefully:
316
Tiny Code: Mini Shell with PATH Lookup
#include <stdio.h>
#include <stdlib.h>
int main(void) {
const char *path = getenv("PATH");
if (!path) path = "(none)";
printf("Current PATH:\n%s\n", path);
setenv("PATH", "/usr/local/bin:/usr/bin", 1);
printf("\nUpdated PATH:\n%s\n", getenv("PATH"));
}
Output:
Current PATH:
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
Updated PATH:
/usr/local/bin:/usr/bin
Understanding how to read and modify them is key to mastering Unix programming in C.
Try It Yourself
Next, you’ll learn about error handling and return codes, the invisible signals that every
Unix process uses to tell the system whether it succeeded or failed.
317
69. Error Handling and Return Codes
Every C program, from the tiniest script to the Linux kernel itself, relies on error codes and
return values to communicate success or failure. Unlike higher-level languages, C gives you
no exceptions, only clear, explicit status codes and errno.
Mastering these patterns will make your programs robust, predictable, and professional.
Every process returns an integer exit code to the operating system. Conventionally:
• 0 → success
• nonzero → failure or specific error
#include <stdio.h>
int main(void) {
printf("Everything OK!\n");
return 0; // exit success
}
./program
echo $?
Output:
Everything OK!
0
return 1;
318
Step 2. Using EXIT_SUCCESS and EXIT_FAILURE
#include <stdio.h>
#include <stdlib.h>
int main(void) {
printf("Failed to open file.\n");
return EXIT_FAILURE;
}
When a library or system call fails, it usually sets a global variable named errno. It’s declared
in <errno.h>.
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
int main(void) {
int fd = open("[Link]", O_RDONLY);
if (fd == -1) {
printf("Error opening file: %s\n", strerror(errno));
}
}
Output:
errno stores an integer code, but strerror() converts it into a readable message.
319
Step 4. Common errno Values
A simpler way to print error messages is perror(), it automatically uses the current errno.
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main(void) {
int fd = open("[Link]", O_RDONLY);
if (fd == -1) {
perror("open");
}
}
Output:
320
#include <stdio.h>
#include <stdlib.h>
Some system calls set errno only when they fail. So always reset it before use if you plan to
inspect it later:
#include <errno.h>
errno = 0;
if (some_syscall() == -1) {
perror("syscall failed");
}
321
#include <stdio.h>
#include <stdlib.h>
int main(void) {
FILE *f = fopen("[Link]", "r");
if (!f) die("fopen");
}
Output:
Not all errors should abort your program. Sometimes you should log, retry, or ignore.
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void) {
FILE *f = fopen("[Link]", "r");
322
if (!f) {
fprintf(stderr, "Error: %s\n", strerror(errno));
return EXIT_FAILURE;
}
char buf[64];
while (fgets(buf, sizeof(buf), f))
printf("%s", buf);
fclose(f);
return EXIT_SUCCESS;
}
Why It Matters
By convention:
• Return 0 on success.
• Return nonzero for recoverable or fatal errors.
• Print messages to stderr, not stdout.
Try It Yourself
Next, you’ll put all of this together in Practice 70: Building a Mini Shell in C, where
you’ll handle processes, pipes, and signals to create your own working Unix shell prototype.
323
70. Practice: Mini Shell in C
It’s time to bring together everything you’ve learned so far, system calls, process creation,
pipes, redirection, and signal handling, into one cohesive project.
In this section, you’ll build a minimal interactive shell, just like bash or zsh, but stripped
down to the essentials. It will run commands, handle input/output redirection, and even
support pipelines.
Optional extensions:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
char input[MAX];
while (1) {
printf("$ ");
324
fflush(stdout);
// Remove newline
input[strcspn(input, "\n")] = 0;
// Exit command
if (strcmp(input, "exit") == 0)
break;
// Tokenize input
char *args[64];
int i = 0;
char *token = strtok(input, " ");
while (token) {
args[i++] = token;
token = strtok(NULL, " ");
}
args[i] = NULL;
printf("Goodbye!\n");
return 0;
}
325
gcc mini_shell.c -o mini_shell
./mini_shell
Try commands:
$ ls
$ pwd
$ echo hello world
$ exit
$ xyz
Output:
This happens because the program handles execvp() failure properly with perror(), just as
you learned in section 69.
Let’s make Ctrl+C stop the running command, but not kill the shell itself.
#include <signal.h>
int main(void) {
signal(SIGINT, sigint_handler);
...
}
Now the shell ignores Ctrl+C while waiting for input, instead of terminating.
326
Step 5. Supporting Output Redirection
#include <fcntl.h>
Now stdout of the command goes to the file instead of the screen.
Add:
if (strcmp(args[j], "<") == 0) {
args[j] = NULL;
int fd = open(args[j + 1], O_RDONLY);
dup2(fd, STDIN_FILENO);
close(fd);
break;
}
327
Step 7. Adding Pipe Support
$ ls | wc -l
int pipefd[2];
pipe(pipefd);
pid_t p1 = fork();
if (p1 == 0) {
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[0]);
execlp("ls", "ls", NULL);
}
pid_t p2 = fork();
if (p2 == 0) {
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[1]);
execlp("wc", "wc", "-l", NULL);
}
close(pipefd[0]);
close(pipefd[1]);
wait(NULL);
wait(NULL);
With ~150 lines of code, you have a working Unix shell prototype.
328
Step 9. Tiny Code: Full Mini Shell
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <signal.h>
int main(void) {
signal(SIGINT, sigint_handler);
char input[1024];
while (1) {
printf("$ ");
fflush(stdout);
if (!fgets(input, sizeof(input), stdin)) break;
input[strcspn(input, "\n")] = 0;
if (strcmp(input, "exit") == 0) break;
char *args[64];
int i = 0;
char *token = strtok(input, " ");
while (token) {
args[i++] = token;
token = strtok(NULL, " ");
}
args[i] = NULL;
329
close(fd);
args[j] = NULL;
} else if (strcmp(args[j], "<") == 0) {
int fd = open(args[j + 1], O_RDONLY);
dup2(fd, STDIN_FILENO);
close(fd);
args[j] = NULL;
}
}
execvp(args[0], args);
perror("execvp");
exit(1);
} else if (pid > 0) {
wait(NULL);
}
}
printf("Exiting shell.\n");
return 0;
}
You’ve just built a simplified version of the core that powers every Unix shell, from bash to
zsh to fish.
Try It Yourself
330
4. Display the exit code after each command.
5. Handle multiple spaces and quoted arguments.
Next, we’ll move into Chapter 8: Debugging, Testing, and Profiling, starting with gdb,
your most powerful ally in understanding and fixing C programs.
331
Chapter 8. Debugging, Testing and Profiling
Every C programmer eventually meets a segmentation fault, and that’s when you discover your
most powerful companion: gdb, the GNU Debugger. Debugging isn’t about luck; it’s about
learning to inspect a program as it runs, to pause time, and to see what the computer is really
doing.
Let’s learn how to use gdb to find bugs, inspect memory, trace crashes, and truly
understand your code.
Before you can debug, you need to tell the compiler to include symbol information (variable
names, line numbers, etc.). Use the -g flag:
gdb ./main
Inside gdb, you can run your program just like normal:
(gdb) run
332
Step 3. Setting Breakpoints
#include <stdio.h>
void buggy(void) {
int *p = NULL;
*p = 10;
}
int main(void) {
printf("Before crash\n");
buggy();
printf("After crash\n");
}
Output:
333
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555159 in buggy () at bug.c:5
5 *p = 10;
Now inspect:
(gdb) backtrace
#0 buggy () at bug.c:5
#1 main () at bug.c:10
You’ve just traced the crash from main to the exact faulty line.
(gdb) print x
(gdb) print *ptr
Command Action
run Start the program
break N Stop at line N
next Run next line
step Step into function
continue Resume until next breakpoint
finish Run until function returns
334
Command Action
backtrace Show call stack
info locals List local vars
print VAR Show value
quit Exit gdb
int counter = 0;
for (int i = 0; i < 10; i++)
counter += i;
In gdb:
Every time counter changes, the program pauses, showing where it happened.
335
(gdb) info registers
This is useful for low-level debugging (e.g., compilers, OS kernels, embedded code).
#include <stdio.h>
int main(void) {
int sum = 0;
for (int i = 1; i <= 5; i++) {
sum = sum + i;
}
printf("Sum = %d\n", sum); // should be 15
}
Introduce a bug:
You’ll see that sum never changes, because the loop body was empty.
Why It Matters
Learning gdb teaches you how C really runs, from stack frames to pointers.
336
Try It Yourself
1. Write a program that crashes (e.g., use a null pointer) and trace the cause with gdb.
2. Use next and step to trace a recursive function.
3. Set a watchpoint on a variable in a loop.
4. Add a conditional breakpoint that triggers only when a value exceeds a limit.
5. Explore backtrace and info locals after a crash.
Next, you’ll learn how to detect hidden memory errors, leaks, invalid frees, and buffer
overflows, using the indispensable Valgrind tool.
If gdb helps you see how your program runs, Valgrind helps you see where it leaks. C gives you
raw control over memory, and that means you’re responsible for every allocation, deallocation,
and pointer access.
Valgrind is your best friend when you need to find:
Let’s learn how to use it to make your programs solid and leak-free.
On Linux:
337
Step 2. Running with Valgrind
valgrind ./memory
Valgrind runs your program inside a virtual CPU and monitors every memory operation. At
the end, it prints a detailed report of allocations and leaks.
Here’s a program with two common mistakes: a leak and an invalid free.
#include <stdlib.h>
#include <stdio.h>
int main(void) {
int *p = malloc(10 * sizeof(int));
p[10] = 42; // invalid write (out of bounds)
return 0; // forgot to free(p)
}
Run it:
Output:
338
Step 4. Fixing the Errors
Correct version:
#include <stdlib.h>
#include <stdio.h>
int main(void) {
int *p = malloc(10 * sizeof(int));
if (!p) return 1;
p[9] = 42; // valid index
free(p);
}
Run again:
valgrind ./mem_bug
Output:
#include <stdlib.h>
#include <stdio.h>
int main(void) {
int *p = malloc(sizeof(int));
*p = 5;
free(p);
printf("%d\n", *p); // using freed memory
}
Valgrind says:
339
==1234== Invalid read of size 4
==1234== at 0x1091A: main (use_after_free.c:7)
==1234== Address 0x5201040 is 0 bytes inside a block of size 4 free'd
#include <stdlib.h>
int main(void) {
int *p = malloc(4);
free(p);
free(p);
}
Valgrind output:
Type Meaning
definitely lost No pointer to the block remains, true leak
indirectly lost Referenced by a leaked block
possibly lost Pointer may exist but Valgrind can’t confirm
still reachable Program ended but memory wasn’t freed (often harmless)
340
valgrind --leak-check=full --show-leak-kinds=definite ./program
#include <stdio.h>
int main(void) {
int x;
printf("%d\n", x); // uninitialized read
}
Valgrind output:
#include <stdlib.h>
int main(void) {
leak1();
leak2();
}
Run:
341
valgrind --leak-check=full ./leaks
Output:
Why It Matters
It’s an essential tool in your workflow, especially for long-running programs, servers, or systems
software.
Try It Yourself
1. Write a program that allocates multiple blocks and forgets to free one.
2. Intentionally use p[10] on a malloc(10) block.
3. Trigger a use-after-free and find it in Valgrind.
4. Use --track-origins=yes to trace uninitialized data.
5. Refactor your code until Valgrind reports:
Next, you’ll explore Assertions and Defensive Programming, techniques to catch logic
errors before they reach runtime crashes.
342
73. Assertions and Defensive Programming
Bugs are inevitable, but crashes don’t have to be. C gives you direct power over the machine,
which means you must protect your own assumptions. That’s where assertions and
defensive programming come in: they help you catch mistakes early, fail fast, and make
your code predictable.
An assertion is a sanity check built into your code. It tests whether something you believe to
be true actually is. If not, the program immediately stops with an error message, before things
get worse.
Include the header:
#include <assert.h>
Example:
if (!(expression)) {
fprintf(stderr, "Assertion failed: %s, file %s, line %d\n",
"expression", __FILE__, __LINE__);
abort();
}
When compiled normally, it checks the condition. When compiled with -DNDEBUG, assertions
are disabled.
343
Step 3. Enabling or Disabling Assertions
#include <assert.h>
#include <stdio.h>
int main(void) {
int data[] = {3, 5, 7, 2, 8};
printf("Max: %d\n", find_max(data, 5));
}
If you pass a NULL pointer or invalid length, the program fails immediately.
assert(argc == 3);
Good:
344
if (argc != 3) {
fprintf(stderr, "Usage: %s input output\n", argv[0]);
return 1;
}
Use assertions to check invariants inside your logic, things that should never happen unless
there’s a bug.
Defensive programming goes beyond assertions, it’s about writing code that assumes
mistakes will happen.
Check every function return value:
Validate inputs:
345
Step 7. Assertions in Complex Systems
In large programs, assertions act like tripwires to detect when state becomes inconsistent.
Example: a queue
#include <assert.h>
If something goes wrong in your logic, the assertion will tell you immediately, before memory
corruption happens.
Combine both:
#include <assert.h>
#include <stdio.h>
#include <assert.h>
_Static_assert(sizeof(int) == 4, "int must be 4 bytes");
346
error: static assertion failed: "int must be 4 bytes"
#include <assert.h>
#include <stdio.h>
#define MAX 5
int main(void) {
int nums[MAX] = {1, 2, 3, 4, 5};
printf("%d\n", safe_get(nums, MAX, 2)); // OK
printf("%d\n", safe_get(nums, MAX, 10)); // triggers assertion
}
Output:
Assertion failed: (i >= 0 && i < n), function safe_get, file main.c, line 7.
Why It Matters
In C, a single bad pointer can crash your system. Assertions are your safety net.
347
Try It Yourself
Next, you’ll move into unit testing in C, building small, automated tests to ensure every
function works exactly as intended.
Testing isn’t just something you do at the end, it’s how you build confidence in every line of
code. Unit testing means checking small, isolated pieces (functions, modules) automatically, so
you can change your code without fear.
C doesn’t come with a built-in testing framework, but it’s easy to build lightweight ones, and
several excellent libraries exist if you want more power.
Let’s walk through how to design and run unit tests in plain C.
For example:
void test_add(void) {
if (add(2, 3) != 5) printf("test_add failed!\n");
else printf("test_add passed!\n");
}
int main(void) {
test_add();
}
348
Output:
test_add passed!
src/
math.c
math.h
tests/
test_math.c
Makefile
all:
gcc -g -Wall -I../src ../src/math.c test_math.c -o test_math
#include <stdio.h>
Now:
349
int add(int a, int b) { return a + b; }
void test_add(void) {
ASSERT_EQ_INT(5, add(2, 3));
ASSERT_EQ_INT(0, add(-1, 1));
}
Output:
PASS: test_math.c:10
PASS: test_math.c:11
int main(void) {
test_add();
test_subtract();
}
#include <math.h>
#define ASSERT_EQ_FLOAT(expected, actual, eps) \
if (fabs((expected) - (actual)) > (eps)) \
printf("FAIL: expected %.3f, got %.3f\n", (expected), (actual)); \
else \
printf("PASS\n");
350
Step 6. Using Return Codes to Mark Failures
Instead of just printing results, you can make the test binary return EXIT_FAILURE if any test
fails.
int fails = 0;
#define TEST(cond) \
do { if (!(cond)) { \
printf("FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \
fails++; \
} else { \
printf("PASS: %s:%d\n", __FILE__, __LINE__); \
} } while (0)
At the end:
#include <stdio.h>
#include <stdlib.h>
351
TEST(test_addition) {
int sum = 2 + 3;
ASSERT_TRUE(sum == 5);
}
int main(void) {
RUN(test_addition);
printf("\nTests run: %d, failed: %d\n", tests_run, tests_failed);
return tests_failed ? EXIT_FAILURE : EXIT_SUCCESS;
}
Output:
Running test_addition... OK
• Check (POSIX-compliant)
• Unity (embedded-friendly)
• CMocka
• Criterion
#include <check.h>
START_TEST(test_add)
{
ck_assert_int_eq(2 + 3, 5);
}
END_TEST
352
gcc test.c -lcheck -o test
test:
gcc -Wall -g src/*.c tests/*.c -o tests/run_tests
./tests/run_tests
make test
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
void test_push(void) {
Node *head = NULL;
head = push(head, 10);
head = push(head, 20);
assert(head->value == 20);
353
assert(head->next->value == 10);
printf("test_push passed\n");
}
int main(void) {
test_push();
printf("All tests passed.\n");
}
Output:
test_push passed
All tests passed.
Why It Matters
• Prevents regressions.
• Encourages small, clean functions.
• Makes debugging faster.
• Builds confidence before refactoring.
When you trust your tests, you can rewrite your code fearlessly.
Try It Yourself
Next, you’ll learn how to add logging systems to your C programs, to record what’s happening
under the hood in a controlled, readable way.
354
75. Logging Systems
As your programs grow, printf debugging quickly becomes messy. You need a way to see
inside your program, what it’s doing, what went wrong, and why, without flooding your terminal
with random messages.
That’s where logging systems come in. A good log system helps you trace execution, record
errors, and understand how your program behaves over time.
Logging is like keeping a diary for your program. Instead of printing everything to the screen,
you log structured messages with levels (INFO, WARN, ERROR) and timestamps.
It’s essential for:
#include <stdio.h>
int main(void) {
printf("[INFO] Starting program\n");
printf("[WARN] Low memory\n");
printf("[ERROR] Failed to open file\n");
}
355
#include <stdio.h>
#include <time.h>
int main(void) {
LOG_INFO("Program started");
LOG_WARN("Memory usage at %d%%", 80);
LOG_ERROR("File %s not found", "[Link]");
}
Output:
356
struct tm *tm_info = localtime(&t); \
char buf[20]; \
strftime(buf, 20, "%H:%M:%S", tm_info); \
fprintf(stderr, "[%s] [%s] " msg "\n", buf, tag, ##__VA_ARGS__); \
} \
} while (0)
Now:
CURRENT_LOG_LEVEL = LOG_LEVEL_WARN;
#include <stdio.h>
#include <time.h>
int main(void) {
log_to_file("[Link]", "Program started");
log_to_file("[Link]", "Action complete");
}
357
Step 6. Including File and Line Information
You can include source info automatically using __FILE__ and __LINE__:
Example:
Output:
[DEBUG] main.c:42 x = 10
For long-running programs, you don’t want logs to grow forever. You can:
Example:
char filename[64];
time_t now = time(NULL);
strftime(filename, sizeof(filename), "log_%Y-%m-%[Link]", localtime(&now));
log_to_file(filename, "Daily entry");
358
Example:
#include <assert.h>
#include <stdio.h>
#include <time.h>
int main(void) {
LOG("Starting program");
LOG("Loading config");
LOG("Finished setup");
}
Output:
359
[23:42:00] Starting program
[23:42:01] Loading config
[23:42:02] Finished setup
Why It Matters
In real systems, servers, compilers, databases, logs are your lifeline when things go wrong.
Try It Yourself
Next, you’ll learn about profiling with gprof, how to measure where your program spends
its time, and how to make it faster.
When your program works but feels slow, guessing isn’t enough, you need to measure. Profiling
shows you where your program spends its time, which functions are hot, and where optimization
truly matters.
C gives you a lot of control, but performance tuning without profiling is like driving blindfolded.
That’s why we use gprof, the GNU profiler, a tool that measures how long your code spends
in each function.
360
Step 1. What Is Profiling?
It helps you find bottlenecks, functions that dominate runtime, and focus your optimization
there.
Compile your program with the -pg flag to enable profiling hooks:
./program
After it finishes, a file named [Link] is created. This file contains execution data collected
during runtime.
Generate a report:
Now open [Link] to see where your program spent its time.
#include <stdio.h>
void slow_function(void) {
for (volatile long i = 0; i < 50000000; i++);
}
void fast_function(void) {
361
for (volatile long i = 0; i < 5000000; i++);
}
int main(void) {
slow_function();
fast_function();
slow_function();
return 0;
}
Flat profile:
2. Call Graph
This shows relationships, which functions called which, and how time was distributed among
them.
362
Step 5. Profiling Multi-File Programs
Profiling works best for real workloads. Avoid profiling tiny runs, because initialization costs
can dominate and distort results.
For example, a 10 ms startup delay might look huge if your program only runs 20 ms in total.
Use representative input and real loops to get meaningful data.
When you know which functions dominate runtime (often the top 5%), you can:
Optimization is about precision, don’t guess where your code is slow; let the profiler prove it.
Then run both and inspect the reports. You’ll see dramatic changes in timing distribution,
sometimes even inlined functions disappear entirely from the profile.
363
Step 9. Visualizing Profiles
This generates a call graph image, showing which functions dominate. The thicker the arrow,
the more time is spent there.
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N 100000
int main(void) {
int *arr = malloc(N * sizeof(int));
fill_random(arr, N);
bubble_sort(arr, N);
free(arr);
}
364
gcc -pg main.c -O0 -o sort_profile
./sort_profile
gprof sort_profile [Link] | head -n 20
Output (excerpt):
This tells you 95% of time is spent in bubble_sort(), confirming the algorithmic bottleneck.
Why It Matters
Profiling bridges the gap between “feels slow” and knowing why. It helps you:
Try It Yourself
Next, you’ll explore common undefined behaviors in C, the silent bugs that can make your
perfectly profiled program crash unpredictably.
C gives you freedom, but also responsibility. Unlike higher-level languages, C doesn’t protect
you from dangerous mistakes. Some actions cause undefined behavior (UB): the compiler is
allowed to do anything in response, crash, hang, or even appear to work fine until it doesn’t.
Undefined behavior is what makes C both powerful and perilous. Let’s explore what causes it,
how to recognize it, and how to write code that never falls into its traps.
365
Step 1. What Is Undefined Behavior?
In the C standard, undefined behavior means “no rules apply.” If your program does
something the language doesn’t define, the compiler can assume it never happens and optimize
freely.
This means your program might:
• Crash immediately.
• Produce wrong results.
• Behave differently each time.
• Work fine on one compiler and fail on another.
Example:
Here are the most frequent offenders every C programmer must know:
Category Example
Out-of-bounds access arr[10] when the array has 10 elements
Use of uninitialized variable int x; printf("%d", x);
Dangling pointer access Use memory after free()
Invalid pointer arithmetic (p + 5) when p doesn’t point into an array
Signed integer overflow int x = INT_MAX + 1;
Modifying and reading same variable i = i++; or a[i] = i++;
Null pointer dereference int *p = NULL; *p = 5;
Incorrect type punning Accessing a float as int through wrong pointer
type
Mismatched malloc/free free() memory not allocated by malloc()
Violating const or volatile contracts Writing to a const variable
366
C doesn’t check bounds, you’re responsible for it. You might print garbage, crash, or accidentally
overwrite another variable.
Always check:
int x;
printf("%d\n", x); // UB: x is uninitialized
Even if it prints 0, that’s luck, not correctness. Always initialize your variables explicitly:
int x = 0;
int *p = malloc(sizeof(int));
*p = 10;
free(p);
printf("%d\n", *p); // UB: accessing freed memory
After free(), the pointer still exists but the memory doesn’t belong to you. Set it to NULL:
free(p);
p = NULL;
int x = 2147483647;
x = x + 1; // UB
Unsigned version:
367
unsigned int x = 4294967295;
x = x + 1; // wraps to 0 (defined)
if (a > INT_MAX - b) {
printf("overflow\n");
} else {
x = a + b;
}
int i = 0;
i = i++ + 1; // UB: reading and writing i without sequence point
i++;
i = i + 1;
int *p = NULL;
*p = 10; // UB
if (p != NULL) *p = 10;
float f = 3.14;
int *ip = (int *)&f; // UB: violates strict aliasing
printf("%d\n", *ip);
368
If you must reinterpret bytes, use memcpy:
int i;
memcpy(&i, &f, sizeof(i));
Sample program:
#include <stdio.h>
int main(void) {
int x = 2147483647;
x++;
printf("%d\n", x);
}
Output:
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
This is the Undefined Behavior Sanitizer (UBSan) in action, your best friend for finding
invisible bugs.
Why It Matters
369
Try It Yourself
Next, you’ll learn how to perform crash analysis and read core dumps, so even when your
program fails, you can find out exactly why.
Even with careful coding and testing, programs crash. In C, a crash is your system’s way
of saying “you touched something you shouldn’t have.” The good news is you can analyze
crashes scientifically using core dumps, snapshots of your program’s memory at the moment
of failure.
Learning how to read them is an essential skill for every systems programmer.
A core dump is a file that captures your program’s state (stack, registers, memory) at the
time it crashed. You can inspect it later using a debugger like gdb to see what went wrong.
Common crash signals that generate core dumps:
ulimit -c unlimited
Check:
Now when your program crashes, a file named core or core.<pid> will appear.
370
Step 3. A Crashing Example
#include <stdio.h>
int main(void) {
int *p = NULL;
*p = 42; // crash: dereferencing null pointer
return 0;
}
Output:
You’ll see:
This tells you the exact line where the program crashed.
371
Step 5. Inspecting Variables and Stack
Within gdb:
(gdb) bt
#0 main () at crash.c:5
Output:
$1 = (int *) 0x0
#include <stdio.h>
void f1(void) {
int *x = NULL;
f2(x);
}
int main(void) {
f1();
}
372
Run it, crash it, then:
Inside gdb:
(gdb) bt
#0 f3 (p=0x0) at crash.c:4
#1 f2 (p=0x0) at crash.c:8
#2 f1 () at crash.c:12
#3 main () at crash.c:16
You can see the entire call chain that led to the null dereference.
When you compile with -O2 or -O3, the compiler may inline or reorder code, making debugging
harder. For debugging, always use:
The -g flag keeps symbol information (file names, line numbers). Without it, gdb can’t tell
you much beyond addresses.
This example saves them in /tmp with program name and process ID:
/tmp/[Link].1234
373
Step 9. Crash Analysis Workflow
This gives you a full picture of what happened just before failure.
#include <assert.h>
#include <stdio.h>
int main(void) {
int x = 5;
assert(x == 10); // fails
printf("Done\n");
}
Output:
Now inspect:
374
gdb ./assert_fail core
(gdb) bt
Output:
You can trace exactly how the assertion caused the abort signal.
Why It Matters
Crash analysis turns chaos into clarity. Instead of guessing, you can:
Every serious C programmer must master this, it’s how systems engineers debug everything
from user tools to kernels.
Try It Yourself
Next, you’ll build a code review checklist for C projects, habits and principles that help
prevent these crashes before they ever happen.
375
79. Code Review Checklist for C Projects
Before your C program ships to production (or even your homework submission), it should
survive one last test, a code review. This is where you or your teammates look at the code
not just for correctness, but for clarity, safety, and maintainability.
Think of this as your personal pilot checklist before takeoff. Every great C programmer has
one.
// Bad
void d(int a, int b) { printf("%d\n", a+b); }
// Good
void print_sum(int a, int b) {
printf("%d\n", a + b);
}
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
// declarations
#endif
376
Step 3. Memory Safety
p = malloc(size);
if (!p) { perror("malloc"); exit(1); }
free(p);
p = NULL;
int* bad(void) {
int x = 10;
return &x; // wrong: stack memory
}
377
Step 6. Undefined Behavior Prevention
• No uninitialized variables.
• No out-of-bounds array access.
• No signed integer overflow.
• No use-after-free.
• Compile with:
Step 7. Portability
• Every function that can fail must have at least one test.
• Edge cases: empty input, zero values, large input.
• Tests should run automatically:
make test
Step 9. Documentation
/* math_utils.c
* Simple math helpers.
* Author: Your Name
* License: MIT
*/
378
• Comment tricky code, but not the obvious.
• Maintain a [Link] explaining build and run steps.
• Version your code (Git). Write meaningful commit messages.
Review notes:
Fixed:
#include <stdlib.h>
#include <stdio.h>
int *make_array(int n) {
if (n <= 0) return NULL;
int *arr = malloc(n * sizeof(int));
if (!arr) { perror("malloc"); return NULL; }
for (int i = 0; i < n; i++) arr[i] = i;
return arr;
}
Passes checklist �
379
Why It Matters
Try It Yourself
1. Take one of your old C programs and review it using this checklist.
2. Fix memory leaks, add error checks, clean up naming.
3. Compile with all warnings on.
4. Run it through AddressSanitizer or Valgrind.
5. Document everything, then repeat for your next project.
Next, you’ll wrap up this debugging chapter with a hands-on practice session: fixing real
memory and logic bugs step by step.
Now it’s time to apply everything you’ve learned, debugging, testing, assertions, logging, and
analysis, to real code that’s broken. This section walks you through a handful of small, common
C bugs that new programmers (and even experienced ones) run into, showing how to find,
understand, and fix them.
Buggy Code:
#include <stdio.h>
int main(void) {
int *p;
*p = 10; // writing to uninitialized pointer
printf("%d\n", *p);
}
Symptom:
380
Segmentation fault (core dumped)
Diagnosis:
Fix:
#include <stdio.h>
int main(void) {
int x = 10;
int *p = &x;
printf("%d\n", *p);
}
Lesson: Always initialize pointers before use. If dynamic, allocate with malloc() and check
for NULL.
Buggy Code:
#include <stdlib.h>
void leak(void) {
int *arr = malloc(10 * sizeof(int));
for (int i = 0; i < 10; i++) arr[i] = i;
// forgot to free
}
int main(void) {
for (int i = 0; i < 10000; i++) leak();
}
Diagnosis: Each call to leak() allocates memory and never frees it. Use Valgrind to confirm:
valgrind ./[Link]
Fix:
381
void leak(void) {
int *arr = malloc(10 * sizeof(int));
if (!arr) return;
for (int i = 0; i < 10; i++) arr[i] = i;
free(arr);
}
Buggy Code:
#include <stdio.h>
int main(void) {
int nums[5] = {0, 1, 2, 3, 4};
for (int i = 0; i <= 5; i++) // � should be < 5
printf("%d ", nums[i]);
}
Lesson: Off-by-one errors are the most common bug in loops. Always check your boundary
conditions carefully.
Buggy Code:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *x = malloc(sizeof(int));
*x = 5;
382
free(x);
printf("%d\n", *x); // � accessing freed memory
}
Fix:
free(x);
x = NULL;
Now:
Lesson: Once you free memory, it’s no longer yours, never touch it again.
Buggy Code:
int *make_ptr(void) {
int x = 10;
return &x; // � pointer to local variable
}
int main(void) {
int *p = make_ptr();
printf("%d\n", *p); // UB
}
Fix:
int *make_ptr(void) {
int *x = malloc(sizeof(int));
*x = 10;
return x;
}
383
Step 6. Bug #6, Missing Return Statement
Buggy Code:
int main(void) {
printf("%d\n", add(2, 3));
}
Fix:
return c;
Lesson: If the function’s return type is non-void, always return a value. Compile with -Wall
-Wextra to catch this automatically.
Buggy Code:
int sum(void) {
int s;
for (int i = 0; i < 3; i++) s += i; // s not initialized
return s;
}
Fix:
int s = 0;
Buggy Code:
384
#include <stdio.h>
int main(void) {
int a = -1;
unsigned int b = 1;
if (a < b) printf("less\n"); else printf("greater\n");
}
Output:
greater
Buggy Code:
#include <stdio.h>
#include <string.h>
int main(void) {
char name[8];
strcpy(name, "Superlongname"); // � too big
printf("%s\n", name);
}
Fix:
Buggy Code:
385
#include <stdio.h>
int main(void) {
float a = 0.1f * 3;
if (a == 0.3f) printf("Equal\n");
else printf("Not equal\n");
}
Output:
Not equal
Fix:
Why It Matters
Debugging teaches you how programs fail. Each bug fixed makes you a more confident systems
engineer. C doesn’t forgive mistakes, but it rewards precision.
386
Try It Yourself
Next, we’ll begin Chapter 9: Portable and Modern C, where you’ll learn how to write C
that runs everywhere, from embedded chips to modern servers.
387
Chapter 9. Portable and Modern C
C has been around for more than fifty years, and it has evolved slowly and carefully. Every
version of the C standard improves the language while keeping backward compatibility with
decades of existing code.
Understanding the timeline of C standards helps you write portable, modern code and know
which features are safe to use in your target environments.
C was born at Bell Labs in the early 1970s, developed by Dennis Ritchie as a systems
programming language for Unix. The first book, The C Programming Language by Kernighan
and Ritchie (1978), informally defined “K&R C.”
Key Traits:
• No standardization yet.
• Implicit function declarations.
• No void type for functions without return.
• No function prototypes (parameters not type-checked).
• Header files were optional.
Example:
main() {
printf("Hello, world\n");
}
388
Step 2. ANSI C (C89 / C90)
In 1989, C became standardized by ANSI (and in 1990 by ISO). This version, C89/C90,
unified compiler behavior and made C portable across systems.
Key Features:
Tiny Code:
#include <stdio.h>
int main(void) {
printf("%d\n", add(2, 3));
}
A minor update that refined C90, rarely mentioned but still significant.
Added:
389
Step 4. C99, Modernization Begins
C99 (published in 1999) was the biggest update since the beginning.
Major Improvements:
• // single-line comments
• Variable declarations anywhere
• Inline functions
• long long (64-bit integer)
• stdbool.h for bool, true, false
• stdint.h for fixed-width integers (int32_t, uint64_t)
• Designated initializers and compound literals
• Flexible array members
• snprintf safer string formatting
• Variable-length arrays (VLAs)
Tiny Code:
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
int main(void) {
bool done = false;
uint64_t sum = 0;
for (int i = 0; i < 5; i++)
sum += i;
printf("%llu\n", (unsigned long long)sum);
return done;
}
C99 made C feel modern, introducing safer and more expressive syntax.
390
• _Static_assert for compile-time checks
• Bounds-checked functions (strcpy_s, memcpy_s)
• Optional Annex K for safer standard library functions
• Improved Unicode and wide character support
Tiny Code:
#include <threads.h>
#include <stdio.h>
int main(void) {
int id = 1;
thrd_t t;
thrd_create(&t, run, &id);
thrd_join(t, NULL);
}
C11 made C safer and concurrency-aware, though not all compilers implemented <threads.h>
fully.
Officially ISO/IEC 9899:2018 (published in 2018), C17 fixed inconsistencies and bugs in C11
but didn’t add new features.
Highlights:
391
Step 7. C23, The Latest Standard
C23 is the most recent (published in 2024), continuing modernization without breaking backward
compatibility.
Major Features:
Tiny Code:
#include <stdio.h>
int main(void) {
int x = 10;
[[maybe_unused]] int y = 20;
static_assert(sizeof(int) == 4, "Expected 4-byte int");
printf("%d\n", x);
}
C23 brings C closer to modern C++ and Rust-style safety while staying simple and lightweight.
Common outputs:
199901L → C99
201112L → C11
201710L → C17
202311L → C23
392
Or compile with:
#include <stdio.h>
int main(void) {
#if __STDC_VERSION__ >= 202311L
printf("C23 or newer\n");
#elif __STDC_VERSION__ >= 201710L
printf("C17\n");
#elif __STDC_VERSION__ >= 201112L
printf("C11\n");
#elif __STDC_VERSION__ >= 199901L
printf("C99\n");
#else
printf("C90 or earlier\n");
#endif
}
Why It Matters
C’s evolution shows its unique philosophy: change slowly, but never break old code.
Knowing which standard you target means you can use modern features confidently, without
losing portability.
393
Try It Yourself
1. Write the version detector program above and run it with -std=c99, -std=c11, and
-std=c23.
2. Experiment with _Static_assert and _Thread_local, see which standards support
them.
3. Try compiling a small thread example using <threads.h>.
4. Look up your compiler’s documentation to see which features of C23 are implemented.
5. Pick one feature (like [[nodiscard]]) and use it in a tiny project.
Next, you’ll explore portability and endianness, the invisible details that determine how
your C programs behave across different machines and architectures.
Portability means your C program behaves the same way everywhere, on Linux, Windows,
ARM, x86, or even a tiny microcontroller. Writing portable code is one of the hardest and
most important skills in systems programming.
This section helps you understand the biggest low-level trap of all: endianness, and how to
write code that runs safely across architectures.
Portability depends on respecting what the C standard guarantees, and avoiding assumptions
that might be true only on your machine.
You might write a C program on macOS (little-endian x86_64) and later need to run it on:
If your program reads or writes binary data, it must handle endianness, or the same file may
be misread on another architecture.
394
Step 3. Understanding Endianness
Intel and ARM (in most modes) are little-endian. Many older CPUs (PowerPC, SPARC) are
big-endian.
C does not define the byte order, it depends on the platform.
#include <stdio.h>
int main(void) {
unsigned int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
if (*p == 0x78)
printf("Little-endian\n");
else
printf("Big-endian\n");
}
Explanation: The pointer p reads the lowest memory byte. If it contains the least significant
byte (0x78), it’s little-endian.
int main(void) {
395
uint32_t x = 0x12345678;
uint32_t y = htonl(x); // Host to Network Long (big-endian)
printf("0x%x -> 0x%x\n", x, y);
}
Functions:
Now, any machine can read your file by reversing the conversion (ntohl).
396
Type Typical 32-bit Typical 64-bit
int 4 bytes 4 bytes
long 4 bytes 8 bytes
long long 8 bytes 8 bytes
void* 4 bytes 8 bytes
The compiler may insert padding between structure fields for speed or alignment.
Example:
struct Example {
char a;
int b;
};
On most systems:
Be careful with:
397
#ifdef _WIN32
#include <winsock2.h>
#else
#include <arpa/inet.h>
#endif
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h>
int main(void) {
FILE *f = fopen("[Link]", "wb");
uint32_t n = 0x12345678;
uint32_t net = htonl(n);
fwrite(&net, sizeof(net), 1, f);
fclose(f);
f = fopen("[Link]", "rb");
uint32_t read_net;
fread(&read_net, sizeof(read_net), 1, f);
fclose(f);
printf("Read back: 0x%x\n", ntohl(read_net));
}
This program writes and reads a 32-bit integer in portable big-endian form, the same bytes
on any machine.
Why It Matters
Portability ensures your software lives longer than your hardware. A portable program:
398
Try It Yourself
Next, you’ll explore inline assembly and hardware access, the bridge between pure C and
the underlying CPU instructions.
C gives you precise control over memory and performance, but sometimes you need to go
one level deeper, directly to the CPU. That’s where inline assembly comes in: embedding
assembly language inside your C code to optimize performance or access hardware-level
features.
This chapter will show how to mix C and assembly safely, portably, and meaningfully.
Inline assembly lets you insert small snippets of machine instructions into your C program.
You can use it to:
However, it’s also non-portable and compiler-specific, so use it sparingly and isolate it behind
clean C interfaces.
1. GCC / Clang syntax (AT&T or Intel style) Uses the asm or __asm__ keyword.
2. MSVC syntax Uses __asm { ... } inside functions.
We’ll focus on GCC/Clang syntax, since it’s used in most systems programming contexts.
399
Step 3. Basic Inline Assembly Example
#include <stdio.h>
int main(void) {
unsigned int eax, ebx, ecx, edx;
eax = 0;
__asm__ __volatile__(
"cpuid"
: "=a"(eax), "=b"(ebx), "=c"(ecx), "=d"(edx)
: "a"(0)
);
printf("CPU Vendor: %.4s%.4s%.4s\n",
(char*)&ebx, (char*)&edx, (char*)&ecx);
}
Explanation:
General form:
Example:
Explanation:
400
• "addl %%ebx, %%eax", assembly instruction
• "=a"(result), output in eax goes to result
• "a"(x), "b"(y), inputs: put x in eax, y in ebx
#include <stdio.h>
int main(void) {
unsigned long long start = rdtsc();
for (volatile int i = 0; i < 1000000; i++);
unsigned long long end = rdtsc();
printf("Cycles: %llu\n", end - start);
}
Explanation:
If you’re writing embedded code or OS kernels, you often interact with hardware registers
directly.
Example (x86, privileged mode only):
This writes a byte to an I/O port, used for devices like serial ports, timers, or PIC controllers.
In user-space, you generally can’t do this (needs kernel privileges).
401
Step 7. Memory Barriers and CPU Fences
When working with concurrency or hardware, you may need to control instruction ordering.
This tells the CPU and compiler not to reorder memory operations, essential for writing
thread-safe or device-control code at the hardware level.
Example:
You can write small routines in separate .S files (pure assembly) and call them from C:
# file: add.S
.global add_two
add_two:
addl %esi, %edi
movl %edi, %eax
ret
Then in C:
402
int add_two(int a, int b);
int main(void) {
printf("%d\n", add_two(5, 7));
}
#include <stdio.h>
int main(void) {
printf("%d\n", add_fast(3, 5));
}
The "0"(a) constraint tells the compiler to use the same register for input and output.
Why It Matters
Inline assembly teaches you what really happens beneath your C code. Even if you rarely use
it, understanding it helps you:
Try It Yourself
403
4. Inspect compiler-generated assembly using gcc -S.
5. Try to reimplement a basic math operation in assembly and compare performance.
Next, you’ll learn cross-compilation, how to build your C programs for other architectures
and systems, from your own machine.
84. Cross-Compilation
A cross-compiler is a compiler that produces executables for a target platform different from
the host platform.
Term Meaning
Host The system where you build the code
Target The system where the program will run
Build The system where the compiler itself was built (often same as host)
Example: You’re on macOS (x86_64) and want to compile for a Raspberry Pi (ARM). Your
toolchain must translate x86 instructions into ARM ones.
404
Step 3. Installing a Cross-Compiler
arm-linux-gnueabihf-gcc -v
Output example:
Target: arm-linux-gnueabihf
<architecture>-<vendor>-<OS>-<ABI>
For instance:
• x86_64-pc-linux-gnu
• arm-none-eabi (bare-metal, no OS)
• aarch64-linux-gnu
Tiny Code:
405
#include <stdio.h>
int main(void) {
printf("Hello from cross-compiled C!\n");
}
./hello_arm
When cross-compiling, your target system might not have the same libraries. You can link
everything into one binary:
Static linking ensures the binary runs even if the target lacks shared libraries, useful for
minimal or embedded systems.
--sysroot points to a directory that mimics the target’s filesystem, containing its headers and
libraries.
406
Step 8. Building for Windows or macOS from Linux
Linux → macOS: More complex, usually requires Clang with Apple SDKs or osxcross.
SET(CMAKE_SYSTEM_NAME Linux)
SET(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc)
Then:
cmake -DCMAKE_TOOLCHAIN_FILE=[Link] ..
make
#include <stdio.h>
int main(void) {
#if defined(__x86_64__)
printf("x86_64\n");
#elif defined(__aarch64__)
printf("ARM64\n");
#elif defined(__arm__)
printf("ARM 32-bit\n");
#elif defined(__riscv)
407
printf("RISC-V\n");
#else
printf("Unknown architecture\n");
#endif
}
Why It Matters
Cross-compilation connects your laptop to every other device you’ll ever program. It’s how
kernel modules, embedded systems, and even Android apps are built. Once you learn it, you
can build anywhere, for anything.
Try It Yourself
Next, you’ll explore threading with pthreads, how to run multiple parts of your program at
the same time using standard C threads.
Modern computers run many things at once. Your web browser, text editor, and compiler
all share CPU time through threads. In C, the most widely used threading API is POSIX
threads, or pthreads. It’s low-level, portable, and gives you fine-grained control over parallel
execution.
This section will teach you how to create, manage, and synchronize threads safely.
A thread is a lightweight execution unit that shares the same memory space as other threads
in a process.
408
Process Thread
Has its own memory (stack, heap, code) Shares memory with other threads
Created by OS Created by process
Expensive to start Cheap and fast to start
Communicates via IPC Communicates via shared memory
Threads are ideal for tasks like handling multiple network requests, performing parallel compu-
tation, or keeping a UI responsive.
#include <pthread.h>
Each thread runs a separate function. The function must take and return void *.
Tiny Code: Basic Thread Creation
#include <pthread.h>
#include <stdio.h>
int main(void) {
pthread_t thread;
int value = 42;
409
printf("Main thread finished.\n");
return 0;
}
Output:
Explanation:
#include <pthread.h>
#include <stdio.h>
int main(void) {
pthread_t threads[3];
int ids[] = {1, 2, 3};
410
Step 5. Race Conditions
When two threads modify the same variable at the same time, bad things happen. This is
called a race condition.
Example (unsafe):
#include <pthread.h>
#include <stdio.h>
int counter = 0;
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Counter = %d\n", counter);
}
A mutex ensures that only one thread modifies shared data at a time.
#include <pthread.h>
#include <stdio.h>
int counter = 0;
pthread_mutex_t lock;
411
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_mutex_init(&lock, NULL);
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&lock);
printf("Counter = %d\n", counter);
}
Condition variables let threads wait for a signal. They’re used to coordinate producer–consumer
models.
#include <pthread.h>
#include <stdio.h>
pthread_mutex_t lock;
pthread_cond_t cond;
int ready = 0;
int main(void) {
412
pthread_t t;
pthread_mutex_init(&lock, NULL);
pthread_cond_init(&cond, NULL);
sleep(1);
pthread_mutex_lock(&lock);
ready = 1;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&lock);
pthread_join(t, NULL);
}
• Stack size
• Detach state (joinable or detached)
• Scheduling policy
Example:
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&thread, &attr, task, NULL);
pthread_attr_destroy(&attr);
413
Step 10. Tiny Code: Parallel Sum
#include <pthread.h>
#include <stdio.h>
#define N 4
int partial[4];
int main(void) {
pthread_t threads[N];
int ids[N];
for (int i = 0; i < N; i++) {
ids[i] = i;
pthread_create(&threads[i], NULL, compute, &ids[i]);
}
int total = 0;
for (int i = 0; i < N; i++) {
pthread_join(threads[i], NULL);
total += partial[i];
}
This program splits a task across multiple threads and combines results.
414
Why It Matters
Threads make your programs faster, more responsive, and scalable. They allow C to fully
exploit modern multi-core CPUs, from servers to embedded systems. Learning pthreads means
learning how real systems multitask efficiently and safely.
Try It Yourself
Next, you’ll explore atomic operations and memory models, how modern CPUs ensure
consistency when multiple threads share data without locks.
When multiple threads share data, you usually protect that data with locks like
pthread_mutex_t. But sometimes, you need something faster, a way to perform an update
that can’t be interrupted, even across threads. That’s where atomic operations come in.
This section introduces atomic operations in C and how the memory model ensures your
program behaves predictably across cores.
An atomic operation is one that happens all at once, it can’t be divided or interrupted.
Example idea: If two threads both run counter++ at the same time:
Atomic operations are essential in lock-free algorithms, concurrent queues, and reference
counters.
415
Step 2. The Problem with counter++
counter++;
Thread A: load(5)
Thread B: load(5)
Thread A: store(6)
Thread B: store(6)
#include <stdatomic.h>
#include <stdio.h>
int main(void) {
atomic_int counter = 0;
atomic_fetch_add(&counter, 1);
atomic_fetch_add(&counter, 1);
printf("%d\n", counter); // 2
}
No locks. No race conditions. The atomic_* functions guarantee the operations are atomic at
the hardware level.
416
Function Description
atomic_load Read atomically
atomic_store Write atomically
atomic_fetch_add Add and return old value
atomic_fetch_sub Subtract and return old value
atomic_exchange Replace and return old value
atomic_compare_exchange_strong Compare-and-swap
Example:
If counter == expected, replace it with desired. Otherwise, update expected with the
current value.
#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>
atomic_int counter = 0;
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, work, NULL);
pthread_create(&t2, NULL, work, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Counter = %d\n", counter);
}
417
Step 6. Relaxed vs Sequential Consistency
Atomic operations can have different memory orders. By default, they’re sequentially
consistent, the strongest and safest ordering.
Example:
atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
This is faster but weaker, use only when you understand your memory model.
Modern CPUs reorder reads/writes for performance. Atomics, fences, and locks control when
updates become visible to other threads.
Example: Thread A writes ready = 1. Thread B waits until it sees ready == 1. If the
compiler reorders memory operations, Thread B might not see the change.
Use:
atomic_thread_fence(memory_order_seq_cst);
int expected = 0;
int desired = 1;
if (atomic_compare_exchange_strong(&counter, &expected, desired)) {
printf("Swapped!\n");
}
418
It atomically checks if counter == expected and updates it, all in one instruction. This is
used to build things like spinlocks, queues, and reference counters.
#include <stdatomic.h>
#include <unistd.h>
void lock_spin(void) {
while (atomic_flag_test_and_set(&lock))
; // busy wait
}
void unlock_spin(void) {
atomic_flag_clear(&lock);
}
This is efficient when the lock is held for a very short time. For longer waits, use
pthread_mutex_t instead.
#include <stdatomic.h>
#include <stdio.h>
typedef struct {
atomic_int refcount;
} Object;
419
}
int main(void) {
Object obj = { .refcount = 1 };
retain(&obj);
release(&obj);
release(&obj);
}
Output:
Object freed
This is how many real-world systems (e.g. file handles, shared memory) track usage.
Why It Matters
Atomic operations are the building blocks of lock-free programming. They allow you to write
high-performance concurrent code without blocking other threads. The C memory model gives
you guarantees to reason about correctness even across multiple CPU cores.
Try It Yourself
Next, you’ll explore using C with other languages (FFI), how to make C libraries callable
from Python, Rust, and Go.
C is often called the universal assembly language, nearly every modern language can call into
it. This is made possible through the Foreign Function Interface (FFI), which defines how
different languages talk to C code.
In this section, you’ll learn how to expose your C functions to Python, Rust, and Go, and how
to call functions from those languages inside C.
420
Step 1. What Is an FFI?
An FFI (Foreign Function Interface) is a bridge that lets programs written in one language
use code written in another.
Why FFI matters:
The ABI (Application Binary Interface) defines how function calls, parameters, and data
structures are represented in memory. The FFI works because C has a stable and simple ABI.
Rules include:
That’s why almost every language provides a way to “speak” the C ABI.
You can make your C functions callable by other languages by marking them with extern "C"
(if compiling as C++) or just regular C functions otherwise.
Tiny Code: Shared C Library
// file: mathlib.c
#include <stdio.h>
421
Compile it into a shared library:
This creates a .so (Linux) or .dll (Windows) or .dylib (macOS) file you can load in other
languages.
import ctypes
lib = [Link]("./[Link]")
print([Link](2, 3))
print([Link](4, 5))
Output:
5
20
#[link(name = "mathlib")]
extern "C" {
fn add(a: i32, b: i32) -> i32;
}
fn main() {
unsafe {
println!("{}", add(2, 3));
}
}
422
Compile with:
rustc [Link] -L .
Rust enforces unsafe because it can’t verify what happens inside the C function.
Step 6. Using C in Go
/*
#include "mathlib.c"
*/
import "C"
import "fmt"
func main() {
[Link]([Link](2, 3))
}
go run [Link]
Go will compile your C code behind the scenes and link it automatically.
You can also go the other way, call functions from another language inside C.
Example: C calling Python
#include <Python.h>
int main(void) {
Py_Initialize();
PyRun_SimpleString("print('Hello from Python in C!')");
Py_Finalize();
}
423
Compile with:
This embeds a Python interpreter in your C program, powerful for scripting or AI integration.
• int, double, char *, and flat structs. Avoid C++ classes, pointers to complex structs,
or variable-length arrays, they often don’t translate cleanly.
Example:
typedef struct {
int id;
double score;
} Record;
You can use this struct easily from Python ([Link]) or Rust (#[repr(C)]
struct).
char* greet(void) {
char* s = malloc(32);
sprintf(s, "Hello from C!");
return s;
}
Then the caller (e.g., Python) must call free() via FFI to avoid leaks. Never assume the
garbage collector of another language will clean up C memory.
424
// greet.c
#include <stdio.h>
#include <stdlib.h>
Compile:
Python:
import ctypes
lib = [Link]("./[Link]")
[Link] = ctypes.c_char_p
print([Link](b"World"))
Output:
Hello, World!
Why It Matters
FFI turns C into the foundation of the software world, your C code can power systems written
in any language. This is how databases, OS kernels, and AI frameworks expose APIs across
ecosystems. Understanding FFI means you can build language bridges, not just programs.
Try It Yourself
Next, you’ll explore safer alternatives and modern C features, bounds checking, static
assertions, and ways to make C code more reliable.
425
88. Safer Alternatives (Bounds Checking, _Static_assert, and Modern C Safety
Tools)
C gives you power and control, but also responsibility. Because C does not automatically
protect you from memory errors, buffer overflows, or type misuse, you must add safety at the
language and tool level.
This section explores modern safety features in C11 to C23, including bounds checking,
static assertions, and practical habits for writing safer C.
That trust is both the reason C is used for kernels and the reason it causes so many bugs. The
goal is not to make C “safe by default,” but to make your use of C safe by design.
A classic error in C:
char name[8];
strcpy(name, "HelloWorld"); // buffer overflow
Fix 2: Use safer alternatives introduced in C11 Annex K (if your compiler supports them):
They automatically check bounds and return error codes. However, Annex K is optional, so
not all compilers implement it.
426
Step 3. Tiny Code: Safe String Copy
#include <stdio.h>
#include <string.h>
int main(void) {
char dst[8];
strncpy(dst, "Example", sizeof(dst) - 1);
dst[sizeof(dst) - 1] = '\0';
printf("Safe copy: %s\n", dst);
}
Compile with:
The -Wall -Wextra flags warn about suspicious behavior early, one of your best “safety
tools.”
Introduced in C11, _Static_assert lets you validate conditions before the program even
compiles.
Example:
427
int x = 2147483647 + 1; // overflow
Safer options:
Example:
char *p = malloc(100);
if (!p) {
fprintf(stderr, "Out of memory\n");
exit(1);
}
428
Step 7. Tools for Safety
Tool Purpose
AddressSanitizer (-fsanitize=address) Detects buffer overflows, use-after-free
UndefinedBehaviorSanitizer Detects integer and type errors
(-fsanitize=undefined)
Valgrind Checks for memory leaks and invalid
accesses
clang-tidy Static analysis and style checking
cppcheck Portable static analyzer for C/C++
Example:
Unintended padding can cause issues when serializing or working with hardware. You can
assert layout at compile time:
#include <stddef.h>
#include <stdio.h>
struct Packet {
char type;
int id;
};
429
Step 9. Defensive Macros and Compile Flags
Flag Purpose
-Wall -Wextra Enable important warnings
-Werror Treat warnings as errors
-Wconversion Warn on implicit type conversions
-fsanitize=address Detect memory safety issues
-D_FORTIFY_SOURCE=2 Add runtime buffer checks (glibc)
-fstack-protector-strong Detect stack corruption
-O2 Optimize safely without risky transformations
Example:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct Data {
int id;
char name[16];
};
int main(void) {
struct Data d = {42, "C Safety"};
printf("%d %s\n", [Link], [Link]);
char buf[8];
strncpy(buf, "Safe", sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
430
Compile with:
This program will abort if memory safety is violated, giving you immediate feedback during
testing.
Why It Matters
Safety doesn’t make your code slower, it makes your software trustworthy. Even though C gives
you sharp tools, the combination of static checks, compiler warnings, and runtime sanitizers
can make your programs robust enough for production systems.
Try It Yourself
Next, you’ll explore modern C style, how to write clear, maintainable, and idiomatic code in
the C23 era.
C has been around for over 50 years, and yet it keeps evolving. Modern C (C11–C23) combines
the power of low-level programming with safer syntax, cleaner idioms, and new features that
make code easier to reason about.
This section will help you write modern, readable, and maintainable C, the kind of C
that feels timeless.
431
Compilers can handle complexity, your teammates (and future you) can’t.
Bad:
Good:
Always initialize your variables. Uninitialized memory is one of the biggest sources of bugs.
Bad:
int x;
printf("%d\n", x);
Good:
int x = 0;
printf("%d\n", x);
This helps the compiler optimize, prevents accidental modification, and improves clarity.
432
Step 4. Prefer Modern Standard Headers
Use standard headers like <stdint.h>, <stdbool.h>, and <stddef.h> for clear, portable
code.
Example:
#include <stdint.h>
#include <stdbool.h>
bool is_even(uint32_t n) {
return (n % 2) == 0;
}
Avoid using old-style typedefs like typedef unsigned long ulong; unless it improves mean-
ing.
In old C, people used int for true/false. Modern C gives you _Bool via <stdbool.h>:
#include <stdbool.h>
Good:
433
void read_input(void);
void process_data(void);
void write_output(void);
In early C, macros were overused for constants and functions. Today, prefer inline functions
and const instead.
Bad:
Good:
Since C99, you can declare variables close to where they’re used:
Avoid keeping variables alive longer than necessary, this reduces bugs and clarifies scope.
434
• auto, type inference for local variables
• UTF-8 character support and string literals
• alignof / alignas for precise memory layout
Example:
#include <stdio.h>
#include <stdbool.h>
#include <stdint.h>
int main(void) {
const uint32_t x = 10, y = 20;
uint32_t sum = add(x, y);
return 0;
}
Output:
Sum = 30
This code uses [[nodiscard]], bool, and const, small touches that improve both style and
safety.
435
Why It Matters
Readable C code lasts for decades. The best systems code, in kernels, compilers, and libraries,
looks simple because it follows clear patterns:
Modern C doesn’t mean rewriting everything. It means writing intentional C, clear, correct,
and expressive.
Try It Yourself
Next, you’ll conclude this journey with Practice: Portable Multithreaded Program (90),
a hands-on project that combines everything from memory management to threading and
portability.
It’s time to bring together everything you’ve learned, memory management, threads, synchro-
nization, and portability, into one cohesive program.
In this final section of Chapter 9, you’ll build a portable multithreaded counter that runs
correctly across architectures, compilers, and systems, demonstrating clean, safe, and modern
C in practice.
436
Step 2. Plan the Design
#include <stdio.h>
#include <stdlib.h>
#include <stdatomic.h>
#include <pthread.h>
#include <stdint.h>
#include <time.h>
#ifdef _WIN32
#include <windows.h>
#define SLEEP(ms) Sleep(ms)
#else
#include <unistd.h>
#define SLEEP(ms) usleep((ms) * 1000)
#endif
#define THREADS 4
#define ITERATIONS 250000
atomic_int counter = 0;
double now(void) {
437
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec / 1e9;
}
int main(void) {
pthread_t threads[THREADS];
int ids[THREADS];
double start = now();
• Atomic Counter: atomic_fetch_add ensures that increments are atomic and race-free
without using a mutex.
• Thread Creation: Each thread runs the worker() function independently.
• Synchronization: pthread_join ensures all threads finish before printing results.
• Timing: Uses clock_gettime() for precise cross-platform timing.
• Sleep Macro: SLEEP(ms) abstracts away platform differences between Windows and
POSIX.
438
Step 5. Compile and Run
On Linux or macOS:
On Windows (MinGW):
Expected output:
The program finishes with perfect accuracy, no race conditions, and works across platforms.
� No raw pointers shared unsafely � Atomic operations prevent races � Sleep and timing are
cross-platform � Clean, modern syntax with C23 support � Easy to modify (e.g., change thread
count or workload)
439
Step 8. Why It’s Portable
You’ve completed Chapter 9, Portable and Modern C. Next comes Chapter 10: Building
Real Projects, where you’ll apply these foundations to construct real-world systems, libraries,
servers, and interpreters, all in clean, idiomatic C.
440
Chapter 10. Building Real Projects
Writing libraries is how you make your C code reusable, modular, and easy to maintain. In this
section, you’ll learn how to design and structure a small, portable, and well-documented
C library, the kind used in real systems for decades.
A library in C is a collection of functions and data types that can be used by multiple
programs.
There are two kinds of libraries:
• Static libraries (.a or .lib) – compiled into the final program at build time.
• Shared libraries (.so or .dll) – loaded dynamically at runtime.
You’ll start by building a small static library that provides reusable math utilities.
Structure:
simplemath/
��� include/
� ��� simplemath.h
��� src/
� ��� simplemath.c
��� Makefile
441
Step 3. The Header File (simplemath.h)
#ifndef SIMPLEMATH_H
#define SIMPLEMATH_H
#ifdef __cplusplus
extern "C" {
#endif
#ifdef __cplusplus
}
#endif
#endif
Notes:
#include "simplemath.h"
#include <stdio.h>
442
}
if (error) *error = 0;
return a / b;
}
#include <stdio.h>
#include "simplemath.h"
int main(void) {
int err;
double x = sm_div(10, 2, &err);
printf("10 / 2 = %.2f\n", x);
x = sm_div(10, 0, &err);
if (err) printf("Error detected during division.\n");
return 0;
}
CC = gcc
CFLAGS = -std=c23 -O2 -Wall -Wextra -Iinclude
libsimplemath.a: src/simplemath.o
ar rcs libsimplemath.a src/simplemath.o
clean:
rm -f src/*.o *.a test
443
Build it:
make
Run:
./test
Output:
10 / 2 = 5.00
Division by zero
Error detected during division.
Principle Description
Prefix all symbols Avoid global name clashes (e.g., sm_add)
Single responsibility Each function should do one clear thing
Minimal dependencies Don’t rely on non-standard headers
Use header guards Prevent duplicate inclusion
Provide error handling Return codes, errno, or out parameters
Write documentation Use Doxygen or simple comment blocks
Version your API Track breaking changes cleanly
In your CMake or Makefile build scripts, you can propagate this version into your packaging
system or documentation.
Writing a library transforms you from a script author into a systems builder. It teaches API
design, separation of interface and implementation, and long-term maintenance,
the same principles used in real-world software like glibc, SQLite, and curl.
444
Step 10. Try It Yourself
Next, you’ll learn how to build a full command-line tool in C (92), connecting your
reusable libraries to practical, user-facing applications.
Command-line tools are where most C programmers begin building real software. They are
fast, portable, and integrate naturally with Unix-like environments. In this section, you’ll build
a small, self-contained CLI tool that processes input arguments, reads files, and outputs
results, the same pattern used by tools like grep, cat, and wc.
• Counts lines, words, and characters in a text file (like a mini wc).
• Takes input from a file or standard input.
• Accepts flags like -l, -w, -c.
• Uses clean error handling and modular functions.
linestat/
��� linestat.c
��� Makefile
��� [Link]
445
4. Process data line-by-line.
5. Report results clearly and consistently.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Parse arguments
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-l") == 0) count_lines = 1;
else if (strcmp(argv[i], "-w") == 0) count_words = 1;
else if (strcmp(argv[i], "-c") == 0) count_chars = 1;
else if (argv[i][0] != '-') filename = argv[i];
else {
print_usage(argv[0]);
return 1;
}
}
446
int ch;
fclose(fp);
return 0;
}
Makefile
CC = gcc
CFLAGS = -std=c23 -O2 -Wall -Wextra
linestat: linestat.c
$(CC) $(CFLAGS) linestat.c -o linestat
clean:
rm -f linestat
Build it:
make
Run it:
447
./linestat -l -w -c [Link]
Or from a pipeline:
Example output:
Lines: 12
Words: 85
Chars: 430
12 85 430 [Link]
448
Step 9. Why It Matters
Every developer who writes in C eventually writes a CLI, it’s how tools like Git, Curl, and
GCC were born.
Next, you’ll move to 93. Tiny HTTP Server (Sockets and Threads), where your command-
line skills evolve into network programming: accepting connections, handling requests, and
serving content in pure C.
Now that you know how to build command-line tools, it’s time to make your program talk
to the network. In this section, you’ll build a tiny multithreaded HTTP server, a small,
minimal clone of what powers the web.
You’ll learn sockets, threading, request parsing, and response generation, all from first princi-
ples.
449
This project combines file I/O, networking, and concurrency, three of C’s most powerful
capabilities.
tinyhttp/
��� server.c
��� Makefile
��� [Link]
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
char buffer[BUF_SIZE];
int bytes = read(client_fd, buffer, sizeof(buffer) - 1);
if (bytes <= 0) {
450
close(client_fd);
return NULL;
}
buffer[bytes] = '\0';
int main(void) {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket failed");
exit(EXIT_FAILURE);
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
451
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
while (1) {
int client_fd;
struct sockaddr_in client;
socklen_t len = sizeof(client);
client_fd = accept(server_fd, (struct sockaddr *)&client, &len);
if (client_fd < 0) {
perror("accept failed");
continue;
}
pthread_t tid;
pthread_create(&tid, NULL, handle_client, pclient);
pthread_detach(tid);
}
close(server_fd);
return 0;
}
Makefile
CC = gcc
CFLAGS = -std=c23 -pthread -O2 -Wall -Wextra
all: server
server: server.c
$(CC) $(CFLAGS) server.c -o server
452
clean:
rm -f server
[Link]
1. Socket setup: The server creates a TCP socket (socket()), binds it to port 8080, and
listens.
2. Accept loop: The main thread waits for connections.
3. Threading: Each connection is handled by a new thread (pthread_create), allowing
multiple clients at once.
4. HTTP parsing: Minimal, just reads the request header and ignores the rest for now.
5. Response: A static HTML body is written to the socket.
6. Cleanup: Each thread closes its client socket after responding.
Step 7. Extend It
453
Step 8. Cross-Platform Notes
Building an HTTP server from scratch teaches you how the web really works:
You’re no longer just writing programs, you’re shaping communication between machines.
Next, you’ll build 94. A Simple Key-Value Store, where you’ll learn file-based persistence,
indexing, and serialization, the first step toward writing databases in pure C.
Databases look scary until you build one yourself. In this section you will write a tiny append
only key value store that persists data to disk, loads an in memory index on startup, and
supports get and set from a simple CLI.
You will learn files, serialization, indexing, and crash safety basics.
454
Step 1. Design the file format
We will use htonl and ntohl to encode and decode 32 bit lengths.
On startup, scan the log file once and build a hash map of key -> file offset of the newest
record. We will implement a simple open addressing hash table for clarity.
Index entry:
typedef struct {
uint64_t offset; // file position of record start
uint32_t key_hash; // cached hash for quick probing
uint32_t key_len; // used to confirm match
} kv_slot;
Step 4. Hashing
455
}
return h;
}
// file: kv.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h> // Windows: include <winsock2.h> and link Ws2_32
#include <errno.h>
typedef struct {
FILE *f;
char *path;
// simple hash table index
struct slot { uint64_t off; uint32_t h, klen; } *tab;
size_t cap, used;
} kv_db;
static int kv_index_put(kv_db *db, const unsigned char *key, uint32_t klen, uint64_t off) {
if (db->used * 2 >= db->cap) { // grow
size_t ncap = db->cap ? db->cap * 2 : 1024;
struct slot *old = db->tab;
size_t oldcap = db->cap;
db->tab = calloc(ncap, sizeof(*db->tab));
if (!db->tab) return -1;
db->cap = ncap; db->used = 0;
456
for (size_t i = 0; i < oldcap; i++) if (old[i].off) {
// reinsert based on stored key hash and key length
size_t m = ncap - 1, j = old[i].h & m;
while (db->tab[j].off) j = (j + 1) & m;
db->tab[j] = old[i];
db->used++;
}
free(old);
}
uint32_t h = fnv1a(key, klen);
size_t m = db->cap - 1, i = h & m;
while (db->tab[i].off) {
if (db->tab[i].h == h && db->tab[i].klen == klen) { db->tab[i].off = off; return 0; }
i = (i + 1) & m;
}
db->tab[i].off = off; db->tab[i].h = h; db->tab[i].klen = klen; db->used++;
return 0;
}
static long kv_index_find_slot(kv_db *db, const unsigned char *key, uint32_t klen) {
if (db->cap == 0) return -1;
uint32_t h = fnv1a(key, klen);
size_t m = db->cap - 1, i = h & m, steps = 0;
while (db->tab[i].off && steps <= db->cap) {
if (db->tab[i].h == h && db->tab[i].klen == klen) return (long)i;
i = (i + 1) & m; steps++;
}
return -1;
}
457
uint64_t off = 0;
for (;;) {
uint32_t klen_be, vlen_be;
if (fread(&klen_be, 4, 1, r) != 1) break;
if (fread(&vlen_be, 4, 1, r) != 1) break;
uint32_t klen = ntohl(klen_be), vlen = ntohl(vlen_be);
unsigned char *k = malloc(klen);
if (!k) break;
if (fread(k, 1, klen, r) != klen) { free(k); break; }
if (fseek(r, vlen, SEEK_CUR) != 0) { free(k); break; }
kv_index_put(db, k, klen, off);
free(k);
off += 8u + klen + vlen;
}
fclose(r);
return 0;
}
static int kv_set(kv_db *db, const unsigned char *key, uint32_t klen,
const unsigned char *val, uint32_t vlen) {
uint32_t klen_be = htonl(klen), vlen_be = htonl(vlen);
if (fwrite(&klen_be, 4, 1, db->f) != 1) return -1;
if (fwrite(&vlen_be, 4, 1, db->f) != 1) return -1;
if (fwrite(key, 1, klen, db->f) != klen) return -1;
if (fwrite(val, 1, vlen, db->f) != vlen) return -1;
fflush(db->f); // durability: fsync would be stronger
// compute offset of the record we just wrote
long end = ftell(db->f);
if (end < 0) return -1;
uint64_t off = (uint64_t)end - (8u + klen + vlen);
return kv_index_put(db, key, klen, off);
}
static int kv_get(kv_db *db, const unsigned char *key, uint32_t klen,
unsigned char **out, uint32_t *outlen) {
long s = kv_index_find_slot(db, key, klen);
if (s < 0) return -1;
uint64_t off = db->tab[s].off;
if (fseek(db->f, (long)off, SEEK_SET) != 0) return -1;
uint32_t klen_be, vlen_be;
if (fread(&klen_be, 4, 1, db->f) != 1) return -1;
if (fread(&vlen_be, 4, 1, db->f) != 1) return -1;
458
uint32_t kL = ntohl(klen_be), vL = ntohl(vlen_be);
unsigned char *kbuf = malloc(kL);
if (!kbuf) return -1;
if (fread(kbuf, 1, kL, db->f) != kL) { free(kbuf); return -1; }
// confirm key match to be safe
if (kL != klen || memcmp(kbuf, key, klen) != 0) { free(kbuf); return -1; }
free(kbuf);
unsigned char *v = malloc(vL + 1);
if (!v) return -1;
if (fread(v, 1, vL, db->f) != vL) { free(v); return -1; }
v[vL] = 0; // NUL terminate for convenience
*out = v; *outlen = vL;
return 0;
}
459
if (kv_get(&db, k, (uint32_t)strlen((char*)k), &out, &n) == 0) {
fwrite(out, 1, n, stdout);
fputc('\n', stdout);
free(out);
} else {
fprintf(stderr, "not found\n");
}
} else {
usage(argv[0]);
}
kv_close(&db);
return 0;
}
Build:
Run:
Step 6. Compaction
Because we append forever, the log grows. Implement a simple compact command that
rewrites only the latest version of each key to a new file, then swaps files.
Idea:
1. Iterate index
2. Read the newest record for each key
3. Append it to [Link]
4. Replace the old file
This keeps disk usage under control and speeds up startup scanning.
460
Step 7. Crash safety basics
Add subcommands:
stats can print number of keys, file size, load factor, and index capacity.
Step 9. Testing
• Insert 10k keys, then get a random 100 keys and verify values.
• Overwrite the same key many times and ensure get returns the latest one.
• Kill the program during writes and ensure the log is still readable.
• Run with AddressSanitizer to catch memory bugs:
clang -std=c23 -O1 -g -fsanitize=address,undefined kv.c -o kv_asan
You just built the foundation that many production systems use at larger scale.
461
Try it yourself
1. Add a delete tombstone record type and have get respect it.
2. Store expiration timestamps and implement a purge command.
3. Use memory mapped I O for reads to speed up lookups.
4. Replace the linear probing table with a chained hash or hopscotch hashing.
5. Add a simple checksum per record and verify on read.
Next you will implement 95. Implementing a Custom Allocator where you will learn how
malloc like systems manage the heap, and write a tiny arena allocator you can drop into small
C projects.
Every C program eventually asks the operating system for memory, but malloc and free are
not magic—they are layers above system calls like brk and mmap. In this section, you will build
your own custom memory allocator—a simple arena allocator that grabs a large block of
memory once and doles it out efficiently.
You’ll see how real allocators work inside kernels, games, and embedded systems.
This model is perfect for short-lived data structures, parsing, and high-performance applica-
tions.
Step 2. Design
462
When you allocate, it simply bumps the pointer forward.
Structure:
typedef struct {
unsigned char *base;
size_t capacity;
size_t offset;
} Arena;
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
typedef struct {
unsigned char *base;
size_t capacity;
size_t offset;
} Arena;
463
a->offset = 0;
}
int main(void) {
Arena *arena = arena_create(1024);
if (!arena) {
fprintf(stderr, "Failed to create arena\n");
return 1;
}
printf("Squares: ");
for (int i = 0; i < 10; i++) printf("%d ", arr[i]);
printf("\n");
464
4. arena_free releases the entire block in one call.
Sometimes allocations must be aligned (for example, 16-byte alignment for SIMD). We can
round up the offset to the nearest alignment boundary.
typedef struct {
Arena *parent;
size_t start;
} ArenaScope;
465
ArenaScope arena_push(Arena *a) {
return (ArenaScope){ .parent = a, .start = a->offset };
}
void arena_pop(ArenaScope s) {
[Link]->offset = [Link];
}
This lets you “temporarily allocate” for a function or block and reset automatically.
Allocators define how performance feels in large systems. By writing one, you understand:
Games, web servers, and compilers all use custom allocators to control lifetime and avoid
overhead.
Next you’ll build 96. Writing a Text Parser, using your allocator to manage short-lived
strings and tokens as you build a mini lexer and parser in pure C.
Time to turn raw text into structure. In this section you will write a tiny expression parser
that converts strings like 3 + 4*2 - (1 + 5) into an AST (abstract syntax tree). We will
build a simple tokenizer, a recursive descent parser with precedence, and a pretty printer to
check the result. In the next section you can add an evaluator to run it.
466
Step 1. Goal and scope
Step 3. Tokens
467
Binary nodes use left and right. Integer nodes use value.
Allocating nodes frequently with malloc is noisy. Use a tiny arena so each node is just a bump
allocation and everything frees at once when you are done.
Keep it simple:
// file: expr_parser.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <string.h>
468
if (!a) return NULL;
a->base = malloc(cap);
if (!a->base) { free(a); return NULL; }
a->cap = cap; a->off = 0;
return a;
}
static void *arena_alloc(Arena *a, size_t n, size_t align) {
size_t p = (a->off + (align - 1)) & ~(align - 1);
if (p + n > a->cap) return NULL;
void *ptr = a->base + p; a->off = p + n; return ptr;
}
static void arena_free(Arena *a) { if (!a) return; free(a->base); free(a); }
typedef struct {
TokKind kind;
long ival;
const char *start; // for error messages
const char *end;
} Token;
typedef struct {
const char *src;
const char *cur;
Token look; // one-token lookahead
} Lexer;
static Token make(Lexer *L, TokKind k, const char *s, const char *e, long v) {
Token t = {k, v, s, e};
return t;
}
469
skip_ws(L);
const char *s = L->cur;
if (*L->cur == 0) return make(L, TOK_EOF, s, s, 0);
char c = *L->cur++;
switch (c) {
case '+': return make(L, TOK_PLUS, s, L->cur, 0);
case '-': return make(L, TOK_MINUS, s, L->cur, 0);
case '*': return make(L, TOK_STAR, s, L->cur, 0);
case '/': return make(L, TOK_SLASH, s, L->cur, 0);
case '(': return make(L, TOK_LPAREN, s, L->cur, 0);
case ')': return make(L, TOK_RPAREN, s, L->cur, 0);
default:
if (isdigit((unsigned char)c)) {
long v = c - '0';
const char *p = L->cur;
while (isdigit((unsigned char)*p)) {
v = v * 10 + (*p - '0');
p++;
}
Token t = make(L, TOK_INT, s, p, v);
L->cur = p;
return t;
}
return make(L, TOK_ERR, s, L->cur, 0);
}
}
470
static Node *node_new_int(Arena *A, long v) {
Node *n = arena_alloc(A, sizeof(*n), _Alignof(Node));
if (!n) return NULL;
n->kind = N_INT; n->l = n->r = NULL; n->value = v; return n;
}
static Node *node_new_bin(Arena *A, NodeKind k, Node *l, Node *r) {
Node *n = arena_alloc(A, sizeof(*n), _Alignof(Node));
if (!n) return NULL;
n->kind = k; n->l = l; n->r = r; n->value = 0; return n;
}
471
take(P->L);
Node *r = parse_factor(P);
if (!r) return NULL;
n = node_new_bin(P->A, k == TOK_STAR ? N_MUL : N_DIV, n, r);
if (!n) { P->ok = 0; return NULL; }
}
return n;
}
472
Parser P = { .L = &L, .A = A, .ok = 1 };
Node *root = parse_expr(&P);
arena_free(A);
return [Link] ? 0 : 1;
}
Example output:
Parsing transforms bytes into meaning. With a tokenizer, a clean grammar, and a small AST,
you can build:
• Expression evaluators
473
• Config file readers
• Query languages
• Full interpreters
In the next section you will use this AST to build a tiny interpreter that evaluates expressions
at runtime.
You already built a tokenizer, parser, and AST. Now you will evaluate that AST so 3 + 4*2
- (1 + 5) produces 5 at runtime. We will add a tiny environment for variables, a few built in
functions, and a simple REPL.
Keep everything in long for now. You can switch to double later if you want decimals.
Step 3. Environment
474
typedef struct { const char *name; long value; } Binding;
typedef struct {
Binding *items;
size_t count, cap;
} Env;
This is not the fastest map, but it is simple and good for a tiny interpreter.
475
typedef enum { N_INT, N_ADD, N_SUB, N_MUL, N_DIV, N_IDENT, N_ASSIGN } NodeKind;
Below is a compact interpreter that builds on the earlier parser. For brevity, the lexer and
arena are trimmed to only the new bits you need here.
// file: tiny_interp.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <string.h>
476
TOK_LPAREN, TOK_RPAREN, TOK_IDENT, TOK_EQ, TOK_EOF, TOK_ERR } TokKind;
typedef struct { TokKind kind; long ival; const char *s,*e; char *lexeme; } Token;
477
static Node* new_ident(Arena*A,const char*name){ Node*n=arena_alloc(A,sizeof(*n),_Alignof(Nod
static Node* new_bin(Arena*A,NodeKind k,Node*l,Node*r){ Node*n=arena_alloc(A,sizeof(*n),_Alig
static Node* new_assign(Arena*A,Node*name,Node*expr){ Node*n=arena_alloc(A,sizeof(*n),_Aligno
478
Token save = P->L->look;
Token ident = take(P->L);
if (peek(P->L).kind==TOK_EQ){
take(P->L); // consume '='
Node*rhs = parse_expr(P);
if (!rhs) return NULL;
return new_assign(P->A, new_ident(P->A, [Link]), rhs);
}
// no '=', rewind
P->L->look = save;
}
return parse_expr(P);
}
479
}
case N_ASSIGN: {
long v; if(!eval(n->r,E,&v)) return 0;
if(!n->l || n->l->k != N_IDENT){ fprintf(stderr,"Left side of '=' must be a name\
if(!env_set(E, n->l->name, v)){ fprintf(stderr,"Env set failed\n"); return 0; }
*out = v; return 1;
}
}
return 0;
}
// strip newline
if (m>0 && line[m-1]=='\n') line[m-1]=0;
Arena *A = arena_new(1<<16);
if(!A){ fprintf(stderr,"arena failed\n"); break; }
long result = 0;
if ([Link] && root && eval(root, &env, &result))
printf("%ld\n", result);
else
fprintf(stderr,"Error\n");
arena_free(A);
}
free(line);
// free env bindings
for(size_t i=0;i<[Link];i++) free((void*)[Link][i].name);
480
free([Link]);
return 0;
}
Example session:
> 3 + 4*2 - (1 + 5)
5
> x = 10
10
> x + 7
17
> y = x * 3
30
> y / 5
6
You can recognize identifiers like max or min and parse a function call form name '(' args
')'. Then implement small handlers in the evaluator that pop evaluated arguments and return
a result.
481
Step 10. Why this matters
• Text
• Tokens
• AST
• Evaluation
This is the heart of configuration languages, query languages, calculators, and many scripting
systems. In the next section you will connect this skill to external data by interfacing with
SQLite or LevelDB from C and building a tiny query tool.
Time to connect your C programs to real data. In this section you will talk to two popular
embeddable databases:
You will write tiny programs that insert and query data with both engines.
• Choose SQLite when you want tables, indexes, SQL, and transactions
• Choose LevelDB when you want a simple sorted key value store, no SQL, and you
control schema in your app
# SQLite
sudo apt install libsqlite3-dev # Debian based
# or
brew install sqlite # macOS
# LevelDB
sudo apt install libleveldb-dev # Debian based
482
# or
brew install leveldb # macOS
Windows users can grab prebuilt binaries or build from source and link the .lib files.
// file: sqlite_demo.c
#include <stdio.h>
#include <sqlite3.h>
static int print_row(void *unused, int argc, char **argv, char **col) {
for (int i = 0; i < argc; i++)
printf("%s = %s\n", col[i], argv[i] ? argv[i] : "NULL");
puts("---");
return 0;
}
int main(void) {
sqlite3 *db = NULL;
if (sqlite3_open("[Link]", &db) != SQLITE_OK) {
fprintf(stderr, "open: %s\n", sqlite3_errmsg(db));
return 1;
}
483
fprintf(stderr, "prepare: %s\n", sqlite3_errmsg(db));
return 1;
}
const char *q = "SELECT id, name, age FROM people WHERE age >= ? ORDER BY age DESC;";
if (sqlite3_prepare_v2(db, q, -1, &stmt, NULL) != SQLITE_OK) {
fprintf(stderr, "prepare q: %s\n", sqlite3_errmsg(db));
return 1;
}
sqlite3_bind_int(stmt, 1, 40);
sqlite3_close(db);
return 0;
}
484
gcc -std=c23 -O2 sqlite_demo.c -lsqlite3 -o sqlite_demo
./sqlite_demo
You should see rows printed for people with age 40 or higher.
// file: leveldb_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <leveldb/c.h>
int main(void) {
char *err = NULL;
485
// Get a value
size_t vlen = 0;
char *val = leveldb_get(db, ropt, "name", 4, &vlen, &err);
if (err) { fprintf(stderr, "get: %s\n", err); leveldb_free(err); err = NULL; }
if (val) { printf("name=%.*s\n", (int)vlen, val); leveldb_free(val); }
// Clean up
leveldb_readoptions_destroy(ropt);
leveldb_writeoptions_destroy(wopt);
leveldb_close(db);
leveldb_options_destroy(opts);
return 0;
}
486
– For crash safety use the default rollback journal or WAL mode
Never build SQL by string concatenation with user input. Bindings prevent SQL injection and
handle escaping for you.
You can store serialized structs, protobufs, or JSON. Remember to define your own versioning
for compatibility.
• SQLite
• LevelDB
487
Step 10. Why this matters
Embedding a database takes your C program from toy to tool. You now know how to:
Try it yourself
1. Extend the SQLite demo with a BEGIN and COMMIT around a loop of 10000 inserts and
measure time.
2. Add an index on age and compare query performance.
3. In the LevelDB demo add a write batch that inserts 1000 sequential keys.
4. Store binary blobs in both systems and read them back.
5. Build a tiny CLI that routes sql ... lines to SQLite and kv ... lines to LevelDB.
Next up is 99. Packaging, Versioning, and Documentation where you will learn how to
ship your code like a pro with Makefiles, pkg config, semantic versioning, and clean README
docs.
You’ve written real C programs—now it’s time to package, version, and document them
like a professional. This is what makes your code usable by others and maintainable by your
future self.
You’ll create a structure that helps others build and use your code without guessing.
488
Step 2. Standard project layout
myproject/
��� include/
� ��� myproject.h
��� src/
� ��� main.c
� ��� util.c
��� tests/
� ��� test_basic.c
��� Makefile
��� [Link]
��� LICENSE
// include/myproject.h
#ifndef MYPROJECT_H
#define MYPROJECT_H
#endif
// src/myproject.c
#include "myproject.h"
489
Step 4. Minimal Makefile
CC = gcc
CFLAGS = -std=c23 -O2 -Wall -Iinclude
LDFLAGS =
all: $(LIB)
$(LIB): $(OBJ)
ar rcs $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
install:
mkdir -p /usr/local/include/myproject
cp include/*.h /usr/local/include/myproject/
cp $(LIB) /usr/local/lib/
uninstall:
rm -f /usr/local/lib/$(LIB)
rm -rf /usr/local/include/myproject
clean:
rm -f $(OBJ) $(LIB)
make
sudo make install
490
Step 5. Versioning your releases
[Link]
Examples:
prefix=/usr/local
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include/myproject
Name: myproject
Description: Tiny math helper library
Version: 1.0.0
Libs: -L${libdir} -lmyproject
Cflags: -I${includedir}
491
Step 7. Documentation with Markdown and Doxygen
# myproject
## Build
## Usage
```c
#include <myproject.h>
int main() {
printf("%d\n", add(3, 4));
}
```bash
sudo apt install doxygen
doxygen -g
doxygen Doxyfile
Step 8. Licensing
Add a LICENSE file so others know how they can use your code. Common ones:
492
Example MIT License header for your source files:
/*
* Copyright (c) 2025 Your Name
* Licensed under the MIT License.
*/
# .github/workflows/[Link]
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: make
- run: make test || echo "No tests yet"
You have now moved from C programmer to C maintainer, the person others trust to deliver
solid, reusable, and well-documented software.
Next is 100. Practice: Build Your Own Mini Project, where you will bring everything
together, writing, building, debugging, and packaging a complete small system in pure C.
493
100. Practice: Build Your Own Mini Project
You’ve walked through all the essential layers of C, syntax, memory, data structures, file I/O,
compilation, debugging, and even packaging. Now you’ll bring it all together by building a
complete mini project from scratch.
This final section is a synthesis: plan, design, implement, test, and document a small, useful
system in pure C.
Pick something small enough to finish but rich enough to touch multiple topics. Here are three
good options:
Option A: A Tiny Note Manager
tinynotes/
��� include/
� ��� tinynotes.h
��� src/
� ��� main.c
� ��� notes.c
� ��� util.c
494
��� data/
� ��� [Link]
��� Makefile
��� [Link]
��� LICENSE
// file: tinynotes.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int id;
char text[MAX_NOTE_LEN];
} Note;
Note n = {0};
fseek(f, 0, SEEK_END);
long size = ftell(f);
[Link] = (int)(size / sizeof(Note)) + 1;
strncpy([Link], msg, MAX_NOTE_LEN - 1);
495
Note n;
while (fread(&n, sizeof(n), 1, f) == 1)
printf("%d: %s\n", [Link], [Link]);
fclose(f);
}
Build it:
Try it:
Output:
496
Added note 1: Learn C deeply
Added note 2: Write clear code
1: Learn C deeply
2: Write clear code
Step 4. Extend it
Step 5. Package it
Add a Makefile:
CC=gcc
CFLAGS=-std=c23 -O2 -Wall
TARGET=tinynotes
all:
$(CC) $(CFLAGS) tinynotes.c -o $(TARGET)
install:
cp $(TARGET) /usr/local/bin/
clean:
rm -f $(TARGET)
Install:
497
Step 6. Document it
[Link]
# tinynotes
## Build
make
sudo make install
## Usage
tinynotes add "hello world"
tinynotes list
tinynotes clear
Add LICENSE (MIT, Apache, or GPL). Publish it on GitHub if you want others to use or
contribute.
C gives you the power to build precise, fast, and minimal software. You now know every
layer—from compiler to system call.
498
Step 9. Try it yourself
• Operating Systems
• Compilers and Interpreters
• Embedded Systems
• Databases and Networking
C is not just a language. It is the foundation of computing. You now speak it fluently, like a
systems engineer.
You’ve reached the final page, the quiet epilogue of The Little Book of C.
Let’s close this journey the same way C programs begin: with clarity, purpose, and curiosity.
C is not just about syntax, pointers, or the compiler. It is a mindset, one that teaches you to
think about how machines actually work.
When you write in C, you’re speaking the native tongue of computers. You tell the processor
what to do, byte by byte, without any illusion between you and the hardware.
You’ve learned that:
499
C rewards those who think deeply and punishes those who guess. But when you master it, you
gain a kind of freedom that few languages can match.
Now that you can code confidently in C, here are natural next steps:
1. Systems Programming Explore Linux internals, system calls, and kernel modules. Books
like The Linux Programming Interface or your future “Little Book of System Programming
with C” are perfect companions.
2. Compilers and Language Tools Write your own parser or bytecode interpreter. C gives
you the precision to build new languages from scratch.
3. Operating Systems and Embedded Try building a tiny OS (like xv6), or program
microcontrollers with bare-metal C. You’ll see how C shapes the firmware world.
4. Libraries and Open Source Contribute to open-source projects written in C, from SQLite
to Redis to Git. You’ll read world-class C and learn design by example.
5. Build Your Own X in C You can build your own database, HTTP server, shell, or
compiler. Each one is a new chance to reapply what you’ve learned here.
When Dennis Ritchie designed C in the 1970s, he wasn’t just inventing a language. He was
inventing a way to think, about data, control, and abstraction at the same time.
Fifty years later, the same clarity still matters. C is timeless because it stays close to truth.
Every byte you allocate, every loop you write, every segmentation fault you fix, it all teaches
you how computers really work.
So keep experimenting. Break things. Fix them. Rebuild.
That’s how every systems engineer begins.
500
Final Exercise
Before you leave this book, write one last C program. It doesn’t need to do anything fancy,
just something that reminds you why you love building things from first principles.
#include <stdio.h>
int main(void) {
printf("I learned to think in C.\n");
return 0;
}
Compile it. Run it. Smile. You now speak the language of the machine.
The Little Book of C End of Volume
501