Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SiliconOS: documentação

O SiliconOS é um sistema operacional para PCs de 64 bits (arquitetura x86-64), escrito em Rust, que liga diretamente pelo firmware UEFI. Ele é um projeto de aprendizado: cada parte do código existe para ensinar uma peça de como um sistema operacional funciona.

Estes documentos explicam o que cada parte faz, por que ela é necessária e como o código a implementa, linha por linha quando vale a pena. Eles partem do princípio de que você:

  • já programa (em Java, Kotlin, JavaScript, Python…),
  • nunca escreveu um sistema operacional,
  • nunca programou em Rust.

O código-fonte completo está em github.com/moises-dev-git/siliconOS.

Os textos são originais. A ordem dos temas acompanha o livro Writing Operating Systems in Rust (Aura Fenwick), para você poder ler o capítulo do livro e depois o documento correspondente.


Por onde começar

  1. Leia o 00: Visão geral inteiro. Ele dá o mapa de tudo: o que é um kernel, como o PC liga e o que o SiliconOS faz do boot até agora.
  2. Mantenha abertos, para consulta:
    • Rust para quem já programa: só os recursos de Rust usados no projeto, comparados com Java, Kotlin, JS e Python;
    • Glossário: registradores, pilha, anéis, interrupções, hexadecimal…
  3. Siga os capítulos em ordem. Cada um começa dizendo o que você vai aprender e termina com um resumo e perguntas para fixar.

Índice

DocAssuntoCapítulo do livroEstado
00: Visão geralO que é um kernel, como o PC liga, UEFI, Rust sem sistema operacional1 e 2✅
01: Serial e vídeoComo o kernel escreve texto: porta serial, framebuffer, fonte, kprintln!3.1 e 3.3✅
02: Panic handlerO que fazer quando o kernel encontra um erro do Rust3.2✅
03: GDT e TSSSegmentos, níveis de privilégio, pilhas de emergência4✅
04: IDT e exceçõesComo tratar erros detectados pela CPU (page fault, double fault…)5✅
05: Memória físicaMapa de memória, buracos, alocador de quadros (bitmap)6✅
06: PaginaçãoEndereços virtuais, tabelas de 4 níveis, TLB, as tabelas do kernel7✅
07: HeapBox, Vec, String dentro do kernel8⏳
08: Interrupções de hardwareControlador de interrupções, timer, teclado9⏳
09: Shell e testesShell interativo, testes no QEMU, depuração com GDB10⏳
Rust para quem já programaReferência de Rust—✅
GlossárioReferência de termos—✅

Como compilar e rodar

Pré-requisitos:

  • Rust nightly com o alvo x86_64-unknown-uefi (o compilador gera um executável no formato que o UEFI entende):
    rustup toolchain install nightly
    rustup target add x86_64-unknown-uefi --toolchain nightly
    
  • QEMU (qemu-system-x86_64), o emulador de PC;
  • OVMF, o firmware UEFI que o QEMU usa. No Ubuntu/Debian, os dois vêm com sudo apt install qemu-system-x86 ovmf, e o firmware fica em /usr/share/ovmf/OVMF.fd (em outras distribuições o caminho pode mudar).

O script run.sh faz tudo: compila, copia o executável para a pasta esp/ e abre o QEMU.

./run.sh                    # roda normalmente: janela do QEMU + mensagens no terminal
SEM_TELA=1 ./run.sh         # sem janela; depois de 15 s mostra o que o kernel escreveu
./run.sh teste-pagefault    # compila uma versão que provoca um erro de propósito

Versões de teste disponíveis (cada uma provoca um tipo de erro para provar que o kernel o trata):

ComandoO que provocaDoc
./run.sh teste-panicerro detectado pelo Rust (índice fora do array)02
./run.sh teste-breakpointinstrução de breakpoint (o kernel deve continuar)04
./run.sh teste-divisaodivisão por zero04
./run.sh teste-opcodeinstrução inválida04
./run.sh teste-gpfendereço impossível (proteção geral)04
./run.sh teste-pagefaultleitura de memória que não existe04
./run.sh teste-doublefaultpilha destruída (erro durante o tratamento de outro erro)04
./run.sh teste-quadrosaloca, escreve, confere, libera e realoca 1000 quadros05
./run.sh teste-liberacao-duplalibera o mesmo quadro duas vezes (deve dar panic)05
./run.sh listar-regioesnão é teste: lista o mapa de memória completo no boot05
./run.sh teste-paginacaomapeia, traduz, lê/escreve por 2 caminhos, desmapeia06
./run.sh teste-desmapeadoacessa uma página depois de desmapeá-la (deve dar page fault)06
./run.sh teste-nulolê o endereço 0 (deve dar page fault)06

Para mudar a memória da máquina emulada (padrão: 128 MiB), use RAM, por exemplo RAM=4G ./run.sh teste-quadros.


Mapa do código

siliconos/
├── Cargo.toml     Configuração do projeto: dependências e "features" de teste
├── run.sh         Compila, copia para esp/ e roda no QEMU
├── esp/           Pasta que o QEMU apresenta como um pendrive FAT
│   └── EFI/BOOT/BOOTX64.efi   ← o nosso kernel compilado
├── docs/          Esta documentação
└── src/
    ├── main.rs     Ponto de entrada. Fase UEFI → sai do firmware → kernel_main
    ├── serial.rs   Escreve na porta serial (canal de depuração)        [doc 01]
    ├── console.rs  Desenha texto na tela, pixel a pixel                [doc 01]
    ├── panic.rs    O que fazer quando o Rust detecta um erro           [doc 02]
    ├── gdt.rs      Tabela de segmentos e pilha de emergência           [doc 03]
    ├── idt.rs      Tabela de interrupções e tratadores de exceções     [doc 04]
    └── memoria/                                                         [doc 05]
        ├── mod.rs      constantes (tamanho do quadro, 1 MiB)
        ├── mapa.rs     cópia própria do mapa de memória do UEFI
        ├── quadros.rs  alocador de quadros físicos (bitmap)
        └── paginacao.rs  tabelas de página do kernel                    [doc 06]

O fluxo de execução atual, de ponta a ponta:

 firmware UEFI
   └─► main()                       [main.rs]  ainda com ajuda do firmware
         ├─ descobre onde fica a tela
         ├─ exit_boot_services()    ← dispensa o firmware, recebe o mapa de memória
         └─► kernel_main()          [main.rs]  a partir daqui a máquina é nossa
               ├─ cli                    desliga interrupções de hardware
               ├─ Serial::init()         [serial.rs]
               ├─ Console::new()         [console.rs]
               ├─ gdt::init()            [gdt.rs]
               ├─ idt::init()            [idt.rs]
               ├─ (testes de exceção, se compilado com alguma feature de teste)
               ├─ memoria::mapa::init()  [memoria/mapa.rs]     + resumo do mapa
               ├─ memoria::quadros::init() [memoria/quadros.rs] + resumo do alocador
               ├─ (testes de memória, se compilado com alguma feature de teste)
               ├─ memoria::paginacao::init() monta as tabelas do kernel e troca o CR3
               ├─ (testes de paginação, se compilado com alguma feature de teste)
               └─ loop { hlt }           dorme para sempre

Onde o SiliconOS difere do livro

O livro usa um bootloader pronto (um programa de terceiros que carrega o kernel e entrega as informações para ele) e um alvo de compilação personalizado, como no projeto de exemplo que acompanha o livro.

O SiliconOS não usa bootloader: ele mesmo é um programa UEFI. O firmware o carrega, ele coleta as informações de vídeo e memória e então dispensa o firmware para assumir a máquina. O livro aborda esse caminho no capítulo 11.

Na prática, muda só como o kernel recebe as informações iniciais. Do capítulo 3 ao 10, os conceitos são os mesmos. Quando algum detalhe for diferente, o documento avisa numa seção “Diferença em relação ao livro”.

Outra diferença: o livro usa a crate (biblioteca) x86_64, que já traz estruturas prontas para GDT, IDT etc. O SiliconOS monta essas estruturas à mão, bit a bit, para que fique visível o que essas abstrações escondem.

00: Visão geral: do botão de ligar até o kernel

Você vai entender: o que é um kernel e por que escrever um é diferente de escrever um programa comum; o que acontece entre apertar o botão de ligar e o nosso código rodar; e por que o código Rust do SiliconOS tem um “dialeto” próprio (no_std, unsafe).

Arquivo principal: src/main.rs Livro: capítulos 1 e 2


1. O que é um kernel

Pense num programa que você já escreveu, por exemplo um backend em Java que lê um arquivo e responde requisições HTTP. Esse programa nunca falou diretamente com o hardware:

  • Para ler o arquivo, ele chamou Files.readAllBytes(...). Lá no fundo, isso virou um pedido ao sistema operacional: “leia tais bytes do disco para mim”.
  • Para abrir a porta 8080, a JVM pediu ao sistema operacional.
  • Para alocar memória (new Objeto()), a JVM pediu blocos de memória ao sistema operacional.
  • Quando o programa deu NullPointerException e morreu, foi o sistema operacional que limpou a bagunça e liberou a memória.

Quem atende todos esses pedidos é o kernel: o núcleo do sistema operacional. Ele é o único programa que roda com controle total da CPU, da memória e dos dispositivos. Todos os outros programas pedem as coisas a ele.

 ┌─────────────────────────────────────────────┐
 │  seus programas (navegador, IDE, backend)    │  ← pedem coisas
 ├─────────────────────────────────────────────┤
 │  bibliotecas (libc, JVM, Python runtime)     │  ← traduzem os pedidos
 ├─────────────────────────────────────────────┤
 │  KERNEL  ← o que estamos escrevendo          │  ← atende os pedidos
 ├─────────────────────────────────────────────┤
 │  hardware (CPU, RAM, disco, vídeo, teclado)  │
 └─────────────────────────────────────────────┘

Por que escrever um kernel é diferente

Quando você escreve o kernel, não existe ninguém abaixo de você para pedir ajuda. Coisas que você sempre usou sem pensar simplesmente não existem:

Você está acostumado a…No kernel…
System.out.println / print()Não há terminal. Temos que desenhar cada letra na tela, pixel a pixel (doc 01).
new / alocar listasNão há quem forneça memória. Teremos que escrever o alocador (capítulos futuros).
Exceções com stack traceUm erro trava ou reinicia a máquina sem mensagem nenhuma, até escrevermos os tratadores (docs 02 e 04).
Depurador da IDESó depois de muito trabalho. No começo, depuramos imprimindo texto.
ThreadsNão existem até nós as criarmos.

Por isso, desenvolver um SO segue uma ordem quase obrigatória, em que cada etapa dá ferramentas para a próxima:

  1. Conseguir escrever texto (doc 01). Sem isso, não dá para depurar nada.
  2. Mostrar erros em vez de travar (docs 02 e 04).
  3. Colocar a CPU num estado conhecido, que é nosso, não do firmware (doc 03).
  4. Gerenciar a memória (próximos capítulos).
  5. Reagir ao hardware: timer e teclado.
  6. Rodar outros programas de forma isolada e segura.

O SiliconOS já concluiu as etapas 1 a 3 e está no meio da etapa 4: a memória física e a paginação estão prontas (docs 05 e 06), e o heap vem a seguir.


2. O que acontece quando o PC liga

2.1 O firmware

A CPU, ao receber energia, não sabe nada sobre discos nem sistemas operacionais. Ela simplesmente começa a executar instruções de um endereço fixo, que aponta para um chip de memória na placa-mãe. Esse chip contém o firmware.

O firmware:

  1. testa e inicializa o hardware (memória RAM, placa de vídeo, USB…);
  2. procura nos discos um programa para carregar;
  3. carrega esse programa na memória e passa o controle para ele.

Existem duas gerações de firmware:

BIOS (desde 1981)UEFI (desde ~2010)
Modo da CPU ao entregar o controle16 bits (“modo real”, como um 8086)64 bits, já configurado
O que carregaos primeiros 512 bytes do discoum arquivo .efi de uma partição FAT
Serviços oferecidosrotinas antigas e limitadasvídeo, teclado, arquivos, memória, rede…

Com BIOS, o primeiro trabalho de um sistema operacional é sair dos 16 bits, passar por 32 bits e chegar a 64 bits. É o que faz o clássico setor de boot (o boot_sect.asm de muitos tutoriais de SO). Com UEFI, a CPU já chega em 64 bits.

O SiliconOS usa UEFI.

2.2 Como o UEFI acha o SiliconOS

O UEFI procura uma partição formatada em FAT e, dentro dela, o arquivo EFI/BOOT/BOOTX64.EFI. No SiliconOS:

  1. O cargo build --target x86_64-unknown-uefi gera target/x86_64-unknown-uefi/debug/siliconos.efi. O alvo x86_64-unknown-uefi faz o compilador gerar um executável no formato PE (o mesmo formato dos .exe do Windows), que é o formato que o UEFI entende.
  2. O run.sh copia esse arquivo para esp/EFI/BOOT/BOOTX64.efi.
  3. O QEMU é iniciado com -drive format=raw,file=fat:rw:esp, que significa “finja que a pasta esp/ é um disco FAT”. O firmware (OVMF) acha o arquivo e o executa.

Para rodar num PC de verdade, bastaria copiar a pasta EFI para um pendrive FAT32 e dar boot por ele. Por enquanto só testamos no QEMU.


3. As duas fases do SiliconOS

Enquanto o firmware UEFI está “no comando”, ele oferece funções úteis, chamadas Boot Services: escrever texto na tela, alocar memória, ler arquivos. Mas ele também mantém seus próprios timers, drivers e tabelas rodando. Um sistema operacional de verdade precisa do controle total, então em algum momento ele diz ao firmware: “obrigado, pode sair”. Isso é a função ExitBootServices.

Por isso o SiliconOS tem duas fases bem separadas:

 ┌─────────── FASE 1: UEFI (função main) ───────────┐
 │ • o firmware ainda ajuda: uefi::println! funciona │
 │ • perguntamos ao firmware onde fica a tela        │
 │ • chamamos exit_boot_services()                   │
 │   └─ o firmware nos entrega o MAPA DE MEMÓRIA     │
 └───────────────────────┬───────────────────────────┘
                         ▼
 ┌──────── FASE 2: BARE-METAL (função kernel_main) ──┐
 │ • o firmware não ajuda mais em nada               │
 │ • só funciona o que NÓS escrevemos                │
 │ • esta função nunca retorna                       │
 └───────────────────────────────────────────────────┘

O mapa de memória é uma lista de regiões da RAM com o tipo de cada uma: “livre”, “usada pelo firmware”, “usada pelo programa carregado”, “pertence a um dispositivo”… É a partir dele que, no próximo capítulo, vamos decidir qual memória o kernel pode usar.

3.1 O código da fase 1

#[entry]
fn main() -> Status {
    uefi::helpers::init().unwrap();
    uefi::println!("Carregador UEFI iniciado.");
  • #[entry] é um atributo (parecido com uma annotation do Java) fornecido pela crate uefi. Ele marca esta função como o ponto de entrada que o firmware vai chamar.
  • -> Status indica que a função devolveria um código de status ao firmware, embora, como veremos, ela nunca chegue a retornar.
  • uefi::helpers::init() prepara a crate uefi (por exemplo, para o println! funcionar). O .unwrap() quer dizer “se der erro, aborte”, porque não há o que fazer se o firmware falhar logo no início (ver rust-basico §6).
#![allow(unused)]
fn main() {
    let fb = {
        let handle = uefi::boot::get_handle_for_protocol::<GraphicsOutput>().unwrap();
        let mut gop = uefi::boot::open_protocol_exclusive::<GraphicsOutput>(handle).unwrap();
        let info = gop.current_mode_info();
        let (width, height) = info.resolution();
        FrameBuffer {
            base: gop.frame_buffer().as_mut_ptr() as *mut u32,
            width,
            height,
            stride: info.stride(),
        }
    };
}

Aqui perguntamos ao firmware sobre o vídeo. O UEFI organiza seus serviços em protocolos (pense em “interfaces de serviço”); o de vídeo se chama GOP (Graphics Output Protocol). Pegamos dele:

  • o endereço da memória de vídeo (o framebuffer, explicado no doc 01);
  • a largura e a altura da tela em pixels;
  • o stride (quantos pixels cada linha ocupa na memória).

Guardamos tudo numa struct nossa, FrameBuffer.

Por que o bloco { ... } em volta? Em Rust, valores são destruídos quando saem do escopo (rust-basico §8). Ao sair do bloco, o objeto gop é destruído, e a destruição dele fecha o protocolo no firmware. Precisamos que isso aconteça antes de dispensar o firmware. Se tentássemos fechar o protocolo depois, estaríamos chamando um firmware que não existe mais. O que sobra do bloco é só a struct FrameBuffer, com números simples.

#![allow(unused)]
fn main() {
    let memory_map = unsafe { uefi::boot::exit_boot_services(MemoryType::LOADER_DATA) };
    kernel_main(fb, &memory_map);
}
}
  • exit_boot_services dispensa o firmware e devolve o mapa de memória. O argumento MemoryType::LOADER_DATA diz em que tipo de memória guardar a cópia do mapa.
  • Está dentro de unsafe porque, depois desta linha, qualquer código que ainda tente usar o firmware quebra, e o compilador não tem como verificar isso por nós (rust-basico §9).
  • kernel_main nunca retorna, por isso main também nunca chega ao fim.

3.2 O começo da fase 2

#![allow(unused)]
fn main() {
fn kernel_main(fb: FrameBuffer, memory_map: &impl MemoryMap) -> ! {
    unsafe { core::arch::asm!("cli", options(nomem, nostack)) };
    Serial::init();
    *CONSOLE.lock() = Some(Console::new(fb));
    ...
}
  • -> ! significa “esta função nunca retorna” (rust-basico §3). Ela termina num loop infinito.
  • cli é uma instrução da CPU que desliga as interrupções de hardware. O motivo é explicado no doc 03.
  • Depois inicializamos a serial e a tela (doc 01), a GDT (doc 03) e a IDT (doc 04), e imprimimos um resumo do mapa de memória.

4. Rust sem sistema operacional: no_std e no_main

As primeiras linhas do main.rs:

#![allow(unused)]
#![no_std]
#![no_main]
#![feature(abi_x86_interrupt)]
fn main() {
}

4.1 #![no_std]: sem a biblioteca padrão

A biblioteca padrão do Rust (std) é como a biblioteca padrão do Java: traz arquivos, rede, threads, println!, coleções. Só que quase tudo nela depende de um sistema operacional por baixo: println! escreve no terminal via o SO; Vec pede memória ao SO; File abre arquivos pelo SO.

Como nós somos o sistema operacional, não podemos usá-la. Sobra a core, a parte do Rust que não depende de nada: tipos numéricos, Option, Result, iteradores, formatação de texto, operações atômicas (rust-basico §17).

O que a std fazia e agora é responsabilidade nossa:

A std forneciaNo SiliconOS
println!kprintln!, que escreve na serial e na tela (doc 01)
o que fazer quando o programa entra em pânico#[panic_handler] (doc 02)
alocador de memória (Vec, String, Box)ainda não existe (capítulo futuro)
ponto de entrada main#[entry] da crate uefi

4.2 #![no_main]

Num programa Rust comum, existe um código escondido que roda antes do seu main (prepara argumentos, variáveis de ambiente, threads). Esse código depende do SO. #![no_main] diz: “não gere esse código; o ponto de entrada eu forneço”. No nosso caso, quem fornece é o #[entry].

4.3 #![feature(...)]

Liga um recurso experimental do Rust nightly. Usamos um só, explicado no doc 04.


5. Por que tem tanto unsafe

Rust garante, em tempo de compilação, que você não vai acessar memória inválida, desde que o compilador consiga provar que o acesso é válido.

Um kernel faz coisas que o compilador não tem como provar:

  • “Escreva a cor azul no endereço 0x80000000.” Nós sabemos que ali fica a tela porque o firmware disse; o compilador não sabe.
  • “Execute a instrução out para mandar um byte à porta serial.”
  • “Troque a tabela de segmentos da CPU.”

Para essas situações existe o unsafe { ... }, que significa: “compilador, aqui quem garante a correção sou eu”. Ele não desliga as outras verificações; só libera algumas operações extras.

A disciplina que seguimos (e que o livro defende no capítulo 8.4):

  1. blocos unsafe pequenos, cada um com um comentário dizendo por que é seguro;
  2. por cima deles, funções seguras. Exemplo: a função outb (mandar um byte para uma porta) é unsafe, mas Serial::write_byte não é. Quem usa a serial não precisa saber nada de portas.

6. Ferramentas

FerramentaPara que serve
Cargocompila o projeto e baixa dependências (como Maven/Gradle/npm)
alvo x86_64-unknown-uefifaz o compilador gerar um .efi em vez de um executável Linux
QEMUemula um PC inteiro. Liga em 1 segundo, mostra a saída serial no terminal, não estraga nada se o kernel travar
OVMFfirmware UEFI de código aberto, usado pelo QEMU no lugar do firmware de uma placa-mãe real

Dependências (crates) do projeto, declaradas no Cargo.toml:

CratePara que usamos
ueficonversar com o firmware na fase 1 (vídeo, mapa de memória, exit_boot_services)
spinMutex e Once que funcionam sem sistema operacional
font8x8o desenho das letras (uma tabela de bits)

Resumo

  • O kernel é o programa que controla o hardware e atende os pedidos de todos os outros. Escrevendo um, você perde tudo que o SO fornecia.
  • O firmware UEFI liga o PC, coloca a CPU em 64 bits e executa o arquivo EFI/BOOT/BOOTX64.EFI, que é o SiliconOS.
  • O SiliconOS tem duas fases: com a ajuda do firmware (descobre a tela) e sem ela, depois de exit_boot_services (o resto do kernel).
  • #![no_std] troca a biblioteca padrão pela core, que não depende de SO. unsafe marca os trechos em que o programador garante a correção.
Para fixar (clique para ver as respostas)
  1. Por que o SiliconOS pega as informações de vídeo antes de chamar exit_boot_services? Porque depois dessa chamada o firmware não existe mais, e não há a quem perguntar onde fica a tela.

  2. Por que não dá para usar println! comum no kernel? Porque ele faz parte da std, que pede ao sistema operacional para escrever no terminal. Não há SO abaixo do kernel.

  3. O que significa o -> ! em kernel_main? Que a função nunca retorna. Ela termina num loop infinito, porque não há para onde voltar.

01: Como o kernel escreve texto: serial e tela

Você vai entender: como um kernel consegue mostrar texto sem nenhum sistema operacional por baixo; o que é uma porta de E/S; como a tela funciona como um grande array de pixels; como as letras são desenhadas; e como o kprintln! junta tudo.

Arquivos: src/serial.rs, src/console.rs, e as macros no começo de src/main.rs Livro: capítulo 3.1 (serial) e 3.3 (VGA/framebuffer) Rust novo aqui: traits (§5), Option (§6), ponteiros crus e unsafe (§9), static (§10), Mutex (§11), asm! (§15)


1. O problema

Na fase 1 (doc 00), escrever texto é fácil: uefi::println! pede ao firmware para escrever. Depois de exit_boot_services, o firmware foi embora. Não existe mais nenhuma função que escreva texto. Se o kernel travar, a tela fica parada e você não tem a menor pista do que aconteceu.

Precisamos de dois canais de saída que nós mesmos controlamos:

CanalVantagemDesvantagem
Porta serialFunciona com uma única instrução da CPU, antes de qualquer outra coisa. O QEMU grava tudo num arquivo ou mostra no terminal.Num PC real, precisa de um cabo/adaptador serial para ver.
Tela (framebuffer)É o que o usuário vê.Precisamos desenhar cada letra pixel a pixel.

O SiliconOS escreve nos dois ao mesmo tempo.


2. A porta serial

2.1 O que é

A porta serial (COM1, aquele conector de 9 pinos dos PCs antigos) é a forma mais simples de um computador mandar bytes para fora, um bit de cada vez. Placas-mãe modernas quase não têm o conector, mas o chip continua existindo e todo emulador o implementa. Para depuração ela é perfeita:

  • não depende de vídeo, memória ou interrupções configuradas;
  • o QEMU pode mandar tudo para o terminal (-serial stdio) ou para um arquivo (-serial file:serial.log). O arquivo fica salvo mesmo que a máquina reinicie logo depois, e muitas vezes é a única pista de um travamento.

2.2 Como se fala com ela: portas de E/S

O processador x86 tem dois “espaços de endereços” separados:

  1. a memória, acessada com instruções comuns (mov);
  2. as portas de E/S: 65 536 endereços (de 0x0000 a 0xFFFF), acessados só com duas instruções especiais:
    • out: envia um byte para uma porta;
    • in: lê um byte de uma porta.

Cada dispositivo antigo “escuta” em algumas dessas portas. Escrever na porta certa é como chamar uma função do dispositivo. A COM1 usa as portas 0x3F8 a 0x3FF.

Em Rust não existe função pronta para in/out, então usamos assembly embutido (rust-basico §15):

#![allow(unused)]
fn main() {
pub unsafe fn outb(port: u16, value: u8) {
    unsafe {
        core::arch::asm!("out dx, al", in("dx") port, in("al") value,
            options(nomem, nostack, preserves_flags));
    }
}
}

Lendo a linha do asm!:

  • "out dx, al" é a instrução: “mande o byte que está no registrador AL para a porta cujo número está em DX”. O x86 exige exatamente esses registradores para essa instrução.
  • in("dx") port significa “antes de executar, coloque a variável port em DX”.
  • in("al") value significa “coloque value em AL”.
  • options(...) são promessas ao compilador: a instrução não lê nem escreve memória (nomem), não usa a pilha (nostack) e não altera as flags da CPU (preserves_flags). Com isso ele pode otimizar melhor o código ao redor.

A função é unsafe fn porque mandar um byte para a porta errada pode fazer qualquer coisa (desligar um dispositivo, por exemplo); quem chama precisa saber o que está fazendo. inb é igual, no sentido inverso.

2.3 Configurando o chip (UART 16550)

O chip da serial se chama UART. Cada uma das 8 portas a partir de 0x3F8 é um registrador do chip, uma espécie de “variável” interna dele:

PortaRegistradorO que fazemos com ele
0x3F8 (+0)dados, ou parte baixa da velocidadeenviar o byte; definir a velocidade
0x3F9 (+1)interrupções, ou parte alta da velocidadedesligamos as interrupções do chip
0x3FA (+2)controle do buffer (FIFO)ligamos e limpamos
0x3FB (+3)formato da transmissão8 bits por caractere, sem paridade, 1 bit de parada (“8N1”)
0x3FC (+4)controle do modemsinaliza “estou pronto” (DTR + RTS)
0x3FD (+5)statuso bit 5 diz “pode mandar o próximo byte”

A função Serial::init() é só essa configuração, porta por porta:

#![allow(unused)]
fn main() {
pub fn init() {
    unsafe {
        outb(COM1 + 1, 0x00); // desliga interrupções da UART
        outb(COM1 + 3, 0x80); // DLAB=1: próximos 2 registradores são o divisor
        outb(COM1 + 0, 0x03); // divisor = 3 -> 115200 / 3 = 38400 baud
        outb(COM1 + 1, 0x00);
        outb(COM1 + 3, 0x03); // DLAB=0, formato 8N1
        outb(COM1 + 2, 0xC7); // liga e limpa as FIFOs
        outb(COM1 + 4, 0x03); // DTR + RTS
    }
}
}

O trecho mais estranho é o da velocidade. O chip tem um relógio de 115 200 “batidas” por segundo, e a velocidade é definida por um divisor: 3 dá 115 200 / 3 = 38 400 bits por segundo (baud). Como só há 8 portas para muitas configurações, o chip reaproveita portas: ligar o bit DLAB (valor 0x80 na porta +3) faz as portas +0 e +1 passarem a significar “divisor” em vez de “dados”. Gravamos o divisor e desligamos o DLAB.

2.4 Enviando um byte

#![allow(unused)]
fn main() {
pub fn write_byte(b: u8) {
    unsafe {
        while inb(COM1 + 5) & 0x20 == 0 {   // bit 5 do status ainda é 0?
            core::hint::spin_loop();        // então espere
        }
        outb(COM1, b);                      // pronto: envia
    }
}
}

O chip só aceita um byte novo quando terminou de enviar o anterior. O bit 5 (0x20 = 0010 0000) da porta de status diz se ele está livre. Ficamos em loop até estar. spin_loop() é uma dica para a CPU de que estamos esperando em loop (ela economiza energia).

Curiosidade: a primeira versão do SiliconOS mandava “KO” para a porta sem configurar nem esperar. Funcionava porque o QEMU é tolerante; num chip real, bytes se perderiam.

2.5 Transformando a serial num “destino de texto”

Para escrever texto formatado ("x = {}"), implementamos a trait core::fmt::Write, a “interface” de destinos de texto do Rust (rust-basico §5):

#![allow(unused)]
fn main() {
pub struct Serial;           // struct sem campos: só um "nome" para pendurar métodos

impl fmt::Write for Serial {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for b in s.bytes() {
            if b == b'\n' {
                Serial::write_byte(b'\r');   // terminais seriais querem \r\n
            }
            Serial::write_byte(b);
        }
        Ok(())
    }
}
}

