sábado, 16 de noviembre de 2024

ZB2SB. Conversor de Basic de los ZX al Super Basic del QL (III): Analizadór léxico segunda parte: Extractor de Tokens

Índice de entradas del conversor


Expresiones Regulares, Gramáticas regulares, Autómatas finitos, Autómatas de Pila, Máquinas de Turing, estos son algunas de las cosas que se emplean en la descomposición en tokens de los programas. Como he dicho que no voy a ser muy académico (y también porque no existen el manejo de expresiones regulares en SuperBASIC), voy a desarrollar la descomposición de Tokens usando un pequeño autómata.

No os voy a aburriros explicando lo que son los autómatas y como se programan, aunque es un tema importante en Informática, por ejemplo si leéis el manual del Kenbak-1 veréis que John Blankenbaker lo planteó como un autómata. De todas formas hay información de sobra en Internet sobre estos temas, y centrándonos en lo que estoy desarrollando podéis consultar libros sobre Compiladores, que hay muchos, de los cuales os recomiendo los dos que más se usan en las universidades sobre la materia: "Compiladores, principios, técnicas y herramientas" de Aho, Sethi y Ullman, también llamado "El libro del dragón" por su portada, de los varios que he leído creo que es el más claro y que más me gustó, el otro es "Construcción de compiladores. Principios y práctica" de Louden, aunque personalmente me gusta menos por no explicar tan bien como el otro, pero la parte de generación de código creo que es un poco mejor.

En lugar de eso voy a hacer un autómata muy sencillo que será fácilmente comprensible sin necesidad de muchas explicaciones. Así gano dos cosas, que no os canséis y no sobrecargar al QL de procesos, ya que aunque dispone (para mi) del mejor Basic con diferencia de las máquinas de la época, mi muy querido QL nunca se ha lucido por su velocidad. Por ser honestos, es superado por el BBC Basic, pero este tuvo una evolución importante con varias versiones, lo que no ocurrió con SuperBasic. Ambos fueron mucho mejor que los basados en Microsoft BASIC, de los que la mejor versión fue el MSX Basic, y desde luego mucho mejores que el GWBasic del MS.DOS en los PC. 

El BBC fue desarrollado por uno/una de los grandes de la informática, Roger Wilson, que luego cambió de sexo y hoy es Sophie Wilson (lo que personalmente me parece estupendo el que cada uno haga lo que desee, menciono ambos nombres por si los buscáis en Internet), que no solo desarrolló el BBC Micro y su Basic, sino que desarrolló para IBM los primeros procesadores RISC, luego usados en estaciones de trabajo de Acorn, unas máquinas estupendas pero poco conocidas.

Volviendo al tema, este es mi autómata:

tipo = Get_Tipo(c$)
SELect ON tipo
  = TIPONUM : Extrae_Numero(c$)
  = TIPOALFA : Extrae_Identificador(c$)
  = TIPOCADENA : Extrae_Cadena(c$)
  = TIPOSEPARADOR: Extrae_Separador(c$)
  = TIPOOPERADOR : Extrae_Operador(c$)
  = TIPOOTRO : Extrae_Desconocido(c$)
END SELect

 

Como vemos toma un carácter de la línea que estamos analizando, mira de que tipo es, y según este tipo va a llamar al proceso que extrae el token de ese tipo de la cadena, con la salvedad de que cuando extrae un identificador este puede ser: 

  • Un comando del BASIC. En este caso si además el comando es el comentario extrae también el resto de la cadena como texto del comentario.
  • En otro caso será el nombre de una variable.

He desarrollado un módulo para probar la extracción de Tokens del analizador léxico de forma independiente al resto del desarrollo, podéis descargarlo desde aquí

En la siguiente entrada ya entramos en la conversión, ampliaré este módulo para incluir las pruebas de conversión, ya más adelante uniré el módulo de lectura de líneas con este para comenzar a analizar programas.

Para poder convertir haré una mejora de la parte donde identifico los comandos del ZX Basic, que ahora mismo es una cadena de caracteres con estos separador por un espacio, la tengo que convertir en una tabla que identifique el comando y el proceso que se usará para convertirlo.

