Crea tu propio compilador – Parte 7 – Empezando a generar código

En el artículo anterior iniciamos la implementación del reconocimiento de variables agregando las variables  y estructuras necesarias que nos servirán de base para el objetivo final de implementar el reconocimiento de variables.

En este artículo repasaremos, brévemente, algunos conceptos importantes del ensamblador, los cuales usaremos en este primer nivel que será poder reconocer la declaración de variables, y empezaremos  a generar verdadero código ensamblador Intel, que es una de las novedades de este artículo.

Generación de Código

En el diseño de un compilador, es necesario siempre incluir el módulo llamado Generador de Código, que es el que lógicamente se encarga de generar el código que será el producto final  del compilador (código objeto, código binario, lenguaje intermedio, código de alto nivel, …).

La generación de código es un tema algo oscuro y complejo. En comparación con el análisis léxico y sintáctico, existe poca documentación, estándares, librerías y “frameworks”.

Uno de los motivos podría ser que, a diferencia de un lexer o parser, el generador de código (al menos para compiladores a código nativo) está muy amarrado a la arquitectura de la máquina para la cual compila, y por ello se pueden tener diseños muy diferentes en cada caso.

La necesidad de poder generar código para diferentes arquitecturas, muchas veces motiva a la creación de un código intermedio, más sencillo, que luego puede ser traducido, con menos esfuerzo, a diferentes arquitecturas. No confundir este proceso con compiladores como Java o .NET que compilan a un código intermedio como producto final. Los compiladores a código nativo, pueden tener representaciones intermedias, pero esta es solo temporal, y desaparece cuando se obtiene el código binario/objeto.

Existen en la actualidad “frameworks ” como LLVM que ayudan en la creación de generadores de código, pero son sistemas complejos y no siempre se adapta a la arquitectura que vamos a manejar.

La fuente de información (el alimento) para un generador de código suele ser el árbol de sintaxis o alguna estructura similar.

Nuestro compilador, Titan, implementará la generación de código de una forma más sencilla. Estará integrado en el mismo “parser”, de modo que mientras vayamos identificando a los correspondientes elementos sintácticos, iremos escribiendo código ensamblador en el archivo de salida.

Variables en Ensamblador

En estos artículos, como ya indicamos al inicio, no enseñaremos a programar en ensamblador, sino que asumiremos que el lector tiene ya conocimientos al respecto. En caso contrario, hay disponible suficiente información en la red, como para empaparse del tema. Aquí solo haremos un breve repaso:

Comenzaremos repasando primero, cómo se hace la declaración de variables  en lenguaje Intel x86 de 32 bits.

La sintaxis en la declaración de variables en ensamblador (y en general todo el lenguaje ensamblador) depende no solo de la marca (Intel) y la arquitectura destino (x86 de 32 bits), sino que también dependerá del ensamblador que estemos usando.

En el mundo de Intel existen dos corrientes de ensambladores, ya bastante establecidos:

  • Sintaxis AT&T: Usa prefijos para los registros y las direcciones de memoria, además el operando destino va siempre al final: movl    $1,%eax
  • Sintaxis Intel: No usa prefijos para registros o memoria y el operando destino va siempre al inicio: mov eax,1

En cuanto a las declaraciones de variables, hay también diferencias entre ambas sintaxis, pero aquí nos centraremos en la de Intel, porque estaremos usando el ensamblador MASM32, que trabaja precisamente en la sintaxis Intel.

Continuando con nuestro repaso de ensamblador, la declaración de variables se hace en sección DATA:

.DATA
num1 DB  5
num2 DW 5000
num3 DD 500000
arre1 DB 10 DUP(?)

Este bloque de código define variables de diverso tamaño, como posiciones de memoria en la sección de datos de nuestro programa ensamblador. Existen diversas instrucciones para declarar espacio de memoria:

DB Reserva 1 byte

DW Reserva 2 bytes

DD Reserva 4 bytes

DQ Reserva 8 bytes

DT Reserva 10 bytes

Así, la instrucción:

num1  DW 5000

Indica que se debe reservar 2 bytes de memoria de datos, y se debe inicializar esos datos con el valor 5000.

