¿Què es la paginación de la memoria?

El Z80 solo puede gestionar 64K simultáneamente. Para superarlo, la arquitectura MSX utiliza Slots, dividiendo el espacio en cuatro páginas de 16K. Aunque esto ya existía en el MSX1, fue con el MSX2 y la aparición del Memory Mapper cuando se pudo gestionar hasta 4MB de RAM. Este mapper se controla mediante los puertos de entrada/salida 0xFC-0xFF. Como cada fabricante ubicaba la RAM en slots diferentes (por ejemplo, el Philips VG-8235 la tiene en el slot 3-2, mientras que un Sony HB-F1XDJ la tiene en el 3-0), y si además tenía algún mapeador de memoria (memory mapper) adicional para disponer de más RAM, la gestión era compleja. Por eso, el MSX-DOS2 introdujo un gestor de memoria estándar que permitía a los programadores utilizar la RAM sin preocuparse de la arquitectura física del modelo.

En este artículo investigaremos esta gestión y cómo poder utilizarla en nuestros programas. En el artículo Paginación de la RAM ya tratamos este tema y explicamos las funciones de DOS2 en Fusion-C; aquí veremos las equivalentes en MSXgl.

Además, como hemos profundizado en el conocimiento del entorno sdcc, veremos cómo podemos enlazar de una forma menos costosa los diferentes módulos. En el artículo había una gestión de desarrollo como un módulo aparte y después su integración. Esta estrategia también sirve para MSXgl, pero aquí presentaremos una nueva (igualmente compleja pero más eficiente) que también se puede aplicar a Fusion-C.

¿Qué funciones usaremos de MSXgl?

bool DOS_Mapper_Init()
La función para inicializar el control del mapeador de memoria.
u8 DOSMapper_GetPage(u8 page)
Devuelve el número de segmento de la página page.
u8 DOSMapper_GetPage0()
Devuelve directamente el número de segmento de la página 0. Mientras que en la anterior podíamos escoger una de las 4 páginas, aquí va directamente a la 0, haciendo que la ejecución sea un poco más rápida. También tenemos el equivalente para las otras 3 páginas: DOSMapper_GetPage1, DOSMapper_GetPage2 y DOSMapper_GetPage3.
bool DOSMapper_Alloc(u8 type, u8 slot, DOS_Segment* seg)
Para solicitar a DOS2 que ubique 16K de memoria que podamos utilizar y modifique la información de la estructura de segmento. El type puede ser de dos tipos: DOS_ALLOC_USER, donde la memoria reservada se libera cuando nuestro programa termina, y DOS_ALLOC_SYS, que una vez finaliza el programa sigue reservada. Esta última es útil para crear RAM disks o para la memoria de algún driver. slot es la página/slot donde queremos que se ubique: 0,1,2,3. En el caso de DOS2, recomiendo utilizar las páginas 1 y 2, ya que en la parte baja de la 0 y en la parte alta de la 3 hay variables del sistema y hay que tener cuidado al manipularlas para no dañar el sistema y que se quede colgado.

Ejemplo de paginación

Hemos creado un pequeño ejemplo para trabajar la paginación en DOS2. La primera parte cambia los segmentos en la página/slot 1 (dirección 0x4000) y escribe diferentes valores en el mapa de memoria. La segunda parte llama a funciones creadas en otros archivos y que han sido cargadas en la página 1. El código del programa principal se encuentra en el archivo paginate.c y las dos funciones que se llaman en los archivos funcio1.c y funcio2.c

También utilizaremos la librería Debug que nos permite escribir mensajes en la consola del PC a través de openMSX activando el dispositivo -ext debugdevice y que también nos servirá para ver lo que está haciendo el MSX.

  1#include "dos_mapper.h"
  2#include "dos.h"
  3#include "print.h"
  4#include "input.h"
  5#include "vdp.h"
  6#include "bios.h"
  7#include "debug.h"
  8
  9#include "font/font_mgl_std3.h"
 10
 11#include "funcions.h"
 12
 13__at(0x4000) u8 array[0x4000];
 14__at(0x8000) u8 res_func1;
 15__at(0x8001) u8 res_func2;
 16
 17u8 startingSeg;
 18u8 newSeg;
 19u8 newSeg2;
 20DOS_Segment g_newSeg;
 21DOS_Segment g_newSeg2;
 22DOS_Segment g_func1;
 23DOS_Segment g_func2;
 24u8 g_StrBuffer[128];
 25DOS_FCB g_File;
                

