OS1: kernel primitivo no Rust para x86

Decidi escrever um artigo e, se possível, uma série de artigos para compartilhar minha experiência de pesquisa independente do dispositivo Bare Bone x86 e da organização de sistemas operacionais. No momento, meu hack não pode nem ser chamado de sistema operacional - é um pequeno kernel que pode inicializar a partir do Multiboot (GRUB), gerenciar memória real e virtual e também executar várias funções inúteis no modo multitarefa em um único processador.


Durante o desenvolvimento, eu não me propus a escrever um novo Linux (embora, admito, tenha sonhado com isso há 5 anos) ou impressionar alguém, por isso peço que você não pareça mais particularmente impressionado. O que eu realmente queria fazer era descobrir como a arquitetura i386 funciona no nível mais básico, e como exatamente os sistemas operacionais fazem sua mágica, e desenterrar o hype Rust.


Nas minhas anotações, tentarei compartilhar não apenas os textos de origem (eles podem ser encontrados no GitLab) e a teoria básica (pode ser encontrada em muitos recursos), mas também o caminho que percorri para encontrar respostas não óbvias. Especificamente, neste artigo, falarei sobre como criar um arquivo do kernel, carregá-lo e inicializá-lo .


Meus objetivos são estruturar as informações na minha cabeça e ajudar aqueles que seguem um caminho semelhante. Entendo que materiais e blogs semelhantes já estão na rede, mas, para chegar à minha situação atual, tive que reuni-los por um longo tempo. Todas as fontes (de qualquer forma, das quais me lembro), compartilharei agora.


Literatura e Fontes


Obviamente , obtive a maior parte do excelente recurso OSDev , tanto no wiki quanto no fórum. Em segundo lugar, vou nomear Philip Opperman com seu blog - muitas informações sobre o monte de ferrugem e ferro.


Alguns pontos são espionados no kernel do Linux, o Minix não está sem a ajuda de literatura especial, como o livro de Tanenbaum, “ Sistemas Operacionais. Design e implementação, livro de Robert Love“ O Linux Kernel. Descrição do processo de desenvolvimento . ” Questões difíceis sobre a organização da arquitetura x86 foram resolvidas usando o manual “ Manual do desenvolvedor de software das arquiteturas Intel 64 e IA-32, Volume 3 (3A, 3B, 3C e 3D): Guia de programação do sistema ”. No entendimento do formato dos binários, os layouts são guias para ld, llvm, nm, nasm, make.
UPD Agradeço ao CoreTeamTech por me lembrar do maravilhoso sistema Redox OS. Eu não saí de sua fonte . Infelizmente, o sistema oficial do GitLab não está disponível no IP russo, para que você possa ver o GitHub .


Outro Prefácio


Sei que não sou um bom programador em Rust, além disso, este é o meu primeiro projeto nesse idioma (não é a melhor maneira de começar a namorar, certo?). Portanto, a implementação pode parecer completamente incorreta para você - com antecedência, quero pedir clemência ao meu código e ficarei feliz em comentar e sugestões. Se um leitor respeitado puder me dizer onde e como seguir em frente, também ficarei muito agradecido. Alguns fragmentos de código podem ser copiados dos tutoriais como estão e levemente modificados, mas tentarei dar explicações tão claras quanto possível a essas seções, para que você não tenha as mesmas perguntas que eu tinha ao analisá-las. Também não pretendo usar as abordagens corretas no design; portanto, se meu gerenciador de memória faz com que você queira escrever comentários irritados, entendo o porquê.


Toolkit


Então, vou começar mergulhando nas ferramentas de desenvolvimento que usei. Como ambiente, escolhi um bom e conveniente editor de código VS com plug-ins para Rust e um depurador GDB. Às vezes, o código VS não é muito bom com o RLS, especialmente ao redefini-lo em um diretório específico; portanto, após cada atualização noturna do Rust, era necessário reinstalar o RLS.


A ferrugem foi escolhida por várias razões. Em primeiro lugar, sua crescente popularidade e filosofia agradável. Em segundo lugar, sua capacidade de trabalhar com um nível baixo, mas com uma menor probabilidade de "dar um tiro no próprio pé". Em terceiro lugar, como um amante de Java e Maven, sou muito viciado em criar sistemas e gerenciamento de dependências, e a carga já está incorporada na linguagem de ferramentas. Quarto, eu só queria algo novo, não como C.


