Configurando o ambiente - Criando um sistema operacional em rust EP1

Estes dias tava pensando em criar um sistema operacional. Eu tenho familiaridade com rust. o Windows e Linux usam rust no kernel nos drivers, e eu quero descer no buraco do coelho de como sistemas operacionais funcionam.

Este e o começo de uma série de posts mostrando a minha jornada para criar o tinyx: tiny Unix, mostrando todos as descobertas que faço pelo caminho e mostrando todo o processo de aprendizado para vocês aprenderem junto comigo.

Porquê criar um sistema operacional?

Como expliquei a cima, criar um sistema operacional e uma ótima ideia se você quer entender como o seu sistema operacional e o seu computador funcionam e como gerenciam os recursos do seu computador.

O que um sistema operacional faz?

Primeiro precisamos de pensar no que e um sistema operacional, um sistema operacional deve fazer as seguintes coisas:

  • Criar uma plataforma para permitir que os processos realizem tarefas e interajam com o hardware facilmente
  • Gerenciar e dividir os recursos da máquina entre todos os processos
  • Permitir que o usuário facilmente utilize o computador através de um Shell ou interface gráfica (GUI)
  • (Opcional) Diminuir os previlegios dos processos pra impedir que acessem coisas que não devem acessar como memória de outros processos, do kernel, ou tentar mudar o estado ou código do kernel de alguma forma.

O que vamos fazer?

Vamos criar um sistema operacional Unix-like em rust, que suporta as arquitecturas ARM (64 bits, usado em celulares e micro computadores como raspberry pi) e x86_64 (Muito provavelmente a arquitetura do computador que você tá usando pra ler esse post).

Ready. Set. GOOO!

A primeira coisa que precisamos de fazer e criar um projeto rust, básico.

Configurando o ambiente

Vamos precisar do rust e do rustup instalado no computador então verifique isso antes:

cargo -V
rustc -V
rustup --version

Se algum destes comandos falharem, verifique que instalou o rust através do rustup.

Criando o projeto

Vamos fazer o clássico comando para criar um projeto rust:

$ cargo new tinyx

Com este comando, nós acabanis de criar uma aplicação em rust normal, so que tem um mal, a crate std:

No ambiente que nós vamos programar, não temos um sistema operacional, a crate std do rust que vem por padrão e uma biblioteca que nos ajuda a interagir com o sistema operacional, por isso depende de um sistema operacional.

O que significa que precisamos de desativar o std.

Felizmente no rust é simples desativar ela, é só adicionar 2 linhas:

+ #![no_std]
+ #![no_main]
fn main() {
    println!("Hello world");
}

Isto irá desativar a biblioteca padrão do rust, e outra biblioteca chamada core vai tomar o seu lugar.

Certo, desativamos a biblioteca padrão do rust, vamos tentar comp...

~/tinyx $ cargo b
   Compiling tinyx v0.1.0 (/data/data/com.termux/files/home/tinyx)
error: cannot find macro `println` in this scope
 --> src/main.rs:3:5
  |
3 |     println!("Hello, world!");
  |     ^^^^^^^

error: `#[panic_handler]` function required, but not found

error: language item required, but not found: `eh_personality`
  |
  = note: this can occur when a binary crate with `#![no_std]` is compiled for a target where `eh_personality` is defined in the standard library
  = help: you may be able to compile for a target that doesn't need `eh_personality`, specify a target with `--target` or in `.cargo/config`

error: could not compile `tinyx` (bin "tinyx") due to 3 previous errors
~/tinyx $

Ue? deu 3 erro?

Calma, não se assuste ainda.

Sobre binários sem std

Como você percebeu, deu 3 erros diferentes. o macro println não existe mais, já que não temos terminal para printar, nós precisamos de implementar a nossa função de print, nós iremos fazer isso no próximo episódio. Por enquanto remova essa linha

#![no_std]
#![no_main]
fn main() {
-    println!("Hello world");
}

Panic handler

Mas vamos dar uma olhada no 2° erro:

error: `#[panic_handler]` function required, but not found

Em ambientes sem std, nós tambem perdemos o handler padrão dos panics, nós precisamos de implementar o nosso panic handler.

O panic handler é uma função que o rust chama sempre que o macro panic!() é chamado, ela é responsável por desligar o programa e informar o erro ao utilizador.

