Assembly programozás

 

Előszó

Üdvözlöm a Tisztelt Olvasót! Új cikksorozatot indítok útjára, amelyben szeretném megismertetni Önöket az Assembly nyelvcsalád IBM PC-ken alkalmazott változatával.

Kortárs programozók számára mindez anakronisztikus - sőt talán értelmetlennek vélt - vállalkozásnak tűnhet. A cikksorozat célja épp az, hogy fellebbentsem a fátylat erről az alacsony szintű, elavultnak tartott nyelvről azért, hogy megmutassam, pontosan milyen folyamatok zajlanak a számítógépben egy program futása során. Az olvasó így megismeri, mi lapul a magas szintű nyelvek szintaktikája mögött, hogyan működik a végrehajtás szintjén nézve egy feltétel kiértékelése, egy ciklus működése, vagy épp a tömbök kezelése.

Remélem, hogy a cikksorozat célba - megértésre - talál és általa a leendő programozók ügyesebben terveznek majd programokat, többet gondolkodnak az algoritmusok kivitelezésén, ismerve egy adott hardver alacsony szintű működését, hatékonyabban tudják ellátni a rájuk bízott feladatokat.

Utóiratként még annyit említenék meg, hogy ez a cikksorozat egy egyetemi jegyzetnek indult, vagyis a felsőoktatásban is - remélhetőleg - helyet fog kapni.

Bevezetés

Ismerkedjünk meg az Assembly-vel kapcsolatos alapfogalmakkal. Maga az angol eredetű assembly szó összeszerelést jelent. Az Assembly nem egy nyelv, hanem egy nyelvcsalád: az "ASM" nyelvek egy-egy változata egy adott processzorhoz tartozik. Jelen cikksorozatban az Intel 80386-os processzor utasításkészletének megfelelő Assembly nyelv segítségével próbálom bevezetni az olvasót az Assembly rejtelmeibe és szépségeibe.

Szó lesz memóriakezelésről, regiszterkezelésről, vezérlésátadásról, feltételek kiértékeléséről, ciklusokról, tömbökről, konstans- és makródefiníciókról, paraméterekről és operátorokról az alapok tisztázása érdekében. A példaprogramok Turbo Assembler szintaktikában készültek.

A 2. fejezetben megismerkedünk az IBM PC valós módú, szöveges képernyő és billentyűkezelésével, néhány rövidke példaprogram segítségével.

A 3. fejezetben szó lesz az MS-DOS által nyújtott, hasonló jellegű szolgáltatásokról is. Komolyabb programozásba (portok kezelése, DMA, megszakítás vezérlés, időzítés, párhuzamos taszkok ütemezése) nem megyünk bele, mert nem ez a jegyzet célja.

Általában igaz, hogy könnyebb úgy megérteni az assembly nyelveket, ha már előtte programoztunk más, magas szintű nyelven. Abban azonban nem teljesen értek egyet, hogy mindenképpen, vakon követni kell ezt az elvet. Úgy vélem, ha valaki először megtanulja, hogy egy adott hardware processzorát hogyan kell programozni, akkor a magasabb szintű nyelveket is sokkal jobban meg fogja érteni.

Szerintem nem egy adott magas szintű nyelven kell tudni jól programozni, hanem programozni kell tudni. Hogy milyen nyelven, az már részletkérdés.

I. fejezet

 

1. Intel 80386

Mindenek előtt ismerjük meg az i80386 processzort, hiszen ezzel fogunk dolgozni. A mi szempontunkból a következő műszaki paraméterek érdekesek:

a; az egész számokat 32 biten kezeli, vagyis az "integer" mérete 32 bit. Lehetőség van 16 bites, úgynevezett rövid egész, "short integer" számok és 8 bites, vagyis "byte" méretű számok kezelésére is. Ez a processzor - néhány lépésen keresztül - a 8086-osból fejlődött ki, ezért a szintaktikában majd látható lesz, hogy inkrementálisan fejlesztettek (azaz foltozgatták a nyelvet, a nyelvi elemeket a régiek figyelembevételével bővítették). Régebbi gépeken (ez alatt a processzort kell érteni) a gépi szó 16 bites volt, így a 386-os processzoron duplaszónak hívják a 32 bites számokat.

