Interactive course · about 5 hours

Assembly and Debugging

A processor cannot add two numbers from a book. It moves one number into a named slot, moves another beside it, adds, and remembers whether the answer came out zero. Everything a computer has ever done is a few billion of those. Here you write them by hand, then stop a broken program in the middle of one and find out what it did wrong.

How this works

There is a working machine in this page: sixteen registers, a flags register, and memory you can watch. You type instructions, press Step, and see exactly what changed. Every program here runs on that machine, including the broken ones, and the last step hands you a program that produces the wrong answer with no explanation, which is the actual job.

Two things to know before you start

The instructions here are written destination-first, which is called Intel syntax: mov rax, 5 puts 5 into rax. Debuggers usually print the other order, and Step 2 has a note on reading both. And this machine understands a chosen part of what a real processor can do, rather than all of it. The part it understands is roughly the part that real compiled programs are actually made of, so nothing you learn here is a special case invented for teaching.

The steps

Step 1

One line of C, several instructions

Take total = total + price;. One line, one idea, and a processor cannot do it. It has no instruction for "add the thing called price to the thing called total", because it has no idea what a name is. Names are a convenience your compiler invented and then threw away.

What is C, and why are the examples in it

C is a programming language, and one of the oldest still in wide use. A line of C such as total = total + price; is text a person writes. On its own it does nothing at all: it has to be compiled first, which turns it into instructions.

The examples here are in C because C sits closer to the instructions than most languages do, so the two sides line up neatly and you can see which piece became which. You do not need to know C to follow this course. Everything you need to read is explained where it turns up, and the instructions on the right are the real subject.

What is a compiler

A compiler is a program whose job is to turn the code a person writes into the instructions a processor can run. You write total = total + price;, hand the file to the compiler, and it hands back a list of instructions like the ones on the right of the lab below. It runs once, before the program does. By the time the program is running the compiler is long gone, which is why the names you chose are gone too.

This matters more than it sounds, because you will spend the rest of this course reading a compiler's output rather than your own. When a program misbehaves, the thing running is never the text you wrote. It is what the compiler decided that text meant. Most of the surprises in Step 9 come from the gap between the two.

Read that line the way a programmer reads it, not the way you read it in maths. In maths total = total + price would be a claim about two things being equal, and a false one. In C the equals sign is an instruction: work out what is on the right, then put the answer into the thing on the left. So the line means "take the old total, add the price to it, and make that the new total".

What the processor has instead of names is a handful of numbered slots called registers, and instructions that each do one small thing to them. That one line becomes three: fetch, add, put back, which is a word-for-word translation of it once you read the equals sign that way. Instructions written out as text, one per line, the way you are about to see them, are called assembly. It is the closest thing to writing down what the processor itself does: one line of assembly is one instruction, no more and no less. Turning that text into the numbers a processor actually reads is a small mechanical job called assembling, and the machine in this page does it every time you press a button. When it tells you a line will not assemble, it means it could not work out which instruction you meant.

What a register is, and why there are so few

A register is a slot inside the processor itself that holds one number. There are sixteen of them for general use, and that is not a limitation anybody regrets: they are the fastest storage that exists, because they are physically part of the circuit doing the arithmetic.

Memory is enormous and comparatively far away. A register is instant. So the shape of all assembly is the same: bring a value in from memory, work on it in registers, put the result back. If you have done the microprocessor course, these are the same registers you built out of gates.

Lab 1 · One line, and what it becomes
Try this firstPoint at the third line of C, total = total + price;. Three instructions light up on the right, and the note underneath says so. Then point at one of those instructions and watch the line of C light up.
You are not meant to be able to read the right-hand side yet. Every symbol on it gets its own step, starting with the next one. For now only count: one line on the left is almost never one line on the right. Then press the an if and a loop tabs: a single line of C becomes a comparison, a jump, and a place to jump to, and the order on screen stops matching the order on the page.
There is no line of code down here

A processor does not run lines. It runs instructions, and a line of source is just a note about which instructions came from where. This is why a debugger sometimes stops on a line that looks wrong, and why stepping through optimised code jumps about. The line numbers are a map of the ground, not the ground.

A debugger is a program that runs another program on a leash: it can stop it at a chosen spot, let it move forward one piece at a time, and show you every value it is holding while it is stopped. Moving it forward one piece is called stepping. Part 4 of this course is nothing but that, and it is the part that keeps working in every language you ever learn. The question below is about what one press of step actually moves.

Lab 2 · How many instructions is that?
Try this firstPress one of the five number buttons to answer question 1, then press Next. The real count and the reason for it appear underneath either way, along with how many you have got right so far.
Guess before you look. Six lines of C, and the question each time is how many instructions it takes. Most people are far too low on the array one and far too high on the multiply. Some of the answers mention things you have not met. That is on purpose: this lab is only about the counting, and every name in the explanations is a later step.
You are stepping through a program in a debugger and it stops on the line total = total + price;. You press step once. Which is true?
Several. Stepping by line runs every instruction the compiler produced for that line, however many that is. There is a separate command for stepping one instruction at a time, and Step 10 uses it constantly, because when a line contains a mistake the only way to see it is to stop inside the line.
Step 2

Registers, and moving things

Sixteen slots, each holding one 64-bit number. They have names rather than numbers: rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, and r8 to r15. Two of them have jobs, which Step 7 gets to.

The instruction that moves a value about is called mov, and it is the most common instruction in any program by a wide margin.

Reading mov, and the other order it gets printed in

mov rax, 5 means "put 5 into rax". Destination first, like the equals sign in almost every programming language. Square brackets mean "the memory at this address", so mov rax, [rbx] means "go to the address in rbx, fetch what is there, put it in rax".

Debuggers on Linux print the opposite order and add sigils: the same instruction appears as mov $5, %rax. That is called AT&T syntax, which only means the way the instructions are spelled out and punctuated, and it is the same instruction written backwards. When you meet it, the rule is simply that the last operand is the destination.

Why do some lines say qword

mov rax, [rsp-8] is unambiguous: rax is 64 bits wide, so the machine knows to fetch eight bytes. But mov [rsp-8], 5 is not. Five would fit in one byte, or two, or four, or eight, and the address does not say how wide it is. So you say: qword means eight bytes, and dword, word and byte mean four, two and one.

The rule is simple. If one side of the instruction is a named register, its size settles the question and you write nothing. If both sides are a plain number and an address, you have to say which size you meant. The machine here will tell you when you have left it out rather than guessing, because a wrong guess would write four bytes where you wanted eight and leave half a value behind.

Lab 3 · Move things around, one step at a time
Try this firstPress Step, not Run, five times: once for each line. A register changes on each press and is marked as it changes, except the fourth: that one writes to memory instead, and a value appears in the memory strip at the bottom.
Notice that mov copies. After moving rax into rbx, both hold the value. Nothing is ever moved out of anywhere, which makes the name a small lie everyone lives with.

How to read the memory strip. Each row is eight bytes of memory, sitting next to each other. On the left is the address of the first of them, written in hex. In the middle are the eight bytes themselves, one pair of hex digits each. On the right is what those eight bytes come to when read as a single number, which is what the machine would hand you if you fetched from that address. Bytes this program has just written are picked out and the rest are dimmed. The ← rsp arrow marks the row the stack pointer is on, which Step 7 is about. The strip is showing the same memory the instructions are writing into, and it turns up again in five later steps.
The same register, four sizes

Each register can be used whole or in part. rax is all 64 bits, eax is the low 32, ax the low 16, al the low 8. They are not separate registers: they are windows onto the same one. And the rules for what happens to the bits you did not write are not what anyone guesses.

Lab 4 · The rule nobody guesses right
Try this firstPress one of the three predictions, then press Run to the end. The register panel under the program shows what rax ended up holding, so you can see straight away whether your prediction held, and the note below it says why.
Predict, then run. rax is full of ones. You write a zero into part of it. What happens to the rest depends on which part you wrote, and the two answers are inconsistent with each other. This is real, it is in the hardware, and it is the source of a whole family of bugs where a program works with small numbers and fails with large ones.
rax holds 0xFFFFFFFFFFFFFFFF, every bit set. You run mov eax, 0. What does rax hold afterwards?
Zero, all of it. Writing any 32-bit register wipes the top half of the 64-bit register it lives in. Writing an 8-bit or 16-bit register does not: those leave everything above them untouched. There is no principle here to remember, only the rule, and the compiler relies on it constantly to clear a register cheaply.
Step 3

Arithmetic, and what it remembers

Arithmetic is mostly what you would expect. add adds, sub subtracts, and imul multiplies. The i on the front of imul stands for integer, because there is a separate multiply for numbers with a decimal point in them. Two more turn up constantly, because counting up and down is so common that each gets an instruction of its own: inc adds one, dec takes one away.

What is not obvious is that each of them quietly writes down four facts about the answer, in a place called the flags register, and those four facts are how a processor makes every decision it ever makes.

The four flags, and what each one means

ZF, the zero flag: the answer was exactly zero. SF, the sign flag: the top bit of the answer was set, which means it looks negative. CF, the carry flag: the answer ran off the top, treating the numbers as unsigned. OF, the overflow flag: the answer ran off the top treating them as signed, which is a different event.

They are set as a side effect, not asked for. Six kinds of instruction update them: add, sub, cmp, and, or and shr. The last three have the same names as the bit operations from the earlier course, and they do the same thing here. Whichever of them ran most recently is the one the flags are describing and the instruction that reads the flags always comes afterwards. That gap is where a certain kind of bug lives: put any arithmetic between a comparison and the jump that depends on it and the jump is now answering a different question.

Lab 5 · Predict the flags
Try this firstPress the flags you think will be set out of ZF, SF, CF and OF, then press Run to the end. The four badges under the program show what actually happened, and the note says which of your four were the other way round.
Commit before revealing. Two numbers and an operation. Say which flags will be set, then look. The pair worth studying is 127 + 1 and 255 + 1 in a single byte: one sets overflow and not carry, the other sets carry and not overflow, and understanding why is understanding the difference between signed and unsigned for good.
cmp is a subtraction you throw away

cmp rax, rbx does exactly what sub rax, rbx does, sets all four flags the same way, and then discards the answer instead of storing it. That is the whole instruction. A processor compares two numbers by subtracting them and looking at what fell out.

