WSTĘP
Pracując nad rozwojem systemu operacyjnego AROS nie raz spotkałem się z pytaniem, czy AROS wspiera uruchamianie klasycznego oprogramowania skompilowanego dla procesora z rodziny 68000. Poza jednym wyjątkiem odpowiedź zawsze była rozczarowaniem. Nie, ten system nie potrafi uruchomić klasycznego oprogramowania, chyba że sam jest uruchomiony na procesorze Motoroli. Przyczyna jest trywialna – taki tryb pracy nigdy nie był celem tego projektu; oprogramowanie zawsze miało działać natywnie na danym procesorze. Jest to koncepcja tak mocno zakorzeniona w AROS-ie, że 64-bitowa wersja dla procesorów firmy Intel nie jest w stanie (i nigdy nie będzie) uruchomić programów skompilowanych dla trybu 32-bitowego.
Mieszanie trybów pracy w przypadku procesorów tak bardzo od siebie różnych jak x86 i m68k jest dość karkołomnym zadaniem. Wykonalnym, owszem, ale związanym z ogromnym nakładem pracy przyćmiewającym sens przekucia takiej idei w gotowy produkt. Podstawowym problemem jest inna kolejność bajtów w 16-, 32- czy też 64-bitowych słowach. O ile produkty firmy Intel zapisują bajty od najmłodszego do najstarszego w kolejności rosnących adresów, czyli w tak zwanym trybie Little Endian, o tyle procesory Motoroli robią dokładnie odwrotnie; najmniej istotny bajt danego słowa jest zapisany pod najstarszym adresem. Jest to tryb Big Endian. Konwersja pomiędzy obydwoma formatami jest sama w sobie dość prosta, można ją zmieścić w jednej linijce kodu w języku C, wymaga jednak znajomości nie tylko rozmiaru danych, ale i kontekstu.
Dlaczego? Posłużę się przykładem. Wyobraźmy sobie dwa 16-bitowe słowa (X, Y) zapisane w pamięci RAM jedno po drugim. Każde z nich zostało zapisane w trybie Big Endian, całość wygląda w pamięci RAM następująco:
XH XL YH HL
gdzie XH i XL to odpowiednio starszy i młodszy bajt słowa X, a YH i YL analogicznie starszy i młodszy bajt słowa Y. Jaką wartość przeczyta program pracujący w trybie Little Endian? To zależy od tego, w jaki sposób odczyt będzie przeprowadzony. Przeczytamy dwa słowa po 16-bitów, otrzymamy w obu wypadkach „odwrócone” wartości, które trzeba będzie przekonwertować. Jeśli jednak zdecydujemy się na optymalizację kodu i przeczytamy wszystkie cztery bajty na raz, nie tylko kolejność bajtów w każdym ze słów będzie odwrócona, słowa X oraz Y zostaną zamienione miejscami. Konwersja będzie w tym wypadku możliwa tylko i wyłącznie wtedy, kiedy format danych odczytywanych z pamięci jest znany.
Wyżej wymieniony problem nie jest niestety wyssanym z palca argumentem za niedodawaniem emulacji do AROS-a. Problem taki pojawia się dość często, ponieważ celem każdego kompilatora jest stworzenie możliwie optymalnego kodu, zapisanie dwóch następujących po sobie 16-bitowych słów za pomocą jednej instrukcji procesora aż prosi się o zastosowanie. A ponieważ w trakcie kompilacji utracona zostaje informacja na temat kontekstu i struktur danych, hipotetyczna i niewidoczna dla użytkownika konwersja pomiędzy Big i Little Endian staje się niemalże niewykonalna. Owszem, mając informacje o strukturach systemowych można pokusić się przynajmniej o poprawny dostęp do nich, mimo ewentualnych optymalizacji, ale w każdym innym przypadku prawdopodobieństwo błędnej konwersji jest niezerowe.
DWUJĘZYCZNY ARM
Prace rozpoczęte nad portem AROS-a na platformę Raspberry Pi pozwoliły spojrzeć na kwestię kompatybilności z 680×0 z innej strony. Procesor zainstalowany w tym komputerze nie tylko posiada instrukcje CPU do konwersji danych między Big i Little Endian, ale też posiada możliwość pracy w obu trybach. Postanowiłem skorzystać z tej możliwości i tworząc port AROS-a na Raspberry Pi zdecydowałem się na „dużego Indianina”. Droga do emulacji m68k została otwarta. Pozostało znaleźć odpowiedni emulator procesora i opracować ABI pozwalające na wspólne korzystanie z komponentów systemu przez programy skompilowane dla obu procesorów.
Na przeszkodzie dość szybko stanęły problemy licencyjne. Owszem, istnieją dość dobre emulacje m68k, niektóre z nich działają nawet na procesorze ARM. Co z tego, skoro upublicznione są na przykład na licencji GPL, która wyklucza zastosowanie ich w systemie AROS. Co prawda licencja APL jest również wolna, ale nie narzuca tak wielu restrykcji jak GPL i z tego względu nie jest z nią kompatybilna. Z drugiej strony emulatory, których mógłbym użyć, prędkością nie grzeszą, bo interpretują rozkazy m68k na bieżąco i mają za zadanie możliwie wiernie emulować zachowanie oryginalnego procesora.
Z braku lepszych możliwości i z chęci nauczenia się czegoś nowego, postanowiłem spróbować sił i stworzyć od podstaw zupełnie nowy emulator. Tak pod koniec stycznia 2019 narodził się projekt Emu68, dostępny na GitHubie pod adresem https://github.com/michalsc/Emu68. Projekt jest udostępniony na licencji MPLv2.
Dwa rodzaje emulatorów
Zanim przedstawię główne założenia projektu i mechanikę w nim zaszytą muszę wyjaśnić osobom nieobeznanym z tematem dwa różne tryby pracy emulatorów: interpretery i emulację JIT.
Interpreter
W przypadku interpretera kod emulacji czyta instrukcje procesora 680×0 jedną po drugiej i dla każdej instrukcji wykonuje pewien podprogram odpowiadający jej działaniu. Jeżeli zachowana jest duża wierność oryginałowi, czas wykonania podprogramów będzie dość realnie odtwarzał ilość cykli potrzebnych procesorowi Motoroli. Odpowiednie podprogramy są skojarzone z instrukcjami 680×0 za pomocą tablicy, w optymalnym przypadku zawierającej 65536 elementów. Instrukcja CLR.L Dn mogła by być w takim emulatorze napisana na przykład tak:
void CLR_L_Dn(uint16_t opcode, m68k_context_t *ctx)
{
uint8_t reg = opcode & 7;
ctx->Dn[reg] = 0;
ctx->SR = ctx->SR & ~(SR_N | SR_C | SR_V);
ctx->SR = cts->SR | SR_Z;
ctx->PC += 2;
}
Co ta funkcja robi? Pobiera numer rejestru do wyzerowania z instrukcji, zapisuje zero w wybranym rejestrze w strukturze opisującej stan m68k, a następnie ustawia odpowiednio flagi stanu (zeruje flagi N, C, V i ustawia flagę Z) i przesuwa rejestr PC na następną instrukcję do wykonania.
Do zalet interpreterów należy prostota budowy, łatwość wykonania, duże możliwości debugowania kodu emulatora i możliwość bardzo wiernego odtworzenia procesora. Wadą jest wydajność daleka od optymalnej.
Emulator JIT
Sposób działania emulatora JIT (Just in Time, czyli w samą porę) różni się znacząco od wyżej opisanego interpretera. Taki emulator jest tak naprawdę połączeniem kompilatora i środowiska uruchomieniowego dla produktu kompilacji. Jak to działa? Emulator czyta instrukcje dla procesora 680×0 blokami, przy czym każdy z takich bloków jest kompilowany dla docelowego procesora. Bloki takie są następnie wykonywane przez procesor bez dodatkowych opóźnień. Każdy wygenerowany blok może być następnie wielokrotnie użyty bez konieczności ponownej kompilacji.
Do zalet takiej emulacji poza znacznie zwiększoną wydajnością należy bardzo duża swoboda w optymalizacji wygenerowanego kodu. Sam generator kodu może korzystać z szablonów – prekompilowanych fragmentów kodu napisanych np. w C, takich jak przykładowa funkcja podana powyżej albo generować całość kodu samodzielnie.
PROJEKT EMU68
Założenia
Projekt Emu68 jest emulatorem JIT, który ma wykonać kod m68k w możliwie krótkim czasie. Nie zachowuje zgodności z żadnym konkretnym modelem procesora Motoroli, czas wykonania instrukcji CPU może być różny, w zależności od położenia względem innych rozkazów dla procesora jak i w zależności od aktualnego stanu m68k jak i procesora wykonującego kod. Emulowany procesor ma pełny dostęp do 4 GB przestrzeni adresowej. W chwili obecnej implementacja nie jest jeszcze pełna, prace nie są jeszcze zakończone. Docelowo projekt miał pracować pod kontrolą systemu AROS, chociaż to akurat uległo zmianie przed paroma tygodniami. Ale o tym później.
Alokacja rejestrów
W moim emulatorze rejestry m68k, z dwoma drobnymi wyjątkami, nie mają na stałe przypisanych rejestrów procesora ARM, tych jest po prostu zbyt mało. Zamiast tego kompilator dysponuje pulą ośmiu tymczasowych rejestrów ARM ogólnego przeznaczenia. W przypadku braku wolnych elementów w puli, alokator zwolni automatycznie najmniej używany rejestr. Istnieje możliwość zwykłej alokacji, mapowania rejestru m68k oraz mapowania rejestru do zapisu. Rejestr może być dodatkowo oznaczony jako „brudny”.
W przypadku mapowania rejestru m68k, kompilator w pierwszej kolejności sprawdza czy istnieje już powiązanie między rejestrem m68k i rejestrem ARM. W przypadku jego braku generowany jest kod, który pobiera wartość danego rejestru ze stanu m68k do rejestru procesora ARM. Jeżeli dany rejestr zostanie oznaczony jako brudny, zostanie z powrotem zapisany do stanu m68k w momencie uwolnienia alokacji. Rejestr rezerwowany do zapisu jest automatycznie oznaczony jako brudny, a pierwotna wartość nie jest pobierana ze stanu m68k.
Kompilator
Kompilator analizuje kod m68k generując odpowiadający mu ciąg instrukcji dla procesora ARM. Rejestry tymczasowe jak i rejestry 680×0 są pobierane z alokatora opisanego powyżej. Kompilator przetwarza do 256 instrukcji m68k, przy czym skoki bezwarunkowe przerywają proces. Z drugiej strony, odgałęzienia pozwalają na powrót ze skompilowanego kodu w przypadku spełnienia warunku.
Każdy wygenerowany blok rozpoczyna się standardową ramką zapisującą stan użytych rejestrów ARM na stosie, włączającą tryb Big Endian oraz załadowaniem flag procesora i wskaźnika aktualnie wykonywanej instrukcji m68k do dwóch rejestrów ARM. W tym samym czasie inicjalizowany jest alokator rejestrów. W następnej kolejności kod m68k jest analizowany instrukcja po instrukcji i kompilowany. Długość kodu zależy w tym wypadku od konkretnej instrukcji, aktualnej relacji pomiędzy rejestrami m68k i ARM, jak i użytych trybów adresowania. Skok bezwarunkowy umieszcza w kodzie specjalny znacznik informujący kompilator o zakończeniu translacji. W tym momencie alokator rejestrów zostaje wyzerowany, wszystkie „brudne” rejestry m68k będące w rejestrach ARM zapisywane są do stanu 680×0 i tworzona zostaje końcowa ramka stosu. Blok jest gotowy do wykonania.
Optymalizacje
Poza alokatorem rejestrów, którego użycie automatycznie skraca długość wygenerowanego kodu, kompilator JIT przeprowadza dwie kolejne optymalizacje. Teoretycznie wskaźnik na kolejną instrukcję m68k do wykonania powinien być bez przerwy aktualizowany, ponieważ korzystają z niego chociażby instrukcje dla procesora ARM. Synchronizacja przebiega po stronie kompilatora, ale w wygenerowanym kodzie aktualizacje następują tak rzadko jak to tylko możliwe. Dzięki temu wiele instrukcji m68k może być skompilowanych do jednej, maksymalnie dwóch instrukcji ARM, znacząco przyspieszając wynikowy kod.
Druga istotna optymalizacja związana jest z flagami stanu: Z, X, C, V, N. Kompilator aktualizuje tylko i wyłącznie te flagi, które nie będą modyfikowane przez kolejną instrukcję. Dzięki temu w przypadku np. ciągu 100 instrukcji, gdzie każda z nich modyfikuje flagi stanu, tylko ostatnia z nich spowoduje aktualizacje; inne nie będą wygenerowane.
Optymalizacja przewidziana na przyszłość to rozwijanie skoków do absolutnego adresu – w tym wypadku kompilator niejako „sklei” dwa wygenerowane fragmenty kodu tworząc ciąg instrukcji ARM pozbawiony skoku i nie przerywając procesu kompilacji.
Cache procesora m68k
Kompilowanie kodu m68k za każdym razem od nowa zabiłoby natychmiast wszelkie zyski wydajności. Dlatego też emulator ma do dyspozycji 8 MB pamięci cache. Skompilowane fragmenty kodu są mianowicie umieszczone w specjalnej tablicy umożliwiającej bardzo szybkie znalezienie skompilowanego fragmentu kodu na podstawie adresu instrukcji m68k. Każdy blok może być usunięty z cache w jednym z dwóch przypadków – albo gdy brak miejsca dla nowo generowanego kodu albo, jeśli po stronie m68k zostanie wykonana instrukcja czyszcząca pamięć cache.
Środowisko uruchomieniowe
Początkowo zakładałem uruchomienie emulatora pod kontrolą AROS-a, ostatnio jednak postanowiłem wprowadzić zmiany. W tej chwili środowisko uruchomieniowe pracuje bez pośrednictwa jakiegokolwiek systemu operacyjnego. W momencie startu komputera rezerwuje dla własnych celów 8 MB przestrzeni adresowej, aktywuje cache procesora, ustawia maksymalną częstotliwość pracy, tworzy tabele dla jednostki zarządzania pamięcią (MMU) i uruchamia główną pętlę emulatora. Tam ładowany jest wskaźnik na kolejną instrukcję m68k do wykonania, odpowiedni fragment kodu jest wyszukiwany w cache i w przypadku niepowodzenia, kompilowany. Po wykonaniu kodu emulator wraca na początek pętli. Skok do adresu 0, przerywa w tej chwili pętlę i kończy emulację.
Moja praca z kodem
Nad kodem emulatora, tak jak i nad większością moich projektów, pracuję na laptopie na zmianę z moim desktopowym komputerem. Oba pracują pod kontrolą systemu macOS. Projekt jest budowany za pomocą kompilatora skrośnego dla procesorów ARM w trybie Little Endian. W przyszłości kompilator zostanie wymieniony na wersję z bibliotekami dla trybu Big Endian dając dodatkowy zysk wydajności. Do kontroli wersji używam programu git, do pisania zaś VS Code. Programy testowe dla m68k kompiluję za pomocą gcc6 (kompilator bebbo) i linkuję (konsoliduję) pod stały adres za pomocą programu vlink.
Skompilowany kod dla ARM jest testowany dwojako. Wersja budowana dla systemu linux uruchamiana jest w maszynie wirtualnej (Docker) za pomocą programu qemu-arm. Wersja budowana dla Raspberry Pi jest uruchamiana na tej maszynie albo po przegraniu na kartę microSD albo jest pobierana przez sieć z lokalnego serwera tftp. Wyniki pracy programu wysyłane są przez port szeregowy i oglądane/zapisywane na moim głównym komputerze.
Tworzenie emulatora wymaga ode mnie bezustannej pracy z dokumentacją procesorów z rodziny 680×0 jak i ARM. Musiałem stworzyć od podstaw mechanizm wyliczania adresu efektywnego i przełożyć go na instrukcje wykonalne dla Raspberry Pi. Następnie musiałem stworzyć generatory kodu dla każdej możliwej instrukcji m68k i przetestować je z przykładowymi fragmentami kodu. Ponieważ nie mam żadnego systemu operacyjnego który mógłby mi pomóc (dałby mi chociażby możliwość użycia debugera), jedyna możliwość to wypisanie w postaci binarnej wygenerowanego kodu, jego analiza i porównanie z kodem źródłowym dla procesora m68k. Jest to mozolny i czasochłonny proces pełen frustracji i niespodzianek, dający jednak porządny „strzał” dopaminy w momencie, w którym wszystko nagle działa tak jak powinno.
Co z tego będzie?
Na chwilę obecną dopracowuję kod kompilatora i dodaję wsparcie dla brakujących instrukcji. Poza tym testuję krótkie programy skompilowane dla m68k i rozmawiające bezpośrednio z peryferiami Raspberry Pi. Wykonałem też bardziej syntetyczny test, w którym skonwertowałem 4-bitowy obraz w formacie chunky pixel na format planarny – wykorzystałem w tym celu procedurę Roberta Szackiego. Wynik przerósł moje najśmielsze oczekiwania. Olbrzymia bitmapa o rozmiarze 25600 na 4800 pikseli została skonwertowana w ułamku sekundy, wydajność mojego emulatora osiągnęła 700 milionów instrukcji na sekundę na maszynie Raspberry Pi 3b+.
No dobrze, ale po co to wszystko? Co z tego będzie i dlaczego emulator ma działać bezpośrednio na Raspberry Pi? Plan jest taki, żeby całego AROS-a skompilować dla procesora 680×0, łącznie ze sterownikami sprzętowymi do wszystkich komponentów. Biorąc pod uwagę lekkość systemu jak i starych programów dla Amigi takie rozwiązanie powinno być wystarczająco wydajne. Jeżeli zaś chodzi o kompatybilność, wszystko co korzysta z dobrodziejstw systemu operacyjnego powinno funkcjonować bez problemu. Gorzej będzie z grami i demami, które odwołują się bezpośrednio do sprzętu albo wymagają emulacji procesora dokładnej co do cyklu; takie rzeczy nie zadziałają wcale.
Czy rozwiązanie takie zyska jakąkolwiek popularność? Tego niestety nie wiem. Jedna biorąc pod uwagę cenę Raspberry Pi wydaje mi się, że wiele osób mogłoby być zainteresowane chociażby pobieżnym testem taniej wirtualnej maszyny m68k kilku(nasto)krotnie szybszej od akceleratora Vampire.
’mschulz’ – Amiga NG (8) 3/2019