La instrucción DUP, indica que se debe reservar múltiples elementos de memoria. Por ejemplo, la instrucción:

arre1 DB 10 DUP(?)

Indica que se deben reservar 10 bytes de memoria y no se pide inicializar estos bytes a algún valor en particular. Esta notación no será útil cuano implementemos arreglos.

Generando ensamblador

Ahora que sabemos cómo son las instrucciones que debemos generar, nos centraremos en darle a nuestro compilador, la posibilidad de generar código ensamblador real.

Esto se sencillo porque un programa en ensamblador es solo un archivo de texto con las instrucciones apropiadas.

Necesitamos entonces incluir instrucciones al programa principal, para crear un archivo de texto a modo de plantilla al que podamos ir agregando las instrucciones que vayamos generando.

La platilla o esquema general de un programa en ensamblador que implementaremos será bastante simple:

    include \masm32\include\masm32rt.inc
    .data

    .code

    end

La sección “.code” es la que incluirá a código ejecutable a diferencia de la sección “.data” que solo contendrá declaraciones. Este programa bastante sencillo puede ensamblarse sin errores, pero fallará en el enlazado, porque se espera un punto de entrada para el programa principal. Por eso nuestra plantilla final será un poco diferente:

    include \masm32\include\masm32rt.inc
    .data
    .code
start:

end start

Que mantiene la misma idea de simplicidad. Desde luego que los programas serán más complejos que esta simple plantilla, que no hace nada, pero debemos tomar una base desde donde comenzar.

Para generar esta plantilla debemos modificar el bloque principal de nuestro compilador. Una solución simple podría ser algo como esto:

begin
  //Abre archivo de entrada
  AssignFile(inFile, 'input.tit');
  Reset(inFile);
  //Abre archivo de salida
  AssignFile(outFile, 'input.asm');
  Rewrite(outFile);
  //Inicia banderas
  nVars := 0;  //Número inicial de variables
  srcRow := 0;
  //Escribe encabezado de archivo
  WriteLn(outFile, '    include \masm32\include\masm32rt.inc');
  WriteLn(outFile, '    .data');
  //Aquí procesaremos las variables
  WriteLn(outFile, '    .code');
  WriteLn(outFile, 'start:');
  MsjError := '';
  NextLine;  //Para hacer la primera lectura.
  while EndOfFile<>1 do begin
    NextToken;
    writeln(srcToken);
  end;
  WriteLn(outFile, '    exit');
  WriteLn(outFile, 'end start');
  CloseFile(outFile);
  CloseFile(inFile);
  ReadLn;
end.

Este código generará la plantilla que buscamos (Revisar el archivo ‘input.asm’). Y aunque se trata de un código vacío, es en realidad el primer código, en ensamblador, que genera nuestro compilador.

Pero debemos considerar que nuestra definición de lenguaje permite declarar variables en cualquier punto, y no solo en el inicio (El lector experimentado habrá notado rápidamente el problema). Entonces esto plantea un problema adicional. Afortunadamente el MASM32 permite muchas secciones “.data” y “.code”. Pero tampoco queremos crear un bloque “.data” por cada variable que declaremos, sino que en lo posible se agrupen en un solo bloque “.data”.

Para subsanar este detalle, debemos hacer uso de un par de banderas adicionales:

 InVarSec : integer; 
 FirstCode: integer;

La primera nos servirá para saber si en el proceso de exploración de tokens, nos encontramos dentro de una sección “.data”. Así sabremos que debemos abrir una sección “.code” si vamos a generar instrucciones ASM ejecutables.

La otra bandera nos servirá para saber si es la primera vez que estamos generando código. Necesitamos esto para saber donde incluir la entrada “start:” del procedimiento principal de nuestro código ASM generado.

Nuestro código del programa principal, quedará entonces así:

begin
  //Abre archivo de entrada
  AssignFile(inFile, 'input.tit');
  Reset(inFile);
  //Abre archivo de salida
  AssignFile(outFile, 'input.asm');
  Rewrite(outFile);
  //Inicia banderas
  nVars := 0;   //Número inicial de variables
  srcRow := 0;  //Número de línea
  FirstCode := 1;  //Inicia bandera
  //Escribe encabezado de archivo
  WriteLn(outFile, '    include \masm32\include\masm32rt.inc');
  WriteLn(outFile, '    .data');
  WriteLn(outFile, '    _regstr DB 256 dup(0)');
  InVarSec := 1;    //Estamos en la sección de variables
  MsjError := '';
  NextLine;  //Para hacer la primera lectura.
  while EndOfFile<>1 do begin
    NextToken;
    writeln(srcToken);
    if srcToktyp = 0 then begin
      //Salto de línea, no se hace nada
    end else if srcToktyp = 1 then begin
      //Espacio, no se hace nada
    end else if srcToktyp = 5 then begin
      ExtractComment; //Comentario
    end else if srcToken = 'var' then begin
      //Es una declaración
      if InVarSec = 0 then begin
        //Estamos fuera de un bloque de variables
        WriteLn(outFile, '    .data');
        InVarSec := 1;  //Fija bandera
      end;
      //*** Aquí procesamos variables

    end else begin
      //Debe ser una instrucción. Aquí debe empezar la sección de código
      if InVarSec = 1 then begin
        //Estamos dentro de un blqoue de variables
        WriteLn(outFile, '    .code');
        InVarSec := 0;  //Fija bandera
      end;
      if FirstCode=1 then begin
        //Primera instrucción
        WriteLn(outFile, '    .code');
        WriteLn(outFile, 'start:');
        FirstCode := 0;  //Activa bandera
      end;
      //**** Aquí procesamos instrucciones.

    end;
  end;
  if MsjError<>'' then begin
    WriteLn('Line: ', srcRow,',', idxLine, ': ', MsjError);
  end;
  //Terminó la exploración de tokens
  if FirstCode = 1 then begin
    //No se han encontrado instrucciones. Incluimos encabezado de código en ASM.
    WriteLn(outFile, '    .code');
    WriteLn(outFile, 'start:');
  end;
  WriteLn(outFile, '    exit');
  WriteLn(outFile, 'end start');
  CloseFile(outFile);
  CloseFile(inFile);
  WriteLn('<<< Pulse <Enter> para continuar >>>');
  ReadLn;
end.

Vemos varios detalles nuevos en este nuevo código. Primero está la declaración obligatoria de una variable llamada “_regstr”, en la sección de datos. Esta variable se usará como registro de trabajo para cadenas. El concepto de registro de trabajo lo veremos más adelante, pero a grandes rasgos, es el lugar en donde se almacena un valor para realizar alguna operación sobre él.

Otro detalle nuevo que vemos en este código, es que se ha agregado una rutina de identificación, para el tipo de token, en el bucle principal. Esta rutina utiliza el valor almacenado en “srcToken” para determinar el tipo de token.

Nuestra rutina de identificación de tokens considera lo siguiente.

  • Si “srcToken” es 0, se trata de un salto de línea.
  • Si “srcToken” es 1, se trata de un espacio en blanco.
  • Si “srcToken” es 5, se trata de un comentario.
  • Si el token es “var”, se trata de la declaración de una variable.
  • Si no es ninguna de las anteriores, se asume que se trata de una instrucción común.

Este método de identificación es bastante sencillo, pero nos servirá bien en esta primera etapa de nuestro software. Recordar que los valores de “srcToken” se definieron en un artículo anterior.

Observar que aún no hemos implementado las rutinas para el tratamiento de declaración de variables o de instrucciones, pero ya podríamos escribir un programa cualquiera en “input.tit” y veremos que nos genera un código ASM, vacío pero funcional en “input.asm”.

La bandera FirstCode es necesaria para poder saber cuando el compilador está generando el primer código ,para incluir el bloque “start:”. También sirve al final del bucle principal para saber cuando no se ha generado ninguna instrucción y poder dar al archivo ASM el formato adecuado.

Si la forma como trabaja este código le resulta confuso, lo mejor podría ser que trate usted mismo de implementar su propia solución, y probablemente llegará a un esquema parecido a este. De todas formas, plantear soluciones alternativas es siempre una buena forma de aprender.

Como siempre, el código fuente completo de este artículo, se encuentra en mi GitHub.

En el siguiente artículo veremos finalmente como implementar el procesamiento de variables.

 

Puntuación: 0 / Votos: 0

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *