Intro
Un compilatore tipico esegue le seguenti operazioni:
- Analisi: il testo di origine viene convertito in un albero di sintassi astratto (AST).
- Risoluzione di riferimenti ad altri moduli (C rimanda questo passaggio fino al collegamento).
- Convalida semantica: eliminando le affermazioni sintatticamente corrette che non hanno senso, ad esempio codice irraggiungibile o dichiarazioni duplicate.
- Trasformazioni equivalenti e ottimizzazione di alto livello: l'AST viene trasformato per rappresentare un calcolo più efficiente con la stessa semantica. Ciò include ad esempio il calcolo precoce delle sottoespressioni comuni e delle espressioni costanti, l'eliminazione di assegnazioni locali eccessive (vedere anche SSA ), ecc.
- Generazione di codice: l'AST si trasforma in codice lineare di basso livello, con salti, allocazione dei registri e simili. Alcune chiamate di funzione possono essere integrate in questa fase, alcuni loop srotolati, ecc.
- Ottimizzazione dello spioncino: il codice di basso livello viene scansionato alla ricerca di semplici inefficienze locali che vengono eliminate.
La maggior parte dei compilatori moderni (ad esempio gcc e clang) ripetono ancora una volta gli ultimi due passaggi. Usano un linguaggio intermedio di basso livello ma indipendente dalla piattaforma per la generazione iniziale del codice. Quindi quel linguaggio viene convertito in codice specifico della piattaforma (x86, ARM, ecc.) Facendo all'incirca la stessa cosa in modo ottimizzato per la piattaforma. Ciò include, ad esempio, l'uso di istruzioni vettoriali quando possibile, il riordino delle istruzioni per aumentare l'efficienza di previsione del ramo e così via.
Successivamente, il codice oggetto è pronto per il collegamento. La maggior parte dei compilatori di codice nativo sa come chiamare un linker per produrre un eseguibile, ma non è di per sé un passaggio di compilazione. In linguaggi come Java e C # il collegamento può essere totalmente dinamico, fatto dalla VM al momento del caricamento.
Ricorda le basi
- Fallo funzionare
- Rendilo bellissimo
- Rendilo efficiente
Questa sequenza classica si applica a tutto lo sviluppo del software, ma porta ripetizione.
Concentrati sul primo passo della sequenza. Crea la cosa più semplice che potrebbe funzionare.
Leggi i libri!
Leggi il Dragon Book di Aho e Ullman. Questo è classico ed è ancora abbastanza applicabile oggi.
Anche il design moderno del compilatore è elogiato.
Se questa roba è troppo difficile per te in questo momento, leggi prima alcune introduzioni sull'analisi; di solito l'analisi delle librerie include introduzioni ed esempi.
Assicurati di lavorare comodamente con i grafici, in particolare gli alberi. Queste cose sono le cose di cui sono fatti i programmi a livello logico.
Definisci bene la tua lingua
Usa qualunque notazione desideri, ma assicurati di avere una descrizione completa e coerente della tua lingua. Ciò include sia la sintassi che la semantica.
È giunto il momento di scrivere frammenti di codice nella tua nuova lingua come casi di test per il futuro compilatore.
Usa la tua lingua preferita
È assolutamente OK scrivere un compilatore in Python o Ruby o in qualsiasi lingua sia facile per te. Usa semplici algoritmi che capisci bene. La prima versione non deve essere veloce, efficiente o completa di funzionalità. Deve solo essere abbastanza corretto e facile da modificare.
Va anche bene scrivere diverse fasi di un compilatore in diverse lingue, se necessario.
Preparati a scrivere molti test
L'intera lingua dovrebbe essere coperta da casi di test; efficacemente sarà definito da loro. Conosci bene il tuo framework di test preferito. Scrivi i test dal primo giorno. Concentrarsi su test "positivi" che accettano il codice corretto, anziché il rilevamento di un codice errato.
Esegui tutti i test regolarmente. Correggere i test rotti prima di procedere. Sarebbe un peccato finire con un linguaggio mal definito che non può accettare un codice valido.
Crea un buon parser
I generatori di parser sono molti . Scegli quello che vuoi. Si può anche scrivere il proprio parser da zero, ma solo vale la pena se la sintassi del linguaggio è morto semplice.
Il parser dovrebbe rilevare e segnalare errori di sintassi. Scrivi molti casi di test, sia positivi che negativi; riutilizzare il codice che hai scritto durante la definizione della lingua.
L'output del tuo parser è un albero di sintassi astratto.
Se la tua lingua ha dei moduli, l'output del parser potrebbe essere la rappresentazione più semplice del "codice oggetto" che generi. Esistono molti modi semplici per scaricare un albero in un file e ricaricarlo rapidamente.
Crea un validatore semantico
Molto probabilmente il tuo linguaggio consente costruzioni sintatticamente corrette che potrebbero non avere senso in determinati contesti. Un esempio è una dichiarazione duplicata della stessa variabile o il passaggio di un parametro di tipo errato. Il validatore rileverà tali errori guardando l'albero.
Il validatore risolverà anche i riferimenti ad altri moduli scritti nella tua lingua, caricherà questi altri moduli e li userà nel processo di validazione. Ad esempio, questo passaggio assicurerà che il numero di parametri passati a una funzione da un altro modulo sia corretto.
Ancora una volta, scrivi ed esegui molti casi di test. I casi fondamentali sono indispensabili per la risoluzione dei problemi quanto intelligenti e complessi.
Genera codice
Usa le tecniche più semplici che conosci. Spesso è OK tradurre direttamente un costrutto linguistico (come if
un'istruzione) in un modello di codice leggermente parametrizzato, non diversamente da un modello HTML.
Ancora una volta, ignora l'efficienza e concentrati sulla correttezza.
Targeting di una macchina virtuale di basso livello indipendente dalla piattaforma
Suppongo che tu ignori cose di basso livello a meno che non sia profondamente interessato ai dettagli specifici dell'hardware. Questi dettagli sono cruenti e complessi.
Le tue opzioni:
- LLVM: consente una generazione efficiente del codice macchina, in genere per x86 e ARM.
- CLR: target .NET, principalmente x86 / basato su Windows; ha una buona squadra.
- JVM: si rivolge al mondo Java, piuttosto multipiattaforma, ha una buona JIT.
Ignora l'ottimizzazione
L'ottimizzazione è difficile. Quasi sempre l'ottimizzazione è prematura. Genera codice inefficiente ma corretto. Implementa l'intera lingua prima di provare a ottimizzare il codice risultante.
Ovviamente, sono ottimali banali ottimizzazioni da introdurre. Ma evita tutte le cose astute e pelose prima che il tuo compilatore sia stabile.
E allora?
Se tutte queste cose non ti intimidiscono troppo, procedi! Per un linguaggio semplice, ciascuno dei passaggi può essere più semplice di quanto si pensi.
Vedere un "Hello world" da un programma creato dal tuo compilatore potrebbe valere la pena.