Intérprete BrainFuck para RISC-V. Parte II
ZONA GEEK
Intérprete BrainFuck para RISC-V. Parte II
2024-05-09
Por
Don Bit0

En la entrega anterior aprendimos a programar en BrainF*ck y las bases de como programar los procesadores RISC-V en ensamblador para Linux. También vimos como configurar rápida y fácilmente un sistema en el que desarrollar y probar nuestros programas. En esta entrega vamos a continuar con nuestra implementación del intérprete de BrainFuck.

Vamos a comenzar con una sencilla modificación con la que eliminar los caracteres de control como tabuladores o retornos de carro y así introducir algunas instrucciones más y mostraros la base para sanear la entrada de datos de nuestro programa. En el caso general deberíamos eliminar cualquier cosa que no sea una de las instrucciones del lenguaje.

Filtrando Caracteres de control

El código sería algo así:

_start:
    # Lee datos desde entrada standard
    la a1, prog
    move t1, zero
    li t2, 0x20                           *** NUEVO!!!!
bucle01:
    li a7, 63
    li a0, 0
    li a2, 1
    ecall
    # Si <=0 (EOF) terminamos bucle
    blez a0, cont01

    lb t0, 0(a1)                            *** NUEVO!!!
    ble t0,t2, bucle01  # Elimina espacios  *** NUEVO!!!

    add t1, t1, 1
    add a1, a1, 1
    j bucle01

cont01:

Os hemos marcado las líneas añadida para que sea más fácil seguir la explicación, Comencemos por la instrución lb Load Byte. Esta instrucción nos permite cargar en un registro un byte leído desde una posición de memoria. El parámetro 0(a1) se puede leer como: el contenido de la posición de memoria a1 + 0.

También podemos ver la instrucción ble que como os podéis imaginar realiza un salto si el primer parámetro es menor o igual (Less or Equal) que el segundo. En este caso nos estamos saltando todos los caracteres menores que 0x20 que representa el espacio. Estos valores bajos del código ASCII se utilizan para representar secuencias de control como el retorno de carro o el tabulador.

Como podéis ver, en el caso de que el valore leído sea una secuencia de control no incrementamos el puntero dentro de nuestro buffer de lectura de tal suerte que la siguiente llamada a SYS_read volverá a escribir el caracter en la posición actual.

Preparándonos para la interpretación

Para poder interpretar nuestros programas en BrainFuck, tendremos que leer los distintos comandos uno a uno, y según el comando realizar una operación u otra. Así que el siguiente paso que vamos a hacer es imprimir nuestro programa en memoria, pero caracter a caracter, como paso previo para la implementación de nuestros programas.

Para ello, eliminamos la llamada a SYS_write con el tamaño del buffer, por un bucle en el que imprimiremos el buffer byte a byte. El fragmento de código sería algo así:

cont01:
    # t1 contiene longitud del programa

    # Muestra la cadena volcada desde memoria
    # Este es el bucle base de ejecución
    la t2, prog     # Contador de programa
    add t3, t2, t1  # Apunta al final del programa

bucle02:
    li a7, 64
    move a1, t2
    li a2, 1
    ecall

    add t2, t2, 1       # Incrementa contador del programa
    blt t2, t3, bucle02 # Mientras no alcancemos el final, repetimos
exit:

Puesto que ya hemos introducido todos los elementos vamos a comentar brevemente el código. En el bucle principal de ejecución vamos a utilizar los registros temporales. Por el momento esto es lo que tenemos:

  • t1 contiene la longitud del programa a ejecutar (lo calculamos al leer la entrada de usuario).
  • t2 va a ser el puntero de programa, el que iremos moviendo por el buffer para ir leyendo cada uno de los comandos que conforman el programa.
  • t3 representa la dirección inmediatamente después del último comando del programa. No es más que el inicio del buffer + t1, así que, realmente, no necesitaremos t1 más en el resto del programa.

Una vez inicializados t2 y t3, el bucle principal irá incrementando el valor de t2 hasta que sea igual a t3, momento en el cual habremos alcanzado el final del programa y debemos parar su ejecución.

Implementando los comandos básicos

Ahora que ya podemos extraer los comandos de nuestro buffer de programa, vamos a interpretarlos. Esto es muchísimo más fácil de lo que parece. Primero tendremos que añadir una nueva variable local para poder acceder a la memoria de datos.

