Lekker Low Level 1: Introductie

We zijn tegenwoordig ontzettend verwend met onze high level talen en dikke, vette standaardlibraries. Met drie regels code heb je al een webserver draaien. Hartstikke leuk en handig, maar wat gebeurt er allemaal op die processor?

Om antwoord te krijgen op die vraag gaan we voor deze blogserie terug in de tijd naar de vroege jaren ‘80, toen de MOS Technology 6502 heer en meester was in thuiscomputerland. Deze goedkope processor (en verwanten) vond je in verschillende Commodores (waaronder de geliefde C64), de Apple II en spelcomputers als de Atari 2600 en de NES. Hij is een ideale kandidaat als je wil beginnen met assembly, want door zijn populariteit en eenvoud is er veel documentatie over te vinden.

In dit eerste deel introduceren we de processor en verkennen we hoe hij werkt.

Teaser

We zijn er nog niet, maar… aan het eind van deze serie hebben je een werkende implementatie van Pong geschreven in assembly en weet je precies hoe hij werkt (en vast ook hoe het beter kan!). Dat ziet er dan zo uit:

Bits en bytes: wat moet je weten?

We gaan in deze blogserie heel dicht tegen de hardware aan programmeren. Dit betekent dat we heel veel bezig gaan met bits en en bytes. Ik ga er even heel snel doorheen, als je behoefte hebt aan meer detail raad ik je aan het internet te raadplegen, die kunnen dat namelijk veel beter dan ik. Er zijn vele tutorials te vinden, bijvoorbeeld deze van Sparkfun.

Binair

Omdat in computers alles met elektrische stroompjes verloopt, is het het makkelijkst om binair te rekenen en data op te slaan (met als belangrijke uitzondering modern Flash-geheugen). Dit heeft als gevolg dat computers met het tweetallig talstelsel rekenen: binair dus. Wiskundig gezien maakt het talstelsel waarin je rekent maar weinig uit, alleen als de basis lager is heb je meer posities nodig om eenzelfde getal te representeren.

In het ons gebruikelijke tientallige talstelsel hebben we tien verschillende cijfers, namelijk de cijfers 0 tot en met 9. Als we 1 bij 9 willen optellen, wordt de 9 een 0 en zetten we er een 1 voor, en dan krijgen we 10. Na 19 komt 20 en na 99 komt 100 – afijn, je weet hoe het werkt. Je doet dit immers al sinds de basisschool.

In het binaire talstelsel hebben we maar 2 cijfers: 0 en 1, en dus komt er na de 1 geen 2, maar 102. Daarna komt 112, en dan al zitten we al op de 1002, wat dus heel wat minder is dan 100 in het decimale talstelsel.

Hexadecimaal

Omdat de lengte van de getallen zo snel oploopt, is het voor ons superlastig om een goed besef te krijgen van de waarde van binaire getallen. Ook het omrekenen naar decimaal is niet iets wat iedereen goed in zijn of haar hoofd kan. Daarom hebben we daar een foefje op verzonnen: het hexadecimale (zestientallige) talstelsel. Hier hebben we 16 cijfers tot onze beschikking in plaats van 10. We zijn natuurlijk gewend aan maar 10 unieke symbolen, daarom tellen we na 9 door met de letters a tot en met f.

Omrekenen van binair naar hexadecimaal vrij eenvoudig omdat 16 een macht van 2 is, en het ligt ook niet zó ver bij onze decimale belevingswereld vandaan dat het compleet onbevattelijk is. Met een enkel hexadecimaal cijfer kunnen dus een waarde van 0 tot en met 15 uitdrukken, wat binair een getal is van 02 tot en met 11112.

Een overzicht van deze eerste 16 waardes:

dec  hex bin
 0   0   0000
 1   1   0001
 2   2   0010
 3   3   0011
 4   4   0100
 5   5   0101
 6   6   0110
 7   7   0111
 8   8   1000
 9   9   1001
10   a   1010
11   b   1011
12   c   1100
13   d   1101
14   e   1110
15   f   1111

Het fijne hiervan is ook dat je met een hexadecimaal getal van 2 cijfers een volledige byte (8 bits) kan uitdrukken: 111111112 = ff16 = 255.

Notatie en naamgeving

In het stuk hierboven heb ik de basis van het talstelsel (indien niet decimaal) aangegeven met een subscript, wat vrij gebruikelijk is om te doen in de wetenschappelijke literatuur. Hoewel dat echt superduidelijk is, is het niet zo handig want het is typografisch en dus niet iets wat zo in te typen is.

