Adapted from the CS1101 lecture sequence at IIT Madras (BS in Electronic Systems, foundational level).
How to use this book
This book is a standalone textbook. You do not need the original lectures to learn from it. Every concept is introduced from first principles, illustrated with runnable C programs, and reinforced with exercises.
Read the chapters in order. Each chapter is built on the previous one. Skipping ahead is rarely useful before Chapter 6.
Type every program. The only way to learn to program is to program. Watching — or reading — is not enough.
The book is organised into five large parts:
Part
Theme
Chapters
I — Foundations
How a computer works; what a program is; what C is for; how data is represented
1 – 4
II — Core C
Types, variables, operators, control flow, functions
A reference appendix collects the C standard library functions you will encounter, the ASCII table, operator precedence, and a glossary.
Table of Contents
Front Matter
Course Overview
Learning Objectives
Part I — Foundations
How a Computer Works
Programs, Algorithms, and Data Structures
Why C? The Language and the Machine
How Data Is Represented in Memory
Part II — Core C
Writing Your First C Program
Variables, Types, and Operators
Control Flow
Loops
Functions and Modular Programs
Part III — Pointers and Memory
The Runtime: Stack Frames and the Heap
Recursion
Scope and Lifetime
Introduction to Pointers
Endianness, Alignment, and the Optimised Compiler
Part IV — Arrays and Strings
Arrays
Strings in C
Part V — Structured Data
Structures (struct)
Custom Types: typedef, union, and enum
Part VI — Dynamic Memory
The Heap, malloc, and Dynamic Allocation
Multidimensional Arrays
Part VII — I/O and the Lower Level
File Handling
Bit Manipulation
Part VIII — The Toolchain
The Preprocessor and Macros
Multi-file Projects and Compilation
Back Matter
Appendix A: Operator Precedence
Appendix B: Common Standard Library Functions
Appendix C: ASCII Reference
Appendix D: Glossary
Index
Course Overview
This textbook accompanies a one-semester introductory programming course in C. The course assumes no prior programming background and is paced for first-year students in electronics and electrical engineering programmes.
The course has three goals, in order of importance:
Make you a competent C programmer. You should be able to read a moderate-sized C program, write a comparable program from scratch, and locate defects using a debugger.
Make you reason about memory. C exposes memory in a way that other languages hide. This is a feature, not a bug — it is exactly what makes C suitable for systems programming. After this course you should be able to predict where your variables live (stack, heap, static data), what happens when a pointer is dereferenced, and why 5 / 2 produces 2 rather than 2.5.
Make you comfortable at the boundary between software and hardware. C sits unusually close to the CPU. Knowing what a compiler does, how an instruction becomes machine code, and how memory is laid out will give you a head start in later courses on microcontrollers, operating systems, compilers, and embedded design.
Learning Objectives
By the end of this course you will be able to:
Explain the structure of a stored-program computer: CPU, ALU, registers, memory, clock, and the role of the program counter.
Convert between decimal, binary, and hexadecimal representations of integers, including signed (two's complement) and floating-point forms.
Read a small C program and identify the role of each token: keyword, identifier, operator, literal, comment.
Write correct C programs using variables, expressions, conditionals, loops, functions, arrays, strings, structures, and pointers.
Trace the execution of a C program by hand: what each variable holds at each step, when each function is entered and exited, and how memory is allocated and freed.
Predict the output of unfamiliar-looking but well-formed C code, and identify why a buggy program produces its observed output.
Use the C standard library for formatted I/O (printf, scanf), file handling (fopen, fclose, fprintf, fscanf, fread, fwrite), string manipulation (strlen, strcpy, strcmp, strcat), and dynamic memory (malloc, calloc, realloc, free).
Compile multi-file C projects using a Makefile and the gcc toolchain.
Debug programs using a debugger (gdb or an IDE's built-in debugger).
You do not need any of these as prerequisites — only school-level mathematics and basic computer literacy (using an editor, running a command in a terminal).
Course sequence
Part I — Foundations
Before writing a single line of C, we look at the machine that C is designed for. C is unusual among modern languages in that it sits very close to the hardware. To write good C, you need to know what the hardware is doing.
Chapter
Chapter 1 — How a Computer Works
Interactive model
Trace one instruction cycle
A small CPU model: fetch, decode, then execute.
Instruction → decoder → ALU → registers → next program counter.
1.1 What "the computer" really means
When you hear the word computer, you probably picture a desktop box, a laptop, or a phone. That mental picture is convenient but misleading. A computer is not the box; it is the chip inside the box.
That chip is called the CPU — the Central Processing Unit. The box around it contains many other things: a power supply, a fan, a battery, a screen, a keyboard, ports, disk drives, speakers, and so on. All of those exist to serve the CPU.
Why does this matter? Because C is a language designed to talk to the CPU. When you write a C program, you are issuing instructions that will eventually reach this chip. Other languages (Python, JavaScript, Java) hide the CPU behind several layers of software. C does not. To use C well, you must understand what the CPU actually does.
1.2 The simplest possible computation
Let us start with a problem: add two numbers. We want the computer to take two numbers as input and produce their sum as output.
Three things are required for even this trivial task:
A way to represent the numbers inside the machine. Computers only understand electrical signals: a high voltage or a low voltage, a 1 or a 0. The unit of information is the bit — short for binary digit. To represent larger numbers, we group bits together.
Hardware that computes. A digital circuit that takes two electrical signals representing numbers, performs addition, and emits a result signal. This is called an adder circuit.
A way to talk to the outside world. Inputs (the two numbers) must somehow enter the machine, and the output (the sum) must leave it. This is called input/output, or I/O.
For now, the adder is a black box. Its inputs are the two numbers — the operands — and its output is the result of the operation performed on them.
1.3 Reusing the adder: introducing memory
Once we have an adder, we naturally want to reuse it. We do not want to design a new circuit for every pair of numbers we might want to add.
To reuse it, we need a place to store values. Imagine a table with three columns — A, B, S — each row holding one problem. We write the inputs into A and B, ask the adder to compute A + B, and write the answer into S.
This storage place is called memory. Memory is one of the most important concepts in the entire course.
Memory supports three operations:
Operation
Meaning
Write
Store a value at a particular location.
Read
Retrieve a value that was previously stored.
Address
The label or tag identifying where a value is stored.
The address is essential. Imagine leaving your bag at a shop counter and getting a token in return. The token is the address; the bag is the value. Without the address you could not find your bag again. Memory works the same way: every stored value has an address, and you must know that address to read it back.
1.4 Words, widths, and capacity
Memory is divided into words. A word is a fixed collection of bits — the smallest unit the memory hardware treats as a single chunk.
A word has a data width, the number of bits in it. Common widths today are 32 bits and 64 bits. The data width determines what range of values a single word can hold. An N-bit word can hold one of 2^N different patterns, so it can represent unsigned values from 0 up to 2^N − 1.
Memory also has a capacity — a finite number of words. A modern laptop might have 8 billion words of memory; a microcontroller might have 16 thousand. The capacity is chosen when the hardware is built; you cannot change it later by software.
1.5 From fixed adder to programmable operation
The adder we built does only one thing. To make it useful we need to choose, for each problem, which operation to perform. Sometimes we want addition; sometimes subtraction, multiplication, comparison, or bit manipulation.
We can do this by storing the operation code alongside the operands in memory. The hardware now takes three inputs from memory: A, B, and OP. The OP tells the hardware which operation to perform this time. Step through a sequence of stored triples, and you have a programmable computer.
The hardware that performs the arithmetic and logic is called the ALU — the Arithmetic and Logic Unit. Every modern CPU contains one or more ALUs.
1.6 Unifying memory
Originally we kept four separate memories: one for A, one for B, one for OP, one for S. But there is no fundamental reason to do so. We can put everything in one big memory, distinguishing the roles of locations only by their address ranges. The plan that says "addresses 0 to 99 are for A, 100 to 199 for B, …" is called the address map.
This unification is conceptually important: data and instructions are stored in the same memory. Both are just bits. The only thing that distinguishes them is the role they play in the program.
When operations are encoded as numbers (1 = add, 2 = subtract, 3 = multiply, …) and stored alongside data, we have the stored-program computer — the architecture underlying every computer you have ever used.
1.7 Splitting memory: the speed mismatch
Small memory is fast. The wires are short, the address decoders are simple, and the time to charge the wires' capacitance is small. As memory grows, the wires get longer, the address decoders get bigger, and memory becomes slower.
This is a problem: the ALU is very fast, but if memory is slow, the ALU is constantly waiting.
The fix is to split memory into two tiers:
Main memory — large, but relatively slow. Holds the program and all the data.
Registers — tiny, very fast temporary storage inside the CPU. Holds only the few values the ALU is currently working on.
To use a value, the CPU loads it from main memory into a register. When it is done with the value, it stores the result back. These two verbs — load and store — are fundamental. You will see them again when we discuss functions.
The combination of an ALU and its registers is the CPU. Everything else — main memory, disk, screen, keyboard, network — is outside the CPU.
A modern machine may have several CPUs on one chip (a quad-core machine has four). Each core has its own ALU and its own set of registers.
1.8 The clock
Inside the CPU there is a clock — a signal that ticks at a fixed rate (e.g. 3 billion ticks per second, or 3 GHz). Each tick, the CPU performs one step: fetch an instruction, decode it, execute it, write the result. The goal of the CPU designer is to make every basic operation finish within one tick. (In practice, modern CPUs pipeline instructions and may issue more than one per tick — a metric called instructions-per-cycle or IPC — so clock speed alone does not determine throughput. But the one-tick-per-step picture is the right mental model for now.)
1.9 Three kinds of instructions
Despite their apparent variety, all CPU instructions fall into just three categories:
Arithmetic/logic instructions — performed by the ALU. Addition, subtraction, comparison, bit shifts, and so on.
Load/store instructions — move data between main memory and registers.
Control-flow instructions — change the order in which instructions are executed. The most common are branches (jump to a different instruction) and the underlying mechanism that powers loops.
This is, in essence, all a computer is. Once you have an ALU, registers, a load/store pathway to memory, a program counter that steps through instructions, and the ability to change that counter arbitrarily, you have a complete computer.
1.10 Programs: stored sequences of instructions
A program is a sequence of instruction codes stored in memory. The CPU reads the first instruction, executes it, reads the next, executes it, and so on.
The hardware keeps track of which instruction is next using a special register called the program counter. After each instruction, the program counter advances by one — except when it does not.
1.11 Communication with the outside world
The CPU needs to talk to devices outside itself: keyboards, screens, disks, network cards. The trick the hardware uses is memory-mapped I/O: certain addresses in the address space do not correspond to memory chips at all. They correspond to a peripheral. Sending a value to such an address makes the peripheral do something; reading such an address returns the peripheral's current state.
This is the chain behind printf. When your program calls printf, it does not talk to the screen directly. The call goes to the standard library, which formats the text and hands it to the operating system through a system call. The operating system's runtime and device drivers then deliver the bytes to the terminal or device subsystem, which displays them. From the CPU's perspective, the final step may well be a write to a memory-mapped address belonging to the display controller — but between your program and the screen there is a whole stack of software. We will see the C side of this chain (the standard library and its printf) in Chapter 5.
1.12 Summary of Chapter 1
The "computer" of interest is the CPU, not the desktop box.
A bit is one binary digit. Bits are grouped into words of fixed data width.
Memory stores bits at addresses. Memory supports read, write, and addressing.
The ALU performs arithmetic and logic. Registers are small, fast temporary storage inside the CPU. The combination is the CPU.
The CPU communicates with main memory through load and store instructions, and with peripherals through memory-mapped I/O.
All instructions fall into three classes: arithmetic/logic, load/store, and control flow.
A program is a sequence of instruction codes. The program counter tracks which one runs next. Control-flow instructions change the program counter.
Exercises 1
A certain memory has 8-bit words and 256 words of capacity. How many bits of storage does it hold in total? What is the largest unsigned value a single word can represent?
A 16-bit word holds 2¹⁶ patterns. If interpreted as a signed value using two's complement (Chapter 4), what range can it express?
Explain in one sentence each: (a) the role of the ALU, (b) the role of registers, (c) the role of the program counter.
Why does a CPU need both an ALU and registers? What problem is solved by having them together inside the CPU rather than both being part of main memory?
Discussion. Modern CPUs can perform billions of operations per second but only very simple operations (add, compare, branch). How is it possible to play a video, search the web, or train a machine-learning model using only these operations?
Practical. Look up the model number of the CPU in your laptop or phone. How many cores does it have? What is its clock speed in GHz? How much main memory does the device have?
Chapter
Chapter 2 — Programs, Algorithms, and Data Structures
2.1 What programming is
Programming is the act of providing a sequence of instructions to the processor so that it can solve a problem we are interested in.
That sentence does a lot of work. "Provide a sequence of instructions" sounds simple, but the difficulty is that the CPU speaks a very narrow language. Every step must be expressed as an arithmetic operation, a load or store, or a branch. What humans find intuitive ("compute the average of these ten numbers") must be broken down into dozens or hundreds of primitive CPU steps.
2.2 A running example: the quadratic formula
Suppose we want to solve a quadratic equation:
ax² + bx + c = 0
for known constants a, b, c. The formula we all know gives the two roots:
x = (-b ± √(b² - 4ac)) / (2a)
For a human, the steps are obvious: compute b², compute 4ac, subtract, take a square root, compute -b, divide. For a computer, every one of these is a primitive operation that must be specified explicitly.
Let us build this program step by step.
2.2.1 The memory model
Before we write any instructions, we decide where the data lives in memory. This decision is called the memory model.
Address
Holds
Why
M1
a
known constant
M2
b
known constant
M3
c
known constant
M4
x₁
first root (output)
M5
x₂
second root (output)
T1
b²
intermediate result
T2
4ac
intermediate result
T3
b² − 4ac
intermediate result
T4
√(b² − 4ac)
intermediate result
T5
−b
intermediate result
T6
2a
intermediate result
Notice that the addresses are zero-indexed. Most modern languages, including C, number memory locations starting from 0. This is a convention that simplifies the underlying address arithmetic — a fact you will appreciate when we cover pointer arithmetic in Chapter 15.
2.2.2 The algorithm
The sequence of operations is called the algorithm. Here is one for our quadratic problem. Each line names a memory location and an operation.
Each step is what we called a computer primitive — an operation we assume the hardware can do directly. Addition, subtraction, multiplication, division, square root, negation: all primitives.
Notice also some steps that look trivial to a human but are real operations to a computer:
Step 5 takes the negation of b. This is a primitive, not a freebie.
Step 6 multiplies a by 2. We are treating the literal 2 as a direct operand, but a very simple CPU might require it to be loaded into a register first.
2.2.3 Reusing temporaries
Look at Step 2: T2 = 4 * M1 * M3. After Step 3, we never use the bare value of ac again. We could therefore overwrite M3 (which held c) with the result of step 2, reusing the same storage for two different roles.
This is a powerful idea. As long as the old value of a location is no longer needed, the program is free to reuse it. Disciplined programmers reuse temporary storage aggressively; undisciplined programmers declare a fresh variable for every subexpression and produce programs full of half-used temporaries.
Writing each computation as a separate assignment, in single-assignment style, also helps the compiler optimise the code: it can analyse where each value is used and skip keeping copies of values that are never referenced again.
2.3 A second example: summing n numbers
Let us try a slightly harder example. Suppose we have n numbers stored at addresses M, M+1, …, M+n−1. We want their sum in S.
A direct translation would write something like
S = M[0] + M[1] + … + M[n-1]
but this expression has n operands, and n is not known ahead of time. We need to iterate. Here is the algorithm, in pseudo-code.
r0 = 0 # r0 is the index i, starting at 0
r1 = 0 # r1 is the running sum, starting at 0
loop:
if r0 == n goto done
r1 = r1 + memory[M + r0]
r0 = r0 + 1
goto loop
done:
memory[S] = r1
Here r0 and r1 are registers. We could equivalently have used memory locations; registers and memory locations play the same role in an algorithm, only their performance characteristics differ.
Two new ideas appear in this example:
Iteration. The same set of steps runs once for each element of the input. The hardware does this by branching back to a previous instruction.
Conditional branch. The if r0 == n goto done line is a branch that decides between two paths based on a condition. We will see this construct in C as if (Chapter 7) and as loop termination (Chapter 8).
2.4 Algorithms and data structures
We can now state two foundational definitions.
An algorithm is the sequence of operations needed to perform a computation, written in a form that a computer can understand.
A data structure is the organisation of data in memory. The quadratic-equation example used simple scalar constants in fixed locations. The sum-of-n-numbers example used an array — a contiguous run of n values accessed by index.
The title of Niklaus Wirth's classic 1976 book says it all: Algorithms + Data Structures = Programs. Every program you will ever write is, in the end, a combination of these two things: a procedure (algorithm) and an arrangement of values (data structure).
2.5 The workflow of writing a program
Real programming rarely begins at a keyboard. A useful workflow has five stages.
Idea. What problem are you trying to solve?
Informal specification. A description, in plain language, of what the program should do, what inputs it accepts, what outputs it produces. Often written on paper before any computer is involved.
Architecture. Decisions about memory layout, data structures, intermediate results, and how to split the problem into pieces.
Coding. Translating the architecture into a programming language. This is the part most people think of as "programming," but it is usually the smallest part in time.
Running and debugging. Compiling, executing, and finding mistakes. Often the longest stage.
This is iterative: while running the program you discover that the specification was incomplete, the architecture was wrong, or the code has a bug. You cycle back to fix it and continue.
2.6 The components of a development environment
To write and run programs you need a small toolkit:
Component
Role
Editor
The application in which you type and edit your program. A blank page; anything you type is saved to a file.
Compiler
Converts the text of your program into machine instructions the CPU can execute.
Shell / console
A text interface where you type commands to compile and run your program and observe its output.
Debugger
A program that lets you pause your program during execution and inspect its state.
An IDE — Integrated Development Environment — combines all of these into one application. Examples include VS Code, CLion, and the cloud-based Replit environment.
2.7 Choosing a language
Many languages exist: C, C++, Java, Python, JavaScript, Rust, Go, and many more. For the work of programming, all general-purpose languages are mutually equivalent in the sense that any one of them can express any algorithm — a property called Turing completeness, after Alan Turing's model of computation. Every general-purpose programming language is Turing-complete, so in expressive power none is strictly greater than another. Differences appear in performance, in the abstractions they offer, and in how much work the programmer must do.
C is chosen for this course because it sits very close to the hardware, gives precise control over memory, and is the foundation language of Unix, embedded systems, and most performance-critical code. Mastering C makes later languages easier to learn, not harder.
2.8 Summary of Chapter 2
A program is a sequence of instructions that solves a problem.
An algorithm is the sequence of operations a program performs.
A data structure is the arrangement of data in memory that the algorithm operates on.
Every program is a combination of algorithms and data structures.
The full programming workflow includes idea, specification, architecture, coding, and debugging.
You learn programming by programming, not by watching or reading.
Exercises 2
Write the algorithm (in pseudo-code, not real C yet) to compute the average of n numbers stored at memory addresses M, M+1, …, M+n−1. Store the result at S. Hint: reuse your sum algorithm.
Write the algorithm to compute the maximum of n numbers stored at M, M+1, …, M+n−1. Store the result at S. Hint: keep the running maximum in a register and update it whenever you see something bigger.
Why might it be better to declare temporaries (T1, T2, …) rather than reusing memory locations like M3 for new computations, even when reusing is technically correct?
Discussion. Wirth's title "Algorithms + Data Structures = Programs" is from 1976. Does it still apply in an age of object-oriented programming, functional programming, and large language models? Argue for or against.
Practical. Open a terminal on your machine. Type gcc --version and make --version. If either command is missing, install the corresponding toolchain (Linux: install via your package manager; macOS: install Xcode Command Line Tools with xcode-select --install; Windows: install MinGW or WSL).
Chapter
Chapter 3 — Why C? The Language and the Machine
3.1 A short history of C
In the early 1970s, two researchers at Bell Labs — Ken Thompson and Dennis Ritchie — built an operating system called Unix for a minicomputer called the PDP-7, manufactured by Digital Equipment Corporation (DEC). PDP stands for Programmed Data Processor.
At the time, operating systems were written directly in assembly language — the human-readable form of a CPU's native machine code. Assembly is fast and gives total control, but it is also tedious and tied to one specific processor. A program written for the PDP-7 could not run on, say, an IBM mainframe.
Thompson and Ritchie wanted a language that would let them write the operating system once and have it run on different processors with minimal effort. Such a language is called portable.
3.2 Portability and compilation
A portable language is one whose programs can be made to run on multiple processors without rewriting them from scratch. The trick is compilation: a separate program, called the compiler, takes the text of your program and converts it into the machine code of whatever processor you want to run on. Write the program once, compile it on each new machine, and you have a working binary.
C was designed for exactly this. Its syntax is small, its abstractions are minimal, and the things it does not try to hide — addresses, sizes, sign conventions — are exactly the things a compiler needs to know to produce good code.
3.3 The bootstrapped compiler
C has a chicken-and-egg problem. To compile C, you need a C compiler. To get a C compiler, you need to write one — but in what?
The trick is the bootstrapped compiler:
Write a tiny C compiler in assembly language. This compiler handles only a small subset of C, but it can compile a larger C compiler.
Write the rest of the C compiler in C itself, using the small subset.
Compile the big C compiler using the small one. You now have a fully functional C compiler written entirely in C.
From this point on, the entire compiler toolchain is in C. Assembly is needed only to maintain the bootstrap compiler, which is rarely touched.
3.4 Why C won
C has remained the dominant language for systems programming for over fifty years. The reasons are partly historical (Unix's success, the rise of Linux), but mostly technical:
C stays close to the hardware while still abstracting away the worst details. You can read and write specific memory addresses; you can write code that runs without an operating system; you can put a C program on a microcontroller with kilobytes of memory.
New processor designs almost always come with a C compiler first, because producing a C compiler is the cheapest way to demonstrate the chip's capabilities.
The runtime is small. C does not need a virtual machine or a garbage collector. The programs you compile are essentially the machine code of the target CPU.
3.5 What kind of language C is
C is imperative: you write commands that the processor will execute in sequence. This is in contrast to declarative or functional languages, where you describe the result you want and let the system figure out the steps.
C uses a static type system. Every variable must be declared with a type before it is used. The type — int, float, char, etc. — tells the compiler how to interpret the bits stored in that variable. Static typing lets the compiler catch many mistakes before the program ever runs. Languages like Python and JavaScript are dynamically typed: types are inferred at runtime.
C is also weakly typed. It is easy to convert a value of one type to another — sometimes too easy. The compiler will happily let you write float x = 3.14; int y = x; and silently truncate 3.14 to 3. That truncation is a defined conversion the standard specifies — the fractional part is discarded and the integer part is kept; the bits are not being reinterpreted (that is a different, dangerous operation we meet in §4.9's memcpy trick and §18.4's unions). C is "weakly typed" because it allows such implicit, silent conversions between types, not because it lets you re-read memory as a different type. This deliberate trade-off makes C more flexible but also a frequent source of bugs.
C supports structured programming: programs are organised as nested blocks of code, with explicit control-flow constructs. We will see this in detail in Chapter 7.
3.6 C's intellectual family
C belongs to a family of languages that descended from Algol (Algorithmic Language), a language designed in the late 1950s and 1960s. Algol introduced many ideas we now take for granted: block structure, lexical scoping, and call-stack-based function calls. Algol itself was too complex to become a daily-use language, but its descendants — C, C++, Java, C#, JavaScript, Go, and others — are everywhere.
3.7 Free-form source text
Unlike some older languages, C source code is free-form. You may write multiple statements on one line, or split one statement across many lines. The compiler treats whitespace (spaces, tabs, newlines) as a token separator and otherwise ignores it. There are a few exceptions: preprocessor directives must begin at the start of a logical line, and string literals cannot be split across lines without special syntax.
In Python, by contrast, indentation is part of the syntax. In C, indentation is a convention — strictly for human readability — and the compiler does not care about it.
3.8 What C does not give you
C is a small language. Several features common in other languages are missing:
No built-in string type. Strings are arrays of characters with a sentinel byte at the end (Chapter 15).
No Boolean type. Original C had no boolean type — the integer convention 0 = false, non-zero = true (§6.4) was all there was. C99 added _Bool, made usable as bool/true/false via <stdbool.h> (§6.3, §6.4). The integer convention predates it and remains idiomatic C.
No bounds checking on arrays. Reading past the end of an array is not an error as far as the language is concerned — though it usually crashes the program.
No garbage collector. Memory you allocate, you must free (Chapter 19).
No exception handling. Functions signal errors through return values or by setting a global variable.
These omissions are deliberate. They are what make C small and fast. They are also what make C dangerous for beginners and why careful study is essential.
3.9 The role of the operating system
The operating system is the program that coordinates everything else. It loads your program into memory, gives it access to the screen, the keyboard, the disk, and the network, and switches the CPU between programs so several can appear to run at once.
The OS is itself a program, but a special one: it is the first program to run when the computer is switched on. The act of starting the OS is called booting, a name that comes from the phrase "pulling yourself up by your bootstraps." The hardware has a small, fixed program called the BIOS that knows how to find the OS on disk and load it into memory; once the OS is running, the BIOS is no longer needed.
For a C programmer, the OS is mostly invisible. Your program makes calls like printf and fopen; underneath, these are translated by the C standard library into system calls that the OS handles. You will rarely need to think about this directly, but it is good to know it is there.
3.10 Recommended further reading
Three books cover the territory of this course well:
The C Programming Language, by Brian Kernighan and Dennis Ritchie (often called K&R). Concise, precise, and authoritative. Best read alongside, not before, hands-on programming.
Practical C Programming, by Steve Oualline. A gentler introduction, focused on writing correct, maintainable code.
Beej's Guide to C Programming, by Brian Hall. Available free online. Friendly and practical.
You can also learn by doing. After each chapter, write code, run it, change it, break it, and fix it. The remainder of this book is designed to give you the structure and the reference material to do exactly that.
3.11 Summary of Chapter 3
C was created at Bell Labs in the early 1970s to write Unix portably.
C compiles to the machine code of the target CPU, with very little runtime overhead.
C is imperative, statically typed, weakly typed, and supports structured programming.
C does not provide strings, booleans, bounds checking, garbage collection, or exception handling — features that are common in other languages.
The operating system is the program that loads and coordinates all other programs.
Exercises 3
Why was the bootstrap compiler needed? What problem would arise if every C compiler had to be written in assembly from scratch?
List three things C does not give you that some other languages do. For each, give a one-sentence justification of why the C designers might have left it out.
Discussion. A common saying is that "C is portable assembly." Is this literally true? In what sense is it true, and in what sense is it an exaggeration?
Practical. Find out which version of the C compiler you have installed (gcc --version). What standard does it default to (e.g. C99, C11, C17)? What flags would you use to ask for a specific standard (e.g. -std=c11)?
Chapter
Chapter 4 — How Data Is Represented in Memory
Interactive model
Read an 8-bit pattern
Change a bit and compare unsigned, signed, and hexadecimal values.
01011010 = 90 unsigned = 90 signed = 0x5A.
4.1 The representation problem
A computer memory cell holds a pattern of high and low voltages — ones and zeros. To use those patterns as numbers, characters, or anything else, we must agree on a convention for what the patterns mean. The same eight bits might represent the integer 65, the character 'A', the instruction code for some operation, or a fragment of a music file. The bits are the same; only the convention we apply decides the interpretation.
This chapter builds the most important conventions used in C: integers, signed integers, floating-point numbers, and characters. Once you know these, you will understand why 5 / 2 gives 2 and not 2.5, why mixing signed and unsigned integers can surprise you, and why comparing floats with == is dangerous.
4.2 Binary and place value
You already know decimal place value:
956 = 9 × 10² + 5 × 10¹ + 6 × 10⁰
Binary works the same way, with base 2 instead of 10:
Every position corresponds to a power of 2. The bits (short for binary digits) are the individual 0s and 1s. Reading from right to left, they are the units bit, twos bit, fours bit, eights bit, and so on.
A few binary-to-decimal conversions worth committing to memory:
Binary
Decimal
0000
0
0001
1
0010
2
0100
4
1000
8
1111
15
1 0000
16
Decimal-to-binary uses repeated division or, more practically, repeated subtraction of the largest power of 2 that fits:
Long binary strings are hard for humans to read. Hexadecimal (base 16) is a compact way to write binary. Because 16 = 2⁴, each hex digit corresponds to exactly four binary bits:
Hex
Binary
Decimal
0
0000
0
1
0001
1
2
0010
2
…
…
…
9
1001
9
A
1010
10
B
1011
11
C
1100
12
D
1101
13
E
1110
14
F
1111
15
To convert a binary number to hex, group its bits in fours from the right:
0101 1011 = 0x5B = 5 × 16 + 11 = 91
C marks hexadecimal literals with the prefix 0x (or 0X):
int a = 91; /* decimal */
int b = 0x5B; /* hexadecimal, same value */
int c = 0133; /* octal (leading zero), same value — avoid this form */
Hex is especially useful when inspecting memory: each byte is exactly two hex digits.
4.4 Fixed widths and the range of an unsigned integer
Memory is divided into fixed-size words. We have to pick a width — say, 8 bits, 16 bits, 32 bits, or 64 bits — and stick to it. An N-bit word can hold 2^N different patterns, so an N-bit unsigned integer can represent values from 0 to 2^N − 1.
Width
Range
8 bits
0 to 255
16 bits
0 to 65 535
32 bits
0 to 4 294 967 295
64 bits
0 to 18 446 744 073 709 551 615
A fixed width forces a trade-off. Doubling the width doubles the memory cost for every integer, but it also roughly doubles the range. Hardware designers pick a width that balances the typical needs of programs against the cost of memory and logic.
C's standard types (char, short, int, long, long long) are guaranteed only minimum widths; the actual widths depend on the platform. To write portable code that depends on width, use the exact-width types from <stdint.h>:
A word's bits have positions. The most significant bit (MSB) is the leftmost — it carries the largest place value. The least significant bit (LSB) is the rightmost — it carries the smallest.
1 0 1 1 0 0 1 0
^ ^
MSB LSB
When we say "the eighth bit" or "bit 7", we usually mean the bit at position 7 — counting from 0 at the LSB. So bit 0 is the units bit, bit 1 is the twos bit, bit 7 is the 128s bit.
4.6 Signed integers: sign-magnitude and its problems
How do we represent negative numbers in bits? The natural idea is signed magnitude: reserve one bit (the MSB) as a sign bit — 0 means positive, 1 means negative — and use the remaining bits for the absolute value.
+5 in 8 bits: 0000 0101
-5 in 8 bits: 1000 0101
Signed magnitude has two problems:
Two representations of zero. Both 0000 0000 and 1000 0000 mean "zero". This is wasteful and confuses comparisons.
Addition does not work as expected. The hardware would need to check the sign bits and decide whether to add or subtract the magnitudes. There is no simple algorithm.
Signed magnitude is therefore not used in modern computers. It survives in a few legacy systems, but for everyday programming it is irrelevant.
4.7 Two's complement
The convention that won is two's complement. For an N-bit signed integer:
Non-negative values are stored as ordinary unsigned binary.
A negative value −x (where x > 0) is stored as 2^N − x.
For 8-bit integers:
0000 0101 (5)
−1 is stored as 256 − 1 = 255 = 1111 1111
−2 is stored as 256 − 2 = 254 = 1111 1110
…
−128 is stored as 256 − 128 = 128 = 1000 0000
The 8-bit two's-complement range is −128 to +127. Note the asymmetry: there are 256 patterns, and they split as 128 negative values (−128 … −1), 1 zero, and 127 non-negative values (+1 … +127).
Two's complement has two crucial properties that made it the universal choice:
One representation of zero. Adding +x and −x always gives zero with the carry discarded.
Negation is trivial. To negate a value, invert all bits and add one. This is one hardware instruction. The arithmetic is straightforward: inverting all bits of x (the bitwise NOT) produces 2^N − 1 − x; adding one gives 2^N − x, which is exactly the two's-complement encoding of −x from the definition above. So "invert and add one" is not a magic trick — it is the definition 2^N − x written as two cheap bit operations.
A consequence: subtraction is just addition of the negated operand. Hardware that adds can subtract; no separate subtractor is needed.
4.8 Overflow
Because the width is fixed, the result of arithmetic can exceed the representable range. With signed two's complement, the result simply wraps around — there is no warning.
#include <stdio.h>
int main(void) {
int x = 2000000000;
int y = 2000000000;
printf("%d\n", x + y); /* wraps to a negative number */
return 0;
}
The C standard says signed overflow is undefined behaviour: the program may print a negative number, may print a positive number, may crash, or may do anything else. Compilers do not have to warn, but they often will with optimisation enabled.
For unsigned integers, the wrap-around is well-defined: the result is taken modulo 2^N. This makes unsigned arithmetic useful when wrap-around is exactly what you want — for example, in modular arithmetic or hashing.
4.9 Floating-point numbers
What about real numbers like π, e, or 0.1? They cannot be represented exactly in any fixed number of bits, because they have infinitely many digits after the decimal point.
C uses the IEEE 754 floating-point standard, which represents a real number as:
sign × mantissa × 2^exponent
The layout of a 32-bit (single-precision) IEEE 754 float is:
The layout above lists three fields, but two non-obvious rules govern how they are filled. Both matter if you want to reconstruct a value from its bits, and both explain why floating-point is approximate.
The implicit leading 1 (normalised mantissa). For any "normal" (non-tiny, non-special) floating-point number, the mantissa is stored in normalised form: 1.something in binary — there is always a single leading 1 to the left of the binary point. Because that leading 1 is always present for normalised numbers, there is no need to store it. The 23 mantissa bits hold only the fractional part — the bits after the implicit 1.. So the real mantissa is 1.mantissa_bits, giving 24 bits of precision in 23 bits of storage. (There is a special "denormal" form for values too small to normalise; we mention it below.)
The biased exponent. The exponent field is not a signed two's-complement integer. It is stored biased: the stored value E_stored represents the true exponent E_true = E_stored − bias, where the bias is 127 for single precision and 1023 for double precision. So an exponent field of 10000001 (129) means a true exponent of 129 − 127 = 2; a field of 01111111 (127) means an exponent of 0. The bias lets one field encoding cover both positive and negative exponents without a separate sign bit for the exponent.
A worked decode: 0x40490FD0
Take the hex output of the program below, 0x40490FD0, and decode it by hand. This is the exercise that makes the representation click.
Write the 32 bits in order, then split into the three fields:
Mantissa field 10010010000111111010000 → prepend the implicit leading 1: 1.10010010000111111010000 binary.
So the value is +1.10010010000111111010000₂ × 2¹. Move the binary point one place right (the exponent is 1): 11.0010010000111111010000₂. The integer part 11₂ = 3; the fractional part 0.0010010000111111010000₂ is ≈ 0.14159. Together: ≈ 3.14159. The bits are an approximation of π — the stored value is not exactly π, which is the whole reason == on floats is unsafe (§4.10, §7.9).
Decoding it in code
A worked example (using memcpy — the standards-compliant way to reinterpret bits, as in §18.4):
#include <stdio.h>
#include <string.h>
int main(void) {
float pi = 3.14159f;
unsigned int bits;
memcpy(&bits, &pi, sizeof(bits));
printf("%.5f is stored as 0x%08x\n", pi, bits);
return 0;
}
The exact hex output is implementation-defined, but it is something like 0x40490FD0.
Special values
The encoding reserves a few field patterns for values that are not ordinary numbers:
Exponent field all zeros — denormal numbers (too small to normalise; no implicit leading 1) and the value zero itself.
Exponent field all ones — infinity (mantissa zero) and NaN (Not a Number, mantissa non-zero). 0.0 / 0.0 produces NaN; 1.0 / 0.0 produces infinity. NaNs propagate: any arithmetic with a NaN yields NaN, and NaN == NaN is false — which is why checking for NaN uses isnan(x) from <math.h>, not ==.
Range and precision
Type
Approx. range
Decimal precision
float
10⁻³⁸ to 10³⁸
about 7 digits
double
10⁻³⁰⁸ to 10³⁰⁸
about 15 digits
The dynamic range — the ratio between largest and smallest representable values — is enormous: about 10⁷⁶ for float, about 10⁶¹⁶ for double. The trade-off is precision: only finitely many real numbers fit between any two representable values.
Half precision
For machine-learning applications, where memory bandwidth and throughput matter more than precision, a 16-bit half precision float is gaining popularity. The hardware uses fewer transistors and consumes less power, at the cost of precision.
4.10 Rounding and accumulation error
Because only finitely many real numbers are representable, every arithmetic operation may round to the nearest representable value. After many operations, errors can accumulate:
#include <stdio.h>
int main(void) {
double sum = 0.0;
for (int i = 0; i < 100; i++) {
sum += 0.01;
}
printf("%.20f\n", sum); /* probably not exactly 1.0 */
return 0;
}
Numerical programmers learn to be wary of accumulated error. Comparisons with == are dangerous; use tolerances. The textbook Numerical Recipes* and the IEEE 754 standard are the next places to read.
4.11 Character encoding
Characters — letters, digits, punctuation — must also be represented as bits. A character encoding is a two-way mapping between characters and integers.
The historic encoding is ASCII (American Standard Code for Information Interchange). It started as a 7-bit code (128 characters: A–Z, a–z, 0–9, punctuation, control codes) and was later extended to 8 bits for compatibility with byte-oriented hardware.
ASCII is enough for English but not for the rest of the world. Hindi combines letters with matras (diacritical marks); Russian uses a completely different alphabet; Chinese uses thousands of ideograms.
Unicode solves this with a single consistent mapping called a code point: an integer that uniquely identifies every character in every supported script. Code points are converted to bytes by an encoding. The most popular encoding today is UTF-8:
Code points 0–127 are encoded as one byte (compatible with ASCII).
Code points 128–2047 are encoded as two bytes.
Larger code points take three or four bytes.
The mapping uses distinctive leading-bit patterns so a program can find character boundaries by inspection:
0xxxxxxx — a one-byte character (code points 0–127, ASCII-compatible).
110xxxxx — lead byte of a two-byte character; one 10xxxxxx continuation byte follows.
1110xxxx — lead byte of a three-byte character; two continuation bytes follow.
11110xxx — lead byte of a four-byte character; three continuation bytes follow.
The rule: a byte starting with 0 or 11 is a lead byte; a byte starting with 10 is a continuation byte. So to find the start of any character, scan backwards until you hit a byte that does not start with 10.
4.12 Encoding instructions
The same idea — reducing everything to numbers — applies to instructions. A CPU's instruction set architecture (ISA) defines the format of instructions, and a tool called an assembler converts human-readable mnemonics like addi (add immediate) into the numeric codes the hardware actually consumes.
For example, in the RISC-V 32-bit ISA, the instruction
addi x7, x0, 0 # x7 = 0 + 0 = 0
is encoded as a 32-bit number. The instruction has a regular structure — an operation, a source register, and a destination register — and each part maps to a fixed field of the 32-bit word:
The 12-bit immediate holds the constant 0 (the value to add).
rs1 (5 bits) names the source register x0. Five bits can address 2⁵ = 32 registers, which is exactly how many RISC-V defines; x7 in binary is 00111.
rd (5 bits) names the destination register x7.
The opcode and funct3 fields identify the operation as addi; the RISC-V committee chose these codes, and the assembler looks them up in the reference manual.
Notice that only 5 + 5 + 12 = 22 of the 32 bits carry this instruction's unique data; the remaining 10 bits (opcode and funct3) are fixed for the addi operation. Other encodings are possible — the point is that the mapping exists at all.
A second example: the load-word instruction
lw R2, 0(R7) # R2 = memory[R7 + 0]
encodes to the 32-bit hexadecimal value 0003A103. The processor reads that number from memory, decodes the fields, and performs the load.
The key observation is that programs are stored in memory as numbers, exactly like data. A 32-bit-wide memory with 1M locations holds 4 MB of RAM, and that memory can be partitioned arbitrarily into a code region (holding instruction numbers) and a data region (holding data numbers). The hardware tells them apart by how it accesses them: the program counter fetches numbers as instructions, while load and store instructions fetch numbers as data.
This is why a compiler's output — what was once called a machine code or executable — is just a sequence of numbers. The same memory holds both the program (more numbers) and the data (also numbers).
4.13 The single most important sentence in this chapter
Memory is a sequence of bits. Meaning — integer, float, character, instruction — comes from conventions the programmer and the language agree on. When something in C surprises you, the surprise is almost always because two different conventions collided.
4.14 Summary
Memory holds bits. Conventions assign meaning to bit patterns.
Binary, decimal, and hexadecimal are three notations for the same underlying numbers.
Unsigned integers use the full range 0 … 2^N − 1.
Signed integers use two's complement; the range is approximately −2^N⁻¹ to 2^N⁻¹ − 1.
Two's complement makes negation and subtraction cheap.
ASCII is the historic character encoding; Unicode and UTF-8 are the modern replacements.
Instructions are encoded as numbers; an assembler is a translator between mnemonics and codes.
Exercises 4
Convert to binary by hand: 13, 42, 128, 255.
Convert to hex: 170, 4096, 65535.
Convert to decimal: 0xCAFE, 0x100, 0b1100100. (The 0b prefix is a C23 extension; in C11, write the same value as 0x64.)
What is the range of an unsigned 32-bit integer? Of a signed 32-bit two's-complement integer?
Negate the following 8-bit two's-complement values by inverting and adding one: 0000 0111, 0111 1111, 1000 0000. Verify each.
What is the result of adding two unsigned 8-bit integers, 200 + 100? Explain why.
Floating point. Write a program that prints the exact bit pattern of 1.0f, 2.0f, −1.0f, and 0.5f as hex. Use the memcpy trick shown in this chapter.
Floating-point surprise. Compute 0.1 + 0.2 in double precision. Print the result with 20 decimal digits. Explain why it is not 0.3.
Unicode. The UTF-8 encoding of the smiley face 🙂 (code point U+1F642) is F0 9F 99 82. Decode these four bytes back to the code point, showing each step.
Endianness preview. Suppose a 32-bit integer with hex value 0x12345678 is stored at address 1000. What bytes appear at addresses 1000, 1001, 1002, 1003 on a big-endian machine? On a little-endian machine? (You will study endianness properly in Chapter 14.)
Course sequence
Part II — Core C
Now we begin to write programs. The chapters in this part introduce the C language piece by piece: how a program is structured, what variables are, what expressions are, how decisions are made, how work is repeated, and how programs are split into reusable units called functions.
Chapter
Chapter 5 — Writing Your First C Program
5.1 The smallest possible program
Here is the smallest meaningful C program:
#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
return 0;
}
That is it. Let us read it line by line.
Line 1 — #include <stdio.h>
Lines that begin with # are preprocessor directives — instructions to a program called the preprocessor that runs before the compiler. We will cover the preprocessor in detail in Chapter 23; for now, all we need to know is that #include <stdio.h> instructs the preprocessor to paste the contents of a file called stdio.h into our program. stdio.h is part of the C standard library and contains the declaration of printf and other input/output functions. Without it the compiler would not know how printf works.
The angle brackets < > mean "look in the standard system locations for this header". Double quotes ("stdio.h") would mean "look first in the current directory".
Line 3 — int main(void)
Every C program must contain exactly one function named main. When the program is run, the operating system starts execution at the beginning of main. We will study functions in detail in Chapter 9; for now, the syntax is:
<return-type> <name>(<parameters>) {
<body>
}
int is the return type: when main finishes, it produces an int value back to the operating system. The convention is 0 for success and non-zero for failure.
main here takes the parameter void, meaning "no parameters". Older C programs sometimes omit the void and write main(); both forms are accepted, but int main(void) is unambiguous and modern.
The opening brace { marks the start of the function body. A matching } will close it.
Line 4 — printf("Hello, world!\n");
This is a statement — an instruction to be executed. Every statement ends with a semicolon ;. The statement calls a library function called printf (the name comes from print formatted) and asks it to print the characters Hello, world! followed by a newline.
A newline is a special character that moves the cursor to the next line. In C it is written as \n. The backslash introduces an escape sequence — a pair of characters that represents a single character that cannot easily be typed. Other useful escape sequences include \t (tab) and \\ (a literal backslash).
Line 5 — return 0;
Another statement. The return statement ends the function immediately and produces the value that follows it as the function's result. Here the result is 0, signalling success.
5.2 Building and running the program
Save the program above into a file called hello.c. Then, in a terminal:
The first command invokes the compiler (gcc, the GNU C Compiler). The flags mean:
-Wall — turn on common warnings
-Wextra — turn on additional warnings
-std=c11 — compile against the 2011 ISO C standard
-o hello — name the executable file hello (not the default a.out)
If everything is well, the compiler prints nothing and produces an executable called hello. The second command, ./hello, tells the shell to execute the program. The leading ./ is necessary: by default the shell does not look in the current directory for programs to run, so we have to spell out the path. (The dot is a one-character name for "the current directory".)
If you forget the -std flag, your compiler may default to an older standard (C99, C17, or even older). Be explicit — it makes code more portable.
5.3 Anatomy of a C source file
A C source file is plain text. Its structure is mostly free-form, but a few pieces have conventional placements.
/* File: add.c
Adds two numbers and prints the result. */
#include <stdio.h> /* Preprocessor directive: pull in stdio.h */
#define PI 3.14159 /* A macro definition */
int add(int a, int b); /* Function declaration (prototype) */
int main(void) {
int x = 3;
int y = 4;
int z = add(x, y);
printf("%d + %d = %d\n", x, y, z);
return 0;
}
int add(int a, int b) {
return a + b;
}
Six kinds of elements appear:
Comments. Text for human readers. Two forms:
- /* ... */ — a block comment that can span multiple lines. - // ... — a line comment, introduced in C99, that lasts until the end of the line.
Preprocessor directives. Lines starting with #, processed before compilation.
Function declarations. Lines that announce a function's name, parameter types, and return type without giving its body. They end with a semicolon.
Function definitions. The actual body of a function: its declarations, statements, and closing brace.
Statements. Instructions ending in semicolons.
Declarations. Lines that introduce a variable and its type, optionally with an initial value.
5.4 Statements and expressions
A statement is an instruction to the computer to do something. Almost every statement ends with ;.
An expression is anything that evaluates to a value: 3, x, x + y, add(3, 4).
The distinction is sometimes fuzzy: an expression followed by ; becomes a statement. x + y; is a legal statement whose only effect is to compute x + y and discard the result. This is rarely useful — we mention it only to make clear that the boundary between statement and expression is not sacred.
The format string in printf contains placeholders that begin with %. When printf runs, each placeholder is replaced by the corresponding value, formatted as text.
Placeholder
Type
Example output
%d
int
42
%u
unsigned int
42
%ld
long
42
%f
double (or float)
3.140000
%c
char
A
%s
string (char *)
Hello
%x
hexadecimal
2a
%p
pointer
0x7ffee4bff8b0
%%
a literal %
%
5.5 Syntax versus semantics
Syntax is the grammar of the language — the rules for what tokens may appear in what order. The compiler checks syntax first. If you forget a semicolon or mismatch a brace, the compiler prints a syntax error and refuses to produce an executable.
Semantics is the meaning of the program — what it actually does when it runs. The compiler checks only enough semantics to translate correctly. Logic errors are semantic errors: the program runs, but does not do what you intended. The compiler cannot help with these.
A typical debugging session, therefore, involves:
Fix syntax errors until the program compiles.
Run the program and observe what it does.
Compare that with what it is supposed to do.
Trace the semantic gap back to its source.
5.6 Style: writing code for humans
Computers do not care how a program is formatted. Humans do. A well-formatted program is easier to read, easier to debug, and easier to maintain.
A few style conventions:
One statement per line. Easier to spot mistakes.
Indentation. Each nested block is indented by a consistent amount — usually four spaces or one tab. The textbook you are reading uses four spaces.
Spaces around operators.a + b is easier to read than a+b.
Descriptive names.sum, velocity, student_count are better than s, v, sc.
Comments. Explain why the code does what it does, not what it does (the code itself shows what).
A program should read like well-structured prose. Use blank lines to separate ideas. Use comments to label sections. Avoid "clever" tricks that save one line but cost five minutes of comprehension.
5.7 A more interesting first program
/* add_numbers.c
Read three integers from standard input and print their sum. */
#include <stdio.h>
int main(void) {
int a, b, c;
printf("Enter three integers, separated by spaces: ");
if (scanf("%d %d %d", &a, &b, &c) != 3) {
printf("Input error.\n");
return 1;
}
printf("%d + %d + %d = %d\n", a, b, c, a + b + c);
return 0;
}
A few new things:
int a, b, c; — three integer variables are declared on one line.
scanf — the input counterpart to printf. It reads characters from standard input and parses them according to the format string. The &a syntax means "the address of a"; it tells scanf where to store the value it reads. We will return to addresses in Chapter 13.
if (...) — a conditional. If the value of scanf is not equal to 3, the program prints an error and returns 1. Otherwise it falls through and prints the sum.
5.8 Common mistakes for beginners
A short list of pitfalls you will encounter repeatedly. Each is worth committing to memory.
Mistake
Result
Missing semicolon
Syntax error.
Missing brace
Cascading syntax errors.
= where == is intended
Assignment instead of comparison. Compiles, runs, often wrong.
Uninitialised variable
Contains whatever was in that memory location. Output looks random.
printf("...", x) without &x for scanf
scanf writes to a random address. Often crashes.
printf with wrong format specifier
Undefined behaviour. Garbage output.
Integer division of two ints
5 / 2 is 2, not 2.5.
Reading past the end of an array
Often a crash; sometimes silent corruption.
Forgetting to free allocated memory
Memory leak; eventually the program or system runs out of memory.
5.9 Summary of Chapter 5
A C program is plain text, conventionally stored in a file ending in .c.
The preprocessor runs first, expanding #include and #define directives.
The compiler then translates the preprocessed text into machine code.
Every C program must contain a function called main. Execution begins there.
A statement is an instruction ending in ;. An expression is anything that has a value.
Comments, indentation, and naming are for humans, not the compiler, but they matter a great deal.
Exercises 5
Compile and run the hello.c program on your own machine. What does gcc -v report? What does ./hello print?
Modify the program to print your name, your registration number, and the date on three separate lines.
Write a program that prompts for two integers and prints their product. Compile and test it.
Modify add_numbers.c so that it prints the average of the three numbers rather than the sum. Watch out: average of integers is integer division unless one operand is a double. Cast a + b + c to double before dividing by 3.0.
Trace. What does the following program print? Predict first, then run it.
``c #include <stdio.h> int main(void) { int i = 5; int j = i++; int k = ++i; printf("%d %d %d\n", i, j, k); return 0; } ``
Debug. The following program is supposed to read an integer and print whether it is even or odd. It compiles. It runs. It always prints "odd". Why?
``c #include <stdio.h> int main(void) { int n; scanf("%d", n); /* missing & */ if (n % 2 == 0) printf("even\n"); else printf("odd\n"); return 0; } `` Fix the bug and verify your fix.
Style. Reformat the following program with consistent indentation and spaces. Identify any other style problems.
``c #include<stdio.h> int main(void){ int x=10,y=20; printf("x=%d,y=%d\n",x,y); return 0;} ``
Chapter
Chapter 6 — Variables, Types, and Operators
6.1 Why variables?
In Chapter 2 we discussed algorithms in the abstract: values lived at memory addresses M1, M2, T1, … and flowed between them under the control of the algorithm. That notation worked on paper, but imagine writing a real program that way. Every value would need a hard-coded address; the algorithm would become a jungle of labels like M17 and T3; and the moment you added one more value, every address after it would shift and the whole plan would need rewriting.
C solves this with variables: human-readable names that stand for memory locations. You write x; the compiler worries about which address x gets. This is the first abstraction the language gives you — the name is yours, the address is the compiler's business.
int x; /* declares x to be an integer variable */
x = 5; /* stores the value 5 at the location named x */
After these two statements, x is the name of a memory location whose current contents are 5. We may later overwrite the contents: x = 36; does not move x to a different place; it changes what valuex holds. The name stays the same; the value varies. That is what the word variable means.
It helps to resist the common picture of a variable as a "box" you put values into. The box image gets one thing right — the contents change while the box stays — but it gets the location wrong. Your program does not choose where x lives. When the compiler sees the declaration int x;, it selects a memory location, notes the address internally, and from then on substitutes that address wherever you write x. The address only becomes visible to you through the & operator, which we meet properly in Chapter 13.
Notice also what the value 5 becomes inside the machine: a bit pattern, per the conventions of Chapter 4. How many bits, and how they are interpreted, is decided by the type of x — the subject of §6.3.
6.2 Names
Every variable (and later, every function and type) needs a name — in C, an identifier. Names exist so that humans can talk about memory locations and functions without quoting addresses. The rules below are the grammar the compiler accepts; the conventions that follow are about writing names a human can read.
C names consist of letters (A–Z, a–z), digits (0–9), and the underscore _. They must not start with a digit. (They may start with an underscore, but two underscores in a row are reserved for the implementation.) They cannot contain spaces or other punctuation.
Two consequences of these rules are worth noticing:
A name may start with _, but practically you should never do so: names beginning with an underscore are conventionally reserved for the implementation (the compiler and its headers). The same applies to names beginning with an underscore followed by a capital letter (_MyVar). If you avoid leading underscores entirely, you will never collide with reserved names.
Because a name cannot contain a space, you cannot write student count. The underscore student_count or the capital studentCount are the standard ways to join words.
C is case-sensitive: Sum, sum, and SUM are three different names. If you declare int Total; and then write total, the compiler reports an undeclared identifier error — not a warning. This surprises newcomers from case-insensitive languages, and it is a very common source of compile errors. There is no "fix" to write; the name must match exactly, including case.
There are two common naming styles:
Snake case: words separated by underscores — add_numbers, student_count, max_value.
Camel case: words joined, with capitals for new words — addNumbers, studentCount, maxValue.
Both are widely used. Pick one and apply it consistently.
Names are also restricted in one more way: a name cannot be one of the keywords the language reserves for itself — int, if, while, return, and so on. The compiler uses those words for its own purposes, so int int = 3; is a syntax error. C has roughly three dozen keywords; you will meet them all by the end of the course.
6.3 Types
Every variable in C must be declared with a type. The type determines how the bits stored at the variable's memory location are interpreted.
This is the lesson of Chapter 4 put into practice. Memory stores nothing but patterns of bits; the pattern 01100101 might be the integer 101, the character 'e', a fraction of a floating-point number, or an instruction. The type is the agreement that fixes the meaning. When you write int x;, you are telling the compiler: "reserve some bytes, and interpret those bytes as an integer from now on." The same bytes interpreted as a float would be a completely different number. This is why the type of a variable is not an optional decoration — it is the very mechanism by which the bits acquire a meaning.
Why C needs types
Other languages hide types from the programmer: a variable in Python or JavaScript can hold an integer now and a string later, and the language sorts out the representation at run time. C does not work that way. The compiler must decide at compile time how many bytes to allocate for x, how to translate x + 1 into a machine instruction, and what to do when x is used where a different type is expected. It can do none of this without knowing the type in advance. Types are therefore a contract: the compiler allocates the right amount of storage, and it can catch a whole class of mistakes before the program ever runs — for example, using an integer where a function expects a floating-point value, or subtracting two pointers when you meant two numbers.
A type determines three things for every variable:
Size — how many bytes of memory it occupies, and hence how many distinct bit patterns it can hold.
Interpretation — how to turn those bytes into a value (Chapter 4's conventions: two's complement for signed integers, plain binary for unsigned, IEEE 754 for floating-point, a code for characters).
Legal operations — which operators apply. You can add two ints, but you cannot meaningfully add two chars-as-characters; % requires integer operands; and so on.
Type
Typical size
Range (typical)
Purpose
char
1 byte
usually −128 to +127 (or 0 to 255)
one character
short
2 bytes
−32 768 to +32 767
small integer
int
4 bytes
about −2.1 × 10⁹ to +2.1 × 10⁹
general integer
long
4 or 8 bytes
depends on platform
larger integer
long long
8 bytes
about −9.2 × 10¹⁸ to +9.2 × 10¹⁸
very large integer
unsigned variants
same as the signed type but ≥ 0
e.g. unsigned int 0 to ~4.3 × 10⁹
non-negative integers
float
4 bytes
about 10⁻³⁸ to 10³⁸, ~7 decimal digits
real numbers
double
8 bytes
about 10⁻³⁰⁸ to 10³⁰⁸, ~15 decimal digits
higher precision real numbers
_Bool / bool
1 byte
0 or 1
boolean values
The integer types are related in a strict order: char ≤ short ≤ int ≤ long ≤ long long, where the ordering is by size. A 4-byte int is not "four times bigger" than a 1-byte char in any meaningful sense — both are just fixed-width bit patterns — but the ordering tells you which types are guaranteed to hold larger values.
Why char is an integer type
A point that confuses many beginners: char is an integer type, not a text type. A char is the smallest addressable unit of memory — typically 8 bits — and the character interpretation is just a convention applied to that small integer. 'A' is really the number 65 (see Appendix C). This is why you can write char c = 'A' + 1; and get 'B', and why a char variable can be used in arithmetic just like a tiny int. We will lean on this heavily in Chapters 15 and 16, where strings are just arrays of these small integers.
Signed versus unsigned
Every integer type comes in signed and unsigned flavours. The signed form uses the leftmost bit as part of two's-complement encoding (Chapter 4), so it can represent negative values; the unsigned form uses that same bit as ordinary place value, doubling the largest positive value. For an 8-bit type: signed char runs from −128 to +127, unsigned char from 0 to 255. The same eight bits can mean −1 or 255, depending on which interpretation you declare. Choosing the wrong one is a classic source of bugs — we will see a live example in §6.7.
The C standard only sets minimum sizes; the actual size of int, long, and pointers depends on the compiler and platform. We will come back to this in Chapter 13. For most modern systems, int is 32 bits and long is 64 bits.
Floating-point types
float and double hold real numbers under the IEEE 754 convention of Chapter 4. The key fact to internalise now: they are not exact for most real numbers. Only finitely many values are representable, so float and double are approximations. Use them when you need fractional values and you can tolerate small errors; use integers when you need exactness. float uses half the memory of double but has roughly half the decimal precision (~7 digits versus ~15), which is why double is the default choice for most work. Section 6.7 shows how these approximations leak into even simple-looking calculations.
_Bool / bool
C originally had no boolean type at all — it used the integer convention 0 = false, non-zero = true (§6.4). C99 added _Bool, a type that can hold only 0 or 1, and the header <stdbool.h> gives it the friendlier names bool, true, and false. It is a type like any other, with a size (typically 1 byte) and a fixed interpretation.
The standard header <stdint.h> provides exact-width types whose sizes are guaranteed:
Type
Exact size
int8_t, uint8_t
1 byte
int16_t, uint16_t
2 bytes
int32_t, uint32_t
4 bytes
int64_t, uint64_t
8 bytes
size_t
unsigned, the size of any object in bytes
These exist precisely because the built-in types have only minimum sizes. If you need a variable that is exactly 32 bits on every machine — for a file format, a network protocol, or a hardware register — int is not enough; int32_t is. Using int32_t instead of int is a good habit when portability matters. Using int is fine for everyday programs that are not expected to be compiled on exotic platforms.
size_t deserves a note. It is an unsigned type wide enough to hold the size in bytes of any object, and it is the type returned by sizeof (§6.7) and by the array-size pattern sizeof(a) / sizeof(a[0]) (Chapter 15). Because it is unsigned, comparing it with a signed value can produce surprises; we will see why in §6.7.
How a type becomes storage: declaration at compile time
When the compiler processes int x;, it does real work: it consults a table of sizes, allocates the corresponding bytes, records the variable's address and type in its symbol table, and forgets nothing until the end of the translation unit. From then on, every use of x is checked against that recorded type. This is what makes C statically typed: the type is fixed at compile time and never changes during execution. A variable declared int cannot hold a string later, and the compiler will reject any code that tries to make it.
6.4 Booleans
In mathematics, a proposition is either true or false. In C, a "truth value" is an integer, and the convention is delightfully simple:
Zero is false.
Anything non-zero is true.
Why does C work this way? Because the hardware has no notion of truth — only bits. A condition in C is evaluated down to an integer, and the branch instruction (if, while) simply tests whether that integer is zero or not. Making "true" mean "anything non-zero" costs the machine nothing: the comparison instructions the CPU executes produce a 0 or 1 anyway, and any integer expression can be tested directly. A dedicated boolean type would add nothing for the hardware and would only constrain what you can write. So C inherited the older convention: a condition is a test for non-zero.
This means you can write if (x) to mean "if x is non-zero", and you can use any integer expression as a condition. That is a real convenience — it lets you write if (count) to test "have we seen anything yet?" without spelling out count != 0 — but it is also a trap: if (x = 5) is a legal condition (§6.6), and a function that returns 0 on success becomes "false" when used as a condition. We will meet that second pattern repeatedly in later chapters.
C99 added a header <stdbool.h> that defines bool, true, and false as synonyms for _Bool, 1, and 0 respectively. Including it makes boolean code read more clearly.
Two things to notice about this snippet. First, bool is a real type (§6.3): running occupies (typically) one byte and can only hold 0 or 1. Second, the ! operator flips the truth value: !running is 1 (true) when running is 0, and 0 when running is non-zero. So the block runs when running is false — exactly what the comment-free code says.
Truth and the ! operator
! is the logical NOT. Applied to any value, it yields 1 if the value is zero and 0 otherwise. It is idempotent in the sense that !!x is 1 if x is non-zero and 0 if x is zero — a neat way to "normalise" any integer into a strict 0/1.
6.5 Declaration and initialisation
A declaration introduces a variable and its type. An initialisation gives it an initial value in the same statement.
int x; /* declaration; x is uninitialised */
int y = 0; /* declaration and initialisation */
int a = 1, b = 2; /* two declarations on one line */
The distinction matters because the two statements do different things at the machine level.
A declaration is a request for storage: the compiler allocates sizeof(int) bytes at some address and records that x names them. It does not put anything there. The bytes contain whatever happened to be in that memory — leftover data from a previously terminated function, part of a string, anything.
An initialisation is a declaration plus a store: the compiler allocates the bytes and emits a machine instruction that writes 0 into them.
Reading uninitialised variables is undefined behaviour: the program may appear to work, or it may crash, or it may output garbage, and the C standard places no requirements on what happens. The "may appear to work" part is what makes it dangerous. A program that reads an uninitialised variable often prints the right answer by pure luck on one run and the wrong answer on the next, because the leftover bytes differed. The failure is intermittent and nearly impossible to reproduce deliberately. Always initialise your variables.
This is one of the most common bugs in real C code, and the C language gives you very little help: unlike some languages, C does not automatically zero your local variables. The default is "garbage", by design — zeroing everything costs time, and C assumes you will take responsibility. In Chapter 12 we will see the one exception: variables with static storage duration are zero-initialised, because the system zeroes the memory region they live in before your program starts.
If you have a long block of declarations, consider initialising each one at the point of declaration:
int students_present = 0;
int rooms_visited = 0;
int current_floor = 1;
Initialisation versus assignment
Do not confuse initialisation with assignment, even though both use =. Initialisation happens once, at the moment the variable comes into existence, and it is how the variable gets its first value. Assignment happens later, any number of times, and changes the value. The distinction is not pedantic: the compiler treats them differently (initialisation can often be resolved at compile time), and some variables — array elements, and later const-qualified variables — can be initialised but not assigned to. In §6.6 we will see why = as assignment-in-a-condition is a hazard; keep in mind that in a declaration, = is initialisation and perfectly safe.
6.6 The single-equals / double-equals trap
The single character = is the assignment operator: it computes the value of its right-hand side and stores it into the left-hand side. The double character == is the equality operator: it compares two values and yields 0 (false) or 1 (true).
To see why this trap exists — and it catches every C programmer eventually — you need to know one fact from §5.4: in C, an assignment is itself an expression. It does not merely perform a store; it yields a value, namely the value that was just assigned. That is the root of both the feature and the bug.
It is a feature when you combine assignment with a comparison, because it lets you read the result of a function and test it in one step:
int x;
if ((x = compute_something()) == 5) {
/* compute_something returned 5 */
}
Read this carefully. compute_something() runs and its result is stored into x. The assignment expression as a whole has that value, so == 5 compares the assigned value against 5. The parentheses around x = compute_something() are essential: without them, x = compute_something() == 5 would assign the result of the comparison (a 0 or 1) into x, because == binds tighter than =.
But the same property makes the following very common bug possible:
int x = 3;
if (x = 5) { /* BUG: assigns 5 to x, then tests whether 5 is non-zero (always true) */
printf("x was 3 but now it is 5\n");
}
Let us trace what actually happens, step by step:
x = 5 performs an assignment: the value 5 is written into x. x is no longer 3.
The assignment expression yields the value 5.
That value is used as the condition of the if. Per §6.4, any non-zero value is true — and 5 is definitely non-zero.
So the body always runs, and the variable x has been silently changed from 3 to 5.
Two separate wrongs compound: the branch is always taken, and a variable you only meant to test has been modified. If the printf did not mention the change, you might never notice the variable was clobbered at all.
The test is always true, because 5 is non-zero. The program quietly does the wrong thing. Compilers will warn you about this if you turn on -Wall. Read those warnings.
The same idea bites a == b == c
Exercise 6 of this chapter asks about a == b == c, and the same "expression has a value" rule explains it. Because == is left-associative and yields 0 or 1, the expression parses as (a == b) == c. If a == b is true (yielding 1), the whole thing tests whether 1 == c; if a == b is false (yielding 0), it tests whether 0 == c. So a == b == c does not mean "all three are equal" — it means "the truth of a == b equals c". The correct way to test that a, b, and c are all equal is a == b && b == c (or a == b && a == c). We will use the same reasoning for every chained comparison from now on.
Defensive habits
Three habits eliminate almost every instance of this bug:
Write comparisons the way the compiler can check: if (x == 5) is fine, but if (5 == x) puts the constant on the left. If you slip and write 5 = x, the compiler reports an error (you cannot assign to a constant), instead of silently producing a condition that is always true. This is the classic "Yoda condition", and while some find it ugly, it converts a silent bug into a compile error.
Compile with warnings enabled: gcc -Wall -Wextra will flag if (x = 5) as a "suggest parentheses around assignment used as truth value" warning. Treat warnings as errors during learning: -Werror makes the build stop.
When you really want assignment inside a condition (the feature case above), write the extra parentheses. They tell the compiler, and the next reader, that the assignment is deliberate.
6.7 Operators
An operator is a symbol that performs a computation. Most operators in C are non-alphabetic symbols (+, -, *, /, etc.); a few are keywords (sizeof).
Every operator works on one, two, or three operands and, like an expression, produces a value. In fact, an operator is precisely what builds expressions: x + y is an expression whose value is computed by applying + to the values of x and y. The operands themselves are usually expressions, which is why expressions can nest — a + b * c applies + to a and to the value of b * c.
Two ideas unify everything in this section, and you should keep them in mind as you read each operator:
Every operator has a type rule. Its operands must be of certain types, and it produces a result of a certain type. Understanding the rule for + on integers vs floats, or for %, prevents a whole class of "why did my program do that?" moments.
Every operator is implemented by real machine instructions. When the compiler sees x + y, it emits an add instruction (§1.5). Seeing operators as thin syntax over CPU operations explains why integer division truncates, why overflow wraps, and why % exists at all.
The most commonly used operators are summarised here. The full precedence table is in Appendix A.
These are the operators of everyday arithmetic, with one crucial twist: the result takes its type from the operands. In Chapter 4 you saw that integers and floats are represented in completely different ways. The machine cannot add a two's-complement integer to an IEEE 754 float in one instruction — the representations are incompatible. So C follows a single rule: if either operand is floating-point, both are converted to floating-point first, and the result is floating-point; otherwise the operation is integer, and the result is integer.
This one rule explains three apparently unrelated behaviours:
Integer division truncates. When both operands of / are integers, the result is integer division: 5 / 2 is 2, not 2.5. The fractional part is discarded. Why? Because the machine instruction for division produces an integer quotient (and a separate remainder — see below); there is no fractional result to keep. To get a fractional result, at least one operand must be a floating-point value: 5 / 2.0 is 2.5. The compiler performs an implicit type promotion to make this work.
int a = 5, b = 2;
double q1 = a / b; /* 2.0, NOT 2.5: division happened in int */
double q2 = a / 2.0; /* 2.5: the 2.0 forced floating-point division */
The first line is the classic beginner surprise: you look at the result and think "2.5", but the division already happened in integer arithmetic before the value was stored in q1. Promotion never converts a value that is already gone. The order is: evaluate the expression (with the operands' types) → get an int → convert that int to double for storage. The lesson: choose your types at the point of the operation, not at the point of assignment. Dividing by 3.0 in the worked example (§6.9) is the same idea.
% is the partner of /.% is defined only for integers: 5 % 2 is 1. On most CPUs, integer division and remainder are computed by the same machine instruction, which returns both the quotient and the remainder. So % is not an exotic feature — it is the leftover half of the division instruction, given its own operator. Its use is exactly that leftover: you need the quotient, or you need the remainder, or (for "is it divisible by n?") you need the remainder to be zero — x % 2 == 0 tests evenness. % is also the reason 5 / 2 truncates toward zero: the C standard guarantees that (a / b) * b + a % b == a, which pins down the behaviour of both operators together.
Arithmetic on signed integers wraps. §4.8 showed that when an integer operation overflows the fixed width, the bits wrap around with no warning. The operator section is where that warning bites: 2000000000 + 2000000000 does not become 4000000000; it becomes the two's-complement re-reading of the wrapped bits, which for signed int is a negative number. For signed overflow this is undefined behaviour (the compiler may assume it cannot happen, which leads to the subtle behaviour we will discuss in Chapter 14); for unsigned it is well-defined modulo arithmetic (§4.8).
Mixed signed and unsigned is where bugs live. When one operand is signed and the other unsigned, the C rule is that the signed value is converted to unsigned first. If the signed value is negative, this conversion is a huge number (e.g. -1 becomes 4294967295 as an unsigned 32-bit value), and the comparison or arithmetic quietly goes wrong:
int i = -1;
unsigned int u = 1;
if (i < u) printf("yes\n"); /* prints NOTHING: -1 converts to a huge unsigned */
Why does C do this? Because converting in the other direction — making the unsigned value signed — would overflow for large unsigned values, which is worse. So C errs on the side of the unsigned representation, and the programmer pays for it with surprises like this one. The rule of thumb: avoid mixing signed and unsigned in the same expression. If you must compare, convert explicitly (e.g. if (i < (int)u) when you know the value fits, or use a signed type for both). The %d/%u format mismatch in printf is a milder relative of the same disease — the bits are the same, but the interpretation differs (Chapter 4's lesson again).
6.7.2 Assignment
= simple assignment: x = 5;
+= add and assign: x += 3; /* same as x = x + 3; */
-= subtract and assign: x -= 3;
*= multiply and assign: x *= 3;
/= divide and assign: x /= 3;
%= remainder and assign:x %= 3;
We already met = in §6.5 and §6.6. The compound forms are abbreviations: x += 3 means "add 3 to the current value of x, and store the result back into x". They are not magic — they are read-modify-write, the same sequence of load, compute, store that x = x + 3 compiles to. The compound form has two genuine advantages: it reads left-to-right (x += 3 says x, then grow by 3) rather than repeating the left-hand side, and — importantly for later chapters — it evaluates the left-hand side once, which matters when the left-hand side is something expensive like arr[compute_index()] (Chapter 15). For a plain variable the two forms compile identically.
The compound assignment operators are usually clearer when the left-hand side is a long expression.
6.7.3 Increment and decrement
++ increment by 1: ++x; x++; /* pre- vs post-increment */
-- decrement by 1: --x; x--;
Increment and decrement are so common in C that they have their own operators and their own syntax: x = x + 1 is written x++. Every C programmer uses these constantly — in loops (Chapter 8) and in pointer walking (Chapter 13). They are also where beginners first meet the difference between a value and a side effect, so we look at them closely.
Every operator in C yields a value. An operator that also changes the operand has a side effect. ++ does both: it changes x, and it yields a value. The question is which value, and that is the whole pre/post distinction:
++x increments x first and then yields the new value.
x++ yields the old value first and then increments.
int x = 5;
int a = ++x; /* x becomes 6, a is 6 */
int b = x++; /* b is 6, x becomes 7 */
Think of it as where the side effect happens relative to the reading: in ++x, the machine loads x, adds 1, stores, and the stored value is the expression's value; in x++, the machine loads x, uses that as the expression's value, and only then stores the incremented result. In both cases x ends up incremented — the only difference is the value the expression yields.
When the value is unused — the common case — pre and post are identical in effect: x++; and ++x; both leave x one greater. The difference only matters when the value is used, as in the example above.
Mixing pre- and post-increment on the same variable inside the same expression is undefined behaviour:
int i = 5;
i = i++; /* undefined */
i = ++i; /* undefined */
The reason is not mysticism: C guarantees only that i's side effects are sequenced relative to other whole expressions, not relative to each other within one expression. In i = i++; the read of i, the increment of i, and the assignment to i all happen in unspecified order, so the result depends on the compiler's scheduling. Different compilers, different flags, different answers — and sometimes a warning. The compiler will warn. Do not write code that triggers these warnings.
6.7.4 Comparison operators
== equal to
!= not equal to
< less than
<= less than or equal
> greater than
>= greater than or equal
Each yields 0 (false) or 1 (true). The comparison operators are the way a program makes a decision: the CPU's comparator circuitry tests two values and produces a condition flag; the C operator turns that flag into the integers 0 and 1. That is also why §6.6's chained comparisons misbehave — each comparison is a function from two values to {0, 1}, and the result feeds the next comparison as an ordinary integer.
Three cautions are worth stating now, because all three recur:
== and = are different (§6.6). One compares; one assigns.
Never compare floating-point values with == except in special circumstances (§4.10). Because float and double are approximations, 0.1 + 0.2 == 0.3 is false on most machines, and even x * 2.0 == y can fail for values that "should" match. Compare with a tolerance: fabs(x - y) < 1e-9.
Mixed signed/unsigned comparison can invert your logic (§6.7.1). A negative int is "greater than" an unsigned int after conversion, because it becomes a huge positive number.
6.7.5 Logical operators
&& logical AND
|| logical OR
! logical NOT
Logical operators work on integers, treating zero as false and non-zero as true. The result of && and || is always exactly 0 or 1 — never a "truthy" 5. So 5 && 3 is 1, not 5. (This is a point where C is more disciplined than several later languages.)
Two properties make these operators special, and both follow from one fact: they are defined to evaluate their operands only as needed. The C standard guarantees that && and ||short-circuit:
a && b does not evaluate b if a is zero — once a is false, the whole AND is false regardless of b.
a || b does not evaluate b if a is non-zero — once a is true, the whole OR is true regardless of b.
This is not an optimisation; it is a guaranteed rule of the language, and programs rely on it. The classic use is guarding a dereference: you cannot access p->count if p is NULL, but you can test p != NULL first, and if it fails the && never evaluates the dangerous part:
if (p != NULL && p->count > 0) { /* safe: p->count is not accessed if p is NULL */ }
If && evaluated both sides unconditionally, this line would crash on a NULL pointer. The rule exists precisely so such guards are safe. The same reasoning applies when the second operand has side effects: flag || printf("warn\n") prints the warning only when flag is false, because the right operand is evaluated only when the left is non-zero.
Short-circuiting is the reason the order of the operands matters. Put the cheap test and the guard first, and the expensive or dangerous one second.
6.7.6 Bitwise operators
These operate on the individual bits of integer values. They are covered in detail in Chapter 22.
& bitwise AND
| bitwise OR
^ bitwise XOR (exclusive OR)
~ bitwise NOT (ones complement)
<< left shift
>> right shift
What matters now is the distinction between the bitwise operators and the logical operators of §6.7.5, because the symbols are easy to confuse. && works on truth values: it asks "is both non-zero?" and answers 0 or 1. & works on every bit independently: it combines two values bit by bit. For example, 5 & 3 is 1:
0101 (5)
& 0011 (3)
= 0001 (1)
And 5 && 3 is 1 as well here, but for a different reason — because both are non-zero. They can also disagree: 4 && 2 is 1 (both non-zero), while 4 & 2 is 0 (no bit is set in both). One operates on the whole value, the other on the bits. Keep them distinct, and remember that ~ (bitwise NOT) and ! (logical NOT) are similarly unrelated. Bitwise operators are everywhere in embedded and systems code — setting flags in a status register, packing fields, fast multiplication and division by powers of two — which is why Chapter 22 is devoted to them.
6.7.7 The ternary operator
The ternary operator is the only C operator that takes three operands. Its syntax is:
<condition> ? <value-if-true> : <value-if-false>
For example:
int abs_x = (x < 0) ? -x : x;
It is equivalent to:
int abs_x;
if (x < 0) abs_x = -x;
else abs_x = x;
The ternary is an expression: it evaluates the condition, then evaluates exactly one of the two branches and yields that branch's value. Because it is an expression, it can appear where if statements cannot — inside a larger expression or an argument list. That is both its power and its danger: an if/else makes the two paths visually obvious, while a ternary compresses them into one line. Use it sparingly. Nested ternaries are unreadable.
6.7.8 The comma operator
The comma operator evaluates its left operand (for side effects), discards its value, evaluates its right operand, and yields its value.
int a, b;
a = (b = 3, b + 2); /* sets b to 3, then sets a to 5 */
The comma is the C way of sequencing two operations where the language otherwise expects a single expression. It is rarely needed because C has real statements for sequencing — but it survives in one genuinely useful place, the for loop header (Chapter 8), where the language expects exactly one expression in each slot and the comma lets you put two (e.g. two loop variables). Outside of for headers, the comma operator is rare and confusing.
Do not confuse the comma operator with the comma in a declaration or a function call — int a, b; and f(1, 2) use the comma as a separator, not an operator, and the rules are different (a separator does not sequence or discard anything).
6.7.9 sizeof
sizeof is a unary operator that yields the size, in bytes, of its operand. The operand may be a type or an expression.
sizeof(int) /* size of an int, e.g. 4 */
sizeof x /* size of variable x */
sizeof(x + 1) /* size of the type of x + 1 */
sizeof(int[10]) /* size of an array of ten ints, e.g. 40 */
sizeof is evaluated at compile time by the compiler. It is not a function call — it does not run when the program runs, and it never evaluates its operand. sizeof x is answered by the compiler's knowledge of x's type, not by reading x's value at run time. That is why sizeof(x + 1) works even if x is uninitialised (the expression is typed but never executed), and why you cannot sizeof a value whose type the compiler does not know.
The result is of type size_t, an unsigned integer type defined in <stddef.h> or <stdio.h>. Two practical consequences follow:
size_t is unsigned, so sizeof results participate in the signed/unsigned hazards of §6.7.1. Comparing sizeof(x) with a signed int can misbehave.
Because size_t is unsigned, a common idiom that looks like it should count down backwards can fail — a detail we meet properly in Chapter 15, where sizeof(a) / sizeof(a[0]) becomes the standard way to count the elements of an array.
You will see sizeof in three recurring roles: array element counts (Chapter 15), allocating exactly the right number of bytes with malloc (Chapter 19), and code that must work on platforms of different word sizes (Chapter 13).
6.8 Operator precedence
When an expression has multiple operators, C uses precedence rules to decide which to evaluate first. Multiplication binds tighter than addition: 1 + 2 * 3 is 7, not 9. Most of the time precedence does what you expect from mathematics, but a few surprises exist.
Precedence is the answer to a question the language must answer: the CPU executes one operation at a time, so a compound expression like 1 + 2 * 3 must be broken into a sequence of individual operations. C resolves it by ranking operators: the higher-precedence operator (multiplication) attaches its operands first, and the lower-precedence one (+) combines the result. You can think of precedence as deciding where the implicit parentheses go: 1 + 2 * 3 is really 1 + (2 * 3), and 2 * 3 + 4 * 5 is (2 * 3) + (4 * 5).
Three ideas beyond "multiplication before addition" are worth knowing, because C's precedence table was not designed for intuitive math:
Every operator has a fixed rank.*, /, % rank above +, -. Comparisons rank below arithmetic. Logical && ranks below the comparisons; || ranks below &&. Assignment ranks lowest of all — almost everything binds tighter than =. This is why x = a + b means "add, then assign" and not the reverse.
Associativity decides ties. When two operators have equal precedence, they group left-to-right (for most binary operators): a - b - c is (a - b) - c, which matters because subtraction is not associative. Assignment groups right-to-left: a = b = c assigns c into b, then that value into a.
A few combinations are counter-intuitive. These are the ones that routinely surprise:
- << and >> bind tighter than < and <=, so a < b << 2 is parsed as a < (b << 2) — not (a < b) << 2. - == binds looser than & (bitwise AND), so a & b == c is parsed as a & (b == c). This is a favourite source of "it compiles but the answer is wrong" bugs. - ! binds tighter than most relational operators, so !a < b is parsed as (!a) < b. Usually you mean !(a < b) — and you should write exactly that.
The last point deserves emphasis because it is a real failure mode, not a trivia item. Consider a & b == c. Following the table, the compiler computes b == c (a 0 or 1) and ANDs it with a. The code compiles. The program runs. The result is almost never what the author wanted — and no warning is produced, because the expression is entirely legal. Precedence mistakes are silent mistakes.
When in doubt, add parentheses. Compilers accept redundant parentheses and they make the code unambiguous. Prefer clarity to cleverness: (a & b) == c and a & (b == c) are both explicit, and only one of them is what you meant.
The full precedence table is in Appendix A.
6.9 Putting it together: a worked example
Before you read the program, work out the types of every subexpression, because the whole example is a demonstration of type rules in action.
The program reads three integers a, b, c, and computes two statistics: the mean and the standard deviation. The mean is the average:
mean = (a + b + c) / 3
and the standard deviation measures how spread out the values are around the mean:
The first expression is a type trap: a + b + c is an int sum, and dividing it by 3 would give integer division (§6.7.1) — the mean of 1, 2, and 100 would come out as 34, not 34.33. The code avoids this by dividing by 3.0, a double literal, which forces the division to happen in floating-point. The second expression deliberately uses double arithmetic everywhere: a - mean subtracts an int from a double, so the int is promoted and the result is a double; squaring and averaging all stay floating-point. There is not a single accidental integer division in the whole program.
/* stats.c
Compute the mean and standard deviation of three integers. */
#include <stdio.h>
#include <math.h>
int main(void) {
int a, b, c;
printf("Enter three integers: ");
if (scanf("%d %d %d", &a, &b, &c) != 3) {
printf("Input error.\n");
return 1;
}
double mean = (a + b + c) / 3.0;
double variance = ((a - mean) * (a - mean) +
(b - mean) * (b - mean) +
(c - mean) * (c - mean)) / 3.0;
double stddev = sqrt(variance);
printf("Mean: %.3f\n", mean);
printf("Std dev: %.3f\n", stddev);
return 0;
}
Two notes:
The sum a + b + c is computed as an int. To make the division produce a double, we divide by 3.0 (a literal double), which forces the compiler to convert.
sqrt is from <math.h>. To link the program, compile with -lm on Linux or macOS:
A variable is a name for a memory location. Its value changes; its name does not. The compiler, not your program, chooses the address.
C is statically typed: every variable has a fixed type declared at compile time. The type fixes the size, the interpretation of the bits, and the legal operations.
Common types: char, short, int, long, float, double, and their unsigned variants. char is a small integer, not a "text" type.
The exact-width types (int32_t, etc.) and size_t are defined in <stdint.h>; use them when size or portability matters.
C treats 0 as false and any non-zero value as true. && and || yield exactly 0 or 1, and short-circuit — the second operand runs only if needed.
Reading uninitialised variables is undefined behaviour, because a declaration reserves storage but stores nothing; initialisation is a declaration plus an initial store.
= assigns; == compares. Because assignment yields a value, if (x = 5) is always true and silently changes x. Parenthesise deliberately when combining them.
Arithmetic operators take their type from their operands: integer division truncates, % is the remainder partner of /, mixed signed/unsigned comparisons convert the signed value to unsigned, and signed overflow wraps (undefined behaviour).
++/-- change a variable and yield a value; pre- and post- forms differ in which value that is. Two increments on the same variable in one expression are undefined.
The bitwise operators (&, |, ^, ~, <<, >>) work on bits; the logical operators (&&, ||, !) work on truth. Do not confuse them.
sizeof is a compile-time operator returning a size_t byte count; it never evaluates its operand.
Precedence fixes where the "implicit parentheses" go. a & b == c is a & (b == c). When in doubt, add parentheses.
Exercises 6
Declare three variables: one int, one float, one char. Initialise each with a sensible value and print them all with one printf call using %d, %f, and %c.
What is the value of 7 / 2 in C? Of 7 / 2.0? Of 7.0 / 2? Of (int)(7.0 / 2)? Write a tiny program to verify each.
Without running it, predict the output of:
``c int a = 1, b = 2, c = 3; int x = a++ + ++b * c--; printf("%d %d %d %d\n", a, b, c, x); `` Then run it. Was your prediction right?
Write a program that reads two integers and prints their quotient and remainder. Use the % operator for the remainder.
Write a program that reads an integer n and prints whether it is divisible by 3, by 5, by both, or by neither. (A number divisible by both is a multiple of 15.)
The expression a == b == c does not test whether a, b, and c are all equal. Explain why, and write the correct expression.
Precision. Floating-point arithmetic is not exact. Write a program that prints the value of 0.1 + 0.2 and of 0.1 + 0.2 == 0.3. Explain the output.
Chapter
Chapter 7 — Control Flow
Interactive model
Trace a loop
Step through a simple branch and watch the state change.
Start → test condition → execute body → update → test again.
7.1 What control flow means
A straight-line program executes its statements one after another, top to bottom. Most interesting programs cannot be written that way: they must choose between alternatives, repeat operations, or skip work under certain conditions. A program that reads a number and prints "negative" or "positive" has to go down one path or the other depending on the input — it cannot decide at compile time which statements to run. The ability to change course during execution is not a luxury; it is what turns a recipe into a program.
The mechanism by which a program chooses which statement to execute next is called control flow. We already met the underlying hardware concept — the program counter and the branch instruction — in Chapter 1. This chapter shows the C syntax that maps directly onto those hardware concepts.
It is worth recalling that connection now, because it explains how an if works at the lowest level. In §1.9 and §1.10 we saw that the CPU keeps a special register, the program counter, holding the address of the next instruction to execute. Normally it advances by one after each instruction. A branch instruction does something different: it overwrites the program counter, so the next instruction is taken from somewhere else — possibly skipping over a whole block of code. When the compiler processes if (n < 0) { ... }, it emits exactly that: a comparison instruction that sets a condition flag, and a conditional branch that jumps over the body when the comparison is false. Every control-flow construct in this chapter is, at bottom, a branch dressed in more readable syntax. The higher-level structure of the language hides the jump, but the jump is what actually happens.
7.2 Structured programming
In the 1960s, a pair of computer scientists named Böhm and Jacopini proved a remarkable theorem: any computation a Turing machine can perform can be expressed using only three kinds of control-flow operations:
Sequencing — executing statements one after another.
Selection — choosing between two paths based on a condition.
Iteration — repeating a sequence of statements.
These three primitives are the basis of structured programming, a way of writing programs that replaced the older and more error-prone practice of jumping around using goto statements. (Edsger Dijkstra's famous 1968 paper Go To Statement Considered Harmful argued against the older style.)
The theorem is surprising, so let us make its content plain. Before structured programming, a program might contain dozens of goto statements jumping both forward and backward, and the flow of control could resemble spaghetti — which is why that style is still called spaghetti code. The Böhm–Jacopini theorem says none of that freedom is necessary: if you can nest these three constructs — a selection inside a loop inside a selection, and so on — you can express anything a program can compute. The benefit is that a program built only from blocks with a single entry point and a single exit is far easier to reason about: when you read the body of an if, you know the program entered it from the top and leaves it at the bottom, and nothing outside the block can change what happens inside it. That is the property that makes programs provable and debuggable.
C supports all three. The constructs that implement them are:
Primitive
C constructs
Sequencing
function call, statement list
Selection
if, if/else, switch/case
Iteration
while, do/while, for
Two remarks before we start. First, sequencing is so natural you may not think of it as a "construct" at all: a function body is a list of statements that runs top to bottom, and a function call transfers control to another sequence and brings it back. Second, C does retain a goto keyword (§23), but you should think of it the way engineers think of a manual override — a mechanism for rare, carefully-reviewed situations, not a routine tool. Everything you can write with goto you can write more readably with the three primitives.
The rest of this chapter covers selection. Iteration is in Chapter 8.
7.3 The if statement
The simplest selection construct is the if statement:
if (condition) {
/* statements to run when condition is true */
}
The parenthesised condition is an expression. C treats zero as false and any non-zero value as true, so the condition can be any integer expression.
Read the syntax carefully: if is followed by a parenthesised condition, then a block of statements in braces. The parentheses are not optional decoration — they are part of the grammar, the way the compiler knows where the condition ends. The braces group any number of statements into a single unit; without them, only the next statement is conditional (the trap of §7.6).
Execution proceeds like this: the condition is evaluated to an integer value; if it is non-zero (true), control enters the block; if it is zero (false), control skips the whole block and continues at the statement after it. At the machine level this is the conditional branch described in §7.1: the compiler emits a test followed by a jump over the block's instructions when the condition fails.
A worked example:
#include <stdio.h>
int main(void) {
int n;
printf("Enter an integer: ");
scanf("%d", &n);
if (n < 0) {
printf("%d is negative.\n", n);
}
return 0;
}
What makes this a program rather than a statement: for n = 5 the block is skipped entirely and the program goes straight to return 0;, printing nothing; for n = −5 the block runs. The same source text describes both behaviours, because the decision happens at run time based on the value scanf stored. That is control flow: one program, many possible paths, chosen when the program runs.
Notice that the condition is an expression, not a statement. You could write if (n % 2) to test oddness, if (count) to test non-zero-ness, or any expression at all — including a function call if (read_input()). The only requirement is that it evaluates to an integer (§6.4).
7.4 The if / else statement
To do one thing if a condition is true and something else if it is not, attach an else:
The else is always a complement of an if. It cannot start a program; it is always the alternative.
The key idea is that if/else guarantees exactly one of the two branches runs, never both and never neither. The condition is evaluated once, and whichever branch it selects executes; then control continues after the whole construct. This is the "selection" primitive of §7.2. The machine implementation is two branches: one conditional jump to branch B if the condition is false, and one unconditional jump after the construct so branch B does not also run branch A.
A common pattern is a chain of else if clauses for multiple mutually exclusive cases:
if (n < 0) { /* handle negative */ }
else if (n == 0) { /* handle zero */ }
else if (n < 10) { /* handle small positive */ }
else { /* handle large positive */ }
There is no special "else if" keyword in C — else if is just else whose block happens to be another if statement. The chain above is really:
The indented form is a legal but painful way to write it; the flat else if chain is the same structure with the nesting pulled out of sight. Understanding that it is still nested matters for one reason: evaluation stops at the first true condition. In the chain, if n is negative, the first branch runs and none of the later conditions are even checked. The conditions are tested top to bottom, in order, and the first one that is true wins. This ordering is a feature — it is how you express "otherwise" — and it is also the source of a classic bug: if you check score >= 70beforescore >= 90, the "good" students will be misclassified. Exercise 6 of this chapter is exactly that trap.
The final else is optional but recommended — it catches the case the programmer did not think of. A chain that ends with else can never fall through with no branch taken, so an unexpected value will at least be noticed rather than silently ignored.
7.5 Truth in C: 0 and non-zero
Because C has no native boolean type, the conditions in if, while, and for are interpreted as integers:
The value 0 is false.
Any non-zero value is true.
This is the §6.4 convention, and it is worth stating here again because it is the whole contract of a condition. When the compiler evaluates if (expr), it computes expr's value and tests it against zero — nothing more. There is no hidden "boolean conversion" beyond that test, which is why the condition may be any integer expression, including one with side effects.
The most important consequence is that a condition is not a statement about equality; it is a test for non-zero. Three everyday patterns all fall out of this:
if (ptr) — test whether a pointer is non-NULL. Since NULL is 0, this is exactly if (ptr != NULL).
if (count) — test whether a counter is non-zero. "Have we seen anything yet?" without spelling out the comparison.
if (x = 5) — the trap of §6.6: an assignment whose value is 5, always true, and it silently changes x too.
That last pattern is why this section exists. The convention is sometimes convenient, but it is also the reason a whole class of bugs is possible: any expression, even a wrong one, can serve as a condition, and the program will run with the wrong truth value instead of refusing to compile. Compilers can help, but only if you enable warnings — if (x = 5) triggers a "suggest parentheses around assignment used as truth value" warning under -Wall. Read those warnings.
Two related conventions complete the picture. Comparison operators yield exactly 0 or 1 (§6.7), so a condition like n < 0 produces precisely one of those two values. And logical operators&&, ||, ! also yield 0 or 1, so you can build compound conditions: if (a > 0 && b > 0) tests both. Because && and || short-circuit (§6.7), you can also guard — if (i < n && arr[i] > 0) never reads arr[i] past the end of the array, because the second operand is skipped when the first is false. Guarding with short-circuiting is one of the most useful patterns in all of C, and you will use it constantly.
7.6 Braces and the single-statement trap
If the body of an if is a single statement, the braces { } are optional:
if (x > 0) printf("positive\n");
This is legal but fragile. A programmer who later adds a second statement inside the if will forget to add braces, and only the first statement will be conditional. The second will always run, regardless of the condition.
if (x > 0)
printf("positive\n");
printf("always runs\n"); /* not actually inside the if */
The visual indentation suggests the second printf is inside the if, but C follows braces, not indentation. Always use braces, even for one-line bodies.
Why does the language even allow the braces to be omitted? Because of §3.7: C is free-form, and the grammar is deliberately small. A single statement was made the minimal legal body — a rule inherited from the desire to keep the language compact — rather than requiring a compound statement (a block) everywhere. The cost of that small grammar is exactly this trap, and every C programmer has been bitten by it at least once.
The deeper lesson is that in C, indentation is a lie the compiler does not honour. In a language like Python, indentation is the block structure, so the indented second printf above would genuinely be inside the if. In C, indentation is purely cosmetic (§3.7): it signals intent to the reader, but the compiler groups statements only by braces. When the two disagree, the compiler wins and the reader — usually the author, later — loses.
The fix is a habit, not a language feature: always write the braces, even for a one-line body. It costs two characters now and prevents a debugging session later. If you keep this habit, the "add a second statement and forget the braces" bug becomes impossible by construction. The house style in §7.10 formalises it.
7.7 The switch statement
When you want to choose between several possibilities based on a single integer value, the switch statement is more compact than a long if / else if chain:
#include <stdio.h>
#define APPLE 1
#define MANGO 2
#define BANANA 3
int main(void) {
int fruit = MANGO;
switch (fruit) {
case APPLE: printf("an apple a day...\n"); break;
case MANGO: printf("mangoes are tropical.\n"); break;
case BANANA: printf("bananas have potassium.\n");break;
default: printf("unknown fruit.\n"); break;
}
return 0;
}
The expression in switch (...) must be an integer type (_Bool counts as an integer type). Each case label gives a constant value to compare against. The default label is taken when none of the cases match; it is optional.
Where if/else chains and switch differ is in the machinery, and that difference is why switch exists. An if/else if chain tests its conditions one at a time, top to bottom — a linear search that can evaluate many conditions before finding a match. A switch is closer to a jump table: the compiler can compute, from the value alone, exactly where to jump, without testing the intervening cases. The source-level comparison happens at compile time, when the compiler checks that each case constant is unique; at run time, the machine often performs a single indexed jump. This is not a promise the language makes — a compiler may translate switch any way it likes — but it is the reason switch is the natural choice for a dispatch on many values, and why it feels like a different construct rather than sugar.
A switch is not a general replacement for if/else if, though. It can only test equality against integer constants. You cannot write switch (x) { case x > 0: ... }; a range test like score >= 70 cannot be a case label. When your decision is "is this value equal to one of these constants", use switch; when your decision is "how does this value relate to others", use if/else if.
Why break matters
Each case ends with break;. Without it, execution falls through into the next case — usually not what you want. If you forget break in case APPLE, the case MANGO code runs as well. This is occasionally useful but is more often a bug.
Fall-through is the most distinctive and most surprising property of switch, so it is worth understanding precisely. A case label does not delimit a block, as the indentation suggests; it is merely a jump target inside a single block of statements. The compiler's job is only to find which label to jump to. Once control lands on that label, it executes statements in ordinary top-to-bottom order until it is explicitly stopped — and the only thing that stops it, short of reaching the end of the switch, is break (or return). So break; is not "part of the case"; it is the command that says "leave the switch now". This is the historical design: switch descends from the hardware jump-and-continue model, where arriving at a label and running on was the natural behaviour, and stopping had to be requested.
switch (score / 10) {
case 10:
case 9:
case 8: printf("good\n"); break; /* 80-100 falls into one branch */
case 7:
case 6: printf("average\n");break; /* 60-79 */
case 5: printf("poor\n"); break; /* 50-59 */
default: printf("fail\n"); break;
}
Here the deliberate fall-through (cases 10, 9, 8 stacked on top of each other) is a tidy way to express ranges. When score is 95, score / 10 is 9, control jumps to case 9:, and — because case 9 has no statements and no break — it falls straight through to the statements under case 8, printing "good". Empty cases stacked together are the idiomatic use of fall-through: several labels, one shared body. The accidental fall-through is the opposite case: a non-empty case forgetting its break, letting one branch's statements run into the next. Compilers can flag missing break under warnings such as -Wimplicit-fallthrough, which is worth enabling.
7.8 A first non-trivial example: the quadratic solver
The if statements so far have been one or two branches. Real programs need several decisions in sequence, and the quadratic solver shows how control flow combines into a small decision tree. Here is the program:
/* quadratic.c
Compute the real roots of a*x*x + b*x + c = 0.
If the discriminant is negative, print a message. */
#include <stdio.h>
#include <math.h>
int main(void) {
double a, b, c;
printf("Enter a, b, c (separated by spaces): ");
if (scanf("%lf %lf %lf", &a, &b, &c) != 3) {
printf("Input error.\n");
return 1;
}
if (a == 0.0) {
printf("Not a quadratic (a must be non-zero).\n");
return 1;
}
double disc = b * b - 4.0 * a * c;
if (disc < 0.0) {
printf("Discriminant is negative; roots are complex.\n");
} else {
double sqrt_disc = sqrt(disc);
double x1 = (-b + sqrt_disc) / (2.0 * a);
double x2 = (-b - sqrt_disc) / (2.0 * a);
printf("Roots: %.6f and %.6f\n", x1, x2);
}
return 0;
}
Trace the decision tree by hand before running it, because that is the skill this chapter is building. The program makes three independent decisions:
Did the input parse? If scanf did not read exactly three numbers, print an error and stop. This is not a mathematical decision at all — it protects against bad input.
Is it really a quadratic? If a is zero, the "quadratic" formula would divide by zero. Print a message and stop.
Are the roots real? If the discriminant b² − 4ac is negative, the square root is undefined in real arithmetic; print a message. Otherwise compute and print both roots.
The first two decisions end with return 1; — they abandon the computation. The third decides between two real outcomes and continues. So the program is a funnel: at each stage it either rejects the input and exits, or passes to the next stage. By the time execution reaches the discriminant test, we know the input is valid and the quadratic is genuine.
Several patterns to note:
The early returns (return 1;) inside if blocks act as guard clauses. They check for invalid input up front and bail out before any real work begins. Guards are the structured-programming way to handle errors: instead of nesting the entire computation inside an if (valid) block, you test each condition and leave early, so the main body of the function reads as the happy path only. This is why the "final else is optional" advice of §7.4 pairs naturally with guard clauses — the guards make the later code's assumptions explicit by checking them first.
The format specifier %lf is for double in scanf. In printf, %f works for both float and double.
The use of floating-point literals (0.0, 4.0, 2.0) ensures the arithmetic happens in double rather than int. Writing 4 * a * c would convert the result back to int and lose precision.
Note the two if statements are sequential, not else if chained: a == 0.0 and disc < 0.0 are independent questions, and a quadratic with a != 0 still needs to test the discriminant. Chaining them with else would be a bug — it would make the discriminant test run only when a == 0. This is the flip side of §7.4: use else when cases are mutually exclusive; use separate ifs when decisions are independent.
7.9 Common mistakes
Forgetting braces. When extending a one-statement body, forgetting to add braces silently moves the second statement outside the if. The root cause is the free-form grammar of §7.6: a single statement is a legal body, and indentation is not structure. Always use braces.
if (x = 5) instead of if (x == 5). The root cause is §6.6: assignment yields a value, so the condition is always true, and the variable is silently modified as a bonus. Compilers warn. Always read the warnings.
Comparing floats with ==. Floating-point arithmetic is approximate (§4.9). 0.1 + 0.2 == 0.3 is false. The root cause is that neither 0.1 nor 0.2 is exactly representable, so their sum rounds differently from 0.3. To compare floats, test whether their difference is small: if (fabs(x - y) < 1e-9).
Missing break in switch. A classic source of bugs. The root cause is the jump-target model of §7.7: a case does not delimit a block, so execution continues into the next case unless break stops it. Enable -Wimplicit-fallthrough.
Wrong ordering in an else if chain. When the conditions are ranges, the order decides the outcome: checking score >= 70 before score >= 90 misclassifies every score of 90 or more. The root cause is that the first true condition wins (§7.4). Test the most specific or extreme cases first.
Testing a == 0 for a float that is not exactly zero. Related to float comparison: a computed coefficient may be 0.0000000001 rather than 0.0. For inputs from the keyboard this is rarely an issue, but when values are computed, a near-zero check like fabs(a) < 1e-12 is safer. (This book keeps a == 0.0 in §7.8 because the input is typed, and it avoids confusing the lesson.)
Unreachable dead code after return in a guard. Statements after an unconditional return 1; inside an if can never run. The compiler may warn with -Wunreachable-code. If you see the warning, you probably put a statement after a guard by mistake.
7.10 A note on style
The C standard allows you to omit braces for one-line bodies, to place multiple statements on one line, to use exotic forms like if (1) { } for unconditional blocks, and so on. The compiler accepts all of these. Your colleagues and your future self will not. Choose a consistent style and stick to it.
This section matters more than it looks, because of a fact established in §7.6: the compiler does not see indentation. If you write programs whose indentation says one thing and whose braces say another, the code is actively misleading — the reader is led toward the wrong mental model, and bugs hide exactly there. Style is not decoration; it is the way you keep the human-level structure of the program aligned with its actual structure. The cost of consistency is small; the cost of inconsistency is measured in debugging hours.
A reasonable house style for this textbook:
Always use braces around the body of if, else, while, for, do/while.
Indent by four spaces per level.
Put a single space between if and (.
Keep lines under 80 characters.
The single most valuable of these is the first. It converts the entire class of brace bugs in §7.6 and §7.9 into things that cannot be written. The others are conventional; what matters is that you adopt one set and apply it everywhere, so that when you read a program, the visual layout is a trustworthy map of the control flow.
7.11 Summary of Chapter 7
Control flow is the mechanism by which a program chooses which statement to execute next; at the hardware level it is the program counter and the branch instruction.
C supports the three primitives of structured programming: sequencing, selection, and iteration. if, if/else, and switch/case implement selection; iteration is Chapter 8.
if evaluates its condition as an integer and runs its block when the value is non-zero; otherwise it skips the block.
if/else guarantees exactly one of two branches runs. else if is a nested if, and the first true condition wins — so order matters in a chain.
C treats 0 as false and any non-zero value as true; the condition in an if may be any integer expression.
= assigns; == compares. Read compiler warnings.
A switch dispatches on equality against integer constants — often a jump table rather than a linear search. A case is a jump target, not a block: without break, execution falls through.
Always use braces, even for one-statement bodies; C follows braces, not indentation.
Exercises 7
Without running it, predict the output of:
``c int x = 3; if (x = 0) printf("A\n"); else if (x) printf("B\n"); else printf("C\n"); `` Explain. Then run it.
Write a program that reads three integers and prints the largest. Do not use a function; use only if, else, and basic operators.
Write a program that reads a year (integer) and prints whether it is a leap year. The rule: divisible by 4, except for years divisible by 100, except for years divisible by 400. So 2000 was a leap year; 1900 was not.
Write a program that implements a simple calculator. Read two numbers and an operator (+, -, *, /) and print the result. Use a switch statement on the operator.
The following code is supposed to print "negative" if x is negative and "non-negative" otherwise. It compiles, runs, and prints "negative" for x = 0. Why?
``c if (x < 0) printf("negative\n"); else if (x > 0) printf("positive\n"); else if (x == 0) printf("zero\n"); else printf("non-negative\n"); ` What does it actually print for x = 0`? Fix the logic so that all three cases (negative, zero, positive) are handled.
Debug. A student writes the following grade-classifier. It compiles. It runs. For an input of 75 it prints "C". For an input of 105 it prints "A". Diagnose and fix.
``c int score; scanf("%d", &score); if (score >= 90) printf("A\n"); else if (score >= 80) printf("B\n"); else if (score >= 70) printf("C\n"); else if (score >= 60) printf("D\n"); else printf("F\n"); ` *Hint:* look at the order of the comparisons and what happens when score = 105`.
Style. Convert the following program to use braces consistently and standard four-space indentation:
``c #include <stdio.h> int main(void){ int n; scanf("%d",&n); if(n%2==0) printf("even\n"); else printf("odd\n"); return 0;} ``
Chapter
Chapter 8 — Loops
8.1 Iteration: repeating a sequence
The second primitive of structured programming is iteration: doing something more than once. Three C constructs implement it — while, do/while, and for. They differ in how the repetition condition is structured, but they are equivalent in expressive power: any loop you can write with one, you can rewrite with another.
Recall from Chapter 2 what iteration does to a program. In the sum-of-n-numbers algorithm (§2.3), we could not write S = M[0] + M[1] + … + M[n-1] because n was not known ahead of time. We instead wrote a loop: a small sequence of steps that runs once per element, branching back to its own start each time. That is iteration in the abstract, and it is exactly what the constructs of this chapter express in C.
Think of a loop in hardware terms (§7.1): an ordinary program runs forward, the program counter advancing after each instruction. A loop is a backward branch — at the end of the body, a branch instruction sets the program counter back to the loop's start, so the same instructions run again. The loop condition decides whether that backward branch is taken or whether control falls through and continues after the loop. This is why every loop needs two things working together: a body (what to repeat) and a condition (when to stop). A loop with no way for the condition to become false is an infinite loop — the backward branch is taken forever.
The three constructs you will learn differ only in where the condition sits relative to the body and how conveniently they package the machinery of a counted repetition. while puts the condition first, so a loop may run zero times. do/while puts it last, so the body always runs at least once. for groups the counter's initialisation, condition, and update into one header, which is ideal when you are counting through a known range. Underneath, all three compile to the same pattern of test-and-backward-branch.
8.2 The while loop
The simplest loop is while:
while (condition) {
/* body — repeated as long as condition is true */
}
The condition is tested before each iteration. If it is false on entry, the body never runs.
This last property — "test before body" — is the defining characteristic of while, and it is worth understanding the flow precisely. Execution works like this:
Evaluate the condition.
If it is false, skip the body entirely and continue after the loop.
If it is true, run the body, then go back to step 1.
So the body runs 0, 1, 2, … times, and the condition is evaluated once more than the body runs — after the final (false) test. This is why the loop variable must be initialised before the loop: the very first condition test sees whatever value the variable has on entry. Get that wrong and the loop never runs, or runs with a garbage first condition.
A worked example: read characters from standard input until the user enters 'q'.
#include <stdio.h>
int main(void) {
int ch;
while ((ch = getchar()) != 'q') {
putchar(ch);
}
putchar('\n');
return 0;
}
Note the assignment inside the condition: (ch = getchar()) != 'q'. This is a common idiom, but be careful to add the extra parentheses — without them, C would parse the expression differently.
Let us unpack this idiom, because it combines two ideas from earlier chapters. getchar() returns one character from standard input. We want to (a) store it in ch and (b) test whether it is the letter 'q'. Assignment yields a value (§6.6), so ch = getchar() does the store and evaluates to the stored character; the comparison != 'q' then tests that value. The result is the loop condition: keep reading and echoing while the character is not 'q'. The extra parentheses are mandatory — ch = getchar() != 'q' would parse as ch = (getchar() != 'q'), storing a 0 or 1 into ch and testing that instead, because != binds tighter than = (§6.8). This is the same "assignment as expression" feature that caused the §6.6 trap; here it is being used correctly, with the parentheses making it deliberate.
The loop also demonstrates a key point: ch is declared int, not char. That is not a mistake — it is the standard way to read input, because getchar returns an int so that it can signal "end of input" with the special value EOF (a value that does not fit in a char). We will meet this pattern again with files in Chapter 21.
8.3 Sentinel-controlled loops
A common pattern is to use a special input value — a sentinel — to indicate "no more data". The body of the loop does something for each real value and stops when the sentinel appears.
/* Read integers until 0 is entered, then print the sum. */
#include <stdio.h>
int main(void) {
int n;
int sum = 0;
printf("Enter integers (0 to stop):\n");
scanf("%d", &n); /* read the first value before entering the loop */
while (n != 0) {
sum += n;
scanf("%d", &n); /* read the next value at the end of the loop */
}
printf("Sum = %d\n", sum);
return 0;
}
Two notes:
We must read the first value before the loop, because the while condition is tested before the body runs.
We read again at the end of the body, just before the loop closes — so the next iteration sees the new value.
The sentinel pattern is one of the most important in programming, so let us be precise about why it is shaped this way. The problem is that the loop must decide, before processing a value, whether it is real data or the stop signal — and the only way to know is to read it. Reading is therefore forced into two roles: one read must happen before the loop (to give the first condition test a value), and one read must happen at the end of each iteration (to give the next condition test a value). The reads bracket the body: read; while (value != sentinel) { process; read; }. This "read-check-process-read" rhythm is so common that it has a name — a loop-and-a-half — and you will recognise it in countless programs.
The sentinel itself is a choice the programmer makes: 0 here, -1 for "stop when you see a negative number", 'q' for character input. Two rules govern a good sentinel. First, it must be a value that can never legitimately occur in the data — if you were summing exam scores (always ≥ 0), 0 could be a sentinel, but if you were averaging a list that might contain zeroes, it could not. Second, the loop body must treat the sentinel as a stop signal, not as data — here, the body processes n only after the condition has already confirmed it is not 0.
A common variation initialises the loop variable to something other than the sentinel:
int ch = 'X'; /* not 'Q', so the loop body runs */
while (ch != 'Q') {
scanf("%c", &ch);
printf("got: %c\n", ch);
}
This trick lets you use a single scanf/getchar inside the loop, with the first read happening as part of the loop test.
Why does this work? Because the pre-loop initialisation ch = 'X' guarantees the first condition test is true (any character other than 'Q'), so the body runs and performs the first real read. From then on, each iteration reads a fresh value into ch and the condition tests it. The loop has only one read statement instead of two. The price is that the loop's first "value" ('X') is a fake — but the loop never uses ch until the body has overwritten it with a genuine read. This is a compact variant of the loop-and-a-half, and understanding both forms lets you read and write either one.
8.4 The do / while loop
Sometimes you want the body to run at least once before the condition is tested. The do/while loop provides exactly that:
do {
/* body — runs at least once */
} while (condition);
The semicolon at the end is required.
Where while puts the test before the body, do/while puts it after:
Run the body.
Evaluate the condition.
If true, go back to step 1; if false, continue after the loop.
The consequence is guaranteed: the body always runs at least once, and the condition is evaluated once per iteration after it. The machine translation is the same backward branch as while, just with the test moved to the bottom.
When is "at least once" the right behaviour? Whenever the body must do something before there is anything to test. The classic case is prompting for input: you cannot test whether the input is valid until you have asked for it. Example: prompt the user until they enter a positive integer.
int n;
do {
printf("Enter a positive integer: ");
scanf("%d", &n);
} while (n <= 0);
printf("You entered %d.\n", n);
If you used while, the prompt would have to be duplicated — once before the loop and once inside it. do/while removes the duplication.
This is the same loop-and-a-half shape as §8.3, but in a cleaner form. Compare: the sentinel while needed an awkward read before the loop and another at the end, because the first test had to have a value. Here, the first test happens after the first body run, so there is nothing to set up in advance — n does not even have a meaningful value until the first scanf. That is the real reason do/while exists: it expresses "do this, and repeat until the result is acceptable", which needs no pre-loop setup.
One caution: because the body always runs once, you must be sure the body is safe to run even when the input is empty or invalid. If the body reads from a file that might be empty, a do/while would perform a read that never should have happened; a while that checks the file first would be correct. Choose do/while when "run at least once" is genuinely required, and while when even one run might be wrong.
8.5 The for loop
When the number of iterations is known in advance, the for loop is the most natural choice. Its syntax packs three pieces of information into one header:
for (initialisation; condition; update) {
/* body */
}
The three parts are:
Initialisation. Runs once before the loop starts. Usually declares and initialises a counter variable.
Condition. Tested before each iteration. If false, the loop exits.
Update. Runs at the end of each iteration, before the next condition test.
A worked example: print the integers 0 through 9.
#include <stdio.h>
int main(void) {
for (int i = 0; i < 10; i++) {
printf("%d\n", i);
}
return 0;
}
The loop variable i is declared inside the header. C99 and later allow this; its scope is the loop header and body. (Older C required the declaration to be before the loop.)
Execution order is:
Run the initialisation.
Test the condition. If false, exit.
Run the body.
Run the update.
Go to step 2.
The for header is a complete description of the repetition in one place: the counter starts at 0, the loop continues while i < 10, and i grows by 1 each time. Every fact you need to understand how many times the loop runs — and how the counter changes — is visible in a single line instead of being scattered through the body. That is the point of for: it concentrates the loop control in the header and leaves the body free to contain only the work. When you read for (int i = 0; i < 10; i++), you should immediately read "runs 10 times, with i taking the values 0 through 9".
Note the loop runs 10 times with i from 0 to 9, not 10 times with i up to 10. The condition i < 10 is tested before each iteration, so when i reaches 10 the test fails and the loop exits without running the body for i = 10. This is the §8.12 off-by-one trap waiting to happen; the discipline that avoids it is to make the condition say exactly what the range is — < 10 for 0…9, <= 10 for 0…10, < n for 0…n−1.
The for loop is while in disguise
A for loop is equivalent to a while loop with the initialisation moved before it and the update moved into the body:
/* The for loop above is equivalent to: */
int i = 0;
while (i < 10) {
printf("%d\n", i);
i++;
}
This is why we say for and while are interchangeable. Use for when the loop has a clear counter; use while when the condition is more complex or the number of iterations is not known up front.
The equivalence is exact in behaviour, with one subtlety we defer to Chapter 12: the loop variable declared inside a for header has a scope that ends when the loop ends, whereas an i declared before the equivalent while survives the loop. In this example, i is not needed after the loop, so the for version is tidier. The for/while choice is therefore both a readability decision (is there a counter?) and a scoping decision (should the counter vanish when the loop ends?).
8.6 Multiple variables in a for loop
The comma operator allows two or more variables in the initialisation or update clauses:
for (int i = 0, j = 10; i < j; i++, j--) {
printf("i = %d, j = %d\n", i, j);
}
This loop walks i and j towards each other, stopping when they meet.
Recall from §6.7 that the comma operator sequences two sub-expressions, discarding the first's value and yielding the second's. That is exactly what is happening in the header: int i = 0, j = 10 initialises two counters, and i++, j-- runs both updates each iteration. (In the declaration, the comma is a separator as discussed in §6.7; in i++, j-- it is the operator.) The header is still "three parts, each of which may contain several sub-expressions".
This is the one everyday use of the comma operator, and it is exactly the pattern §6.7 predicted: the for header has room for one expression per slot, and the comma fits two. If you find yourself wanting three or more loop variables, reconsider — a while loop with the counters managed in the body is usually clearer, and two variables converging (as here) is already enough for most problems.
8.7 Empty parts
Any of the three parts of a for header may be omitted. A missing condition is treated as always true, which produces an infinite loop.
for (;;) {
/* runs forever; use break or return to exit */
}
The semicolons must still be there. The empty for (;;) is C's idiomatic "loop forever".
The semicolons are the grammar's skeleton: for (; ; ) still has its two required separators, and the empty slots mean "no initialisation", "condition always true", "no update". Omitting the initialisation or update is common — for example, when the counter is managed elsewhere — but omitting the condition is deliberate: it makes the loop terminate only through break or return inside the body.
An infinite loop is not an error in itself; it is a contract. The program intends to loop forever, and the exit will be an explicit statement inside the body (§8.8). What you must avoid is the accidental infinite loop — a while whose condition never becomes false because the update is missing (§8.12). The for (;;) form makes the intent obvious; the accidental form hides it.
8.8 The break and continue statements
Two statements modify the normal flow of a loop.
break exits the innermost enclosing loop or switch immediately.
for (int i = 0; i < 1000; i++) {
if (some_condition(i)) break;
process(i);
}
continue skips the rest of the current iteration and jumps to the next iteration's condition test.
for (int i = 0; i < 10; i++) {
if (i == 5) continue;
printf("%d\n", i); /* prints 0..4 and 6..9, skipping 5 */
}
Both are useful but easy to overuse. A loop riddled with break and continue is usually a sign that the loop is doing too much. Prefer to structure the condition so the loop terminates naturally.
break and continue are the two explicit exits a loop provides, and they answer two different questions:
break answers "should this loop end, right now?" It abandons the current iteration and the loop, jumping to the first statement after the loop. In the example, once some_condition(i) is true, the search is over — continuing would be wasted work, so break leaves immediately. Note that break also exits a switch (§7.7); it always exits the innermost enclosing loop or switch, whichever comes first. A break inside nested loops only exits the inner one; the outer loop keeps running.
continue answers "should this iteration be skipped?" It abandons only the current iteration and immediately performs the next loop step. In a for loop that means running the update and testing the condition again; in a while loop it means testing the condition again. The loop itself continues. continue is the loop-native version of "this item is not interesting — move on".
The distinction matters, and getting it backwards is a real bug: using break to skip one value stops the whole loop, while using continue to end the loop merely skips one iteration and then plods on. In the continue example, i == 5 is skipped and the loop prints 0–4 and 6–9; had that been break, the loop would have printed only 0–4 and stopped.
Both statements interact with the loop condition, which is a subtlety worth noticing. continue in a while loop jumps straight to the condition test — the rest of the body is skipped, including any update that lived there. In a for loop, the update is part of the loop machinery, not the body, so continue still runs it. This is one reason for is safer for counted loops than a hand-rolled while with the increment at the bottom of the body: a continue cannot accidentally skip the increment.
Both are useful but easy to overuse. A loop riddled with break and continue is usually a sign that the loop is doing too much. Prefer to structure the condition so the loop terminates naturally. In particular, a while loop with a complex exit condition is often better written as a for (;;) with a break — or, when the loop body is genuinely doing several things, split into functions so each loop has a single clear purpose.
8.9 Integer overflow in loops
A common bug: a loop accumulates values that grow beyond the range of an int. C will not warn; it will silently produce wrong answers.
int sum = 0;
for (int i = 1; i <= 1000000; i++) {
sum += i;
}
printf("%d\n", sum); /* 32-bit signed int overflows for large sums */
The fix is to declare sum as long long (or int64_t):
long long sum = 0;
for (int i = 1; i <= 1000000; i++) {
sum += i;
}
A useful test: the sum of the first n integers is n(n+1)/2. For 32-bit int (max ≈ 2.1 × 10⁹), that exceeds the range once n is a few tens of thousands — the loop above, summing 1 … 1,000,000, overflows long before it finishes. The risk is not about the number of values but about the size of the running total.
The bug is silent because of §4.8: signed overflow is undefined behaviour, and the machine simply wraps the bits with no diagnostic. On a 32-bit int, the true answer — the sum of 1 … 1,000,000 is 500000500000 — needs 39 bits and simply does not fit; the wrapped result is wrong, and nothing tells you. This is the same overflow discussed in §6.7 and §4.8; loops are where it most often sneaks into beginners' code, because the loop itself "looks right".
Two lessons follow. First, when accumulating, estimate the largest value the total can reach and choose the type accordingly — long long is cheap and exact for sums up to about 9.2 × 10¹⁸. Second, the loop counter can overflow too, in loops like for (int i = 0; i <= 2000000000; i++) where the test never fails because i wraps before it reaches the bound — yet another reason to prefer < bound over <= huge and to keep bounds modest. In Chapter 22 we will see that unsigned types make wrap-around well-defined (§4.8), which is sometimes a deliberate tool, but for ordinary sums you want a type big enough that no wrap happens at all.
8.10 Nested loops
Loops can be nested. The body of one loop can contain another.
#include <stdio.h>
int main(void) {
for (int i = 1; i <= 9; i++) {
for (int j = 1; j <= 9; j++) {
printf("%4d", i * j);
}
printf("\n");
}
return 0;
}
This prints a 9×9 multiplication table. The inner loop runs to completion on each iteration of the outer loop, so the total number of iterations is 9 × 9 = 81.
The mental model is a clock or a counter: the inner loop is the fast hand, completing a full revolution for every single tick of the outer loop. For each value of i, the whole inner loop runs through every value of j. The pattern of execution, in order, is:
So the inner loop body runs 9 × 9 = 81 times total. The general rule: if the outer loop runs m times and the inner runs n times, the inner body executes m × n times — the product, not the sum. That multiplicative growth is what makes nested loops powerful and also expensive: a 3-deep nest of loops each running 1,000 times executes a billion inner-body runs.
Two things make nested loops readable. First, distinct variable names for each level — i and j here, by long-standing convention — so it is always clear which loop controls which counter. Second, the outer loop should drive the slower-changing quantity and the inner loop the faster-changing one, matching the clock analogy; in the multiplication table, i selects the row and j walks across it.
Avoid nesting loops more than three or four levels deep; the resulting code is hard to follow. Sometimes a nested loop can be replaced by a function call, which makes the structure clearer.
8.11 A non-trivial example: prime testing
/* primes.c
Print all primes less than N. */
#include <stdio.h>
int is_prime(int n) {
if (n < 2) return 0;
if (n == 2) return 1;
if (n % 2 == 0) return 0;
for (int d = 3; (long long)d * d <= n; d += 2) {
if (n % d == 0) return 0;
}
return 1;
}
int main(void) {
int n;
printf("Print primes up to: ");
scanf("%d", &n);
for (int p = 2; p <= n; p++) {
if (is_prime(p)) printf("%d ", p);
}
printf("\n");
return 0;
}
A few features to study:
A function is_prime is used; we will study functions in Chapter 9.
The trial-division loop only needs to test divisors up to the square root of n — because if n has a divisor larger than that, it also has one smaller.
(long long)d * d <= n casts d to long longbefore multiplying, so the comparison does not overflow for large d.
printf("%d ", p) prints the primes separated by spaces; the final printf("\n") moves to the next line.
Let us look at the why behind each of these, because each is a lesson in its own right.
Why trial division stops at the square root. Suppose n is composite, so it factors as n = a × b with both factors greater than 1. One of a and b must be ≤ √n — if both were strictly larger than √n, their product would be larger than n. So it is enough to search for a divisor only up to √n: if no divisor is found there, none exists at all. The loop encodes this as (long long)d * d <= n, which is d ≤ √n with integer arithmetic — no floating point, no sqrt call, no rounding error.
Why the long long cast.d * d for a 32-bit intd overflows once d exceeds 46,340 (§8.9's overflow, again). Computing d * d as a 32-bit int and then comparing against n would give a wrapped, wrong result for large d. Casting one operand to long longfirst makes the multiplication happen in 64 bits (§6.7 — the result takes its type from the operands), so d * d is exact. This is the same "choose your types at the point of the operation" lesson as the 3.0 in §6.9.
Why only odd divisors after 2. The checks n == 2 and n % 2 == 0 handle 2 and every even number. After that, no even divisor can divide n — so the loop starts at d = 3 and steps d += 2, testing only odd candidates and halving the work. The small checks before the loop are guard clauses (§7.8): they dispatch the trivial cases once, up front, so the loop itself only ever sees the interesting ones.
Why the outer loop is a counted loop. The main loop for (int p = 2; p <= n; p++) is a textbook for — a known range, a counter, nothing else. It embodies everything §8.5 said: the whole repetition is described in the header, and the body is a single question ("is p prime?") with a conditional print. The program is two loops, each doing one job, which is exactly the modularity Chapter 9 will formalise.
8.12 Common mistakes
Off-by-one errors. Using <= n instead of < n (or vice versa) produces a result that is one too large or one too small. Trace through the loop by hand for the smallest and largest input values to make sure the boundary is right.
The off-by-one is the most common loop bug in existence, and it deserves a diagnosis, not just a name. The loop condition is the contract about the range, and the off-by-one is a mismatch between the contract you wrote and the range you meant. The reliable cure is to check the boundary cases by hand: run the loop in your head (or on paper) for the smallest input, the largest input, and the input one past the boundary. for (int i = 0; i < 10; i++) runs for i = 0 … 9 and noti = 10; if you needed 0…10, the condition was wrong, not the loop.
Modifying the loop variable inside the body. If you write for (int i = 0; i < 10; i++) { ... i++ ... }, the update clause adds another increment, and the loop will run twice as many times as you expected. The update clause is the only place the loop variable should change.
The root cause is that the header already specifies the counter's trajectory; a body that also changes i creates two conflicting sources of truth, and the loop does neither thing you intended. If the body genuinely needs a different counting step, change the update clause (i += 2) — that is what it is for.
Forgetting to update a while loop variable. A while loop has no automatic update; you must put the increment or change inside the body. A forgotten update means an infinite loop.
This is the natural companion of the previous mistake. for concentrates the update in the header where you cannot miss it; while leaves the update to you, and if you omit it the condition never changes and the loop never ends (§8.1's backward branch taken forever). If your while loop is hanging, the first thing to check is "does anything in the body move the condition toward false?" Prefer for when there is a counter precisely because it makes this mistake structurally impossible.
Comparing floats in a loop condition. Floating-point arithmetic is approximate; testing x != 0.0 after a series of subtractions may never become exactly zero. Use a tolerance instead.
The mechanism is §4.9's rounding: x approaches 0 through a sequence of rounded values, and whether it lands exactly on 0.0 is a matter of luck in the least significant bits. The robust pattern is a bound on the difference — fabs(x - y) < 1e-9 (§7.9) — or, better, a counted loop (for with an integer counter) so the loop does not depend on floating-point convergence at all. Whenever you can make a loop's termination depend on an integer, prefer that; integer comparisons are exact.
8.13 Summary of Chapter 8
A loop repeats a body of statements while a condition holds; at the hardware level it is a backward branch.
while tests the condition before each iteration; the body may run zero times.
do/while tests the condition after each iteration; the body runs at least once. Use it when the body must run before there is anything to test.
for packs an initialisation, condition, and update into one header. It is equivalent to while but more compact for counted loops; the header is the complete description of the repetition.
The comma operator fits two counters into one for header slot.
A missing condition in a for header means "always true": for (;;) is the idiomatic infinite loop.
break exits a loop; continue skips to the next iteration. break exits the innermost loop or switch; in a for, continue still runs the update.
Integer overflow can corrupt loop variables and totals silently; use larger types (long long) and estimate the largest total up front.
The off-by-one error, the double-increment, the missing-while-update, and float loop conditions are the classic loop bugs; each is a mismatch between the loop's stated contract and its actual behaviour.
Exercises 8
Write three programs that print the integers 0 through 9: one using while, one using do/while, and one using for. They should produce identical output.
What does the following program print?
``c for (int i = 0; i < 5; i++) { for (int j = 0; j <= i; j++) { printf("*"); } printf("\n"); } ``
Compute the sum of all even integers from 2 to 100. Compute the sum of all odd integers from 1 to 99. Print both.
Write a program that reads integers until the user enters 0, then prints the maximum, the minimum, and the average of the values entered. Use sentinel-controlled input.
Write a program that prints the first n Fibonacci numbers, where n is read from the user. Use long long for the values. (A Fibonacci sequence starts 1, 1, 2, 3, 5, 8, ….)
Write a program that reads an integer n and prints whether it is a perfect square. Hint: compare i * i to n for i = 1, 2, ….
The Collatz conjecture says that for any positive integer n, the sequence defined by n → n/2 if n is even, n → 3n+1 if n is odd, eventually reaches 1. Write a program that reads n and prints the length of the sequence until it first reaches 1. (For example, starting at 6 the sequence is 6, 3, 10, 5, 16, 8, 4, 2, 1 — length 9.)
Trace. What does the following program print? Predict first.
``c int n = 0; while (n++ < 5) { printf("%d ", n); } printf("\n"); ``
Chapter
Chapter 9 — Functions and Modular Programs
9.1 Why functions?
Once a program grows beyond a few dozen lines, keeping it in one place becomes painful. The same calculation is repeated; a bug fix has to be applied in three different places; the order of statements is hard to reason about.
Think about the programs you have written so far. In the prime tester of §8.11, the two loops did two different jobs — one tested a single number, the other walked through a range — but both lived in the same flat sequence, with break statements and returns tangled together. That works for thirty lines. It stops working at three hundred: the more statements a reader must hold in mind at once, the harder each one is to reason about, and the easier it is to fix one part and break another. The solution is not to write less code; it is to give the code names and boundaries.
A function is a named, self-contained block of code. Once written, it can be called from anywhere — possibly many times — without rewriting it.
Two principles justify the existence of functions:
Modularity. Group related code into a single named unit. A function is the C unit of structure: a small piece of code that does one thing, with a name that documents what that thing is.
Reusability. "Don't repeat yourself" (DRY). If the same computation appears twice, factor it into a function and call the function twice. The duplication vanishes and any bug fix applies in one place.
Both principles point at the same underlying payoff, and it is worth stating plainly: a function is a name for a computation. When you read a program made of functions, you read a list of named operations — discriminant, root, is_prime — rather than a flat stream of statements. The names carry the meaning; the bodies carry the detail. This is the same layering you met in Chapter 2, where an algorithm was a sequence of named primitive steps: functions let you invent your own primitives and then build on them. And it is the same reason control flow was worth studying: just as if and for give the program structure, functions give the program vocabulary.
A function may also have side effects — it changes something outside itself, like writing to the screen — or it may be a pure function — given the same inputs it always returns the same output and changes nothing else. Pure functions are easier to reason about; favour them when you can.
The pure-function idea deserves a moment, because it is the seed of a whole way of thinking. square(5) is pure: every call, anywhere, returns 25, and nothing in the universe is changed by calling it. You can replace square(5) with 25 and the program behaves identically. printf is impure: it returns a count of characters, but its real job — writing to the screen — is a side effect, and the outcome depends on the screen's state, not just on its inputs. The practical difference: pure functions are trivial to test (call them with known inputs, check known outputs), trivial to reason about (no hidden state), and safe to call in any order or any number of times. Prefer them when you can, and keep side effects at the boundaries of your program — reading input, writing output — where they belong.
9.2 The shape of a function
<return-type> <name>(<parameter-list>) {
/* body */
}
The return type is the type of value the function produces. If the function produces no value, use void.
The name is any valid identifier, by the same rules as variable names.
The parameter list is a comma-separated list of parameter declarations, each with its own type. An empty list () means "no parameters" in older C; the unambiguous modern form is (void).
The body is a sequence of declarations and statements enclosed in braces.
The shape is worth reading as a contract, because every part answers a question the caller must know:
Return type — what value (if any) comes back?
Name — how do I refer to this computation?
Parameters — what inputs does it need, and of what types?
Body — what does it actually do?
The signature — the first three parts, before the body — is all the information a caller needs. The body is the caller's business no more than the inside of a printf is. This separation between signature (what the function promises) and body (how it delivers) is the heart of modularity: you can read a call site and know everything relevant about the call without reading the implementation.
A worked example:
int square(int x) {
return x * x;
}
The function square takes one int parameter x, computes x * x, and returns the result. It can be called from anywhere:
int y = square(5); /* y is now 25 */
Trace the call through the machinery of Chapter 8 and §7.1: square(5) suspends the current computation, transfers control to the square function, and when square executes return, control comes back to the statement after the call, carrying the value 25 with it. (Chapter 10 shows exactly how this transfer works — the stack frame.) For now, notice the symmetry: the argument 5 on the way in matches the parameter x; the returned 25 matches the return type int. The whole transaction is described by the signature.
9.3 Declaring and defining
A function can be either declared (signature only) or defined (signature plus body). The declaration lets you use the function before its definition appears in the file; the definition provides the actual code.
/* declaration */
int add(int a, int b);
/* definition */
int add(int a, int b) {
return a + b;
}
Why two forms? If every function had to be defined before use, you could not call printf from your own function, because printf's definition lives in the standard library — not in your file. The declaration solves this: it tells the compiler what printf's signature is, so the compiler can generate calls without seeing the body.
The declaration is sometimes called a prototype, and the word is apt: it is the shape of the function without the substance. The compiler needs that shape for one reason — to generate correct machine code for a call. When it compiles printf("hi\n"), it needs to know how many arguments to pass and what types they are, so that the calling convention (§10.3) lays out the arguments correctly. It does not need the body to do this. The definition can be anywhere — later in the same file, in another .c file, or in a pre-compiled library — as long as a declaration is visible at every call site.
This also explains the failure mode of §9.9: if no declaration is visible, a pre-C99 compiler guessed the signature, and the guess was often wrong.
A standard practice is to put declarations in a header file (.h) and definitions in a source file (.c). The stdio.h header that you #include is full of declarations for functions whose definitions live in the C standard library.
9.4 Calling a function
To call a function, write its name followed by a parenthesised list of arguments:
double r = sqrt(2.0); /* one argument */
double c = pow(2.0, 10.0); /* two arguments */
int n = printf("hi\n"); /* printf returns the number of characters written */
The number, type, and order of arguments must match the parameter list of the function being called. Mismatches are bugs the compiler can often catch — and occasionally cannot.
Note the vocabulary: the function declaresparameters (the names it uses inside its body), and the call passesarguments (the values supplied by the caller). The two lists must agree in number, type, and order — the first argument fills the first parameter, the second fills the second, and so on. This positional matching is the whole meaning of "calling with arguments": the call site is plugging values into the slots the signature declared.
The compiler's ability to check this agreement is exactly what declarations buy (§9.3). With a correct prototype visible, sqrt("hello") is a compile-time error — the compiler knows sqrt takes a double. But two gaps remain. First, printf and friends take a variable number of arguments, and the compiler cannot check those against the format string — hence the %d/double mismatches below. Second, some conversions are performed silently: passing an int where a double is expected converts automatically (§6.7), which is usually fine but occasionally hides a mistake. The compiler can catch what the prototype reveals; it cannot catch what the prototype does not say.
A common beginner mistake is to write printf("%d\n", n) and pass n as a double (or vice versa). The format string specifies what the type should be; the argument list must agree.
9.5 Pass by value
When you call a function, the arguments are copied into the parameters. The function operates on the copies, not on the original variables.
void increment(int x) {
x = x + 1;
/* x is now 6 (locally); the caller's variable is unchanged */
}
int main(void) {
int a = 5;
increment(a);
printf("%d\n", a); /* prints 5, not 6 */
return 0;
}
This mechanism is called pass by value. C only has pass by value. To let a function modify the caller's variable, you must pass a pointer to it (Chapter 13).
Why does a stay 5? Because the parameter x is a fresh variable created for this call, and increment(a) copies the value 5 into it. Inside the function, x = x + 1 changes x to 6 — but x is a copy living in the function's own working area (§10.3's stack frame). The variable a in main is a different variable in a different frame; nothing the function does to its copy can touch it. When increment returns, its x (now 6) is discarded along with the frame, and a is still 5.
The intuition to build is the direction of the copy. Data flows one way, into the function: caller's argument → callee's parameter. There is no channel back. A function can compute and return a single value, but it cannot reach out and modify the caller's variables through ordinary arguments. That one-way rule is the entire reason pointers exist (§13.5 will revisit this exact swap problem), and it is also the reason the §9.8 root function must return its result rather than "set" anything.
Pass by value has a consequence worth noting: copying a large struct (Chapter 17) into a function can be expensive, because the entire structure is copied onto the function's stack frame. We will discuss the workaround in Chapter 17.
9.6 The void keyword
void means "no value". It appears in three places:
As a return type: void greet(void) { ... } — the function returns nothing.
As the parameter list: void greet(void) — the function takes no parameters.
As a pointer type: void * — a pointer to memory whose type is unknown (Chapter 13).
A void function ends by reaching its closing brace, or by return; with no value. Reaching the closing brace is equivalent to executing return;.
The two voids in void greet(void) mean different things, and both are worth spelling out. The first says this function produces no result — it is called purely for its side effects (§9.1), like printing. The second says this function takes no arguments — a deliberate, explicit "no parameters", as opposed to the older empty list () whose meaning in old C was "parameters unspecified". Always write (void) for a no-argument function; it makes the intent unambiguous and is required for portability.
Why would a function exist if it returns nothing? Because much of programming is side effects. printf returns a value you almost always ignore; a function that prints a table, draws a frame, or writes to a file has no useful value to return — its purpose is the work it does, not a result. Such functions are the boundaries of a program, and it is good style to keep the pure logic (which returns values) separate from the I/O (which usually returns void), exactly as §9.1 suggested.
9.7 The return statement
return ends the function immediately and produces a value for the caller.
int add(int a, int b) {
return a + b;
}
A function whose return type is not void must execute return with a value of the right type on every possible execution path. The compiler will warn if a path exists where the function falls off the end without returning.
return does two things at once, and both are essential:
It produces the function's result — the expression after return is evaluated and becomes the value of the call expression back at the call site.
It terminates the function immediately — control leaves the function at that moment, skipping everything after the return.
The second property is what makes return a control-flow statement as well as a value-returning one. Like break in a loop (§8.8), it abandons the rest of the function. This is why early returns can serve as guard clauses (§7.8): return lets a function bail out from anywhere, not just the end.
The value part obeys the type rules of §6.7: return x * x; in a function whose return type is int evaluates x * x as an int and hands that to the caller. If the expression's type does not exactly match the return type, it is converted (§6.7's conversions) — so return 2.7; in an int function returns 2, silently truncating. The return type is a promise to the caller about what value will appear; a mismatch is a contract violation even when the compiler does not complain.
A void function may use return; with no value to exit early:
void process(int x) {
if (x < 0) return; /* bail out if x is invalid */
/* main work */
}
Here return; is return with no value, valid only in a void function, and its only job is the second role above: leave the function now. It is the same guard-clause pattern as §7.8, expressed inside a function.
9.8 A worked example: the quadratic function
Let us rewrite the quadratic solver of Chapter 7 as a set of functions:
/* quadratic.c */
#include <stdio.h>
#include <math.h>
/* Compute the discriminant. */
double discriminant(double a, double b, double c) {
return b * b - 4.0 * a * c;
}
/* Compute one root given the discriminant. */
double root(double a, double b, double disc, int sign) {
return (-b + sign * sqrt(disc)) / (2.0 * a);
}
int main(void) {
double a, b, c;
printf("Enter a, b, c: ");
if (scanf("%lf %lf %lf", &a, &b, &c) != 3) {
printf("Input error.\n");
return 1;
}
if (a == 0.0) {
printf("Not a quadratic.\n");
return 1;
}
double disc = discriminant(a, b, c);
if (disc < 0.0) {
printf("Complex roots.\n");
return 1;
}
printf("Roots: %.6f and %.6f\n",
root(a, b, disc, +1),
root(a, b, disc, -1));
return 0;
}
Three observations:
The mathematical pieces are now named: discriminant, root, main. Reading the program tells you what it does before you study the details.
The function root takes the sign of the square root as a parameter. Calling with +1 gives the plus root; with -1 gives the minus root. This trick lets us write one function instead of two nearly identical ones.
The functions can be tested independently. You could write a small program whose only job is to call discriminant with a few values and check the answers. This is the beginning of unit testing.
Compare this program with the single-function version in §7.8, and you will see the modularity payoff in action. The same three decisions are made — input valid, genuinely quadratic, real roots — but now each decision is visible in main as a guard clause, and the computation lives in named functions. discriminant and root are pure functions (§9.1): they take numbers, return numbers, and touch nothing else. main is where the side effects — reading input and printing output — are concentrated. That split is the design: the two helpers can be reasoned about and tested in isolation, and main becomes a readable description of the program's flow rather than a tangle of arithmetic.
The sign parameter is a small but instructive trick. The two roots differ only in whether the square root is added or subtracted; encoding that choice as an integer parameter (+1 or -1) lets one function cover both, and the call site root(a, b, disc, +1) reads almost like the formula (−b + √disc)/(2a). The alternative — two nearly identical functions, plus_root and minus_root — would duplicate the body. Whenever two functions would differ only by a constant or a flag, consider folding them into one function with a parameter; that is DRY (§9.1) applied at the function level. (A more general way to parameterise "which operation" is a function pointer, which we meet in Chapter 17.)
The unit-testing point is worth taking seriously, because it is the practical payoff of pure functions. discriminant(1, -3, 2) must return 1 (since the equation x² − 3x + 2 has discriminant 9 − 8 = 1); root(1, -3, 1, +1) must return 2 and root(1, -3, 1, -1) must return 1. You can check all of these by calling the functions from a tiny program — no input, no printing — and the fact that you can is exactly why pure functions are easier to verify than functions entangled with I/O.
9.9 Forward declarations and the implicit-int warning
Older C (before C99) allowed you to call a function whose declaration had not been seen. The compiler would assume the function returns int. This is called the implicit int rule, and it is a frequent source of bugs: a function that actually returns double would be miscompiled, leading to nonsense output.
Why was such a rule ever in the language, and why is it so dangerous? Recall from §9.3 that the compiler generates a call based on the signature it knows. With no declaration visible, a pre-C99 compiler had nothing to go on, so it fell back on a guess: "returns int, arguments as written." For sqrt(2.0) — which actually returns double — the compiler would generate code that reads an int from the return register (§10.3). The two conventions disagree, so the value you get is garbage. Worse, the call usually compiles, so nothing tells you the assumption was wrong until the output is nonsense — or, in the worst cases, the stack is corrupted because the guessed number of arguments was wrong.
Modern compilers warn about this when they see it:
warning: implicit declaration of function 'sqrt'
Treat that warning as an error. It means a function is being used without a prototype, and the call may be silently wrong.
Always declare functions before using them. The fix is to #include the right header (<math.h> for sqrt, <stdio.h> for printf, etc.) or to write your own declaration.
9.10 Library functions you will use often
A short list of standard library functions that come up again and again:
Function
Header
Purpose
printf
<stdio.h>
formatted output to stdout
scanf
<stdio.h>
formatted input from stdin
fprintf, fscanf
<stdio.h>
formatted I/O to/from a file
fopen, fclose
<stdio.h>
open / close a file
fgetc, fputc
<stdio.h>
read / write one character
fgets, fputs
<stdio.h>
read / write one line
fread, fwrite
<stdio.h>
binary I/O
strlen, strcpy, strcmp, strcat
<string.h>
string operations
malloc, calloc, realloc, free
<stdlib.h>
dynamic memory
sqrt, pow, sin, cos, log, exp
<math.h>
mathematical functions
exit
<stdlib.h>
terminate the program
rand, srand
<stdlib.h>
pseudo-random numbers
The standard library is documented in detail in Appendix B and in the man pages (man printf, man strcpy, …).
9.11 Multiple source files
A function definition can live in a different file from the function call, as long as the call site sees a declaration. This is the basis of multi-file projects and is treated fully in Chapter 24. For now: it is fine to assume all functions live in one file.
The mechanism is worth stating once, because it follows directly from §9.3. A call needs only a declaration; the definition may be anywhere. So nothing stops you from putting discriminant in one file, root in another, and main in a third — each file sees the relevant prototypes (via a shared header), and the linker (§24.2) connects the calls to the definitions at build time. Splitting a program across files is a physical form of modularity: it enforces the boundaries you draw, because each file can hide the parts the others need not see. We will develop this properly in Chapter 24.
9.12 A note on style: function size
There is no hard rule, but a useful guideline is the screenful rule: a function should fit on one screen (about 50 lines). If a function grows longer, look for ways to split it. The candidates for extraction are usually groups of statements that together compute one value, or that together perform one step.
The rule is not about aesthetics; it is about the limits of the reader's working memory. A function is a unit of reasoning — you hold its inputs, its invariants, and its output in your head while you read it. The longer it is, the more state you must track, and the more likely a bug hides in the middle where the eye does not linger. When a function crosses the screenful, it is usually because it is really doing several different things, and the extraction candidates are the natural seams between them.
How do you find those seams? Ask "what groups of statements would make sense as a named operation?" In the quadratic example of §9.8, discriminant and root are exactly such groups — each is a self-contained computation with a clear name and clear inputs and outputs. A good rule of thumb: if you find yourself writing a comment above a block of statements describing what the block does, that block is a candidate for a function — the comment is already naming it.
9.13 Summary of Chapter 9
A function is a named, self-contained block of code that may take parameters and may return a value — a name for a computation.
Modularity and reusability (DRY) are why functions exist. Pure functions are easier to reason about and test; keep side effects at the boundaries.
A declaration (prototype) specifies the function's signature; a definition provides the body. Calls need only the signature; the compiler generates a call from it.
C passes arguments by value: the function receives copies of the caller's values, one way in. To modify a caller's variable, pass a pointer (Chapter 13).
void means "no value" — used as a return type for functions that return nothing, and as the parameter list for functions that take nothing (always write (void)).
return both produces the function's value and terminates the function; it may appear anywhere, enabling guard clauses.
A non-void function must return on every path; the returned value is converted to the return type.
Pre-C99 compilers guessed the signature of undeclared functions (implicit int); modern compilers warn. Always declare before use.
Every C program must have exactly one function named main; execution begins there.
The C standard library provides many useful functions; their declarations live in headers like <stdio.h>, <math.h>, <string.h>, and <stdlib.h>.
Keep functions to about a screenful; extract groups of statements that form one named computation.
Exercises 9
Write a function int abs_int(int x) that returns the absolute value of x. Test it.
Write a function int is_even(int x) that returns 1 if x is even and 0 otherwise. Test it for several values, including negative ones.
Write a function double distance(double x1, double y1, double x2, double y2) that returns the Euclidean distance between (x1, y1) and (x2, y2). Test it.
Write a function int gcd(int a, int b) that returns the greatest common divisor of a and b using the Euclidean algorithm. Test it.
Write a function double average(double a[], int n) that returns the arithmetic mean of the first n elements of a. Test it. (Note that a is here a pointer to the first element of the array; the array length is passed separately.)
Trace. What does the following program print?
``c void swap(int a, int b) { int t = a; a = b; b = t; } int main(void) { int x = 3, y = 5; swap(x, y); printf("%d %d\n", x, y); return 0; } ` Why are x and y` not exchanged? How would you fix the function so it actually swaps its arguments? (You will need pointers; see Chapter 13.)
Refactor. Take any program you wrote earlier in this book (say, the prime tester from Chapter 8) and rewrite it so that the main loop is in one function and each sub-task is in its own function. Does the result read better?
Course sequence
Part III — Pointers and Memory
Now we look under the hood. This part explains what actually happens in memory when a C program runs: how each function gets its own working area (a stack frame), how local variables differ from global ones, and how pointers, addresses, and the rules of memory layout all fit together. The single most important chapter in this part is the one on pointers — read it slowly.
Chapter
Chapter 10 — The Runtime: Stack Frames and the Heap
Interactive model
Inspect call frames
Follow calls, locals, and return points in a runtime model.
main() calls a function; each active call has its own locals and return point.
10.1 What the runtime does
When you write #include <stdio.h> and call printf, the compiler inserts calls into a piece of code that is not yours — code that comes with the C compiler and the operating system. That code is the C runtime: a collection of functions the program can rely on (for example, to start main correctly, to allocate memory, to read and write files).
Most of the time you do not think about the runtime. It just works. But certain C constructs — function calls, recursion, dynamic memory — depend on what the runtime does. This chapter is about that hidden machinery.
It is worth being precise about when the runtime acts, because it occupies a different phase of a program's life than the compiler. The compiler (§3.2) translates source text into machine instructions and stops; it does not run anything. When you later execute the program, the runtime takes over: it sets up the process, builds the memory regions described below, calls your main, manages the stack as functions call and return, and cleans up on exit. So the compiler decides the layout of a single function, but the runtime provides the stack on which every function's frame is placed, the heap that malloc draws from, and the machinery that makes main start at all.
Why should a beginner care? Because several behaviours you have already seen — pass-by-value in §9.5, recursion in Chapter 11, and every pointer in Chapter 13 — are runtime phenomena. The rule "a function's local variables disappear when it returns" is a description of what the runtime does with the stack, not a rule the compiler invents. Once you can picture the stack and the heap, these behaviours stop being arbitrary rules and start being consequences of a memory layout you understand.
10.2 Two kinds of memory
A running C program's memory is conventionally divided into regions:
(Exact layouts differ between operating systems. This is a useful mental picture, not a contract.)
Code contains the machine instructions of your program. It is fixed in size from the moment the program starts.
Constants contain values that do not change while the program runs — string literals, for example ("Hello, world").
Heap is where memory you allocate dynamically lives. It grows upwards as the program calls malloc and shrinks as it calls free.
Stack is where each function's local variables live. It grows downwards as functions are called and shrinks as they return.
The split between heap and stack has a consequence we will return to many times: the location of a variable matters for how it behaves. Local variables live in stack frames and disappear when the function returns; heap variables persist until explicitly freed.
The two growth arrows deserve a second look, because they explain why the heap and stack are separate regions at all. Notice that they grow toward each other — the heap upwards, the stack downwards — into the empty space between them. This is deliberate: a program's total memory is shared, so the two regions that need to grow (dynamic allocation on one side, call nesting on the other) can each expand into the unused middle without colliding with each other or with the fixed code and constant regions. It is also why the two regions are managed so differently: the stack grows and shrinks in a strict last-in-first-out pattern as functions call and return (exactly matching the nesting of calls — you cannot return from foo before bar that it called), while the heap can allocate and free blocks in any order, which is why malloc/free must be called explicitly.
Three regions, three lifetimes — this is the mental skeleton the whole book hangs on:
Region
What lives there
Lifetime
Stack
function parameters, local variables
while the function is active (a call's frame)
Heap
memory from malloc
from allocation to free
Static data (§10.8)
globals, function-statics
the entire run of the program
We will refer back to this table constantly. When Chapter 12 discusses scope and lifetime, when Chapter 19 discusses dynamic memory, when Chapter 15 discusses arrays — each is a story about which region holds the data and when it comes and goes.
10.3 The stack frame
When a function is called, the runtime carves out a block of memory called a stack frame. The frame holds:
The function's parameters.
Its local variables.
A pointer to the caller's frame (so return knows where to go back to).
Whatever extra bookkeeping the calling convention requires.
Each call to a function gets its own frame. Two simultaneous calls — for example, main calling foo, and foo calling bar — produce three frames: one for main, one for foo, one for bar. Each frame is independent: foo's local x is a different variable from main's local x, even though they have the same name.
The stack frame is where §9.5's pass-by-value becomes visible. When you call increment(a), the runtime does not borrowa's storage; it allocates a new slot inside the callee's frame and copies the value 5 into it. The callee's x is that new slot. When the callee returns, its whole frame — including x — is discarded. This is exactly why the caller's variable is unaffected: they never shared storage in the first place, only a copied value. §9.5 described this as "a fresh variable created for this call"; the stack frame is where that fresh variable is created.
The frame is also the mechanism behind recursion (Chapter 11). Because each call — including a function calling itself — gets its own fresh frame, two recursive calls to factorial do not clobber each other; each n is a separate copy in a separate frame. A loop and a recursive call both repeat work, but a recursive call does it with new storage each time, which is why recursion can keep multiple partial computations alive simultaneously.
A useful picture, growing downward:
Before any call:
┌─────────────────────────┐
│ (empty) │ ← stack pointer
└─────────────────────────┘
After main() starts:
┌─────────────────────────┐
│ main's local variables │
│ ... x, y, ... │
├─────────────────────────┤ ← frame pointer for main
│ return address │
└─────────────────────────┘
After main() calls foo():
┌─────────────────────────┐
│ foo's local variables │
│ ... a, b, ... │
├─────────────────────────┤ ← frame pointer for foo
│ return address │
├─────────────────────────┤
│ main's local variables │
│ ... x, y, ... │
├─────────────────────────┤ ← frame pointer for main
│ return address │
└─────────────────────────┘
When foo returns, its frame is popped off. The stack pointer moves back up. main's frame is exactly as it was — its locals still hold the values they had before foo was called.
Two details in the picture are worth decoding, because they are the machinery that makes returning work. The return address is the address of the instruction that called foo (plus one) — stored in foo's frame so that when foo finishes, the program counter can be set back to continue right after the call. The frame pointer marks where foo's frame begins, so the runtime knows exactly which block of memory to reclaim when foo returns, and so the function's variables can be located relative to it. These two pointers are the entire "calling convention" — the agreed layout by which call and callee communicate. When §9.9 said a wrong signature "corrupts the stack", it meant precisely this: the caller lays out arguments or reads a return value according to the declared convention, and if the guess was wrong, the frame is misread.
10.4 A worked example
#include <stdio.h>
void foo(int a) {
int b = a + 1;
printf("foo: a=%d b=%d\n", a, b);
}
int main(void) {
int x = 3;
foo(x);
printf("main: x=%d\n", x);
return 0;
}
Trace, step by step:
main is called. Its frame is created. x is initialised to 3.
main calls foo(3). The argument 3 is copied into foo's parameter a. foo's frame is created.
foo computes b = a + 1 = 4.
foo prints its values. Its frame is destroyed when foo returns.
Back in main, x is still 3. The copy that was in foo is gone.
The crucial point: even though a and x hold the same value 3, they are different variables living in different frames. Changing a inside foo does not affect x in main.
Let us make the frames concrete by giving each variable its storage location, because the whole point of this chapter is that the location is the explanation.
Step 1 — the stack has one frame:
main's frame: x = 3
Step 2 — calling foo(3) pushes a second frame on top. The argument is copied: foo's parameter a is a new slot holding 3, not x's slot:
foo's frame: a = 3 (a new copy)
main's frame: x = 3 (untouched)
Step 3 — foo runs its body:
foo's frame: a = 3, b = 4
main's frame: x = 3
Step 4 — foo returns. Its frame is popped; a and b cease to exist.
main's frame: x = 3
Step 5 — main prints 3. The two frames never shared a byte.
If you re-read §9.5 now, the picture should feel complete: "pass by value" was shorthand for "the runtime copies the argument into a slot in the callee's fresh frame". The value 3 is duplicated; the storage is not shared. This is why Exercise 6 of Chapter 9's swap does nothing — and why the pointer fix (Chapter 13) works by passing the address of the storage instead of a copy.
10.5 Why pass by value is sometimes a problem
Pass-by-value is safe and predictable, but it can be expensive. Imagine a function that takes a struct with thousands of bytes:
A workaround exists — pass a pointer — but to write that workaround you need to understand pointers, which is the subject of Chapter 13.
Where does the expense come from? Recall §10.3: the argument is copied into the callee's frame. struct Huge is 8000 bytes, so every call to process must copy 8000 bytes onto the stack, and then discard them on return. Copying 8000 bytes is not slow by itself — a few nanoseconds — but if process is called in a loop a million times, the program spends seconds on nothing but copying. Pass-by-value also doubles memory: the copy coexists with the original for the duration of the call.
Why does C copy at all instead of just letting the function use the original? Because of §9.5's one-way rule: copying is what guarantees the caller's variable is untouched. If the callee got the actual storage, then any change it made would corrupt the caller's data — which is exactly what pointers let you opt into deliberately. Copying is the safe default; pointers are the deliberate exception. That safety has a price, and this section is where the price shows.
The standard solution is to pass a pointer to the struct instead of the struct itself: a pointer is only 8 bytes (one address) regardless of what it points to, so the copy cost disappears and the function reads the original through the pointer. Chapter 13 builds the machinery, and Chapter 17 shows the practical pattern — passing a pointer to a struct by value (the pointer is copied, the struct is not). The trade-off is that the callee must promise not to modify what it points at — a promise we will see expressed with const in Chapter 13.
10.6 Visualising the runtime
The web site Python Tutor (https://pythontutor.com) — despite its name — supports C as well as Python and is excellent for visualising what happens during execution. It shows each frame, each variable, and each step. If a function call is confusing you, paste the code into Python Tutor and step through it.
This recommendation is worth taking seriously rather than skipping, because the thing Python Tutor makes visible is exactly the mental model this chapter builds: the stack of frames, growing and shrinking with each call and return, each frame holding its own copies of parameters and locals. Watching main push a frame, call foo, push another frame on top, then pop them back in reverse order is the fastest way to internalise why pass-by-value behaves as it does and why recursion (§11.3) works at all. When you later move to a real debugger (gdb, or the debugger in your IDE — Chapter 24's toolchain section shows how), you will see the same frames as live data, and the familiarity will pay off immediately.
10.7 Heap memory
The heap is the region of memory that holds values outliving a single function call. The most common source of heap memory is malloc:
int *p = malloc(4 * sizeof(int)); /* allocate 16 bytes on the heap */
malloc returns a pointer to the start of the heap block. The block stays alive until you call free:
free(p);
Unlike a stack frame, a heap block does not automatically vanish when the function that allocated it returns. If you forget to call free, the memory is leaked: it remains allocated until the program exits.
The contrast with the stack is the whole point of the heap, so let us state it sharply. A stack frame is automatic: pushed on entry, popped on exit, its lifetime tied to the call. That is convenient but restrictive — anything you want to outlive the function that created it cannot live on the stack. The heap is the region with explicit lifetime: memory you allocate, you keep until you free it, regardless of which function is active. This makes the heap the natural home for:
Data whose size is not known until the program runs — an array of n integers where n is typed by the user. The stack needs the size at compile time; the heap does not.
Data that must survive the function that created it — a tree or list built by one function and traversed by another.
Large blocks that should not consume stack space (§15.9).
The price of that flexibility is responsibility. The stack cleans up after itself; the heap does not. Every malloc must be matched by a free, and getting the pairing wrong produces two distinct failures: leaks (memory never freed, vanishing until the program exits) and dangling pointers (memory freed too early, then used). Both are subjects of Chapter 19, where malloc, free, calloc, realloc, and their pitfalls get full treatment.
Heap and stack are detailed in Chapter 19. For now, remember that they are different regions with different lifetimes.
10.8 Static and global variables
Variables declared outside any function are global; they exist for the entire life of the program and are visible to every function in the same file (and, with the extern keyword, in other files too).
Variables declared inside a function with the keyword static are function-static: they live for the entire life of the program but are visible only inside the function.
#include <stdio.h>
int counter = 0; /* global */
void bump(void) {
static int n = 0; /* function-static: keeps value across calls */
n++;
counter++;
printf("bump #%d (counter=%d)\n", n, counter);
}
int main(void) {
bump(); /* n=1, counter=1 */
bump(); /* n=2, counter=2 */
bump(); /* n=3, counter=3 */
return 0;
}
Both n and counter persist across calls; each bump increments them. They differ only in scope (visibility): n is visible only inside bump; counter is visible everywhere.
Function-static and global variables live in the static data region of memory — a fixed area set aside by the operating system when the program starts. They are not on the stack (which grows and shrinks with function calls) and not on the heap (which is managed by malloc and free). Their addresses are fixed for the life of the program.
The static keyword here does something subtle that beginners often misread: it changes storage duration, not visibility in the obvious direction. Compare the three cases in this one program:
Declaration
Storage
Visibility
Value after bump returns
int n = 0; (plain local)
stack frame
inside bump only
gone (re-initialised to 0 each call)
static int n = 0; (function-static)
static region
inside bump only
kept (1, 2, 3, …)
int counter = 0; (global)
static region
every function
kept (1, 2, 3, …)
Two independent properties — where it lives (stack vs static region) and who can see it (scope) — combine independently. static moves the variable off the stack so it survives calls; it does not make it visible elsewhere, which is why n stays private to bump. counter is visible everywhere, which is why it is "global" — but both share the static region, which is why both persist. The word "static" is easy to confuse with "global", but they answer different questions: static is about storage, global is about scope. Chapter 12 untangles both fully.
Two practical cautions follow. First, the initialisation static int n = 0; runs once, when the program starts, not when bump is called — which is why the counter keeps counting instead of resetting. (A plain local's initialisation runs on every entry, which is why it resets.) Second, globals are easy to overuse: a global can be modified from anywhere, which makes programs harder to reason about — the same reason §9.1 preferred pure functions. Prefer function-static for hidden counters, and reserve globals for values that are genuinely shared.
10.9 Compile time vs runtime
It is worth distinguishing two phases of a program's life.
Compile time is when the compiler runs, reads your source files, and produces an executable. Decisions made at compile time include:
The layout of structures.
The value of sizeof expressions.
The targets of goto-equivalent control flow.
Runtime is when the executable runs. Decisions made at runtime include:
The value of variables.
Which branch of an if is taken.
How many times a for loop iterates.
A useful question to ask, when confused about C syntax or behaviour, is: is this decided at compile time or at runtime? The answer often makes the semantics clear.
The two phases answer to different authorities, which is the deep reason the question matters. At compile time, the only information available is types and source structure: the compiler can know how many bytes int occupies or how a struct is laid out, because those are fixed properties of the types involved. At runtime, the only information available is values and execution: whether n is negative, how many times a loop ran. Almost every C "surprise" is a case where a construct is resolved in the wrong phase from the one you assumed. sizeof resolves at compile time, so it cannot depend on a variable's value; a VLA's size resolves at runtime, so it can. Asking the phase question first will resolve most confusions before they become bugs.
sizeof, in particular, is evaluated at compile time — even though it looks like a function call, the compiler replaces it with a constant number when it sees it. That is why sizeof(x) does not evaluate x at runtime: the compiler only needs x's type to compute the size. The identifier must still be declared and in scope — sizeof cannot conjure a size for an undeclared name — but the value of x is never read.
10.10 A note on the operating system
You will notice that we have not mentioned the operating system since Chapter 3. That is by design. The C language is defined to run on a bare CPU — there is no requirement that an OS exist. C is the language of operating systems, after all, so it cannot depend on one.
In practice, most C programs do run under an operating system, and the runtime (the code that starts main, allocates memory, and handles I/O) is provided by a combination of the C compiler and the OS. When a textbook example uses printf, the compiler emits code that calls into a runtime library, which calls into the OS, which writes to the screen.
Why does this chapter care? Because the memory regions of §10.2 are, on a real system, provided by the OS, and their behaviour is part of the C contract. When your program starts, the OS creates the process, lays out the code, constant, static, heap, and stack regions, and then the runtime calls main. When the stack grows too deep (recursion out of control, Chapter 11), it is the OS that refuses to provide more stack and terminates the program with a stack overflow — a failure you will meet directly in §15.9. The OS is not an abstraction you need to program against; it is the layer that makes the C mental model physically real.
10.11 Summary of Chapter 10
The runtime provides the bookkeeping that makes C work: starting main, allocating memory, performing I/O. The compiler lays out code; the runtime runs it.
A running program's memory is divided into code, constants, heap, and stack. The heap and stack grow toward each other into shared space; three regions (stack, heap, static) have three different lifetimes.
Each function call allocates a stack frame that holds its parameters, locals, and return address. The frame is freed when the function returns.
Pass-by-value is the stack in action: arguments are copied into fresh slots in the callee's frame, so the caller's variables are never shared, only copied.
The frame's return address and frame pointer implement the calling convention — and why a wrong signature (§9.9) corrupts it.
The heap is for memory that must outlive a single function call. The programmer must explicitly allocate (malloc) and free (free) heap memory; the stack cleans up after itself.
Global and function-static variables live in the static data region and persist for the entire run of the program. static changes storage (lifetime), not visibility (scope).
Asking "is this decided at compile time or at runtime?" resolves most C confusions.
Exercises 10
Trace the following program by hand. For each line, write down what is on the stack and what value each variable holds.
```c #include <stdio.h>
void outer(int p) { int q = p + 10; printf("outer: p=%d q=%d\n", p, q); }
void middle(int m) { int n = m * 2; outer(n); printf("middle: m=%d n=%d\n", m, n); }
int main(void) { int x = 5; middle(x); return 0; } ```
Then run it to check your trace.
A function-local variable goes out of scope when the function returns. A function-static variable does not. Explain, with a small program, how you can observe this difference.
What is the difference between a global variable and a function-static variable? Write a small program that uses both, and predict the output.
Memory regions. Modify the prime tester from Chapter 8 to print the address of each prime (printf("%p ", (void *)&p)). Run the program several times. Do the addresses change between runs? Why?
Chapter
Chapter 11 — Recursion
11.1 What recursion is
A recursive function is one that calls itself. The idea is at first sight paradoxical — how can a function do its job by, well, asking itself to do its job? — but it is one of the most powerful techniques in programming, and C supports it directly.
The key insight is that a recursive function does not call itself with exactly the same arguments. It calls itself with simpler arguments — arguments that are closer to a stopping condition. Eventually the arguments become simple enough that the function can return a value without making another recursive call.
A recursive function therefore has two parts:
The base case — the simplest possible input, for which the answer is known directly.
The recursive case — for any other input, do a small amount of work and call the function on a smaller input.
This is the same pattern as mathematical induction: prove the base case, then assume the result for the smaller input and derive the result for the larger input.
11.2 A first example: factorial
The factorial of a non-negative integer n is
n! = n × (n−1) × (n−2) × … × 1
with the convention 0! = 1. We can compute this with a loop (Chapter 8), or with a recursive function:
#include <stdio.h>
int factorial(int n) {
if (n <= 1) return 1; /* base case */
return n * factorial(n - 1); /* recursive case */
}
int main(void) {
for (int i = 0; i <= 6; i++) {
printf("%d! = %d\n", i, factorial(i));
}
return 0;
}
Each call to factorial produces a new frame on the stack. The frames accumulate until the base case is reached, then unwind as each call returns.
11.3 Why recursion works in C
Three things make recursion possible in C:
A function may call itself once its declaration has been seen. The declaration does not have to be followed by the definition, so the compiler does not need to know how the function works in order to allow it to call itself.
Each call creates an independent stack frame. Two simultaneous calls to the same function do not collide; each has its own parameters and locals.
Frames are released when the function returns. Memory used by a frame is freed automatically; we do not have to clean it up explicitly.
In a language without stack frames (such as raw assembly), recursion is possible but the programmer must manually save and restore registers, allocate stack space, and arrange returns. The C compiler does all of this for us.
11.4 Common recursion patterns
The examples below use a few pointer and struct mechanisms that are formally introduced in later chapters. Here is a short primer so the recursion lesson does not depend on them:
char *s is a pointer to a char. s[0] is the first character it points to; s[1] the second, and so on. (Full treatment: Chapter 13 and Chapter 16.)
s + 1 is pointer arithmetic: it advances the pointer to the next element of the pointed-to type, not the next byte. For char *, each element is one byte, so s + 1 moves one byte; for int *, each element is sizeof(int) bytes, so p + 1 moves four bytes. The compiler scales the addition by sizeof of the pointed-to type — this is why pointer arithmetic "walks" through an array one element at a time. (Chapter 15.)
int a[] as a parameter is really a pointer to the first element: int sum(int a[], int n) and int sum(int *a, int n) are the same function. The array length is passed separately because the function cannot know it otherwise. (Chapter 15.)
typedef struct Node { ... } Node; names a structure type Node so we can write Node *n instead of struct Node *n. (Chapter 17 and Chapter 18.)
n->name is shorthand for (*n).name — dereference the pointer n, then read the field name. (Chapter 17.)
If any of these feels unfamiliar, read the pointer chapter (Chapter 13) first and return here; the recursion ideas themselves do not depend on the details.
Sum of an array
int sum(int a[], int n) {
if (n == 0) return 0; /* empty sum is 0 */
return a[n-1] + sum(a, n-1); /* last element + sum of rest */
}
Maximum of an array
int max(int a[], int n) {
if (n == 1) return a[0]; /* one element */
int m = max(a, n - 1); /* max of first n-1 */
return (a[n-1] > m) ? a[n-1] : m; /* larger of last element and m */
}
double power(double x, int n) {
if (n == 0) return 1.0;
if (n < 0) return 1.0 / power(x, -n);
return x * power(x, n - 1);
}
The last one is inefficient: it makes n recursive calls. A better version uses exponentiation by squaring:
double power(double x, long n) {
if (n == 0) return 1.0;
if (n < 0) return 1.0 / power(x, -n);
double half = power(x, n / 2);
if (n % 2 == 0) return half * half;
return x * half * half;
}
Now the call depth is logarithmic in n — a dramatic improvement.
11.5 Recursion and the stack
Each recursive call adds a frame. If the recursion is deep — say, factorial(1000000) — the stack may overflow. Most operating systems set the stack size to a few megabytes; each frame is at least a few dozen bytes, so you can typically make thousands of recursive calls before trouble, but not millions.
A function with no base case calls itself forever. Each call uses more stack memory until the OS runs out and kills the program with the message Segmentation fault (core dumped). Try the following to see it:
This is the C equivalent of an infinite loop, but it kills the program with a stack overflow rather than running forever. Always include a base case.
11.6 Recursion versus iteration
Every recursive function can be rewritten as a loop. Conversely, every loop can be written as a recursive function (the compiler typically does this for you). The two are equally powerful.
Recursion
Iteration
Code clarity
Often shorter
Often longer for inherently recursive problems
Memory
Each call uses a frame
One frame, possibly smaller
Speed
Often similar after optimisation (tail recursion can become a loop)
No call overhead
Stack depth
Can overflow
Cannot overflow from the loop itself
Use recursion when it makes the code clearer. Use iteration when performance matters or stack depth is a concern.
A rule of thumb: if a function makes one recursive call and then does work on the result, it can be turned into a loop easily. If it makes two or more recursive calls (as in tree traversal), the loop version is much harder and recursion is the natural choice.
11.7 A tree example
Recursion shines on tree-shaped data. Consider the problem of printing a directory hierarchy:
src/
main.c
util.c
include/
util.h
We could represent this as a tree of nodes and write a recursive function to print it:
#include <stdio.h>
typedef struct Node {
char *name;
struct Node *first_child;
struct Node *next_sibling;
} Node;
void print_tree(Node *n, int depth) {
for (int i = 0; i < depth; i++) printf(" ");
printf("%s/\n", n->name);
for (Node *c = n->first_child; c != NULL; c = c->next_sibling) {
print_tree(c, depth + 1);
}
}
Each node is printed, then each of its children is recursively printed one level deeper. The data structure (a tree of nodes) and the algorithm (a recursive traversal) are natural partners.
We have not shown how to build the tree — that needs heap allocation (malloc for each node) and linking the first_child/next_sibling pointers, which are the subjects of Chapter 17 (self-referential structs, §17.11) and Chapter 19 (dynamic memory). The focus here is the traversal, which is the recursive part. When you reach Chapter 19, come back and write a make_node helper that allocates a Node with malloc, fills its fields, and returns a pointer to it; then link several nodes together and run print_tree on the root.
11.8 Summary of Chapter 11
A recursive function calls itself, but with simpler arguments that approach a base case.
Every recursive function has at least one base case and at least one recursive case.
Each recursive call produces a new stack frame; recursion is therefore subject to stack overflow.
Iteration can replace recursion; recursion can replace iteration. Choose what makes the code clearer.
Tree and graph algorithms are often most naturally written recursively.
Exercises 11
Write a recursive function int sum_to(int n) that returns 1 + 2 + … + n. Test it.
Write a recursive function int count_digits(int n) that returns the number of digits in n (in base 10). Test it.
Write a recursive function int fib(int n) that returns the n-th Fibonacci number (1, 1, 2, 3, 5, 8, …). What happens when you call fib(50)? Why?
The Euclidean algorithm for greatest common divisor can be written recursively:
`` gcd(a, 0) = a gcd(a, b) = gcd(b, a mod b) `` Implement it. How deep does the recursion go?
Tail recursion. A function is tail recursive if the recursive call is the very last thing the function does. The factorial function in this chapter is not tail recursive (the multiplication happens after the call). A tail-recursive version is:
``c int factorial_helper(int n, int acc) { if (n <= 1) return acc; return factorial_helper(n - 1, n * acc); } int factorial(int n) { return factorial_helper(n, 1); } `` This version can be compiled into a loop by a smart compiler. Why is it tail recursive? Verify that it produces the same answers.
The Towers of Hanoi. Three pegs, n disks on the leftmost peg, smaller disks always on top of larger ones. Move all disks to the rightmost peg, one disk at a time, never placing a larger disk on a smaller one. Write a recursive solution and test it for n = 1, 2, 3, 4. How many moves does it take?
Stack overflow. What happens if you call factorial(-1)? Modify the function so it returns 0 for negative input and prints a warning.
Chapter
Chapter 12 — Scope and Lifetime
12.1 Two related ideas
Two distinct questions arise for every variable in a C program:
Scope: where in the source code is the variable visible? Where can it be referred to by name?
Lifetime: when does the variable exist? From when to when does the memory it occupies actually hold a value?
These two are often — but not always — the same. A function's parameter has the same scope as its body, and the same lifetime as the function call. A global variable has a scope that includes every function in the file, and a lifetime that is the entire run of the program.
The two diverge when nested blocks are involved: a variable declared inside a nested block has the scope of that block, but its lifetime begins when the block is entered and ends when the block is exited.
12.2 Block scope
A block is a sequence of declarations and statements enclosed in braces { }. Every block introduces a new scope.
int main(void) {
int x = 3; /* scope of x: the body of main */
if (x > 0) {
int y = 4; /* scope of y: the body of this if */
printf("%d %d\n", x, y); /* both visible */
}
/* y is out of scope here; using it is a compile error */
printf("%d\n", x); /* x is still visible */
return 0;
}
The variables x and y are both block-scoped. x's scope is the body of main; y's scope is the body of the if. Outside that if, y is invisible to the compiler.
12.3 Redeclaration and shadowing
What if a variable in an inner block has the same name as a variable in an outer block? The inner declaration shadows the outer one: within the inner block, only the inner variable is visible.
#include <stdio.h>
int main(void) {
int x = 1;
printf("outer x = %d\n", x); /* 1 */
{
int x = 2;
printf("inner x = %d\n", x); /* 2 */
{
int x = 3;
printf("inner-inner x = %d\n", x); /* 3 */
}
printf("inner x = %d\n", x); /* still 2 */
}
printf("outer x = %d\n", x); /* still 1 */
return 0;
}
Three different variables named x exist at three nested scopes. The outer xs are still there, but invisible until the inner scope ends.
Shadowing is occasionally useful (you can give an inner variable the same name as an outer one and reuse the name rather than invent a new one). It is more often a source of confusion: it is easy to think you are using the outer variable when you are actually using the inner one. Many style guides discourage shadowing for this reason.
12.4 The for loop variable
A for loop declares its index variable in its header. That variable is scoped to the loop header and body:
for (int i = 0; i < 10; i++) {
printf("%d\n", i);
}
/* i is out of scope here */
C99 introduced this; before C99 the index variable had to be declared before the loop. The new form is cleaner and avoids the bug where the same index variable name is reused by two loops.
A subtle pitfall: a for loop variable shadows an outer variable of the same name, but only during the loop. After the loop, the outer variable is visible again.
#include <stdio.h>
int main(void) {
int i = 100;
printf("before: i = %d\n", i); /* 100 */
for (int i = 0; i < 3; i++) {
printf("loop: i = %d\n", i);
}
printf("after: i = %d\n", i); /* 100 again */
return 0;
}
12.5 File scope
A variable declared outside any function has file scope: it is visible from its point of declaration to the end of the file (and, with extern, in other files that share the declaration).
int global_count = 0; /* file scope, starts life at program start */
void increment(void) {
global_count++;
}
int main(void) {
increment();
increment();
increment();
printf("%d\n", global_count); /* 3 */
return 0;
}
We will return to file-scope variables and the extern keyword in Chapter 24.
12.6 Lifetime
The lifetime of a variable is the time during program execution that the variable actually exists (i.e. its memory is allocated and accessible).
Kind
Lifetime
Local (block-scoped)
From the point of declaration to the end of the enclosing block
Static / global
The entire duration of the program
Dynamic (heap)
From malloc to free (or end of program if not freed)
Function parameter
Same as the function call
Lifetime matters when you take the address of a variable:
int *bad(void) {
int x = 5;
return &x; /* DANGER: x ceases to exist after return */
}
int *good(void) {
static int x = 5;
return &x; /* OK: x is static, lives forever */
}
Returning the address of a local variable is a classic bug. The address points to memory that, once the function returns, may be reused for something else. The compiler usually warns.
12.7 auto, register, static, extern
Storage-class specifiers modify lifetime and visibility:
auto — automatic, the default for local variables. Memory is allocated when the block is entered and freed when it is exited. In C, auto has no practical effect — every local is automatic unless declared static or extern — so the keyword is essentially never written. **It does not mean type deduction** as auto does in C++; C has no equivalent of C++'s auto. Do not use auto in C; it is a leftover from early versions of the language.
register — a hint to the compiler that the variable is heavily used and should ideally live in a register. Modern compilers ignore this hint.
static — for a local variable, makes its lifetime the entire program (but its scope is still the block). For a file-scope variable, makes the variable visible only in the file.
extern — declares a variable that is defined in another file (or elsewhere in this file). The compiler does not allocate storage; it refers to the existing definition.
/* file a.c */
int counter = 0; /* definition */
/* file b.c */
extern int counter; /* declaration, no storage */
void bump(void) { counter++; }
This is the basis of multi-file projects (Chapter 24).
12.8 Initialisation rules
Local variables are uninitialised by default — reading one before assigning to it is undefined behaviour. Global and static variables are initialised to zero by default.
int g; /* zero-initialised */
static int s; /* zero-initialised */
int main(void) {
int a; /* uninitialised */
int b = 0; /* explicitly zero */
/* printf("%d\n", a); */ /* undefined behaviour */
}
Always initialise your variables. The compiler will warn about suspicious uninitialised use if you turn on -Wall.
12.9 A common beginner confusion
#include <stdio.h>
int main(void) {
for (int i = 0; i < 3; i++) {
for (int i = 0; i < 2; i++) {
printf("%d ", i);
}
printf("\n");
}
return 0;
}
Output:
0 1
0 1
0 1
The inner i shadows the outer i; the outer i increments only after the inner loop finishes its two iterations. The code is legal but rarely what the author intended. Choose distinct names.
12.10 Summary of Chapter 12
Scope is where in the source code a name is visible. Lifetime is when in program execution the variable exists.
C uses block scope. A variable declared inside a block is visible from its declaration to the closing brace.
An inner declaration shadows an outer one with the same name.
File-scope variables (declared outside any function) are visible from their declaration to the end of the file.
Local variables are uninitialised by default; file-scope and static variables are zero-initialised.
Trace the following program by hand. What does it print?
``c #include <stdio.h> int main(void) { int x = 1; { int x = 2; { int x = 3; printf("%d\n", x); } printf("%d\n", x); } printf("%d\n", x); return 0; } ``
Modify the program above so all three printfs print the value 1.
A for loop's index variable has block scope. What happens if you try to refer to it after the loop? Try it.
Write a function int *next(void) that returns successive integers 1, 2, 3, … on each call. (Hint: function-static variable.)
Without running it, predict the output of:
``c #include <stdio.h> int x = 5; int main(void) { int x = 10; printf("%d\n", x); { int x = 20; printf("%d\n", x); } printf("%d\n", x); return 0; } `` Run it. Was your prediction right?
Discussion. The keyword register was a useful hint in the early days of C, when compilers were simple. Modern compilers ignore it. Should the keyword be removed from the language? Argue for or against.
Chapter
Chapter 13 — Introduction to Pointers
Interactive model
Follow an address
Select a memory cell and inspect the pointer read.
A pointer stores an address; dereferencing reads the cell at that address.
13.1 The central idea
A pointer is one of the simplest ideas in C — and one of the most often bungled. Strip away the folklore and you find a single rule: a pointer is a variable whose value is an address.
An address is the location of something in memory. Every byte in memory has a unique address. When we declare a normal variable int x = 5;, the compiler allocates some bytes for x and notes their address somewhere internally; the address itself is not directly accessible by name. A pointer is what we use when we want to hold that address and use it.
How big is an address? Data models
An address is a number, and the number of bits it uses determines how much memory the machine can address. The width of an address is tied to the word size of the machine, which in turn is tied to the width of its registers: a 32-bit CPU typically uses 32-bit addresses (up to 4 GB of memory), a 64-bit CPU uses 64-bit addresses (far more than 4 GB — this is exactly why 64-bit pointers were introduced).
The C standard does not fix the sizes of int, long, or pointers. It only sets lower bounds (int at least 16 bits, char at least 8 bits) and an ordering char ≤ short ≤ int ≤ long ≤ long long. The actual sizes are chosen by each compiler for each platform, and the combination is called a data model:
Data model
int
long
pointer
Typical platform
ILP32
32 bits
32 bits
32 bits
32-bit Windows, older 32-bit Linux
LP64
32 bits
64 bits
64 bits
64-bit Linux, macOS
The names encode the sizes: in ILP32, Int, Long, and Pointer are all 32 bits; in LP64, Long and Pointer are 64 bits while Int stays 32 bits.
Why this matters for C portability:
sizeof(int) is 4 on both models, but sizeof(long) and sizeof(pointer) differ — 4 on ILP32, 8 on LP64.
Code that assumes sizeof(long) == sizeof(int) or that a pointer fits in an int will break when moved from a 32-bit to a 64-bit platform.
The portable way to write such code is to use the exact-width types from <stdint.h> (int32_t, int64_t, intptr_t) rather than relying on int or long.
So when you see sizeof return different values on different machines, it is not a bug — it is the data model doing its job.
13.2 Declaration
A pointer is declared by writing * after the type:
int *p; /* p points to an int */
char *q; /* q points to a char */
double *r; /* r points to a double */
The type of the pointer matters: it tells the compiler what kind of value lives at the pointed-to location, which in turn determines how many bytes to read or write when we follow the pointer.
The value of p is undefined at this point — it holds whatever happened to be in that memory location. Always initialise.
13.3 & — the address-of operator
To take the address of an existing variable, use &:
int x = 5;
int *p = &x; /* p now holds the address of x */
After this, p and x are linked: p knows where x lives.
printf("%p\n", (void *)p); /* prints the address, e.g. 0x7ffee4bff8b0 */
printf("%d\n", x); /* prints 5 */
13.4 * — the dereference operator
To access the value at the address held in a pointer, use * (the same symbol as the declaration, but in a different role):
int x = 5;
int *p = &x;
printf("%d\n", *p); /* prints 5 — the value x points to */
*p = 10; /* changes x to 10, via p */
printf("%d\n", x); /* prints 10 */
So *p and x are interchangeable as long asp actually points to x. You can read through p to get x's value, and you can write through p to change x.
A complete worked program
Let us put all of this together in one program: a variable, its address, a pointer, reading through the pointer, and modifying the variable through it.
#include <stdio.h>
int main(void) {
int x = 5; /* a variable */
int *p = &x; /* a pointer holding the address of x */
printf("x = %d\n", x); /* the value of x */
printf("&x = %p\n", (void *)&x); /* the address of x */
printf("p = %p\n", (void *)p); /* the address stored in p */
printf("*p = %d\n", *p); /* the value at that address */
*p = 10; /* modify x through the pointer */
printf("after *p = 10: x = %d\n", x);
return 0;
}
Expected output (the address will differ on your machine):
x = 5
&x = 0x7ffee4bff8b0
p = 0x7ffee4bff8b0
*p = 5
after *p = 10: x = 10
What happens in memory:
int x = 5; allocates four bytes somewhere and stores 5 there. That location has an address, printed by &x.
int *p = &x; allocates a pointer variable and stores the address of x in it. p and &x print the same value.
*p reads the four bytes at the address stored in p — which is x's location — so it prints 5.
*p = 10; writes 10 to that same location. Because p points at x, this changes x itself. The address stored in p does not change; only the value at that address does.
This is the bridge to everything that follows: arrays, strings, and dynamic memory all work by passing addresses around and reading or writing through them.
13.5 The classic swap, fixed
In Chapter 9, Exercise 6, you saw that swap(a, b) did not swap a and b because C passes by value. With pointers, we can fix it:
#include <stdio.h>
void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
int main(void) {
int x = 3, y = 5;
swap(&x, &y);
printf("%d %d\n", x, y); /* 5 3 */
return 0;
}
The function takes the addresses of x and y. Inside the function, *a is x and *b is y. Swapping *a and *b swaps the originals.
The lesson: if you want a function to change a variable, pass its address.
13.6 The null pointer
A pointer that holds the value 0 points to nothing. It is called the null pointer.
int *p = NULL; /* p points to nothing */
NULL is a macro defined in several standard headers (<stddef.h>, <stdio.h>, <string.h>). It expands to a null pointer constant — which the standard permits to be the integer 0, ((void *)0), or 0L; all forms are equivalent, and writing p = 0 directly is equally valid. C23 introduces nullptr, a type-safe null-pointer keyword that avoids some of the integer/pointer ambiguities of NULL.
Dereferencing a null pointer is undefined behaviour — typically a Segmentation fault — but the rule is important to know: always check a pointer before using it.
The keyword const can appear in several positions around a pointer, and the position changes the meaning:
int *p; /* pointer to int */
const int *p; /* pointer to const int — p can change, but *p cannot */
int const *p; /* same as above */
int *const p; /* const pointer to int — p cannot change, but *p can */
const int *const p; /* const pointer to const int — neither can change */
Read the declaration right-to-left: const int *p is "p is a pointer to a const int". int *const p is "p is a const pointer to an int".
The first form lets a function accept a string without promising to modify it — the pointed-to characters must not be changed through that pointer:
size_t strlen(const char *s); /* standard signature */
Inside strlen, s may be reassigned to point elsewhere, but writing s[0] = 'x' (or *s = 'x') is a compile-time error. The function can read the string but cannot modify it through s.
13.8 Pointers to pointers
A pointer's value is itself a number, so it can be stored in another pointer:
int x = 5;
int *p = &x;
int **pp = &p; /* pp points to p, which points to x */
*pp is p; **pp is x. Pointer-to-pointer is most often seen when passing addresses of pointers (e.g. into a function that needs to modify the pointer itself), and in 2D arrays (Chapter 20).
13.9 Memory diagrams
A picture is worth a thousand words. After
int x = 5;
int *p = &x;
the state of memory looks like this:
x p
┌─────────┐ ┌──────────┐
│ 5 │ ◄──────── │ &x │
└─────────┘ └──────────┘
address A address B
x holds 5 at address A. p holds the value A (the address of x) at address B. *p reads the value at address A, which is 5. Writing *p = 10 updates the value at address A — i.e. changes x.
After *p = 10:
x p
┌─────────┐ ┌──────────┐
│ 10 │ ◄──────── │ &x │
└─────────┘ └──────────┘
x is now 10, but p still holds &x. The address stored in p did not change; the value at that address did.
13.10 The void * pointer
A void * is a pointer to unknown type. It is the C analogue of a generic address — a pointer whose target type has not been specified.
void * is what malloc returns:
void *malloc(size_t size);
The caller is expected to cast it to the appropriate type before use:
int *p = (int *)malloc(10 * sizeof(int));
void * converts implicitly to and from any other object pointer type. Converting between void * and function pointers (e.g. void (*)(void)) is not guaranteed by the standard — it is a common extension but not portable, so do not use void * to hold a function pointer. (Function pointers have their own pointer types; we meet them in §17.10.) We will use void * again in Chapter 19 (dynamic memory) and Chapter 20 (multi-dimensional arrays).
13.11 Common pitfalls
Using an uninitialised pointer. The pointer holds some random address. Dereferencing it may crash, may silently corrupt memory, or may "work" until the worst possible moment.
int *p; /* uninitialised */
*p = 5; /* crash, or worse */
Fix: always initialise, either to NULL or to a real address.
Forgetting & in scanf.
int n;
scanf("%d", n); /* wrong — should be &n */
Without &, scanf writes the parsed integer to whatever address is currently in n — which is whatever junk was there.
Dereferencing after free. Once you have freed a pointer, the memory it points to may be reused. Using the pointer again is use after free.
Off-by-one pointer arithmetic. Adding one to a pointer does not add one byte — it advances by sizeof of the pointed-to type. Adding n to an int * skips forward by n ints, not n bytes.
13.12 Pointers and arrays
Pointers and arrays are intimately related — so intimately that we devote the entire next chapter to it. The short version:
An array name in an expression decays to a pointer to its first element.
a[i] and *(a + i) are exactly equivalent.
Pointer arithmetic moves in units of the pointed-to type.
We will see this in detail in Chapter 15.
13.13 Why pointers matter
Pointers are not a curiosity. They are the mechanism by which C programs:
Pass large data structures to functions efficiently (pass a pointer, not a copy).
Modify variables that live in other functions.
Build dynamic data structures (linked lists, trees, hash tables).
Interface with the operating system (every system call receives pointers).
Most C programs of any length would be impossible to write without pointers.
13.14 Summary of Chapter 13
A pointer is a variable whose value is a memory address.
&x takes the address of x.
*p reads or writes the value at the address held in p.
A pointer that points to nothing is a null pointer; dereferencing it is undefined behaviour.
void * is a pointer to an unknown type, used when the target type is not yet known.
Pointers exist so that functions can modify caller variables, share large data structures efficiently, and build dynamic data structures.
Always initialise pointers. Always check before dereferencing.
Exercises 13
Write a program that declares an int and a pointer to it. Print the value of the int in three ways: directly, through the pointer, and through a pointer-to-the-pointer.
Write a function void inc(int *p) that adds one to the variable pointed to by p. Test it from main.
Write a function void zero_array(int *a, int n) that sets all n elements of a to zero. (This is memset in disguise.) Call it from main and verify.
What does the following print? Predict first.
``c int a = 3, b = 5; int *p = &a, *q = &b; *p = *p + *q; p = q; *p = *p + 1; printf("%d %d\n", a, b); ``
Bug. The following program crashes. Identify the bug and fix it.
``c #include <stdio.h> int main(void) { int *p; int x = 10; *p = x; printf("%d\n", *p); return 0; } ``
Discussion. Why does C use 0 for the null pointer instead of, say, -1? What problems could arise from using a non-zero sentinel value?
Chapter
Chapter 14 — Endianness, Alignment, and the Optimised Compiler
14.1 Endianness
A short is two bytes; an int is four. When you store a multi-byte value, the bytes must appear in memory in some order. Two conventions are common.
Big-endian places the most significant byte at the lowest address.
The names come from Jonathan Swift's Gulliver's Travels, in which two kingdoms argue over which end of a boiled egg to crack. The computer-science usage is similarly tongue-in-cheek.
Which is which?
Most desktop and laptop CPUs (Intel x86, AMD x86-64, recent ARM in many configurations) are little-endian. Many older workstation CPUs (Sun SPARC, classic PowerPC) were big-endian. ARM and RISC-V support both, configurable per device.
Network protocols (the standard byte order for data sent over the internet) use big-endian — called network byte order. When little-endian machines communicate, they must byte-swap their integers before sending and after receiving.
Inspecting endianness
A small C program reveals the endianness of the machine it runs on:
#include <stdio.h>
int main(void) {
unsigned int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
printf("bytes: %02x %02x %02x %02x\n", p[0], p[1], p[2], p[3]);
if (p[0] == 0x78) printf("little-endian\n");
else printf("big-endian\n");
return 0;
}
On a little-endian machine this prints bytes: 78 56 34 12 and little-endian. On a big-endian machine it prints bytes: 12 34 56 78 and big-endian.
14.2 Alignment
The CPU's memory bus typically transfers data in chunks of 4 or 8 bytes. To make the hardware fast, the compiler arranges variables so that each one starts at an address that is a multiple of its size:
A char (1 byte) can start anywhere.
A short (2 bytes) starts at an even address.
An int (4 bytes) starts at an address divisible by 4.
A double (8 bytes) starts at an address divisible by 8.
A variable that starts at such an address is aligned. Reading it requires a single bus transfer. Reading it from a misaligned address requires two transfers plus rearrangement, which is slower — and on some architectures, simply wrong.
C does not require alignment. The compiler will normally align variables itself, but a programmer can deliberately create misaligned access:
int a = 0x12345678;
int b = 0x9ABCDEF0;
char *p = (char *)&b;
int *misaligned = (int *)(p + 2);
printf("%08x\n", *misaligned); /* undefined behaviour on strict CPUs */
The struct layout problem
Alignment has an important consequence for struct. The compiler inserts padding bytes between fields so each one is properly aligned:
struct S {
char a; /* 1 byte */
int b; /* 4 bytes, but needs alignment at offset 4 */
short c; /* 2 bytes, but needs alignment at offset 8 */
char d; /* 1 byte */
};
The natural size would be 1 + 4 + 2 + 1 = 8 bytes. With padding, it is actually 12 bytes:
offset 0: a (char)
offset 1: <padding>
offset 2: <padding>
offset 3: <padding>
offset 4: b (int)
offset 8: c (short)
offset 10: d (char)
offset 11: <padding>
Reordering the fields can save space. Putting the largest first is usually best:
struct T {
int b; /* offset 0 */
short c; /* offset 4 */
char a; /* offset 6 */
char d; /* offset 7 */
};
/* sizeof(T) is 8, not 12 */
When memory layout matters — for example, when sending a struct over the network or to a file — use sizeof and the offsetof macro from <stddef.h> rather than guessing. Layout is not guaranteed to be the same across compilers.
14.3 Virtual addresses
When a program runs, the addresses its variables carry are virtual addresses — not real hardware addresses. The operating system maps virtual addresses to physical ones using a page table, and this mapping is invisible to the program.
Why the indirection? Three reasons. First, isolation and protection: each process gets its own private address space, so one program cannot read or write another's memory — the OS's page-table mapping enforces the boundary. Second, illusion of contiguity: a process sees a neat, contiguous block of memory from address 0 upward, even when its physical pages are scattered anywhere in RAM; the page table assembles the illusion. Third, security via ASLR (Address Space Layout Randomisation): the OS places the code, stack, and heap at randomised virtual addresses on each run, so an attacker who finds a bug cannot predict where to redirect control — a defence that only works because virtual→physical mapping is flexible.
Consequence: the address of a variable changes between runs. Two consecutive runs of the same program will produce different addresses for the same variable. Do not hard-code addresses; do not assume a particular layout.
14.4 What the optimiser does
A modern compiler does not just translate your code; it tries to make it faster. The optimiser rewrites your program in ways that preserve the observable behaviour but use fewer instructions, fewer memory accesses, or fewer branches.
The most important effect for our purposes is register allocation. Consider:
int sum(int n) {
int s = 0;
for (int i = 1; i <= n; i++) {
s += i;
}
return s;
}
An unoptimised compiler might emit, for each iteration:
load i into a register
load s into a register
add them
store the sum back into s
increment i
compare i, n
branch if <=
An optimised compiler may keep s and ientirely in registers for the whole loop, storing to memory only at the very end. The optimised code is dramatically faster.
You can see the optimisation in action using the Compiler Explorer website (https://godbolt.org). Paste C code on the left, see the assembly on the right. Try compiling sum(100) both with and without optimisation (-O0 vs -O2).
14.5 How optimisation interacts with &
Taking the address of a variable forces the compiler to give that variable a memory location — it can no longer live in a register. This can make the program slower.
#include <stdio.h>
void f(int n) {
int x = 0;
for (int i = 0; i < n; i++) {
x += i;
}
printf("%d\n", x); /* x could live in a register */
}
void g(int n) {
int x = 0;
for (int i = 0; i < n; i++) {
x += i;
}
int *p = &x; /* taking the address forces x into memory */
printf("%d\n", *p);
}
In f, the compiler may keep x in a register and skip the store after each iteration. In g, the statement int *p = &x; takes the address of x, so the compiler must give x a real memory location — a register has no address. The address is then used through p (*p reads the value at that address). The price of &x is that x can no longer be optimised into a register.
14.6 Memory-mapped I/O
Embedded systems use pointers to talk directly to hardware. Many peripherals expose their control registers at fixed memory addresses. Writing to those addresses configures the device; reading them returns its status.
volatile uint32_t *uart = (uint32_t *)0x40010000;
*uart = 'H'; /* transmit 'H' on the UART */
while (!(*uart & 0x80)) { /* spin */ } /* wait for transmit done */
The volatile keyword tells the compiler that the value can change behind its back — for example, because hardware wrote to it — so it must reload the value on every access rather than caching it in a register.
This is a niche but important use of pointers. Embedded-systems programming is mostly applied pointer arithmetic.
14.7 Summary of Chapter 14
Endianness is the byte order of multi-byte values: big-endian (MSB first) or little-endian (MSB last).
Alignment requires each value to start at an address divisible by its size; misaligned access is slow or wrong.
The compiler inserts padding to align struct fields; reordering fields can save space.
Variable addresses change between runs because of virtual memory and ASLR.
The optimiser keeps frequently-used variables in registers; taking the address of a variable forces it into memory.
volatile tells the compiler that a value can change outside the program's control (memory-mapped I/O).
Exercises 14
Write the endianness-detection program from §14.1 and run it. Report which endianness your machine uses.
Write a program that uses sizeof to print the size of several different structs, including the two from §14.2. Verify your understanding of padding.
Visit https://godbolt.org and paste a small sum-style function. Compare the assembly at -O0 and -O2. How many fewer instructions does the optimised version use?
Discussion. Why does the optimiser have to be conservative about taking addresses? What would happen if it kept a variable in a register when the program actually observed its address?
Course sequence
Part IV — Arrays and Strings
Arrays and strings are how C programs store collections of related values. They are intimately connected to pointers — so much so that the array-indexing syntax a[i] is just a more readable way of writing pointer arithmetic.
Chapter
Chapter 15 — Arrays
15.1 The motivation
Suppose we want to keep the temperature of every day in a week, or the price of every item in a shop, or the marks of every student in a class. Declaring seven separate ints is clumsy:
int mon, tue, wed, thu, fri, sat, sun;
We want a single name — call it temp — that refers to the whole collection. We want to access each element by position: temp[0] for Monday, temp[1] for Tuesday, and so on.
That is an array: a contiguous sequence of identically-typed values accessed by integer index.
15.2 Declaration and initialisation
int a[10]; /* ten ints */
double prices[5]; /* five doubles */
char name[20]; /* twenty chars */
int b[5] = {1, 2, 3, 4, 5}; /* fully initialised */
int c[5] = {1, 2}; /* c[0]=1, c[1]=2, c[2]=0, c[3]=0, c[4]=0 */
int d[5] = {0}; /* all zeros */
int e[] = {1, 2, 3, 4, 5}; /* size inferred from initialiser: 5 */
Trailing elements not explicitly initialised are zero-initialised whenever an initialiser is present — for both automatic and static arrays. So int c[5] = {1, 2}; yields {1, 2, 0, 0, 0} whether c is automatic (on the stack) or static. The "indeterminate garbage" behaviour applies only to an automatic array with no initialiser at all, such as int a[10]; inside a function — that array's contents are whatever the stack happened to hold (§12.8). The rule is: some initialiser forces zero-fill of the rest; no initialiser leaves automatic storage indeterminate.
15.3 Indexing
Elements are indexed with square brackets. Indices start at 0 and go up to size − 1.
int a[5] = {10, 20, 30, 40, 50};
a[0] /* 10 */
a[1] /* 20 */
a[4] /* 50 */
a[5] /* out of bounds; undefined behaviour */
The choice of zero-indexing is not arbitrary. With base address B and element size S, the address of a[i] is B + i * S. With 1-indexing it would be B + (i − 1) * S, an extra subtraction per access. Zero-indexing is one machine instruction cheaper.
15.4 Arrays and pointers
In an expression, the name of an array decays to a pointer to its first element:
int a[5] = {10, 20, 30, 40, 50};
int *p = a; /* p points to a[0] */
printf("%d\n", *p); /* 10 */
printf("%d\n", *(p + 1)); /* 20 */
printf("%d\n", p[2]); /* 30 — yes, indexing a pointer works */
The array-indexing syntax a[i] is defined to be exactly equivalent to *(a + i). So p[2] and *(p + 2) are the same thing.
Pointer arithmetic is scaled: adding 1 to an int * advances by sizeof(int) (typically 4) bytes. Adding 1 to a char * advances by 1 byte. The compiler knows the size of the pointed-to type, and the addition is done in those units.
This is why
int a[10];
for (int i = 0; i < 10; i++) {
printf("%d\n", *(a + i)); /* same as a[i] */
}
works: a + i walks through the array, one int at a time.
15.5 sizeof on arrays
For an array, sizeof returns the total size in bytes:
int a[10];
sizeof(a) /* 40 (10 ints × 4 bytes each) */
sizeof(a) / sizeof(a[0]) /* 10 — the element count */
The expression sizeof(a) / sizeof(a[0]) is the idiomatic way to compute the number of elements in an array. It only works inside the function where the array is declared, because in any other function a would have decayed to a pointer and sizeof(a) would return the pointer size, not the array size.
void print_size(int a[]) {
printf("%zu\n", sizeof(a)); /* prints 8 — the size of an int*, not the array */
}
This is a common gotcha: array parameters lose their size. We will return to it shortly.
15.6 Out-of-bounds access
C does not check array bounds. Reading or writing outside the declared range is undefined behaviour. In practice, what happens depends on what memory lies outside the array:
If the array is on the stack, you may read or corrupt another local variable.
If the array is on the heap, you may read or corrupt another heap allocation.
If you stray far enough, you may hit an unmapped page and crash with Segmentation fault.
The base address is B. a[i] is at B + i * 4. After int *p = a;, p holds B, p + 1 holds B + 4, and so on.
15.8 Passing arrays to functions
When you pass an array to a function, what gets passed is a pointer to the first element. The array does not get copied; the function works on the original.
#include <stdio.h>
void zero_array(int a[], int n) {
for (int i = 0; i < n; i++) a[i] = 0;
}
int main(void) {
int a[5] = {1, 2, 3, 4, 5};
zero_array(a, 5);
for (int i = 0; i < 5; i++) printf("%d ", a[i]);
printf("\n");
return 0;
}
zero_array(a, 5) modifies a in place. After the call, all elements are 0.
Two things to note:
The parameter int a[] is exactly equivalent to int *a — the array syntax is just a hint to the human reader.
You must pass the size separately; sizeof(a) inside the function would give the pointer size, not the array size.
This convention is the root of many C pitfalls. Always pass the length of an array along with the array.
15.9 Stack overflow
Large arrays on the stack can overflow it. Each function's stack frame is limited (typically a few megabytes), and an array of one million ints is four megabytes — already too big.
int main(void) {
int a[1000000]; /* ~4 MB — may already overflow */
for (int i = 0; i < 1000000; i++) a[i] = 0;
return 0;
}
For larger arrays, use the heap (Chapter 19) or a global/static array:
static int a[1000000]; /* in the static-data region, no stack limit */
15.10 Worked example: linear search
#include <stdio.h>
int find(int a[], int n, int target) {
for (int i = 0; i < n; i++) {
if (a[i] == target) return i;
}
return -1; /* not found */
}
int main(void) {
int values[] = {4, 8, 15, 16, 23, 42};
int n = sizeof(values) / sizeof(values[0]);
printf("%d\n", find(values, n, 15)); /* 2 */
printf("%d\n", find(values, n, 99)); /* -1 */
return 0;
}
find walks the array until it finds the target or exhausts the array. The function returns the index where the target was found, or -1 if not.
15.11 Worked example: selection sort
#include <stdio.h>
void sort(int a[], int n) {
for (int i = 0; i < n - 1; i++) {
int min_idx = i;
for (int j = i + 1; j < n; j++) {
if (a[j] < a[min_idx]) min_idx = j;
}
int tmp = a[i];
a[i] = a[min_idx];
a[min_idx] = tmp;
}
}
int main(void) {
int a[] = {5, 2, 8, 1, 4, 7, 3};
int n = sizeof(a) / sizeof(a[0]);
sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
return 0;
}
Selection sort runs in O(n²) time — fine for small arrays, slow for large ones. The inner loop finds the smallest element in the unsorted portion; the outer loop puts it into its place.
15.12 Arrays of pointers
The elements of an array can themselves be pointers. This is most useful for arrays of strings:
Each element of days is a char * (a pointer to the first character of a string literal). The strings themselves live in read-only memory.
15.13 Common pitfalls
Forgetting the size when passing to a function. The array decays to a pointer; the function cannot tell how big the array is. Always pass n separately.
Using == to compare arrays.a == b compares the pointers, not the contents. To compare contents, use a loop or a library function (e.g. memcmp).
Declaring a variable-length array (VLA) with a non-constant size. C99 allows this:
int n;
scanf("%d", &n);
int a[n]; /* VLA: size determined at runtime */
VLAs are convenient but allocate on the stack, with all the stack-overflow risk that implies. For large or unknown sizes, use malloc.
15.14 Summary of Chapter 15
An array is a contiguous sequence of identically-typed values, indexed from 0.
The array name decays to a pointer to its first element in most expressions.
a[i] and *(a + i) are equivalent.
sizeof(a) gives the total byte count; inside the function where a is declared only.
C does not check array bounds; out-of-range access is undefined behaviour.
When passed to a function, an array decays to a pointer; the length must be passed separately.
Large arrays should live on the heap or as static storage, not on the stack.
Exercises 15
Write a program that declares int a[10], fills it with values 0 through 9, and prints them using a for loop.
Write a function int sum_array(int a[], int n) that returns the sum of the first n elements of a.
Write a function void reverse(int a[], int n) that reverses the array in place. Test it.
Write a function int *find_max(int a[], int n) that returns a pointer to the largest element. Test it.
Write a program that finds the second-largest element in an array of ints.
Trace by hand the value of a[i] and *(a + i) for several values of i in an array of five doubles.
Debug. The following function is supposed to zero out an array. It does not. Why?
``c void zero(int a[], int n) { for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++) { a[i] = 0; } } ``
Memory picture. After int a[4] = {7, 14, 21, 28}; and int *p = &a[1];, what does p[-1], p[0], p[1], p[2] evaluate to?
Chapter
Chapter 16 — Strings in C
16.1 What a C string is
C has no built-in string type. A string is a convention: a sequence of char values terminated by a null byte — a byte with value zero.
String: H e l l o \0
Bytes: 72 65 76 76 79 0
The trailing \0 marks the end of the string. Library functions that read strings (such as printf with %s or strlen) walk forward byte by byte until they see the zero.
16.2 Declaring strings
Three common forms:
char s1[] = "Hello"; /* 6 bytes including the \0 */
char s2[20] = "Hello"; /* 20 bytes allocated; "Hello" fits, rest is zero */
char *s3 = "Hello"; /* pointer to a string literal */
sizeof(s1) is 6. sizeof(s2) is 20. sizeof(s3) is 8 (the size of a pointer on a 64-bit machine), regardless of how long the string is.
A char * like s3 is a pointer into a string literal — a region of memory that is usually read-only. Modifying it is undefined behaviour:
printf("%s", s) prints a string. scanf("%s", buf) reads a string into a buffer (stopping at whitespace). For safety, limit how many characters scanf will write:
char buf[64];
scanf("%63s", buf); /* reads at most 63 chars + \0 */
A safer alternative is fgets, which reads a whole line:
char buf[64];
if (fgets(buf, sizeof(buf), stdin)) {
/* fgets keeps the newline; strip it if you need to */
buf[strcspn(buf, "\n")] = '\0';
}
16.4 The string.h functions
The header <string.h> declares a family of functions for working with strings. The most important:
Function
Purpose
strlen(s)
Length, not counting the \0.
strcpy(dst, src)
Copy src into dst (including the \0).
strncpy(dst, src, n)
Copy at most n bytes; does not guarantee a null terminator if src is n or more chars — you must write dst[n-1] = '\0' yourself. snprintf is usually a better choice.
strcpy and strcat assume the destination is big enough. If it is not, they overwrite memory beyond the buffer — a classic source of bugs and security holes. Always prefer strncpy/strncat, or compute the required length first.
16.5 Implementing strlen
strlen is short enough to write from scratch:
size_t my_strlen(const char *s) {
size_t n = 0;
while (*s++) n++;
return n;
}
while (*s++) n++ walks through the string, incrementing n for each character, until the loop reads the \0 (which is false and stops the loop). The number of increments equals the number of characters before the \0.
The assignment *dst++ = *src++ copies one byte and advances both pointers. The loop body is empty because the work is in the condition.
16.7 The const guarantee
Functions in <string.h> that take a string they will not modify declare it const char *:
size_t strlen(const char *s);
int strcmp(const char *a, const char *b);
char *strchr(const char *s, int c);
The const is a promise to the caller: the function will not modify the bytes of s. The compiler enforces the promise — passing a non-const char * works, but passing a string literal to a non-const parameter is a warning.
16.8 Wide characters and Unicode
Plain char strings can only carry characters whose encoding fits in one byte — essentially ASCII and Latin-1. For Unicode, C provides two further types:
wchar_t: a wide character. Its size is platform-dependent (2 bytes on Windows, 4 on most Unix systems) and its encoding is locale-dependent, not fixed to Unicode — so it is not a portable "Unicode type". The header <wchar.h> provides wide-string functions like wcslen, wcscpy, wcscmp, and wprintf. Reserve wchar_t for legacy wide-API interop (notably the Windows API); it is not the right tool for new Unicode code on other platforms.
char16_t, char32_t: exactly 16 and 32 bits, respectively. Used for UTF-16 and UTF-32. Conversion functions live in <uchar.h>.
For modern Unicode work, the usual recommendation is to store UTF-8 in ordinary char arrays (§4.11) and process it byte by byte, or use a dedicated library (ICU, libunistring) for serious text handling. UTF-8 in char is what most new C code does; wchar_t and the <uchar.h> types are for specific interop needs.
16.9 Buffers, boundaries, and security
C strings do not carry their length. Every standard function that operates on a string depends on finding a \0 to stop. If the \0 is missing — because the buffer was not properly terminated — the function will read past the end, possibly crashing, possibly leaking memory contents, possibly triggering a security vulnerability.
This is the root cause of many famous bugs. Defensive string handling in C looks like this:
snprintf always writes a terminating null byte. Use it instead of sprintf.
16.10 A worked example: word count
#include <stdio.h>
#include <ctype.h>
#include <string.h>
int main(void) {
char line[1024];
int total_words = 0;
int in_word = 0;
while (fgets(line, sizeof(line), stdin)) {
for (int i = 0; line[i] != '\0'; i++) {
if (isspace((unsigned char)line[i])) {
in_word = 0;
} else if (!in_word) {
in_word = 1;
total_words++;
}
}
}
printf("%d words\n", total_words);
return 0;
}
This is the classic "word count" program (the seed of wc -w). It uses fgets to read lines safely, isspace from <ctype.h> to detect whitespace, and a small state machine to count transitions from whitespace to non-whitespace.
16.11 Summary of Chapter 16
A C string is a sequence of char values terminated by a \0 byte.
Use char s[] for strings you will modify; char *s for strings you will only read.
The string.h functions operate on null-terminated strings and assume the \0 is present.
Always prefer snprintf, strncpy, strncat to their unbounded counterparts.
Unicode and wide characters are supported but require care; use a library for serious work.
Exercises 16
Write your own strlen. Test it against the standard strlen.
Write your own strcpy. Test it.
Write your own strcmp. Test it for equal, less-than, and greater-than cases.
The standard strcat is unsafe. Write a strcat_safe that takes a third argument giving the size of the destination buffer, copies at most that many bytes, and always terminates.
Write a program that reads a line of text and prints it backwards. Use strlen.
Write a program that reads a line of text and counts the number of vowels (a, e, i, o, u, both cases).
Buffer overrun. A user types a 100-character name into a 16-byte buffer. What does gets do? What does scanf("%s", buf) do? What does fgets(buf, sizeof(buf), stdin) do?
Course sequence
Part V — Structured Data
So far we have worked with single values and arrays of single values. Real programs need to group related values together: a student's name, ID, and marks belong in one record; an item's name, quantity, and price belong in one record. C's struct is the tool for this.
Chapter
Chapter 17 — Structures
17.1 The motivation
Suppose we are writing a fruit-shop inventory. For each fruit we want to record its name, the quantity in stock, and the unit price. Three separate arrays would work:
char *names[100];
int quantities[100];
double prices[100];
But the i-th element of each array describes one fruit, and the three arrays must be kept in lockstep. Forgetting to update one of them introduces inconsistency. It is much cleaner to put the three values in a single record.
17.2 Declaring a struct
The keyword struct introduces a record type:
struct Fruit {
char *name;
int quantity;
double price;
};
This declares a new type called struct Fruit. The keyword struct is part of the type name. The fields — name, quantity, price — are the named pieces of data inside the record.
To declare a variable of this type:
struct Fruit apple = {"Apple", 10, 12.5};
struct Fruit banana = {"Banana", 25, 4.0};
The initialiser uses braces, with one value per field in declaration order.
17.3 Accessing fields
Use the dot operator to access a field:
printf("%s: %d @ %.2f\n", apple.name, apple.quantity, apple.price);
apple.quantity -= 1; /* sold one apple */
If you have a pointer to a struct, you have two options:
struct Fruit *p = &apple;
printf("%s\n", (*p).name); /* explicit dereference */
printf("%s\n", p->name); /* arrow operator — same thing */
The arrow operator-> is shorthand for (*p).field. Both forms are equivalent; -> is the idiomatic choice when working with pointers to structs.
17.4 Three ways to initialise
struct Fruit apple = {"Apple", 10, 12.5}; /* positional */
struct Fruit mango = {.name = "Mango",
.quantity = 7,
.price = 30.0}; /* designated (C99) */
struct Fruit grapes;
/* The warning below is the key lesson: if `name` is declared as `char *`
(a pointer), strcpy writes through an uninitialised pointer — UB.
Two safe patterns exist (see text after the code). */
strcpy(grapes.name, "Grapes"); /* NOT safe: grapes.name is a pointer,
not an array — needs to point to
allocated or literal memory */
grapes.quantity = 12;
grapes.price = 80.0;
Designated initialisers (the .field = value form) are easier to read and resilient to reordering. Use them when the struct has many fields.
The grapes example above warns but does not show the fix. Two safe patterns exist:
Declare the field as an array (char name[64]; inside the struct definition) and strcpy into it — the array owns storage, so the write is valid:
``c struct Fruit { char name[64]; int quantity; double price; }; struct Fruit grapes; strcpy(grapes.name, "Grapes"); /* now safe — name is an array */ ``
Keep the field as a pointer and point it at a string literal or heap memory:
``c struct Fruit grapes; grapes.name = "Grapes"; /* points at a literal — read-only */ grapes.name = strdup("Grapes"); /* heap copy — caller must free */ ``
Most beginners want pattern 1; pattern 2 is for when the string lives elsewhere or its size is not known at struct-definition time.
17.5 Operations on structs
You can do three things directly with a struct value:
Assign it to another struct of the same type. The fields are copied.
Always use -> when p is a pointer. The expression p.field would be a compile error if p is a pointer (you cannot take a field of a pointer) and an unexpected result if p is a value (it would access the wrong memory).
17.7 Nested structs
A field of a struct may itself be a struct:
struct Date { int day, month, year; };
struct Student {
char name[64];
int id;
struct Date birthday;
};
struct Student s = {"Asha", 12345, {15, 8, 2006}};
printf("%s was born on %d/%d/%d\n",
s.name, s.birthday.day, s.birthday.month, s.birthday.year);
17.8 Structs in memory
The fields of a struct are stored consecutively in memory, in declaration order — subject to alignment. As discussed in Chapter 14, the compiler inserts padding so that each field starts at an address divisible by its size.
A worked example. With
struct S { char a; int b; short c; char d; };
the layout is typically:
offset 0: a (1 byte)
offset 1-3: padding (so b is 4-aligned)
offset 4-7: b (4 bytes)
offset 8-9: c (2 bytes)
offset 10: d (1 byte)
offset 11: padding (so the struct's size is a multiple of 4)
sizeof(struct S) is 12, even though the natural sum of field sizes is only 8. To save space, reorder fields from largest to smallest.
Use the offsetof macro from <stddef.h> to find a field's offset:
Note const in print_fruit: the function promises not to modify the fruit. The compiler enforces this.
17.10 Function pointers in structs
A struct field can be a pointer to a function. This is the basis of many object-oriented patterns in C:
typedef void (*SpeakFn)(void);
The parentheses around *SpeakFn are mandatory: without them, typedef void *SpeakFn(void); would declare SpeakFn as a function returning void *, not a typedef for a pointer to a function. The parentheses bind the * to the typedef-name, which is what makes it a function-pointer type. This is the classic function-pointer-syntax trap; when in doubt, read the declaration right-to-left: "SpeakFn is a pointer (*) to a function ((...)) returning void."
The cat and dog structures look similar but invoke different behaviour. This is how C simulates method dispatch. It is the foundation of C's "object systems" — used by GTK, GLib, and many other libraries.
17.11 Self-referential structs
A struct cannot contain an instance of itself, but it can contain a pointer to one:
struct Node {
int value;
struct Node *next;
};
This is the definition of a linked list: each node holds a value and a pointer to the next node. The last node's next is NULL.
Linked lists are the simplest dynamic data structure. Building them and operating on them is a standard exercise.
17.12 Summary of Chapter 17
A struct groups related fields into a single record.
The keyword struct is part of the type name: struct Fruit.
Access fields with . (for a value) or -> (for a pointer).
Structs are copied on assignment and when passed to functions by value.
Memory layout includes padding for alignment; reorder fields to save space.
Use const struct Foo * for read-only function parameters.
Self-referential structs (using pointers) form the basis of linked lists and trees.
Exercises 17
Define a struct Point with int x, y. Write a function that takes two Points and returns a Point representing their sum.
Define a struct Rect with Point top_left, bottom_right. Write a function that computes the area.
Define a struct Student with char name[64], int id, and an array of three int marks. Write a program that reads five students' data and prints each one's name and average mark.
Predict the output of sizeof for several structs of different shapes. Verify by running a program.
Write a function that takes a const struct Student * and prints the student's name and total marks.
Implement a singly linked list of integers: create, append, find, and print functions. Test with at least three nodes.
Discussion. Why is (*p).field awkward, and why did the designers of C introduce the -> operator instead of just letting you write p.field for pointers?
Chapter
Chapter 18 — Custom Types: typedef, union, enum
18.1 typedef: naming a type
The keyword typedef introduces a new name for an existing type:
typedef unsigned long ulong;
typedef int (*Comparator)(const void *, const void *);
typedef struct { int x, y; } Point;
typedef does not create a new type — it creates an alias for an existing one. After typedef ulong, you can use ulong anywhere unsigned long would be valid; they are interchangeable.
typedef is most useful in three places:
Complex types — function pointers, struct types — get shorter, more readable names.
Platform-portable code — by changing one typedef, you can change the underlying type everywhere it is used.
Hiding implementation details — a header file can declare a typedef whose underlying type is opaque to the user.
A common idiom:
typedef struct Fruit Fruit; /* forward declaration */
struct Fruit {
char *name;
int quantity;
double price;
};
This splits the type definition into two parts. With it, you can write Fruit *p instead of struct Fruit *p — the struct keyword is no longer needed at the use site.
18.2 Anonymous structs
If you do not need to refer to a struct by its tag, you can omit the tag entirely:
typedef struct {
int x, y;
} Point;
Point p = {3, 4};
This is convenient for short-lived structs. For larger programs, prefer named tags: forward declarations, opaque pointers, and self-referential structs all need the tag.
18.3 typedef of built-in types
You can typedef a built-in type. This is occasionally useful for portability:
typedef double real;
typedef int count;
If you later decide you want single-precision floats, change the typedef to typedef float real; and every variable of type real recompiles with the new type.
The risk is that typedef introduces a new name that readers have to learn. Use it only when the new name genuinely improves readability or portability.
18.4 union: overlapping storage
A union is like a struct whose fields share the same memory. At any time, only one of the fields holds a meaningful value; the others are aliases for the same bytes.
union Number {
int as_int;
float as_float;
};
union Number n;
n.as_int = 1065353216; /* 0x3F800000 in hex */
printf("%f\n", n.as_float); /* prints 1.0 */
Both as_int and as_float are at the same address. Writing one overwrites the bits that the other would read.
The size of a union is the size of its largest member. All members start at offset 0.
A union is a cleaner way to achieve what pointer casts do — reinterpret the same bytes through different types — but the syntax is type-safe (the compiler checks the type of each access).
Why use unions?
To save memory when only one of several interpretations is needed at a time.
To inspect the bit-level representation of a value (e.g. what does 3.14159 look like as bytes?).
For variant records — a value that may be one of several types. (C has no proper variant type; a tagged union is the standard workaround.)
Caveat
A union is only safe when you know which field was last written. There is no automatic tracking; the program must remember.
Reading a union member other than the one last written is, strictly, undefined behaviour per the C standard — though in practice nearly all compilers define it as bit reinterpretation (the trick the example above relies on). For strictly portable code, the safe way to reinterpret the bits of one type as another is memcpy into a different variable, as shown in §4.9; that approach has no UB and the optimiser usually optimises it to the same load. Use the union form when you know your compiler's behaviour and you want the convenience; use memcpy when you need the standard's guarantee.
18.5 enum: a list of named integers
An enumeration is a list of named integer values:
enum Day { MON, TUE, WED, THU, FRI, SAT, SUN };
enum Day today = WED;
MON is 0, TUE is 1, WED is 2, and so on — unless you assign explicit values:
enum Color { RED = 10, GREEN = 20, BLUE = 30 };
Or set the starting value and let the rest follow:
enum Priority { LOW = 1, MEDIUM, HIGH };
/* MEDIUM is 2, HIGH is 3 */
Enums in C are essentially integers; the names are just for readability. The compiler does not check that a variable of an enum type holds a valid enum value — you can assign any integer to it.
enum Day d = 200; /* legal, but probably wrong */
Enums and switch
The most common use of enum is in switch statements:
enum Day today = WED;
switch (today) {
case MON: printf("Monday\n"); break;
case TUE: printf("Tuesday\n"); break;
case WED: printf("Wednesday\n"); break;
/* ... */
default: printf("Other\n"); break;
}
Why use enums?
Readability.today = WED is clearer than today = 2.
Maintainability. Adding a new day to the enum does not change the values of existing ones (unless you reorder).
Documentation. The enum lists all the legal values in one place.
18.6 Combined example: a tagged union
A common pattern is to combine struct, union, and enum to make a variant record:
The kind field tracks which union member is currently meaningful. This is exactly the pattern used by compilers, operating systems, and many libraries.
18.7 Summary of Chapter 18
typedef introduces an alias for an existing type.
union puts multiple types at the same memory location; only one is meaningful at a time.
enum is a list of named integer values for readability.
Tagged unions combine enum and union to represent "one of several" types.
Exercises 18
typedef int (*BinOp)(int, int); declares BinOp to be a pointer to a function taking two ints and returning an int. Use it to declare variables for + and *.
Define a union with an int, a float, and a char[4]. Print the bytes of an int by writing it as an int and reading the four chars.
Define an enum for the months of the year. Write a function that prints the number of days in a given month (ignore leap years).
Implement the Shape example from §18.6. Add a TRIANGLE variant.
Discussion. Why doesn't C have a real variant type like other languages? What would change if it did?
Course sequence
Part VI — Dynamic Memory
Until now, every variable's size has been known at compile time: a fixed-size array, a single struct, an int. For many real programs, the data is not known until the program runs: a list of files in a directory, a user-supplied number of sensor readings, the contents of a network packet. Dynamic memory — memory allocated at runtime — is how C handles these cases.
Chapter
Chapter 19 — The Heap, malloc, and Dynamic Allocation
Interactive model
Allocate bounded blocks
A deliberately small model of allocation and release.
Fixed blocks are either free or allocated; freeing a block makes it available again.
19.1 What dynamic memory is
A normal local variable lives on the stack: its size is fixed at compile time, and its lifetime ends when the function returns. Dynamic memory is allocated from a different region called the heap: the size is decided at runtime, and the lifetime is controlled by the programmer — not by the language.
The standard library function malloc performs this allocation. The complementary function free releases it.
19.2 malloc
#include <stdlib.h>
void *malloc(size_t size);
malloc takes a size in bytes and returns a pointer to the start of a freshly-allocated block, or NULL if the allocation fails. The pointer is void * — a pointer to unknown type — because malloc does not know what kind of object the caller wants to store.
The idiomatic call is:
int *p = malloc(10 * sizeof(int)); /* allocate space for 10 ints */
Multiplying by sizeof(int) keeps the code portable: it works correctly whether int is 4 bytes or 8. (Hard-coding 40 would silently break on a platform where int is 8 bytes.)
19.3 free
void free(void *ptr);
free releases a block previously returned by malloc. After free, the pointer is no longer valid and must not be used.
int *p = malloc(10 * sizeof(int));
/* ... use p ... */
free(p);
p = NULL; /* defensive: any accidental use will now crash instead of silently corrupting */
19.4 calloc and realloc
calloc allocates and zeroes:
void *calloc(size_t nmemb, size_t size);
int *p = calloc(10, sizeof(int)); /* 10 ints, all zero */
calloc is convenient when you want a clean array and do not want to write a loop to zero it.
realloc resizes an existing block:
void *realloc(void *ptr, size_t size);
int *p = malloc(10 * sizeof(int));
/* ... realise we need 20 ints ... */
int *tmp = realloc(p, 20 * sizeof(int));
if (tmp == NULL) {
/* realloc failed; p still points to the original 10-int block,
which is still allocated. Handle the error (or keep using p),
and do NOT overwrite p with NULL here or the original is leaked. */
} else {
p = tmp; /* safe: realloc succeeded; p may have moved */
}
If the new size is larger, realloc allocates a bigger block, copies the old contents, and frees the old one. The pointer may change, so always assign the result back. But assign it to a temporary first, not back to p directly: on failure realloc returns NULLand leaves the original block intact, so p = realloc(p, ...) would overwrite your only pointer to the still-allocated original block — a classic leak. The tmp-then-assign idiom above is the safe pattern, and it mirrors the malloc null-check of §19.5.
19.5 Checking for failure
malloc returns NULL when it cannot satisfy the request. Always check:
int *p = malloc(10 * sizeof(int));
if (p == NULL) {
fprintf(stderr, "Out of memory\n");
return 1; /* or exit(EXIT_FAILURE); */
}
On modern desktop systems with virtual memory, allocation almost never fails — but on embedded systems, in long-running servers, or for very large requests, it can. Defensive code checks.
19.6 Freeing memory
Every successful malloc (or calloc, or realloc that grows) must eventually be matched by a free. Forgetting to free is a memory leak: the program holds memory it can no longer use.
void leak(void) {
int *p = malloc(1000 * sizeof(int));
/* no free! */
} /* p is gone but the 4000 bytes are still allocated */
Running this in a loop will eventually exhaust memory.
19.7 Use after free and double free
Two equally serious bugs:
Use after free. Reading or writing through a pointer that has been freed. The bytes may have been reused for something else.
Double free. Calling free twice on the same pointer. The runtime's bookkeeping gets confused; modern systems abort the program.
Both bugs can be detected by setting the freed pointer to NULL immediately after freeing.
19.8 Fragmentation
When allocations and deallocations are interleaved, the free heap can break into scattered small holes. A subsequent request for a larger block may not fit into any single hole — even though the total free space is large enough. This is fragmentation. The C standard places no requirements on how the runtime handles it; in practice, runtimes defragment opportunistically, and programmers who care about allocation patterns use specialised allocators (slabs, pools, arenas) tailored to their workload.
A common pattern to avoid fragmentation: allocate a few large blocks up front and sub-divide them yourself, rather than calling malloc for every small object.
19.9 A complete example
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int n;
printf("How many integers? ");
if (scanf("%d", &n) != 1 || n <= 0) {
fprintf(stderr, "Invalid count.\n");
return 1;
}
int *data = malloc(n * sizeof(int));
if (data == NULL) {
fprintf(stderr, "Out of memory.\n");
return 1;
}
for (int i = 0; i < n; i++) {
data[i] = i * i;
}
long long sum = 0;
for (int i = 0; i < n; i++) {
sum += data[i];
}
printf("Sum of squares 0..%d = %lld\n", n - 1, sum);
free(data);
return 0;
}
The program:
Asks the user how many integers to allocate.
Allocates that much memory on the heap.
Fills it with 0², 1², …, (n−1)².
Sums them.
Frees the memory.
The size n is determined at runtime — exactly what dynamic memory is for.
19.10 The stack-versus-heap comparison
Stack
Heap
Allocation
Automatic on function entry
Explicit via malloc
Deallocation
Automatic on function return
Explicit via free
Size
Limited (typically a few MB)
Limited by available memory
Speed
Very fast
Slightly slower
Lifetime
Until end of enclosing block
Until free or program exit
Indexing
Array decays to pointer
Pointer arithmetic
A rule of thumb:
Use stack allocation for small, fixed-size data with short lifetimes.
Use heap allocation for large data, data whose size is not known at compile time, or data that must outlive the function that created it.
19.11 Memory regions: where variables live
After
int global; /* static region */
static int file_local; /* static region */
void f(int n) {
int local; /* stack */
static int persistent; /* static region, function-scoped */
int *heap = malloc(n * sizeof(int)); /* heap */
/* ... */
free(heap);
}
the locations are:
global and file_local: in the static region, allocated once when the program starts.
persistent: also in the static region, but visible only inside f. Its value persists across calls.
local: on the stack, lives only while f is running.
*heap: on the heap, lives from malloc to free.
19.12 Common pitfalls
Forgetting to multiply by sizeof.malloc(n) allocates n bytes, not n ints.
Forgetting to free. A leak.
Freeing twice. A double-free, often a crash.
Use after free. Reading freed memory; the result is undefined.
Allocating a huge block and trusting the OS. Modern operating systems overcommit: malloc may succeed for an amount larger than the physical memory, but the OS will kill the program when the memory is actually touched (the "OOM killer"). Use sensible sizes and handle failure.
19.13 Summary of Chapter 19
malloc allocates a block of memory on the heap; free releases it.
Always check the return value of malloc for NULL.
calloc zeroes the block; realloc resizes it.
Every successful malloc must be matched by exactly one free.
Use the heap for data whose size is not known at compile time or that must outlive a single function.
Exercises 19
Write a program that reads an integer n from the user, allocates an array of n doubles, fills it with the values 0.0, 1.0, …, n-1.0, and prints them.
Write a function int *make_range(int lo, int hi) that returns a heap-allocated array containing the integers lo, lo+1, …, hi-1. Return NULL if hi <= lo. The caller is responsible for freeing the array.
Write a program that reads a sequence of integers from the user (terminated by 0), stores them in a dynamically grown array (start at size 4 and double when full), and prints them in reverse order.
Implement strdup from scratch: a function that takes a string and returns a heap-allocated copy. The caller must free the result.
Memory leak. The following function leaks memory. Find and fix it.
``c char *read_line(void) { char buf[1024]; fgets(buf, sizeof(buf), stdin); return buf; /* bug: returns a pointer to local storage */ } ``
Discussion. Why does C not include a garbage collector? What are the trade-offs?
Chapter
Chapter 20 — Multidimensional Arrays
20.1 What a multidimensional array is
A two-dimensional array (a matrix) is a grid of values accessed by two indices: row and column.
Memory has no second dimension. To store a 2D array, C flattens it into a 1D block. The default order is row-major: the elements of row 0 come first, then row 1, then row 2.
Each row is in its own braces; the compiler counts fields.
20.4 Iterating a 2D array
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 4; j++) {
printf("%4d", m[i][j]);
}
printf("\n");
}
Two nested loops, one per dimension. The outer loop walks rows; the inner walks columns.
20.5 Higher dimensions
C supports arrays of any dimension:
int v[2][3][4]; /* 2 × 3 × 4 = 24 ints */
The address of v[i][j][k] is base + i * (3 * 4) + j * 4 + k.
20.6 Arrays of pointers: a flexible representation
When the inner dimension is not known at compile time, the static-array trick does not work. The alternative is an array of pointers:
int **m = malloc(rows * sizeof(int *));
for (int i = 0; i < rows; i++) {
m[i] = malloc(cols * sizeof(int));
}
m[i] is a pointer to the i-th row. Each row can have its own length; rows need not be allocated together. But the rows are no longer contiguous, which means cache locality is worse and you must free each row separately:
for (int i = 0; i < rows; i++) free(m[i]);
free(m);
20.7 Flattening for performance
When you allocate dynamically, it is often more efficient to allocate one big block and compute indices yourself:
The macro is defined inside the function, after cols is declared, so cols is in scope when the macro is expanded. (A macro defined at file scope would have to refer to a file-scope cols.) This is one allocation, one free, and the memory is contiguous — better cache behaviour. The price is the manual index computation, which is error-prone. Wrap it in a macro or an inline function.
A subtlety worth stating plainly: macros do not obey C scope rules. A macro is a preprocessor text substitution; once #defined, it remains in effect from that point to the end of the translation unit, regardless of which function or block it was written in. Defining IDX inside main does not make it local to main — any code after the #define will expand IDX too. If you need to confine a macro, follow it with #undef IDX when you are done. This is why macros are a poor substitute for functions or inline helpers, which do respect scope.
20.8 Passing multidimensional arrays to functions
A function that takes a 2D array must know the column count:
void print_matrix(int m[][4], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
printf("%4d", m[i][j]);
}
printf("\n");
}
}
The first dimension can be omitted; the rest must be specified. This is because the address computation depends on the column count.
For arbitrary row counts, pass the number of rows as a separate argument; for arbitrary column counts, you cannot use the static syntax at all — you must use the pointer-to-pointer form or the flattened form.
20.9 A worked example: matrix multiply
void multiply(int a[][MAX], int b[][MAX], int c[][MAX], int n) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
c[i][j] = 0;
for (int k = 0; k < n; k++) {
c[i][j] += a[i][k] * b[k][j];
}
}
}
}
MAX is a constant defining the maximum matrix size. The function computes C = A × B for square n × n matrices. The triple-nested loop runs n³ iterations — the classic O(n³) complexity of matrix multiplication.
20.10 Summary of Chapter 20
C stores multidimensional arrays in row-major order: all of row 0, then all of row 1, and so on.
The compiler needs the inner dimension to compute addresses; it can be inferred only for the outermost dimension.
For dynamic sizes, use an array of pointers (flexible but less efficient) or a flattened block (efficient but error-prone).
Pass the column count when passing a 2D array to a function.
Exercises 20
Write a program that declares int m[3][4], fills it with values i*4 + j + 1, and prints it with row and column headings.
Write a function void zero_matrix(int m[][MAX], int rows) that zeroes a matrix.
Write a function void transpose(int a[][MAX], int b[][MAX], int n) that computes B = Aᵀ.
Implement matrix multiply using the dynamic, flattened layout (allocate a single block).
Without running it, predict the address of m[1][2] in int m[3][4] with base address B = 100. Verify.
Discussion. Why does Fortran use column-major order? What goes wrong if you write a 2D array to a file in C and read it in Fortran?
Course sequence
Part VII — I/O and the Lower Level
Files, bit manipulation, and the preprocessor are how C talks to the outside world: persistent storage on disk, hardware registers at the bit level, and the textual layer that runs before the compiler itself.
Chapter
Chapter 21 — File Handling
21.1 Volatile and persistent memory
The variables we have used so far live in RAM, which is volatile: the contents disappear when the computer loses power. To keep data between runs, we need persistent storage — disks (HDDs, SSDs) or flash.
A file is the operating system's abstraction for persistent storage. You open a file, read or write data, and close it. The OS handles the messy details of moving bytes to and from the actual hardware.
The C standard library provides a small but complete file API, declared in <stdio.h>.
21.2 The FILE abstraction
A file in C is represented by the FILE type — a struct containing metadata (name, location, current position, open mode) plus a buffer. The standard library functions operate on FILE * pointers.
Three files are pre-opened for every program:
Stream
Purpose
Default device
stdin
standard input
keyboard
stdout
standard output
screen
stderr
standard error
screen
These are constants of type FILE *, defined in <stdio.h>. You can use them directly: printf writes to stdout, scanf reads from stdin, and fprintf(stderr, ...) writes error messages.
21.3 Opening and closing
FILE *fopen(const char *path, const char *mode);
int fclose(FILE *fp);
fopen opens a file and returns a FILE *. The mode string specifies the access:
Mode
Meaning
"r"
read (file must exist)
"w"
write (creates or truncates)
"a"
append (creates or appends)
"r+"
read and write (file must exist)
"w+"
read and write (truncates)
"a+"
read and append
A "b" may be appended for binary mode: "rb", "wb". On Linux the b is ignored; on Windows it matters: text mode translates \n to \r\n (CRLF) on write and back to \n on read, which corrupts non-text data. Binary mode performs no translation — what the program writes is exactly what is on disk. Always include the b for binary data, for portability across operating systems.
Always check the return value:
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
perror("data.txt"); /* prints "data.txt: No such file or directory" */
return 1;
}
fclose flushes any buffered output and releases the OS resources. Always close files you have opened.
21.4 Text-mode reading
Several functions read text from a file:
Function
Reads
fgetc(fp)
one character; returns EOF at end-of-file
fgets(buf, n, fp)
a line (up to n−1 characters or newline)
fscanf(fp, fmt, ...)
formatted input
EOF is a macro defined as −1; it is also returned if the read fails.
Reading one character at a time
int ch;
while ((ch = fgetc(fp)) != EOF) {
putchar(ch);
}
This is the simplest file copy.
Reading a line at a time
char line[256];
while (fgets(line, sizeof(line), fp)) {
/* line now holds up to 255 chars plus \0; the newline is preserved if it fit */
printf("%s", line);
}
fgets returns NULL at end-of-file.
Formatted input
char name[64];
int age;
fscanf(fp, "%63s %d", name, &age);
%s reads up to the next whitespace (space, tab, newline); the 63 limit prevents buffer overflow.
21.5 Text-mode writing
Function
Writes
fputc(c, fp)
one character
fputs(s, fp)
a string (without the trailing \0, without adding a newline)
fprintf(fp, fmt, ...)
formatted output
The first argument is the file pointer; the rest is like printf/scanf.
Two output streams exist by design. stdout carries normal program output; stderr carries error messages. They can be redirected independently:
$ ./program > output.txt # stdout to file, stderr to terminal
$ ./program 2> errors.txt # stdout to terminal, stderr to file
$ ./program > out.txt 2> err.txt # both to separate files
Always send errors to stderr, not stdout.
21.7 Binary mode
Text mode is convenient for humans; binary mode is exact for machines. In binary mode, no character translation happens — what the program writes is exactly what is on disk.
The bytes of each int are reversed relative to the numeric value — that is the byte-order of the platform. To exchange binary data across architectures, you must serialise it explicitly (libraries like Protocol Buffers do this for you).
21.8 File position
The FILE struct keeps track of the current position — an offset from the start of the file. Sequential reads advance the position automatically. To move the position explicitly, use:
int fseek(FILE *fp, long offset, int whence);
long ftell(FILE *fp);
void rewind(FILE *fp);
fseek(fp, 0, SEEK_SET) moves to the start; fseek(fp, 0, SEEK_END) moves to the end. ftell returns the current position.
This is a tiny version of the Unix cp command. It opens a source file for binary reading, a destination for binary writing, transfers 4 KB at a time, and closes both.
Note argc and argv: the program's command-line arguments. argv[0] is the program's name; argv[1] and argv[2] are the source and destination filenames.
21.10 Common pitfalls
Forgetting to close a file. The OS will eventually clean up, but the last bytes may not be flushed; data may be lost.
Writing past the end of a string with %s. Always specify a width: snprintf(buf, sizeof(buf), "%s", name).
Reading past EOF.fgetc after EOF returns EOF again, but fread after EOF returns 0. Either is safe; the program just gets no more data.
Mixing binary and text on the same file. If you opened with "w" and write a few bytes, then open again with "wb" and write more, the result will not be what you expected.
21.11 Summary of Chapter 21
A file is the OS abstraction for persistent storage.
C represents files with FILE *; the standard streams stdin, stdout, stderr are pre-opened.
Open with fopen, close with fclose. Always check for NULL.
Text mode: fgetc, fgets, fprintf, fscanf. Binary mode: fread, fwrite.
stderr is for error messages and can be redirected independently of stdout.
Exercises 21
Write a program that opens a file named input.txt, reads it character by character, and prints how many lines, words, and characters it contains. (This is wc.)
Modify the program to copy one file to another using text mode (one line at a time).
Write a program that reads a text file and prints the longest line. (Hint: use fgets.)
Write a program that writes a struct to a file using fwrite, then reads it back with fread. Verify that the bytes round-trip.
Run hd (or xxd) on a binary file produced by your program. Confirm that the byte order matches your machine's endianness.
Write a program that opens two files given on the command line and prints whether they are byte-identical.
Chapter
Chapter 22 — Bit Manipulation
Interactive model
Inspect a bit register
Toggle named flags and read the resulting mask.
Each named flag contributes one bit to the register value.
22.1 Why manipulate bits?
Most programs deal with whole numbers. Some need to look at — or change — the individual bits inside them. The classic examples are:
Reading the state of a switch or button (each input is one bit).
Driving an LED or relay (each output is one bit).
Storing several boolean flags in one integer.
Encoding data compactly (e.g., a Unicode character in 21 bits).
Implementing cryptographic algorithms and checksums.
Working with hardware registers, where each bit has a specific meaning.
C's bitwise operators are the tools for these jobs.
22.2 The bitwise operators
Operator
Meaning
Per-bit rule
&
AND
1 if both bits are 1
`
`
OR
1 if either bit is 1
^
XOR (exclusive OR)
1 if the bits differ
~
NOT (complement)
flip every bit
<<
left shift
shift left, fill with 0
>>
right shift
shift right; behaviour for signed depends on compiler
These are bitwise — they operate on every bit of their operands in parallel. They are different from the logical operators &&, ||, !, which treat their operands as truth values.
1100 (12)
& 1010 (10)
= 1000 (8)
22.3 Bit masks
A bit mask is a value with 1s in the positions you care about and 0s elsewhere. You combine it with & to extract, | to set, ~& to clear, and ^ to toggle.
unsigned int x = 0xCA; /* hypothetical value: binary 1100 1010 */
/* Test bit 3 */
unsigned int mask = 1u << 3;
if (x & mask) { /* bit 3 is set */ }
/* Set bit 3 */
x |= mask;
/* Clear bit 3 */
x &= ~mask;
/* Toggle bit 3 */
x ^= mask;
1u << n is a mask with the n-th bit set. The u makes it unsigned, which avoids surprises with negative shifts.
22.4 A worked example: status register
Imagine a hardware status register with bits:
bit 0: power on
bit 1: error
bit 2: busy
bit 3: data ready
(status >> n) & 1 extracts the n-th bit. For multi-bit fields, widen the mask:
/* bits 4-7 hold a 4-bit error code */
unsigned int err = (status >> 4) & 0xF;
22.5 A worked example: setting and clearing bits interactively
#include <stdio.h>
int main(void) {
unsigned int reg = 0;
int bit;
char op;
while (scanf(" %c %d", &op, &bit) == 2) {
switch (op) {
case 's': reg |= (1u << bit); break; /* set */
case 'c': reg &= ~(1u << bit); break; /* clear */
case 't': reg ^= (1u << bit); break; /* toggle */
default:
fprintf(stderr, "Unknown op '%c'\n", op);
return 1;
}
printf("0x%08X\n", reg);
}
return 0;
}
Try the session:
s 0
0x00000001
s 4
0x00000011
t 0
0x00000010
c 4
0x00000000
22.6 Bit fields in structs
C lets you declare struct fields with a specific bit width:
struct Packed {
unsigned int a : 5; /* 5 bits */
unsigned int b : 3; /* 3 bits */
unsigned int c : 8; /* 8 bits */
unsigned int d : 16; /* 16 bits */
};
sizeof(struct Packed) is typically 4 bytes — the compiler packs the four fields into a single unsigned int.
Bit fields are tempting for memory savings, but the layout is compiler-defined. Two concrete pitfalls bite in practice: (1) the bit order within a storage unit is implementation-defined — some compilers fill the least-significant bits first, others the most-significant, so the same struct may pack differently on different compilers; (2) whether a field may span a storage-unit boundary is also implementation-defined — a 3-bit field may or may not straddle two unsigned ints. Because of these, the on-disk or on-wire layout of a bit-field struct is not predictable. Avoid bit fields when the layout matters (network protocols, file formats, hardware registers); use explicit shifts and masks instead.
22.7 Shift pitfalls
Shifting by a negative amount or by more than the type's width is undefined behaviour.
int x = 1 << 32; /* undefined if int is 32 bits */
int y = 1 << -1; /* undefined */
Right-shifting a negative signed integer. The result is implementation-defined — usually a sign-extended shift (preserves the sign), but some platforms zero-fill. Cast to unsigned if you need a defined behaviour.
int signed_x = -1;
unsigned int u = (unsigned int)signed_x >> 4; /* defined */
Left-shifting into the sign bit of a signed integer. Also undefined behaviour. Use unsigned for bit-level work.
22.8 Common idioms
Multiply or divide by a power of two.
x << 3 /* x * 8 */
x >> 2 /* x / 4 (for unsigned) */
The compiler does this automatically when it sees x * 8, so the manual idiom is rarely needed — but it is the result of the optimisation, which makes it useful to recognise when reading generated code.
Test whether a number is a power of two.
(x > 0) && ((x & (x - 1)) == 0)
A power of two has exactly one bit set. Subtracting one clears that bit and sets all lower bits; AND with the original gives zero.
Swap two integers without a temporary.
a ^= b;
b ^= a;
a ^= b;
Clever, but rarely worth the obscurity. Use a temporary in real code.
**Round up to a multiple of n.**
((x + n - 1) / n) * n /* for positive x */
This pattern shows up in allocation, alignment, and buffer sizing.
22.9 Endianness revisited
Bit operations interact with endianness when values cross machine boundaries. A value like 0x12345678 is stored as bytes 12 34 56 78 in big-endian and 78 56 34 12 in little-endian. The bit pattern within each byte is the same; only the byte order differs.
For pure in-memory bit manipulation, endianness is invisible. For data interchange, it is everything.
22.10 Summary of Chapter 22
C provides bitwise operators &, |, ^, ~, <<, >> that operate on every bit of their operands.
A mask with 1 << n isolates the n-th bit.
Combine the mask with & to test, |= to set, &= ~ to clear, ^= to toggle.
Bit fields in structs are tempting but compiler-defined in layout; prefer explicit masks for portable bit packing.
Avoid undefined behaviour: shifting by a negative amount, by more than the width, or into the sign bit of a signed value.
Exercises 22
Write a function int is_bit_set(unsigned int x, int n) that returns 1 if bit n of x is set and 0 otherwise.
Write a function unsigned int set_bit(unsigned int x, int n) that returns x with bit n set.
Write a function unsigned int toggle_bits(unsigned int x, unsigned int mask) that toggles the bits of x selected by mask.
Without running it, predict the output of:
``c unsigned int x = 0xCAFE; printf("%x\n", x & 0xFF); printf("%x\n", x >> 8); printf("%x\n", (x >> 4) & 0xF); printf("%x\n", x | 0x0001); ``
Implement a function unsigned int reverse_bits(unsigned int x) that reverses the order of bits in x.
Implement a function unsigned int popcount(unsigned int x) that returns the number of 1-bits in x.
Discussion. Why does the C standard leave right-shifting a negative signed integer undefined? What does this say about the design philosophy of the language?
Course sequence
Part VIII — The Toolchain
Before the C compiler sees your code, a separate program — the preprocessor — transforms it. After the compiler produces object files, another program — the linker — combines them into an executable. This part explains both halves of the toolchain.
Chapter
Chapter 23 — The Preprocessor and Macros
Interactive model
Follow the compilation pipeline
Step from source text to a running program.
Preprocess → compile → assemble → link → run.
23.1 What the preprocessor does
When you write #include <stdio.h> or #define PI 3.14159, you are writing a directive to a program called the preprocessor that runs before the compiler. The preprocessor is a separate tool (often called cpp, for C preprocessor) that performs macro expansion — direct text substitution — and produces a modified source file that the compiler then compiles.
You can see the preprocessor's output by running it directly:
$ cpp hello.c
On a typical system this prints the preprocessed source — your #includes expanded, your macros substituted, your #ifdefs evaluated.
23.2 #include — pasting in another file
We have used #include since Chapter 4. It tells the preprocessor to replace the #include line with the entire contents of the named file.
#include <stdio.h> /* system header */
#include "myheader.h" /* your own header */
Angle brackets search the standard system include paths. Double quotes search the current directory first, then the system paths.
23.3 #define — simple macros
#define PI 3.14159
#define MAX 100
After preprocessing, every occurrence of the token PI is replaced by the text 3.14159, and every occurrence of MAX by 100. Macros have no type and no scope; they are pure text substitution.
A macro creates no variable and allocates no memory. After preprocessing, the macro name has vanished — the compiler sees only its substituted value.
#ifdef NAME is true if NAME is defined as a macro; #ifndef is the opposite. #if defined(X) && !defined(Y) allows more complex conditions.
Conditional compilation is the standard way to handle platform differences and to enable debug code in development builds without affecting release performance.
23.9 Built-in macros
The preprocessor defines several macros automatically:
Macro
Meaning
__FILE__
current source file name (string)
__LINE__
current line number
__func__
current function name (C99)
__DATE__
date of compilation
__TIME__
time of compilation
__STDC__
1 if the compiler is standard C
__STDC_VERSION__
the C standard version (e.g. 201112L for C11)
These are invaluable for debugging:
#define CHECK(x) do { \
if (!(x)) { \
fprintf(stderr, "%s:%d: check failed: %s\n", \
__FILE__, __LINE__, #x); \
abort(); \
} \
} while (0)
CHECK(p != NULL) automatically includes the file, line, and expression in the error message.
23.10 Macros versus functions
Macros look like functions but differ in three important ways:
Macro
Function
Evaluation
Compile-time text substitution
Runtime call
Argument types
None — works for any type
Fixed by the signature
Side effects
Arguments may be evaluated multiple times
Each argument evaluated once
The third row is the dangerous one:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int i = 0;
int j = MAX(i++, 5); /* i++ evaluated twice; undefined behaviour */
i++ is incremented twice if i is the larger value. Use a function instead:
static inline int max_int(int a, int b) { return a > b ? a : b; }
Modern C code prefers static inline functions over macros for almost all uses. Reserve macros for cases where you genuinely need compile-time text substitution (include guards, conditional compilation, stringification).
The #ifndef CONFIG_H / #define CONFIG_H / #endif block is an include guard: it ensures the header is processed only once even if it is included multiple times. (Many compilers also support #pragma once, but the guard pattern is portable.)
23.12 Summary of Chapter 23
The preprocessor runs before the compiler, performing text substitution and conditional compilation.
#include pastes in another file's contents.
#define declares a macro — a token the preprocessor replaces with arbitrary text.
Macros with arguments must parenthesise both arguments and body to avoid precedence bugs.
# stringifies an argument; ## pastes tokens together.
#if/#ifdef/#ifndef allow conditional compilation.
Modern C code prefers static inline functions over function-like macros when both are possible.
Exercises 23
Define a macro SQUARE(x) that computes x * x correctly. Test with SQUARE(2 + 3). Compare with a version without parentheses.
Define a macro IS_POWER_OF_TWO(x) that tests whether x is a power of two.
Write a header file with an include guard, one constant, one macro, and one conditional-compilation block. Include it from two .c files in a small project.
Write a DEBUG_LOG macro that prints file:line: message only when DEBUG is defined, and does nothing otherwise.
Bug. The macro MAX(x, y) x > y ? x : y is broken. Give an example input that produces the wrong answer, and fix it.
Discussion. Why might a project prefer macros to functions, despite the side-effect risks?
Chapter
Chapter 24 — Multi-file Projects and Compilation
24.1 Why split a program into multiple files?
A single-file program is fine for short examples. Real programs reach thousands or millions of lines; a single file becomes unmanageable. Splitting a project into multiple files offers several benefits:
Compilation speed. When one file changes, only that file needs to be recompiled.
Modularity. Each file can be developed and tested independently.
Reusability. A function defined in one file can be used by other programs.
Team work. Different programmers can own different files.
24.2 The compilation pipeline revisited
To compile a multi-file project, the toolchain does three things:
The preprocessor expands #include and #define.
The compiler translates each .c file into a separate object file (.o), containing machine code and relocation information. This intermediate form is sometimes called object code.
The linker combines all the object files and any libraries into a single executable.
main.c sees only the declarations from util.h. It does not need to know how square is implemented. As long as util.c provides the bodies, the linker will resolve the calls.
24.5 extern for variables
If a global variable is defined in one file and used in another, declare it extern in the user:
/* counter.c */
int global_count = 0; /* definition: allocates storage */
/* main.c */
extern int global_count; /* declaration: refers to existing */
void increment(void) { global_count++; }
The keyword extern says "this variable exists somewhere; do not allocate storage for it here". Without it, the declaration in main.c would be a redefinition, and the linker would complain.
A common pattern is to put the extern declaration in a header file:
/* shared.h */
extern int global_count;
Then every .c file that wants global_count does #include "shared.h". The single definition lives in one .c file.
24.6 static for internal linkage
The keyword static, applied to a function or a global variable, gives it internal linkage: the name is visible only in the file where it is defined.
/* util.c */
static int internal_helper(int x) { /* not visible from main.c */
return x * 2;
}
int public_function(int x) { /* visible from main.c (via util.h) */
return internal_helper(x) + 1;
}
static is a powerful encapsulation mechanism. It lets you write helper functions and helper variables that are hidden from the rest of the program — a primitive form of information hiding.
24.7 The compilation database
As a project grows, the command to compile it grows too. Re-typing gcc -Wall -Wextra -std=c11 -Iinclude -c main.c util.c ... is tedious and error-prone.
Save as build.sh, run with bash build.sh. This is better than nothing.
For real projects, the standard tool is make. A Makefile lists targets, their dependencies, and the commands to build them. make then does the minimum work to bring everything up to date.
The indentation must be a real TAB character, not spaces — a common Makefile pitfall.
Reading the file:
myprog depends on main.o and util.o; to build it, link them.
main.o depends on main.c and util.h; to build it, compile main.c.
util.o depends on util.c and util.h; to build it, compile util.c.
all is the default target; it builds myprog.
clean removes the build artefacts.
Run make to build, make clean to remove.
24.9 How make decides what to do
make looks at the modification times of the files. If main.c is newer than main.o, main.o is rebuilt. If main.o is newer than myprog, myprog is relinked. Files older than their outputs are skipped.
This is incremental compilation: only the changed files are rebuilt. For a project with hundreds of files, this saves hours.
% is a Make pattern; %.o: src/%.c says "to build any .o file, look in src/ for the matching .c". $@ is the target, $^ is the list of prerequisites, $< is the first prerequisite.
24.11 Compiler flags worth knowing
Flag
Meaning
-Wall
common warnings
-Wextra
additional warnings
-Wpedantic
strict standard-conformance warnings
-std=c11 (or c99, c17)
language standard
-O0, -O1, -O2, -O3
optimisation level
-g
include debugging symbols
-Ipath
add path to header search list
-Lpath
add path to library search list
-lm
link the math library
-D NAME
define the macro NAME (equivalent to #define NAME)
-D NAME=value
define NAME as value
-c
compile only, do not link
-o name
name the output file name
-fsanitize=address,undefined
enable runtime bug detection
-Wall -Wextra -Wpedantic -std=c11 -O2 is a sensible default for development.
24.12 Sanitisers: catching bugs early
Modern versions of gcc and clang can instrument the program to detect common bugs at runtime:
AddressSanitizer (ASan) detects buffer overflows, use-after-free, and other memory errors.
UndefinedBehaviourSanitizer (UBSan) detects signed overflow, misaligned access, and other undefined behaviour.
ThreadSanitizer (TSan) detects data races in multi-threaded code.
Enable with -fsanitize=address,undefined. The runtime overhead is significant (often 2–3×), but the bugs you find are priceless.
A multi-file project splits source into .c files (implementations) and .h files (interfaces).
Each .c is compiled to a .o object file; the linker combines object files and libraries into an executable.
Use extern to declare variables defined elsewhere; use static to keep names private to a file.
A Makefile automates the build; make rebuilds only what changed.
Use -Wall -Wextra -Wpedantic -std=c11 for development; add -fsanitize=address,undefined to catch bugs.
Exercises 24
Split the prime-tester program from Chapter 8 into a prime.c (containing is_prime) and a main.c (containing main). Add a prime.h. Write a Makefile. Build it.
Add a -DDEBUG flag to the Makefile that, when set, enables an extra #ifdef DEBUG block in prime.c.
Try compiling a deliberately buggy program with -fsanitize=address. Observe the diagnostic output.
Build the project from this chapter with both -O0 and -O2. Compare the size and runtime.
Discussion. Why does the linker complain if you forget to #include the header that declares a function you call?
Back Matter
Reference
Appendix A — Operator Precedence
From highest to lowest precedence. Operators on the same line have equal precedence and associate left-to-right (unless noted).
Precedence
Operators
Description
Associativity
1
()[]->.
postfix
left
2
++--+-!~(type)*&sizeof
unary
right
3
*/%
multiplicative
left
4
+-
additive
left
5
<<>>
shift
left
6
<<=>>=
relational
left
7
==!=
equality
left
8
&
bitwise AND
left
9
^
bitwise XOR
left
10
`
`
bitwise OR
left
11
&&
logical AND
left
12
`
`
logical OR
left
13
?:
ternary
right
14
=+=-=*=/=%=<<=>>=&=^= `
=`
assignment
right
15
,
comma
left
When in doubt, add parentheses.
Reference
Appendix B — Common Standard Library Functions
A short reference for the library functions used in this book. Headers in angle brackets.
<stdio.h> — input and output
Function
Purpose
int printf(const char *fmt, ...)
print formatted text to stdout
int fprintf(FILE *fp, const char *fmt, ...)
print formatted text to a file
int sprintf(char *buf, const char *fmt, ...)
print to a string buffer (unsafe)
int snprintf(char *buf, size_t n, const char *fmt, ...)
int memcmp(const void *a, const void *b, size_t n)
compare n bytes
<math.h> — mathematical functions
Link with -lm on Linux/macOS.
Function
Purpose
double sqrt(double x)
square root
double pow(double x, double y)
x to the y
double exp(double x)
e to the x
double log(double x)
natural log
double log10(double x)
base-10 log
double sin(double x), cos, tan
trigonometric
double asin, acos, atan
inverse trigonometric
double sinh, cosh, tanh
hyperbolic
double floor(double x), ceil
round down/up
double fabs(double x)
absolute value
double fmod(double x, double y)
floating-point remainder
<ctype.h> — character classification
Function
Purpose
int isspace(int c)
whitespace?
int isdigit(int c)
digit?
int isalpha(int c)
alphabetic?
int isalnum(int c)
alphanumeric?
int isupper(int c), islower
upper/lower case?
int toupper(int c), tolower
convert case
int ispunct(int c)
punctuation?
<stdint.h> — exact-width integer types
Type
Exact size
int8_t, uint8_t
1 byte
int16_t, uint16_t
2 bytes
int32_t, uint32_t
4 bytes
int64_t, uint64_t
8 bytes
intptr_t, uintptr_t
large enough to hold a pointer
size_t
unsigned, the size of any object
<assert.h> — assertions
Function
Purpose
void assert(int expression)
abort if expression is zero (in debug builds)
<errno.h> — error codes
Symbol
Meaning
errno
global variable set by some functions on error
EDOM, ERANGE, EILSEQ, ...
specific error codes
perror and strerror
print error messages
Reference
Appendix C — ASCII Reference
The most useful ASCII codes. Note that ASCII is a 7-bit code (0–127); values 128–255 are extensions that vary by encoding (UTF-8 is backward compatible).
Dec
Hex
Char
Description
0
00
NUL
null
9
09
HT
tab
10
0A
LF
newline
13
0D
CR
carriage return
32
20
SP
space
48–57
30–39
0–9
digits
65–90
41–5A
A–Z
uppercase letters
97–122
61–7A
a–z
lowercase letters
Full table: see man ascii.
Reference
Appendix D — Glossary
A condensed vocabulary of the most important terms in the course. Page references point to where each concept is first introduced or explained in detail.
address. A numerical location of a byte in memory. §1.3.
ALU (Arithmetic and Logic Unit). The hardware that performs arithmetic and logical operations. §1.5.
algorithm. A sequence of operations needed to perform a computation. §2.4.
argument. A value passed to a function when it is called. §9.4.
array. A contiguous sequence of identically-typed values indexed from 0. §15.
big-endian. Byte order in which the most significant byte comes first. §14.1.
BIOS. The small fixed program that loads the operating system on boot. §3.9.
bit. A single binary digit (0 or 1). §1.3.
bitwise operator. An operator that acts on individual bits (&, |, ^, ~, <<, >>). §22.2.
block. A sequence of declarations and statements enclosed in braces. §12.2.
boolean. The values true and false. C has no native boolean type; 0 is false, non-zero is true. §6.4.
bootstrapping. Building a C compiler in a small Assembly core, then using C to write everything else. §3.3.
break. A statement that exits the innermost enclosing loop or switch. §8.8.
byte. The unit of memory addressing, conventionally 8 bits. §1.4.
call by value. Passing arguments as copies. §9.5.
character encoding. A mapping between characters and integers (e.g. ASCII, UTF-8). §4.11.
comment. Text in a program that documents it; ignored by the compiler. §4.3.
compiler. A program that translates source code into machine code. §3.2.
compilation unit. A source file together with its included headers. §24.
constant. A literal value written in the program (e.g. 42, 'a', "hello").
control flow. The order in which statements of a program are executed. §7.
CPU (Central Processing Unit). The chip that executes instructions. §1.1.
dangling pointer. A pointer to memory that has been freed or to a local variable whose scope has ended.
data structure. The arrangement of data in memory. §2.4.
declaration. A statement that introduces a name and its type. §4.3.
dereference. Access the value at the address held in a pointer (*p). §13.4.
dynamic memory. Memory allocated at runtime via malloc. §19.
endianness. Byte order for multi-byte values. §14.1.
enum. A list of named integer constants. §18.5.
escape sequence. A two-character representation of a non-printable character (\n, \t, etc.).
executable. A file that the operating system can load and run. §3.2.
expression. A fragment of code that has a value. §4.4.
FILE. The C abstraction for a file. §21.2.
flag. A boolean value stored as a single bit. §22.
format string. A string passed to printf/scanf containing placeholders (%d, %s, …). §4.4.
function. A named, self-contained block of code. §9.
function-static variable. A variable inside a function that persists across calls. §12.7.
global variable. A variable declared outside any function; visible throughout the file. §12.5.
heap. The memory region for dynamically allocated blocks. §19.
header file. A .h file containing declarations to be shared between source files. §24.4.
IEEE 754. The standard for binary floating-point representation. §4.9.
include guard.#ifndef ... #define ... #endif preventing multiple inclusion of a header. §23.11.
initialisation. Giving a variable its first value at the point of declaration.
integer overflow. The wrap-around that happens when arithmetic exceeds the representable range. §4.8.
instruction set architecture (ISA). The set of instructions a CPU understands. §4.12.
ISO C. The standard that defines the C language; current versions are C99, C11, C17, C23.
kernel. The central program of the operating system. §3.9.
keyword. A word reserved by the C language (int, if, while, …).
linker. The tool that combines object files into an executable. §24.2.
little-endian. Byte order in which the least significant byte comes first. §14.1.
loop. A construct that repeats a sequence of statements. §8.
macro. A preprocessor substitution rule. §23.
main. The function where execution of a C program begins.
malloc. Allocate a block of heap memory. §19.2.
memory. The hardware that stores program data and instructions. §1.3.
memory-mapped I/O. Accessing peripherals via ordinary load/store instructions. §1.11.
null pointer. A pointer whose value is zero; it points to nothing. §13.6.
null-terminated string. A char array terminated by a zero byte. §16.
object file. The output of the compiler for a single source file (.o). §24.2.
operand. An input to an operation. §1.2.
operator. A symbol that performs a computation. §6.7.
operator precedence. The rules for the order in which operators are evaluated. Appendix A.
pointer. A variable whose value is an address. §13.
preprocessor. The program that processes #include and #define directives before compilation. §23.
printf. The standard formatted output function. §4.
program counter. The register that holds the address of the next instruction to execute. §1.10.
recursion. A function calling itself. §11.
register. A small, fast storage location inside the CPU. §1.7.
scope. The region of source code where a name is visible. §12.
semantics. The meaning of a program (as opposed to its syntax). §4.5.
shadowing. An inner declaration hiding an outer declaration of the same name. §12.3.
sizeof. Compile-time operator that returns the size of a type or expression. §6.7.9.
stack. The memory region where function frames live. §10.3.
stack frame. The block of memory associated with one function call. §10.3.
standard library. The set of functions and headers specified by ISO C. Appendix B.
statement. A unit of execution in a program, ending with ;. §4.3.
static. A keyword giving internal linkage or persistent storage. §12.7, §24.6.
string. A sequence of char values terminated by a null byte. §16.
struct. A composite data type whose members are accessed by name. §17.
switch. A control-flow statement for multi-way branching. §7.7.
syntax. The grammar rules of a programming language. §4.5.
tagged union. A struct containing an enum tag and a union of variant data. §18.6.
ternary operator. The ?: operator that selects between two values. §6.7.7.
truncation. Discarding the fractional part when converting a floating-point value to an integer. §6.7.1.
two's complement. The signed-integer convention used by all modern computers. §4.7.
type. A classification of values that determines how they are represented and what operations apply. §6.3.
typedef. A keyword that introduces a new name for an existing type. §18.1.
uninitialised. Said of a variable that has been declared but not yet given a value. Reading such a variable is undefined behaviour.
union. A data type whose members share the same memory. §18.4.
unsigned. A type modifier that forbids negative values. §6.3.
variable. A named storage location. §6.
void. The keyword meaning "no value" or "unknown type". §9.6.
volatile. A type qualifier telling the compiler that a value can change outside the program's control. §14.6.
while. A loop construct that tests its condition before each iteration. §8.2.
word. A fixed collection of bits; the natural unit of data for a CPU. §1.4.
Index
A selection of important terms and where to find them. Page references would normally point to physical pages; in this digital edition they point to chapters.
Address — §1.3, §13
Address-of operator (&) — §13.3
ALU — §1.5
Alignment — §14.2
Argument — §9.4
Array — §15
Array decay — §15.4
ASCII — §4.11, Appendix C
Assembly language — §3.1
Assignment — §6.6
Big-endian — §14.1
Bit — §1.3
Bitwise operators — §22.2
Block (scope) — §12.2
Boolean — §6.4
break statement — §8.8
Buffer overrun — §16.9
calloc — §19.4
char — §6.3, §16
Character encoding — §4.11
Compiler — §3.2
Compound assignment (+= etc.) — §6.7.2
Conditional compilation — §23.8
const — §13.7, §16.7
Control flow — §7
CPU — §1.1
Data structure — §2.4
define (#define) — §23.3
Dereference (*) — §13.4
Dynamic memory — §19
Endianness — §14.1
enum — §18.5
Escape sequence — §5.1
exit — see Appendix B
extern — §12.7, §24.5
fclose — §21.3
fgetc — §21.4
fgets — §21.4
FILE — §21.2
float — §4.9, §6.3
fopen — §21.3
Format string — §5.4
fprintf — §21.5
fread — §21.7
free — §19.3
fscanf — §21.4
fseek — §21.8
fwrite — §21.7
Function — §9
Function pointer — §17.10
getchar — see Appendix B
Global variable — §12.5
Header file — §24.4
Heap — §10.7, §19
if statement — §7.3
#include — §5.1, §23.2
Include guard — §23.11
int — §6.3
Integer overflow — §4.8, §8.9
ISO C — §3.2
Keyword — see Appendix A
Lifetime — §12.6
Linker — §24.2
Little-endian — §14.1
Local variable — §10.3
Loop — §8
main — §5.1
malloc — §19.2
#include <math.h> — Appendix B
Makefile — §24.8
memcpy, memset — see Appendix B
Memory — §1.3
Memory layout — §10.2
Memory model — §2.2.1
Memory-mapped I/O — §14.6
Mnemonic — §4.12
Multi-dimensional array — §20
NULL — §13.6
Null pointer — §13.6
Null-terminated string — §16.1
Object file — §24.2
Operator precedence — Appendix A
Optimization — §14.4
Out-of-bounds access — §15.6
Pass by value — §9.5
Pointer — §13
Pointer arithmetic — §15.4
printf — §5.1, §21.5
Program counter — §1.10
putchar — see Appendix B
qsort — see Appendix B
realloc — §19.4
Recursion — §11
register — §12.7
Register (CPU) — §1.7
return — §9.7
scanf — §5.7
Scope — §12
Shadowing — §12.3
short — §6.3
signed/unsigned — §6.3
sizeof — §6.7.9, §15.5
snprintf — §16.9
Stack — §10.3
Stack frame — §10.3
static — §12.7, §24.6
stderr — §21.6
stdin — §21.2
stdout — §21.2
strcat, strcmp, strcpy — §16.4
String — §16
strlen — §16.4
strncpy, strncat — §16.4
struct — §17
Structure padding — §14.2, §17.8
switch statement — §7.7
Tag (of struct) — §17.2
Tail recursion — §11, Exercise 5
Ternary operator (?:) — §6.7.7
typedef — §18.1
Two's complement — §4.7
Type casting — §14.6
unsigned — §6.3
void — §9.6
void * — §13.10
volatile — §14.6
while loop — §8.2
Wide character (wchar_t) — §16.8
Word (data unit) — §1.4
Final Note
Programming in C is not a topic you can absorb in one sitting. Each chapter of this book introduces ideas that you will return to for years — sometimes decades. As your programs grow in size and ambition, you will find yourself reaching back into the fundamentals: control flow, pointers, memory layout, the build process. The C language rewards depth.
The best way to use this book is to keep it close while you write code. When something does not behave as you expect, look up the relevant chapter. When a concept seems abstract, write a program that demonstrates it. When a chapter's exercises feel too easy, invent harder ones.