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 PS2outb
: 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
:
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:
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
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.