Assembler po ludzku

Assembler to dziwny temat. Z jednej strony, zdrowy rozsądek podpowiadałby, że to podstawa, bo warto rozumieć urządzenie, które się programuje. Z drugiej, uchodzi za trudny, dziwny, niezrozumiały i w ogóle niepraktyczny. Postrach kierunku, za czasów mojego studiowania informatyki. W tym tekście próbuję przedstawić to co warto wiedzieć o assemblerach w ogóle i o najpopularniejszym obecnie assemblerze x86-64. W prostych słowach, próbując pokazać, że warto rozumieć zasady jego działania, bo jeśli nie zmuszać się do pisania w nim programów (czego, słusznie, się już prawie nie robi) to okazuje się całkiem prosty i może pokazać programowanie od innej, ciekawej, strony.

w tym artykule

  1. Co to jest assembler?
  2. Kto potrzebuje assemblera?
  3. Na ile różnią się różne assemblery?
  4. Przykład: 64-bitowy x86
  5. Podsumowując

Co to jest assembler?

Assemblery to języki programowania, które charakteryzują się tym, że bezpośrednio oddają specyfikę danego procesora (można też mówić o assemblerze dla JVM, ale ten aspekt pominę, bo to trochę inna rzecz), niemal dosłownie przekładają się na kod maszynowy, który jest zrozumiały dla procesora. W związku z tym procesory x86(Intel i AMD) mają inny język assemblera niż np. ARM czy AVR. Poszczególne procesory z danej architektury są w dużej mierze kompatybilne, dzięki czemu mogą uruchamiać ten sam kod maszynowy, choć czasem różnią się wydajnością różnych operacji (na przykład za procesorów 32 bitowych, dystrybucje Linuksa były kompilowane specjalnie pod konkretne generacje x86, ze względu na optymalizację). Czasami też mamy do czynienia z rozkazami typowymi dla rozszerzeń procesora (np. związanych z wirtualizacją, czy bezpieczeństwem, jak SGX). W przypadku x86, 64-bitowy procesor może uruchomić 32-bitowy kod, ale nie na odwrót.

Pomimo, że każda architektura jest inna, assemblery zwykle mają bardzo podobną formę. Weźmy na przykład prostą implementację potęgowania:

int fastpow(int base, int exp) {
    int result = 1;
    for (int i = 0; i < exp; i++)
        result *= base;
    return result;
}

gcc -S -O3 zwróci następujący kod dla x86(32-bit):

fastpow:
    pushl   %ebx
    movl    12(%esp), %ecx
    movl    8(%esp),  %ebx

    testl    %ecx,     %ecx
    jle     .L4

    xorl    %edx,     %edx
    movl    $1,       %eax

.L3:
    incl    %edx
    imull   %ebx,     %eax

    cmpl    %edx,     %ecx
    jne     .L3

    popl    %ebx
    ret
.L4:
    movl    $1, %eax
    popl    %ebx
    ret

dla ARM:

fastpow:
    cmp     r1, #0
    movle   r2, #1
    ble     .L1

    mov     r3, #0
    mov     r2, #1
.L3:
    add     r3, r3, #1
    cmp     r1, r3
    mul     r2, r0, r2
    bne     .L3
.L1:
    mov     r0, r2
    bx      lr

Tu i tu mamy linie z instrukcjami w podobnej formie (choć mamy inne instrukcje i operandy), etykiety pozwalające odwoływać się do miejsc w kodzie. Pomijam tu kwestię dyrektyw, które pozwalają zamieścić dodatkowe informacje, jak instrukcje powinny być przełożone na kod maszynowy — czasem jedną instrukcję można przełożyć na kilka sposobów, procesory często mają kilka trybów działania, czasem chcemy zastosować wypełnienie między kawałkami kodu dla optymalizacji.

Kto potrzebuje assemblera?

W dzisiejszych czasach, zapotrzebowanie na znajomość assemblera jest właściwie marginalne, choć uważam, że każdy szanujący się programista powinien znać ogólną charakterystykę komputera na który programuje. Myślę, że tak niskopoziomowe podejście do programowania jest bardzo niepraktyczne, lecz zmienia perspektywę na programowanie, a to jest wartością samą w sobie.

