ADRIANOLAUREANO← Artigos

Como um computador executa um programa por dentro

Da abertura de um executável ao primeiro ciclo da CPU, construindo um loader simplificado em Rust.

NO DISCOprograma.lumebytes imóveis
LOADERvalidar · mapear · prepararo nascimento do processo
NA CPUentry pointinstruções em movimento
O loader é a ponte entre um arquivo armazenado e um processo executando.
3formatos reais comparados
4segmentos mapeados
64 KiBmemória virtual simulada
1mini-loader funcional

01 / O INSTANTE QUE ESCONDE TUDO

Você dá dois cliques em um programa. Um instante depois ele está funcionando. O que aconteceu nesse intervalo?

O ícone reage, uma janela aparece e parece que o arquivo simplesmente começou a executar. Mas a CPU não sabe abrir arquivos. Ela não entende nomes como editor.exe, não procura uma função chamada main e não arrasta o programa inteiro para a RAM.

Entre o clique e a primeira instrução existe uma sequência coordenada. O sistema localiza o executável, reconhece seu formato, valida sua arquitetura, cria um processo, prepara uma memória privada, mapeia código e dados, resolve bibliotecas, monta a stack, inicializa o runtime e só então entrega um endereço à CPU.

01Clique
02Executável
03Loader
04Memória
05Runtime
06Entry point
07CPU
A IDEIA CENTRAL

Executar não é “abrir um arquivo”. É construir um ambiente vivo a partir das instruções e dos dados descritos por esse arquivo.

02 / A SÉRIE CHEGA À EXECUÇÃO

Até agora organizamos os bytes. Hoje vamos fazê-los ganhar vida.

Começamos descobrindo que extensão e conteúdo não são a mesma coisa. Depois vimos pixels virarem chunks em um PNG, vários arquivos virarem um arquivador e um disco vazio virar o RaizFS. Cada artigo acrescentou uma camada.

01Identificarextensão, magic e formato
02Representarpixels, chunks e compressão
03Agruparíndice, offsets e integridade
04Armazenarblocos, inodes e diretórios
05Executarloader, memória e CPU

Agora já sabemos como o sistema encontra os bytes no disco. A pergunta muda: como esses bytes deixam de ser conteúdo passivo e passam a controlar cálculos, janelas, redes e jogos?

03 / DUAS PALAVRAS PARECIDAS

Programa é o arquivo guardado. Processo é o programa acontecendo.

Uma receita em um livro continua igual mesmo quando ninguém cozinha. O prato sendo preparado possui utensílios, ingredientes em uso e um estado que muda a cada minuto. Programa e processo se relacionam da mesma forma.

ProgramaProcesso
bytes persistidos no discoestado vivo mantido pelo sistema operacional
pode ficar anos sem mudarmuda a cada instrução
descreve código e dados iniciaispossui memória, registradores, recursos e identidade
um arquivo pode ser compartilhadocada execução é uma instância separada

Se abrirmos o mesmo editor três vezes, existe um executável no disco e três processos. Eles podem compartilhar páginas de código somente leitura, mas cada um mantém documento, stack, heap e estado próprios.

04 / O CONTRATO DO EXECUTÁVEL

Windows, Linux e macOS usam formatos diferentes para responder às mesmas perguntas.

Um executável não é apenas uma fileira de instruções. Antes do código existem cabeçalhos e tabelas que informam arquitetura, segmentos, permissões, bibliotecas e entry point. O loader precisa desse mapa para não adivinhar.

SistemaFormato comumAssinatura conhecidaOrganização principal
WindowsPE4D 5A — MZseções e cabeçalhos PE/COFF
Linux e outros UnixELF7F 45 4C 46program headers e segmentos
macOS e iOSMach-Omagic depende da varianteload commands e segmentos

Os nomes e detalhes mudam, mas todos respondem: este arquivo serve para qual arquitetura? Que regiões vão para a memória? Quais podem ser executadas ou alteradas? De quais bibliotecas depende? Em qual endereço a execução começa?

Seção descreve uma organização útil para linkedição e análise do arquivo. Segmento costuma descrever o que será mapeado em memória. Eles podem se sobrepor conceitualmente, mas não são sinônimos perfeitos em formatos reais.

Por que não basta colocar as instruções uma depois da outra?

Imagine receber um livro sem capa, índice, números de página ou separação entre texto e anotações. Mesmo conhecendo o idioma, você não saberia por onde começar nem quais trechos podem ser alterados. Uma CPU sofre um problema parecido diante de bytes crus.

Alguns bytes representam instruções; outros são textos, números iniciais ou tabelas para o carregador dinâmico. Se o sistema marcasse tudo como executável e gravável, um erro poderia transformar dados modificados em instruções. Separar regiões permite aplicar permissões diferentes e detectar operações perigosas.

O nome do arquivo não decide a arquitetura

