Por que Rust para desenvolvimento de kernels
Sistemas operacionais costumam ser associados a C.
Essa associação é compreensível. C é pequeno, previsível, próximo da máquina e historicamente dominante no trabalho de kernel. Ele dá ao programador acesso direto a memória, registradores, layout e convenções de chamada.
Essas são exatamente as coisas de que um kernel precisa.
Também são exatamente as coisas que tornam kernels difíceis de proteger.
EriX é escrito principalmente em Rust porque o projeto é construído em torno de uma ideia central:
A autoridade deve ser explícita.
Isso se aplica a capacidades, estruturas de handoff de boot, mensagens IPC, objetos de memória e acesso a dispositivos. Também se aplica à linguagem de implementação. Rust não torna o desenvolvimento de kernels automaticamente seguro, mas dá ao código uma forma de tornar visíveis propriedade, mutação, lifetimes e insegurança.
Essa visibilidade importa.
O kernel é um lugar desconfortável para bugs de memória⌗
Bugs de memória são ruins em qualquer lugar.
Em um kernel, são piores.
Um use-after-free em uma aplicação comum pode corromper essa aplicação. Um use-after-free no kernel pode corromper o estado do scheduler, tabelas de capacidades, espaços de endereçamento ou mappings de dispositivos.
Um erro de limites em um parser de espaço de usuário pode derrubar um processo. Um erro de limites em um parser de boot pode afetar qual código a máquina executa em seguida.
Um ponteiro ruim em uma aplicação é um bug local. Um ponteiro ruim em um kernel pode ser um bug de autoridade.
É por isso que segurança de memória não é uma preocupação cosmética para EriX. O kernel faz parte da base de computação confiável. O bootloader faz parte da base de computação confiável. Várias bibliotecas de parser e ABI são TCB ou adjacentes à TCB.
Se esses componentes lidam mal com memória, o modelo de isolamento pode falhar antes que qualquer verificação de capacidade tenha chance de ajudar.
O que Rust dá a um kernel⌗
Rust dá a um kernel vários padrões úteis.
Dá slices com verificação de limites em vez de pares ponteiro-comprimento sem verificação no código comum.
Dá regras de propriedade que dificultam aliasing acidental.
Dá lifetimes que podem expressar visões emprestadas para imagens de boot, binários ELF, blobs de handoff e buffers IPC sem fingir que essas visões possuem o armazenamento subjacente.
Dá Result e enums como ferramentas comuns para tornar caminhos de erro
explícitos.
Dá um sistema de tipos forte o bastante para distinguir muitos estados que código C frequentemente representaria como comentários, flags ou convenções.
Nada disso remove a necessidade de projeto de kernel.
Rust não decidirá o que uma capacidade significa. Não decidirá qual serviço deve receber um endpoint de dispositivo. Não provará que um protocolo IPC está correto. Não impedirá toda race em um scheduler.
Mas muda o padrão.
Em C, o padrão é que quase toda função pode fazer aritmética de ponteiros sem verificação, criar alias de memória mutável, reinterpretar bytes como estruturas e sofrer overflow silenciosamente se o programador não for disciplinado em todos os lugares.
Em Rust, código comum não pode fazer essas coisas sem passar por unsafe ou por
uma operação explicitamente verificada.
Essa é uma diferença prática de segurança.
no_std é o ambiente normal⌗
Rust de kernel não é Rust de aplicação comum.
O crate do kernel EriX é #![no_std]. Ele não roda em cima de um sistema
operacional. Ele é o substrato do sistema operacional.
Muitas bibliotecas EriX também são no_std, incluindo:
lib-bootimglib-elflib-handofflib-ipclib-capabilib-bootstraplib-consolelib-devicelib-driverlib-time
Vários desses crates também usam #![deny(unsafe_code)].
Essa combinação é importante. Ela significa que as mesmas bibliotecas podem ser usadas em contextos de boot, kernel e serviços freestanding sem depender de serviços do OS host. Também significa que código de parser e ABI muitas vezes pode ser escrito sem ponteiros raw.
Esse é um dos encaixes mais fortes de Rust para EriX.
O projeto tem muitos crates pequenos cujo trabalho não é tocar diretamente em hardware. O trabalho deles é parsear, validar, codificar, decodificar e rejeitar. Esses são exatamente os trabalhos em que Rust seguro é valioso.
Parsers seguros são fronteiras de segurança⌗
EriX trata parsers como fronteiras de segurança.
lib-bootimg parseia e verifica boot.img. Seu caminho de leitura faz parte da
cadeia de confiança de boot. Ele valida estrutura, alinhamento, limites, hashes
e assinaturas antes que o bootloader confie na imagem.
lib-elf parseia binários ELF64 antes que o bootloader use segmentos de carga.
lib-handoff valida estruturas de handoff versionadas entre estágios de boot e
entre o kernel e rootd.
lib-capabi define tipos de capacidade, direitos, constantes de slot,
descritores de transferência e helpers de validação de startup.
Esses crates são deliberadamente tediosos.
Eles usam slices de bytes. Usam aritmética verificada. Retornam erros estruturados. Rejeitam versões sem suporte, tamanhos ruins, offsets ruins e tabelas malformadas.
Não fazem I/O. Não possuem autoridade runtime. Não adivinham.
Esse design seria possível em C, mas exigiria disciplina sustentada:
- cada ponteiro deve ser verificado
- cada offset deve ser validado contra intervalos
- cada operação inteira deve ser auditada para overflow
- cada visão retornada deve ser ligada manualmente ao lifetime do buffer de entrada
Rust torna essas regras mais baratas de manter.
Por exemplo, uma imagem de boot parseada pode emprestar do slice de bytes original. Um segmento ELF validado pode expor bytes emprestados em vez de fabricar um novo ponteiro sem verificação. Um parser de handoff pode retornar um iterador que verifica o intervalo de cada entrada antes de parseá-la.
A linguagem não prova que o parser é semanticamente perfeito.
Ela remove uma grande classe de erros acidentais de memória do caminho normal do parser.
unsafe não desaparece⌗
Desenvolvimento de kernel não pode ser inteiramente Rust seguro.
A interface de hardware não é type-safe. Registradores de CPU, tabelas de páginas, descritores de interrupção, port I/O, MMIO, buffers DMA e ponteiros de entrada de boot não chegam como valores Rust amigáveis.
Em algum momento o kernel precisa dizer:
Eu sei o que este endereço raw significa.
Essa afirmação é unsafe.
EriX tem unsafe nos lugares em que a máquina o força:
- código de entrada de boot converte um ponteiro e comprimento raw de handoff em um slice de bytes
- glue syscall x86_64 usa assembly inline e uma ABI fixa de registradores
- context switch usa assembly específico da arquitetura
- GDT, IDT, TSS e inicialização syscall tocam estado de descritores da CPU
- manipulação de tabelas de páginas usa leituras e escritas voláteis
- caminhos seriais e de dispositivos legacy usam port I/O
- drivers voltados a MMIO e DMA usam accessors voláteis
- alguns runtimes single-thread usam
UnsafeCellatrás de buffers estáticos documentados
Essa lista não é uma falha de Rust.
É a fronteira honesta entre a linguagem e a máquina.
O objetivo não é não ter unsafe em lugar nenhum. O objetivo é manter unsafe
pequeno, local, documentado e cercado por interfaces seguras.
Fronteiras unsafe devem ser estreitas⌗
A pergunta importante não é:
O codebase contém
unsafe?
A pergunta importante é:
Onde está o
unsafe, e qual invariante o torna válido?
Em EriX, a ponte syscall raw em ipc-syscall_x86_64 é um bom exemplo.
O crate expõe funções wrapper seguras como ipc_call, ipc_recv, ipc_reply,
query_local_cap e drop_local_cap. Internamente, ele tem uma única fronteira
de assembly raw que mapeia argumentos para a ABI syscall de registradores x86_64
e retorna valores raw de registradores.
O wrapper não desreferencia ponteiros de usuário por conta própria. Ele passa valores de ponteiro e comprimento ao kernel, onde acontecem as verificações de capacidade, endpoint e comprimento de mensagem.
Esse é o formato correto:
- o assembly raw fica isolado
- a ABI é documentada
- a API pública é tipada
- a validação semântica permanece no kernel
A fronteira de context switch do kernel segue o mesmo padrão. Há assembly
específico de arquitetura para salvar e restaurar registradores, mas o restante
do scheduler pode trabalhar com uma estrutura KernelContext e um trait
ContextSwitcher. Testes no host podem usar um switcher software que modela a
transferência de estado sem realmente fazer a CPU saltar.
Essa separação importa porque mantém a maior parte da lógica do scheduler testável sem executar instruções privilegiadas.
Acesso a hardware continua sendo acesso a hardware⌗
Rust não torna MMIO seguro por si só.
Se um driver escreve o valor errado no registrador errado, Rust não salvará o dispositivo. Se um buffer DMA é descrito incorretamente, o hardware ainda pode escrever na memória. Se uma entrada de tabela de páginas está errada, a CPU vai aplicar o mapping errado de modo muito eficiente.
EriX lida com isso de duas formas.
Primeiro, a autoridade de hardware é explícita. Um driver não deve receber autoridade ampla sobre a máquina. Deve receber apenas o device frame, intervalo I/O, linha de interrupção ou família de endpoints de que precisa.
Segundo, o código unsafe que toca hardware fica perto da fronteira de hardware.
Por exemplo, drv-virtio-block tem helpers voláteis de MMIO e DMA. Isso é
esperado para um driver de bloco. O ponto importante é que esse código roda em
um driver de espaço de usuário com uma superfície estreita de autoridade de
dispositivo, não dentro de um grande kernel monolítico com autoridade total da
máquina.
Rust ajuda dentro desse driver, mas EriX ainda depende da arquitetura:
- capacidades explícitas de dispositivo
- memória de dispositivo tipada como
CAP_TYPE_DEVICE_FRAME - endpoints estreitos de kernel-control
- isolamento de drivers em espaço de usuário
- validação de startup fail-closed
A linguagem e o design do OS se reforçam.
Nenhum substitui o outro.
Rust combina com o pensamento de capacidades⌗
O modelo de propriedade de Rust não é a mesma coisa que um sistema de capacidades.
Um borrow de Rust não é uma capacidade EriX. Um tipo Rust não é autoridade aplicada pelo kernel. O compilador não pode saber se um processo deveria poder mapear um frame ou enviar para um endpoint.
Ainda assim, Rust e capacidades combinam bem.
Ambos incentivam explicitude.
Em EriX, um componente só pode agir quando possui a capacidade correta com os direitos corretos. Em Rust, o código só pode mutar dados quando tem o tipo certo de acesso a esses dados.
Essa semelhança é útil cultural e mecanicamente.
Ela incentiva APIs em que autoridade é passada como valor, não descoberta por estado global. Incentiva funções a declarar do que precisam. Incentiva código de startup a validar o bundle real de capacidades recebido em vez de presumir que um número de slot significa permissão.
lib-capabi é um exemplo desse estilo. Ele não aplica autoridade em runtime,
mas define a linguagem compartilhada da autoridade: IDs de tipos de capacidade,
flags de direitos, constantes de slot, descritores de transferência, helpers de
papel e validação de startup.
Por ser no_std e deny(unsafe_code), esse vocabulário de autoridade pode ser
usado amplamente sem transformar cada consumidor em consumidor de memória raw.
A comparação com C⌗
C dá a um kernel controle máximo com custo mínimo de abstração.
Essa é sua força.
Também é sua fraqueza.
Em C, uma tabela de capacidades pode ser indexada fora dos limites. Um buffer de mensagem pode ser copiado com o comprimento errado. Uma estrutura empacotada em disco pode ser lida por um ponteiro desalinhado. Um ponteiro pode sobreviver ao armazenamento que descreve. Um overflow inteiro pode virar uma alocação menor. Uma função pode mutar estado acidentalmente por meio de um alias que o chamador não esperava.
Tudo isso pode ser evitado em C.
É evitado por revisão, convenção, análise estática, sanitizers, fuzzing e disciplina.
Essas ferramentas importam. EriX ainda precisaria de revisão, testes e fuzzing se fosse escrito inteiramente em Rust.
A diferença é que Rust move muitas dessas verificações para o modelo padrão da linguagem.
Quando EriX parseia uma imagem de boot, percorre uma tabela de transferência,
constrói uma mensagem IPC ou valida um envelope de handoff, a representação
comum é um slice, enum, iterador, operação inteira verificada ou Result
estruturado.
Em C, a representação comum muitas vezes seria um ponteiro, um comprimento, um cast e uma promessa.
Promessas não são inúteis.
Mas kernels acumulam promessas rapidamente.
Rust reduz quantas dessas promessas precisam ser confiadas.
Rust não remove bugs de design⌗
Rust não é uma prova de segurança.
Não impede que a capacidade errada seja concedida.
Não impede que um endpoint tenha direitos amplos demais.
Não decide se rootd, procd ou deviced deve possuir uma decisão de política.
Não faz automaticamente um parser rejeitar todo arquivo semanticamente inválido.
Não remove canais laterais.
Não torna DMA coerente.
Não torna simples a ordenação de interrupções.
Não torna o scheduler justo.
É por isso que EriX ainda enfatiza arquitetura:
- TCB pequena
- capacidades explícitas
- endpoints estreitos
- startup determinístico
- comportamento fail-closed
- dependências clean-room
- serviços em espaço de usuário
- autoridade de driver específica por papel
Rust melhora o substrato de implementação. Não substitui o modelo de segurança.
Panic não é uma estratégia de erro⌗
Código de kernel precisa de um modelo claro de falha.
Em EriX, falhas recuperáveis vindas de input externo devem ser retornadas como erros. Dados de handoff malformados, versões sem suporte, descritores de transferência inválidos, tipos de endpoint errados e autoridade ausente não devem ser reparados silenciosamente.
Devem falhar explicitamente.
Panic é reservado para violações de invariantes internos, não para validação comum de input.
Isso importa porque Rust torna panic fácil, mas um kernel não pode tratar panic como mecanismo normal de controle de fluxo. Um parser na TCB não deve dar panic em input malformado. Um caminho de boot não deve fazer unwind acidentalmente pelo estado de firmware. Um serviço runtime não deve tratar input controlado por atacante como motivo para entrar em um caminho de recuperação indefinido.
O padrão útil em Rust é:
- validar antes de confiar
- retornar
Result - manter variantes de erro explícitas
- reservar panic para estados internos impossíveis
- falhar fechado quando o estado de segurança é ambíguo
Esse padrão aparece nas crates de parser e handoff de EriX.
Benefícios para testes⌗
Rust também ajuda nos testes.
O kernel EriX é uma crate no_std, mas boa parte da sua lógica ainda pode ser
testada no host com suporte #[cfg(test)].
Isso é útil para código que não deveria exigir um boot de VM só para validar um invariante puro:
- verificações de limites de parsers
- validação de handoff
- parsing de descritores de capacidade
- lógica de máscaras de direitos
- tabelas de política de slots
- transições de estado do scheduler
- modelos software de context switch
As partes específicas de arquitetura ainda precisam de testes de integração. Assembly inline, manipulação de tabelas de páginas, entrega de interrupções e transições para modo usuário precisam ser testados no ambiente onde realmente rodam.
Mas Rust facilita manter lógica pura como lógica pura.
Isso é valioso em um microkernel, porque muitos componentes podem ser reduzidos a pequenas máquinas de estado determinísticas em torno de mensagens e capacidades explícitas.
Por que isso importa para EriX⌗
EriX não usa Rust porque Rust está na moda.
Usa Rust porque os objetivos do projeto combinam com os pontos fortes da linguagem:
- reduzir insegurança de memória em código confiável
- tornar propriedade e mutação visíveis
- isolar acesso raw ao hardware atrás de fronteiras estreitas
- manter código de parser seguro, determinístico e auditável
- expressar dados de protocolo e autoridade com estruturas tipadas
- dar suporte a código
no_stdcompartilhado entre boot, kernel e serviços de espaço de usuário - tornar muitos bugs mais difíceis de escrever desde o início
O código unsafe restante continua sério.
Ele deve ser revisado com tanto cuidado quanto C seria revisado. A diferença é que EriX pode concentrar esse escrutínio nos lugares em que a máquina realmente força controle raw: código de entrada, setup de CPU, tabelas de páginas, syscalls, context switches, MMIO, DMA e port I/O.
Esse é o valor prático.
Rust não torna o kernel seguro.
Ele faz as partes unsafe se destacarem.
Olhando adiante⌗
O próximo passo é seguir a execução desde o primeiro código carregado pelo firmware até o kernel.
Esse caminho é onde muitas ideias dos posts anteriores se encontram: a TCB, fronteiras Rust, parsers validados, imagens de boot assinadas, estruturas de handoff e a primeira transferência de autoridade de máquina para objetos explícitos do kernel.
O próximo post percorrerá o processo de boot do EriX do firmware ao kernel: o ponto de partida UEFI, o modelo de handoff do bootloader e por que o kernel é entrado como um executável higher-half.