Empezamos colocando los includes de las librerías que utilizaremos:

  1. dos_mapper.h contiene las funciones del mapeo que hemos explicado más arriba.
  2. dos.h nos permitirá cargar las funciones externas en memoria y controlar la disquetera.
  3. print.h para poder escribir mensajes en la pantalla del MSX.
  4. input.h para controlar las teclas del MSX.
  5. vdp.h podremos gestionar los diferentes modos de pantalla del MSX.
  6. bios.h contiene diferentes llamadas que utiliza el DOS y que también nos permite volver al DOS2 cuando termina el programa.
  7. debug.h aquí encontramos las diferentes funciones para escribir en la consola del PC los mensajes que el MSX va enviando.
  8. font/font_mgl_std3.h la librería MSXgl incluye diferentes tipologías de fuentes (tipos de letra) que pueden ser cargadas. En este caso utilizaremos la mgl_std3. Si queréis cambiar la tipografía, probad variando este valor para encontrar una que os guste más.
  9. funcions.h la definición de las funciones externas que se encuentran en los archivos funcio1.c y funcio2.c.

Y pasamos ahora a las variables. En primer lugar, array, que es de 16K y nos reserva la memoria para que el linker no coloque ninguna otra función o variable en la página 1 que iremos cargando con los diferentes bloques de código de las funciones externas. Aunque hemos de tener en cuenta, tal y como Aoineko ha señalado, que la documentación del SDCC dice que si ponemos manualmente contenido usando las directivas __at, no hay ninguna garantía que este espacio no sea sobreescrito por contenido acumulado del compilador. Por tanto, si nuestro programa peta, una de las causas puede ser esta sobreescritura. Las dos variables que guardarán el retorno de las funciones externas que llamamos son res_func1 y res_func2. Después guardamos el número de página inicial en newSeg y el retorno de asignar las dos nuevas páginas. Luego tenemos las estructuras de DOS_Segment, que guardarán la información de los segmentos asignados. Finalmente, las variables para leer del disquete: el buffer de lectura g_StrBuffer y la estructura del archivo DOS g_File.

 27void FT_SetName(DOS_FCB *p_fcb, const char *p_name) {
 28  char i, j;
 29  Mem_Set(0, p_fcb, sizeof(DOS_FCB));
 30  for (i = 0; i < 11; i++) {
 31    p_fcb->Name[i] = ' ';
 32  }
 33  for (i = 0; (i < 8) && (p_name[i] != 0) && (p_name[i] != '.'); i++) {
 34    p_fcb->Name[i] = p_name[i];
 35  }
 36  if (p_name[i] == '.') {
 37    i++;
 38    for (j = 0; (j < 3) && (p_name[i + j] != 0) && (p_name[i + j] != '.');
 39         j++) {
 40      p_fcb->Name[8 + j] = p_name[i + j];
 41    }
 42  }
 43}
            

