Jak gcc generuje przenośny kod?

0

Witam.

Zastanawiałem się nad napisaniem własnego języka. Po pierwsze zdobyłbym więcej wiedzy o assemblym, plikach, pamięci, działaniu i wielu więcej podczas tego projektu a po drugie miałbym "narzędzie" (język) w którym można bardzo szybko coś napisać (główny zamysł). Z tego co wiem, instrukcje assemblyego są zależne od procesora. Na wikipedii, możemy przeczytać, że
Stosowanie kompilatorów ułatwia programowanie (programista nie musi znać języka maszynowego) i pozwala na większą przenośność kodu pomiędzy platformami.
Dodatkowo gdzieś na 4p znalazłem wątek o tym jakiego kompilatora do C++ używać. Jedna z odpowiedzi:

gcc, bo

[...]
kompiluje na wiele platform,
jest bezpłatny,
ma całkiem niezły optymalizator,
dzięki fladze -pedantic możemy pisać przenośny kod, który mamy pewność, że skompiluje każdy kompilator zgodny ze standardem,
bardzo popularny. [...]

A więc pytanie. Jak np. gcc kompiluje ten kod do exe, który można uruchomić wszędzie (chodzi o procesor)?

3

Kuleje zdolność czytania ze zrozumieniem :] :P
Programista pisze przenośny kod źródłowy (np w C), tzn kod którego nie trzeba modyfikować, by skompilować go pod jedną z obsługiwanych platform. Kompilator kompiluje przenośny kod źródłowy do docelowego (i już nieprzenośnego) kodu maszynowego.

Z drugiej strony mamy Javkę, gdzie kompilator javac kompiluje kod źródłowy z plików .java do plików .class z kodem pośrednim (tzn konkretnie jest to kod maszynowy dla maszyny wirtualnej Javy), a na komputerze klienta JVM interpretuje oraz kompiluje tenże kod pośredni do kodu maszynowego procesora zamontowanego w komputerze.

Czyli jak napiszę program u siebie, to może ale nie musi działać u Kowalskiego?

Jeśli chodzi o przesłanie binarki Kowalskiemu to problemy mogą być następujące:

  • binarka zawiera kod maszynowy dla architektury procesora X (i ewentualnie Y oraz Z, bo mogą istnieć binarki z wieloma wersjami kodu maszynowego), a Kowalski ma architekturę procesora W niekompatybilną z X (analogicznie: także z Y i Z)
  • w programie założyłeś, że na dysku istnieje plik C:\ala ma kota.txt, a u Kowalskiego takiego nie ma i program się wywala
  • założyłeś, że w systemie jest biblioteka libblabla.dll, ale istnieje tylko u ciebie, a Kowalskiego już nie
  • założyłeś, że biblioteka abc.dll jest w wersji 1.0.0, a Kowalski ma ją w wersji 2.0.0, która jest niekompatybilna i program się wywala
  • założyłeś, że użytkownik ma mieć prawa np do modyfikacji rejestru, a u Kowalskiego nie ma i program się wysypuje
  • itd

Język programowania jest przenośny jeżeli da się napisać idiomatyczny (tzn bez przesadnych kombinacji) kod źródłowy, który potem może być bez zmian skompilowany (jednym lub wieloma kompilatorami) na wiele systemów operacyjnych (bo inne architektury procesora to chyba trochę za mało, chociaż mogę się mylić).

1

Jak sądzisz, czy jeśli za pomocą gcc skompilujesz kod na Linuxie i dostaniesz w outpucie plik wykonywalny w formacie ELF, to otworzysz go na Windowsie? Wskazówka: Windows używa formatu PE plików wykonywalnych.

Inny przykład: w programie możesz użyć os-specific wywołań systemowych. I tak kompilujac kod chociażby na Fedorze używając przy tym specyficznych dla Fedory ABI calls, jak chciałbyś odpalić to na Ubuntu?

Chcąc mieć pewność, że binarka zbudowana na jednym sprzęcie odpali się na drugim, musisz stworzyć dodatkowa warstwę (już specyficzna dla każdego OS/architektury), która tę binarke czyta i przenosi na kod maszynowy. Tak działa jvm. Albo maszyna wirtualna go. Albo CPython.

Instrukcje asm są zależne od CPU, to prawda. To znaczy, że Intel ma instrukcje, których nie ma AMD. I najnowszy Intel i7 ma trochę takich, których nie zna core 2 duo. Czego nie rozumiesz?

1

Zastanawiałem się nad napisaniem własnego języka. Po pierwsze zdobyłbym więcej wiedzy o assemblym, plikach, pamięci, działaniu i wielu więcej podczas tego projektu a po drugie miałbym "narzędzie" (język) w którym można bardzo szybko coś napisać (główny zamysł).

