spectrum:articulos:htprogemu

No renderer 'odt' found for mode 'odt'

Cómo programar un emulador

(por Marat Fayzullin)

Prohibida la distribución no autorizada. Enlaza a esta página, no la copies.

Escribí este documento tras recibir grandes cantidades de emails de gente que quería saber cómo escribir un emulador de una u otra computadora pero no sabía por donde empezar. Cualquier opinión o consejo contenido en el presente texto es solamente mio y no debe ser tomado como una verdad absoluta. El documento principalmente cubre los emuladores "interpretadores" en contraposición a los "compiladores", ya que no tengo mucha experiencia en técnicas de recompilación. Encontrarás varios enlaces a lugares donde puedes encontrar información sobre estas técnicas.

Si piensas que en este documento falta algo o quieres hacer una corrección, sé libre de enviarme por email tus comentarios. No contestaré a flames/idiocy o peticiones de ROMs. Probablemente faltan algunas direcciones FTP/WWW importante en la lista de recursos de este documento de modo que si conoces alguna que valga la pena poner aquí, dimelo. Y lo mismo para algunas FAQs (Preguntas Frecuentemente Contestadas) que no están en este documento.

Este documento ha sido traducido al japonés por Bero. Hay también traducciones al Chino disponibles, cortesía de Jean-Yuan Chen, y una traducción al Francés hecha por Guillaume Tuloup. Esta traducción al castellano está realizada por Santiago Romero. Nota del traductor: La versión original en Inglés del documento está en la página de Marat Fayzullin.

¿Así que has decidido escribir un emulador? Bien, entonces este documento puede ser una ayuda para ti. Cubre varias cuestiones técnicas comunes que la gente suele preguntar sobre la programación de emuladores. También te proveerá de las pautas que alguna manera podrás seguir en el funcionamiento interno del emulador.

  • General
    • ¿Qué puede ser emulado?
    • ¿Qué es "emulación y en qué se diferencia de "simulación"?
    • ¿Es legal emular el hardware propietario?
    • ¿Qué es un emulador "interpretador" y en qué se diferencia de un emulador "recompilador"?
    • Quiero programar un emulador: ¿Por dónde debería empezar?
    • ¿Qué lenguaje de programación debería usar?
    • ¿Dónde puedo obtener información del hardware emulado?
  • Implementación
    • ¿Cómo emulo una CPU?
    • ¿Cómo manejo los accesos a la memoria emulada?
    • Tareas cíclicas: ¿Qué son?
  • Técnicas de Programación
    • ¿Cómo optimizo el código C?
    • ¿Qué es low/high-endianess?
    • ¿Por qué debería hacer mi programa modular?

Básicamente, cualquier cosa que tenga un microprocesador dentro. Por supuesto, sólo los dispositivos ejecutando programas más o menos flexibles son interesantes para emular. Esto incluye:

  • Computadoras
  • Calculadoras
  • Consolas de VídeoJuegos
  • VídeoJuegos Arcade
  • etc.

Es necesario que te des cuenta de que puedes emular cualquier sistema o computadora, incluso si éste es muy completo (como la computadora Commodore Amiga, por ejemplo). No obstante, el rendimiento de una emulación así puede ser bastante lento.

Emulación es un intento de imitar el diseño interno de un dispositivo. Simulación es un intento de imitar las funciones de un dispositivo. Por ejemplo, un programa imitando el hardware del arcade Pacman y ejecutando la ROM real de Pacman en él es un emulador. Un juego de Pacman escrito para tu computadora pero usando gráficos similares a los del arcade real es un simulador.

Aunque esta materia entra dentro del área "gris", parece legal emular el hardware propietario, siempre que la información sobre él no haya sido obtenida de manera ilegal. Debes tener cuidado también la distribución ilegal de ROMs del sistema (BIOS, etc.) con un emulador si estas ROMS tienen copyright.