Esta es la función que hemos utilizado muchas veces y que se encarga de poner el nombre en la estructura de lectura de archivos DOS_FCB para poder leer los bytes del archivo indicado

 45void main(){
 46  VDP_SetMode(VDP_MODE_TEXT2);
 47
 48  VDP_SetColor(0xF0);
 49	VDP_FillVRAM_16K(0, 0x0000, 0x4000); // Clear VRAM
 50	
 51	Print_SetTextFont(g_Font_MGL_Std3, 1);
 52	Print_SetColor(0xF, 0x0);
 53
 54  DOSMapper_Init();
 55  DEBUG_INIT();

            

Las diferentes inicializaciones de la pantalla, eligiendo el modo de pantalla y borrando la VRAM. También inicializamos la tipografía de fuente que hemos escogido, así como el mapeador de memoria y el debug.

 57	Print_SetPosition(0, 0);
 58	Print_DrawText("We write value 1 to the starting segment");
 59  for (u16 j=0; j<0x3fff; j++) {
 60    array[j] = 1;
 61  }
 62  Print_DrawText("\nValue written.\n");
 63
 64	Print_DrawCharX('-', g_PrintData.ScreenWidth);
 65  Print_DrawText("\nNow we add a new segment and we put it on page 1");
 66  startingSeg = DOSMapper_GetPage(1);
 67  DEBUG_LOGNUM("startingSeg number, value returned by alloc: ", startingSeg);
 68
 69  newSeg = DOSMapper_Alloc(DOS_ALLOC_USER, DOS_SEGSLOT_PRIM, &g_newSeg);
 70  DEBUG_LOGNUM("newSeg number, value returned by alloc: ", newSeg);
 71  DEBUG_LOGNUM("g_newSeg number", g_newSeg.Number);
 72
 73  DOSMapper_SetPage(1, g_newSeg.Number);
 74  Print_DrawText("\nSegment changed. Now we write value 2");
 75  for (u16 j=0; j<0x3FFf; j++) {
 76    array[j] = 2;
 77  }
 78  Print_DrawText("\nDone.\n");
 79  DEBUG_LOG("Written value 2 to segment g_newSeg 2");
 80
 81	Print_DrawCharX('-', g_PrintData.ScreenWidth);
 82  Print_DrawText("\nNow we add a new segment and we put it again on page 1");
 83  newSeg2 = DOSMapper_Alloc(DOS_ALLOC_USER, DOS_SEGSLOT_PRIM, &g_newSeg2);
 84  DEBUG_LOGNUM("newSeg2 value returned by alloc", newSeg2);
 85  newSeg = DOSMapper_GetPage1(); 
 86  DEBUG_LOGNUM("newSeg value returned by getPage", newSeg);
 87  DOSMapper_SetPage(1, g_newSeg2.Number);
 88  DEBUG_LOG("set in page 1 g_newSeg2");
 89  Print_DrawText("\nSegment changed. Now we write value 3");
 90  for (u16 j=0; j<0x3FFf; j++) {
 91    array[j] = 3;
 92  }
 93  Print_DrawText("\nDone");
            

La línea 57 se encarga de posicionar el cursor para escribir las frases que iremos mostrando. Después de explicar en pantalla lo que haremos, creamos un bucle donde asignamos el valor 1 a todas las posiciones del bloque de 16K. En la línea 66 recuperamos el número de segmento que había al inicializar la aplicación. En la 69 reservamos 16K de la memoria de usuario y lo guardamos en la estructura g_newSeg; el valor devuelto si ha funcionado bien es 0xFF, que guardamos en newSeg. En la línea 73 colocamos en la página 1 este nuevo segmento y lo llenamos con el número 2. En las líneas 81-93 hacemos lo mismo para el segmento g_newSeg2, pero ahora guardamos el valor 3 en este nuevo segmento. Hemos ido asignando valores a los segmentos utilizando la variable array, que hemos dicho que se encuentra en la dirección 0x4000, que tiene un tamaño de 16K (0x4000 bytes), lo que corresponde a toda la página 1 del Z80. Así, el procesador ha creído que siempre llenaba la página 1, pero nosotros hemos cambiado esta página por diferentes segmentos que corresponden a direcciones distintas.

En este punto, si vamos al menú Debugger de openMSX y escogemos la opción Add hex editor y después slotted memory, veremos toda la memoria del ordenador y, si navegamos arriba y abajo, nos será fácil ver los bloques con los números 1, 2 o 3. En mi caso, estos comenzaban en 0x8000, 0x14000 y 0xC4000.

En la imagen de abajo veréis un momento de la simulación donde se pueden observar los valores de la memoria en la página 1 y en la memoria general:

 96  DOSMapper_SetPage(1, g_newSeg.Number);
 97  Print_DrawText("Changed segment g_newSeg , read value: ");
 98  Print_DrawInt(array[3]);
 99  DEBUG_LOGNUM("Changed segment g_newSeg: ", array[3]);
            

Ahora hacemos los pasos inversos, volviendo a los segmentos que habíamos guardado para leer los valores de estos segmentos cuando vuelven a estar en la página 1. Imprimimos los resultados tanto en pantalla utilizando Print_DrawInt como en la consola con DEBUG_LOGNUM.

101  DOSMapper_Alloc(DOS_ALLOC_USER, DOS_SEGSLOT_PRIM, &g_func1);
102  DOSMapper_Alloc(DOS_ALLOC_USER, DOS_SEGSLOT_PRIM, &g_func2);
103
104  DOSMapper_SetPage(1, g_func1.Number);
105
106  Print_DrawText("\n");
107  Print_DrawCharX('-', g_PrintData.ScreenWidth);
108  Print_DrawText("\nReading file funcio1.bin. ");
109  DEBUG_LOG("Reading file funcio1.bin");
110  FT_SetName(&g_File, "funcio1.bin");
111  DOS_OpenFCB(&g_File);
112  for (u16 i=0; i<g_File.Size; i+=128) {
113    DOS_SetTransferAddr(&array[i]);
114    DOS_SequentialReadFCB(&g_File);
115  }
116  res_func1 = 4;
117  DEBUG_LOGNUM("Value before func1: ", res_func1);
118  Print_DrawText("\nBefore func1: ");
119  Print_DrawInt(res_func1);
120  res_func1 = funcio1();
121  DEBUG_LOGNUM("Value after calling func1: ", res_func1);
122  Print_DrawText("\nAfter func1: ");
123  Print_DrawInt(res_func1);
124
125  Print_DrawText("\nReading file funcio2.bin. ");
126  DEBUG_LOG("Reading file funcio2.bin");
127  DOSMapper_SetPage(2, g_func2.Number);
128  FT_SetName(&g_File, "funcio2.bin");
129  DOS_OpenFCB(&g_File);
130  for (u16 i=0; i<g_File.Size; i+=128) {
131    DOS_SetTransferAddr(&array[i]); // Els he de guardar a array, llegir directament a allà
132    DOS_SequentialReadFCB(&g_File);
133  }
134
135  res_func2 = 16;
136  DEBUG_LOGNUM("\nBefore func2: ", res_func2);
137  Print_DrawText("Before func2: ");
138  Print_DrawInt(res_func2);
139  res_func2 = funcio2();
140  DEBUG_LOGNUM("After func2: ", res_func2);
141  Print_DrawText("\nAfter func2: ");
142  Print_DrawInt(res_func2);
143
144  Print_DrawText("\nWe have run the two function in different pages");
145  Print_DrawText("\nYou can debug the memory page in openmsx debugger");
146
147  Print_DrawText("\nPress space to go back to DOS");
148  while (!Keyboard_IsKeyPressed(KEY_SPACE));
149
150  Bios_Exit(0);
151}
            

