Dynamiczna alokacja pamięci z poziomu assemblera [Linux]

Pisząc dzisiaj w assemblerze program zacząłem zastanawiać się, jak wygląda sprawa dynamicznej alokacji pamięci na stercie programowej z jego poziomu. Jak wiadomo, wszystkie dostępne assemblery umożliwiają tylko zaalokowanie stałej ilości pamięci w sekcji BSS lub sekcji danych, nie ma jednak sposobu na uzależnienie tej ilości od jakiejś liczby podawanej w runtime. Najbardziej oczywistym (i chyba też najbardziej efektywnym i najprostszym) rozwiązaniem jest zlinkowanie naszego programu z biblioteką C i wykorzystanie malloc.

Problem jednak pojawia się, gdy z jakichkolwiek powodów nie chcemy naszego programu linkować z libc – bo mamy taki kaprys, bo piszemy na platformę wbudowaną gdzie jej nie ma (chociaż to bardzo naciągany powód), bo uważamy że nasze metody alokacji pamięci będą o wiele ładniejsze i przyjemniejsze. Rozwiązaniem jest wykorzystanie wywołania systemowego (syscall) – pytanie tylko którego.

Pierwszymi wywołaniami narzucającymi się osobie szukającej są brk i sbrk, które zmieniają coś co podręcznik definiuje jako „program break”, czyli miejsce, gdzie kończy się sekcja niezainicjalizowanych danych programu. Przy wykorzystaniu tego pierwszego zmieniamy ten adres w sposób bezwzględny (czyli musimy podać nowy adres końca sekcji danych), natomiast argumentem tego drugiego jest liczba określająca, o ile bajtów ma on zostać zainkrementowany. Wywołań tych jednak nie będziemy używać, głównie z tego powodu, że wydają się one być stosunkowo trudne w obsłudze (gołe operacje na adresach, problemy z dealokacją miejsca – np. zaalokujemy dwa obszary jeden po drugim i chcemy pozbyć się tego pierwszego – będzie problem), a poza tym sam podręcznik odradza ich używania (co ciekawe, na rzecz wspomnianego już malloc). Ponadto wywołania te zostały usunięte z specyfikacji POSIX w roku 2001, co powinno samo w sobie mówić o tym ile są naprawdę warte.

Nasz cel osiągniemy używając wywołań mmap i munmap. Teoretycznie służą one do odtworzenia (zmapowania) w przestrzeni wirtualnej naszego procesu zawartości jakiegoś pliku – jednak mają one opcję tzw. anonymous mapping, czyli odwzorowania pamięci bez przypisania do pliku. Obszar pamięci w ten sposób zarezerwowany jest wtedy inicjalizowany zerami, co jest równoważne z wywołaniem funkcji C calloc. Dealokacją zajmuje się drugie wywołanie. Jedyny mankament jest taki, że do dealokacji musimy znać rozmiar zarezerwowanego obszaru pamięci, a nie sam jego adres (jak to jest w free).

Teraz trochę historii. mmap, jako jedna z najbardziej podstawowych funkcji kernela, została wprowadzona już w wersji 1.0. Niestety, tamte wersje jądra nie pozwalały na przesłanie przez rejestry więcej niż czterech parametrów wywołania (parametry od lewej do prawej w ebx, ecx, edx, esi), więc dla wywołań wymagających ich więcej potrzebne było ich „owinięcie” w pewną (zdefiniowaną przez jądro) strukturę i przesłanie jako parametr wskaźnika na tą strukturę. W przypadku mmap wyglądała ona tak (zapożyczone z linux-2.6.34.1/mm/mmap.c) :

struct mmap_arg_struct {
        unsigned long addr;
        unsigned long len;
        unsigned long prot;
        unsigned long flags;
        unsigned long fd;
        unsigned long offset;
};

Od jądra 1.3.0 wywołania systemowe mogły mieć 5 parametrów (dodano edi), a od 2.3.31 – sześć (dodano ebp). Aby to wykorzystać, napisane zostały nowe wywołania – funkcjonalnie równoważne starym, ale wykorzystujące możliwości nowych jąder. Tak więc mmap zamieniło się w mmap2, które jest dzisiaj używane. Na architekturze x64 nie ma nawet możliwości wywołania „starego” mmap, bo na niej absolutnie wszystkie argumenty (chociaż dalej może być ich maksymalnie 6) przekazywane są w rejestrach – kolejno rdi, rsi, rdx, r10, r8 i r9. W obydwu architekturach numer wywołania musi znajdować się w akumulatorze.

Przejdźmy jednak do konkretów, bo jakiś przydługawy ten wstęp. Prototypy naszych wywołań w rozumieniu C wyglądają następująco :

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

