Intérprete BrainF@ck para RISC-V. Parte I
ZONA GEEK
Intérprete BrainF@ck para RISC-V. Parte I
2024-04-03
Por
Don Bit0

Iniciamos una nueva sección en la que vamos a hacer cosas muy geeks y empezamos con un artículo que creemos reflejará muy bien la filosofía de esta sección. Vamos a programar un intérprete del lenguaje esotérico BrainFuck en ensamblador RISC-V… por que?… pues porque podemos :)

Para los que no conozcáis brainfuck, bueno enseguida entenderéis de donde viene el nombre. Es quizás el lenguaje esotérico más conocido y emblemático, además de raro de narices. Para los que no conozcáis RISC-V, pues se trata de una arquitectura de procesadores RISC que tiene la peculiaridad de estar libre de licencias, lo que permite a cualquiera desarrollar libremente su propio procesador basado en esa arquitectura.

Arquitecturas como ARM por ejemplo requiere que los desarrolladores de procesadores basados en ARM paguen una licencia. Los usuarios finales no nos enteramos, pero para los desarrolladores de HW es un coste a tener en cuenta.

Sin más preámbulos vamos a contaros que es BrainFuck, las peculiaridades de RISC-V y ponernos al tajo. Vamos a ir construyendo el programa con pasos reproducibles que podáis seguir y que os ayuden a depurar el programa en caso de que tengáis cualquier problema… Y como siempre, no dudéis en poneros en contacto con nosotros ;)

BrainFuck

BrainFuck es uno de los lenguajes esotéricos más emblemáticos. Probablemente el más conocido. Si queréis conocer más sobre su historia, la página de la wikipedia contiene toda la información que podáis buscar. En este artículo nosotros solo vamos a explicaros la sintaxis del lenguaje de forma que el artículo sea auto-contenido.

El lenguaje está compuesto por 8 caracteres que funcionan sobre una, digamos, máquina virtual muy sencilla. Esta máquina define una zona de memoria en la que se almacena el programa (una secuencia de los 8 caracteres que describiremos a continuación), y otra zona de memoria en la que se almacenan datos.

La máquina dispone de dos registros. Un contador de programa, que apunta a la instrucción del lenguaje que estamos ejecutando (en la primera zona de memoria) y un puntero de datos que apunta a alguna de las posiciones disponibles en el segundo bloque de datos.

Veamos las instrucciones y un programa de ejemplo:

