Nazaj k osnovam: NASM, Linux in asembler

Z asemblerjem sem se prvič srečal že na fakulteti. Takrat svet računalništva ni bil videti tako kot danes. Delali smo na računalnikih DEC VAX, kjer je bil pogled na procesor, registre in pomnilnik precej bolj neposreden kot pri današnjem razvoju programske opreme.

Od takrat je minilo kar nekaj časa. Vmes so prišli Linux, strežniki, virtualizacija, kontejnerji, Kubernetes in nešteto programskih jezikov ter orodij, ki razvijalca ali sistemskega administratorja precej oddaljijo od samega procesorja.

Pred kratkim pa sem se ponovno lotil asemblerja. Tokrat na precej bolj domačem okolju: Intel x86-64 in Linux, z uporabo assemblerja NASM.

In presenetljivo — še vedno je zabavno.

Zakaj sploh asembler?

Danes skoraj nihče ne piše celotnih aplikacij v asemblerju. Za večino stvari imamo C, C++, Rust, Go, Python in celo vrsto drugih jezikov.

Toda asembler ima še vedno posebno vrednost.

Ko pišemo program v Pythonu, na primer:

print("Hello world")

se med našo vrstico kode in procesorjem zgodi ogromno stvari.

Pri asemblerju smo precej bližje procesorju. Ukvarjamo se neposredno z:

  • registri,
  • pomnilnikom,
  • skladom,
  • naslovi,
  • procesorskimi ukazi,
  • sistemskimi klici operacijskega sistema.

Prav zaradi tega je asembler odličen način za razumevanje, kaj računalnik dejansko počne pod pokrovom.

NASM

Za eksperimentiranje sem uporabil NASM – Netwide Assembler.

Na Debianu oziroma Ubuntuju ga namestimo zelo preprosto:

sudo apt install nasm

Na distribucijah iz družine Red Hat oziroma Oracle Linux:

sudo dnf install nasm

NASM uporablja Intelovo sintakso, ki mi je tudi osebno nekoliko bolj pregledna od AT&T sintakse, ki jo pogosto srečamo pri GNU assemblerju.

Prvi program

Klasičnega Hello World se seveda ne da preskočiti.

Datoteka hello.asm:

section .data
    message db "Hello from assembler!", 10
    message_len equ $ - message

section .text
    global _start

_start:

    mov rax, 1
    mov rdi, 1
    mov rsi, message
    mov rdx, message_len
    syscall

    mov rax, 60
    xor rdi, rdi
    syscall

Program prevedemo z NASM:

nasm -f elf64 hello.asm -o hello.o

Nato ga povežemo:

ld hello.o -o hello

in poženemo:

./hello

Rezultat:

Hello from assembler!

Na prvi pogled je za nekaj, kar bi v Pythonu napisali v eni sami vrstici, potrebnega absurdno veliko dela.

Toda prav v tem je čar.

Kaj se pravzaprav dogaja?

Na 64-bitnem Linuxu se za sistemske klice uporabljajo registri procesorja.

Pri:

mov rax, 1

v register RAX zapišemo številko sistemskega klica write.

Nato pripravimo argumente:

mov rdi, 1

RDI vsebuje file descriptor. Vrednost 1 pomeni standardni izhod oziroma stdout.

mov rsi, message

RSI kaže na naslov podatkov, ki jih želimo izpisati.

mov rdx, message_len

RDX vsebuje dolžino podatkov.

Ko izvedemo:

syscall

procesor preda izvajanje jedru Linuxa.

Jedro nato izvede sistemski klic write().

Če bi isto stvar napisali v jeziku C, bi bila videti približno tako:

write(1, message, message_len);

Asembler nam tako pokaže precej neposredno povezavo med uporabniškim programom in Linux jedrom.

Registri

Pri x86-64 hitro ponovno naletimo na stare znance:

RAX
RBX
RCX
RDX
RSI
RDI
RSP
RBP

poleg njih pa še:

R8
R9
R10
R11
R12
R13
R14
R15

Registri so izjemno hiter pomnilnik neposredno v procesorju.

Če prihajamo iz višjenivojskih programskih jezikov, kjer ves čas delamo s spremenljivkami, objekti in strukturami, je delo z nekaj registri sprva skoraj nenavadno omejujoče.

Toda kmalu začneš drugače gledati na program.

Namesto:

Kaj naj naredi ta funkcija?

začneš razmišljati:

