1 pontos por GN⁺ 4 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • Binários Rust passam por uma fase de inicialização do runtime antes de fn main(), e nessa etapa são feitos trabalhos como tratamento de panic·unwinding e conversão dos argumentos do programa
  • Quando o loader do sistema operacional transfere o controle para o entrypoint, o runtime C e o runtime Rust executam funções de inicialização, e é possível posicionar código pre-main com #[unsafe(link_section = "...")] e com o modelo de construtores
  • Seções do linker reúnem em um só lugar, no momento da criação do binário, os dados enviados por vários crates, e link-section permite tratá-las como slices de Rust
  • Usando ctor e link-section juntos, é possível montar antes do main padrões como registro de subcomandos de CLI e ordenação de pools de string interning, para depois ler tudo sem locks
  • Essa abordagem oferece agregação sem alocação e inversão de controle, mas é preciso escolher seu uso com cuidado por causa da dificuldade de remoção de código morto, restrições de construtores, diferenças entre plataformas e limitações de compatibilidade com Miri

A fase anterior ao main em binários Rust

  • Todo binário Rust tem fn main(), mas o fluxo real de execução chega ao main só depois de passar pelo loader do sistema operacional e pela inicialização do runtime
  • C tem o runtime C, reconhecido como libc, e Rust possui seu próprio runtime por meio da biblioteca padrão, construindo abstrações de nível mais alto sobre o runtime C
  • O objetivo do runtime é integrar o código do desenvolvedor ao sistema operacional da plataforma
  • O runtime C configura serviços de runtime antes do main, como alocação, acesso a arquivos e armazenamento local de thread
  • Nesse ponto, Rust prepara o tratamento de panic e unwinding, e converte argumentos de programa em estilo C para a interface std::env::args
  • A etapa pre-main roda antes do código do usuário, em thread única e com ordem previsível, o que a torna adequada para inicialização determinística

Entry point

  • Um binário começa quando o loader do sistema operacional o carrega na memória, configura o ambiente e transfere o controle
  • No Linux, o entry point fica armazenado no campo e_entry do cabeçalho ELF, e por padrão o linker posiciona ali o endereço do símbolo _start
  • O Windows também tem um hook semelhante, e o executável começa na função _WinMainCRTStartup
  • O bootstrapping inicial do runtime era uma árvore de chamadas estáticas de funções como inicialização de I/O de arquivos e inicialização do alocador
  • Conforme o runtime ficou mais complexo, essa árvore de chamadas de inicialização estática também cresceu, e o binário passou a incluir mais funcionalidades do runtime C que poderiam ou não ser necessárias
  • Quando o linker passou a conseguir remover código não utilizado antes de gerar o binário, surgiu a necessidade de uma forma de substituir essa árvore de chamadas estáticas de inicialização
  • O modelo __attribute__((constructor)) do GCC colocava uma lista de ponteiros para funções de inicialização em uma região contígua do binário, e o runtime C a percorria no início para fazer as chamadas
  • Esses construtores passaram a poder receber prioridades; por exemplo, a inicialização de malloc pode precisar ocorrer antes de I/O de arquivos com buffer
  • O runtime glibc moderno no Linux guarda ponteiros para funções em .init_array, e é possível definir a ordem de execução com sufixos numéricos
  • Valores de prioridade até 100 são reservados ao próprio runtime, então código que usa o runtime C deve usar 101 ou mais
  • Em Rust, é possível posicionar ponteiros para funções de inicialização com atributos como #[used] e #[unsafe(link_section = ".init_array.101")]

linktime: ctor, link-section e outros

  • Os exemplos funcionam em Linux e em vários BSDs, mas não foram projetados como exemplos cross-platform
  • O macOS oferece suporte a símbolos start e stop, mas com nomes diferentes; o Windows não oferece suporte a esses símbolos, mas tem regras de ordenação de seção que são, na prática, equivalentes
  • ctor e link-section são crates do projeto linktime e abstraem diferenças entre plataformas e a complexidade do trabalho com linker
  • inventory e linkme são crates amplamente usados, construídos sobre o mesmo princípio, mas têm limitações para os exemplos apresentados
  • O crate ctor cuida do boilerplate para registrar construtores de forma cross-platform
  • Uma função anotada com algo como #[ctor(unsafe, priority = 101)] será chamada pelo runtime C após a organização feita pelo linker, mesmo que nunca seja chamada diretamente no código