Dois arquivos chamados programa.exe podem conter código para processadores diferentes. O cabeçalho registra se as instruções foram produzidas para x86-64, ARM64 ou outra arquitetura. O loader compara essa informação com a máquina atual antes de entregar qualquer byte à CPU.

Também existe a ABI, o acordo sobre chamadas, registradores, stack, tipos e interação com o sistema. Formato reconhecido e arquitetura correta ainda não garantem que todas as convenções esperadas pelo programa estejam disponíveis.

05 / O CONSTRUTOR DO PROCESSO

O loader lê o mapa do executável e monta o cenário antes de levantar a cortina.

Loader é o componente responsável pelo carregamento. Parte dele vive no kernel; outra parte pode envolver um carregador dinâmico em espaço de usuário. Para nossa visão didática, trataremos o conjunto como uma sequência única de responsabilidades.

01validar formato
02criar processo
03mapear segmentos
04entregar o controle

“Carregar” também não significa necessariamente copiar o executável inteiro imediatamente. Sistemas modernos mapeiam regiões e trazem páginas conforme necessário. Evitaremos paginação e MMU para manter o foco, mas preservaremos a abstração importante: endereços vistos pelo processo não são posições cruas no arquivo.

O clique não conversa diretamente com a CPU

O explorador de arquivos reconhece que aquele caminho está associado a um executável e pede ao sistema operacional que crie uma nova execução. O kernel verifica permissões e abre o arquivo. A partir daí, o formato orienta o carregamento.

No Linux, uma família de chamadas como execve substitui a imagem do processo atual por outra. No Windows, APIs de criação de processo coordenam a nova execução. No macOS, mecanismos Unix convivem com o formato Mach-O. As APIs diferem, mas o resultado conceitual é o mesmo: surge uma identidade executável com memória e contexto próprios.

Criar um processo é mais do que reservar RAM

O sistema precisa de um identificador, credenciais, tabela de recursos abertos, estado agendável e contexto de CPU. Nosso Process não imita tudo isso. Ele guarda somente o pedaço necessário para observar a execução: memória, ponteiro de instrução, acumulador e um indicador de atividade.

Essa simplificação é importante. Se implementássemos agendador, interrupções, páginas, permissões e chamadas de sistema ao mesmo tempo, o loader desapareceria no meio de assuntos diferentes.

06 / BATIZANDO O PROJETO

Vamos construir o LUME: um pequeno formato executável e o loader que o acende.

Precisamos de um projeto concreto. Seu nome será LUME, porque nosso loader transforma bytes imóveis em uma execução observável — como uma chama pequena o bastante para enxergarmos cada etapa.

O LUME não é PE, ELF nem Mach-O e não executa instruções x86 ou ARM. Ele é uma maquete funcional: possui assinatura, cabeçalho, tabela de segmentos, entry point, memória virtual, stack, heap, runtime e um conjunto minúsculo de bytecodes.

O CONTRATO

Se conseguirmos fechar o programa, reabrir programa.lume, reconstruir o processo e chegar ao resultado 42, teremos reproduzido a espinha dorsal de um carregamento real.

cargo new mini-loader
cd mini-loader

mini-loader/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
│   ├── binary.rs
│   ├── segments.rs
│   ├── memory.rs
│   ├── process.rs
│   ├── loader.rs
│   └── runtime.rs
└── tests/
    └── load.rs

07 / DESENHANDO O EXECUTÁVEL

Antes de escrever o loader, precisamos combinar o significado de cada byte.

0…3LUMEassinatura
4versão1
5quantidadesegmentos
8…11entry pointendereço inicial
12…tabelatipo, endereço, offset e tamanhos
finalbytesconteúdo dos segmentos
O cabeçalho orienta o loader; a tabela conecta posições no arquivo a endereços virtuais.
pub const MAGIC: &[u8; 4] = b"LUME";
pub const VERSION: u8 = 1;

pub struct Executable {
    pub entry_point: u32,
    pub segments: Vec<Segment>,
}

MAGIC impede interpretar qualquer arquivo como LUME. A versão permite mudar o formato no futuro. entry_point é um endereço virtual, não um offset no arquivo. Vec<Segment> guarda as regiões descritas pela tabela.

if &bytes[0..4] != MAGIC {
    return Err(LoadError::InvalidMagic);
}
if bytes[4] != VERSION {
    return Err(LoadError::UnsupportedVersion(bytes[4]));
}

let segment_count = bytes[5] as usize;
let entry_point = read_u32(bytes, 8)?;

O parser verifica tamanho antes de criar fatias, compara assinatura e versão e só depois lê os números. O operador ? encerra a operação ao primeiro erro. Arquivos executáveis são entrada não confiável: validar limites faz parte do loader, não é acabamento opcional.

Uma entrada da tabela funciona como uma instrução para o loader

