Compilador: generación de código intermedio

Un código fuente se puede traducir directamente a su código de máquina de destino, entonces, ¿por qué necesitamos traducir el código fuente a un código intermedio que luego se traduce a su código de destino? Veamos las razones por las que necesitamos un código intermedio.

  • Si un compilador traduce el lenguaje fuente a su lenguaje de máquina de destino sin tener la opción de generar código intermedio, entonces para cada nueva máquina, se requiere un compilador nativo completo.

  • El código intermedio elimina la necesidad de un nuevo compilador completo para cada máquina única al mantener la misma porción de análisis para todos los compiladores.

  • La segunda parte del compilador, síntesis, se cambia según la máquina de destino.

  • Resulta más fácil aplicar las modificaciones del código fuente para mejorar el rendimiento del código aplicando técnicas de optimización de código en el código intermedio.

Representación intermedia

Los códigos intermedios se pueden representar de diversas formas y tienen sus propios beneficios.

  • High Level IR- La representación del código intermedio de alto nivel está muy cerca del propio lenguaje fuente. Se pueden generar fácilmente a partir del código fuente y podemos aplicar fácilmente modificaciones de código para mejorar el rendimiento. Pero para la optimización de la máquina de destino, es menos preferido.

  • Low Level IR - Este está cerca de la máquina de destino, lo que lo hace adecuado para la asignación de registros y memoria, selección de conjuntos de instrucciones, etc. Es bueno para optimizaciones dependientes de la máquina.

El código intermedio puede ser específico del idioma (por ejemplo, código de bytes para Java) o independiente del idioma (código de tres direcciones).

Código de tres direcciones

El generador de código intermedio recibe información de su fase predecesora, el analizador semántico, en forma de árbol de sintaxis anotado. Ese árbol de sintaxis se puede convertir en una representación lineal, por ejemplo, notación de sufijo. El código intermedio tiende a ser un código independiente de la máquina. Por lo tanto, el generador de código asume que tiene un número ilimitado de almacenamiento de memoria (registro) para generar código.

Por ejemplo:

a = b + c * d;

El generador de código intermedio intentará dividir esta expresión en subexpresiones y luego generará el código correspondiente.

r1 = c * d;
r2 = b + r1;
a = r2

r se utiliza como registros en el programa de destino.

Un código de tres direcciones tiene como máximo tres ubicaciones de direcciones para calcular la expresión. Un código de tres direcciones se puede representar de dos formas: cuádruples y triples.

Cuádruples

Cada instrucción en la presentación cuádruple se divide en cuatro campos: operador, arg1, arg2 y resultado. El ejemplo anterior se representa a continuación en formato cuádruple:

Op arg 1 arg 2 resultado
* C re r1
+ segundo r1 r2
+ r2 r1 r3
= r3 una

Triples

Cada instrucción en presentación de triples tiene tres campos: op, arg1 y arg2. Los resultados de las sub-expresiones respectivas se indican mediante la posición de expresión. Los triples representan similitud con DAG y árbol de sintaxis. Son equivalentes a DAG mientras representan expresiones.

Op arg 1 arg 2
* C re
+ segundo (0)
+ (1) (0)
= (2)

Los triples enfrentan el problema de la inmovilidad del código durante la optimización, ya que los resultados son posicionales y cambiar el orden o la posición de una expresión puede causar problemas.

Triples indirectos

Esta representación es una mejora sobre la representación de triples. Utiliza punteros en lugar de posición para almacenar resultados. Esto permite a los optimizadores reposicionar libremente la subexpresión para producir un código optimizado.

Declaraciones

Se debe declarar una variable o procedimiento antes de que pueda utilizarse. La declaración implica la asignación de espacio en la memoria y la entrada de tipo y nombre en la tabla de símbolos. Un programa puede codificarse y diseñarse teniendo en cuenta la estructura de la máquina de destino, pero no siempre es posible convertir con precisión un código fuente a su idioma de destino.

Tomando el programa completo como una colección de procedimientos y subprocedimientos, es posible declarar todos los nombres locales al procedimiento. La asignación de memoria se realiza de manera consecutiva y los nombres se asignan a la memoria en la secuencia en que se declaran en el programa. Usamos la variable de compensación y la establecemos en cero {compensación = 0} que denota la dirección base.

El lenguaje de programación de origen y la arquitectura de la máquina de destino pueden variar en la forma en que se almacenan los nombres, por lo que se utiliza el direccionamiento relativo. Mientras que al primer nombre se le asigna memoria a partir de la ubicación de memoria 0 {offset = 0}, al siguiente nombre declarado más tarde, se le debe asignar memoria junto al primero.

Example:

Tomamos el ejemplo del lenguaje de programación C donde a una variable entera se le asignan 2 bytes de memoria y a una variable flotante se le asignan 4 bytes de memoria.

int a;
float b;

Allocation process:
{offset = 0}

   int a;
   id.type = int
   id.width = 2

offset = offset + id.width 
{offset = 2}

   float b;
   id.type = float
   id.width = 4
   
offset = offset + id.width 
{offset = 6}

Para ingresar este detalle en una tabla de símbolos, se puede utilizar un procedimiento de ingreso . Este método puede tener la siguiente estructura:

enter(name, type, offset)

Este procedimiento debe crear una entrada en la tabla de símbolos, para el nombre de la variable , con su tipo establecido en tipo y desplazamiento de dirección relativa en su área de datos.