Lab 6 · Make the flags say what I want
Try this firstLeave Target 1 selected and press Check the flags. It fails and names the flag that is wrong. Now write two lines that make ZF come out set: put a number in rax, then compare rax with the same number.
Work backwards. You are given a state the flags must end up in, and you write the arithmetic that gets them there. Set the flags you want by choosing two numbers and subtracting them, then read off what happened: equal gives you ZF, a smaller minus a bigger gives you SF and CF together, and running off the end of a single byte is how you get CF or OF on their own. There is more than one answer to each, and the checker looks at the flags rather than at your instructions.

The first line of the program starts with a semicolon. That makes it a comment: a note for whoever is reading, which the machine skips over entirely. Anything after a semicolon on a line is ignored, so you can leave yourself reminders next to your instructions without changing what runs.
A program does cmp rax, rbx, then add rcx, 1, then je equal. The jump behaves unpredictably. Why?
The add clobbered the flags. je jumps when ZF is set, and after the add, ZF describes whether rcx + 1 came out zero. The comparison's answer is gone. This is a real and nasty bug because the program still runs, and it gives the right answer whenever rcx happens not to be minus one. Instructions that leave the flags alone exist for exactly this reason, and lea in Step 6 is the one compilers reach for.
Step 4

Jumps, and building a loop

There is no if and no while down here. There is one mechanism: change which instruction runs next. An unconditional jump always changes it. A conditional jump changes it only when a flag says so.

A jump has to say where to go, and it says so by name. You mark a place in the program by writing a name of your choosing followed by a colon, on a line of its own: top:. That is called a label. It is not an instruction and nothing happens when the machine reaches it; it is only a name for the spot, so that jmp top has something to aim at. Pick any name you like. The examples here use top, done and skip because those say what the place is for.

Every jump instruction starts with the letter j. jmp is the unconditional one: go there, no question asked. The conditional ones add a short word for the thing they test, and what they test is the flags the last comparison left behind. je is jump if equal, jne is jump if not equal, jl is jump if less, jg is jump if greater and an e on the end adds "or equal", so jge is jump if greater or equal. Out of that one mechanism come every loop, every branch, every switch and every function call ever written.

Say a loop another way

A loop is a jump backwards to a label you have already passed, guarded by a comparison. That is all. "While rcx is less than ten" becomes: label at the top, compare rcx with ten, jump past the bottom if the comparison fails, do the work, jump back to the label.

Which explains something odd about compiled code: the test for a loop often ends up at the bottom rather than the top, because that arrangement needs one jump per pass instead of two. The C looks like a loop with a test at the top, and the instructions look like nothing of the kind.

Lab 7 · Watch the arcs, and where it goes next
Try this firstPress Step six times, until the marker reaches jmp top on line 8. The arc beside that line lights up. Press Step once more: the marker lands back on line 4 and the arc's label says taken.
Step it and watch the marker. Every jump is drawn as an arc in the column beside the code, running from the jump to the line it aims at. The arc lights up while the marker is sitting on that jump, and one press later its label says whether the jump was taken. Watch the loop go round: the same backward arc lights on every pass until the comparison finally fails and the marker falls past it.
Lab 8 · Build a loop that counts down
Try this firstPress Check the total before you write anything. It says rax is still 0 and the body never ran. Now write the loop on the empty lines at the bottom and press it again.
Add up ten down to one, and land on 55. You need a label, a comparison, a conditional jump and a jump back. If it never stops, the machine catches it and says so rather than hanging.
A loop's body runs once when it should not run at all. The test is at the bottom, after the body. What is the fix?
Test before entering as well. A test at the bottom cannot stop the body running the first time, because it has not run yet. Compilers handle this by testing once up front and then using the bottom test for every pass after, which is why optimised code often contains what looks like the same comparison twice for no reason.
Step 5

The same bits, two answers

Here is a fact that sounds like a trick and is not. The processor does not know whether your numbers are signed. The bit pattern for minus one and the bit pattern for the largest possible unsigned number are the same pattern. Which one it is depends entirely on which instruction you use to ask.

How one pattern can be two numbers

With 64 bits there are a fixed number of patterns, and someone has to decide what they mean. Read 0xFFFFFFFFFFFFFFFF as unsigned and it is 18,446,744,073,709,551,615, which is eighteen billion billion and a bit. That is the largest number sixty-four bits can hold, and there is nothing above it. Read the same bits as signed, where the top bit means negative, and it is minus one. Both readings are correct. Neither is stored anywhere.

So the processor offers two families of conditional jump. jl and jg, which you used to build the loop in Step 4, read the flags the signed way. jb and ja read them the unsigned way. Same comparison, same flags, opposite answers. The bits-and-memory course has the full story of how negative numbers are represented.

Why is one pair l and g and the other b and a

Step 4 gave you the naming scheme: j, then a short word for the condition. jl is jump if less, jg is jump if greater, je is jump if equal, jne is jump if not equal. English only has one pair of words for smaller and larger, but the processor needs two, so it borrowed a second pair: below and above for the unsigned reading, less and greater for the signed one. That is the entire difference between jb and jl.

Sticking an e on the end adds "or equal": jge is jump if greater or equal, jbe is jump if below or equal. An n means not: jne, jnz. That is the whole naming scheme, and it means you can read a jump you have never seen before. When you meet jle in Step 9, it is jump if less or equal, signed.