Cada entrada ocupa 16 bytes. O primeiro informa o tipo. Quatro bytes guardam o endereço virtual. Outros quatro guardam o offset no arquivo. Dois registram quantos bytes realmente existem no arquivo e dois dizem quanto espaço deve existir na memória.

CampoPergunta respondidaExemplo
tipoque papel essa região possui?1 = .text
endereço virtualonde ela aparecerá para o processo?0x1000
offsetonde seus bytes começam no arquivo?76
tamanho no arquivoquantos bytes devemos ler?8
tamanho na memóriaquanto espaço devemos reservar?64

Offset e endereço virtual não são intercambiáveis. O offset pertence ao recipiente em disco; o endereço pertence ao mundo que construiremos para o processo. O loader é justamente a tradução entre esses dois mapas.

Os inteiros são gravados em little-endian: o byte menos significativo aparece primeiro. Escolher uma ordem fixa impede que duas máquinas interpretem a mesma sequência de maneiras diferentes.

08 / CADA BYTE EM SEU LUGAR

Código, constantes e dados possuem necessidades diferentes — por isso vivem em segmentos diferentes.

pub enum SegmentKind {
    Text = 1,
    Data = 2,
    Rodata = 3,
    Bss = 4,
}

pub struct Segment {
    pub kind: SegmentKind,
    pub virtual_address: u32,
    pub memory_size: u32,
    pub bytes: Vec<u8>,
}
SegmentoConteúdoPermissão típicaNo LUME
.textinstruçõesleitura + execução0x1000
.rodataconstantes, textos literaissomente leitura0x2000
.dataglobais inicializadasleitura + escrita0x3000
.bssglobais inicialmente zeradasleitura + escrita0x4000

O detalhe surpreendente está em .bss: seus zeros não precisam ocupar espaço no arquivo. A tabela diz “reserve 64 bytes”, mas o arquivo fornece zero bytes. O loader cria a região e a preenche com zeros. O executável fica menor sem mudar o estado inicial.

Nosso sample_program produz quatro descrições. A região .text contém bytecodes; .rodata, “Olá do LUME”; .data começa em 37; .bss nasce com 64 zeros.

.text não significa texto para leitura humana

O nome é histórico. Aqui “text” significa o corpo de código da máquina: opcodes e operandos que a CPU decodifica. Em um editor hexadecimal ele parece apenas uma sequência numérica. Torná-lo executável é uma propriedade do mapeamento, não da aparência dos bytes.

Por que separar .rodata de .data?

A mensagem “Olá do LUME” não deveria mudar durante a execução. Um sistema real pode mapear essa região sem permissão de escrita. Já o valor 37 precisa virar 42, então .data deve aceitar alterações. A separação expressa intenção e permite proteção.

O tamanho em memória pode ser maior que o tamanho no arquivo

Essa diferença explica .bss, mas também permite alinhamento. Processadores e sistemas trabalham melhor quando certas regiões começam em fronteiras apropriadas. Nosso formato ignora alinhamentos complexos, embora mantenha os campos separados para mostrar onde a decisão existiria.

09 / UM MAPA PRIVADO

O processo trabalha com endereços virtuais, como se possuísse seu próprio território.

Quando uma instrução lê 0x3000, esse número não significa “byte 12.288 do pente de RAM”. É um endereço dentro do espaço virtual daquele processo. O sistema operacional mantém a tradução para memória física.

0xFFFFreservado
0xF000stack ↓
espaço livrestack e heap podem crescer
0x8000heap ↑
0x4000.bss
0x3000.data
0x2000.rodata
0x1000.text + entry point
Nosso espaço didático tem 64 KiB. Endereços altos estão no topo; baixos, na base.

No Rust, simulamos esse espaço com um Vec<u8> de 65.536 posições. É uma simplificação: não há páginas nem proteção de hardware. Mesmo assim, arquivo e endereço virtual já ficam separados.

pub fn map(
    &mut self,
    address: u32,
    data: &[u8],
    memory_size: u32,
) -> Result<(), LoadError> {
    let start = address as usize;
    let end = start + memory_size as usize;
    self.bytes[start..start + data.len()].copy_from_slice(data);
    self.bytes[start + data.len()..end].fill(0);
    Ok(())
}

address escolhe o destino. Copiamos apenas os bytes existentes no arquivo e preenchemos até memory_size com zero. A mesma função mapeia todos os segmentos e materializa o .bss.

for segment in executable.segments {
    process.memory.map(
        segment.virtual_address,
        &segment.bytes,
        segment.memory_size,
    )?;
}

Por que o programa não usa o offset do arquivo?

Durante a compilação e a linkedição, referências são organizadas para um layout de memória. Uma instrução pode esperar que uma constante esteja em determinado endereço relativo. Se executássemos diretamente os offsets do arquivo, cabeçalhos e tabelas entrariam no meio do código.