Seções e scripts de linker

  • O compilador pode colocar dados ou código em posições específicas dentro do binário, regiões que na maioria das plataformas são chamadas de seções
  • Rust também pode usar esse mesmo mecanismo de organização por meio do atributo link_section
  • Muitos linkers permitem que o desenvolvedor forneça scripts de linker, arquivos de texto que instruem o linker sobre como os object files devem ser montados
  • Com scripts de linker, um único arquivo C pode virar um executável Linux, ou um bloco bruto de assembly colocado no setor de boot de um disco rígido
  • Scripts de linker podem definir símbolos virtuais que não existem no código-fonte, mas que podem ser usados no código C para acessar ponteiros de dados-base do binário carregado
  • No exemplo de script de linker, _TEXT_START_ e _TEXT_END_ são definidos para apontar para o início e o fim da seção .text
  • O ponto em _TEXT_START_ = .; representa o contador de posição, interpretado como um valor próximo ao endereço de saída atual do binário

Símbolos do linker

  • O linker não define o valor dos símbolos de início e fim como ponteiros; ele define o endereço em que ficaria um static com aquele nome
  • Os símbolos de início e fim não são ponteiros *const Type; eles não têm dados próprios e só carregam significado pelo endereço
  • Uma seção é composta pelos dados no intervalo que inclui o símbolo de início e exclui o símbolo de fim
  • Muitos linkers passaram a definir automaticamente limites de todas as seções do executável
  • No toolchain GNU, uma seção chamada MY_SECTION faz com que os símbolos __start_MY_SECTION e __stop_MY_SECTION sejam definidos automaticamente
  • O macOS tem um padrão semelhante, sintetizando símbolos section$start e section$end para cada seção
  • No linker GNU, seções que não são mencionadas explicitamente no script de linker são chamadas de orphan sections
  • O linker só define automaticamente símbolos com prefixos _start e _stop quando o nome da seção é compatível com nomes de símbolos C
  • our_strings funciona, mas our.strings ou .our_strings não funcionam da mesma forma
  • Como símbolos de fronteira não contêm dados e só o endereço importa, o exemplo os representa com MaybeUninit<()>
  • O tipo ideal de “opaque extern type” ainda não existe em Rust stable, então MaybeUninit cumpre esse papel
  • Criar um ponteiro &raw const para um item static é sempre válido, então é possível obter o endereço com segurança sem ler o valor
  • link-section abstrai esses detalhes das seções do linker e os converte em slices Rust sobre os quais se pode usar operações padrão de slice
  • O poder das link sections está em que qualquer crate que forneça código ao binário pode enviar itens para a mesma seção, e o linker junta tudo imediatamente antes da geração do binário final

Injeção de dependência

  • O padrão de registro baseado em seções funciona pelo mesmo princípio de injeção de dependência
  • Frameworks como Dagger e Spring também seguem o princípio de que o consumidor dos dados de registro não deve ficar acoplado ao fornecedor
  • O fornecedor registra os dados no local em que eles são definidos, e o consumidor lê o registro
  • Na injeção de dependência tradicional, muitas vezes o framework precisa percorrer o grafo de módulos ou escanear classes carregadas na inicialização para encontrar fornecedores e consumidores
  • Com seções do linker, o linker coleta os dados dos fornecedores na hora de montar o binário e os deixa fáceis de ler pelo consumidor
  • O exemplo de registro de subcomandos de CLI é um caso desse padrão, registrando subcomandos com link_section::section
  • Turbopack usa esse padrão para constantes de string pool, mecanismos de registro de serialização·desserialização e registro de funções de compilação incremental do turbotask
  • Um servidor web hipotético também poderia usar esse padrão para coletar automaticamente rotas e middlewares no momento do build

