Assembly: The Most Honest Programming Language

Modern programming languages like Python or JavaScript do everything to hide real hardware from you. You just write print("Hello"), and the language figures out where to find memory, how to pass data to the graphics card, and how to control the processor. It's convenient, but it's magic.

Assembly is the complete opposite. It is the most honest language in the world. There are no hidden mechanisms, no complex variable types, and no garbage collectors. Each line of assembly code is exactly one basic instruction that the processor's decoder physically understands (an opcode).

Write LDA 10 — and the processor obediently loads the number 10 into the accumulator. But let's agree on honesty right away: LDA N loads the immediate value N — the number itself. ADD N, on the other hand, reads data from address N on the bus — even if that's an I/O port. One word, two different operations, and you must always remember this.

Where the instruction byte comes from

Every instruction is a number. The formula is simple: byte = (opcode << 4) | operand. The high 4 bits are the operation code, the low 4 bits are an operand from 0 to 15.

Let's assemble ADD 5: the add opcode is 1, so the byte is (1 << 4) | 5 = 0x15 = 0b00010101. That's how a line of code becomes a single byte of ROM.

The Ershov Computer instruction set

Here's the full instruction set:

MnemonicOpcodeWhat it does
NOP0Nothing — a pause
ADD1Acc = Acc + RAM[address/port]
SUB2Acc = Acc − RAM[address]
AND3Acc = Acc AND RAM[address]
OR4Acc = Acc OR RAM[address]
LDA5Acc = number (immediate value)
STA6RAM[address] = Acc
JMP7Unconditional jump
JZ8Jump if the Zero flag = 1
LDX9IX = number
LDAX10Acc = RAM[IX]
STAX11RAM[IX] = Acc
JN12Jump if the Negative flag = 1
INX13IX = IX + 1
HLT15Stop

The conditional jumps JZ and JN rely on the ALU flags — we covered them in detail in the article about status flags. And LDX, LDAX, STAX, and INX work with the index register — that's the topic of the article about pointers.

In the byte format the operand is limited to 0–15. The game also has a word format: an instruction takes two bytes — an opcode byte and an operand byte from 0 to 255. It's used in the advanced levels, starting from 2.19.

Python vs assembly

Let's compare. In Python you write a = 10, and the interpreter allocates memory, creates an object, and sets up a reference counter. In assembly, LDA 10 means exactly one thing: put the number 10 into the accumulator. No magic.

Three instructions — LDA 10, ADD 20, STA 30 — and you've manually performed what Python did with dozens of lines of C. You're no longer asking the computer to do something — you're controlling its electrical impulses directly.

Reading the gamepad

Time to put the honest language to work: let's read the gamepad from level 2.23.

LDA 0      ; Acc = 0 — clear the accumulator
ADD 254    ; Acc = 0 + buttons (gamepad port 0xFE)
STA 0      ; store buttons in RAM[0]
HLT        ; stop

Notice: ADD 254 doesn't mean "add the number 254" — it means "read the data at address 254 and add it." Feeling the difference between an immediate value and an address matters; all the logic that follows depends on it.

Common mistakes

1. Confusing LDA N (a number) with ADD N (an address/port).

2. Forgetting the 0–15 operand range in the byte format: the high bits are silently dropped, so LDA 42 becomes LDA 10.

3. Treating HLT as optional: without it the program counter keeps reading garbage from ROM.

Summary

1. One line of assembly is one processor instruction.

2. LDA N loads the number N; ADD N reads the data at address N.

3. The instruction byte is (opcode << 4) | operand; ADD 5 assembles to 0x15.

4. The full set runs from NOP to HLT; the conditional jumps JZ and JN work on the ALU flags.

5. The gamepad is read with three instructions: LDA 0, ADD 254, STA 0.

In level 2.23 you will write a program that reads the gamepad and stores the buttons in memory. It's your first real input/output program — and it only gets more interesting from here.

Try it in the simulator →