Sistemas de Build
O processo de build de um kernel difere do de uma aplicação comum em um aspecto importante, além de simplesmente usar um cross-compiler em vez do próprio do host: a saída final não é um programa que o próprio loader do host sabe como rodar: é um binário com um layout de memória bem específico, e frequentemente precisa ser empacotado em uma imagem inicializável antes de sequer poder rodar, seja sob um emulador ou em hardware real. Praticamente todo sistema de build de kernel hobbyist, independentemente da ferramenta específica por trás dele, existe para produzir e impor esse layout, não apenas para transformar arquivos-fonte em código-objeto.
O que um build de kernel precisa fazer além de compilação
Seção intitulada “O que um build de kernel precisa fazer além de compilação”Compilar e montar arquivos-fonte em arquivos-objeto é a parte comum, idêntica em princípio a construir qualquer projeto de C ou assembly. O que difere é tudo depois disso: os arquivos-objeto precisam ser linkados de acordo com um linker script controlando exatamente onde na memória cada seção acaba: um detalhe que nenhum build de aplicação comum precisa especificar, já que o loader de um programa normal trata o posicionamento automaticamente, mas um kernel carregado diretamente por um bootloader não tem loader algum fazendo isso em seu nome. O binário resultante frequentemente também precisa ser embutido em uma imagem inicializável (uma ISO contendo o GRUB e o kernel juntos, no caso comum de depender de um bootloader já existente) antes que um emulador ou hardware real tenha algo de fato para inicializar.
Linker scripts
Seção intitulada “Linker scripts”Um linker script diz ao linker onde posicionar cada seção do binário final, o que importa enormemente para um kernel esperado a ser carregado em um endereço físico específico (ou, para um kernel higher-half, virtual):
ENTRY(kernel_main)
SECTIONS { . = 1M; /* endereço convencional de carga logo acima do primeiro megabyte */
.text : { *(.multiboot) *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { *(COMMON) *(.bss) }
kernel_end = .;}Posicionar .multiboot primeiro dentro de .text é o que satisfaz o requisito, coberto em Multiboot, de que um header Multiboot apareça dentro dos primeiros 8 KB do binário: um posicionamento que o compilador e o assembler sozinhos não têm como garantir, já que a ordenação comum de seção de outra forma fica a cargo dos próprios padrões do toolchain, e o linker script é o único lugar onde essa ordenação é de fato fixada explicitamente. A atribuição kernel_end ao final do script define um símbolo de linker comum, não uma palavra reservada (qualquer nome funcionaria), mas é um nome usual, já que dá ao próprio código C do kernel (declarado ali como extern char kernel_end[]; e referenciado por seu endereço, &kernel_end, nunca desreferenciado como se fosse dado) uma resposta confiável para “onde meu próprio binário termina e a memória física livre começa”, uma pergunta que um gerenciador de memória física precisa ter respondida antes de poder distribuir qualquer página que o próprio binário do kernel ocupe.
A seção .bss merece atenção particular por causa do que um linker script não faz por ela: .bss guarda dados inicializados com zero, mas não armazena dado algum deles no próprio arquivo, apenas seu tamanho, o que significa que nada em lugar algum zera automaticamente essa região de memória na RAM antes de o kernel começar a usá-la: um programa hospedado comum ganha isso de graça de seu código de inicialização de runtime C, mas um kernel não tem código de inicialização algum desses por padrão e precisa zerar a faixa entre os símbolos bss_start e bss_end fornecidos pelo linker por conta própria, tipicamente como uma das primeiríssimas instruções que seu ponto de entrada executa, ou variáveis globais que dependem de um valor inicial zero silenciosamente contêm qualquer lixo que ocupava aquela memória física antes.
Layout higher-half e a divisão entre endereço de carga e de linkagem
Seção intitulada “Layout higher-half e a divisão entre endereço de carga e de linkagem”Muitos kernels são higher-half: linkados para rodar em um endereço virtual alto como 0xFFFFFFFF80000000, mantendo o espaço de endereçamento baixo livre para programas em modo usuário, enquanto o bootloader só consegue posicionar o binário bruto em um endereço físico baixo como 1 MB, já que paginação (o único mecanismo capaz de fazer um endereço virtual alto resolver para essa localização física baixa) ainda não está ativa quando o bootloader passa o controle adiante. Um linker script reconcilia isso com duas noções distintas de endereço para a mesma seção: a VMA (endereço de memória virtual, onde o código espera rodar uma vez que a paginação esteja ativa) e a LMA (endereço de memória de carga, onde o bootloader de fato posiciona os bytes), separadas explicitamente com AT():
KERNEL_VMA = 0xFFFFFFFF80000000;KERNEL_LMA = 0x00100000;
SECTIONS { . = KERNEL_VMA + KERNEL_LMA;
.text ALIGN(4K) : AT(ADDR(.text) - KERNEL_VMA) { *(.multiboot) *(.text) }}Código e dados em um kernel assim são compilados e linkados contra a VMA alta o tempo todo: toda referência de símbolo e endereço absoluto que o compilador gera já presume que o endereço alto está correto, enquanto o bootloader só vê e carrega os bytes da LMA baixa; a pequena quantidade de assembly que roda primeiro, antes de a paginação ser habilitada, precisa ser position-independent ou explicitamente linkada em seu próprio endereço baixo por essa razão, já que executa antes de a VMA contra a qual foi compilada se tornar válida, e saltar para um endereço higher-half cedo demais, antes de as tabelas de página que o mapeiam existirem, produz uma falha imediata indistinguível, de fora, de quase qualquer outra falha de boot inicial.
Um Makefile representativo
Seção intitulada “Um Makefile representativo”O Makefile de um kernel geralmente espelha a sequência descrita acima (compilar, montar, linkar com o script customizado, depois empacotar) explicitamente, já que builds de kernel raramente se beneficiam das suposições de um sistema de build pronto sobre produzir um executável hospedado comum:
CC := x86_64-elf-gccAS := nasmCFLAGS := -ffreestanding -O2 -Wall -Wextra -mno-red-zone -mno-sse -mno-mmxASFLAGS := -f elf64
OBJS := boot.o kernel.o
kernel.elf: $(OBJS) linker.ld $(CC) -T linker.ld -o $@ -ffreestanding -O2 -nostdlib $(OBJS) -lgcc
%.o: %.c $(CC) $(CFLAGS) -MMD -c $< -o $@
%.o: %.asm $(AS) $(ASFLAGS) $< -o $@
iso: kernel.elf mkdir -p isodir/boot/grub cp kernel.elf isodir/boot/kernel.elf cp grub.cfg isodir/boot/grub/grub.cfg grub-mkrescue -o mykernel.iso isodir
.PHONY: iso cleanclean: rm -f $(OBJS) kernel.elf mykernel.iso *.d
-include $(OBJS:.o=.d)-ffreestanding diz ao compilador para não presumir que um ambiente hospedado de biblioteca C está disponível: a mesma suposição da qual um cross-compiler já é configurado sem, mas vale a pena passar explicitamente de qualquer forma, já que também desabilita certas otimizações do compilador que presumem semântica de biblioteca padrão para funções como memcpy, que um kernel freestanding pode implementar por conta própria, de forma diferente. -mno-red-zone desabilita a red zone da ABI System V x86-64, uma otimização que presume que 128 bytes abaixo do stack pointer são seguros para usar sem ajustá-lo primeiro: uma suposição que não vale dentro de um handler de interrupção, que pode ele mesmo ser invocado a qualquer ponto sobre qualquer stack que o código interrompido tivesse, tornando o uso de red zone uma fonte sutil de corrupção de stack específica de código de kernel se deixada habilitada. -mno-sse e -mno-mmx impedem o compilador de gerar instruções de ponto flutuante ou vetoriais de forma alguma, não porque um kernel nunca possa usá-las, mas porque fazer isso com segurança exige salvar e restaurar aquele estado de registrador a cada troca de contexto e interrupção, trabalho que a maioria dos kernels adia inteiramente até decidir explicitamente suportar tarefas que usam ponto flutuante, tornando essas flags o padrão seguro para código que ainda não fez esse trabalho.
Dois detalhes de sistema de build importam tanto quanto as próprias flags de compilador. Primeiro, ter duas regras de padrão separadas (uma compilando arquivos .c com o cross-GCC, outra montando arquivos .asm com o NASM) reflete uma divisão comum em árvores de código-fonte de kernel: o código de boot mais inicial, precisando de controle preciso sobre registradores de segmento, a stack, e transições de modo antes de qualquer runtime C existir para depender dele, geralmente é escrito diretamente em assembly, enquanto tudo alcançável depois desse ponto é C comum. Arquivos .asm em sintaxe Intel do NASM e arquivos .S em sintaxe AT&T do GNU as não são intercambiáveis sem tradução, e um Makefile combinando ambos os toolchains (NASM para assembly escrito à mão, o próprio assembler do cross-GCC invocado implicitamente para qualquer bloco asm inline dentro de arquivos .c) precisa de uma regra de padrão separada por sintaxe, já que nenhum dos dois assemblers aceita o formato de entrada do outro. Segundo, -MMD junto ao passo de compilação, combinado com a linha -include $(OBJS:.o=.d) perto do final, é o que faz o Make ciente de que o objeto de um arquivo .c depende não só daquele arquivo mas de todo header que ele inclui transitivamente: sem isso, editar um header compartilhado silenciosamente falha em disparar a reconstrução de tudo que o incluiu.
Construindo uma ISO inicializável
Seção intitulada “Construindo uma ISO inicializável”grub-mkrescue, parte do conjunto de ferramentas do GRUB, pega um diretório organizado com o binário do kernel e um arquivo de configuração grub.cfg nas localizações convencionais que o GRUB espera, e produz uma imagem ISO inicializável utilizável diretamente pelo QEMU (-cdrom) ou gravada em mídia física. Por baixo dos panos, o próprio grub-mkrescue é um wrapper em torno do xorriso, a ferramenta que de fato monta o sistema de arquivos ISO 9660 e escreve as entradas do catálogo de boot El Torito que uma firmware BIOS ou UEFI lê para localizar a imagem de boot dentro dele; uma falha do grub-mkrescue reclamando de uma ferramenta ausente quase sempre significa que o xorriso (ou, em algumas distribuições, um mtools empacotado separadamente, necessário para a partição de boot formatada em FAT que o GRUB embute ao lado da partição BIOS) não está instalado, em vez de algo errado com o próprio kernel ou grub.cfg.
O próprio grub.cfg é tipicamente mínimo para um kernel hobby: uma entrada de menu nomeando o binário do kernel para carregar via a diretiva multiboot (ou multiboot2) que o próprio GRUB interpreta:
menuentry "My Kernel" { multiboot /boot/kernel.elf}Construir uma ISO completa a cada iteração não é a única opção, e a maioria dos desenvolvedores de kernel pula isso no trabalho do dia a dia: a flag -kernel do QEMU, coberta com mais detalhe junto ao resto das flags do emulador, carrega um binário ELF compatível com Multiboot diretamente, sem bootloader ou ISO algum envolvido, reduzindo o ciclo de iteração a compilar-linkar-rodar. O passo da ISO só se torna necessário de novo ao testar contra hardware real, ou contra um caminho de boot de firmware (UEFI em particular) que -kernel ignora completamente e, portanto, não consegue exercitar.
Sistemas de build além do Make
Seção intitulada “Sistemas de build além do Make”O Make é a escolha dominante para kernels pequenos e hobbyist: ele vem instalado em praticamente todo ambiente de desenvolvimento tipo Unix já por padrão, e o grafo de build de um kernel geralmente é simples o suficiente para que as limitações do Make raramente se tornem um problema prático. Projetos de kernel maiores frequentemente o superam por razões específicas da própria escala: o SerenityOS constrói com CMake, gerando arquivos de build do Ninja, em grande parte porque seu build abrange todo um userspace de bibliotecas e aplicações junto com o próprio kernel, uma escala na qual a descoberta automática de dependências e os diretórios de build fora da árvore de código-fonte do CMake compensam a complexidade adicionada. O seL4 também constrói com CMake, motivado pela necessidade adicional de rodar um pipeline separado de geração de código e prova formal como parte de um build comum, algo que um Makefile simples não tem facilidade nativa alguma para orquestrar. O Redox, sendo escrito em Rust em vez de C, usa Cargo como seu driver de build principal em vez de Make ou CMake de qualquer forma, dependendo de #![no_std] (a própria declaração de modo freestanding do Rust, funcionalmente equivalente a -ffreestanding) junto com um arquivo de especificação de alvo customizado descrevendo o ambiente bare-metal no lugar de um bloco SECTIONS de linker script, embora o linker do Redox ainda resolva o código-objeto resultante com a mesma semântica subjacente de ld descrita acima. Nenhuma dessas ferramentas muda o problema fundamental que um build de kernel precisa resolver (posicionar seções em endereços controlados e produzir uma imagem inicializável), apenas quanto da orquestração ao redor é tratada por regras escritas à mão versus pelas próprias convenções de uma ferramenta mais capaz.
Notas de implementação
Seção intitulada “Notas de implementação”O rastreamento implícito de dependências de um Makefile (ou a falta dele) é uma fonte comum de comportamento confuso especificamente durante o desenvolvimento de kernel: editar um arquivo de header incluído por vários arquivos-fonte, sem que o build recompile corretamente tudo que o incluiu, produz um kernel construído a partir de uma mistura desatualizada de código-objeto antigo e novo: erros que se manifestam como comportamento de tempo de execução bizarro e aparentemente impossível, em vez de uma falha de build. Gerar dependências de header adequadas (via gcc -MMD, ou equivalente para o toolchain em uso) e incluí-las no Makefile evita essa classe de bug, que é desproporcionalmente dolorosa de diagnosticar em código de kernel, dado quão poucas outras ferramentas de diagnóstico estão disponíveis comparado a desenvolvimento em user space. A ordem na linha de linkagem é uma segunda armadilha, menos óbvia, específica de linkagem estática: o ld resolve símbolos da esquerda para a direita, então um arquivo-objeto que referencia um símbolo que a libgcc define precisa aparecer na linha de comando antes de -lgcc, não depois: um kernel que linka sem problemas hoje pode começar a falhar com um erro de símbolo não resolvido depois de nada mais do que reordenar arquivos-fonte dentro da variável $(OBJS), se essa reordenação por acaso mover um objeto dependente de libgcc para depois do ponto onde -lgcc já foi passado. Declarar iso e clean como alvos .PHONY, por fim, importa mais em um projeto de kernel do que talvez importaria em outro lugar, já que é comum um arquivo perdido chamado exatamente iso ou clean acabar parando em um diretório de build (uma pasta de staging de ISO deixada para trás, por exemplo), o que de outra forma faria o Make tratar o alvo como já atualizado e silenciosamente pular sua execução.
Referências
Seção intitulada “Referências”- ^ OSDev Wiki, “Bare Bones”: um passo a passo completo de um kernel mínimo compilável, linker script, e configuração do GRUB.
- ^ Manual do GNU
ld, “Linker Scripts”: documentaSECTIONS,AT(), e o resto da sintaxe de linker script usada acima. - ^ OSDev Wiki, “Higher Half Kernel”: cobre a divisão VMA/LMA e a independência de posição do código de boot inicial com detalhe mais específico de implementação.
Ver também
Seção intitulada “Ver também”- Building a Cross-Compiler: o toolchain que este processo de build invoca.
- Multiboot: o header que o linker script deste artigo posiciona dentro dos primeiros 8 KB do binário.
- Paging: o mecanismo que eventualmente faz os endereços virtuais de um kernel higher-half resolverem corretamente.
- A System V ABI: a convenção de chamada completa da qual
-mno-red-zonee as regras de preservação de registrador que ela implica fazem parte. - ISO 9660: o formato em disco por trás da imagem inicializável que
grub-mkrescue/xorriso produzem.