Skip to content

Instantly share code, notes, and snippets.

@LucasAlfare
Created January 17, 2024 03:54
Show Gist options
  • Save LucasAlfare/cd4cbb90502fc83534e6059422bbefe8 to your computer and use it in GitHub Desktop.
Save LucasAlfare/cd4cbb90502fc83534e6059422bbefe8 to your computer and use it in GitHub Desktop.
tradução crua de "how to make an operating system from scratch", de nick blundell, para português
Esta narração é gerada automaticamente com base em traduções também automáticas do excelente material escrito por Nick Blundell. Dessa forma, é importante que vocês ouvintes entendam que muitas palavras que serão narradas aqui poderão não ter entonação ou serem pronunciadas de forma estranha. Além disso, por se tratar de um conteúdo relacionado a códigos, a narração pode não conseguir balbuciar esses trechos de uma forma tão compreensível.
Baseado nessa conclusão, a ideia deste áudio-livro é servir como auxílio para aqueles que têm dificuldade de leitura. Além disso, encorajamos o máximo possível que o acompanhamento dessa narração seja feita em simultâneo com o material original, o que será algo que dará uma compreensão muito melhor para vocês.
Por fim, o material original conta com vários exemplos, snippets de código e até mesmo imagens, então é imprecindível que esse livro seja utilizado, também, como guia prático para o tema que ele propõe, o desenvolvimento de um sistema operacional simples.
Vale lembrar também que esse tópico em questão é algo relativamente avançado, o que requer que já se saiba pelo menos alguns conceitos básicos sobre coisas de baixo nível, como o que é um processador, o que é memória, o que é um "assembler", e coisas assim.
Algum link para o material original será anexado à publicação desse áudio-livro, seja em alguma descrição ou no título. Se houver uma seção de comentários no local onde está sendo publicado então lá também será um bom lugar onde uma referência para o material original estará. De qualquer forma, caso não haja referências para o material original como links ou algo assim, busquem na internet pelo título do livro em inglês: "Escrevendo um sistema operacional simples — do zero".
Capítulo 1 - Introdução
Todos nós já utilizamos um sistema operacional (s.o.) antes (por exemplo, Windows XP, Linux, etc.), e talvez até mesmo tenhamos escrito alguns programas para serem executados nele; mas para que serve realmente um sistema operacional? Quanto do que vejo ao usar um computador é feito pelo hardware e quanto é feito pelo software? E como o computador realmente funciona?
O saudoso Professor Doug Shepherd, um professor animado meu na Universidade de Lancaster, uma vez me lembrou durante minhas reclamações sobre algum problema irritante de programação que, na época, antes mesmo de começar qualquer pesquisa, ele tinha que escrever seu próprio sistema operacional, do zero. Parece que, hoje em dia, damos muito como certo sobre como essas maravilhosas máquinas realmente funcionam sob todas aquelas camadas de software que normalmente vêm incluídas e são necessárias para sua utilidade diária.
Aqui, concentrando-nos na amplamente utilizada CPU de arquitetura x86, vamos despir nosso computador de todo o software e seguir os primeiros passos de Doug, aprendendo pelo caminho sobre:
• Como um computador é inicializado
• Como escrever programas de baixo nível na paisagem desolada onde ainda não existe nenhum sistema operacional
• Como configurar a CPU para que possamos começar a usar suas funcionalidades estendidas
• Como inicializar código escrito em uma linguagem de nível mais alto, para que possamos realmente começar a progredir em direção ao nosso próprio sistema operacional
• Como criar alguns serviços fundamentais do sistema operacional, como drivers de dispositivo, sistemas de arquivos, processamento multitarefa.
Observe que, em termos de funcionalidade prática do sistema operacional, este guia não tem a pretensão de ser abrangente, mas sim visa reunir trechos de informações de muitas fontes em um documento autocontido e coerente, que lhe proporcionará uma experiência prática de programação de baixo nível, como os sistemas operacionais são escritos e os tipos de problemas que eles devem resolver. A abordagem deste guia é única, pois as linguagens e ferramentas específicas (por exemplo, assembly, C, Make, etc.) não são o foco, mas são tratadas como um meio para um fim: aprenderemos o necessário sobre essas coisas para nos ajudar a alcançar nosso principal objetivo.
Este trabalho não se destina a substituir, mas sim a ser um trampolim para trabalhos excelentes, como o projeto Minix e para o desenvolvimento de sistemas operacionais em geral.
Capítulo 2
Arquitetura de computadores e processo de boot
Tópico 2.1: O processo de boot
Agora, começamos nossa jornada.
Quando reiniciamos nosso computador, ele precisa iniciar novamente, inicialmente sem qualquer noção de um sistema operacional. De alguma forma, ele deve carregar o sistema operacional --- seja qual for a variante --- de algum dispositivo de armazenamento permanente atualmente conectado ao computador (por exemplo, um disco flexível, um disco rígido, um dongle USB, etc.).
Como descobriremos em breve, o ambiente pré-S-O- do seu computador oferece poucos serviços avançados: nesta fase, até mesmo um sistema de arquivos simples seria um luxo (por exemplo, ler e gravar arquivos lógicos em um disco), mas não temos nada disso. Felizmente, o que temos é o Software Básico de Entrada/Saída (BIOS), uma coleção de rotinas de software que são inicialmente carregadas de um chip para a memória e inicializadas quando o computador é ligado. O BIOS fornece auto-detecção e controle básico dos dispositivos essenciais do seu computador, como a tela, o teclado e os discos rígidos.
Após o BIOS concluir alguns testes de baixo nível do hardware, especialmente se a memória instalada está funcionando corretamente ou não, ele deve inicializar o sistema operacional armazenado em um dos seus dispositivos. Aqui, somos lembrados de que o BIOS não pode simplesmente carregar um arquivo que representa seu sistema operacional de um disco, pois o BIOS não tem noção de um sistema de arquivos. O BIOS deve ler setores específicos de dados (geralmente com 512 bytes de tamanho) de locais físicos específicos dos dispositivos de disco, como Cilindro 2, Cabeça 3, Setor 5 (detalhes sobre endereçamento de disco são descritos posteriormente, na Seção XXX).
Portanto, o lugar mais fácil para o BIOS encontrar nosso SO é no primeiro setor de um dos discos (ou seja, Cilindro 0, Cabeça 0, Setor 0), conhecido como setor de inicialização. Como alguns de nossos discos podem não conter sistemas operacionais (podem simplesmente estar conectados para armazenamento adicional), é importante que o BIOS consiga determinar se o setor de inicialização de um disco específico é código de inicialização destinado à execução ou simplesmente dados. Note que a CPU não diferencia entre código e dados: ambos podem ser interpretados como instruções da CPU, onde o código é simplesmente instruções elaboradas por um programador em algum algoritmo útil.
Novamente, um meio pouco sofisticado é adotado aqui pelo BIOS, em que os últimos dois bytes de um setor de inicialização pretendido devem ser configurados como o número mágico 0x aa55. Portanto, o BIOS percorre cada dispositivo de armazenamento (por exemplo, unidade de disquete, disco rígido, unidade de CD, etc.), lê o setor de inicialização na memória e instrui a CPU a começar a executar o primeiro setor de inicialização que encontra, terminando com o número mágico.
É aqui que tomamos controle do computador.
Tópico 2.2: BIOS, Blocos de Boot e o número mágico
Se utilizarmos um editor binário, como o TextPad ou o GHex, que nos permitirá escrever valores brutos de bytes para um arquivo --- ao contrário de um editor de texto padrão que converterá caracteres como 'A' em valores ASCII ---, então podemos criar um setor de inicialização simples, mas válido, para nós mesmos.
Observe que, na Figura 2.1, as três características importantes são:
• Os três primeiros bytes, em hexadecimal como 0xe9, 0xfd e 0xff, são, na verdade,
instruções de código de máquina, conforme definido pelo fabricante da CPU, para realizar
um salto infinito.
• Os dois últimos bytes, 0x55 e 0xaa, formam o número mágico, que informa ao BIOS
que este é realmente um bloco de inicialização e não apenas dados que estão no setor
de inicialização de um disco.
• O arquivo é preenchido com zeros (o '*' indica zeros omitidos por brevidade), basicamente para posicionar o número mágico do BIOS no final do setor de 512 bytes do disco.
Uma nota importante sobre endianismo. Você pode estar se perguntando por que o número mágico do BIOS foi anteriormente descrito como o valor de 16 bits 0xaa55, mas em nosso setor de inicialização foi escrito como os bytes consecutivos 0x55 e 0xaa. Isso ocorre porque a arquitetura x86 lida com valores de vários bytes no formato little-endian, onde bytes menos significativos precedem bytes mais significativos, o que é contrário ao nosso sistema de numeração familiar --- embora se nosso sistema mudasse e eu tivesse £0000005 na minha conta bancária, eu poderia me aposentar agora e talvez doar algumas libras para a necessitada Fundação dos Ex-milionários.
Compiladores e assemblers podem ocultar muitos problemas de endianismo ao nos permitir definir os tipos de dados, de modo que, por exemplo, um valor de 16 bits seja serializado automaticamente em código de máquina com seus bytes na ordem correta. No entanto, às vezes é útil, especialmente ao procurar por bugs, saber exatamente onde um byte individual será armazenado em um dispositivo de armazenamento ou na memória, então o endianismo é muito importante.
Este é possivelmente o menor programa que seu computador poderia executar, mas é um programa válido mesmo assim, e podemos testar isso de duas maneiras, sendo a segunda muito mais segura e adequada aos nossos experimentos:
• Usando os meios que seu sistema operacional atual permitir, escreva este bloco de inicialização no primeiro setor de um dispositivo de armazenamento não essencial (por exemplo, disquete ou unidade flash), e então reinicie o computador.
• Use software de máquina virtual, como VMWare ou VirtualBox, e defina o código do bloco de inicialização como uma imagem de disco de uma máquina virtual, em seguida, inicie a máquina virtual.
Você pode ter certeza de que esse código foi carregado e executado se o seu computador simplesmente travar após a inicialização, sem uma mensagem como "Nenhum sistema operacional encontrado". Este é o loop infinito em ação, que colocamos no início do código. Sem este loop, a CPU iria descontroladamente, executando cada instrução subsequente na memória, a maioria das quais serão bytes aleatórios e não inicializados, até se jogar em algum estado inválido e reiniciar ou, por acaso, encontrar e executar uma rotina de BIOS que formata o seu disco principal.
Lembre-se, somos nós que programamos o computador, e o computador segue nossas instruções cegamente, buscando e executando-as, até que seja desligado; então precisamos garantir que ele execute nosso código elaborado em vez de bytes aleatórios de dados armazenados em algum lugar na memória. Neste nível baixo, temos muito poder e responsabilidade sobre nosso computador, então precisamos aprender a controlá-lo.
Tópico 2.3
Emulação de CPU
Existe uma terceira opção mais conveniente para testar esses programas de baixo nível sem ter que reiniciar continuamente uma máquina ou arriscar apagar dados importantes de um disco, e isso é usar um emulador de CPU, como Bochs ou QEmu. Ao contrário da virtualização de máquinas (por exemplo, VMware, VirtualBox), que tenta otimizar o desempenho e, portanto, o uso do sistema operacional hospedeiro, executando instruções do convidado diretamente na CPU, a emulação envolve um programa que se comporta como uma arquitetura de CPU específica, usando variáveis para representar registradores da CPU e estruturas de controle de alto nível para simular saltos de nível mais baixo e assim por diante, sendo muito mais lenta, mas frequentemente mais adequada para o desenvolvimento e depuração desses sistemas.
Observe que, para fazer algo útil com um emulador, é necessário fornecer a ele algum código para ser executado na forma de um arquivo de imagem de disco. Um arquivo de imagem é simplesmente os dados brutos (ou seja, código de máquina e dados) que, de outra forma, teriam sido gravados em um meio de um disco rígido, um disquete, um CD-ROM, uma unidade USB, etc. De fato, alguns emuladores inicializarão e executarão um sistema operacional real a partir de um arquivo de imagem baixado ou extraído de um CD-ROM de instalação --- embora a virtualização seja mais adequada para esse tipo de uso.
Os emuladores traduzem instruções de dispositivo de exibição de baixo nível em renderização de pixels em uma janela de desktop, para que você possa ver exatamente o que seria renderizado em um monitor real. Em geral, e para os exercícios neste documento, segue-se que qualquer código de máquina que seja executado corretamente em um emulador também será executado corretamente na arquitetura real --- embora obviamente muito mais rápido.
Tópico 2.3.1
Bochs: Um Emulador de CPU x86
O Bochs requer que configuremos um arquivo de configuração simples, bochsrc, no diretório local, que descreve os detalhes de como os dispositivos reais (por exemplo, a tela e o teclado) devem ser emulados e, o que é mais importante, qual imagem de disco flexível deve ser inicializada quando o computador emulado é iniciado.
A Figura 2.2 mostra um exemplo de arquivo de configuração do Bochs que podemos usar para testar o setor de inicialização escrito na Seção XXX e salvo como o arquivo boot sect.bin.
Para testar nosso setor de inicialização no Bochs, basta digitar:
$ bochs
Como um experimento simples, tente alterar o número mágico do BIOS em nosso setor de inicialização para algo inválido e depois execute novamente o Bochs.
Dado que a emulação da CPU pelo Bochs é próxima ao real, após testar o código no Bochs, você deverá ser capaz de inicializá-lo em uma máquina real, onde ele será executado muito mais rapidamente.
Tópico 2.3.2
O emulado QEmu
O QEmu é semelhante ao Bochs, embora seja muito mais eficiente e capaz de emular também arquiteturas diferentes da x86. Embora o QEmu tenha menos documentação que o Bochs, a ausência de um arquivo de configuração significa que é mais fácil colocá-lo em funcionamento, da seguinte forma:
$ qemu <seu-arquivo-de-imagem-de-inicializacao-do-sistema-operacional>
Lembre-se de substituir <seu-arquivo-de-imagem-de-inicializacao-do-sistema-operacional> pelo caminho e nome do arquivo de imagem do disco de inicialização do seu sistema operacional.
Tópico 2.4
A simplesmente útil notação hexadecimal
Já vimos alguns exemplos de hexadecimal, então é importante entender por que o hexadecimal é frequentemente usado em programação de baixo nível.
Primeiro, pode ser útil considerar por que contar até dez nos parece tão natural, porque quando vemos hexadecimal pela primeira vez, sempre nos perguntamos: por que não contar simplesmente até dez? Não sendo um especialista no assunto, vou assumir que contar até dez tem algo a ver com a maioria das pessoas tendo um total de dez dedos nas mãos, o que levou à ideia de números sendo representados por 10 símbolos distintos: 0,1,2,...8,9
O decimal tem uma base de dez (ou seja, tem dez símbolos de dígitos distintos), mas o hexadecimal tem uma base de 16, então precisamos inventar alguns novos símbolos numéricos; e a maneira fácil é simplesmente usar algumas letras, dando-nos: 0,1,2,...8,9,a,b,c,d,e,f, onde o dígito único d, por exemplo, representa uma contagem de 13.
Para distinguir entre hexadecimal e outros sistemas numéricos, frequentemente usamos o prefixo 0x, ou às vezes o sufixo h, que é especialmente importante para dígitos hexadecimais que não contêm nenhum dos dígitos de letras, por exemplo: 0x50 não é igual a (decimal) 50 --- 0x50 é na verdade 80 em decimal.
A questão é que um computador representa um número como uma sequência de bits (dígitos binários), já que fundamentalmente sua circuitaria só pode distinguir entre dois estados elétricos: 0 e 1 --- é como se o computador tivesse um total de apenas dois dedos. Portanto, para representar um número maior que 1, o computador pode agrupar uma série de bits, assim como podemos contar acima de 9 tendo dois ou mais dígitos (por exemplo, 456, 23, etc.).
Nomes foram adotados para séries de bits de certos comprimentos para facilitar a discussão e concordância sobre o tamanho dos números com os quais estamos lidando. As instruções da maioria dos computadores lidam com valores de no mínimo 8 bits, que são chamados de bytes. Outros agrupamentos são short, int e long, que geralmente representam valores de 16 bits, 32 bits e 64 bits, respectivamente. Também vemos o termo word, que é usado para descrever o tamanho da unidade de processamento máxima do modo atual da CPU: então, no modo real de 16 bits, uma word refere-se a um valor de 16 bits; no modo protegido de 32 bits, uma word refere-se a um valor de 32 bits; e assim por diante.
Portanto, voltando ao benefício do hexadecimal: sequências de bits são bastante verbosas para escrever, mas são muito mais fáceis de converter de e para a notação hexadecimal mais concisa do que de e para o nosso sistema decimal natural, essencialmente porque podemos quebrar a conversão em segmentos menores de 4 bits do número binário, em vez de tentar somar todos os bits componentes em um total grandioso, o que se torna muito mais difícil para sequências de bits maiores (por exemplo, 16, 32, 64, etc.). Essa dificuldade com a conversão decimal é mostrada claramente pelo exemplo dado na Figura 2.3.
Capítulo 3
Programação de Setor de Inicialização (em Modo Real de 16 bits)
Mesmo com o código de exemplo fornecido, você sem dúvida achou frustrante escrever código de máquina em um editor binário. Você teria que lembrar, ou fazer referência contínua, a quais dos muitos códigos de máquina possíveis fazem a CPU executar determinadas funções. Felizmente, você não está sozinho, e, portanto, foram desenvolvidos montadores que traduzem instruções mais amigáveis para humanos em código de máquina para uma CPU específica.
Neste capítulo, exploraremos alguns programas de setor de inicialização cada vez mais sofisticados para nos familiarizarmos com a linguagem de montagem e o ambiente pré-SO desolado no qual nossos programas serão executados.
Tópico 3.1
Setor de Inicialização Revisitado
Agora, vamos recriar o setor de inicialização editado em binário da Seção XXX usando a linguagem de montagem, para que possamos realmente apreciar o valor mesmo de uma linguagem de nível muito baixo.
Podemos montar isso em código de máquina real (uma sequência de bytes que nossa CPU pode interpretar como instruções) da seguinte forma:
$nasm boot sect.asm -f bin -o boot sect.bin
Onde boot sect.asm é o arquivo no qual salvamos o código-fonte na Figura 3.1 e boot sect.bin é o código de máquina montado que podemos instalar como um setor de inicialização em um disco.
Observe que usamos a opção -f bin para instruir o nasm a produzir código de máquina bruto, em vez de um pacote de código que possui informações adicionais para vinculação a outras rotinas que esperaríamos usar ao programar em um ambiente de sistema operacional mais típico. Não precisamos de nada disso. Além das rotinas de BIOS de baixo nível, somos o único software em execução neste computador agora. Somos o sistema operacional agora, embora neste estágio não tenhamos mais a oferecer do que um loop infinito --- mas em breve construiremos a partir disso.
Em vez de salvar isso no setor de inicialização de um disquete e reiniciar nossa máquina, podemos testar este programa convenientemente executando o Bochs:
$ bochs
Ou, dependendo de nossa preferência e da disponibilidade de um emulador, poderíamos usar o QEmu, da seguinte forma:
$ qemu boot sect.bin
Alternativamente, você pode carregar o arquivo de imagem em um software de virtualização ou gravá-lo em algum meio inicializável e inicializá-lo a partir de um computador real. Observe que, ao gravar um arquivo de imagem em algum meio inicializável, isso não significa que você adiciona o arquivo ao sistema de arquivos do meio: você deve usar uma ferramenta apropriada para gravar diretamente no meio em um sentido de baixo nível (por exemplo, diretamente nos setores de um disco).
Se quisermos ver mais facilmente exatamente quais bytes o montador criou, podemos executar o seguinte comando, que exibe o conteúdo binário do arquivo em um formato hexadecimal de fácil leitura:
$ od -t x1 -A n boot sect.bin
A saída deste comando deve parecer familiar.
Parabéns, você acabou de escrever um setor de inicialização em linguagem de montagem. Como veremos, todos os sistemas operacionais devem começar dessa maneira e depois se elevam para abstrações de nível superior (por exemplo, linguagens de nível superior, como C/C++).
Tópico 3.2
Modo Real de 16 bits
Os fabricantes de CPUs precisam se esforçar muito para manter suas CPUs (ou seja, seu conjunto específico de instruções) compatíveis com CPUs mais antigas, para que softwares mais antigos, e em particular sistemas operacionais mais antigos, ainda possam ser executados nas CPUs mais modernas.
A solução implementada pela Intel e CPUs compatíveis é emular a CPU mais antiga da família: a Intel 8086, que tinha suporte para instruções de 16 bits e nenhuma noção de proteção de memória. A proteção de memória é crucial para a estabilidade dos sistemas operacionais modernos, pois permite que um sistema operacional restrinja um processo do usuário de acessar, por exemplo, a memória do kernel, o que, feito acidentalmente ou intencionalmente, poderia permitir que tal processo contornasse mecanismos de segurança ou até mesmo derrubasse todo o sistema.
Portanto, por questões de compatibilidade reversa, é importante que as CPUs inicializem inicialmente no modo real de 16 bits, exigindo que os sistemas operacionais modernos mudem explicitamente para o modo protegido mais avançado de 32 bits (ou 64 bits), mas permitindo que os sistemas operacionais mais antigos continuem, felizes sem saber que estão rodando em uma CPU moderna. Mais tarde, veremos com detalhes essa etapa importante da transição do modo real de 16 bits para o modo protegido de 32 bits.
Geralmente, quando dizemos que uma CPU é de 16 bits, queremos dizer que suas instruções podem lidar com no máximo 16 bits de uma vez, por exemplo: uma CPU de 16 bits terá uma instrução específica que pode somar dois números de 16 bits em um ciclo da CPU; se fosse necessário que um processo somasse dois números de 32 bits, levaria mais ciclos, que fazem uso da adição de 16 bits.
Primeiro, exploraremos esse ambiente de modo real de 16 bits, pois todos os sistemas operacionais devem começar aqui; depois veremos como mudar para o modo protegido de 32 bits e os principais benefícios desse processo.
Tópico 3.3
Erm, Hello?
Agora vamos escrever um programa de setor de inicialização aparentemente simples que imprime uma mensagem curta na tela. Para fazer isso, precisaremos aprender alguns fundamentos de como a CPU funciona e como podemos usar o BIOS para nos ajudar a manipular o dispositivo de tela.
Primeiramente, vamos pensar no que estamos tentando fazer aqui. Gostaríamos de imprimir um caractere na tela, mas não sabemos exatamente como nos comunicar com o dispositivo de tela, pois pode haver muitos tipos diferentes de dispositivos de tela e eles podem ter interfaces diferentes. É por isso que precisamos usar o BIOS, pois o BIOS já fez alguma detecção automática do hardware e, evidentemente pelo fato de o BIOS ter impresso informações na tela anteriormente sobre auto teste e assim por diante, pode nos oferecer ajuda.
Então, em seguida, gostaríamos de pedir ao BIOS para imprimir um caractere para nós, mas como pedimos ao BIOS para fazer isso? Não existem bibliotecas Java para imprimir na tela - elas são um sonho distante. Podemos ter certeza, no entanto, de que em algum lugar na memória do computador haverá algum código de máquina do BIOS que sabe como escrever na tela. A verdade é que poderíamos possivelmente encontrar o código do BIOS na memória e executá-lo de alguma forma, mas isso daria mais trabalho do que vale a pena e estará sujeito a erros quando houver diferenças entre as partes internas da rotina do BIOS em máquinas diferentes.
Aqui podemos fazer uso de um mecanismo fundamental do computador: interrupções.
Tópico 3.3.1
Interrupções
As interrupções são um mecanismo que permite à CPU interromper temporariamente o que está fazendo e executar algumas outras instruções de maior prioridade antes de retornar à tarefa original. Uma interrupção pode ser gerada por uma instrução de software (por exemplo, int 0x10) ou por algum dispositivo de hardware que requer ação de alta prioridade (por exemplo, para ler alguns dados recebidos de um dispositivo de rede).
Cada interrupção é representada por um número único que é um índice para o vetor de interrupção, uma tabela inicialmente configurada pelo BIOS no início da memória (ou seja, no endereço físico 0x0) que contém ponteiros de endereço para rotinas de serviço de interrupção (ISRs). Uma ISR é simplesmente uma sequência de instruções de máquina, assim como nosso código de setor de inicialização, que lida com uma interrupção específica (por exemplo, talvez para ler novos dados de uma unidade de disco ou de um cartão de rede).
Portanto, em resumo, o BIOS adiciona algumas de suas próprias ISRs ao vetor de interrupção que se especializam em determinados aspectos do computador, por exemplo: a interrupção 0x10 faz com que a ISR relacionada à tela seja invocada; e a interrupção 0x13, a ISR relacionada à E/S de disco.
No entanto, seria desperdício alocar uma interrupção por rotina do BIOS, então o BIOS multiplexa as ISRs por meio do que poderíamos imaginar como uma grande declaração de switch, com base geralmente no valor definido em um dos registradores de propósito geral da CPU, ax, antes de gerar a interrupção.
Tópico 3.3.2
Registradores da CPU
Assim como usamos variáveis em linguagens de nível superior, é útil se pudermos armazenar dados temporariamente durante uma rotina específica. Todas as CPUs x86 têm quatro registradores de propósito geral, ax, bx, cx e dx, exatamente para esse propósito. Além disso, esses registradores, que podem armazenar cada um uma palavra (duas bytes, 16 bits) de dados, podem ser lidos e escritos pela CPU com um atraso negligenciável em comparação com o acesso à memória principal. Em programas assembly, uma das operações mais comuns é mover (ou mais precisamente, copiar) dados entre esses registradores:
mov ax, 1234 ; armazena o número decimal 1234 em ax
mov cx, 0x234 ; armazena o número hexadecimal 0x234 em cx
mov dx, 't' ; armazena o código ASCII para a letra 't' em dx
mov bx, ax ; copia o valor de ax para bx, agora bx == 1234
Observe que o destino é o primeiro e não o segundo argumento da operação mov, mas essa convenção varia entre diferentes assemblers.
Às vezes, é mais conveniente trabalhar com bytes individuais, então esses registradores nos permitem configurar seus bytes altos e baixos independentemente:
mov ax, 0 ; ax -> 0x0000, ou em binário 0000000000000000
mov ah, 0x56 ; ax -> 0x5600
mov al, 0x23 ; ax -> 0x5623
mov ah, 0x16 ; ax -> 0x1623
Tópico 3.3.3
Juntando Tudo
Então, lembre-se de que gostaríamos que o BIOS imprimisse um caractere na tela para nós e que podemos chamar uma rotina específica do BIOS definindo ax para algum valor definido pelo BIOS e, em seguida, acionando uma interrupção específica. A rotina específica que queremos é a rotina de rolagem de teletipo do BIOS, que imprimirá um único caractere na tela e avançará o cursor, pronto para o próximo caractere. Existe uma lista completa de rotinas do BIOS publicadas que mostram qual interrupção usar e como configurar os registradores antes da interrupção. Aqui, precisamos da interrupção 0x10 e configurar ah para 0x0e (indicando modo de teletipo) e al para o código ASCII do caractere que desejamos imprimir.
A Figura 3.2 mostra o programa completo do setor de inicialização. Observe como, neste caso, precisamos configurar ah apenas uma vez e depois apenas alteramos al para diferentes caracteres.
Apenas para completar, a Figura 3.3 mostra o código de máquina bruto deste setor de inicialização. Esses são os bytes reais que estão dizendo à CPU exatamente o que fazer. Se você ficar surpreso com a quantidade de esforço e compreensão envolvidos na escrita de um programa tão simples - se é que é útil -, lembre-se de que essas instruções mapeiam muito de perto a circuitaria da CPU, sendo, portanto, necessariamente muito simples, mas também muito rápidas. Você está conhecendo seu computador agora, como ele realmente é.
Tópico 3.4
Hello, World!
Agora vamos tentar uma versão um pouco mais avançada do programa 'hello', que introduz alguns conceitos mais avançados da CPU e uma compreensão do espaço de memória no qual nosso setor de inicialização é colocado pelo BIOS.
Tópico 3.4.1
Memória, Endereços e Rótulos
Dissemos anteriormente como a CPU busca e executa instruções na memória e como foi o BIOS que carregou nosso setor de inicialização de 512 bytes na memória e, após concluir suas inicializações, instruiu a CPU a pular para o início do nosso código, momento em que começou a executar nossa primeira instrução, depois a próxima e assim por diante.
Então, nosso código de setor de inicialização está em algum lugar na memória; mas onde? Podemos imaginar a memória principal como uma longa sequência de bytes que podem ser acessados individualmente por um endereço (ou seja, um índice), então se quisermos descobrir o que está no 54º byte de memória, então 54 é nosso endereço, que muitas vezes é mais conveniente expressar em hexadecimal: 0x36.
Portanto, o início do nosso código de setor de inicialização, o primeiro byte de código de máquina, está em algum endereço na memória, e foi o BIOS que nos colocou lá. Podemos supor, a menos que soubéssemos o contrário, que o BIOS carregou nosso código no início da memória, no endereço 0x0. No entanto, não é tão simples, porque sabemos que o BIOS já estava realizando trabalhos de inicialização no computador muito antes de carregar nosso código e continuará a atender interrupções de hardware para o relógio, unidades de disco, etc. Portanto, essas rotinas do BIOS (por exemplo, ISRs, serviços para impressão na tela, etc.) elas próprias devem ser armazenadas em algum lugar na memória e devem ser preservadas (ou seja, não sobrescritas) enquanto ainda estiverem em uso. Além disso, observamos anteriormente que o vetor de interrupção está localizado no início da memória, e se o BIOS nos carregasse lá, nosso código pisaria na tabela, e na próxima interrupção ocorrer, o computador provavelmente travará e reiniciará: a associação entre o número de interrupção e a ISR seria efetivamente interrompida.
Como acontece, o BIOS gosta sempre de carregar o setor de inicialização no endereço 0x7c00, onde tem certeza de que não será ocupado por rotinas importantes. A Figura 3.4 dá um exemplo da disposição típica de baixa memória do computador quando nosso setor de inicialização acabou de ser carregado. Portanto, embora possamos instruir a CPU a gravar dados em qualquer endereço na memória, isso pode causar problemas, pois parte da memória está sendo usada por outras rotinas, como a interrupção do temporizador e dispositivos de disco.
Tópico 3.4.2
A "X" Marca o Local
Agora vamos jogar um jogo chamado "encontre o byte", que demonstrará a referência de memória, o uso de rótulos em código assembly e a importância de saber onde o BIOS nos carregou. Vamos escrever um programa em assembly que reserva um byte de dados para um caractere e depois tentaremos imprimir esse caractere na tela. Para fazer isso, precisamos descobrir seu endereço absoluto na memória, para que possamos carregá-lo em al e fazer o BIOS imprimi-lo, como no último exercício.
Em primeiro lugar, quando declaramos alguns dados em nosso programa, prefixamos com um rótulo (o segredo). Podemos colocar rótulos em qualquer lugar em nossos programas, com o único propósito de nos dar um deslocamento conveniente do início do código para uma instrução ou dados específicos.
Se olharmos o código de máquina montado na Figura 3.5, podemos ver que nosso 'X', que tem um código ASCII hexadecimal 0x58, está em um deslocamento de 30 (0x1e) bytes do início do código, imediatamente antes de preenchermos o setor de inicialização com zeros.
Se executarmos o programa, veremos que apenas as duas últimas tentativas têm sucesso em imprimir um 'X'.
O problema com a primeira tentativa é que ela tenta carregar o deslocamento imediato em al como o caractere a ser impresso, mas na verdade queríamos imprimir o caractere no deslocamento em vez do próprio deslocamento, como tentado na próxima, em que os colchetes quadrados instruem a CPU a fazer exatamente isso - armazenar o conteúdo de um endereço.
Então, por que a segunda tentativa falha? O problema é que a CPU trata o deslocamento como se fosse do início da memória, em vez do endereço de início de nosso código carregado, o que o colocaria por volta do vetor de interrupção. Na terceira tentativa, adicionamos o deslocamento ao segredo ao endereço que acreditamos que o BIOS carregou nosso código, 0x7c00, usando a instrução de adição da CPU. Podemos pensar em add como a declaração em uma linguagem de nível mais alto bx = bx + 0x7c00. Agora calculamos o endereço de memória correto do nosso 'X' e podemos armazenar o conteúdo desse endereço em al, pronto para a função de impressão do BIOS, com a instrução mov al, [bx].
Na quarta tentativa, tentamos ser um pouco inteligentes, pré-calculando o endereço do 'X' depois que o setor de inicialização é carregado na memória pelo BIOS. Chegamos ao endereço 0x7c1e com base em nossa análise anterior do código binário (Veja a Figura 3.5), que revelou que 'X' estava a 0x1e (30) bytes do início do nosso setor de inicialização. Este último exemplo nos lembra por que os rótulos são úteis, pois sem rótulos teríamos que contar os deslocamentos do código compilado e, em seguida, atualizá-los quando as alterações no código causassem mudanças nesses deslocamentos.
Agora vimos como o BIOS de fato carrega nosso setor de inicialização no endereço 0x7c00, e também vimos como os rótulos de endereçamento e código em assembly estão relacionados.
É inconveniente ter que sempre considerar esse deslocamento de rótulo-memória em seu código, então muitos montadores corrigirão as referências de rótulo durante a montagem se você incluir a seguinte instrução no início do seu código, informando exatamente onde você espera que o código seja carregado na memória:
org 0x7c00
Pergunta 1
O que você espera que seja impresso agora, quando esta diretiva org é adicionada a este programa de setor de inicialização? Para obter boas notas, explique por que isso ocorre.
Tópico 3.4.3
Definindo Strings
Supondo que você queira imprimir uma mensagem predefinida (por exemplo, "Booting OS") na tela em algum momento; como você definiria essa string em seu programa em assembly? Devemos nos lembrar de que nosso computador não sabe nada sobre strings e que uma string é apenas uma sequência de unidades de dados (por exemplo, bytes, palavras, etc.) armazenada em algum lugar na memória.
No montador, podemos definir uma string da seguinte forma:
my_string:
db 'Booting OS'
Na verdade, já vimos db, que se traduz para "declarar byte(s) de dados", o que diz ao montador para escrever os bytes subsequentes diretamente no arquivo de saída binário (ou seja, não interpretá-los como instruções do processador). Como cercamos nossos dados com aspas, o montador sabe converter cada caractere para seu código ASCII em byte. Note que frequentemente usamos um rótulo (por exemplo, my_string) para marcar o início de nossos dados, caso contrário, não teríamos uma maneira fácil de referenciá-los dentro de nosso código.
Uma coisa que esquecemos neste exemplo é que saber o comprimento de uma string é igualmente importante a saber onde ela está. Como somos nós que temos que escrever todo o código que lida com strings, é importante ter uma estratégia consistente para saber o comprimento de uma string. Existem algumas possibilidades, mas a convenção é declarar strings como nulas, o que significa que sempre declaramos o último byte da string como 0, da seguinte forma:
Quando mais tarde iteramos por uma string, talvez para imprimir cada um de seus caracteres, podemos facilmente determinar quando chegamos ao final.
Tópico 3.4.4
Usando a Pilha
Quando se trata de computação em nível baixo, frequentemente ouvimos pessoas falando sobre a pilha como se fosse algo especial. A pilha é realmente apenas uma solução simples para a seguinte inconveniência: a CPU tem um número limitado de registradores para o armazenamento temporário das variáveis locais de nossa rotina, mas muitas vezes precisamos de mais armazenamento temporário do que cabe nesses registradores; agora, podemos obviamente usar a memória principal, mas especificar endereços de memória específicos ao ler e gravar é inconveniente, especialmente porque não nos importamos exatamente onde os dados serão armazenados, apenas que podemos recuperá-los facilmente o suficiente. E, como veremos mais tarde, a pilha também é útil para a passagem de argumentos para realizar chamadas de função.
Portanto, a CPU oferece duas instruções, push e pop, que nos permitem, respectivamente, armazenar um valor e recuperar um valor do topo da pilha, e assim sem nos preocuparmos exatamente onde eles estão armazenados. No entanto, observe que não podemos empurrar e puxar bytes individuais para dentro e para fora da pilha: no modo de 16 bits, a pilha funciona apenas em limites de 16 bits.
A pilha é implementada por dois registradores especiais da CPU, bp e sp, que mantêm os endereços da base da pilha (ou seja, a parte inferior da pilha) e o topo da pilha, respectivamente. Como a pilha se expande à medida que empurramos dados para ela, geralmente definimos a base da pilha longe de regiões importantes da memória (por exemplo, como o código BIOS ou nosso código) para que não haja perigo de sobrescrever se a pilha crescer muito. Uma coisa confusa sobre a pilha é que ela realmente cresce para baixo a partir do ponteiro da base, então quando emitimos um push, o valor realmente é armazenado abaixo --- e não acima --- do endereço de bp, e sp é decrementado pelo tamanho do valor.
O programa de setor de inicialização a seguir na Figura 3.6 demonstra o uso da pilha.
Pergunta 2
O que será impresso e em que ordem pelo código na Figura 3.6? E em que endereço de memória absoluto o caractere ASCII 'C' será armazenado? Você pode achar útil modificar o código para confirmar sua expectativa, mas certifique-se de explicar por que é esse endereço.
Tópico 3.4.5
Estruturas de Controle
Nunca ficaríamos confortáveis usando uma linguagem de programação se não soubéssemos como escrever algumas estruturas de controle básicas, como if...then...elseif...else, for e while. Essas estruturas permitem ramos alternativos de execução e formam a base de qualquer rotina útil.
Após a compilação, essas estruturas de controle em nível elevado se reduzem a instruções simples de salto. Na verdade, já vimos o exemplo mais simples de loops:
some_label:
jmp some_label ; salta para o endereço do rótulo
jmp $ ; salta para o endereço da instrução atual
Portanto, essa instrução nos oferece um salto incondicional (ou seja, sempre saltará); mas frequentemente precisamos saltar com base em alguma condição (por exemplo, continuar em loop até que tenhamos feito o loop dez vezes, etc.).
Saltos condicionais são alcançados em linguagem de montagem executando primeiro uma instrução de comparação e, em seguida, emitindo uma instrução de salto condicional específica.
Podemos ver pelo exemplo em assembly que algo está acontecendo nos bastidores que relaciona a instrução cmp com a instrução je que a segue. Este é um exemplo de onde o registrador de flags especial da CPU é usado para capturar o resultado da instrução cmp, de modo que uma instrução de salto condicional subsequente pode determinar se deve ou não saltar para o endereço especificado.
As seguintes instruções de salto estão disponíveis, com base em uma instrução cmp anterior (cmp x, y):
je target ; salta se igual (ou seja, x == y)
jne target ; salta se não igual (ou seja, x != y)
jl target ; salta se menor que (ou seja, x < y)
jle target ; salta se menor ou igual (ou seja, x <= y)
jg target ; salta se maior que (ou seja, x > y)
jge target ; salta se maior ou igual (ou seja, x >= y)
Pergunta 3
É sempre útil planejar seu código condicional em termos de uma linguagem de nível superior e, em seguida, substituí-lo pelas instruções em assembly. Tente converter este pseudocódigo assembly em código assembly completo, usando cmp e instruções de salto apropriadas. Teste-o com diferentes valores de bx. Comente completamente seu código, em suas próprias palavras.
Tópico 3.4.6
Chamando Funções
Em linguagens de alto nível, dividimos grandes problemas em funções, que essencialmente são rotinas de propósito geral (por exemplo, imprimir uma mensagem, escrever em um arquivo, etc.) que usamos repetidamente em nosso programa, geralmente alterando os parâmetros que passamos para a função para mudar o resultado de alguma maneira. No nível da CPU, uma função não é nada mais do que um salto para o endereço de uma rotina útil e, em seguida, um salto de volta para a instrução imediatamente após o primeiro salto.
Primeiramente, observe como usamos o registro al como um parâmetro, configurando-o pronto para que a função o utilize. É assim que a passagem de parâmetros é possível em linguagens de alto nível, onde o chamador e o chamado devem ter algum acordo sobre onde e quantos parâmetros serão passados.
Infelizmente, a principal falha dessa abordagem é que precisamos dizer explicitamente para onde retornar após a chamada de nossa função, e assim não será possível chamar essa função de pontos arbitrários em nosso programa - ela sempre retornará para o mesmo endereço, neste caso, o rótulo return_to_here.
Adotando a ideia de passagem de parâmetros, o código chamador pode armazenar o endereço de retorno correto (ou seja, o endereço imediatamente após a chamada) em algum local conhecido, e o código chamado pode pular de volta para esse endereço armazenado. A CPU controla a instrução atual em execução no registrador especial ip (instruction pointer), que, infelizmente, não podemos acessar diretamente. No entanto, a CPU fornece um par de instruções, call e ret, que fazem exatamente o que queremos: call age como jmp, mas, adicionalmente, antes de efetivamente saltar, empurra o endereço de retorno na pilha; ret então retira o endereço de retorno da pilha e salta para ele.
Nossas funções estão quase autocontidas agora, mas ainda há um problema feio que agradeceremos a nós mesmos mais tarde se agora nos dermos ao trabalho de considerá-lo. Quando chamamos uma função, como uma função de impressão, dentro de nosso programa em assembly, internamente essa função pode alterar os valores de vários registros para realizar seu trabalho (de fato, com os registros sendo um recurso escasso, quase certamente fará isso), então, quando nosso programa retorna da chamada de função, pode não ser seguro presumir, por exemplo, que o valor que armazenamos em dx ainda estará lá.
É frequentemente sensato (e educado), portanto, que uma função imediatamente empurre qualquer registro que planeje alterar para a pilha e depois os retire novamente (ou seja, restaure os valores originais dos registros) imediatamente antes de retornar. Como uma função pode usar muitos dos registros de propósito geral, a CPU implementa duas instruções convenientes, pusha e popa, que convenientemente empurram e puxam todos os registros para e da pilha, respectivamente.
Tópico 3.4.7
Reunindo Tudo
Agora temos conhecimento suficiente sobre a CPU e assembly para escrever um programa de setor de inicialização "Olá, Mundo" mais sofisticado.
Questão 4
Reúna todas as ideias desta seção para criar uma função independente para imprimir strings terminadas em nulo.
Para obter boas notas, certifique-se de que a função é cuidadosa ao modificar registradores e que você comenta totalmente o código para demonstrar sua compreensão.
Tópico 3.4.9
Resumo
Ainda assim, parece que não avançamos muito. Isso é normal, dada a natureza primitiva do ambiente em que estávamos trabalhando. Se você compreendeu tudo até aqui, então estamos bem encaminhados.
Tópico 3.5
Enfermeira, traga-me meu estetoscópio
Até agora, conseguimos fazer o computador imprimir caracteres e strings que carregamos na memória, mas em breve tentaremos carregar alguns dados do disco. Será muito útil se pudermos exibir os valores hexadecimais armazenados em endereços de memória arbitrários para confirmar se conseguimos realmente carregar algo. Lembre-se, não temos o luxo de uma GUI de desenvolvimento agradável, completa com um depurador que nos permita percorrer cuidadosamente e inspecionar nosso código, e o melhor feedback que o computador pode nos dar quando cometemos um erro é visivelmente não fazer nada, então precisamos cuidar de nós mesmos.
Já escrevemos uma rotina para imprimir uma string de caracteres, então agora vamos estender essa ideia para uma rotina de impressão hexadecimal - uma rotina certamente a ser valorizada neste mundo impiedoso de baixo nível.
Vamos pensar cuidadosamente sobre como faremos isso, começando por considerar como gostaríamos de usar a rotina. Em uma linguagem de alto nível, gostaríamos de algo assim: imprimir hex(0x1fb6), o que resultaria na string '0x1fb6' sendo impressa na tela. Já vimos, na Seção XXX, como as funções podem ser chamadas em assembly e como podemos usar registradores como parâmetros, então usemos o registrador dx como um parâmetro para armazenar o valor que desejamos que nossa função de impressão hex imprima.
Como estamos imprimindo uma string na tela, podemos muito bem reutilizar nossa função de impressão anterior para fazer a parte real da impressão. Assim, nossa principal tarefa é examinar como podemos construir essa string a partir do valor em nosso parâmetro, dx. Definitivamente, não queremos complicar mais as coisas do que precisamos ao trabalhar em assembly, então consideremos o seguinte truque para começar com esta função. Se definirmos a string hexadecimal completa como uma espécie de variável de modelo em nosso código, como definimos nossas mensagens anteriores de "Olá, Mundo", podemos simplesmente fazer com que a função de impressão de string a imprima, e a tarefa de nossa rotina de impressão hexadecimal é alterar os componentes dessa string de modelo para refletir o valor hexadecimal como códigos ASCII.
Tópico 3.5.1
Questão 5 (Avançado)
Complete a implementação da função de impressão hexadecimal. Você pode achar úteis as instruções da CPU and e shr, para as quais você pode encontrar informações na Internet.
Certifique-se de explicar completamente seu código com comentários, em suas próprias palavras.
Tópico 3.6
Lendo o disco
Agora fomos apresentados ao BIOS e brincamos um pouco no ambiente de baixo nível do computador, mas temos um pequeno problema que pode atrapalhar nosso plano de escrever um sistema operacional: o BIOS carregou nosso código de inicialização do primeiro setor do disco, mas isso é tudo que ele carregou; e se nosso código de sistema operacional for maior - e estou imaginando que será - do que 512 bytes.
Sistemas operacionais geralmente não cabem em um único setor (512 bytes), então uma das primeiras coisas que eles precisam fazer é inicializar o restante de seu código do disco para a memória e começar a executar esse código. Felizmente, como foi sugerido anteriormente, o BIOS fornece rotinas que nos permitem manipular dados nos discos.
Tópico 3.6.1
Acesso de memória extendido usando segmentos
Quando a CPU opera em seu modo real inicial de 16 bits, o tamanho máximo dos registradores é de 16 bits, o que significa que o endereço mais alto que podemos referenciar em uma instrução é 0xffff, o que hoje corresponde a míseros 64 KB (65536 bytes). Agora, talvez um sistema operacional simples, como o que pretendemos criar, não seja afetado por esse limite, mas sistemas operacionais do dia a dia não se sentiriam confortáveis em uma caixa tão apertada. Portanto, é importante que entendamos a solução para esse problema, que é a segmentação.
Para contornar essa limitação, os projetistas da CPU adicionaram alguns registradores especiais adicionais, cs, ds, ss e es, chamados registradores de segmento. Podemos imaginar a memória principal sendo dividida em segmentos indexados pelos registradores de segmento, de modo que, ao especificarmos um endereço de 16 bits, a CPU calcula automaticamente o endereço absoluto como o endereço inicial do segmento apropriado, compensado pelo endereço especificado. Por segmento apropriado, quero dizer que, a menos que explicitamente informado do contrário, a CPU compensará nosso endereço a partir do registrador de segmento apropriado para o contexto de nossa instrução, por exemplo: o endereço usado na instrução mov ax, [0x45ef] seria por padrão compensado a partir do segmento de dados, indexado por ds; da mesma forma, o segmento de pilha, ss, é usado para modificar a localização real do ponteiro de base da pilha, bp.
A coisa mais confusa sobre o endereçamento de segmento é que segmentos adjacentes se sobrepõem quase completamente, exceto por 16 bytes, de modo que combinações diferentes de segmento e offset podem apontar para o mesmo endereço físico; mas chega de conversa: só entenderemos verdadeiramente esse conceito quando virmos alguns exemplos.
Para calcular o endereço absoluto, a CPU multiplica o valor no registrador de segmento por 16 e depois adiciona o endereço do offset; e como estamos trabalhando com hexadecimal, quando multiplicamos um número por 16, simplesmente o deslocamos um dígito para a esquerda (por exemplo, 0x42 * 16 = 0x420). Portanto, se definirmos ds como 0x4d e, em seguida, emitirmos a instrução mov ax, [0x20], o valor armazenado em ax será realmente carregado do endereço 0x4d0 (16 * 0x4d + 0x20).
A Figura 3.7 mostra como podemos definir ds para realizar uma correção semelhante ao endereçamento de rótulos quando usamos a diretiva [org 0x7c00] na Seção XXX. Como não usamos a diretiva org, o montador não compensa nossos rótulos para as localizações de memória corretas quando o código é carregado pelo BIOS no endereço 0x7c00, então a primeira tentativa de imprimir um 'X' falhará. No entanto, se definirmos o registrador de segmento de dados como 0x7c0, a CPU fará essa compensação por nós (ou seja, 0x7c0 * 16 + o segredo), e a segunda tentativa imprimirá corretamente o 'X'. Nas terceira e quarta tentativas, fazemos o mesmo e obtemos os mesmos resultados, mas explicitamos à CPU qual registrador de segmento usar ao calcular o endereço físico, usando o registrador de segmento geral es.
Observe que as limitações do circuito da CPU (pelo menos no modo real de 16 bits) se revelam aqui, quando instruções aparentemente corretas como mov ds, 0x1234 não são realmente possíveis: só porque podemos armazenar um endereço literal diretamente em um registrador de propósito geral (por exemplo, mov ax, 0x1234 ou mov cx, 0xdf), não significa que podemos fazer o mesmo com todos os tipos de registradores, como registradores de segmento; e assim, como na Figura 3.7, devemos dar um passo adicional para transferir o valor via um registrador de propósito geral.
Portanto, o endereçamento baseado em segmento nos permite alcançar mais profundamente na memória, até um pouco mais de 1 MB (0xffff * 16 + 0xffff). Mais tarde, veremos como mais memória pode ser acessada quando mudamos para o modo protegido de 32 bits, mas por enquanto é suficiente para nós entendermos o endereçamento baseado em segmento no modo real de 16 bits.
Tópico 3.6.2
Como Funcionam os Discos Rígidos
Mecanicamente, os discos rígidos contêm um ou mais discos empilhados que giram sob uma cabeça de leitura/escrita, semelhante a um antigo toca-discos, apenas potencialmente, para aumentar a capacidade, com vários discos empilhados um sobre o outro, onde uma cabeça se move para dentro e para fora para cobrir toda a superfície giratória de um disco específico; e como um disco específico pode ser legível e gravável em ambas as suas superfícies, uma cabeça de leitura/escrita pode flutuar acima e outra abaixo dela. A Figura 3.8 mostra o interior de um disco rígido típico, com a pilha de discos e cabeças expostas. Observe que a mesma ideia se aplica aos drives de disquete, que, em vez de vários discos rígidos empilhados, geralmente têm um único meio de disquete de dois lados.
O revestimento metálico dos discos lhes confere a propriedade de que áreas específicas de sua superfície podem ser magnetizadas ou desmagnetizadas pela cabeça, permitindo efetivamente que qualquer estado seja gravado permanentemente neles. Portanto, é importante ser capaz de descrever o local exato na superfície do disco onde algum estado deve ser lido ou gravado, e é por isso que é utilizado o endereçamento Cylinder-Head-Sector (CHS), que efetivamente é um sistema de coordenadas 3D (veja a Figura 3.9):
Cilindro: o cilindro descreve a distância discreta da cabeça a partir da borda externa do disco e recebe esse nome porque, quando vários discos estão empilhados, pode-se visualizar que todas as cabeças selecionam um cilindro através de todos os discos.
Cabeça: a cabeça descreve qual trilha (ou seja, qual superfície específica do disco dentro do cilindro) estamos interessados.
Setor: a trilha circular é dividida em setores, geralmente com capacidade de 512 bytes, que podem ser referenciados com um índice de setor.
Tópico 3.6.3
Usando a BIOS para Ler o Disco
Como veremos um pouco mais tarde, dispositivos específicos requerem rotinas específicas a serem escritas para utilizá-los. Por exemplo, um dispositivo de disquete exige que ativemos e desativemos explicitamente o motor que gira o disco sob a cabeça de leitura e gravação antes de podermos usá-lo, enquanto a maioria dos dispositivos de disco rígido tem mais funcionalidades automatizadas em chips locais. No entanto, as tecnologias de barramento com as quais esses dispositivos se conectam à CPU (por exemplo, ATA/IDE, SATA, SCSI, USB, etc.) também afetam como os acessamos. Felizmente, a BIOS pode oferecer algumas rotinas de disco que abstraem todas essas diferenças para dispositivos de disco comuns.
A rotina específica da BIOS que estamos interessados em acessar é feita levantando a interrupção 0x13 após configurar o registro "al" para 0x02. Essa rotina da BIOS espera que configuremos alguns outros registros com detalhes sobre qual dispositivo de disco usar, quais blocos desejamos ler do disco e onde armazenar os blocos na memória. A parte mais difícil de usar essa rotina é que precisamos especificar o primeiro bloco a ser lido usando um esquema de endereçamento Cylinder-Head-Sector (CHS); caso contrário, é apenas uma questão de preencher os registros esperados, como detalhado no trecho de código a seguir.
Observe que, por algum motivo (por exemplo, indexamos um setor além do limite do disco, foi feita uma tentativa de ler um setor com defeito, o disco flexível não foi inserido na unidade, etc.), a BIOS pode falhar ao ler o disco para nós. Portanto, é importante saber como detectar isso; caso contrário, podemos pensar que lemos alguns dados, mas na verdade, o endereço de destino contém os mesmos bytes aleatórios que continha antes de emitirmos o comando de leitura. Felizmente, a BIOS atualiza alguns registros para nos informar o que aconteceu: o sinalizador de transporte (CF) do registro de sinalizadores especiais é definido para sinalizar uma falha geral, e "al" é definido como o número de setores realmente lidos, em oposição ao número solicitado. Após emitir a interrupção para a leitura de disco pela BIOS, podemos realizar uma verificação simples da seguinte maneira.
Tópico 3.6.4
Colocando Tudo Junto
Como explicado anteriormente, a capacidade de ler mais dados do disco será essencial para inicializar nosso sistema operacional. Portanto, aqui iremos consolidar todas as ideias desta seção em uma rotina útil que simplesmente lerá os primeiros n setores após o setor de inicialização de um dispositivo de disco especificado.
Capítulo 4
Entrando no Modo Protegido de 32 bits
Seria bom continuar trabalhando no modo real de 16 bits com o qual nos tornamos mais familiares agora, mas para aproveitar melhor a CPU e compreender melhor como os desenvolvimentos nas arquiteturas de CPU podem beneficiar os sistemas operacionais modernos, especialmente a proteção de memória em hardware, precisamos avançar para o modo protegido de 32 bits.
As principais diferenças no modo protegido de 32 bits são:
Os registradores são estendidos para 32 bits, com sua capacidade total sendo acessada adicionando um "e" ao nome do registrador, por exemplo: mov ebx, 0x274fe8fe.
Por conveniência, existem dois registradores de segmento de propósito geral adicionais, fs e gs.
São disponibilizados deslocamentos de memória de 32 bits, permitindo que um deslocamento referencie uma enorme quantidade de memória (0xffffffff).
A CPU suporta um meio de segmentação de memória mais sofisticado, embora ligeiramente mais complexo, que oferece duas grandes vantagens:
O código em um segmento pode ser proibido de executar código em um segmento mais privilegiado, permitindo proteger o código do kernel de aplicativos de usuário.
A CPU pode implementar memória virtual para processos de usuário, de modo que páginas (ou seja, pedaços de tamanho fixo) da memória de um processo possam ser trocadas de forma transparente entre o disco e a memória conforme necessário. Isso garante que a memória principal seja usada eficientemente, de modo que o código ou dados raramente executados não ocupem valiosa memória.
O tratamento de interrupções também é mais sofisticado.
A parte mais difícil ao alternar a CPU do modo real de 16 bits para o modo protegido de 32 bits é que precisamos preparar uma estrutura de dados complexa na memória chamada Tabela de Descritores Globais (GDT), que define segmentos de memória e seus atributos no modo protegido. Uma vez que tenhamos definido a GDT, podemos usar uma instrução especial para carregá-la na CPU, antes de definir um único bit em um registro de controle especial da CPU para realizar a troca real.
Esse processo seria fácil o suficiente se não fosse necessário definir a GDT em linguagem de montagem, mas infelizmente essa mudança de baixo nível é inevitável se desejarmos posteriormente carregar um kernel compilado a partir de uma linguagem de nível mais alto, como C, que geralmente será compilado para instruções de 32 bits em vez das instruções menos eficientes de 16 bits.
Ah, há uma coisa surpreendente que eu quase esqueci de mencionar: não podemos mais usar a BIOS depois de mudarmos para o modo protegido de 32 bits. Se você achava que fazer chamadas à BIOS era de baixo nível, isso é como dar um passo para trás para dar dois passos à frente.
Tópico 4.1
Adaptando-se à Vida Sem a BIOS
É verdade: em nossa busca por aproveitar ao máximo a CPU, precisamos abandonar todas aquelas rotinas úteis fornecidas pela BIOS. Como veremos ao analisar mais detalhadamente a transição para o modo protegido de 32 bits, as rotinas da BIOS, codificadas para funcionar apenas no modo real de 16 bits, não são mais válidas no modo protegido de 32 bits; na verdade, tentar usá-las provavelmente travaria a máquina.
Então, o que isso significa é que um sistema operacional de 32 bits deve fornecer seus próprios drivers para todo o hardware da máquina (por exemplo, o teclado, a tela, as unidades de disco, o mouse, etc.). Na verdade, é possível para um sistema operacional de 32 bits em modo protegido voltar temporariamente ao modo de 16 bits, onde pode utilizar a BIOS, mas essa técnica pode ser mais problemática do que vale a pena, especialmente em termos de desempenho.
O primeiro problema que encontraremos ao mudar para o modo protegido é saber como imprimir uma mensagem na tela para que possamos ver o que está acontecendo. Anteriormente, pedíamos à BIOS para imprimir um caractere ASCII na tela, mas como isso resultava nos pixels apropriados sendo destacados na posição apropriada da tela do nosso computador? Por enquanto, basta saber que o dispositivo de exibição pode ser configurado em uma de várias resoluções em um de dois modos, modo de texto e modo gráfico; e o que é exibido na tela é uma representação visual de uma faixa específica de memória. Portanto, para manipular a tela, precisamos manipular a faixa específica de memória que ela está usando em seu modo atual. O dispositivo de exibição é um exemplo de hardware mapeado por memória porque funciona dessa maneira.
Quando a maioria dos computadores é inicializada, mesmo que possam ter hardware gráfico mais avançado, eles começam em um modo de texto simples de matriz de cores VGA (Video Graphics Array) com dimensões de 80x25 caracteres. No modo de texto, o programador não precisa renderizar pixels individuais para descrever caracteres específicos, pois uma fonte simples já está definida na memória interna do dispositivo de exibição VGA. Em vez disso, cada célula de caractere da tela é representada por dois bytes na memória: o primeiro byte é o código ASCII do caractere a ser exibido, e o segundo byte codifica os atributos do caractere, como a cor do primeiro plano e do plano de fundo e se o caractere deve piscar.
Portanto, se quisermos exibir um caractere na tela, precisamos definir seu código ASCII e atributos no endereço de memória correto para o modo VGA atual, que geralmente é o endereço 0xb8000. Se modificarmos um pouco nossa rotina original (modo real de 16 bits) de impressão de string para que não use mais a rotina da BIOS, podemos criar uma rotina de 32 bits em modo protegido que escreve diretamente na memória de vídeo, como na Figura 4.1.
Observe que, embora a tela seja exibida em colunas e linhas, a memória de vídeo é simplesmente sequencial. Por exemplo, o endereço da coluna 5 na linha 3 pode ser calculado da seguinte forma: 0xb8000 + 2 * (linha * 80 + coluna)
A desvantagem de nossa rotina é que ela sempre imprime a string no canto superior esquerdo da tela e, portanto, sobrescreverá mensagens anteriores em vez de rolar. Poderíamos gastar tempo adicionando sofisticação a esta rotina em assembly, mas não vamos tornar as coisas muito difíceis para nós mesmos, pois depois de dominarmos a transição para o modo protegido, em breve estaremos inicializando código escrito em uma linguagem de nível mais alto, onde podemos facilitar muito essas coisas.
Tópico 4.2
Compreendendo a Tabela Global de Descritores
É importante entender o ponto principal desta GDT, que é fundamental para o funcionamento do modo protegido, antes de entrarmos nos detalhes. Lembremos da Seção XXX que a lógica por trás do endereçamento baseado em segmentos no modo real clássico de 16 bits era permitir que o programador acessasse (embora ligeiramente, pelos padrões de hoje) mais memória do que um deslocamento de 16 bits permitiria. Como exemplo disso, suponha que o programador queira armazenar o valor de ax no endereço 0x4fe56. Sem o endereçamento baseado em segmentos, o melhor que o programador poderia fazer é isso:
mov [0xffff], ax
o que está muito aquém do endereço pretendido. Por outro lado, usando um registrador de segmento, a tarefa poderia ser realizada da seguinte maneira:
mov bx, 0x4000
mov es, bx
mov [es:0xfe56], ax
Embora a ideia geral de segmentar a memória e usar deslocamentos para acessar esses segmentos tenha permanecido a mesma, a maneira como isso é implementado no modo protegido mudou completamente, principalmente para proporcionar mais flexibilidade. Uma vez que a CPU foi alterada para o modo protegido de 32 bits, o processo pelo qual ela traduz endereços lógicos (ou seja, a combinação de um registrador de segmento e um deslocamento) para o endereço físico é completamente diferente: em vez de multiplicar o valor de um registrador de segmento por 16 e depois adicionar a ele o deslocamento, um registrador de segmento torna-se um índice para um descritor de segmento (SD) específico na GDT.
Um descritor de segmento é uma estrutura de 8 bytes que define as seguintes propriedades de um segmento em modo protegido:
Endereço base (32 bits), que define onde o segmento começa na memória física
Limite do segmento (20 bits), que define o tamanho do segmento
Várias flags, que afetam como a CPU interpreta o segmento, como o nível de privilégio do código que é executado nele ou se é somente leitura ou escrita.
A Figura 4.2 mostra a estrutura real do descritor de segmento. Observe como, apenas para aumentar a confusão, a estrutura fragmenta o endereço base e o limite do segmento ao longo da estrutura. Por exemplo, os 16 bits inferiores do limite do segmento estão nos dois primeiros bytes da estrutura, mas os 4 bits superiores estão no início do sétimo byte da estrutura. Talvez isso tenha sido feito como uma espécie de piada, ou mais provavelmente tem raízes históricas ou foi influenciado pelo design de hardware da CPU.
Não nos preocuparemos com detalhes de todas as configurações possíveis de descritores de segmento, uma explicação completa da qual é dada no Manual do Desenvolvedor da Intel, mas aprenderemos o necessário para fazer nosso código funcionar no modo protegido de 32 bits.
A configuração mais simples e funcional dos registradores de segmento é descrita pela Intel como o modelo básico e plano, onde dois segmentos sobrepostos são definidos que cobrem toda a memória acessível de 4 GB, um para código e outro para dados. O fato de esses dois segmentos se sobreporem significa que não há tentativa de proteger um segmento do outro, nem há tentativa de usar os recursos de paginação para memória virtual. É bom manter as coisas simples no início, especialmente porque mais tarde podemos alterar os descritores de segmento mais facilmente depois de inicializar em uma linguagem de nível mais alto.
Além dos segmentos de código e dados, a CPU exige que a primeira entrada na GDT seja intencionalmente um descritor nulo inválido (ou seja, uma estrutura de 8 bytes de zeros). O descritor nulo é um mecanismo simples para detectar erros nos quais esquecemos de definir um registrador de segmento específico antes de acessar um endereço, o que é facilmente feito se tivermos alguns registradores de segmento definidos como 0x0 e esquecermos de atualizá-los para os descritores de segmento apropriados após a troca para o modo protegido. Se uma tentativa de endereçamento for feita com o descritor nulo, a CPU gerará uma exceção, que essencialmente é uma interrupção - e que, embora não seja muito diferente como conceito, não deve ser confundida com exceções em linguagens de nível mais alto, como Java.
Nosso segmento de código terá a seguinte configuração:
Base: 0x0
Limite: 0xfffff
Presente: 1, pois o segmento está presente na memória - usado para memória virtual
Privilégio: 0, o anel 0 é o mais alto privilégio
Tipo de Descritor: 1 para segmento de código ou dados, 0 é usado para armadilhas
Tipo:
Código: 1 para código, já que este é um segmento de código
Conformidade: 0, ao não conformar, significa que o código em um segmento com um privilégio mais baixo não pode chamar o código neste segmento - isso é fundamental para a proteção de memória
Leitura: 1, 1 se legível, 0 se apenas executável. A leitura permite ler constantes definidas no código.
Acessado: 0, isso é frequentemente usado para depuração e técnicas de memória virtual, pois a CPU define o bit quando acessa o segmento
Outras flags
Granularidade: 1, se definido, isso multiplica nosso limite por 4 K (ou seja, 161616), então nosso 0xfffff se tornaria 0xfffff000 (ou seja, desloque 3 dígitos hexadecimais para a esquerda), permitindo que nosso segmento abranja 4 Gb de memória
Padrão de 32 bits: 1, já que nosso segmento conterá código de 32 bits, caso contrário, usaríamos 0 para código de 16 bits. Isso define realmente o tamanho padrão da unidade de dados para operações (por exemplo, push 0x4 se expandiria para um número de 32 bits, etc.)
Segmento de código de 64 bits: 0, não utilizado em processadores de 32 bits
AVL: 0, podemos definir isso para nossos próprios usos (por exemplo, depuração), mas não vamos usá-lo
Como estamos usando um modelo simples e plano, com dois segmentos sobrepostos de código e dados, o segmento de dados será idêntico, exceto pelas flags de tipo:
Código: 0 para dados
Expandir para baixo: 0. Isso permite que o segmento se expanda para baixo
Gravável: 1. Isso permite que o segmento de dados seja gravado, caso contrário, seria apenas leitura
Acessado: 0 Isso é frequentemente usado para depuração e técnicas de memória virtual, pois a CPU define o bit quando acessa o segmento
Agora que vimos uma configuração real de dois segmentos, explorando a maioria das configurações possíveis de descritores de segmento, deve ficar mais claro como o modo protegido oferece muito mais flexibilidade na partição de memória do que o modo real.
Tópico 4.3
Definindo a GDT em Assembly
Agora que entendemos quais descritores de segmento incluir em nossa GDT para o modelo básico e plano, vamos ver como podemos realmente representar a GDT em assembly, uma tarefa que requer mais paciência do que qualquer outra coisa. Enquanto você está experimentando a monotonia disso, tenha em mente a importância disso: o que fazemos aqui nos permitirá em breve inicializar nosso kernel de sistema operacional, que escreveremos em uma linguagem de nível mais alto, então --- para citar melhor --- nossos pequenos passos se tornarão grandes saltos.
Já vimos exemplos de como definir dados em nosso código assembly, usando as diretivas db, dw e dd, e é exatamente isso que devemos usar para colocar os bytes apropriados nas entradas de descritores de segmento de nossa GDT.
Na verdade, por uma razão simples de que a CPU precisa saber o quão grande é nossa GDT, na verdade não damos diretamente à CPU o endereço de início de nossa GDT, mas, em vez disso, damos o endereço de uma estrutura muito mais simples chamada descritor da GDT (ou seja, algo que descreve a GDT). O GDT é uma estrutura de 6 bytes contendo:
Tamanho da GDT (16 bits)
Endereço da GDT (32 bits)
Observe, ao trabalhar em uma linguagem de baixo nível com estruturas de dados complexas como essas, não podemos adicionar comentários suficientes. O código a seguir define nossa GDT e o descritor da GDT; no código, observe como usamos db, dw, etc. para preencher partes da estrutura e como as flags são definidas de forma mais conveniente usando números binários literais, que são seguidos por b.
Tópico 4.4
Fazendo a Mudança
Uma vez que a GDT e o descritor da GDT tenham sido preparados dentro do nosso setor de inicialização, estamos prontos para instruir a CPU a mudar do modo real de 16 bits para o modo protegido de 32 bits.
Como eu disse antes, a transição real é bastante simples de codificar, mas é importante entender o significado das etapas envolvidas.
A primeira coisa que precisamos fazer é desativar interrupções usando a instrução cli (clear interrupt), o que significa que a CPU simplesmente ignorará quaisquer interrupções futuras que possam ocorrer, pelo menos até que as interrupções sejam posteriormente ativadas. Isso é muito importante porque, assim como na abordagem de endereçamento baseado em segmento, o tratamento de interrupções é implementado de maneira completamente diferente no modo protegido em comparação com o modo real, tornando a atual IVT (Tabela de Vetores de Interrupção) configurada pelo BIOS no início da memória completamente sem sentido. Mesmo que a CPU pudesse mapear os sinais de interrupção para suas rotinas corretas do BIOS (por exemplo, quando o usuário pressiona uma tecla, armazene seu valor em um buffer), as rotinas do BIOS estariam executando código de 16 bits, que não terá conceito dos segmentos de 32 bits que definimos em nossa GDT e, portanto, acabará causando uma falha na CPU por ter valores de registros de segmento que assumem o esquema de segmentação de 16 bits do modo real.
O próximo passo é informar à CPU sobre a GDT que acabamos de preparar - com grande dificuldade. Usamos uma única instrução para fazer isso, à qual passamos o descritor da GDT:
lgdt [gdt_descriptor]
Agora que tudo está no lugar, fazemos a mudança real, definindo o primeiro bit de um registro de controle especial da CPU, o cr0. Agora, não podemos definir esse bit diretamente no registro, então devemos carregá-lo em um registro de propósito geral, definir o bit e, em seguida, armazená-lo de volta em cr0. Da mesma forma como usamos a instrução and na Seção XXX para excluir bits de um valor, podemos usar a instrução or para incluir certos bits em um valor (ou seja, sem perturbar quaisquer outros bits que, por alguma razão importante, já podem ter sido definidos no registro de controle).
Depois que cr0 foi atualizado, a CPU está no modo protegido de 32 bits.
Essa última afirmação não é totalmente verdadeira, pois os processadores modernos usam uma técnica chamada pipeline, que permite processar diferentes estágios da execução de uma instrução em paralelo (e estou falando de CPUs individuais em oposição a CPUs paralelas), e, portanto, em menos tempo. Por exemplo, cada instrução pode ser buscada na memória, decodificada em microinstruções, executada e, talvez, o resultado seja armazenado de volta na memória; e, como esses estágios são semi-independentes, todos podem ser feitos dentro do mesmo ciclo da CPU, mas em circuitos diferentes (por exemplo, a instrução anterior pode ser decodificada enquanto a próxima é buscada).
Normalmente, não precisamos nos preocupar com detalhes internos da CPU, como o pipeline, ao programar a CPU, mas a troca de modos da CPU é um caso especial, pois há um risco de a CPU processar alguns estágios da execução de uma instrução no modo errado. Portanto, o que precisamos fazer imediatamente após instruir a CPU a mudar de modo é forçar a CPU a concluir quaisquer trabalhos em seu pipeline, para que possamos ter a certeza de que todas as instruções futuras serão executadas no modo correto.
Agora, o pipeline funciona muito bem quando a CPU conhece as próximas instruções que virão no horizonte, pois pode pré-buscá-las, mas não gosta de instruções como jmp ou call, porque até que essas instruções tenham sido totalmente executadas, a CPU não tem ideia sobre as instruções que as seguirão, especialmente se usarmos um salto ou chamada longo, o que significa que pulamos para outro segmento. Portanto, imediatamente após instruir a CPU a mudar de modo, podemos emitir um salto longo, que forçará a CPU a descarregar o pipeline (ou seja, concluir todas as instruções atualmente em diferentes estágios do pipeline).
Para emitir um salto longo, em vez de um salto próximo (ou seja, padrão), fornecemos adicionalmente o segmento de destino, da seguinte forma:
jmp <segmento>:<offset_do_endereço>
Para este salto, precisamos pensar cuidadosamente onde desejamos pousar. Suponha que configuramos um rótulo em nosso código, como start_protected_mode, que marca o início de nosso código de 32 bits. Como acabamos de discutir, um salto próximo, como jmp start_protected_mode, pode não ser suficiente para descarregar o pipeline, e, além disso, agora estamos em algum tipo de limbo estranho, pois nosso segmento de código atual (ou seja, cs) não será válido no modo protegido. Portanto, devemos atualizar nosso registrador cs para o deslocamento do descritor de segmento de código de nossa GDT. Como os descritores de segmento têm cada um 8 bytes de comprimento e como nosso descritor de código foi o segundo descritor em nossa GDT (o descritor nulo foi o primeiro), seu deslocamento será 0x8, e é esse valor que devemos agora definir nosso registrador de segmento de código. Observe que, pela própria definição de um salto longo, ele automaticamente fará com que a CPU atualize nosso registrador cs para o segmento de destino. Fazendo uso hábil de rótulos, conseguimos que nosso montador calculasse esses deslocamentos de descritores de segmento e os armazenasse como as constantes CODE_SEG e DATA_SEG, então agora chegamos à nossa instrução de salto:
jmp CODE_SEG:start_protected_mode
[bits 32]
start_protected_mode:
... ; Agora, com certeza, estamos no modo protegido de 32 bits.
Observe que, na verdade, não precisamos pular muito longe em termos da distância física entre onde pulamos e onde pousamos, mas a importância estava em como pulamos.
Observe também que precisamos usar a diretiva [bits 32] para informar ao nosso montador que, a partir desse ponto, ele deve codificar instruções no modo de 32 bits. No entanto, isso não significa que não podemos usar instruções de 32 bits no modo real de 16 bits, apenas que o montador deve codificar essas instruções de maneira ligeiramente diferente do que no modo protegido de 32 bits. De fato, ao mudar para o modo protegido, usamos o registrador de 32 bits eax para definir o bit de controle.
Agora estamos no modo protegido de 32 bits. Uma boa prática uma vez que entramos no modo de 32 bits adequado é atualizar todos os outros registradores de segmento para que agora apontem para nosso segmento de dados de 32 bits (em vez dos segmentos de modo real agora inválidos) e atualizar a posição da pilha.
Podemos combinar todo o processo em uma rotina reutilizável, como na Figura XXX.
Tópico 4.5
Agregando Tudo
Finalmente, podemos incluir todas as nossas rotinas em um setor de inicialização que demonstre a transição do modo real de 16 bits para o modo protegido de 32 bits.
Capítulo 5
Escrevendo, Compilando e Carregando seu Kernel
Até agora, aprendemos muito sobre como o computador realmente funciona, comunicando-nos com ele na linguagem de montagem de baixo nível. No entanto, também vimos como pode ser lento progredir em uma linguagem tão específica: precisamos pensar muito cuidadosamente até mesmo sobre as estruturas de controle mais simples (por exemplo, if (<algo>) <faça isso> else <faça aquilo>), e precisamos nos preocupar com a melhor forma de usar o número limitado de registradores e equilibrar a pilha. Uma outra desvantagem de continuarmos na linguagem de montagem é que ela está intimamente ligada à arquitetura específica da CPU, tornando mais difícil portar nosso sistema operacional para outra arquitetura de CPU (por exemplo, ARM, RISC, PowerPC).
Felizmente, outros programadores também ficaram cansados de escrever em linguagem de montagem, então decidiram criar compiladores de linguagens de nível mais alto (por exemplo, FORTRAN, C, Pascal, BASIC, etc.) que transformariam código mais intuitivo em linguagem de montagem. A ideia desses compiladores é mapear construções de nível mais alto, como estruturas de controle e chamadas de função, para um código de montagem de modelo, e assim a desvantagem — e geralmente há sempre uma desvantagem — é que os modelos genéricos nem sempre são ótimos para funcionalidades específicas. Sem mais delongas, vamos ver como o código C é transformado em linguagem de montagem para desmistificar o papel do compilador.
Tópico 5.1
Compreendendo a Compilação em C
Vamos escrever alguns pequenos trechos de código em C e ver que tipo de código de montagem eles geram. Esta é uma ótima maneira de aprender sobre como o C funciona.
Tópico 5.1.1
Gerando Código de Máquina Puro
Salve o código da Figura XXXX em um arquivo chamado basic.c e compile-o da seguinte maneira:
$ gcc -ffreestanding -c basic.c -o basic.o
Isso produzirá um arquivo de objeto, que, sendo completamente não relacionado, não deve ser confundido com o conceito de programação orientada a objetos. Em vez de compilar diretamente para código de máquina, o compilador gera código de máquina anotado, onde informações meta, como rótulos textuais, que são redundantes para a execução, permanecem presentes para possibilitar mais flexibilidade na forma como o código é eventualmente unido. Uma grande vantagem desse formato intermediário é que o código pode ser facilmente relocado para um arquivo binário maior ao ser vinculado a rotinas de outras bibliotecas, já que o código no arquivo de objeto usa referências de memória interna relativas em vez de absolutas. Podemos ter uma boa visão do conteúdo de um arquivo de objeto com o seguinte comando:
$ objdump -d basic.o
A saída desse comando fornecerá algo semelhante ao apresentado na Figura XXX. Observe que podemos ver algumas instruções de montagem e alguns detalhes adicionais sobre o código. A sintaxe da montagem é um pouco diferente da usada pelo NASM, então ignore isso, já que logo a veremos em um formato mais familiar.
Para criar o código executável real (ou seja, que será executado em nossa CPU), precisamos usar um linker, cujo papel é vincular todas as rotinas descritas nos arquivos de objeto de entrada em um único arquivo binário executável, efetivamente costurando-os juntos e convertendo esses endereços relativos em endereços absolutos dentro do código de máquina agregado, por exemplo: call <function X label> se tornará call 0x12345, onde 0x12345 é o deslocamento no arquivo de saída onde o linker decidiu colocar o código para a rotina denotada por function X label.
No nosso caso, no entanto, não queremos vincular a nenhuma rotina de nenhum outro arquivo de objeto - vamos analisar isso em breve - mas mesmo assim o linker converterá nosso arquivo de código de máquina anotado em um arquivo de código de máquina puro. Para gerar código de máquina puro em um arquivo chamado basic.bin, podemos usar o seguinte comando:
$ ld -o basic.bin -Ttext 0x0 --oformat binary basic.o
Observe que, assim como o compilador, o linker pode gerar arquivos executáveis em vários formatos, alguns dos quais podem reter metadados dos arquivos de objeto de entrada. Isso é útil para executáveis que são hospedados por um sistema operacional, como a maioria dos programas que escreveremos em uma plataforma como Linux ou Windows, já que os metadados podem ser retidos para descrever como essas aplicações devem ser carregadas na memória. Para fins de depuração, por exemplo: a informação de que um processo travou no endereço de instrução 0x12345678 é muito menos útil para um programador do que informações apresentadas usando metadados redundantes e não executáveis, indicando que um processo travou na função my function, arquivo basic.c, na linha 3.
De qualquer forma, como estamos interessados em escrever um sistema operacional, não adiantaria tentar executar código de máquina intercalado com metadados em nossa CPU, pois sem saber, a CPU executará cada byte como código de máquina. É por isso que especificamos um formato de saída (binário) cru.
A outra opção que usamos foi -Ttext 0x0, que funciona da mesma forma que a diretiva org que usamos em nossas rotinas de montagem anteriores, permitindo-nos dizer ao compilador para ajustar os endereços de rótulos em nosso código (por exemplo, para qualquer dado que especificamos no código, como strings como 'Hello, World') para seus endereços de memória absolutos quando carregados posteriormente em uma origem específica na memória. Por enquanto, isso não é importante, mas quando chegarmos a carregar o código do kernel na memória, é importante que configuremos isso para o endereço ao qual planejamos carregar.
Agora compilamos com sucesso o código C em um arquivo de código de máquina puro, que poderíamos (assim que descobrirmos como carregá-lo) executar em nossa CPU, então vamos ver como ele é. Felizmente, como a montagem mapeia muito de perto as instruções de código de máquina, se você receber um arquivo contendo apenas código de máquina, pode facilmente desmontá-lo para visualizá-lo em montagem. Ah, sim; este é outro benefício de entender um pouco de montagem, porque você pode potencialmente engenharia reversa em qualquer software que caia no seu colo sem o código-fonte original, ainda mais com sucesso se o desenvolvedor deixou alguns metadados para você - o que eles quase sempre fazem. O único problema ao desmontar código de máquina é que alguns desses bytes podem ter sido reservados como dados, mas aparecerão como instruções de montagem. No entanto, em nosso programa C simples, não declaramos nenhum dado. Para ver qual código de máquina o compilador realmente gerou a partir do nosso código-fonte C, execute o seguinte comando:
$ ndisasm -b 32 basic.bin > basic.dis
A opção -b 32 simplesmente diz ao desmontador para decodificar as instruções de montagem para instruções de 32 bits, que é o que nosso compilador gera. A Figura XXX mostra o código de montagem gerado pelo gcc para nosso simples programa C.
Aqui está: o gcc gerou algum código de montagem não muito diferente do que temos escrito até agora. As três colunas de saída do desmontador, da esquerda para a direita, mostram os deslocamentos de arquivo das instruções, o código de máquina e as instruções de montagem equivalentes. Embora nossa função faça algo muito simples, há algum código adicional lá que parece estar manipulando os registros base e topo da pilha, ebp e esp. C faz uso intensivo da pilha para armazenar variáveis locais a uma função (ou seja, variáveis que não são mais necessárias quando a função retorna), então, ao entrar em uma função, o ponteiro base da pilha (ebp) é aumentado até o topo atual da pilha (mov ebp, esp), criando efetivamente uma pilha local, inicialmente vazia, acima da pilha da função que chamou nossa função. Esse processo é frequentemente referido como a função configurando seu quadro de pilha, no qual alocará quaisquer variáveis locais.
No entanto, se, antes de retornar de nossa função, falharmos em restaurar o quadro de pilha para aquele originalmente configurado pelo chamador, a função chamadora ficaria em uma confusão real ao tentar acessar suas variáveis locais; então, antes de atualizar o ponteiro base para nosso quadro de pilha, precisamos armazená-lo, e não há lugar melhor para armazená-lo do que no topo da pilha (push ebp).
Após preparar nosso quadro de pilha, que, infelizmente, na verdade não é usado em nossa função simples, vemos como o compilador manipula a linha return 0xbaba;: o valor 0xbaba é armazenado no registro de 32 bits eax, onde a função chamadora (se houver uma) esperaria encontrar o valor retornado, da mesma forma que tínhamos nossa própria convenção de usar certos registros para passar argumentos para nossas rotinas de montagem anteriores, por exemplo: nossa rotina de impressão de string esperava encontrar o endereço da string a ser impressa no registro bx.
Finalmente, antes de emitir ret para retornar ao chamador, a função retira o ponteiro base original da pilha (pop ebp), para que a função chamadora não perceba que seu próprio quadro de pilha foi alterado pela função chamada. Note que realmente não mudamos o topo da pilha (esp), pois neste caso nosso quadro de pilha foi usado para armazenar nada, então o registro esp intocado não precisava ser restaurado.
Agora temos uma boa ideia de como o código C se traduz em montagem, então vamos provocar o compilador um pouco mais até termos entendimento suficiente para escrever um kernel simples em C.
Tópico 5.1.2
Variáveis Locais
Agora, escreva o código da Figura XXX em um arquivo chamado local_var.c e compile, vincule e desmonte conforme antes.
Agora, o compilador gera o código de montagem na Figura XXX.
A única diferença agora é que realmente alocamos uma variável local, my_var, mas isso provoca uma resposta interessante do compilador. Como antes, o quadro de pilha é estabelecido, mas depois vemos sub esp, byte +0x10, que está subtraindo 16 (0x10) bytes do topo da pilha. Em primeiro lugar, precisamos (constantemente) nos lembrar de que a pilha cresce para baixo em termos de endereços de memória, então, em termos mais simples, esta instrução significa 'alocar mais 16 bytes no topo da pilha'. Estamos armazenando um int, que é um tipo de dado de 4 bytes (32 bits), então por que foram alocados 16 bytes na pilha para essa variável, e por que não usar push, que aloca novo espaço na pilha automaticamente? A razão pela qual o compilador manipula a pilha dessa maneira é uma otimização, já que as CPUs frequentemente operam de maneira menos eficiente em um tipo de dado que não está alinhado nas fronteiras de memória que são múltiplos do tamanho do tipo de dado. Como o C deseja que todas as variáveis sejam devidamente alinhadas, ele usa a largura máxima do tipo de dado (ou seja, 16 bytes) para todos os elementos da pilha, ao custo de desperdiçar alguma memória.
A próxima instrução, mov dword [ebp-0x4], 0xbaba, armazena efetivamente o valor da nossa variável no espaço recém-alocado na pilha, mas sem usar push, pela razão anteriormente fornecida de eficiência de pilha (ou seja, o tamanho do tipo de dado armazenado é menor que o espaço na pilha reservado). Entendemos o uso geral da instrução mov, mas duas coisas que merecem alguma explicação aqui são o uso de dword e [ebp-0x4]:
dword declara explicitamente que estamos armazenando uma palavra dupla (ou seja, 4 bytes) na pilha, que é o tamanho do nosso tipo de dado int. Portanto, os bytes reais armazenados seriam 0x0000baba, mas sem ser explícito, poderiam facilmente ser 0xbaba (ou seja, 2 bytes) ou 0x000000000000baba (ou seja, 8 bytes), que, embora tenham o mesmo valor, têm intervalos diferentes.
[ebp-0x4] é um exemplo de uma atalho moderno de CPU chamado computação de endereço efetivo, o que é mais impressionante do que o código de montagem parece refletir. Essa parte da instrução referencia um endereço que é calculado dinamicamente pela CPU, com base no endereço atual do registrador ebp. À primeira vista, poderíamos pensar que nosso montador está manipulando um valor constante, como faria se escrevêssemos algo assim mov ax, 0x5000 + 0x20, onde nosso montador simplesmente pré-processaria isso para mov ax, 0x5020. Mas somente quando o código é executado o valor de qualquer registrador seria conhecido, então isso definitivamente não é pré-processamento; isso faz parte da instrução da CPU. Com essa forma de endereçamento, a CPU nos permite fazer mais por ciclo de instrução e é um bom exemplo de como o hardware da CPU se adaptou para atender melhor aos programadores. Poderíamos escrever o equivalente, sem tal manipulação de endereço, menos eficientemente, nas três linhas de código a seguir:
mov eax, ebp ; EAX = EBP
sub eax, 0x4 ; EAX = EAX - 0x4
mov [eax], 0xbaba ; armazena 0xbaba no endereço EAX
Portanto, o valor 0xbaba é armazenado diretamente na posição apropriada da pilha, de modo que ocupará os primeiros 4 bytes acima (embora fisicamente abaixo, já que a pilha cresce para baixo) do ponteiro base.
Agora, sendo um programa de computador, nosso compilador pode distinguir números diferentes tão facilmente quanto podemos distinguir nomes de variáveis diferentes, então o que pensamos como a variável my_var, o compilador pensará como o endereço ebp-0x4 (ou seja, os primeiros 4 bytes da pilha). Vemos isso na próxima instrução, mov eax, [ebp-0x4], que basicamente significa 'armazenar o conteúdo de my_var em eax', novamente usando eficientemente a computação de endereço; e sabemos da função anterior que eax é usado para retornar uma variável ao chamador de nossa função.
Agora, antes da instrução ret, vemos algo novo: a instrução leave. Na verdade, a instrução leave é uma alternativa às etapas seguintes, que restauram a pilha original do chamador, recíproca às duas primeiras instruções da função:
Portanto, o valor 0xbaba é armazenado diretamente na posição apropriada da pilha, de modo que ocupará os primeiros 4 bytes acima (embora fisicamente abaixo, já que a pilha cresce para baixo) do ponteiro base.
Agora, sendo um programa de computador, nosso compilador pode distinguir números diferentes tão facilmente quanto podemos distinguir nomes de variáveis diferentes, então o que pensamos como a variável my_var, o compilador pensará como o endereço ebp-0x4 (ou seja, os primeiros 4 bytes da pilha). Vemos isso na próxima instrução, mov eax, [ebp-0x4], que basicamente significa 'armazenar o conteúdo de my_var em eax', novamente usando eficientemente a computação de endereço; e sabemos da função anterior que eax é usado para retornar uma variável ao chamador de nossa função.
Agora, antes da instrução ret, vemos algo novo: a instrução leave. Na verdade, a instrução leave é uma alternativa às etapas seguintes, que restauram a pilha original do chamador, recíproca às duas primeiras instruções da função:
mov esp, ebp ; Coloca a pilha de volta como a encontramos.
pop ebp
Embora seja apenas uma única instrução, nem sempre é o caso de que leave seja mais eficiente do que as instruções separadas. Como nosso compilador optou por usar esta instrução, deixaremos essa discussão específica para outras pessoas.
Tópico 5.1.3
Chamando Funções
Agora, vamos analisar o código em C na Figura XXX, que possui duas funções, onde uma função, a função chamadora, chama a outra, a função chamada, passando um argumento inteiro.
A função chamada simplesmente retorna o argumento que lhe foi passado.
Se compilarmos e desmontarmos o código C, obteremos algo semelhante ao que está na Figura XXX.
Em primeiro lugar, observe como podemos diferenciar entre o código de montagem das duas funções, procurando pela instrução ret, que sempre aparece como a última instrução de uma função. Em seguida, observe como a função superior usa a instrução de montagem call, que sabemos ser usada para pular para outra rotina da qual normalmente esperamos retornar. Isso deve ser nossa função chamadora, que está chamando a função chamada no deslocamento 0x14 do código de máquina. As linhas mais interessantes aqui são aquelas imediatamente antes da chamada, pois estão de alguma forma garantindo que o argumento my_arg seja passado para a função chamada. Após estabelecer seu quadro de pilha, como vimos ser comum a todas as funções, a função chamadora aloca 8 bytes no topo da pilha (sub esp, byte +0x8), em seguida, armazena nosso valor passado, 0xdede, nesse espaço da pilha (mov dword [esp], 0xdede).
Agora, vamos ver como a função chamada acessa esse argumento. A partir do deslocamento 0x14, vemos que a função chamada estabelece seu quadro de pilha como de costume, mas depois observe o que ela armazena no registrador eax, um registrador que sabemos, a partir de nossa análise anterior, ser usado para armazenar o valor de retorno de uma função: ela armazena o conteúdo do endereço [ebp + 0x8]. Aqui, temos que nos lembrar novamente desse fato confuso de que a pilha cresce para baixo na memória, então em termos de uma pilha logicamente mais sensata que cresce para cima, ebp + 0x8 está 8 bytes abaixo da base da nossa pilha, então estamos realmente acessando o quadro de pilha da função que nos chamou para obter o valor do argumento. Isso é o que esperamos, é claro, porque a chamadora colocou esse valor no topo da sua pilha e, em seguida, colocamos nossa base de pilha no topo da pilha deles para estabelecer nosso quadro de pilha.
É muito útil conhecer a convenção de chamada usada por qualquer compilador de linguagem de alto nível ao interagir com seu código em assembly. Por exemplo, a convenção de chamada padrão do C é empurrar os argumentos para a pilha em ordem inversa, então o primeiro argumento está no topo da pilha. Misturar a ordem dos argumentos certamente causaria o funcionamento incorreto do programa e provavelmente causaria uma falha.
Tópico 5.1.4
Ponteiros, Endereços e Dados
Ao trabalhar em uma linguagem de alto nível, podemos facilmente esquecer o fato de que variáveis são simplesmente referências a endereços de memória alocados, onde foi reservado espaço suficiente para acomodar seu tipo de dado específico. Isso ocorre porque, na maioria dos casos em que lidamos com variáveis, estamos realmente interessados nos valores que elas contêm, em vez de onde residem na memória. Considere o seguinte trecho de código em C:
int a = 3;
int b = 4;
int total = a + b;
Agora que temos mais consciência de como o computador realmente executará essas instruções simples em C, podemos fazer uma suposição informada de que a instrução int a = 3; envolverá duas etapas principais: primeiro, pelo menos 4 bytes (32 bits) serão reservados, talvez na pilha, para armazenar o valor; em seguida, o valor 3 será armazenado no endereço reservado. O mesmo ocorrerá na segunda linha. E na linha int total = a + b;, mais espaço será reservado para a variável total, e nela será armazenada a soma dos conteúdos dos endereços apontados pelos rótulos a e b.
Agora, suponha que desejamos armazenar um valor em um endereço específico de memória; por exemplo, como fizemos em assembly, para escrever caracteres diretamente na memória de vídeo no endereço 0xb8000 quando o BIOS não estava mais disponível. Como faríamos isso em C, quando parece que qualquer valor que desejamos armazenar deve estar em um endereço determinado pelo compilador? De fato, algumas linguagens de alto nível não nos permitem acessar a memória dessa maneira, o que essencialmente quebraria a abstração mais simplificada da linguagem. Felizmente, C nos permite usar variáveis ponteiro, que são tipos de dados usados para armazenar endereços (em vez de valores), e que podemos desreferenciar para ler ou escrever dados para onde apontam.
Tecnicamente, todas as variáveis ponteiro são do mesmo tipo de dado (por exemplo, um endereço de memória de 32 bits), mas geralmente planejamos ler e gravar tipos de dados específicos nos endereços apontados por um ponteiro, então dizemos ao compilador que, por exemplo, este é um ponteiro para um char e aquele é um ponteiro para um int. Isso é realmente uma conveniência, para que não tenhamos sempre que dizer ao compilador quantos bytes ele deve ler e gravar do endereço mantido por um determinado ponteiro. A sintaxe para definir e usar ponteiros é mostrada na Figura XXX.
No código em C, frequentemente vemos variáveis do tipo char* usadas para strings. Vamos pensar sobre o motivo disso. Se quisermos armazenar um único int ou char, então sabemos que ambos são tipos de dados de tamanho fixo (ou seja, sabemos quantos bytes eles usarão), mas uma string é um array de tipos de dados (geralmente char), que pode ter qualquer comprimento. Portanto, como um único tipo de dado não pode conter uma string inteira, apenas um elemento dela, podemos usar um ponteiro para um char e definir seu valor para o endereço de memória do primeiro caractere da string. Isso é realmente o que fizemos em nossas rotinas de assembly, como print_string, onde alocamos uma string de caracteres (por exemplo, "Hello, World") em algum lugar dentro de nosso código, então, para imprimir uma string específica, passamos o endereço do primeiro caractere via o registro bx.
Vamos ver um exemplo do que o compilador faz quando configuramos uma variável de string. Na Figura XXX, definimos uma função simples que não faz nada além de alocar uma string para uma variável.
Como antes, podemos desmontar para obter algo semelhante ao que está na Figura XXX.
// Código de montagem gerado para a função da string
push ebp
mov ebp, esp
sub esp, 0x10
mov dword [ebp-0x4], 0xf
leave
ret
// Dados da string (assumidos pela análise)
dec eax
add byte [eax], cl
add byte [eax], dl
add byte [eax], dl
add byte [eax], 0x6f
add byte [eax], 0
Primeiramente, para nos orientarmos, procuramos pela instrução ret, que marca o final da função. Vemos que as duas primeiras instruções da função configuram o quadro de pilha, como de costume. A próxima instrução, que também já vimos antes, sub esp, byte +0x10, aloca 16 bytes na pilha para armazenar nossa variável local. Agora, a próxima instrução, mov dword [ebp-0x4], 0xf, deve ter uma forma familiar, pois armazena um valor em nossa variável; mas por que ela armazena o número 0xf — não pedimos para fazer isso, certo? Após armazenar esse valor suspeito, vemos que a função reverte cortesmente a pilha para o quadro da pilha do chamador (leave) e então retorna (ret). Mas espere, há cinco instruções a mais após o final da função! O que você acha que a instrução dec eax está fazendo? Talvez ela diminua o valor de eax em 1, mas por quê? E quanto ao restante das instruções?
Nesses momentos, precisamos fazer uma verificação de sanidade e lembrar que: o desmontador não pode distinguir entre código e dados; e em algum lugar desse código deve haver dados para a string que definimos. Agora, sabemos que nossa função consiste na primeira metade do código, pois essas instruções faziam sentido para nós, e elas terminaram com ret. Se agora assumirmos que o restante do código é, na verdade, nossos dados, então o valor suspeito, 0xf, que foi armazenado em nossa variável faz sentido, pois é o deslocamento do início do código para onde os dados começam: nossa variável ponteiro está sendo definida no endereço dos dados. Para tranquilizar nossos instintos, se procurarmos na tabela ASCII os valores dos caracteres de nossa string 'Hello', encontraríamos que são 0x48, 0x65, 0x6c, 0x6c e 0x6f. Agora está ficando claro, porque se olharmos para a coluna do meio da saída do desmontador, veremos que esses são os bytes de código de máquina para aquelas instruções estranhas que não pareciam fazer sentido; vemos também que o último byte é 0x0, que o C adiciona automaticamente ao final das strings, para que, assim como em nossa rotina de assembly print_string, durante o processamento, possamos determinar facilmente quando atingimos o final da string.
Tópico 5.2
Chega de teoria, vamos inicializar e executar o kernel mais simples escrito em C. Este passo utilizará tudo o que aprendemos até agora e abrirá caminho para um progresso mais rápido no desenvolvimento das funcionalidades do nosso sistema operacional.
Os passos envolvidos são os seguintes:
Escrever e compilar o código do kernel.
Escrever e montar o código do setor de inicialização.
Criar uma imagem de kernel que inclua não apenas nosso setor de inicialização, mas também nosso código de kernel compilado.
Carregar nosso código de kernel na memória.
Alternar para o modo protegido de 32 bits.
Iniciar a execução do nosso código de kernel.
Tópico 5.2.1
Escrevendo Nosso Kernel
Isso não levará muito tempo, pois, por enquanto, a principal função de nosso kernel é apenas nos informar que foi carregado e executado com sucesso. Podemos elaborar mais sobre o kernel posteriormente, então é importante inicialmente manter as coisas simples. Salve o código na Figura XXX em um arquivo chamado kernel.c.
Compile isso para um binário cru da seguinte forma:
$ gcc -ffreestanding -c kernel.c -o kernel.o
$ ld -o kernel.bin -Ttext 0x1000 kernel.o --oformat binary
Observe que, agora, informamos ao linker que a origem de nosso código, uma vez carregado na memória, será 0x1000. Dessa forma, ele saberá compensar as referências de endereços locais a partir dessa origem, assim como usamos [org 0x7c00] em nosso setor de inicialização, porque é onde o BIOS o carrega e começa a executá-lo.
Tópico 5.2.2
Criando um Setor de Inicialização para Inicializar Nosso Kernel
Vamos escrever um setor de inicialização agora, que deve inicializar (ou seja, carregar e começar a executar) nosso kernel a partir do disco. Como o kernel foi compilado como instruções de 32 bits, teremos que mudar para o modo protegido de 32 bits antes de executar o código do kernel. Sabemos que o BIOS carregará apenas nosso setor de inicialização (ou seja, os primeiros 512 bytes do disco) e não nosso kernel, quando o computador for iniciado. Contudo, na Seção XXX, vimos como podemos usar as rotinas de disco do BIOS para fazer com que nosso setor de inicialização carregue setores adicionais de um disco. Também temos uma vaga ideia de que, após mudarmos para o modo protegido, a falta do BIOS dificultará o uso do disco: teríamos que escrever nossos próprios drivers de disquete ou disco rígido!
Para simplificar o problema de qual disco e de quais setores carregar o código do kernel, o setor de inicialização e o kernel de um sistema operacional podem ser unidos em uma imagem do kernel. Essa imagem pode ser gravada nos setores iniciais do disco de inicialização, de modo que o código do setor de inicialização esteja sempre no início da imagem do kernel. Depois de compilar o setor de inicialização descrito nesta seção, podemos criar nossa imagem do kernel com o seguinte comando de concatenação de arquivos:
cat boot sect.bin kernel.bin > os-image
A Figura XXX mostra um setor de inicialização que inicializará nosso kernel a partir de um disco contendo nossa imagem do kernel, os-image.
Antes de executar este comando no Bochs, certifique-se de que o arquivo de configuração do Bochs esteja configurado para usar seu arquivo de imagem do kernel, conforme mostrado na Figura XXX.
Uma pergunta que você pode estar se fazendo é por que carregamos até 15 segmentos (ou seja, 512 * 15 bytes) do disco de inicialização, quando nossa imagem do kernel era muito menor que isso; na verdade, era menor que um setor, então carregar 1 setor seria suficiente. A razão é simplesmente que não há problema em ler esses setores adicionais do disco, mesmo que eles não tenham sido inicializados com dados, mas pode haver problemas ao tentar detectar que não lemos setores suficientes nesta fase quando posteriormente adicionamos e, portanto, aumentamos o tamanho da pegada de memória do nosso código do kernel: o computador travaria sem aviso prévio, talvez no meio de uma rotina dividida entre um limite de setor não carregado - um bug desagradável.
Parabéns se um 'X' foi exibido no canto superior esquerdo da tela, pois, embora pareça sem sentido para o usuário médio do computador, isso significa um grande avanço desde o ponto de partida: agora inicializamos em uma linguagem de nível mais alto e podemos começar a nos preocupar menos com a codificação em assembly e nos concentrar mais em como gostaríamos de desenvolver nosso sistema operacional, e é claro, aprender um pouco mais sobre C; mas esta é a melhor maneira de aprender C: olhando para ele como uma linguagem de nível mais alto, em vez de olhar para baixo a partir da perspectiva de uma abstração ainda mais alta, como Java ou uma linguagem de script (por exemplo, Python, PHP, etc.).
Tópico 5.2.3
Encontrando o Caminho para o Kernel
Foi definitivamente uma boa ideia começar com um kernel muito simples, mas ao fazer isso, negligenciamos um problema potencial: ao inicializar o kernel, saltamos imprudentemente para a primeira instrução do código do kernel, e, portanto, começamos a execução a partir dela; no entanto, vimos na Seção XXX como o compilador C pode decidir colocar código e dados onde bem entender no arquivo de saída. Como nosso kernel simples tinha uma única função e, com base em nossas observações anteriores sobre como o compilador gera o código de máquina, poderíamos assumir que a primeira instrução de código de máquina é a primeira instrução da função de entrada do kernel, main. No entanto, suponha que nosso código do kernel pareça com o da Figura XXX.
Agora, o compilador provavelmente antecederá as instruções da função de entrada pretendida main com as de alguma função, e como nosso código de inicialização começará a execução cegamente a partir da primeira instrução, atingirá a primeira instrução ret de alguma função e retornará ao código do setor de inicialização sem nunca ter entrado em main. O problema é que entrar em nosso kernel no lugar correto depende muito da ordem dos elementos (por exemplo, funções) no código-fonte do nosso kernel e dos caprichos do compilador e do vinculador, então precisamos tornar isso mais robusto.
Um truque que muitos sistemas operacionais usam para entrar no kernel corretamente é escrever uma rotina de montagem muito simples que está sempre anexada ao início do código de máquina do kernel e cujo único propósito é chamar a função de entrada do kernel. A razão pela qual se usa a montagem é porque sabemos exatamente como ela será traduzida em código de máquina, e assim podemos garantir que a primeira instrução acabará resultando na função de entrada do kernel sendo alcançada.
Isso é um bom exemplo de como o vinculador funciona, pois ainda não exploramos completamente essa ferramenta importante. O vinculador aceita arquivos de objeto como entradas e os une, resolvendo quaisquer rótulos para seus endereços corretos. Por exemplo, se um arquivo de objeto tem um trecho de código que faz uma chamada para uma função, algumaFuncao, definida em outro arquivo de objeto, então, após o código do arquivo de objeto ter sido fisicamente vinculado em um único arquivo, o rótulo :code:'algumaFuncao' será resolvido para o deslocamento de onde quer que aquela rotina específica tenha acabado no código combinado.
A Figura XXX mostra uma rotina de montagem simples para entrar no kernel.
Você pode ver, a partir da linha call main, que o código simplesmente chama uma função chamada main. Mas main não existe como um rótulo dentro deste código, pois espera-se que exista em um dos outros arquivos de objeto, para que seja resolvido para o endereço correto durante o tempo de vinculação; essa expectativa é expressa pela diretiva [extern main] no topo do arquivo, e o vinculador falhará se não encontrar tal rótulo.
Anteriormente, compilamos a montagem em um formato binário bruto, porque queríamos executá-lo como código de setor de inicialização na CPU, mas para este trecho de código não pode ficar sozinho, sem ter esse rótulo resolvido, então devemos compilá-lo da seguinte forma, como um arquivo de objeto, preservando assim informações sobre os rótulos que ele deve resolver:
$nasm kernel entry.asm -f elf -o kernel entry.o
A opção -f elf diz ao montador para produzir um arquivo de objeto no formato Executable and Linking Format (ELF), que é o formato padrão produzido pelo nosso compilador C.
Agora, em vez de simplesmente vincular o arquivo kernel.o consigo mesmo para criar kernel.bin, podemos vinculá-lo com kernel entry.o, da seguinte forma:
$ld -o kernel.bin -Ttext 0x1000 kernel entry.o kernel.o --oformat binary
O vinculador respeita a ordem dos arquivos que fornecemos a ele na linha de comando, de modo que o comando anterior garantirá que kernel entry.o preceda o código em kernel.o.
Como antes, podemos reconstruir nosso arquivo de imagem do kernel com o seguinte comando:
cat boot sect.bin kernel.bin > os-image
Agora podemos testar isso no Bochs, mas com mais confiança de que nosso bloco de inicialização encontrará o caminho até o ponto de entrada correto do nosso kernel.
Tópico 5.3
Automatizando Builds com o Make
Neste ponto, você deve estar cansado de ter que digitar várias vezes comandos toda vez que você muda um trecho de código, para obter algum feedback sobre uma correção ou uma nova ideia que você tentou. Novamente, os programadores já estiveram aqui antes e desenvolveram uma variedade de ferramentas para automatizar o processo de construção de software. Aqui vamos considerar o make, que é o predecessor de muitas dessas outras ferramentas de construção e é usado para construir, entre outros sistemas operacionais e aplicativos, o Linux e o Minix.
O princípio básico do make é que especificamos em um arquivo de configuração (geralmente chamado Makefile) como converter um arquivo em outro, de modo que a geração de um arquivo possa ser descrita como dependente da existência de um ou mais outros arquivos. Por exemplo, poderíamos escrever a seguinte regra em um Makefile, que diria ao make exatamente como compilar um arquivo C em um arquivo de objeto:
kernel.o: kernel.c
gcc -ffreestanding -c kernel.c -o kernel.o
A beleza disso é que, no mesmo diretório do Makefile, agora podemos digitar:
$ make kernel.o
O que irá recompilar nosso arquivo fonte C apenas se kernel.o não existir ou tiver uma data de modificação de arquivo mais antiga que kernel.c. Mas é somente quando adicionamos várias regras interdependentes que vemos como o make pode realmente nos ajudar a economizar tempo e execuções de comandos desnecessárias.
Se executarmos make kernel.bin com o Makefile na Figura XXX, o make saberá que, antes de poder executar o comando para gerar kernel.bin, ele deve construir suas duas dependências, kernel.o e kernel entry.o, a partir de seus arquivos-fonte, kernel.c e kernel entry.asm, resultando na seguinte saída dos comandos que ele executou:
nasm kernel entry.asm -f elf -o kernel entry.o
gcc -ffreestanding -c kernel.c -o kernel.o
ld -o kernel.bin -Ttext 0x1000 kernel entry.o kernel.o --oformat binary
Então, se executarmos make novamente, veremos que o make relata que o alvo de construção kernel.bin está atualizado. No entanto, se modificarmos, por exemplo, kernel.c, salvá-lo e depois executar make kernel.bin, veremos que apenas os comandos necessários são executados pelo make, como segue:
gcc -ffreestanding -c kernel.c -o kernel.o
ld -o kernel.bin -Ttext 0x1000 kernel entry.o kernel.o --oformat binary
Para reduzir a repetição e, portanto, melhorar a facilidade de manutenção do nosso Makefile, podemos usar as variáveis especiais de makefile $<, $@ e $^, como na Figura XXX.
É frequentemente útil especificar alvos que não são alvos reais, no sentido de que não geram arquivos. Um uso comum desses alvos fictícios é criar um alvo clean, para que, ao executarmos make clean, todos os arquivos gerados sejam excluídos do diretório, deixando apenas os arquivos-fonte, como na Figura XXX.
clean:
rm *.bin *.o
Limpar seu diretório dessa maneira é útil se você quiser distribuir apenas os arquivos-fonte para um amigo, colocar o diretório sob controle de versão ou se quiser testar se modificações no seu Makefile criarão corretamente todos os alvos do zero.
Se o make for executado sem um alvo, o primeiro alvo no arquivo principal será considerado o padrão, então frequentemente vemos um alvo fictício como all no topo do Makefile, como na Figura XXX.
Observe que, ao dar kernel.bin como uma dependência para o alvo all, garantimos que kernel.bin e todas as suas dependências são construídas para o alvo padrão.
Agora podemos colocar todos os comandos para construir nosso kernel e a imagem de kernel carregável em um makefile útil (veja a Figura XXX), que nos permitirá testar alterações ou correções em nosso código no Bochs simplesmente digitando make run.
Tópico 5.3.1
Organizando a Base de Código do Nosso Sistema Operacional
Agora chegamos a um kernel C muito simples, que imprime um 'X' no canto da tela. O fato de o kernel ter sido compilado em instruções de 32 bits e ter sido executado com sucesso pela CPU significa que chegamos longe; mas agora é hora de nos prepararmos para o trabalho que está por vir. Precisamos estabelecer uma estrutura adequada para nosso código, acompanhada de um makefile que nos permita adicionar facilmente novos arquivos de origem com novos recursos ao nosso sistema operacional e verificar essas adições incrementalmente com um emulador como o Bochs.
Assim como kernels como o Linux e o Minix, podemos organizar nossa base de código nas seguintes pastas:
boot: qualquer coisa relacionada à inicialização e ao setor de inicialização pode ser colocada aqui, como boot sect.asm e nossas rotinas de montagem do setor de inicialização (por exemplo, print string.asm, gdt.asm, switch to pm.asm, etc.).
kernel: o arquivo principal do kernel, kernel.c, e outros códigos relacionados ao kernel que não são específicos de drivers de dispositivos irão aqui.
drivers: qualquer código de driver específico de hardware irá aqui.
Agora, dentro do nosso makefile, em vez de ter que especificar cada arquivo de objeto que gostaríamos de construir (por exemplo, kernel/kernel.o, drivers/screen.o, drivers/keyboard.o, etc.), podemos usar uma declaração especial de wildcard da seguinte forma:
# Expand automaticamente para uma lista de arquivos existentes que
# correspondem aos padrões
C_SOURCES = $(wildcard kernel/*.c drivers/*.c)
Em seguida, podemos converter os nomes dos arquivos de origem em nomes de arquivos de objeto usando outra declaração make, da seguinte forma:
# Crie uma lista de arquivos de objeto a serem construídos, simplesmente substituindo
# a extensão ’.c’ dos nomes de arquivos em C_SOURCES por ’.o’
OBJ = ${C_SOURCES:.c=.o}
Agora podemos vincular os arquivos de objeto do kernel juntos, para construir o binário do kernel, da seguinte forma:
# Vincule os arquivos de objeto do kernel em um binário, garantindo que o
# código de entrada esteja no início do binário
kernel.bin: kernel/kernel_entry.o ${OBJ}
ld -o $@ -Ttext 0x1000 $^ --oformat binary
Um recurso do make que funcionará de mãos dadas com nossa inclusão dinâmica de arquivos de objeto são as regras de padrões, que dizem ao make como construir um tipo de arquivo a partir de outro com base em um padrão simples de nomes de arquivos.
Ótimo, agora que entendemos o make o suficiente, podemos avançar para desenvolver nosso kernel sem ter que reescrever muitos comandos repetitivos várias vezes para verificar se algo está funcionando corretamente. A Figura XXX mostra um makefile completo que será adequado para progredir com nosso kernel.
Tópico 5.4
Fundamentos de C
C possui algumas peculiaridades que podem desconcertar um novo programador da linguagem.
Tópico 5.4.1
O Pré-processador e Diretivas
Antes que um arquivo em C seja compilado para um arquivo objeto, um pré-processador o examina em busca de diretivas e variáveis do pré-processador e, geralmente, as substitui por código, como macros e valores de constantes, ou simplesmente as remove. O pré-processador não é essencial para compilar código em C, mas serve para oferecer alguma conveniência que torna o código mais gerenciável.
#define PI 3.14159
float radius = 3.0;
float circumference = 2 * radius * PI;
O pré-processador produziria o seguinte código, pronto para a compilação:
float radius = 3.0;
float circumference = 2 * radius * 3.141592;
O pré-processador também é útil para gerar código condicional, não condicional no sentido de uma decisão tomada em tempo de execução, como com uma instrução if, mas sim no sentido de tempo de compilação. Por exemplo, considere o seguinte uso de diretivas do pré-processador para a inclusão ou exclusão de código de depuração:
#ifdef DEBUG
print("Alguma mensagem de depuração\n");
#endif
Agora, se a variável do pré-processador DEBUG estiver definida, esse código de depuração será incluído; caso contrário, não será. Uma variável pode ser definida na linha de comando ao compilar o arquivo em C da seguinte maneira:
$gcc -DDEBUG -c algum_arquivo.c -o algum_arquivo.o
Essas declarações de variáveis na linha de comando são frequentemente usadas para configuração em tempo de compilação de aplicativos e, especialmente, de sistemas operacionais, que podem incluir ou excluir seções inteiras de código, talvez para reduzir a pegada de memória do kernel em um pequeno dispositivo embarcado.
Tópico 5.4.2
Declarações de Funções e Arquivos de Cabeçalho
Quando o compilador encontra uma chamada para uma função, que pode ou não estar definida no
arquivo em processo de compilação, ele pode fazer suposições incorretas e gerar instruções de código de máquina incorretas se ainda não tiver encontrado uma descrição do tipo de retorno
e argumentos da função. Lembre-se da Seção XXX que o compilador deve preparar a pilha para
as variáveis que são passadas para uma função, mas se a pilha não estiver de acordo com o que a função espera,
a pilha pode se corromper. Por esse motivo, é importante que pelo menos uma
declaração da interface da função, se não a definição completa da função, seja fornecida antes
de ser utilizada. Essa declaração é conhecida como protótipo da função.
Agora, como algumas funções serão chamadas a partir de código compilado em outros arquivos de objeto,
elas também precisarão declarar protótipos idênticos para essas funções, o que levaria a
muitas declarações duplicadas de protótipos, o que é difícil de manter. Por esse motivo,
muitos programas em C usam a diretiva de pré-processador #include para inserir código comum que
contém os protótipos necessários antes da compilação. Esse código comum é conhecido como um
arquivo de cabeçalho, que podemos pensar como a interface para o arquivo de objeto compilado e que
é utilizado da seguinte forma.
Às vezes, um arquivo de cabeçalho pode incluir outro, então é importante não redefinir
o mesmo código...
• Conversão de tipos
Capítulo 6
Tópico 6.1
Entrada/Saída de Hardware
Ao escrever na tela, já nos deparamos com uma forma mais amigável de Entrada/Saída (I/O) de hardware, conhecida como I/O mapeado em memória, em que dados escritos diretamente em uma determinada faixa de endereços na memória principal são gravados no buffer de memória interna do dispositivo. No entanto, agora é hora de compreender mais sobre essa interação entre a CPU e o hardware.
Vamos usar o monitor TFT, agora popular, como exemplo. A superfície da tela é dividida em uma matriz de células retroiluminadas. Ao conter uma camada de cristais líquidos entre filmes polarizados, a quantidade de luz que passa por cada célula pode ser variada pela aplicação de um campo elétrico, pois os cristais líquidos têm a propriedade de que, quando submetidos a um campo elétrico, sua orientação pode ser alterada de maneira consistente. À medida que a orientação dos cristais muda, eles alteram a direção de vibração da onda de luz, de modo que parte da luz será bloqueada pelo filme polarizado na superfície da tela. Para uma exibição colorida, cada célula é dividida em três áreas sobrepostas com filtros para vermelho, azul e verde.
Portanto, cabe ao hardware garantir que as células apropriadas ou áreas de cor da subcélula sejam submetidas ao campo elétrico adequado para reconstruir a imagem desejada na tela. Este aspecto do hardware é melhor deixado para engenheiros eletrônicos especializados, mas haverá um chip controlador, idealmente com funcionalidade bem definida descrita na folha de dados do chip, no dispositivo ou placa-mãe com o qual a CPU pode interagir para direcionar o hardware. Na realidade, por questões de compatibilidade reversa, monitores TFT geralmente emulam monitores CRT mais antigos e, portanto, podem ser controlados pelo controlador VGA padrão da placa-mãe, que gera um sinal analógico complexo que direciona um feixe de elétrons para digitalizar a tela revestida de fósforo. Como não há realmente um feixe de CRT para direcionar, o monitor TFT interpreta inteligentemente esse sinal como uma imagem digital.
Internamente, os chips controladores geralmente têm vários registros que podem ser lidos, escritos ou ambos pela CPU, e é o estado desses registros que diz ao controlador o que fazer (por exemplo, quais pinos definir como alto ou baixo para acionar o hardware ou qual função interna executar). Como exemplo, a partir da folha de dados do controlador de disco flexível de um único chip amplamente utilizado da Intel, o 82077AA, vemos que há um pino (pino 57, rotulado como ME0) que aciona o motor do primeiro dispositivo de disco flexível (já que um único controlador pode controlar vários dispositivos desse tipo): quando o pino está ligado, o motor gira; quando desligado, o motor não gira. O estado desse pino em particular está diretamente vinculado a um bit específico do registro interno do controlador chamado Registro de Saída Digital (DOR). O estado desse registro pode então ser definido ajustando um valor, com o bit apropriado definido (bit 4, neste caso), pelos pinos de dados do chip, rotulados como DB0--DB7, e usando os pinos de seleção de registro do chip, A0--A2, para selecionar o registro DOR por seu endereço interno 0x2.
Tópico 6.1.1
I/O Buses
Although historically the CPU would talk directly to device controllers, with ever increasing CPU speeds, that would require the CPU artificially to slow down to the same
speed as the slowest device, so it is more practical for the CPU to issue I/O instructions
directly to the controller chip of a high-speed, top-level bus. The bus controller is then
responsible for relaying, at a compatible rate, the instructions to a particular device’s
controller. Then to avoid the top-level bus having to slow down for slower devices, the
controller of a another bus technology may be added as a device, such that we arrive at
the hierarchy of buses found in modern computers.
Tópico 6.1.1
Barramentos de E/S
Embora historicamente a CPU se comunicasse diretamente com os controladores de dispositivos, com o aumento constante da velocidade da CPU, isso exigiria que a CPU diminuísse artificialmente para a mesma velocidade do dispositivo mais lento. Portanto, é mais prático para a CPU emitir instruções de E/S diretamente para o chip controlador de um barramento de alta velocidade e alto nível. O controlador de barramento é então responsável por transmitir, a uma taxa compatível, as instruções para o controlador de um dispositivo específico. Para evitar que o barramento de alto nível tenha que diminuir a velocidade para dispositivos mais lentos, o controlador de outra tecnologia de barramento pode ser adicionado como um dispositivo, resultando na hierarquia de barramentos encontrada em computadores modernos.
Tópico 6.1.2
Então, a pergunta é: como podemos ler e escrever programaticamente nos registros dos controladores de dispositivos (ou seja, dizer aos nossos dispositivos o que fazer)? Em sistemas de arquitetura Intel, os registros dos controladores de dispositivos são mapeados em um espaço de endereçamento de E/S, separado do espaço de endereçamento principal da memória. Variantes das instruções de E/S in e out são então utilizadas para ler e escrever dados nos endereços de E/S mapeados para registros específicos do controlador. Por exemplo, o controlador de disco flexível descrito anteriormente geralmente tem seu registro DOR mapeado para o endereço de E/S 0x3F2, então poderíamos ligar o motor da primeira unidade com as seguintes instruções:
mov dx, 0x3f2 ; Deve-se usar DX para armazenar o endereço da porta
in al, dx ; Lê o conteúdo da porta (ou seja, DOR) para AL
or al, 00001000b ; Liga o bit do motor
out dx, al ; Atualiza o DOR do dispositivo.
Em sistemas mais antigos, como o barramento Industry Standard Architecture (ISA), os endereços da porta seriam atribuídos estaticamente aos dispositivos. No entanto, com barramentos modernos plug-and-play, como o Peripheral Component Interconnect (PCI), a BIOS pode alocar dinamicamente endereços de E/S para a maioria dos dispositivos antes de inicializar o sistema operacional. Essa alocação dinâmica exige que os dispositivos comuniquem informações de configuração sobre o barramento para descrever o hardware, tais como: quantas portas de E/S precisam ser reservadas para os registros; quanto espaço mapeado em memória é necessário; e um ID único do tipo de hardware, para permitir que drivers apropriados sejam encontrados posteriormente pelo sistema operacional.
Um problema com a E/S de porta é que não podemos expressar essas instruções de baixo nível na linguagem C, então precisamos aprender um pouco sobre assembly embutido: a maioria dos compiladores permite que você injete trechos de código assembly no corpo de uma função, com o gcc implementando isso da seguinte forma.
Observe que a instrução assembly real, em %%dx, %%al, parece um pouco estranha para nós, já que o gcc adota uma sintaxe de assembly diferente (conhecida como GAS), onde os operandos de destino e origem são invertidos em relação à sintaxe da nossa sintaxe mais familiar do nasm; também, % é usado para denotar registradores, e isso requer um %% feio, já que % é um caractere de escape do compilador C, e assim %% significa: escape o caractere de escape, para que ele apareça literalmente na string.
Como essas funções de E/S de porta de baixo nível serão usadas por grande parte dos drivers de hardware em nosso kernel, vamos agrupá-las no arquivo kernel/low level.c, que podemos definir conforme mostrado na Figura XXX.
Tópico 6.1.3
Acesso Direto à Memória
Como a E/S de porta envolve a leitura ou escrita de bytes ou palavras individuais, a transferência de grandes quantidades de dados entre um dispositivo de disco e a memória poderia potencialmente consumir uma grande quantidade do valioso tempo da CPU. Esse problema tornou necessário um meio para a CPU delegar essa tarefa tediosa a outra entidade: um controlador de acesso direto à memória (DMA) [?].
Uma boa analogia para o DMA é a de um arquiteto que deseja mover uma parede de um lugar para outro. O arquiteto sabe exatamente o que deve ser feito, mas tem outras questões importantes para considerar além de mover cada tijolo, então ele instrui um construtor a mover os tijolos, um por um, e a alertá-lo (ou seja, gerar uma interrupção) quando a parede estiver concluída ou se houver algum erro impedindo a conclusão da parede.
Tópico 6.2
Driver de Tela
Até agora, nosso kernel é capaz de imprimir um 'X' no canto da tela, o que, embora seja suficiente para nos informar que nosso kernel foi carregado e executado com sucesso, não nos diz muito sobre o que está acontecendo no computador.
Sabemos que podemos ter caracteres exibidos na tela escrevendo-os em algum lugar dentro do buffer de exibição no endereço 0xb8000. No entanto, não queremos ter que nos preocupar continuamente com esse tipo de manipulação de baixo nível em todo o nosso kernel. Seria muito melhor se pudéssemos criar uma abstração da tela que nos permitisse escrever print("Hello") e talvez clear_screen(), e se pudesse rolar quando imprimíssemos além da última linha de exibição, isso seria a cereja no bolo. Essa abstração não apenas facilitaria a exibição de informações em outros trechos de nosso kernel, mas também nos permitiria substituir facilmente um driver de exibição por outro em uma data posterior, talvez se um determinado computador não pudesse suportar o modo de texto colorido VGA que atualmente assumimos.
Tópico 6.2.1
Compreendendo o Dispositivo de Exibição
Comparado com alguns dos outros hardwares que veremos em breve, o dispositivo de exibição é relativamente simples, uma vez que, como um dispositivo mapeado em memória, podemos nos virar sem entender nada sobre mensagens de controle e E/S de hardware. No entanto, um dispositivo útil da tela que requer controle de E/S (ou seja, via portas de E/S) para manipulação é o cursor, que pisca para marcar a próxima posição em que um caractere será escrito na tela. Isso é útil para um usuário, pois pode chamar a atenção para um prompt para inserir algum texto, mas também o usaremos como um marcador interno, quer o cursor seja visível ou não. Isso significa que um programador não precisa sempre especificar as coordenadas de onde na tela uma string deve ser exibida, por exemplo: se escrevermos print("hello"), cada caractere será escrito.
Tópico 6.2.2
Implementação Básica do Driver de Tela
Embora pudéssemos escrever todo esse código em kernel.c, que contém a função de entrada do kernel, main(), é bom organizar código específico dessa funcionalidade em seu próprio arquivo, o que pode ser compilado e vinculado ao nosso código de kernel, ultimamente com o mesmo efeito que colocar tudo em um arquivo. Vamos criar um novo arquivo de implementação do driver, screen.c, e um arquivo de interface do driver, screen.h, em nossa pasta de drivers. Devido ao uso de inclusão de arquivos por wildcard em nosso makefile, screen.c (assim como qualquer outro arquivo C colocado nessa pasta) será compilado e vinculado automaticamente ao nosso kernel.
Primeiramente, definamos as seguintes constantes em screen.h para tornar nosso código mais legível.
Agora, vamos considerar como escreveríamos uma função, print char(...), que exibe um único caractere em uma coluna e linha específicas da tela. Usaremos essa função internamente (ou seja, de forma privada) dentro do nosso driver, de modo que as funções de interface pública do driver (ou seja, as funções que gostaríamos que o código externo usasse) se basearão nela. Agora sabemos que a memória de vídeo é simplesmente uma faixa específica de endereços de memória, onde cada célula de caractere é representada por dois bytes: o primeiro byte é o código ASCII do caractere, e o segundo byte é um byte de atributo que nos permite definir um esquema de cores para a célula de caractere. A Figura XXX mostra como poderíamos definir tal função, fazendo uso de algumas outras funções que definiremos: get cursor(), set cursor(), get screen offset(), e handle scrolling().
Vamos abordar primeiro a função mais fácil dessas: get screen offset. Esta função mapeará as coordenadas de linha e coluna para o deslocamento de memória de uma célula de caractere de exibição específica a partir do início da memória de vídeo. O mapeamento é direto, mas devemos lembrar que cada célula contém dois bytes. Por exemplo, se eu quiser definir um caractere na linha 3, coluna 4 da tela, então a célula de caractere correspondente estará em um deslocamento (decimal) de 488 ((3 * 80 (ou seja, a largura da linha) + 4) * 2 = 488) desde o início da memória de vídeo. Assim, nossa função get screen offset se parecerá com algo na Figura XXX.
Agora, vamos analisar as funções de controle do cursor, get cursor() e set cursor(), que manipularão os registros do controlador de exibição por meio de um conjunto de portas de E/S. Utilizando as portas de E/S específicas do dispositivo de vídeo para ler e gravar seus registros internos relacionados ao cursor, a implementação dessas funções será algo parecido com a Figura XXX.
Agora temos uma função que nos permitirá imprimir um caractere em uma localização específica da tela, e essa função encapsula todos os detalhes complicados específicos do hardware. Geralmente, não queremos imprimir cada caractere na tela, mas sim uma sequência inteira de caracteres. Portanto, vamos criar uma função mais amigável, print at(...), que recebe um ponteiro para o primeiro caractere de uma string (ou seja, um char *) e imprime cada caractere subsequente, um após o outro, nas coordenadas fornecidas. Se as coordenadas (-1, -1) forem passadas para a função, ela começará a imprimir a partir da posição atual do cursor. Nossa função print at(...) se parecerá com algo na Figura XXX.
E apenas por conveniência, para nos poupar de ter que digitar print at("hello", -1, -1), podemos definir uma função, print, que leva apenas um argumento como na Figura XXX.
Outra função útil, mas não muito difícil, é clear screen(...), que nos permitirá limpar nossa tela escrevendo caracteres em branco em cada posição. A Figura XXX mostra como poderíamos implementar tal função.
Tópico 6.2.3
Rolagem da Tela
Se você esperava que a tela rolasse automaticamente quando seu cursor alcançasse a parte inferior da tela, então sua mente deve ter retrocedido para o domínio de computação de nível mais alto. Isso pode ser perdoado, porque a rolagem da tela parece ser algo tão natural que simplesmente damos por garantido; mas trabalhando nesse nível, temos controle total sobre o hardware e, portanto, devemos implementar essa funcionalidade nós mesmos.
Para fazer com que a tela aparente rolar quando chegamos ao final, devemos mover cada célula de caractere para cima em uma linha e, em seguida, limpar a última linha, pronta para escrever a nova linha (ou seja, a linha que teria sido escrita além do final da tela). Isso significa que a primeira linha será sobrescrita pela segunda linha, e assim a primeira linha será perdida para sempre. No entanto, não nos preocuparemos com isso, pois nosso objetivo é permitir que o usuário veja o log mais recente da atividade em seu computador.
Uma maneira elegante de implementar a rolagem é chamar uma função, que definiremos como handle scrolling, imediatamente após incrementar a posição do cursor em nosso print char. O papel de handle scrolling, então, é garantir que, sempre que o deslocamento de memória de vídeo do cursor for incrementado além da última linha da tela, as linhas sejam roladas e o cursor seja reposicionado dentro da última linha visível (ou seja, a nova linha).
Mover uma linha equivale a copiar todos os seus bytes - dois bytes para cada uma das 80 células de caractere em uma linha - para o endereço da linha anterior. Esta é uma oportunidade perfeita para adicionar uma função de cópia de memória de propósito geral ao nosso sistema operacional. Como provavelmente usaremos tal função em outras áreas do nosso SO, vamos adicioná-la ao arquivo kernel/util.c. Nossa função de cópia de memória receberá os endereços da origem e do destino e a quantidade de bytes a serem copiados, e, com um loop, copiará cada byte conforme mostrado na Figura XXX.
Agora podemos usar a cópia de memória, como na Figura XXX, para rolar nossa tela.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment