Implementando função de print - Criando um sistema operacional em rust EP2

No ultimo episódio, nós configuramos um projeto rust para construir o nosso sistema operacional/kernel e configuramos o ambiente de desenvolvimento.

Neste epísodio vamos implementar a nossa função de print e println para conseguirmos ver o que o nosso kernel está a fazer e spoiler alert: conseguimos implementar um test runner customizado e printar os resultados dos testes unitários do nosso kernel.

Como vai ser implementado?

Se você estiver pensando em printar na tela, nós vamos cobrir renderização de texto mais para a frente. O bootloader limine tinha um recurso que permitia printar na tela, mas ele foi descontinuado e removido na versão 5.x por problemas e riscos de segurança. Por enquanto vamos utilizar um recurso do qemu que nos permite printar no stdout do terminal do host: UART 16550 ou Porta Serial.

Sobre portas em x86

Na arquitetura x86_64, as "portas" referem-se aos endereços que são usados para se comunicar com dispositivos de hardware. Cada dispositivo é atribuído a um conjunto específico de portas. As instruções in e out são usadas para ler e escrever dados nessas portas.

Para podermos comunicar como chip UART, vamos precisar de criar 2 funções que abstraem estas 2 instruções em assembly:

  • inb: Essa instrução é usada para ler um byte de uma porta de entrada específica. Por exemplo, inb 0x60 pode ser usado para ler o byte de um teclado PS2
  • outb: Esta instrução é usada para escrever um byte em uma porta de saída específica. Por exemplo, outb 0x80, al pode ser usado para enviar um byte para a porta 0x80.

Então vamos criar funções para ler e escrever nas portas!

Para manter as coisas organizadas, crie uma pasta em src/arch/x86_64 para colocar tudo o que for especifico a x86_64, não se esqueça de declarar os modulos se não o rust não vai encontrar os arquivos. Se você não sabe como pastas e modulos funcionam no rust, faça o favor de ler a documentação/livro do rust.

Então dentro dessa pasta crie um arquivo chamado ports.rs:

use core::arch::asm;

/// Escrever um byte em uma porta
///
/// # Safety
/// Isto é `unsafe` porque o usuário desta função precisa garantir que a porta e data estão corretos, caso contrário, pode gerar comportamento indefinido.
pub unsafe fn write(port: u16, data: u8) {
    asm!(
        // Escrever o que está no registrador `dx` para `al`
        "out dx, al",
        // Colocar os parametros `port` e `data` dentro dos registradores `dx` e `al` respectivamente
        in("dx") port,
        in("al") data,
        options(nomem, nostack, preserves_flags)
    );
}

/// Ler um byte de uma porta
/// 
/// # Safety
/// Isto é `unsafe` porque o usuário desta função precisa garantir que a porta está correta, caso contrário, pode gerar comportamento indefinido.
pub unsafe fn read(port: u16) -> u8 {
    // Declarar uma variavel para receber a informação que vamos ler da porta
    let mut data;
    asm!(
        // Instrução para ler da porta que está no registrador `dx` para `al`
        "in al, dx",
        // Colocar o que estiver no registrador `al` dentro da variavel data depois de executar a instrução
        out("al") data,
        // Colocar o parametro `port` dentro do registrador `dx`
        in("dx") port,
        options(nomem, nostack, preserves_flags)
    );
    data
}

É bem simples se você prestou atenção aos comentários que eu escrevi no código, se não leu, faça o favor, não apenas copie e cole.

Agora com isso feito, podemos prosseguir com a implementação da função de print.

Inicializando o UART

Agora vamos criar outro arquivo chamado serial.rs e vamos criar uma função para inicializar o chip UART:

+ pub fn init() {
+     unsafe {
+        // ...
+     }
+ }

A primeira coisa que precisamos de fazer, é desativar interrupts. Interrupts são como eventos para o processador, quando algum evento ou erro acontece, o processador interrompe o que estava a fazer e chama um interrupt handler em uma tabela chama IDT (Interrupt Descriptor Table) que nós podemos configurar, nós iremos ver interrupts com mais detalhes nos próximos episódios.

Quando estamos a configurar o chip UART, nenhum interrupt handler deve ser acionado durante o processo, porque pode quebrar tudo.

Então vamos fazer isso, algumas portas no processador que podemos utilizar para comunicar com o chip UART:

+ pub const DATA_PORT: u16 = 0x3F8u16;
+ pub const INTERRUPT_ENABLE_PORT: u16 = DATA_PORT + 1;
+ pub const FIFO_CONTROL_PORT: u16 = DATA_PORT + 2;
+ pub const LINE_CONTROL_PORT: u16 = DATA_PORT + 3;
+ pub const MODEM_CONTROL_PORT: u16 = DATA_PORT + 4;
+ pub const LINE_STATUS_PORT: u16 = DATA_PORT + 5;
+ 
pub fn init() {
    unsafe {
        // ...
    }
}

Você provavelmente percebeu que definimos a constante para uma porta chamada INTERRUPT_ENABLE, e não é atoa, essa é a porta que vamos utilizar para desativar os interrupts:

pub fn init() {
    unsafe {
+        write(INTERRUPT_ENABLE_PORT, 0);
    }
}

Ao mandar um 0 para essa porta, conseguimos desativar os interrupts vindos do chip UART.

A 2ª coisa que precisamos de fazer, é configurar a velocidade máxima de transmissão. Para isso precisamos de ativar o DLAB para ter o acesso aos registradores DLL (Divisor Latch Low) e DLM (Divisor Latch Medium), que são usados para configurar a taxa de transmissão:

// Ativar o DLAB
write(LINE_CONTROL_PORT, 0x80);
// Usar o DLL and DLM para configurar a velocidade máxima para 38400 bits por segundo
write(DATA_PORT, 0x03);
// Ter a certeza que interrupts ainda estão desativados.
//
// Alguns computadores podem ativar os interrupts
// automaticamente depois de configurar a velocidade.
write(INTERRUPT_ENABLE_PORT, 0x00);
// Desativa o DLAB (Divisor Latch Access Bit) e configura o tamanho da palavra de dados para 8 bits
// Isso significa que os dados transmitidos e recebidos terão um comprimento de 8 bits
write(LINE_CONTROL_PORT, 0x03);

Outra coisa que provavelmente vamos querer ativar é o FIFO (First In, First Out), que é um buffer para armazenar dados temporariamente.

Essa configuração ajuda a otimizar o fluxo de dados:

write(FIFO_CONTROL_PORT, 0xc7);

Agora podemos informar o chip UART que todas as configurações foram feitas e ele pode começar a trabalhar, e tambem voltar a ativar os interrupts:

write(MODEM_CONTROL_PORT, 0x0b);
write(INTERRUPT_ENABLE_PORT, 0x01);

Acabamos com a inicialização, apenas nos resta chamar a nossa função de inicialização na função _start no main.rs:

mod arch;

#[no_mangle]
pub extern "C" fn _start() -> ! {
    #[cfg(target_arch = "x86_64")]
    arch::x86_64::serial::init();
    loop {}
}

Aqui adicionalmente usei um #[cfg(...)] para apenas compilar essa linha quando estamos compilando para a arquitetura x86_64, porque isso em arm não vai funcionar, é um processo diferente.

Printando na porta serial

Para printar na porta serial é muito simples, só precisamos de mandar todos os caracteres na DATA_PORT:

pub fn print_str(s: &str) {
    for b in s.bytes() {
        unsafe { write(DATA_PORT, b) };
    }
}

Isso provavelmente vai funcionar sem nenhum problema, mas uma coisa importante é a gente esperar o UART conseguir mandar os bytes que nos printamos antes de mandar o próximo, em algumas maquinas mais lentas podemos sobrecarregar a transmissão se fizermos desse jeito.

Então vamos criar uma função io_wait:

fn io_wait() {
  const OUTPUT_EMPTY_BIT: u8 = 1 << 5;
  while (unsafe { read(LINE_STATUS_PORT) } & (OUTPUT_EMPTY_BIT)) == 0 {}
}

Em seguida podemos modifica a nossa função para printar uma string e incorporar a nossa função io_wait:

pub fn print_str(s: &str) {
    for b in s.bytes() {
+        io_wait();
        unsafe { write(DATA_PORT, b) };
    }
}

Testando a nossa função de print

Agora podemos voltar para a nossa função main e tentar usar a nossa função de print:

arch::x86_64::serial::print_str("Olá mundo!");

Se você fez tudo corretamente, deverá ver Olá mundo! no terminal que você rodou cargo run: img

Ufa! Nos livramos da maldição... Mas ainda não acabamos!

Criando o macro print! e println!

Nós ainda vamos criar o macro print! e println! para poder formatar valores e printar no terminal.

