A szerelési nyelv tanulása
A szerelési nyelv a számítógépes programozás legalacsonyabb szoftverszintjének – a gépi kódnak – az ember számára olvasható megfelelője.
Míg a számítógép minden programot számokként értelmez, ahol különböző számok különböző műveletek elvégzésére utasítják a számítógépet, ez túlságosan unalmas az emberi fogyasztáshoz (nemhogy a szerzői munkához). Ezért az emberek assembly nyelven programoznak, amely szinte 1:1 arányban megfelel a gépi kódnak.
Tanuljuk meg a C programozási nyelvet. Ha ismered a C-t (vagy a C++-t), az assembly könnyebb lesz.
A C# és a Java jó nyelvek, de kevés olyan eszköz van, amely megmutatja, hogyan néz ki a C# és a Java kód assembly nyelven. (Valójában a Java és a C# saját byte kódú assembly nyelvvel rendelkezik, amely meglehetősen különbözik, és távol áll a valódi hardveres CPU utasításkészlet architektúráktól.)
A C (és C++) kódból a fordítóprogramok segítségével megmutathatjuk, hogyan néz ki az assembly nyelv. A C egy alacsony szintű nyelv, és ha tudjuk, hogy a C egyes konstrukciói hogyan fordíthatók le assembly nyelvre, akkor ismerjük az assembly nyelvet! Példák azokra a C konstrukciókra, amelyeket az assembly nyelvhez ismerni szeretnénk, a következők:
C konstruktum | összeszerelési nyelvi megfelelő |
---|---|
lokális változó | CPU regiszter vagy verem memóriahely |
globális változó | hozzáférés globális vagy const memóriahely |
tömbhivatkozás | összeadás és memóriaelérés mintája |
goto | független elágazási utasítás |
if utasítás | mintája az if- ban.goto: feltételes teszt & elágazási utasítás |
while ciklus | a minta az if- ban.goto |
hozzárendelés | regiszter vagy memória módosítása |
aritmetikai | aritmetikai utasítások |
paraméter. átadás | regiszter vagy verem memória módosítása |
paraméter fogadás | regiszter vagy verem memória elérése |
funkció. hívás | hívó utasítás |
függvény belépés | prológus minta |
függvény kilépés | epilógus minta |
visszatérési érték | regiszter módosítás és epilógus |
Miben különbözik az assembly nyelv más programozási nyelvektől
Az assembly nyelven, a mögöttes CPU hardver puszta erőforrásainak leszünk kitéve. (Továbbá, amit C-ben egy sorban meg tudunk írni, az assembly-ben több sorba kerül.)
-
Tudatában leszel olyan dolgoknak, mint a CPU regiszterek és a hívási verem – ezeket az erőforrásokat kezelni kell ahhoz, hogy egy program működjön (más nyelveken ezeket az erőforrásokat automatikusan kezelik helyettünk.) Ezeknek az erőforrásoknak a tudatossága kritikus a függvényhíváshoz és -visszatéréshez. (A CPU regiszterek a memóriához képest szűkösek, viszont szupergyorsak, és teljesítményükben is konzisztensek.)
-
Az assembly nyelvben nincsenek strukturált utasítások – így minden vezérlésfolyam-konstrukciót “If-Goto” stílusban kell programozni (más nyelvekben a fordító a strukturált utasításokat If-Goto-ra fordítja.) Az if-goto stílus címkéket használ célként. (Bár a C támogatja a goto és a címkék használatát, a programozók legtöbbször a strukturált utasításokat részesítik előnyben, így a C-ben nem gyakran látunk goto-kat és címkéket.)
Assembly Language vs. Machine Code
Az assembly nyelv az emberek számára írásra és olvasásra egyaránt készült.
Labels
A assembly nyelvben sok labelt használunk. A címkék használata számokká fordítódik a gépi kódban, és a címkék eltűnnek a gépi kódban. A címkék (szemben a számok közvetlen használatával) lehetővé teszik a kis változtatásokat a programokban – nélkülük még egy kis változtatás is valójában hatalmas változtatás lenne.
A processzornak tudnia kell, hogy hol van egy operandus, esetleg egy adatoperandus vagy esetleg egy kódhely. A kódhelyekre például jellemzően a processzor tudni akarja, hogy az adott elem milyen messze van attól a kódhelytől, amelyet éppen végrehajt – ezt nevezzük pc-relatív címzésnek. Tehát egy if-then utasítás végrehajtásához megmondhatjuk a processzornak: bizonyos feltételek mellett ugorjon előre N utasítást, ahol az N a kihagyandó (vagy nem kihagyandó) utasítások méretét vagy számát jelenti, azaz N a then-rész mérete – ez lenne a gépi kód. Ha a then-részhez hozzáadnánk egy utasítást, akkor a kihagyandó utasítások száma nő (ha pedig eltávolítunk egy utasítást a then-részből, akkor csökken).
A címkék használata automatikusan figyelembe veszi a változásokat (Pl. utasítások hozzáadása, eltávolítása) – egy olyan elágazási utasítás, amely címkét használ célként, alkalmazkodik a feltételesen kihagyandó utasítások számának változásaihoz.
Pszeudo utasítások
A legtöbb assembly nyelvi utasítás 1:1-ben lefordítható gépi kódú utasítássá. Alkalmanként azonban olyasmit kell közölnünk az assemblerrel, ami nem fordítható le közvetlenül gépi kódra. Tipikus példák erre, amikor az asszemblert arról tájékoztatjuk, hogy a következő utasítások kódok (általában egy .text
utasítással) vagy adatok (általában egy .data
utasítással). A normál utasításokhoz hasonlóan ezek az utasítások is általában saját sorba íródnak, azonban az asszemblert tájékoztatják, ahelyett, hogy közvetlenül gépi kódú utasításokat generálnának.
Assembly nyelv írása
A C-ben utasításokat írunk; ezek az utasítások egymás után, alapértelmezés szerint szekvenciálisan futnak, amíg valamilyen vezérlésáramlási konstrukció (ciklus, if) meg nem változtatja az áramlást. Az assembly nyelven ugyanez igaz.
C-ben az utasítások hatással vannak a változókra – úgy tekinthetjük, hogy az egyes utasítások változókon keresztül kommunikálnak egymással. Ugyanez igaz az assembly nyelvben is: az utasítások a változókra gyakorolt hatásukon keresztül kommunikálnak egymással, bár mivel több utasítás van (szükségszerűen így van, mivel az utasítások egyszerűbbek), szükségszerűen több változó is lesz, sok közülük nagyon rövid életű ideiglenes változó, amelyet az egyik utasítástól a következőig való kommunikációra használnak.
Alapvető assembly nyelvi programozás
Váltakozva választjuk ki a regisztereket és az utasításokat. Mivel az assembly nyelvi utasítások meglehetősen egyszerűek, gyakran több utasítást kell használnunk, igen, összekapcsolva a cpu regiszterekben változóként szereplő értékeken keresztül. Amikor ez történik, ki kell választanunk egy regisztert a köztes eredmények tárolására.
Regiszterkiválasztás
A regiszter kiválasztásához szükségünk van egy mentális modellre arról, hogy mely regiszterek foglaltak és melyek szabadok. Ennek a tudásnak a birtokában kiválaszthatunk egy szabad regisztert, amelyet egy átmeneti (rövid ideig tartó) eredmény tárolására használhatunk. Ez a regiszter addig marad foglalt, amíg utoljára nem használjuk, majd visszatér a szabad állapotába – ha már szabad, újra felhasználható valami teljesen másra. (Bizonyos értelemben a processzor nem igazán tudja, hogy ezt az állandó újrafelhasználást végezzük, mivel nem ismeri a program szándékát.)
Utasítás kiválasztása
Mihelyt megállapítottuk a felhasználandó erőforrásokat, kiválaszthatjuk a szükséges konkrét utasítást. Gyakran nem találunk olyan utasítást, amely pontosan azt teszi, amit akarunk, ezért 2 vagy több utasítással kell elvégeznünk egy feladatot, ami, igen, több regiszterválasztást jelent az ideiglenesek számára.
A MIPS és a RISC V például hasonló architektúrák, amelyek biztosítanak egy compare & elágazási utasítást, de csak két cpu regisztert tudnak összehasonlítani. Ha tehát meg akarjuk nézni, hogy egy karakterláncban egy bájt egy bizonyos karakter-e (például egy újsor), akkor ezt az újsor értéket (egy numerikus konstans) meg kell adnunk (betöltenünk) egy cpu regiszterben, mielőtt használhatnánk a compare & elágazási utasítást.
Funkcióhívás & Visszatérés
Egy program talán több ezer függvényből áll! Bár ezeknek a függvényeknek meg kell osztozniuk a memórián, a memória hatalmas, és így jól kijönnek egymással. Azonban egy CPU-nál korlátozott és rögzített számú regiszter van (például 32 vagy 16), és, az összes függvénynek meg kell osztoznia ezeken a regisztereken!!!
Azért, hogy ez működjön, a szoftvertervezők konvenciókat határoznak meg – amelyeket szabályoknak tekinthetünk – e korlátozott, rögzített erőforrások megosztására. Továbbá ezek a konvenciók szükségesek ahhoz, hogy egy függvény (hívó) meghívhasson egy másik függvényt (hívott). A szabályokat hívási konvenciónak nevezzük, amely a hívó/hívott számára meghatározza, hogy hol kell elhelyezni/keresni az érveket és a visszatérési értékeket. A hívási konvenció az alkalmazás bináris interfészének része, amely nagyjából megmondja, hogy egy függvénynek hogyan kell kommunikálnia a külvilággal (más függvényekkel) a gépi kódban. A hívási konvenciót a szoftveres használatra határozzák meg (a hardvert ez nem érdekli).
Ezek a dokumentumok megmondják, hogy mit kell tennünk a paraméterátadásban, a regiszterhasználatban, a verem ki/kiosztásban és a visszatérési értékekben. Ezeket a dokumentumokat az utasításkészlet-szolgáltató vagy a fordítóprogram-szolgáltató teszi közzé, és jól ismertek, hogy a különböző helyekről származó kódok megfelelően együttműködhessenek. A közzétett hívási konvenciókat adottnak kell tekinteni, még akkor is, ha a hardver nem tud róla.
Egy ilyen dokumentáció nélkül nem tudnánk, hogyan kell ezeket a dolgokat (paraméterátadás, stb…) csinálni. Nem kell szabványos hívási konvenciókat használnunk, de egy szimulátoron futtatott egyszerű játékprogramon kívül nem praktikus sajátot kitalálni. A saját hívási konvenciónk kigördítése azt jelenti, hogy nincs interoperabilitás más kódokkal, amire minden reális programban szükségünk van, mint például a rendszer- és könyvtárhívásokra.
A hardver nem ismeri és nem érdekli a szoftveres használati konvenciókat. Csak vakon hajtja végre utasításról utasításra (nagyon gyorsan). A hardver számára például a legtöbb regiszter egyenértékű, még akkor is, ha a regisztereket a szoftveres konvenciók partícionálják vagy különleges felhasználási módokhoz rendelik.
Dedikált regiszterek
Egyes CPU-regiszterek speciális célokra vannak dedikálva. Például a legtöbb CPU manapság valamilyen formában rendelkezik veremmutatóval. A MIPS-en és a RISC V-n a veremmutató egy közönséges regiszter, amely nem különbözik a többitől, kivéve a szoftveres konvenciók meghatározását – a választás, hogy melyik regiszter, önkényes, de valóban kiválasztják, és ezt a választást közzéteszik, hogy mindannyian tudjuk, melyik regiszterről van szó. Az x86-on viszont a veremmutatót dedikált push
& pop
utasítások támogatják (valamint a hívási és visszatérési utasítások), így a veremmutató regiszterére csak egy logikai választás van.
Regiszterfelosztás
A többi regiszter ezernyi funkció közötti megosztása érdekében szoftveres konvenció szerint jellemzően négy csoportra vannak felosztva:
- Regiszterek paraméterek átadására
- Regiszterek visszatérési értékek fogadására
- Regiszterek, amelyek “illékonyak”
- Regiszterek, amelyek “nem illékonyak”
paraméter & visszatérési regiszterek
A paraméter regiszterek a paraméter átadására szolgálnak! A hívási konvenció betartásával a hívók tudják, hogy hol találják meg az argumentumaikat, a hívók pedig tudják, hogy hová helyezzék az argumentumértékeket. A paraméterregisztereket a paraméterátadásra a hívási konvencióban meghatározott szabályok szerint használjuk, és ha több paramétert adunk át, mint amennyit a CPU konvenciója megenged, a maradék paraméterek átadása a verem memória segítségével történik.
A visszatérési érték regiszterek a függvények visszatérési értékeit tartják, így amikor egy függvény befejeződik, az a feladata, hogy a visszatérési értékét a megfelelő regiszterbe helyezze, mielőtt a vezérlés áramlását visszaadja a hívónak. A hívási konvenció betartásával mind a hívó, mind a hívott tudja, hogy hol találja a függvény visszatérési értékét.
Volatile & Non-Volatile regiszterek
A volatilis regisztereket bármelyik függvény szabadon használhatja anélkül, hogy törődne a regiszter korábbi használatával vagy értékeivel. Ezek alkalmasak az utasítássorozat összekapcsolásához szükséges átmeneti értékek tárolására, és helyi változókhoz is használhatók. Mivel azonban ezeket a regisztereket bármelyik függvény szabadon használhatja, nem számíthatunk arra, hogy a függvényhívások során – a (szoftverkonvenció szerinti) definíció szerint – megtartják értékeiket! Így a függvény visszatérése után a hívója nem tudja megmondani, hogy milyen értékek lennének ezekben a regiszterekben – bármelyik függvény szabadon felhasználhatja őket, bár azzal a tudattal, hogy nem számíthatunk arra, hogy értékeik megmaradnak a hívott függvény visszatérése után. Így ezeket a regisztereket olyan változók és ideiglenes változók számára használjuk, amelyeknek nem kell egy függvényhíváson átívelniük.
A függvényhíváson átívelő változók számára vannak nem-illékony regiszterek (és a veremmemória is). Ezek a regiszterek egy függvényhíváson keresztül megmaradnak (a hívási konvenciót betartó szoftverek által). Így ha egy függvény egy változót egy gyors CPU regiszterben szeretne tartani, de ez a változó egy híváson keresztül él (a hívás előtt be van állítva, és a hívás után használatos), akkor választhatunk egy nem-illékony regisztert, azzal a (hívási konvenció által meghatározott) követelménnyel, hogy a regiszter előző értéke visszaáll a hívóhoz való visszatéréskor. Ehhez a nem-illékony regiszter aktuális értékét el kell menteni (a veremmemóriába) a regiszter újrafelhasználása előtt, és a visszatéréskor vissza kell állítani (a veremmemóriából származó előző értéket használva).