A20 Gate Hell e Protected Mode
Ou: as dificuldades que a retrocompatibilidade nos impõe para usar 32bit e 4GB de memória
O post anterior finalizou o P1 criando uma imagem bootável a partir de arquivos ELF. Agora, vamos iniciar o P2. Só que tem uma pegadinha, observe o que é dito na área de Hints:
The boot loader provided to you for this project does more than the one you wrote last project. Specifically, it enables address line 20 and enters protected mode.
Ok, esse trabalho tem uma parte do código que já vem pronta e não especifica muito o que é feito. Como nós estamos construindo tudo do zero, pois não estamos acessando o código original, é nossa missão fazer essas etapas. E isso não é dito ou explicado no trabalho como é ou deveria ser feito. Vamos então entender o problema e ver como solucionamos.
A20 gate
Muito antigamente, no tempo de guaraná de rolha, os processadores da arquitetura x86 (8086, 8088, 80186, 80188 e variantes) eram da arquitetura 16bit com endereçamento de 20bit. Como você armazena um endereço de 20 bits em um registrador de 16? Você já sabe a resposta, e a mágica é usar registradores de segmentos para compor o endereço: com um registrador de segmentos de 16bit. O maior endereço possível para 20 bits é 2^20 = 1048576 - 1 (já que o endereço começa em zero), ou 0x100000
. Isso pode ser representado pela combinação de segmento 0xf800
e offset 0x8000
(0xf800
* 16 + 0x8000
).
Até aqui ok, certo? Ai surgiu o processador 80286. Esse processador trouxe o advento do famigerado Modo Protegido, que explicarei mais a frente. Esse processador também trouxe uma expansão do endereçamento, podendo agora usar endereços de 24 bits (e 30bits no modo protegido com MMU, mas não nos avancemos muito. Foquemos nos 24bits).
Contudo, olha a cagada: esses processadores eram vendidos como retrocompatíveis com os sistemas antigos, ou seja, ele deveria emular um 8086 antigo em modo real 16bit, até que você dissesse para ele fazer o contrário, assim, programas antigos escritos para a outra arquitetura continuavam funcionando. Isso na teoria.
Porque, na prática, um programa antigo ao acessar o endereço 0xf800
:0x8000
esperaria que o processador acessasse o endereço físico 0x000000
, afinal, pra ele o endereço 0x100000
não existe. Mas o processador 80286 original não fazia nenhum tipo de correções nesse acesso, e programa antigo capotava ao acessar um endereço inválido sem ter muito o que fazer para corrigir o problema.
A solução veio na mais pura e cristalina gambiarra, o pessoal decidiu resolver isso direto na placa-mãe. Um gate AND
lógico foi adicionado nesse último bit do barramento de endereços da memória (ou o vigésimo bit, ou o 20th address bit, e dai o nome A20), de modo que ele viria com uma porta lógica trabalhando como chave: se o bit de controle estiver zerado, o bit de memória era também zerado, caso o bit estivesse ligado, o endereço de memória (zero ou um) seria preservado.
E como é que ligamos ou desligamos esse bit? Ah, ai que a coisa começa a ficar divertida. O pessoal notou que o controlador 8042, responsável pelo teclado, tinha um pino sobrando. Então, foi soldado esse pino no A20 gate, e se você enviar os comandos certos para o controlador do teclado, você consegue habilitar ou desabilitar essa porta lógica. O diagrama lógico dessa parte do sistema fica assim portanto:
Ok, e como ativamos o A20 gate? Bom, eu não contei a história inteira ainda: não existe uma forma “uniforme e padronizada” de fazer essa ativação. Até porque, existem sistemas embarcados x86 que não possuem controladores de teclado, são acessíveis via rede, porta serial, ou algum outro tipo de método de entrada.
Bom, para encurtar a história, HOJE em dia nas CPUs mais modernas há uma função da BIOS para isso, a BIOS 15H
função 0x2401
. Se essa função não estiver disponível (se você invocar essa interrupção e a flag CF estiver setada) você precisa emitir comandos diretamente para o controlador do teclado (porta 0x64
), ou usar comandos na porta der controle A (porta 0x92
). Leia AQUI para quais sistemas já se tem mapeado quais métodos funcionam, o buraco é muito mais embaixo do que parece.
No nosso caso, o qemu-system-i386 aceita comandos da BIOS 15H. Então, será esse que usaremos. Deixei um comentário proposital para você completar o código caso a BIOS 15H não funcione para você:
...
; Let's enable A20 gate
mov ax, BIOS_INT_15H_ENABLE_A20
int BIOS_INT_15H
jnc a20_success
; write here code for Keyboard Controller or 0x92 Port!!!
a20_success:
...
Com sorte, após a execução da instrução INT
, a flag CF
estará zerada e o sistema entenderá que o gate A20 está ativo.
Agora é só mudar para o modo protegido e nossa vida está ganha? Tá achando que a vida é fácil jovem? Nada disso.
De fato, para mudar para o modo protegido, basta ativarmos o bit 0 no registrador CR0
(Control Register). Só que ao ativarmos o modo protegido, a gente recebe de brinde um monte de coisas legais que esse modo proporciona: endereçamento de 32bit, poderemos especificar endereços de memória onde o kernel vai rodar (Ring 0) ou o usuário (Ring 3), proteção de memória, onde indicamos quais áreas são somente leitura (para os códigos) e escrita (dados/pilha) e no futuro, até paginação!
O problema é que tudo isso que eu disse é configurável, e o processador não assume nada por default. Ao executar em modo protegido, o processador vai buscar o que pode ou não ser feito nesse modo em um registrador específico para esse fim, que armazena informações sobre o infame GDT. Aperte os cintos que a jornada vai ser turbulenta.
Global Descriptor Table
GDT significa Global Descriptor Table, ou tabela de descritores global. Cada entrada dessa tabela possui 8 bytes, portanto 64 bits. A interpretação da entrada depende do que ela se refere, e no caso vamos analisar entradas para código privilegiado de kernel para código e dados.
As informações em vermelho são a numeração dos bits da entrada, indo de 0 a 63. Note que as informações em verde e azul dizem respeito as informações de endereço base e limite da região de memória apontada por essa entrada. O limite tem então 20 bits (indo dos bits 0 a 15 e 48 a 51) e o endereço base 32 bits (indo dos bits 16 a 31, 32 a 39 e 56 a 63). Não me pergunte porque o endereço base não pode ser contíguo, por ex, dos bits 0 a 31, com certeza é coisa do projeto do processador (assim espero!)
Se você está atento, notou que o endereço base pode ter qualquer endereço dentro do espaço de endereçamento de 32bit, mas o limite só tem 20. Será que essa entrada não consegue representar todo os 4GB (2^32) que os 32 bits nos permitem? A resposta é que sim! Devemos usar esses valores em conjunto com o bit 55, o G na figura acima, que significa granularidade. Assim, caso setado, ele especifica que o limite é representado como em unidades de 4KB (multiplicando 4KB por 1MB - os 20 bits do limite, chegamos nos 4GB). Se esse bit estiver zerado, significa que a unidade é 1 byte, e portanto essa entrada do GDT endereça apenas 1MB.
Vamos verificar o restante dos campos dessa entrada:
O bit 40 é o bit de acesso. Ele é usado pela CPU ao acessar o segmento, e coloca 0 quando o segmento é lido, ou 1 quando o segmento é escrito. Tentativas de escrever em um segmento somente leitura (como é o caso de segmento de código) tentará setar esse bit e falhará causando uma falha de segmentação ou page fault se a paginação já estiver habilitada.
Os próximos 4 bits (41 a 44) dizem respeito ao tipo de segmento que essa entrada referencia. O bit 43 indica se esse é um segmento de código (1) ou dados (0). Se for um segmento de código, o bit 41 indica se esse segmento de código pode ser lido (1). Se for um segmento de dados/pilha, esse mesmo bit indica se esse segmento pode ser escrito (1). O bit 42, para segmentos de dados/pilha, indica a direção da expansão. Se estiver setado, indica que o segmento cresce para baixo, ou seja, que o offset do endereço é maior que o limite. Do contrário, endereço base + offset < limite, que é o nosso caso. Para entender isso é melhor verificar como é feita a tradução do segmento na figura abaixo:
O bit 44 é setado, quando esse segmento é referente a segmentos de código ou dados (nosso caso). Do contrário, zero significa que é um segmento TSS (Task State Segment, será apresentado em algum momento futuro).
Os bits 45 e 46 referem-se ao DPL (Descriptor Privilege Level). Você pode ter ouvindo falar dos anéis de privilégio de execução do código em outros sistemas operacionais. Quanto maior o nível, menor o privilégio. Por esse motivo, geralmente códigos do kernel rodam no nível 0 (bits 00) e código do usuário rodam no nível menos privilegiado, ou seja, 3 (bits 11). Por enquanto, rodaremos no nível 0. Códigos que executam em Ring 3 por exemplo não podem executar certas instruções, como
IN
/OUT
(manipulação de portas de hardware).O bit 47 diz se o segmento está presente. Obviamente deixaremos esse bit setado para nossas entradas.
Os bits 52 a 55 são os bits de flags, definidos abaixo:
O bit 52 é reservado, deixe este zerado.
O bit 53 é o bit do modo longo (modo de 64 bits). Como estamos trabalhando como 32 bits, deixe esse bit zerado.
O bit 54 indica se estamos trabalhando com 16 (bit zerado) ou 32 bits (bit setado). Pois é, é possível trabalhar no modo protegido em 16 bits (é o caso do processador 80286). No nosso caso, como estamos em modo protegido 32 bits, deixe esse bit setado.
O bit 55 já foi explicado acima, é o bit de granularidade. Quando zerado, o segmento descrito por esse seletor possui no máximo 1MB, pois a unidade usada é de 1 byte. Caso contrário (nosso caso), a unidade é 4KB e nosso segmento pode ter tamanho máximo de 4GB.
No fim das contas, dado um endereço base de 32 bits, o limite de 20 bits (que pode ser armazenado em um registrador de 32 sem perdas) e os bytes de tipo e flags, é questão de coreografia de shifts e máscaras para acomodar tais valores no formato previsto do GDT.
Tudo o que foi dito até agora é referente a apenas UMA entrada da da tabela de descritores globais. Precisamos de algumas. Por enquanto definiremos três, mas já sabendo que no futuro vamos precisar de mais. Precisamos de um descritor nulo (os 64 bits da entrada zerados), por exigências da especificação do processador. Precisamos ainda de um descritor para o segmento de código do kernel e outro para o segmento de dados (no momento, os endereços físicos se sobrepõem, acessando os mesmos endereços de 0 a 4GB de memória). Depois, precisaremos de um descritor para o segmento de código do usuário e outro para o segmento de dados, no menor nível de privilégio (ring 3). Ainda haverá um descritor TSS, que auxilia na troca de contexto a nível de hardware. Por enquanto, apenas a entrada verde (descritor nulo) e as entradas pretas da imagem abaixo estão presentes no código atual.
O GDT é carregado através da instrução LGDT
, e passamos o endereço de uma estrutura especial em memória de 48 bits. Essa estrutura possui os 16 primeiros bits relacionado a quantos bytes a tabela ocupa, e pode ser calculada como o número de entradas multiplicado pelo tamanho da entrada (8 bytes ou 64 bits). Os próximos 32 bits são o endereço em memória do início da tabela, conforme apresentado na figura acima.
Definimos algumas constantes em assembly para representar os dois segmentos, de código e dados do kernel.
%define GDT_KCS_LIMIT 0xfffff
%define GDT_KCS_BASE 0x00000000
%define GDT_KCS_MIDDLE_LIMIT (GDT_KCS_LIMIT >> 16) & 0x0f ; lower 4 bits
%define GDT_KCS_FLAGS (1100b << 4) ; 0 - 0 - (1) 32bit - (1) 4Kb unit ; upper 4 bits
%define GDT_KCS_LIMIT_BYTES_0_1 GDT_KCS_LIMIT & 0xffff
%define GDT_KCS_BASE_BYTES_2_3 GDT_KCS_BASE & 0xffff
%define GDT_KCS_BASE_BYTE_4 (GDT_KCS_BASE >> 16) & 0xff
%define GDT_KCS_ACCESS_BYTE_5 10011010b ; (0) not used now - (1) readable - (0) user code can run here? - (1) code segment - (1) not system segment - (00) DPL/ring 0 - (1) present
%define GDT_KCS_MIDDLE_LIMIT_FLAGS_BYTE_6 GDT_KCS_MIDDLE_LIMIT | GDT_KCS_FLAGS
%define GDT_KCS_BASE_BYTE_7 (GDT_KCS_BASE >> 24) & 0xff
%define GDT_KDS_LIMIT GDT_KCS_LIMIT
%define GDT_KDS_BASE GDT_KCS_BASE
%define GDT_KDS_MIDDLE_LIMIT GDT_KCS_MIDDLE_LIMIT
%define GDT_KDS_FLAGS GDT_KCS_FLAGS
%define GDT_KDS_LIMIT_BYTES_0_1 GDT_KCS_LIMIT_BYTES_0_1
%define GDT_KDS_BASE_BYTES_2_3 GDT_KCS_BASE_BYTES_2_3
%define GDT_KDS_BASE_BYTE_4 GDT_KCS_BASE_BYTE_4
%define GDT_KDS_ACCESS_BYTE_5 10010010b ; 0 (not used now) - (1) writable - (0) expand down - (0) data segment - (1) not system segment - (00) DPL/ring 0 - (1) present
%define GDT_KDS_MIDDLE_LIMIT_FLAGS_BYTE_6 GDT_KCS_MIDDLE_LIMIT_FLAGS_BYTE_6
%define GDT_KDS_BASE_BYTE_7 GDT_KCS_BASE_BYTE_7
Não se assuste com as operações de shift e máscara, não é tão difícil assim de entender. Por exemplo, a constante GDT_KCS_MIDDLE_LIMIT.
Eu pretendo colocar esse valor nos primeiros 4 bits do byte representado pelos bits 48 a 55. O interesse é pegar os bits 16 a 19 do valor original de limite (0xfffff
), que é o primeiro f mais a esquerda desse número. Seria então o 0xf0000
. Mas esse f
está na posição errada, ele precisa estar nos primeiros 4 bits, portanto a operação shift right 16: GDT_KCS_LIMIT >> 16
. Por fim, chegamos ao valor 0x0000f
, a operação está encerrada. Contudo, se o número fosse maior que 0xfffff
(por exemplo, se alguém usasse todo os 32 bits de um registrador e definisse o limite como 0xffffffff
), poderia haver bits mais significativos que não compõe o número original. Para garantir que isso não vai ocorrer, fazemos uma operação de AND bitwise com o valor 0x0f
. Assim, preservamos os 4 primeiros bits e o restante é zerado. Então a operação abaixo
%define GDT_KCS_MIDDLE_LIMIT (GDT_KCS_LIMIT >> 16) & 0x0f ; lower 4 bits
descara os 16 primeiros bits menos significativos, e desse novo número resultante, ele preserva os 4 primeiros bits menos significativos e zera os outros. Conseguimos então carregar esse byte nos bits 48 a 55 da entrada do GDT, sendo que os que possuem valor de fato são os bits 48 a 51, que são os bits de limite. Esse valor vai ser composto com a constante GDT_KCS_FLAGS
para compor o valor do byte completo.
Outro ponto interessante de notar é que os valores de segmento de código do kernel são idênticos ao segmento de dados, com exceção do byte 5, que altera o tipo de segmento.
Definimos a estrutura do GDT, seus valores e o ponteiro para essa tabela no arquivo bootloader.asm da seguinte forma:
gdt:
gdt_null:
dq 0
gdt_kcs:
dw GDT_KCS_LIMIT_BYTES_0_1, GDT_KCS_BASE_BYTES_2_3
db GDT_KCS_BASE_BYTE_4, GDT_KCS_ACCESS_BYTE_5 , GDT_KCS_MIDDLE_LIMIT_FLAGS_BYTE_6, GDT_KCS_BASE_BYTE_7
gdt_kds:
dw GDT_KDS_LIMIT_BYTES_0_1, GDT_KDS_BASE_BYTES_2_3
db GDT_KDS_BASE_BYTE_4, GDT_KDS_ACCESS_BYTE_5 , GDT_KDS_MIDDLE_LIMIT_FLAGS_BYTE_6, GDT_KDS_BASE_BYTE_7
gdt_end:
gdt_pointer:
dw gdt_end - gdt - 1
dd gdt+(NEW_BOOTLOADER_SEGMENT << 4)
Aqui também, sem surpresa. Temos o descritor nulo, e os descritores do segmento de código e dados do kernel, com base nas constantes que mostramos antes. Definimos então o ponteiro do gdt, definindo o tamanho em bytes e o ponteiro para a tabela. Mas temos uma pegadinha: note que o endereço foi adicionado a um valor. NEW_BOOTLOADER_SEGMENT
é 0x8000, NEW_BOOTLOADER_SEGMENT << 4
é 0x80000.
Isso é porque o GDT precisa do endereço absoluto na memória. Se usarmos só o endereço gdt, o assembly tentará calcular esse deslocamento relativo ao código atual. Isso só funcionaria se nosso código começasse no endereço 0, mas esse código é o bootloader, que movemos para o endereço 0x80000
.
Não está no código, mas se você quisesse chumbar os valores direto, você teria algo do tipo.
gdt:
gdt_null:
dq 0
gdt_kcs:
dq 0x00cf9a000000ffff
gdt_kds:
dq 0x00cf92000000ffff
gdt_end:
gdt_pointer:
dw gdt_end - gdt - 1
dd gdt+(NEW_BOOTLOADER_SEGMENT << 4)
Eu acho pior dessa forma pois é difícil decodificar esses magic numbers depois. Mas ai vai de você.
Para finalizar, temos que carregar o GDT, ativar o modo protegido, recarregar os registradores de segmentos para que eles apontem para os descritores do GDT e fazer um far jump para o kernel. Importante, as interrupções precisam estar desabilitadas para esse procedimento. E depois que pousarmos no modo protegido no kernel, não teremos mais interrupções da BIOS disponíveis, e estaremos por conta própria.
...
a20_success:
; clear interruptions. BIOS INT will not be accessible anymore!
cli
lgdt [gdt_pointer]
mov eax, cr0
or eax, 0x1
mov cr0, eax
mov ax, gdt_kds - gdt
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, (STACK_SEG_BOOTLOADER << 4) + STACK_POINTER_BOOTLOADER
mov ebp, esp
jmp gdt_kcs - gdt:KERNEL_INIT
Se tudo der certo, o código do kernel já pode ser escrito em C/C++! Meu objetivo aqui é só saber se a configuração está certa, e estou escrevendo uma mensagem na tela. O arquivo kernel.cpp ficou assim.
#include "include/gdt.h"
#include "include/screen.h"
extern "C" void _start() {
install_gdt();
const char *kernelMsg = "Hello Protected Mode!";
clear_screen(RED, YELLOW);
printk((char *)kernelMsg, RED, YELLOW);
while (true) {
/* BUSY LOOP*/
}
}
Ignorem o arquivo gdt.h
e install_gdt()
por enquanto. O código é simples: a gente define uma mensagem que será mostrada, limpamos a tela e escrevemos tal mensagem na tela. Depois, ficamos em um loop infinito. O extern “C”
solicita ao compilador C++ para exportar o nome da função dessa forma mesmo _start, para o linker o encontrar. Isso porque no C++ existe um negócio chamado Name Mangling, para evitar o conflito e colisão de nomes as funções nos código objeto são alteradas. A função printk(char*, CharColors, CharColors)
se torna _Z6printkPc10CharColorsS0_.
Isso é suficiente para decodificar a função e seus argumentos, inclusive quando métodos de uma classe. O utilitário c++filt
ajuda nessa decodificação, teste no seu shell os comandos abaixo, e você perceberá o padrão:
$ c++filt -n _Z12clear_screen10CharColorsS_
clear_screen(CharColors, CharColors)
$ c++filt -n _ZN3GDT17install_gdt_entryEjjhh
GDT::install_gdt_entry(unsigned int, unsigned int, unsigned char, unsigned char)
Para testar nos seus programas C++ compilados, execute o objdump -D e veja os nomes das funções e decodifique usando o c++filt.
Vale a pena verificar as funções clear_screen
e printk
, já que você estava atento e sabe que não podemos usar a BIOS INT 13H
mais. Mas você tem sorte, se escrevermos em certos endereços da memória (no caso, a partir do endereço 0xB8000
), o que for escrito lá aparece na tela. Por padrão, o vídeo vem configurado em modo texto, e cada dois bytes a partir do endereço 0xB8000
representam um caractere (primeiro byte) e seus atributos como cor de frente e fundo (segundo byte). Essa memória de vídeo pode ser encarada como uma matrix de 80 linhas por 25 colunas, dois bytes por elemento. Para limpar a tela como é feito na função clear_screen
, basta criar dois laços e colocar o caracter ‘ ‘ na posição deseja, como fiz no código abaixo.
void clear_screen(CharColors foreground, CharColors background) {
reset_current_position();
for (int i = 0; i < NUM_ROWS; i++) {
for (int j = 0; j < NUM_COLUMNS; j++) {
*current_position++ = ' ';
*current_position++ = get_char_attr(foreground, background);
}
}
reset_current_position();
}
A função reset_current_position
apenas posiciona o ponteiro para o valor inicial de SCREEN_POINTER
(0xB8000
).
inline void reset_current_position() {
current_position = (char *)SCREEN_POINTER;
}
A função printk
AINDA não é similar a função printf da libC, ou seja, ela não processa o formato da mensagem, mas isso será feito eventualmente. Atualmente só percorremos a string até encontrar o caractere nulo.
void printk(char *msg, CharColors foreground, CharColors background) {
char *currentMsg = msg;
do {
if (*currentMsg == NULL) {
break;
}
*current_position++ = *currentMsg++;
*current_position++ = get_char_attr(foreground, background);
} while (*currentMsg != NULL);
}
Por fim, a função get_char_attr
prepara o byte de atributos, dado que especifiquemos as cores de frente e fundo do caractere. Os 4 primeiros bits (os menos significativos) são a cor de frente, e os quatro últimos (3 na verdade, o último bit mais significativo é uma outra coisa que não vem ao caso agora) representam a cor de fundo.
inline char get_char_attr(CharColors foreground, CharColors background) {
return (foreground & 0x0f) | (background << 4);
}
Se são 4 bits, então podemos ter 16 combinações diferentes de cores? Isso ai. 0000 é preto, 0001 é azul, 0010 é verde e assim por diante. Fiz uma enumeração para facilitar nossa vida.
enum CharColors {
BLACK,
BLUE,
GREEN,
CYAN,
RED,
PURPLE,
BROWN,
GRAY,
DARK_GRAY,
LIGHT_BLUE,
LIGHT_GREEN,
LIGHT_CYAN,
LIGHT_RED,
LIGHT_PURPLE,
YELLOW,
WHITE
};
Só faltou o bode na sala, a função install_gdt()
na função _start
, no arquivo kernel.cpp
. O GDT instalado no bootloader era temporário, mas nada o impedia de ser definitivo se adicionássemos as entradas de interesse lá. O problema na real é que o GDT lá estava na região de memória 0x80000
, mesma região do bootloader. Depois que passamos o controle para o kernel, essa memória pode (e vai) ser sobrescrevida pelos códigos do kernel e usuário.
Uma solução seria copiar aquela região de memória para uma estrutura de dados nos arquivos novos do kernel, mas teríamos que saber o endereço correto, e isso pode mudar com alterações do arquivo bootloader.asm. E como no futuro vamos instalar um GDT parrudo, para usuários e TSS também, o melhor é recriarmos a instalação do GDT, mas agora usando os benefícios de uma linguagem de alto nível.
Assim, eu criei um arquivo de cabeçalho gdt.h para declarar duas classes: GDT e GDTPointer, conforme o código a seguir:
class GDT {
public:
uint16_t limit_bytes_0_1 = 0;
uint16_t base_address_bytes_2_3 = 0;
uint8_t base_address_byte_4 = 0;
uint8_t type_access_byte_5 = 0;
uint8_t limit_flags_byte_6 = 0;
uint8_t base_address_byte_7 = 0;
void install_gdt_entry(uint32_t, uint32_t, uint8_t, uint8_t);
} __attribute__((__packed__));
class GDTPointer {
public:
uint16_t size = 0;
uint32_t gdt_entries_address = 0;
} __attribute__((__packed__));
Perceba que o GDT é muito “similar” à discussão que tivemos em assembly, mas agora com classes e método. A declaração __attribute__((__packed__))
solicita que o compilador gentilmente ignore o alinhamento dos tipos de dados da estrutura, ou seja, evita que o compilador adicione paddings. Se você não sabe do que eu estou falando, volte ao PDF do formato ELF, especificamente nesse trecho abaixo.
Enquanto nas estruturas do arquivo ELF é previsto que exista padding se necessário, de forma nenhuma isso pode ocorrer com nossa estrutura GDT, pois é esperado pelo hardware do processador que ela tenha esse formato rígido.
No arquivo gdt.cpp definimos as variáveis globais abaixo, garantindo portanto que essas variáveis estejam no domínio de memória do kernel.
GDT gdt_entries[GDT_ENTRIES];
GDTPointer gdtp;
A instalação de uma entrada é aquela coreografa de shifts e máscaras dito anteriormente.
void GDT::install_gdt_entry(uint32_t base_address, uint32_t limit, uint8_t type,
uint8_t flags) {
this->limit_bytes_0_1 = limit & 0xffff;
this->base_address_bytes_2_3 = base_address & 0xffff;
this->base_address_byte_4 = (base_address >> 16) & 0xff;
this->type_access_byte_5 = type;
this->limit_flags_byte_6 = (limit >> 16) & 0xf;
this->limit_flags_byte_6 |= flags;
this->base_address_byte_7 = (base_address >> 24) & 0xff;
}
A função install_gdt
, é, portanto, instalar as três primeiras entradas do GDT (descritores nulo, código e dados do kernel), carregar a estrutura do GDTPointer
e executar as instruções assembly inline para instalar esse novo GDT.
void install_gdt() {
// NULL descriptor (gdt_entries[GDT_NULL_SEL]) is constructed by default
// Kernel Code segment
gdt_entries[GDT_KCS_SEL].install_gdt_entry(
0x00000000, 0xfffff,
GDT_ACCESS_CODE_SEG_READ | GDT_TYPE_CODE_SEG_READ |
GDT_TYPE_CODE_SEG_NOT_CONFIRMING | GDT_TYPE_CODE_SEG |
GDT_TYPE_DESCRIPTOR_CODE_OR_DATA | GDT_RING_00 | GDT_TYPE_PRESENT,
GDT_FLAG_USER_DEFINED | GDT_LONG_MODE_DISABLED | GDT_32BIT_FLAG |
GDT_GRANULARITY_FLAG);
// Kernel Data segment
gdt_entries[GDT_KDS_SEL].install_gdt_entry(
0x00000000, 0xfffff,
GDT_ACCESS_DATA_SEG_WRITE | GDT_TYPE_DATA_SEG_WRITE |
GDT_TYPE_DATA_SEG_GROW_UPSIDE | GDT_TYPE_DATA_SEG |
GDT_TYPE_DESCRIPTOR_CODE_OR_DATA | GDT_RING_00 | GDT_TYPE_PRESENT,
GDT_FLAG_USER_DEFINED | GDT_LONG_MODE_DISABLED | GDT_32BIT_FLAG |
GDT_GRANULARITY_FLAG);
// User code, data segments and TSS goes here
// Load GDT
gdtp.size = (uint16_t)(sizeof(GDT) * GDT_ENTRIES);
gdtp.gdt_entries_address = (uint32_t)gdt_entries;
asm("lgdt %0" : : "m"(gdtp));
asm("ljmp %0, $continue_load_gdt_register \n\t"
"continue_load_gdt_register: \n\t" ::"i"(GDT_KCS_SEL * 0x8));
asm("mov %0, %%eax \n\t"
"mov %%ax, %%ds \n\t"
"mov %%ax, %%es \n\t"
"mov %%ax, %%fs \n\t"
"mov %%ax, %%gs \n\t"
"mov %%ax, %%ss \n\t" ::"i"(GDT_KDS_SEL * 0x8));
}
Se você tem dúvidas de que isso funciona, experimente comentar as chamadas gdt_entries[GDT_KCS_SEL].install_gdt_entry
e gdt_entries[GDT_KDS_SEL].install_gdt_entry
e execute o código, para verificar uma falha de segmentação ao vivo e em cores, a nível de kernel!
Fiz uma pequena alteração no Makefile, pois agora temos 3 arquivos cpp do kernel. Precisamos compilarmos e linkarmos, garantindo que o arquivo kernel.o seja o primeiro para que ele fique no endereço 0x1000 como inicialmente previsto.
OBJS:=screen.o gdt.o
OBJ_LINK_LIST:=kernel.o $(OBJS)
Para os arquivos .cpp
do kernel, precisamos indicar ao compilador que ele de forma alguma use runtime, stdlib ou similares, já que não há suporte a tais firulas quando o computador inicia.
%.o: $(SRCDIR)/%.cpp
g++ -g -m32 -c $< -o $(OUTPUTDIR)/$@ -ffreestanding -O2 -Wall -Wextra -fno-exceptions -nostdlib -fno-rtti -nostartfiles
...
kernel: $(OBJ_LINK_LIST)
cd $(OUTPUTDIR) && ld -nostdlib -O2 -g -m elf_i386 -Ttext 0x1000 -o $@ $(OBJ_LINK_LIST)
Por fim, todo aquele malabarismo para fazer o gdb mostrar as informações em modo real já não nos servirá (você pode voltar sempre que precisar debugar o bootloader). O arquivo debug_BoringOS.sh fica bem mais simples agora:
gdb -ex 'target remote localhost:1234' -ex 'hbreak *0x7c00' -ex 'set confirm off' -ex "add-symbol-file $CURRDIR/build/bootloader 0x80000" -ex "add-symbol-file $CURRDIR/build/kernel" -ex 'set confirm on' -ex 'c'
Contudo, ainda acho a visualização default do gdb bem ruim. Por isso, criei um arquivo ~/.gdbinit e coloquei esse código DAQUI. Fica bem melhor para ver as informações. Essa visualização fica mais ou menos como a imagem abaixo.
Se tudo deu certo, você será agraciado com a seguinte tela no QEMU.
Ufa, longa jornada hein? Agora é sobra e água fresca? Nada disso. As interrupções ainda desabilitadas, e assim que as habilitarmos, se não tivermos instalado o IDT (Interruption Descriptor Table, uma tabela de descritores muito parecido com o GDT, mas para o tratamento das interrupções), qualquer interrupçãozinha disparada (por exemplo, teclar uma tecla no teclado) vai fazer seu kernel capotar. Tem muita coisa a ser feita ainda, mas não nesse artigo. Acesso o código descrito até o momento AQUI.
Quem constrói os construtores?
Esse artigo terminou, mas fiz algumas tentativas infrutíferas que quero compartilhar. Inicialmente havia colocado um construtor no GDTPointer da seguinte forma:
class GDTPointer {
public:
uint16_t size = 0;
uint32_t gdt_entries_address = 0;
GDTPointer(uint16_t size, uint32_t address) {
this->size = size;
this->gdt_entries_address = address;
}
} __attribute__((__packed__));
Coisa boba né? Depois, alterei a declaração do objeto GDTPointer no arquivo gdt.cpp da seguinte forma:
GDTPointer gdtp((uint16_t)(sizeof(GDT) * GDT_ENTRIES), (uint32_t)gdt_entries);
Nada demais. Mas ao fazer isso, o código para de funcionar. O motivo inicial é que apareceu uma função no endereço 0x1000, e a função _start foi deslocada:
Symbol table '.symtab' contains 22 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FILE LOCAL DEFAULT ABS gdt.cpp
2: 00001228 0 NOTYPE LOCAL DEFAULT 1 continue_load_gd[...]
3: 00001000 35 FUNC LOCAL DEFAULT 1 _GLOBAL__sub_I_g[...]
4: 00000000 0 FILE LOCAL DEFAULT ABS kernel.cpp
5: 00000000 0 FILE LOCAL DEFAULT ABS screen.cpp
6: 00000000 0 FILE LOCAL DEFAULT ABS
7: 00003ff4 0 OBJECT LOCAL DEFAULT 5 _GLOBAL_OFFSET_TABLE_
8: 000010e0 157 FUNC GLOBAL DEFAULT 1 _Z12clear_screen[...]
9: 0000117d 0 FUNC GLOBAL HIDDEN 1 __x86.get_pc_thunk.ax
10: 0000400c 24 OBJECT GLOBAL DEFAULT 7 gdt_entries
11: 00001181 0 FUNC GLOBAL HIDDEN 1 __x86.get_pc_thunk.dx
12: 00001030 56 FUNC GLOBAL DEFAULT 1 _start
13: 00001068 0 FUNC GLOBAL HIDDEN 1 __x86.get_pc_thunk.bx
14: 00004000 4 OBJECT GLOBAL DEFAULT 6 current_position
15: 00004004 0 NOTYPE GLOBAL DEFAULT 7 __bss_start
Note que agora há a função _GLOBAL__sub_I_gdt_entries
no endereço 0x1000
, e a função _start
foi para o endereço 0x1030
. Pelo nome nem parece ser o construtor de GDTPointer
. Poderíamos alterar o createimage.cpp
para ler a tabela de símbolos e obter esse novo endereço e escrever no bootloader, tal qual fazemos com a variável os_size
, mas o problema é mais profundo que isso. Na verdade, só com essas alterações, se você mudar jmp gdt_kcs - gdt:0x1000
para jmp gdt_kcs - gdt:0x1030
, o código não funciona, e o motivo é que o construtor do GDTPointer
não é invocado.
Para usarmos construtores C++, até no kernel, precisamos fazer algumas alterações como dito AQUI, tal como fornecer arquivos crti.asm
e crtn.asm
. Ainda precisamos dos arquivos crtbegin.o e crtend.o fornecidos pelo compilador. Até instalei o pacote g++-multilib pois do contrário ele usará a versão de 64 bits por padrão e o linker irá reclamar:
sudo apt-get install g++-multilib
Fazendo o código abaixo (adaptado DAQUI):
SRCDIR=src
OUTPUTDIR=build
OBJS:=screen.o gdt.o
CRTI_OBJ=crti.o
CRTBEGIN_OBJ:=$(shell g++ -m32 $(CFLAGS) -print-file-name=crtbegin.o)
CRTEND_OBJ:=$(shell g++ -m32 $(CFLAGS) -print-file-name=crtend.o)
CRTN_OBJ=crtn.o
OBJ_LINK_LIST:=$(CRTI_OBJ) $(CRTBEGIN_OBJ) $(OBJS) $(CRTEND_OBJ) $(CRTN_OBJ)
all: $(OUTPUTDIR) kernel bootloader createimage
./$(OUTPUTDIR)/createimage --extended ./$(OUTPUTDIR)/bootloader ./$(OUTPUTDIR)/kernel
$(OUTPUTDIR):
mkdir $@
%.o: $(SRCDIR)/%.asm
nasm -wall -O2 -f elf32 -F dwarf -g -o $(OUTPUTDIR)/$@ $<
%.o: $(SRCDIR)/%.cpp
g++ -g -m32 -c $< -o $(OUTPUTDIR)/$@ -ffreestanding -O2 -Wall -Wextra -fno-exceptions -nostdlib -fno-rtti -nostartfiles -lg++
bootloader: bootloader.o
ld -nostdlib -O2 -g -m elf_i386 -Ttext 0x0 -o $(OUTPUTDIR)/$@ $(OUTPUTDIR)/$<
kernel: kernel.o $(OBJ_LINK_LIST)
cd $(OUTPUTDIR) && ld -nostdlib -O2 -g -m elf_i386 -Ttext 0x1000 --section-start .init=0x5000 -o $@ kernel.o $(OBJ_LINK_LIST)
createimage: $(SRCDIR)/createimage.cpp
g++ -o $(OUTPUTDIR)/$@ $<
clean:
rm -rf $(OUTPUTDIR)/*
Ainda assim não funcionou, o linker não encontra a função _init()
:
$ make clean && make
rm -rf build/*
g++ -g -m32 -c src/kernel.cpp -o build/kernel.o -ffreestanding -O2 -Wall -Wextra -fno-exceptions -nostdlib -fno-rtti -nostartfiles -lg++
nasm -wall -O2 -f elf32 -F dwarf -g -o build/crti.o src/crti.asm
g++ -g -m32 -c src/screen.cpp -o build/screen.o -ffreestanding -O2 -Wall -Wextra -fno-exceptions -nostdlib -fno-rtti -nostartfiles -lg++
g++ -g -m32 -c src/gdt.cpp -o build/gdt.o -ffreestanding -O2 -Wall -Wextra -fno-exceptions -nostdlib -fno-rtti -nostartfiles -lg++
nasm -wall -O2 -f elf32 -F dwarf -g -o build/crtn.o src/crtn.asm
cd build && ld -nostdlib -O2 -g -m elf_i386 -Ttext 0x1000 --section-start .init=0x5000 -o kernel kernel.o crti.o /usr/lib/gcc/x86_64-linux-gnu/13/32/crtbegin.o screen.o gdt.o /usr/lib/gcc/x86_64-linux-gnu/13/32/crtend.o crtn.o
ld: warning: crtn.o: missing .note.GNU-stack section implies executable stack
ld: NOTE: This behaviour is deprecated and will be removed in a future version of the linker
ld: kernel.o: in function `_start':
/home/p_8410/projects/boring-os/src/kernel.cpp:7:(.text+0x10): undefined reference to `_init()'
make: *** [Makefile:29: kernel] Error 1
Deve ser coisa boba, mas como não consegui resolver isso em um tempo razoável, se alguém souber como resolver isso, por favor me avise!