Vamos criar um arquivo chamado src/print.rs e colocar coisas relacionadas a print lá dentro.

Nós podemos implementar a função write_str na trait core::fmt::Writer em uma struct, e assim ganhamos a função write_fmt de graça que podemos usar para criar os macros print! e println!:

struct Writer;

impl core::fmt::Write for Writer {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        #[cfg(target_arch = "x86_64")]
        {
            crate::arch::x86_64::serial::print_str(s);
        }
        #[cfg(target_arch = "aarch64")]
        {
            todo!("printar no UART em arm")
        }
        Ok(())
    }
}

Uma coisa que devemos fazer é deixar apenas 1 thread por vez acessar o nosso writer por vez. Por isso vamos usar a biblioteca spin, que implementa um Mutex que podemos usar sem a standard library.

cargo add spin

Com o spin, agora podemos criar um Writer global dentro de um Mutex para garantir que não tem 2 threads escrevendo ao mesmo tempo:

static WRITER: Mutex<Writer> = Mutex::new(Writer);

Agora podemos criar uma função para printar no nosso writer usando os argumentos de formatação passados para ele, já que o writer global é privado e a struct tambem, essa função vai ser a unica maneira para conseguir printar no terminal:

pub fn _print(args: fmt::Arguments) {
    // Bloquear o mutex do writer, assim as outras threads vão esperar nós 
    // acabar o serviço aqui
    let mut writer = WRITER.lock();
    // Usar a função write_fmt da trait Writer para formatar e printar na tela
    // usando a nossa implementação da função `write_str`
    core::fmt::Write::write_fmt(&mut *writer, args).ok();
}

Apesar de não termos multiplas CPUs rodando nem temos threads nem scheduler nem nada, isso não vai ter valor nenhum por enquanto. No futuro quando adicionarmos threads e processos no nosso sistema operacional, isso vai começar a ter importancia, porque não queremos que vários processos escrevam ao mesmo tempo causando data races e bugs de memoria. Agora, os macros print! e println! vão apenas ser açucar de sintaxe para chamar esta função que acabamos de criar:

#[macro_export]
macro_rules! print {
    ($($t:tt)*) => { $crate::io::_print(format_args!($($t)*)) };
}

#[macro_export]
macro_rules! println {
    ()          => { $crate::print!("\n"); };
    ($($t:tt)*) => { $crate::print!("{}\n", format_args!($($t)*)); };
}

No macro, simplesmente passamos todos os argumentos para o macro format_args!, e ai só espetamos isso na nossa função _print e pronto!

Outra coisa que podemos fazer tambem é adicionar o atributo #[macro_use] em cima da declaração modulo io no arquivo main.rs para termos o print! e println! disponivel globalmente no código do nosso kernel:

+ #[macro_use]
mod print;

Agora podemos testar substituindo a chamada direta para o serial por apenas println!:

#[no_mangle]
pub extern "C" fn _start() -> ! {
    #[cfg(target_arch = "x86_64")]
    arch::x86_64::serial::init();
-    arch::x86_64::serial::print_str("Olá mundo!");
+    let a = "mundo";
+    println!("Olá {a}!");
    loop {}
}

Vamos rodar cargo run e ver o que acontece: img

Como você pode ver, no nosso terminal ainda temos Olá mundo! sendo printado, o que significa que está tudo funcionando corretamente e ainda podemos formatar valores igual fazemos com programas rust normais.

Conclusão

Neste episódio cobrimos como usar o chip UART para printar no terminal do qemu para conseguirmos debugar o kernel, aprendemos como implementar a trait Writer para conseguir formatar valores e como criar o macro print! e println! e nos livramos da maldição do Olá mundo!.

Se tiver algum problema, ou só quiser conversar sobre rust e OSDev, entre no meu servidor no discord Rust Lusófono: https://discord.gg/BpbkksEN !

No proximo episodio eu planejo fazer como configurar um test runner para conseguirmos escrever testes automatizados para o nosso kernel e rodar usando cargo test, vamos ver como corre!

See yaa!!

Tem previsão para a parte 3? Esse texto é de sua autoria? Percebi alguns termos em portugues de portugal, ou parecendo tradução literal

sim, e realmente meu, eu sou de portugal

Muito bom mesmo. É legal ver coisas que vi bem de forma teórica na universidade, como o UART e Mutex, sendo aplicadas na prática e em outro cenário. Show de mais. Parabéns.