miércoles, 13 de marzo de 2024

Implementación de BEGIN AGAIN/UNTIL

BEGIN - AGAIN es un ciclo infinito que se expresa en assembler como una secuencia de instrucciones terminadas con un JMP de vuelta a la primera instrucción del ciclo.
inicio: inst1
        inst2
         ...
        inst n
        jmp inicio

BEGIN es un palabra inmediata, es decir, es ejecutada durante la compilación. Durante la compilación, el BEGIN simplementa deja en el stack la dirección de la primera celda libre en memoria, donde se deja la palabra construida. Esa es la misma celda que ocupara la primera instrucción dentro del bloque.

Cuando la compilación encuentra el AGAIN (tambien inmediata), este toma la dirección en el stack y la graba en la celda libre, a continuación de la última instrucción del bloque. En J1s, una dirección es también un salto a esa dirección.

Ejemplo

: x begin 1 + again ;

compila en

>see x
 c0e  8001    ; cargar 1 en el stack
 c0f  6203    ; sumar
 c10  c0e     ; salto a la primer instrucción
 c11  608c    ; Retorno (agregada por default a toda palabra)
 c12  0       ; Marca de fin.

 

BEGIN - UNTIL

Este es un ciclo que se repite mientras UNTIL (también inmediata) vea un cero en el tope del stack. Su implementación es la misma del BEGIN - AGAIN, pero con un salto condicional:

: x begin 1 + until ;

compila en

>see x
 c18  8001    ; Cargar 1 en el stack
 c19  6203    ; Sumar
 c1a  2c18    ; Salto condicional si stack == 0 a c18.
 c1b  608c    ; Retorno (agregado por default)
 c1c  0       ; Marca de fin.

Balanceo

La parte interesante es asegurarse de que los BEGIN - AGAIN/UNTIL esten bien anidados.

Las celdas son de 16 bits, pero las direcciones sólo tiene 12 bits. Podemos aprovechar esos 4 bits marcar las direcciones que dejamos en el stack. Para ello usaremos la siguiente asignación:

0x1000 constant sys-begin
0x2000 constant sys-if
0x3000 constant sys-do
...

Entonces, BEGIN coloca en el stack el valor 0x1xxx, donde xxx es la dirección donde ocurre el BEGIN.

Cuando la compilación encuentra el AGAIN/UNTIL, en el stack debe haber una celda con el valor 1 en los bits 15-12. Si tiene cualquier otro valor, hay algo mal anidado y se aborta la compilación.

Recuerde que todo esto pasa durante la compilación de una palabra, no durante la ejecución.

Implementación 

Finalmente, este el código que implementa las estructuras de control.

\ Convierte un valor en inst. de carga
: make-load ( n -- n ) 0x7FFF invert + ;
\ Convierte una dirección en salto condicional
: make-branch ( n -- n ) 0x2000 or ;
\ Convierte un valor en un sys-valor
: make-sys ( addr n -- sys-n ) or ;
\ Revisa el sys-valor en el stack;
: sys-check ( n sys-code -- n ) over 0x7000 and = ;

\  make-clean borra los bits 15-12 (definido en el kernel)

: begin sys-begin here make-sys ; immediate
: until sys-begin sys-check
      if make-clean make-branch ,
      else ." UNTIL desbalanceado" quit then ; immediate
: again sys-begin sys-check
      if make-clean ,
      else ." AGAIN desbalanceado" quit then ; immediate
 


jueves, 7 de marzo de 2024

Implementación de S"

La palabra S" (s-quote) sirva para almacenar un string constante en el heap, retornando la dirección del primer caracter y el largo, tal cual lo hace COUNT

Cuando el intérprete encuentra

s" hola chao"

genera la siguiente estructura en memoria :

                salto sobre_string
largo_string:   9
inicio_string:  h
                o
                ...
                a
                o             
sobre_string:   cargar dir_string 
                cargar largo_string

No hay instrucción de retorno, pues la palabra ha sido puesta en línea (inlining).