b; a processzor az operatív memóriaegységet háromféleképpen tudja elérni: 1) valós módban, ekkor egy megabájt címezhető - az egyszerűség kedvéért mi ezzel fogunk foglalkozni; 2) védett módban a teljes 32 bites értéktartomány szerint 4,3 gigabájt memória érhető el - a memória több lapra osztható, melyek egymástól védettek; 3) real flat módban a védett és valós mód keverékében a valós módban megszokott módon lehet címezni néhány, maximum 4,3 gigabájt méretű védtelen memórialapot.

c; lehetőség van taszkok (folyamatok) párhuzamos futtatására, a tervezők gondoskodtak arról, hogy a processzorban oldják meg a programok közti környezetek tárolását.

d; meg kell ismerkedni a "megszakítás" (interrupt) fogalmával is: egy megszakítás általában egy rutinkönyvtár, mely kívülről egy azonosítón keresztül meghívható. Valós módban a megszakítás leíró tábla (IDT: Interrupt Descriptor Table) 256 elemű, védett módban 2048. Valós módban a leíró táblázat elemei mutatók, amelyek az adott megszakítási rutin címét tárolják. Ha a 16h (16 hexa) megszakítást szeretnénk meghívni, akkor az IDT 16h helyén lévő mutató értéke töltődik be a processzor utasításszámlálójába. Védett módban a táblázat elejét a váratlan események (kivételek) kezelőinek leírója foglalja el. A 0-ás interrupt kapu a 0. kivétel, az ehhez rendelt rutin egy szám nullával való osztása esetén fog lefutni, a 13. kivétel védelemsértés esetén fut le, ha például egy felhasználói program közvetlenül akar elérni egy hardware egységet: alaplapi chipet, bővítőkártyát, stb - ezt hívják viccesen ÁVH-nak (Általános Védelmi Hiba: General Protection Fault). A 14. kivétel is ismerős lehet: ez a laphiba, vagyis amikor egy program a számára kiosztott memórián kívüli területekre próbál nyúlni.

e; védett módban megoldható, hogy az operációs rendszer interfészét úgynevezett hívási kapukkal (call gate) valósítsák meg. Intel-es szabály szerint megszakításból egyszerre csak egy lehet aktív, de call kapu használatával lehetőség van hívások egymásba ágyazására. Egy ilyen call kapu meghívásakor a hívó program vermében tárolódnak a visszatérési címek. A visszatérési cím azt mutatja meg, hol van az a pont a hívó programban, ahova egy adott függvény (például call kapu) lefutása után vissza kell lépni.

f; a szöveges jellegű (string) adatokat integrált, kifejező utasításokkal lehet ciklikusan kezelni: olvasni stringből, vagy másolni egyik stringből a másikba, stb.

2. Szegmentált memóriacímzés

Operatív memória nélkül nem tudunk programokat futtatni egy Neumann-architektúrájú (tárolt programú) számítógépen - valamilyen módon tehát a memóriát el kell érnünk, hogy oda programokat töltsünk be. A memóriához való hozzáférés, mint tevékenység két fázisra osztható: 1; címzés: ekkor meghatározzuk, milyen memóriacímre (cellaként, rekeszként lehet elképzelni) vagyunk kíváncsiak 2; operáció: az adott címre beírunk vagy onnan kiolvasunk egy adatot.

Ahhoz, hogy beírjunk egy értéket, tudnunk kell, mekkora helyet foglal a memóriában. Számértékekben kell gondolkodni, mást ugyanis a digitális gépek nem ismernek...

A számokat az assembly nyelveken programozók szeretik hexadecimális formában ábrázolni - ez ugyanis kifejezőbb, mint a decimális forma, később látjuk is, miért lesz ez fontos.

A hexadecimális számok mögé egy 'h' utótagot írok, vagyis a 12h egy hexa szám, értéke decimálisan tizennyolc.

Vegyünk egy példát a memóriában való értékek tárolására. Tegyük fel, hogy három számot szeretnénk eltárolni. Az egyik szám értéke a [0,100] intervallumba tartozik, a második a [-200,+200] tartományba, a harmadik pedig a [-100.000,+100.000] tartományba. Az első intervallumot 7 biten tudjuk ábrázolni, mert 7 biten 128 féle adat fér el, ebbe belefér a [0,100] intervallum. Mi azonban csak kerek címekre, byte határokra írhatunk. Ezen intervallumon tárolt számérték tehát 8 bit helyet foglal a memóriában. Tegyük fel, hogy a hely memóriacíme "0000h". A számolás szerint a következő szám már a "0001h" memóriahelytől fér csak el.

