Aplicando patches no GCC para compilar Actually Portable Executables
(ahgamut.github.io)- Para compilar mais software C existente com a Cosmopolitan Libc, foi preciso ir além de enganar o sistema de build: um patch de cerca de 2.000 linhas no GCC teve de fazer o compilador transformar automaticamente o código problemático
- O obstáculo era que valores do sistema como
SIGTERMeEINVALnão eram constantes em tempo de compilação, esbarrando nas regras da linguagem C para rótuloscaseemswitche inicialização destatic/const struct - Foram tentados
sed, expressões regulares em Python, plugins do GCC e até interceptação de macros do pré-processador, mas os erros de rótuloscaseocorriam durante o parsing, dificultando bloqueá-los apenas com plugins - O patch final é ativado pela flag
-fportcosmo; pouco antes de o GCC emitir o erro, ele transformaswitchemif/gotoe complementa inicializações de estruturas preenchendo valores em tempo de execução - Ainda há limitações, como estruturas anônimas,
enum, algunsconst inte índices de array dentro de expressões de inicialização, mas abriu-se um caminho para compilar softwares comobash,curl,git,ninjaegccsem alterar o código-fonte
Objetivo do patch no GCC
- Foi escrito um patch de cerca de 2.000 linhas no
gccpara facilitar a criação de Actually Portable Executables com a Cosmopolitan Libc - Após aplicar o patch, é possível compilar os seguintes softwares com sistemas de build
./configureoucmakebashcurlgitninja- o próprio
gcc
- Os executáveis gerados devem poder rodar também em Linux, FreeBSD, MacOS, OpenBSD, NetBSD e Windows
- Há a ressalva de que a execução no Windows não foi testada diretamente
- Os binários gerados pelo GitHub Actions podem ser baixados em superconfigure releases
O ponto de bloqueio no porte para a Cosmopolitan Libc
- Depois que a Cosmopolitan Libc chamou atenção em 2021 com o servidor web
redbean, uma questão importante passou a ser quão bem softwares C existentes funcionam sobre essa libc - Ao portar runtimes de linguagens como Lua, Wren, Janet e
quickjs, o mesmo padrão se repetia - Usar constantes do sistema como
SIGTERMeEINVALem rótuloscasede umswitchconflita com as regras de compilação de C - O padrão C exige que rótulos
casesejam constantes em tempo de compilação- Na Cosmopolitan Libc, macros do pré-processador C relacionadas a interfaces do sistema precisavam ser simbólicas
- Como resultado, código comum como
switch(errno) { case EINVAL: ... }podia não compilar
Transformando switch em if e goto
- Se rótulos como
case EINVALecase ENOSYSdentro deswitch(errno)não forem constantes em tempo de compilação, o GCC emite o errocase label does not reduce to an integer constant - O patch transforma automaticamente a instrução
switchna seguinte estrutura- Adiciona uma ramificação no formato
if (errno == EINVAL) goto caselabel_EINVAL;para cada valor decase - Adiciona um
gotoque salta para o rótulodefault - O bloco
caseoriginal é reestruturado com rótulos egoto endofthis_switch
- Adiciona uma ramificação no formato
- Essa abordagem não é a mais elegante, mas conseguiu lidar com os casos vistos em várias bases de código
- Como era preciso lidar até com
switchmisturado comifdefe fallthrough, a transformação baseada emgotoera a opção simples
Inicialização de struct sofre a mesma restrição
- O problema de constantes em tempo de compilação também aparece na inicialização de
staticouconst struct - Se
EINVALnão for uma constante em tempo de compilação, algumas das inicializações abaixo não são válidas em C- Inicialização de uma
structlocal comum dentro de uma função é possível const structstatic structconst structglobalstatic structglobal
- Inicialização de uma
- Um caso real é o módulo
faulthandlerdo CPython - A solução é inicializar a estrutura com valores fictícios e depois adicionar código para preencher os valores corretos antes do uso
- Dentro de uma função, adiciona-se uma instrução
if - Para inicializações globais ou estáticas, usa-se
__attribute__((constructor))
- Dentro de uma função, adiciona-se uma instrução
Limites de substituição de texto e plugins do GCC
- No início, tentou-se automatizar a transformação com um script shell usando
sed; depois, tentou-se lidar até com fallthrough por meio de um script Python e expressões regulares - Mas, por causa do pré-processador C e de
ifdef, a substituição de texto tinha dificuldade para funcionar de forma completa- Isso porque é difícil saber antecipadamente quais
ifdefestarão ativos na compilação
- Isso porque é difícil saber antecipadamente quais
- A tentativa seguinte foi usar transformação de AST por meio de um plugin do GCC
- Plugins do GCC podem ser carregados com
-fplugin=your-plugin.so - É possível acessar a AST em eventos como
PLUGIN_PRE_GENERICIZE,PLUGIN_FINISH_PARSE_FUNCTIONePLUGIN_FINISH_DECL - Com
debug_tree, é possível imprimir a AST; comwalk_tree_without_duplicates, é possível percorrê-la
- Plugins do GCC podem ser carregados com
- O problema era que a validação de rótulos
caseocorria durante o parsing do GCC- O plugin acessa a AST depois que o parsing termina
- Um rótulo inválido como
case SIGTERM:não permanece na AST - Como o GCC já havia emitido erro, era difícil corrigi-lo no plugin
Experimento de interceptação de macros do pré-processador
- Como era difícil evitar erros antes do parsing apenas com o plugin, tentou-se um método que interagia com o pré-processador C
- Pela estrutura
cpp_readerdos headers de plugin do GCC, era possível registrar callbacks quando uma macro era definida com#define, removida com#undefou usada - Usando a estrutura das macros de valores do sistema da Cosmopolitan Libc, eram inseridas constantes temporárias
- Ex.:
extern const int SIGTERM; - Ex.:
#define SIGTERM SYMBOLIC(SIGTERM) - Para o plugin, mantinha-se um valor temporário como
static const int __tmpcosmo_SIGTERM = ...
- Ex.:
- O plugin interceptava o uso de
SYMBOLIC(SIGTERM), substituía-o pelo valor temporário e, depois, na etapaPLUGIN_PRE_GENERICIZE, alterava novamente a AST para oVAR_DECLcorreto - Esse hack de macros conseguiu contornar os erros de inicialização em
switchestruct - Com essa abordagem, tornou-se possível compilar pelo menos o CPython 3.11
Por que corrigir o próprio GCC em vez de usar um plugin
- Depois de uma discussão com Justine Tunney, ao confirmar que o problema podia ser resolvido, foi sugerida uma abordagem mais simples: modificar diretamente a base de código do GCC
- O plugin e o hack de macros envolviam muito trabalho desnecessário, e o tratamento de casos extremos e crashes era complexo
- Por exemplo, mesmo em uma situação em que bastaria transformar
case ENOSYS, por causa da substituição de macros o plugin também precisava lidar com usos normais comoENOSYS + 1ouprintf("ENOSYS = %d", ENOSYS) - Na abordagem com patch no GCC, a funcionalidade é ativada pela flag
-fportcosmo, em vez de um-fpluginseparado - Pouco antes de o GCC emitir o erro
case is not constant, ele verifica seflag_portcosmoestá ativo e, se estiver, realiza as substituições necessárias - O código de transformação de AST do plugin existente foi movido para dentro do GCC, para ser executado antes da chamada de outros callbacks de plugin
- Com essa mudança, foi possível remover o código de hack relacionado a macros
Builds reais e expansão do patch
- Em 5 de junho de 2023, o GCC com patch passava em todos os casos de teste do plugin anterior, e um novo binário foi adicionado ao monorepo da Cosmopolitan Libc
- À medida que mais código era compilado, correções e melhorias continuaram
- O build de
luaficou mais simples, e, ao encontrar onde o erro decase constantocorria nog++, também se tornou possível compilarninja - Ao tentar anexar
ncursesao Python 3.11, um novo problema foi encontrado- Em
ncurses, havia código com uma expressão binária em valores de inicialização de estrutura, comoDATA2(PARODD | PARENB, PARODD) - O patch existente tratava inicializações com constantes simples, mas não expressões como
PARODD | PARENB
- Em
- Depois, o patch foi expandido para permitir que rótulos
casee elementos de inicialização de estruturas em C pudessem ser expressões arbitrárias- Pelo padrão C, rótulos
caseainda precisam ser constantes em tempo de compilação - O compilador com patch emite um aviso para esse uso, mas continua a compilação
- C++ foi excluído da substituição de expressões arbitrárias porque ela não combinava com o tratamento de
constexprdog++11
- Pelo padrão C, rótulos
- Após essa mudança,
ncursespassou a compilar sem problemas
Restrições restantes e conclusão prática
- O patch não é perfeito
- Não consegue tratar algumas estruturas anônimas
- Não consegue tratar alguns casos de
enumeconst int - Não consegue tratar casos em que
SIGTERMé usado como índice de array dentro de uma expressão de inicialização - Casos estranhos de inicialização
staticou situações em queSIGTERMé colocado em umstatic const int8_tainda podem causar crashes raros do compilador
- Ainda assim, muitos contraexemplos óbvios foram removidos, e vários softwares populares passam a compilar de forma fluida
- Ambientes de teste mais rigorosos podem revelar pontos para novas melhorias
- Se quiser, também é possível corrigir manualmente instruções
switche expressões de inicialização destruct, mas, em muitos casos, o compilador consegue fazer isso automaticamente - Se um software C puder ser compilado estaticamente, especialmente também com
musl, há chance de que ele também possa ser compilado com a Cosmopolitan Libc
3 comentários
Então agora o neo está adotando um formato em que as mensagens ficam envoltas em blocos de código? É um pouco chato ter que fazer rolagem horizontal, e a cor do texto em tons de vermelho também deixa um pouco a desejar...
Ah, isso ser envolvido em um bloco de código é um bug T_T
Opiniões no Hacker News
Eu escrevi este texto, mas o título deveria ser “Patching GCC to build Actually Portable Executables”, porque ele se refere à Cosmopolitan Libc e ao formato Actually Portable Executable do jart.
Com o meu patch para o GCC, agora é possível compilar softwares como vim, emacs, ninja, bash, git e gcc com a Cosmopolitan Libc, usando os sistemas de build habituais no estilo autotools/cmake. Os executáveis gerados devem conseguir rodar em Linux, FreeBSD, MacOS, OpenBSD, NetBSD e Windows. Porém, ainda não testei no Windows.
A lista de softwares que compilei usando essa técnica está aqui: https://github.com/ahgamut/superconfigure
O script superconfigure é apenas um wrapper em torno do script configure comum usado para compilar software, adicionando flags como
--enable-static.Se quiser compilar o GCC com a Cosmopolitan Libc, você pode usar este repositório: https://github.com/ahgamut/musl-cross-make/tree/gccbuild
Espero que receba um tratamento melhor no HN.
Por exemplo, fico curioso se houve testes suficientes para demonstrar que não há regressões em comparação com a versão glibc.
Há uma issue relacionada a isso no repositório do Go: https://github.com/golang/go/issues/51900
No geral, é bem legal, mas fico curioso sobre a fundamentação teórica. Se você está criando um novo alvo de sistema operacional, logicamente parece fazer sentido definir novas constantes.
Em vez de repassar as constantes de cada sistema operacional subjacente, bastaria converter as constantes de cada SO para que fossem iguais em todos os lugares. Assim, código que usa constantes como índices de array ou em instruções case também funcionaria corretamente.
Isso pode ter mais overhead do que a abordagem descrita no texto, mas parece muito mais fácil e mais simples de usar para quem quer compilar novos programas para a Cosmopolitan libc.
A lista de constantes está aqui: https://github.com/jart/cosmopolitan/blob/master/libc/sysv/c...
Cada vez que uma dessas constantes fosse usada, seria preciso incluir muitas constantes no binário em uma grande tabela de consulta, e toda vez que o programa precisasse verificá-las teria que passar por essa tabela. Talvez não fosse extremamente lento, mas acredito que certamente seria perceptível.
O objetivo era facilitar o porte sem alterar muito o código-fonte da libc ou do software que se quer portar, e produzir binários com desempenho semelhante ou melhor. Dentro dessas restrições, este patch do GCC pareceu ser a melhor forma de simplificar o processo.
Se eu encontrar um número suficiente de codebases que usam
SIGHUPpara inicializar índices de arrays, penso em testar a abordagem sugerida e medir o trade-off. Ou você mesmo pode tentar e me contar se um conjunto separado de constantes é melhor.Se “você passou bastante tempo eliminando contraexemplos óbvios, e muitos softwares populares compilam sem problemas”, seria útil publicar uma lista de softwares que compilam com sucesso. Talvez ela já exista.
O script superconfigure é apenas um wrapper em torno do script configure comum usado para compilar software, adicionando flags como
--enable-static.Pergunta-se se há algum objetivo prático para o APE. Existe algum lugar que realmente distribua um único executável que precise rodar em vários sistemas operacionais?
O próprio lnav não é compilado como APE, mas o agente embutido nele é. Quando o usuário quer ler os logs daquele host, esse agente é enviado remotamente
Assim, não é necessário ter uma etapa extra para detectar separadamente o tipo de sistema operacional ou incluir várias versões do executável. Há um texto curto relacionado aqui: https://lnav.org/2021/05/03/tailing-remote-files.html
Programas compilados estaticamente estão na base de tudo, mas, no nível de aplicação, são minoria
O APE levanta a grande pergunta: “se uma linguagem AOT, de compilação antecipada, de baixo nível como C pudesse ser compilada uma vez e executada em qualquer lugar, quem precisaria de uma máquina virtual?”
Além disso, cross-compilation é doloroso. Projetos open source pequenos que não têm uma build farm com vários BSDs muitas vezes simplesmente não distribuem binários para essas plataformas
Até o kubectl tem binários para Linux, mas não para BSD — a menos que se conte o OSX como BSD. Então isso parece uma vitória fácil para esse tipo de projeto
Ele é grande demais para ser compilado na hora na máquina do usuário. Inclui várias dependências C++ grandes de terceiros e, dependendo do hardware, a compilação leva de 2 a 20 minutos
Precisa rodar em macOS x86-64+ARM, Linux x86-64 e Windows x86-64. Seria bom se também rodasse em ARM Linux
No momento, WASM está sendo avaliado, mas isso exige um runtime WASI instalado
Para quem estiver confuso: “Actually Portable Executables” https://justine.lol/ape.html
Trocar
switchporifé bastante tosco. Acho que daria para corrigir mapeando para um conjunto de constantes neutras de plataforma e desreferenciando os símbolos de runtime por meio de uma funçãoPor exemplo, usando algo como
switch(CosmoMappErrno(EINVAL))ecase COSMO_EINVAL:, não seria necessário transformar o código em um monte de gotoClaro, seria diferente se a ideia fosse o compilador reconhecer esses
switches e, em vez de reescrevê-los como uma árvore deif, trocar os labels por constantes especiais específicas do cosmo para cada valor e envolver o valor de entrada doswitchem uma chamada de função que mapeie o valor da plataforma de runtime atual para essa constante específica do cosmo, deixando outros valores passarem sem alteraçãoIsso poderia, na prática, ser uma transformação de compilador mais simples. Só que surgiria a complexidade de reconhecer qual dos vários conjuntos de constantes está sendo usado e aplicar a chamada de mapeamento adequada ao valor de entrada do
switch. Também seria necessário criar esse mapeamento no lado da biblioteca, o que adiciona complexidadeComo já existe uma implementação funcionando com outra forma de transformação, não sei se o autor teria vontade de implementar isso
Por fim, esse método de mapeamento traz o risco de mapear errado e também detalhes complicados, como ter de inverter a entrada antes do mapeamento em código que usa valores negativos de errno
O motivo de eu gostar da matriz if-else-goto é que ela se encaixou perfeitamente em outras partes do GCC. Olhando meu patch[1], dá para ver que foi preciso alterar muito pouco do código existente do GCC para adicionar esse recurso
[1]: https://github.com/ahgamut/gcc/tree/portcosmo-11.2
Não entendo por que causa tanta surpresa código que usa
SIGxxxcomo índice. Números de sinal são bem definidos e, em geral, sabe-se que são contínuos e começam em 1Além disso, esse erro apareceu junto com um erro do compilador no meu patch que levou bastante tempo para depurar, então ficou como uma surpresa marcante
Fico curioso para saber se esse padrão de código é considerado comum. Também gostaria de saber em quais bases de código, além do busybox, ele aparece. Se houver muitos exemplos, talvez valha a pena dedicar tempo para atualizar o patch para lidar com esse padrão
Se errno é um problema tão grande assim, não sei que esperança há de fazer a biblioteca padrão C++ e std::error_code funcionarem
Lá não há como usar macros mágicas
std::error_codenão é uma constante, então não deve ter o mesmo problema