Eerste stapjes
In het vorige deel van deze tutorial heb je kunnen lezen over hoe de MOS Technology 6502 werkt: wat voor registers er zijn en hoe het geheugen benaderd wordt. In dit deel gaan we die kennis in de praktijk brengen met kleine voorbeeldjes.
6502 Simulator
Om makkelijk assembly te kunnen typen, compileren en testen heb ik een handige simulator gevonden, die uitmaakt van deze uitstekende tutorial. Omdat de stand-alone simulator een verouderde versie heeft van de assembler ten opzichte van de tutorial, en omdat het invoerveld zo klein is, heb ik hem geforked en die zaken aangepast en wat kleine features toegevoegd. Mijn versie vind je hier.
Deze simulator is niet heel precies waar het aankomt op timing: er zijn geen klokcycli waar je rekening mee kan houden. Er is dan ook geen timingverschil tussen zero page en normaal adresgebruik en de snelheid is (deels) afhankelijk van je eigen computer.
De simulator begint standaard met het uitvoeren van instructies op adres $0600
. De echte 6502 leest het adres waarop hij start (dit heet de reset vector) met uitvoeren van geheugenadressen $fffc - $fffd
. $fffa - $fffb
en $fffe - $ffff
worden gebruikt voor interrupt vectors, dit werkt niet in de simulator en daar gaan we ook niet verder op in.
De met de simulator meegebakken assembler begint ook standaard met assembleren op het adres $0600
, dus maak je hier niet druk om.
Speciale geheugenadressen
De simulator heeft twee speciale adressen (naast het display). Dit zijn:
$fe
– na elke instructie bevat dit adres een nieuwe random waarde$ff
– de ascii code van de laatst ingedrukte toets
Het display
Het display is 32 bij 32 pixels, en elke pixel heeft 1 geheugenadres (1 byte). Deze vind je in de adresruimte $0200 - $05ff
. We gaan hier later verder op in.
Referenties
In de hierbovengenoemde tutorial worden twee links gegeven met referentiemateriaal. Ze zijn beide ietwat onvolledig maar vullen elkaar prima aan.
- www.6502.org/tutorials/6502opcodes.html
- www.obelisk.me.uk/6502/reference.html
Daarnaast heb ik nog een cheat sheet gemaakt. Op de voorkant vind je een overzicht van alle instructies en de adresseringsmodi waarmee ze gebruikt kunnen worden, en een samenvatting van die modi.
Op de achterkant vind je een overzicht van de geheugenlayout, wat voorbeelden voor 16 bit optellen en aftrekken en wat overige mnemonics specifiek voor de assembler in de simulator.
De basis
We gaan eerst een paar basisinstructies uitproberen.
Tips voor je begint
De debugger is een handige feature, zeker met deze korte voorbeelden. Als je het vinkje aanzet kan je stap voor stap door de code heen, en zie je precies hoe de waarden in de processor veranderen.
Als we met het geheugen werken, is dat meestal in de eerste paar bytes. Het is dan ook handig om de "monitor" aan te zetten, met start op $0
en de length op $10
. Je ziet deze dan ook live veranderen als je door de code gaat.
LDA en STA
Deze instructies staan voor LoaD Accumulator en STore Accumulator. Je gebruikt ze om een waarde in A
te laden, of om de waarde van A
op te slaan in het geheugen.
LDA #$10
STA $00
De eerste instructie laadt de letterlijke waarde $10
in A
, de tweede slaat de waarde van A
op in het geheugen op adres $0000
.
ADC en CLC
De 6502 heeft geen instructies om op te tellen of af te trekken zonder de Carry flag, dus deze wordt altijd gebruikt. ADC
staat dan ook voor ADd with Carry. Je telt altijd op bij de Accumulator.
Als je wil optellen zonder de carry te gebruiken, moet je die eerst clearen met CLC
: CLear Carry. Het is een goede gewoonte om dit altijd te doen voordat je optelt. Mocht je in een later stadium je code optimaliseren en je weet zeker dat C
0
is op het moment dat de instructie wordt uitgevoerd dan kan je altijd nog een comment plaatsen.
LDA #$10
CLC
ADC #$01
Eerst laden we de waarde $10
in A
, we clearen de carry en tellen er $01
bij op.
Je kan natuurlijk ook waarden uit het geheugen gebruiken, bijvoorbeeld zo:
LDA #$02
STA $00
CLC
ADC $00
We laden $02
in A
, en slaan die op in het geheugen. Vervolgens gebruiken we ADC
om die waarde weer bij A
op te tellen.
We hebben nu een paar waarden bij elkaar opgeteld, maar die hele carry nog niet gebruikt. De carry-flag wordt gezet als het resultaat van de berekening groter is dan in A
past, oftewel $ff
(want: 8 bits).
Als we dus $90 + $90
zouden willen doen is het resultaat $120
en dat is groter dan $ff
. We hebben hier dus te maken met een overflow. Als we dit proberen zal je zien dat het resultaat in A
$20
is, en dat C
wordt gezet:
LDA #$90
CLC
ADC #$90
Nu kunnen we deze carry meenemen naar een volgende berekening, om getallen bij te houden die groter zijn dan $ff
. Dit kan echter niet alleen op de processor, we hebben ook het geheugen nodig. Stel dat we de waarde $190
hebben en daar $90
(samen $220
) bij willen optellen, dan doen we dat zo:
LDA #$90
STA $00
LDA #$01
STA $01
LDA $00
CLC
ADC #$90
STA $00
LDA $01
ADC #$00
STA $01
In de eerste 4 instructies laan we de waarde $190
op in het geheugen. Dit doen we met de lage byte eerst, dus slaan we $90
op in $00
en $01
in $01
. De eerste 2 bytes in het geheugen zijn dan dus $90 $01
. Deze constructie zal je vaker terug zien komen.
De volgende 4 instructies zijn het eerste deel van de optelsom: de lage byte. We laden eerst de lage byte ($90
uit $00
) in A
en tellen daar $90
bij op. A
is nu 20
en de Carry flag is gezet! Als laatste slaan we het resultaat weer op op de originele plek $00
, anders zouden we dat kwijtraken.
In de laatste 3 instructies doen we de hoge bytes. We de hoge byte ($01
uit $01
) in A
, en tellen daar $00
bij op zonder de Carry te clearen, dus de Carry wordt er ook bij opgeteld. Het resultaat is $01 + $01 = $02
en dat slaan we weer op in $01
. Je ziet dan ook de waarden $90 $02
als de eerste twee waarden in het geheugen als resultaat van deze berekening.
Als je een waarde groter dan $ff
wil optellen, moet je de hoge byte hiervan samen met de Carry optellen in de tweede stap, in plaats van $00
wat we nu gedaan hebben.
Dit kan je natuurlijk uitbreiden tot veel grotere getallen; uiteindelijk word je alleen gelimiteerd door het beschikbare geheugen.
SBC en SEC
Ook aftrekken kan niet carry-loos. SBC
staat dan ook voor SuBtract with Carry. Echter gaat aftrekken precies omgekeerd als optellen, dus gebeurt dat met het inverse van de carry. Voordat je gaat aftrekken moet je dus de carry zetten met SEC
: SEt Carry.
LDA #$10
SEC
SBC #$01
Hier laden we eerst de waarde $10
in A
, zetten C
op 1
met SEC
en trekken $01
af. Het resultaat is dan ook $0f
.
Dit kan je zien als volgt: omdat we geen overflow (of eigenlijk underflow) hadden, hebben we de extra "leen-1" in de carry niet nodig gehad, en is deze nog steeds 1
na het uitvoeren van deze berekening.
Stel nu dat we een grotere waarde aftrekken dan in A
staat, dan moet er een extra 1
geleend worden uit C
en wordt deze op 0
gezet:
LDA #$10
SEC
SBC #$20
Wat je normaal zou verwachten is -$10
, maar we hebben geen plek voor het sign. Wat er in A overblijft is $f0
(wat ook niet geheel toevallig het two's complement is voor -$10
, maar daar gaan we nu niet dieper op in). Wat er dus eigenlijk gebeurt: $110 - $020 = $0f0
is uitgevoerd, en die extra 1
links kwam uit de carry – die is nu dan ook 0
.
Net als met optellen kan je dit trucje gebruiken bij het aftrekken van waarden. Hier een voorbeeld voor $210 - $20 = $1f0
:
LDA #$10
STA $00
LDA #$02
STA $01
LDA $00
SEC
SBC #$20
STA $00
LDA $01
SBC #$00
STA $01
We slaan eerst weer een waarde ($210
) op in de eerste twee bytes van het geheugen ($10 $02
), op dezelfde manier als in het optel-voorbeeld.
Vervolgens laden we de lage byte, en trekken daar $20
vanaf. Dit resulteert in de waarde $f0
en C
0
. Als laatste laden we weer de hoge byte, en trekken daar $00
vanaf, samen met (de inverse van) C
, dus $02 - $01 = $01
. Het resultaat is dus $1f0
, in het geheugen staat dat als $f0 $01
.
Bitshifts en -rotaties
We hebben 4 instructies om bitshifts en -rotaties doen. Laten we beginnen met ASL
(Arithmetic Shift Left) en LSR
(Logical Shift Right). Deze twee instructies gebruik je om bits naar links of naar rechts te shiften (schuiven). Waarom het linksom "arithmetic" en rechtsom "logical" heet ontgaat mij volledig, we zullen het er maar mee moeten doen.
Bij ASL
worden de bits in de opgegeven locatie naar links geschoven. De bit die aan de linkerkant afvalt wordt in de carry gestopt. Vanaf de rechterkant wordt er aangevuld met 0
.
LDA #$01
ASL A
ASL A
ASL A
ASL A
ASL A
ASL A
ASL A
ASL A
We gebruiken hier A
als adres, om aan te geven dat we willen opereren op de accumulator. We kunnen deze instructies ook gebruiken om direct waarden in het geheugen te manipuleren.
Om te beginnen laden we de waarde $01
in A
(in bits: b0000 0001
). Vervolgens shiften we naar links, en wordt A
dus verdubbeld naar $02
(b0000 0010
), $04
(b0000 00100
), $08
(b0000 1000
) et cetera, tot $80
(b1000 0000
) bij de enalaatste. De laatste ASL
shift nogmaals naar links, waardoor de laatste bit eraf valt, en de waarde in A
weer $00
is. De carry is dan 1
(en Z == 0
, want het resultaat van deze shift was $00
).
LSR
is precies omgekeerd, en schuift de bits naar rechts (en vult van links aan met 0
). Als we dus beginnen met $80
(b1000 0000
) kunnen we die bit met 8 LSR
s in de carry krijgen:
LDA #$80
STA $00
LSR $00
LSR $00
LSR $00
LSR $00
LSR $00
LSR $00
LSR $00
LSR $00
Dit doet het omgekeerde als bovenstaande voorbeeld (het ene bitje schuift dus naar rechts: b0100 0000
, b0010 0000
et cetera).
Voor de verandering (heb je de extra STA
op regel 2 gespot? en het gebruik van een geheugenadres in plaats van A
bij de LSR
's?) opereren we op een waarde in het geheugen in plaats van A
; die is dus aan het eind nog steeds $80
.
Rotaties werken iets anders: in plaats van dat de bits rechts of links aangevuld worden met 0
wordt de waarde uit de carry gebruikt.
LDA #$80
CLC
ROL A
ROL A
Hier beginnen we met bit 7 op 1
, en de carry op 0
. De ROL
"duwt" de 0
uit de carry aan de rechterkant in het register, en de 1
die er links "vanaf valt" wordt weer in C
gezet. Hierdoor wordt de waarde in A
dus $00
, en C
wordt 1
. De volgende ROL
voegt de 1
uit C
rechts toe, en zet de linker 0
in C
met als gevolg dat A == $01
, C == 0
.
Dit werkt natuurlijk ook de andere kant op met ROR
:
LDA #$01
CLC
ROR A
ROR A
Control flow en labels
Programma's zijn over het algemeen niet zo interessant als ze alleen maar kunnen optellen en aftrekken. Gelukkig zijn er ook instructies om de flow van je programma te beïnvloeden: de branch-instructies.
Labels
We hebben een aantal commando's om te springen in de code. De meest eenvoudige is JMP
(JuMP), die de program counter (PC
) verzet naar het opgegeven adres. De branch-instructies krijgen altijd een relatief adres mee, dat wil zeggen dat als er gebrancht wordt PC
veranderd wordt met het opgegeven aantal.
Het lastige is dat je dan precies moet weten waar naar toe gesprongen moet worden en eventueel hoeveel geheugenposities dat verschilt van de huidige positie. Je kan dit natuurlijk uittellen, maar dat is saai, foutgevoelig en lastig leesbaar. Het is veel handiger om dit door de assembler te laten doen. Hiervoor gebruiken we labels.
Labels zijn simpelweg een woord gevolgd door een dubbele punt op een eigen regel. Neem dit simpele voorbeeld (zonder label):
JMP $0605
LDA #$01
CLC
ADC #$01
Als je op de "Disassemble" knop drukt krijg je de volgende output:
Address Hexdump Dissassembly
-------------------------------
$0600 4c 05 06 JMP $0605
$0603 a9 01 LDA #$01
$0605 18 CLC
$0606 69 01 ADC #$01
De eerste instructie is een JMP
. Deze krijgt zoals gezegd een geheugenadres mee waarnaartoe gesprongen moet worden. In dit geval is dat $0605
, hij slaat dus de LDA #$01
over en gaat gelijk verder met de CLC
. Het uiteindelijke resultaat in A is dus $01
(vanwege de ADC $01
). Zonder de geheugenadressen links is dit dus echt ontzettend slecht leesbaar.
Met een label wordt het veel gemakkelijker:
JMP label
LDA #$01
label:
CLC
ADC #$01
Als je nu weer op Disassemble klikt zie je dat dit exact hetzelfde resultaat oplevert, maar voor jou als ontwikkelaar is het een wereld van verschil.
Flags
De branch-instructies gebruiken verschillende flags om te bepalen of ze al dan niet moeten branchen. We moeten daarom goed beseffen wat ze betekenen. In deze tutorial maken we ons niet druk om andere flags dan Carry en Zero, dus we behandelen ook niet alle branch-instructies in detail.
De carry flag hebben we al gebruikt bij het optellen en aftrekken hierboven. Deze wordt ook gebruikt bij bitshifts en -rotaties, en natuurlijk bij SEC
en CLC
.
De Zero flag wordt op 1
gezet als het resultaat van de laatste instructie 0
is. Dat geldt niet alleen voor de accumulator maar ook voor de andere registers. Ook als je een waarde in een register laadt die 0
wordt Z
op 1
gezet: LDX #$00
laadt de waarde $00
in X
en zet Z
op 1
.
CMP
Voordat we gaan branchen maken we eerst nog een kort uitstapje naar CMP
: CoMPare. De compare-instructie zet alle flags alsof de SEC
+ SBC
-instructies zijn uitgevoerd, maar past de waarde in A
niet aan.
Het resultaat is dus dat C
wordt gezet als A >= m
(m
is de waarde van CMP
) (en op 0
als A
kleiner is), en Z
wordt gezet als A
== waarde.
Voorbeeld $10 > $09
:
LDA #$10
CMP #$09
Resultaat: Carry is 1
, Zero is 0
.
Voorbeeld $10 == $10
:
LDA #$10
CMP #$10
Resultaat: Carry is 1
, Zero is 1
.
Voorbeeld $10 <= $11
:
LDA #$10
CMP #$11
Resultaat: Carry is 0
, Zero is 0
(je ziet ook dat de Negative flag hier wordt gezet, omdat het resultaat van $10 - $11
negatief is).
In alle gevallen blijft de waarde van A
gelijk aan $10
, omdat deze niet wordt beïnvloed door CMP
.
NB: vergeet niet dat je in plaats van een letterlijke waarde (met #
) mee te geven aan CMP
je ook diverse andere adresseringsmodi kan gebruiken (zie de documentatie) om A
te vergelijken met waarden die in het geheugen staan!
Branchen
Branchen (of aftakken) is niets meer en niets minder dan conditioneel een jump uitvoeren. Het is eigenlijk een if
-statement in zijn puurste vorm.
We beginnen met BEQ
en BNE
, deze betekenen respectievelijk Branch if EQual en Branch if Not Equal. Zoals de naam aangeeft gebruik je deze om te branchen als waarden wel of niet gelijk zijn aan elkaar, maar dat is niet precies wat er gebeurt. Zoals je een stukje terug hebt gelezen, wordt er namelijk gebrancht op basis van de flags, en dus niet op basis van of waarden al dan niet gelijk zijn.
Feitelijk wordt er bij BEQ
en BNE
gekeken naar de Zero flag, de flag die dus wordt gezet door CMP
als de waarden gelijk zijn. Neem het volgende voorbeeld:
LDA #$10
CMP #$10
BEQ equal
LDX #$01
equal:
LDY #$01
Natuurlijk is $10 == $10
, dus zal BEQ
branchen naar het label equal
en wordt de LDX
-instructie overgeslagen. Alleen Y
wordt op $01
gezet. Probeer zelf de waarden in LDA
en CMP
aan te passen, en verander BEQ
eens in BNE
.
Als je op Disassemble drukt zie je het label ook hier vervangen worden.
Address Hexdump Dissassembly
-------------------------------
$0600 a9 10 LDA #$10
$0602 c9 10 CMP #$10
$0604 f0 02 BEQ $0608
$0606 a2 01 LDX #$01
$0608 a0 01 LDY #$01
Onder "Disassembly" zie je het resultaat $0608
, dat is een bug in de disassembler: het is niet mogelijk om een absoluut adres op te geven bij de branch-instructies! De bytecode klopt wel: $f0
is de opcode voor BEQ
en $02
is de relatieve jump (+2 bytes na de huidige instructie van 2 bytes op $0604
).
Simpele loopjes
Je kan deze instructies natuurlijk ook ook gebruiken om te branchen als de Zero flag om een andere reden is gezet. Je kan dit bijvoorbeeld gebruiken om een loop te maken:
LDA #$05
LDX #$05
loop:
CLC
ADC #$02
DEX
BNE loop
We zetten zowel A
als X
hier op $05
. Vervolgens starten we een loopje, waarbij we A
verhogen met $02
, en DEX
(DEcrement X) gebruiken om X
met 1
te verlagen. Zolang X != 0
zal de BNE
-instructie naar loop
springen, maar als het loopje 5 keer is uitgevoerd is X == 0
en dus Z == 1
, waardoor BNE
niet meer brancht.
Als je deze code disassembled zie je dat BNE
hier terugspringt met 6 instructies: $fa
is het two's complement voor -6.
BCS en BCC
De twee laatste branch-instructies die we behandelen zijn BCS
(Branch if Carry Set) en BCC
(Branch if Carry Clear).
Zoals je las bij CMP
wordt C
gezet als de waarde in A >= m
. BCS
kan je dus gebruiken om te branchen als A >= m
en BCC
als A < m
.
Hier weer een voorbeeld, probeer het zelf weer aan te passen net als bij BEQ
en BNE
:
LDA #$10
CMP #$10
BCS larger_or_equal
LDX #$01
larger_or_equal:
LDY #$01
Ook hier geldt: je hoeft niet niet per sé na een CMP
-instructie te doen. Wellicht wil je branchen als het resultaat van een optelling groter was dan $ff
, of als bit 0 1
was na een ROR
(ROtate Right).
Overige branching-instructies
Naast BEQ
, BNE
, BCS
en BCC
zijn er nog een aantal branching-instructies:
BPL
– Branch on PLus (N == 0
)BMI
– Branch on MInus (N == 1
)BVC
– Branch on oVerflow Clear (V == 0
)BVS
– Branch on oVerflow Set (V == 1
)
Hier gaan we verder niet op in, maar check vooral de documentatie.
De 6502 heeft geen BRA
(BRanch Always) instructie, welke door veel andere processoren wel wordt aangeboden. Deze is meestal iets sneller dan het alternatief JMP
. Het is te emuleren (door bijvoorbeeld SEC
en BCS
te combineren), maar dat is slechts in zeer specifieke gevallen zinvol.
Subroutines
Het is ook mogelijk een subroutine aan te roepen met JSR
– Jump to SubRoutine. Dit is eigenlijk een soort JMP
, met het verschil dat PC
op de stack wordt gepusht. Aan het eind van je subroutine gebruik je vervolgens de instructie RTS
ReTurn from Subroutine.
LDA #$01
JSR add_one
JSR add_one
JSR add_one
JSR add_one
JMP end
add_one:
CLC
ADC #$01
RTS
end:
Ik raad je aan om hier het eind van de stack te monitoren: stel start in op $01f0
en length op $10
. Als je door de code stept pusth hij bij elke JSR
een adres op de stack (de laatste 2 bytes), en de SP
verlaagt met 2 tot $fd
. Bij RTS
wordt de stackpointer weer verhoogt naar $ff
(de waarden op de stack worden niet leeggemaakt).
De JMP end
is nodig, omdat hij anders na de laatste JSR
gewoon doorgaat met het uitvoeren van de CLC
uit de subroutine, want alle instructies staan gewoon achter elkaar in het geheugen. Als je die weglaat, zal hij uiteindelijk bij RTS
uitkomen en een waarde van de stack proberen te pullen met als gevolg dat SP
underflowt naar $01
.
Het adres wat op de stack wordt gepusht door JMP
is feitelijk het adres van de laatste byte van de instructie. RTS
pullt dat adres, telt er 1 bij op (zodat het het adres wordt van de eerste byte van de volgende instructie) en gebruikt dat om PC
te zetten.
Deel 3
In het volgende deel van deze serie gaan we deze basiskennis inzetten om een aantal interessante stukjes code in elkaar te zetten. Hopelijk heb je er zoveel zin in dat je tot die tijd zelf vast wat andere instructies gaat uitproberen!