Para código de baixo nível, usei o NASM, como Sinto-me confiante na sintaxe da Intel e também me sinto à vontade trabalhando com suas diretrizes. Eu deliberadamente abandonei as inserções do assembler no Rust para separar explicitamente o trabalho com ferro e lógica de alto nível.
A Make e o vinculador do suprimento LLVM LLD (como vinculador mais rápido e melhor) foram usados ​​como montagem e layout geral - isso é uma questão de gosto. Era possível fazer scripts de construção para carga.


O Qemu foi usado para iniciar - eu gosto da velocidade, do modo interativo e da capacidade de conectar o GDB. Para inicializar e ter imediatamente todas as informações de hardware - é claro que o GRUB (Legacy é mais fácil de organizar o cabeçalho, então pegue).


Link e layout


Curiosamente, para mim, esse acabou sendo um dos tópicos mais difíceis. Após um longo período de teste com registros de segmento x86, ficou extremamente difícil perceber que segmentos e seções não são a mesma coisa. Na programação para o ambiente existente, não há necessidade de pensar em como colocar o programa na memória - para cada plataforma e formato, o vinculador já possui uma receita pronta, portanto, não há necessidade de escrever um script de vinculador.


Para o ferro nu, pelo contrário, é necessário indicar como colocar e endereçar o código do programa na memória. Aqui, quero enfatizar que estamos falando de um endereço linear (virtual) usando o mecanismo de página. O OS1 usa um mecanismo de página, mas vou me debruçar sobre ele separadamente na seção correspondente do artigo.


Lógico, linear, virtual, físico ...

Endereços lógicos, lineares, virtuais e físicos. Eu quebrei minha cabeça nessa questão, então, pelos detalhes que quero abordar neste excelente artigo


Para sistemas operacionais que usam paginação, em um ambiente de 32 bits, cada tarefa possui 4 GB de espaço de memória endereçável, mesmo se você tiver 128 MB de RAM instalados. Isso acontece apenas devido à organização de paginação da memória; a ausência de páginas na memória principal é tratada de acordo.


No entanto, na realidade, os aplicativos geralmente estão disponíveis um pouco menos que 4 GB. Isso ocorre porque o sistema operacional deve lidar com interrupções, chamadas do sistema, o que significa que pelo menos seus manipuladores devem estar nesse espaço de endereço. Estamos diante da pergunta: onde exatamente nesses 4 GB os endereços do kernel devem ser colocados para que os programas funcionem corretamente?


No mundo moderno dos programas, esse conceito é usado: cada tarefa acredita que reina supremamente sobre o processador e é o único programa em execução no computador (neste estágio, não estamos falando de comunicação entre processos). Se você observar exatamente como os compiladores coletam programas no estágio de vinculação, acontece que eles começam com um endereço linear de zero ou quase zero. Isso significa que, se a imagem do kernel ocupar um espaço de memória próximo de zero, os programas montados dessa maneira não podem ser executados, qualquer instrução jmp no programa levará à entrada na memória protegida do kernel e a um erro de proteção. Portanto, se quisermos usar não apenas programas auto-escritos no futuro, é razoável fornecer ao aplicativo o máximo de memória possível perto de zero e colocar a imagem do kernel mais alta.


Esse conceito é chamado de metade superior do kernel (aqui eu o refiro ao osdev.org, se você quiser informações relacionadas). Qual pedaço de memória escolher depende apenas do seu apetite. 512 MB é suficiente para alguém, mas eu decidi comprar 1 GB, então meu kernel está localizado em 3 GB + 1 MB (é necessário + 1 MB para atender aos limites de memória mais altos, o GRUB nos carrega na memória física após 1 MB) .
Também é importante especificar o ponto de entrada para o nosso arquivo executável. Para o meu executável, essa será a função _loader escrita em assembler, na qual abordarei mais detalhadamente na próxima seção.


Sobre ponto de entrada

Você sabia que mentiu a vida toda sobre o fato de que main () é o ponto de entrada para o programa? De fato, main () é uma convenção da linguagem C e das linguagens geradas por ela. Se você cavar, acontece algo como o seguinte.


Primeiramente, cada plataforma possui sua própria especificação e nome de ponto de entrada: para linux, geralmente é _start, para Windows, é mainCRTStartup. Em segundo lugar, esses pontos podem ser redefinidos, mas não funcionarão para usar as delícias da libc. Terceiro, o compilador fornece esses pontos de entrada por padrão e eles estão nos arquivos crt0..crtN (CRT-C RunTime, N - número de argumentos principais).