Lab 9 · One comparison, two verdicts
Try this firstLeave -1 vs 1 selected and read the two bottom lines of the panel. jl is taken and jb is not, off the same comparison. Then press 5 vs 3 and watch them agree.
Same two values, both jumps. They are not disagreeing about the bits, which are identical, but about what the bits mean. The pairs where they agree are why this bug survives testing: every value anybody tried was small and positive.
Where this actually bites

A length is unsigned. If a program checks if (i < len - 1) and len is zero, then len - 1 is not minus one, it is the largest number there is, and the check passes when it should fail. Whole classes of security holes are this exact subtraction, and Step 4 of the memory-bugs course is built on it.

Lab 10 · Write the comparison that holds up
Try this firstPress Check against awkward pairs before you write anything. It fails on the second pair and tells you rcx came out 0 where it should be 1. Then fill in the blank line with the same shape you built in Step 4: a conditional jump to a label of your own, and a mov on each path.
Tested with values chosen to catch you. Write a check that answers correctly for negative numbers as well as positive ones. It is run against pairs including the boundary and the pattern that reads as both minus one and the largest number.
A loop is written cmp rcx, 10 then jb top, and rcx starts at minus one. How many times does the body run?
Zero. jb is the unsigned jump, and as an unsigned number minus one is the biggest value a register can hold, which is comfortably not below ten. The loop is skipped entirely. With jl it would have run eleven times. One letter, and the difference between a loop that runs and a loop that does not.
Step 6

Working out an address

To reach the seventh item of an array, something has to work out where the seventh item is. That arithmetic is so common that the processor does it inside the instruction, for free, in one form: base plus index times scale plus displacement.

Why the scale can only be 1, 2, 4 or 8

Because those are the sizes things come in: a byte, a short, an int, and a 64-bit value or a pointer. [rbx + rcx*8] means "the array starts at rbx, take item number rcx, and each item is eight bytes wide". It is exactly what array[i] compiles to.

The displacement on the end, as in [rbp - 8], is for reaching a particular slot at a known distance. Local variables live at fixed distances from rbp, so nearly every access to a local variable in unoptimised code looks like that.

What are .data, .quad and .text doing at the top of the program

The lines beginning with a dot are not instructions. They are directions to the assembler about how to lay the program out, and they are gone by the time anything runs. .data says "what follows is not code, it is values to put in memory before we start". .quad 3, 17, 8, 42, 5 puts those five numbers into memory, eight bytes each, one after the other. .asciz "Hello!", which turns up in Step 11, lays down the letters of a piece of text instead, followed by a zero byte to mark the end. The label in front of either one, nums:, names the address of the first byte, so you can write mov rbx, nums and get that address into a register.

.text says "back to instructions now", and main: is just a label marking where to begin. So the whole header means: five numbers here, a count after them, and the program starts below. Writing [count] reads the value stored at that label, which is how the loop below can find out how long the array is instead of being told five.

Lab 11 · The address calculator
Try this firstDrag the index slider from 2 up to 3. The computed address goes up by eight and the marked row moves down one item. Then press scale 4 and watch the same index land in the middle of an item instead.
Change the parts and watch where it lands. The arithmetic is shown as it is worked out, and the computed address is marked on the memory strip. Set the scale wrong and watch the address land between two items, which is what reading an array with the wrong element size does.
lea computes the address and does not go there

mov rax, [rbx+8] fetches what is at that address. lea rax, [rbx+8] works out the address and puts the address itself in rax, touching no memory at all. Compilers use it for arithmetic that has nothing to do with addresses, because it multiplies and adds in one instruction without disturbing the flags.

Lab 12 · Walk an array and total it
Try this firstPress Run to the end. Three instructions run, rax stays 0, and the five numbers sit untouched in the memory strip. The loop that adds them up is the part you write, on the empty line at the bottom.
Five numbers in memory, and their sum in rax. One loop, one scaled address. The checker also runs it against a longer array, so an answer that only works for five will not pass.
An array of 8-byte values starts at the address in rbx. Which reaches array[3]?
[rbx+24]. Item three is three whole items along, and each is eight bytes wide. [rbx+3] lands three bytes in, in the middle of item zero, and returns a number assembled from the wrong bytes without any complaint. The last option is close but wrong for a different reason: lea would put the address in rax rather than the value.
Step 7

The stack

Sixteen registers is not many, and a program with three nested function calls needs somewhere to put things it will want back later. That place is the stack: a region of memory and one register, rsp, holding the address of its top.

It grows downwards, towards lower addresses. That detail sounds arbitrary and it is the reason a whole category of security holes works the way it does.

Why downwards, and why it matters later

Historically, so that the stack and the program's data could start at opposite ends of memory and grow towards each other, using whatever was free in the middle. The consequence is that push subtracts from rsp and pop adds to it.

And here is the part that matters in a later course: your local variables sit above rsp and grow upwards as you fill them, while the address the function will return to sits above them. So writing past the end of a local array walks straight into the return address. That is a buffer overflow, and the direction of the stack is why it points at exactly the most useful thing to overwrite.

