Assembly from scratch - from registers and memory to a real program
Assembly language looks like something from another planet at first:
LDA #$01
STA $0400
INX
BNE loop
There are no classes.
No objects.
No npm install.
No garbage collector.
There is not even one universal version of the language.
And yet assembly is one of the best ways to truly understand:
- how a processor works,
- what registers are,
- what the stack is,
- how memory is organized,
- where addresses come from,
- what a function really is,
- what a compiler does,
- what a linker does,
- how source code differs from machine code,
- why C looks the way it does,
- where function-call overhead comes from,
- what an ABI is,
- what “32-bit”, “64-bit”, or “8-bit” actually means.
This material is not meant to turn you into a professional assembly programmer.
Its goal is that when you see:
mov rax, rbx
or:
lda #$20
you understand what the computer is actually doing.
Our main practical path will be:
MOS 6502 / MOS 6510 / Commodore 64
because it is simple enough to reveal the processor without immediately burying us under the enormous complexity of modern x86-64.
At the end we will compare it with:
- x86-64,
- ARM,
- RISC-V.
Related TechHandbook material:
- C - reading, building and debugging projects
- Debian - shell
- Visual Studio Code
- GitHub
- 20 modern programming languages worth knowing
- Old programming languages that shaped computing
First, one important distinction: assembler or assembly?
In everyday speech, people often say:
I program in assembler.
Everyone understands what they mean.
Technically, however, it is useful to distinguish two things.
Assembly language
This is the symbolic language used to write processor instructions.
Example:
LDA #$01
Assembler
This is the program that translates textual assembly source into machine code.
Examples:
ca65
NASM
GNU as
MASM
64tass
DASM
So the basic path is:
assembly source
|
v
assembler
|
v
machine code
In this article we may occasionally use “assembler” in the common informal sense, but it is worth knowing the technical distinction.
There is no single assembly language
This is the most important difference compared with Python, Java, or Go.
Code such as:
LDA #$10
makes sense for the 6502 family.
Code such as:
mov rax, 10
is characteristic of x86-64.
Code such as:
mov x0, #10
can appear in AArch64.
Code such as:
li a0, 10
is typical RISC-V.
Every architecture has its own:
Instruction Set Architecture
or:
ISA
An ISA defines, among other things:
- which instructions the processor understands,
- which registers it has,
- how it addresses memory,
- how instructions are encoded,
- which kinds of operations it can perform.
ISA - the contract between software and the processor
Imagine a processor as a machine that understands a fixed set of commands.
A hypothetical CPU might understand:
LOAD
STORE
ADD
SUB
JUMP
COMPARE
A program such as:
LOAD A, 10
LOAD B, 20
ADD A, B
is only a convenient symbolic notation for humans.
The processor does not read the word:
ADD
The processor sees numbers.
Hypothetically:
00010010 00000001 00000010
The assembler therefore performs a translation from symbolic instructions to the correct bit patterns.
Machine code
Machine code is executed directly by the CPU.
If the 6502 instruction:
LDA #$01
is encoded as:
A9 01
then:
A9
is the operation code for:
LDA immediate
and:
01
is the operand.
In memory we therefore have two bytes:
A9 01
The processor:
- fetches
A9, - decodes the instruction,
- fetches the next byte,
- executes the operation.
Hexadecimal numbers
Assembly code very often uses hexadecimal notation.
Instead of:
0
1
2
...
9
10
11
we have:
0
1
2
...
9
A
B
C
D
E
F
10
One hex digit represents exactly:
4 bits
Two hex digits represent:
8 bits = 1 byte
Examples:
$00 = 0
$01 = 1
$0A = 10
$10 = 16
$FF = 255
6502 documentation often uses:
$FF
C commonly uses:
0xFF
NASM can use:
0xFF
These are the same value.
Bits and bytes
A bit can contain:
0
or:
1
Eight bits make one byte:
10110100
The unsigned range of a byte is:
0..255
or:
$00..$FF
The 6502 is called an 8-bit processor partly because its main accumulator and primary data operations are 8 bits wide.
That does not mean it can address only 256 bytes.
It has a 16-bit address space:
$0000..$FFFF
which is:
65536 bytes = 64 KiB
What does a CPU actually do?
In a very simplified model:
fetch instruction
|
v
decode instruction
|
v
execute instruction
|
v
fetch next instruction
This is the:
fetch -> decode -> execute
cycle.
Inside the processor we find, among other things:
- registers,
- an arithmetic and logic unit,
- an instruction decoder,
- control logic.
Registers - the processor's fastest storage
A register is a small storage location directly inside the CPU.
Do not confuse it with RAM.
RAM is a separate memory area.
Registers:
CPU
┌────────────────────┐
│ register A │
│ register X │
│ register Y │
│ program counter │
│ stack pointer │
└────────────────────┘
RAM:
CPU <----> RAM
Accessing a register is fundamentally different from reading ordinary memory.
MOS 6502 and MOS 6510
The MOS 6502 was one of the most important processors of the 8-bit era.
Members of the family appeared in systems including:
- Apple II,
- Atari 2600 - through the 6507 variant,
- Atari 8-bit computers,
- BBC Micro,
- NES - through a related CPU,
- many embedded systems.
The Commodore 64 uses:
MOS 6510
The 6510 is a close relative of the 6502.
Its most important addition is a built-in I/O port used by the C64, among other things, for memory mapping.
For learning the instruction set we can think of it roughly as:
6502 + a few C64-specific features
6502 registers
The 6502 has surprisingly few registers.
That is one reason it is so good for learning.
A - accumulator
A
The main data register.
Many operations work through it.
Example:
LDA #10
means:
Load Accumulator
or effectively:
A = 10
X
The index register:
X
Useful for:
- indexing arrays,
- counters,
- loops.
Example:
LDX #0
Y
The second index register:
Y
It is used similarly to X, though the exact supported instructions differ.
PC - Program Counter
PC
is the program counter.
It stores the address of the next instruction.
If:
PC = $C000
the processor fetches the instruction from:
$C000
After executing it, execution proceeds.
A jump changes PC.
Example:
JMP $C100
is essentially:
PC = $C100
SP - Stack Pointer
SP
points to the current stack position.
On the 6502, the stack always lives in:
$0100..$01FF
SP itself is 8-bit.
If:
SP = $FD
the active stack location is around:
$01FD
P - Processor Status
The processor status register contains flags.
The most important:
N - Negative
V - Overflow
B - Break
D - Decimal
I - Interrupt Disable
Z - Zero
C - Carry
You may see the compact form:
NV-BDIZC
Each flag is one bit.
Zero flag
If the result of an operation is zero:
Z = 1
For example:
LDA #0
sets the Zero flag.
We can then use:
BEQ somewhere
or:
Branch if Equal
The instruction really checks Z.
Carry flag
Carry is used, among other things, for:
- addition,
- subtraction,
- bit shifts,
- arithmetic larger than one byte.
Example:
CLC
LDA #200
ADC #100
Mathematically:
200 + 100 = 300
But an 8-bit register can store at most:
255
The value in A wraps, while the extra carry information is stored in the Carry flag.
Memory
For the CPU, memory is essentially a large array of bytes.
Imagine:
address value
$0000 $12
$0001 $A0
$0002 $FF
$0003 $00
...
Instruction:
LDA $2000
means:
read one byte from address $2000
and place it in A
Instruction:
STA $2000
means:
store A at address $2000
Immediate value versus address
This is one of the first things that confuses beginners.
Immediate
LDA #$10
means:
A = $10
The:
#
marks an immediate value.
Absolute memory access
LDA $0010
means:
A = memory[$0010]
That is a completely different operation.
#$10
is a value.
$0010
is an address.
Addressing modes
The 6502 supports several addressing modes.
You do not need to memorize all of them at once.
The most important ones are:
Immediate
LDA #$20
The value is part of the instruction.
Zero page
LDA $20
An address from:
$0000..$00FF
The 6502 has shorter and often faster encodings for this memory region.
Absolute
LDA $2000
A full 16-bit address.
Indexed X
LDA table,X
Address:
table + X
Very useful for arrays.
Indexed Y
LDA table,Y
Indirect
The effective address is loaded from memory.
This is similar in concept to a pointer.
Zero page
Addresses:
$0000..$00FF
form the:
zero page
For the 6502 this region is especially important.
You can think of it a little like a bank of fast pseudo-registers.
Example:
LDA $10
can have a shorter encoding than:
LDA $2010
In old 6502 programs, zero-page space was a precious resource.
The stack
A stack follows:
Last In, First Out
or:
LIFO
Imagine a stack of plates.
You push:
A
B
C
You pop:
C
B
A
Push
6502:
PHA
means:
Push Accumulator
Pull
PLA
means:
Pull Accumulator
Example:
LDA #10
PHA
LDA #20
PLA
After PLA, A contains:
10
again.
Why do we need a stack?
Among other things:
- temporary values,
- subroutine calls,
- interrupt handling.
Instruction:
JSR subroutine
must remember:
where to return
The return address goes onto the stack.
Then:
RTS
retrieves it.
A function in assembly is not magic
In C:
foo();
looks like one operation.
Underneath, something must:
- prepare arguments,
- save a return address,
- transfer control,
- execute the function,
- return,
- recover the result.
On 6502, the basic mechanism is:
JSR foo
and the function ends with:
RTS
Example:
JSR clear_screen
JSR draw_player
JSR update_score
This is a very direct representation of function calls.
Labels
Instead of writing:
JMP $C042
we can name the destination:
JMP game_loop
and later:
game_loop:
...
The assembler calculates the actual address during the build.
This is one of the most important improvements assembly brought over manually written machine code.
A loop
6502 example:
LDX #0
loop:
INX
CPX #10
BNE loop
Meaning:
X = 0
loop:
X = X + 1
compare X with 10
if not equal:
go back to loop
In C:
for (int x = 0; x < 10; x++) {
}
Same logic, lower-level representation.
Comparisons on 6502
Instruction:
CMP
compares the accumulator with a value.
Example:
LDA score
CMP #10
BEQ player_won
The CPU does not create a magical Boolean value called:
true
It sets flags.
Instructions such as:
BEQ
BNE
BCC
BCS
BMI
BPL
inspect those flags.
This reveals where higher-level constructs such as:
if
while
for
come from.
The processor does not know those concepts.
A compiler constructs them from comparisons and jumps.
Addition
On 6502:
CLC
LDA #10
ADC #20
After execution:
A = 30
CLC means:
Clear Carry
Why is it needed?
Because ADC performs:
A + value + Carry
If Carry was still set from an earlier operation, the result would be one larger.
Subtraction
SEC
LDA #30
SBC #10
SEC means:
Set Carry
Carry semantics during subtraction are initially a little unintuitive.
That is why the usual pattern begins with:
SEC
Numbers larger than 255
The 6502 is 8-bit, but of course it can calculate larger values.
We simply split a number into bytes.
A 16-bit value:
$1234
contains:
high byte = $12
low byte = $34
Adding two 16-bit values means adding both bytes and propagating Carry.
Conceptual example:
CLC
LDA a_low
ADC b_low
STA result_low
LDA a_high
ADC b_high
STA result_high
Carry from the first addition enters the second.
In C you write:
uint16_t c = a + b;
The compiler does similar work for you.
Little endian
6502 stores multi-byte values in:
low byte
high byte
order.
That is:
little endian
Value:
$1234
may appear in memory as:
address $2000 -> $34
address $2001 -> $12
x86 is also little endian.
That is why memory dumps need to be interpreted carefully.
Commodore 64 - memory map
The C64 is an excellent machine for learning assembly because the hardware is relatively simple and exceptionally well documented.
The CPU sees an address space:
$0000..$FFFF
or 64 KiB.
Important regions:
| Address | Meaning |
|---|---|
$0000-$00FF |
zero page |
$0100-$01FF |
CPU stack |
$0400-$07E7 |
default text screen memory |
$0801... |
typical BASIC program start |
$A000-$BFFF |
BASIC ROM, depending on mapping |
$D000-$DFFF |
I/O / character ROM, depending on mapping |
$D800-$DBE7 |
color memory |
$E000-$FFFF |
KERNAL ROM, depending on mapping |
This is a simplified map.
The C64 can switch the visibility of RAM, ROM, and I/O regions.
C64 text screen
The default screen memory begins at:
$0400
Each byte corresponds to one screen cell.
40 columns:
40
25 rows:
25
Total:
1000 characters
If you store the appropriate screen code at:
$0400
you change the top-left character.
Example:
LDA #1
STA $0400
With the standard character set, screen code 1 corresponds to A.
This is one of the beautiful things about old hardware:
write to memory
=
change what appears on screen
No:
DOM
Canvas
OpenGL
DirectX
browser API
Color memory
Character color is stored separately:
$D800...
Example:
LDA #2
STA $D800
changes the color of the first cell.
The color number refers to the standard C64 palette.
We can therefore do:
LDA #1
STA $0400
LDA #2
STA $D800
and set:
character
+
color
Hardware registers
In old computers, hardware is often controlled through memory addresses.
This is:
memory-mapped I/O
For example, VIC-II registers live in:
$D000...
Changing a byte can:
- move a sprite,
- change the background color,
- change a graphics mode,
- affect raster behavior.
For the CPU:
STA $D020
is simply a memory write.
But the hardware interprets $D020 as:
border color register
Changing the C64 border color
One of the simplest experiments:
LDA #2
STA $D020
Address:
$D020
is the border color register.
Value:
2
is red in the standard C64 palette.
Two instructions and the physical appearance of the screen changes.
KERNAL - ready-made ROM routines
You do not need to do everything manually.
The C64 contains ROM with system routines.
One of the best-known is:
CHROUT
at address:
$FFD2
If we place a character in A:
LDA #'A'
and execute:
JSR $FFD2
the KERNAL prints the character.
This is a primitive form of a system API.
Our 6502/C64 toolchain
We will use:
cc65
Not because we want to write C.
The cc65 package contains a complete toolkit:
cc65 - C compiler
ca65 - 6502 assembler
ld65 - linker
cl65 - build driver
da65 - disassembler
sim65 - simulator
For this article, the most important are:
ca65
ld65
cl65
Installing cc65 - Windows
The cc65 project publishes Windows builds.
Project site:
https://cc65.github.io/
Repository:
https://github.com/cc65/cc65
After installing or extracting the tools, add the bin directory to:
PATH
Verify:
ca65 --version
cl65 --version
Installing cc65 - macOS
Homebrew:
brew install cc65
Verify:
ca65 --version
cl65 --version
Installing cc65 - Linux / Debian
sudo apt update
sudo apt install cc65
Verify:
ca65 --version
cl65 --version
The package contains cross-development tools for 6502 targets including the C64.
C64 emulator - VICE
You do not need a physical C64.
We will use:
VICE
VICE emulates, among other systems:
- C64,
- C128,
- VIC-20,
- PET,
- Plus/4.
VICE - Windows
Current builds are available from:
https://vice-emu.sourceforge.io/
For C64 work we are mainly interested in:
x64sc
VICE - macOS
Homebrew:
brew install vice
Verify:
x64sc --version
VICE - Debian
VICE is distributed in Debian's:
contrib
section.
After enabling contrib:
sudo apt update
sudo apt install vice
Verify:
x64sc --version
Depending on installation method, VICE may require legally obtained ROM images.
VS Code
Assembly works perfectly well in an ordinary text editor.
You can use:
- VS Code,
- Vim,
- Neovim,
- Micro,
- any editor you like.
More detail:
Useful features:
- syntax highlighting,
- integrated terminal,
- build commands,
- convenient switching between source and debugger.
You do not need a heavy IDE.
First real C64 program
Create:
hello.s
Code:
.segment "CODE"
start:
ldx #0
loop:
lda message,x
beq done
jsr $ffd2
inx
bne loop
done:
rts
message:
.byte "HELLO FROM 6502!", 13, 0
What does this code do?
Segment
.segment "CODE"
tells the assembler and linker that the following bytes belong to the code segment.
X = 0
LDX #0
X will be the string index.
Read one character
LDA message,X
Equivalent idea:
A = message[X]
End of string
The text ends with byte:
0
After LDA, the processor sets Z if the loaded byte is zero.
So:
BEQ done
exits the loop.
Output
JSR $FFD2
calls KERNAL CHROUT.
Next character
INX
or:
X++
Loop
BNE loop
Because X is 8-bit, it wraps to zero after 255.
For a short string this does not matter.
Building a C64 program
cc65 provides a special linker configuration:
c64-asm.cfg
We can use:
cl65 \
-o hello.prg \
-u __EXEHDR__ \
-t c64 \
-C c64-asm.cfg \
hello.s
Option:
-u __EXEHDR__
adds a small BASIC header.
That allows the loaded program to be started with:
RUN
instead of manually typing a SYS address.
Running in VICE
x64sc -autostart hello.prg
VICE:
- starts a C64,
- loads the PRG,
- starts the program.
Now we have a complete chain:
source
↓
assembler
↓
object code
↓
linker
↓
PRG
↓
emulator
↓
MOS 6510
ca65 and ld65 separately
cl65 performs multiple steps automatically.
It is useful to know what happens underneath.
Assembler
ca65 -t c64 hello.s -o hello.o
Result:
hello.o
This is not yet a ready C64 program.
Linker
The linker:
ld65
combines:
- segments,
- symbols,
- libraries,
- final addresses.
For practical C64 work, linker configuration matters a great deal.
That is why cl65 is convenient at the beginning.
What does the linker do?
Imagine two files.
main.s:
.import print_message
JSR print_message
print.s:
.export print_message
print_message:
...
RTS
The assembler processes them independently.
Inside main.o, the final address of:
print_message
does not need to be known yet.
The linker:
- combines the modules,
- assigns addresses,
- resolves symbols,
- fixes references.
This is the same fundamental mechanism you later encounter in C and C++.
Symbols
An assembler allows symbolic constants:
SCREEN = $0400
BORDER = $D020
CHROUT = $FFD2
Then:
STA BORDER
is easier to understand than:
STA $D020
Program:
SCREEN = $0400
COLOR = $D800
LDA #1
STA SCREEN
LDA #2
STA COLOR
is almost self-documenting.
Constants versus data
Assembler constant:
BORDER = $D020
does not allocate a variable in program memory.
It is simply a symbolic substitution performed by the assembler.
A variable:
counter:
.byte 0
actually reserves one byte in the program's data.
Array
numbers:
.byte 1, 2, 3, 4, 5
We can read:
LDX #0
LDA numbers,X
Then:
INX
to reach the next element.
Copying an array
ldx #0
loop:
lda source,x
sta destination,x
inx
cpx #10
bne loop
Equivalent C:
for (int x = 0; x < 10; x++) {
destination[x] = source[x];
}
This is a good point to see how close C is to assembly concepts.
Pointers
In C:
char *ptr;
In assembly there is no special magical type called:
pointer
An address is simply a number.
On 6502, a 16-bit pointer can be stored in two zero-page bytes:
ptr:
.word $0000
and used with indirect addressing.
Example:
LDA (ptr),Y
Conceptually:
A = memory[ptr + Y]
Very close to:
ptr[y]
Why are C and assembly so closely related?
C was created for systems programming.
That is why constructs such as:
*p
p++
array[i]
uint8_t
uint16_t
map naturally onto low-level operations.
Once assembly becomes readable, many parts of C stop looking strange.
See:
C - reading, building and debugging projects
Mini-project: fill the screen
Default screen memory:
$0400
We want to fill the first 256 cells with the letter A.
SCREEN = $0400
.segment "CODE"
start:
ldx #0
lda #1
loop:
sta SCREEN,x
inx
bne loop
rts
Why does the loop run exactly 256 times?
X is 8-bit.
Values:
0
1
2
...
254
255
0
When:
255 -> 0
the Zero flag is set.
BNE loop
does not branch.
This is a beautiful example of using processor behavior instead of maintaining another counter.
Clearing the whole screen
The screen contains:
1000
cells.
That does not fit in a single 8-bit loop.
We can clear it in chunks:
SCREEN = $0400
.segment "CODE"
start:
lda #32
ldx #0
loop:
sta SCREEN,x
sta SCREEN+$0100,x
sta SCREEN+$0200,x
inx
bne loop
ldx #0
last:
sta SCREEN+$0300,x
inx
cpx #232
bne last
rts
Why:
232
Because:
1000 - 768 = 232
The first loop clears:
3 * 256 = 768
cells.
The second clears:
232
Total:
1000
Macros
Assemblers can provide macro systems.
ca65 lets us write:
.macro set_border color
lda #color
sta $d020
.endmacro
Then:
set_border 2
The assembler expands the macro during the build.
This is not a CPU function.
It is a source transformation performed before machine code is produced.
Assembler directives
Line:
.byte 1, 2, 3
is not a CPU instruction.
It tells the assembler:
place these bytes in the output.
Likewise:
.word $1234
.segment "CODE"
.import foo
.export bar
These are:
assembler directives
The CPU never sees them.
CPU instruction versus directive
Instruction:
LDA #1
becomes machine code executed by the processor.
Directive:
.byte 1
tells the assembler to insert a byte.
This distinction is fundamental.
Pseudo-instructions
Some assemblers provide syntax that looks like a processor instruction but is actually translated into one or more real instructions.
This is called a:
pseudo-instruction
It is especially common in RISC-V.
For example:
li a0, 100
may be expanded into an appropriate instruction sequence depending on the value.
Interrupts
Normally the CPU executes:
instruction
instruction
instruction
instruction
But sometimes hardware says:
I need attention now.
That is an:
interrupt
The processor:
- finishes the current instruction,
- saves necessary state,
- jumps to an interrupt handler,
- handles the event,
- returns.
Interrupts can be used for:
- timers,
- keyboards,
- network cards,
- graphics hardware,
- disk controllers.
Raster interrupt on the C64
VIC-II draws the screen line by line.
The programmer can configure an interrupt at a particular raster line.
That allows effects such as:
- changing colors while the screen is being drawn,
- multiplexing sprites,
- synchronizing animation,
- demoscene raster effects.
This is an advanced topic.
The important idea is:
hardware
|
v
interrupt
|
v
our code
Clock cycles
Old processors are excellent for learning performance because instruction cost is tangible.
An instruction may cost:
2 cycles
3 cycles
4 cycles
In raster-synchronized code, a single cycle may matter.
Modern x86 is much more complex:
- pipelines,
- cache,
- out-of-order execution,
- branch prediction,
- superscalar execution.
On the 6502 the relationship between instruction and time is far easier to observe.
Self-modifying code
Because a program is stored in memory, it can theoretically modify its own instructions.
For example:
change the operand of an LDA instruction
Such techniques were used in old systems for performance.
Today self-modifying code is far less common in ordinary applications because of:
- security,
- memory protection,
- instruction caches,
- maintainability.
But it is worth remembering that code is also just data in memory.
Code and data
For the processor, byte:
$A9
is not inherently an instruction.
It becomes an instruction if the Program Counter points to it as the beginning of one.
The same byte may represent:
number
character
color
instruction fragment
part of an address
pixel
Meaning comes from context.
Disassembler
An assembler performs:
assembly -> machine code
A disassembler tries the reverse:
machine code -> assembly
In cc65 we have:
da65
In NASM:
ndisasm
In GNU binutils:
objdump
Example:
objdump -d program
This is one of the fundamental tools of reverse engineering.
Debugger
A debugger lets you:
- stop the CPU,
- execute one instruction,
- inspect registers,
- inspect memory,
- set breakpoints.
For assembly work, a debugger is especially valuable.
At a high level you look at:
variable x
At a low level:
RAX
RSP
memory[0x7fff...]
flags
VICE monitor
VICE has a built-in monitor.
This is the debugger of the C64 world.
It allows you to:
- inspect memory,
- disassemble,
- set breakpoints,
- inspect registers,
- modify memory.
It is ideal for learning.
Inside the emulator you may see something like:
A:00 X:00 Y:00 SP:F6
Suddenly abstract register names become real state.
Debug symbols in cc65
cc65 can generate debugging information.
For a larger project, you may build with:
-g
With symbols, a debugger can show names instead of only raw addresses.
The second world: x86-64
6502 is simple.
The processor in a modern PC is a completely different beast.
The dominant PC architecture is:
x86-64
also known as:
AMD64
Intel also uses the name:
Intel 64
x86-64 has much larger registers
Examples:
RAX
RBX
RCX
RDX
RSI
RDI
RSP
RBP
R8
R9
...
R15
A typical general-purpose register is:
64 bits
or:
8 bytes
A huge difference from the 8-bit A register of the 6502.
RAX and its subregisters
Historical compatibility in x86 gives one register several names.
RAX - 64 bits
EAX - lower 32 bits
AX - lower 16 bits
AL - lower 8 bits
AH - historical upper 8 bits of AX
Conceptually:
RAX
┌────────────────────────────────────────────────────────────────┐
│ 64 bits │
└────────────────────────────────────────────────────────────────┘
└──────── EAX ───────────────────┘
└── AX ───────┘
AL / AH
This is one example of x86's historical baggage.
Installing NASM - Windows
NASM:
Netwide Assembler
is a popular x86/x86-64 assembler.
Official site:
https://www.nasm.us/
Download an installer or archive for Windows.
After adding it to PATH:
nasm -v
NASM - macOS
brew install nasm
Verify:
nasm -v
NASM - Debian
sudo apt update
sudo apt install nasm
Verify:
nasm -v
x86-64: Hello World on Linux
Linux lets a process communicate with the kernel using:
system calls
NASM example:
section .data
message db "Hello from x86-64!", 10
message_len equ $ - message
section .text
global _start
_start:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, message_len
syscall
mov rax, 60
xor rdi, rdi
syscall
What do those numbers mean?
On Linux x86-64:
rax = 1
selects the:
write
system call.
Arguments:
rdi = file descriptor
rsi = data address
rdx = length
So:
mov rdi, 1
means:
stdout
Then:
syscall
asks the kernel to perform the operation.
Building x86-64 on Linux
Assembler:
nasm -f elf64 hello.asm -o hello.o
Linker:
ld hello.o -o hello
Run:
./hello
We again have the same model:
hello.asm
|
v
NASM
|
v
hello.o
|
v
ld
|
v
hello
Why does the same code not run on Windows?
Because assembly depends not only on the CPU.
It also depends on:
operating system
ABI
executable format
system API
Linux x86-64 uses, among other things:
ELF
Linux syscall ABI
Windows uses:
PE/COFF
Windows x64 ABI
WinAPI
The processor may be the same.
The program environment is different.
ABI
ABI means:
Application Binary Interface
It defines things such as:
- where arguments are passed,
- where return values are placed,
- which registers a function must preserve,
- how the stack is organized,
- how a process communicates with the system.
On Linux x86-64, typical function arguments are passed in:
RDI
RSI
RDX
RCX
R8
R9
On Windows x64, the first arguments go into:
RCX
RDX
R8
R9
Same processor.
Different ABI.
Calling convention
Imagine a C function:
int add(int a, int b);
The compiler must know:
where does a go?
where does b go?
where is the result returned?
who restores the stack?
which registers may be overwritten?
The answer is provided by the:
calling convention
Without one, binary modules produced by different tools could not interoperate reliably.
Stack pointer in x86-64
Register:
RSP
points to the top of the stack.
Instructions:
push rax
pop rax
are conceptually similar to:
PHA
PLA
on 6502.
Difference:
6502 -> stack in fixed area $0100-$01FF
x86-64 -> stack in ordinary process memory
CALL and RET
x86:
call function
stores the return address on the stack and transfers control.
Return:
ret
The same fundamental idea as:
JSR
RTS
on 6502.
6502 versus x86-64
| Feature | 6502/6510 | x86-64 |
|---|---|---|
| era | 1970s/80s | modern |
| main data width | 8 bit | 64 bit |
| classic address space | 16 bit | large 64-bit model |
| general registers | very few | many |
| ISA | relatively small | very large |
| stack | fixed $0100 page |
ordinary memory |
| learning | excellent | harder |
| modern desktop | no | yes |
The third world: ARM
ARM is everywhere:
- phones,
- tablets,
- Raspberry Pi,
- routers,
- microcontrollers,
- servers,
- Apple Silicon Macs.
Modern 64-bit ARM is commonly called:
AArch64
AArch64 registers
Main registers:
X0..X30
Each:
64 bits
Their lower 32-bit parts are:
W0..W30
Example:
mov x0, #10
mov x1, #20
add x2, x0, x1
Meaning:
X0 = 10
X1 = 20
X2 = X0 + X1
This is much more regular than historical x86.
ARM and load/store
RISC architectures often clearly separate:
register operations
from:
memory access
Typical pattern:
ldr x0, [x1]
add x0, x0, #1
str x0, [x1]
Meaning:
load from memory
calculate in a register
store back to memory
ARM cross-toolchain - Debian
For Cortex-M/R microcontrollers:
sudo apt update
sudo apt install gcc-arm-none-eabi
The package also contains the GNU assembler:
arm-none-eabi-as
Verify:
arm-none-eabi-as --version
ARM - Windows and macOS
Arm publishes the official:
Arm GNU Toolchain
for:
- Windows,
- Linux,
- macOS.
Website:
https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
Depending on target, you may use tools such as:
arm-none-eabi-gcc
arm-none-eabi-as
aarch64-none-elf-gcc
The fourth world: RISC-V
RISC-V is an open ISA.
That is an important distinction.
x86 and ARM are controlled by specific companies and licensing ecosystems.
The RISC-V specification is open.
The architecture is modular.
A base instruction set can be extended with:
- multiplication,
- atomics,
- floating point,
- vectors,
- compressed instructions.
RISC-V is very regular
Example:
li a0, 10
li a1, 20
add a2, a0, a1
Argument registers:
a0
a1
...
Temporary registers:
t0
t1
...
Saved registers:
s0
s1
...
This regularity often makes RISC-V easier to read than x86.
RISC-V on Debian
Linux cross-toolchain:
sudo apt install gcc-riscv64-linux-gnu
Bare metal:
sudo apt install gcc-riscv64-unknown-elf
Tools include:
riscv64-linux-gnu-as
riscv64-unknown-elf-as
RISC versus CISC
This topic is more subtle than:
RISC = simple
CISC = complicated
but as an introduction:
CISC
Classic example:
x86
A large instruction set with many historical addressing modes.
RISC
Examples:
ARM
RISC-V
More regular instruction sets and a strong register-oriented load/store philosophy.
Modern CPUs are internally much more complicated than these labels suggest.
The same operation on four ISAs
We want:
10 + 20
6502
CLC
LDA #10
ADC #20
Result:
A
x86-64
mov rax, 10
add rax, 20
AArch64
mov x0, #10
add x0, x0, #20
RISC-V
li a0, 10
addi a0, a0, 20
Same idea.
Four different processor languages.
Why assembly is not portable
C code:
int x = a + b;
can be compiled for:
x86-64
ARM
RISC-V
PowerPC
The compiler selects the correct instructions.
Assembly:
mov rax, rbx
is tied to x86-64.
That is why assembly is:
architecture-dependent
What does a C compiler do?
Take:
int add(int a, int b) {
return a + b;
}
A compiler may produce something like:
mov eax, edi
add eax, esi
ret
That lets you use C without manually writing processor instructions.
A compiler is, among other things, an automatic machine-code generator.
See the assembly generated by GCC
Create:
add.c
int add(int a, int b) {
return a + b;
}
Generate assembly:
gcc -S -O2 add.c
Result:
add.s
This is one of the best assembly-learning exercises.
Change the C code:
if
for
while
function
struct
and observe what the compiler generates.
Intel syntax versus AT&T syntax
On x86 you will encounter two major syntaxes.
Intel
mov rax, rbx
Read:
destination <- source
AT&T
movq %rbx, %rax
Operand order is reversed:
source -> destination
You also see:
%
$
suffixes
So the same x86 code can look very different in two tutorials.
NASM uses Intel-like syntax.
GNU as traditionally uses AT&T syntax, though GNU tools can also work with Intel syntax.
Object file
After assembly, you often do not have a complete program yet.
You have an:
object file
On Linux:
.o
It contains, among other things:
- machine code,
- data,
- symbols,
- relocation information,
- possibly debug information.
Relocation
Suppose the assembler sees:
call foo
but does not yet know where:
foo
will finally be placed.
It records:
linker, fix this address later.
That is:
relocation
Once the linker lays out all sections, it may know:
foo = 0x401040
and patch the reference.
Segments and sections
A typical program has separate regions.
For example:
.text
.data
.bss
.rodata
.text
Executable code.
.data
Initialized writable data.
.rodata
Read-only data.
.bss
Data that should start as zero.
In cc65 you encounter similar segment concepts, though names and organization depend on the target configuration.
Loader
After producing an executable, something must load it into memory.
On a modern OS this is done by the operating-system loader.
On C64, a:
PRG
contains a load address.
The system knows where to place the bytes.
Then the CPU must be given the entry point.
Firmware, ROM and boot
When a computer powers on, the CPU does not magically know where the operating system is.
The architecture defines a reset/start mechanism.
6502 reads vectors from specific addresses.
A modern PC goes through firmware such as:
UEFI
and then a bootloader.
On a microcontroller, code may begin directly from flash.
Assembly makes this layer much easier to understand.
Reverse engineering
Assembly is the foundational language of reverse engineering.
Even without source code, you can inspect:
machine code
and disassemble it.
Tools include:
- Ghidra,
- IDA,
- Binary Ninja,
- radare2,
- objdump,
- gdb.
A disassembler tries to turn bytes back into instructions.
A decompiler goes one step further and tries to reconstruct something resembling C.
Assembly and security
Many vulnerability classes become much easier to understand after learning assembly basics:
- buffer overflow,
- stack smashing,
- use-after-free,
- ROP,
- shellcode,
- calling conventions,
- return-address overwrite.
Not because you need to write exploits.
Simply because you understand what:
overwriting memory
really means.
Buffer overflow - the core idea
Imagine a stack frame:
[ local buffer ]
[ saved register ]
[ return address ]
If a program writes past the end of the buffer, it may overwrite the:
return address
After RET, the processor may jump somewhere unexpected.
Modern systems use mitigations such as:
- ASLR,
- NX,
- stack canaries,
- PIE,
- CFI.
But the mechanism becomes much clearer when you understand:
stack
return address
PC/RIP
Cache
On 6502, memory can be understood almost as one uniform space.
Modern CPUs have several levels of cache:
registers
L1
L2
L3
RAM
storage
Access times may differ enormously.
That means modern performance depends not only on instruction count.
It also depends on:
- locality,
- cache misses,
- memory bandwidth,
- branch prediction.
This is one reason hand-optimizing modern assembly is much harder than optimizing a simple 6502 routine.
Pipeline
A CPU does not necessarily finish one instruction before beginning to process the next.
It can overlap stages:
fetch
decode
execute
memory
writeback
across multiple instructions.
This is a:
pipeline
Branch prediction
When the CPU sees a branch:
if
it may not want to wait until the condition is fully resolved.
It predicts:
which path will execute
If correct:
great
If wrong:
part of the pipeline must be discarded
That is another reason modern instruction cost is not a simple table.
Out-of-order execution
A modern CPU may execute instructions in a different internal order if doing so does not change the observable result.
For example:
A waits for RAM
B is independent arithmetic
The CPU may begin B while waiting for A's data.
This is one of the foundations of modern processor performance.
On a typical 6502 we do not need to think about this world.
SIMD
Modern CPUs can perform the same operation on multiple values at once.
This is:
SIMD
x86 examples:
SSE
AVXAVX2
AVX-512
ARM:
NEON
SVE
RISC-V:
Vector Extension
Useful for:
- graphics,
- audio,
- compression,
- ML,
- scientific computing.
Should ordinary applications be written in assembly?
Usually no.
Reasons:
- far more code,
- poor portability,
- harder testing,
- harder maintenance,
- optimizing compilers are very good.
Assembly still makes sense in areas such as:
- bootloaders,
- parts of kernels,
- firmware,
- startup code,
- highly specific embedded code,
- cryptography,
- hand-tuned hot paths,
- reverse engineering,
- demoscene,
- retrocomputing.
Assembly as a learning tool
For most modern programmers, this may be its most valuable use.
If you understand assembly, you better understand:
C
pointers
stack
heap
ABI
debugger
compiler
linker
system calls
processes
memory
CPU
You do not need to use assembly professionally.
Exercise 1 - register
6502:
LDA #10
Question:
what does A contain now?
Answer:
10
Exercise 2 - memory
LDA #10
STA $2000
Question:
what is stored at $2000?
Answer:
10
Exercise 3 - counter
LDX #0
loop:
INX
CPX #5
BNE loop
After completion:
X = 5
Exercise 4 - array
values:
.byte 10, 20, 30, 40
LDX #2
LDA values,X
A contains:
30
The index starts at zero.
Exercise 5 - subroutine
JSR foo
...
foo:
LDA #10
RTS
After returning:
A = 10
Exercise 6 - stack
LDA #10
PHA
LDA #20
PLA
After PLA:
A = 10
Exercise 7 - overflow
CLC
LDA #255
ADC #1
8-bit A cannot store:
256
After the operation:
A = 0
Carry = 1
A very tangible lesson about fixed-width numbers.
Mini-project: blinking C64 border
We can make a simple loop that changes the border color.
BORDER = $D020
.segment "CODE"
start:
ldx #0
loop:
stx BORDER
jsr delay
inx
txa
and #$0f
tax
jmp loop
delay:
ldy #0
delay_outer:
ldx #0
delay_inner:
dex
bne delay_inner
dey
bne delay_outer
rts
This delay is deliberately crude.
Real C64 code should often synchronize with hardware, for example with the raster.
But the exercise demonstrates:
- I/O writes,
- loops,
- registers,
- subroutines,
- bit masking.
AND and bit masks
Instruction:
AND #$0F
keeps only the low four bits.
$0F = 00001111
If A is:
10110110
then:
10110110
AND
00001111
=
00000110
Result:
6
Bit masking is everywhere in low-level programming.
OR
ORA #$80
sets selected bits.
Example:
00100010
OR
10000000
=
10100010
XOR
6502 instruction:
EOR
means exclusive OR.
EOR #$FF
flips all bits in A.
Shifts
Shift instructions:
ASL
LSR
ASL
Arithmetic Shift Left
For unsigned values, shifting left by one bit is similar to:
* 2
when there is no overflow.
LSR
Logical Shift Right
is similar to:
/ 2
for unsigned values.
Bit fields in hardware registers
A hardware register often contains several independent settings in one byte.
Hypothetical example:
bit 7 = enable
bit 6 = interrupt
bit 5 = mode
...
Instead of replacing the whole byte, we can manipulate one bit:
ORA #%10000000
or:
AND #%01111111
This is foundational in:
- microcontrollers,
- drivers,
- device programming.
Binary notation
ca65 supports:
%10101010
which is convenient for bit patterns.
Example:
LDA #%00000001
Hex:
LDA #$01
Decimal:
LDA #1
Same value.
How to read unfamiliar assembly
Do not try to understand every instruction independently.
First identify:
- entry point,
- main loop,
- subroutines,
- data,
- memory accesses,
- system or firmware calls,
- conditions and branches.
Look for patterns.
Example:
loop:
...
dec counter
bne loop
You immediately recognize:
counter-controlled loop
6502 instructions worth recognizing
Data transfer
LDA
LDX
LDY
STA
STX
STY
Register transfer
TAX
TAY
TXA
TYA
TSX
TXS
Stack
PHA
PLA
PHP
PLP
Arithmetic
ADC
SBC
INC
DEC
INX
DEX
INY
DEY
Logic
AND
ORA
EOR
Shifts and rotates
ASL
LSR
ROL
ROR
Comparisons
CMP
CPX
CPY
Jumps and subroutines
JMP
JSR
RTS
Branches
BEQ
BNE
BCC
BCS
BMI
BPL
BVC
BVS
Flags
CLC
SEC
CLI
SEI
CLD
SED
CLV
You do not need to memorize the entire table.
After a few small programs, most of it starts to feel natural.
Branch versus jump
JMP
JMP somewhere
always transfers control.
Branch
BNE somewhere
transfers control only when a condition is satisfied.
Classic 6502 branches also have limited range relative to the current address.
That matters in larger functions.
JSR versus JMP
JMP foo
jumps to foo with no automatic return mechanism.
JSR foo
stores a return address.
That is why a subroutine ends with:
RTS
BRK
Instruction:
BRK
triggers a software interrupt.
It is not simply:
stop CPU
On 6502 it enters the interrupt mechanism.
NOP
NOP
means:
No Operation
The CPU executes an instruction that intentionally changes almost nothing.
NOPs are useful for:
- timing,
- alignment,
- patching,
- debugging.
Illegal opcodes
The classic 6502 has instruction encodings that were not officially documented but still produce defined hardware behavior.
They are called:
illegal opcodes
undocumented opcodes
Demoscene programs and old games sometimes used them.
Beginners should avoid them.
Learn the official instruction set first.
6502 versus 6510
For most simple examples, the instruction set is effectively the same.
The 6510 adds an I/O port visible around:
$0000
$0001
The C64 uses this to control memory mapping.
Changing $0001 can influence whether the CPU sees:
- BASIC ROM,
- KERNAL ROM,
- I/O,
- RAM underneath ROM.
This is one of the things that makes the C64 more interesting than a purely abstract 6502 trainer.
RAM under ROM
The C64 physically has 64 KiB of RAM.
At the same time, within the same address space we can see:
ROM
I/O
How?
Hardware changes what is visible under particular addresses.
At:
$A000
the CPU may see BASIC ROM in one configuration and RAM in another.
This is:
bank switching / memory mapping
Hardware is part of the program
On a modern operating system, an application mostly sees:
OS abstractions
On C64, a program can directly touch:
VIC-II
SID
CIA
RAM
ROM
That is why retro assembly is such a good way to study the architecture of a computer as a whole.
What should you learn next on C64?
A natural order:
- registers,
- memory,
- loops,
- subroutines,
- zero page,
- text screen,
- colors,
- keyboard,
- sprites,
- raster,
- SID,
- interrupts,
- custom data structures,
- cycle optimization.
After that, assembly stops looking like mysterious incantations.
Are we learning 6502 or C64?
These are two layers.
6502/6510
We learn:
CPU
instructions
registers
flags
stack
addressing
C64
We learn:
memory map
VIC-II
SID
CIA
KERNAL
BASIC ROM
screen memory
You can know the 6502 instruction set and still know very little about the C64.
And vice versa.
Building a small project
Simple layout:
hello-c64/
├── src/
│ └── main.s
├── Makefile
└── README.md
Example Makefile:
PROGRAM = hello
SOURCE = src/main.s
all:
cl65 -o $(PROGRAM).prg \
-u __EXEHDR__ \
-t c64 \
-C c64-asm.cfg \
$(SOURCE)
run: all
x64sc -autostart $(PROGRAM).prg
clean:
rm -f $(PROGRAM).prg
Build:
make
Run:
make run
Clean:
make clean
Why use a Makefile for one file?
For one source file, you barely need it.
But soon you may have:
main.s
screen.s
sprites.s
sound.s
input.s
and the build command becomes tedious.
Automating the build is a natural next step.
Git
Assembly source is plain text.
It works perfectly with Git.
git init
git add .
git commit -m "Initial C64 assembly project"
See:
Avoid committing generated binaries if they can be reproduced from source.
Typical .gitignore:
*.o
*.prg
*.map
*.lbl
Map file
A linker can generate a map file.
It may show:
- segment placement,
- symbol addresses,
- code size.
In low-level programming this is extremely useful.
Label file
You can generate symbol labels for an emulator or debugger, so instead of:
$C042
you see:
game_loop
That dramatically improves debugging.
How to think about optimization
Do not start with:
how many cycles can I shave off?
Start with:
- write correct code,
- test it,
- measure,
- find the bottleneck,
- optimize only then.
Even on a C64. Code that is:
shorter
is not always:
faster
And faster code is not always worth losing readability.
Size versus speed
Retro programming often forces a trade-off between:
fewer bytes
and:
fewer cycles
A lookup table may replace a calculation:
more memory
less CPU
or vice versa:
less memory
more computation
The same trade-off still exists in modern software.
Why does the demoscene love assembly?
Demoscene programming often tries to do the maximum possible with severely limited hardware.
Every:
- byte,
- cycle,
- timing detail
can matter.
Assembly gives a level of control that a high-level language may not.
That is why platforms such as:
- C64,
- Amiga,
- Atari ST,
- ZX Spectrum
have such a rich history of low-level programming.
Assembly and microcontrollers
In embedded development you may still need to read assembly even when most of the project is written in C or Rust.
Examples:
- startup code,
- bootloader,
- interrupt vector,
- context switch,
- fault handler.
On Cortex-M you may encounter:
startup_stm32.s
It is useful to understand what happens there even if you never write the entire application in assembly.
Assembly and operating systems
A kernel must do things ordinary applications cannot.
For example:
- change CPU modes,
- handle interrupts,
- switch process context,
- manage page tables,
- execute privileged instructions.
Some of this code naturally belongs in assembly.
Most of a kernel can be written in C or Rust, but its lowest layers still have to understand the processor.
Assembly in cryptography
Cryptographic software often needs:
- very high performance,
- SIMD,
- special hardware instructions,
- predictable execution.
That is why crypto libraries may contain hand-written assembly.
Such optimization should be done by people who deeply understand the target architecture.
Inline assembly
C and C++ compilers may allow assembly inside source code.
Conceptual example:
asm("nop");
This is:
inline assembly
It is:
- compiler-specific,
- architecture-specific,
- easy to get wrong.
It should usually be kept to a minimum.
Intrinsics
Instead of pure assembly, modern code often uses:
intrinsics
These are compiler-provided functions that map to particular CPU operations.
They are common for:
- SIMD,
- cryptography,
- atomics.
The compiler still handles:
- register allocation,
- calling convention,
- instruction scheduling.
This is often a better compromise than hand-written assembly.
What does “64-bit” really mean?
There is no single universal definition.
It can refer to:
- register width,
- address width,
- natural integer size,
- ISA family.
x86-64 has 64-bit general-purpose registers.
But modern CPUs do not necessarily implement every possible 64-bit virtual or physical address bit.
The marketing label simplifies the technical reality.
What is an opcode?
Opcode means:
operation code
It identifies an operation.
6502:
A9
can mean:
LDA immediate
Whole instruction:
A9 10
means:
LDA #$10
Opcode:
A9
Operand:
10
Variable-length instructions
6502 instructions may be:
1 byte
2 bytes
3 bytes
long.
x86 goes much further - instruction lengths vary considerably.
RISC-V uses a more regular format, though compressed instructions add shorter encodings.
Instruction encoding is part of the ISA.
Alignment
Some architectures prefer or require data at aligned addresses.
For example:
address divisible by 4
for a 32-bit value.
The situation differs between classic 6502, ARM, and modern x86.
Modern programmers encounter alignment in:
- C structures,
- SIMD,
- memory allocation,
- ABI rules.
Atomics
Multicore CPUs need mechanisms for safe synchronization between cores.
ISAs provide special atomic operations.
On x86:
LOCK
CMPXCHG
ARM and RISC-V have their own mechanisms.
These form the basis for:
- mutexes,
- spinlocks,
- atomics,
- lock-free structures.
A classic single-CPU C64 does not have to solve the same multicore problems.
Privilege levels
Modern CPUs have different privilege levels.
Application code cannot simply:
disable memory protection
The kernel can use instructions unavailable to normal processes.
On x86 you encounter:
rings
On ARM:
exception levels
This is part of the operating system's security foundation.
Syscall versus library function
In C:
printf("Hello");
is not directly an operating-system instruction.
printf is a library function.
It may eventually use a system call such as:
write
Assembly lets us bypass some library layers and invoke the kernel more directly.
But we lose library convenience.
The CPU does not know text
The processor does not know what:
"Hello"
means.
It sees bytes.
For example:
48 65 6C 6C 6F
may be interpreted as ASCII.
The same sequence could be treated as:
- numbers,
- instructions,
- pixels,
- audio data.
The data format gives those bytes meaning.
The CPU does not know variables
In high-level code:
int score = 10;
the CPU does not know the name:
score
After compilation, the value may live in:
a register
or:
a memory address
The name mainly exists for humans and development tools.
The CPU does not know loops
The CPU knows:
jumps
conditions
addresses
Loop:
while (x != 0) {
x--;
}
may become:
loop:
dec ...
jne loop
This is an important shift in perspective.
The CPU does not know functions
The CPU knows:
addresses
stack
jump/call
return
A function is a convention built from those mechanisms.
The CPU does not know objects
C++:
player.move();
eventually becomes:
- addresses,
- pointers,
- functions,
- data,
- CPU instructions.
Abstraction layers are extremely useful.
But assembly remains underneath them.
Why assembly is less frightening than it looks
The basics are often simpler than a modern frontend framework.
6502 gives you only a small set of fundamental concepts:
a few registers
a small instruction set
memory
flags
stack
jumps
The difficulty appears when you build a large system from those small pieces.
You do not need advanced mathematics to understand the foundation.
Minimal list to remember
If you forget most of this article, remember:
- The processor executes machine code.
- An assembler translates symbols into machine code.
- Assembly depends on the ISA.
- Registers live inside the CPU.
- RAM is an addressable array of bytes.
- PC points to an instruction.
- SP points to the stack.
- Flags describe operation results.
JMPchanges control flow.CALL/JSR+RET/RTSbuild function calls.- The linker combines modules and resolves symbols.
- An ABI defines how binary components cooperate.
- The operating system adds another layer above the ISA.
- C64 is a great laboratory because hardware is visible without hundreds of abstraction layers.
6502 cheat sheet
| Instruction | Meaning |
|---|---|
LDA |
load A |
STA |
store A |
LDX |
load X |
STX |
store X |
LDY |
load Y |
STY |
store Y |
ADC |
add with Carry |
SBC |
subtract with Carry |
CMP |
compare A |
CPX |
compare X |
CPY |
compare Y |
INC |
increment memory |
DEC |
decrement memory |
INX |
X++ |
DEX |
X-- |
INY |
Y++ |
DEY |
Y-- |
AND |
bitwise AND |
ORA |
bitwise OR |
EOR |
bitwise XOR |
ASL |
shift left |
LSR |
shift right |
JMP |
unconditional jump |
JSR |
call subroutine |
RTS |
return |
BEQ |
branch if Z=1 |
BNE |
branch if Z=0 |
BCC |
branch if C=0 |
BCS |
branch if C=1 |
BMI |
branch if N=1 |
BPL |
branch if N=0 |
PHA |
push A |
PLA |
pull A |
CLC |
clear Carry |
SEC |
set Carry |
NOP |
no operation |
Register cheat sheet
6502/6510
A - accumulator
X - index
Y - index
PC - program counter
SP - stack pointer
P - status
x86-64
RAX RBX RCX RDX
RSI RDI
RSP RBP
R8-R15
RIP
RFLAGS
AArch64
X0-X30
SP
PC - conceptually, not as a regular GPR
PSTATE
RISC-V
x0-x31
pc
ABI aliases:
zero
ra
sp
gp
tp
t0-t6
s0-s11
a0-a7
Tools cheat sheet
| Purpose | 6502/C64 | x86-64 | ARM | RISC-V |
|---|---|---|---|---|
| assembler | ca65 |
nasm / as |
arm-none-eabi-as |
riscv64-unknown-elf-as |
| linker | ld65 |
ld |
GNU ld |
GNU ld |
| disassembler | da65 |
ndisasm, objdump |
objdump |
objdump |
| emulator/simulator | VICE / sim65 | QEMU / native CPU | QEMU / hardware | QEMU / hardware |
| debugger | VICE monitor | GDB | GDB | GDB |
Installation - quick reference
C64 / 6502
Debian
sudo apt install cc65
sudo apt install vice
VICE requires contrib.
macOS
brew install cc65
brew install vice
Windows
Download current builds of:
cc65
VICE
from the project sites.
x86-64
Debian
sudo apt install nasm binutils gdb
macOS
brew install nasm
Windows
NASM:
https://www.nasm.us/
For native Windows linking, Visual Studio Build Tools or MinGW/MSYS2 can also be useful depending on workflow.
ARM bare metal
Debian
sudo apt install gcc-arm-none-eabi
Windows/macOS
Arm GNU Toolchain:
https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
RISC-V bare metal
Debian
sudo apt install gcc-riscv64-unknown-elf
Linux cross:
sudo apt install gcc-riscv64-linux-gnu
How to continue practicing
Do not begin with:
I will write my own operating system
A better path:
Step 1
Registers:
LDA
LDX
LDY
Step 2
Memory:
STA
Step 3
Loops:
CMP
BNE
Step 4
Arrays and indexing.
Step 5
Subroutines:
JSR
RTS
Step 6
Stack:
PHA
PLA
Step 7
C64:
screen RAM
color RAM
VIC-II
Step 8
KERNAL.
Step 9
Interrupts.
Step 10
Sprites and SID.
Only then does it make sense to dive more deeply into:
x86-64
ARM
RISC-V
Why start with 6502 instead of Intel?
Because you can almost hold the whole CPU model in your head.
You have:
A
X
Y
SP
PC
flags
With x86-64 you immediately inherit:
- decades of compatibility,
- multiple register sizes,
- many addressing modes,
- SIMD,
- ABIs,
- privilege levels,
- a huge ISA.
6502 is a small model for learning the principles.
Then x86 stops looking like total chaos.
It looks more like:
the same basic ideas after forty-plus years of expansion.
The most important experiment: compare C with assembly
Create:
example.c
int sum(int a, int b)
{
return a + b;
}
Then:
gcc -O0 -S example.c -o example-O0.s
gcc -O2 -S example.c -o example-O2.s
Compare the two files.
You will see what the optimizer changes.
Then:
gcc -O2 -c example.c -o example.o
objdump -d example.o
Now you can observe the full path:
C
↓
assembly
↓
object code
↓
disassembly
Compiler Explorer
For quickly comparing high-level code with generated assembly, use:
https://godbolt.org/
You can enter:
int add(int a, int b) {
return a + b;
}
and inspect output for:
- GCC,
- Clang,
- x86-64,
- ARM,
- RISC-V,
- multiple optimization levels.
It is one of the best educational tools for learning the relationship:
C/C++/Rust -> assembly
What does assembly give a high-level programmer?
Even if you never write another pure assembly program, you will understand these concepts better:
Pointers
They are addresses.
References
Eventually they must identify data.
Stack
It is real memory with a real pointer.
Functions
They are conventions for moving control between addresses and passing data.
Types
The CPU sees bits. Types are a layer above them.
Overflow
It follows from fixed-width representations.
Segmentation fault
A process accessed memory it was not allowed to access.
Calling convention
It is the agreement that lets binary components communicate.
Compiler optimization
It is automatic transformation of code into a more efficient instruction sequence.
What assembly does not teach you
Assembly does not replace knowledge of:
- algorithms,
- application architecture,
- databases,
- networking,
- application security,
- testing,
- API design.
It is one layer.
A very important one, but still only one.
The key conclusion
The computer does not execute:
button.addEventListener(...)
It does not execute:
for x in items:
It does not execute:
go worker()
It does not execute:
printf(...)
At the bottom, it executes instructions of its architecture.
The entire modern stack:
framework
runtime
library
language
compiler
operating system
eventually leads to:
registers
memory
instructions
CPU
Assembly lets you look directly at that layer.
That is why it is worth learning even if it never becomes your main language.
Related TechHandbook material
- C - reading, building and debugging projects
- Go - reading code
- Debian - shell
- Shell programming
- Debian - desktop and server
- Visual Studio Code
- GitHub
- 20 modern programming languages worth knowing
- Old programming languages that shaped computing
Official sources and documentation
Tooling status: September 2026.
6502 / cc65
- cc65: https://cc65.github.io/
- ca65 User's Guide: https://cc65.github.io/doc/ca65.html
- ld65 User's Guide: https://cc65.github.io/doc/ld65.html
- C64-specific information: https://cc65.github.io/doc/c64.html
- cc65 GitHub: https://github.com/cc65/cc65
Commodore 64
- VICE: https://vice-emu.sourceforge.io/
x86 / x86-64
- NASM: https://www.nasm.us/
- Intel Software Developer Manuals: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- AMD64 Architecture Programmer's Manual: https://www.amd.com/en/search/documentation/hub.html
ARM
- Arm developer documentation: https://developer.arm.com/
- Arm GNU Toolchain: https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
RISC-V
- RISC-V International: https://riscv.org/
- Specifications: https://riscv.org/technical/specifications/
Tools
- Compiler Explorer: https://godbolt.org/
- GNU Binutils: https://www.gnu.org/software/binutils/
- GDB: https://www.gnu.org/software/gdb/
Next in the series
- 20 modern programming languages worth knowing
- Old programming languages that shaped computing
- Assembly from scratch - from registers and memory to a real program - this article
- Ada - the language where mistakes are meant to be harder to make
The next article moves to a very different philosophy.
After assembly, where the programmer has almost complete control over the machine, we move to Ada - a language designed so that the programmer cannot casually do everything that happens to be possible.
That contrast makes the two languages excellent examples of two very different approaches to software development.