Interactive course · about 10 hours

Build-a-Unix

An operating system is a program whose job is running other programs. It has no screen of its own and nothing to show you. What it has is a table of the programs it is running, a list of what each of them has open, a map of where each of them thinks its memory is, and a timer that takes the processor back every few thousandths of a second. Take those four things apart and there is nothing left over.

The machine you are taking apart

Eight process slots, six file descriptors per process, a disk of twenty blocks holding six files, twenty four frames of memory, and a timer. Every limit is small enough to reach: you can fill the process table, fill the disk, and run out of frames, and each of those is a real failure that a real machine has too and answers in the same way.

What you should have met already

Registers, memory addresses and the idea of an instruction, from Reading the Machine's Mind, and bytes and hexadecimal from Bits and Memory. Nothing else. The system call numbers and the shape of the calls here are xv6's, which is a teaching Unix small enough to read in a weekend, and they are close enough to Linux that the habits carry over.

The steps

Step 1

What the kernel does for you

Your program cannot print. It cannot open a file, take a key press, send a packet or ask what time it is. Not because those are difficult, but because the processor it is running on refuses. There is a bit inside the processor that says which of two modes it is in, and the two have names. In kernel mode every instruction the processor knows how to run is allowed. In user mode, which is where your own program spends its entire life, the instructions that touch hardware do not run at all: they fault. To fault is to stop part way through an instruction, refuse to finish it, and hand the problem over instead of carrying it out. Nothing partial happens, because the check is in front of the instruction rather than behind it.

The code that is allowed to run in the other mode is the kernel. It is the part of the operating system that is always in memory and never quits. It is what starts programs, what hands out memory, and what owns the disk and the screen and the keyboard. It is the only code on the machine with permission to touch any of them. Everything in this course is either the kernel, or a program asking the kernel for something. When you see "the kernel" from here on, picture a few thousand lines of ordinary code sitting in memory with one privilege nobody else has.

So every useful thing a program does, it asks for. The asking has a name, a system call, and there is exactly one instruction that performs it: a trap, which changes the mode and jumps to an address your program did not choose.

Why would a processor have modes at all

Because one machine runs programs written by people who have never met, and some of them are hostile. If any instruction could reprogram the timer, edit another program's memory or talk to the disk controller, then the first program to run would own the machine and everything after it.

The mode bit makes that impossible in hardware rather than by agreement. In user mode a whole class of instructions faults. The kernel sets up the mode bit and the trap address once, at boot, which is the word for the first moments after the machine is switched on, when the kernel is running and no program of yours exists yet. It still has permission to do so then, and from then on user code can only get in at the front door.

What are a7 and a0, which the lab keeps mentioning

Register names. The trap instruction carries no arguments of its own, so everything the kernel needs has to be sitting in registers before it runs, and both sides have to agree in advance about which ones. This machine is a RISC-V, where the agreement is: a7 holds the number of the call you want, and a0, a1 and a2 hold its arguments in order. When the kernel is finished it puts the answer back in a0, so the register that carried the first argument in is the one that carries the result out.

The names are arbitrary and every family of processor picks its own. An x86-64 machine does exactly the same job with rax for the number and rdi for the first argument, and its trap instruction is called syscall rather than ecall. Nothing about the idea changes: some agreed registers, one instruction, and a number that chooses a handler.

What happens to my program while the kernel is running?

It is stopped, completely. A trap does not start a second thing running alongside your program. It takes the one processor away from it, part way through, and gives it to the kernel. While the kernel works, your program executes no instructions at all.

What keeps its place is a small block of memory called the trap frame, holding every register your program had at the moment it trapped, including the address of the instruction it had reached. When the kernel is finished it copies all of that back and returns to that address, and your program carries on as though the call had simply taken a while. It has no way of telling how long it was stopped, or that anything else ran while it was, except by asking the kernel what time it is.

Lab 1 · One trap, eight moments
Try this firstPress Step three times. On the third one the badge flips to kernel mode and a trap frame appears under the registers.
Step it and watch the mode badge. One print statement, taken apart into the eight things that actually happen. Notice which of them the program does and which of them are done to it, and notice that the address the trap jumps to comes from the kernel, not from the caller. Then press Try it without the kernel and watch the refusal.

How to read the strip at the bottom. It is labelled system calls, newest last, and it turns up under most of the labs from here on. Every line is one crossing into the kernel and reads left to right: which tick of the timer it happened on, which running program made the call, the call's name with its number in brackets, what it was handed, and after the arrow what it answered. Nothing else in this course ever appears in that strip, because nothing else crosses.
The kernel is not running most of the time

There is a picture of an operating system as something that hovers over your program watching it. Nothing like that exists. While your program runs, the kernel is a lump of code sitting in memory doing nothing at all. It runs when your program traps, when a device interrupts, or when the timer fires, and the rest of the time it is asleep in the most literal sense.

Lab 2 · What each mode will let you do
Try this firstPress "write to the console device" while the mode still says user. It is refused, and the refused count goes up by one.
Break it on purpose. Seven operations, four of which need kernel mode. Try them all in user mode, count the refusals, then switch the mode and try the same ones again. The last operation is the interesting one: it is how a user program gets a privileged thing done without ever being privileged.
A program calls a function from a library, which is ready-made code somebody else wrote, built into your program and running as part of it with no more permission than the rest of your code. The function prints a line. So how does the line reach the screen?
It traps. A library is just more of your own program and has exactly the same permissions your code does, which is none. Somewhere underneath it there is a handful of instructions that put a number in a register and trap, and that is the only way out. Swapping the library changes nothing about the boundary.
Step 2

Your first system call

The call is write, and it takes three things: a number saying where to write, the bytes, and how many of them. It returns how many it managed. That is the whole interface. It has not changed in fifty years.

The number is a file descriptor, and the only thing surprising about it is how little it means. It is not a file. It is an index: a position in a small numbered table and the table belongs to your running program. Every program the machine is running gets its own table of six of these, handed to it when it starts. What sits at position 1 in your table has nothing to do with what sits at position 1 in anybody else's. Step 3 gives that running-program-with-a-table-of-its-own its proper name. For now the useful part is that the number is only a position. What the position leads to was decided by whoever started you.

Why 1 is the screen, and what 0 and 2 are for

Nothing in the hardware makes descriptor 1 the screen. It is a convention, and the whole reason it works is that whoever starts your program sets the table up before you run. By agreement, 0 is where you read your input from, 1 is where your ordinary output goes, and 2 is where your complaints go.

Keeping 1 and 2 apart is what lets somebody send a program's output into a file while still seeing its error messages on the screen. Both are just numbers, and a program that writes to 2 has no idea whether anybody is looking.

What does it mean that a call "returns a number"

Every system call in this course answers with one whole number and nothing else. There is no other channel. So that one number has to carry the news, and the convention is the same for all twenty one calls. A number of zero or more means it worked and the number is usually a count of something: how many bytes were written, or which descriptor you got. A negative number means it failed and nothing happened.

That is why -1 turns up so often below. It is not an error message and it does not say what went wrong; it only says the call did not do its job. A real machine puts a reason code in a second place beside the -1, which is why two completely different failures can look identical from the number alone.

Why does a print not go straight out

Crossing into the kernel is expensive compared with adding two numbers, so the printing code in your program does not cross once per character. It collects characters in a buffer, which is an ordinary patch of memory used as a waiting area, and only when the buffer is full, or a new line arrives, does it make one system call carrying the lot.

It saves an enormous amount of work and it costs you one thing: if the program dies while characters are still sitting in the buffer, those characters are gone. They never reached the kernel. A crash can therefore omit the final partial line unless the program flushes it or writes to an unbuffered stream.

The descriptor table in the labs has four columns. What are the other two?

The panel shows, for each descriptor: the number, which open file entry it leads to, what that entry points at, and a count called refs. The number and the destination you have met. The middle one is the part worth knowing about, because it means your table does not hold the open file itself. Your table holds only positions, and each position names one entry in a second table that the kernel keeps once per open, whoever did the opening.

That is what refs counts: how many descriptors, in this process or in any other, currently point at that one entry. The entry, and the position in the file it is holding, is thrown away only when the count reaches zero. So closing a descriptor does not always close the file, and two descriptors can share one position in one file. Step 4 and step 7 are both built on exactly that.

