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.rsLivro: capítulo 3.2 Testar:./run.sh teste-panicRust 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ódigo | Por 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 inteiros | divisã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 é:
- imprimir a mensagem e o local (
arquivo:linha); - desfazer a pilha, chamando os destrutores no caminho (unwinding);
- encerrar o processo. O sistema operacional limpa o resto.
2. O problema no kernel
No kernel, nenhum desses três passos existe:
- Imprimir onde? O
println!é dastd, que não temos. - O unwinding depende de bibliotecas de suporte da
std. - 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: &PanicInfotraz 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 oAtomicBooleando Java.swap(true, ...)gravatruee 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:
- Um
kprintln!pega o lock do console. - No meio do desenho, um bug dispara um panic.
- O tratador chama
CONSOLE.lock()para escrever a mensagem… - …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)) };
}
}
}
hltfaz a CPU dormir até a próxima interrupção. Gasta menos energia que um loop vazio, que deixaria a CPU a 100%.- O
cliantes garante que nenhuma interrupção comum a acorde. - O
loopexiste 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 cadapanic!.info.message()é a mensagem já formatada.- O parâmetro
w: &mut impl Writeaceita qualquer destino de texto. Por isso a mesma função escreve tanto naSerialquanto noConsole(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,
unwrapemNone…). Num programa comum, astdimprime 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 comcli; hlt. - Erros detectados pela CPU (não pelo Rust) precisam de outro mecanismo: a IDT.
Para fixar (clique para ver as respostas)
-
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.
-
O que aconteceria sem o
force_unlock, se o panic ocorresse dentro de umkprintln!? Deadlock: o tratador esperaria para sempre por um lock que nunca seria liberado, e a mensagem de erro nunca apareceria na tela. -
Por que o teste usa
paginas_livrescomo índice em vez de10? 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. -
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).