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.rsLivro: 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
NullPointerExceptione 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 listas | Não há quem forneça memória. Teremos que escrever o alocador (capítulos futuros). |
| Exceções com stack trace | Um erro trava ou reinicia a máquina sem mensagem nenhuma, até escrevermos os tratadores (docs 02 e 04). |
| Depurador da IDE | Só depois de muito trabalho. No começo, depuramos imprimindo texto. |
| Threads | Nã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:
- Conseguir escrever texto (doc 01). Sem isso, não dá para depurar nada.
- Mostrar erros em vez de travar (docs 02 e 04).
- Colocar a CPU num estado conhecido, que é nosso, não do firmware (doc 03).
- Gerenciar a memória (próximos capítulos).
- Reagir ao hardware: timer e teclado.
- 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:
- testa e inicializa o hardware (memória RAM, placa de vídeo, USB…);
- procura nos discos um programa para carregar;
- 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 controle | 16 bits (“modo real”, como um 8086) | 64 bits, já configurado |
| O que carrega | os primeiros 512 bytes do disco | um arquivo .efi de uma partição FAT |
| Serviços oferecidos | rotinas antigas e limitadas | ví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:
- O
cargo build --target x86_64-unknown-uefigeratarget/x86_64-unknown-uefi/debug/siliconos.efi. O alvox86_64-unknown-uefifaz o compilador gerar um executável no formato PE (o mesmo formato dos.exedo Windows), que é o formato que o UEFI entende. - O
run.shcopia esse arquivo paraesp/EFI/BOOT/BOOTX64.efi. - O QEMU é iniciado com
-drive format=raw,file=fat:rw:esp, que significa “finja que a pastaesp/é 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 crateuefi. Ele marca esta função como o ponto de entrada que o firmware vai chamar.-> Statusindica 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 crateuefi(por exemplo, para oprintln!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_servicesdispensa o firmware e devolve o mapa de memória. O argumentoMemoryType::LOADER_DATAdiz em que tipo de memória guardar a cópia do mapa.- Está dentro de
unsafeporque, 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_mainnunca retorna, por issomaintambé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 fornecia | No 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
outpara 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):
- blocos
unsafepequenos, cada um com um comentário dizendo por que é seguro; - por cima deles, funções seguras. Exemplo: a função
outb(mandar um byte para uma porta) éunsafe, masSerial::write_bytenão é. Quem usa a serial não precisa saber nada de portas.
6. Ferramentas
| Ferramenta | Para que serve |
|---|---|
| Cargo | compila o projeto e baixa dependências (como Maven/Gradle/npm) |
alvo x86_64-unknown-uefi | faz o compilador gerar um .efi em vez de um executável Linux |
| QEMU | emula um PC inteiro. Liga em 1 segundo, mostra a saída serial no terminal, não estraga nada se o kernel travar |
| OVMF | firmware 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:
| Crate | Para que usamos |
|---|---|
uefi | conversar com o firmware na fase 1 (vídeo, mapa de memória, exit_boot_services) |
spin | Mutex e Once que funcionam sem sistema operacional |
font8x8 | o 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 pelacore, que não depende de SO.unsafemarca os trechos em que o programador garante a correção.
Para fixar (clique para ver as respostas)
-
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. -
Por que não dá para usar
println!comum no kernel? Porque ele faz parte dastd, que pede ao sistema operacional para escrever no terminal. Não há SO abaixo do kernel. -
O que significa o
-> !emkernel_main? Que a função nunca retorna. Ela termina num loop infinito, porque não há para onde voltar.