Kateri podatek je trenutno v katerem registru?

In to je precej drugačen pogled na računalništvo.

Linux je za takšne poskuse idealno okolje

Ena od stvari, ki mi je pri Linuxu vedno všeč, je, da ti sistema ne skriva.

Program lahko poženemo skozi:

strace ./hello

in vidimo sistemske klice:

write(1, "Hello from assembler!\n", 22) = 22
exit(0)

To je pravzaprav natanko tisto, kar smo napisali v asemblerju.

Program lahko odpremo tudi z:

objdump -d hello

ali:

readelf -a hello

in pogledamo, kako je sestavljena ELF datoteka.

Za še globlji pogled pa lahko uporabimo debugger:

gdb ./hello

ter izvajamo program ukaz za ukazom in opazujemo registre.

Na primer:

info registers

Nenadoma stvari, ki jih običajno jemljemo za samoumevne, postanejo zelo konkretne.

Od DEC VAX do x86-64

Ko sem se prvič učil asemblerja na sistemih DEC VAX, je bila računalniška arhitektura precej drugačna.

VAX je predstavljal filozofijo procesorjev CISC – Complex Instruction Set Computer. Procesor je imel zelo bogat nabor ukazov, nekateri ukazi pa so lahko izvajali precej kompleksne operacije.

Današnji x86-64 je zgodovinsko prav tako CISC arhitektura, čeprav sodobni Intelovi in AMD-jevi procesorji interno delujejo bistveno bolj kompleksno. Ukaze x86 procesor praviloma razbije v preprostejše mikrooperacije, ki jih nato izvaja moderno procesorsko jedro.

Kljub desetletjem razvoja pa osnovna ideja ostaja enaka.

Imamo podatke.

Imamo registre.

Imamo pomnilnik.

In imamo ukaze, ki podatke premikajo ter nad njimi izvajajo operacije.

MOV je kralj

Pri ponovnem učenju asemblerja hitro dobiš občutek, da polovico programa sestavljajo ukazi:

mov

Premakni nekaj v register.

Premakni nekaj iz registra.

Premakni naslov.

Pripravi argument.

Shrani rezultat.

Seveda obstajajo še:

add
sub
inc
dec
cmp
jmp
call
ret
push
pop

in še ogromno drugih ukazov.

Toda presenetljivo veliko dela je preprosto premikanje podatkov na pravo mesto.

Kaj ti asembler da danes?

Verjetno ne bom naslednje spletne aplikacije napisal v NASM-u.

Tudi večine administratorskih orodij zagotovo ne.

Toda nekaj ur dela z asemblerjem zelo hitro osveži razumevanje številnih stvari, ki jih pri vsakodnevnem delu z Linuxom srečujemo ves čas:

  • kako delujejo procesi,
  • kaj so sistemski klici,
  • kako program komunicira z jedrom,
  • kaj pravzaprav pomeni stack,
  • kako delujejo funkcijski klici,
  • zakaj obstaja ABI,
  • kako se argumenti prenašajo med funkcijami,
  • kaj se zgodi pri segfaultu,
  • kako debugger vidi program,
  • kaj pravzaprav vsebuje izvršna ELF datoteka.

Še posebej zanimivo je potem pogledati kodo, ki jo generira compiler iz programa v C.

Na primer:

gcc -S test.c

Kar naenkrat assembler ni več neka skrivnostna plast nekje globoko pod programom.

Postane nekaj, kar lahko prebereš.

Zaključek

Moje ponovno srečanje z asemblerjem po vseh teh letih je bilo precej nostalgično.

Od DEC VAX računalnikov na fakulteti do današnjega x86-64 Linuxa z NASM-om se je spremenilo skoraj vse.

Procesorji so neprimerno hitrejši.

Operacijski sistemi so kompleksnejši.

Programski jeziki imajo več plasti abstrakcije.

Računalniki imajo danes gigabajte pomnilnika tam, kjer smo nekoč šteli kilobajte in megabajte.

Toda nekje globoko spodaj še vedno obstaja zelo preprost svet.

Registri.

Naslovi.

Pomnilnik.

Ukazi.

In procesor, ki jih enega za drugim izvaja.

Včasih je prav prijetno odstraniti vse moderne plasti abstrakcije in se za nekaj časa vrniti tja.

Čisto do železa.

Dodaj odgovor

Vaš e-naslov ne bo objavljen. * označuje zahtevana polja