Implementando só write_str, a trait nos dá write_fmt de graça, e com ele o writeln!(Serial, "valor = {}", x) funciona.


3. A tela: framebuffer

3.1 A tela é um array de pixels na memória

Com UEFI, o vídeo funciona assim: o firmware reserva uma região da memória em que cada pixel da tela é um número de 32 bits (u32). Escrever um número nessa memória muda a cor do pixel na hora. Essa região se chama framebuffer.

 framebuffer (memória)                       tela
 ┌────┬────┬────┬─────┬────┐              ┌─────────────┐
 │ px │ px │ px │ ... │ px │  linha 0  →  │ ▓▓▓▓▓▓▓▓▓▓  │
 ├────┼────┼────┼─────┼────┤              │             │
 │ px │ px │ px │ ... │ px │  linha 1  →  │             │
 └────┴────┴────┴─────┴────┘              └─────────────┘

É um array de uma dimensão representando uma imagem de duas dimensões. Para achar o pixel da coluna x, linha y:

índice = y * stride + x

stride é quantos pixels cada linha ocupa na memória. Pode ser maior que a largura visível, porque algumas placas de vídeo arredondam o tamanho das linhas. No QEMU, stride = largura = 1280. Usar a largura no lugar do stride é um bug clássico: num hardware em que eles diferem, a imagem sai “inclinada”.

Cada u32 é uma cor no formato 0x00RRGGBB: 0x00FF0000 é vermelho, 0x0000FF00 é verde, 0x00FFFFFF é branco. (O UEFI pode informar outra ordem, BGR; no QEMU é RGB, e é o que assumimos por enquanto.)

3.2 A struct FrameBuffer

#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
pub struct FrameBuffer {
    pub base: *mut u32,    // endereço do primeiro pixel
    pub width: usize,
    pub height: usize,
    pub stride: usize,
}
}

*mut u32 é um ponteiro cru: um endereço de memória, como em C (rust-basico §9). Usamos ponteiro cru porque o Rust não tem como saber que esse endereço é válido: foi o firmware que nos disse.

3.3 Pintando um pixel

#![allow(unused)]
fn main() {
fn pixel(&mut self, x: usize, y: usize, cor: u32) {
    if x < self.fb.width && y < self.fb.height {
        unsafe { self.fb.base.add(y * self.fb.stride + x).write_volatile(cor) };
    }
}
}
  • base.add(n) calcula o endereço do n-ésimo u32 depois de base (aritmética de ponteiro).
  • write_volatile(cor) grava a cor nesse endereço. O volatile é importante: diz ao compilador “não otimize esta escrita”. Sem ele, o otimizador poderia pensar “ninguém lê essa memória depois, então a escrita é inútil” e removê-la. Ele não sabe que uma placa de vídeo está lendo.
  • O if evita escrever fora da tela, o que corromperia outra memória.

3.4 Desenhando letras: fonte bitmap

Não há fonte nenhuma disponível. Usamos a crate font8x8, que é basicamente uma tabela de bits: para cada um dos 128 caracteres ASCII, 8 bytes, um por linha; em cada byte, cada bit diz se aquele pixel fica aceso.

Por exemplo, a letra A:

byte   binário      desenho (bit 0 à esquerda)
0x0C   0000 1100    . . # # . . . .
0x1E   0001 1110    . # # # # . . .
0x33   0011 0011    # # . . # # . .
0x33   0011 0011    # # . . # # . .
0x3F   0011 1111    # # # # # # . .
0x33   0011 0011    # # . . # # . .
0x33   0011 0011    # # . . # # . .
0x00   0000 0000    . . . . . . . .

Nessa fonte, o bit 0 é o pixel mais à esquerda, por isso o desenho parece “espelhado” em relação ao binário escrito.

A função que desenha um caractere percorre os 8×8 bits:

#![allow(unused)]
fn main() {
fn desenhar_char(&mut self, c: u8, col: usize, row: usize) {
    let glifo = BASIC_LEGACY[(c & 0x7F) as usize];   // os 8 bytes da letra
    for (gy, linha) in glifo.iter().enumerate() {    // gy = 0..7 (linha do desenho)
        for gx in 0..8 {                             // gx = 0..7 (coluna do desenho)
            let cor = if linha & (1 << gx) != 0 { self.cor } else { self.fundo };
            for dy in 0..ESCALA {                    // cada bit vira um bloco ESCALA×ESCALA
                for dx in 0..ESCALA {
                    self.pixel(col * CHAR_W + gx * ESCALA + dx,
                               row * CHAR_H + gy * ESCALA + dy, cor);
                }
            }
        }
    }
}
}
  • c & 0x7F garante um índice entre 0 e 127, o tamanho da tabela.
  • linha & (1 << gx) != 0 testa se o bit gx está ligado (glossário: bits).
  • Uma letra de 8×8 pixels é minúscula numa tela de 1280×800, então cada bit vira um quadrado de 2×2 (ESCALA = 2). Cada letra ocupa 16×16 pixels, mais 4 pixels de espaço entre linhas (CHAR_H = 8*2 + 4).
  • .iter().enumerate() é como um for (i, item) com índice: devolve pares (posição, valor).

A tabela só tem ASCII. Letras acentuadas aparecem como ?, por isso as mensagens do kernel são escritas sem acento (“memoria”, “excecao”).

3.5 O Console: cursor, quebra de linha e rolagem

#![allow(unused)]
fn main() {
pub struct Console {
    fb: FrameBuffer,
    col: usize,       // coluna do cursor (em caracteres, não em pixels)
    row: usize,       // linha do cursor
    pub cor: u32,     // cor do texto
    pub fundo: u32,   // cor do fundo
}
}

O Console funciona como um terminal simples:

  • write_byte(b): se for \n, pula para a próxima linha; senão, desenha o caractere na posição do cursor e avança uma coluna. Se a linha encher, quebra automaticamente.
  • rolar(): quando o cursor passa da última linha, copia toda a imagem uma linha de texto para cima (core::ptr::copy, que equivale ao System.arraycopy do Java) e pinta a última linha com a cor de fundo.

Assim como a Serial, o Console implementa fmt::Write, então writeln!(console, ...) também funciona.


4. kprintln!: escrevendo nos dois lugares

4.1 A variável global do console

O console precisa ser acessível de qualquer lugar do kernel: hoje do kernel_main, amanhã de dentro de um tratador de erro ou de interrupção. Ou seja, precisa ser uma variável global:

#![allow(unused)]
fn main() {
pub static CONSOLE: Mutex<Option<Console>> = Mutex::new(None);
}

Lendo de dentro para fora:

PartePor quê
Consoleo objeto que desenha na tela
Option<...>ele começa vazio (None), porque o endereço da tela só é conhecido durante a execução. O kernel_main coloca Some(Console::new(fb))
Mutex<...>garante que só um trecho de código use o console por vez (rust-basico §11)
staticvariável global com endereço fixo (rust-basico §10)

Por que um Mutex se ainda não há threads? Porque logo haverá interrupções (capítulo 9). Uma interrupção pode chegar no meio de um kprintln! e o tratador dela também querer imprimir. Sem proteção, as duas escritas se misturariam. Além disso, o Rust simplesmente não deixa criar uma global mutável sem proteção, a não ser com unsafe.

O Mutex é da crate spin: em vez de “pôr a thread para dormir” (o que exigiria um sistema operacional), ele fica em loop tentando pegar o lock.

Um detalhe técnico: o Rust só permite colocar num static compartilhado tipos que ele considera seguros para compartilhar entre threads (a trait Send). Um ponteiro cru (*mut u32) não é considerado seguro automaticamente, então declaramos manualmente:

#![allow(unused)]
fn main() {
unsafe impl Send for FrameBuffer {}
}

“Eu garanto que é seguro”, e é mesmo, porque todo acesso passa pelo Mutex.

4.2 A macro

#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! kprintln {
    () => { $crate::kprint!("\n") };
    ($($arg:tt)*) => { $crate::kprint!("{}\n", format_args!($($arg)*)) };
}
}

A sintaxe de macro_rules! é densa, mas a ideia é simples: kprintln! é “kprint! com um \n no fim”, e kprint! empacota os argumentos com format_args! (a formatação da core, que não aloca memória) e chama a função _print:

#![allow(unused)]
fn main() {
pub fn _print(args: core::fmt::Arguments) {
    let _ = Serial.write_fmt(args);                      // 1. serial, sempre
    if let Some(con) = CONSOLE.lock().as_mut() {         // 2. tela, se já existir
        let _ = con.write_fmt(args);
    }
}
}
  • CONSOLE.lock() pega o lock; .as_mut() dá acesso ao Console de dentro do Option; if let Some(con) só entra se o console já foi criado (rust-basico §6).
  • O lock é liberado sozinho no fim do if, quando o “guarda” devolvido por lock() sai de escopo.
  • let _ = ignora de propósito o resultado da escrita: se a tela falhar, não há o que fazer.

Uso, igual a um println!:

#![allow(unused)]
fn main() {
kprintln!("Video: {}x{} (stride {})", fb.width, fb.height, fb.stride);
}

5. Como foi testado

Rodando no QEMU sem janela (SEM_TELA=1 ./run.sh), o arquivo serial.log recebe todas as linhas, e uma captura da tela feita pelo monitor do QEMU mostra o mesmo texto desenhado em branco sobre azul-escuro.


Diferença em relação ao livro

O livro também mostra o modo texto VGA: um modo antigo em que a placa de vídeo desenha as letras sozinha e o programa só escreve códigos de caracteres no endereço 0xB8000. Esse modo só existe com boot por BIOS. Com UEFI, o único vídeo disponível é o framebuffer de pixels, por isso o SiliconOS vai direto para ele.


Resumo

  • A serial é o canal de depuração mais confiável: uma instrução out manda um byte; o QEMU mostra ou grava tudo.
  • Portas de E/S são um espaço de endereços separado da memória, acessado com in/out.
  • O framebuffer é a tela vista como um array de u32 na memória: índice = y * stride + x.
  • Letras são desenhadas a partir de uma tabela de bits (8 bytes por caractere).
  • kprintln! formata o texto e o envia à serial e ao console global (static Mutex<Option<Console>>).
Para fixar (clique para ver as respostas)
  1. Por que a escrita no framebuffer usa write_volatile em vez de uma atribuição normal? Para o compilador não remover a escrita achando que ela é inútil. Quem lê aquela memória é a placa de vídeo, que o compilador não conhece.

  2. O que acontece se usarmos width no lugar de stride num hardware em que eles são diferentes? Cada linha começa no lugar errado e a imagem sai inclinada e embaralhada.

  3. Por que o console é um Option? Porque ele só pode ser criado quando sabemos o endereço da tela, em tempo de execução. Antes disso, ele é None e o kprintln! escreve só na serial.

  4. Por que o Serial::write_byte espera o bit 5 da porta de status? Porque o chip só aceita um novo byte depois de terminar de enviar o anterior. Sem a espera, bytes se perdem.

02: Panic handler: quando o Rust encontra um erro no kernel

Você vai entender: o que é um panic em Rust; por que, num kernel, somos nós que decidimos o que acontece quando ele ocorre; e as armadilhas de escrever um tratador de erro que não pode falhar.

Arquivo: src/panic.rs Livro: capítulo 3.2 Testar: ./run.sh teste-panic Rust novo aqui: -> ! (§3), atributos (§13), features e #[cfg] (§12)


1. O que é um panic

Rust não tem exceções. Erros esperados (arquivo não encontrado, número inválido) são valores do tipo Result, que você trata explicitamente (rust-basico §6).

Já os erros que indicam um bug no programa, situações que “nunca deveriam acontecer”, geram um panic. Exemplos:

CódigoPor que dá panic
panic!("estado impossível")chamada explícita
v[10] num array de 3 elementosíndice fora dos limites
None.unwrap()“eu garanti que existia”, e não existia
255u8 + 1 (em modo debug)estouro aritmético
x / 0 com inteirosdivisão por zero

Para comparar com linguagens que você conhece: um panic é como uma IndexOutOfBoundsException ou uma NullPointerException que ninguém vai capturar. O comportamento padrão de um programa Rust comum é:

  1. imprimir a mensagem e o local (arquivo:linha);
  2. desfazer a pilha, chamando os destrutores no caminho (unwinding);
  3. encerrar o processo. O sistema operacional limpa o resto.

2. O problema no kernel

No kernel, nenhum desses três passos existe:

  1. Imprimir onde? O println! é da std, que não temos.
  2. O unwinding depende de bibliotecas de suporte da std.
  3. Encerrar o processo e voltar para quem? O kernel é o processo, e não há ninguém abaixo dele.

Por isso, num projeto #![no_std], o compilador exige que você escreva a função que decide o que acontece num panic. Sem ela, o projeto não compila:

#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    ...
}
}
  • #[panic_handler] é o atributo que diz “esta é a função de panic”. Só pode haver uma no programa inteiro.
  • info: &PanicInfo traz a mensagem e o local do erro.
  • -> ! diz que ela nunca retorna (rust-basico §3). Faz sentido: depois de um bug, não há como continuar com segurança. O compilador verifica que a função termina num loop infinito.

3. Antes e depois

Até a etapa anterior, o SiliconOS usava o panic handler que vem pronto na crate uefi (ligado pela opção features = ["panic_handler"] no Cargo.toml). Ele escreve a mensagem usando o console do firmware. Só que, depois de exit_boot_services, esse console não existe mais. Resultado: um panic no kernel travava em silêncio, o pior tipo de bug para investigar.

Agora a opção foi removida do Cargo.toml e o SiliconOS tem o seu próprio tratador. Um panic mostra isto, na serial e na tela (com fundo vermelho):

!!! KERNEL PANIC !!!
Local: src/main.rs:111:25
Mensagem: index out of bounds: the len is 3 but the index is 20790

4. O tratador, passo a passo

Um tratador de erro tem uma exigência especial: ele mesmo não pode falhar, porque não há mais ninguém para tratar o erro dele. Cada passo abaixo existe para evitar uma forma de falha.

Passo 1: desligar as interrupções

#![allow(unused)]
fn main() {
unsafe { core::arch::asm!("cli", options(nomem, nostack)) };
}

cli (clear interrupt flag) impede que interrupções de hardware interrompam o tratador. Hoje elas já estão desligadas, mas no capítulo 9 vamos ligar o timer. Sem o cli, um tique do timer poderia rodar código no meio do relatório de erro, e esse código pode estar justamente na parte quebrada do sistema. Num panic, a regra é: ninguém mais roda.

Passo 2: proteção contra panic dentro do panic

#![allow(unused)]
fn main() {
static EM_PANIC: AtomicBool = AtomicBool::new(false);

if EM_PANIC.swap(true, Ordering::SeqCst) {
    let _ = Serial.write_str("\n!!! PANIC DUPLO - parando\n");
    parar();
}
}

E se o próprio tratador tiver um bug, por exemplo no código que desenha na tela? Ele dispararia outro panic, que chamaria o tratador de novo, que dispararia outro panic… até a pilha estourar.

Para evitar isso, uma variável global marca “já estou tratando um panic”:

  • AtomicBool é um booleano que pode ser lido e alterado com segurança mesmo por várias CPUs ao mesmo tempo, como o AtomicBoolean do Java.
  • swap(true, ...) grava true e devolve o valor anterior, numa única operação que nada consegue interromper ao meio.
  • Na primeira vez, o valor anterior é false: seguimos normalmente. Se o tratador for chamado de novo, o valor anterior é true: escrevemos uma linha curta e paramos, sem tentar mais nada.

No futuro, com várias CPUs, isso também garante que só uma delas relate o panic.

Passo 3: escrever primeiro na serial

#![allow(unused)]
fn main() {
let _ = writeln!(Serial, "\n!!! KERNEL PANIC !!!");
escrever_detalhes(&mut Serial, info);
}

A serial é o canal com mais chance de funcionar: não depende de vídeo, memória ou lock nenhum. Se o resto falhar, pelo menos a serial já recebeu a mensagem.

Passo 4: escrever na tela, com cuidado com o lock

#![allow(unused)]
fn main() {
if CONSOLE.is_locked() {
    unsafe { CONSOLE.force_unlock() };
}
if let Some(con) = CONSOLE.lock().as_mut() {
    con.cor = 0x00FF_FFFF;
    con.fundo = 0x00B0_0000;      // vermelho
    let _ = writeln!(con, "\n!!! KERNEL PANIC !!!");
    escrever_detalhes(con, info);
    let _ = writeln!(con, "Sistema parado.");
}
}

Considere este cenário:

  1. Um kprintln! pega o lock do console.
  2. No meio do desenho, um bug dispara um panic.
  3. O tratador chama CONSOLE.lock() para escrever a mensagem…
  4. …e fica esperando para sempre um lock que nunca será liberado, porque quem o segurava nunca mais vai rodar.

Isso é um deadlock: a máquina trava e a tela não mostra nada. Para evitá-lo, se o lock estiver preso, forçamos a liberação com force_unlock().

Normalmente forçar a liberação de um lock é perigoso, porque o dado protegido pode ter ficado pela metade. Aqui é aceitável: quem segurava o lock nunca mais vai rodar, e o pior que pode acontecer é uma letra mal desenhada. Por isso a chamada é unsafe.

Passo 5: parar a CPU para sempre

#![allow(unused)]
fn main() {
fn parar() -> ! {
    loop {
        unsafe { core::arch::asm!("cli; hlt", options(nomem, nostack)) };
    }
}
}
  • hlt faz a CPU dormir até a próxima interrupção. Gasta menos energia que um loop vazio, que deixaria a CPU a 100%.
  • O cli antes garante que nenhuma interrupção comum a acorde.
  • O loop existe para um caso raro: há uma interrupção especial (NMI) que não pode ser desligada. Se ela acordar a CPU, o loop a põe para dormir de novo.

5. As informações do PanicInfo

#![allow(unused)]
fn main() {
fn escrever_detalhes(w: &mut impl Write, info: &PanicInfo) {
    if let Some(loc) = info.location() {
        let _ = writeln!(w, "Local: {}:{}:{}", loc.file(), loc.line(), loc.column());
    }
    let _ = writeln!(w, "Mensagem: {}", info.message());
}
}
  • info.location() traz arquivo, linha e coluna. O compilador grava essas informações dentro do binário no momento em que compila cada panic!.
  • info.message() é a mensagem já formatada.
  • O parâmetro w: &mut impl Write aceita qualquer destino de texto. Por isso a mesma função escreve tanto na Serial quanto no Console (rust-basico §5).

