Primeira parte
O primeiro artigo ainda não tinha tempo para esfriar, mas decidi não mantê-lo intrigante e escrever uma sequência.
Portanto, no artigo anterior, falamos sobre vincular, carregar o arquivo do kernel e a inicialização primária. Forneci alguns links úteis, contei como o kernel carregado está localizado na memória, como os endereços virtuais e físicos são comparados no momento da inicialização e como habilitar o suporte ao mecanismo de página. Por fim, o controle passou para a função kmain do meu kernel, escrita em Rust. É hora de seguir em frente e descobrir a profundidade da toca do coelho!
Nesta parte das notas , descreverei brevemente minha configuração de Rust; em termos gerais, falarei sobre a saída de informações em VGA e em detalhes sobre a configuração de segmentos e interrupções . Peço a todos os interessados sob o corte, e começamos.
Configuração de ferrugem
Em geral, não há nada de particularmente complicado nesse procedimento. Para obter detalhes, entre em contato com o blog Philippe . No entanto, vou parar em alguns pontos.
O Stable Rust ainda não suporta alguns recursos necessários para o desenvolvimento de baixo nível; portanto, para desativar a biblioteca padrão e desenvolver o Bare Bones, precisamos do Rust todas as noites. Tenha cuidado, uma vez que após atualizar para o mais recente, obtive um compilador completamente inoperante e tive que reverter para o mais próximo estável. Se você tiver certeza de que seu compilador estava funcionando ontem, mas atualizado e não funciona, execute o comando, substituindo a data que você precisa
rustup override add nightly-YYYY-MM-DD
Para detalhes do mecanismo, entre em contato aqui .
Em seguida, configure a plataforma de destino para a qual estamos indo. Eu estava baseado no blog de Philip Opperman; muitas coisas nesta seção foram tiradas dele, desmontadas por ossos e adaptadas às minhas necessidades. Philip está desenvolvendo para x64 em seu blog, eu originalmente escolhi x32, então meu target.json será um pouco diferente. Eu trago completamente
{ "llvm-target": "i686-unknown-none", "data-layout": "em:ep:32:32-f64:32:64-f80:32-n8:16:32-S128", "arch": "x86", "target-endian": "little", "target-pointer-width": "32", "target-c-int-width": "32", "os": "none", "executables": true, "linker-flavor": "ld.lld", "linker": "rust-lld", "panic-strategy": "abort", "disable-redzone": true, "features": "-mmx,-sse,+soft-float" }
A parte mais difícil aqui é o parâmetro " layout de dados ". A documentação do LLVM nos diz que essas são opções de layout de dados, separadas por "-". O primeiro personagem "e" é responsável pela índole - no nosso caso, é pouco endian, como a plataforma exige. O segundo caractere é m, "distorção". Responsável pelos nomes dos personagens durante o layout. Como nosso formato de saída será ELF (consulte o script de construção), selecionamos "m: e". O terceiro caractere é o tamanho do ponteiro em bits e ABI (Application binary interface). Tudo é simples aqui, temos 32 bits, então colocamos corajosamente “p: 32: 32”. A seguir, são apresentados os números de ponto flutuante. Estamos relatando que suportamos números de 64 bits de acordo com a ABI 32 com alinhamento 64 - "f64: 32: 64", bem como números de 80 bits com alinhamento por padrão - "f80: 32". O próximo elemento é números inteiros. Começamos com 8 bits e passamos para a plataforma, no máximo, 32 bits - “n8: 16: 32”. O último é o alinhamento da pilha. Eu até preciso de números inteiros de 128 bits, então seja S128. De qualquer forma, o LLVM pode ignorar com segurança esse parâmetro, essa é a nossa preferência.
Quanto aos demais parâmetros, você pode espiar Philip, ele explica tudo bem.
Também precisamos do cargo-xbuild - uma ferramenta que permite a compilação cruzada do núcleo de ferrugem ao construir sob uma plataforma de destino desconhecida.
Instale.
cargo install cargo-xbuild
Vamos coletá-lo assim.
cargo xbuild -Z unstable-options --manifest-path=kernel/Cargo.toml --target kernel/targets/$(ARCH).json --out-dir=build/lib
Eu precisava de um manifesto para a operação correta do Make, já que ele inicia no diretório raiz e o kernel está no diretório do kernel.
Entre os recursos do manifesto, posso destacar apenas o tipo de caixa = ["staticlib"] , que fornece um arquivo vinculável à saída. Vamos alimentá-lo no LLD.
kmain e configuração inicial
De acordo com as convenções do Rust, se criarmos uma biblioteca estática (ou um arquivo binário "simples"), a raiz do engradado deverá conter o arquivo lib.rs, que é o ponto de entrada. Nele, com a ajuda de atributos, os recursos de idioma são configurados e também o kmain estimado.
Portanto, na primeira etapa, precisaremos desativar a biblioteca std. Isso é feito com uma macro.
#![no_std]
Com um passo tão simples, esquecemos imediatamente sobre multithreading, memória dinâmica e outras delícias da biblioteca padrão. Além disso, até nos privamos da macro println!, Portanto teremos que implementá-la nós mesmos. Vou lhe dizer como fazê-lo na próxima vez.
Muitos tutoriais em algum lugar deste lugar terminam com a saída de "Hello World" e sem explicar como viver. Vamos para o outro lado. Primeiro, precisamos definir segmentos de código e dados para o modo protegido, configurar VGA, configurar interrupções, o que faremos.
#![no_std] #[macro_use] pub mod debug; #[cfg(target_arch = "x86")] #[path = "arch/i686/mod.rs"] pub mod arch; #[no_mangle] extern "C" fn kmain(pd: usize, mb_pointer: usize, mb_magic: usize) { arch::arch_init(pd); ...... } #[panic_handler] fn panic(_info: &PanicInfo) -> ! { println!("{}", _info); loop {} }
O que está acontecendo aqui? Como eu disse, desligamos a biblioteca padrão. Também anunciaremos dois módulos muito importantes - debug (no qual escreveremos na tela) e arch (no qual toda a magia dependente da plataforma permanecerá). Eu uso o recurso Rust com configurações para declarar as mesmas interfaces em diferentes implementações de arquitetura e usá-las ao máximo. Aqui eu paro apenas no x86 e depois falamos apenas sobre isso.
Eu declarei um manipulador de pânico completamente primitivo, que Rust exige. Então será possível modificá-lo.
O kmain aceita três argumentos e também é exportado na notação C sem distorção de nome, para que o vinculador possa associar corretamente a função à chamada de _loader, que descrevi no artigo anterior. O primeiro argumento é o endereço da tabela da página PD, o segundo é o endereço físico da estrutura do GRUB, de onde obteremos o cartão de memória, o terceiro é o número mágico. No futuro, gostaria de implementar o suporte ao Multiboot 2 e meu próprio gerenciador de inicialização, portanto, uso um número mágico para identificar o método de inicialização.
A primeira chamada do kmain é a inicialização específica da plataforma. Nós vamos para dentro. A função arch_init está localizada no arquivo arch / i686 / mod.rs, é pública, específica ao x86 de 32 bits e tem a seguinte aparência:
pub fn arch_init(pd: usize) { unsafe { vga::VGA_WRITER.lock().init(); gdt::setup_gdt(); idt::init_idt(); paging::setup_pd(pd); } }
Como você pode ver, para x86, a saída, a segmentação, as interrupções e a paginação são inicializadas em ordem. Vamos começar com VGA.
Inicialização VGA
Cada tutorial considera seu dever imprimir o Hello World, para que você descubra como trabalhar com o VGA em qualquer lugar. Por esse motivo, irei o mais brevemente possível, focarei apenas nos chips que fiz. Sobre o uso de lazy_static, enviarei você para o blog de Philippe e não explicarei em detalhes. O const fn ainda não está no lançamento, portanto, ainda é possível inicializar maravilhosamente estático. E adicionaremos um bloqueio de rotação para que não se torne uma bagunça.
use lazy_static::lazy_static; use spin::Mutex; lazy_static! { pub static ref VGA_WRITER : Mutex<Writer> = Mutex::new(Writer { cursor_position: 0, vga_color: ColorCode::new(Color::LightGray, Color::Black), buffer: unsafe { &mut *(0xC00B8000 as *mut VgaBuffer) } }); }
Como você sabe, o buffer da tela está localizado no endereço físico 0xB8000 e possui um tamanho de 80x25x2 bytes (largura e altura da tela, byte por caractere e atributos: cores, tremulação). Como já ativamos a memória virtual, o acesso a esse endereço trava, por isso, adicionamos 3 GB. Também desreferenciamos um ponteiro bruto, o que não é seguro - mas sabemos o que estamos fazendo.
Uma das coisas interessantes deste arquivo, talvez, é apenas a implementação da estrutura do Writer, que permite não apenas exibir caracteres em uma linha, mas também rolar, ir para qualquer lugar na tela e outras coisas agradáveis.
Escritor VGA pub struct Writer { cursor_position: usize, vga_color: ColorCode, buffer: &'static mut VgaBuffer, } impl Writer { pub fn init(&mut self) { let vga_color = self.vga_color; for y in 0..(VGA_HEIGHT - 1) { for x in 0..VGA_WIDTH { self.buffer.chars[y * VGA_WIDTH + x] = ScreenChar { ascii_character: b' ', color_code: vga_color, } } } self.set_cursor_abs(0); } fn set_cursor_abs(&mut self, position: usize) { unsafe { outb(0x3D4, 0x0F); outb(0x3D5, (position & 0xFF) as u8); outb(0x3D4, 0x0E); outb(0x3D4, ((position >> 8) & 0xFF) as u8); } self.cursor_position = position; } pub fn set_cursor(&mut self, x: usize, y: usize) { self.set_cursor_abs(y * VGA_WIDTH + x); } pub fn move_cursor(&mut self, offset: usize) { self.cursor_position = self.cursor_position + offset; self.set_cursor_abs(self.cursor_position); } pub fn get_x(&mut self) -> u8 { (self.cursor_position % VGA_WIDTH) as u8 } pub fn get_y(&mut self) -> u8 { (self.cursor_position / VGA_WIDTH) as u8 } pub fn scroll(&mut self) { for y in 0..(VGA_HEIGHT - 1) { for x in 0..VGA_WIDTH { self.buffer.chars[y * VGA_WIDTH + x] = self.buffer.chars[(y + 1) * VGA_WIDTH + x] } } for x in 0..VGA_WIDTH { let color_code = self.vga_color; self.buffer.chars[(VGA_HEIGHT - 1) * VGA_WIDTH + x] = ScreenChar { ascii_character: b' ', color_code } } } pub fn ln(&mut self) { let next_line = self.get_y() as usize + 1; if next_line >= VGA_HEIGHT { self.scroll(); self.set_cursor(0, VGA_HEIGHT - 1); } else { self.set_cursor(0, next_line) } } pub fn write_byte_at_xy(&mut self, byte: u8, color: ColorCode, x: usize, y: usize) { self.buffer.chars[y * VGA_WIDTH + x] = ScreenChar { ascii_character: byte, color_code: color } } pub fn write_byte_at_pos(&mut self, byte: u8, color: ColorCode, position: usize) { self.buffer.chars[position] = ScreenChar { ascii_character: byte, color_code: color } } pub fn write_byte(&mut self, byte: u8) { if self.cursor_position >= VGA_WIDTH * VGA_HEIGHT { self.scroll(); self.set_cursor(0, VGA_HEIGHT - 1); } self.write_byte_at_pos(byte, self.vga_color, self.cursor_position); self.move_cursor(1); } pub fn write_string(&mut self, s: &str) { for byte in s.bytes() { match byte { 0x20...0xFF => self.write_byte(byte), b'\n' => self.ln(), _ => self.write_byte(0xfe), } } } }
Ao rebobinar, basta copiar seções da memória do tamanho da largura da tela para trás, preenchendo com espaços em branco uma nova linha (é assim que eu faço a limpeza). As chamadas de saída são um pouco mais interessantes - de nenhuma outra maneira que trabalhar com portas de E / S, é impossível mover o cursor. No entanto, ainda precisamos de entrada / saída via portas, para que elas tenham sido entregues em um pacote separado e envoltas em invólucros seguros. Abaixo do spoiler abaixo está o código do assembler. Por enquanto, basta saber isso:
- O deslocamento absoluto do cursor, não a coordenada, é exibido.
- Você pode enviar para o controlador um byte de cada vez
- A saída de um byte ocorre em dois comandos - primeiro, escrevemos o comando no controlador, depois os dados.
- A porta para comandos é 0x3D4, a porta de dados é 0x3D5
- Primeiro, imprima o byte inferior da posição com o comando 0x0F, depois o topo com o comando 0x0E
out.asmPreste atenção ao trabalho com variáveis passadas na pilha. Como a pilha começa no final do espaço e reduz o ponteiro da pilha ao chamar a função, para obter parâmetros, um ponto de retorno, etc., você precisa adicionar o tamanho do argumento alinhado com o alinhamento da pilha ao registro ESP, no nosso caso 4 bytes.
global writeb global writew global writed section .text writeb: push ebp mov ebp, esp mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes) mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 byte value aligned 4 bytes out dx, al ;write byte by port number an dx - value in al mov esp, ebp pop ebp ret writew: push ebp mov ebp, esp mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes) mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 word value aligned 4 bytes out dx, ax ;write word by port number an dx - value in ax mov esp, ebp pop ebp ret writed: push ebp mov ebp, esp mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes) mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 double word value aligned 4 bytes out dx, eax ;write double word by port number an dx - value in eax mov esp, ebp pop ebp ret
Configuração de segmento
Chegamos ao ponto mais intrigante, mas ao mesmo tempo o tópico mais simples. Como eu disse em um artigo anterior, a organização da página e do segmento de memória estava misturada na minha cabeça, carreguei o endereço da tabela de páginas no GDTR e agarrei minha cabeça. Levei vários meses para ler o material o suficiente, digeri-lo e poder percebê-lo. Eu posso ter sido vítima do livro de Peter Abel, Assembler. A linguagem e programação para o IBM PC ”(um ótimo livro!), Que descreve a segmentação para o Intel 8086. Nesses momentos agradáveis, carregamos os 16 bits superiores de um endereço de vinte bits no registro de segmento, e esse era o endereço na memória. Acabou sendo uma decepção cruel que, a partir do i286 no modo protegido, tudo estivesse completamente errado.
Portanto, a teoria básica é que o x86 suporta um modelo de memória segmentada, pois os programas mais antigos só podiam ultrapassar 640 KB e, em seguida, 1 MB de memória.
Os programadores tiveram que pensar em como inserir código executável, como inserir dados e como manter sua segurança. O advento da organização da página tornou desnecessária a organização segmentada, mas ela permaneceu com o objetivo de compatibilidade e proteção (separação de privilégios para o espaço do kernel e o espaço do usuário); portanto, sem ela, não é lugar nenhum. Algumas instruções do processador são proibidas quando o nível de privilégio é menor que 0 e o acesso entre os segmentos do programa e do kernel causará um erro de segmentação.
Vamos fazer de novo (espero que no último) sobre tradução de endereços
Endereço da linha [0x08: 0xFFFFFFFF] -> Verificar permissões do segmento 0x08 -> Endereço virtual [0xFFFFFFFF] -> tabela da página + TLB -> endereço físico [0xAAAAFFFF]
Um segmento é usado apenas dentro do processador, é armazenado em um registro de segmento especial (CS, SS, DS, ES, FS, GS) e é usado exclusivamente para verificar os direitos de execução de código e controle de transferência. É por isso que você não pode simplesmente pegar e chamar a função kernel do espaço do usuário. O segmento com o descritor 0x18 (eu tenho um, o seu é diferente) possui direitos de nível 3, e o segmento com o descritor 0x08 possui direitos de nível 0. De acordo com a convenção x86, para proteger contra acesso não autorizado, um segmento com menos privilégios não pode chamar diretamente um segmento com grande número. direitos via jmp 0x08: [EAX], mas é obrigado a usar outros mecanismos, como armadilhas, portões, interrupções.
Os segmentos e seus tipos (código, dados, escadas, portões) devem ser descritos na tabela de descritores globais da GDT, cujo endereço virtual e o tamanho carregado no registro GDTR. Ao alternar entre segmentos (por simplicidade, presumo que seja possível uma transição direta), você deve chamar a instrução jmp 0x08: [EAX], em que 0x08 é o deslocamento do primeiro descritor válido em bytes desde o início da tabela e EAX é o registro que contém o endereço de transição. O deslocamento (seletor) será carregado no registro CS e o descritor correspondente será carregado no registro shadow do processador. Cada descritor é uma estrutura de 8 bytes. Está bem documentado e sua descrição pode ser encontrada no OSDev e na documentação da Intel (consulte o primeiro artigo).
Eu resumo. Quando inicializamos o GDT e executamos a transição jmp 0x08: [EAX], o status do processador será o seguinte:
- GDTR contém um endereço GDT virtual
- CS contém o valor 0x08
- Um identificador para o endereço [GDTR + 0x08] foi copiado para o registrador sombra CS da memória
- O registro EIP contém o endereço do registro EAX
O descritor zero deve sempre ser não inicializado e o acesso a ele é proibido. Vou me debruçar sobre o descritor TSS e seu significado em mais detalhes quando discutirmos multithreading. Minha tabela GDT agora fica assim:
extern { fn load_gdt(base: *const GdtEntry, limit: u16); } pub unsafe fn setup_gdt() { GDT[5].set_offset((&super::tss::TSS) as *const _ as u32); GDT[5].set_limit(core::mem::size_of::<super::tss::Tss>() as u32); let gdt_ptr: *const GdtEntry = GDT.as_ptr(); let limit = (GDT.len() * core::mem::size_of::<GdtEntry>() - 1) as u16; load_gdt(gdt_ptr, limit); } static mut GDT: [GdtEntry; 7] = [
E aqui está a inicialização, sobre a qual eu falei muito acima. O endereço GDT e o carregamento do tamanho são feitos através de uma estrutura separada, que contém apenas dois campos. O endereço dessa estrutura é passado para o comando lgdt. Nos registros do segmento de dados, carregue o seguinte descritor com um deslocamento de 0x10.
global load_gdt section .text gdtr dw 0 ; For limit storage dd 0 ; For base storage load_gdt: mov eax, [esp + 4] mov [gdtr + 2], eax mov ax, [esp + 8] mov [gdtr], ax lgdt [gdtr] jmp 0x08:.reload_CS .reload_CS: mov ax, 0x10 ; 0x10 points at the new data selector mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov ax, 0x28 ltr ax ret
Então tudo será um pouco mais fácil, mas não menos interessante.
Interrupções
Na verdade, é hora de nos dar a oportunidade de interagir com nosso núcleo (pelo menos para ver o que pressionamos no teclado). Para fazer isso, você deve inicializar o controlador de interrupção.
Digressão lírica sobre o estilo do código.
Graças aos esforços da comunidade e, especificamente, a Philip Opperman, a convenção de chamada com interrupção x86 foi adicionada ao Rust, que permite escrever manipuladores de interrupção que executam iret. No entanto, conscientemente decidi não seguir esse caminho, pois decidi separar assembler e Rust em arquivos diferentes e, portanto, funções. Sim, estou usando excessivamente a memória da pilha, estou ciente disso, mas ainda é saboroso. Meus manipuladores de interrupção são escritos em assembler e fazem exatamente uma coisa: eles chamam quase os mesmos manipuladores de interrupção escritos em Rust. Por favor, aceite esse fato e seja indulgente.
Em geral, o processo de inicialização de interrupções é semelhante à inicialização de um GDT, mas é mais fácil de entender. Por outro lado, você precisa de muito código uniforme. Os desenvolvedores do Redox OS tomam uma decisão linda, usando todas as delícias da linguagem, mas eu fui "na testa" e decidi permitir a duplicação de código.
De acordo com a convenção x86, temos interrupções, mas existem situações excepcionais. Nesse contexto, as configurações para nós são praticamente as mesmas. A única diferença é que, quando uma exceção é lançada, a pilha pode conter informações adicionais. Por exemplo, eu o uso para lidar com a falta de uma página ao trabalhar com muitos (mas tudo tem seu tempo). As interrupções e exceções são processadas da mesma tabela, que você e eu precisamos preencher. Também é necessário programar o PIC (Programmable Interrupt Controller). Também existe o APIC, mas ainda não o descobri.
Ao trabalhar com o PIC, não darei muitos comentários, pois há muitos exemplos na rede sobre como trabalhar com ele. Vou começar com os manipuladores no assembler. Eles são todos completamente idênticos, então removerei o código do spoiler.
IRQ global irq0 global irq1 ...... global irq14 global irq15 extern kirq0 extern kirq1 ...... extern kirq14 extern kirq15 section .text irq0: pusha call kirq0 popa iret irq1: pusha call kirq1 popa iret ...... irq14: pusha call kirq14 popa iret irq15: pusha call kirq15 popa iret
Como você pode ver, todas as chamadas para funções Rust começam com o prefixo “k” - para distinção e conveniência. O tratamento de exceções é exatamente o mesmo. Para funções de montador, o prefixo "e" é selecionado, para Ferrugem, "k". O manipulador de falhas de página é diferente, mas sobre isso - nas notas sobre gerenciamento de memória.
Exceções global e0_zero_divide global e1_debug ...... global eE_page_fault ...... global e14_virtualization global e1E_security extern k0_zero_divide extern k1_debug ...... extern kE_page_fault ...... extern k14_virtualization extern k1E_security section .text e0_zero_divide: pushad call k0_zero_divide popad iret e1_debug: pushad call k1_debug popad iret ...... eE_page_fault: pushad mov eax, [esp + 32] push eax mov eax, cr2 push eax call kE_page_fault pop eax pop eax popad add esp, 4 iret ...... e14_virtualization: pushad call k14_virtualization popad iret e1E_security: pushad call k1E_security popad iret
Declaramos manipuladores de assembler:
extern { fn load_idt(base: *const IdtEntry, limit: u16); fn e0_zero_divide(); fn e1_debug(); ...... fn e14_virtualization(); fn e1E_security(); fn irq0(); fn irq1(); ...... fn irq14(); fn irq15(); }
Definimos manipuladores de ferrugem que chamamos acima. Observe que, para interromper o teclado, simplesmente mostro o código recebido, que recebo da porta 0x60 - é assim que o teclado funciona no modo mais simples. No futuro, isso se transforma em um driver completo, espero. Após cada interrupção, você precisa enviar para o controlador o sinal do final do processamento 0x20, isso é importante! Caso contrário, você não terá mais interrupções.
#[no_mangle] pub unsafe extern fn kirq0() {
Inicialização do IDT e PIC. Sobre o PIC e seu remapeamento, encontrei um grande número de tutoriais com vários graus de detalhes, começando com o OSDev e terminando com sites amadores. Como o procedimento de programação opera com uma sequência constante de operações e comandos constantes, darei esse código sem maiores explicações. , 0x20-0x2F , 0x20 0x28, 16 IDT.
unsafe fn setup_pic(pic1: u8, pic2: u8) {
IDTR GDTR — . STI — — , , ASCII- -.
global load_idt section .text idtr dw 0 ; For limit storage dd 0 ; For base storage load_idt: mov eax, [esp + 4] mov [idtr + 2], eax mov ax, [esp + 8] mov [idtr], ax lidt [idtr] sti ret
Posfácio
, , . setup_pd, . , , , .
- GitLab .
Obrigado pela atenção!
UPD: 3