Lab 13 · Push, pop, and watch rsp
Try this firstPress Step four times. The first three put numbers in registers. The fourth is push rax: rsp drops by 8 and 111 appears in the stack strip, on the row marked ← rsp.
Push three things and pop them back. They come back in the opposite order, which is the whole idea, and rsp moves eight bytes each time. Pop one more time than you pushed and watch what you get: not an error, just whatever was underneath.
Lab 14 · Swap two values without a third register
Try this firstPress Check the swap before you write anything. It says nothing was swapped: rax is still 111 and rbx is still 222. Now add two pushes and two pops on the empty line and press it again.
rax and rbx, swapped, using the stack. Two pushes and two pops, in the right order. Reverse the two pops and both values come back exactly as they were; push and pop one at a time instead and you end up with two copies of one value. Both are worth doing once deliberately.
A function pushes rax, then pushes rbx, then does its work, then pops rax and pops rbx before returning. What is wrong?
The values come back swapped. The stack returns things in the opposite order to the one they went in, so the pops have to mirror the pushes: pop rbx first, then rax. The function still runs perfectly and quietly corrupts two registers belonging to whoever called it, which will surface somewhere else entirely.
Step 8

Calling, and coming back

A function call is two problems. Getting there is easy: jump. Getting back is the interesting one, because the function has to return to whoever called it, and it does not know who that was.

call solves it by pushing the address of the next instruction onto the stack before jumping. ret pops that address and jumps to it. The stack is what makes a function callable from two places.

Who puts the arguments where

There is nothing in the hardware about arguments. There is an agreement, and everyone follows it so that code compiled by different people can call each other. The first six arguments go in rdi, rsi, rdx, rcx, r8, r9, in that order, and the return value comes back in rax.

The agreement also splits the registers in two. A function may use rax, rcx, rdx, rsi, rdi and r8 to r11 freely, wrecking whatever was in them, so a caller that wants to keep something in one of those has to save it first. The rest, rbx, rbp and r12 to r15, must come back exactly as they were found, so a function that wants to use one pushes it at the start and pops it at the end. Get that backwards and you have written a function that works perfectly and breaks its caller, which is the hardest kind of bug to find because the symptom is nowhere near the cause.

Why does almost every function start with push rbp

rsp moves. Every push and pop shifts it, so if a function used [rsp+8] to reach one of its own variables, that address would mean something different after the next push. So a function takes a second register, rbp, and parks it: mov rbp, rsp makes rbp a copy of where the stack was when the function started, and then rbp does not move for the rest of the call. Now [rbp-8] means the same slot from the first instruction to the last, which is why the compiler output in Step 1 was full of exactly that.

The push rbp on the line before is the function being polite. rbp belongs to whoever called it and is still holding their copy, so the function saves it, uses it, and pops it back before returning. Those three lines, push rbp, mov rbp rsp, and a matching pop, are the most repeated pattern in all of compiled code. Optimised builds often skip them and go back to counting from rsp, which is one of the reasons optimised code is harder to read.

Lab 15 · Follow a call all the way in and out
Try this firstPress Step twice. The second press runs call square: rsp drops by 8, the return address appears on the stack strip, and the marker jumps down into the function.
Step through, and watch the stack. The return address goes on, the frame goes up out of push rbp and mov rbp, rsp, the work happens, the frame comes off, and ret takes the address back off and jumps to it. Then try the recursive one and watch four return addresses stack up, each waiting for its turn.
Lab 16 · Write a function and call it
Try this firstPress Check with several values before you write anything. It says the function returned 0 where it should return 21. The two lines that fix it go between triple: and ret.
Follow the agreement. Take an argument in rdi and return an answer in rax. You are free to wreck rax, rcx and rdx on the way, so the short answer does not need to save anything, but the checker also loads rbx with a number of its own and looks at it afterwards, because rbx is one of the registers a function has to hand back untouched.
A function writes 40 bytes into a 16-byte local array, then returns. The program crashes at the ret, not at the write. Why there?
The write walked into the return address. Locals sit near the top of the stack and the return address sits just above them, so writing past the end of a local array overwrites it. The write itself is perfectly legal memory access and raises nothing. The program only dies at the ret, when it jumps to whatever those bytes happened to spell. This is a buffer overflow, and it is a whole course of its own.
Step 9

Reading what a compiler wrote

You will read far more assembly than you write, and almost all of it will be a compiler's. A compiler can be asked to work harder. Left alone it does the obvious thing, translating each piece of your code in turn and not looking for shortcuts, which is called an unoptimised build: long-winded, slow to run, and easy to follow line by line. Asked to optimise, it goes back over its own output looking for work that can be removed without changing the answer, and what comes out is short, fast, and rearranged past the point of recognition. Both are the same program. Programs are built unoptimised while they are being written and optimised when they are shipped, which is why the thing you debug and the thing your users run are rarely the same instructions.

Why optimised code is so hard to follow

An optimiser is allowed to do anything that does not change the answer. It keeps variables in registers instead of memory, so the loads and stores vanish. It reorders instructions that do not depend on each other. It unrolls loops, computes constants in advance, and deletes variables that turn out not to matter.

Which is why debugging optimised code is unpleasant: a variable you want to print may not exist anywhere, and stepping jumps between lines in an order the source does not have. This is exactly why real programs are built twice, once for debugging and once to ship.