Assembler jest swoistym łącznikiem między sprzętem i oprogramowaniem, jest innym poziomem abstrakcji. Projektowanie układów scalonych też jest rodzajem programowania, choć rządzi się innymi prawami. To nie jest tak, że sprzęt jest po to, żebyśmy mogli uruchomić kod. Sprzęt i kod są kompozycją i niektóre problemy lepiej zaimplementować w krzemie. Na przykład TPM zwiększa poziom bezpieczeństwa poprzez wyizolowanie implementacji od głównego procesora. Innym przykładem są akceleratory AI. Algorytmy AI zwykle zakładają wiele obliczeń, które mogą być znacznie zoptymalizowane na poziomie sprzętu. To przydatna wiedza jeśli chce się rozwiązywać problemy, a nie koniecznie klepać kod.

Oczywiście programowanie w krzemie i kodzie mają też podobieństwa. Stosunkowo wiele problemów powraca znów i znów na różnych poziomach. Weźmy na przykład taką pamięć podręczną, mechanizm stosowany zarówno w kodzie, jak i krzemie. Zatem rozwiązania ze świata sprzętu i assemblera mogą znajdować swoje odzwierciedlenie w wyższych warstwach.

Kodu w assemblerze nie pisze się już prawie w ogóle, choć nie zawsze tak było. We wczesnych latach 90tych było to jeszcze powszechne wśród programistów gier, chcących wyciągnąć jak najwięcej z procesora (teraz taką funkcję przejął C++), a jeszcze dawniej, zanim języki wysokiego poziomu stały się powszechne (myślę, że powstanie Fortranu, w 1957r. to dobre przybliżenie) programowanie w assemblerze, czy nawet bezpośrednio kodzie maszynowym, nie było niczym dziwnym.

Nawet firmware, przez długi czas będący domeną assemblera (BIOS), jest już pisany w większości w C. Firmware we współczesnych komputerach PC i MAC są oparte przeważnie na UEFI (Kod wspólnej podstawy, pozbawionej własności intelektualnych jest dostępny jako Tianocore/EDK2), Chromebooki stawiają na ściśle otwartoźródłowy projekt coreboot. W obu przypadkach użycie assemblera jest marginalne, tylko najbardziej podstawowa inicjalizacja tego wymaga. Zgodnie ze statystyką GitHuba, w obu przypadkach  1%, przy czym warto pamiętać, że jedna linia w C potrafi wygenerować kilka-kilkanaście linii assemblera.

Szczegółowa znajomość assemblera jest i raczej zawsze będzie domeną programistów kompilatorów i specjalistów od bezpieczeństwa. Ci pierwsi ze względu na to, że zadaniem kompilatora jest wygenerowanie kodu maszynowego (≈ assemblera). W bezpieczeństwie jest kilka powodów. Analitycy złośliwego oprogramowania nie mają kodów źródłowych, więc muszą zrozumieć działanie na podstawie kodu maszynowego.

Autorzy wirusów komputerowych muszą z zegarmistrzowską precyzją podmieniać kod maszynowy w plikach programów (choć to już wychodzi z użycia, dosyć jest dziur w przeglądarkach i klientach poczty). Niektóre ataki jednak wykorzystują luki, które wymagają użycia assemblera, jako przykład polecam przejrzeć pracę Attacking SMM Memory via Intel® CPU Cache Poisoning, dobrze pokazuje poziom znajomości o jakim mówimy — czarna magia. To jednak jest ekstraklasa, natomiast reverse-engineering jest codziennością analityka bezpieczeństwa, co jest już w zasięgu umiarkowanie inteligentnego człowieka mającego życie prywatne.

Na ile różnią się różne assemblery?

Konstrukcja procesora jest silnie uwarunkowana jego przeznaczeniem. Historia rozwoju x86 to w dużej mierze pogoń za wydajnością jaka miała miejsce w latach 90tych. Skalę zjawiska świetnie opisano w książce Game Engine Black Book: DOOM, która opisuje z czym mierzyli się programiści z ID software, tworząc legendarnego DooMa z 1994r. Natomiast ARM, dominujący rynek urządzeń przenośnych, stawia na minimalizm i niskie zużycie energii. Wprawdzie Intel walczy o udział w tym rynku, a ARM próbuje wchodzić na rynek serwerów, jednak to te tendencje najbardziej wpłynęły na to jak te platformy wyglądają.