Ya hemos terminado la primera parte del test, en la que cambiamos bancos de memoria. En la segunda parte hacemos lo mismo, pero ahora en cada nuevo segmento guardaremos una función. Recordemos que estos módulos con las funciones adicionales no pueden superar los 16K.

Empezamos reservando estos segmentos en las líneas 101 y 102, para después colocar el primero en la página 1. Mostramos diferente información por pantalla y en el log sobre el estado del experimento. Cargamos el binario de la función funcio1, que se encuentra en el archivo funcio1.bin, en el segmento recientemente asignado g_func1 entre las líneas 110 y 115. Asignamos el valor 4 a la variable res_func1, leemos y mostramos este valor, llamamos a la función funcio1 y volvemos a leer y mostrar el valor de la variable res_func1.

Entre las líneas 125 y 145 hacemos lo mismo que antes, pero ahora para la función funcio2.

Ya solo queda escribir el texto para que se pulse la barra espaciadora y esperar a que presionen esta tecla para finalizar.

1#pragma codeseg funcio1
2
3extern void DEBUG_LOG(const char* msg);
4
5unsigned char funcio1(){
6  DEBUG_LOG("funcio1 has been called");
7  return 0x5;
8}

1#pragma codeseg funcio2
2
3extern void DEBUG_LOG(const char* msg);
4
5unsigned char funcio2(){
6  DEBUG_LOG("funcio2 has been called");
7  return 0xde;
8}

Aquí tenemos las dos funciones que hacen lo mismo, devolver un valor, pero este valor es diferente. Ambas utilizan la función DEBUG_LOG, que se encontrará en los 16K principales iniciales, en la función principal, que en este caso es la de paginate.c, y es el linker (enlazador) el que se encargará de buscar su dirección cuando realice la unión de todos los binarios.

A diferencia del artículo anterior, Paginación de la RAM, aquí no cargamos de nuevo la función DEBUG_LOG, ya que está en la memoria de la función principal. De esta forma, los módulos quedan más pequeños, pero aumentamos la complejidad para realizar correctamente el enlace.

La directiva del compilador #pragma codeseg funcio1 sirve para indicarle al compilador que todo este código debe ir a una dirección concreta de la memoria. Así, cuando cree los archivos .map y traduzca el código a binario, utilizará la dirección indicada en el momento de compilar, cuando le pasemos el parámetro --code-loc 0x4000 a sdcc.

En la siguiente imagen podéis ver la ejecución del script tanto en la pantalla emulada del MSX como en la salida de la consola. Podemos ver cómo realiza los diferentes pasos y cómo los valores leídos corresponden a los valores esperados según el cambio de página que se había realizado.

¿Cuáles son los pasos para compilar estos módulos de 16K?

