Escrevendo um OS em Rust

Philipp Oppermann's blog

Um Kernel Rust Mínimo

Conteúdo Traduzido: Esta é uma tradução comunitária do post A Minimal Rust Kernel. Pode estar incompleta, desatualizada ou conter erros. Por favor, reporte qualquer problema!

Traduzido por @richarddalves.

Neste post, criamos um kernel Rust mínimo de 64 bits para a arquitetura x86. Construímos sobre o binário Rust independente do post anterior para criar uma imagem de disco inicializável que imprime algo na tela.

Este blog é desenvolvido abertamente no GitHub. Se você tiver algum problema ou dúvida, abra um issue lá. Você também pode deixar comentários na parte inferior. O código-fonte completo desta publicação pode ser encontrado na branch post-02.

Tabela de Conteúdos

🔗O Processo de Boot

Quando você liga um computador, ele começa a executar código de firmware que está armazenado na ROM da placa-mãe. Este código executa um teste automático de inicialização, detecta a RAM disponível e pré-inicializa a CPU e o hardware. Depois, ele procura por um disco inicializável e começa a inicializar o kernel do sistema operacional.

No x86, existem dois padrões de firmware: o “Basic Input/Output System” (BIOS) e o mais novo “Unified Extensible Firmware Interface” (UEFI). O padrão BIOS é antigo e ultrapassado, mas simples e bem suportado em qualquer máquina x86 desde os anos 1980. UEFI, em contraste, é mais moderno e tem muito mais recursos, mas é mais complexo de configurar (na minha opinião, pelo menos).

Atualmente, fornecemos apenas suporte para BIOS, mas suporte para UEFI também está planejado. Se você gostaria de nos ajudar com isso, confira o issue no Github.

🔗Boot BIOS

Quase todos os sistemas x86 têm suporte para boot BIOS, incluindo máquinas mais novas baseadas em UEFI que usam um BIOS emulado. Isso é ótimo, porque você pode usar a mesma lógica de boot em todas as máquinas do último século. Mas essa ampla compatibilidade é ao mesmo tempo a maior desvantagem do boot BIOS, porque significa que a CPU é colocada em um modo de compatibilidade de 16 bits chamado modo real antes do boot, para que bootloaders arcaicos dos anos 1980 ainda funcionem.

Mas vamos começar do início:

Quando você liga um computador, ele carrega o BIOS de uma memória flash especial localizada na placa-mãe. O BIOS executa rotinas de teste automático e inicialização do hardware, então procura por discos inicializáveis. Se ele encontra um, o controle é transferido para seu bootloader, que é uma porção de 512 bytes de código executável armazenado no início do disco. A maioria dos bootloaders é maior que 512 bytes, então os bootloaders são comumente divididos em um primeiro estágio pequeno, que cabe em 512 bytes, e um segundo estágio, que é subsequentemente carregado pelo primeiro estágio.

O bootloader tem que determinar a localização da imagem do kernel no disco e carregá-la na memória. Ele também precisa mudar a CPU do modo real de 16 bits primeiro para o modo protegido de 32 bits, e então para o modo longo de 64 bits, onde registradores de 64 bits e a memória principal completa estão disponíveis. Seu terceiro trabalho é consultar certas informações (como um mapa de memória) do BIOS e passá-las ao kernel do SO.

Escrever um bootloader é um pouco trabalhoso, pois requer linguagem assembly e muitos passos pouco intuitivos como “escrever este valor mágico neste registrador do processador”. Portanto, não cobrimos a criação de bootloader neste post e em vez disso fornecemos uma ferramenta chamada bootimage que anexa automaticamente um bootloader ao seu kernel.

Se você estiver interessado em construir seu próprio bootloader: Fique ligado, um conjunto de posts sobre este tópico já está planejado!

🔗O Padrão Multiboot

Para evitar que todo sistema operacional implemente seu próprio bootloader, que é compatível apenas com um único SO, a Free Software Foundation criou um padrão de bootloader aberto chamado Multiboot em 1995. O padrão define uma interface entre o bootloader e o sistema operacional, para que qualquer bootloader compatível com Multiboot possa carregar qualquer sistema operacional compatível com Multiboot. A implementação de referência é o GNU GRUB, que é o bootloader mais popular para sistemas Linux.