Lab 3 · One print, all the way down
Try this firstPress Step down a layer four times. Nothing has crossed into the kernel yet. The fourth line is the library deciding it is time to send the buffer.
Step down a layer at a time. Eight layers between a print statement and a character appearing, and only one of them crosses into the kernel. Watch where the output buffer fills up and when it is finally sent, because that is why a program that crashes can lose the last thing it meant to say.
Lab 4 · The same call, six different destinations
Try this firstPress write with fd 1 chosen, then choose fd 3 and press it again. The first lands on the console. The second lands nowhere you can see, and bench.txt grows.
Write to each descriptor and read the number that comes back. Three of them are the console, one is a file, and the last two are the two ends of a pipe, which is a small buffer inside the kernel with a way in and a way out, and which step 7 builds properly. All you need here is that bytes only travel through it one way. The call is character for character identical every time and five of the six work. Find the one that refuses and work out why before you read the answer, because "not open" and "open, but the wrong way round" look exactly the same from the return value. Then close descriptor 1 and write to it again, and notice that the console is still there: it is this process that has lost the name for it.
Lab 5 · Make any call you like
Try this firstPress getpid, then uptime, then open. Three numbers come back, and three rows appear in the trace with the call number beside each one.
Nothing here is marked. Eleven calls, a real process to make them in, and every table read back out of the kernel afterwards. Open a file and see which descriptor it lands in, close one and open another. The last button, fork(), makes a second copy of this program; press it and notice that the copy is handed the same descriptors. That call is the whole of step 4, so press it out of curiosity rather than trying to work it out now. Nothing you can press breaks anything that Reboot will not put back.
A program calls write(1, buf, 5000) and it returns 4096. What should the program do?
Carry on from where it stopped. A positive return that is smaller than what you asked for means part of the job was done, usually because a buffer somewhere filled up. It is not an error and there is nothing to report. Ignoring the return value works perfectly until the day the output goes into a pipe instead of a terminal, and then it truncates.
Step 3

What a running program is

A program is a file on a disk. A process is one attempt at running it, and the difference matters because the same file can be running four times at once with four different sets of everything.

What the kernel keeps for each one is smaller than you would guess. Its own number, which everybody calls its pid, short for process id, and which is how every other call in this course refers to it. The pid of the process that started it, which is called its parent, and which the table calls ppid; every process on the machine was started by another one, and step 4 is about how. Which program it is running. What state it is in. Its table of descriptors. And a map of its memory. The kernel stores this information in a process-control structure and uses it to stop and resume execution.

What the five states mean, and why sleeping is not waiting in a queue

Running means this one is on the processor right now, and on a machine with one core, core being the word for one processor inside a chip that may hold several, exactly one process is in that state. Runnable means ready and simply not chosen yet. Sleeping means waiting for something specific that has not happened. Zombie means finished but not yet collected. Unused means the slot is empty.

A sleeping process costs nothing at all. The part of the kernel that chooses who runs next, the scheduler, which step 11 takes apart, does not try it and skip it: it is not in the queue, and the note beside it says what would wake it. A machine with four hundred sleeping processes and one runnable one is doing exactly as much work as a machine with one process.

Where does the very first process come from?

Every process is started by another process, which cannot be true of all of them or there would be no first one. So the kernel cheats exactly once. At boot it builds one process by hand: it fills in a row in the table, gives it pid 1, hands it a page of memory and a descriptor on the console, and starts it. That process is called init, and on the machine in these labs it is the row you will see above the shell in every process table on the page.

What init does is small and it never stops doing it. It starts a shell, and then it waits. Whenever a process dies and leaves children behind, those children are handed to init, and init collects their exit status. The lab protects pid 1 because removing the reaper would leave finished child entries in the simulated process table.

Why is the table a fixed eight slots and not as many as you like?

Because the kernel cannot allocate memory the comfortable way your programs do. It is the thing that hands memory out, so it cannot ask anybody for more, and a table that grew would have to be moved, halfway through, while other parts of the kernel were pointing into it. A fixed array of rows, decided when the kernel is built, can never fail halfway through and never needs moving.

The price is a hard ceiling, and the ceiling is real on real machines too. Linux has one, it is just a large number rather than eight. When you meet a machine that will not start anything at all while apparently doing nothing, this table, full of rows nobody has collected, is very often the reason.

Lab 6 · The process table, from the inside
Try this firstPress Start a long job. A third row appears, and its name is still sh.
Click a row and look at what a process is made of. Eight slots, two of them filled on a freshly booted machine. Start a long job and watch a slot fill in. Then kill something and watch the row refuse to disappear, which is the one thing about a zombie worth understanding.
Lab 7 · Say which state, then look
Try this firstRead the question and press one of the four states. The process table appears with the process in question marked, and the state it is really in.
Commit before you look. Four situations, and for each one the machine is driven to exactly that moment and the state is read out of the real table. The one people get wrong is the third, because runnable and running feel like the same thing until you remember there is only one processor.
A program finished ten minutes ago and its row is still in the process table, marked zombie. What is being held there?
The exit status, and almost nothing else. The memory was handed back and the descriptors were closed the moment it exited. What is left is a number nobody has collected, and the kernel cannot throw it away because the parent has a right to ask. A program that forks children and never waits for them fills the table with rows like that, which is how a machine ends up unable to start anything at all while apparently running nothing.
Step 4

fork, and the two answers

There is only one way to make a new process on a Unix machine, and it is not what anyone expects. Unix is the family of operating systems this small one belongs to. The first was written in 1969, and Linux, Android and macOS are all its descendants, which is why the calls here are close to the calls on the machine in front of you. You cannot ask for a process that runs a named program. What you can do is ask for a copy of yourself.

fork takes no arguments. It makes a second process with the same memory, the same open descriptors and the same next instruction, and then both of them return from it. The parent is told the child's number and the child is told zero. That single difference is the only way either of them can tell which one it is.

How can one call return twice

It returns once in each process, which is not the same as returning twice in one. At the moment of the copy there are two processes, both stopped just after the trap instruction, both about to be given an answer. The kernel writes a different number into each one's register and lets them both go.

From inside either process nothing strange happened at all: a function was called and it came back with a number. The strangeness is only visible from outside, where you can see two of them.

Surely copying all the memory is enormously slow

It would be, and no real kernel does it. This machine copies every page at the moment of the fork because that is the easiest version to watch, and it is the pessimistic one: forking a process using a gigabyte would cost a gigabyte. What a real kernel does instead is point both page tables at the same frames and mark every one of them read only in both. Nothing is copied at all, and for that moment the two processes are sharing one set of frames.

Then the first time either of them writes, that write faults, exactly the way step 10 describes. The kernel sees a page that ought to be writable, copies that one page, points the writer at the copy, and restarts the instruction. Only the pages that are actually written cost anything, which is why forking is cheap and why forking and immediately calling exec, which throws the whole image away, costs almost nothing at all. It is called copy on write, and the frame counts in this course are the honest upper bound rather than what you would measure.

Frames, and why this is not the stack frames I already know

Same word, two completely unrelated meanings, and both of them turn up in this course. A stack frame is the patch of stack one function call uses while it runs. A frame here is a fixed-size slab of the actual memory chips, and this machine has twenty four of them, which the kernel hands out whole and takes back whole.

The kernel deals in frames because it never wants to search for a piece of memory the right size. Every request is one frame or several, every frame is interchangeable with every other, and the free ones are a list of numbers. Step 9 is about how a program's addresses get attached to particular frames, and until then the only thing to watch is the count: fork takes some, exec hands some back, and when there are none left the call that wanted one fails.

Lab 8 · One becomes two
Try this firstSelect pid 3, then press fork the selected process. A row appears, and the table underneath fills in with the two different answers that one call gave.
Fork a running job and compare the two side by side. Everything matches except three rows. Physical memory on this machine is handed out in twenty four equal slabs called frames, and step 9 is about why memory is handed out that way at all. The important thing here is that the child gets its own frames holding copies of the same values, which is why neither copy can disturb the other. Fork enough times and watch the call start returning -1: the eighth process slot and the twenty fourth frame are used up by the very same fork, and either one running out on its own is enough to make the call fail.
Lab 9 · How many lines does it print
Try this firstChoose a program, press a number to predict, then press Run it on the machine. The lines it printed appear, one per process that reached the print.
Predict, then let the machine count. Five little programs made of forks and prints. Everybody is confident about the first one and wrong about at least one of the others. The last one asks for more processes than the table has, which is worth seeing at least once.
A program opens a file, gets descriptor 3, and then forks. How many processes can now write through descriptor 3?
Both, sharing one position. The copy includes the descriptor table, and the entries point at the same open file rather than at copies of it. That sounds like a detail and it is the reason two processes can append to the same log without overwriting each other, and also the reason a forked program that has buffered output can print it twice.
Step 5

