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.
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.
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
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.
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.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.
total = total + price;. You press step once. Which is true?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.
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.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.
rax holds 0xFFFFFFFFFFFFFFFF, every bit set. You run
mov eax, 0. What does rax hold afterwards?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.
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.
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.
cmp rax, rbx, then add rcx, 1, then
je equal. The jump behaves unpredictably. Why?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.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.
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.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.
-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.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.
mov on each path.cmp rcx, 10 then jb top, and rcx starts at
minus one. How many times does the body run?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.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.
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.
rbx. Which reaches
array[3]?[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.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.
push rax: rsp drops by 8 and 111 appears in the
stack strip, on the row marked ← rsp.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.
call square: rsp drops by 8, the return address appears on the stack strip, and the marker
jumps down into the function.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.triple: and ret.ret, not at the write. Why there?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.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.
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.lea rax, [rdi+rdi*4] where the C said
x * 5. Why would a compiler do that?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.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.
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.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.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.
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..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.0x41414141.
What does that strongly suggest?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.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.
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.
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.
mov eax, 1. Find
the four little-endian bytes that hold the immediate value, then press Start one byte late.01 00 00 00 inside
b8 01 00 00 00?From object files to a running program
An assembler usually does not produce a complete program. It produces an object file containing code,
data, a symbol table and relocation records. A symbol is a name such as sum. A relocation is
a note saying, in effect, “put the final address of sum into these bytes once somebody knows
where it will be.”
The linker combines object files, resolves their symbols and lays out an executable. At launch, the operating-system loader maps its segments into virtual memory. Shared libraries can be connected by the dynamic linker then or on first use. Debug information such as DWARF maps instructions back to source lines and types; it can live in a separate file and is not needed for the processor to run the program.
Static linking, dynamic linking, PIE and ASLR
Static linking copies the needed library code into the executable. Dynamic linking leaves references for a shared library, which reduces duplication and lets several programs share read-only pages. Version mismatches are the trade-off. Position-independent executables use relative addressing so the loader can place them at different addresses. ASLR uses that freedom to make addresses less predictable.
The full compiler pipeline is covered in Language Engineering. Page mappings, loaders and shared libraries continue in Operating Systems.
sum.o and link.
Read the unresolved symbol, restore the file, then compare static and dynamic library modes.main.o may promise that a
function named sum exists, but one input still has to provide its code. A dynamic library
reference can remain to be resolved at load time; an ordinary missing project function cannot.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.
r12. What must it do under the System V x86-64 ABI?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.
mov?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.
-O0 to
-O2. Compare the source mapping, visible variables, frames and instruction count.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.
password_ok for a register value. What is safe
to conclude?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.