Vegyük a következő számot. Értéke a [-200,+200] tartományba eshet, vagyis 401 féle értéket vehet fel. A 401, mint szám tárolásához 9 bitre lesz szükség, mert 2^8 (ejtsd: 2 ad 8, vagyis kettő a nyolcadikon) 256, ez kicsi, 2^9 pedig 512, ez már nagyobb, mint 401. Tudunk 9 bitnyi helyet kicímezni? Nem, 8 bitenként tudunk címezni, vagy annak többszörösén. A "0001h"-s cím tehát már egy 16 bites számra fog mutatni. Ez kétszer 8 bit, vagyis 2 bájt, 0001h + 2 = 0003h, tehát ez lesz a következő szám címe.

A harmadik szám értéke a [-100.000,+100.000] tartományba fog esni. Ez összesen 200.001 lehetőség, ez a szám még 16 biten sem fér el: 2^16 = 65536. Ez kevés. Ennek kétszerese 131072, ez 2^17, ez is kevés, ennek kétszerese kell, 262144, vagyis 2^18. Ez elférhetne a 8 bit többszöröse szabály alapján 16+8=24 biten. A szám ugyan elfér, de a processzor csak háromféle számot tud kezelni: 8 bitest (byte), 16 bitest (short integer) és 32 bitest (integer). Ezek alapján már tudjuk, hogy egy 32 bites memóriahelyre lesz szükség. Ez 4 bájt, a következő szabad terület memóriacíme tehát 0003h + 4 = 0007h.

Említettem, hogy valós módban mindössze 1MB memória érhető el. Tudni kell azonban azt is, hogy a processzor valós módjában a memóriacímzés 16 bites. Itt jön a bökkenő. 16 bittel csak 2^16, vagyis 65536 féle cím különböztethető meg. Ez sokkal kevesebb, mint egy megabájt. Erre vezették be a szegmenseket. A szegmensek egymás után 16 bájtonként következnek, mert 65536 = 64kB memóriaterület fedhető le, és 1MB osztva 64kB = 16.

Vegyünk megint egy példát - három felvonásban. Az elsőben el szeretnénk érni a valós memória 18. bájtját, a másik esetben a 48. bájtot, a harmadikban pedig a 123.789-es memóriacímet. Tudjuk, hogy a szegmensek 16 bájtonként következnek. A 18-as memóriacímhez tartozó szegmenscím meghatározása a következőképpen történhet: 18-at elosztjuk 16-tal és kapunk 1-et. Egy lesz a szegmenscím: az első szegmensben van a 18-as cím. Mi az osztás maradéka? Kettő, ez lesz az index. Szegmenscím=1, Index=2. Ezt hexadecimálisan szokták jelölni és kettősponttal elválasztva, vagyis "0001h:0002h".

Második esetben a 48. bájt kell, osszuk el 48-at 16-tal, 3-at kapunk és nulla a maradék, vagyis a cím 0003h:0000h lesz.

Harmadik esetben 123.789 / 16 = 7.736, vagyis 1E38h, az osztás maradéka 13, vagyis az index 000Dh - tehát a cím 1E38h:000Dh.

Most pedig jön a szépség - kicsit másképpen - csak tisztán matematikai alapon. Az index 16 bites, értéktartománya 65536, tehát egy szegmens mérete 64kB. Emiatt a 18. bájtot a 0. szegmens 18 bájtjaként is el lehet érni, a 48-as bájtot pedig a 0. szegmens 48. bájtjaként. Hogyan lehetne akkor a 123.789-ik bájtot is így, másképpen elérni? Tudjuk, hogy 1 < (123.789 / 65.536) < 2, ez azt jelenti, hogy mivel 65.536/16 = 4096, vagyis 4096 féle lehetséges szegmenscím van egy 64kB-os szegmens (memóriablokk) területén, a 4096-edik szegmenscímtől már a következő 64kB is elérhető. 123.789 - 65.536 = 58.253, vagyis 4096 = 1000, 58.253 = E38Dh, tehát a 1000h:E38Dh címen ugyanaz az adat van, mint a korábban kiszámolt 1E38h:000Dh címen.

Javaslom a tisztelt olvasónak, fusson újra végig ezen a gondolatmeneten, mert ez a valós módú memóriacímzés módszere és alapvető fontosságú. Aztán nézze jól meg ezt a két párost (1000h:E38Dh) és (1E38h:000Dh), vajon miért is ugyanaz az értékük? Látható, hogy (1E00h:038Dh) és (1E30h:008Dh), de természetesen még (1E31h:007Dh) és (1E10h:028Dh) is ugyanarra a címre mutat. Aki ezt belátja, az érti a szegmentált memóriacímzést.

