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:
¡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:
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:
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:
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:
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».
¿Cómo citar este artículo?
- En APA: Hinostroza, T. (25 de septiembre de 2019). Crea tu propio compilador – Cap. 13 – Asignaciones. Blog de Tito. https://blogdetito.com/2019/09/25/crea-tu-propio-compilador-cap-13/
- En IEEE: T. Hinostroza. (2019, septiembre 25). Crea tu propio compilador – Cap. 13 – Asignaciones. Blog de Tito. [Online]. Available: https://blogdetito.com/2019/09/25/crea-tu-propio-compilador-cap-13/
- En ICONTEC: HINOSTROZA, Tito. Crea tu propio compilador – Cap. 13 – Asignaciones [blog]. Blog de Tito. Lima Perú. 25 de septiembre de 2019. Disponible en: https://blogdetito.com/2019/09/25/crea-tu-propio-compilador-cap-13/
Excelente!! Espero el próximo articulo.
Is the next chapter of this good tutorial available ?
No yet. I’ll be reviewing the last posts before publish the part 12.
… mañana …