Hay tres esquemas básicos que pueden ser usados para un emulador. Pueden ser combinados para obtener el mejor resultado.

  • Interpretación: Un emulador lee código emulado byte a byte desde la memoria, lo decodifica y realiza las operaciones apropiadas en los registros emulados, memoria y E/S. Las virtudes de este modelo incluyen facilidad de depuración, portabilidad y facilidad de sincronización (puedes simplemente contar los cíclos de reloj que han ocurrido y adaptar el resto de tu emulación a este contaje de ciclos). Una debilidad simple, grande y obvia es el bajo rendimiento. La interpretación toma mucho tiempo de CPU y puedes necesitar una computadora bastante rápida para ejecutar el código a una velocidad decente. El algoritmo general para este tipo de emuladores es:
      while(CPUIsRunning)
      {
        Fetch OpCode
        Interpret OpCode
      }
  • Recompilación estática: En esta técnica se toma un programa escrito en el código emulado y se intenta traducir éste al código ensamblador de tu computadora. El resultado será un ejecutable que puedes hacer funcionar en tu computadora sin ninguna herramienta especial. Aunque esto suena muy bien, no siempre es posible. Por ejemplo, no puedes recompilar estáticamente código que se automodifique a él mismo ya que no hay manera de saber a qué hay que convertirlo sin ejecutarlo. Para evitar ese tipo de situaciones, es posible combinar la recompilación estática con un intérprete o recompilador dinámico.
  • Recompilación dinámica: La recompilación dinámica es esencialmente la misma cosa que la estática, pero ocurre durante la ejecución del programa. En lugar de intentar recompilar todo el código de una vez, se hace al vuelo cuando se encuentra una instrucción CALL o JUMP. Para incrementar la velocidad, esta técnica puede ser combinada con la recompilación estática. Puedes leer más sobre recompilación dinámica en el "white paper" de Ardi, creadores del emulador recompilador de Macintosh.

Para programar un emulador, debes tener un buen conocimiento general de programación de computadoras y de electrónica digital. La experiencia en programación en ensamblador es muy útil también.

  1. Selecciona un lenguaje de programación para usar.
  2. Encuentra toda la información disponible sobre el hardware emulado.
  3. Escribe la emulación de la CPU o utiliza código ya existenet para la emulación de la misma.
  4. Escribe algo de código en forma de borrador para emular el resto del hardware, al menos parcialmente.
  5. En este punto, resulta muy útil escribir un pequeño depurador interno que te permita detener la emulación y ver qué está haciendo el programa. Necesitarás también un desensamblador para el lenguaje ensamblador del sistema emulado. Escribe el tuyo propio si no existe ninguno disponible.
  6. Intenta ejecutar programas en tu emulador.
  7. Utiliza el desensamblador y el depurador para ver cómo los programas utilizan el hardware y adapta tu código de forma apropiada.

Las alternativas más obvias son C y Ensamblador. A continuación enumero los pros y contras de cada uno:

Lenguaje ensamblador

  • Generalmente, permite producir código más rápido.
  • Los registros de la CPU que realiza la emulación pueden ser utilizados directamente para almacenar los registros de la CPU emulada.
  • Muchos opcodes (códigos de operación) pueden ser emulados directamente con opcodes similares de la CPU emuladora.
  • El código es no portable, es decir, no puede ser ejecutado en una computadora con diferente arquitectura.
  • El código se hace difícil de depurar y mantener.

Lenguaje C

  • El código puede ser hecho portable de forma que funcione en diferentes computadoras y Sistemas Operativos.
  • Es relativamente fácil de depurar y mantener.
  • Diferentes hipótesis de cómo funciona el hardware real pueden ser testeadas rápidamente.
  • C es generalmente más lento que el código ensamblador puro.

Un buen conocimiento del lenguaje elegido es una necesidad absoluta para escribir un emulador que funcione, ya que es un proyecto bastante completo y el código debe ser optimizado para ejecutarse tan rápido como sea posible. La emulación no es definitivamente uno de esos proyectos donde se aprende un lenguaje de programación.