O loader percorre cada descrição e coloca somente o conteúdo correto no endereço combinado. Depois dessa etapa, o processo não precisa saber se .text estava no byte 76 ou 7.600 do arquivo.

Virtual não significa imaginário

Os endereços são reais do ponto de vista do processo: suas instruções leem e escrevem usando-os. “Virtual” significa que existe uma camada de tradução. Isso permite isolamento, compartilhamento controlado, realocação e a impressão de um espaço contínuo mesmo quando a memória física está fragmentada.

No LUME, o índice do vetor faz o papel dessa tradução. É intencionalmente direto: endereço 0x3000 acessa a posição 12.288. Em um sistema real, tabelas de páginas e hardware realizam a tradução com regras muito mais sofisticadas.

10 / MEMÓRIA QUE MUDA DURANTE A EXECUÇÃO

A stack acompanha chamadas; o heap atende dados cujo tamanho e duração surgem em tempo de execução.

A stack funciona como uma pilha de pratos. Uma chamada coloca um quadro com endereço de retorno, parâmetros e variáveis locais; ao retornar, remove o quadro mais recente. Em muitas arquiteturas ela cresce em direção aos endereços menores.

O heap serve a alocações dinâmicas: uma string recebida da rede, uma árvore ou um vetor cujo tamanho não era conhecido durante a compilação. Nosso alocador começa em 0x8000 e apenas move uma fronteira para cima.

STACKmain()calcular()somar()cresce ↓
espaço ainda livreerro quando as regiões se encontram
cresce ↑objeto Avetor BHEAP

Stack overflow acontece quando chamadas ou variáveis locais ultrapassam a região disponível. Falta de heap ocorre quando novas alocações não podem ser atendidas. São memórias com políticas diferentes dentro do mesmo espaço virtual.

Um quadro de stack conta a história de uma chamada

Quando main chama calcular, a execução precisa saber para onde voltar. O quadro pode guardar esse endereço, valores temporários e registradores preservados. Ao terminar, o ponteiro da stack retorna à posição anterior e a CPU continua depois da chamada.

No nosso projeto, push_u32 subtrai quatro do ponteiro e grava um inteiro. Fazemos isso para registrar a quantidade de argumentos. O movimento para baixo não é uma lei universal, mas representa a convenção comum que queremos visualizar.

Nosso heap é simples de propósito

allocate devolve a fronteira atual e avança pelo tamanho solicitado. Não existe free, reaproveitamento ou alinhamento. Esse estilo é conhecido como bump allocator. Ele mostra a essência da reserva sem introduzir listas livres e fragmentação interna no meio do artigo.

11 / CÓDIGO QUE NÃO PRECISA SER REINVENTADO

Bibliotecas compartilhadas permitem que programas usem código preparado fora do executável.

Um programa raramente traz sozinho tudo o que precisa. Funções de interface, arquivos, criptografia e rede podem vir de DLLs no Windows, arquivos .so no Linux e dylibs/frameworks no macOS.

O carregador dinâmico encontra essas bibliotecas, mapeia seus segmentos e resolve símbolos: liga o nome de uma função usada pelo programa ao endereço onde sua implementação foi carregada. Relocações ajustam referências que dependem da posição final.

Processo Aendereço virtual A
Processo Bendereço virtual B
↘ ↓ ↙
páginas físicas compartilhadascódigo somente leitura da biblioteca

O LUME omite resolução dinâmica para não misturar dois projetos grandes. Mas deixa o espaço conceitual correto: bibliotecas seriam novos segmentos mapeados antes de alcançar o entry point.

12 / ANTES DE MAIN

O entry point não costuma ser main(): primeiro o runtime prepara o mundo que main espera encontrar.

main é uma convenção da linguagem. O sistema operacional conhece o entry point registrado no formato. Esse código inicial prepara argumentos, ambiente, estado da linguagem, construtores globais e tratamento de encerramento. Depois chama main; quando ela retorna, converte o resultado em término do processo.

pub fn initialize(
    process: &mut Process,
    arguments: &[String],
) -> Result<(), LoadError> {
    process.memory.push_u32(arguments.len() as u32)?;
    let _runtime_area = process.memory.allocate(64)?;
    Ok(())
}

No LUME, o runtime empilha a quantidade de argumentos e reserva 64 bytes no heap. É pequeno, mas preserva a ordem: processo criado → runtime inicializado → entry point executado.

SISTEMAentry point
RUNTIMEargumentos e ambiente
PROGRAMAmain()
RUNTIMEexit code

Então quem chama main?

Em um programa Rust real, código de inicialização preparado pela toolchain e pelo runtime participa da entrada. Ele recebe o ambiente oferecido pela plataforma, estabelece as condições esperadas pela linguagem e invoca a função do usuário dentro do contrato correto.

