Não existe printf aqui embaixo
Meus primeiros passos em assembly x86, interrupções da BIOS, setores de boot e a estranha constatação de que não existe mais um sistema operacional embaixo de mim.
Passei a maior parte do meu tempo programando em cima de um monte de coisas que eu não sei direito como funcionam por baixo.
Sistemas operacionais, runtimes, bibliotecas, frameworks.
Elas simplesmente estão ali.
Recentemente, decidi tentar construir um sistema operacional minúsculo em assembly x86. Estou usando a série Building an OS, do Nanobyte, como referência enquanto tento entender o que de fato estou digitando.
Ele ainda não tem nome.
E, sinceramente, ainda não entendo a maior parte disso.
Mas estou ridiculamente empolgado.
Então, estas são basicamente as primeiras linhas que escrevi:
org 0x7C00bits 16org 0x7C00 diz ao NASM para calcular rótulos e endereços de memória assumindo que nosso código será carregado no endereço 0x7C00.
Por que 0x7C00?
Porque é ali que uma BIOS tradicional carrega o setor de boot antes de passar o controle para ele.
Depois temos:
bits 16que diz ao NASM para gerar instruções x86 de 16 bits.
Nesse ponto, o processador está rodando em modo real.
Eu costumava escrever código assumindo que existia um sistema operacional inteiro embaixo de mim.
Agora estou escrevendo a coisa que roda antes mesmo de o sistema operacional existir.
Isso é esquisito.
E extremamente legal.
Aparentemente uma quebra de linha precisa de dois números
Em seguida, escrevi isto:
%define ENDL 0x0D, 0x0ANo começo, eu não fazia a menor ideia de por que se chamava ENDL.
O Nanobyte chamou assim, então eu também chamei.
Imagino que signifique algo como "end line".
Não é uma palavra-chave especial do NASM nem nada do tipo. É só uma constante que nós mesmos definimos.
0x0D é um carriage return e 0x0A é um line feed.
Juntos, eles formam uma nova linha.
Aparentemente até apertar Enter tem lore.
Onde tudo começa
Depois:
start: jmp mainstart é só um rótulo.
A primeira coisa que o bootloader faz é pular para main.
E jmp significa literalmente jump, ou seja, pular.
Finalmente, uma coisa aqui é exatamente tão idiotamente simples quanto parece.
Minha primeira rotina
Depois eu precisava de um jeito de imprimir uma string.
Vou chamar isto de rotina porque, aparentemente, já sou baixo nível o bastante para que chamar tudo de "função" pareça suspeito.
puts: push si push axEsta rotina vai modificar SI e AX, então salvamos os valores atuais deles na pilha primeiro.
Mais tarde vamos restaurá-los.
Já com saudade das variáveis locais.
Aí chegamos à parte que mais me confundiu:
.loop: lodsb or al, al jz .done mov ah, 0x0E mov bh, 0x00 int 0x10 jmp .loopVamos tentar entender essa bagunça.
lodsb carrega um byte do endereço apontado por DS:SI para o registrador AL.
Depois, move SI para o próximo byte.
Então, basicamente, estamos percorrendo nossa string um caractere por vez.
Só que existe um pequeno detalhe.
Se lodsb avança ou recua depende de algo chamado Direction Flag.
Então, antes de usá-lo, eu limpo explicitamente essa flag mais adiante com:
cldIsso garante que SI avance.
Essa já é uma das coisas que estou começando a gostar na programação de baixo nível.
Seu código pode funcionar dependendo de algum estado da CPU que você nem sabia que existia.
Depois temos isto:
or al, aljz .doneIsso me pareceu completamente inútil.
Estamos fazendo uma operação OR entre AL e ele mesmo.
O valor não muda.
Então... por quê?
Porque as flags da CPU mudam.
Se AL contém 0, a Zero Flag é ativada.
Então o jz, que significa "pular se for zero", nos manda para .done.
E por que nos importamos com um zero?
Porque a string termina com um byte nulo.
Então isto:
or al, aljz .doneé basicamente a nossa versão amaldiçoada, em assembly, de:
if character == 0: stopAparentemente tiraram de mim os comandos if.
Agora vem a parte que realmente imprime alguma coisa:
mov ah, 0x0Emov bh, 0x00int 0x10A interrupção 0x10 da BIOS, função 0x0E, oferece um serviço de saída teletype.
O caractere que queremos imprimir já está em AL.
Colocamos 0x0E em AH para selecionar a função da BIOS e depois chamamos:
int 0x10E a BIOS imprime o caractere para a gente.
Sem biblioteca padrão.
Sem emulador de terminal.
Sem printf.
Estou literalmente pedindo para a BIOS colocar um caractere na tela.
Não sei por quê, mas essa é uma das coisas mais legais que programei em muito tempo.
Depois:
jmp .loope fazemos tudo de novo.
Um caractere por vez, até encontrarmos o byte nulo.
Então é isso que ret faz
Quando terminamos:
.done: pop ax pop si retOs pops restauram os registradores que salvamos antes de entrar na rotina.
Mas ret era outra instrução que eu tinha digitado, no começo, sem saber direito o que diabos ela estava fazendo.
Começou a fazer sentido quando olhei para isto:
call putscall não pula simplesmente para puts.
Antes de pular, ele salva na pilha o endereço da próxima instrução.
Depois, quando puts chega em:
retret pega esse endereço de volta da pilha e a execução continua de onde a rotina foi chamada.
Então call e ret juntos nos dão algo que começa a se parecer um pouco mais com as chamadas de função a que estou acostumado.
A pilha já está fazendo muito mais trabalho do que eu esperava.
Por fim, main
Agora chegamos aqui:
main: mov ax, 0 mov ds, ax mov es, axEstamos inicializando nossos registradores de segmento.
Com DS em zero, os endereços ficam bem mais fáceis de eu raciocinar por enquanto.
Depois inicializamos a pilha:
mov ss, axmov sp, 0x7C00O ponteiro da pilha começa em 0x7C00.
E as pilhas x86 crescem para baixo.
O que é engraçado, porque o nosso próprio bootloader acabou de ser carregado em 0x7C00.
Então nosso código fica acima desse endereço, enquanto a pilha começa a crescer para dentro da memória abaixo dele.
Depois adicionei:
cldpara garantir que instruções de string como lodsb avancem pela memória.
Por fim:
mov si, msg_hellocall putsSI agora aponta para a nossa mensagem:
msg_hello: db 'Hello, World!', ENDL, 0E esse 0 final é importante.
É o byte nulo que o nosso loop está procurando.
puts percorre a mensagem um byte por vez, pede para a BIOS imprimir cada caractere e para quando chega nesse zero.
Depois:
hlthlt diz ao processador para parar de executar instruções até que uma interrupção o acorde.
E logo depois disso tenho:
.halt: jmp .haltAssim, se a execução continuar, apenas pulamos para o mesmo lugar para sempre, em vez de deixar a CPU começar a executar, sem querer, quaisquer bytes que por acaso venham depois do nosso código.
Nunca achei um loop infinito tão reconfortante.
512 bytes
E aí tem provavelmente a minha parte favorita:
times 510 - ($ - $$) db 0dw 0xAA55Um setor de boot de BIOS tradicional ocupa exatamente 512 bytes.
Isto:
times 510 - ($ - $$) db 0preenche o espaço restante com zeros até o byte 510.
Depois escrevemos:
dw 0xAA55Como o x86 é little-endian, esses bytes aparecem no binário final como:
55 AAEsses são os dois últimos bytes do setor.
A BIOS verifica essa assinatura para reconhecer o setor como inicializável.
Então, pelo que entendo hoje, a conversa é basicamente:
eu: aqui estão 512 bytesBIOS: os dois últimos são 55 AA?eu: simBIOS: serve pra mimO que parece uma quantidade absurda de confiança para depositar em alguém que aprendeu o que ret faz há cinco minutos.
E, de algum jeito, ele deu boot
Estou escrevendo tudo isso no Vim e rodando dentro do QEMU.
Montei o setor de boot, iniciei a máquina virtual e, depois de um tempo, vi:
Hello, World!Só isso.
Treze caracteres.
Provavelmente a saída menos impressionante com a qual já me empolguei tanto.
Mas dessa vez não havia Node por baixo.
Nem JVM.
Nem libc.
Nem sistema operacional.
Nem printf.
Só uma CPU acordando em modo real de 16 bits, um código que eu escrevi e uma BIOS que, aparentemente, estava disposta a confiar em mim.
Ainda não entendo uma parte enorme do que está acontecendo aqui.
E essa provavelmente é a melhor parte.
Existe tanta coisa por baixo das abstrações que uso todo dia e em que eu nunca parei para pensar de verdade.
Por enquanto, tenho 512 bytes.
E, de algum jeito, eles dão boot.
Continue lendo
Comentários
Com tecnologia do GitHub Discussions.
Os comentários carregam do giscus (GitHub Discussions) quando você chega a esta seção.