Para obtener los binarios de las funciones de 16K correctamente dirigidos a las funciones que ya se encuentran en la parte del programa principal, se deben seguir los siguientes pasos:

  1. 1- Compilar el archivo paginate.c
    
    ~/MSXgl/tools/sdcc/bin/sdcc -c -mz80 -DTARGET=TARGET_DOS2 -DMSX_VERSION=MSX_TR 
    -I~/MSXgl/projects/learning-msxgl/DOS2_pagination/ 
    -I~/MSXgl/engine/src 
    -I~/MSXgl/engine/content 
    -I~/MSXgl/tools/  
    --opt-code-speed --debug -DAPPSIGN ./paginate.c 
    -o ~/MSXgl/projects/learning-msxgl/DOS2_pagination/out/
            

    Hemos utilizado el mismo comando que usábamos para compilar el archivo principal del módulo en MSXgl. En este paso, el compilador crea las etiquetas para que posteriormente el linker pueda ubicar las funciones en el espacio de memoria, creando el archivo .rel. Este archivo contiene el código máquina, las definiciones de símbolos, las referencias externas y permite ser reubicado en memoria (la extensión .rel proviene de relocate, que significa reubicar).

  2. 2- Compilar las funciones
    
    ~/MSXgl/tools/sdcc/bin/sdcc -mz80 -c --codeseg funcio1 funcio1.c -o funcio1.rel
    ~/MSXgl/tools/sdcc/bin/sdcc -mz80 -c --codeseg funcio2 funcio2.c -o funcio2.rel
                

    Utilizamos el parámetro --codeseg para indicar que cree un segmento en memoria llamado funcio1 y que después podremos ubicar en la posición que queramos al enlazarlo con el parámetro de sdcc -Wl-g_funcio1.

  3. 3- Enlazar paginate.c
    
    ~/MSXgl/tools/sdcc/bin/sdcc -mz80 --vc --no-std-crt0 -L~/MSXgl/tools/sdcc/lib/z80 
    --code-loc 0x0100 --data-loc 0x0000  --opt-code-speed --debug  
    ~/MSXgl/projects/learning-msxgl/DOS2_pagination/out/crt0_dos.rel 
    ~/MSXgl/projects/learning-msxgl/DOS2_pagination/out/msxgl.lib 
    ~/MSXgl/projects/learning-msxgl/DOS2_pagination/out/paginate.rel 
    -o ~/MSXgl/projects/learning-msxgl/DOS2_pagination/out/paginate.ihx 
    funcio1.rel funcio2.rel
                

    Una vez tenemos los archivos .rel, se debe utilizar el enlazador de sdcc para colocarlos definitivamente en memoria y resolver todos los símbolos y referencias. Por eso también debemos proporcionarle los binarios de las funciones funcio1.rel y funcio2.rel que hemos compilado anteriormente y que son llamadas desde paginate.c.

  4. 4- Buscar la dirección de DEBUG_LOG y añadirla en el linker
    
    ~/MSXgl/tools/sdcc/bin/sdcc -mz80 --no-std-crt0 --code-loc 0x4000 funcio1.rel -o funcio1.ihx -Wl-g_DEBUG_LOG=0x1a87 -L~/MSXgl/tools/sdcc/lib/z80;
    
    ~/MSX/MSXgl/tools/sdcc/bin/sdcc -mz80 --no-std-crt0 --code-loc 0x4000 funcio2.rel -o funcio2.ihx -Wl-g_DEBUG_LOG=0x1a87 -L~/MSXgl/tools/sdcc/lib/z80;
            

    Ahora debemos buscar la dirección que tiene DEBUG_LOG en el programa principal paginate.c para indicarle a los módulos de 16K que cuando realicen la llamada a esta función salten a la dirección del programa principal. Una vez localizada en el archivo paginate.map, debemos indicárselo al linker. Esto lo hacemos con el parámetro -Wl-g, indicando el nombre de la función que aparece en el .map, en este caso _DEBUG_LOG, y la dirección obtenida.

  5. 5- Generar los binarios y el archivo .com
    
    ~/MSXgl/tools/MSXtk/bin/MSXhex ~/MSXgl/projects/learning-msxgl/DOS2_pagination/out/paginate.ihx -e com -s 0x0100 -l 0;
    
    ~/MSXgl/tools/MSXtk/bin/MSXhex ~/MSXgl/projects/learning-msxgl/DOS2_pagination/funcio1.ihx -e bin;
    
    ~/MSXgl/tools/MSXtk/bin/MSXhex ~/MSXgl/projects/learning-msxgl/DOS2_pagination/funcio2.ihx -e bin;
            

    Para traducir los binarios al formato de MSX, utilizamos la herramienta de MSXgl MSXhex. Observemos que la invocamos de forma diferente para el programa principal que para las funciones, ya que el primero debe seguir el formato .com de MSXDOS2, mientras que los binarios son solo la conversión.

  6. 6- Copiar el .com y los binarios al directorio emulado
    
    cp funcio1.bin emul/dos2/;
    cp funcio2.bin emul/dos2/;
    cp out/paginate.com emul/dos2/;
                
  7. 7- Ejecutar el emulador
    
    ~/openMSX/derived/openmsx 
    -machine Panasonic_FS-A1ST -ext moonsound -ext debugdevice
    -ext msxdos2 -diska ~/MSXgl/projects/learning-msxgl/DOS2_pagination/emul/dos2
             

    Con esto ya tendremos el emulador en funcionamiento y podremos ver el resultado de nuestras simulaciones tal y como se muestra en la imagen superior.

¿Podemos automatizar este proceso?

MSXgl ya permite crear proyectos de más de 64K, pero deben ser para ROM y te ayuda a compilarlos. Sin embargo, nosotros podemos crear un script en JavaScript para que MSXgl lo utilice.

¿Cómo funciona la compilación en MSXgl?

El archivo build.sh, que se encuentra en el directorio de nuestro proyecto, llama a node, que es el comando del proyecto Node.js que permite ejecutar JavaScript fuera del navegador. El script que se ejecuta es el que se indica en build.sh, que en este caso es el que se encuentra en .../engine/script/js/build.js, encargado de orquestar toda la compilación. Lo primero que busca es ./project_config.js y, si no lo encuentra, busca .../projects/default_config.js y, si tampoco lo encuentra, .../engine/script/js/default_config.js. La estructura de estos archivos es la misma y contiene los diferentes pasos para compilar el proyecto. Finalmente, además de los anteriores (de los cuales solo carga uno según la prioridad), también carga, en caso de existir, [project_name].js.

El archivo project_config.js permite cargar scripts antes de la compilación principal y scripts después de la compilación. Esta característica es la que utilizaremos para compilar nuestros drivers antes de la compilación principal y después, en el postproceso, localizar las funciones del bloque principal que se utilizan en los módulos. Para ello, solo hay que rellenar las variables PreBuildScripts y PostBuildScripts con los comandos a ejecutar. En nuestro caso, hemos creado un script en JavaScript, build_pagination.js, que se encarga de realizar esto. Por lo tanto, nuestro código quedaría así:

            
231//-- Command lines to be executed before the build process (array)
232PreBuildScripts = [	"node build_pagination.js pre" ];
233
234//-- Command lines to be executed after the build process (array)
235PostBuildScripts = [ "node build_pagination.js post" ];
            
        