Najbardziej podstawową miarą jest liczba rozkazów. W x86 mamy ok 1200 różnych instrukcji, często o bardzo wyspecjalizowanych funkcjach. W ARMv-7 (wariant obecny w m.in Raspberry Pi) mamy ok. 50 w 500 wariantach. ARM nie tylko jest w stanie kodować każdy rozkaz na 32 bitach, posiada też specjalny, ograniczony tryb, w którym rozkazy mają 16 bitów. ARMv-7 jest tak minimalistyczny, że nawet nie posiada specjalnego układu obsługującego arytmetykę zmiennoprzecinkową. W x86, niektóre operacje są kodowane na jednym bajcie, ale niektóre nawet na 15 bajtach (120 bitów).

Są nawet procesory VLIW (very long instruction word), w których każda instrukcja ma po kilkaset bitów, ale za to opisuje kilka działań wykonywanych w tym samym czasie, pod warunkiem, że każda używa innego podzespołu procesora (Pipelining, procesory x86 i ARM robią to w locie).

Również można wspomnieć assembler AVR (używany m.in. w Arduino), podobnie jak ARM, ma mniej instrukcji i pamięć RAM obsługuje tylko za pomocą operacji load i store (w x86 jest możliwość adresowania komórek pamięci w wielu innych instrukcjach). To co różni AVR od x86 i ARM to to, że oddziela pamięć rozkazów i danych. Te pierwsze zwykle przechowywane są w pamięci tylko do odczytu (ROM), a tylko dane w pamięci o dostępie swobodnym (RAM).

Choć te różnice są dość znaczne, to jednak assemblery jako takie są z reguły podobne do siebie. Dobre zrozumienie jednego daje bardzo dobry początek do nauki kolejnego. Aby nauczyć się czytać kod w innym assemblerze przeważnie wystarczy się pobieżnie zapoznać z ogólną specyfiką procesora i listą rozkazów procesora. W końcu każdy z nich zapewnia te same podstawowe funkcje, z których tworzymy większe. Jest to akurat ta część, którą najbardziej cenię w nauce assemblera i wymaga mniejszej ilości czasu, a zatem całkiem opłacalna do nauki.

Przykład: 64-bitowy x86

Ze względu na jego powszechność, najwięcej miałem do czynienia za assemblerem x86 i dlatego przedstawię go pokrótce, aby zaprezentować rzeczywistość programowania w assemblerze. Na wstępie zaznaczę, że daruję sobie większość cech x86 zarezerwowanych dla programistów firmware'u i systemów operacyjnych.

Procesor, a pamięć i sprzęt

Zasadniczo, procesor (CPU) ma bezpośredni dostęp do pamięci RAM i magistrali (obecny standard w PC to PCI-Express), która pośredniczy w komunikacji z innymi urządzeniami. Współcześnie, dostęp do sprzętu jest obsługiwany wyłącznie przez system operacyjny. Natomiast dostęp do pamięci jest powszechny i będzie pokazany dalej, wiele instrukcji może podać adres żeby odczytać lub zapisać coś w pamięci.

Dostęp do magistrali początkowo był odrębny i używał odrębnych instrukcji in i out (porty), a komórki pamięci były adresowane bezpośrednio. Obecnie jednak dostęp odbywa się za pomocą wirtualizacji pamięci. To oznacza, że dla każdego programu, system operacyjny określa zakresy pamięci, które są dostępne i definiuje czy odpowiadają określonemu zakresowi pamięci RAM lub portowi (MMIO). Niedozwolony dostęp do pamięci skutkuje dobrze znanym błędem Access Violation (Windows) lub Segmentation Fault (Linux). Sprzęt również ma możliwość powiadomić procesor, że wystąpiło jakieś zdarzenie za pomocą przerwania.

