Artículo publicado el 9 de diciembre de 2014Actualización para 2018: RenéRebe hizo un video interesante basado en este artículo ( parte 2 )El fin de semana pasado participé en
Ludum Dare # 31 . Pero incluso antes de que se anunciara la conferencia, debido a mi
reciente pasatiempo, quería hacer un juego de la vieja escuela bajo DOS. La plataforma de destino es DOSBox. Esta es la forma más práctica de ejecutar aplicaciones de DOS, a pesar del hecho de que todos los procesadores x86 modernos son totalmente compatibles con versiones anteriores, hasta el 8086 de 16 bits.
Creé y mostré con éxito el juego
DOS Defender en la conferencia. El programa funciona en el modo real del 80386 de 32 bits. Todos los recursos están integrados en el archivo COM ejecutable, sin dependencias externas, por lo que todo el juego está empaquetado en un binario de 10 kilobytes.
Necesitarás un joystick o un gamepad para jugar. Incluí soporte para mouse en el lanzamiento de Ludum Dare por el bien de la presentación, pero luego lo eliminé porque no funcionó muy bien.
¡La parte más interesante técnicamente es que
no se necesitaban herramientas de desarrollo de DOS para crear el juego ! Solo utilicé el compilador regular de Linux C (gcc). En realidad, ni siquiera puedes construir un Defender de DOS para DOS. Veo DOS solo como una plataforma incrustada, que es la única forma en que
DOS todavía existe hoy . Junto con DOSBox y DOSEMU, este es un conjunto de herramientas bastante conveniente.
Si solo está interesado en la parte práctica del desarrollo, vaya a la sección "Cheat on GCC", donde escribiremos el programa COM de DOS "Hello, World" con GCC Linux.
Encontrar las herramientas adecuadas
Cuando comencé este proyecto, no pensaba en GCC. En realidad, seguí este camino cuando descubrí el paquete
bcc (Compilador C de Bruce) para Debian, que recopila binarios de 16 bits para 8086. Se almacena para compilar cargadores de arranque x86 y otras cosas, pero bcc también se puede usar para compilar archivos COM de DOS. Me intereso
Como referencia: el microprocesador Intel 8086 de 16 bits se lanzó en 1978. No tenía características extrañas de los procesadores modernos: sin protección de memoria, sin instrucciones de coma flotante, y solo 1 MB de RAM direccionable. Todas las computadoras de escritorio y computadoras portátiles modernas x86 aún pueden pretender ser este procesador de 16 bits 8086 hace cuarenta años, con el mismo direccionamiento limitado y todo eso. Esta es una compatibilidad bastante hacia atrás. Tal función se llama
modo real . Este es el modo en que se inician todas las computadoras x86. Los sistemas operativos modernos cambian inmediatamente al
modo protegido con direccionamiento virtual y multitarea segura. DOS no hizo eso.
Desafortunadamente, bcc no es un compilador ANSI C. Admite un subconjunto de K&R C, así como un código de ensamblador x86 incorporado. A diferencia de otros compiladores 8086 C, no tiene el concepto de punteros "lejanos" o "largos", por lo que se necesita un código ensamblador incorporado para acceder a
otros segmentos de memoria (VGA, relojes, etc.). Nota: los restos de estos "punteros largos" 8086 aún se conservan en la API de Win32:
LPSTR
,
LPWORD
,
LPDWORD
, etc. Ese ensamblador incorporado ni siquiera se compara estrechamente con el ensamblador incorporado GCC. En el ensamblador, debe cargar manualmente las variables de la pila, y dado que bcc admite dos convenciones de llamada diferentes, las variables en el código deben estar codificadas de acuerdo con una u otra convención.
Dadas estas limitaciones, decidí buscar alternativas.
DJGPP
DJGPP : puerto GCC en DOS. Un proyecto realmente muy impresionante que transfiere casi todo el POSIX bajo DOS. Muchos programas portados por DOS están hechos en DJGPP. Pero solo crea programas de 32 bits para el modo protegido. Si en modo protegido necesita trabajar con hardware (por ejemplo, VGA), el programa realiza solicitudes al servicio de
la interfaz de modo protegido de DOS (DPMI). Si tomé DJGPP, no podría haberme limitado a un solo binario independiente, porque tendría que tener un servidor DPMI. El rendimiento también sufre de solicitudes de DPMI.
Obtener las herramientas necesarias para DJGPP es difícil, por decir lo menos. Afortunadamente, encontré un útil proyecto
build-djgpp que ejecuta todo, al menos en Linux.
O hubo un grave error, o los archivos binarios oficiales de DJGPP se
infectaron nuevamente con el virus , pero cuando comencé mis programas en DOSBox, apareció constantemente el error "No COFF: buscar virus". Para verificar aún más que los virus no están en mi propia máquina, configuré el entorno DJGPP en mi Raspberry Pi, que actúa como una sala limpia. Este dispositivo basado en ARM no puede infectarse con el virus x86. Y todavía surgió el mismo problema, y todos los hash binarios eran iguales entre las máquinas, por lo que no es mi culpa.
Dado esto y el problema DPMI, comencé a buscar más.
Engañando a gcc
Lo que finalmente decidí fue el truco complicado de "engañar" a GCC para construir archivos COM de DOS en modo real. El truco funciona hasta 80386 (que generalmente es lo que necesitas). El procesador 80386 se lanzó en 1985 y se convirtió en el primer microprocesador x86 de 32 bits. GCC todavía se adhiere a este conjunto de instrucciones, incluso en entornos x86-64. Desafortunadamente, GCC no puede producir código de 16 bits de ninguna manera, así que tuve que abandonar el objetivo original de hacer un juego para 8086. Sin embargo, esto no importa, porque la plataforma DOSBox de destino es esencialmente un emulador 80386.
En teoría, el truco también debería funcionar en el compilador MinGW, pero hay un error de larga data que impide que funcione correctamente ("no se pueden realizar operaciones PE en un archivo de salida que no sea PE"). Sin embargo, se puede omitir, y lo hice yo mismo: debe eliminar la directiva
OUTPUT_FORMAT
y agregar un paso adicional de
objcopy
(
objcopy -O binary
).
Hola mundo en DOS
Para la demostración, crearemos el programa COM de DOS "Hello, World" usando GCC en Linux.
Hay un obstáculo importante y significativo en este método:
no habrá una biblioteca estándar . Es como escribir un sistema operativo desde cero, con la excepción de algunos servicios que proporciona DOS. Eso significa que no hay
printf()
o similar. En cambio, le pedimos a DOS que imprima la cadena en la consola. ¡Crear una solicitud de DOS requiere una interrupción, lo que significa código de ensamblador en línea!
DOS tiene nueve interrupciones: 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2F. Lo más importante que nos interesa es 0x21, la función 0x09 (imprimir una línea). Entre DOS y BIOS, hay
miles de funciones nombradas después de este patrón . No voy a tratar de explicar el ensamblador x86, pero en pocas palabras el número de función se queda atascado en el registro
ah
, y la interrupción 0x21 se dispara. La función 0x09 también toma un argumento: un puntero a una línea para imprimir, que se pasa en los registros
dx
y
ds
.
Aquí está la función
print()
del ensamblador en línea GCC. Las líneas pasadas a esta función deben terminar con el carácter $. Por qué Porque DOS.
static void print(char *string) { asm volatile ("mov $0x09, %%ah\n" "int $0x21\n" : : "d"(string) : "ah"); }
El código se declara
volatile
porque tiene un efecto secundario (impresión de línea). Para GCC, el código del ensamblador es opaco y el optimizador se basa en restricciones de salida / entrada / clobber (últimas tres líneas). Para tales programas de DOS, cualquier ensamblador incorporado tendrá efectos secundarios. Esto se debe a que no está escrito para optimización, sino para acceso a recursos de hardware y DOS, cosas inaccesibles para C.
También debe ocuparse de la declaración de llamada, porque GCC no sabe que la memoria apuntada por
string
alguna vez ha sido leída. Es probable que una matriz que admita la cadena también deba declararse
volatile
. Todo esto presagia lo inevitable: cualquier acción en dicho entorno se convierte en una lucha interminable con el optimizador. No todas estas batallas se pueden ganar.
Ahora a la función principal. Su nombre no es importante en principio, pero evito llamarlo
main()
, porque MinGW tiene ideas divertidas sobre cómo procesar dichos caracteres específicamente, incluso si le piden que no lo haga.
int dosmain(void) { print("Hello, World!\n$"); return 0; }
Los archivos COM están limitados a 65279 bytes de tamaño. Esto se debe a que el segmento de memoria x86 es de 64 KB, y DOS simplemente descarga los archivos COM a la dirección del segmento 0x0100 y se ejecuta. Sin encabezados, solo un binario limpio. Dado que el programa COM, en principio, no puede tener un tamaño significativo, entonces no debe ocurrir un diseño real (independiente), todo se compila como una sola unidad de traducción. Esta será una llamada GCC con un montón de parámetros.
Opciones del compilador
Aquí están las principales opciones del compilador.
-std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding
Como las bibliotecas estándar no se usan, la única diferencia entre gnu99 y c99 son los trigrafos deshabilitados (como debería ser), y el ensamblador incorporado se puede escribir como
asm
lugar de
__asm__
. Este no es el contenedor de Newton. El proyecto estará tan estrechamente relacionado con GCC que todavía no estoy preocupado por las extensiones de GCC.
La opción
-Os
reduce el resultado de la compilación tanto como sea posible. Entonces el programa funcionará más rápido. Esto es importante teniendo en cuenta DOSBox, porque el emulador predeterminado se ejecuta lentamente como una máquina de los 80. Quiero encajar en esta limitación. Si el optimizador está causando problemas,
-O0
temporalmente
-O0
para determinar si su error o el optimizador está aquí.
Como puede ver, el optimizador no comprende que el programa funcionará en modo real con las restricciones de direccionamiento correspondientes.
Realiza todo tipo de optimizaciones inválidas que rompen sus programas perfectamente válidos. Esto no es un error de GCC, porque nosotros mismos estamos haciendo locuras aquí. Tuve que rehacer el código varias veces para evitar que el optimizador rompa el programa. Por ejemplo, tuvimos que evitar devolver estructuras complejas de las funciones porque a veces se llenaban de basura. El verdadero peligro es que la versión futura de GCC se volverá aún más inteligente y romperá aún más código. Aquí está tu amigo
volatile
.
El siguiente parámetro es
-nostdlib
, ya que no podremos vincular a ninguna biblioteca válida, ni siquiera estáticamente.
Los parámetros
-m32-march=i386
compilador que emita el código 80386. Si escribí el gestor de arranque para una computadora moderna, entonces la vista en 80686 también sería normal, pero el DOSBox es 80386.
El argumento
-ffreestanding
requiere que GCC no emita código que acceda a las funciones auxiliares de la biblioteca estándar incorporada. A veces, en lugar de trabajar realmente con el código, produce un código para invocar una función incorporada, especialmente con operadores matemáticos. Tuve uno de los principales problemas con bcc, donde este comportamiento no se puede deshabilitar. Esta opción se usa con mayor frecuencia al escribir cargadores de arranque y núcleos del sistema operativo. Y ahora los archivos dos dos .com.
Opciones de vinculador
La
-Wl
usa para pasar argumentos al enlazador (
ld
). Necesitamos esto porque hacemos todo en una llamada a GCC.
-Wl,--nmagic,--script=com.ld
--nmagic
deshabilita la alineación de la página de sección. En primer lugar, no lo necesitamos. En segundo lugar, desperdicia un espacio precioso. En mis pruebas, esto no parece ser una medida necesaria, pero por si acaso, dejo esta opción.
El parámetro
--script
indica que queremos usar un
script de enlace especial. Esto le permite colocar con precisión las secciones (
text
,
data
,
bss
,
rodata
) de nuestro programa. Aquí está el script
com.ld
OUTPUT_FORMAT(binary) SECTIONS { . = 0x0100; .text : { *(.text); } .data : { *(.data); *(.bss); *(.rodata); } _heap = ALIGN(4); }
OUTPUT_FORMAT(binary)
le dice que no coloque esto en un archivo ELF (o PE, etc.). El enlazador solo debe restablecer el código limpio. Un archivo COM es solo código limpio, es decir, le damos el comando al vinculador para crear un archivo COM.
Dije que los archivos COM se cargan a
0x0100
. La cuarta línea desplaza el binario allí. El primer byte del archivo COM sigue siendo el primer byte del código, pero se iniciará desde este desplazamiento de memoria.
Luego siguen todas las secciones:
text
(programa),
data
(
data
estáticos),
bss
(datos con inicialización cero),
rodata
(cadenas). Finalmente, marco el final del binario con el símbolo
_heap
. Esto será útil más adelante cuando escriba
sbrk()
cuando hayamos terminado con "Hello, World".
_heap
alinear
_heap
con 4 bytes.
Casi terminado
Lanzamiento del programa
El enlazador generalmente conoce nuestro punto de entrada (
main
) y lo configura para nosotros. Pero como solicitamos un problema "binario", tendremos que resolverlo nosotros mismos. Si la función
print()
es la primera en ejecutarse, el programa comenzará desde allí, lo cual es incorrecto. El programa necesita un pequeño encabezado para comenzar.
Hay una opción de
STARTUP
en el script del vinculador para tales cosas, pero por simplicidad la implementaremos directamente en el programa. Por lo general, tales cosas se llaman
crt0.o
o
Boot.o
, en caso de que las encuentres en alguna parte. Nuestro código
debe comenzar con este ensamblador incorporado, antes de cualquier inclusión y similares. DOS hará la mayor parte de la instalación por nosotros, solo tenemos que ir al punto de entrada.
asm (".code16gcc\n" "call dosmain\n" "mov $0x4C, %ah\n" "int $0x21\n");
.code16gcc
le dice al ensamblador que vamos a trabajar en modo real, para que haga la configuración correcta. A pesar del nombre, ¡
no producirá código de 16 bits! Primero, se
dosmain
función
dosmain
, que escribimos anteriormente. Luego le dice a DOS usando la función 0x4C ("terminar con el código de retorno") que hemos terminado pasando el código de salida
al
registro
al
1 byte (ya establecido por
dosmain
). Este ensamblador incorporado es automáticamente
volatile
porque no tiene entradas ni salidas.
Todos juntos
Aquí está todo el programa en C.
asm (".code16gcc\n" "call dosmain\n" "mov $0x4C,%ah\n" "int $0x21\n"); static void print(char *string) { asm volatile ("mov $0x09, %%ah\n" "int $0x21\n" : : "d"(string) : "ah"); } int dosmain(void) { print("Hello, World!\n$"); return 0; }
No repetiré
com.ld
Aquí está el desafío del CCG.
gcc -std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding \ -o hello.com -Wl,--nmagic,--script=com.ld hello.c
Y su prueba en DOSBox:

Entonces, si desea gráficos hermosos, la única pregunta es llamar a la interrupción y
escribir en la memoria VGA . Si desea sonido, use PC Speaker interrupt. No he descubierto cómo llamar a Sound Blaster. Desde ese momento, DOS Defender creció.
Asignación de memoria
Para cubrir otro tema, ¿recuerdas ese
_heap
? Podemos usarlo para implementar
sbrk()
y asignar memoria dinámicamente en la sección principal del programa. Este es un modo real y no hay memoria virtual, por lo que podemos escribir en cualquier memoria a la que podamos acceder en cualquier momento. Algunas áreas están reservadas (por ejemplo, memoria inferior y superior) para el equipo. Por lo tanto, no hay necesidad
real de usar sbrk (), pero es interesante intentarlo.
Como es habitual en x86, su programa y particiones están en la memoria inferior (0x0100 en este caso), y la pila está en la memoria superior (en nuestro caso, en la región 0xffff). En sistemas tipo Unix, la memoria devuelta por
malloc()
proviene de dos lugares:
sbrk()
y
mmap()
. Lo que hace
sbrk()
es asignar memoria justo por encima de los segmentos de programa / datos, incrementándola "hacia arriba" hacia la pila. Cada llamada a
sbrk()
aumentará este espacio (o lo dejará exactamente igual). Esta memoria será administrada por
malloc()
y similares.
Aquí se explica cómo implementar
sbrk()
en un programa COM. Tenga en cuenta que debe definir su propio
size_t
, porque no tenemos una biblioteca estándar.
typedef unsigned short size_t; extern char _heap; static char *hbreak = &_heap; static void *sbrk(size_t size) { char *ptr = hbreak; hbreak += size; return ptr; }
Simplemente establece el puntero en
_heap
y lo incrementa según sea necesario. Un poco más inteligente
sbrk()
también tendrá cuidado con la alineación.
Algo interesante sucedió durante la creación de DOS Defender. (Incorrectamente) consideré que la memoria de mi
sbrk()
restableció. Así fue después del primer juego. Sin embargo, DOS no restablece esta memoria entre programas. Cuando comencé el juego nuevamente,
continuó exactamente donde me detuve , porque las mismas estructuras de datos con el mismo contenido se cargaron en su lugar. Muy buena coincidencia! Esto es parte de lo que hace que esta plataforma integrada sea divertida.