Finch/docs/pl/dla-inzynierow.md
Nexoniarz 0591cd20f3 Finch 1.0.0
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>
2026-10-08 18:21:06 +02:00

55 KiB
Raw Blame History

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

  1. Architektura
  2. Pliki źródłowe
  3. Lekser
  4. Gramatyka i parser
  5. AST
  6. Model semantyczny
  7. System typów
  8. Generowanie kodu
  9. Kontrole w czasie działania
  10. Współpraca z C przez libclang
  11. Optymalizacja, emisja, linkowanie
  12. Diagnostyka
  13. System budowania
  14. Testy
  15. Semantyka a C: zachowania zdefiniowane i niezdefiniowane
  16. Spis funkcji: kto co robi
  17. Rozbudowa kompilatora
  18. 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() w src/error.h). Nie ma odtwarzania po błędach, więc AST i codegen nigdy nie widzą częściowo poprawnego wejścia.
  • Jedno przejście. Codegen niesie 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, a mem2reg/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, exit pochodzą 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: line i col liczone są od 1, a col liczy 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 jako Ident, a parser rozpoznaje je przez typeFromName(). Lekser nie zależy więc od listy typów, a u8(x) parsuje się jak zwykłe wywołanie.
  • Liczby całkowite: dziesiętne albo szesnastkowe 0x, z separatorami _ (usuwanymi z text). Zera wiodące nic nie zmieniają: Finch nie ma ósemkowych, 010 to 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ęc 0..10 to Int DotDot Int, a nie Float(0.) Dot ….
  • Teksty / znaki: "…" i '…'. Sekwencje \n \t \r \0 \\ \" \' są dekodowane do text. 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:

  1. Słowa kluczowe (if, while, for, return, break, continue, {) są obsługiwane wprost. fn, import i link wewnątrz bloku dają celowane komunikaty.
  2. Deklaracja z typem: atDeclaration() zwraca prawdę, gdy bieżący token jest nazwą typu, a następny identyfikatorem (int x), albo gdy to ptr, a po nim [ (ptr[int] p). To jedyne miejsce wymagające dwóch tokenów wyprzedzenia.
  3. Deklaracja z wnioskowaniem: IDENT, a po nim :=.
  4. 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ć VarExpr albo MemberExpr, 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.
  5. Samodzielne wyrażenie jako instrukcja musi być CallExpr. Wszystko inne daje this 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
};
  • int i i64 to ten sam typ (I64), a name() wypisuje go jako int. Analogicznie float to F64.
  • Null to typ wyłącznie literału null. Żadna zmienna nie może go mieć.
  • Void to „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() to I8..U64, isSigned() to I8..I64, a isUnsigned() to U8..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:

  1. 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, zwraca int albo i32).
  2. 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:

  1. zmienna lokalna (lookup),
  2. stała C (cimports.consts: wartości enum, liczby z #define),
  3. zmienna globalna C (cimports.globals: stdout, …),
  4. 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:

  1. identyczne typy,
  2. null → dowolny ptr… albo str,
  3. dowolny ptr… albo str → ptr bez typu, a ptr bez typu → dowolny ptr[T] (reguły void*; tak działa ptr[i32] n = malloc(4)),
  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),
  5. dowolna całkowita → dowolna zmiennoprzecinkowa (sitofp/uitofp),
  6. f32 → f64 (fpext),
  7. 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:

  1. ten sam typ → gotowe,
  2. 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),
  3. całkowita i zmiennoprzecinkowa → całkowita zamieniana na ten typ zmiennoprzecinkowy,
  4. f32 i f64 → obie f64.

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… albo null, dozwolone są tylko ==/!=, kompilowane do icmp eq/ne ptr. str porównywany z null też porównuje adresy.
  • str == str: wywołanie strcmp, a potem icmp eq/ne i32 %r, 0 (porównanie treści). </> na str są odrzucane.
  • Liczby zmiennoprzecinkowe: oeq olt ole ogt oge (uporządkowane: fałsz dla NaN) oraz une dla != (nieuporządkowane: NaN != NaN to prawda). Tak samo jak w C.
  • Całkowite / char: eq ne oraz slt sle sgt sge albo ult ule ugt uge wg znakowości. char poró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) zwraca Place {Value *addr; FType type}, czyli adres l-wartości.
    • VarExpr → alloca zmiennej (albo GlobalVariable zmiennej globalnej C).
    • MemberExpr p.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) zwraca Value_, czyli r-wartość. Dla Var i Member woła place(), a potem emituje jeden load typu ty(place.type) z place.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 bez static jest zewnętrzna i musi zostać zachowana tak, jak ją napisano. To prawdopodobnie jeden z powodów, dla których rekurencyjne fib(40) działa odrobinę szybciej niż ten sam kod C pod clang -O2 (ok. 0,19 s wobec 0,23 s na maszynie autora).
  • main jest emitowane jako i32 @main() z wiązaniem zewnętrznym. fn main() zwraca i32 0 na każdej ścieżce wyjścia, także przy jawnym return. fn main() -> int przycina swój wynik i64 przez CreateIntCast(…, i32, signed).
  • Brakujący return: jeśli po ciele funkcji bieżący blok nie ma terminatora, funkcje void dostają ret void (albo ret i32 0 dla main). 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ęc if/else zwracają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 / -1 się 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ę 5 w main.