exec, and the same process

Fork gives you a second copy of the program you already were, which is rarely what you wanted. The other half of the pair is exec, and it does the opposite: it keeps the process and throws away the program.

The memory goes back to the kernel and a new image is loaded from the disk, image being the word for the whole contents of a program's memory: its instructions, its variables and its stack. The program counter, which is the register holding the address of the next instruction the processor will run, goes back to the start. But the process number does not change, its parent does not change, and, most usefully of all, its open descriptors do not change.

Why is this two calls rather than one

Plenty of systems have a single call that makes a new process running a named program, and it always grows a long list of options: run it with this input, that output, this directory, those permissions. Every one of those options exists because the new process cannot be adjusted before it starts.

Splitting the job leaves a gap in the middle where the child exists, is under your control, and is not yet the program. Anything you want to arrange, you arrange there using the ordinary calls you already have. Step 7 is entirely about what people do in that gap, and it is why redirection needs no support in the kernel and none in the program being redirected.

What is actually in the file that exec loads?

Three things, and the third is the surprising one. The instructions, as the numbers the processor reads. The starting values of any variables that exist for the whole run, because a program that begins with a total of zero has to get that zero from somewhere. And one number saying which instruction to start at, because the first instruction is not always the first byte.

The stack is not in the file. Nothing in a file could say what the stack should contain, because it depends entirely on what happens while the program runs. Exec sets aside room for it, empty, and the program fills it as it goes. A program is executable code and data stored in a file. A process is a running instance with registers, memory mappings, open descriptors, and other execution state.

The free list, which the lab keeps counting

The kernel has to know which frames of memory are in use and which are not, and it keeps that in the simplest structure there is: a free list, meaning nothing more than the set of frame numbers nobody currently has. Taking a frame means picking one out of it. Giving one back means putting the number back in. No searching for a piece the right size, no fitting things together, because every frame is the same size and any one of them will do.

Which is why the lab below shows something that looks wrong. Exec hands three frames back and then takes three more, and gets the same three numbers. This machine always hands out the lowest free number, and the moment the old ones were released they were the lowest free numbers there were. Nothing was kept and nothing was reused on purpose.

Lab 10 · The image is replaced, the process is not
Try this firstChoose cat notes.txt and press exec the chosen program. A before and after table appears. Two of its six rows change.
Exec a program into a forked copy and read the before and after. The name changes and the program counter goes back to the beginning. The pid, the parent and the descriptors do not move, and that last one is what the whole of step 7 is built on. Then look at the frame numbers, which is where this gets interesting: the line under the table counts the frames handed back and the frames taken, and yet the numbers on both sides of the row are the same three. The old memory really was given up. The free list simply handed the lowest numbers straight back out, and the lowest were the ones just released.
Lab 11 · What exec without fork costs you
Try this firstPress exec without forking first, then Try to run a command now. The second button reports that there is nothing left to type at.
Do the wrong one first. Exec a command straight into the shell, then try to type another command. Nothing happens, because there is nothing left to listen. Then do the same command the other way and watch the shell survive. One extra call is the whole difference.
A process opens a log file on descriptor 3, then calls exec. The new program knows nothing about any log. What is descriptor 3 in the new program?
Still the log file. Exec replaces the memory image and leaves the descriptor table alone. That is not an oversight, it is the mechanism: it is how a shell can set up where a program's output goes before the program exists, and how a program can be handed an already open connection it has no way to open itself.
Step 6

Building a shell

Before the machine had windows to click on, and underneath them still, there is one way to ask a computer to run a program: you type its name and press Enter. The thing that reads what you typed is a shell, and the strip of screen it prints into and reads from is the terminal, also called the console in this course, because console is what the kernel calls the device. Both words mean the same rectangle. Every command in the labs below is the name of an ordinary program sitting on the disk: ls lists the files, cat prints a file, echo prints whatever you type after it, and wc counts lines and words. None of them is built into the shell. That is the point of this step.

A shell is a loop. Read a line, make a process to run it in, wait for it to finish, do it again. Every shell anybody has ever used is that loop with several decades of convenience piled on top, and underneath the convenience the three calls are still fork, exec and wait.

The loop is short enough to write out in full, which is what this step asks you to do. It is also easy to get subtly wrong in two different ways, and both of them work at first.

What wait actually does, and what happens when you leave it out

wait asks for the exit status of a finished child. If one has already finished it answers straight away and the row in the process table is thrown away. If none has, the caller sleeps until one does. If there are no children at all it answers -1 rather than waiting forever.

A shell without a wait keeps running and looks fine. The commands still work, because the child does its job whether or not anybody collects it. What builds up is finished rows nobody has claimed, one per command, until the table is full and the next fork fails.

Why does a shell need to be a separate program at all

Because it is not special. The shell has no permission your programs do not have and no help from the kernel: it is one more ordinary process, reading lines from descriptor 0 and making the same three calls anybody can make. If you deleted it and wrote your own, the machine would not notice.

The loop below is a minimal shell: read a command, create a child, replace the child image, and wait. Real shells add parsing, pipelines, redirection, job control, expansion, and built-in commands around those process operations.

What does the exit status a shell collects actually get used for?

When a process exits it hands back one small number, and the convention everywhere is that 0 means it worked and anything else means it did not. That number is the whole of what wait collects. It is what the zombie row in step 3 was holding on to. It exists because the process that started a program is usually the only one in a position to care whether it succeeded.

Almost everything a shell does with several commands is built on it. Joining two commands so that the second only runs if the first worked is a check on that number. A script that stops the moment anything fails is checking it after every line. And the reason a program should bother to exit with the right number, rather than always 0, is that nothing else it prints can be checked so cheaply by whatever started it.

Wait, I have to write this? In what?

Yes, and only three lab boxes in the whole course ask for it. The language in those boxes is a small one built for this page. It is close enough to the C you read in Bits and Memory that reading it takes nothing new. Three differences are worth knowing. You write const x = 5; or let x = 5; instead of naming a type. Text is compared with ==, the same way numbers are. And while (true) { ... } with a break; inside is how you write a loop that runs until something stops it.

Everything else is what you already read: a name followed by round brackets calls something, = puts a value somewhere, == asks a question, curly brackets group lines together, and if (...) { ... } else { ... } picks one group or the other. There is a Show me one that works button under every one of these boxes, and using it and then reading the answer is a perfectly good way to do the lab.

Lab 12 · Write the loop
Try this firstPress Check it before changing anything. It fails, and the message says what happened to the shell after the very first command. The fix has a shape: call fork(), keep the number it gives back, and then do one thing if that number is 0 and a different thing if it is not.
The loop as given runs the first command and destroys the shell doing it. Fix it. Your loop is really running as the shell process, and the checker gives it three different sets of commands, one of which does not exist, then looks at whether the shell is still alive and whether anything is left in the table.
Lab 13 · A terminal, with the table beside it
Try this firstType ls and press Enter. Two file names appear, and a row appears and then vanishes from the table beside it.
Type commands and watch rows appear. A row shows up when the fork lands, its name changes when the exec lands, and it goes when the shell asks how it went. Put an ampersand on the end of a command and the shell does not wait, so you can get two jobs going at once and see them share the processor.
A shell forks, and in the child it calls exec on a command that does not exist. exec returns -1. What must the child do next?
Exit, immediately. A failed exec changes nothing and returns like an ordinary call, which leaves a full copy of the shell running the shell's own loop. Two shells now read from the same terminal, and every command after that gets run by whichever one grabs the line first. This is why the child branch always ends with an exit that looks unreachable.
Step 7

Descriptors, redirection, pipes

A program writes to descriptor 1 through a common interface. It does not need separate output code for a terminal, regular file, or pipe. The process that launches it chooses the descriptor binding.

Two rules do all the work. First, open always uses the lowest free descriptor. Second, descriptors survive exec. Put those together in the gap between fork and exec and you have redirection, with no help from the kernel and none from the program.

Sending a program's output somewhere else is called redirection, and it is the first half of this step. The second half is a pipe. Ask the kernel for one and it hands back two descriptors at once, joined by a small buffer inside the kernel. Whatever is written into the write end can be read out of the read end, in order, and nobody else on the machine can see it. It only carries bytes one way. Now put a pipe in the gap between fork and exec, twice over: one child's descriptor 1 pointing at the write end, the next child's descriptor 0 pointing at the read end. That is the vertical bar in cat notes.txt | wc, built out of nothing but the calls you already have. Neither program can tell it is not talking to a terminal.