martes, 12 de noviembre de 2024

ZB2SB. Conversor de Basic de los ZX al Super Basic del QL. Indice

01. Introducción
02. Analizador Léxico primera parte: Lectura de líneas 
03. Analizador Léxico segunda parte: Extractor de Tokens

ZB2SB. Conversor de Basic de los ZX al Super Basic del QL (II): Analizadór léxico primera parte, lectura de líneas

Índice de entradas del conversor


Comenzamos con el analizador léxico, esta lo divido en dos partes para simplificar, por un lado la lectura de líneas y por otro su descomposición en Tokens.

Partimos de que el programa de origen será un fichero de texto guardado en el Microdriver (o equivalentes como las unidades con una SD, un disquete, un disco duro, o desde un emulador un directorio en el equipo). El resultado serán líneas completas que pasaremos a la rutina que la descompone en Tokens. En esta entrada presentaré las rutinas de lectura del fichero fuente y las de escritura del fichero de destino, que son bastante sencillas realmente.

Para las pruebas el programa simplemente copiar la línea leída en el fichero de origen y la guardará tal cual en el fichero de destino. En otras entradas iré desarrollando esta parte.

En esta primera versión del programa incluyo la base del sistema, compuesta de una serie de procedimientos y funciones que iré llamando, usando este procedimiento:

1170 DEFine PROCedure Main
1180   Init        : REMark Inicializar variables globales
1190   Pantalla    : REMark Pantalla de petición de datos
1200   :
1210   REMark Preparar una ventana para ver los resultados
1220   :
1230   OPEN#cCon,con
1240   WINDOW #cCon,400,140,50,70 : BORDER #cCon; 2,2
1250   :
1260   Proceso     : REMark procesar el fichero de entrada
1270   :
1280   INK #cCon;6 : PRINT #cCon;LF$;"Finalizado"
1290   CLOSE #cCon
1300 END DEFine
 

  • Init: Este procedimiento inicializa las variables globales que usaremos. Al no existir contantes en SuperBASIC, usaremos variables para simularlas, por haber programado en C tengo la costumbre de que las contantes se escriban con nombres en Mayúsculas siempre.
  • Pantalla: Este procedimiento presenta la pantalla inicial, y solicita los nombres de los ficheros de origen y destino.
  • El OPEN y WINDOW crea una ventana para ir presentando las líneas leidas y las generadas
  • Proceso: Este es el procedimeinto que realizará todo el proceso de conversión, en este momento lo único que hace es copìar la entrada sobre la salida para las pruebas de lectura y escritura de ficheros.
  • Luego se presenta el mensaje de fin.

Veamos los procesos principales, comenzando por el denominado Proceso que es el que hace todo el trabajo de dirección:

1850 DEFine PROCedure Proceso
1860   LOCal fin,salida$
1870   :
1880   OPEN_IN  #cIn, fIn$
1890   OPEN_NEW #cOut, fOut$
1900   CLS #0 : REMark Por si hay un mensaje de sobreescribir
1910   :
1920   REMark --- Lectura del fichero de entrada
1930   :
1940   REPeat BucleLectura
1950     :
1960     fin = LeerFichero
1970     linea$=Trim$(linea$)
1980     IF (linea$ <> "") THEN
1990       nrl = nrl + 1
2000       Mostrar_Entrada linea$
2010       salida$=Montar$(linea$)
2020       Mostrar_Salida salida$
2030       Guardar(salida$)
2040     END IF
2050     IF fin THEN EXIT BucleLectura
2060     :
2070   END REPeat BucleLectura
2080   :
2090   REMark Finalizar el proceso
2100   :
2110   CLOSE #cIn
2120   CLOSE #cOut
2130 END DEFine Proceso

Este proceso comienza abriendo los canales para lectura y escritura asociandolos a los ficheros que usaremos, al llegar a la línea 1890, si el sistema encuentra que el fichero de salida ya existe nos pregunta si deseamos sobre-escribirlo. si usamos Minerva o algún Toolkit existe la posibilidad de evitar esto indicando que deseamos sobre-escribir siempre, pero yo prefiero usar la ROM estándar únicamente en estas entradas.

