A small, fast programming language on LLVM with C-like syntax. - Lexer, recursive-descent parser, single-pass type checker + LLVM IR codegen, O2 pipeline - Types: int/float/bool/char/str, sized i8..u64/f32/f64, pointers ptr[T] with addr()/.value/null - Functions (fn ... -> T), if/else, while, for..in, break/continue, Go-style operator precedence - Implicit conversions only when lossless; literals adapt to their target type - Runtime checks for division by zero, MIN/-1 and null .value - C interop: import "header.h" via libclang, link "lib" with pkg-config, link-error advice - Human-readable errors with source excerpts - Test suite (34 tests), examples, docs for beginners/technicians/engineers in English and Polish Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
55 KiB
Finch dla inżynierów
Kompletny opis wnętrza kompilatora Finch 1.0. Opisuje, jak kod źródłowy staje się tokenami, tokeny drzewem AST, a AST kodem LLVM IR, który potem jest optymalizowany, zapisywany do pliku obiektowego i linkowany. Zawiera dokładne reguły typów, sposób tłumaczenia każdej konstrukcji na IR (z prawdziwym wyjściem kompilatora), import nagłówków C przez libclang, szczegóły ABI oraz opis każdej funkcji kompilatora wraz z tym, czym różni się od sąsiednich.
Zakładam, że znasz C/C++ i widziałeś już LLVM IR. Język z perspektywy użytkownika opisuje przewodnik dla techników.
🇬🇧 English version: for-engineers.md
Spis treści
- Architektura
- Pliki źródłowe
- Lekser
- Gramatyka i parser
- AST
- Model semantyczny
- System typów
- Generowanie kodu
- Kontrole w czasie działania
- Współpraca z C przez libclang
- Optymalizacja, emisja, linkowanie
- Diagnostyka
- System budowania
- Testy
- Semantyka a C: zachowania zdefiniowane i niezdefiniowane
- Spis funkcji: kto co robi
- Rozbudowa kompilatora
- Znane ograniczenia i plan
1. Architektura
Finch to klasyczny jednoprzebiegowy front-end przed LLVM. Sprawdzanie typów i generowanie IR odbywają się w jednym przejściu po AST. Nie ma osobnego przebiegu semantycznego ani własnej reprezentacji pośredniej.
plik.fn
│ readFile() src/main.cpp
▼
lex() ──────────► std::vector<Token> src/lexer.cpp
▼
parse() ─────────► Program (AST) src/parser.cpp, src/ast.h
│ ├─ imports ──► importHeaders() ──► CImports src/cimport.cpp (libclang)
│ └─ links
▼
hostMachine() ───► llvm::TargetMachine (triple, data layout)
▼
generate() ──────► llvm::Module (typy + IRBuilder, potem verifyModule) src/codegen.cpp
▼
optimize() ──────► potok PassBuilder default<O2> (pomijany przy -O0)
▼
emitObject() ────► plik.o (legacy::PassManager + addPassesToEmitFile)
▼
link() ──────────► cc plik.o -o plik -lm [-l… | pkg-config --libs …]
▼
plik wykonywalny (finch run: uruchamiany z pliku tymczasowego, potem usuwany)
Decyzje, które kształtują całą resztę:
- Szybka porażka. Pierwszy błąd wypisuje diagnostykę i wywołuje
exit(1)(fail()wsrc/error.h). Nie ma odtwarzania po błędach, więc AST i codegen nigdy nie widzą częściowo poprawnego wejścia. - Jedno przejście.
Codegenniesie stan zasięgów, pętli i bieżącej funkcji. Każde wyrażenie zwraca jednocześnie wartość IR i swój typ Fincha (Value_). - Niech LLVM robi robotę. Zmienne lokalne to
alloca, amem2reg/SROA zamieniają je na SSA. Finch nigdy sam nie buduje SSA ani węzłów φ, z wyjątkiem&&/||. - Systemowy toolchain C jest środowiskiem uruchomieniowym.
printf,strcmp,dprintf,exitpochodzą z libc. Finch nie ma własnej biblioteki uruchomieniowej.
2. Pliki źródłowe
| Plik | Linie | Odpowiedzialność |
|---|---|---|
src/error.h |
~25 | fail(line, col, msg): wypisuje plik:linia:kol: error: i fragment kodu z ^, kończy proces. Trzyma g_file, g_source. |
src/lexer.h/.cpp |
~240 | enum Tok, Token, lex(), tokName() |
src/ast.h |
~235 | Type, wszystkie węzły Expr/Stmt, FnDecl, Import, Link, Program |
src/parser.h/.cpp |
~430 | Parser zstępujący Parser, typeFromName() |
src/cimport.h/.cpp |
~280 | Import nagłówków przez libclang: importHeaders() → CImports (funkcje, stałe, zmienne globalne) |
src/codegen.h/.cpp |
~900 | Codegen: sprawdzanie typów + generowanie LLVM IR, generate() |
src/main.cpp |
~260 | Sterownik: CLI, maszyna docelowa, optymalizacja, emisja, linkowanie, analiza błędów linkera, run |
tests/run.sh |
Testy wzorcowego wyjścia i oczekiwanych błędów |
3. Lekser
lex(src, file) przechodzi raz, liniowo, przez źródło i zwraca std::vector<Token>
zakończony Tok::End.
struct Token {
Tok kind;
std::string text; // treść identyfikatora / literału (sekwencje \ już zdekodowane)
int line, col; // od 1
bool newlineBefore; // między poprzednim tokenem a tym był znak nowej linii
};
Nowe linie jako koniec instrukcji
Finch nie ma średników. Zamiast emitować tokeny NEWLINE, jak robią to wewnętrznie Python i Go,
lekser oznacza każdy token flagą newlineBefore. Co ona znaczy, decyduje parser (rozdział 4).
Dzięki temu gramatyka nie zawiera tokenów nowej linii, które trzeba by wszędzie pomijać:
w listach argumentów, między } a else i tak dalej.
Tokeny
- Pozycje:
lineicolliczone są od 1, acolliczy znaki, nie bajty. Bajty kontynuacji UTF-8 jej nie zwiększają, więc^trafia we właściwe miejsce także w linijkach z"Błąd". Nieoczekiwany znak spoza ASCII jest pokazywany w całości ('ż'), z podpowiedzią, że nazwy są tylko ASCII. - Identyfikatory / słowa kluczowe:
[A-Za-z_][A-Za-z0-9_]*. Słowa kluczowe:fn return if else while for in break continue true false null import link. Nazwy typów nie są słowami kluczowymi.int,u8,ptr… są leksowane jakoIdent, a parser rozpoznaje je przeztypeFromName(). Lekser nie zależy więc od listy typów, au8(x)parsuje się jak zwykłe wywołanie. - Liczby całkowite: dziesiętne albo szesnastkowe
0x, z separatorami_(usuwanymi ztext). Zera wiodące nic nie zmieniają: Finch nie ma ósemkowych,010to 10. Wartość liczy parser (strtoull, podstawa 10 lub 16). - Liczby zmiennoprzecinkowe:
cyfry.cyfry. Kropka należy do liczby tylko wtedy, gdy po niej jest cyfra, więc0..10toInt DotDot Int, a nieFloat(0.) Dot …. - Teksty / znaki:
"…"i'…'. Sekwencje\n \t \r \0 \\ \" \'są dekodowane dotext. Nieznana sekwencja, niezamknięty literał albo nowa linia w środku literału to błąd. Literał znakowy musi dać dokładnie jeden bajt. - Operatory: zasada najdłuższego dopasowania:
->przed-,<<i<=przed<,&&przed&,..przed.,:=(samotny:to błąd z podpowiedzią:=). - Komentarze:
//do końca linii,/* … */(bez zagnieżdżania; niezamknięty to błąd).
4. Gramatyka i parser
Parser (src/parser.cpp) to ręcznie pisany parser zstępujący z jednym tokenem wyprzedzenia
(cur()), a w dwóch miejscach z drugim (peekTok()).
Gramatyka (EBNF)
program = { import | link | function } ;
import = "import" STRING ;
link = "link" STRING ;
function = "fn" IDENT "(" [ param { "," param } ] ")" [ "->" type ] block ;
param = type IDENT ;
type = TYPENAME | "ptr" "[" type "]" ; (* TYPENAME: int float bool char str ptr i8..u64 f32 f64 *)
block = "{" { statement TERMINATOR } "}" ;
statement = if | while | for | return | "break" | "continue" | block
| type IDENT [ "=" expr ] (* deklaracja *)
| IDENT ":=" expr (* deklaracja z wnioskowaniem *)
| target assignop expr (* target: IDENT albo postfix kończący się .value *)
| call ;
if = "if" expr block [ "else" ( if | block ) ] ;
while = "while" expr block ;
for = "for" IDENT "in" expr ".." expr block ;
return = "return" [ expr ] ; (* expr tylko w tej samej linii *)
assignop = "=" | "+=" | "-=" | "*=" | "/=" | "%=" ;
expr = or ;
or = and { "||" and } ;
and = cmp { "&&" cmp } ;
cmp = add { ( "==" | "!=" | "<" | "<=" | ">" | ">=" ) add } ;
add = mul { ( "+" | "-" | "|" | "^" ) mul } ;
mul = unary { ( "*" | "/" | "%" | "<<" | ">>" | "&" ) unary } ;
unary = ( "-" | "!" | "~" ) unary | postfix ;
postfix = primary { "." IDENT } ;
primary = INT | FLOAT | STRING | CHAR | "true" | "false" | "null"
| IDENT [ "(" [ expr { "," expr } ] ")" ]
| "(" expr ")" ;
Reguła końca instrukcji
TERMINATOR nie jest tokenem. Po każdej instrukcji endOfStatement() wymaga, żeby następny
token był }, końcem pliku albo miał newlineBefore == true. W przeciwnym razie zgłasza błąd
put each statement on its own line.
Wewnątrz wyrażeń sameLine() decyduje, czy operator binarny, . albo ( wywołania
kontynuuje bieżące wyrażenie:
bool sameLine() const { return parenDepth > 0 || !cur().newlineBefore; }
Czyli:
a := 1 + // '+' jest w tej linii → wyrażenie przechodzi do następnej
2
b := (1 // w nawiasach (parenDepth > 0) nowe linie są ignorowane
+ 2)
c := 1
-5 // '-' zaczyna nową linię → instrukcja skończyła się na '1'; "-5" to wtedy błąd
parenDepth rośnie wokół grup ( … ), list argumentów i list parametrów. Przy wejściu do bloku
{ } jest zapamiętywany i zerowany, więc blok wewnątrz nawiasów (dziś to się nie zdarza)
i tak używałby reguł linii.
( wywołania musi być w tej samej linii co nazwa funkcji. Inaczej f⏎(x) po cichu stałoby się wywołaniem.
Priorytety: jak w Go, nie jak w C
| Poziom | Operatory |
|---|---|
| 5 | * / % << >> & |
| 4 | + - | ^ |
| 3 | == != < <= > >= |
| 2 | && |
| 1 | || |
Wszystkie operatory binarne są lewostronnie łączne. Porównania też (a < b < c się sparsuje),
ale sprawdzanie typów odrzuci potem porównanie bool < int. C stawia &, ^, | poniżej ==,
przez co x & MASKA == 0 znaczy x & (MASKA == 0). Finch przyjmuje tabelę z Go, co usuwa całą tę klasę błędów.
Jak rozpoznawane są instrukcje
statement() rozgałęzia się po pierwszym tokenie:
- Słowa kluczowe (
if,while,for,return,break,continue,{) są obsługiwane wprost.fn,importilinkwewnątrz bloku dają celowane komunikaty. - Deklaracja z typem:
atDeclaration()zwraca prawdę, gdy bieżący token jest nazwą typu, a następny identyfikatorem (int x), albo gdy toptr, a po nim[(ptr[int] p). To jedyne miejsce wymagające dwóch tokenów wyprzedzenia. - Deklaracja z wnioskowaniem:
IDENT, a po nim:=. - W pozostałych przypadkach parser najpierw parsuje całe wyrażenie, a potem patrzy na następny
token. Jeśli to operator przypisania w tej samej linii, wyrażenie staje się celem przypisania.
Musi być
VarExpralboMemberExpr, inaczej:only a variable or p.value can be assigned to. Takie podejście („najpierw wyrażenie, potem decyzja”) obsługuje dowolne formy l-wartości (pp.value.value = 1) bez osobnej gramatyki l-wartości. - Samodzielne wyrażenie jako instrukcja musi być
CallExpr. Wszystko inne dajethis value is computed but never used.
Poziom pliku
program() przechodzi przez import, link i fn. Element zaczynający się nazwą typu dostaje
podpowiedź functions start with 'fn', like: fn add(int a, int b) -> int { ... }, co łapie
definicje funkcji pisane jak w C. Argument link jest walidowany: "libfoo.so" albo ścieżka
są odrzucane z podpowiedzią samej nazwy.
5. AST
Wszystkie węzły są w src/ast.h. Wyrażenia i instrukcje to małe hierarchie klas z jawnym
znacznikiem kind. Kod je przetwarzający robi switch po kind i static_cast, co pozwala
obejść się bez RTTI i dynamic_cast i sprawia, że rozgałęzienia są oczywiste.
struct Expr { ExprKind kind; Pos pos; virtual ~Expr(); };
using ExprPtr = std::unique_ptr<Expr>;
| ExprKind | Węzeł | Pola |
|---|---|---|
Int |
IntExpr |
long long value |
Float |
FloatExpr |
double value |
Bool / Char / Str |
BoolExpr / CharExpr / StrExpr |
value |
Null |
NullExpr |
|
Var |
VarExpr |
name |
Unary |
UnaryExpr |
char op (- ! ~), operand |
Binary |
BinaryExpr |
BinOp op, lhs, rhs |
Call |
CallExpr |
std::string callee, args |
Member |
MemberExpr |
obj, field (w 1.0 ma sens tylko value) |
| StmtKind | Węzeł | Pola |
|---|---|---|
Block |
BlockStmt |
std::vector<StmtPtr> body |
VarDecl |
VarDeclStmt |
hasType, type, name, init (może być null, gdy hasType) |
Assign |
AssignStmt |
target (Var/Member), char op (= + - * / %), value |
Expr |
ExprStmt |
expr (zawsze wywołanie) |
If |
IfStmt |
cond, then, otherwise (null, BlockStmt albo zagnieżdżony IfStmt dla else if) |
While |
WhileStmt |
cond, body |
For |
ForStmt |
var, from, to, body |
Return |
ReturnStmt |
value (może być null) |
Break / Continue |
Wywołania przechowują funkcję po nazwie, a nie jako wyrażenie. Finch nie ma funkcji jako
wartości, a rozwiązywanie nazw dopiero w codegenie pozwala, żeby jeden CallExpr oznaczał
funkcję wbudowaną (print, addr), konwersję (u8), funkcję Fincha albo funkcję C (rozdział 8.10).
Przykład
x += add(x, 2)
parsuje się do:
AssignStmt (op '+')
├─ target: VarExpr "x"
└─ value: CallExpr "add"
├─ VarExpr "x"
└─ IntExpr 2
W AST nie ma węzła „load”. To, czy VarExpr znaczy „adres x”, czy „wartość w x”, zależy
od miejsca, w którym występuje. Decyduje o tym codegen (place() kontra expr(), rozdział 8.2).
Type
struct Type {
enum Kind { Void, Bool, Char, I8, I16, I32, I64, U8, U16, U32, U64, F32, F64, Str, Ptr, Null };
Kind kind;
std::shared_ptr<Type> elem; // tylko Ptr: typ wskazywany; null = `ptr` bez typu
};
intii64to ten sam typ (I64), aname()wypisuje go jakoint. AnalogiczniefloattoF64.Nullto typ wyłącznie literałunull. Żadna zmienna nie może go mieć.Voidto „typ” wywołań, które nic nie zwracają, i funkcji bez->.- Równość jest strukturalna (
ptr[ptr[int]] == ptr[ptr[int]]). - Rodzaje są ułożone tak, że
isInt()toI8..U64,isSigned()toI8..I64, aisUnsigned()toU8..U64.
6. Model semantyczny
Całe sprawdzanie semantyczne odbywa się w Codegen podczas emisji IR.
Funkcje
Codegen::run() robi dwa przejścia po Program::fns:
declare()tworzy każdąllvm::Function(tylko sygnaturę), więc kolejność definicji nie ma znaczenia i działa rekurencja wzajemna. Sprawdza też: duplikaty nazw, nazwy wbudowane i sygnaturęmain(bez parametrów; nic nie zwraca, zwracaintalboi32).define()generuje ciała.
Bez main kompilacja kończy się błędem there is no main function.
Zasięgi
scopes to std::vector<std::unordered_map<std::string, Var>>. Nowa mapa jest wkładana dla
parametrów funkcji, dla każdego bloku { } i dla każdej pętli for (trzyma zmienną pętli).
struct Var { AllocaInst *slot; FType type; bool readonly; };
Bez przesłaniania: addVar() odrzuca nazwę, którą lookup() znajdzie w dowolnym
zewnętrznym zasięgu, a nie tylko w bieżącym. Odrzuca też nazwy funkcji i wbudowanych.
To świadoma reguła prostoty: w całym ciele funkcji nazwa znaczy jedną rzecz.
Tylko do odczytu: zmienne for mają readonly. Przypisanie do nich albo addr() na nich
to błąd, więc licznika pętli nie da się zmienić za jej plecami.
Rozwiązywanie identyfikatorów
Dla VarExpr name w pozycji wartości kolejność jest taka:
- zmienna lokalna (
lookup), - stała C (
cimports.consts: wartości enum, liczby z#define), - zmienna globalna C (
cimports.globals:stdout, …), - błąd
there is no variable named 'name'.
Dla wywołań: print → addr → nazwa typu (konwersja) → funkcja Fincha → funkcja C → błąd.
Ponieważ funkcje Fincha są sprawdzane przed funkcjami C, funkcja Fincha przesłania funkcję C
o tej samej nazwie. Funkcja Fincha jest emitowana jako finch.<nazwa>, więc oba symbole
współistnieją przy linkowaniu.
7. System typów
Reprezentacja w LLVM
| Finch | LLVM | Uwagi |
|---|---|---|
bool |
i1 |
|
char, i8, u8 |
i8 |
znakowość jest w typie Fincha, nie w LLVM |
i16, u16 |
i16 |
|
i32, u32 |
i32 |
|
int/i64, u64 |
i64 |
|
f32 |
float |
|
float/f64 |
double |
|
str |
ptr |
napis C zakończony zerem |
ptr, ptr[T] |
ptr |
LLVM 21 ma nieprzezroczyste wskaźniki; typ wskazywany istnieje tylko w typie Fincha |
null |
ptr |
ConstantPointerNull |
| nic | void |
Liczby całkowite w LLVM nie mają znaku. Każda operacja, w której znak ma znaczenie
(sdiv/udiv, srem/urem, ashr/lshr, icmp slt/ult, sext/zext,
sitofp/uitofp, fptosi/fptoui), wybiera instrukcję na podstawie typu Fincha.
Wartości w codegenie
struct Value_ {
Value *v; // wartość IR
FType type; // jej typ w Finchu
bool literal; // liczba wpisana w kodzie (albo zaimportowana stała C)
};
Literały („stałe bez typu”)
literal jest ustawiane dla IntExpr, FloatExpr i zaimportowanych stałych C. Przechodzi
przez minus jednoargumentowy i przez arytmetykę między dwoma literałami, która zwija się do
stałej (arith() ustawia literal = l.literal && r.literal && isa<Constant>(wynik)). Jawna
konwersja je kasuje: u8(250) to stała typu u8, a nie literał.
Literał całkowity może stać się dowolnym typem całkowitym, w którym się mieści (fits()
sprawdza zakres, uwzględniając znakowość źródłowego literału). Dzięki temu u8 b = 200,
malloc(4 * 10) (do u64) i glClear(GL_COLOR_BUFFER_BIT) (makro i64 do parametru u32)
działają bez rzutowań, a u8 b = 300 jest odrzucane komunikatem the number 300 doesn't fit in u8.
Literał f64 może stać się f32 (f32 h = 0.5).
Czemu nie wystarczy sprawdzać isa<ConstantInt>? IRBuilder zwija stałe, więc u8(250) też jest
ConstantInt. Gdyby regułą było „jest stałą”, u8(250) + 10 po cichu rozszerzyłoby się do
int i dało 260, a ten sam kod ze zmienną u8 zawinąłby się do 4. Jawna flaga sprawia, że
zachowanie zależy od tego, co napisałeś, a nie od tego, co zdołał zobaczyć optymalizator.
Konwersje niejawne: coerce(value, want, pos, what)
Używane wszędzie, gdzie wartość trafia do miejsca o znanym typie: inicjalizatory, przypisania,
argumenty, wartości zwracane, warunki (want = bool), granice zakresu (want = int).
Dozwolone, po kolei:
- identyczne typy,
null→ dowolnyptr…albostr,- dowolny
ptr…albostr→ptrbez typu, aptrbez typu → dowolnyptr[T](regułyvoid*; tak działaptr[i32] n = malloc(4)), - int → int, gdy wartość jest mieszczącym się literałem albo
widens(from, to):- ta sama znakowość i ściśle więcej bitów, albo
- bez znaku → ze znakiem o ściśle większej liczbie bitów,
- (ze znakiem → bez znaku nigdy się nie rozszerza),
- dowolna całkowita → dowolna zmiennoprzecinkowa (
sitofp/uitofp), f32→f64(fpext),- literał
f64→f32(fptrunc, zwijane do stałej).
Wszystko inne to błąd. Komunikat podpowiada jawną konwersję (use i32(...) to convert)
i ostrzega, gdy obetnie wartość (it cuts off the fraction).
Argumenty operatorów binarnych: unify(l, r)
Przed arytmetyką i porównaniem obie strony są sprowadzane do jednego typu:
- ten sam typ → gotowe,
- obie całkowite: strona-literał dopasowuje się do drugiej, jeśli się mieści; w przeciwnym razie
rozszerzana jest strona, która
widens()do drugiej; w przeciwnym razie błąd (i32+u32: żadna nie rozszerza się do drugiej, a Finch nie zgaduje), - całkowita i zmiennoprzecinkowa → całkowita zamieniana na ten typ zmiennoprzecinkowy,
f32if64→ obief64.
Typ wyniku arytmetyki to ten wspólny typ. Nie ma promocji całkowitych jak w C:
u8 + u8 liczy się w u8 i się zawija. To celowe: tego wymaga typ z rozmiarem, i tak samo działają Rust i Zig.
Konwersje jawne: convert(call, to)
T(x) parsuje się jako wywołanie o nazwie typu i jest tłumaczone tutaj:
| Z → Na | Instrukcja |
|---|---|
| int/char → int/char | CreateIntCast(signed = from.isSigned()) → trunc / sext / zext |
| bool → int/char | zext |
| float → int | fptosi / fptoui (wg znakowości celu) |
| int → float | sitofp / uitofp (wg znakowości źródła) |
| float → float | fpext / fptrunc (CreateFPCast) |
| int → bool | icmp ne x, 0 |
| ptr/str/null → ptr…, ptr → str | brak instrukcji (wszystko to ptr) |
fptosi dla wartości spoza zakresu to w LLVM poison (w C: UB). Finch 1.0 tego nie sprawdza, zobacz rozdział 15.
Porównania: compare()
- Wskaźniki: jeśli którakolwiek strona jest
ptr…albonull, dozwolone są tylko==/!=, kompilowane doicmp eq/ne ptr.strporównywany znullteż porównuje adresy. str == str: wywołaniestrcmp, a potemicmp eq/ne i32 %r, 0(porównanie treści).</>nastrsą odrzucane.- Liczby zmiennoprzecinkowe:
oeq olt ole ogt oge(uporządkowane: fałsz dla NaN) orazunedla!=(nieuporządkowane:NaN != NaNto prawda). Tak samo jak w C. - Całkowite / char:
eq neorazslt sle sgt sgealboult ule ugt ugewg znakowości.charporównywany jest bez znaku. - bool: tylko
==/!=.
8. Generowanie kodu
Codegen posiada llvm::IRBuilder<> b, Module, tablicę funkcji fns
(nazwa → {Function*, const FnDecl*}), scopes, stos loops i curFn.
8.1 Zmienne to miejsca na stosie
Każda zmienna, łącznie z parametrami, dostaje alloca tworzoną przez slot().
slot() zawsze wstawia ją na początek bloku wejściowego, drugim IRBuilder ustawionym na
entry.begin(), niezależnie od miejsca deklaracji. Tylko takie alloca mem2reg/SROA umieją
zamienić na rejestry SSA, więc wyjście O2 nie ma ruchu na stosie dla wartości skalarnych.
Ponieważ każde nowe miejsce trafia na sam początek, w IR z -O0 alloca są w odwrotnej kolejności
deklaracji (%b2 przed %a1 niżej).
Parametry są kopiowane do swoich miejsc na wejściu do funkcji (store %a, %a1), dzięki czemu
parametry są zwykłymi, modyfikowalnymi zmiennymi lokalnymi.
8.2 Load i store: expr() kontra place()
To jest serce generowania dostępów do pamięci:
place(e)zwracaPlace {Value *addr; FType type}, czyli adres l-wartości.VarExpr→allocazmiennej (alboGlobalVariablezmiennej globalnej C).MemberExprp.value→expr(p)(które ładuje wskaźnik z miejsca p) plus kontrola null (rozdział 9). Adresem jest sam załadowany wskaźnik, a typem*p.type.elem.
expr(e)zwracaValue_, czyli r-wartość. DlaVariMemberwołaplace(), a potem emituje jedenloadtyputy(place.type)zplace.addr.
Instrukcja load dla identyfikatora powstaje więc w Codegen::expr(), w przypadku ExprKind::Var,
jako b.CreateLoad(ty(pl.type), pl.addr, v.name). Nazwa wzięta od zmiennej sprawia, że IR z -O0
pokazuje %x1, %x2 itd. (LLVM dokleja numery do powtórzonych nazw). p.value kosztuje dwa
loady: wskaźnika i wartości wskazywanej. pp.value.value kosztuje trzy.
Instrukcje store powstają w dokładnie trzech miejscach: addVar() (inicjalizacja), assign() i krok pętli for.
8.3 Przypisanie i kolejność obliczeń
x += add(x, 2)
assign() najpierw liczy place(target). Dla operatorów złożonych potem ładuje bieżącą
wartość, następnie oblicza prawą stronę, wywołuje arith(), robi coerce() z powrotem do typu
celu i zapisuje. Prawdziwe wyjście z -O0:
define i32 @main() {
entry:
%x = alloca i64, align 8
store i64 5, ptr %x, align 8 ; x := 5
%0 = load i64, ptr %x, align 8 ; bieżące x (dla +=)
%x1 = load i64, ptr %x, align 8 ; argument x
%1 = call i64 @finch.add(i64 %x1, i64 2)
%2 = add i64 %0, %1
store i64 %2, ptr %x, align 8
%x2 = load i64, ptr %x, align 8
%3 = call i32 (ptr, ...) @printf(ptr @fmt, i64 %x2)
ret i32 0
}
Kolejność obliczeń w Finchu jest zawsze od lewej do prawej: cel, potem argumenty operatora
binarnego od lewej, potem argumenty wywołania od lewej. C zostawia większość tego nieokreśloną.
Zauważ, że w x += f() stara wartość x jest czytana przed wywołaniem f(). Gdyby f
zmieniła x przez wskaźnik, ta zmiana zostałaby nadpisana. To to samo co x = x + f()
czytane od lewej do prawej.
Po O2 cały program powyżej zwija się do printf(@fmt, i64 12).
8.4 Funkcje, linkowanie i main
define internal i64 @finch.add(i64 %a, i64 %b) {
entry:
%b2 = alloca i64, align 8
%a1 = alloca i64, align 8
store i64 %a, ptr %a1, align 8
store i64 %b, ptr %b2, align 8
%a3 = load i64, ptr %a1, align 8
%b4 = load i64, ptr %b2, align 8
%0 = add i64 %a3, %b4
ret i64 %0
}
- Funkcje użytkownika nazywają się
finch.<nazwa>i mają wiązanie wewnętrzne (internal linkage). Kropka nie może wystąpić w identyfikatorze C, więc funkcja Fincha nigdy nie zderzy się z symbolem libc ani biblioteki, a wiązanie wewnętrzne pozwala LLVM swobodnie je wstawiać (inline), specjalizować albo usuwać. W C funkcja bezstaticjest zewnętrzna i musi zostać zachowana tak, jak ją napisano. To prawdopodobnie jeden z powodów, dla których rekurencyjnefib(40)działa odrobinę szybciej niż ten sam kod C podclang -O2(ok. 0,19 s wobec 0,23 s na maszynie autora). mainjest emitowane jakoi32 @main()z wiązaniem zewnętrznym.fn main()zwracai32 0na każdej ścieżce wyjścia, także przy jawnymreturn.fn main() -> intprzycina swój wyniki64przezCreateIntCast(…, i32, signed).- Brakujący return: jeśli po ciele funkcji bieżący blok nie ma terminatora, funkcje
voiddostająret void(alboret i32 0dlamain). Funkcje zwracające wartość dostają błąd kompilacji:function 'f' can reach its end without returning int. Sprawdza się to na faktycznie zbudowanym CFG (rozdział 8.5), więcif/elsezwracające wartość w obu gałęziach jest akceptowane bez dodatkowej analizy. - Arytmetyka jest emitowana bez flag
nsw/nuw, więc przepełnienie liczb całkowitych to zdefiniowane zawinięcie w kodzie uzupełnień do dwóch (rozdział 15).
8.5 Sterowanie przebiegiem i stan „zakończony”
Blok, do którego wstawia builder, jest jedynym źródłem prawdy o osiągalności:
bool terminated() { return b.GetInsertBlock()->getTerminator() != nullptr; }
Po return, break albo continue bieżący blok ma terminator.
blockBody() sprawdza terminated() przed każdą instrukcją. Jeśli jest ustawiony, instrukcja
jest nieosiągalna i to błąd kompilacji (this code can never run). Martwe bloki dla kodu po skoku nigdy nie powstają.
continueAt(bb, deadEnd) jest używane po if i while. Jeśli blok łączący bb nie ma
poprzedników (wszystkie ścieżki wróciły albo przerwały), zostaje usunięty, a builder zostaje
w deadEnd, który jest zakończony. Stan „zakończony” propaguje się więc na zewnątrz przez
zagnieżdżone konstrukcje i widzi go sprawdzanie brakującego returna.
if / else if / else: bloki then, else (jeśli jest) i endif. else if to IfStmt
w otherwise, generowany rekurencyjnie wewnątrz bloku else.
while: bloki while.cond, while.body, while.end. Jeśli warunek zwinie się do stałej
true, emitowany jest bezwarunkowy br, więc while.end ma poprzedników tylko przez break.
while true bez break zostawia więc funkcję „zakończoną”, a funkcja, której ostatnią instrukcją
jest taka pętla, nie potrzebuje końcowego return.
for i in a..b: a i b są obliczane raz, przed pętlą, i zamieniane na int.
Bloki: for.cond (icmp slt i, end), for.body, for.step (i + 1), for.end. continue
skacze do for.step, a nie do for.cond, więc inkrementacja nigdy nie jest pomijana.
Zmienna pętli żyje we własnym zasięgu wokół pętli.
store i64 0, ptr %i, align 8
br label %for.cond
for.cond:
%3 = load i64, ptr %i, align 8
%4 = icmp slt i64 %3, 2
br i1 %4, label %for.body, label %for.end
for.body:
...
br label %for.step
for.step:
%6 = load i64, ptr %i, align 8
%7 = add i64 %6, 1
store i64 %7, ptr %i, align 8
br label %for.cond
for.end:
break / continue: loops to stos par {continueTo, breakTo}. while odkłada
{while.cond, while.end}, a for odkłada {for.step, for.end}. Poza pętlą to błąd.
8.6 Skrócone && / ||: jedyne ręcznie budowane φ
fn check(int a, int b) -> bool {
return a > 0 && b > 0
}
%a3 = load i64, ptr %a1, align 8
%0 = icmp sgt i64 %a3, 0
br i1 %0, label %and.rhs, label %and.end
and.rhs:
%b4 = load i64, ptr %b2, align 8
%1 = icmp sgt i64 %b4, 0
br label %and.end
and.end:
%2 = phi i1 [ false, %entry ], [ %1, %and.rhs ]
logic() zapamiętuje blok, w którym lewa strona się skończyła (from), i blok, w którym
skończyła się prawa (rhsEnd). To niekoniecznie bloki, w których obliczanie się zaczęło, bo
zagnieżdżone && tworzy własne bloki. φ używa !isAnd jako stałej skrótu (false dla &&, true dla ||).
8.7 print
print(a, b, …) kompiluje się do jednego wywołania printf. Format jest budowany w czasie
kompilacji z typów argumentów, łączony spacjami i zakończony \n:
| Typ Fincha | Format | Konwersja argumentu |
|---|---|---|
| całkowite ze znakiem | %lld |
sext → i64 |
| całkowite bez znaku | %llu |
zext → i64 |
f32, f64 |
%g |
fpext → double |
char |
%c |
zext → i32 |
str |
%s |
bez zmian |
bool |
%s |
select i1, "true", "false" |
ptr… |
%p |
bez zmian (glibc wypisuje (nil) dla null) |
null |
dosłowny tekst null |
brak |
Format jest generowany, więc teksty użytkownika zawsze idą jako argumenty %s, nigdy jako
format. % w tekście użytkownika jest bezpieczne. O2 często zamienia printf("%s\n", s) na puts(s).
8.8 Teksty
Literał tekstowy to prywatna stała globalna unnamed_addr (CreateGlobalString), a jego
wartością jest ptr na nią. Optymalizator może scalać identyczne literały. Zmienna str
zadeklarowana bez wartości wskazuje na wspólną stałą "". W 1.0 teksty są niezmienne
i statyczne. Nie ma sklejania ani alokacji (rozdział 18).
8.9 addr() i wskaźniki
addrOf() wymaga argumentu Var albo Member i zwraca place(arg).addr z typem ptr[T].
Nie ma żadnego load: addr(x) to jest alloca zmiennej x. Takiej alloca mem2reg nie zamieni
na rejestr, co jest spodziewanym kosztem pobrania adresu. addr(p.value) daje wskaźnik już
zapisany w p, po kontroli null. addr zmiennej for jest odrzucane (jest tylko do odczytu).
8.10 Wywołania: callFinch() kontra callC()
Obie sprawdzają liczbę argumentów i robią coerce() każdego argumentu do typu parametru. Różnice:
callFinch() |
callC() |
|
|---|---|---|
| Wołana funkcja | finch.<nazwa>, utworzona w declare() |
getOrInsertFunction(nazwa, …): deklarowana leniwie przy pierwszym użyciu, potem używana ponownie |
| Zmienna liczba argumentów | nigdy | obsługiwana; dodatkowe argumenty dostają domyślne promocje C |
| Atrybuty parametrów | niepotrzebne | signext/zeroext dla parametrów i wartości zwracanych węższych niż 32 bity, na deklaracji i w miejscu wywołania |
| Nieobsługiwane sygnatury | niemożliwe | odrzucane z powodem zapisanym przez cimport (passes a C struct by value, …) |
Wywołanie main |
odrzucane | nie dotyczy |
Domyślne promocje dla części zmiennej: f32 → double; bool, char i liczby całkowite
węższe niż 32 bity → i32 (z rozszerzeniem znaku albo zerami). Wskaźniki, str i liczby
32/64-bitowe przechodzą bez zmian. To odwzorowuje reguły C, których oczekuje va_arg w printf.
Po co signext/zeroext: na x86-64 System V clang przekazuje char/short/_Bool już
rozszerzone do 32 bitów i tak je oznacza. Niektóre funkcje na tym polegają. Bez atrybutu LLVM
zostawiłby górne bity nieokreślone, co byłoby niezgodnością ABI z kodem C skompilowanym clangiem.
9. Kontrole w czasie działania
Finch usuwa dwa najczęstsze źródła niezdefiniowanego zachowania w C, które da się tanio sprawdzić. Wszystkie kontrole korzystają z jednej funkcji pomocniczej na moduł:
; Function Attrs: cold noinline noreturn
define internal void @finch.panic(ptr %0, i64 %1) #0 {
entry:
%2 = call i32 (i32, ptr, ...) @dprintf(i32 2, ptr @panic.fmt, ptr @panic.file, i64 %1, ptr %0)
call void @exit(i32 1)
unreachable
}
panicFn() tworzy ją przy pierwszym użyciu. panicIf(cond, msg, pos) emituje
br cond, %panic, %ok, wywołanie finch.panic(msg, linia) i unreachable w %panic,
a dalej kontynuuje w %ok. Pisze na deskryptor 2 przez dprintf, więc nie zależy od symbolu
stderr z libc. Funkcja jest cold i noinline, więc układ skoków faworyzuje ścieżkę bez
błędu, a kontrola kosztuje jedno porównanie i dobrze przewidywany skok.
Dzielenie i reszta całkowita: checkDivisor()
define internal i64 @finch.div(i64 %a, i64 %b) {
...
%0 = icmp eq i64 %b4, 0
br i1 %0, label %panic, label %ok
panic:
call void @finch.panic(ptr @panic.msg, i64 2)
unreachable
ok:
%1 = icmp eq i64 %b4, -1
%2 = icmp eq i64 %a3, -9223372036854775808
%3 = and i1 %2, %1
br i1 %3, label %panic5, label %ok6
panic5:
call void @finch.panic(ptr @panic.msg.1, i64 2)
unreachable
ok6:
%4 = sdiv i64 %a3, %b4
- Dzielnik będący stałym zerem to błąd kompilacji.
- Stały dzielnik różny od zera (i od −1 dla typów ze znakiem) nie generuje żadnej kontroli.
- Przy dzieleniu ze znakiem
MIN / -1się przepełnia (na x86 kończy się SIGFPE). To też jest sprawdzane. - Gdy wszystko jest znane, O2 usuwa kontrole całkowicie:
div(10, 2)powyżej staje się5wmain.
.value na null: w place()
Każde p.value, którego wskaźnik nie jest oczywiście niezerowy (nie jest wprost alloca ani
zmienną globalną), dostaje panicIf(icmp eq p, null, "used .value on a null pointer").
Bez tego LLVM traktuje load z null jako UB i może go usunąć. Zanim dodano tę kontrolę,
print(p.value) przy p równym null wypisywało śmieciową wartość pod O2 zamiast się wysypać.
10. Współpraca z C przez libclang
importHeaders(imports, dir) (src/cimport.cpp) działa przed codegenem i tworzy:
struct CImports {
std::unordered_map<std::string, CFunc> fns; // nazwa, nagłówek, wynik, parametry, variadic, powód braku obsługi
std::unordered_map<std::string, CConst> consts; // wartości enum + liczbowe #define
std::unordered_map<std::string, CGlobal> globals; // zmienne extern
};
Parsowanie
Dla każdego import "h" parsowana jest osobna jednostka translacji z pliku w pamięci
finch_import.c zawierającego #include "h". Osobne jednostki dają dokładne przypisanie błędów:
brakujący nagłówek wskazuje swoją linijkę import. Flagi:
-x c -std=gnu11 -I<katalog pliku .fn> -isystem<każdy systemowy katalog nagłówków>
CXTranslationUnit_DetailedPreprocessingRecord (żeby widzieć makra)
CXTranslationUnit_SkipFunctionBodies (szybkość: ciała funkcji inline są zbędne)
- Systemowe katalogi nagłówków pochodzą z uruchomienia
$CC -E -v -x c /dev/nulli odczytania bloku między#include <...> search starts here:aEnd of search list.. Dzięki temustdio.hrozwiązuje się tak samo jak dla systemowego kompilatora. Ma to znaczenie na NixOS, gdzie wrapper cc dokłada ścieżki ze store, o których „goły” libclang nic nie wie. gnu11zamiastc11: glibc w trybie ścisłym ukrywaM_PIi inne nazwy spoza ISO.- Diagnostyka o wadze ≥ error przerywa import.
file not foundzamienia się wcan't find the C header 'h'.
Odwiedzający
visit() przechodzi po kursorach najwyższego poziomu. Wygrywa pierwsza deklaracja danej nazwy.
| Kursor | Działanie |
|---|---|
FunctionDecl |
Mapuje kanoniczny typ funkcji (clang_getArgType, więc parametry tablicowe są już zamienione na wskaźniki). Funkcje static są zapisywane jako nieobsługiwane (static inline nie ma symbolu do zlinkowania). FunctionNoProto (K&R f()) jest nieobsługiwane. Zmienna liczba argumentów pochodzi z clang_isFunctionTypeVariadic. |
VarDecl z extern |
→ CGlobal, jeśli typ się mapuje |
EnumDecl |
rekurencja do dzieci |
EnumConstantDecl |
→ CConst (i64, literał) |
MacroDefinition |
pomija wbudowane i makra-funkcje; macroValue() próbuje odczytać liczbę |
Mapowanie typów: mapType()
Działa na typie kanonicznym, więc typedefy (size_t, uint32_t, GLuint, FILE) są rozwijane.
Typy całkowite są mapowane według rozmiaru (clang_Type_getSizeOf), a nie nazwy, więc
long staje się i64 na LP64.
| C (kanoniczny) | Finch |
|---|---|
void |
nic |
_Bool |
bool |
char (dowolna znakowość) |
char |
signed char, short, int, long, long long, enum |
i8/i16/i32/i64 wg rozmiaru |
warianty unsigned |
u8…u64 wg rozmiaru |
float / double |
f32 / f64 |
char * (dowolne kwalifikatory) |
str |
T *, gdzie T mapuje się na typ niebędący void |
ptr[T] |
void *, wskaźnik na struct/union/funkcję/typ niemapowalny |
ptr (nieprzezroczysty) |
| struct/union przez wartość | nieobsługiwane („passes a C struct by value”) |
long double, __int128, wektory, … |
nieobsługiwane |
Funkcje nieobsługiwane zostają w tablicy, więc próba ich wywołania daje precyzyjny błąd zamiast „nie ma takiej funkcji”.
Makra: macroValue()
Tokenizuje zakres makra, odrzuca token nazwy i komentarze, zdejmuje zrównoważone nawiasy
zewnętrzne, akceptuje jeden wiodący - i wymaga dokładnie jednego literału liczbowego.
Liczby całkowite są czytane przez strtoull(…, 0), czyli według reguł C: 0x szesnastkowo
i zero wiodące jako ósemkowe, inaczej niż literały samego Fincha. Zmiennoprzecinkowe to
wszystko, co zawiera ., e lub E i nie jest szesnastkowe. Akceptowane są przyrostki
u U l L f F. To obejmuje M_PI, EOF ((-1)), RAND_MAX, INT_MAX oraz stałe GL_* i GLFW_*.
Wyrażenia ((1 << 4)), odwołania do innych makr i teksty są pomijane.
Wywoływanie
Zaimportowane funkcje są deklarowane w module dopiero przy wywołaniu, przez
getOrInsertFunction. To używa istniejącej deklaracji, jeśli print już utworzył printf.
Sygnatury się zgadzają (i32 (ptr, ...)), więc nie ma konfliktu.
Błędy linkowania
Gdy linkowanie się nie uda, linkFailed() w main.cpp przeszukuje wyjście linkera pod kątem
undefined reference to \sym'(GNU ld) iundefined symbol: sym(lld). Grupuje symbole wedługCFunc::headeri wypisuje, z którego nagłówka pochodzą, razem z odgadniętą linijkąlink (guessLib(): nazwa pliku bez rozszerzenia i końcowych cyfr, małymi literami: GLFW/glfw3.h→glfw). cannot find -lfoozamienia się wthe library 'foo' wasn't found`.
11. Optymalizacja, emisja, linkowanie
Cel
hostMachine() tworzy TargetMachine dla sys::getDefaultTargetTriple() z CPU "generic",
bez dodatkowych cech i z Reloc::PIC_, które jest wymagane dla domyślnych plików PIE we
współczesnych dystrybucjach. Jej data layout jest ustawiany na module, zanim powstanie
jakikolwiek IR, w konstruktorze Codegen. To ważne: IRBuilder bierze wyrównanie loadów
i store'ów z data layoutu i bez niego dostępy do i64 byłyby emitowane z align 4.
generic znaczy, że plik zadziała na każdym x86-64. Zachowanie w stylu -march=native wymagałoby
zmiany nazwy CPU i cech w tym miejscu.
Optymalizacja
optimize() buduje potok nowego menedżera przebiegów:
PassBuilder pb(tm);
pb.register{Module,CGSCC,Function,Loop}Analyses(...);
pb.crossRegisterProxies(lam, fam, cgam, mam);
pb.buildPerModuleDefaultPipeline(OptimizationLevel::O2).run(mod, mam);
To jest środkowa część -O2 z clanga: SROA/mem2reg, instcombine, inlining, GVN, LICM,
rozwijanie pętli, wektoryzacja i tak dalej. -O0 pomija to wywołanie, co przydaje się do
czytania IR wyprodukowanego przez Codegen (finch ir plik.fn -O0). Generator kodu maszynowego
i tak działa na domyślnym poziomie TargetMachine.
Emisja
emitObject() używa starego (legacy) menedżera przebiegów, bo potok kodu maszynowego LLVM
jest wciąż dostępny tylko przez niego: tm->addPassesToEmitFile(pm, out, nullptr, CodeGenFileType::ObjectFile).
Linkowanie
$CC plik.o -o plik -lm [pkg-config --libs NAZWA | -lNAZWA]...
Jako linker służy systemowy sterownik kompilatora C (domyślnie cc). Zna pliki crt platformy,
ścieżkę dynamicznego loadera, libc i domyślne ścieżki bibliotek, a odtwarzanie tego byłoby
pracochłonne. -lm jest dodawane zawsze. Dla każdego link "NAZWA" i -l NAZWA najpierw
próbowane jest pkg-config --libs NAZWA, żeby dołączyć ścieżki -L i biblioteki zależne.
Jeśli to się nie uda, używane jest -lNAZWA. Na Niksie wrapper cc zamienia ścieżki -L
w wpisy RPATH, więc wynik działa bez LD_LIBRARY_PATH. Plik obiektowy jest usuwany po
linkowaniu, niezależnie od wyniku.
finch run
Kompiluje do pliku tymczasowego (sys::fs::createTemporaryFile), uruchamia go przez system(),
usuwa i przekazuje dalej kod wyjścia. Jeśli proces potomny zginął od sygnału, wypisuje
the program crashed: <strsignal> i zwraca 128 + numer_sygnału, jak powłoka.
12. Diagnostyka
fail(line, col, msg) jest [[noreturn]]: wypisuje
plik:linia:kol: error: msg
linia | tekst źródła
| ^
i kończy proces z kodem 1. Fragment jest brany z g_source, a tabulatory w prefiksie są
zachowywane, żeby ^ trafiło w miejsce. Konwencje w całym kodzie:
- Pozycje: operatory binarne raportują pozycję operatora, wywołania pozycję nazwy funkcji, a błędy typów w argumentach pozycję argumentu.
- Komunikaty mówią prostymi słowami, co jest źle, i jak to naprawić, gdy naprawa jest
oczywista (
use int(...) to convert,did you mean ':='?,add import "math.h"). headerHint()mapuje około dwóch tuzinów popularnych nazw z libc/libm na ich nagłówki, na potrzeby wywołań nieznanych funkcji.- Wewnętrzna niespójność, jeśli
verifyModulekiedykolwiek zawiedzie, wypisujeinternal compiler error (please report)z wyjściem weryfikatora i kończy proces z kodem 2.
Kompilator nie ma ostrzeżeń. Wszystko, co byłoby ostrzeżeniem, jest albo w porządku, albo błędem.
13. System budowania
CMakeLists.txt:
find_package(LLVM CONFIG). Jeśli istnieje celLLVM(współdzielonelibLLVM.so), Finch linkuje tę jedną bibliotekę. To nie kosmetyka, tylko konieczność:libclang.sosam linkujelibLLVM.so. Linkowanie Fincha ze statycznymi bibliotekami komponentów (libLLVMCore.a, …) wprowadza do procesu drugą kopię LLVM. Obie kopie rejestrują te same obiekty globalne i program wywracał się przy wyjściu w globalnym destruktorze (std::vector<TensorSpec>::~vector, podwójne zwolnienie). Do tego binarka miała 82 MB zamiast 11 MB.- libclang jest szukany przez
find_path(clang-c/Index.h)ifind_library(clang), z podpowiedzią katalogów LLVM. FINCH_VERSIONpochodzi zproject(VERSION …)i jest wypisywane przezfinch versionrazem zLLVM_VERSION_STRING.- C++17,
-Wall -Wextra, a budowanie przechodzi bez ostrzeżeń.
14. Testy
tests/run.sh (ścieżkę kompilatora nadpisuje FINCH=…):
tests/run/*.fn: uruchamiane przezfinch run. Połączone stdout+stderr musi być dokładnie równe*.out.tests/fail/*.fn: muszą zakończyć się kodem różnym od zera, a wyjście musi zawierać tekst po// expect:z pierwszej linii. To obejmuje błędy kompilacji, błędy w czasie działania (z numerami linii) oraz problemy z nagłówkami i linkowaniem.tests/fail/lib_hint.hdeklaruje funkcję, której nie ma w żadnej bibliotece, żeby przetestować podpowiedźlink.
Stan obecny: 34 passed, 0 failed. Każdy nowy komunikat błędu powinien dostać test w fail/,
a każda nowa funkcjonalność test w run/.
15. Semantyka a C: zachowania zdefiniowane i niezdefiniowane
| Sytuacja | C | Finch 1.0 |
|---|---|---|
| Przepełnienie liczby ze znakiem | UB | Zdefiniowane zawinięcie (bez flag nsw) |
| Przepełnienie liczby bez znaku | zawija się | zawija się |
| Dzielenie całkowite przez zero | UB | Błąd kompilacji, jeśli stałe, w przeciwnym razie błąd w czasie działania |
INT_MIN / -1 |
UB | Błąd w czasie działania |
| Load/store przez null | UB | Błąd w czasie działania dla .value |
x & 1 == 0 |
parsuje się jako x & (1 == 0) |
parsuje się jako (x & 1) == 0 |
Niejawne zwężenie (int → char) |
po cichu | Błąd kompilacji |
| Mieszanie ze znakiem / bez znaku | cicha konwersja | Błąd kompilacji, chyba że jedna strona się rozszerza |
| Użycie zmiennej przed nadaniem wartości | UB | Niemożliwe: każda deklaracja inicjalizuje (domyślnie zerem) |
Brak return w funkcji z wynikiem |
UB, jeśli wynik jest użyty | Błąd kompilacji |
| Przesunięcie o ≥ szerokość bitową | UB | Nadal poison (w 1.0 niesprawdzane) |
float → int poza zakresem |
UB | Nadal poison (w 1.0 niesprawdzane) |
| Wiszący wskaźnik na martwą zmienną lokalną | UB | Nadal UB (brak kontroli czasu życia) |
| Kolejność obliczeń | w większości nieokreślona | Od lewej do prawej |
16. Spis funkcji: kto co robi
Grupy funkcji o podobnych nazwach i czym się różnią.
Leksowanie i parsowanie
| Funkcja | Robi | Różni się od |
|---|---|---|
lex() |
tekst → tokeny, ustawia newlineBefore |
nic nie wie o gramatyce ani typach |
tokName() |
rodzaj tokenu → nazwa czytelna dla człowieka (do błędów) | |
Parser::cur() / peekTok() / next() |
bieżący token / podgląd bez konsumowania / konsumpcja | |
accept(k) kontra expect(k) |
konsumuje, jeśli pasuje, i zwraca bool / konsumuje albo zgłasza błąd „expected …, found …” | |
unexpected(ctx) |
błąd „unexpected X (ctx)” na bieżącym tokenie | expect mówi, czego chciał; unexpected tłumaczy kontekst |
sameLine() |
czy bieżący token może kontynuować wyrażenie? | endOfStatement() to odpowiednik na poziomie instrukcji (wymaga nowej linii albo }) |
atDeclaration() |
podgląd int x / ptr[...] p |
type() faktycznie konsumuje typ |
typeFromName() kontra isTypeName() |
nazwa → Type / nazwa → bool |
|
statement() kontra block() |
jedna instrukcja / { instrukcja* } z kontrolą końców i zerowaniem parenDepth |
|
callArgs() |
wspólne dla wywołań i konwersji: ( expr, … ) |
|
primary() / postfix() / unary() / multiplicative() / additive() / compare() / andExpr() / orExpr() |
po jednej funkcji na poziom priorytetu |
Codegen: typy i wartości
| Funkcja | Robi | Różni się od |
|---|---|---|
ty(FType) |
typ Fincha → typ LLVM | |
zero(FType) |
wartość domyślna dla deklaracji bez inicjalizatora | |
widens(from, to) |
czy każdą wartość from da się przedstawić w to? (czysta reguła typów) |
fits() pyta o to samo dla jednej stałej |
fits(c, from, to) |
czy wartość tego literału mieści się w to? |
|
coerce(v, want) |
niejawna konwersja do miejsca o znanym typie albo błąd | convert() to jawne T(x) i pozwala na stratne rzutowania; unify() zbliża do siebie dwa argumenty |
unify(l, r) |
sprowadza argumenty binarne do wspólnego typu | używane przez arithOp() i compare() |
intCast() / intToFloat() |
emitują rzutowanie z poprawną znakowością | niskopoziomowe pomocniki trzech powyższych |
Codegen: wyrażenia
| Funkcja | Robi | Różni się od |
|---|---|---|
expr(e) |
r-wartość: emituje kod, zwraca {wartość, typ, literal} |
place() zwraca adres, zamiast ładować |
place(e) |
adres l-wartości: zmiennej, zmiennej globalnej C albo p.value |
używane przez expr() (potem load), assign() (potem store) i addrOf() (zwraca bez zmian) |
binary() |
rozgałęzienie po BinOp |
|
arith() kontra arithOp() |
arith() opakowuje arithOp() i liczy flagę literal wyniku |
arithOp() robi unify i emituje instrukcję |
compare() |
wszystkie porównania: wskaźniki/str/float/int | zawsze zwraca bool i nigdy nie jest literałem |
logic() |
&&/|| ze skokami i φ |
jedyne miejsce budujące φ ręcznie |
call() |
zamienia nazwę wywołania na jeden z pięciu rodzajów | |
callFinch() kontra callC() |
zobacz rozdział 8.10 | |
print() |
buduje format printf z typów | |
addrOf() |
addr(x) → place(x).addr |
|
convert() |
T(x) |
|
libc() |
getOrInsertFunction: zadeklaruj albo użyj istniejącej funkcji zewnętrznej |
declare() tworzy funkcje Fincha przez Function::Create |
cGlobal() |
zadeklaruj albo użyj istniejącej zmiennej extern |
Codegen: instrukcje i sterowanie
| Funkcja | Robi | Różni się od |
|---|---|---|
declare() kontra define() |
przejście sygnatur / przejście ciał | |
block() kontra blockBody() |
block() otwiera i zamyka zasięg wokół blockBody(); blockBody() emituje instrukcje i odrzuca nieosiągalne |
define() woła blockBody() bezpośrednio, bo parametry i zewnętrzne ciało dzielą jeden zasięg |
stmt() |
rozgałęzienie po StmtKind; obsługuje też break/continue |
|
varDecl() / assign() |
deklaracja z opcjonalnym wnioskowaniem / zapis do istniejącego miejsca | |
addVar() |
tworzy miejsce, zapisuje wartość początkową, rejestruje nazwę (z kontrolą przesłaniania) | slot() tylko tworzy alloca |
lookup() |
szuka we wszystkich zasięgach, od najbardziej wewnętrznego | |
condition(e) |
coerce(expr(e), bool) z komunikatem „a condition must be bool” |
|
ifStmt() / whileStmt() / forStmt() / returnStmt() |
rozdział 8.5 | |
terminated() |
czy bieżący blok kończy się już terminatorem? | |
continueAt(bb, deadEnd) |
przejdź do bloku łączącego albo usuń go, jeśli jest nieosiągalny | |
newBlock() |
tworzy blok podstawowy w bieżącej funkcji |
Kontrole w czasie działania
| Funkcja | Robi |
|---|---|
panicFn() |
pobiera albo tworzy finch.panic |
panicIf(cond, msg, pos) |
warunkowy skok do bloku z panic |
checkDivisor(l, r, pos) |
kontrole zera i MIN/-1 albo błąd kompilacji |
Sterownik (main.cpp)
| Funkcja | Robi |
|---|---|
hostMachine() |
tworzy TargetMachine |
optimize() |
potok O2 |
emitObject() |
zapisuje .o |
capture() |
uruchamia polecenie powłoki, zbiera wyjście i kod wyjścia |
libFlags() |
pkg-config albo -l dla jednej biblioteki |
link() |
uruchamia linker, usuwa .o, tłumaczy porażki |
linkFailed() / guessLib() |
zamieniają błędy linkera na porady w języku Fincha |
17. Rozbudowa kompilatora
Nowa funkcja wbudowana (przykład: len(str) -> int)
- Dodaj nazwę do
Codegen::isBuiltin(), żeby nie dało się jej zdefiniować ponownie. - W
Codegen::call()dodaj rozgałęzienieif (c.callee == "len") return len(c);. - Zaimplementuj: sprawdź liczbę argumentów,
coerce(expr(arg), Str, …), wyemitujstrlenprzezlibc(), zwróć{call, I64}.strlenzwracau64; jeśli chceszint, przekonwertuj przezintCast. - Dodaj
tests/run/len.fnz plikiem.outi przypadek wtests/fail/dla złego argumentu.
Nowy operator binarny
- Lekser: nowy
Tok, jego zapis wswitchoperatorów i wtokName(). - AST: nowy
BinOp. - Parser: umieść go w funkcji właściwego poziomu priorytetu.
- Codegen:
opName(), emisja warithOp()albocompare()i wyjaśnienie złych argumentów wbadOperands().
Nowa instrukcja
Dodaj StmtKind i węzeł w ast.h, rozpoznaj ją w Parser::statement() i obsłuż w Codegen::stmt().
Jeśli może przenosić sterowanie, upewnij się, że wynik respektuje terminated() i używa continueAt()
dla swojego bloku łączącego.
Konwencje
Trzymaj się istniejącego stylu: wczesne fail() z podpowiedzią naprawy, komentarze tylko tam,
gdzie dlaczego nie jest oczywiste, i jeden test na jedno zachowanie.
18. Znane ograniczenia i plan
- Brak typów złożonych:
struct, tablice/wycinki z.leni struktury C przez wartość. To kolejne kamienie milowe, w tej kolejności: tablice opierają się na strukturach (wskaźnik + długość), a model pamięci na obu. - Model pamięci (uzgodniony projekt): wartości posiadające pamięć na stercie są zwalniane
automatycznie na końcu bloku, który je posiada,
deferuruchamia kod przy wyjściu z bloku, afreezostaje do ręcznej kontroli. Szkic implementacji: lista sprzątania na blok, emitowana na każdej krawędzi wyjścia (normalny koniec,return,break,continue).blockBody()i stosloopsjuż znają każdą krawędź wyjścia. - Teksty: sklejanie,
len,str(x). Zależą od modelu pamięci. - Wejście:
input(). - Moduły:
import nazwadla kodu w Finchu. Dziś jeden.fn= jeden program. - Niesprawdzane UB: wielkość przesunięcia, zakres float→int, wiszące wskaźniki. Przesunięcia i float→int dałoby się sprawdzać jak dzielenie, niewielkim kosztem.
- Informacje dla debuggera: brak DWARF.
DIBuilderpodpiąłby się wdefine()i przyposkażdej instrukcji. - Platformy: tylko triple hosta, testowane na Linux/x86-64. Kod jest niezależny od platformy
z wyjątkiem wykrywania nagłówków przez
cc -E -vi użyciadprintf. - Bootstrap: długoterminowym celem jest przepisanie kompilatora w Finchu. Wołanie API LLVM-C już
działa: LLVM-C używa nieprzezroczystych uchwytów (
LLVMModuleRefitd.), które mapują się naptrFincha.examples/llvm.fnbuduje i wypisuje moduł z poziomu Fincha (import "llvm-c/Core.h",link "LLVM"). Do samodzielnego kompilatora brakuje jeszcze struktur, tablic, tekstów i operacji na plikach.