Heisenberg OS: Começando o bootloader

Começando do começo (obvio!): Quando você liga o computador ele faz um montão de inicializações e lê um único setor do disco ou dispositivo que você escolheu para “dar o boot”. A BIOS (Basic Input and Output System) carrega esse setor no endereço 0x0000:0x7c00 (na notação de segmento:offset) e salta para ele… O que acontece depois disso é por sua própria conta e risco…

Só lembrando: A notação segmento:offset deve-se ao fato de que os processadores da família 8086 dividem a memória em blocos de 64 kB chamados segmentos e obtém o endereço inicial desse bloco deslocando esse valor em 4 bits para a esquerda (o que equivale a multiplicá-lo por 16). Com esse endereço inicial obtido, o offset é adicionado para obter o endereço efetivo ou linear da memória. Isso funciona assim no modo real, de 16 bits… No modo protegido, de 32 ou 64 bits, continuamos usando segmentos, mas de uma maneira diferente (que explicarei noutro texto).

Não me perguntem porque a indústria escolheu esse offset 0x7c00 do segmento zero. É tão misterioso para mim quando deve ser para você. Esse endereço localiza-se pouco antes dos 32 kB da região baixa da memória RAM, sendo que, a partir do endereço 0x0000:0x0600 a RAM (1,5 kB) já é “usável”. Eu suspeito que a BIOS mantenha a pilha em algum lugar dessa região ou, pelo menos, mantinha…

O motivo da porção segmento ser zero é que, dessa maneira, o seu código pode acessar diretamente qualquer variável da BIOS (que ficam dentro da faixa dos offsets 0x0400 até 0x04ff), os vetores de interrupção e até mesmo uma área de dados “inútil” (em 0x0000:0x0500 até 0x0000:0x05ff) sem ter que modificar os seletores de segmentos, mas isso é apenas outra suspeita minha, sem base histórica alguma… Acontece que todos os segmentos virtuais, num arquivo objeto, iniciam a contagem do offset a partir de zero, não de 0x7c00. Para nos adequarmos a isso poderíamos usar a diretiva ORG e mudar o endereço de origem. Alguns compiladores até aceitam isso. Outros, mudam o offset virtual, acrescentando 31744 bytes (0x7c00, em hexadecimal) no início da imagem binária… Para evitar esse problema, começo meu bootloader assim:

  jmp 0x07c0:boot_start
boot_start:
  push cs
  pop  ds

Parece ridículo saltar para a próxima instrução do programa, logo de cara, mas esse salto modifica o seletor CS automaticamente e boot_start é uma referência a partir do offset 0, portanto, isso ai funciona perfeitamente bem e todo o restante de nosso código lidará com as referências à memória corretamente! É bastante óbvio que 0x0000:0x7c00 é a mesma coisa que 0x07c0:0x0000, não é?

O push e o pop que seguem apenas copiam o seletor CS para DS, já que todas as referências à memória usam, por default, o seletor DS e, assim, precisamos dele com o mesmo valor de CS. Ao invés disso, poderíamos fazer algo assim:

boot_start:
  mov  ax,cs
  mov  ds,ax

Eis a diferença: Enquanto o primeiro método usa a pilha da BIOS (já que não ajustamos o seletor SS e o registrador SP, ainda) e, ao gravar e ler na memória — bem como manipular SP para isso — é mais lento do que o segundo método, a primeira maneira cria código menor:

0E       push cs
1F       pop  ds
-----%<----- corte aqui -----%<-----
8C C8    mov  ax,cs
8E D8    mov  ds,ax

Os valores hexadecimais à esquerda correspondem ao micro-código das instruções. PUSH/POP de seletores como CS, DS e ES “gastam” apenas 2 bytes, como mostrado acima. Já usar AX como registrador auxiliar “gasta” 4. E espaço é algo que queremos poupar ao máximo, mesmo em detrimento da performance. Afinal de contas, estamos apenas preparando terreno para carregar um programa bem maior: o kernel.

E, por falar nele, meu plano é criar um loader que coloque o processador em modo protegido para carregar o kernel diretamente na memória alta e só então saltar para o modo “longo” ou x86-64. Entrar no modo protegido de cara é um passo necessário porque, em 16 bits, só podemos acessar até 1 MB de RAM… Para fazer isso o loader não precisa de muita coisa… Todo o esquema de paginação, task switching, multiprocessamento, etc ficará no kernel, que pode ter o luxo de ser bem grande. Nosso bootloader tem que ser pequeninho, de preferência ter apenas alguns quilobytes.