What dup is for, when closing and opening already works

dup copies a descriptor to the lowest free number, so two numbers lead to one open file. Closing 1 and then opening a file gets you the same result in the simple case, and shells use dup when they need to keep the old descriptor to put back afterwards, or when what they want in slot 1 is already open, such as one end of a pipe.

Both copies share one position in the file. Writing through either moves both along, which is what you want when a program and the thing it launched are both appending to the same output.

How does a reader know when there is no more coming

A read from a pipe with nothing in it does one of two completely different things, and which one depends on something the reader cannot see. If some process somewhere still holds the write end open, more might yet arrive, so the read sleeps and waits. If every write end everywhere is closed, nothing can ever arrive, so the read answers 0 immediately. That 0 is what end of file means: not an error, just the news that there will be no more.

This is why the closing matters so much in the lab below. A write end left open by somebody who is not using it, the shell for instance, makes the second answer impossible, and a pipeline that should have finished sits waiting forever with no error anywhere.

What happens when the pipe's buffer fills up?

The buffer is small, a few thousand bytes on a real machine and a handful of characters here, and a program on the writing end can easily produce faster than the program on the reading end consumes. Nothing is thrown away and nobody is killed. The write simply does not finish: the writer is put to sleep until the reader takes something out. Then it wakes and carries on.

So a pipeline runs at the speed of its slowest program, without any of the programs in it knowing that is what is happening. Each one performs ordinary reads and writes that may block. The kernel maps each descriptor to the appropriate pipe end, so neither program needs pipe-specific transfer code.

Lab 14 · The lowest free descriptor
Try this firstPress close(1), then open("out.txt"). open answers 1. That number was not chosen to be convenient.
Press close(1), then open, then write. Predict where the open will land before you press it. That one rule, boring on its own, is the entire mechanism behind the greater-than sign in every command line you have ever typed.
Lab 15 · Make the output go somewhere else
Try this firstPress Run it before changing anything. The words land on the console, and out.txt is never created at all.
You are the child, between fork and exec. Send the program's output into a file without touching the program. The checker runs three different programs into three different files and looks at where the bytes ended up, so any correct arrangement passes, including the one using dup.
Lab 16 · A pipe, and what an empty read means
Try this firstPress write into the pipe, then read from the pipe. Four of the five characters come back, and the buffer is left holding the fifth.
Write into it, read out of it, then close the write end and read again. The same empty pipe answers two completely different ways depending on whether anybody could still write to it. Then run a real pipeline and see the shell build the same thing out of one pipe, two forks, two dups and eight closes, until each of the three processes is holding exactly the one end it needs and nothing else. That last part is not tidiness. It is what lets the pipeline finish.
A shell builds a | b, forks both children, and forgets to close the write end of the pipe in the shell itself. What happens?
b waits forever. End of file on a pipe means the buffer is empty and every write end is closed. The shell holding one open, doing nothing with it, is enough to keep that from ever being true. The reader sits in a read that will never return and the pipeline hangs with no error anywhere. Closing the ends you are not using is not tidiness, it is what makes the pipe finish.
Step 8

Inodes and blocks

A disk gives you numbered blocks of a fixed size and nothing else. No names, no sizes, no idea which blocks belong together. Everything else is a structure the kernel writes into those blocks and then reads back. There is one place the kernel is allowed to start from: block 0, called the superblock. It sits at a number known in advance, so a machine that knows nothing at all can read that one block and find out how many of the blocks after it hold records and how many hold contents.

The structure at the middle of it is the inode: one record per file, holding the size and the list of which blocks hold the contents. That list cannot simply grow, because every inode has to be the same size as every other one. So it is kept in two parts. The inode holds four block numbers directly, which is enough for a small file and most files are small. When a fifth block is needed, the kernel takes one extra block and uses it to hold nothing but more block numbers, an indirect block, and puts that one block's number in the inode. From then on the file costs one block that holds no part of its contents at all, which is what Lab 18 is about. A name is not part of any of this. A directory is a separate list of names and inode numbers, which is why one file can have two names and why deleting a name is not deleting a file.

Why an inode cannot just hold the whole list of blocks

Because an inode has to be a fixed size. They live packed together in a table so the kernel can find number 4000 by arithmetic instead of by searching, and that only works if they are all the same size. A fixed size record cannot hold a list that grows.

Four direct numbers and one indirect block, which is what this disk has, is where the idea starts rather than where it stops. Real filesystems carry on the same trick with a pointer to a block of pointers to blocks of pointers, which is how a fixed size record describes a file of many gigabytes while staying exactly as big as every other record in the table.

What is a directory really?

An ordinary file. It has an inode and blocks like any other, and the only thing unusual about it is what is written inside: a list of pairs, each one a name and an inode number. That is all a directory is. It is why the kernel can look up a name in one without knowing anything about what the name leads to.

Two things follow that are otherwise strange. Two different names, in the same directory or in different ones, can carry the same inode number, and then one file has two names with neither one being the real one. And renaming a file inside one directory copies not one byte of its contents: the kernel writes a different name next to the same number. The contents never moved because the contents were never attached to the name in the first place.

What if the power goes off halfway through?

Writing one file changes several blocks: the inode, the blocks holding the contents, the record of which blocks are free, and the directory holding the name. The disk does one block at a time. So there is always a moment when some of those have been written and the rest have not. A machine that stopped at that moment starts up again with a disk that contradicts itself. A block marked in use that no inode mentions is lost space. An inode listing a block the free record also offers to the next file is much worse.

Every real filesystem has an answer to this and none of them is simple. The usual one is to write down what you are about to do, in one place, before doing any of it, so a machine starting up can finish or undo whatever was in progress. That is a whole course of its own, and it is the one called Fault Tolerance at the bottom of this page.

Lab 17 · Write a file and watch the inode fill up
Try this firstPress write it. Blocks turn from free to full, and the inode row lists the ones it has taken.
Write a little, then write a lot. Watch which blocks get taken and in what order, then keep going until the four direct pointers run out and an indirect block appears. Delete it afterwards and notice what is still sitting in those blocks.
Lab 18 · How many blocks will that cost
Try this firstSet the slider to 40, press a number to predict, then press Write it for real. The disk panel shows exactly which blocks went, and what they went on.
Predict, then write it for real. Pick a size, say how many blocks the disk gives up, then have the file written and counted. The sizes just over thirty two characters are the interesting ones, because that is where a file starts costing a block that holds no part of it.
Lab 19 · A terminal, a disk and no marking
Try this firstType echo a line of text > story.txt and press Enter. Inode 2 is taken, two blocks turn from free to full, and the shell's own descriptor table beside it still says three: the redirection happened in the child.
Nothing here is graded. Everything from part three in one place: a shell you type into, the shell's own descriptors, and the disk. Send output into a file, read it back, delete it and look at the block it used. There is room for six files and two of them exist already, so it does not take long to find out what open says when the inodes run out. Then fill the eighteen data blocks and watch a write come back with a smaller number than it was asked for.
A file is deleted while another process still has it open. What happens to the blocks?
The name goes, the file stays. Deleting removes an entry from a directory. The inode is only thrown away when nothing refers to it any more, counting both names on the disk and open descriptors in running processes. This is why deleting a large log file frees no space at all while the program writing it is still running, and why the space appears the moment it is restarted.
Step 9

Walking a page table

Every address in your program is a lie. Not a metaphor and not an abstraction: the number in the register is not the number that reaches the memory chips. Something between the processor and the memory rewrites it on every single access.

That something is the page table, and every process has one of its own. The number your program uses is its virtual address, virtual meaning that it names a place in a map the program has to itself rather than a place on the memory chips. The number that actually reaches the chips is the physical address. Memory is handed out a page at a time, a page being 4096 bytes' worth of virtual addresses. Every page that exists is living in one frame, the same-sized slab of real memory you have been watching the counts of since step 4. The table is nothing but the list saying which page is in which frame. Two processes can both use virtual address 0x01000 and reach entirely different frames. That, rather than any rule or any check, is what makes them separate. The whole range of addresses one process is allowed to use is called its address space.

Why the address is split up rather than looked up whole

A table with an entry per address would be bigger than the memory it describes. So translation works per page instead: the top bits of the address choose a page and the bottom bits say where you are inside it, and only the top part is translated. Where you are inside a page is the same before and after, which is why the offset is carried across untouched.

Splitting the top part again, into a directory index and a table index, saves more. A program using the very bottom of its address space and the very top needs two small tables and one directory instead of one enormous table with a vast empty middle. That is the only reason for the second level.

