2 pontos por GN⁺ 2024-03-27 | 1 comentários | Compartilhar no WhatsApp
  • O firmware do switch de rede da Oxide não ligava após um teste de mudanças no sequenciamento de energia, e a causa foi um bug em que a verificação de empréstimo de memória por IPC do kernel Hubris entrava em conflito com o novo modo de layout de memória
  • Hubris é um sistema operacional embarcado que isola tarefas com MPU e, ao emprestar memória a outra tarefa via IPC, o kernel verifica se aquela memória está de fato em uma região acessível
  • O empacotamento de tarefas introduzido recentemente recuperou 30% da RAM em algumas imagens de firmware, mas a verificação antiga falhava por assumir que a memória emprestada estava dentro de uma única região de MPU
  • A tarefa sequencer morreu com uma synthetic memory fault ao tentar emprestar ao driver I2C uma memória que incluía o endereço 0x801bffd, e o humility tasks mostrava 115 reinicializações e o estado mem fault... in syscall
  • A correção mudou o algoritmo de verificação para permitir empréstimos que atravessem várias regiões de MPU adjacentes, e levou cerca de 3 horas desde a descoberta da falha até a correção do bug no kernel

Um switch de rede que não liga

  • Arjen Roodselaar, da Oxide, estava testando mudanças de sequenciamento de energia e configuração de clock no firmware de um switch de rede quando, após uma alteração aparentemente trivial, encontrou um problema em que o switch não ligava
  • Parte do firmware respondia a consultas, mas a parte crítica responsável pelo sequenciador de energia parecia ter parado
  • Como erros no sequenciamento de energia podem realmente danificar o hardware, primeiro era preciso confirmar se o switch estava morto ou apenas sem responder

Hubris e memória limitada

  • Hubris é um sistema operacional para sistemas profundamente embarcados, como controladores internos de teclado, e foi criado para executar o trabalho necessário para iniciar os processadores maiores do Oxide Rack
  • O firmware baseado em Hubris é composto por vários programas compilados separadamente, chamados tarefas (tasks)
    • Cada tarefa tem seu próprio código necessário, como partes da biblioteca padrão
    • As tarefas são isoladas pela MPU de hardware para que não derrubem umas às outras nem corrompam memória
  • Na família ARM Cortex-M mais usada, ARMv7-M, as regiões de memória protegida precisam ter tamanho em potência de dois e ser alinhadas a esse tamanho
    • Por exemplo, se uma região de 1024 bytes precisar de mais 1 byte, ela não vira 1025 bytes, e sim uma região de 2048 bytes

As novas fronteiras criadas pelo empacotamento de tarefas

  • No Hubris inicial, era usado um método simples com uma região para a RAM da tarefa e outra para o flash, mas isso criava espaços vazios inutilizáveis entre tarefas e desperdiçava memória
  • Matt Keeter melhorou o sistema de build para, quando possível, posicionar tarefas combinando várias regiões power-of-two
    • O hardware permite no máximo 8 regiões por tarefa
    • Em algumas imagens de firmware, isso recuperou 30% da RAM
    • Os menores dispositivos, que antes eram apertados a ponto de precisar de otimização constante, passaram a ter folga
  • Com essa mudança, passaram a poder surgir fronteiras de regiões de MPU difíceis de prever no meio do flash e da RAM de uma tarefa

A pista deixada por humility tasks

  • Arjen investigou o switch com falha usando o Humility, o depurador do Hubris, e viu que o processador de serviço responsável pelo sequenciamento de energia estava vivo e executando, então a chance de problema de hardware parecia baixa
  • Na saída de humility tasks, a tarefa sequencer aparecia com o seguinte estado
