Este artículo profundiza en una mejora crucial del proyecto de compilador de código abierto "A Compiler Writing Journey" (acwj) en su etapa 45: la capacidad de procesar declaraciones de variables globales que utilizan expresiones. Anteriormente, el compilador solo admitía literales enteros simples para el tamaño de arreglos o la inicialización de variables globales. Sin embargo, los compiladores de C reales a menudo manejan construcciones como extern char Text[TEXTLEN + 1], donde el tamaño o el valor inicial se determinan mediante una expresión.
Exploraremos la reimplementación de la función parse_literal(), las modificaciones complementarias en el análisis de declaraciones en decl.c, la extensión de los tokens de terminación para binexpr(), y cómo esta arquitectura, junto con opt.c y cg.c, permite que el compilador avance en su capacidad de auto-compilación. Al final de la lectura, comprenderá cómo reemplazar el análisis manual de literales por un analizador de expresiones, cómo realizar el plegado de constantes y la validación del ancho de tipos durante el análisis, y cómo los valores iniciales de las variables globales se integran en la generación de código ensamblador.
Motivación: La Necesidad de Reescritura en la Declaración de Variables Globales
En la etapa 43 del proyecto, se encontró una limitación al intentar compilar el siguiente fragmento de código:
enum { TEXTLEN = 512 }; // Longitud de identificadores en la entrada
extern char Text[TEXTLEN + 1];
El analizador de declaraciones existente solo podía procesar un *único literal entero* para especificar el tamaño de un arreglo. Sin embargo, TEXTLEN + 1 es una *expresión* compuesta por dos literales enteros. Para soportar esta sintaxis, la etapa previa (44\_Fold\_Optimisation) introdujo la optimización de **plegado de constantes** en el compilador. Esta optimización permite que expresiones puramente literales como 10 + 6 se colapsen en un único nodo hoja A_INTLIT en el Árbol de Sintaxis Abstracta (AST) durante la compilación; por ejemplo, x = 5 + 4 * 5 se vuelve equivalente a x = 25 a nivel del AST.
Con el plegado de constantes implementado, la capacidad de usar expresiones para valores iniciales o tamaños de arreglos se volvió factible. El objetivo de esta etapa fue **eliminar el código manual de análisis de literales y conversión de tipos** en la declaración de variables globales. En su lugar, se adoptó un enfoque unificado: invocar directamente el analizador de expresiones binexpr() y luego usar optimise() para plegar el resultado en un único literal. Esto no solo elimina la duplicación de código, sino que también estandariza las rutas de análisis para declaraciones globales y expresiones ordinarias.
Reimplementación de parse\_literal(): Mismo Nombre, Nueva Lógica
El primer paso de la refactorización fue decidir el destino de parse_literal(). Se optó por **mantener la función**, pero vaciar su implementación original y redefinir sus responsabilidades. La nueva versión no solo analiza literales individuales, sino que también maneja expresiones literales, incluyendo aquellas precedidas por una conversión de tipo explícita (cast). La firma de la función (ubicada en decl.c) es la siguiente:
// Dada una tipo, analiza una expresión de literales y asegura
// que el tipo de esta expresión coincida con el tipo dado.
// Analiza cualquier conversión de tipo (cast) que preceda a la expresión.
// Si es un literal entero, retorna su valor.
// Si es un literal de cadena, retorna el número de etiqueta de la cadena.
int parse_literal(int tipo_esperado);
Desde su firma, parse\_literal() actúa como un reemplazo "plug-and-play" de su versión anterior: el **parámetro de entrada es el tipo esperado, y el valor de retorno es el valor de un literal entero o el número de etiqueta de un literal de cadena**. Todo el código manual de análisis de casts anterior pudo ser eliminado.
Paso 1: Análisis de la Expresión y Plegado de Constantes
La primera acción dentro de la nueva función (en decl.c) es:
int parse_literal(int tipo_esperado) {
struct ASTnode *arbol_ast; // Renombre 'tree' a 'arbol_ast'
// Analiza la expresión y optimiza el árbol AST resultante
arbol_ast = optimise(binexpr(0));
Se invoca a binexpr(0) para analizar la expresión en la posición actual del archivo de entrada, y luego se llama a optimise() para plegar todas las subexpresiones literales. La función optimise(), definida en opt.c, se basa en la recursiva fold(): primero pliega el subárbol izquierdo, luego el derecho. Si un nodo tiene dos hijos que son A_INTLIT, se usa fold2(). Si solo tiene un hijo literal, se usa fold1() (para operaciones como A_WIDEN, A_INVERT, A_LOGNOT). Actualmente, fold2() soporta A_ADD, A_SUBTRACT, A_MULTIPLY, A_DIVIDE. En el caso de una división por cero, la implementación de opt.c **mantiene el árbol original sin plegar**, delegando el error de ejecución.
Después del plegado, el nodo raíz del árbol debe ser uno de tres tipos: A_INTLIT (literal entero), A_STRLIT (literal de cadena) o A_CAST (expresión con conversión de tipo).
Paso 2: Eliminación de Nodos A\_CAST
Si el nodo raíz es A_CAST (en decl.c):
// Si existe un cast, obtiene el hijo y
// le asigna el tipo de la conversión (cast)
if (arbol_ast->op == A_CAST) {
arbol_ast->left->type = arbol_ast->type;
arbol_ast = arbol_ast->left;
}
Aquí, el nodo A_CAST se descarta, perro la información del tipo de destino (arbol_ast->type) se transfiere al nodo hijo. Esto **preserva la información de tipo del cast, simplificando la estructura del AST**.
Paso 3: Verificación de que la Raíz sea un Literal
(en decl.c):
// El árbol ahora debe ser un literal entero o de cadena
if (arbol_ast->op != A_INTLIT && arbol_ast->op != A_STRLIT)
fatal("No se pueden inicializar globales con una expresión general");
Si, después del plegado, el nodo raíz no es un literal entero ni de cadena (por ejemplo, si la expresión es x = y + 1, que referencia una variable), se asume que el usuario intentó inicializar una variable global con una **expresión general**. En este caso, el compilador emite un error fatal Cannot initialise globals with a general expression y detiene la compilación.
Paso 4: Manejo Especial para Tipos char \*
(en decl.c):
// Si el tipo es char * y
if (tipo_esperado == pointer_to(P_CHAR)) {
// Si tenemos un literal de cadena, retornamos el número de etiqueta
if (arbol_ast->op == A_STRLIT)
return arbol_ast->a_intvalue;
// Si tenemos un literal entero con valor cero, es NULL
if (arbol_ast->op == A_INTLIT && arbol_ast->a_intvalue == 0)
return 0;
}
Esta rama gestiona dos tipos de entradas válidas para un puntero a carácter:
char *puntero_cadena = "Hola mundo";
char *puntero_nulo = (char *)0;
- Para un literal de cadena, la función retorna el **número de etiqueta** asignado a la cadena en la sección de datos, que se utilizará en la generación de ensamblador.
- Para un literal entero con valor 0 (independientemente de si ha pasado por un cast como
(char *)0), se consideraNULLy se retorna 0.
Los casos de prueba tests/input106.c (char *y = (char *)0;) y tests/input107.c (char *y[] = { "pez", "vaca", NULL }; char *z = NULL;) demuestran estas capacidades. Si una variable char * se inicializa con algo que no es una cadena ni un cero, la verificación de tipo final generará un error.
Paso 5: Verificación del Ancho de Tipo para Enteros
Para tipos no puntero, se sigue la siguiente lógica (en decl.c):
// Solo llegamos aquí con un literal entero. El tipo de entrada
// es un tipo entero y lo suficientemente ancho para contener el valor literal.
if (inttype(tipo_esperado) && typesize(tipo_esperado, NULL) >= typesize(arbol_ast->type, NULL))
return arbol_ast->a_intvalue;
fatal("Desajuste de tipo: literal vs. variable");
return 0; // Para satisfacer -Wall
Este paso aborda tres escenarios clave, ejemplificados de la siguiente manera:
long mi_variable_larga = 3; // Permitido, donde 3 es de tipo char
char mi_variable_char = 4000; // No permitido, 4000 es demasiado ancho
char *mi_puntero = 4000; // No permitido, mi_puntero no es de tipo entero
long mi_variable_larga = 3;: El literal3se marca comoP_CHARdurante el escaneo (verexpr.c, los literales enteros en el rango0..255tienen tipoP_CHAR). Sin embargo,longes lo suficientemente amplio, por lo que **se permite**.char mi_variable_char = 4000;: El tipo destinochar(1 byte) no puede contener el valor4000. **Se rechaza**.char *mi_puntero = 4000;:char *no es un tipo entero (inttype()retorna falso). **Se rechaza**.
La condición inttype(tipo_esperado) asegura que la variable destino sea un tipo entero, y typesize(tipo_esperado, NULL) >= typesize(arbol_ast->type, NULL) garantiza que el tipo destino tenga un ancho igual o mayor que el tipo del literal. Las pruebas de fallo correspondientes se encuentran en tests/err.input092.c y tests/err.input094.c, esperando el mensaje Type mismatch: literal vs. variable on line 1 of input0xx.c.
Adaptaciones en decl.c: Declaraciones Escalares y de Arreglos
Con la unificación de parse_literal(), el código anterior de análisis de casts en decl.c fue reemplazado por completo. Se realizaron tres modificaciones principales (todas implementadas en decl.c):
Valores Iniciales para Variables Globales Escalares
En scalar_declaration(), cuando una variable global encuentra T_ASSIGN (en decl.c):
// Las variables globales deben asignarse un valor literal
if (clase == C_GLOBAL) { // Renombre 'class' a 'clase'
// Crea un valor inicial para la variable y
// analiza este valor
simbolo->initlist = (int *)malloc(sizeof(int)); // Renombre 'sym' a 'simbolo'
simbolo->initlist[0] = parse_literal(tipo_esperado); // Use 'tipo_esperado'
}
Se asigna una lista de inicialización de tipo int al símbolo en la tabla de símbolos, y el valor retornado por parse_literal(tipo_esperado) se almacena en initlist[0]. Es importante destacar que la inicialización de variables locales sigue una ruta diferente (construyendo un árbol AST A_ASSIGN para evaluación en tiempo de ejecución), mientras que los valores iniciales de las variables globales deben determinarse en tiempo de compilación.
Determinación del Tamaño de Arreglos
En array_declaration(), si después de un [ no sigue inmediatamente un ], se utiliza un analizador de expresiones para determinar el tamaño del arreglo (en decl.c):
// Si tenemos un tamaño de arreglo
if (Token.token != T_RBRACKET) {
num_elementos = parse_literal(P_INT); // Renombre 'nelems' a 'num_elementos'
if (num_elementos <= 0)
fatald("Tamaño de arreglo ilegal", num_elementos);
}
Aquí se pasa P_INT como tipo esperado, de modo que una expresión como int arreglo[2 + 3] plegará 2 + 3 a 5, que luego se usará como el número de elementos. Los tamaños ilegales (<= 0) son rechazados con fatald("Tamaño de arreglo ilegal", ...). Si el tamaño del arreglo no se especifica después del plegado (num_elementos == -1), la lista de valores iniciales se expande dinámicamente usando realloc en incrementos de TABLE_INCREMENT (10 elementos) (en decl.c).
Listas de Inicialización de Arreglos
Cada elemento en una lista de inicialización de arreglos, como int mi_arreglo[] = { 1, 2, 6 };, también se procesa mediante parse_literal(tipo_esperado) (en decl.c):
// Bucle para obtener un nuevo valor literal de la lista
while (1) {
// ...
lista_inicializacion[indice++] = parse_literal(tipo_esperado); // Renombre 'initlist' a 'lista_inicializacion' y 'i' a 'indice'
// ...
}
El bucle continúa hasta encontrar el corchete de cierre }, con los elementos separados por comas. Si se especifica explícitamente num_elementos y la cantidad de valores iniciales excede este límite, se informa un error Too many values in initialisation list. La lista de valores iniciales se adjunta finalmente al campo initlist de la entrada de la tabla de símbolos (definido en defs.h), y las partes no inicializadas se rellenan con ceros.
Beneficio Neto del Código
Como señaló el autor, esta sustitución eliminó aproximadamente 20-30 líneas de código dedicadas al aálisis de casts. Sin embargo, esto tuvo como contrapartida la adición de unas 100 líneas de código de plegado de constantes en la etapa anterior. No obstante, el plegado de constantes también beneficia a las **expresiones generales** (como las expresiones aritméticas dentro de los cuerpos de las funciones), lo que resulta en una ganancia neta global.
Modificaciones en expr.c: Adición de T\_RBRACE como Terminador para binexpr()
Las expresiones analizadas por parse_literal() pueden aparecer dentro de listas de inicialización de arreglos, por ejemplo:
int mi_arreglo[] = { 1, 2, 6 };
Después de que parse_literal() analiza el valor 6, el siguiente token en el flujo de entrada es } (T_RBRACE). Anteriormente, binexpr() solo retornaba el subárbol izquierdo al encontrar ;, ), ], , o :. Al encontrar }, lo trataría como un operador normal, lo que causaría un error. Por lo tanto, se añadió T_RBRACE al conjunto de tokens de terminación (en expr.c, modificado en dos puntos):
// Si encontramos un token de terminación, retornamos solo el nodo izquierdo
tipo_token = Token.token; // Renombre 'tokentype' a 'tipo_token'
if (tipo_token == T_SEMI || tipo_token == T_RPAREN ||
tipo_token == T_RBRACKET || tipo_token == T_COMMA ||
tipo_token == T_COLON || tipo_token == T_RBRACE) { // T_RBRACE es nuevo
nodo_izquierdo->rvalue = 1; // Renombre 'left' a 'nodo_izquierdo'
return (nodo_izquierdo);
}
Ambas instancias de la comprobación de terminación en binexpr() (la primera en expr.c y la segunda dentro del bucle while en expr.c) fueron actualizadas para incluir T_RBRACE, asegurando que las expresiones se finalicen correctamente al encontrar } en cualquier posición, marcando el resultado como rvalue.
Del AST al Ensamblador: El Flujo de initlist
El valor inicial calculado por parse_literal() debe finalmente ir a la sección de datos. En el backend x86-64, la función cgglobsym() en cg.c lo gestiona:
// Genera espacio para uno o más elementos
for (int i = 0; i < nodo_simbolo->nelems; i++) { // Renombre 'node' a 'nodo_simbolo'
// Obtiene cualquier valor inicial
int valor_inicial = 0; // Renombre 'initvalue' a 'valor_inicial'
if (nodo_simbolo->initlist != NULL)
valor_inicial = nodo_simbolo->initlist[i];
// Genera el espacio para este tipo
switch (tamano) { // Renombre 'size' a 'tamano'
case 1:
fprintf(Outfile, "\t.byte\t%d\n", valor_inicial);
break;
case 4:
fprintf(Outfile, "\t.long\t%d\n", valor_inicial);
break;
case 8:
// Genera el puntero a un literal de cadena. Trata un valor cero
// como cero, no como la etiqueta L0
if (nodo_simbolo->initlist != NULL && tipo_simbolo == pointer_to(P_CHAR) && valor_inicial != 0) // Renombre 'type' a 'tipo_simbolo'
fprintf(Outfile, "\t.quad\tL%d\n", valor_inicial);
else
fprintf(Outfile, "\t.quad\t%d\n", valor_inicial);
break;
// ...
}
}
Observamos lo siguiente:
- Las variables globales sin valor inicial (
initlist == NULL) se rellenan con 0 por defecto. - Tipos de 1 byte generan
.byte, tipos de 4 bytes generan.long, y tipos de 8 bytes (long/punteros) generan.quad. - **Tratamiento especial para punteros a cadenas**: Si el tipo es
char *y el valor inicial no es 0, este valor se interpreta como el número de etiqueta de la cadena, generando una referencia con el formatoL%d. Esto valida la semántica de retorno deparse_literal()paraA_STRLIT. Un valor inicial de 0 genera un 0 real (NULL), no la etiquetaL0.
El backend ARM (cgn.c) implementa una lógica simétrica, lo que confirma que la semántica de retorno de parse_literal() (valor entero / número de etiqueta de cadena / 0) está directamente acoplada con el proceso de generación de código.
Verificación mediante Pruebas: Nuevas Capacidades en Acción
Esta sección añadió el nuevo caso de prueba tests/input112.c, que abarca dos escenarios novedosos:
#include <stdio.h>
char *mensaje = NULL; // Puntero global inicializado a NULL
int suma_global = 10 + 6; // Variable global escalar inicializada con expresión
int numeros_varios[2 + 3]; // Arreglo con tamaño definido por expresión
int main() {
numeros_varios[2] = suma_global;
printf("El valor de numeros_varios[2] es: %d\n", numeros_varios[2]);
return 0;
}
int suma_global = 10 + 6;: Una variable global escalar inicializada con una **expresión**, que se pliega a16.int numeros_varios[2 + 3];: Un arreglo cuyo tamaño se especifica mediante una **expresión**, plegada a5elementos.char *mensaje = NULL;: Un puntero global inicializado aNULL.
La salida esperada de la ejecución es El valor de numeros_varios[2] es: 16 (o similar, dependiendo del printf). La compilación y prueba se realizan mediante el Makefile del directorio:
make # Compila cwj (backend x86-64)
make test # Instala en /tmp y ejecuta el conjunto completo de regresión tests/runtests
El comando make test depende del script tests/runtests, que ejecuta todas las pruebas positivas (input001.c a input112.c) y negativas (err.input0xx.c, verificando los mensajes de error). Otros casos de prueba positivos incluyen: char *y = (char *)0 (tests/input106.c), la inicialización de arreglos de cadenas { "pez", "vaca", NULL } (tests/input107.c), y la definición de la tabla de tokens del propio compilador char *Tstring[] = { "EOF", "=", ... } como un arreglo de cadenas global (tests/input099.c). Este último es un ejemplo práctico del camino hacia la auto-compilación del compilador.