Text, data, stack: what the three pages in the labs are

Every program in this course has its memory in three pieces and the labs name them the way Unix has named them since the seventies. The text is the program's instructions, which is an unhelpful name, since it holds no words at all, but it is the one everything uses. The data is the variables that exist for the whole run. The stack is the one from Bits and Memory: the part that grows and shrinks as functions call each other, and it sits at the very top of the address space, which is why its addresses all begin 0xFF.

The page table gives these regions different permissions. Text is readable and executable but not writable, which blocks ordinary stores from changing instructions. Data and stack pages are writable but should not be executable. Real systems may use finer divisions, but the protection still comes from permission bits in each page-table entry.

Does the processor really do all this on every single access?

It would be unusable if it did. Two extra reads of memory to work out where one read of memory goes would make every program three times slower, and the walk in the lab below is the short version with only two levels. Real machines have four.

So the processor keeps a small store of translations it has done recently, right next to the part that does the arithmetic, and looks in there first. Almost every access hits it, because programs use the same few pages over and over. The full walk happens only when it misses. This is why the same program can be slow for reasons that have nothing to do with how much work it does: a program that jumps all over its memory misses that store constantly, and a program that keeps to a few pages almost never does.

Lab 20 · The walk, one step at a time
Try this firstPress Step the walk four times. The stack address comes apart into two indexes and an offset, and ends as a physical address.
Type an address and step the four moments. Four bits pick a directory entry, four more pick a table entry, and twelve go straight through. Try the stack address and the text address and notice that they use different directory entries, which is exactly the case the second level exists for. Then try an address in the empty middle.
Lab 21 · Build a page table that works
Try this firstPress Check the mapping before you change anything. It fails on the first access, because nothing is mapped anywhere yet.
Three pages, three frames, and one rule nobody tells you. Make all three accesses land somewhere real. The frame numbers are yours to choose and any choice passes, because nothing outside the table knows which frame holds what. The rule that is not in the list of accesses is about what a program should not be allowed to do to itself.
Two processes both read address 0x01000 and get different values. Neither is buggy. Why?
Different tables, different frames. A virtual address only means something in the company of a page table, and the kernel loads a different one whenever it switches processes. This is why a pointer value printed by one program is meaningless in another, and why two copies of the same program can both put their stack at exactly the same address without any conflict at all.
Step 10

Page faults

When the walk finds nothing, the processor cannot invent an answer. It stops in the middle of the instruction, saves where it was, and traps into the kernel. That is a page fault, and the word makes it sound like an error when most of the time it is how memory gets handed out at all.

The kernel looks at the address and decides which of three things this is: a page that ought to exist and does not yet, an access this program is not allowed to make, or an address that belongs to nothing. It decides by looking the address up in a short list the kernel keeps for every process, of the regions that process asked for. A region is one stretch of the address space with a purpose and a rule attached: the stretch holding the instructions, which may be read but not written, the stretch holding the data, and the stretch holding the stack. An address inside a region is one the program is entitled to and the kernel will find a frame for. An address outside every region is one nobody ever asked for. The first case is fixed and the instruction is restarted. The other two kill the process.

What restarting the instruction means

The faulting instruction did not half happen. The processor works out that it cannot complete before it changes anything, so nothing was written and no register was updated. Once the kernel has mapped the page it returns to the same address, and the instruction runs again from the beginning, this time finding what it needs.

The program cannot tell any of this happened. There is no callback, no return value, no flag. The only trace is the time it took, which is why a program's first pass over a large array is slower than its second for reasons that have nothing to do with the cache.

Why is a fault the normal case rather than an emergency?

Because it is how a program gets memory at all. Nothing is handed over when a program starts and nothing is handed over when it asks; a frame appears at the moment a page is first touched, and not before. So the ordinary life of an ordinary program includes a fault for its first instruction, a fault for the first thing it puts on the stack, and a fault for every new page after that. Starting a program and running one command costs tens of them before anything interesting has happened.

Lab 24 keeps the count on a badge that says page faults. Watch it while you touch a handful of addresses and you will see it climb steadily and nothing go wrong. The word makes it sound like a breakdown because it is the same mechanism as the two kinds that really are fatal, and the kernel cannot tell which of the three it has until it looks the address up.

How does the kernel find out which address the fault was for?

It is handed it. The trap carries nothing by itself, so the processor puts the address that could not be translated into a register set aside for exactly this, along with a few bits saying whether the access was a read or a write and whether the program was in user mode at the time. The handler's first job is to read those, and it has no other way of knowing.

Which of the three cases it is comes entirely out of those two facts. An address in a region, with no frame behind it, is the ordinary case and gets one. An address in a region the program is entitled to read but not write, faulting on a write, is the second case. An address in no region at all is the third. The same handler, the same few lines, and the answer is different because what it looked up was different.

Lab 22 · Touch memory that is not there
Try this firstPress the button that writes to 0xFF018, the stack one. It faults, the kernel takes a frame, and the same instruction then works.
Five accesses, three outcomes. One needs no fault, two fault and are fixed while the program carries on none the wiser, and two are fatal for two different reasons. Read the before and after lines each time: the same fault handler produces all three outcomes, and the only thing that differs is what it finds when it looks the address up.
Lab 23 · Hand it everything, or hand it nothing
Try this firstPress Run both strategies. Two columns: frames taken and faults paid, for the same program.
Choose which pages the program touches, then race the two strategies. Map everything at exec, or map each page the first time it is asked for. The frames saved and the traps taken are both counted on the real model. Make the program touch all six pages and compare again; the better strategy depends on how many mapped pages are actually used.
Lab 24 · Any address you like
Try this firstType 0xFF040 in the box and press write. A fault, a frame, and the write goes through. Now change one digit to 0xFE040 and press it again.
Nothing here is graded. One program with the three pages every program has, and an address box you can put anything in. Find an address that works with no fault, one that faults and is fixed, and one that kills the program. Then ask for more memory with the button marked ask for another page (sbrk), which is the call that moves the top edge of a program's data upwards, and notice that not one frame is spent until you touch what it gave you. Keep asking, and find the point where the address space itself has no room for another page.
A program allocates a gigabyte and the allocation returns instantly. Memory in use barely moves. Has something gone wrong?
Nothing is wrong and nothing has been handed over yet. The kernel wrote down that the range belongs to you and did no more. Each page appears at the moment of its first touch, one fault at a time. Which is why a program can be refused memory long after the allocation that apparently succeeded, at some innocent line that happened to touch a new page.
Step 11

The timer, and taking turns

One processor runs one thing at a time. Everything else about running twenty programs at once follows from one arrangement: a timer chip wired to the processor, which interrupts it a few hundred times a second, whatever it is doing. An interrupt is the same crossing as a trap: the mode changes to kernel, and the processor jumps to an address the kernel wrote down at boot. Unlike a synchronous trap, a timer interrupt is caused by hardware rather than the current instruction. The running program does not request it, which lets the kernel regain control and schedule another process.

On each interrupt the kernel gets control. Usually it hands the processor straight back. Sometimes it copies the running process's registers into that process's row, copies another process's registers out of its row, and returns to a different program entirely. That copy is the whole context switch. The piece of kernel code that makes that choice is the scheduler, and it is far smaller than the name suggests. It keeps a list of the processes that are ready to run, called the run queue. Each time the timer fires it takes the next one off the front of that list and puts the one that was running on the back. A process that is sleeping is not on the list at all, which is why the scheduler spends nothing on it.

Why the timer has to be hardware and cannot be a rule

Early systems asked programs to give the processor up politely, and the arrangement works right up until one program has a loop with no exit in it. Then nothing else ever runs again, and there is no software anywhere that can help, because software only runs when it has the processor.

A timer that interrupts regardless is the only fix. It is why a modern machine survives a program spinning forever, and it is why turning interrupts off is a privileged instruction: a user program allowed to do that could take the machine and never give it back.

A tick, and a time slice: the two words the labs keep counting in

A tick is one firing of the timer, and it is the unit this whole machine measures time in. It is the number in the first column of the system call strip, it is the badge marked tick under the labs below. It is what a sleeping process is waiting for a certain number of. On a real machine a tick is a few thousandths of a second; here it is however often you press the button.

A time slice is how many ticks in a row one process is allowed to keep the processor before the scheduler takes it away and gives it to somebody else. The slider in the lab below sets it. A short slice means more switching, so more of the machine goes on the switching itself and less on the work; a long slice means less switching, but a job that wants the processor may sit and wait a long while for its turn. There is no right answer, and every operating system picks a compromise.