6. Como foi testado

Uma “feature” só para teste

Para ver o tratador funcionando, precisamos provocar um panic de propósito, mas sem deixar esse código na versão normal. Para isso, o Cargo.toml declara uma opção de compilação:

[features]
teste-panic = []

E o código de teste fica dentro de um bloco que só é compilado quando essa opção está ligada (rust-basico §12):

#![allow(unused)]
fn main() {
#[cfg(feature = "teste-panic")]
{
    let v: [u32; 3] = [1, 2, 3];
    let i = paginas_livres as usize;   // um número grande: índice inválido de propósito
    kprintln!("Testando panic: acessando v[{}]...", i);
    kprintln!("{}", v[i]);             // ← panic aqui
}
}

Por que o índice vem de paginas_livres, e não de um número fixo como 10? Porque, se escrevêssemos v[10], o compilador perceberia o erro durante a compilação e recusaria compilar. Usando um valor que só é conhecido durante a execução, o erro acontece de verdade em tempo de execução.

./run.sh teste-panic compila com a feature ligada. ./run.sh compila sem ela, e o bloco simplesmente não existe no binário.

-no-reboot

O run.sh passa -no-reboot ao QEMU. Se a CPU reiniciar (o que acontece num triple fault, doc 04), o QEMU fecha em vez de reiniciar em loop. Assim a última mensagem na serial fica visível, e fica fácil perceber que algo deu muito errado.

7. O que este tratador ainda não pega

O panic handler só é chamado por erros que o Rust detecta: verificações que o compilador inseriu no código (limite de array, unwrap…).

Existe outra categoria: erros que a própria CPU detecta ao executar uma instrução, como acessar um endereço que não existe, executar bytes que não são instrução ou dividir por zero em assembly. Esses geram exceções da CPU, que não passam pelo Rust e não chamam o panic handler. Para tratá-las precisamos da IDT (doc 04), que por sua vez depende da GDT (doc 03).


Resumo

  • Um panic é um erro de lógica do programa (índice inválido, unwrap em None…). Num programa comum, a std imprime e encerra o processo.
  • Num kernel no_std, nós escrevemos o #[panic_handler], que nunca retorna (-> !).
  • O tratador é escrito para não poder falhar: desliga interrupções, evita recursão (AtomicBool), escreve primeiro na serial, força a liberação do lock da tela e para a CPU com cli; hlt.
  • Erros detectados pela CPU (não pelo Rust) precisam de outro mecanismo: a IDT.
Para fixar (clique para ver as respostas)
  1. Por que o tratador escreve na serial antes da tela? Porque a serial não depende de vídeo nem de lock; é o canal com mais chance de funcionar, mesmo que o console esteja quebrado.

  2. O que aconteceria sem o force_unlock, se o panic ocorresse dentro de um kprintln!? Deadlock: o tratador esperaria para sempre por um lock que nunca seria liberado, e a mensagem de erro nunca apareceria na tela.

  3. Por que o teste usa paginas_livres como índice em vez de 10? Com um número fixo, o compilador detectaria o erro durante a compilação e recusaria compilar. Precisamos de um valor conhecido só em tempo de execução.

  4. Um acesso a um endereço de memória inexistente chama o panic handler? Não. Quem detecta é a CPU, que gera uma exceção (page fault), tratada pela IDT (doc 04).

03: GDT e TSS: colocando a CPU num estado que é nosso

Você vai entender: o que são segmentos e níveis de privilégio; por que, mesmo num processador de 64 bits, ainda precisamos de uma tabela chamada GDT; o que é a TSS e por que ela guarda “pilhas de emergência”; e como se entrega uma tabela nova à CPU.

Arquivo: src/gdt.rs (chamado no início de kernel_main) Livro: capítulo 4 Rust novo aqui: #[repr(C)] e packed (§13), const e static mut (§10), Once (§11), closures (§16) Leia antes: a seção “Bits, bytes e hexadecimal” do glossário


1. O problema

Quando o kernel_main começa, a CPU ainda está configurada com tabelas que o firmware montou para uso próprio. O próprio SiliconOS mostra isso no boot:

GDT do firmware:  base=0x75dc000 CS=0x38 SS=0x30 TR=0x0
GDT do SiliconOS: base=0x60af020 limite=55 CS=0x8 SS=0x10 DS=0x10 TR=0x28

A primeira linha diz que, antes desta etapa, o kernel rodava usando uma tabela localizada no endereço 0x75dc000, que pertence ao firmware. Isso traz três problemas:

  1. Aquela memória não é nossa. Quando escrevermos o gerenciador de memória, essa região pode ser reaproveitada e sobrescrita. A CPU continuaria lendo a tabela dali, agora cheia de lixo, e o sistema quebraria de forma misteriosa.
  2. Faltam coisas que vamos precisar: pilhas de emergência para erros graves (usadas no doc 04) e configurações para programas comuns (modo usuário).
  3. Não sabemos exatamente o que o firmware configurou. Um kernel precisa saber em que estado a CPU está.

A solução é montar nossa própria tabela e mandar a CPU usá-la.


2. Um pouco de história: o que são segmentos

Para entender a GDT, é preciso saber de onde ela veio. A arquitetura x86 tem mais de 45 anos e nunca quebrou a compatibilidade; cada geração empilhou novidades sobre a anterior.

1978: o 8086 e os segmentos

Os registradores do 8086 tinham 16 bits, o que permite endereçar só 64 KiB. Para chegar a 1 MiB, a Intel criou os registradores de segmento (CS para código, DS para dados, SS para a pilha…). O endereço real era calculado assim:

endereço real = segmento × 16 + deslocamento

Era como ter vários “ponteiros-base” para regiões da memória.

1982–1985: modo protegido e a GDT

Nos processadores seguintes (286, 386), os registradores de segmento deixaram de guardar um endereço e passaram a guardar um seletor: um número que aponta para uma linha de uma tabela na memória, a GDT (Global Descriptor Table). Cada linha, chamada descritor, descreve um segmento:

  • onde ele começa (base);
  • qual o tamanho (limite);
  • quem pode usá-lo (nível de privilégio);
  • o que se pode fazer com ele (executar? escrever?).

Foi assim que nasceu a proteção de memória: um programa comum não conseguia acessar os segmentos do sistema operacional.

2003: modo de 64 bits (“long mode”)

Quando a AMD criou o x86-64, a segmentação já tinha sido trocada por um mecanismo melhor, a paginação (capítulo 7 do livro). Então, em 64 bits, a CPU ignora a base e o limite dos segmentos de código e dados: a memória vira um espaço único e “plano”.

Mas a GDT não foi abolida, porque ela ainda é necessária para três coisas:

  1. CS define o modo e o privilégio do código. Um bit do descritor diz “este é código de 64 bits”, e outro campo diz em que nível de privilégio o código roda.
  2. SS precisa apontar para um descritor válido em algumas situações (ao voltar de uma interrupção, ao trocar de nível de privilégio).
  3. A TSS (seção 5) só pode ser encontrada pela CPU através da GDT.

Resumindo: em 64 bits, a GDT é uma tabela pequena e quase protocolar, mas obrigatória.


3. Níveis de privilégio (anéis)

A CPU x86 tem 4 níveis de privilégio, chamados anéis, numerados de 0 a 3. Na prática, todos os sistemas operacionais modernos usam só dois:

 ┌───────────────────────────────────────────────────────────────┐
 │ anel 3: programas comuns (navegador, editor, seu backend)      │
 │   • não podem executar instruções privilegiadas                │
 │   • não podem acessar a memória do kernel                      │
 │   ┌───────────────────────────────────────────────────────┐   │
 │   │ anel 0: kernel                                         │   │
 │   │   • pode tudo: cli, hlt, lgdt, in/out, mexer na memória│   │
 │   └───────────────────────────────────────────────────────┘   │
 └───────────────────────────────────────────────────────────────┘

O anel atual da CPU fica nos 2 bits mais baixos do registrador CS. Se CS = 0x08, os 2 bits baixos são 00, ou seja, anel 0. Se um programa no anel 3 tentar executar cli ou hlt, a CPU recusa e gera uma exceção (doc 04).

É essa separação que impede um programa com bug de derrubar o sistema inteiro. Hoje o SiliconOS só tem código no anel 0, mas a GDT já prevê os segmentos do anel 3 para quando rodarmos programas.


4. A nossa GDT

4.1 O conteúdo

Linha (índice)SeletorDescreveUsado para
00x00nada (linha nula)obrigatória pela especificação; carregar o seletor 0 em CS é proibido
10x08código do kernel, 64 bits, anel 0CS enquanto o kernel roda
20x10dados do kernel, anel 0SS, DS, ES
30x1Bdados de usuário, anel 3futuro: programas comuns
40x23código de usuário, 64 bits, anel 3futuro: programas comuns
5 e 60x28a TSS (ocupa duas linhas)pilhas de emergência

No código, a tabela é simplesmente um array de 7 números de 64 bits:

#![allow(unused)]
fn main() {
[
    0,                // linha 0: nula
    DESC_KERNEL_CODE, // linha 1
    DESC_KERNEL_DATA, // linha 2
    DESC_USER_DATA,   // linha 3
    DESC_USER_CODE,   // linha 4
    tss_baixo,        // linha 5 ┐ descritor da TSS
    tss_alto,         // linha 6 ┘ (16 bytes)
]
}

4.2 Como funciona um seletor

O seletor é o número que vai nos registradores de segmento (CS, DS…). Ele não é o índice da linha diretamente; é o índice multiplicado por 8, mais alguns bits de informação:

seletor (16 bits):   índice da linha  |  TI  |  RPL
                     bits 15 ... 3    | bit 2| bits 1-0
  • índice: qual linha da GDT. Multiplicar por 8 equivale a deslocar 3 bits para a esquerda (1 << 3 = 8), abrindo espaço para os outros campos. Cada linha tem 8 bytes, então “índice × 8” também é a posição em bytes dentro da tabela.
  • TI: 0 = GDT (há uma tabela alternativa, a LDT, que não usamos).
  • RPL: o nível de privilégio “pedido”. Para segmentos de usuário, é 3.

Em Rust:

#![allow(unused)]
fn main() {
pub const KERNEL_CODE: u16 = 1 << 3;        // linha 1 → 0x08
pub const KERNEL_DATA: u16 = 2 << 3;        // linha 2 → 0x10
pub const USER_DATA: u16 = (3 << 3) | 3;    // linha 3, anel 3 → 0x18 | 3 = 0x1B
pub const USER_CODE: u16 = (4 << 3) | 3;    // linha 4, anel 3 → 0x20 | 3 = 0x23
pub const TSS_SEL: u16 = 5 << 3;            // linha 5 → 0x28
}

Por que os dados de usuário vêm antes do código de usuário? Porque as instruções syscall/sysret, que usaremos no futuro para os programas pedirem coisas ao kernel, calculam esses seletores somando valores fixos e esperam exatamente essa ordem.

4.3 Montando um descritor bit a bit

Cada descritor é um número de 64 bits em que cada bit (ou grupo de bits) tem um significado definido pelo manual da Intel/AMD. Muitos tutoriais escrevem o valor pronto, como 0x00AF9A000000FFFF, o que é impossível de entender. O SiliconOS dá nome a cada bit e monta o valor com | (OR):

#![allow(unused)]
fn main() {
const P: u64 = 1 << 47;    // Present: o descritor é válido
const S: u64 = 1 << 44;    // 1 = segmento de código/dados (0 = "de sistema", como a TSS)
const E: u64 = 1 << 43;    // Executable: é código
const RW: u64 = 1 << 41;   // código: pode ser lido / dados: pode ser escrito
const L: u64 = 1 << 53;    // Long: código de 64 bits
const DB: u64 = 1 << 54;   // tamanho padrão de 32 bits (não pode ir junto com L)
const G: u64 = 1 << 55;    // Granularity: o limite conta em blocos de 4 KiB
const fn dpl(n: u64) -> u64 { n << 45 }   // nível de privilégio: bits 45 e 46

const DESC_KERNEL_CODE: u64 = P | S | E | RW | L | G | LIMITE_MAX | dpl(0);
const DESC_KERNEL_DATA: u64 = P | S | RW | DB | G | LIMITE_MAX | dpl(0);
const DESC_USER_DATA:   u64 = P | S | RW | DB | G | LIMITE_MAX | dpl(3);
const DESC_USER_CODE:   u64 = P | S | E | RW | L | G | LIMITE_MAX | dpl(3);
}

Leia DESC_KERNEL_CODE como uma frase: “presente, de código/dados, executável, legível, de 64 bits, …, anel 0”. A diferença entre o código do kernel e o do usuário é só o DPL (0 ou 3).

const fn é uma função que o compilador consegue executar durante a compilação. Todos esses valores são calculados antes do programa rodar; o binário já contém os números prontos.

Base e limite são ignorados em 64 bits, mas preenchemos o limite com o valor máximo (LIMITE_MAX) por convenção: algumas ferramentas e emuladores conferem esse campo.


5. A TSS (Task State Segment)

5.1 O que sobrou dela em 64 bits

O nome é histórico. Em 32 bits, a TSS guardava o estado completo de uma tarefa (todos os registradores) para que o próprio processador trocasse de tarefa. Os sistemas operacionais não usavam muito esse recurso, e ele foi removido em 64 bits. O que sobrou da TSS é basicamente uma lista de endereços de pilha:

#![allow(unused)]
fn main() {
#[repr(C, packed(4))]
pub struct Tss {
    _reservado0: u32,
    pub rsp: [u64; 3],     // pilhas para quando a CPU sobe de privilégio (anel 3 → 0)
    _reservado1: u64,
    pub ist: [u64; 7],     // Interrupt Stack Table: 7 pilhas de emergência
    _reservado2: u64,
    _reservado3: u16,
    pub iomap_base: u16,
}

const _: () = assert!(size_of::<Tss>() == 104);
}

Dois detalhes de Rust importantes:

  • #[repr(C, packed(4))]: a CPU lê essa struct diretamente da memória, esperando cada campo numa posição exata (o manual diz “o campo ist[0] fica no byte 36”). repr(C) impede o compilador de reordenar os campos; packed(4) impede que ele insira espaços de alinhamento entre eles (rust-basico §13).
  • const _: () = assert!(...): uma verificação feita durante a compilação. Se, por qualquer erro, a struct não tiver exatamente 104 bytes (o tamanho definido pela Intel), o projeto não compila. É uma rede de segurança barata.

5.2 Por que pilhas de emergência

Lembre que a pilha guarda as variáveis locais e os endereços de retorno das funções (glossário: pilha). Quando ocorre uma exceção, a CPU empilha informações (onde o código estava, as flags…) antes de chamar o tratador. Agora imagine:

  1. O kernel tem uma recursão infinita, e a pilha cresce até sair da região de memória válida.
  2. A próxima chamada tenta escrever na pilha, numa memória que não existe: a CPU gera uma exceção (page fault).
  3. Para chamar o tratador do page fault, a CPU precisa empilhar as informações… na mesma pilha quebrada. Falha de novo, e a CPU declara um double fault (“falha enquanto tratava uma falha”).
  4. Para chamar o tratador do double fault, a CPU tenta empilhar de novo, e falha de novo. Isso é um triple fault: a CPU desiste e reinicia o computador, sem mostrar nada.

A solução é a IST: a TSS guarda até 7 endereços de pilhas separadas. Uma entrada da tabela de interrupções (doc 04) pode dizer: “para esta exceção, troque para a pilha IST nº 1 antes de empilhar qualquer coisa”. Assim o tratador de double fault roda numa pilha limpa e garantidamente válida, e consegue mostrar a mensagem de erro.

O doc 04 mostra isso funcionando, com teste.

5.3 A pilha de emergência no código

#![allow(unused)]
fn main() {
const TAM_PILHA_IST: usize = 5 * 4096;     // 20 KiB

#[repr(C, align(16))]
struct Pilha([u8; TAM_PILHA_IST]);

static mut PILHA_DOUBLE_FAULT: Pilha = Pilha([0; TAM_PILHA_IST]);
}
  • A pilha é só um array de 20 480 bytes.
  • align(16): o início do array fica num endereço múltiplo de 16, um alinhamento que a convenção de chamada do x86-64 exige para a pilha.
  • É static, portanto fica dentro do próprio executável (numa seção chamada .bss, para variáveis inicializadas com zero). Não precisamos de alocador de memória, que ainda não temos.
  • É static mut (global mutável) porque a CPU vai escrever nela.

A pilha cresce para baixo: cada push diminui o endereço. Por isso a TSS recebe o endereço do fim do array, não do começo:

#![allow(unused)]
fn main() {
let inicio = &raw const PILHA_DOUBLE_FAULT as u64;   // endereço do primeiro byte
ist[0] = inicio + TAM_PILHA_IST as u64;              // topo = primeiro byte DEPOIS do array
}

&raw const X obtém o endereço de X como ponteiro cru, sem criar uma referência Rust. É a forma recomendada de pegar o endereço de uma static mut.

Curiosidade vista no boot: o topo da pilha de emergência (0x60af020) é exatamente o endereço onde começa a GDT. O compilador colocou a GDT logo depois da pilha na memória. Está correto: o “topo” é o primeiro byte depois da pilha, e o primeiro push grava em topo - 8.

5.4 Os outros campos

  • rsp[0]: usado no futuro. Quando um programa do anel 3 for interrompido, a CPU troca automaticamente para a pilha do kernel indicada aqui. O kernel não pode confiar na pilha de um programa comum.
  • iomap_base: definir com o tamanho da TSS significa “não há mapa de permissões de E/S”, ou seja, programas comuns não podem usar in/out para falar com dispositivos.

5.5 O descritor da TSS ocupa duas linhas

Descritores normais têm 8 bytes, e a base de um descritor só tem espaço para 32 bits (espalhados em três pedaços, herança do 286). A TSS pode estar em qualquer endereço de 64 bits, então o descritor dela foi estendido para 16 bytes, ocupando duas linhas da GDT. A segunda linha guarda os 32 bits altos do endereço:

#![allow(unused)]
fn main() {
fn descritor_tss(base: u64, limite: u64) -> (u64, u64) {
    let tipo_tss_disponivel: u64 = 0x9;      // "TSS de 64 bits, disponível"
    let baixo = (limite & 0xFFFF)              // limite, bits 0-15
        | ((base & 0xFF_FFFF) << 16)          // base, bits 0-23
        | (tipo_tss_disponivel << 40)
        | P
        | (((limite >> 16) & 0xF) << 48)      // limite, bits 16-19
        | (((base >> 24) & 0xFF) << 56);      // base, bits 24-31
    let alto = base >> 32;                    // base, bits 32-63
    (baixo, alto)
}
}

É só “picar” o endereço e o tamanho e encaixar cada pedaço no lugar que o manual manda. A função devolve uma tupla com os dois números (como um Pair do Kotlin).


6. Por que spin::Once

A GDT e a TSS têm duas exigências:

  1. Precisam existir para sempre e no mesmo endereço. A CPU guarda só o endereço delas, não uma cópia. Se a tabela fosse uma variável local de init(), ela seria destruída quando a função terminasse, e a CPU ficaria apontando para lixo.
  2. Precisam ser montadas em tempo de execução. O descritor da TSS contém o endereço da TSS, que só é conhecido quando o programa está carregado na memória.

Once<T> atende as duas (rust-basico §11):

#![allow(unused)]
fn main() {
static TSS: Once<Tss> = Once::new();     // vazio no início, mas com endereço fixo
static GDT: Once<[u64; 7]> = Once::new();

let tss = TSS.call_once(|| { /* monta a TSS */ });   // executa só na primeira vez
}

call_once executa a closure (rust-basico §16) uma única vez, guarda o resultado dentro do static e devolve uma referência &'static, ou seja, válida para sempre.


7. Entregando as tabelas à CPU

Montar a tabela na memória não basta: é preciso dizer à CPU onde ela está e fazê-la usar os novos segmentos. São quatro passos, todos em assembly.

Passo 1: lgdt: “a GDT está aqui”

#![allow(unused)]
fn main() {
#[repr(C, packed(2))]
pub(crate) struct PonteiroTabela {
    pub limite: u16,   // tamanho da tabela em bytes, menos 1
    pub base: u64,     // endereço da tabela
}

asm!("lgdt [{}]", in(reg) &ponteiro, ...);
}

A instrução lgdt (load GDT) lê uma estrutura de 10 bytes: o limite (tamanho − 1; aqui 7 × 8 − 1 = 55) e o endereço. O packed(2) garante que os dois campos fiquem colados, sem espaço entre eles, como a CPU espera.

Atenção: depois do lgdt, a CPU ainda não está usando os novos segmentos. Cada registrador de segmento guarda uma cópia interna do descritor que foi carregado nele, e só relê a tabela quando é recarregado. Por isso vêm os passos 2 e 3.

Passo 2: recarregar CS com um “retorno falso”

Não existe uma instrução mov cs, .... O registrador CS só muda por meio de um salto, uma chamada ou um retorno do tipo “far” (que troca de segmento). O truque usado por quase todo kernel é simular um retorno de função:

#![allow(unused)]
fn main() {
asm!(
    "push {sel}",            // empilha o novo seletor de código (0x08)
    "lea {tmp}, [rip + 2f]", // calcula o endereço do rótulo "2:" logo abaixo
    "push {tmp}",            // empilha esse endereço
    "retfq",                 // "far return": desempilha endereço + seletor e salta
    "2:",                    // ...e a execução continua aqui, com CS = 0x08
    ...
);
}

Um retfq (retorno “far” de 64 bits) desempilha um endereço e um seletor e salta para lá, carregando CS. Empilhamos exatamente o que ele espera: o endereço da linha seguinte e o seletor novo. Na prática, a execução continua na linha de baixo, mas agora com o CS novo.

(rip + 2f significa “o endereço do rótulo 2, procurando para frente (forward)”. rip é o registrador do endereço da instrução atual.)

Passo 3: recarregar os segmentos de dados

#![allow(unused)]
fn main() {
asm!("mov ss, {0:x}", "mov ds, {0:x}", "mov es, {0:x}", in(reg) KERNEL_DATA, ...);
asm!("mov fs, {0:x}", "mov gs, {0:x}", in(reg) 0u16, ...);
}

SS, DS e ES recebem o seletor de dados (0x10). FS e GS recebem 0: em 64 bits eles têm outro uso (dados por CPU e por thread), que veremos no futuro. O {0:x} pede a versão de 16 bits do registrador, porque registradores de segmento têm 16 bits.

Passo 4: ltr: “a TSS está aqui”

#![allow(unused)]
fn main() {
asm!("ltr {0:x}", in(reg) TSS_SEL, ...);
}

ltr (load task register) aponta o registrador de tarefa (TR) para o descritor da TSS na linha 5. Um detalhe: a CPU escreve na GDT neste momento, marcando o descritor como “ocupado” (busy). Por isso a GDT precisa estar em memória que aceite escrita, e um segundo ltr com o mesmo seletor causaria uma exceção.


8. Por que o cli no início do kernel_main

A tabela de interrupções que está ativa nesse momento ainda é a do firmware (doc 04), e cada entrada dela aponta para tratadores que usam o seletor de código do firmware, 0x38.

Na nossa GDT, a linha 7 (seletor 0x38) não existe: a tabela só tem 7 linhas, de 0 a 6. Se chegasse uma interrupção de hardware (um tique do timer do firmware, por exemplo) logo depois do lgdt, a CPU tentaria usar um segmento inexistente, falharia, e terminaria num triple fault.

Por isso o kernel_main começa com cli, que desliga as interrupções de hardware. Elas continuam desligadas até configurarmos nosso próprio controlador de interrupções (capítulo 9 do livro).


9. Como foi verificado

A função gdt::estado() lê os registradores de volta da CPU:

InstruçãoO que lê
sgdta base e o limite da GDT que a CPU está usando
mov reg, cs / ss / dsos seletores atuais
stro registrador de tarefa (qual TSS está carregada)

Resultado:

  • antes: CS=0x38 SS=0x30 TR=0x0: segmentos do firmware, e nenhuma TSS;
  • depois: CS=0x8 SS=0x10 DS=0x10 TR=0x28, base apontando para a nossa tabela, limite 55.

Também conferimos pelo monitor do QEMU (info registers), que mostra os registradores do ponto de vista do emulador, sem depender do nosso código. Ele confirmou: CS é “código de 64 bits, anel 0”, SS/DS são “dados”, e TR aponta para uma TSS de 64 bits com 104 bytes.

Se algum bit de um descritor estivesse errado, a CPU teria recusado o retfq ou o ltr com uma exceção, e a máquina teria reiniciado (com o QEMU em -no-reboot, ele simplesmente fecharia). O fato de o boot seguir normalmente já é uma prova de que os descritores são válidos.


Diferença em relação ao livro

O livro usa a crate x86_64, que tem GlobalDescriptorTable e TaskStateSegment prontos. O SiliconOS monta tudo à mão para que fique visível o que essas abstrações fazem. No futuro, se você preferir, dá para trocar pela crate.


Resumo

  • Segmentos vêm do 8086. Em 64 bits, base e limite são ignorados, mas a GDT continua obrigatória: define o anel do código (0 = kernel, 3 = programas) e localiza a TSS.
  • Seletor = índice da linha × 8 + bits de privilégio. CS = 0x08 significa “linha 1, anel 0”.
  • A TSS, em 64 bits, é uma lista de pilhas. A IST dá pilhas de emergência para erros graves, evitando que uma pilha quebrada vire reinício.
  • Para trocar a GDT: lgdt (endereço), retfq (recarrega CS), mov nos segmentos de dados, ltr (carrega a TSS).
  • O cli no início evita que uma interrupção use o seletor do firmware, que não existe na nossa tabela.
Para fixar (clique para ver as respostas)
  1. Se em 64 bits a CPU ignora a base e o limite dos segmentos, para que serve a GDT? Para definir o nível de privilégio e o modo (64 bits) do código, ter segmentos válidos para SS, e localizar a TSS.

  2. O que significa o seletor 0x23? 0x23 = 0x20 | 3 = linha 4 (4 × 8 = 0x20), com privilégio 3: o segmento de código de usuário.

  3. Por que a TSS recebe o endereço do fim do array da pilha? Porque a pilha cresce para baixo: o primeiro push grava logo abaixo do topo.

  4. Por que não basta executar lgdt? Porque os registradores de segmento guardam cópias internas dos descritores antigos; é preciso recarregá-los (CS via retfq, os outros via mov).

  5. O que aconteceria se uma interrupção de hardware chegasse logo depois do lgdt, sem o cli? A IDT do firmware mandaria a CPU usar o seletor 0x38, que não existe na nossa GDT. Isso causaria uma falha, depois um double fault e provavelmente um triple fault (reinício).

04: IDT e exceções: quando a CPU encontra um erro

Você vai entender: a diferença entre um erro detectado pelo Rust e um erro detectado pela CPU; o que é a tabela de interrupções (IDT); o que a CPU faz, passo a passo, quando algo dá errado; e como o SiliconOS transforma cada tipo de erro numa mensagem útil, inclusive o temido double fault.

Arquivo: src/idt.rs (chamado em kernel_main logo depois de gdt::init()) Livro: capítulo 5 Testar: ./run.sh teste-breakpoint (ou teste-divisao, teste-opcode, teste-gpf, teste-pagefault, teste-doublefault) Leia antes: doc 02 (panic) e doc 03 (GDT, TSS e IST) Rust novo aqui: extern "x86-interrupt" (seção 5 deste doc), impl Display (seção 4)


1. Dois tipos de erro

O doc 02 tratou dos erros que o Rust detecta: quando você escreve v[i], o compilador insere uma verificação “i é menor que o tamanho?” antes do acesso, e chama panic! se não for.

Existe outra classe de erros, que o Rust nem chega a ver, porque quem os detecta é a própria CPU, no meio da execução de uma instrução:

SituaçãoQuem detectaO que acontece
v[10] num array de 3o Rust (verificação gerada pelo compilador)panic! → doc 02
ler um endereço de memória que não existea CPUexceção nº 14 (page fault)
instrução div com divisor 0 (em assembly)a CPUexceção nº 0
bytes que não formam uma instrução válidaa CPUexceção nº 6

Uma comparação com Java: o panic é como uma exceção lançada pelo seu código (throw). A exceção da CPU é como um segmentation fault que derruba a JVM inteira: acontece abaixo da linguagem.

Quando a CPU detecta um problema desses, ela interrompe o que estava fazendo e salta para uma rotina de tratamento, o tratador (handler). A pergunta é: qual rotina? A resposta está numa tabela que o kernel precisa fornecer, a IDT.

Sem uma IDT nossa, essas exceções caem nos tratadores do firmware (que não funcionam mais depois de exit_boot_services) ou viram reinício.


2. O que é a IDT

A IDT (Interrupt Descriptor Table) é uma tabela com 256 linhas, uma para cada número de vetor (0 a 255). Cada linha diz:

“Quando acontecer o evento nº N, salte para este endereço (o tratador), usando este segmento de código e, opcionalmente, esta pilha de emergência.”

É parecida com um mapa de event listeners do JavaScript: para cada tipo de evento, a função que deve ser chamada. A diferença é que quem dispara os eventos é o hardware, e o “mapa” é uma tabela binária com formato definido pela Intel.

Quem usa a IDT

Os 256 vetores são compartilhados por três tipos de evento:

TipoOrigemExemploVetores
Exceçõesa CPU, por causa da instrução atualdivisão por zero, page fault0–31 (fixos, definidos pela Intel)
Interrupções de hardwareum dispositivo pedindo atençãotecla pressionada, tique do timerescolhidos pelo kernel (32+)
Interrupções de softwarea instrução int Nbreakpoint (int3), chamadas de sistema antigasqualquer um

Neste capítulo tratamos só de exceções. Interrupções de hardware vêm no capítulo 9 do livro.

As exceções que o SiliconOS trata

VetorSiglaNomeQuando aconteceEmpilha código de erro?Tipo
0#DEDivide Errordivisão inteira por zeronãofault
3#BPBreakpointinstrução int3nãotrap
6#UDUndefined Opcodebytes que não são uma instruçãonãofault
8#DFDouble Faulterro durante o tratamento de outro errosim (sempre 0)abort
13#GPGeneral Protection“você não pode fazer isso” (vários motivos)simfault
14#PFPage Faultacesso a memória não mapeada ou proibidasimfault

A coluna Tipo diz o que acontece depois do tratador:

  • Fault (“falha”): a CPU guarda o endereço da instrução que falhou. Se o tratador corrigir o problema e retornar, a CPU tenta a instrução de novo. É assim que funciona a memória virtual sob demanda: o page fault acontece, o kernel carrega a página que faltava, retorna, e o programa nem percebe.
  • Trap (“armadilha”): a CPU guarda o endereço da instrução seguinte. Ao retornar, a execução continua normalmente. Breakpoints de depurador funcionam assim.
  • Abort: não há como continuar; o estado da máquina não é confiável.

A coluna código de erro é crucial e volta na seção 5: algumas exceções empilham um número extra com detalhes do erro, e o tratador precisa saber disso.

E as exceções que não tratamos?

As linhas da tabela que não preenchemos ficam marcadas como “não presentes”. Se acontecer uma exceção sem tratador, a CPU não consegue entregá-la e gera uma #GP (proteção geral), com o código de erro indicando qual vetor estava faltando. Como a #GP tem tratador, a mensagem aparece mesmo assim. Por isso o tratador de #GP decodifica o código de erro (seção 6.4).


3. O formato de uma linha da IDT

Cada linha tem 16 bytes:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
#[repr(C)]
struct Entrada {
    endereco_0_15: u16,    // endereço do tratador, bits 0-15
    seletor: u16,          // segmento de código do tratador: 0x08 (KERNEL_CODE)
    ist: u8,               // 0 = usar a pilha atual; 1 a 7 = trocar para a pilha IST n
    tipo_atributos: u8,    // 0x8E (explicado abaixo)
    endereco_16_31: u16,   // endereço, bits 16-31
    endereco_32_63: u32,   // endereço, bits 32-63
    _reservado: u32,
}

const _: () = assert!(size_of::<Entrada>() == 16);   // garantido na compilação
}

O endereço do tratador vem partido em três pedaços pela mesma razão da GDT (doc 03): o formato de 32 bits foi estendido para 64 sem mudar a posição dos campos antigos.

#[repr(C)] garante que os campos fiquem nessa ordem exata na memória (rust-basico §13), e o assert! garante que a struct tenha exatamente os 16 bytes que a CPU espera.

O byte 0x8E

0x8E = 1000 1110
       │└┤ └──┴─ bits 0-3 = 1110 (0xE): "interrupt gate" de 64 bits
       │ └────── bits 5-6 = 00: DPL 0 (só o kernel pode disparar com `int N`)
       └──────── bit 7 = 1: presente
  • Presente (P = 1): a linha é válida.
  • DPL = 0: se um programa comum (anel 3) tentar int 14 para fingir um page fault, a CPU recusa com #GP. O breakpoint às vezes usa DPL 3 para que depuradores de programas comuns funcionem; não precisamos disso agora.
  • Tipo 0xE, “interrupt gate”: a alternativa seria 0xF, “trap gate”. A única diferença: o interrupt gate desliga as interrupções de hardware ao entrar no tratador, para que um tique do timer não interrompa o tratamento de um erro. É a escolha mais segura e a que quase todo kernel usa.

O seletor: por que a GDT veio antes

O campo seletor diz em que segmento de código o tratador vai rodar, e precisa ser o nosso 0x08. Este é o motivo de o doc 03 vir primeiro: a IDT do firmware apontava para o seletor 0x38 dele, que não existe na nossa GDT.

Montando uma linha

#![allow(unused)]
fn main() {
fn new(tratador: u64, ist: u16) -> Entrada {
    Entrada {
        endereco_0_15: tratador as u16,              // os 16 bits de baixo
        seletor: gdt::KERNEL_CODE,
        ist: ist as u8,
        tipo_atributos: INTERRUPT_GATE,              // 0x8E
        endereco_16_31: (tratador >> 16) as u16,     // descarta 16 bits, pega os próximos 16
        endereco_32_63: (tratador >> 32) as u32,
        _reservado: 0,
    }
}
}

Lembre de rust-basico §1: converter com as u16 descarta os bits altos. É exatamente o que queremos para picar o endereço.


4. O que a CPU faz ao receber uma exceção

Este é o coração do capítulo. Quando, por exemplo, uma instrução tenta ler um endereço inválido, a CPU executa sozinha, em hardware, esta sequência:

 1. consulta a linha 14 da IDT
 2. se a linha pede IST n: troca RSP para tss.ist[n-1]       (pilha de emergência)
 3. alinha RSP num múltiplo de 16
 4. empilha: SS, RSP antigo, RFLAGS, CS, RIP                 (o "quadro de interrupção")
 5. se a exceção tem código de erro: empilha o código de erro
 6. se a linha é um "interrupt gate": desliga as interrupções (IF = 0)
 7. salta para o endereço do tratador

O tratador, quando termina, executa a instrução iretq (interrupt return), que faz o caminho inverso: desempilha RIP, CS, RFLAGS, RSP e SS de uma vez e volta exatamente para onde estava, com o estado restaurado. Dois detalhes importantes:

  • não é ret: um ret comum desempilharia só o endereço de retorno, e a pilha ficaria desalinhada;
  • o código de erro, se existir, precisa ser removido da pilha antes do iretq, senão o iretq interpretaria o código de erro como se fosse o RIP.

O quadro em Rust

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct QuadroInterrupcao {
    pub rip: u64,      // onde o código estava (ou a próxima instrução, nas traps)
    pub cs: u64,       // segmento de código de quem foi interrompido
    pub rflags: u64,   // as flags da CPU naquele momento
    pub rsp: u64,      // a pilha de quem foi interrompido
    pub ss: u64,
}
}

Os campos estão na ordem em que ficam na memória, do endereço mais baixo (o último empilhado, RIP) para o mais alto (o primeiro empilhado, SS).

Para imprimir o quadro com {} em mensagens, implementamos a trait Display, o equivalente ao toString() do Java (rust-basico §5):

#![allow(unused)]
fn main() {
impl fmt::Display for QuadroInterrupcao {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "RIP={:#x} CS={:#x} RFLAGS={:#x} RSP={:#x} SS={:#x}",
               self.rip, self.cs, self.rflags, self.rsp, self.ss)
    }
}
}

