Autómatas
Autómatas
Autómatas
StringTemplates
Volum: 1/1
Alumne: Alicia González López
QUALIFICACIÓ
Qualificació numèrica:
Qualificació descriptiva:
Data:
Agradecimientos
Este proyecto ha sido desarrollado en la Facultad de Informática de Barcelona bajo la
supervisión de José Miguel Rivero profesor del departamento de Lenguajes y Sistemas
Informáticos.
Me gustaría dar las gracias a mis padres, mi abuelo, a Albert y mis amigos, por la
paciencia que han tenido durante la realización de este proyecto.
Finalmente, a todas aquellas personas que preguntaron sobre qué trataba este proyecto y
tuvieron que aguantar una explicación que les dejó en la misma situación en la que
estaban.
ÍNDICE
1. Introducción ......................................................................................................... - 15 -
1.1. Objetivos del proyecto .................................................................................... - 15 -
1.2. Trabajos Previos ............................................................................................. - 16 -
1.3. Qué es un Compilador .................................................................................... - 17 -
1.3.1. Definición ................................................................................................ - 17 -
1.3.2. Etapas del Compilador ............................................................................. - 18 -
2. Conceptos Teóricos: Etapa de Análisis ............................................................... - 27 -
2.1. Etapa de Análisis ............................................................................................ - 27 -
2.2. Análisis Léxico ............................................................................................... - 28 -
2.2.1. Reconocimiento Léxico ........................................................................... - 29 -
2.2.2. Mecanismos para el reconocimiento ....................................................... - 30 -
2.3. Análisis Sintáctico .......................................................................................... - 35 -
2.3.1. Reconocimiento del Lenguaje ................................................................. - 35 -
2.3.2. Mecanismos para el reconocimiento ....................................................... - 36 -
2.4. Análisis Semántico ......................................................................................... - 46 -
2.4.1. Funcionamiento y Objetivos .................................................................... - 46 -
2.4.2. Sistema de Tipos ...................................................................................... - 47 -
2.4.3. Tabla de Símbolos ................................................................................... - 49 -
2.4.4. Decoración de ASTs ................................................................................ - 49 -
3. Conceptos Teóricos: Etapas de Síntesis .............................................................. - 52 -
3.1. Generación de Código Intermedio .................................................................. - 53 -
3.1.1. Entorno en tiempo de ejecución .............................................................. - 54 -
3.2. Optimización de Código Intermedio .............................................................. - 66 -
3.2.1. Optimización del Código Dividido en Bloques Básicos ......................... - 69 -
3.3. Generación de Código Objeto ........................................................................ - 72 -
3.4. Optimización de Código Objeto ..................................................................... - 73 -
4. Herramientas de Desarrollo de un Compilador ................................................... - 74 -
4.1. ANTLR Parsing .............................................................................................. - 75 -
4.1.1. Estructura de ANTLR .............................................................................. - 75 -
4.1.2. Gramáticas en ANTLR ............................................................................ - 76 -
4.1.3. Principales características ........................................................................ - 78 -
4.1.4. AST Construction .................................................................................... - 92 -
Figura 4.25 - Gramática de ANTLR para reconocer cadenas de hasta cien B’s ........ - 89 -
Figura 4.26 - Regla instr en CL .................................................................................. - 90 -
Figura 4.27 - Fuente de ambigüedad para la gramática de la Figura 4.26 ................. - 91 -
Figura 4.28 - Regla instr con la ambigüedad resuelta ................................................ - 91 -
Figura 4.29 - Predicado sintáctico de la regla de la Figura 4.28 ................................ - 91 -
Figura 4.30 - Ejemplo de aplicación de los operadores de modificación en ANTLR - 93 -
Figura 4.31 - Reescritura de reglas en ANTLR .......................................................... - 94 -
Figura 4.32 - Ejemplo I de reescritura de reglas aplicado a CL ................................. - 94 -
Figura 4.33 - Ejemplo II de reescritura de regla en CL .............................................. - 94 -
Figura 4.34 - Ejemplo de reordenación de elementos en una regla ............................ - 95 -
Figura 4.35 - Ejemplo de raíz de un árbol en CL ....................................................... - 95 -
Figura 4.36 - Ejemplo de creación de nodos imaginarios en CL ............................... - 96 -
Figura 4.37 - Ejemplo de duplicación de árbol en CL ............................................... - 96 -
Figura 4.38 - Ejemplo de recolección de elementos en CL ........................................ - 96 -
Figura 4.39 - Ejemplo expresión a serializar .............................................................. - 98 -
Figura 4.40 - Serialización de la expresión de la Figura 4.40.................................... - 98 -
Figura 4.41 - Ejemplo de gramática para convertir en Tree Grammar ...................... - 98 -
Figura 4.42 - Tree Grammar de la Figura 4.41 .......................................................... - 98 -
Figura 4.43 - Ejemplo de Tree Rule para CL ............................................................. - 99 -
Figura 4.44 - Ejemplo de predicado sintáctico en la Tree Grammar de CL ............ - 100 -
Figura 4.45 - Esquema de un patrón de traducción .................................................. - 101 -
Figura 4.46 - Ejemplo de patrón de la instrucción writeln de CL ............................ - 101 -
Figura 4.47 - Ejemplo de patrón de la llamada a un procedimiento de CL .............. - 102 -
Figura 4.48 - Llamada al patrón de la Figura 4.46 desde el código en CL .............. - 102 -
Figura 4.49 - Llamada al patrón de la Figura 4.47 desde el código en CL .............. - 103 -
Figura 5.1 - Proceso de traducción del código fuente a código objeto ..................... - 104 -
Figura 5.2 - Estructura de un programa en CL ......................................................... - 105 -
Figura 5.3 - Programa en CL: ejemplo sencillo ....................................................... - 106 -
Figura 5.4 - Programa en CL: ejemplo complicado ................................................. - 108 -
Figura 5.5 - Programa en CL: Ejemplo de reglas de visibilidad .............................. - 112 -
Figura 5.6 - Ejemplo de programa CL con procedimientos y funciones anidados .. - 113 -
Figura 5.7 - Estructura de bloques del programa de la Figura 5.6 ........................... - 114 -
Figura 5.8 - Estructura de un programa en Z-Code .................................................. - 115 -
Figura 5.9 - Ejemplo de programa CL...................................................................... - 119 -
Figura 5.10 - Traducción en Z-Code del programa de la Figura 5.9 ....................... - 119 -
Figura 6.1 - Estructura de ficheros del compilador .................................................. - 121 -
Figura 6.2 - Programa en CL con definición de tipo estructurado ........................... - 124 -
Figura 6.3 - Programa en CL con definición de tipos .............................................. - 124 -
Figura 6.4 - Ejemplo de definición circular de tipos ................................................ - 125 -
Figura 6.5 - Programa en CL con punteros .............................................................. - 125 -
Figura 6.6 - Programa en CL con punteros .............................................................. - 126 -
Figura 6.7 - Ejemplo I de programa CL para la generación de Z-Code ................... - 128 -
1. Introducción
Todo programa desarrollado puede ser directamente ejecutado por un intérprete
o bien, compilado para ser posteriormente ejecutado. En ambos casos existen unas fases
de análisis que detectan la corrección del programa. Un compilador, que no es más que
otro programa, después de verificar la corrección, continúa y finaliza la traducción a
código objeto.
Donde las etapas se ejecutan de forma secuencial y cada una tiene como entrada la
salida de la etapa que le precede, que no son más que diversas representaciones internas
del código fuente inicial.
Otro objetivo aplicado al desarrollo de todas las etapas del compilador ha sido la
utilización de la herramienta ANTLR v3 y la aplicación de los nuevos mecanismos que
incorpora para automatizar el desarrollo de muchas fases del compilador.
Así pues, el resultado final del proyecto es un compilador para el lenguaje CL, que ha
sido implementado con los últimos avances que proporcionan las herramientas actuales
de desarrollo, y que además, cumple con los objetivos que se han fijado anteriormente.
A pesar de que existen compiladores para CL, ninguno cumplía con las características
deseadas, es por ello que se procedió a la realización de este proyecto.
La implementación del compilador no parte desde cero puesto que ya se había trabajado
antes en él y, por lo tanto, había partes implementadas. Es por ello, que el proyecto se
ha enfocado principalmente en dos de las tres etapas en las que se divide el compilador.
Es importante destacar que, aunque se parte de un trabajo previo, parte de ese trabajo se
ha visto modificado, debido a ampliaciones realizadas y al uso de las nuevas
herramientas de desarrollo. Concretamente, la etapa de Análisis o Front-end es la que
estaba más desarrollada.
1.3.1. Definición
Un compilador es un programa que traduce un código fuente en un lenguaje de
alto nivel a otro lenguaje de programación de más bajo nivel, el código objeto, que
puede ser directamente ejecutable. Para realizar la traducción, primero se debe
comprobar la corrección del código fuente informando de posibles errores al usuario. El
proceso de compilación se divide en diferentes etapas llevándose a cabo en cada una de
ellas una tarea diferente.
Como hemos visto en la Figura 1.1 - Etapas de un compilador, las principales etapas
son tres:
Front-end.
Middle-end.
Back-end.
Hay que destacar el hecho que no hay un acuerdo total en la forma de dividir el trabajo
el compilador. En la principal bibliografía consultada [1], tan sólo se reconocen las
etapas Front-end y Back-end. Pero, cada vez más se reconoce una nueva etapa
intermedia, debido a la importancia que ésta está tomando hoy día el uso de una
representación intermedia.
También podemos encontrar diferencias relacionadas con las tareas que realiza cada una
de las etapas debido a que no siempre la estructura formal de las etapas del compilador
se corresponde con la estructura real de muchas implementaciones.
Las tareas con las que cuenta el compilador están divididas en tres grandes etapas: las
de análisis o también llamadas de Front-end , las que tratan con la representación
intermedia (RI) del lenguaje a compilar, agrupadas en el Middle-end y finalmente, las
de síntesis del código objeto, también conocidas como Back-end.
Antes de empezar con la definición de cada una de las etapas, se plantean dos ejemplos,
uno para el caso del lenguaje natural y otro para nuestro ejemplo de programación en
lenguaje CL.
program
vars
a int
La casa de mi abuela es muy endvars
grande a := 3
write(a + 1)
endprogram
o Para el ejemplo del lenguaje natural, los tokens reconocidos ahora deben
estar agrupados dentro de una oración que cumpla las siguientes reglas:
Análisis Semántico. Llegados a este punto nos interesa comprobar que además
que lo que teníamos inicialmente en la entrada esté bien escrito y estructurado,
tenga también un sentido.
program:
; parameters
; static_link
; endparameters
;
; variables
; a 4
; endvariables
a := 3
t0 := a + 1
wrii t0
stop
movl $3,-4(%ebp)
movl $3, %eax
addl $1, %eax
pushl %eax
En el ámbito de los compiladores ocurre lo mismo que lo que hemos comentado con el
lenguaje natural, pero refiriéndonos a ello con diferentes términos y por supuesto,
siguiendo un orden más lineal. El reconocimiento a nivel léxico se realiza en lo que
conocemos como Scanner o Lexer, y el reconocimiento de la gramática del lenguaje se
realiza en el Parser. El Scanner obtiene componentes léxicos (tokens) a partir de los
caracteres de entrada y pasa esa secuencia de tokens como entrada al Parser. La
diferencia entre ambos radica en que el Lexer reconoce palabras a partir de una cadena
de caracteres, mientras que el Parser reconoce estructuras gramaticales a partir de una
cadena de palabras (tokens).
Cabe mencionar el hecho que a pesar de que a nivel conceptual entendamos esta tarea
como dos reconocedores independientes, habitualmente a nivel de implementación,
Scanner y Parser trabajan de manera conjunta, siendo el Lexer quien, bajo demanda, lee
un nuevo token y lo pasa al Parser.
La decisión de dividir esta primera tarea de análisis en dos fases, Lexer y Parser se
fundamenta en [2]:
Una vez completadas las tareas del Lexer y Parser, se pasaría a realizar la
comprobación de la corrección semántica del código. Este proceso recibe el nombre de
Análisis Semántico o Type Checking.
Se puede decir que uno de los objetivos del Análisis Léxico es simplificar el trabajo del
Parser.
El Lexer recibe como entrada una cadena de símbolos que es todo el programa.
Volviendo al símil con el lenguaje natural, es como si recibiera una secuencia de
caracteres muy larga, que se compone de otras palabras, espacios, signos de
puntuación... Por lo tanto, su función es intentar encontrar dentro de esa secuencia de
caracteres, palabras más pequeñas pertenecientes a nuestro lenguaje, que permitan partir
la entrada en algo más sencillo de interpretar y corregir.
program
vars
a int
b int
c bool
endvars
a := 1
b := 2
a := a + b
if a = 3 then
write(“Correcto”)
else
write(“Incorrecto”)
endif
endprogram
Por lo tanto, en el ejemplo de la Figura 2.2, entenderíamos ese texto de entrada al que
nos referimos como la siguiente secuencia de caracteres:
program vars a int b int c bool endvars a := 1...
Así pues, lo que haría el Lexer sería empezar a leer la palabra “program”, más en
concreto, el carácter “p” y seguiría con las siguientes letras. Al llegar a la “m” de
“program”, como se daría cuenta que existe una palabra clave de nuestro lenguaje que
es program, guardaría esa información como relevante. Después, empezaría con el
reconocimiento de la palabra “vars”, símbolo a símbolo, de igual manera que para
“program”. Así sucesivamente analizaría toda la entrada hasta finalizar con la palabra
clave endprogram.
Acto seguido, reconocería que existe un salto de línea, pero esta información será
omitida para la siguiente fase. Algunos componentes léxicos como separadores,
espacios, comentarios....también serán omitidos.
Por lo tanto, llegados a este punto, el Lexer habría seleccionado como los siguientes
tokens:
PROGRAM, VARS, IDENTIFICADOR (a), INT, IDENTIFICADOR (b), INT,
IDENTIFICADOR(c), BOOL, ENDVARS, IDENTIFICADOR (a), :=, ENTERO(1),
IDENTIFICADOR(b), :=, ENTERO(2), IDENTIFICADOR(a), IDENTIFICADOR(a),
+, IDENTIFICADOR(b), IF, IDENTIFICADOR(a), =, ENTERO(3), THEN, WRIS,
STRING(Correcto), ELSE, WRIS, STRING(Inconrrecto), ENDIF, ENDPROGRAM
Como podemos observar, cuando hablamos de las variables, nos referimos a ellas como
identificadores y es que, para el compilador, a estas alturas del análisis, simplemente es
un componente léxico del lenguaje que reconoce como identificador sin importarle qué
nombre recibe. Lo mismo pasa con los strings y con los números, simplemente son
componentes léxicos que pertenecen al lenguaje correspondiente independientemente
del texto asociado. Cabe destacar el hecho que, aunque a nivel de la etapa de análisis es
indiferente el valor de los componentes léxicos, es necesario guardar esta información
puesto que más adelante se hará uso de ella.
A efectos prácticos, al final de esta etapa, lo que tenemos como resultado es que hemos
partido el código fuente y de ahí, hemos extraído un conjunto de componentes léxicos.
El lenguaje que define cada componente léxico será un lenguaje regular, por lo
tanto vamos a utilizar máquinas de estados (autómatas finitos) y algoritmos de
reconocimiento de expresiones regulares para realizar el Análisis Léxico.
Supongamos la palabra del lenguaje: bool. La figura inferior muestra una máquina de
estados para el reconocimiento de esta palabra.
El NDFA del ejemplo reconoce las palabras del tipo: a b del alfabeto ∑ =
{a,b}. En este caso sí que podemos observar que en el estado E2 existen dos
transiciones para el mismo símbolo de entrada a. Esta es una de las diferencias
que hemos comentado que existe con los DFA.
2.2.2.2. Token
Los componentes léxicos que reconocen los DFA o NDFA y por tanto, palabras
que son importantes para nuestro análisis, reciben el nombre de token.
Identificadores: x, y, oldValue,...
Palabra formada por una secuencia fija de caracteres. Por ejemplo, las palabras
clave del lenguaje: program, vars, while, endwhile,...
Una expresión regular nos permite definir el conjunto de palabras que forman
parte de un lenguaje regular. Por ejemplo, podemos usar expresiones regulares para la
definición de los identificadores. Generalmente el programador puede proporcionar
cualquier nombre para identificar una variable, función, procedimiento,....siempre que
éste comience con un carácter alfabético que puede ir seguido de cualquier número de
caracteres alfanuméricos. Por ello, definimos los identificadores mediante una expresión
regular como la siguiente:
IDENTIFICADOR : [A-Za-z][a-zA-Z0-9]*
Otro de los tokens que deberíamos definir como una expresión regular son los enteros,
los cuáles podríamos definir como:
ENTERO : [1-9][0-9]* | 0
Con la definición de la figura Figura 2.7, se indica que un entero será un cero o bien un
número indeterminado de cifras donde la primera cifra no es un cero (puesto que no se
permite que un número entero empiece por cero) seguido de cualquier otro número de
cifras.
Cada uno de los componentes léxicos será definido mediante una expresión regular,
aunque en ocasiones el lenguaje regular correspondiente a algunos de ellos incluye una
sola palabra como por ejemplo, las palabras clave del lenguaje.
Una buena forma de implementar un analizador léxico sería construir el diagrama que
representa la estructura de los tokens del lenguaje que estamos tratando y luego traducir
esto en un algoritmo para reconocer tokens, aunque como se puede imaginar y,
adelantándonos a lo que veremos en próximos capítulos, no es la forma más sencilla de
hacerlo, puesto que existen hoy en día herramientas automáticas que generan estos
algoritmos.
Así pues, una vez reconocido el prefijo más largo, en caso de empate, el algoritmo del
analizador léxico tomará como token reconocido el que se ha definido en primer lugar
en la lista de tokens.
El objetivo de esta etapa es comprobar que dada una secuencia de tokens pasada desde
el Lexer, éstos forman un programa correcto escrito en un determinado lenguaje,
notificando los posibles errores al usuario. Es decir, actúa como un filtro previo en el
análisis de la corrección del programa facilitando el trabajo de la próxima etapa
(Análisis Semántico).
En esta etapa también nos encargaremos de eliminar información no relevante para las
posteriores etapas como por ejemplo, los paréntesis ahora sobrantes.
En cuanto a la actuación del Parser, cuando éste encuentra un error lo reporta al usuario
indicando el motivo y la localización. Normalmente, el propio compilador se encarga de
recuperarse del error y continuar con fase de Análisis Semántico, aunque esta opción no
es fácil de implementar y a veces puede llevar a detectar más errores.
Así pues, en este apartado, las principales herramientas formales con las que
trabajaremos son las gramáticas, los árboles de derivación (Parse Tree) y los árboles de
sintaxis abstracta (Abtract Syntax Tree en inglés), a los que nos referiremos como AST
de aquí en adelante.
También, veremos cuáles son los algoritmos de reconocimiento con los que trabaja esta
etapa del compilador y la información que necesitan.
2.3.2.1. Gramáticas
El Parser trabaja a partir de una definición del lenguaje hecha mediante una gramática
contextual (CFG).
A: α1| α2 | … | αn
Hay que destacar el hecho que para la definición de las reglas se usa la notación EBNF
(Extended Backus–Naur Form). Este tipo de notación no aporta un mayor poder
expresivo a la gramática, pero sí permite escribirla de manera más compacta y
entendible, ya que ésta permite tener en la parte derecha de la regla formas típicas de las
expresiones regulares, como por ejemplo el símbolo “*”.
PROGRAM: „program‟;
ENDPROGRAM: „endprogram‟;
VARS: „vars‟;
ENDVARS: „endvars‟;
ASIG: „:=‟
IDENT: [A-Za-z][a-zA-Z0-9]*;
INT: [1-9][0 -9]* | 0;
program
vars
a int
b int
endvars
b := 8
a := b
endprogram
Por ejemplo, para el caso de la gramática del apartado anterior (Figura 2.9), el árbol de
derivación de un programa, sería:
Así pues, como podemos observar, un árbol de derivación cumple que tiene como raíz
el símbolo inicial de la gramática y como hijos los símbolos de la producción de aquella
regla, y así recursivamente para cualquier símbolo del árbol que no sea un token.
Una gramática puede dar lugar a más de un árbol de derivación para una misma entrada.
En este caso, diremos que nuestra gramática es ambigua, lo cual no es deseable, siendo
éste uno de los principales problemas con los que se encuentra el Parsing. Dada una
gramática ambigua, el algoritmo de Parsing no puede decidir qué alternativa debe
seguir para conseguir el reconocimiento de la entrada. En la mayoría de ocasiones las
gramáticas reconocidas como ambiguas pueden reescribirse para eliminar el problema.
IF: if
ELSE: else
THEN: then
Para la entrada:
Como se puede observar, en este ejemplo estamos tratando con una gramática ambigua
que dado un ejemplo de código como el mostrado, no sería capaz de decidir a cuál de
los dos if, pertenece el else. Por este motivo, los lenguajes de programación fuerzan el
uso de palabras clave como endif o el símbolo “}” de manera que nos permitan
diferenciar en los casos como el expuesto.
El objetivo del Parser es reconocer la estructura de la entrada, es decir, debe ser capaz
de generar un árbol de derivación para ella. Se explicarán brevemente algoritmos de
Parsing lineal, es decir, que recorren la entrada de izquierda a derecha una única vez sin
posibilidad de reconsiderar las decisiones tomadas.
El primer token de lookahead sería IDENT y continuaría con ASIG hasta llegar al último,
IDENT.
Existen dos tipos diferentes de Parsers lineales. En ambos casos la lectura de la entrada
se realiza de izquierda a derecha, símbolo a símbolo.
Son los más intuitivos y muy habituales ya que permiten construir de manera sencilla
Parsers eficientes. Estos Parsers empiezan creando el árbol de derivación por la raíz y
continúan con los hijos de manera recursiva. Parten del símbolo inicial de la gramática y
van derivando en función del siguiente símbolo que están analizando sin hacer
backtracking, sino que simplemente observan los conjuntos FIRST y FOLLOW para
tomar las decisiones acertadas. Las gramáticas reconocidas por este tipo de Parsers
reciben el nombre de gramáticas LL. Los Parsers LL se corresponden normalmente con
los creados manualmente.
A modo gráfico podemos entender este algoritmo como que el árbol se monta de arriba
abajo, es decir, de raíz a hojas. Esto mismo es lo que hace que sean algoritmos mucho
más intuitivos.
El pequeño problema con el que nos podemos encontrar es que en ocasiones hay que
modificar las gramáticas debido a problemas como la recursividad por la izquierda o la
no factorización por la izquierda. Estas características de la gramática no suponen
ningún problema si lo que queremos es construir un Parser ascendente (LR).
Los Parsers de Análisis Descendente utilizan métodos predictivos, lo cual indica que
dado un token de lookahead, el Parser debe conocer cuál es el siguiente paso a seguir
en el reconocimiento.
Hasta el momento nos hemos referido a Parsers LL y LR, pero en realidad éstos se
denotan como LL(k) siendo k el número de tokens de lookahead. k siempre es un entero
mayor que cero. Así pues, nos referiremos a los Parsers que tan sólo contemplan un
token de lookahead como LL(1) o LR(1).
Sin embargo, los Parsers generados por ANTLR v3, son Parsers LL(*), donde el “*”
indica que hay un número indeterminado de tokens de lookahead. Es decir, que el valor
de “*” tan sólo se conoce en tiempo de ejecución.
Análisis Ascendente(LR)
En realidad estos permiten reconocer un mayor número de lenguajes que los LL,
pero no son tan sencillos de implementar ni de comprender. En este caso, sí que pueden
trabajar con gramáticas con recursividad tanto izquierda como derecha. Las gramáticas
reconocidas por este tipo de Parsers reciben el nombre de gramáticas LR. Este tipo de
algoritmo es usado habitualmente por las herramientas automáticas de generación de
Parsers como Yacc [3] o Bison [4].
Ahora vamos a ver un ejemplo claro de cómo sería la derivación de una palabra
aplicando un análisis y otro. Supongamos la siguiente gramática:
S: aAbB
A: b | c
B: d
Y la palabra : w = abbd
Como ya hemos podido comprobar, el análisis LL es mucho más sencillo según nuestra
forma de razonar.
Otro tema a tener en cuenta a la hora del análisis LL o LR, es el número k de tokens de
lookahead que vamos a considerar en un momento determinado para decidir la acción
llevada a cabo en cada paso. En general, nos referiremos a los algoritmos LL y LR
como LL(k) o LR(k), donde k es siempre un entero mayor o igual a 1.
Ante los posibles errores que pueda encontrar el compilador, éste puede actuar
aplicando diferentes protocolos:
Inventar el o los tokens que faltan para así poder continuar con el análisis.
Continuar con el análisis buscando un token que pueda reconocer
Continuar con el análisis hasta que encuentra un token delimitador.
Para remitir la información del error al usuario, el compilador almacena datos como la
línea en la que tiene lugar el error, el motivo del fallo y si es necesario, sigue algún
protocolo de recuperación como los antes explicados.
A diferencia del Parser, donde existen herramientas que permiten generar el código
automáticamente a partir de la gramática del lenguaje, aquí normalmente es el
programador el encargado de implementar la mayor parte del código del analizador
semántico.
Existen una gran cantidad de verificaciones que realizar y que dependen básicamente de
la complejidad del lenguaje a analizar. Algunas de ellas son las siguientes:
Si los tipos de los operandos de cada uno de los operadores se corresponde con
el tipo esperado.
....
El código del analizador semántico se encarga de recorrer el AST que recibe de la etapa
previa (Análisis Sintáctico) e ir añadiendo a sus nodos más información en cada paso
que da. Es lo que llamamos decoración del AST. Esta información va a ser usada tanto
por el propio Análisis Semántico como por posteriores etapas del compilador.
Se puede implementar mediante un código recursivo que recorre el AST siguiendo una
estrategia bottom-up que va añadiendo la información y que a medida que se deshace la
recursividad va realizando las comprobaciones de corrección.
Cada expresión del lenguaje tiene asociado un tipo que puede ser básico o estructurado.
Los tipos permitidos por el lenguaje CL son los siguientes:
Además, para poder definir un sistema de tipos, debemos tener claro también cuándo
dos tipos son equivalentes. Existen principalmente dos nociones de equivalencia de
tipos:
Equivalencia estructural. En este caso decimos que dos tipos son equivalentes
si y sólo si son estructuralmente idénticos.
types
tA array[10] of int
tB array[10] of int
endtypes
vars
a tA
b tB
endvars
Aparte de lo que estrictamente entendemos por tipo deberemos tener en cuenta otra
clase de información sobre los identificadores, por ejemplo, la clase de símbolo, si es
referenciable, el tipo de visibilidad y el ámbito en el que están declarados.
Toda esta información relativa a cada símbolo deberá ser recogida en una estructura de
datos adecuada para su uso. Denominaremos a esta estructura Tabla de Símbolos.
Dado que una de la información más relevante para la parte que estamos tratando es el
ámbito al que pertenece un símbolo (identificador), la tabla de símbolos organizará su
información de manera que tendremos una tabla para cada ámbito del programa. Por
tanto, la Tabla de Símbolos realmente consiste en una pila de ámbitos. Dentro de cada
ámbito estarán declarados sus identificadores.
Según la clase de nodo, deberá guardar una información u otra. Por ejemplo, si el nodo
corresponde a una subexpresión deberá guardar la información del tipo de la expresión
y si ésta es referenciable o no.
program
vars
x int
a array[5] of int
endvars
x := 3
a[3] := x + 1
endprogram
Como podemos observar, el AST decorado es igual en estructura que el AST sin
decorar. La única diferencia es que ahora, en cada nodo, hemos añadido información
sobre el tipo y la referenciabilidad de las expresiones.
Nos referimos por análisis a aquella etapa encargada de validar la corrección del
programa fuente, mientras que las etapas de síntesis tienen como objetivo la traducción
del código fuente a otros lenguajes. Así pues, en este capítulo hablaremos de las etapas
de Middle-end y de Back-end.
En el Capítulo 2 hemos tratado las fases en las que se divide la etapa de análisis (Front-
end), y ahora hablaremos de las que componen la etapa de síntesis (Middle-end, Back-
end).
Back-end. Esta etapa tiene el mismo objetivo que la anterior, pero en este caso,
se traduce a un código objeto.
A pesar de que para poder entender el funcionamiento de la etapa de análisis hubo que
introducir muchos conceptos teóricos, no ocurre lo mismo con la de síntesis. El motivo
de esto es que el trabajo llevado a cabo en la etapa de síntesis está mucho menos
sistematizado, ya que buena parte de la implementación corre a cargo del programador.
En la etapa de análisis, el programador se encarga de especificar unas reglas de análisis
y, herramientas como ANTLR se encargan de generar el código del Lexer y Parser.
Para la generación de código, ANTLR también proporciona mecanismos como las Tree
Grammar y los Tree Parser que facilitan este proceso.
Los objetivos de esta fase son conseguir un código que permita la reusabilidad de varias
etapas del compilador, ya que cuando cambiamos de arquitectura, sólo tendremos que
modificar la traducción de código intermedio a código objeto; pero también, simplificar
el desarrollo de un compilador dividiéndolo en tareas. A pesar de todo ello, el objetivo
fundamental es el de poder aplicar técnicas de optimización independientes de la
arquitectura.
El proceso de traducción a código intermedio se define a partir del AST decorado que
recibimos de la etapa de Análisis Semántico, considerando que se han superado las fases
previas y que contamos con un código de entrada correcto.
Donde op1, op2 y op3 denotan los operandos, que pueden ser identificadores,
registros temporales o constantes; y op denota un operador cualquiera del lenguaje:
aritmético, lógico...
En este apartado se describirá cómo es ese entorno en tiempo de ejecución, puesto que
es necesario tener en cuenta algunos de esos conceptos a la hora de definir la traducción.
Heap o montículo
Para que un programa se ejecute en una máquina virtual, el contexto debe encargarse de
realizar las siguientes tareas:
....
Dentro de este espacio de direcciones contaremos con espacios de tamaño fijo, es decir,
con una gestión estática y, con espacios de tamaño variable, con una gestión dinámica.
Consideraremos que en memoria estática se ubican objetos que tan sólo tienen una
instancia y un tamaño acotado. En ella se encuentran el código, las variables globales,
cadenas de caracteres (strings) y constantes.
Las estructuras de datos que almacena la pila reciben el nombre de registros o bloques
de activación. Cada bloque de activación contiene información relativa a un
procedimiento o función como puede ser el valor del PC al que volver tras la llamada a
una función/procedimiento. En un registro de activación también encontraremos espacio
para los datos cuya vida está ligada a la llamada correspondiente; por ejemplo, variables
locales y parámetros.
Veamos una figura que ilustra el estado de la memoria en tiempo de ejecución, tal como
se muestra en la bibliografía [1]:
A pesar de que la pila crece hacia direcciones inferiores de memoria, en este apartado
supondremos que lo hace hacia superiores para poder usar desplazamientos positivos y
facilitar el entendimiento de los conceptos.
program
vars
a int
b int
endvars
procedure P(ref pa int)
procedure P1(ref p1a int)
p1a := p1a + b
P(p1a)
writeln(p1a)
endprocedure
pa := pa + a
P2(pa)
P1(pa)
writeln(pa)
endprocedure
procedure P2(ref p2a int)
p2a := 1
writeln(p2a)
endprocedure
a := 1
b := 2
P(b)
endprogram
Siguiendo una regla de visibilidad habitual de lenguajes que permiten definir bloques
anidados, dentro de un procedimiento/función se puede llamar a cualquier otro
procedimiento/función declarado localmente o bien declarado por alguno de sus
ancestros. Por lo tanto, un procedimiento o función siempre puede llamar a todo
procedimiento o función hijo o hermano.
Como hemos dicho, unos procedimientos o funciones pueden llamar a otros/as y por
tanto, deberemos activar un bloque de activación para cada llamada. Podemos
representar estas activaciones mediante lo que denominaremos árboles de activación.
Un árbol de activación es una estructura en forma de árbol donde cada nodo representa
una activación durante la ejecución y la raíz representa el programa principal que inicia
la ejecución de todo el programa.
programa principal P P2 P1
En este árbol, P es el padre dinámico de P2. Así pues, podemos observar que las
relaciones jerárquicas estáticas no son las mismas que las dinámicas.
Como podemos observar, en un nodo para la activación del programa principal, el hijo
corresponde con la activación del procedimiento al cual se llama mediante la activación
del programa principal. Lo mismo ocurre con P y sus hijos P2 y P1.
Para poder implementar las llamadas, un procedimiento o función debe poder apuntar a
su padre estático y dinámico. El apuntador dinámico nos permite volver al punto
anterior a la llamada. Por su parte, el apuntador estático permite acceder a los datos que
son accesibles en ese bloque de activación.
El bloque de activación del programa principal será siempre el de posiciones más bajas,
y a medida que se van realizando sucesivas llamadas, la pila irá creciendo con los
bloques de activación de los otros procedimientos.
La Figura 3.8 inferior muestra en detalle qué información, y en qué orden, contiene un
bloque o registro de activación montado por las instrucciones del Z-Code:
Para poder acceder a una variable o parámetro no local haremos uso de los enlaces
estáticos, que a partir de ahora denominaremos static_link. El static_link siempre apunta
al bloque de activación del padre estático, en concreto al correspondiente a la activación
más reciente.
Como ya se ha comentado, para acceder a un objeto no local se hará uso del static_link,
pero además, se utilizará el valor Dif_niveles. Éste nos indicará cuál es el número de
indirecciones a realizar para llegar al ámbito del objeto, siempre que éste sea mayor que
0.
Veamos los diferentes casos que podemos encontrarnos a la hora de acceder a un objeto
no local:
temp := static_link
temp := temp + offset(identificador)
temp := *static_link
temp := temp + offset(identificador)
temp := *static_link
(temp := *temp)n-1
temp := temp + offset(identificador)
Paso de un parámetro por valor de tipo no básico, para hacer una copia del
parámetro real.
Aux_space también se puede usar para otras instrucciones que, aunque no definidas para
el lenguaje CL, se podrían implementar.
Así pues, cuando necesitemos trabajar con el valor de un objeto de tipo no básico
realizaremos una copia en el aux_space del bloque de activación donde necesitamos ese
valor.
calculoDireccion(IDENT, temp2)
temp1 := aux_space
temp1 := temp1 + offsetAux_space
copy temp2 temp1 sizeOf(IDENT)
Aux_space es un dato con el que cuenta toda función o procedimiento de igual manera
que lo es static_link. Su valor es el número máximo de bytes que necesitará una
instrucción para realizar copias en el ámbito de ese procedimiento o función, es decir,
que su valor se inicializa para cada ámbito. Dado que dentro de una misma instrucción
de alto nivel podemos necesitar realizar más de una copia, necesitamos de un control del
espacio ocupado hasta ese determinado momento, offsetAux_space.
OffsetAux_space es un valor que se inicializa para cada instrucción de alto nivel. Este
valor se actualiza a medida que se encuentra con situaciones en las que es necesario usar
aux_space dentro de una misma instrucción. Como se puede observar en la Figura
3.14, la instrucción b := f(f(a,a),a) realiza más de una copia. offsetAux_space se
encarga de que en el momento de realizar cada una de las copias, indicar la posición de
memoria en la que se realiza.
Veamos el ejemplo ya comentado de una instrucción que necesita más de una copia en
aux_space:
program
vars
a array[2] of int
b array [2] of int
endvar
function f(val a1 array[2] of int, val a2 array[2]) return
array[2] of int
return a
endfunction
a[1] := 1
a[2] := 2
b := f(f(a,a),a)
endprogram
El montículo o heap es el espacio de memoria que usamos para ubicar los datos
que tienen una vida indefinida, o hasta que el programador lo determina de forma
explícita en el código.
En CL, estos datos serán los creados por la función new y eliminados por la función
free. La diferencia entre estos datos y las variables o parámetros es que una vez
desaparece el bloque de activación en el que han sido definidos no desaparecen, ya que
están ubicados en una porción de memoria que trabaja de manera diferente a la pila.
Hay que destacar el hecho que existen dos fases de optimización de código, una
independiente de la arquitectura, y otra, llevada a cabo en posteriores fases del
compilador, que sí que es dependiente.
Para la optimización se aplican diferentes técnicas que no detallaremos puesto que ésta
no ha sido objeto de estudio de este proyecto, pero sí que veremos un ejemplo de
optimización para comprender mejor a que nos referimos.
Veamos dos ejemplos muy sencillos, en lenguaje de alto nivel, que contienen
claramente redundancias e ineficiencias.
int f (int x)
{
int y, z;
y = x + 1 ;
if(x == 2){
z = 2;
return x;
}
else if (x == 3){
z = 3;
return x;
}
else{
z = 1;
return x+1;
}
}
Como podemos observar este es un ejemplo muy obvio de código ineficiente, pero
realmente, las redundancias creadas en él, en códigos de extensiones grandes son muy
habituales.
tenemos una gran casuística donde el código dentro de cada alternativa es muy
extenso.
De nuevo, puede parecer algo absurdo pero en casos de funciones con gran
extensión de código puede ser un despiste habitual por parte del programador.
int f2 (int x)
{
int j;
for(int i := 0; i < 2*x; i++){
A[i] := j
}
return j;
}
....
while(i<10){
A[2*i] = i;
B[2*i] = i+1;
C[2*i] = A[i] + B[i];
}
....
A priori, podemos pensar que este código no contiene ninguna ineficiencia y, desde el
punto de vista de lenguaje de alto nivel, es cierto. Sin embargo, como hemos comentado
anteriormente, los accesos a tipos estructurados, como en este caso las arrays,
introducen muchas redundancias.
Si nos planteamos como se traduce el acceso A[i], B[i] o C[i], en un supuesto código
intermedio con temporales, éste es de la forma:
temp1 := calculoDireccion(A)
temp2 := valor(2*i) * tamañoElementos(A)
temp1 := temp1 + temp2
Este mismo cálculo se repetiría para B[i] y C[i] en todas sus ocurrencias a lo largo del
código.
Así pues, una optimización que realiza el compilador en esta etapa es, simplemente
realizar este cálculo una vez, y usar el registro temporal que almacena el valor cuando
sea necesario.
El flujo de control sólo puede salir del bloque a través de la última instrucción
del bloque.
...
i := 0
j := 0
etiq_while:
if j > 10 goto etiq_endwhile
i := i + 2
t1 := 8 * i
x := a + b
j := j+1
A[t1] := j
if2 i!=1 goto etiq_endif2
A[t1] := j
etiq_endif2:
goto etiq_while
etiq_endwhile:
...
i := 0
j := 0
etiq_while:
if j > 10 goto etiq_endwhile
i := i + 2
t1 := 8 * i
x := a + b
j := j+1
A[t1] := j
if2 i!=1 goto etiq_endif2
A[t1] := j
etiq_endif2:
goto etiq_while
etiq_endwhile:
...
En este ejemplo se pueden realizar diversas optimizaciones que darían como resultado
el siguiente código:
...
i := 0
j := 0
x := a + b
t1 := 0
etiq_while:
if j > 10 goto etiq_endwhile
t1 := t1 + 16
j := j+1
A[t1] := j
goto if
etiq_endwhile:
...
Traducción a partir del grafo de control de flujo. En este caso se trabaja con
cada bloque básico por separado. Se realiza la traducción para cada uno de los
bloques. A continuación, se une todo el código generado formando el programa
completo.
Para el desarrollo de este proyecto se ha optado por la segunda opción debido a que ésta
ya ofrece unos buenos resultados en términos de optimización.
Por ejemplo, la sustitución de una determinada instrucción por otra más eficiente puede
ser algo concreto de una cierta arquitectura y cierto lenguaje ensamblador.
Otra de las optimizaciones más importantes son las realizadas con los registros. Siempre
se debe intentar llevar a registros físicos la información para que sean accesibles de una
forma más rápida.
4. Herramientas de Desarrollo de un
Compilador
Hoy en día existen diferentes herramientas que ayudan con el desarrollo de un
compilador. Estas herramientas facilitan el trabajo del programador además de reducir
el tiempo de implementación. También permiten detectar errores de programación a la
hora de desarrollar las diferentes etapas del compilador.
ANTLR (ANother Tool for Language Recognition) es una herramienta de lenguaje que
proporciona un framework para la construcción de reconocedores, intérpretes,
compiladores y traductores a partir de descripciones gramaticales que contienen
acciones en varios lenguajes. Además, la nueva versión dispone de un entorno gráfico
llamado ANTLRWorks que permite la definición de gramáticas de manera más fácil
para el usuario incluso cuando éste no es experto en el diseño de gramáticas y lenguajes
[5].
ANTLR genera Parsers de tipo recursivo descendente para el lenguaje deseado. Este
lenguaje puede ser tanto un lenguaje de programación como un formato de datos, pero
ANTLR no sabe nada acerca del lenguaje especificado en la gramática. También se
pueden incluir fragmentos de código que realicen determinadas tareas durante el
proceso de reconocimiento. ANTLR permite anotar la gramática con operadores de
forma que el usuario describe como quiere que sea el AST que espera que se construya.
Así pues, como hemos visto, la función principal de ANTLR es facilitar el trabajo al
programador, automatizando aquellas tareas más complicadas que forman parte del
proceso de reconocimiento a nivel léxico y sintáctico de un lenguaje.
ANTLR permite generar código en C, Java, Python, C#, Objective-C y otros que
actualmente están en desarrollo como por ejemplo, C++.
Donde el nombre de cada archivo indica el rol de ese mismo archivo. Y la extensión
java indica el código en que se está generando el analizador.
Una vez tenemos creados estos ficheros ya podemos compilarlo y ejecutarlo para
comprobar la corrección de nuestro código fuente.
Como podemos apreciar, usar herramientas como ANTLR nos facilita el trabajo puesto
que simplemente especificando una gramática y un conjunto de tokens obtenemos un
Parser y Lexer, en lugar de tener que escribir nosotros estos programas.
El nombre de los tokens siempre empieza con mayúscula. Las reglas sintácticas
o producciones de la gramática comienzan con minúsculas.
Para referirnos a los literales1 deberemos hacerlo siempre con tal literal entre comillas
simples, de la forma: „literal‟.
4.1.2.2. Reglas
Las gramáticas pueden definirse utilizando diferentes notaciones. Las gramáticas con
notación BNF se especifican mediante un conjunto de reglas de la forma:
a → x | y | z
ANTLR soporta otro tipo de gramáticas, las EBNF (Extended Backus–Naur Form).
Éstas permiten definir cualquier expresión regular a la derecha de la regla. Algunos
ejemplos de partes derechas de reglas especificadas con notación EBNF se muestran en
las siguientes figuras [2]:
1
Los literales son tokens cuya expresión regular asociada solo contiene esa palabra
x? x es un elemento opcional
Ahora veamos parte de una gramática con notación EBNF donde se aplica lo
anteriormente comentado.
l_instrs
: ( instr )*
;
instr
: left_expr ASSIG expr
| IF^ expr THEN! l_instrs ( ELSE! l_instrs )? ENDIF!
| WHILE^ expr DO! l_instrs ENDWHILE!
| READ^ LEFT_PAR! left_expr RIGHT_PAR!
| WRITE^ LEFT_PAR! ( expr | STRING_CONST ) RIGHT_PAR!
| NEW^ LEFT_PAR! left_expr RIGHT_PAR!
| FREE^ LEFT_PAR! expr RIGHT_PAR!
| WRITELN^ LEFT_PAR! ( expr | STRING_CONST ) RIGHT_PAR!
| IDENT LEFT_PAR actual_params RIGHT_PAR
;
La novedad más importante es el concepto del Parsing LL(*), junto con el uso de los
StringTemplantes de cara a la generación de código. Muchas de las funcionalidades
específicas se activan en el propio fichero donde se describe la gramática, dentro de un
determinado bloque.
expr_simple: INT_CONST
| NULL
| TRUE
| FALSE
| IDENT DOT IDENT
| IDENT LEFT_BRKT expr RIGHT_BRKT
.....
;
Para la regla anterior existen dos alternativas que comienzan con el mismo token
(IDENT) y que por lo tanto, se deberían de factorizar. Una vez factorizada, la regla
queda de la siguiente forma:
expr_simple: INT_CONST
| NULL
| TRUE
| FALSE
| IDENT (DOT IDENT | LEFT_BRKT expr RIGHT_BRKT)
.....
;
El Parsing LL(*) es una de las principales características que ahora pasa a formar parte
del comportamiento por defecto de ANTLR. En esta nueva versión, por defecto,
ANTLR v3 trabaja con un número indeterminado y no limitado de tokens de lookahead.
Si se quiere deshabilitar esta opción, simplemente hay que indicar que la opción k
(haciendo referencia al ya comentado token de lookahead) pase a ser un número entero
mayor que 0. De esta manera estaremos limitando el Análisis Sintáctico al clásico
LL(k).
Existen estrategias de Parsing, como las LR, que permiten reconocer un mayor número
de gramáticas, pero en cambio, entre sus características están las de ser más
complicadas de entender y desarrollar.
Los analizadores descendentes que genera ANTLR v3 son más intuitivos y fáciles de
comprender, aunque a veces es necesario realizar modificaciones sobre la gramática
definida inicialmente como por ejemplo, la factorización izquierda de sus reglas.
Aunque herramientas como, por ejemplo ANTLRWorks la realizan automáticamente.
Esta gramática no es LL(k). Si observamos la producción method podemos ver que las
dos alternativas posibles tienen un prefijo común:
Estas partes comunes impiden tomar una decisión al Parser con un número de tokens de
lookahead finito, ya que no sabemos cuántos argumentos tendrá el método (args) y por
tanto, no podemos decidir cuál de las dos alternativas de la producción nos llevará a
aceptar la entrada.
No podemos fijar una k que nos permita diferenciar entre el “;” de la primera
alternativa y el “{“ de la segunda alternativa de la regla. Pero, esta arbitrariedad
realmente desaparece en tiempo de ejecución, por lo tanto, la k es conocida sólo en ese
momento.
Esta gramática es LL(k). Dado que el símbolo inicial de las dos alternativas es el
mismo, podemos apreciar que necesitaríamos una k = 2 para poder determinar cuál es la
alternativa correcta.
void stat() {
if ( LA(1)==ID&&LA(2)==EQUALS ) { // PREDICT
match(ID); // MATCH
match(EQUALS);
expr();
}
else if ( LA(1)==ID&&LA(2)==COLON ) { // PREDICT
match(ID); // MATCH
match(COLON);
stat();
}
else «error»;
}
Figura 4.10 - Código generado por versiones anteriores de ANTLR para la Figura 4.9
Si nos paramos a pensar, veremos, que realmente la decisión no depende del token
IDENT, sino que depende del segundo token de lookahead.
Otro algoritmo posible, esta vez fijándonos únicamente en aquél token decisivo sería:
void stat() {
int alt=0;
if ( LA(1)==ID ) {
if ( LA(2)==EQUALS ) alt=1;
else if ( LA(2)==COLON ) alt=2;
}
// MATCH PREDICTED ALTERNATIVE
switch (alt) {
case 1 : // match alternative 1
match(ID);
match(EQUALS);
expr();
break;
case 2 : // match alternative 2
match(ID);
match(COLON);
stat();
break;
default : «error»;
}
}
Figura 4.11 - Código generado por ANTLR para reconocer la Figura 4.9
En este caso, el código ya está más enfocado hacia lo que realmente es la toma de
decisiones. Como podemos ver, primero se toma cierta información acerca de los tokens
de la entrada y, una vez se tiene la información, se pasa a ejecutar la decisión que
corresponda.
Este DFA codifica la secuencia de tokens de lookahead donde los estados s2 y s3 son
estados aceptadores para cada una de las alternativas.
LL(*) extiende LL(k) permitiendo DFAs cíclicos2 que permiten escanear la entrada de
forma arbitraria hasta que encuentran la k que permite diferenciar entre una alternativa y
otra.
La decisión LL(*) se basa en buscar la k mínima que nos permite tomar la decisión
correcta. Como se puede observar la diferencia con el LL(k) reside en que no tenemos
nosotros la responsabilidad de especificar el número de tokens de lookahead necesarios.
Una vez se conoce cuál es la k necesaria para la decisión, ANTLR trabaja igual que en
el LL(k). Por lo tanto, ANTLR sólo usa el DFA para distinguir entre las diferentes
alternativas dentro de una determinada producción y no para toda la gramática.
2
Por DFA cíclico nos referimos a que puede contener ciclos en alguno de sus caminos
void def() {
int alt=0;
while (LA(1) in modifier) consume();
if ( LA(1)==CLASS ) alt=1;
else if ( LA(1)==INTERFACE ) alt=2;
switch (alt) {
case 1 : ...
case 2 : ...
default : error;
}
}
Podemos ver como el código generado traduce a la perfección el DFA cíclico mostrado.
Permaneciendo en un bucle hasta que aparece el token diferenciador de las alternativas.
Podemos decir que una decisión es LL(*) si se cumplen las siguientes condiciones:
Existe un DFA que reconoce el lenguaje generado por los tokens de lookahead.
Existe como mínimo un estado de aceptación para cada una de las alternativas
de la gramática.
Finalmente, diremos que una gramática es LL(*) si todas las decisiones que contiene
son LL(*).
Podríamos pensar que esta forma de actuar es similar al backtracking, pero en realidad
no son iguales. El backtracking tiene un coste mucho más elevado puesto que seguimos
avanzando y realizando acciones que, en el caso que el camino finalmente no sea el
correcto, hay que deshacer.
La predicción LL(*) lo que realmente hace es explorar antes de tomar ninguna decisión
sin realizar ninguna acción y, una vez dispone de información suficiente, entonces y
sólo entonces actúa realizando las acciones pertinentes en ese momento.
producción y por lo tanto, nos encontramos con una decisión ambigua. A pesar
de ello, en este caso ANTLR sí que es capaz de generar un DFA.
Con una entrada del tipo: if a then b=2 if b then b=3 else b=1 no
seríamos capaces de determinar a qué if se corresponde el else.
Con esta regla sólo se puede generar la secuencia: expr „+‟ IDENT ‟+‟
IDENT... entrando en un bucle infinito que el algoritmo de Parsing no sabría
cómo resolver.
Los predicados son una herramienta que ANTLR pone a disposición del
programador para que éste especifique al Parser cómo resolver problemas de
reconocimiento. Nos sirven para solventar, por ejemplo, problemas de ambigüedad que
el LL(*) Parsing no podría solucionar.
: ^( ASSIG
{ $instr::type = $ASSIG.getASTChild(0).getTypeTree();
$instr::isRef = $ASSIG.getASTChild(1).getReferenciable();
size = typeSize($instr::type)
}
...
Éstos se usan principalmente para resolver los problemas con gramáticas no-LL(*).
Imaginemos que queremos reconocer aquellas palabras formadas por cadenas de B‟s.
a: B B B
| B B
| B
;
Si estas cadenas son de un número pequeño de B‟s como el del ejemplo, la regla no
supone ningún problema. Supongo ahora que tuviéramos que reconocer todas las
cadenas formadas por, como máximo cien B’s. Como podemos imaginar, esta solución
no es viable para el programador. La solución pasa por incorporar código en la regla
como el siguiente:
Figura 4.25 - Gramática de ANTLR para reconocer cadenas de hasta cien B’s
La diferencia con los predicados semánticos es que los primeros hacen una lectura
previa de parte de la entrada intentando reconocerla mientras que los semánticos
evalúan condiciones booleanas.
La diferencia entre el uso de los predicados sintácticos y el Parsing LL(*) reside en que
son mucho más potentes que los DFAs creados por este último para la toma de
Los predicados sintácticos en realidad implementan una CFG que trabaja con pila y por
ello, permite reconocer estructuras anidadas y de árbol que los DFAs no pueden. Es
decir, que ante un predicado sintáctico, ANTLR genera un Parser usado únicamente
para la toma de la decisión en cuestión. Una vez tomada esa decisión, se continúa el
análisis de la forma habitual.
p( ), siendo p( ) un procedimiento
La regla instr inferior define el tipo de instrucciones que podemos encontrar en CL. Si
definimos la sintaxis de CL de manera natural tendríamos la siguiente regla:
El problema en este caso viene dado porque la regla left_expr contiene, entre otras, la
alternativa que veremos a continuación.
Si nos fijamos, se produce una ambigüedad puesto que podemos encontrar la siguiente
secuencia:
Esta secuencia se puede obtener por dos caminos diferentes, entrando por la alternativa
1 o 9 (resaltadas) de la regla instr. Y por lo tanto, no podemos diferenciar a priori
entre las entradas comentadas anteriormente: f( ).x = a y p( ), dado que las dos
tienen la misma estructura inicial.
Podríamos intentar una posible factorización, pero dado el nivel de complicación de ésta
y que perderíamos legibilidad del código, el uso de un predicado sintáctico es la mejor
opción para solucionar el problema.
Este predicado indica a ANTLR de manera explícita cuándo debe escoger la alternativa
1. En este caso, cuando encuentre una left_expr seguida del token ASSIG sabe que esa
alternativa es la correcta. Si no fuera el caso debería seguir buscando en las otras
alternativas fuente del conflicto.
ANTLR permite decorar la gramática con anotaciones sobre cómo se debe construir el
AST. Hemos de tener en cuenta que cada regla produce un subárbol y que, la
producción inicial de la gramática genera el AST completo. Cada nodo del árbol
proviene de un token de la entrada, pero también es posible crear nodos del árbol que
representan tokens “ficticios3”.
Además de crear la estructura del árbol que deseemos, ANTLR también traslada al
usuario la decisión de cómo quiere que sean los nodos del árbol. Esto permite que poder
decidir qué información guardar en los nodos del árbol. Para ello, simplemente hay que
indicar mediante la opción ASTLabelType que el nodo sea de la clase nodo creada,
definida como una clase derivada de la clase base CommonTree.
ANTLR proporciona la clase CommonTree, la cual está formada por unos atributos
básicos que almacenan la información considerada necesaria para cada token y por
tanto, para cada nodo del árbol. A partir de CommonTree se puede definir una clase que
hereda sus atributos, pero además, permita almacenar otra información relevante para
determinados compiladores. Para el caso de CL, es interesante guardar la información
relativa al número de línea dentro del código donde se encuentra un token, saber si es
referenciable,...
Para que ANTLR dé como resultado un árbol necesita tener activada la opción output =
AST. De esta manera, si no se especifica ninguna anotación en la gramática, el Parser
creará un conjunto de ASTs donde cada uno contiene el correspondiente token de la
entrada, es decir, una lista plana de tokens.
3
Llamaremos nodos ficticios a aquellos que no provienen del Parser de la entrada.
Sin embargo, ANTLR permite el uso de operadores que alteren la creación del árbol
según nuestro criterio. La sintaxis de las reglas gramaticales se ven poco afectadas
puesto que, a priori, se trata de introducir los siguientes operadores: “^” y “!”.
El operador “^” detrás de un token indica que ese token pasará a ser la nueva
raíz del subárbol de aquella regla.
Como podemos observar, en este caso, la regla dec_block define los bloques que
podemos encontrar en un programa CL: funciones y procedimientos. Para cada una de
estas alternativas, ANTLR construirá un subárbol que tendrá como raíz el token
PROCEDURE o FUNCTION. Sus hijos serán el token IDENT y los subárboles resultantes de
las llamadas al resto de reglas gramaticales.
La regla l_instrs define que una lista de instrucciones se compone de cero o más
instrucciones. El símbolo “->” indica que a continuación vamos a detallar la creación
del árbol para esta regla.
En esta misma regla también podemos ver como ANTLR permite definir utilizar
el token “ficticio” UnaryMinus como raíz del árbol en la segunda alternativa,
pero incorporando todo el resto de información del token “real” MINUS en dicho
nodo.
Creación de nodos ficticios. En ocasiones, puede ser útil añadir nodos ficticios
para crear una estructura de árbol más legible. Por ejemplo, en el caso de CL
ayuda a comprender la declaración de variables, lista de instrucciones, lista de
bloques....
Para que un nodo sea ficticio, no debe de tener ninguna referencia en el lado
izquierdo del símbolo “->” , aunque sí que debe estar declarado como token
junto con el resto de tokens de la gramática.
l_vars :
( VARS l_dec_vars ENDVARS)? -> ^( ListOfVars
(l_dec_vars)? )
;
l_dec_vars: ( dec_var )*
;
Como podemos ver, el resultado para el ejemplo serán dos árboles, donde int
es la raíz de ambos y sus hijos respectivos son a y b.
Tal y como hemos podido comprobar, en la reescritura de las reglas se utiliza notación
EBNF haciendo uso de los operadores “+”,”*” y “?” con la semántica habitual.
Una extensión de los Tree Walkers son los Tree Parsers. Un Tree Parser es un
algoritmo que recorre un árbol a la vez que va realizando comprobaciones sobre su
estructura. Además, también es capaz de llevar a cabo acciones durante tal recorrido.
Los Tree Parsers pueden ser implementados manualmente, pero ANTLR proporciona
mecanismos para poder ser generados automáticamente, las Tree Grammars.
Una Tree Grammar es una gramática con reglas muy similares a las reglas de una
gramática standard, donde la parte derecha de las reglas especifica cómo debe ser la
estructura del subárbol a recorrer. De hecho, una de las facilidades que proporciona
ANTLR es que a partir de un Parser que genera un AST, se puede construir de manera
sencilla una Tree Grammar.
La notación usada para las Tree Grammars es la misma que para las gramáticas. Las
Tree Grammars se encargan de definir la estructura del AST generado por la etapa de
Parsing.
ANTLR reduce el Tree Parsing al habitual Parsing de tokens, utilizando las mismas
estrategias de reconocimiento. Esto es posible puesto que ANTLR serializa los árboles
como una cadena de nodos de árbol donde se han insertado nodos “ficticios” UP y DOWN
que indican la estructura del árbol.
Los nodos DOWN indican cuando se baja un nivel en el árbol para visitar los hijos y el UP
indica cuando se acaba la lista de hijos.
+ DOWN 10 * DOWN 3 2 UP UP
grammar G;
…
decl : 'int' ID ( ',' ID )* -> ^ ( 'int' ID+ ) ;
…
La parte de regla a la derecha del símbolo “->” es la reescritura de esa regla, indicando
cómo es el formato del AST para esa regla. Así pues, la Tree Grammar que contiene esa
Tree Rule es de la forma:
tree grammar G;
...
decl : ^ ( 'int' ID+ ) ;
…
Como se puede ver, la Tree Rule decl es simplemente la reescritura que ya existía en la
gramática de la Figura 4.41.
Cabe destacar el hecho que se debe indicar que una gramática es una Tree Grammar en
la primera línea del fichero donde se define con la sentencia “tree grammar
<nombre>;”.
Un ejemplo de algunas de las Tree Rules para la Tree Grammar de CL encargada del
Análisis Semántico es:
...
l_dec_types: ^( ListOfTypes ( dec_type )* );
Las anteriores reglas definen la declaración de tipos por parte del usuario. Éstas
muestran como es el AST esperado y para la regla dec_type se puede observar cómo
se realizan acciones y comprobaciones durante el recorrido del árbol, que verifican que
el nombre de tipo no ha estado previamente declarado y lo inserta en la Tabla de
Símbolos.
: ^( ListOfActualParams
(
( {formal_param.isValFormalParam()}? expr_value[temp]
| {formal_param.isRefFormalParam()}? expr_address[temp]
)
)*
);
4.3. StringTemplates
Los StringTemplates son la nueva herramienta que proporciona ANTLR para
facilitar la emisión de código estructurado, trabajo que anteriormente se realizaba
mediante sentencias printf o cout.
Los principales motivos para usar StringTemplate son (según consta en [2]):
Debido a todo esto, podemos crear un traductor para múltiples lenguaje target
simplemente definiendo un fichero de StringTemplates para cada uno de ellos.
Podemos considerar los StringTemplates como strings con huecos que se van rellenando
con expresiones que son función de los atributos que reciben. La clase StringTemplate
contiene un método toString() que lo convierte en el texto correspondiente al
programa ya traducido, y que puede ser mostrado por pantalla.
Un StringTemplate puede contener texto, que puede ser directamente impreso, huecos
que podemos rellenar con strings o StringTemplates, y también se pueden utilizar
directivas, comprendidas entre los signos “<” y “>”, que permiten describir
StringTemplates complejos que contienen iteraciones, alternativas,...
La figura superior muestra uno de los ejemplos más sencillos de StringTemplates que
podemos encontrar. Éste se encarga de traducir una llamada a la función
writeln(param), la cual muestra por pantalla el contenido del parámetro “param”. El
patrón traduce esa llamada por dos nuevas instrucciones: wris <string>, seguida de
writeln. La directiva <string> indica que en esa posición se mostrará por pantalla
directamente el valor de ese parámetro del StringTemplate.
Veamos ahora un ejemplo de patrón más complejo donde se aprovecha toda la potencia
que permiten los StringTemplates:
Este patrón se encarga que traducir las llamadas a procedimientos. Esta regla recibe el
nombre de procCall. procCall contiene una serie de parámetros que recibe como
información para instanciar el patrón.
Veamos ahora como serían las llamadas a las reglas de la Figura 4.46 y Figura 4.47
desde el punto concreto del programa donde decidimos el patrón por el que se ha de
traducir:
| ^( WRITELN
procedure
@init {
Integer temp;
}
: ^( PROCEDURE IDENT
{ symTab.pushScope($IDENT.text); }
proc_header
l_vars
l_blocks
{ temp = 0;
resetLabelCounters();
resetAuxSpace();
}
l_instrs[temp]
{ symTab.popScope(); }
)
-> procedure(id={symTab.getFullName($IDENT.text)},
params={$proc_header.st},
vars={$l_vars.st},
auxspace={auxspaceMaxim>0?auxspaceMaxim:null},
instrs={$l_instrs.st},
subrs={$l_blocks.st})
;
5.1. Lenguaje CL
5.1.1. Estructura
La estructura básica de un programa CL está comprendida entre las palabras
clave program y endprogram. Dentro del programa podremos declarar tipos y variables
globales, funciones y procedimientos, además de especificar las instrucciones.
Podríamos decir que se trata de un lenguaje pensado para realizar una programación
modular, en el que se pueden definir bloques de funcionalidad (funciones y
procedimientos) de forma anidada.
program
types
/*Declaración de tipos*/
endtypes
vars
/*Declaración de variables*/
Endvars
/*Procedimientos y Funciones*/
/*Instrucciones*/
endprogram
A continuación se muestran dos ejemplos de programas CL, uno de ellos mucho más
sencillo que el otro.
El primer ejemplo muestra un programa que calcula el factorial de un número que lee
por pantalla. El segundo crea un árbol a partir de punteros a un tipo estructurado
definido por el usuario. Este tipo estructurado contiene la información habitual de los
árboles de programación.
program
vars
x int
i int
fact int
endvars
i := 1
read(x)
if x = 1 then
fact := 1;
else
fact := 1
while i <= x do
fact := fact * i;
i := i + 1;
endwhile
endif
writeln(fact);
endprogram
program
types
arbolnode struct
info int
firstchild pointer to arbolnode
nextsibbling pointer to arbolnode
endstruct
endtypes
vars
A pointer to arbolnode
aux2 pointer to arbolnode
aux3 pointer to arbolnode
aux4 pointer to arbolnode
aux5 pointer to arbolnode
endvars
{
A := a
/ \
b c
/ \ / \
d e f g
aux2 := crearhoja(104)
aux3 := crearhoja(105)
aux4 := crearhoja(106)
aux5 := crearhoja(107)
anyadirhermanoizquierdo(aux2, aux3)
aux3 := enraizarlistaarboles(102, aux2)
anyadirhermanoizquierdo(aux4, aux5)
aux5 := enraizarlistaarboles(103, aux4)
anyadirhermanoizquierdo(aux3, aux5)
A := enraizarlistaarboles(101, aux3)
escribirarbol(1, A)
destruyearbol(A)
endprogram
o POINTER TO tipo
Nombres de tipos definidos por el usuario a partir de otros tipos del lenguaje.
Se declaran entre los delimitadores TYPES y ENDTYPES al comienzo del programa
principal y sólo en él, tras la palabra clave PROGRAM.
5.1.3. Expresiones
Las expresiones con las que contamos en CL son las habituales en la mayoría de
los lenguajes de programación:
[], .,^
*,/
+,-
=,<,>
AND , OR
Los operadores que se encuentran en un mismo nivel tienen la misma prioridad. En este
caso, cuando exista ambigüedad se asumirá asociatividad izquierda de todos los
operadores binarios. Como es habitual, si queremos utilizar otra prioridad o
asociatividad podemos usar los paréntesis.
5.1.4. Instrucciones
CL dispone de los siguientes tipos de instrucciones:
o expr1: = expr2
Sentencias alternativas:
Sentencias iterativas:
Veamos un ejemplo que nos permita observar con más detalle estos conceptos:
program
vars
a array[10] of int
b int
endvars
procedure P(val A array[10] of int, ref B array[10] of int)
b := 0
while b <10 do
writeln(A[b])
writeln(B[b])
b := b + 1
endwhile
endprocedure
function F(val c int, ref d int)return int
vars
a int
endvars
a:= c + d
return a
endfunction
b := 0
while b <10 do
a[b] := b
b := b+1
endwhile
P(a, a)
b := F(b+1, b)
b := F(b, b)
endprogram
En la función F, son visibles y por tanto se pueden usar los siguientes símbolos:
c, d y a declarados localmente, y los símbolos b, P y F declarados en el
programa principal. La declaración de la variable a (array) del programa
principal no es visible puesto que ha sido ocultada por la definición de la
variable local a (entero) de F. Nótese que desde F es posible realizar tanto una
llamada recursiva a F como una llamada al procedimiento P, y lo mismo ocurre
para P, podemos llamar a F aunque ésta haya sido declarada como un hermano
posterior.
program
vars
X int
Y int
endvars
function F1(val X int, ref Y int)return int
function F11(val X int, ref Y int)
function F111(val X int, ref Y int)return int
write(111)
endfunction
function F112(val X int, ref Y int)return int
write(112)
endfunction
write(11)
endfunction
F11(X, Y)
endfunction
procedure P2(val X int, ref Y int)
procedure P21(val X int, ref Y int)
procedure P211(val X int, ref Y int)
writeln(Y)
endprocedure
writeln(Y)
endprocedure
procedure P22(val X int, ref Y int)
procedure P221(val X int, ref Y int)
Y := Y-1
if 0 < Y then
P221(X,Y)
else
X := 1
endif
endprocedure
endprocedure
endprocedure
X := 1
Y := 2
P2(X,Y)
writeln(Y)
endprogram
Aplicando estas reglas, en el ámbito de P221 son visibles los símbolos locales
X e Y, el propio procedimiento P221 declarado en P22, los símbolos P21 y P22
declarados en P2 y finalmente, los símbolos F1 y P2 declarados en el ámbito
del programa principal.
5.2. Z-Code
A continuación definiremos cómo es el código intermedio al que vamos traducir
el lenguaje CL. En la práctica de la asignatura Compiladores ya se generaba código
intermedio (T-Code), pero éste era diferente al generado ahora.
T-Code era un código mucho más cercano al lenguaje ensamblador mientras que el
nuevo código intermedio Z-Code intenta ser más próximo a un lenguaje de alto nivel.
5.2.1. Estructura
La estructura básica de un programa Z-Code será siempre:
program:
; parameters
; static_link
; endparameters
;
; variables
; /* declaración de variables*/
; endvariables
/*instrucciones programa principal*/
stop
Como podemos observar, todo programa Z-Code está formado por una secuencia de
bloques de código. El primer bloque es siempre el programa principal, con su respectiva
declaración de parámetros (en este caso sólo static_link) y variables locales, y el código
correspondiente a sus instrucciones. Este bloque acaba siempre con la instrucción stop.
5.2.2. Operandos
Z-Code cuenta con dos clases de operandos, nucleares y no nucleares. Los
operandos nucleares son:
Constantes. Dado que CL sólo trabaja con números enteros, las constantes serán
números enteros. Los valores booleano true y false se corresponden con los
enteros 1 y 0.
Tres operandos especiales con los que contaremos serán static_link, aux_space
y returnvalue, que representan direcciones simbólicas para acceder a algunas
posiciones del bloque de activación en curso.
5.2.3. Instrucciones
Los tipos de instrucciones que generaremos son:
o op1 = op2 op op3, donde op1, op2 y op3 son operandos nucleares y; op
es un operador binario aritmético, de comparación o lógico.
o op1 = op2, donde op1 y op2 son operandos nucleares. Estas son
instrucciones de copia donde el valor del op2 se asigna a op1.
o op1 = *op2, donde op1 y op2 son operandos nucleares. Se asigna a op1,
el contenido de a lo que apunta el puntero op2.
Instrucciones de entrada/salida:
o wrii op1, donde op1 es un operando nuclear.
o wrln.
Otras instrucciones:
o copy op1 op2 size, donde op1 y op2 son operandos nucleares y size
indica el número total de bytes que se van a copiar.
o name :
program
vars
x int
s struct
z1 int
z2 int
endstruct
a array[5] of int
endvars
x:=3
s.z1 := 4
a[x] := s.z1
endprogram
program:
; parameters
; static_link
; endparameters
;
; variables
; x 4
; s 8
; a 20
; endvariables
x := 3
s[0] := 4
t1 := x * 4
t2 := s[0]
a[t1] := t2
stop
6. Trabajo Realizado
Hasta el momento hemos tratado los diversos aspectos teóricos relacionados con
la construcción de compiladores y la herramienta usada para la realización de este
proyecto.
Las partes ya implementadas de las que se ha partido para este proyecto eran las de la
etapa de análisis o Front-end del compilador.
El proyecto ya contaba con una estructura de ficheros que facilitan el desarrollo y testeo
del compilador. Esa estructura es la siguiente:
Toda nueva clase, shellscript o juego de pruebas ha sido creado respetando la estructura
de ficheros existente.
6.3.1.1. Types
Al permitir que el usuario pueda definir nuevos tipos, se ha tenido que definir una
noción de equivalencia de tipos. Entre la noción de equivalencia estructural y la de
equivalencia por nombre, se ha optado por la primera como ya se ha explicado en el
capítulo 2.
program
types
t struct
b int
c int
endstruct
A array [1000] of int
endtypes
vars
v A
w A
a t
d int
endvars
procedure P(ref x t, ref y int, ref z A, val t A)
x.c := y
y := y + x.b * x.c
z[0] := y - 1
z[999] := y - 1
t[0] := y + 1
t[999] := y + 1
endprocedure
a.b := 2
a.c := 3
d := 10
w[0] := 333
w[999] := 333
P(a, d, v, w)
endprogram
program
types
tA array[3] of int
tB array[3] of int
endtypes
vars
a array[10] of tA
b array[20] of tB
endvars
a[2] := b[5]
endprogram
program
types
tA struct
next pointer to tA
info int
endstruct
endtypes
endprogram
Como se puede observar en el ejemplo anterior. El tipo de uno de los campos del struct,
es del tipo definido por el usuario, dando lugar a una circularidad.
program
vars
ppi pointer to pointer to array[10] of int
A array[10] of int
endvars
ppi := null
ppi^ := null
ppi^^ := A
ppi^^[3] := 23
endprogram
program
vars
p pointer to int
i int
endvars
new(p)
i := 3
p := @i
writeln(p^)
p^ := 5
writeln(i)
free(p)
endprogram
Dada una expresión expr de tipo pointer(T) para algún tipo T, el significado de las
instrucciones y los operadores con punteros es la siguiente:
Antes de continuar veremos dos ejemplos de patrones sencillos para poder comprender
con mayor facilidad los que se presentarán en los siguientes apartados.
...
s struct
a int
b int
c int
endstruct
...
s.b := 4
...
program
vars
i int
a int
endvars
...
i := 5
a := 3 + 2 * i
...
endprogram
Por ello mismo, el valor estará almacenado en un registro temporal temp y se trata de un
nuclear.
Además de estas tres funciones principales, definiremos otras funciones auxiliares, que
veremos al final del apartado para facilitar la comprensión, y la notación usada para la
descripción de los templates o patrones de traducción.
referencia o parámetro por valor. Otras clases de identificadores son los nombres de
tipos, los procedimientos y las funciones.
GenExprValue(expr, temp) ||
temp := temp + offsetField(expr, IDENT)
[“expr.exprText[temp]”, false, Z-CodeList]
Para facilitar el entendimiento empezaremos con las traducciones más sencillas, para así
poder ir elevando el nivel de abstracción a medida que nos encontremos con cuestiones
más complicadas. También, agruparemos las traducciones por tipo de función con la
que se generarán.
Como podemos ver, calcular el valor de constantes es muy sencillo puesto que no
necesita de generación de Z-Code. Simplemente se debe indicar cuál es el string con el
valor de la constante y devolver la información de si ese string es expresión nuclear.
El cálculo del valor de expresiones con operadores unarios consiste en generar el valor
de la expresión en cuestión y, a continuación, añadirle el operador al string resultante.
6.3.2.1.4. Identificador
Ahora que ya hemos visto algunos de los patrones de traducción más sencillos,
veamos el patrón que define la generación de código de los identificadores.
El patrón de generación del valor de un identificador es uno de los que más casuística
contempla, siendo por tanto, uno de los más complicados de implementar. Es por ello
que se ha pospuesto su presentación para este punto.
Este número viene determinado por el número de saltos de nivel que debemos
realizar para llegar a la definición del identificador.
Todo este cálculo se realiza de una manera determinada dependiendo del tipo del campo
del struct y de si es un struct referenciable.
Construcción de un compilador con parsing LL(*) y StringTemplates - 136 -
Capítulo 6. Trabajo Realizado
Figura 6.17 - Generación del Z-Code para el acceso a un elemento de una array
El resultado de los accesos a arrays son iguales a los de campos de struct. En ambos
casos son de la forma expr1 [expr2].
A la hora de generar el código, también nos basaremos en el tipo de los elementos del
array y de si es referenciable. Siempre deberemos calcular el valor del índice del array
A continuación, en función de que estemos tratando con un parámetro por valor o por
referencia, generaremos el código para pasarlo tal y como toque. Es decir, si tenemos
que pasar un parámetro por valor, llamaremos a GenExprValue y si es por referencia a
GenExprAddress. Se pasará a guardar en pila en resultado retornado por estas funciones.
Figura 6.19 - Generación del Z-Code de acceso al valor apuntado por un puntero
Para acceder al valor apuntado por un puntero, hemos de calcular primero el valor de la
expresión mediante una llamada a GenExprValue. Después, en función del nivel en el
que está definida la expresión y de si es nuclear o no, actuaremos de una u otra manera.
GenExprAddress(&expr, temp) :
GenExprAddress(expr, temp)
si expr.exprNuclear [“&expr.exprText”, false, Z-CodeList]
sino
[“temp”, true, Z-CodeList]
El cálculo del valor de la dirección de una expresión se realiza mediante una llamada a
GenExprAddress. Según si el string que devuelve esta función es nuclear o no, el
resultado final será uno u otro.
6.3.2.2.1. Identificadores
GenExprAddress(IDENT, temp) :
si isTypeBasic(IDENT) && !isTypePointer(IDENT) entonces
si levelsDiff(IDENT) = 0 entonces
si isVarLocal(IDENT) entonces [“IDENT.text”, true, ]
si isParamRef(IDENT) entonces [“IDENT.text”, false, ]
si isParamVal(IDENT) entonces
temp := &IDENT.text
[“temp”, true, Z-CodeList]
sino
GenStaticLink( IDENT, temp)
[“temp”, false, Z-CodeList]
sino si isTypePointer(IDENT) entonces
si levelsDiff(IDENT) = 0 entonces
si isVarLocal(IDENT) entonces [“&IDENT.text”, false, ]
si isParamRef(IDENT) entonces [“IDENT.text”, true, ]
si isParamVal(IDENT) entonces
temp := &IDENT.text
[“temp”, true, Z-CodeList]
sino
GenStaticLink( IDENT, temp)
[“temp”, true, Z-CodeList]
sino
si levelsDiff(IDENT) = 0 entonces
si isVarLocal(IDENT) entonces [“&IDENT.text”, true, ]
si isParamRef(IDENT) entonces [ “IDENT.text”,false, ]
si isParamVal(IDENT) entonces [“IDENT.text”, false, ]
sino
GenStaticLink( IDENT, temp)
[“temp”, false, Z-CodeList]
GenExprAddress(expr.IDENT, temp) :
si isTypeBasicOrPointer(IDENT) entonces
GenExprAddress(expr, temp)
[“expr.exprText[offsetField(expr,IDENT)]”, false, Z-CodeList]
El patrón definido es muy similar a su análogo para el caso del valor. El resultado final
acaba siendo un string de la forma expr1[expr2], donde expr2 indica el desplazamiento
hasta el campo del struct.
GenExprAddress(expr1[expr2], temp) :
si isTypeBasicOrPointer(expr1[expr2]) entonces
GenExprAddress(expr1, temp) ||
GenExprValue(expr2, temp+1) ||
temp+1 := temp+1 * sizeOfArrayElem(expr1)
[“expr1.exprText[temp+1]”, false, Z-CodeList]
Figura
sino 6.24 - Generación del Z-Code de la dirección de entonces
si !isTypeBasicOrPointer(expr1[expr2]) un elemento de un array
GenExprAddress(expr1, temp) ||
El resultado final, también es del tipo expr1[expr2] con expr2 indicando el
GenExprValue(expr2,
desplazamiento hasta el elemento. temp+2) ||
6.3.2.3.1. Sentencias If
Existen dos tipos de sentencias if, las que contienen la alternativa else y las que no. La
generación del código es igual en ambos casos hasta el punto en el que se genera el
código de la alternativa else.
GenZ-CodeInstruction(read expr) :
GenExprAddress(expr, temp) ||
read expr.exprText
[Z-CodeList]
GenZ-CodeInstruction(write expr) :
si isTypeString(expr) entonces wris expr.exprText
sino
GenExprValue(expr, temp) ||
wrii expr.exprText
[Z-CodeList]
GenZ-CodeInstruction(writeln expr) :
si isTypeString(expr) entonces wris expr.exprText ||
wrln
sino
GenExprValue(expr, temp) ||
wrii expr.exprText ||
wrln
[Z-CodeList]
GenZ-CodeInstruction(new expr) :
GenExprAddress(expr, temp) ||
new expr.exprText
[Z-CodeList]
GenZ-CodeInstruction(free expr) :
GenExprValue(expr, temp) ||
free expr.exprText
[Z-CodeList]
Figura 6.31 - Generación del Z-Code para instrucciones de punteros (new y free)
New y free son instrucciones que trabajan con punteros. New se encarga de reservar
espacio para la expresión expr. Free se encarga de liberar el espacio reservado por new
para esa expresión.
6.3.2.3.7. Asignación
En función de estas dos cosas, deberemos generar la dirección o valor de las expr1 y
expr2. Si el tipo de las expresiones no es básico, deberemos realizar una copia usando el
aux_space.
Hasta el momento hemos visto los patrones de traducción usados para traducir del
lenguaje CL al lenguaje Z-Code. Para finalizar con la traducción de CL a Z-Code
veremos tres funciones auxiliares de las cuales se hace uso en varios de los patrones
mostrados a lo largo de este apartado.
La instrucción (temp := * temp)n-1, indica que realizaremos esta acción n-1 veces
siendo n el número de saltos hasta llegar al ámbito donde ha sido declarada la variable
o el parámetro ident.
popResult (temp):
top temp ||
pop
Figura 6.34 - Generación del Z-Code de recogida de resultados para llamadas a funciones
pushResult:
si isTypeBasicOrPointer( returnType(ident) ) entonces push 0
sino
temp := &aux_space ||
temp := temp + offsetAux_Space ||
push temp
Figura 6.35 - Generación del Z-Code de reserva de espacio para el resultado de funciones
Así pues, en la fase que se presenta a continuación veremos cómo se ha llevado a cabo
la traducción al lenguaje ensamblador IA-32, para arquitectura Intel.
Una ZCodeInstr está formada por un token o código de operador que indica la
instrucción que estamos tratando y por tres ZCodeOperand, dado que Z-Code es un
código de tres direcciones y como máximo, tendremos tres operandos.
Instrucciones con una sintaxis tipo ensamblador: COPY, REAI, WRII, WRIS,
WRLN, CALL, STOP, RETU, PUSH, POP, TOP, NEW, FREE.
Las instrucciones tipo ensamblador son fáciles de diferenciar entre sí, puesto que tienen
nombres diferentes. Las instrucciones de tipo asignación las diferenciaremos por el tipo
Antes de ver algunos ejemplos de traducción, analizaremos una de las cuestiones más
importantes, el mapeo de los registros temporales de Z-Code a registros físicos. Sólo se
han llevado a registros físicos los registros temporales de Z-Code y no variables. En Z-
Code existen un número ilimitado de temporales, pero a nivel de ensamblador, los
temporales ya no pueden existir puesto que todo dato debe estar ubicado en memoria o
en registros. El problema es que IA-32 tan sólo cuenta con ocho registros: eax, ebx, ecx,
edx, edi, esi, ebp y esp.
ebp es el base pointer, es decir, el puntero al inicio de la pila actual mientras que esp, es
el puntero a la posición actual de la pila. Por lo tanto, como podemos apreciar, en
realidad sólo contamos con seis registros donde poder almacenar datos.
A la hora de mapear los temporales, hemos de seguir un criterio. Algunos de los que nos
podemos plantear son:
…
Los registros eax y edx se han reservado para guardar resultados intermedios y
por ser registros destino de algunos cálculos que realiza el procesador como son
multiplicación y división.
o t0 ebx
o t1 ecx
o t2 edi
o t3 esi
A partir del temporal t4, el mapeo se hace en memoria (en la pila de ejecución).
La forma de calcular la posición exacta de memoria asociada a uno de estos
temporales es:
calc = totalSizeOfVars(actualScope)+(numTemp-3)*4;
mem = “-calc(%ebp)";
Es interesante destacar el hecho que en IA-32 una misma instrucción no puede contener
dos accesos a memoria. Los modos de direccionamiento con los que cuenta son:
translateInstruction( temp := a + b)
translateOperand(op1, temp) ||
translateOperand(op2, a) ||
translateOperand(op3, b) ||
addl op2, op3 ||
movl op3, op1
La diferencia entre los patrones es que en uno, los operandos son temporales mientras
que en el otro no. El hecho que los operandos sean temporales obliga a tener que cargar
primero el valor, por si se diera el caso que esos temporales han sido mapeados en
memoria.
Algo que es interesante remarcar en este punto es que a nivel de lenguaje ensamblador,
los desplazamientos de memoria son negativos. Si recordamos, en el capítulo 3,
tomamos como convención de notación que los desplazamientos eran positivos para
facilitar la comprensión. Así pues, veamos ahora cómo es realmente la gestión de
memoria y la activación de los bloques:
Como podemos observar esta imagen es justo la inversa a la otra mencionada. Por ello,
debemos interpretar que el valor retornado está en posiciones de memoria más altas que,
por ejemplo lo están las variables locales.
ReturnValue. Éste es el valor que retornan las funciones. Hay un espacio en pila
reservado para él. Se accede de forma similar a los parametros del
procedimiento
Etiquetas. Las etiquetas se traducen como simples strings, que es lo que ya son
en Z-Code.
.section .data
.section .rodata
/* INICIO PARTE OPCIONAL */
. comm res_reai, 4
.FMTwris:
.string %s
.FMTwrln:
.string \n
.FMTwrii:
.string %d
.FMTreai:
.string %d
.STRwrts_i:
.string CONST
/* FIN PARTE OPCIONAL */
.section .text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
call program
popl %ecx
popl %ebp
leal -4(%ecx), %esp
ret
.globl program
.type program, @function
program:
pushl %ebp
movl %esp, %ebp
subl $varsSize, %esp
A partir de la duración real de las tareas llevadas a cabo, se podrá estimar un coste total
aproximado del proyecto.
Veamos las tareas que se han llevado a cabo, divididas en tres grandes grupos:
Además de las tareas propias del proyecto, antes de empezar con su desarrollo, se
realizó un estudio de las nuevas herramientas a utilizar que proporciona ANTLR v3 y
del estado en el que se encontraba el trabajo, puesto que como ya se ha comentado en
varias ocasiones, el proyecto partía de un trabajo ya existente.
En general las tareas han requerido mayor duración, sobre todo las que se refieren al
segundo grupo, Diseño de la traducción y generación de código intermedio. La
definición del lenguaje Z-Code y la traducción hasta él han necesitado una gran
dedicación, puesto que fallos en esta parte provocarían errores de difícil corrección una
vez se hubiera finalizado la etapa.
Suponiendo que el trabajo realizado ha sido llevado a cabo en jornadas con un promedio
de dos horas y media diarias, el total de horas imputables al proyecto son:
Veamos ahora la relación de esas tareas con el perfil que las ha realizado:
De la figura anterior se pueden extraer el número de horas dedicadas por cada perfil:
Analista.
o 170 días x 2.5 horas/día = 425 horas.
o 425 horas x 50 euros/hora = 21.250 euros
Programador.
o 181 días x 2.5 horas/día = 452.5 horas.
o 452.5 horas x 30 euros/hora = 13.575 euros
Una vez concluido el proyecto podemos decir que este objetivo se ha cumplido
totalmente, ya que:
Esta ha sido la primera vez que he empezado un trabajo a partir de algo que
alguien ya había previamente desarrollado, teniendo que modificarlo y
ampliarlo. Esta experiencia conlleva un aprendizaje muy importante, ya que es
necesario comprender el trabajo existente antes de continuar. Además, ha sido
muy útil poner en práctica esta forma de trabajo puesto que es la más habitual
hoy en día, sobre todo en lo que se refiere al entorno empresarial.
Estudio de una nueva versión ANTLR v3. Previamente ya había trabajado con
versiones anteriores de ANTLR, pero la implementación de este compilador me
ha permitido, por un lado, profundizar mucho más en la herramienta ANTLR y
por otro, poner en práctica los nuevos mecanismos que ofrece, Parsing LL(*) y
StringTemplates.
Ampliar los conocimientos de IA-32. A pesar de que previamente también había
hecho uso del lenguaje ensamblador IA-32 en algunas asignaturas de la carrera,
nunca lo había llevado a una aplicación tan real como esta. Esto me ha
permitido profundizar mucho más en mis conocimientos sobre el lenguaje para
la arquitectura IA-32 y sobre el formato de cómo deben de ser sus ficheros.
Todas las alternativas presentadas se basan bien en ampliaciones, en mejoras del trabajo
ya realizado o en alternativas de implementación. Aunque mayoritariamente están
relacionadas con la fase de optimización de la representación en Z-Code, ya que como
se ha comentado, en este proyecto no se ha tratado estrictamente esta fase por no ser el
objeto de estudio, pero sí que es importante tener en cuenta que hoy en día es una de las
características más interesantes y deseables de un compilador.
pero aún podría ser mejor, por ejemplo, estudiando en mayor detalle el
uso de registros temporales.
Alternativas de implementación
Así pues, durante la realización del proyecto se ha tenido constancia de las posibles
ampliaciones, mejoras o alternativas de implementación con la idea de que sean
llevadas a cabo en futuros trabajos siendo este proyecto una buena base para empezar.
9. Acrónimos
Acrónimo Descripción
ANTLR ANother Tool for Language Recognition
AST Abstract Syntax Tree
BNF Backus–Naur Form
BP Base Pointer
CFG Context-Free Grammar
DFA Deterministic Finite Automaton
EBNF Extended Backus–Naur Form
IA-32 Intel Architecture, 32 bits
LL Left to right, Leftmost derivation
LR Left to right, Rightmost derivation
NDFA Non-Deterministic Finite Automaton
PC Program Counter
PCCTS Purdue Compiler Construction Tool Set
RI Representación Intermedia
SP Stack Pointer
10. Bibliografía
1. Aho, Alfred V., Sethi, Ravi y Ullman, Jeffrey D. Compiladores : principios,
técnicas y herramientas. s.l. : Addison-Wesley Iberoamericana, 1990. 0201629038.
3. Johnson, Stephen C. Unix Programmer's Manual Vol 2b. Yacc: Yet Another
Compiler-Compiler. [En línea] 1979. http://dinosaur.compilertools.net/yacc/.
12. Rivero, José Miguel, Godoy, Guillem y Padró, Lluís. Translation from T-Code to
IA32.
En este anexo vamos a mostrar todas las etapas del compilador a través de un
programa ejemplo en CL. El programa que utilizaremos es una versión del mergesort,
donde se ha hecho uso de memoria dinámica con el fin de comprobar el buen
funcionamiento del compilador. En la figura inferior se muestra la implementación en
CL de este programa.
program
vars
Res array[10] of int
cont int
aux int
pA array[10] of pointer to int
endvars
procedure merge(ref pm array[10] of pointer to int, val start int, val mid
int, val end int)
vars
R array[10] of pointer to int
p1 int
p2 int
aux int
i int
endvars
i := 0
p1 := start
p2 := mid
aux := start
while i < 10 do
new(R[i])
i := i+1
endwhile
i:=0
while i < start do
R[i]^ := pm[i]^
i := i+1
endwhile
while aux < end do
if p1 < mid and p2 < end then
if pm[p1]^ < pm[p2]^ then
R[aux]^ := pm[p1]^
p1 := p1 + 1
else
R[aux]^ := pm[p2]^
p2 := p2 + 1
endif
else
if p1 < mid then
R[aux]^ := pm[p1]^
p1 := p1 + 1
else
R[aux]^ := pm[p2]^
p2 := p2 + 1
endif
endif
aux := aux + 1
endwhile
i := end
while i < 10 do
R[i]^ := pm[i]^
i := i+1
endwhile
i := 0
while i < 10 do
pm[i]^ := R[i]^
i := i+1
endwhile
endprocedure
cont := 0
while cont < 10 do
read(aux)
Res[cont] := aux
cont := cont + 1
endwhile
cont := 0
while cont < 10 do
new(pA[cont])
pA[cont]^ := Res[cont]
cont := cont + 1
endwhile
writeln("ENTRADA")
cont := 0
while cont < 10 do
writeln(pA[cont]^)
cont := cont + 1
endwhile
mergesort(pA,0,10)
writeln("RESULTADO")
cont := 0
while cont < 10 do
Res[cont] := pA[cont]^
writeln(Res[cont])
cont := cont + 1
endwhile
endprogram
| + if
| + <
| | + ident(p1)
| | + ident(mid)
| + ListOfInstrs
| | + :=
| | | + ^
| | | | + []
| | | | + ident(R)
| | | | + ident(aux)
| | | + ^
| | | + []
| | | + ident(pm)
| | | + ident(p1)
| | + :=
| | + ident(p1)
| | + '+'
| | + ident(p1)
| | + int_const(1)
| + ListOfInstrs
| + :=
| | + ^
| | | + []
| | | + ident(R)
| | | + ident(aux)
| | + ^
| | + []
| | + ident(pm)
| | + ident(p2)
| + :=
| + ident(p2)
| + '+'
| + ident(p2)
| + int_const(1)
La siguiente etapa del compilador es la del Type Checking, donde se recibe como
entrada el árbol AST generado por la etapa de Parsing, y se decora con la información
relevante para cada nodo del árbol. En la siguiente figura tenemos el mismo árbol de la
Figura I.5 decorado.
| + if
| + < <bool>
| | + ident(p1) <int> [R]
| | + ident(mid) <int> [R]
| + ListOfInstrs
| | + :=
| | | + ^ <int> [R]
| | | | + [] <(pointer int)> [R]
| | | | + ident(R) <(array int_const(10) (pointer int))> [R]
| | | | + ident(aux) <int> [R]
| | | + ^ <int> [R]
| | | + [] <(pointer int)> [R]
| | | + ident(pm) <(array int_const(10) (pointer int))> [R]
| | | + ident(p1) <int> [R]
| | + :=
| | + ident(p1) <int> [R]
| | + '+' <int>
| | + ident(p1) <int> [R]
| | + int_const(1) <int>
| + ListOfInstrs
| + :=
| | + ^ <int> [R]
| | | + [] <(pointer int)> [R]
| | | + ident(R) <(array int_const(10) (pointer int))> [R]
| | | + ident(aux) <int> [R]
| | + ^ <int> [R]
| | + [] <(pointer int)> [R]
| | + ident(pm) <(array int_const(10) (pointer int))> [R]
| | + ident(p2) <int> [R]
| + :=
| + ident(p2) <int> [R]
| + '+' <int>
| + ident(p2) <int> [R]
| + int_const(1) <int>
A partir del árbol decorado, el compilador genera el código intermedio, en nuestro caso
el Z-Code. El Z-Code correspondiente al código de la Figura I.4 es el siguiente:
t0 := p1 < mid
if0 !t0 goto else_3
t1 := aux * 4
t0 := R[t1]
t2 := pm
t3 := p1 * 4
t2 := t2[t3]
t2 := *t2
*t0 := t2
p1 := p1 + 1
goto endif_3
else_3:
t1 := aux * 4
t0 := R[t1]
t2 := pm
t3 := p2 * 4
t2 := t2[t3]
t2 := *t2
*t0 := t2
p2 := p2 + 1
endif_3:
Por ejemplo, en caso de cometer un error sintáctico al programar (por ejemplo, no poner
el símbolo “:=” en una asignación) como en la Figura I.10, el compilador lo detecta y
envía el aviso de la Figura I.11 al programador.
Otro error típico que se comete en la programación es asignar dos variables de tipo
incorrecto. Esto es lo que sucede en el ejemplo de la Figura I.12. En este caso, la salida
del compilador sería la de la Figura I.13.
Definiremos la siguiente nomenclatura para poder describir de manera más clara las
instrucciones:
4. Instrucciones aritméticas
4.1. Instrucción de suma
INSTRUCCIÓN DESCRIPCIÓN
ADD %regA, %regB
ADD $inm, %reg La instrucción de suma recibe dos operandos, los suma, y
ADD mem, %reg
ADD %reg, mem
deposita el resultado en el lugar especificado por el segundo
ADD $inm, mem operando perdiendo el valor del segundo operando.
INSTRUCCIÓN DESCRIPCIÓN
SUB %regA, %regB
SUB $inm, %reg La instrucción de resta recibe dos operandos, resta op1 a op2,
SUB mem, %reg
SUB %reg, mem
y deposita el resultado en el lugar especificado por el segundo
SUB $inm, mem operando perdiendo el valor del segundo operando.
INSTRUCCIÓN DESCRIPCIÓN
NEG %reg Esta instrucción es equivalente a multiplicar el valor del
NEG mem
operando por -1. El resultado se devuelve en el mismo
operando.
INSTRUCCIÓN DESCRIPCIÓN
IMUL %regA, %regB La instrucción multiplica ambos operandos. Esta instrucción
hace uso de los registros %eax y %edx para devolver el
resultado.
INSTRUCCIÓN DESCRIPCIÓN
IDIV %reg Esta instrucción realiza la división. El dividendo es implícito
IDIV mem
y se encuentra en el registro %eax y %edx; y el divisor es el
otro operando. El resultado se recoge en %eax.
5. Instrucciones lógicas
5.1. Instrucción de conjunción
INSTRUCCIÓN DESCRIPCIÓN
AND %regA, %regB La conjunción bit a bit significa que ambos operandos deben
AND $inm, %reg tener el mismo tamaño y que cada bit del resultado se calcula
AND mem, %reg
AND %reg, mem
haciendo la conjunción de los correspondientes bits de ambos
AND $inm, mem operandos.
INSTRUCCIÓN DESCRIPCIÓN
OR %regA, %regB La disyunción exclusiva bit a bit significa que ambos
OR $inm, %reg operandos deben tener el mismo tamaño y que cada bit del
OR mem, %reg
OR %reg, mem
resultado se calcula haciendo la disyunción de los
OR $inm, mem correspondientes bits de ambos operandos
6. Instrucción de salto
6.1. Instrucción de salto incondicional
INSTRUCCIÓN DESCRIPCIÓN
JMP %reg Esta instrucción simplemente cambia la secuencia de
JMP mem ejecución del procesador que ejecuta la instrucción indicada
en la posición de memoria dada como operando.
INSTRUCCIÓN DESCRIPCIÓN
JE mem Esta instrucción ejecuta un salto si el valor de la posición de
memoria mem es igual a 0.
9. Instrucción de comparación
INSTRUCCIÓN DESCRIPCIÓN
OR %regA, %regB Esta instrucción realiza el cálculo op2 - op1.Su efecto se
OR $inm, %reg refleja únicamente en los flags de la palabra de estado. Estos
OR mem, %reg
OR %reg, mem
flags se pueden utilizar para, por ejemplo, cambiar el flujo de
OR $inm, mem ejecución mediante una instrucción de salto condicional.