> Incrementa el puntero de datos en 1
< Decrementa el puntero de datos en 1
+ Incrementa el valor apuntado por el puntero de datos
- Decrementa el valor apuntado por el puntero de datos
. Muestra en pantalla el valor apuntado por el puntero de datos
, Lee un valor del teclado y lo almacena en la posición indicada por el puntero de datos
[ Si el valor apuntado por el puntero de datos es 0 salta al ] correspondiente. Sino continua la ejecución normalmente
] Si el valor apuntado por el puntero de datos no es cero, salta al [ correspondiente, sino continua la ejecución normalmente.

En contra de lo que pueda parecer el lenguaje es mucho más sencillo de lo que parece, si bien, hacer cosas útiles es complicado. Veamos un sencillo programa.

Ejecutando un programa BrainFuck

Este va a ser nuestro programa de ejemplo. Como podéis ver utiliza todos los comandos, así que nos va a permitir ver como funciona el lenguaje en su totalidad.

++>,.<[>+.<-]

Vamos a ir ejecutando el programa caracter a caracter para que entendáis como funciona cada uno de los comandos. Los caracteres ^ indican la posición a la que apunta el puntero de programa (PC) y el puntero de datos (PD):

PRG   | ++>,.<[>+.<-]
PC    | ^ 
DATOS | 0 0 0 0 0 0 0 ....
PD    | ^

Al comenzar el programa encontramos dos caracteres +. Estos caracteres incrementan el valor al que apunta el puntero de datos. Así que tras ejecutar las dos primeras instrucciones la máquina virtual tendrá el siguiente estado:

PRG   | ++>,.<[>+.<-]
PC    |   ^ 
DATOS | 2 0 0 0 0 0 0 ....
PD    | ^

Hemos almacenado el valor dos en la primera posición de memoria. Este va a ser nuestro contador para el bucle que implementamos un poco más adelante. Ahora nos encontramos un caracter >, el cual nos mueve a la siguiente posición de memoria:

PRG   | ++>,.<[>+.<-]
PC    |    ^ 
DATOS | 2 0 0 0 0 0 0 ....
PD    |   ^

En esta nueva posición vamos a leer un caracter de la consola e imprimirlo inmediatamente. Eso es lo que hacen los comandos , y .. Supongamos que hemos introducido el caracter A. La máquina virtual mostrará el siguiente estado:

PRG   | ++>,.<[>+.<-]
PC    |      ^ 
DATOS | 2 64 0 0 0 0 0 ....
PD    |    ^
  

Estamos a punto de comenzar el bucle, así que primero nos movemos a la posición de memoria que contiene nuestro contador de bucle. Esto lo hacemos con el comando < con el que nos moveremos a la posición inicial.

PRG   | ++>,.<[>+.<-]
PC    |       ^ 
DATOS | 2 64 0 0 0 0 0 ....
PD    | ^

Ahora estamos en condiciones de ejecutar nuestro bucle. Puesto que el valor al que apunta el puntero de datos no es cero, continuamos la ejecución normalmente entrando en el bucle. Dentro del bucle haremos dos cosas, incrementar el valor que hemos leído por consola y decrementar el contador del bucle:

PRG   | ++>,.<[>+.<-]
PC    |        ^ 
DATOS | 2 64 0 0 0 0 0 ....
PD    |   ^

Ahora que apuntamos a la A, sumamos uno e imprimimos.

PRG   | ++>,.<[>+.<-]
PC    |          ^ 
DATOS | 2 65 0 0 0 0 0 ....
PD    |   ^

Ahora apuntamos a nuestro contador y lo de crementamos

PRG   | ++>,.<[>+.<-]
PC    |           ^ 
DATOS | 2 65 0 0 0 0 0 ....
PD    | ^

PRG   | ++>,.<[>+.<-]
PC    |             ^ 
DATOS | 1 65 0 0 0 0 0 ....
PD    | ^

Llegados a este punto nos encontramos el caracter ], el cual, si el valor actual de los datos no es cero, saltará a la posición del caracter [ correspondiente. En este caso el valor al que estamos apuntando es 1, así que volvemos hacia atrás.

PRG   | ++>,.<[>+.<-]
PC    |       ^       
DATOS | 1 65 0 0 0 0 0 ....
PD    | ^

Y procedemos a ejecutar una vez más el cuerpo del bucle.

PRG   | ++>,.<[>+.<-]
PC    |             ^       
DATOS | 0 66 0 0 0 0 0 ....
PD    | ^

Tras lo cual volveremos a comprobar nuestro contador, el cual ahora vale 0 y por lo tanto el bucle termina.

Este sencillo programa imprime el caracter que introducimos por teclado y los dos siguientes.

Como podéis ver, lo que hacen los programas es super-sencillo, pero programar cualquier cosa útil es muy complicado.

En nuestras pruebas, vamos a utilizar este programa que acabamos de mostrar, además de el Hola Mundo para la prueba final. Aquí os lo dejamos para que os divirtáis un poco:

++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-.<.+++.------.--------.>>+.>++.

RISC-V

Hablemos ahora brevemente del la arquitectura RISC-V, en concreto la versión de 64 bits. La arquitectura RISC-V es, como su propio nombre indica una arquitectura RISC (Reduced Instruction Set Computer u Ordenadores con Conjunto de Instrucciones Reducido). Este tipo de procesadores normalmente ofrecen muy pocas instrucciones, pero es capaz de ejecutarlas muy rápido. Otros procesadores como los Intel tienen una arquitectura CISC, donde la C significa complejo y ofrecen varios cientos de instrucciones con tiempos de ejecución muy variados.

Tanto los nombres de los registros como los de las instrucciones se corresponden casi uno a uno con los procesadores MIPS, sin embargo hay algunas diferencias pequeñas diferencias entre ellos. Vamos, que si sabéis ensamblador MIPS también sabéis ensamblador RISC-V.

Si no sabéis ninguno, no os preocupéis, iremos explicando cada una de las instrucciones que usemos en nuestro programa.

RISC-V Registros

La arquitecura RISC-V define 32 registros de los cuales los 5 primeros tienen funciones especiales, y el resto se agrupan de acuerdo a la forma en la que se usan normalmente, si bien, esto es solo una convención. Los registros se nombran por su índice o por un nombre simbólico, al igual que sucede con los procesadores MIPS.

x0      zero    Siempre devuelve 0
x1      ra      Dirección de Retorno
x2      sp      Puntero de pila
x3      gp      Puntero Global
x4      tp      Puntero de Hebra
x6      t0      Temporal/Dirección de Retorno Alternativa
x6-7    t1-2    Registros temporales
x8      s0/fp   Registros Guardados/Frame Pointer
x9      s1      Registro Guardado
x10-11  a0-a1   Parametros Funciones/Valores Retorno
x12-18  a2-7    Parámetros Funciones
x18-27  s2-11   Registros Guardados
x28-31  t3-t6   Temporales

En nuestro programa nosotros usaremos los registros a0-a7 para hacer las llamadas al sistema y los registros t0-t6 para almacenar nuestros valores temporales.

En nuestro caso no vamos a utilizar funciones, así que, en realidad, podemos utilizar los registros que nos de la gana, pero en un programa normal, tendremos varias funciones y unas llamarán a otras. Los registros que aparecen en la lista como Registros Guardados deben ser guardados por la función que es llamada. Es decir nuestra función no debe modificar esos registros, puesto que el código que llame a nuestra función, va a esperar que esos registros se preserven. En otras palabras, si queremos usar esos registros en un programa normal, debemos guardar sus valores originales y restaurarlos antes de volver de la función.

Como os adelantamos, en nuestra implementación no vamos a tener ese problema, pero en caso de que alguien se preguntara esto… bueno, pues ahí lo tenéis.

BF01. Leyendo el programa en memoria

Comenzaremos haciendo un programa muy sencillo que lea la entrada del usuario, y almacene esos datos en memoria. El código sería algo así:

    .text
    .global _start
    .equ MEM_SIZE, 4096
_start:
    # Lee datos desde entrada standard
    la   a1, prog  # Buffer de lectura
    move t1, zero  # Contador para saber la longitud del programa
bucle01:    
    li a7, 63   # SYS_read
    li a0, 0    # stdin
    li a2, 1    # leemos caracter a caracter
    ecall       # a0 = read (a7=SYS_read, a0=stdin, a1=prog, a2=1);
    blez a0, cont01 # Si a0 <= 0 dejamos de leer
    
    add t1, t1, 1 # Contamos un caracter
    add a1, a1, 1 # Actualizamos el pumtero
    j bucle01     # Repetimos
cont01:
    # Muestra la cadena leida por pantalla
    li a7, 64     # SYS_write
    li a0, 1      # a0 = stdout
    la a1, prog   # a1 = prog
    move a2, t1   # a2 = size
    ecall         # write (a7=SYS_write, a0=stdout, a1=prog, a2=size);
exit:
    li a7, 93   # Exit  
    li a0, 0
    ecall
prog:
    .fill MEM_SIZE  

Para los que no sepáis mucho de programación en ensamblador en GNU/Linux, vamos a daros un curso intensivo con este programa. Para los que ya sepáis… bueno, os podéis saltar la siguiente sección ;).

