Antes de partir para os finalmentes, gostaria de deixar claro uma coisa: O nome BoringOS não tem relação com a Boring Company. Reforçando, o boring aqui é para indicar que o nosso produto não é tão interessante assim, o legal é construir ele (se é que se pode dizer que é um produto).
Outra coisa é que não tenho a pretensão de ensinar assembly, C ou qualquer coisa do tipo. É bom que você já tenha uma bagagem disso antes de prosseguir (ou pode aprender no processo). Para assembly, por exemplo, há outros materiais que recomendo, como os artigos do Leandro aqui.
Como somos disciplinados, vamos seguir os passos apresentados aqui. O primeiro passo é: “Review project specs, read the necessary documentation, download the start code.”. Ok, aceito os termos, quero continuar :D !
Vamos para o passo dois: “Complete the Quickstart Tutorial to working with Bochs on the lab machines.”
Aqui, duas diferenças importantes. Quero deixar o meu código o mais diferente possível do código dos caras de Princeton, então, além de não o consultar, ainda propus duas alterações, no compilador assembler e no emulador/simulador/virtualizador:
Vamos usar NASM em vez do Gnu Assembler; e
Em relação ao NASM, na verdade eu já tinha experiência com ele. O as1 eu aprendi fazendo o Projeto 1. Já em relação ao QEMU, confesso que usei muito pouco. Usei muito mais o Bochs, ele tem um mini gdb integrado e tem coisas legais como magic breakpoint2.
Mas isso não vai nos amedrontar certo? Vamos fazer igual o Frank Sinatra, My Way (ou se você for Millenium como eu, a referência é o Limp Bizkit).
Um ponto importante é que programar um kernel é praticamente um Metroidvania: você precisar ir e voltar várias vezes, testar, restartar e frequentemente você vai quebrar algo que já estava funcionando. Se eu postei aqui, é porque eu já passei por essas etapas e já estou mostrando algo funcional.
Ok, vamos começar. Primeiro vamos entender o que o Bochs Quickstart propõe.
Processo de Boot
Essa parte do trabalho quer verificar seu ambiente, se o Bochs está corretamente configurado, se você consegue executar o bootloader dado e ver a letra S na tela, etc.
Vamos recapitular então como é o processo de boot no processador x86. Para detalhes sórdidos, veja aqui. Mas vamos resumir: quando o computador liga, o processador não faz ideia do que está ligado nele: periféricos, discos, quanto de RAM tem no seu sistema, e assim por diante. Por esse motivo, há um conjunto de passos padronizados que são efetuados:
O processador executa um código de um chip/firmware ROM que faz toda a checagem do sistema, chamado POST. A maior parte desse trabalho é de responsabilidade da BIOS, e em sistemas mais modernos, UEFI. Há várias checagens de hardware que são feitas nesses passos, como detecção de RAM e outras coisas que não nos interessam agora.
Ao finalizar os testes de hardware, a BIOS vai tentar localizar o bootloader. Para isso, ele vai varrer a lista de discos3, na ordem especificada pelo usuário (e na ausência desta ordem, ele usa a default do dispositivo), em busca do disco de boot. A BIOS lê o primeiro setor do disco, a procura de uma assinatura específica (que no caso do x86, é verificar se nos últimos dois bytes do primeiro setor (um setor tem 512 bytes) há o valor 0x55AA. Não há nada de especial neste valor em si, mas se você transformar em binário, verificará que é um padrão 01010101 10101010. Isso indica que alguém intencionalmente escreveu tal padrão neste setor, indicando para o sistema ser o setor de boot.
A BIOS coloca esses 512 bytes em um endereço absoluto específico e pré-acordado da arquitetura: 0x7c00. O porquê desse eu não sei, não me pergunte. Mas depois que a BIOS termina esse trabalho, ela faz um far jump para ele e entrega a execução para o bootloader. A partir dai é seu código que está no controle do equipamento.
O projeto apresenta um código para mostrar um caractere na tela, no passo 2 daqui e, se ainda precisar de ajuda, pode consultar algumas dicas aqui. Vamos nos basear nesse código então, mas vamos dar uma simplificada.
global _start
_start:
mov ah, 0x0e
mov al, 'S'
mov bh, 0x00
mov bl, 0x02
int 0x10
TIMES 510-($-$$) DB 0
db 0x55, 0xaa
No código acima, definimos o símbolo global _start para que o linker encontre o ponto de entrada do programa. Depois, usamos a função da BIOS de escrita de caractere na tela. os parâmetros são:
ah = 0x0e (sempre)
al = caractere a ser escrito (no caso, ‘S’)
bh = número da página ativa (usamos 0x00)
bl = cor de fundo em modo gráfico (usamos 0x02)
Ai é só executar a instrução int 0x10 e correr para o abraço.
Até esse ponto, o código acima consumiu alguns bytes, não sabemos o quanto necessariamente, nem precisamos (fica de lição de casa você pegar o manual do ISA x86 e descobrir quantos bytes tem até o momento). Mas até a linha 7 acima, esse código não representa um setor de boot, que virá logo em seguida.
Precisamos de vários bytes até preencher 510, e depois inserir dois bytes da assinatura do setor de boot. Essa é a função das próximas duas linhas. $-$$
verifica quanto bytes foram gastos do início do arquivo até a linha anterior, e a diretiva de compilação TIMES repete DB 0 bytes quantas vezes quisermos, no caso, 510-($-$$) vezes. Isso garante que tenhamos 510 bytes preenchidos, e ai finalizamos com a cereja do bolo com os últimos dois bytes 0x55AA.
Ok, e como a gente monta isso. Perceba no passo 3 que eles executam um Makefile que faz várias coisas (imagem abaixo).
Por enquanto, estamos só na primeira parte. Queremos compilar o arquivo para que seja gerado um binário de boot. Portanto, estamos tratando apenas das 2 primeiras linhas do comando. A primeira, gcc -Wall -g -m32 -fomit-frame-pointer -O2 -fno-builtin bootblock.s invoca por baixo dos panos o as. Vamos esclarecer o que esses parâmetros invocados no comando significam.
-Wall significa ligar todos os warnings. O compilador é seu amigo, e se ele tá te alertando de algo, é bom ficar esperto.
-g é necessário para ativar a depuração e para que possamos carregar a tabela de símbolos quando usamos um compilador como o gdb.
-m32 indica a arquitetura do código gerado, no caso, x86 (não compilar para 64bit).
-fomit-frame-pointer tem relação com os registradores de base de pilha, no caso do x86, o bp. Como estamos executando um código em que nós mesmos precisamos gerenciar a pilha, isso indica ao compilador não criar o registro de ativação.
-O2 ativa a otimização em nível de compilação (nem sempre é possível ativar essa opção, nesse nosso caso não fará diferença).
-fno-builtin desabilita a utilização de funções de biblioteca da arquitetura local. Você não pode usar as funções da stdio.h da libc no kernel por exemplo (já que no fim das contas, as chamadas acabam invocando system calls do sistema host, mas o seu código não tem um SO aceitando system calls ainda…). Por isso, avisa ao compilador para não se preocupar com isso.
Esse primeiro comando gerará um arquivo objeto bootblock.o, mas esse arquivo ainda não pode ser utilizado dessa forma. O segundo comando executa o linker (ld) para gerar o binário executável final (but not so much, como veremos a seguir). As opções passadas para o linker foram:
-nostartfiles -nostdlib tem a mesma função do -fno-builtin dito anteriormente, não usar a libc e similares.
-melf_i386 indica o formato do executável, no caso um ELF para x86.
-Ttext 0x0 é um caso interessante, pois este indica o endereço na memória que o segmento de texto (portanto, o código) deve residir. Contudo, já foi dito que este primeiro trecho de código é colocado pela BIOS no endereço absoluto 0x7c00. Então, para que se importar? Primeiro, haverá um próximo passo, não abordado nesse artigo, que irá condensar o bootblock e o kernel em uma “imagem” só que será usada. Isso pode ser visto na imagem acima no comando ./createimage.given —extended ./bootblock ./kernel. O segundo motivo é que as instruções de jump dentro do código do boot podem ser absolutas ou relativas a instrução atual. Dependendo de como você faz o jump no código, ele pode ir para um endereço inválido. Mas a real REAL mesmo é que a imagem é uma coleção de bytes contíguos, e como temos o código bootloader + kernel gerados pelo programa createimage.given, iriamos acabar com o bootloader no endereço 0x7c00 e o kernel no endereço 0x1000, antes portanto, do próprio bootloader! Isso de fato é um problema real para o bootloader, pois se o kernel for muito grande, ele pode começar no endereço 0x1000 e ultrapassar o endereço 0x7c00, sobreescrevendo o bootloader na memória. Isso é uma das coisas que iremos resolver depois inclusive. Mas, agora, precisamos apenas que o compilador e linker não reclamem desses endereços em tempo de compilação, e portanto colocamos o código do bootloader no arquivo ELF no endereço 0x0.
E o que a gente faz com todas essas informações? Vamos começar a montar nosso Lego.
Pré-requisitos
Eu estou usando o Linux no WSL no meu Windows 11. Instalei basicamente os pacotes do QEMU, NASM e GDB com o comando abaixo:
sudo apt install aqemu qemu-system qemu-system qemu-system-x86 virt-manager bridge-utils nasm gdb
Infelizmente não tenho como garantir que mais algum pacote necessário tenha sido instalado anteriormente em outros momentos. Talvez o gcc.
Mas isso não é suficiente para poder rodar o QEMU usando o KVM no WSL. Para isso, ainda é necessário seguir os passos daqui:
Adicione o seu usuário ao grupo kvm com o comando abaixo:
sudo usermod -a -G kvm ${USER}
Altere o grupo default do dispositivo /dev/kvm. Para isso edite o arquivo /etc/wsl.conf e adicione a linha abaixo na seção boot:
[boot] command = /bin/bash -c 'chown -v root:kvm /dev/kvm && chmod 660 /dev/kvm'
Obs: é possível que já exista outras instruções nesse bloco, nesse caso, apenas adicione a linha indicada.
Habilite a virtualização aninhada. Para isso, adicione a seguinte linha no bloco wsl2 no arquivo /etc/wsl.conf (se esse bloco não existir, crie-o):
[wsl2] nestedVirtualization=true
No fim das contas, o meu arquivo /etc/wsl.conf ficou assim (o seu pode ter ficado diferente dependendo do sistema):
[boot] systemd=true command = /bin/bash -c 'chown -v root:kvm /dev/kvm && chmod 660 /dev/kvm' [wsl2] nestedVirtualization=true
Feche todos os terminais WSL abertos, abra um Power Shell no windows e reinicie o WSL:
wsl.exe --shutdown
Isso foi suficiente para o QEMU funcionar com KVM na minha máquina.
Nosso bootloader
A primeira alteração será mudar a extensão do arquivo de .s para .asm, já que estamos usando o NASM em vez do Gnu Assembler. O segundo ponto é que eu deliberadamente alterei o nome do arquivo de bootblock.s para bootloader.asm, afinal, why not?
Não verifiquei o Makefile da imagem acima, e não sou um especialista em Makefiles, então bear with me, please. Nosso Makefile fica assim (por enquanto!):
all: bootloader
bootloader.o: bootloader.asm
nasm -wall -O2 -f elf32 -F dwarf -g $<
bootloader: bootloader.o
ld -nostdlib -g -m elf_i386 -Ttext 0x0 --oformat binary -o $@ $<
clean:
rm -rf *.o bootloader
Feio né? É o que tem pra hoje. Eu queria muito entender daquelas paradas avançadas de Makefile, .PHONY etc, mas fiz uma busca rápida aqui para usar algumas coisas pelo menos e por enquanto, vamos usar só o $@
e $<
. Um dia eu fico bom nisso (ou não, não é minha prioridade :D )
Vamos então entender esse Makefile. Esse tipo de arquivo é executado de forma recursiva. Começando pelo target all, ele buscará o target bootloader. O bootloader, por sua vez, espera que exista o target bootloader.o. O bootloader.o espera que exista o arquivo bootloader.asm. Esse é diretamente compilado pelo comando nasm -wall -O2 -f elf32 -F dwarf -g $<
. Para qualquer par de targets e pré-requisitos (neste caso, estamos falando do target bootloader.o
e do pré-requisito bootloader.asm
na linha 3 do Makefile, é possível especificar o target com $@ e o primeiro pré-requisito com $<. Nesse caso então $< é substituído pelo primeiro pré-requisito do target, e no caso só temos um, bootloader.asm
. Se houve mais de um pré-requisito e se quiséssemos referenciar todos, usaríamos a diretiva $^. Não vou entrar a fundo nisso, até porque não tenho o conhecimento necessário, mas essas dicas que estou dizendo foram tiradas daqui.
O comando do target bootloader.o
não é muito diferente do original da imagem acima, com exceção do parâmetro -F dwarf,
que apenas indica o formato para geração de informações de debugging. Para mais detalhes disso, veja com o comando nasm —help
.
Com o bootloader.o criado, o próximo target executado é o bootloader,
que invoca o linker na linha 7. O comando também é como o anterior, exceto no formato de saída: --oformat binary.
Ou seja, não vamos gerar um ELF, mas sim um binary raw
que será executado diretamente pelo emulador. Isso por enquanto, essa situação será alterada no futuro, e o formato de saída desse comando voltará a ser um ELF.
Observe ainda o final do comando: -o $@ $<.
Isso indica que a saída/output do comando será o parâmetro $@,
que como vimos é o target desse trecho do Makefile e no nosso caso, equivale a bootloader.
E a diretiva $<
é o primeiro dos pré-requisitos do target, e neste caso só há um, bootloader.o.
Isso significa que -o $@ $<
é diretamente traduzido para -o bootloader bootloader.o.
O target clean serve para fazer as limpezas de praxe. Como esse Makefile só gera dois arquivos, bootloader.o e bootloader, é suficiente para o nosso caso executar o comando rm -rf *.o bootloader.
Eu gosto de executar os comandos make clean && make simultaneamente, para limpar lixo de execuções passadas e já executar uma compilação nova. A saída desse comando ficou assim:
Nesse momento, os arquivos bootloader.o e bootloader devem aparecer no seu sistema de arquivos.
Temos mais dois arquivos auxiliares para a execução de nosso bootloader. O primeiro é o VM_BoringOS.sh, um arquivo inicialmente criado pelo AQEMU mas que eu adaptei para nosso caso. O conteúdo desse arquivo é o seguinte:
#!/bin/sh
# This script created by AQEMU
CURRDIR=`pwd`
/usr/bin/qemu-system-x86_64 -machine accel=kvm -m 16 -drive file="$CURRDIR/bootloader",index=0,if=floppy,format=raw -boot once=a,menu=on -net nic -net user -rtc base=utc -name "BoringOS" -cpu host -s -S $*
O comando define a variável de ambiente CURRDIR como o diretório do projeto (onde se encontra os arquivos bootloader e bootloader.o). Solicitamos o tipo de virtualização kvm, e 16MB de RAM. Solicitamos que o arquivo bootloader (nosso executável) seja usado como imagem do floppy disk (disquete, para os íntimos) em formato raw. Ele define vários outros valores desimportantes para o momento, mas precisei adicionar o final do comando: -cpu host -s -S. -cpu host
indica que o cpu do meu host seja usado (pois ele possui atributos de virtualização), -s habilita a depuração pela porta padrão 1234/tcp, e -S congela a execução assim que a máquina é ligada, aguardando a continuação da execução do comando pelo debugger.
Ao executar o arquivo com o comando ./VM_BoringOS.sh, devemos ver a tela abaixo:
Neste momento o QEMU está pausado aguardando instruções. O bootloader ainda nem foi carregado.
O próximo arquivo auxiliar é o debug_BoringOS.sh. Esse arquivo deve ser executado depois do anterior, para que o gdb possa se atachar ao QEMU e possa emitir comandos.
O conteúdo do arquivo VM_BoringOS.sh é o seguinte:
#!/bin/sh
gdb -ex 'target remote localhost:1234' -ex 'set confirm off' -ex 'add-symbol-file bootloader.o 0x7c00' -ex 'set confirm on' -ex 'hbreak *_start' -ex 'c'
Perceba que usamos o gdb para se conectar no QEMU (1234/tcp), desligamos momentaneamente as confirmações e adicionamos os símbolos de depuração do arquivo bootloader.o
alinhado no endereço
0x7c00 (lembre-se que na realidade o segmento de texto está no endereço 0x0). Depois religamos as confirmações e definimos um breakpoint para o endereço _start. Usamos o hbreak *, que significa hardware assisted breakpoint, pois estamos executando em um ambiente virtualizado do KVM. Se fosse diferente disso, o breakpoint seria definido no endereço virtual 0x7c00 que está relacionado ao processo do QEMU, mas não ao nosso bootloader virtualizado. Enfim, hbreak *0x7c00
também funcionaria nesse caso.
Considerando que o comando ./VM_BoringOS.sh foi executado anteriormente e está pausando aguardando comandos, executando o comando ./debug_BoringOS.sh, temos:
Do lado esquerdo, temos que o QEMU adiciona os símbolos do bootloader.o e define o breakpoint de _start. Ele então manda o QEMU continuara execução, e o QEMU, do lado direito, executa o POST/BIOS, busca e encontra o setor de boot no disquete montando (nosso arquivo bootloader). Ele então carrega o conteúdo desses 512 bytes a partir do endereço 0x7c00 e faz um far jump para esse endereço. Nesse momento, o QEMU encontra nosso breakpoint definido anteriormente, para a execução ai.
Ao disparar comandos s (ou step) no gdb, navegamos pelos nossos comandos do arquivo bootloader.asm, e o resultado é um S impresso na tela do QEMU, que é o que queríamos fazer desde o início!
Quanta trabalheira só para isso. Boring, não é mesmo? No próximo artigo, vamos criar o código para juntar o bootloader e um kernel safado, baseado no createimage do Projeto 1. O código pode ser encontrado nessa tag aqui no github ou nesse commit específico.
É o nome do executável do Gnu Assembler na linha de comando do Linux
É a instrução especial xchg bx, bx, que, quando emulado pelo Bochs, automaticamente faz o código parar para depuração. Até onde eu sei, o QEMU não tem nada similar.