Learn › Learn C › C Foundations
switch - a hands-on Linux lab on a real virtual machine.
Learn the tools that steer a C program: if/else decisions, for and while loops, switch dispatch, and the && and combiners. Predict branch outcomes before you compile, watch a switch with a missing break print a word twice, and see why short-circuit evaluation can stand guard in front of a division by zero. Sets up Lab: Max of Three, where an if/else chain becomes a real graded program.
In Types and sizes you watched INT_MAX + 1 wrap around to a huge negative number. That surprise came from how integers are stored. Today's surprise comes from somewhere else entirely: the order your lines run in.
Here is a short program. It counts a number n from 1 to 3. For each value, a switch picks a word to print. You have not learned for or switch yet; that is fine. Read it like plain English for now. Every piece gets its own section below.
#include <stdio.h>
int main(void) { for (int n = 1; n <= 3; n++) { switch (n) { case 1: printf("one\n"); break; case 2: printf("two (falls through)\n"); case 3: printf("three\n"); break; } } return 0; }
Three numbers, three matching words. It looks like it should print exactly three lines.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every program output in this lesson was captured on the real lab image (Debian 12, gcc 12.2.0), the same machine your labs run on. Remember the compile shape from Hello, machine: gcc -Wall file.c -o name builds the program, ./name runs it, and -Wall asks gcc to warn about suspicious code.
prompt: operator@camp:~$ answer: gcc -Wall fallthrough.c -o fallthrough && ./fallthrough output: one two (falls through) three three hint: The compile-then-run shape is gcc -Wall fallthrough.c -o fallthrough && ./fallthrough
Count the lines. Four, not three. The word three printed twice, and gcc -Wall did not say a word about it. Nothing in the source says print three twice. Hold that thought. By the end of this lesson you will see exactly why, and it will feel obvious.
Every program you have compiled so far ran top to bottom, every line exactly once, like reading a page. Real programs cannot live like that. A login check needs to do one thing for a good password and another for a bad one. A file copier needs to repeat the same step for every file. A menu needs to jump to whichever option was picked.
Control flow is the order in which a program's statements run, and the tools that change that order. Think of a recipe. A recipe is not just a list: it says "if the dough is sticky, add flour" and "knead ten times". Decisions and repeats. That is control flow.
C gives you a small toolkit. if and else decide. for and while repeat. switch chooses one arm out of many. Two operators, && (AND) and || (OR), combine tests into bigger tests.
One ground rule before any of them: C has no separate yes/no value in its old core. A condition is just a number. Zero means false. Anything that is not zero means true. A comparison like score >= 70 produces the int 1 when it holds and the int 0 when it does not. Burn that in; two of the classic bugs below come straight from it.
if (condition) { ... } runs the lines between the braces only when the condition is true, meaning not zero. The braces and everything between them are called a block. An else if adds another test to try when the first fails, and a bare else catches everything that fell through the tests.
#include <stdio.h>
int main(void) { int score = 71;
if (score >= 90) { printf("grade: A\n"); } else if (score >= 70) { printf("grade: B\n"); } else { printf("grade: C\n"); } return 0; }
The comparisons you can use inside a condition: == equal, != not equal, < less, <= less or equal, > greater, >= greater or equal. Note that testing for equality takes TWO equals signs. A single = means assign, and it is a trap we will spring before this step is over.
>>> The chain is checked from the top. score >= 90 is false, since 71 is not 90 or more, so the first block is skipped. score >= 70 is true, so grade: B prints. And here is the part people miss: once one branch runs, the whole chain is finished. The else is never even looked at. An if/else chain runs exactly one branch, always.
prompt: operator@camp:~$ answer: gcc -Wall grade.c -o grade && ./grade output: grade: B hint: Same shape as before: gcc -Wall grade.c -o grade && ./grade
One line, grade: B. The order of the tests is doing real work here. The chain tries score >= 90 first, so an A-grade score is claimed by the first branch before the B test ever runs. Flip the order, testing score >= 70 first, and a score of 95 would print grade: B, because the first true test wins and the rest are skipped. When you write a chain, put the most specific test first. Most engineers get bitten by chain order at least once.
Now the promised trap. Here is the grader idea again with one character missing. The condition was supposed to read score == 90, a comparison. This one has a single =:
#include <stdio.h>
int main(void) { int score = 55;
if (score = 90) { printf("grade: A\n"); } printf("score is now %d\n", score); return 0; }
>>> The single = does two things here, both bad. It STORES 90 into score, destroying the 55, and the whole expression then takes the value 90. Remember the ground rule: nonzero means true. The if sees 90, calls it true, and hands out an A to a score that was 55. The last line proves the damage: score is now 90. It is not a compile error, because assignment inside a condition is legal C; the compiler cannot forbid it, only warn. And score cannot still be 55, because the = already overwrote it before the branch even ran.
prompt: operator@camp:~$ answer: gcc -Wall whoops.c -o whoops && ./whoops output: whoops.c: In function 'main': whoops.c:6:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses] 6 | if (score = 90) { | ^~~~~ grade: A score is now 90 hint: Same shape as always: gcc -Wall whoops.c -o whoops && ./whoops
gcc smelled it: suggest parentheses around assignment used as truth value. Read that warning as gcc asking "are you sure you meant = here?" But look at what happened next: it is a warning, not an error, so the broken grader still built, still ran, and still printed an A for a 55. The one sentence that resolves it: == compares two values and leaves them alone, while = overwrites the left side and hands the new value to the if, and since nonzero means true, the branch runs no matter what the data said. When a branch fires every time regardless of input, read the condition character by character.
A loop runs the same block again and again. The for loop is C's counting loop, and its header packs three parts into the parentheses, separated by semicolons: the start, the test, and the step.
#include <stdio.h>
int main(void) { for (int i = 1; i <= 4; i++) { printf("lap %d\n", i); } printf("done\n"); return 0; }
Read the highlighted header left to right. Start: int i = 1 runs once, creating the counter. Test: i <= 4 is checked BEFORE every pass; the loop keeps going while it is true. Step: i++ runs after each pass, and ++ just means add 1. Inside the block, %d in the printf means "print an int here", the same trick Hello, machine used to print argc.
>>> i starts at 1, and the test i <= 4 lets the values 1, 2, 3, and 4 through. When i++ pushes i to 5, the test fails and the loop ends, so done prints after four laps. If the test were i < 4 you would get three. That single character difference, < versus <=, is the classic off-by-one, and you will hunt it for the rest of your career.
prompt: operator@camp:~$ answer: gcc -Wall laps.c -o laps && ./laps output: lap 1 lap 2 lap 3 lap 4 done hint: gcc -Wall laps.c -o laps && ./laps builds it and runs it in one line
Trace it like the machine does. Pass one: test 1 <= 4 true, print lap 1, step to 2. Pass two: true, lap 2, step to 3. Pass three: true, lap 3, step to 4. Pass four: true, lap 4, step to 5. Then the test reads 5 <= 4, which is false, and the program falls out of the loop to the done line. The test ran five times to produce four laps. That is normal: the last check is the one that says stop.
while is the same idea with the bookkeeping stripped away. It keeps only the test; you create the counter before and step it inside the block yourself:
#include <stdio.h>
int main(void) { int i = 1; while (i <= 4) { printf("lap %d\n", i); i++; } printf("done\n"); return 0; }
This prints exactly what laps.c printed. Reach for for when you know the count up front, and for while when you do not: retry until a service answers, read until a file runs out. One rule keeps every while honest: something inside the block must be able to change the test. Delete the i++ line here and i stays 1 forever, the test stays true forever, and the program never ends. More on that in the errors below.
That rule raises a fair question. Real code is full of the line while (1). The 1 is not a comparison, nothing inside the loop can ever change it, and it looks like a loop that can never end. Surely the compiler rejects that:
#include <stdio.h>
int main(void) { int attempts = 0;
while (1) { attempts++; printf("try %d\n", attempts); if (attempts == 3) { break; } } printf("gave up after %d tries\n", attempts); return 0; }
>>> Rewind to the ground rule: a condition is just a number, and anything that is not zero counts as true. C never demanded a comparison in the first place, so while (1) is not an error; the 1 is a perfectly legal condition that happens to be true forever. There is no can-never-end warning either. gcc stays silent even with -Wextra, because looping forever on purpose is normal C, not a mistake. And this program does not need Ctrl-C, because of the highlighted escape hatch: break, the same keyword from the cold open, leaves the loop on the spot the moment attempts reaches 3.
prompt: operator@camp:~$ answer: gcc -Wall retry.c -o retry && ./retry output: try 1 try 2 try 3 gave up after 3 tries hint: gcc -Wall retry.c -o retry && ./retry
So while (1) is not an error because C sees a number where you expected it to demand a comparison, and 1 is never zero. It is also not a bug: servers, shells, and every long-running program you have ever used sit in exactly this loop, waiting for work, with a break or a return as the way out. The honest-while rule survives intact, just wider than it first sounded: either the test can change, or something inside the block can leave. The dishonest loop, the one with neither, is waiting for you in the errors section.
Here is what the counter physically is while the loop runs. From Types and sizes: an int is four bytes on this machine. The test does nothing magical; each pass it re-reads those same four bytes and compares:
{ "kind": "memory", "cells": [ { "addr": "0x7ffd5e189ee4", "bytes": "05 00 00 00", "label": "i after the last lap" } ] }
Your address will differ every run. Linux randomizes where each program's memory lands (a security feature called ASLR), so treat the address as illustrative. The 05 sits in the first byte because this machine stores the low byte first. Both facts get their full story in Pointers and memory.
Real conditions rarely come alone. "The user is logged in AND has permission." "The cache is empty OR it is stale." C spells AND as && and OR as ||. An AND is true only when both sides are true. An OR is true when at least one side is.
Now the rule this whole step exists for: C evaluates the left side first, and if that already settles the answer, the right side is never evaluated at all. Not evaluated lazily, not evaluated quietly: never run. This is called short-circuit evaluation, and it is a guarantee in the language, not an optimization. Watch it save a program's life:
#include <stdio.h>
int main(void) { int total = 42; int count = 0;
if (count != 0 && total / count > 10) { printf("average is big\n"); } else { printf("no data yet\n"); } return 0; }
Look at the highlighted line and notice the danger sitting on the right: total / count with count equal to 0. Dividing an integer by zero is not defined in C, and on this machine it kills the program on the spot.
>>> The && reads its left side first: count != 0 is false. A false left side settles an AND, so C never touches the right side. The division does not produce zero or garbage; it simply never runs. The if is false, the else block prints no data yet, and the program exits cleanly. The left test is a guard standing in front of the dangerous right side.
prompt: operator@camp:~$ answer: gcc -Wall guard.c -o guard && ./guard output: no data yet hint: gcc -Wall guard.c -o guard && ./guard
No crash, one calm line. The order inside count != 0 && total / count > 10 is load-bearing. The safe test stands on the left, the dangerous expression hides on the right, and the short circuit keeps it unreached. Swap the two sides and the program dies. || mirrors the rule: in count == 0 || total / count > 10, a TRUE left side settles the OR, so the right side is skipped. Engineers lean on this every day, and you will meet its most famous form, a null pointer check guarding a dereference, in Pointers and memory.
switch takes one integer value and jumps to the case label that matches it. If nothing matches, it jumps to default: if you wrote one, or skips the whole thing. It reads like a cleaner if/else chain when you are comparing one variable against a list of constant values.
But a case label is a door, not a room. This is the fact the cold open hinges on. Execution ENTERS at the matching label and then keeps running downward, straight through the next labels, until it hits a break or the end of the switch. break means leave the switch now. A label by itself stops nothing.
Here is the demo again, with the crime scene highlighted:
#include <stdio.h>
int main(void) { for (int n = 1; n <= 3; n++) { switch (n) { case 1: printf("one\n"); break; case 2: printf("two (falls through)\n"); case 3: printf("three\n"); break; } } return 0; }
>>> Execution jumps to case 2 and prints its line. There is no break after it, so nothing stops the flow: it rolls straight past the case 3 label and prints three too. Then it finally meets a break and leaves. The label did not stop anything, because labels never do. Only break does. This behavior is called fallthrough.
Now trace the whole run, pass by pass. n is 1: jump to case 1, print one, hit break, leave. n is 2: jump to case 2, print two (falls through), find no break, fall into case 3's code, print three, hit break, leave. n is 3: jump to case 3, print three, break, leave.
Four lines: one, two (falls through), three, three. The word three printed twice because case 3's printf ran on two different passes: once by falling through from case 2, once by matching n equal to 3. No line of code says print twice. The missing break does.
Why was the compiler silent? Because fallthrough is legal C; sometimes you want two cases to share code, and you stack the labels on purpose. gcc -Wall therefore says nothing. Add -Wextra to your flags and gcc will start flagging suspicious fallthroughs. Cheap insurance.
The fix is one line. Compare carefully; only the highlighted line is new:
#include <stdio.h>
int main(void) { for (int n = 1; n <= 3; n++) { switch (n) { case 1: printf("one\n"); break; case 2: printf("two (falls through)\n"); break; case 3: printf("three\n"); break; } } return 0; }
>>> The new break ends the n equals 2 pass right after its own line, so case 3's code no longer runs on that pass. The n equals 3 pass still prints three, because that is its own case. Three lines now. And notice the second line still SAYS falls through: that string is just text you typed. The compiler does not read your words. The behavior changed; the message did not.
prompt: operator@camp:~$ answer: gcc -Wall fallthrough.c -o fallthrough && ./fallthrough output: one two (falls through) three hint: You edited the file, so compile and run again: gcc -Wall fallthrough.c -o fallthrough && ./fallthrough
Three lines, one per number. The rule to burn in: end every case with a break unless you are falling through on purpose, and if you are, say so in a comment right where the break would be. This trap has real teeth. In January 1990, a misplaced break in a C switch inside AT&T's switching software helped take down their long-distance network for nine hours. One keyword, continental blast radius.
Control flow multiplies lines, braces, and semicolons, so it multiplies the places they can go missing. Four classics, oldest first.
1. The missing semicolon, again. You met this exact error in Hello, machine:
broken.c: In function 'main':
broken.c:5:13: error: expected ';' before '}' token
5 | return 7
| ^
What it means: the statement gcc is pointing at never got its semico
Practice Control flow in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.