3. Regiszterek kezelése

Ismert, hogy a regiszterek a processzor futási környezetét alkotják. Hogyan tudjuk feltölteni a regisztereket értékekkel? Egy ilyen feltöltést (adat-)mozgatásnak hívunk, és a MOV utasítást használjuk rá. Először meg kell nevezni az adott regisztert és megmondani, hogy milyen értékkel akarjuk feltölteni.

Említettem négy általános célú regisztert. Ezek a regiszterek az EAX, EBX, ECX és az EDX. A jelölés értelme a következő: hajdanában-danában az Intel regiszterek 16 bitesek voltak, de 80386 óta 32 bitesek, ez egy kiterjesztés (Extension), ezt jelzi az E betű a regiszterek előtt. Az általános célt a matematikában is használt X, mint változó jelöli. Nem teljesen igaz azonban, hogy mind a négy regiszter általános célú. Vegyük őket sorra.

EAX: A valóban általános célú regiszter, a legtöbb műveletet ezzel szokás végezni.

EBX: A bázisregiszter, ezt címzéseknél fogjuk használni.

ECX: A ciklusregiszter, vagyis ciklusváltozóként használt regiszter.

EDX: Data, vagyis adatátvitelre (pl. osztás maradéka) használt regiszter.

A régi programokkal és címzésekkel való kompatibilitás miatt megtartották a 16 bites elnevezéseket is, vagyis a valóságban (a hardware-ben) ugyan nem létezik, de nyelvi elemként megmaradt az AX, BX, CX, DX regiszter is - és a későbbiekben ez igaz lesz az indexregiszterekre is.

Nézzünk végre példákat. Első pédánkban az EAX regiszterbe 1237ABCEh értéket töltünk, majd ezt átmozgatjuk az EDX regiszterbe, végül ennek alsó 16 bitjét az ECX regiszterbe.

; amit pontosvessző jel után írunk, az megjegyzés

mov eax, 1237ABCEh      ; mozgasd EAX-be a 1237ABCEh hexa értéket

mov edx, eax            ; mozgasd EDX-be EAX értékét

mov cx, dx              ; mozgasd CX-be DX értékét

A lényeg, hogy EnX és nX ugyanazt a regisztert jelöli, csak EnX esetében mind a 32 bitjét el tudjuk érni, nX esetében pedig csak az alsó 16 bitet (mert ugye a felső az Extension). Lehetőség van az alsó, 16 bites rész alsó és felső bájtjának elérésére is, ezt nL-nek (low, alacsony) és nH-nak (high, magas) jelölik. Például: EBX(32), BX(16), BL(8), BH(8).

mov edx, 13550000h

mov dl, 0E3h

mov dh, 0D7h

Remélem, belátható ezek után, hogy EDX értéke 1355D7E3h lesz. Vegyünk észre még valamit - ez már ugyan csupán a szintaktikára vonatkozik - ha a hexadecimális formájú szám betű karakterrel kezdődik, akkor előnullázzuk (például 0E3h).

Nos, nézzünk egy kis címzést - immár a gyakorlatban.

mov esi, Offset adatok ; "adatok" címke eltolási címe ESI-be

mov al, ds:[esi]        ; DS:ESI címen található érték AL regiszterbe

mov bl, Byte Ptr [adatok] ; Byte jellegű adat BL regiszterbe

add esi, 2              ; indexregiszer értékének növelése 2-vel

; ezáltal az indexmutató a memóriában 2 bájttal távolabbi címre fog mutatni

mov ecx, ds:[esi]

mov dx, Word Ptr [adatok + 4]

add esi, 4

mov ebx, ds:[esi]

 

; egyéb programrészlet

 

adatok:

db 01h

db 02h

db 03h

db 04h

dw 0AACCh

dd 22AACC88h

Használtunk végre egy szegmensregisztert és egy indexregisztert is.

Hat szegmensregiszterrel rendelkezik az i80386-os processzor, ezek a következők: CS, DS, ES, FS, GS, SS (gépi kód szerinti sorrendben ES, CS, SS, DS, FS, GS).

Kódszegmens (CS): az ezen regiszter által meghatározott szegmensben fut a felhasználói folyamat.

Adatszegmens (DS): ez a folyamat alapértelmezett adatszegmensének címét tartalmazza. Rövid programoknál a kód és adatszegmens ugyanaz, hiszen felesleges lenne memóriát pazarolni.