Então vamos criar o nosso panic handler:

pub fn hcf() -> ! {
	loop {
		core::hint::spin_loop();
	}
}
  

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    hcf();
}

Acredito que esse ! possa ser um pouco estranho já que é pouco falado, o ! é o tipo never do typescript se você for de typescript. Ele significa que essa função nunca retorna um valor, o tipo ! é impossivel de ser instanciado e não existem valores de tipo !.

Item eh_personality

O item eh_personality do rust é uma função que o rust chama para fazer unwinding da stack quando dá panic. Ela deve fazer uma serie de coisas como chamar os desconstrutores de todas as variaveis, printar o backtrace de todas as funções que foram chamadas até dar panic.

Infelizmente isso é demasiado complicado implementar para o estagio que nós estamos neste momento, por isso vamos desativar stack unwinding, mudando o modo de panic para abort no Cargo.toml:

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

Mudando para abort, o rust não vai mais tentar fazer stack unwinding ao entrar em panico, fazendo o eh_personality não ser mais necessário.

error: requires `start` lang_item

Como estamos sem a biblioteca padrão do rust, não temos um runtime para chamar a nossa função main.

Em aplicações rust normais, o real ponto de entrada é dentro da biblioteca padrão, porque ele precisa de configurar o ambiente primeiro para o programa rodar e depois chama a função main. Por exemplo, as funções que pegam as variaveis de ambiente e os argumentos em std::env são inicializadas dentro da standard library antes da função main ser chamada.

No nosso caso, nós temos que criar o ponto de entrada:

- fn main() {
-     println!("Hello world");
- }
+ #[no_mangle]
+ pub extern "C" fn _start() -> ! {
+     loop {}
+ }

Configurando a toolchain

Nós precisamos de criar um binário que não depende de um sistema operacional, por isso precisamos de mudar o target padrão para x86_64-unknown-none.

Para mudar o target padrão, podemos criar um arquivo em .cargo/config.toml e colocar o seguinte:

[build]
target = "x86_64-unknown-none"

Com isso, agora vamos conseguir compilar o nosso codigo para um binário completamente independente de um sistema operacional, tambem chamado de binário freestanding.

Para o tinyx, vamos utilizar esse rust-toolchain.toml

[toolchain]
channel = "nightly-2023-11-17"
components = ["rust-src", "rustc", "rustfmt", "cargo", "clippy"]
targets = ["x86_64-unknown-none", "aarch64-unknown-none"]

Se não está sabendo, você pode definir a versão, componentes e targets que precisam estar instalados no seu rustup em um arquivo chamado rust-toolchain.toml.

Com isso fora do caminho, como que nós ligamos o nosso sistema operacional? Isso nos leva a...

Preparando o bootloader

O bootloader é o pedaço de software que o firmware, ou antigamente, BIOS, inicia, ele é responsavel por procurar o sistema operacional no seu armazenamento e iniciar o kernel, fornecendo-lhe coisas como informações de quanta memoria tem no computador, que regiões de memória o sistema operacional pode usar, um endereço para o framebuffer para conseguir desenhar na tela, endereço do PCI para conseguir listar e controlar dispositivos e várias outras coisas.

O bootloader que vamos utilizar é o limine, um bootloader moderno, portavel, que suporta vários protocolos de boot, incluindo um próprio, tambem chamado limine (antigamente chamado stivale2), que vamos utilizar no nosso kernel.

Configurar o limine para o nosso kernel em rust requer alguns passos.

Primeiro precisamos de instalar a crate do limine para conseguirmos puxar as informações do sistema através do limine:

[dependencies]
+ limine = "0.1.12"

Depois disso, precisamos de criar um script para o linker conseguir organizar o nosso binário no formato que o limine precisa para rodar o nosso kernel corretamente. Eu criei 2 arquivos em conf/linker-x86_64.ld e conf/linker-aarch64.ld:

conf/linker-aarch64.ld

ENTRY(_start)
OUTPUT_ARCH(arm:aarch64)
OUTPUT_FORMAT(elf64-aarch64)

KERNEL_BASE = 0xffffffff80000000;

