Executando verificação de segurança...
46

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.

5

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!

1

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.

1

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 :)

1

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

Ansioso pelos próximos episódios.

1

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.

1
1

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.

1
1

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.

1

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.

1
0
0