Para tornar um kernel compatível com Multiboot, basta inserir um chamado cabeçalho Multiboot no início do arquivo do kernel. Isso torna muito fácil inicializar um SO a partir do GRUB. No entanto, o GRUB e o padrão Multiboot também têm alguns problemas:

  • Eles suportam apenas o modo protegido de 32 bits. Isso significa que você ainda tem que fazer a configuração da CPU para mudar para o modo longo de 64 bits.
  • Eles são projetados para tornar o bootloader simples em vez do kernel. Por exemplo, o kernel precisa ser vinculado com um tamanho de página padrão ajustado, porque o GRUB não consegue encontrar o cabeçalho Multiboot caso contrário. Outro exemplo é que as informações de boot, que são passadas ao kernel, contêm muitas estruturas dependentes de arquitetura em vez de fornecer abstrações limpas.
  • Tanto o GRUB quanto o padrão Multiboot são documentados apenas esparsamente.
  • O GRUB precisa estar instalado no sistema host para criar uma imagem de disco inicializável a partir do arquivo do kernel. Isso torna o desenvolvimento no Windows ou Mac mais difícil.

Por causa dessas desvantagens, decidimos não usar o GRUB ou o padrão Multiboot. No entanto, planejamos adicionar suporte Multiboot à nossa ferramenta bootimage, para que seja possível carregar seu kernel em um sistema GRUB também. Se você estiver interessado em escrever um kernel compatível com Multiboot, confira a primeira edição desta série de blog.

🔗UEFI

(Não fornecemos suporte UEFI no momento, mas adoraríamos! Se você gostaria de ajudar, por favor nos diga no issue do Github.)

🔗Um Kernel Mínimo

Agora que sabemos aproximadamente como um computador inicializa, é hora de criar nosso próprio kernel mínimo. Nosso objetivo é criar uma imagem de disco que imprima um “Hello World!” na tela quando inicializada. Fazemos isso estendendo o binário Rust independente do post anterior.

Como você deve se lembrar, construímos o binário independente através do cargo, mas dependendo do sistema operacional, precisávamos de nomes de ponto de entrada e flags de compilação diferentes. Isso ocorre porque o cargo compila para o sistema host por padrão, ou seja, o sistema em que você está executando. Isso não é algo que queremos para nosso kernel, porque um kernel que executa em cima de, por exemplo, Windows, não faz muito sentido. Em vez disso, queremos compilar para um sistema alvo claramente definido.

🔗Instalando o Rust Nightly

O Rust tem três canais de lançamento: stable, beta e nightly. O Livro do Rust explica a diferença entre esses canais muito bem, então dê uma olhada aqui. Para construir um sistema operacional, precisaremos de alguns recursos experimentais que estão disponíveis apenas no canal nightly, então precisamos instalar uma versão nightly do Rust.

Para gerenciar instalações do Rust, eu recomendo fortemente o rustup. Ele permite instalar compiladores nightly, beta e stable lado a lado e facilita a atualização deles. Com rustup, você pode usar um compilador nightly para o diretório atual executando rustup override set nightly. Alternativamente, você pode adicionar um arquivo chamado rust-toolchain com o conteúdo nightly ao diretório raiz do projeto. Você pode verificar que tem uma versão nightly instalada executando rustc --version: O número da versão deve conter -nightly no final.

O compilador nightly nos permite optar por vários recursos experimentais usando as chamadas feature flags no topo do nosso arquivo. Por exemplo, poderíamos habilitar a macro asm! experimental para assembly inline adicionando #![feature(asm)] no topo do nosso main.rs. Note que tais recursos experimentais são completamente instáveis, o que significa que versões futuras do Rust podem alterá-los ou removê-los sem aviso prévio. Por esta razão, só os usaremos se absolutamente necessário.

🔗Especificação de Alvo

