No artigo anterior nós mostramos o layout da imagem do SO e também o layout de memória. Nesse artigo, vamos proceder com a leitura dos dados na imagem para a posição correta do kernel na memória (0x1000).
Contudo, ainda não temos um kernel, então vamos trabalhar com o Dummy Kernel. No fim das contas por enquanto queremos apenas carregar o Kernel na posição 0x1000, e já cuidamos para que, se ele for muito grande, este não sobrescreverá o bootloader, que foi movido da posição 0x7c00 para 0x80000.
Para este teste, criei um arquivo kernel.asm e movi o código de impressão de caracteres e strings para ele. Vamos ver o que foi necessário para que isso funcione.
No arquivo booloader.asm, entre os rótulos _start
e load_kernel
, adicionamos os seguintes dados:
os_size:
dw 1, 0
disk_index:
db 0xff
O primeiro campo, os_size
, possui duas palavras e indica o tamanho do Sistema Operacional que deve ser lido do disco, em número de setores. Ao juntar o bootloader e o kernel em artigos posteriores, o utilitário irá alterar esse campo para indicar o tamanho do kernel que deve ser posteriormente lido. No momento, temos um kernel dummy e, neste nosso artigo, sei que tem menos de 512 bytes, portanto, ler um setor é suficiente.
Note ainda que o defini o 1 na primeira palavra. Não deveria ser na segunda? Não, pois o utilitário que junta o bootloader e kernel possui 32bit, e 32bit na arquitetura x86 é LITTLE ENDIAN. Significa que, do ponto de vista dessa aplicação, ela irá escrever os 32bit de uma vez como um número só, e os bits menos significativos ficam nos menores bytes na memória, e portanto, aparecem antes.
Note ainda que se um número de 32bit for escrito nessa região, teremos que nos desdobrar para fazer “caber” nos registradores de 16bit. Possivelmente a solução é quebrar esse número grande e fazer várias leituras. Mais do que isso, se pretendemos usar a interrupção BIOS 13h para fazer a leitura e não pudermos ler tudo de uma vez, precisamos saber como ir avançando a leitura usando o padrão CHS (Cylinder, Head and Sector). Essa conversão será um problema para o nosso eu do futuro.
O próximo campo, disk_index
, guardará qual disco deve ser lido. O disco de boot pode estar em qualquer um dos dispositivos: Floppy, HD, SSD, CD-Rom, Rede… De onde devemos ler? Por sorte, ao achar o setor de boot, quando o controle é passado ao bootloader, o registrador dl possui o índice do disco que devemos usar, e podemos passar esse valor diretamente para a interrupção BIOS 13h. Como podemos perder esse valor se o registrador dx for usado, salvamos esse valor para uso futuro. Você pode me perguntar, o que o valor 0xff
significa? Na realidade, nada. Foi um valor arbitrário que defini, pois como estamos usando o primeiro floppy disk no QEMU, esse valor deve ser 0. Assim, eu garanto que essa posição de memória foi corretamente alterada de 0xff
para 0 e isso facilita nossa depuração.
Aproveitei a oportunidade para deixar o registrador bp
em um endereço bem definido:
mov sp, STACK_POINTER_BOOTLOADER
mov bp, sp
Isso acompanha aquela ideia do registro de ativação já explicada anteriormente. Apesar de nossas funções ajustar tais registradores, não dói fazer isso agora.
Depois, antes de copiar o bootloader para a posição 0x80000
, salvamos o disco presente no registrador dl
, como dito anteriormente:
mov [ds:disk_index], dl
Após já estar executando na posição nova, fazemos a leitura do kernel:
new_boot_region:
mov ax, NEW_BOOTLOADER_SEGMENT
mov ds, ax
mov ah, BIOS_INT_13H_READ
mov al, [ds:os_size]
mov ch, BIOS_INT_13H_CYLINDER_NUMBER
mov dh, BIOS_INT_13H_HEAD_NUMBER
mov cl, BIOS_INT_13H_SECTOR_NUMBER
mov dl, [ds:disk_index]
mov bx, KERNEL_SEGMENT
mov es, bx
mov bx, KERNEL_INIT
int BIOS_INT_13H
jmp KERNEL_SEGMENT:KERNEL_INIT
Agora é necessário ler o disco para buscar o kernel e posicioná-lo no endereço 0x1000
. Vamos ver o que é necessário para invocar a interrupção BIOS 13h. Dentre todas as funções que podemos fazer com essa interrupção (estamos falando de manipulação de discos, então podemos ler, escrever, formatar, resetar, etc), nosso interesse é ler do disco, portanto, o valor correto é o BIOS_INT_13H_READ
= 0x2. Depois, indico quantos setores eu pretendo ler de uma vez. Se você voltar, notará que eu defini de antemão o valor para 1 (1 setor - 512 bytes), pois já sei que agora meu kernel tem menos de 512 bytes1.
Já sabemos quantos setores queremos ler, mas começamos a leitura a partir de onde no disco? O problema é mais ou menos parecido com encontrar uma determinada faixa musical em um LP tocando em uma vitrola2.
No caso da interrupção da BIOS 13h, precisamos indicar em qual Cilindro (Cylinder), Cabeça (Head) e Setor (Sector) vamos iniciar a leitura. Se tiver dúvidas em qual a lógica por trás disso, veja esse vídeo aqui. No nosso caso, o primeiro setor de todos é nosso bootloader, que fica no Cilindro 0, Cabeça 0 e Setor 1 (note que o setor não começa no 0). Nosso kernel está grudado e está logo na sequência, portanto queremos começar a leitura a partir do Cilindro 0, Cabeça 0 e Setor 2. Atente-se novamente, se você precisar ler uma quantia muito grande de setores, provavelmente precisará quebrar uma leitura grande em várias pequenas, recalculando os índices de Cilindro, Cabeça e Setor no processo.
Mas a vida, além de não ser fácil, ainda é difícil. Isso porque os valores de cilindro, cabeça e setor são guardados nos registradores ch
, dh
e cl
, respectivamente. E enquanto não temos problemas com relação aos 8 bit que podem representar as cabeças, de 0 a 255, o mesmo não pode ser dito dos cilindros e setores. Isso porque, na verdade o índice de cilindro é um número de 10 bits, e o número de setores é um número de 6 bits. Significa que pegamos emprestado dois bits dos setores para compor os cilindros. É confuso, mas acredito que a imagem abaixo possa ajudar no entendimento.
Sem surpresa então, temos as constantes BIOS_INT_13H_CYLINDER_NUMBER
= 0, BIOS_INT_13H_HEAD_NUMBER
= 0
e BIOS_INT_13H_SECTOR_NUMBER
= 2,
e não precisamos de nenhum malabarismo para encaixar os bits do cilindro e setor. BIOS_INT_13H_SECTOR_NUMBER
= 2 usa menos que os 6 bits disponíveis, BIOS_INT_13H_SECTOR_NUMBER
= 2 em binário é 00000010
, então os dois últimos bits já estão zerados, e para o cilindro, é só deixar ch
= 0.
Prosseguindo pelo código, recuperamos o disco que devemos ler e guardamos esse valor no registrador dl, que será usado diretamente pela interrupção.
Por fim, só falta indicar onde deveremos guardar os dados lidos. Lembre-se que queremos colocar o kernel em 0x1000
, então ES:BX valendo 0x0000:0x1000
é suficiente para o nosso caso (representado pelas constantes KERNEL_SEGMENT:KERNEL_INIT
).
Assim que a interrupção finaliza, saltamos para o endereço 0x0000:0x1000
usando a instrução jmp KERNEL_SEGMENT:KERNEL_INIT,
pousando no endereço absoluto 0x1000
, como o esperado.
Esse endereço possui o código presente no arquivo kernel.asm, que é basicamente as funções print_char
e print_s
retiradas do arquivo bootloader.asm. A diferença foi que ajustei os registradores ds e es para o mesmo segmento de cs (zero, portanto), e alterei a mensagem a ser impressa para “Dummy Kernel Loaded!”. Nada mais é alterado em relação ao que já existia anteriormente.
Como agora temos um bootloader e um kernel (cof cof), não faz mais sentido carregar a imagem como bootloader, por isso gerei um novo arquivo chamado boringos.img
, que é uma amálgama dos dois anteriores. Na real são apenas os bytes copiados em sequência usando cat
.
O novo Makefile ficou então:
SRCDIR=src
OUTPUTDIR=build
all: kernel bootloader
cat $(OUTPUTDIR)/bootloader $(OUTPUTDIR)/kernel > $(OUTPUTDIR)/boringos.img
%.o: $(SRCDIR)/%.asm
nasm -wall -O2 -f elf32 -F dwarf -g -o $(OUTPUTDIR)/$@ $<
bootloader: bootloader.o
ld -nostdlib -O2 -g -m elf_i386 -Ttext 0x0 -s --oformat binary -o $(OUTPUTDIR)/$@ $(OUTPUTDIR)/$<
kernel: kernel.o
ld -nostdlib -O2 -g -m elf_i386 -Ttext 0x1000 -s --oformat binary -o $(OUTPUTDIR)/$@ $(OUTPUTDIR)/$<
clean:
rm -rf $(OUTPUTDIR)/*
Perceba que deixei a compilação dos arquivos .asm de forma genérica. O mesmo não pode ser dito dos executáveis bootloader e kernel, já que estes são posicionados em seções de texto diferentes (parâmetros -Ttext 0x0
e -Ttext 0x1000).
O arquivo VM_BoringOS.sh
foi alterado para usar o novo arquivo boringos.img
. Já no arquivo debug_BoringOS.sh
adicionamos os símbolos do arquivo kernel.o
no endereço 0x1000
. Retirei os símbolos do bootloader do endereço 0x7c00
, e deixei apenas no endereço 0x80000
, para evitar conflitos do mesmo símbolo estar em lugares diferentes. Alterei o primeiro breakpoint de hbreak *_start
para hbreak *0x7c00
, já que não temos mais o símbolo _start
carregado pelo gdb
no endereço 0x7c00
…
Se tudo der certo, ao iniciar a depuração e solicitar ao depurador que continue a execução, você deve ver a tela abaixo.
OBS: Fique esperto com os segmentos. Inicialmente eu tentei fazer com que KERNEL_SEGMENT:KERNEL_INIT fosse 0x0100:0x0000
. Embora isso seja igual a 0x1000:0x0000
, que é igual ao endereço absoluto 0x1000
, isso fez com que eu tivesse dor de cabeça com os helpers do gdb
. Nesse caso, o helper do gdb
para o modo real vai fazer SIGTRAPs
dependendo de onde você coloca o breakpoint, e a execução não vai para frente com os comandos s
, si
, n
, stepo
, etc no gdb
. Observe esse exemplo:
Logo após o gdb
parar a execução no endereço 0x7c00
, eu pedi um novo breakpoint em 0x1000
. Ele informa que o programa parou por algum breakpoint definido, mas não por mim. Para prosseguir, tive que fazer um delete e apagar todos os breakpoints presentes. Assim, o si volta a funcionar e você pode setar os breakpoints novamente.
Ajustando os segmentos dos registradores cs
, ds
e es
para 0x0000
, o problema parou de acontecer.
O código para essa versão estão aqui.
Perceba que ler muitos setores de uma vez pode ser uma dor de cabeça. Você não pode cruzar um segmento físico da memória (cada 64KB começando do byte 0) em uma leitura única. Se estivéssemos usando o BOCHS, ainda teríamos a limitação de poder ler menos do que 64 setores, senão a leitura falharia. Não sei se esse problema permanece em versões mais novas do BOCHS. Em um código correto e robusto deveríamos verificar o retorno da interrupção para saber se a leitura foi bem sucedida, neste caso, verificando os registradores ah, al e a Carrier Flag. E não sei se isso ocorre no QEMU, tampouco pretendo descobrir isso agora.
Se não sabe do que eu estou falando, faça uma visita ao Manual do Mundo!