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.