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.
  • JG: az eredmény nagyobb.
  • JL: az eredmény kisebb.
  • JGE: az eredmény nagyobb vagy egyenlő.
  • JLE: az eredmény 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)

Fibonacci rekurzióval

A Fibonacci számítás szándékosan elrontott rekurzív megvalósítása az alábbi:

        global  _main
        extern  _printf
        section .text

; fibonacci function
_fibonacci:
        mov     ebx, [esp+4]        ; move the parameter to ebx
        cmp     ebx, 1              ; is it 0 or 1?
        jle     terminal_condition  ; if yes, this is the terminal condition

        dec     ebx                 ; prepare for recursive call factorial(n-1)
        push    ebx                 ; save for factorial(n-2)

        push    ebx                 ; parameter: n-1
        call    _fibonacci          ; recursive call: fibonacci(n-1)
        add     esp, 4              ; restore stack

        pop     ebx                 ; pair of 'save for factorial(n-2)'
        dec     ebx                 ; prepare for recursive call factorial(n-2)

        push    eax                 ; push the result into the stack

        push    ebx                 ; parameter: n-2
        call    _fibonacci          ; recursive call: fibonacci(n-2)
        add     esp, 4              ; restore stack

        pop     ebx                 ; this is the pair of 'push the result into the stack', so the eax goes into ebx
        add     eax, ebx            ; this is the plus operation in fibonacci(n-1) + fibnacci(n-2)
        ret                         ; return with the calculated result

terminal_condition:
        mov     eax, ebx            ; the result is in the eax register, which is the input (0 -> 0, 1 -> 1)
        ret

; main function
_main:
        mov     ecx, 45             ; calculate 45th Fibonacci number
        push    ecx                 ; this is the parameter for fibonacci(n) call
        call    _fibonacci          ; call fibonacci with ecx parameter
        add     esp, 4              ; restore stack (ecx)

        push    eax                 ; the result
        push    format              ; print one number
        call    _printf             ; print the result
        add     esp, 8              ; restore the stack (eax + format)

        ret                         ; end of the main function

format:
        db      '%d', 0ah           ; 0a hexadecimal = 10 decimal = line feed

Itt a 45. Fibonacci számot számoljuk ki (a 46.-nál már túlcsordul). Annaka mérése, hogy mennyi ideig fut, Power Shellben:

Measure-Command { ./fibonacci.exe }

Nálam kb. 4,5 másodperc volt.

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License