Hand-Assembly — Turning Mnemonics Into Machine Code With Just a Pencil
Last lesson handed you the dictionary. This one hands you a pencil and a real program, and asks you to do what an assembler does in microseconds — except you'll do it slowly enough to feel every bit fall into place. There's a particular, almost puzzle-like satisfaction in watching a line of assembly collapse into four hex digits you derived yourself, with nothing but a bit-field table and arithmetic. Let's do it.
Learning Objectives
By the end of this lesson, you should be able to:
- translate a small assembly listing into 16-bit instruction words using opcode fields;
- distinguish a word value such as
0xE005from the little-endian bytes stored in flash; - compute an AVR relative branch offset from
target = PC + 1 + k; - spot common hand-assembly mistakes before they become silent machine-code bugs.
The Program We're Hand-Assembling
A tiny AVR routine: load a constant into R16, load another constant into R17, add them together into R16, then jump back to the start.
LDI R16, 0x05 ; R16 = 5
LDI R17, 0x03 ; R17 = 3
ADD R16, R17 ; R16 = R16 + R17
RJMP start ; loop forever
Four instructions. Four opcode-table lookups. Let's go one at a time.
Step 1 — LDI R16, 0x05
Recall the LDI field layout from lesson 10:
1110 KKKK dddd KKKK
| Field | Bits | Value needed |
|---|---|---|
| Opcode | 15–12 | 1110 (fixed) |
| K (upper nibble) | 11–8 | upper 4 bits of 0x05 |
| d | 7–4 | register encoding for R16 |
| K (lower nibble) | 3–0 | lower 4 bits of 0x05 |
Register field for LDI is 4 bits wide and covers only R16–R31, using the mapping register − 16. R16 → 0000.
Immediate split: 0x05 = 0000 0101 in binary. Upper nibble = 0000, lower nibble = 0101.
Assemble the word:
1110 0000 0000 0101
opcode K(hi) d=R16 K(lo)
= 1110000000000101 = 0xE005
| Mnemonic | Binary | Hex |
|---|---|---|
LDI R16, 0x05 |
1110 0000 0000 0101 |
0xE0 0x05 |
(AVR stores instructions little-endian in memory as two bytes — low byte first — so in program memory you'd see 0x05 then 0xE0. We'll keep showing the word as a 16-bit value for clarity, and note byte order at the end.)
Step 2 — LDI R17, 0x03
Same field layout, new register and new immediate.
Register field: R17 → 17 − 16 = 1 → 0001.
Immediate split: 0x03 = 0000 0011. Upper nibble 0000, lower nibble 0011.
1110 0000 0001 0011
opcode K(hi) d=R17 K(lo)
= 1110000000010011 = 0xE013
| Mnemonic | Binary | Hex |
|---|---|---|
LDI R17, 0x03 |
1110 0000 0001 0011 |
0xE0 0x13 |
Step 3 — ADD R16, R17
Recall:
0000 11rd dddd rrrr
| Field | Bits | Value needed |
|---|---|---|
| Opcode | 15–10 | 000011 (fixed) |
| r (MSB of Rr) | 9 | MSB of register 17's 5-bit number |
| d (Rd, full 5 bits) | 8–4 | register number 16, in 5 bits |
| r (low 4 bits of Rr) | 3–0 | low 4 bits of register number 17 |
This time the full 5-bit register numbers are used (not the −16 offset trick — that's only for LDI). R16 = 10000 in 5 bits. R17 = 10001 in 5 bits.
Split Rr = R17 = 10001: MSB = 1, low 4 bits = 0001.
Rd = R16 = 10000 goes in whole, as the 5-bit ddddd field.
000011 1 10000 0001
opcode r d=R16 r(low4)=R17
= 0000111100000001 = 0x0F01
| Mnemonic | Binary | Hex |
|---|---|---|
ADD R16, R17 |
0000 1111 0000 0001 |
0x0F 0x01 |
This is the trickiest one precisely because the register field is split across non-adjacent bit positions — exactly the kind of thing that's invisible when you just call ADD in an assembler, and exactly the kind of thing this exercise is designed to make visible.
Step 4 — RJMP start
start is the address of the first instruction (LDI R16, 0x05), which lives at word address 0x0000. This RJMP is the 4th instruction, at word address 0x0003 (each instruction here is one word). The offset field k in RJMP is relative to PC + 1 (the address after the RJMP itself):
target = current_RJMP_address + 1 + k
0x0000 = 0x0003 + 1 + k
k = 0x0000 − 0x0004 = −4
In 12-bit two's complement, −4 = 1111 1111 1100.
1100 k k k k k k k k k k k k
opcode = 111111111100
= 1100111111111100 = 0xCFFC
| Mnemonic | Binary | Hex |
|---|---|---|
RJMP start (k = −4) |
1100 1111 1111 1100 |
0xCF 0xFC |
The Finished Machine-Code Listing
| Address | Mnemonic | Binary (16-bit word) | Hex word | Bytes in memory (LE) |
|---|---|---|---|---|
0x0000 |
LDI R16, 0x05 |
1110 0000 0000 0101 |
0xE005 |
05 E0 |
0x0001 |
LDI R17, 0x03 |
1110 0000 0001 0011 |
0xE013 |
13 E0 |
0x0002 |
ADD R16, R17 |
0000 1111 0000 0001 |
0x0F01 |
01 0F |
0x0003 |
RJMP start |
1100 1111 1111 1100 |
0xCFFC |
FC CF |
That right-most column — eight raw bytes, 05 E0 13 E0 01 0F FC CF — is exactly what would sit in the AVR's flash memory. There is nothing else to it. No symbol table, no parser, no magic: just bit-fields, filled in by hand, from a table you could photocopy and tape to a wall.
Worked Check: Recompute the Jump Offset
The RJMP field is where most hand-assembly errors hide, so always check it twice:
| Quantity | Value |
|---|---|
| Target word address | 0x0000 |
| RJMP word address | 0x0003 |
Address after RJMP, PC + 1 |
0x0004 |
Signed displacement, k |
0x0000 - 0x0004 = -4 |
| 12-bit two's complement | 0xFFC |
| Final word | 0xC000 OR 0x0FFC = 0xCFFC |
If you accidentally use the current PC instead of PC + 1, you get k = -3 and the loop lands on the second instruction. If you count byte addresses instead of word addresses, the error is even larger. AVR relative branches are measured in instruction words, not bytes.
Common Mistakes
| Mistake | Symptom | How to catch it |
|---|---|---|
Using R17 directly in the 4-bit LDI register field |
Wrong destination register | Remember LDI encodes d = register - 16 for R16-R31 |
| Forgetting split fields | Good opcode, wrong operands | Mark every K, d, and r bit before converting to hex |
| Swapping word value and memory bytes | Hex file appears reversed | Show both 0xE005 and stored bytes 05 E0 |
Branching from current PC instead of PC + 1 |
Loop lands one instruction late | Recalculate with target = current + 1 + k |
| Treating bytes as word addresses | Jump distance doubled | AVR program counter counts instruction words for these 16-bit opcodes |
Why This Exercise Matters
Every assembler ever written — from the one bundled with avr-gcc to the cross-assemblers used on 1970s minicomputers — does exactly the arithmetic you just did, just faster and without typos. Once you've manually placed a register number into a split bit-field and manually computed a signed branch offset, "the assembler computed the relative jump offset" stops being a magic phrase and becomes a concrete, checkable fact. And that hard-earned feeling — of being the one doing the work a machine usually does for you — is about to get a name: this is precisely what programmers did, instruction by instruction, using nothing but a hex keypad.
Key Takeaway
Hand-assembly is opcode tables applied with discipline: pick the fixed bits, slot in operand fields, and watch a line of assembly collapse into bytes you can verify by hand. There is no hidden step between mnemonic and machine code — only fields, positions, and arithmetic you now own. Next, we go back in time to where this hex-byte listing was the only interface between a programmer and a running computer.
Further Reading
- Microchip, AVR Instruction Set Manual, especially
LDI,ADD, andRJMPopcode formats. - Microchip, ATmega328P Datasheet, program memory and instruction execution sections.
- GNU Binutils documentation for
avr-asandavr-objdump, useful for comparing hand-assembled bytes against assembler output.