Controle fino.
Ideal para boot, registradores, interrupções e mudanças de privilégio.
mov cr3, rax
Marco 27 · fatia 1: imagem do disco na janela
Papel de parede lido do disco no boot, cinco atalhos no fundo, barra de tarefas com hora e data reais do CMOS, e janelas que arrastam, minimizam e fecham. Cada janela tem uma superfície de pixel própria em RAM, e desde o Marco 27 um programa de ring 3 abre um arquivo de imagem e desenha nela, uma linha por leitura.
saída do SOIA
soia> mouse
mouse: x=1080 y=612 botoes=--- pacotes=8
soia> janelas
foco na janela 0 | trocas por clique: 1
soia> run fault.elf
Recusadas 14 de 14 | fronteira intacta
SOIA excecao: falha de pagina | vetor 14 | tarefa 4
soia> echo vivo apos a falha
vivo apos a falha
soia> run net.elf
DHCP 10.0.2.15/24: OK
gateway.soia → 10.0.2.2: OK
TCP SYN → SYN-ACK → ACK: OK
Ambiente seguro
O QEMU cria um laboratório isolado. O SOIA enxerga recursos virtuais; seu Windows e seu disco físico ficam fora do experimento.
O que entregamos
1 CPU virtual, perfil de 64 MiB ou 2 GiB, tela, teclado, COM1 e a imagem do projeto.
O que não entregamos
Seu HD/SSD físico, arquivos pessoais ou acesso direto ao Windows.
Seu ritmo
Você pode trocar de nível a qualquer momento. O assunto continua o mesmo.
Seção especial · C no SOIA
C++ é outra linguagem e ainda não foi habilitada. Aqui o Clang transforma C em instruções x86_64 que o SOIA consegue carregar.
C é uma forma mais curta e organizada de escrever regras. A CPU continua executando código de máquina; o compilador faz a tradução.
O alvo é x86_64-unknown-none-elf.
Não há Windows, libc, runtime hospedado ou símbolos externos.
Ideal para boot, registradores, interrupções e mudanças de privilégio.
mov cr3, rax
Ideal para strings, buffers, comandos, editores e protocolos.
soia_write("ola", 3);
Exigiria definir runtime, construtores, new/delete, exceções e RTTI.
etapa futura
libsoia é a ponte: organiza registradores e chama
INT 0x80. O aplicativo não toca no hardware.
.datavariável com valor gravado no ELF
.bssvariável que começa zerada
W^Xgravável ou executável; nunca ambos
Comandos, histórico, métricas e arquivos.
Ctrl+S persiste NOTE.TXT no disco virtual.
CPU, RAM, SOIAFS1, ATA, scheduler e rede.
DHCP, UDP, DNS e TCP por syscalls validadas.
Capacidade fixa, escrita e blocos livres.
Marco 11 · Interface gráfica
Antes, a VGA entregava uma grade pronta de caracteres. Agora o SOIA recebe uma tela vazia e decide a cor de cada ponto.
O estágio 2 seleciona VBE, copia a fonte BIOS 8 × 16 para
0x6000 e entrega ao kernel o endereço do framebuffer
XRGB8888 informado pelo ModeInfo.
SOIA Terminal 0.7 - rede e processos em ring 3 F1 Terminal | F2 Notas | F3 Monitor | F4 ultimo app soia> status Status do SOIA: CPU: 1 vCPU x86_64 RAM gerenciada: 2 GiB soia> _
O salto conceitual
Pense no framebuffer como papel quadriculado: para colorir um quadrado, o kernel encontra sua posição e grava o número da cor.
O pixel (x, y) fica em
framebuffer + y × 4.096 + x × 4. O valor
0x00RRGGBB contém diretamente os três canais.
Já funciona
1920 × 1080 em janela GTK 1:1,
terminal e notas C, foco por F1/F2/F3, texto 8 × 16, cursor,
Backspace e rolagem.
Custo medido
2,56× mais pixels e cerca de
1,83 MiB adicionais de memória de vídeo.
Marco 13.1 · Monitor do sistema
Terminal, notas e monitor são aplicativos C separados; o kernel conserva as chaves e fornece métricas validadas.
Três ELF executam em CPL 3. O scheduler usa round-robin, SLEEP e HLT; o TSC virtual separa trabalho de tempo ocioso.
A viagem de uma linha
cat até os bytes do disco.SOIA Terminal 0.7 - rede e processos em ring 3
F1 Terminal | F2 Notas | F3 Monitor | F4 ultimo app
soia> echo ola
ola
soia> ticks
ticks: 46
soia> _
Contrato INT 0x80
RAX escolhe o serviço; registradores carregam argumentos e o retorno.
O endereço também é conferido. O shell não pode apontar para qualquer lugar e mandar o kernel escrever.
Leitura: texto, dados, pilha ou heap concedido. Escrita: dados/BSS, pilha e heap RW+NX, nunca acima do break atual.
helpMapa do shellMostra os comandos disponíveis.
clearJanela limpaRedesenha a interface e reposiciona o cursor gráfico.
echo TEXTOTexto de voltaPreserva o trecho digitado depois do espaço.
ticksTempo do kernelConsulta quantos pulsos de 20 Hz ocorreram.
memRAM disponívelConsulta as páginas físicas livres de 4 KiB.
statusResumo honestoMostra CPU pelo TSC virtual, uptime, RAM, SOIAFS1, ATA e tarefas.
lsDiretório raizPercorre as entradas oferecidas pela VFS.
cat ARQUIVOArquivo realLê README.TXT, NOTE.TXT ou outro arquivo da raiz pelo nome.
psTabela de processosMostra PID, estado e nome das vagas ocupadas.
run CALC.ELFNovo processoCria a calculadora, aguarda sua saída e recolhe a memória.
run HEAP.ELFMemória dinâmicaTesta malloc, free, calloc e páginas sob demanda.
run IPC.ELFMensagem isoladaEnvia ping, recebe pong e prova que um handle antigo deixa de valer.
run NET.ELFRede útilMostra DHCP, UDP, DNS e um handshake TCP real no QEMU.
Marco 13.1 · telemetria protegida
SOIA Monitor 0.1
CPU virtual 4%
terminal 0%
notas 0%
monitor 3%
RAM usada 192 KiB
SOIAFS1 105.070 / 2.044.928 B
ATA leituras + escritas · 0 erros
rede RTL8139 DHCP 1 · UDP 2 · DNS 2 · TCP 2
Teste comportamental
O teste injeta comandos e percorre dez posições com as setas. O resultado só aparece se IRQ1, fila, tarefa, syscall, terminal, VFS e ATA cooperarem.
soia> ls
README.TXT
soia> cat readme.txt
Ola do SOIAFS: bytes persistentes no disco virtual.
Limite honesto
Capítulo zero · A base
Antes de estudar kernel, precisamos separar três perguntas: qual é o valor, onde ele está e o que ele significa.
01 + 02 · Bit e byte
Cada bit é uma escolha entre 0 e 1.
Ligue alguns interruptores; os pesos ligados são somados.
Um byte possui 2⁸ = 256 combinações. O bit mais à
direita vale 2⁰; o mais à esquerda vale
2⁷.
Um byte nem sempre viaja bit por bit. A COM1 usa start e stop; o ATA PIO entrega 16 bits por leitura.
A COM1 atual usa 38.400 baud em 8N1: 26,04 µs por bit e 260,42 µs por quadro. Offset localiza bytes já armazenados; não delimita o sinal.
Comparar tempo e enquadramento no PlaygroundPesos ligados: 64 + 16 + 4 + 1 = 85
O U só aparece porque ASCII interpreta
0x55 como texto. Em uma instrução, o mesmo byte teria
outro significado.
03 · Hexadecimal
Hexadecimal conta com dez números e seis letras. Cada casa vai de
0 a F; depois de 0F vem
10.
08090A0B
0C0D0E0F
vai um →10
00 até FF
256 valores possíveis · máximo decimal 255
01015
+
01015
= 0x55
04 · A rua da memória
A base é o número da porteira do terreno; o offset diz quantas casas andar dentro dele. Somando os dois, chegamos ao endereço.
Neste exemplo, segmentos estão zerados e ORG 0x7C00
corresponde à carga da BIOS. Em modo real, a regra geral é
segmento × 16 + deslocamento.
05 · Primeiro setor
A BIOS copia o primeiro setor para 0x7C00. Os
offsets começam em zero, então o último byte fica em
0x1FF, não em 0x200.
0x7C00
assinatura0x7DFE e 0x7DFF
55 AA não é o texto “55 AA”.
São dois valores binários usados pela BIOS como uma marca de setor
inicializável.
06 · CPU e significado
A CPU moderna inicia em um estado compatível com PCs antigos. Nosso carregador prepara gradualmente os ambientes de 32 e 64 bits antes de entregar o controle ao kernel.
Ela começa com regras compatíveis com o 8086 e precisa ser preparada para usar os modos modernos.
É principalmente o tamanho padrão de operandos e registradores. Instruções x86 têm tamanho variável.
O modo real usa segmento × 16 + deslocamento; a linha A20 acrescenta uma nuance no limite.
01010101
→
como número85
como texto ASCIIU
como opcode x86push bp/ebp/rbp
Próxima volta do ciclo
O laboratório mostra offset, hexadecimal, texto e instrução nos arquivos que realmente inicializam o SOIA.
Marcos 09 + 15 · Disco e persistência
O disco é o depósito, o driver busca caixas, o sistema de arquivos organiza e a VFS oferece um catálogo comum.
O kernel usa ATA PIO com LBA28, valida o SOIAFS1 v2,
consulta o bitmap e grava dados e metadados com flush.
Capacidade fixa
33.554.432 bytesdisco virtual de 32 MiB
A distinção que evita a confusão
Setor organiza bytes persistentes no disco. Endereço identifica onde cada byte será colocado na RAM durante a execução.
96 × 512 = 49.152 B
superfícies + desktop + janelas
kernel.bin · 61.440 B
Cento e vinte é o teto, e não uma escolha: o
kernel carrega em 0x10000 e a IDT começa em
0x20000. Passar disso exige mudar a IDT de lugar
antes. O último setor continua preenchido até fechar sua caixa de 512 bytes.
0x100000x1EFFFinício fixo61.440 bytes consecutivosnovo fim
O começo continua em 0x10000. Apenas o fim passa
de 0x1BFFF para 0x1EFFF. O teto é
0x20000, onde começa a IDT: sobraram 4 KiB, e o
próximo recurso grande do kernel vai cobrar a mudança dela de
lugar. Aqui cada endereço nomeia um byte; o kernel também
gerencia a RAM em páginas de 4 KiB.
O disco ganhou vinte e quatro caixas de kernel; na RAM, o mesmo terreno inicial recebeu 12.288 bytes a mais no final, e agora encosta no teto.
A ficha que permite reconhecer e validar o volume antes de confiar nele.
0x000x080x0C0x100x140x30Entradas associam nomes aos dados; um bit marca cada setor livre ou ocupado.
O transporte
O kernel informa a caixa, espera o disco e transporta um setor entre a imagem e a RAM.
0x20 + REP INSW leem; 0x30
+ REP OUTSW escrevem; 0xE7 solicita flush.
A abstração
A VFS recebe um nome. O driver do formato localiza a entrada; o driver de bloco busca os bytes.
vfs_read_file("README.TXT")
└─ soiafs_lookup()
└─ ata_read_sector(40)
Evidência no QEMU
O teste salva NOTE.TXT, fecha o primeiro QEMU e
exige o texto ao iniciar outro com a mesma imagem.
“NOTE.TXT carregado do disco virtual.”
Gravável, com uma fronteira pequena e explícita.
Somente NOTE.TXT, com até 2.047 bytes, pode ser salvo.
A imagem limpa começa com 216 usados · 3.778 livres no
volume. Ainda não existem exclusão, diretórios, journal ou recuperação.
Fundamentos do núcleo
Estes marcos sustentam o terminal atual. O seletor evita repetir toda a história na página; a documentação Markdown conserva o desenvolvimento completo.
A syscall atual pertence ao Marco 10; aqui, Processos explica apenas o isolamento e a troca de tarefas que a tornam possível.
Marco 08 · Isolamento
Cada programa vive em seu próprio apartamento. O número da sala pode ser igual; a planta escolhida pela CPU leva a lugares físicos diferentes.
Cada tarefa possui CR3, tabelas, código e pilhas próprios. PTEs de usuário usam U/S=1; as folhas do kernel permanecem U/S=0.
Autoridade em camadas
A CPU impede que programas comuns executem instruções privilegiadas ou escrevam nas páginas do kernel.
Tradução por tarefa
RIP 0x100000000
O mesmo número não significa o mesmo lugar depois da tradução.
Preempção a 20 Hz
Isolamento, CR3 e scheduler ficam aqui.
O contrato atual de INT 0x80, suas trinta e duas syscalls e a
prova interativa pertencem ao Terminal.
Marco 07 · Gerenciamento
A BIOS entrega uma planta. O kernel abre caminhos somente até a memória existente e distribui quartos de 4 KiB sem colisões.
E820 descreve intervalos físicos; 1.024 PDEs de 2 MiB ampliam o mapa e um bitmap controla 524.288 quadros de 4 KiB.
Mapa recebido da BIOS
Esquema didático: o mapa real contém várias entradas e pode ter pequenos “buracos” reservados.
quais regiões existem?
onde está o mapa?
até 2 GiB identidade
livre ou ocupada?
Dois tipos de endereço
0x020000000x02000000Hoje os números são iguais: isso é um mapeamento identidade. A tradução ainda existe.
Um bit por página
1 não pode ser entregue; 0 está livre para alocação.
Autoteste real no boot
A máquina ganhou campainhas
Em vez de perguntar sem parar se algo aconteceu, o kernel dorme. Timer e teclado tocam a campainha quando precisam de atenção.
IRQ0 e IRQ1 passam pelo PIC remapeado, chegam aos vetores
0x20 e 0x21 da IDT e retornam com
IRETQ.
IDT na prática
base 0x20000 · limite 4095
Dentro de uma entrada
0x8E
O endereço da rotina é repartido em três pedaços porque o formato da porta evoluiu junto com a família x86.
O espaço de 8 bits
O vetor é apenas um número de 8 bits usado como índice:
0 a 255.
Viagem completa da tecla A
Tecladolevanta IRQ 1
PICentrega vetor 0x21
CPUsalva RIP, CS e RFLAGS
IDT[33]fornece o endereço da ISR
ISRlê 0x60 e envia EOI
IRETQrestaura e continua
IDT, vetores e a viagem da tecla ficam aqui. A fila de teclado, edição de linha e interpretação do comando estão reunidas no Terminal.
Detalhes do Marco 6A primeira entrega
O bootloader encontra o kernel, prepara a máquina, entrega uma ficha com o ambiente disponível e sai de cena.
O estágio 2 lê 96 setores por INT 13h/AH=42h em
0x10000, obtém o mapa E820, prepara VBE, entra em
long mode e salta com RAX=SOIABOOT e
RDI=&boot_info.
O segundo estágio busca o novo programa nos setores seguintes.
INT 13h/AH=42h lê os LBAs 6–101 em 1000:0000.
O carregador monta o ambiente antes de chamar o núcleo.
A20, GDT, paginação e long mode permanecem responsabilidades do bootloader.
Uma ficha informa memória, tela, serial e endereço do kernel.
A versão 4 descreve VBE, framebuffer, pitch, profundidade, fonte e conserva o mapa E820.
Depois de conferir a ficha, ele limpa a tela e usa seus próprios recursos.
A entrada em 0x10000 valida RAX/RDI e não retorna ao bootloader.
Disco, RAM e CPU
soia.img contém carregador, kernel e SOIAFS1. Código é
carregado na RAM; o volume continua no disco até o kernel pedir
seus setores.
O arquivo soia.img reúne quatro blocos persistentes
que precisam existir antes de a máquina ligar.
O kernel abre o conteúdo que ficou depois dele no disco.
O carregador copia o kernel e cria estruturas temporárias em endereços distintos do computador virtual:
0x1000PML40x2000PDPT0x3000Diretório0x5000Mapa E8200x6000Fonte BIOS 8 × 160x7000VBE ModeInfo0x8000Estágio 20x10000Kernel0x20000IDT0x30000Bitmap · 64 KiB0x100000000Tarefas0x90000PilhaVBEFramebuffer MMIOBootloader e kernel coexistem na RAM, mas têm funções separadas.
A CPU continua em 64 bits; o salto para 0x10000
entrega a execução a outro binário.
O disco guarda código e arquivos. A RAM recebe
o código no boot e os setores do volume quando o kernel os lê.
A CPU troca o dono da execução no salto para 0x10000.
Laboratório hexadecimal
Arquivos .bin não são texto: cada posição contém um
número de 0 a 255. Aqui você pode abrir os bytes reais do SOIA sem
precisar saber binário previamente.
A unidade
FA vira oito bits?
Um dígito hexadecimal representa quatro bits. Por isso dois
dígitos, de 00 a FF, representam um byte.
Seu primeiro comando
Format-Hex.00000000
FA 31 C0 8E D8 8E C0 8E
.1......
1Offsetposição dentro do arquivo
2Bytesvalores em hexadecimal
3Textotentativa de ler como caracteres
Um ponto significa “este byte não forma um caractere imprimível”. Isso não é erro: provavelmente é instrução ou dado.
Seu segundo comando
ndisasm.00007C00
FA
cli
1Endereçoonde a CPU vê a instrução
2Byteo número armazenado
3Assemblytradução para humanos
Nesse contexto, FA significa cli:
desligar interrupções enquanto a pilha é preparada.
Bytes reais do build
Compile o projeto com .\scripts\Build.ps1 para gerar
os dados deste visualizador.
Marcos 14 + 19 · Rede básica e útil
O kernel encontra a placa, negocia DHCP, resolve um nome e completa
TCP; NET.ELF vê resultados, nunca portas ou buffers DMA.
Analogia
Ethernet é o envelope; DHCP entrega a configuração; DNS acha um endereço; TCP confirma que os dois lados podem conversar.
Negociação real
O endereço não é mais constante: DHCP entrega
10.0.2.15/24, gateway .2 e DNS .3.
UDP 68 → 67. O cliente transmite DISCOVER e REQUEST em broadcast.
Opções 1, 3 e 6. Máscara, gateway e DNS vêm do ACK aceito.
Validação. XID, magic cookie, portas, tipo DHCP e checksum IPv4 precisam coincidir.
Nome seguro e reproduzível
gateway.soia vira 10.0.2.2.A syscall 23 copia até 63 bytes para o kernel. O registro local usa o gateway recebido; o cliente por UDP continua disponível para consultas A externas.
Determinístico. O teste não depende de internet nem do resolver do host.
Formato real. O caminho por fio monta QNAME, valida a resposta e percorre nomes comprimidos.
Limite honesto. A prova automática não afirma resolução externa quando o sandbox bloqueia a resposta.
Handshake mínimo
guestfwd liga 10.0.2.100:8080 a um listener temporário em localhost. A syscall 24 repete a prova para NET.ELF.
Ainda não existem sockets genéricos, payload TCP, retransmissão, FIN, HTTP, TLS ou IPv6.
No fio virtual
qemu-network.pcap
ARP 6 quadros
IPv4 14 quadros
DHCP 4 mensagens
TCP 2 handshakes
Na userland
run NET.ELF.DHCP + UDP OK
gateway.soia OK
TCP :8080 OK
memória devolvida OK
Marco 16 · processos dinâmicos
O kernel prepara um espaço isolado para o programa e limpa tudo quando ele sai. As syscalls 15–18 implementam spawn, exit, wait e leitura validada da tabela de processos.
Experimento útil
run CALC.ELFA calculadora aceita inteiros, + - * /, trata divisão por zero e encerra com sair.
Ciclo do pai. spawn + wait cria o filho e bloqueia o terminal sem polling.
Tabela de oito. Três vagas residentes e cinco vagas dinâmicas.
Estados reais. Livre, pronto, dormindo, aguardando e finalizado.
Sem vazamento. O teste exige a mesma contagem de páginas antes e depois.
soia> ps
PID ESTADO PROGRAMA
1 executando TERMINAL.ELF
2 dormindo NOTEPAD.ELF
3 dormindo MONITOR.ELF
soia> run calc.elf
Processo iniciado; PID 4.
calc> -7 + 2
resultado: -5
exit primeiro marca o filho como zombie, pois sua pilha
de kernel ainda está em uso. Depois da troca de contexto, o pai
recolhe CR3, sete páginas de tabelas, oito de userland e uma pilha
de kernel. O mesmo loader valida CALC.ELF e continua
rejeitando deliberadamente BROKEN.ELF.
A pergunta que veio da tela
Com o CALC.ELF recebendo conta e respondendo, a coluna ESTADO dizia dormindo em toda atualização do monitor. A contagem estava certa. A pergunta é que estava mal feita.
PID PROGRAMA ESTADO CPU% CICLOS BASE HEAP TOTAL
1 TERMINAL.ELF dormindo 0% 982.872.970 65.536 0 65.536
2 NOTEPAD.ELF dormindo 0% 80.062.113 49.152 0 49.152
3 MONITOR.ELF executando 6% 2.360.976.015 61.440 0 61.440
Como é. READ_KEY não bloqueia: sem tecla na fila ela devolve SOIA_KEY_NONE e o app chama soia_cpu_relax(), que é soia_sleep(1). O .sleep grava TASK_STATE_SLEEPING e cede a CPU no mesmo instante, então sempre que outra tarefa está rodando, a vaga do app já voltou a dormir. Não é sorte de amostragem: flagrar o app em READY exigiria a preempção cair na fresta de microssegundos entre acordar e dormir de novo.
Por que assim. A alternativa seria bloquear a tarefa no teclado e acordá-la pela IRQ1. Só que o foco decide quem recebe tecla, e a IRQ1 empilha seis registradores em vez do quadro completo: mexer no despacho a partir dela é exatamente o que o Ctrl+C do Marco 21 teve de evitar, adiando o encerramento para o tick. Enquanto o teclado tiver dono único, polling é o contrato mais simples que funciona.
O que muda. A tabela ganhou CPU%, que é o delta de ciclos da vaga contra o TSC decorrido na janela de 500 ms, e CICLOS, o acumulado desde que a vaga nasceu. O PROCESS_INFO foi de 48 para 56 bytes. Continua impossível ler tempo de CPU em milissegundos, porque a coluna é de ciclos crus.
Camada essencial
Imagine conferir se um caixa de supermercado está trabalhando tirando uma foto a cada meio segundo. Ele atende um cliente em dois segundos e espera trinta. Quase toda foto vai pegá-lo parado, e nenhuma foto isolada responde quanto ele produziu no dia.
ESTADO é a foto, e ela nunca vai deixar de existir: saber que a vaga está dormindo é a informação certa para o escalonador. CICLOS é o contador, que só cresce quando o trabalho acontece e nunca anda para trás.
Camada técnica
cmp r14, [current_task]
jne .process_state_ready
cmp r15, TASK_STATE_READY
jne .process_state_ready
mov r15, TASK_STATE_RUNNING
O array task_state nunca guarda RUNNING: quem está na CPU fica marcado READY. O valor é sintetizado na leitura, e apenas para a current_task. Como quem chama a syscall é o monitor, ele é a única tarefa que pode aparecer como executando naquela tabela, em qualquer amostra. O teste do QEMU derruba a bateria se outra vaga aparecer assim.
O detalhe que faria o número mentir
lea rdx, [task_run_cycles]
mov r15, [rdx + r14 * 8]
cmp r14, [current_task]
jne .process_info_cycles_ready
rdtsc ; o quantum em curso
shl rdx, 32
or rax, rdx
sub rax, [task_dispatch_tsc]
add r15, rax
scheduler_account_current_cycles_64 só fecha a conta quando a tarefa deixa a CPU. Sem somar a parcela decorrida, a única linha errada da tabela seria justamente a de quem está trabalhando naquele instante.
A diferença medida
MONITOR.ELF redesenha a cada 500 ms +891.718.054 pico 10%
NOTEPAD.ELF acorda, olha a fila, dorme +4.332.008 pico 0%
Os dois aparecem como vaga viva; o segundo aparece como dormindo em toda amostra e mostra 0% o tempo inteiro, mesmo acordando vinte vezes por segundo. É o mesmo laço da calculadora que motivou a pergunta, e o contador é o que separa um do outro.
Marco 17 · heap + libc mínima
O aplicativo ganha uma bancada privada, reaproveita espaços livres e devolve tudo ao fechar. A syscall 19 mapeia até 16 páginas RW+NX no PT de usuário; first-fit administra blocos alinhados em 16 bytes.
Experimento verificado
run HEAP.ELFTrês alocações atravessam duas páginas, um bloco liberado volta no mesmo endereço e a coleta encerra sem vazamento.
Base fixa. O heap começa vazio em 0x100008000.
Limite honesto. São no máximo 16 páginas, ou 64 KiB por processo.
W^X preservado. Dados dinâmicos são graváveis e nunca executáveis.
Camada essencial
Pequenos blocos vizinhos são unidos. As páginas continuam no processo para evitar trabalho repetido e voltam ao kernel no exit.
Camada técnica
base 0x100008000
break base + 6.288 B
mapa 2 × 4 KiB
limite 0x100018000
soia> run heap.elf
primeiro bloco: 0x0000000100008020
buffer dinamico via WRITE: buffer do heap aceito pela syscall WRITE
bloco liberado reutilizado no mesmo endereco: OK
calloc zerou 128 bytes: OK
paginas fisicas mapeadas sob demanda: 2
Memoria devolvida: OK.
Se uma alocação física falhar, BRK desfaz somente as PTEs
criadas naquela chamada. INVLPG invalida o TLB e a
validação das outras syscalls usa o break atual como limite.
Marco 18 · IPC + serviços
Cada processo tem uma caixa postal privada. O kernel confere o destinatário, copia a mensagem e acorda quem estava esperando. Cada tarefa possui uma fila circular de duas mensagens de até 48 bytes; handles codificam geração e PID para rejeitar identidades obsoletas.
Experimento verificado
run IPC.ELFO terminal envia ping, bloqueia sem consumir CPU e volta a executar quando o filho responde pong.
Identidade temporal. O handle combina geração e PID; reutilizar a vaga não ressuscita uma referência antiga.
Cópia controlada. O remetente não recebe acesso ao CR3 nem aos ponteiros do destinatário.
Espera eficiente. RECEIVE vazio muda a tarefa para RECEIVING; o timer executa outra tarefa.
Camada essencial
O PID diz qual vaga procurar; a geração confirma que ainda é a mesma vida do processo. Ao recolher o filho, o kernel troca a geração.
Camada técnica
handle = geração << 8 | PID
fila = 2 mensagens
dados = até 48 bytes
struct = 64 bytes
soia> run ipc.elf
Processo iniciado; PID 4; handle 772.
ping enviado ao filho: OK
RECEIVE bloqueou sem consumir CPU: OK
pong recebido do filho: OK
conteudo da resposta: OK
handle do remetente: OK
handle antigo rejeitado: OK
Memoria devolvida: OK.
As syscalls 20, 21 e 22 abrem um handle, enviam e recebem. Ao entregar
diretamente para um receptor bloqueado, o kernel desabilita interrupções,
troca temporariamente para o CR3 do receptor, valida e copia o resultado,
grava o retorno salvo em RAX e restaura o CR3 do remetente.
Ainda não existem nomes de serviço, permissões por handle nem mensagens maiores.
Marco 20 · Desktop concluído
O mouse nunca diz onde está: diz apenas quanto andou. Quem guarda a posição é o kernel, e é ele que desenha a seta em uma cópia da tela. Neste marco os três programas passaram a dividir essa tela e trabalhar ao mesmo tempo. O dispositivo auxiliar do 8042 envia pacotes de três bytes pela IRQ12; o kernel estende o sinal de nove bits, aplica limites, compõe o cursor no front buffer e entrega estado, eventos e geometria de janela pelas syscalls 25, 26 e 27.
O mosaico descrito aqui foi substituído no Marco 26 por janelas flutuantes que se sobrepõem, arrastam e minimizam. O que continua valendo é a base: o pacote PS/2, a fila de eventos e o cursor composto no front buffer.
Experimento verificado
mouseSeis deslocamentos de 20 × 12 no monitor do QEMU tiram o cursor do centro e o comando confirma a posição exata.
Sincronização. O bit 3 do primeiro byte é sempre 1. Sem essa checagem, um byte perdido desalinha o fluxo para sempre.
Sinal de nove bits. 0xFF com o bit de sinal ligado vale −1, não 255; sem estender, a seta salta ao andar para a esquerda.
Eixo invertido. No PS/2 o Y cresce para cima; no framebuffer, para baixo. O kernel subtrai onde o mouse soma.
Camada essencial
Os aplicativos desenham em uma cópia da tela guardada na RAM. O cursor nem existe lá: ele é pintado só na hora de copiar a cópia para a tela de verdade. Assim a seta nunca suja o que está embaixo dela.
Camada técnica
byte 0 = YO XO YS XS 1 BM BR BL
byte 1 = deslocamento X
byte 2 = deslocamento Y
cursor = 8 × 12 px, 2 bits/px
soia> mouse
mouse: x=1080 y=612 botoes=--- pacotes=8
soia> eventos
mover x=1080 y=612 botoes=0 tick=64
apertar x=1080 y=612 botoes=1 tick=65
soltar x=1080 y=612 botoes=0 tick=67
eventos drenados: 8 | descartados: 0
soia> janelas
N POSICAO TAMANHO GRADE DONO FOCO ESCRITOS
0 24,44 928x1012 112x58 1 sim 1459
1 968,720 928x336 112x16 2 - 196
2 968,44 928x660 112x36 3 - 8896
foco na janela 0 | trocas por clique: 1
Caixa de dano. Copiar os 8,29 MiB da tela inteira a cada quadro seria 166 MB/s por software. O kernel amplia um retângulo com o que mudou e copia só ele: 732 KiB por quadro, onze vezes menos.
Publicar na hora do movimento. O mouse relata a 100 Hz e o relógio bate a 20 Hz. Publicar só no relógio engolia quatro em cada cinco pacotes e a seta pulava. Agora a IRQ12 também publica, e cada publicação extra custa 768 bytes dirigidos: 384 para devolver o fundo onde a seta estava e 384 para pintá-la no lugar novo. Custava a caixa de dano inteira até 31/07/2026
Fila de eventos. Trinta e duas vagas em anel. Cheia, o kernel descarta o novo e conta o descarte, em vez de sobrescrever um evento que ninguém leu ainda.
Escrever deixou de exigir foco. A syscall WRITE recusava quem não estava na frente. Agora ela aponta o desenho para a janela de quem chamou, e as três ficam vivas.
Fatia 4 · o gerenciador de janelas
O terminal do SOIA nunca guardou texto: ele escreve pixels e rola por cópia de pixels. Isso parecia uma limitação e virou vantagem. O conteúdo de cada janela já mora no back buffer, na região dela, então não foi preciso inventar um buffer de caracteres por janela. Bastou reapontar quatro variáveis de origem e grade a cada escrita.
Mosaico, não sobreposição
As três janelas dividiam a tela sem se cruzar, e era justamente isso que impedia uma tarefa de pintar pixel de outra. Janelas que se cobrem exigem uma superfície por janela e um compositor empilhando em ordem z: mais 2 MiB por janela e uma cópia extra por quadro.
A dívida foi paga nos Marcos 25 e 26. Cada janela ganhou superfície própria, o mosaico deu lugar a janelas flutuantes, e o compositor as monta na ordem de profundidade. Ver como
Camada essencial
Antes, F1, F2 e F3 escolhiam qual programa ocupava a tela inteira; os outros dois congelavam. Agora os três aparecem sempre, e o clique escolhe apenas para onde vai a tecla. A distinção entre "estar visível" e "ter o teclado" só existe quando há mais de uma janela viva.
Camada técnica
Com três tarefas escrevendo, a saída serial virou uma trança de textos. A serial passou a espelhar só a janela em foco, como faria um console físico. Isso quebrou a verificação: o texto das janelas de fundo não passa mais por lá. A resposta foi um contador de caracteres por janela, exposto pela syscall 27.
A prova da fatia está nesse contador. Na primeira leitura do teste,
feita antes de qualquer F3, a janela do monitor já
acumulou 8.896 caracteres sem nunca ter recebido o foco. Nenhuma
outra explicação cabe: uma tarefa de fundo desenhou na própria
janela. O teste ainda confere, na captura do framebuffer, que as três
áreas de console têm pixels de texto no mesmo instante.
A IRQ12 nasce no PIC escravo: a linha 2 do mestre, que é a cascata,
também sai da máscara, e o fim de interrupção vai para as portas
0xA0 e 0x20. Pacote com bit de estouro é
descartado inteiro, porque a magnitude real já se perdeu. O kernel
passou de 56 para 96 setores neste marco, empurrando o SOIAFS1 para o
LBA 102. As portas do vetor 0x2C e do IRQ0 têm atributo
0x8E, que zera o IF na entrada: por isso timer e mouse
nunca entram um no meio da publicação do outro, e nem a fila de
eventos nem o hit test do clique precisam de trava.
O que o teste não pega
O SOIA desenhava um X vermelho na barra de título, em uma coordenada literal da época de 1024 × 768. A 1920 ele caía no meio da barra, e nunca houve clique ligado a ele. Somado ao X real da janela do QEMU, eram dois botões de fechar, um deles falso. Ele volta na fatia 4, quando existir janela para fechar.
Relativo contra absoluto
O PS/2 informa deslocamento; um tablet USB informaria coordenada. Com dispositivo relativo existem dois ponteiros independentes, o do Windows e o do SOIA, que nunca se reconciliam. Sem pilha USB, a saída é pedir ao QEMU que prenda o ponteiro na janela com grab-on-hover=on. Ctrl+Alt+G solta.
Marco 21 · Robustez
Até aqui, um erro em qualquer programa congelava a máquina inteira. Agora o programa que erra morre sozinho, a janela dele mostra o motivo, e o terminal aceita o próximo comando no segundo seguinte. Os 32 vetores de exceção ganharam stubs próprios que uniformizam o quadro; o RPL do CS salvo decide entre encerrar a tarefa e parar o kernel de forma declarada.
Experimento verificado
run FAULT.ELFUm programa escrito para quebrar de propósito, logo depois de provar que a fronteira das syscalls recusa catorze argumentos hostis sem se abalar.
soia> run fault.elf
Recusadas 14 de 14 | fronteira intacta
SOIA excecao: falha de pagina | vetor 14 | codigo 0x0000000000000007 | RIP 0x0000000100001072 | CR2 0x0000000000000000 | tarefa 4.
SOIA robustez: tarefa encerrada pela excecao; as demais seguem executando.
Processo encerrado por falha; nao chegou a chamar exit. Memoria devolvida: OK.
soia> echo vivo apos a falha
vivo apos a falha
O que provava nada. Antes, os 256 vetores apontavam para o mesmo manipulador: cli, uma mensagem genérica e hlt eterno. Toda exceção terminava igual, e não dava para saber qual tinha acontecido.
Um zero que uniformiza. Dez exceções empurram código de erro e vinte e duas não. O stub das que não têm empurra um zero no lugar, e o tratador passa a ter um caminho de leitura só.
A última linha é a prova. Não é a ausência de travamento que fecha o marco: é o shell respondendo um comando novo depois da falha.
Camada essencial
Um prédio sem porta corta-fogo queima inteiro quando um apartamento pega fogo. O privilégio guardado no seletor de código é a porta: valor 3 significa que quem errou foi o programa, e só o apartamento dele precisa ser isolado.
Camada técnica
mov rax, [rsp + EXCEPTION_CS_OFFSET]
and eax, 0x03
cmp eax, 0x03
jne exception_kernel_panic_64
Fatia 3 · Camada essencial
Para contar que houve um acidente, a CPU precisa de um papel onde anotar. Ela anota na pilha. Quando o problema é a pilha, não há onde anotar, e a máquina reinicia sem dizer nada. A IST é um papel guardado em outro lugar, reservado só para essa hora.
Fatia 3 · Camada técnica
RSP antes de empilhar.mov eax, DOUBLE_FAULT_STACK_TOP
mov [tss64 + TSS_IST1_OFFSET], rax
mov byte [abs IDT_BASE + (8 * 16) + 4], 1
Duas metades distantes: a porta do vetor 8 guarda o índice, o TSS guarda o ponteiro. Ter uma sem a outra não serve de nada, e é por isso que a prova serial sai depois do TSS.
Double fault nunca devolve o controle. Ele contraria a regra do RPL: mesmo com CS de ring 3, o vetor 8 vai para parada declarada. Chegar nele significa que o quadro da falha original se perdeu, e encerrar só a tarefa daria a impressão de um sistema saudável que esqueceu o acidente.
Duas verdades no mesmo endereço. A fatia encontrou um bug que não era dela: PAGE_DIRECTORY_APIC, do Marco 22, e FILESYSTEM_SUPERBLOCK_BUFFER, do Marco 9, apontavam ambos para 0x00041000. Montar o page directory apagava o diretório lido do disco; gravar um arquivo apagava o mapeamento do LAPIC. O sintoma foi o bloco de notas recusando salvar em disco novo, sem nenhuma pista de memória.
Abaixo de 1 MiB nada é alocado. Todo endereço nessa faixa é reserva manual por constante, sem verificação nenhuma: o assembler aceita, o boot passa, e o dano aparece longe da causa.
A pilha saiu do binário, e o motivo é espaço. Ela continua reserva por constante e continua no mapa de identidade, que eram as duas razões da escolha original. O que mudou é o endereço: 0x56000, na área estática do kernel, em vez de dentro do kernel.bin. A auditoria do kernel mediu 13.283 bytes de times N db 0 embutidos no binário, 25,9 setores carregados do disco a cada boot para conter zeros, num kernel que tinha 6,42 setores livres até o teto de 120. Depois de mover os quatro maiores, a folga foi para 27,22. Zerar a faixa passou a ser trabalho do boot, em kernel_static_area_clear_64, e não do NASM em tempo de montagem.
Fatia 3 · Ctrl+C
Até aqui todo encerramento vinha de um erro. Ctrl+C é o primeiro caminho em que ninguém errou: o programa está funcionando, esperando entrada, e você simplesmente não quer mais. Por isso ele tem código de saída próprio, -7, e o terminal diz uma frase diferente da frase de falha.
Fatia 3 · Quem executa o pedido
O teclado empilha seis registradores, não o quadro completo que o escalonador precisa para recarregar RSP. Encerrar a tarefa ali quebraria o despacho, então a tecla só deixa o alvo anotado e o tick seguinte faz o trabalho. A latência máxima é um pulso de 50 ms.
calc>
[SOIA] processo encerrado: Ctrl+C do teclado.
Processo encerrado por Ctrl+C; o programa nao errou.
soia> ps
Só processo dinâmico. As três vagas estáticas fazem parte do contrato do boot; sem terminal, notas e monitor o sistema ficaria sem interface. Na janela de uma delas a tecla não faz nada, de propósito.
Uma frase plausível e falsa, evitada. A rotina que escreve na janela montava o texto com o nome da última exceção. Chamada pelo Ctrl+C, ela usaria a falha anterior, possivelmente de outra tarefa, e diria "encerrado: falha de pagina" sobre um processo que ninguém quebrou. Agora ela aceita um motivo explícito.
Encerrar a tarefa exige acordar o pai que estava bloqueado em
WAIT: um filho morto por exceção nunca chega a chamar
EXIT, e sem isso o terminal esperaria para sempre por um
processo que já não existe. O quadro de exceção na pilha do kernel é
simplesmente abandonado, porque o despacho recarrega
RSP a partir da tarefa escolhida. Vaga estática não
volta para o pool: ela não tem pai para coletá-la, e a contagem de
tarefas residentes faz parte do contrato do boot.
Sem tratador, o que acontecia
Uma exceção que a CPU não consegue entregar vira double fault; sem tratador para esse, vira triple fault, e o processador reinicia. De fora, os três casos pareciam idênticos: a janela sumia. Nomear a falha é a diferença entre depurar e adivinhar.
Um bug que o marco encontrou
O anúncio de cada programa escolhia a frase pela primeira letra do nome. FAULT.ELF começa com F, não batia com nenhum caso e era anunciado como CALC.ELF. Agora o nome vem da tabela de tarefas.
Marco 22 · Plataforma moderna
Todos os marcos anteriores acrescentaram peças. Este remove nove: com firmware UEFI, o computador já acorda em 64 bits, e metade do trabalho de boot simplesmente deixa de existir. O firmware carrega um PE32+ de uma partição FAT e entrega o controle em long mode com paginação ativa; GOP substitui o VBE, GetMemoryMap substitui o E820 e o RSDP chega pela tabela de configuração.
Experimento verificado
Run-QemuUefi.ps1O mesmo SOIA, acordado por um firmware de 3,6 MB em vez de um setor de 512 bytes. Os dois caminhos convivem e nenhum arquivo é compartilhado.
O que o BIOS precisa fazer
Setor de 512 bytes terminando em 0x55AA, segundo estágio porque 512 bytes não bastam, modo real de 16 bits, linha A20, GDT, CR0.PE, INT 13h para ler o disco, INT 15h/E820 para a memória e INT 10h para o vídeo.
O que o UEFI já entrega
A CPU já está em long mode de 64 bits, com paginação ligada e pilha válida, antes da primeira instrução do SOIA. O firmware leu o arquivo de uma partição FAT e chamou uma função C.
SOIA UEFI: firmware entregou o controle em long mode.
SOIA UEFI: sem MBR, sem modo real, sem A20, sem INT 13h.
SOIA UEFI: firmware oferece 30 modos de video.
SOIA UEFI: modo 22 selecionado para 1920x1080.
SOIA UEFI: GOP em 1920x1080, pitch 1920 pixels, base 0x0000000080000000.
SOIA UEFI: formato de pixel BGRX8888 compativel com o desenho atual, framebuffer de 8294400 bytes.
SOIA UEFI: mapa de memoria com 102 entradas de 48 bytes; 1998 MiB utilizaveis.
SOIA UEFI: RSDP da ACPI 2.0 em 0x000000007F77E014.
SOIA UEFI: kernel.bin lido da particao para 0x0000000000010000; 49152 bytes.
SOIA UEFI: fonte 8x16 copiada para 0x6000.
SOIA UEFI: 4 GiB mapeados por identidade em paginas de 2 MiB.
SOIA UEFI: contrato versao 4 pronto com 5 regioes de memoria; entregando o controle ao kernel.
SOIA kernel 64-bit: controle recebido!
SOIA kernel: contrato de boot validado.
8.294.400. É o mesmo tamanho de framebuffer do caminho BIOS, no mesmo formato BGRX8888. O kernel vai desenhar ali sem uma linha de conversão.
O modo não tem número. No BIOS a resolução vinha de 0x0192, tirado da tabela do vgabios. No GOP os modos são enumerados e escolhidos pela dimensão: no QEMU o 1920 × 1080 é o modo 22, e esse número não está escrito em lugar nenhum do código.
Outro formato de binário. O firmware não executa binário cru: só PE32+, o mesmo de um .exe. Clang e lld-link já sabem produzir isso, então nenhuma ferramenta nova entrou no projeto.
Rodar e testar são a mesma máquina. Quando o kernel passou a ser carregado pelo UEFI, a placa de rede e o disco entraram no script de teste e ficaram de fora do script de execução. A bateria passava verde e o comando do dia a dia morria com a tela preta, porque o kernel para sem rede e nada chega a ser publicado no framebuffer. Duas listas descrevendo o mesmo computador sempre divergem: hoje o hardware virtual mora em QemuUefiHardware.ps1 e cada script só acrescenta a saída.
Camada essencial
O boot BIOS é o material dos marcos 1 a 4. Apagá-lo apagaria a explicação de como um computador acorda do nada. Manter os dois transforma o marco em comparação: a mesma máquina, dois jeitos de ligar.
Camada técnica
A ABI do UEFI passa argumentos em RCX/RDX/R8/R9, e o alvo errado no compilador só falha na primeira chamada ao firmware. As tabelas são vetores de ponteiros, onde um campo faltando desloca todos os seguintes. E o mapa de memória avança por descriptor_size, nunca por sizeof: o OVMF publica 48 bytes contra os 40 da estrutura.
O que a primeira execução ensinou
O carregador terminava com sucesso e o firmware seguia para a próxima entrada da lista de boot: o menu de configuração do OVMF, que limpava a tela. Do lado de fora, parecia que o SOIA não tinha feito nada. Um carregador de verdade nunca retorna: ele chama ExitBootServices, toma posse da máquina e salta para o kernel.
Um efeito colateral de não retornar
Ele arma um temporizador de cinco minutos ao iniciar um aplicativo. Um carregador que nunca devolve o controle levaria um reset com o sistema já rodando, e pareceria um travamento aleatório. set_watchdog_timer(0, ...) desarma.
A troca de CR3 quase matou o carregador
A primeira tabela de páginas cobria 2 GiB, o mesmo teto do caminho BIOS. Só que o firmware havia carregado o próprio BOOTX64.EFI perto dos 2 GiB: no instante do mov cr3, a instrução seguinte deixou de existir. A correção é mapear 4 GiB, o que também traz o framebuffer do GOP, o LAPIC e o IO-APIC para dentro do mapa.
Contar memória livre não basta
Contar só EfiConventionalMemory abria um buraco justo onde o back buffer precisa de 8 MiB contíguos, e o kernel recusava o contrato. Memória de LoaderCode, LoaderData e BootServices* volta a ser livre no instante do ExitBootServices: ela entra no mapa entregue ao kernel.
O carregador não observa mais: ele assume. Lê kernel.bin da
partição para 0x10000, copia uma fonte 8 × 16 embutida no
binário para 0x6000 (o firmware UEFI não tem a fonte que a
BIOS deixava na memória, então ela foi extraída de um boot BIOS real e
virou um vetor de 4.096 bytes em font-8x16.h), monta 4 GiB de
identidade em páginas de 2 MiB, escreve o boot_info versão 4 em
0x7000, chama ExitBootServices e salta. A partir
daí quem fala na serial é o mesmo kernel.bin do caminho BIOS,
sem recompilar, e é essa igualdade linha a linha que o teste cobra. O
desenho não é enfeite: a captura confere cinco pontos com as cores exatas
do mosaico, entre eles a marca âmbar em 7,5 e o console do
terminal em 400,900.
Marco 22 · ACPI
Até aqui o SOIA adivinhava o hardware: PIC em 0x20, PIT em 0x40, teclado em 0x60, tudo herdado do IBM PC de 1981. A ACPI é a tabela que o firmware publica para acabar com a adivinhação, e a primeira coisa que ela destrava é a mais simples de conferir.
SOIA ACPI: revisao 0 lida pelo RSDT; PM1a em 0x0000000000000604; SLP_TYP do _S5 = 0.
soia> desligar
Desligando pela ACPI...
SOIA energia: desligando pela ACPI a pedido do ring 3.
Camada essencial
Até o Marco 21, encerrar o SOIA era fechar a janela do QEMU: o equivalente a tirar da tomada. kernel_halt era três instruções, cli e um laço de hlt eterno. Agora o sistema pede a própria energia de volta, e a janela fecha sozinha.
Camada técnica
Oito bytes RSD PTR podem aparecer por acaso em qualquer lugar da memória. A defesa é o checksum: a soma de todos os bytes da tabela tem que fechar em zero. Toda tabela ACPI carrega esse campo, e o SOIA confere em todas.
O valor que desliga mora em bytecode. O _S5 está dentro do DSDT, escrito em AML. O SOIA não interpreta AML: ele procura os quatro bytes _S5_, confirma que vem um PackageOp, pula o comprimento e lê o primeiro valor. É um atalho, e está registrado como tal: um interpretador de verdade seria maior que todo o resto do kernel.
0x604 não foi escrito no código. Muitos tutoriais mandam fazer outw(0x604, 0x2000) direto, porque é o que funciona no QEMU. Aqui esse número foi descoberto pela FACP, e o mesmo código funcionaria numa máquina que usasse outra porta.
Um bug de dois colchetes. A primeira execução disse "tabelas ausentes". A causa era mov rbx, rotulo em vez de mov rbx, [rotulo]: o registrador recebia o endereço da assinatura em vez dos oito bytes dela, e nenhuma posição da memória batia.
Por que ACPI veio antes do resto
O plano era UEFI carregar o kernel primeiro. Mas o RSDP também é encontrável pelo caminho BIOS, varrendo dois lugares: o UEFI só entrega o ponteiro de graça. Fazendo ACPI antes, ela roda no kernel que já funciona, é coberta pela bateria de testes existente e serve os dois caminhos de boot desde o primeiro dia.
Uma prova de tipo novo
O kernel escreve a intenção antes de escrever no PM1a. Uma máquina que ignorasse a ordem produziria exatamente o mesmo log. Então a prova do desligar é o processo do QEMU terminar, verificado de fora. Quando o efeito acontece fora do sistema, a prova precisa vir de fora também.
Marco 22 · APIC
O 8259 que o SOIA usa desde o Marco 6 é o controlador do IBM PC. Ele tem oito linhas, e o segundo chip existe só porque oito não bastavam: a IRQ2 do mestre carrega as oito do escravo em cascata. O APIC troca isso por 24 linhas, cada uma escolhendo o vetor e o núcleo de destino.
SOIA APIC: 1 nucleo(s) na MADT; LAPIC em 0x00000000FEE00000;
IO-APIC em 0x00000000FEC00000 com 24 linhas e 5 remapeamentos; 8259 mascarado.
Cinco remapeamentos. O firmware não entrega as IRQs legadas nas linhas de mesmo número. Ler as Interrupt Source Overrides da MADT é o que evita programar o timer no lugar errado.
A IDT não mudou uma linha. O IO-APIC entrega nos mesmos vetores em que o PIC foi remapeado no Marco 6. O que muda é o fim de interrupção: duas escritas no 8259 viram uma no LAPIC.
Uma CPU, de propósito. A MADT já diz quantos núcleos existem e o LAPIC já traz o mecanismo para acordá-los. Usá-los exige trava em todo estado compartilhado, e isso é marco próprio.
O Marco 21 pagou por si mesmo
O LAPIC fica em 0xFEE00000, perto de 4 GiB, e o mapa identidade do kernel para em 2 GiB. A primeira escrita virou falha de página, e o tratador de exceções entregou o diagnóstico inteiro: vetor 14, CR2 = 0xFEE000F0. Sem ele, teria sido um triple fault silencioso e a janela do emulador sumindo.
A segunda falha, mais sutil
O PDPT tem quatro entradas, uma por GiB. O LAPIC, o IO-APIC e o framebuffer VBE caem todos no quarto. Como cada tarefa tem um page directory próprio para o vídeo, apontar o slot para o do APIC apagaria a tela, e o contrário apagaria o APIC. A saída foi comparar os slots antes de escolher: coincidindo, as duas regiões de 2 MiB cabem lado a lado no mesmo diretório.
Camada essencial
No 8259, qual dispositivo usa qual linha foi decidido pelo fabricante da placa em 1981, e o sistema operacional apenas obedece. No IO-APIC, cada linha é programada: o SOIA escolhe o vetor, e numa máquina com vários núcleos escolheria também qual deles atende.
Camada técnica
As páginas do APIC são mapeadas com PWT e PCD, que desligam o cache. Uma escrita retida no cache nunca chegaria ao controlador, e uma leitura poderia devolver o valor antigo. É a mesma correção que o framebuffer ainda não recebeu, e que lá continua registrada como dívida.
Marco 22 · A entrega ao kernel
O carregador UEFI monta o contrato que o estágio 2 do BIOS já montava e some. O kernel não mudou uma linha para aceitar o boot novo: ele encontra a fonte em 0x6000, o mapa de memória em 0x5000 e as tabelas de página onde sempre estiveram.
SOIA UEFI: kernel.bin lido da particao para 0x0000000000010000; 49152 bytes.
SOIA UEFI: fonte 8x16 copiada para 0x6000.
SOIA UEFI: 4 GiB mapeados por identidade em paginas de 2 MiB.
SOIA UEFI: contrato versao 4 pronto com 5 regioes de memoria.
SOIA kernel 64-bit: controle recebido!
SOIA kernel: contrato de boot validado.
A fonte foi extraída, não inventada. O firmware UEFI não tem fonte VGA. Um script sobe o próprio caminho BIOS no QEMU, espera o contrato ser validado e lê 0x6000 pelo monitor. As letras das duas telas são idênticas, não parecidas.
Quatro GiB de mapa por causa de uma instrução. O firmware pôs o carregador perto de 2 GiB. No instante em que CR3 troca, a próxima instrução está lá. Um mapa de 12 MiB desmapearia o próprio código no meio do salto.
A mesma máquina, dois firmwares, dois relatos. Pelo BIOS a ACPI é revisão 0 pelo RSDT, com energia em 0x604. Pelo UEFI é revisão 2 pelo XSDT, em 0xB004.
O erro que parecia conservador
Contar só EfiConventionalMemory parece a leitura segura e é a errada: a especificação diz que o código e os dados do carregador e dos boot services deixam de ter dono quando ExitBootServices é chamado. Descartá-los abria um buraco em 0x800000, justo onde o back buffer de 8 MiB precisa caber contíguo.
Um campo novo no contrato
No UEFI a assinatura RSD PTR não está na memória baixa, e a varredura não acha nada. O campo em +136 carrega o ponteiro que o firmware entregou; o estágio 2 do BIOS deixa zero. E o ponteiro vindo de fora não é aceito de graça: assinatura e checksum são conferidos igual aos da varredura.
Marco 23 · Precisão e tempo
Até aqui o SOIA só trabalhava com números inteiros. O processador sempre soube fazer conta com vírgula, em gavetas próprias chamadas XMM, mas exigia que o sistema avisasse antes que sabe cuidar delas. Agora avisa.
CR0.EM e CR0.TS zerados, CR4.OSFXSR e CR4.OSXMMEXCPT ligados, e 512 bytes de FXSAVE por tarefa no despacho. A userland passou de -msoft-float para -msse2.
Experimento verificado
run FPU.ELFQuatro contas provam que a unidade responde. A quinta prova o que ninguém vê: oito registradores anotados, uma soneca de propósito para outras tarefas rodarem, e a conferência de que os oito voltaram intactos.
soia> run fpu.elf
0,1 + 0,2 = 0.300 (nao e 0,3 exato, e isso e correto)
raiz de 2 = 1.414
0,1 somado 10x = 1.000 (o erro de arredondamento acumula)
A prova que importa: XMM atravessando troca de contexto.
dormi 3 ticks; o escalonador rodou outras tarefas nesse meio
8 registradores XMM intactos apos dormir: OK
Como é. O processador não libera XMM sem dois consentimentos: CR4.OSFXSR promete que o sistema sabe salvar os registradores com FXSAVE, e CR4.OSXMMEXCPT promete que a exceção 19 tem tratador. A segunda promessa só pôde ser feita porque o Marco 21 instalou os 32 vetores; antes dele, seria mentira.
Por que assim. A área de estado mora no binário do kernel, e não no alocador de páginas, pelo mesmo motivo da pilha de IST: ela precisa estar no mapa de identidade que todo CR3 enxerga, seja qual for a tarefa que entra ou sai.
O que muda. Escala de imagem, decodificação de vídeo e qualquer motor de script futuro dependiam disso: número em JavaScript é double. O que continua impossível é AVX, cujo estado passa de 512 bytes e exigiria XSAVE com área negociada.
Camada essencial
Dois funcionários se alternam na mesma mesa a cada 50 milissegundos e nenhum sabe que o outro existe. Se ninguém guardar as gavetas na troca de turno, o segundo mexe nas contas do primeiro, e o primeiro volta e continua sem perceber que os números mudaram. O resultado não é um erro visível: é uma conta errada com cara de conta certa.
Camada técnica
je que evita um bug intermitente.cmp rbx, [current_task]
je .load ; mesma tarefa: nada a fazer
call fpu_save_current_64
mov [current_task], rbx
call fpu_restore_current_64
Quando o escalonador escolhe a mesma tarefa de novo, restaurar carregaria a cópia do despacho anterior e apagaria toda conta feita desde então. O erro seria proporcional à carga do sistema.
O bug que o marco encontrou
Ligar SSE quebrou o boot antes do shell aparecer, com protecao geral | vetor 13 | codigo 0x0 em duas tarefas. Código de erro zero em #GP não é violação de segmento: é instrução SSE alinhada operando em endereço que não é múltiplo de 16.
A ABI diz que, na entrada de uma função, RSP % 16 vale 8, porque o CALL empurrou o endereço de retorno. O kernel entregava USER_STACK_TOP, múltiplo de 4096, e o frame inteiro ficava deslocado em oito bytes. Estava errado desde o primeiro programa em C, e ficou invisível dez marcos porque nenhuma instrução exigia alinhamento.
USER_STACK_ENTRY_RSP equ USER_STACK_TOP - 8
A lição é geral: habilitar um recurso não só adiciona o que ele faz, também passa a cobrar contratos que ninguém verificava.
Por que a prova é assembly
A ABI trata XMM como registradores voláteis. Escrito em C, o compilador salvaria os valores na pilha antes da syscall e os recarregaria depois: o programa compararia a pilha com ela mesma e imprimiria OK sobre um kernel que não salva nada.
movsd 0x00(%[src]), %%xmm0
...
int $0x80 ; a tarefa dorme aqui
movsd %%xmm0, 0x00(%[dst])
Mantendo os valores nos registradores através do int 0x80, o único responsável por preservá-los é o FXSAVE do escalonador, e mais ninguém.
Um detalhe que morde quem tenta economizar a inicialização: um
FXRSTOR de área zerada falha, porque o
MXCSR tem bits reservados cujo valor zero é inválido. Cada
vaga nasce com uma cópia de um estado real, produzido por
fninit, ldmxcsr 0x1F80 e um
FXSAVE de referência. E a raiz quadrada do
FPU.ELF vem da instrução sqrtsd, não de
biblioteca: não existe libm aqui, e não vai existir.
Fatia 2 · O relógio
O LAPIC tem temporizador próprio, melhor que o PIT em tudo menos numa coisa: ninguém informa em que velocidade ele conta. Descobrir isso é medir contra o único relógio de frequência conhecida da máquina.
SOIA APIC timer: 626293 contagens em 10 ms, ou seja 62629300 Hz
apos o divisor 16; pulso a 100 Hz, video a 50 Hz e tick publico a 20 Hz.
Como é. O canal 2 do PIT, que existe desde 1981 para ligar o alto-falante, serve de cronômetro: o canal 0 está ocupado pela batida do escalonador desde o Marco 6. A janela de medição é exatamente o período que será programado, então a contagem medida vira o recarregamento sem nenhuma divisão no meio, e sem resto para acumular erro.
Por que assim. O LVT fica mascarado durante a medição, porque um pulso no meio dela entraria no handler antes de existir contagem válida. E depois de calibrar, a IRQ0 sai do IO-APIC mas o PIT continua contando: ele é a referência, e desligá-lo seria jogar fora o instrumento.
O que muda. Existe uma unidade de tempo que não existia. A menor coisa que o SOIA sabia medir era um tick de 50 ms; agora uma soneca de três ticks pode ser descrita como 150 ms. O que continua impossível é microssegundo, que exigiria ler o TSC com a frequência dele calibrada pelo mesmo método.
Camada essencial
Uma única interrupção fazia três trabalhos ao mesmo tempo: trocar de programa, desenhar a tela e contar o tempo. Como era a mesma batida, mudar o ritmo de um mudava o dos outros três. Agora cada um tem seu próprio compasso, e o do meio, o que conta o tempo, continua igual de propósito: tudo que já existia foi escrito contra ele.
Camada técnica
dec byte [timer_tick_divider]
jnz .acknowledge
mov byte [timer_tick_divider], 5
inc qword [timer_ticks]
SLEEP recebe ticks. Um soia_sleep(10) que passasse a valer 100 ms em vez de 500 ms mudaria o comportamento de todos os aplicativos de uma vez, sem nenhum deles ter sido tocado.
O erro que custou quatro rodadas
Depois de ligar o timer, o uptime relatado parecia ser metade do de uma execução anterior. Conclusão imediata: pulsos estavam se perdendo. E os números internos pareciam confirmar, porque 3 ticks e 150 ms fechavam perfeitamente.
Só que uptime_milliseconds e timer_ticks derivam os dois do pulso. Se pulsos se perdessem, ambos errariam junto, na mesma proporção, e continuariam coerentes: eles nunca poderiam detectar o próprio defeito.
$sw = [System.Diagnostics.Stopwatch]::StartNew()
.\scripts\Test-QemuBoot.ps1 -MemoryMiB 64
$sw.Stop()
parede: 41 s | uptime do SOIA: 37 s | diferenca: o boot
O relógio sempre estivera certo, e os "74 s de referência" vinham de uma execução que demorou mais por disputar a máquina com outros testes. No caminho, o pulso chegou a ser reduzido para 40 Hz e o código chegou a afirmar que 100 Hz não era sustentável. Era falso, e foi revertido.
A regra que fica: contador derivado de um sinal não valida esse sinal. A referência precisa ser independente, e a mais independente disponível costuma ser o mundo de fora.
O próximo marco se anunciou aqui
A primeira tentativa foi mostrar o relógio novo no status do terminal. O linker recusou:
ld.lld: error: section .text virtual address range
overlaps with .data
Cada processo recebe 16 KiB de texto, e o TERMINAL.ELF não aceita mais nenhuma função nem string sem perder outra. A prova foi para o FPU.ELF, que tinha folga, e o limite ficou registrado onde será resolvido: o Marco 24, com binário de tamanho variável, demand paging e heap em MiB.
Resolvido na fatia 1 do Marco 24. O relógio está no status, e o TERMINAL.ELF passou a ocupar 5 páginas de texto. Ver como
Marco 24 · Capacidade
Todo programa do SOIA ganhava o mesmo espaço, do maior ao menor: quem era grande não cabia, quem era pequeno pagava pelo grande. Agora o espaço é medido a partir do próprio arquivo do programa, e a memória que ele pede depois só é entregue quando ele realmente usa.
O mapa da região de usuário deixou de ser soma de constantes e passou a ser derivado dos PT_LOAD do ELF. USER_TEXT_PAGE_COUNT, USER_DATA_VIRTUAL e USER_TOTAL_PAGE_COUNT não existem mais; o que restou é o teto: 128 páginas de imagem em 8.192 de região, com o heap indo a 4.096 páginas entregues por falha.
Experimento verificado
A mensagem sai uma vez por tarefa criada, e não uma vez por boot. Um número único não mostraria o que interessa, que é a diferença entre eles.
SOIA imagem: tarefa 0 carregada com 5 paginas de texto, 1 de dados e heap em 0x0000000100006000.
SOIA imagem: tarefa 1 carregada com 1 paginas de texto, 1 de dados e heap em 0x0000000100002000.
SOIA imagem: tarefa 2 carregada com 3 paginas de texto, 1 de dados e heap em 0x0000000100004000.
Como é. elf64_validate_64 parou de comparar cada p_vaddr com uma constante e passou a devolver o mapa: páginas de texto em RAX, primeira página de dados em RDX, páginas de dados em R8 e início do heap em R9. O que ela ainda cobra é só o que precisa ser verdade: texto na base, dados em fronteira de página depois do texto, e a soma dentro de USER_IMAGE_PAGE_LIMIT.
Por que assim. A pilha foi para o fim da região em vez de ficar depois dos dados. Assim ela não se move quando o binário engorda, e USER_STACK_ENTRY_RSP continua sendo uma constante, o que preserva o alinhamento de ABI que o Marco 23 pagou caro para descobrir.
O que muda. O NOTEPAD.ELF ocupa 1 página de texto onde recebia 4, e as três tarefas do boot custam 41 páginas onde custavam 48. O que continua impossível é passar de 2 MiB de espaço virtual por processo: a região é um page table só, e mais que isso depende de vários.
Camada essencial
Imagine uma estante em que todas as divisões foram serradas do mesmo tamanho antes de saber o que ia ser guardado. Um livro grosso não entra. Um panfleto entra e sobra um espaço que ninguém mais pode usar, porque a divisão é fixa e está ali para sempre.
A estante continua do mesmo tamanho, e o limite total continua existindo. O que mudou é que agora quem precisa de mais recebe mais, e quem precisa de menos devolve a diferença.
Camada técnica
USER_STACK_GUARD_PAGE_COUNT equ 1
USER_HEAP_PAGE_CEILING equ USER_STACK_FIRST_PAGE \
- USER_STACK_GUARD_PAGE_COUNT
Entre o teto do heap e a base da pilha fica uma página que nunca recebe PTE. Pilha que transborda cai nela e vira falha de página, ou seja, fim declarado da tarefa culpada pelo caminho do Marco 21. Sem a guarda, o transbordo escreveria no heap em silêncio e o dano apareceria longe da causa.
Conferir a ausência dela é a única prova possível de uma página que nunca deve estar lá, e é o que o boot faz.
A decisão que a fatia forçou
Existia uma tabela, task_user_pages, com o endereço físico de cada página de cada tarefa. As PTEs já guardam exatamente esse endereço, e é a PTE que a MMU consulta: a tabela era uma cópia.
Com tamanho fixo, isso era só redundância. Com tamanho variável ela teria que ser dimensionada para o teto de páginas vezes oito tarefas, e as duas listas passariam a ter como discordar. Ela saiu, e no lugar entrou uma leitura da própria PTE.
mov rax, [rbx + rsi * 8]
test rax, 0x01 ; bit de presenca
jz .not_present
mov rdx, PAGE_FRAME_MASK
and rax, rdx
A liberação de uma vaga ganhou de graça: eram três laços, um por região, cada um sabendo de cor quantas páginas procurar. Virou uma varredura das 512 entradas devolvendo o que estiver presente, que não depende de tamanho nenhum.
O bug que a mudança quase deixou passar
As duas rotinas que validam ponteiro de userland aceitavam de USER_TEXT_VIRTUAL até o break do heap. No layout antigo isso cobria a pilha, porque o heap vinha depois dela. Uma comparação, e cobria tudo.
Com a pilha no fim da região, a mesma comparação passaria a recusar exatamente o caso mais comum que existe: um buffer declarado como variável local dentro de uma função C. Toda syscall com buffer teria começado a devolver erro.
cmp rdi, rdx ; parte baixa: imagem + heap
jb .try_stack
cmp rax, r9 ; ate o break
jbe .success
.try_stack: ; parte alta: a pilha
mov rcx, USER_STACK_VIRTUAL
cmp rdi, rcx
jb .failure
A regra que fica: mudar layout de memória é mudar todas as afirmações implícitas sobre vizinhança. As que estão escritas o assembler aponta; as que só existiam como consequência de duas coisas serem vizinhas não aparecem em lugar nenhum.
O endereço que saiu da userland
#define SOIA_HEAP_BASE 0x0000000100008000UL
#define SOIA_HEAP_CAPACITY (16UL * 4096UL)
As duas saíram do soia.h. Com imagem de tamanho variável elas errariam em silêncio no primeiro binário que crescesse: o soia_malloc montaria a lista de blocos num lugar e o kernel mapearia as páginas em outro. No lugar entraram dois seletores de get_system_info, que respondem sobre a tarefa que perguntou.
soia> run heap.elf
heap base: 0x0000000100003000
teto do heap: 0x0000000100013000
primeiro bloco: 0x0000000100003020
O HEAP.ELF confere três coisas: que a base informada é igual ao break inicial, que ela fica acima do segmento de dados do próprio programa, e que crescer o heap consome exatamente duas páginas físicas.
O erro estava no teste
O Test-BootImage.ps1 passou a conferir o mapa derivado e recusou o TERMINAL.ELF com "não cabe no mapa de imagem". O binário estava correto; a conta do teste, não.
# Errado: a divisao devolve double e [int] ARREDONDA
[int]((16288 + 4095) / 4096) # -> 5
# Certo
[int][math]::Floor((16288 + 4095) / 4096) # -> 4
Um texto de 16.288 bytes ocupa quatro páginas. O teste calculava cinco, concluía que os dados começavam antes do fim do texto e acusava uma sobreposição que não existia. Fica o aviso: quando um teste novo recusa código que o sistema aceita, o suspeito é o teste.
Fatia 2 · Demand paging
O heap era de 64 KiB porque o BRK alocava tudo no ato do pedido: teto alto significaria memória física reservada de verdade. Agora ele só promete, e a página aparece quando alguém a toca.
soia> run heap.elf
heap base: 0x0000000100003000
teto do heap: 0x0000000101003000
reservados 4 MiB sem custo: 0 pagina(s)
tocadas 3 paginas, entregues por falha: 3
break devolvido; paginas de volta ao alocador: OK
Como é. Três perguntas decidem, e todas precisam ser sim: o bit 0 do código de erro está em zero (página ausente, não violação de permissão), existe tarefa corrente, e o CR2 está entre a base do heap e o break vigente. Qualquer não manda a falha para o caminho do Marco 21, que encerra a tarefa culpada.
Por que assim. Adiar a entrega é o ganho inteiro; adiar a devolução seria vazamento. Por isso a assimetria: crescer o break não aloca nada, mas encolher desmapeia e devolve na hora, porque quem chama BRK para baixo espera a memória de volta no alocador.
O que muda. Um programa pode reservar megabytes e pagar só pelo que tocar, que é o que uma foto de 1920 × 1080 descomprimida (8,29 MB) vai exigir. O que continua impossível é troca para disco: página entregue só volta quando o break encolhe ou o processo morre.
Camada essencial
Reservar dez mesas para um grupo que talvez venha não ocupa mesa nenhuma: ocupa uma linha no caderno. As mesas só saem de circulação quando as pessoas chegam e sentam.
Antes, o SOIA arrastava as dez mesas para o canto no instante da reserva, e elas ficavam lá vazias. Por isso a reserva máxima era pequena: mesa parada é mesa perdida.
Camada técnica
O Marco 21 estabeleceu que o RPL do CS salvo decide o destino de uma exceção. Aqui a regra não se aplica, e repeti-la seria um bug grave:
char *buffer = soia_malloc(4096);
soia_read_file("README.TXT", 10, buffer, 4096);
A syscall escreve no buffer pelo endereço virtual, com o CR3 da tarefa carregado, e quem executa é o kernel. A primeira falha nessa página chega em ring 0, e é legítima. Filtrar por privilégio faria toda leitura de arquivo para um buffer recém-alocado derrubar o sistema.
O bug que a fatia encontrou
Depois da fatia 2, o FAULT.ELF passou a ser encerrado com código -4 em vez de -6, e só no perfil de 64 MiB. Nenhuma linha do encerramento tinha sido tocada.
Quando um filho morre e o pai não está bloqueado em WAIT, o kernel liberava a vaga na hora, e o código de saída ia junto. O WAIT seguinte respondia "não encontrado" sobre um processo que tinha acabado de morrer.
A corrida existia desde o Marco 16 e nunca aparecia porque criar um processo era lento: oito páginas alocadas e zeradas. Com imagem derivada do ELF e heap sem alocação antecipada, são três, e o filho passou a alcançar a própria falha antes de o pai chegar no WAIT.
A regra que fica: otimizar não só acelera, também muda quais interleavings acontecem. Uma corrida latente não precisa de código novo para virar bug; basta o código antigo ficar rápido.
O segundo bug, na saída
bytes reservados no break: Processo iniciado;
PID 4. O terminal aguardara sua saida.
O app imprimia número dígito a dígito, uma syscall por caractere, e o terminal escreve na mesma janela enquanto espera o filho. Entre duas syscalls cabe uma preempção, e a 100 Hz ela cabe com folga.
A saída é montar a linha inteira num buffer e emitir com uma chamada só: a syscall roda com interrupções desabilitadas, então a linha sai inteira. Vale para qualquer asserção de teste sobre linha com número interpolado.
Um page table cobre 2 MiB, e 2 MiB era o teto de tudo que um processo enxergava: um heap de 16 MiB não cabia nem em promessa. A região passou a ter 16 PTs, 32 MiB, e catorze deles não existem até o heap chegar neles, porque um PT vazio custaria 4 KiB para mapear nada. A lista de quais existem mora no próprio PD, pela mesma razão que os quadros moram nas PTEs desde a fatia 1.
Estudar a fatia 2 completaFatia 3 · A fronteira
O limite estava certo para o sistema de 2019 linhas atrás: o processo inteiro tinha 64 KiB, então nenhum buffer podia ser maior. Com heap em megabytes, ele virou o gargalo.
soia> run heap.elf
reservados 4 MiB sem custo: 0 pagina(s)
tocadas 3 paginas, entregues por falha: 3
arquivo lido em uma syscall: 22960 bytes
chamadas que isso custava antes: 12
Como é. USER_BUFFER_LIMIT foi para 65.536, o mesmo tamanho do buffer de arquivo da VFS, então um arquivo inteiro cabe numa chamada. E a validação de ponteiro deixou de comparar duas pontas: agora percorre cada página da faixa, no máximo 17 leituras de PTE.
Por que assim. Não foi zelo. Comparar as pontas deixa passar uma faixa que atravesse o vazio entre texto e dados, e aí o rep movsb do kernel falha em ring 0 num endereço que o demand paging não atende: panic, provocado por um ponteiro de userland. Com 2.048 bytes era difícil de alcançar; com 64 KiB, uma faixa cobre 17 páginas.
O que muda. Mover 1 MiB custava 512 syscalls e passa a custar 16. O que continua existindo é o intermediário: READ_FILE lê do disco para o buffer do kernel e depois copia para o usuário, dois movimentos onde poderia haver um.
Camada essencial
Não adianta ter um depósito grande se a porta só aceita duas caixas por viagem: mudar de sala vira o trabalho, e não carregar. A porta ficou trinta e duas vezes mais larga, e a conferência passou a ser caixa por caixa em vez de olhar só a primeira e a última da pilha.
Conferir mais dá mais trabalho, e é justamente por a porta ser larga que ele passou a valer: uma pilha grande tem mais lugares onde algo errado se esconde no meio.
Camada técnica
cmp r9, r12 ; antes do heap?
jb .require_pte
cmp r9, r13 ; dentro do prometido?
jb .advance ; passa sem PTE
.require_pte:
test rcx, 0x01 ; presente?
jz .failure
test r11, r11 ; e escrita?
jz .advance
test rcx, 0x02 ; gravavel?
jz .failure
Página no heap prometido passa sem PTE, porque a cópia do kernel vai gerar a falha e ela será atendida. É esse caminho que faz um buffer recém-alocado por malloc ser um destino válido mesmo sem nenhuma página física.
O erro que quase passou
jmp que roubava o RCX de quinze chamadas.A direção da transferência virou parâmetro em RCX, e os wrappers montavam o argumento antes de saltar:
validate_user_read_range_64:
xor ecx, ecx ; destrói o RCX de quem chamou
mov rdx, USER_TEXT_VIRTUAL
jmp validate_user_range_64
São quinze chamadas espalhadas pelo despacho de syscalls, e nenhuma espera perder RCX. O assembler não avisa, e o dano apareceria em uma syscall qualquer, longe daqui.
Os wrappers passaram a salvar e restaurar tudo, com call e ret no lugar do jmp. pop não altera flags, então o CF da validação atravessa a restauração intacto.
A prova que encadeia tudo
O HEAP.ELF aloca 40 KiB no heap, o que só existe pela fatia 2, e pede o MONITOR.ELF inteiro numa chamada, o que só existe pela fatia 3.
As dez páginas do buffer estão prometidas e nenhuma foi tocada. Quem toca primeiro é o rep movsb do kernel, ou seja, a falha legítima acontece em ring 0.
É exatamente o cenário que decidiu não filtrar o demand paging por privilégio. Se a decisão olhasse o RPL, esta prova não imprimiria nada: o sistema teria parado ao ler um arquivo.
Fatia 4 · O armazenamento
O disco tinha 2 MiB e a leitura era tudo ou nada, para um buffer de 64 KiB no kernel. Um arquivo de 1 MiB não cabia em nenhuma das três medidas.
soia> run disco.elf
gravados 1048576 bytes
blocos de 64 KiB: 16 chamadas
conferidos 1048576 bytes, um a um
leitura apos o fim devolveu 0 sem erro: OK
GRANDE.BIN permanece no disco para o proximo boot.
Como é. Duas syscalls novas, 29 e 30, leem e gravam uma janela do arquivo em vez do arquivo inteiro. O quinto argumento, o deslocamento, vai em R8, que já estava no quadro salvo por PUSH_TASK_REGISTERS: do lado do kernel bastou nomear a posição dele.
Por que assim. A escrita exige deslocamento múltiplo de 512 e a leitura não. Escrever no meio de um setor obrigaria a lê-lo, alterar o pedaço e gravá-lo de volta, e quem escreve sequencialmente nunca precisa disso: só o último bloco tem tamanho quebrado, e ele termina o arquivo.
O que muda. Uma foto passa a caber no disco e a ter caminho até a memória do processo. O que continua impossível é apagar arquivo, e crescer um arquivo num disco fragmentado: a alocação é contígua, então estender depende de o vizinho estar livre.
Camada essencial
O mapa de blocos ocupados cabia em um setor. Um setor tem 512 bytes, ou seja 4.096 bits, ou seja descreve 4.096 blocos. E o disco tinha exatamente 4.096 blocos.
O limite existia desde sempre e nunca apareceu, porque a régua tinha o mesmo tamanho da coisa medida. Ele só se manifesta quando você tenta aumentar a coisa.
Camada técnica
; Depois do fim nao e erro: e o fim.
cmp r12, r10
jae .end_of_file
É o sinal de parada de qualquer leitor sequencial. Tratá-lo como falha faria todo programa confundir o fim normal de um arquivo com um problema, e o laço de leitura precisaria saber o tamanho de antemão para nunca chegar lá.
O DISCO.ELF confere justamente isso depois de ler o último bloco: pede mais 64 KiB no deslocamento 1 MiB e exige zero de volta.
O bug que só apareceu num dos caminhos
Subir o kernel de 96 para 112 setores foi anunciado pelo NASM, no lugar certo: TIMES value -65 is negative, o padding que empurra a mensagem final para o último setor ficando sem espaço.
Só que o carregador UEFI tem o tamanho do kernel escrito dentro dele:
#define KERNEL_IMAGE_SIZE (96ULL * 512ULL)
Ele carregou 49.152 dos 57.344 bytes, e o kernel respondeu contrato de boot invalido. O sintoma engana porque é assimétrico: o BIOS continuava bootando normalmente, já que lá o estágio 2 usa o valor montado no próprio binário.
A regra que fica: os dois caminhos de boot não compartilham arquivo nenhum, e é exatamente isso que faz um número duplicado passar despercebido. Ao mexer no tamanho do kernel, os dois lados precisam ser tocados.
Como a prova evita se enganar
static soia_byte_t pattern_at(soia_size_t position) {
return (soia_byte_t)((position * 31UL
+ (position >> 8)) & 0xFFUL);
}
Se todos os bytes fossem iguais, um bloco gravado no deslocamento errado passaria despercebido, e é justamente esse o erro que a escrita com deslocamento pode cometer.
E o teste de persistência confere o arquivo de fora, lendo a imagem no PowerShell com a fórmula reescrita ali. Comparar o arquivo com um valor lido do próprio arquivo seria compará-lo consigo mesmo, que é a mesma armadilha do relógio do Marco 23.
O Marco 24 fecha aqui, nas quatro fatias, e com ele o SOIA passa a ter as três medidas que uma foto exige: espaço no processo, memória para a imagem decodificada e um caminho do disco até ela. O que falta agora não é capacidade, é superfície: as syscalls de tela ainda movem caractere, e não pixel.
Estudar a fatia 4 completaMarco 25 · Superfície e compositor
Antes, as três janelas escreviam no mesmo quadro-negro da sala, cada uma no seu canto combinado: ninguém podia mudar de lugar nem passar por cima da outra. Agora cada uma tem uma folha própria, escreve nela do canto dela, e quem monta as folhas no quadro é o compositor, cinquenta vezes por segundo.
Cada janela recebeu uma superfície contígua na faixa de 16,6 MiB pedida ao alocador no boot. SURFACE_INFO mapeia a superfície no espaço da tarefa e devolve endereço, largura, altura e passo; SURFACE_PUBLISH alimenta a caixa de dano dela. O texto do kernel também passou a sair na superfície, em coordenada local.
Experimento verificado
O PIXEL.ELF recebe um endereço e escreve row[x] = cor, como qualquer programa gráfico faz. A syscall só entra no fim, para dizer o que mudou.
superficie em 0x4320133120
largura: 836 pixels
altura: 940 pixels
passo de linha: 3344 bytes
superficie publicada e composta; pixels vieram de ring 3: OK
Como é. As rotinas de pixel do kernel escrevem num alvo: draw_target_base, draw_target_pitch e draw_target_window. window_select_64 aponta o alvo para a superfície da janela e zera a origem do console; moldura, atalhos e barra de tarefas empilham o alvo, desenham na tela e devolvem o que encontraram.
Por que assim. Guardar base e passo, e não um índice de janela, deixa graphics_fill_rect_64 e graphics_draw_glyph_64 ignorarem completamente a diferença entre os dois destinos. E guardar o alvo anterior, em vez de "voltar para a janela corrente", é o que permite uma rotina de moldura chamar outra sem que a de dentro estrague o trabalho da de fora.
O que muda. Uma janela pode ser movida, coberta e fechada sem que o conteúdo dela se perca: ele mora em RAM, não na tela. O que continua impossível é transparência, escala e duas superfícies por janela, então uma tarefa que desenha devagar mostra o quadro pela metade.
Camada essencial
Um quadro de 1920 × 1080 tem 8,29 MB. Mandar isso por syscall trinta vezes por segundo seriam 250 MB/s atravessando a fronteira, com conferência em cada travessia.
A saída é a mesma de todo sistema real: o kernel entrega o endereço uma vez, o programa escreve direto, e a syscall só carrega o retângulo que mudou.
Camada técnica
cmp qword [draw_target_window], WINDOW_COUNT
jb .surface_damage
Quando o alvo é superfície, a coordenada é local e vai para a caixa daquela janela. Misturar as duas na mesma caixa marcaria o canto do desktop como sujo toda vez que alguém escrevesse uma letra.
É a mesma variável que o compositor consulta para saber que precisa forçar o back buffer antes de copiar: ele roda por interrupção e pode pegar uma tarefa no meio de uma linha.
O bug que o recurso encontrou
terminal_scroll_64 move o console inteiro com rep movsd, e mover pixel não passa por nenhuma rotina que marque dano. O único retângulo marcado era o da última linha.
Isso nunca apareceu porque as células escritas em seguida iam ampliando a caixa até cobrir quase tudo. Com o texto na superfície o acidente acabou, e a rolagem passou a marcar o console inteiro. O preço é real: ela ficou cara, e foi essa lentidão que expôs a corrida do item ao lado.
A corrida que a lentidão revelou
O roteiro do teste digitava run calc.elf, esperava 350 ms e apertava F2. Se o terminal ainda não tivesse lido o Enter quando o foco mudasse, quem recebia a tecla era o bloco de notas: o comando ficava ecoado na tela sem nunca executar.
A corrida sempre existiu. Ela ficou visível porque escrever caractere ficou mais caro. Nenhuma linha de kernel mudou para resolver: a espera do roteiro subiu, com o motivo escrito ao lado.
Marco 26 · Desktop visual
O SOIA tinha janelas, mouse e programas desde antes, mas só havia um jeito de abrir alguma coisa: digitar o nome do arquivo. Agora a tela liga limpa, com atalhos na esquerda e uma barra embaixo. Clicar num atalho abre a janela; o terminal aparece com tudo o que já tinha escrito, e o monitor com o painel já preenchido, porque as tarefas nunca pararam de trabalhar.
O mosaico do Marco 20 deu lugar a janelas flutuantes sobre o fundo, e window_open nasce zerado: as três tarefas do boot rodam com a janela escondida. A ordem da tabela é o z-order e o hit test varre de trás para frente. Todo clique registra pendência e o pulso do timer executa, pelo mesmo motivo do Ctrl+C do Marco 21.
Experimento verificado
O teste move o cursor até o atalho, clica, fotografa a janela aberta, move até o x e clica de novo.
SOIA desktop: 8 atalhos no fundo, barra de tarefas e menu Iniciar; area de trabalho vazia no boot.
SOIA janela: TERMINAL.C / USERLAND mostrada pelo atalho; a tarefa nunca parou de escrever.
SOIA janela: CALC.ELF na janela flutuante 3; o mosaico continua vivo embaixo.
SOIA processo: CALC.ELF aberto pelo clique no dock.
SOIA janela: flutuante 3 fechada; desktop repintado e superficies recompostas.
Como é. As três janelas flutuantes já existem na tabela desde o boot, com posição, tamanho e superfície repartida; abrir é dar dono, copiar o título, limpar a superfície e desenhar. O botão de fechar grava pending_kill_task, exatamente o que o Ctrl+C faz, e a janela morre junto com o programa.
Por que assim. O botão não fecha a janela: ele encerra o programa. A ordem inversa deixaria na tela uma moldura com o conteúdo congelado de um processo que não existe mais. E alocar superfície no clique significaria pedir memória contígua com a tela dependendo do resultado.
O que muda. Dá para abrir, usar e fechar programa só com o mouse, e o que estava embaixo volta intacto. O que ainda não dá: redimensionar pela borda e abrir mais de três ao mesmo tempo. Arrastar e trazer para a frente entraram na quinta fatia.
Camada essencial
O terminal é um garçom: você precisa saber o nome do prato e pedir com as palavras certas. Os atalhos e o menu Iniciar são o cardápio com fotos: você aponta.
Os dois levam ao mesmo prato. A lista de programas mora no kernel, e é a mesma que o SPAWN aceita: dois caminhos diferentes para executar arquivo seria um a mais para esquecer de proteger.
Camada técnica
window_hit_test_64:
mov rax, WINDOW_COUNT
.next:
dec rax
As molduras são desenhadas na ordem da tabela, então as flutuantes cobrem as do sistema. A varredura do clique precisa ser a inversa, senão um clique numa janela de cima é atribuído à que ela está cobrindo.
Pelo mesmo motivo, os atalhos passaram de primeiro para último teste: agora qualquer janela passa por cima deles.
O bug que o dock encontrou
O EXIT marcava a tarefa como zumbi e deixava a coleta para o WAIT do pai. Isso estava certo desde o Marco 16, porque todo processo tinha um pai que o pediu.
O programa aberto pelo clique não tem pai: ninguém vai chamar WAIT. Cada clique vazava uma vaga e uma janela flutuante, e em quatro cliques o desktop parava de abrir programa. Agora, sem pai, a vaga é coletada no próprio EXIT; com pai, ela continua zumbi de propósito.
O bug que a bateria verde escondeu
O present marca as duas posições do cursor, a velha e a nova, como danificadas. Com o texto na superfície, essa marcação passou a cair na caixa da superfície que estivesse sendo escrita no momento da interrupção, e o lugar antigo nunca era limpo.
A bateria estava verde: o cursor do teste parava dentro da janela do monitor, que se repinta duas vezes por segundo e apagava o rastro antes da captura. A asserção nova leva o cursor para um console ocioso e conta os pixels: uma seta tem exatamente 30.
E essa asserção nasceu certa pela metade, o que só apareceu no dia seguinte. Ver por que ela reprovou código correto
Arrastar sem repintar sessenta vezes por segundo
Mover a janela a cada pacote do mouse custaria um repintado do desktop a 100 Hz. Durante o arrasto o kernel desenha só um retângulo de contorno, direto no front buffer, como faz com o cursor. Ao soltar, a janela muda de lugar e o desktop é repintado uma vez.
É o que o Windows 95 fazia por padrão, e pelo mesmo motivo. O contorno some sozinho: o present marca as quatro tiras dele como danificadas e as recopia do back buffer no quadro seguinte.
Isso durou um dia. A janela inteira passou a seguir o mouse a 25 Hz, e o que tornou o repintado barato foi o papel de parede virar uma cópia. Ver o que mudou
Três estados, não dois
fechada window_in_taskbar=0 window_open=0
minimizada window_in_taskbar=1 window_open=0
na tela window_in_taskbar=1 window_open=1
A primeira versão usava só um estado, e minimizar fazia o programa sumir da barra: sem botão e sem janela, a única forma de trazê-lo de volta era o atalho, o que transformava minimizar em fechar.
Para janela de programa há uma condição a mais: a tarefa dona precisa estar viva. Quando ela morre, o botão some sozinho, sem ninguém avisar a barra.
O limite honesto do compositor
A superfície de cada janela é copiada inteira para a área dela, sem recortar contra as vizinhas. Uma janela de baixo que escreve o tempo todo, como o monitor, apagava a de cima duas vezes por segundo.
O conserto acontece na mesma passada: a moldura da janela de cima é redesenhada na hora, porque moldura não está em superfície nenhuma, e a superfície dela é marcada como suja, sendo composta em seguida na ordem de profundidade. Custa uma cópia a mais na área sobreposta, e nenhuma lista de retângulos.
Esconder não é encerrar
cmp rax, STATIC_WINDOW_COUNT
jae .close_program
mov [pending_hide_window], al
Fechar a janela do terminal, das notas ou do monitor esconde: as três tarefas são residentes e encerrá-las deixaria o sistema sem interface, que é a mesma regra do Ctrl+C desde o Marco 21. Fechar a janela de um programa aberto por clique encerra o programa, e a janela morre junto.
Por isso o hit test do botão devolve duas coisas: a janela e a tarefa dona. Quem decide é o chamador, e não um número sozinho.
O que a área de trabalho vazia prova
As três tarefas do boot rodam com a janela escondida e escrevem na superfície delas o tempo todo. Clicar no atalho do monitor mostra o painel já preenchido, com os números que ele vinha calculando enquanto ninguém olhava.
É o teste mais direto de que a superfície é memória de verdade, e não um atalho para a tela: se fosse a tela, a janela escondida teria escrito no vazio.
31/07/2026 · depois do Marco 26
A tela ganhou foto de fundo, o relógio passou a mostrar a hora de verdade e a janela passou a acompanhar o mouse enquanto você arrasta. E o ponteiro ficou leve, que é a parte que não se vê: ele estava obrigando o sistema a redesenhar meia tela a cada milímetro de movimento.
Papel de parede em paleta de 256 cores lida do disco no boot e expandida para XRGB8888; hora e data do CMOS com espera de UIP e leitura dupla; dock de quatro entradas; window_drag_pending_64 a 25 Hz; e o cursor fora da caixa de dano, com reposição por cópia dirigida de 384 bytes.
A medida que decidiu tudo
Mesma bateria, mesmo roteiro, medido pelo próprio monitor do SOIA.
Como é. A caixa de dano é um único min/max global: ela guarda o retângulo que contém tudo que mudou desde a última publicação. As duas posições da seta, a antiga para apagar e a nova, eram jogadas dentro dela. Agora mouse_cursor_restore_64 devolve ao front os 8 × 12 pixels da posição antiga copiando do back, e a caixa só carrega o que as janelas mudaram.
Por que assim. Não existe backing store para manter em sincronia: o back buffer nunca teve cursor, então ele já é o fundo verdadeiro daquela área. Guardar uma cópia do fundo seria uma segunda verdade sobre os mesmos pixels, e sincronia a mais para errar.
O que muda. Os 2.825 quadros que sumiram da conta não deixaram de acontecer: eles deixaram de copiar. São as publicações disparadas por pacote de mouse quando nenhuma janela mudou nada. O que continua impossível é o cursor custar zero: sem sprite de hardware, alguém tem que escrever esses pixels.
Camada essencial
Para mover um post-it na mesa você limpa a marca onde ele estava e cola no lugar novo. O SOIA fazia isso pegando uma régua, medindo o retângulo que contém o post-it velho, o novo e qualquer papel que tivesse mudado na mesa, e redesenhando esse retângulo inteiro. Cem vezes por segundo.
Se um papel tinha mudado no canto oposto, o retângulo virava a mesa toda. Limpar o quadradinho do post-it não tem nada a ver com o resto da mesa, e é isso que a correção diz.
Camada técnica
surface_composite_pending_64 ; superficies -> back
mouse_cursor_restore_64 ; 384 B na posicao ANTIGA
cmp r8d, r10d
jae .compose ; caixa vazia pula a copia
; ... copia a caixa ...
.compose:
mouse_cursor_compose_64 ; 384 B na posicao NOVA
Restaurar dentro do caminho da cópia deixa rastro no desktop parado, porque caixa vazia pula a etapa. Compor lá dentro faz o cursor sumir pelo mesmo motivo. E restaurar usando mouse_x apaga o lugar novo e deixa o antigo na tela.
O caminho errado, e ele ensinou mais que o certo
A mesma correção foi implementada e revertida duas vezes, com a mensagem O cursor sumiu depois do trajeto pelo console: 0 pixels. Eu li isso como bug da implementação nova. Não era: o mesmo binário passava e falhava alternadamente.
antes de qualquer mudanca original falhou
backing store alterado falhou
copia dirigida alterado falhou
revertido original passou
revertido original falhou
revertido, isolado original passou
A causa era o teste tirar uma foto da tela. Entre a cópia da caixa de dano e a composição da seta, a tela realmente não tem cursor nenhum, e a foto às vezes cai exatamente aí.
A distinção que conserta a asserção
Uma seta esquecida fica na tela até alguém repintar aquele retângulo, e ninguém repinta: o trajeto do teste termina no console ocioso do terminal. Já a ausência dura o tempo de uma cópia.
Então nenhuma amostra pode passar de 40 pixels, e basta uma mostrar a seta inteira. São três capturas espaçadas por 600 ms, e a prova de que a hipótese estava certa veio na primeira execução com o código novo:
Rastro: qemu-trail.ppm: 0 | qemu-trail.ppm.2: 30 | qemu-trail.ppm.3: 30
A primeira foto caiu no meio do present. O teste antigo teria reprovado ali, pela terceira vez, um código que está certo.
Papel de parede
cores distintas 23.217
cores a 5 bits/canal 912
erro medio com 256 0,8%
XRGB8888 puro 7,91 MiB
indice + paleta 1,98 MiB
O kernel não decodifica imagem: PNG e JPEG são o Marco 27, em ring 3. O conversor roda fora do build, em Python, para o build seguir sendo PowerShell puro. RLE foi descartado por medição: 95% dos pixels começam corrida nova.
Sem o arquivo, sem RAM contígua ou com dimensão diferente da tela, o fundo volta a ser cor sólida e a serial diz isso. Enfeite não derruba boot.
A decisão que mudou no mesmo dia
A primeira versão desenhava sempre por lookup, e o argumento estava escrito no cabeçalho do arquivo: o desktop é repintado poucas vezes por sessão, então 8 MiB de cache não se pagariam num perfil de 64 MiB.
O arraste derrubou o argumento horas depois. Com a janela seguindo o mouse, o desktop voltou a ser repintado 25 vezes por segundo, e 2.073.600 iterações de lookup passam de 25 ms sob TCG: não cabe num quadro de 40 ms. Hoje a imagem é expandida uma vez no boot e desenhada com rep movsd.
O custo de uma operação depende de quantas vezes ela vai acontecer, e isso muda quando outra coisa muda. O comentário que justificava a decisão antiga sobreviveu à decisão por algumas horas.
Relógio
0x70 = indice, 0x71 = dado
0x0A bit 7 = update in progress
0x0B bit 2 = BCD ou binario
0x0B bit 1 = 12 h ou 24 h
O chip atualiza os registradores uma vez por segundo, e ler no meio devolve minuto novo com segundo velho. Esperar o UIP cair não resolve, porque a atualização pode começar entre a checagem e a leitura: a defesa é ler duas vezes e comparar.
O contrato devolve EAX, EBX e ECX. A primeira versão usava AL, AH e BL, e o NASM recusou: em x86-64, instrução com prefixo REX perde o acesso a AH, BH, CH e DH.
Fuso não é assunto do kernel. O QEMU recebe -rtc base=localtime e entrega a hora local do host; sem isso o relógio mostra UTC.
O bug mais estranho do dia
O menu mostrava DOCK_ENTRY_PATH + 1, o caminho do executável sem a barra inicial. As três janelas do sistema têm PATH = 0, porque elas não lançam processo nenhum: para elas, o menu lia a partir do endereço físico 1, que é a tabela de vetores de interrupção do BIOS, e desenhava aquilo como texto.
O conserto é usar o LABEL, que toda entrada tem. A categoria é maior que o bug: ponteiro derivado de campo opcional precisa testar o campo antes de derivar. Sem o + 1, o sintoma seria nome vazio, e ninguém notaria diferença entre vazio e lixo.
O dock caiu de onze para quatro entradas: TERMINAL, NOTAS, MONITOR e
CALCULADORA. Os outros sete programas continuam no disco e rodam por
run nome.elf. E o modo janela do QEMU passou a usar SDL,
porque o grab do GTK no Windows captura o teclado mas não confina o
ponteiro: o cursor do host escapava pela borda e o do SOIA parava antes
de alcançar os atalhos.
Marco 27 · concluído nas três fatias
A janela que era texto passou a mostrar imagem de verdade, guardada em arquivo do disco. Cada foto tem um ícone na área de trabalho, e o SOIA abre os três formatos em que uma imagem costuma chegar. A mesma cena existe nos três, e o peso conta a história: 929 KB, 929 KB e 8,6 KB.
BMP de 24 e 32 bits, PNG de 8 bits em cor RGB e JPEG baseline, abertos por FOTO.ELF em ring 3. O BMP é lido uma linha por chamada; o PNG e o JPEG exigem a imagem em RAM, porque os dois dependem do que já foi emitido. Cabeçalho tratado como dado hostil nos três, com CRC por chunk e Adler-32 no PNG.
Experimento verificado
O teste fotografa a tela e confere seis pontos por cor, em posições calculadas a partir do que o próprio programa relatou.
soia> run foto.elf
SOIA Foto 0.1 - a primeira imagem vinda do disco
BMP 642 x 482, 24 bits, linha de 1928 bytes
ordem das linhas: de baixo para cima; pixels em 54
superficie: 968 x 568; imagem ajustada para 756 x 568 em 106,0
desenhando linha a linha, direto do disco para a superficie.
imagem do disco composta na janela; ring 3 desenhou: OK
Como é. Para cada linha da janela o programa calcula qual linha do arquivo precisa e lê só ela, no deslocamento pixel_offset + linha × passo. O BMP guarda a última linha primeiro, então a conta inverte; o passo é arredondado para múltiplo de 4, porque o formato exige.
Por que assim. Carregar a imagem inteira caberia (929 KiB num heap de 16 MiB), e mesmo assim não é o que se faz: uma foto de 8 MiB custaria 8 MiB. Lendo por linha, o custo é o passo, 1.928 bytes, para qualquer tamanho de imagem. Isso só existe desde a fatia 4 do Marco 24, que deu leitura por deslocamento à VFS.
O que muda. O SOIA mostra imagem de arquivo. O que continua impossível é abrir PNG ou JPEG, que é o que uma foto costuma ser, e é a fatia 2 e a 3. Também não há canal alfa, nem filtragem na redução: a escala é vizinho mais próximo e serrilha.
Camada essencial
Uma imagem no disco é uma tira longa de pontinhos em ordem, e a tela é um retângulo. Mostrar a foto é cortar a tira em linhas e pendurar cada uma na altura certa.
Duas coisas complicam, e as duas são do formato. O rolo está de trás para frente, porque o BMP guarda a última linha primeiro, herança de quem media a tela de baixo para cima em 1990. E cada linha tem um pedaço de sobra, porque o formato exige que ela ocupe um número de bytes múltiplo de quatro.
Camada técnica
'BM' senao nao e imagem
bfOffBits depois dos dois cabecalhos
biWidth/Height 1 a 16.384; altura < 0 = topo primeiro
biBitCount 24 ou 32, e nada mais
biCompression zero, ou os pixels nao sao pixels
A que mais importa é a segunda: deslocamento apontando para antes do fim dos cabeçalhos faz o programa desenhar o próprio cabeçalho como imagem, que é o começo de todo vazamento de memória adjacente.
E há uma sexta pergunta, que não está no cabeçalho: a última linha existe mesmo? O programa a lê antes de desenhar qualquer coisa. Sem isso, uma imagem truncada desenharia lixo da metade para baixo.
A prova que separa certo de quase certo
A imagem de teste tem quatro quadrantes de cores diferentes e um quadrado amarelo no canto superior esquerdo. Os quadrantes provam que a imagem chegou; o marcador prova a ordem das linhas.
Se o programa lesse o BMP de cima para baixo, a imagem sairia espelhada na vertical, e os quatro quadrantes continuariam parecendo certos numa olhada rápida. No lugar do amarelo estaria o verde do quadrante de baixo, e é exatamente isso que o teste mede.
As dimensões são feias de propósito: 642 × 482. A largura em bytes não é múltiplo de quatro, então cada linha carrega dois bytes de enchimento, e nenhuma metade cai em número par. Uma imagem de 640 × 480 esconderia os dois erros.
O bug que a fatia encontrou, e não é de imagem
Com FOTO.ELF e FOTO.BMP, o volume passou a nascer com 16 arquivos. O diretório do SOIAFS1 era um setor de 512 bytes com entradas de 32, ou seja 16 entradas, e o sistema cria mais dois arquivos em uso: NOTE.TXT e GRANDE.BIN.
O sintoma foi o bloco de notas dizendo erro: NOTE.TXT nao foi salvo num disco com 29 MiB livres. Faltava linha no catálogo, e não espaço. O diretório passou a ter dois setores, e o build reserva duas vagas: ele recusa gastar as últimas, porque quem gasta só descobre em uso.
O dano silencioso da mesma mudança
; a montagem le o campo do superbloco
cmp dword [... BITMAP_LBA_OFFSET], FILESYSTEM_BITMAP_LBA
; a gravacao tinha o numero na mao
mov eax, FILESYSTEM_START_LBA + 2
Crescer o diretório empurrou o bitmap do LBA 2 para o 3. A montagem passou a exigir 3; a gravação continuou mandando para o 2. O comentário do arquivo até defendia o literal, dizendo que o 2 tinha respaldo porque a montagem recusava outro valor. O respaldo existia, e sumiu no instante em que a constante mudou.
O sintoma foi cruel. O arquivo era criado, o diretório ia ao disco, o conteúdo voltava intacto no boot seguinte, e só o bit daquele setor ficava zerado no bitmap gravado: o alocador entregaria o mesmo setor a outro arquivo depois. Nenhuma mensagem de erro. Quem pegou foi o teste de persistência, que confere o bitmap além do conteúdo.
O que a fatia deixou pronto para o PNG
O caminho está inteiro: arquivo no volume, leitura por deslocamento, validação de cabeçalho, ajuste à janela, escrita na superfície e composição. A fatia 2 troca uma coisa só, entre a leitura e o desenho: onde hoje há uma troca de ordem de bytes, entra o inflate.
E ele roda em ring 3 por decisão de projeto. Decodificador de mídia é vetor clássico de invasão, e no SOIA uma falha dele encerra a tarefa culpada sem derrubar o sistema, desde o Marco 21.
O aplicativo virou galeria
O FOTO.ELF mostra duas imagens: a de prova, com os quadrantes, e uma foto real convertida de um PNG qualquer. A ordem é deliberada, porque a bateria fotografa a tela logo depois da abertura e mede cores exatas em posições calculadas, e nenhuma cor de uma paisagem serve para isso. Quem quer ver a foto aperta espaço; o teste não aperta nada.
O desktop ganhou o quinto atalho, FOTOS. Ele usa o desenho genérico de ícone, que pinta a inicial do rótulo: arte própria para cada programa novo não escala.
O relógio que virou corrida
O terminal fica bloqueado em WAIT enquanto o programa aberto existir, e digitar antes de ele sair perde as teclas. Foi o que aconteceu: a bateria mandou run disco.elf e o log registrou n disco.elf, com o ru caindo no vazio.
Aumentar a espera só empurra o problema, porque o programa vive dez segundos depois de terminar de desenhar, e desenhar depende da máquina. O sinal certo já existia: a frase que ele imprime ao sair.
Fatia 2 · o PNG é decodificado
abrindo FOTO.PNG...
PNG 642 x 482, 8 bits RGB; 3 chunks com CRC conferido
inflate: 928895 bytes comprimidos viraram 928332 de pixel, com Adler-32 conferido
A prova é a mesma cena duas vezes. O teste confere os mesmos seis pontos, com as mesmas cores, na imagem que veio do BMP e na que veio do PNG. O BMP não passa por nada: os bytes do arquivo são os pixels. O PNG passa por descompressão, cinco filtros de linha e troca de ordem de canais. Cor igual nos dois prova que o decodificador novo não inventou nada.
Três verificações, três perguntas diferentes. A assinatura pergunta "é um PNG?"; o CRC de cada chunk pergunta "este pedaço chegou inteiro?"; o Adler-32 pergunta "a descompressão chegou ao mesmo resultado que o compressor tinha?". Um arquivo truncado no meio de um IDAT passa em todas as checagens de cabeçalho e morre no CRC.
Os filtros erram em silêncio
Antes de comprimir, o codificador escolhe por linha uma previsão que deixa os bytes parecidos entre si, porque bytes parecidos comprimem melhor: nada, o pixel da esquerda, o de cima, a média dos dois, ou o Paeth, que escolhe entre três vizinhos o mais perto de uma previsão linear.
Errar um deles não trava nada. A imagem aparece, com faixas ou arrasto de cor, e nenhuma verificação de integridade reclama, porque os bytes descomprimidos estavam certos. É por isso que a prova confere cores exatas em posições calculadas, e não "a imagem apareceu".
Um atalho por foto
A área de trabalho tem oito atalhos, e três abrem uma foto cada. Os quatro últimos usam o mesmo FOTO.ELF: o que muda é o argumento guardado na entrada do dock. Sem isso, um ícone por foto exigiria um binário por foto, ou uma lista fixa que ninguém muda sem recompilar o sistema.
O kernel copia o argumento para a vaga da tarefa, e não guarda o ponteiro. Hoje a string vive numa tabela do kernel, que não morre; no dia em que ela vier de ring 3, o ponteiro seria de outra tarefa, e o bug apareceria longe daqui. A vaga tem 16 bytes, o mesmo que um nome de arquivo, e argumento maior vira string vazia em vez de nome truncado: truncar produz pedido a um arquivo que não existe.
O mesmo argumento pelo teclado
run FOTO.ELF FOTO2.PNG, e o corte no primeiro espaço.O clique já sabia dizer qual foto abrir; a linha digitada não. Agora sabe, e é o mesmo caminho: quem recebe o pedido não faz ideia se veio de um ícone ou de uma tecla.
soia> run foto.elf foto.bmp
aberto pelo argumento FOTO.BMP
O SPAWN passou a ler o argumento em RDX, e o terminal a cortar a linha no primeiro espaço. Sem o corte, run FOTO.ELF FOTO2.PNG vira um nome de programa de 18 caracteres e é recusado antes de existir. Zero em RDX significa "sem argumento" e não erro, então nenhum aplicativo antigo mudou.
A bateria digita o nome da imagem em que a galeria já começaria sozinha. Assim o argumento é provado sem mexer em nenhuma das capturas seguintes, que dependem da ordem BMP → espaço → PNG e conferem seis cores exatas. Prova nova que reorganiza a prova velha custa duas vezes.
O erro que essa mudança causou
SOIA excecao: instrucao invalida | vetor 6 | RIP 0x0000000000000007
SOIA excecao: falha em ring 0; o kernel nao tem a quem devolver o controle.
A rotina que lança programa ganhou um registrador salvo a mais, e o push ficou de fora numa edição enquanto os dois pop entraram: a função passou a voltar para um endereço tirado do lixo.
O relatório de exceção do Marco 21 resolveu isso em um minuto. Sem ele, o sintoma seria a máquina parada sem nenhuma pista. Ao mexer no prólogo de uma rotina em Assembly, conferir todos os caminhos de saída: as deste kernel costumam ter dois, um de sucesso e um de falha.
Duas medições, e nenhuma onde eu apostei
Abrir uma foto de 1,9 MiB levava quase um minuto, e a suspeita óbvia era o decodificador. Medido no PC, o inflate dessa mesma foto custa 20 ms: o tempo estava em 486 leituras de 4 KiB, cada uma descendo ao disco ATA em PIO. O pedaço virou 64 KiB, que é o teto da fronteira das syscalls, e foram 31 chamadas.
A segunda: a imagem aparecia descendo linha a linha, com o texto do programa visível por baixo do que ainda não fora pintado. O compositor copia a superfície a cada quadro, e o desenho leva segundos. Agora o programa pinta um quadro fora da tela e copia de uma vez; a cópia de 2,2 MiB custa menos que o desenho de uma linha.
O algoritmo foi testado fora do sistema
Um ciclo no QEMU custa dois minutos e não tem depurador. O mesmo arquivo do inflate foi compilado para Windows num harness pequeno e rodado contra um PNG real, comprimido com Huffman dinâmico:
descomprimido: 4720997 bytes (esperado 4720997)
adler32 calculado: 18d8b04b | declarado: 18d8b04b
O Adler batendo sobre 4,7 MB é prova completa: cada byte saiu no lugar certo. O algoritmo entrou no SOIA já correto, e o que sobrou para depurar lá foi só a integração.
O bug que o atalho encontrou
Abrir o visualizador pelo atalho respondeu FALHA: o kernel nao entregou a superficie desta janela. A faixa de superfícies era repartida avançando largura × altura × 4, que não é múltiplo de 4.096, e quem entrega a superfície a ring 3 monta uma PTE, que aponta para página e não para byte. A primeira janela nascia alinhada porque a faixa é; as outras cinco, não.
Isso estava lá desde o Marco 25. O único aplicativo que pedia superfície era aberto por run, e nesse caminho o filho herda a janela do pai, que é a do terminal, que é a janela 0, a única alinhada. Recurso testado sempre pelo mesmo caminho é recurso testado num caso só, e o caso que a bateria escolhe costuma ser o mais fácil.
O arquivo de prova é montado à mão
O PNG de teste sai de um script que monta o fluxo zlib inteiro: CMF/FLG, blocos stored e Adler-32. Um bloco stored carrega os bytes crus com cinco bytes de cabeçalho, então o arquivo é um PNG legítimo, que qualquer visualizador abre, e a metade mais simples do inflate já dá conta dele.
Usar a biblioteca pronta do sistema produziria o fluxo comprimido e esconderia justamente o que o exercício quer mostrar.
Fatia 3 · o JPEG
abrindo FOTO.JPG...
JPEG 642 x 482, croma 2x2; 1271 MCUs decodificados de 1271
IDCT: 8621 bytes comprimidos viraram 928332 de pixel
Os três arquivos desenham exatamente a mesma coisa. O primeiro ocupa 929 KB, o segundo também, e o terceiro cabe em 8,6 KB. A diferença é que o JPEG joga fora informação antes de guardar, e escolhe jogar fora justamente o que o olho não cobra.
Huffman, DC diferencial e marcadores são exatos; a única etapa com perda é a divisão pela tabela de quantização, que aconteceu no codificador e não tem volta. É por isso que o PNG fecha com Adler-32 e o JPEG não tem equivalente: não existe resultado esperado para conferir.
Onde o MCU entra
Com a crominância subamostrada, os três componentes têm tamanhos diferentes e não dá para alternar bloco a bloco. O arquivo intercala em minimum coded units: em 4:2:0, um pedaço de 16 × 16 pixels com quatro blocos de luminância e um de cada crominância. Uma imagem de 642 × 482 tem 41 × 31 = 1.271 deles, e a bateria confere essa conta em vez de aceitar o número que o programa imprimiu.
A IDCT é separável: em duas passadas de oito, 1.024 multiplicações por bloco em vez de 4.096. E há o atalho do bloco constante, quando só o coeficiente 0 sobrevive: aí o bloco é uma cor chapada e as 1.024 viram uma.
A prova mudou de forma
Nas duas primeiras fatias o teste exige cor exata nos seis pontos. Aqui isso reprovaria um decodificador correto, porque o formato guarda uma aproximação. A asserção passou a ser distância máxima de 8 níveis por canal.
O 8 saiu de medição, não de gosto: o erro real nos cinco pontos de quadrante é de 0 a 1 nível. Oito dá folga para outra versão do encoder e continua reprovando de longe qualquer erro de verdade, porque trocar canais ou inverter linhas muda dezenas.
O diagnóstico que a média escondia
contra a referencia: erro medio 1.6572, pior 71
erro medio luma 0.2099 | Cb 1.4915 | Cr 1.1890
Comparado com o decodificador do Windows, o resultado divergia em média 1,66 por byte. Esse número serve tanto para "está correto" quanto para "está errado", e por isso não decide nada.
Separado por componente, ele decide. A luminância, que não passa por reamostragem, bate com 0,21: Huffman, quantização, IDCT e DC diferencial estão certos. Todo o desvio está na crominância, e a causa é conhecida: o SOIA reamostra por vizinho mais próximo e a implementação madura interpola. Regra que fica: diferença agregada não vira diagnóstico até ser separada pelas partes que a produzem.
Por que este gerador NÃO é escrito à mão
O gerador do PNG monta o fluxo zlib byte a byte de propósito, porque um bloco cru cabe em trinta linhas. JPEG não tem equivalente: não existe bloco cru, então qualquer arquivo válido exige DCT, quantização e Huffman no gerador, o que seria escrever o decodificador ao contrário.
O arquivo de prova sai do codec que já vem no Windows. Abrir o que outra implementação produziu é prova de interoperabilidade; abrir o que eu mesmo gerei seria prova de coerência interna, que é bem mais barata e vale bem menos.
O byte que quase virou bug
Dentro da varredura, todo 0xFF é seguido de outro: 0x00 quer dizer "é um 0xFF de verdade" e qualquer outra coisa é marcador. Sem esse enchimento, um coeficiente qualquer poderia formar a assinatura de um marcador no meio da imagem.
Ao encontrar um marcador, o leitor precisa recuar um byte, para que a busca pelo reinício seguinte ache o 0xFF e não o byte depois dele. A primeira versão não recuava, e o sintoma não apareceria em nenhum arquivo da galeria: apareceria no primeiro arquivo de câmera com intervalo de reinício, com a imagem escorregando na horizontal a partir do meio.
Marco 28 · concluído nas quatro fatias
Um programa do SOIA não podia passar de 64 KB, e ninguém tinha decidido isso: era efeito de como ele entrava na memória. Agora o sistema traz o programa do disco em pedaços, e o limite passou a ser oito vezes maior. O primeiro binário grande já existe, e ele foi escrito só para provar que o limite caiu.
Todo ELF entrava pelo FILESYSTEM_FILE_BUFFER de 64 KiB, e vfs_read_file_64 recusa acima de 65.535 bytes. O carregador passou a ler um setor de cabeçalho para ELF_HEADER_BUFFER, validar ali contra o tamanho que o diretório informa, e trazer cada página do segmento por vfs_read_range_64 direto para o quadro físico da tarefa. O teto voltou a ser USER_IMAGE_PAGE_LIMIT: 128 páginas, 512 KiB de imagem.
Experimento verificado
O programa confere a própria tabela, mede o próprio arquivo lendo do volume e compara o texto carregado com o do disco, byte a byte.
soia> run pesado.elf
SOIA imagem: tarefa 3 carregada com 18 paginas de texto, 1 de dados
e heap em 0x0000000100013000, de um ELF de 80792 bytes lido em 19 janelas.
SOIA PESADO 0.1 - o binario que nao cabia
tabela conferida byte a byte: OK
80792 bytes em 3 leituras de 64 KiB
arquivo maior que os 65535 do caminho antigo: OK
73152 bytes iguais entre o arquivo e a memoria em execucao
texto carregado igual ao do disco: OK
Como é. elf_prepare_file_64 pega o tamanho no diretório, lê um setor para ELF_HEADER_BUFFER e chama a validação. Depois que task_create_64 mapeia texto, dados e pilha, elf_load_user_pages_64 percorre cada página do segmento, pergunta à PTE qual é o quadro físico e manda vfs_read_range_64 escrever ali. A diferença entre p_memsz e p_filesz continua saindo de graça: a página veio zerada do alocador.
Por que assim. A capacidade de ler por deslocamento existia desde a fatia 4 do Marco 24 e já era usada para imagem; só o carregador de ELF não a usava. Não foi preciso inventar mecanismo nenhum, e sim ligar dois lados que já estavam prontos. É o padrão a procurar antes de escrever peça nova.
O que muda. Um aplicativo pode ter até 512 KiB de imagem, e o navegador do Marco 28 cabe. O que continua impossível é carregamento preguiçoso: o ELF inteiro entra na criação da tarefa, página por página, e nenhuma parte espera o primeiro acesso.
Camada essencial
Carregar um programa era como fazer mudança exigindo que todos os móveis coubessem na sala de espera do prédio antes de subir. A sala tinha 64 KB. Se a mudança fosse maior, o porteiro recusava na porta, e não importava que o apartamento fosse enorme.
Agora o caminhão encosta e cada caixa sobe direto para o cômodo dela. A sala continua existindo, com 512 bytes, e guarda só a lista do que vem: quantas caixas são, o que tem em cada uma e em que quarto elas vão. A lista é conferida antes de qualquer caixa entrar.
Camada técnica
Elf64_Ehdr 64 bytes
2 x Elf64_Phdr 112 bytes
---
176 de 512
cmp rax, ELF_HEADER_WINDOW_SIZE
ja .failure
Os dois números da rotina deixaram de ser o mesmo: RSI cobre 512 bytes e RCX é o tamanho do arquivo no disco. Sem a comparação acima, um e_phoff de 40.000 num ELF de 80 KiB passaria pela conta que olha o arquivo, e a rotina leria como cabeçalho de programa o que estivesse depois do buffer, na memória do kernel.
A decisão que parece paranoia e não é
O carregamento lê os cabeçalhos de programa da janela que já foi validada, e não do disco outra vez. Reler pareceria mais robusto e seria o contrário: é o padrão TOCTOU, verificar uma coisa e usar outra.
Isso torna as duas rotinas um par com ordem obrigatória, e nada entre elas pode tocar o buffer. Pela mesma razão, task_create_64 passou a receber o nome do arquivo em vez da imagem: a alternativa era ela confiar no cabeçalho que o chamador tivesse deixado no buffer, e contrato implícito é o que o próximo chamador esquece.
Dois erros que passariam na bateria
A tabela é volatile. Sem isso o compilador sabe o conteúdo dela, dobra a conferência inteira em tempo de compilação e some com os 64 KiB de .rodata. O binário voltaria a caber no teto antigo e o teste diria OK sobre nada.
E o padrão não pode ter período curto. A primeira versão usava (i * 31 + 7) & 0xFF, que se repete a cada 256 posições. Como 4.096 é múltiplo de 256, páginas trocadas de lugar dariam exatamente o mesmo resultado, e a conferência passaria sobre um carregador quebrado.
O que o build passou a cobrar
Ligando PESADO.ELF...
2 PT_LOAD: texto RX + dados/BSS RW; imports externos: zero
imagem: 19 de 128 paginas de usuario
A cobrança de 64 KiB por arquivo saiu e entrou a conta que elf64_validate_64 faz: a ponta da imagem, ou seja o índice da primeira página de dados mais as páginas de dados. O vazio entre texto e dados conta contra o limite, porque ele existe dentro da região.
Build que não cobra o mesmo teto do kernel entrega ao QEMU um binário que o kernel recusa sem dizer por quê.
O que a fatia deixou pronto para o renderizador
Medida a densidade deste projeto, de 12,8 a 15,5 bytes de binário por linha de C, o orçamento anterior deixava cerca de 19.900 bytes de texto livres depois do FOTO.ELF. Um navegador com tokenizer, árvore, cascata e layout não cabe nisso.
As três fatias seguintes são o renderizador em si, e a próxima é o tokenizer com a árvore, provados num harness compilado para Windows antes de entrar no SOIA, como o inflate do Marco 27 foi. Parser é justamente a parte que erra em silêncio: ele produz uma árvore plausível e errada.
Fatia 2 · o HTML do disco vira árvore
O mesmo html.c é compilado para Windows e rodado contra 24 casos com árvore esperada, antes de qualquer boot.
soia> run navegador.elf
PAGINA.HTM: 1688 bytes em 2 leituras
titulo: SOIA - pagina de prova
arvore: 29 elementos, 29 nos de texto, profundidade 5
recuperacao: implicitos 6, orfaos 0, desconhecidas 1, recusadas 2
entidades resolvidas: 5; texto util: 839 bytes
tags fora do escopo: <article>
arvore montada em ring 3: OK
O que o harness pegou na primeira execução
Profundidade contada pela pilha, e não pelo pai: <html> explícito empilha a raiz duas vezes, e todo documento saía um nível mais fundo. O número era plausível.
Texto emitido duas vezes em 3 < 5: o parser despejava o acumulado antes de descobrir que aquele < era texto.
</article> nunca fechava <article>: todas as tags fora da lista tinham o mesmo enum. Virou recurso: elemento desconhecido guarda o próprio nome no campo que estaria vago, e o fechamento casa por nome.
E o quarto era o teste que estava errado: <hr> é bloco e fecha parágrafo, como no HTML de verdade.
Camada técnica
Quem chama entrega o vetor de nós e o pool de texto; o parser devolve HTML_ERROR_NODE_CAPACITY quando um deles acaba. É o contrato do jpeg_probe do Marco 27, e pela mesma razão: decodificador que aloca sozinho decide o teto de memória de um processo que ele não conhece.
A pilha de tags é um vetor de HTML_MAX_DEPTH, 32 níveis. A pilha de um processo tem 8 KiB com página de guarda embaixo, e um parser recursivo em documento fundo bateria nela: aninhamento demais vira frase, e não falha de página.
Cada recuperação vira número: implicit_closes, stray_close_tags, unknown_tags, refused_tags. Um parser que conserta em silêncio deixa a dúvida para o layout.
A página de prova é feia de propósito
Como a imagem de 642 × 482 do Marco 27, a PAGINA.HTM tem dois parágrafos sem fechar, quatro <li> abertos, um <article> desconhecido, um <script> e uma <table> recusados, entidades e um <pre>.
O caminho difícil de um parser não é o HTML correto: é a recuperação. Quase nenhuma página da web fecha todas as tags, e um leitor que responde "arquivo invalido" a isso não serve para nada.
O mesmo número dos dois lados
Test-HtmlParser.ps1 roda os 24 casos e depois analisa a própria PAGINA.HTM, imprimindo o resumo. São os mesmos números que o teste do QEMU cobra na saída serial.
Conferir aqui custa um segundo; conferir lá custa dois minutos e não tem depurador. Foi assim que o inflate do Marco 27 entrou já correto.
Fatia 3 · a árvore vira página
A unidade é a célula de 8 × 16 que o kernel já rasteriza: sem fonte própria em ring 3, quebra de linha é uma conta de colunas.
layout: 29 linhas de 121 colunas, 133 trechos, 18 blocos
--------
O primeiro HTML do SOIA
=========================================================================
Esta pagina veio do disco, nao da rede: o volume SOIAFS1 guarda PAGINA.HTM
e o kernel entrega os bytes.
O que esta fatia entrega
-------------------------------------------------------------------------
- tokenizer com comentario, doctype e atributo nas tres formas
- arvore com fechamento implicito de p e li
--------
pagina desenhada em ring 3: OK
A decisão que parece detalhe
Dois blocos vizinhos pedem margem duas vezes: o fim do primeiro e o começo do segundo. Se cada um escrevesse a linha em branco, a página sairia com o dobro do espaço.
Aqui a margem é um pedido pendente que só vira linha quando alguém escreve. Isso também resolve o fim da página de graça: sem conteúdo depois, a margem não acontece, e nenhum bloco precisa saber se é o último.
O que o colapso de espaço destrói
<b>disco</b>, nao e a vírgula colada.O parser colapsa espaço e o layout separa palavras por padrão, então os dois viravam disco , nao. O espaço na BORDA de um trecho é informação, e ela morre no colapso.
A correção tem duas metades: o nó de texto ganhou dois bits lidos do documento original, e o parser passou a lembrar de um trecho que colapsou para nada — o espaço entre </b> e <b> é exatamente isso e não vira nó nenhum. Sem a segunda metade, <b>a</b> <b>rede</b> saía como arede.
Dois defeitos que o harness pegou
O ponteiro: o layout recebia o pool de texto inteiro e indexava com o cursor relativo ao nó. As palavras saíam nas posições certas, mas os limites delas vinham do texto de OUTRO nó, e a página apareceu com buracos no meio das palavras: O pr m iro HT L o SOIA.
O byte: o marcador de lista media o próprio comprimento olhando marker[2], que a versão de um caractere nunca escrevia. Com lixo diferente de zero, o trecho cobria três células e apagava o começo do texto do item.
Os casos pequenos passavam nos dois: neles o primeiro nó de texto começa perto do offset zero, e a pilha estava limpa.
Por que o desenho não usa SET_CURSOR
O aplicativo percorre os trechos em ordem, avança com espaços e quebra com \n. Posicionar cada trecho com SET_CURSOR daria a mesma tela, e a COM1 receberia a página inteira numa linha só.
Com quebra de verdade, o que o teste lê é exatamente o que está na tela. Por isso as asserções da fatia 3 são linhas da página, e não coordenadas.
Fatia 4 · a cascata e o link que navega
Id vale 100, classe vale 10, tag vale 1, empate é por ordem, e o atributo style ganha de qualquer seletor.
css: 7 regras, 10 declaracoes, 1 seletores ignorados
layout: 35 linhas de 121 colunas, 173 trechos, 1 ocultos pelo CSS
--------
O primeiro HTML do SOIA
=============================================================
...
links desta pagina:
[1] SEGUNDA.HTM
[2] #topo
numero abre o link, v volta, q encerra.
O que é ignorado também é declarado
Seletor composto (p.nota), descendente (div p), @media e propriedade fora da lista viram contador, e o contador aparece na tela.
Recusar a regra inteira por causa de uma propriedade desconhecida apagaria a que funciona ao lado, e é isso que uma folha de verdade tem: dezenas de propriedades que esta v1 não conhece. Só o display: none leva a subárvore junto, e ele é contado à parte.
Cor é semântica, e não pintura
<code> continua sendo código em vermelho.O trecho guarda duas coisas separadas: o estilo (título, forte, ênfase, código, link) e a cor da cascata. O estilo decide o que fazer quando não há cor nenhuma.
A paleta tem cinco cores porque é o que o terminal do SOIA tem. Nome de cor do CSS é traduzido para a mais próxima, e nome desconhecido cai na cor padrão em vez de sumir.
O link precisa de um número
Cada <a href> ganha [n] depois do texto, e os destinos são listados no fim. Digitar o número abre a página, v volta pelo histórico e q encerra.
São nove links por página, porque a navegação é por tecla e só existem nove dígitos: um décimo link seria visível e inalcançável, que é pior que não existir. E o destino só é aceito quando termina em .HTM sem barra nem #: âncora e caminho de servidor não existem num volume plano.
A folha não é herdada entre páginas
SEGUNDA.HTM tem o próprio <style>, e a folha é remontada do zero a cada navegação. O título da primeira página é amarelo e o da segunda é ciano porque cada arquivo pede o seu.
O heap, ao contrário, é alocado uma vez: pedir memória a cada página deixaria o programa dependente de o alocador devolver o mesmo bloco, e uma sequência de idas e voltas acabaria com o heap fragmentado.
Marco 29 · concluído nas três fatias
Escrever no SOIA era pedir ao sistema: "põe a letra A na linha 3, coluna 12". A grade era dele, e o programa não escolhia nada — título maior que o texto era impossível. Agora o programa recebe o desenho das 256 letras e encosta onde quiser. Junto vieram outras duas coisas que faltavam para uma página se comportar: rolar sem perder o começo, e clicar num link.
A fonte 8 × 16 do BIOS mora em BIOS_FONT_ADDRESS (0x6000), memória do kernel. A syscall 34 FONT_BITMAP copia os 4.096 bytes dela para um buffer validado de ring 3, e userland/glyph.c desenha na superfície da janela em qualquer posição de pixel e em qualquer escala inteira. O navegador ganhou faixa visível [top, top + visible), e o clique virou navegação com SOIA_SYSTEM_SELF_WINDOW mais window.text_x/text_y.
Fatia 1 · a fonte em ring 3
O PIXEL.ELF escreve SOIA 29 em corpo triplo numa posição que a grade de texto não alcança, e prova o desenho contra os bits da própria fonte.
soia> run pixel.elf
SOIA Pixel 0.1 - a janela deixou de ser so texto
fonte 8x16: 253 glifos com traco em 4096 bytes
texto em pixel: 1863 acesos, esperado 1863
superficie publicada e composta; pixels vieram de ring 3: OK
Como é
O handler valida a faixa com validate_user_write_range_64 e copia 256 × FONT_GLYPH_HEIGHT bytes. Cada byte é uma linha do glifo. Escalar é repetir cada bit num quadrado N × N, e é isso que dá um título em corpo triplo sem fonte nova.
O texto de prova sai em (37, 61), escala 3: 37 não é múltiplo de 8 e 61 não é múltiplo de 16, e 24 × 48 não é um corpo que a syscall de texto tenha como produzir. Um texto ali só pode ter vindo do desenho por pixel.
Por que fizemos assim
Mapear 0x6000 no espaço da tarefa entregaria uma página da memória baixa do kernel a um processo que só precisa dos bytes. O kernel copia.
E é uma cópia só: com a fonte e a superfície na mão, um título em corpo dobrado custa zero ida ao kernel. Por uma syscall de desenho por glifo, custaria 8.192.
O cld antes do rep movsb é defesa, não estilo: o iretq não limpa DF, e um programa que executasse std antes do INT 0x80 faria a cópia andar para trás em ring 0, escrevendo abaixo do buffer aprovado.
O que muda
Passa a existir: título maior que o corpo, legenda fora da linha, texto centralizado por pixel, desenho de texto num quadro fora da tela no heap.
Continua impossível: outro desenho de letra e meio tamanho. A fonte é uma só, 8 × 16, e a escala é inteira. Fonte vetorial com contorno e curva é marco próprio.
A prova tem duas metades que não se falam
glyph_text_pixels percorre a fonte; glyph_draw_text percorre o alvo, com passo de linha, posição e recorte. Exigir que as duas deem o mesmo número transforma erro de pitch, de posição e de recorte numa asserção só.
A captura da tela é a outra metade: ela prova que os 1.863 pixels chegaram ao framebuffer, na cor certa. E cada elemento tem balde próprio na contagem — com uma cor só, um erro no texto poderia ser compensado por um erro no quadrado e a soma continuaria fechando.
O bug que essa fatia encontrou
A bateria acusou Nenhuma amostra da superficie esta inteira, com as três amostras idênticas e 1.920 pixels de barra faltando. A explicação óbvia, e registrada no roteiro desde julho, era captura tirada antes de o desenho terminar.
Separando os pixels estranhos por posição e cor, a resposta veio em um minuto: 464 pixels de largura na altura 224, na cor do texto sobre a cor do console. O padrão de palavras era 8 9 3 2 1 8 9 3 6, e isso é uma frase: Processo iniciado; PID 4. O terminal aguardara sua saida.
O pai. O terminal faz SPAWN, imprime a linha e só então entra em WAIT; o filho começa a rodar no meio disso, e os dois escrevem na mesma superfície. A correção é do filho: dormir vinte ticks antes de desenhar, quatrocentas vezes a folga que o pai precisa. Diferença agregada não vira diagnóstico até ser separada.
Fatia 2 · a rolagem
Antes, a página inteira era escrita e o console rolava sozinho: o começo subia para fora da tela sem volta.
== SOIA - pagina de prova ==
O primeiro HTML do SOIA
=======================================================
...
linhas 1-32 de 35; espaco desce, b sobe, t topo, clique ou numero abre, v volta, q encerra
[espaço]
linhas 4-35 de 35; espaco desce, b sobe, t topo, clique ou numero abre, v volta, q encerra
Como é
browser_paint pula os trechos com row < top e para no primeiro com row >= top + visible. O que sobra é preenchido com linhas vazias até fechar a faixa: sem elas, o rodapé subiria e a última tela ficaria com o texto de posição num lugar diferente do resto.
visible = window.rows - 3. As três são o título, o rodapé e uma linha livre depois dele.
Por que fizemos assim
Sem ela, qualquer aviso (nao existe link com esse numero) rolaria a tela inteira uma linha, e o desenho passaria a mentir sobre onde a página começa.
O passo do espaço é visible - 1, com uma linha de sobreposição, pelo mesmo motivo do more: sem ela, a última linha lida some antes de ser lida de novo.
E o resumo do parser saiu da tela para a serial. css:, layout: e a lista de links são diagnóstico, não conteúdo: mantê-los no mesmo espaço da página foi o que fez uma página de 35 linhas nunca caber numa janela de 35.
O que muda
A frase Fim da pagina de prova. é a linha 35, e a bateria só a encontra depois do espaço. Antes ela aparecia de graça — e era o começo que se perdia, sem volta.
Continua fora: barra de rolagem arrastável, roda do mouse (o protocolo PS/2 básico do Marco 20 não tem) e Page Up, porque soia_read_key entrega caractere.
Fatia 3 · o clique
O mouse existe desde o Marco 20 e só o kernel usava. Faltavam duas informações, e as duas só ele tem.
clique em (960, 744)
janela desta tarefa SOIA_SYSTEM_SELF_WINDOW = 0
origem da grade text_x = 476, text_y = 256
coluna (960 - 476) / 8 = 60
linha na tela (744 - 256) / 16 = 30
linha da pagina top 0 + 30 - 1 = 29
trecho com link 1 linha 29, colunas 55 a 73
resultado abre SEGUNDA.HTM
navegacoes: 4, rolagens: 2, cliques em link: 1
Como é
Qual janela é a minha: SYSTEM_INFO_SELF_WINDOW, que lê task_window[current_task], a mesma fonte que o kernel usa para desenhar.
Onde a grade dela pousa: WINDOW_INFO_SIZE foi de 72 para 88 bytes, com text_x e text_y.
O que está na célula: layout_run_t.link, que o layout carimba em todos os trechos do <a>, do texto ao [1].
Por que fizemos assim
FOCUSED_WINDOW e SELF_WINDOW coincidem enquanto o programa em primeiro plano pergunta, e divergem no instante em que ele perde o foco — que é exatamente quando atribuir um clique corretamente importa.
O recuo da moldura e a altura da barra de título são geometria do kernel. Copiar as duas constantes para o aplicativo criaria duas verdades sobre a mesma janela, e a cópia passaria a mentir no dia em que a barra mudasse de altura.
E o clique vira a mesma tecla que o teclado produziria: dois caminhos para o mesmo destino fazem o segundo herdar os bugs do primeiro sem herdar as correções.
O bug que custou uma bateria
soia_mouse_event devolve 0 em sucesso e um erro negativo quando a fila está vazia, como toda syscall deste kernel. Com if (... <= 0) break;, o laço saía no primeiro evento e o clique nunca chegava.
Sem nenhum sinal: o navegador seguia funcionando pelo teclado e a bateria inteira passava. Só o contador cliques em link: 0 denunciou. Recurso que pode falhar em silêncio precisa de contador exposto, não de asserção de comportamento.
O teste faz a conta inversa
O roteiro converte célula para pixel; o aplicativo converte pixel para célula. Um erro em qualquer uma erra o alvo. E o clique mira o meio de uma palavra, não a primeira letra: mirar na borda de um trecho testa arredondamento, não hit test.
De forma didática
O mouse conta a partir do canto da tela. O programa pensa em linha da página. Entre as duas há uma janela que pode estar em qualquer lugar, e uma página que pode estar rolada: o dedo não sai do lugar e a linha apontada muda.
Por isso a conta precisa de três termos, e não de dois: origem da grade (do kernel), tamanho da célula (8 × 16) e deslocamento da rolagem (do aplicativo). Tirar qualquer um deles produz um resultado que funciona no caso mais fácil e erra assim que a janela ou a página se mexem.
Marco 30 concluído
O SOIA já sabia discar, ouvir o "alô" e desligar: isso prova que a linha funciona e não transmite conversa nenhuma. Para conversar é preciso lembrar do que já foi dito, e era exatamente isso que não existia. Agora ele manda um pedido, espera a confirmação, baixa um megabyte inteiro em 729 pedaços, confere se chegou certo e desliga combinando o desligamento com o outro lado.
O TCP do Marco 19 não guardava estado entre chamadas: network_tcp_probe_64 mandava SYN, esperava o SYN-ACK, mandava ACK e ia embora. kernel/tcp.asm acrescenta o bloco de conexão com snd_nxt, rcv_nxt e cinco estados, com ACK cumulativo por segmento aceito, envio stop-and-wait com retransmissão do mesmo segmento e o FIN reconhecido dos dois lados. Quatro syscalls novas: 35 TCP_CONNECT, 36 TCP_RECEIVE, 37 TCP_CLOSE e 38 TCP_SEND.
Experimento verificado
O programa não diz apenas que recebeu. Ele calcula a assinatura do que chegou, e o outro lado calcula a mesma sobre o que escreveu.
soia> run baixar.elf
SOIA Baixar 0.1 - o primeiro byte de aplicacao pela rede
conexao aberta com 10.0.2.100:8081
requisicao de 5 bytes confirmada pelo peer
bytes recebidos: 1048576 em 17 leituras
segmentos aceitos: 729, recusados: 0, ACKs enviados: 730
enviados: 5 bytes, retransmissoes: 0
SOIA TCP: 1048576 bytes de aplicacao recebidos em 729 segmentos, CRC 0x5428F918 conferido em ring 3.
tempo do download: 120 ms
Como é
snd_nxt é o próximo número que o SOIA vai usar; rcv_nxt é o próximo que ele espera receber. Segmento com número diferente é contado e descartado, com ACK cumulativo do que já chegou: o peer retransmite.
O SYN e o FIN consomem um número cada, mesmo sem carregar byte nenhum. Reconhecer o FIN sem incrementar deixaria o outro lado retransmitindo para sempre.
Por que fizemos assim
A cópia vai do anel de DMA direto para o endereço de ring 3, porque o CR3 corrente é o da tarefa que chamou. O que permite isso é uma regra de parada: enquanto sobrar menos que um MSS no buffer, o laço para de aceitar segmento.
Assim todo segmento aceito cabe inteiro, e o kernel nunca guarda sobra. Um buffer de remontagem é a estrutura que esta fatia não precisou ter.
O que muda
Passa a existir: pedir alguma coisa, baixar conteúdo de um servidor, conferir integridade e fechar a conexão de comum acordo. É o caminho inteiro que o Marco 31 usa para buscar uma página por HTTP — o que falta lá não é transporte, é formato.
Continua fora: janela de envio deslizante (um segmento em voo por vez), RTO em relógio de verdade, TIME_WAIT, duas conexões ao mesmo tempo, e tirar a espera de dentro do IF=0.
Cinco números, e nenhum é o programa dizendo OK
O total de bytes na serial, o total que o servidor do host escreveu no socket (sem saber o que o SOIA leu), a soma de payload na captura de rede, o número que veio dentro do ACK do FIN (campo de pacote, não contador de programa), e o CRC-32 0x5428F918, calculado por crc32.c dentro do SOIA e por uma implementação em PowerShell do outro lado.
Mais um piso: 1 MiB a 1.460 bytes são centenas de segmentos, e o teste recusa abaixo de 600. Sem ele, um kernel que remontasse o fluxo de um jeito que não é TCP passaria com um segmento gigante que nunca existiu.
O número de segmentos varia entre execuções (729 numa, 730 noutra): o servidor escreve em blocos e o slirp agrupa diferente. Por isso piso, nunca valor fixo.
O bug, e o sintoma que mentia
A serial dizia bytes recebidos: 0 em 4 leituras, que se lê como "a rede não entregou". O PCAP dizia o contrário: os dados estavam nos quadros 26 a 31, e o FIN do SOIA saiu no quadro 24.
A causa era um jc apontando para o rótulo errado: no anel vazio, o laço pulava o orçamento inteiro e consumia as 24 rodadas de espera em 24 leituras instantâneas. O recv voltava vazio em microssegundos.
Sinal autoritativo ganha de sintoma, e em rede o sinal autoritativo é a captura. Um minuto contra uma sessão.
Fatia 2 · Como é
tcp_send_64 chama a mesma network_build_tcp_segment_64 que monta Ethernet, IPv4 e TCP, e corrige três campos: copia o payload depois dos 54 bytes, ajusta o total length do IPv4 e recalcula os dois checksums.
Os checksums precisam ser zerados antes: eles entram na própria soma, e o valor que estava lá era do segmento sem payload. Somar o antigo faria o peer descartar em silêncio.
Duplicar sessenta linhas de montagem para trocar a origem de um campo seria criar duas verdades sobre o mesmo quadro — e duas montagens divergem no primeiro campo que alguém corrigir de um lado só.
Fatia 2 · Por que fizemos assim
O buffer de TX é compartilhado por ARP, ICMP, DHCP e DNS. Guardar um ponteiro para ele e retransmitir depois entregaria ao peer o conteúdo do próximo protocolo que montasse quadro, com o cabeçalho TCP certo por cima: um erro que só aparece sob perda de pacote, que é justamente quando ninguém está olhando.
São 1.472 bytes na área estática do kernel, um MSS, que é exatamente o que o stop-and-wait precisa guardar.
E fechar virou acordo: tcp_close_64 manda o FIN e espera o ACK que o cubra. Sair depois de mandar faria o teste passar sobre um fechamento que o outro lado nunca viu.
Fatia 2 · O caminho errado, que ensina mais
pop que o destruía.tcp_send_64 guardava os bytes confirmados em R12 e fazia mov rax, r12 depois do pop r12. O valor devolvido a ring 3 era o R12 do chamador.
O envio funcionava, o segmento saía certo, o ACK era reconhecido — e só o número aparecia errado na tela. RAX não entra na lista de registradores salvos justamente por ser o retorno, e é nele que o resultado tem de estar antes do primeiro pop.
Fatia 2 · E a prova mudou de lugar
O plano era o servidor de teste receber a requisição. Ele nunca recebeu, em duas execuções com 8 s e 25 s de espera: o guestfwd do slirp responde ACK aos bytes do convidado e não os entrega ao socket local. O sentido host → convidado funciona, e é ele que traz o megabyte.
Exigir a requisição no host reprovaria um SOIA que envia certo, que é o oposto de uma prova. A asserção migrou para a captura de rede: cinco bytes de payload saindo com o conteúdo exato, o ACK do peer cobrindo snd_nxt, e zero sequências repetidas ao lado do retransmissoes: 0 do kernel.
As duas últimas são o par que interessa. O contador diria zero se a rotina de retransmissão estivesse quebrada; a captura conta o que saiu no fio sem saber que existe contador. Errarem juntos exigiria dois defeitos independentes concordando.
E o instrumento de medida também erra
O parser de PCAP que escrevi para diagnosticar mostrava tcp 0 -> 144 no lugar de 49152 -> 8080, porque em PowerShell [byte] -shl 8 trunca. Antes de acusar o código medido, conferir o código que mede.
Quando a captura virou a asserção central da fatia 2, o leitor ganhou arquivo próprio e teste próprio: PcapReader.ps1 monta todo campo de 16 bits por multiplicação, lê IHL e data offset em vez de presumir 5 e 20, e é provado por Test-PcapParser.ps1 contra oito capturas escritas byte a byte, sem QEMU nenhum.
E a asserção montada em cima dele nasceu flaky: exigia que a soma bruta de payload fosse exatamente 1.048.576, passou em 2048 MiB e reprovou em 64 MiB com 1.051.476 — dois segmentos a mais, com recusados: 0 e o CRC certo do lado do SOIA. Não era defeito: o peer retransmite quando um quadro se perde no anel de RX, que não tem interrupção, e a captura registra o original e a cópia. Hoje a soma conta cada sequência uma vez, e a soma bruta continua sendo relatada, porque ela descreve a rede e não o SOIA.
De forma didática
Dez bytes caberiam num pedaço só e provariam quase nada. Um megabyte precisa ser cortado em 729 partes, cada uma com um número. Se uma vier fora de ordem, sumir, ou duas forem remontadas trocadas, o resultado tem aparência normal e conteúdo errado — e só a assinatura denuncia.
Três peças foram provadas fora do QEMU antes de entrar, na tradição do inflate e do parser de HTML: Test-TcpTestServer.ps1 prova o servidor do host contra um cliente .NET local; Test-Crc32.ps1 compila o mesmo crc32.c para Windows e compara com a implementação em PowerShell, conferindo também que o CRC incremental em blocos bate com a passada única; e Test-PcapParser.ps1 prova o leitor de captura contra oito arquivos sintéticos. crc32.c não inclui soia.h, e é isso que permite compilá-lo dos dois lados sem nenhum #ifdef.
Roteiro
A IA ajuda a construir este projeto, mas não executa dentro dele. O roteiro agora segue os subsistemas clássicos de um SO completo.
Próximo · Marco 28, renderizador offline
O SOIA fica no QEMU até ver foto, tocar vídeo e abrir um site. Ver foto saiu com o Marco 27. O próximo era o transporte virtio, e ele foi para o fim da fila depois que três números derrubaram a justificativa dele: o disco entrega ~10.400 setores/s, então a leitura acusada de custar um minuto custa 0,37 s; decodificar JPEG custa 312 a 556 ms por megapixel sob TCG, o que faz vídeo ser problema de CPU e não de transporte; e o teto real de um aplicativo é o ELF em 64 KiB, do qual o visualizador de fotos já usa 69,7%.
Por isso o próximo é o renderizador, e ele começa sem rede,
lendo uma página do próprio disco. É a peça mais testável fora do
QEMU, do mesmo jeito que o inflate foi, e é o único teste
honesto daquele teto de 64 KiB: descobrir agora que o navegador não
cabe custa uma sessão; descobrir com ele escrito custa um marco.
O teto de um aplicativo era o ELF em 64 KiB, e não as 128 páginas de imagem que a constante sugere: todo ELF era carregado pelo buffer da VFS. Medida a densidade deste projeto, sobravam cerca de 19.900 bytes de texto, e navegador mais decodificadores de imagem não cabiam no mesmo binário. A fatia 1 pagou isso em 02/08/2026, com a leitura por deslocamento que já existia: hoje o teto é o do kernel, e um binário de 80.792 bytes roda. Ver como
A pilha de um processo tem 8 KiB, com página de guarda embaixo. Parser recursivo cabe, mas com teto de profundidade declarado na primeira linha: são 60 a 113 níveis conforme o quadro, e um único quadro gordo consome um terço sozinho.
Ring 3 ainda não desenha texto onde quer. Só existem escrever byte e posicionar cursor em grade de 8 × 16, não há fonte nenhuma na userland e a única rolagem do sistema move pixel e perde o que subiu. O renderizador v1 usa a grade; o Marco 29 paga o que ele cobrar de verdade, medido em vez de suposto.
Ferramenta de desenvolvimento
Ela apoia arquitetura, código, revisão, testes e documentação. A imagem final funciona sem modelo, conta, API ou conexão de IA.
Vocabulário
Abra um conceito quando precisar. O glossário completo permanece na documentação técnica.
Ver glossário em MarkdownSimples: a autoridade que permanece administrando o computador.
Técnico: código em ring 0 que controla memória, hardware e serviços.
Simples: a área limitada em que programas comuns executam.
Técnico: CPL 3, sem acesso a páginas supervisoras ou instruções privilegiadas.
Simples: um pedido controlado que um programa faz ao kernel.
Técnico: travessia validada por INT 0x80, com número e argumentos em registradores.
Simples: a área que mostra texto e permite editar uma linha.
Técnico: driver de console em janela gráfica, com cursor, rolagem e entrada enfileirada.
Simples: o programa que entende o comando digitado.
Técnico: tarefa em ring 3 que analisa comandos e usa syscalls para pedir serviços.
Simples: uma porta comum para abrir arquivos sem conhecer o disco por baixo.
Técnico: interface que traduz operações genéricas para o formato SOIAFS1.
Simples: o retângulo que contém tudo que mudou na tela desde a última vez que ela foi atualizada.
Técnico: um único mínimo e máximo global, e não uma lista: no pior caso copia demais, nunca de menos. Quem entra nela custa a união com todo o resto, e foi por isso que o cursor saiu dela.
Simples: os números no começo de um arquivo, escritos por quem gerou o arquivo, e que podem estar errados de propósito.
Técnico: todo campo vira conta, tamanho ou deslocamento, então cada um é validado antes do uso: faixa, tipo e coerência com o tamanho real do arquivo. É a razão de o decodificador rodar em ring 3.
Fontes deste caderno
O Markdown guarda o detalhe completo. Este caderno organiza o mesmo conhecimento para revisão e estudo.