Lab 17 · Match the instructions to the source
Try this firstPoint at the second line of C, the for line. The instructions it became light up on the right, in two separate places rather than one block: the setup and the test at the top, the increment and the jump back at the bottom. Then press Optimised and point at the same line again.
Point at a line of C and watch its instructions light up, then point at an instruction and watch its line of C light up. Go through the unoptimised version first and check that each block does what you would expect. Then press Optimised and try the same thing on the same function: the multiply has become part of an address calculation, the loop counter now lives in a register and is never written to memory at all, and two lines of C have collapsed into one instruction. If an instruction surprises you, point at it and see which line of C is being blamed.
Lab 18 · The same function, two ways
Try this firstPress Run both and count. Two lines appear with the same answer and two different instruction counts, and a third line says how many times as much work the unoptimised one did.
Both versions, running. Same answer, and count the instructions each one takes to get there. The optimised version is not doing anything clever in any single instruction: it is just not doing the pointless work. The two tabs load each version into the editor, so you can step either one and watch where the difference actually comes from.
In optimised output you see lea rax, [rdi+rdi*4] where the C said x * 5. Why would a compiler do that?
One instruction, no memory, no flags disturbed. lea exists to work out addresses, and address arithmetic happens to be "add these, scale that", which is exactly a small multiplication. Compilers use it for arithmetic constantly. Recognising it is most of learning to read optimised output, because it looks like a memory access and is not.
Step 10

Break, step, print

This is the step that outlasts the rest of the course. Languages come and go; the ability to stop a running program and interrogate it does not. A debugger does four things: stop somewhere, run one step, show you a value, and keep going.

The commands are short because you type them constantly. break to stop somewhere, step for one instruction, print for a value, continue to carry on.

How a debugger stops a program without breaking it

It replaces the instruction at your breakpoint with one that hands control back, remembering what was there. When you continue, it puts the real instruction back, runs it, and restores the trap. The program has no idea.

That is why a breakpoint costs nothing while it is not being hit, and why a debugger can attach to a program that is already running. The one here works differently inside, because it is a simulator and can simply choose not to take the next step, but the commands and what they mean are the real ones.

Lab 19 · The debugger
Try this firstPress the break 12 button, then run, then print rax. It stops the first time it reaches line 12, which is the line inside the loop that does the adding, and prints the total so far, which is still 0 because that line has not run yet.
Type help to see everything it takes. After that first print rax, press continue, then print rax again, and keep going: the same breakpoint catches every pass and you watch the total build up one number at a time. Then try info registers to see them all at once, info flags for the four flags, and x/6 nums to look at the six numbers it is adding. These are the real commands, spelled the way a real debugger spells them.
You set a breakpoint at line 6, inside a loop that runs a hundred times, and press continue. Where does it stop?
Every pass. A breakpoint is a place, not an event, so it triggers every time execution reaches it. That is useful for watching a value change and maddening on the ninety-ninth pass, which is why real debuggers let you attach a condition, and why the watchpoint in the next step is often the better tool.
Step 11

Catching the moment it changes

The hardest bugs are not "this line is wrong". They are "something, somewhere, is changing this value, and I have no idea what". A breakpoint cannot help, because you do not know where to put it.

A watchpoint inverts the question. Instead of naming a place, you name a value, and the machine stops the instant anything changes it.

Another way to see the difference

A breakpoint is a trap on a doorway: you know where the thief will walk and you wait there. A watchpoint is an alarm on the jewels: you have no idea who is coming or from where, but you will know the moment they are touched.

Which is why a watchpoint is the right tool for corruption. When a value that should be 10 has become 1094795585, you do not know which instruction did it, and that is the one thing a watchpoint tells you directly.

Lab 20 · Set a watchpoint and catch the culprit
Try this firstPress the watch [total] button, then run. It stops with a line saying total changed from 0 to 7, and names the instruction that wrote it. Then press print rcx.
A value is being changed by something that has no business changing it. Watch the address, run, and the machine names the exact instruction that did it and shows you the old and new values. It cannot stop the write from happening, so it stops on the instruction after it, which is what a real debugger does too: the line it reports as "now at" is one further on than the line that is to blame. Then look at where the write came from, which is not near the code you would have suspected.
Lab 21 · Read memory as different things
Try this firstPress "as characters" while "the text" is still selected. The bytes turn into the greeting stored there, followed by a dot standing for the zero byte on the end. Then press "the numbers" and watch 65, 66 and 67 come out as the letters A, B and C.
The same bytes, several readings. One region of memory shown as numbers, as characters, as eight-byte values and as raw bytes. Nothing in memory says which it is: the reading is your decision, and choosing wrong is how a program prints nonsense while working perfectly. The program behind this one puts both there with two directives: .asciz lays down the letters of a piece of text with a zero byte on the end, and .quad lays down 8-byte numbers. Once they are in memory they are only bytes, which is the point of the lab.
A counter that should be 10 contains 1094795585. That number is 0x41414141. What does that strongly suggest?
Text was written over it. 0x41 is capital A, so those four bytes are "AAAA": something copied a string past its end and over the counter. Recognising bytes on sight is a real debugging skill, and this pattern in particular has been the fingerprint of a buffer overflow for about forty years.
Step 12

Find the bug

Everything up to here has been in service of this. A program that gives the wrong answer, no explanation, and a machine you can stop wherever you like. That is the job. It is the same job whether the program is twenty instructions or twenty million.

How to look, when you have no idea where to look

Halve it. Find a place where the state is definitely still right and a place where it is definitely already wrong, then look in the middle. Two or three rounds of that will land you on the instruction even in a large program, and it needs no cleverness at all, only a way to inspect state.

The mistake to avoid is reading the code hoping to spot it. Reading finds bugs you would not have written; it does not find the bug you did write, because you already believed that code was correct once. Stop the machine and ask it.

Lab 22 · Three broken programs
Try this firstPress Run to the end. rax comes out 45 where it should be 55. Then press Reset and Step your way round the loop, watching rcx and the four flags on each press.
Each one has exactly one wrong instruction. You get the expected answer and the answer it produces. Press Step to run one instruction at a time and watch the registers and the four flags change underneath. That is the same skill as the last two steps with fewer buttons: instead of setting a breakpoint you step until you reach the interesting part, and instead of printing a register you read it off the panel. When you think you have it, fix the instruction and press Check the fix. The third one is the one where reading the code will not save you: step it and watch ZF rather than the registers.
Lab 23 · Scratch pad
Try this firstPress Count set bits, then Run to the end. rax comes out 5, which is how many 1s there are in the number it started with. Then change the number and run it again.
Nothing is marked here. The machine, a blank editor, and four programs you can load and pull apart. Write something that does not work and then find out why, which is the only way any of this ever sticks.
A program works with an input of 5 and fails with an input of 1000. Which is the most useful first move?
Find the boundary, then look there. The gap between working and failing is the most informative thing you have: it usually names the cause on its own, whether that is a size limit, an overflow, or a loop running one time too many. Reading the code comes later, when you know which four instructions to read.

What you can do now

  • Read assembly and work out what it does, including a compiler's.
  • Write it: registers, memory, comparisons, loops, functions that follow the agreement.
  • Tell signed from unsigned, and know which comparison to use.
  • Explain what the stack is doing and why overwriting a local array is dangerous.
  • Use a debugger properly: break, step, print, examine, watch.
  • Find a bug by halving the problem rather than by staring at it.

Where this goes

  • Defuse the Binary Bomb. A program that refuses to run unless you type the right secret, with no source. Six stages, a disassembly, and the debugger you just learned. Everything you need is now in place.
  • Memory Exploits. The buffer overflow from Step 8, properly: why writing past an array reaches the return address, and every defence that was invented to stop it.
  • Build-a-Unix. What happens when an instruction asks the operating system for something, and how one machine runs many programs that each believe they are alone.
Step 13

Bytes behind an instruction

The processor never sees the words add rax, rbx. An assembler turns that line into 48 01 d8, three bytes that select an operation, the 64-bit register size, and the two registers. A disassembler performs the reverse job. It reads bytes and prints the instruction they most likely encode.

x86-64 instructions are not all the same length. An instruction can contain a size prefix, an opcode, a byte that names registers or an addressing form, an optional scale-and-index byte, a displacement, and an immediate value. Most instructions use only some of those fields. This density saves space, but it also means that starting one byte late can produce a completely different disassembly.

Opcode bytes, data bytes and little-endian order

The instruction stream is read from low address to high address. Opcode and prefix bytes stay in that order. A number occupying several bytes, such as the 1 in mov eax, 1, is stored little-endian: least significant byte first. That instruction is b8 01 00 00 00. Endianness rearranges the bytes of the number; it does not reverse the whole instruction.

Intel's complete decoding tables are large because old and new instruction families share the same byte space. You do not memorise them. Use objdump, llvm-objdump or a debugger, then check the bytes when instruction boundaries or patches matter.

Lab 24 · Take an instruction apart
Try this firstChoose mov eax, 1. Find the four little-endian bytes that hold the immediate value, then press Start one byte late.
The coloured roles are a decoding aid, not a universal template. Some instructions need no register byte; others need a SIB byte and a displacement. Starting at the wrong byte changes every boundary after it until the decoder happens to line up again.
Why is the number 1 written as 01 00 00 00 inside b8 01 00 00 00?
It is the immediate, not the instruction, that is little-endian. The opcode remains first so the decoder knows what the following bytes mean.
Step 15

Calling convention rules

Step 8 used the first two argument registers. The complete System V x86-64 convention used on Linux and macOS puts the first six integer or pointer arguments in rdi, rsi, rdx, rcx, r8 and r9. Later arguments go on the stack. Integer results come back in rax. Floating-point and vector arguments use a separate set of registers.

The caller may assume that rbx, rbp and r12 through r15 survive a call. A function that changes one of them must save and restore it. Other general registers are caller-saved. The caller must also align the stack to a 16-byte boundary before call. Windows x64 follows a different agreement, which is why handwritten assembly must name its target platform.

Arguments that do not fit the simple rule

Structures, vectors, variable-argument functions and large return values need extra classification rules. A large returned structure is often written through a hidden pointer supplied by the caller. C++ exceptions and stack backtraces also depend on unwind tables that describe how to recover saved registers at each instruction. These are ABI rules, not new processor instructions.

When debugging unfamiliar code, read the platform ABI rather than guessing. The register list here is the common integer path, deliberately small enough to trace by hand.

Lab 26 · Lay out a function call
Try this firstSet the argument count to eight. Find which two values move to the stack, then choose a register for the callee to change.
The convention is a contract between separate pieces of code. The hardware does not enforce it. A function that changes a callee-saved register without restoring it can return normally and still break its caller several instructions later.
A function wants to use r12. What must it do under the System V x86-64 ABI?
r12 belongs to the caller across the call. The callee may borrow it, but must hand back the original value.
Step 16