cont01:
    # t1 contiene longitud del programa

    # Muestra la cadena volcada desde memoria
    # Este es el bucle base de ejecución
    la t2, prog     # Contador de programa
    la t6, data     # Puntero de datos
    add t3, t2, t1  # Apunta al final del programa

Sin sorpresas. data es otra etiqueta en la sección .data definiendo un buffer en el que almacenar datos. En nuestro programa, el puntero a la memoria de datos se almacenará en el registro temporal t6.

Ahora veamos como interpretar los comandos básicos (+-<>,.[]).

bucle02:
    lb t4, 0(t2)              # Leemos comando de memoria de programa

    li t5, '<'
    beq t4, t5, inc_data_ptr
    li t5, '>'
    beq t4, t5, dec_data_ptr
    li t5, '+'
    beq t4, t5, inc_data
    li t5, '-'
    beq t4, t5, dec_data
    li t5, '.'
    beq t4, t5, print_data
    li t5, ','
    beq t4, t5, read_data
    li t5, '['
    beq t4, t5, inicia_bucle
    li t5, ']'
    beq t4, t5, fin_bucle

    # Error command desconocido va aquí
    j cont02

La instrucción lb ya la conocemos. En esta ocasión estamos leyendo en el registro t4 el operador BrainFuck al que apunta el puntero de programa t2.

Puesto que RISC-V solo ofrece instrucciones de salto condicional donde el condicional se debe aplicar sobre dos registros, debemos cargar el valor que queremos comparar en un registro antes de poder procesarlo. Los distintos comandos los cargamos en t5 y usando la instrucción beq (Branch if EQual Salta si es Igual), comparamos los dos valores y si coincide saltamos a una determinada etiqueta en nuestro programa. En un segundo implementaremos esas etiquetas.

Si el valor leído de la memoria no coincide con ningún comando, tenemos la opción de mostrar un error, si bien, en este caso es mejor ignorar el caracter. De esta forma podemos incluir comentarios en nuestros programas bf , simplemente escribiendolos. Por ejemplo:

,     Lee un valor y lo almacena en DP
++    Suma 2 al valor leido
.     Imprimimos el valor

Para comprobar esto, vamos a incluir la siguiente implementación temporal para nuestros comandos. Simplemente mostraremos una cadena que representa el tipo de operación. Veremos como los comentarios simplemente desaparecen.

Comprobando que filtramos los comentarios

La implementación de los comandos básicos es muy sencilla. Aquí tenéis el código, de los dos primeros comandos. El resto son igual, simplemente cambiando el valor que cargamos en a1

inc_data_ptr:
    la a1, label1
    j cont02
dec_data_ptr:
    la a1, label2
    j cont02

cont02:

    li a7, 64
    li a0, 1
    li a2, 10
    ecall

    add t2, t2, 1       # Incrementa contador del programa
    blt t2, t3, bucle02 # Mientras

Al final del código podemos añadir las etiquetas. En este caso se trata de literales o valores de solo lectura, así que los podemos dejar en la sección .text.

label1:.asciz "Comando <\n"
label2:.asciz "Comando >\n"
label3:.asciz "Comando +\n"
label4:.asciz "Comando -\n"
label5:.asciz "Comando .\n"
label6:.asciz "Comando ,\n"
label7:.asciz "Comando [\n"
label8:.asciz "Comando ]\n"

Si ahora ejecutáis el intérprete, veréis como podéis escribir lo que queráis entre medias del programa, solamente los caracteres que forman parte del lenguaje serán interpretados (tened cuidado con las comas y puntos).

Implementando los comandos básicos

Comenzaremos implementando los comandos de manipulación de la memoria de datos.

inc_data_ptr:    # Comando >
    add t6, t6, 1
    j cont02
dec_data_ptr:    # Comando <
    add t6, t6, -1
    j cont02
inc_data:        # Comando +
    lb t1, 0(t6)
    add t1, t1, 1
    sb t1, 0(t6)
    j cont02
dec_data:         # Comando -
    lb t1, 0(t6)
    add t1, t1, -1
    sb t1, 0(t6)
    j cont02

La nueva instrucción que aparece en este caso es sb y os podéis imaginar que hace. Si, almacena un byte en memoria (Store Byte). Los comandos < y > simplemente modifican el valor de t6, nuestro puntero de datos. Nótese que estas funciones deberían de comprobar que el valor t6 permanece en rango.

Los comandos + y - leen el byte en la posición de memoria apuntada por t6, le suman 1, y lo vuelven a almacenar. Más fácil no se puede.

Veámos ahora los comandos , y ., si bien, a estas alturas deberíais de ser capaces de implementarlos vosotros mismos.

print_data:
    li   a7, 64
    li   a0, 1
    move a1, t6
    li   a2, 1
    ecall

    j cont02
read_data:
    li   a7, 63
    li   a0, 0
    move a1, t6
    li   a2, 1
    ecall

    j cont02

Si, una vez más, simplemente llamamos a SYS_read y SYS_write pero esta vez utilizando como puntero la memoria de datos. Con este último cambio podremos ejecutar cualquier programa BF que no utilice bucles.

El intérprete por ahora

Para terminar esta entrega y ya que hemos hecho muchas cambios en distintas partes del programa, os dejamos la versión actual y completa del código para los que os hayáis podido perder.

    .text
    .global _start
    .equ MEM_SIZE, 4096
    
_start:
    # Lee datos desde entrada standard
    la    a1, prog
    move  t1, zero
    li    t2, 0x20
bucle01:    
    li     a7, 63
    li     a0, 0
    li     a2, 1
    ecall
    # Si <=0 (EOF) terminamos bucle
    blez   a0, cont01
    
    lb     t0, 0(a1)
    ble    t0, t2, bucle01  # Elimina espacios
    
    add    t1, t1, 1
    add    a1, a1, 1
    j      bucle01
    
cont01:
    # Este es el bucle base de ejecución
    la     t2, prog     # Contador de programa
    la     t6, data     # Puntero de datos
    add    t3, t2, t1  # Apunta al final del programa

bucle02:    
    lb     t4, 0(t2)
    li     t5, '<'
    beq    t4, t5, inc_data_ptr
    li     t5, '>'
    beq    t4, t5, dec_data_ptr
    li     t5, '+'
    beq    t4, t5, inc_data
    li     t5, '-'
    beq    t4, t5, dec_data
    li     t5, '.'
    beq    t4, t5, print_data
    li     t5, ','
    beq    t4, t5, read_data
    j      cont02
inc_data_ptr:
    add    t6, t6, 1
    j      cont02
dec_data_ptr:
    add    t6, t6, -1
    j      cont02
inc_data:
    lb     t1, 0(t6)
    add    t1, t1, 1
    sb     t1, 0(t6)
    j      cont02
dec_data:
    lb      t1, 0(t6)
    add     t1, t1, -1
    sb      t1, 0(t6)
    j       cont02
print_data:
    li       a7, 64           # SYS_write
    li       a0, 1
    move     a1, t6
    li       a2, 1
    ecall
    j        cont02
read_data:
    li       a7, 63           # SYS_read
    li       a0, 0
    move     a1, t6
    li       a2, 1
    ecall
    # Aqui no hace falta el salto, pero la necesitaremos luego
    j        cont02          
cont02:
    add      t2, t2, 1       # Incrementa contador del programa
    blt      t2, t3, bucle02 # Mientras no alcancemos el final, repetimos
    
exit:
    li       a7, 93          # SYS_exit 
    li       a0, 0
    ecall

    .data
prog:
    .fill MEM_SIZE
data:
    .fill MEM_SIZE

COLOFÓN

Ya tenemos casi listo nuestro intérprete, solo nos faltan los bucles, que son la parte que tiene algo de complicación y pulirlo un poco. Pero eso lo veremos en la siguiente entrega.

Continua Leyendo...

Intérprete BrainFuck para RISC-V. Parte III

Header Image Credits: Elijah O'Donnell

SOBRE Don Bit0
No os podemos contar mucho sobre Don Bit0. Es un tipo misterioso que de vez en cuando colabora con nosotros y luego, simplemente se desvanece. Como os podéis imaginar por su nick, Don Bit0, está cómodo en el bajo nivel, entre bits, y cerquita del HW que lo mantiene calentito.

 
Tu publicidad aquí :)