Artigo publicado em 9 de dezembro de 2014Atualização para 2018: RenéRebe fez um vídeo interessante com base neste artigo ( parte 2 )No final de semana passado, participei do
Ludum Dare # 31 . Mas mesmo antes do anúncio da conferência, por causa do meu
hobby recente, eu queria criar um jogo da velha escola no DOS. A plataforma de destino é o DOSBox. Essa é a maneira mais prática de executar aplicativos DOS, apesar de todos os processadores x86 modernos serem totalmente compatíveis com os antigos, até o 8086 de 16 bits.
Criei e mostrei com sucesso o jogo
DOS Defender na conferência. O programa funciona no modo real do 80386 de 32 bits. Todos os recursos estão embutidos no arquivo COM executável, sem dependências externas, portanto o jogo todo é compactado em um binário de 10 kilobytes.
Você precisará de um joystick ou gamepad para jogar. Incluí o suporte ao mouse no lançamento do Ludum Dare por uma questão de apresentação, mas o excluí porque não funcionou muito bem.
A parte mais tecnicamente interessante é que
não foram necessárias ferramentas de desenvolvimento do DOS para criar o jogo ! Eu usei apenas o compilador Linux C regular (gcc). Na realidade, você não pode nem criar um DOS Defender para DOS. Eu vejo o DOS apenas como uma plataforma incorporada, que é a única forma na qual o
DOS ainda existe hoje . Juntamente com o DOSBox e o DOSEMU, este é um conjunto de ferramentas bastante conveniente.
Se você está interessado apenas na parte prática do desenvolvimento, vá para a seção "Cheat on GCC", onde escreveremos o programa DOS COM "Hello, World" com o GCC Linux.
Encontrando as ferramentas certas
Quando iniciei este projeto, não pensei no GCC. Na realidade, eu fui assim quando descobri o pacote
bcc (Bruce's C Compiler) para o Debian, que coleta binários de 16 bits para o 8086. Ele é usado para compilar os carregadores de inicialização x86 e outras coisas, mas o bcc também pode ser usado para compilar arquivos COM do DOS. Isso me interessou.
Para referência: o microprocessador Intel 8086 de 16 bits foi lançado em 1978. Não possuía recursos bizarros dos processadores modernos: nenhuma proteção de memória, instruções de ponto flutuante e apenas 1 MB de RAM endereçável. Todos os desktops e laptops x86 modernos ainda podem fingir ser esse processador de 16 bits 8086 há quarenta anos, com o mesmo endereçamento limitado e tudo mais. Essa é uma compatibilidade bastante antiga. Essa função é chamada
modo real . Este é o modo no qual todos os computadores x86 são inicializados. Os SOs modernos mudam imediatamente para o
modo protegido com endereçamento virtual e multitarefa segura. O DOS não fez isso.
Infelizmente, o bcc não é um compilador ANSI C. Ele suporta um subconjunto do K&R C, bem como o código do assembler x86 embutido. Diferentemente de outros compiladores 8086 C, ele não possui o conceito de ponteiros "long" ou "long", portanto, o código do assembler interno é necessário para acessar
outros segmentos de memória (VGA, relógios, etc.). Nota: os remanescentes desses "ponteiros longos" 8086 ainda são preservados na API do Win32:
LPSTR
,
LPWORD
,
LPDWORD
, etc. Esse montador
LPDWORD
nem se compara ao GCC do montador interno. No assembler, você precisa carregar manualmente variáveis da pilha e, como o bcc suporta duas convenções de chamada diferentes, as variáveis no código devem ser codificadas de acordo com uma ou outra convenção.
Dadas essas limitações, decidi procurar alternativas.
DJGPP
DJGPP - porta GCC no DOS. Um projeto realmente muito impressionante que transfere quase todo o POSIX no DOS. Muitos programas com porta DOS são criados no DJGPP. Mas ele cria apenas programas de 32 bits para o modo protegido. Se no modo protegido você precisar trabalhar com hardware (por exemplo, VGA), o programa fará solicitações ao serviço da DPMI (
interface de modo protegido do DOS). Se eu tomasse o DJGPP, não poderia me limitar a um único binário independente, porque teria que ter um servidor DPMI. O desempenho também sofre com solicitações de DPMI.
Obter as ferramentas necessárias para o DJGPP é difícil, para dizer o mínimo. Felizmente, encontrei um projeto
build-djgpp útil que executa tudo, pelo menos no Linux.
Houve um erro grave ou os binários oficiais do DJGPP foram
infectados pelo vírus novamente , mas quando iniciei meus programas no DOSBox, o erro "Não é COFF: verifique se há vírus" constantemente aparecia. Para verificar se os vírus não estão em minha própria máquina, configurei o ambiente DJGPP no meu Raspberry Pi, que funciona como uma sala limpa. Este dispositivo baseado em ARM não pode ser infectado com o vírus x86. E ainda surgiu o mesmo problema, e todos os hashes binários eram os mesmos entre as máquinas, por isso não é minha culpa.
Então, considerando esse problema e o DPMI, comecei a procurar mais.
Enganando gcc
Finalmente decidi o truque de "trapacear" o GCC para criar arquivos COM do DOS em modo real. O truque funciona até 80386 (que geralmente é o que você precisa). O processador 80386 foi lançado em 1985 e se tornou o primeiro microprocessador x86 de 32 bits. O GCC ainda segue esse conjunto de instruções, mesmo em ambientes x86-64. Infelizmente, o GCC não pode produzir código de 16 bits de forma alguma, então tive que abandonar o objetivo original de criar um jogo para o 8086. No entanto, isso não importa, porque a plataforma DOSBox de destino é essencialmente um emulador 80386.
Em teoria, o truque também deve funcionar no compilador MinGW, mas há um erro de longa data que o impede de funcionar corretamente (“não é possível executar operações de PE no arquivo de saída que não é PE”). No entanto, você pode contornar isso, e eu fiz isso sozinho: você deve remover a diretiva
OUTPUT_FORMAT
e adicionar uma etapa de
objcopy
adicional (
objcopy -O binary
).
Olá Mundo no DOS
Para demonstração, criaremos o programa DOS COM "Hello, World" usando o GCC no Linux.
Há um obstáculo importante e significativo nesse método:
não haverá biblioteca padrão . É como escrever um sistema operacional a partir do zero, com exceção de alguns serviços que o DOS fornece. Isso significa que não
printf()
ou similar. Em vez disso, pedimos ao DOS para imprimir a string no console. A criação de uma solicitação do DOS requer uma interrupção, o que significa código montador embutido!
O DOS possui nove interrupções: 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2F. O mais importante que nos interessa é 0x21, a função 0x09 (imprimir uma linha). Entre o DOS e o BIOS, existem
milhares de funções nomeadas após esse padrão . Não vou tentar explicar o montador x86, mas, em poucas palavras, o número da função fica preso no registro
ah
- e a interrupção 0x21 é acionada. A função 0x09 também recebe um argumento - um ponteiro para uma linha para impressão, que é passada nos registros
dx
e
ds
.
Aqui está a função
print()
do montador embutido do GCC. As linhas passadas para esta função devem terminar com o caractere $. Porque Porque DOS.
static void print(char *string) { asm volatile ("mov $0x09, %%ah\n" "int $0x21\n" : : "d"(string) : "ah"); }
O código é declarado
volatile
porque tem um efeito colateral (impressão de linha). Para o GCC, o código do montador é opaco e o otimizador depende de restrições de saída / entrada / bloqueio (últimas três linhas). Para esses programas DOS, qualquer assembler interno terá efeitos colaterais. Isso ocorre porque ele foi escrito não para otimização, mas para acesso a recursos de hardware e DOS - coisas inacessíveis ao simples C.
Você também deve cuidar da instrução de chamada, porque o GCC não sabe que a memória apontada pela
string
já foi lida. É provável que um array que suporte a string também precise ser declarado
volatile
. Tudo isso pressagia o inevitável: qualquer ação nesse ambiente se transforma em uma luta sem fim com o otimizador. Nem todas essas batalhas podem ser vencidas.
Agora para a função principal. Seu nome não é importante em princípio, mas evito chamá-lo de
main()
, porque o MinGW tem idéias engraçadas sobre como processar esses personagens especificamente, mesmo se eles pedirem que ele não o faça.
int dosmain(void) { print("Hello, World!\n$"); return 0; }
Arquivos COM são limitados a 65279 bytes de tamanho. Isso ocorre porque o segmento de memória x86 tem 64 KB e o DOS simplesmente baixa os arquivos COM para o endereço do segmento 0x0100 e é executado. Sem títulos, apenas um binário limpo. Como o programa COM, em princípio, não pode ter um tamanho significativo, nenhum layout real (independente) deve ocorrer, tudo é compilado como uma única unidade de tradução. Essa será uma chamada do GCC com vários parâmetros.
Opções do compilador
Aqui estão as principais opções do compilador.
-std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding
Como as bibliotecas padrão não são usadas, a única diferença entre gnu99 e c99 são os trigraphs desativados (como deveriam ser), e o assembler
__asm__
pode ser escrito como
asm
vez de
__asm__
. Este não é o escaninho de Newton. O projeto estará tão intimamente relacionado ao GCC que ainda não estou preocupado com as extensões do GCC.
A opção
-Os
reduz o resultado da compilação, tanto quanto possível. Portanto, o programa funcionará mais rápido. Isso é importante de olho no DOSBox, porque o emulador padrão roda lentamente como uma máquina dos anos 80. Eu quero me encaixar nessa limitação. Se o otimizador estiver causando problemas,
-O0
temporariamente
-O0
para determinar se o seu erro ou o otimizador está aqui.
Como você pode ver, o otimizador não entende que o programa funcionará em modo real com as restrições de endereçamento correspondentes.
Ele executa todos os tipos de otimizações inválidas que quebram seus programas perfeitamente válidos. Este não é um bug do GCC, porque nós mesmos estamos fazendo coisas loucas aqui. Tive que refazer o código várias vezes para impedir que o otimizador interrompesse o programa. Por exemplo, tivemos que evitar o retorno de estruturas complexas a partir de funções porque elas às vezes eram preenchidas com lixo. O verdadeiro perigo é que a versão futura do GCC se torne ainda mais inteligente e quebre ainda mais o código. Aqui está seu amigo
volatile
.
O próximo parâmetro é
-nostdlib
, pois não poderemos vincular a nenhuma biblioteca válida, mesmo estaticamente.
Os parâmetros
-m32-march=i386
compilador a emitir o código 80386. Se eu escrevesse o gerenciador de inicialização para um computador moderno, a exibição no 80686 também seria normal, mas o DOSBox é 80386.
O argumento
-ffreestanding
exige que o GCC não emita código que acesse as funções auxiliares da biblioteca padrão interna. Às vezes, em vez de trabalhar com código, ele produz um código para chamar uma função interna, especialmente com operadores matemáticos. Eu tive um dos principais problemas com o cco, em que esse comportamento não pode ser desativado. Essa opção é usada com mais frequência ao gravar carregadores de inicialização e kernels do SO. E agora os arquivos dos dos .com.
Opções do vinculador
A
-Wl
usada para passar argumentos para o vinculador (
ld
). Precisamos disso porque fazemos tudo em uma chamada para o GCC.
-Wl,--nmagic,--script=com.ld
--nmagic
desativa o alinhamento da página de seção. Em primeiro lugar, não precisamos disso. Em segundo lugar, desperdiça um espaço precioso. Nos meus testes, isso não parece ser uma medida necessária, mas por precaução, deixo essa opção.
O parâmetro
--script
indica que queremos usar um
script vinculador especial. Isso permite que você coloque com precisão as seções (
text
,
data
,
bss
,
rodata
) do nosso programa. Aqui está o script
com.ld
OUTPUT_FORMAT(binary) SECTIONS { . = 0x0100; .text : { *(.text); } .data : { *(.data); *(.bss); *(.rodata); } _heap = ALIGN(4); }
OUTPUT_FORMAT(binary)
diz para você não colocar isso em um arquivo ELF (ou PE, etc.). O vinculador deve apenas redefinir o código limpo. Um arquivo COM é apenas um código limpo, ou seja, damos o comando ao vinculador para criar um arquivo COM!
Eu disse que os arquivos COM são carregados em
0x0100
. A quarta linha muda o binário para lá. O primeiro byte do arquivo COM ainda é o primeiro byte do código, mas será iniciado a partir desse deslocamento de memória.
Em seguida, todas as seções seguem:
text
(programa),
data
(
data
estáticos),
bss
(dados com inicialização zero),
rodata
(strings). Por fim,
_heap
o final do binário com o símbolo
_heap
. Isso será útil mais tarde ao escrever
sbrk()
quando terminarmos com “Hello, World”. Eu indiquei para alinhar
_heap
com 4 bytes.
Quase pronto.
Lançamento do programa
O vinculador geralmente conhece nosso ponto de entrada (
main
) e o define para nós. Mas desde que solicitamos um problema "binário", teremos que descobrir por nós mesmos. Se a função
print()
for a primeira a ser executada, o programa iniciará a partir dela, o que está errado. O programa precisa de um cabeçalho pequeno para começar.
Existe uma opção
STARTUP
no script do vinculador para essas coisas, mas por simplicidade, a implementaremos diretamente no programa. Geralmente, essas coisas são chamadas de
crt0.o
ou
Boot.o
, caso você as
Boot.o
em algum lugar. Nosso código
deve começar com este assembler interno, antes de qualquer inclusão e similares. O DOS fará a maior parte da instalação para nós, só precisamos ir para o ponto de entrada.
asm (".code16gcc\n" "call dosmain\n" "mov $0x4C, %ah\n" "int $0x21\n");
.code16gcc
diz ao assembler que vamos trabalhar em modo real, para que ele faça a configuração correta. Apesar do nome, ele
não produzirá código de 16 bits! Primeiro, a função
dosmain
, que escrevemos anteriormente, é chamada. Ele então diz ao DOS usando a função 0x4C (“terminar com o código de retorno”) que terminamos passando o código de saída para o registro de 1 byte
al
(já definido pelo
dosmain
). Esse montador embutido é automaticamente
volatile
porque não possui entradas e saídas.
Todos juntos
Aqui está o programa inteiro em 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; }
Não vou repetir
com.ld
Aqui está o desafio do GCC.
gcc -std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding \ -o hello.com -Wl,--nmagic,--script=com.ld hello.c
E seus testes no DOSBox:

