Machine Instructions and Registers
The processor does not execute C
Section titled “The processor does not execute C”The virtual-memory lesson had you listing mappings marked r-xp, where the x means the processor is allowed to execute whatever bytes are sitting in those pages. Nothing so far has said what those bytes are, and they certainly aren’t C.
A processor has circuits that recognize and carry out a defined instruction set: operations such as copying a value, adding two values, or continuing execution somewhere else. Each instruction is encoded as a pattern of bits that identifies the operation and the operands it uses. There’s no stage where the processor reads if (value > 10) and works out what the C programmer intended. The program has to be translated into instructions the processor understands.
The compiler and assembler do that translation. The compiler translates C into machine-level operations, and the assembler encodes assembly instructions as machine-code bytes. A compiler toolchain can perform these stages without leaving a separate assembly file for you to read. The linker combines the resulting code and other pieces into the executable we inspected earlier.
Assembly language gives those instructions readable names. add, for example, names an addition operation. The processor doesn’t execute the letters a, d, and d; it executes the instruction encoded by the bytes. Disassembly decodes those bytes back into assembly text. It can show the operations, but it won’t restore the original comments or the programmer’s intent. Debugging information can supply source names separately.
We’ll look at the bytes and their assembly interpretation side by side. First, build a small program so we have something concrete to inspect.
Create decision.c:
#include <stdio.h>
int classify(int value) { if (value > 10) { return value + 2; } return value - 2;}
int main(void) { printf("%d\n", classify(12)); return 0;}Compile it with the optimizer switched off, which keeps the output a lot easier to follow:
gcc -O0 -g -o decision decision.cobjdump -d -Mintel decision | lessSearch inside less by typing /<classify> and pressing Enter, then press q when you’re done.
Read one instruction at a time
Section titled “Read one instruction at a time”An instruction line holds an address, the encoded bytes, a mnemonic, and its operands. In Intel syntax, the common instructions include:
| Instruction | Useful first interpretation |
|---|---|
mov destination, source |
Copy a value |
add destination, source |
Add into the destination |
sub destination, source |
Subtract from the destination |
cmp left, right |
Compare by setting processor flags |
jmp target |
Continue at another instruction |
jle, jg, and relatives |
Branch when a condition represented by flags is true |
call target |
Transfer control to a function while preserving a return location |
ret |
Return to the saved location |
Registers hold the values instructions work on
Section titled “Registers hold the values instructions work on”A register is a small, fixed-width storage location inside the processor. The processor uses registers to hold values it is working with: a number being added, an address used to reach memory, or information about where execution should continue. Registers hold bits just as memory does, but they are separate from the memory locations we’ve been examining. An instruction selects a register by its encoded register identifier, rather than by a virtual-memory address.
Names such as rax, rbp, rsp, and rdi are the assembly names for registers on x86-64. rax names a 64-bit register, enough to hold eight bytes. The name stays the same when its contents change, just as a C variable’s name stays the same after an assignment. Unlike C variables, these register names come from the processor architecture. The compiler has a limited set to work with and can reuse one register for several different values over a function’s execution.
Suppose eax contains 12. The instruction add eax,2 reads that value, adds two, and writes 14 back into eax. It changes the contents of the register, not its name, and it doesn’t write the result into a C variable’s memory slot unless another instruction does that. If eax holds an address instead, it’s still holding bits: the surrounding instructions determine how those bits are used.
Some names select smaller portions of the same register. eax is the low 32 bits of rax, so these aren’t two independent boxes. Updating eax also changes part of rax:
| 64 bits | low 32 | low 16 | low 8 |
|---|---|---|---|
rax |
eax |
ax |
al |
rdi |
edi |
di |
dil |
Writing eax also clears the upper 32 bits of rax; writing ax or al leaves the other bits alone. So writing 14 into eax leaves the full rax holding 14 too. These are x86-64 rules, not properties of every processor. Intel’s architecture manuals describe the register views in Volume 1 and instruction encodings in Volume 2.
Before continuing, suppose eax starts at 12 and add eax,2 executes twice. Predict its final contents and whether the register’s name or width changes.
Check the register trace
The contents go from 12 to 14 to 16. eax still names the same 32-bit portion of rax. The instruction bytes can stay unchanged while the data they operate on changes each time.
if (value > 10) return value + 2;edi = 12cmp [rbp-4], 10sets flagsjle lowernot takeneax = 14Operand roles and memory syntax
Section titled “Operand roles and memory syntax”Intel syntax puts the destination first. An operand can be an immediate constant, a register, or memory in brackets. So mov eax,10 loads a constant, mov eax,DWORD PTR [rbp-4] reads four bytes, and mov DWORD PTR [rbp-4],edi writes four bytes back. The BYTE, WORD, DWORD, and QWORD PTR markers mean 1, 2, 4, and 8-byte accesses.
Don’t translate every mov into “assignment”, however tempting that is. Compilers use moves for arguments, for temporary values, for saved state, and for plain memory access. Follow where the value came from and where it ends up instead.
Assembly, bytes, and bits describe the same instruction
Section titled “Assembly, bytes, and bits describe the same instruction”Here’s one instruction we’ll meet in classify. In Intel syntax, add eax,2 means add the constant two to the value in eax. One x86-64 encoding is:
| Representation | The instruction |
|---|---|
| Assembly text | add eax, 2 |
| Machine-code bytes, written in hexadecimal | 83 c0 02 |
| The same bytes, written in binary | 10000011 11000000 00000010 |
These aren’t three successive versions that the processor has to translate. The last two rows show the same 24 bits, grouped into three eight-bit bytes. Hexadecimal is a compact way for us to write those bits: each hex digit represents four bits, so two digits represent one byte. 83 here means hexadecimal 0x83, which is decimal 131 and binary 10000011. Changing the notation doesn’t change the stored byte.
Together, 83 c0 select this form of addition and its register operand, eax; 02 supplies the constant two. Don’t read 83 as a universal number meaning “add.” x86-64 encodings use fields spread across bytes, and an operation can have several encodings depending on its operands. Instructions can occupy different numbers of bytes.
The CPU fetches the instruction bits from executable memory and decodes them according to those rules. The 12 currently in eax is separate data, not part of 83 c0 02. Executing the instruction uses that current value and replaces it with 14. An assembler converts the readable instruction into an encoding; objdump decodes an encoding and prints both the bytes and readable text for us.
In the disassembly below, find 83 c0 02 beside add eax,0x2. The 0x2 is the same constant as decimal 2. The address at the start of that line tells you where these three bytes belong; it isn’t another byte of the instruction.
Flags and signed branches
Section titled “Flags and signed branches”cmp left,right computes left - right purely for the flags, and it never stores the result anywhere. Zero (ZF), sign (SF), carry (CF), and overflow (OF) are what support the decisions that follow.
| Meaning | Signed | Unsigned |
|---|---|---|
| greater | jg |
ja |
| greater or equal | jge |
jae |
| less | jl |
jb |
| less or equal | jle |
jbe |
Choose the signedness from the type or from the surrounding use, and probably not from the general vibe of the branch.
Read the real disassembly of classify
Section titled “Read the real disassembly of classify”Run the command above and find the classify block. On the course VM, with GCC 13 and binutils 2.42, it looks like this. Your addresses and instruction sequence may differ with the compiler version, build options, and linker. objdump reads the executable file, so the addresses in this listing aren’t a measurement of where Linux loaded a running process:
0000000000001149 <classify>: 1149: f3 0f 1e fa endbr64 114d: 55 push rbp 114e: 48 89 e5 mov rbp,rsp 1151: 89 7d fc mov DWORD PTR [rbp-0x4],edi 1154: 83 7d fc 0a cmp DWORD PTR [rbp-0x4],0xa 1158: 7e 08 jle 1162 <classify+0x19> 115a: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 115d: 83 c0 02 add eax,0x2 1160: eb 06 jmp 1168 <classify+0x1f> 1162: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 1165: 83 e8 02 sub eax,0x2 1168: 5d pop rbp 1169: c3 retEach line has four parts: the address on the left, the encoded bytes the processor actually runs, the mnemonic, and its operands. A jump like jle 1162 <classify+0x19> names its destination as both an address and an offset into the function, and that’s how you follow a branch by eye. The constants are written in hexadecimal, so 0xa is 10 and 0x2 is 2.
The opening instructions are setup you can skim past for now. endbr64 is a control-flow-integrity marker sitting at the entry, and the push rbp and mov rbp,rsp pair establishes the function’s stack frame, which the next lesson takes apart properly. I’d skim them today and come back. After that setup, follow the comparison, branch, and arithmetic; the instructions at the end restore the frame and return.
Now trace classify(12) one instruction at a time, and watch where that value goes:
ediarrives holding the argument,12.mov DWORD PTR [rbp-0x4],edicopies that argument into the stack slot the function uses forvalue.cmp DWORD PTR [rbp-0x4],0xacomparesvaluewith 10 and sets the processor flags for12 - 10.jle 1162jumps to the subtract path only ifvalueis less than or equal to 10. Herevalueis greater, so the processor does not jump and continues to the next line.mov eax,DWORD PTR [rbp-0x4]loadsvalueintoeax, andadd eax,0x2turns it into 14.jmp 1168skips over the subtract path,pop rbptears down the frame, andretreturns with 14 left ineax.
Now trace classify(8) and classify(10) for yourself. Both of them are less than or equal to 10, so both take the jle branch to 1162 and return value - 2. The value 10 is worth checking deliberately, because jle includes equality, so 10 follows the lower-or-equal path rather than the add path.
The objdump manual documents the disassembly and syntax options. Check the syntax option before interpreting operand order.
Change one value
Section titled “Change one value”Change the call from classify(12) to classify(8), predict the output, and then recompile and re-inspect just that one function:
gcc -O0 -g -o decision decision.c./decisionobjdump -d -Mintel --disassemble=classify decision--disassemble=classify prints only that one function rather than the entire file. The printed result changes, and the instructions of classify don’t. You changed the data flowing into the function and not the function itself, and telling those two apart is most of what reading disassembly is for.
Addresses and values are different jobs
Section titled “Addresses and values are different jobs”The number printed at the left of an instruction is where that instruction lives. An operand may itself be an immediate value, a register, or a memory address, and it isn’t an instruction address. Treating every hexadecimal number on the screen as “the address” is a very reliable way to get lost.
When you take notes, label the numbers by role: instruction address, branch target, constant, register value, or memory address. Those labels are conclusions supported by context, and they aren’t decoration on your notes.
Extend the decision (optional stretch)
Section titled “Extend the decision (optional stretch)”For a harder target, rewrite classify so it returns a different value for negative, zero, and positive inputs. Predict the comparisons and branches you expect to see, compile with the same -O0 -g flags, and then trace -1, 0, and 1 through the new disassembly. Do all of that in a separate copy of the source, so your baseline decision.c survives for comparison.
Check your result
Section titled “Check your result”Compare the classify(8) run with the branch you followed in objdump. The comparison and conditional jump should explain the printed result. If they disagree, check that the listing and executable came from the same rebuild.
Common failures and recovery
Section titled “Common failures and recovery”Restore a known state with the pinned build and listing commands, and then work through the likely causes in order:
- A missing
classifysymbol: check the binary and build options. Optimization or stripping can remove the name; omitting-galone doesn’t necessarily remove it. Rebuild the lesson source withgcc -O0 -g. - Instructions that vanished or merged together are the optimizer at work, so confirm you compiled with
-O0. - Operands that look reversed mean the syntax option differs, so confirm the command includes
-Mintel, which puts the destination first. - Stale constants after an edit mean you’re reading the old binary, so remove it and rebuild before you read again.
What this lets us investigate
Section titled “What this lets us investigate”You can now reduce a small assembly function to data movement, arithmetic, a comparison, and one control-flow choice. Next we’ll look at what call and ret actually require from the stack.
Check your understanding
Section titled “Check your understanding”Knowledge check
Question 1 of 2
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.