SISTEMAS OPERACIONAIS · FORMATOS BINÁRIOS · RUST
Construindo um sistema de arquivos do zero em Rust
Descubra como sistemas operacionais armazenam arquivos criando um pequeno sistema de arquivos totalmente funcional dentro de um disco virtual.
01 / A SÉRIE CONTINUA
Já olhamos dentro dos arquivos. Agora precisamos descobrir onde eles moram.
No primeiro artigo desta sequência, separamos extensão de conteúdo e construímos um formato próprio. Depois empacotamos vários arquivos dentro de um arquivador. Em seguida desmontamos um PNG até recuperar seus pixels. Cada projeto respondeu a uma pergunta, mas deixou outra esperando.
Um arquivo é uma sequência de bytes. Umformato de arquivo define o significado desses bytes. Já umsistema de arquivos precisa administrar milhares de arquivos: decidir onde cada um começa, quais regiões estão livres, como nomes formam diretórios e o que acontece quando um arquivo cresce ou desaparece.
Antes administrávamos bytes dentro de um arquivo. Agora administraremos arquivos dentro de um disco.
02 / UM DISCO SEM REGRAS
Um milhão de bytes vazios não sabe onde nenhum arquivo começa.
Imagine um dispositivo que oferece posições numeradas de zero a 1.048.575. Podemos gravar qualquer valor em qualquer posição, mas o dispositivo não conhece nomes, pastas ou arquivos. Ele apenas guarda bytes e devolve os mesmos bytes quando pedimos uma posição.
Se copiarmos uma foto a partir do byte 20.000, precisamos lembrar onde ela começa, quantos bytes ocupa e quais posições pertencem a ela. Se desligarmos o computador e perdermos esse papel, os dados continuam no disco, mas viram uma sequência impossível de localizar com segurança.
Nosso sistema de arquivos será justamente esse catálogo persistente. Ele também ficará gravado no disco, ao lado dos dados que descreve. Isso cria uma ideia poderosa: o programa não precisa “lembrar” da sessão anterior; basta reler as estruturas reservadas quando abrir o dispositivo.
É aqui que nasce o nosso projeto
Em vez de continuar falando sobre um sistema de arquivos abstrato, vamos construir um. Ele precisa de um nome, porque a partir de agora teremos decisões, comandos e um formato que pertencem a ele. Vamos chamá-lo de RaizFS.
Raiz vem do primeiro diretório da nossa árvore, representado por /. FS é a abreviação de file system, sistema de arquivos em inglês. O nome não apareceu por acaso: ele lembra que qualquer caminho que criaremos — como/documentos/notas.txt — começará nessa raiz.
O RaizFS será um programa em Rust que trata meu_hd.disk como um disco, grava pastas e arquivos dentro dele e consegue encontrá-los novamente mesmo depois que o programa é fechado.
Ele será funcional, mas deliberadamente pequeno. Não criaremos um driver nem substituiremos o sistema de arquivos do seu computador. Nosso executável abrirá um arquivo comum e oferecerá seis operações: formatar o disco, criar uma pasta, copiar um arquivo para dentro, listar, ler e remover. Essa redução nos permite enxergar o sistema inteiro sem esconder a parte importante atrás de uma biblioteca.
Vamos criar a casca do projeto antes de inventar o disco
Abra um terminal e peça ao Cargo — a ferramenta que cria e compila projetos Rust — para iniciar um programa chamado raizfs:
cargo new raizfs
cd raizfs
cargo run -- format meu_hd.diskO último comando ainda não funciona; ele é o nosso destino. Ao final, a palavraformat criará o disco virtual. Manter esse resultado visível desde o começo ajuda a entender por que cada módulo será necessário.
03 / MEU_HD.DISK
Vamos simular hardware usando um arquivo comum de exatamente 1 MiB.
Trabalhar diretamente com uma partição real seria perigoso. Um offset errado poderia destruir dados do computador. Por isso criaremosmeu_hd.disk, um arquivo com tamanho fixo que nosso programa abrirá usando leitura, escrita e seek.
Para o sistema hospedeiro, ele é apenas mais um arquivo. Para o RaizFS, ele é o dispositivo inteiro. A posição zero do arquivo corresponde ao início do disco virtual; avançar 512 bytes corresponde a entrar no bloco seguinte.
Nossa primeira peça de código vai para src/layout.rs. Esse módulo será a planta baixa: ele não grava nada, apenas define tamanhos e transforma o número de um bloco em uma posição do disco.
pub const BLOCK_SIZE: usize = 512;
pub const BLOCK_COUNT: usize = 2048;
pub const DISK_SIZE: usize = BLOCK_SIZE * BLOCK_COUNT;
pub fn block_offset(block: usize) -> u64 {
(block * BLOCK_SIZE) as u64
}usize é um inteiro usado pelo Rust para tamanhos e índices.BLOCK_SIZE fixa 512 bytes por bloco.BLOCK_COUNT diz que teremos 2.048 deles. A multiplicação produz 1.048.576 bytes, exatamente 1 MiB. Já block_offset responde: “em qual byte começa o bloco N?”. O retorno é u64 porque as APIs de arquivos representam posições com um inteiro de 64 bits.
Essa técnica é usada em imagens de disco, máquinas virtuais, emuladores e testes. A abstração muda, mas as contas permanecem reais: offsets, limites e persistência funcionam como funcionariam em um dispositivo organizado em setores.
04 / DIVIDINDO O ESPAÇO
Em vez de administrar um milhão de bytes, administraremos 2.048 blocos.
Um bloco é uma unidade fixa de armazenamento. No RaizFS cada bloco possui 512 bytes. O bloco zero contém os bytes de 0 a 511; o bloco um começa em 512; o bloco dois começa em 1.024. A fórmula é bloco × 512.
Se um arquivo possui 900 bytes, ele precisa de dois blocos. O primeiro fica cheio; o segundo usa 388 bytes e deixa 124 sem conteúdo útil. Esse desperdício dentro da última unidade é chamado de fragmentação interna. Blocos menores desperdiçam menos, mas exigem mais números e mais trabalho de administração.
05 / O MAPA FÍSICO
Antes de gravar arquivos, reservamos regiões que descrevem o próprio disco.
O bloco zero é o superbloco: a identidade e o contrato do RaizFS. O bloco um guarda o bitmap. Os blocos 2 a 17 formam a tabela de inodes. Somente a partir do bloco 18 os dados de usuários podem ser alocados.
O superbloco começa com RAIZFS01. Esses magic bytes evitam que o programa interprete um PNG, ZIP ou arquivo aleatório como nosso disco. Em seguida registramos versão, tamanho do bloco, quantidade de blocos, quantidade de inodes e início da região de dados.
06 / ENCONTRANDO ESPAÇO LIVRE
Um único bit responde se um bloco pode ser usado.
O bitmap é uma sequência em que cada bit representa um bloco. Bit zero significa livre; bit um significa ocupado. Como um byte possui oito bits, 2.048 blocos exigem apenas 256 bytes. Nosso bloco de bitmap ainda deixa metade de sua área reservada para uma possível expansão.
Ao formatar, marcamos 0 a 17 como ocupados porque pertencem à estrutura. Para gravar um arquivo, percorremos do bloco 18 em diante, coletamos zeros suficientes e os transformamos em uns. Se qualquer etapa posterior falhar, devolvemos esses bits ao estado livre.
Esse retorno é importante. Sem ele, uma tentativa malsucedida criaria blocos órfãos: o bitmap diria “ocupado”, mas nenhum inode apontaria para o conteúdo. Sistemas reais usam transações ou journals para resolver versões muito mais difíceis desse problema depois de quedas de energia.
Como um número de bloco encontra seu único bit?
Agora o conceito vira código. O quociente de block / 8 escolhe o byte do bitmap. O resto de block % 8 escolhe uma das oito posições dentro desse byte. Assim, o bloco 18 usa o byte 2 e o bit 2, porque18 / 8 = 2 e 18 % 8 = 2.
fn get_bit(bitmap: &[u8; BLOCK_SIZE], block: usize) -> bool {
bitmap[block / 8] & (1 << (block % 8)) != 0
}
fn set_bit(bitmap: &mut [u8; BLOCK_SIZE], block: usize, used: bool) {
let mask = 1 << (block % 8);
if used {
bitmap[block / 8] |= mask;
} else {
bitmap[block / 8] &= !mask;
}
}1 << posição cria uma máscara com apenas o bit desejado ligado. O operador & consulta esse bit; |= liga;&= !mask desliga. Não é “mágica de bits”: são três maneiras de perguntar, marcar e desmarcar a mesma casa.
07 / O REGISTRO DE CADA OBJETO
O inode conecta um nome e um tamanho aos blocos que realmente carregam os bytes.
Inode é uma abreviação histórica para index node. Pense nele como uma ficha de catálogo. No RaizFS cada ficha ocupa exatamente 128 bytes e registra: está em uso, tipo, diretório pai, tamanho, quantidade de blocos, vinte números de blocos diretos e um nome de até 32 bytes.
“Ponteiro” aqui não é um endereço de memória. É apenas o número de um bloco. Se a lista contém 20, 84 e 21, o leitor busca dados nesses três blocos, nessa ordem. Eles não precisam ser vizinhos. Isso permite que um arquivo continue existindo mesmo quando o espaço livre está espalhado.
Sistemas Unix reais separam o nome do inode: o diretório associa nomes a números de inode, o que permite hard links. Nosso formato guarda o nome na ficha para reduzir o número de estruturas introduzidas de uma vez. A simplificação é explícita, não uma descrição falsa do EXT4.
Vamos transformar a ficha em tipos Rust
pub enum InodeKind {
File = 1,
Directory = 2,
}
pub struct Inode {
pub used: bool,
pub kind: InodeKind,
pub parent: u16,
pub size: u64,
pub blocks: Vec<u32>,
pub name: String,
}O enum limita o tipo a duas alternativas válidas: arquivo ou diretório. A struct reúne os campos de uma ficha. Stringguarda o nome; Vec<u32> é uma lista de números de blocos que pode ter tamanho variável em memória. No disco, porém, reservaremos espaço para no máximo vinte números. A estrutura em memória é confortável; o formato em disco continua fixo.
No método encode do projeto completo, começamos com[0_u8; 128] e copiamos cada campo para sua faixa: pai em2..4, tamanho em 4..12 e assim por diante.to_le_bytes transforma um número em bytes little-endian.decode percorre o caminho inverso. É assim que a ficha sobrevive ao encerramento do programa.
08 / DIRETÓRIOS SÃO RELAÇÕES
Uma pasta não contém fisicamente os arquivos: ela organiza nomes em uma árvore.
O inode zero é a raiz, representada por /. Um objeto dentro de/documentos guarda como pai o número do inode de documentos. Para resolver /documentos/notas.txt, começamos na raiz, procuramos um filho chamado documentos e depois procuramos notas.txt entre os filhos dele.
Rejeitamos caminhos que não começam em barra, possuem barras duplas,. ou ... Isso mantém a resolução determinística e impede que a CLI escape da árvore ao interpretar um destino. Também recusamos dois filhos com o mesmo nome dentro do mesmo pai.
Resolver um caminho é caminhar pela árvore, um nome por vez
fn resolve(&self, path: &str) -> Result<usize, FsError> {
if path == "/" {
return Ok(0);
}
let mut current = 0;
for part in clean_parts(path)? {
current = self.child(current, part)
.ok_or_else(|| FsError::NotFound(path.to_owned()))?;
}
Ok(current)
}current começa no inode zero, nossa raiz. Para cada parte,child procura um inode usado com aquele nome e aquele pai. O operador ? interrompe a função se surgir um erro; caso contrário, entrega o valor interno do Result. Ao terminar o laço,current é o número do objeto procurado.
09 / FORMAT
Formatar não é “apagar tudo”: é escrever as estruturas iniciais do sistema.
O comando format cria ou trunca o disco, fixa seu tamanho, grava o superbloco, inicializa o bitmap e escreve o inode raiz. Depois dessa operação, todos os dados de usuário estão livres, mas o disco já possui identidade, geometria e uma árvore vazia válida.
Finalmente escrevemos a primeira operação em filesystem.rs.OpenOptions permite declarar exatamente como o arquivo será aberto:
let mut disk = OpenOptions::new()
.create(true)
.truncate(true)
.read(true)
.write(true)
.open(path)?;
disk.set_len(DISK_SIZE as u64)?;
let mut superblock = [0_u8; BLOCK_SIZE];
superblock[..8].copy_from_slice(MAGIC);
superblock[8..10].copy_from_slice(&VERSION.to_le_bytes());
disk.seek(SeekFrom::Start(block_offset(0)))?;
disk.write_all(&superblock)?;create(true) cria o arquivo se ele não existir;truncate(true) esvazia uma versão anterior; leitura e escrita serão necessárias nas próximas operações. set_len fixa 1 MiB.seek move o cursor até um offset e write_all garante que a fatia inteira seja enviada. O ? devolve qualquer falha ao chamador em vez de fingir que o disco foi formatado.
cargo run -- format meu_hd.disk
bloco 0 ← RAIZFS01 + versão + geometria
bloco 1 ← bits 0…17 ocupados
inode 0 ← diretório raiz /
dados ← livresEm um disco real, formatar uma partição também cria estruturas do formato escolhido. O hardware não “vira EXT4”; ele recebe superblocos, tabelas, bitmaps e outras regiões que um driver EXT4 sabe interpretar.
10 / MKDIR
Criar um diretório exige resolver o pai e ocupar um inode livre.
Para mkdir /projetos/rust, o diretório/projetos precisa existir. O RaizFS separa o último componente, resolve o restante, confirma que o pai é diretório, procura colisão de nome e escolhe o primeiro inode livre.
Diretórios vazios não recebem blocos em nossa versão. Sua existência está na ficha, e seus filhos são encontrados varrendo os inodes cujo campo pai aponta para ela. Isso torna mkdir simples, mas deixals linear. Um sistema maior manteria entradas de diretório organizadas em blocos e possivelmente índices.
O código segue exatamente as quatro caixas do desenho
pub fn mkdir(&mut self, path: &str) -> Result<(), FsError> {
let (parent, name) = self.resolve_parent(path)?;
self.ensure_missing(parent, &name, path)?;
let index = self.free_inode()?;
self.inodes[index] = Inode {
used: true,
kind: InodeKind::Directory,
parent: parent as u16,
size: 0,
blocks: Vec::new(),
name,
};
self.flush_inode(index)
}A primeira linha devolve o inode do pai e o último nome do caminho. A segunda evita duplicatas. A terceira procura uma ficha vazia. Só então montamos o novoInode. Como diretório vazio não tem conteúdo, tamanho e blocos começam vazios. flush_inode calcula o offset da ficha e a grava no disco; sem essa última linha, a pasta existiria apenas na memória e sumiria ao fechar o programa.
11 / COPY
Gravar um arquivo é uma pequena transação entre dados, bitmap e inode.
O comando copy lê um arquivo do sistema hospedeiro e o leva para o RaizFS. Primeiro calculamos ceil(tamanho / 512). Depois reservamos blocos, escrevemos cada pedaço e somente então publicamos o inode que torna o arquivo encontrável.
Nosso limite é vinte blocos diretos, portanto 10.240 bytes por arquivo. O limite não vem do Rust nem do disco: vem da especificação que inventamos. Para crescer, poderíamos adicionar um bloco indireto contendo centenas de outros números, como fazem famílias de sistemas baseadas em inodes.
O arquivo grande vira pequenas fatias de 512 bytes
let blocks_needed = data.len().div_ceil(BLOCK_SIZE);
let blocks = self.allocate_blocks(blocks_needed)?;
for (chunk, block) in data.chunks(BLOCK_SIZE).zip(&blocks) {
self.disk.seek(SeekFrom::Start(block_offset(*block as usize)))?;
self.disk.write_all(chunk)?;
}div_ceil divide arredondando para cima: 1.200 bytes pedem três blocos, não dois. chunks(512) empresta uma fatia de cada pedaço do arquivo; zip(&blocks) emparelha cada fatia com o bloco que a receberá. Em cada volta, movemos o cursor e escrevemos os bytes.
O código completo também preenche o restante do último bloco com zeros e só grava o inode depois dos dados. Essa ordem importa: publicar a ficha antes poderia deixar um arquivo visível apontando para conteúdo incompleto. Se não houver inode livre, os blocos recém-selecionados são devolvidos ao bitmap.
12 / LS E CAT
Ler é refazer o caminho: nome → inode → blocos → bytes.
ls resolve um diretório e seleciona todos os inodes usados cujo pai corresponde a ele. cat resolve um arquivo, percorre a lista de blocos e concatena seus conteúdos até alcançar o tamanho registrado.
O último bloco sempre é cortado pelo número de bytes restantes. Sem essa regra,cat exibiria zeros ou dados antigos existentes na parte não usada. O tamanho é, portanto, tanto um metadado de apresentação quanto um limite de segurança para a leitura.
cat reconstrói a lista original sem devolver o espaço vazio
for block in inode.blocks {
let mut bytes = [0_u8; BLOCK_SIZE];
self.disk.seek(SeekFrom::Start(block_offset(block as usize)))?;
self.disk.read_exact(&mut bytes)?;
let remaining = inode.size as usize - data.len();
data.extend_from_slice(&bytes[..remaining.min(BLOCK_SIZE)]);
}read_exact carrega um bloco inteiro no arraybytes. Depois calculamos quanto ainda falta. No último bloco,remaining.min(512) escolhe somente os bytes reais. Por isso um arquivo de 900 bytes volta com 900 bytes, embora ocupe 1.024 no disco.
ls não lê conteúdo. Ele resolve o inode da pasta, filtra as fichas cujo campo parent aponta para ela e ordena os nomes. Duas operações diferentes reaproveitam o mesmo catálogo porque separamos metadados de dados.
13 / RM
Remover significa desfazer referências — não necessariamente triturar os dados.
Para apagar um arquivo, o RaizFS resolve seu inode, marca cada bloco como livre e zera a ficha. Um diretório só pode ser removido quando não possui filhos. A raiz nunca pode ser removida.
Os bytes antigos continuam fisicamente nos blocos até serem sobrescritos. O que desaparece imediatamente é a referência confiável. Essa é a razão pela qual ferramentas forenses às vezes recuperam arquivos apagados e por que exclusão segura exige outra estratégia, especialmente em SSDs.
No código, remove protege primeiro a raiz e diretórios com filhos. Depois copia a lista de blocos, chama release_blocks, substitui a ficha por 128 bytes zerados e persiste inode e bitmap. Observe o espelho:copy liga bits e cria uma referência; rm desliga os mesmos bits e apaga a referência.
14 / QUANDO O DISCO ENVELHECE
Arquivos podem continuar inteiros mesmo quando seus blocos ficam espalhados.
Depois de muitas criações e remoções, os zeros do bitmap aparecem em pequenos intervalos. Nosso alocador pega os primeiros blocos livres, então um novo arquivo pode receber 18, 42, 91 e 300. Isso é fragmentação externa.
Em discos mecânicos, espalhamento custa movimento físico da cabeça. Em SSDs, o custo muda, mas alinhamento, amplificação de escrita e coleta de lixo continuam relevantes. Sistemas modernos usam extents, alocação atrasada e heurísticas para tentar manter regiões relacionadas próximas.
| Problema | Onde sobra espaço | Exemplo |
|---|---|---|
| Interna | dentro do último bloco | arquivo de 513 B ocupa 1.024 B |
| Externa | entre regiões ocupadas | blocos livres 18, 42 e 91 |
15 / PROJETO COMPLETO
Agora vamos reunir as peças que construímos em um programa completo.
Até aqui, cada trecho apareceu no momento em que resolvia um problema:layout.rs nasceu quando dividimos o disco;inode.rs nasceu quando precisávamos persistir uma ficha; efilesystem.rs reuniu formatação, caminhos, alocação e leitura. A árvore abaixo não é uma lista arbitrária: é o mapa dessas decisões.
raizfs/
├── Cargo.toml
├── README.md
├── src/
│ ├── lib.rs
│ ├── main.rs
│ ├── error.rs
│ ├── layout.rs
│ ├── inode.rs
│ └── filesystem.rs
└── tests/
└── filesystem.rsBaixar o projeto completo do RaizFS A CLI é apenas a porta de entrada
Em main.rs, std::env::args() coleta as palavras digitadas no terminal. Um match compara o formato dessa lista: três itens para format, quatro para mkdir,ls, cat e rm, cinco paracopy. Cada braço abre o mesmo FileSystem e chama a operação correspondente. A CLI não sabe calcular offsets; ela só encaminha o pedido.
A seguir está o código consolidado. Leia-o como uma revisão, não como um salto: os painéis repetem as peças já explicadas e incluem o tratamento de erros, validações e testes necessários para transformar os trechos em um utilitário executável.
Cargo.tomlDefine o pacote sem dependências externas
Ver código completo de Cargo.toml
[package]
name = "raizfs"
version = "0.1.0"
edition = "2024"
description = "Sistema de arquivos didático armazenado em um disco virtual"
license = "MIT"
[lib]
name = "raizfs"
path = "src/lib.rs"
[[bin]]
name = "raizfs"
path = "src/main.rs"
[dependencies]
src/lib.rsExpõe os módulos e a API pública
Ver código completo de src/lib.rs
pub mod error;
pub mod filesystem;
pub mod inode;
pub mod layout;
pub use error::FsError;
pub use filesystem::FileSystem;
pub use inode::{Inode, InodeKind};
src/layout.rsDesenha o mapa físico do disco
Ver código completo de src/layout.rs
pub const MAGIC: &[u8; 8] = b"RAIZFS01";
pub const VERSION: u16 = 1;
pub const BLOCK_SIZE: usize = 512;
pub const BLOCK_COUNT: usize = 2048;
pub const DISK_SIZE: usize = BLOCK_SIZE * BLOCK_COUNT;
pub const SUPERBLOCK_BLOCK: usize = 0;
pub const BITMAP_BLOCK: usize = 1;
pub const INODE_TABLE_START: usize = 2;
pub const INODE_SIZE: usize = 128;
pub const INODE_COUNT: usize = 64;
pub const INODE_TABLE_BLOCKS: usize = (INODE_SIZE * INODE_COUNT) / BLOCK_SIZE;
pub const DATA_START_BLOCK: usize = INODE_TABLE_START + INODE_TABLE_BLOCKS;
pub const DIRECT_BLOCKS: usize = 20;
pub const MAX_FILE_SIZE: usize = BLOCK_SIZE * DIRECT_BLOCKS;
pub const MAX_NAME_BYTES: usize = 32;
pub fn block_offset(block: usize) -> u64 {
(block * BLOCK_SIZE) as u64
}
pub fn inode_offset(index: usize) -> u64 {
block_offset(INODE_TABLE_START) + (index * INODE_SIZE) as u64
}
src/inode.rsSerializa e interpreta os registros de arquivos
Ver código completo de src/inode.rs
use crate::error::FsError;
use crate::layout::{DIRECT_BLOCKS, INODE_SIZE, MAX_NAME_BYTES};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InodeKind {
File = 1,
Directory = 2,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Inode {
pub used: bool,
pub kind: InodeKind,
pub parent: u16,
pub size: u64,
pub blocks: Vec<u32>,
pub name: String,
}
impl Inode {
pub fn root() -> Self {
Self {
used: true,
kind: InodeKind::Directory,
parent: 0,
size: 0,
blocks: Vec::new(),
name: String::new(),
}
}
pub fn encode(&self) -> Result<[u8; INODE_SIZE], FsError> {
let name = self.name.as_bytes();
if name.len() > MAX_NAME_BYTES || self.blocks.len() > DIRECT_BLOCKS {
return Err(FsError::InvalidDisk("inode excede o formato"));
}
let mut out = [0_u8; INODE_SIZE];
out[0] = u8::from(self.used);
out[1] = self.kind as u8;
out[2..4].copy_from_slice(&self.parent.to_le_bytes());
out[4..12].copy_from_slice(&self.size.to_le_bytes());
out[12..14].copy_from_slice(&(self.blocks.len() as u16).to_le_bytes());
out[14] = name.len() as u8;
for (index, block) in self.blocks.iter().enumerate() {
let start = 16 + index * 4;
out[start..start + 4].copy_from_slice(&block.to_le_bytes());
}
let name_start = 16 + DIRECT_BLOCKS * 4;
out[name_start..name_start + name.len()].copy_from_slice(name);
Ok(out)
}
pub fn decode(bytes: &[u8; INODE_SIZE]) -> Result<Self, FsError> {
if bytes[0] == 0 {
return Ok(Self {
used: false,
kind: InodeKind::File,
parent: 0,
size: 0,
blocks: Vec::new(),
name: String::new(),
});
}
let kind = match bytes[1] {
1 => InodeKind::File,
2 => InodeKind::Directory,
_ => return Err(FsError::InvalidDisk("tipo de inode desconhecido")),
};
let parent = u16::from_le_bytes([bytes[2], bytes[3]]);
let size = u64::from_le_bytes(bytes[4..12].try_into().unwrap());
let block_count = u16::from_le_bytes([bytes[12], bytes[13]]) as usize;
let name_len = bytes[14] as usize;
if block_count > DIRECT_BLOCKS || name_len > MAX_NAME_BYTES {
return Err(FsError::InvalidDisk("inode possui contagem inválida"));
}
let mut blocks = Vec::with_capacity(block_count);
for index in 0..block_count {
let start = 16 + index * 4;
blocks.push(u32::from_le_bytes(bytes[start..start + 4].try_into().unwrap()));
}
let name_start = 16 + DIRECT_BLOCKS * 4;
let name = std::str::from_utf8(&bytes[name_start..name_start + name_len])
.map_err(|_| FsError::InvalidDisk("nome não é UTF-8"))?
.to_owned();
Ok(Self { used: true, kind, parent, size, blocks, name })
}
}
src/error.rsNomeia cada falha possível
Ver código completo de src/error.rs
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum FsError {
Io(std::io::Error),
InvalidDisk(&'static str),
NotFound(String),
AlreadyExists(String),
NotDirectory(String),
IsDirectory(String),
DirectoryNotEmpty(String),
NoSpace,
TooLarge,
InvalidPath(String),
}
impl Display for FsError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "erro de I/O: {error}"),
Self::InvalidDisk(reason) => write!(f, "disco inválido: {reason}"),
Self::NotFound(path) => write!(f, "caminho não encontrado: {path}"),
Self::AlreadyExists(path) => write!(f, "caminho já existe: {path}"),
Self::NotDirectory(path) => write!(f, "não é diretório: {path}"),
Self::IsDirectory(path) => write!(f, "é um diretório: {path}"),
Self::DirectoryNotEmpty(path) => write!(f, "diretório não está vazio: {path}"),
Self::NoSpace => write!(f, "não há espaço livre no disco"),
Self::TooLarge => write!(f, "arquivo excede o limite de blocos diretos"),
Self::InvalidPath(path) => write!(f, "caminho inválido: {path}"),
}
}
}
impl std::error::Error for FsError {}
impl From<std::io::Error> for FsError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
src/filesystem.rsImplementa alocação, caminhos e operações
Ver código completo de src/filesystem.rs
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use crate::error::FsError;
use crate::inode::{Inode, InodeKind};
use crate::layout::*;
pub struct FileSystem {
disk: File,
inodes: Vec<Inode>,
bitmap: [u8; BLOCK_SIZE],
}
impl FileSystem {
pub fn format(path: impl AsRef<Path>) -> Result<(), FsError> {
let mut disk = OpenOptions::new()
.create(true).truncate(true).read(true).write(true).open(path)?;
disk.set_len(DISK_SIZE as u64)?;
let mut superblock = [0_u8; BLOCK_SIZE];
superblock[..8].copy_from_slice(MAGIC);
superblock[8..10].copy_from_slice(&VERSION.to_le_bytes());
superblock[10..12].copy_from_slice(&(BLOCK_SIZE as u16).to_le_bytes());
superblock[12..16].copy_from_slice(&(BLOCK_COUNT as u32).to_le_bytes());
superblock[16..18].copy_from_slice(&(INODE_COUNT as u16).to_le_bytes());
superblock[18..22].copy_from_slice(&(DATA_START_BLOCK as u32).to_le_bytes());
disk.seek(SeekFrom::Start(block_offset(SUPERBLOCK_BLOCK)))?;
disk.write_all(&superblock)?;
let mut bitmap = [0_u8; BLOCK_SIZE];
for block in 0..DATA_START_BLOCK {
set_bit(&mut bitmap, block, true);
}
disk.seek(SeekFrom::Start(block_offset(BITMAP_BLOCK)))?;
disk.write_all(&bitmap)?;
disk.seek(SeekFrom::Start(inode_offset(0)))?;
disk.write_all(&Inode::root().encode()?)?;
disk.sync_all()?;
Ok(())
}
pub fn open(path: impl AsRef<Path>) -> Result<Self, FsError> {
let mut disk = OpenOptions::new().read(true).write(true).open(path)?;
if disk.metadata()?.len() != DISK_SIZE as u64 {
return Err(FsError::InvalidDisk("tamanho inesperado"));
}
let mut superblock = [0_u8; BLOCK_SIZE];
disk.read_exact(&mut superblock)?;
if &superblock[..8] != MAGIC {
return Err(FsError::InvalidDisk("magic bytes incorretos"));
}
if u16::from_le_bytes([superblock[8], superblock[9]]) != VERSION {
return Err(FsError::InvalidDisk("versão não suportada"));
}
let mut bitmap = [0_u8; BLOCK_SIZE];
disk.seek(SeekFrom::Start(block_offset(BITMAP_BLOCK)))?;
disk.read_exact(&mut bitmap)?;
let mut inodes = Vec::with_capacity(INODE_COUNT);
for index in 0..INODE_COUNT {
let mut bytes = [0_u8; INODE_SIZE];
disk.seek(SeekFrom::Start(inode_offset(index)))?;
disk.read_exact(&mut bytes)?;
inodes.push(Inode::decode(&bytes)?);
}
if !inodes[0].used || inodes[0].kind != InodeKind::Directory {
return Err(FsError::InvalidDisk("diretório raiz ausente"));
}
Ok(Self { disk, inodes, bitmap })
}
pub fn mkdir(&mut self, path: &str) -> Result<(), FsError> {
let (parent, name) = self.resolve_parent(path)?;
self.ensure_missing(parent, &name, path)?;
let index = self.free_inode()?;
self.inodes[index] = Inode {
used: true, kind: InodeKind::Directory, parent: parent as u16,
size: 0, blocks: Vec::new(), name,
};
self.flush_inode(index)
}
pub fn copy_in(&mut self, host_path: impl AsRef<Path>, destination: &str) -> Result<(), FsError> {
let data = std::fs::read(host_path)?;
if data.len() > MAX_FILE_SIZE {
return Err(FsError::TooLarge);
}
let (parent, name) = self.resolve_parent(destination)?;
self.ensure_missing(parent, &name, destination)?;
let blocks_needed = data.len().div_ceil(BLOCK_SIZE);
let blocks = self.allocate_blocks(blocks_needed)?;
let inode_index = match self.free_inode() {
Ok(index) => index,
Err(error) => {
self.release_blocks(&blocks);
return Err(error);
}
};
for (chunk, block) in data.chunks(BLOCK_SIZE).zip(&blocks) {
self.disk.seek(SeekFrom::Start(block_offset(*block as usize)))?;
self.disk.write_all(chunk)?;
if chunk.len() < BLOCK_SIZE {
self.disk.write_all(&vec![0_u8; BLOCK_SIZE - chunk.len()])?;
}
}
self.inodes[inode_index] = Inode {
used: true, kind: InodeKind::File, parent: parent as u16,
size: data.len() as u64, blocks, name,
};
self.flush_inode(inode_index)?;
self.flush_bitmap()
}
pub fn read_file(&mut self, path: &str) -> Result<Vec<u8>, FsError> {
let index = self.resolve(path)?;
let inode = self.inodes[index].clone();
if inode.kind == InodeKind::Directory {
return Err(FsError::IsDirectory(path.to_owned()));
}
let mut data = Vec::with_capacity(inode.size as usize);
for block in inode.blocks {
let mut bytes = [0_u8; BLOCK_SIZE];
self.disk.seek(SeekFrom::Start(block_offset(block as usize)))?;
self.disk.read_exact(&mut bytes)?;
let remaining = inode.size as usize - data.len();
data.extend_from_slice(&bytes[..remaining.min(BLOCK_SIZE)]);
}
Ok(data)
}
pub fn list(&self, path: &str) -> Result<Vec<(String, InodeKind, u64)>, FsError> {
let parent = self.resolve(path)?;
if self.inodes[parent].kind != InodeKind::Directory {
return Err(FsError::NotDirectory(path.to_owned()));
}
let mut entries: Vec<_> = self.inodes.iter()
.filter(|inode| inode.used && inode.parent as usize == parent && !inode.name.is_empty())
.map(|inode| (inode.name.clone(), inode.kind, inode.size))
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok(entries)
}
pub fn remove(&mut self, path: &str) -> Result<(), FsError> {
let index = self.resolve(path)?;
if index == 0 {
return Err(FsError::InvalidPath("não é possível remover /".into()));
}
if self.inodes[index].kind == InodeKind::Directory
&& self.inodes.iter().any(|inode| inode.used && inode.parent as usize == index)
{
return Err(FsError::DirectoryNotEmpty(path.to_owned()));
}
let blocks = self.inodes[index].blocks.clone();
self.release_blocks(&blocks);
self.inodes[index] = Inode::decode(&[0_u8; INODE_SIZE])?;
self.flush_inode(index)?;
self.flush_bitmap()
}
fn resolve(&self, path: &str) -> Result<usize, FsError> {
if path == "/" { return Ok(0); }
let parts = clean_parts(path)?;
let mut current = 0;
for part in parts {
if self.inodes[current].kind != InodeKind::Directory {
return Err(FsError::NotDirectory(part.to_owned()));
}
current = self.child(current, part)
.ok_or_else(|| FsError::NotFound(path.to_owned()))?;
}
Ok(current)
}
fn resolve_parent(&self, path: &str) -> Result<(usize, String), FsError> {
let mut parts = clean_parts(path)?;
let name = parts.pop().ok_or_else(|| FsError::InvalidPath(path.to_owned()))?.to_owned();
if name.as_bytes().len() > MAX_NAME_BYTES {
return Err(FsError::InvalidPath("nome excede 32 bytes".into()));
}
let parent_path = if parts.is_empty() { "/".to_owned() } else { format!("/{}", parts.join("/")) };
let parent = self.resolve(&parent_path)?;
if self.inodes[parent].kind != InodeKind::Directory {
return Err(FsError::NotDirectory(parent_path));
}
Ok((parent, name))
}
fn child(&self, parent: usize, name: &str) -> Option<usize> {
self.inodes.iter().position(|inode| {
inode.used && inode.parent as usize == parent && inode.name == name
})
}
fn ensure_missing(&self, parent: usize, name: &str, path: &str) -> Result<(), FsError> {
if self.child(parent, name).is_some() {
Err(FsError::AlreadyExists(path.to_owned()))
} else {
Ok(())
}
}
fn free_inode(&self) -> Result<usize, FsError> {
(1..INODE_COUNT).find(|index| !self.inodes[*index].used).ok_or(FsError::NoSpace)
}
fn allocate_blocks(&mut self, count: usize) -> Result<Vec<u32>, FsError> {
let blocks: Vec<u32> = (DATA_START_BLOCK..BLOCK_COUNT)
.filter(|block| !get_bit(&self.bitmap, *block))
.take(count)
.map(|block| block as u32)
.collect();
if blocks.len() != count {
return Err(FsError::NoSpace);
}
for block in &blocks {
set_bit(&mut self.bitmap, *block as usize, true);
}
Ok(blocks)
}
fn release_blocks(&mut self, blocks: &[u32]) {
for block in blocks {
set_bit(&mut self.bitmap, *block as usize, false);
}
}
fn flush_inode(&mut self, index: usize) -> Result<(), FsError> {
self.disk.seek(SeekFrom::Start(inode_offset(index)))?;
self.disk.write_all(&self.inodes[index].encode()?)?;
self.disk.sync_data()?;
Ok(())
}
fn flush_bitmap(&mut self) -> Result<(), FsError> {
self.disk.seek(SeekFrom::Start(block_offset(BITMAP_BLOCK)))?;
self.disk.write_all(&self.bitmap)?;
self.disk.sync_data()?;
Ok(())
}
}
fn clean_parts(path: &str) -> Result<Vec<&str>, FsError> {
if !path.starts_with('/') || path.contains("//") {
return Err(FsError::InvalidPath(path.to_owned()));
}
let parts: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect();
if parts.iter().any(|part| *part == "." || *part == "..") {
return Err(FsError::InvalidPath(path.to_owned()));
}
Ok(parts)
}
fn get_bit(bitmap: &[u8; BLOCK_SIZE], block: usize) -> bool {
bitmap[block / 8] & (1 << (block % 8)) != 0
}
fn set_bit(bitmap: &mut [u8; BLOCK_SIZE], block: usize, used: bool) {
let mask = 1 << (block % 8);
if used { bitmap[block / 8] |= mask; } else { bitmap[block / 8] &= !mask; }
}
src/main.rsTransforma argumentos em comandos
Ver código completo de src/main.rs
use raizfs::{FileSystem, InodeKind};
fn main() {
if let Err(error) = run() {
eprintln!("erro: {error}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
match args.as_slice() {
[_, command, disk] if command == "format" => {
FileSystem::format(disk)?;
println!("disco formatado: {disk}");
}
[_, command, disk, path] if command == "mkdir" => {
FileSystem::open(disk)?.mkdir(path)?;
}
[_, command, disk, source, destination] if command == "copy" => {
FileSystem::open(disk)?.copy_in(source, destination)?;
}
[_, command, disk, path] if command == "ls" => {
for (name, kind, size) in FileSystem::open(disk)?.list(path)? {
let marker = if kind == InodeKind::Directory { "d" } else { "-" };
println!("{marker} {size:>8} {name}");
}
}
[_, command, disk, path] if command == "cat" => {
let bytes = FileSystem::open(disk)?.read_file(path)?;
print!("{}", String::from_utf8_lossy(&bytes));
}
[_, command, disk, path] if command == "rm" => {
FileSystem::open(disk)?.remove(path)?;
}
_ => {
eprintln!("uso:");
eprintln!(" raizfs format <disco>");
eprintln!(" raizfs mkdir <disco> </diretorio>");
eprintln!(" raizfs copy <disco> <origem> </destino>");
eprintln!(" raizfs ls <disco> </diretorio>");
eprintln!(" raizfs cat <disco> </arquivo>");
eprintln!(" raizfs rm <disco> </caminho>");
std::process::exit(2);
}
}
Ok(())
}
tests/filesystem.rsProva persistência, leitura e remoção
Ver código completo de tests/filesystem.rs
use std::time::{SystemTime, UNIX_EPOCH};
use raizfs::{FileSystem, FsError, InodeKind};
fn temp_path(name: &str) -> std::path::PathBuf {
let id = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
std::env::temp_dir().join(format!("raizfs-{name}-{id}"))
}
#[test]
fn format_copy_list_read_and_remove() {
let disk = temp_path("disk");
let source = temp_path("source");
std::fs::write(&source, b"ola, blocos!").unwrap();
FileSystem::format(&disk).unwrap();
let mut fs = FileSystem::open(&disk).unwrap();
fs.mkdir("/docs").unwrap();
fs.copy_in(&source, "/docs/nota.txt").unwrap();
let entries = fs.list("/docs").unwrap();
assert_eq!(entries, vec![("nota.txt".into(), InodeKind::File, 12)]);
assert_eq!(fs.read_file("/docs/nota.txt").unwrap(), b"ola, blocos!");
fs.remove("/docs/nota.txt").unwrap();
assert!(fs.list("/docs").unwrap().is_empty());
fs.remove("/docs").unwrap();
let _ = std::fs::remove_file(disk);
let _ = std::fs::remove_file(source);
}
#[test]
fn refuses_to_remove_non_empty_directory() {
let disk = temp_path("non-empty");
let source = temp_path("source");
std::fs::write(&source, b"x").unwrap();
FileSystem::format(&disk).unwrap();
let mut fs = FileSystem::open(&disk).unwrap();
fs.mkdir("/docs").unwrap();
fs.copy_in(&source, "/docs/x").unwrap();
assert!(matches!(fs.remove("/docs"), Err(FsError::DirectoryNotEmpty(_))));
let _ = std::fs::remove_file(disk);
let _ = std::fs::remove_file(source);
}
Por que serializamos manualmente?
Poderíamos usar uma biblioteca, mas escrever os campos explicitamente torna o formato observável. to_le_bytes decide endianess.copy_from_slice decide offsets. read_exact impede aceitar estruturas truncadas. Cada linha corresponde a uma regra que podemos localizar no editor hexadecimal.
Onde estão as validações importantes?
Ao abrir, verificamos tamanho, magic, versão e raiz. Ao decodificar inode, limitamos contagem de blocos e nome. Caminhos rejeitam componentes perigosos. Leituras usam o tamanho real. Remoções protegem diretórios não vazios. Um formato didático não precisa ser ingênuo para permanecer compreensível.
O que os testes realmente provam?
O teste principal cria um disco temporário, formata, cria/documentos, copia bytes para dentro, fecha o objeto, abre o mesmo disco novamente e compara a leitura com a entrada. Esse fechamento no meio é essencial: ele prova persistência, não apenas uma estrutura que ficou na RAM. Outro teste remove o arquivo e verifica que o caminho deixa de existir.
16 / FAT, NTFS, EXT4 E APFS
Os nomes mudam, mas os problemas fundamentais já apareceram no RaizFS.
FAT encadeia unidades por uma tabela: cada cluster aponta para o próximo. EXT4 usa inodes, bitmaps e extents. NTFS organiza grande parte dos metadados na Master File Table. APFS foi projetado para SSDs, snapshots, clones e copy-on-write. Nenhum deles é “um RaizFS maior”, mas todos precisam responder onde estão os dados, como localizar nomes e como sobreviver a mudanças.
| Sistema | Ideia marcante | Relação conceitual |
|---|---|---|
| FAT | cadeia de clusters | números conectam partes do arquivo |
| NTFS | Master File Table | catálogo central de registros |
| EXT4 | inodes, bitmaps e extents | metadados separados da alocação |
| APFS | copy-on-write e snapshots | alterações criam novas estruturas antes de publicar |
O que falta para montar o RaizFS no sistema operacional?
Nosso executável abre o disco diretamente. Para aparecer no explorador de arquivos, precisaríamos escrever um driver ou uma integração como FUSE, que recebe operações do sistema operacional e as traduz para nosso formato. Também precisaríamos permissões, datas, renomeação, escrita parcial, cache, locks, consistência após falhas e limites muito maiores.
Reduzimos a quantidade de regras, não a importância das perguntas.
17 / DO ZERO AO CAT
Seis comandos permitem observar todo o ciclo de vida de um arquivo.
cargo run -- format meu_hd.disk
cargo run -- mkdir meu_hd.disk /documentos
echo "meu primeiro arquivo" > notas.txt
cargo run -- copy meu_hd.disk notas.txt /documentos/notas.txt
cargo run -- ls meu_hd.disk /documentos
cargo run -- cat meu_hd.disk /documentos/notas.txt
cargo run -- rm meu_hd.disk /documentos/notas.txt
cargo testFaça experimentos: copie um arquivo de 513 bytes e observe dois blocos; remova arquivos alternados e crie outro maior; altere RAIZFS01 em um editor hexadecimal; troque o número de um bloco no inode; tente remover uma pasta não vazia. Cada falha conecta um byte persistido a uma regra de engenharia.
Agora podemos responder à pergunta inicial. Um sistema operacional encontra um arquivo porque existe uma cadeia de estruturas persistentes: o volume identifica seu formato, diretórios resolvem nomes, metadados informam tamanho e localização, e o alocador administra as unidades físicas que carregam os bytes.
18 / PERGUNTAS FREQUENTES
Dúvidas que aparecem quando um arquivo deixa de ser apenas um nome.
Um arquivo e um sistema de arquivos são a mesma coisa?
Não. Um arquivo é uma sequência de bytes com um nome. O sistema de arquivos é a estrutura que encontra, organiza, grava, modifica e remove milhares dessas sequências dentro de um dispositivo.
O RaizFS é um sistema de arquivos real?
Ele é funcional e persiste diretórios e arquivos em um disco virtual, mas foi reduzido para ensino. Não possui journal, permissões, links, cache, concorrência nem montagem pelo sistema operacional.
Por que dividir o disco em blocos?
Blocos transformam um espaço enorme de bytes em unidades numeradas e administráveis. O sistema pode marcar unidades livres, alocar algumas para um arquivo e encontrá-las novamente sem registrar cada byte individual.
O que é um inode?
É um registro de metadados. No RaizFS ele informa tipo, diretório pai, nome, tamanho e números dos blocos. Em sistemas Unix reais, o nome normalmente pertence à entrada do diretório, não ao inode.
O que o bitmap armazena?
Um bit por bloco: zero para livre e um para ocupado. Assim, 2.048 blocos podem ser descritos usando apenas 256 bytes.
Apagar um arquivo apaga imediatamente seus bytes?
No RaizFS, remover libera os blocos no bitmap e limpa o inode, mas não sobrescreve cada bloco. Os dados antigos permanecem até que novos arquivos reutilizem o espaço.
Qual é a diferença entre fragmentação interna e externa?
A interna é o espaço desperdiçado dentro do último bloco de um arquivo. A externa ocorre quando os blocos livres existem, mas estão espalhados pelo disco.
Por que não implementar EXT4 ou NTFS completos?
Esses formatos acumulam décadas de regras, recuperação, segurança e otimizações. O objetivo é reconstruir as ideias fundamentais em um formato pequeno o bastante para ser compreendido por inteiro.
A CONVERSA CONTINUA
O que este artigo fez você pensar?
Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.