En esta tercera y última entrega vamos a extender nuestro intérprete con la capacidad
de ejecutar bucles BF
y así completar la implementación en
ensamblador RISC-V.
Comencemos con un breve recordatorio de los comandos [
y
]
. El comando [
comprueba el valor apuntado
por el puntero de datos. Si es cero continua la ejecución como si nada,
pero en caso de ser distinto de cero, deberá saltar al comando
]
correspondiente. Si, podemos anidar los bucles en
BF
. Veamos un ejemplo
>++[>++[>++++[<<<++++>>>-]<-]<-]<+.
1 2 3 210 123 2 1 0 Puntero de datos
En este ejemplo podemos ver 3 bucles anidados, cada uno utilizando un
contador en una posición de memoria diferente. El primer contador se
inicializa a 2, el segundo contador, también a 2 y el tercero a 4. Esto
significa que el bucle más interno se va a ejecutar 16 veces
(2*2*4
). El bucle más interno es:
[<<<++++>>>-]
Este pedazo de código mueve el puntero de datos al primer dato en la memoria (justo antes del primer contador) e incrementa ese valor en 4. Luego vuelve a moverse a la posición con el contador del bucle actual, de decrementa y continua.
Si llamamos VAL a la primera posición de memoria, i al primer contador (segunda posición de memoria), j al segundo contador y k al tercero, la memoria irá cambiando de la siguiente forma en cada iteración:
VAL i j k
0 2 2 4 -> Entrada del bucle interno
4 2 2 3
8 2 2 2
....
16 2 2 0
16 2 1 4 -> Bucle interno de nuevo
20 2 1 3 ...
... -> Continuamos has que todos los contadores lleguen a cero
Así que el programa anterior imprime una A
. Primero
genera el número 64
con varios bucles y luego le suma
1
. El valor ASCII 65
es la letra
A
Que pasaría con el siguiente programa?
[>++[>++++[<<<++++>>>-]<-]<-]<+.
En este caso, el contador del bucle externo es 0
(no lo
hemos modificado) y por lo tanto debemos saltar todos los bucles
internos hasta el último ]
y continuar desde allí. A esto
nos referimos con el corchete correspondiente.
Como implementar los bucles
La implementación de los bucles tiene dos complicaciones. La primera es encontrar el corchete correspondiente y la segunda es saber a donde hay que retornar cuando el bucle se repite. Bueno, podéis implementar el segundo problema buscando el corchete correspondiente hacia atrás, pero eso es bastante ineficiente.
La forma de encontrar el corchete correspondiente que vamos a usar es recorrer el programa hacia adelante, sumando uno a una variable auxiliar cada vez que se abre un corchete, y restando uno cada vez que se cierra. Cuando esa variable valga 0 habremos encontrado el corchete que buscamos.
Tomemos nuestro programa de ejemplo. En la figura se muestra los
valores que toma esta variable auxiliar mientras buscamos el
]
correspondiente al primer [
>++[>++[>++++[<<<++++>>>-]<-]<-]<+.
1 2 3 2 1 0 Valores variable auxiliar
Para el segundo problema… bueno, simplemente usaremos la pila. Cada vez que entremos en un bucle almacenaremos en la pila la posición de retorno y cada vez que salgamos eliminaremos ese valor de la pila. De esta forma nuestros bucles se anidan de forma natural.
Ahora ya podemos escribir el código para los dos comandos que nos faltan.
Implementando [
Continuando con la última versión de nuestro programa del artículo anterior, vamos a incluir los nuevos comandos primero. Simplemente añadimos dos comparaciones en nuestro bucle principal.
(...), '['
li t5, t5, bucle_ini
beq t4, ']'
li t5, t5, bucle_fin
beq t4 (...)
Vamos con bucle-ini
. Esta rutina va a ser un poco más
larga, así que la vamos a ver por cachos. La primera parte es la
inicialización.
while_start:
, 0(sp) # ALmacenamos el contador de programa en la pila
sd t2add sp, sp, -8 # Actualizamos el puntero de pila.
# Estos dos comandos son un PUSH t2, 0(t6) # Comprobamos valor en memoria de datos
lb t4, cont02 # Si no es cero.. continuamos ejecución normal
bnez t4es cero... Salta al final del bucle
# Si data , 1 # a1 es nuestro contador de corchetes. Ya tenemos uno li a1
Lo primero que hacemos es almacenar el valor actual del contador de
programa en la pila de forma que podamos recuperarlo fácilmente cuando
haya que ejecutar una nueva iteración del bucle. En este caso utilizamos
el comando sd
que hace lo mismo que sb
pero
con una palabra doble (Double Word) por eso la d
final. Una vez almacenado el valor tenemos que actualizar el puntero de
pila sp
. Esta es la forma de hacer un PUSH
en
la pila de un RISC-V.
A continuación comprobamos el valor al que apunta el puntero de
datos. Si el valor no es cero, simplemente continuamos ejecutando. Es
decir, si el valor no es cero, es que estamos en un bucle y debemos
iterar una vez más. De lo contrario debemos saltarnos todo el cuerpo del
bucle y continuar la ejecución en la posición del ]
correspondiente.
Para ello, recorreremos el programa como os contamos, sumando uno por
cada [
y restando uno por cada ]
. Debemos
empezar con un valor 1
ya que hemos encontrado el primer
corchete. El código que hace esto lo podéis ver a continuación.
bucle03:
add t2, t2, 1 # Nos movemos a la siguiente instrucción
, 0(t2) # Leemos instruccion actual
lb t4, '[' # Si es un corchete incrementamos
li t5,t5, incrementa
beq t4, ']'
li t5, t5, decrementa
beq t4
j cont03incrementa:
add a1, a1, 1
j cont03decrementa:
add a1, a1, -1
cont03:
, bucle03 # Si a1 != 0 Seguimos buscando
bnez a1# SIno continuamos j cont02
Como podéis ver, el programa hace exactamente lo que dijimos que
haría. Observad como en la etiqueta decrementa
simplemente
dejamos que la ejecución continúe, no es necesario incluir un salto como
en incrementa
.
Implementando ]
El final del bucle es más sencillo, gracias al uso de la pila que
hemos preparado astutamente cuando encontramos el [
.
bucle_fin:
, 0(t6)
lb t4, zero, cont04 # Si data es cero.. terminamos el bucle
beq t4, 8(sp) # Sino restaura el último contador de programa
ld t2# y continua normalmente
j cont02 cont04:
add sp, sp, +8 # Al terminar el bucle limpiamos la pila
j cont02
El código es una vez más muy fácil de leer. Lo primero que hacemos es
comprobar el valor al que apunta el puntero de datos (nuestro contador
de bucle). Si el valor es cero, hemos terminado nuestro bucle, así que
lo único que tenemos que hacer es limpiar la pila (cont04
).
En caso de que estuviéramos ejecutando código dentro de otro bucle,todo
quedaría listo continuar la ejecución del bucle externo.
Veamos esto en nuestro programa de ejemplo.
0123456789ABCD
>++[>++[>++++[<<<++++>>>-]<-]<-]<+. PILA
^ ^ ^
| | +----------------------- 0xD (retorno bucle más interno)
| +----------------------------- 0X5 (retorno bucle intermedio)
+--------------------------------- 0x3 (retorno bucle externo
Cuando terminamos el bucle interno, eliminaremos de la pila el valor
0xD
de tal forma que, al encontrar el siguiente
]
en caso de que tengamos que continuar el bucle
intermedio, sabremos perfectamente a que dirección saltar. Observa que
cada vez que se repite el bucle intermedio se ejecuta un nuevo bucle
interno y el valor 0xD
volverá a aparecer.
Si el valor al que apunta el puntero de datos fuera distinto de
0
, simplemente restauramos el contador del programa con el
último valor de la pila. Sin embargo, observa que no eliminamos el valor
de la pila, de forma que todo sigue listo para continuar el bucle.
Y con esto nuestro intérprete de BF
estaría completo.
Pero antes de mostraros el código completo...
Últimos detalles
El último detalle que os queremos comentar tiene que ver con el formato del binario que producimos.
En nuestros ejemplos, hemos estado utilizando el segmento
.data
para declarar nuestro buffers de datos. Eso es
correcto, sin embargo tiene la consecuencia de que todo lo que pongamos
ahí va a acabar en el ejecutable. Así, si ponemos 4K de memoria para
almacenar nuestro programa y nuestros datos, el binario resultante va a
tener como mínimo 4Kb, aún cuando nuestro código es solo unos pocos
bytes.
Para un buffer de 2Kb; 1 Kb para el programa y 1 Kb para datos. El programa resultante tiene un tamaño de:
$ ls -l bf-riscv
-rwxr-xr-x 1 occam occam 4296 Mar 28 09:55 bf-riscv
Pero si miramos a los Program Headers:
$ riscv64-linux-gnu-readelf -l bf-riscv
Elf file type is EXEC (Executable file)
Entry point 0x100e8
There are 3 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
RISCV_ATTRIBUT 0x0000000000000a48 0x0000000000000000 0x0000000000000000
0x0000000000000037 0x0000000000000000 R 0x1
LOAD 0x0000000000000000 0x0000000000010000 0x0000000000010000
0x0000000000000248 0x0000000000000248 R E 0x1000
LOAD 0x0000000000000248 0x0000000000011248 0x0000000000011248
0x0000000000000800 0x0000000000000800 RW 0x1000
Vemos que nuestro código ocupa 248 bytes, pero nuestra sección de datos 2048 (0x800).
Lo que podemos hacer para reducir el tamaño de nuestro ejecutable es
poner nuestros buffers en la sección .bss
. Esta sección se
utiliza para almacenar datos no inicializados, si bien, en los sistemas
modernos, a la hora de comenzar la ejecución de nuestro programa, la
sección se inicializará con ceros. La idea es que si necesitamos datos
que tenga ciertos valores al comenzar el programa los pongamos en
.data
, pero sino los dejamos .bss
y el sistema
reservará la memoria al cargar el programa y la inicializará con
ceros.
Cambiemos .data
a .bss
en nuestro
programa:
$ ls -l bf-riscv-bss
-rwxr-xr-x 1 edma edma 2240 Mar 28 10:04 bf-riscv-bss
$ riscv64-linux-gnu-readelf -l bf-riscv-bss
Elf file type is EXEC (Executable file)
Entry point 0x100e8
There are 3 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
RISCV_ATTRIBUT 0x0000000000000248 0x0000000000000000 0x0000000000000000
0x0000000000000037 0x0000000000000000 R 0x1
LOAD 0x0000000000000000 0x0000000000010000 0x0000000000010000
0x0000000000000248 0x0000000000000248 R E 0x1000
LOAD 0x0000000000000248 0x0000000000011248 0x0000000000011248
0x0000000000000000 0x0000000000000800 RW 0x1000
Como podéis ver, el tamaño es ahora prácticamente la mitad. Si os
fijáis en la salida de readlef
veréis que el tamaño de la
zona de datos de memoria sigue siendo 2048, el tamaño en el fichero es
ahora 0.
Si pasamos nuestros programas por la utilidad strip
podremos eliminar alguna información no necesaria para ejecutar nuestros
programas y hacerlos más pequeños:
$ riscv64-linux-gnu-strip bf-riscv
$ riscv64-linux-gnu-strip bf-riscv-bss
$ ls -l bf-riscv*
-rwxr-xr-x 1 edma edma 1000 Mar 28 10:06 bf-riscv-bss
-rwxr-xr-x 1 edma edma 3048 Mar 28 10:06 bf-riscv
Ahora nuestro versión usando .bss
ocupa menos de 1 Kb,
mientras que la otra tiene que mantener los 2Kb de ceros que hemos
puesto en la sección data.
EL CÓDIGO FINAL
Como prometimos, aquí tenéis la versión final del programa con todo lo que hemos desarrollado en esta mini sería.
.text
.global _startequ MEM_SIZE, 2048
._start:
# Lee datos desde entrada standard, mem
la a1, zero
move t1, 0x20
li t2bucle01:
, 63
li a7, 0
li a0, 1
li a2
ecall<=0 (EOF) terminamos bucle
# Si , cont01
blez a0
, 0(a1)
lb t0,t2, bucle01 # Elimina espacios
ble t0
add t1, t1, 1
add a1, a1, 1
j bucle01cont01:
, mem # Contador de programa (2K inferiores código)
la t2, MEM_SIZE / 2
li t4add t6, t2, t4 # Puntero de datos (2K superiores datos)
12 bits con signo. Valor maximo 2047
# addi solo puede sumar numeros de add t3, t2, t1 # Apunta al final del programa
bucle02: # Main Loop
, 0(t2)
lb t4, '<'
li t5, t5, inc_data_ptr
beq t4, '>'
li t5, t5, dec_data_ptr
beq t4, '+'
li t5, t5, inc_data
beq t4, '-'
li t5, t5, dec_data
beq t4, '.'
li t5, t5, print_data
beq t4, ','
li t5, t5, read_data
beq t4, '['
li t5, t5, while_start
beq t4, ']'
li t5, t5, while_end
beq t4
# Simplemente ignora cualquier otro caracter
j cont02
while_start:
, 0(sp) # Push contador de programa
sd t2add sp, sp, -8
, 0(t6)
lb t4, cont02 # Si data no es cero.. continua,
bnez t4es cero... Salta al final del bucle
# Si data , 1 # En a1 contamos corchetes. Empezamos con 1
li a1bucle03:
add t2, t2, 1 # Nos saltamos el corchete actual
, 0(t2) # Leemos instruccion actual
lb t4[ Sumamos 1 a a1.
# Si encontramos ] restamos 1
# Si encontramos
# Cuando a1 valga cero habremos encontrado el corchete correcto, '['
li t5, t5, incrementa
beq t4, ']'
li t5, t5, decrementa
beq t4
j bucle03incrementa:
add a1, a1, 1 # Cuando sumamos nunca nos dará cero
j bucle03decrementa:
add a1, a1, -1
, bucle03
bnez a1
j cont02
while_end:
, 0(t6)
lb t4, zero, cont04 # Si data es cero.. continua,
beq t4, 8(sp) # Sino, salta al principio del bucle
ld t2
j cont02cont04:
add sp, sp, +8 # Hemos terrminado el bucle. Eliminamos de la pila
j cont02
inc_data_ptr:
add t6, t6, 1
j cont02dec_data_ptr:
add t6, t6, -1
j cont02inc_data:
, 0(t6)
lb t1add t1, t1, 1
, 0(t6)
sb t1
j cont02dec_data:
, 0(t6)
lb t1add t1, t1, -1
, 0(t6)
sb t1
j cont02print_data:
, 64
li a7, 1
li a0, t6
move a1, 1
li a2
ecall
j cont02read_data:
, 63
li a7, 0
li a0, t6
move a1, 1
li a2# Fallback en cont02
ecall cont02:
add t2, t2, 1 # Incrementa contador del programa
, t3, bucle02 # Mientras no alcancemos el final, repetimos
blt t2exit:
, 93 # Exit
li a7, 0
li a0
ecall
.bssmem: .space MEM_SIZE
CONCLUSIÓN
En esta pequeña serie hemos aprendido a programar en BF
y en ensamblador para la plataforma RISC-V de una sola tacada. También
hemos visto como construir programas de forma iterativa, como configurar
un entorno para compilarlos y ejecutarlos y algunos detalles como se
asigna memoria a ejecutables. Y os preguntaréis por que hemos hecho
esto?… La respuesta que todo buen geek daría es: Porque
podiamos.
■