mem fault (precise: 0x801bffd) in syscall (was: wait: reply from i2c_driver/gen0)
  • A mesma tarefa também aparecia com 115 reinicializações, e no Hubris quase sempre tarefas são reiniciadas em resposta a um crash
  • A string de estado significava o seguinte
    • mem fault: violação das regras de tratamento de memória
    • precise: 0x801bffd: era possível saber o endereço exato do problema
    • in syscall: a tarefa não estava executando código normal, e sim no meio de uma system call
    • was: wait: reply from i2c_driver/gen0: ela estava esperando a resposta de uma mensagem enviada ao driver I2C
  • gen0 significava que i2c_driver ainda nunca havia sofrido crash, enquanto sequencer estava na geração 115

IPC do Hubris e empréstimo de memória

  • As tarefas do Hubris se comunicam por mensagens IPC, que funcionam como chamadas de função
    • A tarefa que envia a mensagem para
    • A tarefa receptora recebe o controle da CPU
    • Quando o resultado volta, a tarefa remetente é acordada novamente
  • O IPC foi projetado para combinar bem com o modelo de ownership do Rust, permitindo que uma tarefa empreste parte de sua memória a outra tarefa junto com a mensagem IPC
  • Tarefas que interagem com dispositivos I2C emprestam uma faixa de sua memória ao driver do barramento I2C, e o driver lê ou escreve diretamente nessa faixa
    • Isso reduz a necessidade de o driver de barramento manter um pool de buffers separado
    • Também reduz a quantidade de cópias de dados
  • Se mal implementado, isso poderia abrir uma brecha de segurança, então o kernel Hubris proíbe que uma tarefa empreste memória que ela não possui ou à qual não pode realmente acessar
    • O servidor recebe um código de erro
    • O cliente recebe um fault e sempre é encerrado
    • Isso é tratado como uma violação de acesso que indica bug, corrupção ou possibilidade de exploit

Synthetic fault e a causa real

  • O Hubris distingue fault em real fault e synthetic fault
    • Real fault é uma violação de regra do hardware, como dereferenciar um null pointer ou escrever em uma região de código
    • Synthetic fault é uma violação de regra de software adicionada pelo Hubris, como IPC ou empréstimo de memória
  • O fault de sequencer era um synthetic fault ocorrido no processo de emprestar memória ao driver I2C via IPC
  • O endereço problemático 0x801bffd era um endereço de flash válido, mas parecia estranho por estar 3 bytes abaixo de uma fronteira de potência de dois
  • A saída de humility mem mostrava que duas regiões de flash pertencentes à tarefa sequencer se encontravam em 0x801c000
LOW         HIGH           SIZE ATTR   ID TASK
0x08018000 - 0x0801bfff   16kiB r-x--- 17 sequencer
0x0801c000 - 0x0801dfff    8kiB r-x--- 17 sequencer
  • Como as duas regiões pertenciam à mesma tarefa, em execução normal o MPU de hardware permitia o acesso sem problemas, mas a verificação de empréstimo de memória por IPC no kernel partia de outra suposição

Onde uma simplificação antiga virou bug

  • A verificação antiga do kernel apenas confirmava se todo o slice de memória a ser emprestado cabia completamente dentro de uma única região da tarefa
self.region_table().iter().any(|region| {
    region.covers(slice)
        && region.attributes.contains(desired)
        && !region.attributes.intersects(forbidden)
})
  • Esse código fazia sentido com o desenho da época em que foi escrito: uma região de RAM e uma região de flash por tarefa
  • Com a introdução do empacotamento de tarefas, a memória de uma mesma tarefa podia passar a ficar dividida entre várias regiões de MPU adjacentes, e essa suposição deixou de valer
  • O acesso normal à memória não foi afetado, porque o MPU de hardware fazia essa checagem diretamente; o problema só aparecia ao tentar emprestar essa memória via IPC