{:#x} formata em hexadecimal com o prefixo 0x.


5. Escrevendo tratadores em Rust: extern "x86-interrupt"

Uma função Rust comum não serve como tratador, por três motivos:

  1. Ela termina em ret, não em iretq.
  2. Ela pode alterar registradores sem salvá-los. Numa chamada de função comum existe um combinado (a convenção de chamada): quem chama sabe que alguns registradores podem ser alterados e salva o que precisa. Mas aqui ninguém chamou: a CPU interrompeu, no meio de uma instrução qualquer, um código que não esperava por isso. Se o tratador alterar RAX, o código interrompido volta com RAX trocado e se comporta de forma imprevisível. O tratador precisa salvar e restaurar todos os registradores que usar.
  3. Ela não sabe que há um quadro e, às vezes, um código de erro na pilha.

Fazer isso à mão exige um trecho de assembly para cada tratador. O Rust nightly oferece um atalho: a convenção de chamada x86-interrupt, ligada no topo do main.rs:

#![allow(unused)]
#![feature(abi_x86_interrupt)]
fn main() {
}

Com ela, basta declarar a função assim:

#![allow(unused)]
fn main() {
extern "x86-interrupt" fn breakpoint(quadro: QuadroInterrupcao) { ... }
extern "x86-interrupt" fn page_fault(quadro: QuadroInterrupcao, codigo: u64) { ... }
extern "x86-interrupt" fn double_fault(quadro: QuadroInterrupcao, codigo: u64) -> ! { ... }
}

e o compilador gera tudo: salva os registradores usados, entrega o quadro como parâmetro, retira o código de erro da pilha e termina com iretq.

Cuidado: a assinatura tem que bater com o que a CPU empilha. Se você declarar codigo: u64 numa exceção que não empilha código de erro (ou esquecer numa que empilha), o tratador vai ler a pilha deslocada em 8 bytes, e o iretq vai voltar para um endereço aleatório. Por isso a coluna “Empilha código de erro?” da tabela da seção 2 é tão importante. O compilador não tem como verificar isso.

extern "..." em Rust especifica a convenção de chamada de uma função: como os parâmetros são passados e como ela retorna. Você já usou algo parecido sem perceber: quando Java chama código nativo via JNI, existe uma convenção específica para isso.


6. Os tratadores, um a um

6.1 Montando a tabela

#![allow(unused)]
fn main() {
static IDT: Once<[Entrada; 256]> = Once::new();

pub fn init() {
    let idt = IDT.call_once(|| {
        let mut t = [Entrada::VAZIA; 256];      // 256 linhas "não presentes"
        t[DIVISAO]         = Entrada::new(divisao as *const () as u64, 0);
        t[BREAKPOINT]      = Entrada::new(breakpoint as *const () as u64, 0);
        t[OPCODE_INVALIDO] = Entrada::new(opcode_invalido as *const () as u64, 0);
        t[DOUBLE_FAULT]    = Entrada::new(double_fault as *const () as u64, gdt::IST_DOUBLE_FAULT);
        t[PROTECAO_GERAL]  = Entrada::new(protecao_geral as *const () as u64, 0);
        t[PAGE_FAULT]      = Entrada::new(page_fault as *const () as u64, 0);
        t
    });
    // ... lidt (seção 7)
}
}
  • divisao as *const () as u64 converte a função no endereço dela. O Rust exige o passo intermediário por um ponteiro (*const ()) para deixar claro que estamos tratando a função como endereço.
  • Só o double fault recebe IST (gdt::IST_DOUBLE_FAULT = 1); os outros usam a pilha atual (0).
  • A tabela fica num Once pelo mesmo motivo da GDT (doc 03, seção 6): precisa ter endereço fixo para sempre e é montada em tempo de execução.

6.2 #BP: breakpoint, o único que continua

#![allow(unused)]
fn main() {
extern "x86-interrupt" fn breakpoint(quadro: QuadroInterrupcao) {
    kprintln!("[excecao] BREAKPOINT (#3) em {:#x} - continuando", quadro.rip);
}
}

Imprime e retorna. Saída do ./run.sh teste-breakpoint:

Teste: executando int3...
[excecao] BREAKPOINT (#3) em 0x608984e - continuando
Teste: voltamos do breakpoint normalmente.

Esse teste é a prova de que o caminho completo funciona: a CPU salvou o estado, consultou a IDT, chamou o tratador, e o iretq devolveu o controle exatamente para a instrução depois do int3, com todos os registradores intactos.

É assim que depuradores como o GDB funcionam: para pôr um breakpoint numa linha, eles trocam o primeiro byte da instrução por 0xCC, o código de máquina do int3.

6.3 #DE e #UD: erros fatais simples

#![allow(unused)]
fn main() {
extern "x86-interrupt" fn divisao(quadro: QuadroInterrupcao) {
    panic!("EXCECAO #0 DIVISAO POR ZERO\n{}", quadro);
}
}

Esses erros não têm conserto, então o tratador simplesmente chama panic!, reaproveitando todo o trabalho do doc 02 (serial, tela vermelha, CPU parada). Saída:

!!! KERNEL PANIC !!!
Local: src/idt.rs:131:5
Mensagem: EXCECAO #0 DIVISAO POR ZERO
RIP=0x608e546 CS=0x8 RFLAGS=0x46 RSP=0x7e96130 SS=0x10

Atenção ao ler: o Local: é a linha do panic! dentro do idt.rs, não onde o erro aconteceu. O local real do erro é o RIP. Para traduzir um RIP em “arquivo e linha”, vamos usar o GDB ou a ferramenta addr2line mais adiante (capítulo 10 do livro).

Por que o teste de divisão usa assembly? Em Rust, x / 0 com inteiros é verificado pelo compilador e vira panic antes de chegar à CPU. Para ver a exceção da CPU, precisamos executar a instrução div diretamente:

#![allow(unused)]
fn main() {
core::arch::asm!("xor edx, edx", "mov eax, 1", "xor ecx, ecx", "div ecx", ...);
//                 EDX = 0        EAX = 1       ECX = 0        EDX:EAX / ECX → divide por 0
}

O #UD é testado com ud2, uma instrução que existe justamente para ser inválida: a Intel garante que ela sempre gera #UD.

6.4 #GP: proteção geral

É a exceção “genérica” do x86: a CPU a usa para dezenas de situações do tipo “isso não é permitido”. As mais comuns:

  • carregar um seletor inválido num registrador de segmento;
  • acessar um endereço não-canônico (explicado abaixo);
  • executar uma instrução privilegiada no anel 3;
  • disparar um vetor sem tratador na IDT.

O código de erro ajuda a diferenciar. Quando não é zero, ele identifica o seletor ou vetor envolvido, no mesmo formato de seletor do doc 03:

bits 3-15: índice    bits 1-2: qual tabela (00 = GDT, 01 ou 11 = IDT, 10 = LDT)
#![allow(unused)]
fn main() {
extern "x86-interrupt" fn protecao_geral(quadro: QuadroInterrupcao, codigo: u64) {
    if codigo == 0 {
        panic!("EXCECAO #13 PROTECAO GERAL (codigo=0: nenhum seletor envolvido)\n{}", quadro);
    }
    let origem = match (codigo >> 1) & 0b11 {
        0b00 => "GDT",
        0b10 => "LDT",
        _ => "IDT",
    };
    panic!("EXCECAO #13 PROTECAO GERAL (codigo={:#x}: indice {} na {})\n{}",
           codigo, codigo >> 3, origem, quadro);
}
}

Endereço não-canônico. Ponteiros em x86-64 têm 64 bits, mas a CPU usa só 48 deles para endereços. A regra é que os bits 48 a 63 sejam cópias do bit 47. Isso divide o espaço em duas metades válidas, com um enorme “buraco” proibido no meio:

0xFFFF_FFFF_FFFF_FFFF ┐
                      │ metade de cima (válida) ← o kernel costuma morar aqui
0xFFFF_8000_0000_0000 ┘
                       ╳ buraco: endereços "não-canônicos" → #GP
0x0000_7FFF_FFFF_FFFF ┐
                      │ metade de baixo (válida) ← programas comuns
0x0000_0000_0000_0000 ┘

O teste teste-gpf lê o endereço 0x8000_0000_0000_0000, que está no buraco. A CPU nem tenta traduzir: dá #GP direto, com código 0 (não há seletor envolvido).

6.5 #PF: page fault

Acontece quando a CPU tenta traduzir um endereço e não encontra a página correspondente, ou encontra mas o acesso não é permitido. A tradução de endereços (paginação) é o tema do capítulo 7 do livro; por enquanto, basta saber que a memória é dividida em páginas de 4 KiB e que o firmware deixou mapeada só a parte que existe.

Duas informações tornam o diagnóstico preciso:

  • CR2: um registrador de controle onde a CPU grava o endereço que se tentou acessar;
  • o código de erro, em que cada bit conta uma parte da história:
BitSe 1Se 0
0 (P)a página existe, mas a permissão foi violadaa página não está mapeada
1 (W)foi uma escritafoi uma leitura
2 (U)aconteceu em modo usuário (anel 3)aconteceu no kernel
3 (R)um bit reservado estava ligado numa tabela de páginas (tabela corrompida)—
4 (I)foi a busca de uma instrução (tentou executar)—
#![allow(unused)]
fn main() {
extern "x86-interrupt" fn page_fault(quadro: QuadroInterrupcao, codigo: u64) {
    let cr2: u64;
    unsafe { asm!("mov {}, cr2", out(reg) cr2, ...) };   // lê o endereço culpado
    panic!("EXCECAO #14 PAGE FAULT ao {} {:#x} ({}, {}{})\n{}",
           if codigo & 0x10 != 0 { "executar" } else if codigo & 0x2 != 0 { "escrever em" } else { "ler" },
           cr2,
           if codigo & 0x1 != 0 { "pagina presente, sem permissao" } else { "pagina nao mapeada" },
           if codigo & 0x4 != 0 { "modo usuario" } else { "modo kernel" },
           if codigo & 0x8 != 0 { ", bit reservado na tabela" } else { "" },
           quadro);
}
}

Saída do ./run.sh teste-pagefault:

EXCECAO #14 PAGE FAULT ao ler 0x400000000000 (pagina nao mapeada, modo kernel)

0x4000_0000_0000 (64 TiB) é um endereço canônico (bit 47 = 0, então está na metade de baixo), mas o firmware não mapeou nada lá. Por isso dá #PF e não #GP.

Hoje o tratador só relata. No capítulo 7 ele pode ficar inteligente: se a página que faltou pertence a uma região válida (por exemplo, a pilha crescendo), o kernel mapeia uma página nova e retorna. A CPU repete a instrução, que agora funciona, e o código interrompido nem percebe.

6.6 #DF: double fault, e a pilha de emergência em ação

Um double fault acontece quando ocorre uma exceção enquanto a CPU tenta entregar outra. É o cenário descrito no doc 03, seção 5.2, e o teste teste-doublefault o reproduz de propósito:

#![allow(unused)]
fn main() {
core::arch::asm!("mov rsp, 0x400000000000", "push rax", options(noreturn));
}
  • mov rsp, 0x400000000000: aponta a pilha para memória que não existe.
  • push rax: tenta escrever na pilha.

O que a CPU faz:

 push rax → escrita em memória inexistente → #PF
   └─ para entregar o #PF, a CPU precisa empilhar o quadro...
      na MESMA pilha inexistente (a linha do #PF não usa IST) → falha de novo
        └─ a CPU desiste do #PF e gera #DF
           └─ a linha do #DF tem ist = 1:
              a CPU TROCA para a pilha de emergência ANTES de empilhar
              └─ empilha o quadro na pilha boa → chama o tratador ✔

O tratador:

#![allow(unused)]
fn main() {
extern "x86-interrupt" fn double_fault(quadro: QuadroInterrupcao, _codigo: u64) -> ! {
    let rsp_atual: u64;
    unsafe { asm!("mov {}, rsp", out(reg) rsp_atual, ...) };
    panic!("EXCECAO #8 DOUBLE FAULT\n{}\nTratador rodando na pilha IST1 (RSP={:#x})",
           quadro, rsp_atual);
}
}

Saída:

Pilha IST1 (double fault): topo em 0x60ad070          ← impresso no boot
...
EXCECAO #8 DOUBLE FAULT
RIP=0x608c827 CS=0x8 RFLAGS=0x6 RSP=0x400000000000 SS=0x10
Tratador rodando na pilha IST1 (RSP=0x60acf78)

Como ler isso:

  • RSP=0x400000000000 no quadro: a pilha destruída de quem falhou, exatamente o valor que colocamos;
  • RSP=0x60acf78 do tratador: 248 bytes abaixo do topo da pilha de emergência (0x60ad070). A troca de pilha aconteceu.

A prova contrária também foi feita. Trocamos temporariamente o gdt::IST_DOUBLE_FAULT por 0 (sem pilha de emergência) e rodamos o mesmo teste. O QEMU fechou 2 segundos depois do boot, sem nenhuma mensagem: a última linha na serial era “Teste: apontando RSP…”. Foi um triple fault: o #DF também tentou usar a pilha inexistente, falhou, e a CPU reiniciou. (Com -no-reboot, o QEMU fecha em vez de reiniciar.) Depois do teste, o código foi restaurado.

Dois detalhes:

  • O tipo de retorno -> ! é obrigatório: a Intel define que não há como retomar a execução depois de um double fault.
  • Nem toda combinação de duas exceções gera #DF, só os pares graves (por exemplo, #PF durante a entrega de #PF, ou #GP durante a entrega de #GP). Um #DE durante a entrega de um #PF é tratado em sequência, normalmente.

7. Carregando a IDT

Igual à GDT (doc 03, seção 7): a instrução lidt recebe uma estrutura de 10 bytes com o limite (tamanho − 1 = 256 × 16 − 1 = 4095) e o endereço da tabela. Reaproveitamos a struct gdt::PonteiroTabela:

#![allow(unused)]
fn main() {
let ponteiro = gdt::PonteiroTabela {
    limite: (size_of::<[Entrada; 256]>() - 1) as u16,
    base: idt.as_ptr() as u64,
};
unsafe { asm!("lidt [{}]", in(reg) &ponteiro, ...) };
}

Diferente da GDT, não é preciso “recarregar” nada: a partir da próxima exceção, a CPU já consulta a tabela nova.


8. As interrupções de hardware continuam desligadas

Uma dúvida comum: “se o kernel_main começa com cli (interrupções desligadas), como as exceções funcionam?”

  • O cli desliga só as interrupções de hardware (bit IF).
  • As exceções não podem ser desligadas: elas são consequência da instrução atual, e a CPU não teria como continuar sem tratá-las.

As interrupções de hardware continuam desligadas até o capítulo 9, porque ainda não configuramos o controlador de interrupções (o chip que decide qual vetor cada dispositivo usa). Se as ligássemos agora, o timer do firmware ainda poderia disparar num vetor sem tratador, e cairíamos em #GP.


9. Resultados dos testes

Todos rodados no QEMU com SEM_TELA=1 ./run.sh <feature>:

FeatureResultado
(nenhuma)boot normal; IDT carregada com limite 4095
teste-breakpointtratador imprime o RIP e o kernel continua
teste-divisao#DE com o RIP da instrução div
teste-opcode#UD com o RIP do ud2
teste-gpf#GP com código 0 (endereço não-canônico)
teste-pagefault#PF com CR2 = 0x400000000000: “não mapeada, leitura, kernel”
teste-doublefault#DF tratado na pilha IST1 (RSP dentro dela)
teste-doublefault sem IST (teste temporário)triple fault: QEMU fecha em 2 s sem mensagem

Em todos os casos houve um único boot, sem reinício inesperado.


Diferença em relação ao livro

O livro usa a crate x86_64, que tem InterruptDescriptorTable e InterruptStackFrame prontos. Uma vantagem dela: seus tipos impedem registrar um tratador com a assinatura errada (com ou sem código de erro). O SiliconOS monta a tabela à mão, como fez com a GDT, então essa responsabilidade é nossa: confira a tabela da seção 2 antes de adicionar um tratador novo.


Resumo

  • Exceções são erros detectados pela CPU (não pelo Rust). Elas não passam pelo panic handler; precisam da IDT.
  • A IDT tem 256 linhas. Cada uma aponta para um tratador, com o seletor de código (0x08) e, opcionalmente, uma pilha IST.
  • Ao receber uma exceção, a CPU empilha o quadro (RIP, CS, RFLAGS, RSP, SS) e às vezes um código de erro; o tratador volta com iretq.
  • extern "x86-interrupt" faz o compilador gerar esse protocolo; a assinatura precisa bater com o que a CPU empilha.
  • Fault repete a instrução, trap continua depois dela, abort não continua.
  • O double fault usa a pilha IST1 e, por isso, uma pilha destruída vira uma mensagem em vez de um reinício. Isso foi provado nos dois sentidos.
Para fixar (clique para ver as respostas)
  1. Por que um acesso a v[10] gera panic, mas um acesso ao endereço 0x400000000000 gera page fault? No primeiro, o Rust inseriu uma verificação de limite antes do acesso. No segundo, é um ponteiro cru: não há verificação do Rust, e quem descobre o problema é a CPU, ao tentar traduzir o endereço.

  2. Depois do tratador de breakpoint, a execução continua na instrução int3 ou na seguinte? E depois de um page fault corrigido? Breakpoint é trap: continua na seguinte. Page fault é fault: a CPU repete a instrução que falhou.

  3. O que aconteceria se o tratador de page fault fosse declarado sem o parâmetro codigo? O tratador leria a pilha deslocada em 8 bytes, e o iretq interpretaria o código de erro como endereço de retorno: a execução saltaria para um lugar aleatório.

  4. Por que 0x8000_0000_0000_0000 dá #GP e 0x4000_0000_0000 dá #PF? O primeiro é não-canônico (os bits altos não repetem o bit 47); a CPU o rejeita antes de traduzir. O segundo é canônico, mas não está mapeado.

  5. No teste de double fault, como sabemos que a pilha IST1 foi usada? O RSP lido dentro do tratador (0x60acf78) está logo abaixo do topo da IST1 informado no boot (0x60ad070). E, sem a IST, o mesmo teste vira triple fault.

05: Memória física: saber o que é de quem e entregar pedaços

Você vai entender: como o kernel descobre quanta RAM existe e onde ela está; por que a memória de um PC é cheia de “buracos”; o que é um quadro de 4 KiB; e como funciona um alocador de quadros baseado em bitmap, a peça sobre a qual toda a gestão de memória será construída.

Arquivos: src/memoria/mod.rs, src/memoria/mapa.rs, src/memoria/quadros.rs (chamados em kernel_main logo depois da IDT) Livro: capítulo 6 Testar: ./run.sh teste-quadros, ./run.sh teste-liberacao-dupla, ./run.sh listar-regioes, e com mais memória: RAM=4G ./run.sh teste-quadros Rust novo aqui: enum (§19), cadeias de iteradores (§20), slices (§21), newtype (§22), assert! e expect (§23)


1. O problema

Até agora o kernel só usou memória que outros reservaram para ele:

  • o código e as variáveis static (GDT, IDT, pilha de emergência) vieram dentro do executável, e o firmware os carregou;
  • a pilha em que o kernel roda foi criada pelo firmware.

Para tudo que vem pela frente (tabelas de página no capítulo 7, Vec e String no capítulo 8, uma pilha por processo depois), o kernel precisa pedir memória durante a execução. Em Java, você escreve new e a JVM pede memória ao sistema operacional. Aqui nós somos o sistema operacional: precisamos responder a pergunta “qual pedaço da RAM está livre agora?”.

Isso exige duas coisas, que são as duas partes deste capítulo:

  1. Saber o que existe (o mapa): quanta RAM há, em que endereços, e o que já está ocupado por firmware, dispositivos ou pelo próprio kernel.
  2. Controlar o que foi entregue (o alocador): entregar pedaços livres a quem pede, receber de volta e nunca entregar o mesmo pedaço para dois donos.

2. A memória de um PC não é um bloco contínuo

A primeira intuição é imaginar que uma máquina com 128 MiB tem endereços de 0 a 128 MiB, todos de RAM. Não é assim. O espaço de endereços físicos é compartilhado entre a RAM, os dispositivos e o firmware. Este é o mapa real do QEMU com 128 MiB, gerado por ./run.sh listar-regioes (resumido):

0x000000000000 - 0x000000001000        4 KiB  BootServices
0x000000001000 - 0x0000000a0000      636 KiB  Livre
               (0xa0000 - 0x100000: nada no mapa: buraco do PC original)
0x000000100000 - 0x000000800000     7168 KiB  Livre
0x000000800000 - 0x000000808000       32 KiB  ACPI
...                                   (dezenas de regiões alternando tipos)
0x000006070000 - 0x0000060a6000      216 KiB  Kernel       ← o SiliconOS
...
0x000007f78000 - 0x000008000000      544 KiB  ACPI         ← fim da RAM: 128 MiB
               (0x80000000: o framebuffer, fora do mapa)
0x00fd00000000 - 0x010000000000  12582912 KiB  Reservada   ← 12 GiB perto de 1 TiB: não é RAM

Três coisas a observar:

2.1 Há buracos

Entre 0xA0000 (640 KiB) e 0x100000 (1 MiB) não há nada no mapa. Essa faixa é uma herança do IBM PC de 1981: ali ficavam a memória da placa de vídeo e as ROMs, e até hoje ela é reservada. Endereços que não aparecem no mapa não são RAM e nunca devem ser usados.

2.2 Há dispositivos no meio dos endereços

O framebuffer (doc 01) está em 0x80000000, um endereço físico que não é RAM: escrever ali vai direto para a placa de vídeo (é o MMIO, ver glossário). Por isso ele aparece como “fora do mapa”.

A região “Reservada” de 12 GiB perto de 1 TiB também não é RAM: é uma faixa de endereços que o QEMU reserva para o chipset. Ela aparece no mapa justamente para avisar “não use”.

2.3 Com mais memória, os buracos ficam maiores

Com RAM=4G ./run.sh listar-regioes, o fim do mapa fica assim:

0x0000bff78000 - 0x0000c0000000      544 KiB  ACPI          ← RAM até 3 GiB
               (0xc0000000 - 0x100000000: buraco de 1 GiB para dispositivos PCI;
                o framebuffer está aqui, em 0xc0000000)
0x000100000000 - 0x000140000000  1048576 KiB  Livre         ← o 1 GiB restante, acima de 4 GiB

Os PCs reservam o último pedaço abaixo de 4 GiB para dispositivos (a “janela PCI”). A RAM que caberia ali é remapeada para depois de 4 GiB. Resultado: uma máquina com 4 GiB de RAM tem endereços de RAM até 5 GiB. O alocador precisa lidar com isso.


3. Parte 1: o mapa de memória do kernel

3.1 O que o UEFI entrega

No doc 00 vimos que exit_boot_services devolve o mapa de memória: uma lista de descritores, cada um com endereço inicial, número de páginas de 4 KiB e um tipo. O UEFI define cerca de 16 tipos. Os que aparecem no QEMU:

Tipo UEFIO que éPodemos usar?
CONVENTIONALRAM livresim
BOOT_SERVICES_CODE/DATARAM usada pelo firmware durante o bootem tese sim, depois do exit_boot_services… (seção 3.4)
LOADER_CODE/DATAo programa que o UEFI carregou (o SiliconOS) e o que ele alocounão: é o kernel
RUNTIME_SERVICES_CODE/DATApartes do firmware que continuam ativas (relógio, variáveis)nunca
ACPI_RECLAIM, ACPI_NON_VOLATILEtabelas que descrevem o hardwarenão (ainda)
RESERVED, MMIO…reservado, dispositivosnunca

3.2 Por que copiar para um formato nosso

O mapa do UEFI tem três inconvenientes:

  1. Depende da crate uefi e de um buffer que o firmware alocou. Queremos que o resto do kernel trabalhe com tipos nossos.
  2. Não vem necessariamente em ordem de endereço (a especificação não garante).
  3. É fragmentado: 127 entradas no QEMU, muitas delas vizinhas e do mesmo tipo.

Então o memoria::mapa::init faz três passos: copiar convertendo o tipo, ordenar e juntar vizinhas. Resultado no boot:

Mapa de memoria: 127 entradas do UEFI -> 31 regioes (vizinhas unidas)

3.3 O código

Os tipos simplificados são um enum (rust-basico §19):

#![allow(unused)]
fn main() {
pub enum Tipo {
    Livre, BootServices, Kernel, Runtime, Acpi, Mmio, Reservada,
}
}

e a conversão é um match (rust-basico §7):

#![allow(unused)]
fn main() {
fn de_uefi(t: MemoryType) -> Tipo {
    match t {
        MemoryType::CONVENTIONAL => Tipo::Livre,
        MemoryType::BOOT_SERVICES_CODE | MemoryType::BOOT_SERVICES_DATA => Tipo::BootServices,
        MemoryType::LOADER_CODE | MemoryType::LOADER_DATA => Tipo::Kernel,
        ...
        _ => Tipo::Reservada,      // qualquer tipo desconhecido: na dúvida, não usar
    }
}
}

O _ => Tipo::Reservada é uma decisão de segurança: se um firmware reportar um tipo que não conhecemos, tratamos como “não tocar”.

Uma região é início + tamanho + tipo:

#![allow(unused)]
fn main() {
pub struct Regiao {
    pub inicio: u64,     // endereço físico do primeiro byte
    pub quadros: u64,    // tamanho, em blocos de 4 KiB
    pub tipo: Tipo,
}

impl Regiao {
    pub fn fim(&self) -> u64 {               // primeiro endereço DEPOIS da região
        self.inicio + self.quadros * TAMANHO_QUADRO
    }
    pub fn contem(&self, endereco: u64) -> bool {
        endereco >= self.inicio && endereco < self.fim()
    }
}
}

O intervalo é semiaberto, [inicio, fim): inclui o início e exclui o fim, como substring(inicio, fim) em Java. Isso evita os erros de “mais um ou menos um”.

O mapa guarda as regiões num array de tamanho fixo, porque ainda não temos Vec (que precisa de um alocador, exatamente o que estamos construindo):

#![allow(unused)]
fn main() {
const MAX_REGIOES: usize = 512;   // o OVMF entrega ~130

pub struct Mapa {
    regioes: [Regiao; MAX_REGIOES],
    qtd: usize,                    // quantas posições estão em uso
    pub entradas_uefi: usize,
}
}

É o mesmo padrão de um ArrayList por dentro: um array grande e um contador. O método regioes() devolve só a parte usada, como um subList(0, qtd) (rust-basico §21).

Os três passos do init:

#![allow(unused)]
fn main() {
// 1. Copiar, convertendo o tipo.
for d in mapa_uefi.entries() {
    assert!(m.qtd < MAX_REGIOES, "mapa de memoria com mais de {} regioes", MAX_REGIOES);
    m.regioes[m.qtd] = Regiao { inicio: d.phys_start, quadros: d.page_count, tipo: Tipo::de_uefi(d.ty) };
    m.qtd += 1;
}

// 2. Ordenar por endereço.
m.regioes[..m.qtd].sort_unstable_by_key(|r| r.inicio);

// 3. Juntar vizinhas do mesmo tipo: [A][A][B][A] -> [AA][B][A]
let mut escrita = 0;
for leitura in 0..m.qtd {
    let r = m.regioes[leitura];
    if escrita > 0 {
        let anterior = &mut m.regioes[escrita - 1];
        if anterior.tipo == r.tipo && anterior.fim() == r.inicio {
            anterior.quadros += r.quadros;     // estende a anterior
            continue;                          // e descarta esta
        }
    }
    m.regioes[escrita] = r;
    escrita += 1;
}
m.qtd = escrita;
}

O passo 3 usa a técnica de dois índices no mesmo array: leitura percorre tudo; escrita marca até onde vai o resultado. Quando a região atual “cola” na anterior (mesmo tipo, e a anterior termina exatamente onde esta começa), ela é absorvida. Assim o array é compactado no próprio lugar, sem precisar de um segundo array.

O mapa fica num Once (rust-basico §11), pelo mesmo motivo da GDT: é montado uma vez e precisa viver para sempre.

3.4 Uma descoberta: a pilha está em memória “do firmware”

O boot imprime onde estão as coisas que o kernel está usando:

  codigo do kernel 0x607dee0 -> Kernel
  GDT              0x60a2070 -> Kernel
  pilha atual(RSP) 0x7e95e00 -> BootServices
  framebuffer      0x80000000 -> fora do mapa

O código e a GDT estão na região “Kernel”, como esperado. Mas a pilha em que o kernel roda agora está numa região BootServices: o firmware a criou para executar o nosso programa, com o tipo “dados do firmware”.

A especificação UEFI diz que, depois de exit_boot_services, a memória BootServices pode ser reaproveitada pelo sistema operacional. Se o alocador entregasse essa memória, ele poderia entregar a própria pilha do kernel para outro uso. O próximo dono escreveria por cima das variáveis locais e dos endereços de retorno, e o kernel quebraria de forma completamente misteriosa.

Por isso, por enquanto, o alocador usa só as regiões Livre. Os 40 MiB de BootServices serão recuperados quando o kernel tiver uma pilha própria (fica para depois da paginação). Este é um bom exemplo de por que o kernel precisa saber onde estão as próprias coisas antes de sair distribuindo memória.


4. Parte 2: o alocador de quadros

4.1 Quadros de 4 KiB

O alocador não entrega bytes soltos; entrega quadros (frames): blocos de 4 KiB (4096 bytes), alinhados, isto é, começando num endereço múltiplo de 4096.

Por que 4 KiB? Porque é o tamanho da página da paginação x86 (capítulo 7): a CPU traduz endereços de 4 em 4 KiB. Todo o gerenciamento de memória de um kernel x86 é feito nessa unidade. Pedaços menores (um String de 20 bytes) serão cortados de dentro de quadros por um segundo alocador, o heap, no capítulo 8.

endereço físico:   0x0000    0x1000    0x2000    0x3000    0x4000
                   ├─────────┼─────────┼─────────┼─────────┤
                   │quadro 0 │quadro 1 │quadro 2 │quadro 3 │ ...
                   └─────────┴─────────┴─────────┴─────────┘
número do quadro = endereço / 4096        endereço = número × 4096

4.2 A ideia do bitmap

Precisamos lembrar, para cada quadro, se ele está livre ou ocupado. A forma mais simples é um bitmap: um bit por quadro, 1 = ocupado, 0 = livre.

bitmap[0] (u64) = ...0000 0000 0000 0111  → quadros 0,1,2 ocupados; 3,4,5... livres
bitmap[1] (u64) = ...                      → quadros 64 a 127

Os bits são agrupados em números de 64 bits (u64). O quadro q está na palavra q / 64, no bit q % 64.

Custo: 1 bit para cada 4096 bytes, ou seja, 1/32 768 da memória.

RAM cobertaQuadrosBitmap
128 MiB32 7684 KiB
5 GiB (máquina de 4 GiB, seção 2.3)1 310 720160 KiB
9 GiB (máquina de 8 GiB)2 359 296288 KiB

Esses são os tamanhos reais que o boot mostrou.

Há alternativas (listas encadeadas de quadros livres, buddy allocator), que são mais rápidas para alocar mas mais difíceis de entender e depurar. O bitmap é o ponto de partida clássico: simples, compacto e fácil de inspecionar.

4.3 Onde guardar o próprio bitmap

O bitmap de uma máquina de 8 GiB tem 288 KiB. Onde colocar isso, se ainda não temos alocador? É o problema do ovo e da galinha. A solução clássica:

  1. calcular o tamanho do bitmap;
  2. procurar a primeira região Livre que caiba nele inteiro;
  3. colocar o bitmap no começo dela;
  4. marcar esses quadros como ocupados no próprio bitmap.

O bitmap fica dentro da memória que ele mesmo administra e se protege marcando-se como ocupado.

#![allow(unused)]
fn main() {
let regiao = mapa
    .regioes()
    .iter()
    .filter(|r| r.tipo == Tipo::Livre)
    .find(|r| {
        let inicio = r.inicio.max(INICIO_UTILIZAVEL);
        inicio + quadros_bitmap * TAMANHO_QUADRO <= r.fim()
    })
    .expect("nenhuma regiao livre comporta o bitmap de quadros");
}

Essa cadeia de .filter().find() é como um stream() do Java: “das regiões, filtre as livres e ache a primeira em que o bitmap cabe” (rust-basico §20).

No boot: Alocador de quadros: bitmap de 4 KiB em 0x100000. É exatamente o início da primeira região livre acima de 1 MiB.

4.4 Transformando um endereço físico numa variável Rust

O bitmap está num endereço físico, por exemplo 0x100000. Para usá-lo como um array Rust comum, convertemos o endereço num slice:

#![allow(unused)]
fn main() {
let bitmap: &'static mut [u64] = unsafe {
    core::slice::from_raw_parts_mut(endereco_bitmap as *mut u64, palavras)
};
}

from_raw_parts_mut(ponteiro, quantidade) diz: “a partir deste endereço, existem quantidade valores u64; trate isso como um array”. Daqui em diante, bitmap[i] funciona com verificação de limites, como qualquer array.

É unsafe porque o compilador não tem como saber se aquela memória existe e se ninguém mais a usa. Quem garante somos nós: ela é uma região Livre do mapa, e vamos marcá-la como ocupada logo em seguida.

Por que usar um endereço físico como ponteiro funciona? Ponteiros na CPU são endereços virtuais, traduzidos pela paginação. O UEFI deixou a paginação em mapeamento identidade: o endereço virtual X aponta para o endereço físico X. Por isso, hoje, “endereço físico” e “ponteiro” são a mesma coisa. Isso mudou no capítulo seguinte: quando o kernel monta as próprias tabelas de página, o bitmap passa a ser acessado pela metade alta do espaço de endereços (doc 06, seção 5.7).

4.5 A inicialização: começar com tudo ocupado

#![allow(unused)]
fn main() {
// 3. Começar com TUDO ocupado. Só o que provarmos ser livre vira 0.
bitmap.fill(u64::MAX);
}

u64::MAX é um número com todos os 64 bits em 1. Todos os quadros começam ocupados. Depois, só as regiões Livre do mapa (acima de 1 MiB) são marcadas como livres, e por fim os quadros do próprio bitmap voltam a ser marcados como ocupados:

#![allow(unused)]
fn main() {
for r in mapa.regioes().iter().filter(|r| r.tipo == Tipo::Livre) {
    let inicio = r.inicio.max(INICIO_UTILIZAVEL);
    if inicio >= r.fim() { continue; }
    let primeiro = (inicio / TAMANHO_QUADRO) as usize;
    let n = ((r.fim() - inicio) / TAMANHO_QUADRO) as usize;
    a.marcar_intervalo(primeiro, n, false);
}
a.marcar_intervalo((endereco_bitmap / TAMANHO_QUADRO) as usize, quadros_bitmap as usize, true);
}

Por que começar com “tudo ocupado” e não “tudo livre”? Porque os dois erros possíveis não têm o mesmo custo:

  • marcar como ocupado algo que era livre: desperdiça um pouco de memória. Ninguém percebe;
  • marcar como livre algo que era ocupado: o alocador entrega a memória de alguém (do firmware, de um dispositivo, do próprio kernel). Isso causa corrupção silenciosa, o pior tipo de bug.

Começar com tudo ocupado garante que os buracos, os dispositivos, as regiões reservadas e tudo que não está no mapa nunca sejam entregues. Só é liberado o que temos certeza de que é livre.

Por que pular o primeiro 1 MiB (INICIO_UTILIZAVEL)? Duas razões:

  1. o quadro 0 contém o endereço 0. Um ponteiro nulo apontando para memória “válida” esconderia bugs;
  2. quando formos ligar as outras CPUs (capítulo 11), elas começam em modo de 16 bits e só enxergam o primeiro megabyte. Precisaremos de memória livre ali.

O custo é desprezível: 636 KiB.

4.6 Alocar: achar o primeiro bit 0

#![allow(unused)]
fn main() {
pub fn alocar(&mut self) -> Option<Quadro> {
    let palavras = self.bitmap.len();
    for i in 0..palavras {
        let p = (self.proxima_palavra + i) % palavras;
        let valor = self.bitmap[p];
        if valor != u64::MAX {                        // há algum bit 0 aqui
            let bit = valor.trailing_ones() as usize; // posição do primeiro 0
            let quadro = p * 64 + bit;
            if quadro >= self.total { continue; }
            self.marcar(quadro, true);
            self.livres -= 1;
            self.proxima_palavra = p;
            return Some(Quadro(quadro as u64 * TAMANHO_QUADRO));
        }
    }
    None                                              // memória acabou
}
}

Os truques de desempenho:

  • Testar 64 quadros de uma vez: se a palavra inteira é u64::MAX, os 64 quadros estão ocupados; pulamos sem olhar bit a bit.
  • trailing_ones() conta quantos bits 1 seguidos há a partir do bit 0. Esse número é a posição do primeiro bit 0. A CPU tem uma instrução para isso, então custa um único ciclo.
    valor = ...1111 0111   → trailing_ones = 3 → o quadro livre é o bit 3
    
  • proxima_palavra lembra onde a última busca parou. Sem isso, cada alocação recomeçaria do início e passaria por todas as palavras já cheias. O % palavras faz a busca “dar a volta” no fim do array.

O retorno é Option<Quadro>: Some(quadro) ou None quando a memória acabou. Não existe null nem exceção (rust-basico §6): quem chama é obrigado a tratar o caso “acabou”.

4.7 O tipo Quadro

#![allow(unused)]
fn main() {
pub struct Quadro(pub u64);
}

Um Quadro é só um u64 com outro nome: o padrão newtype (rust-basico §22). A vantagem é que o compilador não deixa misturar um endereço físico de quadro com um número qualquer (um tamanho, um índice, um endereço virtual). A partir do capítulo 7 teremos endereços físicos e virtuais convivendo, e confundi-los é uma fonte clássica de bugs em kernels. O custo em tempo de execução é zero: na memória, um Quadro é exatamente um u64.

4.8 Liberar, com verificações

#![allow(unused)]
fn main() {
pub fn liberar(&mut self, quadro: Quadro) {
    assert!(quadro.0 % TAMANHO_QUADRO == 0, "liberar: endereco {:#x} nao alinhado em 4 KiB", quadro.0);
    let i = quadro.indice();
    assert!(i < self.total, "liberar: {:#x} esta fora da RAM", quadro.0);
    assert!(self.ocupado(i), "liberar: quadro {:#x} ja estava livre (liberacao dupla)", quadro.0);
    self.marcar(i, false);
    self.livres += 1;
    ...
}
}

As três verificações pegam os erros mais comuns de quem usa o alocador. A mais importante é a liberação dupla: se um quadro é liberado duas vezes, ele pode acabar entregue a dois donos diferentes no futuro, e um vai escrever por cima do outro. Esse bug aparece longe da causa e é muito difícil de rastrear. Detectá-lo na hora, com panic, transforma um bug misterioso numa mensagem clara.

assert!(condição, mensagem) é um if !condição { panic!(mensagem) } (rust-basico §23).

4.9 O alocador global

Como o console (doc 01), o alocador precisa ser acessível de qualquer lugar, então fica num static Mutex<Option<...>>:

#![allow(unused)]
fn main() {
pub static QUADROS: Mutex<Option<AlocadorQuadros>> = Mutex::new(None);

pub fn alocar() -> Option<Quadro> {
    QUADROS.lock().as_mut().expect("alocador de quadros nao inicializado").alocar()
}
}

As funções alocar(), liberar() e livres() no nível do módulo são atalhos que escondem o lock() e o Option. Quem usa só escreve memoria::quadros::alocar().


5. Como foi testado

5.1 O boot normal

Alocador de quadros: bitmap de 4 KiB em 0x100000
  20613 quadros livres (80 MiB) de 32768 cobertos

O mapa diz 81 MiB livres; o alocador oferece 80 MiB. A diferença é o primeiro megabyte (pulado de propósito) e o próprio bitmap.

5.2 ./run.sh teste-quadros

O teste faz cinco verificações em sequência:

  1. aloca 1000 quadros e confere que o contador de livres diminuiu 1000;
  2. confere que cada quadro está alinhado em 4 KiB, acima de 1 MiB, dentro de uma região Livre do mapa e não foi entregue duas vezes;
  3. escreve um padrão diferente em cada quadro (512 números de 64 bits, combinando o número do quadro e a posição) e depois lê tudo de volta. Isso prova que os quadros são RAM de verdade, que o mapeamento identidade funciona e que nenhum quadro se sobrepõe a outro: se dois se sobrepusessem, o segundo padrão apagaria parte do primeiro;
  4. libera todos e confere que o contador voltou ao valor inicial;
  5. aloca de novo e confere que o primeiro quadro entregue é o mesmo de antes (a busca recomeça do lugar certo depois das liberações).
Teste de quadros OK: 1000 alocados (0x101000..0x4e8000), escritos,
  conferidos, liberados e realocados. Livres: 20611

(O número de livres varia alguns quadros entre compilações diferentes, porque o tamanho do kernel muda; o que o teste confere é que ele volta ao valor do início.)

5.3 ./run.sh teste-liberacao-dupla

Teste: liberando o quadro 0x101000 duas vezes...
!!! KERNEL PANIC !!!
Local: src/memoria/quadros.rs:100:9
Mensagem: liberar: quadro 0x101000 ja estava livre (liberacao dupla)

5.4 Com mais memória: RAM=4G e RAM=8G

O run.sh ganhou a opção RAM=..., que muda a memória da máquina emulada (o padrão do QEMU é 128 MiB). O teste-quadros passou nos dois casos:

RAMFim da RAMBitmapQuadros livresObservação
128 MiB128 MiB4 KiB20 613 (80 MiB)—
4 GiB5 GiB160 KiB1 036 381 (4048 MiB)buraco PCI de 3 a 4 GiB (seção 2.3)
8 GiB9 GiB288 KiB2 084 925 (8144 MiB)o firmware carregou o kernel acima de 4 GiB (0x14000ba00)

O caso de 4 GiB confirma que o buraco entre 3 e 4 GiB fica marcado como ocupado (ele está coberto pelo bitmap, mas nunca foi “liberado”). O caso de 8 GiB mostra que o kernel não pode supor que está em endereços baixos.

5.5 Os testes antigos continuam passando

teste-panic, teste-breakpoint e teste-doublefault foram rodados de novo, com o mesmo resultado de antes.


Diferença em relação ao livro

O livro recebe o mapa de memória do bootloader que ele usa, num formato próprio desse bootloader. O SiliconOS recebe o mapa diretamente do UEFI, e por isso precisa converter os tipos do UEFI e lidar com a questão da memória BootServices (seção 3.4).

O livro também pode apresentar o alocador com outra estrutura de dados (é comum começar com um alocador que só avança e nunca libera). O SiliconOS usa o bitmap desde o início porque ele já permite liberar e é fácil de inspecionar. Os conceitos (quadros, regiões, proteger o que é do kernel) são os mesmos.


Resumo

  • O espaço de endereços físicos não é só RAM: há buracos herdados do PC antigo, dispositivos (MMIO), firmware e uma janela PCI abaixo de 4 GiB que empurra parte da RAM para cima de 4 GiB.
  • O kernel copia o mapa do UEFI para um formato próprio: 7 tipos, ordenado, com vizinhas unidas.
  • A pilha atual está em memória BootServices, então por enquanto só as regiões Livre são usadas.
  • O alocador de quadros entrega blocos de 4 KiB usando um bitmap (1 bit por quadro). O bitmap mora na primeira região livre e se marca como ocupado.
  • Princípio de segurança: começar com tudo ocupado e só liberar o que se tem certeza de que é livre. Liberação dupla vira panic imediato.
Para fixar (clique para ver as respostas)
  1. Uma máquina tem 4 GiB de RAM. Por que o alocador cobre endereços até 5 GiB? Porque o último GiB abaixo de 4 GiB é reservado para dispositivos PCI, e a RAM correspondente é remapeada para depois de 4 GiB.

  2. Por que o alocador ainda não usa a memória BootServices, se a especificação UEFI permite? Porque a pilha em que o kernel está rodando fica numa região BootServices. O alocador poderia entregar a própria pilha do kernel para outro uso.

  3. Por que o bitmap começa com todos os bits em 1 (ocupado)? Porque marcar um quadro livre como ocupado só desperdiça memória, mas marcar um ocupado como livre entrega memória de alguém e causa corrupção. Na dúvida, “ocupado”.

  4. Quanto ocupa o bitmap de uma máquina com 16 GiB de RAM (supondo que a RAM vá até 17 GiB)? 17 GiB / 4 KiB = 4 456 448 quadros = 4 456 448 bits = 544 KiB.

  5. O que trailing_ones() devolve para 0b1011 e o que isso significa no alocador? 2 (os bits 0 e 1 são 1; o bit 2 é 0). O quadro do bit 2 dessa palavra é o primeiro livre.

  6. Por que usar o endereço físico do bitmap como ponteiro funciona hoje, e por que isso vai mudar? Porque o UEFI deixou a paginação em mapeamento identidade (virtual = físico). Quando o kernel montar suas próprias tabelas de página (capítulo 7), essa equivalência deixa de valer.

06: Paginação: endereços virtuais e as tabelas do kernel

Você vai entender: a diferença entre endereço virtual e endereço físico; como a CPU traduz um no outro usando 4 níveis de tabelas; por que isso é a base de toda a proteção de memória de um sistema operacional; e como o SiliconOS troca as tabelas do firmware pelas suas próprias sem derrubar a si mesmo no meio do caminho.

Arquivo: src/memoria/paginacao.rs (chamado em kernel_main depois do alocador de quadros) Livro: capítulo 7 Testar: ./run.sh teste-paginacao, ./run.sh teste-desmapeado, ./run.sh teste-nulo (e com mais memória: RAM=8G ./run.sh teste-paginacao) Leia antes: doc 05 (quadros e alocador) Rust novo aqui: o operador ? (§24), atômicos (§25), #[repr(transparent)] (§13)


1. A ideia: todo ponteiro passa por um “tradutor”

Quando o seu programa Java lê um campo de um objeto, a JVM usa um endereço de memória, digamos 0x7f3a_1000. Se você abrir dois programas ao mesmo tempo, os dois podem usar exatamente o mesmo endereço 0x7f3a_1000 sem que um enxergue os dados do outro. Como?

Porque esse endereço não é a posição real na RAM. É um endereço virtual. A CPU, a cada acesso à memória, o traduz para um endereço físico (a posição de verdade nos chips de RAM), usando um conjunto de tabelas que o kernel monta:

 programa A:  ponteiro 0x7f3a_1000 ──► tabelas do programa A ──► físico 0x0230_5000
 programa B:  ponteiro 0x7f3a_1000 ──► tabelas do programa B ──► físico 0x0811_2000

Esse mecanismo, chamado paginação, resolve de uma vez vários problemas:

ProblemaComo a paginação resolve
Isolamento: um programa não pode ler a memória de outrocada programa tem tabelas próprias; o que não está nas tabelas dele simplesmente não existe para ele
Proteger o kernelas páginas do kernel são marcadas “só anel 0”; um programa que tentar acessá-las recebe page fault
Memória física fragmentada (doc 05)o programa vê um bloco contínuo de endereços virtuais, montado com quadros espalhados pela RAM
Detectar bugsuma página sem mapeamento gera page fault: ponteiros nulos e pilhas estouradas são pegos na hora
Carregar sob demanda, swap, mmap…o kernel mapeia a página só quando ela é usada pela primeira vez (o page fault avisa)

A unidade da tradução é a página: um bloco de 4 KiB de endereços virtuais, que aponta para um quadro de 4 KiB de RAM física (doc 05). Os 12 bits mais baixos do endereço (o deslocamento dentro da página, de 0 a 4095) passam direto; só o resto é traduzido.

O que o SiliconOS já usava sem saber

A paginação é obrigatória no modo de 64 bits: não existe x86-64 sem ela. Desde o primeiro boot, o SiliconOS rodava com tabelas que o UEFI montou. Elas estavam em mapeamento identidade: virtual X → físico X. Por isso até aqui pudemos tratar endereço físico e ponteiro como a mesma coisa (doc 05, seção 4.4).


2. Como a tradução funciona: 4 níveis de tabelas

2.1 Por que não uma tabela só

Um endereço virtual em x86-64 tem 48 bits úteis: 256 TiB de espaço. Com páginas de 4 KiB, são 2³⁶ = 68 bilhões de páginas. Uma tabela simples, com uma entrada de 8 bytes por página, teria 512 GiB. Impossível.

A solução é uma árvore: tabelas pequenas (uma por quadro de 4 KiB), em 4 níveis, e só se criam as partes da árvore que são usadas. Um programa que usa pouca memória precisa de poucas tabelas.

2.2 O endereço virtual é picado em 5 pedaços

 endereço virtual (64 bits)
 ┌────────────┬─────────┬─────────┬─────────┬─────────┬──────────────┐
 │ 63 ... 48  │ 47...39 │ 38...30 │ 29...21 │ 20...12 │  11 ... 0    │
 │ cópia do   │ índice  │ índice  │ índice  │ índice  │ deslocamento │
 │ bit 47     │ PML4    │ PDPT    │ PD      │ PT      │ na página    │
 │ (canônico) │ 9 bits  │ 9 bits  │ 9 bits  │ 9 bits  │ 12 bits      │
 └────────────┴─────────┴─────────┴─────────┴─────────┴──────────────┘

Cada índice tem 9 bits, ou seja, vai de 0 a 511. Cada tabela tem 512 entradas de 8 bytes = 4096 bytes: exatamente um quadro. Não é coincidência: o formato foi desenhado para isso.

Os quatro níveis têm nomes herdados dos manuais da Intel:

NívelNomeCada entrada cobre
4PML4 (Page Map Level 4)512 GiB
3PDPT (Page Directory Pointer Table)1 GiB
2PD (Page Directory)2 MiB
1PT (Page Table)4 KiB (uma página)

No código:

#![allow(unused)]
fn main() {
pub fn indices(virt: u64) -> [usize; 4] {
    [
        ((virt >> 39) & 0x1FF) as usize, // PML4: bits 39–47
        ((virt >> 30) & 0x1FF) as usize, // PDPT: bits 30–38
        ((virt >> 21) & 0x1FF) as usize, // PD:   bits 21–29
        ((virt >> 12) & 0x1FF) as usize, // PT:   bits 12–20
    ]
}
}

>> 39 desloca o endereço para que o pedaço desejado fique nos bits mais baixos, e & 0x1FF (= 511 = nove bits 1) descarta o resto.

2.3 A caminhada (page walk)

O registrador CR3 guarda o endereço físico da PML4. A cada acesso à memória, a CPU faz:

 CR3 ──► PML4 ─[índice PML4]─► entrada: "a PDPT está no físico X"
                                  │
          PDPT (em X) ─[índice PDPT]─► entrada: "a PD está no físico Y"
                                  │
          PD (em Y) ─[índice PD]──► entrada: "a PT está no físico Z"
                                  │
          PT (em Z) ─[índice PT]──► entrada: "a página está no quadro Q"
                                  │
          físico final = Q + deslocamento (12 bits de baixo do virtual)

Se, em qualquer nível, a entrada estiver marcada como não presente, a CPU para e gera um page fault (doc 04), com o endereço virtual em CR2.

O SiliconOS tem essa mesma caminhada em software, na função traduzir_em. Ela não é usada pela CPU (a CPU faz isso em hardware); serve para o kernel consultar o mapeamento e para os relatórios e testes:

#![allow(unused)]
fn main() {
pub fn traduzir_em(raiz: u64, virt: u64) -> Option<Traducao> {
    if !canonico(virt) { return None; }
    let idx = indices(virt);
    let mut tabela = raiz;
    for nivel in 0..4 {
        let e = tabela_em(tabela).entradas[idx[nivel]];
        if !e.presente() { return None; }
        let tamanho = match nivel {
            1 if e.grande() => Some(TamanhoPagina::G1),
            2 if e.grande() => Some(TamanhoPagina::M2),
            3 => Some(TamanhoPagina::K4),
            _ => None,                          // ainda não chegou: desce mais um nível
        };
        if let Some(t) = tamanho {
            let mascara_pagina = t.bytes() - 1;
            let base = e.endereco() & !mascara_pagina;
            return Some(Traducao { fisico: base + (virt & mascara_pagina), tamanho: t, flags: e.flags() });
        }
        tabela = e.endereco();                  // o próximo nível
    }
    None
}
}

(1 if e.grande() => é um match com condição extra, a guarda: “se for o nível 1 e a entrada for grande”.)

2.4 Páginas grandes: atalhos na árvore

Mapear 8 GiB de RAM com páginas de 4 KiB exigiria 2 milhões de entradas e 4096 tabelas PT (16 MiB só de tabelas). Para regiões grandes e contínuas, a CPU permite parar a caminhada mais cedo:

  • uma entrada de PD com o bit GRANDE aponta direto para uma página de 2 MiB (pula a PT);
  • uma entrada de PDPT com o bit GRANDE aponta para uma página de 1 GiB (pula a PD e a PT; nem toda CPU suporta).

Nesses casos, os bits do endereço virtual que seriam os índices dos níveis pulados viram parte do deslocamento: 21 bits de deslocamento para 2 MiB, 30 bits para 1 GiB. É por isso que traduzir_em usa t.bytes() - 1 como máscara.

O SiliconOS usa páginas de 2 MiB para mapear a RAM inteira (seção 5) e páginas de 4 KiB onde precisa de controle fino.

2.5 A TLB: o cache da tradução

Fazer 4 leituras de memória a cada acesso seria lentíssimo. A CPU guarda as traduções recentes num cache chamado TLB (Translation Lookaside Buffer). Consequência importante para quem escreve um kernel:

Quando você muda uma entrada de tabela, a CPU pode continuar usando a tradução antiga que está na TLB.

Duas formas de limpar:

  • invlpg endereço: tira um endereço da TLB. O SiliconOS faz isso em todo mapear/desmapear;
  • escrever em CR3: limpa a TLB inteira (exceto páginas marcadas GLOBAL). Acontece quando trocamos as tabelas.

Esquecer o invlpg é um bug clássico: o código funciona “às vezes”, dependendo do que estava no cache.


3. A entrada de tabela

Cada entrada é um u64 com um endereço físico e bits de controle:

 bit:  63  62..52  51 ................ 12  11..9  8  7  6  5  4  3  2  1  0
      ┌──┬───────┬────────────────────────┬──────┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
      │NX│  ---  │ endereço físico do     │ livre│G │PS│D │A │CD│WT│US│RW│P │
      │  │       │ próximo nível/página   │      │  │  │  │  │  │  │  │  │  │
      └──┴───────┴────────────────────────┴──────┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

O endereço ocupa os bits 12–51. Como tudo é alinhado em 4 KiB, os 12 bits de baixo de um endereço de quadro são sempre zero, e a Intel aproveitou esse espaço para os bits de controle. No código, cada bit tem nome:

#![allow(unused)]
fn main() {
pub mod flags {
    pub const PRESENTE: u64 = 1 << 0;       // a entrada é válida
    pub const ESCRITA: u64 = 1 << 1;        // permite escrever (senão, só leitura)
    pub const USUARIO: u64 = 1 << 2;        // acessível pelo anel 3
    pub const ESCRITA_DIRETA: u64 = 1 << 3; // cache write-through
    pub const SEM_CACHE: u64 = 1 << 4;      // desliga o cache para esta página
    pub const ACESSADA: u64 = 1 << 5;       // a CPU liga quando a página é lida
    pub const SUJA: u64 = 1 << 6;           // a CPU liga quando a página é escrita
    pub const GRANDE: u64 = 1 << 7;         // página de 2 MiB (nível PD) ou 1 GiB (nível PDPT)
    pub const GLOBAL: u64 = 1 << 8;         // não sai da TLB ao trocar o CR3
    pub const SEM_EXECUCAO: u64 = 1 << 63;  // proibido executar código daqui (precisa de EFER.NXE)
}
}

Os mais importantes para entender:

  • PRESENTE: se for 0, a CPU ignora todo o resto e gera page fault.
  • ESCRITA e USUARIO: as permissões. A CPU combina as permissões de todos os níveis da caminhada: para escrever, todos precisam permitir. Por isso o SiliconOS cria as tabelas intermediárias com permissões abertas e deixa a restrição para a entrada final.
  • ACESSADA e SUJA: a CPU os liga sozinha. Sistemas operacionais usam isso para decidir quais páginas mandar para o disco (swap) quando falta memória: uma página “suja” precisa ser gravada antes; uma não acessada há muito tempo é uma boa candidata.
  • SEM_EXECUCAO (NX): impede executar código em páginas de dados. É uma proteção importante contra ataques que injetam código. Só funciona se o bit NXE do registrador EFER estiver ligado; no QEMU o UEFI o deixa ligado.

A entrada em Rust é um newtype sobre u64 (rust-basico §22):

#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
#[repr(transparent)]
pub struct Entrada(u64);
}

#[repr(transparent)] garante que, na memória, uma Entrada é exatamente um u64, sem nada a mais, o que é necessário porque a CPU lê essas tabelas diretamente. Uma Tabela é um array de 512 entradas, alinhado em 4 KiB:

#![allow(unused)]
fn main() {
#[repr(C, align(4096))]
pub struct Tabela {
    pub entradas: [Entrada; 512],
}

const _: () = assert!(core::mem::size_of::<Tabela>() == 4096);
}

4. Por que não dava para só “acrescentar” às tabelas do UEFI

A ideia mais simples seria pegar as tabelas que o UEFI deixou e acrescentar o que precisamos. Antes de escrever o código, inspecionamos a máquina pelo monitor do QEMU (info registers e info mem) e encontramos:

CR0=80010033  CR3=0000000007801000  CR4=00000668  EFER=0000000000000d00

0000000006a00000-0000000006c00000  -r-
0000000007800000-0000000007e00000  -r-     ← as tabelas do UEFI (CR3 = 0x7801000) estão aqui
  • As páginas onde ficam as próprias tabelas do UEFI estão marcadas como somente leitura (-r-). É uma proteção do firmware contra corrupção.
  • O bit WP (write protect, bit 16) de CR0 está ligado (0x80010033): com ele, nem o kernel (anel 0) consegue escrever em páginas somente leitura.

Qualquer tentativa de alterar as tabelas do UEFI daria page fault. E, mesmo que desse, elas moram em memória BootServices, que um dia queremos reaproveitar. O kernel precisa das próprias tabelas.

Os outros valores confirmaram o ambiente: CR4 sem o bit 12 (sem paginação de 5 níveis) e EFER com o bit 11 (NXE ligado).


5. O desenho das tabelas do SiliconOS

5.1 O problema da troca

Trocar as tabelas é como trocar o pneu com o carro andando. No instante do mov cr3, a CPU passa a traduzir tudo com as tabelas novas, inclusive:

  • o endereço da próxima instrução do kernel;
  • a pilha (variáveis locais, endereços de retorno);
  • a GDT, a IDT, a TSS e a pilha de emergência, que a CPU consulta em qualquer exceção;
  • o framebuffer e o bitmap do alocador.

Se qualquer um deles não estiver mapeado nas tabelas novas, no mesmo endereço virtual de antes, o kernel cai na hora. E o tratador de page fault também está em memória que precisa estar mapeada…

5.2 A solução: três regiões

 espaço virtual (48 bits)
 0xFFFF_FFFF_FFFF_FFFF ┐
                       │
 0xFFFF_8000_0000_0000 ├─ (3) TODA a RAM física de novo: virtual = DESLOCAMENTO + físico
                       │     é por aqui que o kernel acessa memória física daqui em diante
                       │
       ╳ buraco não-canônico ╳
                       │
 0x0000_0000_8000_0000 ├─ (2) framebuffer (identidade)
                       │
 0x0000_0000_0000_1000 ├─ (1) TODA a RAM em identidade: virtual = físico
 0x0000_0000_0000_0000 ┘     ...exceto a página 0 (ponteiro nulo → page fault)

(1) Identidade. Mantém válidos todos os endereços que o kernel já está usando (código, pilha, statics), porque o UEFI carregou o kernel e criou a pilha em endereços físicos. É o que permite a troca “com o carro andando”. É um andaime temporário: quando houver programas comuns, a metade baixa será deles, e o kernel terá que sair dali.

(2) Framebuffer. Não é RAM, então não aparece no mapa de memória (doc 05). Mapeado à parte, em identidade.

(3) A RAM inteira na metade alta, a partir de DESLOCAMENTO_FISICO = 0xFFFF_8000_0000_0000. Esta é a parte permanente do desenho, chamada de mapeamento com deslocamento (offset mapping). Para o kernel acessar o quadro físico F (uma tabela de página, o bitmap, um quadro que ele acabou de alocar), basta usar o ponteiro DESLOCAMENTO_FISICO + F. Quase todos os kernels de 64 bits fazem algo parecido. O Linux chama isso de direct map.

5.3 A página 0 sem mapeamento

Os primeiros 2 MiB são mapeados em páginas de 4 KiB, pulando a página 0:

#![allow(unused)]
fn main() {
for p in 1..512u64 {      // começa em 1, não em 0
    mapear_em(pml4, p * TAMANHO_QUADRO, p * TAMANHO_QUADRO, TamanhoPagina::K4, rw).unwrap();
}
}

Resultado: ler ou escrever através de um ponteiro nulo agora dá page fault. Com as tabelas do UEFI, o endereço 0 era memória válida, e um bug de ponteiro nulo passaria despercebido, corrompendo dados em silêncio. (É a mesma razão pela qual seu programa Java ou C no Linux recebe NullPointerException / segmentation fault em vez de ler lixo.)

5.4 A RAM em páginas de 2 MiB

#![allow(unused)]
fn main() {
for r in mapa.regioes().iter().filter(|r| !matches!(r.tipo, Tipo::Mmio | Tipo::Reservada)) {
    let mut a = r.inicio & !(DOIS_MIB - 1);                  // arredonda para baixo
    let fim = (r.fim() + DOIS_MIB - 1) & !(DOIS_MIB - 1);    // arredonda para cima
    while a < fim {
        if a != 0 && traduzir_em(pml4, a).is_none() {
            mapear_em(pml4, a, a, TamanhoPagina::M2, rw).unwrap();
        }
        if traduzir_em(pml4, DESLOCAMENTO_FISICO + a).is_none() {
            mapear_em(pml4, DESLOCAMENTO_FISICO + a, a, TamanhoPagina::M2, rw).unwrap();
        }
        a += DOIS_MIB;
    }
}
}

Para cada região de RAM do mapa (doc 05), cada bloco de 2 MiB é mapeado duas vezes: em identidade e na metade alta. Detalhes:

  • x & !(DOIS_MIB - 1) arredonda x para baixo, para um múltiplo de 2 MiB (zera os 21 bits de baixo). Somar DOIS_MIB - 1 antes faz arredondar para cima. É o truque padrão de alinhamento com potências de 2.
  • O traduzir_em(...).is_none() evita mapear duas vezes o mesmo bloco: várias regiões pequenas do mapa caem dentro do mesmo bloco de 2 MiB.
  • O bloco 0 (a != 0) é pulado na identidade porque já foi feito em páginas de 4 KiB (seção 5.3), mas não na metade alta: lá, o físico 0 não é um ponteiro nulo.
  • Arredondar para blocos de 2 MiB pode mapear alguns KiB de “buraco” ao lado de uma região de RAM. É inofensivo, porque o kernel nunca usa esses endereços (o alocador só entrega o que o mapa diz ser livre).

5.5 Criar tabelas sob demanda: mapear_em

#![allow(unused)]
fn main() {
pub fn mapear_em(raiz: u64, virt: u64, fisico: u64, tamanho: TamanhoPagina, bits: u64)
    -> Result<(), ErroMapeamento>
{
    // ... verifica alinhamento e se o endereço é canônico ...
    let idx = indices(virt);
    let folha = tamanho.nivel_folha();       // 1 para 1 GiB, 2 para 2 MiB, 3 para 4 KiB
    let mut tabela = raiz;

    for nivel in 0..folha {                  // desce até o nível da folha
        let e = &mut tabela_em(tabela).entradas[idx[nivel]];
        if !e.presente() {
            let nova = nova_tabela().ok_or(ErroMapeamento::SemMemoria)?;
            *e = Entrada(nova | PRESENTE | ESCRITA | (bits & USUARIO));
        } else if e.grande() {
            return Err(ErroMapeamento::JaMapeado);
        }
        tabela = e.endereco();
    }

    let e = &mut tabela_em(tabela).entradas[idx[folha]];
    if e.presente() { return Err(ErroMapeamento::JaMapeado); }
    let grande = if tamanho == TamanhoPagina::K4 { 0 } else { GRANDE };
    *e = Entrada(fisico | bits | grande | PRESENTE);
    invalidar(virt);                         // invlpg: tira da TLB
    Ok(())
}
}

É a caminhada da seção 2.3 com uma diferença: quando falta um nível, ele é criado. nova_tabela() pede um quadro ao alocador do doc 05 e o zera (uma tabela zerada = 512 entradas “não presentes”). É aqui que os dois capítulos se encontram: as tabelas de página são feitas de quadros físicos.

O retorno é um Result (rust-basico §6) com um enum de erros possíveis:

#![allow(unused)]
fn main() {
pub enum ErroMapeamento { Desalinhado, NaoCanonico, JaMapeado, NaoMapeado, SemMemoria }
}

O ? depois de ok_or(...) significa “se deu erro, retorne esse erro agora” (rust-basico §24).

desmapear_em faz a caminhada, zera a entrada final, chama invlpg e devolve o endereço físico que estava mapeado (para quem chamou poder liberar o quadro). As tabelas intermediárias que ficarem vazias não são devolvidas ao alocador por enquanto; é um pequeno desperdício, anotado no código.

5.6 Acessar uma tabela pelo endereço físico

As entradas guardam endereços físicos, mas o código só consegue usar endereços virtuais. A ponte é esta função:

#![allow(unused)]
fn main() {
static DESLOCAMENTO_ATUAL: AtomicU64 = AtomicU64::new(0);

pub fn fisico_para_virtual(fisico: u64) -> u64 {
    fisico + DESLOCAMENTO_ATUAL.load(Ordering::Relaxed)
}

fn tabela_em(fisico: u64) -> &'static mut Tabela {
    unsafe { &mut *(fisico_para_virtual(fisico) as *mut Tabela) }
}
}

