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.