Lo siguiente es una lista de lugar en donde te puede interesar echar un vistazo.

  • Grupos de News
    • comp.emulators.misc : Este es un grupo de news para la discusión general sobre emulación de computadoras. Muchos autore de emuladores lo leen, aunque el nivel de "ruido" es bastante alto. Lee la FAQ de c.e.m. antes de postear mensajes en este grupo de news.
    • comp.emulators.game-consoles Lo mismo que comp.emulators.misc, pero dedicado específicamente a los emuladores de videojuegos de consola. Lee la FAQ de c.e.m antes de postear mensajes en este grupo de news.
    • comp.sys./emulated-system/ La jerarquía comp.sys.* contiene grupos de news dedicados a computadoras específicas. Puedes obtener un montón de información técnica leyendo estos grupso de news. Ejemplos típicos:
      • comp.sys.msx: Computadoras MSX/MSX2/MSX2+/TurboR
      • comp.sys.sinclair: Sinclair ZX80/ZX81/ZXSpectrum/QL
      • comp.sys.apple2: Apple ][
      • etc.
    • alt.folklore.computers
    • rec.games.video.classic
  • FTP
  • WWW

Lo primero de todo, si necesitas emular una CPU Z80 o 6502 estándar, puedes usar uno de los emuladores de CPUs que yo escribí. No obstante, deben aplicarse ciertas condiciones a su uso.

Para aquellos que quieren escribir su propio núcleo de emulación CPU o están interesados en cómo funciona, a continuación puede verse un esqueleto en C de un emulador de CPU típico. En el emulador real, tal vez desees evitar algunas partes de él o escribir algunas otras por tí mismo.

Counter=InterruptPeriod;
PC=InitialPC;
 
for(;;)
{
  OpCode=Memory[PC++];
  Counter-=Cycles[OpCode];
 
  switch(OpCode)
  {
    case OpCode1:
    case OpCode2:
    ...
  }
 
  if(Counter<=0)
  {
    /* Chequear interrupciones y      */
    /* hacer tareas cíclicas aquí...  */
    ...
    Counter+=InterruptPeriod;
    if(ExitRequired) break;
  }
}

Primero asignamos valores iniciales al contador de ciclos de la CPU (Counter) y al Contador de Programa (PC):

Counter=InterruptPeriod;
PC=InitialPC;

Counter contiene el número de cíclos de CPU que quedan para la próxima posible interrupción. Nota que la interrupción no debe de ocurrir necesariamente cuando este contador expire: puedes aprovecharlo para otros propósitos, como sincronizar temporizadores, o actualizar scanlines en la pantalla. Veremos más sobre esto más adelante. El PC contiene la dirección de memoria de la cual la CPU emulada leerá el siguiente opcode.

Tras la asignación inicial de valores, empezamos el bucle principal:

for(;;)
{

Nota que este bucle podría haberse implementado también como:

while(CPUIsRunning)
{

Donde CPUIsRunning es una variable booleana. Esto tiene ciertas ventajas, ya que podemos terminar el bucle en cualquier momento poniendo CPUIsRunning=0. Desafortunadamente, chequear esta variable en cada paso del bucle toma mucho tiempo de CPU y debe ser evitado en la medida de lo posible. Por otra parte, no implementes este bucle como

while(1)
{

porque en este caso, algunos compiladores generarán código chequeando si 1 es verdadero o no. Realmente no deseamos que el compilador haga este trabajo innecesario en cada paso del bucle.

Ahora, mientras estamos en el bucle, la primera cosa a hacer es leer el siguiente opcode, y modificar el Contador de Programa:

OpCode=Memory[PC++];

Notése que aunque este es el método más simple y rápido de acceder a la memoria emulada, no es siempre posible. Un método más universal de acceder a la memoria será cubierto más tarde en este documento.

Una vez que el opcode ha sido leido (fetch), decrementamos el contador de ciclos de la CPU en el número de ciclos requeridos para dicho opcode:

Counter-=Cycles[OpCode];

La tabla de ciclos debe contener el número de ciclos de CPU para cada opcode. Algunos opcodes (como saltos condicionales o llamadas a subrutinas) pueden tardar un diferente número de ciclos dependiendo de sus argumentos. Esto puede ser ajustado más tarde en el código.

Ahora viene el tiempo de interpretar el opcode y ejecutarlo:

switch(OpCode)
{

Es un fallo común el pensar que la construcción switch() es ineficiente, porque compila como una cadena de instrucciones if() … else if(). Aunque esto es cierto para construcciones con un pequeño número de casos, las construcciones grandes (típicamente 100-200 o más casos) parecen compilarse como una tabla de saltos, lo que lo hace bastante eficiente.

Hay 2 maneras alternativas de interpretar los opcodes. La primera es crear una tabla de funciones y llamar a la apropiada. Este método parece menos eficiente que un switch() ya que se tiene la sobrecarga de la llamada a la función. El segundo método sería utilizar una tabla de etiquetas (labels) y utilizar la sentencia goto. Aunque este método es ligeramente más rápido que un switch(), sólo funcionará en compiladores que soporten "etiquetas precalculadas". Otros compiladores no te permitirán crear un vector de direcciones de etiquetas.

Una vez que hemos interpretado y ejecutado con éxito el opcode, llega un momento en que tenemos que chequear algunas interrupciones. En este momento podemos también aprovechar para realizar cualquier tarea que necesite ser sincronizada con el reloj del sistema:

if(Counter<=0)
{
  /* Chequear interrupciones y realizar otras
     emulaciones de hardware aquí */
  ...
  Counter+=InterruptPeriod;
  if(ExitRequired) break;
}

Estas tareas cíclicas serán cubiertas más tarde en este documento.

Nota que no asignamos simplemente Counter=InterruptPeriod, y que en cambio hacemos Counter+=InterruptPeriod: esto hace el contaje de ciclos más preciso, ya que puede haber un valor negativo de ciclos en el contador Counter.

También fíjate en la línea:

if(ExitRequired) break;

Como es muy costoso testear la salida del programa en cada paso del bucle, lo podemos hacer sólo cuando el contador Counter expire: esto hará también finalizar la emulación cuando hagamos ExitRequired=1, pero no tomará tanto tiempo de CPU.

La manera más simple de acceder a la memoria emulada es tratarlo como un vector (array) de bytes (words, etc.). Accederla es trivial entonces:


Data=Memory[Address1];  /* Leer de Address1 */
Memory[Address2]=Data;  /* Escribir a Address2  */

Ese simple método de acceso a memoria no es siempre posible por las siguientes razones:

  • Memoria paginada : El espacio de direccionamiento puede estar fragmentado entre páginas modificables (también llamadas bancos). Esto se hace habitualmente para expandir la memoria cuando el espacio de direccionamiento es pequeño (64Kb).
  • Mirrored Memory : Un área de memoria puede ser accesible desde diferentes direcciones. Por ejemplo, el data que intentamos escribir en la dirección $4000 aparecerá también en la $6000, $8000, etc. Las ROMS también pueden aparecer repetidas debido a la decodificación incompleta.
  • Protección de la ROM : Hay software basado en cartuchos (como algunos juegos de MSX, por ejemplo) que intenta escribir en su propia ROM y se niega a funcionar si la escritura tiene éxito. Esto se hace normalmente como protección anticopia. Para hacer funcionar este software en tu emulador debes desactivar la escritura en la ROM.
  • E/S mapeada en memoria : Pueden haber en el sistema dispositivos de E/S mapeados en la memoria. Accesos a dichas posiciones de memoria producen "efectos especiales" y deben ser tenidos en cuenta.

Para cubrir todos estos problemas introduciremos un par de funciones:


Data=ReadMemory(Address1);  /* Read from Address1 */
WriteMemory(Address2,Data); /* Write to Address2  */

Todo el procesado especial como acceso de páginas, mirroring, control de E/S, etc. se hace dentro de estas funciones.

ReadMemory() y WriteMemory() normalmente causan bastante sobrecarga en la emulación si son llamadas muy frecuentemente. Por ello deben ser creadas de la manera más eficiente posible. A continuación tenemos un ejemplo de dichas funciones escritas para acceder a una dirección de memoria paginada:


static inline byte 
  ReadMemory(register word Address)
{
  return(
     MemoryPage[Address>>13][Address&0x1FFF]
        );
}
 
static inline void 
   WriteMemory(register word Address,register byte Value)
{
  MemoryPage[Address>>13]
            [Address&0x1FFF]=Value;
}

Date cuenta de la palabra clave inline. Esta palabra le dice al compilador que embeba la función dentro del código, en lugar de realizar llamadas a ella. Si tu compilador no soporta inline o _inline, intenta hacer la función static (con static): algunos compiladores (WatcomC, por ejemplo) optimizarán funciones estáticas cortas haciéndolas inline.

También ten claro que la mayoría de las veces la función ReadMemory() es llamada con mucha más frecuencia que WriteMemory(). Por ello, es útil implementar la mayoría del código en WriteMemory(), dejando ReadMemory() tan simple y corta como sea posible.

Una pequeña nota sobre "memory mirroring": Como se ha dicho antes, muchas computadoras disponen de RAM con zonas reflejadas donde un valor escrito en una posición de memoria aparece también en otras. Aunque esta situación puede ser gestionada en ReadMemory(), esto no es recomendable, ya que ReadMemory() es llamada mucho más frecuentemente que WriteMemory(). Una manera más eficiente de implementar la mirrored memory consistiría en realizar toda su gestión en la función WriteMemory().

Las tareas cíclicas son cosas que deben ocurrir periódicamente en una máquina emulada, como:

  • Refresco de pantalla
  • Interrupcions VBlank y HBlank
  • Actualización de temporizadores
  • Actualización de parámetros de sonido
  • Actualización del estado de teclado/joystick
  • etc.

Con el fin de emular estas tareas deberás ejecutarlas tras el número apropiado de ciclos de CPU. Por ejemplo, si la CPU se supone que corre a 2.5Mhz y el display usa una frecuencia de refresco de 50Hz (estándar para el vídeo PAL), entonces la interrupción VBlank ocurrirá cada:

2500000/50 = 50000 ciclos de CPU

Si asumimos que la pantalla entera (incluyendo VBlank) es de 256 scanlines de alto y 212 de ellas son mostradas en el display (las otras 44 caen en el VBlank), entonces obtenemos que nuestra emulación debe refrescar un scanline cada:

50000/256 ~= 195 ciclos de CPU

Tras eso, debemos generar una interrupción VBlank y no hacer nada hasta que se haya acabado con el VBlank, es decir, durante:

(256-212)*50000/256 = 44*50000/256 ~= 8594 ciclos de CPU

Calcula cuidadosamente el número de ciclos de reloj necesarios para cada tarea, y entonces utiliza el divisor común mayor para InterruptPeriod, ajustando todas las otras tareas a él (no tienen porqué ejecutarse en cada expiración del contador Counter).

Se puede conseguir mucho rendimiento adicional eligiendo las opciones correctas de optimización del compilador. Basadas en mi experiencia, las siguientes combinaciones de opciones te darán la mejor velocidad de ejecución:

Watcom C++      -oneatx -zp4 -5r -fp3
GNU C++         -O3 -fomit-frame-pointer
Borland C++

Si encuentras una mejor combinación de opciones para cualquiera de estos compiladores o para otro diferente, por favor házmelo saber.

Una pequeña nota sobre desenrollamiento de bucles: Puede parecer muy útil activar el "loop unrolling" (desenrrollamiento de bucles) del compilador. Esta opción intentará convertir los bucles cortos en piezas lineales de código. Mi experiencia me muestra, no obstante, que esta opción no produce ningún incremento del rendimiento. Activarla puede incluso romper tu código en algunos casos especiales.

Optimizar el código C por tí mismo es algo más laborioso que simplemente elegir opciones del compilador, y normalmente dependerá de la CPU para la que compilemos el código, aunque hay muchas reglas generales que se aplican a todas las CPUs. No te las tomes como verdades absolutas, ya que pueden variar según muchos factores:

  • ¡Usa el profiler! Una ejecución de tu programa bajo cualquier utilidad de profiling decente (GPROF viene inmediatamente a mi mente) puede revelarte cosas maravillosas que no habías sospechado. Puedes encontrar que porciones de código insignificantes son ejecutadas con mucha más frecuencia que el resto y que éstas ralentizan tu programa. Optimizar estas porciones de código o reescribirlas en lenguaje ensamblador puede incrementar el rendimiento.
  • Evita el C++ Evita utilizar cualquier construcción que te fuerce a compilar el programa con un compilador de C++ en lugar de simple C: los compiladores de C++ normalmente añaden mucha sobrecarga al código generado.
  • Tamaño de los enteros Intenta utilizar sólo enteros del tamaño base soportado por la CPU (int en lugar de short o long). Esto reducirá la cantidad de código generado por el compilador debido a conversiones entre diferentes longitudes de enteros. También reducirá el tiempo de acceso a memoria, ya que algunas CPUs trabajan más rápido cuando leen/escriben datos del tamaño base y alineados a direcciones múltiples del tamaño base.
  • Uso de registros Utiliza tan pocas variables como te sea posible en cada bloque y declara las más usadas como tipo register (muchos nuevos compiladores pueden poner automáticamente variables en los registros, no obstante). Esto tiene sentido para las CPUs con muchos registros de propósito general (PowerPC) mas que para aquellas con unos pocos registros dedicados (Intel 80x86).
  • Desenrrolla los bucles pequeños Si tienes un pequeño bucle que se ejecuta unas pocas veces, es una buena idea desenrrollarlo manualmente en porciones de código lineales. Mira la nota más arriba sobre el desenrrollamiento automático.
  • Desplazamientos contra multiplicación/división Utiliza desplazamientos cada vez que necesites multiplicar o dividir por 2^ (J/128==J»7). Estos se ejecutan mucho más rápido en muchas CPUs. Usa también el operador de bits AND para obtener el módulo en estos casos (J%128==J&0x7F).

Ls CPUs se dividen generalmente en muchas clases, dependiendo de cómo almacenan los datos en memoria. Aunque hay algunos especímenes muy particulares, la mayoría de las CPUs caen en una de estas dos clases:

  • Las CPUs High-endian almacenarán los datos de modo que los bytes más altos de una palabra aparezcan primero en memoria. Por ejemplo, si almacenas el número 0x1234567 en una de estas CPUs, la memoria aparecerá como:
     0  1  2  3
    +--+--+--+--+
    |12|34|56|78|
    +--+--+--+--+
  • Las CPUs Low-endian CPUs almacenan los datos de forma que los bytes más bajo de una palabra aparezcan primero en memoria. El ejemplo anterior aparece bastante diferente en estas CPUs:
     0  1  2  3
    +--+--+--+--+
    |78|56|34|12|
    +--+--+--+--+

Ejemplos típicos de CPUs high-endian so las 6809, las seriers Motorola 680x0, PowerPC, y Sun SPARC. Las CPUs Low-endian incluyen el 6502 y su sucesor el 65816, el Zilog Z80, muchos chips Intel (incluyendo el 8080 y los 80x86), DEC Alpha, etc.

Cuando se programa un emulador debes tener cuidado en con el formato que usan ambas CPUs (la emulada y la emuladora). Digamos que quieres emular una cpu Z80 que es low-endian. Esto quiere decir que el Z80 almacena sus palabras de 16 bits con el byte inferior primero. Si utilizas una CPU low-endia (como por ejemplo el Intel 80x86) para esto, todo ocurre naturalmente. Pero si usas una CPU high-endian (PowerPC) aparece un problema al almacenar los datos de 16 bits del Z80 en memoria. Peor aún, si tu programa debe trabajar en ambas arquitectureas necesitarás algún sistema para tratar este problema.

Una manera de gestionar este problema se da a continuación:

typedef union
{
  short W;        /* Acceso a Word */
  struct          /* Acceso a Byte... */
  {
#ifdef LOW_ENDIAN
    byte l,h;     /* ...en arquitectura low-endian */
#else
    byte h,l;     /* ...en arquitectura high-endian */
#endif
  } B;
} word;

Como puedes ver, una palabra puede ser accedida por completo usando W. Cada vez que la emulación necesite acceder a ella por sus bytes por separado, utilizaremos B.l y B.h, que preservan el orden.

Si tu programa va a ser compilado en diferentes plataformas, puedes querer saber si ha sido compilado con el flag de endianess correcto antes de ejecutar algo realmente importante. Aquí podemos ver una manera de realizar dicho test:

int *T;
 
T=(int *)"\01\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
if(*T==1) printf("Esta máquina es high-endian.\n");
else      printf("Esta máquina es low-endian.\n");

Muchos sistemas están formados por muchos chips cada uno de los cuales realiza cierta parte de las funciones del sistema. Suele haber una CPU, una controladora de vídeo, un generador de sonido, etc. Algunos de estos chips pueden tener su propia memoria u otro hardware asociado a ellos.

Un emulador típico debe repetir el diseño del sistema original implementando cada subsistema y sus funciones en un módulo separado. Primero, esto hace la depuración más fácil ya que todos los fallos están localizados en los módulos. Segundo, la arquitectura modular permite reutilizar los módulos en otros emuladores. El hardware de computadoras está bastante estandarizado: puedes encontrar la misma CPU o chip de vídeo en muchos modelos diferentes de computadoras. Es mucho más fácil emular el chip una sola vez que implementarlo una y otra vez por cada computadora que lo use.

  • 1997-1999 Copyright por Marat Fayzullin [fms at cs.umd.edu]
  • Traducción al Castellano por Santiago Romero
  • spectrum/articulos/htprogemu.txt
  • Última modificación: 27-01-2009 08:29
  • por sromero