Niby w jaki sposób stworzenie własnego języka miałoby sprawić, że w nim coś szybciej napiszesz niż gdybyś po prostu miał wykorzystać już jakiś istniejący język? o_O

Jeśli ciekawi cię tworzenie języków programowania to polecam zacząć od oparcia go o GraalVMa: https://www.graalvm.org/docs/graalvm-as-a-platform/implement-language/
Dostaniesz od ręki interpreter, JIT do kodu maszynowego, debugger, profiler, możliwość wywoływania kodu Javowego, JavaScriptowego, itp itd To najszybszy sposób by stworzyć wydajny język (w sensie wydajną implementację) z dużymi możliwościami (dostęp do biblioteki standardowej Javy daje bardzo duże możliwości).

0
PrezesiQ napisał(a):

A więc pytanie. Jak np. gcc kompiluje ten kod do exe, który można uruchomić wszędzie (chodzi o procesor)?

  1. Jeśli użyjesz opcji -mtune=generic to kompilator nie użyje żadnych unikalnych instrukcji, użyje tylko te które są powszechnie dostępne
    https://gcc.gnu.org/onlinedocs/gcc-4.5.3/gcc/i386-and-x86_002d64-Options.html

  2. Możesz użyć kodu wariantowego - kilka wersji pod różne procki:
    https://lwn.net/Articles/691932/

  3. Ale jeśli się da to:
    http://gcc.gnu.org/wiki/DontUseInlineAsm

1
PrezesiQ napisał(a):

Witam.

Zastanawiałem się nad napisaniem własnego języka. Po pierwsze zdobyłbym więcej wiedzy o assemblym, plikach, pamięci, działaniu i wielu więcej podczas tego projektu a po drugie miałbym "narzędzie" (język) w którym można bardzo szybko coś napisać (główny zamysł). Z tego co wiem, instrukcje assemblyego są zależne od procesora. Na wikipedii, możemy przeczytać, że ...

Po pierwsze, tworząc język nie dowiesz się zbyt dużo o plikach. Te rzeczywistości niemal nie mają nic wspólnego. Wydaje się nie masz za dużo doświadczenia, zanim z tego będzie język "w którym napiszesz coś szybko", to tak z dziesięc osobolat.

Po drugie i najważniejsze, STRASZNIE ciężko cokolwiek zdziałać bez podkładu teoretycznego o formalizmie języków, konstrukcji kompilatora itd... metodą "naiwną" cięzko dojść do wyrażeń arytmetycznych z nawiasami. Szkoda nawet rozmawiac o assemblerze bez podkładu.

Po trzecie, wejście w świat języków implementowanych w otoczeniu gcc wydaje się trudne, to ściezka dla zawodowców. Podrzucił bym poczytanie materiałów z kręgu antlr.org, choć zupełnie bez teorii będzie ciezko

3
PrezesiQ napisał(a):

Witam.

Zastanawiałem się nad napisaniem własnego języka. Po pierwsze zdobyłbym więcej wiedzy o assemblym, plikach, pamięci, działaniu i wielu więcej podczas tego projektu a po drugie miałbym "narzędzie" (język) w którym można bardzo szybko coś napisać (główny zamysł).

Wiedzy o assemblym zdobędziesz aż nadto, ale chyba nie takiej, jak byś oczekiwał. To, co Cię powinno interesować, to przede wszystkim ISA czyli Instruction Set Architecture. Wykaz instrukcji, odpowiadających im kodów maszynowych, lista rejestrów i ich zastosowanie, tryby adresowania, obsługa przerwań, typy danych itp itd. Do powieszenia nad łóżkiem. Na pierwszy ogień najważniejsze, by dana instrukcja z języka znalazła przełożenie na odpowiednią instrukcję lub zestaw instrukcji asemblera docelowej architektury.

Z tego co wiem, instrukcje assemblyego są zależne od procesora. Na wikipedii, możemy przeczytać, że
Stosowanie kompilatorów ułatwia programowanie (programista nie musi znać języka maszynowego) i pozwala na większą przenośność kodu pomiędzy platformami.

Owszem, każda architektura ma własny assembler, jedne bardziej przyjazny, inne mniej. x86 to był dla mnie jakiś kosmos, a Nios2 jest właściwie naprawdę czytelnym assemblerem. Bardzo spójny, bez dodatkowych nawiasików, dupików, dopowiedzień czy pracujesz na słowie czy może jednak na bajcie - chwalmy RISC. Problem jest taki, że aby zapewnić wieloplatformowość Twój kompilator musi posiadać backendy do wielu architektur, chyba że celujesz w jedną albo jednak wolisz interpreter lub maszynę wirtualną. LLVM ma chyba ze 20 backendów, nie pamiętam. A w każdym backendzie nie brakuje klas po kilka-kilkanaście tysięcy linii kodu.