Teraz zerknijmy do asm/unistd_32.h, gdzie znajdziemy numery wywołań systemowych. Wyglądają następująco :

#define __NR_mmap 90
#define __NR_munmap 91
#define __NR_mmap2 192

Pierwszy numer to „stary” mmap, czyli ten który wymaga od nas wskaźnika na strukturę mmap_arg_struct. mmap2 to ten „nowy”, który wszystkie swoje argumenty przyjmuje w rejestrach. Skoro o argumentach mowa, dobrze byłoby wiedzieć, jak mają one wyglądać w naszym przypadku. No więc tak (munmap pominę, bo ich znaczenie chyba jest oczywiste) :

  • void *addr to początkowy adres odwzorowania, które chcemy utworzyć, traktowany jednak przez jądro tylko jako „wskazówka”. Jeśli jest równy NULL, to adres ten wybiera jądro (oczywiście jest on w obszarze pamięci wirtualnej procesu, który tą funkcję wywołał), i to właśnie nas interesuje.
  • size_t length to oczywiście rozmiar odwzorowania w bajtach.
  • int prot to flagi zabezpieczeń odwzorowania. Określają one, czy możemy z nim nic nie robić (PROT_NONE), czytać z niego (PROT_READ), zapisywać do niego (PROT_WRITE), czy też uruchamiać umieszczony w nim kod (PROT_EXEC). Flagi te są ze sobą ORowane celem pozyskania końcowej, w naszym przypadku interesują nas PROT_WRITE i PROT_READ, chyba że ktoś ma bardzo dziwne wymagania, to jeszcze może do tego dojść PROT_EXEC.
  • int flags określają zaś, czy do naszego odwzorowania mają dostęp inne procesy – może ono być prywatne (MAP_PRIVATE) lub współdzielone (MAP_SHARED). Parametr ten musi być logicznym OR jednej (i tylko jednej, co chyba jest jasne) z tych stałych i innych, których pełna lista znajduje się w podręczniku (mmap(2)). Nas interesuje tutaj jedna konkretna stała : MAP_ANONYMOUS, która oznacza, że mapowanie nie odbywa się do pliku. Niektórzy mogą też próbować z stałą MAP_UNINITIALIZED (wspieraną od jądra 2.6.33 i dostępną tylko jeśli zostało ono odpowiednio skonfigurowane), która nie inicjalizuje odwzorowań „anonimowych” zerami.
  • int fd to deskryptor pliku który ma zostać odwzorowany w pamięci. W przypadku MAP_ANONYMOUS parametr ten jest ignorowany. (chociaż, jeśli wierzyć podręcznikowe, to niektóre implementacje nawet w takim przypadku wymagają, by był on równy -1)
  • off_t offset określa, na jakiej pozycji rozpocząć odwzorowanie pliku. Jak powyższy, jest ignorowany przy MAP_ANONYMOUS.

Nazwy pisane WIELKIMI LITERAMI to oczywiście nazwy stałych, które zostały z#define’dowane w odpowiednich plikach nagłówkowych (tutaj bits/mman.h).

W asm/unistd_64.h mamy natomiast :

#define __NR_mmap 9
#define __NR_munmap 11

Jak już wspominałem, nie ma podziału na „stary” mmap i „nowy” mmap2 – istnieje tylko jeden, który przyjmuje parametry przez rejestry.