Why does the kernel save the registers rather than the program?

Because the program does not know it is being interrupted. When a function in your program calls another function, the two of them have agreed in advance about who saves what, and the caller can put anything it cares about somewhere safe before the call. A timer interrupt has no such moment. It lands between two instructions chosen by nobody and the program has no chance to prepare because it is not involved.

So the kernel has to save all of it, every register, exactly as it stands. In the lab below that is the three values you can see: pc, the address of the next instruction; the accumulator, which is where the counting is happening; and sp, the address of the top of the stack. Each process's row holds a copy of all three, and the row is out of date for exactly as long as that process is the one running. Lab 26 takes one of those three out of the saving code and lets you read what happens next.

Lab 25 · One tick at a time
Try this firstPress One tick five times. The accumulator climbs, and on one of those five presses the processor changes hands.
Two jobs counting to nine, and one accumulator between them. Step the timer and watch the moment the switch happens: the value in the processor is copied into one row and replaced from another. Shorten the time slice and count the switches, then lengthen it and notice what a job has to wait for.
Lab 26 · A kernel that forgets one register
Try this firstPress Run both machines. Four answers from two pairs of identical jobs. Two of them are wrong.
The same two jobs on two kernels. One saves the accumulator across a switch and one does not. Neither program changes and neither crashes. Read the two answers, and consider that the difference is a single missing line in code that runs a few hundred times a second.
A machine has one core and eight processes, seven of them sleeping on input. How much of the processor is the scheduler spending on the seven?
None. Sleeping is not "asked and declining", it is not being on the list. The process was taken out of the queue when it slept and it goes back on when whatever it was waiting for happens. That is why a machine can have hundreds of processes and still spend all its time on the one that has work, and why the count of processes tells you almost nothing about how busy a machine is.
Step 12

Two processors, one counter

Add a second processor and something breaks that has never broken before. Two of them can be inside the same piece of code at the same time, on the same data, and correct code stops being correct.

Adding one to a counter is not one operation. It is three: read it, add one in a register, write it back. If the other processor reads between your read and your write, both of you write the same answer and one of the two increments is simply gone. Nothing was corrupted and no rule was broken. Two pieces of code racing to touch the same thing, where the answer depends on which of them gets there first, is called a race, and it is the name for this whole family of bugs.

The fix is a lock. A lock is one shared thing, in the machine below a single number that is either 0 or 1, with two operations on it. To acquire it: if it is 0, set it to 1 and carry on; if it is already 1 then somebody else has it, so stop here and do nothing at all until they give it back. To release it: put it back to 0. Testing the number and setting it has to happen as one indivisible step. Otherwise two processors could both find it 0 at the same instant and both carry on. Processors have a single instruction that does exactly that, and atomic is the word for an operation nothing can get in the middle of. If both processors acquire the same lock before touching the counter and release it after, then only one of them can ever be between the read and the write, and no update can go missing. Nothing enforces this. The lock protects the counter only because every piece of code that touches the counter agrees to take it first.

Why this is a kernel problem and not only a program problem

Two processors run kernel code at the same time as easily as they run yours, and the kernel's data is shared by definition: one process table, one free frame list, one open file table. Two processes on two cores calling fork at the same moment are both walking the same table looking for a free slot.

The same problem turns up on a machine with one core, because the timer can interrupt between any two instructions, including between a read and the write that follows it. Multiple cores make it easier to see and much harder to get lucky with.

Two ways a lock goes wrong, and what waiting costs

Take it too late or let it go too early and it protects nothing. If the gap between the read and the write is not entirely inside the lock, the other processor can still get in, and the count is still wrong, just less often, which is worse. Take it and never let it go and the counter is perfectly safe and the machine stops: the other processor waits for something that is never coming back. A wait with no end is called a hang. It is the most common way locking is got wrong.

And waiting is never free. A processor sitting on an acquire is doing no work at all, so every instruction inside a lock is an instruction some other processor may be spending doing nothing. That is why the piece of code a lock covers is kept as short as it possibly can be, and why the answer is never "put a lock round everything".

What goes wrong when there are two locks?

Something that neither piece of code is wrong on its own. Suppose one lock protects the process table and another protects the list of free frames. One processor takes the process table, then wants the frame list. The other takes the frame list, then wants the process table. Now each of them is holding the thing the other is waiting for, and neither will ever let go, because letting go is what they were going to do after getting the second one.

Nothing has crashed and no rule has been broken. Both processors are simply waiting, forever, and the machine is finished. The fix is a rule rather than a mechanism: everybody who takes both locks takes them in the same order. Kernels write that order down, in a comment. It is one of the things a change to kernel code is checked against most carefully.

Lab 27 · Lose an update on purpose
Try this firstPress CPU A, then CPU B, then A again, then B again. Both of them read the same value out of the counter, and the log below says so.
Step the two processors in any order you like. Run them one after the other and the answer is right. Interleave them and it is not. Then turn the lock on and try to break it again, and watch the waiting show up in the last column, because a lock is never free.
Lab 28 · Put the lock in the right place
Try this firstPress Try every possible order before placing anything. It fails, because with the lock nowhere there is nothing stopping the two processors overlapping.
Every way the two of them can interleave one increment each gets tried, all 252 of them. Choose where the lock is taken and where it is let go. A placement that is right nearly always is still wrong, and the checker will hand you the exact order that breaks it. Three of the placements lose no updates at all and hang instead. Only one of the sixteen placements survives, and it survives every single order rather than most of them, which is the difference between a lock and a lucky test.
Lab 29 · Share the machine yourself
Try this firstPress spin 12 & twice, then One tick five times. Two rows appear, and on one of those five presses the processor changes hands.
Nothing here is graded. Two of the programs here exist only for this: spin counts in a register as fast as it is allowed to and never waits for anything, and sleepy does almost nothing but wait. The ampersand on the end tells the shell not to wait for the job, so you can start several. Start whatever mix you like, set the time slice, and drive the timer by hand. Try a slice of one and count the switches, then a slice of eight and see how long the last job waits for its turn. Start a sleeping job alongside a counting one and watch the scheduler pass it over without spending anything on it.
A counter is wrong roughly one run in ten thousand. A developer adds a print statement to investigate and the problem stops happening. What is the most likely explanation?
The timing moved. The window where this goes wrong is a handful of instructions wide, and anything that slows one side down makes hitting it far less likely. The bug is still there, untouched, and it will come back on a faster machine or a busier day. This is the single most recognisable signature of a race, and the reason these are fixed by reasoning about the code rather than by experiment.
Step 13

Add a system call

Twenty one calls have been sitting in that table since the machine booted, and none of them is special. Each one is a number, an entry in a table, a function that runs in the kernel, called the handler, and a scrap of user side code that puts the number in a register and traps, called the stub. The stub is the piece on your side of the boundary and the handler is the piece on the other side. Add all four and you have a twenty second.

The one to add here is nproc: how many process slots are in use. It is a good first system call because a user program has no way to answer it on its own. The process table is kernel memory, and asking is the only way in.

What can go wrong in a handler, and why kernels are so careful

A handler runs with every permission there is, on behalf of a program that may be actively hostile. Anything it is passed has to be checked before it is used: a pointer from a user program might point into the kernel, a length might be enormous, a descriptor number might be off the end of the table. The handler cannot trust any of it.

It also cannot take its time. While it runs, the process that called it is stopped, and if it holds a lock then everything else that wants that lock is stopped too. Handlers that need to wait put the process to sleep and return later rather than sitting there.

Why can a user program not simply read the process table itself?

Because it is not in your address space. The process table is in kernel memory, and no page of kernel memory appears anywhere in your page table: there is no virtual address your program could put in a register that would reach it. It is not that reading it is forbidden and checked. There is nothing at those addresses as far as your program is concerned, and an attempt to touch them is the third kind of page fault from step 10, the fatal one.

Which is why the trap is not a formality on the way to something a program could have done anyway. It is a change of address space as well as a change of mode: the same instruction that raises the privilege puts the processor somewhere the table is visible. A system call crosses that protection boundary, allowing checked kernel code to inspect process-wide state that user code cannot access directly.

Twenty one calls. How many does a real machine have?

Linux has around three hundred and fifty, and the great majority of them are variations. There is a call to open a file, and another one that opens it relative to a directory you already have open, and another one that takes a bag of options instead of a fixed set of arguments. The twenty one here are close to the whole of what xv6 has. They are enough to run a shell, a compiler and a text editor, which suggests how much of the rest is convenience rather than capability.

