Ü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.
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.
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.
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