Lære assemblagesprog
Assemblesprog er den menneskeligt læsbare pendant til det laveste softwareniveau inden for computerprogrammering – maskinkode.
Mens computeren forstår alle programmer som tal, hvor forskellige tal giver computeren instrukser om at udføre forskellige operationer, er dette for kedeligt til menneskelig brug (for slet ikke at tale om at skrive). Derfor programmerer mennesker ved hjælp af assemblagesprog, som har en næsten 1:1 overensstemmelse med maskinkode.
Lær programmeringssproget C. Hvis du kan C (eller C++), bliver assembler lettere.
C# og Java er gode sprog, men der er få til ingen værktøjer til at vise dig, hvordan C#- og Java-kode ser ud i assembler-sprog. (Faktisk har Java og C# deres eget bytekode-assembler-sprog, som er ret forskelligt og fjernt fra rigtige hardware CPU-instruktionssæt-arkitekturer.)
Fra C (og C++)-kode kan vi bruge kompilatorerne til at vise os, hvordan assembler-sproget ser ud. C er et lavniveausprog, og hvis man ved, hvordan hver enkelt konstruktion i C kan oversættes til assemblagesprog, kender man assemblagesproget! Eksempler på de C-konstruktioner, som vi ønsker at kende til assemblagesprog, er:
C-konstruktion | Assemblysprogsækvivalent |
---|---|
lokal variabel | CPU-register eller stak-hukommelsesplacering |
global variabel | tilgang til global eller const-hukommelsesplacering |
array-reference | mønster i addition og hukommelsesadgang |
goto | ubetinget forgreningsinstruktion |
if-statement | mønster i if-goto: betinget test & forgreningsinstruktioner |
while loop | et mønster i if-goto |
tildeling | ændring af register eller hukommelse |
aritmetiske | aritmetiske instrukser |
parameter videregivelse | modificering af register eller stakhukommelse |
modtagelse af parameter | adgang til register eller stakhukommelse |
funktion call | opkaldsinstruktion |
funktionsindgang | prologmønster |
funktionsudgang | epilogmønster |
returværdi | registrering ændring og epilog |
Hvordan assemblagesprog adskiller sig fra andre programmeringssprog
I assemblagesprog, bliver du udsat for de nøgne ressourcer i den underliggende CPU-hardware. (Desuden tager ting, som vi kan skrive i C på én linje, flere linjer i assembler.)
-
Du vil være opmærksom på ting som CPU-registre og call stack – disse ressourcer skal forvaltes, for at et program kan fungere (og i andre sprog forvaltes disse ressourcer automatisk for os.) Kendskab til disse ressourcer er afgørende for kald og returnering af funktioner. (CPU-registrene er små i forhold til hukommelsen, men de er superhurtige og også konsekvente i deres ydeevne.)
-
Der findes ingen strukturerede udsagn i assembler – så alle flow of control-konstruktioner skal programmeres i en “If-Goto”-stil (i andre sprog oversætter compileren strukturerede udsagn til If-Goto). If-goto-stilen bruger labels som mål. (C understøtter brugen af goto og labels, men programmører foretrækker for det meste strukturerede udsagn, så vi ser ikke ofte goto’er og labels i C.)
Assembleringssprog vs. maskinkode
Assembleringssprog er beregnet til, at mennesker både kan læse og skrive.
Labels
I assembleringssprog bruger vi masser af labels. Brugen af etiketterne oversættes til tal i maskinkode, og etiketterne forsvinder i maskinkoden. Labels (i forhold til at bruge tal direkte) gør det muligt at lave små ændringer programmer – uden dem ville selv en lille ændring faktisk være en stor ændring.
Processoren har brug for at vide, hvor en operand er, måske en dataoperand eller måske en kodeplacering. For kodeplaceringer vil processoren f.eks. typisk ønske at vide, hvor langt væk dette element er fra den kodeplacering, som den er i gang med at udføre – dette kaldes pc-relativ adressering. Så for at udføre en hvis-så-erklæring kan vi fortælle processoren: under visse betingelser skal du springe N instruktioner fremad, hvor N repræsenterer størrelsen eller antallet af instruktioner, der skal springes over (eller ikke), dvs. N er størrelsen af så-delen – det vil være i maskinkode. Hvis vi tilføjer en instruktion til then-delen, stiger antallet af instruktioner, der skal springes over, (og hvis vi fjerner en instruktion fra then-delen, falder antallet).
Ved brug af labels tages der automatisk højde for ændringer (f.eks. tilføjelse, fjernelse af instruktioner) – en greninstruktion, der bruger en label som mål, tager højde for ændringer i optællingen af, hvor mange instruktioner der betinget skal springes over.
Pseudoinstruktioner
De fleste intruktioner i assemblagesprog oversættes 1:1 til maskinkodeinstruktioner. Indimellem er vi dog nødt til at fortælle assembleren noget, som ikke direkte oversættes til maskinkode. Typiske eksempler er at fortælle assembleren, at følgende instruktioner er kode (normalt via et .text
-direktiv) i modsætning til data (normalt via et .data
-direktiv). Ligesom almindelige instruktioner skrives disse instruktioner typisk på deres egen linje, men de informerer assembleren i stedet for at generere maskinkodeinstruktioner direkte.
Skrivning af assemblagesprog
I C skriver vi statements; disse statements kører efter hinanden, som standard sekventielt, indtil en eller anden kontrolflow-konstruktion (loop, if) ændrer flowet. I assemblagesprog er det samme tilfældet.
I C har statements virkninger på variabler – vi kan betragte det som om, at separate statements kommunikerer med hinanden via variabler. Det samme gælder i assemblagesprog: instruktioner kommunikerer med hinanden via den virkning, de har på variabler, men da der er flere instruktioner (nødvendigvis, da instruktionerne er enklere), vil der også nødvendigvis være flere variabler, mange er meget kortvarige temporære, der bruges til at kommunikere fra en instruktion til en næste.
Basic Assembly Langauge Programming
Vi veksler mellem registervalg og instruktionsvalg. Da assemblerinstruktioner er ret enkle, har vi ofte brug for at bruge flere instruktioner, ja, indbyrdes forbundet via værdier som variabler i cpu-registre. Når dette sker, skal vi vælge et register til at opbevare mellemresultater.
Registervalg
For at vælge register skal vi have en mental model over, hvilke registre der er optaget, og hvilke der er ledige. Med denne viden kan vi vælge et frit register, som vi kan bruge til at holde et midlertidigt (kortvarigt) resultat. Dette register vil forblive optaget indtil vores sidste brug af det, hvorefter det igen bliver frit – når det først er frit, kan det genanvendes til noget helt andet. (I en vis forstand ved processoren ikke rigtig, at vi foretager denne konstante genanvendelse, da den ikke kender programmets hensigt.)
Valg af instruktion
Når vi har fastlagt de ressourcer, der skal bruges, kan vi vælge den specifikke instruktion, vi har brug for. Ofte vil vi ikke finde en instruktion, der gør præcis det, vi ønsker, så vi bliver nødt til at udføre en opgave med 2 eller flere instruktioner, hvilket betyder, jeps, mere registervalg for temporære.
For eksempel er MIPS og RISC V lignende arkitekturer, der giver en compare & branch-instruktion, men de kan kun sammenligne to cpu-registre. Så hvis vi ønsker at se, om en byte i en streng er et bestemt tegn (f.eks. en newline), skal vi tilvejebringe (indlæse) denne newline-værdi (en numerisk konstant) i et cpu-register, før vi kan bruge compare &-greninstruktionen.
Funktionskald & Returnering
Et program består af måske tusindvis af funktioner! Selv om disse funktioner skal dele hukommelse, er hukommelsen stor, og derfor kan de godt komme hinanden ved. Men med en CPU er der et begrænset og fast antal registre (f.eks. 32 eller 16), og alle funktionerne skal dele disse registre!!!
For at få det til at fungere, definerer softwaredesignere konventioner – som kan opfattes som regler – for deling af disse begrænsede faste ressourcer. Endvidere er disse konventioner nødvendige for, at en funktion (en caller) kan kalde en anden funktion (en callee). Reglerne kaldes calling-konventionen, som angiver for calleren/callee, hvor argumenterne og returværdierne skal placeres/findes. En kaldskonvention er en del af Application Binary Interface, som i store træk fortæller os, hvordan en funktion skal kommunikere med omverdenen (andre funktioner) i maskinkode. Calling Convention er defineret til brug for software (hardwaren er ligeglad).
Disse dokumenter fortæller os, hvad vi skal gøre med hensyn til parameteroverlevering, registerbrug, stackallokering/tildeling og returværdier. Disse dokumenter offentliggøres af udbyderen af instruktionssættet eller compilerudbyderen, og de er velkendte, så kode fra forskellige steder kan fungere korrekt sammen. De offentliggjorte kaldskonventioner bør tages som givet, selv om hardwaren ikke kender til dem.
Uden en sådan dokumentation ville vi ikke vide, hvordan vi skal gøre disse ting (parameterpassage osv.). Vi behøver ikke at bruge standardkaldskonventioner, men det er upraktisk at opfinde vores egne til alt andet end et simpelt legetøjsprogram, der køres på en simulator. At rulle vores egen kaldskonvention betyder ingen interoperabilitet med anden kode, hvilket vi er nødt til at gøre i ethvert realistisk program, som f.eks. at foretage system- og bibliotekskald.
Hardwaren kender ikke eller er ligeglad med softwarens brugskonventioner. Den udfører bare blindt instruktion efter instruktion (virkelig hurtigt). For hardwaren er de fleste registre f.eks. ækvivalente, selv om registrene er opdelt eller tildelt særlige anvendelsesformål i henhold til softwarekonventionerne.
Dedikerede registre
Nogle CPU-registre er dedikeret til bestemte formål. For eksempel har de fleste CPU’er i dag en eller anden form for stack pointer. På MIPS og RISC V er stack pointeren et almindeligt register, der ikke adskiller sig fra de andre, undtagen ved definitionen af softwarekonventionerne – valget af hvilket register er arbitrært, men det er faktisk valgt, og dette valg er offentliggjort, så vi alle ved, hvilket register det er. På x86 understøttes stackpointeren derimod af dedikerede push
& pop
instruktioner (samt call- og return-instruktioner), så for stackpointerregisteret er der kun ét logisk valg.
Registerpartionering
For at kunne dele de øvrige registre mellem tusindvis af funktioner er de efter softwarekonvention typisk opdelt i fire sæt:
- Registre til at videregive parametre
- Registre til at modtage returværdier
- Registre, der er “flygtige”
- Registre, der er “ikke-flygtige”
Parameter & Returregistre
Parameterregistre bruges til at videregive parameter! Ved at overholde opkaldskonventionen ved calllee’s, hvor de skal finde deres argumenter, og callers ved, hvor de skal placere argumentværdierne. Parameterregistre bruges til parameteroverdragelse efter de regler, der er fastsat i kaldskonventionen, og når der overdrages flere parametre, end konventionen for CPU’en tillader, overdrages resten af parametrene ved hjælp af stakhukommelse.
Returneringsværdi-registre indeholder funktionsreturværdier, så når en funktion er færdig, er det dens opgave at have lagt sin returværdi i det rette register, inden den returnerer kontrolstrømmen til den, der kalder. Ved at overholde kaldskonventionen ved både kalder og kalder, hvor de kan finde funktionens returværdi.
Flygtige & Ikke-flygtige registre
Flygtige registre kan frit anvendes af enhver funktion uden hensyn til den tidligere brug af eller værdier i det pågældende register. De er gode til temporære registre, der er nødvendige for at sammenkoble en instruktionssekvens, og de kan også bruges til lokale variabler. Men da disse registre frit kan anvendes af enhver funktion, kan vi ikke regne med, at de bevarer deres værdier på tværs af et funktionsopkald – pr. definition (software-konvention)! Når en funktion vender tilbage, kan den, der kalder den, således ikke fortælle, hvilke værdier der er i disse registre – enhver funktion kan frit anvende dem igen, dog med den viden, at vi ikke kan regne med, at deres værdier bliver bevaret, når en kaldt funktion vender tilbage. Disse registre bruges således til variabler og temporære variabler, der ikke behøver at strække sig over et funktionsopkald.
For variabler, der behøver at strække sig over et funktionsopkald, er der ikke-flygtige registre (og også stakhukommelse). Disse registre bevares på tværs af et funktionsopkald (af software, der overholder opkaldskonventionen). Hvis en funktion således ønsker at have en variabel i et hurtigt CPU-register, men denne variabel er levende på tværs af et kald (indstillet før et kald og brugt efter kaldet), kan vi vælge et ikke-flygtigt register med det (i henhold til kaldskonventionen definerede) krav, at registrets tidligere værdi genoprettes ved returnering til den, der kalder den. Dette kræver, at man gemmer det ikke-flygtige registers aktuelle værdi (i stakhukommelsen), før man genbruger registret, og at man genopretter det (ved hjælp af den tidligere værdi fra stakhukommelsen) ved returnering.