Usando seções para registro

  • A vantagem do trabalho feito antes do main é que nenhuma thread roda a menos que seja iniciada explicitamente
  • Nesse ambiente, em muitos casos dá para evitar a complexidade de locks e primitivas de sincronização
  • É possível separar claramente o ciclo de vida dos dados em uma fase gravável antes do main e uma fase imutável depois do main
  • Evitar adquirir e liberar locks ao acessar dados no programa em execução pode simplificar a estrutura e melhorar a eficiência
  • O exemplo usa uma struct CliSubcommand, uma função construtora const e #[section] para coletar subcomandos
  • Subcomandos como list, add e help podem ficar em qualquer lugar do código
  • A função main pode fazer dispatch dinâmico sem conhecer nomes ou posições dos subcomandos registrados, desde que veja apenas a definição da seção CLI_SUBCOMMANDS
  • Se não houver subcomandos registrados, o fluxo volta para um subcomando padrão; no exemplo, help funciona como default

Além de dados imutáveis

  • O exemplo anterior assume que os dados linkados são imutáveis, mas a organização de dados baseada em linker também pode ser usada com dados mutáveis
  • A mutabilidade de dados estáticos globais é um problema comum em Rust, normalmente resolvido com ferramentas de mutabilidade interna como mutexes ou tipos atômicos
  • Mutexes e tipos atômicos não são caros quando não há contenção, mas também não são gratuitos
  • Para modificar dados com segurança em Rust, a alteração precisa ser thread-safe, e não pode haver outras referências aos mesmos dados enquanto existir uma referência mutável
  • Como o ambiente pre-main é single-threaded, a menos que threads sejam iniciadas explicitamente, não há necessidade de operações atômicas
  • Em um ambiente de thread única, a relação happens-before entre a escrita e a leitura posterior se estabelece automaticamente
  • Dados de link section modificados antes do main podem ser acessados depois com segurança, sem locks, por qualquer thread
  • Se referências mutáveis forem criadas e encerradas apenas antes do main, também se cumpre a condição de ausência de outras referências durante sua existência
  • O slice de uma link section é um alias para itens static dentro da seção, então as regras de aliasing se aplicam tanto ao slice quanto aos itens estáticos
  • Para modificar com segurança por meio do slice, os itens estáticos precisam obrigatoriamente ficar dentro de UnsafeCell
  • Itens estáticos não encapsulados em UnsafeCell permitem que o LLVM faça cache de valores, reordene operações ou assuma propriedades sobre os dados
  • UnsafeCell por si só não implementa Sync, então é necessário um tipo wrapper adicional
  • O exemplo usa SyncUnsafeCell e MaybeUninit<SyncUnsafeCell<...>> para compor símbolos de fronteira e itens
  • No exemplo de pool de string interning ordenável, o pool é definido no momento do link, o slice é ordenado no início do runtime e depois as strings são buscadas com busca binária
  • A implementação manual exige bastante boilerplate, mas com ctor e link-section é possível montar a mesma estrutura de forma mais concisa com TypedMutableSection e construtores
  • Os itens de TypedMutableSection precisam ser const, porque internamente é usado código parecido com o do exemplo de implementação manual

Vantagens do padrão com link sections

  • Esse padrão agrega itens marcados de forma garantida e posiciona todos os dados em memória contígua pré-alocada
  • O local de registro pode ficar espalhado por qualquer parte do código
  • É possível obter um número garantido de itens dentro da seção
  • Link sections não exigem alocação separada
  • Sem link sections, construir a mesma estrutura exigiria alocar um HashMap, Vec ou outra estrutura, e talvez redimensioná-la várias vezes enquanto os itens são coletados
  • Em abordagens tradicionais de coleta, as dependências entre módulo de tipos compartilhados, módulos contribuintes e módulo coletor tendem a ficar profundamente entrelaçadas
  • Com link sections, o coletor pode ficar em qualquer lugar e não precisa se preocupar com quais módulos contribuem com os dados
  • scattered-collect fornece vários análogos de estruturas de dados com suporte a montagem no momento do link
    • Scattered*Slice oferece várias estruturas parecidas com Vec que expõem slices e, opcionalmente, suportam ordenação
    • ScatteredMap e ScatteredSet são estruturas parecidas com HashMap e HashSet, oferecendo lookup baseado em hash com inicialização pre-main mínima