Quando main termina, ainda há trabalho: converter o retorno, executar rotinas de encerramento, descarregar buffers e informar o código de saída. Por isso dizer “o sistema chama main” é uma aproximação útil no começo, mas incompleta.

13 / A PRIMEIRA INSTRUÇÃO

Depois de toda a preparação, a CPU recebe um endereço — e começa o ciclo buscar, decodificar, executar.

O sistema restaura o contexto do processo e coloca o endereço inicial no registrador de instruções. A CPU busca os bytes em .text, decodifica o opcode, executa a operação e avança para a próxima instrução.

Nosso Process deixa esse estado visível: instruction_pointer indica a próxima instrução; accumulator guarda um resultado intermediário; running informa se ainda existe execução.

BytecodeAção do LUME
10 slotimprime uma string de .rodata
20 slot valorlê um inteiro de .data e soma
30 slotgrava o acumulador em .data
FFencerra a execução

O programa começa com 37, soma 5 e grava 42. Antes desses opcodes funcionarem, assinatura, tabela, memória, stack, heap e runtime precisaram estar coerentes. A execução curta revela o valor de todas as etapas anteriores.

Buscar

O ponteiro de instrução começa em 0x1000. O interpretador lê o byte daquela posição. Em uma CPU real, caches e unidades de busca tornam a operação muito mais rápida, mas a pergunta permanece: qual é a próxima instrução?

Decodificar

O valor 0x20 significa “somar”. Os bytes seguintes são operandos: qual slot ler e qual valor acrescentar. A arquitetura define como separar opcode e operandos.

Executar

O valor de .data é lido, somado e colocado no acumulador. O opcode seguinte grava o acumulador novamente. Por fim, 0xFF encerra o laço. Instruções reais alteram registradores, memória e fluxo seguindo o mesmo princípio geral.

14 / PROJETO COMPLETO

Agora reunimos as peças em um mini-loader que cabe na cabeça — e no terminal.

Cada módulo responde a uma pergunta que já apareceu: binary.rs entende o arquivo; segments.rs dá nomes às regiões; memory.rs recebe os mapeamentos; process.rs guarda o estado vivo; loader.rs coordena; runtime.rs prepara e executa.

Baixar o projeto completo do LUME
01
Cargo.toml

Define biblioteca, executável e edição do Rust

Ver código completo de Cargo.toml
[package]
name = "mini-loader"
version = "0.1.0"
edition = "2024"

[lib]
name = "mini_loader"
path = "src/lib.rs"

[[bin]]
name = "mini-loader"
path = "src/main.rs"

02
src/segments.rs

Representa .text, .data, .rodata e .bss

Ver código completo de src/segments.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SegmentKind {
    Text = 1,
    Data = 2,
    Rodata = 3,
    Bss = 4,
}

