Leren van assembleertaal
Assembleertaal is het door mensen leesbare equivalent van het laagste softwareniveau van computerprogrammering – machinecode.
Weliswaar begrijpt de computer alle programma’s als getallen, waarbij verschillende getallen de computer opdragen verschillende bewerkingen uit te voeren, maar dit is te eentonig voor menselijke consumptie (laat staan voor auteurschap). Daarom programmeren mensen met behulp van assembleertaal, die een bijna 1:1 overeenkomst heeft met machinecode.
Leer de programmeertaal C. Als u C (of C++) kent, zal assemblage gemakkelijker zijn.
C# en Java zijn goede talen, maar er zijn weinig tot geen hulpmiddelen om u te laten zien hoe C# en Java code er in assembleertaal uitziet. (In feite hebben Java en C# hun eigen byte-code assembleertaal die nogal verschilt, en ver afstaat, van echte hardware CPU instructieset architecturen.)
Van C (en C++) code kunnen we de compilers gebruiken om ons te laten zien hoe de assembleertaal eruit ziet. C is een taal op laag niveau, en als je weet hoe elk construct in C zich vertaalt in assembleertaal, dan ken je assembleertaal! Voorbeelden van de C constructen die we willen kennen voor assembleertaal zijn:
C Construct | Assembly Language Equivalent |
---|---|
local variable | CPU register of stack memory location |
global variable | access global or const-geheugenplaats |
arrayverwijzing | een patroon van optellen en geheugentoegang |
goto | onvoorwaardelijke branch-instructie |
if-statement | een patroon in if-goto: voorwaardelijke test &aftakinstructies |
voordelige lus | een patroon in if-goto |
toewijzing | wijziging van register of geheugen |
aritmetische | aritmetische instructies |
parameter passeren | wijziging van register- of stapelgeheugen |
parameter ontvangen | toegang tot register- of stapelgeheugen |
functie call | call instructie |
functie entry | proloog patroon |
functie exit | epiloog patroon |
return waarde | register wijziging en epiloog |
Hoe assembly taal verschilt van andere programmeertalen
In assembly taal, word je blootgesteld aan de kale middelen van de onderliggende CPU hardware. (Dingen die we in C in één regel kunnen schrijven, kosten in assembly meerdere regels.)
-
Je bent je bewust van zaken als CPU-registers en de aanroepstapel – deze bronnen moeten worden beheerd om een programma te laten werken (en in andere talen worden deze bronnen automatisch voor ons beheerd.) Bewustzijn van deze bronnen is cruciaal voor het aanroepen van functies en het terugkeren. (CPU-registers zijn schaars in vergelijking met geheugen, maar ze zijn supersnel, en ook consistent in hun prestaties.)
-
Er zijn geen gestructureerde verklaringen in assembleertaal – dus alle controlestroomconstructies moeten worden geprogrammeerd in een “if-goto”-stijl (in andere talen vertaalt de compiler gestructureerde verklaringen in if-goto). De if-goto stijl gebruikt labels als doelen. (Hoewel C het gebruik van goto’s en labels ondersteunt, geven programmeurs meestal de voorkeur aan gestructureerde statements, zodat we goto’s en labels niet vaak in C zien.)
Assembleertaal vs. Machine Code
Assembleertaal is bedoeld voor mensen om zowel te lezen als te schrijven.
Labels
In assembleertaal wordt veel gebruik gemaakt van labels. Het gebruik van de labels vertaalt zich in getallen in de machinecode en de labels verdwijnen in de machinecode. Labels (in tegenstelling tot het directe gebruik van getallen) maken het mogelijk om kleine veranderingen in programma’s aan te brengen – zonder labels zou zelfs een kleine verandering een enorme verandering zijn.
De processor moet weten waar een operand is, misschien een data operand of misschien een code-locatie. Voor codelocaties bijvoorbeeld wil de processor doorgaans weten hoe ver die operand verwijderd is van de codelocatie die hij op dat moment uitvoert – dit wordt pc-relatieve adressering genoemd. Dus, om een if-then statement uit te voeren, zouden we de processor kunnen vertellen: sla onder bepaalde voorwaarden N instructies over, waarbij de N de grootte of het aantal instructies voorstelt die moeten worden overgeslagen (of niet), d.w.z. N is de grootte van het dan-gedeelte – dat zou in machinecode zijn. Als we een instructie toevoegen aan het then-gedeelte, gaat het aantal over te slaan instructies omhoog (en als we een instructie uit het then-gedeelte verwijderen, omlaag).
Het gebruik van labels houdt automatisch rekening met veranderingen (bijv. een vertakkingsinstructie die een label als doel gebruikt, houdt rekening met veranderingen in het aantal instructies dat voorwaardelijk moet worden overgeslagen.
Pseudo-instructies
De meeste instructies in assembleertaal worden 1:1 vertaald naar machinecode-instructies. Soms moeten we de assembler echter iets vertellen dat niet direct in machinetaal wordt vertaald. Typische voorbeelden zijn het vertellen aan de assembler dat de volgende instructies code zijn (meestal via een .text
directief) vs. data (meestal via een .data
directief). Net als gewone instructies worden deze instructies meestal op hun eigen regel geschreven, maar ze informeren de assembler eerder dan dat ze direct machinecode-instructies genereren.
Assembleertaal schrijven
In C schrijven we statements; deze statements lopen achter elkaar door, standaard sequentieel, totdat een of andere controlestroom-constructie (lus, if) de stroom verandert. In assembleertaal is hetzelfde waar.
In C hebben statements effecten op variabelen – we kunnen ervan uitgaan dat afzonderlijke statements met elkaar communiceren via variabelen. Hetzelfde geldt in assembleertaal: instructies communiceren met elkaar via het effect dat zij hebben op variabelen, hoewel naarmate er meer instructies zijn (noodzakelijkerwijs zo, omdat de instructies eenvoudiger zijn), er ook noodzakelijkerwijs meer variabelen zullen zijn, waarvan vele zeer kortstondige tijdelijk zijn die worden gebruikt om van de ene instructie naar de volgende te communiceren.
Basic Assembly Langauge Programming
We wisselen af tussen registerselectie en instructieselectie. Aangezien assembly-taal instructies vrij eenvoudig zijn, moeten we vaak meerdere instructies gebruiken, ja, onderling verbonden via waarden als variabelen in cpu-registers. Wanneer dit gebeurt, zullen we een register moeten selecteren om tussenresultaten in te bewaren.
Registerselectie
Om een register te selecteren, hebben we een mentaal model nodig van welke registers bezet zijn en welke vrij zijn. Met die kennis kunnen we een vrij register kiezen om een tijdelijk (kortstondig) resultaat in te bewaren. Dat register blijft bezet totdat we het voor het laatst gebruiken, daarna is het weer vrij – eenmaal vrij kan het voor iets heel anders worden gebruikt. (In zekere zin weet de processor niet echt dat we deze constante herbestemming doen, omdat hij de bedoeling van het programma niet kent.)
Instructieselectie
Als we eenmaal hebben vastgesteld welke bronnen moeten worden gebruikt, kunnen we de specifieke instructie kiezen die we nodig hebben. Vaak zullen we geen instructie vinden die precies doet wat we willen, dus zullen we een klus moeten klaren met 2 of meer instructies, wat betekent, jawel, meer registerselectie voor temporaries.
Voorbeeld, MIPS en RISC V zijn gelijksoortige architecturen die een vergelijk & branch instructie bieden, maar ze kunnen slechts twee cpu registers vergelijken. Dus, als we willen zien of een byte in een string een bepaald karakter is (zoals een newline), dan moeten we die newline-waarde (een numerieke constante) in een cpu-register plaatsen (laden) voordat we de compare & branch-instructie kunnen gebruiken.
Function Calling & Returning
Een programma bestaat uit misschien wel duizenden functies! Hoewel deze functies geheugen moeten delen, is het geheugen groot, en dus kunnen ze met elkaar overweg. Maar een CPU heeft een beperkt en vast aantal registers (zoals 32 of 16), en alle functies moeten deze registers delen!
Om dat te laten werken, definiëren software-ontwerpers conventies – die kunnen worden gezien als regels – voor het delen van deze beperkte vaste bronnen. Verder zijn deze conventies nodig om een functie (een caller) een andere functie (een caller) te laten aanroepen. De regels worden aangeduid als de aanroepconventie, die voor de aanroeper/aanroepende aangeeft waar arguements en return values moeten worden geplaatst/vonden. Een aanroepconventie maakt deel uit van de Application Binary Interface, die ons in grote lijnen vertelt hoe een functie met de buitenwereld (andere functies) moet communiceren in machinecode. De aanroepconventie wordt gedefinieerd voor software-gebruik (de hardware geeft er niets om).
Deze documenten vertellen ons wat te doen bij het doorgeven van parameters, register-gebruik, stack-allocatie/deallocatie, en het retourneren van waarden. Deze documenten worden gepubliceerd door de instructie-set provider, of compiler provider, en zijn bekend zodat code van verschillende plaatsen goed kan samenwerken. De gepubliceerde aanroepconventies moeten als gegeven worden beschouwd, ook al kent de hardware ze niet.
Zonder dergelijke documentatie zouden we niet weten hoe we deze dingen moeten doen (parameter passing, etc..). We hoeven geen standaard aanroepconventies te gebruiken, maar het is ondoenlijk om onze eigen conventies uit te vinden voor iets anders dan een simpel speelgoedprogramma dat op een simulator draait. Het gebruiken van onze eigen aanroepconventie betekent geen interoperabiliteit met andere code, die we nodig hebben in elk realistisch programma, zoals het aanroepen van het systeem en de bibliotheek.
De hardware weet niets van of geeft niets om de software gebruiksconventies. Het voert gewoon blindelings instructie na instructie uit (heel snel). Voor de hardware zijn de meeste registers bijvoorbeeld gelijkwaardig, ook al zijn ze door de softwareconventies gepartitioneerd of voor een bepaald gebruik bestemd.
Dedicated Registers
Sommige CPU-registers zijn voor specifieke doeleinden bestemd. De meeste CPU’s van tegenwoordig hebben bijvoorbeeld een of andere vorm van stack pointer. Op MIPS en RISC V is de stack pointer een gewoon register dat niet verschilt van de andere, behalve door de definitie van de software conventies – de keuze van welk register is arbitrair, maar wordt wel degelijk gekozen, en deze keuze wordt gepubliceerd zodat we allemaal weten welk register het is. Op x86, daarentegen, wordt de stack pointer ondersteund door speciale push
& pop
instructies (evenals call en return instructies), dus voor het stack pointer register is er slechts één logische keuze.
Register Partioning
Om de overige registers over duizenden functies te kunnen verdelen, zijn ze softwarematig in vier groepen verdeeld:
- Registers voor het doorgeven van parameters
- Registers voor het ontvangen van retourwaarden
- Registers die “volatile”
- Registers die “non-volatile”
Parameter & Return Registers
Parameter registers worden gebruikt voor het doorgeven van parameters! Door zich aan de aanroepconventie te houden, weten calllee’s waar ze hun argumenten kunnen vinden, en weten callers waar ze de argumentwaarden moeten plaatsen. Parameter-registers worden gebruikt voor het doorgeven van parameters volgens de regels van de aanroepconventie, en als er meer parameters worden doorgegeven dan de conventie voor de CPU toestaat, worden de resterende parameters doorgegeven met behulp van stack-geheugen.
Return-value-registers bevatten de return-waarden van functies, dus als een functie is voltooid, is het zijn taak om zijn return-waarde in het juiste register te hebben gezet, voordat de controlestroom wordt teruggegeven aan de aanroeper. Door zich aan de aanroep-conventie te houden, weten zowel de aanroeper als de aanroeper waar ze de retourwaarde van de functie kunnen vinden.
Volatile & Non-Volatile Registers
Volatile registers kunnen door elke functie worden gebruikt zonder zich zorgen te maken over het eerdere gebruik van of de waarden in dat register. Ze zijn goed voor tijdelijke waarden die nodig zijn om een instructie-sequentie te verbinden, en ze kunnen ook worden gebruikt voor lokale variabelen. Echter, omdat deze registers vrij zijn om door iedere functie te worden gebruikt, kunnen we er niet op rekenen dat ze hun waarden vasthouden tijdens een functie-aanroep – per (software-conventie) definitie! Dus nadat een functie is teruggekeerd kan de aanroeper niet zeggen welke waarden zich in deze registers bevinden – elke functie is vrij om ze te hergebruiken, maar wel in de wetenschap dat we er niet op kunnen vertrouwen dat hun waarden behouden blijven nadat een aangeroepen functie is teruggekeerd. Deze registers worden dus gebruikt voor variabelen en tijdelijke waarden die geen functie-aanroep hoeven te overbruggen.
Voor variabelen die wel een functie-aanroep moeten overbruggen, zijn er niet-vluchtige registers (en ook stack-geheugen). Deze registers blijven behouden tijdens een functie-aanroep (door software die zich aan de aanroepconventie houdt). Als een functie dus een variabele in een snel CPU-register wil hebben, maar die variabele is live tijdens een aanroep (ingesteld voor een aanroep, en gebruikt na de aanroep), kunnen we een niet-vluchtig register kiezen, met de (door de aanroepconventie bepaalde) eis dat de vorige waarde van het register wordt hersteld bij terugkeer naar de aanroeper. Dit vereist dat de huidige waarde van het niet-vluchtige register wordt opgeslagen (in het stack-geheugen) voordat het register opnieuw wordt gebruikt, en dat de waarde bij terugkeer wordt hersteld (met gebruikmaking van de vorige waarde uit het stack-geheugen).