Wykorzystując tą całą wiedzę napisałem taki oto moduł w assemblerze (NASM), który udostępnia funkcje służące do alokacji i dealokacji pamięci przy wykorzystaniu mmap (tego nowego, rejestrowego). Wszystko jest zgodne z ABI dla i386 i AMD64, więc nie będzie problemu z wykorzystaniem tego chociażby z poziomu C – prototypy w źródłach. Niestety WordPress nie ma podkreślania składni asemblerowej. :(


bits 32

section .text

mmap2             equ 192
munmap             equ 91 ; asm/unistd_32.h
PROT_READ         equ 0x1
PROT_WRITE         equ 0x2
MAP_ANONYMOUS     equ 0x20
MAP_PRIVATE     equ 0x02 ; bits/mman.h

; void *mmap_alloc(size_t ile [ebp+8])
global mmap_alloc
mmap_alloc:
 push    ebp
 mov        ebp,    esp ; ramka stosu
 push     ebx
 push     edi
 push     esi ; rejestry zarezerwowane dla f-cji wywolujacej

 xor     ebx,    ebx ; NULLujemy (ABI mowi, ze NULL == 0) void *addr
 mov     ecx,    [ebp+8] ; size_t length - podany do funkcji parametr
 mov     edx,    PROT_READ | PROT_WRITE ; int prot
 mov     esi,    MAP_ANONYMOUS | MAP_PRIVATE ; int flags
 mov     edi,    -1 ; int fd
 ; w ebp powinien sie jeszcze znalezc offset, ale mmap go ignoruje i dziala
 ; poprawnie bez niego, wiec w ogole go nie przekazujemy

 mov     eax,    mmap2 ; numer wywolania do eax
 int     0x80
 ; wynik wywolania systemowego jest w eax, zwracamy go jako wynik funkcji

 pop     esi
 pop     edi
 pop     ebx ; przywracamy rejestry
 mov     esp,    ebp
 pop     ebp
 ret ; i powracamy do f-cji wywolujacej

; int mmap_free(void* p [ebp+8],size_t ile [ebp+12])
global mmap_free
mmap_free:
 push     ebp
 mov     ebp,    esp
 push     ebx

 mov     eax,    munmap
 mov     ebx,    [ebp+8]
 mov     ecx,    [ebp+12]
 int     0x80

 pop     ebx
 mov     esp,    ebp
 pop     ebp
 ret

I to samo dla x64…


bits 64

section .text

mmap             equ 9
munmap             equ 11 ; asm/unistd_64.h

PROT_READ         equ 0x1
PROT_WRITE         equ 0x2
MAP_PRIVATE     equ 0x02
MAP_ANONYMOUS     equ 0x20 ; bits/mman.h

global mmap_alloc
; void *mmap_alloc(size_t size [rdi])
mmap_alloc:
 push     rbp
 mov     rbp,    rsp

 mov     rax,    mmap
 mov     rsi,    rdi ; size_t length
 xor     rdi,    rdi ; void *addr (NULLujemy zeby dac kernelowi dowolnosc)
 mov     rdx,    PROT_READ | PROT_WRITE ; int prot
 mov     r10,    MAP_PRIVATE | MAP_ANONYMOUS ; int flags
 mov     r8,        -1 ; int fd (ignorowany)
 xor     r9,        r9 ; off_t offset (ignorowany)
 sysenter

 mov     rsp,    rbp
 pop     rbp
 ret

global mmap_free
; int mmap_free(void *addr [rdi],size_t size [rsi])
mmap_free:
 push     rbp
 mov     rbp,    rsp

 mov     rax,    munmap
 ; wszystko jest juz w odpowiednich rejestrach
 syscall

 mov     rsp,    rbp
 pop     rbp
 ret

(niestety widzę że na wordpressie się to wszystko porozjeżdżało, jak coś to na pastebin : i386 ; x64)

Tak przygotowane funkcje umożliwiają nam stosunkowo bezbolesną dynamiczną alokację pamięci w Linuksie z poziomu assemblera. Wątpię jednak, żeby była to metoda w jakikolwiek sposób lepsza bądź efektywniejsza od najzwyklejszego malloc (albo raczej calloc w tym przypadku) – i moim zdaniem (tak jak i ludzi z ##asm na freenode) o wiele wygodniej, prościej i lepiej po prostu pogodzić się z tym linkowaniem z libc i wykorzystać funkcje, które daje.

Na koniec jeszcze słówko o error-checking : jak nietrudno zauważyć, żadna z napisanych przeze mnie funkcji nie sprawdza wartości zwracanej przez mmap/munmap i przekazuje ją bezpośrednio jako swój wynik działania. mmap zwraca adres na początek odwzorowania, jeśli się ono powiodło – jeśli natomiast nie, to zwraca wartość -1, czyli 0xff..ff. Rozsądnie jest jednak założyć, że każda wartość w przedziale od -1 do -4095 oznacza jakiś błąd – mówi o tym oficjalnie ABI AMD64, nie wspomina zaś ABI i386 – jednak podczas pisania tych funkcji wiele razy natknąłem się na jakąś dziwaczny adres który adresem nie mógł być i był na pewno liczbą ujemną różną od -1. munmap zaś zwraca 0, jeśli usunięcie odwzorowania powiodło się i -1, jeśli nie.

Informacje o Daniel

freezingly cold soul
Ten wpis został opublikowany w kategorii komputer i oznaczony tagami , , , , , , , , , , . Dodaj zakładkę do bezpośredniego odnośnika.

Skomentuj

Wprowadź swoje dane lub kliknij jedną z tych ikon, aby się zalogować:

Logo WordPress.com

Komentujesz korzystając z konta WordPress.com. Log Out / Zmień )

Zdjęcie z Twittera

Komentujesz korzystając z konta Twitter. Log Out / Zmień )

Facebook photo

Komentujesz korzystając z konta Facebook. Log Out / Zmień )

Google+ photo

Komentujesz korzystając z konta Google+. Log Out / Zmień )

Connecting to %s