impl SegmentKind {
    pub fn from_byte(value: u8) -> Option<Self> {
        match value {
            1 => Some(Self::Text),
            2 => Some(Self::Data),
            3 => Some(Self::Rodata),
            4 => Some(Self::Bss),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
    pub kind: SegmentKind,
    pub virtual_address: u32,
    pub memory_size: u32,
    pub bytes: Vec<u8>,
}

03
src/binary.rs

Cria e interpreta o formato executável LUME

Ver código completo de src/binary.rs
use crate::loader::LoadError;
use crate::segments::{Segment, SegmentKind};

pub const MAGIC: &[u8; 4] = b"LUME";
pub const VERSION: u8 = 1;
pub const HEADER_SIZE: usize = 12;
pub const SEGMENT_HEADER_SIZE: usize = 16;

#[derive(Debug, Clone)]
pub struct Executable {
    pub entry_point: u32,
    pub segments: Vec<Segment>,
}

impl Executable {
    pub fn parse(bytes: &[u8]) -> Result<Self, LoadError> {
        if bytes.len() < HEADER_SIZE {
            return Err(LoadError::Truncated);
        }
        if &bytes[0..4] != MAGIC {
            return Err(LoadError::InvalidMagic);
        }
        if bytes[4] != VERSION {
            return Err(LoadError::UnsupportedVersion(bytes[4]));
        }

        let segment_count = bytes[5] as usize;
        let entry_point = read_u32(bytes, 8)?;
        let table_end = HEADER_SIZE + segment_count * SEGMENT_HEADER_SIZE;
        if table_end > bytes.len() {
            return Err(LoadError::Truncated);
        }

        let mut segments = Vec::with_capacity(segment_count);
        for index in 0..segment_count {
            let start = HEADER_SIZE + index * SEGMENT_HEADER_SIZE;
            let kind = SegmentKind::from_byte(bytes[start])
                .ok_or(LoadError::InvalidSegmentKind(bytes[start]))?;
            let virtual_address = read_u32(bytes, start + 4)?;
            let file_offset = read_u32(bytes, start + 8)? as usize;
            let file_size = read_u16(bytes, start + 12)? as usize;
            let memory_size = read_u16(bytes, start + 14)? as u32;
            let end = file_offset.checked_add(file_size).ok_or(LoadError::Truncated)?;
            let data = bytes.get(file_offset..end).ok_or(LoadError::Truncated)?.to_vec();

            if data.len() as u32 > memory_size {
                return Err(LoadError::SegmentTooLarge);
            }
            segments.push(Segment { kind, virtual_address, memory_size, bytes: data });
        }
        Ok(Self { entry_point, segments })
    }
}

pub fn sample_program() -> Vec<u8> {
    let text = vec![0x10, 0x00, 0x20, 0x00, 0x05, 0x30, 0x00, 0xFF];
    let data = vec![37, 0, 0, 0];
    let rodata = b"Ola do LUME!\n".to_vec();
    let descriptions = [
        (SegmentKind::Text, 0x1000, text, 8_u16),
        (SegmentKind::Rodata, 0x2000, rodata, 13_u16),
        (SegmentKind::Data, 0x3000, data, 4_u16),
        (SegmentKind::Bss, 0x4000, Vec::new(), 64_u16),
    ];

    let mut output = vec![0_u8; HEADER_SIZE + descriptions.len() * SEGMENT_HEADER_SIZE];
    output[0..4].copy_from_slice(MAGIC);
    output[4] = VERSION;
    output[5] = descriptions.len() as u8;
    output[8..12].copy_from_slice(&0x1000_u32.to_le_bytes());

    let mut file_offset = output.len();
    for (index, (kind, address, data, memory_size)) in descriptions.iter().enumerate() {
        let start = HEADER_SIZE + index * SEGMENT_HEADER_SIZE;
        output[start] = *kind as u8;
        output[start + 4..start + 8].copy_from_slice(&address.to_le_bytes());
        output[start + 8..start + 12].copy_from_slice(&(file_offset as u32).to_le_bytes());
        output[start + 12..start + 14].copy_from_slice(&(data.len() as u16).to_le_bytes());
        output[start + 14..start + 16].copy_from_slice(&memory_size.to_le_bytes());
        output.extend_from_slice(data);
        file_offset += data.len();
    }
    output
}

fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, LoadError> {
    let value = bytes.get(offset..offset + 2).ok_or(LoadError::Truncated)?;
    Ok(u16::from_le_bytes(value.try_into().unwrap()))
}

fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, LoadError> {
    let value = bytes.get(offset..offset + 4).ok_or(LoadError::Truncated)?;
    Ok(u32::from_le_bytes(value.try_into().unwrap()))
}

04
src/memory.rs

Simula memória virtual, stack e heap

Ver código completo de src/memory.rs
use crate::loader::LoadError;

pub const ADDRESS_SPACE_SIZE: usize = 0x10_000;
pub const HEAP_START: u32 = 0x8000;
pub const STACK_TOP: u32 = 0xF000;

#[derive(Debug)]
pub struct Memory {
    bytes: Vec<u8>,
    heap_break: u32,
    stack_pointer: u32,
}

impl Memory {
    pub fn new() -> Self {
        Self {
            bytes: vec![0; ADDRESS_SPACE_SIZE],
            heap_break: HEAP_START,
            stack_pointer: STACK_TOP,
        }
    }

    pub fn map(&mut self, address: u32, data: &[u8], memory_size: u32) -> Result<(), LoadError> {
        let start = address as usize;
        let end = start.checked_add(memory_size as usize).ok_or(LoadError::AddressOutsideMemory)?;
        if end > self.bytes.len() || data.len() > memory_size as usize {
            return Err(LoadError::AddressOutsideMemory);
        }
        self.bytes[start..start + data.len()].copy_from_slice(data);
        self.bytes[start + data.len()..end].fill(0);
        Ok(())
    }

    pub fn read(&self, address: u32, size: usize) -> Result<&[u8], LoadError> {
        self.bytes
            .get(address as usize..address as usize + size)
            .ok_or(LoadError::AddressOutsideMemory)
    }

    pub fn read_u32(&self, address: u32) -> Result<u32, LoadError> {
        Ok(u32::from_le_bytes(self.read(address, 4)?.try_into().unwrap()))
    }

    pub fn write_u32(&mut self, address: u32, value: u32) -> Result<(), LoadError> {
        self.map(address, &value.to_le_bytes(), 4)
    }

    pub fn allocate(&mut self, size: u32) -> Result<u32, LoadError> {
        let address = self.heap_break;
        self.heap_break = self.heap_break.checked_add(size).ok_or(LoadError::OutOfMemory)?;
        if self.heap_break >= self.stack_pointer {
            return Err(LoadError::OutOfMemory);
        }
        Ok(address)
    }

    pub fn push_u32(&mut self, value: u32) -> Result<(), LoadError> {
        self.stack_pointer = self.stack_pointer.checked_sub(4).ok_or(LoadError::OutOfMemory)?;
        self.write_u32(self.stack_pointer, value)
    }
}

impl Default for Memory {
    fn default() -> Self {
        Self::new()
    }
}

05
src/process.rs

Guarda o estado vivo da execução

Ver código completo de src/process.rs
use crate::memory::Memory;

#[derive(Debug)]
pub struct Process {
    pub memory: Memory,
    pub instruction_pointer: u32,
    pub accumulator: u32,
    pub running: bool,
}

impl Process {
    pub fn new(entry_point: u32) -> Self {
        Self {
            memory: Memory::new(),
            instruction_pointer: entry_point,
            accumulator: 0,
            running: true,
        }
    }
}

06
src/loader.rs

Valida, cria o processo e mapeia segmentos

Ver código completo de src/loader.rs
use std::fmt;

use crate::binary::Executable;
use crate::process::Process;
use crate::runtime;

#[derive(Debug)]
pub enum LoadError {
    Truncated,
    InvalidMagic,
    UnsupportedVersion(u8),
    InvalidSegmentKind(u8),
    SegmentTooLarge,
    AddressOutsideMemory,
    OutOfMemory,
    EntryPointNotExecutable,
    InvalidOpcode(u8),
    InvalidText,
    Io(std::io::Error),
}

impl fmt::Display for LoadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{self:?}")
    }
}