O Cargo suporta diferentes sistemas alvo através do parâmetro --target. O alvo é descrito por uma chamada target triple, que descreve a arquitetura da CPU, o vendor, o sistema operacional e a ABI. Por exemplo, o target triple x86_64-unknown-linux-gnu descreve um sistema com uma CPU x86_64, sem vendor claro, e um sistema operacional Linux com a ABI GNU. O Rust suporta muitos target triples diferentes, incluindo arm-linux-androideabi para Android ou wasm32-unknown-unknown para WebAssembly.

Para nosso sistema alvo, no entanto, precisamos de alguns parâmetros de configuração especiais (por exemplo, nenhum SO subjacente), então nenhum dos target triples existentes se encaixa. Felizmente, o Rust nos permite definir nosso próprio alvo através de um arquivo JSON. Por exemplo, um arquivo JSON que descreve o target x86_64-unknown-linux-gnu se parece com isto:

{
    "llvm-target": "x86_64-unknown-linux-gnu",
    "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": 64,
    "target-c-int-width": 32,
    "os": "linux",
    "executables": true,
    "linker-flavor": "gcc",
    "pre-link-args": ["-m64"],
    "morestack": false
}

A maioria dos campos é exigida pelo LLVM para gerar código para aquela plataforma. Por exemplo, o campo data-layout define o tamanho de vários tipos integer, floating point e pointer. Então há campos que o Rust usa para compilação condicional, como target-pointer-width. O terceiro tipo de campo define como a crate deve ser construída. Por exemplo, o campo pre-link-args especifica argumentos passados ao linker.

Também visamos sistemas x86_64 com nosso kernel, então nossa especificação de alvo será muito similar à acima. Vamos começar criando um arquivo x86_64-blog_os.json (escolha qualquer nome que você goste) com o conteúdo comum:

{
    "llvm-target": "x86_64-unknown-none",
    "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": 64,
    "target-c-int-width": 32,
    "os": "none",
    "executables": true
}

Note que mudamos o SO no llvm-target e no campo os para none, porque executaremos em bare metal.

Adicionamos as seguintes entradas relacionadas à compilação:

"linker-flavor": "ld.lld",
"linker": "rust-lld",

Em vez de usar o linker padrão da plataforma (que pode não suportar alvos Linux), usamos o linker multiplataforma LLD que vem com o Rust para vincular nosso kernel.

"panic-strategy": "abort",

Esta configuração especifica que o alvo não suporta stack unwinding no panic, então em vez disso o programa deve abortar diretamente. Isso tem o mesmo efeito que a opção panic = "abort" no nosso Cargo.toml, então podemos removê-la de lá. (Note que, em contraste com a opção Cargo.toml, esta opção de alvo também se aplica quando recompilamos a biblioteca core mais adiante neste post. Então, mesmo se você preferir manter a opção Cargo.toml, certifique-se de incluir esta opção.)

"disable-redzone": true,

Estamos escrevendo um kernel, então precisaremos lidar com interrupções em algum momento. Para fazer isso com segurança, temos que desabilitar uma certa otimização do ponteiro de stack chamada “red zone”, porque ela causaria corrupção do stack caso contrário. Para mais informações, veja nosso post separado sobre desabilitando a red zone.

"features": "-mmx,-sse,+soft-float",

O campo features habilita/desabilita recursos do alvo. Desabilitamos os recursos mmx e sse prefixando-os com um menos e habilitamos o recurso soft-float prefixando-o com um mais. Note que não deve haver espaços entre flags diferentes, caso contrário o LLVM falha ao interpretar a string de features.

Os recursos mmx e sse determinam suporte para instruções Single Instruction Multiple Data (SIMD), que frequentemente podem acelerar programas significativamente. No entanto, usar os grandes registradores SIMD em kernels de SO leva a problemas de desempenho. A razão é que o kernel precisa restaurar todos os registradores ao seu estado original antes de continuar um programa interrompido. Isso significa que o kernel tem que salvar o estado SIMD completo na memória principal em cada chamada de sistema ou interrupção de hardware. Como o estado SIMD é muito grande (512-1600 bytes) e interrupções podem ocorrer com muita frequência, essas operações adicionais de salvar/restaurar prejudicam consideravelmente o desempenho. Para evitar isso, desabilitamos SIMD para nosso kernel (não para aplicações executando em cima!).

