Assembly

Á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:

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.

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
Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License