Quando não usar essa abordagem

  • Computação no momento do link é poderosa, mas nem sempre é a ferramenta certa
  • Em vez de usar uma abordagem de link time, é possível coletar manualmente os dados em um crate que tenha visibilidade de cada crate que quer contribuir com dados
  • A coleta manual pode ser incômoda e exigir um crate coletor com referências a muitos crates, em vez de permitir que os contribuidores olhem para um único ponto de contribuição no crate central
  • A remoção de código morto se torna mais difícil
  • link-section e linkme marcam itens com #[used], então o linker não consegue remover dados não utilizados
  • Com dados pequenos, como átomos de strings internadas, isso pode não ser um problema; mas ao internar pedaços brutos de JSON·JavaScript ou estruturas grandes, pode-se acumular muito código morto difícil de identificar
  • Funções construtoras pre-main têm restrições
  • Funções construtoras não podem causar panic, e Rust não garante que todas as funções da biblioteca padrão estejam disponíveis
  • A ordem de chamada de funções de inicialização com a mesma prioridade não é garantida e depende bastante da plataforma
  • Essas restrições podem ser contornadas com projeto cuidadoso, mas a abordagem pre-main pode ainda assim ser inadequada por motivos sutis e difíceis de depurar
  • Miri não oferece suporte completo a todos os construtores pre-main e configurações de link sections
  • Atualmente, o Miri trata a execução pre-main apenas de forma muito básica e não modela link sections
  • Para testar comportamento indefinido, são recomendados sanitizers LLVM como ASan e TSan
  • Padrões de inversão de controle podem dificultar a auditoria de todos os pontos que contribuem dados para link sections
  • Muitos programas Rust amplamente distribuídos e bastante usados já dependem de recursos pre-main como ctor, link-section, inventory e linkme

Resumo rápido sobre WASM

  • WASM não oferece suporte nativo a link sections por influência de escolhas antigas
  • A anotação #[link_section] não coloca itens em uma seção real de código; em vez disso, eles vão para uma seção customizada do WASM que o próprio código WASM não consegue acessar
  • O crate linktime oferece suporte a WASM e fornece uma solução de emulação para que a abordagem funcione também em binários WASM
  • Pode haver no futuro propostas para adicionar suporte adequado a WASM

Conclusão

  • Antes do main, é possível fazer muito trabalho que traz vantagens relevantes em casos específicos
  • O ambiente pre-main tem ordem altamente controlada e alta previsibilidade, permitindo fazer muita coisa com mais confiança, sem locks, tipos atômicos ou outras primitivas de sincronização
  • Link sections permitem agregar e posicionar juntos dados relacionados de forma arbitrária por todo o binário, evitando ordens estranhas de dependência entre crates
  • Em muitos casos, é possível evitar completamente alocações, afastando-se de problemas do alocador, como fragmentação causada por alocações repetidas
  • Entre os crates relacionados estão ctor, dtor, link-section e scattered-collect