Este ejemplo tonto es una palabra que crea un string con una expresión ( 1 1 + ). El string se entrega al intérprete, que lo ejecuta, imprimiendo el resultado.

: x
s" 1 1 + " evaluate . ;

Y asi luce en memoria:

>: x s" 1 1 + " evaluate . ;

>see x
 a94  a9c     ; Salto sobre el string a la dirección a9c.
 a95  6       ; Largo del string.
 a96  31      ; 1
 a97  20      ; espacio
 a98  31      ; 1
 a99  20      ; espacio
 a9a  2b      ; +
 a9b  20      ; espacio
 a9c  8a96    ; Carga el stack con 0xa96, dir comienzo string
 a9d  8006    ; Carga el stack con 6, el largo.
 a9e  4037    ; Llamada a EVALUATE.
 a9f  93c     ; Llamada a . (imprime tope del stack)
 aa0  608c    ; return

Implementación

: s"
    0x22 word     \ El contenido del texto
    dup @ 1+      \ ( -- addr largo+1 )
    here swap     \ ( -- addr here largo+1 )
    2dup + 1+ ,   \ Salto sobre el contenido del string
    dup allot
    swap 1+ swap  \ Correr el comienzo para hacer espacio al jump
    2dup >r >r    \ ( -- addr here largo+1 : R -- largo+1 here )
    cmove         \ ( --  : R -- largo+1 here )
    r>
    1+
    dup 0x7FFF invert or ,
    r>
    1-
    dup 0x7FFF invert or ,
    swap
    ; immediate

Nota: Una celda con el bit superior en 1 es una instrucción que carga en el stack el contenido de los bits 14-0. Para transformar un valor (6, por ejemplo) en una instrucción de carga, hay que poner el bit 15 en uno. La secuencia 

0x7FFF invert

produce el valor 0x8000, y luego or con el valor deseado resulta en la instrucción de carga (en este caso, 0x8006) que se grabara en memoria. 


miércoles, 6 de marzo de 2024

El diccionario


En el modelo de memoria de la J1s la unidad de almacenamiento y la celda tienen el mismo tamaño (16 bits), lo mismo que los caracteres (una celda por caracter). Existe sólo un heap donde se almacena código y valores.

El diccionario es una simple lista enlazada donde los elementos se agregan por el comienzo. Todas las búsquedas son secuenciales y recorren desde la palabra más reciente hasta la primera.

CREATE

Cuando el intérprete ejecuta CREATE XYZ, este es el resultado en memoria

Formato Entrada Diccionario

Primero encontramos el nombre de la palabra almacenado como counted string. Luego vienen tres punteros: 

  • El primero apunta a la palabra previa. El fin del diccionario se marca con un cero en este campo.
  • El segundo puntero lleva al nombre almacenado.
  • El tercer puntero lleva al código ejecutable compilado de la palabra.

 CREATE genera dos instrucciones por default, según el standard: 

  • Cargar en el stack la dirección siguiente a la entrada (cola en el diagrama).
  • Retornar.

CREATE no reserva memoria más allá del RETURN; eso depende del uso posterior. Por ejemplo, para definir una variable primero se usa CREATE y luego se reserva una celda adicional para dejar en ella el valor.

El formato es flexible, pues permite colocar el nombre y código en cualquier otra parte de la memoria. Después de todo, se accede ellos vía punteros.

En particular, las primeras palabras del diccionario tienen nombre y código ya definidos (en assembler) antes de crear la entrada. No tiene sentido copiar nombre y código; simplemente se ajustan los punteros.

Palabras asociadas

dict ( -- addr )

Retorna dirección de la última palabra agregada al diccionario (o sea, el principio de la lista).

search ( <nombre> -- addr )

Lee un nombre desde la entrada y busca la palabra en el diccionario. Retorna puntero a la entrada correspondiente (cero si no se encuentra).

entry-name ( addr -- addr )

Dado el puntero a una entrada, retorna la dirección del counted string con el nombre de la palabra.

