Como compilar um arquivo COM do DOS pelo compilador GCC

Artigo publicado em 9 de dezembro de 2014
Atualizaçã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" : /* no output */ : "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" : /* no output */ : "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.

Source: https://habr.com/ru/post/pt412867/


All Articles