Lekker Low Level 3: Dieper water

Techniek

In de vorige twee delen van deze serie hebben we de MOS Technology 6502 geïntroduceerd en wat eenvoudige assembly geschreven voor deze processor.

In deze blog gaan we dieper in op assembly voor deze processor, en gebruiken we nieuwe eigenschappen van de assembler.

Speciale instructies

Er zijn een aantal instructies die afhankelijk zijn van je specifieke assembler. De assembler in onze simulator is vrij beperkt, maar toch hebben we hier ook wel wat handigs tot onze beschikking.

Daarnaast hebben we nog een “debug”-instructie, die alleen maar op een virtuele machine zoals de onze kan werken.

define

De define-instructie is gerelateerd aan labels. Waar een label automatisch de waarde krijgt van het geheugenadres van de volgende instructie, kan je die met define zelf bepalen.

define one $01
define two $02
  LDA #one
  STA two

Probeer het zelf

Hier zetten we de waarde $01 in het label one, en $02 in two. Met de LDA-insructie laden de we letterlijke waarde $01 in A – let erop dat je hier dus nog steeds de # voor moet zetten. We slaan deze waarde vervolgens op in het geheugen op positie $02.

Origin

Origin gebruik je om de location counter van de assembler in te stellen. Standaard begint deze (in onze simulator) op $0600. Hier wordt de instructie heengeschreven, en de location counter wordt verhoogt met het aantal bytes dat de instructie kostte. Bijvoorbeeld na LDA #$00 is de location counter $0602, omdat deze instructie uit 2 bytes bestaat.

Soms wil je deze handmatig beïnvloeden, omdat je bijvoorbeeld een datablock (zie hieronder) wil wegschrijven, of een subroutine op een specifieke plek in het geheugen wil zetten.

In onze assembler gebruiken we hiervoor *=. In andere assemblers wordt ook wel alleen de asterisk gebruikt, of de instructie ORG.

  JMP $0800 

*=$0900
  LDA #$01

*=$0800
  JMP $0700

*=$0700
  JMP $0900

Probeer het zelf

In dit (verder niet super-overzichtelijke) voorbeeld zie je dat er op diverse plekken in het geheugen een JMP instructie wordt geplaatst. Ze hoeven dus ook niet op volgorde te staan.

DCB

Met DCB (Data Control Block) kan je bytes in het geheugen schrijven. Na DCB volgen een aantal door komma’s gescheiden bytes die je in het geheugen wil schrijven.
Zo kan je LDA #$01 ook schrijven als:

  DCB $a9, $01

Probeer het zelf

Natuurlijk is dit zinniger om te gebruiken voor datablokken. Dit is bij uitstek een goede combinatie met de origin-instructie die hierboven is beschreven:

*=$0000
  DCB $00, $01, $02, $03, $04, $05, $06, $07
  DCB $08, $09, $0a, $0b, $0c, $0d, $0e, $0f

Probeer het zelf

Druk op “Run” om de memory monitor te updaten en het resultaat te zien.

Vergeet niet om je location counter weer terug te zetten naar $0600 voordat aan je programmacode begint, want dat is waar de waar de vm begint met uitvoeren van instructies.

NB: de reset-functie van de simulator reset al het geheugen van $0000 - $05ff, dus als je een datablock zoals hier in de zero page zet zal dit ook worden leeggemaakt als je op reset drukt. Dat is niet per sé handig als je je programma twee keer achter elkaar wil draaien.

Alternatieven voor deze instructie in andere assemblers zijn (onder andere) .BYTE, DB of DFB. Sommige assemblers kunnen ook words wegschrijven (bv .WORD), strings of zelfs complete sprites op basis van afbeeldingbestanden.

WDM

De instructie WDM (Write Debug Message) is behoorlijk speciaal. Hiermee kan je informatie naar de debugconsole wegschrijven, en dat in slechts 1 instructie! Dat komt omdat deze eigenlijk in de Javascript-VM is gehackt, en het is dus geen instructie die je terugvindt in andere machines.

De eerste variant is WDM 0: als je deze gebruikt wordt waarde van A geïnterpreteerd als een ASCII-code en weggeschreven naar de debugconsole.

  LDA #$41
  WDM 0

Probeer het zelf

Hier wordt het karakter ‘A’ geschreven – ASCII-waarde $41 (65).

In de simulator bevat het geheugenadres $ff de ASCII-waarde van de laatst ingedrukte toets. We kunnen dit gebruiken om naar de messages-console te typen:

loop:
  LDA $ff
  BEQ loop
  WDM 0
  LDA #$00
  STA $ff
  JMP loop

Probeer het zelf

Eerst wordt de meest recente aanslag geladen. Als deze gelijk is aan $00 wordt er gelijk weer naar loop gesprongen. Als er wel een waarde in staat, wordt deze weggeschreven met WDM 0. We zetten daarna de waarde $00 in $ff, zodat we niet hetzelfde karakter blijven printen, en springen weer naar loop. Zodra er weer een toets wordt ingedrukt verandert de waarde van $ff en wordt deze weer afgedrukt.

Naast WDM 0 is er ook WDM 1: hierbij wordt een dump van de processorstatus naar de debugconsole geschreven:

  LDA #$10
  LDX #$20
  LDY #$30
  SEC
  WDM 1

Probeer het zelf

Als je dit uitvoert en in de console kijkt zie je het volgende:

A: 16 ($10, b10000)
X: 32 ($20, b100000)
Y: 48 ($30, b110000)
SP: $ff
PC: $609
NV-BDIZC: 00-10001

Ik ben hier nog niet helemaal tevreden over, dus wellicht is dit iets veranderd in de simulator tegen de tijd dat je dit zelf probeert.

Overige instructies

We hebben nog niet alle instructies behandeld, maar wel de meest voorkomende en ingewikkelde. Kijk vooral eens de documentatie door en houd de Cheat Sheet bij de hand voor een overzicht van alle instructies en bijbehorende addresseringsmodi.

Adresseringsmodi

In deel 1 zijn de verschillende adresseringsmodi al kort geïntroduceerd. Een aantal hebben we ook al in de praktijk gezien, maar de meer geavanceerde nog niet. Daar gaan we nu verandering in brengen.

Eerst laten we de modi die we al gebruikt hebben even de revue passeren:

  • Implicit
    Hierbij wordt geen adres opgegeven.
  • Immediate
    We zetten een # voor het “adres” om een letterlijke waarde aan te duiden.
  • Zero Page Absolute
    Een enkele byte die verwijst naar een adres in het zero page geheugen.
  • Absolute
    Een volledig 16 bit adres.
  • Relative
    Een relatief adres: we gebruiken hier normaal labels.
  • Accumulator
    Gebruik A als adres om de accumulator te gebruiken.

De volgende modi zijn wat complexer, maar daarom ook veel leuker!

Zero Page,X/Y

We gaan nu de index-registers (X en Y) gebruiken als index! Hiermee kunnen we dus een wisselend adres aanspreken met dezelfde instructie.

Door deze manier van adresseren te gebruiken, wordt de waarde van het betreffende register opgeteld bij het opgegeven adres voordat het geheugen wordt benaderd.

Zie dit voorbeeld om een null-terminated string af te drukken in de debugconsole:

*=$0000
  DCB $48, $65, $6c, $6c, $6f, $20, $57, $6f, $72, $6c, $64, $21, $00

*=$0600
  LDX #$00

loop:
  LDA $00,x
  BEQ end
  WDM 0
  INX
  BNE loop

end:
  LDA #$0a
  WDM 0

Probeer het zelf

Hier wordt eerst de string “Hello World!” weggeschreven in het geheugen, beginnend op adres $00 en eindigend op adres $c0 met de waarde $00. We willen deze uitlezen, beginnend bij het eerste karakter, tot de $00-waarde wordt bereikt. We zetten dus ook onze index X op $00 voordat we aan de loop beginnen.

De loop begint met LDA $00,X. Nu wordt uit het geheugen gelezen op positie $00 + X. X is op dit moment $00, dus het resulterende adres is… $00. Deze waarde is $48 en wordt geprint (WDM 0). X wordt verhoogd middels INX (INcrease X).
Nu X dus $01 is leest LDA $00,X de waarde $65 van adres $00 + $01 = $01. Dit gaat zo door, tot dat X == $c0 (12): dan is de waarde in het geheugen $00 en wordt de loop beëindigd en $0a (een line feed) naar de debugconsole geprint.

Deze adresseringsmodus bestaat ook met het Y-register. Deze kan je alleen maar gebruiken met LDX en STX en zul je dus niet veel gebruiken.

NB: Als het resultaat van de optelling groter is dan $ff wordt alleen de 8 laagste bits gebruikt, met andere woorden als je X == $12 hebt en LDA $f0,X gebruikt, is het resultaat $02 en niet $102. Dit wordt wrap-around genoemd, en je ziet het in het volgende voorbeeld geïllustreerd:

*=$0000
  DCB $00, $01, $02, $03, $04, $05, $06, $07
  DCB $08, $09, $0a, $0b, $0c, $0d, $0e, $0f

*=$0600
  LDX #$12
  LDA $f0,x

Probeer het zelf

Je ziet dat hij de waarde $02 in het geheugen laadt, die komt dus uit het geheugen op adres $02.

Mocht je de wrap-around bewust gebruiken dan raad ik je aan het goed te documenteren, want het is niet voor de hand liggend dat je wil dat dat gebeurt.

Als je hier geen last van wil hebben (en in het stack-geheugen uit wil komen – het is niet per sé een goed idee) kan je gebruik maken van de Absolute,X/Y modus.

Absolute,X/Y

Deze adresseringsmodus is hetzelfde als de bovenstaande, alleen kan je al het geheugen benaderen in plaats van alleen de zero page. We kunnen vrijwel hetzelfde voorbeeld gebruiken:

*=$0000
  DCB $48, $65, $6c, $6c, $6f, $20, $57, $6f, $72, $6c, $64, $21, $00

*=$0600
  LDX #$00

loop:
  LDA $0000,x
  BEQ end
  WDM 0
  INX
  BNE loop

end:
  LDA #$0a
  WDM 0

Probeer het zelf

Je hebt hier zoals gezegd geen last van de zero page wrap-around; het volgende voorbeeld laadt de waarde uit $0102:

*=$0000
  DCB $00, $01, $02, $03, $04, $05, $06, $07
  DCB $08, $09, $0a, $0b, $0c, $0d, $0e, $0f

*=$0102
  DCB $ff

*=$0600
  LDX #$12
  LDA $00f0,x

Probeer het zelf

Voor zover ik heb na kunnen gaan heb je ook hier last van wrap-around, maar dan op de laagste 16 bits in plaats van 8. Dit wordt echter door de simulator niet goed verwerkt (die telt gewoon door, dus als je echt wil zou je hier 256 bytes extra geheugen kunnen benutten).

Indirect

De indirecte adresseringsmodus wordt alleen ondersteund door JMP. Het opgegeven adres is een pointer naar een ander adres, waar uiteindelijk de waarde staat waar naar gejumpt wordt. Dit schrijf je als JMP ($1234).

Dit kan je gebruiken als het jump-adres niet vast ligt op het moment dat je de code schrijft.

Omdat je hier een geheugenadres van 16 bits in het geheugen wil opslaan heb je 2 bytes nodig. Het adres dat je opgeeft in de instructie ($1234 in het voorbeeld hierboven) is het adres van de eerste byte, de tweede byte haalt hij dan op uit het volgende adres ($1235).
Omdat de 6502 is little endian is (zie deel 1), komt de lage byte op het eerste adres, en de hoge byte op het volgende.

  LDA #$cd
  STA $1234

  LDA #$ab
  STA $1235

  JMP ($1234)

*=$abcd
  LDA #$01

Probeer het zelf

Je ziet dat we hier het adres $abcd opslaan in het geheugen als $cd $ab op lokate $1234 en de opvolgende $1235. Vervolgens kan de JMP-instructie hiernaar toe jumpen.

In de meeste NMOS hardware-implementaties zit een bug als je een adres wil lezen dat over een page boundary valt. Stel: je doet JMP ($0FFF), dan zou je verwachten dat hij de lage byte leest $0FFF en de hoge byte van $1000, maar hij leest de hoge byte van $0F00.

Indexed Indirect

Nu wordt het écht leuk met de Indexed Indirect en Indirect Indexed modi. De namen zijn een beetje verwarrend, net als het volgen van wat precies doen. Maar als je er een beetje mee speelt snap je ze al snel. Beide instructies werken alleen in het zero page-geheugen.

Het indirecte slaat hier ook op het feit dat er een adres uit het geheugen wordt gelezen, wat vervolgens gebruikt wordt voor de definieve lokatie in het geheugen waar je moet zijn.

We beginnen met Indexed Indirect. Hierbij wordt de waarde van X opgeteld bij het opgegeven adres voordat het uitgelezen wordt (het werkt niet met Y). Deze adresseringsmodus is bijvoorbeeld handig als je een array van pointers hebt naar structs elders in je geheugen, maar we houden het bij een eenvoudiger voorbeeld, waarbij we alleen maar een paar waardes uitlezen.