What a real kernel does not do is renumber them. Every program ever compiled has the numbers built into it, so number 1 has to mean the same thing in thirty years as it does today, and a call that turns out to be a mistake is left in place forever with a better one added beside it. That is why the list only ever grows, and why the quiz below matters more than it looks.

Lab 30 · A number, a handler and a stub
Try this firstType 22 in the number box, press Show me one that works, then Install it and try it. Type nproc in the terminal below and a number comes back.
Pick a free number, write the handler, name the command. Then type the name in the terminal below and it traps into your code. The checker installs your call on three different machines and compares your answer with the truth at the moment of the call, so a handler that returns the right number once by luck will not survive the second one.
Lab 31 · The whole machine, unmarked
Try this firstType cat notes.txt | wc and press Enter. Three numbers: lines, words and characters, counted by a program that never opened the file.
Nothing here is graded. A shell, nine programs, redirection, one pipe and background jobs, with the process table, a descriptor table and the disk beside it. Try to fill the table. Try to fill the disk. Start a pipeline and watch which process is asleep and on what.
A new system call is added to the kernel and given the number 7, which is already exec. Everything still compiles. What happens?
One entry, and whichever was written last is what everybody gets. The table is an array indexed by number, and nothing anywhere is checking that two names do not claim the same slot. The symptom is spectacular and completely unhelpful: programs that appear to call exec and instead do something unrelated. This is why the numbers live in one file, in one list, and why nobody ever reuses one.

What you can do now

  • Explain what a system call is at the level of the mode bit, the trap and the table.
  • Read a process table and say what each process is doing and what it is waiting for.
  • Write the fork, exec and wait loop that every shell is built on, and say what goes wrong when either of the other two calls is missing.
  • Set up redirection and a pipe out of open, close and dup, in the gap between fork and exec.
  • Follow a file from a name to an inode to the blocks holding it, and say what a delete does and does not free.
  • Translate a virtual address by hand, and say what the kernel does with each of the three kinds of page fault.
  • Say exactly which state a context switch saves and what a race is, and place a lock so that no interleaving can lose an update.
  • Add a system call to a kernel: a number, a handler and a user stub.

Where this goes

  • Fault Tolerance. The same machinery when parts of it stop working: crashes in the middle of a write, messages that never arrive, and the small set of ideas that keeps a system standing anyway.
  • Performance Engineering. Why the page faults and context switches you have just been counting dominate the running time of real programs, and what to do about it.
  • Memory Exploits. What a process looks like to somebody attacking it, now that you know exactly what the kernel is and is not protecting.
  • The real thing. xv6 is a complete Unix in about nine thousand lines of C, with the same call numbers used here. Everything in this course is in there, in code you can read in an afternoon and change in an evening.
Step 14

Choose who runs next

The round-robin scheduler from step 11 is fair in one useful sense: every runnable process gets a turn. It does not know that one process is waiting to redraw the screen while another is compressing a large file. Giving both a long turn makes the screen feel stuck. Giving both a very short turn spends too much time switching. Scheduling is an engineering choice because there is more than one thing worth improving.

Response timeHow long until a ready job first runs?
TurnaroundHow long from arrival until the job finishes?
ThroughputHow many jobs finish in a fixed time?
DeadlineDid the work finish before its result became useless?

First-come, first-served is simple but lets one long job hold up every short one. Round robin limits that wait with a time slice. A priority scheduler runs important work first, but a low-priority job can starve if important work keeps arriving. Fair schedulers keep a running account of CPU time and favour jobs that have received less. Current Linux kernels use EEVDF, which considers whether a task is eligible and gives it a virtual deadline. The details are more careful than round robin, but the input is still measured runtime and a policy about who should wait.

Why not use one policy for every computer?

A game wants low input latency. A render farm wants throughput. A flight controller must finish some work before a deadline. These goals can disagree. Operating systems therefore provide scheduling classes and weights, while safety-critical systems often reserve time explicitly. Write the workload and the failure that matters before choosing a policy.

Priority inversion: when high priority has to wait for low priority

Suppose a low-priority task holds a lock. A high-priority task needs that lock, so it blocks. A medium-priority task can now run ahead of the low-priority task, preventing it from releasing the lock. The high-priority task is indirectly delayed by medium-priority work. With priority inheritance, the lock holder temporarily receives the waiting task's priority. It runs, releases the lock, then returns to its old priority.

On several cores the scheduler must also decide where a task runs. Moving it may balance load, but it also makes the new core refill its caches. CPU affinity, cache topology and NUMA memory all affect that choice.

Current reference: Linux EEVDF scheduler documentation.

Lab 32 · Compare four scheduling policies
Try this firstSwitch from first-come to round robin. Find the short job's first response, then check whether any deadline was missed.
No policy wins every column. Compare response time, completion time, context switches and missed deadlines. State which column matters for your system before calling one result better.
A high-priority control task is waiting for a lock held by a low-priority logger, while a medium-priority task keeps running. What directly addresses the problem?
Use priority inheritance. It lets the lock holder finish the small critical section and release the resource that the high-priority task needs.
Step 15

Reclaim memory under pressure

A page fault does not always mean there is a free frame waiting. When memory is full, the kernel chooses a resident page to evict. A clean file-backed page can be dropped because the file still contains it. A dirty page must be written first. An anonymous page, such as a program's heap, has no file to reread, so it needs swap space or the process must be stopped. The victim choice changes both speed and storage traffic.

FIFO evicts the page that arrived first. LRU tries to evict the page used least recently. Perfect LRU would require recording every access, so real kernels approximate it with reference bits and lists. The clock algorithm walks frames in a circle: a referenced page gets a second chance and has its bit cleared; an unreferenced page is the victim. All three can be tested with the same reference string in the lab.

The page cache, mmap and one physical copy

Reading a file usually places its blocks in the page cache. A later read can use those same physical pages without touching storage. mmap maps cached file pages into a process, so a load instruction can read them directly. Copy-on-write can let several processes share a page until one writes. These are different interfaces built on the same frame, page-table and fault machinery.

Why more processors make virtual memory harder

Each core has a TLB. If the kernel changes a mapping that other cores may have cached, it sends them a TLB shootdown so they discard the stale entry. On a NUMA machine, memory attached to the local CPU socket is faster than remote memory. Page placement and task placement then become one problem: moving a task can turn local accesses into remote ones, while moving its pages costs bandwidth and pauses.

Lab 33 · Run three page-replacement policies
Try this firstSelect FIFO, then LRU. Follow the same page references and count faults and dirty write-backs.
The trace is the evidence. A policy that has fewer faults on this sequence may lose on another. Change which page is dirty and separate read latency from write-back cost.
Why can the kernel discard a clean file-backed page without first writing it to storage?
The file is the backing copy. A dirty page differs from that copy and must be preserved before its frame is reused.
Step 16

Keep storage consistent after a crash

A call to write can finish while the new bytes are only in the page cache. Storage may reorder commands, and power can fail between any two durable writes. fsync asks the operating system to make a file's required data and metadata durable before returning. That promise matters only when the storage device and its controller honour flush and ordering commands.

Changing a file often changes several blocks: new data, an inode size, allocation records and a directory entry. If only some reach storage, the on-disk structures can disagree. A journal first writes a description of the transaction and marks it committed; recovery replays complete transactions and ignores incomplete ones. A copy-on-write filesystem writes new blocks away from the old tree, then switches one root pointer. Both approaches define a point at which the new state becomes the one recovery will choose.

Atomic, ordered and durable are different promises

Atomic means recovery sees the old state or the new state, not a mixture. Ordered means one write becomes durable before another. Durable means acknowledged data survives the failures named in the contract. A database transaction may be atomic but not durable if nobody asks the filesystem to flush it. Begin every crash test by naming the failure: process crash, kernel panic, device reset or sudden power loss.

Checksums detect damage; they do not create another copy

A checksum can tell recovery that a block is torn or corrupted. It cannot reconstruct missing bytes on its own. Mirroring, erasure coding or another replica supplies a second copy. Checksums plus redundancy let a system both notice damage and repair it.

Lab 34 · Pull the power during a rename
Try this firstUse unordered writes and stop after the first durable action. Then compare journal and copy-on-write recovery at the same point.
A recovery rule is part of the design. Record which state is visible, whether its referenced blocks exist and whether the new value was promised as durable.
A program calls write and immediately prints “saved”. Power fails before the dirty cache page reaches storage. What was missing?
Write returning and durable storage are separate events. The application must ask for the durability guarantee it needs, and handle an error if that guarantee cannot be made.
Step 17