Dzięki temu, wszystkie adresy w pamięci mogą być ustalone już na etapie linkowania, a procesy zasadniczo nie mają dostępu do pamięci innych procesów (jest możliwe zaalokowanie pamięci współdzielonej przez procesy). Nowinką jest wspomniany SGX, który pozwala nawet na ochronę pamięci programu przed dostępem z systemu operacyjnego.

Warto wspomnieć, że procesor x86 zwykle jest wyposażony w 2-3 poziomową pamięć podręczną (cache). Główna przyczyna jest taka, że dostęp do pamięci RAM, choć szybki w stosunku do pamięci trwałych (dysk twardy, pamięć USB, itd.), jest bardzo wolny w porównaniu z wewnętrzną pamięcią procesora (rejestrami), która jest bardzo ograniczona. Pamięć podręczna pozwala przyspieszyć dostęp do pamięci gdy często korzystamy z jakiegoś zakresu pamięci. Również odczytując większy zakres, można taką sytuację przewidzieć i wczytać do pamięci podręcznej więcej niż oryginalnie zażądano.

Cache ma też mniej znane, lecz bardzo ciekawe zastosowanie w coreboot, otwartoźródłowej alternatywie dla UEFI. Mianowicie, współczesna pamięć RAM wymaga inicjalizacji, co jest dość skomplikowane. Aby umożliwić używanie pamięci, stosu (i dzięki temu również języka C), we wczesnej fazie, coreboot używa cache jako RAM.

Wreszcie, to by było bardzo nieefektywne, gdyby procesor musiał pośredniczyć w przesyłaniu danych z urządzeń do pamięci. Dlatego już dawno temu wprowadzono możliwość dostępu urządzeń do pamięci (DMA).

Rejestry

Procesory używają rejestrów do przechowywania danych pośrednich. W 64-bitowym x86 (często nazywany x86_64, x64, AMD64 lub Intel64) mamy do dyspozycji 16 64-bitowych rejestrów ogólnego przeznaczenia. Ich nazewnictwo jest dość niespójne ze względu na długą historię architektury. Oryginalne znaczenia się trochę zatarły, ale podaję je jako ciekawostkę i żeby łatwiej było zapamiętać. Pierwsze 4 to:

istnieją również nazwy pochodne, adresujące tylko część tego rejestru:

2 rejestry indeksowe:

one mają tylko trzy warianty częściowe: E_I (32 bity), _I (16 bitów), _IL (8 bitów).

2 rejestry służące do obsługi rejestru, są jednak uznawane za rejestry ogólnego przeznaczenia, bo można ich używać tak jak pozostałe:

Mają one warianty: E_P, _P, _PL.

Na samym końcu mamy mamy rejestry r8r15, dodane już w wariancie 64-bitowym. Mają warianty: r_d, r_w, r_b, odpowiednio od double word, word, byte.

Istnieją specjalne rejestry XMM0 — XMM15 (mają po 128 bitów), ymm0-ymm15 (256 bitów), ZMM0 — ZMM15 (512 bitów) oraz ST0 — ST7 (rejestry zmiennoprzecinkowe); każdemu z tych zestawów odpowiada specjalny zestaw instrukcji.

Wszystkich rejestrów jest znacznie więcej, jednak z punktu widzenia zwykłych programów istotne są już tylko RIP (instruction pointer, wskazuje adres następnej instrukcji do wykonania) i CFLAGS (rejestr flag oznaczających szczególne sytuacje, np. przepełnienie w dodawaniu, działanie dające wynik 0, itp.).

Podstawowe instrukcje

Na poziomie kodu maszynowego i assemblera, kod to tylko ciąg instrukcji zapisanych w pamięci z możliwymi skokami. Nic więcej. W kodzie assemblera znajdziemy jeszcze różnego rodzaju dyrektywy: deklaracje danych, deklaracje trybu procesora dla którego kod chcemy wygenerować czy dyrektywy dodające informacje dla debuggera. Mamy też etykiety, które w kodzie oznaczają adresy następujących po nich instrukcji lub danych. Jedna linia znacząca to jedna dyrektywa etykieta lub instrukcja. Sposób kodowania instrukcji i zestaw dyrektyw jest zależny od konkretnej implementacji. Poniżej przedstawiam dwie instrukcje zakodowane w NASM (najpopularniejszy) i AT&T (domyślna składnia GNU Assembera i w Linuksie w ogóle). Niektóre assemblery posiadają też możliwość definiowania makr.

    ; załaduj wartość 5 do rejestru RAX
    movq    $5,  %rax    ;AT&T
    mov     rax, 5       ;NASM

    ; pobierz wartość z 64-bitowej komórki pamięci
    ; pod adresem z RBX+2 i załaduj do RAX
    movq    2(%rbx), %rax       ;AT&T
    mov     rax,     [rbx + 2]  ;NASM