*=$0004
  DCB $cd, $ab, $34, $12

*=$abcd
  DCB $34 ; ascii voor 4

*=$1234
  DCB $32 ; ascii voor 2

*=$0600
  LDX #$00
  LDA ($04,x)
  WDM 0

  INX
  INX
  LDA ($04,x)
  WDM 0

Probeer het zelf

We hebben hier 2 adressen opgeslagen in het geheugen: $abcd op positie $04 en $1234 op positie $06. Op die adressen hebben we ook twee waarden opgeslagen.

Eerst laden we $00 in X, dus LDA ($04,X) leest het adres $abcd van $04 + $00 = $04, en laadt de waarde die daar staat ($34) in A. Deze wordt dan naar de debugconsole geprint.

Vervolgens verhogen we X twee keer tot $02, dus LDA ($04,X) leest het adres $1234 van $04 + $02 = $06. De waarde die daar staat ($32) wordt ook nu weer in A geladen en met WDM naar de debugconsole geprint.

Als het goed is wordt “42” geprint in de debug console.

NB: We kunnen hier te maken krijgen met 2 vormen van wrap-around! Ten eerste is er de zero page wrap-around die je al bij de andere indexed-modi hebt gezien: als het adres in de instructie + X groter is dan $ff worden alleen de laagtste 8 bits gebruikt:

*=$0004
  DCB $cd, $ab

*=$abcd
  DCB $34

*=$0600
  LDX #$ff
  LDA ($05,x)

Probeer het zelf

Hierbij is de optelsom $05 + $ff = $104, maar wordt er gelezen van $04.

Ten tweede kan het voorkomen dat je een adres wil lezen van $ff (als het adres in de instructie + X gelijk is aan $ff), zoals in dit voorbeeld:

*=$00ff
  DCB $cd, $ab

*=$0000
  DCB $12

*=$abcd
  DCB $01

*=$12cd
  DCB $02

*=$0600
  LDX #$00
  LDA ($ff,x)

Probeer het zelf

Van wat ik heb kunnen vinden, moet de hoge byte van het adres worden genegeerd. In dat geval zou je verwachten dat hij het adres $12cd zou gebruiken (en het LDA commando dus $02 in A moet laden). In onze simulator zie je echter dat hij de hoge byte ook verhoogt, en het adres $abcd leest (resulterent in $01 in A).

Dit is dus een prima situatie om te vermijden!

Indirect Indexed

De laatste adresseringsmodus die we hebben is Indirect Indexed.

Hierbij wordt eerst op de inmiddels bekende indirecte wijze het te adres opghaald uit het geheugen. Daar tel je vervolgens de waarde van Y bij op (en dit werkt niet met X). Dit kan je bijvoorbeeld gebruiken als je een pointer naar een struct hebt (maar ook hier houden we het voorbeeld wat simpeler).

*=$0004
  DCB $30, $12

*=$1230
  DCB $34 ; ascii voor 4

*=$1234
  DCB $32 ; ascii voor 2

*=$0600
  LDY #$00
  LDA ($04),y
  WDM 0

  LDY #$04
  LDA ($04),y
  WDM 0

Probeer het zelf

Hier wordt altijd gelezen van adres $04, het adres wat daar staat is $1230. Bij de eerste LDA, waarbij Y == $00 is dat ook het definitieve adres (want $1230 + $00 = $1230). Bij de tweede LDA is Y == $04, dus is het definitieve adres $1230 + $04 = $1234. Hier wordt ook weer “42” in de debug console geprint.

NB: hier hebben we geen last van de zero page wrap-around, maar nog wel steeds van de situatie waarbij het adres gelezen wordt van $ff (dus LDA ($ff), Y voor alle waarden van Y). Ik weet ook hier niet hoe hier mee om gegaan moet worden.

Ik weet ook niet wat de situatie is als het gelezen adres + Y groter is dan $ffff; het enige wat ik weet is dat de simulator er hetzelfde mee omgaat als bij absolute,x/y, wat dus in ieder geval niet goed is – er zullen op echte hardware niet spontaan 255 nieuwe bytes ontstaan!

Deel 4

We hebben nu al een heleboel leuke dingen gedaan. In het volgende deel gaan we het in de praktijk brengen, en beginnen we aan een implementatie van Pong!

Een afspraak maken bij ons op kantoor of wil je even iemand spreken? Stuur ons een mail of geef een belletje.