1 comentários

 
GN⁺ 4 시간 전
Opiniões no Lobste.rs
  • O Go é uma exceção por evitar o runtime C na maioria das plataformas, mas a Apple exige o runtime C para acesso a chamadas de sistema
    A Apple usa libSystem.dylib como fronteira de estabilidade de ABI das chamadas de sistema, e o Windows da família NT usa ntdll.dll como fronteira de estabilidade de ABI, não as chamadas de sistema em si: not syscalls
    No OpenBSD, parece que o Go chegou a definir flags de metadados para desativar a aplicação forçada do bit NX, para contornar a política em que o kernel encerra o processo se ele tentar fazer chamadas de sistema fora do mapeamento somente leitura de libc configurado pelo loader
    No entanto, como libSystem.dylib contains the functionality which would normally be libc.so plus other things, nesse sentido isso é igual à abordagem dos BSDs, em que a libc é a fronteira de estabilidade
    E, a partir do Go 1.16, o Go passou a usar libc para seguir a política de chamadas de sistema do OpenBSD
    O Linux é relativamente raro nesse aspecto quando tem números de chamadas de sistema estáveis, porque não segue a estrutura de outros SOs, em que “um pedaço do kernel carregado como biblioteca dinâmica no espaço de endereçamento do processo compartilha com o código em modo kernel definições instáveis de enum de chamadas de sistema”, e porque Linux e glibc não são desenvolvidos juntos no mesmo repositório como em outros lugares
    No Windows, o runtime C também fica responsável por converter a string de comando no estilo CP/M, herdada do MS-DOS e mantida pela API de criação de subprocessos do Windows, em um array argv no estilo POSIX
    Por isso a documentação de subprocess do Python tem a seção Converting an argument sequence to a string on Windows, explicando como transformar um array argv em string segundo as regras de aspas embutidas no runtime C da Microsoft. O parser do subprocesso chamado pode, se quiser, se comportar de forma diferente dessas regras
    O _start do Linux também não significa exatamente que o linker injeta automaticamente no binário um símbolo com esse nome. Se um binário em formato ELF é um executável, e não uma biblioteca, o campo e_entry do cabeçalho — no offset 0x18 — contém o endereço para o qual o loader salta depois de configurar a memória
    _start é a convenção do GCC para indicar o alvo apontado por e_entry quando não se usa o ponto de entrada fornecido pela libc, e pelo que lembro ferramentas como NASM também seguem isso
    No Windows, _WinMainCRTStartup também é encontrado pelo loader via AddressOfEntryPoint do PE header. Ele fica no offset 0x0028 em relação ao início do cabeçalho PE, que vem depois do cabeçalho MZ (DOS EXE) e do DOS Stub
    Para aprender os detalhes do cabeçalho PE, Making the smallest Windows application e Tiny PE são bons. Tiny PE chega a violar a especificação PE de maneiras que o Windows ainda aceita, por exemplo sobrepondo partes que o SO não lê ou colocando código em campos de cabeçalho que não são usados. Nesse nível, o menor tamanho de arquivo aceito pelo Windows passa a variar conforme a versão do Windows em que se executa
    Para executáveis ELF minúsculos no Linux, A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux também vale a leitura
    • As chamadas de sistema do FreeBSD e do NetBSD têm estabilidade de ABI, assim como as bibliotecas de sistema
    • Sobre _start, em sistemas a.out o ponto de entrada em que o kernel entrava no executável era tradicionalmente start, declarado em csu/crt0. Há exemplos na 7th edition e no VAX BSD
      Naquela época, o compilador C colocava _ na frente dos símbolos globais, então no V7 aparece a declaração de _main, e no BSD dá para ver que o nome de assembly da start() em C foi declarado como start, sem decoração
      Na época, os programas começavam no início do arquivo, e a invocação do linker por cc era organizada para que crt0 viesse primeiro. csu significa código de inicialização em C, e crt0 significa o objeto de suporte do runtime C número 0
      É mais difícil descobrir exatamente como isso funcionava no System V que introduziu o ELF, mas start ou _start continuou sendo usado como ponto de entrada do programa declarado em csu/crt0
      Nunca entendi direito como o ELF mudou esse tratamento de prefixo _, mas talvez por diversão alguém tenha acrescentado uma camada extra e por algum motivo start tenha virado _start
      Um par evidente é que o ELF parece ter acrescentado _end, que corresponde ao topo da BSS e ao ponto que sbrk(0) retornaria antes de malloc() criar o heap
  • Eu tinha interesse nessa vida antes do main no Rust, e achei que valeria reunir em um texto o que é isso e por que é útil
    Tenho ideias para textos seguintes, como formas de usar agregação no linker para criar coleções mais rápidas, mas antes queria ouvir feedback sobre este tema mais introdutório
    • Tenho trabalhado bastante com Rust embarcado, então em ambientes no_std e às vezes até sem alloc, main é só mais uma função e a inicialização em geral fica por conta do desenvolvedor
      Tenho bastante código repetitivo caseiro na base de código para usos parecidos, então fiquei curioso sobre como esses crates se encaixam em ambientes embarcados