SECTIONS {
    . = KERNEL_BASE + SIZEOF_HEADERS;

    .hash                   : { *(.hash) }
    .gnu.hash               : { *(.gnu.hash) }
    .dynsym                 : { *(.dynsym) }
    .dynstr                 : { *(.dynstr) }
    .rela                   : { *(.rela*) }
    .rodata                 : { *(.rodata .rodata.*) }
    .note.gnu.build-id      : { *(.note.gnu.build-id) }
    .eh_frame_hdr           : {
        PROVIDE(__eh_frame_hdr = .);
        KEEP(*(.eh_frame_hdr))
        PROVIDE(__eh_frame_hdr_end = .);
    }
    .eh_frame               : {
        PROVIDE(__eh_frame = .);
        KEEP(*(.eh_frame))
        PROVIDE(__eh_frame_end = .);
    }
    .gcc_except_table       : { KEEP(*(.gcc_except_table .gcc_except_table.*)) }

    . += CONSTANT(MAXPAGESIZE);

    .plt                    : { *(.plt .plt.*) }
    .text                   : { *(.text .text.*) }

    . += CONSTANT(MAXPAGESIZE);

    .tdata                  : { *(.tdata .tdata.*) }
    .tbss                   : { *(.tbss .tbss.*) }

    .data.rel.ro            : { *(.data.rel.ro .data.rel.ro.*) }
    .dynamic                : { *(.dynamic) }

    . = DATA_SEGMENT_RELRO_END(0, .);

    .got                    : { *(.got .got.*) }
    .got.plt                : { *(.got.plt .got.plt.*) }
    .data                   : { *(.data .data.*) }
    .bss                    : { *(.bss .bss.*) *(COMMON) }

    . = DATA_SEGMENT_END(.);

    .comment              0 : { *(.comment) }
    .debug                0 : { *(.debug) }
    .debug_abbrev         0 : { *(.debug_abbrev) }
    .debug_aranges        0 : { *(.debug_aranges) }
    .debug_frame          0 : { *(.debug_frame) }
    .debug_funcnames      0 : { *(.debug_funcnames) }
    .debug_info           0 : { *(.debug_info .gnu.linkonce.wi.*) }
    .debug_line           0 : { *(.debug_line) }
    .debug_loc            0 : { *(.debug_loc) }
    .debug_macinfo        0 : { *(.debug_macinfo) }
    .debug_pubnames       0 : { *(.debug_pubnames) }
    .debug_pubtypes       0 : { *(.debug_pubtypes) }
    .debug_ranges         0 : { *(.debug_ranges) }
    .debug_sfnames        0 : { *(.debug_sfnames) }
    .debug_srcinfo        0 : { *(.debug_srcinfo) }
    .debug_str            0 : { *(.debug_str) }
    .debug_typenames      0 : { *(.debug_typenames) }
    .debug_varnames       0 : { *(.debug_varnames) }
    .debug_weaknames      0 : { *(.debug_weaknames) }
    .line                 0 : { *(.line) }
    .shstrtab             0 : { *(.shstrtab) }
    .strtab               0 : { *(.strtab) }
    .symtab               0 : { *(.symtab) }
}

conf/linker-x86_64.ld:

ENTRY(_start)
OUTPUT_ARCH(i386:x86-64)
OUTPUT_FORMAT(elf64-x86-64)

KERNEL_BASE = 0xffffffff80000000;