O detalhe elegante: o deslocamento muda no meio da execução.

  • Durante a montagem, as tabelas do UEFI (identidade) ainda estão ativas. Deslocamento = 0: o físico F é acessado pelo ponteiro F.
  • Depois da troca, o deslocamento passa a ser DESLOCAMENTO_FISICO: o físico F é acessado pelo ponteiro 0xFFFF_8000_0000_0000 + F.

O mesmo código (mapear_em, traduzir_em, nova_tabela) funciona nos dois momentos sem saber disso. A variável é um AtomicU64 (rust-basico §25) porque é uma global alterada durante a execução.

5.7 A troca

#![allow(unused)]
fn main() {
// 4. A troca
unsafe { asm!("mov cr3, {}", in(reg) pml4, options(nostack, preserves_flags)) };

// 5. Daqui em diante, memória física é acessada pela metade alta.
DESLOCAMENTO_ATUAL.store(DESLOCAMENTO_FISICO, Ordering::Relaxed);
quadros::reposicionar_bitmap(DESLOCAMENTO_FISICO);
}

Uma instrução. Escrever em CR3 também esvazia a TLB, então as traduções antigas do UEFI não sobrevivem.

reposicionar_bitmap resolve a pendência deixada no doc 05: o bitmap do alocador era acessado pelo endereço físico (identidade). Agora o slice é recriado apontando para a metade alta. Ainda funcionaria pela identidade, mas assim o alocador não depende mais do andaime temporário.