Move data without making the CPU copy it

A device controller is a small computer beside the device. Its registers let the kernel start work, inspect status and acknowledge completion. A driver translates operating-system requests into that device's register operations and turns device events back into results the rest of the kernel understands. The hardware interface may be awkward; the driver keeps that awkwardness out of the filesystem and network stack.

The CPU can repeatedly poll a status register, but those checks use time even when nothing has happened. An interrupt lets the controller notify the CPU. The interrupt handler does the minimum urgent work, records what happened and schedules longer processing for later. Otherwise one busy device could keep more important interrupts from being handled.

For a large transfer, DMA lets the controller read or write memory itself. The kernel builds a list of buffers and descriptors, gives their device-visible addresses to the controller and receives an interrupt when a batch completes. The CPU still prepares and checks the operation; it no longer executes a load and store for every byte.

If a device can read memory, what stops it reading everything?

An IOMMU translates and checks device memory accesses in much the same way that a page table checks process accesses. The kernel maps only the buffers a device may use. Without that boundary, a broken or hostile device can overwrite kernel memory even though every process page table is correct.

Queues, batches and the latency trade-off

Modern storage and network devices use rings of descriptors. Batching several completions into one interrupt reduces overhead and improves throughput. Waiting to fill a batch adds latency, so drivers tune queue depth and interrupt coalescing for the workload. Async I/O lets a process submit work, do something else and collect completions later instead of blocking one thread per request.

Lab 35 · Move a 64 KiB buffer four ways
Try this firstCompare programmed copying with interrupt-driven DMA. Count CPU byte copies, status checks, descriptors and interrupts.
Turn IOMMU protection off only to inspect the consequence. Speed and isolation are separate columns. A fast transfer that grants the device all of RAM is not a safe design.
What does DMA remove from a large device transfer?
The controller moves the bytes. The kernel still prepares descriptors, maps authorised buffers and handles completion.
Step 18

Limit identity, visibility and resources

Page tables isolate memory, but a useful security boundary needs more. User and group IDs label who is asking. File permissions and access-control rules decide what that identity may do. Linux capabilities split the all-powerful root role into narrower privileges, such as binding a low network port without also gaining permission to load a kernel module.

A namespace changes what a process can see: its own process-number tree, mount table, hostname, network interfaces or user IDs. A cgroup accounts for and limits resources such as memory, CPU time and process count. A syscall filter such as seccomp limits which entrances to the kernel are available. Mandatory access-control policies add rules that remain in force even when ordinary file permissions would allow access. These mechanisms solve different problems and are normally layered.

Why “runs in a container” is not a complete threat model

A container normally shares the host kernel. A mount namespace does not limit memory use. A memory cgroup does not hide host files. A syscall filter does not repair a dangerous file mount. Write down the attacker, the assets and the allowed operations, then map each risk to a specific boundary. Also decide what happens when a limit is reached: return an error, throttle work or terminate a process.

Current reference: Linux cgroup v2 documentation.

Lab 36 · Build a boundary one control at a time
Try this firstEnable only the mount namespace, then run all four tests. Notice which attack it blocks and which three it does not.
Each test has one matching control. Keep a table of attempted action, kernel decision and evidence. “It looked isolated” is not a test result.
A process cannot see host files, but it can create processes until the host runs out. Which missing control addresses this directly?
Limit the resource being exhausted. Visibility and resource accounting are different layers.
Step 19

Separate virtual machines from containers

A process trusts the host kernel to enforce its boundary. A container is a group of processes given a restricted view and resource budget by that same kernel. A virtual machine includes a guest kernel. A hypervisor controls privileged operations and uses second-level page tables so a guest physical address is translated again before reaching host memory.

Sharing the host kernel makes containers quick to start and relatively small. A VM has a stronger kernel boundary and can run a different operating system, but booting and storing a guest costs more. A microVM trims the emulated hardware and boot path to keep that separate kernel with less overhead. These are points on a design range, not competing names for the same mechanism.

How a guest talks to real hardware

Fully emulating a device register by register works but can be slow. Paravirtual devices such as virtio give the guest and hypervisor a shared queue designed for virtualisation. Hardware virtualisation lets the guest run ordinary instructions directly and traps only operations the hypervisor must control. An IOMMU can safely assign a physical device or virtual function to a guest.

Lab 37 · Pick a boundary for the workload
Try this firstSelect “different operating-system kernel”. Compare a process, container, microVM and full VM, then choose the smallest boundary that meets the requirement.
Do not rank only by startup time. Record whether the host kernel is shared, which kernel may be compromised and what hardware model the workload needs.
What is the defining difference between an ordinary container and a virtual machine?
The kernel boundary differs. Both can limit resources and present virtual devices or networks.
Step 20

Choose where kernel services live

A monolithic kernel keeps core services and many drivers in one protected address space. Calls between them are cheap, and a bad pointer in any of them can damage the whole kernel. Loadable modules allow drivers to be added later but normally keep the same privilege. A microkernel keeps a smaller set in privileged mode: address spaces, scheduling and message passing. It moves services such as filesystems and drivers into user processes. A hybrid design mixes these choices.

Moving a service out of the kernel can contain a crash and shrink the trusted computing base, the code that must behave correctly for the security claim to hold. It also adds messages, context switches and more explicit failure handling. The right placement depends on fault containment, latency, hardware access, change rate and how much code the team can review.

What formal verification can and cannot prove

A proof connects a precisely stated model, assumptions and implementation property. The seL4 microkernel has machine-checked proofs that include functional-correctness results for its verified configurations. That does not prove every driver, application, compiler, circuit or deployment policy correct. Read the theorem and assumptions before repeating a claim. Testing samples executions; model checking explores a finite state model; deductive proof reasons from a specification. Strong systems use several kinds of evidence.

Further reading: seL4 verification overview.

Lab 38 · Move one service across the boundary
Try this firstMove the network driver from kernel space to a user process. Compare privileged code, message crossings and fault containment.
The diagram counts a cost as well as a benefit. Use a workload measurement and failure test before deciding that either placement is universally better.
Moving a driver into a user process most directly improves which property?
A process boundary can contain the crash. The price is communication and a more explicit interface to authorised hardware.
Step 21

Measure a kernel before changing it

A symptom is not yet a cause. “The service is slow” might mean it was not scheduled, waited for a lock, faulted in pages or waited for storage. Start with a question, choose the smallest evidence that can separate the hypotheses, reproduce the event and keep the unmodified trace. Counters show totals. Logs describe named events. Profiles sample where CPU time is spent. Traces preserve order and timing. Core dumps preserve a failed state.

Linux tracepoints expose stable event locations, and eBPF programs can collect selected data in the kernel with a verifier checking important safety rules before they run. That makes targeted observation possible without recompiling the kernel. Observation still changes timing and consumes buffers, so repeat the test, record probe overhead and treat dropped events as missing data rather than as proof that nothing happened.

Use the right evidence for bugs that do not repeat easily

For a crash, preserve the build, configuration, input, logs and core dump. For a race, use stress tests, schedule perturbation and a race detector, then reason about the required ordering. For a protocol state machine, model checking can search every transition in a bounded model. Fuzzers generate many malformed inputs. A proof can cover all inputs under stated assumptions. None of these replaces a clear failure model.

Where AI tools fit in systems engineering

A current coding model can suggest a trace query, summarise a large log or propose likely causes. Treat each result as a hypothesis. Keep raw evidence, check generated commands, minimise a reproducer and confirm the explanation with a counter or controlled change. Do not paste secrets, crash dumps or production memory into a service without permission. A plausible explanation is not a measurement.

Current reference: eBPF tracepoint program documentation.

Lab 39 · Design a trace from a question
Try this firstSelect “storage stall”, then add only the probes needed to tell queueing time from device time.
A useful trace answers the question with the least extra data. The checker tells you what remains ambiguous; add probes until the causal interval can be reconstructed.
An AI assistant says a lock caused a pause. What is the strongest next step?
Turn the suggestion into a testable hypothesis. Preserve the trace and compare it with a run in which the suspected cause is controlled.

What you can now investigate

  • Trace a request across a system call, process state, scheduler, page table, filesystem and device.
  • Choose scheduling and replacement policies from measured goals rather than habit.
  • State a crash model and test atomicity, ordering and durability separately.
  • Build an isolation boundary from identity, visibility, resource and syscall controls.
  • Compare processes, containers, microVMs and VMs by the kernel boundary they provide.
  • Use counters, profiles, traces, fuzzing, model checking and proofs as different forms of evidence.