Crossing into the operating system

Ordinary instructions run in user mode. They cannot map arbitrary memory, talk directly to a disk controller or change another process. To request protected work, a program loads a system-call number and arguments into agreed registers and executes syscall. The processor switches privilege, enters a checked kernel entry point, and later returns to user mode.

On Linux x86-64, the system-call number goes in rax; the first six arguments use rdi, rsi, rdx, r10, r8 and r9. Notice that the fourth register differs from an ordinary function call. A raw failure is returned as a negative error number. A C library wrapper usually turns that into -1 and records the reason in errno.

System calls, interrupts, exceptions and signals

A system call is an intentional request from a program. A hardware interrupt is an asynchronous event from a device or timer. A processor exception is caused by the current instruction, such as a page fault or division by zero. A signal is an operating-system notification delivered to a process. They can share low-level entry machinery, but they are different events and should be named separately while debugging.

Operating Systems follows the kernel side of this boundary, including scheduling, virtual memory, files and device I/O.

Lab 27 · Trace a write system call
Try this firstRun the valid write. Step through user setup, privilege entry, validation, device work and return. Then choose a bad descriptor.
The kernel validates the request before using it. A file descriptor can be invalid and a user pointer can refer to an unmapped page. A system-call boundary is therefore an API, a security boundary and a debugging boundary at once.
Why can user code not perform protected device operations with an ordinary mov?
Privilege is enforced by hardware. The system-call path changes privilege only at a controlled entry point where the kernel can validate the request.
Step 17

When the source and machine disagree

Optimisation preserves the program's allowed behaviour, not the shape of its source. A variable can live only briefly in a register, be folded into a constant, or disappear. A small function can be inlined into its caller. Instructions from different source lines can be interleaved, and the frame pointer may be omitted. That is why a debugger sometimes says a variable is “optimised out” or appears to jump between lines.

Build with symbols first. -g -Og is a useful compromise while investigating: it keeps debug information and applies optimisations that usually leave a readable result. If the fault only occurs in the shipped -O2 build, debug that exact binary too. Keep its symbols, capture a core dump or record a trace, and identify the loaded module and address before trusting a source line.

Threads change the debugging method

A breakpoint may stop one thread or all of them, depending on the debugger. Either choice changes timing, so a race can vanish. Inspect thread lists and lock ownership, but use ThreadSanitizer, deterministic record/replay or event tracing when the failure depends on an unlucky schedule. A watchpoint is still useful, but most processors provide only a few hardware watchpoint slots.

Continue with Concurrent Data Structures for memory ordering and reclamation, and Performance Engineering for profiles and hardware counters.

Lab 28 · Debug two builds of one function
Try this firstSwitch from -O0 to -O2. Compare the source mapping, visible variables, frames and instruction count.
“Optimised out” does not mean the debugger lost a stored value. It often means no such stored value exists at that point. Inspect the instructions and live registers, or stop earlier while the value still exists.
A crash appears only in the release build. Which binary should you investigate?
Debug the program that failed. A simpler build can help form a hypothesis, but it may not reproduce the instruction order, timing or undefined behaviour that exposed the fault.
Step 18

Use tools as evidence, not answers

Current debuggers sit beside decompilers, sanitizers, fuzzers, symbolic execution, record/replay and assistants that explain a trace or suggest a likely fix. These tools can shorten the search. They cannot decide that a guessed variable name, inferred type or reconstructed loop is true. Stripped machine code does not contain most of that source-level information.

Keep the engineering loop explicit: reproduce the failure, reduce it, record the first bad state, form a hypothesis, try to disprove it, make the smallest fix, and add a regression test. A tool suggestion is a hypothesis. Register values, memory bytes, instruction order, a minimized input and a repeatable test are evidence.

Which tool answers which question

Use a debugger for control flow and state at a known moment; a sanitizer for invalid memory, undefined arithmetic or races; a fuzzer for inputs you did not think of; a profiler for where time is spent; record/replay for a rare execution you need to revisit; and a decompiler for a readable starting hypothesis when source is missing. Use more than one when the cost of being wrong is high.

Practise the source-free path in Defuse the Bomb, the security consequences in Memory Exploits, and failure reporting and recovery in Systems That Fail Well.

Lab 29 · Turn a suggestion into a test
Try this firstSelect “The loop is off by one.” Mark which observations support it, then ask the lab for the next experiment.
A neat explanation is not a measurement. The useful next action is the one that makes the hypothesis risk being wrong. If both possible outcomes would leave you believing the same thing, it was not a test.
A decompiler invents the name password_ok for a register value. What is safe to conclude?
Names and types are reconstructed guesses unless debug information supplies them. Treat them as navigation hints and verify the machine-level facts.

What you can do now

  • Read and write the common integer path through x86-64 assembly.
  • Follow instruction bytes through assembly, linking, loading and execution.
  • Trace ordinary calls and system calls using the correct platform contracts.
  • Debug source, optimised binaries and source-free programs without confusing a tool's guess for fact.
  • Choose breakpoints, watchpoints, sanitizers, traces, fuzzers and regression tests by the question each answers.