Você poderia supor que todo o loader deveria ser feito em assembly, não é? Nope! Podemos misturar assembly com código em C. A medida que a complexidade aumenta, programar diretamente em assembly introduz muitos erros e fica cada vez mais difícil manter códigos “sem gambiarra”. Deixo o assembly apenas para inicializações que seriam difíceis de serem feitas em C e para códigos mais simples. No máximo quando quero alguma rotina mais “compacta” ou onde a performance não pode ser melhorada de outra forma… É preciso dar um jeito de misturar o código em assembly com C.

Este é o caminho contrário de tudo o que já escrevi por aqui: Geralmente recomendo que se use assembly para escrever rotinas que serão usadas num programa escrito em C. Fazer o contrário parece meio que “heresia”… Especialmente porque nossos programas em C fazem uso extenso de funções pré-programadas, disponíveis em bibliotecas como a libc, que não podemos usar!

Não ter funções como printf ou scanf, ou sequer exit, pode parecer um pesadelo, mas a ideia aqui é criar funções em C numa linguagem mais “abstrata” que o assembly, mas não tão de alto-nível que dependa de grandes cuidados… Na prática, tudo o que temos que fazer é montar um ambiente mínimo de suporte para as nossas funções e chamá-las.

Toda função em C funciona de acordo com uma convenção de chamada, que espera ter algumas condições atendidas: Que exista uma pilha com espaço suficiente para empilharmos não somente os endereços de retorno das chamadas, mas também os parâmetros que são passados (no modo i386 os parâmetros são passados pela pilha!). Fora isso, o compilador não liga para o conteúdo dos seletores de segmento, desde que ambos DS e ES apontem, de alguma maneira, para os segmentos de dados do programa. Assim, é só uma questão de obedecermos à convenção e efetuar um call para o endereço de entrada de uma função que estamos em bom território.

Isso não significa que não possamos entortar as regras um pouquinho em nosso benefício! O GCC, por exemplo, permite mudar a convenção de chamadas de maneira a usar os registradores EAX, EDX e ECX, nessa ordem, como parâmetros de valores inteiros. Do contrário, a pilha é usada… assim, uma função declarada como:

void __attribute__((regparm(2))) f(int x, int y);

Receberá em EAX o conteúdo do parâmetro x e em EDX o do y. Uma chamada a essa função pode ser feita assim:

; pseudo-código para o NASM...

extern f         ; f é um símbolo externo a esse código.

  ...
  mov  eax,1
  mov  edx,2
  call f
  ...

Caso contrário, se não colocarmos esse atributo, a chamada ficaria assim:

 push dword 2     ; Note que o empilhamento é de trás para frente
 push dword 1     ; como na convenção cdecl...
 call f
 add esp,8        ; ... e temos que limpar a pilha aqui!

Neste caso os dois PUSH consomem o mesmo tamanho de código que os dois MOV anteriores e temos mais 5 bytes do ajuste de ESP. Sem contar que os PUSHs são mais lentos que os MOVs. É importante notar que regparm é diferente do atributo fastcall. No fastcall apenas os registradores ECX e EDX são usados (nessa ordem) e no regparm podemos dizer quantos dos parâmetros poderão ser alocados em registradores (o 2, no atributo, diz para o compilador usar os 2 dos 3 registradores disponíveis – assim, os demais parâmetros serão passados de acordo com a convenção cdecl).

Só temos que fazer mais uma coisa para que nossas rotinas em C se misturem corretamente… Outra requisição do compilador com relação ao ambiente está relacionada aos nomes dos segmentos ou sessões onde ele colocará o código e os dados. O GCC espera poder colocar seus códigos na sessão .text e seus dados nas sessões .data, .rodata e .bss. A existência dessas sessões têm que ser informadas para o linker para ele poder “montar” as referências às variáveis e aos labels do código corretamente. Só resta um aviso: A sessão .bss normalmente não é carregada ou existe, de fato, no arquivo objeto… Tudo o que o linker sabe é que essa sessão existe e tem um tamanho conhecido. É trabalho do código de inicialização do seu programa preencher essa sessão com zeros…

Isso desfaz alguns preconceitos quanto às variáveis gloabais não inciializadas: Elas são sempre iguais a zero! São as variáveis locais não inicializadas que podem conter lixo, porque elas geralmente são alocadas num registrador ou na pilha.

Anúncios

2 comentários sobre “Heisenberg OS: Começando o bootloader

Deixe um comentário

Faça o login usando um destes métodos para comentar:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s