Então, se você quiser belos gráficos, a única questão é chamar a interrupção e
gravar na memória VGA . Se você deseja som, use a interrupção do alto-falante do PC. Ainda não descobri como ligar para o Sound Blaster. A partir desse momento, o DOS Defender cresceu.
Alocação de memória
Para cobrir outro tópico, lembre-se que
_heap
? Podemos usá-lo para implementar
sbrk()
e alocar memória dinamicamente na seção principal do programa. Este é um modo real e não há memória virtual, para que possamos gravar em qualquer memória que possamos acessar a qualquer momento. Algumas áreas são reservadas (por exemplo, memória inferior e superior) para o equipamento. Portanto, não há necessidade
real de usar sbrk (), mas é interessante tentar.
Como de costume no x86, seu programa e partições estão na memória inferior (0x0100 nesse caso) e a pilha está na memória superior (no nosso caso, na região 0xffff). Em sistemas tipo Unix, a memória retornada por
malloc()
vem de dois lugares:
sbrk()
e
mmap()
. O que o
sbrk()
faz é alocar memória logo acima dos segmentos de programa / dados, incrementando-a na direção da pilha. Cada chamada para
sbrk()
aumentará esse espaço (ou deixará exatamente o mesmo). Essa memória será gerenciada por
malloc()
e similares.
Veja como implementar
sbrk()
em um programa COM. Observe que você precisa definir seu próprio
size_t
, porque não temos uma biblioteca padrão.
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; }
Simplesmente define o ponteiro para
_heap
e o incrementa conforme necessário. Um pouco mais inteligente
sbrk()
também terá cuidado com o alinhamento.
Uma coisa interessante aconteceu durante a criação do DOS Defender. Eu (incorretamente) considerei que a memória do meu
sbrk()
redefinida. Então foi depois do primeiro jogo. No entanto, o DOS não redefine essa memória entre os programas. Quando iniciei o jogo novamente,
ele continuou exatamente onde parei , porque as mesmas estruturas de dados com o mesmo conteúdo foram carregadas no lugar. Muito legal coincidência! Isso faz parte do que torna essa plataforma incorporada divertida.