Na verdade, o que compiladores como gcc ou vc fazem - eles selecionam um script de link específico da plataforma que define um ponto de entrada padrão, selecionam o arquivo de objeto desejado com a função de inicialização de inicialização C pronta e chamam a função principal e vinculam a saída na forma de um arquivo do formato desejado com um ponto de entrada padrão.


Portanto, para nossos propósitos, o ponto de entrada padrão e a inicialização do CRT devem ser desativados, pois não temos absolutamente nada além de ferro puro.


O que mais você precisa saber para vincular? Como serão localizadas as seções de dados (.rodata, .data), variáveis ​​não inicializadas (.bss, comum) e também lembre-se de que o GRUB exige a localização de cabeçalhos de inicialização múltipla nos primeiros 8 KB do binário.


Então agora podemos escrever um script vinculador!


ENTRY(_loader) OUTPUT_FORMAT(elf32-i386) SECTIONS { . = 0xC0100000; .text ALIGN(4K) : AT(ADDR(.text) - 0xC0000000) { *(.multiboot1) *(.multiboot2) *(.text) } .rodata ALIGN(4K) : AT(ADDR(.rodata) - 0xC0000000) { *(.rodata*) } .data ALIGN (4K) : AT(ADDR(.data) - 0xC0000000) { *(.data) } .bss : AT(ADDR(.bss) - 0xC0000000) { _sbss = .; *(COMMON) *(.bss) _ebss = .; } } 

Download após o GRUB


Como mencionado acima, a especificação de inicialização múltipla exige que o cabeçalho esteja nos primeiros 8 KB da imagem de inicialização. A especificação completa pode ser vista aqui , mas vou me concentrar apenas nos detalhes de interesse.


  • O alinhamento de 32 bits (4 bytes) deve ser respeitado
  • Deve haver um número mágico 0x1BADB002
  • É necessário informar ao multibooter quais informações queremos receber e como colocar os módulos (no meu caso, quero que o módulo do kernel seja alinhado em uma página de 4 KB e também receba um cartão de memória para economizar tempo e esforço)
  • Forneça uma soma de verificação (soma de verificação + número mágico + sinalizadores devem dar zero)

 MB1_MODULEALIGN equ 1<<0 MB1_MEMINFO equ 1<<1 MB1_FLAGS equ MB1_MODULEALIGN | MB1_MEMINFO MB1_MAGIC equ 0x1BADB002 MB1_CHECKSUM equ -(MB1_MAGIC + MB1_FLAGS) section .multiboot1 align 4 dd MB1_MAGIC dd MB1_FLAGS dd MB1_CHECKSUM 

Após a inicialização, o Multiboot garante algumas condições que devemos considerar.


  • O registro EAX contém o número mágico 0x2BADB002, que indica que o download foi bem-sucedido
  • O registro EBX contém o endereço físico da estrutura com informações sobre os resultados do carregamento (falaremos sobre isso mais adiante)
  • O processador está no modo protegido, a memória da página é desativada, os registradores de segmento e a pilha estão em um estado indefinido (para nós), o GRUB os usou para suas necessidades e precisa ser redefinido o mais rápido possível.

A primeira coisa que precisamos fazer é ativar a paginação, ajustar a pilha e, finalmente, transferir o controle para o código Rust de alto nível.
Não vou me deter em detalhes na organização das páginas da memória, no Diretório de páginas e na Tabela de páginas, porque excelentes artigos foram escritos sobre isso ( um deles ). A principal coisa que quero compartilhar é que as páginas não são segmentos! Por favor, não repita meu erro e não carregue o endereço da tabela de páginas no GDTR! Para a tabela de páginas é CR3! A página pode ter um tamanho diferente em arquiteturas diferentes. Para simplificar o trabalho (para ter apenas uma tabela de páginas), escolhi um tamanho de 4 MB devido à inclusão do PSE.