Curso intensivo de Ensamblador GNU/Linux

Para este cursillo intensivo, simplemente vamos a ir comentando las distintas partes del programa que os acabamos de mostrar. Vamos a ello:

    .text
    .global _start
_start:

Así suelen empezar todos los programas en ensamblador, independientemente del procesador. La primera línea .text indica que lo que sigue es código (no datos). La segunda declara un símbolo global llamado _start. Este es el símbolo que va a utilizar el linker como nuestro punto de entrada… o dicho de otra forma, es donde el programa va a empezar a ejecutarse.

A continuación tenemos la etiqueta _start: que indica la dirección en la que empezará nuestro programa.

Llamadas al sistema en RISC-V

Para poder hacer cosas como leer datos del usuario o imprimir mensajes en pantalla debemos hacer llamadas al sistema operativo o syscalls. Para cada sistema operativo esto se hace de una determinada forma y, dentro de cada sistema operativo, cada arquitectura usa un método distinto.

Para UNIX se suele utilizar lo que se conoce como System V ABI. ABI es el acrónimo de Application Binary Interface… es como un API, pero en vez de decirte que funciones puedes utilizar, te dice como llamar a esas funciones (entre otras cosas). Linux utiliza esta ABI y en general funciona de la siguiente forma:

  • Almacenamos en un registro el número de syscall que queremos invocar.
  • Almacenamos en otros registros los parámetros que queremos pasar a la syscall
  • Utilizamos un comando especial para pasar a modo kernel
  • Recibimos en un registro (o dos) el resultado de la operación. Normalmente se usa el mismo registro que el utilizado para indicar la syscall o el primer parámetro.

