Ingegneria
Il confine dove compilano entrambe le parti e una delle due mente
Attraverso un ABI C, una funzione col nome storpiato non è un errore di compilazione e una col tipo sbagliato non la vede nemmeno nm. Una dichiarazione a 32 bit su un valore a 64 ha trasformato 23347 MB in 2,8 GB — plausibile, e sbagliato di otto volte.
Aggiornato 2026-09-12Misurato 2026-09-11
Due linguaggi che si incontrano su un ABI C non hanno un compilatore in comune, quindi non c'è niente che controlli se sono d'accordo. Ognuno dichiara cosa crede che l'altro fornisca, compilano entrambi, e il disaccordo — se c'è — diventa una proprietà del tempo di esecuzione.
Lo sanno tutti in astratto. Quello che è meno ovvio è quanto i due modi di fallire siano diversi, perché uno è soltanto fastidioso e l'altro è genuinamente pericoloso.
Un nome sbagliato fallisce tardi, ma fallisce
Dichiara runlix_generat dove la libreria esporta runlix_generate, e non protesta nessuno. Su
quasi tutte le piattaforme la risoluzione dei simboli è pigra: la libreria si carica, il programma
parte, e la chiamata fallisce la prima volta che ci si arriva. Che potrebbe essere in un percorso
che percorri il secondo giorno.
Fastidioso, ma onesto. Quando succede l'errore nomina il simbolo, e tu correggi un refuso.
La correzione che scala non è l'attenzione: è un test. Leggi la tabella dei simboli della libreria costruita e confrontala con le dichiarazioni, in tutti e due i versi:
- ogni simbolo che il chiamante dichiara di voler chiamare dev'essere esportato dalla libreria;
- ogni simbolo esterno che la libreria dichiara di volere dev'esistere in ciò che colleghi.
Contano entrambe le metà. Noi abbiamo avuto la prima per un po' e non la seconda, e la metà mancante era quella che avrebbe preso un difetto vero.
Un tipo sbagliato non fallisce affatto
Questo è quello interessante.
Dichiara il nome giusto col tipo sbagliato e il controllo sulla tabella dei simboli passa, perché
una tabella dei simboli conosce i nomi e non le firme. nm ti dirà che runlix_memoria
esiste. Non ti dirà che una parte crede di scrivere 64 bit e l'altra crede di leggerne 32.
Ecco il caso misurato. Il motore riporta il tetto di memoria che MLX raccomanda, come un u64
scritto attraverso un parametro d'uscita. La parte C# dichiarava il parametro come out uint — 32
bit.
Compilano entrambe. Non crasha niente. Il valore torna troncato ai suoi 32 bit bassi.
Il tetto vero sulla macchina era 23347 MB. Quello che è arrivato era 2,8 GB.
Guarda cosa lo rende grave. Non è zero, che sarebbe evidentemente sbagliato. Non è enorme, che sarebbe evidentemente sbagliato. È un numero che somiglia esattamente al tetto di memoria di un Mac più piccolo — quindi il codice a valle ci ha creduto, ha dimensionato il proprio budget su quello, e ha cominciato a sfrattare modelli per cui c'era spazio in abbondanza. Sbagliato di otto volte, e del tutto plausibile.
Perché le difese solite non lo prendono
Il compilatore non può. Ogni parte ha compilato contro la propria dichiarazione, ed erano entrambe internamente coerenti.
Il linker non può. Il nome combaciava.
nm non può. Le firme nella tabella dei simboli non ci sono.
I test spesso non possono, ed è la parte su cui vale la pena fermarsi. Un test che verifica che il valore sia «ragionevole» passa: 2,8 GB è ragionevole. Un test che verifica che sia maggiore di zero passa. Un test che verificasse un valore specifico l'avrebbe preso — ma un tetto di memoria specifico non lo puoi verificare, perché cambia da macchina a macchina.
Quello che l'ha preso è stato confrontare le due dichiarazioni una accanto all'altra, deliberatamente, come compito. Cioè: un essere umano le ha lette entrambe e se n'è accorto. Non è una risposta soddisfacente, quindi la domanda diventa come renderla ripetibile.
Com'è fatta la ripetibilità
Tre cose, in ordine crescente di fatica e di efficacia.
Genera una parte dall'altra. Se le dichiarazioni vengono da una sorgente sola non possono non essere d'accordo. Per quasi ogni coppia di linguaggi esistono strumenti, e se il confine è abbastanza piccolo va bene anche generarle a mano da una descrizione condivisa. È la correzione vera.
Verifica il giro completo dove puoi. Certi valori si possono confrontare con una fonte indipendente — un tetto di memoria si può paragonare, alla larga, a quello che dichiara il sistema, e uno scarto di otto volte fa fallire comodamente anche un controllo largo. Non tutto ha una fonte così, ma più cose di quante ne immagineresti.
Tieni il confine piccolo e noioso. Ogni funzione è un posto in cui le due dichiarazioni possono allontanarsi. Un confine di quindici funzioni si legge in una sessione; uno di duecento no, e quelle che nessuno legge sono dove questa cosa vive.
Noi facciamo la terza, adesso abbiamo la prima metà del controllo sui simboli in entrambi i versi, e il tetto di memoria ha un controllo di sanità indipendente. Il disaccordo sul tipo, quello, è stato trovato leggendo.
Com'è fatto in pratica il controllo sui simboli
È meno lavoro di quanto sembri, e vale la pena scriverlo per esteso perché l'implementazione ovvia fa metà del mestiere.
Leggi i simboli esportati della libreria costruita — nm -gU su macOS, nm -D su Linux, o
l'equivalente della tua piattaforma — e dalla stessa uscita leggi quelli non definiti. Poi
confronta con due elenchi che hai già: le funzioni esterne che il chiamante dichiara, e le
funzioni esterne che la libreria dichiara di volere.
Il primo confronto prende un refuso dalla parte del chiamante. Il secondo prende un refuso dalla parte della libreria, e prende anche qualcosa di più interessante: una dipendenza che credevi di collegare e non stai collegando. È la seconda metà quella che si salta, ed è quella che qui ha trovato un problema vero.
Due dettagli fanno la differenza fra un controllo che funziona e uno che passa per sempre. Prendi l'elenco dei simboli dall'artefatto costruito, non dal sorgente: analizzare il sorgente riproduce le stesse dichiarazioni che stai cercando di verificare, quindi è d'accordo con sé stesso per costruzione. E fallo girare nello stesso lavoro che costruisce, non come passo manuale a parte, perché un controllo che qualcuno deve ricordarsi è un controllo che smette di girare la settimana in cui serve.
Cosa resta comunque scoperto
Meglio essere chiari sui limiti, visto che il senso di questa nota è che le difese solite si lasciano sfuggire delle cose.
Il controllo sui simboli non copre la disposizione delle struct. Se le dichiarano entrambe le parti e una aggiunge un campo, i nomi combaciano ancora e le dimensioni no. È lo stesso fallimento con un raggio più ampio, e l'unica difesa vera è generare la struct da una definizione sola.
Non copre la convenzione di chiamata né le durate di vita. Chi alloca, chi libera, se un puntatore sopravvive alla chiamata: niente di questo sta in una tabella dei simboli, e niente nemmeno in una firma. Vive nei commenti e nella disciplina, che è un altro argomento per un confine abbastanza piccolo da tenere in testa.
L'altro motivo per tenere il confine piccolo
Vale la pena dirlo perché ha spinto la stessa decisione da un'altra direzione: il nostro confine C è un'interfaccia a polling e non a callback. Il chiamante chiede il token successivo; niente richiama indietro dentro la parte gestita.
Quella scelta è stata fatta per evitare che un thread estraneo entri in un runtime con garbage collector in un momento che non ha scelto nessuno. Ma ha un secondo effetto che si è rivelato più importante: un confine a polling ha meno forme di funzione, nessun puntatore a funzione che attraversa, e nessuna domanda su chi possiede un callback. Meno dichiarazioni da tenere d'accordo è la stessa proprietà di meno posti in cui sbagliare.
La versione corta
- Una funzione esterna col nome storpiato non è un errore di build. La risoluzione è pigra.
- Una col tipo sbagliato non la vede nemmeno
nm, perché le tabelle dei simboli tengono i nomi, non le firme. - Misurato:
out uintsu unu64ha trasformato un tetto di 23347 MB in 2,8 GB — troncato, plausibile, sbagliato di otto volte. - Controlla i simboli in tutti e due i versi, genera le dichiarazioni da una sorgente sola dove puoi, e tieni il confine abbastanza piccolo che qualcuno possa leggerlo tutto.
Misurato l'11 settembre 2026, su un ABI C fra Rust e .NET. Niente di tutto ciò è specifico di quella coppia.