Um problema com desabilitar SIMD é que operações de ponto flutuante em x86_64 exigem registradores SIMD por padrão. Para resolver este problema, adicionamos o recurso soft-float, que emula todas as operações de ponto flutuante através de funções de software baseadas em inteiros normais.

Para mais informações, veja nosso post sobre desabilitando SIMD.

"rustc-abi": "x86-softfloat"

Como queremos usar o recurso soft-float, também precisamos dizer ao compilador Rust rustc que queremos usar a ABI correspondente. Podemos fazer isso definindo o campo rustc-abi para x86-softfloat.

🔗Juntando Tudo

Nosso arquivo de especificação de alvo agora se parece com isto:

{
    "llvm-target": "x86_64-unknown-none",
    "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": 64,
    "target-c-int-width": 32,
    "os": "none",
    "executables": true,
    "linker-flavor": "ld.lld",
    "linker": "rust-lld",
    "panic-strategy": "abort",
    "disable-redzone": true,
    "features": "-mmx,-sse,+soft-float",
    "rustc-abi": "x86-softfloat"
}

🔗Construindo nosso Kernel

Compilar para nosso novo alvo usará convenções Linux, já que o linker-flavor ld.lld instrui o llvm a compilar com a flag -flavor gnu (para mais opções de linker, veja a documentação do rustc). Isso significa que precisamos de um ponto de entrada chamado _start como descrito no post anterior:

// src/main.rs

#![no_std] // não vincule a biblioteca padrão do Rust
#![no_main] // desativar todos os pontos de entrada no nível Rust

use core::panic::PanicInfo;

/// Esta função é chamada em caso de pânico.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[unsafe(no_mangle)] // não altere (mangle) o nome desta função
pub extern "C" fn _start() -> ! {
    // essa função é o ponto de entrada, já que o vinculador procura uma função
    // denominado `_start` por padrão
    loop {}
}

Note que o ponto de entrada precisa ser chamado _start independentemente do seu SO host.

Agora podemos construir o kernel para nosso novo alvo passando o nome do arquivo JSON como --target:

> cargo build --target x86_64-blog_os.json

error[E0463]: can't find crate for `core`

Falha! O erro nos diz que o compilador Rust não consegue mais encontrar a biblioteca core. Esta biblioteca contém tipos básicos do Rust como Result, Option e iteradores, e é implicitamente vinculada a todas as crates no_std.

O problema é que a biblioteca core é distribuída junto com o compilador Rust como uma biblioteca pré-compilada. Então ela é válida apenas para target triples host suportados (por exemplo, x86_64-unknown-linux-gnu) mas não para nosso alvo customizado. Se quisermos compilar código para outros alvos, precisamos recompilar core para esses alvos primeiro.

🔗A Opção build-std

É aí que entra o recurso build-std do cargo. Ele permite recompilar core e outras crates da biblioteca padrão sob demanda, em vez de usar as versões pré-compiladas enviadas com a instalação do Rust. Este recurso é muito novo e ainda não está finalizado, então é marcado como “unstable” e disponível apenas em compiladores Rust nightly.

Para usar o recurso, precisamos criar um arquivo de configuração cargo local em .cargo/config.toml (a pasta .cargo deve estar ao lado da sua pasta src) com o seguinte conteúdo:

# em .cargo/config.toml

[unstable]
build-std = ["core", "compiler_builtins"]

Isso diz ao cargo que ele deve recompilar as bibliotecas core e compiler_builtins. Esta última é necessária porque é uma dependência de core. Para recompilar essas bibliotecas, o cargo precisa de acesso ao código-fonte do rust, que podemos instalar com rustup component add rust-src.

Nota: A chave de configuração unstable.build-std requer pelo menos o Rust nightly de 15-07-2020.

Depois de definir a chave de configuração unstable.build-std e instalar o componente rust-src, podemos executar novamente nosso comando de compilação:

> cargo build --target x86_64-blog_os.json
   Compiling core v0.0.0 (/…/rust/src/libcore)
   Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
   Compiling compiler_builtins v0.1.32
   Compiling blog_os v0.1.0 (/…/blog_os)
    Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs

Vemos que cargo build agora recompila as bibliotecas core, rustc-std-workspace-core (uma dependência de compiler_builtins) e compiler_builtins para nosso alvo customizado.

