Crea tu propio compilador – Cap. 13 – Asignaciones

13.1 Introducción

En el capítulo anterior creamos la primera versión de nuestro analizador-evaluador de expresiones y pudimos compilar, por primera vez, expresiones aritméticas y de cadena.

Pero, aunque podemos compilar expresiones; estas expresiones en sí, no constituyen instrucciones válidas en nuestro lenguaje de programación y por ello tuvimos que hacer un «hacking» en el compilador para las pruebas.

En este capítulo, por primera vez, implementaremos el procesamiento de una verdadera instrucción de nuestro lenguaje Titan, que será la instrucción de asignación. Esta será la primera vez, que generaremos código, verdaderamente ejecutable.

13.2 Teoría de Compiladores

Código de tres direcciones

Aunque existen muchas representaciones intermedias, se menciona esta, porque es una de las más usadas y estudiadas. Se trata de un lenguaje bastante compacto que se usa para representar expresiones, usando asignaciones con expresiones de uno o dos operandos.

Este lenguaje es bastante usado porque:

  • Debido a su simplicidad, es susceptible de un análisis más detallado y completo.
  • Se presta bien para la aplicación de algoritmos de optimización.
  • Se adapta bien a la forma en que trabajan internamente la mayoría de CPU, que consiste en realizar operaciones de dos operandos que, en la práctica, son registros internos de la CPU.

No existe un estándar universal para la notación del Código de tres direcciones o TAC (Three Address Code) pero se suele representar como simples asignaciones de un lenguaje de programación:

t1 := a + 1
t2 := t1 * 2
c := t2

Este código en tres direcciones podría ser la representación de la expresión:

c := ( a + 1 ) * 2

Las características principales del TAC son

  • Sus instrucciones son principalmente asignaciones. Esto se debe a que se usan para representar expresiones.
  • Tiene uno o dos operandos al lado derecho de la asignación. Lo que hace que en total no superen tres operandos.
  • Suele trabajar con variables temporales.

Una particularidad del TAC es que permite hacer el seguimiento de las operaciones realizadas en la evaluación de expresiones con el objetivo de realizar simplificaciones u optimizaciones.

Una vez que el código esté optimizado, es fácil convertir este código a código ejecutable porque el código de tres direcciones se asemejan mucho a las instrucciones ensamblador.

El TAC se puede obtener de alguna representación intermedia previa como puede ser el árbol de sintaxis.

Lenguaje de Transferencia de Registros

Otra de las representaciones intermedias que está muy cerca al código ensamblador es el llamado Lenguaje de Transferencia de Registros o Register Transfer Language (RTL) en su versión inglesa.

El RTL constituye una abstracción a un lenguaje portable para definir las operaciones entre los registros internos de una CPU. Su existencia está motivada por el hecho de que la arquitectura interna de una CPU (que define la cantidad de registros, su tipo y su uso) es muy variada para cada fabricante y cada modelo, de modo que tener un lenguaje muy cercano al hardware, como lo es RTL, pero sin ser específico de alguna CPU en particular, ayuda en generar código estándar que puede luego adaptarse fácilmente a arquitecturas diversas.

El RTL es reconocido por ser una de las representaciones intermedias que usa el famoso compilador GCC y que le ayuda en la generación de código óptimo y con soporte para diversas arquitecturas.

La sintaxis de RTL es similar a la declaración de expresiones anidadas entre paréntesis, como el código que se muestra a continuación:

(set (reg:SI 20)
     (plus:SI (reg:SI 21
              (const_int -1))))

Este código podría representar a la instrucción: x = y – 1

La estructura de esta instrucción se ve mejor como una lista anidada de elementos:

(set (reg:SI 20)
     (plus:SI (reg:SI 21
                   (const_int -1)
              )
     )
)

Esta instrucción tiene la forma:

(set (destino) (origen))

El RTL es un lenguaje minimalista y de bajo nivel, que a solo trabaja con enteros y cadenas.

Al igual que el Three Address Code el RTL permite implementar optimizaciones, pero dichas optimizaciones llegan hasta el nivel de operaciones entre registros.

La descripción detallada del RTL usado en GCC, aunque escaso de ejemplos, se encuentra aquí.

