Zadanie 1: Konwerter plików binarnych

Data ogłoszenia: 01.03.2022

Termin oddania: 29.03.2022 (ostateczny 12.04.2022)

Wprowadzenie

Choć prawie wszystkie współczesne maszyny x86 są już 64-bitowe i odchodzi się już od 32-bitowego oprogramowania, wciąż zdarza się spotkać kod 32-bitowy kod binarny. Praktycznym narzędziem mogącym pozwolić na łączenie kodu 32-bitowego i 64-bitowego może okazać się konwerter zamieniające 64-bitowe pliki ELF na ich 32-bitowe odpowiedniki — na przykład, by używać pojedynczych funkcji z 64-bitowego pliku .o w 32-bitowym pliku .o.

Zadanie

Napisać konwerter 64-bitowych plików ET_REL w 32-bitowe pliki ET_REL, pozwalający wywoływać między nimi funkcje, dodający odpowiednie sekwencje kodu przechodzące pomiędzy trybami wykonania i dopasowujące konwencje przekazywania parametrów do funkcji.

Założenia

  1. Wejściami do procesu konwersji są 1 (jeden) 64-bitowy plik binarny w formacie ET_REL o architekturze EM_X86_64 oraz 1 (jeden) plik tekstowy z listą funkcji i ich typów (wyspecyfikowany niżej).

  2. Wyjściem z procesu konwersji powinien być 1 (jeden) 64-bitowy plik binarny w formacie ET_REL o architekturze EM_386.

  3. Plik ET_REL może zawierać:

    • dowolne sekcje z flagą SHF_ALLOC (powinny zostać skopiowane do skonwertowanego pliku, a jedyne zmiany w tych sekcjach powinny dotyczyć relokacji)

    • własne symbole typu STT_NOTYPE, STT_FUNC, STT_OBJECT, STT_SECTION (mogą też istnieć symbole STT_FILE, ale można je zignorować)

      • dla globalnych symboli typu STT_FUNC należy wygenerować kawałek kodu (stub) pozwalający wywołać odpowiednią funkcję z 64-bitowego kodu na podstawie typu funkcji z listy, zmienić oryginalny symbol na lokalny, i stworzyć nowy symbol globalny wskazujący na stuba

    • odwołania do symboli zewnętrznych; jeśli symbol zewnętrzny jest wymieniony w pliku z listą funkcji, należy wygenerować dla niego odpowiedni kawałek kodu (stub) pozwalający wywołać zewnętrzną 32-bitową funkcję z 64-bitowego kodu, skonwertować odwołania tak by odwoływały się do wygenerowanego stuba, a właściwą 32-bitową funkcję wywołać z samego stuba; jeśli symbol zewnętrzny nie jest wymieniony w tym pliku, należy go traktować jak symbol danych i nie konwertować go w ten sposób

    • relokacje następujących typów: R_X86_64_32/R_X86_64_32S, R_X86_64_PC32/R_X86_64_PLT32 — należy je skonwertować na relokacje R_386_32, R_386_PC32

  4. Nie trzeba obsługiwać następujących funkcjonalności:

    • symboli w “sekcji” SHN_COMMON bądź innych specjalnych sekcjach

    • innych typów symboli i relokacji niż podane powyżej

  5. Wygenerowany kod może zakładać, że wszystkie przekazywane wskaźniki i wartości long / unsigned long mieszczą się w 32 bitach. W szczególności, można założyć, że wskaźnik stosu zawiera się w niskich 4GB.

  6. Można założyć, że funkcje do których będziemy generować stuby mają co najwyżej 6 parametrów.

Plik z funkcjami

Plik z listą funkcji jest plikiem tekstowym, w którym każda niepusta linijka opisuje jedną funkcję. Każda definicja funkcji zawiera następujące pola, oddzielone sekwencjami białych znaków (spacji bądź tabów):

  • nazwa funkcji

  • typ wartości zwracanej z funkcji, jeden z:

    • void

    • int (32-bitowa liczba ze znakiem)

    • uint (32-bitowa liczba bez znaku)

    • long (32-bitowa liczba ze znakiem po stronie 32-bitowej, 64-bitowa po stronie 64-bitowej)

    • ulong (32-bitowa liczba bez znaku po stronie 32-bitowej, 64-bitowa po stronie 64-bitowej)

    • longlong (64-bitowa liczba ze znakiem)

    • ulonglong (64-bitowa liczba bez znaku)

    • ptr (wskaźnik — 32-bitowy po stronie 32-bitowej, 64-bitowy po stronie 64-bitowej)

  • typy parametrów do funkcji (0 lub więcej razy): takie same jak typy wartości zwracanej, poza void

Przykładowo:

fopen ptr ptr ptr
fclose int ptr
fputc int int ptr
fputs int ptr ptr

Odpowiadające:

FILE *fopen(const char *, const char *)
void fclose(FILE *)
int fputc(int, FILE *)
int fputs(const char *, FILE *)

Forma rozwiązania

Jako rozwiązanie należy dostarczyć paczkę zawierającą:

  • dowolną liczbę plików źródłowych z kodem rozwiązania w języku C, C++ lub Python

  • jeśli rozwiązanie jest napisane w języku kompilowanym, plik Makefile kompilujący rozwiązanie, lub odpowiadający plik z innego sensownego systemu budowania (np. cmake)

  • plik readme z krótkim opisem rozwiązania i instrukcjami kompilacji na obrazie qemu z pierwszych zajęć

Rozwiązanie (po ew. kompilacji) powinno znajdować się w pliku wykonywalnym o nazwie converter. Program powinien mieć następujący interfejs:

./converter <plik ET_REL> <plik z listą funkcji> <docelowy plik ET_REL>

Rozwiązania będą testowane wewnątrz qemu, uruchomionego z obrazem z pierwszych zajęć. Polecamy sprawdzenie, czy rozwiązania kompilują się w tym obrazie.

Rozwiązania prosimy nadsyłać na adres a.jackowski@mimuw.edu.pl z kopią do p.zuk@mimuw.edu.pl.

Zasady oceniania

Za zadanie można uzyskać do 10 punktów. Na ocenę zadania składają się dwie części:

  • wynik automatycznych testów (od 0 do 10 punktów)

  • ocena kodu rozwiązania (od 0 do -10 punktów)

Przechodzenie między trybem 32-bitowym a 64-bitowym

W architekturze x86, wybór trybu 32-bitowego bądź 64-bitowego jest dokonywany przez użycie odpowiedniego segmentu kodu. Aby przełączyć się między trybami, musimy użyć jednej z trzech instrukcji, które są w stanie zmienić aktywny segment kodu (i mogą być użyte w trybie użytkownika):

  • jmp far (ljmp w składni AT&T)

  • call far (lcall w składni AT&T)

  • ret far (lret w składni AT&T)

Aby przejść w tryb 32-bitowy, należy użyć jednej z powyższych instrukcji z docelowym selektorem segmentu kodu równym 0x23. Aby przejść w tryb 64-bitowy, należy użyć selektora 0x33. Po przejściu w tryb 32-bitowy należy ponadto ustawić segment danych na 0x2b zanim będziemy mogli odwołać się do pamięci, na przykład w następujący sposób:

pushl $0x2b
popl %ds
pushl $0x2b
popl %es

Należy pamiętać, że przejście w tryb 32-bitowy zniszczy stan rejestrów %r8-%r15 oraz górną część wszystkich rejestrów.

Należy pamiętać, że tryby 32-bitowy i 64-bitowy mają różne konwencje wywołań funkcji, w tym zbiór zachowanych rejestrów.

Należy pamietać o zapewnieniu odpowiedniego wyrównania stosu.

Wskazówki

Kompilacja z -m32

Czasami przydatny może być pakiet gcc-multilib, który rozwiązuje problem niektórych brakujących bibliotek przy kompilacji z -m32. Pakiet ten można zainstalować (również w maszynie wirtualnej przygotowanej na laby) przy pomocy apt-geta.

Sekwencje przechodzenia pomiędzy trybami wykonania

Stub wywołujący 32-bitową funkcję z 64-bitowego kodu może wyglądać np. następująco:

# long long fun(void *ptr, int x, long long y)
.code64
fun_stub:
# zapis rejestrów
pushq %rbx
pushq %rbp
pushq %r12
pushq %r13
pushq %r14
pushq %r15
# zapisujemy argumenty na stosie
subq $0x18, %rsp
movq %rdx, 8(%rsp)
movl %esi, 4(%rsp)
movl %edi, (%rsp)
# zmiana trybu
ljmpl *fun_addr_64to32

# część 32-bitowa
.code32
fun_stub_32:
# segmenty
pushl $0x2b
popl %ds
pushl $0x2b
popl %es
# wywołanie właściwej funkcji
call fun
# powrót
ljmpl *fun_addr_32to64

# znowu część 64-bitowa
.code64
fun_stub_64:
# konwertujemy wartość zwracaną
mov %eax, %eax
shlq $32, %rdx
orq %rdx, %rax
# zrzucamy rzeczy ze stosu i wracamy
addq $0x18, %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbp
popq %rbx
retq

fun_addr_64to32:
.long fun_stub_32
.long 0x23

fun_addr_32to64:
.long fun_stub_64
.long 0x33

Stub wywołujący 64-bitową funkcję z 32-bitowego kodu może wyglądać np. następujaco:

# long long fun(void *ptr, int x, long long y)
.code32
fun_stub:
# zapis rejestrów
pushl %edi
pushl %esi
# wyrównanie stosu
subl $4, %esp
# zmiana trybu
ljmpl *fun_addr_32to64

# część 64-bitowa
.code64
fun_stub_64:
# bierzemy argumenty ze stosu
movl 0x10(%rsp), %edi
movslq 0x14(%rsp), %rsi
movq 0x18(%rsp), $rdx
# wołamy właściwą funkcję
call fun
# konwersja wartości zwracanej
movq %rax, %rdx
shrq $32, %rdx
# powrót
ljmpl *fun_addr_64to32

.code32
fun_stub_32:
addl $4, %esp
popl %esi
popl %edi
retl

fun_addr_64to32:
.long fun_stub_32
.long 0x23

fun_addr_32to64:
.long fun_stub_64
.long 0x33