AKTUALIZACJA: Tak bardzo spodobało mi się to pytanie, że stałem się tematem mojego bloga 18 listopada 2011 r . Dzięki za świetne pytanie!
Zawsze zastanawiałem się: jaki jest cel stosu?
Zakładam, że masz na myśli stos ewaluacyjny języka MSIL, a nie rzeczywisty stos na wątek w czasie wykonywania.
Dlaczego istnieje przeniesienie z pamięci na stos lub „ładowanie”? Z drugiej strony, dlaczego istnieje przeniesienie ze stosu do pamięci lub „przechowywanie”? Dlaczego po prostu nie umieścisz ich wszystkich w pamięci?
MSIL to język „maszyny wirtualnej”. Kompilatory takie jak kompilator C # generują CIL , a następnie inny kompilator zwany kompilatorem JIT (Just In Time) zamienia IL w rzeczywisty kod maszynowy, który można wykonać.
Więc najpierw odpowiedzmy na pytanie „dlaczego MSIL w ogóle?” Dlaczego po prostu kompilator C # nie wypisuje kodu maszynowego?
Ponieważ taniej jest to zrobić w ten sposób. Załóżmy, że nie zrobiliśmy tego w ten sposób; załóżmy, że każdy język musi mieć własny generator kodów maszynowych. Masz dwadzieścia różnych języków: C #, JScript .NET , Visual Basic, IronPython , F # ... Załóżmy, że masz dziesięć różnych procesorów. Ile generatorów kodu musisz napisać? 20 x 10 = 200 generatorów kodu. To dużo pracy. Załóżmy teraz, że chcesz dodać nowy procesor. Musisz napisać dla niego generator kodu dwadzieścia razy, po jednym dla każdego języka.
Co więcej, jest to trudna i niebezpieczna praca. Pisanie wydajnych generatorów kodu dla układów, na których nie jesteś ekspertem, to ciężka praca! Projektanci kompilatorów są ekspertami w analizie semantycznej ich języka, a nie w efektywnym przydzielaniu rejestrów nowych zestawów układów.
Załóżmy teraz, że robimy to w CIL. Ile generatorów CIL musisz napisać? Jeden na język. Ile kompilatorów JIT musisz napisać? Jeden na procesor. Łącznie: 20 + 10 = 30 generatorów kodów. Ponadto generator języka do kodu CIL jest łatwy do napisania, ponieważ CIL jest prostym językiem, a generator kodu do kodu maszynowego jest również łatwy do napisania, ponieważ CIL jest prostym językiem. Pozbywamy się wszystkich zawiłości C # i VB oraz wszystkiego i „obniżamy” wszystko do prostego języka, dla którego łatwo jest napisać jitter.
Posiadanie języka pośredniego znacznie obniża koszty produkcji nowego kompilatora językowego . Znacząco obniża to także koszt obsługi nowego układu. Chcesz wesprzeć nowy układ, znajdziesz ekspertów na tym układzie i każesz im napisać fluktuację CIL i gotowe! następnie obsługujesz wszystkie te języki na swoim chipie.
OK, więc ustaliliśmy, dlaczego mamy MSIL; ponieważ znajomość języka obcego obniża koszty. Dlaczego zatem język jest „maszyną stosową”?
Ponieważ maszyny stosowe są koncepcyjnie bardzo łatwe w obsłudze dla twórców kompilatorów językowych. Stosy to prosty, łatwy do zrozumienia mechanizm opisywania obliczeń. Maszyny stosowe są również koncepcyjnie bardzo łatwe w obsłudze dla twórców kompilatorów JIT. Korzystanie ze stosu jest abstrakcją upraszczającą i dlatego obniża nasze koszty .
Pytasz „po co w ogóle stos?” Dlaczego nie zrobić wszystkiego bezpośrednio z pamięci? Pomyślmy o tym. Załóżmy, że chcesz wygenerować kod CIL dla:
int x = A() + B() + C() + 10;
Załóżmy, że mamy konwencję, zgodnie z którą „dodaj”, „wywołaj”, „zapisz” i tak dalej, zawsze usuwają argumenty ze stosu i umieszczają wynik (jeśli taki istnieje) na stosie. Aby wygenerować kod CIL dla tego C # mówimy po prostu coś takiego:
load the address of x // The stack now contains address of x
call A() // The stack contains address of x and result of A()
call B() // Address of x, result of A(), result of B()
add // Address of x, result of A() + B()
call C() // Address of x, result of A() + B(), result of C()
add // Address of x, result of A() + B() + C()
load 10 // Address of x, result of A() + B() + C(), 10
add // Address of x, result of A() + B() + C() + 10
store in address // The result is now stored in x, and the stack is empty.
Załóżmy teraz, że zrobiliśmy to bez stosu. Zrobimy to po swojemu, gdzie każdy kod operacji bierze adresy swoich operandów i adres, pod którym przechowuje swój wynik :
Allocate temporary store T1 for result of A()
Call A() with the address of T1
Allocate temporary store T2 for result of B()
Call B() with the address of T2
Allocate temporary store T3 for the result of the first addition
Add contents of T1 to T2, then store the result into the address of T3
Allocate temporary store T4 for the result of C()
Call C() with the address of T4
Allocate temporary store T5 for result of the second addition
...
Widzisz jak to idzie? Nasz kod staje się ogromny, ponieważ musimy jawnie przydzielić całą pamięć tymczasową , która normalnie zgodnie z konwencją po prostu idzie na stos . Co gorsza, same nasze kody stają się ogromne, ponieważ wszyscy muszą teraz wziąć za argument adres, pod którym zamierzają zapisać swój wynik, oraz adres każdego argumentu. Instrukcja „dodaj”, która wie, że zamierza usunąć dwie rzeczy ze stosu i nałożyć jedną rzecz, może być pojedynczym bajtem. Instrukcja add, która przyjmuje dwa adresy operandów i adres wynikowy, będzie ogromna.
Używamy kodów opartych na stosach, ponieważ stosy rozwiązują typowy problem . Mianowicie: chcę przeznaczyć trochę pamięci tymczasowej, zużyć ją bardzo szybko, a potem szybko ją pozbyć, kiedy skończę . Zakładając, że mamy do dyspozycji stos, możemy sprawić, że kody będą bardzo małe, a kod bardzo zwięzły.
AKTUALIZACJA: Kilka dodatkowych przemyśleń
Nawiasem mówiąc, pomysł drastycznego obniżenia kosztów poprzez (1) określenie maszyny wirtualnej, (2) pisanie kompilatorów ukierunkowanych na język VM i (3) pisanie implementacji VM na różnych urządzeniach, wcale nie jest nowym pomysłem . Nie pochodzi od MSIL, LLVM, kodu bajtowego Java ani żadnej innej nowoczesnej infrastruktury. Najwcześniejsza realizacja tej strategii, o której wiem, to maszyna pcode z 1966 roku.
Pierwszy raz osobiście usłyszałem o tej koncepcji, kiedy dowiedziałem się, jak implementatorzy Infocom zdołali sprawić, że Zork działał na tak wielu różnych maszynach. Określili maszynę wirtualną o nazwie Z-machine, a następnie stworzyli emulatory maszyny Z dla całego sprzętu, na którym chcieli uruchomić swoje gry. Miało to dodatkową zaletę, że mogły implementować zarządzanie pamięcią wirtualną w prymitywnych systemach 8-bitowych; gra może być większa niż zmieściłaby się w pamięci, ponieważ mogłaby po prostu umieścić kod z dysku, gdy go potrzebował, i odrzucić, gdy potrzebował załadować nowy kod.