Lär dig assembleringsspråk
Assembleringsspråk är den mänskligt läsbara motsvarigheten till den lägsta programvarunivån inom datorprogrammering – maskinkod.
Datorn förstår alla program som siffror, där olika siffror instruerar datorn att utföra olika operationer, men detta är för tråkigt för mänsklig konsumtion (för att inte tala om författande). Därför programmerar människor med hjälp av assemblerspråk, som har en nästan 1:1 motsvarighet till maskinkod.
Lär dig programmeringsspråket C. Om du kan C (eller C++) blir assembler lättare.
C# och Java är bra språk, men det finns få eller inga verktyg som visar hur C#- och Javakod ser ut i assemblerspråk. (Faktum är att Java och C# har sitt eget bytecode-assembleringsspråk som är ganska annorlunda och avlägset från verkliga maskinvaruarkitekturer för CPU-instruktioner.)
Från C (och C++)-kod kan vi använda kompilatorerna för att visa oss hur assembleringsspråket ser ut. C är ett lågnivåspråk, och om du vet hur varje konstruktion i C översätts till assemblerspråk kan du assemblerspråk! Exempel på C-konstruktioner som vi vill känna till för assemblerspråk är:
C-konstruktion | Assembleringsspråkets motsvarighet |
---|---|
lokal variabel | CPU-register eller minnesplats i stacken |
global variabel | access global eller konstant minnesplats |
arrayreferens | mönster för addition och minnesåtkomst |
goto | instruktion för ovillkorlig förgrening |
if-anvisning | mönster i om-goto: villkorligt test & förgreningsinstruktioner |
while-slinga | ett mönster i om-goto |
tilldelning | ändring av register eller minne |
aritmetiska | aritmetiska instruktioner |
parameter Överföring | Modifiering av register eller stapelminne |
Mottagning av parametrar | Access till register eller stapelminne |
Funktion anrop | anvisning för anrop |
funktionsingång | prologmönster |
funktionsutgång | epilogmönster |
returvärde | registerändring och epilog |
Hur Assembly Language skiljer sig från andra programmeringsspråk
I assembly language, utsätts du för den underliggande CPU-hårdvarans knappa resurser. (Dessutom tar saker som vi kan skriva i C på en rad flera rader i assembler.)
-
Du kommer att vara medveten om saker som CPU-register och anropsstacken – dessa resurser måste hanteras för att ett program ska fungera (och i andra språk hanteras dessa resurser automatiskt åt oss.) Medvetenhet om dessa resurser är avgörande för att kunna anropa och returnera funktioner. (CPU-registren är små i jämförelse med minnet, men de är supersnabba och även konsekventa i sin prestanda.)
-
Det finns inga strukturerade uttalanden i assembler – så alla kontrollflödeskonstruktioner måste programmeras i en ”If-Goto”-stil (i andra språk översätter kompilatorn strukturerade uttalanden till If-Goto). I if-goto-stilen används etiketter som mål. (Även om C stöder användningen av goto och etiketter föredrar programmerare oftast strukturerade uttalanden så vi ser inte ofta goto och etiketter i C.)
Assembleringsspråk vs. maskinkod
Assembleringsspråk är tänkt att människor ska kunna både läsa och skriva.
Etiketter
I assembleringsspråk använder vi massor av etiketter. Användningen av etiketterna översätts till siffror i maskinkod och etiketterna försvinner i maskinkoden. Etiketter (jämfört med att använda siffror direkt) gör det möjligt att göra små ändringar program – utan dem skulle även en liten ändring faktiskt bli en stor ändring.
Processorn behöver veta var en operand är, kanske en dataoperand eller kanske en kodplats. För kodplatser, till exempel, vill processorn typiskt sett veta hur långt bort det objektet ligger från den kodplats som den för närvarande utför – detta kallas pc-relativ adressering. För att utföra ett ”om-då”-påstående kan vi säga till processorn: under vissa förhållanden, hoppa över N instruktioner, där N representerar storleken eller antalet instruktioner som skall hoppas över (eller inte), dvs. N är storleken på ”då-delen” – det skulle vara i maskinkod. Om vi lägger till en instruktion till den dåvarande delen ökar antalet instruktioner att hoppa över (och om vi tar bort en instruktion från den dåvarande delen minskar antalet).
Användning av etiketter tar automatiskt hänsyn till förändringar (t.ex. lägga till, ta bort instruktioner) – en greninstruktion som använder en etikett som mål tar hänsyn till förändringar i räkningen av hur många instruktioner som ska överhoppas villkorligt.
Pseudoinstruktioner
De flesta intruktioner i assemblerspråk översätts 1:1 till instruktioner i maskinkod. Ibland måste vi dock berätta något för assemblerprogrammet som inte direkt översätts till maskinkod. Typiska exempel är att tala om för assembler att följande instruktioner är kod (vanligtvis via ett .text
-direktiv) respektive data (vanligtvis via ett .data
-direktiv). Liksom vanliga instruktioner skrivs dessa instruktioner vanligtvis på en egen rad, men de informerar assembleren snarare än att generera maskinkodinstruktioner direkt.
Att skriva assemblerspråk
I C skriver vi uttalanden; dessa uttalanden körs efter varandra, som standard sekventiellt, tills någon kontrollflödeskonstruktion (loop, if) ändrar flödet. I assemblerspråk gäller samma sak.
I C har uttalanden effekter på variabler – vi kan betrakta att separata uttalanden kommunicerar med varandra via variabler. Samma sak gäller i assemblerspråk: instruktioner kommunicerar med varandra via den effekt de har på variabler, men eftersom det finns fler instruktioner (med nödvändighet, eftersom instruktionerna är enklare) kommer det också med nödvändighet att finnas fler variabler, många är mycket kortlivade temporära som används för att kommunicera från en instruktion till nästa.
Grundläggande programmering i assemblerspråk
Vi alternerar mellan registerval och instruktionsval. Eftersom instruktioner i assemblerspråk är ganska enkla behöver vi ofta använda flera instruktioner, ja, sammankopplade via värden som variabler i cpu-register. När detta händer måste vi välja ett register för att hålla mellanliggande resultat.
Registerval
För att välja register behöver vi en mental modell av vilka register som är upptagna och vilka som är lediga. Med den kunskapen kan vi välja ett fritt register att använda för att hålla ett tillfälligt (kortvarigt) resultat. Det registret förblir upptaget tills vi använder det för sista gången, sedan återgår det till att vara fritt – när det väl är fritt kan det användas för något helt annat. (På sätt och vis vet processorn inte riktigt att vi gör detta ständiga omplacering eftersom den inte känner till programmets avsikt.)
Val av instruktion
När vi har fastställt vilka resurser som ska användas kan vi välja den specifika instruktion som vi behöver. Ofta kommer vi inte att hitta en instruktion som gör exakt det vi vill ha, så vi måste utföra ett arbete med 2 eller flera instruktioner, vilket innebär, ja, mer registerval för temporärer.
Till exempel är MIPS och RISC V liknande arkitekturer som tillhandahåller en compare & greninstruktion, men de kan bara jämföra två cpu-register. Så om vi vill se om en byte i en sträng är ett visst tecken (t.ex. en newline) måste vi tillhandahålla (ladda) detta newline-värde (en numerisk konstant) i ett cpu-register innan vi kan använda instruktionen compare & branch-instruktionen.
Function Calling & Returning
Ett program består av kanske tusentals funktioner! Även om dessa funktioner måste dela på minnet är minnet stort och därför kan de samsas. Med en CPU finns det dock ett begränsat och fast antal register (t.ex. 32 eller 16), och alla funktioner måste dela på dessa register!!
För att få detta att fungera definierar programvarukonstruktörer konventioner – som kan ses som regler – för att dela på dessa begränsade fasta resurser. Vidare är dessa konventioner nödvändiga för att en funktion (en anropare) ska kunna anropa en annan funktion (en anropare). Reglerna kallas för anropskonventionen, som anger för anroparen/anroparen var argument och returvärden skall placeras/finnas. En anropskonvention är en del av Application Binary Interface, som i stort sett talar om hur en funktion ska kommunicera med omvärlden (andra funktioner) i maskinkod. Anropskonventionen definieras för programvarubruk (hårdvaran bryr sig inte).
Dessa dokument talar om för oss vad vi ska göra vid parameteröverlämning, registeranvändning, allokering/allokering av stackar och returvärden. Dessa dokument publiceras av leverantören av instruktionsuppsättningar eller kompilatorleverantören och är välkända så att kod från olika ställen kan samverka på ett korrekt sätt. De offentliggjorda anropskonventionerna bör betraktas som givna, även om maskinvaran inte känner till dem.
Och utan sådan dokumentation skulle vi inte veta hur vi ska göra dessa saker (parameteröverlämning osv.). Vi behöver inte använda standardiserade anropskonventioner, men det är opraktiskt att uppfinna egna för något annat än ett enkelt leksaksprogram som körs på en simulator. Att rulla vår egen anropskonvention innebär ingen interoperabilitet med annan kod, vilket vi måste göra i alla realistiska program, som att göra system- och biblioteksanrop.
Hårdvaran känner inte till eller bryr sig inte om programvarans användningskonventioner. Den utför bara blint instruktion efter instruktion (riktigt snabbt). För hårdvaran är till exempel de flesta register likvärdiga, även om registren är uppdelade eller tilldelade särskilda användningsområden genom programvarukonventionerna.
Dedikerade register
Vissa CPU-register är dedikerade för särskilda ändamål. Till exempel har de flesta CPU:er nuförtiden någon form av stackpointer. På MIPS och RISC V är stackpointern ett vanligt register som inte skiljer sig från de andra, utom genom definition av programvarukonventionerna – valet av vilket register är godtyckligt, men det är faktiskt valt, och detta val offentliggörs så att vi alla vet vilket register det är. På x86 stöds stackpointern däremot av dedikerade push
& pop
-instruktioner (liksom call- och return-instruktioner), så för stackpointerregistret finns det bara ett logiskt val.
Registerpartionering
För att dela upp de andra registren mellan tusentals funktioner är de enligt programvarukonvention uppdelade, vanligtvis i fyra uppsättningar:
- Register för överlämnande av parametrar
- Register för mottagande av returvärden
- Register som är ”flyktiga”
- Register som är ”icke-flyktiga”
Parameter & Returregister
Parameterregister används för att överföra parameter! Genom att följa anropskonventionen vet anroparna var de ska hitta sina argument och anroparna vet var de ska placera argumentvärdena. Parameterregister används för parameteröverföring enligt de regler som anges i anropskonventionen, och när fler parametrar överförs än vad konventionen för CPU:n tillåter, överförs resten av parametrarna med hjälp av stapelminne.
Returneringsvärderegister innehåller funktionens returvärden, så när en funktion avslutas är det dess uppgift att ha placerat sitt returvärde i rätt register, innan kontrollflödet återgår till den som anropar. Genom att följa anropskonventionen vet både anroparen och anroparen var funktionens returvärde finns.
Flyktiga & Icke-flyktiga register
Flyktiga register kan användas fritt av vilken funktion som helst utan att ta hänsyn till tidigare användning av eller värden i det registret. Dessa är bra för temporära register som behövs för att koppla ihop en instruktionssekvens, och de kan också användas för lokala variabler. Eftersom dessa register är fria att användas av vilken funktion som helst kan vi dock inte räkna med att de behåller sina värden under ett funktionsanrop – per definition (enligt programvarukonvention)! Efter att en funktion har återvänt kan den som ringer upp inte säga vilka värden som finns i dessa register – alla funktioner är fria att använda dem på nytt, dock med vetskapen om att vi inte kan lita på att deras värden bevaras efter att en anropad funktion har återvänt. Dessa register används alltså för variabler och temporära som inte behöver sträcka sig över ett funktionsanrop.
För variabler som behöver sträcka sig över ett funktionsanrop finns det icke-flyktiga register (och även stackminne). Dessa register bevaras över ett funktionsanrop (av programvara som följer anropskonventionen). Om en funktion vill ha en variabel i ett snabbt CPU-register, men denna variabel är levande över ett anrop (inställd före anropet och använd efter anropet), kan vi alltså välja ett icke-flyktigt register, med kravet (definierat enligt anropskonventionen) att registrets tidigare värde återställs när det återgår till den som anropar funktionen. Detta kräver att det icke-flyktiga registrets aktuella värde sparas (i stapelminnet) innan registret återanvänds, och att det återställs (med hjälp av det tidigare värdet från stapelminnet) vid återvändandet.