Script para automatizar la compilación paginada

Una vez tenemos claros los pasos, vamos a ver cómo podemos incluirlos en un script de JavaScript para continuar utilizando el lenguaje que se usa en MSXgl. Esta implementación se encuentra en el archivo build_pagination.js.

  1#!/usr/bin/env node
  2// ____________________________
  3// ██▀▀█▀▀██▀▀▀▀▀▀▀█▀▀█        │   ▄▄▄                ▄▄
  4// ██  ▀  █▄  ▀██▄ ▀ ▄█ ▄▀▀ █  │  ▀█▄  ▄▀██ ▄█▄█ ██▀▄ ██  ▄███
  5// █  █ █  ▀▀  ▄█  █  █ ▀▄█ █▄ │  ▄▄█▀ ▀▄██ ██ █ ██▀  ▀█▄ ▀█▄▄
  6// ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀────────┘
  7// DOS2 Pagination Build Handler - Pre & Post Build Steps
  8
  9const fs = require('fs');
 10const path = require('path');
 11const { execSync } = require('child_process');
 12
 13const PROJECT_DIR = __dirname;
 14const OUT_DIR = path.join(PROJECT_DIR, 'out');
 15const EMUL_DIR = path.join(PROJECT_DIR, 'emul', 'dos2');
 16
 17// Load PaginatedFunctions from project_config.js
 18let PaginatedFunctions = [];
 19try {
 20    // Create a context object to capture the PaginatedFunctions variable
 21    const configCode = fs.readFileSync(path.join(PROJECT_DIR, 'project_config.js'), 'utf-8');
 22    const configModule = {};
 23    // Execute the config file in a controlled context
 24    const vm = require('vm');
 25    const context = vm.createContext({ PaginatedFunctions: [] });
 26    vm.runInContext(configCode, context);
 27    PaginatedFunctions = context.PaginatedFunctions;
 28
 29    if (!PaginatedFunctions || PaginatedFunctions.length === 0) {
 30        console.error('PaginatedFunctions not found or empty in project_config.js');
 31        process.exit(1);
 32    }
 33} catch (err) {
 34    console.error(`Error loading PaginatedFunctions from project_config.js: ${err.message}`);
 35    process.exit(1);
 36}
 37
 38const SDCC_PATH = '/home/jepsuse/MSX/MSXgl/tools/sdcc/bin';
 39const MSXHEX = '/home/jepsuse/MSX/MSXgl/tools/MSXtk/bin/MSXhex';
 40
 41const colors = {
 42    reset: '\x1b[0m',
 43    green: '\x1b[32m',
 44    yellow: '\x1b[33m',
 45    red: '\x1b[31m',
 46    blue: '\x1b[36m'
 47};
 48
 49function log(msg, color = 'reset') {
 50    console.log(`${colors[color]}${msg}${colors.reset}`);
 51}
 52
 53function exec(command, description) {
 54    log(`  ${description}...`, 'yellow');
 55    try {
 56        execSync(command, {
 57            cwd: PROJECT_DIR,
 58            stdio: 'pipe'
 59        });
 60        log(`  ✓ Done`, 'green');
 61    } catch (error) {
 62        log(`  ✗ Failed: ${error.message}`, 'red');
 63        throw error;
 64    }
 65}

Comenzamos definiendo las variables que utilizaremos a lo largo del proceso de compilación. Cabe destacar la variable PaginatedFunctions, que contendrá el nombre de los archivos que queremos compilar como funciones para ser llamadas posteriormente. Este valor se obtiene de la variable con el mismo nombre PaginatedFunctions del archivo project_config.js.

 69function preBuild() {
 70    log('\n╔════════════════════════════════════════════════╗', 'blue');
 71    log('║   DOS2 Pagination - PRE-BUILD                  ║', 'blue');
 72    log('║   Step 1: Compile paginated functions          ║', 'blue');
 73    log('╚════════════════════════════════════════════════╝\n', 'blue');
 74
 75    if (!PaginatedFunctions || PaginatedFunctions.length === 0) {
 76        log('No paginated functions configured. Skipping pagination pre-build.\n', 'yellow');
 77        return;
 78    }
 79
 80    log('Compiling paginated functions:', 'yellow');
 81
 82    // Create out directory if it doesn't exist
 83    if (!fs.existsSync(OUT_DIR)) {
 84        fs.mkdirSync(OUT_DIR, { recursive: true });
 85    }
 86
 87    for (const func of PaginatedFunctions) {
 88        const file = `${func.name}.c`;
 89        if (!fs.existsSync(path.join(PROJECT_DIR, file))) {
 90            log(`  ✗ ${file} not found`, 'red');
 91            continue;
 92        }
 93        const cmd = `"${SDCC_PATH}/sdcc" -mz80 -c "${file}" -o "${OUT_DIR}/${func.name}.rel"`;
 94        exec(cmd, `  ${func.name}.c → out/${func.name}.rel`);
 95    }
 96
 97    log('\n✓ Pre-build complete. Main build will now process paginated functions.\n', 'green');
 98}