🔗Intrínsecos Relacionados a Memória

O compilador Rust assume que um certo conjunto de funções embutidas está disponível para todos os sistemas. A maioria dessas funções é fornecida pela crate compiler_builtins que acabamos de recompilar. No entanto, existem algumas funções relacionadas a memória nessa crate que não são habilitadas por padrão porque normalmente são fornecidas pela biblioteca C no sistema. Essas funções incluem memset, que define todos os bytes em um bloco de memória para um valor dado, memcpy, que copia um bloco de memória para outro, e memcmp, que compara dois blocos de memória. Embora não precisássemos de nenhuma dessas funções para compilar nosso kernel agora, elas serão necessárias assim que adicionarmos mais código a ele (por exemplo, ao copiar structs).

Como não podemos vincular à biblioteca C do sistema operacional, precisamos de uma maneira alternativa de fornecer essas funções ao compilador. Uma possível abordagem para isso poderia ser implementar nossas próprias funções memset etc. e aplicar o atributo #[unsafe(no_mangle)] a elas (para evitar a renomeação automática durante a compilação). No entanto, isso é perigoso, pois o menor erro na implementação dessas funções pode levar a undefined behavior. Por exemplo, implementar memcpy com um loop for pode resultar em recursão infinita porque loops for implicitamente chamam o método da trait IntoIterator::into_iter, que pode chamar memcpy novamente. Então é uma boa ideia reutilizar implementações existentes e bem testadas em vez disso.

Felizmente, a crate compiler_builtins já contém implementações para todas as funções necessárias, elas estão apenas desabilitadas por padrão para não colidir com as implementações da biblioteca C. Podemos habilitá-las definindo a flag build-std-features do cargo para ["compiler-builtins-mem"]. Como a flag build-std, esta flag pode ser passada na linha de comando como uma flag -Z ou configurada na tabela unstable no arquivo .cargo/config.toml. Como queremos sempre compilar com esta flag, a opção do arquivo de configuração faz mais sentido para nós:

# em .cargo/config.toml

[unstable]
build-std-features = ["compiler-builtins-mem"]
build-std = ["core", "compiler_builtins"]

(O suporte para o recurso compiler-builtins-mem foi adicionado muito recentemente, então você precisa pelo menos do Rust nightly 2020-09-30 para ele.)

Nos bastidores, esta flag habilita o recurso mem da crate compiler_builtins. O efeito disso é que o atributo #[unsafe(no_mangle)] é aplicado às implementações memcpy etc. da crate, o que as torna disponíveis ao linker.

Com esta mudança, nosso kernel tem implementações válidas para todas as funções exigidas pelo compilador, então ele continuará a compilar mesmo se nosso código ficar mais complexo.

🔗Definir um Alvo Padrão

Para evitar passar o parâmetro --target em cada invocação de cargo build, podemos sobrescrever o alvo padrão. Para fazer isso, adicionamos o seguinte ao nosso arquivo de configuração cargo em .cargo/config.toml:

# em .cargo/config.toml

[build]
target = "x86_64-blog_os.json"

Isso diz ao cargo para usar nosso alvo x86_64-blog_os.json quando nenhum argumento --target explícito é passado. Isso significa que agora podemos construir nosso kernel com um simples cargo build. Para mais informações sobre opções de configuração do cargo, confira a documentação oficial.

Agora podemos construir nosso kernel para um alvo bare metal com um simples cargo build. No entanto, nosso ponto de entrada _start, que será chamado pelo bootloader, ainda está vazio. É hora de mostrar algo na tela a partir dele.

🔗Imprimindo na Tela

A maneira mais fácil de imprimir texto na tela neste estágio é o buffer de texto VGA. É uma área de memória especial mapeada para o hardware VGA que contém o conteúdo exibido na tela. Normalmente consiste em 25 linhas que cada uma contém 80 células de caractere. Cada célula de caractere exibe um caractere ASCII com algumas cores de primeiro plano e fundo. A saída da tela se parece com isto:

saída de tela para caracteres ASCII comuns