Wygląda strasznie? Przytoczę cytat z klasyka: „I don’t even see the code. All I see is blonde, brunette, red-head. Hey, you uh… want a drink?”. Można przywyknąć.

Przytoczę tutaj tylko kilka podstawowych instrukcji, które się często spotyka, tym bardziej, że celuję raczej w umiejętność czytania kodu, nie pisania go (w większości przypadków, masochizm). Po szczegóły odsyłam do listy rozkazów.

Chyba najczęstszą instrukcją jest właśnie MOV (move). Pozwala załadować do rejestru stałą, wartość innego rejestru bądź wartość komórki pamięci, lub wartość rejestru do komórki pamięci. Jest to odpowiednik operatora = w językach imperatywnych jak C czy Java.

Podobną instrukcją jest LEA (load effective address). Pozwala ono wziąć wyrażenie typu [rbx + 2] z powyższego przypadku i zapisać adres wynikowy, zamiast zawartości pamięci pod tym adresem. Warto zaznaczyć, że możliwości są większe, można nawet napisać tak: [rbx + rax * 4] (potraktuj rbx jako adres tablicy elementów o rozmiarze 4 i weź element o indeksie z rax). To dość potężne narzędzie i dlatego czasem jest używane do zwykłej arytmetyki, bo tak wychodzi po prostu szybciej. Rozważmy taką linię:

    lea rax, [rcx + rsi * 8]

robi ona dokładnie to samo (tyle, że szybciej), co:

    push rdx      ; wrzuć edx na stos, x86 nie mnoży przez stałą
    mov  rax, rsi ; załaduj offset (rsi) do eax
    mov  rdx, 8   ; wrzuć mnożnik do rdx
    mul  rdx      ; pomnóż
    add  rax, rcx ; dodaj do rax (wynik w rax)
    pop  rdx      ; przywróć wartość rdx

Jedyne ograniczenie jest takie, że mnożnik może wynosić tylko 2, 4 lub 8. Swoją drogą, ten kod dobrze ilustruje czemu programowanie w assemblerze rzadko ma sens. Na dłuższą metę już bardziej opłaca się stworzyć nowy język programowania, jeśli coś jest nie tak z C.

Dosyć prosto działają instrukcje dodawania (ADD), odejmowania (SUB) i operacje logiczne (OR, AND, XOR, SHL, SHR, itd.). Warto jednak zwrócić uwagę na to, że niektóre instrukcje istnieją w wielu różnych wariantach, również kwestia flag jest bardzo nieoczywista dla osoby, która nigdy nie używała assemblera. Tu też są popularne optymalizacje. xor eax, eax zajmuje mniej miejsca (dawniej było też szybsze) niż mov eax, 0. Podobnie używanie SHL i SHR są wygodniejszym i wydajniejszym sposobem na mnożenie i dzielenie przez potęgi dwójki.

Skoro mowa o mnożeniu i dzieleniu, tu już jest dosyć topornie, co można zobaczyć w powyższym przypadku. Zarówno MUL jak i DIV przyjmują tylko jeden parametr (dzielnik w przypadku dzielenia), który zawsze musi być rejestrem i w obu przypadkach używane są rejestry RAX i RDX. W przypadku mnożenia RAX jest mnożone przez argument, w RDX ląduje starsze 64 bity wyniku. Oznacza to, że zawsze mamy dostęp do pełnego wyniku, choćby miał 128 bitów. W przypadku dzielenia, symetrycznie RDX zawiera starsze 64-bity pierwszego argumentu dzielenia. Jest to o tyle podchwytliwe, że jeśli wynik dzielenia nie zmieści się w RAX, wystąpi wyjątek procesora (System operacyjny na ogół pozwala go obsłużyć), podobna sytuacja występuje gdy próbujemy dzielić przez zero. Wynik 128-bitowy nie jest dozwolony ze względu na to, że w RDX zostaje zapisana reszta z dzielenia.

