Skip to content
Courses

Machine Instructions and Registers

How Programs WorkLesson 4 of 7105 minutesLessonBuilds on: Executable Mapping LabNo lab next

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:

Terminal window
gcc -O0 -g -o decision decision.c
objdump -d -Mintel decision | less

Search inside less by typing /<classify> and pressing Enter, then press q when you’re done.

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.

A comparison becomes data movement, flag-setting, a conditional branch, and a return value
if (value > 10) return value + 2;
Input registeredi = 12
Comparecmp [rbp-4], 10sets flags
Signed branchjle lowernot taken
Return registereax = 14

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.

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.

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 ret

Each 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:

  • edi arrives holding the argument, 12.
  • mov DWORD PTR [rbp-0x4],edi copies that argument into the stack slot the function uses for value.
  • cmp DWORD PTR [rbp-0x4],0xa compares value with 10 and sets the processor flags for 12 - 10.
  • jle 1162 jumps to the subtract path only if value is less than or equal to 10. Here value is greater, so the processor does not jump and continues to the next line.
  • mov eax,DWORD PTR [rbp-0x4] loads value into eax, and add eax,0x2 turns it into 14.
  • jmp 1168 skips over the subtract path, pop rbp tears down the frame, and ret returns with 14 left in eax.

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 the call from classify(12) to classify(8), predict the output, and then recompile and re-inspect just that one function:

Terminal window
gcc -O0 -g -o decision decision.c
./decision
objdump -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.

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.

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.

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.

Restore a known state with the pinned build and listing commands, and then work through the likely causes in order:

  • A missing classify symbol: check the binary and build options. Optimization or stripping can remove the name; omitting -g alone doesn’t necessarily remove it. Rebuild the lesson source with gcc -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.

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.

Knowledge check

Question 1 of 2

In Intel syntax, mov eax, DWORD PTR [rbp-4] does what?