Esta es la primera función que se ejecuta antes de la compilación estándar de MSXgl y que se encarga de crear los binarios de los archivos de un máximo de 16K que utilizaremos en nuestro programa.

100/**
101 * Extract extern function declarations from a C source file and its includes.
102 * Looks for patterns like: extern void FUNCTION_NAME(...);
103 */
104function extractExternFunctions(filePath) {
105    const externFuncs = [];
106    const processedFiles = new Set();
107
108    function processFile(currentPath) {
109        // Avoid processing the same file twice
110        if (processedFiles.has(currentPath)) {
111            return;
112        }
113        processedFiles.add(currentPath);
114
115        if (!fs.existsSync(currentPath)) {
116            return;
117        }
118
119        const content = fs.readFileSync(currentPath, 'utf-8');
120
121        // Extract extern declarations: extern <return_type> FUNCTION_NAME(...)
122        const externRegex = /extern\s+\w+[\s\*]+(\w+)\s*\(/g;
123        let match;
124
125        while ((match = externRegex.exec(content)) !== null) {
126            const funcName = match[1];
127            if (!externFuncs.includes(funcName)) {
128                externFuncs.push(funcName);
129            }
130        }
131
132        // Extract #include directives and process them
133        const includeRegex = /#include\s+["<]([^"<>]+)[">]/g;
134        while ((match = includeRegex.exec(content)) !== null) {
135            const includePath = match[1];
136            // Try to find the included file in the project directory
137            const fullPath = path.join(PROJECT_DIR, includePath);
138            processFile(fullPath);
139        }
140    }
141
142    processFile(filePath);
143    return externFuncs;
144}
145
146/**
147 * Find symbol address in MAP file by function name.
148 * Looks for patterns like:
149 *   00001AA5  _DEBUG_LOG                         debug
150 */
151function findSymbolInMap(mapFile, funcName) {
152    const content = fs.readFileSync(mapFile, 'utf-8');
153    const lines = content.split('\n');
154
155    // Look for the symbol with underscore prefix: _FUNCNAME
156    const symbolName = '_' + funcName;
157
158    for (const line of lines) {
159        // Match: ADDRESS  _SYMBOL_NAME  module
160        const match = line.match(/^\s*([0-9A-Fa-f]+)\s+_[\w]+\s+/);
161        if (match && line.includes(symbolName)) {
162            const addr = '0x' + match[1];
163            return addr;
164        }
165    }
166
167    return null;
168}
169
170function postBuild() {
171    log('\n╔════════════════════════════════════════════════╗', 'blue');
172    log('║   DOS2 Pagination - POST-BUILD                 ║', 'blue');
173    log('║   Step 2: Relink & convert paginated functions ║', 'blue');
174    log('╚════════════════════════════════════════════════╝\n', 'blue');
175
176    if (!PaginatedFunctions || PaginatedFunctions.length === 0) {
177        log('No paginated functions configured. Skipping pagination post-build.\n', 'yellow');
178        return;
179    }
180
181    // Step 2a: Verify MAP file exists
182    log('Step 2a: Checking for main program MAP file...', 'yellow');
183    const mapFile = path.join(OUT_DIR, 'paginate.map');
184
185    if (!fs.existsSync(mapFile)) {
186        log(`   MAP file not found at ${mapFile}`, 'red');
187        log('     The main program must be linked first to generate the MAP file.\n', 'red');
188        return;
189    }
190    log('   MAP file found\n', 'green');
191
192    // Step 2b: Relink each paginated function at specified address with required symbols
193    log('Step 2b: Relinking paginated functions at specified addresses...', 'yellow');
194
195    for (const func of PaginatedFunctions) {
196        const name = func.name;
197        const pageAddr = typeof func.page === 'string' ? parseInt(func.page, 16) : func.page;
198        const codeLoc = `0x${pageAddr.toString(16).toUpperCase()}`;
199        const relFile = path.join(OUT_DIR, `${name}.rel`);
200
201        if (!fs.existsSync(relFile)) {
202            log(`   ${relFile} not found`, 'red');
203            continue;
204        }
205
206        // Extract extern function declarations from this source file
207        const sourceFile = path.join(PROJECT_DIR, `${name}.c`);
208        const externFuncs = extractExternFunctions(sourceFile);
209
210        if (externFuncs.length === 0) {
211            log(`  Relinking ${name} at ${codeLoc} (no external dependencies)...`, 'yellow');
212        } else {
213            log(`  Relinking ${name} at ${codeLoc} with ${externFuncs.length} external function(s):`, 'yellow');
214        }
215
216        // Find each extern function's address in the MAP file
217        let linkerFlags = '';
218        for (const funcName of externFuncs) {
219            const addr = findSymbolInMap(mapFile, funcName);
220            if (addr) {
221                linkerFlags += ` -Wl-g_${funcName}=${addr}`;
222                log(`    ${funcName} = ${addr}`, 'green');
223            } else {
224                log(`     ${funcName} NOT FOUND in MAP file!`, 'red');
225            }
226        }
227
228        const cmd = `"${SDCC_PATH}/sdcc" -mz80 --no-std-crt0 --code-loc ${codeLoc} ` +
229            `"${relFile}"${linkerFlags} ` +
230            `-L"${SDCC_PATH}/../lib/z80" -o "${OUT_DIR}/${name}.ihx"`;
231
232        exec(cmd, `  Relinking ${name} at ${codeLoc}`);
233    }
234
235    // Step 2c: Convert IHX to binary format
236    log('\nStep 2c: Converting IHX files to binary format...', 'yellow');
237
238    for (const func of PaginatedFunctions) {
239        const name = func.name;
240        const ihxFile = path.join(OUT_DIR, `${name}.ihx`);
241
242        if (fs.existsSync(ihxFile)) {
243            const cmd = `"${MSXHEX}" "${ihxFile}"`;
244            exec(cmd, `  Converting ${name}.ihx  ${name}.bin`);
245        }
246    }
247
248    // Step 2d: Copy files to emulator directory
249    log('\nStep 2d: Copying binaries to emulator directory...', 'yellow');
250
251    if (!fs.existsSync(EMUL_DIR)) {
252        fs.mkdirSync(EMUL_DIR, { recursive: true });
253    }
254
255    for (const func of PaginatedFunctions) {
256        const name = func.name;
257        const binFile = path.join(OUT_DIR, `${name}.bin`);
258
259        if (fs.existsSync(binFile)) {
260            fs.copyFileSync(binFile, path.join(EMUL_DIR, `${name}.bin`));
261            log(`   Copied ${name}.bin`, 'green');
262        }
263    }
264
265    log('\n Post-build complete!\n', 'green');
266}
267
        

Esta es la parte que se ejecuta una vez ha terminado la compilación estándar de MSXgl. La primera función extractExternFunctions se encarga de buscar el nombre de las funciones que están definidas en el archivo C como extern; estos nombres son los que después la función findSybolInMap deberá localizar en el archivo map para encontrar la dirección de memoria que el link le ha asignado. Estas dos funciones son las que se utilizan en el postBuild para relinkar nuestros programas que irán a las páginas de 16K y saltarán a la dirección correspondiente.

268// Determine which phase to run
269const args = process.argv.slice(2);
270const phase = args[0] || 'pre';
271
272try {
273    if (phase === 'pre') {
274        preBuild();
275    } else if (phase === 'post') {
276        postBuild();
277    } else {
278        log(`Unknown phase: ${phase}`, 'red');
279        process.exit(1);
280    }
281} catch (error) {
282    log(`\n✗ Build failed!\n`, 'red');
283    process.exit(1);
284}
        

Finalmente, solo queda controlar si cuando se llama al script se hace en la fase de prebuild o en la de postbuild y, según de dónde venga la llamada, ejecutar un bloque u otro.

Función driver de MSXgl

Dentro del extenso ejemplo de MSXDOS2 que se puede encontrar en el directorio samples, s_dos2.c, hay una parte llamada driver que permite cargar binarios en memoria y llamarlos. Esto es más parecido a lo que habíamos hecho en Paginación de la RAM, donde el binario es autocontenido y no utiliza funciones comunes con el bloque principal. Pero la forma de compilarlo es más sencilla.

¿Qué pasos debemos seguir para utilizar la funcionalidad de driver?

  1. Crear el binario y compilarlo

    Primero de todo se debe tener el programa funcionando; en nuestro ejemplo sencillo, driv1.c, solo devolvemos un valor cuando se llama a la función. Para compilarlo podemos utilizar la misma plantilla que se usa para compilar los archivos en C de MSXgl; en nuestro caso la hemos copiado en driv1.js y se debe cambiar la línea:

    
    //-- Sobrescribir la dirección de inicio del código (número). Por ejemplo, 0xE000 para un driver en RAM
    ForceCodeAddr = 0xE000;
                    
    indicando la dirección de memoria donde será guardado. De esta forma, el linker redirige todas las direcciones de memoria a partir de esta base.

  2. Llamarlo en el programa principal

    Una vez creado, se debe cargar en memoria y llamarlo desde el programa principal. En nuestro caso este es pag_driver.c y para llamarlo hacemos:

    
    res_func1 = CallDriver(0xE000, 5);
                    
    Para cargarlo en memoria hemos usado las mismas instrucciones de disco utilizadas anteriormente.

  3. Compilar el programa principal

    Ya solo queda compilar el programa principal y ejecutarlo con el emulador.