Lekker Low Level 2: Eerste stapjes

Techniek

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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 LSRs in de carry krijgen:

  LDA #$80
  STA $00
  LSR $00
  LSR $00
  LSR $00
  LSR $00
  LSR $00
  LSR $00
  LSR $00
  LSR $00

Probeer het zelf

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

Probeer het zelf

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:
asm
LDA #$01
CLC
ROR A
ROR A

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

Resultaat: Carry is 1, Zero is 0.

Voorbeeld $10 == $10:

  LDA #$10
  CMP #$10

Probeer het zelf

Resultaat: Carry is 1, Zero is 1.

Voorbeeld $10 <= $11:

  LDA #$10
  CMP #$11

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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

Probeer het zelf

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:

Probeer het zelf

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!

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