Então, queremos ativar a memória da página virtual. Para fazer isso, precisamos de uma tabela de páginas e seu endereço físico carregados no CR3. Ao mesmo tempo, nosso arquivo binário foi vinculado para funcionar em um espaço de endereço virtual com um deslocamento de 3 GB. Isso significa que todos os endereços e rótulos variáveis ​​têm um deslocamento de 3 GB. A tabela de páginas é apenas uma matriz na qual o endereço da página contém seu endereço real, alinhado ao tamanho da página, bem como sinalizadores de acesso e status. Como uso páginas de 4 MB, preciso apenas de uma tabela de páginas PD com 1024 entradas:


 section .data align 0x1000 BootPageDirectory: dd 0x00000083 times (KERNEL_PAGE_NUMBER - 1) dd 0 dd 0x00000083 times (1024 - KERNEL_PAGE_NUMBER - 1) dd 0 

O que há na mesa?


  1. A primeira página deve levar à seção atual do código (0 a 4 MB de memória física), pois todos os endereços no processador são físicos e a conversão para virtual ainda não foi realizada. A ausência deste descritor de página levará a uma falha imediata, pois o processador não poderá seguir as próximas instruções depois de ativar as páginas. Sinalizadores: bit 0 - a tabela está presente, bit 1 - a página está escrita, bit 7 - tamanho da página 4 MB. Depois de ligar as páginas, o registro é redefinido.
  2. Pule até 3 GB - zeros garantem que a página não esteja na memória
  3. A marca de 3 GB é o nosso núcleo na memória virtual, referenciando 0 na memória física. Depois de virar as páginas, trabalharemos aqui. Os sinalizadores são semelhantes ao primeiro registro.
  4. Pule até 4 GB.

Então, declaramos a tabela e agora queremos carregar seu endereço físico no CR3. Não se esqueça do deslocamento de endereço de 3 GB no estágio de vinculação. Tentar carregar o endereço como ele está nos enviará para o endereço real de 3 GB + deslocamento variável e levará a uma falha imediata. Portanto, pegamos o endereço do BootPageDirectory e subtraímos 3 GB dele, colocando-o no CR3. Ativamos o PSE no registro CR4, ativamos o trabalho com as páginas no registro CR0:


  mov ecx, (BootPageDirectory - KERNEL_VIRTUAL_BASE) mov cr3, ecx mov ecx, cr4 or ecx, 0x00000010 mov cr4, ecx mov ecx, cr0 or ecx, 0x80000000 mov cr0, ecx 

Até agora, tudo está indo bem, mas assim que redefinirmos a primeira página para finalmente chegar à metade superior de 3 GB, tudo entrará em colapso, porque o registro EIP ainda tem um endereço físico na região do primeiro megabyte. Para consertar isso, realizamos uma manipulação simples: coloque uma marca no local mais próximo, carregue seu endereço (ele já está com um deslocamento de 3 GB, lembre-se disso) e faça um salto incondicional por ele. Depois disso, uma página desnecessária pode ser redefinida para aplicativos futuros.


  lea ecx, [StartInHigherHalf] jmp ecx StartInHigherHalf: mov dword [BootPageDirectory], 0 invlpg [0] 

Agora é tudo muito pequeno: inicialize a pilha, passe a estrutura do GRUB e o montador é suficiente!


  mov esp, stack+STACKSIZE push eax push ebx lea ecx, [BootPageDirectory] push ecx call kmain hlt section .bss align 32 stack: resb STACKSIZE 

O que você precisa saber sobre esse trecho de código:


  1. De acordo com a convenção C de chamadas (também é aplicável ao Rust), as variáveis ​​são transferidas para a função através da pilha na ordem inversa. Todas as variáveis ​​são alinhadas por 4 bytes em x86.
  2. A pilha cresce a partir do final, portanto, o ponteiro para a pilha deve levar ao final da pilha (adicione STACKSIZE ao endereço). O tamanho da pilha que peguei foi de 16 KB, deve ser suficiente.
  3. O seguinte é transferido para o kernel: o número mágico da inicialização múltipla, o endereço físico da estrutura do gerenciador de inicialização (existe um cartão de memória valioso para nós), o endereço virtual da tabela de páginas (em um espaço de 3 GB)

Além disso, não se esqueça de declarar que o kmain é externo e o _loader é global.


Passos adicionais


Nas notas a seguir, falarei sobre a configuração de registros de segmentos, examinarei brevemente a saída de informações através de um buffer VGA, mostrarei como organizei o trabalho com interrupções, gerenciamento de páginas e a coisa mais doce - multitarefa - que deixarei para a sobremesa.


O código completo do projeto está disponível no GitLab .


Obrigado pela atenção!


UPD2: Parte 2
UPD2: Parte 3

Source: https://habr.com/ru/post/pt445506/


All Articles