6. Como foi testado

6.1 Antes e depois, no boot

O kernel imprime a tradução de alguns endereços com as tabelas do UEFI e depois com as dele. Os números entre colchetes são os 4 índices (PML4, PDPT, PD, PT):

Paginacao: tabelas do UEFI (CR3=0x7801000)
  codigo   0x00000000060711a0 [  0   0  48 113] -> 0x60711a0 (2 MiB)
  pilha    0x0000000007e96150 [  0   0  63 150] -> 0x7e96150 (4 KiB)
  nulo     0x0000000000000000 [  0   0   0   0] -> 0x0 (2 MiB)
Paginacao: tabelas do KERNEL ativas (CR3 0x7801000 -> 0x101000, 7 quadros usados)
  codigo   0x00000000060711a0 [  0   0  48 113] -> 0x60711a0 (2 MiB)
  pilha    0x0000000007e96150 [  0   0  63 150] -> 0x7e96150 (2 MiB)
  nulo     0x0000000000000000 [  0   0   0   0] -> NAO MAPEADO
  fis 1MiB 0xffff800000100000 [256   0   0 256] -> 0x100000 (2 MiB)

Como ler:

  • código e pilha continuam traduzindo para o mesmo físico: a troca foi transparente. O UEFI usava páginas de 4 KiB perto da pilha; nós usamos 2 MiB.
  • nulo: antes era memória válida; agora gera page fault.
  • fis 1MiB: o endereço 0xFFFF_8000_0010_0000 (índice 256 na PML4, a primeira entrada da metade alta) chega ao físico 0x100000, onde mora o bitmap.
  • O kernel inteiro, com 128 MiB de RAM, precisou de só 7 quadros (28 KiB) de tabelas, graças às páginas de 2 MiB.

6.2 Do ponto de vista do QEMU

O monitor do QEMU (info mem), que lê as tabelas sem passar pelo nosso código, mostrou exatamente as três regiões da seção 5.2:

CR3=0000000000101000
0000000000001000-0000000008000000 0000000007fff000 -rw     ← (1) identidade, sem a página 0
0000000080000000-0000000080400000 0000000000400000 -rw     ← (2) framebuffer (4 MiB)
ffff800000000000-ffff800008000000 0000000008000000 -rw     ← (3) RAM na metade alta

6.3 ./run.sh teste-paginacao

  1. mapeia um endereço virtual sem uso (0x5000_0000_0000, 80 TiB) num quadro novo e confere que traduzir devolve esse quadro, com página de 4 KiB;
  2. escreve por esse endereço e lê o mesmo dado pela metade alta: dois caminhos virtuais diferentes, o mesmo quadro físico;
  3. mapeia um segundo endereço virtual no mesmo quadro (aliasing) e confere que o que se escreve por um aparece pelo outro;
  4. confere os erros: mapear de novo → JaMapeado; endereço não alinhado → Desalinhado; endereço não-canônico → NaoCanonico;
  5. desmapeia os dois, confere que deixaram de traduzir e que desmapear de novo dá NaoMapeado, e libera o quadro.
Teste de paginacao OK: mapear, traduzir, escrever/ler por 2 caminhos,
  aliasing, erros e desmapear. Quadros em tabelas novas: 3

Os 3 quadros são a PDPT, a PD e a PT criadas para o endereço de teste (nada mais existia naquela região).

6.4 ./run.sh teste-desmapeado e ./run.sh teste-nulo

Teste: escrevi em 0x500000000000; desmapeando e lendo de novo...
Mensagem: EXCECAO #14 PAGE FAULT ao ler 0x500000000000 (pagina nao mapeada, modo kernel)

Teste: lendo o endereco 0 (ponteiro nulo)...
Mensagem: EXCECAO #14 PAGE FAULT ao ler 0x0 (pagina nao mapeada, modo kernel)

O primeiro prova que desmapear + invlpg funcionam: a página foi usada (e estava na TLB), e mesmo assim a leitura seguinte falhou.

O teste do ponteiro nulo usa assembly (mov rax, [0]) de propósito. Em Rust, ler um ponteiro nulo é comportamento indefinido: o compilador assume que isso nunca acontece e pode gerar qualquer código (inclusive remover a leitura). Em modo debug, o Rust ainda verifica o ponteiro e daria panic antes de a CPU tentar o acesso. Para testar a CPU, é preciso falar diretamente com ela.

6.5 Com mais memória

RAMQuadros de tabelasObservação
128 MiB7—
4 GiB13código e pilha em PDPT 2 (entre 2 e 3 GiB)
8 GiB21código acima de 4 GiB (0x140009a00, PDPT 5) e pilha abaixo: a identidade cobre os dois

teste-paginacao passou nas três configurações.

6.6 Todos os testes anteriores

Os 12 testes dos capítulos anteriores (panic, as 6 exceções, quadros, liberação dupla) passaram com as tabelas novas. Em especial, o double fault continua funcionando: a pilha IST1 é um static do kernel, coberto pela identidade.


Diferença em relação ao livro

Pelo sumário, o capítulo 7 do livro trata dos mesmos temas: páginas, quadros e tradução (7.1), mapear e desmapear (7.2), as estratégias higher-half, offset mapping e identidade (7.3), e como depurar page faults e a TLB (7.4). Como o livro parte de um bootloader pronto, o ponto de partida dele provavelmente é diferente do nosso. Vale comparar ao ler.

As particularidades do SiliconOS:

  • parte das tabelas do UEFI, que são somente leitura (seção 4), e por isso monta as próprias tabelas do zero e faz a troca do CR3;
  • monta tudo à mão, sem a crate x86_64;
  • usa identidade + mapeamento com deslocamento (seção 5.2). O código do kernel ainda roda em endereços baixos, porque foi o UEFI que o carregou ali. Mover o kernel para a metade alta (higher-half kernel) fica para quando os programas comuns precisarem da metade baixa.

Resumo

  • Todo ponteiro é um endereço virtual; a CPU o traduz para físico percorrendo 4 níveis de tabelas (PML4 → PDPT → PD → PT), a partir do CR3. Cada tabela é um quadro de 4 KiB com 512 entradas.
  • O endereço virtual é picado em 4 índices de 9 bits + 12 bits de deslocamento. Páginas de 2 MiB e 1 GiB param a caminhada mais cedo.
  • Cada entrada tem um endereço físico e bits de controle (presente, escrita, usuário, NX…). A TLB guarda traduções; ao mudar uma entrada, é preciso invlpg.
  • As tabelas do UEFI são somente leitura; o SiliconOS monta as suas com identidade (andaime para a troca), framebuffer e toda a RAM na metade alta (0xFFFF_8000_0000_0000 + físico), sem a página 0.
  • fisico_para_virtual usa um deslocamento que vale 0 antes da troca e DESLOCAMENTO_FISICO depois, então o mesmo código funciona nos dois momentos.
Para fixar (clique para ver as respostas)
  1. Quais são os 4 índices do endereço 0xFFFF_8000_0010_0000? PML4 = 256, PDPT = 0, PD = 0, PT = 256 (0x100000 >> 12 = 256). É o que o boot mostra na linha “fis 1MiB”.

  2. Por que cada tabela tem exatamente 512 entradas? Porque cada índice tem 9 bits (2⁹ = 512), e 512 entradas × 8 bytes = 4096 bytes, exatamente um quadro.

  3. O kernel muda uma entrada de PT para apontar para outro quadro, mas continua lendo os dados antigos. O que faltou? O invlpg: a tradução antiga continuava na TLB.

  4. Por que o SiliconOS não acrescentou seus mapeamentos às tabelas do UEFI? Porque as páginas onde elas ficam estão marcadas como somente leitura, e o bit WP de CR0 faz a CPU respeitar isso até no anel 0. Além disso, elas moram em memória BootServices.

  5. Para que serve a identidade, se o kernel passa a acessar a memória física pela metade alta? Para que o código, a pilha e as tabelas da CPU (GDT, IDT, TSS), que estão em endereços físicos baixos, continuem válidos no instante da troca do CR3. É um andaime temporário.

  6. Mapear 8 GiB com páginas de 4 KiB custaria quantas tabelas PT? E com páginas de 2 MiB? 8 GiB / 4 KiB = 2 097 152 páginas, ou seja, 4096 PTs (16 MiB). Com 2 MiB, nenhuma PT: só 8 PDs (8 GiB / 1 GiB por PD).

Rust para quem já programa (só o que o SiliconOS usa)

Este guia não ensina Rust inteiro. Ele explica apenas os recursos que aparecem no código do SiliconOS, comparando com linguagens que você já conhece (Java, Kotlin, JavaScript/TypeScript, Python). Os capítulos remetem para cá sempre que um recurso aparece pela primeira vez.

Se algo daqui parecer abstrato, pule e volte quando o capítulo pedir.


1. Tipos numéricos: o tamanho é explícito

Em Java você tem int (32 bits) e long (64 bits). Em Python, inteiros não têm tamanho. Em Rust, todo inteiro diz quantos bits tem e se tem sinal:

RustSignificadoEquivalente aproximado
u8sem sinal, 8 bits (0 a 255)byte sem sinal
u16sem sinal, 16 bitschar do Java (numérico)
u32sem sinal, 32 bitsint sem sinal
u64sem sinal, 64 bitslong sem sinal
i32, i64com sinalint, long
usizesem sinal, do tamanho de um ponteiro (64 bits aqui)usado para índices e tamanhos

Num kernel isso é essencial: o hardware exige campos de tamanho exato (“este campo tem 16 bits”), então os tipos mapeiam um para um com o manual da Intel.

Números podem ter _ para facilitar a leitura e prefixos de base:

#![allow(unused)]
fn main() {
0x00FF_FFFF        // hexadecimal (o _ é só visual)
0b0000_1100        // binário
5 * 4096           // expressões normais
}

Conversões são sempre explícitas, com as:

#![allow(unused)]
fn main() {
let x: u64 = 300;
let y = x as u16;   // como um cast em Java: (short) x
let z = x as u8;    // 300 não cabe em 8 bits: vira 44 (descarta os bits altos)
}

Não existe conversão automática entre u32 e u64, como existe de int para long em Java. Isso parece chato no começo, mas evita bugs sutis de truncamento.


2. Variáveis: imutáveis por padrão

#![allow(unused)]
fn main() {
let a = 5;          // imutável: como `val` do Kotlin ou `final` do Java
let mut b = 5;      // mutável: como `var` do Kotlin
b += 1;             // ok
// a += 1;          // ERRO de compilação
}

O tipo geralmente é inferido, como em Kotlin e TypeScript. Quando precisa, ele vai depois do nome: let x: u64 = 0;.


3. Funções e o tipo ! (“nunca retorna”)

#![allow(unused)]
fn main() {
fn soma(a: u32, b: u32) -> u32 {
    a + b           // a última expressão SEM ponto e vírgula é o retorno
}
}

Como em Kotlin, quase tudo é expressão: um bloco { ... } “vale” o valor da sua última linha.

O SiliconOS usa muito um tipo especial: !, chamado never.

#![allow(unused)]
fn main() {
fn kernel_main(...) -> ! {
    ...
    loop { ... }    // loop infinito: nunca sai
}
}

-> ! significa “esta função nunca retorna”. Não é void (que retorna sem valor); é uma promessa verificada pelo compilador de que a função termina num loop infinito, para a máquina ou chama outra função -> !. Num kernel isso aparece o tempo todo: não existe “para onde voltar” depois que o kernel começa.


4. Structs e impl: classes sem herança

Rust não tem classes. Tem structs (dados) e blocos impl (métodos para esses dados):

#![allow(unused)]
fn main() {
pub struct Console {        // como uma classe Java só com campos
    col: usize,
    row: usize,
    pub cor: u32,           // pub = público; sem pub = privado ao módulo
}

impl Console {
    pub fn new(...) -> Self { ... }        // "construtor" é só convenção de nome
    fn cols(&self) -> usize { ... }        // método que só lê o objeto
    pub fn write_byte(&mut self, b: u8) { ... }  // método que altera o objeto
}
}
  • Self = o próprio tipo (aqui, Console).
  • &self = como o this do Java, mas somente leitura.
  • &mut self = this com permissão de alterar campos.
  • Uma função dentro de impl sem self é como um método static: chamada com Console::new(...).

5. Traits: interfaces

Uma trait é como uma interface do Java/Kotlin. O SiliconOS implementa a trait da biblioteca padrão core::fmt::Write, que exige um único método:

#![allow(unused)]
fn main() {
impl fmt::Write for Serial {           // "Serial implements Write"
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for b in s.bytes() { ... }
        Ok(())
    }
}
}

Assim como interfaces Java podem ter métodos default, a trait Write traz de graça o método write_fmt, que formata texto ("{} + {}") e chama o nosso write_str. Implementamos 1 método e ganhamos a formatação inteira. É por isso que writeln!(Serial, "x = {}", x) funciona.

impl Trait em parâmetro significa “qualquer tipo que implemente esta trait”, como um genérico com restrição (<T extends MemoryMap> em Java):

#![allow(unused)]
fn main() {
fn kernel_main(fb: FrameBuffer, memory_map: &impl MemoryMap) -> ! { ... }
}

6. Option e Result: sem null e sem exceções

Rust não tem null nem exceções (try/catch). No lugar:

  • Option<T> é Some(valor) ou None. É o Optional<T> do Java ou o T? do Kotlin.
  • Result<T, E> é Ok(valor) ou Err(erro). É um “retorno que pode ser erro”, que você é obrigado a examinar.
#![allow(unused)]
fn main() {
static CONSOLE: Mutex<Option<Console>> = ...;   // pode não existir ainda

if let Some(con) = CONSOLE.lock().as_mut() {    // "se existir, chame de con"
    con.write_fmt(args);
}
}

if let Some(x) = valor é como valor?.let { x -> ... } do Kotlin.

.unwrap() significa “eu garanto que é Some/Ok; se não for, aborte”. Se falhar, gera um panic (capítulo 02). Usamos unwrap() na fase de boot, em que não há o que fazer se o firmware falhar.

let _ = algo(); significa “ignoro o resultado de propósito”. O compilador avisa se você ignorar um Result sem dizer nada.


7. match: um switch que obriga a cobrir tudo

#![allow(unused)]
fn main() {
let origem = match (codigo >> 1) & 0b11 {
    0b00 => "GDT",
    0b10 => "LDT",
    _    => "IDT",       // _ = "qualquer outro valor" (o default)
};
}

