
Então, direto ao ponto. Escreveremos no Linux, no NASM e usando o QEMU. Como é fácil de instalar, pule esta etapa.
Entende-se que o leitor está familiarizado com a sintaxe do NASM pelo menos no nível básico (no entanto, não haverá nada particularmente complicado aqui) e entende o que são registros.
Teoria básica
A primeira coisa que inicia o processador quando o computador é ligado é o código do BIOS (ou UEFI, mas aqui vou falar apenas sobre o BIOS), que é "conectado" na memória da placa-mãe (especificamente, em 0xFFFFFFF0).
Imediatamente após ligar o BIOS, o POST (Power-On Self-Test) é iniciado - autoteste após a ativação. O BIOS verifica a integridade da memória, detecta e inicializa os dispositivos conectados, verifica os registros, determina o tamanho da memória e assim por diante.
O próximo passo é identificar o disco de inicialização a partir do qual você pode inicializar o sistema operacional. Um disco de inicialização é um disco (ou qualquer outra unidade) que possui os últimos 2 bytes do primeiro setor (o primeiro setor significa os primeiros 512 bytes da unidade, porque 1 setor = 512 bytes) é 55 e AA (no formato hexadecimal). Assim que um disco de inicialização for encontrado, o BIOS carregará seus primeiros 512 bytes na RAM no endereço 0x7c00 e transferirá o controle para o processador nesse endereço.
Obviamente, nesses 512 bytes, não funcionará para se ajustar a um sistema operacional completo. Portanto, geralmente nesse setor, coloque o carregador primário, que carrega o código principal do sistema operacional na RAM e transfere o controle para ele.
Desde o início, o processador estava sendo executado no modo Real (= modo de 16 bits). Isso significa que ele só pode funcionar com dados de 16 bits e usa endereçamento de memória segmentada e também pode endereçar apenas 1 MB de memória. Mas não usaremos o segundo aqui. A imagem abaixo mostra o estado da RAM ao transferir o controle para o nosso código (a foto é tirada daqui ).

