Áttekintés
Az assembly programozási nyelv segítségével tudjuk közvetlenül programozni a processzort. Az utasításkészlete tehát lényegében megegyezik a processzor utasításkészletével.
Mivel közvetlenül a processzort programozzuk, olyan optimalizálásra van elvi lehetőség, melyet semmilyen más programozási nyelvben nem lehet elérni. Ezt egy egyszerű példával teszteltem: egy egymilliárd lépésből álló ciklusban növeltem nulláról egy változót ill. regisztert: az assemblyben megírt változat hétszer-nyolcszor gyorsabb volt.
Ugyanakkor mivel (hardver közeli értelembe véve) alacsony szintű nyelvről beszélünk, a fejlesztés folyamata rendkívül lassú. Ez jelentős mértékben megdrágítaná a fejlesztést, emiatt assembly-ben igen ritkán fejlesztenek a gyakorlatban.
Az assembly kód és az eredmény is platformfüggő: adott processzoron, adott operációs rendszer alatt fut csak, és a kódot is platformfüggő módon tudjuk csak elkészíteni. Tehát egy még ugyanazon a processzoron belül is máshogy kell elkészíteni egy assembly programot Windows és másképp Linux operációs rendszerre írva. Az eltérő processzorokról nem is beszélve.
Az assembly nyelv jelentősége tehát nem abban rejlik, hogy abban fogunk valaha is fejleszteni, hanem a jelentőség inkább elméleti: jó tudni, hogy mi van "legalul". Egy hétköznapi életből vett hasonlattal illusztrálom: autót úgy is tudunk vezetni, hogy nem tudjuk, hogyan működik a motor. Viszont ha tudjuk, akkor az kihatással lehet a vezetési stílusunkra, talán pontosabban fogjuk érteni mondjuk az olajcsere jelentőségét. Tehát még ha nem is leszünk autószerelők (és különösen nem autómotor szerelők), az, hogy hogyan működik a motor, végső soron egy hasznos tudás. Persze a mértéket érdemes megtartani: az sem helyes, ha megfosztjuk magunkat a vezetés élményétől ill. hasznosságától, ha nem ismerjük kellőképpen a motor elvi működését.
Egy fejlesztő talán hatékonyabb kódot ír "fent", ha tudja, hogy mi található "lent". Ezen az oldalon áttekintjük az assembly programozás alapjait.
Néhány fontosabb forrás, ami alapján ezt az oldalt elkészítettem:
- https://www.tutorialspoint.com/assembly_programming/
- https://www.rose-hulman.edu/class/csse/resources/MinGW/installation.htm
- https://medium.com/@skbrowser/how-to-create-a-hello-world-program-in-assembly-6d60313141b6
- https://labs.bilimedtech.com/nasm/windows-install/3.html
- http://web.archive.org/web/20120414223112/http://www.cs.lmu.edu/~ray/no
- http://courses.ics.hawaii.edu/ReviewICS312/morea/X86NASM/ics312_nasm_data_bss.pdf
Telepítés
Az alábbiakat egy 64 bites magyar nyelvű Windows 10-es operációs rendszeren próbáltam ki, egy Intel Core i3-as processzoron, az alábbiakban felsorolt szoftvereket használva. Mivel az assembly programozás platformfüggő, más rendszerek esetében máshogy kell megvalósítani a programokat. Az alapelvek viszont azonosak.
Az alábbi programokat kell feltelepíteni.
- Assembly fordító: több próbálkozás után a NASM (https://www.nasm.us/) fordítóval sikerült működő programot készítenem. Az alábbit töltöttem le és telepítettem fel: https://www.nasm.us/pub/nasm/releasebuilds/2.15.05/win64/nasm-2.15.05-installer-x64.exe. Telepítés után a NASM könyvtárat (nálam c:\Programs\NASM) bele kell tenni a PATH környezeti változóba.
- C fordító: a MinGW-t használtam (https://www.mingw-w64.org/). Telepíteni nem is olyan egyszerű; a https://www.rose-hulman.edu/class/csse/resources/MinGW/installation.htm oldalról töltöttem le. Fontos megjegyeznem, hogy a Code::Blocks integrált fejlesztőkörnyezettel feltelepítettem a MinGW C/C++ fordítót is, azzal viszont folyamatosan hibákba futottam. Szükséges tehát külön feltelepíteni. A PATH környezeti változóba itt is bele kell tenni a bin könyvtárat, és az is fontos, hogy a célkönyvtár ne tartalmazzon szóközt.
- Szövegszerkesztő: én a Notepad++-t (https://notepad-plus-plus.org/) használtam erre a célra.
Látható tehát, hogy már a telepítés sem egyszerű. Lelövöm a poént: a használata sem lesz az.
Helló, assembly világ!
Önálló assembly program
Önálló assembly program valójában nem létezik; valójában szükség van C fordítóra is, de legalább operációs rendszer könyvtárra. Önálló assembly program alatt azt értem, hogy nincs szükség más (pl. C-ben megírt) programra.
Tetszőleges szövegszerkesztővel készítsük el a következő forrást:
hello.asm
global _main
extern _printf
section .text
_main:
push hello
call _printf
add esp, 4
ret
section .data
hello db 'Hello, assembly world!', 10, 0
Fordítsuk le az assembly fordítóval:
nasm -fwin32 hello.asm
Az eredmény: hello.obj. Készítsünk belőle futtathatót:
gcc hello.obj -o hello.exe
Az eredmény: hello.exe. Ha futtatjuk, kiírja azt:
Hello, assembly world!
A részleteket később magyarázom el.
Assembly függvény hívása C programból
Ebben a példában egy olyan assembly-ben megírt függvényt készítünk, amit egy C programból hívunk meg. A függvény két paramétert kap, és az összegével tér vissza. A függvény neve add.
add.asm
global _add
section .text
_add:
mov eax, [esp+4]
add eax, [esp+8]
ret
add.c
#include <stdio.h>
int add(int, int);
void main() {
printf("%d\n", add(3, 2));
}
Fordítás:
nasm -fwin32 add.asm
gcc add.c add.obj -o add.exe
Az eredmény az add.exe, aminek futtatásával 5 lesz az eredmény.
Az assembly nyelv
Alapok
Egy assembly utasítás általános kinézete az alábbi:
[címke] utasítás [operandusok] [;megjegyzés]
A megjegyzés karakter tehát a ;.
Szegmensek
A fenti példákban volt egy olyan utasítás, hogy section .text. Az alábbi 3 lehetőség van:
- section .text: ide kerülnek az utasítások.
- section .data: ide kerülnek a konstansok.
- section .bss: ide kerülnek a változók.
Lássunk erre is egy példát, ami létrehoz 2 darab 4 bájtos változót, programból beleteszi a 3 és a 2 értékeket, a kettőt összeszorozza és kiírja az eredményt.
global _main
extern _printf
section .text
_main:
mov eax, 3
mov [a], eax
mov eax, 2
mov [b], eax
mov eax, [a]
mov ecx, [b]
mul ecx
push eax
push format
call _printf
add esp, 8
ret
section .data
format db '%d', 10, 0
section .bss
a resb 4
b resb 4
Itt tehát már formázást is látunk.
Memóriahelyre a szögletes zárójel segítségével hivatkozhatunk, pl. [b]. Adott számú bájttal eltolni a szögletes zárójelbe írt + művelettel tudunk, pl.: [esp+4].
Néhány rövidítés jelentése:
- Inicializált adatok (tipikusan a data szegmensben):
- DB: define byte (1 bájt)
- DW: define word (2 bájt)
- DD: define doubleword (4 bájt)
- DQ: define quadword (8 bájt)
- DT: define ten bytes (10 bájt)
- Inicializálatlan adatok (tipikusan a bss szegmensben):
- RESB: 1 bájt
- RESW: 2 bájt
- RESD: 4 bájt
- RESQ: 8 bájt
- REST: 10 bájt
Érdekesség: a memóriában "fordított sorrendben" tárolódnak a bájtok (a bitek nem). Ennek az angol neve: little endian. Tehát egy pl. 16 bites szám esetében az alsó 8 bit jön először, a felső 8 bit utána. Regiszterek esetén viszont a sorrend "normális".
Regiszterek
A fentiekben többször előfordult az eax, ecx stb. Ezek az ún. regiszterek. Felfogható úgy is, mint beépített változók, de a számuk korlátozott, és általában speciális célra használatosak.
Általános regiszterek
Adat regiszterek: EAX, EBX, ECX, EDX. Ezek regiszterek 32 bitesek. Viszont AX, BX, CX és DX néven hivatkozhatunk az alsó 16 bitre is, és ezeknek külön szerepük van:
- AX: accumulator. Általában az aritmetikai műveletek bemeneti és kimeneti regisztere.
- BX: base register. Általános felhasználása: memória indexelése.
- CX: count register. Általában ciklusváltozóként szerepel.
- DX: data register. Kimeneti/bemeneti műveleteknél ill. gyakran az EAX regiszterrel együtt használják.
Az alsó 16 bit tovább bontható felső és alsó 8 bitre, AH, AL, BH, BL, CH, CL és DH, DL néven.
Mutató (pointer) regiszterek: EIP, ESP, EBP. Az alsó 16 bit jelentése:
- IP: instruction pointer. Az aktuálisan végrehajtandó utasításra mutat.
- SP: stack pointer. A verem aktuális elemére mutat.
- BP: base pointer. Egy szubrutinnak átadott paraméterekre lehet ezzel hivatkozni.
Index regiszterek: ESI, EDI. Az alsó 16 bit jelentése:
- SI: source index.
- DI: destination index.
Vezérlő regiszterek
Ezek valójában jelzőbitek; egy 16 bites regiszter 9 bitje az érdekes. Az utoljára végrehajtott művelet befolyásolhatja az értéküket. Pár példa:
- OF: overflow flag: túlcsordulás előjeles művelet esetén.
- ZF: zero flag: az utolsó művelet eredménye 0 volt-e.
- CF: carry flag: művelet túlcsordult bitje (pl. egy aritmetikai művelet vagy bitforgatás után).
Szegmens regiszterek
Meghatározzák a program helyét a memóriában. A fentiekben már említettekkel együtt határozzák meg a pontos helyet.
- CS: code segment
- DS: data segment
- SS: stack segment
A verem
A veremben "egymás tetejére" lehet helyezni az adatokat, és az utoljára beletett elemet tudjuk kivenni. Vonatkozó utasítások:
- PUSH
- POP
Paraméterként vagy egy regisztert vagy egy konkrét adatot értéket adhatunk meg.
Tekintsük ismét az alábbi kódrészletet:
push eax
push format
call _printf
add esp, 8
A _printf függvény számára behelyez a verembe két értéket: az EAX regiszter tartalmát és a formázó utasítás címét. Majd meghívja a _printf függvényt. Ez utóbbi kiveszi a két paramétert (a format alapján tudja, hogy ki kell még venni egyet, mert egy darab százalékjel van benne), viszont a stack pointert kézzel módosítani kell. A 8 azt jelenti, hogy ennyi bájttal kell léptetni; a 2 paraméter miatt.
Műveletek
Az egy operandusú műveleteket regisztereken vagy memóriahelyeken, míg a két operandusúakat az alábbi kombinációkban lehet végrehajtani:
- regiszter-regiszter
- memória-regiszter
- regiszter-memória
- regiszter-konstans
- memória-konstans
Memória-memória művelet nincs, tehát nem tudjuk hozzáadni az a memóriahelyhez a b memóriahely értékét.
Értékadás:
- MOV: értékadás; az első operandus értékül kapja a másodikat. Pl. MOV AX, 5 eredményeképpen az AX regiszter értéke 5 lesz.
Összehasonlítás:
- CMP: a két operandus értékét összehasonlítja, és az eredmény alapján beállítja a jelzőbiteket.
Aritmetikai műveletek:
- ADD: az első operandushoz hozzáadja a másodikat, pl. ADD AX, 2 az AX regiszterhez hozzáad 2-t.
- SUB: az első operandusból kivonja a másikat.
- INC: egyoperandusú művelet; az adott regiszter vagy memóriahely értékének növelése eggyel.
- DEC: csökkentés eggyel.
- MUL: az AX regisztert megszorozza az adott értékkel. Pl. MUL BX eredménye AX*BX lesz az EAX regiszterben. A paraméter hosszától függően az AX tényező valójában AL, AX vagy EAX, az eredmény pedig AX, EAX vagy EDX:EAX lesznek.
- DIV: osztás AX-szel. Az eredmény és a maradék a paraméter hossza függvényében AL és AH; AX és DX; ill. EAX és EDX lesznek.
Logikai műveletek:
- AND: két operandusú bitenkénti logikai és. Az eredmény az első operandusba kerül
- OR: két operandusú bitenkénti logikai vagy.
- XOR: két operandusú bitenkénti logikai kizáró vagy.
- TEST: olyan mint az AND, de az értéket nem változtatja meg. AZ értelme ennek az, hogy a jelzőbitekre hatással lehet.
- NOZ: egy operandusú bitenkénti logikai tagadás
Shift utasítások:
- SHL: két operandusú balra léptető, pl. SHL AX, 4 az AX regiszter bitjeit lépteti balra (és a baloldaliak "kiesnek").
- SHR: ugyanez jobbra.
- RCL: forgatás balra.
- RCR: forgatás jobbra.
Még számos shiftelő, forgató utasítás van, melyekről itt lehet olvasni: https://web.archive.org/web/20120426171627/https://www.arl.wustl.edu/~lockwood/class/cs306/books/artofasm/Chapter_6/CH06-3.html.
Ugró utasítások
Feltétel nélküli ugró utasítás: JMP címke.
Feltételes ugró utasítások: J és valamilyen feltétel. Pl.
cmp eax, 5
je címke
...
címke: ...
Itt a JE azt jelenti, hogy az összehasonlítás eredmény egyenlő (equals). Néhány példa:
- JE vagy JZ: ha az összehasonlítás eredménye egyenlő. A JZ arra utal, hogy ha a két értéket kivonnánk egymásból, akkor az eredmény 0 lenne, és a Z jelzőbit azt jelzi, hogy az utolsó művelet eredménye 0 volt-e (vagy 0 lett-e volna).
- JNE vagy JNZ: az előző ellentettje.
- JGE vagy JNLE: az eredmény nagyobb vagy egyenlő, ill. nem kisebb vagy egyenlő.
Még számos egyéb lehetőség van, amit a https://www.tutorialspoint.com/assembly_programming/assembly_conditions.htm oldal jól összefoglal.
Egyebek
Számos téma van, amire nem tértem ki:
- tömbök,
- beolvasás,
- megszakítások,
- port műveletek,
- és amire még én sem gondoltam.
Mivel ez az oldal inkább egy leírás az első lépésekről, mintsem egy valódi assembly tanfolyam, nem is cél a teljesség igénye. A fent megadott oldalakon lehet találni további információt.
Példák
Fibonacci
Az első 10 Fibonacci számot írja ki.
fibonacci.asm
global _main
extern _printf
section .text
_main:
xor eax, eax
xor ebx, ebx
xor ecx, ecx
inc ebx
print:
push eax
push ecx
push eax
push ecx
push format
call _printf
add esp, 12
pop ecx
pop eax
mov edx, eax
mov eax, ebx
add ebx, edx
inc ecx
cmp ecx, 10
jne print
ret
format:
db '%d %d', 0ah
Faktoriális
A faktoriálist rekurzió segítségével valósítja meg.
factorial.asm
global _factorial
section .text
_factorial:
mov eax, [esp+4] ; n
cmp eax, 1 ; n <= 1
jnle L1 ; if not, go do a recursive call
mov eax, 1 ; otherwise return 1
jmp L2
L1:
dec eax ; n-1
push eax ; push argument
call _factorial ; do the call, result goes in eax
add esp, 4 ; get rid of argument
imul eax, [esp+4] ; n * factorial(n-1)
L2:
ret
factorial.c
#include <stdio.h>
int factorial(int);
int main() {
printf("%d\n", factorial(5));
return 0;
}
Parancssori argumentumok
Az alábbi program kiírja a parancssori argumentumokat.
cmdargs.asm
global _main
extern _printf
section .text
_main:
mov ecx, [esp+4]
mov edx, [esp+8]
top:
push ecx
push edx
push dword [edx]
push format
call _printf
add esp, 8
pop edx
pop ecx
add edx, 4
dec ecx
jnz top
ret
format:
db '%s', 10, 0
Függvényhívás
Az alábbi program tartalmaz egy főprogramot, és egy függvényt; a főprogram hívja a függvényt. A függvény a prímellenőrzés, a főprogram pedig 2-től 20-ig kiírja a prmíszámokat.
global _main
extern _printf
section .text
; isprime function
_isprime:
mov ebx, [esp+4] ; move the parameter to ebx because that does not change
mov ecx, 2 ; while loop variable within the function
while_loop:
mov eax, ecx ; preparing for i*i
mul eax ; eax * eax = edx, eax, so edx also changes
cmp eax, ebx ; did the square reach the parameter?
jnle isprime_end_true ; if yes, then the number is prime
mov eax, ebx ; the division is made on eax
div ecx ; eax / ecx; result = eax, remainder = edx
cmp edx, 0 ; is the remainder = 0?
je isprime_end_false ; if yes, then number is not prime
inc ecx ; increment the loop variable
jmp while_loop ; continue the while loop
isprime_end_true:
mov eax, 1 ; 1 = true
ret
isprime_end_false:
mov eax, 0 ; 0 = false
ret
; main function
_main:
mov ecx, 2 ; loop from 2
for_loop:
push ecx ; for increment
push ecx ; for print result
push ecx ; as parameter
call _isprime ; call isprime with ecx parameter
add esp, 4 ; restore stack (ecx)
pop ecx ; possible print if prime
cmp eax, 1 ; if the response is 1 => prime
jne skip_print ; if not, then do not print
push ecx ; at this point the ecx contains the loop variable
push format ; print one number
call _printf ; printing ecx
add esp, 8 ; restore the stack (ecx + format)
skip_print:
pop ecx ; restore exc for loop
inc ecx ; increment within the for loop
cmp ecx, 20 ; did we reach 20?
jle for_loop ; if ecx <= 20, then continue the for loop
ret ; end of the main function
format:
db '%d', 0ah ; 0a hexadecimal = 10 decimal = line feed
Érdekességképpen, a program Python megfelelője:
def is_prime(n):
i = 2
while i**2 <= n:
if n % i == 0:
return False
i += 1
return True
for i in range(2, 21):
if is_prime(i):
print(i)