Discutiremos o layout exato do buffer VGA no próximo post, onde escreveremos um primeiro pequeno driver para ele. Para imprimir “Hello World!”, só precisamos saber que o buffer está localizado no endereço 0xb8000 e que cada célula de caractere consiste em um byte ASCII e um byte de cor.

A implementação se parece com isto:

static HELLO: &[u8] = b"Hello World!";

#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
    let vga_buffer = 0xb8000 as *mut u8;

    for (i, &byte) in HELLO.iter().enumerate() {
        unsafe {
            *vga_buffer.offset(i as isize * 2) = byte;
            *vga_buffer.offset(i as isize * 2 + 1) = 0xb;
        }
    }

    loop {}
}

Primeiro, convertemos o inteiro 0xb8000 em um ponteiro bruto. Então [iteramos] sobre os bytes da byte string static HELLO. Usamos o método enumerate para obter adicionalmente uma variável em execução i. No corpo do loop for, usamos o método offset para escrever o byte da string e o byte de cor correspondente (0xb é um ciano claro).

Note que há um bloco unsafe em torno de todas as escritas de memória. A razão é que o compilador Rust não pode provar que os ponteiros brutos que criamos são válidos. Eles poderiam apontar para qualquer lugar e levar à corrupção de dados. Ao colocá-los em um bloco unsafe, estamos basicamente dizendo ao compilador que temos absoluta certeza de que as operações são válidas. Note que um bloco unsafe não desativa as verificações de segurança do Rust. Ele apenas permite que você faça cinco coisas adicionais.

Quero enfatizar que esta não é a maneira como queremos fazer as coisas em Rust! É muito fácil bagunçar ao trabalhar com ponteiros brutos dentro de blocos unsafe. Por exemplo, poderíamos facilmente escrever além do fim do buffer se não tivermos cuidado.

Então queremos minimizar o uso de unsafe o máximo possível. O Rust nos dá a capacidade de fazer isso criando abstrações seguras. Por exemplo, poderíamos criar um tipo de buffer VGA que encapsula toda a unsafety e garante que seja impossível fazer algo errado de fora. Desta forma, precisaríamos apenas de quantidades mínimas de código unsafe e poderíamos ter certeza de que não violamos memory safety. Criaremos tal abstração de buffer VGA segura no próximo post.

🔗Executando nosso Kernel

Agora que temos um executável que faz algo perceptível, é hora de executá-lo. Primeiro, precisamos transformar nosso kernel compilado em uma imagem de disco inicializável vinculando-o com um bootloader. Então podemos executar a imagem de disco na máquina virtual QEMU ou inicializá-la em hardware real usando um pendrive USB.

🔗Criando uma Bootimage

Para transformar nosso kernel compilado em uma imagem de disco inicializável, precisamos vinculá-lo com um bootloader. Como aprendemos na seção sobre boot, o bootloader é responsável por inicializar a CPU e carregar nosso kernel.

Em vez de escrever nosso próprio bootloader, que é um projeto por si só, usamos a crate bootloader. Esta crate implementa um bootloader BIOS básico sem nenhuma dependência C, apenas Rust e assembly inline. Para usá-lo para inicializar nosso kernel, precisamos adicionar uma dependência nele:

# em Cargo.toml

[dependencies]
bootloader = "0.9"

Nota: Este post é compatível apenas com bootloader v0.9. Versões mais novas usam um sistema de compilação diferente e resultarão em erros de compilação ao seguir este post.

Adicionar o bootloader como uma dependência não é suficiente para realmente criar uma imagem de disco inicializável. O problema é que precisamos vincular nosso kernel com o bootloader após a compilação, mas o cargo não tem suporte para scripts pós-compilação.

Para resolver este problema, criamos uma ferramenta chamada bootimage que primeiro compila o kernel e o bootloader, e então os vincula juntos para criar uma imagem de disco inicializável. Para instalar a ferramenta, vá para seu diretório home (ou qualquer diretório fora do seu projeto cargo) e execute o seguinte comando no seu terminal:

cargo install bootimage

Para executar bootimage e construir o bootloader, você precisa ter o componente rustup llvm-tools-preview instalado. Você pode fazer isso executando rustup component add llvm-tools-preview.