Skoki i instrukcje warunkowe

Podstawową instrukcją skoku jest JMP. Nie ma tu za bardzo czego tłumaczyć, w parametrze podajemy tylko nazwę etykiety (ewentualnie adres względny lub bezwzględny). Jednak mało takie skakanie miałoby sensu. W przeciętnym programie większość skoków będą stanowić skoki warunkowe.

Skoki warunkowe przyjmują jeden parametr — miejsce do którego należy skoczyć. Skok zostanie wykonany lub nie, w zależności od instrukcji i stanu rejestru flag. Na ogół, wszystkie instrukcje wykonujące jakieś obliczenia zmieniają stan flag, na przykład możemy sprawdzić czy wynik dodawania przekracza rozmiar rejestru (flaga carry):

    add rax, rbx
    jc  przepelnienie

Innym razem możemy odejmować i jednocześnie sprawdzić znak wyniku:

    sub rax, rbx
    ja  rax_wiekszy ;jump if above
    jz  rowne       ;jump if zero (to samo co JE – jump if equal)

Istnieją jednak 2 instrukcje, które tylko ustawiają flagi. Jest to CMP (compare, ustawia flagi jak SUB) i TEST (działa jak AND) do sprawdzania masek bitowych.

Czasem mogą się również przydać instrukcje warunkowego MOV (CMOV_). Na przykład:

    cmp     rax, rbx ;  porównaj rax i rbx
    cmova   rax, 1   ;  rax = 1 jeśli rax > rbx
    cmove   rax, 0   ;  rax = 0 jeśli rax = rbx
    cmovb   rax, -1  ;  rax = -1 jeśli rax < rbx

Stos, funkcje

Stos daje szybki sposób na to, żeby zapamiętać jakąś wartość (PUSH) i potem ściągnąć (POP). Trzeba tylko pamiętać o ściąganiu w odpowiedniej kolejności, nie trzeba myśleć, gdzie w pamięci to położyć. Warto jednak zaznaczyć, że procesor nijak nie wie gdzie się stos zaczyna i gdzie się kończy się miejsce na niego przeznaczone (stąd stack overflow). Wierzchołek stosu jest zapisany w rejestrze RSP.

Stos jest również używany w mechanizmie wywoływania funkcji. Dawniej wszystkie parametry były przekazywane przez stos, w 64-bitowej architekturze konwencja zmieniła się i pierwsze 4 parametry przekazywane są przez rejestry RCX, RDX, R8 i R9, z wyjątkiem parametrów zmiennoprzecinkowych, które zajmują rejestry XMM0 — XMM3. Ewentualne struktury są przez referencję (na stosie umieszczany jest adres). Wartość zwracana umieszczana jest w rejestrze RAX.

Gdy parametry są umieszczone na swoich miejscach, może być wywołana instrukcja CALL (etykieta początku kodu funkcji w parametrze). Umieszcza ona adres następnej instrukcji na stosie i wykonuje skok do podanej etykiety. Analogicznie, w funkcji użyta jest instrukcja RET aby wykonać skok do adresu zapisanego na stosie. Oczywiście, żeby to zadziałało, stos musi być przywrócony do stanu z początku działania funkcji. Aby się upewnić się, że tak będzie, przeważnie funkcja zaczyna się następująco:

    push    rbp
    mov     rbp, rsp
    sub     rsp, x

gdzie x to ilość bajtów potrzebnych na zmienne lokalne. Wówczas możemy je łatwo adresować, używając RBP jako punkt odniesienia. Jest to opcjonalne, kompilator może je pominąć i używać RBP wedle uznania. Dla ułatwienia tego procesu, można użyć dedykowanych instrukcji ENTER i LEAVE odpowiednio na początku funkcji i zaraz przed instrukcją RET.