impl std::error::Error for LoadError {}

impl From<std::io::Error> for LoadError {
    fn from(value: std::io::Error) -> Self {
        Self::Io(value)
    }
}

pub struct Loader;

impl Loader {
    pub fn load(bytes: &[u8], arguments: &[String]) -> Result<Process, LoadError> {
        let executable = Executable::parse(bytes)?;
        let text = executable
            .segments
            .iter()
            .find(|segment| segment.kind == crate::segments::SegmentKind::Text)
            .ok_or(LoadError::EntryPointNotExecutable)?;
        let text_end = text.virtual_address + text.memory_size;
        if executable.entry_point < text.virtual_address || executable.entry_point >= text_end {
            return Err(LoadError::EntryPointNotExecutable);
        }

        let mut process = Process::new(executable.entry_point);
        for segment in executable.segments {
            process.memory.map(segment.virtual_address, &segment.bytes, segment.memory_size)?;
        }
        runtime::initialize(&mut process, arguments)?;
        Ok(process)
    }
}

07
src/runtime.rs

Prepara o ambiente e interpreta instruções

Ver código completo de src/runtime.rs
use crate::loader::LoadError;
use crate::process::Process;

pub fn initialize(process: &mut Process, arguments: &[String]) -> Result<(), LoadError> {
    process.memory.push_u32(arguments.len() as u32)?;
    let _runtime_area = process.memory.allocate(64)?;
    Ok(())
}

pub fn execute(process: &mut Process) -> Result<String, LoadError> {
    let mut output = String::new();
    while process.running {
        let opcode = process.memory.read(process.instruction_pointer, 1)?[0];
        process.instruction_pointer += 1;
        match opcode {
            0x10 => {
                let slot = process.memory.read(process.instruction_pointer, 1)?[0] as u32;
                process.instruction_pointer += 1;
                let address = 0x2000 + slot * 32;
                let bytes = process.memory.read(address, 32)?;
                let length = bytes.iter().position(|byte| *byte == 0).unwrap_or(bytes.len());
                output.push_str(std::str::from_utf8(&bytes[..length]).map_err(|_| LoadError::InvalidText)?);
            }
            0x20 => {
                let slot = process.memory.read(process.instruction_pointer, 1)?[0] as u32;
                let value = process.memory.read(process.instruction_pointer + 1, 1)?[0] as u32;
                process.instruction_pointer += 2;
                process.accumulator = process.memory.read_u32(0x3000 + slot * 4)? + value;
            }
            0x30 => {
                let slot = process.memory.read(process.instruction_pointer, 1)?[0] as u32;
                process.instruction_pointer += 1;
                process.memory.write_u32(0x3000 + slot * 4, process.accumulator)?;
            }
            0xFF => process.running = false,
            value => return Err(LoadError::InvalidOpcode(value)),
        }
    }
    Ok(output)
}

08
src/main.rs

Demonstra o ciclo completo pelo terminal

Ver código completo de src/main.rs
use mini_loader::binary::sample_program;
use mini_loader::runtime;
use mini_loader::Loader;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let path = "programa.lume";
    std::fs::write(path, sample_program())?;
    println!("executável criado: {path}");

    let bytes = std::fs::read(path)?;
    let arguments = vec!["programa.lume".to_owned()];
    let mut process = Loader::load(&bytes, &arguments)?;
    println!("entry point: 0x{:04X}", process.instruction_pointer);

    let output = runtime::execute(&mut process)?;
    print!("{output}");
    println!("valor final em .data: {}", process.memory.read_u32(0x3000)?);
    Ok(())
}