Depois de instalar bootimage e adicionar o componente llvm-tools-preview, você pode criar uma imagem de disco inicializável voltando para o diretório do seu projeto cargo e executando:

> cargo bootimage

Vemos que a ferramenta recompila nosso kernel usando cargo build, então automaticamente pegará quaisquer mudanças que você fizer. Depois, ela compila o bootloader, o que pode demorar um pouco. Como todas as dependências de crate, ele é compilado apenas uma vez e então armazenado em cache, então compilações subsequentes serão muito mais rápidas. Finalmente, bootimage combina o bootloader e seu kernel em uma imagem de disco inicializável.

Após executar o comando, você deve ver uma imagem de disco inicializável chamada bootimage-blog_os.bin no seu diretório target/x86_64-blog_os/debug. Você pode inicializá-la em uma máquina virtual ou copiá-la para um pendrive USB para inicializá-la em hardware real. (Note que este não é uma imagem de CD, que tem um formato diferente, então gravá-la em um CD não funciona).

🔗Como funciona?

A ferramenta bootimage executa os seguintes passos nos bastidores:

  • Ela compila nosso kernel para um arquivo ELF.
  • Ela compila a dependência do bootloader como um executável autônomo.
  • Ela vincula os bytes do arquivo ELF do kernel ao bootloader.

Quando inicializado, o bootloader lê e analisa o arquivo ELF anexado. Ele então mapeia os segmentos do programa para endereços virtuais nas tabelas de página, zera a seção .bss e configura um stack. Finalmente, ele lê o endereço do ponto de entrada (nossa função _start) e salta para ele.

🔗Inicializando no QEMU

Agora podemos inicializar a imagem de disco em uma máquina virtual. Para inicializá-la no QEMU, execute o seguinte comando:

> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin

Isso abre uma janela separada que deve se parecer com isto:

QEMU mostrando “Hello World!”

Vemos que nosso “Hello World!” está visível na tela.

🔗Máquina Real

Também é possível escrevê-lo em um pendrive USB e inicializá-lo em uma máquina real, mas tenha cuidado para escolher o nome correto do dispositivo, porque tudo naquele dispositivo será sobrescrito:

> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync

Onde sdX é o nome do dispositivo do seu pendrive USB.

Depois de escrever a imagem no pendrive USB, você pode executá-la em hardware real inicializando a partir dele. Você provavelmente precisará usar um menu de boot especial ou alterar a ordem de boot na configuração do BIOS para inicializar a partir do pendrive USB. Note que atualmente não funciona para máquinas UEFI, já que a crate bootloader ainda não tem suporte UEFI.

🔗Usando cargo run

Para facilitar a execução do nosso kernel no QEMU, podemos definir a chave de configuração runner para o cargo:

# em .cargo/config.toml

[target.'cfg(target_os = "none")']
runner = "bootimage runner"

A tabela target.'cfg(target_os = "none")' se aplica a todos os alvos cujo campo "os" do arquivo de configuração de alvo está definido como "none". Isso inclui nosso alvo x86_64-blog_os.json. A chave runner especifica o comando que deve ser invocado para cargo run. O comando é executado após uma compilação bem-sucedida com o caminho do executável passado como o primeiro argumento. Veja a documentação do cargo para mais detalhes.

O comando bootimage runner é especificamente projetado para ser utilizável como um executável runner. Ele vincula o executável dado com a dependência do bootloader do projeto e então lança o QEMU. Veja o Readme do bootimage para mais detalhes e opções de configuração possíveis.

Agora podemos usar cargo run para compilar nosso kernel e inicializá-lo no QEMU.

🔗O que vem a seguir?

No próximo post, exploraremos o buffer de texto VGA em mais detalhes e escreveremos uma interface segura para ele. Também adicionaremos suporte para a macro println.



Comentários

Teve algum problema, quer deixar um feedback ou discutir mais ideias? Fique à vontade para deixar um comentário aqui! Por favor, use o inglês e siga o código de conduta do Rust. Este tópico de comentários está diretamente vinculado a uma discussão no GitHub, então você também pode comentar lá se preferir.

Instead of authenticating the giscus application, you can also comment directly on GitHub.

Por favor, deixe seus comentários em inglês se possível.