Dodatkowo gdzieś na 4p znalazłem wątek o tym jakiego kompilatora do C++ używać. Jedna z odpowiedzi:

gcc, bo

[...]
kompiluje na wiele platform,

chwalmy Pana

jest bezpłatny,

LLVM też

ma całkiem niezły optymalizator,

LLVM ma trochę lepszy

dzięki fladze -pedantic możemy pisać przenośny kod, który mamy pewność, że skompiluje każdy kompilator zgodny ze standardem,
bardzo popularny. [...]

Bo powstał w czasach, które pamiętają najstarsi górale. Ale myślęNaj, że będzie stopniowo tracił na rzecz nowszych kompilatorów, którym po prostu nie dotrzyma tempa. Zresztą clang czy kompilator Rusta stoją na LLVM, a nie gcc, a to tylko dwa z kilku albo kilkunastu frontendów (jeden frontend - jeden język wysokiego poziomu) LLVM-a.

A więc pytanie. Jak np. gcc kompiluje ten kod do exe, który można uruchomić wszędzie (chodzi o procesor)?

Jak robi to gcc to tak dokładnie nie wiem, ale pewnie w zbliżony sposób do LLVM - z wykorzystaniem kodu pośredniego. Frontend opiszę w skrócie, bo nigdy w żadnym się nie babrałem.

  1. Frontend bierze kod napisany w języku wysokiego poziomu, analizuje go pod kątem poprawności składni i semantyki, po czym przekształca do postaci reprezentującej - tak z grubsza rzecz ujmując - model programu. Najprawdopodobniej graf jakichś operacji i wywołań powiązanych ze sobą danymi, na których operują, z uwzględnieniem kolejności itd.
  2. Na tej reprezentacji frontend jest w stanie przeprowadzić wstępną optymalizację np. usunięcie węzłów które nie mają żadnych efektów (np. przypisanie wartości do zmiennej zostanie zapewne wywalone, jeśli nigdy nie następuje jej odczyt aż do zakończenia życia lub nadpisania).
  3. Instrukcje języka wysokiego poziomu są rozwijane/zwijane (zależnie od sytuacji) w jedną lub więcej instrukcji reprezentacji pośredniej (IR). To coś w rodzaju uogólnionego assemblera z typami danych, wirtualnymi rejestrami które mogą być zapisywane tylko przy inicjalizacji (ułatwia optymalizacje w kolejnych fazach), agregatami (coś w rodzaju struktur) itd. Ma zarówno instrukcje znajdujące odwzorowanie w praktycznie każdym assemblerze (dodawanie, shifty, ładowanie z pamięci) jak i na nieco wyższym poziomie abstrakcji (np. operacje na wektorach i agregatach).

Następnie, jak już masz reprezentację pośrednią, do gry wchodzi backend LLVM. Backend generalnie zajmuje się skompilowaniem tej uogólnionej reprezentacji pośredniej na assembler i/lub kod maszynowy konkretnej architektury.

  1. IR po zwalidowaniu składni i semantyki jest przekształcany w DAG (z ang. acykliczny graf skierowany), reprezentujący logiczną strukturę programu. Mówiłem już o tym przy frontendzie.
  2. następuje wstępna optymalizacja DAG, usuwane są niepotrzebne czy znoszące swoje działanie instrukcje, albo zastępowane są innymi (np. 4-krotne dodanie jakichś stałych pewnie zostanie zastąpione dodaniem jednej) i takie tam.
  3. Po wstępnej optymalizacji mamy etapy legalizacji oraz ISEL (instruction selection), w których typy danych i instrukcje z IR są zastępowane tymi występującymi w danej architekturze tak, aby nic się nie wysypało. Jeśli np. w IR mamy 64-bitowe operacje na rejestrach a architektura takich nie wspiera, muszą zostać rozbite na odpowiednie instrukcje np. 32-bitowe. W międzyczasie występują dodatkowe przebiegi optymalizacji.
  4. Scheduler ustala ostateczną kolejność występowania po sobie instrukcji. Generalnie zaczyna od "najpierwszej" instrukcji u "szczytu" grafu i przechodzi po nim tak, aby żadna instrukcja nie wykonała się przed inną, od której wyniku jest zależna.

I w zasadzie jeśli naprawdę bardzo chcesz napisać swój kompilator, to oszczędź sobie mordęgi kompilowania na konkretne platformy i zbuduj swój kompilator jako frontend np. LLVM. Będziesz miał do opanowania jedną reprezentację pośrednią, a nie srylion asemblerów, i bardzo dobrą optymalizację pod spodem out-of-the-box.

Btw zachęcam do lektury:
CodeGen: http://llvm.org/docs/CodeGenerator.html
LLVM IR: http://llvm.org/docs/LangRef.html

1 użytkowników online, w tym zalogowanych: 0, gości: 1