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

Podobne dokumenty