Keyboard Shortcuts

Ctrl+K
Open search
Esc
Close search / dialog
↑
Back to top

Reading a Disassembly Without Being Fooled

24 minadvancedModule 1: The Surface: What the Instructions Are
24 min · x86-64 Assembly and Encoding

Why this matters

Every claim in the next four concepts is quoted out of a disassembly, and the single most common way to be wrong about one is to read two of them written in two different syntaxes and assume they are the same instruction. They are not, and the difference is one line of code that no tool will warn you about.

$ as -o /dev/null <<'EOF' && objdump -d -M intel /dev/null
.text
x:  addps %xmm1, %xmm0
EOF

  0:   0f 58 c1    addps xmm0,xmm1
  

Those are the same three bytes. The first line is AT&T syntax, the second is Intel syntax, and between them the operands are reversed. Read the AT&T line as “add xmm0 by xmm1, and put the result in xmm0” and you have it right. Read the Intel line as “add xmm0 by xmm1” and you have computed the wrong thing, in a language where addps xmm0, xmm1 really does mean xmm0 = xmm0 + xmm1.

It is worse than a confusion, because the two syntaxes are both correct and a document that mixes them is a document that is wrong in a way that disassembles. The trap is not exotic either: a search for “x86 lea example” returns AT&T lines, a search for “x86 movaps example” returns Intel lines, and they land in the same editor.

The model: two syntaxes, one rule each, and the two prefixes that are not syntax

There is a single rule that generates both syntaxes, and once you have it the two are not two things to remember but one thing seen from two sides.