SECTIONS {
    . = KERNEL_BASE + SIZEOF_HEADERS;

    .hash                   : { *(.hash) }
    .gnu.hash               : { *(.gnu.hash) }
    .dynsym                 : { *(.dynsym) }
    .dynstr                 : { *(.dynstr) }
    .rela                   : { *(.rela*) }
    .rodata                 : { *(.rodata .rodata.*) }
    .note.gnu.build-id      : { *(.note.gnu.build-id) }
    .eh_frame_hdr           : {
        PROVIDE(__eh_frame_hdr = .);
        KEEP(*(.eh_frame_hdr))
        PROVIDE(__eh_frame_hdr_end = .);
    }
    .eh_frame               : {
        PROVIDE(__eh_frame = .);
        KEEP(*(.eh_frame))
        PROVIDE(__eh_frame_end = .);
    }
    .gcc_except_table       : { KEEP(*(.gcc_except_table .gcc_except_table.*)) }

    . += CONSTANT(MAXPAGESIZE);

    .plt                    : { *(.plt .plt.*) }
    .text                   : { *(.text .text.*) }

    . += CONSTANT(MAXPAGESIZE);

    .tdata                  : { *(.tdata .tdata.*) }
    .tbss                   : { *(.tbss .tbss.*) }

    .data.rel.ro            : { *(.data.rel.ro .data.rel.ro.*) }
    .dynamic                : { *(.dynamic) }

    . = DATA_SEGMENT_RELRO_END(0, .);

    .got                    : { *(.got .got.*) }
    .got.plt                : { *(.got.plt .got.plt.*) }
    .data                   : { *(.data .data.*) }
    .bss                    : { *(.bss .bss.*) *(COMMON) }

    . = DATA_SEGMENT_END(.);

    .comment              0 : { *(.comment) }
    .debug                0 : { *(.debug) }
    .debug_abbrev         0 : { *(.debug_abbrev) }
    .debug_aranges        0 : { *(.debug_aranges) }
    .debug_frame          0 : { *(.debug_frame) }
    .debug_funcnames      0 : { *(.debug_funcnames) }
    .debug_info           0 : { *(.debug_info .gnu.linkonce.wi.*) }
    .debug_line           0 : { *(.debug_line) }
    .debug_loc            0 : { *(.debug_loc) }
    .debug_macinfo        0 : { *(.debug_macinfo) }
    .debug_pubnames       0 : { *(.debug_pubnames) }
    .debug_pubtypes       0 : { *(.debug_pubtypes) }
    .debug_ranges         0 : { *(.debug_ranges) }
    .debug_sfnames        0 : { *(.debug_sfnames) }
    .debug_srcinfo        0 : { *(.debug_srcinfo) }
    .debug_str            0 : { *(.debug_str) }
    .debug_typenames      0 : { *(.debug_typenames) }
    .debug_varnames       0 : { *(.debug_varnames) }
    .debug_weaknames      0 : { *(.debug_weaknames) }
    .line                 0 : { *(.line) }
    .shstrtab             0 : { *(.shstrtab) }
    .strtab               0 : { *(.strtab) }
    .symtab               0 : { *(.symtab) }
}

Isso é um monte de coisa, mas relaxa que você não vai precisar de se preocupar com isso.

Agora não basta apenas adicionar os arquivos, o compilador do rust não vai usar eles sozinho, para isso vamos criar um arquivo build.rs para fazer o cargo adicionar os scripts para o linker, e direcionando para o arquivo certo dependendo do target que estivermos a compilar:

use std::{env, error::Error};
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
	// Pegar o nome do nosso projeto
	let kernel_name = env::var("CARGO_PKG_NAME")?;
	// Pegar para qual arquitetura estamos compilando o kernel
	let arch = env::var("CARGO_CFG_TARGET_ARCH")?;
	// Fazer o cargo adicionar o linker script certo dependendo da arquitetura
	match arch.as_str() {
		"x86_64" => {
			println!("cargo:rustc-link-arg-bin={kernel_name}=--script=conf/linker-x86_64.ld");
		}
		"aarch64" => {
			println!("cargo:rustc-link-arg-bin={kernel_name}=--script=conf/linker-aarch64.ld");
		}
		other_arch => todo!("{other_arch} is not implemented yet"),
	}
	// Mandar o cargo rodar o nosso build.rs sempre que mudarmos o nome do projeto ou a arquitetura
	println!("cargo:rerun-if-env-changed=CARGO_PKG_NAME");
	println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_ARCH");
	Ok(())
}

Se fizemos tudo corretamente, agora o nosso kernel vai ser compilado em um formato que o limine entende.

Outro arquivo que precisamos de criar é a configuração para o limine, que felizmente é simples, é so 6 linhas: conf/limine.conf

TIMEOUT=0
SERIAL=yes
VERBOSE=yes
: Tinyx
PROTOCOL=limine
KERNEL_PATH=boot:///tinyx

Este arquivo de configuração deve ser colocada dentro da imagem ISO para o limine conseguir encontrar o executavel do nosso kernel.

Configurando o comando cargo run para ligar uma maquina virtual

Nós vamos utilizar uma funcionalidade do cargo que permite mudar como o comando cargo run se comporta.

No nosso caso, nós precisamos de testar o nosso sistema operacional dentro de uma maquina virtual, que requer compilar o nosso kernel, criar uma imagem com uma configuração para o limine, com o proprio limine e qualquer coisa que formos precisar no futuro. Fazer isso manualmente é, pouco divertido para dizer o minimo.