Parecido com o when do Kotlin. O match é uma expressão (devolve um valor), e o compilador exige que todos os casos possíveis sejam cobertos.


8. Referências e o “borrow checker” (versão curtíssima)

Em Java, objetos são sempre acessados por referência e o coletor de lixo cuida da memória. Rust não tem coletor de lixo. Em vez dele, o compilador acompanha quem é “dono” de cada valor e quem o pegou emprestado:

  • &x: empréstimo somente leitura (pode haver vários ao mesmo tempo);
  • &mut x: empréstimo com escrita (só um por vez, e nenhum de leitura junto).

Essa regra (“ou muitos leitores, ou um escritor”) é verificada em tempo de compilação e elimina uma classe inteira de bugs (dois trechos alterando o mesmo dado ao mesmo tempo). É por isso que o SiliconOS usa um Mutex para a variável global do console (seção 11).

Quando um valor sai de escopo, ele é destruído automaticamente (o drop). É como o try-with-resources do Java, só que para tudo. O SiliconOS depende disso, por exemplo, para liberar o lock do console sozinho.


9. Ponteiros crus e unsafe

Referências (&T) são sempre válidas: o compilador garante. Mas um kernel precisa fazer coisas que o compilador não tem como verificar, como “escreva no endereço 0x80000000, que eu sei que é a tela”. Para isso existem os ponteiros crus:

#![allow(unused)]
fn main() {
*mut u32     // ponteiro para u32, com escrita (como `uint32_t*` em C)
*const u64   // ponteiro somente leitura
}

Ponteiros crus podem ser criados livremente, mas só podem ser usados dentro de um bloco unsafe:

#![allow(unused)]
fn main() {
unsafe {
    self.fb.base.add(y * self.fb.stride + x).write_volatile(cor);
}
}

unsafe não desliga as verificações do Rust. Ele libera apenas algumas operações extras (usar ponteiro cru, chamar função unsafe, executar assembly, mexer em static mut) e significa: “compilador, aqui quem garante a correção sou eu”. A boa prática, seguida no SiliconOS, é manter os blocos unsafe pequenos e comentados, e expor funções seguras por cima. Exemplo: outb é unsafe, mas Serial::write_byte não é, então quem usa a serial não precisa se preocupar.

Uma unsafe fn é uma função que só pode ser chamada dentro de unsafe { }, porque quem chama precisa garantir alguma condição.


10. static e const

#![allow(unused)]
fn main() {
const ESCALA: usize = 2;              // constante: substituída no código, como #define
static CONSOLE: Mutex<...> = ...;     // variável global: um único endereço na memória
static mut PILHA: [u8; 20480] = ...;  // global MUTÁVEL: todo uso exige unsafe
}

Em Java, static é um campo da classe. Em Rust, static é uma variável global com endereço fixo que vive o programa inteiro. Isso importa para a CPU, que guarda endereços de tabelas (GDT, IDT): elas precisam ficar no mesmo lugar para sempre.

static mut é perigoso, porque qualquer parte do código poderia alterá-la ao mesmo tempo. Por isso o Rust exige unsafe em todo acesso e recomenda envolver globais num Mutex ou Once.

'static (com apóstrofo) é um tempo de vida que significa “válido até o fim do programa”. &'static T é uma referência que nunca fica inválida.


11. Mutex e Once (da crate spin)

  • Mutex<T> protege um dado compartilhado, como o synchronized do Java. CONSOLE.lock() devolve um “guarda”; enquanto ele existir, ninguém mais pega o lock. Quando o guarda sai de escopo, o lock é liberado sozinho (seção 8).
    • O Mutex da biblioteca padrão põe a thread para dormir pedindo ao sistema operacional, mas nós somos o sistema operacional. A crate spin oferece um spinlock: quem quer o lock fica em loop tentando até conseguir.
  • Once<T> guarda um valor que é calculado uma única vez, na primeira chamada de call_once, como um lazy do Kotlin ou um singleton. Devolve &'static T.

12. Módulos, crates, Cargo e features

RustAnalogia
crateum pacote/biblioteca (como um .jar ou um pacote npm)
Cargoo gerenciador de build e dependências (Maven/Gradle/npm juntos)
Cargo.tomlo pom.xml/package.json
mod serial;“existe um módulo no arquivo serial.rs” (o Rust não descobre arquivos sozinho)
use spin::Mutex;import
crate::gdtcaminho a partir da raiz do nosso projeto
pubpublic; sem pub é privado ao módulo

Features são opções de compilação declaradas no Cargo.toml:

[features]
teste-pagefault = []

No código, #[cfg(feature = "teste-pagefault")] faz o bloco seguinte só existir quando a feature é ligada (cargo build --features teste-pagefault). É o #ifdef do C. O código de teste nem entra no binário normal.


13. Atributos: #[...] e #![...]

São como as annotations do Java (@Override):

AtributoEfeito
#![no_std](vale para o crate todo, por causa do !) não use a biblioteca padrão
#![no_main]não use o main padrão
#![feature(...)]ligue um recurso experimental (só no Rust nightly)
#[entry](da crate uefi) esta função é o ponto de entrada UEFI
#[panic_handler]esta função trata os panics
#[repr(C)]organize os campos na memória exatamente na ordem escrita, como em C
#[repr(C, packed(2))]idem, e sem espaços de alinhamento (padding) entre campos
#[repr(align(16))]o início deste tipo fica num endereço múltiplo de 16
#[repr(transparent)]uma struct com um único campo tem exatamente a mesma representação na memória que esse campo
#[derive(Clone, Copy)]gere automaticamente a cópia do valor
#[allow(dead_code)]não avise que isto não está sendo usado

O #[repr(...)] é crucial num kernel. Sem ele, o compilador Rust pode reordenar os campos de uma struct para economizar memória. Quando a struct descreve algo que a CPU vai ler (uma entrada da IDT, a TSS), a ordem e o tamanho precisam bater bit a bit com o manual.


14. Macros: o ! no fim do nome

println!, panic!, writeln!, format_args!, asm!: o ! indica uma macro, não uma função. Macros são expandidas em código em tempo de compilação. É assim que println!("{} {}", a, b) aceita qualquer número de argumentos e verifica o texto de formatação durante a compilação.

O SiliconOS define suas próprias macros (kprintln!, kprint!) com macro_rules!. Não é preciso entender a sintaxe interna delas: basta saber que kprintln!("x = {}", x) funciona como o println! normal.


15. asm!: assembly dentro do Rust

#![allow(unused)]
fn main() {
core::arch::asm!("out dx, al", in("dx") port, in("al") value);
}
  • O texto entre aspas é a instrução de assembly x86 (sintaxe Intel: destino primeiro).
  • in("dx") port significa “antes de executar, coloque a variável port no registrador DX”.
  • out("al") valor significa “depois de executar, copie o registrador AL para valor”.
  • in(reg) x significa “coloque x em qualquer registrador livre”, e o {} no texto vira o nome do registrador escolhido.
  • options(nomem, nostack, ...) são promessas ao otimizador (“esta instrução não mexe na memória nem na pilha”), que permitem gerar código melhor.

Sempre exige unsafe.


16. Closures

#![allow(unused)]
fn main() {
TSS.call_once(|| { ... })
}

|| { ... } é uma função anônima sem parâmetros, como uma lambda do Java (() -> { ... }) ou uma arrow function do JS (() => { ... }). Com parâmetros: |x, y| x + y.


17. no_std, core e alloc

BibliotecaO que temDepende de SO?
coretipos, Option, Result, iteradores, formatação, atômicosnão
allocBox, Vec, String (precisa de alguém que forneça memória)não, mas exige um alocador
stdarquivos, rede, threads, println!…sim

O SiliconOS usa só core (#![no_std]). Quando escrevermos um alocador de memória (capítulo 8 do livro), ganharemos também alloc e poderemos usar Vec e String dentro do kernel.


18. Rust nightly

O Rust tem três canais: stable, beta e nightly. O nightly permite recursos experimentais ligados com #![feature(...)]. O SiliconOS usa um deles: abi_x86_interrupt (capítulo 04), que permite escrever tratadores de interrupção em Rust puro.


19. enum simples

#![allow(unused)]
fn main() {
pub enum Tipo { Livre, BootServices, Kernel, Runtime, Acpi, Mmio, Reservada }
}

Como um enum do Java ou do Kotlin: um tipo que só pode assumir um dos valores listados. Usa-se com o nome do tipo na frente (Tipo::Livre) e quase sempre com match, que obriga a tratar todos os valores (§7).

Os enums de Rust também podem carregar dados em cada variante (é assim que Option e Result são feitos: Some(valor), Ok(valor)), mas o SiliconOS ainda não precisou disso.

Para comparar dois valores com ==, o enum precisa de #[derive(PartialEq, Eq)]. Em Java, a comparação de enums funciona sem declarar nada; em Rust, toda capacidade é pedida explicitamente.

matches!(valor, A | B) é um atalho para “o valor é A ou B?”, que devolve um bool.


20. Iteradores em cadeia: filter, map, fold…

#![allow(unused)]
fn main() {
self.regioes()
    .iter()                                  // percorre as regiões
    .filter(|r| r.tipo == tipo)              // só as do tipo pedido
    .fold((0, 0), |(n, q), r| (n + 1, q + r.quadros))   // acumula (contagem, soma)
}

É a mesma ideia dos Streams do Java, das funções de coleção do Kotlin ou de filter/map/reduce do JavaScript. Os nomes:

RustJava Stream / JS
.iter().stream()
.filter(|x| ...).filter(x -> ...)
.map(|x| ...).map(x -> ...)
.find(|x| ...).filter(...).findFirst() / .find()
.fold(inicial, |acc, x| ...).reduce(inicial, ...)
.max().max()
.count().count()
.enumerate()(pares índice + valor)

Uma diferença importante: em Rust isso não aloca memória nem cria objetos intermediários. O compilador transforma a cadeia num laço comum, tão rápido quanto escrever o for à mão. Por isso pode ser usado dentro do kernel antes mesmo de existir um alocador.


21. Slices: um pedaço de um array

#![allow(unused)]
fn main() {
&self.regioes[..self.qtd]     // do início até qtd (exclusivo)
&lista[..i]                   // os i primeiros
}

Um slice (&[T] ou &mut [T]) é uma “janela” sobre uma sequência de valores na memória: um ponteiro mais um tamanho. Não copia nada. É como o subList do Java ou uma view sobre um array. Funciona com a sintaxe de intervalo: [a..b] (de a até antes de b), [..b], [a..].

Slices conhecem o próprio tamanho, então slice[i] verifica o limite (e dá panic se i for grande demais).

core::slice::from_raw_parts_mut(ponteiro, n) cria um slice a partir de um endereço cru e de uma quantidade. É unsafe, porque o compilador não tem como saber se aquela memória existe. O SiliconOS usa isso para enxergar o bitmap do alocador como um array comum.

Métodos úteis de slice usados no projeto: .len(), .fill(valor), .contains(&x), .sort_unstable_by_key(|x| chave), .iter(), .iter_mut().


22. Newtype: um tipo próprio para um número

#![allow(unused)]
fn main() {
pub struct Quadro(pub u64);       // uma "struct-tupla" com um único campo

let q = Quadro(0x101000);
q.0                               // acessa o campo (por posição, não por nome)
}

Embrulhar um número num tipo próprio faz o compilador recusar misturas erradas: uma função que pede Quadro não aceita um u64 qualquer, nem um futuro EnderecoVirtual. Em Java, o equivalente seria criar uma classe só para isso, com custo de objeto. Em Rust, o custo é zero: na memória, um Quadro é só um u64.


23. assert! e expect: verificações que viram panic

#![allow(unused)]
fn main() {
assert!(condicao, "mensagem {}", valor);    // se a condição for falsa: panic com a mensagem
assert_eq!(a, b);                           // se a != b: panic mostrando os dois valores
opcao.expect("mensagem")                    // como unwrap(), mas com mensagem explicativa
}

São usados para invariantes: coisas que, se forem falsas, significam um bug. Diferente do Java, os assert! do Rust ficam ligados também na versão final (há debug_assert! para os que só valem em modo de depuração). Num kernel isso é uma vantagem: um estado impossível vira uma mensagem clara na hora, em vez de corrupção silenciosa mais tarde.

expect("...") é preferível a unwrap() quando a falha é possível em teoria: a mensagem aparece no panic e diz o que estava faltando.


24. O operador ?: propagar erros

#![allow(unused)]
fn main() {
let nova = nova_tabela().ok_or(ErroMapeamento::SemMemoria)?;
}

Lendo da esquerda para a direita:

  1. nova_tabela() devolve Option<u64> (None se a memória acabou);
  2. .ok_or(erro) converte para Result: Some(x) vira Ok(x), None vira Err(erro);
  3. o ? no fim: se for Ok(x), a expressão vale x e o código segue; se for Err(e), a função atual retorna Err(e) imediatamente.

É o equivalente a deixar uma exceção “subir” em Java, mas visível no código (o ? marca exatamente onde a função pode sair mais cedo) e checado pelo compilador: só se pode usar ? numa função que também devolve Result (ou Option).

Sem o ?, seria preciso escrever:

#![allow(unused)]
fn main() {
let nova = match nova_tabela() {
    Some(x) => x,
    None => return Err(ErroMapeamento::SemMemoria),
};
}

25. Atômicos: AtomicU64, AtomicBool

#![allow(unused)]
fn main() {
static DESLOCAMENTO_ATUAL: AtomicU64 = AtomicU64::new(0);

DESLOCAMENTO_ATUAL.store(valor, Ordering::Relaxed);   // escrever
let d = DESLOCAMENTO_ATUAL.load(Ordering::Relaxed);   // ler
}

Os tipos Atomic* são como os AtomicLong/AtomicBoolean do Java: valores que podem ser lidos e alterados com segurança mesmo por várias CPUs ao mesmo tempo, sem lock. São a forma mais simples de ter uma global mutável sem unsafe e sem Mutex, quando o dado é um único número.

O parâmetro Ordering controla as garantias de ordem entre CPUs diferentes. Relaxed (a mais fraca) basta quando só importa o valor da própria variável; SeqCst (a mais forte, usada no panic handler) garante uma ordem global. Enquanto o SiliconOS rodar numa CPU só, a diferença não aparece.

Glossário: hardware e sistemas operacionais

Termos que aparecem nos capítulos, explicados para quem programa mas nunca trabalhou “abaixo” do sistema operacional. Os termos de Rust estão em rust-basico.md.


Bits, bytes e hexadecimal (revisão rápida)

Quase tudo em OSDev é descrito em hexadecimal (base 16, prefixo 0x), porque cada dígito hexa corresponde a exatamente 4 bits:

0x8E  =  1000 1110  (binário)  =  142 (decimal)
   8      E

Isso deixa fácil ler “quais bits estão ligados” num número. Os bits são numerados da direita para a esquerda, começando em 0:

bit:   7 6 5 4 3 2 1 0
0x8E:  1 0 0 0 1 1 1 0     → bits 7, 3, 2 e 1 ligados

Operações de bits usadas no código:

ExpressãoSignificado
1 << 47um número com só o bit 47 ligado
a | bjunta os bits ligados de a e b (OR)
x & 0x20isola o bit 5 de x (dá 0 se ele estiver desligado)
x >> 16desloca para a direita: descarta os 16 bits de baixo

Tamanhos: KiB = 1024 bytes, MiB = 1024 KiB, GiB, TiB… (o “i” indica potência de 2, para diferenciar de KB = 1000).


A CPU por dentro

Registrador
Uma “variável” dentro da própria CPU, muito mais rápida que a memória. Em x86-64 há 16 registradores de uso geral de 64 bits (RAX, RBX, RCX, RDX, RSI, RDI, RSP, RBP, R8–R15) e vários especiais. Partes menores têm outros nomes: EAX = 32 bits de baixo do RAX, AX = 16 bits, AL = 8 bits.
RIP (instruction pointer)
Registrador com o endereço da próxima instrução a executar. Quando um erro mostra “RIP=0x608c827”, é ali que o código estava.
RSP (stack pointer)
Endereço do topo da pilha atual.
RFLAGS
Registrador de “bandeiras”: bits que indicam resultado da última conta (zero? negativo? estouro?) e estados da CPU. O bit IF (interrupt flag) liga/desliga interrupções de hardware.
Registradores de controle (CR0, CR2, CR3, CR4)
Configuram o modo de operação da CPU. Exemplo: o CR2 guarda o endereço que causou o último page fault; o CR3 aponta para as tabelas de página.
Registradores de segmento (CS, DS, SS, ES, FS, GS)
Relíquias da arquitetura de 16/32 bits. Em 64 bits servem quase só para indicar o nível de privilégio (CS) e alguns usos especiais (FS/GS). Ver capítulo 03.
Instrução privilegiada
Instrução que só o kernel (anel 0) pode executar: cli, sti, hlt, lgdt, lidt, in, out… Se um programa comum tentar, a CPU gera uma exceção.

A pilha (stack)

Região de memória onde ficam variáveis locais, parâmetros e endereços de retorno das funções. É a mesma pilha que aparece no stack trace de um erro em Java/Python.

Em x86 a pilha cresce para baixo: cada push diminui o RSP e grava no novo endereço; cada pop lê e aumenta o RSP. Por isso, ao reservar uma pilha de 20 KiB entre os endereços A e A + 20480, informamos à CPU o topo A + 20480, e ela vai descendo a partir dali.

Se uma recursão infinita consumir a pilha inteira, acontece o stack overflow, que num kernel pode virar um double fault (capítulo 04).


Memória

Endereço físico
Posição real de um byte nos chips de RAM (ou num dispositivo).
Endereço virtual
O endereço que o programa usa. A CPU traduz endereço virtual para físico usando as tabelas de página, configuradas pelo kernel. Cada programa pode ter sua própria tradução, por isso dois programas podem usar o mesmo endereço virtual sem se enxergarem. (Capítulo 7 do livro.)
Página / quadro (page / frame)
A memória é gerenciada em blocos de 4 KiB. “Página” é o bloco virtual; “quadro” é o bloco físico correspondente.
Tabelas de página (PML4, PDPT, PD, PT)
A árvore de 4 níveis que a CPU percorre para traduzir um endereço virtual. Cada tabela ocupa um quadro de 4 KiB e tem 512 entradas (doc 06).
CR3
Registrador que guarda o endereço físico da tabela do nível mais alto (PML4). Trocar o CR3 troca o espaço de endereços inteiro.
Página grande (huge page)
Página de 2 MiB ou 1 GiB, que interrompe a caminhada pelas tabelas mais cedo. Economiza tabelas e entradas de TLB.
TLB (Translation Lookaside Buffer)
Cache da CPU com traduções recentes de endereços. Depois de alterar uma entrada de tabela, o kernel precisa invalidá-la (invlpg), senão a CPU pode continuar usando a tradução antiga.
Mapeamento com deslocamento (offset mapping, direct map)
Toda a RAM física mapeada a partir de um endereço virtual fixo: o físico F fica no virtual deslocamento + F. No SiliconOS, a partir de 0xFFFF_8000_0000_0000.
Kernel na metade alta (higher-half kernel)
Kernel que roda nos endereços virtuais altos, deixando a metade baixa para os programas comuns.
NX (no-execute)
Bit da entrada de página que proíbe executar código naquela página.
WP (write protect, bit 16 de CR0)
Quando ligado, até o kernel recebe page fault ao escrever numa página somente leitura.
Page fault
Exceção gerada quando a CPU tenta traduzir um endereço virtual e não encontra a página (ou ela não tem permissão para o acesso).
Mapa de memória
Lista, fornecida pelo firmware, de quais regiões da RAM estão livres, quais são do firmware, quais são de dispositivos etc.
Mapeamento identidade (identity mapping)
Configuração da paginação em que o endereço virtual X aponta para o endereço físico X. O UEFI entrega a máquina assim, e por isso, hoje, um endereço físico pode ser usado diretamente como ponteiro.
Alocador de quadros
Parte do kernel que entrega quadros físicos livres de 4 KiB a quem pede e os recebe de volta (doc 05).
Bitmap
Estrutura em que cada bit representa um item. No alocador de quadros, um bit por quadro: 1 = ocupado, 0 = livre.
Janela PCI (PCI hole)
Faixa de endereços físicos logo abaixo de 4 GiB reservada para dispositivos (placa de vídeo, controladores). A RAM que ficaria ali é remapeada para cima de 4 GiB.
Memória baixa
O primeiro megabyte de endereços, herança do PC original (até 640 KiB de RAM, depois vídeo e ROMs). Tem usos especiais, como ligar as outras CPUs.
MMIO (memory-mapped I/O)
Dispositivo que aparece como se fosse memória: escrever num certo endereço envia dados ao dispositivo. O framebuffer de vídeo é um exemplo.
Porta de E/S (I/O port)
Forma mais antiga de falar com dispositivos: um espaço separado de 65 536 “endereços” acessados pelas instruções in e out. A serial e o teclado PS/2 usam portas.

Software de baixo nível

Firmware
Programa gravado num chip da placa-mãe, que roda ao ligar o computador. Inicializa o hardware e carrega o sistema operacional.
BIOS
Firmware antigo (desde 1981). Entrega a CPU em modo de 16 bits e carrega 512 bytes do disco.
UEFI
Firmware moderno. Entrega a CPU em 64 bits, lê arquivos de partições FAT e oferece serviços (vídeo, teclado, memória) até o SO dispensá-los.
OVMF
Implementação de UEFI de código aberto que o QEMU usa.
ESP (EFI System Partition)
A partição FAT onde o UEFI procura EFI/BOOT/BOOTX64.EFI. No SiliconOS é a pasta esp/.
Bootloader
Programa que o firmware executa e que carrega o kernel. No SiliconOS, o próprio kernel faz esse papel.
Kernel
O núcleo do sistema operacional: o único código que roda com privilégio total.
Bare-metal
“Direto no metal”: código rodando sem nenhum sistema operacional por baixo.
QEMU
Emulador de PC. Roda o SiliconOS numa janela e reinicia em segundos.

Privilégio e proteção

Anéis (rings)
Níveis de privilégio da CPU x86. Anel 0 = kernel (pode tudo); anel 3 = programas comuns (restritos). Os anéis 1 e 2 quase não são usados.
Modo usuário / modo kernel
O mesmo que anel 3 / anel 0.
Chamada de sistema (syscall)
Quando um programa pede algo ao kernel (abrir arquivo, alocar memória), ele executa uma instrução especial que passa a CPU para o anel 0 de forma controlada.

Interrupções e exceções

Interrupção
Sinal que faz a CPU parar o que está fazendo, executar uma rotina (tratador ou handler) e depois voltar. Pode vir do hardware (tecla pressionada, timer) ou do software (instrução int).
Exceção
Interrupção gerada pela própria CPU porque a instrução atual tem um problema (divisão por zero, acesso inválido…).
Vetor
Número de 0 a 255 que identifica cada interrupção/exceção.
IDT
Tabela que diz qual tratador atende cada vetor (capítulo 04).
Double fault / triple fault
Double fault = falha durante o tratamento de outra falha. Triple fault = falha durante o tratamento do double fault; a CPU desiste e reinicia o computador.
cli / sti
Desligam / ligam as interrupções de hardware (o bit IF).
hlt
Faz a CPU dormir até a próxima interrupção.

Tabelas da CPU

GDT (Global Descriptor Table)
Tabela de segmentos. Em 64 bits define principalmente os níveis de privilégio (capítulo 03).
Descritor
Uma entrada de 8 (ou 16) bytes numa dessas tabelas.
Seletor
Número que aponta para um descritor da GDT (índice × 8 + bits de privilégio). É o que fica guardado em CS, DS, SS.
TSS (Task State Segment)
Estrutura que, em 64 bits, guarda principalmente endereços de pilhas a usar em situações especiais (capítulo 03).
IST (Interrupt Stack Table)
As 7 pilhas de emergência da TSS.