AT&T (the assembler's default, and what `as` accepts) source first, DESTINATION LAST. sub %eax, %ebx means ebx = ebx - eax. Intel (what objdump -M intel prints, and what most documentation uses) destination first. sub ebx, eax means ebx = ebx - eax. THE MEMORY OPERAND IS THE EXTRA BIT OF CONFUSION: mov 8(%rbx), %eax AT&T: load from rbx+8 into eax mov eax, [rbx+8] Intel [rbx+8] is a MEMORY reference and 8(%rbx) is ALSO a memory reference -- neither of them is an immediate. A number with no $ and no brackets, in AT&T, is an immediate. That is the whole of the immediate syntax.

Which one is the default flipped for, and it is a toolchain decision rather than an architectural one, which is the point worth keeping. GNU as is AT&T. GNU objdump is Intel. So the assembler and the disassembler that ship in the same binutils tarball speak different languages about the same bytes, and that is why a beginner's first disassembly looks like the assembler has been tampered with. It has not; you are looking at the same three bytes printed by a program that is trying to be helpful to a different audience.

Two prefixes that get mistaken for syntax, and are not:

$ echo '.text
x: lock addq $1, %rax' > /tmp/q.s && as -o /tmp/q.o /tmp/q.s
/tmp/q.s: Error: expecting lockable instruction after `lock'

$ echo '.text
x: lock addq $1, (%rax)' > /tmp/q.s && as -o /tmp/q.o /tmp/q.s && objdump -d /tmp/q.o
   0:	f0 48 83 00 01    lock add QWORD PTR [rax],0x1
  

The error names the prefix rather than the reason, and the reason is one clause long: LOCK is defined only for a memory destination, because it makes the read-modify-write atomic in memory and a register has no memory to be atomic about. The message costs the reader a trip to the manual. The bytes are in the corpus for exactly this reason — int_lock_add and int_lock_add_plain are one f0 apart and both are in corpus.s.

The reality: the corpus, and the twenty-six instructions gcc chose for itself

The course ships two corpora. One is hand-written: corpus.s, 52 instructions, every one of them chosen because some claim in these pages depends on its encoding. The other is a compiler's: corpus.c, compiled at -O2 and disassembled by the build script. A claim about what a compiler did is a claim about bytes, and the only way to check one is to read them.

$ sed -n '5,20p' corpus_c.txt
shift_left       | 48 89 fa 8b f0 89 d6  | mov    rdx,rdi
                 | c1 e2 08             | shl    edx,0x8
                 | 39 f7                | cmp    esi,edi
shift_by_one     | 48 d1 e2             | shl    rdx,1
pick             | 4c 0f 4c e1 0f      | cmovl  r12,r13
bump             | 0f b6 00 48 0f be 00 | movzx  rax,BYTE PTR [rax]
                 | 48 83 c0 01          | add    rax,0x1
                 | 88 00                | mov    BYTE PTR [rax],al
  

Four things in eight lines that a reader of the C would not have predicted, and every one of them is a fact about the encoding rather than about the algorithm:

Worked: reading one instruction all the way down

Take the row that appears in every disassembly of every compiled program ever written, and read it byte by byte. This is sub %ebx, %eax, which the artifact's corpus records as int_sub with the bytes 29 d8.

  29        the opcode.  It is in the ONE-BYTE map, and
            0x28-0x2d are the SUB group: 28 = r/m8,r8
            29 = r/m32,r32   2a = r8,r/m8   2b = r8,r/m32
            2c = AL,imm8     2d = eAX,imm32
            The suffix IN THE MNEMONIC is the low three bits.
            A mnemonic that says `subl` is telling you the
            encoding, not the language.

  d8        the ModRM byte, and the course that has the whole
            layout of this byte is at isa-modrm, so this is
            one line of it:
              d8 = 11 011 000
                   |  |  `-- rm  = 000 = EAX
                   |  `----- reg = 011 = EBX
                   `-------- mod = 11 = REGISTER, so there is
                               no SIB byte and no displacement

  TWO BYTES TOTAL, and the length is not stored anywhere.
  A decoder knows it is two bytes because it has read the
  opcode and the ModRM and mod says register.  That is the
  whole of the length arithmetic, and the neutral course
  derives it; this is what it looks like applied.
                

Now the same instruction with a memory operand, from the same corpus: int_lea_mem is 48 8d 44 f7 08, and every byte is doing work.

  48        REX.W.  One byte that says four things at
            once: W (64-bit operands), R (extension for the
            reg field), X and B (for the SIB).  Without it
            the instruction is 32-bit and the address
            computation is 32-bit.  isa-rex has the rest.

  8d        LEA.  Note that the opcode for LEA is 0x8d,
            not 0x8b: MOV to a register and LEA into a
            register are DIFFERENT OPCODES, and the
            difference is the entire subject of the next
            concept.

  44        ModRM = 01 000 100
               mod = 01  -> a ONE-BYTE displacement follows
               reg = 000 -> rax is the destination
               rm  = 100 -> a SIB byte follows

  f7        SIB = 11 111 111
               scale = 11 -> x8
               index = 111 -> NONE.  An index of 111 means
                               no index register at all
               base  = 111 -> rdi

  08        the one-byte displacement, +8

  FIVE BYTES to compute rdi + rsi*8 + 8 and put it in rax,
  touching no memory.  Compare `mov 8(%rdi,%rsi,8), %rax`,
  which is six bytes and DOES touch memory.  The next concept
  measures the difference between those two and finds it is
  not the five-versus-six you would guess.
                

One trap in that, and it is the one that costs a day. You cannot write a scale of 3. The scale field is two bits and it encodes 1, 2, 4 and 8, and as says so out loud rather than silently computing something else:

$ cat _probe.s
.text
probe:  lea 0(%rdi,%rsi,3), %rax
$ as -o _probe.o _probe.s
_probe.s:2: Error: expecting scale factor of 1, 2, 4, or 8: got `3'
  

So lea 0(rdi,rsi,3) is the same shape as lea 0(rdi,rsi,4) — a base, an index, a scale, a displacement, a destination — and there is a value of the scale for which that shape has no encoding at all. A ×3 address is a LEA, a shift and an add, and the assembler makes you write all three. That is the whole ModRM-and-SIB story in one diagnostic, and the neutral course has the byte layout if you want it.

Apply it

  1. Disassemble one function twice, in both syntaxes, and diff the bytes.objdump -d f.o and objdump -d -M intel f.o | sed 's/\t/ /g' — or more simply, write the same six instructions in both syntaxes, assemble each, and compare the hex. (Expect the bytes to be identical and the operand order to be reversed. If your assembler is LLVM rather than GNU, `.intel_syntax noprefix` switches it and the exercise becomes the same experiment with a different toolchain — which is the point: the choice of default is a toolchain decision, not an architectural one, and knowing which one you are looking at is the skill.)
  2. Find the SIB scale 3. Take a real loop and replace a[i] with a[3*i] and watch the compiler emit three instructions instead of one. (Expect the array-index form to become a LEA with a scale, a multiply or a shift-and-add, and a register to be spilled if the loop was register-tight. The interesting number is not the speed; it is that the source looks identical in shape and differs in encoding, which is the property the whole architecture is built on.)
  3. Count the extensions in a compiled char loop.unsigned char c = p[i]; use(c); at -O2, then read the disassembly. (Expect a movzx and then nothing, because the zero extension IS the value. Then write char c = p[i] and expect a movsx instead — and then ask what happens to the upper 32 bits when the compiler uses a 32-bit register, which is the trap the next concept opens with.)
  4. Break the assembler's error message on purpose. Try lock on a register destination, mov %eax, (%rbx,%rsi,3), mov with four operands, and a 66 prefix on an instruction that has no 16-bit form. (Expect four different messages and three of them to be about something other than what you got wrong. Record each one and what the real reason was: this course's whole argument is that a plausible message is worse than a crash, and a collection of eight real assembler messages is the cheapest possible way to believe it.)

Connect

Backwards, The ISA course's length concept is where the rule this page applied twice is derived rather than illustrated, and its decoder is the thing this course's last concept deliberately tries to break. isa-rex has the four bits in 48 and isa-sib has the three in f7; both are linked rather than repeated, because repeating them is exactly the duplication docs/x86-64-section-plan.md exists to prevent. isa-modes is where the 64-bit mode you have been assuming comes from.

Forwards, this page read three instructions out of a compiled C file and said that each of them encodes a decision. The integer set concept is the reference for what those decisions are: what every one of the instructions in that disassembly writes, what it costs, and the three places where the register name in the source decides the answer rather than the speed. The prefix encodings takes the 66 in addpd and shows that its two bits are the VEX pp field wearing a different hat.

Outward, the neighbouring courses that this one leans on rather than repeats. The vector course measured what happens when the width in movzx is wrong, at a width of 128 bits, and found that an unaligned load is free on this machine — which is not re-measured here and not restated as this course's number. The execution course's frontend is where the "one instruction is a decode slot" claim in the next concept comes from, and where the reason the three-operand encoding buys nothing measurable is properly explained.

render_lesson_js(&mut page)