Az extraszegmens regiszter az ES, ugyanilyen célt szolgálnak az FS és GS regiszterek is, ezek általános céllal használható szegmensjelölők.

A verem szegmens címét az SS (Stack Segment) regiszter tartalmazza, ide általában a programok paraméterei, hívási láncok címei, stb. kerülnek.

Nézzük az indexregisztereket. Ezek meghatároznak egy memóriahelyet egy adott szegmenscímhez viszonyítva - ezt hívjuk "offset"-nek, vagyis relatív címnek. Utóbbi példaprogramunkban az ESI (Extended Source Index, vagyis forrásindex) regiszter tartalma az "adatok" címke memóriabeli címe volt az adott szegmensen belül. A címke egy elnevezett memóriacím.

Az ESI regisztert input (Source Index: forrás index) címzésére használják.

Az EDI (D, mint Destination, vagyis cél) regisztert output (kimenet) célokra.

Az EBP a bázismutató (Base Pointer), ami általános célokra használható címzésnél.

Az ESP a veremmutató (Stack Pointer), ez jelzi, hogy milyen (hány bájt) mélységig jutottunk a program vermében. A Stack Pointer értéke a program futása során csökken, nem nő, mert a verem verem és nem cső...

Láthatjuk, hogy a szegmens regiszterekhez tartoznak indexregiszterek, a DS-hez az ESI, az ES, FS és GS-hez az EDI, az SS-hez az ESP és például az EBP... Természetesen a kódszegmenshez is tartozik mutató, ez az EIP, vagyis utasításszámláló (Instruction Pointer), ez jelöli ki a kódszegmensben azt a címkét, ahol a program végrehajtása tart, vagyis EIP mindig a következő utasítás címét tartalmazza.

Térjünk vissza a példaprogram elemzéséhez. Először tehát elhelyezi ESI-ben az "adatok" címke memóriacímét, majd AL regiszterbe mozgatja a DS:ESI által kijelölt memóriaterületen található értéket, vagyis 1-et.

Azt a műveletet, amikor egy memóriacímről értéket olvasunk ki, vagy oda értéket írunk be, indirekciónak hívjuk.

A tömbzárójelek (array brackets: [ és ] jelek) közé írt azonosító jelzi, hogy mivel indexelünk. Ha ez egy regiszter, akkor egyértelmű, hogy a regiszter által jelöljük ki a relatív memóriacímet. Ilyen zárójelben az indexregisztereken kívül az EBX regiszter is szerepelhet még.

Amennyiben a tömbzárójelek közé egy elnevezett címke (ezt hívják magaszintű nyelvekben "változó"-nak) azonosítóját írjuk, akkor jelezni kell, hogy milyen típusú adatra mutatunk (PTR - pointer - kulcsszó). A lehetséges típusok:

BYTE - például AL, vagy CH esetén, amelyek 8 bites regiszterek

WORD - 16 bites regiszetereknél, pl. AX, SI esetén

DWORD - double word: duplaszó, vagyis 32 bites, pl. EBP regiszter esetén

Menjünk tovább a példában. Ha ESI értékéhez hozzáadunk kettőt ("add esi, 2"), akkor az "adatok" címke után 2 bájttal található memóiacímre fog mutatni ESI. Ott mi található? A db-vel jelölt helyek bájtos helyek, vagyis két bájttal arrébb a 3 érték található. Az ECX regiszter tartalma mi lészen? ECX tudjuk, 32 bites, vagyis ami az aktuális ESI címétől 32 bit, 4 bájt hosszon helyezkedik el, az egy számként bekerül ECX-be. Ez a szám az AACC0403h lesz.

Hogy miért? A számok ábrázolása Intel processzoron LITTLE ENDIAN jellegű, vagyis legelöl van a legkisebb helyiérték. Legelöl áll 03, utána 04, majd pedig egy dw short int (16 bites egész) szám, ez bekerül a 32 bites regiszter tetejére, mert ez van leghátul: így épül fel ECX értéke AACC0403h-vá.

A következő utasítással DX értéke is 0AACCh lesz, mivel a "mov dx, word ptr [adatok + 4]" az adatok címe után 4 bájttal található címről cseni el az adatot.

Megint növeljük ESI értékét, most néggyel, majd az itt található értéket bemozgatjuk az EBX regiszterbe. Ezáltal az a 22AACC88h értékkel töltődik fel.

Előzetes

A következő részben a feltételek kiértékelésével és a ciklusokkal fogunk megismerkedni.

 Németh Róbert - nrobcsi@freemail.hu