A falha causada pela combinação de duas funcionalidades

  • O empacotamento de tarefas funciona de forma oportunista
    • Existe o limite de até 8 regiões por tarefa
    • Tarefas de drivers de hardware já usam algumas regiões por causa de registradores mapeados em memória
    • Um layout mais inteligente só é tentado quando sobram slots de região
  • Como resultado, as fronteiras entre regiões surgem em posições difíceis de prever para quem escreve a tarefa
  • Uma pequena mudança de tamanho na tarefa A pode mudar a posição da fronteira de região de MPU de uma tarefa B sem relação direta
  • Bastava adicionar código de depuração para mudar a decisão de layout e a posição das fronteiras, fazendo o crash desaparecer
  • Matt desativou imediatamente o empacotamento de tarefas no sistema de build para que Arjen pudesse gerar uma imagem de firmware funcional, enquanto a análise e a correção do bug no kernel seguiam em paralelo

Como a correção do kernel foi feita

  • O ponto central da correção foi mudar o algoritmo de verificação de acesso à memória para permitir que a memória emprestada atravesse exatamente várias regiões de MPU adjacentes
  • O novo algoritmo foi projetado para percorrer a tabela de regiões apenas uma vez
    • O Hubris evita expor operações com complexidade de tempo controlável pela tarefa
    • O desempenho não podia depender do tamanho da memória emprestada, e sim apenas da tabela de regiões, que tem tamanho fixo
    • O tamanho da tabela de regiões é fixado em 8 entradas
  • Para isso, o sistema de build passou a ordenar as regiões da tarefa em ordem crescente de endereço
regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);
  • O commit da correção faz o kernel usar essa propriedade de ordenação para uma checagem de acesso mais barata
  • O código que ficou mais complexo foi separado do núcleo do kernel Hubris e movido para um crate mais portável, e testes unitários foram adicionados para corner cases importantes
  • Com o novo código, foi possível reativar o empacotamento de tarefas sem deixar crashes imprevisíveis para quem desenvolve tarefas

Por que a falha não se espalhou mais

  • Todo o processo começou com um switch de rede que não ligava e terminou com a correção de um bug no kernel em cerca de 3 horas
  • Graças ao fault isolation, entre as 23 tarefas isoladas que compõem o firmware do switch, apenas sequencer morria repetidamente, enquanto muitos outros componentes continuavam funcionando
    • Sistema de atualização de firmware
    • Pilha de rede IP para interfaces de gerenciamento e controle
    • Vários serviços de rede, da implementação do protocolo echo até a interface do plano de controle do rack
    • I2C, SMBus e PMBus para monitoramento de sensores, ventoinhas e outros estados do sistema
    • Drivers dos 32 transceptores QSFP 100G da parte frontal
  • O IPC do Hubris foi projetado assumindo que outras tarefas podem falhar, então trabalhos marcados como idempotentes podem ser repetidos de forma transparente
  • O bug antigo na verificação de acesso à memória bloqueava acessos de programas corretos, e não permitia acessos incorretos ou maliciosos, então não houve impacto de segurança
  • No momento em que sequencer e o driver I2C praticamente compartilhavam memória, sequencer morria, mas o driver I2C continuava operando sem risco de corrupção

Infraestrutura de depuração e operação da equipe

  • O Humility é um depurador desenvolvido junto com o kernel Hubris, e Arjen conseguiu identificar em poucos minutos a localização do código que sofreu crash até o nível de número de linha, além de compartilhar snapshots independentes do processador de serviço
  • O Hubris grava core dumps compactados de tarefas que sofreram crash na RAM e permite recuperá-los pela rede
    • É possível obter crash dumps mesmo sem armazenamento persistente gravável
    • O recurso de crash dump fica em uma tarefa separada, não no kernel
  • Esses processadores não tratam dados de workload de clientes, apenas tráfego de gerenciamento do sistema, e os relatórios de crash não são enviados automaticamente
  • A parte independente de arquitetura do kernel Hubris tem 1.789 linhas de código e 1.192 linhas de comentários, enquanto o suporte a ARMv6-M, ARMv7-M e ARMv8-M adiciona mais 1.075 linhas de código e 534 linhas de comentários
  • Como os conceitos do kernel e o IPC são simples, quando um fault apontava para IPC não havia muitos lugares para investigar