13.3 Implementando asignaciones

Las asignaciones son probablemente las instrucciones más usadas dentro de un lenguaje de programación. Sirven para inicializar variables, para modificar variables  o para guardar el resultado de expresiones.

Muchos lenguajes, como el C o C++, consideran a las asignaciones como simples expresiones que hacen uso del operador de asignación (=) y que pueden también devolver valores. De allí que se pueda hacer códigos como:

x = (y = 1);

Sin embargo otros lenguajes, como Pascal, consideran a las asignaciones como instrucciones especiales, que no devuelven valores, es decir, que no se manejan como expresiones.

En nuestro lenguaje Titan, consideraremos a las expresiones como instrucciones especiales así que no formarán parte de una expresión como tal. Esto ayudará a simplificar el análisis de expresiones.

Las asignaciones son de la forma:

a = b

Para el reconocimiento de este tipo de instrucciones, haremos uso de la rutina FindVariable() que ya habíamos creado en el Capítulo 10.

Ahora que tenemos como identificar a una variable, nos toca implementar el procedimiento para realizar la asignación en sí. Una instrucción de asignación tener la forma:

<variable_destino> = <expresión>;

Esta es la forma más simple de la asignación porque no considera el caso de asignación a arreglos.

Como ya tenemos implementado nuestro procedimiento para leer expresiones y dejarlas en los registros de trabajo, no será difícil implementar la asignación.

Un primer acercamiento a una rutina que procese una instrucción de asignación puede ser:

procedure ProcessAssigment();
begin
  FindVariable();   //Actualiza "curVarName", "curVarType" y "curVarArSiz".
  if curVarName = '' then begin
    MsjError := 'Se esperaba variable: ' + srcToken;
    exit;
  end;
  NextToken(); //Toma nombre de variable
  TrimSpaces();
  //Preservamos datos de variable destino
  asgVarName := curVarName;
  asgVarType := curVarType;
  asgVarArSiz := curVarArSiz;
  TrimSpaces();
  if srcToken<>'=' then begin
    MsjError := 'Se esperaba "=".';
    exit;
  end;
  NextToken();  //Toma "="
  //Evalúa expresión
  EvaluateExpression();
  if MsjError<>'' then begin
    exit;
  end;
  //Codifica la asignación
  if resType = 1 then begin
    //Integer
    if asgVarType<>1 then begin
      MsjError := 'No se puede asignar un entero a esta variable.';
      exit;
    end;
    asmline('mov ' + asgVarName + ', eax');
  end else begin
    //String
    if asgVarType<>2 then begin
      MsjError := 'No se puede asignar una cadena a esta variable.';
    end;
    //<variable> <- Registro
    asmline('invoke szCopy, addr _strA, addr '+ asgVarName);
  end;
end;

El funcionamiento de esta rutina consiste en leer información sobre la variable destino, leer la expresión y copiar el valor leído (de los registros de trabajo EAX/_strA) en la variable destino de acuerdo a su tipo. La asignación de números es fácil porque se hace con instrucciones simples de transferencia MOV, pero la asignación de cadenas se implementa llamando a la subrutina szCopy, que nos proporciona MASM32, y que permite mover bytes de una dirección a otra.

Todo este proceso incluye las validaciones necesarias, que ayudan en asegurar que el código generado sea seguro.

Para el funcionamiento de esta nueva rutina, vamos a usar unas variables globales que nos permitan guardar información sobre la variable a la que queremos asignar algún valor.

//Datos de variable a asignar
var asgVarName : string;
var asgVarType : integer;
var asgVarArSiz: integer;

Como ya podemos procesar asignaciones, es hora de modificar nuevamente a ProcessBlock() para darle, ahora sí, la posibilidad de reconocer una verdadera instrucción y no solo simples operandos o expresiones.

El código de ProcessBlock() quedaría como se indica a continuación:

procedure ProcessBlock;
//Procesa un bloque de código.
begin
  while EndOfBlock()<>1 do begin
    if srcToktyp = 2 then begin
      //Es un identificador, debe ser una asignación
      ProcessAssigment();
      if MsjError<>'' then begin break; end;
    end else begin
      MsjError := 'Instrucción desconocida: ' + srcToken;
      break;
    end;
    //Verifica delimitador de instrucción
    Capture(';');
    if MsjError<>'' then begin exit; end;
  end;
end;

13.4 Probando asignaciones

Con este cambio, ya hemos dotado a nuestro compilador de la capacidad para procesar las primeras instrucciones formales. Probemos con el siguiente código de ejemplo:

Figura 13.1 – Asignaciones de número y cadena

¡Eureka! Después de más de 12 Capítulos y de casi un millar de líneas de código, por fin hemos generado código para las primeras instrucciones verdaderas de nuestro lenguaje Titan.

El trabajo de un compilador puede parecer magia, pues se está generando código a partir de código. Pero si analizamos el comportamiento del compilador, veremos que no hay nada de mágico en su trabajo.  Solo consiste en generar correctamente, las instrucciones apropiadas, para la situación apropiada.

Quizás alguien se pregunte ¿Y cómo sabe el compilador que debe generar código para una asignación?

La clave está en ProcessBlock(), llamado por ParserProgram(), que, cuando encuentra un identificador, asume que estamos ante una instrucción de asignación y llama a la rutina ProcessAssigment() quien hace el análisis y la generación de código apropiada para la asignación.

En la forma actual en que hemos dejado a ProcessBlock(), ya no nos permitirá poner expresiones como instrucciones, sino que solo se aceptarán asignaciones. Si, por ejemplo, escribimos nuestro programa la siguiente expresión «x+1», veremos que nuestro compilador, se quejará con un mensaje de error:

Figura 13.2 – Detección de error en expresión

El mensaje de error es preciso y además indica correctamente la posición. Lo que indica el error es que se espera que el operador, después de la «x», sea el operador de asignación, o sea, que esperaba una expresión de tipo «x = …».

Como nuestra rutina ProcessAssigment() hace uso de EvaluateExpression(), también es capaz de generar código para operaciones más complejas. Probemos, por ejemplo con una expresión de suma:

Figura 13.3 – Asignación de expresiones

En este caso se esta generando el código en dos partes. La primera parte evalúa «x + 1» y deja el resultado en EAX:

mov eax, x
mov ebx, eax
mov eax, 1
add eax, ebx

La otra parte hace la asignación del resultado a la variable destino:

mov x, eax

Es claro que no es la forma más optima de realizar la tarea, pero no olvidar que estamos apuntando a la simplicidad del compilador.

Desgraciadamente, nuestro evaluador de expresiones EvaluateExpression() solo soporta expresiones simples de uno o dos operandos, como se indicó en el Capítulo 12.

Como EvaluateExpression(), soporta concatenación de cadenas; también podemos realizar la asignación de una concatenación de cadenas:

Figura 13.4 – Concatenación de cadenas

La concatenación se hace llamando a la macro szCatStr, que es quien hace el trabajo pesado de ir juntando los bytes de las variables cadena.

Aunque nuestro compilador ha generado código para concatenar dos cadenas que son constantes, un compilador moderno es bastante astuto como para poder concatenar estas cadenas en una sola cadena constante sin necesidad de generar código. Esta es una de las características de las que carece nuestro compilador, y es producto del nivel de simplificación que le estamos aplicando.

13.5 Asignación a arreglos

Para terminar de colocar la cereza en el pastel de nuestra rutina ProcessAssigment(), necesitamos implementar el soporte para la asignación de arreglos, que no es gran complicación excepto por el hecho de tener que direccionar adecuadamente la posición en la variable destino.

Las asignaciones a un arreglo se pueden reconocer fácilmente porque tienen la siguiente forma:

<variable_destino>[índice] = <expresión>;

Pero también se puede usar la información de la tabla de variables que nos devuelve FindVariable() cuando leemos la variable destino. El valor que nos interesa es «curVarArSiz» y este será el punto de comprobación para detectar si estamos ante una asignación a un arreglo.

El código siguiente muestra las líneas que necesitamos agregar a nuestro código para darle soporte a la asignación a arreglos:

procedure ProcessAssigment();
begin
  FindVariable();   //Actualiza "curVarName", "curVarType" y "curVarArSiz".
  if curVarName = '' then begin
    MsjError := 'Se esperaba variable: ' + srcToken;
    exit;
  end;
  NextToken(); //Toma nombre de variable
  TrimSpaces();
  //Preservamos datos de variable destino
  asgVarName := curVarName;
  asgVarType := curVarType;
  asgVarArSiz := curVarArSiz;
  if asgVarArSiz>0 then begin //La variable destino es un arreglo.
    if srcToken = '[' then begin
      ReadArrayIndex();  //Actualiza "idxConVal" e "idxVarNam".
      if MsjError<>'' then begin exit; end;
      //Actualiza variables de arreglo.
      asgIdConVal := idxConVal;
      asgIdVarNam := idxVarNam;
    end else begin  //Sin corchetes
      //Puede ser asignación de arreglos, pero no lo implementamos por ahora.
      MsjError := 'Se esperaba "[".';
      exit;
    end;
  end;
  TrimSpaces();
  if srcToken<>'=' then begin
    MsjError := 'Se esperaba "=".';
    exit;
  end;
  NextToken();  //Toma "="
  //Evalúa expresión
  EvaluateExpression();
  if MsjError<>'' then begin
    exit;
  end;
  //Codifica la asignación
  if resType = 1 then begin
    //Integer
    if asgVarType<>1 then begin
      MsjError := 'No se puede asignar un entero a esta variable.';
      exit;
    end;
    if asgVarArSiz=0 then begin  //Sin arreglo
      asmline('mov ' + asgVarName + ', eax');
    end else begin  //Asignación a Arreglo
      if asgIdVarNam<>'' then begin //Indexado por variable
        asmline('mov esi, DWORD PTR [' + asgIdVarNam + ']');
      end else begin          //Indexado por constante
        asmline('mov esi, ' + asgIdConVal);
      end;
      asmline('mov DWORD PTR [' + asgVarName + ' + 4*esi], eax');
    end;

  end else begin
    //String
    if asgVarType<>2 then begin
      MsjError := 'No se puede asignar una cadena a esta variable.';
    end;
    //<variable> <- Registro
    if asgVarArSiz=0 then begin  //Sin arreglo
      asmline('invoke szCopy, addr _strA, addr '+ asgVarName);
    end else begin  //Asignación a Arreglo
      if asgIdVarNam<>'' then begin //Indexado por variable
        //asmline('mov eax, DWORD PTR [' + asgIdVarNam + ']');
        asmline('mov esi, ' + asgIdVarNam);
        asmline('shl esi, 8');  //Multiplica por 256
        asmline('add esi, offset '+ asgVarName);
        asmline('invoke szCopy, addr _strA, esi');
      end else begin          //Indexado por constante
        asmline('invoke szCopy, addr _strA, addr '+ asgVarName + ' + 256*' + asgIdConVal);
      end;
    end;
  end;
end;

Para trabajar con este rutina, necesitamos un grupo más de variables globales que nos deben dar información sobre la variable a la que vamos a asignar algún valor:

//Datos de variable a asignar
var asgIdVarNam: string; //Nombre de índice variable
var asgIdConVal: string; //Valor de índice constante. Por practicidad, como cadena.

Para la lectura del índice se usa el procedimiento ReadArrayIndex() que ya habíamos creado y que nos devuelve información del índice que nosotros guardaremos en «asgIdConVal» y «asgIdVarNam».

El direccionamiento de la variable destino, para el caso en que sea de tipo arreglo, usa el mismo direccionamiento que las instrucciones para leer operandos arreglos, tal como se describió en el Capítulo 11.

Para concluir, pongamos a prueba una instrucción de asignación usando arreglos:

Figura 13.5 – Asignación a arreglos

La asignación a un elemento de un arreglo se hace tal cual se esperaría para un tamaño de ítem de 256 bytes.

Y con esta demostración, concluimos esta entrega. Por ahora no podemos ver la salida en pantalla de las acciones realizadas, pero en la siguiente entrega, escribiremos una rutina que nos permitirá mostrar el resultado de nuestras operaciones, de modo que podremos escribir nuestro primer «Hola mundo».


4 comentarios

Dejar una contestacion

Tu dirección de correo electrónico no será publicada.


*