Luego llamará al procedimiento que lee el fichero línea a línea, presentando la línea en la pantalla, llama al proceso que Montará la línea de salida (repito en este momento solo copia origen a destino), y nos presenta la línea generada.

Al final cierra los canales de entrada y salida.

2530 DEFine FuNction LeerFichero
2540     LOCal car$
2550     :
2560     linea$=""
2570     REPeat Bucle_Lectura
2580         IF (EOF(#cIn)) THEN RETurn TRUE
2590         :
2600         car$=INKEY$(#cIn)
2610         :
2620         IF (car$ <> CR$) AND (car$ <> LF$) THEN
2630           linea$ = linea$ & car$
2640         ELSE
2650           IF (linea$<>"") THEN
2660             EXIT Bucle_Lectura
2670           END IF
2680         END IF
2690     END REPeat Bucle_Lectura
2700     RETurn FALSE
2710     :
2720 END DEFine LeerFichero

Esta es la función de lectura del fichero de origen, como el final de las líneas puede ser LF, CR+LF o solo CR dependiendo del origen del fichero, no puedo leer por líneas sino que leo carácter a carácter, y si no es un CR o un LF lo incluyo en la línea que estoy leyendo, pero si es uno de estos entiendo que es el fin de línea y retorno lo que he leído hasta el momento. Si el siguiente carácter es CR o LF, es de la línea anterior o es una línea en blanco, lo que no está realmente permitido, y en este caso no retorno nada y sigo leyendo.

Ahora el procedimiento para guardar las línea convertidas en el fichero de salida, como vemos es muy  sencillo ya que guarda la línea completa sin complicaciones, en formato del QL que es el que lo ejecutará:

2760 DEFine PROCedure Guardar(s$)
2770   PRINT #cOut, s$
2780 END DEFine 
 

Como de momento el proceso solo copia tal cual, si se ejecuta con un fichero de prueba la pantalla que aparece será como esta:

 

Vemos que se solicita los nombres de los ficheros de origen y destino, y el proceso va presentando las líneas, el primer número en color blanco es el contador de líneas leídas. Luego en verde está la línea que ha leído, marcada como "L:", debajo y en color rojo la línea que ha convertido (repito que nuevamente en esta fase solo es copia de la leída) marcada como "C:".

El módulo completo lo podéis descargar desde este enlace

lunes, 11 de noviembre de 2024

ZB2SB. Conversor de Basic de los ZX al Super Basic del QL (I): Introducción

Índice de entradas del conversor


Como sabemos, los programas se escriben en un lenguaje de alto nivel, y usando otro programa se convierten en programas escritos en otro lenguaje. Cuando ese lenguaje es directamente código máquina (a veces es ensamblador que se ensambla y linka de forma transparente) tenemos los que habitualmente se llama un COMPILADOR. 

Por definición, un COMPILADOR es un programa que convierte un programa escrito en un lenguaje, en un programa escrito en otro lenguaje. Cuando ese otro lenguaje no es código máquina (o ensamblador, que se considera lo mismo en este tema), sino que lo convierten en otro lenguaje diferente, se le denomina TRANSPILADOR.

Los viejos quizá recuerden el compilador Turbo Pascal y luego el compilador Turbo C (posteriormente Borland C), que eran ampliamente usados por generar no solo de forma mas rápida que otros compiladores del momento (recuerdo en un 386 esperar 20 minutos para que terminara de compilar un programa en ABAL), sino porque generaba un código bastante rápido al estar bien optimizado. Luego se pasó al Turbo C++, pero las primeras versiones usaban dos pasos, primero un pre-procesador convertía el código C++ en C estándar, y luego el compilador compilaba el programa en C. Esto hacía que depurar fuera un infierno, depurabas el programa en C que no era el que habías escrito tu en C++, lo que era un desastre. Por fortuna, fueron rápidos en sacar un compilador completo, que generaba ya directamente desde C++ código máquina y podías debugear sin problemas.

Partes de un compilador

Un compilador se divide en varias partes, cada una centrada en un aspecto de la conversión del fuente en el objeto:

  • El analizador Léxico toma el programa y lo descompone en sus unidades básicas, sentencias, variables, operadores, separadores, etc. Para hacerlo necesita unas tablas auxiliares con los elementos que debe reconocer, separadores, sentencias, formato de variables, formato de comentarios, formato de cadenas, etc. Cada uno de estos componentes se denomina un TOKEN, y tiene dos atributos, el token y su tipo. Por ejemplo si tenemos la instrucción 100 IF a>=b THEN GOTO 500 lo descomponemos en estos tokens: 
    • 100, número de línea
    • IF, sentencia de comparación
    • a, variable
    • >=, operador de comparación
    • b, variable
    • THEN, sentencia de ejecución
    • GOTO, sentencia de salto
    • 500, operador del salto
    • vacío, fin de instrucción (en lenguajes como C sería ";" en lugar de vacío)
  • El analizador sintáctico toma los Tokens que va recibiendo del léxico y monta lo que se denomina el árbol de sintaxis, que representa la estructura lógica de las sentencias, y unas tablas auxiliares con las variables, funciones, procedimientos y en los lenguajes que los usan los saltos que se han ido encontrando.
  • El Analizador semántico es el resultado de validar que el árbol de sintaxis es correcto.
  • El generador de código crea a partir de lo anterior el código de destino. En general se divide en tres partes:
    • El generador de código intermedio crea a partir de la estructura lógica el código en el lenguaje de destino, o a veces en un lenguaje similar simplificado, en general genera lo que se denomina "código de tres direcciones", no mira mas que las sentencias que está recibiendo.
    • El optimizador mejora el código intermedio generando uno lo más rápido posible, usará las tablas auxiliares y puede tener en cuenta código precedente y posterior para eso.
    • El generador final convierte el código intermedio optimizado en el definitivo en lenguaje de destino.
El compilador actúa simultáneamente en las tres fases, no las hace una tras otra (salvo en algunos compiladores antiguos o cuando se desea ver lo que se genera de forma didáctica), en general el Sintáctico es el director, ya pidiendo tokens al léxico y va montando las estructuras, cuando tiene una sentencia completa llama al generador de código.

El diseño de compiladores actual utiliza programas que se han desarrollado, probado y optimizado para ayudarnos a generar el código necesario, estos programas los encontrareis escritos en C y en Java principalmente, como son Flex y JFlex para el léxico y para el sintáctico yacc, GNU bison y javaCC. El generador de código es lo que se programa realmente.

Notación BNF

No quiero ser muy académico (aunque para eso estamos los ingenieros informáticos), prefiero ser lo mas didáctico posible, pero solo por completar la definición, para poder compilar un lenguaje es primero necesario definirlo correctamente, para eso se utiliza la notación BNF.

Para definir el lenguaje a compilar, el señor John Backus, uno de los grandes pioneros, de los que antes de que existieran lenguajes de programación ya trabajaba con ordenadores en código máquina, para desarrollar el FORTRAN ideó una notación para describir la sintaxis del lenguaje, de forma que no hubiera problemas ni existieran ambigüedades. Este sistema fue ampliada por Peter Naur fijando lo que se conoce como Notación BNF. Para poder hacer algo con lenguajes de programación, lo primero que se debe hacer es definir el lenguaje de la forma más precisa y exhaustiva posible, para eso lo mejor es la BNF, o su equivalente en formato gráfico.

Objetivo

Mi idea en esta serie de entradas es intentar desarrollar algo mas sencillo de que un transpilador, ya que los lenguajes de origen y destino son muy similares y bastante compatibles, en su lugar mi idea es desarrollar en Super Basic un conversor que transforme programas escritos en ZX BASIC o Sinclair BASIC (se conoce por ambos nombres), que así se denomina el BASIC que fue inicialmente para el ZX80/ZX81, luego ampliado con nuevas sentencias en el Spectrum (el mismo en todos sus modelos, salvo comandos específicos para el +2 para cambiar el modo 48/ampliado o los de manejo del disco en los +3), en programas en Super Basic del QL. Digo intento porque espero acabarlo, ahora mismo lo tengo iniciado y genera código sin problemas para programas sencillos, pero le faltan todavía algunas cosas para considerarlo operativo.

Del compilador solo voy a usar el analizador léxico, que me retornará las sentencias separadas en Tokens, que convertirá a la sintaxis del SuperBasic

En esta serie de entradas mi idea es reescribirlo mejorado, siempre que se reescribe un programa no partes de cero, sino de lo aprendido anteriormente, por lo que siempre se genera mejor código, y el que tengo no me convence actualmente, por lo que tengo excusa para empezar esta serie. Pero hay que empezar desde el principio.

Características del Basic de Sinclair

Me basaré es estas características que tienen todos los programas en Sinclair Basic, que en principio son comunes al SuperBasic:

  1. El programa lo leeremos desde un fichero de texto.
  2. Un programa se compone de líneas individuales, separadas por un terminador de línea * ver nota al final. Este terminador depende del origen del fichero que las contiene (si el origen es Windows será CR+LF, si el origen es Linux o el propio QL será LF, si estamos en Apple será CR). Por tanto lo primero que haremos es un proceso que lea un fichero de texto con el programa y lo descomponga en líneas, pasando el proceso para cada una de forma independiente.
  3. Cada línea del programa se compone de un número de línea, un espacio en blanco, y una o varias sentencias separadas por el carácter ":"
  4. Cada sentencia comienza por un identificador de sentencia, seguido por un espacio y una serie de modificadores. Por ejemplo la sentencia LET a=r-3 se compone del identificador "LET", seguido por el modificadores"a=r-3". 
  5. Existen sentencias simples como REM, LET o GOTO, y sentencias compuestas como pueden ser las decisiones IF/THEN o los bucles FOR/TO.
  6. Un comentario se define por el identificador de sentencia REM y su modificador abarca siempre hasta el final de la línea en que se encuentra. No hay comentarios multi-línea y por tanto tampoco anidados.
  7. Se aceptan nombres de variables con cualquier longitud.

Hay algunas sentencias que no se pueden usar en SuperBasic, por ejemplo PLOT, hay que analizar todas las sentencias y buscar su equivalencia, pero en general las principales diferencias entre ambos BASIC las podemos resumir en:

  1. El ZXBasic del Spectrum amplia el de los ZX80/81, por lo que incluye algunos comandos adicionales, como PAPER o INK. Por tanto todo lo aplicable a los programas para el Spectrum se debe poder aplicar a los programas para los ZX80/81.
  2. El ZXBasic utiliza variables numéricas sin atributo adicional, o de cadena con el atributo $. Super Basic se comporta igual, pero añade la posibilidad de usar variables enteras con el atributo %
  3. ZXBasic utiliza obligatoriamente la sentencia LET en las asignaciones, en SuperBasic es opcional.
  4. ZXBasic usa NEXT como final del bucle FOR, mientras que SuperBasic utilizar END FOR en su lugar, reservando NEXT dentro del bucle para otro comportamiento.
  5. No existe un cierre de los condicionales en el ZXBasic, por tanto estas instrucciones solo ocupan una línea. En este caso la sentencia "IF operador1 THEN sentencias", en la parte de las sentencias puede abarcar varias. Se admite lo mismo en SuperBasic, pero existe un fin de comparación por lo que es posible separar en varias líneas un IF.
  6. Las sentencias GO TO y GO SUB se escriben como una o como dos palabras separadas por un espacio, aunque sean una sola sentencia. En ZXBasic irán juntas, en SuperBasic separadas
  7. La sentencia "PRINT expresion" admite varios operadores separados por coma o por punto y coma, algunos operadores NO los admite super Basic. Dentro de una sentencia PRINT la expresión puede ser uno de estos términos (algunos no se pueden usar en ZX80/81):
    • Quedar vacío
    • Una expresión numérica
    • Una expresión de cadena
    • AT m,n  >> Desplaza el curso a la línea-columnas. Esto no lo admite SuperBasic,
    • TAB n >> Desplaza el cursor al tabulador n. No admitido por SuperBasic
    • PAPER, INK, FLASH, BRIGHT,  INVERSE, OVER >> Atributos de color, que no admite SuperBasic
     
    Por esto, si tenemos por ejemplo:
    PRINT AT 4,12;INK 4;PAPER 5;"Hola ";INK 2;"que tal"
    Lo debemos convertir en:
    AT 4,12 : INK 4 : PAPER 5 : PRINT "Hola" : INK 2 : PRINT "que tal"

Limitaciones

El principal problema proviene de las diferencias entre los sistemas, no por los procesadores que sean diferentes, sino por las características propias de cada máquina, esencialmente en la pantalla y las llamadas a la ROM o a las variables del sistema desde el Basic.

Los ZX usan una pantalla con un tamaño y unos colores, mientras que el QL tiene varias resoluciones con varios modos de color. Aunque no es directo pero esto se puede ajustar creando una ventana en el QL.

Los ZX usan un juego de caracteres de 8x8, mientras que el QL usa varios juegos de resoluciones variables, y los comandos para redefinir el juego de caracteres del Spectrum no funcionan en el QL, que usa otras resoluciones y puede disponer de varios juegos, esto es complejo pero se puede llegar a solucionar.

Casi todos (por no decir todos) los comandos que se refieren al hardware del aparato no se pueden convertir, si en el ZX se hace un POKE para obtener una variable del sistema, lo más seguro es que esa variable no exista en el QL. Hay veces que se llama a una rutina en la ROM para acelerar el programa, esto no es posible replicarlo en el QL (no es el objetivo de estas entradas, pero siempre existe la posibilidad de escribir rutinas en código máquina que hagan algo equivalente), por tanto no se van a convertir comandos dependientes del Hard como PEEK o POKE.

(*) Nota sobre CR y LF. En las máquinas de escribir, el papel se insertaba en un rodillo que estaba ubicado sobre lo que se denominada Carro. Las teclas eran fijas, pero el carro se movía a izquierda o derecha, y el rodillo se movía hacia arriba o hacia abajo, de esta forma al pulsar una tecla esta presionaba una cinta entintada sobre el papel marcando un carácter, y luego movía el carro una posición a la izquierda para escribir el siguiente carácter. Si pulsábamos el espacio el carro se movía una posición a la izquierda sin imprimir nada. Al llegar al final de la hoja, había que hacer dos cosas, mover el carro completamente hacia la derecha y mover el rodillo una línea hacia arriba, esto se hacía con una palanca ubicada a la derecha, al apretarla primero movía el rodillo hacia arriba una línea (esto se podía usar siempre de manera independiente), si seguías apretando soltaba un freno y movías todo el carro hacia la derecha. Cuando se crearon los teletipos, esto se hacía de forma eléctrica, pero necesitaban dos comandos para hacerlo:

  • CR: significa Carriage Return (Retorno de carro). Al recibir este comando el sistema movía el carro completamente a la derecha.
  • LF: significa Line Feed (Avance de línea). Al recibir este comando el sistema movía el rodillo una línea hacia arriba.

Realmente se solía enviar primero el LF y luego el CR, aunque el orden es indiferente. En los sistemas operativos se adoptó una codificación u otra, según lo decidieron sus creadores, así:

  • En Unix (y todos sus derivados como Linux o BSD) se decidió que los ficheros de texto usaran solo LF para marcar el fin de línea. A la hora de imprimir se hacía con un programa que la controlaba y cambiaba en los teletipos LF por CR+LF. 
  • En CP/M se decidió usar CR+LF, que es lo que se remitía realmente a los teletipos al usar un controlador de impresión muy básico, del CP/M pasó al MS.DOS (a través del QDOS que era un clon del CP/M), y de este al Windows actual. 
  • Apple decidió lo de siempre, llevar la contraria a todos y usar CR únicamente. 
  • Los Mainframes de IBM usaban otro carácter diferente, denominado NEL, de NExt Line (línea siguiente). Otros sistemas como VMS no requerían usar fin de línea ya que guardaban los textos como registros.