Para el caso de RISC-V:

La forma de ejecutar estar esta syscall sería:

  • Almacenamos el número de syscall en registro a7
  • Pasamos los parámetros en los registros a1-a6
  • Ejecutamos la instrucción ecall
  • Procesamos el resultado devuelto en a0

Poniendo como ejemplo la llamada al sistema SYS_READ (que tiene valor 63 para RISC-V) que tiene el siguiente prototipo C:

 ssize_t read(int fd, void buf[.count], size_t count);

El siguiente código leería un caracter de la entrada estándar (descriptor 0):

    li   a7, 63      # SYS_read
    move a0, zero    # stdin
    la   a1, buffer  # Puntero a nuestro buffer de lectura
    li   a2, 1       # leemos un caracter
    ecall           # SYS_read (a0 = 0, a1 = buffer, a2 = 1)

Expliquemos brevemente las instrucciones que hemos utilizado:

  • li (Load Immediate). Permite cargar un valor en registro.
  • la (Load Address). Permite cargar una dirección en un registro. Esta es una pseudo instrucción
  • move. Permite mover valores entre registros. Está también es una pseudo instrucción.

Pseudo Instrucciones

Los procesadores RISC tienen muy pocas instrucciones, como su propio nombre indica y sucede a menudo que, para realizar operaciones relativamente comunes es necesario utiliza dos instrucciones. En esos casos se definen pseudo instrucciones, es decir, instrucciones que no existen en el procesador, pero que se han especificado y los ensambladores entiende. ARM, MIPS y también RISC-V definen varias de estas pseudo instrucciones.

Para nuestro ejemplo anterior, veremos como se implementan las dos pseudo instrucciones de las que os hemos hablado. Vamos a comenzar con move que es más sencilla:

   mov t1, zero   <=> addi t1, zero, 0
   mov a0, a1     <=> addi a0, a1, 0

La instrucción addi Add Immediate nos permite sumar un valor numérico a un registro y almacenarlo en otro. Si le sumamos 0… bueno, estamos copiando el valor de un registro en otro… Véis por donde vamos no?

La instrucción li también se implementa usando addi en algunos casos:

   li a1, 10  <=> addi a1, zero, 10

La instrucción la es un poco más complicada. Se expande de la siguiente forma:

    la a1, buffer  <=> auipc a1, XX
                       addi  a1, a1, YY

La instrucción auipc r, 0xAABB realiza la siguiente operación:

r = 0xAABB0000 + PC

Es decir, toma el parámetro y lo carga en la parte alta del registro, y luego le suma el valor del contador del programa. Es una forma de direccionamiento relativo. El addi que sigue ajusta la parte baja de la dirección si es necesario. Afortunadamente no tenemos que preocuparnos de calcular todas estas cosas, el ensamblador lo hará por nosotros.

Podéis estudiar vosotros que instrucciones son pseudo instrucciones y en que se traducen utilizando el siguiente comando sobre vuestros binarios: riscv64-linux-gnu-objdump -d -M no-aliases PROG

Leyendo nuestro programa BrainF*ck en un buffer

Con todo lo que acabamos de ver el código para leer la entrada del usuario (el programa BrainF*ck) en un buffer sería tal que así:

    # Lee datos desde entrada standard
    la   a1, prog  # Buffer de lectura
    move t1, zero  # Contador para saber la longitud del programa
bucle01:    
    li a7, 63   # SYS_read
    li a0, 0    # stdin
    li a2, 1    # leemos caracter a caracter
    ecall       # a0 = read (a7=SYS_read, a0=stdin, a1=prog, a2=1);
    blez a0, cont01 # Si a0 <= 0 dejamos de leer
    
    add t1, t1, 1 # Contamos un caracter
    add a1, a1, 1 # Actualizamos el pumtero
    j bucle01     # Repetimos