In programmacode voorzien we binaire en hexadecimale doorgaans van een prefix. Meestal wordt tegenwoordig 0x gebruikt voor hexadecimale getallen, hoewel je ook wel x en # ziet (dus 0xabcd, xabcd en #abcd). Vroeger was $ meer gebruikelijk ($abcd), en dat is ook wat er gebruikt werd in de documentatie van de 6502 en dientengevolge ook in alles wat je er tegenwoordig nog van vindt. Dat gebruiken wij dus ook.

In navolging van 0x en x worden vaak 0b en b gebruikt voor binaire getalen: 0b1010, b1010. Daarnaast worden binaire getallen vaak gegroepeerd in groepjes van 4 bits om de leesbaarheid te vergroten. Een groepje van 4 bits noemen we een nibble (en 8 bits een byte). Vaak worden ook de nullen aangevuld tot de eerstvolgende nibble, of een ander aantal wat logisch is in de context. Daarom staat in het lijstje hierboven dus ook 0001 in plaats van simpelweg 1.

Hoog en laag

Wat je ook wel tegen zal komen zijn “hoge” en “lage” bytes (en soms nibbles). Dit heeft te maken met het opsplitsen van een getal. Als je bijvoorbeeld een getal hebt wat uitgedrukt kan worden in 2 bytes/16 bits (bijvoorbeeld: $abcd), dan kan je dat opsplitsen in 2 bytes ($ab en $cd). De linker byte ($ab) is de hoge byte (want die vertegenwoordigt zijn waarde * $100: $ab * $100 = $ab00), en de rechter ($cd) de lage. Hetzelfde geldt voor nibbles: $ab bestaat uit de hoge nibble $a (waarde $a * $10 = $a0) en de lage $b. In feite is het niet anders dan dat het decimale getal 42 bestaat uit een “hoge decimaal” 4 (waarde 4 * 10) en de “lage decimaal” 2.

Endianness

De 6502 is een Little Endian processor. Dit betekent dat als hij een waarde van meer dan 1 byte verwerkt, hij de lage byte wegschrijft (of leest) voor de hoge byte(s). Als hij bijvoorbeeld het geheugenadres $abcd in het geheugen zet, schrijft hij eerst $cd weg en daarna $ab; in het geheugen staat dan $cd $ab – eigenlijk precies verkeerd om. Dit werkt handiger bij bijvoorbeeld optellen, omdat je begint bij de laagtste byte (we zien dit later).

Het tegenovergestelde is Big Endian, waarbij het bovenstaande adres wel als $ab $cd in het geheugen zou staan. Ook zijn er nog tussenvormen (ja echt), maar ik zal je die hoofdpijn besparen.

De 6502: een relatief eenvoudige processor

De 6502 is ontwikkeld als een significant goedkopere processor dan de concurrentie, en is met name afgeleid van de Motorola 6800. Dat op zich is een aardig stukje geschiedenis waar waarschijnlijk hele boeken over zijn geschreven (begin eens op Wikipedia als je dat interessant vindt). Hij kon dan ook weer niet superveel, maar wel genoeg. En dat was inderdaad genoeg – ze waren razend populair en… ze worden nog steeds gemaakt!

De 6502 heeft slechts 3 registers (allemaal 8 bit) en 56 instructies om alles mogelijk te maken. Dat staat in schril contrast tot bijvoorbeeld de ook zeer populaire Z80, met 20 8-bit registers en 4 16-bit, en maar liefst 252 instructies. Toch is dit genoeg om leuke dingen te maken, en is instappen vrij gemakkelijk.

Registers

Zoals gezegd heeft de 6502 3 registers van 8 bits. Een register is een klein stukje geheugen op de processor, waarmee berekeningen kunnen worden uitgevoerd.

  • De accumulator A: voor de belangrijkste berekeningen
  • De index-registers X en Y: voornamelijk handig bij loopjes, tijdelijke waarden en indexen dus

Verder zijn er nog de 16 bit Program Counter PC, die het adres van de eerstvolgende instructie bevat (dus niet de huidige) en de Stack Pointer SP die verwijst naar de eerstvolgende lege plek in de stack.