Są tu jeszcze dalsze niuanse, jak rejestry, które musimy przyjąć, że mogą być zmienione przez funkcję i takie, które nie powinny. Również kwestią wyboru jest to czy argumenty ze stosu zdejmuje funkcja wołająca (caller) czy wołana (callee). Szczegóły doczytać tutaj.

Biblioteki, systemowe API.

Każdy program w przestrzeni użytkownika musi polegać, w mniejszym lub większym stopniu, na wywołaniach zewnętrznych, czy to systemowego API czy bibliotek. Zasadniczo odbywa się to na trzy sposoby. Po pierwsze, wywołania funkcji za pomocą CALL (wówczas rolą linkera i programu ładującego systemu operacyjnego jest zapewnienie, że odpowiednia funkcja znajdzie się w oczekiwanym zakresie pamięci. W ten sposób możemy korzystać z biblioteki standardowej C i każdej innej biblioteki dostępnej dla tego języka. Jest to również sposób wywoływania WinApi w Windows.

Na ogól jest też możliwe załadowanie biblioteki dynamicznej (.dll, .so, .dylib, itd.) i załadowanie odpowiedniej funkcji w czasie programu, przy użyciu systemowego API.

W przypadku systemów takich jak Linux i FreeBSD możemy również skorzystać z mechanizmu wspieranego przez procesor (wywołanie SYSCALL, w architekturze 32-bitowej to było INTinterrupt). Opis wszystkich dostępnych wywołań można znaleźć tutaj, szczegóły działania poszczególnych wywołań można znaleźć w MAN.

Podsumowując

To właściwie wszystkie najistotniejsze, z punktu widzenia programów użytkowych, aspekty assemblera x86. Z jednej strony, trochę zawiłe, bo twórcy architektury próbowali jak najlepiej zagospodarować instrukcje i dać łatwy sposób na uniknięcie liczenia dwa razy tego samego (np. dzielenie i reszta z dzielenia wymagają tego samego działania) i wykorzystanie tego, że na poziomie układów scalonych zrównoleglanie jest łatwiejsze. Z drugiej, zakres funkcjonalności jest naprawdę niewielki (większość i tak zapożyczymy z wywołań systemowych lub bibliotek).

Z jednej strony, sprawia to, że programowanie w assemblerze jest bardzo uciążliwe (bo trzeba dużo pisać dla małego efektu, a w takim rozwlekłym kodzie trudno się połapać). Z drugiej, taka perspektywa w dużej mierze odczarowuje tajemnicę kodu maszynowego. Kiedyś myślałem, że to jakaś czarna magia, nikomu niedostępna. Teraz wiem, że może jest w nim sporo szumu, ale mimo wszystko są to proste rzeczy. Tu odczytać, tam zapisać, coś pozmieniać. Fakt, trzeba się przedzierać przez duże ilości kodu, ale jeśli wiemy czego szukać, nie musi być to wcale bardzo trudne.

Poza tym, ten sposób myślenia z jakim architektura została stworzona stanowi ciekawy kontrast do obecnej postawy programistów — „co tam wydajność, RAMu się dołoży i już…”. Nie to, żeby trzeba było walczyć o każdy bajt i milisekundę (choć czasem trzeba), nie można jednak warto popadać w drugą skrajność, zwłaszcza jeśli możemy optymalizować stosunkowo niewielkim kosztem.

W temacie reverse engineeringu (wydaje mi się, że to najbardziej atrakcyjna ścieżka w temacie około assemblera), naturalna kontynuacja to formaty plików wykonywalnych (PE — Windows, UEFI; ELF – Linux, FreeBSD; Mach-O — MacOS), disassemblery, debuggery. Formaty plików wykonywalnych to kolejna porcja informacji, jakich możemy się spodziewać, zaś narzędzia dają perspektywę na ile można sobie poradzić z natłokiem danych. To jednak temat na kolejny raz.

Spodobało ci się? Pozostańmy w kontakcie:

Email:
Imię:
Wiadomość:

Subskrybując nie tylko będziesz dostawać powiadomienia o nowych odcinkach, ale też krótkie nowości z świata programowania i wpływ na tematykę przyszłych treści. Twój adres i imię będą przechowywane wyłącznie w tym celu.

więcej artykułów…