Se você ainda não conferiu o post anterior, corre lá para preparar o projeto. Sigamos.
Eu estava meio destreinado em assembly, e uma das etapas do Projeto 1 é fazer o Design Review. Nele é pedido para fazermos as funções print_char e print_string em assembly. Ótima oportunidade! Estávamos escrevendo um caractere na tela no post anterior, mas convenhamos, aquilo não é exatamente uma função certo? Um dos pré-requisitos de uma função é ser chamada com a instrução CALL, portanto, mudanças seriam necessárias.
Havia outras coisas que eu propositalmente deixei incompleto, no post anterior eu não mexia na pilha nem nos registradores de segmento. Mas se agora vamos definir funções formalmente, então é hora também de ajustarmos tais valores.
Ao começar a mexer no código, tive muito problema pois o código carregado pelos símbolos do arquivo bootloader.o não correspondiam às instruções que o gdb me mostrava. Isso é o equivalente a traduzir um texto escrito em alemão com um dicionário japonês. Sem conseguir resolver isso, eu não conseguiria avançar muito.
O problema principal é que esse código inicial é um código 16-bit em modo real, mas o NASM está gerando código ELF32. Até ai tudo bem, já que minhas instruções não usam registradores de 32bit ainda. O linker também está gerando código elf_i386, e enquanto o otimizador não fizer nenhuma gracinha, até aqui estamos bem.
O problema começa quando esse código é carregado no QEMU e atachado ao GDB. Estava usando o qemu-system-x86_64, e isso por si só não é um problema devido a retro compatibilidade da arquitetura x86_64, mas o GDB alterava a instrução que me era apresentada. Havia ocasiões em que registradores de 32bit me eram apresentados quando rodava o layout asm no GDB (do nada aparecia um eax), e eu levei muito tempo para entender o que estava acontecendo.
Primeiro, por enquanto pelo menos, eu alterei o qemu-system-x86_64 para qemu-system-i386. Isso não é suficiente. Segundo, preciso indicar para o NASM gerar código de 16bit. Isso é feito com a diretiva [BITS 16] no código bootloader.asm.
Segundo, a visualização padrão do GDB é muito ruim. Ou pelo menos eu achei algo BEM melhor aqui! Dessa forma conseguimos altera a visualização do GDB para que ele mostra uma espécie de visualização Real Mode 16bit, exatamente o que nós queremos!
Vamos ver as mudanças então.
Makefile
Criei uma pasta src, e movi o código fonte para lá (por enquanto o arquivo bootloader.asm). Também criei uma pasta de build, onde os arquivos objetos e executáveis são colocados. O Makefile foi adaptado para usar essas duas novas pastas.
Pasta gdb_util e arquivo debug_BoringOS.sh
Os arquivos helper para alterar a visualização do GDB, obtidos aqui, foram incluídos na pasta gdb_util. O comando que invoca o gdb foi alterado para contemplar tais arquivos:
gdb -ix "$CURRDIR/gdb_util/gdb_init_real_mode.txt" -ex "set tdesc filename $CURRDIR/gdb_util/target.xml" -ex 'target remote localhost:1234' -ex 'set confirm off' -ex "add-symbol-file $CURRDIR/build/bootloader.o 0x7c00" -ex 'set confirm on' -ex 'hbreak *_start' -ex 'c'
Observe o antes e depois de usar esses helpers. Antes:
Depois:
A diferença é brutal, eu consigo acompanhar muito melhor o conteúdo dos registradores e algumas partes da memória.
print_char e print_string
Indo ao ponto que importa, organizei melhor o código assembly. Defini algumas constantes e as coloquei no arquivo constants.asm. O conteúdo desse arquivo está assim:
%define STACK_SEG_BOOTLOADER 0x9000
%define STACK_POINTER_BOOTLOADER 0xffff
%define SEGMENTS_BOOTLOADER 0x7c0
%define BIOS_INT_10H 0x10
%define BIOS_INT_10H_OUTPUT 0x0e
%define BIOS_INT_10H_PAGE_NUMBER_0 0x00
%define BIOS_INT_10H_FOREGROUND_COLOR_GM 0x02
%define NULL_CHARACTER 0
%define LINE_FEED 10
%define CARRIER_RETURN 13
%define BOOT_SIGNATURE db 0x55, 0xaa
Vamos explicar alguns dos valores, mesmo que de forma superficial agora. Primeiro, as duas primeiras constantes dizem respeito à pilha. Segundo o trabalho: “Your bootloader can safely modify memory in the range [0x0a00, 0xa0000) without having to worry about overwriting video memory, the interrupt vector table, or BIOS.” Então, eu joguei a pilha para o endereço 0x9ffff (STACK_SEG_BOOTLOADER:STACK_POINTER_BOOTLOADER
), um byte a menos que o valor limite 0xa0000. Como a pilha decresce, temos um bom espaço sem problemas, inclusive quando realocarmos o bootloader em um endereço mais alto.
Estamos ajustando os registradores de segmento também, com a constante SEGMENTS_BOOTLOADER
, em especial os registradores ds e es. Isso porque a função print_string usa a instrução lodsb, que utiliza esses registradores implicitamente. Como os valores buscados estão no mesmo segmento do código que inicia em 0x7c00, nada mais justo que ajustar tais registradores para esses valores também.
As constantes para a chamada de escrita de caractere no vídeo dispensam comentários. Em caso de dúvida, sugiro verificar aqui ou aqui.
Depois, há algumas definições para manipulação de string (NULL_CHARACTER, LINE_FEED e CARRIER_RETURN),
e quem já trabalhou com strings em C ou similar sabem do que se trata. Na dúvida, consulte alguma Tabela ASCII.
Por fim eu defini a constante da assinatura do boot.
O código bootloader.asm, já incluindo as constantes, ficou assim:
[BITS 16]
%include "src/constants.asm"
global _start
_start:
jmp load_kernel
print_char:
push bp
mov bp, sp
push ax
push bx
mov ax, [bp+4]
mov ah, BIOS_INT_10H_OUTPUT
mov bh, BIOS_INT_10H_PAGE_NUMBER_0
mov bl, BIOS_INT_10H_FOREGROUND_COLOR_GM
int BIOS_INT_10H
pop bx
pop ax
pop bp
ret
print_string:
push bp
mov bp, sp
push ax
push bx
push si
mov si, [bp+4]
loop_print_string:
lodsb
cmp al, NULL_CHARACTER
je end_print_string
push ax
call print_char
pop ax
jmp loop_print_string
end_print_string:
pop si
pop bx
pop ax
pop bp
ret
load_kernel:
mov ax, STACK_SEG_BOOTLOADER
mov ss, ax
mov sp, STACK_POINTER_BOOTLOADER
mov ax, SEGMENTS_BOOTLOADER
mov ds, ax
mov es, ax
mov ax, 'p'
push ax
call print_char
pop ax
mov ax, msg
push ax
call print_string
pop ax
jmp $
msg db "Hello, world", CARRIER_RETURN, LINE_FEED, NULL_CHARACTER
TIMES 510-($-$$) DB 0
BOOT_SIGNATURE
O código começa e já pula para o label load_kernel,
desviando da definição das funções print_char e print_string. O código define então os registradores de segmento e ponteiro da pilha.
Depois, testaremos a função print_char. Defini que a convenção das funções segue o padrão ABI i386, ou seja, os parâmetros são passados na pilha. Isso porque no futuro quero ligar minhas funções com código em C, então preciso seguir a convenção que os compiladores já conhecem.
Então, empilhamos o parâmetro ‘p’ e invocamos a função print_char. Para quem não sabe, a instrução CALL empilha o endereço da próxima instrução na pilha e desvia para o label print_char. Para foco, a função é repetida para melhor entendimento:
print_char:
push bp
mov bp, sp
push ax
push bx
mov ax, [bp+4]
mov ah, BIOS_INT_10H_OUTPUT
mov bh, BIOS_INT_10H_PAGE_NUMBER_0
mov bl, BIOS_INT_10H_FOREGROUND_COLOR_GM
int BIOS_INT_10H
pop bx
pop ax
pop bp
ret
A função começa ajustando o registro de ativação (isto é, ajustando o valor do registrador bp), e como vamos manipular os registrados ax e bx, estes também são empilhados para restauração posterior. O parâmetro informado é então recuperado da pilha ([bp+4]) e a BIOS é invocada, como já visto. Depois que o caractere é impresso, os registradores ax e bx são restaurados, e o antigo valor de bp também é restaurado. A instrução RET é executada, o que implica em desempilhar o endereço da próxima instrução da pilha (este endereço havia sido empilhado pela instrução CALL, lembra)? Ao retornar, como havíamos empilhado o parâmetro, desempilhamos para que a pilha fique no mesmo estado antes da chamada.
Observe que a chamada para a função print_string acontece da mesmíssima forma. A diferença é que, ao invés de termos empilhado os “valores” que queremos imprimir (como foi o caso do parâmetro ‘p’), agora passamos o endereço de memória de uma string que possui terminação nula (0, ou NULL_CHARACTER definido anteriormente), ou seja, a velha convenção de strings em C. Focando na função print_string:
print_string:
push bp
mov bp, sp
push ax
push bx
push si
mov si, [bp+4]
loop_print_string:
lodsb
cmp al, NULL_CHARACTER
je end_print_string
push ax
call print_char
pop ax
jmp loop_print_string
end_print_string:
pop si
pop bx
pop ax
pop bp
ret
Perceba que a função não é tão diferente assim da função print_char. A diferença é que agora vamos usar o registrador si, e por isso ele é empilhado para restauração posterior.
O endereço da string é então movido para este endereço (instrução mov si, [bp+4]
), e a instrução lodsb
é executada. Essa instrução pega o conteúdo da memória presente no endereço ds:si e coloca no registrador al. Depois disso, si é incrementado, indo portanto para o próximo caractere automaticamente.
Precisamos testar se o registrador al possui o valor 0/NULL_CHARACTER,
indicando que chegamos ao fim da string. Em caso positivo, vamos para o fim da função para limpeza e retorno. Em caso negativo, há um caractere válido para a impressão no registrador al. Nesse caso, podemos usar a função print_char
para imprimir esse elemento, certo? Basta apenas empilhar esse parâmetro e invocar a função print_char1
e retornar para o rótulo loop_print_string,
para continuar carregando mais caracteres.
Como resultado, temos a string “pHello, world” seguindo do cursor na outra linha da tela. É exatamente o que queremos para o momento!
O código pode ser encontrado nessa branch no meu github.
Não estamos preocupados com desempenho no momento. Obviamente invocar a função print_char
para cada caractere, fazendo manipulações de pilha e chamadas custosas como CALL poderiam deteriorar um sistema de produção. Nesse caso, seria melhor duplicar o código da print_char no trecho da chamada da função da BIOS. Ou mesmo escrever diretamente na memória de vídeo no endereço 0xB8000. Em todo caso, o importante agora é a legibilidade do código, até porque esse código deve desaparecer depois.