Laboratorium programowania komputerów
Transkrypt
Laboratorium programowania komputerów
Laboratorium programowania komputerów Temat: Stworzenie prostej maszyny wirtualnej Autor: Tomasz (bla) Fortuna <[email protected]> Kierunek: Informatyka, sem. II, gr. I Prowadzący: dr Agnieszka Debudaj-Grabysz Spis treści Spis treści 1 Wstęp i analiza zadania 1.1 Struktury danych . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2 Algorytmy . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 3 3 2 Specyfikacja zewnętrzna 2.1 Programy wchodzące w skład projektu NVM . . . . . . . . . . . . . . . . 2.2 Formaty plików i danych . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3 Nagłówek nvmo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 4 5 6 3 Specyfikacja wewnętrzna 3.1 Kod wspólny . . . . 3.2 NVMasm . . . . . . 3.3 NVM . . . . . . . . . 3.4 NVMcompile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Podsumowanie . . . . . . . . . . . . . . . . 6 . 6 . 8 . 9 . 10 11 1 Wstęp i analiza zadania Celem projektu jest stworzenie prostej maszyny wirtualnej, składającej się z następujących programów: 1. Środowiska uruchomieniowego. 2. Kompilatora języka niskiego poziomu (zwanego dalej „asemblerem”). 3. Dekompilatora/disasemblera. 4. Kompilatora języka wyższego poziomu (Mimo braku ostatecznej nazwy będę nazywał go albo „Językiem”, albo „bLang”). Projekt, jako całość, otrzymał nazwę NVM (będącą oczywiście rekursywnym akronimem), natomiast poszczególne w/w programy otrzymały kolejno nazwy: NVM, NVMasm, NVMdump, NVMcompile. Trzy pierwsze z nich zostały napisane w języku C, natomiast do napisania NVMcompile posłużył mi yacc i lex w wersji stworzonej dla języka OCaml. Dlatego też ten program jest jak gdyby dodatkiem, a nie samym sednem zadania i nie poświęcę mu wiele uwagi w tym opracowaniu. Z założeń, jakie podjąłem przy tworzeniu projektu warto wymienić przenośność, szybkość i skalowalność. • NVM, NVMasm, NVMdump i NVMcompile powinny być przenośne przynajmniej między platformami x86, x86 64 oraz x86 Cygwin. Natomiast same środowisko uruchomieniowe chciałem ostatecznie przenieść na mikroprocesory AVR. 2 1 Wstęp i analiza zadania • Poświęciłem trochę czasu na optymalizację procesu odczytywania opcodów1 oraz starałem się tak dobrać algorytmy pracy programu, aby wykonywanie instrukcji było jak najszybsze. W przykładach załączony jest prosty program Testcases.nvm, który służył mi do badania czasu potrzebnego na wykonanie kodu bajtowego. • Maszynę można skompilować w paru trybach pracy: w trybie standardowym ustawiającym wielkość słowa maszyny na 32 bity, oraz w pozostałych: 8,16,64 bitach. Ilość bitów ma tu wpływ głównie na: zakres pamięci możliwej do zaadresowania, szerokość danych czytanych/zapisywanych do pamięci, wielkość rejestrów NVM, oraz wielkość plików wykonywalnych. W bardzo prosty sposób można dodawać do NVM nowe operacje. 1.1 Struktury danych Ze względu na chęć osiągnięcia dobrych wyników w prędkości działania maszyny w samym NVM większość struktur jest statyczna. Na przykład struktura będąca sercem wszystkich 3 programów, przechowująca nazwy mnemoników, typy ich parametrów oraz kody operacji. Pamięć jest alokowana tylko w konieczności — podczas operacji na ciągach znaków, gdy wcześniej zarezerwowana pamięć przestaje wystarczać. Jednak sam stan maszyny wirtualnej jest przechowywany w pojedynczej strukturze, która jest alokowana i zwalniana przy pomocy odpowiednich funkcji. O wiele bardziej wyrafinowanych struktur musiałem użyć w NVMasm, gdzie podczas działania budowana jest lista etykiet o znanej wartości, oraz tych o wartości nieznanej wraz z podlistą zawierającą miejsca ich wystąpień. Dzięki tej liście, po przejrzeniu całego pliku źródłowego i ustaleniu adresów/wartości wszystkich etykiet, możliwe jest uzupełnienie operacji, które korzystały z jeszcze nie odnalezionych adresów. Także w przypadku istnienia nie odnalezionej etykiety można użytkownikowi wyświetlić wiadomość informującą go w której linijce wystąpiło odwołanie. NVMcompile ponadto używa tablic adresowanych za pomocą funkcji mieszających2 do trzymania listy zmiennych, a także automatów skończonych. Wszystkie te struktury są jednak zaimplementowane natywnie w języku. 1.2 Algorytmy NVM, trzeba przyznać, nie zawiera żadnych złożonych algorytmicznie problemów. Chciałem natomiast opisać przebieg wykonywania kodu bajtowego przez środowisko uruchomieniowe oraz sposób wczytywania plików przez parser3 i lekser4 . Jest to jedynie opis najważniejszych czynności i całego przebiegu. Algorytmy zastosowane w niektórych z użytych tutaj funkcji są bardziej szczegółowo opisane w specyfikacji wewnętrznej. Wykonywanie kodu bajtowego: 1 liczba kodująca operacje w kodzie bajtowym lub kodzie skompilowanym natywnie ang. hashtable 3 analizator składniowy 4 analizator leksykalny 2 3 2 Specyfikacja zewnętrzna 1. W aktualnej wersji uruchamianie programu zaczyna się od wczytania całego kodu do pamięci. W przyszłości będzie to oparte o funkcje zwrotne5 podające kolejne bajty lub bajty o konkretnym adresie (które np. będą odczytywać program z pamięci SD/MMC) 2. VMExecute(); (VM/Execute.c) wykonuje w pętli następujące dwa kroki: 3. Code2OP(); (VM/OP.c) Przeprowadza konwersję kodu bajtowego na strukturę OP (VM/OP.h). 4. VMExecuteOP(); (VM/Execute.c) Wykonuje operację opisaną strukturą OP. Proces kompilacji kodu asemblera: 1. main() z NVMasm.c; Otwiera pliki wejściowy i wyjściowy; Następnie w pętli wywołuje ParseLine(), aż do zakończenia pliku wejściowego. 2. ParseLine(); Za pomocą danych ze statycznej tabeli operacji buduje struktury opisujące kolejne operacje i zapisuje w tabeli dynamicznej. Do czytania pliku używa funkcji parsera (Parser.c), które omijają komentarze i białe znaki. Do określania wartości stałych oraz etykiet wykorzystuje funkcje z pliku Lexer.c 3. Po zbudowaniu tabeli operacji wywoływana jest funkcja leksera, która uzupełnia brakujące adresy w tabeli operacji i wyświetla błąd, jeśli któraś etykieta pozostała nie znaleziona. 4. Po powrocie do main() z NVMasm.c wykonywany jest OP2Code na całej tabeli operacji, konwertując ją do kodu bajtowego. Następnie zamykane są pliki i zwalniana pamięć. 2 Specyfikacja zewnętrzna 2.1 Programy wchodzące w skład projektu NVM Program ma prosty tekstowy interfejs. W jego skład wchodzą 4 programy: 1. NVMasm; kompilator asemblera. Na wejście pobiera kod w języku NVM Assembler, znajdujący się w plikach o rozszerzeniu nvm, oraz zwraca plik zawierający kod bajtowy o rozszerzeniu nvmo. 2. NVMdump; disasembler kodu bajtowego NVM. Jako parametr przyjmuje jeden plik nvmo. Nie tworzy żadnych plików, jego wyjście to po prostu zrzut operacji zapisanych w pliku. 3. NVM; środowisko uruchomieniowe. Jako parametr przyjmuje nazwę pliku nvmo, który ma za zadanie wykonać. 5 ang. callback functions 4 2 Specyfikacja zewnętrzna 4. NVMcompile; kompilator języka bLang. Pobiera on jako opcję plik o rozszerzeniu nvml, zawierający program w w/w języku, a jego wyjściem jest plik o rozszerzeniu nvm zawierający kod asemblerowy. Jeśli nie zostanie sprecyzowany plik wyjściowy za pomocą opcji „-o”, zostanie on nazwany out.nvm. Tak więc, żeby skompilować a następnie uruchomić program zapisany w pliku nvml, wykonalibyśmy przykładowo następujące instrukcje: ./NVMcompile -o program.nvm program.nvml ./NVMasm program.nvm program.nvmo ./NVM program.nvmo 2.2 Formaty plików i danych Składnię plików nvm, wraz z listą obsługiwanych operacji szczegółowo omówiłem w załączonym pliku Reference.pdf (Reference.latex). Nie chciałbym się powtarzać, dlatego przedstawię tylko przykład poprawnego pliku nvm: .equ .equ Number Str 99 0 ; Stałe numeryczne definiowane przez użytkownika ALLOC ASETS MOV 100 Str R1 ; Insrukcja alokująca pamięć na stos i dane. ’ bottles standing on the wall\n’ A ; Zapisanie długości ciągu w rejestrze R1 SET R0 Number ; Ustawienie rejestru na stałą wartość. DEC PUTI APUTS R0 R0 Str ; Zmniejszenie R0 o 1 ; Wyświetlenie R0 R1 ; Wyświetlenie R1 bajtów zaczynając od adresu Str MOV JNZ DIE A Begin 0 R0 ; Skok do begin jeśli R0 jest różne od 0. Begin: ; Zakończenie programu oraz krótki przykład programu napisanego w bLangu (specyfikacja języka do znalezienia w pliku Docs/NVMLang), wyświetlającego za pomocą rekursywnej funkcji parę pierwszych wyrazów ciągu fibonacciego: def fibo($a, $b) $c = $a + $b; puti($a); puts($Sep); $a = $b; 5 3 Specyfikacja wewnętrzna $b = $c; if ($c < 10000) call fibo($a, $b); ;; ;; def start($test) $Sep = ’\n’; puts(’Fibonacci series counted recursively:\n’); call fibo(1, 1); ;; W ramach czasu wolnego warto poanalizować asembler, wygenerowany przez kompilator dla tego fragmentu kodu. Poszczególne fragmenty jak np. odwołania do zmiennych lokalnych (ALV — Access Local Variable) lub ich modyfikacje (ULV), są tam dość wyraźnie oznaczone komentarzami. 2.3 Nagłówek nvmo Pliki nvmo wyposażone są w dziesięcio-bajtowy nagłówek, który opisuje ich typ. Nagłówek zaczyna się zawsze literami „NVM”, po których następuje informacja o szerokości słowa maszyny dla której dany program był kompilowany (np. „32”) oraz na końcu oznaczenie typu pliku i bajty zarezerwowane równe 0. Aktualnie wykorzystywane jest tylko oznaczenie „O”, informujące, że jest to „object file” — plik z kodem bajtowym. W przyszłości chcę dodać typ „D”, będący zrzutem stanu pamięci maszyny wraz z wykonywanym kodem. Nagłówek ten zabezpiecza przed odpaleniem plików, nie będących plikami kodu bajtowego NVM, lub plików, które były kompilowane pod inny typ maszyny wirtualnej. 3 Specyfikacja wewnętrzna 3.1 Kod wspólny Wszystkie trzy programy, tj. NVM, NVMasm i NVMdump, wykorzystują pewne wspólne pliki. Jest to kod zawarty w katalogu General (używany do usuwania błędów) oraz definicje typów (Types.h). Jednak przede wszystkim są to pliki VM/OP.c i VM/OP.h, które stanowią właściwie serce maszyny wirtualnej. W pliku VM/OP.h zawarta jest tabela operacji VM-a, składająca się z następującej struktury: enum Parm { P_UNUSED, P_REG, P_DATA, P_ADDR }; 6 3 Specyfikacja wewnętrzna struct OPData { enum Parm A, B; /* Typ pierwszego i drugiego parametru */ CHAR *Mnemonic; }; Zawiera ona informacje o tym, jakich parametrów wymagają poszczególne operacje, a także ich mnemoniki. Adres operacji w tej tabeli jest tożsamy z jej opcodem — wartością identyfikującą operacje w pliku kodu bajtowego. Ponadto zdefiniowany jest tam enumerator, dzięki któremu w programie można używać mnemoników operacji, zamiast ich opcodów, do adresowania tej tabeli. Ponadto znajdziemy tam inną ważną strukturę: typedef struct OP { enum Operation Type; union { UCHAR R; VDATA Data; VADDR Address; } A, B; /* Numer rejestru */ /* Dane */ /* Adres */ VREG OtherLen; /* Dane o ciągu znaków wykorzystywane przez */ VREG OtherAlloc;/* operacje SETS */ CHAR *Other; } OP; opisuje ona jaką operację powinna wykonać maszyna wirtualna. Zawiera typ operacji — opcode, oraz, jeśli operacja wymaga parametrów — ich wartości. Plik OP.c zawiera dwie funkcje, używane w całym NVM, wykorzystujące te deklaracje. Są to OP2Code oraz Code2OP. OP2Code konwertuje strukturę opisującą operację na jej kod bajtowy. Przyjmuje dwa parametry: wskaźnik do wielkości zwracanego kodu oraz strukturę OP. Przebieg generowania kodu: 1. Ustalenie za pomocą tabeli opcodów istnienia, a także typu pierwszego parametru. Jeśli istnieje, kopiowane do chwilowego buffora są dane z podanej parametrem struktury OP. Ustalana jest również wielkość tych danych. 2. Analogiczna operacja przeprowadzana jest dla drugiego parametru operacji. 3. Alokowana jest pamięć na ostateczny kod, następnie zapisywane do niej są: opcode, dane pierwszego parametru, dane drugiego parametru. 4. Jeśli operacja wymaga dodatkowych danych (Jak np. ciągu znaków, dla operacji z rodziny SETS ) zapisywana jest wielkość tych danych, a następnie same te dane. 5. Funkcja zwraca stworzony bajt kod. 7 3 Specyfikacja wewnętrzna Funkcją, która przeprowadza tą operację w drugim kierunku jest Code2OP. Przyjmuje ona wskaźnik do bajt kodu, jego wielkość, oraz wskaźnik do zmiennej liczbowej, w której musi umieścić informację o ilości bajtów, którą przeczytała. W celu przyśpieszenia procesu analizy bajt kodu, funkcja nie alokuje pamięci jeśli nie musi. Dane umieszcza w lokalnej-statycznej strukturze, której wskaźnik zwraca po poprawnym zakończeniu. Przebieg konwersji: 1. Odczyt numeru operacji (opcode’u). 2. Zależnie od danych odczytanych w tablicy operacji dla danego opcode’u następuje wczytanie parametrów operacji. Podobnie dla pierwszego jak i dla drugiego argumentu. Po wczytaniu danych każdorazowo zwiększany jest wskaźnik ilości odczytanych bajtów. 3. Jeśli operacja należy do rodziny SETS wczytywana jest wielkość danych drugiego parametru, a następnie pozostałe dane. Na te dane alokowana jest pamięć, jeśli pamięć wcześniej zarezerwowana nie jest wystarczająca. Również jeśli ilości pamięci zarezerwowanej jest duża w porównaniu z potrzebną (co najmniej dziesięciokrotnie) zbędna pamięć jest zwalniana. 4. Po zapisaniu danych do statycznej struktury, funkcja zwraca jej wskaźnik. 3.2 NVMasm NVM Assembler jest podzielony na 3 fragmenty: kod podstawowy (NVMasm/NVMasm.c), parser (NVMasm/Parser.c) oraz lekser (NVMasm/Lexer.c) Parser zawiera funkcje służące do wczytywania słów, ciągów znaków lub do omijania białych znaków i komentarzy. Służy po prostu do wygodnego wczytywania kodu asemblera. Lexer zawiera struktury służące do określenia semantycznej wartości etykiet, konwersji nazw rejestrów i mnemoników do odpowiadających im numerów oraz funkcje uzupełniające brakujące wartości parametrów tych operacji, które korzystały z nie odnalezionych etykiet. enum Argument enum LabelType {First, Second}; {DataLabel, AddrLabel}; /* Struktura wykorzystania nieodnalezionych etykiet */ struct Loc { struct Loc *next; UINT OPNum; /* Numer operacji korzystającej z etykiety */ UINT Line; /* Numer linii, służący do debugowania. */ /* Który z argumentów operacji wymaga podmiany po znalezieniu etykiety */ 8 3 Specyfikacja wewnętrzna enum Argument Place; }; /* Lista znalezionych etykiet */ struct Label { struct Label *next; CHAR *Name; /* Nazwa etykiety */ VDATA Value; enum BOOL Resolved; struct Loc *Locs; /* Jednokierunkowa lista miejsc, gdzie ta etykieta była wykorzystywana, gdy nie była jeszcze odnaleziona */ } *Label; Ich zastosowanie powinno być w miarę oczywiste. 3.3 NVM Środowisko uruchomieniowe NVM składa się z pliku VM/VM.c, który zarządza strukturą stanu maszyny wirtualnej, z pliku VM/Execute.c odpowiedzialnego za przetwarzanie operacji oraz pliku tworzącego prosty interfejs maszyny, czyli NVM.c Struktura stanu maszyny, którą możemy znaleźć w VM/VM.h, przedstawia się następująco: typedef struct VM { /* Rejestry: Standardowe + A + SP + BP */ VREG Reg[REGS+3]; /* Pamięć */ UCHAR *Mem; LONG MemSize; /* Program */ UCHAR *Code; LONG CodeSize; /* Aktualna pozycja (wskaźnik programu) */ VADDR Current; } VM; Zawiera zarezerwowany blok pamięci, który jest dostępny dla programu uruchomionego na maszynie (Mem), kod programu (Code), adres aktualnej instrukcji (Current) oraz tablicę wartości rejestrów (Reg). Nazwy rejestrów oraz enumerator z ich nazwami zawiera już omówiony plik OP.h 9 3 Specyfikacja wewnętrzna 3.4 NVMcompile Na kompilator składają się cztery pliki: 1. Main.ml — plik stanowiący interfejs użytkownika, wywołujący funkcje zawarte w pozostałych plikach 2. Generate.ml — plik zawierający funkcje generujące asembler; np. genALV generuje kod umieszczający wartość zmiennej lokalnej w wyróżnionym rejestrze VALUE, ULV — ustawia wartość zmiennej na równą wartości VALUE. AGV, UGV — spełniają tą samą rolę ale wobec zmiennych globalnych. 3. Lexer.mll — Definicja leksera. Ponadto zawiera kod, który zlicza wystąpienia zmiennych w funkcjach i przypisuje im semantyczne wartości, które służą później do ich adresowania. Ponadto zawiera kod odpowiedzialny za rezerwowanie pamięci na stercie. 4. Grammar.mly — definicja gramatyki języka (LALR). Z ciekawszych danych dotyczących kompilatora chciałem przytoczyć budowę ramki stosu, którą stosuję. Podczas wywołania kolejnej funkcji, tworzona jest kopia istotnych rejestrów a następnie wrzucane są na stos argumenty funkcji. Później wywoływany jest CALL, który umieszcza tam adres powrotu. Na samym początku funkcje kopiują do rejestru BP (Base Pointer), adres stosu (SP). Jest on później wykorzystywany do adresowania zmiennych lokalnych. WYSOKIE adresy pamięci R0 \ R1 | R2 | -> Kopia rejestrów funkcji poprzedniej ... | R15 | BP / -- RAMKA STOSU FUNKCJI -arg1 \ arg2 | -> Argumenty funkcji ... / IP -> Adres powrotu z funkcji (umieszczony przez CALL) W tym momencie do BP wrzucany jest adres z SP (wskazuje na IP) local1 \ local2 | -> Zmienne lokalne ... / -- RAMKA STOSU FUNKCJI -NISKIE adresy pamięci 10 4 Podsumowanie 4 Podsumowanie Do programu dołączone jest parę przykładów, napisanych zarówno w asemblerze jak i w bLang-u. Przykłady te jak można się przekonać działają. Mam nadzieję, że udało mi się spełnić większość postawionych założeń, choć szczególnie z przenośnością był spory problem. Nie jestem pewien, czy nie istnieje lepsza metoda na posiadanie pełnej kontroli nad wielkością wykorzystywanych typów, niż ta zastosowana przeze mnie. Niemniej jednak ta metoda zdaje się działać. Również pod sporym znakiem zapytania jest to czy ten program, prócz celów dydaktycznych, może czemuś służyć. Faktycznie jeśli uda mi się zakończyć proces przenoszenia środowiska uruchomieniowego na µC AVR, można będzie to wykorzystać w celu prostego skryptowania akcji wykonywanych przez urządzenia. NVM zajmuje mniej miejsca, niż zajmują inne znane mi interpretery skryptów, przeniesione na AVR, więc może ma to jakąś przyszłość? 11