Als laatste zijn er nog 7 flags, waar we in deze blogserie alleen de Carry en Zero gebruiken.

  • Carry C: of er na een berekening nog een 1 over is/nodig was
  • Zero Z: als het resultaat van de laatste berekening 0 was

Verder zijn er nog IRQ Disable, Decimal Mode, Break Instruction, Overflow en Negative.

Geheugen

De 6502 kan 64kB aan geheugen adresseren. Het geheugen is opgedeeld in 256 pagina’s van 256 bytes. Oftewel, de hoge byte van een adres is de pagina en de lage byte het adres in die pagina.

De eerste 2 pagina’s in het geheugen krijgen een speciale behandeling van de processor. Hoe de rest van het geheugen gebruikt wordt ligt aan de implementatie van de machine. Zo gebruiken wij straks pagina’s 2-5 voor het “beeldscherm” en zijn pagina’s 6+ vrij in te vullen.

    Pagina   Adressen
    $00      $0000 - $00ff   Zero page
    $01      $0100 - $01ff   Stack
    $02      $0200 - $02ff   Beeldschermgeheugen
    $03      $0300 - $03ff            "
    $04      $0400 - $04ff            "
    $05      $0500 - $05ff            "
    $06      $0600 - $06ff   Programmacode (executie start op $0600)
    $07      $0700 - $07ff   
    ...
    $fe      $fe00 - $feff
    $ff      $ff00 - $ffff

Pagina 0: de Zero Page

Om te compenseren voor het beperkte aantal registers, zijn er speciale adresseringsmodi voor de eerste pagina in het geheugen. Hierbij laat je de hoge byte achterwege, dus is de instructie 1 byte korter dan een vergelijkbare die het hele geheugen kan aanspreken maar ook niet geheel onbelangrijk is de geheugentoegang 1 klokcyclus sneller dus kost de instructie 1 cyclus minder tijd om uit te voeren. Daarnaast zijn er een aantal features die met de ZP kunnen werken die je bij andere processoren alleen met registers kan gebruiken.

Pagina 1: de Stack

De 6502 heeft één hardware-stack, en deze leeft op pagina 1 in het geheugen. De stack pointer (SP) is maar 8 bits, dus de stack vind je van $0100 - $01ff. De SP begint overigens op $ff en telt af, dus als je de eerste waarde op de stack pusht komt die op $01ff, en wordt SP $fe.

Het is ook mogelijk om decimale waarden op te geven, door het dollarteken over te slaan, dus $80 is hetzelfde als 128. De assembler doet hier de interpretatie; sommige kunnen bijvoorbeeld ook omgaan met binaire of octale getallen. Voor de uiteindelijke code maakt dat niet uit, het is puur een hulpmiddel voor de programmeur.

Instructies

Instructies voor de processor bestaan uit een opcode en eventueel een adres of letterlijke waarde (dat noemen we ook een adres, hoewel het dat technisch niet helemaal is). Zo gebruik je $a9 om een letterlijke waarde in de accumulator (het rekenregister) te laden, bijvoorbeeld $a9 $80 zorgt ervoor dat de waarde $80 in de accumulator geladen wordt. Een andere opcode, $a5 gebruik je om een waarde uit het zero page geheugen te laden. $a5 $80 bijvoorbeeld laadt de waarde uit geheugenadres $0080 in de accumulator.

Nu al in de war? Assembly gaat ons helpen!

Als je nu al in de war bent: geen nood! We maken het ons (iets) makkelijker met assembly. Assembly is een taal die 1:1 vertaalt naar machinecode, maar die wel iets beter leesbaar is. Er worden namelijk zogenaamde “mnemonics” gebruikt om de instructies te beschrijven. Mnemonics vertaalt in het Nederlands ongeveer naar ezelsbruggetje.

De voorgenoemde twee opcodes bijvoorbeeld laden namelijk beide een waarde in A, en hebben daarom in assembly dezelfde mnemonic: LDA voor LoaD Accumulator. Zo hoef je niet de verschillende opcodes te onthouden. Om onderscheid te maken tussen de verschillende adresseringsmodi geven de we argumenten op verschillende manieren op. Dit samen levert veel leesbaardere code op.

Zo schrijf je $a9 $80 in assembly als LDA #$80, en $a5 $80 als LDA $80. Het verschil is subtiel (alleen het #), maar wel duidelijk en expliciet.

Aan de andere kant is er geen enkele vorm van abstractie, dus bijvoorbeeld een for (int i = 0; i < 10; i++) zit er niet in: dat moet je helemaal handmatig schrijven.