09
tests/load.rs

Prova carregamento, execução e rejeição

Ver código completo de tests/load.rs
use mini_loader::binary::sample_program;
use mini_loader::runtime;
use mini_loader::Loader;

#[test]
fn loads_maps_and_executes_program() {
    let mut process = Loader::load(&sample_program(), &[]).unwrap();
    let output = runtime::execute(&mut process).unwrap();

    assert_eq!(output, "Ola do LUME!\n");
    assert_eq!(process.memory.read_u32(0x3000).unwrap(), 42);
}

#[test]
fn rejects_unknown_magic() {
    let mut bytes = sample_program();
    bytes[0] = b'X';
    assert!(Loader::load(&bytes, &[]).is_err());
}

Por que o loader valida o entry point?

Não basta o número caber na memória. Ele precisa cair dentro de .text. Caso contrário, o arquivo poderia mandar a CPU começar em dados, zeros ou área não mapeada. O LUME procura o segmento de código e prova que text_start ≤ entry_point < text_end.

O que os testes provam?

O primeiro teste gera o executável, carrega, executa, verifica a mensagem e confirma 42 em .data. O segundo corrompe a assinatura e exige rejeição. Assim testamos o caminho feliz e a fronteira de confiança.

15 / DO ARQUIVO AO RESULTADO

Um comando cria o executável, fecha o ciclo e mostra o processo nascendo.

cargo run

executável criado: programa.lume
entry point: 0x1000
Ola do LUME!
valor final em .data: 42

cargo test

Abra programa.lume em um editor hexadecimal. Os primeiros bytes formam LUME; em 0x1000 da memória virtual aparecem os opcodes, embora eles estejam em outro offset no arquivo. Essa diferença é o coração do carregamento.

Faça experimentos: altere o magic; mude a versão; aponte o entry point para 0x3000; aumente o .bss; troque 37 por 100; invente um opcode. Cada falha conecta um byte do executável a uma regra do loader.

16 / CURIOSIDADES

As pequenas decisões do LUME aparecem, em versões muito maiores, nos sistemas reais.

Por que existe um entry point?

Porque o arquivo pode conter milhares de funções. O loader precisa de um endereço inequívoco para iniciar, inclusive quando a função visível ao programador não é a primeira.

Dois programas podem usar a mesma biblioteca?

Sim. Páginas de código somente leitura podem apontar para a mesma memória física. Cada processo continua enxergando seu espaço virtual privado.

Por que cada processo acredita possuir toda a memória?

O isolamento simplifica programas e protege processos uns dos outros. O mesmo endereço virtual pode corresponder a regiões físicas diferentes em duas execuções.

Por que um executável funciona em um sistema e não em outro?

Formato, arquitetura, ABI, bibliotecas e serviços esperados precisam combinar. Reconhecer PE, ELF ou Mach-O é só o começo do contrato.

O que vem depois?

Agora sabemos como software ganha vida. Essa base prepara os próximos projetos: banco de dados, linguagem, compilador, servidor HTTP e, finalmente, partes de um sistema operacional.

17 / PERGUNTAS FREQUENTES

Dúvidas que aparecem quando o arquivo deixa o disco e encontra a CPU.

O sistema operacional chama main() diretamente?

Normalmente não. O loader entrega o controle ao entry point registrado no executável. Código de inicialização do runtime prepara o ambiente e, só depois, chama main().

Qual é a diferença entre um programa e um processo?

Programa é o conjunto de bytes armazenado no disco. Processo é a execução viva desse programa, com memória, estado da CPU, recursos e identidade próprios.

PE, ELF e Mach-O fazem a mesma coisa?

São formatos diferentes, usados principalmente por Windows, Linux e macOS. Seus detalhes mudam, mas todos descrevem como o loader deve mapear código e dados e onde a execução começa.

Por que cada processo parece possuir toda a memória?

Porque usa endereços virtuais privados. O sistema operacional traduz esses endereços para regiões físicas e pode compartilhar páginas de forma controlada.

Stack e heap são arquivos ou segmentos do executável?

Não. São regiões de memória preparadas para a execução. A stack guarda chamadas e variáveis locais; o heap atende alocações com duração e tamanho dinâmicos.

O que é o entry point?

É o endereço da primeira instrução que deve receber o controle depois do carregamento. Ele geralmente pertence ao runtime, e não à função main escrita pelo programador.

Uma biblioteca compartilhada é copiada inteira para cada processo?

As partes somente de leitura podem compartilhar as mesmas páginas físicas, embora apareçam em espaços virtuais separados. Dados modificáveis continuam isolados.

O LUME executa código de máquina real?

Não. Ele executa bytecodes fictícios para tornar visíveis as responsabilidades do loader sem precisar implementar uma CPU ou um formato industrial completo.

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.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.