No artigo anterior, movemos o bootloader para o endereço 0x80000
e carregamos um dummy kernel de apenas um setor (512 bytes) no endereço 0x1000
. Neste artigo, vamos engordar esse dummy kernel para que ele tenha muitos setores, de forma que precisaremos carregar muitos setores do disco para o endereço 0x1000
.
E como engordamos nosso kernel? Vou explicar o que fiz e depois explico os detalhes sórdidos. A primeira parte possui algumas pequenas alterações, mas no geral permanece da mesma forma: as funções print_char
, print_string
e o ponto de entrada kernel_entry
.
[BITS 16]
%include "src/constants.asm"
global _start
_start:
jmp kernel_entry
print_char:
push bp
mov bp, sp
push ax
push bx
mov ax, [bp+4]
mov ah, BIOS_INT_10H_OUTPUT
mov bh, BIOS_INT_10H_PAGE_NUMBER_0
mov bl, BIOS_INT_10H_FOREGROUND_COLOR_GM
int BIOS_INT_10H
pop bx
pop ax
pop bp
ret
print_string:
push bp
mov bp, sp
push ax
push bx
push si
mov si, [bp+6]
cld
loop_print_string:
lodsb
cmp al, NULL_CHARACTER
je end_print_string
push ax
call print_char
pop ax
jmp loop_print_string
end_print_string:
pop si
pop bx
pop ax
pop bp
retf
kernel_entry:
mov ax, cs
mov ds, ax
mov es, ax
mov ax, msg
push ax
call KERNEL_SEGMENT:print_string
pop ax
jmp 0x3000:big_dummy_kernel
msg db "Dummy Kernel Loaded!", CARRIER_RETURN, LINE_FEED, NULL_CHARACTER
Até aqui tudo bem? O código começa em _start e pula para kernel_entry
. Ali ele corrige os registradores de segmento e invoca a função print_string
(já explico alteração nessa chamada). Depois de corrigir a pilha, ele pula para outro endereço, bem longe do endereço 0x1000
onde esse código está rodando: ele passa o controle para 0x3000:big_dummy_kernel
, que é depois do endereço absoluto 0x30000
.
Vamos às explicações das alterações então. Primeiro, estamos chamando a função print_string
de uma forma diferente. Onde antes era call print_string
, agora fazemos call KERNEL_SEGMENT:print_string
, conhecido como far call. Na primeira versão, o call
chama o rótulo dentro de 16 bits de deslocamento dentro do mesmo segmento de código. Como pretendemos que o código que está depois de 0x30000
também chame essa função print_string
, o código que está lá precisa apontar onde está o segmento de código da função print_string!
Há mais alguma alteração importante nessa chamada? Sim, afinal, quando a função print_string
terminar sua execução, como ele vai saber o segmento original do código que chamou a função (no nosso exemplo, o código rodando após o endereço 0x30000)? Essa forma de chamar a instrução call
(far call) empilha o registrador cs
na pilha. Assim, ao retornar da função, recuperamos essa informação da pilha e restauramos o segmento de código de quem chamou a função. Isso por si só inclui duas alterações importantes que a função print_string
precisa tratar:
Há agora um argumento adicional na pilha: o registrador
cs
. A forma de recuperar o argumento do parâmetro que será impresso na função é portanto deslocado em dois bytesAo retornar, precisamos alterar o registrador
cs
. Por sorte, há uma instrução que faz isso:retf
. Já explico como ela funciona.
Antes de prosseguirmos, apresento como fica a pilha quando a função print_string
é invocada junto com a execução das duas primeiras instruções (push bp
e mov bp, sp
).
Lembrem-se que a pilha cresce para baixo (conforme vamos adicionando elementos na pilha, o endereço do registrador sp
diminui). A imagem mostra que apareceu um elemento adicional na pilha (no caso, o registrador cs
). Quando anteriormente acessávamos o parâmetro msg
com [bp+4]
, como cada parâmetro da pilha de 16 bits possui dois bytes, agora acessamos com [bp+6]
.
Outra alteração é que a instrução de retorno da função precisa corrigir essa pilha, e mais do que isso, saber para qual segmento de código a execução deve retornar. Por isso, usamos a forma far call: enquanto a função call
normal desempilha o endereço de retorno (parâmetro ret
na primeira imagem), o far call desempilha o endereço de retorno E o segmento de código de retorno (parâmetros ret
e cs
na segunda imagem).
Uma coisa que você pode indagar é: ué, se o código está em outro segmento, o parâmetro msg também não pode estar? E a resposta é sim, veremos que empilhamos um parâmetro que está no mesmo segmento do código. A diferença é que nesse caso, ao entrar nesses novos pontos de entrada do kernel (apresentaremos a seguir), os registradores ds
e es
são corrigidos para o mesmo segmento do código. Isso significa que a instrução far call armazena e altera o segmento de código, mas os outros permanecem inalterados. Como as instruções que buscam os dados da memória usam tais registradores implicitamente se nenhum segmento for explicitamente informado (como por exemplo, a função mov
), acessamos os endereços corretos por tabela.
Temos outra implicação desse fato: a função print_char
é invocada pela função print_string
. As duas funções já estão no mesmo segmento de código, portanto a função print_string
invoca a função print_char
, usando a instrução call
que já conhecemos, e por esse motivo o acesso ao parâmetro na pilha permanece [bp+4]
e o retorno também permanece ret
.
Agora que explicamos o funcionamento do código, vamos ver como ele fica depois da fronteira do endereço 0x30000
.
...
msg db "Dummy Kernel Loaded!", CARRIER_RETURN, LINE_FEED, NULL_CHARACTER
TIMES 0x30000-($-$$) DB 0
big_dummy_kernel:
mov ax, cs
mov ds, ax
mov es, ax
mov ax, big_msg
push ax
call KERNEL_SEGMENT:print_string
pop ax
jmp 0x6000:big_big_dummy_kernel
big_msg db "Big Dummy Kernel Loaded!", CARRIER_RETURN, LINE_FEED, NULL_CHARACTER
TIMES 0x60000-($-$$) DB 0
big_big_dummy_kernel:
mov ax, cs
mov ds, ax
mov es, ax
mov ax, big_big_msg
push ax
call KERNEL_SEGMENT:print_string
pop ax
jmp $
big_big_msg db "Big Big Dummy Kernel Loaded!", CARRIER_RETURN, LINE_FEED, NULL_CHARACTER
Perceba que agora que entendemos o funcionamento da função KERNEL_SEGMENT:print_string
, pouco fizemos de diferente comparando com o caso da primeira invocação dessa função. Depois da string “Dummy Kernel Loaded!”
, atochamos zeros para completar o código até o endereço 0x30000. Ai ajustamos os segmentos, empilhamos uma nova mensagem a ser apresentada ("Big Dummy Kernel Loaded!"
) e executamos a instrução far call para invocar a print_string
. Depois do retorno da função, pulamos para um endereço mais longe ainda, o 0x60000
. E o procedimento é repetido, mas para a mensagem "Big Big Dummy Kernel Loaded!".
Se tudo der certo, o que é esperado é a tela abaixo, que indica que estamos fazendo chamadas cross-segment e que nossa pilha está sendo corretamente manipulada.
Contudo, pequeno gafanhoto, em que momento esse monte de bytes desse Big Big Dummy Kernel foi carregado do disco para a memória? Pois é, eu ainda não mostrei esse código. Isso faz parte do crédito extra no P1.
Primeiro, vamos ver qual é o tamanho desse dummy kernel, para saber quantos setores eu preciso carregar do disco para a memória. Depois de compilado, o comando wc build/kernel
mostra quantos bytes tem o arquivo.
Ok, então esse arquivo possui 393265 bytes. Como cada setor possui 512 bytes, então devemos ler 768,095703125 setores. Não conseguimos ler um setor de forma parcial, precisaremos ler esse setor inteiro. Então, ler 769 setores do disco é suficiente nesse caso e inclusive facilita nossa vida. A primeira alteração portanto é quantos setores do OS/Kernel serão lidos, e o arquivo bootloader.asm fica da seguinte forma:
os_size: ;in sectors
dw 769, 0
Alterei o disk_index
de db 0xff
para db 11111111
b. Perceba que 0xff
é igual a 11111111
b, então nada muda. Então, para que se importar com isso? O lance aqui é deixar explícito que todos os bytes dessa variável estão setados com 1 quando esse código é executado. É esperado que o bootloader altere essa variável na memória com o valor do registrador dl
, e valores comuns são 0x0
(floppy), 0x80
(primeiro HD), 0x81
(segundo HD), etc. Assim, fica fácil acompanhar essa alteração no debugger e identificar se há algum erro nesse processo.
Agora começa a complicação. Se pudéssemos tratar o disco como um conjunto contíguo de bytes na memória, agrupados em blocos de 512 bytes (os famigerados setores), poderíamos pedir para ler depois de 512 bytes (esses primeiros pertencem ao bootloader) até onde gostaríamos, nesse caso, 769 setores após o primeiro setor. Para dispositivos mais novos, isso já existe e se chama LBA. O esquema LBA permite endereçar blocos no disco, começando do bloco 0 linearmente até o fim do disco. BIOS mais novas possuem extensões da INT 13h que permitem ler discos usando esse esquema. Contudo, controladoras de disco mais antigas e BIOS mais antigas não possuem esse luxo. Nesse caso vamos com o bom (bom pra quem?) e velho (bota velho nisso) esquema CHS.
Nós sabemos exatamente quais blocos queremos ler. Nesse artigo queremos ler do segundo setor (endereço LBA 1) até o fim do arquivo kernel (endereço LBA 769). Precisamos transformar esses endereços no padrão de cilindros, cabeças, trilha e setores.
Para quem programou em C/C++, provavelmente você já encarou o problema de acessar um byte arbitrário em uma matriz 3D alocada de forma contígua. Você pode encarar uma matriz 3D como várias matrizes 2D dispostas uma ao lado da outra. Há várias formas de se encarar esse problema, e várias soluções possíveis. Para mais informações, veja aqui.
Para encontrar um bloco arbitrário na estrutura da imagem acima, precisamos identificar em qual setor, em qual trilha, em qual cabeça e em qual cilindro está o nosso dado. Precisamos de algumas informações adicionais para isso:
Quantos setores por trilha esse disco possui?
Quantas cabeças esse disco possui?
Com essas informações já conseguimos fazer um cálculo LBA para CHS. Definimos portanto mais três variáveis:
sectors_per_track:
db 0
number_of_heads:
db 0
current_lba_address: ; second sector
dw 1
Começamos do segundo setor (LBA 1) com a variável current_lba_address
. As outras duas variáveis dizem respeito às perguntas feitas anteriormente e já verificamos como conseguimos tais dados.
Antes, fixamos os_size
como 1 e solicitamos a interrupção BIOS 13h
que lesse apenas um setor, no endereço CHS 0,0,2 (pois diferente do LBA, os setores são indexados a partir de 1). Agora temos um número bem maior em os_size.
Primeiro de tudo, nós resetamos o sistema do disco:
; let's reset the disk system first
reset_disk:
mov dl, [disk_index]
mov ah, BIOS_INT_13H_RESET_DISK_SYSTEM
jc reset_disk ; reset went wrong. Try again
Isso serve pois não sabemos em que estado está o hardware do disco, precisamos que ele esteja pronto para receber nossas requisições de leitura. Inclusive, se a leitura for mal sucedida (Carrier Flag esteja setada), saltamos para tentar fazer o reset novamente.
Depois disso, vamos obter a geometria do disco, ou seja, responder às perguntas feitas anteriormente:
; let's get disk geometry
mov ah, BIOS_INT_13H_GET_DISK_GEOMETRY
push es
xor di, di
mov es, di ; hack to get es:di = 0x0000:0x0000
int BIOS_INT_13H
pop es
O comando em si é apenas o mov ah, BIOS_INT_13H_GET_DISK_GEOMETRY e int BIOS_INT_13H.
O restante do código está presente pois algumas BIOS possuem um bug e se os registradores es:di
não possuirem os valores 0x0000:0x0000
essa query retorna valores inválidos. Então estou apenas salvando o valor antigo de es e zerando ele junto com o registrador di.
A essa interrupção retorna o índice da última cabeça no registrador dh
(iniciando de zero), portanto, ao adicionar 1 nesse valor temos o número total de cabeças do disco. O registrador cl
retorna o número total de setores por trilha (começando em um). Contudo, apenas os 6 bits menos significativos desse registrador representam o número de setores por trilha, portanto temos valores válidos entre 1 a 63. Os outros dois bits são os dois bits mais significativos do índice do último cilindro do disco, que em compõem esse valor em conjunto com os outros 8 bits do registrador ch
. Por sorte, nosso dummy kernel não é tão grande assim para termos que fazer qualquer tipo de checagem de fronteiras no número de cilindros, a ponto de não precisarmos nem armazenar esse valor. Temos apenas que garantir que vamos obter apenas os últimos 6 bits do registrador cl, e podemos fazer isso com a máscara BIOS_INT_13H_SECTOR_MASK
(valor 00111111b
) e a instrução and
. Dessa forma, os últimos bits do registrador cl
são descartados e como não vamos usá-los agora, isso é exatamente o que precisamos para o momento. Esse trecho fica portanto assim:
mov [number_of_heads], dh
inc byte [number_of_heads]
and cl, BIOS_INT_13H_SECTOR_MASK
mov [sectors_per_track], cl
Agora, já sabemos quantos setores por trilha esse disco tem, e quantas cabeças também.
O destino da cópia você já conhece, vamos copiar os setores para o endereço es:bx
, portanto:
; we'll copy sectors to here
mov bx, KERNEL_SEGMENT
mov es, bx
Agora vem a parte mais difícil: indicar para a interrupção a partir de qual setor devemos ler. Para exemplificar, vamos usar exemplos números e figuras. Suponha que você deseje ler endereço LBA 105 do disco. Esse disco possui uma geometria com 36 setores por trilha e duas cabeças (ou seja, é um floppy disc). Teríamos algo do tipo:
Relembrando um pouquinho das aulas de Matemática Discreta e Álgebra, para o setor LBA 105, com 36 setores por trilha, precisamos de 2*36 + 33 setores. Com a operação de módulo (resto da divisão), 105 mod 36 é igual a 33. Contudo, os setores iniciam em 1, então precisamos somar 1 para que a chamada da interrupção BIOS 13h ocorra de forma correta.
Pegando a parte inteira dessa divisão, nós obtemos o número da trilha (105 dividido por 36 dá 2 e uns quebrados). Isso bate com nossa figura, track 2. Mas com duas cabeças, temos os índices de cabeça variando de 0 a 1. Portanto, precisamos pegar o número da trilha e também usar a operação módulo. Assim, 2 (número da trilha) mod 2 (número de cabeças do disco) é igual a zero, então nossa cabeça é 0 (head 0 na figura). Para encontrar o cilindro, basta pegar a parte inteira da divisão do número da trilha com o número de cabeças do disco: 2 div 2 é igual a 1, portanto, cilindro 1, conforme nossa figura apresenta. Assim, o endereço LBA 105 é igual ao endereço CHS (1,0,34) para esta geometria de disco de 36 setores por trilha e 2 cabeças. Se quiser olhar mais a esse respeito, veja aqui.
Vamos ver como fica nosso código:
read_sector:
; which Logical Block Address do we have to copy now?
mov ax, [current_lba_address]
; have we reached the end?
cmp ax, word [os_size
]
jg end_read_sector
; not yet, lets get CHS parameters
mov bl, [sectors_per_track]
div bl
inc ah
mov cl, ah ; here, we know which sector we start
xor ah, ah ; al has the track number.
div byte [number_of_heads]
mov dh, ah ; now we get the head number...
mov ch, al ; ... and the cylinder number. We have all we need.
Começamos verificando qual endereço LBA vamos converter (começa em 1, representando o setor 2 no disco, que é o primeiro setor logo após o bootloader). Depois verificamos se já chegamos ao fim da leitura, ou seja current_lba_address
é igual ao os_size
, e current_lba_address
é incrementado a cada leitura. Em caso negativo, ainda há leitura de setores a ser feita.
Então, ax possui o endereço LBA. Ao colocar sectors_per_track
e executar a instrução div bl
, o resto da operação fica no registrador ah
e o quociente em al
. Significa que ah
(depois de incrementado em 1) possui o índice do setor, e al
possui o número da trilha.
Devemos agora usar o número de trilha para encontrar os índices de cabeça e cilindro. Esse valor deve estar em ax
; al
já contém essa informação, mas ah está com o valor sujo e precisamos zerá-lo. Por isso, a instrução xor ah, ah
.
Neste ponto, ax
já tem o número de trilha. Dividindo esse valor pelo número de cabeças do disco (variável number_of_heads
), temos ah com o índice da cabeça e al
com o índice do cilindro.
Ufa, já temos todos os valores nos registradores exigidos pela interrupção de leitura BIOS 13H
. Ou quase todos.
Ainda falta nos livrarmos de um grande elefante branco no meio da sala: quantos setores devemos ler de uma vez? Até o momento, estávamos lendo apenas um setor do disco. Um algoritmo ingênuo diria nos instruiria a ler a maior quantidade de setores possíveis de uma vez. O problema é que há restrições impostas pelas arquiteturas e controladoras de disco. Vamos discutir algumas delas.
A primeira limitação é a quantidade máxima teórica que podemos ler usando a interrupção BIOS 13H
. A quantidade de setores que gostaríamos de ler em uma chamada da interrupção fica armazenada no registrador al
. Valores possíveis então poderiam ser de 1 a 255, mas aqui diz que é até 128. Ainda assim, parece não ser possível ler tudo isso de uma vez em um floppy disk, e aqui é dito que em controladoras antigas, pode ser possível ler apenas 18 setores de uma vez, e quando usando o emulador BOCHS, o máximo permitido é 72.
Ainda há outro problemão: o endereço es:bx
não pode cruzar a fronteira de 64KB de um segmento físico na memória durante a leitura. No nosso caso, começamos no endereço 0x1000
(4096 bytes) e vamos até o endereço 0xfa00
(64 KB). Nesse intervalo, temos 59.904 bytes, e com setores com tamanho de 512 bytes cada, temos um total de 117 setores a serem lidos nesse intervalo.
Pegando um mínimo denominador comum, você pode ir lendo de 18 setores 6 vezes, e no fim leria apenas 9 setores para parar na barreira do segmento físico. Ou se for ler 16 setores de uma vez, você teria que fazer 7 leituras completas e mais uma com 5 setores e parar na barreira do segmento. Ao cruzar a barreira do segmento físico, você tem mais 64KB para o próximo segmento. Setores de 512 bytes totalizam 125 setores, um bom número seria um número de cinco, mas menor que 18? Sinceramente, não vale a pena. Enquanto não precisamos otimizar o código, podemos ler setor por setor que não teremos problema nenhum. Portanto, defini BIOS_INT_13H_SECTOR_COUNT = 1
.
Invocamos a função como de costume (lembre-se que o registrador es já havia sido corretamente definido):
mov dl, [disk_index]
mov al, BIOS_INT_13H_SECTOR_COUNT ; how many sectors are we reading at once?
mov ah, BIOS_INT_13H_READ
mov bx, KERNEL_INIT ; bx will be always the same. es is the register that will change
int BIOS_INT_13H
jc read_sector ; if CF is set, an error occurred. Try again
Fazemos a leitura, e checamos se a Carrier Flag está definida. Em caso positivo, houve um erro na leitura, pule para read_sector
e repita o processo.
Em caso negativo, a leitura foi bem sucedida. Malandramente, eu mantenho o registrador bx
fixo, calculo quantos bytes foram lidos e incremento apenas o registrador de segmento es
. Por exemplo, se apenas um setor foi lido como é o nosso caso (BIOS_INT_13H_SECTOR_COUNT = 1
), significa que lemos 0x200
bytes (512 em decimal), basta fazer um shift right no número hexadecimal 0x200
para resultar em 0x20
. Em decimal, basta fazer (SECTOR_SIZE*BIOS_INT_13H_SECTOR_COUNT)/16
(é o valor definido na constante BIOS_INT_13H_SEGMENT_OFFSET
no arquivo constants.asm
).
Depois, incrementamos o endereço LBA com o número de setores lidos, e fazemos um salto para ler mais setores. Quando todos os setores forem lidos, o kernel está com certeza no endereço 0x0000:0x1000
, basta pular para lá e executar o que já mostramos no começo desse artigo. O trecho final do código fica assim:
mov ax, es
add ax, BIOS_INT_13H_SEGMENT_OFFSET
mov es, ax ; we've read BIOS_INT_13H_SECTOR_COUNT*SECTOR_SIZE bytes. Move es to new position (move to segment BIOS_INT_13H_SEGMENT_OFFSET)
add word [current_lba_address], BIOS_INT_13H_SECTOR_COUNT ; move LBA pointer
jmp read_sector
end_read_sector:
; the kernel is entirely in the memory. Jump to its start address
jmp KERNEL_SEGMENT:KERNEL_INIT
O código completo está no meu github na branch p1-v5. No próximo artigo vamos parar de roubar, nosso Makefile está fazendo com que o linker gere arquivos raw binary, mas o Projeto P1 espera que esses arquivos sejam gerados no formato ELF, e precisamos criar um programa que faça o parser nesses arquivos e os junte, inclusive alterando o campo campo os_size
no arquivo bootloader.asm
a depender do tamanho do código presente no kernel. Essa será a nossa próxima missão, até lá.