cont01:
.data
prog:
    .fill MEM_SIZE  

Deberíais de identificar fácilmente la llamada al sistema SYS_read. También podéis ver como declaramos el buffer en la sección .data. Esto le dice al linker que, lo que pongamos a partir de ahí se cargará en un bloque de memoria con permisos de lectura y escritura, pero no de ejecución.

Veamos como funciona el bucle de lectura. Una vez que ejecutamos la llamada al sistema con ecall en el registro a0 tendremos el resultado de la operación. Para read un valor negativo indica un error, un valor 0 indica fin de fichero (EOF) y un valor 1 indica que hemos leído nuestro caracter correctamente.

Así que usamos la instrucción blez a0, cont01. Esta instrucción salta a cont01 si el valor de a0 es menor o igual que cero. Y si. Esta es también una pseudo instrucción. RISC-V solo define un par de instrucciones de salto condicional y el resto de posibles saltos se generan como pseudo instrucciones. En este caso:

blez r, offset   <=> bge zero, r, offset

Que significa _Salta si 0 es mayor o igual el registro r. Que es lo mismo que salta si r es menor o igual que cero.

Esa es nuestra condición de final de buffer. En caso de tener que hacer otra iteración simplemente incrementamos el puntero dentro de nuestro buffer y una variable que usamos para contar los caracteres leídos y saber el tamaño de nuestro programa.

Comprobando que vamos bien

Para comprobar que todo ha ido bien, podéis añadir una llamada a SYS_write para imprimir el buffer.

    # Muestra la cadena leida por pantalla
    li a7, 64     # SYS_write
    li a0, 1      # a0 = stdout
    la a1, prog   # a1 = prog
    move a2, t1   # a2 = size
    ecall         # write (a7=SYS_write, a0=stdout, a1=prog, a2=size);

Finalmente terminamos el programa con una llamada a SYS_EXIT para que todo termine de forma ordenada.

exit:
    li a7, 93   # Exit  
    li a0, 0
    ecall
prog:
    .fill MEM_SIZE  

El código que hemos escrito hasta el momento debería funcionar sin problemas. Leerá todo lo que escribamos en la consola hasta que pulsemos CTRL+D (esta es la forma de mandar un EOF desde la consola) y luego lo imprimirá en pantalla.

Ejecutando nuestro primer programa

La forma más sencilla de que podáis instalar todas las herramientas que necesitáis sin problemas de configuración especial de vuestra máquina es utilizando un contenedor docker. Eso sí, tenéis que tener docker configurado correctamente y eso no lo vamos a tratar en este artículo. Así que vamos a ello:

Supongamos que tenemos nuestro programa en ensamblador en un subdirectorio llamado bf najo nuestro directorio actual. Así que iniciamos un contenedor Debian montando ese directorio:

$ docker run -it --rm -v $PWD/bf:/opt/src debian:12
root@5bca9d382ba1:/# apt update
(...)
root@5bca9d382ba1:/# apt upgrade
(...)
root@5bca9d382ba1:/# apt install gcc-riscv64-linux-gnu qemu-user-static
root@5bca9d382ba1:/# cd /opt/src/
root@5bca9d382ba1:/opt/src# riscv64-linux-gnu-as -o bf-riscv.o bf-riscv.s
root@5bca9d382ba1:/opt/src# riscv64-linux-gnu-ld -o bf-riscv bf-riscv.o 
root@5bca9d382ba1:/opt/src# qemu-riscv64-static ./bf-riscv

O si tenéis alguna máquina RISC-V como mi Milk-V Duo, podéis subir el binario y ejecutarlo allí.

CONCLUSIONES

En este artículo hemos aprendido a programar en BrainFuck y unas cuantas cosas de como programar ensamblador para RISC-V, Hemos programa el esque- leto de nuestro intérprete y el código para leer pro- gramas desde la consola y procesarlos. En el próximo número añadiremos los comandos básicos para poder empezar a ejecutar nuestros propios programas.

Continua Leyendo...

Intérprete de Brainf@ck para RISC-V. Parte II

Header Image Credits: Andrea Piacquadio

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í :)