entry-code ( addr -- addr )

Dado el puntero a una entrada, retorna la dirección del código asociado.

forget ( <nombre> -- )

Lee un nombre de la entrada y elimina la palabra del diccionario, junto con todos las palabras definidos después de él. La memoria se recicla. Si la palabra no existe, no se hace nada.


domingo, 3 de marzo de 2024

Estado del Proyecto

Un computador no es solo una CPU que pueda ejecutar instrucciones. Para ser una máquina útil, requiere de hardware y software con los que accionar con el mundo real.

J1s: la CPU

  wire [16:0] minus = {1'b1, ~st0} + st1 + 1;
  wire signedless = st0[15] ^ st1[15] ? st1[15] : minus[16];

  always @*
  begin
    // Compute the new value of st0
    casez ({pc[12], insn[15:8]})
      9'b1_???_?????: st0N = insn;                    // literal
      9'b0_1??_?????: st0N = { {(`WIDTH - 15){1'b0}}, insn[14:0] };    // literal
      9'b0_000_?????: st0N = st0;                     // jump
 

El módulo CPU (Verilog) está completo. Queda por mejorar el módulo UART, usado para comunicación serial y que oficia de consola/tty (falla en reconocer apropiadamente los caracteres de entrada, por su baja tasa de muestreo).

Como quedan bits sin usar en el juego de instrucciones, hay espacio para definir nuevas instrucciones de máquina que pueden ser utiles para reducir el tamaño final del código.

Otros módulos por implementar son I2C y SPI, de uso habitual para conectar sensores y actuadores con microcontroladores.

También hay que implementar la configuración dinámica de los pines (de Entrada a Salida y viceversa), un reloj por hardware (necesario para el timing en la interacción con sensores y actuadores), entre otros.

 

J1asm: El ensamblador

  30           ;-------------------------- Kernel ----------------------
  31           ;
  32           ;
  33 0000 8000     0                   ;   Marca el fin del stack de retorno.
  34 0001 6127     >r
  35 0002 4f64     call init
  36           
  37           try:
  38               ;   Ejecutamos la palabra REPL, que ejecuta el ciclo
  39               ;   de leer y evaluar una línea de programa.
  40               ;   Este ciclo es infinito, por lo que code_evaluate
  41               ;   no debiera retornar nunca, pero ...
  42 0003 8007     repl
  43 0004 44e3     call move_2_TIB
  44 0005 402e     call code_evaluate

 

El ensamblador (Python) está completo. Su único objetivo real es compilar el kernel de Forth, por lo que sus capacidades se limitan a lo estrictamente necesario.

Es un ensamblador de dos pasos que genera directamente la imagen a cargar en la RAM de la tarjeta FPGA. También genera información de ayuda a la emulación.

J1emu: El emulador

El emulador (Python) es una aplicación de línea de comando. Está completo;  funciona adecuadamente y sin él habría sido imposible depurar el kernel.

Pero hay una larga lista de deseos, el primero de los cuales es convertirlo en una aplicación ncurses, para ordenar mejor la información presentada y permitir la interrupción del código desde el teclado en cualquier momento.

Otra función útil es un "mapa de calor" de la ejecución, que permita identificar donde gasta su tiempo la CPU y saber así que se debe optimizar.

Forth: El kernel

 

El kernel de Forth (una mezcla de Assembler y Forth) está completo. Puede ejecutar cualquier código Forth válido. Si una palabra no existe, se puede crear dentro de la misma implementación.

Queda escribir los drivers para el hardware y las librerías para procesar los diversos sensores y actuadores a conectar.

Por ahora la tarea es reducir el tamaño del kernel a menos de 2K celdas, dejando así otros 2K libres.

¿Un compilador Forth?

Trabajando con microprocesadores, el espacio es un recurso escaso. Una manera de reducir aún más el código en aplicaciones reales es compilar Forth mas la aplicación en el PC, descartando todo el código sin uso.