Adresseringsmodi

De 6502 heeft een aantal verschillende adresseringsmodi. Verschillende instructies kunnen met verschillende adresseringsmodi gebruikt worden. De adresseringsmodi zijn zo’n beetje het meest ingewikkelde concept wat je moet begrijpen, daarom beschrijven we ze nu kort en gaan we er later nog een op in, maar dan wat dieper en met voorbeelden.

Als je dit overzicht leest zal je wellicht opvallen dat de argumenten bij sommige adresseringsmodi er hetzelfde uitzien. Zo heb je bijvoorbeeld LDA $01 (zp absolute) en BEQ $01 (relative) – hoe weet de assembler dan wat je wil? Dat is simpel: er is geen relatieve variant van LDA en van BEQ is alleen een relatieve variant.

Dat is een beetje verwarrend, maar als je straks in de praktijk bezig bent zal je merken dat het meeste vanzelf komt.

  • Implicit
    Sommige instructies gebruiken geen adres, maar de instructie maakt impliciet al duidelijk waarop geopereerd wordt. Bijvoorbeeld INX (INcrease X) verhoogt de waarde van het X-register met 1.

  • Accumulator
    Sommige instructies kan je op een geheugenadres loslaten, maar ook op de accumulator. In dat laatste geval geef je in plaats van een geheugenadres de letter A op; dus ASL A doet een left shift op de accumulator en ASL $80 doet een left shift op de waarde op geheugenadres $80.
    NB: in de assembler die wij gaan gebruiken in deze tutorial kan je de A ook achterwege laten, en ASL schrijven (net als implicit).

  • Immediate
    Immediate is de letterlijke waarde die we al gezien hebben. Hiervoor zetten we een # voor het adres, bijvoorbeeld LDA #$80 om de waarde $80 in A te laden.

  • Zero Page Absolute
    Een absoluut adres in de zero page. Zo laadt LDA $80 de waarde uit geheugenadres $0080 in de accumulator. Hier staat dus géén # voor.

  • Absolute
    Als je een waarde uit het geheugen wil benaderen dat niet in de zero page staat, gebruik je het volledige adres; bijvoorbeeld LDA $1234 om de waarde uit geheugenadres $1234 te laden.
    NB: je kan ook LDA $0080 gebruiken, op deze manier benader je dus adres $0080 in de zero page, maar niet met de Zero Page Absolute adresseringsmodus, dus dit duurt een klokcyclus langer.

  • Relative
    De branchinginstructies gebruiken allemaal relatieve adressen. Hierbij wordt het adres (8 bit) als signed integer behandeld. Je kan dus -128 tot +127 posities verspringen. Je kan dit handmatig doen, maar meestal zal je labels gebruiken en de assembler hierover laten nadenken voor je.
    NB: program counter staat altijd op het adres van de volgende instructie, dus je moet ook rekening houden met de grootte van de huidige instructie (of gebruik labels en bespaar jezelf de moeite).

  • Zero Page,X/Y
    Nu wordt het wat ingewikkelder. We gaan er nu niet te diep op in, dat doen we in een volgende aflevering waarin we daadwerkelijk code gaan schrijven.
    Met Zero Page,X en Zero Page,Y adressering wordt de waarde van het genoemde index-register opgeteld bij de waarde die je de instructie meegeeft. Dit schrijf je als LDA $80,x.

  • Absolute,X/Y
    Dit is hetzelfde als bovenstaande, maar dan met een volledig geheugenadres in plaats van zero page. Dit schrijf je als LDA $1234,x.

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

  • Indexed Indirect, Indirect Indexed
    Als laatste zijn er nog Indexed Indirect (LDA ($12,X)) en Indirect Indexed (LDA ($34),Y).

Deel 2

Als het je nu een beetje duizelt na al deze informatie: niet gevreesd want in de volgende delen gaan we deze kennis in de praktijk brengen. Je zal zien dat het simpeler is dan het lijkt als je het eenmaal aan het typen bent!

Wil je iets waarmaken met Infi?

Wil jij een eigen webapplicatie of mobiele app waarmee jij het bij anderen maakt?

Waargemaakt door de nerds van Infi.
Nerds met liefde voor softwareontwikkeling en die kunnen communiceren. En heel belangrijk: wat we doen, doen we met veel lol!

Wij willen het fixen. Laat jij van je horen?