Por isso eu tenho alguns scripts no meu repositorio que você pode simplesmente baixar para o seu projeto, mas se poder, faça o favor de ler o que eles fazem.

Para isso funcionar precisamos do make, o xorriso para criar a imagem ISO, e da maquina virtual qemu para x86_64 bits e aarch64.

Em ubuntu/debian podemos instalar essas coisas facilmente:

$ sudo apt install build-essential xorriso qemu-system-x86 qemu-system-arm qemu-efi-aarch64 ovmf

Agora com os scripts dentro da pasta .cargo e com as dependenias instaladas, podemos configurar o cargo para usar os nossos runners no arquivo config.toml:

[build]
target = "x86_64-unknown-none"
+ 
+ [target.aarch64-unknown-none]
+ runner = ".cargo/runner-aarch64.sh"
+ [target.x86_64-unknown-none]
+ runner = ".cargo/runner-x86_64.sh"

Se você fez tudo corretamente, quando rodar cargo run, deve abrir uma maquina virtual com o seu sistema operacional rodando dentro!

Conclusão

Conseguimos configurar o compilador para não incluir a biblioteca padrão, criar binários freestanding, fazer o binario estar num formato que o bootloader entende, e ainda configuramos o cargo para criar um maquina virtual com o nosso sistema operacional automaticamente quando damos cargo run.

Esse foi um post bem denso, ainda não consegui nem fazer arranhar a superficie do que vamos fazer com isso. Espero que tenham gostado, e esperem pelos proximos episodios.

Conteúdo sensacional! Parabéns pelo projeto e qualidade do seu post. Vou acompanhar a série do OS.

Hoje em dia está cada vez mais difícil de achar gente falando sobre programação baixo nível, só existe webdev parece. Ai você juntou Rust + desenvolvimento se sistemas operacionais + UNIX ( inspiração ou seguir convenções imagino ). Meus olhos brilharam já no segundo parágrafo.

Uma sugestão de quem gosta desse tipo de coisa: uma wiki de desenvolvimento de sistemas operacionais. Me ajudou muito a entrar nesse universo.

Abraço!

Conteúdo fenomenal. Não entendo nada de Rust, mas adoro essas questões de low level e entender melhor como as coisas funcionam por baixo dos panos. Estarei no aguardo pelo próximo episódio :)

Tá aí uma coisa que sempre tive curiosidade e vontade de fazer mas é muito avançado pra mim.

Ansioso pelos próximos episódios.

E pelo fato de ser complicado e ter pouca documentação que eu estou a fazer esta serie. Eu vou tentar explicar tudo de um jeito simples de entender, porque parece que o pessoal de OSDev gosta muito de fazer gate keeping e eu quero mostrar que qualquer um consegue criar um sistema operacional.

Incrível, gostaria muito que o Tabnews tivesse mais conteúdos densos e diferentes como esse! Realmente isso não é coisa que vemos todo dia!

Parabéns mano, seu conteúdo vai me ajudar muito sobre entender como funciona os sistemas operacionais. Já tinha certo interesse em saber sobre sistemas operacionais. Seu conteúdo vai me ajudar muito.

Cara que massa esse projeto, vou acompanhar com toda certeza,e alem disso, vou fazer o meu com base na sua série 😁

Caramba, que sensacional. Recentemente decidi estudar Rust, e estou gostando bastante. E ter achado essa postagem criando um sistema operacional usando Rust me deixou bem empolgado. Vou acompanhar. Sucesso.

vou aconpanhar de pertinho, pois isso é algo que senpre pensei na possibilidade mas nunca pensei em algo na pratica mesmo, parabens e continue nos atualizando.

Que sensacional! Parabéns pela iniciativa, muito bacana ver um BR fazendo isso. Espero que tenha disposição para ir longe no projeto!

Amei o conteúdo!

Bem explicado! Rust é uma linguagem que estou aprendendo a amar, e ver conteúdos assim me motivam a continuar estudando hehehe, espero que continue com o conteúdo.

muito bom pela iniciativa sou iniciate na linguagem rust gostaria de dar uma paracela de contruibuição nesse projeto na qualidade de iniciante na linguagem rust!!

Muito legal cara! Por favor, continue a série!

Cade a continuação ?