1 comentários

 
GN⁺ 2024-03-27
Comentários do Hacker News
  • Hubris é realmente muito bom. Li o código do kernel por uns 30 minutos e ele está bem longe daquele C cheio de macros ifdef, preferência por nomes de variáveis com duas letras e poucos comentários que eu via antigamente; está escrito de forma muito clara
    Também é uma boa leitura antes de dormir, recomendo dar uma olhada: https://github.com/oxidecomputer/hubris/blob/b44e677fb39cde8...

    • Acho bem irritante como boa parte da cultura de C parece se resumir a “preguiça de aprender a digitar numa velocidade razoável”
      Espaço em disco para código-fonte deixou de ser um grande problema há 40 anos, mas as pessoas ainda economizam em nomes de variáveis
    • A IA pode acabar com esse costume. Se você jogar código C velho e tosco numa IA, de repente todas as variáveis podem ficar limpas e nomeadas do jeito que o usuário gosta
      Isso porque a IA aprendeu com precisão os gostos e maneirismos de um programador específico
  • Bom texto, mas a posição do comentário abaixo deixa a desejar
    O comentário acima de regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);, dizendo que “deve ser ordenado por endereço crescente e o kernel usa essa propriedade para baratear as verificações de acesso”, parece mais uma invariante de campo que todo autor deve respeitar e todo leitor pode aproveitar do que um detalhe desta função
    Então parece mais adequado colocá-lo na docstring de TaskDesc::regions: https://github.com/oxidecomputer/hubris/commit/b44e677fb39cd...

    • Ainda assim, é bom ter um comentário ao lado do código de ordenação. Sem isso, a própria ordenação pode parecer bem inesperada
      Talvez a melhor abordagem seja criar um método construtor em TaskDesc para ordenar as regiões e assim impor a invariante. Dá para ver o código ficando mais complexo com o tempo, então talvez já valha a pena gastar algum tempo encapsulando essa complexidade dentro de métodos
  • Está entre os melhores anúncios de vaga que já vi. Gosto de como a transição para falar de cultura acontece naturalmente e termina com um “aliás, estamos contratando”
    É uma análise pós-incidente realmente excelente, e eu, como desenvolvedor de camada de aplicação, consegui acompanhar. Também ajudou o fato de eu estar lendo Rust in Action agora, então já estava mais preparado para esse tipo de conteúdo
    Também é sempre bom ver alguém que comenta bastante o código. Programação literária funciona

    • Infelizmente isso só vale para os EUA
  • Parece que a parte anterior pode ser vista aqui

    1. https://hachyderm.io/@mjk/112157472314396711
    2. https://www.mattkeeter.com/blog/2024-03-25-packing/
  • A parte sobre a “integração densa e não hierárquica da equipe” chamou minha atenção. Não é exatamente um recurso do Hubris, mas foi marcante a explicação de que é difícil separar o Hubris da equipe que o criou e que, na prática, não existem silos internos no time de engenharia da Oxide
    Gostaria de ouvir mais sobre por que eles criaram uma cultura que incentiva abertura, curiosidade e comunicação e desencoraja defensividade, formação de feudos e gatekeeping, e também sobre como isso é implementado na prática. Também tenho curiosidade sobre se não há desvantagens ao cultivar esse tipo de cultura dentro de uma organização
    Existem lugares que escolhem sistemas hierárquicos mais rígidos, e talvez o organograma precise ser definido estrategicamente, então não tenho muita clareza sobre os trade-offs

    • Os valores declarados em si são difíceis de criticar, mas, em geral, a desvantagem de organizações sem uma estrutura fortemente definida é que algum tipo de estrutura de poder acaba surgindo de qualquer forma
      Se essa estrutura não é explícita, ela fica menos pública, não foi escolhida de forma deliberada e é ainda mais difícil de entender, especialmente para quem não tem tanta habilidade em interações sociais. Por causa desse caráter mais sombrio, ela também pode permitir comportamentos mais patológicos e, mesmo quando não chega a tanto, pode dificultar muito mais a coordenação
      Já vivi isso em várias empresas. Uma grande consultoria tinha uma estrutura formal de poder, mas na prática ela não era muito seguida, e a forma de entrar em projetos se parecia mais com fazer amizade com as pessoas de vendas e gestão do que usar os canais oficiais. Funcionava bem se você conseguia construir a rede social necessária, mas, se não conseguia, não funcionava tão bem
      Um exemplo parecido é The Tyranny of Structurelessness. É uma palestra de uma feminista que viu a mesma coisa acontecer em organizações que rejeitavam hierarquia por considerá-la patriarcal, e há discussões semelhantes sobre a Valve, onde a estrutura interna também não é clara. Projetos open source também podem sofrer do mesmo problema, e acho que parte dos conflitos no ecossistema Rust veio de questões parecidas
      Isso não quer dizer que uma estrutura explícita de poder precise necessariamente ser hierárquica. Organizações empresariais tradicionais são hierárquicas, mas a estrutura da Oxide pode ser explícita e ainda assim não hierárquica. Esse tipo de arranjo costuma funcionar melhor em menor escala, e a consultoria que mencionei antes era o maior exemplo que conheço de uma empresa operando quase nesse estilo livre, mas ainda assim havia alguma base de sustentação
      Isso não é uma dicotomia, e sim um espectro. Mesmo a estrutura de poder mais rígida no papel tem abaixo dela estruturas implícitas mais complexas, porque essa é a natureza dos grupos humanos
      Não estou dizendo que estruturas explícitas sejam sempre melhores que estruturas implícitas. Só estou apontando as desvantagens observadas em organizações menos explícitas; estruturas de poder mais explícitas também têm seus próprios problemas. Há também discussões relacionadas em “seeing like a state” e na questão da legibilidade
  • Excelente texto mostrando em profundidade o processo de depurar um problema complexo. O fato de o restante do sistema ter permanecido estável mostra bem a qualidade de engenharia da equipe da Oxide
    Pessoalmente, também achei bastante inspirador e estou pensando em aplicar técnicas semelhantes no trabalho do dia a dia

  • Se tratassem esse hardware como um TLB com preenchimento por software, poderiam suportar mais de 8 regiões

    • Imagino que eles quisessem (a) desempenho de tempo real leve e (b) evitar introduzir um elemento central que pudesse atrapalhar a capacidade de depuração ou a confiabilidade
      Eu nunca faria isso a menos que fosse a última opção. Paginação virtual é uma bagunça, e eu não gostaria de deixar dúvidas
    • Entendo que TLB é translation lookaside buffer, mas aqui o que significa “soft fill”?
  • O que a Oxide faz é realmente impressionante

    • Depois da Tailscale, agora a Oxide virou mais um daqueles projetos adorados que 99% das pessoas nunca vão precisar
  • Gosto de qualquer coisa que o pessoal da Oxide faça, e isso é mais uma dessas coisas

  • Eles deram ao sistema operacional o nome Hubris? Ah, isso é... nem sei o que dizer

    • Você vai gostar de saber que o depurador se chama “humility”: https://github.com/oxidecomputer/humility
    • Mais precisamente, foi o Brian Cantrill que deu ao sistema operacional o nome hubris
      Quem em sã consciência criaria um novo sistema operacional hoje em dia? A resposta é alguém tentando resolver um problema que todos os sistemas operacionais ignoram: o problema dos controladores na placa-mãe e nas placas de expansão, que o sistema operacional não controla nem pode controlar
    • Combina bastante com a marca mesmo