Construindo um Cross-Compiler
Um cross-compiler é um compilador que roda em um sistema mas produz código para outro: em desenvolvimento de kernel, um que roda no sistema operacional host da máquina de desenvolvimento mas produz binários freestanding sem dependência alguma da biblioteca C, convenções de ABI, ou suposições de formato executável daquele host. Construir um é um dos primeiros passos práticos na maioria dos projetos de kernel, e pulá-lo em favor do compilador já instalado na máquina de desenvolvimento é uma fonte comum de bugs sutis e difíceis de diagnosticar mais tarde, bugs que costumam só aparecer quando o kernel já está avançado o suficiente para esbarrar na suposição específica de host que um determinado recurso do compilador fazia, e não no momento em que a flag responsável foi usada pela primeira vez.
Por que o compilador do host não funciona
Seção intitulada “Por que o compilador do host não funciona”Uma instalação padrão de GCC ou Clang é configurada para ter como alvo o sistema operacional host, e vários de seus comportamentos padrão dependem de facilidades que um kernel freestanding simplesmente não tem. A proteção contra stack smashing, habilitada por padrão nos builds de GCC da maioria das distribuições Linux, insere uma chamada a __stack_chk_fail na saída de cada função: um símbolo que a biblioteca C do host fornece e que um kernel não tem, produzindo uma falha de linkagem ou, pior, um salto para lixo se a flag for silenciosamente aceita mas o símbolo nunca for definido. Executáveis position-independent, também um padrão comum de distribuição, presumem que um loader dinâmico vai realocar o binário no momento do carregamento; um kernel carregado diretamente por um bootloader não tem loader algum desses, e uma imagem de kernel PIE sem um, no endereço que seus próprios ponteiros internos presumem, simplesmente está quebrada. Um compilador host também linka uma pequena quantidade de código de inicialização de runtime C (convencionalmente crt0.o, crti.o, e crtn.o) em todo executável por padrão, código cujo único trabalho é chamar a inicialização de uma biblioteca C antes de sequer alcançar main, nada disso existe ou faria sentido para um kernel que não tem biblioteca C alguma para inicializar e que é, ele mesmo, o primeiro código que a CPU roda depois que o bootloader passa o controle adiante.
Algumas dessas suposições podem ser contornadas com flags de compilador suficientes no compilador host diretamente (-nostdlib, -fno-stack-protector, -fno-pie, e similares), mas a combinação é frágil e difere entre distribuições de host, versões de compilador, e até releases de patch, já que uma distribuição é livre para mudar quais flags vêm habilitadas por padrão a qualquer momento sem tratar isso como uma quebra de compatibilidade para software comum de user space. Um cross-compiler evita o problema estruturalmente, em vez de defensivamente: como é configurado desde o início para ter como alvo um ambiente bare-metal, freestanding, sem sistema operacional host algum no quadro, nenhum desses padrões específicos de host é compilado nele desde o começo, e não existe risco contínuo algum de uma atualização de distribuição reintroduzir silenciosamente um deles no build de um kernel.
O target triplet
Seção intitulada “O target triplet”Um cross-compiler GCC é identificado por um target triplet, uma string separada por hífens historicamente lida como cpu-vendor-os, mas, mais precisamente, uma vez que um quarto campo opcional está presente, como cpu-vendor-kernel-system. Para desenvolvimento de kernel o campo “vendor” é convencionalmente omitido ou deixado como um placeholder, e a escolha que de fato importa é o campo final: x86_64-elf (comumente escrito com a posição de vendor vazia, às vezes explicitamente como x86_64-elf-elf por sistemas de build que insistem em três partes) tem como alvo uma máquina de 64 bits sem sistema operacional algum e nenhuma biblioteca C presumida, em contraste com x86_64-linux-gnu, o triplet descrevendo um compilador host comum tendo Linux com glibc como sua biblioteca C.
A mesma convenção se estende a outras arquiteturas que um kernel hobbyist possa ter como alvo: i686-elf para x86 de 32 bits, aarch64-elf ou aarch64-none-elf para ARM de 64 bits, e riscv64-unknown-elf para RISC-V, cada um seguindo o padrão de nomear um ambiente bare e independente, em vez da ABI de um sistema operacional específico. A variante eabi vista em alguns triplets ARM de 32 bits (arm-none-eabi) se refere à ARM Embedded Application Binary Interface, não a sistema operacional algum, e já é, ela mesma, um alvo orientado a freestanding comumente usado sem modificação para trabalho com microcontroladores: desenvolvedores de kernel tendo ARM de 32 bits como alvo às vezes a usam diretamente em vez de construir um arm-none-elf separado, já que os dois diferem principalmente em detalhes de convenção de chamada que importam mais para interoperar com bibliotecas fornecidas por fabricantes do que para um kernel autocontido.
Esse triplet é o que tanto o Binutils quanto o GCC são configurados com durante seu próprio build, e é também o prefixo usado para invocar as ferramentas resultantes (x86_64-elf-gcc, x86_64-elf-ld, x86_64-elf-objdump, e assim por diante para o resto do conjunto do Binutils), permitindo que um cross-compiler coexista no mesmo sistema que o próprio toolchain nativo do host, sem que um sombreie o outro.
Versões de toolchain e dependências de build
Seção intitulada “Versões de toolchain e dependências de build”Binutils e GCC são desenvolvidos e lançados independentemente, e embora a maioria das combinações de versão lançadas compile e funcione corretamente junta, combinar um GCC muito recente com um Binutils muito antigo (ou o inverso) ocasionalmente falha completamente, seja porque o sistema de build do GCC espera uma diretiva de assembler ou recurso de linker que o Binutils mais antigo não implementa, seja porque os próprios scripts de build do Binutils são anteriores a uma mudança em como um compilador host mais novo aplica avisos. Manter-se com releases de Binutils e GCC que eram atuais na mesma época, ambos com um ou dois anos em vez de recém-lançados, é a forma mais confiável de evitar essa classe de problema sem precisar rastrear matrizes de compatibilidade diretamente.
O próprio build do GCC também depende de três bibliotecas de aritmética de precisão arbitrária que ele usa internamente para constant folding e emulação de ponto flutuante durante a própria compilação (GMP, MPFR, e MPC), que não fazem parte da árvore de código-fonte do próprio GCC. A maioria das distribuições Linux empacota versões de desenvolvimento das três (comumente libgmp-dev, libmpfr-dev, libmpc-dev ou nomes similares), e instalá-las de antemão evita uma falha de configure no meio do build do GCC; a árvore de código-fonte do GCC também traz um script contrib/download_prerequisites que busca e descompacta versões compatíveis das três diretamente na árvore de código-fonte, como alternativa para sistemas onde os pacotes de distribuição correspondentes estão indisponíveis ou desatualizados demais.
Construindo o Binutils
Seção intitulada “Construindo o Binutils”O Binutils (o assembler, linker, e ferramentas relacionadas das quais o próprio GCC depende) é construído primeiro, já que o processo de build do próprio GCC precisa de um cross-assembler e cross-linker correspondentes já disponíveis:
mkdir build-binutils && cd build-binutils../binutils-gdb/configure --target=x86_64-elf --prefix="$HOME/opt/cross" \ --with-sysroot --disable-nls --disable-werrormakemake install--with-sysroot (mesmo sem caminho algum seguindo) diz ao Binutils para se tratar como tendo como alvo uma raiz de sistema independente, em vez do próprio / do host, e --disable-nls pula a construção de traduções de suporte a idioma nativo, que são irrelevantes para um toolchain que só será invocado a partir de scripts de build. --disable-werror importa mais do que seu nome sugere: o próprio código-fonte do Binutils ocasionalmente dispara avisos sob um GCC de host mais novo do que o atual quando um determinado release do Binutils foi lançado, e sem essa flag esses avisos são tratados como erros irrecuperáveis, abortando o build por causa de um diagnóstico completamente alheio ao cross-compiler sendo produzido. Um build bem-sucedido popula $HOME/opt/cross/bin com o conjunto completo de ferramentas prefixadas pelo triplet (x86_64-elf-as, x86_64-elf-ld, x86_64-elf-ar, x86_64-elf-objcopy, x86_64-elf-objdump, x86_64-elf-nm, e x86_64-elf-ranlib entre elas), mesmo que apenas as e ld sejam estritamente necessários para o próprio build subsequente do GCC prosseguir.
Construindo o GCC
Seção intitulada “Construindo o GCC”Com o Binutils cross recém-construído no PATH, o próprio build do GCC segue um padrão semelhante de configure/make, mas com duas flags específicas de um alvo freestanding:
export PATH="$HOME/opt/cross/bin:$PATH"
mkdir build-gcc && cd build-gcc../gcc/configure --target=x86_64-elf --prefix="$HOME/opt/cross" \ --disable-nls --enable-languages=c,c++ --without-headersmake all-gccmake all-target-libgccmake install-gccmake install-target-libgcc--without-headers diz ao build do GCC para não esperar headers de biblioteca C disponíveis de forma alguma, apropriado para um compilador que só vai construir código de kernel freestanding, nunca programas hospedados de user space esperando uma biblioteca padrão. Construir apenas all-gcc e all-target-libgcc, em vez do alvo all completo, pula componentes (como libstdc++) que ou não são necessários para código de kernel ou exigem mais de um ambiente-alvo funcional do que existe nesse ponto para construir corretamente.
A divisão em dois estágios entre all-gcc e all-target-libgcc reflete uma dependência genuína, não apenas uma conveniência de script de build: all-gcc produz o próprio driver do compilador e o gerador de código, enquanto all-target-libgcc constrói a libgcc, uma pequena biblioteca de runtime que o compilador linka silenciosamente para operações que nenhum conjunto de instruções do alvo trata diretamente: divisão e módulo de inteiro de 64 bits em um alvo de 32 bits (__udivdi3, __moddi3), emulação de ponto flutuante por software em alvos sem FPU, e suporte a stack-unwinding usado por exceções de C++ entre elas. Um kernel construído com --without-headers, ironicamente, ainda depende da libgcc para esses auxiliares de baixo nível, mesmo não dependendo de mais nada que o host ou uma biblioteca C normalmente forneceriam, razão pela qual construí-la é um segundo estágio obrigatório em vez de um extra opcional: um kernel que nunca dispara uma divisão de 64 bits em um alvo de 32 bits pode nunca notar sua ausência, mas um que dispara recebe um erro de linkagem de símbolo não resolvido sem indicação alguma de qual linha de código C aparentemente comum foi responsável por puxá-lo.
Um build falhando no meio de all-target-libgcc com reclamações sobre um gmp.h, mpfr.h, ou header de nome similar ausente quase sempre remonta à dependência descrita acima, em vez de a algo errado com a própria configuração do cross-compiler, e é resolvido instalando o pacote de desenvolvimento ausente ou rodando novamente o script de download de pré-requisitos do GCC antes de tentar o build de novo a partir de um diretório limpo.
C++ e o subconjunto freestanding
Seção intitulada “C++ e o subconjunto freestanding”Passar --enable-languages=c,c++ constrói um cross-compiler capaz de compilar código-fonte C++, mas um kernel usando-o ainda não consegue usar C++ da forma que uma aplicação hospedada usaria, sem abrir mão de algo. Tratamento de exceções e dynamic_cast/RTTI dependem ambos de uma biblioteca de suporte de runtime (normalmente libsupc++, combinada com tabelas de unwind interpretadas por uma personality routine) que presume um ambiente hospedado com um abort funcional e outras facilidades de biblioteca C que um kernel freestanding não fornece; a maioria dos kernels escritos parcial ou totalmente em C++ compila com -fno-exceptions -fno-rtti por essa razão; habilitar qualquer um dos dois exige fornecer manualmente as peças de runtime ausentes, o que poucos kernels hobbyist se dão ao trabalho de fazer, dado quão pouca funcionalidade é ganha em relação ao esforço. Construtores globais apresentam uma versão menor, e mais comumente resolvida, do mesmo problema subjacente: o código de inicialização de runtime C de um programa hospedado percorre a seção .init_array e chama cada entrada antes de main rodar, mas um kernel não tem código de inicialização algum desses por padrão e precisa percorrer essa seção por conta própria, tipicamente como uma das primeiras coisas que seu próprio ponto de entrada faz, ou objetos globais com construtores não triviais silenciosamente nunca são inicializados, apesar de compilar e linkar sem erro algum.
Verificando o resultado
Seção intitulada “Verificando o resultado”Um cross-compiler corretamente construído deve, por padrão, recusar-se a produzir saída dependente de facilidades de host para as quais não foi configurado:
x86_64-elf-gcc -v # confirma o target triplet em sua própria configuração reportadaecho 'int main(){return 0;}' | x86_64-elf-gcc -x c -c - -o /tmp/test.o # deveria ter sucessox86_64-elf-readelf -d /tmp/test.o # deveria não reportar seção dinâmica algumaUm sinal revelador de estar acidentalmente ainda usando o compilador host, em vez do cross recém-construído, é qualquer erro ou aviso mencionando os próprios headers de biblioteca C do host ou um target triple específico do host na saída de diagnóstico: uma invocação genuína de x86_64-elf-gcc não tem conhecimento algum do /usr/include do host. Rodar x86_64-elf-nm sobre um binário de kernel já linkado e encontrar nomes de símbolo de libc comuns (malloc, printf, memcpy fornecidos pela glibc em vez da própria implementação do kernel) é um sinal de alerta semelhante, um nível mais adiante, um que uma compilação bem-sucedida ainda pode esconder se o linker do host foi invocado no lugar do cross em algum ponto de um script de build.
Clang e LLVM como alternativa
Seção intitulada “Clang e LLVM como alternativa”O Clang, construído sobre o LLVM, adota uma abordagem estruturalmente diferente para cross-compilation que evita a maior parte do processo descrito acima: em vez de cada alvo exigir seu próprio binário de compilador configurado e construído separadamente, um único binário do Clang suporta nativamente todo backend do LLVM com o qual foi construído, selecionado no momento da invocação com uma flag -target (clang -target x86_64-elf -ffreestanding ...) em vez de por qual binário prefixado está no PATH. Isso não elimina toda preocupação de cross-compilation: o Clang ainda precisa de um linker apropriado para o alvo (o próprio lld do LLVM suporta os mesmos triplets que o Clang e é comumente combinado com ele por essa razão) e seu próprio equivalente à libgcc, chamado compiler-rt, ainda precisa ser construído para o alvo caso um kernel acabe dependendo das mesmas rotinas auxiliares de baixo nível que a libgcc fornece, mas remove a sequência de build de horas Binutils-depois-GCC completamente para um desenvolvedor que já tem um Clang adequadamente configurado instalado. A maioria dos tutoriais e scripts de build de desenvolvimento de kernel existentes ainda é escrita contra o GCC, o que é a principal razão prática pela qual um cross-compiler GCC continua sendo o ponto de partida mais comumente documentado, apesar do modelo de cross-compilation mais simples do Clang.
Notas de implementação
Seção intitulada “Notas de implementação”A ordem de PATH importa para todo passo de build subsequente em um projeto de kernel: se o compilador nativo do host é encontrado primeiro, make ou um script de build escrito à mão o usa silenciosamente em vez do cross-compiler, a menos que toda invocação referencie explicitamente o nome prefixado do cross-compiler: um modo de falha comum e confuso é um kernel que aparenta construir com sucesso mas trava imediatamente no boot, resultado de ter sido de fato compilado com suposições de host embutidas por toda parte. Colocar o diretório bin do cross-compiler cedo no PATH, e referenciar ferramentas por seus nomes prefixados com triplet explicitamente em scripts de build, em vez de depender de gcc puro, evita essa ambiguidade por completo.
O tempo de build varia enormemente com o paralelismo disponível: um make de thread única construindo apenas o GCC pode levar quase uma hora inteira mesmo em hardware rápido, enquanto passar -j seguido da contagem de núcleos do host (make -j$(nproc) no Linux) comumente reduz isso a bem menos de quinze minutos em uma máquina moderna multi-core, já que a maior parte do tempo vai para compilar o considerável código-fonte C e C++ do próprio GCC, e não para nada inerente à cross-compilation em si. Os diretórios de build e código-fonte juntos tipicamente consomem de um a dois gigabytes de espaço em disco até que tanto o Binutils quanto o GCC terminem de construir, a maior parte do que é seguro apagar assim que make install tiver copiado o toolchain finalizado para o diretório --prefix. Por fim, uma vez que um cross-compiler funcional exista para um determinado par de alvo e versão de toolchain, vale a pena manter à mão, ou anotadas em algum lugar, as versões exatas dos arquivos de código-fonte usadas: reconstruir a partir de um release de ponto mais novo depois, após meses de desenvolvimento de kernel contra o antigo, ocasionalmente revela um aviso ou mudança de flag padrão que um toolchain estável e imutável nunca teria introduzido no meio do projeto.
Referências
Seção intitulada “Referências”- ^ OSDev Wiki, “GCC Cross-Compiler”: um passo a passo hobbyist detalhado cobrindo flags de configure adicionais e erros comuns de build.
- ^ GCC Installation, “Configure Terms and Generalities”: a referência canônica para
--target,--without-headers, e as demais opções de tempo de configure do GCC. - ^ Manual do GNU Binutils: documenta
--with-sysroote as demais opções de configure do próprio Binutils.
Ver também
Seção intitulada “Ver também”- Build Systems: como o cross-compiler resultante se encaixa em um build completo de kernel.
- Emulating & Debugging: rodando o kernel que este compilador produz.