Învățarea limbajului de asamblare
Limbajul de asamblare este echivalentul lizibil pentru om al celui mai mic nivel software de programare a calculatoarelor – codul mașinii.
În timp ce calculatorul înțelege toate programele ca numere, în care diferite numere diferite instruiesc calculatorul să facă diferite operații, acest lucru este prea plictisitor pentru consumul uman (ca să nu mai vorbim de creație). Prin urmare, oamenii programează folosind limbajul de asamblare, care are o corespondență aproape 1:1 cu codul mașinii.
Învățați limbajul de programare C. Dacă știți C (sau C++), asamblarea va fi mai ușoară.
C# și Java sunt limbaje bune, dar există puține sau deloc instrumente care să vă arate cum arată codul C# și Java în limbaj de asamblare. (De fapt, Java și C# au propriul lor limbaj de asamblare a codului de octeți care este destul de diferit, și îndepărtat, de arhitecturile reale ale setului de instrucțiuni ale procesoarelor hardware.)
Din codul C (și C++) putem folosi compilatoarele pentru a ne arăta cum arată limbajul de asamblare. C este un limbaj de nivel scăzut, iar dacă știți cum se traduce fiecare construcție din C în limbaj de asamblare, veți cunoaște limbajul de asamblare! Exemple de construcții în C pe care dorim să le cunoaștem pentru limbajul de asamblare sunt:
Construcție C | Echivalent în limbaj de asamblare |
---|---|
variabilă locală | Registru CPU sau locație de memorie stivă |
variabilă globală | acces global sau locație de memorie const |
referință de matrice | un model de adunare și acces la memorie |
goto | instrucțiune de ramificare necondiționată |
instrucțiune if | un model în if-.goto: test condiționat &instrucțiuni de bifurcare |
while loop | un model în if-.goto |
asignare | modificarea unui registru sau a unei memorii |
instrucțiuni aritmetice | instrucțiuni aritmetice |
parametru trecere | modificare registru sau memorie stivă |
recepție parametru | acces registru sau memorie stivă |
funcție apel | instrucțiune de apel |
funcție de intrare | model de prolog |
funcție de ieșire | model de epilog |
valoare de retur | modificare registru și epilog |
Cum diferă limbajul de asamblare de alte limbaje de programare
În limbajul de asamblare, veți fi expus la resursele goale ale hardware-ului de bază al procesorului. (Mai mult, lucrurile pe care le putem scrie în C într-un singur rând necesită mai multe rânduri în limbajul de asamblare.)
-
Voi fi conștient de lucruri precum registrele CPU și stiva de apeluri – aceste resurse trebuie gestionate pentru ca un program să funcționeze (iar în alte limbaje, aceste resurse sunt gestionate automat pentru noi.) Conștientizarea acestor resurse este critică pentru apelarea și returnarea funcțiilor. (Registrele CPU sunt scace în comparație cu memoria, cu toate acestea, ele sunt super-rapide și, de asemenea, consistente în performanța lor.)
-
Nu există instrucțiuni structurate în limbajul de asamblare – astfel, toate construcțiile de flux de control trebuie să fie programate într-un stil „If-Goto” (în alte limbaje, compilatorul traduce instrucțiunile structurate în If-Goto.) Stilul „if-goto” utilizează etichete ca ținte. (Deși C suportă utilizarea de goto și etichete, programatorii preferă de cele mai multe ori declarațiile structurate, așa că nu vedem adesea goto-uri și etichete în C.)
Limbajul de asamblare vs. Codul mașină
Limbajul de asamblare este destinat atât citirii cât și scrierii de către oameni.
Etichete
În limbajul de asamblare, folosim o mulțime de etichete. Utilizările etichetelor se traduc în numere în codul mașină, iar etichetele dispar în codul mașină. Etichetele (față de utilizarea directă a numerelor) fac posibilă efectuarea de mici schimbări în programe – fără ele, chiar și o mică schimbare ar fi de fapt o schimbare uriașă.
Procesorul trebuie să știe unde se află un operand, poate un operand de date sau poate o locație de cod. Pentru locațiile de cod, de exemplu, de obicei procesorul va dori să știe la ce distanță se află acel item de locația de cod pe care o execută în acel moment – aceasta se numește adresare relativă pc. Astfel, pentru a executa o instrucțiune „if-then”, am putea spune procesorului: în anumite condiții, săriți N instrucțiuni, unde N reprezintă dimensiunea sau numărul de instrucțiuni care trebuie sărite (sau nu), adică N este dimensiunea părții „then-part” – aceasta ar fi în cod mașină. Dacă ar fi să adăugăm o instrucțiune la then-part, numărul de instrucțiuni de sărit crește (iar dacă eliminăm o instrucțiune din then-part, scade).
Utilizarea etichetelor ține cont automat de modificări (de ex. adăugarea, eliminarea unor instrucțiuni) – o instrucțiune de ramificare care folosește o etichetă ca țintă acomodează schimbările în numărul de instrucțiuni care trebuie sărite condiționat.
Pseudoinstrucțiuni
Majoritatea instrucțiunilor din limbajul de asamblare se traduc 1:1 în instrucțiuni de cod mașină. Cu toate acestea, ocazional, trebuie să-i spunem asamblorului ceva care nu se traduce direct în cod mașină. De obicei, exemplele tipice constau în a-i spune asamblorului că următoarele instrucțiuni sunt cod (de obicei prin intermediul unei directive .text
) față de date (de obicei prin intermediul unei directive .data
). Ca și instrucțiunile obișnuite, aceste instrucțiuni sunt de obicei scrise pe propria linie, însă ele informează asamblorul mai degrabă decât să genereze direct instrucțiuni de cod mașină.
Scrierea limbajului de asamblare
În C, scriem instrucțiuni; aceste instrucțiuni se execută consecutiv, una după alta, implicit secvențial, până când o construcție de flux de control (buclă, if) schimbă fluxul. În limbajul de asamblare, același lucru este valabil.
În C, declarațiile au efecte asupra variabilelor – putem considera că declarațiile separate comunică între ele prin intermediul variabilelor. Același lucru este valabil și în limbajul de asamblare: instrucțiunile comunică între ele prin intermediul efectului pe care îl au asupra variabilelor, deși, pe măsură ce există mai multe instrucțiuni (în mod necesar, deoarece instrucțiunile sunt mai simple), vor exista în mod necesar și mai multe variabile, multe dintre ele fiind temporare de foarte scurtă durată folosite pentru a comunica de la o instrucțiune la alta.
Programare de bază în limbaj de asamblare
Alternăm între selectarea registrelor și selectarea instrucțiunilor. Deoarece instrucțiunile în limbaj de asamblare sunt destul de simple, deseori trebuie să folosim mai multe instrucțiuni, da, interconectate prin intermediul valorilor ca variabile în registrele cpu. Când se întâmplă acest lucru, va trebui să selectăm un registru pentru a păstra rezultatele intermediare.
Selectarea registrului
Pentru a selecta registrul, avem nevoie de un model mental al registrelor care sunt ocupate și care sunt libere. Cu aceste cunoștințe putem alege un registru liber pe care să îl folosim pentru a păstra un rezultat temporar (de scurtă durată). Acel registru va rămâne ocupat până la ultima noastră utilizare a acestuia, după care va redeveni liber – odată liber, el poate fi reutilizat pentru ceva complet diferit. (Într-un anumit sens, procesorul nu știe cu adevărat că facem această redistribuire constantă, deoarece nu cunoaște intenția programului.)
Selectarea instrucțiunii
După ce am stabilit resursele care vor fi folosite, putem alege instrucțiunea specifică de care avem nevoie. De multe ori nu vom găsi o instrucțiune care să facă exact ceea ce dorim, așa că va trebui să facem o treabă cu 2 sau mai multe instrucțiuni, ceea ce înseamnă, da, mai multă selecție de registre pentru temporizări.
De exemplu, MIPS și RISC V sunt arhitecturi similare care oferă o instrucțiune de comparare & a ramurii, dar pot compara doar două registre cpu. Deci, dacă vrem să vedem dacă un octet dintr-un șir de caractere este un anumit caracter (cum ar fi o linie nouă), atunci trebuie să furnizăm (încărcăm) acea valoare a liniei noi (o constantă numerică) într-un registru cpu înainte de a putea folosi instrucțiunea de ramificare compara &.
Apelarea funcțiilor & Returnare
Un program este format poate din mii de funcții! Deși aceste funcții trebuie să împartă memoria, memoria este vastă și astfel ele se pot înțelege. Cu toate acestea, cu un procesor există un număr limitat și fix de registre (cum ar fi 32 sau 16) și, toate funcțiile trebuie să împartă aceste registre!!!
Pentru a face ca acest lucru să funcționeze, proiectanții de software definesc convenții – care pot fi considerate reguli – pentru împărțirea acestor resurse fixe limitate. Mai mult, aceste convenții sunt necesare pentru ca o funcție (un apelant) să poată apela o altă funcție (un apelat). Regulile sunt denumite convenție de apelare, care identifică, pentru apelant/căutat, unde trebuie plasate/găsite argumentele și valorile de returnare. O convenție de apelare face parte din interfața binară a aplicației, care ne spune, în linii mari, cum ar trebui să comunice o funcție cu lumea exterioară (alte funcții) în cod mașină. Convenția de apelare este definită pentru utilizarea software (hardware-ului nu-i pasă).
Aceste documente ne spun ce trebuie să facem în ceea ce privește trecerea parametrilor, utilizarea registrelor, alocarea/dealocarea stivei și valorile de returnare. Aceste documente sunt publicate de furnizorul setului de instrucțiuni, sau de furnizorul compilatorului, și sunt bine cunoscute astfel încât codul din diferite locuri să poată interopera în mod corespunzător. Convențiile de apelare publicate ar trebui să fie considerate ca fiind date, chiar dacă hardware-ul nu știe despre ele.
Fără o astfel de documentație, nu am ști cum să facem aceste lucruri (trecerea parametrilor, etc.). Nu trebuie să folosim convenții de apelare standard, dar este impracticabil să inventăm propriile convenții pentru orice altceva decât un simplu program de jucărie rulat pe un simulator. Rularea propriei noastre convenții de apelare înseamnă că nu există interoperabilitate cu alte coduri, ceea ce trebuie să facem în orice program realist, cum ar fi efectuarea de apeluri de sistem și de bibliotecă.
Hardware-ul nu știe sau nu-i pasă de convențiile de utilizare a software-ului. Pur și simplu execută orbește instrucțiune după instrucțiune (foarte repede). De exemplu, pentru hardware, majoritatea registrelor sunt echivalente, chiar dacă registrele sunt partiționate sau li se atribuie utilizări particulare prin convențiile software.
Registre dedicate
Câteva registre ale CPU sunt dedicate unor scopuri specifice. De exemplu, majoritatea procesoarelor din zilele noastre au o anumită formă de pointer de stivă. Pe MIPS și RISC V, pointerul de stivă este un registru obișnuit nediferențiat de celelalte, cu excepția definiției convențiilor software – alegerea registrului este arbitrară, dar este într-adevăr aleasă, iar această alegere este publicată astfel încât să știm cu toții despre ce registru este vorba. Pe x86, pe de altă parte, pointerul de stivă este susținut de instrucțiuni dedicate push
& pop
(la fel ca și instrucțiunile de apelare și întoarcere), astfel încât pentru registrul pointerului de stivă există o singură alegere logică.
Partajarea registrelor
Pentru a împărți celelalte registre între mii de funcții, acestea sunt partiționate, prin convenție software, de obicei în patru seturi:
- Registre pentru trecerea parametrilor
- Registre pentru primirea valorilor de retur
- Registre care sunt „volatile”
- Registre care sunt „non-volatile”
Registre de parametru &Registre de retur
Registrele de parametru sunt utilizate pentru trecerea parametrilor! Prin respectarea convenției de apelare, apelanții știu unde să își găsească argumentele, iar apelanții știu unde să plaseze valorile argumentelor. Registrele de parametri sunt utilizate pentru trecerea parametrilor conform regulilor stabilite în convenția de apelare și, atunci când sunt trecuți mai mulți parametri decât permite convenția pentru CPU, restul parametrilor sunt trecuți utilizând memoria stivei.
Registrele de valori de întoarcere păstrează valorile de întoarcere ale funcțiilor, astfel încât, atunci când o funcție se termină, sarcina sa este de a fi pus valoarea de întoarcere în registrul corespunzător, înainte de a returna fluxul de control către apelant. Prin respectarea convenției de apelare, atât apelantul, cât și apelantul știu unde să găsească valoarea de întoarcere a funcției.
Registre volatile & Registre nevolatile
Registrele volatile sunt libere să fie utilizate de orice funcție fără a se preocupa de utilizarea anterioară sau de valorile din registrul respectiv. Acestea sunt bune pentru temporarele necesare pentru a interconecta o secvență de instrucțiuni și, de asemenea, pot fi utilizate pentru variabilele locale. Cu toate acestea, din moment ce aceste registre pot fi utilizate liber de orice funcție, nu ne putem baza pe faptul că acestea își păstrează valorile în timpul unui apel de funcție – prin definiție (convenție software)! Astfel, după întoarcerea funcției, apelantul acesteia nu poate spune ce valori s-ar afla în aceste registre – orice funcție este liberă să le reutilizeze, știind însă că nu ne putem baza pe faptul că valorile lor vor fi păstrate după întoarcerea unei funcții apelate. Astfel, aceste registre sunt utilizate pentru variabile și temporare care nu trebuie să se întindă pe durata unui apel de funcție.
Pentru variabilele care trebuie să se întindă pe durata unui apel de funcție, există registre nevolatile (și memoria stivă, de asemenea). Aceste registre sunt păstrate de-a lungul unui apel de funcție (de către un software care aderă la convenția de apelare). Astfel, în cazul în care o funcție dorește să aibă o variabilă într-un registru rapid al procesorului, dar această variabilă este activă în timpul unui apel (setată înainte de apel și utilizată după apel), putem alege un registru nevolatil, cu cerința (definită de convenția de apelare) ca valoarea anterioară a registrului să fie restabilită la întoarcerea la apelant. Acest lucru necesită salvarea valorii curente a registrului nevolatil (în memoria stivei) înainte de reutilizarea registrului și restabilirea acesteia (folosind valoarea anterioară din memoria stivei) la întoarcere.