.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/null i odczytania bloku między #include <...> search starts here: a End of search list.. Dzięki temu stdio.h rozwią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.
  • gnu11 zamiast c11: glibc w trybie ścisłym ukrywa M_PI i inne nazwy spoza ISO.
  • Diagnostyka o wadze ≥ error przerywa import. file not found zamienia się w can'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 verifyModule kiedykolwiek zawiedzie, wypisuje internal 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 cel LLVM (współdzielone libLLVM.so), Finch linkuje tę jedną bibliotekę. To nie kosmetyka, tylko konieczność: libclang.so sam linkuje libLLVM.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) i find_library(clang), z podpowiedzią katalogów LLVM.
  • FINCH_VERSION pochodzi z project(VERSION …) i jest wypisywane przez finch version razem z LLVM_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 przez finch 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.h deklaruje 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)

  1. Dodaj nazwę do Codegen::isBuiltin(), żeby nie dało się jej zdefiniować ponownie.
  2. W Codegen::call() dodaj rozgałęzienie if (c.callee == "len") return len(c);.
  3. Zaimplementuj: sprawdź liczbę argumentów, coerce(expr(arg), Str, …), wyemituj strlen przez libc(), zwróć {call, I64}. strlen zwraca u64; jeśli chcesz int, przekonwertuj przez intCast.
  4. Dodaj tests/run/len.fn z plikiem .out i przypadek w tests/fail/ dla złego argumentu.

Nowy operator binarny

  1. Lekser: nowy Tok, jego zapis w switch operatorów i w tokName().
  2. AST: nowy BinOp.
  3. Parser: umieść go w funkcji właściwego poziomu priorytetu.
  4. Codegen: opName(), emisja w arithOp() albo compare() i wyjaśnienie złych argumentów w badOperands().

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 .len i 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, defer uruchamia kod przy wyjściu z bloku, a free zostaje 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 stos loops już znają każdą krawędź wyjścia.
  • Teksty: sklejanie, len, str(x). Zależą od modelu pamięci.
  • Wejście: input().
  • Moduły: import nazwa dla 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. DIBuilder podpiąłby się w define() i przy pos każ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 -v i użycia dprintf.
  • Bootstrap: długoterminowym celem jest przepisanie kompilatora w Finchu. Wołanie API LLVM-C już działa: LLVM-C używa nieprzezroczystych uchwytów (LLVMModuleRef itd.), które mapują się na ptr Fincha. examples/llvm.fn buduje 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.