A última coisa a dizer antes da parte prática são as interrupções. Uma interrupção é um sinal especial (por exemplo, de um dispositivo de entrada, como um teclado ou mouse) para um processador que diz que é necessário interromper imediatamente a execução do código atual e executar o código do manipulador de interrupções. Todos os endereços dos manipuladores de interrupção estão localizados na Tabela de descritores de interrupção (IDT) na memória principal. Cada interrupção possui seu próprio manipulador de interrupções. Por exemplo, quando uma tecla do teclado é pressionada, uma interrupção é chamada, o processador para, lembra o endereço da instrução interrompida, salva todos os valores de seus registros (na pilha) e prossegue para executar o manipulador de interrupções. Assim que sua execução termina, o processador restaura os valores dos registradores e retorna à instrução interrompida e continua a execução.
Por exemplo, para exibir algo na tela, o BIOS usa a interrupção 0x10 (formato hexadecimal) e a interrupção 0x16 é usada para aguardar que uma tecla seja pressionada. De fato, todas essas interrupções são necessárias aqui.
Além disso, cada interrupção tem sua própria subfunção que determina a peculiaridade de seu comportamento. Para exibir algo no formato de texto (!), É necessário inserir o valor 0x0e no registro AH. Além disso, as interrupções têm seus próprios parâmetros. 0x10 recebe valores de ah (define uma subfunção específica) e al (o caractere a ser impresso). Desta maneira
mov ah, 0x0e mov al, 'x' int 0x10
exibe o caractere 'x'. 0x16 pega o valor de ah (subfunção específica) e carrega o valor da chave inserida no registro al. Usaremos a função 0x0.
Parte prática
Vamos começar com o código auxiliar. Vamos precisar da função de comparar duas linhas e da função de exibir uma linha na tela. Tentei descrever o funcionamento dessas funções nos comentários o mais claramente possível.
str_compare.asm:
compare_strs_si_bx: push si ; push bx push ax comp: mov ah, [bx] ; , cmp [si], ah ; ah jne not_equal ; , cmp byte [si], 0 ; , je first_zero ; inc si ; bx si inc bx jmp comp ; first_zero: cmp byte [bx], 0 ; bx != 0, , jne not_equal ; , not_equal mov cx, 1 ; , cx = 1 pop si ; pop bx pop ax ret ; not_equal: mov cx, 0 ; , cx = 0 pop si ; pop bx pop ax ret ;
A função aceita os registros SI e BX como parâmetros. Se as linhas forem iguais, CX será definido como 1, caso contrário, 0.
Também é importante notar que os registradores AX, BX, CX e DX são divididos em duas partes de byte único: AH, BH, CH e DH para o byte alto e AL, BL, CL e DL para o byte baixo.
Inicialmente, entende-se que em bx e si existem ponteiros (!) (Ou seja, armazena o endereço na memória) para algum endereço na memória em que o início da linha está localizado. A operação [bx] pegará um ponteiro de bx, irá para esse endereço e terá algum valor a partir daí. inc bx significa que agora o ponteiro fará referência ao endereço imediatamente após o endereço original.
print_string.asm:
print_string_si: push ax ; ax mov ah, 0x0e ; ah 0x0e, call print_next_char ; pop ax ; ax ret ; print_next_char: mov al, [si] ; cmp al, 0 ; si jz if_zero ; int 0x10 ; al inc si ; jmp print_next_char ; ... if_zero: ret
Como parâmetro, a função pega o registro SI e byte byte imprime uma string.
Agora vamos para o código principal. Primeiro, vamos definir todas as variáveis (esse código estará no final do arquivo):
; 0x0d - , 0xa - wrong_command: db "Wrong command!", 0x0d, 0xa, 0 greetings: db "The OS is on. Type 'help' for commands", 0x0d, 0xa, 0xa, 0 help_desc: db "Here's nothing to show yet. But soon...", 0x0d, 0xa, 0 goodbye: db 0x0d, 0xa, "Goodbye!", 0x0d, 0xa, 0 prompt: db ">", 0 new_line: db 0x0d, 0xa, 0 help_command: db "help", 0 input: times 64 db 0 ; - 64 times 510 - ($-$$) db 0 dw 0xaa55
O caractere de retorno de carro move o carro para a borda esquerda da tela, ou seja, para o início da linha.
input: times 64 db 0
significa que alocamos 64 bytes sob o buffer para entrada e os preenchemos com zeros.
O restante das variáveis é necessário para exibir algumas informações; mais abaixo no código, você entenderá por que todas elas são necessárias.
times 510 - ($-$$) db 0 dw 0xaa55
significa que definimos explicitamente o tamanho do arquivo de saída (com a extensão .bin) para 512 bytes, preenchemos os primeiros 510 bytes com zeros (é claro, eles são preenchidos antes da execução do código inteiro) e os últimos dois bytes com os mesmos bytes "mágicos" 55 e AA . $ significa o endereço da instrução atual e $$ é o endereço da primeira instrução do nosso código.
Vamos para o código atual:
org 0x7c00 ; (1) bits 16 ; (2) jmp start ; start %include "print_string.asm" ; %include "str_compare.asm" ; ==================================================== start: mov ah, 0x00 ; (3) mov al, 0x03 int 0x10 mov sp, 0x7c00 ; (4) mov si, greetings ; call print_string_si ; mainloop
(1) Este comando deixa claro para o NASM que estamos executando o código começando em 0x7c00. Isso permite polarizar automaticamente todos os endereços relativos a esse endereço, para que não façamos isso explicitamente.
(2) Este comando instrui o NASM que estamos operando no modo de 16 bits.
(3) Quando lançado, o QEMU imprime muitas informações que não precisamos. Para fazer isso, defina ah 0x00, al 0x03 e chame 0x10 para limpar a tela de tudo.
(4) Para salvar registros na pilha, você deve especificar em qual endereço seu vértice será localizado usando o ponteiro da pilha SP. SP indicará a área na memória na qual o próximo valor será gravado. Adicione o valor à pilha - o SP diminui a memória em 2 bytes (já que estamos no Modo Real, onde todos os operandos de registro são de 16 bits, ou seja, valores de byte duplo). Como especificamos 0x7c00, os valores na pilha serão armazenados ao lado do código na memória. Mais uma vez - a pilha cresce (!). Isso significa que quanto mais valores houver na pilha, menos memória o ponteiro da pilha SP indicará.
mainloop: mov si, prompt ; call print_string_si call get_input ; jmp mainloop ; mainloop...
Laço principal. Aqui, com cada iteração, imprimimos o caractere ">", após o qual chamamos a função get_input, que implementa o trabalho com interrupção do teclado.
get_input: mov bx, 0 ; bx input_processing: mov ah, 0x0 ; 0x16 int 0x16 ; ASCII cmp al, 0x0d ; enter je check_the_input ; , , ; cmp al, 0x8 ; backspace je backspace_pressed cmp al, 0x3 ; ctrl+c je stop_cpu mov ah, 0x0e ; - ; int 0x10 mov [input+bx], al ; inc bx ; cmp bx, 64 ; input je check_the_input ; , enter jmp input_processing ;
(1) [input + bx] significa que pegamos o endereço do início da entrada do buffer de entrada e adicionamos bx a ele, ou seja, chegamos ao bx + o 1º elemento do buffer.
stop_cpu: mov si, goodbye ; call print_string_si jmp $ ; ; $
Tudo é simples aqui - se você pressionou Ctrl + C, o computador executa a função jmp $ sem parar.
backspace_pressed: cmp bx, 0 ; backspace , input , je input_processing ; mov ah, 0x0e ; backspace. , int 0x10 ; , mov al, ' ' ; , int 0x10 ; mov al, 0x8 ; int 0x10 ; backspace dec bx mov byte [input+bx], 0 ; input jmp input_processing ;
Para não apagar o caractere '>' ao pressionar backspace, verificamos se a entrada está vazia. Caso contrário, não faça nada.
check_the_input: inc bx mov byte [input+bx], 0 ; , ; ( '\0' ) mov si, new_line ; call print_string_si mov si, help_command ; si help mov bx, input ; bx - call compare_strs_si_bx ; si bx ( help) cmp cx, 1 ; compare_strs_si_bx cx 1, ; je equal_help ; => ; help jmp equal_to_nothing ; , "Wrong command!"
Aqui, acho que tudo fica claro com os comentários.
equal_help: mov si, help_desc call print_string_si jmp done equal_to_nothing: mov si, wrong_command call print_string_si jmp done
Dependendo do que foi inserido, exibimos o texto da variável help_desc ou o texto da variável de comando errado.
; done input done: cmp bx, 0 ; input je exit ; , mainloop dec bx ; , mov byte [input+bx], 0 jmp done ; exit: ret
Na verdade, todo o código é:
prompt.asm:
org 0x7c00 bits 16 jmp start ; start %include "print_string.asm" %include "str_compare.asm" ; ==================================================== start: cli ; , ; mov ah, 0x00 ; mov al, 0x03 int 0x10 mov sp, 0x7c00 ; mov si, greetings ; call print_string_si ; mainloop mainloop: mov si, prompt ; call print_string_si call get_input ; jmp mainloop ; mainloop... get_input: mov bx, 0 ; bx input_processing: mov ah, 0x0 ; 0x16 int 0x16 ; ASCII cmp al, 0x0d ; enter je check_the_input ; , , ; cmp al, 0x8 ; backspace je backspace_pressed cmp al, 0x3 ; ctrl+c je stop_cpu mov ah, 0x0e ; - ; int 0x10 mov [input+bx], al ; inc bx ; cmp bx, 64 ; input je check_the_input ; , enter jmp input_processing ; stop_cpu: mov si, goodbye ; call print_string_si jmp $ ; ; $ backspace_pressed: cmp bx, 0 ; backspace , input , je input_processing ; mov ah, 0x0e ; backspace. , int 0x10 ; , mov al, ' ' ; , int 0x10 ; mov al, 0x8 ; int 0x10 ; backspace dec bx mov byte [input+bx], 0 ; input jmp input_processing ; check_the_input: inc bx mov byte [input+bx], 0 ; , ; ( '\0' ) mov si, new_line ; call print_string_si mov si, help_command ; si help mov bx, input ; bx - call compare_strs_si_bx ; si bx ( help) cmp cx, 1 ; compare_strs_si_bx cx 1, ; je equal_help ; => ; help jmp equal_to_nothing ; , "Wrong command!" equal_help: mov si, help_desc call print_string_si jmp done equal_to_nothing: mov si, wrong_command call print_string_si jmp done ; done input done: cmp bx, 0 ; input je exit ; , mainloop dec bx ; , mov byte [input+bx], 0 jmp done ; exit: ret ; 0x0d - , 0xa - wrong_command: db "Wrong command!", 0x0d, 0xa, 0 greetings: db "The OS is on. Type 'help' for commands", 0x0d, 0xa, 0xa, 0 help_desc: db "Here's nothing to show yet. But soon...", 0x0d, 0xa, 0 goodbye: db 0x0d, 0xa, "Goodbye!", 0x0d, 0xa, 0 prompt: db ">", 0 new_line: db 0x0d, 0xa, 0 help_command: db "help", 0 input: times 64 db 0 ; - 64 times 510 - ($-$$) db 0 dw 0xaa55
Para compilar tudo isso, digite o comando:
nasm -f bin prompt.asm -o bootloader.bin
E nós obtemos o binário com nosso código na saída. Agora execute o emulador QEMU com este arquivo (-monitor stdio permite exibir o valor do registro a qualquer momento, usando o comando print $ reg):
qemu-system-i386 bootloader.bin -monitor stdio
E obtemos a saída:
