Reading Assembly Without a Disassembler
Every disassembler is doing the same mechanical thing you can do by hand. Once you can decode raw hex yourself, the tool stops being a black box you trust blindly.
Every disassembler is doing the same mechanical thing you can do by hand, slower and with a much better memory for opcode tables. Learning to read raw hex without IDA or Ghidra open isn't really about replacing the tool, it's that once you can do it by hand, the tool stops being a black box you trust blindly and becomes something you can sanity-check, which matters the moment it gets something wrong (misaligned disassembly after a jump into the middle of an instruction is a classic one, and you won't notice the output is garbage unless you can read enough raw bytes to tell).
Instructions are not fixed-width, and that's the first thing to internalise
x86/x64 instructions range from 1 byte (0x90, nop) to 15 bytes, which is the single biggest difference from architectures like ARM where every instruction is a clean 4 bytes you can just chop the stream into. You can't know where instruction N+1 starts until you've fully decoded instruction N, prefix bytes, opcode, ModRM, SIB, displacement, immediate, in that order, each one optional depending on what came before it. This is also exactly why disassemblers get confused when you jump into the middle of a valid instruction stream at the wrong offset: everything downstream decodes as a different, wrong sequence of instructions, because the byte boundaries genuinely are different depending on where you start reading. Packers abuse this on purpose.
The REX prefix is your first landmark in x64 code
If you're staring at hex and trying to find your footing, 0x48 is the byte you'll see more than almost any other in x64 code. It's a REX prefix with the W bit set, it tells the CPU 'the operand size for this instruction is 64 bits,' since the default operand size without it is still 32 bits even in 64-bit mode, for backward-compatibility reasons that trace back to how AMD64 was designed to not break existing 32-bit code patterns.
48 8B 05 12 34 00 00 mov rax, [rip+0x3412]Breaking that down: 48 is the REX.W prefix (64-bit operand). 8B is the opcode for a MOV where the destination is a register and the source is memory or register, determined by the ModRM byte that follows. 05 is that ModRM byte, decoded, it says 'destination register is RAX' and 'addressing mode is RIP-relative,' one of the more commonly memorized special cases. The remaining four bytes, 12 34 00 00, are a little-endian 32-bit displacement added to the instruction pointer after this instruction to get the actual memory address being read.
That's seven bytes of hex decoding into one line of assembly, and once you've done it a few hundred times by hand the common patterns, function prologues, RIP-relative loads, call instructions, start reading almost as fast as the mnemonics themselves.
Function prologues are a pattern you'll recognize instantly
Compiler-generated code is extremely repetitive at function boundaries, which makes prologues one of the easiest patterns to spot cold in a hex dump:
55 push rbp
48 89 E5 mov rbp, rsp
48 83 EC 20 sub rsp, 0x2055 is push rbp, single-byte opcodes in the 0x50 to 0x57 range are always push <register>, with the specific register encoded directly in the low 3 bits of the opcode itself rather than needing a ModRM byte, which is why these are so compact. 48 89 E5 sets up the frame pointer. 48 83 EC 20 reserves stack space for locals, 83 is the 'immediate operand, sign-extended' form of the arithmetic group, and EC as the ModRM selects the SUB operation against RSP specifically. Once you've seen this byte sequence enough times, 55 48 89 E5 at the start of a function is as recognizable as the word 'function' itself, no decoding required.
Calls and jumps: the part that actually requires arithmetic
E8 45 23 01 00 call <relative target>E8 is a near CALL with a relative displacement. The four bytes after it aren't an address, they're a signed 32-bit offset, added to the address of the instruction immediately following the call to produce the actual target. If the call instruction starts at address 0x140001000 and is 5 bytes long, the next instruction is at 0x140001005, and the real target is 0x140001005 + 0x00012345. This relative encoding is exactly why position-independent code works without modification regardless of where it's loaded, the offset is correct relative to itself no matter what absolute address the function ends up at, which is also why base relocations only have to patch absolute addresses, never relative call targets.
Conditional jumps follow the same relative-displacement logic with a much smaller range: the short forms (0x70 to 0x7F, one for each condition code like JZ, JNZ, JG) carry a single signed byte of displacement, good for roughly plus or minus 127 bytes, which is why compilers sometimes need the two-byte near-jump forms instead once a function is big enough that a branch target falls outside that range.
Why this is worth doing by hand at all
None of this replaces a disassembler for actual day-to-day work, nobody's hand-decoding a 400KB binary byte by byte, that's what the tool is for. The value is calibration. When you can read raw bytes yourself, you develop an instinct for what 'normal' compiled code looks like at the byte level, and that instinct is what makes a disassembler's mistakes visible instead of invisible. A tool silently misaligning its decode after a crafted jump, or confusing data embedded in a code section for actual instructions, produces output that looks plausible if you're trusting it blindly and looks obviously wrong the moment you can cross-check a few bytes by hand. That gap, between trusting the tool and being able to verify it, is the entire reason this is worth learning instead of skipping straight to the GUI.