Mijn niet-portabele transputer C-compiler portabel maken
Een paar weken geleden vond ik een diskette met mijn laatste C-compiler voor de transputer. Deze was destijds aangepast om gecompileerd te kunnen worden met Delorie's G++ of DJGPP (rond 1998). Hoewel de compilatie veel waarschuwingen genereerde, werkte het prima, omdat het een 32-bit compiler was die werd gecompileerd op een 32-bit Intel 80486-processor.
Dit was een tussenstap voordat ik de compiler zou omzetten om code te genereren voor een AMD Am29000-processor. Deze versie was verder verbeterd door overbodige Small-C constructies te verwijderen, ten gunste van een correcte lexicale analyse, ANSI C-syntaxis en een volledige preprocessor.
Als een soort nostalgische oefening probeerde ik de versie uit 1998 van mijn transputer C-compiler te compileren op een moderne 64-bit computer, een Macbook Air M1, en ik stuitte op veel problemen. Zo:
- De code gebruikt integers en pointers door elkaar (beide waren destijds 32-bit).
- Het type
FILE *werd niet gebruikt; in plaats daarvan werdintgebruikt. - Er zijn geen prototypes voor de compilerfuncties, waardoor clang terecht klaagde omdat pointers nu 64-bit zijn en integers 32-bit.
Sommige van deze portabiliteitsproblemen waren overgeërfd van Small-C. Omdat Small-C het struct-trefwoord niet had, werden alle benodigde structuren aangemaakt in een byte-pool (char), en werden woorden verdeeld in twee bytes (voor de 8080-processor). Toen ik dit uitbreidde naar een 32-bit platform, werden deze woorden 4 bytes. Erger nog: pointers worden geconverteerd naar en van het int-type, maar het is nu onmogelijk om een 64-bit pointer te converteren naar een 32-bit integer. Een ontwerpwijziging is noodzakelijk.
(Fragment uit Small-C code waar een waarde als twee bytes in een array wordt opgeslagen)
Eerste stappen
Het is niet de eerste keer dat een oud programma niet gecompileerd kan worden op een modern 64-bit systeem. In plaats van deze compiler echter als een curiositeit te laten voortbestaan die alleen in emulatie werkt, wilde ik hem geschikt maken voor een moderne laptop. Spoiler: dat was niet makkelijk.
Ik stelde mezelf een doel: ik wilde de compiler net genoeg aanpassen om hem op een modern 64-bit platform te kunnen compileren, maar hij moest nog steeds in staat zijn om zichzelf voor de transputer te compileren. Dit betekent dat ik alleen functies kon gebruiken die in mijn eigen compiler waren geïmplementeerd.
De compiler is verdeeld over verschillende bronbestanden, die allemaal worden aangestuurd door één driver-bestand genaamd cc.c. Mijn DJGPP-port creëerde twee verschillende bestanden: cc.c en cc2.c. De eerste was bedoeld om met DJGPP te compileren, en de tweede om direct met mijn C-compiler op de transputer te compileren.
Ik begon met het verplaatsen van de variabelen entrada, salida en entrada2 naar deze hoofdbestanden. Ik paste de versie voor de moderne machine aan om FILE te gebruiken en begon met het toevoegen van Kernighan & Ritchie (K&R) functieprototypes. K&R-prototypes zijn enkel bedoeld om het juiste retourtype aan te geven, bijvoorbeeld: unsigned char expresion();.
De technische uitdagingen
De #include-directieve bewaart de huidige status van de verwerking binnen het bronbestand op de volgende manier:
incl[nivel_incl++] = entrada;
incl[nivel_incl++] = funcion_actual;
incl[nivel_incl++] = comienzo_funcion;
incl[nivel_incl++] = linea_actual;
incl[nivel_incl++] = dentro_funcion;
Hierbij is incl een integer-array. De eerste regel is voor het huidige bestand, de volgende is een pointer naar de functiedefinitie, en de overige drie zijn integers. Hier ligt het grote probleem: een 64-bit machine heeft 64-bit pointers, terwijl int nog steeds 32-bit is. Bovendien is de broncode van de compiler nog steeds voorzien van Spaanse commentaren en variabelenamen.
Ik heb de code herschreven in termen van een struct:
struct {
FILE *entrada;
unsigned char *funcion_actual;
int comienzo_funcion;
int linea_actual;
int dentro_funcion;
} incl[MAX_INCL];
De nieuwe versie van de code is portabel en aanzienlijk schoner:
incl[nivel_incl].entrada = entrada;
incl[nivel_incl].funcion_actual = funcion_actual;
incl[nivel_incl].comienzo_funcion = comienzo_funcion;
incl[nivel_incl].linea_actual = linea_actual;
incl[nivel_incl].dentro_funcion = dentro_funcion;
nivel_incl++;
Terwijl het herontwerp vorderde, voegde ik meer functieprototypes toe. Net toen ik dacht dat het een makkie was, bleek het tegendeel waar te zijn.
Geen eenvoudige klus
De eerste waarschuwing kwam van de subroutine voor de verwerking van expressies:
/*
** Analyseert een expressie en genereert de code.
*/
unsigned char *expresion()
{
struct nodo *origen;
unsigned char *tipo;
origen = ultimo_nodo;
tipo = almacena_expresion(SI);
evalua_arbol(NO);
libera_arbol(ultimo_nodo);
ultimo_nodo = origen;
return tipo;
}
Deze functie compileert een C-expressie en retourneert een pointer naar het type. De functie almacena_expresion doet echter het volgende:
/*
** Analyseert een expressie en houdt deze in het geheugen.
*/
int almacena_expresion(operador_coma)
int operador_coma;
{
int info[1], izq;
if (operador_coma) {
if (nivel0(info))
carga_valor(info);
} else {
if (nivel1(info))
carga_valor(info);
}
return info[0];
}
De type-pointer wordt opgeslagen in een int-array. Ik ben begonnen met het volledig herschrijven van het bestand ccexpr.c om int info[1] te veranderen in unsigned char info. In expresion_constante moest ik int origen vervangen door struct nodo origen. Blijkbaar gaf mijn compiler geen waarschuwingen bij het toewijzen van een pointer aan een integer en vice versa.
Kort daarna ontdekte ik dat de typeverwerking ook pointerproblemen had. Neem bijvoorbeeld de subroutine voor het kopiëren van typen:
/*
** Kopieert een type naar de volgende beschikbare positie.
*/
copia_tipo(tipo)
unsigned char *tipo;
{
int a;
while (*tipo >= APUNTADOR) {
if (*tipo == MATRIZ) {
guarda_tipo(*tipo++);
guarda_tipo(*tipo++);
guarda_tipo(*tipo++);
guarda_tipo(*tipo++);
guarda_tipo(*tipo++);
} else
guarda_tipo(*tipo++);
}
if (*tipo == STRUCT) {
guarda_tipo(*tipo++);
guarda_tipo(*tipo++);
guarda_tipo(*tipo++);
guarda_tipo(*tipo++);
}
guarda_tipo(*tipo++);
}
Deze subroutine kopieert een beschrijving van een C-type, wat handig is bij het instellen van hetzelfde type voor meerdere variabelen (bijv. int a, b, c;). De functie guarda_tipo slaat simpelweg een byte op in de type-pool. Echter, het array-type (MATRIZ) bevat de arraylengte (32-bit, opgeslagen als vier bytes), en het struct-type (STRUCT) wijst naar de structuurdefinitie (een 32-bit pointer geconverteerd naar een integer en opgeslagen als vier bytes). Dit is opnieuw volledig niet-portabel naar een moderne 64-bit architectuur.
De makkelijkste oplossing was om de aanroepen uit te breiden naar acht guarda_tipo-aanroepen (64-bit verdeeld over acht bytes), maar ik vond een betere manier via een backport.
Daarnaast was er het probleem dat structuren werden toegewezen in dezelfde byte-pool, zoals hieronder te zien is in deze (bekeken met huidige ogen) gênante code:
/*
** Een nieuwe structuur.
*/
unsigned char *nueva_estructura(nombre)
unsigned char *nombre;
{
unsigned char *ap;
int conteo;
if(ultima_estruct != NULL)
escribe_entero(ultima_estruct + EST_SIG, sig_tipo);
ultima_estruct = sig_tipo;
if(lista_estruct == NULL)
lista_estruct = sig_tipo;
conteo = 0;
while(conteo++ < EST_NOMBRE)
guarda_tipo(0);
while(*nombre)
guarda_tipo(*nombre++);
guarda_tipo(0);
return ultima_estruct;
}
Vervolgens werd elk lid in de structuur ook in de type-pool gealloceerd. Ik traceerde dit terug naar drie groepen macro's die elk een pseudo-structuur definieerden:
/* Definities van structuur */
#define EST_QUE_ES 0 /* char, geeft aan of het een struct- of enum-label is */
#define EST_ES_UNION 1 /* char, geeft aan of het een unie of een structuur is */
#define EST_TAM 2 /* int, totale grootte van de structuur/unie */
#define EST_LISTA 6 /* char*, lijst van leden */
#define EST_SIG 10 /* char*, volgend label */
#define EST_NOMBRE 14 /* char[], label */
/* Definities van leden */
#define MIE_TIPO 0 /* char*, type van het lid */
#define MIE_POSICION 4 /* int, positie binnen de structuur */
#define MIE_SIG 8 /* char*, volgend lid */
#define MIE_NOMBRE 12 /* naam van het lid */
/* Definities van enumeratoren */
#define ENUM_VALOR 0 /* int, waarde van de enumerator */
#define ENUM_SIG 4 /* char*, volgende enumerator */
#define ENUM_NOMBRE 8 /* char[], naam van de enumerator */
Er zitten vijf pointers in verborgen. Ik had dit kunnen omzetten naar structuren, maar ik had een beter idee: mijn Am29000 C-compiler is een evolutie van deze compiler, en ik had daar deze lelijke definities al vervangen door elegante structs. Ik kon dus een backport doen: recentere code implementeren in de oude code.
De ge-backporte code ziet er veel beter uit en is portabel:
struct rotulo { /* DEFINITIE VAN STRUCTUUR */
struct rotulo *sig; /* Volgende label */
int tam; /* Totale grootte van de structuur */
struct miembro *lista; /* Lijst van leden */
char que_es; /* Geeft aan of het een struct- of enum-label is */
char es_union; /* Geeft aan of het een unie of een structuur is */
char nombre[1]; /* Naam */
};
struct miembro { /* DEFINITIE VAN STRUCTUUR-LID */
struct miembro *sig; /* Volgend lid */
int posicion; /* Positie binnen de structuur */
unsigned char *tipo; /* Gedeclareerd type */
char nombre[1]; /* Naam */
};
struct enumerador { /* DEFINITIE VAN ENUMERATOR */
struct enumerador *sig; /* Volgende enumerator */
int valor; /* Waarde van de enumerator */
char nombre[1]; /* Naam */
};
Tegelijkertijd vertaalde ik alle foutmeldingen van het Spaans naar het Engels, omdat mijn bronbestanden nog een Windows-codepage gebruikten en clang bleef klagen over illegale tekencoderingen.
Mijn compiler had oorspronkelijk geen standaard C-bibliotheek, dus functies als isxdigit, strlen, strcpy en strcat waren in elk programma gerepliceerd. Ik heb deze ondergebracht in mijn cc2.c driver-programma, terwijl cc.c de standaardbibliotheken strings.h en ctype.h gebruikt.
Ik loste ook een paar fouten op uit eerdere versies waar expressienodes waren veranderd van int naar struct nodo *, zoals:
int izq;
izq = ultimo_nodo;
Dit moest worden: struct nodo *izq. Daarnaast waren er gevallen waar arrays werden gebruikt in plaats van structs (weer een overblijfsel uit de tijd dat struct niet beschikbaar was).
Oude versie:
/*
** "break" statement
*/
void s_break()
{
/* Controleer of er een loop open is */
if (ultimo_bucle == NULL) {
error("No loops open");
return;
}
desp_pila(ultimo_bucle[B_PILA]); /* Ja, pas de stack aan */
salto(ultimo_bucle[B_FIN]); /* Spring naar het exit-label */
}
Nieuwe versie:
void s_break()
{
/* Controleer of er een loop open is */
if (ultimo_bucle == NULL) {
error("No loops open");
return;
}
desp_pila(ultimo_bucle->pila); /* Ja, pas de stack aan */
salto(ultimo_bucle->fin); /* Spring naar het exit-label */
}
Van 25 fouten naar succes
Na al deze wijzigingen, veel tijd en koffie, wist ik het aantal fouten met pointers en integers terug te brengen tot 25. Deze konden als volgt worden gecategoriseerd:
- Opslaan/lezen van een pointer voor struct-data (3 voorkomens)
- Lezen van het type van een variabele, argument of functie (5 voorkomens)
- Gebruik van een expressienode-pointer om een integer op te slaan (17 voorkomens)
Ik ben niet bijzonder trots op deze code, maar hier is een voorbeeld van wat ik deed:
if (nodo_temp->oper == N_RESULTA) { /* Functie die structuur retourneert */
pals += (req_res = nodo_temp->der);
nodo_b = -1;
req = NO;
} else {
nodob heeft het type struct nodo * maar krijgt de waarde -1 toegewezen zonder type-cast. Ik heb nodotemp toegewezen aan nodob, en in plaats van te controleren op -1, controleerde ik of nodeb->oper == N_RESULTA.
Ik had de hele dag kunnen nadenken over manieren om struct nodo niet uit te breiden, maar perfectie is de vijand van "het werkt". Daarom hebde ik simpelweg velden toegevoegd om de extra data te verwerken. Vroeger zou ik dit niet hebben gedaan omdat geheugenruimte cruciaal was (ik zou waarschijnlijk een union hebben gebruikt), maar een paar extra velden in expressiebomen verbruiken niet teveel geheugen. De toegevoegde velden aan struct nodo waren: struct nodo *tri en int extra_val.
Ik merkte kansen voor optimalisatie, maar ik heb deze genegeerd om te voorkomen dat er nieuwe bugs ontstonden. Nu was ik beneden de 8 fouten. Een paar hiervan ontstonden omdat er pointers naar struct-typen waren; deze heb ik vervangen door een struct-index (geteld vanaf het begin van een lineaire lijst).
Ten slotte bleven er 5 fouten over bij het benaderen van globale en lokale variabelen:
/* Definitie van de naam-formaten */
#define NOMBRE 0
#define IDENT 17
#define CLASE 18
#define NIVEL 19
#define TIPO 20
#define POSICION 24
Opnieuw een hard-coded structuur. TIPO wijst naar de typedefinitie, wat niet-portabel is voor 64-bit. Ik heb hiervoor een struct aangemaakt:
#define NUM_GLBS 608
#define NUM_LOCS 32
struct nombres {
unsigned char nombre[17];
unsigned char ident;
unsigned char clase;
unsigned char nivel;
unsigned char *tipo;
int posicion;
};
struct nombres globales[NUM_GLBS];
struct nombres locales[NUM_LOCS];
Het verwijderen van de oude definities zorgde voor een stortvloed aan fouten en waarschuwingen, maar ik heb deze één voor één vervangen door de correcte toegang tot de nieuwe structuren. En eindelijk: nul fouten! Alleen bleven er een paar meldingen over over het gebruik van gets (een onbeperkte inputfunctie die de worm van Richard Morris uit 1988 mogelijk maakte), maar daarmee kon ik leven.
Werkt het echt?
Ik begon de compiler tegen zichzelf te draaien, maar dit genereerde fouten omdat ik per ongeluk ANSI C in functiedefinities had geplaatst. Deze moest ik terugzetten naar K&R-syntaxis. Ook vond ik enkele kleine bugs in de variabele-afhandeling. Uiteindelijk lukte een volledige compilatie van de compiler zelf.
Iedereen met ervaring met compilers weet dat dit niet genoeg is. De output zou nog steeds buggy kunnen zijn. Ik moest het testen in mijn transputer-besturingssysteem. Na het draaien van tasm, mijn transputer-assembler, ontdekte ik dat NULL niet was gedefinieerd en dat de compiler fputs en stdout aanriep, die niet beschikbaar waren in mijn besturingssysteem.
void mensaje(cad)
unsigned char *cad;
{
fputs("\n", stdout);
fputs(cad, stdout);
}
Natuurlijk was dit een artefact van de DJGPP-port. Ik haalde de correcte code uit mijn werkende compiler:
void mensaje(cad)
unsigned char *cad;
{
puts("\n");
puts(cad);
}
Na het opnieuw uitvoeren van tasm cc2.a cc2.e stdio.len kreeg ik dit succesbericht: Transputer assembler v0.1. Feb/01/2025 by Oscar Toledo G. 0 error(s) detected. 28254 line(s) assembled.
Het genereerde een executable cc2 van 35.997 bytes. Ter referentie: mijn vorige compiler was 38.176 bytes. Hij is korter omdat het gebruik van struct omslachtige byte-access code voorkomt. Maar zou hij echt werken in mijn besturingssysteem?
Voor de test herinnerde ik me een patch voor floating-point tijdelijke waarden die ik 30 jaar na dato ontdekte in de raytracer van mijn besturingssysteem, en ik voegde deze toe. Ook deed ik twee kleine tests met HOLA.C en PRUEBA.C. De gegenereerde assembler-code was exact hetzelfde als die van de DJGPP-versie. Succes!
Testen in het besturingssysteem
Ik maakte een floppy-image met alle bestanden voor de compiler. Het doel was om de compiler zichzelf te laten compileren. Ik verwachtte dat het nog steeds in de 128 KB RAM zou passen.
Mijn opdrachtregel voor het bouwen van de diskette was: ./buildboot -fd -v2 .floppy.img . tree/SOM.32.bin tree/Halt.p CC.c CC2.c CCanasin.c CCexpr.c CCgencod.c CCinter.c CCvarios.c CCvars.c cc2.a cc2.e
Eenmaal in het besturingssysteem (runosv2.sh) voerde ik c:/ejecutable.p uit om het cc2.e bestand in te lezen, stelde een stack van 8192 bytes in en creëerde zo het cc2.p executable.
Toen ik het uitvoerde, verscheen er wartaal op het scherm omdat het programma ANSI-escape sequenties voor kleuren in commando's wilde gebruiken, wat mijn besturingssysteem niet ondersteunt. Ik voerde de eigen broncode in, maar het programma crashte bij het compileren van inicializa().
Het debuggen begint
Omdat dit emulatie is, keek ik direct naar de floppy-image. Ik zag dat de compiler op mijn transputer-emulator exact dezelfde code genereerde als het macOS-executable.
Ik heb een voorkeur voor programma's die met veel lawaai crashen of bestanden corrupt maken, want dat geeft aanwijzingen. Maar een bug waarbij het programma volledig vastloopt? Dat is lastiger.
Ik schakelde de debug-output in mijn transputer-emulator in en kreeg een bestand van 8,14 GB 🤡 dat ik niet kon openen in XCode of TextEdit. Met Hex Fiend zag ik echter direct een onjuiste waarde in een register. Nu moest ik miljoenen regels doorzoeken om te vinden waar het misging.
Een onjuiste waarde in een register is overduidelijk om twee redenen: ten eerste bevat mijn code geen grote constanten, en ten tweede zijn de adressen van het besturingssysteem duidelijk vastgesteld.
Ik kon het herleiden naar de functie etiqueta() (boom-labeling met registergebruik), waar het vreemde dingen begon te doen na het verwerken van een expressienode met operator 40 (N_ASIGNA) voor een variabele toewijzingsnode. Toen zag ik de Wptr waarde (de transputer stack); deze groeide door naar lagere adressen en overschreef de compiler-code, waardoor het systeem crashte.
Twee uur later ontdekte ik dat het probleem in de N_ASIGNA node zat, waarvan de linker node naar zichzelf wees. Nu moest ik vinden waar de compiler deze knoop in de boom had gemaakt. Na nog een half uur ontdekte ik dat mijn malloc-functie altijd hetzelfde adres retourneerde! Een onverwachte bug in mijn besturingssysteem: wanneer de geheugenruimte vol is, faalt de malloc-routine en retourneert hij geen NULL-pointer.
Ik paste mijn transputer-emulator aan om 128 KB extra RAM toe te voegen, en ik slaagde erin de C-compiler zonder fouten te compileren. Sterker nog, hij genereerde byte voor byte hetzelfde assembler-bestand als de macOS-versie.
Het kostte me drie dagen om mijn compiler van een niet-portabele status naar een bruikbare staat te brengen. Ik ben erg blij dat je nu op elke machine dezelfde transputer-executables kunt bouwen.
Repository
Voor een duidelijk overzicht van de geschiedenis raad ik aan om de commits in de git-repository te bekijken. De volgorde van de commits is:
- De C-compiler uit mijn transputer-besturingssysteem.
- De eerste back-up op de diskette (
cc0in mijn transputer repository). - De tweede back-up op de diskette (
cc1in mijn transputer repository). - De uiteindelijke geportte compiler die werkt op 64-bit machines.
Gerelateerde links
- De broncode voor deze verbeterde transputer C-compiler: https://github.com/nanochess/transputer-cc
- De broncode voor mijn transputer-besturingssysteem, command-line interpreter, tekstverwerker, C-compiler en assembler: https://github.com/nanochess/transputer
Groetjes,