Introducción
En este artículo, discutiremos cómo trabajar con más de 64 KB de memoria RAM y hacerlo de manera sencilla aprovechando las ventajas que ofrece MSX-DOS2.
¿Qué es la paginación de RAM?
El procesador Z80 solo puede direccionar 64 KB de memoria. Para superar esta limitación, los diseñadores del sistema MSX idearon la creación de 4 slots a los que el Z80 podría acceder, y luego dividieron cada uno de estos slots en 4 subslots. Estos subslots son los que se presentarían al Z80 para proporcionar más memoria. Más adelante, se introdujeron los mapas de memoria que permitían asignar subslots dentro de un slot a diferentes segmentos de memoria. Los mapas de memoria tomaban el control de estos segmentos, rompiendo así la limitación de 64 KB de memoria del MSX. Estos segmentos de memoria son lo que llamamos una página de RAM, y su control se conoce como paginación de RAM.
MSXDOS2 contiene una serie de llamadas a su BIOS (BDOS) responsables de gestionar todo este manejo de la memoria, evitándonos el desafío de conocer la disposición de la memoria para cada modelo de MSX. En Fusion-C, utilizamos funciones como InitRamMapperInfo, que inicializa la asignación de memoria, y _GetRamMapperBaseTable, que actualiza los datos en la tabla MAPPERINFOBLOCK. Esta tabla contiene información sobre el estado de la memoria paginada, incluyendo las páginas utilizadas y libres.
Si deseas leer otra explicación sobre la paginación de RAM, puedes consultar esta publicación de Javier Lavandeira, que considero que está muy bien explicada.
¿Qué haremos en el código?
Para probar la paginación, cargaremos archivos en diferentes páginas de memoria, colocaremos estas páginas en el segmento 2 del Z80, que corresponde a la dirección 0x8000, y leeremos sus valores para verificar que se han cargado correctamente. Luego cambiaremos la página y volveremos a leer el valor. Esto es muy similar al ejemplo proporcionado en Fusion-C, pero a diferencia de ese ejemplo, cargaremos un archivo completo en la página de memoria elegida en lugar de cambiar solo un valor.
¿Por qué lo hacemos más complicado? La verdad es que C genera archivos más grandes que si programaras en lenguaje ensamblador de manera óptima. De esta manera, rápidamente se llena un segmento de 16K. Si puedes crear módulos de 16K con funcionalidad específica, puedes cargarlos en una página de memoria y el programa principal que se ejecuta en la página principal puede llamarlos. En otras palabras, programación modular. Por ejemplo, el juego "bricks" utiliza esta estrategia para cargar las animaciones iniciales y finales del juego, cada una de las cuales es un módulo de menos de 16K. "PantInic.c" y "PantFin.c" son los archivos C que permiten compilarlos y probarlos, mientras que "PantInic.c_mod" y "PantFin.c_mod" son los binarios compilados correspondientes de los anteriores, eliminando las funciones comunes que ya están presentes en la función principal y agregando una función de adaptación que proporciona acceso a estos módulos (los archivos anteriores se pueden encontrar en Gitlab).
Esto también permite la programación en paralelo, ya que puedes cargar y desarrollar los módulos de manera independiente por distintos desarrolladores.
Explicación del código
El programa se encuentra en el repositorio de Gitlab con el nombre "caBinMap.c" y consta de:
Como siempre, las primeras líneas (hasta la 8) contienen las bibliotecas que utilizaremos. La primera variable que encontramos (línea 10) es la descripción del archivo de DOS, el FCB, que se explica con más detalle en el artículo "Cargando imágenes en MSX". La variable BufferPagina es el búfer de 16KB donde se cargarán los bytes leídos del disco. Se define con la directiva __at 0x8000 para indicar al enlazador que coloque esta variable en la dirección 0x8000. Esta dirección coincide con el inicio de la página 2 del Z80.
Finalmente, tenemos la variable table, que contendrá información sobre las páginas de memoria de DOS. Esta información es la que imprimimos en la función printRamMapperStatus. Cada vez que la llamamos, mostrará en pantalla el número de slot, los segmentos de 16KB utilizados, los segmentos libres, los que ya hemos solicitado y los que están siendo utilizados por el usuario.
A continuación, tenemos la función FT_SetName (que es la misma que vimos en "Cargando imágenes en MSX") y su función de control de errores correspondiente, FT_errorHandler. Para completar las funciones de lectura de disco, tenemos la función FT_LoadBin en la línea 78. Se asemeja mucho a FT_LoadSc5Image (de "Cargando imágenes en MSX"), pero aquí solo carga los datos en el búfer sin necesidad de una llamada posterior a HMMC para transferir datos de RAM a VRAM. En la línea 90, cargamos el archivo completo en la página 2, en la dirección 0x8000, donde se almacena la variable BufferPagina.
En la línea 95, tenemos la función principal, que comienza definiendo las variables que se utilizarán. Tenemos un puntero p que se utilizará para leer diferentes posiciones de memoria, una variable de estado status de tipo SEGMENTSTATUS que almacenará información sobre el segmento solicitado y tres variables char llamadas segmentId0, segmentId1 e initialSegment que almacenarán el número de segmento, devuelto por SEGMENTSTATUS.
En la línea 102, inicializamos la paginación de memoria DOS2 llamando a su BIOS con un valor de 4, que es el ID del dispositivo (deviceID) para el paginador de memoria. Borramos la pantalla con Cls e imprimimos el estado del paginador, obteniendo:
En la línea 109, solicitamos la asignación de un segmento usando el comando AllocateSegment(0,0), donde el primer 0 indica que debe ser un Segmento de Usuario y el segundo 0 indica asignación automática. Guardamos el valor de retorno de la función en status y el segmento asignado en segmentId0, luego imprimimos esta información en la pantalla.
Repetimos el mismo proceso para otro segmento y almacenamos la información en segmentId1. Lo que vemos en la pantalla en este punto es:
En esta sección, podemos observar que el número de Segmentos de Usuario ha aumentado a 2, y el número de Segmentos Libres (segmentos no asignados) ha disminuido a 3.
Limpiamos la pantalla y leemos la dirección de memoria 0x8000, almacenándola en la variable p (línea 123). Guardamos el segmento que estaba en la página 2 como initialSegment e imprimimos el segmento original y el valor que leemos de p en este segmento original.
Entre las líneas 130 y 134, colocamos el primer segmento reservado (segmentId0) en la página 2 y leemos su valor. Almacenamos el valor 0xDD en la dirección 0x8000 y mostramos este valor. Finalmente, imprimimos el valor del segmento en la página 2 obtenido con la función Get_PN().
Así es como se ve la pantalla en este punto:
El primer segmento que estaba en la memoria era el #1, que tenía un valor de 0xCD. Este valor depende de los valores que había en la RAM en ese momento; no establecimos un valor específico. Cambiamos al segmento 4, donde había un valor de 0x01, y lo reemplazamos por 0xDD. Finalmente, verificamos que el valor que obtenemos con Get_PN(2) es el mismo que establecimos usando Set_PN(2).
Entre las líneas 142 y 155, hacemos algo similar, pero esta vez con segmentId1. Primero, imprimimos el número de segmento de segmentId1 en la pantalla y luego movemos este segmento a la página 2 del Z80. Imprimimos el valor en la dirección 0x8000, lo sobrescribimos en la memoria y lo leemos nuevamente. En la línea 148, cargamos el archivo binario "16k_2.bin", que es un archivo de 16K donde cada byte tiene un valor de 2, y verificamos los valores en las direcciones 0x8000 y 0x800A, que deberían mostrar ambos el valor 2. Finalmente, verificamos que el segmento leído por Get_PN es el mismo que establecimos con Set_PN. Esto es lo que aparece en nuestra pantalla:
Entre las líneas 157 y 161, volvemos a colocar segmentId0 en la página 2 del Z80 y leemos el valor 0xDD, que es lo que habíamos guardado en la línea 133 de nuestro código.
Las líneas siguientes hasta la 171 hacen lo mismo que antes, pero esta vez, cargamos un archivo binario ("16k_1.bin") que solo contiene el valor 1 y lo cargamos en segmentId0. Luego volvemos a poner segmentId1, que contenía los valores de #2 durante 16K, y leemos dos posiciones, verificando que siguen teniendo el valor #2 (si estuviéramos utilizando el depurador de openmsx, podríamos ver que segmentId1 está lleno de 1s). Esto es lo que aparece en nuestra pantalla:
Entre las líneas 173 y 180, recuperamos el segmento original en la página 2 (initialSegment) y verificamos que todavía tiene el mismo valor que vimos en la tercera pantalla (en la línea que dice "Before setting segments #1"), que es 0xCD. Como se puede ver en la captura de pantalla:
Finalmente, liberamos los diferentes segmentos y mostramos el estado del Mapper, lo que produce la siguiente salida en pantalla:
Como podemos ver, no ha habido erroes y por tanto los segmentos libres vuelven a ser 5, y los usados por el ususario son 0.
Resumen y utilidades de la paginación
Quería demostrar cómo cargar bloques de 16K, ya que esto se utiliza en el juego Bricks para cargar las animaciones iniciales y finales. Dado que el código no estaba muy optimizado, ocupó rápidamente 32K de espacio de programa. Fue entonces cuando surgió la idea de dividir la aplicación: el bucle principal ocupa 32K y se encarga de gestionar diferentes estados, como presentación, selección de jugadores, jugabilidad y presentación final. También maneja la parte del juego. Las animaciones iniciales y finales son cada una de menos de 16K, y como se explicó al principio, se desarrollaron de manera independiente. Más tarde, se eliminaron las funciones que ya estaban en el bucle principal, como la carga de archivos y las interrupciones de la música. Estos módulos se cargan en la página 2 del Z80. La última página (página 3) es utilizada por el controlador de música para transcribir un archivo MBM cargado en la memoria y configurar el OPLL para reproducir la música.
¿Cómo compilamos estos archivos para que se carguen en la página 2?
Si compilamos los archivos anteriores como de costumbre, SDCC colocará todas las funciones a partir de la dirección 0x106. Es por eso que cambiamos las opciones a --code-loc 0x8000 (en lugar de la dirección 0x106 habitual, como se explica en la publicación de Introducción en este blog).
Una vez que tenemos las funciones en las direcciones de la página 2, necesitamos cargar este binario de manera similar a lo que hicimos en este tutorial, pero almacenarlo en una matriz en la dirección 0x8000. Las órdenes que hacen esto son:
__at 0x8000 char pag2[0x4000];
FT_openFile("PantIni.bMo");
FcbRead(&file, pag2, 0x4000);
Con esto, obtenemos el MSX cargado con todos los opcodes en la dirección 0x8000.
En el archivo del módulo, debe haber una función que llame a la función principal del módulo. En el caso de Bricks, era:
void CallMain_asm()__naked {
__asm
call _animacio_pantalla_inicial
ret
__endasm;
}
Finalmente, debes llamar a esta función en el módulo principal. Para hacerlo, debes investigar el archivo .map generado para encontrar la dirección de la función anterior y llamarla directamente. Suponiendo que la dirección era 0x8EA6, lo llamarías así:
__asm call #0x8ea6 __endasm;
Tal vez no esté lo suficientemente claro, y se debería hacer una publicación más detallada. Si es así, no dudes en hacérnoslo saber.
Finalmente, algo de tarea. La paginación también nos permite crear una aplicación sencilla para verificar la RAM del sistema. Con la información de RamMapperBaseTable, podemos determinar rápidamente la cantidad de RAM visible para DOS y verificar si todos los bytes se pueden escribir y leer. ¿Quién se anima a desarrollarlo en C?
Haz clic aquí para ver el ejemplo en funcionamiento.
Comments